嵌入式单片机裸机C++软件架构设计

文章来源声明: 原文作者:wwx_03; 来源站点:掘金; 原文链接:https://juejin.cn/post/7703423062712664114; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合单板裸机项目搭建可移植骨架,能约束AI不预埋外设,并降低换MCU成本;硬实时微秒级场景需谨慎。

嵌入式单片机裸机C++软件架构设计 -----------------

不同 MCU 厂商的启动文件、HAL、目录名、句柄变量和回调名都不一样;但 main/ 里的自有代码架构可以完全一样。

配套规则:mcu-baremetal-event-loop Agent Skill。本文讲这套架构为什么这样设计,以及换 MCU 时到底换什么。

仓库地址:wwx-03/non-os-mcu-cpp-utility;适用于VSCode的AI SKILL,引用SKILL.md文件让AI为你生成软件框架,方便管理工程。


一、固定架构:一个 main,四类自有文件

所有工程统一成这一种结构:

<project>/
├── main/                       # 自有代码的唯一根目录
│   ├── application.hpp
│   ├── application.cpp
│   ├── mcu_cmsis.h             # utility 的移植点
│   ├── board/                  # 每个工程必须有
│   │   ├── board.hpp
│   │   └── board.cpp
│   └── <periph>/               # 可选,一外设一目录
│       └── <periph>.hpp
├── utility/                    # header-only:Queue / StreamBuffer / Timer / IrqLock
├── <厂商生成目录>/              # Core/、Drivers/、Src/、Inc/、startup...
└── <IDE 工程目录>/              # EIDE、MDK-ARM、Makefile、CMake...

include 路径要包含 main/、main/board/、每个外设目录(如 main/serial/)和 utility/。Board 里用短名 #include "serial.hpp",前提就是 main/serial/ 在 include 路径里。

只有四类自有文件:

路径职责
`main/application.*`事件号、`QueueItem`、`PostXxx()`、handler、事件循环
`main/board/`持有具体设备对象,暴露 `GetXxx()`,转发厂商弱回调,`Init()` 里 arm 中断
`main//`某个外设的具体驱动,例如 `serial/serial.hpp`、`key/key.hpp`
`main/mcu_cmsis.h`引入目标器件的 CMSIS 头,`utility` 唯一的 MCU 依赖点

main/board/ 是必须的;main/<periph>/ 是可选的。

固定的是骨架,不是外设清单。

一个“刚创建、还没有任何外设”的工程,Board 只需要:

// main/board/board.hpp
#pragma once

class Board {
public:
    static Board &GetInstance() {
        static Board instance;
        return instance;
    }

    Board(const Board &) = delete;
    Board &operator=(const Board &) = delete;

    // 骨架阶段为空;有外设后才在里面 arm
    void Init() {}

private:
    Board() = default;
};

这个工程里没有 serial/、没有 key/、没有 GetSerial(),也没有任何串口事件。它仍然是一个完整、可编译、可运行的固定架构工程。

只有需求里明确出现“串口”“按键”“LED”时,才创建对应目录、Board 成员、GetXxx()、Init() 调用和事件。不要因为框架“看起来应该有”,就预埋外设。


二、职责边界:Board 是边界,不是抽象层

这套架构只有一条依赖主线:

main/application
      │
      ▼
main/board ──► main/<periph> ──► 厂商 HAL / SDK
      │
      ▼
utility(Queue / StreamBuffer / Timer / IrqLock)
      │
      ▼
main/mcu_cmsis.h ──► CMSIS / 器件头

各层职责:

层知道什么不知道什么
`Application`事件号、队列、handler、`Board` 的 `GetXxx()`串口句柄、GPIO 寄存器、HAL 类型
`Board`这块板有哪些设备、句柄从哪来、弱回调怎么路由业务协议、事件号、状态机
`main/`某个外设的寄存器/HAL 细节、缓冲、回调业务、其他外设、事件编号
`utility`固定容量队列、字节流缓冲、软件定时器、临界区具体 MCU、HAL
`mcu_cmsis.h`目标器件的 CMSIS 头业务、Board

这里有两个刻意为之的选择。

1. Board 是具体类,不是抽象接口

不做 IBoard、不做 devices/、不做 create_new_board()、不做虚函数。

原因很简单:这是“一个 main/ 的小裸机工程”,一块板子一份 Board。抽象接口带来的 vtable、生命周期、工厂和文件分层,在这个规模下没有收益。

