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

2020-08-27 10:28:52 浏览数 (1)

【说在前面的话】


很多人对编译器优化等级0("-O0")有着谜之信仰——认为在这个优化等级下编译器一定不会对代码进行不必要的优化——至少不会进行危险且激进的优化。让我们来看一个来自Arm Compiler 5的案例吧:

【正文】


在嵌入式系统中通过属性weak(实际使用的时候很可能用gcc的兼容写法通过 __attribute__((weak)) 来给函数附加这一属性)来为某一个函数提供一个默认实现,实际上大家熟悉的中断处理程序就是这么实现的,比如随便打开一个startup_xxxx.S文件,我们可以看到如下的内容:

代码语言:javascript复制
; 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实现了一个简单阻塞式的毫秒级延时功能:

代码语言:javascript复制
#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 为间隔的事件处理函数:

代码语言:javascript复制
//! 添加一个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 对应的代码生成如下:

代码语言:javascript复制
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中:
代码语言:javascript复制
    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()。假设因为某种原因,我们希望在默认的处理函数里加一个死循环:

代码语言:javascript复制
#include <assert.h>

__attribute__((weak))
void systimer_1ms_handler(void)
{
    assert(false);
}

或者是:‍

代码语言:javascript复制
__attribute__((weak))
void systimer_1ms_handler(void)
{
    while(1);
}

为了便于观察结果,我加入了“NOP三联”:

代码语言:javascript复制
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 );
}

编译器会就此开始它的表演,我们来看此时的代码生成:

代码语言:javascript复制
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”三连可以观察的非常清晰!
代码语言:javascript复制
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的怀抱……

原创不易,

如果你喜欢我的思维、

如果你觉得我的文章对你有所启发或是帮助,

还请“点赞、收藏、转发” 三连!

欢迎订阅 裸机思维

0 人点赞