写一个简单的libc库

导入

当我们用 C 语言编写 printf("Hello, World!"); 时,我们很少会去想这行代码背后究竟发生了什么。printfmallocfopen 这些我们习以为常的函数,并非 C 语言的“内置”功能,而是由一个名为 C 标准库 (libc) 的底层软件库提供的。在 Linux 系统上,它通常是 glibc

这个库是我们的程序与操作系统内核之间的重要桥梁。但是,如果我们不使用它呢?我们能否直接与内核对话来完成工作?

答案是肯定的。本文将完全抛弃 libc,通过直接调用 Linux 系统调用 (System Call),从零开始构建一个我们自己的、迷你的 C 标准库,实现 printfmalloc 等核心功能。

基本概念

两个基本概念:

  • 用户空间 (User Space): 这是普通应用程序(比如我们写的 C 程序)运行的地方。用户程序没有权限直接操作硬件,如读写文件、分配内存或在屏幕上显示文字。
  • 内核空间 (Kernel Space): 这是操作系统内核运行的地方。内核拥有最高权限,负责管理系统的所有资源。

那么,用户空间的程序如何请求内核来执行这些特权操作呢?答案就是 系统调用 (System Call)

系统调用是用户空间向内核空间请求服务的唯一“官方”途径。它就像一个接口或菜单,内核向用户程序暴露了它能提供的所有服务(如“写入文件”、“创建进程”、“退出程序”等),并为每个服务分配了一个唯一的编号。

在 x86-64 架构的 Linux 系统上,执行系统调用的指令是 syscall。为了成功调用它,我们必须遵守一套规则,即调用约定 (Calling Convention)

  1. 系统调用号: 必须将要调用的服务的编号放入 rax 寄存器。例如,write 的编号是 1exit 的编号是 60
  2. 参数传递: 系统调用的前 6 个参数必须按顺序放入 rdi, rsi, rdx, r10, r8, r9 这几个寄存器中。
  3. 发起调用: 执行 syscall 汇编指令。
  4. 获取返回值: 内核完成操作后,会将返回值(例如 write 成功写入的字节数)放回 rax 寄存器。

要在 C 代码中执行汇编指令,我们可以使用 GCC 的内联汇编功能 __asm__ __volatile__。一个包含 3 个参数的系统调用的辅助函数示例:

// my_libc.c

// 3个参数的系统调用
static inline long syscall3(long n, long a1, long a2, long a3) {
    long ret;
    __asm__ __volatile__ (
        "syscall"
        : "=a"(ret)  // 输出:将 rax 寄存器的值存入 C 变量 ret
        : "a"(n), "D"(a1), "S"(a2), "d"(a3) // 输入:将 n, a1, a2, a3 分别放入 rax, rdi, rsi, rdx
        : "rcx", "r11", "memory" // Clobber List:告知编译器这些资源会被修改
    );
    return ret;
}

这段代码是整个库的基石。它精确地遵循了系统调用约定,让我们能用 C 函数的形式来命令内核。

系统调用封装

要实现一个系统调用的封装,首先要知道对应的系统调用号和参数设置。而write系统调用的调用号就是1,且参数就是三个,因此可以用上面的3个参数的系统调用函数封装形式进行调用。

// my_libc.c

#define SYS_WRITE 1 // write 系统调用的编号

long my_write(int fd, const void *buf, size_t count) {
    // write(fd, buf, count) 对应三个参数
    // 分别传入 rdi, rsi, rdx
    return syscall3(SYS_WRITE, fd, (long)buf, count);
}

这样就把write函数封装好了,实现了向特定文件名描述符写入buffer字符串的功能。以此类推,再来实现exit退出系统调用封装,exit系统调用号是60,参数只有一个,就是退出码,因此需要1个参数的系统调用封装形式,如下:

// 1个参数的系统调用
static inline long syscall1(long n, long a1) {
    long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1) : "rcx", "r11", "memory");
    return ret;
}

然后封装exit就是:

void my_exit(int status) {
    syscall1(SYS_EXIT, status);
}

