没有开发板,也能跑通 Cortex-M 的 AI 库。用 QEMU 搭一套模拟环境,把 TSS 导出的异常检测模型真正跑起来。
先介绍这次的情况:手里只有 TSS(Time Series Studio,是 NXP 推出的针对时间序列数据进行模型自动化训练的一个专用工具,可以根据用户传入的数据集,自动搜索数据处理方法以及适配的模型)导出的一个算法库压缩包,里面是一个预编译好的静态库 libtss.a、一份头文件 TimeSeries.h,外加一个 metadata.json;另外还有一份采集好的数据 okfan.csv。
任务很直接:验证这个模型能不能正常推理。
问题也很直接——手边没有对应的开发板。库是给 Cortex-M33 编的,可我们不想因为"等一块板子"就把事情卡在这里。那能不能干脆不用硬件,先在 PC 上把它跑通?
答案当然是可以的。我们用 QEMU 把一颗 Cortex-M33"虚拟"出来,用半主机(semihosting)让固件直接读到 PC 上的 csv,再把每一帧的推理结果打回 PC 终端。下面就一步步把这套环境搭出来。
整理思路:把板子换成 QEMU
在动手之前,先把整条链路理清楚。我们要做的事,本质上就是把"真实开发板"这一环,换成一颗跑在 QEMU 里的虚拟 Cortex-M33,其它环节尽量保持不变。

