有次跟一个做服务器的朋友聊天,他说你们嵌入式搞AI就是在玩具上跑马灯。我当时就跟他杠上了——Cortex-M4带DSP和FPU,跑个轻量推理怎么就玩具了?

后来自己真动手试了试,发现……嗯,确实有点自虐倾向。

先说说硬件条件

手里有块STM32F407VET6,主频168MHz,192KB SRAM,1MB Flash。TFLite Micro官方推荐的最低配置是Cortex-M3起步,所以这块片子在门槛上站着,没被劝退。

TensorFlow Lite Micro的模型要转成C数组——.tflite文件用xxd转成十六进制头文件,然后编译进固件。TensorFlow官方给了个person detection的例程,模型大概300KB出头,我这1MB Flash刚好塞得下。

所以第一步就栽了。

$ xxd -i person_detect.tflite > person_detect_model_data.cc

// 打开一看,好家伙,300多KB的数组
const unsigned char g_person_detect_model_data[] = {
  0x1c, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4c, 0x33,
  // ... 省略三十万行
};

编译的时候Cortex-M4的Flash限死1MB,模型本身占了1/3,剩下的空间要给tensorflow runtime、中间层buffer、还有我自己的业务代码。Model interpreter初始化的时候会申请一片arena内存用于tensor分配——官方建议至少2×模型大小,我这模型300KB意味着至少600KB的arena,SRAM才192KB。

这就有点尴尬了。

第一反应是换个大内存的型号。但倔劲上来了——我就不信F407跑不了。

解决办法其实挺土

主要做了两件事:

一是把模型的量化往死里压。原始模型是float32的,算子输出动不动就几个MB的中间tensor。换成int8量化,模型体积直接压到90KB,中间tensor也缩到四分之一。TFLite Micro的tflm_optimizer工具可以自动插量化节点,不需要自己手写,但前提是你的训练脚本里得加了quantization-aware training的标记——否则精度会崩。我试过纯post-training quantization,准确率从91%掉到73%,直接不能用。最后还是回头加了QAT,折腾了大概两天。QAT训练完再转int8,准确率回到88%,能接受。

二是改了arena的分配策略。TFLite Micro默认用的MicroMutableOpResolver会注册所有算子,但实际上这个模型只用到了其中几个。我手动只注册CONV_2D、DEPTHWISE_CONV_2D、FULLY_CONNECTED和SOFTMAX,省了十多KB的Flash占用。

static tflite::MicroMutableOpResolver<6> resolver;
resolver.AddConv2D();
resolver.AddDepthwiseConv2D();
resolver.AddFullyConnected();
resolver.AddSoftmax();
resolver.AddReshape();
resolver.AddQuantize();

后来我发现一个更坑的事:arena的起始地址要对齐到16字节,否则解释器初始化直接assert失败。查了三天源码才发现是alignment的问题,代码里写的是alignas(16)但Keil MDK的默认对齐策略不认这个——得手动attribute指定。

// 别问我怎么知道的
static uint8_t tensor_arena[90 * 1024] __attribute__((aligned(16)));

加上这个attribute之前,Interpreter::arena_size_设对了但还是立马hard fault。打开调试器看反汇编,发现LDMIA指令加载doubleword的时候地址bit3不是0,直接进异常。这玩意儿在ARM文档里其实写了——Load/store multiple指令要求地址对齐到word边界——但谁看文档啊,还不是出了问题才翻。

跑起来是什么效果

一张32×32的单通道灰度图,从摄像头OV2640抓下来,经过预处理缩放到模型输入尺寸,然后跑一轮推理。我直接在GPIO上拉了示波器,量出来的时间比printf打出来的还准:

conv2d      → 约18ms   (最慢的一层,kernel 3×3, 8 depthwise)
depthwise   → 约12ms   (5×5 depthwise conv)
fc          → 约3ms    (全连接层, 64 neurons)
softmax     → 约1ms    (就2个输出)
总计        → 约34ms

168MHz的M4跑这个速度我觉得还行——30fps做不到,但20fps出头是稳的。实际做person detection场景下,一个人从走进摄像头视野到被检测出来,响应时间不到100ms,够用。

唯一的问题是TFLite Micro在STM32上的UART调试输出默认是关闭的,你得自己去实现一个ErrorReporter。我第一次跑的时候啥输出都没有,Interpreter->Invoke()返回值是kTfLiteError,但完全不知道错在哪。后来加了个串口打印的ErrorReporter,才看到是arena不够大,报了个fail! Arena size is too small

class SerialErrorReporter : public tflite::ErrorReporter {
  int Report(const char* format, va_list args) override {
    char buf[256];
    vsnprintf(buf, sizeof(buf), format, args);
    SerialPrint(buf);  // 自己实现的UART输出
    return 0;
  }
};

关于功耗和发热

跑推理的时候用电流钳量了一下,空闲时大概35mA @ 3.3V,跑推理时跳到85mA左右。推理一轮34ms,如果每秒跑5帧(差不多够用的检测频率),等效电流大概是 idle(1-0.17) + active0.17 ≈ 43mA。跑一天下来约1度电都不到——这大概是MCU跑AI最让GPU用户羡慕的地方。我那朋友的RTX4090跑一次推理的功耗够我这块板子跑一个月的。

还有几个想吐槽的点

如果你也想试,建议直接从官方examples里的micro_speech或者person_detection起步,别像我一样一上来就想从零搭工程——TensorFlow的build系统用的是Bazel,跟STM32的Makefile体系完全不兼容,折腾Bazel的时间比写代码的时间还长。我最后是直接把官方example的cmake工程搬过来改的,而不是从零写Makefile。

另外tflite_micro的版本更新特别快,一个月前看的API跟现在的就不一样了。比如MicroMutableOpResolver构造函数现在要传一个int max_number_of_ops参数,但老版本的示例代码里是没有这个参数的。我在github issues里翻了大半天才搞清楚,结果一看release note——2.0版本改了API,没有向后兼容。

还有那个模型部署的流程,官方文档说“Convert the model to C array, build with the interpreter”,听起来多简单。但实际上你需要把你用的平台的头文件全部包含对,链接参数得配齐,cmsis_nn的加速库也得单独拉——否则conv2d用纯C实现跑一轮要150ms,加了CMSIS-NN的arm_convolve_s8能缩到18ms,差了8倍多。

真心话:MCU上跑AI不是噱头,但要接受一个事实——你只能跑别人已经训练好并且极度量化的模型,想在自己设备上训练就别想了。但如果说"能不能跑",答案是能,而且体验还挺奇妙的。看着一个只有几百KB Flash的单片机判断出"画面里有个人",那种感觉跟用GPU训出一个模型是完全不一样的。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