第 1 章拆解 — 用 Zig 写三个 UNIX 小工具
用 Zig 写三个 UNIX 小工具
这一章讲什么: 不写「Zig 语法入门」,直接拿 Zig 造三个能用的命令行工具: 把标准输入抄一份给文件和屏幕的 ztee、在 PATH 里找程序的 zwhich、生成随机密码的 zpass。 读完全书需要的两块地基也在这里铺好:显式内存管理(谁申请的谁释放,写代码时就必须说清), 和 Zig 0.15→0.16 的破坏性变更(缓冲区从「可选优化」变成「架构要求」,不这么写编译都过不了)。
主线例子:ztee。一个 9 字节的输入
hello\n,从键盘进程序,写进屏幕和一个文件, 每一步旁边都有真实的函数名和字节数。
1. 先交代:这本书站在 Zig 0.16 上,而且 0.15 的写法已经编译不过
读这章之前必须先知道一件事,否则看到后面到处出现的 init.io、init.gpa 会以为作者在故弄玄虚。
Zig 这门语言还没有 1.0,版本之间会「破坏性变更」——书上给的定义是:语言、库或工具的修改,会让现有代码停止工作,或者必须跟着改才能继续工作1。作者的态度是这不完全是坏事:它往往意味着更干净的设计、更好的性能,是一个还在演化的语言在修补早期决定的代价2。
对这本书,这条暗线有个非常具体的爆发点,书里叫它「Writergate」:0.15.1 重做了标准库的 I/O 子系统,把过去「每次 print 都直接发一次系统调用」的舒服写法,换成了「缓冲区不再是可选优化,而是架构要求」的严格模型3。旧写法在 0.15.1 直接报错编译不过;0.16 又来了一波更大的:删掉了 std.net、std.Thread.Mutex、std.crypto.random 等一堆老 API,通用分配器从 GeneralPurposeAllocator 改名 DebugAllocator,连 main 的签名都从 pub fn main() !void 改成了 pub fn main(init: std.process.Init) !void4。
**这本书全部代码对着 Zig 0.16.0 写,每一行都假设你接受这个新世界。**所以第一章前半不是在讲工具,而是在带你认新世界的地形:
- 程序启动时,运行时把常用的东西打包成一个
init参数送给main():输入输出句柄init.io、通用分配器init.gpa、退出时自动释放的 arena 分配器init.arena、命令行参数init.minimal.args、环境变量表init.environ_map(环境变量:进程启动时从系统继承的一串「名字=值」,如 PATH)5。 - 想要「能按格式打印的输出」,现在必须自己声明一个缓冲区、把缓冲区绑到标准输出、用完手动 flush。漏掉 flush,程序会一个字都不输出——因为 print 只是往你自己的栈缓冲区里拷字节,缓冲区没满就不会真的发给操作系统6。
判断(我们的,不是书里的): 0.16 这套「运行时在启动时把一切都递给你」的设计,方向是对的——它让「这个函数会不会偷偷分配内存、偷偷发系统调用」从读文档猜谜变成了看函数签名就知道。 但对学习者,代价是 hello world 从三行变成十行,而且每一行都有必须当场解释的新词。 如果错,会错在: 如果 Zig 1.0 之前再经历一次同等规模的重做,本章这些 API 名字又会被冲掉——0.15→0.16 已经发生过一次,书 itself 就是证据。
2. 顶层全景:三个工具,一条「数据从哪来、到哪去」的线
键盘 ──fd 0(标准输入)──┐
▼
┌───────────┐ 读满 4096 字节或 EOF
│ ztee │◀─────────────────────┐
└─────┬─────┘ │
同一份数据写三处 │ │
┌───────────┼───────────┐ │
▼ ▼ ▼ │
fd 1(屏幕) 文件 A(截断) 文件 B(追加) 磁盘文件
│
zwhich:沿 PATH 逐个目录拼接「目录/命令名」,查存在+是普通文件+可执行,命中即停
zpass:从操作系统密码学随机源逐字节取随机数,映射到 72 个字符的表上图说:三个工具分别练习三件事——同时照管多个输出目标、沿搜索路径探测文件系统、正确地用密码学随机数。每一格都是后面大项目的缩小版。
主线例子(全章共用): 你在终端敲
echo "hello" | ztee /tmp/copy.txt输入是 6 个字节(5 个字母加一个换行符)。下面每一节都会回到这 6 个字节,看它在每个机制下走到哪一步。
3. 核心原理
3.1 UNIX 的每个进程天生开着三个「文件」
ztee 要照管的对象,UNIX 早就规定好了:每个进程一出生,系统就替它开着三个文件——标准输入、标准输出、标准错误7。程序内部不叫文件名,叫文件描述符:非负的小整数,0 是输入、1 是输出、2 是错误8。
这里有个设计上的理由,书里没有明写但全章都在用:输入和错误被故意分成两条通道,这样数据可以顺着「输入→处理→输出」这条线一根管子接一根管子(| 管道符),而报错永远直接打在你屏幕上,不会混进要传给下一个程序的数据里。ztee 的测试(第 3.5 节)整个就是围绕这件事设计的。
3.2 ztee 的骨架: arena 分配器 + 「先立好清理,再开始申请」
分配器(allocator) 是 Zig 里一切动态内存的来源:你想要一块内存,就向它申请;用完了还给它。Zig 不许全局偷偷分配——如果一个函数不接收分配器参数,你可以确定它不会碰堆9。
ztee 的第一个决定:用 init.arena.allocator() 申请一个 arena(一种「只进不出」的分配器,申请次数可以很多,但不用逐块归还,整个程序退出时一次性全部释放)10。理由很实际:命令行工具的内存需求是短而确定的——解析参数、存文件名、建配置,做完就退出;与其逐块记账,不如交给 arena 兜底。
第二个决定写法上更见功力:ztee 在打开任何一个文件之前,就写下了一个块级的 defer:程序不管怎么退出(正常跑完或中途报错),都要先把文件列表里每个文件句柄关掉、再释放列表内存11。
书里叫这个原则「资源获取与销毁要尽可能绑在一起」。defer 是 Zig 的「离开时执行」语句:写在资源申请的正下方,保证任何退出路径都会执行到。
这两个决定串起了后面所有章节:第 3 章自己造分配器、第 7 章 fork 子进程前要先想好谁清理,都是这条线的延伸。
3.3 I/O 循环:一次 4096 字节,读到没有为止
现在主线例子的 6 个字节进场了。ztee 不可能把全部输入一次性读进内存(输入可能是无穷流,也可能是比内存还大的文件),所以它转一个循环12:
while (true) {
stdin.readStreaming(...) // 阻塞在这里,直到有数据;EOF 时报 error.EndOfStream
→ 读到 n 个字节(本例:n = 6)
→ 原样写给屏幕(stdout)
→ 遍历输出文件列表,把这 6 字节逐文件写一遍
→ 追加模式:用 writePositionalAll 写在「上次写到的偏移 + 6」处
}
EOF → 退出循环,defer 关文件图说:缓冲区是栈上声明的 var buf: [4096]u8。6 字节来一次就一次走完整个循环;一个 4 GB 的文件也是同一个循环跑一百万次——内存占用恒定在 4 KB,与输入大小无关13。
两个细节值得记住:
- 读满没读满是靠返回值区分的:返回 0 或抛出
error.EndOfStream都表示「源头枯了」,循环收工12。 - 追加模式之所以要维护一个
write_offsets平行数组,是因为 Zig 的写文件函数把「写到哪个字节位置」做成显式参数而不是隐藏的游标——文件 A 已存在内容 100 字节、文件 B 是新建的,两个文件各自的下一个写入位置就得分别记着14。
3.4 打开文件是一门「选标志位」的学问
ztee 之前,书先用一组小程序把文件操作的几面都亮了相,核心是每个操作背后的标志位(truncate、exclusive、mode)在替你做什么决定:
| 你想要 | 传给 createFile() 的标志 | 出错时的行为 |
|---|---|---|
| 新建或清空重写 | .truncate = true | 已存在就清空 |
| 只新建、绝不覆盖 | .exclusive = true + .truncate = false | 抛 error.PathAlreadyExists |
| 往末尾追加 | openFile(.{ .mode = .read_write }) + 查大小 + 按偏移写 | 不存在就报错 |
图说:书上给的运行实录是写 /tmp/newFile 第一次成功、cat 看到内容,第二次同样的命令打印 File already exists.——错误被接住、变成人话,而不是程序崩溃15。
「检查—量尺寸—申请」模式(先 stat() 拿文件大小、再按大小申请内存、再一次读进来)在静态普通文件上可靠,但书特意给了三条它的失效场景,每一条都是系统编程的真见识:量完尺寸到真正读之间文件被别人改了(检查与使用之间的竞态);/proc 下的特殊文件元数据报 0 字节却有内容;文件比内存还大直接内存溢出。这三条在第一章就出现,是因为它们是「为什么不能一次读进来」的全部理由16。
3.5 zwhich:沿 PATH 逐个目录做三次检查
zwhich ls 要回答「敲 ls 时系统到底执行哪个文件」。做法朴素:把环境变量 PATH 按冒号拆开(在 macOS 上是 /opt/homebrew/bin:/usr/local/bin:/usr/bin:… 这样一串目录)17,对每个目录拼出「目录名 + / + 命令名」,然后过三道关卡:
- 存在吗——
access()查路径是否可访问,任何错误(不存在、没权限)都算否; - 是普通文件吗——查元数据(文件的属性信息:类型、大小、时间戳)的类型字段,目录、设备都不算;
- 有可执行位吗——把权限字段与八进制
0o111按位与,只要属主、属组、其他人里任何一类带执行权限(x)就算过18。
三关全过:打印完整路径,收工(which(1) 的规矩是只报第一个命中)。书上的实录:zwhich ls 输出 /bin/ls,zwhich doesNotExist 先打印一行人话报错,再跟一行 error: FileNotFound——后者是 Zig 错误沿调用栈传播的标准输出,发布版构建里栈跟踪会被抑制19。
3.6 直调 C:一个 extern 声明,没有胶水层
zwhich 的三个检查全用 Zig 标准库能写,但书故意用另一个程序演示同一件事用 C 的 access(2) 怎么做——因为「复用现成 C 库」是 Zig 的看家本领之一:写一行 extern fn access(...) 声明这个函数的存在和签名,就能直接调20。
类型上有一个必须当场说清的点:[*:0]const u8 表示 C 那种以零结尾的字符串(一串字节,末尾跟一个 0 作为句号);而 Zig 自己的字符串是「切片」——一块指针加长度的结构。把 Zig 切片传给 C 函数时,编译器自动剥掉长度、只把指向原始字节的指针递过去21。在 Linux 上跑这类程序如果报链接错误,补救是在命令里加 -lc 让编译器链接 C 标准库22。
3.7 zpass:两种随机数,和「取余偏差」这个坑
zpass 生成密码,引出了随机数的两分法,这条分界线出了门天天会撞见:
| 从哪来 | 适合干什么 | |
|---|---|---|
密码学安全随机数(init.io.randomSecure) | 直接读操作系统的随机源(Unix 上是 /dev/urandom) | 密码、密钥、会话令牌 |
伪随机数(std.Random.DefaultPrng) | 一个确定性算法,给一个种子就产生一条固定序列 | 模拟、游戏、洗牌;种子固定则每次结果一模一样 |
图说:关键区别在于伪随机数算法是确定的——哪怕种子来自操作系统、每次运行不一样,算法本身(Xoshiro256++)不是为保密设计的,不能拿来生成密钥23。
zpass 逐字节从安全随机源取数,然后映射到一张 72 个字符的表上。这里有个不处理就会留安全漏洞的细节:取余偏差。一个字节有 256 种取值,72 不能整除 256(256 = 72×3 + 40),如果直接 byte % 72,前 40 个字符会各多出现一次,密码就不再均匀。zpass 的做法叫拒绝采样:抽到的字节如果 ≥ 216(= 72×3,能整除的最大倍数)就扔掉重抽,只用 0–215 这均匀的一段24。
4. 作者的判断与证据
- 判断: 0.15→0.16 的破坏性变更虽然「打碎了无数构建」,但换来的是「与硬件现实一致的零成本 I/O 抽象:系统调用昂贵,批量是必要的」3。证据是全书:后面第 4 章用基准测试量化「每次系统调用多贵」,那组数字就是这句话的注脚。
- 判断: 「Zig 设计者把很多事做对了」——书里列了五条:性能与安全、手动内存管理(代价,换速度)、错误处理、C 互操作、相对好学;同时承认 Zig 还在开发期、文档在高级领域还稀疏25。这一章的代码就是前四条的车展。
- 证据(实测): ztee 的测试设计得很讲究:用一个同时往 stdout 和 stderr 各打 5 行的小程序做输入,验证「输出文件里只有 OUT 行、ERR 行绕过 ztee 直接上屏」——流的隔离是被测出来的,不是声明出来的26。
- 推测成分: 书说手动内存管理「是我们为速度必须付的价」,这是立场而不是证明——第 8 章的每线程分配器基准测试才给了一半证据。
5. 边界与局限
- 全部代码只对 Zig 0.16.0 负责。 书上明写「每个代码清单都在 Zig 0.16.0 上编译运行」27。0.15 的读者照抄会编译不过,1.0 之后再来读的读者可能又要对一遍改名表。
- zwhich 是 UNIX 专用:路径分隔符
/和 PATH 的分隔符:都写死了,在 Windows 上是\和;,直接就是错的28。 stat()量尺寸的读法在特殊文件上会静默失败,书给了警告但没有给流式读法的完整实现——那要到第 4 章才补上。- C 互操作那节自己也承认:程序暂时分不清普通文件和目录(
access对两者都返回成功)29。
6. 可带走的
- 缓冲区不是优化,是架构。 系统调用昂贵,批量是必要的——0.15.1 把这条从性能建议变成了编译强制。
defer写在申请的正下方,资源获取与销毁绑在一起;块级defer能一次兜住一整个列表。- 看函数签名有没有 allocator 参数,就知道它会不会偷偷碰堆——这是读 Zig 代码的第一反应。
- 短生命周期程序用 arena:退出即全部释放,把逐块记账的精力省下来。
- 管道程序里,数据走 stdout、人话报错走 stderr,两通道不许混。
exclusive创建 + 接住PathAlreadyExists,是「绝不覆盖用户数据」的默认姿势。- 随机数先分两类再问出处:密码学安全的直接读系统随机源;伪随机永远别碰密钥。
- 把随机字节映射到字符表时,记得取余偏差,用拒绝采样(≥ 216 的字节扔掉重抽)。
- 调 C 就是一行
extern+ 正确的类型([*:0]const u8),没有胶水层;Linux 上记得-lc。 - 运行 Zig 程序传参数要加
--:前面是给编译器的,后面才是给你的程序的。
7. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 三个文件描述符 0/1/2 | Understanding standard input, output, and error | text/06-fm-understanding-standard-input-output-and-error.txt:3(搜「three files open all the time」) |
| 破坏性变更的定义与态度 | What does breaking changes mean for Zig? | text/07-fm-what-does-breaking-changes-mean-for-zig.txt:9(搜「stop working」) |
| Writergate 与 0.15.1 的 I/O 重做 | Writergate (Zig 0.15.1) | text/08-fm-writergate-zig-0-15-1.txt:5(搜「overhaul of the standard library's I/O subsystem」) |
0.16 改名与 main(init) 全景 | Learning about the Zig 0.16 breaking changes | text/09-fm-learning-about-the-zig-0-16-breaking-changes.txt:9(搜「removal of」) · :113(搜「runtime gift」) |
| 三种打印与 flush 责任 | Learning how to print on screen | text/10-fm-learning-how-to-print-on-screen.txt:146(搜「canonical pattern」) · :206(搜「print no output」) |
| ArrayList 的显式分配哲学 | Using std.ArrayList | text/11-fm-using-std-arraylist.txt:49(搜「explicit allocation」) |
| errdefer 与「谁申请谁释放」 | Reading user input | text/12-fm-reading-user-input.txt:54(搜「takes responsibility for freeing it」) |
| 文件标志位与「检查—量尺寸」三坑 | Reading and opening files for writing or appending | text/13-fm-reading-and-opening-files-for-writing-or-appendi.txt:142(搜「exclusive creation」) · :495(搜「race condition」) |
| ztee 的 defer 先行与写偏移 | Implementing ztee | text/14-fm-implementing-ztee.txt:270(搜「before a single file is even opened」) |
| 4096 字节 I/O 循环 | The I/O loop: buffering data efficiently | text/15-fm-the-i-o-loop-buffering-data-efficiently.txt:23(搜「page sizes」) |
| 流隔离的测试设计 | Testing ztee | text/16-fm-testing-ztee.txt:93(搜「completely ignoring Standard Error」) |
| PATH 拆分 | Splitting the PATH environment variable | text/18-fm-splitting-the-path-environment-variable.txt:41(搜「splitScalar」) |
zwhich 三关与 0o111 | Connecting the dots | text/19-fm-connecting-the-dots.txt:197(搜「core search logic」) |
extern 调 C 与零结尾字符串 | Connecting the dots | text/19-fm-connecting-the-dots.txt:383(搜「extern」) |
| 两类随机数 | Two ways to generate random values | text/20-fm-two-ways-to-generate-random-values.txt:3(搜「two」) · :120(搜「is deterministic and is not a CSPRNG」) |
| 拒绝采样与取余偏差 | Generating unpredictable passwords | text/21-fm-generating-unpredictable-passwords.txt:31(搜「rejection sampling」) |
| 「Zig 把很多事做对了」五条 | Generating unpredictable passwords | text/21-fm-generating-unpredictable-passwords.txt:97(搜「got many things right」) |
本章拆解依据原书第 1 章(含各小节),Zig 版本口径 0.16.0。每条出处可用 node scripts/book-verify.mjs systems-programming-with-zig 回核。
Footnotes
-
出处:「What does breaking changes mean for Zig?」第 9 段(
text/07-fm-what-does-breaking-changes-mean-for-zig.txt:9,搜「stop working or require changes to keep working」)。 ↩ -
出处:「What does breaking changes mean for Zig?」第 13 段(
text/07-fm-what-does-breaking-changes-mean-for-zig.txt:13,搜「not always a bad thing」)。 ↩ -
出处:「Writergate (Zig 0.15.1)」第 5 段(
text/08-fm-writergate-zig-0-15-1.txt:5,搜「overhaul of the standard library's I/O subsystem」)与第 17 段(text/08-fm-writergate-zig-0-15-1.txt:17,搜「system calls are expensive」)。 ↩ ↩2 -
出处:「Learning about the Zig 0.16 breaking changes」第 9 段(
text/09-fm-learning-about-the-zig-0-16-breaking-changes.txt:9,搜「removal of」)与第 55 段(text/09-fm-learning-about-the-zig-0-16-breaking-changes.txt:55,搜「is renamed」)。 ↩ -
出处:「Learning about the Zig 0.16 breaking changes」第 125 段(
text/09-fm-learning-about-the-zig-0-16-breaking-changes.txt:125,搜「bundles everything a program typically needs」)。 ↩ -
出处:「Learning how to print on screen」第 210 段(
text/10-fm-learning-how-to-print-on-screen.txt:210,搜「the program will print no output」)。 ↩ -
出处:「Understanding standard input, output, and error」第 3 段(
text/06-fm-understanding-standard-input-output-and-error.txt:3,搜「three files open all the time」)。 ↩ -
出处:「Understanding standard input, output, and error」第 5 段(
text/06-fm-understanding-standard-input-output-and-error.txt:5,搜「non-negative integer values」)。 ↩ -
出处:「Using std.ArrayList」第 81 段(
text/11-fm-using-std-arraylist.txt:81,搜「does not take an allocator parameter」)。 ↩ -
出处:「Implementing ztee」第 43 段(
text/14-fm-implementing-ztee.txt:43,搜「monotonic memory region」)。 ↩ -
出处:「Implementing ztee」第 270 段(
text/14-fm-implementing-ztee.txt:270,搜「before a single file is even opened」)。 ↩ -
出处:「The I/O loop: buffering data efficiently」第 28 段(
text/15-fm-the-i-o-loop-buffering-data-efficiently.txt:28,搜「bytes_read = stdin.readStreaming」)。 ↩ ↩2 -
出处:「The I/O loop: buffering data efficiently」第 23 段(
text/15-fm-the-i-o-loop-buffering-data-efficiently.txt:23,搜「page sizes」)。 ↩ -
出处:「Implementing ztee」第 252 段(
text/14-fm-implementing-ztee.txt:252,搜「explicit byte offset」)。 ↩ -
出处:「Reading and opening files for writing or appending」第 291 段(
text/13-fm-reading-and-opening-files-for-writing-or-appendi.txt:291,搜「first execution」)。 ↩ -
出处:「Reading and opening files for writing or appending」第 499 段(
text/13-fm-reading-and-opening-files-for-writing-or-appendi.txt:499,搜「race condition」)。 ↩ -
出处:「Reading environment variables」第 86 段(
text/17-fm-reading-environment-variables.txt:86,搜「/opt/homebrew/bin」)。 ↩ -
出处:「Connecting the dots」第 137 段(
text/19-fm-connecting-the-dots.txt:137,搜「bitwise AND」)。 ↩ -
出处:「Connecting the dots」第 259 段(
text/19-fm-connecting-the-dots.txt:259,搜「doesNotExist」)。 ↩ -
出处:「Connecting the dots」第 383 段(
text/19-fm-connecting-the-dots.txt:383,搜「extern」)。 ↩ -
出处:「Connecting the dots」第 411 段(
text/19-fm-connecting-the-dots.txt:411,搜「sentinel zero value」)。 ↩ -
出处:「Connecting the dots」第 472 段(
text/19-fm-connecting-the-dots.txt:472,搜「not linking against the」)。 ↩ -
出处:「Two ways to generate random values」第 120 段(
text/20-fm-two-ways-to-generate-random-values.txt:120,搜「is deterministic and is not a CSPRNG」)。 ↩ -
出处:「Generating unpredictable passwords」第 31 段(
text/21-fm-generating-unpredictable-passwords.txt:31,搜「rejection sampling」)。 ↩ -
出处:「Generating unpredictable passwords」第 97 段(
text/21-fm-generating-unpredictable-passwords.txt:97,搜「got many things right」)。 ↩ -
出处:「Testing ztee」第 93 段(
text/16-fm-testing-ztee.txt:93,搜「completely ignoring Standard Error」)。 ↩ -
出处:「Preface」(
text/03-fm-preface.txt,搜「compiles and runs on Zig 0.16.0」)。原书序说明版本口径;按本书库规矩序属导航章,只用于交代背景,不作机制论断的出处。 ↩ -
出处:「Connecting the dots」第 221 段(
text/19-fm-connecting-the-dots.txt:221,搜「UNIX-specific」)。 ↩ -
出处:「Connecting the dots」第 500 段(
text/19-fm-connecting-the-dots.txt:500,搜「cannot differentiate」)。 ↩