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 就是这个形状)、把算法当参数(接口不变,实现可换)、把以后要做的事先记下来(按键动作表和模式表早就是这个形状)。宏是另一件事:它工作在编译之前,专门消掉“必须同步修改的重复”,代价是类型检查帮不上忙、报错指向别处——能用函数就用函数,只有类型、名字、数量必须在编译期定下来时才轮到它。
最后蜘蛛说
“写那么复杂,是你写的吗”
“你猜”
“是”
“你再猜”
“……”

菜单加到十项后,代码里冒出一个不像函数的地址:它不执行,却替菜单记住“该调用谁”。函数指针如何把选择变成数据?宏、反斜杠、do-while(0)和X-Macro又为何一起登场?从回调到表驱动,一次拆开。
浙公网安备 33010602011771号