系统调用、符号版本与数据结构演进:FreeBSD 如何处理兼容性

• 本文约 4740 字,阅读大致需要 10 分钟 | Kernel | #FreeBSD | #ABI | #libc | #Programming

操作系统在演进过程中经常会碰到一个很现实的问题:一方面,内核和基础系统不可能几十年保持原样,数据结构会变,系统调用会增加,某些早期设计也迟早需要推倒重来;另一方面,一个已经发布出去的ABI又不能想改就改。如果几十年间内核里的数据结构换了一批又一批,这艘船究竟还是不是当年那艘船?保证它在用户态眼里仍然是同一艘船的关键,就在于ABI的稳定性。否则系统每升级一次,过去编译好的程序都有可能需要重新编译,整个生态很快就会变得难以维护。

FreeBSD在这方面有一个比较有利的条件:内核和基础用户态程序都在同一个源代码树中开发,因此,一次涉及kernel/userland ABI的修改,通常可以同时修改系统调用、libc、头文件以及基础系统中的调用者。

不过,这并不意味着兼容性会自动得到保证;恰恰相反,大多数兼容性最终仍然要靠一些相当具体、甚至有些繁琐的工程手段来维持。

FreeBSD对稳定分支上的用户态ABI非常重视,并将其视为一项核心特性。项目的架构支持政策明确提到,对于Tier 1平台上的userland ABI变化,通常应当提供兼容层,使针对任一稳定分支编译的程序无需重新编译或修改,仍然能够正确运行(不过,某些兼容层可能并不在默认安装中启用)。换句话说,ABI稳定并不是一句抽象的承诺,它最终会落实成libc里的版本符号、内核里的旧系统调用入口,以及各种名字里带着 freebsd11freebsd12 的兼容代码。

这些代码单独看往往并不复杂,真正麻烦的是它们一旦成为ABI的一部分,就很难轻易删除。

libc:接口可以变,实现可以换,但已经发布的符号很难收回来

对普通应用程序来说,最常见的ABI边界并不是系统调用本身,而是libc。

程序在编译时看到的是 stat()gettimeofday()getentropy() 这样的函数;至于这些函数最终对应的是一条或若干条系统调用,还是完全由用户态实现,原则上并不是应用程序需要关心的事情。正如那条著名的计算机科学谚语所言,没有什么问题是增加一层间接性解决不了的——而libc正是这层最经典的屏障,给操作系统留下了不少腾挪空间。

FreeBSD的libc还使用ELF符号版本机制来管理这种演进。不同开发周期加入的ABI会进入不同的 FBSD_1.x 符号命名空间;目前,源码树仍然通过 Versions.def 来声明这些版本。这样做的一个重要作用,是允许同一个共享库中同时保留某个接口的新旧实现,而动态链接器可以根据旧二进制文件在链接时记录下来的符号版本,来找到它所期待的那一版ABI。

这比简单地「保留同名函数」要重要得多。

假设某个函数的参数、返回值或者某个相关结构体发生了不兼容变化,如果只是把libc里的实现直接换掉,那么旧程序即使仍然能找到这个函数名,也未必能够正确调用它;而有了符号版本机制,旧ABI和新ABI可以长期并存,新程序使用新版本,旧程序则继续匹配到旧版本的兼容实现上。

弱符号(weak symbol)也是libc中经常使用的一种工具,但它解决的是略有不同的问题。弱符号更适合处理可覆盖的默认实现、interposition或某些内部兼容用的占位实现,而不能简单地将其理解为带版本符号的替代品。FreeBSD libc中最典型的例子是pthread桩函数: _pthread_stubs.c 通过 __weak_referencepthread_mutex_lock() 等接口定义成弱符号的空实现,单线程程序因此只链接libc就可以正常工作;而libthr则以强符号提供真正的实现,把这些桩覆盖掉。

因此,如果未来要把一个过去主要由Ports中的第三方库提供的接口引入基本系统,弱符号可能是需要考虑的工具之一,但具体怎么做,仍然取决于现有ABI和动态链接语义,不能只靠「把libc里的符号设成weak」就认为问题解决了。

新userland遇到旧内核:不是普遍保证,但libc有时会留后路

兼容性的另一个方向比较有意思:如果用户态比正在运行的内核更新,会发生什么?

首先需要说明,FreeBSD通常并不保证新版world可以与旧版内核任意搭配使用。 FreeBSD使用手册中明确表示,如果内核和系统工具来自不同版本,类似 ps(1)vmstat(8) 等紧密依赖内核接口的程序就可能无法正常工作,因此正常的升级过程始终要求按规定顺序更新kernel与world。

不过,这并不妨碍某些libc接口为这种情况做兼容,这样做的考量,通常是为了方便用户从一次失败的升级中恢复,或者在运行旧内核的系统上方便地运行使用新版userland的jail容器。

