妙不可言的异常捕获机制
WangGaojie Lv4

什么是异常捕获

异常捕获是程序员在程序中捕获异常的一种方式,异常捕获可以避免程序崩溃,让程序继续运行。当程序的运行环境收到异常信息后,会自动寻址处理该异常的catch块处理该异常,这个过程被称为异常捕获。

大部分高级的编程语言都提供了异常捕获机制,如Java、C++、Python等。C语言不提供异常捕获机制,尤其是在嵌入式开发中,C语言的异常捕获机制非常弱,而且容易导致程序崩溃。所有一般MCU提供了错误检测机制,如:HardFault等中断,但是这些中断是直接导致程序崩溃的,而不是异常捕获机制。

本文提供了一种方法,可以捕获C语言中的异常,并且可以自定义异常处理过程,可以到达同一般异常捕获机制相似的效果。

异常捕获的基本原理

通过try语句标记捕获异常的代码,通过catch语句标记异常处理代码。所以在执行try语句时,需要保存程序上下文的信息,以便在catch语句中恢复执行。保存上下文的过程根据不同的处理器架构有所不同,但基本思路是保存处理器寄存器、堆栈指针、堆栈内容等。

如何保存上下文信息

这里通过特定的处理器来说明上下文信息是如何保存的,以Cortex-M7芯片为例,其它的芯片的实现方式类似。上下文信息包括:寄存器、栈指针、LR、PC、PSR。其中寄存器需要根据AAPCS规范来处理,即主要保存被调用者保存寄存器,包括R4-R11。调用者的寄存器在外层栈帧中已经处理过了,所以不需要保存。栈指针为SP寄存器。程序指针这里将使用LR指针,通过它可以快速返回到异常调用的出口位置。

Show code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.global try_save_context
.type try_save_context,%function
.thumb_func

// typedef int try_ctx_t[10];
// int try_save_context(try_ctx_t ctx);
try_save_context:
push {r0, lr}
mov r2, r0
mov r0, #0
mov r1, #0
bl vTaskSetThreadLocalStoragePointer
pop {r0, lr}
mov ip, sp
stmia.w r0!, {r4, r5, r6, r7, r8, r9, sl, fp, ip, lr}
mov r0, 0
bx lr

这里实现了一个保存上下文函数的接口,用户传入一个try_ctx_t结构体指针,保存上下文。该指针将保存到FreeRTOS的线程本地存储中,方便后续恢复上下文。将处理器的r4、r5、r6、r7、r8、r9、sl、fp、ip、lr保存到try_ctx_t结构体中。最后函数返回0表示上下文保存完成。

对于非RTOS的用户,这里的代码需要做响应的调整,可以使用一个独立的全局变量来存储ctx,这里就不再详细介绍了。后续的整个实现都是基于FreeRTOS来实现的。

如何在中断内恢复到用户线程的上下文

这是整个异常捕获机制中最复杂的部分。当处理器进入中断后处于handle模式,而用户的线程处于user模式。两者没有在同一个上下运行空间内。如何恢复到用户线程的上下文呢?

基本思路是从FreeRTOS的线程本地存储中获取用户上下文信息,然后将其中主要的寄存器恢复到处理器寄存器中。然后在用户线程中模拟一个中断栈来保证当前的handle中断能够正常退出,使得用户返回到线程模式后能够继续执行,且用户栈和中断栈都满足平衡的要求。

Show code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#define FAULT_THROW()                                                                           \
{ \
__asm volatile( \
"dsb \n isb \n" \
"push {r0, r1, r2, r3, r12, lr} \n" \
"tst lr, #4 \n" \
"beq .end" END_LINE " \n" /* Can't try in Handle mode */ \
"mov r0, #0 \n" \
"mov r1, #0 \n" \
"bl pvTaskGetThreadLocalStoragePointer\n" /* Get try context */ \
"cmp r0, #0 \n" \
"beq .end" END_LINE " \n" \
"mov r12, r0 \n" \
"mov r0, #0 \n" \
"mov r1, #0 \n" \
"mov r2, #0 \n" \
"bl vTaskSetThreadLocalStoragePointer\n" /* Set try context to NULL */ \
"mov r0, r12 \n" \
"ldr r1, [r0, 32] \n" /* get sp in context */ \
"ldr r2, [r0, 36] \n" /* get pc in context */ \
"sub r1, #32 \n" /* adjust sp for user stack frame */ \
"str r2, [r1, 24] \n" /* save PC to the user stack frame */ \
"mov r2, #0x1000000 \n" /* xPSR, bit24 is thumb state, it's always 1 */ \
"str r2, [r1, 28] \n" /* save xPSR to the user stack frame */ \
"mov r2, #0x40000000 \n" \
"str r2, [r1, 0] \n" /* R0=0x40000000, it's the throw value */ \
"ldmia r0!, {r4, r5, r6, r7, " \
" r8, r9, r10, r11} \n" /* restory context */ \
"msr psp, r1 \n" /* restore the PSP */ \
"isb \n" \
"pop {r0, r1, r2, r3, r12, lr} \n" /* MSP stack balance */ \
"orr lr, lr, #0x10 \n" /* clear floating-point flag */ \
"isb \n" \
"bx lr \n" /* exit ISR and jump to user context */ \
".end" END_LINE ": \n" \
"pop {r0, r1, r2, r3, r12, lr} \n"); \
}
#define _STR2(x) #x
#define _STR(x) _STR2(x)
#define END_LINE _STR(__LINE__)

