模块接口设计:定义清晰的接口函数、参数设计与返回值设计、错误码的规范定义

模块接口设计,说白了就是给代码立规矩。我做了这么多年嵌入式,见过太多项目因为接口混乱而翻车。有的函数返回0表示成功,有的返回1表示成功,有的干脆不告诉你成功没成功——这种代码,维护起来简直要命。

今天我们就来聊聊,怎么把接口设计得既清晰又优雅。

接口函数:你的模块对外说的话

每个模块就像一个黑盒子,接口函数就是它对外说的话。话说得好不好,直接决定了别人用起来顺不顺手。

我个人习惯,接口函数命名要遵循三个原则:

  • 动词开头:比如 uart_init()sensor_read()led_set()
  • 模块前缀:比如 uart_sensor_led_,一看就知道属于哪个模块
  • 功能明确:不要用 do_stuff() 这种名字,要用 temperature_get()

核心原则:接口函数的名字,应该让调用者不用看注释就能猜出它要干什么。

我在项目中遇到过,有人把 init 写成 setup,把 read 写成 get,同一个模块里混着用。结果新同事接手时,每次都要翻代码确认哪个是哪个。嗯,后来我定了个规矩:一个模块内,语义相同的操作必须用同一个动词。

参数设计:少即是多

参数设计这块,我踩过不少坑。最惨的一次,一个函数有7个参数,调用的时候顺序搞反了,查了两天才找到问题。

参数设计的原则,我总结为「三要三不要」:

要做的 不要做的
参数尽量少,3个以内最佳 不要超过5个参数
用结构体打包相关参数 不要用一堆零散参数
输入参数用 const 修饰 不要随意修改传入的指针

举个例子,一个配置传感器的函数:

// 不好的设计:参数太多,顺序容易搞错
void sensor_config(uint8_t addr, uint8_t mode, uint16_t interval, 
                   uint8_t filter, uint8_t gain, uint8_t range);

// 好的设计:用结构体打包
typedef struct {
    uint8_t  addr;      // I2C地址
    uint8_t  mode;      // 工作模式
    uint16_t interval;  // 采样间隔(ms)
    uint8_t  filter;    // 滤波系数
    uint8_t  gain;      // 增益
    uint8_t  range;     // 量程
} sensor_config_t;

int sensor_config(const sensor_config_t *cfg);

你看,用结构体之后,参数顺序错了也不怕,而且调用的时候一目了然。我曾经在一个项目里,把所有配置参数都塞进结构体,后来改参数时只需要改结构体定义,调用方代码几乎不用动——爽得很。

小技巧:如果参数有默认值,可以在结构体定义时用宏初始化。比如 #define SENSOR_CFG_DEFAULT {0x68, 0x01, 100, 0, 1, 3},调用方直接传默认配置,省心。

返回值设计:让调用者知道发生了什么

返回值设计,说白了就是告诉调用者:「刚才那件事办得怎么样?」

我见过最糟糕的做法:函数返回 void,出了错也不吭声。程序跑飞了都不知道是哪个模块的问题。

返回值设计,我推荐用 int 类型:

  • 返回 0:表示成功
  • 返回负数:表示错误码
  • 返回正数:表示数据或状态(比如读取的字节数)
// 标准做法
int uart_send(const uint8_t *data, uint16_t len);
// 返回:成功时返回发送的字节数,失败时返回负的错误码

int sensor_read(float *temperature);
// 返回:0表示成功,负值表示错误码

为什么会这样设计?因为 0 在C语言里天然就是「假」,用 if(func()) 就能判断是否出错,写起来很顺手。

注意:不要用 1 表示成功,0 表示失败。这跟C语言的布尔逻辑是反的,容易把人搞晕。我见过一个项目,一半函数用 0 表示成功,一半用 1 表示成功,每次调用都要看文档——这种项目,迟早要重构。

错误码规范:给错误一个身份证

