傻孩子(GorgonMeducer) · 2020年08月25日

【编译器玄学研究报告】第三期——“-O0” 就能逃出优化的魔爪么?

作者:GorgonMeducer 傻孩子
首发:裸机思维

image.png

【说在前面的话】


很多人对编译器优化等级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)时关闭了优化:

image.png

编译后通过仿真,可以看到 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      
...

image.png

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

如果你喜欢我的思维,欢迎订阅裸机思维
版权归裸机思维(傻孩子图书工作室旗下公众号)所有,
所有内容原创,严禁任何形式的转载。
推荐阅读
关注数
1466
内容数
108
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息