0%

STM32智能小车——标准库工程整体架构解析

一、从上一篇到这篇

上一篇《Keil 工程迁移到 VS Code + EIDE》完成了把 Keil 标准库工程迁移到 VS Code 里编译烧录。接下来我一步步组装部件,把程序烧录到小车上,让小车成功跑了起来。50b02f84e45a5d75b09044f0c7f524eb

但”能跑”和”看懂”是两回事。标准库工程虽然只有十几二十个文件,但对外设的调用像一张网——这篇博客就把这张网完整地拆开:

  • 工程怎么分层?每个文件夹是干什么的?
  • 蓝牙 APP 发一个指令,到电机转动,中间走了哪几步?
  • 避障、循迹这两个”自动驾驶”功能是怎么实现的?
  • 这个架构好在哪、坑在哪?为什么我计划用 HAL 重写?

二、工程目录全景:一个清晰的三层结构

先看整体。忽略 .cmsis(EIDE 插件自带的设备描述,不用管)和 Library 里的杂项,工程其实只有三层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
stdperiph/                         # 我命名的 EIDE工程根目录
│
├── User/ # 🟢 应用层:业务逻辑,唯一"动脑子"的地方
│ ├── main.c # 主流程:初始化 + 蓝牙指令分发(switch)
│ ├── mycontrol.c / .h # 避障、循迹算法
│ └── stm32f10x_it.c # 中断服务框架(本工程中断写在各模块里)
│
├── HARDWARE/ # 🟡 驱动层:一外设一目录,封装寄存器操作
│ ├── LED/ # 板载 LED(PC13)
│ ├── MOTOR/ # TB6612 电机驱动:Set_Car_Speed()
│ ├── TIME/ # TIM3 输出两路 PWM(5kHz),给电机调速
│ ├── Servo/ # TIM2 输出 PWM(50Hz),控制 SG90 舵机
│ ├── HCSR04/ # 超声波测距(TIM4 中断计时法)
│ ├── TRACE/ # 四路红外循迹:X1~X4 宏
│ ├── OLED/ # OLED 显示(软件 IIC + 字库)
│ └── USART/ # USART1:接 JDY-31 蓝牙,中断收指令
│
├── System/ # 🔵 基础层
│ ├── Delay.c # SysTick 延时:Delay_us / ms / s
│ └── sys/ # 位带操作宏(PAout 等)+ NVIC 配置
│
├── Library/ # ⚪ 标准外设库(官方 SPL 3.5.0,不修改)
├── Start/ # ⚪ CMSIS 启动:startup + core_cm3 + system_stm32f10x
└── .eide/ # EIDE 插件工程配置(eide.yml 等)

这个分层是教科书级的:Library 和 Start 是官方库、永远不改;HARDWARE 只关心”某个外设怎么驱动”,不管业务;User 只关心”车要干什么”,不碰寄存器。

image-20260812214352244

三、硬件资源分配总表

整个小车的外设全部集中在 PA、PB、PC 三个端口上。这张表是”地图”,后面讲每个模块都要回头看它:

外设 引脚 用途 说明
TIM3_CH2 PA7 左电机调速 PWM 5kHz
TIM3_CH1 PA6 右电机调速 PWM 5kHz
TB6612 PB5/PB6 AIN1/AIN2:左电机方向
TB6612 PB7/PB8 BIN1/BIN2:右电机方向
TIM2_CH1 PA0 SG90 舵机 PWM 50Hz,周期 20ms
HC-SR04 PA4 Trig:触发脉冲输出
HC-SR04 PA5 Echo:回波电平输入 下拉输入
USART1 PA9/PA10 JDY-31 蓝牙 TX/RX 波特率 9600
OLED PA11/PA12 SCL/SDA(软件 IIC) ⚠️ 见文末踩坑
循迹 PB0/PB1/PB10/PB11 X1~X4 四路红外输入 下拉输入
板载 LED PC13 指示灯 低电平点亮