getentropy(3) 就是一个很好的例子。

FreeBSD 12 加入了 getrandom(2) 系统调用,而libc中的 getentropy(3) 使用它来获取高质量随机数。问题在于,一个包含新版libc的用户态并不总能假定其底层的内核已经提供了相应的系统调用。FreeBSD在引入这一接口后,随即加入了针对旧内核的回退机制:如果 getrandom(2) 因内核太旧而返回 ENOSYSgetentropy(3) 就退回到旧内核中已有的 kern.arandom sysctl。

这个例子值得注意的地方,不在于随机数本身,而在于libc在这里实际上充当了一层版本适配器:

flowchart TD
    App[application] --> GetEntropy["getentropy()"]
    GetEntropy -->|新内核| SysGetRandom["getrandom(2)"]
    GetEntropy -->|旧内核| CompatSysctl["旧的兼容机制 (kern.arandom)"]

对于应用程序来说,它调用的始终是同一个 getentropy();至于具体调用内核的哪一种能力,则交给libc自己判断。

顺带一提,这段回退代码也展示了兼容代码的完整生命周期:到了2024年,由于所有仍受支持的FreeBSD版本都已提供 getrandom(2)这段回退逻辑最终被删除了。兼容代码虽然极难消亡,但也并没有哪一段代码能够真正永生;当它的历史使命彻底完成时,终究也会像雨中的泪水一样悄然隐退。

这里也涉及一个容易混淆的细节:调用一个不存在或未实现的系统调用,通常会得到 ENOSYS 错误,而FreeBSD的系统调用派发机制默认还会向调用进程发出 SIGSYS 信号(这一行为可以用2023年加入的 kern.signosys sysctl 关闭)。这属于内核处理未知系统调用时的ABI语义,不能简单理解成「libc收到 ENOSYS 以后产生 SIGSYS」;FreeBSD在2023年还修正过几条系统调用派发路径,使触发 ENOSYS 的相关情况能正确产生 SIGSYS

所以,从工程上看,新userland对旧kernel的兼容并不是一个全局属性,而往往是针对每个接口逐一实现的。有些东西可以很容易回退,而另一些功能则可能完全没有旧接口可用。如何取舍,要视接口的重要程度而定。

旧程序遇到新内核:系统调用兼容层才是真正的大头

反过来的情况更常见,也更加重要:一个多年前编译的程序,放到今天的FreeBSD上能否继续运行?

这里真正构成ABI契约的,不只是系统调用编号,还包括参数布局、数据类型宽度以及用户态能够看到的各种结构体。

FreeBSD 12 ino_t 扩展为64位(即ino64项目),就是一个很典型的例子。inode编号扩展宽度之后,struct statstruct dirent 等公开数据结构也必须相应变化;然而旧程序在编译时已经把这些结构的大小和字段偏移写进了机器码,不可能因为内核升级就自动知道新的布局。

这种情况下,就不能简单地让旧系统调用直接返回新的 struct stat

FreeBSD的处理办法是保留旧ABI的系统调用入口。在今天的源码里仍然可以看到 freebsd11_* 一类接口,而内核内部同时存在处理新版结构的 kern_statat()kern_fstat() 等实现。

大致可以理解为:

flowchart TD
    OldProg[旧程序] -->|旧ABI| Freebsd11Stat["freebsd11_stat()"]
    Freebsd11Stat -->|内核内部统一表示| KernStatat["kern_statat()"]
    KernStatat -->|转换成旧struct| CopyOut["copyout()"]

也就是说,兼容层负责对接旧ABI,真正执行文件系统操作的核心代码则尽量使用当前版本的数据结构。

这样的分层很有用,因为如果每个文件系统都需要知道FreeBSD 7、8、9、10、11的 struct stat 分别长什么样,代码很快就会变得难以维护;把历史ABI集中在syscall兼容层中,可以让内核核心实现只使用一套通用且功能完备的代码,而把翻译数据结构的工作交给兼容层去解决。

当然,这种转换不可能凭空创造信息。

假如新内核中的inode编号已经超过旧ABI能表示的范围,兼容层就必须做出选择。FreeBSD为此专门提供了 vfs.ino64_trunc_error sysctl:默认情况下,超出范围的值会被静默截断,以求旧程序尽可能继续工作;管理员也可以选择让 stat(2) 一族的兼容系统调用返回 EOVERFLOW 错误,或是把写不下的值限制为旧类型的最大值。 EOVERFLOW 这类错误存在的意义,正是在这种「内核知道答案,但旧ABI这片纸上的空地太小,写不下答案」的情况下明确告诉程序:不是文件不存在,而是你的接口太老了。

