
1. 动手之前先算清楚这块板子的家底STM32F103C8T6 这块最小系统板大概是很多人摸到的第一块 STM32。72MHz 的 Cortex-M3 内核64KB Flash、20KB SRAM没有 FPU没有 DSP 扩展LQFP48 封装SWD 口用 PA13/PA14 接个 DAPLink 就能烧。平时拿来点灯、读 BMP280、跑个 FreeRTOS 任务调度都挺舒服。我这次要做的事情是在这块片子上部署一个自己训练的 nano 语言模型让它一个字符一个字符地往外吐字。听起来有点离谱但拆开看其实是一道很实在的资源算术题。语言模型的推理过程本质就是一堆定点数的乘加只要参数量足够小、权重的数值范围足够收敛MCU 完全跑得动。真正劝退的不是算力是内存和数值精度这两件事。所以第一个要解决的问题不是怎么写代码而是这个模型最多能有多大。我先把账本摊开。STM32F103C8T6 的 64KB Flash 里向量表、启动代码、HAL 库、我自己的推理引擎加起来大概要吃掉 15KB 到 20KB剩下 40KB 上下是权重的预算。20KB SRAM 里栈、全局变量、串口缓冲、激活缓冲区都要挤真正能给中间结果用的可能就 8KB 到 12KB。这个天花板决定了模型必须做成什么形状。小经验C8T6 标称 64KB Flash但实际能用的部分受链接脚本影响有些开发板默认脚本只给 60KB 甚至更少。工程一开始就把.map文件打开看实际占用比后面发现放不下再回来改要省事得多。1.1 为什么偏偏要在这块板子上跑语言模型有人可能会问ARM 开发板上跑 AI 不是应该用带 NPU 的芯片或者至少上个 Cortex-M7 带 FPU 的在 F103 上折腾语言模型图什么。我的理由是正因为资源紧到极致模型里每一个运算都被迫显式地写出来没有任何框架帮你藏东西。你会在 20KB 内存里亲手实现矩阵乘的累加器位宽、量化缩放因子的移位近似、softmax 的整数化这些细节在你用现成推理框架的时候是永远看不见的。等你把这些都趟过一遍再回头去看那些大模型推理引擎的源码会发现很多东西是同一套逻辑。另一个理由是可控性。自训练的 nano 模型参数量在一万到三万之间词表小、层数浅我可以把整个前向传播拆成能一眼看懂的手写循环出错的时候能一行一行跟。要是拿一个下载来的模型直接往板子上糊出了问题连从哪查都不知道。1.2 参数量、Flash 与 SRAM 的三角关系把模型塞进 MCU涉及三个互相拉扯的量参数量决定 Flash 占用中间激活决定 SRAM 占用层数和隐藏维度同时影响这两者。很多人只盯着参数量估 Flash忽略了 SRAM 才是先爆的那一个。我把不同配置下的大致占用列一下方便你选型时有个参照配置d_model层数词表参数量int8 权重占用峰值激活int8/int32 混合极简16148约 3600约 4KB约 1.5KB推荐32164约 11400约 12KB约 3KB上限32264约 19500约 20KB约 5KB冒险48296约 42000约 42KB约 8KB表里最后一行已经踩到 Flash 红线了HAL 库一进来基本就装不下。所以我最后选的是中间那档d_model32、单层、词表 64。这个配置下权重约 12KB激活峰值控制在 3KB 左右剩下的 SRAM 留给栈和串口跑生成循环的时候不会心慌。这里有个容易忽略的点词表大小的平方增长效应。词表从 64 加到 96光 embedding 就从 2048 参数变成 4608如果你还共享输入输出权重输出层的 LM head 也跟着涨。字符级建模的词表控制在 64 以内既覆盖了常见英文符号加大小写也不至于把 Flash 撑爆。1.3 这次部署真正要解决的三件事把目标拆清楚后面就不会跑偏。我要解决的问题只有三个。第一是把权重从训练框架里搬运到 MCU 能直接读的形式这一步涉及量化和 C 数组导出是整条链路里最容易出数值错误的环节。第二是写一个不依赖任何推理框架的纯 C 前向传播包括矩阵乘、归一化、激活和采样所有运算都要用整数完成。第三是把这些数据合理地摆在 Flash 和 SRAM 里让链接器不报错、运行时栈不溢出。这三件事里最花时间的其实是第一件。很多人以为量化就是round(x/scale)实际上对称量化、per-channel 和 per-tensor 的取舍、缩放因子取不取 2 的幂都会直接影响后面 C 代码里能不能用移位代替除法。我踩过最坑的一次就是用了非 2 的幂的缩放因子结果每个通道都要跑一次浮点除法速度直接掉了一半。2. 在 PC 上先把模型压到能塞进 Flash 的形状部署的功夫其实一大半在 PC 侧。模型训练得好不好、压得合不合理直接决定后面 C 代码是写五十行还是五百行。我这次的流程是先训一个普通精度的浮点模型确认它能正常生成文本再逐步往定点化方向压每压一步都在 PC 上用整数模拟跑一遍对照确认输出偏差在可接受范围内最后才导出成 C 数组。这个边压边对照的习惯非常重要。如果你训练完直接量化导出烧进板子发现输出全是乱码你根本分不清是量化误差、C 代码 bug 还是 Flash 里的权重被改写了。分步对照能把问题锁定在最小范围内。2.1 从零训一个字符级 nano 模型我用的是一套很朴素的字符级 Transformer decoder 结构词表 64d_model 32单层2 个注意力头FFN 中间维度 64最大序列长度 32。训练框架用 PyTorch参数量一万出头在笔记本 CPU 上几分钟就能跑到收敛。语料我选了一段几万字符的英文短语集合字符集手动统计了一遍把所有出现过的符号映射到 0 到 63 的整数上。这里要注意如果你打算让模型处理中文字符级词表会瞬间膨胀到几千MCU 上根本放不下想处理中文只能走字节级建模但字节级对这么小的模型来说生成质量会很难看。所以我在部署 demo 里老老实实用英文。训练代码本身没什么特别的关键是要在训练时就为后面量化留好余地。我在损失函数里加了一个很小的权重正则项让权重数值不要跑得太开这样量化到 int8 时动态范围不会太大精度损失也小。另外我在训练脚本里固定了随机种子保证每次跑出来的权重一致方便后面反复导出一模一样的数组做对照。# 训练时的关键约束限制权重幅值为 int8 量化留余量 def weight_reg(model): reg 0.0 for name, p in model.named_parameters(): if p.dim() 2: # 只约束矩阵权重不约束 norm 参数 reg reg p.pow(2).mean() return reg loss ce_loss 1e-4 * weight_reg(model)这个正则系数不能太大1e-4 这个量级够用了再大模型会欠拟合生成出来的东西没有结构。2.2 结构瘦身砍层数、砍维度、砍词表训完浮点模型后我做的第一件事是把它当对照组存起来。然后开始一层一层地砍。砍的顺序有讲究。最先砍的是注意力头数从 4 头降到 2 头甚至 1 头对这么小的模型影响不大因为 d_model 只有 324 个头每个头才 8 维注意力本身就没多少信息量。其次是砍 FFN 的中间维度从 4 倍 d_model 降到 2 倍。最后才动 d_model因为 hidden 维度一降几乎所有的矩阵都跟着变小是收益最大的操作但也是最伤表达能力的。我砍完之后的实际配置是单层、2 头、FFN 64、d_model 32。参数量从最初的 4 万多降到 11400 左右。这里我用表格记录每一刀的效果方便权衡砍法参数量变化生成质量主观评价4 头 → 2 头-0head 不改变总参数几乎无变化FFN 128 → 64-2048轻微变差可接受2 层 → 1 层-8192明显变差但句子结构还在d_model 48 → 32-10000 以上大幅下降但仍是合法输出砍到 11400 参数这个规模模型已经不太可能生成语法正确的长句了它更像一个记住了局部字符模式的接龙器。但这正是部署 demo 的价值所在——它验证的是链路能跑通而不是模型有多聪明。如果你希望生成质量更好那就得往上一档硬件上迁比如带 FPU 的 F4 系列。2.3 训练后量化与缩放因子的选法量化这一步是整个链路里最需要较真的地方。我采用的是对称量化权重量化到 int8范围锁定在 -127 到 127。核心公式就是q round(w / s)反量化是w ≈ q * s。关键在于这个s怎么取。如果每个张量用一个全局sper-tensor实现最简单但那些数值范围小的权重会被量化得很粗。如果每个输出通道单独算sper-channel精度好很多代价是卷积/矩阵乘时每个输出通道要单独乘一次缩放因子。我做了一个非常实用的取舍把s强制约束成 2 的整数次幂。这样反量化就变成纯粹的右移操作MCU 上不需要任何浮点除法。代价是量化误差比自由取值的s稍大一点但对这个规模的模型来说完全可以接受。import numpy as np def quantize_pow2(w, bits8): 把权重对称量化到 int8缩放因子取 2 的幂 qmax 2 ** (bits - 1) - 1 # 127 amax np.max(np.abs(w)) if amax 0: return np.zeros_like(w, dtypenp.int8), 0 # 找最小的 k 使 2^k amax / qmax k int(np.ceil(np.log2(amax / qmax))) s 2.0 ** k q np.clip(np.round(w / s), -qmax, qmax).astype(np.int8) return q, k # k 是移位量不是浮点 s返回的k直接存进 C 代码当移位量用反量化就是(q * x) k这个形式全程整数。这个操作让我后面的矩阵乘核心循环干净了一大截。2.4 导出成 C 数组时最容易犯的错量化完要把权重写进 C 文件。我用的是自己写的一个小脚本把 numpy 数组打成const int8_t数组。这里有几个坑必须提前说。第一生成的数组一定要加const修饰并且用__attribute__((section(.rodata)))或者干脆靠编译器默认的只读段让它留在 Flash 里。如果你不加const特别是在 Keil 的某些配置下数组会被分配到 RAM 的.data段上电时从 Flash 拷贝到 SRAM20KB 的 SRAM 直接就被一个数组吃光了程序还没跑就崩。第二数组长度要显式声明比如const int8_t attn_wq[32*32] {...}不要依赖编译器推断否则一旦对不上你很难查。第三也是我踩过最疼的一次导出的数组顺序。PyTorch 里线性层的权重形状是[out_features, in_features]但很多核函数写的是[in, out]的行主序。我一开始没转置结果板子上的输出像被洗过牌一样怎么调都调不对。后来在导出脚本里固定做了一次w.T并且打印形状核对才彻底解决。def dump_c_array(name, arr, dtypeint8_t): flat arr.flatten().astype(np.int8) body ,.join(str(int(v)) for v in flat) return fconst {dtype} {name}[{flat.size}] {{{body}}};导出时我会把每个权重的名字和形状打印出来存一份清单烧之前对着清单核一遍比事后debug省太多时间。3. 纯 C 手写推理没有任何框架可以依赖到了 MCU 这一侧所有事情都得自己来。没有 CMSIS-NN 帮你做 int8 卷积没有 TFLite Micro 的算子库甚至连math.h里的expf我都不敢随便用。我最后的推理引擎总共四百多行 C包含矩阵乘、归一化、激活、注意力、softmax 和采样几个部分。我之所以不直接用 CMSIS-NN有两个原因。一是它主要面向卷积和全连接对这个全是小矩阵和注意力的模型帮助有限二是它的量化约定和我自己定的 2 的幂缩放对不上硬接反而更麻烦。当然如果你只是想快速跑通一个全连接网络CMSIS-NN 是更省事的选择我这里是自己造一遍轮子目的是把每个细节看透。3.1 int8 矩阵乘的累加器位宽设计矩阵乘是整个推理里调用最频繁的算子它写得好不好直接决定生成速度。我把输入激活也量化到 int8权重也是 int8两者相乘必然要扩到 32 位累加否则很快就会溢出。最直观的实现在每个内层循环里做一次acc (int32_t)a * b。Cortex-M3 没有 SIMD一条 int8 乘法实际上会被编译器扩展成若干条指令但因为 M3 有SMULL这类 32 位有符号乘速度还算能接受。void matmul_i8(const int8_t *a, const int8_t *w, const int8_t *bias, int32_t *out, int in_dim, int out_dim) { for (int o 0; o out_dim; o) { int32_t acc bias ? bias[o] : 0; const int8_t *wp w o * in_dim; for (int i 0; i in_dim; i) { acc (int32_t)a[i] * (int32_t)wp[i]; } out[o] acc; // 保持 int32最后统一做移位 } }注意这里我让out保持 int32不急着在函数里做移位。原因是缩放因子的移位操作可以和后续的加 bias、激活合并到一步做减少中间来回。这也是为什么前面强调缩放因子要取 2 的幂——移位能随意挪不受数值表示形式限制。累加器的位宽要算一下。单个乘积最大是 127×127≈16000输入维度最大也就 64理论累加极值约 102 万int32 的 21 亿上限绰绰有余绝对不会溢出。这也是为什么我敢放心用最朴素的累加写法。3.2 LayerNorm 和 Softmax 的整数近似归一化和 softmax 是两块必须小心处理的运算因为它们都涉及除法。M3 上整数除法是几十个周期起步如果用得太多生成速度会掉得很难看。LayerNorm 我的做法是先求均值用 32 位累加除以维度这里做一次移位近似把维度取成 2 的幂这就是为什么我把 d_model 定成 32除法直接变成右移 5 位。方差这一项我做了简化用一个可学习的逐通道仿射参数把归一化效果融合进去训练时就把它学到 LayerNorm 的gamma/beta里。这样运行时其实只剩减均值加仿射两步省掉了开方和除法。void layernorm_i32(int32_t *x, const int16_t *gamma, const int16_t *beta, int n) { int32_t sum 0; for (int i 0; i n; i) sum x[i]; int32_t mean sum 5; // n 固定为 32 for (int i 0; i n; i) { int32_t t x[i] - mean; // gamma/beta 用 Q10 定点乘完右移回到整数 x[i] (t * gamma[i]) 10 beta[i]; } }Softmax 我做得更激进。因为采样只需要相对概率我在正数部分用一个查表法近似exp表长取 64 项把差值映射进去。如果只是 argmax 输出甚至连 exp 都不用算直接找 logits 的最大值下标即可。实际部署时我留了一个编译开关greedy 模式和采样模式切换。注意查表法近似 exp 的时候表的两端要处理好边界。我一开始把索引算越界了没发现结果查表读到别的数组数据生成出来的字符每隔几十步就乱一个查了两天才定位到。3.3 自回归生成循环与采样的组织方式前向传播跑完拿到词表大小的 logits接下来就是采样一个字符吐出来再把它拼回输入序列进入下一轮。这个循环看着简单但内存管理上有讲究。我需要维护一个长度为最大序列长度我设的 32的 token 缓冲每次生成新字符就追加进去超出长度就把最老的挤出去。位置编码我用了可学习的位置嵌入也量化过存在 Flash 里用的时候按位置下标去取。采样这一块我用的是 xorshift 随机数加累积分布法。先算所有概率之和再生成一个随机数落在 [0, sum) 区间从头累加第一个超过随机数的位置就是采样结果。温度参数我把 0.7、1.0、1.3 三档做成编译期可改的常量温度太高模型会胡言乱语太低又总是重复同一句话我实测 0.8 左右最舒服。static uint32_t rng_state 0x12345678; static uint32_t xorshift(void) { uint32_t x rng_state; x ^ x 13; x ^ x 17; x ^ x 5; rng_state x; return x; }随机数种子我固定成常量这样每次上电生成的结果一致方便我调试。要真正随机的话可以在启动时读一次 ADC 悬空引脚的噪声当种子这是个低成本的做法。4. 把 20KB 的 SRAM 抠出来内存布局的实战细节写完算子之后我遇到的最大障碍不是算法而是链接报错和运行时崩溃。原因基本都是内存放错了地方——权重跑进 RAM、大数组开在栈上、堆和栈打架。这一节把这些问题集中讲清楚因为它们是每个在 MCU 上跑模型的人都会撞上的墙。解决思路很清晰让所有只读数据留在 Flash让所有可变数据尽量小、尽量复用把栈和堆的边界钉死。4.1 const 权重的段属性与 XIP 读取Cortex-M3 是直接从 Flash 取指令执行的Flash 地址空间和 SRAM 在同一个线性地址里所以只要数组放在只读段CPU 取权重就像取指令一样不需要额外拷贝。我用的属性是#define FLASH_RO __attribute__((section(.rodata.w))) FLASH_RO const int8_t __attribute__((aligned(4))) attn_wq[32 * 32] { ... };对齐到 4 字节是为了让编译器在可能的情况下用更宽的加载指令虽然 int8 读一次还是一个字节但对齐能避免某些情况下访问异常。更实际的好处是让链接器排布更规整.map文件看起来清楚。验证权重确实在 Flash 的一个简单办法是看.map文件里的段归属或者直接在运行时打印权重的地址如果它落在0x08000000开头的区域那就是 Flash如果落在0x20000000开头那就是被搬到 SRAM 了。4.2 激活缓冲区的复用策略前向传播过程中会用到很多中间结果Q、K、V 三个矩阵、注意力分数、FFN 中间层、logits。这些缓冲区如果各开各的很快就把 SRAM 吃光。我的做法是开一个大的一维缓冲区按用途划成不同区域让不共存的结果共享同一块内存。关键观察是注意力阶段用完 Q/K/V 之后FFN 阶段才开始用中间层两者时间上不重叠所以可以让它们复用同一块空间。不过这个复用一定要分析清楚生命周期一旦搞错后面会出现输入变了输出不变这种非常难查的 bug。我在复用之前用铅笔在纸上画了一遍时序确定某块内存的前一个用途彻底结束之后才敢复用。具体尺寸上Q/K/V 每个是 32 个 int32共 384 字节FFN 中间层 64 个 int32256 字节logits 64 个 int32256 字节。注意力分数矩阵是 2 头 × 32 × 32用 int32 存要 8KB太大所以我把它量化成 int16 存降到 4KB或者干脆把序列长度限死在 32 以内并按头复用。这些取舍直接决定了能不能通过编译。4.3 栈和堆的边界钉死栈溢出是新手最容易踩的坑因为它崩得莫名其妙。我在一个函数里开了一个int32_t tmp[64]的局部数组做临时缓冲结果这个数组在栈上占了 256 字节加上其它局部变量栈一下就被撑爆函数返回到一个已经损坏的地址硬件直接进 HardFault。解决办法是所有超过几十字节的缓冲区一律开成 static 或者全局绝对不放在栈上。启动文件里的栈大小默认可能只有 1KB 到 2KB对这个模型来说远远不够我把它调到 4KB 并且在链接脚本里把堆设成 0这个项目根本不用 malloc。调完之后 HardFault 就再没出现过。小经验判断是不是栈溢出最简单的方法是看 HardFault 发生时LR和PC的值是不是一片乱码。如果PC指向了一个根本不在 Flash 范围内的地址那十有八九就是栈被冲了。5. 实测效果以及那些只有真上手才知道的坑烧进去之后通过 USART1 接串口助手波特率 115200上电就能看到模型开始一个字一个字往外吐。生成速度我实测每字符大约 20 到 30 毫秒一个 32 字符的句子要一秒左右。这个速度对交互式应用来说很慢但对于验证MCU 能跑语言模型这件事来说够用了。我做了一个简单的串口交互PC 端发一个起始字符板子从那个字符开始往下续写续写满 32 个字符就换行等待下一次输入。整个过程没有任何 RTOS就是一个主循环加 UART 中断接收。要提的一点是你完全可以把推理放在一个 FreeRTOS 任务里串口收发放另一个任务但要注意任务栈别开太小我试过在任务里跑推理栈给 2KB 直接崩。5.1 一个完整的实测数据记录我把不同配置下的实测数据记下来方便你估时间配置每 token 耗时32 字符句子总耗时备注d_model 32单层无优化约 28ms约 0.9s-Os 编译d_model 32单层展开内层循环约 20ms约 0.65s手动 unroll 4 次d_model 32两层约 55ms约 1.8s明显变慢d_model 16单层约 12ms约 0.4s质量掉得厉害手动展开内层循环大约能带来 30% 左右的提升代价是代码可读性变差。如果你追求速度可以在关键的内层循环上做 unroll但别全局展开Flash 会涨得很快。5.2 软浮点、栈溢出与权重跑到 RAM 三个经典翻车第一个坑是软浮点。F103 没有硬件浮点单元任何float运算都会调用__aeabi_fmul之类的软件仿真函数一次乘法可能要几十个周期。我第一版代码里偷懒用了float做归一化结果每 token 要 200 多毫秒。全部换成定点之后降到 28ms快了七八倍。这就是为什么我从头到尾都在强调整数化。第二个坑是栈溢出前面讲过把大数组从栈挪到静态区就好了。第三个坑最隐蔽权重数组忘了加const。代码能编译过上电也能跑但 SRAM 莫名其妙只剩几百字节多生成几个字符就死。查了半天才发现.data段被一个 12KB 的权重数组占满了。加了const之后直接解决。5.3 输出乱码时的排查顺序当板子吐出来的字符是一堆乱码时不要急着改模型。按这个顺序查最省时间先确认权重的导出顺序和 C 代码里矩阵乘的读取顺序一致重点看有没有转置。再确认量化缩放因子的移位方向和位数对一个写成结果会完全反过来。然后检查 LayerNorm 的均值和方差计算是否用了正确的维度。最后才怀疑量化误差用 PC 端的整数模拟跑同样权重输出应该和板子上完全一致如果不一致问题在 C 代码而不在量化。我按这个顺序查通常半小时内能定位比漫无目的地改参数快得多。5.4 这个 demo 还能往哪扩展跑通之后我试了几个扩展方向都挺有意思。一个是把生成速度提上去用 DMA 把 Flash 里的权重分批搬到 SRAM 再算减少取数等待但收益有限因为 Flash 在这块片子上本身就不慢。另一个是加一个按键输入用 PA0 做触发按一下生成一句做成一个随机诗签的小玩意放在桌上挺好用。再往远了看如果你手上有一块带 FPU 的 F4可以把同样的权重跑在浮点上做对照直观感受定点量化的精度损失到底有多大。我这边的对照结果是量化后的输出和浮点输出在大部分位置一致少数分叉点也是那种模型本身就在两个字符间摇摆的位置说明量化损失是可控的。最后分享一个小技巧调这个模型的时候把每一层输出的前几个数值通过串口打印出来用 PC 端浮点版本的中间结果做逐层比对。哪一层的数值开始对不上问题就在那一层。这个办法帮我定位过好几次看起来没问题但结果就是错的 bug比盯着 C 代码一行行看高效得多。整条链路跑通之后你会发现在 64KB Flash 的板子上塞下一个能说话的小模型难的从来不是算力而是把每一个字节都安排明白的耐心。