本文章包含 AI 创作,请仔细甄别。

#目录

  1. 问题现象
  2. 远程 unwinding 的信息来源差异
  3. libunwind 的查表逻辑与 两级 fallback
  4. 为什么缺少 PT_GNU_EH_FRAME 会导致“丢帧”
  5. 加上 --eh-frame-hdr 后为何立即恢复
  6. GDB 的多层策略如何弥补缺口
  7. 澄清unw_step() 在找不到 FDE 时并非总返回 0
  8. 测试用最小示例代码
  9. 快速检查二进制调试信息的命令
  10. 附录:关键源代码片段(GDB & libunwind)

#1. 问题现象

在完全静态链接的可执行文件上,使用 libunwind‑ptrace 做远程栈回溯,得到的结果常见类似:

1
2
#0 __libc_recvfrom
#1 main

而实际逻辑调用序列应为:

1
main → poll_msg → recvfrom

同一进程在 GDB 中执行 bt 却能看到完整链。

#2. 远程 unwinding 的信息来源差异

GDB libunwind‑ptrace
可用 ELF 信息 在磁盘上打开目标 ELF 与 split‑debug,解析 .debug_*, .eh_frame 全部 section 读取进程的 Program Header(segment)映射
CFI 首选 .debug_frame.eh_frame .eh_frame(必须先通过 PT_GNU_EH_FRAME 找到)
备用策略 prologue sniffer → rbp‑链 → heuristic rbp‑链(简化版)

#3. libunwind 的查表逻辑与 两级 fallback

unw_step() 内部主要调用 _Ux86_64_step(),流程如下(伪代码):

1
2
3
4
5
// ① 是否有缓存的 proc_info?若无:
find_proc_info(ip, &pi) →
dwarf_find_unwind_table(pid, ip) // 首选 .eh_frame_hdr 查表
if (!table) // (A) 找不到 FDE
try_framechain(); // (B) 用 rbp 链 fallback

#(A) 没有 FDE 返回 ‑UNW_ENOINFO

  • 本地 unwinder unw_step() 会直接返回 0。
  • remote‑ptrace 实现,函数继续尝试 *(B)*:

#(B) try_framechain()

  • 若当前帧保存了 %rbp%rbp 指向合法栈区, 则认为 [rbp] 存 caller 的 %rbp, [rbp+8] 存 caller 的 %rip
  • 组装一个“人工”上一帧 → unw_step() 返回 > 0。

因此:在缺少 FDE 又恰好保留帧指针时,可以继续退到 main(),但 会跳过所有 frameless 函数(如 poll_msg)。

#4. 为什么缺少 PT_GNU_EH_FRAME 会导致“丢帧”

  • .eh_frame 在静态程序里默认是 SHT_PROGBITS,不在任何 PT_LOAD segment;
  • 远程 unwinder 通过读取 /proc/PID/maps 里的 segment 首地址 + program‑header,只能看到 PT_* 指定的段;
  • 缺少 PT_GNU_EH_FRAME ⇒ 找不到 .eh_frame_hdrfind_proc_info() 失败 ⇒ 触发 (B) fallback,仅凭 %rbp 链。
  • poll_msg 恰好省掉帧指针,于是被跳过。

#5. 加上 --eh-frame-hdr 后为何立即恢复

--eh-frame-hdr 命令让链接器:

  1. 复制 .eh_frame 并生成一个紧凑索引 .eh_frame_hdr
  2. 插入 **PT_GNU_EH_FRAME** program‑header 指向该索引;
  3. 由于 .eh_frame_hdr 属于可加载 segment,远程进程映射可见;

于是 _Ux86_64_dwarf_find_unwind_table() 能定位 FDE → poll_msg 拥有 CFI → 回溯链完整。

#6. GDB 的多层策略如何弥补缺口

GDB 全流程简略:

1
2
3
4
5
6
7
8
9
DWARF‑CFI   (文件级)  → 成功 → 返回

├─ 失败

prologue‑sniffer (反汇编) → 若发现 push %rbp/mov %rsp,%rbp → 手算上一帧

├─ 失败

trad‑rbp‑chain (纯链)

因其拥有磁盘 ELF 信息,不依赖 PT_GNU_EH_FRAME;即便 CFI 缺失,也可借助 sniffer 过 frameless 函数。

#7. 澄清unw_step() 在缺少 FDE 时并非总返回 0

  • 若当前函数缺 CFI 保留 **%rbp**:会走 (B) 策略,返回 1,继续到 caller;因此最终仍能走到 main()
  • 丢帧的根因 是 frameless 函数(无 %rbp)缺 CFI → 在 fallback 中被“跳过”。

#8. 测试用最小示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// demo.c ─── gcc demo.c -static -g -fno-omit-frame-pointer \
// -fasynchronous-unwind-tables -o demo
#include <sys/socket.h>
#include <unistd.h>

__attribute__((noinline,optimize("no-optimize-sibling-calls")))
ssize_t poll_msg(int fd, void *buf, size_t len) {
return recvfrom(fd, buf, len, 0, NULL, NULL);
}

int main() {
char b[1];
poll_msg(0, b, 1);
return 0;
}

#编译对比

链接选项 libunwind 结果 GDB 结果
--eh-frame-hdr __libc_recvfrom → main __libc_recvfrom → poll_msg → main
-Wl,--eh-frame-hdr 与 GDB 相同 相同

#9. 快速检查调试信息的命令

1
2
3
4
5
6
7
8
9
10
11
12
# 是否包含 PT_GNU_EH_FRAME
readelf -l a.out | grep EH_FRAME

# 列出 FDE 覆盖 range,不依赖符号
readelf -wf a.out | head

# 检验函数是否有帧指针
objdump -dS a.out | less # 查找 push %rbp / mov %rsp,%rbp

# 查 libc.a 内部符号及 CFI
ar x /usr/lib/x86_64-linux-gnu/libc.a recvfrom.o
readelf -wf recvfrom.o | head

#10. 附录:关键源码片段

#10.1 libunwind (src/x86_64/Gfind_proc_info.c)

1
2
3
4
5
if (phdr->p_type == PT_GNU_EH_FRAME) {
peh_hdr = phdr;
/* ...读取 hdr, 找到 .eh_frame */
}
/* 如果 peh_hdr 为 NULL → 返回 -UNW_ENOINFO */

#10.2 GDB prologue sniffer (简化)

1
2
3
4
5
6
7
static struct frame_id
prologue_frame_this_id (frame_info *this_frame)
{
/* 反汇编起始几十字节,找 push %rbp 与 mov %rsp,%rbp */
if (found)
compute_previous_frame(...);
}

#结语

libunwind 的“缺帧”症状来源于 运行时只能依赖 PT_GNU_EH_FRAME 的设计取舍;当 segment 信息不足或函数 frameless 且无 CFI 时,只能退回极简 %rbp 链,导致中间帧被跳过。GDB 由于离线 ELF + 多级策略,自然更完善。对开发者而言,在链接阶段加 --eh-frame-hdr、保留 CFI 或帧指针,是让任何基于 libunwind 的调试/分析工具完整回溯的关键。