3. 接口设计规范:头文件设计、回调函数机制、抽象接口层设计
接口设计,说白了就是模块之间的「契约」。我做了十几年嵌入式,见过太多因为接口没设计好导致的翻车现场。今天咱们就聊聊怎么把这份契约写清楚、写规范。
3.1 头文件设计——模块的「门面」
头文件是模块对外的唯一窗口。我个人习惯,头文件里只放三样东西:公开类型定义、公开函数声明、公开宏定义。内部实现细节,统统藏到 .c 文件里。
3.1.1 头文件的基本结构
我建议每个头文件都遵循固定的模板。这样团队里谁写都一样,维护起来不费劲。
/*!
* @file timer_drv.h
* @brief 硬件定时器驱动模块 - 公开接口
* @note 本模块提供定时器初始化、启动、停止及回调注册功能
*/
#ifndef TIMER_DRV_H
#define TIMER_DRV_H
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
/* 公开类型定义 */
typedef void (*timer_callback_t)(void *arg);
typedef enum {
TIMER_MODE_ONESHOT = 0,
TIMER_MODE_PERIODIC
} timer_mode_t;
typedef struct {
uint32_t period_us;
timer_mode_t mode;
timer_callback_t callback;
void *arg;
} timer_config_t;
/* 公开函数声明 */
int timer_init(timer_config_t *cfg);
int timer_start(uint32_t handle);
int timer_stop(uint32_t handle);
void timer_deinit(void);
#ifdef __cplusplus
}
#endif
#endif /* TIMER_DRV_H */
#ifdef __cplusplus 包裹函数声明,这样 C++ 项目也能直接包含你的头文件。我在一个混合编程的项目里吃过亏,后来就养成了这个习惯。
3.1.2 头文件设计的避坑指南
我曾经在一个项目里看到有人把全局变量直接定义在头文件里。结果呢?链接的时候一堆重复定义错误,排查了整整一下午。
- 不要在头文件里定义变量——用
extern声明,在 .c 文件里定义 - 不要在头文件里写函数实现——除非是内联函数或静态函数
- 不要包含不必要的头文件——能用前置声明就别用
#include - 一定要加头文件保护宏——
#ifndef / #define / #endif - 一定要写注释——函数的作用、参数含义、返回值说明
3.2 回调函数机制——解耦的「杀手锏」
回调函数,说白了就是「你告诉我该做什么,到时候我来叫你」。这是实现低耦合最常用的手段之一。
3.2.1 回调函数的基本模式
我习惯把回调函数设计成带 void *arg 参数的形式。为什么?因为这样调用方可以传入任意类型的上下文数据,灵活性大大提升。
/* 回调函数类型定义 */
typedef void (*event_handler_t)(uint32_t event_id, void *context);
/* 注册回调 */
int event_register(event_handler_t handler, void *context);
/* 触发事件 */
void event_trigger(uint32_t event_id) {
/* 遍历已注册的回调列表 */
for (int i = 0; i < g_registered_count; i++) {
if (g_handlers[i] != NULL) {
g_handlers[i](event_id, g_contexts[i]);
}
}
}
printf,直接导致系统响应延迟。回调里只做状态记录或事件标记,真正的处理放到主循环或任务里。
3.2.2 回调函数的注册与注销
你想想看,如果只允许注册一个回调,那多个模块怎么同时监听同一个事件?所以,我一般设计成支持多回调注册的模式。
| 模式 | 特点 | 适用场景 |
|---|---|---|
| 单回调 | 简单直接,内存占用小 | 硬件中断、定时器超时 |
| 多回调(链表) | 灵活,支持多个监听者 | 事件总线、消息分发 |
| 多回调(数组) | 查找快,但数量固定 | 资源受限的 MCU |
我个人偏爱链表方式。虽然实现稍微复杂点,但动态增删非常方便。我曾经在一个物联网网关项目里用链表管理了 20 多个回调,运行了两年没出过问题。
3.3 抽象接口层设计——让代码「可插拔」
抽象接口层,说白了就是定义一套「标准动作」,具体怎么实现由底层去管。这样上层代码只依赖接口,不依赖具体硬件。
3.3.1 接口抽象化的典型做法
我常用的手法是用结构体把一组函数指针打包起来。这其实就是 C 语言里的「虚函数表」。
/* 抽象接口:存储设备 */
typedef struct {
int (*init)(void);
int (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
int (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
int (*erase)(uint32_t addr, uint32_t len);
void (*deinit)(void);
} storage_ops_t;
/* 具体实现:SPI Flash */
static const storage_ops_t s_spi_flash_ops = {
.init = spi_flash_init,
.read = spi_flash_read,
.write = spi_flash_write,
.erase = spi_flash_erase,
.deinit = spi_flash_deinit,
};
/* 具体实现:SD卡 */
static const storage_ops_t s_sd_card_ops = {
.init = sd_card_init,
.read = sd_card_read,
.write = sd_card_write,
.erase = sd_card_erase,
.deinit = sd_card_deinit,
};
/* 上层代码只通过接口操作 */
int storage_read(storage_ops_t *ops, uint32_t addr, uint8_t *buf, uint32_t len) {
if (ops == NULL || ops->read == NULL) {
return -1;
}
return ops->read(addr, buf, len);
}
spi_flash_read,而是通过 ops->read 来调用。这样换硬件时,只需要换一个 ops 指针,上层代码一行都不用改。
3.3.2 接口设计的三条铁律
我总结了三句话,每次评审代码时都会拿出来对照:
- 接口要稳定——一旦发布,尽量不要改。改接口意味着所有依赖方都要改。
- 接口要最小——只提供必要的操作。多一个函数就多一份维护成本。
- 接口要通用——不要为某个特定硬件设计接口。比如「读扇区」比「读 Flash 页」更通用。
3.3.3 接口层与硬件层的分离
下面这张图展示了抽象接口层在整个架构中的位置。你看,上层应用只跟抽象接口层打交道,底层驱动负责具体实现。中间用函数指针表做桥梁。
3.4 接口设计的常见误区
嗯,这里我要多说几句。接口设计看着简单,实际坑不少。
- 过度抽象——为了「通用」搞出几十个函数指针,结果每个实现里有一半是空函数。我建议接口函数不超过 8 个。
- 参数过多——一个函数传 6、7 个参数,调用方记都记不住。超过 3 个参数就考虑用结构体打包。
- 返回值不统一——有的函数返回 0 表示成功,有的返回 1 表示成功。我统一用
0 = 成功,负数 = 错误码。 - 缺少错误处理——回调函数里出错了怎么办?接口里要预留错误上报的机制。
接口设计规范,说到底就是一句话:让别人用起来舒服,让自己改起来轻松。头文件把门面收拾干净,回调函数把耦合降到最低,抽象接口层让代码可插拔。这三板斧用好了,你的嵌入式代码架构绝对上一个台阶。