C语言函数指针与宏实战:从回调、表驱动菜单到X-Macro,讲透嵌入式代码解耦

上一节的菜单已经能用了。然后我再给它加一项:文字要加、编号要加、B 键确认时的 if 要加、绘制光标的判断也要加。菜单长到十项以后,就需要在几组代码之间来回对照——这里的第 2 项,到底对应那里哪个动作?

这就是函数指针出现的场合。在回到菜单之前,先把函数指针本身讲明白,后面的菜单、排序和回调,都会用到它。

后半篇还有第二块内容:写这类表时绕不开的宏与预处理器符号——{}、\、do { } while (0)、#、##、X-Macro、...、#pragma once,以及那个名声很坏、但在正确位置上很好用的 goto。

函数指针到底是什么

首先,函数和变量一样,在程序里也是有地址的。

编译完成后,每个函数的代码被放在 Flash 的某个位置,有一个入口地址。平时写 Action_Start();,是让 CPU 跳到那个入口去执行,执行完再回来。

把括号去掉:

Action_Start

它就不再表示“马上执行”,而是表示这个函数的入口地址。既然它是个地址,就可以存进变量里:

MenuAction action = Action_Start;    /* 把入口交给变量 */
action();                            /* 通过变量调用 */

这个变量就是函数指针。它和普通指针的区别只有一点:普通指针指向数据,函数指针指向代码。 其他都一样——它占一份存储、可以赋值、可以比较、可以放进数组和结构体、可以当参数传给别的函数。

有一条限制要记住:函数指针的类型包含完整的函数签名(返回值 + 每个参数的类型)。int (*)(int, int) 和 int (*)(const char *) 是两个不同的类型,不能互相赋值。C 不会替你“差不多就行”,类型不匹配编译器通常会给警告,硬转换之后的行为不可预期。

怎么声明、怎么赋值、怎么调用

从最简单的开始。声明一个指向“无参数、无返回值”函数的指针:

void (*action)(void);

这行要拆开看:括号里 *action 说明它是个指针,右边的 (void) 说明它指向的函数不接收参数,左边的 void 说明那个函数不返回值。括号不能删——void *action(void) 是另一个东西(一个返回 void * 的函数)。

赋值不需要取地址符号:

action = Action_Start;      /* 函数名本身就是入口地址 */
action = &Action_Start;     /* 这样写也行,效果一样 */

调用有两种写法,效果相同:

action();                   /* 常用写法 */
(*action)();                /* 写法上更“显式”,读起来像在解引用 */

放进数组,就是一张“动作表”:

void (*actions[3])(void) = { Action_Start, Action_Speed, Action_Sensor };
actions[1]();               /* 调用第二个动作 */

放进结构体,就是把“数据”和“该对数据做什么”绑在一起——这正是后面菜单要用的形状。

让类型可以读:typedef

上面这些写法一旦嵌套起来就没法读了,所以实际工程里都用 typedef 先给类型起名:

typedef void (*MenuAction)(void);
typedef int  (*CompareFunc)(const int *a, const int *b);

typedef 的作用只是“给这个类型起个名字”,它不产生任何代码。起好名字之后,声明变量就变成普通人能读的样子:

MenuAction action;          /* 一个“动作” */
CompareFunc compare;        /* 一条“比较规则” */

读 typedef 的方法和读指针一样:从中间那个名字出发,先往右看,再往左看。

typedef int (*CompareFunc)(const int *a, const int *b);
/*            ^^^^^^^^^^^  */
/*            名字在这里   */

名字右边是 (const int *, const int *)——所以它描述的是“一个函数,接收两个 const int *”;名字左边是 int——那个函数返回 int;名字被 * 包住——所以 CompareFunc 是指向这种函数的指针的名字。

第一个好处:把“规则”当参数传过去

现在讲它到底解决了什么问题。这是最该记住的一节。

假设要给一串数字排序,而且有时要从小到大、有时要从大到小、有时要按绝对值。最直接的写法是写三个函数:

void Sort_Asc(int *data, uint8_t count);
void Sort_Desc(int *data, uint8_t count);
void Sort_ByAbs(int *data, uint8_t count);

问题立刻来了:排序的过程完全一样,变的只是“两个数谁该排前面”。 三个函数里有三份几乎相同的循环,改一处要改三处;将来再加“按个位数排”,就是第四份。

换个思路:把“谁该排前面”这件事抽出来,单独写成一类小函数——比较函数:

typedef int (*CompareFunc)(const int *a, const int *b);

static int Compare_Asc(const int *a, const int *b)
{
    return (int)((*a > *b) - (*a < *b));        /* 负数:a 在前;正数:b 在前 */
}

static int Compare_Desc(const int *a, const int *b)
{
    return (int)((*b > *a) - (*b < *a));
}

static int Compare_ByAbs(const int *a, const int *b)
{
    int left = (*a < 0) ? -*a : *a;
    int right = (*b < 0) ? -*b : *b;

    return (left - right);
}

比较函数的约定很短:返回负数表示第一个排前面,返回正数表示第二个排前面,返回 0 表示两者等价。

然后排序函数把“规则”当成参数收进来:

static void Sort_Insert(int *data, uint8_t count, CompareFunc compare)
{
    uint8_t i;
    uint8_t j;
    int key;

    for (i = 1U; i < count; i++)
    {
        key = data[i];
        j = i;

        while ((j > 0U) && (compare(&data[j - 1U], &key) > 0))
        {
            data[j] = data[j - 1U];
            j--;
        }

        data[j] = key;
    }
}