至此,我们已经可以写一个简单的小程序了,比如向终端打印一个字符串:

#define SYS_WRITE   1
#define SYS_EXIT    60 

#define STDOUT 1

static __inline long __syscall3(long n, long a1, long a2, long a3)
{
    unsigned long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
                          "d"(a3) : "rcx" , "r11" , "memory");
    return ret;
}

static __inline long __syscall1(long n, long a1)
{
    unsigned long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1) : "rcx" , "r11" , "memory");
    return ret;
}

int my_write(int fd, void *buf, int count);
void my_exit(int ec); 

int my_write(int fd, void *buf, int count)
{
    unsigned long int ret = __syscall3(SYS_WRITE , sys_stdout, (long)(buf), count);
    return ret;
}


void my_exit(int ec)
{
    __syscall1(SYS_EXIT , ec);
} 

void _start()
{
    my_write(STDOUT, "hello,world!", 13);
    my_exit(1);
}

然后gcc编译gcc write_without_libs.c -nostdinc -nostdlib -e _start即可,运行后就可以再终端看到hello,world!字符串。

  • -nostdinc: 不搜索标准的系统头文件目录

  • -nostdlib: 不链接标准的系统启动文件和库文件(比如 libc)

  • -e _start: 指定程序的入口点为 _start函数,因为main函数是标准库的默认入口函数,我们不借助标准库

其他的系统调用封装同理,根据参数数目选择对应的syscall封装函数,然后传入正确的系统调用号和参数即可,比如:

long my_open(const char *pathname, int flags, int mode) {
    return syscall3(SYS_OPEN, (long)pathname, flags, mode);
}

long my_read(int fd, void *buf, size_t count) {
    return syscall3(SYS_READ, fd, (long)buf, count);
}

