这里是《贺老师讲嵌入式AI》,我《嵌入式AI:让单片机学会思考》课程主理人,专注AI在MCU上的落地实践。

本文手把手记录从新建 STM32CubeMX/STM32CubeIDE 工程,到导入 sinemodel.tflite,再到在 STM32F407 上运行 CubeAI 推理并通过串口显示正确正弦波的完整过程。

1. 实验目标

本实验最终要实现的效果是:STM32F407 上电后初始化 GPIO、USART1 和 X-CUBE-AI 模型,MCU 内部自动生成正弦模型输入 x,周期性运行模型推理,然后通过串口把理论正弦值、模型推理值和误差发送给上位机显示。

图片

项目

配置

MCU

STM32F407VET6

工具链

STM32CubeIDE

STM32CubeMX

6.16.1

STM32Cube FW_F4

V1.28.3

X-CUBE-AI

10.2.0

模型文件

sinemodel.tflite

CubeAI 应用模板

ApplicationTemplate

USART1

PA9 / PA10,115200,8N1

LED

PB12 / PB13,低电平点亮

上位机协议

帧头 0x7E,长度 0x08,帧尾 0x7F

图片

2. 新建 STM32CubeIDE 工程

打开 STM32CubeIDE,选择 File -> New -> STM32 Project。在 MCU/MPU Selector 中搜索:

STM32F407VET6

图片

选择对应芯片后新建工程,如下所示:

Project Name: STM32F407_TinyML
Targeted Language: C
Targeted Binary Type: Executable
Targeted Project Type: STM32Cube
Toolchain/IDE: STM32CubeIDE

图片

图片

容易踩坑:芯片型号要和板子一致

STM32F407 系列有不同封装和 Flash/RAM 规格。如果芯片型号选错,可能导致下载失败、链接脚本不匹配、引脚不存在,甚至工程能编译但烧录后运行异常。模型部署对 Flash 和 RAM 更敏感,所以第一步就要选对芯片。

3. 基础外设配置

3.1 配置调试接口

在 System Core -> SYS 中,把 Debug 设置为:

Serial Wire

这样会占用 PA13 和 PA14 作为 SWD 调试口。

图片

3.2 配置时钟

本实验模型非常小,只有 321 MACC。为了先把部署链路跑通,可以使用默认 HSI 16 MHz,不必一开始就配置到最高主频。

RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI;
RCC_OscInitStruct.HSIState = RCC_HSI_ON;
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_NONE;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_HSI;

图片

调试建议

先用简单时钟把模型部署、串口协议和上位机显示跑通。等链路确认无误后,再切换 HSE + PLL 提高主频。

3.3 配置 LED

本实验使用 PB12 和 PB13 作为 LED 输出:

PB12 -> LED1
PB13 -> LED2

由于开发板 LED 是低电平点亮,所以 GPIO 默认输出应该设置为 GPIO_PIN_SET,也就是上电默认熄灭。

#define LED1_GPIO_Port GPIOB
#define LED1_Pin GPIO_PIN_12
#define LED2_GPIO_Port GPIOB
#define LED2_Pin GPIO_PIN_13

#define LED_ON GPIO_PIN_RESET
#define LED_OFF GPIO_PIN_SET
#define LED_BLINK_HALF_PERIOD_MS 500U

容易踩坑:低电平点亮不是低电平熄灭

很多开发板 LED 一端接 VCC,MCU 引脚输出低电平时灌电流点亮。因此 GPIO_PIN_RESET 是点亮,GPIO_PIN_SET 是熄灭。

图片

3.4 配置 USART1

USART1 用于给上位机发送波形数据:

PA9  -> USART1_TX
PA10 -> USART1_RX

Baud Rate: 115200
Word Length: 8 Bits
Parity: None
Stop Bits: 1
Hardware Flow Control: None

图片

容易踩坑:文本调试和二进制协议不能混在一起

最终上位机要解析二进制帧。如果同一串口里还在输出 printf("hello\r\n"),就会破坏帧结构。工程中保留文本调试函数,但默认关闭:

#define UART_TEXT_DEBUG_ENABLED 0U