错误码这东西,看似简单,但做不好就是灾难。我曾经接手过一个项目,错误码从 -1-99 随便定义,同一个错误在不同模块里码值还不一样。查个bug,得先翻三份文档。

我推荐的错误码规范是这样的:

// 模块基础错误码(每个模块分配一个范围)
#define ERR_BASE_UART     (-100)
#define ERR_BASE_SENSOR   (-200)
#define ERR_BASE_LED      (-300)

// UART模块错误码
#define ERR_UART_INIT     (ERR_BASE_UART - 1)  // 初始化失败
#define ERR_UART_TIMEOUT  (ERR_BASE_UART - 2)  // 超时
#define ERR_UART_PARAM    (ERR_BASE_UART - 3)  // 参数错误
#define ERR_UART_BUSY     (ERR_BASE_UART - 4)  // 忙

// 传感器模块错误码
#define ERR_SENSOR_INIT   (ERR_BASE_SENSOR - 1) // 初始化失败
#define ERR_SENSOR_READ   (ERR_BASE_SENSOR - 2) // 读取失败
#define ERR_SENSOR_CALIB  (ERR_BASE_SENSOR - 3) // 校准失败

这样设计的好处很明显:

  • 看一眼错误码,就知道是哪个模块出的问题
  • 每个模块有独立范围,不会冲突
  • 新增错误码时,不会影响其他模块

最佳实践:每个模块的 .h 文件里,都要有完整的错误码定义。并且提供一个 const char* module_err_str(int err) 函数,把错误码转成可读的字符串。这样调试时直接打印,不用猜。

接口设计完整示例

说了这么多,我们来看一个完整的例子。假设我们要设计一个温度传感器模块:

// sensor.h
#ifndef __SENSOR_H__
#define __SENSOR_H__

#include <stdint.h>

/* 错误码定义 */
#define ERR_SENSOR_BASE     (-200)
#define ERR_SENSOR_INIT     (ERR_SENSOR_BASE - 1)  // 初始化失败
#define ERR_SENSOR_READ     (ERR_SENSOR_BASE - 2)  // 读取失败
#define ERR_SENSOR_TIMEOUT  (ERR_SENSOR_BASE - 3)  // 通信超时
#define ERR_SENSOR_PARAM    (ERR_SENSOR_BASE - 4)  // 参数错误

/* 配置结构体 */
typedef struct {
    uint8_t  i2c_addr;    // I2C地址
    uint8_t  resolution;  // 分辨率:0-3
    uint16_t timeout_ms;  // 超时时间
} sensor_cfg_t;

/* 接口函数 */
int  sensor_init(const sensor_cfg_t *cfg);
int  sensor_read_temperature(float *temp);
int  sensor_read_humidity(float *humi);
void sensor_deinit(void);

/* 错误码转字符串 */
const char* sensor_err_str(int err);

#endif

你看,这个接口设计:

  • 函数名以 sensor_ 开头,一看就知道是传感器模块
  • 配置用结构体打包,参数清晰
  • 返回值统一用 int,0表示成功,负值表示错误
  • 错误码有独立范围,并且提供转字符串函数

接口设计知识体系

下面这张图,把接口设计的核心逻辑串起来了:

模块接口设计核心逻辑 模块接口设计 接口函数 参数设计 返回值与错误码 动词开头 模块前缀 少即是多 结构体打包 0成功/负错误 模块独立范围 目标:调用者不用看文档,就知道怎么用

这张图把接口设计的三个核心要素串起来了。你想想看,如果每个模块都按这个套路来设计,整个项目的代码质量会提升一大截。

我的经验:接口设计不是一蹴而就的。我通常先写调用方的代码,也就是「我想怎么用这个模块」,然后再去实现接口。这样设计出来的接口,用起来最顺手。说白了,就是「用户视角驱动设计」。

好了,接口设计这块就聊到这儿。记住一句话:好的接口设计,让调用者舒服;差的接口设计,让维护者崩溃。你选哪个?


公众号:蓝海资料掘金营,微信deep3321