
做了好几年嵌入式开发C 语言、寄存器、RTOS、Linux 驱动这些几乎是我每天打交道的东西。但这两年项目和招聘需求里“端侧 AI”这个词出现得越来越频繁而我发现要想真正吃下这条路绕不开的就是 CNN——卷积神经网络。作为一个长期跟 MCU 和嵌入式 Linux 打交道的人我决定重新从 CNN 开始不是去背 API而是把“模型训练到端侧部署”整条链路自己完整走一遍。这篇文章就是我这段时间的路线复盘和实操记录包括为什么选择 CNN 而不是直接上大模型、卷积在嵌入式视角下到底怎么理解、一个 CNN 从训练到部署到开发板的全过程以及踩坑后整理的排查方法。如果你是嵌入式工程师想往端侧 AI 方向转这篇文章可以帮你省下不少试错时间。如果你已经有 AI 基础但不懂硬件部署也可以看看嵌入式这边是怎么处理内存、算力和算子兼容性问题的。1. 为什么我决定从嵌入式转向端侧 AI1.1 端侧 AI 到底在解决什么问题先说清楚一个容易被概念带偏的事端侧 AI 不是把云端那套 AI 搬下来就行它的价值在于让设备在没有稳定网络、不能承受高延迟、对隐私有要求的环境下自己完成推理。举个最简单的场景一条生产线上用摄像头判断零件是否装反如果每个判断都要上传云端网络抖动一下就是几百毫秒产线根本受不了。把模型放在设备端本地推理延时可以压到 30 到 80 毫秒而且不依赖带宽。类似的需求在工业质检、智能安防、可穿戴设备、医疗器械里到处都是。从硬件形态看端侧 AI 大致分三档第一档是 Cortex-M 级别的 MCU跑轻量级模型适合传感器数据处理、关键词唤醒第二档是带 Linux 的嵌入式 MPU配 NPU 或 DSP 加速可以跑目标检测这类稍重的任务第三档是手机这种性能更强的平台能跑大模型。做嵌入式的工程师本来就懂这三档硬件的选型和约束这是天然的切入点。1.2 我对照了一下自己的技能表认真盘了盘自己的家底发现嵌入式背景做端侧 AI 有两个明显优势也有两个明显短板。优势第一是懂资源边界。模型想在 256KB RAM 的单片机上跑还是 512MB DDR 的核心板上跑决定方案完全不同。嵌入式工程师对内存、Flash、功耗、实时性有直觉不会上来就搞一个跑不动的模型。优势第二是 C 语言和底层功底。端侧部署最终都要落到 C/C 的推理代码上或者要跟各种硬件外设打交道这个过程里调指针、抠内存、做对齐、处理中断都是家常便饭这些恰好是嵌入式人的基本功。短板也很清楚。第一我对 Python 的生态不熟数据加载、训练脚本、模型导出这些都要重新学第二线性代数和概率的基础不够看着论文里的公式会头皮发麻。所以我的策略是不贪多先聚焦 CNN 一个点把原理、训练、部署打通再考虑扩散到其他模型。1.3 转端侧 AI 的三种常见误区我见过不少同行转端侧 AI 时走进误区这里直接说结论。误区一端侧 AI 就是会调 API。有些人拿个开源模型用现成工具转成部署格式能跑出结果就觉得自己会了。但一旦遇到算子不支持、精度掉得厉害、推理速度不达标就完全不知道从哪里下手。不会看中间细节等于黑盒调试很难往前走。误区二先把数学学完再动手。这个想法听起来很严谨实际上很容易劝退自己。卷积、梯度、反向传播这些概念最好的理解方式是在代码里看它们动起来而不是先啃三个月数学书。需要什么补什么效率高得多。误区三一上来就研究大语言模型。大模型对嵌入式工程师来说至少短期内不是主要战场。端侧设备上真正跑得最多的还是 CNN 这类视觉模型因为视觉任务对算力要求相对可控应用面也更广。把 CNN 吃透就等于先在端侧 AI 这个领域站稳了脚跟。2. 为什么从 CNN 重新开始一个嵌入式程序员视角下的卷积2.1 CNN 是端侧视觉任务的底座很多人会问既然有那么多现成的网络结构为什么还要从 CNN 从头学因为端侧视觉相关模型无论是目标检测的 YOLO 系列还是图像分类的 MobileNet、EfficientNet底层骨干网络基本都是 CNN。哪怕现在很多端侧设备上跑的 Transformer 结构也经常和卷积混合使用。理解了 CNN你就拿到了阅读这些模型结构图的钥匙。另外CNN 本身的结构非常适合在低功耗设备上运行。卷积层的权值共享大幅减少了参数量池化层降低了特征图尺寸这都很省内存。相比之下全连接网络光参数量就把 MCU 级别的 Flash 吃光了。所以做端侧 AI 的工程师几乎绕不开 CNN。2.2 图像在计算机眼里就是矩阵要理解卷积先要接受一个事实图像在计算机里是一堆数字。一张灰度图可以看成是一个二维矩阵每个元素就是像素点值在 0 到 255 之间0 是全黑255 是全白。一张彩色图则是一个三维张量形状可以写成高度宽度通道数RGB 三通道就是三个叠起来的矩阵。做过嵌入式的朋友对这个应该不陌生——这就像 SPI 屏上的一小块显存本质上也是一段连续的内存每个地址对应一个像素。只不过在 CNN 里这段内存还要跟一堆权重做乘加运算。你只要把它当成一个数据缓冲区去理解后面所有卷积运算都不会吓人。2.3 卷积核带权重的滑动窗口卷积的核心操作可以理解为一个小的权重矩阵称为卷积核在图像上按固定步长滑动每经过一个位置就把核里的权重和对应位置的像素做乘加得到一个输出值。我用一个具体的例子来说明。假设输入是一张 5x5 的灰度图卷积核是 3x3步长为 1不做填充。卷积核滑动到左上角覆盖图像的前三行前三列时计算如下输出值 图(0,0) * 核(0,0) 图(0,1) * 核(0,1) ... 图(2,2) * 核(2,2)这其实就是 9 次乘法、8 次加法。然后窗口右移一格再算一个输出值。整张图算完得到一个 3x3 的输出特征图。公式上可以写成输出尺寸 H_out (H - 核高 2 * 填充) / 步长 1听到“滑动窗口”做嵌入式的朋友应该很亲切这跟我们平时写的滑动平均滤波、按键扫描里的滑窗去抖本质上是同一类思路只是这里的“窗口”不是取平均而是带了一组训练出来的权重。卷积核里每个位置值是多少靠训练阶段自动学习不需要人手工设计。实际网络里卷积后面通常跟一个非线性激活函数比如 ReLU把负数置 0。这样做是为了让网络有能力表达非线性关系不然多层卷积叠起来数学上还是等价于一次线性变换深度就失去了意义。2.4 为什么卷积比全连接省这么多参数一个让很多新手震惊的对比是卷积层和全连接层的参数量差距。假设输入是 32x32x3 的彩色图如果直接用全连接层连到 1024 个神经元那一层就有 32 * 32 * 3 * 1024 个权重约 314 万个参数。在嵌入式设备上光是存这些 float 权重就需要约 12MB大多数 MCU 直接出局。而如果改用卷积层呢还拿 3x3 卷积核举例假设这一层从 3 个输入通道变成 32 个输出通道参数量就是3 * 32 * 3 * 3再加上 32 个偏置总共 896 个参数。差了三个数量级以上。卷积层省参数的本质在于两个设计局部连接和权值共享。局部连接是说每个输出值只跟输入图像的一个小区域有关而不是全局都连这符合图像局部特征相关的直觉权值共享是说同一个卷积核要在整张图上滑动使用不管图像多大一组权重只保存一份。这两点叠加参数量就断崖式下降了。计算量方面卷积层也不是没有代价。它在推理时要做的乘加次数仍然很多但对于端侧设备来说参数量的缩减直接决定了模型能不能塞进嵌入式设备的 Flash算力问题还能用 NPU、DSP 或者 CMSIS-NN 这类优化库来兜底。先解决能不能放下的问题再解决跑多快的问题这个顺序不能反。2.5 用 C 语言手写一个卷积层有了上面的基础我建议每个嵌入式工程师转型时都至少用 C 语言实现一次卷积层。这不是为了重复造轮子而是为了真正看清数据是怎么在内存里流动的。假设输入是单通道的 5x5 矩阵卷积核是 3x3输出是 3x3。C 语言实现的核心代码如下#define IN_SIZE 5 #define KERNEL 3 #define OUT_SIZE 3 // (5 - 3 2*0)/1 1 3 // 输入图像假设已经按行优先存在一维数组里 const float input[IN_SIZE][IN_SIZE] { {1, 2, 3, 4, 5}, {6, 7, 8, 9, 10}, // ... 省略 }; const float kernel[KERNEL][KERNEL] { {1, 0, -1}, {1, 0, -1}, {1, 0, -1} }; float output[OUT_SIZE][OUT_SIZE] {0}; for (int i 0; i OUT_SIZE; i) { for (int j 0; j OUT_SIZE; j) { float sum 0.0f; for (int ki 0; ki KERNEL; ki) { for (int kj 0; kj KERNEL; kj) { sum input[i ki][j kj] * kernel[ki][kj]; } } output[i][j] sum; } }这段代码看起来简单但里面体现出两个嵌入式开发里头很关键的点。第一多重循环的访存顺序会直接影响 cache 命中率第二在 MCU 上做浮点乘加运算很贵实际部署时往往要用定点数或者查表法替代。我后来部署模型时踩的浮点转定点的坑有一部分就是在这里想明白的。这段代码只处理了单通道和单卷积核的情况。真实 CNN 里输入是多个通道输出也是多个通道那就要在通道维度再加两层循环或者用矩阵乘法的方式重组数据让底层能调用优化过的 GEMM 库。思路是一样的只是工程细节更复杂。3. 训练一个简单的 CNN从数据集到导出模型3.1 训练框架怎么选有了原理基础下一步是选训练框架。我在 PyTorch 和 TensorFlow 之间犹豫过一阵最后选了 PyTorch。两个框架各有各的道理。TensorFlow 的 TFLite 生态非常完整尤其适合直接部署到移动端和嵌入式设备。PyTorch 的优势在于调试直观API 设计更接近 Python 的数据流习惯模型导出到 ONNX 的路也很顺。我为转做端侧 AI 的工程师提供一个选型参考对比维度PyTorchTensorFlow调试体验动态图类似 Python 脚本print 中间结果方便静态图和 Keras 高层接口封装更重模型导出ONNX 导出成熟转换工具链丰富TFLite 转换一键化但算子限制略多社区资料学术文章和开源项目默认给 PyTorch 代码移动端部署资料更多嵌入式生态通过 ONNX 桥接部署自带 TFLite Micro直接面向 MCU我的建议是如果你主要目标是把模型部署到像 MCU 这样资源极有限的设备上那就优先考虑 TensorFlow TFLite Micro。如果你之后还想折腾更多模型结构、读源码、甚至自定义训练逻辑选 PyTorch 更顺手。我自己选了 PyTorch部署端用 ONNX 转其他格式北美社区里也很多工程师这么干。3.2 从 MNIST 数据集训练一个手写数字识别模型第一个练手的项目我强烈推荐 MNIST 手写数字识别。28x28 的小图单通道输入数据也好获取。这个项目麻雀虽小五脏俱全训练、量化、部署的流程全都有。我用 PyTorch 写了一个简单的两层 CNN 结构一个卷积层 一个池化层 两个全连接层。核心代码大概是这样的import torch import torch.nn as nn class TinyCNN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 16, kernel_size3, padding1) self.relu nn.ReLU() self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(16 * 14 * 14, 64) self.fc2 nn.Linear(64, 10) def forward(self, x): x self.pool(self.relu(self.conv1(x))) x torch.flatten(x, 1) x self.relu(self.fc1(x)) x self.fc2(x) return x这个模型第二层全连接输出 10 个值对应 10 个数字的类别得分。训练逻辑把模型输出的 10 个分布分数跟真实标签算交叉熵损失再用反向传播更新权重。训练流程不复杂关键是理解几个基础概念。一个 epoch 就是把整个训练集过一遍batch size 是指一次迭代取多少张图学习率是每次更新权重的步长。我刚开始学的时候最常干的事是盯着 loss 曲线看看它是不是在下降如果下降太慢就调大学习率如果震荡就调小。MNIST 上用这个简单结构测试准确率很容易到 99% 左右你不需要什么花哨技巧。这里的目的不是刷榜而是让整个链路跑通。后面真正做嵌入式项目时数据会比 MNIST 脏很多准确率也不会这么理想但处理问题的方法是一样的。3.3 模型压缩和量化嵌入式部署的必经之路训练好的模型直接搬到嵌入式设备上十有八九跑不动。一个 float 参数占 4 字节上一节那个 TinyCNN 模型大约有 18 万个参数也就是 700 多 KB 的权重直接放 Flash 都紧张。更别说推理时要做大量浮点运算MCU 上根本没有 FPU 或者主频不够。所以量化是绕不开的事。量化的思路是把原来用 float32 表示的权重和激活值压到 int8 甚至更低精度去表示。模型大小直接缩成四分之一推理速度在支持整数运算指令的 MCU 上经常能快好几倍。量化里有两个关键概念scale 和 zero point。简单说就是把一个浮点数范围映射到一个整数范围通过线性变换表达。举例说明如果某层权重范围是 [-1.0, 1.0]要映射到 [-128, 127]那么 scale 约等于 (1.0 - (-1.0)) / (127 - (-128))每个浮点值对应一个整数推理时先做整数乘法累加最后再反变换回浮点。量化有量化感知训练和训练后量化两种路线。我建议新手先学训练后量化改动最小先跑通流程再研究量化感知训练那个需要在训练代码里插入模拟量化操作工程复杂度更高。模型导出这一步还有个高频坑并不是所有算子都能被转换工具支持。比如有些比较新的激活函数或者特殊池化方式转换器不支持就会报错。解决办法通常是改模型结构或者手动替换成等价的标准算子。这也是为什么我强调先看懂 CNN 结构再上手不然出了问题连改哪儿都不知道。4. 端侧部署实操把一个 CNN 搬到开发板上4.1 硬件选型你的模型适合什么级别的设备部署第一步是搞清楚目标硬件的资源边界。不同级别的设备能跑的模型复杂度和部署方式完全不同。我画了一张三档设备的对比表方便你评估项目该走哪条路。设备级别典型芯片RAM适合模型部署方式MCU 级Cortex-M4/M7128KB - 1MB小 CNN、关键词识别TFLite Micro、CMSIS-NNLinux MPU 级带 NPU 的 SoC256MB - 4GB目标检测、人脸识别RKNN、NNAPI、ONNX Runtime移动/桌面级CPUGPU2GB 以上大模型、超分模型TensorRT、CoreML、OpenVINO我做第一个端侧 AI 项目时选了 Cortex-M7 内核的开发板因为手上正好有一块而且这类板子的资料多踩坑相对容易排查。跑的是 MNIST 手写数字识别模型量化成 int8 之后模型权重大概 45KBRAM 峰值占用约 30KB单次推理在 168MHz 主频下大约 80 毫秒。这个性能其实不亮眼但作为一个从零到一跑通的 Demo非常有价值。如果你手上的设备性能更弱也不用担心Cortex-M3/M0 级别的单片机也有办法跑一些极端精简的模型前提是模型结构要改得很保守比如只用深度可分离卷积通道数压到很低。4.2 从 PyTorch 模型到嵌入式推理代码模型从 PyTorch 到 MCU中间要经过一次或多次转换。我走的链路是PyTorch → ONNX → TFLite → TFLite Micro 的 C 工程。为什么要转两次因为 PyTorch 没有直接输出 TFLite 的通道ONNX 是一个中立的模型交换格式。先用torch.onnx.export把模型导出成 ONNX 文件再用 TensorFlow 的转换工具把 ONNX 转成 TFLite 格式。期间要做量化设置输入输出张量的类型为 int8并准备一小部分校准数据集用来统计激活值的取值范围。下面这段是把模型转成 TFLite 并量化的关键代码我加了注释方便你直接照着改import tensorflow as tf # 将 ONNX 模型加载为 TFLite # 先确认 ONNX 里算子都能被转换器支持 converter tf.lite.TFLiteConverter.from_saved_model(mnist_saved_model) # 设置量化默认 int8 converter.optimizations [tf.lite.Optimize.DEFAULT] converter.representative_dataset representative_dataset_gen # 提供校准数据 converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type tf.int8 converter.inference_output_type tf.int8 tflite_model converter.convert() with open(mnist_int8.tflite, wb) as f: f.write(tflite_model)转换过程是个大坑集中地我遇到的最常见错误是“某些算子不在支持列表里”。原因往往是训练代码里用了太新的算子。解决办法倒是不复杂把这些算子手动改写为旧版兼容算子或者在 PyTorch 里用更基础的 API 重新实现对应层。4.3 手写数字识别 Demo 的完整流程我的 Demo 流程分成五步数据准备、训练、导出、部署、验证。这里给你一个可以直接照抄的检查清单。第一步数据准备。MNIST 可以直接从公开数据集下载也可以自己用 Python 读图片转成数组注意归一化到 0 到 1。第二步训练。按第三节的 TinyCNN 结构训练到测试精度 99% 左右。第三步导出。先转 ONNX再转 TFLite同时做 int8 量化。第四步部署。在单片机工程里引入 TFLite Micro 的源码加载模型文件初始化 interpreter把图像数据按固定格式填入输入张量。第五步验证。用开发板上的摄像头采集一张手写数字图片或者直接通过串口发送一张 28x28 的灰度数组看推理结果是否正确。我第一次跑通时结果识别错了而且错得离谱。排查了半个多小时发现是因为喂给模型的图像没有做同样的归一化处理。训练时数据范围是 0 到 1而开发板读到的原始像素是 0 到 255。这算是我踩过的最典型的部署细节坑。后面做任何模型都要注意预处理逻辑必须跟训练完全一致差一个缩放系数结果就全乱了。4.4 部署中的内存和算子问题在 MCU 上部署模型内存管理是个细致活。TFLite Micro 在运行时需要一块缓冲区用来放算子的中间结果叫做 arena buffer。这个 buffer 开多大取决于模型结构。我的经验是一次性加满内存再逐步调小比如先在 100KB 的 buffer 下测试不行就改小直到能跑起来为止。这是非常粗糙但非常有效的方式。算子问题则更隐蔽。模型在网络上有卷积层、全连接层、池化层、ReLU理论上都很标准。但转换之后有些算子虽然转换成功在 TFLite Micro 里跑起来却会报“未实现的 kernel”错误。这通常意味着该算子没有对应的微控制器实现。解决办法要么改模型结构要么手动写一个简单的算子注册。反正我在踩过几次以后学乖了训练模型之前先确认部署目标支持的算子列表别等部署时才拍脑袋。5. 踩过的坑和排查技巧实录5.1 高频问题速查表这段时间的实操我积累了不少问题和解决办法整理成下面这张速查表遇到问题先对着看一遍往往能省下大量 debug 时间。现象可能原因排查与解决模型转换时报算子不支持模型结构里有新算子换兼容算子或重写该层推理结果全乱但训练精度高预处理分发的数据范围不一致检查归一化、均值和标准差分发内存不足interpreter 报错arena buffer 太小逐步增加 buffer或压缩模型结构模型能跑但速度很慢浮点运算太多开启 int8 量化使用 CMSIS-NN 加速输出类别偏向某一类训练数据不平衡或量化校准集不充分增加校准数据检查数据分布单片机复位/死循环内存溢出或数组越界核查输入输出张量维度开堆栈看门狗串口打印的解析结果异常端序、数据类型不匹配统一模型输入类型为 int8并固定字节序这个表是我在几个项目里反复验证过的。有些问题虽然看起来不一样根子上就是训练和部署两边信息不对称参数一样结果就差得远。5.2 训练和部署之间最容易崩的三个环节第一个环节是数据。训练用 0 到 1 的 float 数据部署用 0 到 255 的 int 数据这是最经典的一致性问题。解决办法是模型导出时就把预处理写进模型里或者把预处理步骤在部署端同步实现两边严格对齐。第二个环节是量化精度。我遇到过一次量化后准确率从 99% 掉到 85% 的情况。原因是校准集选得不好只用了 100 张图去统计激活值范围覆盖不全面。后来我把校准集扩大到全部训练集的一个子集覆盖到数字 0 到 9 的各类情况量化损失就回到可接受范围了。量化的本质是对数值分布做压缩数据分布采不准压缩误差就大这点跟嵌入式里做定点信号处理是相通的。第三个环节是推理端的日志。嵌入式调试手段有限我的经验是先在 PC 上用相同模型做推理打印输出值再到板子上做推理打印同样的输出值两边对比。如果中间某个环节对不上很容易定位是预处理、量化还是推理代码的哪一步出了偏差。这个方法笨但真的有效。5.3 调试工具怎么配更顺手嵌入式部署的调试我用排序之后大概这么几样第一串口打印中间计算结果这个最直接能看什么打印什么第二用 GDB 调试 C 代码在定点转浮点那段特别有用第三用上位机脚本读模型输出对比 CPU 推理和板端推理的结果。我强烈建议记录每个版本模型的部署时间、内存占用、精度表现做一张简单的对比表。看起来像是多花了几分钟但当你迭代到第三个模型版本的时候这些数据能让你非常清楚地看到优化空间在哪里而不是靠感觉猜。6. 后续学习路线完成比完美重要走完上面这一整套流程之后我对端侧 AI 这件事的认知发生了不小的变化。从一个只懂硬件、觉得 AI 是魔法的人变成了能亲手把模型压缩到几万字节、让单片机做出正确判断的人。这种“亲手把黑盒打开”的感觉是推动我持续学下去的最大动力。如果你也是嵌入式出身我的建议是不要一上来就啃大模型或者追最新的架构先老老实实把一个简单的 CNN 跑通而且要跑到开发板上不是只在 PC 上训练完看个准确率就结束。“能部署”和“训练出来”是两个完全不同的阶段。完成一个最小的闭环比看十篇技术文章都管用。后面我自己打算继续做的事是把部署优化做得更细一些比如用 CMSIS-NN 的卷积优化要求重新调整内存布局比如在真实项目的图像数据上做量化感知训练再比如把目标检测模型 YOLO 换到带 NPU 的 Linux 开发板上跑。每一步都基于已经打通的 CNN 基础往上叠加路径就清晰得多。最后分享一个小经验端侧 AI 的学习路上卡住是常态不用慌。卡住的地方通常就是理解最薄弱的地方突破它之后后面会顺畅很多。嵌入式转 AI真正难的从来不是某一个算法而是把“算法工整地在设备上跑起来”这件事的全局观慢慢拼出来就好了。