模块接口设计:定义清晰的接口函数、参数设计与返回值设计、错误码的规范定义
模块接口设计,说白了就是给代码立规矩。我做了这么多年嵌入式,见过太多项目因为接口混乱而翻车。有的函数返回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表示成功,负值表示错误 - 错误码有独立范围,并且提供转字符串函数
接口设计知识体系
下面这张图,把接口设计的核心逻辑串起来了:
这张图把接口设计的三个核心要素串起来了。你想想看,如果每个模块都按这个套路来设计,整个项目的代码质量会提升一大截。
我的经验:接口设计不是一蹴而就的。我通常先写调用方的代码,也就是「我想怎么用这个模块」,然后再去实现接口。这样设计出来的接口,用起来最顺手。说白了,就是「用户视角驱动设计」。
好了,接口设计这块就聊到这儿。记住一句话:好的接口设计,让调用者舒服;差的接口设计,让维护者崩溃。你选哪个?
公众号:蓝海资料掘金营,微信deep3321