排序的过程一个字都没变,只是把“比较大小时该调用谁”交给了 compare。于是调用方只需要说清自己要哪种顺序:

Sort_Insert(data, 8U, Compare_Asc);
Sort_Insert(data, 8U, Compare_Desc);
Sort_Insert(data, 8U, Compare_ByAbs);

这就是解耦。 排序函数知道“要比较两个元素”,但不知道“按什么规则比”;比较函数知道“怎么比”,但不知道“谁来排、排多少”。两边只通过 CompareFunc 这个类型见面:

调用方:我只管告诉你“怎么算大、怎么算小”
   ↓ CompareFunc
排序函数:我只管用这个规则把数组排好,不关心规则内容

查找也一样。写一个“按你自己的条件找第一个元素”的函数:

typedef uint8_t (*MatchFunc)(int value);

static uint8_t Find_First(const int *data, uint8_t count, MatchFunc match, int *found)
{
    uint8_t i;

    for (i = 0U; i < count; i++)
    {
        if (match(data[i]) != 0U)
        {
            *found = data[i];
            return 1U;
        }
    }

    return 0U;
}

调用方想找“第一个负数”还是“第一个大于 100 的数”,只需要各写一个几行的 match 函数,Find_First 本身永远不用改。

顺带说一句:标准库里就有这个东西。 我们手上这套工具链的 stdlib.h 里,快速排序的原型是:

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

四个参数里最后那个就是比较函数指针。库里的排序算法怎么写、用哪种快排实现,你不需要知道;你只需要提供一条“谁前谁后”的规则。标准库已经把这种解耦做成了现成的接口,这比任何解释都更能说明函数指针的用处。

第二个好处:把“算法”当参数传进去

上面是“规则”当参数:算法固定,行为可配。

反过来的用法同样常见:规则固定,算法可换。 比如同一串数据,我想在小数组上用插入排序,在大数组上用别的算法:

typedef void (*SortFunc)(int *data, uint8_t count, CompareFunc compare);

static void Sort_Bubble(int *data, uint8_t count, CompareFunc compare)
{
    uint8_t i;
    uint8_t j;
    int tmp;

    for (i = 0U; (i + 1U) < count; i++)
    {
        for (j = 0U; (j + 1U) < (count - i); j++)
        {
            if (compare(&data[j], &data[j + 1U]) > 0)
            {
                tmp = data[j];
                data[j] = data[j + 1U];
                data[j + 1U] = tmp;
            }
        }
    }
}

static void Sort_Use(int *data, uint8_t count, SortFunc algorithm,
                     CompareFunc compare)
{
    algorithm(data, count, compare);
}

调用的时候算法就是参数:

Sort_Use(data, 8U, Sort_Bubble, Compare_Asc);
Sort_Use(data, 8U, Sort_Insert, Compare_Asc);

这就是“把快排、堆排、冒泡各自封装成函数,再由调用方挑一个”。同一个接口,换一个函数指针就换了整套实现。

两种用法的区别要分清:

传什么 谁在变 例子
传“规则” 行为变,算法不变 qsort 的比较函数、Find 的匹配条件
传“算法” 实现变,接口不变 同一次排序换快排/堆排,同一份数据换滤波算法

第三个好处:把“以后要做什么”推迟

函数指针最常被叫作回调,说的其实是同一件事:这个函数现在不决定做什么,先把“到时候该调用谁”记下来。

你已经在用了。前面按键那一回的 action_table:

{ KEY_A, KEY_EVENT_CLICK, Action_NextMode }

那张表的第三列就是函数指针;灯的六档模式表 mode_table 也是同一个形状。当时我们说“表变了,框架不用动”——能这么说的前提,就是表里存的是函数指针。 如果第三列存的是编号,框架里就还是得有一个 switch 去把编号翻译成动作。

指针函数和函数指针

这两个说法长得很像,含义完全不同,是初学阶段最容易混的一处。

int *FindMax(int *data, uint8_t count);      /* 指针函数 */
int (*Compare)(int a, int b);                /* 函数指针 */

它俩的差别,可以只看一件事:名字先跟谁结合。

  • int *FindMax(int *data, uint8_t count):名字 FindMax 右边是 (),所以它首先是一个函数;函数名前面的 * 修饰的是它的返回值——返回 int * 的函数。这叫“指针函数”(返回指针的函数)。
  • int (*Compare)(int a, int b):名字 Compare 先被 (*...) 包住,所以它首先是一个指针;它指向一个“接收两个 int、返回 int”的函数。这叫“函数指针”(指向函数的指针)。

整理成一张表:

指针函数 函数指针
写法 int *FindMax(int *data, int count) int (*Compare)(int a, int b)
它是什么 一个函数 一个指针
名字先跟谁结合 先跟 () → 是函数 先被 * 包住 → 是指针
拿来干什么 调用它,拿到一个地址 存进变量/表,之后再调用
常见场合 返回数组首地址、返回缓冲区 回调、表驱动、解耦

还有第三种更绕的:返回函数指针的函数。

int (*GetHandler(uint8_t id))(int a, int b);

