宏定义与宏函数

C语言进阶 — 宏定义与宏函数

宏是 C 预处理器最强大也最容易"翻车"的特性。本文聚焦于对象宏与函数式宏的用法、函数式宏的三大经典陷阱、以及嵌入式开发中最实用的宏技巧。


1 预处理器本质:文本替换引擎

1.1 编译四阶段中的预处理

源文件 (.c)
    │
    ▼ ① 预处理 (gcc -E)
展开后的源文件 (.i)       ← 宏替换、#include 展开、条件编译
    │
    ▼ ② 编译 (gcc -S)
汇编文件 (.s)
    │
    ▼ ③ 汇编 (gcc -c)
目标文件 (.o)
    │
    ▼ ④ 链接 (gcc / ld)
可执行文件 (.elf / .out)
ℹ️ 核心认知

宏 ≠ 函数,宏 = 文本替换。预处理器在编译之前工作,它不懂 C 语法、不检查类型、不理解作用域——只做纯文本的查找替换。理解这一点是避开所有宏陷阱的关键。

1.2 查看宏展开结果

# 只做预处理,输出到 .i 文件,查看宏展开后的真面目
gcc -E main.c -o main.i

# 查看展开结果(跳过头文件的大量展开)
tail -50 main.i
💡 调试宏的第一步

遇到宏相关的编译错误时,先用 gcc -E 看展开结果,很多"诡异"的错误一看就明白了。

2 对象宏(Object-like Macro)

2.1 基本概念

对象宏 = 无参数宏,就是简单的"名字→内容"替换。

基本语法:

#define 宏名 替换内容
  • 没有括号 ()
  • 不需要分号结尾(这不是 C 语句,是预处理指令)
  • 从定义处开始,后面所有出现 宏名 的地方都会被替换成 替换内容

示例:圆的面积和周长

#include <stdio.h>

#define PI 3.1415926

int main(void) {
    int R = 3;
    double circle_area = PI * R * R;
    double circle_length = 2 * PI * R;
    printf("圆的面积: %.2f\n", circle_area);
    printf("圆的周长: %.2f\n", circle_length);
    return 0;
}

gcc -E 看预处理后的结果:

int main(void) {
    int R = 3;
    double circle_area = 3.1415926 * R * R;        // PI 被替换了
    double circle_length = 2 * 3.1415926 * R;      // PI 被替换了
    printf("圆的面积: %.2f\n", circle_area);
    printf("圆的周长: %.2f\n", circle_length);
    return 0;
}

预处理器把所有的 PI 都替换成了 3.1415926,这就是纯文本替换。

2.2 对象宏的用途

对象宏最核心的目的:给"魔数"(Magic Number)一个有意义的名字,让代码一眼就能看懂含义。

// ---- 基础:常量定义(消灭魔数)----
#define PI          3.14159265358979
#define MAX_SENSORS 8
#define FIRMWARE_VER "v2.1.0"

// ---- 进阶:寄存器地址映射(嵌入式核心用法)----
#define GPIOA_BASE      0x40020000UL
#define GPIOA_MODER     (*(volatile unsigned int *)(GPIOA_BASE + 0x00))
#define GPIOA_ODR       (*(volatile unsigned int *)(GPIOA_BASE + 0x14))
ℹ️ 宏还能替换表达式

替换内容不仅可以是数字/字符串,也可以是表达式。如果替换内容包含运算,需要考虑优先级,加括号(第 3 节详解)。

2.3 #define vs const vs enum

特性 #define const enum
阶段 预处理期(文本替换) 编译期 编译期
类型检查 ❌ 无 ✅ 有 ✅ 有(int)
调试可见 ❌ 符号表中无 ✅ 可调试 ✅ 可调试
作用域 从定义点到文件末 / #undef 遵守 C 作用域规则 遵守 C 作用域规则
用于 switch ❌(C 语言中不行)
字符串/浮点 ❌ 仅整数
💡 嵌入式建议

整数常量优先用 enum;浮点/字符串用 #define;寄存器映射必须用 #define

2.4 多行宏与续行符

#define PRINT_HEADER()                          \
    do {                                        \
        printf("==========================\n"); \
        printf("  Firmware: %s\n", FIRMWARE_VER);\
        printf("==========================\n"); \
    } while (0)
