“C 不再是一种编程语言”

本文标题里的观点很“刺激”,它来自国外一位 Swift 和 Rust 专家 Aria Beingessner,他近日撰写了一篇文章《C 不再是一种编程语言》,在技术社区引起了热议

Beingessner 和他的朋友 Phantomderp 发现彼此在 C 语言的某个方面都有着高度一致的意见——对 C ABI 感到愤怒,并试图修复它们。尽管他们各自愤怒的原因不尽相同,但本文作者想要表达的是:“C 被提升到了一个具备声望和权威的角色,它的统治是如此地绝对和永恒,以至于它完全扭曲了我们之间的对话方式。”“Rust 和 Swift 不能简单地‘说’自己的母语或舒适的语言——它们必须怪异地模拟 C 的皮肤,并把自己包裹其中,使肉体以同样的方式起伏。”

比喻虽尖锐,依据却不无道理。几乎任何程序要做任何有用或有趣的事情,它都必须在操作系统上运行。这意味着它必须与那个操作系统交互——而很多操作系统都是用 C 编写的。因此,该语言必须与 C 代码交互,这意味着它必须调用 C API。这是通过外部功能接口(FFI)完成的。换句话说,即使你从未用 C 编写任何代码,你也必须处理 C 变量、匹配 C 数据结构和布局、通过名称和符号链接到 C 函数。这不仅适用于任何语言与操作系统的交互,也适用于从一种语言调用另一种语言。

虽然很多人都表示自己喜欢 C,但对文章的内容也是表达了认可和赞同。

更精确地说,这篇文章的核心并不是“C 不再是编程语言”,而是“C 不仅仅是一种编程语言”。InfoQ 对原文进行了翻译,以飨读者。以下内容节选自原文:

C 是编程通用语言,我们都必须学 C,因此 C 不再只是一种编程语言,它成了每一种通用编程语言都需要遵守的协议。

本文仅探讨“C 由实现定义导致的难以捉摸的混乱”,这个让所有人都不得不使用的协议已经变成了一个更大的噩梦。

外部函数接口

首先,让我们从技术的角度看看。你完成了新语言 Bappyscript 的设计,它对 Bappy Paws/Hooves/Fins 提供了一流的支持。这是一门神奇的语言,它将彻底改变人们的编程方式!

但现在,你需要用它做一些有用的事情,比如,接受用户的输入,或者输出结果,或者任何可见的东西。如果你希望用你的语言编写的程序成为优秀的公民,可以在主要的操作系统上很好地运行,那么你就需要与操作系统接口进行交互。我听说,Linux 上的任何东西都“只是一个文件”,所以让我们在 Linux 上打开一个文件。

OPEN(2)


NAME
       open, openat, creat - open and possibly create a file


SYNOPSIS


       #include <fcntl.h>


       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 语言头文件的方式描述接口的类型和函数,并以某种方式做一些事情:

  • 匹配这些类型的布局;

  • 用链接器做一些事情,将函数的符号解析为指针;

  • 用适当的 ABI 来调用这些函数(比如把参数放在正确的寄存器中)。然而这里有两个问题:

  • 你不能真的编写一个 C 解析器;

  • C 并没有一个 ABI,甚至是定义好的类型布局。

你不能真的解析一个 C 头文件

真的,解析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稳定性和设计方面比其他任何语言都更进一步

它也是我见过的对 FFI 支持最好的语言之一。它可以本地导入(Objective-)C(++)头文件,并生成一个漂亮的原生 Swift 接口,相关类型会自动“桥接”到 Swift 中对等的类型(通常是透明的,因为这些类型的 ABI 相同)。

Swift 的开发者同时也是苹果公司 Clang 和 LLVM 项目的构建者和维护人。他们都是 C 语言及其衍生物方面的世界级专家。Doug Gregor 就是其中之一,以下是他对 C FFI 的看法:


看吧,即便是 Swift 也不愿意做这种事。(另外可以参见Jordan Rose和John McCall在llvm上的PPT去了解“Swift为何采用这种方式”)。

那么,如果你无论如何也不想使用 C 编译器在编译时分析并解析头文件,那么你要怎么做?你就要“手工翻译”了!int64_t?还是说写i64long?……

C 实际上并没有 ABI

好吧,这没什么可大惊小怪的:出于“可移植性”考虑,C 语言中的整数类型被设计成大小不固定的。我们可以把赌注押在有点怪异的CHAR_BIT上,但我们还是无法知道long的大小和对齐方式。

”但是等等!每个平台都有标准化的调用约定和 ABI!“

的确是有,而且它们通常定义了 C 语言中关键原语的布局!(而且,其中一些不仅仅定义了 C 类型的调用约定,参见 AMD64 SysV。)

但这里有一个棘手的问题:其架构中并没有定义 ABI。操作系统也没有。我们必须针对特定的目标三元组(target triple)做工作,比如“x86_64-pc-windows-gnu”(不要与“x86_64-pc-windows-msvc”弄混了)。