这里实现了一个宏定义,用于捕获异常,并跳转到用户上下文。

  1. 首先判断跳转到该中断前的处理器模式,如果不是用户模式,则直接返回,因为这不能捕获异常。
  2. 然后判断当前线程本地存储指针内容,这是用于保存上下文信息的指针,如果指针为NULL,则表示当前没有执行try_save_context,所以直接返回。
  3. 如果指针不为NULL,则表示当前执行了try_save_context。同时清空线程本地存储指针,表示当前的异常已经被处理了。
  4. 从上下文信息中取出sp指针,以次为基础构造一个中断栈,这是为了当前的中断能够正常返回而必要的。”sub r1, #32”
  5. 在调整的栈中保存PC指针(即原上下文中的LR寄存器),构造一个合法的PSR寄存器,PSR寄存器中不需要浮点标志位、栈对齐等信息,只需要处理器模式为thumb模式即可。
  6. 从上下文信息中取出r4-r11,依次恢复到各种的寄存器中。
  7. 通过msr指令将新的栈指针写入到psp中。
  8. 通过lr寄存器退出当前的中,特别主要需要将lr寄存器的浮点上下文标志清除。

当处理器执行bx lr时,处理器根据运行模式从psp中取出r0-r3的值恢复的处理器上,同时取出PC寄存器跳转到用户程序中,这样就完成了从中断上下文恢复到应用程序的上下文。这里涉及到了大量Cortex-M处理器的中断处理机制相关内容,这里就不展开了。

try语法糖

前面实现了用户线程的保护和恢复,已经就能够实现异常捕获了,类似这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void test_func(void){
try_ctx_t ctx;
if(try_save_context(ctx) == 0){
printf("test_func\n");
// do something

illegal_function_execution(); // Trigger exception

printf("never run here\n");
}else{
printf("catch test_func exception\n");
}
}

void test_func2(void){
_try{
printf("test_func\n");
// do something

illegal_function_execution(); // Trigger exception

printf("never run here\n");
}
_catch{
printf("catch test_func exception\n");
}
}

将test_func函数包装为test_func2函数,需要实现两个语法糖:_try、_catch。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int try_save_context(try_ctx_t ctx);
void try_clear_context(void);

#define _try \
try_ctx_t CONNECT(__ctx, __LINE__); \
if (try_save_context(CONNECT(__ctx, __LINE__)) == 0) \
{
#define _catch \
try_clear_context(); \
} \
else

#define CONNECTION(text1, text2) text1##text2
#define CONNECT(text1, text2) CONNECTION(text1, text2)

这里需要注意一个问题,当用户程序未出现异常时,需要主动调用try_clear_context函数,这样才能将当前try的上下文环境清除掉,否则try的边界不清晰,导致异常捕获机制出现混乱。

这里还需要注意的是该语法糖不支持ex消息传递。我最开始设计的时候是想要取得该消息的,但是后来在设计该语法结构时发现这需要使用finial块,但是finial块的意义并不大,且会导致该语法糖变得复杂,所以该语法糖不支持ex消息传递。此外还需要特别注意的是try语句不能独立出现,它必须和catch语句一起出现。

在使用该语法糖时,还需要注意的一点时,在try语句块中不能直接出现return语句。return语句会破坏try的边界,导致异常捕获机制的混乱。但是在catch语句中可以使用return。

从用户空间抛出一个异常也是一个有意思的设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
.global try_clear_context
.type try_clear_context,%function
.thumb_func

// void try_clear_context(void)
try_clear_context:
push {lr}
mov r0, #0
mov r1, #0
mov r2, #0
bl vTaskSetThreadLocalStoragePointer
pop {pc}


.global _throw
.type _throw,%function
.thumb_func

// void _throw(int ex)
_throw:
push {r0, lr}
mov r0, #0
mov r1, #0
bl pvTaskGetThreadLocalStoragePointer
cmp r0, #0
beq .end_throw
mov r12, r0
mov r0, #0
mov r1, #0
mov r2, #0
bl vTaskSetThreadLocalStoragePointer
pop {r0, lr}
mov r1, r12
ldmia r1!, {r4, r5, r6, r7, r8, r9, r10, r11, r12, lr}
mov sp, r12
cmp r0, #0
it eq
moveq r0, #1
isb
bx lr
.end_throw:
pop {r0, pc}

在用户空间调用_throw可以主动抛出一个异常,ex为一个非零的异常编号。

try嵌套问题