Board 是访问边界,不是抽象层:

  • 应用层只能通过 Board::GetInstance().GetSerial() 拿设备;
  • 应用层不直接 include serial.hpp;
  • Board 返回具体 Serial<128, 128>*,不是 ISerial*;
  • 换板时重写 Board 和外设驱动,Application 的核心循环不变。

这不是“拒绝抽象”,而是不为假想的多板需求提前抽象。真出现多板、多厂商复用,再重构;单板工程先把结构固定住。

2. 厂商初始化不搬进 main/

时钟、HAL_Init、MX_*_Init、启动文件、链接脚本,仍然由厂商工程负责:

厂商 main()
  ├── HAL_Init()
  ├── SystemClock_Config()
  ├── MX_GPIO_Init()
  ├── MX_USART1_UART_Init()
  └── app_main()   ← 只追加这一行

Board 不重复调用这些初始化。它只做两件事:

  1. 构造设备对象,保存厂商已经初始化好的句柄;
  2. 配置厂商没有覆盖的板级 GPIO,例如 LED、独立按键、片选。

这样厂商生成的代码可以继续保持“可重新生成”,不会被业务代码污染。


三、事件链路:ISR 只投递,Application 统一处理

事件从产生到处理只有一条链路:

任一 ISR(UART / GPIO / TIM / SysTick)
        │
        │ 只做:清标志、搬数据、PostXxx()
        ▼
Application::PostXxx()
        │
        ▼
Queue<QueueItem, N>(IrqLock 保护)
        │
        ▼
Application::Start()
        │  Pop → switch(event) → HandleXxx()
        ▼
Board 暴露的设备 / 业务状态机 / Timer

Application 是全工程唯一的事件循环,也是唯一持有事件队列的地方。

一个零外设的最小版本:

// main/application.hpp
#pragma once

#include <stddef.h>
#include <stdint.h>

#include "queue.hpp"

class Application {
public:
    static Application &GetInstance() {
        static Application instance;
        return instance;
    }

    Application(const Application &) = delete;
    Application &operator=(const Application &) = delete;

    void Start();

private:
    static constexpr size_t QUEUE_LENGTH = 16;
    static constexpr uint32_t START_EVENT = 0x00000001u;

    struct QueueItem {
        uint32_t event;
        union {
            uint32_t value;
        } data;
    };

    Application();
    ~Application() = default;

    void PostEvent(uint32_t e);
    void HandleStart();

    Queue<QueueItem, QUEUE_LENGTH> queue_;
    volatile uint32_t dropped_ = 0;
};

// main/application.cpp
#include "application.hpp"

#include "board.hpp"
#include "irq_lock.hpp"

Application::Application() {
    PostEvent(START_EVENT);
}

void Application::Start() {
    // 有外设时,在这里注册设备回调;必须在 Board::Init() 之前
    Board::GetInstance().Init();

    QueueItem item = {};
    while (true) {
        if (!queue_.Pop(item)) {
            continue;
        }

        switch (item.event) {
        case START_EVENT:
            HandleStart();
            break;
        default:
            break;
        }
    }
}

void Application::PostEvent(uint32_t e) {
    QueueItem item = {};
    item.event = e;

    if (!queue_.Push(item)) {
        IrqLock lock;
        ++dropped_;
    }
}

void Application::HandleStart() {
}

extern "C" void app_main(void) {
    Application::GetInstance().Start();
}

这段代码有几个固定点:

  • START_EVENT 由构造函数投递,用来证明事件循环已经跑起来;
  • Queue 是固定容量、编译期确定,没有堆分配;
  • Push 失败必须计数,dropped_++ 在 IrqLock 内;
  • Start() 永不返回;
  • Board::Init() 只在这里调用一次。

QueueItem 在 32 位 MCU 上通常是 8 字节,16 个事件约 128 字节,链接器会把它放进 .bss;整个队列没有堆分配,容量在编译期确定。

事件是“事实”,不是“命令”

事件名应该描述“发生了什么”,而不是“要做什么”。

例如:

  • SERIAL_RECEIVED_EVENT,不是 DO_ECHO_EVENT;
  • KEY_RELEASED_EVENT,不是 PRINT_KEY_MESSAGE;
  • TIMER_ELAPSED_EVENT,不是 BLINK_LED。

同一个“串口有数据”可以触发回显、协议解析、日志记录;业务变化不应该反过来修改中断和 Board。

