【写在前面的话】
作为嵌入式软件工程师,你是否听说过“无副作用(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 这样的普通优化等级,都会给我们带来不小的困扰。但如果学会从编译器的视角去审视代码所传递的信息(审视信息是否充足),并结合适当的编码习惯或规范,就能够轻松的写出默认就能使用最高优化的高品质代码。