注意一个细节:电机调速用了 TIM3 的两个通道,舵机单独用了 TIM2,超声波计时用了 TIM4——三个定时器各司其职,谁都不打架。这是选定时器时的基本思路:看通道够不够、时钟域冲突不冲突。

四、模块职责速览

模块 对外接口 一句话职责
MOTOR Set_Car_Speed(left, right) 唯一的电机入口:正负号定方向,数值定速度
TIM3_PWM TIM3_PWM_Init(arr, psc) 出两路 5kHz PWM,喂给 TB6612 调速
Servo myServo_Init() / Servo_SetAngle(angle) 把超声波转到 30°/90°/150° 三方向
HCSR04 HCSR04_Init() / HCSR04_GetValue() 返回前方距离(cm)
TRACE Trace_Init() + 宏 X1~X4 直接读四个红外传感器电平
USART1 USART1_Init(9600) 中断接收蓝牙指令,写全局 RxData
OLED OLED_ShowString/Num(...) 显示指令、距离、循迹状态
LED LED_Init() / LED_Flash() 板载灯

应用层只有两个函数:Avoidance()(避障)和 Trace_task()(循迹),都放在 mycontrol.c 里——业务与驱动分离,这是这个工程值得学习的地方。

五、主流程:蓝牙指令协议与命令分发

5.1 指令协议

main 的主循环非常简单,本质是一个”命令分发器”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
while(1)
{
switch(RxData) // RxData:蓝牙中断收进来的指令字节
{
case 0x00: Set_Car_Speed(0,0); // 停车
HCSR04_Distance = HCSR04_GetValue(); // 顺带测距
break;
case 0x01: Set_Car_Speed(6500,6500); // 直行
break;
case 0x02: Set_Car_Speed(-6500,-6500); // 后退
break;
case 0x03: Set_Car_Speed(-6500,6500); // 左转(差速)
break;
case 0x04: Set_Car_Speed(6500,-6500); // 右转(差速)
break;
case 0x05: Avoidance(); // 自动避障
break;
case 0x06: Trace_task(); // 自动循迹
break;
}
// OLED 刷新:指令号、距离、循迹状态
}

手机蓝牙 APP 发一个字节,就能切换小车的 7 种行为。这个协议表(0x00~0x06)就是手机 APP 和单片机之间唯一的”接口文档”——协议越简单,联调越不容易出幺蛾子。

5.2 一条指令的完整链路(数据流全链路)

以”手机上按直行键”为例,数据从手机到车轮,一共经过 7 站:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
手机蓝牙APP                单片机内部
│ │
① 发送 0x01 │
▼ │
JDY-31 蓝牙模块 ──TX──► USART1 RX (PA10) ← 9600 波特率
│
② USART1_IRQHandler 中断
RxData = USART_ReceiveData(USART1);
│ (中断写,main 读)
▼
③ while(1) 主循环
switch(RxData) 命中 case 0x01
│
▼
④ Set_Car_Speed(6500, 6500)
│
┌─────────────┴─────────────┐
▼ ▼
⑤ 左电机:TIM3_CH2 占空比 右电机:TIM3_CH1 占空比
6500/7200 ≈ 90% 6500/7200 ≈ 90%
AIN1=0, AIN2=1 BIN1=0, BIN2=1 ← 方向位
│ │
▼ ▼
⑥ TB6612 双路电机驱动芯片 ← 同时输出 5kHz PWM 到两电机
│ │
▼ ▼
⑦ 左电机正转 90% 力 右电机正转 90% 力 → 小车直行

关键点:整条链路上,唯一的”人机接口”是中断里的 RxData,唯一的”执行接口”是 Set_Car_Speed()。 中间每一个环节都是独立模块,任何一个坏了都能单独排查。

六、核心算法详解

6.1 电机控制:为什么一个函数能表达所有运动