负载也只放 POD:字节数、索引、ADC 值。不要放指针,尤其不要放指向中断栈的指针。

handler 必须快速返回

Start() 是串行的。一个 handler 阻塞 50 ms,后面所有事件都等 50 ms。

所以:

  • 不在 handler 里 HAL_Delay、printf、擦 Flash;
  • 长流程拆成状态机,用后续事件或 Timer 推进;
  • 高频事件合并,例如用“可读字节数”代替“每字节一个事件”。

这套架构没有优先级,也没有抢占。它适合“事件处理短、延迟要求以毫秒计”的场景;硬实时、微秒级响应应该交给硬件定时器、DMA 和中断优先级。


四、Board 与可选外设:先空骨架,再按需加

1. 空骨架

空骨架的 Board 已经在第二节出现:只有 GetInstance() 和空的 Init()。

此时:

  • 没有 main/serial/、main/key/、main/led/;
  • 没有 GetSerial()、GetKey()、GetLed();
  • 没有串口事件、按键事件、LED 事件;
  • 工程仍然编译、下载、运行,START_EVENT 命中一次。

这一步是“固定架构”的验收点,也是防止 AI 误加外设的关键。

Board 的固定 API 只有两个:

static Board &GetInstance();
void Init();

GetSerial()、GetKey()、GetLed()、RefreshWatchdog() 都是可选项:本工程用到哪个外设,才出现哪个 getter;没有的就不写。

2. 有串口时

只有需求明确要串口,才创建 main/serial/serial.hpp,并在 Board 里加成员:

// main/board/board.hpp
#pragma once

#include "serial.hpp"

class Board {
public:
    static Board &GetInstance() {
        static Board instance;
        return instance;
    }

    Board(const Board &) = delete;
    Board &operator=(const Board &) = delete;

    Serial<128, 128> *GetSerial();

    void Init();

private:
    Board();

    Serial<128, 128> serial_;
};

// main/board/board.cpp
#include "board.hpp"

#include "usart.h"

Board::Board()
    : serial_(&husart1) {   // 只保存句柄,不在这里 arm
}

Serial<128, 128> *Board::GetSerial() {
    return &serial_;
}

void Board::Init() {
    serial_.Init();         // 现在才 HAL_UART_Receive_IT
}

extern "C" void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance != USART1) {
        return;
    }
    Board::GetInstance().GetSerial()->HandleRxCpltEvent();
}

串口驱动本身是具体类,不是接口:

// main/serial/serial.hpp
#pragma once

#include "py32f0xx_hal.h"

#include <array>
#include <cstring>
#include <tuple>

#include "stream_buffer.hpp"

template <size_t kRxSize, size_t kTxSize>
class Serial {
public:
    using _CharacterCallback = void (*)(size_t, void *);
    using _FullCallback = void (*)(size_t, void *);

    explicit Serial(UART_HandleTypeDef *huart)
        : huart_(huart) {
    }

    void Init() {
        HAL_UART_Receive_IT(huart_, &temp_, sizeof(temp_));
    }

    size_t Read(void *dest, size_t size) {
        return rx_.Read(dest, size);
    }

    size_t Write(const void *src, size_t size) {
        if (size == 0 || size > kTxSize ||
            huart_->gState != HAL_UART_STATE_READY) {
            return 0;
        }
        memcpy(tx_buffer_.data(), src, size);
        return HAL_UART_Transmit_IT(
                   huart_, tx_buffer_.data(),
                   static_cast<uint16_t>(size)) == HAL_OK
                   ? size
                   : 0;
    }

    void HandleRxCpltEvent() {
        const uint8_t byte = temp_;
        const bool stored = rx_.Write(&byte, 1) == 1;

        HAL_UART_Receive_IT(huart_, &temp_, sizeof(temp_));

        if (byte == line_char_ && line_cb_) {
            line_cb_(rx_.Size(), line_ctx_);
        }

        if (!stored && full_cb_) {
            full_cb_(rx_.Size(), full_ctx_);
        }
    }

    void SetSpecificCharactersCallback(char sc, _CharacterCallback cb, void *ctx) {
        line_char_ = sc;
        line_cb_ = cb;
        line_ctx_ = ctx;
    }

