当我们在装 VC 运行库的时候,我们究竟是在装什么

/ 1评 / 0

答:钝角。

有些时候不得不感叹,现代计算机系统竟是在如此孱弱的基础上构建的,属实是一种奇迹。

近日(半年内)网上冲浪的时候,无意间发现 InfoQ 推送了这么一篇文章:《“C 不再是一种编程语言”》[1]。本着「所有翻译的内容都需要读原文」的原则,我就去看了原作[2]

结果我打开了新世界的大门[3],同时知道了过去遇到的一些神秘的问题的究极答案[4]

API 和 ABI

我想先谈谈 API 和 ABI 的事情。

API, 应用程序编程接口,作为开发者的各位可能并不陌生。大多数计算机从业者都或多或少地在使用由他人提供的 API 去实现自己想要的功能。我们几乎认为 API 这种东西是自然的、自发的、理所应当的——毕竟,语言之间的相互调用讲道理应该是一种内建功能。

但是在 API 之下,还有一层 ABI. Application Binary Interface, 应用程序二进制接口。这个东西负责规定具体的硬件交互方式,它指导两个二进制实例之间如何相互传递数据和解读数据、系统如何为这种通信分配具体的资源。注意到这两个二进制实例可能来自同一个文件——它们可能是同一个程序的两个函数;而这种通信可能并不涉及任何花哨的东西——它们很可能只是简单的函数调用。

C 语言究竟是如何在函数之间传递参数的?

这个问题的「标准答案」大概是:C 语言通过栈传递参数,参数按相反的次序依次入栈(实际上相当于参数按内存地址正向排列)。上学的时候都背过,没背过也考过。

但很遗憾,这个答案实际上是「错误」的。比如 Cortex-M 架构下,函数参数不大于 4 个的,通过 r0-r3 寄存器传递[5];AArch64 架构下,函数参数不大于 8 个的,通过 v0-v7 传递。C 语言本身根本就没有规定参数如何在函数间传递,也没有规定函数的返回值究竟放在哪里。这一切的一切由这个平台自行决定,而这个决定通常叫做调用约定 (Calling convention)。它是 ABI 的一部分,而不是 C 语言的一部分。

当初我第一次接触到这个概念的时候感觉背后有点发毛——那个时候我已经在尝试自己写操作系统内核了。这意味着 C 语言为了可移植性,放弃了对底层具体实现的一些假设;而我们却认为这些假设是存在且由 C 语言规定的。我们之前所认为理所应当的东西,有多少是 C 语言立下的确切规定,而又有哪些是我们纯粹的臆想呢?

答案很简单:几乎所有那些被写进考题的、涉及具体硬件的部分,全部都是我们的臆想。

在我最初开始学 C 语言的时候,我纳闷过为啥 Windows 下有两套编译系统,一个是 MinGW, 另一个是 MSVC. 这两套系统似乎又完全不兼容的样子。但当时由于学得非常浅,所以最终是没有得出一个结论。现在我明白了:编译器之间的差异可以非常恐怖。编译器差异并不仅仅体现在编译速度、优化质量之类看得见摸得着的地方。他们可能会在你无法看到的那些地方产生足以炸掉世界但是却又非常细小的差异,比如如何处理 __int128[6]、比如如何处理结构体填充[7]、比如……

当然,如果这个世界里只有你一个人负责编写这个平台上的所有 C 语言代码,那么其实这种差异也没什么。反正最后他们都会由同一个编译器从源码中完全编译,所以任何底层的差异除非你由手写汇编的需求,不然都会被编译器无差别地抹平。

但是现实世界开发当然不是一个人从头到尾全部都写。终归是会用到别人的东西的。而有些时候,别人的东西是预先编译好的静态库;或者预先编译好的动态库;或者是一段无格式平坦二进制 (Flat binary)——总之是你没法从源码中编译的东西。

从这里开始,埋下了不安的种子。

libc

如果你的 C 程序里有用到任何不是你写的部分,那么你就已经在使用一个预先编译好的静态库了:C 语言标准库。这个标准库本身有各种不同的实现——微软自己会有 MSVC CRT; 而 Linux 生态下则有 glibcmusl 来选。注意:你最好二选一,并且祈祷你需要用的工具是按你选择的那个标准库编译的。

即使是在单片机上,你也可能在使用设备制造商或者编译器为你提供的标准库的一种实现。而这些具体的实现更有可能和标准 C 有难以预料的差异[8]

这就意味着对于 C 标准库的更改需要十分谨慎,最好不要破坏原有的行为——毕竟,当你的用户足够多的时候,没人会希望你每次更新系统都意味着这套系统从驱动到应用程序全部都得重新编译一遍才行。

但我需要更改我的标准库实现

人无完人,代码中存在问题需要修改,那么改就是了。

但是有些时候,这些修改并不是单纯地改动函数内部的具体实现。有些时候这个修改可能涉及到更改一些意想不到的地方:寄存器使用、堆栈使用,或者其他对于普通开发者透明的系统部件。它们可能并不涉及具体的函数签名更改,但是它们会产生可以炸掉上层应用的问题。

比如我最爱的 2038 问题。要解决这个问题,最简单的方法是将 time_t 升级到能够用 64 位整数表示时间,这样可以被表示的时间应该会持续到太阳熄灭之后了。但是,对于 32 位系统来说,这个更改会破坏 ABI 兼容性:因为在 32 位系统上需要使用一个额外的寄存器来表示 64 位整数,而这个被使用的寄存器可能已经在应用层被挪作它用了。考虑这样一个例子:

