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

嵌入式单片机裸机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,四类自有文件

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

text 复制代码
<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/<periph>/ 某个外设的具体驱动,例如 serial/serial.hpp、key/key.hpp
main/mcu_cmsis.h 引入目标器件的 CMSIS 头,utility 唯一的 MCU 依赖点

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

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

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

cpp 复制代码
// 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 是边界,不是抽象层

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

text 复制代码
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/<periph> 某个外设的寄存器/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、启动文件、链接脚本,仍然由厂商工程负责:

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

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

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

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


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

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

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

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

一个零外设的最小版本:

cpp 复制代码
// 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;
};
cpp 复制代码
// 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 只有两个:

cpp 复制代码
static Board &GetInstance();
void Init();

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

2. 有串口时

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

cpp 复制代码
// 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_;
};
cpp 复制代码
// 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();
}

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

cpp 复制代码
// 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() 之前注册回调:

cpp 复制代码
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 边界、事件链路、初始化顺序和代码规范;外设是插件。


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

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

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

对应到代码:

c 复制代码
int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART1_UART_Init();   /* 有串口才需要 */

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

    while (1) {
    }
}
cpp 复制代码
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/<periph>/ 按新厂商 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()
IDE EIDE + GCC Keil 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 的唯一移植点:

cpp 复制代码
// 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/<periph>/ 按需
头文件 自写头文件一律 #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()、串口事件。

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

text 复制代码
# 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:空骨架

只生成:

text 复制代码
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 最小示例,实测大约:

text 复制代码
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/ 里的架构可以完全一样。

换的是实现,不换骨架。

相关推荐
Juicedata5 小时前
从 Meta 的工程实践看 EB 级 AI 存储架构
人工智能·架构
CCblandy5 小时前
缓存淘汰实测:一次全表扫描把 LRU 命中率从 46% 打到 38%,W-TinyLFU 守住 50% 却贵 11 倍
后端·python·缓存·性能优化·架构
安易算力5 小时前
智算中心网络架构:RDMA/IB/以太网选型深度解析与优化实践
网络·架构·php
微订校园外卖平台6 小时前
区县外卖跑腿系统搭建:四端架构、订单链路与验收
架构·外卖系统·本地生活·微订
天远API6 小时前
零信任架构实战:基于天远劳动仲裁信息查询构建自动化灵活用工劳务派遣人员准入背调网关
运维·人工智能·架构·自动化
code_slave(码畜)7 小时前
微服务架构落地:慢 SQL 治理与连接池调优 —— 守住数据库的最后一道防线
数据库·spring boot·sql·spring cloud·微服务·架构
Ramble_Naylor9 小时前
Rust深度解读:高性能 Web 网关与 Axum 架构剖析
前端·架构·rust
低调小一9 小时前
客户端开发者学 Eino(01):从一次模型调用理解 Agent 架构
人工智能·架构·eino
Hello.Reader9 小时前
100 人同时操作一座三维城市,为什么不需要服务器渲染 6000 帧?Rust + wgpu 多端并发架构实战
服务器·架构·rust