首页 / 资讯中心 / 文章详情

从一切皆文件到用户态缓冲区:手写简易stdio库

从一切皆文件到用户态缓冲区:手写简易stdio库 ★ FEATURED ARTICLE
我最早看到“一切皆文件”这句话是在一本很厚的 Linux 书上。当时觉得这就是个哲学口号直到后来自己动手做一个极简的stdio替代品才意识到这句话背后是一整套抽象设计。这篇文章想聊三件事怎么理解“一切皆文件”用户态缓冲区为什么存在以及如何用这两点设计一个简易 libc 风格的缓冲库。如果你刚学 Linux 系统编程或者被printf的输出顺序坑过这篇应该能帮你把“文件描述符、FILE、缓冲区、系统调用”这几块碎在地上的知识重新拼起来。文里的代码是我按演示目的做的微缩版能编译能跑但离真正 libc 还很远——这点后面我会老实说。1. “一切皆文件”到底在说哪一层从磁盘块到fd再到FILE的抽象链条1.1 先分清四类会被叫成“文件”的东西很多人理解“一切皆文件”时脑子里只有磁盘上的普通文件这是第一个误区。在 Linux 里至少四类对象都会用“文件”的接口暴露出来对象类型典型例子底层实质用户态访问方式普通文件/home/user/data.log磁盘块、页缓存open/read/write目录/tmp目录项的列表也有 inodeopen加O_DIRECTORY读取用getdents设备文件/dev/null、/dev/sda主设备号、次设备号驱动操作函数open/read/write/ioctl伪文件/proc/self/status、管道、Socket内核临时生成的数据或内存对象open或socket之后同样用 fd这里最反直觉的是目录。你cat /tmp会得到“Is a directory”而不是数据因为目录的内核操作函数不允许普通 read它要你走getdents。但它仍然有 inode、有file_operations所以还是“文件”抽象体系里的一员。Socket 更特殊它不是通过open打开的而是通过socket()创建然后拿回一个 fd。这个 fd 也能 read、write、poll、close。它没有路径但内核里有sockfs文件系统挂它。所以说“一切皆文件”并不是“所有东西都能用cat读出来”而是“所有内核资源都能塞进同一个文件访问框架”。1.2 让“一切皆文件”成立的是 file_operations核心机制不在用户态在内核的file_operations。每个 inode 对应一张操作函数表。普通磁盘文件挂的是ext4_file_operations管道挂的是pipefifo_fops终端挂的是tty_fops。你调用read(fd, buf, len)时内核并不关心背后是 SSD 还是串口它只做一件事从 fd 找到 struct file再从 struct file 找到 inode然后调用 inode 对应的read函数指针。用插座类比就很好懂你只需要认准同一个插头规格插上去充电还是插上去吹风那归电器自己管。Linux 把“插头规格”定死了设备驱动去适配这个规格。于是对用户程序来说读写文件、读写串口、读写pipe的代码长得一模一样。1.3 伪文件是理解“一切皆文件”的关键试金石/dev/null、/dev/zero、/proc这些伪文件最能检验你有没有真懂这个概念。它们背后没有真实磁盘数据数据是内核函数现场生成的。$ echo hello /dev/null $ head -c 8 /dev/zero | od -An -tx1 0000000 00 00 00 00 00 00 00 00 $ cat /proc/self/comm bashecho hello /dev/null不是把数据丢弃在某个“特殊地方”而是打开/dev/null之后内核把写请求交给null_fops的write回调这个回调什么都不干直接返回成功。数据根本没有离开用户态太远就已经“消失”了。/dev/zero则是 read 时内核往你传的用户缓冲区里不断填充0x00。这种“读操作不是从存储介质读而是由内核函数生成字节”的行为是理解伪文件的关键。看懂了它你也就理解了 VFS 那一层抽象的本质名字叫文件行为各自精彩。2. 从0到2看文件描述符进程表、文件表和inode之间发生了什么2.1 一次 open 注入了三个对象文件描述符不是一个简单的整数它是进程 fd 表的索引。真正干活的对象有三个层次进程自己的fdtable保存每个 fd 指向的struct file指针内核全局的struct file包含当前文件偏移、打开标志、引用计数struct inode保存文件元数据、具体文件系统信息、操作函数表。为什么同一个文件打开两次两个 fd 的读写位置互不影响因为两次open会建立两个独立的struct file各自的f_pos是独立的。而dup两次 fd 却共享同一个struct file所以它们共享同一个偏移。int fd1 open(a.txt, O_RDONLY); int fd2 dup(fd1); read(fd1, buf, 10); // 此时 fd2 的偏移也已经被推进了 10很多人在 fork 协作编程里踩过坑父子进程共用同一个struct file所以一方读文件另一方也能看到偏移变化。这不是“玄学”而是对象模型决定的。理解close也要从这个模型出发。close(fd)只是减少struct file的引用计数只有引用计数归零内核才真正释放这个打开文件描述。fork之后子进程退出父进程的 fd 仍然有效也是因为引用计数还没有归零。2.2 标准流 0、1、2 也是文件shell 重定向改变的是“打开文件描述”程序启动时内核默认把 fd 0、1、2 指向当前终端。你执行./a.out out.txtshell 会先open出out.txt然后用dup2把新的 fd 复制到 fd 1 上并关闭原来的 fd。程序自身一点儿不用改。这个机制到处都在体现“一切皆文件”你想接收用户输入read(0, ...)就是从终端文件读你往 fd 1 写终端就是显示器重定向到文件就是普通文件你在 shell 里写21就是把 stderr 这个 fd 也指向 stdout 指向的同一个打开文件描述。所以标准库里 stdin、stdout、stderr 本质上就是三个 fd。后面要做的简易 libc 库也是围绕 fd 1 和 fd 2 做文章。3. 既然 write 已经交给内核为什么还要在用户态再套一层缓冲区3.1 一次 write 到底经过哪几层“缓冲”很多新手以为write(fd, buf, len)返回成功数据就已经落到磁盘了。实际上远远不是。用户态发出 write 系统调用后数据流程大致是层级数据去处关键点用户态缓冲区调用者的变量或 stdio 的 FILE 缓冲区我们自己可控内核页高速缓存page cachewrite 返回时数据大概率在这里存储设备队列等待驱动处理通常有硬件自身的写队列物理介质磁盘扇区需要等内核回写或 fsync 强制write()返回通常只代表数据已经从用户态复制到了内核的页缓存。如果系统突然掉电页缓存里还没回写的部分照样会丢。所以数据库、日志系统才会反复强调fsync这不是矫情是硬件语义。用户态缓冲区解决的则是另一个问题系统调用有成本。每次 syscall 都要做用户态/内核态切换、参数校验、文件对象查找。如果你用printf连续输出几千个字符每次都直接发起 write 系统调用性能会非常难看。标准库的做法是先把内容攒在用户态一块缓冲里攒够一定量再一次性触发 write。3.2 三种缓冲策略全缓冲、行缓冲、无缓冲stdio的缓冲策略通常分三种用setvbuf可以显式设置模式触发刷出时机典型场景全缓冲_IOFBF缓冲区满、fflush、fclose普通磁盘文件行缓冲_IOLBF遇到换行符、fflush、fclose、需要交互输入时终端上的 stdout无缓冲_IONBF每次写入立即触发系统调用stderrglibc 默认策略是如果 stdout 连接的是终端就采用行缓冲如果 stdout 指向管道或普通文件就变成全缓冲。stderr 永远无缓冲。这个默认策略很容易引起“奇怪现象”printf(hello); sleep(3); printf(\n);你运行这段代码会发现第一句hello不会在 sleep 期间出现而是在第二句执行后连同换行一起出现。因为 stdout 在终端下是行缓冲第一句没有换行所以攒在缓冲区里不往下走。等到换行出现整块一起刷出去。3.3 为什么默认策略要和“终端”绑在一起isatty 在起作用标准库怎么知道 stdout 是不是终端答案是调用isatty(fd)。这个函数底层通常是ioctl(fd, TCGETS, termios)。对一个普通文件执行这个 ioctl内核会返回ENOTTY说明它 not a tty。对终端设备执行会成功返回。这个区分是有实际意义的终端用户希望看到一个字符一个字符或者一行一行地出现不能攒 4KB 才显示否则交互体验非常差而普通文件不在乎这点延迟反而适合用大块写入减少系统调用次数。后文要实现的简易 libc 库也会用同样的方法决定默认缓冲策略。4. 从零写一个只覆盖 stdio 输出侧的微型 libc 库4.1 设计范围先做输出侧把缓冲机制讲透标题里说的“简易设计 libc 库”我打算实现一个微缩版的stdio输出侧my_putchar、my_puts、my_printf、my_fflush、my_fclose。读方向的原理完全对称把 write 换成 read把“攒写”换成“预读”即可不必在这里铺开全部代码。代码会直接封装 Linux x86-64 系统调用不依赖 glibc 的write封装。MY_FILE结构体里直接内嵌 4KB 缓冲区方便观察真实 libc 会用动态分配来避免每个流都吃满 4KB。#define MY_IO_BUFSIZE 4096 #define MY_EOF (-1) enum { MY_IONBF 0, MY_IOLBF 1, MY_IOFBF 2 }; typedef struct { int fd; unsigned char buf[MY_IO_BUFSIZE]; size_t pos; int bufmode; int error; int closed; } MY_FILE;4.2 底层系统调用封装my_syscall为了让读者能在普通 Linux 环境里直接编译我用一个最精简的内联汇编封装 syscall。这只对 x86-64 有效但足够演示。#define SYS_open 2 #define SYS_write 1 #define SYS_close 3 #define SYS_ioctl 16 static long my_syscall(long nr, long a1, long a2, long a3) { long ret; __asm__ volatile(syscall : a(ret) : a(nr), D(a1), S(a2), d(a3) : rcx, r11, memory); return ret; }这个封装只处理三个参数的系统调用。SYS_write正好是 fd、buf、count 三个参数。syscall指令会用 rcx 保存返回地址、r11 保存旧标志所以 clobber 列表里必须写rcx和r11否则编译器可能使用被破坏的寄存器。真实 libc 不会只用这种粗糙方式它还要处理 errno、线程取消、参数错误等细节。这里一切从简。4.3 判断终端与初始化默认缓冲策略从这里出现my_isatty用ioctl检查 fd 是否支持终端控制。TCGETS 在 Linux 里的值是0x5401。对非终端设备发起这个 ioctl 会返回错误所以返回值是 0。static int my_isatty(int fd) { struct { unsigned char space[64]; } term; return my_syscall(SYS_ioctl, fd, 0x5401, (long)term) 0 ? 1 : 0; } void my_file_init(MY_FILE *f, int fd) { f-fd fd; f-pos 0; f-error 0; f-closed 0; if (fd 2) f-bufmode MY_IONBF; else f-bufmode my_isatty(fd) ? MY_IOLBF : MY_IOFBF; }stderr 用 fd 2所以直接设为无缓冲。stdout 如果连接终端则行缓冲重定向到文件或管道则全缓冲——这正好复刻了 glibc 的默认行为。4.4 putchar、fflush、fclose缓冲区运转的核心my_putchar是缓冲区机制的发动机。无缓冲模式直接发起一次 write全缓冲模式把字符塞进pos的位置塞满就刷行缓冲模式额外判断字符是不是\n是换行就立刻刷。int my_fflush(MY_FILE *f) { if (!f || f-closed || f-pos 0) return 0; size_t off 0; while (off f-pos) { long n my_syscall(SYS_write, f-fd, (long)(f-buf off), f-pos - off); if (n 0) { f-error 1; return -1; } off (size_t)n; } f-pos 0; return 0; } int my_putchar(int c, MY_FILE *f) { unsigned char ch (unsigned char)c; if (f-bufmode MY_IONBF) return my_syscall(SYS_write, f-fd, (long)ch, 1) 1 ? (int)ch : MY_EOF; if (f-pos MY_IO_BUFSIZE my_fflush(f) ! 0) return MY_EOF; f-buf[f-pos] ch; if (f-bufmode MY_IOLBF c \n my_fflush(f) ! 0) return MY_EOF; return (int)ch; } int my_fclose(MY_FILE *f) { if (f-closed) return 0; int r my_fflush(f); my_syscall(SYS_close, f-fd, 0, 0); f-closed 1; return r; }注意my_fflush里处理了部分写的情况。write 系统调用在信号中断等场景下可能返回小于请求长度的值所以不能只看一次返回值就认为写完了。循环继续写剩余部分是负责任的实现。真实 stdio 底层也做类似处理。4.5 拼一个只支持 %s、%d、%c、%% 的 my_printf完整实现printf格式解析非常复杂这里只做必要部分目的是展示怎么把可变参数和缓冲机制串起来。先写一个整数转字符串的辅助函数static int my_itoa(int val, char *out) { char tmp[32]; int i 0; int neg 0; unsigned int uv (unsigned int)val; if (val 0) { neg 1; uv 0U - (unsigned int)val; } do { tmp[i] (char)(0 uv % 10); uv / 10; } while (uv); if (neg) tmp[i] -; int len 0; while (i 0) out[len] tmp[--i]; out[len] \0; return len; }然后是主体my_printf。它依赖一个全局MY_FILE my_stdout示例代码里需要先my_file_init(my_stdout, 1)int my_printf(const char *fmt, ...) { va_list ap; va_start(ap, fmt); int count 0; for (const char *p fmt; *p; p) { if (*p ! %) { if (my_putchar(*p, my_stdout) MY_EOF) { va_end(ap); return -1; } count; continue; } p; if (*p s) { const char *s va_arg(ap, const char *); while (*s) { if (my_putchar(*s, my_stdout) MY_EOF) { va_end(ap); return -1; } count; s; } } else if (*p d) { int v va_arg(ap, int); char buf[32]; int n my_itoa(v, buf); for (int i 0; i n; i) if (my_putchar(buf[i], my_stdout) MY_EOF) { va_end(ap); return -1; } count n; } else if (*p c) { int c va_arg(ap, int); if (my_putchar(c, my_stdout) MY_EOF) { va_end(ap); return -1; } count; } else if (*p %) { if (my_putchar(%, my_stdout) MY_EOF) { va_end(ap); return -1; } count; } else { if (my_putchar(%, my_stdout) MY_EOF || my_putchar(*p, my_stdout) MY_EOF) { va_end(ap); return -1; } count 2; } } va_end(ap); return count; }这段代码没有处理%x、%u、%f、宽度、精度但它已经足够说明一个关键点格式化的每个字符最终都汇进同一个my_putchar而my_putchar的行为由缓冲模式决定。缓冲区是整个输出通道的汇聚点。一个能跑的 demo 长这样MY_FILE my_stdout; int main(void) { my_file_init(my_stdout, 1); my_printf(id%d, name%s, done%c\n, 42, demo, Y); my_fclose(my_stdout); return 0; }编译后直接运行终端上会看到一行完整输出如果重定向到文件内容相同存在的时机可能不同——这就是下一节要观察的。5. 实际跑几个例子缓冲策略、fflush 和 fork 带来的连锁反应5.1 直接跑一次看全缓冲与行缓冲的输出时机用下面这个 demo 观察时序int main(void) { my_file_init(my_stdout, 1); my_printf(A); sleep(2); my_printf(B\n); sleep(2); my_printf(C); my_fclose(my_stdout); return 0; }直接终端运行时./demo你会看到一开始A没有出现睡 2 秒后B\n写出终端瞬间出现AB和换行再睡 2 秒后C也没有出现直到程序调用my_fclose退出C才出现。原因是终端下 stdout 被设为行缓冲A没有换行所以一直攒着B\n触发刷新把前面的A一起带了出去。C没有换行只能靠最后的my_fclose去 flush。如果改成./demo out.txtstrace 会告诉你 write 系统调用只发生两次一次在my_fclose时把AB\nC整块写出一次有没有取决于缓冲区是否提前满。因为重定向到普通文件后 stdout 变成全缓冲B\n不再触发立即刷新。这就解释了为什么“同样的代码输出到终端和重定向到文件行为不一样”。5.2 忘记 fclose 会丢数据因为内核不替我们保存用户缓冲区我们的库不是 glibcmain返回后没有自动注册的清理函数去刷新MY_FILE的私有缓冲区。如果这样写int main(void) { my_file_init(my_stdout, 1); my_printf(not flushed); return 0; }终端下可能什么都看不到。程序退出时内核只会关闭 fd 1但 fd 1 对应的打开文件描述并不会从我们进程内存里把my_stdout.buf的内容读走。用户态缓冲区是进程自己的内存不是内核的对象。glibc 之所以能在return 0后把printf(not flushed)刷出去是因为__libc_start_main注册了退出清理函数exit()会调用_IO_cleanup。我们的微型库没有这个注册逻辑所以必须自己my_fclose。这也是很多嵌入式半移植场景里标准库输出莫名其妙丢失的常见原因。5.3 fork 之后缓冲区会被复制并重复写出这是缓冲区设计里最容易踩的经典坑。假设父进程先攒了一段数据然后 forkint main(void) { my_file_init(my_stdout, 1); my_printf(prefix ); pid_t pid fork(); my_printf(pid%d\n, getpid()); my_fclose(my_stdout); return 0; }fork会把父进程的整个地址空间复制给子进程包括my_stdout里尚未刷出的prefix。于是父进程最终写了一份prefix pidxxx\n子进程又写了一份同样的prefix pidxxx\n。如果 stdout 重定向到文件你会看到prefix重复出现。这不仅仅是微缩库的问题glibc 也存在完全一样的语义。解决办法是在 fork 之前主动fflush(stdout)把缓冲区清空或者 fork 之后立刻exec让子进程放弃父进程的内存映像。写跨进程日志系统时这个细节能救命。5.4 真实 libc 的缓冲系统比我写的复杂多少看到这里你已经理解了最小可用的缓冲区模型。但真实 libc 还要处理一大堆我没碰的事情动态缓冲区分配和收缩不能在每个FILE里内嵌 4KBread 方向和 write 方向互斥切换fseek时要把缓冲区丢弃线程安全每个FILE都带锁fork后还要通过pthread_atfork修复锁状态errno 的线程局部存储完整的格式解析包括浮点数、宽度、精度、本地化等和 stdin 的交互规则比如从键盘读数据前刷新所有行缓冲输出流。所以我的建议是如果你只是好奇原理自己写一个玩玩非常值如果是生产环境还是老老实实用成熟 libc。写这个微型库的过程里最有价值的部分不是代码本身而是你把“文件描述符→内核文件抽象→用户态缓冲”这条链彻底想通了。最后再分享一个小技巧排查这类缓冲问题时与其盯着终端输出猜不如直接strace -e write ./demo看 write 系统调用到底在哪个时间点发生。看到系统调用和屏幕输出之间的差异远比看代码更快理解缓冲区站在哪一层。
阅读完成 · 觉得有帮助?
咨询建站