Cortex M0+ 内核 HardFault 诊断

HardFault 成因

在 Cortex M0+ 内核中,当程序运行出错时,就会触发 HardFault 异常。不同于 Cortex M3/M4 内核拥有 HardFault、MemManage、BusFault、UsageFault 四种异常,可以对应具体的程序运行出错类型,Cortex M0+ 只有一种 HardFault 对应运行错误。

HardFault 是优先级非常高的异常,其优先级为 -1 。只有优先级 -2 的 NMI( Non-Maskable Interrupt,不可屏蔽中断)和优先级 -3 的 Reset 异常可以抢占 HardFault。除此之外,HardFault 可以抢占所有的异常和中断,包括 SysTick ,MCU 厂商定义的外设中断如 USART 中断等。为方便行文,我们将异常和中断统称为异常。

导致 HardFault 的原因基本可以分为两大类:电气类和软件类。

通常电气类的故障异常发生时软件运行的位置不固定,且异常不稳定复现。当然也存在某些情况,比如软件运行至某些位置处,系统 IO 动作或者进入到高负载状态造成电源波动,从而引发异常,所以也会表现为软件运行到某些位置的函数时引发异常,但根本原因仍然是电气设计造成的。

软件类的故障异常,一般为软件写法不规范,或编译器编译产生的指令流有问题,通常可以准确定位异常发生位置。当然不规范的写法也不一定会导致 HardFault ,后文会提及这种情况。

如果出现 HardFault ,我们应当第一时间查看 MCU 的供电情况,确定电源没有问题再从软件入手查找原因。

以下几种软件错误都会导致 HardFault :

  • 在 SVCCall 或更高优先级的情况下使用 SVC 指令。

  • 在没有 debug 模块情况下使用断点指令(BKPT)。

  • 加载或存储(load or store)时系统产生的总线错误。

  • 从不可执行内存地址(Execute Never , XN)处执行指令。

  • 从系统会产生总线错误的地址处执行指令。

  • 获取向量时系统产生的总线错误。

  • 执行未定义的指令

  • 由于先前 T 位已被清零,导致在非 Thumb 状态下执行指令。

  • 试图对未对齐的地址执行加载(load)或存储( store)操作。

  • 若设备实现了 MPU (内存保护单元),则可能因权限违规或尝试访问未管理区域而产生 MPU 故障

这么多种错误中,我们最容易遇到的有两种。其一是对未对齐的地址执行加载或存储操作,也就是我们常说的非对齐访问。所谓的对齐访问是指对字(word)或多字访问使用字对齐地址,或对半字(halfword)访问使用半字对齐地址,字节(byte)访问始终是对齐的。

其二是在无执行权限的地址空间执行指令。通常,某个地址有可读、可写、可执行三种属性。Cortex-M0+ 的通用地址模型如下:

0x00000000 - 0x1FFFFFFF 地址通常为 ROM/Flash,是可读、可执行。

0x20000000 - 0x3FFFFFFF 地址为 RAM 是可读、可写、可执行的。

0x40000000 - 0x5FFFFFFF 是外设地址,为可读、可写、不可执行的。

0x60000000 - 0x7FFFFFFF 为片外存储,是可执行的。

如果因为指针操作错误之类的情况,程序跳转至 0x40000000 - 0x5FFFFFFF 的外设地址执行则会触发异常。

HardFault 定位

两种栈指针

首先需要确定当前使用栈指针是 MSP 还是 PSP。这部分内容不感兴趣的可以跳过,不影响我们分析 HardFault 的过程。

MSP 的全称是 Main Stack Pointer,这是默认使用的栈指针。内核会在复位后从 0x00000000 地址加载 MSP 的值。PSP 的全称是 Process Stack。继续讲栈指针之前我们有必要先介绍另外两组概念:Thread mode 和 Handler mode, Unprivileged 和 Priviledged。

Thread mode 和 Handler mode 指的是内核模式,内核在执行形如 XXX_Handler 的异常(包括 Reset)服务函数时处于 Hanlder mode,除此之外的程序运行时称为 Thread mode。

Unprivileged 和 Priviledged 指的是对一些内核寄存器的访问权限。程序在 Handler mode 时总是 Priviledged,可以访问所有内核寄存器,在 Thread mode 时,通过配置 CONTROL 寄存器决定是 Priviledged 或 Unprivileged。

同样的, Handler mode 总是使用 MSP ,thread mode 时 MSP 和 PSP 的选用也是通过 CONTORL 寄存器来配置。

+----------------+--------------------+----------------------------+-----------------------------+
| Processor mode | Used to execute    | Optional privilege level   | Stack used                  |
|                |                    | for software execution     |                             |
+----------------+--------------------+----------------------------+-----------------------------+
| Thread         | Applications       | Privileged or unprivileged | Main stack or process stack |
+----------------+--------------------+----------------------------+-----------------------------+
| Handler        | Exception handlers | Always privileged          | Main stack                  |
+----------------+--------------------+----------------------------+-----------------------------+

一般裸机的应用场景没有必要使用 PSP 。如果使用 RTOS 时,MSP 用于 OS 内核的 SP ,而 PSP 用于 thread 或者 task 的 SP ,这两个 SP 需严格分开。

栈帧和异常返回值

