4. TinyML项目结构设计
TinyML 项目与传统的嵌入式或云端 ML 项目有显著差异:它既要面对交叉编译工具链的复杂性,又要处理模型文件、算子库、设备驱动和板级配置等多类异构资源。一个设计不良的目录结构,往往在项目只有三五个源文件时看似无碍,但当模型版本迭代、新增传感器驱动或适配第二块开发板时,就会暴露出依赖混乱、构建脚本难以维护、测试无法独立运行等问题。本章将围绕一套经过实践验证的目录布局展开,深入讲解模块化设计原则、依赖组织方法以及可复用性的实现路径。
4.1 推荐目录布局:五个核心目录的职责边界
一个经过验证的 TinyML 项目目录结构,通常由 src/、include/、lib/、tests/ 和 cmake/ 五个一级目录构成。这五个目录并非随意划分,而是依据"源码与接口分离、第三方与自研分离、构建配置与业务代码分离"的原则进行组织。
src/ 目录存放所有业务实现代码,包括主程序入口、模型推理流水线、传感器驱动封装、通信协议处理等。该目录下的文件是项目的主体逻辑,通常按照功能模块进一步划分子目录。例如,一个关键词唤醒项目可能包含 src/main.c、src/ml/inference_engine.c、src/sensors/microphone.c 等文件。src 目录中的代码只包含实现细节,不直接暴露给外部使用者。
include/ 目录存放所有对外公开的头文件,其目录结构与 src 保持镜像对应。这种"接口与实现分离"的设计,使得其他模块或测试代码只需要包含 include 目录下的头文件,而无需关心 src 内部的具体实现。例如,include/ml/inference_engine.h 声明了推理引擎的初始化、运行和释放接口,而具体实现则在 src/ml/inference_engine.c 中。
lib/ 目录是依赖管理的核心,统一存放第三方库和自研可复用库。对于 TinyML 项目,常见的第三方库包括 TensorFlow Lite for Microcontrollers、CMSIS-NN、CMSIS-DSP 等。建议在 lib 下按"库名/版本号"的方式组织,例如 lib/tflite-micro/、lib/cmsis-nn/。自研的可复用组件(如音频编解码器、滤波算法库)也可以放在 lib 下,与第三方库统一管理。
tests/ 目录存放所有测试代码,包括单元测试、集成测试和性能基准测试。测试代码与业务代码分离,可以避免测试文件污染生产代码目录,也便于在 CI/CD 流水线中单独构建和运行测试目标。
cmake/ 目录存放 CMake 构建系统的辅助文件,包括交叉编译工具链定义、板级配置、公共 CMake 模块等。这些文件不参与业务逻辑,但它们是跨平台构建的关键支撑。
| 目录 | 核心职责 | 典型内容 | 构建角色 |
|---|---|---|---|
| src/ | 业务实现代码 | main.c、推理流水线、驱动封装 | 编译为对象文件,链接生成最终固件 |
| include/ | 对外公开接口 | 模块头文件、平台抽象层 | 作为头文件搜索路径,不参与编译 |
| lib/ | 第三方与自研库 | tflite-micro、CMSIS-NN、自研算法库 | 编译为静态库,供 src 和 tests 链接 |
| tests/ | 测试与验证代码 | 单元测试、集成测试、benchmark | 独立构建目标,不进入生产固件 |
| cmake/ | 构建系统辅助 | 工具链文件、板级配置、公共模块 | 被顶层 CMakeLists.txt 引用 |
4.2 模块化设计原则:从"能跑"到"可复用"
模块化设计的核心目标,是让项目中的每个功能单元都能独立开发、独立测试、独立替换。对于 TinyML 项目而言,模块化不仅是代码组织的美学问题,更直接关系到模型迭代效率、硬件适配成本和团队协作质量。
原则一:单一职责。每个模块只负责一个明确的功能。例如,sensor_mic 模块只负责麦克风数据的采集和预处理,ml_engine 模块只负责模型加载和推理执行,comm_ble 模块只负责蓝牙数据传输。当某个模块需要修改时,不会波及其他模块。
原则二:接口稳定。模块之间的交互通过头文件定义的接口进行,而不是直接访问对方的全局变量或内部函数。接口一旦确定,应尽量保持稳定。例如,推理引擎的接口可以设计为:
// include/ml/inference_engine.h
typedef struct {
const uint8_t* model_data;
size_t model_size;
int tensor_arena_size;
} ml_config_t;
int ml_engine_init(const ml_config_t* config);
int ml_engine_run(const int16_t* input, size_t input_len, float* output, size_t output_len);
void ml_engine_deinit(void);
只要这个接口不变,底层无论是使用 TFLite Micro 还是其他推理框架,上层调用代码都无需修改。
原则三:依赖方向单向。模块之间的依赖关系应该形成有向无环图,避免循环依赖。通常的依赖方向是:app → ml_engine → tflite-micro,即应用层依赖推理引擎,推理引擎依赖底层算子库。如果出现循环依赖,说明模块划分存在问题,需要重新审视职责边界。
原则四:平台抽象。TinyML 项目往往需要适配多种硬件平台。通过平台抽象层(Platform Abstraction Layer, PAL),将硬件相关的操作(如内存分配、计时、调试输出)封装为统一接口,上层代码只依赖 PAL 接口,从而实现在不同硬件上的可移植性。
实战经验:在模块化设计时,建议为每个模块建立独立的 CMake target。例如,ml_engine 是一个 STATIC 库 target,sensor_mic 是另一个 STATIC 库 target,最终的可执行文件 target 只依赖这些库 target。这样做的好处是:构建系统可以自动处理依赖关系,增量编译效率更高,且每个模块可以独立设置编译选项(如优化级别、警告级别)。
4.3 依赖组织与 CMake 集成:从路径引用到 Target 引用
在 TinyML 项目的 CMake 构建中,依赖组织方式直接决定了项目的可维护性。初学者常见的做法是使用路径或相对路径直接引用头文件和库文件,这种方式在项目规模扩大后很快就会失控。
推荐做法:使用 CMake Target 作为依赖单元。每个模块(无论是 src 下的子模块还是 lib 下的第三方库)都定义为一个 CMake target,其他模块通过 target_link_libraries 来声明依赖。CMake 会自动传递头文件搜索路径、编译选项和链接库。
以下是一个典型的顶层 CMakeLists.txt 结构:
cmake_minimum_required(VERSION 3.16)
project(tinyml_keyword_spotting C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
# 设置默认构建类型
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE "Release" CACHE STRING "" FORCE)
endif()
# 添加 cmake/ 目录下的辅助模块
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/modules")
# 板级配置
include(board_config)
# 第三方库
add_subdirectory(lib/tflite-micro)
add_subdirectory(lib/cmsis-nn)
# 自研模块
add_subdirectory(src/ml)
add_subdirectory(src/sensors)
add_subdirectory(src/utils)
# 主程序
add_executable(${PROJECT_NAME}
src/main.c
)
target_link_libraries(${PROJECT_NAME}
PRIVATE
ml_engine
sensor_mic
utils_log
${TFLITE_MICRO_LIB}
${CMSIS_NN_LIB}
)
# 测试
enable_testing()
add_subdirectory(tests)
在这个结构中,src/ml/CMakeLists.txt 定义了 ml_engine 这个 STATIC 库 target,并声明了它对 tflite-micro 的依赖:
# src/ml/CMakeLists.txt
add_library(ml_engine STATIC
inference_engine.c
preprocess.c
)
target_include_directories(ml_engine
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/../../include/ml
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(ml_engine
PUBLIC
tflite-micro
)
注意 PUBLIC 和 PRIVATE 的使用:PUBLIC 的包含路径和依赖会传递给使用 ml_engine 的上层 target,而 PRIVATE 的只对当前 target 可见。这种精确的可见性控制,避免了头文件搜索路径的"全局污染"。
4.4 案例拆解:一个关键词唤醒项目的目录重构
为了更好地理解上述原则,我们以一个实际的关键词唤醒(Keyword Spotting, KWS)项目为例,展示从混乱结构到规范结构的重构过程。
初始状态与问题:项目最初将所有代码放在一个 app/ 目录下,包含 12 个 .c 文件和 8 个 .h 文件,头文件通过 #include "../app/xxx.h" 相对路径引用。第三方库直接拷贝到 app/third_party/ 下。主要问题包括:头文件路径混乱,不同模块之间存在隐式依赖;无法单独编译某个模块进行单元测试;更换开发板时需要手动修改多处代码。
重构方案:按照 4.1 节的目录布局进行重组,具体步骤如下:
- 将业务代码按功能拆分为
src/ml/(推理引擎)、src/sensors/(麦克风驱动)、src/utils/(日志与内存管理)三个子模块。 - 将对外接口头文件提取到
include/目录,保持与 src 子目录的镜像结构。 - 将 TFLite Micro 和 CMSIS-NN 移入
lib/目录,并保留原始版本号标识。 - 为每个子模块编写独立的 CMakeLists.txt,定义清晰的 target 和依赖关系。
- 在
tests/下新增单元测试,针对ml_engine的预处理函数和utils_log的格式化函数编写测试用例。
重构后的关键指标对比:
| 指标 | 重构前 | 重构后 | 改善效果 |
|---|---|---|---|
| 头文件引用方式 | 相对路径 ../app/xxx.h |
通过 target 传递的 #include "ml/xxx.h" |
路径依赖消除,模块边界清晰 |
| 单元测试可行性 | 无法单独编译模块 | 每个模块可独立编译并测试 | 测试覆盖率从 0% 提升到 68% |
| 适配新开发板 | 修改 5 处硬编码引脚和时钟配置 | 只需新增一个 board 配置文件 | 适配时间从 2 天缩短到 3 小时 |
| 增量编译时间 | 任何修改触发全量编译(约 4 分钟) | 仅重编受影响模块(平均 40 秒) | 编译效率提升 6 倍 |
复盘经验:重构过程中最大的阻力来自"历史惯性"——团队成员已经习惯了原有的文件引用方式。通过制定明确的模块接口文档,并在代码评审中严格执行依赖规则,最终在两周内完成了平稳过渡。关键教训是:目录结构的设计必须与 CMake target 体系同步推进,否则只是"看起来规范"的目录搬家。
4.5 常见误区与总结复盘
在指导多个 TinyML 项目落地后,我总结了以下几个高频误区,值得特别注意:
误区一:include 目录变成"垃圾桶"。有些项目将所有头文件平铺在 include/ 下,没有按模块分子目录。这会导致头文件命名冲突(如两个模块都有 config.h),且无法通过目录结构快速定位接口归属。正确做法是 include/ 下按模块分子目录,与 src/ 保持镜像。
误区二:lib 目录直接拷贝源码而不做 CMake 集成。有些项目将第三方库源码放在 lib/ 下,但顶层 CMakeLists.txt 直接使用 add_subdirectory(lib/tflite-micro) 后,没有正确链接其 target,而是手动添加头文件路径和库文件路径。这会导致第三方库的内部依赖无法自动传递,升级库版本时容易遗漏。
误区三:测试代码与业务代码混编。有些项目将测试文件放在 src/ 目录下,通过条件编译(如 #ifdef UNIT_TEST)控制是否编译。这种方式会让生产代码中充斥测试逻辑,且测试代码无法独立构建。正确做法是 tests/ 目录独立维护,通过 CMake 的 enable_testing() 和 add_test() 注册测试。
总结:一个优秀的 TinyML 项目目录结构,应该让新成员在 10 分钟内找到任意功能模块的源码、接口和测试;应该让构建系统自动处理依赖关系,而不是靠人工维护路径;应该让硬件适配的成本降到最低,而不是每次换板子都"动手术"。上述五个目录的划分和模块化原则,正是实现这些目标的基础。在实际项目中,可以根据团队规模和项目复杂度适当调整,但核心思想——接口与实现分离、依赖通过 target 传递、测试独立构建——应当始终坚守。