11

傻孩子(GorgonMeducer) · 2020年07月21日

【为宏正名】本应写入教科书的“世界设定”

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

【说在前面的话】

市面上大部分C程序员对宏存在巨大的误解甚至是恐惧,并因此极力避免宏的适度使用,甚至将宏在封装中发挥正确作用的行为视作是对C语言的“背叛”——震惊之余,对于为什么大家会有这种想法的原因,我曾经一度是非常“傲慢的”,这种傲慢与某些人宣称“穷人都是因为懒所以才穷”时所表现出的那种态度并无任何本质不同——然而我错了,在闲暇之余认真看了不少经典的C语言教材后我才意识到:

不是读者普遍懒或者轻视教材中有关宏的内容,而是那些对宏来说如同“加法交换律、结合律”一样的基本规则和知识并没有认真且完整的出现在教科书中!

640.jpg

这是何等的“呵呵”。这下全都清楚了:

  • 为什么大家会那么惧怕宏的使用
  • 定义宏的时候,为什么遇到哪怕很基本的小问题也根本无从下手
  • 为什么那么多人声称系统提供的诸如 \_\_LINE\_\_ 之类的宏时好时坏
  • 为什么很多关于宏的正常使用被称为奇技淫巧……

真是哭笑不得。这些规则是如此简单,介绍一下根本无需多么复杂的篇幅。接下来,让我们简单的学习一下这些本应该写入教科书中的基本内容。注意,这与你们在其它公众号里学到的关于某些宏的基本使用方法是两回事。

【宏不属于C语言】

640.png

说“宏不属于C语言”是一种夸张的说法,但却非常反映问题的本质和基本事实:

  • C语言的编译分为三个阶段:预编译阶段、编译阶段和链接阶段。正如上图所示的那样,预编译阶段的产物是单个的“.c”文件;编译阶段将这些“.c”文件一个一个彼此独立的编译为对应的对象("*.obj")文件;这些对象文件就像乐高积木一样会在最终的链接阶段按照事先约定好的图纸(地址空间布局描述文件,又称linker script或者scatter script)被linker组装到一起,最终生成在目标机器上可以运行的镜像文件。

  • 宏仅在预编译阶段有效,它的本质只是文字替换。在完成预编译处理以后,进入编译阶段的.c实际上已经不存在任何“宏”、条件编译、“#include”以及"#pragma"之类的预编译内容——此时的C源文件是一个纯粹且独立的文本文件。很多编译器在命令行下都提供一个"-E"的选项,它其实就是告诉编译器,只进行预编译操作并停在这里。此时,编译的结果就是大家所说的“宏展开”后的内容。学会使用"-E"选项,是检测自己缩写的宏是否正确的最有效工具。

知道这一知识有什么用呢?首先,你会明白,宏本身是与C语言的其它语法毫无关联的。宏有自己的语法,且非常简单。在进行宏展开的时候,编译器并不会去进行任何宏以外的C语言语法检查、甚至根本不知道C语言语法。实际上,有大量C语言老鸟特别喜欢在其它C语言以外的文本文件里使用“宏”(其实还有条件编译之类的),最典型的例子就是在Arm Compiler 6的scatter-script中用宏来定义一些地址常数:

#! armclang --target=arm-arm-none-eabi -march=armv6-m -E -x c

这里,第一行的命令行:

#! armclang --target=arm-arm-none-eabi -march=armv6-m -E -x c

就是告诉linker,在处理scatter-script之前要执行“#!” 后面的命令行,这里的"-E"就是告诉armclang:“我们只进行预编译”——也就是"#include"以及宏替换之类的工作——所以宏“ADDRESS” 会被替换会 0x20000000,而"include\_file\_1.h" 中的内容也会被加入到当前的scatter-script文件中来。

需要强调下,在这个例子中,放在第一行“#!”后面的命令行之所以为会被linker自动执行,是因为linker就是这么使用 “.sct” 文件的。对于其它想使用C语言宏对任意文本文件进行预处理的场合,需要自己动手编写命令行和脚本。比如,如果你想在 perl 里使用 C语言的预编译,那么就需要你在执行目标 .pl 文件前,先用C语言编译器对其进行一次预编译。

总的来说,“宏不属于C语言”并非空穴来风,事实上,只要你有兴趣去写脚本,包括宏在内的所有预编译语法可以在一切文本文件中使用

