微软 Windows 为在内核中执行操作提供了大量的 ABI(应用程序二进制接口)表面区域。然而,并非所有 ABI 都是平等的。正如 Casey Muratori 在其讲座《唯一不可打破的定律》(The Only Unbreakable Law)中指出的,软件开发团队的组织结构会直接影响其产出软件的结构。
Windows 上的 DLL 被组织成一个层次结构,其中一些 API 是对更底层 API 的高级封装。例如,无论何时你调用 kernel32.dll 的函数,最终的实际工作都由 ntdll.dll 完成。这一点可以通过使用 ProcMon.exe 并检查堆栈跟踪来直接观察。
从实践经验来看,ntdll API 通常设计精良、合理且强大,而 kernel32 这层封装却可能引入不必要的堆分配、额外的故障模式、意外的 CPU 使用和冗余。使用 ntdll 函数感觉就像在使用资深工程师打造的软件,而使用 kernel32 函数则感觉在使用典型的微软员工开发的软件。
因此,Zig 标准库的策略就是尽量避免使用除 ntdll 之外的所有 DLL。我们还未完全达成这个目标,目前仍有许多对 kernel32 的调用,但近期取得了显著进展。下面将分享两个具体的例子。
示例 1:熵(Entropy)的获取
根据官方文档,Windows 并没有一个直接获取随机字节的方法。许多项目,包括 Chromium、boringssl、Firefox 和 Rust,都选择调用 advapi32.dll 中的 SystemFunction036,因为它早在 Windows 8 之前的版本中就已可用。
然而,从 Windows 8 开始,首次调用此函数时,它会动态加载 bcryptprimitives.dll 并调用 ProcessPrng。如果加载 DLL 失败(例如由于系统过载,我们在 Zig CI 上曾观察到数次),它会返回错误 38——这发生在一个返回类型为 void 且官方文档声称永不失败的函数上。
ProcessPrng 所做的第一件事是分配一小段常量字节的内存。如果分配失败,它会在一个 BOOL 类型中返回 NO_MEMORY(而其文档规定的行为是永不失败并始终返回 TRUE)。此外,bcryptprimitives.dll 每次加载时显然还会运行一个测试套件。
实际上,ProcessPrng 真正执行的操作是对 "\\Device\\CNG" 执行 NtOpenFile,然后使用 NtDeviceIoControlFile 读取 48 字节以获取一个种子,接着初始化一个基于 AES 的每 CPU CSPRNG(加密安全伪随机数生成器)。这意味着,对 bcryptprimitives.dll 和 advapi32.dll 的依赖完全可以避免,同时也能规避首次读取 RNG 时可能出现的非确定性故障和延迟。
示例 2:文件 I/O (NtReadFile 与 NtWriteFile)
让我们来看看不同层级的 API 设计。ReadFile 在 kernel32.dll 中的声明大致如下:
pub extern "kernel32" fn ReadFile(
hFile: HANDLE,
lpBuffer: LPVOID,
nNumberOfBytesToRead: DWORD,
lpNumberOfBytesRead: ?*DWORD,
lpOverlapped: ?*OVERLAPPED,
) callconv(.winapi) BOOL;
而更底层的 NtReadFile 在 ntdll.dll 中是这样定义的:
pub extern "ntdll" fn NtReadFile(
FileHandle: HANDLE,
Event: ?HANDLE,
ApcRoutine: ?*const IO_APC_ROUTINE,
ApcContext: ?*anyopaque,
IoStatusBlock: *IO_STATUS_BLOCK,
Buffer: *anyopaque,
Length: ULONG,
ByteOffset: ?*const LARGE_INTEGER,
Key: ?*const ULONG,
) callconv(.winapi) NTSTATUS;
记住,上层的函数是通过调用下层的函数实现的。仅从声明就能看出使用底层 API 的一些好处。例如,真实的 API (NtReadFile) 直接通过返回值提供状态码,而 kernel32 封装却将状态码隐藏起来,返回一个 BOOL,然后要求你额外调用 GetLastError 来定位问题。想想看,函数直接返回一个明确的值,这难道不是更优雅的设计吗?
此外,OVERLAPPED 本质上是一个假类型。Windows 内核根本不知道或不关心它!真正的底层原语是事件、APC(异步过程调用)和 IO_STATUS_BLOCK。
如果你有一个同步文件句柄,那么 Event 和 ApcRoutine 参数必须为空。你会立即在 IO_STATUS_BLOCK 中得到操作结果。如果你在此传递一个 APC 例程,一些陈旧且可能不稳定的 32 位代码路径会被触发,导致得到垃圾结果。
另一方面,如果你有一个异步文件句柄,那么你需要使用 Event 或 ApcRoutine。kernel32.dll 使用事件,这意味着它进行了额外且不必要的资源分配和管理,仅仅是为了读取文件。相反,Zig 现在直接传递一个 APC 例程,然后调用 NtDelayExecution。这种方式能与取消操作无缝集成,使得无论文件是以同步还是异步模式打开,都能在任务执行文件 I/O 时优雅地取消任务。
总结来说,绕过 kernel32.dll 等高层封装,直接与 ntdll.dll 交互,不仅能提升效率、减少不必要的资源开销,还能获得更清晰、更健壮的错误处理机制。这正是追求极致性能与可控性的系统编程语言(如 Zig)所需要的。对于深入探索系统底层和操作系统原理的开发者,了解这些 Windows 内部的运作机制也大有裨益。
原文链接:https://ziglang.org/devlog/2026/#2026-02-03
本文首发于云栈社区,一个专注于深度技术讨论的开发者社区。欢迎分享你在系统编程或性能优化方面的见解。