因此,所谓二进制兼容,很多时候并不是让旧程序「理解新世界」,而是为它构建一个温馨而真实的虚构世界,让它以为时间停留在它最熟悉的那个时代。

ioctl:结构体本身有时就是ABI

系统调用的参数通常比较稳定,而设备驱动和各种子系统使用的 ioctl 则更容易暴露另一个问题:一个C结构体一旦跨越内核与用户态的边界,就不能再把它当成普通的内部结构体来看待。

传统上,BSD的 ioctl 编号并不只是像系统调用编号那样是一个任意挑选的整数。传统的 _IO_IOR_IOW_IOWR 宏会把参数方向以及参数大小编码进请求;FreeBSD的 ioctl(2) 文档也明确说明,一个请求编号中包含了参数的方向(输入还是输出)以及参数的字节数。

这会产生一个很有意思的效果。例如:

#define FOO_GET _IOWR('F', 1, struct foo)

如果 struct foo 的大小发生变化,那么在采用这种编码机制时,新旧程序编译出来的 FOO_GET 数值就不一样了。

从错误检测的角度来看,这是一件好事。它避免了这样一种更危险的情况:用户态拿着一个32字节的结构体,内核却以为这是48字节,然后双方在完全没有察觉ABI已经不一致的情况下继续交换数据。

然而,ioctl 的ABI问题并不会因此消失,因为调用会失败。现实中的驱动通常还需要决定:

  • 是否继续支持旧的请求编号(request number);
  • 新旧结构体之间是否可以转换;
  • 新字段能否放在结构体末尾并利用长度判断;
  • 是否需要显式的ABI版本(version)字段;
  • 32位程序运行在64位内核上时,指针和对齐应该怎么转换;
  • 一个已经发布的有缺陷的结构体布局,到底还能否修复。

类似ZFS这样复杂而且跨多个操作系统维护的子系统,实际ABI处理还会更加复杂。ZFS的管理操作通过 /dev/zfs 上的ioctl传递 zfs_cmd_t 结构,而这个结构在历史上变化过很多次;早年直接把它编码进ioctl请求的做法意味着,结构体一变,新旧两端计算出的请求编号就无法匹配,旧工具只能直接报错失败。如今OpenZFS同时支持Linux和FreeBSD,在FreeBSD上,ioctl实际传递的只是一个固定大小的包装结构 zfs_iocparm_t,其中带有显式的ABI版本字段和指向真正 zfs_cmd_t 的指针;内核里则专门维护着一层版本转换代码,把旧版结构逐字段翻译成当前布局—— 而不是单纯依靠BSD ioctl命令编号中的size位。

这里真正值得记住的反而是一个比较朴素的经验:任何会跨越ABI边界的数据结构,在第一次发布之前都值得多留几个保留字段(reserved fields)。

所有当时为了节省几个字节而未留白的数据结构,早已在暗中标好了ABI的价格——几年后,往往需要拿几十行甚至上百行复杂的翻译代码来偿还。

兼容代码为什么总是越积越多

把这些机制放在一起看,会发现FreeBSD的兼容性并没有什么特别神秘的地方:

  • libc的符号版本机制让旧动态链接ABI得以继续存在;
  • 某些libc wrapper会在新旧内核接口之间做回退;
  • 内核中的兼容系统调用把旧用户态结构转换成当前内部结构,反之亦然;
  • ioctl 等接口则必须对跨越内核与用户态边界的结构体格外谨慎。

真正麻烦的是时间。

一个ABI在发布以前,修改它可能只需要改三行代码;一旦发布以后,再想修改同样的东西,就可能需要保留旧结构体、旧系统调用、转换函数、32位兼容代码、符号表和测试用例,而且这些东西往往要跟着系统再活很多年。

FreeBSD源码里大量的 COMPAT_FREEBSDxx 并非什么特别值得炫耀的设计技巧,它们更多是过去做出的ABI承诺在今天留下来的痕迹。很多代码甚至谈不上漂亮,例如,我本人在用OpenBSD的ChaCha20实现替换 arc4random() 时,对于被删掉的 arc4random_stir() API便采取了保留一个兼容桩、并在首次调用时记录一条日志的做法,而不是直接删除,仅仅因为可能还有程序在调用它,因此不能直接删除。

但从另一个角度说,这大概也就是ABI稳定真正的含义所在。

所谓兼容性,并不是提前设计出一个永远不会改变的接口——这种事情通常做不到—— 而是在接口不得不改变以后,仍然有人愿意把旧世界留下来的东西接住,再一点一点翻译到新的实现上。

从这个意义上说,操作系统里的兼容层多少都有些像沉积岩:越往下挖,越容易看到当年为什么会留下现在这个样子。有些是深思熟虑的设计,有些只是权宜之计,还有一些纯粹是历史遗留;但只要最上层的程序还在正常运行,这些代码就会一直存在下去。