4. 添加 X-CUBE-AI 并导入模型

在 STM32CubeMX 的 Software Packs 中启用 X-CUBE-AI:

Software Packs -> Select Components -> X-CUBE-AI

图片

启用后,在左侧会出现 X-CUBE-AI 相关配置页面。导入模型:

D:\model\sinemodel.tflite

模型名称保持为:

network

图片

当前工程 .ioc 中对应的关键配置如下:

STMicroelectronics.X-CUBE-AI.10.2.0.ModelStructureFile-...=D\:\\model\\sinemodel.tflite
STMicroelectronics.X-CUBE-AI.10.2.0.ModelName-...=network
STMicroelectronics.X-CUBE-AI.10.2.0.ModelKind-...=TFLITE
STMicroelectronics.X-CUBE-AI.10.2.0.ModelCompression-...=None

5.模型分析

导入模型后,点击

图片

开始分析

图片

分析完成

图片

6. Generate Code 后编译成功

图片

图片

7. 读取 CubeAI 生成报告,确认量化参数

模型导入后,X-CUBE-AI 会生成报告:

X-CUBE-AI\App\network_generate_report.txt

本模型报告中的关键信息:

input 1/1  : int8(1x1), QLinear(0.024573976,-128,int8)
output 1/1 : int8(1x1), QLinear(0.008472007,4,int8)
macc       : 321
weights    : 420 B
activations: 64 B

截图如下:

图片

7.1 QLinear(scale, zero_point) 的含义

量化模型中,真实浮点值和 int8 值之间的关系是:

real_value = (quantized_value - zero_point) * scale

反过来,如果有一个浮点输入,要送进 int8 模型,就要做:

quantized_value = round(real_value / scale + zero_point)

本模型输入参数为:

scale = 0.024573976
zero_point = -128

因此输入量化代码是:

static int8_t sine_quantize_input(float value)
{
  float qf = (value / SINE_AI_INPUT_SCALE) + (float)SINE_AI_INPUT_ZERO_POINT;
  int32_t q = (int32_t)((qf >= 0.0f) ? (qf + 0.5f) : (qf - 0.5f));

  if (q > 127)
  {
    q = 127;
  }
  else if (q < -128)
  {
    q = -128;
  }

  return (int8_t)q;
}

本模型输出参数为:

scale = 0.008472007
zero_point = 4

因此输出反量化代码是:

static float sine_dequantize_output(int8_t value)
{
  return ((float)value - (float)SINE_AI_OUTPUT_ZERO_POINT) * SINE_AI_OUTPUT_SCALE;
}

容易踩坑:不要把 int8 模型当 float 模型使用

如果报告显示输入是 int8,就必须按量化参数写入 int8。输出也是 int8,不能直接当 float 解析,必须先反量化。

8. 理解 CubeAI 输入输出 buffer

报告中还有一句很重要:

(*) 'input'/'output' buffers are allocated in the activations buffer

network.h 中也能看到:

#define AI_NETWORK_INPUTS_IN_ACTIVATIONS   (4)
#define AI_NETWORK_OUTPUTS_IN_ACTIVATIONS  (4)

这意味着输入输出 buffer 由 CubeAI 放在 activations 内存池里。ApplicationTemplate 初始化后会拿到这些地址:

ai_input = ai_network_inputs_get(network, NULL);
ai_output = ai_network_outputs_get(network, NULL);

for (int idx=0; idx < AI_NETWORK_IN_NUM; idx++) {
  data_ins[idx] = ai_input[idx].data;
}

for (int idx=0; idx < AI_NETWORK_OUT_NUM; idx++) {
  data_outs[idx] = ai_output[idx].data;
}

之后采集函数只需要写入:

data[0][0] = (ai_i8)sine_quantize_input(sine_input_x);

推理完成后读取输出:

sine_model_y = sine_dequantize_output((int8_t)data[0][0]);

容易踩坑:不要自己随便换输入输出数组

如果 X-CUBE-AI 配置了 allocate-inputs 和 allocate-outputs,输入输出已经在 activations buffer 中。手动换成另一个数组,可能导致 ai_network_run() 使用的 buffer 和你写入的 buffer 不是同一个。