图:整条链路——PC 出数据,QEMU 里的裸机固件做推理,结果再打回 PC 终端
这里有两个角色。一个是跑在 QEMU 里的裸机固件 tss_app.elf,它负责调用 libtss.a 做推理;另一个是宿主机(也就是我们的 PC),数据文件放在这里,最后的打印也回到这里。两者之间靠半主机这条"暗线"通信——待会儿会专门讲它。
先从库里"问"出编译参数
很多人上来就想着写代码、链接库,结果编出来的固件一跑就跑飞。原因往往是同一个:编译固件用的 ABI 和这个预编译库对不上。
库是别人编好的二进制,它用的是哪颗核、有没有 FPU、浮点用什么调用约定、枚举多大——这些都已经"焊死"在 libtss.a 里了。我们的固件必须完全对齐,否则在库的边界上,结构体和枚举的排布会悄悄错位,轻则结果是垃圾,重则直接崩。
好在这些信息不用猜,库文件自己就带着。用 readelf 把它的编译属性读出来即可:
$ arm-none-eabi-readelf -A libtss.a
Tag_CPU_arch: v8-M.mainline
Tag_CPU_name: "8-M.MAIN"
Tag_FP_arch: FPv5/FP-D16 for ARMv8
Tag_ABI_VFP_args: VFP registers
Tag_ABI_enum_size: small
这几行信息量很大。翻译过来就是:这是一颗 Cortex-M33(v8-M 主线),带 FPv5-SP-D16 单精度 FPU,浮点参数走 VFP 寄存器(也就是 -mfloat-abi=hard),枚举是小尺寸(对应 -fshort-enums)。
再顺手看一眼库还依赖谁:
$ arm-none-eabi-nm libtss.a | grep ' U '
U expf U logf U sqrtf U fabsf U floorf U memset ...
清一色是 expf/logf/sqrtf 这类数学函数——这说明链接时必须把 libm 带上,否则一堆符号找不到。到这一步,编译和链接要用的参数就全定下来了:
锁定的目标参数(全部来自库本身,不是拍脑袋)
-mcpu=cortex-m33 -mfpu=fpv5-sp-d16 -mfloat-abi=hard -fshort-enums;QEMU 机器选 mps2-an505(内置 Cortex-M33),链接时补上 libm。
先跑通:搭一个"不含库"的最小固件
拿到参数,别急着把库塞进去。裸机环境本身就有不少坑:核选错、FPU 没开、半主机读文件路径不对……这些问题要是和库混在一起,排查起来会非常痛苦。
所以我们先做一个不依赖 libtss.a 的"冒烟测试"固件,只验证三件事:终端能不能打印、浮点运算对不对、能不能从 PC 上读到文件。用 CMake 配好目标参数直接编:
cmake -S . -B build -G Ninja -DTSS_SMOKE_TEST=ON \
-DTSS_MCPU=cortex-m33 -DTSS_MFPU=fpv5-sp-d16 \
-DTSS_MFLOAT_ABI=hard
cmake --build build
qemu-system-arm -machine mps2-an505 -cpu cortex-m33 \
-kernel build/tss_app.elf \
-semihosting-config enable=on,target=native \
-nographic -serial none -monitor none
跑完终端里是这样的:
==== bring-up smoke test ====
[1] console output via semihosting: OK
[2] float math (3.5*2+1) = 8 (expect 8): OK
[3] reading host file: tss_lib/okfan.csv
first bytes: 134 123 134 125 135 126 ...
file read: OK
==== smoke test PASSED ====
看到第 [2] 行输出 8 特别关键——它说明 FPU 已经正确开启。如果浮点没配好,程序往往会卡死在第一条浮点指令上,根本走不到这一行。既然三项全绿,说明工具链、核、半主机这一整套底座都是可靠的,接下来再上库就踏实多了。
半主机:让固件"隔空"读到 PC 上的 csv
这里得专门说说半主机,因为它是整套方案能成立的关键。
裸机固件本身没有文件系统,可我们的数据 okfan.csv 明明在 PC 上。半主机的作用,就是让固件通过一条特殊指令把 I/O 请求"托管"给 QEMU,由 QEMU 在宿主机上帮它开文件、读字节。对固件来说,就像凭空多了一双手,能直接摸到 PC 的硬盘。
实现上其实很轻。触发方式是一条断点指令,寄存器里放操作码和参数:
static inline uint32_t semihosting_call(uint32_t op, void *arg) {
register uint32_t r0 __asm__("r0") = op;
register void *r1 __asm__("r1") = arg;
__asm__ volatile("bkpt 0xAB" : "+r"(r0) : "r"(r1) : "memory");
return r0;
/* SYS_OPEN=0x01 读文件, SYS_READ=0x06 ... */
}
QEMU 命令行里的 -semihosting-config enable=on 就是在告诉它"这条断点你别当异常,帮我把 I/O 接过去"。有了它,固件里一个几十行的流式读取器就能把 csv 一帧一帧地喂进来——每行 1000 个浮点数,正好是模型要的一帧(data_len 500 × data_dim 2)。
一个容易踩的小坑:路径是相对 QEMU 的工作目录
半主机开文件时,路径是相对于启动 QEMU 时所在的目录来解析的,不是相对固件。所以我们统一先切到工程根目录再启动 QEMU,代码里就能安心写成 "tss_lib/okfan.csv"。
开 FPU、链接库,写推理驱动
底座稳了,现在把库接进来。有两件事必须做对,缺一个都白干。
第一件,上电先开 FPU。既然我们用 -mfloat-abi=hard,程序里的浮点会直接编成硬件浮点指令;但 Cortex-M33 复位后 FPU 默认是关的,第一条浮点指令就会触发 HardFault。所以在 Reset_Handler 最开头,先把 CPACR 里 CP10/CP11 打开:
void Reset_Handler(void) {
/* 打开 CP10 & CP11,让 FPU 可用,必须在任何浮点代码之前 */
volatile uint32_t *cpacr = (volatile uint32_t *)0xE000ED88u;
*cpacr |= (0xFu << 20);
__asm__ volatile("dsb"); __asm__ volatile("isb");
/* ... 再拷 .data、清 .bss,然后进 main ... */
}
第二件,把 libtss.a 和 libm 放进同一个链接组。它们之间有相互引用,用 --start-group/--end-group 括起来,符号才能干净地解析:
target_link_libraries(tss_app PRIVATE
-Wl,--start-group libtss.a m c -Wl,--end-group)
驱动本身反而是最简单的一环。TSS 的调用套路很固定:拿到任务句柄,读出模型属性(每帧多大、判定阈值多少),初始化,然后进循环——读一帧、推理一帧、打印一帧。
p_ops = tss_get_task_ops(); /* 拿到任务句柄 */
attr = p_ops->algo_attribute(); /* data_len/dim, threshold=0.9 */
p_ops->ad_ops.init();
while (csv_read_frame(&rdr, frame, 1000) == 1000) {
p_ops->ad_ops.predict(frame, &prob);
/* prob < 0.9 判为 Anomaly,否则 Normal */
}
这里判定逻辑很朴素:predict() 给出这一帧"正常"的概率,低于模型推荐阈值 0.9 就判为异常。阈值不是我们定的,是从 metadata.json 里读出来的。
跑起来:251 帧的结果对不对?
到这一步,正式的推理固件就可以编译、下到 QEMU 里跑了。数据 okfan.csv 一共 251 行,也就是 251 帧。终端会逐帧打印概率和判定:
==== TSS Anomaly Detection on Cortex-M33 (QEMU) ====
frame size : 1000 floats threshold : 0.9000
frame | probability | verdict
------+--------------+---------
0 | 0.932860 | Normal
1 | 0.940757 | Normal
9 | 0.861199 | ANOMALY
...
==== Summary ====
frames processed : 251
Normal : 201
Anomaly : 50
重点看最后的汇总:251 帧里判出 201 帧 Normal、50 帧 Anomaly。这个数字不是随便看看就过——它正好和 metadata.json 里 benchmark 的混淆矩阵第一行 [201, 50] 完全对上。
这说明什么?说明我们这套 QEMU 环境跑出来的结果,和模型出厂时在标准流程里跑出来的结果是一致的。库没接错、参数没配错、推理逻辑也没写错——整条链路是可信的。
为什么这条对照很重要
模拟器最怕"跑是跑了,但结果是错的还不自知"。有了 metadata 里的基准数字做锚点,我们才敢说这次验证是真的通过,而不是碰巧打印出了一串看着像样的数。
顺手记下几个最容易踩的坑
整套流程走下来,真正卡人的其实就那么几处。列在这里,方便大家对照排查:
| 症状 |
十有八九是这个原因 |
| 跑到第一次打印浮点前就卡死 / HardFault |
FPU 没开:Reset_Handler 里漏了写 CPACR。 |
| 结果是一堆乱码或数值明显不对 |
ABI 不匹配:-fshort-enums、-mfloat-abi 和库对不上。 |
| 提示打不开 okfan.csv |
路径是相对 QEMU 工作目录的,启动前先切到工程根目录。 |
写在最后
回到开头那个问题:没有开发板,能不能验证一个 Cortex-M 的 AI 库?现在答案很清楚——能,而且验证结果可以和基准对得上。
这套 QEMU+半主机的思路,最舒服的地方在于:数据在 PC 上、结果打回 PC 上,改一改数据文件就能反复跑,完全不用等硬件、不用接线。当然它替代不了最终的真机测试——时序、外设、功耗这些还得回到板子上看——但在"先把库和模型验证通"这个阶段,它足够快也足够可信。
大家如果手里也有 TSS 导出的库,不妨把 okfan.csv 换成自己的数据跑一遍。遇到卡住的地方,多半逃不出上面那张坑表;要是碰到新的坑,也欢迎到云栈社区一起交流。