虫虫首页|资源下载|资源专辑|精品软件
登录|注册

您现在的位置是:首页 > 技术阅读 >  【为宏正名】什么?我忘了去上“数学必修课”!

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

时间:2024-02-13





【说在前面的话】


在前面的文章《【为宏正名】本应写入教科书的“世界设定”》中我们了解到:宏会在预编译阶段被“处理掉”——宏会被逐级展开、其最终代表的字符串会被替换到对应的文本文件中(只不过通常这个文本文件就是".c"文件)——它不仅活不到正式的编译(make)阶段,更无法对程序运行时刻的行为产生丝毫影响
简而言之,通过宏所确定的内容是在编译时刻就固化下来的。很多人都了解这一点,也很擅长使用宏的方式来固化一些常数,比如,教科书中最常见的一个例子是:
//! 非闰年的情况下,一年中有多少秒#define SEC_IN_A_YEAR    (60ul * 60ul * 24ul * 365ul)
static uint32_t s_wTotalSecInAYear = SEC_IN_A_YEAR;

例子虽然简单,但立马引出了一个有趣的问题:宏展开后,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 -xc -std=gnu11 --target=arm-arm-none-eabi -mcpu=cortex-m4 -E -o "preprocessed_main.c" "main.c"

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

# 1 "main.c"# 1 "<built-in>" 1# 1 "<built-in>" 3# 370 "<built-in>" 3# 1 "<command line>" 1# 1 "<built-in>" 2# 1 "main.c" 2# 24 "main.c"static uint32_t s_wTotalSecInAYear = (60ul * 60ul * 24ul * 365ul);


【数位拼接律】


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

定义三个独立的宏,分别代表三个独立的“数字”:
#define NUM_A       2#define NUM_B       5#define NUM_C       5

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

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

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

#define NUM_COMBINE     CONNECT3(NUM_A,NUM_B,NUM_C)

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

#if NUM_COMBINE > 254#warning larger than 254#endif
#if NUM_COMBINE < 256#warning smaller than 256#endif
#if NUM_COMBINE == 0xFF#warning equals to 0xFF#endif

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



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

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


【序号自增】


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

  • 预编译器能够理解“数字字符串”的数值意义;

  • 宏的本质是一个对目标字符串的引用;

  • 目标字符串是个常量,修改常量是不可能的;

推论:

  • 假设一个宏表示一个序号

  • 我们可以根据当前宏的值,计算出下一个序号的值,并借助“数位拼接律”生成一个新的字符串

  • 修改宏的引用关系,让它指向新生成的字符串


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

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

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

//! MFUNC_IN_U8_DEC_VALUE = MY_INDEX + 1; 给脚本提供输入#define MFUNC_IN_U8_DEC_VALUE    (MY_INDEX + 1)
//! 让预编译器执行脚本#include "mf_u8_dec2str.h"
//! MY_INDEX = MFUNC_OUT_DEC_STR; 获得脚本输出#define MY_INDEX MFUNC_OUT_DEC_STR

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


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

