4. 目录结构解剖
TinyML项目与传统的嵌入式或纯云端AI项目最大的区别在于:它既要管理模型训练产物、又要管理C/C++编译链路、还要处理设备端资源约束与部署脚本。如果目录结构混乱,往往会出现“模型文件散落各处、配置参数被硬编码、测试代码与业务代码耦合”等问题,导致项目在迭代3-4个版本后便难以维护。本章将深入解剖一套经过多个量产项目验证的标准TinyML目录骨架,明确每个文件夹的边界、职责与协作关系,帮助你建立一套“拿到任何TinyML项目都能快速定位问题”的工程直觉。
上图展示了标准TinyML项目的顶层目录划分。这套结构并非凭空设计,而是基于三个核心原则:按构建阶段划分(源码、配置、模型产物)、按变更频率划分(高频改动的src与低频变动的third_party分离)、按职责边界划分(测试、文档、脚本各司其职)。下面逐一深入每个目录的职责与最佳实践。
4.1 src/ 与 include/:源码层的双向隔离
核心原理:src/ 存放所有C/C++源文件(.c、.cpp),include/ 存放对应的公共头文件(.h、.hpp)。这种分离不仅是语法层面的要求,更是编译依赖管理的基石。在TinyML项目中,src/ 内部通常进一步划分为 app/(应用逻辑)、hal/(硬件抽象层)、ml_runner/(模型推理封装)等子模块。include/ 则只暴露模块间的公共接口,禁止在头文件中包含局部实现细节。
操作流程:新建一个传感器驱动模块时,遵循以下步骤:
- 在
src/hal/下创建sensor_xxx.c,实现具体的寄存器读写、时序控制逻辑。 - 在
include/hal/下创建sensor_xxx.h,只声明初始化、读取、去初始化三个API函数。 - 在
src/app/中通过#include "hal/sensor_xxx.h"调用,不直接操作寄存器。
案例拆解:某智能家居项目需要同时支持温湿度传感器SHT30和BME280。若将两个驱动直接写在app层,当切换传感器时需修改业务代码。采用hal层抽象后,app层只调用 hal_sensor_read_temperature(),底层通过编译宏 USE_SHT30 或 USE_BME280 选择具体实现。这样硬件更换不影响业务逻辑,测试时也可用mock驱动替换真实硬件。
extern "C" 包裹(如果是C++项目),并添加 #pragma once 或传统Include Guard,防止重复定义。
file(GLOB) 自动收集源文件时,头文件会被误编译或导致依赖追踪混乱。务必保持 src/ 只含 .c/.cpp,include/ 只含 .h/.hpp。
4.2 config/:配置与代码的彻底解耦
核心原理:config/ 目录集中管理所有可调参数,包括板级引脚定义、模型输入输出维度、量化参数、网络服务器地址、采样频率等。在TinyML场景下,配置不仅影响编译期(如宏定义),也影响运行期(如从Flash读取的校准值)。将配置从代码中剥离,可以做到“改配置不重新编译”或“同一套代码适配多种硬件”。
配置分层策略:
| 配置类型 | 存储格式 | 读取时机 | 典型示例 |
|---|---|---|---|
| 编译期宏 | config.h / CMakeLists.txt | 编译时 | #define MODEL_INPUT_SIZE 96 |
| 运行期参数 | config.yaml / JSON | 启动时解析 | WiFi SSID、阈值 |
| 板级硬件映射 | board_config.h | 编译时 | 引脚编号、I2C地址 |
| 模型元信息 | model_metadata.json | 编译时生成 | 输入均值/方差、类别标签 |
操作流程:推荐使用 config/ 下的子目录区分不同硬件平台:
config/
├── boards/
│ ├── esp32_devkitc.h
│ ├── stm32l4_discovery.h
│ └── custom_board.h
├── model_params.yaml
├── app_settings.json
└── CMakeLists.txt # 根据目标板选择配置文件
实战场景:一个关键词唤醒项目需要部署到10台不同麦克风阵列的设备上。每台设备的增益校准值不同。若将增益硬编码在 src/ 中,每次校准都要重新烧录固件。将增益值存放在 config/ 下的 calibration.json 中,设备启动时从外部Flash读取,即可实现同一固件适配所有设备,只需单独烧录配置文件。
config/ 中维护一个 default_config.h,当某个配置缺失时使用默认值,避免因配置遗漏导致运行时崩溃。同时,配置项的命名应遵循 模块_参数_含义 的格式,如 AUDIO_SAMPLE_RATE_HZ。
4.3 models/:模型产物的版本化仓库
核心原理:models/ 目录存放所有模型相关文件,包括原始训练模型(.h5、.onnx)、转换后的TFLite模型(.tflite)、量化参数、标签映射文件(labels.txt)以及模型元信息。在TinyML中,模型是“数据”而非“代码”,因此不应与源码混放。将模型独立管理,可以方便地进行版本对比、大小优化和A/B测试。
推荐目录结构:
models/
├── trained/
│ ├── v1.0_float32.h5
│ └── v1.1_float32.h5
├── converted/
│ ├── v1.0_int8.tflite
│ └── v1.1_int8.tflite
├── labels.txt
├── metadata.json
└── README.md # 记录模型来源、训练数据、精度指标
案例拆解:某手势识别项目在模型迭代过程中,v1.0模型精度92%但体积45KB,v1.1模型精度95%但体积78KB。通过 models/ 目录下的 metadata.json 记录两个版本的RAM/Flash占用、推理延迟、精度指标,团队在评审时可以直接对比,最终选择v1.1并裁剪部分非关键层,将体积压缩至60KB。若没有独立的models目录,这些信息散落在聊天记录中,决策将变得困难。
git-lfs 或独立的模型存储服务。在 .gitignore 中排除 models/trained/ 下的原始大文件,只保留转换后的小体积TFLite文件。
4.4 scripts/:自动化流水线的控制台
核心原理:scripts/ 存放所有Python/Shell脚本,用于自动化重复性任务。在TinyML项目中,这些脚本通常包括:模型转换脚本(Keras→TFLite)、量化校准脚本、固件编译脚本、烧录脚本、日志分析脚本。将脚本独立于src/,是因为它们运行在宿主机(x86)而非目标设备(ARM Cortex-M),且使用Python而非C/C++。
关键脚本清单:
| 脚本名称 | 输入 | 输出 | 典型命令 |
|---|---|---|---|
| convert_model.py | models/trained/*.h5 | models/converted/*.tflite | python scripts/convert_model.py --input v1.1 --quantize int8 |
| build_firmware.py | src/ include/ config/ | build/*.bin | python scripts/build_firmware.py --board esp32 |
| flash_device.py | build/*.bin | 设备Flash | python scripts/flash_device.py --port /dev/ttyUSB0 |
| run_tests.py | tests/ | 测试报告 | python scripts/run_tests.py --target host |
操作流程:一个标准的CI/CD流水线应串联这些脚本:
- 开发者在本地运行
convert_model.py生成新的TFLite模型。 - 运行
build_firmware.py编译固件。 - 运行
run_tests.py在主机上执行单元测试(使用模拟数据)。 - 通过
flash_device.py烧录到真实硬件,运行集成测试。
实战场景:某团队需要每周更新一次模型。手动执行上述流程需要2小时且容易出错。通过编写 scripts/pipeline.py 将四个步骤串联,并加入参数校验和日志记录,整个流程压缩至15分钟,且可在Jenkins上定时触发。脚本中加入了 --dry-run 参数,允许在不实际烧录的情况下验证编译产物。
--help 参数,并包含必要的异常处理。脚本的依赖(如tensorflow、pyserial)应记录在 scripts/requirements.txt 中,确保环境可复现。
4.5 tests/:多层级测试保障
核心原理:tests/ 目录存放所有测试代码,分为单元测试(unit)、集成测试(integration)和硬件在环测试(hardware-in-the-loop)。TinyML项目的测试策略与纯软件项目不同:由于硬件资源有限,部分测试必须在宿主机上通过模拟器完成,另一部分则必须在真实硬件上验证。
测试层级划分:
- 单元测试(tests/unit/):针对纯逻辑函数,如数据预处理、滤波算法、阈值判断。在宿主机上运行,使用
cmocka或Unity框架。 - 集成测试(tests/integration/):验证模块间交互,如传感器驱动→预处理→模型推理的完整链路。可在宿主机上使用模拟传感器数据,也可在开发板上运行。
- 硬件测试(tests/hardware/):需要真实外设,如实际读取麦克风数据、验证Flash读写。通常通过
scripts/flash_device.py烧录专门的测试固件。
案例拆解:一个振动故障诊断项目,其核心算法是FFT特征提取。团队在 tests/unit/test_fft.c 中构造了已知频率的合成信号,验证FFT输出幅值误差小于1%。在 tests/integration/test_pipeline.c 中,将模拟的振动数据流经“采集→加窗→FFT→分类器”全链路,验证最终分类结果与预期一致。这两个测试在CI中每次提交代码时自动运行,确保算法重构不引入回归。
test_main.c 中。随着项目增长,测试文件应按照被测模块组织,并保持与 src/ 相同的目录层级。同时,硬件测试应标记为 requires_hardware,在CI中默认跳过,避免因缺少硬件导致流水线失败。
4.6 docs/、tools/ 与 third_party/:支撑性目录
核心原理:这三个目录虽然不直接参与编译,但对项目的长期可维护性至关重要。docs/ 存放设计文档、API参考、部署指南、调试笔记;tools/ 存放辅助开发的小工具,如内存分析脚本、模型可视化工具、数据集增强脚本;third_party/ 存放第三方依赖库,如TensorFlow Lite Micro、CMSIS-DSP、FreeRTOS内核,通常以git submodule或固定版本源码形式管理。
third_party/ 管理最佳实践:
third_party/
├── tflite-micro/ # 固定到某个commit或tag
├── CMSIS/ # ARM官方DSP库
├── freertos/ # 内核源码
└── README.md # 记录每个库的版本、修改记录、许可证
操作流程:当需要升级TFLite Micro时,不要直接修改 third_party/ 中的代码。正确的做法是:
- 在
third_party/README.md中记录当前版本和升级原因。 - 使用git submodule更新到新版本。
- 运行
tests/中的全部测试,确认兼容性。 - 如有必要,在
src/hal/中增加适配层,而不是修改第三方源码。
案例拆解:某项目需要将TFLite Micro从r2.14升级到r2.16,以获取新的算子优化。直接替换后,原有模型推理结果出现微小偏差。通过查阅 docs/ 中的模型精度基线文档,团队发现新版本改变了某些算子的舍入行为。最终在 config/ 中增加 USE_LEGACY_ROUNDING 宏,在 src/ml_runner/ 中做条件编译,既保留了新版本的性能提升,又通过回归测试验证了精度符合要求。