    void SetRxBufferFullCallback(_FullCallback cb, void *ctx) {
        full_cb_ = cb;
        full_ctx_ = ctx;
    }

private:
    UART_HandleTypeDef *huart_ = nullptr;
    StreamBuffer<kRxSize> rx_;
    std::array<uint8_t, kTxSize> tx_buffer_ = {};
    uint8_t temp_ = 0;

    char line_char_ = 0;
    _CharacterCallback line_cb_ = nullptr;
    void *line_ctx_ = nullptr;

    _FullCallback full_cb_ = nullptr;
    void *full_ctx_ = nullptr;
};

这里的关键不是这段实现有多完整,而是结构:

  • Serial 构造函数只保存句柄;
  • Serial::Init() 才 HAL_UART_Receive_IT;
  • Board 持有 Serial 成员,Board::Init() 调用它;
  • 应用层只通过 Board::GetSerial() 拿指针,不直接 include serial.hpp。

有串口时,Application::Start() 在 Board::Init() 之前注册回调:

void Application::Start() {
    auto &board = Board::GetInstance();
    auto *serial = board.GetSerial();

    serial->SetSpecificCharactersCallback('\n', +[](size_t size, void *ctx) {
        static_cast<Application *>(ctx)->PostSerialReceivedEvent(size);
    }, this);

    serial->SetRxBufferFullCallback(+[](size_t size, void *ctx) {
        static_cast<Application *>(ctx)->PostSerialReceivedEvent(size);
    }, this);

    board.Init();   // 注册完成之后才 arm 中断

    QueueItem item = {};
    while (true) {
        if (!queue_.Pop(item)) {
            continue;
        }

        switch (item.event) {
        case START_EVENT:
            HandleStart();
            break;
        case SERIAL_RECEIVED_EVENT:
            HandleSerialReceived(item);
            break;
        default:
            break;
        }
    }
}

这段代码假设 Application 里已经增加了 SERIAL_RECEIVED_EVENT、PostSerialReceivedEvent() 和 HandleSerialReceived();没有串口时整块删除:不创建 serial/,不声明 GetSerial(),不加串口事件,也不注册串口回调。

3. 有按键、LED、Timer 时

同样的模式:

  • 按键:main/key/key.hpp,Board 持有 Key 成员,Board::Init() 里启动采样;
  • LED:main/led/led.hpp,Board 持有 Led 成员,Board::Init() 里设置初值;
  • Timer:谁持有任务,谁做成员;Create() 在构造函数,Start() 在 Init() 或 HandleStart();在厂商已有的 1 ms tick 里追加一次 Timer::OnMilliSecondTick(),不要新建第二个 tick 源。

没有对应需求,就不创建这些文件、成员和事件。

“框架固定”不等于“外设固定”。 固定的是 main/ 结构、Board 边界、事件链路、初始化顺序和代码规范;外设是插件。


五、启动顺序:两阶段初始化

这套架构的启动顺序是固定的:

厂商 main()
  ├── HAL_Init / 时钟 / MX_*_Init        ← 厂商代码
  └── app_main()
        ├── Application 构造
        │     ├── 创建 Timer(如果有)
        │     └── PostEvent(START_EVENT)
        └── Application::Start()
              ├── ① 注册设备回调
              ├── ② Board::Init()       ← 此刻才 arm 中断
              └── ③ 进入事件循环

对应到代码:

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART1_UART_Init();   /* 有串口才需要 */

    app_main();              /* C++ 入口,只加这一行 */

    while (1) {
    }
}

extern "C" void app_main(void) {
    Application::GetInstance().Start();
}

为什么回调注册必须早于 Board::Init()?

  • Board::Init() 一返回,中断就可能到达;
  • 如果回调还没注册,第一帧数据/第一次按键就会丢;
  • 如果 Board/Serial 还在构造中,中断回调再次进入 Board::GetInstance(),还可能触发静态初始化重入。

这条顺序是架构的一部分,换 MCU 也不变。

空骨架时 Board::Init() 是空的,但顺序仍然成立:注册(没有设备可注册)→ Init() → 循环。以后加外设,只是往这两个位置填内容,不改变顺序。


六、换 MCU 时,到底换什么

换 MCU 时,厂商代码一定会变:

  • CMSIS 器件头不同;
  • UART 句柄变量名不同,例如 husart1 / huart1;
  • 弱回调名不同,按 HAL 头里的实际声明;
  • 1 ms tick 的位置不同;
  • IDE 工程文件不同,EIDE / Keil / Makefile / CMake 各有各的格式。

