傻孩子(GorgonMeducer) · 2021年04月12日

真刀真枪模块化(3.5)——骚操作?不!这才是正统

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

image.png

【你可曾怀疑过?】


C语言写多了,或多或少会听说一些“上古传下来”的教条,比如:

  • include 语句只能用来包含头文件
  • 头文件一定要用宏保护起来——以防止重复包含
  • include 语句包含C源代码是不对的
  • ……

等等不一而足。

然而,对于这些规则,你可曾怀疑过它们的正确性?它们真的是正确的么?它们真的合理么?它们真的是绝对的么?

作为一个培养嵌入式思维的公众号,我们先不着急做出结论。要回答以上的问题,不妨先换个视角。

【编译器“渴求”的理想状况】


在前面的文章《【为宏正名】本应写入教科书的“世界设定”》中,我们有提到过编译器的整个编译过程分为三个阶段:

  • 预编译阶段(preprocess)
  • 编译(compile)

  • 链接(Link)

其中,我们提到过对“预编译”和“编译”阶段来说,每个C源文件都是独立参与编译的,我们一般称为“编译单元(Compilation Unit)”——简单来说,就是在这两个编译阶段,每个C源文件不光“彼此不知对方的存在”,而且也是“老死不相往来”的。记住这一规则,这是理解后续内容的关键。

站在编译器的角度来说,除了“正确的翻译用户的代码逻辑”之外,它也还要面临用户关于其是不是“SB”的各种指指点点。由于信息的不对称性:往往用户掌握的关键信息,编译器是完全无从知道的,因此,难免会产生误会,做出让引发用户“亲切问候”的决策,比如文章《编译器的“智商”你不懂》就举出了这样一个例子。

总的来说,无论编译器有多少黑魔法用于代码优化,但要在“决定”这些黑魔法是否可以使用时,必须要做两件事:

  • 尽可能获取所有C源代码中所涉及的所有信息
  • 对找到的每一个信息,尽可能的确定其作用范围(或者说边界)

对我们嵌入式程序员来说,需要记住:如果你想让编译器生成最优代码,那么请务必要尽可能的多向编译器提供信息,并且一定要让编译器知道这个信息的作用范围。这么说也许有点抽象,让我们来举个最简单的例子:

假设有一些全局变量:

uint32_t g_wParamA;

如果我们希望编译器所生成的代码在访问这些全局变量的时候效率最高,为了“尽可能多向编译器提供信息”,我们可以从以下几点考虑:

  • 由于编译基本单位(Compilation Unit)是C源文件,因此如果可能,应该将这些全局变量定义在同一个C源文件里。
    • *

思考一个反例:对某个全局变量来说,下述两个代码在提供的信息上有什么区别呢?

uint32_t g_wParamA;

extern uint32_t g_wParamA;

关于g\_wParamA,这两个代码都提供以下的信息:

  • 变量的名称:g\_wParamA
  • 变量的类型:uint32\_t
  • 变量的对齐方式:对齐到4字节

但前者说:这个变量的实体就在当前的C源程序里——它具体什么地址、跟其它静态变量之间有什么相关关系,编译器你想怎么安排它就怎么安排它。

后者说:这个变量是定义在别的C源代码里的,我只知道这些,它具体什么地址,跟其它全局变量之间前后有啥关系我不知道。

你看这信息量的多寡,高下立判吧?


  • 有时候某些全局变量实在没法定义在当前C源文件中——这很正常——那么就尽可能的提供变量之间的相对关系,比如:
struct {

通过结构的方式提供了全局变量间的相对关系,可以让某些架构(比如Cortex架构)的处理器生成最优的访问代码。详细分析和代码剖析请参考文章《散装 vs 批发谁效率高?变量访问被ARM架构安排的明明白白》。

接下来,针对这些全局变量,我们又如何能“让编译器知道信息的作用范围”呢?聪明的你一定已经猜到了:这里的“变量作用范围信息”其实就是想办法告诉编译器“这些全局变量究竟被谁使用了”

具体怎么做呢?非常简单——通过加“static”的方式告诉编译器:这些“全局变量”就只在当前C源代码中使用了,你已经拥有关于它的全部信息了——它是你案板上的肉,你想怎么处置就怎么处置

有的小伙伴会立即反驳:这怎么行?某些变量确实在别的C源代码里使用了啊?!解决方案有两个:

方案一:

  • 同样将目标变量添加_static_限制其作用范围在当前C源代码内;

比如:

static uint32_t s_wParamA;
  • 如果外部模块需要读取该变量,则添加一个 get() 方法负责读取该变量;

比如:

uint32_t get_param_a(void)
  • 如果外部模块需要更新该变量,则添加一个_set()_方法负责写入操作;

例如:

uint32_t set_param_a(uint32_t wValue)

方案二:你猜?!

【消灭“全局变量”暴政,世界属于static!】


实际应用中,一个项目中可能全局变量的数量是成百上千的——不要说这不合理,很多时候,祖传屎山就在那里,你动一个试试看?如果你不幸被迫要做代码优化,也许用批量替换的方法给每个这样的全局变量都添加一个static是可以接受的但给每个这样的变量都加一套set()和get()方法,并修改每一个访问了对应变量的地方——以get()或set()来替换——这个改动就太大了,甚至屎山的行为都会因此而改变,这里的风险恐怕没有哪个工程师敢于承受

前方高能——祖传屎山又出现了!!!!

image.png

此时怎么办呢?有没有啥灵丹妙药?没事,还有救:

  • 先给每个这样的全局变量加上_static_;
  • 把所有用到了对应全局变量的C源代码都 #include 到同一个C源代码中。

即:

#include "xxxxxx.c"

这都行?!!!!

image.png

是的!通过把所有用到了对应全局变量的C源代码都 #include 到同一个C文件中,我们成功的向编译器传达了一个信息:所有用到这个变量的人我都给你找齐了,边界就是当前的C源代码,你又可以随心所欲了

【全世界“源代码”联合起来】


说完全局变量,我们再来谈谈函数。认真说起来,在编译器眼中,只有未加static的函数才是编译器觉得真正需要“糊弄”一下用户的——让用户以为函数是真实存在的——没错,函数在编译器的眼中是不存在的,而编译器“糊弄”用户的方式就是提供一个叫做“(entry point)函数入口”的公共符号(public symbol)。

打个比方,在C语言编译器眼中,(如果没有特别的加入section)一个C源代码编译后的结果就像一整条完整的牛肉里脊(这个里脊的名字叫 ".text")而所谓的函数入口其实就是一根根插在里脊上的牙签。是不是很有画面感?

虽然实际情况要复杂的多,但这里可以做一个适当的简化,打个比方:其实在编译器眼中,它手上有一堆类似乐高的积木,习惯上被称为“成语”(idiom)而编译器就像是玩乐高的小孩,一边理解C语言源代码的本意,一边尝试看看手边的乐高积木能不能按照要求搭建出所需的逻辑。一般来说,整个C源代码只有一个边界,也就是被称为 .text 的section——换句话说,编译器拥有整个C源代码的支配权,它可以做以下的事情,以实现代码的优化:

  • 理解了C源代码的意图后,首先按照每个函数的要求,用乐高积木排列出所需的功能,然后扫描这些序列,把逻辑上重复出现的部分提取出来,作为公共序列——只留一份——以而节省代码尺寸;
  • 理解了C源代码的意图后,把某些乐高积木按照特定的顺序排列起来,所谓不同的函数调用,其实就是从这个序列的不同位置进入或退出,从而实现代码尺寸和性能的优化。
  • 对着一个手上已有的优化列表,扫描已有的乐高序列,如果发现一些已知可以等效替换的特殊序列,就将其替换实现所谓的优化(Idiom Recogonition)

    ……

类似的优化方法还有很多,这里就不在赘述。但这里有一个非常重要的要点,即:

