SFrame: fast, low-overhead stack traces
By Jake Edge August 8, 2023 OSSNA ChatGPT assisted translation https://lwn.net/Articles/940686/
获取运行中程序的 stack trace 在很多场景下都非常有用:跟踪(tracing)、性能分析(profiling)、调试、性能优化等。虽然已经有了一些机制可以获取 stack trace,但它们存在一些缺点。于是"Simple Frame"(SFrame) stack trace 格式应运而生,希望解决其他技术的不足之处。今年五月,Steve Rostedt 和 Indu Bhagat 在 LSFMM BPF 活动中就内核中的 SFrame 支持进行了演讲;几天后,Bhagat 在温哥华的北美开源峰会上做了一个更加全面的关于 SFrame 的演讲(YouTube 上有视频)。第二个演讲可以帮助了解 SFrame 和整体 stack trace 的其他方面。
Background
Bhagat 首先定义了什么是 stack trace:"线程中当前正处于 active 状态的函数调用列表"。有价值的 stack trace 将显示相关的 call-chain list 中每个函数内的指令指针(IP, instruction pointer)指向位置的信息,以及一些人类可读的符号名称,包括函数名,但也可以提供文件名和行号等信息。然而,她的演讲重点不在这些符号化的部分,而是专注于如何获取 call chain 中的 IP 指针列表。
不同的工具会用不同的方式生成调用链的 IP,因为它们都是关注在自身的使用场景。"调试器的做法与性能分析工具就不同"。从她的幻灯片中,她列出了五种现有的 stack trace 机制,但表示她会专门讨论其中三种:帧指针(frame pointer)、EH frame ,以及应用程序特有的格式。她说,这三种机制将提供足够的背景,从而解释为什么要有 SFrame 格式。
帧指针技术可能是最古老的 stack trace 方法之一。它预留了一个寄存器来保存帧指针,帧指针是指向当前堆栈帧的指针;编译器会生成额外的代码,在函数进入和退出时将栈指针的值保存到该寄存器(或恢复出来)。因此,每个函数调用都会有一些额外的代码性能开销;除此之外,编译器必须专门为帧指针预留一个寄存器,这也会影响性能。但它是一个易于理解的机制,效果良好;"它设计得很漂亮,运作良好,而且非常简单"。
EH frame 机制是一种基于 DWARF 的方法,不仅可以进行 stack trace,还可以进行堆栈展开(stack unwinding),也就是说它可以把调用链中的每一个点上的所有寄存器的状态都恢复出来。执行此操作所需的信息存储在二进制文件的 .eh_frame 和 .eh_frame_hdr 这两个 ELF section 中。这个格式本身紧凑多样,实用效果不错,Bhagat 表示,对于希望处理该格式的应用程序来说也可以直接使用现有的功能良好的库。
使用 EH frame 不需要为帧指针保留寄存器,但" stack tracer 工具本身速度较慢且复杂"。原因是 DWARF 信息是一组用来把感兴趣信息的堆栈偏移恢复出来的指令;其中一些指令简单,一些复杂,"但你需要实现一种可以执行操作码的 stack machine"。关于该方法的主要抱怨是关于其速度和复杂性,这也使得它在内核中不太适合。她指出,关于现在已被接受的 Fedora 37 提案就要在该发行版的构建中默认启用帧指针的讨论(LWN 有报道)也触及了帧指针和 EH frame 方法的一些问题。
应用程序特定的格式,就是因为上述不足而产生的。例如,内核的基于 ORC 的 stack trace 就是因为 EH frame 方法的局限而产生的;还有其他一些应用程序特定格式但是不是开源的,但也确实可以支持快速和简单的 stack trace 解决方案。应用程序特定的解决方案并不使用由工具链生成的信息,因此可能需要反向工程来以其他方式使用这些格式;这可能会使得移植和维护这些格式变得困难。
Requirements
她展示了一张幻灯片,总结了这三种方法的优缺点,可以用来制定新的 stack trace 方法的需要满足哪些需求。第一个要求是,在给出任何一个 PC (program counter)值或 IP (instruction pointer)值的情况下(在演讲中她两个名词都用到了),可以生成精确的 stack trace,她称之为 "asynchronous stack trace"。演讲结束时,一名观众问了一下在这个上下文中这个术语的含义。Bhagat 说,基于帧指针的 stack trace 并不总是精确的,因为编译器会在函数的前置动作和收尾动作中添加额外的指令。如果 stack trace 是从这些指令之一开始,就丢失一部分 trace 内容,因为在这些点上帧指针处理本身是不完整的。
其他需求更明显地源自她的幻灯片上的优缺点:需要更低开销,使用低复杂度的 tracer,并使用由工具链生成的信息。SFrame 是在考虑这些需求的基础上设计的,她说。
SFrame 格式在 Binutils 2.40 中被定义和实现为 SFrame version 1。自演讲以来,已经发布了 Binutils 2.41,对该格式进行了一些相当小的、但不向后兼容的更改,现在版本为 SFrame version 2。该格式仅包含足够的信息来进行 stack trace:对于指定程序计数器(PC)值,可以查询出规一化之后的帧地址(CFA, canonical frame address)、帧指针(FP, frame pointer)和返回地址(RA, return address)。"这就是进行 stack trace 所需的一切内容了,也是在 Simple Frame stack trace 格式的编码中所包含的一切内容。"
SFrame 定义了两种 ABI:x86_64 和 64 位 Arm。它支持编码过程链接表项(pltN, procedure linkage table entries)。在 Arm 上,这个功能可以对指针认证信息进行编码,以便后续对已被认证处理过之后的返回地址值进行解码。
SFrame 信息存储在 .sframe ELF section 中,也就存储在其自己的 PT_GNU_SFRAME segment 里。需要将 –gsframe 选项传递给 GNU 汇编器来生成这部分信息。如果 GNU 链接器(linker)看到多个 .sframe 部分,它会在输出中将它们合并起来。readelf 和 objdump 工具也支持 SFrame;使用 –sframe 选项将对 SFrame 信息提供出人类可读的文本描述。
Format
SFrame 格式由三个部分组成:header、一组函数描述符实体(FDE, function descriptor entities)和一组帧行条目(FRE, frame row entries)。header 包含一个 magic 数值、一个版本号,以及两个部分的偏移地址。FDE 是固定大小的,按照 PC 顺序排序,因此可以使用二分搜索来找到与指定 PC 值对应的函数。FRE 是可变长度的,以尽可能紧凑。offset 偏移就是用于访问格式中的各种信息。
每个 FDE 对应了一个函数。它存储了起始 PC 值以及函数 size(以字节为单位)。还指示出它是一个常规代码块还是 pltN。在此之后,它有一个偏移量指向第一个 FRE,以及该函数拥有的 FRE 数量和类型。
FRE 是这个格式的核心内容,她说。它们提供了可用于恢复出指定函数内特定 PC 处的 CFA、FP 和 RA 的堆栈偏移量。由于函数 size 不同,表示从起始 PC 值开始的偏移量所需的空间也不同;根据偏移量是否可以在一个、两个或四个字节中编码,FRE 有三种不同的表示方式。每个 FRE 都包含了函数内连续地址范围,并对适用于该范围的 CFA、FP 和 RA 值的堆栈偏移量进行了编码保存。
可以在 FDE 上进行二分搜索,这是 SFrame 的一个优点;这样就可以迅速得到 trace 的起始点。该格式的另一个优点是,FRE 直接编码了恢复 CFA、FP 和 RA 所需的偏移量;无需执行 stack-machine instruction 来实现这一点。内核的 ORC 格式也直接编码了这些偏移量,但 SFrame 进行了一些空间优化,使其格式更紧凑。她展示了一张图表("请对此保持独立的思考"),显示了对比 Binutils 中的十个不同二进制文件的 x86_64 SFrame 和 EH frame 的大小,结果显示 SFrame 大约为 EH frame 所需大小的 80%。她确实提醒说,EH frame 的用例不同,所以这并不是完全公平的比较。
Library
libsframe 格式库随着 Binutils(从 2.40 版本开始)一起发布,它包含了读取和写入 SFrame 数据的 API;之所以创建该库,主要是考虑到 linker 会有这个需求,因此包含了一个 stack tracer 可能不需要的写入 API。还有一些针对 stack tracer 的 API,例如用于找到与 PC 值相对应的 FRE 或从 FRE 获取堆栈偏移量的 API。
Bhagat 表示,libsframe 库还很年轻,所以现在还没法保证其 ABI 稳定,毕竟太早了。API 在 sframe-api.h 中有描述。SFrame 格式在磁盘上并不对齐,但是库函数在内部安排数据时会避免不对齐的访问。她展示了一些示例代码,以演示 "进行堆栈遍历是多么容易";它可以根据 PC 值找到一个 FRE(find_fre()),然后获取 CFA、FP 和 RA 值的偏移量(get_*_offset()),从而获取到它们。
在汇编器中仍然需要支持一个目前被跳过了的 CFI 指令(.cfi_escape);这意味着 SFrame 并不完全是异步的,但编译器很少会发出该指令,因此在实际使用中这不是一个大问题。此外,还需要为 SFrame unwinding 来增加更多的 regression 测试,从而可以用在 GNU 汇编器的测试。除此之外,她表示,SFrame 开发人员计划与社区合作,探讨 SFrame 的用例,包括用于用户空间应用程序和内核的用户空间 stack trace。自演讲以来,也有提议将 SFrame 支持添加到 LLVM 中。
Bhagat 在演讲结束时建议,有兴趣使用 SFrame 的人可以通过 Binutils 邮件列表与开发人员取得联系。一名观众询问了目前使用 SFrame 的应用程序;Bhagat 表示,除了与 perf、Ftrace、BPF 等相关的内核部分之外,没有其他应用程序在使用这种格式。SFrame 开发人员最初从内核场景开始,现在开始研究有哪些用户空间应用程序可能可以从快速、低开销的 stack trace 中受益。
一名参与者询问了其他架构的情况,指出他认为 RISC-V ABI 有些不同。Bhagat 表示,SFrame 已经适配了 x86_64 和 Arm64 之间的差异,但如果另一种架构在处理返回地址的方式上有重大差异,那么 SFrame 可能需要进行更改来适配。目前,x86_64 总是使用堆栈来存储其 RA,而 Arm64 同时使用堆栈和专用寄存器,SFrame 已经处理了这两种情况。
Bhagat 的同事 Jose Marchesi 问到了 SFrame 与 ORC 之间的关系;他想知道为什么内核需要像 SFrame 这样的功能,而不是简单地使用 ORC。Bhagat 表示,因为 ORC 是应用程序特定格式,它可以表示内核中所有不同类型代码的堆栈使用情况,包括手动编写的汇编代码。但要做用户空间 stack trace 的话,ORC 格式还需要进行一些改动;SFrame 并不是要替代内核内部使用的 ORC,虽然两者都有类似的目标,但是 SFFrame 主要是希望能对 ORC 进行补充,从而可以从内核来进行用户空间 tracing。
全文完 LWN 文章遵循 CC BY-SA 4.0 许可协议。