适用于小型固件的 C++ 异常处理¶
标题:C++ Exceptions for Smaller Firmware
日期:2024/11/06
作者:Khalil Estell
链接:https://www.youtube.com/watch?v=bY2FlayomlE
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
所以,各位,我想教大家如何通过使用 C++ 异常来使你的二进制文件更小。首先,简单介绍一下我自己。如果你在某个地方看到过 Campsy 这个名字,那很可能就是我。我几乎在所有地方都使用这个用户名。关于我自己,我 2017 年毕业于圣何塞州立大学。我是前谷歌员工。我参与过几个你可能听说过的产品。初代 Google Pixel Watch。我是触觉反馈、引导加载程序、扬声器和麦克风的直接责任工程师。最终,当我厌倦了那个工作后,我转到了 Google Pixel Buds 团队,从事代码优化工作,以便我们能在设备中塞进更多功能和错误修复。我也是圣何塞州立大学的志愿者,主要帮助那里的机器人团队。从去年开始,我还是 ISO C++ 的成员。
在我们深入之前,我想先谈谈这次演讲的动机,以及我是如何走到这一步的。大约六年前,我在教授一门叫“计算机工程 146”的课程。这是嵌入式系统导论。在这门课上,我为我的学生开发了一个固件框架。它是用 C++ 写的。我们尽可能地遵循 C++ 核心准则。顺便提一下,这是我为我的学生设计和使用的开发板。
我教给我的学生的第一件事是,当你为微控制器开发并进行裸机开发时,你没有操作系统。这意味着你无法使用 fopen
。你不能。你没有文件系统。你没有网络。你甚至没有终端可以打印输出。你甚至没有时钟或线程。如果你想要任何这些东西,你需要自己实现它们,或者引入一个能为你做的库。
但这里还有一个小问题。你可以引入所有这些库,但可用的内存是有限的。这里是一个在 DigiKey 网站上的搜索(如果你了解嵌入式系统,你知道这个网站)。你可以买到各种不同的电子元件,包括微控制器。如果你查看尺寸小于 1 兆字节的设备,大约有 33,000 种。如果你查看尺寸大于 1 兆字节的设备,大约有 5,000 种。所以拥有超过 1 兆字节闪存的设备并不多。RAM 的情况也一样。当涉及到你的 RAM 时,只有大约 100… 如果你寻找低于 100 千字节的东西,大约有 31,000 种。超过这个容量的,只有 7,000 种。所以,同样,支持真正大容量内存和 RAM 的设备并不多。
在内存如此有限的情况下,我们如何防止内存耗尽或内存碎片?通常,我们会尽量避免,甚至完全禁止动态内存分配。所以 free
, new
, calloc
, delete
,所有这些我们通常都尽量不使用。这也意味着使用这些机制的容器也基本是禁区。
尽管很多其他嵌入式开发人员多次告诉我,“等等,等等。我们确实在初始化时使用动态内存分配。” 这没错。你可以在开始时初始化你所有的东西,只要之后不删除或重新分配就行。如果你在嵌入式领域需要容器,这些容器要么拥有自己的内存而不在堆上分配,你可以使用嵌入式模板库(Embedded Template Library)。它叫 ETL。但我们有一个标准的就地(in-place)向量(vector
),它在大约几个月前在圣路易斯的会议上被投票纳入 C++26。这将是你在嵌入式系统中可以使用的标准库中的一个例子,它拥有自己的内存,并且有一个 try_push_back
(尝试压入),这样你实际上可以测试是否超出了分配的内存。
考虑到这些限制,我们来谈谈固件开发人员不使用 C++ 异常的原因。
需要
malloc
。 你必须动态分配你的异常。你知道,这有点令人沮丧。你知道,这很糟糕。所以,你知道,这是问题之一。我也听其他人说过,它是无界的内存使用。 如果你开始使用异常,你无法知道它会分配多少内存。
还有二进制文件大小增加的问题。 有个人告诉我,如果你使用异常,它会引入整个 STL。所有的
string
、vector
,所有东西都会被引入到你的二进制文件中。无法承受。占用了太多内存。还有,很多人都知道这个,它会引入运行时类型信息(RTTI)。 这会占用很多宝贵的空间,而我们在这些嵌入式系统上并没有这些空间。
还有这些叫做异常表(exception tables)的东西。 甚至异常代码本身也相当复杂。所以最终会占用大量内存。
在时间方面,我们有非确定性问题,主要来自像
dynamic_cast
这样的地方,可能还有其他地方。我把这个放在下面保密,因为如果我告诉你,可能会泄露我接下来要讲的内容,所以我不会告诉你太多。
最后,它可能很慢。 这需要二进制搜索。有帧展开(frame unwinding),这可能很复杂。还有帧评估(frame evaluation),也可能很复杂,在时间上代价很高。
让我们回到课堂一会儿。在我的课上,我为我的学生构建了一大堆不同的库,还构建了这些接口,他们可以用它们来指导他们开发驱动程序。我们将特别看一个非常基础的接口,叫做输出引脚(OutputPin
)。
如果你这辈子从未见过微控制器,我们这里有这个小黑盒子,上面有一点字母标识,还有这些伸出来的小金属突起。我们称之为微控制器引脚。其中一些可能是输出引脚。有了输出引脚,你可以将其状态设置为低电压或高电压。在这个例子中,电压是低的,所以接地位置和这个引脚之间没有电位差。因此,没有电流流动。将其设置为高电压,现在就有电位差了。电流可以流动,LED 灯亮,很好。我们现在可以打开和关闭 LED 灯了。你可以将输出引脚用于各种用途,但人们通常展示的基础东西是 LED 灯。
对我来说,我想,好吧,如果我正在为这个构建一个接口,我的意思是,如果我只是能够打开或关闭某个东西,我只需要一个简单的函数,level
,给它一个真/假值,一个 bool
。如果是真,设为高电平;如果是假,设为低电平。很简单。
但是当我做这个的时候,我不确定返回 void
是否可以。我心想,嗯,我需要返回什么吗?我不会从这个调用中得到任何信息。也许是某种错误状态,但这里可能出现什么类型的错误呢?我为微控制器构建了这么多不同的库,大多数情况下,这个特定的函数是不会失败的。它真的不可能失败。
但为了展示它如何可能失败,让我们引入嵌入式系统的另一个概念:I²C。如果你了解嵌入式系统,你无数次地与它交互过。如果你不了解,我想快速介绍一下。I²C 是一种总线协议。它使用两个引脚在多个设备之间传输数据。在 I²C 中,有控制器和设备的概念。控制器能够发起总线上的对话,而设备只能在被询问时才能说话。
这些对话是这样开始的:控制器会创建一个起始信号。这会向所有其他设备发出一个注意信号,说:“嘿,注意了,我要说话了,寻找你的地址。”然后我们呈现一个地址以及读/写信号。对应于该地址的设备必须确认该请求,并允许传输继续。如果该设备不在总线上或无法处理该类型的请求,它将不确认。
现在,传输的工作原理是:在你确认之后,如果控制器正在向设备写入,控制器会写入一个字节,另一个设备会确认,再写入另一个字节,再确认,依此类推。另一方面,如果你正在从设备读取,设备会将字节写入总线,控制器会确认,依此类推,直到完成。
我为什么提这个?嗯,I2C 可能会伴随几个错误。具体来说,地址未确认和 IO 错误。对于地址未确认,在某些情况下不是大问题。例如,我见过热插拔系统,你可以插入 I2C 设备,如果它们当时不在,没关系。如果它们后来在,也可以。但如果你在为无人机编写软件,而你用来保持方向、保持悬浮的传感器突然不再确认你,你就会直接坠落到地面。除非你有降落伞之类的东西。然后 IO 错误可能来自电磁干扰。它们也可能来自总线上其他以某种奇怪方式错误地控制线路的设备。
现在,我们如何将这两者融合在一起?哇,这问题提得好。让我们考虑一个叫做 IO 扩展器的东西。如果你的微控制器引脚用完了,你会使用这个设备。我想要更多的 IO,想要更多的输出控制引脚,但我的设备上没有必要的引脚了。所以我可以通过 I2C 与这个设备通信,并告诉它:“嘿,我想让你把你的引脚 0 设置为高电压或低电压。”
但问题来了。I2C 端是一个可失败的接口,而输出引脚是一个不可失败的接口。至少在我之前编写的方式下是如此。
所以为了说明这个问题,假设我们有一个非常简单的函数叫做 io_expander_level
,它返回一个 bool
,如果成功则为真,如果失败则为假。你给它引脚号,给它想要的电压电平,然后运行。我有一些实现会去做。但是当我们不成功时,我们该做什么?嗯,因为我们不能使用异常,记住,我真的没太多办法。我可以设置一个全局变量,或者在对象内设置一些状态,然后让另一个函数调用该对象的函数,或者调用一个状态函数来查看,“嘿,这个成功了吗?”
我不喜欢那样,虽然设置 errno
不是一个好选项。我们大家某种程度上都明白了那部分。而且我认为使用一个内部变量并不得不调用一个外部函数(out of line function)并不比 errno
好多少。
然后我想,好吧,但这是个大问题吗?所以我考虑了我的学生可能使用这些输出引脚的用例。
假设他们要控制一个 LED。嗯,我认为在大多数情况下,控制 LED 不是什么大问题。如果 LED 不亮,我的意思是,也许客户会有点不高兴。但如果那个 LED 是像你的“检查引擎灯”,那可能就是个问题了。好吧。
加热元件怎么样?嗯,如果它用于控制某种化学反应,如果不能让它正常工作,那可能是个问题。
然后我想,紧急切断继电器怎么样?至少,我们可能对此无能为力,但至少,在你尝试执行此操作时,我们可以告诉你出错了。
当我意识到这是个问题时,我决定将我所有的 API 切换到使用 std::expected
。我在这里创建了自己的小类型,其中我们有一个状态,预期类型是 void
,错误类型是我的小枚举。然后我们在这里返回 bool
。
对于那些不熟悉 std::expected
的人,它是一种类型,可以是一个实际结果,也可以是某种错误类型。在这个例子中,假设我们试图从某个特定设备获取一些版本号。设备未连接。我们可以返回 unexpected
。前提条件被违反。并使用 std::unexpected
来创建该对象的错误状态。或者你可以直接返回数字。显然这就像幻灯片上展示的,非常简单,但基本就是这个意思。
然后我心想,好吧,我不只是编写 API 然后就把它们忘了。我想思考我的学生或我的用户将如何使用我的 API?所以我想,好吧,如果我使用异常,我的代码会是这样。我想切换一个 LED,给它一个引脚号,给它时间量,设为高电平,等待一定时间,设为低电平,再等待一定时间,就好了。
如果你以前用过 std::expected
,你会期望代码看起来像接下来这样,其中对于每一个函数调用,我都需要检查该返回的状态是否成功。否则,返回一个 unexpected
值,并将你获得的错误向上传播。
这是另一个选项。它在当前的 C++ 中不存在,但我看到这个时真的很兴奋。它是 P2561 R1 中的错误传播器。它使用双问号(??
)。它类似于 Rust 的问号运算符(?
),它会自动将错误传播到你的调用栈上。我记得给研究这个的人发过邮件,但从未收到任何回复。也许那是件好事,因为如果回复了,可能反而导致我没有去做我目前所做的研究。
所以我没有这个(特性),那我可以用什么?呃,宏。在 C++ 中没有其他真正的方法可以做到这一点。如果你有办法,请告诉我,我很想知道。但我只是快速想出了一个小宏,sjcheck
。它为你完成所有的检查。看起来几乎和问号问号(??
)选项一样。只差一点点。
然后我必须问自己,哪些 API 需要返回错误?答案是几乎所有的,因为我为电机编写了接口。看看这个。这里有一个通过 i2c 通信的电机控制器。好的。那么这个呢?这是一个通过 i2c 通信的显示器。几乎任何东西都可以通过 i2c 来表示或通信。所以我遇到了一个爆炸式的问题:任何东西都可以通过 i2c 实现。因此,任何东西下面都可能弹出错误。
我本可以将类型分开,一种是不可失败的,一种是可失败的,但我真的不喜欢那种范式。所以我几乎把所有东西都移到了 std::expected
。
这就是两年半时间对我的学生做这件事造成的后果。这是他们的想法:
他们讨厌无处不在的
if
语句。 以至于我的学生变得很聪明,开始用void
强制转换掉所有错误。程度之深,实际上有点可悲。我的一个学生花了大约一周时间在这个特定问题上。我看着他们的代码说:“嘿,你没有传播任何错误。也许这里的一个小错误可以修复。”所以我们重构了代码,花了一段时间,但我们重构了所有代码,我们传播了所有错误吗?然后我们发现,“哦,是的,你用了错误的地址。这就是你在这里出错的原因,但这也是你的代码不工作的原因。”所以这花了我们一天的时间。另一件让他们烦恼的事是传播性(virality)。 我的每个学生都说:“伙计,我每次都得让每件东西都返回状态或结果。如果我想调用任何可能失败的东西,我必须在所有地方放检查。它无处不在,触及一切。”
他们发现宏相当嘈杂(noisy)。 因为当你查看这段代码时,你实际要做的是:尝试把带有宏的部分空白掉,这样你就能只看到代码中真正发生的事情。你不在乎“悲伤路径”。你至少想知道“快乐路径”做了什么。然后你可以稍后再去看“悲伤路径”做了什么。所以他们不得不做很多这样的事情。然后你再去看看发生了什么。
每个学期,我的学生总是问我:为什么不直接使用 C++ 异常? 它们是语言内置的。它们是为错误处理而设计的。这难道不能消除我们正在处理的很多问题吗?
我会口头告诉他们整个这个图。我拿出文章。我拿出我在网上找到的东西。我甚至会修改构建脚本,从 -fno-exceptions
切换到 -fexceptions
,并展示它如何膨胀,然后说:“嘿,看到了吧。这太糟糕了。你为什么要用这个?”大多数人都接受了。但有些人继续问为什么。比如,为什么它是非确定性的?为什么它这么慢?为什么它必须这么慢?为什么它会让我的代码膨胀?为什么它需要堆?同样,我会想办法回答这些问题。他们会接受,但随着时间的推移,我自己开始不接受这些答案了。我不喜欢我告诉学生关于为什么不应该使用某个东西的信息,但自己却不知道它是如何工作的。它对我来说是个黑匣子。我只是知道别人告诉我的东西。我开始因为告诉学生不要用这个东西而感到内疚,因为我并不确切知道我为什么不能用这个东西。
所以我告诉自己,你知道吗?我要给异常一个公平的机会。我们要一路用异常走下去。 所以现在我们要让异常在 ARM 上工作。我将介绍为了让它在 ARM 上工作需要做的步骤,因为这并非易事。
这是我想做的。我有一个叫做 main
的函数。它只是调用一个叫做 start
的函数。它抛出一些东西。我没有内联它,否则编译器会变得聪明并删掉我所有的代码。我只想最终到达这个缓存块这里。我想最终到达这个小返回区域。
你会遇到的第一个障碍是,当你运行程序时,默认情况下异常是禁用的。 如果你下载 GNU ARM 工具链,它将是禁用的。任何时候你试图抛出(throw
),你立即终止(terminate
)。任何时候。无论你的代码有多有效。它总是会终止。这令人难过的地方在于,ARM 仍然会引入运行异常所需的所有库和所有其他东西。它只是设置成总是终止。所以据我所知,它膨胀了你的代码却什么也没做。是的。
所以我们需要做的第一件事是打破这个障碍。如何启用异常? 我们正在弄清楚它是否可行。他们之前更改了指令。我以前会列出来,但现在变得太不一样了,我无法向你解释,因为我不知道怎么做。但基本上,你下载工具链。找到哪个文件里面有 -fno-exceptions
。然后你把它改成 -fexceptions
。你重新编译它。它应该差不多能工作了。你需要做最后一件事,就是设置 _sbrk
。这是一个底层的 C API,它为 malloc
提供内存。一旦你提供了这个,异常就工作了。你可以做到。
但还有一个问题。你的二进制文件现在变得巨大。看看这个。150,000 字节。我从 2.6k 开始,现在我的二进制文件爆炸了。这… 我只有半兆字节的内存来做我的应用程序。而这将吃掉 30%,就为了能抛出和捕获?不了,谢谢。如果生活就是这样,哎呀,就是这样的,那我就不想用这个了。
但那是我的问题。但我想,但为什么它这么大?所以下一个要打破的障碍:消除膨胀。 看看有没有办法以某种方式摆脱那个膨胀。
所以我做了在我过去所有工作中唯一有意义的事情:你反汇编代码,然后查看任何不是你写的代码。 特别是因为代码很简单,这些代码很可能来自编译器。当你查看时,可能有点难看清,但这里有一个,我看到一个叫做 global_string_callback_adapter
的 API。我的代码里没有任何字符串。这是干什么的?你再四处看看,你会看到 append_num
。我看到一个 sprintf
,我看到一个 string_length
。这些东西在我的代码里做什么?我在这里面没做任何这些事情。然后我发现了 __new_cxx_verbose_terminate_handler
。我想:“啊,明白了。原来如此。”
其他人是否知道接下来会发生什么?如果你抛出一个异常并且它到达了 main
?你会得到这条友好的消息:terminate called after throwing an instance of 'MyError'
。代码膨胀似乎来自于渲染这个字符串。
看看这个。我们能对此做些什么?用五个简单的词,可能开发者会恨我。用五行简单的代码,你可以消除所有这些。所以在 GCC 和 Clang 中有个东西叫做弱符号(weak symbols)。 它们是你创建的、可以被其他代码覆盖的变量。你可能创建过的每个变量都一直是强符号。你必须将它们标记为 weak
以使它们变弱。但如果你创建了相同的变量名,相同的符号,它将覆盖原始的符号。在这个例子中,我用我自己的覆盖了原始的 terminate
处理程序,它只是在这里做一个小循环(while(1)
)。这为我节省了 91% 的二进制大小。所以我现在降到 13K 了。现在我对使用异常相当满意了。150K?不行,不能接受。但 13 千字节?我可以用它做很多事情。
但我们还有另一个障碍。它仍然使用动态内存分配。 我仍然必须设置 _sbrk
。我知道 Malik 开始觉得冷了(指等得不耐烦)。所以我感到内疚。我想,好吧,我可以正常使用异常,对吧?嗯,感觉很奇怪,教我的学生不要使用异常… 哦,抱歉,是不要使用动态内存分配。然后我的机制,在他们的软件开发工具包(SDK)中,却使用了动态内存分配。我想弄清楚是否有办法至少避开这个。不是说我必须解决它,但至少避开它。
所以我看了看 throw
的汇编代码是什么样子的?分配发生在哪里?结果发现有一个叫做 __cxa_allocate_exception
的函数。酷。我能覆盖它吗?可以。和之前一样的方式。它是一个弱符号。所以你用自己的东西覆盖它。我稍后会在演讲中解释为什么这里有一个偏移量(offset)之类的东西。但是的,我至少能够为那个分配器提供我自己的内存。现在至少我觉得我对内存以及可以为异常处理预分配多少内存有了控制权。
最后,最后的障碍,也是让我对使用异常感到安心的部分:我如何应对 RTTI 的幽灵? 因为问题是我的程序现在相当小。它没做什么太多的事情。没有那么多类型。但随着程序开始,随着你的应用程序开始增长,你会有越来越多的类型,越来越多的抽象。这将开始增长再增长。所以这会是个问题。所以你必须弄清楚,我们能禁用 RTTI 吗?或者我们能对此做点什么?
这是我做的。我就想,哎呀,我们就直接把 RTTI 改成 no-RTTI 吧。看看会发生什么。我们将讨论其中的几个选项,并让大家投票,看看他们认为哪个是正确答案。哦,这里快速提一下。C++ 异常需要 RTTI 才能工作。 好吗?
编译器告诉你,你不能在禁用 RTTI 的情况下使用 C++ 异常。你会得到一个小小的编译器错误说“不行,做不到”。
编译器从你的二进制文件中剥离所有与异常处理无关的 RTTI 信息。抱歉,抱歉,抱歉。剥离所有与异常处理无关的 RTTI 信息。
编译器完全沉默(不报错),移除所有东西,如果你调用
throw
,那就是未定义行为。
你们觉得你们明白了吗?谁认为是第 1 项?举手。好的,我看到一只手。有意思,好的。我看到两只手,好的。谁认为是第 2 项?有意思,好的,不少人。谁认为是第 3 项?你们都是 Rustaceans 吗?你们是都混进 CppCon 了吗?因为问题是,你们为什么都假设 C++ 默认是未定义行为?不,答案是第 2 项。是的,这实际上是一个非常有趣的巧合。它使得唯一真正需要出现在你的二进制文件中的类型,是你实际抛出的那些类型。而我没有抛出那么多类型,所以它占用的内存并不多。
我想我还有点额外时间,所以我会告诉你们,虽然幻灯片上没有,但我会告诉你们 RTTI 的内存布局。
如果一个对象不继承任何类型,它占用两个指针。
如果一个对象继承一个对象,它占用三个指针。
如果一个对象继承 n 个父类,它使用三个指针加上 n 个指针(每个继承的类一个指针)。 仅此而已。老实说,在我看来,这并不多。
所以到这个时候,我觉得,嗯,当涉及到这个时,malloc
真的不是什么大问题。我可以覆盖 __cxa_allocate
。“引入整个标准库”那个问题,来自于 terminate
。以前它曾经会引入 IO 流(iostreams)、区域设置(locales)、基础字符串(basic strings)等,但现在情况好多了。ARM 做得很好。最后一件事,那 150 千字节是以前的,现在大约是 84 或 87 千字节。对于 RTTI,嗯,它仍然存在,但相当少。
所以我想,在我看来,这不是不使用异常的大理由。我们这里还有更多项目要看。但此时,我已经看得够多了,我准备好了,这太棒了,我可以继续了。
我把所有的 SJSU dev2 代码都改成了异常处理。移除了所有的宏,摆脱了所有那些东西。但与此同时,我也在开发另一个拉取请求,想把所有东西都彻底移除,改用结果类型。我打算比较和对比,看看在代码大小方面哪个效果更好,或者在可用性方面哪个更好。这里有一条小消息,你可能看不到,我想你可能能看到那个。我这里有一条小消息写着:“决定过渡到异常处理,而不是错误返回类型。对于足够大的项目,异常在代码大小上更小,并且在未抛出异常时速度更快。”
以下是我观察到的。当我构建时,让我们把这个放回去。当我构建我所有的演示程序以确保我的代码正常工作,当我测试所有像闪烁灯、Hello World 之类的小演示时,它们的尺寸变大了,这在我的预料之中。这是异常,它应该膨胀,对吧?但后来我尝试了几个其他项目,比如一个我让学生作为他们的期末项目构建的 MP3 播放器。而那些项目的尺寸反而变小了,这让我大吃一惊。
那个代码是为一个 64 千字节的微控制器设计的,我记得二进制大小大约是 56K。我丢失了代码,所以很遗憾无法演示。但当我看到代码降到 40 多 K 时,我的脑子被震撼了。我超级兴奋,但我自己保守了这个秘密好几年。主要是因为,第一,我不知道是否真的有人关心这个发现或这点信息。我也害怕 Rust 社区来找我麻烦,因为如果我展示任何对 C++ 积极的东西,我敢肯定他们会来找我,然后在任何社交媒体上把我彻底淹没(washed)。所以,我一直避免提它,直到去年的 CppCon。
这是在 2023 年我参加 ISO C++ 会议的时候。昨天,大约在同一时间,Patrice(Roy)正在做他关于 SG14 中发生事情的演讲。喝点水。他讨论了多态内存资源向量(pmr::vector
)在嵌入式系统中使用的可能性(或者基本上是用于嵌入式系统)。我们来回讨论了这个问题,我告诉他们,嗯,vector
和 push_back
不能告诉你是否出错了,这有些问题。你可以在那时终止(terminate
),但我想很多开发者不会满意,如果他们的 vector
无法分配就恐慌(panic)。然后我告诉他们这个:“真可惜,嵌入式开发者不用 C++ 异常,因为它会让他们的二进制文件更小。” 他说:“等等,等等。这与所有常识相悖。你什么意思?” 我向他们解释了刚才向你们解释的整个故事。他说:“你有这方面的数据吗?” 我说:“嗯,我丢了旧文件,但我可以合成一些。” 他说:“周三的 ISO 会议上来展示一下你的成果。” 我说:“酷,好的。” 所以我花了 CppCon 会议期间相当一部分时间来做那个。
这是我的假设。我的想法是,如果我观察到这个,这可能意味着什么?好吧,让我们想想结果类型的成本。我们可以把它看作 A * N
,其中 A
是每次函数调用的检查的平均成本(需要多少字节来做一次函数调用的检查),N
是整个代码库中你检查过的函数调用的数量。
对于异常处理,它是 B * M + E
,其中 E
是实时展开(live unwind)的成本,即进行传播所需的机制。B
是异常元数据的平均成本。M
是你代码中非叶子函数的数量。我的断言是 A * N
将大于 B * M
。
嗯,一方面,你不能肯定地说,但至少可以说,而且我想大家都能同意,你调用的函数可能比你代码中存在的函数数量更多,对吧?所以我想,好吧,至少这些数字更大,存在这种相等或不等式关系。我期望我会得到类似这样的结果:在 x 轴上检查次数,在 y 轴上二进制大小的增加。会有一个交叉点,在那里使用异常在代码大小上变得比使用结果更优。
这是实验。我生成了大量的 C++ 代码。它会随机定义一大堆不同的类,包括有平凡析构函数的和没有的。它会创建一系列相互调用的函数。这些函数也会创建对象并调用这些对象上的 API。我有一个叫做 except.cpp
的文件使用异常处理,另一个叫 result.cpp
。这两个文件彼此是同构的。它们做完全相同的事情,除了错误传播的方式不同。我只确保代码专注于错误传播。因为根据我的经验,老实说,我看到的错误传播代码比错误处理代码多得多。我想随着我们进一步深入,你们会同意我的。
这里有一个代码示例。这里我们有一些函数组。它是随机生成的。它创建一个 volatile
变量,因为需要副作用。否则,很多东西会被内联。即使你放 __attribute__((noinline))
,它也会变得非常聪明。GCC 在消除你的整个程序方面非常聪明,如果它在单个翻译单元里。创建一个对象,调用一些 API,创建另一个对象,调用一些 API,依此类推,调用另一个函数。std::expected
也是一样,只是用了 if
语句来检查。
当我创建了一大堆这些不同的文件,编译后查看它们的二进制大小时,我得到了这个。红线是结果类型,蓝线是异常,黄线是随着检查次数增加的大小差异。你可能看不到顶部的数字,就是这里这个。我非常喜欢这个数字,因为它是 8,067,非常接近实时展开(live unwind)的大小。如果你把它们都收集在一起的话。然后这里是 -16.5 * x
。那将对应于你代码中每次检查的成本。不是说它真的是 16.5,但这是 Google Sheets 计算出来的。
凑近一点看,你可以看到这里,大约在 550 次检查左右,你就越过了那个点。这在你的代码中并不多。我肯定写过单个类就有多达 50 次检查。但我只是想让大家明白,这些图表都不是为了说服你们。我不觉得它们很有说服力。我只是觉得它们在那里展示了一些东西。
如果我想说服你们,哦,我需要向你们展示从 throw
到 catch
异常是如何工作的。这样你们可以看到所有不同的数据结构是如何相互作用,并了解整个过程的构成。所以我们开始吧。
我不打算在这里涵盖的东西:
没有嵌套异常。
除了基于表的异常以外的任何东西。因为老实说,我认为基于表的异常是最好的。
考虑以下情况。我们有一个错误(MyError
)。我们有一个叫做 foo
的函数。foo
调用 bar
和一个 try-catch
。bar
创建一个可析构的对象(T
)。我们调用 baz
。baz
抛出一个错误(throw MyError()
)。
在异常处理中有三种类型的函数:
叶子函数:不调用任何其他函数,不包含析构函数,不包含
try-catch
块。平凡函数或透明函数:调用其他函数。不包含任何析构函数,内部没有任何
try-catch
块。可重入函数:调用其他函数,包含析构函数,并有
try-catch
块。它们可以被重新进入以销毁对象或处理错误。
如果我们对这里的所有函数进行分类,foo
和 bar
将是可重入的,baz
将是透明的。这里没有叶子函数,因为那会很无聊。它们根本不参与异常处理或一般的错误处理。
为了理解异常处理,你需要稍微了解一下调用约定,特别是 ARM 是如何工作的。我不想让你们太无聊,但有 16 个寄存器。
r0
到r3
用作临时寄存器或作为函数的输入参数或函数的返回值。而
r4
到r11
是保留寄存器。这些是函数依赖的寄存器,在调用函数时和返回时需要保持相同状态。然后我们有栈指针(
sp
),基本上指向你在栈中的位置。链接寄存器(
lr
),包含函数的返回地址。程序计数器(
pc
),告诉你当前在应用程序中的位置。
这里是一小段反汇编,不要太在意。在 foo
的开头,我们要 push {r4, lr}
。这意味着我们将在函数中使用 r4
。我们不想破坏调用 foo
之前 r4
的值。所以我们要把它保存到栈上。我们还要把返回地址保存到栈上,以便之后可以弹出(pop)回来。我们可以在这里做一些事情。我们可以设置,假设 r4
包含这个函数的返回值。最后,我们把 r4
弹回原位,把链接寄存器弹回程序计数器以跳转回去。ARM 的调用约定基本上就是这样。除此之外真的没太多东西了。
现在我们有了 ARM 寄存器和基本 RAM 的概念,我们可以描述系统的状态会是什么样子。我们这里有个小例子。我们在这个位置。让我们看看它。
在 Itanium C++ ABI 中,当你调用 throw
时,它会调用 __cxa_allocate_exception
,参数是你想要分配的对象的大小,在这个例子中是一个字节(sizeof(MyError)
)。这里的所有这些代码是为了构造那个字节以及设置对 __cxa_throw
的调用。对 __cxa_throw
的调用接收指向被抛出异常的指针,指向该异常类型信息(type_info
)的指针(这样你才能与你的 catch
块进行比较),以及指向该对象析构函数的指针(如果该对象没有析构函数,则为空指针)。
__cxa_allocate_exception
是这样工作的:当你使用它时,它不只是分配你的抛出对象,它还分配了足够空间给你的抛出对象加上这个叫做异常头的东西。异常头被展开机制用来记住系统的状态以及它在展开过程中的进度。
异常头包含以下部分:
一个你的 CPU 的虚拟版本(virtual registers)。异常本质上所做的就是计算如果你走了不同路径 CPU 之前的状态会是什么。所以我们需要记录所有 CPU 的状态,以便稍后可以重新塞回 CPU。
我们还保留一个指向类型信息(
type_info
)的指针,因为稍后我们需要它。我们保留一个指向析构函数的指针,以便之后清理。
我们有一些缓存,可以帮助你加速处理。
我想让你们理解,这不是 Itanium ABI 的异常头定义。它们有自己的定义。这是我为自己实现定义的。你可能想知道为什么不直接调用 malloc
。原因是为了 ABI 兼容性和灵活性。看,如果我将来以任何方式调整异常头的大小,比如我们意识到我们想缓存更多信息来提高速度,或者我们意识到我们可以移除一些寄存器,不再需要它们用于异常展开。如果我们使用 malloc
并把它直接烘焙进你的二进制文件,然后我们以后更新它,头的大小会完全不对,展开机制会混乱,你会出问题。另一个好处是,你可以在一次分配中完成所有事情。而不是先分配抛出对象,然后在 throw
中再为异常头分配一次。
让我们深入到我版本的 __cxa_throw
是如何工作的。
活动异常(active exception)。 我们需要设置它,以便
current_exception
能工作。它是一个线程局部存储(thread local stored)变量。我知道 Ben Craig 告诉过我需要摆脱 TLS。我正在努力。别担心。获取抛出类型(thrown type)。 因为我们知道异常头在它后面,这个函数
extract_exception_header
所做的就是从抛出对象的指针地址减去异常头的大小,就得到了它。从那里你可以设置类型信息(
type_info
),你可以设置析构函数(destructor)。最后,我们可以快速立即展开(immediate unwind)
__cxa_throw
自身,以进入调用这个函数的那个函数的 CPU 状态。
之后,我们得到类似这样的东西:我们的程序计数器指向 __cxa_throw
,因为那是我们当前的位置。我们的调用者是 baz
。是它调用了我们。我们的栈指针(sp
)被初始化了,你看到这个深橙色区域了吗?是的,那是可见的,酷。那个小小的深橙色区域?那是指示当前栈在哪里的标记。其他寄存器,r6
到 r12
,我不关心,我们在这次演示中不会使用它们。r4
和 r5
状态未知。我们还有一些来自输入参数的其他项。
它要做的事情之一是为了展开,是取链接寄存器(lr
)并把它放回程序计数器(pc
),这样我们就可以从那个点开始进行评估。最后,我们引发(raise)异常。
从这里开始,我们有几步:
我们从一个圆圈开始。
我们定位异常入口(exception entry)。异常入口是关于一个函数的信息,关于如何展开它,关于是否有任何析构函数需要调用,以及是否有任何
try-catch
需要处理或评估。从那个入口的信息,我们可以问它:“我们应该拒绝展开吗?” 比如,你是
noexcept
吗?如果是真,终止(terminate
)。如果不是,检查:“你是可重入的吗?” 如果是假,展开这个函数。但如果它是真,就评估这个函数。检查你是否应该进入它。“嘿,我匹配到了一个
catch
吗?也许我们应该进入它。” 或者,“嘿,是析构函数调用吗?也许我应该进入它。”从这里,我们进入悲伤路径。
在悲伤路径中,我们有两条路可以走。我们可以命中一个叫做
__cxa_cleanup_end
的函数。这是在调用析构函数后,为了让你回到异常流(exception flow)中被调用的。或者你可以命中一个叫做__cxa_begin_catch
的函数,它会带你回到快乐路径(happy path)。
现在我们已经讲完了这个小机制的工作原理,我们可以进入第一部分:定位异常入口(exception entry)。
异常索引(exception index)看起来是这样的,有两个符号叫做 __index_start
和 __index_end
。索引中的每个条目长度是八字节,由 ABI 定义。前四个字节是函数地址。技术上,它是一个到你函数位置的偏移量。这有充分的理由,但就是函数地址。下一部分,另外四个字节,将是异常数据(exception data)的偏移量。
在我们继续之前,我们需要谈谈索引的排序。它是相对于你的代码中函数的存在顺序排序的。所以如果你的代码从地址 0 开始,地址 0 的第一个函数是 foo
,下一个是 bar
,再下一个是 baz
。那么异常索引的顺序也将是 foo
,然后 bar
,然后 baz
。这样,如果我的程序计数器(pc
)在其中一个函数的中间,我可以进行二分搜索(binary search)来找到那个条目。我只需要检查一个条目会有一个函数起始地址,另一个条目会有另一个函数起始地址。如果我的程序计数器在这两个函数起始地址之间,它就告诉我我在左边的那个函数里。
这个结构的工作方式是:你有一个 uint32_t
,它是一个地址(func_addr
),另一个 32 位值是一个带标签的联合体,介于一些元数据或内联数据之间。最高有效位告诉你它是哪一种。所以我们将首先介绍内联数据。
想象一下你有一个透明函数,没有析构函数或任何 try-catch
,你把它标记为 noexcept
。它所做的就是用数字 1
替换掉原本在那里的偏移值。数字 1
向展开机制发出信号:“嘿,如果你看到这个并且需要展开它,直接调用 terminate
。”
你也可以将函数的展开指令直接嵌入到索引中,如果只需要很少(三个或更少)指令的话。所以如果你的函数需要三个或更少的展开指令,并且它是透明的,你可以直接把它内联到异常索引中。这样做是为了节省内存。
现在我们讨论指令,不妨深入一下。我们不会深入讨论所有这些是如何工作的细节,但大体上,指令是 8 位的段(segments)。关于 ARM 还有一点要知道:所有指令,如果是 ARM 32 和 64,是 32 位的。如果是 Thumb-2,是 16 位的,有些指令是 16 位或 32 位(即 2 字节或 4 字节)。展开指令不需要做太多事。它们需要减少栈、弹出一些寄存器(pop some registers),差不多就这些。所以它们只需要一个字节的信息来表示。所以它们是指令的压缩版本,模拟了从函数返回的过程。ARM 异常 ABI 文档指出,大多数函数都适合用 3 字节的展开信息。而你在 ARM 上可能编写的任何函数,最长的展开指令最多是 7 字节。
我们有添加栈、减少栈、弹出寄存器、将栈设置到另一个寄存器、完成(finishing,表示我们完成展开了)的指令,最后是拒绝展开,如果你做了清理然后发现需要终止你的应用程序,这个指令会被剔除。这不应该发生。
现在我们可以深入了解这些东西的细节了。它们被称为个性数据(personality data)。那个内联个性数据的最高有效位是这一位(msb
),是 1
。如果这个位是 0
,意味着这是一个偏移量。如果它是 1
,意味着这是内联数据。位 28 到 30 是保留的。位 24 到 27 是个性索引(personality index)。这些个性索引告诉你接下来的字节的格式。
如果它是 0
,那么这个 32 位字中的下一组字节将是 instruction1
、instruction2
和 instruction3
。
如果索引是 1
或 2
,你将使用长个性数据(long personality data),它会给你一个长度(,表示展开这个特定函数所需的额外字数(words)。
不,这个实际上只能是 1
或 2
,因为他们发现你最多只需要 7 条指令。这其实有点令人遗憾,因为我看这个的时候想,我们不需要那里有长度。如果我们移除了长度,那么它就会上移一位,我们就不需要这里的填充了。那本可以为我们节省额外的 4 字节,但我们不可能总是拥有好东西。
现在我们拥有了展开 baz
所需的所有信息。我们对索引进行二分搜索。我们会找到它的条目,然后评估内联数据。我们检查这里,我们查看最高有效字节。它是 1
,是内联的。个性是 0
。这里唯一有意义的指令(除了 finish
)是这个:弹出寄存器 r4
到 rn
的某个范围,以及链接寄存器(lr
)。
执行这个之后,我们会看到 bar+14
被放入了链接寄存器(lr
)。而 bar
的 r4
值已经被移入了 r4
。栈指针(sp
)也相应地向上移动了。
现在我们要对 bar
做同样的事情,但我们意识到它是可重入的,因为它有一个结构需要调用析构函数。所以我们不能用它做任何事情。我们需要再深入一点。所以我们在这里。现在我们可以查看异常表。
对于异常表,我们查看索引,你会得到一个偏移量。假设它是 -2000、3000、2304。我们使用从该数据成员(exception_data
)位置的偏移量来定位异常表中的一个项(item)。把蓝色的项想象成像长个性数据(long personality data)。把红色的值或红框想象成 GCC 语言特定数据区(Language Specific Data Area, LSDA)。
那是什么意思?语言特定数据区(LSDA)是 Itanium ABI 添加的东西,以便你可以支持在 Java 和 C++ 之间抛出异常,并让它们相互协作。是的,我们支持那个。这是为什么普通的 GCC 异常有点慢的原因之一。它们必须处理 Java 和其他语言,即使你的代码里可能根本没有 Java。
所以从技术上讲,你可以取一个 C++ API 函数调用,通过某种外部函数接口(foreign function interface)传递给 Java。你可以做一些事情,调用那个 API,如果它抛出,它会通过 C++ 传播到 Java,再回到 C++。很酷的是,GCC 可以把这个东西直接用于 C++ 本身。所以它创建了自己的语言特定数据区,以便为如何处理跨所有处理器工作的异常创建一个通用格式。
所以这是该数据的各个部分:
第一部分是个性函数(personality function)。这是知道如何处理其下数据的函数。
下一部分是个性数据(personality data),它是展开信息(unwind information)。
下一部分是头部(header),它告诉你如何编码或解码接下来的两个部分(
call_site_table
和action_table
)。我们有调用点表(call site table),它包含你代码中所有重要的作用域(scopes),比如你需要知道以销毁东西的作用域,以及
try-catch
作用域(或try
作用域)的位置。操作表(action table) 为你的
catch
块提供顺序,因为catch
的顺序很重要。最后我们有类型表(type table),它保存了在你的函数中抛出的所有类型的唯一集合。
所以如果你的函数里像 catch std::exception
五次,这个表中只会有一个条目。所以这里,这将是一个函数的偏移量(personality_fn
),我们不关心这个。我在我的运行时里覆盖了这个。对于这个(personality_data
),只是我们之前讲过的相同的东西。
在深入头部之前,我们必须谈谈一种叫做 LEB128(Little Endian Base 128) 的编码。LEB128 是一种可变长度编码。它允许你做的事情是:假设我们按顺序看这两个字节。我们可以查看字节的最高有效位,如果它是 1
,那意味着还有更多数据要继续。如果它是 0
,那意味着你已经完成读取这个数字,把所有东西连接起来,现在你就有了你的数字。这让你可以用少量数据表示小数字,用稍多但不是翻倍的数据表示大数字。
头部是这样工作的:
头部的第一个字节几乎在任何嵌入式系统上总是
0xFF
,因为它意味着“完全省略 DWARF 信息”,这些东西反正会被剥离掉,所以它总是0xFF
。下一个字节(
type_table_encoding
)会告诉你类型表是如何编码的,比如是位置相对(position relative,通常是这种情况),或者这些(类型指针)总是绝对地址。这里的偏移量(
type_table_offset
)表示从这个位置(header
之后)到整个语言特定数据区(LSDA)结束的位置(也就是类型表存在的地方)的距离。然后你有一个调用点(call site)的编码(
call_site_encoding
),它可以是 LEB128,或者是 U8 或 Sign16 等。最后这个(
call_site_table_length
)告诉你从头部结束位置到调用点表的长度(以字节为单位)。
从调用点表,它给你四个字段:start
(起始)、length
(长度)、landing_pad
(着陆点)、action_record
(操作记录)。
start
是该作用域在函数汇编代码中的位置(偏移量)。length
是从那个位置开始的范围。如果你的程序计数器(
pc
)在start
和start+length
之间,那就意味着你在这个作用域内。这意味着那个着陆点(landing_pad
)和那个操作记录(action_record
)与你相关。着陆点(
landing_pad
)告诉你在重新进入该函数时要跳转到函数内的哪个位置。操作记录(
action_record
)可以指示条件跳转,可以指示无条件展开(或者叫调用析构函数),或者它可以指示操作表中的一个位置(索引)。
你会有 n
个这样的条目。一些代码展示它看起来像什么。你有一个叫做 read_encoded_data
的函数。你有一个可以更新的指针(ptr
)。我们有一个调用点格式(call_site_format
)用来提取信息。我们有 start
、length
、landing_pad
和 action
。如果我们的程序计数器在那个范围内,它在那个范围内,我们就跳出这个 while
循环。如果它的着陆点是零(0
),我们提前返回,并简单地说“展开那个函数”。这里没有更多事情可做。
从这里,我们实际上可以执行析构函数。我们现在知道的足够了。所以假设我们得到操作编号(action
)为零。在某些情况下,如果你的类型表不存在,它会给你这个零值。所以这是你可以检查的另一件事。
为了跳入代码,我们取函数的起始地址(func_start
)加上着陆点(landing_pad
),我们用 1
进行或操作(| 1
),因为这是 ARM ABI 的安全要求。我想是安全。我们将目标地址(landing_pad_addr
)赋值给程序计数器(pc
)。然后我们有一个特殊的函数叫做 restore_cpu_state
,它读取我们的虚拟寄存器并将它们填充到 CPU 中。这将使我们跳入这个位置。哦,抱歉。我马上会讲到那里。
所以这里我们可以看一下这里的编码。这个图中的每一行是一个四字节的字。我们有偏移量(offset
),我们有展开指令(unwind_instructions
),我们看到 DW_omit
(省略 DWARF),我们看到这个位置相对(DW_EH_PE_pcrel
),偏移量是零(0
),我们从这个位置开始解码,调用点表大小是四字节(4
)。start
在 0
,length
是 28
,我们的 landing_pad
是 30
。注意我们的位置是 bar+14
,所以我们在 0
到 28
(或者说 26
)的范围内。因此,现在我们要做的就是将着陆点(landing_pad
)设置为 30
,并跳入代码。
所以它看起来会是这样。我要放大一点,你会在这里看到第一个指令:add sp, #4
,放入 r0
。记住,r0
到 r3
是函数的输入参数。我们只是将栈上那个对象的位置设置到第一个参数(r0
)中,这样我们就可以调用这个析构函数(~T()
)。最后,我们调用 __cxa_cleanup
,而 cleanup
会恢复异常对象,将状态设置为帧展开,并继续引发异常。所以它只是像这样:从这里一路进行,最终到达展开,并继续传播。哎呀,它在那里。马上改一下,我光标没了。好了,酷。
所以在这之后,要知道那个函数栈中那个对象下面的数据现在是未定义的,因为析构函数可以使用栈内存,它们可以改变之前栈上的任何东西。所以如果你要在栈下面保存什么东西,那东西可能会被销毁。
关于执行析构函数还有一件事:根据你在代码中的位置(作用域),你可能有多个着陆点(landing pads)。例如,如果你知道只需要销毁这个对象,你可以着陆到这里。但如果你在代码的后面部分,你可以着陆到这里,调用第一个析构函数,调用下一个析构函数,然后清理。
现在我们展开了 bar
,很简单。我们像之前一样做同样的事情。我们使用偏移值(exception_data_offset
)回到 LSD 数据(LSDA)。我们可以跳过第一个指针(personality_fn
),直接进入展开信息(personality_data
)。这里,我们再次看到个性(personality)是 0
。第一条指令告诉我们将栈增加 12 个字节(add sp, #12
)。所以这将把栈指针(sp
)从我们对象曾经所在的位置移动到栈的更高位置。最后,我们将从栈中弹出 r4
、r5
和链接寄存器(lr
)。对于代码的第一部分,它看起来像这样。在我们执行完下一条展开指令之后,像这样。现在我们准备好跳入 foo
了,因为 foo
已经设置好了。我们现在指向 foo
。
所以现在我们在这里。现在我们需要查看类型表(type_table
)和操作表(action_table
)。这将完成整个过程。从这里,类型表有一个所有抛出类型的唯一集合。如果类型表中有一个空指针(nullptr
),它表示 catch (...)
(捕获所有)。操作表稍微复杂一些。它基本上为每个 try
块编码了 catch
块的序列。每一个都有一个叫做过滤号(filter number)的数字。过滤号是你用来访问类型表中元素的负索引(negative index)。我其实应该在那之前展示这个。
它的工作方式是:你到达类型表的末尾(type_table_end
),然后你向后查找所有的指针。如果任何一个类型信息值(type_info*
)与抛出类型的指针(thrown_type_info*
)匹配,那么这些类型就是相等的(equal)。我想人们以为他们必须做字符串比较(string comparisons)来比较 catch
。不,你只需要检查指针。如果有多个类型,并且你有一些继承层次结构(hierarchy),你必须做更复杂的事情,我们现在不会深入讨论。但简单来说,你只需要检查类型。检查指针。如果你得到一个零(0
),它也意味着 catch (...)
(捕获所有)。catch (...)
有两种编码方式。
哦,我太抱歉了。我跳过了一节。在过滤号(filter
)之后,有一个叫做下一记录(next_record
)的东西。这是引导你继续下去的东西,直到你最终到达末尾。下一记录告诉你从这个位置(当前操作记录项)开始,将指针向上(在内存地址减小方向)移动那么多量(next_record
的偏移量),然后再次读取,再重复,直到你碰到零(0
)。是的,如果你碰到 end_of_record
等于零(0
),那仅仅意味着这个点上没有更多记录了。如果你碰到零,end_of_record
,并且没有匹配任何东西,那意味着你应该简单地展开(unwind)这个函数。那里没有适合你的东西。
如果你确实找到一个匹配的 catch
,你要做的是:
将
r0
设置为异常对象的地址(exception_obj
)。将
r1
设置为过滤号(filter
)。将程序计数器(
pc
)设置为函数地址加上着陆点(landing_pad
),并将r1
作为索引(landing_pad[r1]
)。然后设置状态(
state
)。
为了展示这看起来像什么,我们这里有类型表的结尾(type_table_end
),它是 13
。所以从这个位置(action_table
开始)实际上需要 13 个字节才能到达这里(type_table_end
)的末尾。我们有调用点(call_site
),它是四字节的。我们知道 foo
在 10
(地址)。我们检查调用点。我们检查是否在边界内(pc
在 start
和 start+length
之间)。着陆点是 16
。我们的操作记录(action_record
)是 1
。操作记录是零(0
)意味着无条件… 抱歉,意思是调用析构函数(unconditional unwinding)。所以我们必须将其偏移 1
来获得操作表中的真正偏移量(action_table[1]
)。我们查找 filter=1
。我们对类型表进行负索引访问(type_table[-1]
)。我们找到这个类型(type_info for MyError
)。我们比较抛出的(thrown
)和捕获的(caught
)类型。它们彼此相等。现在我们准备好跳入了。我们知道是时候跳入 catch
块了。
所以从这里开始,它看起来像这样。还有,一个小知识点。我不知道是否有人知道,但 catch
块是 switch-case
语句。至少在 Itanium 中,它们就是 switch-case
语句。所以过滤号(filter
)就是你的 switch
值,对应到哪个 case
块。这里我们将 r1
与数字 1
比较(cmp r1, #1
)。如果它是 1
,那么我们简单地通过这一行(bne .Lnext
)。它是“如果不相等则分支”(branch if not equal)。我们进入一个叫做 __cxa_begin_catch
的函数。这将查看 r0
(里面有我们的抛出对象)。它会将异常设置为已处理(handled)。然后它会返回实际的抛出对象(thrown object
)。从那里,我们用它做些事情。我们会调用 __cxa_end_catch
,它会负责释放(deallocating)我们的对象。它也会负责调用析构函数和释放我们的对象。
如果它不等于 1
,假设我们不知怎么带着某个奇怪的过滤号进入了这里,我们将跳转到这个位置(.Lnext
)并开始进一步展开。差不多就是这样了。这就是 C++,这就是 ARM GCC 的异常处理,基本完成了。还有很多细节。我显然不会全部讲完,那会让你们无聊到睡着。但基本就是这样。
现在我们有所有这些信息,我们可以看看异常处理的空间成本(space costs)是什么。这里有一个表格。我会把它填出来。所以:
我写了几个假设。第一,我们假设所有代码都是为代码大小编译的。因为否则不会是一个好的基准测试。第二,我假设几乎所有在这里的人写过的大多数函数的大小不超过 16 千字节(指令大小)。为了提供一些依据,我之前拿了 Firefox,取了它的调试构建(debug build),转储了它所有的函数并查看了它们的大小。有 253 个函数大小在 16 千字节以上,325,000 个在 16 千字节以下。所以我认为找到大于 16 千字节的函数是相当罕见的。我还假设在
catch
块和需要销毁的对象数量之间,我们将限制并封顶在 63 个。这就是我选择那个数字的原因。哦,所有东西都使用 LEB128,因为它是保存所有这些信息最紧凑的方式。异常索引(Exception Index):根据 ABI 定义,它是八字节长。就这样。
个性数据(Personality Data):四字节。根据 ABI 定义。
长数据(Long Data):最多 12 字节,因为不幸的长度(
length
)为一或二。catch
块(Catch Blocks):需要深入一点汇编。对于一个catch
块,有几件事需要发生。记住,它是一个switch-case
。所以我们有比较(cmp
)、分支(bne
)、比较、分支、begin_catch
(如果你看这里可能有点难看清,比较只占两个字节的信息)。不相等分支(bne
)只占两个字节信息。begin_catch
的分支链接(bl
)是四字节,然后end_catch
也是四字节。所以每个catch
块总共花费大约 12 字节。清理区域(Cleanup Regions):它们是
6d + 4
。你需要两个字节来设置栈或为析构函数设置输入参数(add sp, #4; mov r0, sp
或其他)。那个分支链接(bl
)是四字节,所以你需要四字节来做那件事。对于每一个你要销毁的东西,你都需要那么多字节。然后我们还有额外的四字节用于最后调用__cxa_cleanup
。所以这就是6d + 4
的由来(d
是析构函数调用次数)。操作表(Action Table):操作表是
2 * A
,其中A
是你代码中的catch
块数量。一个 LEB128 数字的范围只能是 -64 到 63(有符号)。所以考虑到我之前给的那个约束(最多 63 个catch
),我们不能有一个超过那个值的过滤号(filter
)。因此,我们只需要一个字节来表示那个过滤号。因为我们只使用一个字节的信息,下一记录(next_record
)应该永远不会超过 -3。应该总是 -3、-3、-3。类型表(Type Table):那个的大小是每个指针四字节,乘以
T
,即你函数中唯一类型(unique types)的数量。调用点(Call Site):它介于
4 * num_call_sites
或7 * num_call_sites
之间。这归结于我给出的另一个约束:16 千字节(函数大小)。两个 uLEB128 值放在一起是 2^14。2^14 是 16 千字节。所以我可以确保,只要我的函数不超过那个大小,我所有的调用点,start
、length
和landing_pad
都可以用单个 uLEB128 表示(因为 16KB < 2^14)。如果我的函数不能超过 16 千字节,我的start
不可能超过 16 千字节。我的length
也不可能超过那个。我的landing_pad
也不可能超过那个。操作记录(Action Record):由于我们之前的其他约束,操作记录(
action_record
字段)被限制为… 嗯,所以这里(在调用点表中),最多是一个 uLEB128 值(因为最多 63 个catch
,索引值很小)。我们很幸运(它总是可以用一个 uLEB128 字节表示),本来可能差一点(off by one)。
我将跳过这一节,因为我每次练习这个时,都讨厌解释它,因为它包含很多数学。基本上,我必须推导出,结合所有其他约束,为什么这个特定的表(指 LSDA 头部)会被限制在只有五到七字节。但我以后会写一篇关于所有这些的论文,所以如果你们真的想了解细节,你们可以在将来看那篇论文。但可以说,头部(header
)只能是五字节或最多七字节。
这里列出了我们表中所有的项目。举几个例子。
对于可平凡展开的函数,如果它们没有任何析构函数,没有任何
try-catch
,并且它们没有做太多额外的事情,需要八字节的信息来处理它。在函数本身所需内容之上。对于没有单个析构函数作用域的函数,这大约是 34 字节。你有条目(
exception_index entry
),八字节,然后你有头部(header
),五字节(可能不会是七字节)。个性数据(personality_data
)四字节,调用表(call_site_table
)四字节,也许你需要额外的填充来确保字对齐,所以是额外三字节。然后我们有十字节用于着陆点(landing_pad
)代码(add sp, #4; mov r0, sp; bl __cxa_cleanup
)。34 字节。所以加起来。对于包含一个
try-catch
的函数,最小是 47 字节。
对于时间,我们不会深入细节,但基本上,如果你把我们之前讨论的所有内容加起来,并代入我所有模型中的方程,你得到一个包含 try-catch
的函数的最小值是 47 字节。所以是的,try-catch
有点贵。
现在,让我们看看返回类型错误处理的空间成本。这里有一个小例子。我们有一个叫做 none
的函数,意味着它不做任何错误处理,或者至少那里没有。它调用了三个不同的函数(func1
, func2
, func3
)。每一个都是有价值的(valuable,指可能失败)。每一个都可能抛出异常。执行这个所需的指令数量大约是八条(push lr; bl func1; bl func2; bl func3; pop pc
)。
如果我们使用 std::expected
?我们的代码会变成这样(伪代码:auto r1 = func1(); if (!r1) return r1.error(); auto r2 = func2(); ...
)。我甚至在这里放了 unlikely
,希望它能做点什么。我移除了它。什么也没改变。但在这里,我们调用函数,取值,做我们需要做的事情。从那里,这个汇编代码膨胀成了这样(bl func1; ldr r3, [sp, #0]; cmp r3, #0; bne .Lerror; bl func2; ...
)。
而对于每一个检查,我希望你们仔细看看。我不知道你们是否能看清,但函数一(func1
),分支到函数一(bl func1
),之后我们从栈指针(sp
)加载一个字(ldr r3, [sp, #0]
),或者是从我们从函数返回的对象中加载(取决于具体实现)。然后我们有一个比较零(cmp r3, #0
)和分支如果零(bne .Lerror
),这将使我们跳转到这个小区域(.Lerror
),这是错误返回路径(error return path)。
所以对于你代码中的每一次检查,它至少花费你六字节。在这个例子中,加载字(ldr
)四字节,以及每条分支(bne
)额外两字节。但你会想,好吧,所以 expected
太重了。我们不用那个。我们就返回一个 bool
。那不行吗?呃,有点吧(kinda)。它让你降到四字节(bl func1; cmp r0, #0; bne .Lerror; bl func2; ...
)。我会说这里的指令(cmp r0, #0
)可能让你觉得,“哦,那里只有两字节。” 嗯,这个指令(cmp
)在其作用域内非常受限。编译器没有一遍又一遍地重用它,是因为这个游戏(指短跳转)只能带你走这么远(偏移量有限)。所以一般来说,返回 bool
并检查它们仍然每次至少花费你四字节。
让我们考虑 Rust。这里有一些代码。Rust 在结果类型(result types)上做得很好,所以它们在这方面应该很完美。不,它和 std::expected
差不多。没太大区别。代码生成稍微小一点,这很好,但它几乎和 std::expected
一模一样。但有趣的是,如果你让结果类型(Result
)是 u63
对 u32
,它变得和 bool
一样小。很酷。我们仍然有那个成本,但由于某种原因,当你的对象变得太大时,会出现奇怪的优化。但一般来说,每次函数调用是四字节。
这让我得出我的结论。我将讨论分布式错误处理(distributed error handling) 和 集中式错误处理(centralized error handling)。
什么是分布式错误处理?这是你将错误处理分布在整个代码库中的地方。if-else
分支、所有其他类似的东西、输出参数、所有那些东西。每当一个函数调用一个可失败的函数时,它有两个工作要做:要么 A) 传播错误,要么 B) 自己处理它。
另一方面,我们有集中式错误处理。这是一种错误处理形式,我们有一个用于处理和传播错误的中心机制。你的代码库中的所有代码不需要做任何特殊的事情来参与错误处理。它们只是正常做它们的事情,异常机制会为你处理它,或者集中式错误处理系统会为你处理它。
我喜欢把它想象成和美国邮政(USPS)或拥有邮政系统一样。与其让其他人管理他们自己的投递,我们有一个系统来做这件事。异常就是这样的一个例子。哦,最后,正常代码和错误处理代码在不同的领域(domains)是分开的,所以它们不会相互冲突。所以你可以把你的代码看作快乐路径,另一部分看作悲伤路径。
这是我的主张:在代码大小方面,集中式错误处理方案相对于分布式错误处理具有优势。 为什么我这么说?
分支不是免费的。 每次使用它们都要付出代价。假设我们想添加一组五个函数调用。假设它们都是可失败的。每次我调用这些函数,我都必须支付四到六字节来做检查。而如果我只使用异常,对于这种特定类型的函数(平凡可展开的),我总共只支付八字节。这是另一个小例子。把这个想象成内存使用,有函数和 SU 个性数据(指异常元数据),以及每次比较和分支的成本(四字节)。记住,这里每个检查只算四字节。但我会稍微慷慨一点。如果你使用一个
try-catch
,那大约相当于 11 到 12 次检查过的函数调用的成本(根据之前的表格计算)。所以,有点贵,你知道的。大多数函数是可平凡展开的。 为这件事想一秒钟。至少对于我写过的代码和我见过的代码,我发现实际传播错误的代码远多于处理错误的代码。对于很多嵌入式人员,我敢打赌你写了很多不需要非平凡析构器的对象。你不需要关键内存。我们不使用堆。也许你会使用互斥锁。那有点特殊,但一般来说,我们可能只在真正需要它们的特定地方非常谨慎地使用它们。我记得几个月前和 Jason Turner 聊过,我问他,嘿,我需要一种方法来找出网上是否有其他例子可以参考。他说,看看 Audacity。Audacity 有 1,054 个 C++ 文件。只有 34 个,39 个里面有
catch
块。在源代码中总共找到 60 个catch
块和 69 个catch
(可能指try
块)。在 1,777,000 个函数中,只有 50 个函数实际上有一个try-catch
。Audacity 用于异常处理的总内存成本约为 15 千字节。所以让我们更极端一点。是的,前面的例子说大约是 0.028%。让我们把它变成 5%。让我们把分配对象的函数数量设为代码库的 25%。将可平凡展开函数的百分比设为 55%,叶子函数设为 10%。我觉得这相当慷慨了。也许你对此有不同的看法。但我认为这是合理的。所以让我们为每一个算出平均大小。对于try-catch
,我就给它平均 64 字节。它的最小值是 47。稍微提高一点,说 64 字节。对于我的清理函数,默认最小值是 34 字节。提高到 46。平凡函数,我设定为 8,纯粹是因为长展开信息(long unwind information)比较罕见。所以对于这个,就 8 字节。它会被内联(inlined)。然后叶子函数不参与异常处理,所以它们的元数据不存在。你创建的每一个叶子函数都是零字节。让我们把它们加起来。这里的 8,000 是为了代表实时展开(live unwind)和在代码中使用它的成本。我们把这些数字加起来,得到 27,100 字节。酷。有点多。现在让我们把它与代码中的检查次数进行比较。假设我们要用结果类型来交换这个。假设每次检查花费我们 4 字节。我们得到多少次检查?6,775。那是很多次检查。但在 900 个函数中,有点少。我不知道,每个函数平均调用 7.5 次(函数)才达到一个你不如一开始就用异常的区域。传递性有点限制性。另外请记住,这个数字(6,775)不包括尾声(epilogues)、代码中所有其他的返回路径。这只是用于检查函数的代码。这是 4 字节情况下的最佳场景。它不可能比这更好了。如果你能想出办法让它比这更好,请告诉我。那真的很酷。让我们选择 6 字节用于std::expected
。现在我们每个函数调用得到 5 次检查(27,100 / 6 ≈ 4516.66,但表格中检查数对应的是 N,这里指总检查数上限是 27,100 / 6 ≈ 4516,除以 900 函数 ≈ 5)。哎呀,让我们加入额外的 2 字节来表示尾声成本(指每个函数返回错误路径的额外开销)。现在我们每个函数调用不到 4 次检查(27,100 / (6+2) = 3387.5 / 900 ≈ 3.76)。这感觉非常受限,因为我写了很多代码… 我喜欢调用函数。我觉得在开发时被限制调用函数不应该是件好事。
所以这是我的主要观点:C++ 异常如何实现更小的二进制大小? 嗯,有几个方面:
它在调用可失败函数时减少了代码生成。
展开信息随你拥有的函数数量而扩展,而不是随你调用的函数数量而扩展。记住,你调用的函数数量将比你拥有的函数数量更多。
它消除了额外的返回路径。你不需要那么多,因为你不需要在每次调用函数时都返回。
始终记住,过去在 Itanium ABI 和 ARM 异常 ABI 中已经做了很多努力来压缩所有指令,使它们尽可能小。
最后,你只为这个机制支付一次费用(you only pay for the mechanism once)。 你支付那 8K(或至少我的展开是 4.5K)。你只付一次,然后就完成了。
令人难过的是,这甚至还不是异常的最终形态(final form)。异常可以更小。看看这个。为什么我们需要 DWARF 信息省略(DWARF omitted)?为什么我们需要类型表编码?为什么我们需要调用点编码?为什么我们不创建另一个个性函数并把这些去掉?
想象一下:在美国最高法院,你理论上可以“填充法院”(pack the courts)。你可以不断增加更多人。你可以不断增加更多的 GCC 个性。你可以做个性 v1(personality v1)。而那个新的可以有更小的开销。不仅如此,而且就像我来这里的路上想的那样,我们可以运行一个工具来把一种转换成另一种并保留相同的信息。
还有,为什么我们为每个 catch
都调用 __cxa_end_catch
?用分支(b
)改变它。分支链接(bl
)是四字节。无条件分支(b
)是两字节。每个 catch
块节省两字节。还有那么多其他想法在我脑海中翻滚,我们可以做这些来优化 C++ 中的异常处理。但现在就这样吧。我们有其他的演讲会深入讨论这些。
所以对我来说,我必须问这个问题:C++ 异常到底是什么? 在花了这么多时间查看反汇编、查看元数据、处理这些数据结构之后,我得出一个结论:C++ 异常就是代码压缩(code compression)。 它们是一种将所有这些(分布式错误处理代码)变成一点点那个(集中式元数据)加上八字节元数据的方法。
所以对我来说,当我回顾这个关于为什么不使用异常的图表时,异常表和异常代码不是问题。相反,它是一个解决方案。使用它实际上是有益的。我敢打赌这里的每个人都在想:“我不在乎代码大小。我想要性能。我想要我的东西快。你有关于这个的吗?”
好吧,幸运的是,我的下一个演讲将是关于将 C++ 异常时间减少 88%。之前在 ACCU 是 76%,但后来我又做了一些工作。现在是 88%。我确信我能把它提高到 92%,也许是 95%。别引用我这句话。你知道,我正在尽力。但是的,不,我们可以做很多事情来提高性能。
哦,在我进一步之前,这是额外的一点时间。有人知道 GCC 的实时展开(Live Unwind)上次更新是什么时候吗?里面的任何东西?我想如果我查看 ARM 的,地球上有八个人研究过它,总共。那大约是 15 年前。然后真正的主要部分是在 25 年前。几十年来没有人碰过这段代码。所以将我的实现与 GCC 比较,而使用 std::expected
并展开六帧(unwinding six frames),GCC 比它慢了大约 27 倍。我的实现仍然更慢,但大约是 4.77 倍,没什么是完美的(nothing is good)。对于 96 帧,这有点极端,GCC 慢了 21 倍。随着你深入,情况会变得更好。然后我的实现是比仅仅从函数返回慢了 2.56 倍。
但我想我花了整整 80 分钟或 70 分钟谈论异常以及它们如何更小,减少了代码生成,但老实说,我需要对自己诚实。也许一些真正倡导异常的人也需要对自己诚实。因为问题是:异常特么的很糟糕。
问题是:这个(api()
)抛出什么?老实说。有人知道这个函数抛出什么吗?绝对不知道。你会怎么做?你去查文档。查文档,它说抛出一些错误(SomeError
)。很酷。但它真的那样做吗?文档大约一年前更新了。我们不断从供应商那里获得带有新二进制 blob 的新更新。我不知道他们是否改变了那个。我们给他们发邮件。我们说:“嘿,你还会抛出别的东西吗?我们需要为它期待一个不同的错误代码吗?” 来回,来回。一周过去了。终于得到了一个竖起大拇指。“是的。没有其他东西抛出。你在 5.1.0 版本上可以放心用了。” 现在我升级了。现在我不知道会发生什么了。是的,你可能会说,“哦,如果他们改变了那个值,那应该是一个小补丁号之类的。也许甚至是一个重大变更。但问题是,这无关紧要。无论他们如何改变版本,我不再知道了。这里没有人知道。对我来说,这就是异常处理的根本问题。正因为如此,它让人们担心他们的代码及其行为。正因为如此,人们才如此努力地推动远离异常。这就是为什么我们想在本地处理错误。
很酷的是,这并非一个无法解决的问题。所以我还有另一个演讲,将在这次之后举行。这是三部曲中的第三个演讲,叫做异常洞察工具(Exceptions Insights Tool)。我花了这么多时间看反汇编,我注意到了一些事情。你知道什么是整洁的吗?当你返回一个 bool
或返回 std::expected
时,看反汇编并了解发生了什么并不容易。但每当你 throw
,你的代码中就会有一个对 __cxa_throw
的调用。
所以我的工具将做的是:你把二进制文件传给我,不是你的源代码。你的源代码一无所知。把你将要实际执行的二进制文件给我。我会解析你的文本段(text section),也就是你的代码段。我会找到每一个分支链接到 __cxa_throw
和 __cxa_allocate_exception
的地方,因为我可以弄清楚你为每个抛出的对象分配了多少内存。所以现在我知道你所有抛出对象的大小。我知道它们的类型,我知道你在哪里抛出它们。
现在我可以浏览你的异常索引。从那里,我可以评估所有函数的位置以及它们如何参与异常处理。你找到所有参与异常处理的东西。从索引中,我现在可以弄清楚你所有的 try-catch
块在哪里。从那里,我可以做的只是模拟抛出异常,在你的代码中的所有点引发它。你必须提供给我一个调用图,或者我们将为你生成它。我们还在弄清楚是否能做到。我们可以模拟调用并抛出你所有的异常,并找出哪些命中了 main
,哪些命中了 noexcept
函数,哪些被捕获以及在哪里被捕获。
因为我不认为这是一个不可能实现的工具,我将致力于实现它。实际上,我有一些学生也在这里,他们会帮助我做这件事。
在我们结束之前,我还有两页幻灯片。
特别感谢我的母亲,她从加州来到这里看我的演讲。
我美丽的未婚妻,Malia Labore,她也是会议的一部分。她也是一名 C++ 程序员。她可以帮我做幻灯片。
真的非常重要,Herb Sutter,出于多种原因,但主要的是这个演讲。这是启发我做这次演讲的那个。这是激发我对这个主题感兴趣的东西。
除此之外,我真的、真的必须感谢 Bjarne Stroustrup。我不打算引用全文,因为我觉得可能,你知道,呃。但他写了一篇论文叫《C++ 异常及其替代方案》(C++ Exceptions and Alternatives),他在其中基本上是对 Herb 关于一种新形式异常处理的演讲的反驳。他说:“在 C++ 中,关于异常的性能和结果代码的可靠性,几乎没有进行过严肃的研究。” 所以当我多年前读到那份文件时,我心想,也许我可以成为去研究这个的人。
在我们结束之前,我有 GitHub。我没有工作。我是以自己的名义研究这个。这是我自己的研究。如果你想支持我在异常处理、异常洞察工具以及试图最小化异常处理的代码大小方面所做的工作,你可以考虑赞助。如果你是一个资助机构,你可以考虑发送邮件到
estelle.exceptions@gmail.com
。NSF(美国国家科学基金会)、能源部(Department of Energy),任何一个都可以。不太在意。如果你是一家公司,或者你是关心异常的公司的一员,并且我所说的任何内容对你有用,请告诉我你使用异常时遇到的问题是什么。告诉我你如何使用它们。我非常希望能有尽可能多的数据,这样我们才能为 C++ 社区构建合适的工具。请理解,我正在构建的所有东西都将是开源的,因为我相信这个工具至少对我有用。我认为异常洞察工具和其他我正在做的工作将有助于推广 C++ 并使其在未来保持活力。
所以,差不多就是这样。有问题吗?
问: 嗨。那是一个很棒的演讲。真的很有趣。这确实有点违背了关于异常使用的传统智慧。所以,看到这个非常酷。我一直在想的一件事,从开始就卡在我脑子里。所以,我们最初在谈论复制异常对象,比如 __cxa_allocate
。在嵌入式环境中,我们使用静态缓冲区。还有别的选择吗?假设,如果你填满了静态缓冲区?有没有后备方案?我猜这是一个无法处理的事情,就像我自己更喜欢使用异常一样,如果你耗尽了… 是的,它会终止你的程序。
答: 所以,有两件事。第一是异常洞察工具以及构建一些数据结构。接下来的演讲我们会讨论这些,并涵盖那些项目。但是,我可以告诉你这个:有一种方法可以为你的代码可能抛出的每一个可能的异常分配足够的内存。对于你拥有的每个线程,你需要一定数量的块,你可以从那里开始。但是,再次,我在下一个演讲里有它,我们会讨论所有这些。
问(追问): 所以,如果我可以重述一下… 是的。所以我说异常洞察工具允许你计算一个上限,基本上。
答: 是的。正确。那很酷。谢谢。
问: 嗨。首先,尊重。说真的,我做不到(指完成这么深入的研究)。谢谢。我在航空航天和机器人交叉领域工作。在航空航天方面,法规要求我做的事情之一是证明我已经覆盖了从目标运行到需求的所有代码分支。关于异常,我担心的是,我如何知道生成了什么代码,以及如何将其追溯回需求?
答: 哦,是的。不。好吧,有点回到刚才。我没有太多解释异常洞察工具。它的目标是尽可能给你一个完整的全局视图,或者一张图,显示异常被抛出的每个地方,包括你得到的二进制 blob(库)中。这就是为什么我们在二进制层面查看。我们不查看源代码,因为你可能并不总是有源代码可用。我们应该能够告诉你哪些异常未被捕获(uncaught)。CLI 工具的想法是 exceptions_insights
,你给它你的 ELF 文件(elfile
),它会给你一个统计跟踪,显示你遗漏的每一个异常。这个想法是专门解决这个特定问题的:每当我做机器人工作时,我想确保我捕获了所有错误。而那些我不想捕获的错误,特别是那些我想让它终止我的错误,我允许。我们还在构思如何编写这个工具,让你能轻松指定哪些错误你允许它终止你,哪些错误应该真正给你一个错误。至于它来自哪里,我们至少能告诉你数据来源的位置,并且假设你有这些函数的调试符号,它们将被使用。所以对于那些函数,我们可能能告诉你那些调试符号是什么,或者那些函数是什么,但如果它是完全剥离(stripped)的,那可能会有点困难。
问: 好的。谢谢。
问: 是的。嗨,很棒的演讲。我有两个问题。一个是关于返回 expected
的。如果你尝试像 and_then
这样的单子函数(monadic functions),它比宏更好吗?你的学生对此有什么看法?
答: 哦,是的。哦,我的学生不会用它。他们发现它太令人困惑了。所以是的,他们宁愿只用 if else
语句。我之前尝试向他们展示过这些东西,一旦你开始大量使用 lambda 来做 or_else
、and_then
之类的东西,嗯,一般来说,代码生成差不多一样。如果不是稍微… 代码生成应该差不多,我的学生并没有发现用它工作更令人满意。
问: 好的。第二个问题,你打算把这些东西合并到 GCC 吗?还是什么…
答: 先 Clang,然后 GCC。
问: 非常酷。谢谢。
补充说明: 哦,还有最后一件事要说,改进的异常运行时(improved exception runtime)是一个库。你把它引入你的代码库。我用 Conan 做这个。你把它作为依赖项引入。它将覆盖所有 GCC 的实现并用你的替换它。所以你甚至不需要等到下一个编译器版本。你可以把它引入任何当前的应用程序,并且它是向后兼容的。所以,就像,你不需要改变你的代码来使用它。你只是得到了改进。但也许它设计的某些方面可能不适合你的用例。
问: 你好。谢谢你的演讲。真的很有趣。你的异常分析器工具能跨动态共享库(dynamic shared library)函数调用工作吗?
答: 是的。所以我们对如何解决这个问题有一些想法。我们还没有做到那里。我们正在考虑,我们知道一个事实,如果你要为你的任何应用程序使用那个工具,你需要在它将要执行的机器上运行分析器,因为你需要确保我们有所用共享库的真实情况(ground truth)。但我们正在弄清楚是否还有其他障碍会阻碍它更好地工作。所以这是计划中的。
问: 谢谢。
问: 我有一个搁置的个人项目,叫做 POSIX PP,它试图在很大程度上从 C++ 下面移除 libc,并用一个纯 C++ 层替换系统调用接口。我现在卡住的地方就是进行你所做的研究所需的所有研究。所以,谢谢。
答: 不客气。
问: 一旦你完成了,你觉得你正在开发的库会有用吗?
答: 可能。我们联系一下,可以谈谈。
问: 还有别的问题吗?
问: 哦。是的。我想,一个明显的问题。你有研究 x86、x64 和 Windows 吗?
答: 哦。好的。几件事。好的。所以我们计划先专注于 ARM 和 RISC-V。对于 x86,这将需要社区的一些支持,以便我们有资源来做这个。所以只是说清楚。至于 Windows,微软得联系我跟我谈这个。因为如果是 MSVC,那是他们自己的东西。如果是像 Clang 之类的,它可能可以工作。我必须更深入地研究一下。但我不确定。
答: 我想我们没有问题了。谢谢大家。
结束: 谢谢大家。谢谢大家。