傻孩子(GorgonMeducer) · 2020年08月03日

【为宏正名】什么?我忘了去上“数学必修课”!

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

640.jpg

【说在前面的话】

在前面的文章《【为宏正名】本应写入教科书的“世界设定”》中我们了解到:宏会在预编译阶段被“处理掉”——宏会被逐级展开、其最终代表的字符串会被替换到对应的文本文件中(只不过通常这个文本文件就是".c"文件)——它不仅活不到正式的编译(make)阶段,更无法对程序运行时刻的行为产生丝毫影响

简而言之,通过宏所确定的内容是在编译时刻就固化下来的。很多人都了解这一点,也很擅长使用宏的方式来固化一些常数,比如,教科书中最常见的一个例子是:

//! 非闰年的情况下,一年中有多少秒

例子虽然简单,但立马引出了一个有趣的问题:宏展开后,make时编译器看到的究竟是上述常量表达式的计算结果:

static uint32_t s_wTotalSecInAYear = 31536000ul;

还是原样的字符串替换呢?

static uint32_t s_wTotalSecInAYear = (60ul * 60ul * 24ul * 365ul);

感兴趣的读者可以通过“-E”来研究一下:

SET PATH=C:\Keil_v5\ARM\ARMCLANG\Bin;

这里,命令行使用 armclang(Arm Compiler 6)对 “main.c”进行预编译("-E"的结果),并将结果输出到一个名为“preprocessed\_main.c” 的文件中——而这一文件就是我们在后面文章中要经常观察的,比如,针对前面的例子,一个可能的输出结果是:

# 1 "main.c"

【数位拼接律】

如果你认为“预编译器完全没有数值计算能力、或是对常量计算漠不关心”,那你就大错特错了——体现在宏身上,预编译器有一种根据需要自动在字符串和数值之间进行转换的能力。举个例子:

定义三个独立的宏,分别代表三个独立的“数字”:

#define NUM_A       2

借助上一篇文章中引入的胶水宏 _CONNECT3()_:

#define __CONNECT3(__A, __B, __C)       __A##__B##__C

我们可以把这三个宏粘贴在一起:

#define NUM_COMBINE     CONNECT3(NUM_A,NUM_B,NUM_C)

我们当然知道,最终宏替换的结果肯定是字符串“255”,但这个拼接出来的字符串“255”,和十进制数字255是等效的么?换句话说,预编译器懂得这个字符串“255”的含义么?为了验证这一问题,我们不妨使用下面的代码,去直接问问预编译器本人的看法:

#if NUM_COMBINE > 254

在 “main.c” 中加入上述全部宏定义以后,进行预编译,我们会得到如下的结果:

640-1.jpg

640.png

惊呆了!拼接出来的字符串不仅被正确的当作十进制数字256使用,还可以与十六进制数字进行正确的比较!!

640-1.png

这是不是意味着:无论是十进制、十六进制,我们只要想办法得到对应的“数位”,就可以通过拼接的方法还原出所需进制的“常熟字符串”,而且与编译器还懂得这一字符串的数学意义!——没错,这就是上一篇文章的最后,我们能够1)把任意通过宏编写的常量表达式计算出结果,并2)将数值转换成十进制字符串的原理——恍然大悟的同学可以“单击这里去重温一下”,这里就不再赘述了。

640-2.png

【序号自增】

定义和使用宏的时候,我们也许会突发奇想,能不能让宏里使用的数字实现“序号自增”的效果呢?要回答这个问题,我们不妨根据目前学过的知识简单推理一下:

  • 预编译器能够理解“数字字符串”的数值意义;
  • 宏的本质是一个对目标字符串的引用;
  • 目标字符串是个常量,修改常量是不可能的;

推论:

  • 假设一个宏表示一个序号
  • 我们可以根据当前宏的值,计算出下一个序号的值,并借助“数位拼接律”生成一个新的字符串
  • 修改宏的引用关系,让它指向新生成的字符串

根据上篇文章中引入的脚本头文件"mf\_u8\_dec2str.h",我们可以实现上述效果:

//! 一个用于表示序号的宏,初值是0

每次使用下面的预编译代码,我们就可以实现将 MY\_INDEX的值加一的效果:

