“生锈没有稳定的ABI”

2020-08-13 16:44:07

我见过GNOME人员(通常是长期从事C库工作的人)表达了以下方面的担忧:

此外,Rust将其整个标准库与它编译的每个二进制文件捆绑在一起,这使得Rust构建的库非常庞大。

这些都是非常合理的担忧,应该由像我自己这样的人来解决,他们建议基础设施图书馆的大部分应该在铁锈地区完成。

本文的第一部分非常快速地介绍了共享库以及Linux发行版如何使用它们。如果您已经知道这些内容,请随意跳到“铁锈没有稳定”一节。

如果几个程序同时运行并使用相同的共享库(比如libgtk-3.so),则操作系统可以在内存中加载库的单个副本,并通过虚拟内存的魔力共享代码/数据的只读部分。

从理论上讲,如果库有错误修复但没有更改其接口,只需重新编译库,将新的.so放入/usr/lib或其他任何位置,然后就可以使用它了。依赖于该库的程序不需要重新编译。

如果库将其公共接口限制为纯C ABI(应用程序二进制接口),则从其他编程语言使用它们相对容易。这些语言不必处理C++符号的名称损坏、异常处理程序、构造函数以及所有这些复杂性。几乎每种语言都有某种形式的OFC/FFI(外来函数接口),大致意思是毫不费力地调用C函数。

就库而言,ABI是什么?维基百科说,ABI定义了如何在机器代码中访问数据结构或计算例程[...]。Anabi的一个共同方面是调用约定,这意味着要在机器代码中调用函数,您需要从调用和堆栈指针中提取,在寄存器中传递一些函数参数,或者将其他一些函数参数推入堆栈,等等。每个机器体系结构或操作系统通常定义一个C标准ABI。

对于库,我们通常将ABI理解为其编程接口的机器代码含义。哪些函数在.so文件中作为公共符号可用?C枚举值对应于哪些数值,以便将它们传递给这些函数?函数接受的参数的确切顺序和类型是什么?这些函数的结构大小是多少,字段的顺序、类型和填充是什么?OnePass参数是在CPU寄存器中还是在堆栈中?调用方或被调用方在函数调用后是否清理堆栈?

Linux发行版通常会非常努力地在系统中安装每个共享库的单一版本:单个libjpeg.so、单个libpng.so、单个libc.so等等。