long my_close(int fd) {
    return syscall1(SYS_CLOSE, fd);

标准库函数封装

上面都是系统调用封装,而我们要实现标准库,就要进一步封装成标准库函数。比如利用上面的my_write系统调用封装,实现printf格式化输出,它的职责是解析格式化字符串(如 "%d""%s"),处理可变参数,然后多次调用 write 系统调用将最终结果输出。

要实现 my_printf,我们需要:

  1. 处理可变参数: 我们不能 #include <stdarg.h>,但可以使用 GCC 的内置宏 __builtin_va_start, __builtin_va_arg 等来处理 ... 可变参数列表。
  2. 解析格式字符串: 遍历字符串,当遇到 % 时,检查后面的字符来决定如何处理。
  3. 整数转字符串: printf 遇到 %d 时,需要将一个整数(如 123)转换为字符串("123")。我们需要自己写一个 itoa 函数来完成这个转换。
  4. 调用 my_write: 将所有处理好的字符和字符串通过 my_write 输出到标准输出
// my_libc.c

int my_printf(const char *format, ...) {
    va_list args;
    va_start(args, format);

    // ... 循环遍历 format 字符串 ...

    if (format[i] == '%') {
        i++;
        switch (format[i]) {
            case 'd': {
                int d = va_arg(args, int);
                char buffer[20];
                my_itoa(d, buffer); // 整数转字符串
                my_write(STDOUT_FILENO, buffer, my_strlen(buffer));
                break;
            }
            case 's': {
                char *s = va_arg(args, char*);
                my_write(STDOUT_FILENO, s, my_strlen(s));
                break;
            }
            // ... 其他 case ...
        }
    } else {
        my_write(STDOUT_FILENO, &format[i], 1);
    }
    // ...
    va_end(args);
    return written;
}

printf 类似,malloc 也不是系统调用。它是一个内存管理器,负责向内核申请大块内存,然后将其分割成小块零售给用户程序。

最简单的内存分配方式是移动 program break 的位置。Program break 是进程数据段(Data Segment)的末尾。我们可以通过 brk 系统调用来移动它,从而扩大或缩小堆(Heap)的可用空间。

我们的 my_malloc 策略非常简单粗暴:一个碰撞针分配器 (Bump Allocator)

  1. 通过 brk(0) 获取当前 program break 的地址。
  2. 要分配 size 字节,就调用 brk(current_break + size),将 break 向后移动 size 字节。
  3. 如果成功,内核已经为我们扩展了数据段,我们只需返回移动前的 break 地址,这就是新分配内存的起始地址。

如下:

// brk 系统调用用于改变数据段的结束地址(program break)
// 调用 brk(0) 可以获取当前的 program break 地址
// 调用 brk(addr) 可以将 program break 设置为 addr
static void* my_sbrk(long increment) {
    long current_brk = syscall1(SYS_BRK, 0);
    if (increment == 0) {
        return (void*)current_brk;
    }
    long new_brk = syscall1(SYS_BRK, current_brk + increment);
    if (new_brk == current_brk) { // 如果 brk 地址没有变,说明分配失败
        return (void*)-1;
    }
    return (void*)current_brk;
} 


void *my_malloc(size_t size) {
    if (size <= 0) {
        return NULL;
    }
    // 调用 my_sbrk 扩展数据段
    void *block = my_sbrk(size);
    if (block == (void*)-1) { // 分配失败
        return NULL;
    }
    return block;
}

完整代码

//my_libc.h


#ifndef MY_LIBC_H
#define MY_LIBC_H

typedef unsigned long size_t;

// 定义 NULL 指针
#define NULL ((void*)0)

// 定义文件描述符
#define STDIN_FILENO  0  // 标准输入
#define STDOUT_FILENO 1  // 标准输出
#define STDERR_FILENO 2  // 标准错误

// open() 函数的标志位 (flags)。来源于 <fcntl.h>
#define O_RDONLY    00      // 只读
#define O_WRONLY    01      // 只写
#define O_RDWR      02      // 读写
#define O_CREAT     0100    // 如果文件不存在则创建
#define O_TRUNC     01000   // 如果文件存在则截断
#define O_APPEND    02000   // 追加到文件末尾

long my_open(const char *pathname, int flags, int mode);
long my_read(int fd, void *buf, size_t count);
long my_write(int fd, const void *buf, size_t count);
long my_close(int fd);
void my_exit(int status);
size_t my_strlen(const char *s);
int my_printf(const char *format, ...);
void *my_malloc(size_t size);
#endif // MY_LIBC_H

可以看到实现了open,close,read,write,exit,strlen,printf,malloc这些函数,更多的系统调用和标准库函数封装只需要参考这些然后添加即可。

//my_libc.c
#include "my_libc.h"

// =============================================================================
// 1. 系统调用号 (x86-64 Linux)
// =============================================================================
#define SYS_READ    0
#define SYS_WRITE   1
#define SYS_OPEN    2
#define SYS_CLOSE   3
#define SYS_BRK     12
#define SYS_EXIT    60

// =============================================================================
// 2. 内联汇编: 系统调用辅助函数
// =============================================================================
// 为了处理可变参数,我们需要使用 GCC 内置的宏来处理参数列表
// 这比直接使用 <stdarg.h> 更底层,符合我们不依赖标准库的目标
#define va_start(v,l)   __builtin_va_start(v,l)
#define va_arg(v,l)     __builtin_va_arg(v,l)
#define va_end(v)       __builtin_va_end(v)
#define va_list         __builtin_va_list

// 声明不同参数数量的系统调用底层函数
static inline long syscall0(long n);
static inline long syscall1(long n, long a1);
static inline long syscall2(long n, long a1, long a2);
static inline long syscall3(long n, long a1, long a2, long a3);

// 0个参数的系统调用
static inline long syscall0(long n) {
    long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n) : "rcx", "r11", "memory");
    return ret;
}

// 1个参数的系统调用
static inline long syscall1(long n, long a1) {
    long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1) : "rcx", "r11", "memory");
    return ret;
}

// 2个参数的系统调用
static inline long syscall2(long n, long a1, long a2) {
    long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2) : "rcx", "r11", "memory");
    return ret;
}