GetHandler 右边是 (uint8_t id),所以它是个函数;它返回的是 int (*)(int, int) 这个函数指针类型。读它的办法还是那句:从名字出发,先右后左。

读懂一个“怪物”声明

来看这一行:

int (*(*(*p)(int *))[4])(int *);

我第一次看到它,想的就是:换行、换语言、换专业。但它有确定的读法,读完之后会发现,它只是三层东西套在一起。

读法是从名字出发,先右后左,遇到括号就跳出来:

第一步,找名字。 p。

第二步,往右看。 紧跟着的是 ),右边没东西了,所以往左看:*。→ p 是一个指针。

第三步,跳出这一层括号,往右看。 遇到 (int *)。→ 它指向一个函数,这个函数接收 int *。

第四步,往左看。 另一边是 *。→ 这个函数返回一个指针。

第五步,往右看。 遇到 [4]。→ 返回的指针指向一个有 4 个元素的数组。

第六步,往右看。 又是 (int *)。→ 数组的每个元素是一个函数指针,那些函数接收 int *。

第七步,往左看(回到最外层)。 剩下 int。→ 那些函数返回 int。

连起来读:

p 是一个指针,指向「接收 int *、返回指针」的函数;那个返回的指针指向「4 个元素的数组」,数组元素是「接收 int *、返回 int 的函数」的指针。

这不是绕口令,它可以编译、可以赋值、可以调用。 先用几个普通函数填一张表,再写一个“返回这张表的地址”的函数,最后用 p 把它取回来调用:

static int FnAdd(int *v) { return *v + 1; }
static int FnMul(int *v) { return *v * 2; }

/* 4 个元素的数组,每个元素是“接收 int* 返回 int 的函数”的指针 */
static int (*g_table[4])(int *) = { FnAdd, 0, FnMul, 0 };

/* 接收 int*,返回“指向上面那个数组”的指针 */
static int (*(*GetTable(int *unused))[4])(int *)
{
    (void)unused;
    return &g_table;
}

int (*(*(*p)(int *))[4])(int *);        /* 那个“怪物”声明本身 */

int main(void)
{
    int value = 10;

    p = GetTable;                                    /* 类型必须严丝合缝 */
    printf("%d\n", (*(*p)(&value))[0](&value));      /* 取第 0 个并调用:11 */
    printf("%d\n", (*(*p)(&value))[2](&value));      /* 取第 2 个并调用:20 */
    return 0;
}

(*(*p)(&value))[0](&value) 这一串,刚好就是从里往外读的顺序:

(*p)              p 指向的那个函数
(*p)(&value)      调用它,拿到“指向数组的指针”
*(*p)(&value)     解引用,得到数组本身
(...)[0]          取出第 0 个元素(一个函数指针)
(...)[0](&value)  调用这个函数

真实工程里没人这么写。 它的价值有两个:一是读懂别人的头文件或者编译器报错里冒出来的类型;二是知道它的解药——typedef。上面那一行等价于:

typedef int (*Inner)(int *);              /* 内层:接收 int* 返回 int 的函数 */
typedef Inner InnerArray[4];              /* 4 个这样的函数指针 */
typedef InnerArray *(*Getter)(int *);     /* 接收 int*,返回指向数组的指针 */

Getter p;                                 /* 等价于那个“怪物” */

三行,谁都能读懂。“怪物声明”之所以可怕,只是因为它拒绝换行。 所以有些时候在想换行之前,可以先想想换行能不能解决哈哈哈哈哈。

顺便记住两个和普通指针不一样的地方:

  • 函数指针不能做算术。 p + 1 在标准 C 里没有意义(普通指针加减是按元素移动,函数之间没有“相邻”这回事);
  • 函数指针和 void * 之间的转换不是标准行为。 有些平台能通过,有些不能,别把它当成“万能指针”用。

在工程里的样子:菜单这张表

概念讲完了,回到开头那个菜单。

把动作写成函数,把“名字、编号、动作”放进同一个结构体:

typedef struct
{
    uint8_t id;
    const char *name;
    MenuAction on_confirm;
} MenuEntry;

static const MenuEntry main_entries[] =
{
    {0U, "Start",  Action_Start},
    {1U, "Speed",  Action_Speed},
    {2U, "Sensor", Action_Sensor}
};

最后一列没有括号——那是在登记“将来调用谁”,不是现在执行。

表里除了 Action_Start(),另外两个动作函数都很短,各自只做一件事:

static void Action_Speed(void)
{
    Uart_SendString("action: speed\r\n");
}

static void Action_Sensor(void)
{
    Uart_SendString("action: sensor\r\n");
}

Action_Start() 的定义留到后面讲菜单的时候再给,这里先看清函数指针要的那副“无参数、无返回值”的骨架。

通用菜单模块只负责选择、边界检查和调用:

static const MenuEntry *menu_entries = NULL;
static uint8_t menu_count = 0U;
static uint8_t selected = 0U;
static uint8_t dirty = 1U;

void Menu_Init(const MenuEntry *entries, uint8_t count)
{
    menu_entries = entries;
    menu_count = count;
    selected = 0U;
    dirty = 1U;
}

void Menu_Next(void)
{
    if ((menu_entries == NULL) || (menu_count == 0U))
    {
        return;
    }

    selected = (uint8_t)((selected + 1U) % menu_count);
    dirty = 1U;
}

