模块化设计原则:别让代码变成一团乱麻

做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这种对安全性要求极高的系统里,一个全局变量被意外修改,可能导致电池过充、过放,甚至起火。

全局变量的三大罪状

  1. 谁都能改:任何函数、任何线程都能修改全局变量,出了问题你根本不知道是谁干的。
  2. 依赖隐式:模块之间通过全局变量耦合,改一个模块可能引发连锁反应。
  3. 测试困难:全局变量让单元测试变得极其困难,因为你得模拟各种状态。

警告:我曾经在一个项目里排查过一个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采集超时 ✅ 外部异常,需要处理
硬件寄存器写入失败 ✅ 硬件故障,需要处理

说白了,断言是给开发者看的,错误处理是给系统看的。断言帮你尽早发现代码里的逻辑错误,错误处理帮系统在异常情况下还能安全运行。两者缺一不可。

嗯,关于模块化设计、头文件组织、全局变量和错误处理,今天就聊这么多。这些规范看起来琐碎,但真正坚持下来,你会发现代码质量提升不止一个档次。下一章我们聊聊代码审查和静态分析工具,那又是另一个有意思的话题了。