源码来自 musl libc,这是 Alpine Linux 等轻量发行版的默认 C 库。相比 glibc,它的代码更短、更直觉。这段 __fwritex 不到 50 行,却藏着行缓冲、延迟分配、零拷贝等多个设计。


一、先看调用链

fwrite(src, size, nmemb, f)
  └─→ FLOCK(f)
       └─→ __fwritex(src, size*nmemb, f)   ← 核心在这里
            └─→ 可能调用 f->write() 直写
            └─→ 可能 memcpy 进缓冲区
       └─→ FUNLOCK(f)
  └─→ 返回 nmemb 或 实际写入个数

fwrite_unlocked = fwrite  (弱别名,不加锁)

二、__fwritex 逐行拆解

第 1 行:参数类型

size_t __fwritex(const unsigned char *restrict s, size_t l, FILE *restrict f)
  • s 用 unsigned char* 而不是 void* —— 因为后面要做 s[i-1] 字节级访问,unsigned char 保证不会被符号扩展坑。
  • restrict 告诉编译器指针不别名,可以激进优化。

第 2-4 行:延迟分配 + 早期退出

if (!f->wend && __towrite(f)) return 0;
条件 含义
!f->wend 写缓冲区还没分配
__towrite(f) 当前需要写入(不是只读模式)

两个都成立 → 直接返回 0,不分配。

这是 延迟分配(lazy allocation) 策略:不写就不分配缓冲区,省内存。


第 6-7 行:缓冲区塞不下?直接 flush

if (l > f->wend - f->wpos) return f->write(f, s, l);
f->wend          f->wpos
   |-----------------|  ← 剩余空间
   |    已写入数据    |

如果 l 大于剩余空间,不进缓冲区,直接调用底层 write 系统调用写入。

为什么不先把缓冲区填满再 flush?因为 memcpy + write 两次拷贝不如一次 write 高效。


第 9-23 行:行缓冲的核心逻辑 ⭐

if (f->lbf >= 0) {
    /* Match /^(.*\n|)/ */
    for (i=l; i && s[i-1] != '\n'; i--);

f->lbf >= 0 表示 行缓冲模式lbf = line buffered flag)。

这个 for 循环在干什么?

从后往前找最后一个 \n 的位置。

s = "hello\nworld\n"
         ^        ^
         i=6      i=12

循环从 i=12 开始,s[11]='\n' → 找到!i=12

匹配的正则是 /^(.*\n|)/:要么找到一个换行,要么什么都没找到(i=0)。


如果找到了换行(i > 0):

size_t n = f->write(f, s, i);   // 先把带换行的部分 flush 掉
if (n < i) return n;            // 写入不完整?直接返回,不继续
s += i;                         // 指针后移
l -= i;                         // 长度减少

这就是行缓冲的本质:遇到 \n 就触发 flush。

比如 printf("hello\n"); 之所以立即输出,就是这段代码在工作。


第 25-27 行:零拷贝进缓冲区

memcpy(f->wpos, s, l);
f->wpos += l;

剩下的数据(不含换行的部分,或者非行缓冲模式的全部数据),直接 memcpy 进缓冲区。

零拷贝、无循环、无逐字节操作。


第 28 行:返回值

return l + i;
  • l:最后 memcpy 进缓冲区的字节数
  • i:之前 flush 掉的字节数(含换行)

总共写入的字节数 = 缓冲区内 + 已 flush


三、fwrite 外层包装

size_t l = size * nmemb;
if (!size) nmemb = 0;           // 边界:size=0 时避免除零
FLOCK(f);
k = __fwritex(src, l, f);
FUNLOCK(f);
return k == l ? nmemb : k / size;

返回值的处理很精巧:

情况 返回值
全部写入成功(k == l nmemb(元素个数)
部分写入(k < l k / size(实际写入的元素个数)

这就是为什么 fwrite 返回的是"成功写入的元素个数",而不是字节数。


四、关键设计点总结

设计点 代码位置 收益
延迟分配 !f->wend && __towrite(f) 不写不分配,省内存
大数据直写 l > f->wend - f->wpos 避免 buf 满后二次拷贝
行缓冲 f->lbf >= 0 + 找 \n printf 立即输出的根基
零拷贝 memcpy(f->wpos, s, l) 比逐字节写快一个数量级
弱别名解锁版 weak_alias(fwrite, fwrite_unlocked) 单线程场景省去锁开销

五、对比 glibc

glibc 的 fwrite 有 200+ 行,核心差异:

musl glibc
行数 ~50 ~200+
行缓冲实现 逆序找 \n,一次 write 逐字符判断,可能多次写
缓冲区管理 简单指针算术 复杂的 buffer chain
可读性 低(但功能更全)

musl 的哲学:够用就好,每一行都有明确目的。


六、一句话总结

__fwritex 的本质:能进缓冲区的 memcpy,塞不下的或遇到换行的直接 write,全程不做无用功。


源码分析基于 musl libc 1.2.x,不同版本细节可能有差异。


Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