六年360个补丁,只为删掉一个1970年代的函数

六年360个补丁,只为删掉一个1970年代的函数

linuxkernelcsecuritystrncpy

数据源:HN + Phoronix · HN

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 终止符。调用者拿到的是一个没有结尾标记的字符数组。任何后续的 strlenstrcmpprintf 都会从这个字符数组的末尾继续读取内存,直到碰巧遇上某个 \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 合并之初划了一条红线:“不接受大规模转换 strlcpystrncpy 的补丁。” 他的理由很实用:必须逐点审计语义,每次代码因其他原因被修改时顺带替换一个调用点。这一约束直接定义了后续六年的工作节奏。

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 层构建安全抽象。strscpystrtomemfortify-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.” 这恰好说明了为什么 strtomemstrscpy 必须同时存在——多个内核维护者认定,有些场景下”不 NUL 终止”是正确行为。strncpy 的移除是一次审慎的逐场景分拣,不是一个函数的罪状审判。

结语

362 个补丁换一个函数从代码库消失。单看投入产出比,这不是一个令人兴奋的数字。内核通常不这样干活。

但换一个视角:Linux 内核有数千万行代码,三百多万次提交,开发者平均每天提交约 200 个补丁。在这种流速下,用六年、362 次精准手术清扫一个在 C 生态里存活了近半个世纪的 API,同时没有引发显著的回归事故——这组数字本身说明了这件事的体量。

在足够大的系统里,移除一个被广泛误用的 API,比引入十个新 API 更难。 而真正的安全性改进,往往藏在那些没有 feature announcement 的沉默工作里。


以上分析基于公开信息与社区讨论。笔者并非内核贡献者,如有疏漏,欢迎指正。