当需要更新来修复错误时,无论是否与安全相关,这都很有用:用户只需下载库的更新包,一旦安装,它就会粘在一个新的.so正确的位置,而调用软件将不需要更新。(#39;{#**$$}{#**$$}{##**$$}{##**$$}}{##**$$}。

只有当bug确实只更改内部代码而不更改行为或接口时,这才是可能的。如果错误修复需要更改公共API或ABI的一部分,那么您就完蛋了;所有调用软件都需要重新编译。不负责任的库作者要么在发行版大声抱怨这种变化时学得非常快,要么不学,永远被发行版标记为不负责任的库,它总是需要特殊处理才不会破坏其他软件。

旁白:有时候会更复杂。Poppler(PDFrending库)至少提供了两个稳定的API,一个是基于Glib的Inc,另一个是基于Qt的C++。然而,像texlive这样的一些软件直接使用Poppler的内部库,当然没有不稳定的API,因此随着Poppler的发展,texlive经常中断。有人应该扩展公共的、稳定的API,这样texlive就不必使用库的内部!

有时不是库的不负责任的作者,而是使用库的人发现,随着时间的推移,库的行为会发生微妙的变化,也许不会破坏API或ABI,而且他们最好将特定版本的库与他们的软件捆绑在一起。这个版本是他们测试软件的依据,他们试图了解它的怪癖。

发行版不可避免地会抱怨这一点,他们要么手动修补呼叫软件,迫使它使用系统的共享库,要么成功地让软件接受补丁,这样他们就有了--use-system-libjpeg选项或类似的选项。

如果捆绑版本的库有额外的补丁程序,而这些补丁程序不在发行版的普通补丁程序中,那么这就不能很好地工作。反之亦然,如果发行版的库有捆绑的库没有的额外修复,那么使用发行版的库可能会工作得更好。谁知道呢!这是个案情况。

默认情况下,它确实不是这样的,因为编译器团队希望有自由地在任何时候更改数据布局和锈蚀到锈蚀调用约定,通常是出于性能原因。例如,不能保证结构字段在内存中的布局顺序与它们在代码中写入的顺序相同:

编译器可以根据需要自由地重新排列内存中的结构字段。由于对齐要求,它可能决定将两个布尔域放在彼此旁边,以节省域间填充;可能它进行静态分析或配置文件引导优化,并选择最佳排序。

(旁白:不幸的是gboolean不是bool,但这是因为gboolean早于c99,显然20年前的标准太新而无法使用。(顺便说一句:自从我写了另一篇文章后,Rust‘s repr(C)for bool实际上被定义为C99;’s bool;it;it;不再是未定义的。)。

甚至Rust的数据携带枚举也可以以对C和C++友好的方式进行布局:

这意味着,使用C布局,并使用U8作为枚举的判别式。它的布局将是这样的:

#include<;stdbool.h>;#include<;stdint.h>;enum MyEnumTag{A,B};tyfinf uint32_t MyEnumPayloadA;tyfinf struct{Float x;bool y;}MyEnumPayloadB;tyfinf Union{MyEnumPayloadA;MyEnumPayloadB;}MyEnumPayload;tyfinf struct{uu.。

数据布局的血淋淋的细节在Rustonomicon和不安全代码指南的替代表示部分。

ABI的调用约定详细说明了如何在机器代码中调用函数,以及如何在寄存器或堆栈中布局函数参数。关于X86调用约定的维基百科页面是一个很好的小抄,当您在低级调试器中查看汇编代码和寄存器时非常有用。

我已经写过如何编写Rust代码来导出可从C;调用的函数。在函数定义中使用extern;C&34;和一个#[NO_MANGLE]属性来保持符号名的原始性。这就是Librsvg具有以下功能的方式:

#[NO_MANGLE]pub unsafe extern";C";fn rsvg_Handle_new_from_file(文件名:*const libc::C_char,error:*mut*mut glib_sys::GError,)->;*const RsvgHandle{//...}。

(另外:Librsvg仍然使用一个中间的C库,里面充满了存根,这些存根只调用Rust导出的函数,但是现在有工具可以直接从Rust生成.so,我只是没有时间去研究。感谢您的帮助!)。

从Rust库中导出一个稳定的C ABI是一个人的决定,在C中的类型布局有些笨拙,因为Rust类型系统更丰富,但是只要稍加思考就可以让它工作得很好。当然,没有比用纯C语言设计和维护稳定的API/ABI更麻烦的事情了。

我将把第二个问题归入这里--我们不能以传统的发行版方式共享程序库。是的,我们可以,API/ABI方面,但请继续阅读。

(我作弊:这既是启用了链接时间优化,又是通过在.so上运行strip(1)。如果只使用autogen.sh&;&;,它会更大。)。

它静态链接了Rust的标准库(或者至少是该库vg实际使用的部分),加上所有的Rust依赖项(csparser、选择器、nalgeors、glib-rs、cairo-rs、locale_config、rayon、xml5ever和大量的板条箱)。我可以解释为什么需要每个人:

人造丝-所以过滤器可以使用所有的CPU核心,而不是一次处理一个像素。

或者更确切地说,为什么会发生这种情况,为什么人们认为这是一个问题?

许多Linux发行版都非常努力地确保在安装过程中只有一个系统库的副本。/usr/lib/libc.so、/usr/lib/libjpeg.so等只有一个副本,编译包时会使用特殊选项来告诉它们真正使用系统库而不是其捆绑版本,或者在没有提供构建时选项的情况下对其进行修补。

库中的错误可以在单个位置修复,所有使用它的应用程序都会自动获得修复。

安全漏洞可以在一个地方打补丁,理论上应用程序不需要进一步审核。

如果您维护的是Linux发行版附带的库,并且违反了ABI,您很快就会收到发行版的投诉。

这很好,因为它为可依赖的图书馆创建了负责任的维护人员。这就是为什么Inkscape/GIMP可以有一个稳定的工具包来编写。

这是不好的,因为从长远来看,它会鼓励经济停滞。它表明,我们在libjpeg中得到了一个可怕的、不安全的、容易出错的API,它永远无法改进,因为它需要大量的软件更改;这就是为什么gboolean在二十多年后仍然是一个32位的int,即使所有其他接近C的东西都已经决定布尔值是1个字节。这就是Inkscape/GIMP从GTK2到GTK3要花很多年的时间(好吧,这是因为缺少付费开发人员来做繁琐的工作,但这是因为有了永久稳定的API)。

然而,一个长期稳定的API/ABI有很大的价值。这就是为什么Windows API是皇冠上的明珠;这就是为什么人们可以依赖gliband glibc多年不会破坏他们的代码,并认为它们是理所当然的。

这就是C ABI。即使是C++库也会在这方面遇到麻烦,人们有时会为了方便用C++编写库的内部结构,但却从中导出稳定的C API/ABI。

由于ABI问题,像Python这样的高级语言在调用C++代码时确实有困难。

在GNOME中,我们构建了一个可爱的小宇宙,其中GObjectIntrospection基本上是一个带有大量机器生成注释的C ABI,以使其对语言绑定友好。

尽管如此,我们仍然依赖于其背后的C ABI。看看这条关于推进Rust的C ABI的探索性推特帖子,有很多值得思考的地方。

好的,让我们回到这个问题上。我们为必须输出C ABI的图书馆的单个副本支付什么价格?

可以方便地从C调用的代码,也可能是从C++调用的代码,而从其他任何地方都可以适度地调用到非常不方便的代码。由于大多数新的应用程序代码肯定不是用C语言编写的,也许我们应该重新考虑这里的优先级。

没有泛型或字段可见性这样的语言设施,它们甚至不是现代语言的功能。即使是C++模板也会编译并静态链接到调用代码中,因为无法通过C ABI传递像Array<;T>;中T的大小这样的信息。您想要将一些结构字段设置为公共字段,另一些设置为私有字段吗?你真不走运。

除非仔细阅读C函数的文档,否则不了解数据所有权。函数是否释放其参数?How-with free()或g_free()或my_thing_free()?或者呼叫者只是借给它一个推荐人?数据可以逐位复制还是必须调用特殊函数才能复制?GObject-Insight在其注释中携带此信息,而C ABI则不知道,只是四处运送原始指针。

更多值得深思的注意事项:这条推特帖子说这是关于C++的ABI:";此外,ABI关系到遵循LGPL的实际实用性水平是否与几年前某个项目选择LGPL作为其许可时预期的实用性水平相匹配。当然,该标准也没有讨论LGPL,LGPL对Rust和Go的含义与对C和Java的含义非常不同。很明显,写这封信的目的是考虑到C语言。";

C++有头文件中模板代码多的问题,Rust有泛型单形化产生大量编译代码的问题,有一些技巧可以避免这一点,这些都是库/机箱作者的决定。两者都有一个共同的根本原因,即必须为每个特定用途重新编译模板化或泛型代码,因此不能存在于共享库中。

另外,请参阅这篇关于不同语言如何实现泛型的精彩文章,并认为纯C ABI意味着我们没有这类东西。

此外,还可以了解SWIFT如何实现动态链接,从而获得更多值得思考的食物。这非常粗略地等同于GObject的boxedtype;调用者将值保留在堆上,但通过注释魔术知道类型布局,而库的实际实现可以自由地将值放在堆栈上或任何地方供自己使用。

您可能希望低级值数组Vec<;T>;到处内联,并使用知道向量元素类型的代码。在最好的情况下,元素访问可以内联到单个机器指令。

但并不是所有的东西都需要这种绝对原始的性能,所有的东西都是内联的。如果你不是在一个超级紧凑的循环中,那么传递指向事物的引用或指针并从vtable进行动态分派是很好的,就像我们在GObject世界中喜欢做的那样。

对于库的编译大小,我没有一个很好的答案。如果GNOME-SHELLER合并我的分支来使CSS代码正确化,那么它的二进制大小也会增加相当多。

我的目的是要有一个供Librsvg和gnome-shell共享的Rust机箱来满足他们的CSS样式需求,但是现在我不知道这是一个共享库还是一个普通的Rust机箱。也许它有可能有一个非常通用的CSS库,并且应用程序注册它可以解析哪些属性以及如何解析?有没有可能在不从根本上重新发明libcroco的情况下,将其作为一个共享库来实现呢?我还不知道。我们拭目以待。

如果每个应用程序或最终用户包都有点像一个活生生的有机体,有它自己的周期、行为和器官(依赖库)使之成为可能……。

为什么发行版期望你的机器上的所有生物共享世界的单肺服务、世界的单胃服务和世界的单肝服务?

你知道,与其让每个有机体都有自己略有不同的器官,为它量身定做?我们人类知道如何进行疫苗接种活动和一切;也许我们需要更好的工具来在需要的地方应用错误修复?

我知道这个比喻是非常不完美的,也不是软件中的事情是如何工作的,但它让我想知道。