但你的架构可以不变:

项目换 MCU 时
`main/application.*`核心循环不变;新增外设时只加事件和 handler
`main/board/`重写成员、句柄注入、GPIO 配置、弱回调转发
`main//`按新厂商 HAL 重写具体驱动
`main/mcu_cmsis.h`只换器件头 include
`utility/`不动
厂商目录重新生成,不手改

可以用一张表记录“同一架构、不同实现”:

项PY32 示例STM32 示例你的代码
CMSIS 头`py32f0xx.h``stm32f4xx.h``mcu_cmsis.h`
UART 句柄`husart1``huart1``Board` 构造注入
接收回调按 HAL 头实际名字`HAL_UART_RxCpltCallback``Board.cpp` 转发
tick`HAL_IncTick``HAL_IncTick`追加一次 `Timer::OnMilliSecondTick()`
IDEEIDE + GCCKeil AC6沿用原工程
`main/` 结构相同相同不随厂商变

换 MCU 时先读基线,不要凭型号记忆:

  1. 器件头叫什么;
  2. HAL 主头叫什么;
  3. 外设句柄变量和类型;
  4. 弱回调的实际名字;
  5. 接收/发送 API 的返回值;
  6. 1 ms tick 在哪里;
  7. main() 和 while (1) 在哪里;
  8. 启动文件、链接脚本、工程文件格式;
  9. C/C++ 混编方式。

这些确定之后,再写 Board 和驱动。示例里的 husart1、py32f0xx.h、HAL_UART_RxCpltCallback 只是某块板子的名字,不是架构的一部分。

mcu_cmsis.h 是 utility 的唯一移植点:

// main/mcu_cmsis.h
#pragma once

#include "py32f0xx.h"   // 换成目标器件头,例如 "stm32f4xx.h"

如果目标器件头没有 __get_PRIMASK / __disable_irq / __set_PRIMASK,在这个头里补同名转接,不要改 irq_lock.hpp。这样 utility 永远不需要为平台分叉。


七、代码规范:只约束自己的代码

