近日,Rust和Swift专家Aria Beingessner发布的一篇文章《C 不再是一种编程语言》在Hacker News上引起了热烈讨论。
原文链接:https://gankra.github.io/blah/c-isnt-a-language/
Hacker News评论区:https://news.ycombinator.com/item?id=30704642
Aria和朋友Phantomderp在“对C ABI接口感到非常失望并试图修复上”达成了高度一致。但在失望的原因上,Aria与朋友各自持不同意见。那具体产生了哪些分歧呢?为什么会提出C不再是一种编程语言的观点呢?笔者对原文进行了编译:
Phantomderp试图从原生上改善使用C本身作为编程语言的条件,而Aria则希望改善使用C以外的任何语言条件。
这时候大家就会产生疑问了,这个问题和C有什么关系?
Aria表示:如果C真的是一种编程语言,那就和它无关。不幸的是,它并不是。这不是说数十亿种实现方式和失败的层次结构,导致它的定义方式非常糟糕的事实,而是C被提升到一个具有威望和权力的角色,它的统治是和永恒的。C是编程的通用语言,我们都必须学C,因此C不再只是一种编程语言,它成了每一种通用编程语言都需要遵守的协议。
这实际有点像是关于整个“C是一个不可捉摸的实现定义混乱” 。但仅因为它让我们不得不使用这个协议,这就变成了一个更大的噩梦。
外部功能接口
下面一起来谈谈技术问题。假如你已经完成了你的新语言Bappyscript的设计,对Bappy Paws/Hooves/Fins有的支持。这是一种神奇的语言,将彻底改变cats、sheep、和sharks的编程方式。
但现在需要让它真正做一些有用的事情。比如接受用户的输入,或者输出,或者字面上的任何可观察之类的东西。如果你想让该语言编写的程序与主流操作系统兼容,那就需要与操作系统的界面进行交互。听说Linux上的一切都“只是一个文件”,所以一起在Linux上打开一个文件吧!
OPEN(2)
NAME
open, openat, creat - open and possibly create a file
SYNOPSIS
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int creat(const char *pathname, mode_t mode);
int openat(int dirfd, const char *pathname, int flags);
int openat(int dirfd, const char *pathname, int flags, mode_t mode);
/* Documented separately, in openat2(2): */
int openat2(int dirfd, const char *pathname,
const struct open_how *how, size_t size);
Feature Test Macro Requirements for glibc (see
feature_test_macros(7)):
openat():
Since glibc 2.10:
_POSIX_C_SOURCE >= 200809L
Before glibc 2.10:
_ATFILE_SOURCE
这是Bappyscript,不是C,那Linux的Bappyscript接口在哪里?
你说Linux中没有Bappyscript接口是什么意思?好吧,当然是因为这是一种全新的语言,但你会添加一个,对吗?那这时你就会发现,你好像必须使用他们给的东西。
你将需要某种接口,让语言能够调用外部的函数,就像外部函数接口FFI。然后你发现Rust也有C FFI,Swift也有,甚至Python也有。
你会发现,每个人都必须学会C才能与主流的操作系统对话,然后当需要相互对话时,大家突然都用起了C。所以…为什么不直接用C来相互对话呢?
现在C就变成了一种编程通用语言,不仅是一种编程语言,它还是一种协议了。
与C对话包括哪些内容?
很明显,基本上每种语言都必须学会与C进行对话,而且这种语言是非常明确的。
"对话 "C是什么意思?它意味着以C头文件的形式获得接口类型和功能的描述,并以某种方式:
匹配这些类型的布局
用链接器做一些事情,将函数的符号解析为指针
用适当的ABI来调用这些函数(比如把args放在正确的寄存器中)
那么,这里就有几个问题:
你实际上不能写一个C解析器
-
C实际上没有ABI,甚至没有定义的类型布局
实际上无法解析一个C头文件
Aria曾断言解析C基本上是不可能的,但有人说其实有很多工具可以读取C头文件,比如rust-bindgen。事实果真如此吗?其实不然。
bindgen使用libclang来解析C和C++头文件。要修改bindgen搜索libclang的方式,请参阅clang-sys文档。关于bindgen如何使用libclang的更多细节,请参阅bindgen用户指南。
任何花费大量时间试图快速解析C(++)头文件的人都会很快放弃,然后让一个C(++)编译器来做这件事。请记住,有意义地解析C头文件不仅仅是解析:你还需要解决#includes、typedefs和macros的问题!所以现在不仅要实现所有相关功能,还要实现所有平台的头文件解析逻辑,并且还需要想方设法找到DEFINED!
就拿Swift来说,它在C互操作和资源方面拥有优势,它是由苹果开发的一门编程语言,有效取代了Objective-C,成为在其平台上定义和使用系统API的主要语言。在这样做的过程中,它比其他任何人都更进一步实现了ABI稳定性和设计概念。
它也是Aria见过的支持FFI的语言之一。它可以本地导入(Objective-)C(++)头文件,并产生一个漂亮的本地Swift接口,其类型在边界自动 "桥接 "到它们的Swift对等项(由于类型具有相同的ABI,所以通常是透明的)。
Swift也是由苹果公司中许多构建和维护Clang和LLVM的人开发。这些人都是C及其衍生品方面的世界专家。Doug Gregor就是其中之一,他曾表达了对C FFI的看法:
所有这些都是Swift内部使用Clang来处理 C(++) ABI的原因。这样一来,我们就不会去追着Clang增加的每一个影响ABI的新属性。
可以看出,即使是Swift也不想花时间解析C(++)头文件。那么,如果你不想让C编译器在编译时解析和解决头文件,你该怎么做呢?
你需要手工翻译!int64_t
? 还是写i64. long
…?什么是long?
C实际上没有ABI
好吧,这没有什么好惊讶的:C语言中的整数类型,为了 “可移植性”而被设计成摇摆不定的大小,实际上大小也是不稳定的。我们可以认为CHAR_BIT很奇怪,但这也不能帮助我们了解long
的大小和对齐方式。
有人说每个平台都有标准化的调用约定和ABI,确实有,而且它们通常定义了C中关键原语的布局(并且有些不只是用C类型来定义调用约定,这里侧眼于AMD64 SysV)。
还有一个棘手的问题:架构并没有定义ABI,操作系统也是。我们必须在一个特定的目标三元组上全力以赴,比如 “x86_64-pc-windows-gnu”(不要和 "x86_64-pc-windows-msvc "混淆)。经过测试,一共有176个三元组。
print target-list rustc --
aarch64-apple-darwin
aarch64-apple-ios
aarch64-apple-ios-macabi
aarch64-apple-ios-sim
aarch64-apple-tvos
...
armv7-unknown-linux-musleabi
armv7-unknown-linux-musleabihf
armv7-unknown-linux-uclibceabihf
...
x86_64-uwp-windows-gnu
x86_64-uwp-windows-msvc
x86_64-wrs-vxworks
_
这实在是有太多ABI了,因为测试中甚至没有用到所有不同的调用约定,如stdcall vs fastcall或aapcs vs aapcs-vfp。
但至少所有这些ABI和调用约定之类的东西,都可以一种方便使用的机器可读格式获得。至少主流的C编译器在特定目标三元组的ABI上达成了一致! 当然有一些奇怪的jank C编译器,但Clang和GCC不是:
> abi-checker --tests ui128 --pairs clang_calls_gcc gcc_calls_clang
...
Test ui128::c::clang_calls_gcc::i128_val_in_0_perturbed_small passed
Test ui128::c::clang_calls_gcc::i128_val_in_1_perturbed_small passed
Test ui128::c::clang_calls_gcc::i128_val_in_2_perturbed_small passed
Test ui128::c::clang_calls_gcc::i128_val_in_3_perturbed_small passed
Test ui128::c::clang_calls_gcc::i128_val_in_0_perturbed_big failed!
test 57 arg3 field 0 mismatch
caller: [30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 3A, 3B, 3C, 3D, 3E, 3F]
callee: [38, 39, 3A, 3B, 3C, 3D, 3E, 3F, 40, 41, 42, 43, 44, 45, 46, 47]
Test ui128::c::clang_calls_gcc::i128_val_in_1_perturbed_big failed!
test 58 arg3 field 0 mismatch
caller: [30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 3A, 3B, 3C, 3D, 3E, 3F]
callee: [38, 39, 3A, 3B, 3C, 3D, 3E, 3F, 40, 41, 42, 43, 44, 45, 46, 47]
...
392 passed, 60 failed, 0 completely failed, 8 skipped
上面是Aria在Ubuntu 20.04 x64上运行的FFI abi-checker,她在这个相当重要的、表现良好的平台上测试了一些非常无聊的情况。结果发现,一些整数参数在两个由Clang和GCC编译的静态库之间按值传递失败了!
Aria发现,Clang和GCC甚至不能就Linux x64上_int128
的ABI达成一致。
Aria本来是为了检查rustc中的错误,没想到会在一个重要的、常用的ABI上发现两大主流C编译器的不一致。
试图驯服C
Aria认为,可怕的是对C头文件进行语义解析,只能由该平台的C编译器来完成。即使C编译器告诉了你类型和如何理解注释,但实际上你仍然不知道所有内容的大小/对齐/惯例。那如何与这些乱七八糟的东西进行互操作呢?Aria提供了两种选择。
个选择是完全投降,将你的语言与C进行灵魂绑定,这可以是以下任何一种:
用C(++)编写你的编译器/运行时,这样它就可以用C了
让你的 "codegen "直接发出C(++),这样用户无论如何都需要一个C编译器
将你的编译器建立在一个成熟的主要C编译器(Clang或GCC)之上
但上面这些也只能让你走这么远,因为除非你的语言真的暴露了unsigned long long
,否则你将继承C的巨大可移植性混乱。
这就让我们想到了第二个选择:撒谎、欺骗和偷窃。
如果这一切是无论如何都无法避免的灾难,你还不如开始手工翻译类型和接口定义到你的语言中,基本上就是我们每天在Rust中所做的事情。比如,人们使用rust-bindgen和friends自动化处理一些事,但很多时候,定义会被检查或手工调整。因为人们不想浪费时间,去尝试Phantomderp的定制C构建系统可移植地工作。
在Rust中,Linux x64上的intmax_t
是什么?
pub type intmax_t = i64;
在Nim中,Linux x64上的long long
是什么?
clonglong {.importc: "long long", nodecl.} = int64
很多代码已经完全放弃将C保持在循环中,开始对核心类型的定义进行硬编码。毕竟,它们显然只是平台ABI的一部分!他们要改变intmax_t
的大小吗?这显然是一个破坏ABI的变化!
那phantomderp正在研究的又是什么?
我们讨论过为何 intmax_t
不能被改变,因为如果我们从long long
(64位整数)改为_int128_t
(128位整数),某个地方的二进制会失控使用错误的调用约定/返回约定。但有没有一种方法,如果代码选择了它或其他东西,我们可以为较新的应用程序升级函数调用,而让旧应用程序保持不变?让我们编写一些代码,测试一下透明别名可以帮助ABI的想法。
Aria提出了她的疑问:编程语言如何处理这种变化?如何指定与哪个版本的 intmax_t
互操作?如果你有一些C头文件提到intmax_t
,它使用的是哪个定义?
在此讨论具有不同ABI的平台的主要机制是目标三元组。你知道什么是目标三元组吗?你知道基本上涵盖了过去20年里所有主流桌面/服务器Linux发行版的 x86_64-unknown-linux-gnu
包括什么吗?现在,虽然表面上可以针对这个目标进行编译,并得到一个在所有这些平台上都能“正常工作”的二进制文件,但Aria不相信有些程序会被编译成intmax_t
大于int64_t
。
任何试图做出这种改变的平台都会成为一个新的x86_64-unknown-linux-gnu2
目标三元组吗?如果任何针对x86_64-unknown-linux-gnu
编译的东西都被允许在上面运行,这难道还不够吗?
在不破坏ABI的情况下更改签名
"那又怎样,C永远不会再有进步吗?"不!但也是!因为他们提供了糟糕的设计。
老实说,进行ABI兼容的修改是一种艺术形式。这种艺术的一部分就是准备工作。具体来说,如果你准备好了,做出不破坏ABI的修改就会容易得多。
正如phantomderp的文章所指出的,像glibc( g
是 x86_64-unknown-linux-gnu
中的 gnu
)早就明白了这一点,并使用符号版本化这样的机制来更新签名和API,同时为任何针对旧版本编译的人保留旧版本。
因此,如果你有 int32_t my_rad_symbol(int32_t)
,你告诉编译器将其导出为 my_rad_symbol_v1
,那么任何根据这个头文件进行编译的人,都会在他们的代码中写上 my_rad_symbol
,但针对 my_rad_symbol_v1
链接。
然后当你决定实际上应该使用int64_t
时,你可以把int64_t my_rad_symbol(int64_t)
作为my_rad_symbol_v2
,但保留旧的定义作为 my_rad_symbol_v1
。任何针对较新版本头文件进行编译的人都会高兴地使用v2符号,而针对旧版本进行编译的人则继续使用v1!
但是你仍然有一个兼容性的问题:任何用新头文件编译的人都不能与库的旧版本进行链接,库的V1版本根本没有V2符号!因此,如果你想获得热门的新功能,你就要接受与旧系统的不兼容。
不过这并不是什么大问题,它只是让平台供应商感到难过,因为没有人能够立即使用他们花了这么多时间做的东西。你不得不推出一个闪亮的新功能,然后让大家等待它变得足够普遍和成熟。但为了人们愿意依赖它并中断对旧平台的支持(或者愿意为它实施动态检查和回退)时,你必须坐等几年。
如果你真的想让人们立即升级,那就要谈论向前兼容的问题。这让旧版本的东西以某种方式与他们没有概念的新功能一起工作。
在不破坏ABI的情况下更改类型
那除了可以改变一个函数的签名,还可以改变类型布局吗?Aria表示,这取决于你是如何暴露类型的。
C真正奇妙的一个特点是,它可以让你区分一个已知布局的类型和一个未知布局的类型。如果你只在C头文件中前向声明一个类型,那么任何与之交互的用户代码都不被“允许”知道该类型的布局,并且必须一直在指针后面不透明地处理它。
所以你可以做一个像MyRadType* make_val()
和use_val(MyRadType*)
的API,然后使用同样的符号版本技巧来暴露make_val_v1
和 use_val_v1
符号,任何时候你想改变这个布局,你就在所有与该类型交互的东西上增加版本。类似地,你在MyRadTypeV1
、MyRadTypeV2
和一些类型定义中保留了一些,以确保人们使用“正确”的类型。这样就可以在不同的版本之间改变类型的布局。
如果多个东西建立在你的库之上,然后开始用不透明类型相互交谈,坏事就会发生:
lib1: 制作一个API,接受
MyRadType*
并调用use_val
lib2:调用
make_val
并将结果传递给lib1
如果lib1和lib2针对库的不同版本进行了编译,那么make_val_v1
就会被输入到use_val_v2
中!你有两个选择来处理这个问题:
1.说这是被禁止的,责备那些无论如何都要这么做的人,然后伤心
2.以一种向前兼容的方式设计MyRadType
,这样混合就可以了
常见的前向兼容技巧包括:
保留未使用的字段供未来版本使用
MyRadType的所有版本都有一个共同的前缀,可以让你“检查”你所使用的版本
拥有自定大小的字段,以便旧版本可以“跳过”新的部分
案例研究:MINIDUMP_HANDLE_DATA
微软是这种向前兼容的大师,甚至可以实现在架构之间保持布局兼容。Aria近正在处理的一个例子是Minidumpapiset.h
中的MINIDUMP_HANDLE_DATA_STREAM。
这个API描述了一个有版本的值列表。该列表以这种类型开始:
typedef struct _MINIDUMP_HANDLE_DATA_STREAM {
ULONG32 SizeOfHeader;
ULONG32 SizeOfDescriptor;
ULONG32 NumberOfDescriptors;
ULONG32 Reserved;
MINIDUMP_HANDLE_DATA_STREAM, *PMINIDUMP_HANDLE_DATA_STREAM;
其中:
SizeOfHeader
是MINIDUMP_HANDLE_DATA_STREAM本身的大小。如果他们需要在后增加更多的字段,那也没关系,因为旧版本可以使用这个值来检测头的“版本”,也可以跳过任何他们不知道的字段。SizeOfDescriptor
是数组中每个元素的大小。这让你知道你有什么 "版本 "的元素,并跳过任何你不知道的字段。NumberOfDescriptors
是数组长度Reserved
是一些额外的内存,无论如何他们决定保留在头文件中(Minidumpapiset.h非常谨慎,从不在任何地方进行填充,因为填充字节有未指定的值,而且它是一种序列化的二进制文件格式。我希望他们添加这个字段是为了使结构的大小是8的倍数,这样就不会有任何关于数组元素在标题之后是否需要填充的问题。这是在认真对待兼容性!)
而事实上,微软实际上有理由使用这种版本方案,并定义了两个版本的数组元素:
typedef struct _MINIDUMP_HANDLE_DESCRIPTOR {
ULONG64 Handle;
RVA TypeNameRva;
RVA ObjectNameRva;
ULONG32 Attributes;
ULONG32 GrantedAccess;
ULONG32 HandleCount;
ULONG32 PointerCount;
MINIDUMP_HANDLE_DESCRIPTOR, *PMINIDUMP_HANDLE_DESCRIPTOR;
typedef struct _MINIDUMP_HANDLE_DESCRIPTOR_2 {
ULONG64 Handle;
RVA TypeNameRva;
RVA ObjectNameRva;
ULONG32 Attributes;
ULONG32 GrantedAccess;
ULONG32 HandleCount;
ULONG32 PointerCount;
RVA ObjectInfoRva;
ULONG32 Reserved0;
MINIDUMP_HANDLE_DESCRIPTOR_2, *PMINIDUMP_HANDLE_DESCRIPTOR_2;
The latest MINIDUMP_HANDLE_DESCRIPTOR definition.
typedef MINIDUMP_HANDLE_DESCRIPTOR_2 MINIDUMP_HANDLE_DESCRIPTOR_N;
typedef MINIDUMP_HANDLE_DESCRIPTOR_N *PMINIDUMP_HANDLE_DESCRIPTOR_N;
这些结构的实际细节不是很有趣,除了:
他们只是通过在末尾添加字段来改变它
有一个“新版本”的类型定义
保留了一些也许再次Padding(填充)(RVA是一个ULONG32)
这是一个坚不可摧的向前兼容的庞然大物。它们对填充非常小心,它甚至在32位和64位之间有相同的布局 (这实际上是非常重要的,因为你希望一个架构上的minidump处理器能够处理来自每个架构的minidump)。
案例研究:jmp_buf
Aria对这种情况不是很熟悉,但在研究历史上的glibc中断时,她在LWN上看到了一篇很棒的文章:《glibc s390 ABI中断》,她假设它是准确的。
事实证明,glibc曾经破解过类型的ABI,至少在s390上。根据这篇文章的描述,它是混乱的。
特别是他们改变了setjmp/longjmp使用的保存状态类型的布局,即jmp_buf
。现在,他们知道这是一个破坏ABI的变化,所以他们做了负责任的符号版本化的事情。
但jmp_buf
并不是一个不透明的类型,其他东西都在内联地存储这个类型的实例,比如Perl的运行时间。不用说,这个相对晦涩的类型已经渗透到许多二进制文件中去了,终的结论是,Debian的所有东西都需要重新编译!
这篇文章甚至讨论了将libc版本升级以应对这种情况的可能性:
在像debian这样的混合ABI环境中,SO名称碰撞导致两个libc被加载并争夺相同的符号命名空间,而解析(以及因此选择ABI)则由ELF插值和范围规则决定。这真是一场噩梦。这可能是一个比告诉大家重建并继续生活更糟糕的解决方案。
真的能改变intmax_t吗?
在Aria看来,不完全是。就像jmp_buf
一样,它不是一个不透明的类型,这意味着它被内联到大量的随机结构中,被认为具有大量其他语言和编译器的特定表示,并且可能是大量公共接口的一部分。而这些接口并不在libc、Linux,甚至不在发行版维护者的控制之下。
当然,libc可以适当地使用符号版本技巧来使其API与新的定义兼容,但改变像 intmax_t
这样的基本数据类型的大小,是在一个平台的大生态系统中寻求混乱。
Aria希望被证明自己是错误的,但据她所知,做出这样的改变需要一个新的目标三元组,并且不允许任何为旧ABI构建的二进制/库在这个新三元组上运行。当然有人可以做这些工作,但Aria并不羡慕任何这样做的发行版。
即使如此,面临的还有x64的int问题:这是一个非常基本的类型,而且长期以来一直是这种大小,无数的应用程序可能对它有奇怪的无法察觉的假设。这就是为什么int在x64上是32位的,尽管它应该是64位的:int是32位的时间太长了,以至于完全无望将软件更新到新的大小,尽管它是一个全新的架构和目标三元组。
Aria再次希望自己是错的,但是人们有时犯的错误如此严重,以至于根本无法挽回。如果C语言是一种独立的编程语言?当然可以去做。但它不是,它是一个协议,还是我们必须使用的糟糕的协议。
就算C征服了世界,但也许它再也得不到好东西了。