const MenuEntry *Menu_Current(void)
{
    if ((menu_entries == NULL) || (menu_count == 0U))
    {
        return NULL;
    }

    return &menu_entries[selected];
}

void Menu_Confirm(void)
{
    const MenuEntry *entry = Menu_Current();

    if ((entry != NULL) && (entry->on_confirm != NULL))
    {
        entry->on_confirm();
    }
}

Menu_Confirm() 并不知道 Start 是什么,它只做三件事:拿到当前表项、检查表项和动作是否有效、调用动作。这正是前面“把调用谁变成数据”在工程里的落地。

应用层把按键事件接上去(用的还是上一节那套队列事件):

void MenuApp_Update(void)
{
    KeyEvent event;

    while (Key_GetEvent(&event) != 0U)
    {
        if (page == MENU_PAGE_MAIN)
        {
            if ((event.id == KEY_A) && (event.type == KEY_EVENT_CLICK))
            {
                Menu_Next();
            }
            else if ((event.id == KEY_B) && (event.type == KEY_EVENT_CLICK))
            {
                Menu_Confirm();
            }
        }
        else if (page == MENU_PAGE_SPEED)
        {
            if ((event.id == KEY_A) && (event.type == KEY_EVENT_CLICK) && (Key_IsDown(KEY_B) != 0U))
            {
                if (speed_target >= 5U)
                {
                    speed_target = (uint8_t)(speed_target - 5U);
                }
                dirty = 1U;
            }
            else if ((event.id == KEY_A) && (event.type == KEY_EVENT_CLICK))
            {
                if (speed_target <= 95U)
                {
                    speed_target = (uint8_t)(speed_target + 5U);
                }
                dirty = 1U;
            }
            else if ((event.id == KEY_B) && (event.type == KEY_EVENT_CLICK))
            {
                page = MENU_PAGE_MAIN;
                dirty = 1U;
            }
        }
    }
}

加一个 Manual 项,只需要写一个动作函数、加一行表项。先写函数:

static void Action_Manual(void)
{
    Uart_SendString("action: manual\r\n");
}

再在表里补一行:

{3U, "Manual", Action_Manual}

Menu_Next()、Menu_Confirm()、按键判断都不用多一个分支。

主循环保持上一节的样子:

Menu_Init(main_entries,
          (uint8_t)(sizeof(main_entries) / sizeof(main_entries[0])));

while (1)
{
    MenuApp_Update();

    if (dirty != 0U)
    {
        MenuApp_Draw();
        OLED_Update();
        dirty = 0U;
    }
}

dirty 是上一节那盏灯:状态变了点亮,画面送出去以后熄灭。函数指针只改变了“谁来做事”,没有改变显示链路本身。

这张表上有四个真会发生的错误,都该各试一次:

一、数量写死。 表里加了第四项,初始化却还写 Menu_Init(main_entries, 3U);,第四项永远选不到。用 sizeof(main_entries) / sizeof(main_entries[0]) 算,表改了数字自己跟着变。

二、类型对不上。 void Action_SetSpeed(uint8_t speed) 不能直接放进 MenuAction 表——那张表约定的是“无参数、无返回值”。编译器会给警告,不要用强制类型转换盖住:调用时参数从哪来,转换不会告诉你。正确做法是包一层 void Action_UseDefaultSpeed(void) { Action_SetSpeed(30U); }。

三、空动作不判空。 表里写 {3U, "Reserved", NULL} 本身没问题;问题是在 Menu_Confirm() 里直接 entry->on_confirm();——按下 B 会跳到地址 0 附近,通常直接进 HardFault。

四、改了状态忘了 dirty。 把动作里的 dirty = 1U 注释掉:按键仍然调用正确的函数、串口也照常打印,屏幕却不一定切换。这个现象特别容易被误判成“函数指针没生效”,先看串口那条动作日志,再看 dirty 和 OLED_Update()。

表里还有三处要对齐:这就是宏出场的地方

函数指针把“动作”收进了表里,可还有一件事没收干净。

真实的工程里,同一批菜单项往往要在三个地方各写一遍:一处枚举编号,一处名字表,一处动作表。它们是同一件事的三个副本,删除一项时漏改一处,编译不一定报错,运行时却会对不上号。

宏就是为这种“必须同步修改的重复”准备的。它工作在编译之前:预处理器先把宏展开成普通代码,编译器看到的已经是展开后的结果。

下面这几个符号,就是写这类代码时会反复撞见的。

一对花括号本身就是一条语句

先说一个最容易被忽略、也最容易让人卡住的东西:{}。

我第一次看代码时,也以为花括号必须挂在函数、if、for、while 后面——因为它一直在那些地方出现。其实一对花括号本身就是一个语句,叫复合语句(块)。它可以单独出现在任何能放语句的地方:

void Demo(void)
{
    int total = 0;

    {
        int tmp = 5;            /* 这个 tmp 只活在这对花括号里 */
        total = tmp * 2;
    }

    /* 到这里 tmp 已经不存在了,下面这行会报“未声明的标识符” */
    /* total = tmp; */
}

它的作用有两个,都很实在:

  • 限定作用域:tmp 只在这对花括号里可见,外面同名的变量互不干扰;
  • 限定生存期:块结束时 tmp 就消失了;如果它是个大数组,内存当场还给栈。

这也是为什么临时用的大数组可以放心地在块里声明——块一结束,栈就还回去了。

