使用编译期信息为 ELF 文件添加注解¶
标题:Аннотирование ELF-файлов compile-time информацией для последующего анализа
日期:2021/04/30
作者:Михаил Кашкаров
链接:https://www.youtube.com/watch?v=7Ww_vH9y_Mc
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注:不明觉厉,应该是一个 ABI 检查器?还有另一份介绍工具的演讲,都没看先 mark。
主持人:下午好。大家好。祝大家周六愉快。你好。我向大家介绍 Mikhail Kashkarov,他是三星莫斯科研究办公室编译器与神经网络部门的开发人员。在本次演讲中,您将了解到如何通过在 ELF 文件体中插入特殊标记来确定各种特性,例如,在链接和加载阶段确定 ABI 兼容性,数据类型的格式及其兼容性,以及函数属性、其覆盖范围和运行时要求,还有许多其他内容。Mikhail 将讲述这一切的用途,它如何在编译阶段创建并被后续使用,以及三星如何以实际案例将这项技术引入到他们的 GNU GCC 编译器中。今天的专家是 Sergey Platonov,他是俄罗斯 C++ 社区中知名的开发者,也是俄罗斯 C++ 会议的常驻参与者和组织者。另外,别忘了加入本次演讲的 Telegram 群组提问。先生们,再次祝大家早上好,日安,周六好。
Mikhail & Sergey:你好,早上好。
主持人:你们那儿天气怎么样?我理解我们都在俄罗斯的不同地方。
Sergey:还有非俄罗斯地区。
主持人:还有非俄罗斯地区。我这里天气很好。我刚从陪孩子散步中抽身出来参加活动。再次聆听 Mikhail 的精彩演讲。
Mikhail:这不是第一次了,我们现在正在进行演练。
主持人:太棒了。Mikhail,天气如何?
Mikhail:嗯,有点凉。一直在下雨。窗外已经像是晚上了。
主持人:等等,你在哪个时区?
Mikhail:莫斯科时区。
主持人:莫斯科下午五点?
Mikhail:是的,是的。已经很暗了。莫斯科的十一月就是这样。有时候可能连续几个月都见不到太阳。
Sergey:所以我才搬到了彼得堡。
主持人:是的。这真是个很好的笑话。
Sergey:也许我该去搞脱口秀试试?
主持人:不了,谢谢。不确定报酬是否能与你的技术知识相匹配。
Sergey:你可以边编程边讲笑话。直接上台,一边编程一边讲段子。
主持人:是的。双倍薪水。我不知道哪里会为站立喜剧和实时编码支付这样的薪水,真的。
Sergey:是啊,有意思。得找找看。
主持人:我觉得三星喜欢幽默?
Mikhail:很喜欢。很喜欢。但我不太擅长即兴发挥。所以现在可能说不出什么好笑的。
主持人:不,不。没问题。我只是好奇,比如说,有没有什么有趣的名称。你知道吗,比如在 Yandex,有一个全公司的集会,叫做“呼拉尔”(Hural)。“呼拉尔”是突厥民族的一种集会。这是个蒙古词。当蒙古人出征时,他们会召集“呼拉尔”,以便整个队伍共同决定要做什么、怎么做。当你的公司里开会,你称之为“呼拉尔”,这不是很棒吗?“大家都去开‘呼拉尔’?” 是的,真的是“大家都去开‘呼拉尔’”。而且每周一次,一切都很好。
Mikhail:听起来很酷。去开“呼拉尔”?是的,我记得。
主持人:不是在周五开吧?
Sergey:我现在不在 Yandex 了,也记不清是哪天了。不是周五。
主持人:那么,我们来听听关于 ELF 文件的话题吧?开始吗?
Mikhail:是的,我们可以开始了。
Mikhail:好的。能看到幻灯片吗?
主持人:是的,一切正常。
演讲正文¶
Mikhail Kashkarov: 那么再次向大家问好。我叫 Mikhail Koshkarov。今天我想向大家介绍关于 ELF 文件的注解(Annotation)。它是什么,为什么需要它,我们从哪里得到这个想法,以及我们是如何在自己的项目中实施的。最后,我们从中得到了什么,以及最终在我们的部分产品中使用了哪些成果。
我们曾面临这样一个任务:需要验证应用程序的所有库都是用特定的编译选项集编译的,可以说是遵循了特定的“策略”。对于发布版本的产品,需要确保所有必要的发布标志都已启用,例如栈保护、Fortify
选项,以及来自标准库的各种断言。并且,要确保所有应用程序、所有库都是用相同的 ABI 编译的。也就是说,所有影响 ABI 的选项都以相同的方式传递,并使用同一套设置,以防止应用程序在最终设备(如手机、电视等)上运行时发生崩溃。如果我们找到了不满足这些要求的对象文件,我们希望知道它们是哪些对象,属于哪个应用程序,是哪些源文件,哪些函数,以便分析为什么这些策略没有应用到它们身上,并进行修复。
第一种方法是简单地将所有必要的选项传递给所有库和所有应用程序。但由于许多项目由大量的应用程序组成,而所有这些应用程序都有着五花八门的构建系统——CMake、Ninja 等等,还有手写的 shell 文件。因此,很难一眼就确定并确信我们从顶层传递的选项确实应用到了所有文件,没有被某一组应用程序丢弃等等。
我们的第二种方法是提供一个工具,该工具分析最终的二进制文件,比如一个库或可执行文件,以判断选项是否已应用。这个任务并不简单,并且因为并非所有选项都能在这个阶段被检测出来而变得更加复杂。特别是,很难确定那些只应用于特定函数、通过函数属性被覆盖的选项。因此,这种方法也不能完全满足我们的需求。我们需要将所有这些结合起来,想出一种方法来保存所有必要的信息,所有优化——不仅是针对特定函数的,也不仅是针对所有文件的,而是针对源代码片段、函数片段等等。以及这些信息应该如何附加到二进制文件中,如何存储和传递。
当然,所有这些信息都必须是紧凑的,以便于分析,便于后续的信息收集,以及便于通过第三方工具进行收集。
在 Red Hat,为了解决这个问题,他们为 GCC 开发了一个名为 Annobin 的插件,是 Annotate Binaries(注解二进制文件)的缩写。这个插件已经在 Fedora 操作系统及其构建系统中广泛使用,包括在发布版本中。所有进入该系统的新软件包都已经使用这个插件进行编译,它执行了所有必要的功能。因此,这次演讲就是解释 Annobin 是什么,我们如何在自己的项目中应用这个插件并对其进行扩展,将其整合到标准工具链中——即编译器、链接器和加载器,并根据我们的需求扩展了它的适用性。
我希望从演讲的标题来看,大家都知道什么是 ELF 文件,但对于第一次听到这个缩写的人来说,它是在 Linux 系统上的一种可执行文件格式,全称是 Executable and Linkage Format。Annobin 插件——再次强调,是 Annotate Binaries 的缩写——它在这种格式中添加了特殊的标记,以便后续通过各种工具进行处理和读取。
通过在每个二进制文件中添加这些小标记,主要可以解决以下几个目标:
检查所有对象文件的 ABI 兼容性。
检查必要的标志,确保对象文件是使用特定的标志、特定的宏定义等编译的。
通过结合所有这些检查,我们甚至可以得出运行这类应用程序的运行时要求。这具体是如何工作的,我们稍后会展示。
关于 ABI 兼容性的例子包括,比如说,标准 long double
类型的格式。在不同的编译器、不同的工具链上,它的格式大小可能会不同。这里我们还可以举一个从 C++98/03 标准迁移到 C++11 标准的例子,当时 GCC 中 std::string
和 std::list
的 ABI 发生了变化。这个编译器插件同样能够检测到这一点。这种插入少量元数据的功能扩展了其特性,使我们能够判断,比如,对象 A 不依赖于某个特定的 ABI 属性,而对象 B 依赖。这意味着它们可以一起组合。或者,如果它们使用了完全不同且不兼容的 ABI 标准,那么它们就不能被链接、不能运行等等。最简单的例子就是 wchar_t
,如果它的大小不同,我们希望能够在编译和链接阶段就检测到。
总的来说,这个问题可以这样表述:对于一个对象文件 X,我们想在编译和链接阶段,也就是在运行之前,就搞清楚它是否与对象文件 Y 二进制兼容,以避免在应用程序运行时才去 разбираться 出了什么问题,为什么会出现这样的问题。这个问题也涉及到标准基本类型的使用。例如,跟踪基本类型的大小。同样,wchar_t
的例子也适用于此。相应地,我们只想检测那些真正影响兼容性的情况,而忽略那些不影响的,因为在那些情况下什么都不会改变。
我们想找出对象文件是用哪一套选项编译的,并判断它们是否是我们期望看到的那些选项。例如,对于应用程序或库的发布版本,我们希望用特定的安全选项来编译它们,比如 Stack Protector
, Branch Prediction
, -O2
等等。最终,我们希望仅通过最终的二进制文件就能判断,它们是否都是用 -O2
编译的,是否都是用 -fPIE
编译的,或者是否都使用了相同版本的编译器,而不是某个第三方版本。
用 X 和 Y 的术语来说,这可以表述为:对象 X 是否是用选项 Y 编译的?或者,对象 X 是否是用所需版本的编译器编译的?这些选项最好不仅能从编译器中找到,也能从链接器中找到,因为链接器可能会添加自己的选项,比如与符号重定位相关的。我们也希望了解这些信息,以便后续分析最终得到的行为是否符合预期。
所有之前的目标都是为了最终弄清楚,我们能否在目标平台上运行所有编译好的二进制文件或库。
最简单的例子是,应用程序最终所需的栈大小是否合适。所有这些功能,所有被记录下来的信息,当然必须足够简单,必须体积小,并且能够被快速读取和处理,因为它在链接和启动时都会被使用。应用程序的启动在性能方面是非常关键的一环,我们不应该给基本应用的启动带来任何额外的性能开销,比如说,在移动设备上启动浏览器。
实现方式¶
这一切是如何实现的呢?它是通过所谓的 ElfNote 节(.note
section) 实现的。ElfNote 节是一种标准化的、用于在二进制文件中添加各种“笔记”的节区,编译器(如 GCC、LLVM)已经在使用它来记录编译对象文件时所用的版本。这些节区可以扩展以支持新的集合。
为了解决前面描述的任务——ABI 兼容性、记录编译选项、以及提供设备上应用的运行规则——所有这些都被分成了两组所谓的注解(annotations) 或“笔记”。
一小块区域将存储一小组“笔记”,这些笔记仅在设备(例如手机或电视)上启动应用程序时才需要。根据这些笔记,系统将决定是否可以启动该应用。
第二部分则不会被加载到内存中,而是单独存储。这就是所谓的扩展笔记(extended notes),它们专为外部应用程序或静态分析工具进行分析而设计。所有详尽的信息都存放在这里,这些信息在加载应用程序时是不需要的。例如,这里包含了对所有函数的分析以及所有必要的编译选项。这些都是为静态分析准备的。
这些笔记、这些注解,将分别由编译器插入,由链接器稍作扩展,然后以某种方式进行处理。
这两组笔记被分为:
静态的、非加载的:这是较大的一组。它不会进入内存。你可以把它看作是类似于调试信息(debug info)的东西,可以从二进制文件中剥离出来,单独存放,然后再进行分析。
动态的:这是在加载应用程序时使用的小组注解。它们确实会进入内存,然后由动态加载器决定是否应该启动这个应用程序。
我们来谈谈静态注解。再说一遍,静态注解是那些不被加载的、可以被视为扩展调试信息的部分,但它们是以 ELF 文件的笔记格式存在的。它们都有一个属性,描述了其应用范围,即相对于源文件的应用区域。这个区域由函数的起始地址和结束地址定义。它们都以文本格式实现,以便于第三方工具轻松读取。它们没有被序列化,因此在磁盘上占用的物理空间比可加载的注解要大。你可以用手查看它们,通过一些第三方工具进行少量转换后直接阅读。基本上,它们包含各种特殊代码和一个对应表,说明哪个代码代表什么,哪个选项被记录了等等。现在我们来看一个这类笔记的例子。
它们看起来像这样,就像标准的 C 语言字符串,以 \0
结尾。所有注解都以相同的两个字母 ga
开头,这是 gnu attributes
的缩写。它们允许记录文本信息、各种数值、布尔值、开关等等。因此,这种格式非常容易扩展。有一些标准化的属性。例如,应用程序是用哪个版本构建的,具体到某个特定的文件,某个选项是否被应用。在幻灯片中间有一个 stack protector
的例子。这个选项有一个代码,这个代码被记录下来,然后记录了它是否被应用。这里没有显示,但每个笔记还有一个起始和结束地址。这是这些笔记所应用的源文件区域。也就是说,我们可以说,某个属性在整个文件范围内具有某个值,而对于某个特定函数,它有完全不同的属性值,这简直太棒了。
这些属性可以应用于完全不同的函数、完全不同的对象文件,并且仅通过最终的地址范围来区分,不会重叠。最终,在引入这些笔记并进行分析后,我们可以获得对源文件的 100% 覆盖,并能说明在哪个函数中生效的是什么,哪些选项在这里起作用,哪些属性在那里等等。
这些注解也可能是动态的。这是一个较小的区域,以二进制格式序列化,并在读取时使用,用于对象文件的启动。它由动态加载器读取。这些笔记的规范已由 Linux ABI 标准化,每个供应商、每个制造商,或者任何人如果想添加自己的东西,其实现方式都在 Linux ABI 文档中有说明。所有其他读取此类笔记的工具都能读到你的笔记,但它们不知道该如何处理,只会简单地忽略它。也就是说,它们是以扩展或补充的形式被支持的。
这类笔记的例子由属性数组组成。对于其中一些,它就是一个位掩码(bit array),我们用它来说明选项 X 是否被启用。例如,我们想说,某个特定文件只能在我们的环境 2.0 版本上使用,而另一个文件只能在 1.0 版本上使用。在每个文件的动态笔记中,都会有对应版本 1.0 或 2.0 的标记。链接器在拼接对象文件时会读取这些信息,并根据其内部策略对它们进行合并。对于特定类型的数值,比如,只是选择最大值;对于文本笔记和各种其他类型,则取决于我们的实现决定,根据需求来定。
假设我们想记录特定的编译器选项并在启动时检查它们。我们引入一个笔记,它是一个位掩码,其中说明该文件最终是用 stack protector
、sanitizers
以及比如其他一些安全检查选项编译的。所有这些,假设都已在发布的二进制文件中使用。
另一个例子。假设我们想记录设备在运行时所需的栈大小。这个值是加载器在设备上启动时用来分配特定大小栈空间的值。
工具链集成¶
这一切当然很棒,但如何实现它,如何将其集成到构建系统和工具链中呢?在上游版本的这种笔记实现中,它是通过一个编译器插件完成的。这是一个独立的工具,必须与特定版本的编译器同步。我们传递一个插件,它来完成所有记录工作。我们走了另一条路,将其全部集成到了编译器、链接器和加载器中。
编译器为所有对象文件生成这些注解。
链接器将它们合并,并根据自己的规则进行比较。
加载器读取那些较小的、被加载到内存中的笔记,并决定在启动应用程序时接下来该做什么。
我们来详细看一下第一个领域,即与编译器的集成。对于每个被编译的对象文件,我们都会创建注解,这些注解与源代码的区域绑定。它们可以绑定到整个文件,也可以绑定到单个函数,这意味着函数属性、变量属性等在这里被自动支持。文件的每一行都可能有自己的注解。
这一切通过一个简单的编译选项 -f-annobin
来启用。我们想要生成的额外笔记也可以通过一个列表简单地配置,指定我们最终想要记录的内容。这使我们能够灵活控制输出文件的大小,以及我们想要检查或不检查的内容。
在编译器内部,这一切是在一个非常靠后的阶段(pass)记录的,此时所有信息都已可用。也就是说,所有的优化都已完成,调试信息也已生成,我们正准备组合出从生成的源文件中得到的最终版本。这意味着在这里,我们已经可以记录下哪些优化起作用了,哪些没有,哪些失败了,为什么失败了,以及编译器在编译对象文件时拥有的、但之后会丢失的所有信息。这可能是因为现在没有这样的选项,或者仅仅是因为不需要。但对我们来说,了解那里发生了什么可能很有趣。
编译器会在两个地方记录这些笔记。在编译每个函数时——中间未高亮的部分是标准版本编译器(本例中为 GCC)生成的内容。
我们生成函数体、序言(prologue)、尾声(epilogue)。如果我们想记录笔记,那么在序言之前,我们会为接下来的内存区域生成笔记;在末尾,我们也可以生成一个笔记,并标记它是前一个笔记的结束。所以,这可以看作是笔记的开始和结束标记。相应地,在函数内部,如果有一个函数调用,或者某个变量有属性,这个过程也可以展开。通过这种方式,可以实现对所有代码的无遗漏覆盖。
还有一些全局笔记,它们与整个文件绑定。也就是说,从头到尾,我们都用选项 X 进行编译。有一个特定的函数是用选项 Y 编译的,而 Y 可能是选项 X 的一个子集。对于函数 foo
,我们同样会记录:从某个地址开始、到某个地址结束的函数 foo
,它使用的选项不是 X,而是 Y。
为了更清楚地理解,我们来看一个例子:对整个源文件进行清理(sanitize),但其中有一个函数带有 __attribute__((no_sanitize_address))
属性。这意味着这个函数不应该被清理,不应该用 Address Sanitizer 来编译。
对于这个函数,我们的编译器会生成一个小块,这个小块会放在汇编文件中。这个块由伪指令组成。我们描述了存放这个笔记的节区。如你所见,这个块有 start
和 end
。这正是这个笔记所应用的区域的开始和结束。也就是说,在 start
和 end
之间,属性将是“这块内存区域未被清理”。
在编译器将这个块放入汇编代码,链接器将其链接之后,我们可以用第三方工具读取它,也可以手动 dump 出来。例如,readelf
可以读取它并告诉我们这种情况。绿色高亮的部分是启用了 Address Sanitizer 的内存区域。我们看到,它在测试文件 test.c
的某个区域被启用。这个区域正好代表整个源文件。下面一点,有一个笔记说 asan
在某个区域被关闭了。括号里解释说这是某个源文件中的某个函数。也就是说,我们用笔记完全覆盖了整个源文件,读取了所有信息,现在我们掌握了哪部分源代码被清理了,哪部分没有。
太棒了。我们的编译器生成了这样的笔记,为所有文件、每个函数都生成了。接下来是链接阶段。链接时会进行注解的合并。合并并添加链接器自己拥有的新注解。这些主要涉及符号绑定等等。我们不深入细节,演示文稿末尾有这方面的例子。
此时,第一次检查这些注解的时刻到来了。也就是说,在链接阶段,我们想知道,我们是否可以链接这两个对象文件,是否可以把它们连接在一起,根据我们的期望,这是否是允许的。对此有两种策略:严格(strict) 和 非严格(non-strict)。在严格策略下,我们会以错误终止编译,并解释为什么不能这样做。而在非严格策略下,我们只打印一条警告,说某些对象文件彼此不兼容,然后继续。
我们来看看在严格模式下它是如何工作的。这里有三个编译链接过程。
在第一个案例中,我们用 Address Sanitizer 清理整个文件
sanitize.c
。在第二个案例中,我们编译一个没有 sanitizers 的文件
unsanitize.c
。在第三行,我们尝试链接它们。第三行正是链接器开始工作的地方。带着所有生成的笔记,所有生成的注解,并且设置了模式——我们只能链接被清理过的对象,因为这是我们当前策略的要求——那么我们就会得到一个错误。这个错误会直接告诉我们,我们正在链接一个被清理过的对象和一个未被清理过的对象。如果没有注解,这一切将悄无声息地通过,它会链接成功,而我们对此一无所知。然后,如果在这个例子中出现问题,我们就要在运行时去寻找问题,这通常非常昂贵、耗时且费力。
这是错误信息的大图。同样,我们用笔记生成所有源文件和对象文件,然后进行链接。同样的错误出现了。
在我们链接了所有这些,应用了所有这些之后,加载器就有了自己的一套规则。根据这些规则,我们规定,对于某一套笔记、某一套注解,我们只能运行特定的二进制文件。例如,我们希望运行所有为 5.5 版本编译的应用程序,以及所有启用了 sanitizers 的应用程序。
同样,这里也有两种模式:如果不满足条件就抛出错误,或者只是打印警告。这取决于我们是在测试所有应用,还是已经将其发布并在设备上使用。
这是一个没有注解时如何工作的例子。看第一个代码块。我们在一个不支持的环境、一个不支持的平台上运行应用程序,我们的动态链接器会打印一个错误,说在某个库中找不到某个符号等等。而当启用了注解,当我们拥有关于它是为哪个环境、哪个版本的环境编译的所有信息时,我们将得到下面这个代码块中你看到的错误。
在这种情况下,我们得到的不再是 Relocation Error
,而是一个可读的错误信息:我们正试图在 5.5 的环境上运行一个 4.0 版本的应用程序。错误,一切都结束了。这里立刻就能明白,我们拿了一个旧版本的二进制文件,试图在新版本的手机上运行等等。也就是说,查找错误、处理各种问题的时间大大减少了。
当然,这也使得我们能够非常非常早地检测到 ABI 不兼容性。不是在最终设备上崩溃时,而是在此之前,甚至在启动时,或者如果设置了相应规则,在链接时就能发现。
总结流程¶
最终,整个流程是这样的:
我们将其集成到编译器、链接器和加载器中。
在编译器中创建笔记,在链接器中也创建一点。
链接器将它们合并。
我们在加载器中描述规则,说明我们最终想要什么。
最终输出要么是成功启动,要么是一个可读的错误信息,说明为什么我们不能在某个环境下运行某个应用程序。
这套规则是可扩展的,并且可以根据我们测试的版本而变化。也就是说,我们是测试发布版,还是调试版,是针对不同的架构进行测试等等。
笔记的规则在加载器中描述,但它们是通过外部选项动态配置的。也就是说,我们可以检查一切是否都能启动,如果有什么不能启动,但我们又想看看它会如何工作,我们可以在运行时动态地禁用检查,看看会导致什么结果。在这种情况下,不需要重新编译所有东西。
成果¶
插入这样的笔记正好可以回答我们最初提出任务时的问题。
第一,它们是否都使用相同的 ABI? 拥有了所有的笔记和信息,我们可以明确地说出,对于两个对象 X 和 Y,它们是否二进制兼容,是否可以一起运行。我们可以知道基本类型的大小,也就是说,我们可以直接说,在某个第三方版本中
wchar_t
有这样的大小,在我们的版本中是另一个大小。对于发布的二进制文件,通常会设定要求,它们必须在
-O2
模式、stack protector
模式、fortify
模式下编译,所有特定类型的二进制文件都用-fPIE
,其余的用-fPIC
等等。通过这些笔记,我们只需执行一个特定的查询,就可以分析所有文件,并说出哪里满足了要求,哪里没有。对于那些不满足的地方,我们将得到源文件版本和函数的名称,然后我们可以直接去那里开始 разбираться,问题出在哪里,为什么会这样。这也解决了关于使用第三方版本应用的问题,即它们是否是用同一个工具链编译的。如果它们是用其他工具链编译的,我们也会有信息表明笔记缺失,那么我们最好检查一下它们是否满足最终要求。这些笔记中还包含了必要的运行环境信息。例如,在一台设备上,函数是在运行时选择的,可能会使用函数 x 或函数 y,而我们有一个要求,即只能使用函数 x。要如何检查?同样,通过分析所有笔记可以轻松实现。这其中也包括,比如说,栈的大小。我们可以直接说,要求是否满足,是否会有问题,在设备上运行时一切是否会按预期进行。
当然,还有一个额外的好处,就是在不兼容的情况下得到明确的错误信息。我们得到的不再是
relocation error
、runtime error
等,而是直接的信息,比如,这个二进制文件是用stack protector
编译的,那个没有,根据当前策略,我们不能运行这样的版本。
实际应用¶
在上游版本的这个插件中,Fedora 用它来确定是否所有软件包、所有软件包的作者都正确地应用了与安全相关的选项。也就是说,是否所有地方都设置了 Fortify
,是否所有地方都遵循了特定级别的 stack protector
等等。这里还包括检查,例如,是否所有应用程序都用 -O2
编译了,也就是说,直接检查所有东西是否都是用发布标志编译的。这通过简单的静态分析就能完成。我们调用某个外部工具,问它,这个二进制文件是否满足我们的要求?然后我们得到答案:是,或者不是,以及在源代码的哪些区域不满足。
在我们自己的案例中,我们是如何使用它的呢?我们想知道,我们是否用 sanitizers 编译了整个调试项目,如果没有,就得到那些没有应用 sanitizers 的源文件。它们可能在哪些地方没有被应用呢?同样,要么是直接通过函数属性被覆盖,当我们说某个函数不应该被清理时,然后去看看为什么是这样。也可能是各种汇编插入、源代码,或者,比如,在这个阶段也能检测到使用了没有用 sanitizers 编译的第三方库。为什么我们对 sanitizer 的覆盖率如此感兴趣呢?因为当一个被清理过的项目中发生错误,而这个错误与部分应用被清理、部分没有清理有关时,这可以极大地简化调试。错误从被清理的部分,渗透到未被清理的部分,在那里发生了一些事情,这在运行时已经无法检测到,然后又冒了出来。在未被清理的部分,可能会发生非常奇怪的事情,这会严重干扰分析错误的过程,分析它为什么会发生,以及最终谁该为此负责,应该在哪里修复。
成本分析¶
这一切都很棒,我们添加了注解和笔记,但它们的成本是多少?
对于那些加载到内存中并由加载器使用的小型动态笔记,它们由一个固定区域组成,每个对象文件占用 32 字节。与总大小相比,这是一个相当小的开销。相应地,这 32 字节会包含在每个对象文件中,在链接时,它们会根据特定规则合并在一起。这些规则可以很简单,比如,只是选择最大值,像栈区域的大小;也可以更复杂,比如在文本笔记的情况下。又如,对于被清理的区域,我们可以选择文件是部分被清理还是全部被清理,以及我们是否想检查这一点。这里的笔记合并逻辑就包含了好几个步骤。
对于其余的、更详细的、携带文本信息的静态注解,它们的大小要大得多。对于每个对象文件,我们得到了这样的测量结果:在相当大的项目上,它们的体积在 50 KB 以内。这些项目比如是编译器和浏览器。当所有这些来自所有对象文件的笔记合并时,它们会被拼接在一起。这样我们就能理解,最终应用程序的哪个部分来自源代码的哪个区域,以及哪些注解被应用到了这部分源代码上。也就是说,在这种情况下,它们只是被连接在一起,所以最终的大小已经是以兆字节(MB)来衡量了。同样,可以将其与调试信息进行类比。由于这些笔记相当大,它们可以被剥离出来并单独存储。比如,让它们和调试信息放在一起。就是这样安排的。
谢谢大家。关于这些笔记和注解的内容就到这里。接下来我还有一些补充幻灯片,关于它们是如何应用的,以及这个插件的上游版本最终应用了哪些选项。我们也可以谈谈在我们这里最终哪些得到了应用和体现。
谢谢。请注意,所有必要的链接都在最后一张幻灯片上。这是对注解过程的描述,以及原始插件作者 Nick Clifton 的演示文稿,他来自 GCC……抱歉,来自 Binutils。
我们正是把他的工具应用到了我们自己这里。还有源代码的链接,大家可以从那里获取、体验、查看等等。
问答环节 (Q&A)¶
主持人:太棒了,谢谢。聊天室里已经稍微讨论了一下,从哪里可以找到它。
Sergey:是的,大家在 Google 上交换了链接。
Mikhail:是的,如果你直接在 Google 里输入,会得到很多关于它如何工作、如何使用的文章和链接。
主持人:哦,对了,以防万一我 уточню 一下,我想我知道答案。我们一开始遇到了一些小的技术问题,我理解大部分参与者错过了开头。有人问,当你提到 Fedora 的构建系统时,有什么特别之处吗?这是什么不寻常的东西,还是说在本次演讲的上下文中这并不重要?
Mikhail:总的来说,这不重要。问题在于,大型项目通常有多个构建系统。如果我们想把某些选项传递给所有构建系统,我们要么需要查看每个软件包里发生了什么——每个软件包就是一个独立的 C++ 项目或 C 项目,它们的数量可以达到数千甚至更多。要么就做一个通用的方法,当我们能通过编译器、链接器等来传递所有这些。而这样的注解正好允许我们做到这一点。也就是说,无论用什么来编译、用什么来链接等等,只要使用的是同一个编译器、链接器等等。而在构建发布项目时,通常就是这样的。
Sergey:所以说,可以记录像 -DNDEBUG
这类的选项。但总的来说,你们是记录了所有编译器选项,对吗?
Mikhail:默认情况下,不是记录所有选项,而是只记录那些我们感兴趣的。我现在切换到下一张幻灯片。这就是一份拷贝。能看到幻灯片吗?
主持人/Sergey:现在看到了。是的。
Mikhail:这是上游项目版本使用的部分选项,它在用这些注解进行编译时会记录下来。我们提到了 Fortify Source
、glibc 的断言,这里还记录了比如是否启用了异常(exceptions)、Clash
等等。所有可能的链接方式。如果你用两种不同的方式编译,比如一个带异常一个不带异常,然后把它们 сборку 一起,可能会遇到大麻烦。
Sergey:是的,当然。这可能会让你栽大跟头。
Mikhail:而这正好让我们可以在链接阶段就检测到。也就是说,直接在编译时,而不是在运行时,非常非常早。
Sergey:是的,是的,这太棒了。那么,调试符号你们是分开存储的。
Mikhail:这些笔记、这些注解,它们存储在文件的一个单独的节里,不是在调试节里。
Sergey:不,这是另一个问题。我的意思是,原始问题是,例如,你们是否记录 NDEBUG
?我理解你们主要是在发布版本中使用这个,所以记录它没有意义。
Mikhail:实际上问题是,你们是否添加元信息,来表明这是调试构建还是发布构建?如果调试信息中的某些选项与幻灯片上的内容有交集,比如,所有东西都没有 Fortify Source
,那么就会相应地记录下来,即没有 Fortify Source
。也就是说,不是记录某个具体的宏,而是直接记录每个函数、每个文件的具体选项。
Sergey:明白了。
主持人:如果聊天室里有我错过的问题…
Sergey:是的,是的,我们才刚开始。别担心。我在这里。有个问题。我觉得可能有点… 嗯,我来问吧,然后我们讨论一下。问题是,对于 march=native
会记录什么?也就是说,如果只是开启了原生架构优化…
Mikhail:这取决于策略。甚至不应该说策略,而是取决于我们想记录什么。这些注解是可以通过选项高度配置的。也就是说,我们说“记录注解”,然后说“只记录这些注解”。那么相应地,就只有这些会被记录下来。这一切都是可配置的。而且,本质上,注解就是注解。
Sergey:啊,嗯,当然了,是的。这都取决于配置。
Mikhail:如果我们添加了某种复杂的逻辑,比如“如果架构是这样的,就记录这些注解”,这一切都是从外部控制的。
Sergey:明白了,太棒了。下一个问题。我们这里讨论过… 我理解这和 ABI 兼容性有关。问题是这样的:关于在不兼容的 ABI 之间建立兼容性层(compatibility layers)有过讨论吗?你对此有什么看法?
Mikhail:是的。正是因为 ABI 兼容性的问题,才促使 Fedora 出现了这样的工具,这不仅仅是编译选项的原因。我们用它来实现在我们那个时代向 C++11 标准的平滑过渡,并检测是否所有对象文件都正确编译了,有没有什么旧的东西被带进来,可能会在运行时“爆炸”。这之后可以怎么用呢?现在支持对基本类型 long double
, wchar_t
大小的注解,这使得我们可以同步所有可能具有不同基本大小的基本类型的大小。也就是说,ABI 在编译和链接阶段就已经被检测了。如果将来有什么东西被破坏了,如果未来的标准中 ABI 发生了变化,那么就会相应地添加新的注解和检查函数。同样,在迁移到新标准时,我们可以说,ABI 在这里被破坏了,在那里没有,以及为什么会这样。
Sergey:下一个有趣的问题,是针对你的,所以我问你。应用程序怎么能知道它需要多少栈空间,可能会有递归?
Mikhail:栈大小是加载器在应用程序启动前就分配的一个值,是一个固定的区域。这不是每个函数的栈大小,而是整个应用程序的。它可能是 4 KB,也可能是 8 KB,取决于设置。
Sergey:给 8 MB 的栈。是的。好的,下一个问题也是因为错过了开头。
主持人:还有一个问题也因为错过了开头。嗯,可能我们已经讨论过了,大家问在哪里可以找到它,以及具体情况。还有一个关于动态链接的有趣问题。我的理解是否正确,这整个机制是否对动态链接构成了某种障碍?
Mikhail:对动态链接……什么障碍?
主持人:……是否有任何障碍?
Sergey:就是说,如果我们添加……我们脑子里有人在说“立即停止”。
Mikhail:我能听到声音。
主持人:所以问题是,如果使用动态链接,是否可以使用注解?我理解答案是“是”。
Mikhail:如果我们有编译器生成的注解,并且还有可能通过第三方工具添加注解,比如说在编译之后。这可以通过创建一个单独的注解并将其与需要的东西链接起来实现。这一切都会被合并,在启动时这些注解就会被读取。如果我正确理解“动态”的话。
Sergey:我想问题是,比如,如果我有一个 ELF 文件,我加载了一个动态库。
Mikhail:当然,是的。所有可执行文件和所有库中都会写入这些注解。
Sergey:如果我拿一个动态库,一部分加了注解,一部分没加呢?
Mikhail:是的,这是一个非常有趣的案例,而且立刻就出现了。在这种情况下,我们会写明某个库没有注解。接下来该怎么做,取决于设置。要么是严格模式,我们禁止这样的动态链接;要么是宽松模式,我们只打印一条警告:“这个库没有注解,我们不知道里面是什么,不知道它在运行时会如何表现。祝你好运!”
Sergey:我最喜欢的编译器工作模式。就是编译完,最后写一句:“Good luck!” 是的,是的。
主持人:太棒了。这里大家还补充说,现在可以直接在二进制文件里塞 JSON 了。也就是说,没人阻止我们在这样的注解里塞入一大段 JSON。
Mikhail:如果要严肃回答为什么选择 ELF 文件的节区而不是 JSON,那是因为它们已经存在,已经标准化,并且已经被使用了。而且有很多工具可以读取和支持它们。也就是说,支持是开箱即用的。我们利用了已经实现的东西。
Sergey:嗯,那太好了。你能想象吗,原则上还是可以的。编译级别的 JSON 软件开发。如果我们愿意,我们可以把整个编译器理论的流水线都记录到对象文件中,说“对每个函数应用了什么,为什么这里的内联没有成功,为什么这里的某个插入没有成功”,然后去分析它。问题只在于如何分析,因为可以存储的信息太多了。最终,这个过程是逐步进行的。我们想知道是否所有东西都带 -O2
,就记录 -O2
;是否所有东西都带 stack protector
,就记录 stack protector
,等等。问题在于分析。
主持人:这太棒了,真的。作为一个尝过这种苦头的人,当不同的编译选项把一切都搞砸了,然后你根本找不到它们,尤其是一开始你根本不明白发生了什么的时候。
Sergey:所以 rien
,静态注解可以被看作是和调试信息同一领域的东西,它不加载到内存里,只是跟着二进制文件走。你可以把它单独剥离出来,和调试信息放在一起。而动态注解是那些小的,每个文件 32 字节的,它们会被加载到内存里,加载器会读取并比较它们,最终决定如何处理这些信息。
Sertey:相应地,下一个问题是,应用程序本身能看到这些注解吗?或者说…
Mikhail:应用程序本身……问题是,能否读取已经记录下来的注解?这可以通过外部工具完成,比如在 readelf
这个标准的 ELF 文件读取工具里都有。问题在于,应用程序本身二进制文件是否能使用它们?比如,能否将这些小的动态注解作为某种常量来使用?这已经是关于如何在已编译的文件内部读取自己格式的问题了。原则上是可以的,因为我们有指向代码段(.text)的指针,有指向数据段(.data)的指针,我们同样也有指向笔记开始位置的指针。它们都是同一种格式,可以像数组一样简单地读取并输出,如果你真的想这么做的话。
Sergey:有意思。更想玩一玩,得先想出我为什么需要这个。我问的问题是:在优化方面,这可以如何用于优化?你们研究过这个吗?
Mikhail:下一个问题,可以用像 objdump
这样的工具查看这些注解吗?
Mikhail:readelf
应该能帮忙。readelf
会以文本形式显示,并对一些代码进行解码。objdump
我记得它没有解码功能,即具体字段代表什么。我们的一些笔记可能带有代码,比如某个选项是 1,某个选项是 2。readelf
会显示这些代码的含义,而 objdump
可能会显示为原始信息。但尽管如此,也是可以的。
Sergey:很好。当然了。Alexey 总是在试图搞破坏。下一个问题:可以存储在注解中的信息有什么限制吗?体积上、内容上?可以把被俄罗斯联邦通信监管局禁止的视频存在注解里吗?可以这样传递被禁信息吗?
Mikhail:嗯不,问题主要在于是否有任何限制,剩下的只是玩笑话。对于不加载到内存的静态注解,没有任何限制。也就是说,我们可以放任何文本,任何数量的符号,然后再去分析它。问题在于是否需要这样做。但,是可以的。
Sergey:可以这样分发手册、开发者指南,所有这些都放在二进制文件里。下一个问题关于栈大小:如果使用了分割栈或分段栈(split or segmented stack)呢?
Mikhail:这一切同样由注解来描述。也就是说,不是简单地描述某个大小的栈,而是针对特定的细节、特定的划分。注解只是一个从某个区域到另一个区域的源代码标记,附带某些属性。
Sergey:Alexey 写道,动态注解至少可以用于日志记录,比如记录某个版本。
Mikhail:动态注解当然可以。原则上可以用来做日志。嗯,是的。或者,如果你设定了实现可复现构建(reproducible build)的目标,我觉得这是非常有用的东西。例如,你可以通过编译选项来检查你总是在用同样的方式构建,也就是说,编译选项没有从一个编译器版本变到另一个版本,或者这个二进制文件和前一个二进制文件的构建方式相同。
Mikhail:是的,当然。这一切都可以检查。你可以直接读取那里记录的注解,然后确认。同样,在启动时就可以设置这样的规则:所有应用程序都必须有一套特定的集合,如果这个集合中缺少了什么,就报错。
Sergey:很方便。Alexey 已经在幻想如何用这个来确定哪个微服务返回 500 错误了。Alexey,够了。希望这不会被用于这种目的。
Sergey:一个来自我的问题。根据我的经验,当我们提议用一项新技术来修复某个错误时,一开始的实施过程总是非常痛苦。这里有类似的情况吗?能讲讲吗?
Mikhail:实施过程……嗯,这个项目比较简单,因为我们是第一次应用它。在这个项目中,我们负责工具链,包括编译器、链接器和加载器,整个项目都是用我们的工具构建的。所以我们只是把工具集成到工具链里,然后重新构建了一切,之后再进行分析。所以没有痛苦的实施过程。
Sergey:那对于那些在我们控制范围之外编译的第三方应用,你们是怎么处理的?
Mikhail:这时候就会有提示出现,说某些应用程序是没有注解的,请仔细检查它们。
Sergey:也就是说,这项技术确实帮上了忙。你知道,有时候就是这样,你加了个全新的、不习惯的东西,可能会因为某个莫名其妙的错误卡住一个星期,结果后来发现错误很简单,只是因为你还没切换到那种思维模式。你们有这种有趣的故事吗?
Mikhail:最初,这项技术是作为编译器插件应用的。他们使用的 GCC 插件接口其实不怎么丰富,而且有其自身的缺点,比如更新 GCC 版本时插件会坏掉等等。这意味着需要在两个地方维护,单独检查插件,单独检查编译器。在这种情况下,我们遇到的最大的坑是交叉编译。当我们在 x86 的宿主机上编译,然后在 ARM 架构的手机上运行时,我们需要有相应的插件和相应的编译器,它们必须来自不同的架构,并且能够相互通信。总的来说,这是最大的问题,如何实现它,以及为什么我们最终把这一切都集成到了编译器里,正是为了解决这个问题,就是为了不再折腾。
Sergey:太棒了,很合理。我能想象在交叉编译中这得花多少功夫。如果一切顺利,它就能工作;如果出了点问题,那就要花很长时间了。
Mikhail:还有一个有趣的故事。去年,Annobin
这个项目的作者在一个会议上演讲,我们走过去问他:“我们试了你的产品,非常棒,但我们在交叉编译上遇到了问题。你遇到过吗?” 他回答我们:“嗯?能有什么问题?那里一切看起来都很清楚。” 我们为此折腾了半年,最后把它集成到了编译器里。就在一个月前,那位开发者在 GCC 的公共邮件列表里发了封信,说:“事实证明,用这个插件进行交叉编译时确实存在问题。”
Sergey:看来你们替他踩了坑。
Mikhail:非常非常有意思。再次感谢你的演讲。我们还剩一分钟,稍后将在 Zoom 里继续交流。
主持人:聊天室里有人发链接,感谢你的精彩演讲。
Sergey:我也谢谢大家。
主持人:Mikhail,你觉得怎么样?
Mikhail:也谢谢 Mikhail 的演讲,谢谢 Sergey 的提问。我有一个问题,这项技术在多大程度上与 GCC 深度集成?我需要做跨平台、交叉编译,但我用的是 Clang。这个解决方案是不是被硬编码并且和 GCC 强绑定的?
Mikhail:上游版本是一个插件,它与特定版本的编译器绑定。有用于 GCC 的插件,也有用于 Clang/LLVM 的。我们的版本是集成在编译器内部的,目前似乎只以补丁的形式存在,不在上游。你可以……
主持人:好的。我们还剩 10 秒钟进入下一个环节。请大家移步到讨论室。