【编译器玄学研究报告】第六期——无副作用的副作用

2022-07-30 13:52:32 浏览数 (1)

【写在前面的话】


作为嵌入式软件工程师,你是否听说过“无副作用(no side-effect)的代码”这个概念?

如果没有的话,今天的文章你就真的要好好看一看了。

【没有用的代码】


“无副作用的代码”其实是一个屁股坐在编译器这边的说法。

“无副作用的代码”其实是编译器觉得“没有作用的代码

“无副作用的代码”其实是编译器的一个委婉说法

那么什么样的代码在编译器看来是“无副作用”的呢?我来举几个典型的例子:

代码语言:javascript复制
void infinite_loop(void)
{
    while (1); // this line is considered to have no side-effects
}

也许会出乎很多人的意料,在编译器看来,这里的 while(1) 就是“无副作用的代码”。根据一封 LLVM 讨论邮件的说法:

infinite loops containing no side effects produce undefined behavior in C (and C in some cases), however in other languages, they have fully defined behavior. LLVM's optimizer currently assumes that infinite loops eventually terminate in a few places, and will sometimes delete them in practice. https://lists.llvm.org/pipermail/llvm-dev/2017-October/118558.html

像这种无限循环,就是“无副作用”的代码,其行为在C 和C语言(C11标准下)是“未定义的(undefined)”——换句话说,编译器为它生成怎样的代码都很正常,所以LLVM(其实还有GCC)会根据自己的心情,直接将无限循环删除了事。

是的,你没看错,根据编译器的心情,它可能会把无限循环直接删了!——你以为无限循环就是在这里死等,结果编译器大笔一挥,就当它不存在,撒开四蹄一骑绝尘,只留下一脸懵逼的你……

也许你还在想,LLVM毕竟是全平台编译器,嵌入式环境中超级循环这么常见,总不至于也这么傻吧?嗯……怎么说呢,虽然在 Arm Compiler 6 中的确不那么容易复现无限循环消失的问题,但在文档中也赫然写着:

armclang considers infinite loops with no side-effects to be undefined behavior, as stated in the C11 and C 11 standards. In certain situations armclang deletes or moves infinite loops, resulting in a program that eventually terminates, or does not behave as expected. https://developer.arm.com/documentation/100066/0611/coding-considerations/infinite-loops?lang=en

翻译一下就是:

C11 C 11 标准中所述的那样,armclang 将没有副作用的无限循环视为未定义的行为,(因此)在某些情况下,armclang删除或移动无限循环,从而导致程序最终终止或者无法按预期运行。

最可怕的是——我实际中,真的遇到过 while(1);armclang整体删除的情况……

如果这就已经让你颇为震惊了,那么我就不妨再补一刀:

代码语言:javascript复制
#include <stdbool.h>
#include "cmsis_compiler.h"

extern bool s_bComplete;

__attribute__((used))
void DMA_Handler(void)
{
    s_bComplete = true;
}

void start_dma_transfer(void)
{
    __SEV();   // 放一个 SEV指令便于观察
}


__NO_RETURN
void test(void)
{
    s_bComplete = false;
    start_dma_transfer();
    while(s_bComplete == false);
    __BKPT();
}

上面这个代码其实就是大家非常常用的外设操作代码:

  • 启动DMA传输之前复位完成标志为false
  • 启动DMA
  • 通过while循环,死等DMA完成中断触发并设置标志位为true

眼尖的小伙伴可能会立即指出这里的问题:s_bComplete 没有加 volatile——没错,的确是这样,但我们可以先抛开这个问题,谈谈上述代码有趣的地方。

看过我之前文章《编译器的“智商”你不懂》中介绍过窥孔优化的概念。按照窥孔优化的逻辑,我们可以尝试站在编译器的角度来分析上述代码:

  • 整个函数比较小
  • s_bComplete 在进入循环之前已经有明确的赋值操作,而无论是循环还是 start_dma_transfer() 都没有修改它的值
  • 基于窥孔优化的结论,while 循环事实上是一个无限循环——因为条件恒成立。

既然如此,似乎我们应该能看到汇编代码里生成一个死循环才对,实际上,如果我们将C标准设置为 C99,的确可以看到一个死循环的产生:

注意上图中黄色高亮的部分:

代码语言:javascript复制
0x00001904 E7FE      B        0x00001904

这里 B 是 无条件跳转指令Branch 的缩写,它跳转的地址正是自己——也就是一个彻头彻尾的无限循环。

但当我们将C标准设置为 C11 或者 GNU11,并将优化等级设置为 -O2(或者更高),无关LTO的勾选与否,

下面我们将见证奇迹:

通过在汇编窗口调试,我们可以看到,在调用了函数 start_dma_transfer()之后,完全没有任何无限循环的踪影,我们直接来到了用作观察的 BKPT指令。


为了方便观察,我们在 start_dma_transfer() 中放置了一个固有函数 __SEV(),并在 while() 循环之后放置了 __BKPT()它们在这里没有其它作用,仅仅是作为特征值方便我们在汇编调试窗口中观察而已

它们由头文件 cmsis_compiler.h 提供。


要理解这个问题,就需要补充一个知识:

在编译器看来,无论用户对一个变量做过什么操作,只要该变量:

  • 未经特殊修饰(比如 volatile)
  • 未在嵌入式汇编中被使用(或者引用)过
  • 没有与其它有副作用的代码产生过关联

那么,在编译器看来,所有针对该变量的操作都是“无副作用的代码”

好了,破案了:s_bComplete 标志就是平平无奇的静态变量,整个循环除了“读取s_bComplete的值”这一“无副作用的代码”,再无其它意义——换句话说,C11标准下,编译器对它做啥都是正常的——这当然包括删除循环。

有的小伙伴会说,那如果我们在while()循环里对 s_bComplete 进行写操作呢?答案是:仍然不会改变该循环“无副作用”的事实。其实不难理解,对比前面提到的三条,无论是对该变量进行读取还是写入操作,都不满足三条中的任意一款。为了让你死心,我们修改代码如下:

代码语言:javascript复制
bool s_bComplete;

void start_dma_transfer(void)
{
    __SEV();
}

__NO_RETURN
void test(void)
{
    s_bComplete = 20;
    start_dma_transfer();
    while(s_bComplete--);
    __BKPT();
}

这里,我们在循环中对计数器变量 s_wComplete进行递减操作,并要根据其运算结果判断循环的终止条件,怎么样?是不是连窥孔优化也不会觉得它是无限循环了吧?

这是汇编代码生成:

看不懂不要紧,请注意图中的箭头——这里,在 BNE(如果不相等则跳转)和STRB之间产生了一个循环体,并且原本应该在while()循环之外的 __BKPT()指令却进入了循环体之中!!!

吃惊么? 别吃惊,因为对“无副作用的代码”,编译器想做啥都行……因为C11对它的行为“未定义嘛”——还记得Arm Compiler 6的文档怎么说的么?

In certain situations armclang deletes or moves infinite loops, resulting in a program that eventually terminates, or does not behave as expected.

虽然说的是无限循环,其实它已经告诉你,自己挪一挪循环体的位置,属于基操,不必大惊小怪。

还有一点需要特别强调,我们前面说过:怎么对待“无副作用的代码”要看编译器心情——这句话绝对不是空穴来风,上述代码,你但凡把 bool 修改为 其它整形(包括但不限于 uint8_t,int8_t……),编译器的心情就好了:

我们可以看到,这段代码中,虽然没有循环结构,但聪明的编译器发现我们只是想通过 while() 循环的方式将 s_bComplete 的值设置为0,因此直接帮我们通过指令

代码语言:javascript复制
STRB  r1, [r0, #0x00]

替代了while(),还真是个乖宝宝呢……

【怎么避免“无副作用”】


既然知道了“无副作用代码”会让编译器时不时的“放飞自我”,而且还不算是bug(因为是C11没定义的行为,所以不算编译器bug),那么如何避免呢?

  • 不用 LLVM 或者 Arm Compiler 6,改用 GCC?

你太天真了……GCC一样有这个问题,只是心情好坏的触发条件不同而已。不要想着通过不用某个编译器来避开,还是从如何避免产生“无副作用的代码”入手吧

  • 方法一:在怀疑是“无副作用”的循环体内,插入任意的在线汇编。

最常见的做法是包含 cmsis_compiler.h 后,使用固有函数 __NOP()

代码语言:javascript复制
#include "cmsis_compiler.h"

void infinite_loop(void)
{
    while (1) {
        __NOP();   // this line is considered to have side-effects
    }
}

或者干脆插入汇编:

代码语言:javascript复制
void infinite_loop(void)
{
    while (1) {
        asm volatile("nop");   // this line is considered to have side-effects
    }
}
  • 方法二:将无副作用的代码与有副作用的代码产生关联。

这里,产生关联的方法很多,比如,

1)把代码的运算结果赋值给 volatile 的变量;

2)把运算结果传递给其它有副作用的函数作为输入参数

3)直接给关键的变量加入 volatile 作为修饰

4)插入在线汇编

……

  • 方法三:LLVM在 版本12后,引入了一个新的函数属性 mustprogress

具体使用方法如下:

代码语言:javascript复制
__attribute__((mustprogress))
void my_function(void)
{
    ...
}

由于另外一个函数属性 willreturn 隐含了 mustprogress,因此也可以使用它来解决问题:

代码语言:javascript复制
__attribute__((willreturn))
void my_function(void)
{
    ...
}

可惜,截止到6.18版本,Arm Compiler 6还未支持上述两个函数属性。

【写在后面的话】


正如我在此前很多文章中所提到的那样,程序员与编译器之间存在着巨大的信息鸿沟——很多我们甚至都意识不到需要特别强调的重要信息,在编译器看来是并不存在的——“无副作用(no side-effect)的代码” 正是这类信息不对称在代码逻辑层面的体现。

如果无法给编译器提供足够的信息,那么哪怕是 -O2 这样的普通优化等级,都会给我们带来不小的困扰。但如果学会从编译器的视角去审视代码所传递的信息(审视信息是否充足),并结合适当的编码习惯或规范,就能够轻松的写出默认就能使用最高优化的高品质代码。

0 人点赞