假设我们要构建一个单向链表,它的元素结构如下:
typedef struct node_item_t node_item_tstruct node_item_t {    node_item_t *ptNext;                //!< 指下一个元素    //! 链表节点的其它成员    uint8_t chID;                       //!< 假设有一个元素是序号    ...};

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

static node_item_t s_tItemPool[16];static node_item_t *s_ptListRoot = NULL;

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

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

  • 添加节点的运行时间。 


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

#define ADD_ITEM_TO(__LIST_ADDR, ...)                 \    {                                                 \        .ptNext = &((__LIST_ADDR)[(__COUNTER__ + 1]), \        __VA_ARGS__                                   \    }#define ADD_FINAL_ITEM(...)                           \    {                                                 \        .ptNext = NULL,                               \        __VA_ARGS__                                   \    }

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

static node_item_t s_tItemPool[] = {    ADD_ITEM_TO(s_tItemPool),          //!< 添加节点0    ADD_ITEM_TO(s_tItemPool),          //!< 添加节点1    ...    ADD_ITEM_TO(s_tItemPool),          //!< 添加节点n-1    ADD_FINAL_ITEM(s_tItemPool),       //!< 添加最后一个节点};static node_item_t *s_ptListRoot = s_tItemPool;

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

#define ADD_ITEM_TO(__LIST_ADDR, ...)                   \    {                                                   \        .ptNext = &((__LIST_ADDR)[(__COUNTER__/2 + 1]), \        .chID = (__COUNTER__ / 2),                      \        __VA_ARGS__                                     \    }#define ADD_FINAL_ITEM(__LIST_ADDR, ...)                \    {                                                   \        .ptNext = NULL,                                 \        .chID = (__COUNTER__ / 2),                      \        __VA_ARGS__                                     \    }

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

static node_item_t s_tItemPool[] = {    { .ptNext = &((s_tItemPool)[(0/2 + 1]), .chID = (1 / 2), },    { .ptNext = &((s_tItemPool)[(2/2 + 1]), .chID = (3 / 2), },    ...    { .ptNext = &((s_tItemPool)[(4/2 + 1]), .chID = (5 / 2), },    { .ptNext = NULL, .chID = (6 / 2), },};static node_item_t *s_ptListRoot = s_tItemPool;


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


要解决这一问题,我们就不得不借助宏的“好基友”——枚举的帮助了。基本思路是这样的:
  • 无论 __COUNTER__ 是什么值,我们都可以将其传递给一个枚举——作为初始值;

  • 使用 __COUNTER__ 时,我们首先通过枚举将初始值扣除,从而获得“从0开始的计数”


说干就干:
#define __LIST_ROOT(__NAME)    s_ptList##__NAME##Root#define LIST_ROOT(__NAME)      __LIST_ROOT(__NAME)
#define __IMP_LIST(__NAME)                               \ enum { \        /* 这里 "+1" 是把本次使用__COUNTER__也算进去 */        \ list_##__NAME##_start = __COUNTER__ + 1, \ }; \    static node_item_t s_tList##__NAME##Pool[] = {               #define __END_IMP_LIST(__NAME) \ }; \ static node_item_t *LIST_ROOT(__NAME) = \ s_tList##__NAME##Pool; #define IMP_LIST(__NAME, ...) \            __IMP_LIST(__NAME, __VA_ARGS__)    #define END_IMP_LIST(__NAME) __END_IMP_LIST(__NAME)
#define __ADD_ITEM_TO(__NAME, ...) \ { \ .ptNext = &(s_tList##__NAME##Pool[ \ (__COUNTER__ - list_##__NAME##_start)/2 + 1]), \ .chID = ((__COUNTER__ - list_##__NAME##_start) / 2), \ __VA_ARGS__ \ } #define ADD_ITEM_TO(__NAME, ...) \ __ADD_ITEM_TO(__NAME, __VA_ARGS__) #define __ADD_FINAL_ITEM(__NAME, ...) \ { \ .ptNext = NULL, \ .chID = ((__COUNTER__ - list_##__NAME##_start) / 2), \ __VA_ARGS__ \ }#define ADD_FINAL_ITEM(__NAME, ...) \ __ADD_FINAL_ITEM(__NAME, __VA_ARGS__)

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

  • 以指定的名字定义了一个枚举;

  • 以指定的名字定义了链表的节点池;

  • 以指定的名字定义了指向链表的根指针,用户可以通过宏LIST_ROOT()来获取这一指针;


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

//! 实现一个list,名字叫 MyListIMP_LIST(MyList)
ADD_ITEM_TO(MyList), //!< 添加节点0 ADD_ITEM_TO(MyList), //!< 添加节点1 ... ADD_ITEM_TO(MyList), //!< 添加节点n-1 ADD_FINAL_ITEM(MyList), //!< 添加最后一个节点 END_IMP_LIST(MyList)

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

enum { list_MyList_start = 0 + 1, }; static node_item_t s_tListMyListPool[] = {
{ .ptNext = &(s_tListMyListPool[ (1 - list_MyList_start)/2 + 1]), .chID = ((2 - list_MyList_start) / 2), }, { .ptNext = &(s_tListMyListPool[ (3 - list_MyList_start)/2 + 1]), .chID = ((4 - list_MyList_start) / 2), }, ... { .ptNext = &(s_tListMyListPool[ (5 - list_MyList_start)/2 + 1]), .chID = ((6 - list_MyList_start) / 2), }, { .ptNext = NULL, .chID = ((7 - list_MyList_start) / 2), },
}; static node_item_t *s_ptListMyListRoot = s_tListMyListPool;


【参数宏也支持重载?】


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

#define __CONNECT2(__0, __1)            __0##__1#define __CONNECT3(__0, __1, __2)       __0##__1##__2#define __CONNECT4(__0, __1, __2, __3)  __0##__1##__2##__3...#define __CONNECT8(__0, __1, __2, __3, __4, __5, __6, __7)      \           __0##__1##__2##__3##__4##__5##__6##__7#define __CONNECT9(__0, __1, __2, __3, __4, __5, __6, __7, __8) \           __0##__1##__2##__3##__4##__5##__6##__7##__8           //! 安全“套”           #define CONNECT2(__0, __1)             __CONNECT2(__0, __1)#define CONNECT3(__0, __1, __2)        __CONNECT3(__0, __1, __2)#define CONNECT4(__0, __1, __2, __3)   __CONNECT4(__0, __1, __2, __3)...#define CONNECT8(__0, __1, __2, __3, __4, __5, __6, __7)        \    __CONNECT8(__0, __1, __2, __3, __4, __5, __6, __7)#define CONNECT9(__0, __1, __2, __3, __4, __5, __6, __7, __8)   \    __CONNECT9(__0, __1, __2, __3, __4, __5, __6, __7, __8)

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


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

#define HEX_U8_VALUE(__B1, __B0)                         \      CONNECT3(0x, __B1, __B0)
#define HEX_U16_VALUE(__B3, __B2, __B1, __B0)     \   CONNECT5(0x, __B3, __B2, __B1, __B0)            #define HEX_U32_VALUE(__B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)\     CONNECT9(0x, __B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)

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

#define HEX_U8_VALUE(__B1, __B0)                         \      CONNECT(0x, __B1, __B0)
#define HEX_U16_VALUE(__B3, __B2, __B1, __B0) \      CONNECT(0x, __B3, __B2, __B1, __B0) #define HEX_U32_VALUE(__B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)\      CONNECT(0x, __B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)

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

#define HEX_VALUE(...)          CONNECT(0x, __VA_ARGS__)
#define HEX_U8_VALUE(__B1, __B0) \ HEX_VALUE(__B1, __B0)
#define HEX_U16_VALUE(__B3, __B2, __B1, __B0) \ HEX_VALUE(__B3, __B2, __B1, __B0) #define HEX_U32_VALUE(__B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)\ HEX_VALUE(__B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)

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


既然前景如此诱人,怎么实现宏的重载呢?为了简化这个问题,我们假设有一个“魔法宏”:它可以告诉我们用户实际传递了多少个参数,我们不妨叫它 VA_NUM_ARGS()
#define VA_NUM_ARGS(...)         /* 这里暂时先不管怎么实现 */
借助它,我们可以这样来编写宏 CONNECT():
#define CONNECT(...)                                \    CONNECT2(CONNECT, VA_NUM_ARGS(__VA_ARGS__))     /*part1*/\        (__VA_ARGS__)                               /*part2*/
当用户使用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 =     CONNECT2(CONNECT, VA_NUM_ARGS(0x, D, E, A, D))        (0x, D, E, A, D);
由于VA_NUM_ARGS() 告诉我们有5个参数,最终实际展开为:
uint16_t hwValue =     CONNECT5        (0x, D, E, A, D);
完美!那么我们就来逆推这个问题:如何实现我们的魔法宏“VA_NUM_ARGS()” 呢?答案如下:
#define VA_NUM_ARGS_IMPL(_1,_2,_3,_4,_5,_6,_7,_8,_9,__N,...) __N#define VA_NUM_ARGS(...)                                                \            VA_NUM_ARGS_IMPL(__VA_ARGS__,9,8,7,6,5,4,3,2,1)
这里,首先构造了一个特殊的参数宏,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_ARGS__里有2个参数时,“2”对应第十个参数__N,所以返回值是2...__VA_ARGS__里有9个参数时,"9"对应第十个参数__N,所以返回值是9

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

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);extern device_write2(uint8_t *pchStream, uint_fast16_t hwLength);extern device_write3(uint_fast32_t wAddress, uint8_t *pchStream, uint_fast16_t hwLength);
#define device_write(...) \            CONNECT2(device_write, VA_NUM_ARGS(__VA_ARGS__))  \             (__VA_ARGS__)            

                

使用时:

device_write("hello world");       //!< 发送字符串
extern uint8_t chBuffer[32];device_write(chBuffer, 32);        //!< 发送缓冲
//! 向指定偏移量写数据#define LCD_DISP_MEM_START 0x4000xxxxextern uint16_t hwDisplayBuffer[320*240];device_write( LCD_DISP_MEM_START,     (uint8_t *)hwDisplayBuffer,     sizeof(hwDisplayBuffer));

往期推荐


1、深度好文|面试官:进程和线程,我只问这19个问题

2、他来了,他来了,C++17新特性精华都在这了

3、一文让你搞懂设计模式

4、C++11新特性,所有知识点都在这了!




如果喜欢这篇文章,请点赞、在看,支持一下哦~谢谢!