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 接口设计的三条铁律

我总结了三句话,每次评审代码时都会拿出来对照:

  1. 接口要稳定——一旦发布,尽量不要改。改接口意味着所有依赖方都要改。
  2. 接口要最小——只提供必要的操作。多一个函数就多一份维护成本。
  3. 接口要通用——不要为某个特定硬件设计接口。比如「读扇区」比「读 Flash 页」更通用。

3.3.3 接口层与硬件层的分离

下面这张图展示了抽象接口层在整个架构中的位置。你看,上层应用只跟抽象接口层打交道,底层驱动负责具体实现。中间用函数指针表做桥梁。

抽象接口层架构图 上层应用模块 抽象接口层 (storage_ops_t) SPI Flash 驱动 SD 卡驱动 SPI Flash 芯片 SD 卡硬件 上层应用只依赖抽象接口,不依赖具体硬件。换硬件时只需替换底层驱动,上层代码零改动。

3.4 接口设计的常见误区

嗯,这里我要多说几句。接口设计看着简单,实际坑不少。

  • 过度抽象——为了「通用」搞出几十个函数指针,结果每个实现里有一半是空函数。我建议接口函数不超过 8 个。
  • 参数过多——一个函数传 6、7 个参数,调用方记都记不住。超过 3 个参数就考虑用结构体打包。
  • 返回值不统一——有的函数返回 0 表示成功,有的返回 1 表示成功。我统一用 0 = 成功,负数 = 错误码。
  • 缺少错误处理——回调函数里出错了怎么办?接口里要预留错误上报的机制。
我的习惯:每个接口函数都写单元测试。测试通过后,接口定义就冻结了。后续只改实现,不改接口。这样上层代码永远不受影响。

接口设计规范,说到底就是一句话:让别人用起来舒服,让自己改起来轻松。头文件把门面收拾干净,回调函数把耦合降到最低,抽象接口层让代码可插拔。这三板斧用好了,你的嵌入式代码架构绝对上一个台阶。