▼點擊下方名片,關注公眾號,獲取更多精彩內容▼
這里是《賀老師講嵌入式AI》,我是《嵌入式AI:讓單片機學會思考》課程主理人,專注AI在MCU上的落地實踐。
我們最終要在 STM32F103 上運行一個手寫的 int8 MLP 正弦波模型。單片機上電后通過串口輸出調試信息;進入運行階段后,程序不等待上位機下發輸入,而是在單片機內部自動改變輸入值,把理論正弦值和模型推理值一起打包發給上位機顯示。
最終效果: 上位機能看到兩條主要曲線:理論正弦波和模型推理正弦波。兩條曲線基本重合,誤差曲線用于觀察模型擬合效果。

本文對應的最終工程路徑是:
D:\workspace_Code_Demo\STM32F103\STM32F103_AddAI,聯系我,可以獲取工程源碼。打開 STM32CubeIDE,選擇 File、New、STM32 Project,在 MCU Selector 中搜索 STM32F103RCTx。這里選的是 STM32F103RCT6 對應的 LQFP64 封裝。
STM32F103RCTx。STM32F103_AddAI。C。Executable。STM32Cube。

本工程使用 USART1 作為調試輸出和波形數據輸出。CubeMX 中啟用 USART1 的 Asynchronous 模式,默認引腳是 PA9 作為 TX,PA10 作為 RX。
115200 | |
8 Bits | |
None | |
1 | |
TX/RX |

在 Project Manager、Code Generator 中,建議保持 Keep User Code when re-generating 打開。我們后續寫的代碼都放在 USER CODE BEGIN 和 USER CODE END 之間,這樣再次 Generate Code 時不會被 CubeMX 覆蓋。

在加 AI 推理前,先做一個最小驗證:串口能打印調試信息。這一步不是形式主義,它能證明程序已經進入 main(),USART 初始化成功,后面調試量化輸入、模型輸出和誤差時也有可靠的信息通道。
int __io_putchar(int ch)
{
uint8_t data = (uint8_t)ch;
HAL_UART_Transmit(&huart1, &data, 1U, HAL_MAX_DELAY);
return ch;
}有了這段代碼,就可以直接使用 printf 輸出啟動日志。比如:
printf("\r\n[BOOT] STM32F103_AddAI started\r\n");
printf("[BOOT] USART1: 115200 8N1\r\n");
printf("[BOOT] Handwritten int8 sine MLP test: enabled\r\n");知識點深挖:為什么先做 printf? 嵌入式項目最怕“程序沒有反應”。printf 是最便宜的調試通道,上電能打印,說明啟動流程跑到了 main(),USART 初始化成功,串口線接法基本正確。后面 AI 推理結果不對時,也可以臨時打印量化輸入、輸出和誤差。
STM32Cube.AI 很強,但它并不是“所有 STM32 都無腦支持”。在實際使用中,F407 這類 Cortex-M4 工程可以通過 X-CUBE-AI 生成 runtime 和網絡代碼;但是 F103 是 Cortex-M3,當前常見 X-CUBE-AI 版本并沒有把 F103 當作標準支持目標來提供完整 MCU runtime 體驗。
這不是說 F103 不能跑 AI,而是說不能指望 CubeMX 點幾下就把完整 CubeAI runtime 自動塞進來。對一個很小的正弦波 MLP 模型來說,手寫 int8 推理器反而更適合教學:
知識點深挖:部署 AI 不等于必須部署 TensorFlow Lite Micro 或 CubeAI。
AI 模型最終就是一組數學運算。卷積、全連接、激活函數、歸一化、量化,本質上都可以用 C 寫出來。框架的作用是幫你自動生成這些代碼、優化內存和算子,但對非常小的模型,手寫推理器完全可行,而且更能幫助初學者理解底層機制。
本項目中保留了一個非常關鍵的文件:
D:\workspace_Code_Demo\STM32F103\STM32F103_AddAI\X-CUBE-AI\App\network_generate_report.txt這個文件來自之前已經驗證過的 F407 CubeAI 工程。雖然 F103 這次不直接編譯 CubeAI runtime,但這份報告能告訴我們模型結構、輸入輸出量化參數、權重大小和計算量。
model file : D:\model\sinemodel.tflite
input : int8(1x1), QLinear(0.024573976,-128,int8)
output : int8(1x1), QLinear(0.008472007,4,int8)
macc : 321
weights (ro) : 420 B
activations (rw) : 64 B報告里最重要的是這兩行:
input : QLinear(0.024573976, -128, int8)
output : QLinear(0.008472007, 4, int8)它們的含義是:
real_value = (q_value - zero_point) * scale
對輸入來說:
input_real = (input_q - (-128)) * 0.024573976
對輸出來說:
output_real = (output_q - 4) * 0.008472007
知識點深挖:scale 和 zero point 是什么? int8 只能表示 -128 到 127,但模型原本處理的是浮點數。量化就是用一個比例系數 scale 和一個零點 zero point,把浮點數映射到整數。zero point 負責告訴我們“真實的 0 對應哪個整數值”,scale 負責告訴我們“整數每變化 1,真實值變化多少”。如果 zero point 用錯,波形會整體偏移;如果 scale 用錯,波形幅值會不對。