顺带一个身份:{} 也可以是空语句块,if (x) { } 是合法的,什么都不做。看起来像占位符,其实是真正的语句。记住这一点,后面那个 do { } while (0) 才好理解。

\:把两行拼成一行

写多行宏的时候,一条 #define 要跨好几行,靠的是行尾那个反斜杠:

#define SWAP(a, b)      \
    do {                \
        int t = (a);    \
        (a) = (b);      \
        (b) = t;        \
    } while (0)

\ 的作用是把两行拼成一行——预处理器看到的是一条完整的定义,而不是五行。它必须出现在行尾,后面除了换行什么都不能有。

三个坑,按踩到的概率排:

一、\ 后面多了一个空格。 编辑器的“自动去尾随空格”反过来也能害你:反斜杠后面跟了空格,就不再是行拼接,定义在那一行断掉,后面的行变成语法错误,而报错位置常常指向一个看起来完全正常的行。

二、// 注释后面跟 \。 这一条最阴——因为行拼接发生在注释处理之前。写:

int a = 1;   // 这是一条注释,行尾恰好有个反斜杠 \
a = 2;

看起来 a = 2; 是下一行的代码,实际上它被上面那条注释连着吃掉了。我用这套工程里的编译器实测过:不报错、不警告,编译通过,那行代码整行消失。 想验证的话看预处理输出就明白了——a = 2; 根本不在里面。

所以多行宏里不要写 // 注释,用 /* */,或者把注释写在别处。

三、行号对不上。 一条跨十行的宏展开后,编译器报的位置可能指向宏定义处,也可能指向调用处,还带着“expanded from”的说明。看到这种报错别慌,先看它指的是哪一层。

do { ... } while (0):多语句宏的唯一安全写法

如果宏里要做两件事,最自然的写法是加一对花括号:

#define M_BRACE(a, b)  { a = 1; b = 2; }

然后就会撞上这个经典场面:

if (x == 0)
    M_BRACE(x, y);
else
    x = 9;

展开之后变成:

if (x == 0)
    { x = 1; y = 2; };
else                /* 这里已经语法错了 */
    x = 9;

那个分号把 if 语句结束了,else 找不到自己的 if。报的是:

Error:  #127: expected a statement
      else

换成 do { ... } while (0) 就没事,因为它整体是“一条语句”:

#define M_DOWHILE(a, b)  do { a = 1; b = 2; } while (0)

展开后是 do { ... } while (0);——分号成了 do-while 语句的结束符,else 依旧挂在 if 上。实测这一版编译通过,零警告。

三条补充,都是常被问到的:

  • while (0) 不是真的循环。 条件是常量 0,编译器知道循环体只执行一次,会把它优化掉,没有运行时开销;它借的只是“把它包成一条语句”这个语法外壳。
  • 逗号表达式版 #define M(a,b) (a = 1, b = 2) 也能当一条语句,但它不能放 break、return,也不能在里面声明变量。do-while 版没有这些限制。
  • ({ ... }) 那种语句表达式是某些编译器的扩展,不是标准 C;换编译器就可能不认。

不在宏里,它也是一个好用的形状

do { ... } while (0) 不只服务于宏。它自己就是一句现成的话:这一串步骤只走一趟,任何一步不满足就立刻退出。

break 只能跳出循环或 switch,所以想用 break 提前退出,就得先有一个“只跑一次”的循环——do-while(0) 正好是这个循环。

之前那个硬件 I2C 版的写事务就是这么写的:每一步失败就 return 0U:

if (OLED_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT) == 0U)
{
    return 0U;          /* 从这里出去,后面的收尾没人管了 */
}

能跑,但它有个隐患:失败路径上那句“必须发停止条件”很容易被忘掉。 换成 do-while(0) 之后,收尾就只剩一处:

static uint8_t OLED_Write(uint8_t control, uint8_t data)
{
    uint8_t result = 0U;

    do
    {
        I2C_GenerateSTART(I2C1, ENABLE);

        if (OLED_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT) == 0U)
        {
            break;
        }

        I2C_Send7bitAddress(I2C1, (uint8_t)(OLED_I2C_ADDRESS << 1),
                            I2C_Direction_Transmitter);

        if (OLED_WaitEvent(I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED) == 0U)
        {
            break;
        }

        I2C_SendData(I2C1, control);

        if (OLED_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTING) == 0U)
        {
            break;
        }

        I2C_SendData(I2C1, data);

        if (OLED_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED) == 0U)
        {
            break;
        }

        result = 1U;                    /* 四步都过,才算成功 */
    } while (0);

    I2C_GenerateSTOP(I2C1, ENABLE);     /* 不管成功还是失败,收尾都在这 */

    return result;
}

好处一眼可见:

  • 收尾只有一处,不会因为漏改某个 return 而漏掉停止条件;
  • 成功与失败用同一个出口,result 在最后统一返回;
  • 步骤之间是平铺的,不再往右嵌套——四个 if 就是四件事,读起来像清单。

它的边界也要记住:break 只能跳出一层。 如果里面再套一个 do-while(0),那次 break 只破内层;想一次退到函数末尾的清理点,或者一次跳出多层循环,就得请出下一个工具。

goto:被骂的是乱跳,不是提前退出