当异常产生时,内核会将 xPSR、PC、LR、R12、R3、R2、R1、R0 这 8 个寄存器信息依次存入当前的栈中,这一行为称为入栈,这 8 个寄存器的数据结构称为栈帧。栈帧包括返回地址,用于表示被中断的程序将要执行的下一条指令。其值存储在异常返回的 PC 指针。

           |           +------------+
           |           | <previous> |<--SP points here before interrupt
           |           +------------+
           | SP + 0x1C |    xPSR    |
           |           +------------+
           | SP + 0x18 |     PC     |
           |           +------------+
Decreasing | SP + 0x14 |     LR     |
           |           +------------+
    memory | SP + 0x10 |     R12    |
           |           +------------+
   address | SP + 0x0C |     R3     |
           |           +------------+
           | SP + 0x08 |     R2     |
           |           +------------+
           | SP + 0x04 |     R1     |
           |           +------------+
           | SP + 0x00 |     R0     |<--SP points here after interrupt
           ↓           +------------+

当入栈完成后,内核开始执行异常服务函数,同时把 EXC_RETURN 写入 LR 寄存器。EXC_RETURN 共有三个值用于表示使用哪种栈指针去寻找栈帧和进入异常服务函数之前是什么内核模式。

+------------------+-----------------------------------------------------+
| EXC_RETURN       | Description                                         |
+==================+=====================================================+
|                  | Return to Handler mode                              |
| 0xFFFFFFF1       | Exception return gets state from the main stack MSP |
|                  | Execution uses MSP after return                     |
+------------------+-----------------------------------------------------+
|                  | Return to Thread mode                               |
| 0xFFFFFFF9       | Exception return gets state from MSP                |
|                  | Execution uses MSP after return                     |
+------------------+-----------------------------------------------------+
|                  | Return to Thread mode                               |
| 0xFFFFFFFD       | Exception return gets state from PSP                |
|                  | Execution uses PSP after return                     |
+------------------+-----------------------------------------------------+
| All other values | Reserved                                            |
+------------------+-----------------------------------------------------+

HardFault 溯源实操

下图是产生 HardFault 时内核寄存器的状态。

内核寄存器的状态

本例中进入 HardFault 后的 LR= 0xFFFFFFF9,所以使用的是 MSP 。在内存窗口中搜索 MSP 的值,也就是地址 0x10002FB0

栈帧结构

按栈帧结构解析:

R0 (@0x10002FB0) = 0x00000000
R1 (@0x10002FB4) = 0x00000000
R2 (@0x10002FB8) = 0x10002020
R3 (@0x10002FBC) = 0x10001FF1
R12 (@0x10002FC0) = 0xFFFFFFFF
LR (@0x10002FC4) = 0x100004A9
PC (@0x10002FC8) = 0x10000488
xPSR (@0x10002FCC) = 0x61000000

在反汇编窗口输入栈帧中 PC 值 0x10000488,对应的指令就是导致 HardFault 的元凶。此时 R3=0x10001FF1,所以是非对齐加载字引发的 HardFault ,对应的 C 代码在 main.c 第 9 行。

现在我们知道问题出在 func1 函数了,但是如果有多个位置调用 func1 函数,我们还不能确定哪一个调用出的问题(假如是跟形参有关的非对齐地址访问)。这时我们要看栈帧中的 LR 值 0x100004A9。一般情况下 LR 的 bit0 是 0,因为 thumb 是 2 字节对齐的,但是一些指令需要将 bit0 置 1,用于指示当前是在 thumb 状态下。所以内核调用 func1 函数后继续执行代码的实际地址是 0x100004A8。进一步在反汇编窗口搜索 0x100004A8,可以看到这个地址是在 main 函数里面的 for 循环语句。也就是说内核是在 main 中调用 func1 然后触发非对齐加载字引发的异常。

从左侧的 Debug 窗口我们也能从 Call Stack 层级看到我们是在 main 函数中调用 func1 然后进入的异常。这佐证了我们的分析是正确的。

测试中遇到的问题

一、 GCC 不触发未对齐访问异常

测试代码如下:

volatile long unsigned a = 0;
_Alignas(4) volatile unsigned char buf[]={0x00, 0x01, 0x02, 0x03, 0x04};

static void func1(void)
{
    long unsigned *p;
    p = (long unsigned*)(buf + 1);
    a = *p;
}

int main(void)
{
    func1();
    for(;;);
}

在测试中发现 GCC 开 -Og 的优化等级就无法触发未对齐访问异常,GCC 会逐个字节搬运数据拼凑成新数据, 只有 -O0 的优化等级才会触发未对齐访问异常。反观 IAR 即使用默认的 low level 优化等级,也会老实地按照代码所写直接读写 buf[1] 的地址从而触发未对齐访问异常。

二、 GCC + OpenOCD 查看栈数据失败

使用 GCC 遇到的另外一个问题是查看 SP 指针对应地址的数据时会触发以下的错误,并且数据全是 0 。

Error: Failed to read memory at 0x10004000

估计应该是 LPC845 的 RAM 空间是 16KB, 栈顶放在 RAM 末尾,也就是栈是从 0x100003FFF 向下增长的。而 OpenOCD 一次读取多个地址数据,其中就有超过 0x100004000 的地址,因为识别到超过 RAM 的地址空间反而连靠近 RAM 末尾的栈数据也不显示。最后还是自己手动修改堆和栈的分配地址,避开 RAM 末尾这一块空间。我用 IAR 调试时没有这个问题。

从上面两个问题看来,不得不说 IAR 不愧是专业的嵌入式专用的编译器和调试工具。