知道这一知识的另外一个作用就是回答每一个C语言初学者都绕不开的经典问题:“宏和枚举有啥区别”?有啥区别?这区别老大了:

  • 正如前面所说的,宏只存在于“预编译阶段”,而活不到“编译阶段”;宏是没有任何C语法意义的
  • 枚举与之相反,只存在于“编译阶段”,是具有严格的C语法意义的——它的每一个成员都明确代表一个整形常量值

其实,从宏和枚举服务的阶段看来,他们是老死不相往来的。那么具体在使用时,这里的区别表现在什么地方呢?我们来看一个例子:

#define USART_COUNT     4

这里例子意图很简单,根据宏USART\_COUNT的值来条件编译。如果我们把USART\_COUNT换成枚举就不行了:

typedef enum {

在这个例子里,USART\_COUNT的值会随着前面列举的UARTx\_idx的增加而自动增加——作为一个技巧——精确的表示当前实际有效的USART数量,从意义上说严格贴合了 USART\_COUNT 这个名称的意义。这个代码看似没有问题,但实际上根据前面的知识我们知道:条件编译是在“预编译阶段”进行的、枚举是在“编译阶段”才有意义。换句话说,当下面代码判断枚举USART\_COUNT的时候,预编译阶段根本不认识它是谁(预编译阶段没有任何C语言的语法知识)——这时候USART\_COUNT作为枚举还没出生呢!

#if USART_COUNT > 0

同样道理,如果你想借助下面的宏来生成代码,得到的结果会出人意料:

typedef enum {

应用中,我们期望配合UARTn\_idx与宏USART\_INIT一起使用:

...

借助宏的胶水运算“##”,我们期望的结果是:

...

由于同样的原因——在进行宏展开的时候,枚举还没有“出生”——实际展开的效果是这样的:

...

由于函数  usartUSART1\_idx\_init() 并不存在,所以在链接阶段linker会报告类似“undefined symbol usartUSART1\_idx\_init()”——简单说就是找不到函数。要解决这一问题也很简单,直接把枚举用宏来定义就可以了:

#define USART_COUNT     4

那么是不是说,宏就比枚举好呢?当然不是,准确的说法应该是:在谁的地盘谁的优点就突出。我们说枚举仅在编译阶段有效、它具有明确的语法意义(具体语法意义请参考相应的C语言教材)。相对宏来说,怎么理解枚举的好处呢?

  • 枚举可以被当作类型来使用,并定义枚举变量——宏做不到;
  • 当使用枚举作为函数的形参或者是switch检测的目标时,有些比较“智能”的C编译器会在编译阶段把枚举作为参考进行“强类型”检测——比如检查函数传递过程中你给的值是否是枚举中实际存在的;又比如在switch中是否所有的枚举条目都有对应的case(在省缺default的情况下)。
  • 除IAR以外,保存枚举所需的整型在一个编译环境中是相对来说较为确定的(不是short就是int)——在这种情况下,枚举的常量值就具有了类型信息,这是用宏表示常量时所不具备的。
  • 少数IDE只能对枚举进行语法提示而无法对宏进行语法提示。

【宏的本质和替换规则】

很多人都知道宏的本质是文字替换,也就是说,预编译过程中宏会被替换成对应的字符串;然而在这一过程中所遵守的关键规则,很多人就不清楚了。

首先,针对一个没有被定义过的宏:

  • 在#ifdef、#ifndef 以及 defined() 表达式中,它可以正确的返回boolean量——确切的表示它没有被定义过;
  • 在#if 中被直接使用(没有配合defined()),则很多编译器会报告warning,指出这是一个不存在的宏,同时默认它的值是boolean量的false——而并不保证是"0"
  • 在除以上情形外的其它地方使用,比如在代码中使用,则它会被作为代码的一部分原样保留到编译阶段——而不会进行任何操作; 通常这会在链接阶段触发“undefined symbol”错误——这是很自然的,因为你以为你在用宏(只不过因为你忘记定义了,或者没有正确include所需的头文件),编译器却以为你在说函数或者变量——当然找不到了。

举个例子,宏 _\_\_STDC\_VERSION\_\__ 可以被用来检查当前ANSI-C的标准:

#if __STD_VERSION__ >= 199901L

上述写法在支持C99的编译器中是不会有问题的,因为 _\_\_STDC\_VERSION\_\__ 一定会由编译器预先定义过;而同样的代码放到仅支持C89/90的环境中就有可能会出问题,因为 _\_\_STDC\_VERSION\_\__ 并不保证一定会被事先定义好(C89/90并没有规定要提供这个宏),因此 _\_\_STDC\_VERSION\_\__ 就有可能成为一个未定义的宏,从而触发编译器的warning。为了修正这一问题,我们需要对上述内容进行适当的修改:

#if defined(__STD_VERSION__) && __STD_VERSION__ >= 199901L

其次,定义宏的时候,如果只给了名字却没有提供内容:

  • 在#ifdef、#ifndef 以及 defined() 表达式中,它可以正确的返回boolean量——确切的表示它被定义了;
  • 在#if 中被直接使用(没有配合defined()),编译器会把它看作“空”;在一些数值表达式中,它会被默认当作“0”,没有任何警告信息会被产生
  • 在除以上情形外的其它地方使用,比如在代码中使用, 编译器会把它看作“空字符串”(注意,这里不包含引号)——它不会存活到编译阶段;

最后,我们来说一个容易被人忽视的结论:

  • 第一条:任何使用到胶水运算“##”对形参进行粘合的参数宏,一定需要额外的再套一层
  • 第二条:其余情况下,如果要用到胶水运算,一定要在内部借助参数宏来完成粘合过程

为了理解这一“结论”,我们不妨举一个例子:在前面的代码中,我们定义过一个用于自动关闭中断并在完成指定操作后自动恢复原来状态的宏:

#define SAFE_ATOM_CODE(...)               \

由于这里定义了一个变量wTemp,而如果用户插入的代码中也使用了同名的变量,就会产生很多问题:轻则编译错误(重复定义);重则出现局部变量wTemp强行取代了用户自定义的静态变量的情况,从而直接导致系统运行出现随机性的故障(比如随机性的中断被关闭后不再恢复,或是原本应该被关闭的全局中断处于打开状态等等)。为了避免这一问题,我们往往会想自动给这个变量一个不会重复的名字,比如借助 \_\_LINE\_\_ 宏给这一变量加入一个后缀:

#define SAFE_ATOM_CODE(...)                           \

一个使用例子:

...

假设这里 SAFE\_ATOM\_CODE 所在行的行号是 123,那么我们期待的代码展开是这个样子的(我重新缩进过了):

...

然而,实际展开后的内容是这样的:

...

这里,\_\_LINE\_\_似乎并没有被正确替换为123,而是以原样的形式与wTemp粘贴到了一起——这就是很多人经常抱怨的 \_\_LINE\_\_ 宏不稳定的问题。实际上,这是因为上述宏的构建没有遵守前面所列举的两条结论导致的。

从内容上看,SAFE\_ATOM\_CODE() 要粘合的对象并不是形参,根据结论第二条,需要借助另外一个参数宏来帮忙完成这一过程。为此,我们需要引入一个专门的宏:

#define CONNECT2(__A, __B)    __A##__B

注意到,这个参数宏要对形参进行胶水运算,根据结论第一条,需要在宏的外面再套一层,因此,修改代码得到:

#define __CONNECT2(__A, __B)    __A##__B

修改前面的定义得到:

#define SAFE_ATOM_CODE(...)                           \

有兴趣的朋友可以通过 "-E" 可以观察到 \_\_LINE\_\_ 被正确的展开了。

【宏是引用而非变量】

具体实践中,很多人在使用宏过程中会产生“宏是一种变量”的错觉,这是因为无论一个宏此前是否定义过,我们都可以借助#undef 操作,强制注销它,从而有能力重新给这一宏赋予一个新的值,例如:

#include <stdbool.h>

上述例子里,在stdbool.h中,true通常被定义为1,这会导致很多人在编写期望值是true的逻辑表达式时,一不小心落入圈套——因为true的真实含义是“非0”,这就包含了除了1以外的一切非0的整数,当用户写下:

if (true == xxxxx) {...}

表达式时,实际获得的是:

if (1 == xxxxx) {...}

这显然是过于狭隘的——会出现实际为true却判定为false(走else分支)的情况,为了避免这种情况,实践中,我们应该避免在逻辑表达式中使用true——无论true的值是什么。


实际上,宏的变量特性是不存在的,更确切地说法是,宏是一种“引用”。那么什么是引用呢?《六祖坛经》中有一个非常著名的公案,用于解释慧能关于“不立文字”的主张,他说,通过“文字”来了解真理,就好比用手指向月亮——正如手指可以指出明月的所在,文字也的确可以用来描述真理,但毕竟手指不是明月,文字也不是真理本身,因此如果有办法直击真理,又如何需要执着于文字(经文)本身呢?我们虽然不一定要修禅,但这里手指与明月的关系恰好可以非常生动的解释“引用”这一概念。

640-1.jpg

我们说宏的本质是一个引用,那么如何理解这种说法呢?我们来看一个例子:

#define EXAMPLE_A          123

对于下面的代码:

CONNECT2(uint32_t wVariable, EXAMPLE);

如果宏是一个变量,那么展开的结果应该是:

uint32_t wVariable123;

然而,我们实际获得的是:

uint32_t wVariableEXAMPLE_A;

如何理解这一结果呢?

如果宏是一个引用,那么当EXAMPLE\_A与123之间的关系被销毁时,原本EXAMPLE > EXAMPLE\_A > 123 的引用关系就只剩下 EXAMPLE > EXAMPLE\_A。又由于EXAMPLE\_A已经不复存在,因此EXAMPLE\_A在展开时就被当作是最终的字符串,与"uint32\_t wVariable"连接到了一起。

这一知识对我们有什么帮助呢?帮助实在太大了!甚至可以把预编译器直接变成一个脚本解释器。受到篇幅的限制,我们无法详细展开,就展示一个最常见的用法吧:

还记得前面定义的USART\_INIT()宏么?

#define USART_INIT(__USART_INDEX)    \

使用的时候,我们需要确保填写在括号中的任何内容都必须直接对应一个在效范围内的整数(比如0~3),比如:

USART_INIT(USART1_idx);

由于USART1\_idx直接对应于字符串 “1”,因此,实际会被展开为:

usart1_init();

很多时候,我们可能会希望代码有更多的灵活性,因此,我们会再额外定义一个宏来将某些代码与具体的USART祛除不必要的耦合:

#include "app_cfg.h"

这样,虽然代码默认使用USART0作为 DEBUG\_USART,但用户完全可以通过配置文件 "app\_cfg.h" 来修改这一配置。到目前为止,一切都好。但此时,app\_cfg.h 中的内容已经和模块内的代码有了一定的“隔阂”——用户不一定知道 DEBUG\_USART 必须是一个有效的数字字符串,而不能是一个表达式,哪怕这个表达式会“自动”计算出最终需要使用的值。比如,在 app\_cfg.h 中,可能会出现以下的内容:

/* app_cfg.h */

这里,出于某种不可抗拒原因,用户希望永远使用最后一个USART作为 DEBUG\_USART,并通过一个表达式计算出了这个USART的编号。遗憾的是,当用户自信满满的写下这一“智能算法”后,我们得到的实际上是:

usart(1+2)_init();

对编译器来说,这显然不是一个有效的C语法,因此报错是在所难免。那么如何解决这一问题呢?借助宏的引用特性,我们可以获得如下的内容:

#include "app_cfg.h"

进一步思考,假设一个宏的取值范围是 0~255,而我们想把这一宏的值切实的转化为对应的十进制数字字符串,按照上面的方法,那我们岂不是要累死?且慢,我们还有别的办法,假设输入数值的宏叫 MFUNC\_IN\_U8\_DEC\_VALUE 首先分别获得3位十进制的每一位上的数字内容:

#undef __MFUNC_OUT_DEC_DIGIT_TEMP0

接下来,我们将代表“个、十、百”的三个宏拼接起来:

#if __MFUNC_OUT_DEC_DIGIT_TEMP2 == 0 

此时,保存在 MFUNC\_OUT\_U8\_DEC\_VALUE 中的值就是我们所需的十进制数字了。为了方便使用,我们将上述内容放置到一个专门的头文件中,就叫做mf\_u8\_dec2str.h (https://github.com/vsfteam/vs...\_u8\_dec2str.h),修改前面的例子:

#include "app_cfg.h"

打完收工。

640.jpg

干货不易,如果你觉得这篇文章对你有所帮助或是有所启发,点赞、转发、收藏三联

专栏推荐文章


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