Set_Car_Speed(Left_speed, Right_speed) 的参数范围是 -7200~7200,正负号表达方向。看看它怎么把”正负号”翻译成硬件动作(以左电机为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if(Left_speed > 0)                    // 正转:方向位 AIN1=0/AIN2=1
{
TIM_SetCompare2(TIM3, Left_speed); // 占空比 = 速度值
GPIO_SetBits(GPIOB, GPIO_Pin_6); // AIN2 = 1
GPIO_ResetBits(GPIOB, GPIO_Pin_5); // AIN1 = 0
}
else if(Left_speed < 0) // 反转:方向位反过来
{
TIM_SetCompare2(TIM3, -Left_speed); // 取绝对值给 PWM
GPIO_ResetBits(GPIOB, GPIO_Pin_6);
GPIO_SetBits(GPIOB, GPIO_Pin_5);
}
else // 0:刹停,方向位全置 1
{
TIM_SetCompare2(TIM3, 0);
GPIO_SetBits(GPIOB, GPIO_Pin_5);
GPIO_SetBits(GPIOB, GPIO_Pin_6);
}

设计很巧妙:

  • 速度 = 数值的绝对值,直接写进 TIM3 的比较寄存器 CCR,变成 PWM 占空比(5kHz 方波,占空比越大电机越有力);
  • 方向 = 数值的正负号,翻译成 TB6612 的方向引脚 AIN1/AIN2 的高低电平;
  • 差速 = 左右两个数不同。左转 = 左轮反转右轮正转(原地转),拐弯 = 一快一慢。

所以”直行、后退、左转、右转”四个动作在代码里只是四个不同的二元组,一条指令一个 case,简单粗暴但极其直观。

6.2 超声波测距:TIM4 中断”掐表”法

HC-SR04 的原理是声波往返计时:

1
2
3
MCU ──Trig 高电平 45μs──►  模块发射超声波
模块 ──Echo 高电平──► MCU (回波期间 Echo 保持高电平)
距离 = Echo高电平时间 × 声速(34000cm/s) ÷ 2 ← 往返除 2

代码的实现方式值得细看——用 TIM4 的更新中断来掐表:

1
2
3
4
5
6
7
8
9
10
11
12
void TIM4_IRQHandler(void)          // 每 0.1ms 进一次
{
if (TIM_GetITStatus(TIM4, TIM_IT_Update) == SET)
{
// Echo 是高电平 → 计数器 +1(每格 = 0.1ms)
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_5) == 1)
{
Time++;
}
TIM_ClearITPendingBit(TIM4, TIM_IT_Update);
}
}

测量时:Trig 拉高 45μs 触发超声波 → Time=0 启动 TIM4 → 中断里盯着 Echo,高电平就 Time++ → 等 100ms 后关定时器:

1
2
3
4
5
6
7
8
uint16_t HCSR04_GetValue(void)
{
HCSR04_Start(); // 发 Trig 脉冲 + 启动 TIM4
Delay_ms(100); // 等一个完整测距周期(官方建议 ≥60ms)
TIM_Cmd(TIM4, DISABLE); // 关定时器
if(Time > 235) Time = 0; // 超 4 米算无效(23.5ms ≈ 4m)
return ((Time * 0.0001f) * 34000) / 2; // 时间×声速÷2
}

时间 → 距离的换算很直白:一格 0.1ms = 0.0001s × 34000cm/s ÷ 2 = 1.7cm,所以 Time 的值 ×1.7 就是厘米数。大概 20cm 左右会误差 ±1cm。

6.3 避障:舵机带超声波”左中右”三向扫描

这个工程的功能逻辑在 Avoidance():

1
2
3
4
5
6
7
8
9
10
11
12
     前方距离
│
┌──────▼──────┐
│ 前方 ≤ 20cm ? │
└───┬──────┬───┘
是│ │否
停车,舵机扫描 │ 直行
左150° 测 L │
右30° 测 R │
│ │
L ≥ R ? → 向左转(倒车→原地左转 600ms)
否则 → 向右转(倒车→原地右转 600ms)

