前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【编译器玄学研究报告】第六期——无副作用的副作用

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

作者头像
GorgonMeducer 傻孩子
发布2022-07-30 13:52:32
8870
发布2022-07-30 13:52:32
举报
文章被收录于专栏:裸机思维

【写在前面的话】


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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-06-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 裸机思维 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档