4. 模块接口设计:API设计原则、参数传递规范、错误处理机制
模块接口设计,说白了就是定规矩。你想想看,一个团队十几个人,每个人写自己的模块,最后要拼到一起跑。接口要是没定好,联调的时候就是灾难现场。我这些年见过太多因为接口设计不规范导致的返工,有的甚至要重写整个模块。
今天咱们就聊聊接口设计的三个核心:API怎么设计才优雅、参数怎么传才安全、错误怎么处理才靠谱。
4.1 API设计原则:少即是多
我个人习惯,设计API之前先问自己三个问题:
- 这个函数别人用起来方便吗?
- 函数名一看就懂吗?
- 调用者会不会用错?
如果有一个答案是「不太确定」,那就得重新想。
4.1.1 单一职责原则
一个函数只做一件事。我在项目中遇到过有人写了个函数叫process_data(),里面既做校验、又做转换、还做存储。后来要改存储逻辑,结果校验也跟着出问题。拆开不好吗?
// 不好的设计
int process_data(uint8_t *buf, uint32_t len);
// 好的设计
int validate_data(uint8_t *buf, uint32_t len);
int convert_data(uint8_t *in, uint32_t in_len, uint8_t *out, uint32_t *out_len);
int store_data(uint8_t *buf, uint32_t len);
4.1.2 命名要自解释
函数名就是最好的注释。别用func1、do_sth这种名字。我曾经接手过一个项目,有个函数叫handle_it(),看了半天源码才知道是处理定时器中断的。你想想看,这多浪费时间。
- 模块名_动词_名词,比如
uart_send_byte() - 返回值类型要明确,比如
is_buffer_full()返回 bool - 避免缩写,除非是行业通用(比如
uart、spi)
4.2 参数传递规范:别让调用者猜
参数传递这块,坑特别多。我见过最离谱的,一个函数有12个参数,调用的时候全靠数位置。嗯,这种代码维护起来真要命。
4.2.1 参数数量控制
我个人建议,参数不要超过4个。超过4个就用结构体封装。为什么是4个?因为人的短期记忆大概能记住4个东西。
// 参数太多,容易出错
int config_timer(uint32_t period, uint8_t mode, uint8_t polarity,
uint8_t prescaler, uint8_t interrupt_enable,
void (*callback)(void));
// 用结构体封装,清晰多了
typedef struct {
uint32_t period;
uint8_t mode;
uint8_t polarity;
uint8_t prescaler;
uint8_t interrupt_enable;
void (*callback)(void);
} timer_config_t;
int config_timer(timer_config_t *cfg);
4.2.2 输入输出参数要区分
这个很重要。调用者得知道哪些参数是传进去的,哪些是拿结果的。我习惯用const标记输入参数,输出参数用指针。
// 输入参数用 const,输出参数用指针
int calculate_crc(const uint8_t *data, uint32_t len, uint32_t *crc_out);
4.2.3 参数类型选择
| 场景 | 推荐类型 | 原因 |
|---|---|---|
| 长度/大小 | uint32_t |
避免16位溢出 |
| 状态/标志 | uint8_t 或枚举 |
节省空间,语义清晰 |
| 回调函数 | 函数指针 | 灵活解耦 |
| 缓冲区 | uint8_t * + 长度 |
防止越界 |
4.3 错误处理机制:别让错误静默
错误处理是嵌入式开发里最容易忽视的环节。很多人觉得「这个函数不会出错」,结果一出错就是大问题。我早期做项目时也犯过这种错,后来学乖了。
4.3.1 返回值规范
我建议统一用int作为返回值,0表示成功,负数表示错误码。正数可以表示警告或特殊状态。
// 错误码定义
#define ERR_SUCCESS 0
#define ERR_INVALID_PARAM -1
#define ERR_TIMEOUT -2
#define ERR_BUSY -3
#define ERR_NOMEM -4
// 使用示例
int uart_send_byte(uint8_t byte) {
if (!uart_is_ready()) {
return ERR_BUSY;
}
// 发送逻辑...
return ERR_SUCCESS;
}
4.3.2 错误码要分层
模块级的错误码和系统级的要分开。我习惯用位域来编码:高8位表示模块ID,低8位表示具体错误。
// 错误码编码
#define MODULE_UART 0x01
#define MODULE_SPI 0x02
#define MODULE_TIMER 0x03
#define MAKE_ERR(mod, code) (-((mod << 8) | code))
#define ERR_UART_TIMEOUT MAKE_ERR(MODULE_UART, 1)
#define ERR_UART_OVERRUN MAKE_ERR(MODULE_UART, 2)
#define ERR_SPI_CRC MAKE_ERR(MODULE_SPI, 1)
4.3.3 错误传播策略
函数内部捕获的错误,能处理就处理,处理不了就向上传播。但要注意,不要吞掉错误信息。
int read_sensor_data(sensor_t *sensor, uint32_t *value) {
int ret;
ret = i2c_read(sensor->addr, sensor->reg, (uint8_t *)value, 4);
if (ret != ERR_SUCCESS) {
// 记录错误日志,然后向上传播
log_error("I2C read failed: %d", ret);
return ret;
}
return ERR_SUCCESS;
}
4.3.4 断言的使用
断言只用于调试阶段,检查不应该发生的错误。比如参数为NULL、数组越界等。正式发布时断言要关掉。
void timer_start(timer_t *timer) {
// 调试阶段检查
assert(timer != NULL);
assert(timer->state == TIMER_STOPPED);
// 正式逻辑
timer->state = TIMER_RUNNING;
HAL_TIM_Base_Start(timer->handle);
}
4.4 实战建议:接口文档怎么写
接口设计好了,文档也得跟上。我见过太多「代码即文档」的团队,结果代码改了文档没改,后来谁也看不懂。
我个人习惯在每个接口函数前面加注释模板:
/**
* @brief 发送数据到UART
*
* @param data 要发送的数据缓冲区
* @param len 数据长度(字节)
* @param timeout_ms 超时时间(毫秒)
*
* @return ERR_SUCCESS 成功
* @return ERR_INVALID_PARAM 参数无效
* @return ERR_TIMEOUT 发送超时
*
* @note 此函数会阻塞直到发送完成或超时
* 调用前请确保UART已初始化
*/
int uart_send(const uint8_t *data, uint32_t len, uint32_t timeout_ms);
嗯,接口设计这块就聊这么多。记住三点:API要简洁、参数要规范、错误要明确。做到这三点,你的模块接口就不会太差。