好吧,会有多少个这样的目标三元组呢?

> rustc --print target-list


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


>_

这样的目标三元组总共有 176 个。我原本打算都列出来,以增强视觉冲击,但实在是太多了。ABI 实在是太多了。而且,我们还没有涉及到所有不同的调用约定,比如 stdcall vs fastcall 或者 aapcs vs aapcs-vfp!

至少,所有这些 ABI 和调用约定之类的东西肯定要以机器可读的格式提供给大家使用:冗长的 PDF 文件。

好吧,至少对于特定的目标三原组,主要的 C 语言编译器在 ABI 上达成了一致!当然,也有一些奇怪的 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

这是我在 x64 Ubuntu 20.04 上运行FFI abi-checker的结果。这是一个相当重要的、表现良好的平台。这里测试的是一些非常令人厌烦的情况,即一些整型参数在两个由 clang 和 gcc 编译的静态库之间按值传递……而且失败了!甚至是 x64 linux 上的__int128 ABI,clang 和 gcc 也未能达成一致。该类型是一个 gcc 扩展,但 AMD64 SysV ABI 在一个不错的 PDF 文件里做了明确定义和说明。

我写这个东西是为了检查rustc中的错误,我并没有指望发现,这两个主要的 C 编译器在最重要同时人们也最熟悉的 ABI 上存在不一致!

ABI 就是谎言。

试着把 C 驯化

因此,对 C 语言头文件做语义解析是一个可怕的噩梦,只能由那个平台的 C 编译器来完成,即使你让 C 编译器告诉你类型以及如何理解注释,但实际上,你仍然无法知道所有东西的大小/对齐方式/调用约定。

如何与那堆东西进行互操作呢?

你的第一个选项是完全投降,将你的语言与 C 语言进行灵魂绑定,可以采用以下任何一种方式:

  • 用 C(++)编写编译器/运行时,所以它无论如何都能说 C 语言。

  • 让你的“codegen”直接生成 C(++),这样用户就需要一个 C 编译器。

  • 基于一个成熟的主流 C 编译器(gcc 或 clang)构建自己的编译器。但也仅限于此,因为除非你的语言真的暴露了unsigned long long,否则你就会继承 C 的可移植性混乱。

于是,我们来到了第二个选项:撒谎、欺骗和偷窃。

如果这一切是一场躲不开的灾难,那么还不如开始在自己的语言中手工翻译类型和接口定义。这基本上就是我们在 Rust 中每天都在做的事情。是的,人们使用 rust-bindgen 之类的工具来自动化这个过程,但很多时候,还是需要检查或手工调整那些定义,生命短暂,实在无法让经过某人奇怪定制的 C 构建系统可移植。

嘿,Rust,在x64 linuxintmax_t是什么?

pub type intmax_t = i64;

酷!故事结束了!嘿,Nim,在x64 linuxlong long是什么?

clonglong {.importc: "long long", nodecl.} = int64

酷!故事结束了!很多代码已经从各个环节中剔除了 C,并且已经开始对核心类型的定义进行硬编码。毕竟,它们显然只是平台 ABI 的一部分!它们要做什么?改变intmax_t的大小吗!?这显然是一个破坏 ABI 的修改。

哦,对了,phantomderp正在研究的那个东西又是什么?

我们谈下为什么不能修改intmax_t,因为如果我们从long long(64 位整数)改为__int128_t(128 位整数),某些二进制文件就会无所适从,使用错误的调用约定/返回约定。但是,有没有一种方法——如果代码选用了——我们可以在新的应用程序中升级函数调用,而让老的应用程序保持原样?让我们编写一些代码,测试一下透明别名可以为 ABI 带来什么帮助。是的,他们的文章真的写得很好,解决了一些非常重要的实际问题,但是……编程语言如何处理这种变化?如何指定与哪个版本的intmax_t互操作?如果有一些 C 语言头文件涉及到了intmax_t,它使用哪个定义?

我们在讨论 ABI 不同的平台时使用的主要机制是目标三元组。你知道什么是目标三元组吗? x86_64-unknown-linux-gnu。你知道都包括什么吗?基本上涵盖了过去 20 年里所有主要的桌面/服务器 Linux 发行版。表面上,你可以针对某个目标进行编译,并得到一个在所有这些平台上都能“正常工作”的二进制文件。但是,情况可能并非如此,比如有些程序在编译时会默认intmax_tint64_t大。

任何试图做出这种改变的平台是不是都会成为一个新的目标三元组? x86_64-unknown-linux-gnu2?如果任何针对x86_64-unknown-linux-gnu编译的东西都可以在上面运行,这还不够吗?

修改签名而又不破坏 ABI

”那又怎样,难道 C 语言就永远不会再改进了吗?“

说不是也是,因为它糟糕的设计。

老实说,进行 ABI 兼容的修改可谓是一种艺术形式。这项工作的一部分是准备。如果你准备好了,做不破坏 ABI 的修改就会简单很多。