说完 break,就绕不开 goto。它的名声很坏,我第一次看到它也会皱眉,可它是 C 里唯一能“从任意位置一步跳到函数内任意标签”的工具——这个能力用错地方会把代码变成面条,用对地方能消掉一大片嵌套。

先把负面例子摆出来,而且都是能编译通过的。用这套工程里的编译器实测:

往回跳。

int i = 0;

again:
    i++;
    if (i < 3)
    {
        goto again;         /* 往回跳:这就是 goto 变面条的入口 */
    }

编译通过,零错误零警告。它能模拟循环、continue、break,也能把自己绕成一团——“goto 有害”这句话骂的正是这种用法。

往前跳进一个块里。

int a;

goto skip;
    a = 5;
skip:
    printf("%d\n", a);      /* a 从来没有被赋值 */

实测:编译通过,只给了一条告警——

Warning:  #111-D: statement is unreachable
      a = 5;

它提醒你 a = 5; 到不了,却没有提醒“a 没被初始化就被用了”。跳进块里会绕过初始化,这是 goto 在 C 里最真实的危险:编译能过,运行时读到的是栈上残留的值。所以有一条硬规矩:不要往前跳进任何块内部(跳进有变长数组的作用域,标准直接禁止)。

那么它什么时候是对的?

用法一:统一的错误出口与清理

这是 goto 最正当的用法,也是它在操作系统内核里大量出现的原因。Linux 内核的代码风格文档里专门有一节叫函数的集中出口,讲的就是这件事:当函数有多个出口、而这些出口都要做同样的清理时,goto 很趁手;它给出的理由是——无条件语句更容易读懂、嵌套变少、以后改动时不会漏掉某一个出口点。文档里还有一条很实用的要求:标签名要说清“这个 goto 干什么、为什么存在”,比如 out_free_irq 这种一看就知道跳过去要释放什么,而不是 err1、err2。

形状是这样的:

static uint8_t Car_Start(void)
{
    uint8_t result = 0U;

    if (Motor_Open() == 0U)
    {
        goto out;                       /* 什么都没打开,直接出去 */
    }

    if (Encoder_Open() == 0U)
    {
        goto out_free_motor;
    }

    if (Imu_Open() == 0U)
    {
        goto out_free_encoder;
    }

    result = 1U;                        /* 三步全过 */
    goto out;                           /* 成功也从 out 出去 */

out_free_encoder:
    Encoder_Close();

out_free_motor:
    Motor_Close();

out:
    return result;
}

(Motor_Open() 这一类名字是示意,换成你工程里真实的初始化函数即可。)

三处要注意:

  • 释放顺序是逆序的,谁后打开谁先关,和栈的直觉一致;
  • 标签一个接一个往下落,所以失败在第三步时,会自动把第二、第一步的清理都做完——这是“阶梯”,不是三个独立的出口;
  • 成功路径要不要也走过清理? 这里成功时直接从 out 出去,跳过两个 Close;如果成功也需要收尾,就把那句放进 out 后面,让所有路径都经过它。

再对比不用 goto 的写法:要么把三层嵌套写进去(每多一步就多一层缩进),要么用几个标志变量在各处判断——前者越长越往右跑,后者容易漏改。 清理阶梯正好是 goto 收敛结构的地方。

用法二:一次跳出多层循环

break 只能破一层。两层以上时,用 goto 最干净:

for (row = 0U; row < 8U; row++)
{
    for (col = 0U; col < 8U; col++)
    {
        if (grid[row][col] == target)
        {
            goto found;                 /* 一次跳出两层 */
        }
    }
}

found:
    /* 这里知道 row 和 col 就是命中的位置 */

不用 goto 的话,得在外层再加一个 if (found) break;——两层循环加两处判断,三层加三处,每加一层都要回头补一遍。

和 do-while(0)+break 的分工

两个工具解决的是同一类问题(提前退出),边界很清楚:

想做的事 用哪个 为什么
一串步骤里任何一步失败就退出,收尾只有一处 do { ... } while (0) + break 退出点就在原地,读起来最顺
失败时要逆序释放好几样东西,或者要一次跳出多层循环 goto + 清理标签 break 跳不出那么多层,也跳不到函数末尾的收尾点
只是想从函数中途返回,且没有共同收尾 直接 return 没有清理工作,就不需要 goto

一句话记住两者的分寸:goto 的问题从来不是“跳”,而是“往回跳”和“跳进块里”。 往后跳到统一的收尾或错误出口,是把结构写进了标签名;往回跳、跳进块里,才是把结构拆散。

#:把参数变成字符串

# 出现在宏里时叫“字符串化”:把紧跟其后的那个参数原样变成字符串字面量。

#define STR(x)  #x

STR(hello)      /* 变成 "hello" */
STR(1 + 2)      /* 变成 "1 + 2" */

它在嵌入式里最常见的用途是打印变量名——你既想知道值,也想知道看的是哪个变量:

#define SHOW_INT(x)  do {                    \
        Uart_SendString(#x);                 \
        Uart_SendString(" = ");              \
        printf("%d\r\n", (x));               \
    } while (0)

两个细节要记住:

  • 中间的空白会被压成一个空格。 STR(a + b) 得到的是 "a + b";
  • 它不展开参数里的宏。 想要“先把参数里的宏展开成值,再字符串化”,得再套一层:
#define STR_(x)  #x
#define STR(x)   STR_(x)

