2026 年 6 月 20 日,Linus Torvalds 合并了一个看起来普普通通的 Pull Request。提交标签是 strncpy-removal-v7.2-rc1,没有长文解说,和内核其他几百个合并相比毫无特殊之处。内行人才知道它意味着什么:strncpy——这个诞生于 1970 年代 Unix 的 C 标准库函数——从 Linux 内核代码库中彻底消失了。
代价是六年时间,362 个补丁,70 位贡献者。
一个为文件系统目录项设计的函数
strncpy 的出生证明写在 Unix 第 7 版的文件系统目录结构里。当年目录项是 16 字节的定长记录——14 字节存文件名,2 字节存 inode 编号。strncpy(dest, src, n) 就是为这个场景量身定做的:把字符串塞进一个固定宽度字段,如果源字符串比 n 短,用 \0 填满剩余空间。
这个函数的名称暗示它是 strcpy 的”安全版”,但实际上它解决的是一个问题:如何把变长字符串写入定长记录。文件名的 14 字符上限是一个硬边界,零填充是保证记录格式整洁的必要手段。在那个年代,它的设计完全合理。
问题出在后来。当一代代程序员学到”strcpy 不安全,要用 strncpy”时,这个函数被推上了一个从未被设计承担的岗位——通用安全的字符串复制。从一个定长记录格式化工具变成了”防止缓冲区溢出”的默认答案,这恰恰是所有错误的起点。
三个反直觉的语义缺陷
如果单看 strncpy 的接口——带一个长度参数 n——你很难不以为它在保护你。但它的行为有三处背离直觉。
第一,源字符串长度 ≥ n 时,目标末尾不追加 NUL 终止符。调用者拿到的是一个没有结尾标记的字符数组。任何后续的 strlen、strcmp、printf 都会从这个字符数组的末尾继续读取内存,直到碰巧遇上某个 \0 或触发缺页。这不是理论推演——CVE 数据库中持续记录的 NUL 终止遗漏漏洞,strncpy 贡献了可观的比例。Linux 内核文档给它的定语是 “persistent source of bugs”,这种措辞在内核社区里不多见。
第二,源字符串短于 n 时,它用 \0 填满整个目标的剩余空间。这种”钝头”零填充对于需要清洁固定记录的原始场景是正确行为,但对于只需要 NUL 终止字符串的绝大多数调用来说,是浪费 CPU 周期。拷贝 6 字节的 "ext4" 到一个 256 字节的缓冲区,strncpy 会忠实地写 250 次 \0。内核是性能敏感的代码体,这种”安全税”不适合批量重复缴纳。
第三,返回值始终指向 dest 而不是表示复制的字节数。要判断拷贝是否被截断,调用者必须自己在调用前后计算——这正是实践中最容易被跳过的检查。API 设计的 ergonomics 很差,错误使用的默认路径恰好指向一座悬崖。
内核文档对此做了一个简洁的评价:strncpy 在 99.999% 的情况下不是正确的调用选择。剩下的 0.001% 恰好对应它上个世纪被设计处理的那种固定宽度非字符串字段——这在现代内核中的占比微乎其微。
替代方案:一个函数分裂为一族函数
Linux 内核没有”删了 strncpy 请大家用 strscpy”这样简单的一对一替换,而是构建了一套按意图分层、语义各自明确的函数体系。
- 需要 NUL 终止的字符串拷贝 →
strscpy()。2015 年由 Chris Metcalf 引入,始终保证目标末尾有\0,返回实际复制的字节数(截断时返回负值 errno)。 - 需要 NUL 终止 + 零填充 →
strscpy_pad()。适合拷贝敏感数据前后的清理,避免内核数据泄露到用户空间。 - 需要非 NUL 终止的固定宽度字段 →
strtomem()/strtomem_pad()。这是最接近strncpy原始设计意图的替代品,专门对应那些”这不是字符串,是一个固定宽度字符数组”的场景。 - 已知长度的纯内存拷贝 →
memcpy()。不需要开销的时候不要支付。 - 有界拷贝 + 显式填充 →
memcpy_and_pad()。
这套 API 家族的核心设计哲学是一句话:每个函数只解决一件事,让调用者无法含糊其辞。 不再有那种”看起来安全,但在特定条件下会犯错”的万能函数。选择 strscpy 意味着你承诺目标是一个 C 字符串;选择 strtomem 意味着你声明目标不是字符串。代码审查中,读到这里就已经清楚设计者的意图。
在 strscpy 之前,BSD 社区的 strlcpy 是另一个试图纠正 strncpy 的尝试。它保证了 NUL 终止,返回值是源字符串长度以便调用者检测截断。但 strlcpy 有自己的问题——它为了计算返回值需要读完全部源字符串,即使目标缓冲区很小。在内核语境中,如果源指针指向的缓冲区碰巧没有终止符(或被并发修改),strlcpy 会沿着内存一路读下去。这在内核里是高风险行为。Linus Torvalds 在 2015 年拉入 strscpy 时明确表示不鼓励 strlcpy 的批量转换。
六年,362 个补丁——为什么不是全局替换
初看这组数字容易产生一个自然疑问:不就是换一个函数名吗,为什么需要六年?
因为 strncpy 的每一处调用都承载着调用者对它语义的特定依赖。有的代码依赖它不会写 NUL 终止符(目标字段本来就是定长非字符串)。有的代码依赖它做零填充来清理残留数据。有的代码在 strncpy 之后又手动补了一个 \0——既用了 strncpy 又不信任 strncpy。还有的代码三样全占。
把这些调用点机械地全部替换为 strscpy 会在”非字符串”场景中引入 NUL 终止符从而溢出固定宽度字段,会丢失那些依赖零填充的安全擦除逻辑,会在已经手动补了 \0 的地方造成一次多余的写入。全局搜索替换在这里不只是一刀切——它是一刀切的错误答案。
Linus Torvalds 在 strscpy 合并之初划了一条红线:“不接受大规模转换 strlcpy 或 strncpy 的补丁。” 他的理由很实用:必须逐点审计语义,每次代码因其他原因被修改时顺带替换一个调用点。这一约束直接定义了后续六年的工作节奏。
Kees Cook,内核安全子系统维护者,在 Mastodon 上公布了一组贡献数据:362 个提交来自 70 位贡献者。Google 的内核开发者 Justin Stitt 一人提交了 211 个(占 58%),Xu Panda 贡献了 22 个,Kees Cook 自己 21 个,Thorsten Blum 17 个,Arnd Bergmann 12 个。这些补丁分布在文件系统、网络栈、设备驱动、加密模块、架构代码等几乎每一个内核子系统中——没有一个子系统的维护者能完全躲开这次清理。
HN 讨论中有一条评论概括得精准:这就是系统工程真正发生的地方。新特性几天内就能合并,但移除一个”大概能用、实际上有毒”的遗留接口,需要的是维护者长期坚守、贡献者持续冲锋、工具链同频演进。362 个补丁分布在六年之间,平均每个月约 5 个——不是一朝突击,是刻意降低风险节奏的持续外科手术。
社区中的不同声音
HN 的讨论并没有停留在庆祝。有评论直接指出问题的根源比 strncpy 本身更深——C 语言的 NUL 终止字符串模型本身,就是把长度信息嵌在数据末尾而不是单独存储,任何字符串操作都必须线性扫描才能知道长度。这被某些评论称为”computing’s biggest mistake”——措辞偏绝对,但指出的结构性矛盾是真实的。
Dennis Ritchie 本人在 1990 年就提出过给 C 加上”胖指针”(fat pointer)——一个同时携带起始地址和长度的复合类型,本可以进入 C99。Walter Bright 在 2007 年的文章《C’s Biggest Mistake》以更清晰的语言重述了同一条提议。但 C99、C11、C23 都没有采纳。Linux 内核走的是另一条路:既然语言标准不演进,就在自己的 API 层构建安全抽象。strscpy、strtomem、fortify-source、__nonstring——这些都不属于 C 标准库,但它们是内核内部的通用语言。代价是六年,收益是消除了一整类已知的内存安全 bug。
也有保留声音。XFS 子系统的一次替换补丁曾在邮件列表上被反对:“It removes explicit source buffer overrun protection whilst making the incorrect assumption that the callers need to be protected from unterminated strings in the destination buffer.” 这恰好说明了为什么 strtomem 和 strscpy 必须同时存在——多个内核维护者认定,有些场景下”不 NUL 终止”是正确行为。strncpy 的移除是一次审慎的逐场景分拣,不是一个函数的罪状审判。
结语
362 个补丁换一个函数从代码库消失。单看投入产出比,这不是一个令人兴奋的数字。内核通常不这样干活。
但换一个视角:Linux 内核有数千万行代码,三百多万次提交,开发者平均每天提交约 200 个补丁。在这种流速下,用六年、362 次精准手术清扫一个在 C 生态里存活了近半个世纪的 API,同时没有引发显著的回归事故——这组数字本身说明了这件事的体量。
在足够大的系统里,移除一个被广泛误用的 API,比引入十个新 API 更难。 而真正的安全性改进,往往藏在那些没有 feature announcement 的沉默工作里。
以上分析基于公开信息与社区讨论。笔者并非内核贡献者,如有疏漏,欢迎指正。