4. 目录结构解剖

TinyML项目与传统的嵌入式或纯云端AI项目最大的区别在于:它既要管理模型训练产物、又要管理C/C++编译链路、还要处理设备端资源约束与部署脚本。如果目录结构混乱,往往会出现“模型文件散落各处、配置参数被硬编码、测试代码与业务代码耦合”等问题,导致项目在迭代3-4个版本后便难以维护。本章将深入解剖一套经过多个量产项目验证的标准TinyML目录骨架,明确每个文件夹的边界、职责与协作关系,帮助你建立一套“拿到任何TinyML项目都能快速定位问题”的工程直觉。

TinyML 标准项目目录结构总览 project_root/ src/ main.c / app/ hal/ / ml_runner/ 业务逻辑与驱动 include/ *.h 公共头文件 API声明/类型定义 编译期接口契约 config/ *.yaml / *.json / *.h 板级配置/模型参数 编译宏开关 models/ .tflite / .h5 / .onnx 量化/转换后产物 模型版本管理 scripts/ build.py / flash.py convert_model.py 自动化流水线 tests/ unit/ / integration/ 仿真/硬件在环测试 回归保障 docs/ 设计文档/API手册 部署指南/调优记录 团队知识沉淀 tools/ profiler / 可视化 数据集处理工具 辅助开发效率 third_party/ CMSIS / TFLM / RTOS 外部依赖锁定版本 可复现构建 核心原则:按“构建产物”与“变更频率”划分,而非按“文件类型”划分

上图展示了标准TinyML项目的顶层目录划分。这套结构并非凭空设计,而是基于三个核心原则:按构建阶段划分(源码、配置、模型产物)、按变更频率划分(高频改动的src与低频变动的third_party分离)、按职责边界划分(测试、文档、脚本各司其职)。下面逐一深入每个目录的职责与最佳实践。

4.1 src/ 与 include/:源码层的双向隔离

核心原理:src/ 存放所有C/C++源文件(.c、.cpp),include/ 存放对应的公共头文件(.h、.hpp)。这种分离不仅是语法层面的要求,更是编译依赖管理的基石。在TinyML项目中,src/ 内部通常进一步划分为 app/(应用逻辑)、hal/(硬件抽象层)、ml_runner/(模型推理封装)等子模块。include/ 则只暴露模块间的公共接口,禁止在头文件中包含局部实现细节。

操作流程:新建一个传感器驱动模块时,遵循以下步骤:

  1. 在 src/hal/ 下创建 sensor_xxx.c,实现具体的寄存器读写、时序控制逻辑。
  2. 在 include/hal/ 下创建 sensor_xxx.h,只声明初始化、读取、去初始化三个API函数。
  3. 在 src/app/ 中通过 #include "hal/sensor_xxx.h" 调用,不直接操作寄存器。

案例拆解:某智能家居项目需要同时支持温湿度传感器SHT30和BME280。若将两个驱动直接写在app层,当切换传感器时需修改业务代码。采用hal层抽象后,app层只调用 hal_sensor_read_temperature(),底层通过编译宏 USE_SHT30 或 USE_BME280 选择具体实现。这样硬件更换不影响业务逻辑,测试时也可用mock驱动替换真实硬件。

经验提醒:include/ 目录应保持“扁平化”或“按模块分一层子目录”,避免过深的嵌套。建议在头文件中使用 extern "C" 包裹(如果是C++项目),并添加 #pragma once 或传统Include Guard,防止重复定义。
风险提示:不要将头文件与源文件混放在同一目录。否则当使用CMake的 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仓库(尤其是大文件)。建议使用 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.pymodels/trained/*.h5models/converted/*.tflitepython scripts/convert_model.py --input v1.1 --quantize int8
build_firmware.pysrc/ include/ config/build/*.binpython scripts/build_firmware.py --board esp32
flash_device.pybuild/*.bin设备Flashpython scripts/flash_device.py --port /dev/ttyUSB0
run_tests.pytests/测试报告python scripts/run_tests.py --target host

操作流程:一个标准的CI/CD流水线应串联这些脚本:

  1. 开发者在本地运行 convert_model.py 生成新的TFLite模型。
  2. 运行 build_firmware.py 编译固件。
  3. 运行 run_tests.py 在主机上执行单元测试(使用模拟数据)。
  4. 通过 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/ 中的代码。正确的做法是:

  1. 在 third_party/README.md 中记录当前版本和升级原因。
  2. 使用git submodule更新到新版本。
  3. 运行 tests/ 中的全部测试,确认兼容性。
  4. 如有必要,在 src/hal/ 中增加适配层,而不是修改第三方源码。

案例拆解:某项目需要将TFLite Micro从r2.14升级到r2.16,以获取新的算子优化。直接替换后,原有模型推理结果出现微小偏差。通过查阅 docs/ 中的模型精度基线文档,团队发现新版本改变了某些算子的舍入行为。最终在 config/ 中增加 USE_LEGACY_ROUNDING 宏,在 src/ml_runner/ 中做条件编译,既保留了新版本的性能提升,又通过回归测试验证了精度符合要求。

总结复盘:一个优秀的TinyML项目目录结构,应该让新成员在10分钟内找到“模型在哪里、配置在哪里、如何编译、如何测试”。上述七个目录(src、include、config、models、scripts、tests、docs)加上支撑性的tools和third_party,构成了一个完整的工程骨架。在实际项目中,可以根据团队规模适当合并(如小型项目可将tools并入scripts),但核心的“源码与配置分离、模型与代码分离、测试与业务分离”原则不可动摇。记住:目录结构是团队协作的契约,比个人编码风格更重要。