第三章 架构设计原则:模块化与解耦、接口标准化、可配置化设计
各位同行,咱们直接切入正题。量产阶段的软件架构,跟原型机那套玩法完全是两码事。原型机你只要能跑通就行,代码写得再乱,大不了重启一下。但量产不一样——成千上万台机器铺出去,每台的传感器偏差、马达特性、Wi-Fi信号强度都不一样。这时候架构设计要是没做好,那就是灾难。
我个人习惯,在项目启动阶段就把架构原则定死。说白了,就是三个关键词:模块化与解耦、接口标准化、可配置化设计。这三板斧砍下去,后面量产阶段的坑能少踩一半。
3.1 模块化与解耦:别让代码长成“毛线球”
我在项目中遇到过最头疼的事,就是接手一个“毛线球”项目——传感器采集、路径规划、电机控制、UI显示全揉在一个文件里,改一行代码要牵动七八个模块。这种代码,量产阶段根本没法维护。
模块化的核心是什么?高内聚、低耦合。每个模块只干一件事,模块之间通过定义好的接口通信,谁也不依赖谁的具体实现。
举个例子,扫地机器人的传感器模块:
// 传感器模块接口定义
typedef struct {
int32_t (*get_bumper_status)(void);
int32_t (*get_cliff_sensor)(uint8_t index);
int32_t (*get_gyro_angle)(void);
int32_t (*get_odometry)(int32_t *x, int32_t *y);
} sensor_if_t;
// 路径规划模块只调用接口,不关心底层实现
void navigation_task(void *param) {
sensor_if_t *sensor = (sensor_if_t *)param;
while(1) {
int32_t bumper = sensor->get_bumper_status();
// 处理逻辑...
}
}
你看,路径规划模块根本不知道传感器是GPIO读的、I2C读的还是SPI读的。它只认这个接口结构体。哪天你要换传感器硬件,只需要重新实现这个接口,路径规划模块一行代码都不用改。
量产实战要点:
- 每个模块的代码量控制在2000行以内,超过就考虑拆分
- 模块间通信必须通过接口函数,禁止直接访问全局变量
- 每个模块要有独立的头文件,只暴露必要的API
3.2 接口标准化:CAN和MQTT,两种场景两种玩法
接口标准化这件事,我吃过不少亏。早期做的一款产品,传感器模块用UART,电机模块用I2C,Wi-Fi模块用SPI,每个接口的协议格式都不一样。结果呢?联调的时候天天出问题,不是字节序搞反了,就是超时时间没对齐。
量产阶段,我强烈建议把接口标准化做死。这里重点说两个:CAN总线和MQTT。
3.4.1 CAN总线:底盘与传感器通信的首选
扫地机器人内部,底盘控制、传感器数据、电池管理这些模块,用CAN总线最合适。为什么?实时性好、抗干扰强、而且支持多主通信。
我一般这样定义CAN报文格式:
| 报文ID | 数据域(8字节) | 说明 |
|---|---|---|
| 0x100 | Byte0: 左轮速度 Byte1: 右轮速度 Byte2: 控制模式 |
电机控制指令 |
| 0x200 | Byte0-1: 电池电压(mV) Byte2: 电量百分比 Byte3: 充电状态 |
电池状态上报 |
| 0x300 | Byte0: 碰撞传感器状态 Byte1: 悬崖传感器状态 Byte2-3: 陀螺仪角度 |
传感器数据上报 |
嗯,这里要注意:报文ID的分配要有层次。我习惯用高4位表示模块类型,低4位表示具体指令。这样调试的时候,看ID就知道是哪个模块在通信。
3.4.2 MQTT:云端与App通信的标配
至于跟云端和手机App的通信,MQTT几乎是行业标准了。为什么?轻量、支持QoS、而且有发布/订阅机制,非常适合扫地机器人这种需要实时状态同步的场景。
我建议这样设计Topic结构:
// 设备状态上报
robot/{device_id}/status
{
"battery": 85,
"mode": "cleaning",
"error_code": 0,
"position": {"x": 1.2, "y": 3.4}
}
// 远程控制指令
robot/{device_id}/command
{
"cmd": "start_cleaning",
"params": {"mode": "edge", "suction": "high"}
}
// OTA升级通知
robot/{device_id}/ota
{
"version": "2.1.0",
"url": "http://...",
"md5": "a1b2c3..."
}
避坑指南:我曾经因为Topic设计得太随意,导致量产后的设备跟云端通信老是丢数据。后来统一了Topic命名规范,所有Topic都按"设备类型/设备ID/功能域"的格式来,问题就解决了。另外,MQTT的QoS级别建议用1,既保证至少一次送达,又不会像QoS2那样增加太多开销。
3.3 可配置化设计:参数化与宏定义
量产阶段最怕什么?每台机器的硬件参数都不一样。比如轮子直径,理论上都是100mm,但实际生产出来可能是99.8mm到100.2mm之间。如果代码里写死了轮径,那每台机器跑出来的里程都不准。
可配置化设计就是解决这个问题的。我把它分成两个层次:编译时配置和运行时配置。
3.3.1 编译时配置:用宏定义搞定硬件差异
对于硬件版本、传感器型号这些在出厂前就确定的东西,用宏定义最合适。
// hardware_config.h
// 硬件版本定义
#define HW_VERSION_A 1
#define HW_VERSION_B 2
// 传感器型号选择
#define SENSOR_LIDAR_RPLIDAR_A1 0
#define SENSOR_LIDAR_SLAMTEC_R2 1
// 根据硬件版本选择配置
#if (HW_VERSION == HW_VERSION_A)
#define WHEEL_DIAMETER_MM 100.0f
#define WHEEL_BASE_MM 280.0f
#define BATTERY_CAPACITY_MAH 5200
#define SENSOR_LIDAR_TYPE SENSOR_LIDAR_RPLIDAR_A1
#elif (HW_VERSION == HW_VERSION_B)
#define WHEEL_DIAMETER_MM 102.0f
#define WHEEL_BASE_MM 290.0f
#define BATTERY_CAPACITY_MAH 6000
#define SENSOR_LIDAR_TYPE SENSOR_LIDAR_SLAMTEC_R2
#endif
你想想看,这样设计的好处是什么?产线只需要在编译时指定HW_VERSION,就能生成对应硬件版本的固件。不用维护多套代码,也不用担心搞错配置。
3.3.2 运行时配置:参数化让调试更灵活
对于用户偏好、工作模式这些需要动态调整的东西,我习惯用参数化配置。把配置项放到Flash或EEPROM里,运行时读取。
// 运行时参数结构体
typedef struct {
uint8_t suction_power; // 吸力等级 1-5
uint8_t water_flow; // 拖地水量 1-3
uint8_t cleaning_mode; // 清扫模式
uint16_t edge_clean_time_s; // 沿边清扫时间(秒)
uint16_t return_charge_time_s; // 回充超时时间(秒)
uint8_t language; // 语音语言
uint8_t volume; // 音量 0-100
} runtime_config_t;
// 默认配置
const runtime_config_t default_config = {
.suction_power = 3,
.water_flow = 2,
.cleaning_mode = MODE_AUTO,
.edge_clean_time_s = 300,
.return_charge_time_s = 120,
.language = LANG_CHINESE,
.volume = 70
};
注意:运行时配置一定要做校验。我曾经遇到过一台机器,用户把吸力等级设成了6(合法范围是1-5),结果电机过载烧了。从那以后,所有配置项写入前都要做范围检查,非法值直接恢复默认。
3.4 三个原则的协同效应
模块化、接口标准化、可配置化,这三个原则不是孤立的。它们配合起来,效果是1+1+1>3。
举个例子:你要给扫地机器人增加一个“地毯识别”功能。
- 模块化:新增一个carpet_detector模块,只负责检测地毯
- 接口标准化:通过CAN总线发布地毯检测结果,其他模块订阅这个数据
- 可配置化:地毯检测的灵敏度、触发阈值都做成参数,不同机型可以调
你看,这样设计,新增功能几乎不影响现有代码。量产阶段,这种扩展性太重要了。
总结一下我的经验:
- 模块化是骨架,决定了代码好不好维护
- 接口标准化是血管,决定了数据流不流得通
- 可配置化是肌肉,决定了产品适不适应不同场景
这三样做好了,量产阶段的软件工程化就成功了一大半。
好了,这一章就聊到这儿。下一章咱们讲讲状态机设计——扫地机器人这种多状态切换的产品,状态机设计不好,bug能让你改到怀疑人生。