4、第一个推理程序:从官方例程入手,下载并编译ImageNet分类例程,在开发板上跑通第一个模型

好,到了真正动手的时候了。

前面几章我们聊了CMSIS-NN的理论、量化、算子。说实话,光看代码和公式,总觉得隔着一层纱。我自己的经验是,只有把程序烧进板子,看到LED闪烁或者串口打印出分类结果,那种「原来如此」的感觉才会真正出现。

这一章,我们就来干这件事——下载官方例程,编译,烧录,跑通第一个模型。

4.1 官方例程在哪里?

ARM官方在GitHub上维护了一个仓库叫 CMSIS_5,里面包含了CMSIS-Core、CMSIS-DSP、CMSIS-NN的全部源码和例程。我们需要的ImageNet分类例程就在 CMSIS_5/Device/Examples/ 目录下。

我个人习惯直接克隆整个仓库,虽然大一点,但后续查文档、看源码都方便。

git clone https://github.com/ARM-software/CMSIS_5.git
cd CMSIS_5/Device/Examples/

进去之后你会看到多个文件夹,我们重点关注 ImageNet 这个。里面包含了针对不同开发板的移植版本。

文件夹 目标开发板 MCU型号
ARMCM3 ARM Cortex-M3 模拟器 ARMCM3
ARMCM4_FP ARM Cortex-M4F 模拟器 ARMCM4
ARMCM7_SP ARM Cortex-M7 模拟器 ARMCM7
STM32F746 STM32F746G-Discovery STM32F746
STM32F769 STM32F769I-Discovery STM32F769

如果你手头是STM32F746 Discovery板,直接选 STM32F746 目录。其他板子可以参照类似结构移植,这个我们后面会讲。

4.2 例程结构解析

打开 STM32F746 目录,你会看到这样的文件结构:

STM32F746/
├── Inc/
│   ├── arm_nnexamples_cifar10.h
│   ├── arm_nnexamples_imagenet.h
│   └── ...
├── Src/
│   ├── arm_nnexamples_imagenet.c
│   ├── arm_nnexamples_cifar10.c
│   └── ...
├── RTE/
│   └── ...
├── MDK-ARM/
│   └── project.uvprojx
└── README.md

核心文件就两个:

  • arm_nnexamples_imagenet.c:主程序,包含模型加载、输入预处理、推理、输出解析
  • arm_nnexamples_imagenet.h:头文件,定义网络结构参数、权重数组声明

嗯,这里要注意。官方例程的权重是直接以C数组形式硬编码在头文件里的。这意味着你不需要额外加载模型文件,编译时就全部打包进固件了。对于初学者来说,这其实是个好事——少了一个出错的环节。

4.3 编译环境准备

官方例程支持三种工具链:

  • MDK-ARM (Keil):最省事,直接打开 .uvprojx 项目文件
  • IAR EWARM:需要导入项目
  • GCC + Makefile:适合Linux用户或喜欢命令行的朋友

我个人推荐初学者用Keil。为什么?因为官方例程就是基于Keil做的,你双击打开就能编译,几乎不用改任何配置。我在项目中遇到过太多因为工具链版本不一致导致的奇怪问题,Keil在这方面兼容性最好。

小提示: 如果你用Keil,确保版本在5.27以上。老版本对CMSIS 5的支持不完整,编译会报一堆头文件找不到的错误。

4.4 编译步骤(以Keil为例)

好,我们一步步来。

  1. 打开项目:双击 MDK-ARM/project.uvprojx
  2. 选择目标设备:在Project窗口右键 -> Options for Target -> Device,选择你的MCU型号。STM32F746 Discovery板选 STM32F746NGHx
  3. 配置调试器:在Debug选项卡选择 ST-Link Debugger,点击Settings确认SWD连接正常
  4. 编译:按F7或点击Build按钮

第一次编译会有点慢,因为要编译CMSIS-NN库的全部源文件。我当年第一次编译等了将近两分钟,还以为是电脑卡死了。别急,喝口水等一等。

编译成功的话,你会看到类似这样的输出:

Build Output:
compiling arm_nnexamples_imagenet.c...
compiling arm_convolve_s8.c...
...
linking...
Program Size: Code=286432 RO-data=1245632 RW-data=4096 ZI-data=16384
".\Objects\imageNet.axf" - 0 Error(s), 0 Warning(s)

看到 0 Error(s) 就说明编译通过了。

注意: 如果编译报错提示 #error "CMSIS-NN requires ARMCC or GCC",说明你的编译器版本不对。Keil默认用ARMCC,如果你装了AC6(ARM Compiler 6),需要在Options for Target -> Target中把编译器切回ARMCC。

4.5 烧录与运行

编译通过后,烧录就简单了。

  1. 用USB线连接开发板和电脑
  2. 在Keil中按F8或点击Download按钮
  3. 等待烧录完成,然后按开发板上的Reset键