// 3个参数的系统调用
static inline long syscall3(long n, long a1, long a2, long a3) {
    long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2), "d"(a3) : "rcx", "r11", "memory");
    return ret;
}

// =============================================================================
// 3. 系统调用封装实现
// =============================================================================

long my_open(const char *pathname, int flags, int mode) {
    return syscall3(SYS_OPEN, (long)pathname, flags, mode);
}

long my_read(int fd, void *buf, size_t count) {
    return syscall3(SYS_READ, fd, (long)buf, count);
}

long my_write(int fd, const void *buf, size_t count) {
    return syscall3(SYS_WRITE, fd, (long)buf, count);
}

long my_close(int fd) {
    return syscall1(SYS_CLOSE, fd);
}

void my_exit(int status) {
    syscall1(SYS_EXIT, status);
}

// brk 系统调用用于改变数据段的结束地址(program break)
// 调用 brk(0) 可以获取当前的 program break 地址
// 调用 brk(addr) 可以将 program break 设置为 addr
static void* my_sbrk(long increment) {
    long current_brk = syscall1(SYS_BRK, 0);
    if (increment == 0) {
        return (void*)current_brk;
    }
    long new_brk = syscall1(SYS_BRK, current_brk + increment);
    if (new_brk == current_brk) { // 如果 brk 地址没有变,说明分配失败
        return (void*)-1;
    }
    return (void*)current_brk;
}

// =============================================================================
// 4. C 标准库函数模拟实现
// =============================================================================

size_t my_strlen(const char *s) {
    size_t len = 0;
    while (s[len]) {
        len++;
    }
    return len;
}
// 一个简单的整数转字符串函数 (itoa)
static void my_itoa(int n, char* buf) {
    if (n == 0) {
        buf[0] = '0'; 
        buf[1] = '\0'; 
        return;
    } 
int i = 0;
    int is_negative = 0;
    if (n < 0) {
        is_negative = 1;
        n = -n;
    }

    while (n != 0) {
        buf[i++] = (n % 10) + '0';
        n = n / 10;
    }

    if (is_negative) {
        buf[i++] = '-';
    }
    buf[i] = '\0';  
// 反转字符串
    int start = 0;
    int end = i - 1;
    while (start < end) {
        char temp = buf[start];
        buf[start] = buf[end];
        buf[end] = temp;
        start++;
        end--;

    }
} 

int my_printf(const char *format, ...) {
    va_list args;
    va_start(args, format);

    int written = 0;
    char buffer[20]; // 用于整数转换

    for (int i = 0; format[i] != '\0'; i++) { 
    if (format[i] == '%') {
            i++;
            switch (format[i]) {
                case 'd': {
                    int d = va_arg(args, int);
                    my_itoa(d, buffer);
                    int len = my_strlen(buffer);
                    my_write(STDOUT_FILENO, buffer, len);
                    written += len;
                    break;
                }
                case 's': {
                    char *s = va_arg(args, char*);
                    int len = my_strlen(s);
                    my_write(STDOUT_FILENO, s, len);
                    written += len;
                    break;
                }
                case 'c': {
                    char c = (char)va_arg(args, int); // char 会被提升为 int
                    my_write(STDOUT_FILENO, &c, 1);
                    written++;
                    break;
                }
                case '%': {
                    my_write(STDOUT_FILENO, "%", 1);
                    written++;
                    break;
                }
                default: // 不支持的格式,直接输出
                    my_write(STDOUT_FILENO, &format[i-1], 2);
                    written += 2;
                    break;
            }
        } else {
            my_write(STDOUT_FILENO, &format[i], 1);
            written++;
        }
    }
    va_end(args);
    return written;
} 

void *my_malloc(size_t size) {
    if (size <= 0) {
        return NULL;
    }
    // 调用 my_sbrk 扩展数据段
    void *block = my_sbrk(size);
    if (block == (void*)-1) { // 分配失败
        return NULL;
    }
    return block;
}

编译

 gcc -c -fPIC my_libc.c -o my_libc.o -nostdinc -nostdlib
 gcc -shared my_libc.o -o libmyc.so 