unsigned char is_after_y2k(time_t t) {
    return t > (time_t) 946656000;
}

人畜无害,非常简单。编译器也做简单的事情:

is_after_y2k: ; t -> r0
  ldr r3, .value
  cmp r0, r3
  movle r0, #0
  movgt r0, #1
  bx lr
.value: .word 946656000

但是一旦 time_t 变成了 64 位,编译器产生的结果就会不一样了:

is_after_y2k_64: ; t -> r0(l), r1(h)
  ldr r3, .value
  cmp r0, r3
  sbcs r1, r1, #0
  movge r0, #1
  movlt r0, #0
  bx lr
.value: .word 946656001 ; yes, there is an extra 1

如果标准库更新了但是你的程序没有更新,那么当你的程序被调用的时候,它就会对寄存器的使用产生错误的期待,进而产生错误的结果:比如把一个明显在 2000 年之后的时间当成是 2000 年之前(比如 2039-01-01, 因为此时 r0 部分的值很小)。哦豁,系统爆破大成功。

几乎所有基于数字定义的类型都会遇到这种问题(那基本上是所有类型了)。比如 intmax_t, 这个用于表示预处理器、编译器和标准库可以接受的最大有符号整数类型,原本被设计为「随着系统的迭代和升级可以无缝切换的、安全表示最大整数的类型」实际上完全白给[9],因为它做不到二进制级别 (Binary level) 的兼容。

所以,为什么要装那么多的 MSVC?

图 1 多个 MSVC 版本实例

答案很简单。微软需要对底层做出一些改进,但是这些改进会动到底层,进而破坏 ABI. 那些仍然在使用旧 ABI 的程序就会因此崩溃,或者把系统搞崩溃。

所以,为了解决这个问题,微软选择了不解决这个问题[10],因为这个问题并不是通过某些取巧的手段[11]就能搞定的。那些在某个固定 MSVC 版本编译的,就依赖某个固定版本的 MSVCPxxx.dllMSVCRxxx.dll 就可以了。这样就算 MSVC CRT 做了任何破坏 ABI 的修改,反正发一个新版本就能解决,何必费心思管这种事情呢。

但是有些时候,不得不感叹:我们现在严重依赖的信息系统就建立于这些非常不稳定的根基上。计算机没有科学,计算机全是魔法。最终,编译器作者、标准库作者、系统包管理员、语言委员会抗下了所有,给上层程序员创造了一个不需要去关心这些棘手问题的安全世界。

——不,当然没有。否则哪来的开头的文章?


如果你在翻脚注的话,恭喜你!你是少数几个真的会去求证引用资料真实性的、具有科研精神的同志。Stay vigilant!

参考资料

参考资料
1 Beingessner A, 平川, 罗燕珊. “C 不再是一种编程语言”[EB/OL]. InfoQ. (2022-03-28)[2022-06-28]. https://www.infoq.cn/article/PQi0LYV7HhmsgHkPzkJI.
2 Beingessner A. C Isn't A Programming Language Anymore[EB/OL]. Faultlore. (2022-03-16)[2022-06-28]. https://gankra.github.io/blah/c-isnt-a-language/.
3 Meneide J. To Save C, We Must Save ABI[EB/OL]. ThePhD. (2022-03-13)[2022-06-28]. https://thephd.dev/to-save-c-we-must-save-abi-fixing-c-function-abi.
4 Meneide J. Binary Banshees and Digital Demons[EB/OL]. ThePhD. (2021-09-20)[2022-06-28]. https://thephd.dev/binary-banshees-digital-demons-abi-c-c++-help-me-god-please.
5 ARM. The ARM-THUMB Procedure Call Standard[F/OL]. ARM Limited. (2000-10-24)[2022-06-28]. https://documentation-service.arm.com/static/5ed11d78ca06a95ce53f9043?token=.
6 Beingessner A. abi-checker[CP/OL]. GitHub. (2022-03-28)[2022-06-28]. https://github.com/Gankra/abi-checker.
7 Pendleton N. C Structure Padding Initialization[EB/OL]. Interrupt. (2022-03-01)[2022-07-03]. https://interrupt.memfault.com/blog/c-struct-padding-initialization.
8 Meneide J. Conformance Should Mean Something[EB/OL]. ThePhD. (2022-04-18)[2022-07-03]. https://thephd.dev/conformance-should-mean-something-fputc-and-freestanding.
9 Meneide J. A Special Kind of Hell - intmax_t in C and C++[EB/OL]. ThePhD. (2020-12-05)[2022-07-03]. https://thephd.dev/intmax_t-hell-c++-c.
10 McNellis J. The Great C Runtime (CRT) Refactoring[EB/OL]. Microsoft C++ Team Blog. (2014-06-10)[2022-07-04]. https://devblogs.microsoft.com/cppblog/the-great-c-runtime-crt-refactoring/.
11 Chen R. What can go wrong when you mismatch the calling convention?[EB/OL]. The Old New Thing. (2004-01-15)[2022-07-03]. https://devblogs.microsoft.com/oldnewthing/20040115-00/?p=41043.
  1. Innsd说道:

    好文章

回复 Innsd 取消回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

Your comments will be submitted to a human moderator and will only be shown publicly after approval. The moderator reserves the full right to not approve any comment without reason. Please be civil.