代码骨架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void Avoidance(void)
{
HCSR04_M = HCSR04_GetValue(); // 先测正前方
if(HCSR04_M > 0 && HCSR04_M <= 20) // 前方 ≤ 20cm 才避障
{
Set_Car_Speed(0,0); // 停车
HCSR04_getData(); // 舵机转到左右测距
if(HCSR04_L >= HCSR04_R) // 左边空间大 → 左转
{
Set_Car_Speed(-2600,-2600); // 倒车
Delay_ms(500);
Set_Car_Speed(0,0); // 停一下再转
Delay_ms(200);
Set_Car_Speed(-2500,2500); // 原地左转
Delay_ms(600);
}
else { /* 对称的右转分支 */ }
}
else
{
Set_Car_Speed(2800,2800); // 没障碍 → 直行
}
}

三向扫描靠的是舵机:

1
2
3
4
5
6
7
8
9
10
void HCSR04_getData(void)
{
Servo_SetAngle(90); // 转中间
HCSR04_M = HCSR04_GetValue();
Servo_SetAngle(150); // 转左边
HCSR04_L = HCSR04_GetValue();
Servo_SetAngle(30); // 转右边
HCSR04_R = HCSR04_GetValue();
Servo_SetAngle(90); // 归位
}

这套”先看前方,近了再扫左右,往开阔一侧走”的逻辑,就是最简单的壁障式避障——不建图、不规划路径,只做局部决策。够用,但谈不上聪明,后文会分析它的代价。

6.4 循迹:四路红外组合判断

四个红外传感器横向排开(X1~X4),每个返回 0/1。代码用一组 if 判断”线在车底下哪个位置”,然后差速修正:

1
2
3
4
5
6
// 白地 = 1,压到黑线 = 0(极性以你的模块实测为准)
if(X1==0 && X3==0) Set_Car_Speed(2800,2800); // 中间两路压线 → 直行
if(X1==1 && X3==0) Set_Car_Speed(2500,0); // 线偏右 → 右转
if(X1==0 && X3==1) Set_Car_Speed(0,2500); // 线偏左 → 左转
if(X2==1 && X1==1 && X3==1 && X4==0) Set_Car_Speed(3000,0); // 最右压线 → 大右转
if(X2==0 && X1==1 && X3==1 && X4==1) Set_Car_Speed(0,3000); // 最左压线 → 大左转

直角弯是循迹的难点,教程用”原地转圈直到出线”来处理:

1
2
3
4
5
6
7
8
9
10
11
// 右直角弯:右侧两个传感器都出线
if(X3==0 && X4==0)
{
Delay_ms(200);
Set_Car_Speed(0,0); // 停一下
Delay_ms(500);
do
{
Set_Car_Speed(3000,-3000); // 原地右转
} while(X1==0); // 转到左传感器从线上出来为止
}

七、架构分析:优点与不足

优点(值得保留的)

# 优点 说明
1 驱动与业务分离 电机、超声波、循迹的”怎么用”在 HARDWARE,业务逻辑全在 mycontrol.c
2 唯一入口 所有运动都走 Set_Car_Speed(),上层不需要知道 PWM 和 TB6612 的细节
3 指令协议极简 一个字节 7 种命令,蓝牙联调零负担
4 模块独立性好 每模块自包含 init + 操作函数,单测/复用都方便

不足(HAL 重写要消灭的)

# 不足 后果
1 main 大杂烩 switch 新功能只能不断加 case,没有”状态”概念,加行为容易,加”流程”很难
2 全程阻塞式 Delay_ms(500)、超声波等 100ms、舵机内部还带 500ms 延时——任意时刻只有一个任务在跑,避障时蓝牙指令完全不理
3 中断写、主循环读的裸全局变量 RxData 没有 volatile 修饰,属于”理论上未定义、实际上能跑”的边缘行为
4 延时与运动耦合 “倒车 500ms → 停 200ms → 转 600ms”是死时间,速度变化/障碍变化都不会打断它
5 注释与代码不一致 USART1.c 头注释写”波特率 115200”,实际 main 传的是 9600;Set_Car_Speed 注释范围 -7200~7200,实际 ARR=7199 时最大 7199