规范只适用于 main/** 和 utility/**。

Core/、Drivers/、MDK-ARM/、EIDE/、第三方代码不参与格式化和命名检查。

项目规则
目录`main/application.*`、`main/mcu_cmsis.h`、`main/board/` 必有;`main//` 按需
头文件自写头文件一律 `#pragma once`
文件命名`snake_case.hpp` / `snake_case.cpp`
类名`Application`、`Board`、`Serial` 等 PascalCase
方法`Start`、`Init`、`GetSerial`、`Push`、`Pop`、`Create`
成员后置 `_`
常量`START_EVENT`、`QUEUE_LENGTH` 用 `UPPER_SNAKE_CASE`;模板参数 `kRxSize` / `kTxSize`
include`application.*` 只 include `application.hpp`、`board.hpp`、`queue.hpp`、`timers.hpp`、`irq_lock.hpp`、`mcu_cmsis.h` 与标准库
板级访问`application.*` 只能通过 `board.hpp` 取设备;驱动头只被 `board.*` include
初始化`Board` 构造只配置;驱动构造只存句柄;`Application::Start()` 注册回调后调用 `Board::Init()`
内存禁止 `new` / `malloc` / `std::vector` / `std::string` / `std::function` / 智能指针;`std::array` / `std::tuple` 只作固定大小值类型
共享状态一律 `IrqLock`;`dropped_++` 也在锁内
返回值`Queue::Push`、`StreamBuffer::Write`、`Read` / `Write` 的返回值必须处理
ISR只清标志、搬 1 字节、`Push`;禁止延时、`printf`、阻塞发送、长循环
编译C++17;`-fno-exceptions -fno-rtti -Wall -Wextra`

几条最容易违反的:

  1. application.* 不出现厂商类型/句柄:没有 UART_HandleTypeDef、HAL_、huart。
  2. Board 不重复厂商初始化:HAL_Init、SystemClock_Config、MX_*_Init 留在厂商 main()。
  3. 回调注册早于 arm:Application::Start() 里先注册,再 Board::Init()。
  4. dropped_++ 必须加锁:UART 和 Timer 都可能投递事件,volatile 不保证原子。
  5. 没有请求的外设不生成:没有串口,就没有 serial/、GetSerial()、串口事件。

可以用几条静态检查兜底:

# application 不得 include 驱动头或 HAL
rg -n -g 'main/application.*' '#include\s+"(serial|usart|hal|gpio)' main/

# 应用层不得出现厂商 API / 句柄
rg -n -g 'main/application.*' '\b(HAL_|LL_|huart|htim|hadc)' main/

# 自有代码不得动态分配
rg -n '\b(new|malloc|std::vector|std::string|std::function)\b' main/ utility/

# 丢弃计数必须在锁内
rg -n '\+\+dropped_' main/

# Board 不得重复厂商初始化
rg -n '\b(HAL_Init|SystemClock_Config|MX_.*_Init)\b' main/


八、验证:先把空骨架跑通

生成顺序应该固定成四个阶段。

阶段 0:读厂商基线

把器件头、HAL 头、句柄、回调名、tick 位置、入口、工具链读出来,填成一张表。查不到就标 TODO,不要猜。

阶段 1:空骨架

只生成:

main/application.*
main/mcu_cmsis.h
main/board/board.hpp
main/board/board.cpp(可以暂时不建)

验收:

  • Board 只有 GetInstance() 和空的 Init();
  • 没有 serial/、key/、led/ 目录;
  • 没有 GetSerial()、GetKey()、GetLed();
  • 厂商 main() 只追加一行 app_main();
  • 实际工具链编译通过;
  • START_EVENT 命中一次;可以在 HandleStart() 里翻转一个空闲 GPIO 验证。

阶段 2:加第一个外设

只有需求明确时才做:

  1. 建 main/<periph>/<periph>.hpp;
  2. Board 加成员、GetXxx()、构造注入句柄、转发弱回调;
  3. Application::Start() 里注册回调,再加事件号、PostXxx()、case、HandleXxx();
  4. 编译并确认事件能被处理。

一次只加一个外设。一次生成全部外设,会让编译错误和初始化顺序问题混在一起。

阶段 3:业务

handler 状态机、定时任务、协议解析。此时才进入业务复杂度。

一个带串口的 PY32 最小示例,实测大约:

Flash: 6.6 KB / 24 KB
RAM:   1.6 KB / 3 KB(含 1 KB 栈)

空骨架更小。这个数字说明:固定架构本身的成本很低,主要空间还是厂商 HAL 和外设缓冲。


九、边界与取舍

这套架构不是万能的。

1. 没有优先级,也没有抢占

事件是 FIFO 的,高频事件会挤压低频事件。需要严格优先级时,要么合并事件、拆高优先级队列,要么使用 RTOS。

2. handler 的时间就是所有事件的等待时间

一个 handler 阻塞 50 ms,后面所有事件都等 50 ms。长流程必须拆状态机。

3. 队列满、缓冲满、发送忙都要有策略

  • 队列满:计数、断言、源头限流;
  • 接收缓冲满:尽快取走,或暂停接收;
  • 发送忙:返回 0、丢弃或自己排队。

不要静默丢弃;静默丢弃比丢数据更难查。

4. 看门狗要喂在主循环

不要放在 1 ms 中断里。主循环卡死、中断还在喂狗,看门狗就失去意义。

5. 不为了“以后可能换板”提前抽象

这套架构刻意不引入 IBoard、devices/、工厂和虚接口。单板工程先把结构固定住;真的出现多板、多厂商需求,再重构。提前抽象的成本,往往比未来可能省下的成本更高。

6. 需要 RTOS 时就用 RTOS

需要阻塞等待、任务优先级、复杂同步时,不要硬套事件循环。这套架构解决的是“上下文混乱、中断过长、结构不固定”,不是替代 RTOS。


十、总结

这套裸机架构的核心,不是某个 MCU 的 HAL,也不是某个串口驱动,而是四条固定规则:

  1. 自有代码全部在 main/:application.*、mcu_cmsis.h、board/,外设按需加 <periph>/。
  2. Board 是具体类,也是板载设备的唯一入口:应用层不直接 include 驱动头/HAL;换板只改 Board 和外设驱动。
  3. ISR 只投递,Application 统一处理:一条 Queue,一个事件循环,静态内存,IrqLock 保护共享状态。
  4. 外设是插件,不是框架:没有串口就没有 serial/,没有按键就没有 key/;空骨架只包含 Board::GetInstance() 和 Init()。

不同 MCU 厂商提供的代码一定不同:启动文件、HAL、目录、句柄、回调名都会变。但 main/ 里的架构可以完全一样。

换的是实现,不换骨架。