0%

C 语言中的黑魔法:宏

之前对 C 语言中宏定义的认知十分简单,包括但不限于停留在以下浅薄的层面:

1
2
#define PI 3.14
#define add(a, b) a + b

上述代码完全是大学课本中的用法。但当我看到实际项目中宏的用法后完全是一头雾水,所以自己也要写出那种高逼格让别人看不太懂的代码。宏远远比我想象的要强大,所以本文为每个宏技巧都配备了一个实用场景。

  • 字符串化操作符,实现一个简单的自动化测试样例
  • 字符串连接,实现一个具备计时功能的宏
  • X 宏,实现根据输入执行不同的函数
  • 特殊宏 __VA_ARGS__,实现一个简单的日志函数

字符串化操作符

1
2
3
4
5
6
7
8
#include <iostream>

#define str(a) #a

int main() {
std::cout << str(FUNC); // 输出 FUNC
return 0;
}

上述宏 str 通过单井号的形式实现了字符串化操作符,将传入的参数字符串化。

简单测试框架

C 语言有一些预定义的宏,比如 __LINE__ 表示当前行号,__FILE__ 表示当前的文件名。基于这一基础,我们实现一个简单的测试程序。在测试程序时,打印测试用例、文件名、行号、以及是否通过测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <stdio.h>

#define LOG_INFO(format) printf(format)

#define __TO_STR__(x) #x ":"
#define __TO_REAL__(x) __TO_STR__(x)
// 文件:行号
#define __FILE_LINE__ __FILE__ ":" __TO_REAL__(__LINE__)

#define CHECK_VAL(val) \
do { \
LOG_INFO(__FILE_LINE__ ":calling " #val "\n"); \
if (0 == (val)) { \
LOG_INFO(__FILE_LINE__ ":error \n"); \
goto fail; \
} else { \
LOG_INFO(__FILE_LINE__ ":passed \n"); \
} \
} while(0)

int test_func() {
return 1;
}

int main() {

int n_total = 2;
int n_passed = 0;

CHECK_VAL(1 == test_func());
n_passed ++;

CHECK_VAL(2 == test_func());
n_passed ++;

fail:

printf("################ summary ###################\n");
printf("passed: %d\n", n_passed);
printf("total: %d\n", n_total);

return 0;
}
  • #val 会打印测试样例
  • __FILE_LINE__ 会打印当前的文件名和行号

输出如下:

1
2
3
4
5
6
7
demo.cpp:30::calling 1 == test_func()
demo.cpp:30::passed
demo.cpp:33::calling 2 == test_func()
demo.cpp:33::error
################ summary ###################
passed: 1
total: 2

为什么用 do-while(0) ?

当时我看到这一用法也比较疑惑,但 do-while(0) 的用法还是比较常见的。多用于在一个宏定义中出现多条语句的场景中,那我们来分析一下为什么要这么用。如果我们这样定义:

1
2
3
#define SS \
stmt1; \
stmt2;

在以下的使用场景中:

1
2
3
if (cond)
SS;
stmt3;

宏展开后,会变成:

1
2
3
4
5
if (cond)
stmt1;
stmt2;
;
stmt3;

所以不管 cond 是真是假,stmt2 语句都会执行。而我们自己的意图肯定是,只有 cond 为真的时候,stmt1stmt2 才会执行。那我们给宏加上花括号试一试:

1
2
3
4
#define SS { \
stmt1; \
stmt2; \
}

但是在下面这种情况下,还是会存在一些错误:

1
2
3
4
if (cond)
SS;
else
stmt3;

这样宏展开的结果为:

1
2
3
4
5
6
7
if (cond) { 
stmt1;
stmt2;
}
;
else
stmt3;

直接导致编译错误,而出错的原因是 else 前面多一个分号。当然也可以在使用 SS 的地方后面不加分号,但是在 C 语言中通常我们习惯性的会在语句后面加一个分号。鉴于上面的这些原因,就有人想出了 do-while(0) 式的用法:

1
2
3
4
5
#define SS \
do { \
stmt1; \
stmt2; \
} while(0)

字符串连接

1
2
3
4
5
6
7
8
9
10
#include <iostream>

#define define_val(tag) \
int a_##tag = 77

int main() {
define_val(MAX);
std::cout << a_MAX;
return 0;
}

上面代码的意思是,将 a_ 和传入的 tag 连接在一起,意思是:int a_MAX = 77; 的意思。上述代码中完全没有直接出现 a_MAX 这个字符串,但我们依然可以使用。

这样做的一点点好处是:比如现在有 100 个模块分散在项目的各个角落,需要给各个模块计时统计性能。那么每次都定义起始时间、结束时间,并且计算执行时间,这些操作都是重复的。为了精简重复的操作,我们可以使用这个宏技巧来实现。如下所示的代码,我们把宏放到头文件,用户在引用头文件后,只需要两行代码就可以快速完成对模块的计时功能。

测试函数执行时间的宏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>

typedef struct Time {
double time;
} Time;

void GetTime(Time* T) {
struct timeval tv;
gettimeofday(&tv, NULL);
T->time = (tv.tv_sec * 1000.0) + (tv.tv_usec / 1000.0);
}

