【为宏正名】本应写入教科书的“世界设定”
扫描二维码
随时随地手机看文章
为什么大家会那么惧怕宏的使用;
定义宏的时候,为什么遇到哪怕很基本的小问题也根本无从下手;
为什么那么多人声称系统提供的诸如 __LINE__ 之类的宏时好时坏;
为什么很多关于宏的正常使用被称为奇技淫巧……
真是哭笑不得。这些规则是如此简单,介绍一下根本无需多么复杂的篇幅。接下来,让我们简单的学习一下这些本应该写入教科书中的基本内容。注意,这与你们在其它公众号里学到的关于某些宏的基本使用方法是两回事。
【宏不属于C语言】
C语言的编译分为三个阶段:预编译阶段、编译阶段和链接阶段。正如上图所示的那样,预编译阶段的产物是单个的“.c”文件;编译阶段将这些“.c”文件一个一个彼此独立的编译为对应的对象("*.obj")文件;这些对象文件就像乐高积木一样会在最终的链接阶段按照事先约定好的图纸(地址空间布局描述文件,又称linker script或者scatter script)被linker组装到一起,最终生成在目标机器上可以运行的镜像文件。
宏仅在预编译阶段有效,它的本质只是文字替换。在完成预编译处理以后,进入编译阶段的.c实际上已经不存在任何“宏”、条件编译、“#include”以及"#pragma"之类的预编译内容——此时的C源文件是一个纯粹且独立的文本文件。很多编译器在命令行下都提供一个"-E"的选项,它其实就是告诉编译器,只进行预编译操作并停在这里。此时,编译的结果就是大家所说的“宏展开”后的内容。学会使用"-E"选项,是检测自己缩写的宏是否正确的最有效工具。
! armclang --target=arm-arm-none-eabi -march=armv6-m -E -x c
define ADDRESS 0x20000000
"include_file_1.h" include
LR1 ADDRESS
{
…
}
这里,第一行的命令行:
#! 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文件中来。
正如前面所说的,宏只存在于“预编译阶段”,而活不到“编译阶段”;宏是没有任何C语法意义的;
枚举与之相反,只存在于“编译阶段”,是具有严格的C语法意义的——它的每一个成员都明确代表一个整形常量值。
其实,从宏和枚举服务的阶段看来,他们是老死不相往来的。那么具体在使用时,这里的区别表现在什么地方呢?我们来看一个例子:
extern uint8_t s_chUSARTBuffer[USART_COUNT];
这里例子意图很简单,根据宏USART_COUNT的值来条件编译。如果我们把USART_COUNT换成枚举就不行了:
typedef enum {
/* list all the available USART here */
USART0_idx = 0,
USART1_idx,
USART2_idx,
USART3_idx,
/* number of USARTs*/
USART_COUNT,
}usart_idx_t;
extern uint8_t s_chUSARTBuffer[USART_COUNT];
在这个例子里,USART_COUNT的值会随着前面列举的UARTx_idx的增加而自动增加——作为一个技巧——精确的表示当前实际有效的USART数量,从意义上说严格贴合了 USART_COUNT 这个名称的意义。这个代码看似没有问题,但实际上根据前面的知识我们知道:条件编译是在“预编译阶段”进行的、枚举是在“编译阶段”才有意义。换句话说,当下面代码判断枚举USART_COUNT的时候,预编译阶段根本不认识它是谁(预编译阶段没有任何C语言的语法知识)——这时候USART_COUNT作为枚举还没出生呢!
extern uint8_t s_chUSARTBuffer[USART_COUNT];
同样道理,如果你想借助下面的宏来生成代码,得到的结果会出人意料:
typedef enum {
/* list all the available USART here */
USART0_idx = 0,
USART1_idx,
USART2_idx,
USART3_idx,
/* number of USARTs*/
USART_COUNT,
}usart_idx_t;
extern int usart0_init(void);
extern int usart1_init(void);
extern int usart2_init(void);
extern int usart3_init(void);
usart
应用中,我们期望配合UARTn_idx与宏USART_INIT一起使用:
...
USART_INIT(USART1_idx);
...
借助宏的胶水运算“##”,我们期望的结果是:
...
usart1_init();
...
由于同样的原因——在进行宏展开的时候,枚举还没有“出生”——实际展开的效果是这样的:
...
usartUSART1_idx_init();
...
由于函数 usartUSART1_idx_init() 并不存在,所以在链接阶段linker会报告类似“undefined symbol usartUSART1_idx_init()”——简单说就是找不到函数。要解决这一问题也很简单,直接把枚举用宏来定义就可以了:
extern int usart0_init(void);
extern int usart1_init(void);
extern int usart2_init(void);
extern int usart3_init(void);
枚举可以被当作类型来使用,并定义枚举变量——宏做不到;
当使用枚举作为函数的形参或者是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
/* support C99 */
define SAFE_ATOM_CODE(...) \
{ \
uint32_t wTemp = __disable_irq(); \
__VA_ARGS__; \
__set_PRIMASK(wTemp); \
}
else
/* doesn't support C99, assume C89/90 */
define SAFE_ATOM_CODE(__CODE) \
{ \
uint32_t wTemp = __disable_irq(); \
__CODE; \
__set_PRIMASK(wTemp); \
}
endif
上述写法在支持C99的编译器中是不会有问题的,因为 __STDC_VERSION__ 一定会由编译器预先定义过;而同样的代码放到仅支持C89/90的环境中就有可能会出问题,因为 __STDC_VERSION__ 并不保证一定会被事先定义好(C89/90并没有规定要提供这个宏),因此 __STDC_VERSION__ 就有可能成为一个未定义的宏,从而触发编译器的warning。为了修正这一问题,我们需要对上述内容进行适当的修改:
if defined(__STD_VERSION__) && __STD_VERSION__ >= 199901L
/* support C99 */
...
else
/* doesn't support C99, assume C89/90 */
...
endif
在#ifdef、#ifndef 以及 defined() 表达式中,它可以正确的返回boolean量——确切的表示它被定义了;
在#if 中被直接使用(没有配合defined()),编译器会把它看作“空”;在一些数值表达式中,它会被默认当作“0”,没有任何警告信息会被产生
在除以上情形外的其它地方使用,比如在代码中使用,编译器会把它看作“空字符串”(注意,这里不包含引号)——它不会存活到编译阶段;
第一条:任何使用到胶水运算“##”对形参进行粘合的参数宏,一定需要额外的再套一层
第二条:其余情况下,如果要用到胶水运算,一定要在内部借助参数宏来完成粘合过程
#define SAFE_ATOM_CODE(...) \
{ \
uint32_t wTemp = __disable_irq(); \
__VA_ARGS__; \
__set_PRIMASK(wTemp); \
}
由于这里定义了一个变量wTemp,而如果用户插入的代码中也使用了同名的变量,就会产生很多问题:轻则编译错误(重复定义);重则出现局部变量wTemp强行取代了用户自定义的静态变量的情况,从而直接导致系统运行出现随机性的故障(比如随机性的中断被关闭后不再恢复,或是原本应该被关闭的全局中断处于打开状态等等)。为了避免这一问题,我们往往会想自动给这个变量一个不会重复的名字,比如借助 __LINE__ 宏给这一变量加入一个后缀:
#define SAFE_ATOM_CODE(...) \
{ \
uint32_t wTemp##__LINE__ = __disable_irq(); \
__VA_ARGS__; \
__set_PRIMASK(wTemp); \
}
...
SAFE_ATOM_CODE(
/* do something here */
...
)
...
...
{
uint32_t wTemp123 = __disable_irq();
__VA_ARGS__;
__set_PRIMASK(wTemp);
}
...
...
{
uint32_t wTemp__LINE__ = __disable_irq();
__VA_ARGS__;
__set_PRIMASK(wTemp);
}
...
从内容上看,SAFE_ATOM_CODE() 要粘合的对象并不是形参,根据结论第二条,需要借助另外一个参数宏来帮忙完成这一过程。为此,我们需要引入一个专门的宏:
##__B define __CONNECT2(__A, __B) __A
define CONNECT2(__A, __B) __CONNECT2(__A, __B)
#define __CONNECT3(__A, __B, __C) __A##__B##__C
define CONNECT2(__A, __B, __C) __CONNECT3(__A, __B, __C)
#define SAFE_ATOM_CODE(...) \
{ \
uint32_t CONNECT2(wTemp,__LINE__) = \
__disable_irq(); \
__VA_ARGS__; \
__set_PRIMASK(wTemp); \
}
if (true == xxxxx) {...}
if (1 == xxxxx) {...}
对于下面的代码:
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
USART_INIT(USART1_idx);
usart1_init();
USART_INIT(DEBUG_USART);
/* app_cfg.h */
usart(1+2)_init();
/* 获取个位 */
/* 获取十位数字 */
/* 获取百位数字 */
__MFUNC_OUT_DEC_DIGIT_TEMP0)
__MFUNC_OUT_DEC_DIGIT_TEMP1,\
__MFUNC_OUT_DEC_DIGIT_TEMP0)
/* 建立脚本输入值与 DEBUG_USART 之间的引用关系*/
/* "调用"转换脚本 */
/* 建立 DEBUG_USART 与脚本输出值之间的引用 */
USART_INIT(DEBUG_USART);
打完收工。
干货不易,如果你觉得这篇文章对你有所帮助或是有所启发,点赞、转发、收藏三联!
免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!