嵌入式单片机裸机C++软件架构设计
不同 MCU 厂商的启动文件、HAL、目录名、句柄变量和回调名都不一样;但
main/里的自有代码架构可以完全一样。
配套规则:mcu-baremetal-event-loopAgent 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 不重复调用这些初始化。它只做两件事:
- 构造设备对象,保存厂商已经初始化好的句柄;
- 配置厂商没有覆盖的板级 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()拿指针,不直接 includeserial.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 时先读基线,不要凭型号记忆:
- 器件头叫什么;
- HAL 主头叫什么;
- 外设句柄变量和类型;
- 弱回调的实际名字;
- 接收/发送 API 的返回值;
- 1 ms tick 在哪里;
main()和while (1)在哪里;- 启动文件、链接脚本、工程文件格式;
- 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 |
几条最容易违反的:
application.*不出现厂商类型/句柄 :没有UART_HandleTypeDef、HAL_、huart。Board不重复厂商初始化 :HAL_Init、SystemClock_Config、MX_*_Init留在厂商main()。- 回调注册早于 arm :
Application::Start()里先注册,再Board::Init()。 dropped_++必须加锁 :UART 和 Timer 都可能投递事件,volatile不保证原子。- 没有请求的外设不生成 :没有串口,就没有
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:加第一个外设
只有需求明确时才做:
- 建
main/<periph>/<periph>.hpp; Board加成员、GetXxx()、构造注入句柄、转发弱回调;Application::Start()里注册回调,再加事件号、PostXxx()、case、HandleXxx();- 编译并确认事件能被处理。
一次只加一个外设。一次生成全部外设,会让编译错误和初始化顺序问题混在一起。
阶段 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,也不是某个串口驱动,而是四条固定规则:
- 自有代码全部在
main/:application.*、mcu_cmsis.h、board/,外设按需加<periph>/。 Board是具体类,也是板载设备的唯一入口 :应用层不直接 include 驱动头/HAL;换板只改Board和外设驱动。- ISR 只投递,
Application统一处理 :一条Queue,一个事件循环,静态内存,IrqLock保护共享状态。 - 外设是插件,不是框架 :没有串口就没有
serial/,没有按键就没有key/;空骨架只包含Board::GetInstance()和Init()。
不同 MCU 厂商提供的代码一定不同:启动文件、HAL、目录、句柄、回调名都会变。但 main/ 里的架构可以完全一样。
换的是实现,不换骨架。