导入
当我们用 C 语言编写 printf("Hello, World!");
时,我们很少会去想这行代码背后究竟发生了什么。printf
、malloc
、fopen
这些我们习以为常的函数,并非 C 语言的“内置”功能,而是由一个名为 C 标准库 (libc) 的底层软件库提供的。在 Linux 系统上,它通常是 glibc
。
这个库是我们的程序与操作系统内核之间的重要桥梁。但是,如果我们不使用它呢?我们能否直接与内核对话来完成工作?
答案是肯定的。本文将完全抛弃 libc
,通过直接调用 Linux 系统调用 (System Call),从零开始构建一个我们自己的、迷你的 C 标准库,实现 printf
和 malloc
等核心功能。
基本概念
两个基本概念:
- 用户空间 (User Space): 这是普通应用程序(比如我们写的 C 程序)运行的地方。用户程序没有权限直接操作硬件,如读写文件、分配内存或在屏幕上显示文字。
- 内核空间 (Kernel Space): 这是操作系统内核运行的地方。内核拥有最高权限,负责管理系统的所有资源。
那么,用户空间的程序如何请求内核来执行这些特权操作呢?答案就是 系统调用 (System Call)。
系统调用是用户空间向内核空间请求服务的唯一“官方”途径。它就像一个接口或菜单,内核向用户程序暴露了它能提供的所有服务(如“写入文件”、“创建进程”、“退出程序”等),并为每个服务分配了一个唯一的编号。
在 x86-64 架构的 Linux 系统上,执行系统调用的指令是 syscall
。为了成功调用它,我们必须遵守一套规则,即调用约定 (Calling Convention):
- 系统调用号: 必须将要调用的服务的编号放入
rax
寄存器。例如,write
的编号是1
,exit
的编号是60
。 - 参数传递: 系统调用的前 6 个参数必须按顺序放入
rdi
,rsi
,rdx
,r10
,r8
,r9
这几个寄存器中。 - 发起调用: 执行
syscall
汇编指令。 - 获取返回值: 内核完成操作后,会将返回值(例如
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
,我们需要:
- 处理可变参数: 我们不能
#include <stdarg.h>
,但可以使用 GCC 的内置宏__builtin_va_start
,__builtin_va_arg
等来处理...
可变参数列表。 - 解析格式字符串: 遍历字符串,当遇到
%
时,检查后面的字符来决定如何处理。 - 整数转字符串:
printf
遇到%d
时,需要将一个整数(如123
)转换为字符串("123"
)。我们需要自己写一个itoa
函数来完成这个转换。 - 调用
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):
- 通过
brk(0)
获取当前 program break 的地址。 - 要分配
size
字节,就调用brk(current_break + size)
,将 break 向后移动size
字节。 - 如果成功,内核已经为我们扩展了数据段,我们只需返回移动前的 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.
总结
主要实现了包含标准库函数printf
和malloc
的一个极简的libc
标准库,让读者可以更加清晰的知道标准库内部到底做了什么。实际的libc
库肯定是复杂很多的,包含了大量的优化、错误处理、线程安全机制和 POSIX 标准兼容性。但万变不离其宗,其核心仍然是与内核的“对话”。
但是如果我们进行嵌入式开发,如果设备硬件非常低,支持不了glibc
这种大的标准c库的话,且我们的要求非常简单,不需要复杂的标准库函数的时候,其实自己写一个非常轻量的标准c库也未尝不可。当然,如果稍微复杂的话,也有很多别人写好的轻量级标准c库,比如musl
库,且是开源的,如果对标准libc库底层感兴趣的话,可以去了解musl
库的底层代码。