#define VERSION  3
STR(VERSION)     /* "3":外层先展开 VERSION,内层再字符串化 */
STR_(VERSION)    /* "VERSION":直接字符串化 */

##:把两个记号粘成一个

## 叫“记号粘贴”,把左右两个记号拼成一个新记号。最典型的用处是生成名字:

#define REG(name)  reg_##name

int reg_speed;
REG(speed) = 30;        /* 展开成 reg_speed = 30; */

它的坑和 # 一模一样:## 不展开参数。想拼接两个宏,必须先展开再拼,所以标准写法是“外层套一层”:

#define CAT_(a, b)  a##b
#define CAT(a, b)   CAT_(a, b)

#define A1   111

CAT_(A, 1)      /* 得到 A1 这个记号,不是 111 */
CAT(A, 1)       /* 得到 111 */

实测的预处理输出很直观:

printf("%d\n", 111);        /* CAT(A, 1) 展开成 111 */
printf("%s\n", "111");      /* STR(A1) 展开成 "111" */

内层 CAT_ 负责粘,外层 CAT 负责先把参数展开。少写哪一层,结果都会安静地不一样——这是宏最典型的一类错误:不报错,只是值不对。

X-Macro:一份清单生成三样东西

现在可以把前面几件工具拼起来,解决本节开头说的“三处要对齐”。

思路是:只写一份清单,然后用不同的“X 宏”把同一份清单展开三次。

#define MENU_ITEMS(X)        \
    X(Start,  Action_Start)  \
    X(Speed,  Action_Speed)  \
    X(Sensor, Action_Sensor) \
    X(Manual, Action_Manual)

这份清单里,每一项只写了一对“名字,动作”。接下来三次展开:

/* 第一次:生成名字表 */
#define MENU_NAME(id, action)  #id,
static const char *menu_names[] = { MENU_ITEMS(MENU_NAME) };

/* 第二次:生成函数指针表 */
#define MENU_ACTION(id, action)  action,
static const MenuAction menu_actions[] = { MENU_ITEMS(MENU_ACTION) };

/* 第三次:生成编号 */
#define MENU_ID(id, action)  MENU_ID_##id,
typedef enum
{
    MENU_ITEMS(MENU_ID)
    MENU_ID_COUNT
} MenuId;

展开之后,等价于手写这三份:

static const char *menu_names[] = { "Start", "Speed", "Sensor", "Manual" };

static const MenuAction menu_actions[] = { Action_Start, Action_Speed,
                                           Action_Sensor, Action_Manual };

typedef enum
{
    MENU_ID_Start,
    MENU_ID_Speed,
    MENU_ID_Sensor,
    MENU_ID_Manual,
    MENU_ID_COUNT
} MenuId;

清单里加一项,三处同时变。 这正是我们想要的:不是省几个字,而是消掉“必须记得同步修改”的地方。

代价也得说清楚:

  • 读起来门槛高。 只看展开后的代码很难想到源头在宏里;第一次见到会以为项目里少了一个文件;
  • 报错位置不直观。 宏展开里的类型错误,位置可能指到清单那一行;
  • 调试器不好跳。 断点、单步、IDE 的“转到定义”,在宏生成的表上都不太好用。

所以 X-Macro 适合“一份数据、多处使用、而且要求绝对同步”的场合:菜单项、命令表、状态名字表、错误码表。别拿它去包逻辑。

...:本质是指针一直往后走

可变参数有两种层级:函数那一层,和宏那一层。先看函数。

C 语言允许函数用 ... 表示“后面还有若干个参数,个数不定”:

int printf(const char *format, ...);

问题来了:函数怎么知道后面到底有几个参数、分别多大?

答案是它不知道。它只能靠规则一个个往下读,而读的办法就是“指针一直往后走”:

#include <stdarg.h>

static int Sum(int count, ...)
{
    va_list ap;                 /* 一个游标,指向当前参数 */
    int total = 0;
    int i;

    va_start(ap, count);        /* 从 count 之后开始 */

    for (i = 0; i < count; i++)
    {
        total += va_arg(ap, int);   /* 按 int 取一个,游标往后移一个 int */
    }

    va_end(ap);                 /* 收尾 */
    return total;
}

count 在这里的作用是清单:它告诉循环要往前走几步。printf 的清单则是那根格式串——%d 一次、%s 一次,一个萝卜一个坑。

风险全在“清单”和“实际”对不上:

  • 类型对不上,读到的是别的位。 va_arg(ap, int) 会按 int 的大小往后移,如果实参其实是 double,那两步走得既不齐、取到的也不是那个数。我用这套工程里的编译器实测:Sum(2, 1.0, 2.0) 完全编译通过,零警告。 编译器没有办法检查——... 里的类型对它来说是盲区;
  • 数量对不上,游标会走出界。 传了三个数却告诉它“有五个”,后面那两次 va_arg 读到的是栈上别的数据。读出来是个垃圾数还是直接崩,取决于那里恰好放着什么;
  • 默认参数提升必须记住。 小整数类型会被提升成 int,float 会被提升成 double,所以 va_arg(ap, float) 是错的,应该写 va_arg(ap, double);va_arg(ap, char) 同理。

有一处例外要知道:printf 这类函数是能被检查的,因为我们调用时传的格式串是字面量。实测 printf("%d\n", 3.14),编译器确实给出了警告:

Warning:  #181-D: argument is incompatible with corresponding format string conversion