9. 编写周期推理逻辑

在 X-CUBE-AI/App/app_x-cube-ai.c 的 USER CODE 区域中定义输入变化参数:

#define SINE_AI_FRAME_PERIOD_MS       20U
#define SINE_AI_INPUT_STEP            0.05f
#define SINE_AI_TWO_PI                6.28318530717958647692f

static float sine_input_x = 0.0f;

每次推理后让 x 增加:

static void sine_advance_input(void)
{
  sine_input_x += SINE_AI_INPUT_STEP;

  if (sine_input_x >= SINE_AI_TWO_PI)
  {
    sine_input_x -= SINE_AI_TWO_PI;
  }
}

当前配置下,每 20 ms 推理一次,每次 x 增加 0.05 rad,一圈约 126 个点,一个完整正弦周期大约 2.5 秒,适合上位机观察。

void MX_X_CUBE_AI_Process(void)
{
  uint32_t now = HAL_GetTick();

  if ((sine_ai_ready != 0U) && ((now - sine_last_frame_tick) >= SINE_AI_FRAME_PERIOD_MS))
  {
    sine_last_frame_tick = now;

    if (acquire_and_process_data(data_ins) == 0)
    {
      if (ai_run() == 0)
      {
        post_process(data_outs);
      }
    }
  }
}

调度建议

不要在主循环中用长时间 HAL_Delay() 阻塞。AI 推理、LED 和串口发送都可以用 HAL_GetTick() 做周期调度。

10. 设计上位机串口协议

上位机协议如下:

字段

含义

帧头

0x7E

长度

0x04

 表示 8 位,0x08 表示 16 位,0x10 表示 32 位或 float

曲线1

理论正弦值

曲线2

模型推理值

曲线3

推理值 - 理论值

曲线4

绝对误差

帧尾

0x7F

本实验最终选择 16 位模式:

长度 = 0x08
四路数据 = 每路 2 字节
整帧长度 = 1 + 1 + 2 * 4 + 1 = 11 字节

帧格式:

0x7E 0x08 C1_L C1_H C2_L C2_H C3_L C3_H C4_L C4_H 0x7F

采用小端序发送:

static void sine_protocol_put_i16(uint8_t *buffer, int16_t value)
{
  uint16_t raw = (uint16_t)value;

  buffer[0] = (uint8_t)(raw & 0xFFU);
  buffer[1] = (uint8_t)((raw >> 8) & 0xFFU);
}

最终发送函数:

static void sine_protocol_send_frame(int16_t curve1, int16_t curve2, int16_t curve3, uint16_t curve4)
{
  uint8_t frame[11];

  frame[0] = SINE_PROTOCOL_HEADER;
  frame[1] = SINE_PROTOCOL_UINT16_LEN;
  sine_protocol_put_i16(&frame[2], curve1);
  sine_protocol_put_i16(&frame[4], curve2);
  sine_protocol_put_i16(&frame[6], curve3);
  sine_protocol_put_u16(&frame[8], curve4);
  frame[10] = SINE_PROTOCOL_TAIL;

  HAL_UART_Transmit(&huart1, frame, sizeof(frame), 100U);
}

图片

11. Release 编译并下载

因为实际下载使用的是 Release,所以要确认 Release 能编译通过,而不是只看 Debug。

Project Explorer -> 右键工程
Build Configurations -> Set Active -> Release
Project -> Build Project

本工程最终 Release 编译成功,大小大致如下:

text    data    bss     dec     hex
23400   1692    4776    29868   74ac

图片

图片

12. 上位机显示设置

上位机设置:

串口:对应 USB 转串口端口
波特率:115200
数据位:8
校验位:None
停止位:1
精度:16 位
帧头:0x7E
帧尾:0x7F

曲线

含义

判断标准

曲线1

理论正弦值

应为平滑正弦波

曲线2

模型推理值

应与曲线1基本重合,但允许模型近似误差

曲线3

推理误差

应围绕 0 上下变化

曲线4

绝对误差

应始终为正值

图片

欢迎有不同意见的“老板们”和同学们,在评论区讨论、交流!

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