正如 phantomderp 的文章所指出的那样,像 glibc(gx86_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

好了,除了修改函数的签名,我们还可以修改什么?我们可以修改类型布局吗?

可以!但也不可以!这取决于你暴露类型的方式。

C 语言真正奇妙的其中一个功能是,它让你可以区分布局已知的类型和布局未知的类型。如果你只在 C 语言的头文件中前向声明一个类型,那么任何与该类型交互的用户代码都无法知道该类型的布局,而必须一直通过指针不透明地对它做处理。

所以你可以开发一个像MyRadType* make_val()use_val(MyRadType*)这样的 API,然后利用同样的符号版本化技巧来暴露make_val_v1use_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

微软确实是向前兼容的大师,他们甚至让他们真正关心的东西在不同的架构之间保持布局兼容。我最近遇到的一个例子是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;


// 最新MINIDUMP_HANDLE_DESCRIPTOR定义。
typedef MINIDUMP_HANDLE_DESCRIPTOR_2 MINIDUMP_HANDLE_DESCRIPTOR_N;
typedef MINIDUMP_HANDLE_DESCRIPTOR_N *PMINIDUMP_HANDLE_DESCRIPTOR_N;

关于这些结构的实际细节,有几个比较有趣的地方:

  • 对它的修改只是在末尾添加字段;

  • “最后一个”有类型定义;

  • 保留一些 Maybe Padding(RVA 是 ULONG32 类型)。在向前兼容性方面,微软绝对是一头坚不可摧的巨兽。他们对填充如此谨慎,甚至在 32 位和 64 位之间采用了相同的布局!(实际上,这非常重要,因为你希望一个架构的小型转储文件处理器能够处理每个架构的小型转储文件。)

好吧,至少它真的很健壮,如果你按照它的规则来,通过引用进行操作,并使用 size 字段。

但至少可以玩下去。只是在某些时候,你不得不说“你的用法不对”。微软可能不会这么说,他们只会做一些可怕的事

案例分析:jmp_buf

我对这种情况不是很熟悉,但在研究 glibc 历史上的破坏性修改时,我在 lwn 上看到了这篇很棒的文章:glibc s390 ABI的破坏性修改。我认为这篇文章比较准确。

事实证明,glibc 曾经破坏过类型的 ABI,至少在 s390 上是这样。根据这篇文章的描述,它造成了混乱。

特别地,他们改变了setjmp/longjmp使用的状态保存类型(即jmp_buf)的布局。看吧,他们并不是十足的傻瓜。他们知道这是一个破坏 ABI 的修改,所以他们负责任地做了符号版本化。

但是,jmp_buf并不是一个不透明类型。有些东西内联地存储了这个类型的实例,比如 Perl 的运行时。不用说,这个相比之下不是很容易理解的类型已经渗透到许多二进制文件中去了,最终的结论是,Debian 的所有东西都需要重新编译。

这篇文章甚至讨论了对 libc 进行版本升级以应对这种情况的可能性:

在像 Debian 这样的混合 ABI 环境中,SO 名称的改变(SO name bump)会导致两个 libc 被加载并竞争相同的符号命名空间,而解析(以及 ABI 选择)由 ELF 插值和作用域规则决定。这真是一场噩梦。这可能是一个比告诉所有人重新构建并回归正常轨道更糟糕的解决方案。(这篇文章很不错,强烈建议您读一下。)

真的能修改 intmax_t?

在我看来,未必。和jmp_buf一样,它不是一个不透明类型,也就是说,它被大量的随机结构内联,被其他大量的语言和编译器视为一个特定的表示,并且可能存在于大量的公共接口中,而这些接口不在 libc、linux、甚至发行版维护者的控制之下。

当然,libc 可以适当地使用符号版本化技巧,使其 API 可以适应新的定义,但是,改变一个基本数据类型(像intmax_t)的大小,会在更大的平台生态系统中引发混乱。

如果有人能够证明我是错的,我会很高兴,但据我所知,做出这样的改变需要一个新的目标三元组,并且不允许任何为旧 ABI 构建的二进制文件/库在这个新三元组上运行。当然,你可以这样做,但我并不羡慕任何做了这些工作的发行版。

即使如此,还有 x64 int 的问题:它是非常基本的类型,而且长期以来大小从没变过,无数的应用程序可能对它做了无法察觉的假设。这就是为什么 int 在 x64 上是 32 位的,尽管它“应该”是 64 位的:int 长期以来都是 32 位,以至于将软件升级到新的大小完全无望,尽管它是一个全新的架构和目标三元组。

我也希望我的观点是错的。如果 C 语言只是一种独立的编程语言,那我们就可以毫无顾虑地往前冲。但它实际上不是了,它是一个协议,还是一个糟糕的协议,而我们还必须要用它。

很遗憾,C,你征服了世界,但或许不再拥有往昔的美好。

原文链接:https://gankra.github.io/blah/c-isnt-a-language

本文文字及图片出自 InfoQ

余下全文(1/3)
分享这篇文章:

请关注我们:

发表回复

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