  • 在边界内,编译器拥有足够的自由,通过高度耦合且复杂的“乐高积木”序列来实现代码优化的;
  • 边界会阻断编译器的“某些”优化——就像一把刀切开了里脊肉一样——如果觉得比较抽象,你可以简单地想想一下:和父母分开住以后,共享厨房是不是就不太方便了?
    • *

某些细心的小伙伴可能会发现,当开启编译器“_-ffunction-sections_”选项——为每个函数都分配一个独立的_section_时,虽然可能代码尺寸会小一些(因为某些未被用到的函数会在link阶段连同它自己的_section_一起被删除),但代码性能会低一些——只不过有时候肉眼可见,有时候又微乎其微。其实仔细想想就知道:既然_section_是编译器优化的边界,而为每个函数都分配一个_section_实际上就是在牛肉里脊上细细的切了很多刀,这就阻断了“某些”(注意不是全部)优化的可能性。


然而,在编译器眼中,除了section以外,C源代码编译后的对象文件(“*.o”)也是一个天然边界。我们前面说过,C源代码是彼此“老死不相往来的”,而上面讨论的内容实际上再告诉我们一个很朴素的道理:

  • 边界阻碍某些优化
  • 如果边界内的信息不足,某些优化就无法实施
  • 边界内的信息越多,优化的可能性越大
  • 要想编译器有能力做出更多的优化,就要努力提升编译单元(Compilation Unit)内的信息量

具体怎么做呢?

编译器狂吼:请把所有的C源代码都通过 #include 的方式包含到同一个C源文件里来!

GCC和LLVM狂吼:请不要听楼上傻X的,直接开启 link-time-optimisation就行了!

IAR狂吼:请不要相信GCC和LLVM这俩傻X的,只有把所有的源代码都包含到同一个C源文件里才是王道——不过你不用自己动手,记得请把"Multi-file-compilation"的选项打开——我替你做了!

某些牛逼的开源库(比如CMSIS-DSP和ffmpeg)狂吼:你们楼上都是傻X,我信你们个鬼!我自己动手,这样就不依赖编译器的行为和特性了

Service模型狂吼:楼上都是傻X,请只在模块内部(service模型定义的模块内部)把所有为了追求代码清晰而分开的多个的C源文件通过#include 包含在一个C文件里进行编译

C#哎,好巧,楼上用C语言的兄弟,你说的是 _partial_ 还是_Internal_?

C++哎,好巧,楼上用C语言的兄弟,你说的是 friend_ 还是 _protected ?

【无脑添加才是最棒的!】


通过 #include C源文件的方式,我们可以获得更好的代码优化,可以在模块内部通过 static 实现类似面向对象中  _private_、_protected_甚至是_internal_关键字的效果,好是好,但有个问题:

  • 如果一个库拥看起来拥有多个C源文件,用户在部署的时候“自然而然”的将所有的源文件都加入到工程中——导致编译的时候,很多 .c 中的内容都产生了两倍的实体,最终在链接阶段产生冲突怎么办?

比如 _CMSIS-DSP _中很多目录就如 _InterpolationFunctions_ 这样存在多个.c,

image.png

而他们实际上都被 InterpolationFunctions__.c 文件统一包含:

image.png

如果一股脑的把该目录下的所有.c都加入到 MDK 工程中编译,就会在链接阶段报告大量的重复定义类错误。

怎么解决呢?其实很简单——用宏做个开关就行了。

比方说我们有一系列.c文件:

algorithm_a.c

然后有一个总领的C源文件 _algorithm.c_,其内容如下:

#include "algorithm_a.c"

为了支持所谓“无脑添加”到工程中,我们可以在每个 algorithm\_x.c 里添加一个宏开关用于保护:

#ifdef __ALGORITHM_ENABLE_COMPILE__

在_algorithm.c_中添加宏定义 _\_\_ALGORITHM\_ENABLE\_COMPILE\_\__:

#define __ALGORITHM_ENABLE_COMPILE__

问题就得到了圆满解决。

Cmake都表示非常赞👍🏻!

image.png

【说在后面的话】


最近经常看到一些文章惊叹于“哎?_#include_还能这样用啊?”,或是“哎?#include 还能插入在函数或变量定义的内部啊?”,我想说:“哎?!你们居然不知道 只要独占一行,_#include_ 就可以包含一切文本文件啊?

专栏推荐文章

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