1. 课程导入

TinyML 是指在资源极度受限的微控制器(MCU)或极低功耗处理器上部署机器学习模型的技术与实践。与云端或边缘服务器上的 AI 不同,TinyML 设备通常只有几十 KB 到几 MB 的 RAM,Flash 存储以 MB 计,CPU 主频在几十到几百 MHz 之间,且往往依赖电池供电。在这样的约束下,将传感器数据采集、信号预处理、模型推理和业务逻辑全部揉杂在一个 main() 函数或单一文件中,或许能在 Demo 阶段跑通,但一旦进入产品化、量产维护或功能迭代阶段,就会迅速演变为一场灾难。本章作为整个课程的开篇,将系统阐述 TinyML 工程分层的必要性、核心目标,并给出贯穿后续所有章节的整体架构蓝图。

TinyML 工程分层架构总览 硬件抽象层 (HAL) MCU / 传感器 / 通信外设 / 电源管理 / 时钟与复位 寄存器操作 · 中断服务 · DMA 通道 · 低功耗模式 驱动层 (Driver) 传感器驱动 (IMU/温湿度/麦克风/摄像头) · 通信驱动 (UART/I2C/SPI/BLE) 提供统一数据接口 · 屏蔽寄存器细节 · 支持多实例 信号前处理层 (Pre-processing) 滤波 (低通/带通) · 归一化 · 特征提取 (FFT/MFCC/统计量) · 窗口化 数据质量保障 · 降低模型输入维度 · 适配传感器原始输出 推理引擎层 (Inference Engine) TFLite-Micro / CMSIS-NN / 自定义算子 · 模型解释器 · 张量内存管理 量化推理 (INT8) · 算子调度 · 内存复用 · 异常处理 业务逻辑层 (Application) 状态机 · 告警策略 · 数据上报 · 用户交互 · OTA 触发 依赖方向 ↓ 接口标准化 数据契约 模型无关 策略可配置

上图展示了本课程所倡导的 TinyML 工程分层架构。从底层的硬件抽象到顶层的业务逻辑,每一层都有明确的职责边界和通信契约。这种分层并非为了追求理论上的整洁,而是直接源于 TinyML 产品开发中反复出现的痛点:传感器型号更换导致上层代码大面积修改、模型更新后前处理参数不匹配、业务逻辑与推理过程耦合导致无法单独测试、以及多人协作时代码冲突不断。接下来,我们将深入剖析分层的必要性、目标,并逐一解读各层的职责与交互方式。

一、为什么 TinyML 必须分层:从三个真实痛点说起

在传统嵌入式开发中,工程师习惯将代码按功能模块划分,但在 TinyML 项目中,由于数据流贯穿传感器→预处理→模型→决策,开发者往往倾向于将整个流程写成一个顺序执行的循环。这种“直通式”写法在原型验证阶段效率极高,但进入产品化阶段后,以下三个问题会迅速暴露:

  • 硬件耦合导致“换传感器即重写”:某智能家居项目使用 BMI160 加速度计完成跌倒检测,当供应链原因需要替换为 LSM6DSO 时,由于数据读取代码、量程转换、滤波参数全部散落在业务逻辑中,工程师不得不逐行排查所有涉及加速度数据的代码,耗时两周才完成替换,且引入了新的噪声问题。
  • 模型迭代与前后处理脱节:团队将模型从浮点版本量化为 INT8 后,发现推理结果异常。排查发现,前处理中的归一化参数仍按浮点模型计算,而量化模型要求输入范围是 [-128, 127]。由于前处理与推理引擎没有清晰的接口契约,这类问题极难定位。
  • 测试与验证成本高:当业务逻辑(如告警阈值判断)与推理代码直接嵌套时,无法单独验证“模型输出正确但业务策略有误”的场景。每次测试都必须运行完整的数据采集和推理流程,且难以模拟边界条件。

这些痛点的根源在于:TinyML 系统本质上是一个数据驱动的信号处理与决策系统,其各个阶段具有完全不同的变化频率和验证方法。传感器驱动跟随硬件变化,前处理跟随信号特征和模型输入要求变化,推理引擎跟随模型格式和算子支持变化,业务逻辑则跟随产品需求变化。如果不进行分层,这些不同频率的变化会相互干扰,导致“牵一发而动全身”。

二、分层架构的核心目标与设计原则

TinyML 工程分层并非简单地把代码放进不同文件夹,而是建立一套稳定的接口契约和单向依赖关系。其核心目标可归纳为以下四点:

目标 说明 衡量指标
可替换性 更换传感器、MCU 或模型时,受影响范围被限制在单一层内 替换传感器时,业务层代码改动量为 0 行
可测试性 每一层都可以独立进行单元测试或模拟测试 前处理层可在 PC 端用 Python 验证输出
可维护性 新工程师能快速定位问题所在层,而非通读全部代码 Bug 定位时间缩短 50% 以上
可演进性 支持模型在线升级、传感器动态配置、业务策略远程调整 OTA 升级包仅包含变更层对应的二进制