我最开始是想要设计异常捕获嵌套的,因为这不是一个复杂的技术问题,一个链式结构就能够实现。我之所以选择放弃这部分实现的原因是,我担心这会带来一些问题。因为C语言本身不支持垃圾回收,滥用异常捕获机制可能会导致资源泄漏的风险,所以我放弃了异常捕获嵌套的功能。这里实现的异常捕获机制也许更适合一个故障分析的场景,或者用于对一些关键代码进行保护。

浮点寄存器相关问题

这里的设计没有考虑保存浮点上下文,所以在使用的时候需要特别注意用法环境。此外对于惰性压栈机制,我这里使用DSB、ISB指令进行处理,确保浮点数的中断压栈正常,但我对这一块的具体细节不是很清楚,所以需要进一步研究。

总结

我最开始想要该功能的原因是我在执行一些第三方的代码时经常出现hard故障,我需要对这些不可信代码的执行做一个保护,于是写了一个简单的异常捕获机制,将异常捕获后返回一个错误码,然后根据错误码来判断是否需要重试。对于多任务环境,故障时直接将MCU挂起可能带来一些额外的风险,以为某些关键任务程序不能中断运行。这个异常捕获程序很好的解决了这方面的问题。

测试附件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int devide_zero(void)
{
int r;
volatile unsigned int a;
volatile unsigned int b;
SCB->CCR |= SCB_CCR_DIV_0_TRP_Msk;
a = 1;
b = 0;
r = a / b;
return r;
}

int main_test(){
_try{
printf("devide zero test\n");

// 对于一般的MCU,执行该函数会导致HardFault异常,使处理器停机。
// 但这里的异常捕获机制会使得程序能够继续运行。
devide_zero();
}
_catch{
printf("catch devide zero fault\n");
}

printf("mcu not stop\n");

return 0;
}

void HardFault_Handler(void){
FAULT_THROW();
while(1);
}

Run output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
devide zero test
catch devide zero fault

Fault Analysis:
Exception id: 3
Systick: 25857
Core register:
R0: 00000001 R1: 00000000 R2: e000ed00 R3: 00000000
R4: 900ad014 R5: 24018140 R6: 900ad014 R7: 20003db0
R8: 00000001 R9: 900ad014 R10: 00000000 R11: 24015180
R12: fa000000 SP: 20003d48 LR: 9007f63f PC: 9001905c
PSR: 61000000 ERT: fffffffd (PSP / User)
Usage Fault:
Divide by zero
Hard Fault:
Forced Hard Fault
Stack memory:
20003d48: 00000000 00000001 900ad008 9007a615
20003d58: 900ad008 24018140 900ad014 20003db0
20003d68: 00000001 900ad014 00000000 24015180
20003d78: 20003d58 9007a609 00000001 900ad014
20003d88: 24018140 00000000 00000000 a5a5a5a5
20003d98: a5a5a5a5 9007a969 00000001 a5a5a5a5
20003da8: a5a5a5a5 0da5a5a5 24018140 00000000
20003db8: 00000000 00000000 00000000 00000000
20003dc8: 00000000 00000000 00000000 00000000
20003dd8: 00000000 00000000 00000000 00000000
20003de8: 00000000 00000000 00000000 00000000
20003df8: 00000000 00000000 00000000 00000000
20003e08: 00000000 00000000 a5a5a5a5 a5a5a5a5
20003e18: a5a5a5a5 a5a5a5a5 a5a5a5a5 a5a5a5a5
20003e28: a5a5a5a5 a5a5a5a5 a5a5a5a5 900834f5
20003e38: a5a5a5a5 a5a5a5a5 20003d4c 00006501
Call stack backtrack:
[9001905c] call from <9007f63f>, sp=20003d4c
[9007f63f] call from <9007a615>, sp=20003d54
[9007a615] call from <9007a969>, sp=20003d9c
[9007a969] call from <900834f5>, sp=20003e34
Show fault ASM code:
9001904a: f043 0310 orr r3, r3, #16
9001904e: 6153 str r3, [r2, #20]
90019050: 2301 movs r3, #1
90019052: 9301 str r3, [sp, #4]
90019054: 2300 movs r3, #0
90019056: 9300 str r3, [sp, #0]
90019058: 9801 ldr r0, [sp, #4]
9001905a: 9b00 ldr r3, [sp, #0]
<break point>
9001905c: fbb0 f0f3 udiv r0, r0, r3
90019060: b002 add sp, #8
90019062: 4770 bx lr
90019064: ed00 e000 stc 0, cr14, [r0, #0]!
90019068: b500 push {lr}
9001906a: b083 sub sp, #12
9001906c: 9200 str r2, [sp, #0]
9001906e: 460a mov r2, r1
mcu not stop

经过测试,该功能工作正常,通过在线故障诊断,已经输出了错误时刻的详细信息,包括:寄存器、堆栈、调用栈、故障点附近的汇编代码。可以发现break point已经给出了故障具体的位置,配合寄存器,可以确认这是除零故障。

如何在单片机内自诊断,生成详细的故障诊断报告也是一个非常有意思的功能,以后有时间再写一篇文章详细介绍。