#define TIME_START(tag) \
Time tag##_start, tag##_end; \
do { \
GetTime(&(tag##_start)); \
} while(0)

#define TIME_END(tag) \
do { \
GetTime(&(tag##_end)); \
printf(#tag " cost %.2f \n", tag##_end.time - tag##_start.time); \
} while(0)

void func() {
usleep(10000);
}

int main() {

// 记录开始时间
TIME_START(loop_func_20);

for (int i = 0; i < 20; i++) {
func();
}

// 记录结束时间
TIME_END(loop_func_20);
}

输出如下:

1
loop_func_20 cost 202.44ms 

实现泛型

由于转专业,需要在大二补学大一的课程。在学 C# 的时候,舍友对泛型总结为五个字:「参数化类型」惊艳了连 C 都写不利索的我。就像 C++ 的模板一样,为一个函数只写一套代码,把类型看成参数,但是这个函数能支持各个类型。如果 C 要实现泛型,宏定义是必不可少的方案。

加入此时我们面临一个需求,有一个 matirx,如果数据类型是 u8,那么调用 WriteU8ToFile 函数将数据写到文件;如果数据类型是 u16,那么调用 WriteU16ToFile 函数将数据写出到文件。

我们大概写一下这俩函数的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
typedef struct {
...
void *data;
} Matrix;

WriteU8ToFile(Matrix *mat, const char *file) {
Fopen(file);

uint8_t *data = (uint8_t *)(mat->data);
for (int i = 0; i < mat->size; i++) {
FWrite("%d", (int)data[i]);
}

Fclose(file)
}

WriteU16ToFile(Matrix *mat, const char *file) {
Fopen(file);

uint16_t *data = (uint16_t *)(mat->data);
for (int i = 0; i < mat->size; i++) {
FWrite("%d", (int)data[i]);
}

Fclose(file)
}

可以看到,除了函数名中的 U8U16,以及函数体中的 uint8_tuint16_t,其余内容完全一样。能不能像 C++ 的模板一样,写一套通用的代码,而不是将代码重复这么多次导致冗余?

我们可以用宏定义代替 U8uint8_t

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define WRITE_DATA_TO_TXT(type, typeName)                                   \
void Write##typeName##DataToTxt(Matrix *src, const char *path) { \
FILE *file_ptr = fopen(path, "w"); \
for (int i = 0; i < src->h; i++) { \
type *data = (type *)src->data + i * src->pitch / sizeof(type); \
for (int j = 0; j < src->w; j++) { \
if (#typeName == "U8") { \
fprintf(file_ptr, "U8 %d\n", (int)data[j]); \
} else { \
fprintf(file_ptr, "U16 %d\n", (int)data[j]); \
} \
} \
} \
fclose(file_ptr); \
}

这样,调用这个宏定义的函数时,可以通过设置 typetypeName 字段去生成各个类型的函数:

1
2
3
WRITE_DATA_TO_TXT(uint8_t, U8)      // 生成 uint8_t 的函数 WriteU8DataToTxt

WRITE_DATA_TO_TXT(uint16_t, U16) // 生成 uint16_t 的函数 WriteU16DataToTxt

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

typedef struct {
int w;
int h;
int pitch;
void *data;
} Matrix;

#define WRITE_DATA_TO_TXT(type, typeName) \
void Write##typeName##DataToTxt(Matrix *src, const char *path) { \
FILE *file_ptr = fopen(path, "w"); \
for (int i = 0; i < src->h; i++) { \
type *data = (type *)src->data + i * src->pitch / sizeof(type); \
for (int j = 0; j < src->w; j++) { \
if (#typeName == "U8") { \
fprintf(file_ptr, "U8 %d\n", (int)data[j]); \
} else { \
fprintf(file_ptr, "U16 %d\n", (int)data[j]); \
} \
} \
} \
fclose(file_ptr); \
}

WRITE_DATA_TO_TXT(uint8_t, U8)

WRITE_DATA_TO_TXT(uint16_t, U16)

int main() {
Matrix u8Mat = {.w = 3, .h = 2, .pitch = 3 * sizeof(uint8_t), .data = malloc(3 * 2 * sizeof(uint8_t))};
uint8_t *u8Data = (uint8_t *)u8Mat.data;
u8Data[0] = 1; u8Data[1] = 2; u8Data[2] = 3;
u8Data[3] = 4; u8Data[4] = 5; u8Data[5] = 6;
WriteU8DataToTxt(&u8Mat, "u8_data.txt");

Matrix u16Mat = {.w = 3, .h = 2, .pitch = 3 * sizeof(uint16_t), .data = malloc(3 * 2 * sizeof(uint16_t))};
uint16_t *u16Data = (uint16_t *)u16Mat.data;
u16Data[0] = 10; u16Data[1] = 20; u16Data[2] = 30;
u16Data[3] = 40; u16Data[4] = 50; u16Data[5] = 60;
WriteU16DataToTxt(&u16Mat, "u16_data.txt");

free(u8Mat.data);
free(u16Mat.data);

return 0;
}

特殊宏

__VA_ARGS__ 是一个预处理器宏,用于表示可变参数列表。它通常用于定义可变参数的宏,例如 printf 函数。在宏定义中,__VA_ARGS__ 表示可变参数列表部分,可以在宏展开时将其替换为实际的参数列表。官方定义较为玄幻,直接看代码吧:

1
2
3
4
5
6
7
8
9
#include <stdio.h>

#define LOG(format, ...) printf(format, ##__VA_ARGS__)

int main() {
LOG("===== info =====\n"); // 0 参数
LOG("data is %d\n", 2); // 1 个参数
return 0;
}

一个简单的打日志函数

给上述代码加一些辅助信息,就可以实现一个日志函数:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

#define LOG(tag, format, ...) \
printf("[%s] [%s %s %d] " format, tag, __FILE__, __FUNCTION__, __LINE__, ##__VA_ARGS__)

int main() {
LOG("BASE", "Nothing\n");
LOG("BASE", " ? info diff >= %d : %.4f %d\n", 2, 0.1, 2);
return 0;
}

对于

1
LOG("BASE", "Nothing");

宏展开为:

1
printf("[%s] [%s %s %d] " "Nothing", "Base", "demo.cpp", "main", 7);  

注意,Nothing 这个信息是在 format 中,因此第一个 %s 对应的是 tag,所以最终输出为:

1
[BASE] [test.cpp main 8] Nothing

同理,第二个宏展开后的输出为:

1
[BASE] [test.cpp main 7]  ? info diff >= 2 : 0.1000 2
  • 注意:代码中使用 ##__VA_ARGS__ 而不是 __VA_ARGS__,这是因为 ##__VA_ARGS__ 用于在可变参数列表为空时删除前面的逗号。在 C 语言中,如果可变参数列表为空,则在逗号之后没有参数,这会导致编译错误。

X 宏的使用

通过宏定义的方式,根据指令执行不同的函数。比如输入的指令是 CMD_LED_ON,执行的函数是 led_on;输入的指令是 CMD_LED_OFF,执行的函数是 led_off。首先定义这两个函数:

1
2
3
4
5
6
7
8
9
static void led_on(void* p)
{
printf("%s \r\n", (char *)p);
}

static void led_off(void* p)
{
printf("%s \r\n", (char *)p);
}

将这两个指令 CMD_LED_ONCMD_LED_OFF 定义到一个枚举变量中,不过是以宏的形式:

1
2
3
4
5
6
7
8
9
10
11
12
#define MACROS_TABLE                    \
X_MACROS(CMD_LED_ON, led_on) \
X_MACROS(CMD_LED_OFF, led_off) \

/*定义命令列表*/
typedef enum
{
#define X_MACROS(a, b) a,
MACROS_TABLE
#undef X_MACROS
CMD_MAX
} cmd_e;

#define X_MACROS(a, b) a 表示取出 (a, b) 中的第一个元素 a,则宏展开后的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef enum
{
#define X_MACROS(a, b) a,
X_MACROS(CMD_LED_ON, led_on) \
X_MACROS(CMD_LED_OFF, led_off) \
#undef X_MACROS
}

继续把 X_MACROS 展开得到:

/*定义命令列表*/
typedef enum
{
CMD_LED_ON,
CMD_LED_OFF,
CMD_MAX
} cmd_e;

#define X_MACROS(a, b) b, 表示取出宏的第二个元素。使用同样的方法,在定义一个函数数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef void (*func)(void* p);
const func func_table[] =
{
#define X_MACROS(a, b) b,
MACROS_TABLE
#undef X_MACROS
};

宏展开为:

const func func_table[] =
{
led_on,
led_off
};

此时,func_table[CMD_LED_ON] 指向了 led_on 函数,func_table[CMD_LED_OFF] 指向了 led_off 函数,就实现了简单的根据不同的输入指令执行不同的函数。完成代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <stdio.h>

#define MACROS_TABLE \
X_MACROS(CMD_LED_ON, led_on) \
X_MACROS(CMD_LED_OFF, led_off) \

/*定义命令列表*/
typedef enum
{
#define X_MACROS(a, b) a,
MACROS_TABLE
#undef X_MACROS
CMD_MAX
} cmd_e;

/*定义字符串列表用作Log打印*/
const char* cmd_str[] =
{
#define X_MACROS(a, b) #a,
MACROS_TABLE
#undef X_MACROS
};

typedef void (*func)(void* p);

static void led_on(void* p)
{
printf("%s \r\n", (char *)p);
}

static void led_off(void* p)
{
printf("%s \r\n", (char *)p);
}

const func func_table[] =
{
#define X_MACROS(a, b) b,
MACROS_TABLE
#undef X_MACROS
};

static void cmd_handle(cmd_e cmd)
{
if(cmd < CMD_MAX)
{
func_table[cmd]((void*)cmd_str[cmd]);
}
}

int main()
{
cmd_handle(CMD_LED_ON);
cmd_handle(CMD_LED_OFF);
return 0;
}

参考

  1. X-宏的用法
感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章