为了实现上述目标,分层设计需要遵循三条基本原则:

  1. 单向依赖:上层可以调用下层接口,但下层绝不能反向依赖上层。例如,驱动层不知道业务逻辑的存在,推理引擎也不关心数据是来自真实传感器还是测试向量。
  2. 数据契约先行:层与层之间传递的数据结构必须提前定义并固化。例如,驱动层输出的是经过校准的物理量(如加速度单位为 m/s²),而非原始寄存器值;前处理层输出的是模型输入张量的标准格式(如 INT8 数组 + 缩放因子)。
  3. 配置与逻辑分离:各层的行为参数(如采样率、滤波截止频率、告警阈值)应通过配置结构体或配置文件注入,而非硬编码在层内部。这样可以在不修改代码的情况下调整行为。
经验提醒:在项目启动的第一周,即使代码量还很少,也应该先定义好各层之间的头文件(接口声明)。这些头文件是团队的“宪法”,后续所有开发都围绕这些接口展开。如果发现接口设计不合理,尽早重构——越到后期,修改接口的代价呈指数增长。

三、各层职责详解与交互流程

下面我们逐一剖析上图中五个层次的具体职责、典型实现方式以及它们之间的交互契约。

1. 硬件抽象层(HAL)

HAL 是直接面对 MCU 寄存器和外设的一层。它的存在意义在于:让上层驱动代码与具体的 MCU 型号解耦。例如,STM32 和 ESP32 的 GPIO 操作方式完全不同,但通过 HAL 封装后,驱动层可以调用统一的 hal_gpio_write(pin, level) 接口。HAL 通常包含:

  • 时钟使能与复位控制
  • GPIO 读写与中断注册
  • UART/I2C/SPI 总线的底层收发
  • 定时器与 PWM 输出
  • 低功耗模式切换(sleep/deep-sleep)

在实际项目中,HAL 可以由芯片厂商的 SDK(如 STM32Cube HAL、ESP-IDF)直接提供,也可以基于 CMSIS 自行封装。关键是要保证 HAL 的接口风格统一,且不包含任何业务语义。

2. 驱动层(Driver)

驱动层是传感器、执行器、通信模块等具体器件的“翻译官”。它负责将 HAL 提供的原始寄存器读写能力,转换为具有明确物理意义的数据结构。以加速度计为例,驱动层的典型接口为:

typedef struct {
    float x;  // 单位: m/s²
    float y;
    float z;
    uint32_t timestamp_ms;  // 采样时刻
} accel_sample_t;

// 初始化传感器,返回 0 表示成功
int accel_init(const accel_config_t *cfg);

// 读取一帧数据,阻塞或非阻塞
int accel_read(accel_sample_t *sample);

驱动层需要处理的工作包括:配置传感器量程和采样率、执行自检、进行温度补偿、处理 I2C 通信错误重试等。驱动层输出的数据必须是“干净”的物理量,而不是原始寄存器值——这一约定可以避免上层重复进行单位换算和位操作。

3. 信号前处理层(Pre-processing)

前处理层是 TinyML 系统中容易被低估但至关重要的一环。它的输入是驱动层提供的原始采样序列,输出是模型可以直接消费的特征张量。常见的前处理操作包括:

  • 滤波:去除工频干扰(50Hz/60Hz)、高频噪声或基线漂移
  • 窗口化:将连续数据流切分为固定长度的帧(如 256 点、1 秒窗口)
  • 特征提取:计算时域统计量(均值、方差、峰值)、频域特征(FFT 幅度谱)、或专用特征(MFCC 用于语音)
  • 归一化/标准化:将数据缩放到模型期望的输入范围(如 [-1, 1] 或 [0, 255])

前处理层必须与模型训练时的数据预处理流程保持一致。例如,如果在训练时使用了 100Hz 低通滤波,那么部署时的前处理也必须使用相同的滤波参数。这一层通常需要支持在 PC 端用 Python 进行离线验证,因此建议将核心算法实现为纯 C 函数,便于交叉编译和测试。

4. 推理引擎层(Inference Engine)

推理引擎层负责加载模型、管理张量内存、执行算子计算。它对外提供统一的推理接口,隐藏了模型格式(TFLite、ONNX 转换后的 C 数组)、量化方案(INT8、FLOAT16)和算子实现细节。典型的接口设计如下:

typedef struct {
    const uint8_t *model_data;
    size_t model_size;
    int tensor_arena_size;  // 推理工作区大小
} inference_config_t;

typedef struct {
    int8_t *input;   // 输入张量指针
    int8_t *output;  // 输出张量指针
    size_t input_len;
    size_t output_len;
} inference_result_t;

