4. TinyML项目结构设计

TinyML 项目与传统的嵌入式或云端 ML 项目有显著差异:它既要面对交叉编译工具链的复杂性,又要处理模型文件、算子库、设备驱动和板级配置等多类异构资源。一个设计不良的目录结构,往往在项目只有三五个源文件时看似无碍,但当模型版本迭代、新增传感器驱动或适配第二块开发板时,就会暴露出依赖混乱、构建脚本难以维护、测试无法独立运行等问题。本章将围绕一套经过实践验证的目录布局展开,深入讲解模块化设计原则、依赖组织方法以及可复用性的实现路径。

TinyML 项目目录布局与依赖关系 project_root/ src/ include/ lib/ tests/ cmake/ main.c / app_main.c ml/ (推理流水线) sensors/ (驱动封装) utils/ (日志/内存) ml/ (公开头文件) sensors/ (驱动接口) platform/ (板级抽象) tflite-micro/ (第三方) CMSIS-NN/ (第三方) audio_codec/ (自研) unit/ (单元测试) integration/ (集成测试) benchmark/ (性能基准) toolchains/ (交叉编译) boards/ (板级配置) modules/ (公共CMake) 依赖方向与构建层级 include 头文件 链接 lib 静态库 测试引用 src 核心原则:src 只放实现,include 只放对外接口,lib 统一管理第三方与自研库, tests 与 cmake 保持独立,通过 CMake target 而非路径引用建立依赖。

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 节的目录布局进行重组,具体步骤如下:

  1. 将业务代码按功能拆分为 src/ml/(推理引擎)、src/sensors/(麦克风驱动)、src/utils/(日志与内存管理)三个子模块。
  2. 将对外接口头文件提取到 include/ 目录,保持与 src 子目录的镜像结构。
  3. 将 TFLite Micro 和 CMSIS-NN 移入 lib/ 目录,并保留原始版本号标识。
  4. 为每个子模块编写独立的 CMakeLists.txt,定义清晰的 target 和依赖关系。
  5. 在 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 传递、测试独立构建——应当始终坚守。