C 不再是一种编程语言

ACM比赛整理

共 10562字,需浏览 22分钟

 · 2022-04-24


近日,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不再是一种编程语言的观点呢?笔者对原文进行了编译:

整理 | 于轩       
来自 | 程序人生 (ID:coder _life)

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
       #include 
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个三元组。

> rustc --print target-list
aarch64-apple-darwinaarch64-apple-iosaarch64-apple-ios-macabiaarch64-apple-ios-simaarch64-apple-tvos
...armv7-unknown-linux-musleabiarmv7-unknown-linux-musleabihfarmv7-unknown-linux-uclibceabihf...x86_64-uwp-windows-gnux86_64-uwp-windows-msvcx86_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 passedTest ui128::c::clang_calls_gcc::i128_val_in_1_perturbed_small passedTest ui128::c::clang_calls_gcc::i128_val_in_2_perturbed_small passedTest ui128::c::clang_calls_gcc::i128_val_in_3_perturbed_small passedTest ui128::c::clang_calls_gcc::i128_val_in_0_perturbed_big failed!test 57 arg3 field 0 mismatchcaller: [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 mismatchcaller: [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符号,任何时候你想改变这个布局,你就在所有与该类型交互的东西上增加版本。类似地,你在MyRadTypeV1MyRadTypeV2和一些类型定义中保留了一些,以确保人们使用“正确”的类型。这样就可以在不同的版本之间改变类型的布局。

如果多个东西建立在你的库之上,然后开始用不透明类型相互交谈,坏事就会发生:

  • 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征服了世界,但也许它再也得不到好东西了。


版权申明:内容来源网络,版权归原创者所有。除非无法确认,都会标明作者及出处,如有侵权,烦请告知,我们会立即删除并致歉!

浏览 23
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报