作者:GorgonMeducer 傻孩子
首发:裸机思维
【说在前面的话】
很多人对编译器优化等级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
上述代码使用汇编语言的形式描述了一个典型的中断向量表:
- 跟在 DCD 后面的是中断/异常处理函数的名称,比如,SysTick\_Handler。将中断处理程序的名称放在 DCD 后面实际上相当于1)C语言中对目标函数取地址;2)然后将获取的函数的地址值作为uint32\_t类型的常量替换掉“DCD”——也就是作为地址常数保存在中断向量表里;
- 上述代码提供了 SysTick\_Handler 等异常/中断处理函数的默认实现,这里特别用 “[WEAK]” 加以修饰,表示:如果用户实现了一个同名的函数,则在链接阶段(linking stage)使用用户提供的版本,并舍弃这个默认实现;相反,如果用户并没有提供一个同名的函数,则继续由这个默认实现的异常/中断处理函数来填补空缺。
正是借助了这样的便利,大家可以大大方方的在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的汇编,不要紧,这里的看点主要有两个地方:
- SysTick\_Handler的第一条汇编指令就是 PUSH {r4, lr},也就是将 寄存器 R4和LR 压入栈中;其中 LR 保存了中断处理程序的 “中断结束令牌”。对Cortex-M处理器来说,当一个中断处理程序结束时,只要将这一“32bit的令牌” 赋值给 PC就可以实现中断的推出;
- SysTick\_Handler的最后一条指令是 POP {r4, pc}。可以看出它实际上是和第一条指令一一对应的,最终实现的功能就是从栈中取出R4的值还给R4、取出 LR 的值赋给 PC——这就完成了从中断退出的功能。
- 调用 systimer\_1ms\_handler() 时使用了 BL.W 指令,你可以先无视这里的".W"后缀,关键看 BL 的部分——这里B是Branch(跳转)的英文缩写,而 L 是 Link Register 的缩写。
- BL指令的作用是跳转到指定的函数运行的同时,将函数的返回地址保存在LR寄存器中——当然啦,我们是function call,不是 goto,有去还要有回的嘛。
- *
有的好奇宝宝会问,函数返回地址难道不是应该压在栈里的么?从C语言的标准模型来说是的,但Arm在这里做了一个优化,即函数的返回地址是保存在寄存器LR里的——这么做的原因是为了提高代码执行的效率。要理解这一点,请务必要在脑子里清晰的记住以下内容:
- 栈是保存在RAM存储器里的,如果要操作栈,必然会涉及到总线操作——而进行总线操作通常会消耗2个以上的周期。一般来说保存到芯片的通用寄存器中代价就要小得多——一般可以认为不消耗或者最多消耗1个周期。从结论来说,操作 memory 比操作 寄存器页里的寄存器要“贵重”;
- 有一类函数叫做叶子函数,它的特点就是“不会继续调用其它任何函数了”。通常叶子函数会成为程序执行的热点(hot spot),也就是传说中会被重复调用、代码可能不长但却消耗很大比例CPU时间的函数——正因为这类叶子函数人小胃口大,任何一点的性能损失都会导致系统整体性能的明显下降(甚至是成倍的下降),因为,用LR来保存函数返回值(避免了栈操作)可以在大量频繁的对叶子函数的调用中避免由“贵重”的总线操作带来的性能损失。
- 有人会问,那不是叶子函数的情况怎么办呢?答案很简单,Cortex-M的架构会假设每一个函数都是叶子函数,并通过带“L”字眼的Branch指令(BL或者BLX)来完成跳转——也就是说默认先用LR保存返回地址——这是第一步,由芯片架构做出的约定。第二步,由于编译器完全掌握用户的函数间调用关系,它完全知道哪个函数是叶子函数还是普通函数,因此它可以在一个函数确实要调用别的函数时,先把LR压栈,等从目标函数返回后,再从栈中恢复原来LR中的值。其实,就拿我们这里的例子来说,如果SysTick\_Handler没有调用 systimer\_1s\_handler(),那么它显然就是一个叶子函数,那么由于进入中断时,令牌已经保存在LR中,因此从中断处理程序中退出就只需要普通的 BX LR指令即可——通过编译我们可以轻松的验证这种说法。可以看到,由于我们屏蔽了 对systimer\_1ms\_handler()的调用,头尾的 PUSH和POP都消失了,取而代之的是通过 “BX lr”指令来把 LR寄存器的内容拷贝到PC中:
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了!
- 我们的SysTick\_Handler仍然要调用函数 systimer\_1ms\_handler
- 然而SysTick\_Handler一头一尾的PUSH和POP却消失了!
- 不仅如此,当从systimer\_1ms\_handler返回后,中断处理程序并不会结束,而是直接入侵到别的代码里了,这里通过 跟随在 BL.W 后的 “NOP”三连可以观察的非常清晰!
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的原因,我们可以进行一个合理的猜测:
- systimer\_1ms\_handler 虽然被标记了 weak,但由于它跟调用它的函数SysTick\_Timer() 处于同一个 “.c” 里,因此编译器觉得自己在处理SysTick\_Handler时获得了它所依赖的函数的充分信息——是的,bug就在于:编译器此时忽视了weak的意义——它以为这里看到的systimer\_1ms\_handler的默认版本就是“全部的可能性”;
- 由于 systimer\_1ms\_handler 的默认实现中使用导致函数肯定不会返回的实现,比如:"while(1);" 或是 "assert(false);" 从而让编译器确信,用户一旦在SysTick\_Handler中调用了 systimer\_1ms\_handler 以后就再也不会回来了。
- 基于这一考虑,编译器觉得,既然一去不复返,为啥要保护LR呢?干脆连中断退出都去掉吧。
容易注意到,编译器这里的推理都是合理的,唯一的例外就是它看漏了“weak”——当然严格说他完全看漏了也不对,因为它的确给默认版本的 systimer\_1ms\_handler() 追加了 weak 属性(这点可以通过你实现一个自己版本的systimer\_1ms\_handler() 来验证,这里就不在展开)——但它在分析当前 “.c” 文件中的函数调用关系时,的确忽略了“weak”的存在,从而导致了错误的优化推理过程。
最后值得说一下的是,为啥要往默认函数里加死循环?且不说中断处理程序的默认函数都是死循环,用户可能无脑拷贝,在实际应用中可能存在以下的合理情形:
- 用默认的函数来构造“陷阱”,也就是说,正常应用情况下,用户应该是必须要实现一个自己的版本;一旦用户漏了,就可以通过这个死循环陷阱或是assert() 抓住错误。
- 函数可能有参数传递,而通过assert来确认参数是有效的。这种情况如果因为某种原因,传入的某个参数在编译时刻编译器就能确定这里肯定是触发了assert(),那么也会触发这一bug。
【结论】
【玄学说法1】编译器在 "-O0" 下是不会进行代码优化的
【实际情况】编译器在"-O0"下并没有许诺不进行优化,实际上它只是许诺自己所作的优化以“不影响用户调试”为前提。很多时候,它还是会做一些很基本的优化的。
【玄学说法2】在关闭优化的情况下,我的代码明明逻辑是对的,可是有时候逻辑就是不太对,好像是跑飞了,但我又没有证据……好像完全看编译器心情,有时候我随便挪挪函数的位置,好像问题就解决了。
【实际情况】编译器出bug了!而且,实际上当你无意中破坏了以下两个条件中的任意一个,都会成功回避这个bug的触发条件:
- weak函数跟调用它的函数不放在同一个.c里(让编译器没法觉得自己获取了函数调用关系的足够信息);
- 在weak函数里注释掉了可能会诱发死循环或是assert()的代码。
【后记】
大人,时代变了,不要继续抱着 armcc 不放了…… 它已经走到了自己生命周期的终点,已经不维护了!
最后,欢迎大家尽早投入到Arm Compiler 6、IAR、GCC的怀抱……
专栏推荐文章
【编译器玄学研究报告】第一期——位域和volatile
【【编译器玄学研究报告】第二期——break
如果你喜欢我的思维,欢迎订阅裸机思维
版权归裸机思维(傻孩子图书工作室旗下公众号)所有,
所有内容原创,严禁任何形式的转载。