为什么它能查、Sum 就不能查?因为标准库的头文件里,printf 那一族被专门标注过(这套工具链的头文件里用的是 #pragma __printf_args)——编译器知道那个参数是格式串,才有本事对照着检查。 我们自己写的可变参数函数没有这个标注,也就没有这层保护。

可变参数宏与日志

宏这一层的 ... 是另一套写法:__VA_ARGS__ 表示“可变的那部分参数原样转发”。

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

它看起来能用,但一遇到“没有可变参数”的调用就出事:

LOG("hello\n");     /* 展开成 printf("hello\n", ); —— 多了一个逗号 */

实测这套工程里的编译器给的是:

Error:  #29: expected an expression
Warning:  #225-D: the format string ends before this argument

解决方式有两个,得看编译器支持哪一个:

/* 写法一:GNU 扩展,用 ## 吞掉多余的那个逗号 */
#define LOG(fmt, ...)   printf(fmt, ##__VA_ARGS__)

/* 写法二:C23 的 __VA_OPT__,标准写法,新编译器支持 */
#define LOG(fmt, ...)   printf(fmt __VA_OPT__(,) __VA_ARGS__)

两种我都实测了:写法一在这套工程里干净通过;写法二报 #18: expected a ")"——因为这个工具链的 C 标准还停在 C23 之前。选哪种取决于你手上的编译器,别照抄别人的写法。

日志宏更常见的做法是转发给一个自己的函数,好加时间戳、加模块名、加串口缓冲:

#include <stdarg.h>
#include <stdio.h>

static void Log_Printf(const char *fmt, ...)
{
    char line[80];
    va_list ap;

    va_start(ap, fmt);
    vsnprintf(line, sizeof(line), fmt, ap);     /* v 版本:接收一个现成的游标 */
    va_end(ap);

    Uart_SendString(line);
}

#define LOG(...)   Log_Printf(__VA_ARGS__)

注意那个 v:凡是“把可变参数再转给别人”的函数,标准库都提供了 v 版本(vprintf、vsnprintf 之类)。只有 v 版本才能接收 va_list 这个游标,普通版本收不了。这是 fputc 重定向之外,另一个和标准库打交道的固定套路。

还有两件小事,写在日志宏里能省很多麻烦:

  • 用 do { ... } while (0) 包一层,别让日志宏在 if/else 里出问题(理由前面讲过);
  • 日志宏里不要带副作用。 LOG("count = %d\n", count++) 这种写法,一旦展开次数变了,count 就可能被加两次——宏展开是纯文本替换,它不知道什么叫“只执行一次”。

#pragma once

最后是这个和头文件有关的指令。

老写法是“头文件守卫”:

#ifndef __OLED_H
#define __OLED_H

/* 声明与定义 */

#endif

新写法只有一行:

#pragma once

两者的差别:

  • #pragma once 不是 C 标准里的东西,但主流编译器(GCC、Clang、armcc、armclang、MSVC)都支持;不用挑宏名,也不会因为两个头文件不小心取了同一个宏名而互相屏蔽;
  • 头文件守卫是标准写法,任何编译器都认;缺点是要为每个头文件起一个不重复的宏名,而且它靠“文件内容”判断——同一份内容被复制成两个文件,守卫挡不住重复包含;
  • #pragma once 通常靠文件路径判断,同一份文件通过不同路径(软链接、复制)被包含时,可能失效。

实际工程里两种都行。真要取舍:自己写的头文件用 #pragma once 更省事,需要严格可移植时用守卫;但一个头文件里别同时写两种,也别给守卫起那种一眼看不出所属的名字。

这些符号什么时候该用

回头看这一串:{}、\、do { } while (0)、#、##、X-Macro、...、#pragma once。

它们的共同点是工作在编译之前:预处理器只做文本替换,它不知道类型、不认识作用域,也不会替你想“这里是不是只该执行一次”。这带来两种后果:

  • 好处:能消掉“必须同步修改的重复”,能让同一份数据生成好几张表,能在编译期就拼出名字;
  • 代价:类型检查帮不上忙,报错位置常常指向别处,调试器进不去,展开后的代码和你写下的不是一回事。

所以判据很短:能用一个普通函数解决的,用函数;只有当“类型、名字、数量必须在编译期确定”,或者“一份数据要展开成多处”时,才轮到宏。

菜单这张表是个正好的例子:它既要有名字,又要有编号,还要有动作——三样东西必须永远一致。用 X-Macro 把清单收在一处,比在三张表之间来回对照安全得多。

最后橘猫说

函数指针就是把“调用谁”变成一份数据:函数名不带括号就是入口地址,存进变量、数组、结构体就成了它;类型带着完整签名,不能硬塞,也不能做算术。用法只有三条:把规则当参数(qsort 就是这个形状)、把算法当参数(接口不变,实现可换)、把以后要做的事先记下来(按键动作表和模式表早就是这个形状)。宏是另一件事:它工作在编译之前,专门消掉“必须同步修改的重复”,代价是类型检查帮不上忙、报错指向别处——能用函数就用函数,只有类型、名字、数量必须在编译期定下来时才轮到它。

最后蜘蛛说

“写那么复杂,是你写的吗”
“你猜”
“是”
“你再猜”
“……”

posted @ 2026-11-11 14:59  Zw-awa  阅读(5)  评论(0)    收藏  举报