文章目录
前言
基于FP的栈回溯请参考:
Linux x86_64 基于FP栈回溯
Linux ARM64 基于FP栈回溯
基于FP栈回溯需要一个专门寄存器RBP来保存frame poniter。
gcc优化选项 -O 默认使用-fomit-frame-pointer编译标志进行优化,省略帧指针。将寄存器RBP作为一个通用的寄存器来使用。
-fomit-frame-pointer 是GCC编译器的一个编译选项。当启用该选项时,它告诉编译器在不需要基指针的函数中省略基指针。通过省略基指针,编译器避免了保存、设置和恢复基指针的指令,从而使生成的代码更小、更快。
省略基指针还提供了一个额外的通用寄存器可供使用,这对于具有有限通用寄存器数量的架构(如x86)非常有用。
这样就不能基于FP来进行栈回溯了,Linux通过.eh_frame节可以来进行栈回溯,.eh_frame节通常由编译器(如GCC)在编译可执行文件或共享库时生成。调试器(如GDB)能够读取并解析这些节,以提供强大的调试功能。
在x86_64体系架构上,大多数软件在编译的时采用了gcc的默认选项,而gcc的默认选项不启用函数帧指针FP,而是把RBP寄存器作为一个通用的寄存器,以及无法进行FP进行栈回溯,因此对于用户空间程序,通常使用.eh_frame section 来进行栈回溯。
.eh_frame段中存储着跟函数入栈相关的关键数据。
当函数执行入栈指令后,在该段会保存跟入栈指令一一对应的编码数据,
根据这些编码数据,就能计算出当前函数栈大小和cpu的哪些寄存器入栈了,在栈中什么位置。
和基于fp的栈回溯不同的是,基于DWARF的栈回溯(理论上)还可以回溯各个寄存器在每一个栈帧中的值。
无论是否有-g选项,gcc默认都会生成.eh_frame和.eh_frame_hdr section。
gcc编译过程中会为当前函数布局函数栈,函数栈中的每一个元素位置最终都会对应当基于硬件寄存器sp或fp的偏移。
一、LSB
Linux Standard Base(LSB)定义了编译应用程序的系统接口和支持安装脚本的最小环境。其目的是为符合LSB的大规模应用程序提供统一的行业标准环境。
LSB规范由两个基本部分组成:一个通用部分,描述了在LSB的所有实现中保持不变的接口部分;以及一个特定于体系结构的部分,描述了根据处理器体系结构而变化的接口部分。通用部分和特定于体系结构的部分共同为具有相同硬件体系结构的系统上的编译应用程序提供了完整的接口规范。
LSB包含一组应用程序接口(API)和应用程序二进制接口(ABI)。API可以出现在可移植应用程序的源代码中,而该应用程序的编译二进制文件可以使用更大的一组ABIs。符合规范的实现提供了这里列出的所有ABIs。编译系统可以通过替换(例如通过宏定义)某些API,将其调用转换为一个或多个底层二进制接口的调用,并根据需要插入对二进制接口的调用。
LSB是由Linux Foundation组织架构下的多个Linux发行版共同参与的项目,旨在标准化软件系统结构,包括文件系统层次结构(Filesystem Hierarchy Standard)。LSB基于POSIX规范、Single UNIX Specification(SUS)和其他几个开放标准,但在某些领域进行了扩展。
根据LSB:
LSB的目标是开发和推广一组开放标准,增加Linux发行版之间的兼容性,并使软件应用程序能够在任何符合标准的系统上运行,即使是以二进制形式。此外,LSB还将协调努力,吸引软件供应商为Linux操作系统移植和编写产品。
二、The .eh_frame section
2.1 简介
在Linux系统中,.eh_frame节是一种特殊的节(section),用于存储程序的调试信息和堆栈回溯相关的信息。
这个节通常在可执行文件或共享库中存在,以支持运行时的调试和异常处理。
当程序在Linux系统中进行异常处理和堆栈展开时,会使用到.eh_frame节。.eh_frame节是基于DWARF(Debugging With Attributed Record Formats)调试格式的一部分。
.eh_frame节的主要作用是提供运行时支持,用于正确展开函数调用堆栈。增加.eh_frame段的目的就是为了让其加载到运行时内存中。它存储了一系列编码的调用帧信息,这些信息在异常处理或进行堆栈回溯时起到关键作用。
在异常发生或需要进行堆栈回溯时,运行时系统会利用.eh_frame节中的信息来展开堆栈。它会遵循编码的CFI(Call Frame Information)指令序列,逐层遍历堆栈帧,获取返回地址,并找到对应的异常处理程序或回溯信息。
根据.eh_frame节中的信息和 dwarf 信息来就可以分析出函数的栈信息,基本可以还原函数入栈的所有寄存器信息。
# readelf -S a.out
共有 30 个节头,从偏移量 0x1930 开始:
节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
......
[16] .eh_frame_hdr PROGBITS 00000000004005c0 000005c0
000000000000003c 0000000000000000 A 0 0 4
[17] .eh_frame PROGBITS 0000000000400600 00000600
0000000000000114 0000000000000000 A 0 0 8
......
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
A (alloc)
.eh_frame带有SHF_ALLOC flag(标志一个section是否应为内存中镜像的一部分)。
.eh_frame节包含了用于栈回溯和异常处理的数据结构,其中包括编码了调用帧信息、异常处理表和其他相关数据的指令序列。这些信息用于在程序运行时进行堆栈展开(stack unwinding),即在异常发生时回溯函数调用堆栈以查找异常处理程序。这些结构可以在程序运行时被调试器或其他工具使用。它们提供了关于函数调用链、寄存器状态和局部变量等信息的详细描述,以便进行调试和错误诊断。
当程序中包含异常处理机制(如C++异常)或使用与堆栈相关的特性(如backtrace函数)时,编译器会生成和使用.eh_frame节。这些信息允许运行时系统在异常处理期间正确地展开函数调用堆栈,并将控制权传递给适当的异常处理程序。
尽管.eh_frame增加了可执行文件的大小,但它提供了重要的运行时支持和调试功能。然而,对于某些嵌入式系统或特定的应用程序,可能需要最小化可执行文件的大小,并且不需要异常处理和调试功能。在这种情况下,可以使用编译器选项(如-fno-asynchronous-unwind-tables)来禁用.eh_frame的生成,以减少可执行文件的大小。
.eh_frame中的数据结构通常使用一种称为DWARF(Debugging With Arbitrary Record Formats)的格式进行编码。
DWARF是一种调试信息格式,广泛用于Linux系统和其他类Unix系统中。它定义了一组规范,用于描述程序的调试信息,包括函数、类型、变量、源代码映射等。
通过解析.eh_frame节中的DWARF数据,调试器可以还原函数调用堆栈,获取函数的参数和局部变量值,以及跟踪函数调用的路径。这对于调试复杂的程序、分析错误和优化代码非常有帮助。
.eh_frame节应包含一个或多个调用帧信息(CFI - Call Frame Information)记录。存在的记录数量应由节头中包含的节大小确定。每个CFI记录包含一个通用信息条目(CIE - Common Information Entry)记录,后面跟着一个或多个帧描述条目(FDE - Frame Description Entry)记录。CIE和FDE都应对齐到地址单元大小的边界。
Call Frame Information Format:
----------------------------------
Common Information Entry Record
----------------------------------
Frame Description Entry Record(s)
----------------------------------
如下图所示:
2.2 CFI
CFI(Call Frame Information):CFI(调用帧信息)是一组用于描述函数调用栈布局的元数据,使得调试器(如GDB)和异常处理库(如libunwind)能够在程序运行时正确展开调用栈(stack unwinding)。GAS(GNU Assembler)通过伪指令(directives)生成这些信息,并将其嵌入到目标文件的 .eh_frame 或 .debug_frame 节中,遵循 DWARF 调试格式标准。
任何栈指针变化都需同步更新 CFA 规则。
CFI支持:
栈展开(Stack Unwinding):在异常处理、性能分析或调试时,确定函数的返回地址、局部变量和寄存器状态。
调试支持:允许调试器回溯函数调用链。
CFI 伪指令分类:
详细请参考:https://sourceware.org/binutils/docs-2.31/as/CFI-directives.html
这里介绍一些重要的CFI指令:
GAS 提供以下核心伪指令生成 CFI 信息:
(1).cfi_sections section_list
.cfi_sections 是 GAS(GNU Assembler) 提供的一个伪指令,用于控制 CFI(Call Frame Information) 数据的输出位置,决定生成的调试信息是存储在 .eh_frame 节、.debug_frame 节还是其他特定目标平台支持的节。
section_list 可以是以下选项之一:
.eh_frame:生成标准的 .eh_frame 节(默认)。
.debug_frame:生成 .debug_frame 节(DWARF 调试信息)。
.eh_frame, .debug_frame:同时生成两个节。
.eh_frame_entry:某些平台支持紧凑展开表(Compact Unwinding Tables)。
其他目标特定的节。
默认行为:如果未指定 .cfi_sections,则默认生成 .eh_frame。
.cfi_startproc
# ... 函数代码 ...
.cfi_endproc
同时生成 .eh_frame 和 .debug_frame
.cfi_sections .eh_frame, .debug_frame
.cfi_startproc
# ... 函数代码 ...
.cfi_endproc
(2) .cfi_startproc [simple]
.cfi_startproc 是 GAS(GNU Assembler) 提供的 CFI(Call Frame Information)伪指令,用于标记一个函数的开始,并初始化与栈展开(stack unwinding)相关的内部数据结构。它必须与 .cfi_endproc 配对使用,以正确生成 .eh_frame 或 .debug_frame 节中的调用帧信息。
基本语法:
.cfi_startproc # 标准形式,生成目标平台默认的初始 CFI 指令
.cfi_startproc simple # 简化形式,不生成初始 CFI 指令
不带参数:生成目标平台默认的初始 CFI 指令(如定义 CFA 规则)。
带 simple 参数:仅初始化 CFI 数据结构,不自动生成任何 CFI 指令,需手动定义所有规则。
作用:
标记函数入口
告诉汇编器从这里开始记录 CFI 信息,生成对应的 FDE(Frame Description Entry)。
初始化 CFI 状态
设置默认的寄存器规则和 CFA(Canonical Frame Address)计算方式(除非使用 simple 模式)。
代码示例(标准模式示例(x86-64)):
.globl my_func
.type my_func, @function
my_func:
.cfi_startproc # 开始 CFI 生成,自动插入初始指令
pushq %rbp
.cfi_def_cfa_offset 16 # 手动调整 CFA 偏移(push 后 rsp-8)
.cfi_offset %rbp, -16 # 保存的 rbp 位于 CFA-16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp # CFA 现在基于 rbp
# ... 函数体 ...
popq %rbp
.cfi_def_cfa %rsp, 8 # 恢复 CFA 为 rsp+8
ret
.cfi_endproc # 结束 CFI 生成
自动生成的初始指令(取决于平台):
默认模式:自动生成平台相关的初始 CFI 指令(如 CFA = rsp + 8)。
例如,x86-64 可能会默认设置 CFA = rsp + 8(返回地址压栈后的状态)。
(3).cfi_endproc
.cfi_endproc 是 GAS(GNU Assembler) 提供的 CFI(Call Frame Information)伪指令,用于标记一个函数的结束,并关闭由 .cfi_startproc 打开的栈展开信息条目(FDE, Frame Description Entry)。它的核心功能是 将当前函数的 CFI 规则序列化并写入 .eh_frame 或 .debug_frame 节,确保调试器或异常处理机制能正确展开该函数的栈帧。
.cfi_startproc
# ... 函数代码 ...
.cfi_endproc # 结束 CFI 生成
必须与 .cfi_startproc 成对出现,否则会导致 CFI 数据不完整。
无参数,直接放置在函数末尾(通常紧邻 ret 指令)。
x86-64 函数示例:
.globl my_func
.type my_func, @function
my_func:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16 # CFA = rsp + 16(push后rsp-8)
.cfi_offset %rbp, -16 # 旧rbp保存在CFA-16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp # CFA = rbp + 16
subq $32, %rsp
.cfi_def_cfa_offset 48 # CFA = rbp + 48(16 + 32)
# ... 函数体 ...
addq $32, %rsp # 恢复栈指针
.cfi_def_cfa_offset 16 # 更新 CFA 偏移
popq %rbp
.cfi_def_cfa %rsp, 8 # CFA = rsp + 8(pop后rsp+8)
.cfi_restore %rbp # 标记rbp已恢复
ret
.cfi_endproc # 结束并生成 FDE
CFI 规则需对称恢复
函数结束时,CFA 和寄存器规则应恢复到与入口一致的状态(如 x86-64 的 CFA = rsp + 8)。
(4).cfi_def_cfa register, offset
.cfi_def_cfa 是 GAS(GNU Assembler) 提供的 CFI(Call Frame Information)伪指令,用于 显式定义规范帧地址(Canonical Frame Address, CFA)的计算规则。CFA 是调试器和异常处理机制(如 libunwind)在栈展开(stack unwinding)时用于定位调用栈帧的基准地址。
基本语法:
.cfi_def_cfa <register>, <offset>
:基址寄存器(如 rsp、rbp),使用 DWARF 寄存器编号 或平台特定名称(如 %rsp)。
:有符号整数偏移量(通常为非负数),表示从寄存器值的偏移。
作用:
定义 CFA 的计算公式为:
CFA=register+offset
建立栈帧基准:明确指定如何计算当前函数的栈帧起始地址(CFA),供调试器或异常处理使用。
响应栈指针变化:在 push/pop 或 sub/add 修改栈指针后,需同步更新 CFA 规则。
默认基址寄存器register = rsp。
x86_64的register编号从0-15对应下表。rbp的register编号为6,rsp的register编号为7。
%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15
Register Name Number Abbreviatio
General Purpose Register RAX 0 %rax
General Purpose Register RDX 1 %rdx
General Purpose Register RCX 2 %rcx
General Purpose Register RBX 3 %rbx
General Purpose Register RSI 4 %rsi
General Purpose Register RDI 5 %rdi
Frame Pointer Register RBP 6 %rbp
Stack Pointer Register RSP 7 %rsp
Extended Integer Registers 8-15 8-15 %r8–%r15
Return Address RA 16
CFA的计算可以是以下:
CFA = rsp + offset (默认的计算方式)
CFA = rax + offset
CFA = rdx + offset
CFA = rcx + offset
CFA = rbx + offset
CFA = rsi + offset
CFA = rdi + offset
CFA = rbp + offset
(5).cfi_def_cfa_register register
.cfi_def_cfa_register 是 GNU 汇编器(GAS)中用于 动态修改 CFA(Canonical Frame Address)计算规则中基址寄存器 的 CFI 伪指令。它仅替换 CFA 计算时使用的寄存器,而保持原有的偏移量不变。
作用:修改 CFA 计算公式中的 基址寄存器,不改变偏移量。
规则变化:
原规则:CFA = <old_reg> +
新规则:CFA = <new_reg> +
CFA的默认基址寄存器是%rsp,可以修改为%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp等。
备注:
必须提前定义 CFA 规则
.cfi_def_cfa_register 必须在 .cfi_def_cfa 或 .cfi_def_cfa_offset 之后调用,否则无基准偏移量。
偏移量不会自动调整
若新寄存器的值与原寄存器不同(如 rbp = rsp + 8),需确保偏移量仍然正确。
比如:函数序言中切换至帧指针
my_function:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16 # CFA = rsp + 16(push后rsp-8)
.cfi_offset %rbp, -16 # 旧rbp保存在CFA-16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp # CFA = rbp + 16(偏移不变,寄存器更新)
# ... 函数体 ...
恢复原始寄存器
函数结束时通常需恢复原 CFA 寄存器(如通过 .cfi_def_cfa)
(6).cfi_def_cfa_offset offset
.cfi_def_cfa_offset 是 GNU 汇编器(GAS)中用于 修改规范帧地址(CFA)计算规则中的偏移量 的 CFI 伪指令。它直接覆盖原有的偏移值,而不改变基址寄存器。
作用:更新 CFA 计算公式中的 偏移量,保持基址寄存器不变。
规则变化:
原规则:CFA = + <old_offset>
新规则:CFA = + <new_offset>
关键特性:
绝对偏移:直接指定新偏移值(非增量)。
寄存器不变:仅调整偏移部分。
必须与寄存器规则配合
需先通过 .cfi_def_cfa 或 .cfi_def_cfa_register 定义基址寄存器。
2.3 CIE
CIE:描述通用的栈帧布局规则(如初始寄存器状态、编码方式),记录的是一些公用的入口信息,包括异常的handler,数据增强信息等。
每个函数都会对应一个CIE, 一个CIE可以供多个函数共用, 如果两个函数拥有相同的初始化指令序列那么他们通常指向同一个CIE。其格式如下:
CIE — Common Information Entry Format:
Length | Required |
Extended Length | Optional |
CIE ID | Required |
Version | Required |
Augmentation String | Required |
Code Alignment Factor | Required |
Data Alignment Factor | Required |
Return Address Register | Required |
Augmentation Data Length | Optional |
Augmentation Data | Optional |
Initial Instructions | Required |
Padding |
// libunwind/include/dwarf.h
typedef struct dwarf_cie_info
{
unw_word_t cie_instr_start; /* start addr. of CIE "initial_instructions" */
unw_word_t cie_instr_end; /* end addr. of CIE "initial_instructions" */
unw_word_t fde_instr_start; /* start addr. of FDE "instructions" */
unw_word_t fde_instr_end; /* end addr. of FDE "instructions" */
unw_word_t code_align; /* code-alignment factor */
unw_word_t data_align; /* data-alignment factor */
unw_word_t ret_addr_column; /* column of return-address register */
unw_word_t handler; /* address of personality-routine */
uint16_t abi;
uint16_t tag;
uint8_t fde_encoding;
uint8_t lsda_encoding;
unsigned int sized_augmentation : 1;
unsigned int have_abi_marker : 1;
unsigned int signal_frame : 1;
}
dwarf_cie_info_t;
(1)Length
一个4字节的无符号值,表示CIE结构的长度(以字节为单位),不包括Length字段本身。如果Length字段的值为0xffffffff,则长度包含在Extended Length字段中。如果Length字段的值为0,则此CIE应被视为终止符,并且处理将结束。
(2)Extended Length
这个8字节的无符号值表示CIE结构的字节长度,不包括长度字段和扩展长度字段本身。除非长度字段包含值0xffffffff,否则该字段不存在。
(3)CIE ID
这个4字节的无符号值用于区分CIE(Common Information Entry)记录和FDE(Frame Description Entry)记录。该值应始终为0,表示该记录是一个CIE。
static inline int
is_cie_id (unw_word_t val, int is_debug_frame)
{
/* The CIE ID is normally 0xffffffff (for 32-bit ELF) or
0xffffffffffffffff (for 64-bit ELF). However, .eh_frame
uses 0. */
if (is_debug_frame)
return (val == (uint32_t)(-1) || val == (uint64_t)(-1));
else
return (val == 0);
}
(4)Version
这个1字节的值用于标识帧信息结构的版本号。该值应为1。
/* Read the return-address column either as a u8 or as a uleb128. */
if (version == 1)
{
if ((ret = dwarf_readu8 (as, a, &addr, &ch, arg)) < 0)
return ret;
dci->ret_addr_column = ch;
}
(5)Augmentation String
这个值是一个以NUL(空字符)结尾的字符串,用于标识与该CIE或与该CIE关联的FDE的增强信息。如果字符串长度为零,则表示没有增强数据存在。增强字符串是区分大小写的,并且应按照下面的描述进行解释。
/* read and parse the augmentation string: */
memset (augstr, 0, sizeof (augstr));
for (i = 0;;)
{
if ((ret = dwarf_readu8 (as, a, &addr, &ch, arg)) < 0)
return ret;
if (!ch)
break; /* end of augmentation string */
if (i < sizeof (augstr) - 1)
augstr[i++] = ch;
}
(6)Code Alignment Factor
这个值是一个无符号的LEB128编码值,它被从与该CIE或其FDE关联的所有"advance location"指令中分解出来。该值应与"advance location"指令的增量参数相乘,以获得新的位置值。
(7)Data Alignment Factor
这个值是一个带符号的LEB128编码值,它被从与该CIE或其FDE关联的所有偏移指令中分解出来。该值应与偏移指令的寄存器偏移参数相乘,以获得新的偏移值。
if ((ret = dwarf_read_uleb128 (as, a, &addr, &dci->code_align, arg)) < 0
|| (ret = dwarf_read_sleb128 (as, a, &addr, &dci->data_align, arg)) < 0)
return ret;
(8)Augmentation Length
这个值是一个无符号的LEB128编码值,用于表示增强数据的字节长度。只有当增强字符串中包含字符’z’时,该字段才存在。
(9)Augmentation Data
这是一个数据块,其内容由增强字符串中的内容定义,具体描述如下。只有当增强字符串中包含字符’z’时,该字段才存在。该数据块的大小由增强长度(Augmentation Length)指定。
(9)Initial Instructions
这是初始的调用帧指令集。指令的数量由CIE记录中剩余的空间确定。
(10)Padding
这些额外的字节用于将CIE结构对齐到地址单元大小边界。
2.1.1 Augmentation String Format
增强字符串指示了一些可选字段的存在以及如何解释这些字段。该字符串区分大小写。CIE中增强字符串中的每个字符的解释如下:
‘z’:
字符串的第一个字符可以是’z’。如果存在,则增强数据字段也必须存在。增强数据的内容将根据增强字符串中的其他字符进行解释。
‘L’:
字符串的第一个字符是’z’时,可以在任何位置上出现’L’。如果存在,它表示CIE的增强数据中存在一个参数,并且FDE的增强数据中也存在相应的参数。CIE的增强数据中的参数是1字节,表示用于FDE的增强数据中的参数的指针编码,该参数是指向特定语言数据区(LSDA)的地址。LSDA指针的大小由使用的指针编码指定。
‘P’:
字符串的第一个字符是’z’时,可以在任何位置上出现’P’。如果存在,它表示CIE的增强数据中存在两个参数。第一个参数是1字节,表示用于第二个参数的指针编码,该参数是指向人格例程处理程序的地址。人格例程用于处理特定语言和供应商的任务。系统解旋库接口通过指向人格例程的指针访问特定语言的异常处理语义。个性例程没有ABI-specific的名称。个性例程指针的大小由使用的指针编码指定。
‘R’:
字符串的第一个字符是’z’时,可以在任何位置上出现’R’。如果存在,则增强数据中应包含一个1字节的参数,该参数表示FDE中使用的地址指针的指针编码。
i = 0;
if (augstr[0] == 'z')
{
dci->sized_augmentation = 1;
if ((ret = dwarf_read_uleb128 (as, a, &addr, &aug_size, arg)) < 0)
return ret;
i++;
}
for (; i < sizeof (augstr) && augstr[i]; ++i)
switch (augstr[i])
{
case 'L':
/* read the LSDA pointer-encoding format. */
if ((ret = dwarf_readu8 (as, a, &addr, &ch, arg)) < 0)
return ret;
dci->lsda_encoding = ch;
break;
case 'R':
/* read the FDE pointer-encoding format. */
if ((ret = dwarf_readu8 (as, a, &addr, &fde_encoding, arg)) < 0)
return ret;
break;
case 'P':
/* read the personality-routine pointer-encoding format. */
if ((ret = dwarf_readu8 (as, a, &addr, &handler_encoding, arg)) < 0)
return ret;
if ((ret = dwarf_read_encoded_pointer (as, a, &addr, handler_encoding,
pi, &dci->handler, arg)) < 0)
return ret;
break;
case 'S':
/* This is a signal frame. */
dci->signal_frame = 1;
/* Temporarily set it to one so dwarf_parse_fde() knows that
it should fetch the actual ABI/TAG pair from the FDE. */
dci->have_abi_marker = 1;
break;
default:
Debug (1, "Unexpected augmentation string `%s'\n", augstr);
if (dci->sized_augmentation)
/* If we have the size of the augmentation body, we can skip
over the parts that we don't understand, so we're OK. */
goto done;
else
return -UNW_EINVAL;
}
2.4 FDE
FDE中记录了此函数栈回溯相关的信息, 最主要的是记录了一系列指令序列(CFI,Call Frame Infomation), 此指令序列可用来确定此函数执行到其每个地址时其各个寄存器的值应该如何获取/计算, 每个函数都有且仅有一个FDE结构体。
FDE:描述特定函数范围的栈帧信息(如地址范围、如何恢复寄存器和 CFA)。
每个 FDE 包含:
PC 范围:该 FDE 适用的代码地址范围(initial_location 和 address_range)。
CFA 计算规则:如何从当前栈帧计算 CFA(即调用者的栈指针)。
寄存器恢复规则:如何从栈中恢复寄存器的值(如返回地址、通用寄存器)。
其格式如下:
FDE — Frame Description Entry Format:
Length | Required |
Extended Length | Optional |
CIE Pointer | Required |
PC Begin | Required |
PC Range | Required |
Augmentation Data Length | Optional |
Augmentation Data | Optional |
Call Frame Instructions | Required |
Padding |
(1)Length
一个4字节的无符号值,表示FDE(Frame Description Entry)结构的长度(以字节为单位),不包括Length字段本身。如果Length字段的值为0xffffffff,则长度包含在Extended Length字段中。如果Length字段的值为0,则该FDE应被视为终止器,并且处理过程应该结束。
(2)Extended Length
一个8字节的无符号值,表示FDE(Frame Description Entry)结构的长度(以字节为单位),不包括Length字段或Extended Length字段本身。除非Length字段的值为0xffffffff,否则该字段不会出现。
(3)CIE Pointer
一个4字节的无符号值,从当前FDE中的CIE指针的偏移量中减去,得到关联CIE的起始偏移量。该值永远不应为0。
(4)PC Begin
一个编码值,表示与该FDE关联的初始位置的地址。编码格式在增强数据(Augmentation Data)中指定。
(5)PC Range
一个绝对值,表示与该FDE关联的指令字节数。
(6)Augmentation Length
一个无符号 LEB128 编码值,表示增强数据的字节长度。只有在关联的CIE中的增强字符串包含字符 ‘z’ 时,该字段才存在。
(7)Augmentation Data
一个数据块,其内容由关联CIE中的增强字符串的内容所定义,如上所述。只有当关联CIE中的增强字符串包含字符 ‘z’ 时,该字段才存在。该数据块的大小由增强长度(Augmentation Length)给出。
(8)Call Frame Instructions
一组调用帧指令(Call Frame Instructions)。
(9)Padding
用于将FDE(Frame Description Entry)结构对齐到一个地址单元大小边界的额外字节。
2.4.1 readelf -wF
readelf -w:Displays the contents of the DWARF debug sections in the file
"F"
"=frame-interp"
Display the interpreted contents of a .debug_frame section.
$ readelf -wF /usr/bin/ls
Contents of the .eh_frame section:
......
00002f60 0000000000000044 00002f64 FDE cie=00000000 pc=00000000000174e0..0000000000017545
LOC CFA rbx rbp r12 r13 r14 r15 ra
00000000000174e0 rsp+8 u u u u u u c-8
00000000000174e6 rsp+16 u u u u u c-16 c-8
00000000000174ef rsp+24 u u u u c-24 c-16 c-8
00000000000174f4 rsp+32 u u u c-32 c-24 c-16 c-8
00000000000174f9 rsp+40 u u c-40 c-32 c-24 c-16 c-8
00000000000174fd rsp+48 u c-48 c-40 c-32 c-24 c-16 c-8
0000000000017505 rsp+56 c-56 c-48 c-40 c-32 c-24 c-16 c-8
000000000001750c rsp+64 c-56 c-48 c-40 c-32 c-24 c-16 c-8
000000000001753a rsp+56 c-56 c-48 c-40 c-32 c-24 c-16 c-8
000000000001753b rsp+48 c-56 c-48 c-40 c-32 c-24 c-16 c-8
000000000001753c rsp+40 c-56 c-48 c-40 c-32 c-24 c-16 c-8
000000000001753e rsp+32 c-56 c-48 c-40 c-32 c-24 c-16 c-8
0000000000017540 rsp+24 c-56 c-48 c-40 c-32 c-24 c-16 c-8
0000000000017542 rsp+16 c-56 c-48 c-40 c-32 c-24 c-16 c-8
0000000000017544 rsp+8 c-56 c-48 c-40 c-32 c-24 c-16 c-8
这张表的结构设计如下:
1、第一列。表示程序中包含代码的每个位置的地址。(在共享对象中,这是一个相对于对象的偏移量。)其余的列包含与指示的位置关联的虚拟展开规则。
2、CFA列。定义了计算规范帧地址值(Canonical Frame Address)的规则;它可以是寄存器(register)和带符号的偏移量(signed offset)加在一起,也可以是DWARF表达式(DWARF expression)的求值。
3、其余列。由寄存器编号标记。 其中包括一些在某些架构上具有特殊名称的寄存器,例如PC和堆栈指针寄存器。 (用于特定体系结构的寄存器的实际映射由扩充器定义。)寄存器列包含规则用来描述是否已保存给定寄存器以及在上一帧中查找寄存器值的规则
rbx、rbp,r12,r13,r14,r15 是被调用者保存寄存器 callee-saved registers。函数A调用函数B,函数B必须保存函数A 的 rbx、rbp,r12,r13,r14,r15寄存器的值,函数B将这些寄存器保存在自己的栈帧里。
对于LOC条目 = 0x17544:
CFA = rsp + 8 //根据CFA的值计算出被调用者保存寄存器和返回地址在堆栈中的位置。
rbx = CFA -56
......
ra = CFA -8
CFA 是父函数A的栈顶地址:上一级调用者的堆栈SP指针。
基于DWARF的栈回溯的原理是, 每个函数的FDE指令序列实际已经记录了此函数每条指令执行后SPx到CFAx的偏移(SPx和CFAx的偏移在编译时即可确定), 在运行时通过查表获取CFA的计算规则。
“Caller Save” 和 ”Callee Save” 寄存器,即寄存器的值是由”调用者保存“ 还是由 ”被调用者保存“。当产生函数调用时,子函数内通常也会使用到通用寄存器,那么这些寄存器中之前保存的调用者(父函数)的值就会被覆盖。为了避免数据覆盖而导致从子函数返回时寄存器中的数据不可恢复,CPU 体系结构中就规定了通用寄存器的保存方式。
如果一个寄存器被标识为”Caller Save”, 那么在进行子函数调用前,就需要由调用者提前保存好这些寄存器的值,保存方法通常是把寄存器的值压入堆栈中,调用者保存完成后,在被调用者(子函数)中就可以随意覆盖这些寄存器的值了。如果一个寄存被标识为“Callee Save”,那么在函数调用时,调用者就不必保存这些寄存器的值而直接进行子函数调用,进入子函数后,子函数在覆盖这些寄存器之前,需要先保存这些寄存器的值,即这些寄存器的值是由被调用者来保存和恢复的。
对于arm64,调用者保存寄存器 callee-saved registers 是[x19, x28]:
$ readelf -wF /usr/bin/ls
.eh_frame 节的内容:
00000000 0000000000000010 00000000 CIE "zR" cf=4 df=-8 ra=30
......
0000062c 0000000000000040 00000630 FDE cie=00000000 pc=0000000000006f28..00000000000070e8
LOC CFA x19 x20 x21 x22 x23 x24 x25 x26 x27 x28 x29 ra
0000000000006f28 sp+0 u u u u u u u u u u u u
0000000000006f2c sp+256 u u u u u u u u u u c-256 c-248
0000000000006f34 sp+256 c-240 c-232 u u u u u u u u c-256 c-248
0000000000006f40 sp+256 c-240 c-232 u u u u c-192 c-184 c-176 c-168 c-256 c-248
0000000000006f54 sp+256 c-240 c-232 u u c-208 c-200 c-192 c-184 c-176 c-168 c-256 c-248
0000000000006f68 sp+256 c-240 c-232 c-224 c-216 c-208 c-200 c-192 c-184 c-176 c-168 c-256 c-248
0000000000006fb8 sp+0 u u u u u u u u u u u u
0000000000006fbc sp+256 c-240 c-232 c-224 c-216 c-208 c-200 c-192 c-184 c-176 c-168 c-256 c-248
2.4.2 readelf -wf
"f"
"=frames"
Display the raw contents of a .debug_frame section.
$ readelf -wf /usr/bin/ls
Contents of the .eh_frame section:
00000000 0000000000000014 00000000 CIE
Version: 1
Augmentation: "zR"
Code alignment factor: 1
Data alignment factor: -8
Return address column: 16
Augmentation data: 1b
DW_CFA_def_cfa: r7 (rsp) ofs 8
DW_CFA_offset: r16 (rip) at cfa-8
DW_CFA_nop
DW_CFA_nop
......
00002f60 0000000000000044 00002f64 FDE cie=00000000 pc=00000000000174e0..0000000000017545
DW_CFA_advance_loc: 6 to 00000000000174e6
DW_CFA_def_cfa_offset: 16
DW_CFA_offset: r15 (r15) at cfa-16
DW_CFA_advance_loc: 9 to 00000000000174ef
DW_CFA_def_cfa_offset: 24
DW_CFA_offset: r14 (r14) at cfa-24
DW_CFA_advance_loc: 5 to 00000000000174f4
DW_CFA_def_cfa_offset: 32
DW_CFA_offset: r13 (r13) at cfa-32
DW_CFA_advance_loc: 5 to 00000000000174f9
DW_CFA_def_cfa_offset: 40
DW_CFA_offset: r12 (r12) at cfa-40
DW_CFA_advance_loc: 4 to 00000000000174fd
DW_CFA_def_cfa_offset: 48
DW_CFA_offset: r6 (rbp) at cfa-48
DW_CFA_advance_loc: 8 to 0000000000017505
DW_CFA_def_cfa_offset: 56
DW_CFA_offset: r3 (rbx) at cfa-56
DW_CFA_advance_loc: 7 to 000000000001750c
DW_CFA_def_cfa_offset: 64
DW_CFA_advance_loc: 46 to 000000000001753a
DW_CFA_def_cfa_offset: 56
DW_CFA_advance_loc: 1 to 000000000001753b
DW_CFA_def_cfa_offset: 48
DW_CFA_advance_loc: 1 to 000000000001753c
DW_CFA_def_cfa_offset: 40
DW_CFA_advance_loc: 2 to 000000000001753e
DW_CFA_def_cfa_offset: 32
DW_CFA_advance_loc: 2 to 0000000000017540
DW_CFA_def_cfa_offset: 24
DW_CFA_advance_loc: 2 to 0000000000017542
DW_CFA_def_cfa_offset: 16
DW_CFA_advance_loc: 2 to 0000000000017544
DW_CFA_def_cfa_offset: 8
DW_CFA_nop
2.5 栈回溯的步骤
(1)步骤 1:根据当前 PC 查找对应的 FDE,根据当前的PC在.eh_frame中找到对应的条目,根据条目提供的各种偏移计算其他信息。
输入:当前程序计数器(Program Counter, PC)。
操作:
遍历 .eh_frame,找到包含当前 PC 的 FDE。
从 FDE 中提取 CFA 计算规则 和 寄存器恢复规则。
(2)计算 CFA(调用者的栈指针)
规则:根据 FDE 中的指令计算 CFA。
对于 x86_64 架构,常见规则如 CFA = %rsp + 8(调用函数后,返回地址压栈,栈增长 8 字节)。
CFA = %rsp + 8
(3)步骤 3:恢复通用寄存器的值
规则:从 FDE 中解析寄存器的栈偏移。
例如:rbx = CFA - 56 表示 rbx 的值存储在调用者栈帧的 CFA - 56 位置。
rbx = *(CFA - 56)
(4)步骤 4:恢复返回地址(RA)
规则:返回地址通常位于 CFA - 8(x86_64 调用约定)。
ra = *(CFA - 8)
(5)步骤 5:递归回溯
操作:
将当前 PC 更新为返回地址 ra。
重复步骤 1-4,直到栈底(如 main 函数)。
示例分析(x86_64 架构)
假设函数调用链为 A → B → C,当前执行到函数 C 内部:
回溯过程
(1)在函数 C 中:
根据 PC(C 内部地址)找到 FDE。
计算 CFA = %rsp + 8。
恢复 rbx = *(CFA - 56)。
恢复 ra = *(CFA - 8)。
更新 PC 为 ra(即函数 B 中调用 C 的返回地址)。
(2)在函数 B 中:
根据新 PC(B 内部地址)找到对应的 FDE。
计算新的 CFA(B的%rsp + 8)。
重复寄存器恢复,继续回溯到函数 A。
三、The .eh_frame_hdr section
.eh_frame_hdr 段包含有关 .eh_frame 段的额外信息。该段中包含了指向 .eh_frame 数据起始位置的指针,以及可选的指向 .eh_frame 记录的二进制搜索表。
定位一个pc所在的FDE需要从头扫描.eh_frame,找到合适的FDE(pc是否落在initial_location和address_range表示的区间),所花时间和扫描的CIE和FDE记录数相关。 .eh_frame_hdr包含binary search index table描述(initial_location, FDE address) pairs。
.eh_frame_hdr Section Format:
Encoding | Field |
---|---|
unsigned byte | version |
unsigned byte | eh_frame_ptr_enc |
unsigned byte | fde_count_enc |
unsigned byte | table_enc |
encoded | eh_frame_ptr |
encoded | fde_count |
binary search table |
(1)version
.eh_frame_hdr 格式的版本。该值应为 1。
(2)eh_frame_ptr_enc
eh_frame_ptr字段的编码格式。
(3)fde_count_enc
fde_count 字段的编码格式。DW_EH_PE_omit 的值表示二进制搜索表不存在。
(4)table_enc
二进制搜索表中条目的编码格式。DW_EH_PE_omit 的值表示二进制搜索表不存在。
(5)eh_frame_ptr
指向.eh_frame部分开头的指针的编码值。
(6)fde_count
二进制搜索表中条目数的编码值。
(7)binary search table
一个包含 fde_count 个条目的二进制搜索表。每个表条目包含两个编码值,即初始位置和地址。这些条目按照初始位置的值按升序排序。
binary_search_table:一个按 start_ip 排序的二分查找表,每个表项包含:
start_ip:该 FDE 覆盖的代码起始地址。
fde_addr:对应 FDE 的地址(指向 .eh_frame 中的条目)。
其中binary_search_table是按升序排列的搜索表,通过他可以很快的定位到对应的unwind信息。因此通过 .eh_frame_hdr 快速定位到目标 FDE,无需线性扫描 .eh_frame。
四、libunwind
libunwind 是一个可移植且高效的 C API,用于确定 ELF 程序线程的当前调用链,并可以在该调用链的任何点上恢复执行。该 API 支持本地(同一进程)和远程(其他进程)操作。用于显示引发问题的调用链的回溯信息,或用于性能监控和分析。
libunwind的使用比较简单:
#define UNW_LOCAL_ONLY
#include <libunwind.h>
#include <stdio.h>
#include <stdlib.h>
#define panic(...) \
{ fprintf (stderr, __VA_ARGS__); exit (-1); }
static void do_backtrace (void)
{
unw_cursor_t cursor;
unw_word_t ip, sp;
unw_context_t uc;
int ret;
unw_getcontext (&uc);
if (unw_init_local (&cursor, &uc) < 0)
panic ("unw_init_local failed!\n");
do{
unw_get_reg (&cursor, UNW_REG_IP, &ip);
unw_get_reg (&cursor, UNW_REG_SP, &sp);
char fname[64];
unw_word_t offset;
unw_get_proc_name(&cursor, fname, sizeof(fname), &offset);
printf ("(ip=%016lx) (sp=%016lx): (%s+0x%x) [%p]\n", (long) ip, (long) sp, fname, offset, (long) sp);
ret = unw_step (&cursor);
if (ret < 0)
{
unw_get_reg (&cursor, UNW_REG_IP, &ip);
panic ("FAILURE: unw_step() returned %d for ip=%lx\n",
ret, (long) ip);
}
}while (ret > 0);
}
void func_c(void)
{
do_backtrace();
}
void func_b(void)
{
func_c();
}
void func_a(void)
{
func_b();
}
int main (int argc, char **argv)
{
func_a ();
return 0;
}
# gcc 1.c -lunwind
# ./a.out
(ip=0000000000400897) (sp=00007fffc131ce40): (do_backtrace+0x1a) [0x7fffc131ce40]
(ip=00000000004009f0) (sp=00007fffc131d660): (func_c+0x9) [0x7fffc131d660]
(ip=00000000004009fb) (sp=00007fffc131d670): (func_b+0x9) [0x7fffc131d670]
(ip=0000000000400a06) (sp=00007fffc131d680): (func_a+0x9) [0x7fffc131d680]
(ip=0000000000400a1c) (sp=00007fffc131d690): (main+0x14) [0x7fffc131d690]
(ip=00007ff1df9a2555) (sp=00007fffc131d6b0): (__libc_start_main+0xf5) [0x7fffc131d6b0]
(ip=00000000004007b9) (sp=00007fffc131d770): (+0xf5) [0x7fffc131d770]
五、基于Frame Pointer和基于unwind 形式的栈回溯比较
(1)基于Frame Pointer - fp寄存器的栈回溯:
优点:栈回溯比较快,理解简单。相对较简单:基于Frame Pointer寄存器的栈回溯通常比解析unwind节更简单直接。
缺点:gcc添加了优化选项 -O 就会省略掉省略基指针。这样就不能都通过这种形式进行栈回溯了。
-fomit-frame-pointer编译标志进行优化:避免将%rbp用作栈帧指针,把FP当作一个通用寄存器,这样就提供了一个额外的通用寄存器,提高程序运行效率。
通用寄存器用来暂存数据和参与运算。通过load\store指令操作。
如果把fp寄存器当作栈帧寄存器,那就不能参与指令数据运算,CPU寄存器是很宝贵的,多一个寄存器对加快指令数据运算是有积极意义的。
(2)基于unwind 形式的栈回溯:
优点:只是将入栈相关的指令的编码保存到unwind段中,不用把无关的寄存器保存到栈中,也不用浪费fp寄存器。
把FP当作一个通用寄存器,这样就提供了一个额外的通用寄存器,提高程序运行效率。
更准确:unwind节中的调试信息提供了更详细的函数调用和栈帧信息,可以更准确地还原函数调用链和参数传递。
不受优化影响:unwind节通常包含了编译器生成的准确信息,不受编译器优化选项的影响。
提供更多调试功能:unwind节提供了丰富的调试信息,可以用于更深入的调试和错误诊断。
缺点:栈回溯的速度肯定比fp形式栈回溯慢,理解难度要比fp形式大很多。
复杂性:解析和使用unwind节的调试信息可能需要更多的工具和技术知识。
参考资料
https://refspecs.linuxfoundation.org/LSB_4.0.0/LSB-Core-generic/LSB-Core-generic/ehframechpt.html#EHFRAME
https://github.com/libunwind/libunwind
https://www.cs.dartmouth.edu/~sergey/battleaxe/shmoocon_2011_oakley_bratus.pdf
https://blog.csdn.net/lidan113lidan/article/details/121801335
https://blog.csdn.net/pwl999/article/details/107569603
https://blog.csdn.net/bme314/article/details/107769543