#define SINE_MLP_INPUT_ZERO_POINT (-128)
#define SINE_MLP_OUTPUT_ZERO_POINT 4
int8_t SineMlp_InputIndexToQ(uint8_t input_index);
int8_t SineMlp_RunInt8(int8_t input_q);
int16_t SineMlp_ReferenceY28000(uint8_t input_index);
int16_t SineMlp_OutputY28000(int8_t output_q);
int16_t SineMlp_ErrorY160000(int16_t reference_y28000, int16_t model_y28000);
uint16_t SineMlp_AbsErrorY200000(int16_t reference_y28000, int16_t model_y28000);這里把推理、理論值、輸出縮放和誤差計算都封裝起來,主函數只需要調用這些接口,不需要關心每一層的權重細節。
對一個 Dense 層來說,浮點模型里通常是:
y = x * w + b
量化模型里不能直接這么算,而是:
acc = bias_int32 + sum((input_q - input_zero_point) * weight_q) output_q = clamp_int8(requantize(acc) + output_zero_point)
注意這里的累加器是 int32_t,不是 int8_t。因為乘法和多項累加很容易超過 int8 的范圍。
for (uint32_t out = 0U; out < SINE_MLP_HIDDEN_SIZE; out++)
{
int32_t acc = kB0[out];
acc += ((int32_t)input_q - SINE_MLP_INPUT_ZERO_POINT) * (int32_t)kW0[out];
hidden0[out] = ClampI8(Requantize(acc, SINE_MLP_LAYER0_MULTIPLIER, SINE_MLP_LAYER0_SHIFT)
+ SINE_MLP_HIDDEN_ZERO_POINT);
}第一層只有一個輸入,所以每個輸出神經元只需要一次乘加,再加上對應偏置。
for (uint32_t out = 0U; out < SINE_MLP_HIDDEN_SIZE; out++)
{
int32_t acc = kB1[out];
for (uint32_t in = 0U; in < SINE_MLP_HIDDEN_SIZE; in++)
{
acc += ((int32_t)hidden0[in] - SINE_MLP_HIDDEN_ZERO_POINT)
* (int32_t)kW1[(out * SINE_MLP_HIDDEN_SIZE) + in];
}
hidden1[out] = ClampI8(Requantize(acc, SINE_MLP_LAYER1_MULTIPLIER, SINE_MLP_LAYER1_SHIFT)
+ SINE_MLP_HIDDEN_ZERO_POINT);
}第二層是主要計算量來源,16 個輸出,每個輸出需要 16 次乘加,所以是 256 次乘加。報告中 macc 大頭就在這里。
int32_t acc = kB2;
for (uint32_t in = 0U; in < SINE_MLP_HIDDEN_SIZE; in++)
{
acc += ((int32_t)hidden1[in] - SINE_MLP_HIDDEN_ZERO_POINT) * (int32_t)kW2[in];
}
return ClampI8(Requantize(acc, SINE_MLP_LAYER2_MULTIPLIER, SINE_MLP_LAYER2_SHIFT)
+ SINE_MLP_OUTPUT_ZERO_POINT);int32 累加結果不能直接當作下一層 int8 輸入。它必須經過重量化,把“累加器的尺度”轉換成“下一層輸出的尺度”。工程里使用了和 TFLite Micro 類似的定點計算方式:
static int32_t SaturatingRoundingDoublingHighMul(int32_t a, int32_t b)
{
int64_t ab = (int64_t)a * (int64_t)b;
int64_t nudge = (ab >= 0) ? ((int64_t)1 << 30) : (1 - ((int64_t)1 << 30));
return (int32_t)((ab + nudge) >> 31);
}
static int32_t RoundingDivideByPOT(int32_t x, int exponent)
{
int32_t mask = ((int32_t)1 << exponent) - 1;
int32_t remainder = x & mask;
int32_t threshold = (mask >> 1) + ((x < 0) ? 1 : 0);
return (x >> exponent) + ((remainder > threshold) ? 1 : 0);
}
static int32_t Requantize(int32_t acc, int32_t multiplier, int shift)
{
return RoundingDivideByPOT(SaturatingRoundingDoublingHighMul(acc, multiplier), shift);
}
知識點深挖:為什么不用 float?
STM32F103 是 Cortex-M3,沒有硬件 FPU。float 運算可以跑,但速度和代碼體積都不理想。int8 推理把權重和激活都壓到 8 位,乘加用整數完成,非常適合小模型、小內存 MCU。這個例子中權重只有 420 B,激活只需要幾十字節,F103 跑起來壓力很小。
為了讓上位機能同時顯示理論值和模型推理值,單片機需要知道理論正弦波。這里沒有在 F103 上調用 sinf,而是使用 256 點查表:
static const int16_t kSineY28000[256] = {
0, 688, 1376, 2062, ...
};查表的好處是穩定、快速、不引入浮點庫。表里的數值已經縮放到約 [-28000, 28000],可以直接作為上位機曲線顯示數據。
本項目不需要上位機發送輸入,單片機內部自動改變輸入。這樣學員只要打開上位機,就能看到理論值和推理值的差異。
#define SINE_FRAME_PERIOD_MS 20U
#define SINE_INPUT_INDEX_STEP 2U程序內部維護一個 uint8_t input_index,范圍天然是 0 到 255。每輸出一次曲線數據,input_index += 2,溢出后自動回到 0。20 ms 輸出一次數據,步進為 2,所以一圈是 128 個采樣點,大約 2.56 秒顯示一個周期。
static void SineMlp_ProcessAndSend(void)
{
static uint8_t input_index = 0U;
int8_t input_q = SineMlp_InputIndexToQ(input_index);
int8_t output_q = SineMlp_RunInt8(input_q);
int16_t reference_y28000 = SineMlp_ReferenceY28000(input_index);
int16_t model_y28000 = SineMlp_OutputY28000(output_q);
int16_t error_y160000 = SineMlp_ErrorY160000(reference_y28000, model_y28000);
uint16_t abs_error_y200000 = SineMlp_AbsErrorY200000(reference_y28000, model_y28000);
Waveform_SendValues(reference_y28000, model_y28000, error_y160000, abs_error_y200000);
input_index = (uint8_t)(input_index + SINE_INPUT_INDEX_STEP);
}
為了直觀看出模型是否工作正常,建議一次輸出 4 路曲線數據。文章不展開上位機的底層通信格式,只關注每一路曲線代表什么。
int16 | ||
int16 | ||
int16 | ||
uint16 |
#define UART_TEXT_REPORT_ON 0U啟動階段可以用 printf 打印文本日志;進入波形傳輸后,串口主要輸出曲線數據。如果運行中繼續周期性發送文本,文本字節會夾在曲線數據流里,上位機解析可能錯位,波形會抖動、斷裂甚至完全亂掉。
在 STM32CubeIDE 中選擇 Debug 配置,點擊 Build。最終工程已經驗證可以編譯通過,典型大小約為:
text data bss dec
13388 104 2000 15492對 STM32F103RCT6 來說,這個體積非常輕,說明手寫 int8 推理器沒有引入龐大的運行庫。

連接 ST-LINK,點擊 Run 或 Debug。上電后串口先輸出啟動日志:
[BOOT] STM32F103_AddAI started
[BOOT] USART1: 115200 8N1
[BOOT] Handwritten int8 sine MLP test: enabled
[BOOT] Sine waveform output period: 20 ms115200。判斷標準: 曲線 1 是理論正弦波,曲線 2 是模型推理波形。正確時,兩條曲線都應該是連續正弦形狀,并且幅值、相位基本一致。曲線 3 和曲線 4 不是正弦,它們用于觀察誤差。

QLinear(0.008472007,4,int8) | |||
q - zero_point,輸出時再加目標 zero point | |||
USER CODE BEGIN/END 區域,獨立模塊放到 Core/Src 和 Core/Inc |
關注我,可獲取工程源碼,也可在評論區討論、交流!