Zig 如何在源码层面实现无运行时(第25篇)
大家好,我是Zachel,今天继续我们的 Zig 进阶系列。
Zig 最让人惊艳的卖点之一,就是官方反复强调的 “无运行时”(no runtime)。很多人听到这句话,第一反应是:“这不就是 C 语言吗?” 但 Zig 比 C 走得更远——它在源码层面就把所有可能引入隐式运行时开销的东西全部干掉了。没有隐藏的控制流、没有隐式内存分配、没有异常、没有 GC、甚至连编译器都不会偷偷给你塞一段 runtime 代码。
今天我们就扒开 Zig 的源码(自托管编译器 + 标准库),看看它到底是怎么在源码层面实现“零运行时”的。
1. 设计哲学写在最前面:无隐式控制流
Zig 官网 Overview 里有一句经典的话(直接来自源码设计文档):
“There is no hidden control flow, no hidden memory allocations, no preprocessor, and no macros. If Zig code doesn’t look like it’s jumping away to call a function, then it isn’t.”
这句话不是营销话术,而是 Zig 编译器在语义分析阶段严格执行的铁律。
我们直接看编译器源码(目前在 Codeberg 上,路径大致对应原 GitHub 的 src/Sema.zig):
在 Sema(Semantic Analysis)阶段,Zig 对每一个表达式、每一次函数调用都做精确的控制流追踪。它不会像 C++ 那样因为运算符重载让 a + b 偷偷调用函数,也不会像 Go 那样因为 defer/panic 引入隐式栈展开,更不会像 Rust 那样因为 Drop trait 产生隐藏析构调用。
举个最直观的例子:
var a = b + c.d;
foo();
bar();
这段代码在 Zig 里绝对只会先执行 foo(),再执行 bar()。编译器在 Sema.zig 的 analyzeBodyInner 等函数里,不会插入任何额外的跳转、异常检查或隐式调用。你在源码里看不到的东西,生成的机器码里也绝对没有。
这和很多语言完全相反——那些语言的编译器会在 AST → IR 阶段偷偷插入 runtime hook。
2. 无隐式内存分配:Allocator 必须显式传递
Zig 标准库里几乎所有需要分配内存的函数,都把 allocator: std.mem.Allocator 作为第一个参数。这是源码层面的硬性要求。
打开 lib/std/mem.zig、lib/std/array_list.zig 等文件,你会发现:
-
没有全局的 malloc -
没有隐藏的 arena -
没有像 Rust 的 Box::new那样默认走全局分配器
所有分配都必须你亲自传 allocator。这意味着在 freestanding / bare-metal / 嵌入式场景下,你可以完全不链接 std,直接写 pub fn main() void { ... } 就能跑。
想彻底去掉运行时?一行命令就行:
zig build-exe main.zig -target x86_64-freestanding -fno-compiler-rt -O ReleaseSmall
-fno-compiler-rt 正是 Zig 源码里提供的开关(compiler-rt 是 LLVM 风格的内置函数库,比如 128 位整数运算、memset 等)。Zig 默认会根据需要静态链接一小部分,但你随时可以关掉——源码层面完全可控。
3. 运行时安全检查也是“源码可控”的
Zig 的运行时安全(runtime safety)是很多人误以为“有运行时”的根源。其实它根本不是强制运行时,而是可选的编译期开关。
核心实现就在 Sema.zig 的 setRuntimeSafety 相关逻辑里:
@setRuntimeSafety(false); // 局部关闭
或者全局通过构建模式:
-
Debug → 全开安全检查(栈溢出、越界、整数溢出等) -
ReleaseSafe → 只开必要的安全检查 -
ReleaseFast / ReleaseSmall → 全部关闭
这些开关在语义分析阶段就决定了是否生成 panic 调用。生成的 IR 里要么有 call @panic,要么直接优化掉——没有“偷偷插入”的中间态。
更狠的是,Zig 把“未定义行为”当做优化工具:整数溢出在所有模式下都是非法行为,编译器可以大胆做假设。这在源码层面直接体现在 Sema 对 arithmetic 操作的处理上,比 C 的 signed overflow UB 还要彻底。
4. Comptime 把“运行时”直接搬到编译期
这是 Zig 实现零运行时的杀手锏。
在 Sema.zig 的 comptime 求值引擎里,任意 Zig 代码(包括循环、分支、函数调用)都可以在编译期执行。只要标记 comptime,类型、值、甚至整个数据结构都可以被当做常量处理。
典型例子(标准库里到处都是):
const array = comptime blk: {
var a: [100]u8 = undefined;
@memset(&a, 0xAA);
break :blk a;
};
这段代码在编译期就完成了 memset,运行时二进制里连一个字节的初始化代码都没有。
这正是 Zig 把“元编程”做到极致的地方:类型反射、泛型、代码生成全部在 comptime 完成,没有运行时开销。
5. 源码级别的极致简洁:580 行 PEG 语法
Zig 的整个语言语法用一个 580 行的 PEG 文件就完整描述了(见官方文档 Grammar)。这不是巧合——因为没有宏、没有预处理器、没有隐式特性,解析器和语义分析器才能保持极简。
对比其他语言动辄上万行的 parser + 几百个 builtin,Zig 的编译器源码本身就是“无运行时”理念的最佳实践:自托管后,整个编译器都用 Zig 写,充分体现了自己宣扬的透明性。
总结:源码层面才是真正的“无运行时”
Zig 的无运行时不是营销口号,而是贯穿整个编译器管道的设计:
-
Parser + AST → 无隐藏语法糖 -
Sema → 精确控制流 + 可关闭的安全检查 -
Codegen + Linker → 可选 compiler-rt + 单编译单元优化 -
Std → 所有资源显式管理 + comptime 重度使用
当你写下一行 Zig 代码时,你看到的,就是最终机器码里会有的——不多也不少。
想亲自验证?把上面的 -fno-compiler-rt 命令跑一遍,再用 nm 或 objdump 看二进制,你会发现连 libc 都不需要,纯净得可怕。
这就是 Zig 在源码层面实现的“无运行时”魔法。
本篇完。
如果你也正在用 Zig 写嵌入式、操作系统内核或者追求极致性能的代码,欢迎在评论区分享你的“零运行时”实践~
我们下篇见!(第26篇预告:Zig vector 类型源码:SIMD 黑魔法全在这里 )
夜雨聆风