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 本章知识体系

下面这张图总结了接口设计的核心逻辑:

接口设计规范知识体系 接口设计规范 头文件设计艺术 回调函数与抽象层 避免全局变量污染 保护宏 最小包含 分层设计 接口最小化 函数指针 void *arg 抽象变化 参数稳定 静态变量替代 结构体传参 单例模式 volatile修饰 核心目标:降低模块间耦合度,提高代码可维护性 实践原则:接口最小化、变化抽象化、数据封装化

接口设计这件事,说难不难,说简单也不简单。关键是要养成好习惯——写头文件的时候多想一步「这个接口别人用起来方便吗?」,写回调函数的时候多问一句「这个抽象层真的有必要吗?」,定义变量的时候多提醒自己「这个变量能不能用静态的?」。做到这三点,你的代码质量会有质的提升。