这样编译成动态库,就可以发布了,当然选择静态库也是一样的。

测试

接下来编写测试代码:

//main.c
#include "my_libc.h"

void _start();

// _start 是程序的实际入口
void _start() {
    // --- 1. 测试 my_printf ---
    my_printf("--- 1. Testing my_printf ---\n");
    my_printf("Hello from my_libc!\n");
    my_printf("Testing formats: char '%c', string \"%s\", integer %d, negative %d\n", 
              'A', "world", 12345, -678);
    my_printf("A literal percent sign: %%\n\n");

    // --- 2. 测试文件 I/O ---
    my_printf("--- 2. Testing File I/O ---\n");
    const char* filename = "test.txt";
    const char* content = "This is a test file created by my_libc.\n";
    char buffer[100];

    // 创建并写入文件
    int fd = my_open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd > 0) {
        my_printf("File 'test.txt' opened for writing. FD: %d\n", fd);
        my_write(fd, content, my_strlen(content));
        my_close(fd);
        my_printf("Wrote to file and closed it.\n");
    } else {
        my_printf("Failed to open file for writing.\n");
        my_exit(1);
    }

    // 读取文件并打印内容
    fd = my_open(filename, O_RDONLY, 0);
    if (fd > 0) {
        my_printf("File 'test.txt' opened for reading. FD: %d\n", fd);
        long bytes_read = my_read(fd, buffer, 99);
        buffer[bytes_read] = '\0'; // 手动添加字符串结束符
        my_close(fd);
        my_printf("Read from file: %s\n", buffer);
    } else {
        my_printf("Failed to open file for reading.\n");
        my_exit(1);
    }

    // --- 3. 测试 my_malloc ---
    my_printf("--- 3. Testing my_malloc ---\n");
    char* mem = (char*)my_malloc(50);
    if (mem != NULL) {
        my_printf("Memory allocated successfully!\n");
        // 复制字符串到新分配的内存
        const char* msg = "Copied to malloc-ed memory!";
        char* p_mem = mem;
        const char* p_msg = msg;
        while (*p_msg) {
            *p_mem++ = *p_msg++;
        }
        *p_mem = '\0';
        my_printf("Content of allocated memory: %s\n", mem);
    } else {
        my_printf("Memory allocation failed.\n");
    n}

    // --- 4. 退出程序 ---
    my_printf("\n--- 4. Exiting ---\n");
    my_exit(0);
}

编译运行

gcc main.c -o main_test -L. -lmyc -nostdinc -nostdlib -e _start
LD_LIBRARY_PATH=. ./main_test

应该可以看到如下输出:

--- 1. Testing my_printf ---
Hello from my_libc!
Testing formats: char 'A', string "world", integer 12345, negative -678
A literal percent sign: %

--- 2. Testing File I/O ---
File 'test.txt' opened for writing. FD: 3
Wrote to file and closed it.
File 'test.txt' opened for reading. FD: 3
Read from file: This is a test file created by my_libc.

--- 3. Testing my_malloc ---
Memory allocated successfully!
Content of allocated memory: Copied to malloc-ed memory!

--- 4. Exiting ---

并且目录下生成了test.txt,内部写着This is a test file created by my_libc.

总结

主要实现了包含标准库函数printfmalloc的一个极简的libc标准库,让读者可以更加清晰的知道标准库内部到底做了什么。实际的libc库肯定是复杂很多的,包含了大量的优化、错误处理、线程安全机制和 POSIX 标准兼容性。但万变不离其宗,其核心仍然是与内核的“对话”。

但是如果我们进行嵌入式开发,如果设备硬件非常低,支持不了glibc这种大的标准c库的话,且我们的要求非常简单,不需要复杂的标准库函数的时候,其实自己写一个非常轻量的标准c库也未尝不可。当然,如果稍微复杂的话,也有很多别人写好的轻量级标准c库,比如musl库,且是开源的,如果对标准libc库底层感兴趣的话,可以去了解musl库的底层代码。