第三章:软件架构设计原则

好,咱们今天聊聊软件架构的核心原则。说实话,我见过太多售货机项目,代码写得跟一团乱麻似的。出了问题,没人敢动,一动就崩。为什么会这样?说白了,就是架构没搭好。

我个人习惯,在动手写第一行代码之前,先把架构原则想清楚。这就像盖房子,你得先有图纸,不能边盖边想。今天我把四个最核心的原则掰开揉碎了讲给你听。

一、分层架构: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) 返回下一个状态。这样每个函数都很短,容易测试。

状态机的好处很明显:

  • 可读性强: 状态转换一目了然
  • 易于调试: 出问题可以定位到具体状态
  • 扩展性好: 加一个新状态,只需要加一个处理函数

避坑指南: 我曾经犯过一个错误,在状态处理函数里调用了延时函数。结果整个系统卡住了,因为延时期间无法响应其他事件。记住:状态处理函数要快进快出,不能阻塞。

好了,这四个原则讲完了。分层架构让代码结构清晰,模块化让功能独立,接口隔离让依赖简单,状态机让逻辑可控。把这四个原则用好,你的售货机软件架构就稳了。

下一章,咱们聊聊具体怎么搭建开发环境。到时候我会分享一些我踩过的坑,保证让你少走弯路。