程序运行后,串口会输出推理结果。你需要打开一个串口工具(比如PuTTY、SecureCRT,或者Keil自带的UART #1窗口),波特率设置为 115200。

你会看到类似这样的输出:

ImageNet classification example
Input image: cat.jpg
Top 5 predictions:
  1: tabby cat (0.852)
  2: tiger cat (0.091)
  3: Egyptian cat (0.032)
  4: Persian cat (0.011)
  5: Siamese cat (0.005)

看到这个输出,恭喜你,第一个模型跑通了!

说实话,我第一次看到串口打印出分类结果时,还挺激动的。一个在PC上训练好的模型,经过量化、转换,最终在单片机里跑了起来,而且只用了不到一秒就完成了推理。这种感觉,你试试就知道了。

4.6 例程代码核心流程

我们来看看这个例程到底做了什么。打开 arm_nnexamples_imagenet.c,核心逻辑其实很短:

int main(void)
{
    // 1. 初始化硬件(时钟、GPIO、UART)
    SYSTEM_Init();
    
    // 2. 加载输入图像(这里是从数组加载)
    //    实际应用中可以从摄像头或Flash读取
    q7_t *input_data = get_image_data();
    
    // 3. 运行推理
    arm_nnexamples_imagenet_run(input_data, output_data);
    
    // 4. 解析输出,打印Top-5结果
    print_top5(output_data);
    
    while(1);
}

真正的推理函数 arm_nnexamples_imagenet_run 内部调用了CMSIS-NN的API:

void arm_nnexamples_imagenet_run(const q7_t *input, q7_t *output)
{
    // 卷积层1
    arm_convolve_HWC_q7_RGB(input, conv1_w, conv1_b, conv1_out, 
                            32, 224, 224, 3, 3, 2, 2, 1, 1);
    arm_relu_q7(conv1_out, 32 * 112 * 112);
    arm_maxpool_q7_HWC(conv1_out, 32, 112, 112, 0, 2, 2, 2, pool1_out);
    
    // ... 后续层类似
    
    // 全连接层
    arm_fully_connected_q7_opt(pool5_out, fc6_w, fc6_b, fc6_out, 
                               512, 4096, 1);
    arm_relu_q7(fc6_out, 4096);
    
    // 输出层
    arm_softmax_q7(fc8_out, 1000, output);
}

你看,每一层就是一个函数调用。参数分别是输入、权重、偏置、输出、维度信息。你不需要关心底层是怎么做乘加、怎么处理溢出的,CMSIS-NN全帮你搞定了。

4.7 常见问题与避坑

我在带学生做这个实验时,遇到过几个高频问题,列出来给你参考:

问题1:编译通过,但烧录后串口无输出

原因:多半是串口引脚配置不对。STM32F746 Discovery板的USB虚拟串口对应的是USART1,引脚是PA9(TX)和PA10(RX)。检查一下你的板子原理图,确认串口号和引脚匹配。

问题2:推理结果全是0或乱码

原因:权重数据对齐问题。CMSIS-NN要求权重数组按16字节对齐。检查你的链接脚本,确保 .rodata 段对齐到16字节。在Keil中,可以在Options for Target -> Linker -> Misc controls 中添加 --align=16。

问题3:推理时间异常长(超过10秒)

原因:MCU时钟配置不对。默认HSI时钟只有16MHz,跑ImageNet这种大模型会慢到怀疑人生。检查SystemInit函数,确保配置了HSE+PLL,把主频跑到216MHz(STM32F746最高主频)。

我曾经有一次折腾了一下午,结果发现是时钟配置里PLL的M、N参数写反了,主频只有32MHz。你想想看,推理速度差了将近7倍,能不慢吗?

4.8 知识结构总览

这一章的内容,我用一张图帮你梳理一下:

第4章 知识结构图:跑通第一个推理程序 官方ImageNet例程 1. 下载CMSIS_5仓库 2. 解析例程结构 3. 编译环境配置 4. 编译与烧录 5. 串口查看结果 6. 代码流程分析 常见问题与避坑指南 核心目标:从下载到跑通,完整走通第一个CMSIS-NN推理流程

4.9 小结

这一章我们做了几件事:

  • 从GitHub下载了CMSIS_5官方仓库
  • 找到了ImageNet分类例程,理解了文件结构
  • 用Keil完成了编译和烧录
  • 在开发板上跑通了第一个模型,看到了分类结果
  • 分析了核心代码的推理流程

说白了,这一步就是「Hello World」。虽然模型是固定的、输入是写死的,但整个流程——从源码到二进制、从烧录到推理输出——你已经完整走了一遍。后面再换自己的模型、换自己的数据,无非就是改改权重数组和输入预处理,核心流程一模一样。

嗯,到这一步,你已经不是纸上谈兵了。你的开发板,现在是一个能识别图像的AI设备了。