文章目录
1 问题背景
HardFault(硬件错误)是一类在嵌入式系统开发中较为常见的系统异常,优先级仅低于复位和NMI(不可屏蔽中断)。当系统运行过程中遇到了某些错误时程序就会跳转至HardFault_Handler函数中,引发程序故障进而影响程序的正常运行。
1.1 HardFault产生原因
HardFault的产生原因可依照来源被分为外部因素和内部因素,主要有以下几种:
外部因素
- 电源设计存在瑕疵,导致器件供电不稳。
- 电磁干扰,极端的运行环境(如高温,机械震荡)。
内部因素
- 不当的用法操作,如除零操作、非对齐的数据访问。
- 数组越界导致程序出错,如访问数组时,动态访问的数组下标超过数组长度。
- 动态内存访问不当,访问了已经释放的内存地址。
- 使用了空指针,通过指针去访问未初始化的地址/已失效的局部变量。
一般因外部因素造成HardFault的可能性较低,大多数都是软件层面的问题。所以遇到HardFault问题时,基本先从内部因素着手去排查。
1.2 HardFault问题难点
在嵌入式开发过程中,经常会遇到HardFault异常引发的系统Core dump问题,在企业级项目开发中这种情况更为常见。此类错误是较难处理的,原因有三点:
- 出现HardFault异常时,程序无法像正常运行一样,在IDE的Stack Callback窗口直接找到产生异常的上一级调用函数,不清楚函数的调用关系,就无法得知程序的哪个部分出现了问题。只能采取单步调试+断点的方式查找问题点,效率极低。在代码量庞大的情况下,查找的过程就更为缓慢。
- 在运行操作系统的应用中,线程是CPU调度的最小单位,此时代码并不会严格按照顺序执行,很难依靠缩小包围圈的形式去查找HardFault问题。
- 此类HardFault问题在产品开发后期也会出现,此时片上的调试口已经基本被关闭,且此时出现的HardFault问题通常具有偶发性、难以复现的特点,想要复现此类问题的现场更是难上加难。
2 Cortex-M系统架构
想要快速准确地定位解决HardFault问题需要对Cortex-M系统架构有着一定的理解,下文将以Cortex-M3为例介绍Cortex-M的架构内容。
2.1 操作状态和模式
从编程模型的角度上看,Cortex-M3处理器有两种操作状态和两个操作模式:
操作状态
- 调试状态:该状态下处理器将暂停指令执行。有两种方式进入调试状态:一是调试器发起的暂停请求,如调试器执行单步调试操作;二是调试部件产生的调试事件,如触发断点。在调试状态下,调试器可以访问或修改处理器寄存器的数值。
- Thumb状态:若处理器在执行程序代码(Thumb指令),它就会处于Thumb状态。无论在调试状态还是Thumb状态,调试器都可以访问处理器内外的外设等系统寄存器。Cortex-M处理器在启动后默认处于Thumb状态。
操作模式
- 线程模式:在执行普通的应用程序代码时系统处在该模式,处理器可处于特权访问等级也可处于非特权访问等级,具体由特殊寄存器CONTROL控制。特权访问等级和非特权访问等级的区别在于特权访问等级能访问处理器中的所有资源而非特权访问等级有着访问和执行的限制。
- 处理模式:执行中断服务程序(ISR)等异常处理,在该模式下,处理器具有特权访问等级。
下图展示了程序在运行时,Cortex-M3处理器的操作状态和模式的转换关系。
但对于许多简单的应用,非特权线程模式不会被使用,此时转换关系即可简化为下图所示。
2.2 寄存器组
寄存器是一种时序逻辑电路,在CPU内部暂时存放参与运算的数据和运算结果。与其它几乎所有寄存器类似,Cortex-M3处理器在处理器内核中有多个执行数据处理和控制的寄存器,如下图所示。
从图3可以看出,Cortex-M3寄存器组中共有16个寄存器,其中13个为通用目的寄存器,其它三个则有特殊用途。
R0~R12
寄存器R0~R12为通用目的寄存器,前8个(R0~R7)也被称作低寄存器。由于指令中可用的空间有限,许多16位指令只能访问低寄存器,R8~R12也被称作高寄存器,32位指令可以对其进行访问。
Cortex-M3遵循ATPCS(ARM Thumb Procedure Call Standard)原则,该原则规定了寄存器组的使用规则,R0~R3用于向子程序传递参数,这时寄存器可以记作:A1~A4,被调用的子程序在返回前无需恢复寄存器R0~R3的内容。R4~R11用于保存子程序的局部变量,此时寄存器记为V1~V8,子程序在进入时必须保存这些寄存器的值,在返回前必须恢复这些寄存器的值,对于子程序中没有用到的寄存器则不必执行这些操作。因此,R4~R11也被称作“被调用者保存寄存器”。R12为内部调用暂存寄存器,记作IP,在过程调用期间,可以将它用于任何用途,被调用函数在返回之前不必恢复R12。
R13
R13为栈指针(SP),可以通过PUSH和POP操作实现栈存储的访问。从物理存储的角度上看,存在两个栈指针,分别为MSP(主栈指针)和PSP(进程栈指针),其中MSP适用于系统复位(Thumb状态)或处理器处于处理模式时,而PSP只能用于线程模式。栈指针的选择由特殊寄存器CONTROL决定。
在同一时刻,这两个栈指针只会有一个可见。在简单的不带有OS的嵌入式系统中,线程模式和处理模式都可以只使用MSP,如下图所示。在中断发生后,处理器在进入中断服务程序(ISR)前会首先将多个寄存器压入栈中,在中断退出后,通过出栈操作恢复进入中断前的寄存器内容。整个过程仅使用到主栈指针MSP。
若嵌入式系统带有OS,它们通常会将应用任务和内核所用的栈空间分离开来,PSP此时将被用于线程模式。当中断发生时,会用PSP完成压栈操作并实现栈指针的切换(PSP->MSP),当中断退出时,将完成栈指针的切换(MSP->PSP)并用PSP完成出栈操作,如下图所示。这种设计最大程度确保了不同模式的栈空间不会被相互干扰。
R14
R14被称作链接寄存器(LR),当执行了函数或子程序调用后,LR的数值会自动更新。若某函数需要调用另一个函数或子程序,为了防止上一级函数返回地址丢失,系统会将当前R14的值保存在栈中。
在异常处理期间,LR也会被自动更新为特殊的EXC_RETURN(异常返回)数值,EXC_RETURN的位段含义见下表所示,之后该数值会在异常处理结束时触发异常返回。
位段 | 含义 |
---|---|
31:4 | EXC_RETURN标识符,默认全为1 |
4 | 栈帧类型,1(8字)或0(26字) |
3 | 0(返回进处理模式)1(返回进线程模式) |
2 | 0(返回后使用MSP)1(返回后使用PSP) |
1 | 保留,默认为0 |
0 | 0(返回ARM状态)1(返回Thumb状态) |
R15
R15为程序计数器(PC),读R15将返回当前指令地址加4,即PC总是指向“正在取指”的指令。
2.3 特殊寄存器
除了寄存器组中的寄存器,Cortex-M3还有若干特殊寄存器,这些寄存器只能通过MSR和MRS指令访问。简要介绍如下:
XPSR
xPSR为程序状态寄存器,由APSR、EPSR和IPSR组成,作用是为ALU提供算数标志位(负标志、零标志、进位标志、溢出标志和饱和标志)以及异常编号等。
PRIMASK
用于中断和异常屏蔽,位宽为1,默认值为0。当被置位为1时,表示允许NMI和HardFault异常,其它所有中断都会屏蔽。
FAULTMASK
用于中断和异常屏蔽,位宽为1,默认值为0。当被置位为1时,表示仅允许NMI异常,其它所有中断都会被屏蔽。
BASEPRI
用于中断和异常屏蔽,位宽最多8位(取决于芯片的优先级位数),默认值为0。当它被设置为非0时,它会屏蔽具有相同或更低优先级的异常(包括中断)。
CONTROL
控制寄存器定义了线程模式的访问等级(位0,nPRIV)和栈指针的选择(位1,SPSEL)。在Cortex-M4中,还有一位表示当前上下文是否使用浮点单元。
2.4 存储器映射
Cortex-M3地址总线为32位,存储器空间大小为2^32Bit = 4GB。Cortex-M3的4GB地址空间根据实际用法又被分为了多个存储器区域,如下图所示。这种架构安排具有很大的灵活性,存储器区域可用于其它目的。例如,程序可在CODE区,SRAM区和外部RAM区中执行。
外设存储器区域主要用于管理片上外设,外部设备区则用于片外外设等其它存储器。系统存储器区域可分为以下几个部分:
- 内部私有外设总线:用于访问Systick、MPU、NVIC等系统部件,以及Cortex-M3/M4内的调试部件,多数情况下,该存储器空间只能由在特权访问等级执行的代码访问。
- 外部私有外设总线:用于访问其它的可选调试部件,由芯片制造商添加,只能由在特权访问等级执行的代码访问。
- 供应商定义区域:供应商定义的部件,多数情况下是用不上的。
所有的Cortex-M设备均按照此种架构安排设计芯片,这样可以提高不同Cortex-M设备间的软件可移植性和代码可重用性。
2.5 栈存储
当一个函数调用另一个函数时,系统首先需要保存该函数的特定信息,如局部变量,返回地址,这些被保存的函数信息又被称作函数现场。当被调用函数退出时,根据保存的函数信息,恢复函数现场并继续向下执行程序。通常情况下,我们将保存函数信息的内存区域称为函数栈,栈就是函数的现场。
栈是存储器使用的一种线性数据结构,它由一块连续的内存和一个栈顶指针组成。Cortex-M3采用的栈模型为向下生长的满栈,满栈指的就是栈指针SP始终指向栈顶(最后一个被压入栈的32位数值)。
ARM处理器将系统主存储器用于栈空间操作,使用PUSH指令往栈中存储数据和POP指令从栈中读取数据,两个指令分别对应着函数现场的保存和函数现场的恢复,现将其过程简要介绍如下:
PUSH操作:(1)SP自减4(SP = SP - 4)(2)在SP指针处存入数据
POP操作:(1)读出SP指针处数据(2)SP自增4(SP = SP + 4)
3 HardFault查找和分析方法
3.1 栈回溯法
栈回溯法的核心思路是在程序进入异常后手动获取进入异常前的函数现场,进而得知系统执行哪一行代码出现了错误和函数的调用关系。
栈是函数的现场,获取函数现场即分析栈的存储内容。有两种情况下处理器会向栈中压入数据,一种情况是函数调用,另一种情况是程序进入中断或异常。两种情况下系统往栈中压入的内容不一致。
函数调用
从汇编的角度去看,当一个函数(称为A)调用另一个函数(称为B)时,首先会把当前函数的函数现场保存,将存储局部变量的寄存器(R4~R11)和函数返回地址(R14,LR)压入栈中,压栈顺序为:
L R − > R 11 − > . . . . . . − > R 4 LR->R11->......->R4 LR−>R11−>......−>R4
需要注意的是,只有存储了局部变量的寄存器才会被压入到栈中,未存储的不会被压入,且压栈顺序不会发生改变,假设A函数仅使用R4、R5、R6存储局部变量,则压栈顺序为:
L R − > R 6 − > R 5 − > R 4 LR->R6->R5->R4 LR−>R6−>R5−>R4
函数调用时并不需要将R0~R3,R12,xPSR寄存器压入栈中,根据AAPCS(ARM架构调用过程标准),这些寄存器被称作“调用者保存寄存器”,函数调用时可直接覆盖,无需保存。当函数执行完毕退出前,会执行出栈操作,将被调用者寄存器恢复为调用该函数之前的值。然后根据LR跳转到指定的指令继续向下执行。
程序进入中断/异常
不同于其它的处理器结构,Cortex-M的定位一开始就是为实时性、小体积容量的设计考虑的,所以在中断处理这一块,也做了一个十分有意思的设计——自动压栈处理。以往这个阶段都是通过人工操作写程序完成的,在Cortex-M上,当程序进入中断或异常时,芯片硬件会自动将8个通用寄存器压入当前栈空间,压栈的内容和顺序为:
x P S R − > P C − > L R − > R 12 − > R 3 − > R 2 − > R 1 − > R 0 xPSR->PC->LR->R12->R3->R2->R1->R0 xPSR−>PC−>LR−>R12−>R3−>R2−>R1−>R0
根据上述内容,以下述代码块为例,尝试分析当系统进入HardFault异常时函数栈结构。
static void A(void)
{
printf("Enter function A!\r\n");
...
B();
...
printf("Exit function A!\r\n");
}
static void B(void)
{
printf("Enter function B!\r\n");
...
fault_func();
...
printf("Exit function B!\r\n");
}
//该函数内无任何其它调用
static void Fault_Func(void)
{
...
//此处因某个编程错误进入HardFault异常
...
}
代码构建的函数调用关系为:
A ( ) − > B ( ) − > F a u l t _ F u n c ( ) A()->B()->Fault\_Func() A()−>B()−>Fault_Func()
当函数A调用函数B时,系统将函数A的函数现场压入栈中,当函数B调用函数Fault_Func时,系统再将函数B的函数现场压入栈中,当执行到Fault_Func函数中的异常代码时,芯片硬件自动将8个通用寄存器压入到栈空间,进入HardFault异常。最终得到的栈内容如下图所示。
需要注意的是,函数栈中并不会保存Fault_Func的函数现场,因为其不再有更深层次的调用。
明确了Cortex-M3的压栈机制,我们便可以使用栈回溯法分析MCU HardFault问题,这里选择IDE为Keil uVision5(简称为Keil5,下同),在调试状态下的具体步骤:
- 获取栈指针值。观察系统寄存器窗口。考虑到系统此时处在处理模式,使用的必定是主栈指针MSP,所以不能直接读取SP的数值,而应该先确定系统在进入异常前使用的栈指针类型。可以通过LR寄存器的值去判断,当LR的bit2 = 0时,进入异常前使用的是主栈指针MSP,反之使用的是线程栈指针PSP。确认使用的栈指针类型后在banked栏中找到对应的栈指针值。
- 找到程序计数器PC的值。打开memory窗口,输入SP值查看函数栈内容,将异常压栈内容与具体数值一一对应,找到进入异常前PC指向的地址。打开汇编窗口,右键点击反汇编按钮,输入PC值跳转到进入HardFault异常的代码位置。
-
进一步分析函数调用关系。只知道执行哪行代码时进入HardFault还不足以解决问题,还需要进一步分析在哪个调用链上出错。在分散加载文件中获取代码的起始地址,并在编译输出窗口中获取代码占用大小,计算代码的存储地址区间。将栈中数据与地址区间匹配,找到每一级的函数返回地址,整理出函数调用关系。
-
打开Fault Report窗口,观察各寄存器标志位。结合代码异常位置和函数调用关系,分析HardFault的产生原因,修改代码错误。
下图展示了栈回溯方法查找和分析HardFault问题的流程。
3.2 CmBackTrace查找法
CmBackTrace(Cortex Microcontroller Backtrace)是一款针对ARM Cortex-M系列MCU的错误代码自动追踪、定位、错误原因分析的开源库。该方法支持在非Debug模式下,自动分析故障类型和产生原因并定位发生故障的代码位置。结合addr2line工具,还能够输出错误现场的函数调用栈,支持多种主流编译器和操作系统平台。
3.2.1 移植过程
在使用CmBackTrace分析HardFault问题之前,首先需完成开源库的移植,具体步骤如下:
- 从github/gitee拉取CmBackTrace源码库,并在Keil5中将对应源文件添加到工程目录中,具体添加的文件内容如下图所示。
- 根据工程类型修改cmb_cfg.h的配置,包括使用的操作系统,CPU内核种类,输出信息语言等,具体内容如下表所示。
配置名称 | 功能 | 备注 |
---|---|---|
cmb_println(…) | 错误及诊断信息输出 | 必须使能 |
CMB_USING_BARE_METAL_PLATFORM | 是否使用裸机平台 | |
CMB_USING_OS_PLATFORM | 是否使用操作系统 | |
CMB_OS_PLATFORM_TYPE | 操作系统种类 | RTT/UCOSII/FREERTOS |
CMB_CPU_PLATFORM_TYPE | CPU平台 | M0/M3/M4/M7 |
CMB_USING_DUMP_STACK_INFO | 是否Dump堆栈信息 | |
CMB_PRINT_LANGUAGE | 输出信息时的语言 | CHINESE/ENGLISH |
- CmBackTrace重写了HardFault异常处理函数,如果中断处理文件中含有HardFault异常处理函数,将其注释掉防止重定义。
- 如果使用的操作系统为FreeRTOS,还应该在FreeRTOS/task.c文件中添加代码以适配功能,代码如下。
/* cmbacktrace调用接口 */
#ifdef USE_CMB
char *vTaskName(void)
{
configASSERT(pxCurrentTCB != NULL);
return pxCurrentTCB->pcTaskName;
}
/**
* @brief 获取任务栈起始地址
* @return pxStack 任务栈起始地址
*/
volatile uint32_t *vTaskStackAddr(void)
{
configASSERT(pxCurrentTCB != NULL);
return pxCurrentTCB->pxStack;
}
uint32_t vTaskStackSize(void)
{
configASSERT(pxCurrentTCB != NULL);
/* 根据栈向上/下生长划分出两种计算任务栈深度的方式 */
#if (portSTACK_GROWTH < 0)
return pxCurrentTCB->uxStackDepth;
#else
return pxNewTCB->pxEndOfStack - pxNewTCB->pxStack + 1;
#endif
}
#endif
/* 在结构体tskTCB声明中添加 */
typedef struct tskTaskControlBlock
{
...
#ifdef USE_CMB
StackType_t uxStackDepth;
#endif
...
} tskTCB;
/* 在任务创建函数prvInitialiseNewTask中添加 */
static void prvInitialiseNewTask(...)
{
...
#if( portSTACK_GROWTH < 0 ) {
pxNewTCB->uxStackDepth = ulStackDepth;
...
}
}
- 将addr2line添加到windows系统中。addr2line是一个可以解析可执行映像文件中的节表(section table)和符号表(symbol table)并将其转换成文件名、函数名和源代码行数的工具。其通常集成于Linux系统中,在Windows系统下使用该工具要将addr2line的文件路径添加到环境变量path中。
3.2.2 使用方法
相较于繁琐的移植过程,CmBackTrace的使用方法极为简洁,步骤如下:
- 读取异常信息。当处在Thumb状态下,系统执行到异常代码而进入HardFault时,CmBackTrace会在终端上位机打印异常信息,包含寄存器信息、线程堆栈信息,错误原因分析等内容。
- 在异常信息中查看有关于函数调用栈的内容,如下图所示。在工程output文件夹中(内含.axf文件)打开命令行运行异常信息提示指令,即可得到函数调用关系。
3.2.3 执行原理分析
CmBackTrace的目录结构如下图所示。
CmBackTrace的核心功能主要在cm_backtrace.c中实现,该文件实现的主要功能有:CmBackTrace的初始化、转储当前堆栈信息,打印中断压栈寄存器,打印addr2line指令,错误原因分析等。当程序在某处出现异常时,CmBackTrace的执行步骤如下:
- 跳转到重定义的异常处理函数HardFault_Handler中,读取寄存器lr(EXC_RETURN)和sp(主栈指针),将其作为函数形参传入函数cm_backtrace_fault中。
- 打印固件信息和版本号,识别系统类型。判断函数栈是否溢出,如果未溢出,将栈指针偏移一段地址,打印中断压栈寄存器信息。
- 读取SCB(系统控制块)寄存器组中与中断/异常管理相关的寄存器,解析寄存器数值,完成异常原因分析。
- 分析系统调用栈,根据存储器映射解析出正确的函数返回地址,将其拼接为函数调用栈提示指令打印在终端。
下图展示了CmBackTrace的执行流程。从上文解析和流程图可以看出,CmBackTrace的功能实现主要依靠于以下几点:(1)充分利用Cortex-M3的系统架构特性,代码的功能实现多依赖于系统寄存器和硬件,少有复杂的算法,降低了代码的复杂度和占用空间。(2)模拟栈回溯法分析过程,无冗余代码和输出信息,最大限度地提升代码执行效率。
4 小结
本文介绍了HardFault问题的背景和解决HardFault问题所需的理论知识,并在此基础上介绍了两种HardFault问题的查找和分析法,后续将会介绍两个HardFault实例现场,分别采用栈回溯法和CmBackTrace查找法对现场进行查找和分析,最后比较两种方法间的差异。