第二章 系统总体架构设计:分层架构思想
好,咱们正式开始搭架子了。
做点钞机软件,最怕什么?最怕写到后面,改一个传感器驱动,结果把验钞逻辑搞崩了。这种事我在项目里见过不止一次。所以,从一开始,我们就得把架构想清楚。
2.1 为什么要分层?
说白了,分层就是为了解耦。
你想想看,点钞机里有电机、有红外传感器、有磁头、有图像传感器。这些东西的驱动代码,跟验钞算法、跟用户交互逻辑混在一起,那会是什么场面?
我早期做过一个项目,老板催得急,代码全写在一个大文件里。结果呢?换一个电机型号,改了三天,还带出来两个新bug。从那以后,我坚决要求分层。
核心原则:每一层只关心自己的事,不越界。
2.2 三层架构:HAL、OSAL、APP
我们采用经典的三层架构。嗯,这里要注意,不是随便分的,每一层都有明确的职责。
| 层级 | 全称 | 职责 | 典型内容 |
|---|---|---|---|
| HAL | 硬件抽象层 | 屏蔽硬件差异 | GPIO读写、SPI收发、定时器配置 |
| OSAL | 操作系统抽象层 | 提供任务调度、通信机制 | 任务创建、消息队列、信号量 |
| APP | 应用层 | 实现业务逻辑 | 验钞流程、计数统计、人机交互 |
HAL层:我习惯把它做成一个纯接口层。比如读一个传感器值,HAL层只定义int32_t hal_sensor_read(uint8_t ch),具体怎么读,由芯片相关的代码去实现。这样换MCU时,只需要重写HAL层。
OSAL层:这个层很多人会忽略。其实很重要。你想想,今天用FreeRTOS,明天想换RT-Thread,如果没有OSAL,那APP层代码得全改。我建议把任务创建、延时、互斥锁这些操作,都包一层。
// OSAL 接口示例
typedef void* osal_task_handle_t;
osal_task_handle_t osal_task_create(const char* name,
void (*entry)(void*),
void* param,
uint32_t stack_size,
uint32_t priority);
void osal_task_delay(uint32_t ms);
osal_status_t osal_mutex_create(osal_mutex_t* mutex);
osal_status_t osal_mutex_take(osal_mutex_t* mutex, uint32_t timeout);
osal_status_t osal_mutex_give(osal_mutex_t* mutex);
APP层:这一层只关心业务。比如“收到启动信号 -> 启动电机 -> 等待纸币进入 -> 启动传感器采集 -> 调用验钞算法 -> 输出结果”。它不关心电机是PWM控制的还是IO控制的,也不关心传感器是SPI还是I2C。
我的习惯:APP层代码里,不允许出现任何寄存器操作。如果看到了,那一定是架构设计出了问题。
2.3 模块划分原则
模块怎么切?我总结了几条原则,都是踩坑踩出来的。
- 高内聚、低耦合:一个模块只做一件事。比如“电机控制模块”就只管电机启停和速度,“传感器采集模块”就只管数据读取。
- 接口清晰:模块之间的交互,通过定义好的接口函数完成。不要直接访问对方的全局变量。
- 可替换性:任何一个模块,都应该能被替换掉而不影响其他模块。比如换一个型号的电机驱动芯片,只需要改“电机控制模块”内部实现,接口不变。
- 单一职责:如果一个模块的代码超过1000行,或者它要干三件以上不同的事,那就该拆分了。
我曾经接手过一个项目,有个模块叫“utils.c”,里面什么都有:CRC计算、字符串处理、延时函数、LED闪烁控制……后来维护的人想死的心都有。所以,模块命名要准确,别搞“万能模块”。
2.4 接口定义规范
接口是架构的骨架。接口定义得好,后面写代码就像搭积木。
我一般这样定规范:
- 命名风格:模块名_动作_对象。比如
motor_start()、sensor_read_temperature()。 - 参数规范:输入参数用值传递,输出参数用指针。参数数量尽量不超过4个。
- 返回值规范:统一返回状态码。成功返回0,失败返回负值错误码。
- 头文件规范:每个模块一个头文件,头文件里只放接口声明,不放实现细节。
// 接口定义示例
// motor.h
#ifndef __MOTOR_H__
#define __MOTOR_H__
#include "osal.h"
typedef enum {
MOTOR_DIR_CW, // 顺时针
MOTOR_DIR_CCW // 逆时针
} motor_dir_t;
int32_t motor_init(void);
int32_t motor_start(motor_dir_t dir, uint32_t speed_rpm);
int32_t motor_stop(void);
int32_t motor_get_speed(uint32_t* speed_rpm);
#endif
注意:接口一旦定义好并开始使用,尽量不要修改。如果必须改,要通知所有相关方,并做好版本管理。我曾经因为改了一个接口参数类型,导致三个模块的代码都要同步修改,折腾了一整天。
2.5 数据流与状态机设计
点钞机的核心,其实就是数据流加状态机。
数据流:纸币从进钞口到出钞口,数据是怎么流动的?
- 传感器检测到纸币进入
- 触发采集信号,启动ADC/DMA读取磁头、红外、图像数据
- 数据经过预处理(滤波、归一化)
- 送入验钞算法模块,进行真伪识别、面额识别
- 结果输出到计数模块和显示模块
- 控制电机动作(继续走钞或退钞)
这个流程里,每个环节都是独立的模块,通过消息队列传递数据。我习惯用OSAL的消息队列来做模块间通信,这样每个模块都是异步的,不会互相阻塞。
状态机:点钞机的工作状态,用状态机来描述最合适。
// 点钞机主状态机
typedef enum {
STATE_IDLE, // 空闲
STATE_STARTING, // 启动中
STATE_RUNNING, // 运行中
STATE_JAM, // 卡钞
STATE_ERROR, // 错误
STATE_STOPPING // 停止中
} machine_state_t;
// 状态转移示例
void machine_state_machine(void) {
switch (current_state) {
case STATE_IDLE:
if (start_button_pressed()) {
set_state(STATE_STARTING);
}
break;
case STATE_STARTING:
if (motor_reach_target_speed()) {
set_state(STATE_RUNNING);
} else if (timeout_occurred()) {
set_state(STATE_ERROR);
}
break;
case STATE_RUNNING:
if (jam_detected()) {
set_state(STATE_JAM);
} else if (stop_button_pressed()) {
set_state(STATE_STOPPING);
}
break;
// ... 其他状态处理
}
}
状态机的好处是,逻辑清晰,容易调试。每个状态做什么事,什么条件下跳转到什么状态,一目了然。我调试点钞机时,经常把状态值通过串口打印出来,看状态跳转是否符合预期。
避坑指南:状态机里一定要有超时处理。我曾经遇到过,电机启动时卡住了,状态一直卡在STATE_STARTING,系统死在那。后来加了超时,超过2秒没达到目标速度,自动跳转到STATE_ERROR,并报警。
2.6 小结
这一章我们讲了分层架构的核心思想。HAL管硬件、OSAL管系统、APP管业务。模块划分要内聚、接口要清晰、数据流和状态机要设计好。
这些听起来可能有点抽象,但等你真正开始写代码时,就会发现,好的架构能让你少走很多弯路。下一章,我们开始动手写HAL层的代码。