【说在前面的话】
很多人对编译器优化等级0("-O0")有着谜之信仰——认为在这个优化等级下编译器一定不会对代码进行不必要的优化——至少不会进行危险且激进的优化。让我们来看一个来自Arm Compiler 5的案例吧:
【正文】
在嵌入式系统中通过属性weak(实际使用的时候很可能用gcc的兼容写法通过 __attribute__((weak)) 来给函数附加这一属性)来为某一个函数提供一个默认实现,实际上大家熟悉的中断处理程序就是这么实现的,比如随便打开一个startup_xxxx.S文件,我们可以看到如下的内容:
; Vector Table Mapped to Address 0 at Reset
AREA RESET, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
__Vectors DCD __initial_sp ; Top of Stack
DCD Reset_Handler ; Reset Handler
DCD NMI_Handler ; NMI Handler
DCD HardFault_Handler ; Hard Fault Handler
...
DCD SysTick_Handler ; SysTick Handle
...
; Dummy Exception Handlers (infinite loops which can be modified)
NMI_Handler PROC
EXPORT NMI_Handler [WEAK]
B .
ENDP
HardFault_Handler\
PROC
EXPORT HardFault_Handler [WEAK]
B .
ENDP
SysTick_Handler PROC
EXPORT SysTick_Handler [WEAK]
B .
ENDP
上述代码使用汇编语言的形式描述了一个典型的中断向量表:
正是借助了这样的便利,大家可以大大方方的在C语言中“按需”添加自己的中断处理程序,例如,下面的代码就通过SysTick_Handler实现了一个简单阻塞式的毫秒级延时功能:
#include <stdint.h>
...
static volatile uint32_t s_wMSCounter = 0;
void SysTick_Handler(void)
{
if (s_wMSCounter) {
s_wMSCounter--;
}
}
void delay_ms(uint32_t wMillisecond)
{
s_wMSCounter = wMillisecond;
while( s_wMSCounter > 0 );
}
//! 用 constructor 修饰,会告诉编译器进入main函数之前一定先执行下对应的函数
__attribute__((constructor(255)))
void platform_init(void)
{
...
/* Generate interrupt each 1 ms */
SysTick_Config(SystemCoreClock / 1000);
...
}
毋庸置疑,最终上述代码中所实现的 SysTick_Handler() 会替换掉 startup_xxxxx.S 中所提供的那个默认的版本。到目前为止一切看起来也都还没什么问题。
更进一步的,假设我们想把上述代码封装成一个模块(无论该模块是提供源代码还是只提供库文件"*.lib")——也就是放在一个专门的“.c”文件中,然后就不希望模块的使用者去修改它的内容。这时候可能就会产生一个新的需求,因为这个模块用SysTick产生了一个1ms为间隔的中断,而系统中其它部分可能也需要这样一个1ms为间隔的事件源:一方面考虑只为了一个delay_ms() 就完全独占SysTick实在太浪费,另一方面,你也不希望其它用户仅仅因为想在SysTick_Handler中执行自己的代码就来“染指”你封装好的模块——如果有源代码还好办,如果你提供的是预先编译好的库,那用户想要往SysTick_Handler中插入自己的代码就没那么容易了(仍然可以通过特殊手段做到)。
为了解决这一问题,很容易想到,继续借助weak的方式来创建一个专门的以 1ms 为间隔的事件处理函数:
//! 添加一个weak属性的默认函数实现
__attribute__((weak))
void systimer_1ms_handler(void)
{
//! 提供了一个默认实现
}
void SysTick_Handler(void)
{
if (s_wMSCounter) {
s_wMSCounter--;
}
systimer_1ms_handler();
}
通过在模块内为 systimer_1ms_handler() 提供了一个默认的函数实现,我们“复刻”了 SysTick_Handler 中断处理程序的那套技巧——用户只需要在模块外的任意地方实现一个自己的 systimer_1ms_handler() 函数,就能在“链接时刻” 实现插入自己的代码逻辑到 SysTick_Handler() 中的功能。到目前为止一切都安好。甚至为了“保证安全”,我们在使用Arm Compiler 5(也就是大家熟悉、信任和执念的armcc)时关闭了优化:
编译后通过仿真,可以看到 SysTick_Handler 对应的代码生成如下:
0x000000DA B510 PUSH {r4,lr}
42: if (s_wMSCounter) {
0x000000DC 481F LDR r0,[pc,#124] ; @0x0000015C
0x000000DE 6800 LDR r0,[r0,#0x00]
0x000000E0 B120 CBZ r0,0x000000EC
43: s_wMSCounter--;
44: }
45:
0x000000E2 481E LDR r0,[pc,#120] ; @0x0000015C
0x000000E4 6800 LDR r0,[r0,#0x00]
0x000000E6 1E40 SUBS r0,r0,#1
0x000000E8 491C LDR r1,[pc,#112] ; @0x0000015C
0x000000EA 6008 STR r0,[r1,#0x00]
46: systimer_1ms_handler();
0x000000EC F000F868 BL.W systimer_1ms_handler (0x000001C0)
0x000000F0 BD10 POP {r4,pc}
如果你读不懂Cortex-M的汇编,不要紧,这里的看点主要有两个地方:
有的好奇宝宝会问,函数返回地址难道不是应该压在栈里的么?从C语言的标准模型来说是的,但Arm在这里做了一个优化,即函数的返回地址是保存在寄存器LR里的——这么做的原因是为了提高代码执行的效率。要理解这一点,请务必要在脑子里清晰的记住以下内容:
42: if (s_wMSCounter) {
0x000000DA 481F LDR r0,[pc,#124] ; @0x00000158
0x000000DC 6800 LDR r0,[r0,#0x00]
0x000000DE B120 CBZ r0,0x000000EA
43: s_wMSCounter--;
44: }
45:
46: //systimer_1ms_handler();
0x000000E0 481D LDR r0,[pc,#116] ; @0x00000158
0x000000E2 6800 LDR r0,[r0,#0x00]
0x000000E4 1E40 SUBS r0,r0,#1
0x000000E6 491C LDR r1,[pc,#112] ; @0x00000158
0x000000E8 6008 STR r0,[r1,#0x00]
0x000000EA 4770 BX lr
到目前为止,我们已经有了一个模块,并通过weak的方法为模块的使用者提供了一个毫秒级的事件源——一个周期性被调用的函数 systimer_1ms_handler()。假设因为某种原因,我们希望在默认的处理函数里加一个死循环:
#include <assert.h>
__attribute__((weak))
void systimer_1ms_handler(void)
{
assert(false);
}
或者是:
__attribute__((weak))
void systimer_1ms_handler(void)
{
while(1);
}
为了便于观察结果,我加入了“NOP三联”:
void SysTick_Handler (void)
{
if (s_wMSCounter) {
s_wMSCounter--;
}
systimer_1ms_handler();
}
void delay_ms(uint32_t wMillisecond)
{
//! 展现奇迹的 三连
__asm("nop");__asm("nop");__asm("nop");
s_wMSCounter = wMillisecond;
while( s_wMSCounter > 0 );
}
编译器会就此开始它的表演,我们来看此时的代码生成:
0x000000E2 4826 LDR r0,[pc,#152] ; @0x0000017C
0x000000E4 6800 LDR r0,[r0,#0x00]
0x000000E6 B120 CBZ r0,0x000000F2
43: s_wMSCounter--;
...
46: systimer_1ms_handler();
47: }
48:
49: void delay_ms(uint32_t wMillisecond)
50: {
0x000000F2 F000F875 BL.W systimer_1ms_handler (0x000001E0)
51: __asm("nop");__asm("nop");__asm("nop");
52:
0x000000F6 BF00 NOP
0x000000F8 BF00 NOP
0x000000FA BF00 NOP
...
OH,我的天哪!奇迹发生了!编译器出bug了!
0x000000F2 F000F875 BL.W systimer_1ms_handler (0x000001E0)
51: __asm("nop");__asm("nop");__asm("nop");
52:
0x000000F6 BF00 NOP
0x000000F8 BF00 NOP
0x000000FA BF00 NOP
结论:一旦执行SysTick_Handler,由于缺乏正确的对LR的保护,中断处理程序不仅不会通过“令牌”退出(实际上保存在LR中令牌已经被 BL.W 覆盖了了),还事实上跑飞了——已经进入了别的函数的地盘。
天哪,这是"-O0"啊!
【事后分析】
这是 Arm Compiler 5 真实存在的一个bug。需要强调一点的是:在"-O0"等级下对代码进行优化并不是bug,真正造成现在这样bug的原因,我们可以进行一个合理的猜测:
容易注意到,编译器这里的推理都是合理的,唯一的例外就是它看漏了“weak”——当然严格说他完全看漏了也不对,因为它的确给默认版本的 systimer_1ms_handler() 追加了 weak 属性(这点可以通过你实现一个自己版本的systimer_1ms_handler() 来验证,这里就不在展开)——但它在分析当前 “.c” 文件中的函数调用关系时,的确忽略了“weak”的存在,从而导致了错误的优化推理过程。
最后值得说一下的是,为啥要往默认函数里加死循环?且不说中断处理程序的默认函数都是死循环,用户可能无脑拷贝,在实际应用中可能存在以下的合理情形:
【结论】
【玄学说法1】编译器在 "-O0" 下是不会进行代码优化的
【实际情况】编译器在"-O0"下并没有许诺不进行优化,实际上它只是许诺自己所作的优化以“不影响用户调试”为前提。很多时候,它还是会做一些很基本的优化的。
【玄学说法2】在关闭优化的情况下,我的代码明明逻辑是对的,可是有时候逻辑就是不太对,好像是跑飞了,但我又没有证据……好像完全看编译器心情,有时候我随便挪挪函数的位置,好像问题就解决了。
【实际情况】编译器出bug了!而且,实际上当你无意中破坏了以下两个条件中的任意一个,都会成功回避这个bug的触发条件:
【后记】
大人,时代变了,不要继续抱着 armcc 不放了…… 它已经走到了自己生命周期的终点,已经不维护了! 最后,欢迎大家尽早投入到Arm Compiler 6、IAR、GCC的怀抱……
原创不易,
如果你喜欢我的思维、
如果你觉得我的文章对你有所启发或是帮助,
还请“点赞、收藏、转发” 三连!
欢迎订阅 裸机思维