本文标题里的观点很刺激,它来自国外一位Swift和Rust专家AriaBeingessner,他近日撰写了一篇文章《C不再是一种编程语言》,在技术社区引起了热议。 Beingessner和他的朋友Phantomderp发现彼此在C语言的某个方面都有着高度一致的意见对CABI感到愤怒,并试图修复它们。尽管他们各自愤怒的原因不尽相同,但本文作者想要表达的是:C被提升到了一个具备声望和权威的角色,它的统治是如此地绝对和永恒,以至于它完全扭曲了我们之间的对话方式。Rust和Swift不能简单地‘说’自己的母语或舒适的语言它们必须怪异地模拟C的皮肤,并把自己包裹其中,使肉体以同样的方式起伏。 比喻虽尖锐,依据却不无道理。几乎任何程序要做任何有用或有趣的事情,它都必须在操作系统上运行。这意味着它必须与那个操作系统交互而很多操作系统都是用C编写的。因此,该语言必须与C代码交互,这意味着它必须调用CAPI。这是通过外部功能接口(FFI)完成的。换句话说,即使你从未用C编写任何代码,你也必须处理C变量、匹配C数据结构和布局、通过名称和符号链接到C函数。这不仅适用于任何语言与操作系统的交互,也适用于从一种语言调用另一种语言。 虽然很多人都表示自己喜欢C,但对文章的内容也是表达了认可和赞同。 更精确地说,这篇文章的核心并不是C不再是编程语言,而是C不仅仅是一种编程语言。InfoQ对原文进行了翻译,以飨读者。以下内容节选自原文: C是编程通用语言,我们都必须学C,因此C不再只是一种编程语言,它成了每一种通用编程语言都需要遵守的协议。 本文仅探讨C由实现定义导致的难以捉摸的混乱,这个让所有人都不得不使用的协议已经变成了一个更大的噩梦。外部函数接口 首先,让我们从技术的角度看看。你完成了新语言Bappyscript的设计,它对BappyPawsHoovesFins提供了一流的支持。这是一门神奇的语言,它将彻底改变人们的编程方式! 但现在,你需要用它做一些有用的事情,比如,接受用户的输入,或者输出结果,或者任何可见的东西。如果你希望用你的语言编写的程序成为优秀的公民,可以在主要的操作系统上很好地运行,那么你就需要与操作系统接口进行交互。我听说,Linux上的任何东西都只是一个文件,所以让我们在Linux上打开一个文件。 OPEN(2)NAMEopen,openat,creatopenandpossiblycreateafileSYNOPSISincludefcntl。hintopen(constcharpathname,intflags);intopen(constcharpathname,intflags,modetmode);intcreat(constcharpathname,modetmode);intopenat(intdirfd,constcharpathname,intflags);intopenat(intdirfd,constcharpathname,intflags,modetmode);Documentedseparately,inopenat2(2):intopenat2(intdirfd,constcharpathname,conststructopenhowhow,sizetsize);FeatureTestMacroRequirementsforglibc(seefeaturetestmacros(7)):openat():Sinceglibc2。10:POSIXCSOURCE200809LBeforeglibc2。10:ATFILESOURCE 复制代码 对不起,什么?这是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语言的头文件,比如rustbindgen! 但还是不行: bindgen使用libclang来解析C和C头文件。要修改bindgen搜索libclang的方式,请参阅clangsys文档。关于bindgen如何使用libclang的更多细节,请参阅bindgen用户指南。任何花了大量时间尝试从语法上分析C()头文件的人,很快就会说啊,去他的,并转而用一个C()编译器来做这件事。请记住,仅仅从语法上分析C头文件是没有意义的:你还需要解析includes、typedefs和macros的。因此,现在你需要实现平台所有的头文件解析逻辑,并以某种方式找到与你所关注的环境相对应的DEFINED内容。 就拿Swift这个极端的例子来说吧。在C语言互操作和资源方面,它基本上拥有一切优势。 该语言是由苹果公司开发的,它有效地取代了ObjectiveC,成为在苹果平台上定义和使用系统API的主语言。我认为,在这个过程中,它在ABI稳定性和设计方面比其他任何语言都更进一步。 它也是我见过的对FFI支持最好的语言之一。它可以本地导入(Objective)C()头文件,并生成一个漂亮的原生Swift接口,相关类型会自动桥接到Swift中对等的类型(通常是透明的,因为这些类型的ABI相同)。 Swift的开发者同时也是苹果公司Clang和LLVM项目的构建者和维护人。他们都是C语言及其衍生物方面的世界级专家。DougGregor就是其中之一,以下是他对CFFI的看法: 看吧,即便是Swift也不愿意做这种事。(另外可以参见JordanRose和JohnMcCall在llvm上的PPT去了解Swift为何采用这种方式)。 那么,如果你无论如何也不想使用C编译器在编译时分析并解析头文件,那么你要怎么做?你就要手工翻译了!int64t?还是说写i64。long?。。。。。。C实际上并没有ABI 好吧,这没什么可大惊小怪的:出于可移植性考虑,C语言中的整数类型被设计成大小不固定的。我们可以把赌注押在有点怪异的CHARBIT上,但我们还是无法知道long的大小和对齐方式。 但是等等!每个平台都有标准化的调用约定和ABI! 的确是有,而且它们通常定义了C语言中关键原语的布局!(而且,其中一些不仅仅定义了C类型的调用约定,参见AMD64SysV。) 但这里有一个棘手的问题:其架构中并没有定义ABI。操作系统也没有。我们必须针对特定的目标三元组(targettriple)做工作,比如x8664pcwindowsgnu(不要与x8664pcwindowsmsvc弄混了)。 好吧,会有多少个这样的目标三元组呢? rustcprinttargetlistaarch64appledarwinaarch64appleiosaarch64appleiosmacabiaarch64appleiossimaarch64appletvos。。。 复制代码 还有: 。。。armv7unknownlinuxmusleabiarmv7unknownlinuxmusleabihfarmv7unknownlinuxuclibceabihf。。。 复制代码 还有: 。。。x8664uwpwindowsgnux8664uwpwindowsmsvcx8664wrsvxworks 复制代码 这样的目标三元组总共有176个。我原本打算都列出来,以增强视觉冲击,但实在是太多了。ABI实在是太多了。而且,我们还没有涉及到所有不同的调用约定,比如stdcallvsfastcall或者aapcsvsaapcsvfp! 至少,所有这些ABI和调用约定之类的东西肯定要以机器可读的格式提供给大家使用:冗长的PDF文件。 好吧,至少对于特定的目标三原组,主要的C语言编译器在ABI上达成了一致!当然,也有一些奇怪的C语言编译器,如clang和gcc。 abicheckertestsui128pairsclangcallsgccgcccallsclang。。。Testui128::c::clangcallsgcc::i128valin0perturbedsmallpassedTestui128::c::clangcallsgcc::i128valin1perturbedsmallpassedTestui128::c::clangcallsgcc::i128valin2perturbedsmallpassedTestui128::c::clangcallsgcc::i128valin3perturbedsmallpassedTestui128::c::clangcallsgcc::i128valin0perturbedbigfailed!test57arg3field0mismatchcaller:〔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〕Testui128::c::clangcallsgcc::i128valin1perturbedbigfailed!test58arg3field0mismatchcaller:〔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〕。。。392passed,60failed,0completelyfailed,8skipped 复制代码 这是我在x64Ubuntu20。04上运行FFIabichecker的结果。这是一个相当重要的、表现良好的平台。这里测试的是一些非常令人厌烦的情况,即一些整型参数在两个由clang和gcc编译的静态库之间按值传递而且失败了!甚至是x64linux上的int128ABI,clang和gcc也未能达成一致。该类型是一个gcc扩展,但AMD64SysVABI在一个不错的PDF文件里做了明确定义和说明。 我写这个东西是为了检查rustc中的错误,我并没有指望发现,这两个主要的C编译器在最重要同时人们也最熟悉的ABI上存在不一致! ABI就是谎言。试着把C驯化 因此,对C语言头文件做语义解析是一个可怕的噩梦,只能由那个平台的C编译器来完成,即使你让C编译器告诉你类型以及如何理解注释,但实际上,你仍然无法知道所有东西的大小对齐方式调用约定。 如何与那堆东西进行互操作呢? 你的第一个选项是完全投降,将你的语言与C语言进行灵魂绑定,可以采用以下任何一种方式: 用C()编写编译器运行时,所以它无论如何都能说C语言。让你的codegen直接生成C(),这样用户就需要一个C编译器。基于一个成熟的主流C编译器(gcc或clang)构建自己的编译器。但也仅限于此,因为除非你的语言真的暴露了unsignedlonglong,否则你就会继承C的可移植性混乱。 于是,我们来到了第二个选项:撒谎、欺骗和偷窃。 如果这一切是一场躲不开的灾难,那么还不如开始在自己的语言中手工翻译类型和接口定义。这基本上就是我们在Rust中每天都在做的事情。是的,人们使用rustbindgen之类的工具来自动化这个过程,但很多时候,还是需要检查或手工调整那些定义,生命短暂,实在无法让经过某人奇怪定制的C构建系统可移植。 嘿,Rust,在x64linux上intmaxt是什么? pubtypeintmaxti64; 复制代码 酷!故事结束了!嘿,Nim,在x64linux上longlong是什么? clonglong{。importc:longlong,nodecl。}int64 复制代码 酷!故事结束了!很多代码已经从各个环节中剔除了C,并且已经开始对核心类型的定义进行硬编码。毕竟,它们显然只是平台ABI的一部分!它们要做什么?改变intmaxt的大小吗!?这显然是一个破坏ABI的修改。 哦,对了,phantomderp正在研究的那个东西又是什么? 我们谈下为什么不能修改intmaxt,因为如果我们从longlong(64位整数)改为int128t(128位整数),某些二进制文件就会无所适从,使用错误的调用约定返回约定。但是,有没有一种方法如果代码选用了我们可以在新的应用程序中升级函数调用,而让老的应用程序保持原样?让我们编写一些代码,测试一下透明别名可以为ABI带来什么帮助。是的,他们的文章真的写得很好,解决了一些非常重要的实际问题,但是。。。。。。编程语言如何处理这种变化?如何指定与哪个版本的intmaxt互操作?如果有一些C语言头文件涉及到了intmaxt,它使用哪个定义? 我们在讨论ABI不同的平台时使用的主要机制是目标三元组。你知道什么是目标三元组吗?x8664unknownlinuxgnu。你知道都包括什么吗?基本上涵盖了过去20年里所有主要的桌面服务器Linux发行版。表面上,你可以针对某个目标进行编译,并得到一个在所有这些平台上都能正常工作的二进制文件。但是,情况可能并非如此,比如有些程序在编译时会默认intmaxt比int64t大。 任何试图做出这种改变的平台是不是都会成为一个新的目标三元组?x8664unknownlinuxgnu2?如果任何针对x8664unknownlinuxgnu编译的东西都可以在上面运行,这还不够吗?修改签名而又不破坏ABI 那又怎样,难道C语言就永远不会再改进了吗? 说不是也是,因为它糟糕的设计。 老实说,进行ABI兼容的修改可谓是一种艺术形式。这项工作的一部分是准备。如果你准备好了,做不破坏ABI的修改就会简单很多。 正如phantomderp的文章所指出的那样,像glibc(g是x8664unknownlinuxgnu中的gnu)早就意识到了这一点,并使用符号版本化这样的机制来更新签名和API,同时为任何针对旧版本的编译保留旧版本。 因此,如果有个方法int32tmyradsymbol(int32t),你告诉编译器将其导出为myradsymbolv1,那么任何针对你所提供的头文件进行编译的人,都会在代码中写上myradsymbol,但会链接到myradsymbolv1。 然后,当你确定实际应该使用int64t时,可以把int64tmyradsymbol(int64t)当作myradsymbolv2,但仍然保留旧的定义myradsymbolv1。任何人在针对你的头文件进行编译时,如果是针对新版本就使用符号v2,而针对旧版本则继续使用v1! 但仍然有一个兼容性问题:任何针对新的头文件所做的编译都不能与旧版本的库进行链接!库的v1版本根本没有v2符号。所以,如果你想要热门的新功能,就需要接受与旧有系统不兼容的事实。 不过,这并不是什么大问题,只是会让平台供应商感到难过,因为没有人能够立即使用他们花了这么多时间做出来的东西。你推出了一个闪亮的新特性,却要放在手里等数年的时间,等到大家认为它变得足够普及成熟,愿意依赖它并打破对旧平台的支持(或者愿意为它实现动态检查和回退)。 如果你想让人们立即升级,那么就是向前兼容的问题了。这就需要让旧版本能够适应它们完全没有概念的新特性。修改类型而不破坏ABI 好了,除了修改函数的签名,我们还可以修改什么?我们可以修改类型布局吗? 可以!但也不可以!这取决于你暴露类型的方式。 C语言真正奇妙的其中一个功能是,它让你可以区分布局已知的类型和布局未知的类型。如果你只在C语言的头文件中前向声明一个类型,那么任何与该类型交互的用户代码都无法知道该类型的布局,而必须一直通过指针不透明地对它做处理。 所以你可以开发一个像MyRadTypemakeval()和useval(MyRadType)这样的API,然后利用同样的符号版本化技巧来暴露makevalv1和usevalv1,任何时候你想修改这个布局,都要在与该类型交互的所有东西上修改版本。同样地,你得保留MyRadTypeV1、MyRadTypeV2和一些类型定义,以确保人们使用正确的类型。 很好,我们可以改变不同版本之间的类型布局!对吗?嗯,大多数时候是这样。 如果有多个东西基于你的库构建,它们在类型不透明的情况下相互调用,就会出现糟糕的情况: lib1:开发一个API,使用类型MyRadType调用useval;lib2:调用makeval,并将结果传给lib1。如果lib1和lib2是基于库的不同版本进行编译的,那么makevalv1就会被传递给usevalv2!这时,你有两个选择来处理这个问题: 禁止这样做,警告那些这样做的人,令人伤心。以一种向前兼容的方式设计MyRadType,这样混用就没问题了。实现向前兼容常用的技巧有: 保留未使用的字段供未来版本使用。MyRadType的所有版本都有一个共同的前缀,让你可以检查所使用的版本。有大小自适应的字段,这样旧版本可以跳过新增部分。案例分析:MINIDUMPHANDLEDATA 微软确实是向前兼容的大师,他们甚至让他们真正关心的东西在不同的架构之间保持布局兼容。我最近遇到的一个例子是Minidumpapiset。h中的MINIDUMPHANDLEDATASTREAM。 这个API描述了一个版本化的值列表。该列表以这种类型开始: typedefstructMINIDUMPHANDLEDATASTREAM{ULONG32SizeOfHeader;ULONG32SizeOfDescriptor;ULONG32NumberOfDescriptors;ULONG32Reserved;}MINIDUMPHANDLEDATASTREAM,PMINIDUMPHANDLEDATASTREAM; 复制代码 其中: SizeOfHeader是MINIDUMPHANDLEDATASTREAM本身的大小。如果需要在末尾添加更多的字段,那也没关系,因为旧版本可以使用这个值来检测头的版本,并跳过任何它们不识别的字段。SizeOfDescriptor是数组中每个元素的大小。这也是为了让你知道元素是什么版本,你可以跳过不知道的字段。NumberOfDescriptors是数组长度。Reserved是一个保留字段(Minidumpapiset。h非常严谨,从不使用任何填充字节,因为填充字节的值未定,而且是一种序列化的二进制文件格式。我希望他们添加这个字段是为了使结构的大小是8的倍数,这样就不会有数组元素是否需要在头之后填充的问题了。哇,这才是认真对待兼容性!)事实上,微软使用这种版本化方案是有原因的,他们定义了两个版本的数组元素: typedefstructMINIDUMPHANDLEDESCRIPTOR{ULONG64Handle;RVATypeNameRva;RVAObjectNameRva;ULONG32Attributes;ULONG32GrantedAccess;ULONG32HandleCount;ULONG32PointerCount;}MINIDUMPHANDLEDESCRIPTOR,PMINIDUMPHANDLEDESCRIPTOR;typedefstructMINIDUMPHANDLEDESCRIPTOR2{ULONG64Handle;RVATypeNameRva;RVAObjectNameRva;ULONG32Attributes;ULONG32GrantedAccess;ULONG32HandleCount;ULONG32PointerCount;RVAObjectInfoRva;ULONG32Reserved0;}MINIDUMPHANDLEDESCRIPTOR2,PMINIDUMPHANDLEDESCRIPTOR2;最新MINIDUMPHANDLEDESCRIPTOR定义。typedefMINIDUMPHANDLEDESCRIPTOR2MINIDUMPHANDLEDESCRIPTORN;typedefMINIDUMPHANDLEDESCRIPTORNPMINIDUMPHANDLEDESCRIPTORN; 复制代码 关于这些结构的实际细节,有几个比较有趣的地方: 对它的修改只是在末尾添加字段;最后一个有类型定义;保留一些MaybePadding(RVA是ULONG32类型)。在向前兼容性方面,微软绝对是一头坚不可摧的巨兽。他们对填充如此谨慎,甚至在32位和64位之间采用了相同的布局!(实际上,这非常重要,因为你希望一个架构的小型转储文件处理器能够处理每个架构的小型转储文件。) 好吧,至少它真的很健壮,如果你按照它的规则来,通过引用进行操作,并使用size字段。 但至少可以玩下去。只是在某些时候,你不得不说你的用法不对。微软可能不会这么说,他们只会做一些可怕的事。案例分析:jmpbuf 我对这种情况不是很熟悉,但在研究glibc历史上的破坏性修改时,我在lwn上看到了这篇很棒的文章:glibcs390ABI的破坏性修改。我认为这篇文章比较准确。 事实证明,glibc曾经破坏过类型的ABI,至少在s390上是这样。根据这篇文章的描述,它造成了混乱。 特别地,他们改变了setjmplongjmp使用的状态保存类型(即jmpbuf)的布局。看吧,他们并不是十足的傻瓜。他们知道这是一个破坏ABI的修改,所以他们负责任地做了符号版本化。 但是,jmpbuf并不是一个不透明类型。有些东西内联地存储了这个类型的实例,比如Perl的运行时。不用说,这个相比之下不是很容易理解的类型已经渗透到许多二进制文件中去了,最终的结论是,Debian的所有东西都需要重新编译。 这篇文章甚至讨论了对libc进行版本升级以应对这种情况的可能性: 在像Debian这样的混合ABI环境中,SO名称的改变(SOnamebump)会导致两个libc被加载并竞争相同的符号命名空间,而解析(以及ABI选择)由ELF插值和作用域规则决定。这真是一场噩梦。这可能是一个比告诉所有人重新构建并回归正常轨道更糟糕的解决方案。(这篇文章很不错,强烈建议您读一下。)真的能修改intmaxt? 在我看来,未必。和jmpbuf一样,它不是一个不透明类型,也就是说,它被大量的随机结构内联,被其他大量的语言和编译器视为一个特定的表示,并且可能存在于大量的公共接口中,而这些接口不在libc、linux、甚至发行版维护者的控制之下。 当然,libc可以适当地使用符号版本化技巧,使其API可以适应新的定义,但是,改变一个基本数据类型(像intmaxt)的大小,会在更大的平台生态系统中引发混乱。 如果有人能够证明我是错的,我会很高兴,但据我所知,做出这样的改变需要一个新的目标三元组,并且不允许任何为旧ABI构建的二进制文件库在这个新三元组上运行。当然,你可以这样做,但我并不羡慕任何做了这些工作的发行版。 即使如此,还有x64int的问题:它是非常基本的类型,而且长期以来大小从没变过,无数的应用程序可能对它做了无法察觉的假设。这就是为什么int在x64上是32位的,尽管它应该是64位的:int长期以来都是32位,以至于将软件升级到新的大小完全无望,尽管它是一个全新的架构和目标三元组。 我也希望我的观点是错的。如果C语言只是一种独立的编程语言,那我们就可以毫无顾虑地往前冲。但它实际上不是了,它是一个协议,还是一个糟糕的协议,而我们还必须要用它。 很遗憾,C,你征服了世界,但或许不再拥有往昔的美好。 原文链接:https:gankra。github。ioblahcisntalanguage