⚠️ 注意:续行符 \ 后面不能有任何字符,哪怕一个不可见的空格都会导致续行失败。

2.5 #undef —— 取消宏定义

#define BUFFER_SIZE 256
// ... 使用 ...
#undef BUFFER_SIZE           // 从此处开始不再有效
#define BUFFER_SIZE 1024     // 重新定义

3 函数式宏(Function-like Macro)

函数式宏 = 带参数的宏,看起来像函数调用,但本质还是预处理阶段的文本替换——没有函数调用的开销(参数入栈、出栈、内存拷贝),也没有类型检查。是一种用空间换时间的做法。

基本语法:

#define 宏名(参数列表) 替换内容
⚠️ 宏名和 ( 之间不能有空格!否则会被当成对象宏。

#define SQUARE(x) ((x) * (x)) → ✅ 函数式宏

#define SQUARE (x) ((x) * (x)) → ❌ 变成对象宏!宏名是 SQUARE,替换内容是 (x) ((x) * (x))

简单示例:

#include <stdio.h>

#define SQUARE(x)   ((x) * (x))
#define MAX(a, b)   ((a) > (b) ? (a) : (b))

int main(void) {
    printf("SQUARE(5) = %d\n", SQUARE(5));    // 25
    printf("MAX(3, 7) = %d\n", MAX(3, 7));    // 7
    return 0;
}

gcc -E 看预处理后的结果:

int main(void) {
    printf("SQUARE(5) = %d\n", ((5) * (5)));          // 文本替换,不是函数调用
    printf("MAX(3, 7) = %d\n", ((3) > (7) ? (3) : (7)));
    return 0;
}

函数式宏很好用,但有三个经典陷阱


3.1 陷阱一:运算符优先级 → 必须加括号

// ⚠️ 错误示范:没加括号
#define SQUARE_BAD(x)   x * x

// ✅ 正确写法:参数和整体都加括号
#define SQUARE(x)       ((x) * (x))

展开对比:

int a = 3;

// SQUARE_BAD(a + 1)
// 展开: a + 1 * a + 1 = 3 + 3 + 1 = 7  ← 期望 16,结果 7!
int r1 = SQUARE_BAD(a + 1);

// SQUARE(a + 1)
// 展开: ((a + 1) * (a + 1)) = 4 * 4 = 16  ← 正确!
int r2 = SQUARE(a + 1);
⚠️ 宏函数黄金法则(一般做法)
  1. 每个参数都要用 () 包裹
  2. 整个表达式也要用 () 包裹
  3. 多条语句用 do { ... } while(0) 包裹(见 3.3)

3.2 陷阱二:副作用(参数被多次求值)

#define MAX(a, b)  ((a) > (b) ? (a) : (b))

int x = 5, y = 3;
int result = MAX(x++, y++);

展开后变成了:

int result = ((x++) > (y++) ? (x++) : (y++));
//            ^^^^            ^^^^
//            x++ 被执行了两次!
//
// 期望: result=5, x=6, y=4
// 实际: result=6, x=7, y=4  ← 完全错误!
⚠️ 记住

宏只是文本替换,参数出现几次就求值几次。传入 x++、函数调用等有副作用的表达式会出问题。

怎么办? 调用时不要传带副作用的表达式,或者改用 inline 函数。

3.3 陷阱三:多语句宏 → do { ... } while(0)

多语句宏如果不做处理,在 if 中使用会出问题。通过三个版本来理解为什么最终要用 do { } while(0)

💡 结论先行

{ } 后面加分号会产生空语句,导致 if-else 匹配出错。do { } while(0) 天然需要分号结尾,完美兼容所有语法场景。

❌ 版本一:裸写多条语句(有 bug)

#include <stdio.h>

int error_count = 0;

#define LOG_BAD(msg)                      \
    fprintf(stderr, "[ERROR] %s\n", msg); \
    error_count++

int main(void) {
    int failed = 1;
    if (failed)
        LOG_BAD("越界错误");
    return 0;
}

预处理之后:

int error_count = 0;

int main(void) {
    int failed = 1;
    if (failed)
        fprintf(stderr, "[ERROR] %s\n", "越界错误"); error_count++;
    //                                               ^^^^^^^^^^^^^^
    //                        这句不在 if 里面了!不管 failed 是什么都会执行!
    return 0;
}

运行结果

⚠️ 版本二:用 {} 包裹(只 if 可以,if-else 不行)

#include <stdio.h>

int error_count = 0;

#define LOG_BAD(msg)                          \
    {                                         \
        fprintf(stderr, "[ERROR] %s\n", msg); \
        error_count++;                        \
    }

int main(void) {
    int failed = 1;
    if (failed)
        LOG_BAD("越界错误");
    else
        printf("正常执行");
    return 0;
}

预处理之后:

int main(void) {
    int failed = 1;
    if (failed)
        { fprintf(stderr, "[ERROR] %s\n", "越界错误"); error_count++; };
    //                                                               ^
    //  这个分号变成了空语句,else 找不到对应的 if → 编译报错!
    else
        printf("正常执行");
    return 0;
}

编译报错

✅ 版本三:用 do { } while(0)(完美解决)

#include <stdio.h>

int error_count = 0;

#define LOG_BAD(msg)                          \
    do                                        \
    {                                         \
        fprintf(stderr, "[ERROR] %s\n", msg); \
        error_count++;                        \
    } while (0)

int main(void) {
    int failed = 1;
    if (failed)
        LOG_BAD("越界错误");
    else
        printf("正常执行");
    return 0;
}

预处理之后:

int main(void) {
    int failed = 1;
    if (failed)
        do { fprintf(stderr, "[ERROR] %s\n", "越界错误"); error_count++; } while (0);
    //  ^^^^                                                              ^^^^^^^^^^
    //  do...while(0) 是一条完整语句,分号刚好结尾,if-else 完美匹配!
    else
        printf("正常执行");
    return 0;
}

编译通过

3.4 嵌入式最常用的宏函数

// ========== 位操作宏(操作寄存器的基石)==========
#define BIT_SET(reg, n)     ((reg) |=  (1U << (n)))   // 置 1
#define BIT_CLR(reg, n)     ((reg) &= ~(1U << (n)))   // 清 0
#define BIT_TOGGLE(reg, n)  ((reg) ^=  (1U << (n)))   // 翻转
#define BIT_READ(reg, n)    ((reg) &   (1U << (n)))    // 读取

// ========== 数组长度 ==========
#define ARRAY_SIZE(arr)     (sizeof(arr) / sizeof((arr)[0]))

// ========== 范围限制(PWM、PID 常用)==========
#define CLAMP(val, lo, hi)  ((val) < (lo) ? (lo) : ((val) > (hi) ? (hi) : (val)))

使用示例:

#include <stdio.h>
#include <stdint.h>

#define BIT_SET(reg, n)     ((reg) |=  (1U << (n)))
#define BIT_CLR(reg, n)     ((reg) &= ~(1U << (n)))
#define BIT_READ(reg, n)    ((reg) &   (1U << (n)))
#define ARRAY_SIZE(arr)     (sizeof(arr) / sizeof((arr)[0]))
#define CLAMP(val, lo, hi)  ((val) < (lo) ? (lo) : ((val) > (hi) ? (hi) : (val)))

int main(void) {
    // 位操作
    uint32_t reg = 0x00;
    BIT_SET(reg, 0);    // 设置 bit0
    BIT_SET(reg, 3);    // 设置 bit3
    printf("设置 0,3:  0x%08X\n", reg);   // 0x00000009

    BIT_CLR(reg, 0);    // 清除 bit0
    printf("清除 0:    0x%08X\n", reg);   // 0x00000008

    printf("读 bit3:   %s\n", BIT_READ(reg, 3) ? "1" : "0");  // 1

    // 数组长度
    int sensors[] = {10, 20, 30, 40, 50};
    printf("数组长度:  %zu\n", ARRAY_SIZE(sensors));  // 5

    // 范围限制
    int pwm = 280;
    printf("CLAMP:     %d\n", CLAMP(pwm, 0, 255));  // 255

    return 0;
}

4 常用进阶技巧

4.1 # 字符串化 —— 把参数变成字符串

引入问题: 假设我想写一个调试宏 LOG_INFO(a),期望输出 a is 100(既包含变量名,又包含变量值)。直觉上可能会这样写:

#define LOG_INFO(x) printf("x is %d\n", x)

int main(void)
{
    int a = 100;

    LOG_INFO(a);
    return 0;
}
// 展开:printf("x is %d\n", a);
// 输出: x is 100

运行结果如下:
image-20260316224510083

看似正确,但这里的 x 只是字符串字面量中的普通字符,并不是宏参数的名字。无论传入什么变量,始终输出 x is ...,无法反映真实的变量名。

解决方案: 使用 # 运算符。在宏定义中,#x 会在预处理阶段将宏参数 x 转换为对应的字符串字面量(自动加上双引号):

#define LOG_INFO(x) printf("%s is %d\n", #x, x)

int main(void)
{
    int a = 100;
    LOG_INFO(a);
    return 0;
}
// 展开: printf("%s is %d\n", "a", a);
// 输出: a is 100

运行结果如下:
image-20260316225319586

此时 #x 将传入的参数名 a 转换为字符串 "a",从而正确输出变量名与变量值。

最实用的场景:断言宏

#define ASSERT(expr)                                    \
    do {                                                \
        if (!(expr)) {                                  \
            printf("ASSERT FAILED: %s\n"                \
                   "  File: %s, Line: %d\n",            \
                   #expr, __FILE__, __LINE__);          \
            while (1);  /* 嵌入式:死循环停机 */          \
        }                                               \
    } while (0)

// 使用:
ASSERT(temperature < 100);
// 失败时输出: ASSERT FAILED: temperature < 100
//             File: main.c, Line: 87

4.2 ## 记号粘贴 —— 拼出新的标识符

#define GPIO_PIN_SET(letter, n)  GPIO##letter->BSRR = (1U << (n))
#define GPIO_PIN_CLR(letter, n)  GPIO##letter->BSRR = (1U << ((n) + 16))

GPIO_PIN_SET(A, 5);   // 展开: GPIOA->BSRR = (1U << (5));  点亮 PA5
GPIO_PIN_CLR(B, 3);   // 展开: GPIOB->BSRR = (1U << (3 + 16));

4.3 可变参数宏 —— 调试日志必备

// ##__VA_ARGS__: 没有额外参数时自动吞掉前面的逗号
#define LOG_D(fmt, ...) \
    printf("[D] %s:%d " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)

LOG_D("温度=%d, 湿度=%d", temp, humi);  // 带参数 ✅
LOG_D("系统启动");                        // 无参数也 OK ✅

4.4 条件编译 —— 头文件保护 & 功能开关

// 头文件保护(每个 .h 文件必须有)
#ifndef __SENSOR_H__
#define __SENSOR_H__
// ... 头文件内容 ...
#endif

// 或者更简洁的写法(大多数编译器支持)
#pragma once
// 功能开关:编译时裁剪功能,节省 Flash
#define FEATURE_WIFI   1
#define FEATURE_BT     0

void system_init(void) {
    #if FEATURE_WIFI
        wifi_init();       // 会被编译
    #endif
    #if FEATURE_BT
        bt_init();         // 不会被编译,不占 Flash
    #endif
}
💡 #if vs #ifdef
  • #ifdef X → 只看有没有定义,#define X 0 也为真
  • #if X → 检查值,#define X 0 为假

功能开关推荐用 #if0=关 / 1=开,语义清晰。

4.5 常用预定义宏速查

含义 示例值
__FILE__ 当前源文件名 "main.c"
__LINE__ 当前行号 42
__func__ 当前函数名 "main"
__DATE__ 编译日期 "Mar 14 2026"
__TIME__ 编译时间 "10:30:05"

这些宏在写日志、断言、调试信息时非常有用,前面的 ASSERTLOG_D 宏都用到了它们。


5 宏编写 Checklist

📋 每次写宏函数前过一遍
  • 参数是否全部加了 ()
  • 整体表达式是否加了 ()
  • 多语句是否用 do { } while(0) 包裹?
  • 参数有没有被多次求值的风险?(传 x++ 会不会出问题?)
  • 简单计算逻辑是否可以用 inline 函数替代?(更安全)
ℹ️ 一句话总结:宏就是文本替换——记住这一点,括号加满,do-while 包好,90% 的坑就踩不到了。