4. 接口定义规范:命名规则、参数约定、返回值定义、错误码设计

接口定义这事儿,说大不大,说小不小。我见过太多项目,功能都跑通了,最后联调时因为接口风格不统一,改得鸡飞狗跳。说白了,HAL 层的接口就是硬件和上层软件之间的「合同」。合同条款写得不清楚,后面肯定要扯皮。

这一节,我就把我在几个量产项目里沉淀下来的接口规范拿出来聊聊。不一定适合所有场景,但照着做,至少能少踩一半的坑。

4.1 命名规则:让函数名自己会说话

好的命名,看名字就知道这个函数是干什么的、操作什么设备、要传什么参数。我习惯用一套固定的前缀+动词+对象结构。

组成部分 说明 示例
模块前缀 小写,标识所属硬件模块 uart_、gpio_、spi_
操作动词 小写,描述动作 init、deinit、read、write、config
操作对象 小写,描述操作目标 port、pin、frame、config

举个例子:

// 好的命名 —— 一眼就知道在干什么
int32_t uart_init(uint8_t port, uart_config_t *config);
int32_t gpio_write_pin(uint8_t port, uint8_t pin, uint8_t value);
int32_t spi_read_frame(uint8_t cs_pin, uint8_t *buffer, uint32_t len);

// 糟糕的命名 —— 猜都猜不出来
int32_t uart_setup(int a, void *b);
int32_t gpio_out(int x, int y);
int32_t spi_get(int p, char *buf);
我的习惯:所有 HAL 接口函数统一返回 int32_t,0 表示成功,负数表示错误码。这样上层代码处理起来非常统一,不用记哪个函数返回 bool、哪个返回指针。

4.2 参数约定:少即是多,多即是乱

参数设计有个原则:能用一个结构体,就别拆成五六个散参。我早期做驱动时,特别喜欢把参数全摊开写,结果函数签名越来越长,调用方看着就头疼。

后来我改成这样:

// 不推荐 —— 参数太多,顺序容易搞错
int32_t i2c_write(uint8_t bus, uint8_t addr, uint8_t reg,
                  uint8_t *data, uint32_t len, uint32_t timeout_ms);

// 推荐 —— 用配置结构体封装
typedef struct {
    uint8_t bus;
    uint8_t dev_addr;
    uint8_t reg_addr;
    uint8_t *data;
    uint32_t length;
    uint32_t timeout_ms;
} i2c_transfer_t;

int32_t i2c_write(i2c_transfer_t *transfer);

你想想看,如果以后要加个「是否使用 DMA」的标记,散参版本得改所有调用点,结构体版本只需要在结构体里加个字段,默认给 false 就行。

注意:结构体指针参数必须做 NULL 检查。我曾经在一个项目中漏了这步,结果上层传了个空指针进来,驱动直接硬 fault,查了两天才找到原因。

4.3 返回值定义:统一比什么都重要

返回值这块,我踩过最大的坑就是「混合风格」。同一个项目里,有的函数返回 0 表示成功,有的返回 1 表示成功,有的返回 true,有的返回 SUCCESS 宏。联调时上层代码得对着每个驱动单独写错误处理,那叫一个酸爽。

我现在的做法是:

  • 所有 HAL 接口统一返回 int32_t
  • 0 表示成功
  • 负数表示错误码
  • 正数保留给特殊状态(如超时、忙等)
// 统一返回值风格
#define HAL_OK       (0)
#define HAL_ERROR    (-1)
#define HAL_BUSY     (-2)
#define HAL_TIMEOUT  (-3)
#define HAL_INVALID  (-4)

int32_t uart_send(uint8_t port, const uint8_t *data, uint32_t len);
// 返回 HAL_OK 表示发送完成
// 返回 HAL_BUSY 表示端口忙
// 返回 HAL_INVALID 表示参数错误
核心原则:上层代码永远只需要判断「if (ret != HAL_OK)」,剩下的交给错误码解析。这样错误处理路径非常干净。

4.4 错误码设计:给每个错误一个身份证

错误码不是随便给个负数就完事了。我见过最离谱的设计,整个项目就一个 -1,出问题了谁都不知道是参数错了还是硬件挂了。

我习惯用模块位+错误类型位的设计:

位域 宽度 说明
模块 ID 高 8 位 标识哪个硬件模块报错(0x01=UART, 0x02=SPI, ...)
错误类型 低 8 位 标识具体错误(0x01=超时, 0x02=校验错, ...)
// 错误码定义示例
#define ERR_UART_TIMEOUT     (0x0101)  // UART 超时
#define ERR_UART_PARITY      (0x0102)  // UART 校验错误
#define ERR_SPI_TIMEOUT      (0x0201)  // SPI 超时
#define ERR_SPI_CRC          (0x0202)  // SPI CRC 错误
#define ERR_GPIO_INVALID_PIN (0x0301)  // GPIO 无效引脚号

// 上层解析时,可以快速定位问题模块
uint8_t module_id = (error_code >> 8) & 0xFF;
uint8_t error_type = error_code & 0xFF;

这样做的好处是:看错误码就知道是哪个模块出了什么问题。调试时不用翻代码查错误码表,效率高很多。

避坑指南:错误码定义一定要放在一个独立的头文件里,比如 hal_error.h。我曾经把错误码散落在各个模块的 .h 文件里,结果不同模块定义了相同的错误码值,联调时查错查到怀疑人生。

4.5 接口规范全景图

下面这张图,是我做 HAL 层设计时脑子里始终挂着的一张图。每次定义新接口,我都会对照着过一遍。

HAL 接口定义规范全景图 HAL 接口规范 命名规则 参数约定 返回值定义 错误码设计 前缀+动词+对象 结构体封装 int32_t 统一 模块位+类型位 设计要点总结 1. 命名:模块前缀_动词_对象,全部小写,下划线分隔 2. 参数:超过 3 个参数用结构体封装,必须做 NULL 检查 3. 返回值:统一 int32_t,0 成功,负数错误,正数状态 4. 错误码:高 8 位模块 ID,低 8 位错误类型,独立头文件管理 5. 所有接口必须可重入,全局变量加锁保护

4.6 一些实战中的小细节

最后聊几个我实际项目中遇到过的细节问题:

  • 参数顺序要统一:所有读写接口,我习惯把目标对象放前面,数据缓冲区放中间,长度放最后。比如 spi_read(cs, buf, len),所有模块都按这个顺序来,上层调用时不容易搞混。
  • 超时参数必须有默认值:如果接口需要超时,我建议在配置结构体里给个默认值,比如 1000ms。上层不关心超时的话,直接传 NULL 就用默认值。
  • 回调函数指针要明确生命周期:注册回调时,一定要说清楚这个回调是在中断上下文还是任务上下文执行。我见过因为回调里调了延时函数导致系统挂死的案例。
血的教训:接口定义一旦发布,尽量不要改。我有个项目,HAL 接口发布后因为需求变更改了参数类型,结果上层 20 多个模块全部要重新编译测试,整整浪费了两天。所以,接口设计时多想一步,把可能扩展的字段提前预留好。