4、接口设计规范:头文件设计艺术、回调函数与抽象层、避免全局变量污染
接口设计,说白了就是定规矩。你想想看,一个嵌入式项目少说几十个模块,多则上百。如果每个模块都按自己的喜好来,那联调的时候就是灾难现场。我做了十几年嵌入式,见过太多因为接口混乱导致项目延期甚至重来的案例。今天咱们就聊聊怎么把接口设计得清爽、稳定、好维护。
4.1 头文件设计艺术
头文件是C语言里模块对外暴露的唯一窗口。我个人习惯,头文件里只放别人必须知道的东西,内部实现细节全部藏起来。这就像你家的门牌号——别人只需要知道地址,不需要知道你客厅的沙发是什么颜色。
4.1.1 头文件的基本结构
一个规范的头文件,应该长这样:
#ifndef __MODULE_NAME_H__
#define __MODULE_NAME_H__
/* 头文件包含 */
#include "common_types.h"
#include "hal_config.h"
/* 宏定义 */
#define MODULE_MAX_BUFFER_SIZE 256
#define MODULE_DEFAULT_TIMEOUT 1000 /* 单位:ms */
/* 类型定义 */
typedef enum {
MODULE_OK = 0,
MODULE_ERROR = -1,
MODULE_TIMEOUT = -2
} module_status_t;
typedef struct {
uint8_t *buffer;
uint16_t length;
uint32_t timeout_ms;
} module_config_t;
/* 函数声明 */
module_status_t module_init(module_config_t *config);
module_status_t module_send(const uint8_t *data, uint16_t len);
module_status_t module_recv(uint8_t *data, uint16_t *len);
#endif /* __MODULE_NAME_H__ */
这里有几个要点:
- 头文件保护宏:必须用
#ifndef/#define/#endif,名字要唯一。我习惯用__模块名_H__的格式。 - 只包含必要的头文件:不要图省事把整个工程的头文件都
#include进来。我曾经接手过一个项目,一个头文件里包含了30多个其他头文件,编译一次要5分钟。 - 函数声明要完整:参数类型、返回值类型、参数名都要写清楚。别偷懒只写类型不写参数名,那样别人用的时候根本不知道参数是什么意思。
int g_counter;,结果这个头文件被5个.c文件包含,链接的时候直接报重复定义。正确的做法是在.c文件里定义,在头文件里用 extern 声明。
4.1.2 头文件的分层设计
我建议把头文件分成三层:
| 层级 | 命名规则 | 内容 | 示例 |
|---|---|---|---|
| 芯片层 | chip_xxx.h | 寄存器定义、中断向量 | chip_stm32f4.h |
| 板级层 | board_xxx.h | 引脚映射、外设配置 | board_evk.h |
| 应用层 | app_xxx.h | 业务逻辑接口 | app_sensor.h |
这样做的好处是,换芯片的时候只需要改芯片层和板级层,应用层基本不动。嗯,这就是低耦合的体现。
4.2 回调函数与抽象层
回调函数是C语言里实现解耦的利器。说白了,就是「我告诉你一个函数指针,你到时候帮我调用它」。这样模块之间就不需要知道对方的具体实现。
4.2.1 回调函数的标准模式
来看一个典型的回调函数使用场景——定时器模块:
/* timer.h */
typedef void (*timer_callback_t)(void *arg);
typedef struct {
uint32_t period_ms;
timer_callback_t callback;
void *arg;
uint8_t repeat; /* 0: 单次, 1: 循环 */
} timer_t;
timer_t* timer_create(uint32_t period_ms,
timer_callback_t callback,
void *arg,
uint8_t repeat);
void timer_start(timer_t *timer);
void timer_stop(timer_t *timer);
使用的时候,应用层只需要注册自己的回调函数:
/* app_led.c */
void led_blink_handler(void *arg) {
/* 翻转LED */
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
void app_led_init(void) {
timer_t *timer = timer_create(500, led_blink_handler, NULL, 1);
timer_start(timer);
}
你看,定时器模块完全不知道LED的存在,LED模块也不需要知道定时器内部怎么实现的。这就是抽象层的魅力。
void *arg 参数,这样以后扩展的时候不需要改接口定义。比如上面LED的例子,如果以后需要传不同的LED引脚,直接把引脚号通过 arg 传进去就行。
4.2.2 抽象层的设计原则
抽象层不是越多越好。我见过有人为了「抽象」而抽象,搞了四五层,结果一个简单的GPIO操作要调用七八个函数。这反而增加了复杂度和性能开销。
我的原则是:
- 只抽象变化的部分:比如驱动层,不同芯片的寄存器操作不一样,那就抽象成
hal_gpio_write()、hal_uart_send()这样的接口。 - 保持接口最小化:一个模块对外暴露的函数不要超过10个。如果超过了,说明这个模块职责太重,需要拆分。
- 接口参数要稳定:一旦确定就不要轻易改。我吃过这个亏——改了一个接口的参数类型,结果导致20多个调用方都要改。
4.3 避免全局变量污染
全局变量是嵌入式开发的万恶之源。你想想看,一个变量谁都能改,出了问题你根本不知道是谁改的。我调试过最痛苦的一个bug,就是两个模块同时修改了一个全局变量,导致系统随机崩溃。
4.3.1 全局变量的替代方案
我总结了三种替代全局变量的方法:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 静态局部变量 | 模块内部状态 | 作用域最小,外部无法访问 | 多实例时无法使用 |
| 结构体传参 | 模块间数据传递 | 数据封装性好,易于扩展 | 需要显式传递参数 |
| 单例模式 | 硬件资源管理 | 控制访问权限,可加锁 | 实现稍复杂 |
举个例子,如果你需要一个全局的计数器,不要这样写:
/* 错误示范 */
int g_packet_count; /* 全局变量,谁都能改 */
而是这样:
/* counter.h */
void counter_increment(void);
uint32_t counter_get(void);
void counter_reset(void);
/* counter.c */
static uint32_t s_packet_count = 0; /* 静态变量,外部不可见 */
void counter_increment(void) {
s_packet_count++;
}
uint32_t counter_get(void) {
return s_packet_count;
}
void counter_reset(void) {
s_packet_count = 0;
}
这样,所有对计数器的操作都必须通过接口函数,你可以在函数里加调试信息、加锁、加边界检查,出了问题也容易定位。
4.3.2 全局变量的「合法」使用场景
当然,也不是说全局变量完全不能用。我承认,在嵌入式系统里,有些场景用全局变量确实更方便:
- 中断服务程序中的标志位:比如
volatile uint8_t g_uart_rx_flag,中断里置位,主循环里查询。 - 系统运行状态:比如
system_state_t g_sys_state,整个系统都需要知道当前处于什么模式。 - 调试信息:比如
debug_log_t g_debug_info,只在调试阶段使用,发布版本可以去掉。
但即使在这些场景下,我也建议:
- 用
volatile关键字修饰,防止编译器优化。 - 变量名加上模块前缀,比如
g_uart_、g_sys_。 - 在头文件里用
extern声明,而不是直接定义。
4.4 本章知识体系
下面这张图总结了接口设计的核心逻辑:
接口设计这件事,说难不难,说简单也不简单。关键是要养成好习惯——写头文件的时候多想一步「这个接口别人用起来方便吗?」,写回调函数的时候多问一句「这个抽象层真的有必要吗?」,定义变量的时候多提醒自己「这个变量能不能用静态的?」。做到这三点,你的代码质量会有质的提升。