STM32F103C8T6部署nano语言模型:int8量化与纯C推理
2026/9/18 10:01:50 网站建设 项目流程

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_model=32、单层、词表 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 结构,词表 64,d_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 只有 32,4 个头每个头才 8 维,注意力本身就没多少信息量。其次是砍 FFN 的中间维度,从 4 倍 d_model 降到 2 倍。最后才动 d_model,因为 hidden 维度一降,几乎所有的矩阵都跟着变小,是收益最大的操作,但也是最伤表达能力的。

我砍完之后的实际配置是:单层、2 头、FFN 64、d_model 32。参数量从最初的 4 万多降到 11400 左右。这里我用表格记录每一刀的效果,方便权衡:

砍法参数量变化生成质量主观评价
4 头 → 2 头-0(head 不改变总参数)几乎无变化
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怎么取。

如果每个张量用一个全局s(per-tensor),实现最简单,但那些数值范围小的权重会被量化得很粗。如果每个输出通道单独算s(per-channel),精度好很多,代价是卷积/矩阵乘时每个输出通道要单独乘一次缩放因子。

我做了一个非常实用的取舍:把s强制约束成 2 的整数次幂。这样反量化就变成纯粹的右移操作,MCU 上不需要任何浮点除法。代价是量化误差比自由取值的s稍大一点,但对这个规模的模型来说完全可以接受。

import numpy as np def quantize_pow2(w, bits=8): """把权重对称量化到 int8,缩放因子取 2 的幂""" qmax = 2 ** (bits - 1) - 1 # 127 amax = np.max(np.abs(w)) if amax == 0: return np.zeros_like(w, dtype=np.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 拷贝到 SRAM,20KB 的 SRAM 直接就被一个数组吃光了,程序还没跑就崩。

第二,数组长度要显式声明,比如const int8_t attn_wq[32*32] = {...},不要依赖编译器推断,否则一旦对不上你很难查。

第三,也是我踩过最疼的一次:导出的数组顺序。PyTorch 里线性层的权重形状是[out_features, in_features],但很多核函数写的是[in, out]的行主序。我一开始没转置,结果板子上的输出像被洗过牌一样,怎么调都调不对。后来在导出脚本里固定做了一次w.T并且打印形状核对,才彻底解决。

def dump_c_array(name, arr, dtype="int8_t"): flat = arr.flatten().astype(np.int8) body = ",".join(str(int(v)) for v in flat) return f"const {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 个 int32,256 字节;logits 64 个 int32,256 字节。注意力分数矩阵是 2 头 × 32 × 32,用 int32 存要 8KB,太大,所以我把它量化成 int16 存,降到 4KB,或者干脆把序列长度限死在 32 以内并按头复用。这些取舍直接决定了能不能通过编译。

4.3 栈和堆的边界钉死

栈溢出是新手最容易踩的坑,因为它崩得莫名其妙。我在一个函数里开了一个int32_t tmp[64]的局部数组做临时缓冲,结果这个数组在栈上占了 256 字节,加上其它局部变量,栈一下就被撑爆,函数返回到一个已经损坏的地址,硬件直接进 HardFault。

解决办法是:所有超过几十字节的缓冲区一律开成 static 或者全局,绝对不放在栈上。启动文件里的栈大小默认可能只有 1KB 到 2KB,对这个模型来说远远不够,我把它调到 4KB 并且在链接脚本里把堆设成 0(这个项目根本不用 malloc)。调完之后 HardFault 就再没出现过。

小经验:判断是不是栈溢出,最简单的方法是看 HardFault 发生时LRPC的值是不是一片乱码。如果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 输出乱码时的排查顺序

当板子吐出来的字符是一堆乱码时,不要急着改模型。按这个顺序查最省时间:

  1. 先确认权重的导出顺序和 C 代码里矩阵乘的读取顺序一致,重点看有没有转置。
  2. 再确认量化缩放因子的移位方向和位数对,一个>>写成<<结果会完全反过来。
  3. 然后检查 LayerNorm 的均值和方差计算是否用了正确的维度。
  4. 最后才怀疑量化误差,用 PC 端的整数模拟跑同样权重,输出应该和板子上完全一致,如果不一致,问题在 C 代码而不在量化。

我按这个顺序查,通常半小时内能定位,比漫无目的地改参数快得多。

5.4 这个 demo 还能往哪扩展

跑通之后我试了几个扩展方向,都挺有意思。一个是把生成速度提上去,用 DMA 把 Flash 里的权重分批搬到 SRAM 再算,减少取数等待,但收益有限,因为 Flash 在这块片子上本身就不慢。另一个是加一个按键输入,用 PA0 做触发,按一下生成一句,做成一个"随机诗签"的小玩意,放在桌上挺好用。

再往远了看,如果你手上有一块带 FPU 的 F4,可以把同样的权重跑在浮点上做对照,直观感受定点量化的精度损失到底有多大。我这边的对照结果是,量化后的输出和浮点输出在大部分位置一致,少数分叉点也是那种模型本身就在两个字符间摇摆的位置,说明量化损失是可控的。

最后分享一个小技巧:调这个模型的时候,把每一层输出的前几个数值通过串口打印出来,用 PC 端浮点版本的中间结果做逐层比对。哪一层的数值开始对不上,问题就在那一层。这个办法帮我定位过好几次"看起来没问题但结果就是错"的 bug,比盯着 C 代码一行行看高效得多。整条链路跑通之后你会发现,在 64KB Flash 的板子上塞下一个能说话的小模型,难的从来不是算力,而是把每一个字节都安排明白的耐心。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询