介绍 Linux 函数调用栈生成和展开(stack unwinding)机制。
函数调用栈展开(stack unwinding)指的是获得函数调用栈的过程,当前有几种实现方式:
- FP:frame pointer;
- DWARF CFI:Call Frame Information,如 .eh_frame Section 信息;
- ORC: 4.14 及以后版本内核专用,简化版的 DWARF CFI;
- LBR: 新的 Intel CPU 支持;
FP #
frame pointer(FP) 是通过一个特定的 CPU 寄存器(rbp)来保存栈指针,由编译器在函数调用和退出时添加额外的指令来保存、恢复该寄存器中保存的 frame pointer。
基本原理:函数调用时,gcc 填充的指令会将函数参数 args、函数自动变量、函数返回地址、当前栈指针(rsp 寄存器中保存)push 当前 frame stack,并将 frame stack 地址存入 rpb 寄存器。当前 frame 的函数返回地址位于 rbp+8 内存 中,通过查找二进制符号表(.symtab) 即可获得该地址的函数名称。这些细节是体系结构相关的 ABI 如 X86_64 ABI,来标准化定义的。
测试程序:
// https://medium.com/coccoc-engineering-blog/things-you-should-know-to-begin-playing-with-linux-tracing-tools-part-i-x-225aae1aaf13
#include <stdio.h>
#include <unistd.h>void func_d() {
int msec=1;
printf("%s","Hello world from D\n");
usleep(10000*msec);
}
void func_c() {
printf("%s","Hello from C\n");
func_d();
}
void func_b() {
printf("%s","Hello from B\n");
func_c();
}
void func_a() {
printf("%s","Hello from A\n");
func_b();
}
int main() {
func_a();
}
编译,确认 gcc 在函数开始插入了保存 FP 的指令:
# 编译(不开启优化,默认添加 FP)
root@lima-ebpf-dev:~# gcc test.c -o test
root@lima-ebpf-dev:~# objdump -S test |grep func_c
000000000000119e <func_c>:
11de: e8 bb ff ff ff call 119e <func_c>
root@lima-ebpf-dev:~# objdump -S test |grep -A5 func_c
000000000000119e <func_c>:
119e: f3 0f 1e fa endbr64
11a2: 55 push %rbp
11a3: 48 89 e5 mov %rsp,%rbp # 将函数返回地址 push 到当前栈
11a6: 48 8d 05 6a 0e 00 00 lea 0xe6a(%rip),%rax # 2017 <_IO_stdin_used+0x17>
11ad: 48 89 c7 mov %rax,%rdi
--
11de: e8 bb ff ff ff call 119e <func_c>
11e3: 90 nop
11e4: 5d pop %rbp
11e5: c3 ret
00000000000011e6 <func_a>:
root@lima-ebpf-dev:~#
打印函数栈:
-
使用 bpftrace 的 uprobe + ustack
root@lima-ebpf-dev:~# bpftrace -e 'uprobe:./hello:func_d {printf("%s",ustack)}' -c ./hello Attaching 1 probe... Hello from A Hello from B Hello from C Hello world from D func_d+0 func_b+33 func_a+33 main+18 __libc_start_call_main+128
-
使用 perf probe + record + script
# 保存 FP root@lima-ebpf-dev:~# gcc test.c -g -o test # 添加 uprobe 函数 root@lima-ebpf-dev:~# perf probe -x ./hello func_d # -g 表示记录函数调用栈,默认使用 FP root@lima-ebpf-dev:~# perf record -e probe_hello:func_d -aR -g ./hello # 显示函数调用栈 root@lima-ebpf-dev:~# perf script hello 14239 [002] 5516.587824: probe_hello:func_d: (561112f12169) 561112f12169 func_d+0x0 (/root/hello) 561112f121e3 func_b+0x21 (/root/hello) 561112f12207 func_a+0x21 (/root/hello) 561112f1221c main+0x12 (/root/hello) 7fe3e67e1d90 __libc_start_call_main+0x80 (/usr/lib/x86_64-linux-gnu/libc.so.6)
GCC 优化和 FP 关闭 #
FP 是用户空间程序通用的 stack unwinding 机制,但是由于性能和开销问题,从 GCC 4.6 开始,各大发行版 (Centos7/Ubuntu 20.04)发布的软件包都默认关闭了 FP,转而使用 DWARF CFI 的 .eh_frame
来做 stack unwinding。
Golang 从 1.7 开始对 amd64 提供 FP 的支持。(internal-abi.md)
从 GCC 4.6 开始,只要开启了优化就会关闭 FP,所以如果要开启 FP,则需要:
- 不开启优化,不使用任何 -O 选项或指定 -O0;
- 或者明确指定编译参数:
-fno-omit-frame-pointer
或--enable-frame-pointer
;
参考:
- Writing a Linux Debugger Part 8: Stack unwinding
- https://cs.wellesley.edu/~cs240/s16/slides/x86-procedures.pdf
- https://inst.eecs.berkeley.edu/~cs161/sp15/discussions/dis06-assembly.pdf
DWARF CFI #
FP 并不是运行程序所必须的, 一般只在调试和性能分析时才需要。而编译器了解所有函数栈的大小和分配情况, 所以在编译时可以将函数栈信息写入 ELF 的 .debug_XX Sections
中,后续调试器通过读取这些内容来判断函数调用关系,这就出现了 DWARF CFI 规范.
DWARF CFI(DWARF 3 Spec) 是一种在 EFL 文件的 .debug_frame Section
中保存 stack unwinding 信息的规范:
- 通过编译时添加 -g 选项来生成;
.debug_frame
不需要加载到内存,可以位于二进制外的单独 debuginfo 文件中;- 使用 readelf -w 来查看 .debug_frame 的内容;
在 CFI 的基础上,又提出了 exception handler framework(.eh_frame) 规范,用来解决 C++ 等语言的异常处理时的调用栈展开问题。.eh_frame
是 .debug_frame
的子集,也称为 unwind table,遵从 DWARF CFI Extensions 规范。
gcc 默认会生成 .eh_frame
, 它也不会被 strip,会被加载到内存。
使用 gcc 编译参数 -fno-exceptions
和 -fno-unwind-tables
参数来关闭生成 .eh_frame
。
使用 readelf -Wwf
来查看 .eh_frame 内容。
Kernel ORC #
Linus 反对内核自身使用 DWARF 来调用栈展开,所以 DWARF CFI 的 .debug_frame 只在用户空间程序使用。
老版本内核坚守 FP机制,从 v4.14 开始(CentOS 8 开始),内核开始使用 ORC 机制来作为 DWARF 的简化实现。
内核编译选项:
- CONFIG_FRAME_POINTER:开启 FP,支持的版本: 2.6.9–2.6.39, 3.0–3.19, 4.0–4.20, 5.0–5.19, 6.0–6.4, 6.5-rc+HEAD
- CONFIG_UNWINDER_ORC: 开启 ORC,支持的版本:X86_64, 4.15–4.20, 5.0–5.19, 6.0–6.4, 6.5-rc+HEAD
# 17E 的情况:关闭了 FRAME_POINTER, 使用 ORC
# grep -E 'UNWINDER_ORC|FRAME_POINTER' /boot/config-4.19.91-007.ali4000.alios7.x86_64
CONFIG_SCHED_OMIT_FRAME_POINTER=y
CONFIG_UNWINDER_ORC=y
# CONFIG_UNWINDER_FRAME_POINTER is not set
支持情况 #
对于 DWARF 的解析,主要有两个库:
BFD (libbfd)
:被GNU binutils
使用,如 objdump、ld、as 等。libdwarf
和libelf
: 主要是 Solaris 和 FreeBSD 系统使用;
对于 stack unwinding 的支持,主要有两个库,它们都支持 FP 和 DWARF .eh_frame、.debug_frame
:
libundwind
: 使用 DWARF debuginfo;libdw
: elfutils 项目提供,性能比 libunwind 好一些。
perf/bpftrace
等工具使用的是 libdw,perf tools: Add libdw DWARF unwind support
# perf 依赖 libdw,由 elfutils-libs 包提供
# ldd /usr/bin/perf |grep -E 'dw|unw'
libdw.so.1 => /lib64/libdw.so.1 (0x00007f5a5cf5f000)
# rpm -qf /lib64/libdw.so.1
elfutils-libs-0.176-4.1.alios7.x86_64
gdb、perf 等均通过 libundwind 和 libdw 来屏蔽了实现差异,所以同时支持 FP 和 DWARF CFI。
但是 bpf、bpftrace 等不支持使用 DWARF 做栈展开,所以在关闭 FP 后,uprobe、usdt 等不可用,不能正确打印调用栈。