模块化设计原则:别让代码变成一团乱麻
做BMS嵌入式开发,我见过太多「一个文件走天下」的项目了。说句实话,那种把所有功能塞进一个.c文件的做法,刚开始确实爽——不用来回切换文件,想改哪里改哪里。但等代码量超过5000行,你就知道什么叫痛苦了。
模块化设计,说白了就是「分而治之」。把一个大系统拆成若干个小模块,每个模块各司其职。我在项目中遇到过最典型的反面教材:一个同事把电池电压采集、温度检测、均衡控制、通信协议全部写在一个文件里。结果呢?改一个均衡策略,电压采集那边莫名其妙出了bug。为什么?因为全局变量满天飞,谁都能改,谁都不知道改了会影响到谁。
模块划分的黄金法则
怎么划分模块?我个人的习惯是遵循「高内聚、低耦合」这六个字。
- 高内聚:一个模块只做一件事,并且把它做好。比如电压采集模块,就只管采集电压,别在里面掺和均衡逻辑。
- 低耦合:模块之间尽量少依赖。A模块改了,B模块不应该受影响。
举个例子,BMS里常见的模块划分:
// 模块划分示例
module_adc.c // ADC采集模块
module_voltage.c // 电压处理模块
module_temperature.c // 温度处理模块
module_balance.c // 均衡控制模块
module_comm.c // 通信模块
module_safety.c // 安全保护模块
每个模块都有自己的.h和.c文件。头文件里只暴露必要的接口,内部实现细节全部隐藏。嗯,这里要注意:头文件里不要放变量定义,只放函数声明和类型定义。
核心原则:模块对外暴露的接口越少,后期维护越轻松。你想想看,如果每个模块只暴露3-5个函数,其他人用起来多省心。
头文件与源文件组织:规矩要立好
头文件和源文件的组织,看起来是小事,但做不好真的很要命。我记得刚入行时,有个老工程师跟我说:「看一个人的代码水平,先看他怎么写头文件。」当时不以为然,后来吃了亏才明白。
头文件的标准结构
我建议每个头文件都遵循这个模板:
/*
* module_voltage.h
* 电压采集模块接口定义
* 作者:xxx
* 日期:2024-01-15
*/
#ifndef MODULE_VOLTAGE_H
#define MODULE_VOLTAGE_H
/* 包含必要的头文件 */
#include "bms_types.h"
/* 宏定义 */
#define VOLTAGE_MAX_CELLS 16
#define VOLTAGE_OV_THRESHOLD 4200 /* mV */
/* 类型定义 */
typedef struct {
uint16_t cell_voltage[VOLTAGE_MAX_CELLS];
uint8_t cell_count;
uint16_t pack_voltage;
} VoltageData_t;
/* 函数声明 */
BMS_Status_t Voltage_Init(void);
BMS_Status_t Voltage_ReadAll(VoltageData_t *p_data);
BMS_Status_t Voltage_GetCell(uint8_t cell_index, uint16_t *p_voltage);
#endif /* MODULE_VOLTAGE_H */
这里有几个关键点:
- 头文件保护宏:必须用#ifndef/#define/#endif,防止重复包含。命名规则我习惯用「模块名_H」。
- 只放声明,不放定义:函数实现放在.c里,变量定义也放在.c里。头文件里只放extern声明。
- 最小包含原则:头文件里只包含它真正需要的头文件。别图省事把整个工程的头文件都include进来。
小技巧:我习惯在头文件里加一个「使用说明」的注释块,告诉调用者这个模块怎么用、有什么注意事项。别小看这几行注释,能省下不少沟通成本。
源文件的组织规范
源文件里,我一般按这个顺序组织:
/*
* module_voltage.c
* 电压采集模块实现
*/
/* 1. 包含头文件 */
#include "module_voltage.h"
#include "module_adc.h"
#include "bms_cfg.h"
/* 2. 模块内部宏定义 */
#define VOLTAGE_SAMPLE_DELAY_MS 10
/* 3. 模块内部类型定义 */
typedef enum {
VOLTAGE_IDLE,
VOLTAGE_SAMPLING,
VOLTAGE_DONE
} VoltageState_t;
/* 4. 静态全局变量(模块内部使用) */
static VoltageData_t s_voltage_data;
static VoltageState_t s_state = VOLTAGE_IDLE;
/* 5. 静态函数声明(模块内部函数) */
static BMS_Status_t Voltage_ConvertRawToMv(uint16_t raw, uint16_t *p_mv);
/* 6. 函数实现 */
BMS_Status_t Voltage_Init(void)
{
/* 实现代码 */
}
static BMS_Status_t Voltage_ConvertRawToMv(uint16_t raw, uint16_t *p_mv)
{
/* 实现代码 */
}
为什么要这么严格?因为规范一旦建立,团队里每个人写的代码风格都一样,读起来不费劲。我曾经在一个项目里接手过别人的代码,那个文件里变量定义、函数实现、宏定义混在一起,找半天找不到想要的东西。从那以后,我对自己团队的要求就是:格式必须统一。
全局变量与静态变量使用规范:能不用就不用
说到全局变量,我得先泼一盆冷水:全局变量是万恶之源。这不是危言耸听。在BMS这种对安全性要求极高的系统里,一个全局变量被意外修改,可能导致电池过充、过放,甚至起火。
全局变量的三大罪状
- 谁都能改:任何函数、任何线程都能修改全局变量,出了问题你根本不知道是谁干的。
- 依赖隐式:模块之间通过全局变量耦合,改一个模块可能引发连锁反应。
- 测试困难:全局变量让单元测试变得极其困难,因为你得模拟各种状态。
警告:我曾经在一个项目里排查过一个bug,花了整整三天。最后发现是一个中断服务函数里修改了一个全局变量,而主循环里另一个模块依赖这个变量做决策。这种问题,你靠肉眼很难看出来。
什么时候可以用全局变量?
说实话,完全禁止全局变量也不现实。我个人的原则是:
- 系统级配置:比如系统时钟频率、版本号这些,可以用const修饰的全局常量。
- 硬件寄存器映射:这个没办法,必须用全局地址访问。
- 模块内部状态:用static修饰,限制在本文件内。
看个例子:
/* 可以接受的全局变量 */
extern const uint32_t g_sys_clk_hz; /* 系统时钟,只读 */
extern const char g_version[]; /* 版本号,只读 */
/* 不推荐的全局变量 */
uint16_t g_adc_raw_value; /* 谁都能改,危险! */
uint8_t g_battery_status; /* 多个模块依赖,耦合严重 */
静态变量的正确用法
静态变量(static)是我比较推荐的方式。它把变量的作用域限制在当前文件内,相当于「文件级别的私有变量」。
/* module_balance.c */
static uint8_t s_balance_enabled = 0; /* 均衡使能标志 */
static uint16_t s_balance_current_ma; /* 均衡电流 */
/* 通过接口函数访问,而不是直接暴露变量 */
void Balance_SetEnabled(uint8_t enable)
{
s_balance_enabled = enable;
}
uint8_t Balance_IsEnabled(void)
{
return s_balance_enabled;
}
你想想看,这样设计的好处是什么?外部模块只能通过Balance_SetEnabled和Balance_IsEnabled这两个函数来操作均衡状态,内部怎么实现、变量怎么存储,外部完全不用关心。这就是封装。
断言与错误处理:别让bug悄悄溜走
做嵌入式开发,我最怕的一种情况是:程序跑飞了,但没有任何提示。你不知道是哪里出了问题,只能一点一点加打印、加断点。浪费时间不说,还容易漏掉。
断言(assert)和错误处理,就是用来解决这个问题的。
断言:早发现、早治疗
断言的作用很简单:检查「不应该发生」的情况。比如函数参数不能为NULL,数组下标不能越界,这些都可以用断言来检查。
#include <assert.h>
void Voltage_GetCell(uint8_t cell_index, uint16_t *p_voltage)
{
/* 参数检查:不应该发生的情况 */
assert(cell_index < VOLTAGE_MAX_CELLS);
assert(p_voltage != NULL);
/* 正常处理逻辑 */
*p_voltage = s_voltage_data.cell_voltage[cell_index];
}
断言在调试阶段非常有用。一旦条件不满足,程序会立即停止,并告诉你哪个文件、哪一行出了问题。但要注意:断言只在调试版本中生效。发布版本里,assert宏会被定义为空。
重要提醒:断言不能替代错误处理。断言检查的是「程序内部逻辑错误」,而不是「外部输入错误」。比如用户传了一个非法参数,这是外部错误,应该用返回值或错误码来处理,而不是用断言。
错误处理:优雅地应对异常
BMS系统里,错误处理是重中之重。电池过压、欠压、过温、通信失败……每一种异常都要有对应的处理策略。
我习惯用枚举定义错误码:
/* bms_types.h */
typedef enum {
BMS_OK = 0,
BMS_ERR_PARAM, /* 参数错误 */
BMS_ERR_TIMEOUT, /* 超时 */
BMS_ERR_OVERVOLTAGE, /* 过压 */
BMS_ERR_UNDERVOLTAGE, /* 欠压 */
BMS_ERR_OVERTEMP, /* 过温 */
BMS_ERR_COMM_FAIL, /* 通信失败 */
BMS_ERR_HARDWARE /* 硬件错误 */
} BMS_Status_t;
每个函数都返回状态码,调用者必须检查:
BMS_Status_t Voltage_ReadAll(VoltageData_t *p_data)
{
BMS_Status_t status;
if (p_data == NULL) {
return BMS_ERR_PARAM;
}
status = ADC_StartConversion();
if (status != BMS_OK) {
return BMS_ERR_HARDWARE;
}
/* 等待转换完成,带超时处理 */
uint32_t timeout = 1000;
while (!ADC_IsConversionDone()) {
if (--timeout == 0) {
return BMS_ERR_TIMEOUT;
}
}
/* 读取数据 */
for (uint8_t i = 0; i < VOLTAGE_MAX_CELLS; i++) {
p_data->cell_voltage[i] = ADC_GetValue(i);
}
return BMS_OK;
}
/* 调用者必须检查返回值 */
void MainTask(void)
{
VoltageData_t data;
BMS_Status_t ret = Voltage_ReadAll(&data);
if (ret != BMS_OK) {
/* 处理错误:记录日志、进入安全模式、报警等 */
Safety_EnterSafeMode();
Log_Error("Voltage read failed: %d", ret);
}
}
这里有个细节:不要吞掉错误。我见过有人写代码,调用函数后根本不检查返回值,或者检查了但只是打印一下日志就继续往下走。这种做法在BMS里是致命的。电池管理系统里,任何一个错误都可能引发安全事故。
我的经验:在BMS里,我习惯把错误处理分成三个等级:
- 警告级:不影响当前功能,记录日志即可
- 错误级:当前功能失败,需要重试或降级处理
- 致命级:系统必须立即进入安全模式,比如切断继电器
每个模块都要明确自己的错误等级和处理策略。
断言 vs 错误处理:什么时候用哪个?
| 场景 | 使用断言 | 使用错误处理 |
|---|---|---|
| 函数参数为NULL(内部调用) | ✅ 应该用断言 | ❌ |
| 函数参数为NULL(外部接口) | ❌ | ✅ 应该返回错误码 |
| 数组下标越界 | ✅ 调试阶段检查 | ❌ 不应该发生 |
| ADC采集超时 | ❌ | ✅ 外部异常,需要处理 |
| 硬件寄存器写入失败 | ❌ | ✅ 硬件故障,需要处理 |
说白了,断言是给开发者看的,错误处理是给系统看的。断言帮你尽早发现代码里的逻辑错误,错误处理帮系统在异常情况下还能安全运行。两者缺一不可。
嗯,关于模块化设计、头文件组织、全局变量和错误处理,今天就聊这么多。这些规范看起来琐碎,但真正坚持下来,你会发现代码质量提升不止一个档次。下一章我们聊聊代码审查和静态分析工具,那又是另一个有意思的话题了。