八、发现的坑:TIME.h 无效引用,编译却通过了

读 HCSR04.c 时发现开头有一行:

1
#include "TIME.h"         // 时间管理头文件(延时等)

但整个工程里根本没有 TIME.h 这个文件(TIME 文件夹里只有 TIM3_PWM.h)。按理说 GCC 找不到头文件应该报 fatal error,工程却编译烧录成功了。我用 ARM GCC 单独编译 HCSR04.c 验证,确实零报错。

原因:Windows 文件系统不区分大小写。#include "TIME.h" 在项目里找不到后,GCC 继续往系统 include 目录找,大小写不敏感地匹配到了 ARM 工具链自带的 C 标准库 time.h——一个只声明了 time() 等函数的无害头文件,于是静默编译通过。但是如果交叉编译到 Linux,对大小写有区别的话,会当场报错;如果匹配到有副作用的头文件,会引入查不出来的 bug。

应该怎么做:

  1. 立即修复:删掉 HCSR04.c 里这行 #include "TIME.h"——第 4 行的 delay.h 已经提供了它用到的 Delay_ms/Delay_us,这行本来就是多余的,删完重新编译烧录验证
  2. 以后写 include 精确拼写:文件名、大小写必须和实际文件完全一致,写错就要当场报错,而不是被编译器”好心”兜住
  3. 排查工具:gcc -H xxx.c 会把实际包含的头文件树全部打印出来,怀疑多包含/少包含时用它核对

九、下一步:HAL 重写架构预告

标准库版已完整跑通,接下来按计划用 CubeMX + HAL 重写。重写不是翻译代码,而是按上面的不足逐条解决,目标架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app/                  # 应用层:状态机(遥控/避障/循迹 三个状态)
├── main.c # 主循环只做状态调度,绝不写 Delay
├── car_remote.c # 遥控状态
├── car_avoid.c # 避障状态
└── car_trace.c # 循迹状态
speed_ctrl/ # 中间层:速度控制
└── Speed_Ctrl_SetTarget(left_cm, right_cm) # 开环 PWM 直给,
# 将来换 PID 闭环只改内部
bsp/ # 驱动层:硬件抽象
├── bsp_motor.c # TB6612 + TIM PWM
├── bsp_oled.c # 硬件 IIC
├── bsp_usart.c # 串口(DMA 接收)
├── bsp_servo.c # 舵机
├── bsp_sr04.c # 超声波(定时器输入捕获)
└── bsp_trace.c # 循迹

三样规范:

  • **业务不直接调 HAL_Delay()**,统一走 Delay_Ms()——将来上 FreeRTOS 只改这一处;
  • 中断只置标志位,业务全部放主循环状态机里——将来每个状态就是一个小 task;
  • 速度接口预留 PID:Speed_Ctrl_SetTarget() 内部现在是”占空比直给”,换成 PID 闭环后上层一行不用改。

十、总结

知识点 要点
工程分层 User(应用)/ HARDWARE(驱动)/ System(基础)/ Library+Start(官方库)
蓝牙指令协议 一字节 7 命令,中断收 → 主循环 switch 分发
电机控制 数值绝对值 = PWM 占空比,正负号 = 方向位,差速 = 左右不同
超声波 TIM4 更新中断掐表 Echo 高电平时间 ×1.7cm/格
避障 前向 ≤20cm → 舵机三向扫描 → 往开阔侧转
循迹 X1~X4 组合判断 + 直角弯原地转
最大短板 阻塞式 + 无状态机,任何延时期间所有任务冻结
隐藏坑 #include "TIME.h" 大小写不敏感匹配到标准库 time.h,静默通过

标准库版的价值在于让人完整理解一辆车所有外设的原始协作方式——它不够优雅,但足够透明。

参考资源