第二章 系统总体架构设计:分层架构思想

好,咱们正式开始搭架子了。

做点钞机软件,最怕什么?最怕写到后面,改一个传感器驱动,结果把验钞逻辑搞崩了。这种事我在项目里见过不止一次。所以,从一开始,我们就得把架构想清楚。

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 数据流与状态机设计

点钞机的核心,其实就是数据流加状态机。

数据流:纸币从进钞口到出钞口,数据是怎么流动的?

  1. 传感器检测到纸币进入
  2. 触发采集信号,启动ADC/DMA读取磁头、红外、图像数据
  3. 数据经过预处理(滤波、归一化)
  4. 送入验钞算法模块,进行真伪识别、面额识别
  5. 结果输出到计数模块和显示模块
  6. 控制电机动作(继续走钞或退钞)

这个流程里,每个环节都是独立的模块,通过消息队列传递数据。我习惯用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层的代码。