int inference_init(const inference_config_t *cfg);
int inference_run(const int8_t *input, size_t input_len,
                  int8_t *output, size_t output_len);

推理引擎层需要处理的关键问题包括:内存池管理(避免动态分配)、算子内核优化(利用 CMSIS-DSP 或硬件加速指令)、以及异常处理(如模型校验失败、推理超时)。这一层通常由 TFLite-Micro 或类似框架提供,但工程上需要对其进行封装,以便未来更换推理框架时不影响上层代码。

5. 业务逻辑层(Application)

业务逻辑层是产品功能的直接体现,它决定了“拿到推理结果后做什么”。例如:

  • 如果模型输出是“跌倒”概率 > 0.8,则触发蜂鸣器并发送 BLE 告警
  • 如果连续 10 次推理结果均为“静止”,则进入低功耗模式
  • 根据推理结果调整采样频率(动态功耗管理)

业务逻辑层应该完全感知不到传感器型号、模型内部结构或前处理算法的细节。它只面对两个东西:前处理层输出的特征张量和推理引擎输出的结果张量。这种隔离使得产品经理调整业务策略时,只需要修改这一层的代码,而无需触碰底层任何模块。

四、案例拆解:一个工业振动监测设备的重构过程

为了更直观地展示分层带来的收益,我们来看一个实际的工业案例。某工厂部署了一批旋转机械振动监测设备,用于预测性维护。设备使用 ADXL345 加速度计采集振动信号,通过 TinyML 模型判断设备状态(正常/异常/严重)。

初始状态(未分层):所有代码写在一个 main.c 文件中,约 2000 行。数据采集、滤波、FFT、模型推理、告警逻辑全部顺序执行。设备工作基本正常,但存在以下问题:

  • 更换为 ADXL355(更高精度)时,需要修改 17 处代码,包括 I2C 地址、量程设置、数据格式转换
  • 模型升级后,发现前处理中的归一化参数未同步更新,导致推理准确率下降 15%
  • 无法在实验室单独测试告警逻辑,必须连接真实传感器并等待振动事件发生

重构为分层架构后:团队按照本章所述的五层模型进行重构,耗时 3 天完成代码迁移。重构后的代码结构如下:

project/
├── hal/               # 硬件抽象层
│   ├── hal_gpio.c
│   ├── hal_i2c.c
│   └── hal_uart.c
├── drivers/           # 驱动层
│   ├── adxl345.c
│   └── sht30.c
├── preprocessing/     # 前处理层
│   ├── filter.c
│   ├── fft.c
│   └── normalize.c
├── inference/         # 推理引擎层
│   ├── tflite_micro_wrapper.c
│   └── model_data.c
└── app/               # 业务逻辑层
    ├── main.c
    └── alarm_policy.c

关键改进与结果:

改进项 重构前 重构后
更换传感器耗时 2 周(涉及 17 处修改) 2 小时(仅修改驱动层,新增 adxl355.c)
模型升级验证 需人工检查所有参数 前处理层有独立单元测试,自动验证输出范围
告警逻辑测试 需真实振动台 可注入模拟推理结果,在 PC 上完成测试
代码可读性 2000 行单文件 每层 200-400 行,职责清晰

复盘经验:重构过程中最大的阻力来自“觉得没必要”的心理——因为设备已经能工作了。但重构完成后,团队发现后续的功能迭代(如增加温度监测、调整告警阈值)变得异常轻松。这个案例说明,分层架构的收益不是立竿见影的,而是在项目生命周期中持续释放的。

风险提示:分层架构并非银弹。如果层与层之间的接口设计得过于复杂或抽象,反而会带来额外的性能开销和代码冗余。在 TinyML 场景下,每一层的数据拷贝都应尽量减少——例如,驱动层可以直接将数据写入前处理层的缓冲区,避免多次 memcpy。建议在接口设计时,优先使用指针传递而非值传递,并明确数据所有权。

五、常见误区与总结复盘

在指导多个 TinyML 项目落地后,我们发现工程师在分层实践中容易陷入以下误区:

  • 误区一:分层 = 多写文件。分层的关键是接口契约和依赖方向,而不是文件数量。如果两个文件互相包含头文件,即使分属不同文件夹,也是耦合的。
  • 误区二:过度设计。对于只有 1-2 个传感器、模型固定不变的超简单项目,强行五层分离反而增加维护成本。建议从三层(驱动+处理、推理、应用)起步,随着复杂度增加再细化。
  • 误区三:忽略数据契约的版本管理。当模型升级导致输入张量维度变化时,前处理层和推理层的接口必须同步更新。建议在接口头文件中加入版本宏,并在初始化时进行兼容性检查。
  • 误区四:性能损失不可接受。合理的分层设计几乎不增加运行时开销——函数调用在编译时会被内联优化,数据传递通过指针完成。真正的性能瓶颈在于算法本身,而非分层结构。