//! MFUNC_IN_U8_DEC_VALUE = MY_INDEX + 1; 给脚本提供输入

可以看到,虽然原理上可行,如果真用这种方法写代码,别说可读性差到爹妈都不认识,就算大家都能看懂,使用起来实在特别麻烦!否决!

那有没有一种简单的方法呢?答案是肯定的——GCC扩展的预编译语法提供了一个专门的宏,叫做 _\_\_COUNTER\_\__——真可谓踏破铁鞋无觅处,蓦然回首,他就在灯火阑珊处——对每个编译的目标文件来说,\_\_COUNTER\_\_的初值是0,每使用一次,自动加一\_\_COUNTER\_\_是一柄神器,为了显示它的威力,我们不妨看一个例子:

假设我们要构建一个单向链表,它的元素结构如下:

typedef struct node_item_t node_item_t

实际使用的时候,无论运行时刻链表的内容和结构是否会发生变化,但在编译时刻,我们会给他一些指定数量的初始的节点(比如16个),用数组来存储:

static node_item_t s_tItemPool[16];

一般来说,我们需要编写一个初始化函数——在运行时刻将 s\_tItemPool 中的元素一个一个手工加入到链表中(添加到 s\_ptListRoot 指向的链表中)——这里的代价是双份的:

  • 初始化函数所占用的代码空间

  • 添加节点的运行时间。 

借助\_\_COUNTER\_\_我们可以直接在编译时刻以数组初始值的形式完成链表的初始化

#define ADD_ITEM_TO(__LIST_ADDR, ...)                 \

借助这个宏,我们可以实现对链表的静态初始化:

static node_item_t s_tItemPool[] = {

注意到节点内还有一个节点的序号“_chID_”,我们其实也可以一并将其自动初始化了——当然要记住,每次使用\_\_COUNTER\_\_它的值都会增加1——修改宏如下:

#define ADD_ITEM_TO(__LIST_ADDR, ...)                   \

修改后,实际展开效果如下:

static node_item_t s_tItemPool[] = {

上述效果虽然看似令人满意,但存在一个巨大的隐患,而这一隐患同样来自于\_\_COUNTER\_\_宏的基本特性:每次使用\_\_COUNTER\_\_它的值都会增加1——换句话说,在你使用 ADD\_ITEM\_TO() 的时候,如何才能确保 \_\_COUNTER\_\_是从0开始编号的呢?——别的宏可能已经使用过它了。

要解决这一问题,我们就不得不借助宏的“好基友”——枚举的帮助了。基本思路是这样的:

  • 无论 \_\_COUNTER\_\_ 是什么值,我们都可以将其传递给一个枚举——作为初始值;
  • 使用 \_\_COUNTER\_\_ 时,我们首先通过枚举将初始值扣除,从而获得“从0开始的计数”

说干就干:

#define __LIST_ROOT(__NAME)    s_ptList##__NAME##Root

为了方便隐藏定义枚举的“小动作”,我们追加了一对宏 IMP\_LIST() 和 _END\_IMP\_LIST()_,就是"implement list"的缩写,它实现了以下功能:

  • 以指定的名字定义了一个枚举;
  • 以指定的名字定义了链表的节点池;
  • 以指定的名字定义了指向链表的根指针,用户可以通过宏_LIST\_ROOT()_来获取这一指针;

修改应用代码,实现一个叫做 MyList 的链表:

//! 实现一个list,名字叫 MyList

是不是看起来很“优雅”?实际展开效果如下:

enum { list_MyList_start = 0 + 1, }; 

【参数宏也支持重载?】

  什么是参数宏的重载?——要回答这个问题,哪怕你连“重载(_overload_)”是什么都不知道也不要紧,我们来看一个最实际的例子:在前面的文章中,我们不止一次使用过一个胶水宏 _CONNECT3_,它的作用是将三个字符串粘连在一起变成一个完整的字符串。如果我们要粘连的字符串数量不同,比如,2个、4个、5个……n个,我们就要编写对应的版本:

#define __CONNECT2(__0, __1)            __0##__1

这里定义了最大连接_9_个的_CONNECT_版本,看似麻烦,实际上复制粘贴、一劳永逸——还是挺划算的——当然,如果你比较“耿直”,还可以做得更多,比如_16_个。所谓宏的重载是说:我们不必亲自去数要粘贴的字符串的数量而“手工选取正确的版本”,而直接让编译器自己替我们挑选。

比如,我们举一个组装16进制数字的例子:

#define HEX_U8_VALUE(__B1, __B0)                         \

在支持重载的情况下,我们希望这样使用:

#define HEX_U8_VALUE(__B1, __B0)                         \

如你所见,无论实际给出的参数是多少个,我们都可以使用同一个参数宏_CONNECT()_,而_CONNCT()_ 会自动计算用户给出参数的个数,从而正确的替换为_CONNETn()_版本。假设这一切都是可能做到的,那么实际上我们还可以对上述宏定义进行简化:

#define HEX_VALUE(...)          CONNECT(0x, __VA_ARGS__)

是的,一个 HEX\_VALUE() 就足够了,你随便添几个参数都行(只要小于等于你实现的_CONNECTn_的数量)。

既然前景如此诱人,怎么实现宏的重载呢?为了简化这个问题,我们假设有一个“魔法宏”:它可以告诉我们用户实际传递了多少个参数,我们不妨叫它 _VA\_NUM\_ARGS()_:

#define VA_NUM_ARGS(...)         /* 这里暂时先不管怎么实现 */

借助它,我们可以这样来编写宏 CONNECT():

#define CONNECT(...)                                \

当用户使用_CONNECT()_时,_VA\_NUM\_ARGS(\_\_VA\_ARGS\_\_)_会给出参数的数量;"_part1_" 中 CONNECT2() 的作用就是将 字符串“_CONNCET_”与这个数组组合起来变成一个新的“参数宏的名字”;而 "_part2_" 的作用则是给这个组装出来的参数宏传递参数。如果你觉得头晕了,我们不妨来举一个例子:

假设用户想用 HEX\_VALUE() 组装一个数字

uint16_t hwValue = HEX_VALUE(D, E, A, D);   //! 0xDEAD

它会被首先展开为:

uint16_t hwValue = CONNECT(0x, D, E, A, D); 

进而

uint16_t hwValue = 

由于VA\_NUM\_ARGS() 告诉我们有5个参数,最终实际展开为:

uint16_t hwValue = 

完美!那么我们就来逆推这个问题:如何实现我们的魔法宏“VA\_NUM\_ARGS()” 呢?答案如下:

#define VA_NUM_ARGS_IMPL(_1,_2,_3,_4,_5,_6,_7,_8,_9,__N,...) __N

这里,首先构造了一个特殊的参数宏,_VA\_NUM\_ARGS\_IMPL()_:

  • 在涉及"..."之前,它要用用户至少传递10个参数;
  • 这个宏的返回值就是第十个参数的内容;
  • 多出来的部分会被"..."吸收掉,不会产生任何后果

VA\_NUM\_ARGS() 的巧妙在于,它把\_\_VA\_ARGS\_\_放在了参数列表的最前面,并随后传递了 "9,8,7,6,5,4,3,2,1" 这样的序号:

当__VA_ARGS__里有1个参数时,“1”对应第十个参数__N,所以返回值是1

如果觉得上述过程似懂非懂,我们不妨对前面的例子做一个展开:

VA_NUM_ARGS(0x, D, E, A, D)

展开为:

VA_NUM_ARGS_IMPL(0x, D, E, A, D,9,8,7,6,5,4,3,2,1)

从左往右数,第十个参数,正好是“5”。

宏的重载非常有用,可以极大的简化用户"选择困难",你甚至可以将_VA\_NUM\_ARGS()_ 与 函数名结合在一起,从而实现简单的函数重载(即,函数参数不同的时候,可以通过这种方法在编译阶段有预编译器根据用户输入参数的数量自动选择对应的函数),比如:

extern device_write1(const char *pchString);

使用时:

device_write("hello world");       //!< 发送字符串

原创不易,知识有价

如果你发现本文的知识对你有帮助、有启发,还请点赞、转发、收藏三连

专栏推荐文章


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