MCU 卷积神经网络部署 · 深度技术指南
作者:智汇嵌入式实验室 · 7yewh
本指南以实际部署代码为核心,深入解析 CNN 在 Cortex-M4 MCU 上的完整推理流程。
内容基于 ST Edge AI 生成的代码(app_x-cube-ai.c、network.h)和实际优化经验。
注意 :本文档中的模型参数(输入/输出维度、通道数等)为教学示例,
实际部署时请以 ST Edge AI 生成的
network.h宏定义为准。
目录
- 整体流程与递进关系
- [模型分析 --- 能否部署到 MCU](#模型分析 — 能否部署到 MCU)
- 网络层逐层解析
- 硬件预算详解
- 生成代码深度走读
- 固件集成实战
- 推理速度优化(实战记录)
- [PC 端对比验证](#PC 端对比验证)
- 架构对比与未来方向
第1章 整体流程与递进关系
1.1 五步部署流程
Step 1 训练模型(PyTorch)
| 产出:.pt / .pth 权重文件
| 关键:模型结构必须对 MCU 友好(常规算子、静态 shape)
v
Step 2 导出 ONNX <-- 最容易翻车的关卡
| 产出:model.onnx
| 关键:opset=13、dynamic_axes=None、输入 4D 固定
v
Step 3 工具链转换(ST Edge AI Studio) <-- 另一个关卡
| 产出:network.c/.h(推理图)+ network_data.c(权重)
| 关键:算子全部在白名单内才能成功
v
Step 4 MCU 工程集成 <-- 工程量最大
| 产出:可编译的完整 Keil/CMake 工程
| 关键:实现 acquire_and_process_data() 和 post_process()
v
Step 5 验证(PC 对比 + 上板) <-- 最终检验
| 工具:verify_mcu_vs_onnx.py
| 关键:PC 端 ONNX 输出 ≈ MCU 推理输出(误差 < 5%)
1.2 为什么是这个顺序
每一步的输入 是上一步的输出,不能跳步:
- 没有正确的 ONNX,工具链就没法转换
- 没有转换后的 C 代码,MCU 工程就没法集成
- 没有集成完,就没有 MCU 输出可以验证
哪步最重要?
Step 2(ONNX 导出)和 Step 4(集成)最容易出问题:
- Step 2:模型结构设计错了,整个链路都得推倒重来(常见坑:原始 5D 模型直接失败)
- Step 4:两个桩函数没实现,数据预处理写错,MCU 跑起来但输出全是错的
1.3 ST Edge AI 对模型的要求
| 要求 | 说明 |
|---|---|
| 文件格式 | ONNX(推荐)、Keras .h5、TFLite 等 |
| 输入维度 | 最多 4D,格式 [N, C, H, W],N(batch)必须 =1 且固定 |
| shape 必须静态 | 所有维度在导出时固定,不能有动态维度(dynamic_axes=None) |
| opset 版本 | ONNX opset 7~20(推荐 13) |
| 算子白名单 | Conv / FC / ReLU / Sigmoid / Flatten / Transpose 等常规算子 |
| 数值格式 | float32(工具内部可选做 int8 量化) |
工具根据目标芯片自动决定运行时库版本、激活缓冲区大小、是否启用 SIMD 优化。
第2章 模型分析
2.1 关键信息速查
以下为示例模型的典型参数(实际值以 network.h 生成宏为准):
| 属性 | 示例值 | 对应宏名 |
|---|---|---|
| 输入形状 | [1, 6, 10, 8](6 通道,10 行,8 列) |
STAI_NETWORK_IN_1_SHAPE |
| 输出形状 | [1, 4](Fx, Fy, Fz, Fn 四个力分量) |
STAI_NETWORK_OUT_1_SHAPE |
| 网络节点数 | 12(含 Transpose/ReLU/Sigmoid) | STAI_NETWORK_NODES_NUM |
| 权重大小 | 253,072 字节(约 247 KB) | STAI_NETWORK_WEIGHT_1_SIZE_BYTES |
| 总 MACs | 约 776,000 | STAI_NETWORK_MACC_NUM |
| 激活缓冲区 | 11,520 字节 | STAI_NETWORK_ACTIVATION_1_SIZE_BYTES |
| 输入大小 | 1,920 字节(480 x float32) | STAI_NETWORK_IN_1_SIZE_BYTES |
| 输出大小 | 16 字节(4 x float32) | STAI_NETWORK_OUT_1_SIZE_BYTES |
2.2 MCU 友好度检查
MCU 喜欢的模型特征(部署前必须确认全部满足):
- 静态输入尺寸(所有维度固定,无动态 shape)
- 常规算子:Conv / FC / ReLU / Sigmoid / Flatten / Transpose
- 单分支线性图(无 Attention、无动态索引、无控制流)
2.3 不支持的算子(导出时需避免)
| 不支持的算子 | 原因 | 替代方案 |
|---|---|---|
| Gather(动态索引) | 运行时确定索引,工具链无法推导 shape | 逻辑搬到 MCU 固件中 |
| Reshape(allowzero=1) | PyTorch 默认导出,ST Edge AI 不认 | 导出时指定 allowzero=0 或用 Flatten |
| LayerNorm / GroupNorm | 需要运行时计算均值方差 | 用 BatchNorm(可融合进 Conv) |
| Attention(QKV) | 矩阵乘尺寸是序列长度的平方 | 用 CNN 替代 |
| LSTM / GRU | 有隐状态,运行时不一定支持 | 用 TCN 或多帧展开 |
| 5D+ 张量 | 超过 4D 限制 | 导出前 reshape 成 4D |
| Loop / If | 静态图不支持控制流 | 条件逻辑写在固件里 |
第3章 网络层逐层解析
3.1 完整网络结构(从 ONNX 图推算)
输入: [1, 6, 10, 8] (batch=1, 6通道, 10行, 8列, float32 = 1,920 B)
|
[Transpose] NCHW -> NHWC
|
v
Layer 1: Conv1 W<24x6x3x3> stride=1, padding=1
H_out = (10-3+2x1)/1+1 = 10
W_out = (8-3+2x1)/1+1 = 8
输出: [1, 10, 8, 24] = 7,680 B
MACs: 3x3x6x24x10x8 = 103,680
|
[ReLU] max(0, x)
|
v
Layer 3: Conv2 W<24x24x3x3> stride=1, padding=1
输出: [1, 10, 8, 24] = 7,680 B
MACs: 3x3x24x24x10x8 = 414,720 <-- 主要算力瓶颈(53.4%)
|
[ReLU]
|
v
Layer 5: Conv3 W<48x24x3x3> stride=2, padding=1 <-- 关键!步长=2
H_out = (10-3+2)/2+1 = 5 <-- 减半
W_out = (8-3+2)/2+1 = 4 <-- 减半
输出: [1, 5, 4, 48] = 3,840 B
MACs: 3x3x24x48x5x4 = 207,360
|
[ReLU]
|
v
Layer 7: Flatten 48x5x4 = 960 -> [1, 960] = 3,840 B
数据不动,只是从三维数组换成一维数组读
|
v
Layer 8: Dense1 (Gemm) W<48x960>
960个输入 x 48个输出 = 46,080次 MAC
权重: 46,128 x 4B = 180.2 KB(占总权重 71.2%) <-- 权重瓶颈
输出: [1, 48] = 192 B
|
[ReLU]
|
v
Layer 10: Dense2 (Gemm) W<4x48>
输出: [1, 4] = 16 B
|
[Sigmoid] 1/(1+e^-x) 压到 [0, 1]
|
v
输出: [Fx_norm, Fy_norm, Fz_norm, Fn_norm] ∈ [0, 1]
-> 反归一化后得到实际力值
3.2 各算子详解
Conv2D(二维卷积)--- 提取空间特征
用一个 3x3 的"滤镜"在输入上滑动扫描,每滑一格做一次乘加运算。多个滤镜检测不同特征。
以 Conv1 为例,用 C 语言理解计算过程:
c
/* Conv1: 输入 [10,8,6] -> 输出 [10,8,24] */
for (k = 0; k < 24; k++) /* 24 个输出通道(特征检测器) */
for (r = 0; r < 10; r++) /* 每行 */
for (c = 0; c < 8; c++) /* 每列 */
sum = bias[k];
for (i = 0; i < 3; i++) /* 3x3 卷积核 */
for (j = 0; j < 3; j++)
for (ch = 0; ch < 6; ch++) /* 6 个输入通道 */
sum += weight[k][i][j][ch] * input[r+i][c+j][ch];
output[r][c][k] = sum; /* 这就是一个 MAC */
输出尺寸公式:(input - kernel + 2 x padding) / stride + 1
ReLU(线性整流)--- 激活函数
c
output = (input > 0) ? input : 0; /* max(0, x) */
作用:引入非线性。没有它,多层卷积叠加仍等价于一层线性变换,模型无法学习复杂规律。
Flatten(展平)--- 多维转一维
Conv3 输出 [1, 5, 4, 48] 是三维张量,全连接层需要一维输入:
[1, 5, 4, 48] -> [1, 960] 5 x 4 x 48 = 960
C 语言理解:把三维数组用指针当一维数组访问,数据内存地址不变,只是访问方式变了。
Dense / Gemm(全连接)--- 综合所有特征
输入每个值都与输出每个值有连接。本质是矩阵乘法:
c
/* Dense1: 输入 [960] -> 输出 [48] */
for (j = 0; j < 48; j++) { /* 48 个输出神经元 */
sum = bias[j];
for (i = 0; i < 960; i++) /* 与 960 个输入全连接 */
sum += weight[j][i] * input[i]; /* 一次 MAC */
output[j] = sum;
}
Dense1 是权重最大的层(180 KB,占 71.2%),因为 960 x 48 = 46,080 个参数。
Sigmoid(输出激活)--- 归一化到 [0, 1]
output = 1 / (1 + e^(-x))
x = -inf -> output ≈ 0.0
x = 0 -> output = 0.5
x = +inf -> output ≈ 1.0
本模型用 Sigmoid 是因为输出要表示"归一化的力值"(0~1 范围),MCU 再做反归一化映射到实际值。
3.3 归一化 / 反归一化的因果关系
问题根源:传感器数值和网络期望的数值范围不匹配。
原因1:ADC 输出整数,范围 [0, 4095](12位 ADC)
原因2:神经网络训练时用归一化后的数据(0~1 范围)
网络学到:"输入=0.5 -> 力应输出=0.8"
不是:"输入=2048 -> 力应输出=3277"
原因3:Sigmoid 输出范围天然 [0, 1],不可能输出 2048 这样的大数
结论:MCU 必须在推理前把数据压到 [0,1](归一化)
推理后再把 [0,1] 还原为实际力值(反归一化)
具体数字说明:
第一步:归一化(输入前)
ADC 读到: 2048(整数,0~4095)
/ 4095.0
-> 0.5001(浮点,0~1) <-- 网络看到的输入
第二步:网络推理
输入 [1, 6, 10, 8] 的归一化数据
-> 输出 [0.82, 0.47, 0.91, 0.65] <-- Sigmoid 保证在 0~1
第三步:反归一化(输出后)
Fz 单向力,范围 [0, 4095]:
Fz = 0.91 x 4095 = 3726
Fx/Fy 双向力,范围 [-4095, +4095]:
Fx = (0.82 - 0.5) x 2 x 4095 = +2621
Fy = (0.47 - 0.5) x 2 x 4095 = -246
Fn 法向力,范围 [0, 4095]:
Fn = 0.65 x 4095 = 2662
总结: 模型在 [0,1] 世界里学习和输出,传感器在 [0,4095] 世界里工作。归一化 = 翻译成网络的语言;反归一化 = 翻译回硬件的语言。
第4章 硬件预算详解
4.1 总体资源占用
| 资源 | STM32G4 总量 | AI 占用 | 剩余 |
|---|---|---|---|
| Flash | 512 KB | 247 KB 权重 + 6 KB 代码 = 253 KB | 259 KB |
| 主 SRAM(0x2000_0000) | 96 KB | 11.5 KB 激活缓冲区 | 84.5 KB |
| CCMRAM(0x1000_0000) | 32 KB | 可放激活缓冲区 + 帧缓冲区 | --- |
| FPU | 单精度 float32 | float32 推理有硬件加速 | --- |
| DSP 扩展(SIMD) | 2 路 int16 | ST-AI 运行时已利用 | --- |
| D-Cache / I-Cache | 无 | app_config.h 必须设为 0 | --- |
4.2 激活缓冲区怎么算出来的
推理时每层需要同时持有输入和输出,但上一层算完后输入可以释放。ST Edge AI 分析所有层的内存占用,找到峰值最大的时刻:
c
#define STAI_NETWORK_ACTIVATION_1_SIZE_BYTES (11520) /* ST-AI 自动计算 */
峰值出现在 Conv1(输入 + 输出同时存在):
计算 Conv1 时:
输入 buffer:1x6x10x8x4B = 1,920 B <-- 还在读,不能释放
输出 buffer:1x10x8x24x4B = 7,680 B <-- 正在写
临时工作区 + 对齐开销 ≈ 1,920 B
-----------------------------------------
峰值合计 = 11,520 B <-- 就是这个数
4.3 Overlay(覆盖复用)原理
所有层共用同一块 11,520 字节缓冲区,像流水线一样复用:
时刻1: [ 输入 1,920B | Conv1输出 7,680B | 工作区 ] <-- 峰值 11,520B
时刻2: [ Conv1输出(现为Conv2输入) | Conv2输出(覆盖写) ]
时刻3: [ Conv3输出 3,840B | 空闲 ]
时刻4: [ Dense 192B | 空 ]
...
结果:整个推理只需这一块 RAM,不是每层单独分配
在代码中对应:
c
/* app_x-cube-ai.c */
STAI_ALIGNED(32)
static uint8_t heap_overlay_pool[STAI_NETWORK_ACTIVATION_1_SIZE_BYTES];
stai_ptr data_activations[] = { heap_overlay_pool };
4.4 权重分布
| 层 | 参数量 | 权重大小 | 占比 |
|---|---|---|---|
| Conv1 (6->24) | 6x24x3x3 + 24 = 1,320 | 5.2 KB | 2.1% |
| Conv2 (24->24) | 24x24x3x3 + 24 = 5,208 | 20.3 KB | 8.2% |
| Conv3 (24->48) | 24x48x3x3 + 48 = 10,416 | 40.7 KB | 16.5% |
| Dense1 (960->48) | 960x48 + 48 = 46,128 | 180.2 KB | 71.2% |
| Dense2 (48->4) | 48x4 + 4 = 196 | 0.8 KB | 0.3% |
| 合计 | 63,268 | 247 KB | 100% |
Dense1 占 71.2% 权重是全连接层的本质代价:960 个输入 x 48 个输出,每一对都有一个权重。
4.5 MACs 手算验证
Conv 层:kernel_H x kernel_W x in_ch x out_ch x output_H x output_W
Conv1: 3x3 x 6 x 24 x 10x8 = 103,680
Conv2: 3x3 x 24 x 24 x 10x8 = 414,720 <-- 最耗时!
Conv3: 3x3 x 24 x 48 x 5x4 = 207,360 (stride=2,输出 5x4)
Dense1: 960 x 48 = 46,080
Dense2: 48 x 4 = 192
-----------------------------------------------
合计 772,032
+ Transpose/ReLU/Sigmoid/偏置 ≈ +3,968
≈ 776,000
4.6 数值格式对比
| 格式 | 每参数字节 | Dense1 大小 | 全模型大小 | 适用场景 |
|---|---|---|---|---|
| float32 | 4 B | 180 KB | 247 KB | 有 FPU,Flash 放得下 |
| int8 | 1 B | 45 KB | 62 KB | 需 SIMD 加速,精度可接受 |
| float16 | 2 B | 90 KB | 124 KB | Cortex-M4 不支持原生 f16 |
第5章 生成代码深度走读
5.1 文件职责一览
| 文件 | 来源 | 是否需要修改 | 作用 |
|---|---|---|---|
app_x-cube-ai.c |
ST Edge AI 模板 | 是 --- 实现桩函数 | 推理初始化 + 运行 + 数据桥接 |
app_x-cube-ai.h |
模板 | 否 | 声明 Init/Process/Deinit 入口 |
app_config.h |
模板 | 是 --- 修正平台参数 | D-Cache/I-Cache/ExtRAM 配置 |
bsp_ai.h |
模板 | 否 | BSP 桥接:HAL + AI 头文件 |
network.h |
自动生成 | 否 | 网络规格宏(尺寸/MACs/shape) |
network.c |
自动生成 | 否 | 推理图实现(算子调用序列) |
network_data.c |
自动生成 | 否 | 权重数据(const 存 Flash) |
stai.h |
ST-AI SDK | 否 | 平台 API 类型和常量定义 |
5.2 内存布局分析
c
/* === 网络上下文(不透明结构体,由 ST-AI 内部管理) === */
STAI_NETWORK_CONTEXT_DECLARE(network_context, STAI_NETWORK_CONTEXT_SIZE);
/* 包含:magic, signature, flags, return_code, callback,
activations[1], weights[1], inputs[1], outputs[1] */
/* === 激活缓冲区(Overlay 复用池) === */
STAI_ALIGNED(32)
static uint8_t heap_overlay_pool[STAI_NETWORK_ACTIVATION_1_SIZE_BYTES];
/* === I/O 指针(由 aiInit 填充) === */
static stai_ptr stai_input[STAI_NETWORK_IN_NUM];
static stai_ptr stai_output[STAI_NETWORK_OUT_NUM];
I/O 缓冲区采用 allocate-inputs / allocate-outputs 模式:ST-AI 内部分配缓冲区,通过 stai_network_get_inputs/get_outputs 获取地址。代码中 stai_network_set_inputs/set_outputs 被注释掉,就是这个原因。
5.3 初始化流程 6 步详解
c
int aiInit(void) {
/* Step 1: 初始化运行时库
* 加载 NetworkRuntime_CM4_GCC.a 中的内核函数表 */
stai_runtime_init();
/* Step 2: 初始化网络上下文
* 将 network.c 中的拓扑结构绑定到 network_context */
stai_network_init(network_context);
/* Step 3: 绑定激活缓冲区
* 告诉运行时中间计算结果放在哪块 RAM
* 这块 RAM 被所有层 Overlay 复用 */
stai_network_set_activations(network_context, data_activations,
STAI_NETWORK_ACTIVATIONS_NUM);
/* Step 4: 获取输入缓冲区地址
* stai_input[0] 将指向运行时内部分配的输入 buffer */
stai_network_get_inputs(network_context, stai_input, &_in_size);
/* Step 5: 获取输出缓冲区地址
* stai_output[0] 将指向运行时内部分配的输出 buffer */
stai_network_get_outputs(network_context, stai_output, &_out_size);
return 0;
}
5.4 推理执行
c
int aiRun() {
stai_return_code ret_code;
/* 同步阻塞推理:从 stai_input 读取数据,经过 12 层计算,结果写入 stai_output */
ret_code = stai_network_run(network_context, STAI_MODE_SYNC);
if (ret_code != STAI_SUCCESS) {
ret_code = stai_network_get_error(network_context); /* 获取详细错误码 */
}
return 0;
}
STAI_MODE_SYNC:阻塞直到推理完成。MCU 单核场景推荐。
5.5 桩函数实现参考
生成代码中 acquire_and_process_data() 和 post_process() 是空函数,需要开发者根据实际硬件实现。
数据采集(输入前处理)
c
int acquire_and_process_data() {
float *input = (float *)stai_input[0];
/* 从传感器采集数据,归一化后填入输入缓冲区
* 示例:6 通道 x 10 行 x 8 列 = 480 个 float32 */
for (int ch = 0; ch < 6; ch++)
for (int r = 0; r < 10; r++)
for (int c = 0; c < 8; c++)
input[ch * 80 + r * 8 + c] = adc_value / 4095.0f; /* 归一化 [0,1] */
return 0;
}
输出后处理(反归一化)
c
int post_process() {
float *output = (float *)stai_output[0];
/* Sigmoid 输出 [0,1] -> 反归一化为实际力值 */
float Fx = (output[0] - 0.5f) * 2.0f * 4095.0f; /* 双向力 [-4095, +4095] */
float Fy = (output[1] - 0.5f) * 2.0f * 4095.0f; /* 双向力 */
float Fz = output[2] * 4095.0f; /* 单向力 [0, 4095] */
float Fn = output[3] * 4095.0f; /* 法向力 [0, 4095] */
/* 发送到通信总线 或 存入结果结构体 */
return 0;
}
5.6 主循环改造
生成代码的 main_loop() 包含 while(1) 死循环,必须去掉:
c
/* 原始(不可用) */
void main_loop() {
while (1) { /* <-- AI 独占 CPU,其他功能全部停摆 */
acquire_and_process_data();
aiRun();
post_process();
}
}
/* 改造后:在固件主循环中单次调用 */
while (1) {
/* 传感器采集 */
sensor_acquisition();
/* AI 推理(单次) */
acquire_and_process_data();
aiRun();
post_process();
/* 通信发送 */
can_send_data();
}
第6章 固件集成实战
6.1 集成清单
| 序号 | 步骤 | 操作 | 关键点 |
|---|---|---|---|
| 1 | 添加源文件 | network.c, network_data.c, app_x-cube-ai.c |
加入工程编译 |
| 2 | 添加头文件路径 | AI/App/, generated/, Middlewares/ST/AI/Inc/ |
编译器 Include Path |
| 3 | 链接运行时库 | NetworkRuntime_CM4_GCC.a |
加入链接器输入 |
| 4 | 修正 app_config.h | D-Cache=0, I-Cache=0, ExtRAM=0, Overdrive=0 | G4 无这些硬件 |
| 5 | 调用初始化 | aiInit() 在 main() 初始化阶段 |
必须在推理前调用 |
| 6 | 实现桩函数 | acquire_and_process_data() + post_process() |
归一化 + 反归一化 |
| 7 | 去掉 while(1) | main_loop() 改为单次调用 |
不能让 AI 独占 CPU |
| 8 | CCMRAM 优化 | heap_overlay_pool 放入 .ccmram 段 |
修改链接脚本 |
| 9 | 开启 Flash 预取 | PREFETCH_ENABLE = 1 |
减少 Flash 等待周期影响 |
| 10 | 验证 | 比对 PC 端 ONNX 输出 | 误差应 < 5% |
6.2 app_config.h 修正
ST Edge AI 模板默认面向 STM32N6/H7 高端芯片,4 个配置必须改为 0:
c
/* 改后(适配 STM32G4) */
#define USE_MCU_DCACHE 0 /* G4 无 D-Cache */
#define USE_MCU_ICACHE 0 /* G4 无 I-Cache */
#define USE_EXTERNAL_RAM 0 /* G4 无外部 RAM */
#define USE_OVERDRIVE 0 /* G4 无超频模式,最高 170MHz */
不修正会导致运行时初始化失败或访问不存在的硬件寄存器。
6.3 CCMRAM 链接脚本配置
c
/* 链接脚本新增 CCMRAM 区域 */
MEMORY {
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 112K
CCMRAM (rw) : ORIGIN = 0x10000000, LENGTH = 32K /* CPU 专用,零等待 */
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
}
SECTIONS {
.ccmram : {
*(.ccmram)
*(.ccmram*)
} >CCMRAM
}
源码中标记需要放入 CCMRAM 的缓冲区:
c
/* 激活缓冲区 -> CCMRAM,零等待访问 */
static uint8_t ai_activation_pool[STAI_NETWORK_ACTIVATION_1_SIZE_BYTES]
__attribute__((section(".ccmram")));
注意: CCMRAM 不能被 DMA 访问,只能放 CPU 独占的数据。传感器 DMA 缓冲区必须留在主 SRAM。
6.4 ONNX 导出要点
python
import torch
model.eval()
dummy = torch.randn(1, 6, 10, 8) # 单帧 4D 输入
torch.onnx.export(
model, dummy, "model.onnx",
opset_version=13, # ST Edge AI 要求 >= 11
dynamic_axes=None, # 禁用动态维度
do_constant_folding=True, # 常量折叠优化
input_names=["input"],
output_names=["output"],
)
第7章 推理速度优化
7.1 性能瓶颈分析
示例模型参数:约 776,000 MACs,float32,12 层
MCU:STM32G4,Cortex-M4F
FPU 单精度(1 float MAC/cycle)
无 D-Cache/I-Cache(但有 ART Accelerator)
Flash 4 等待周期
理论下限:776K MACs / 160MHz = 4.9ms(纯算力,无访存延迟)
实际测量:远高于理论值
差距原因:
1. Flash 读权重有 4 个等待周期(权重约 247KB 全在 Flash)
2. 主 SRAM 访问有 AHB 总线争用(DMA 也在用)
3. 中断打断(FDCAN、TIM 等)
4. 非推理代码开销(传感器采集、数据打包、通信发送)
7.2 已实施优化
优化1:CCMRAM 放置关键缓冲区
原理: CCMRAM(0x10000000,32KB)通过 CPU 专用 D-bus 直连,零等待周期,且不与 DMA 争用 AHB 总线。
c
/* 激活缓冲区移到 CCMRAM */
static uint8_t ai_activation_pool[STAI_NETWORK_ACTIVATION_1_SIZE_BYTES]
__attribute__((section(".ccmram")));
效果: 激活读写零等待,减少 AHB 总线争用,提速约 10-15%。
优化2:开启 Flash 预取(ART Accelerator)
原理: ART Accelerator 的预取缓冲区可在 CPU 执行当前指令时提前从 Flash 加载下一条。
c
/* stm32g4xx_hal_conf.h */
#define PREFETCH_ENABLE 1U /* 改前是 0,必须开启 */
#define INSTRUCTION_CACHE_ENABLE 1U /* 已开 */
#define DATA_CACHE_ENABLE 1U /* 已开 */
效果: 减少 Flash 4 等待周期的影响,提速约 10-20%。
7.3 评估过但未采用的方案
帧跳过(每 N 帧推理一次)
未采用原因: 业务要求每帧都必须推理,跳帧导致力输出有延迟,反馈不连续。
int8 量化(Post-Training Quantization)
预期效果:
权重 247 KB -> 约 62 KB
推理速度提升 4-8 倍(SIMD 对 int8 更友好)
未采用原因:力值精度要求高,int8 存在精度损失风险
备注:如果未来要启用 int8 量化,固件代码完全不用改,
只需替换 generated/ 下的 network.c、network.h、network_data.c
7.4 ST-AI 优化策略选项
| 策略 | 含义 | 代码体积 | 推理速度 |
|---|---|---|---|
size |
优先缩小生成代码 | 最小 | 最慢 |
balanced |
平衡(默认) | 中等 | 中等 |
speed |
优先最快推理 | 最大(循环展开) | 最快 |
7.5 中断对推理的影响
MCU 单核处理器,推理在主循环顺序执行。
无中断:
t=0ms: Conv1 -> Conv2 -> ... -> 推理完成
有高优先级中断每 1ms 打断:
推理被反复打断,总时间可能膨胀数倍
解决方案:
1. 推理期间临时关闭低优先级中断
2. 使用 DMA 处理外设(主循环不等待)
3. 合理设置中断优先级
7.6 优化效果汇总
| 优化项 | 状态 | 预期加速 | 说明 |
|---|---|---|---|
| CCMRAM 放置缓冲区 | 已实施 | 10-15% | 零等待访问 |
| Flash PREFETCH | 已实施 | 10-20% | 减少 Flash 等待 |
| I-Cache / D-Cache | 已开启 | 含在基准内 | HAL_CONF 中已启用 |
| 优化选 "Time" | 可尝试 | 5-15% | CubeAI 生成时选 Time |
| int8 量化 | 暂不用 | 4-8 倍 | 精度要求高 |
| 帧跳过 | 不用 | N 倍 | 业务要求每帧推理 |
| 训练更小模型 | 未来方向 | 2-8 倍 | 减通道/DepthwiseConv |
7.7 根本性加速方向(需重新训练模型)
MACs 数量是瓶颈根因。MCU 端优化只能挤出 20-40%,质的飞跃需从模型架构入手:
方案A:减少 Conv 通道数
Conv: 24->12,Dense: 48->16
MACs 可减少 4-7 倍
方案B:用 DepthwiseConv 替代 Conv2D
MobileNet 风格:DepthwiseConv + PointwiseConv
MACs 减少 3-8 倍,精度损失通常很小
方案C:减少输入窗口
减少输入帧数,计算量减少约 30%
需重新训练,可能影响时序特征提取能力
第8章 PC 端对比验证
8.1 四级对比链
基准1: PyTorch 原始输出 <-- 训练基准
| 对比:导出是否正确
基准2: ONNX Runtime 输出 <-- verify_mcu_vs_onnx.py
| 对比:量化损失多少
基准3: 量化后 int8 输出 <-- 如果做了量化
| 对比:MCU 推理是否正确
基准4: MCU 推理输出(通信打印) <-- 最终目标
8.2 验证脚本示例
python
import numpy as np
import onnxruntime as ort
# 1. 用传感器数据构造输入(归一化)
input_data = (raw_data / 4095.0).astype(np.float32) # [1, 6, 10, 8]
# 2. ONNX Runtime 推理
session = ort.InferenceSession("model.onnx")
sigmoid_out = session.run(None, {"input": input_data})[0] # [Fx, Fy, Fz, Fn] in [0,1]
# 3. 反归一化(与 MCU post_process 完全一致)
Fx = (sigmoid_out[0][0] - 0.5) * 2 * 4095 # 双向力
Fy = (sigmoid_out[0][1] - 0.5) * 2 * 4095
Fz = sigmoid_out[0][2] * 4095 # 单向力
Fn = sigmoid_out[0][3] * 4095 # 法向力
# 4. 与 MCU 实测值对比
print(f"PC: Fx={Fx:.1f}, Fy={Fy:.1f}, Fz={Fz:.1f}, Fn={Fn:.1f}")
print(f"MCU: Fx={mcu_fx}, Fy={mcu_fy}, Fz={mcu_fz}, Fn={mcu_fn}")
print(f"误差: < 5% 即为通过")
第9章 架构对比与未来方向
9.1 MCU 架构对 AI 推理的影响
| 模块 | 作用 | STM32G4 现状 |
|---|---|---|
| FPU | 浮点硬件加速 | 有,单精度 float32 |
| Cache | 减少 Flash/RAM 访问延迟 | 无(用 ART Accelerator 补偿) |
| SIMD/DSP | 单指令多数据并行 | 2 路 int16 |
| CCMRAM | CPU 专用零等待内存 | 32 KB |
无 FPU 时的影响:
有 FPU:776K MAC x 3 cycles / 128MHz ≈ 18ms -> 优化后约 5ms
无 FPU:776K MAC x 20 cycles / 128MHz ≈ 121ms(软件模拟浮点)
9.2 不同架构推理速度对比
| 架构 | 代表芯片系列 | FPU | Cache | SIMD | 推理速度趋势 |
|---|---|---|---|---|---|
| Cortex-M4F | STM32G4 | float32 | 无 | 2 路 int16 | 基准 |
| Cortex-M7 | STM32H7 | float32+64bit | 256KB | 同 M4 | 约 2 倍(Cache 加速) |
| Cortex-M33+Helium | STM32U5 | 有 | 有 | 16 路 int8 | int8 量化后约 13 倍 |
G4 -> H7 只快 2 倍,因为两者 SIMD 相同,Cache 只解决带宽问题。真正的飞跃在 M33+Helium:int8 量化 + 16 路 SIMD = 13 倍加速。
9.3 运行时与 Kernel 关系
模型格式(文件) 推理运行时(执行引擎) 底层 kernel
----------------- ---------------------- ----------------
ONNX (.onnx) -> ST Edge AI Runtime -> CMSIS-NN (M4 优化)
TFLite (.tflite) -> LiteRT for MCU (TFLM) -> CMSIS-NN
自定义 -> 自己写的解析器 -> 自己的数学函数
9.4 模型瘦身方向
| 方向 | 方法 | 预期效果 | 代价 |
|---|---|---|---|
| 减参数 | Dense1 输出减半,Conv 通道减半 | 权重减半,MACs 减少 4-7 倍 | 需重新训练 |
| int8 量化 | float32 -> int8 PTQ | 权重缩小 4 倍,推理快 4-8 倍 | 精度损失风险 |
| DepthwiseConv | 替代标准 Conv2D | MACs 减少 3-8 倍 | 需修改网络定义 |
| 减输入窗口 | 减少输入帧数/通道 | 计算量减少约 30% | 可能影响精度 |
智汇嵌入式实验室 · 7yewh