第三章:软件架构设计原则
好,咱们今天聊聊软件架构的核心原则。说实话,我见过太多售货机项目,代码写得跟一团乱麻似的。出了问题,没人敢动,一动就崩。为什么会这样?说白了,就是架构没搭好。
我个人习惯,在动手写第一行代码之前,先把架构原则想清楚。这就像盖房子,你得先有图纸,不能边盖边想。今天我把四个最核心的原则掰开揉碎了讲给你听。
一、分层架构:HAL/OS/APP
分层架构,这是嵌入式软件的基石。我把它分成三层:硬件抽象层(HAL)、操作系统层(OS)、应用层(APP)。
为什么要分层? 你想想看,如果所有代码都混在一起,换个MCU你得重写整个项目。我在项目中遇到过,客户临时要求换主控芯片,从STM32换成GD32。因为用了HAL层,我只改了底层驱动,上层代码一行没动。嗯,这就是分层的好处。
| 层级 | 职责 | 典型内容 |
|---|---|---|
| HAL层 | 屏蔽硬件差异 | GPIO、UART、I2C、SPI驱动 |
| OS层 | 任务调度、资源管理 | FreeRTOS、任务创建、信号量 |
| APP层 | 业务逻辑 | 售货流程、支付逻辑、UI界面 |
核心原则: 上层只能调用下层,不能反向依赖。APP层永远不能直接操作寄存器,必须通过HAL层。
代码示例,看看HAL层怎么封装:
// HAL层:硬件抽象
void HAL_Motor_Start(uint8_t motor_id) {
switch(motor_id) {
case 1: GPIO_SetPin(MOTOR1_PIN, HIGH); break;
case 2: GPIO_SetPin(MOTOR2_PIN, HIGH); break;
default: break;
}
}
// APP层:业务逻辑
void Vending_DeliverProduct(uint8_t slot_id) {
HAL_Motor_Start(slot_id); // 只调用HAL接口
// 不用关心底层是GPIO还是I2C扩展
}
我的小技巧: 定义HAL接口时,函数名统一用 HAL_ 前缀。这样一眼就能看出哪些是底层接口,方便后期维护。
二、模块化设计
模块化,说白了就是「高内聚、低耦合」。每个模块只做一件事,并且把它做好。
我曾经接手过一个售货机项目,一个.c文件写了3000多行。里面有电机控制、有支付逻辑、还有UI刷新。改一个bug,牵出三个新bug。后来我花了整整一周,把它拆成十几个小模块。嗯,从那以后,我再也不敢把代码写成一锅粥了。
模块划分的原则:
- 按功能划分: 电机模块、支付模块、显示模块、网络模块
- 每个模块一个.c和一个.h: motor.c / motor.h
- 模块间通过接口通信: 不要直接访问其他模块的全局变量
看个例子,模块接口应该怎么设计:
// motor.h - 电机模块接口
#ifndef __MOTOR_H__
#define __MOTOR_H__
// 初始化电机模块
void Motor_Init(void);
// 启动指定货道的电机
void Motor_Start(uint8_t channel);
// 停止所有电机
void Motor_StopAll(void);
// 获取电机状态
uint8_t Motor_GetStatus(uint8_t channel);
#endif
注意: 模块的.h文件只暴露必要的接口。内部使用的函数和变量,用 static 关键字隐藏起来。这叫「信息隐藏」,是模块化的精髓。
三、接口隔离原则
接口隔离原则,听起来高大上,其实意思很简单:不要强迫调用方依赖它不需要的接口。
我举个例子你就明白了。假设你有一个支付模块,支持现金支付和扫码支付。如果你把两个功能塞到一个接口里:
// 不好的设计:一个接口包含所有功能
typedef struct {
void (*CashPay)(int amount);
void (*ScanPay)(int amount);
void (*CashBack)(int amount); // 扫码支付不需要找零
} PaymentInterface;
扫码支付模块被迫实现了 CashBack 函数,虽然它根本用不到。这就是接口污染。
正确的做法: 拆分成两个小接口。
// 好的设计:接口隔离
typedef struct {
void (*Pay)(int amount);
void (*Back)(int amount);
} CashPayment;
typedef struct {
void (*Pay)(int amount);
} ScanPayment;
核心思想: 接口要小而专。一个接口只服务于一个客户端。这样修改一个接口,不会影响到其他模块。
四、状态机设计模式
售货机天生就是一个状态机。空闲、选货、支付、出货、找零...每个状态都有特定的行为和转换条件。
我刚开始做售货机时,用了一堆 if-else 来管理状态。代码长什么样?大概是这样:
// 糟糕的状态管理
if (state == IDLE) {
if (button_pressed) state = SELECTING;
} else if (state == SELECTING) {
if (product_selected) state = PAYING;
} else if (state == PAYING) {
if (payment_ok) state = DELIVERING;
}
// ... 越来越长,越来越乱
这种写法,状态一多就完蛋。我曾经调试一个bug,花了三天才发现是某个状态转换条件写错了。嗯,从那以后,我改用正式的状态机模式。
推荐的结构:
// 状态枚举
typedef enum {
STATE_IDLE,
STATE_SELECTING,
STATE_PAYING,
STATE_DELIVERING,
STATE_ERROR
} VendingState;
// 状态处理函数指针
typedef VendingState (*StateHandler)(void);
// 状态表
StateHandler state_table[] = {
[STATE_IDLE] = Idle_Handler,
[STATE_SELECTING] = Selecting_Handler,
[STATE_PAYING] = Paying_Handler,
[STATE_DELIVERING] = Delivering_Handler,
[STATE_ERROR] = Error_Handler
};
// 主循环
VendingState current_state = STATE_IDLE;
while(1) {
current_state = state_table[current_state]();
}
我的经验: 每个状态处理函数只做三件事:1) 处理当前状态的事件 2) 执行状态动作 3) 返回下一个状态。这样每个函数都很短,容易测试。
状态机的好处很明显:
- 可读性强: 状态转换一目了然
- 易于调试: 出问题可以定位到具体状态
- 扩展性好: 加一个新状态,只需要加一个处理函数
避坑指南: 我曾经犯过一个错误,在状态处理函数里调用了延时函数。结果整个系统卡住了,因为延时期间无法响应其他事件。记住:状态处理函数要快进快出,不能阻塞。
好了,这四个原则讲完了。分层架构让代码结构清晰,模块化让功能独立,接口隔离让依赖简单,状态机让逻辑可控。把这四个原则用好,你的售货机软件架构就稳了。
下一章,咱们聊聊具体怎么搭建开发环境。到时候我会分享一些我踩过的坑,保证让你少走弯路。