专栏首页裸机思维真刀真枪模块化(3.5)——骚操作?不!这才是正统

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

【你可曾怀疑过?】

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

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

等等不一而足。

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

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

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


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

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

  • 链接(Link)

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

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

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

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

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

假设有一些全局变量:

uint32_t g_wParamA;
uint32_t g_wParamB;
uint32_t g_wParamC;
...

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

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

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

uint32_t g_wParamA;

extern uint32_t g_wParamA;

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

  • 变量的名称:g_wParamA
  • 变量的类型:uint32_t
  • 变量的对齐方式:对齐到4字节

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

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

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


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

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

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

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

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

方案一:

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

比如:

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

比如:

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

例如:

uint32_t set_param_a(uint32_t wValue)
{
    s_wParamA = wValue;
}

方案二:你猜?!

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


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

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

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

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

即:

#include "xxxxxx.c"
#include "yyyyyy.c"
#include "zzzzzz.c"
...

这都行?!!!!

是的!通过把所有用到了对应全局变量的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 实现类似面向对象中 privateprotected甚至是internal关键字的效果,好是好,但有个问题:

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

比如 CMSIS-DSP 中很多目录就如 InterpolationFunctions 这样存在多个.c,

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

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

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

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

algorithm_a.c
algorithm_b.c
algorithm_c.c
...

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

#include "algorithm_a.c"
#include "algorithm_b.c"
#include "algorithm_c.c"
...

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

#ifdef __ALGORITHM_ENABLE_COMPILE__

...
C 源代码的所有内容
...

#endif

algorithm.c中添加宏定义 __ALGORITHM_ENABLE_COMPILE__

#define __ALGORITHM_ENABLE_COMPILE__
#include "algorithm_a.c"
#include "algorithm_b.c"
#include "algorithm_c.c"
...

问题就得到了圆满解决。

Cmake都表示非常赞??!

【说在后面的话】


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

本文分享自微信公众号 - 裸机思维(bare-metal),作者:GorgonMeducer 傻孩子

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2021-04-12

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 虐杀机械人抑或被僵尸虐,你选择哪个?

    VRPinea
  • 十张图,数据分析如何赋能销售

    “数据助力业务”大号口喊了很多年,可一提到数据分析,人们习惯性的依然讲的是:excel,python,sql,依然是数据清洗、数据计算、可视化。到底业务部门需要...

    接地气的陈老师
  • 百度微服务架构师随手笔记:教你如何手写Docker涉及到的技术Hello world要有Shell彻底分离

    美的让人心动
  • 【程序员故事】elber是个程序猿

    elber是个程序猿,我就是elber。 百度百科对程序猿的解释:是一种近几十年来出现的新物种,是信息革命的产物,在行为和物种归类上我们也可称为码字猴。程序猿是...

    程序员互动联盟
  • 腾讯云社区协议政策

    为了创建更好的社区氛围,腾讯云技术社区制定内容规范政策,维护社区的和谐氛围。本社区提供的网络服务中包含的标识、版面设计、排版方式、文本、图片、图形等均受著作权、...

    云加创业小助手
  • 战斗即将打响,你的VR体感枪上膛了吗?

    VRPinea
  • 如何用OKR搞垮一个团队?

    洋哥在两年前就尝试过OKR,也踩过不少坑、交了学费,没有人比我更懂如何用OKR搞垮一个团队,总结了11个招式,招招致命,请谨慎使用:

    用户6983566
  • AI提供假证!65岁老人含冤入狱近一年,两次患上新冠险些自杀

    从每天能给妻子打三个电话,到每周只有几小时的通话时间,险些就打算在狱中服药了结自己的生命。

    新智元
  • AI溃败,被Dota2职业战队打蠢!独家专访OpenAI:我们发现一个Bug

    今天早间进行的Ti8 OpenAI表演赛上,人类职业战队paiN Gaming,在5v5的Dota2人机大战首场战斗中,轻松击败OpenAI Five战队。

    量子位
  • 百度李彦宏谈Google回归:真刀真枪地再PK一次,再赢一次

    针对人民日报发布推特,欢迎Google回归一事,百度创始人李彦宏今日在朋友圈发表回应,称“如果现在Google回来,我们正好可以真刀真枪地再PK一次,再赢一次”...

    量子位
  • 如果用编程语言参加战争,哪门语言才是程序员的最强武器?

    自从计算机问世,各种编程言语也随之降生,作爲程序猿,Java、Python和C++是必学的三种编程言语,但有时难免疑惑:这三种言语终究孰优孰劣? 那麼无妨读一读...

    企鹅号小编
  • 现实版高达!美日巨型机器人格斗大战结果即将见分晓

    李根 发自 凹非寺 量子位 报道 | 公众号 QbitAI ? 一场两年前约的架,现在终于要有个(视频)结果了。 如果你痴迷于那种人坐进里面操控的巨型机器人—...

    量子位
  • 《仙剑》、《真三国无双》或推VR版游戏,是神作续写,还是毁灭经典?

    VRPinea
  • 《失控玩家》:“元宇宙”爆火出圈,打破虚拟和现实的边界!

    最近被刚上映的《失控玩家》刷屏了,这部影片讲述了“小贱贱”瑞安•雷诺兹饰演的银行职员“盖”在电子游戏中走上人生巅峰的故事。在电影中,主人公盖每天重复过着一模一样...

    用户8763535
  • 真刀真枪模块化(2)——图解Service模型

    道理说起来简单,真要实际操作起来,一线开发人员往往会直摇头:手中已有的所谓“模块”质量参差不齐、模块的开发者鱼龙混杂、很多模块别说出了问题要找开发方负责维护了,...

    GorgonMeducer 傻孩子
  • 用编程语言参加战争,谁会是最强武器?

    自从计算机问世,各种编程语言也随之诞生,作为程序猿,Java、Python和C++是必学的三种编程语言,但有时难免疑惑:这三种语言究竟孰优孰劣? 那么不妨读一读...

    企鹅号小编
  • 定了!美日巨型机器人大战就在今天

    经历多次爽约之后,日本水道桥重工Kuratas与美国MegaBots两大巨型机器人大战的日子终于敲定了,就在明天,这一决战将在Twitch 上进行放映。 事情...

    机器人网
  • Super快报第23期:中国移动的4G

    1、中移动董事长:微信和Skype比电信联通更可怕 巴塞罗那MWC移动世界大会,中国移动董事长奚国华游走于产业链各方,向外界频繁传递他对中国移动主导的4G标准的...

    罗超频道
  • 产品经理·杂谈

    //02.01-2018更新 - 增加:13.0

    天青色

扫码关注云+社区

领取腾讯云代金券