关键字 :HardFault|cmBacktrace|栈回溯|Cortex-M|异常处理
适用人群 :STM32 / GD32 / 国产MCU工程师
阅读前提 :熟悉C语言,了解ARM汇编基础,有过HardFault调试经验
官方参考:ARM Cortex-M3/M4权威指南
前两篇文章已经解决了「怎么定位 HardFault 」和「怎么抓到踩内存的第一现场」。
这一篇我们不再停留在"工具使用",而是彻底拆掉黑盒,搞清楚一个核心问题:
👉 cmBacktrace 到底是如何在「没有调试器」的情况下,回溯调用栈的?
1 为什么一定要理解 cmBacktrace 的实现原理?
很多工程师已经在用 cmBacktrace,但常见困惑包括:
- ❓ 为什么有时调用栈能打 8 层,有时只有 2 层?
- ❓ 为什么 LR 一定要减 1?
- ❓ 为什么回溯结果看起来"像对的",但又不完全可信?
本质原因只有一个:
你在"使用一个算法",却不知道它的"假设前提"。
所以这一篇,我们不讲"怎么用",而是站在源码级别 :从异常入口汇编 ,到C语言回溯算法,一行一行,把cmBacktrace拆干净。
2 HardFault 发生时,CPU到底保存了什么?
2.1 HardFault 是什么?它从哪来?
根据《ARM Cortex-M3/M4权威指南》,Cortex-M处理器定义了多种异常类型,其中与故障相关的有:
| 异常号 | 异常类型 | 触发条件 |
|---|---|---|
| 3 | HardFault | 所有无法处理的故障最终归宿 |
| 4 | MemManage | 内存保护单元(MPU)冲突或者访问非法地址 |
| 5 | BusFault | 总线错误(如访问无效地址) |
| 6 | UsageFault | 非法指令、除零等 |
📌 关键点:
所有二级 Fault(Mem / Bus / Usage)
只要没开,或者处理时再出错,都会升级为 HardFault
是
否或处理时
再次出错
程序正常执行
触发异常
UsageFault
非法指令/除零
MemManageFault
内存访问违规
BusFault
总线错误
异常已使能?
进入对应异常处理
🚨 升级为HardFault
可能恢复执行
💀 系统崩溃
需要复位
2.2 Cortex-M 的"异常自动压栈"机制
当 Cortex-M 异常(HardFault / BusFault / UsageFault)发生时,CPU硬件会自动保存一个异常栈帧:
typedef struct {
uint32_t r0;
uint32_t r1;
uint32_t r2;
uint32_t r3;
uint32_t r12;
uint32_t lr; // 返回地址
uint32_t pc; // 出问题的指令地址(最重要)
uint32_t xpsr;
} ExceptionStackFrame;
👉产生异常时,硬件自动保存的寄存器共有8个(FPU寄存器除外),记住8这个数字,后面源码会使用。
Cortex-M栈是向下增长,因此栈内存布局如下:
// ---------------------------------------------------------------
// **低地址** +----------------+ <- 异常后的SP (SP指向这里)
// | R0 | <- 通用寄存器0 (Argument/Return Register)
// | | **函数调用时的第1个参数**
// | | **函数返回值(int或指针类型)**
// | | **在HardFault中常包含故障地址!**
// +----------------+
// | R1 | <- 通用寄存器1 (Argument/Scratch Register)
// | | **函数调用时的第2个参数**
// | | 或作为临时寄存器使用
// +----------------+
// | R2 | <- 通用寄存器2 (Argument/Scratch Register)
// | | **函数调用时的第3个参数**
// | | 或作为临时寄存器使用
// +----------------+
// | R3 | <- 通用寄存器3 (Argument/Scratch Register)
// | | **函数调用时的第4个参数**
// | | 或作为临时寄存器使用
// +----------------+
// | R12 | <- 临时寄存器 (Intra-Procedure-call scratch)
// | | **ARM调用约定中的中间寄存器**
// | | 常用于保存函数调用间的临时值
// +----------------+
// | LR | <- 链接寄存器 (Link Register)
// | | **函数调用返回地址**
// | | **低3位包含EXC_RETURN编码:**
// | | bit[2]: 0=使用MSP,1=使用PSP
// | | bit[3]: 返回模式
// | | bit[4]: 0=包含FPU寄存器
// +----------------+
// | PC | <- 程序计数器 (Program Counter)
// | | **崩溃发生时正在执行的指令地址**
// | | **最关键的调试信息!**
// +----------------+
// | xPSR | <- 程序状态寄存器
// | | bit[24] = 1: Thumb模式 (Cortex-M始终为1)
// | | bit[9] = 1: 栈指针调整过(双字对齐特性)
// **高地址** +----------------+ <- 异常**前**的SP (压栈前的栈顶)
// ---------------------------------------------------------------
// ARM函数调用约定 (AAPCS):
// R0-R3: 函数参数 (Parameter Registers)
// - R0: 第1个参数,也用于函数返回值
// - R1: 第2个参数
// - R2: 第3个参数
// - R3: 第4个参数
// R12: 临时寄存器 (IP - Intra-Procedure-call scratch)
// - 在函数调用和链接代码间使用
// R13: 栈指针 (SP - Stack Pointer)
// - MSP: 主栈指针 (Handler模式使用)
// - PSP: 进程栈指针 (Thread模式使用)
// R14: 链接寄存器 (LR - Link Register)
// - 保存函数返回地址
// R15: 程序计数器 (PC - Program Counter)
// - 当前执行指令的地址
// R0~R3、R12、LR、PSR:调用者保存寄存器,若在函数调用后还需要使用这些寄存器,在进行调用前,调用子程序的程序代码需要将这些寄存器的内容保存在栈中,函数调用后不需要使用的寄存器则不用保存,由处理器硬件控制。
// R4-R11: 被调用者保存 (Callee-saved Registers)
// - 如果函数要使用这些寄存器,必须保存原值(寄存器的值会在函数执行过程中变化,在退出前恢复原值)
**💡 关键点:这个栈帧包含了故障发生时的CPU状态,是故障分析的重要线索:
PC寄存器告诉我们"死在哪一行"LR告诉我们"从哪里来",即函数调用返回后,将要执行的指令SP告诉我们"当前栈帧在哪里"
💡 技巧 :
从 PC / LR / SP 开始看,就能快速定位问题根源
📌 结论:
cmBacktrace 并不是"猜调用栈", 而是严格从 CPU 自动保存的现场开始推理。
2.3 EXC_RETURN:决定"使用哪一个栈"的关键
当Cortex-M处理器响应异常时,硬件自动执行以下操作:
- 保存现场:将R0-R3、R12、LR、PC、xPSR压入当前栈
- 设置LR :写入特殊的EXC_RETURN值(不是函数返回地址)
- 跳转执行:PC指向异常处理函数
异常处理完成后,通过 BX、POP、LDR 或 LDM 等指令将EXC_RETURN 加载到PC,触发硬件异常返回,自动恢复之前的执行上下文。
2.3.1 为什么异常中的 LR 不是"返回地址"
在正常函数调用中,LR 保存的是函数返回地址;但在异常上下文中,LR 保存的是 EXC_RETURN ,它并不指向代码地址,而是一个 带有语义的控制值。
2.3.2 EXC_RETURN 关键位含义
EXC_RETURN 的每一位都在告诉处理器:异常发生前,CPU 处于什么状态。
| Bit | 含义 |
|---|---|
| Bit[0] | 保留(1) |
| Bit[1] | 保留(0) |
| Bit[2] | 异常返回前所使用的栈:0 → Handler 模式(MSP),1 → Thread 模式(PSP) |
| Bit[3] | 返回模式:0 → Handler 模式,1 → Thread 模式 |
| Bit[4] | 0:异常前压入了 FPU 上下文 |
| Bit[5:27] | 保留(全为 1) |
| Bit[31:28] | 固定为 0xF,表示该 LR 为 EXC_RETURN |
其中,Bit[2] 是回溯实现中最关键的一位。
2.3.3 cmBacktrace 如何利用 EXC_RETURN
cmBacktrace 在 HardFault 处理中,首先需要确定:异常发生前,当前任务运行在哪个栈上。
对应的判断逻辑如下:
on_thread_before_fault = fault_handler_lr & (1UL << 2);
- Bit[2] = 1 → 异常发生前处于 Thread 模式 → 使用 PSP
- Bit[2] = 0 → 异常发生前处于 Handler 模式 → 使用 MSP
只有选对栈,后续从栈中解析出的PC / LR / xPSR / R0--R3 / R12才是真实的异常现场。
📌 这是异常回溯是否"从正确栈开始"的第一道门槛。
3 cmBacktrace 整体架构
3.1 三个核心模块 + 两个外部依赖
cmBacktrace由三个核心部分组成,协同完成崩溃分析:
用户操作
外部依赖
cmBacktrace源码
汇编层
cmb_fault.S
C核心层
cm_backtrace.c
配置文件层
cmb_cfg.h + 语言文件
编译工具链
生成ELF文件
外部解析工具
addr2line/objdump
cmBacktrace输出
原始地址
手动运行工具
解析符号
获得可读信息
函数名:行号
📌 总结:
cmBacktrace 只负责打印"地址",真正的"符号解析",在 PC 上完成
3.2 实际工作流程(从死机到函数名)
addr2line工具 开发者 cm_backtrace.c cmb_fault.S Cortex-M CPU addr2line工具 开发者 cm_backtrace.c cmb_fault.S Cortex-M CPU cm_backtrace_fault()开始 发生HardFault 自动保存R0-R3,R12,LR,PC,PSR 入参为LR(EXC_RETURN)和SP 恢复寄存器现场 分析故障寄存器(CFSR等) 执行栈帧回溯算法 输出死机报告 PC=0801096e 调用栈=0801096e 0804282e... 运行addr2line -e Project.axf 0801096e 0804282e 从axf文件读取 调试信息 返回解析结果 0x0801096e: process_data at sensor.c:156
3.3 实际代码调用关系
HardFault_Handler
cmb_fault.S
cm_backtrace_fault
恢复寄存器
dump_stack
打印异常时的8个寄存器
fault_diagnosis
print_call_stack
addr2line工具
符号解析
3.4 汇编入口:第一现场保护
; cmb_fault.S - HardFault处理汇编入口
AREA |.text|, CODE, READONLY, ALIGN=2
THUMB
REQUIRE8
PRESERVE8
IMPORT cm_backtrace_fault ; 导入C分析函数
EXPORT HardFault_Handler ; 导出为HardFault处理器
HardFault_Handler PROC
MOV r0, lr ; 参数1: LR值(包含EXC_RETURN)
MOV r1, sp ; 参数2: 当前SP(异常后的MSP)
BL cm_backtrace_fault ; 调用C分析函数
Fault_Loop
BL Fault_Loop ; 死循环,防止继续执行
ENDP
END
汇编代码精妙之处:
- 极简设计:只有3条关键指令,最大限度减少对现场的影响
- 参数传递:通过R0、R1传递关键参数给C函数
- 现场保护:使用BL调用而非直接跳转,保持LR不变
- 安全锁定:分析后进入死循环,防止意外继续执行
4 C语言核心实现深度解析
4.1 初始化:地址范围自动探测
/**
* 初始化CmBacktrace库
*
* @param firmware_name 固件名称字符串
* @param hardware_ver 硬件版本字符串
* @param software_ver 软件版本字符串
*/
void cm_backtrace_init(const char *firmware_name, const char *hardware_ver, const char *software_ver) {
// 拷贝固件信息到全局变量
strncpy(fw_name, firmware_name, CMB_NAME_MAX);
strncpy(hw_ver, hardware_ver, CMB_NAME_MAX);
strncpy(sw_ver, software_ver, CMB_NAME_MAX);
// 根据不同编译器获取栈和代码段信息
#if defined(__ARMCC_VERSION)
// KEIL编译器获取栈和代码段地址及大小
main_stack_start_addr = (uint32_t)&CSTACK_BLOCK_START(CMB_CSTACK_BLOCK_NAME);
main_stack_size = (uint32_t)&CSTACK_BLOCK_END(CMB_CSTACK_BLOCK_NAME) - main_stack_start_addr;
code_start_addr = (uint32_t)&CODE_SECTION_START(CMB_CODE_SECTION_NAME);
code_size = (uint32_t)&CODE_SECTION_END(CMB_CODE_SECTION_NAME) - code_start_addr;
#elif defined(__ICCARM__)
// IAR编译器获取栈和代码段地址及大小
main_stack_start_addr = (uint32_t)__section_begin(CMB_CSTACK_BLOCK_NAME);
main_stack_size = (uint32_t)__section_end(CMB_CSTACK_BLOCK_NAME) - main_stack_start_addr;
code_start_addr = (uint32_t)__section_begin(CMB_CODE_SECTION_NAME);
code_size = (uint32_t)__section_end(CMB_CODE_SECTION_NAME) - code_start_addr;
#elif defined(__GNUC__)
// GCC编译器获取栈和代码段地址及大小
main_stack_start_addr = (uint32_t)(&CMB_CSTACK_BLOCK_START);
main_stack_size = (uint32_t)(&CMB_CSTACK_BLOCK_END) - main_stack_start_addr;
code_start_addr = (uint32_t)(&CMB_CODE_SECTION_START);
code_size = (uint32_t)(&CMB_CODE_SECTION_END) - code_start_addr;
#else
#error "not supported compiler"
#endif
// 检查主栈大小是否有效
if (main_stack_size == 0) {
cmb_println(print_info[PRINT_MAIN_STACK_CFG_ERROR]);
return;
}
// 标记初始化完成
init_ok = true;
}
关键技术点:
- 多编译器支持:一套代码支持Keil、GCC、IAR三大编译器
- 自动探测:通过链接器符号自动获取代码和栈的地址范围
- 零配置:用户无需手动输入内存布局信息
4.2 崩溃分析入口:cm_backtrace_fault
这是从汇编调用的核心函数:
/**
* 处理硬件故障并打印回溯信息
*
* @param fault_handler_lr 故障处理时的LR寄存器值(用于判断是否在中断前处于线程模式)
* @param fault_handler_sp 故障处理时的栈指针值
*/
void cm_backtrace_fault(uint32_t fault_handler_lr, uint32_t fault_handler_sp) {
uint32_t stack_pointer = fault_handler_sp, saved_regs_addr = stack_pointer, tcb_stack_pointer = 0;
const char *regs_name[] = { "R0 ", "R1 ", "R2 ", "R3 ", "R12", "LR ", "PC ", "PSR" };
#ifdef CMB_USING_DUMP_STACK_INFO
uint32_t stack_start_addr = main_stack_start_addr;
size_t stack_size = main_stack_size;
#endif
CMB_ASSERT(init_ok); // 确保库已初始化
CMB_ASSERT(!on_fault); // 确保此函数只被调用一次
on_fault = true; // 设置故障标志
cmb_println("");
cm_backtrace_firmware_info(); // 打印固件信息
#ifdef CMB_USING_OS_PLATFORM
// 检查故障前是否处于线程模式(通过LR的bit2判断)
on_thread_before_fault = fault_handler_lr & (1UL << 2);
if (on_thread_before_fault) {
cmb_println(print_info[PRINT_FAULT_ON_THREAD], get_cur_thread_name() != NULL ? get_cur_thread_name() : "NO_NAME");
saved_regs_addr = stack_pointer = cmb_get_psp(); // 使用线程栈指针
#ifdef CMB_USING_DUMP_STACK_INFO
get_cur_thread_stack_info(&tcb_stack_pointer, &stack_start_addr, &stack_size);
#endif
} else {
cmb_println(print_info[PRINT_FAULT_ON_HANDLER]); // 处理中断模式下的故障
}
#else
// 裸机环境下的故障处理
cmb_println(print_info[PRINT_FAULT_ON_HANDLER]);
#endif
// 进入hardfault之前,硬件自动保存8个寄存器到栈中(线程栈/主栈)
// 跳过保存的寄存器空间(R0-R3, R12, LR, PC, PSR)
stack_pointer += sizeof(size_t) * 8;
#if (CMB_CPU_PLATFORM_TYPE == CMB_CPU_ARM_CORTEX_M4) || (CMB_CPU_PLATFORM_TYPE == CMB_CPU_ARM_CORTEX_M7) || \
(CMB_CPU_PLATFORM_TYPE == CMB_CPU_ARM_CORTEX_M33)
// 对于带FPU的芯片,跳过FPU寄存器空间
stack_pointer = statck_del_fpu_regs(fault_handler_lr, stack_pointer);
#endif
#ifdef CMB_USING_DUMP_STACK_INFO
// 检查栈溢出情况
if (stack_pointer < stack_start_addr || stack_pointer > stack_start_addr + stack_size) {
cmb_println("stack_pointer: 0x%08x, stack_start_addr: 0x%08x, stack_end_addr: 0x%08x",
stack_pointer, stack_start_addr, stack_start_addr + stack_size);
stack_is_overflow = true;
#if (CMB_OS_PLATFORM_TYPE == CMB_OS_PLATFORM_RTT)
if (on_thread_before_fault) {
// RT-Thread下栈溢出时使用TCB中的栈指针
stack_pointer = tcb_stack_pointer;
}
#endif
}
// 打印栈信息
dump_stack(stack_start_addr, stack_size, (uint32_t *) stack_pointer);
#endif
{
// 打印寄存器值
cmb_println(print_info[PRINT_REGS_TITLE]);
// 从栈中恢复寄存器值
regs.saved.r0 = ((uint32_t *)saved_regs_addr)[0];
regs.saved.r1 = ((uint32_t *)saved_regs_addr)[1];
regs.saved.r2 = ((uint32_t *)saved_regs_addr)[2];
regs.saved.r3 = ((uint32_t *)saved_regs_addr)[3];
regs.saved.r12 = ((uint32_t *)saved_regs_addr)[4];
regs.saved.lr = ((uint32_t *)saved_regs_addr)[5];
regs.saved.pc = ((uint32_t *)saved_regs_addr)[6];
regs.saved.psr.value = ((uint32_t *)saved_regs_addr)[7];
// 格式化输出寄存器值
cmb_println(" %s: %08x %s: %08x %s: %08x %s: %08x",
regs_name[0], regs.saved.r0, regs_name[1], regs.saved.r1,
regs_name[2], regs.saved.r2, regs_name[3], regs.saved.r3);
cmb_println(" %s: %08x %s: %08x %s: %08x %s: %08x",
regs_name[4], regs.saved.r12, regs_name[5], regs.saved.lr,
regs_name[6], regs.saved.pc, regs_name[7], regs.saved.psr.value);
cmb_println("==============================================================");
}
#if (CMB_CPU_PLATFORM_TYPE != CMB_CPU_ARM_CORTEX_M0)
// 读取并分析故障状态寄存器(Cortex-M0不支持)
regs.syshndctrl.value = CMB_SYSHND_CTRL;
regs.mfsr.value = CMB_NVIC_MFSR;
regs.mmar = CMB_NVIC_MMAR;
regs.bfsr.value = CMB_NVIC_BFSR;
regs.bfar = CMB_NVIC_BFAR;
regs.ufsr.value = CMB_NVIC_UFSR;
regs.hfsr.value = CMB_NVIC_HFSR;
regs.dfsr.value = CMB_NVIC_DFSR;
regs.afsr = CMB_NVIC_AFSR;
fault_diagnosis(); // 诊断故障原因
#endif
print_call_stack(stack_pointer); // 打印调用栈
}
关键算法:
- 模式判断:通过EXC_RETURN的Bit[2]判断异常前模式
- 栈帧定位:精确定位异常栈帧在内存中的位置
- 寄存器恢复:从栈中恢复崩溃瞬间的CPU状态
- 现场分析:基于恢复的寄存器分析故障原因
从栈结构更形象的分析:
// ================================================================
// 利用栈结构分析 cm_backtrace_fault() 的工作原理
// ================================================================
// Cortex-M异常栈帧结构(向下增长)
// ---------------------------------------------------------------
// 高地址 +----------------+ <- fault_handler_sp + 32 (异常前的SP)
// | |
// | 已使用的栈 | ↑ 栈向高地址表示已使用的栈空间
// | | |
// +----------------+ | **异常前SP指向这里**
// | xPSR | | ← saved_regs_addr[7] (偏移28)
// | | | 程序状态寄存器
// +----------------+ |
// | PC | | ← saved_regs_addr[6] (偏移24)
// | | | **崩溃地址** (regs.saved.pc)
// +----------------+ |
// | LR | | ← saved_regs_addr[5] (偏移20)
// | | | **EXC_RETURN** (regs.saved.lr)
// +----------------+ |
// | R12 | | ← saved_regs_addr[4] (偏移16)
// | | | 临时寄存器 (regs.saved.r12)
// +----------------+ |
// | R3 | | ← saved_regs_addr[3] (偏移12)
// | | | 第4个参数 (regs.saved.r3)
// +----------------+ |
// | R2 | | ← saved_regs_addr[2] (偏移8)
// | | | 第3个参数 (regs.saved.r2)
// +----------------+ |
// | R1 | | ← saved_regs_addr[1] (偏移4)
// | | | 第2个参数 (regs.saved.r1)
// +----------------+ |
// | R0 | | ← saved_regs_addr[0] (偏移0)
// 低地址 +----------------+ <- **SP指向这里** fault_handler_sp (异常后的SP)-栈顶
// | | |
// | | |
// | 空闲栈空间 | |
// | | |
// +----------------+ | 栈顶
// ---------------------------------------------------------------
// ========== cm_backtrace_fault() 函数逐步分析 ==========
void cm_backtrace_fault(uint32_t fault_handler_lr, uint32_t fault_handler_sp) {
// 1. fault_handler_sp 是汇编传进来的参数
// 它就是异常后的SP,指向栈顶(R0)位置
uint32_t stack_pointer = fault_handler_sp;
uint32_t saved_regs_addr = stack_pointer; // 指向R0
// 2. 判断异常前的模式
// 通过fault_handler_lr (实际上是EXC_RETURN)的bit[2]
on_thread_before_fault = fault_handler_lr & (1UL << 2);
// 3. 恢复寄存器现场
// 从saved_regs_addr开始,按照栈结构读取
// R0在偏移0处
regs.saved.r0 = ((uint32_t *)saved_regs_addr)[0];
// R1在偏移1处(实际地址:saved_regs_addr + 4)
regs.saved.r1 = ((uint32_t *)saved_regs_addr)[1];
// R2在偏移2处(实际地址:saved_regs_addr + 8)
regs.saved.r2 = ((uint32_t *)saved_regs_addr)[2];
// R3在偏移3处(实际地址:saved_regs_addr + 12)
regs.saved.r3 = ((uint32_t *)saved_regs_addr)[3];
// R12在偏移4处(实际地址:saved_regs_addr + 16)
regs.saved.r12 = ((uint32_t *)saved_regs_addr)[4];
// LR在偏移5处(实际地址:saved_regs_addr + 20)
// 注意:这里保存的是EXC_RETURN,不是原始LR!
regs.saved.lr = ((uint32_t *)saved_regs_addr)[5];
// PC在偏移6处(实际地址:saved_regs_addr + 24)
// 这是崩溃地址!
regs.saved.pc = ((uint32_t *)saved_regs_addr)[6];
// PSR在偏移7处(实际地址:saved_regs_addr + 28)
regs.saved.psr.value = ((uint32_t *)saved_regs_addr)[7];
// 4. 计算异常前的栈指针
// 异常前的SP = 当前SP + 8个寄存器 * 4字节
// 实际就是 saved_regs_addr + 32
// 5. 准备调用栈回溯
// 进入hardfault之前,硬件自动保存8个寄存器到栈中(线程栈/主栈)
// stack_pointer需要指向异常发生时的栈帧
// 也就是跳过异常栈帧,继续向上(向高地址)查找
stack_pointer += sizeof(size_t) * 8; // 跳过8个寄存器
// 6. 如果是M4/M7/M33且使用了FPU
// 栈中可能还保存了FPU寄存器,需要额外跳过
#if (CMB_CPU_PLATFORM_TYPE == CMB_CPU_ARM_CORTEX_M4) || \
(CMB_CPU_PLATFORM_TYPE == CMB_CPU_ARM_CORTEX_M7) || \
(CMB_CPU_PLATFORM_TYPE == CMB_CPU_ARM_CORTEX_M33)
stack_pointer = statck_del_fpu_regs(fault_handler_lr, stack_pointer);
#endif
// 7. 现在stack_pointer指向异常发生时的栈顶
// 可以开始回溯调用栈了
print_call_stack(stack_pointer);
}
// ========== 代码与栈结构的对应关系 ==========
// 代码中的关键行与栈位置的对应:
/*
saved_regs_addr = stack_pointer = fault_handler_sp;
↓
指向异常后的SP,即栈顶(R0位置)
((uint32_t *)saved_regs_addr)[0] → R0 (偏移0)
((uint32_t *)saved_regs_addr)[1] → R1 (偏移4)
((uint32_t *)saved_regs_addr)[2] → R2 (偏移8)
((uint32_t *)saved_regs_addr)[3] → R3 (偏移12)
((uint32_t *)saved_regs_addr)[4] → R12 (偏移16)
((uint32_t *)saved_regs_addr)[5] → LR (偏移20)
((uint32_t *)saved_regs_addr)[6] → PC (偏移24)
((uint32_t *)saved_regs_addr)[7] → PSR (偏移28)
stack_pointer += sizeof(size_t) * 8;
↓
从R0位置向上跳8个寄存器,到达异常前的SP位置
这个位置就是异常发生时的栈顶
*/
// ========== 实际案例的栈内存分析 ==========
/*
Firmware name: Project, hardware version: V1.0.0, software version: V0.1.0
Fault on thread NO_NAME
===== Thread stack information ===== //【异常前的SP位置~栈顶的内容,即已使用栈内存】
addr: ....... data: ........
addr: 20027d00 data: 2005bb7c
addr: 20027d04 data: 0003e000
====================================
=================== Registers information ====================//【saved_regs_addr】
R0 : 5a5a5a00 R1 : ffffffff R2 : 5a5a5a01 R3 : 00000000
R12: 0804ca4d LR : 0813708b PC : 0801096e PSR: 01000000
==============================================================
Bus fault is caused by precise data access violation //【fault_diagnosis()】
The bus fault occurred address is 5a5a5a00
Show more call stack info by run: addr2line -e Project.axf -a -f 0801096e 0804282e 0804470e 08060216 08060ea4 080612bc 0804a6b4 0801096c 08028fd8 08027f96 //【print_call_stack()】
*/
4.3 栈帧回溯算法:调用链重建
这是cmBacktrace最精妙的部分:
size_t cm_backtrace_call_stack(uint32_t *buffer, size_t size, uint32_t sp) {
uint32_t stack_start_addr = main_stack_start_addr;
size_t depth = 0, stack_size = main_stack_size;
uint32_t pc;
// 1. 如果处于故障状态,首先保存PC和LR
if (on_fault && !stack_is_overflow) {
buffer[depth++] = regs.saved.pc; // 第一层:崩溃地址
// LR也可能包含有效返回地址(Thumb模式修正)
pc = regs.saved.lr - 1;
if (is_valid_code_address(pc)) {
buffer[depth++] = pc; // 第二层:调用者地址
}
}
// 2. 遍历栈内存,寻找其他返回地址
for (; sp < stack_start_addr + stack_size; sp += sizeof(size_t)) {
pc = *((uint32_t *) sp) - sizeof(size_t);
// 检查是否是Thumb指令地址(最低位为1)
if (pc % 2 == 0) continue;
// Thumb模式修正(PC实际指向地址+1)
pc = *((uint32_t *) sp) - 1;
// 多重验证条件
if ((pc >= code_start_addr + sizeof(size_t)) &&
(pc <= code_start_addr + code_size) &&
(depth < CMB_CALL_STACK_MAX_DEPTH) &&
// 关键:验证前一条指令是否是BL/BLX
disassembly_ins_is_bl_blx(pc - sizeof(size_t)) &&
(depth < size)) {
buffer[depth++] = pc;
}
}
return depth;
}
栈帧回溯的核心挑战:
- 地址识别:如何从栈内存中识别出返回地址
- Thumb模式:Cortex-M使用Thumb指令,地址需要特殊处理
- 指令验证:区分返回地址和普通数据
- 栈帧边界:防止越界访问无效内存
结论:栈回溯的"推理逻辑"
cmBacktrace 并不知道栈里"哪一项是返回地址",它做的是排除法:
- 是不是奇数地址?
- 否 → 不可能是 Thumb 返回地址
- 是否落在代码段?
- 否 → 普通数据
- 前一条指令是不是 BL / BLX?
- 否 → 大概率不是函数返回点
📌 只有 同时满足这三点,才被认为是"可信的调用栈一层"。
4.4 BL / BLX 指令识别的源码细节
/* 检查反汇编指令是否是'BL'或'BLX' */
static bool disassembly_ins_is_bl_blx(uint32_t addr) {
uint16_t ins1 = *((uint16_t *)addr); // 指令低16位
uint16_t ins2 = *((uint16_t *)(addr + 2)); // 指令高16位
// BL指令编码模式:0xF8xx (高16位) + 0xF0xx (低16位)
if ((ins2 & 0xF800) == 0xF800 && (ins1 & 0xF800) == 0xF000) {
return true; // 是BL指令
}
// BLX指令编码模式:0x4700
else if ((ins2 & 0xFF00) == 0x4700) {
return true; // 是BLX指令
}
return false; // 不是函数调用指令
}
ARM指令小知识:
- BL (Branch with Link):标准函数调用,LR=返回地址,目标地址在指令里,编译期已确定
- BLX (Branch with Link eXchange):目标地址可以来自寄存器,支持通过目标地址最低位决定指令集状态的函数调用(在 Cortex-M 上仅用于 Thumb 状态)
- Cortex-M使用Thumb-2指令集,支持16位和32位混合编码
- 函数调用指令的特征编码是识别的关键
为什么这一步极其重要?
如果不做 BL / BLX 校验:
- 普通变量
- 指针
- 结构体成员
都可能被误判为"返回地址"
📌 这也是很多"自己写回溯算法"的失败根源。
4.5 故障类型分析:CFSR / HFSR 的价值
fault_diagnosis函数通过检查ARM Cortex-M处理器的各种故障状态寄存器(HFSR、MFSR、BFSR、UFSR、DFSR等),分析故障原因并打印相应的错误信息。函数按照故障类型(内存管理、总线、用法、调试)进行分类处理,并针对不同处理器架构(M0/M3/M4/M7/M33)提供了特定支持,下面列出部分:
static void fault_diagnosis(void) {
// 读取各种故障状态寄存器
regs.hfsr.value = CMB_NVIC_HFSR; // 硬故障状态寄存器
regs.cfsr.value = CMB_NVIC_CFSR; // 可配置故障状态寄存器
regs.mmfar = CMB_NVIC_MMAR; // 内存管理故障地址
regs.bfar = CMB_NVIC_BFAR; // 总线故障地址
// 分析具体故障原因
if (regs.hfsr.bits.VECTBL) {
cmb_println("向量表读取失败"); // 通常表示堆栈溢出或内存损坏
}
if (regs.cfsr.bits.MEMFAULT) {
if (regs.cfsr.bits.IACCVIOL) {
cmb_println("指令访问违规"); // 尝试执行非代码区域
}
if (regs.cfsr.bits.DACCVIOL) {
cmb_println("数据访问违规"); // 非法内存访问
}
}
if (regs.cfsr.bits.BUSFAULT) {
if (regs.cfsr.bits.PRECISERR) {
cmb_println("精确总线错误,故障地址: 0x%08X", regs.bfar);
}
}
if (regs.cfsr.bits.USGFAULT) {
if (regs.cfsr.bits.UNDEFINSTR) {
cmb_println("未定义指令"); // 代码损坏或错误跳转
}
if (regs.cfsr.bits.INVSTATE) {
cmb_println("非法状态"); // 通常因为LR被破坏
}
if (regs.cfsr.bits.DIVBYZERO) {
cmb_println("除零错误"); // 整数除法除数为零
}
}
}
常见故障模式:
- IMPRECISERR:不精确总线错误(最难调试)
- PRECISERR:精确总线错误(可定位到具体指令)
- INVSTATE:非法状态(LR/PC被破坏的典型表现)
- DIVBYZERO:除零错误(数学运算问题)
5 cmBacktrace 的局限性
cmBacktrace只检测BL/BLX指令,会漏掉其他跳转指令,特别是:
- 尾调用优化:编译器用B指令优化尾调用(函数最后一条语句是调用另一个函数)
- 条件函数调用:可能被编译为条件跳转+B指令
- 某些间接调用:可能使用BX而不是BLX
这对于调试的影响:
- 调用链可能不完整
- 某些函数调用无法追踪
- 优化级别高的代码更难调试
📌 漏检的影响统计:
| 跳转类型 | cmBacktrace能否检测 | 典型场景 | 影响程度 |
|---|---|---|---|
| BL | ✅ 能 | 普通函数调用 | 无影响 |
| BLX | ✅ 能 | 间接函数调用 | 无影响 |
| B | ❌ 不能 | 尾调用优化、循环 | 中等影响 |
| B | ❌ 不能 | 条件函数调用 | 较大影响 |
| BX | ❌ 不能 | 函数指针返回 | 中等影响 |
| 直接修改PC | ❌ 不能 | 异常处理、启动代码 | 较小影响 |
6 cmBacktrace 的能力边界
理解边界,才能正确使用。
6.1 它无法保证 100% 正确的场景
- 编译器强优化(-O2 / -O3)
- 大量内联函数
- 汇编裸函数
- 栈已经被严重破坏
6.2 但它仍然极其有价值
第一层 PC + 第二层 LR,已经能解决 80% 的 HardFault。
实际建议:
- 理解cmBacktrace的局限性
- 对于复杂的调用链,可能需要结合其他调试手段
7 cmBacktrace 的拓展应用:不止于 HardFault 调试
前文我们深入剖析了 cmBacktrace 在 HardFault 定位中的工作原理。但这款工具的潜力远不止于此。它的核心价值在于 "在不依赖调试器的情况下,获取程序运行时的调用栈信息"。这一章,我们将探索 cmBacktrace 在嵌入式开发中的更多实用场景。
7.1 实时性能分析:定位耗时函数调用
7.1.1 场景描述
在实时嵌入式系统中,某些关键任务有严格的执行时间限制。当一个监测线程发现某个工作线程执行超时时,传统的调试手段难以在不中断系统的情况下定位问题。
传统做法的问题:
- 添加日志打印会改变时序
- 断点调试会中断系统运行
- 性能分析工具通常需要调试器支持
7.1.2 cmBacktrace 解决方案
我们可以在任务中增加调用栈记录功能:
#include "cm_backtrace.h"
void task1(void) {
uint32_t task_start_time;
while (1) {
task_start_time = HAL_GetTick();
// 执行实际任务
actual_task_work();
// 检查执行时间
uint32_t elapsed = HAL_GetTick() - task_start_time;
if (elapsed > 100) { // 超过100ms视为超时
printf("[PERF] Task timeout: %lums\n", elapsed);
// 打印栈回溯信息
print_call_stack(__get_PSP());
}
}
}
7.2 增强断言信息:定位调用者
7.2.1 场景描述
传统断言的局限:标准断言只能显示失败位置,但不知道是谁调用了这个函数:
// 传统断言 - 信息有限
#define ASSERT(expr) \
if (!(expr)) { \
printf("Assert failed: %s at %s:%d\n", #expr, __FILE__, __LINE__); \
while(1); \
}
7.2.2 cmBacktrace 解决方案
// 增强版断言
#include "cm_backtrace.h"
// 获取当前上下文栈指针(智能选择MSP或PSP)
static uint32_t get_current_sp(void) {
uint32_t control_reg;
// 读取CONTROL寄存器,bit[1]表示当前栈指针
// 0 = MSP, 1 = PSP
__asm volatile ("MRS %0, CONTROL" : "=r" (control_reg));
if (control_reg & 0x02) {
// Thread模式,使用PSP
return __get_PSP();
} else {
// Handler模式,使用MSP
return __get_MSP();
}
}
void assert_dump_stack(const char *message) {
uint32_t current_sp;
printf("\n=== ASSERTION CONTEXT ===\n");
if (message) {
printf("Message: %s\n", message);
}
// 获取当前任务的栈指针
current_sp = get_current_sp();
// 打印栈回溯信息
print_call_stack(__get_PSP());
}
// 增强版断言宏
#define ENHANCED_ASSERT(expr, message) \
do { \
if (!(expr)) { \
printf("[ASSERT] %s failed at %s:%d\n", #expr, __FILE__, __LINE__); \
assert_dump_stack(message); \
while(1); \
} \
} while(0)
📌 总结:
通过本章的拓展应用,我们可以看到 cmBacktrace 的价值远不止于 HardFault 调试:
结语:理解原理,才是真正"会用"
cmBacktrace 不是"万能工具",而是一套非常工程化、非常克制的算法实现。
当你真正理解它之后:
- 你知道什么时候该信它
- 什么时候该结合 dis / map / Keil 条件断点
- 什么时候该怀疑"栈已经被踩烂了"
HardFault 不再是玄学,而是一次可以被复盘、被积累的工程事件。