一、从上一篇到这篇
上一篇《Keil 工程迁移到 VS Code + EIDE》完成了把 Keil 标准库工程迁移到 VS Code 里编译烧录。接下来我一步步组装部件,把程序烧录到小车上,让小车成功跑了起来。
但”能跑”和”看懂”是两回事。标准库工程虽然只有十几二十个文件,但对外设的调用像一张网——这篇博客就把这张网完整地拆开:
- 工程怎么分层?每个文件夹是干什么的?
- 蓝牙 APP 发一个指令,到电机转动,中间走了哪几步?
- 避障、循迹这两个”自动驾驶”功能是怎么实现的?
- 这个架构好在哪、坑在哪?为什么我计划用 HAL 重写?
二、工程目录全景:一个清晰的三层结构
先看整体。忽略 .cmsis(EIDE 插件自带的设备描述,不用管)和 Library 里的杂项,工程其实只有三层:
1 | stdperiph/ # 我命名的 EIDE工程根目录 |
这个分层是教科书级的:Library 和 Start 是官方库、永远不改;HARDWARE 只关心”某个外设怎么驱动”,不管业务;User 只关心”车要干什么”,不碰寄存器。
三、硬件资源分配总表
整个小车的外设全部集中在 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 | while(1) |
手机蓝牙 APP 发一个字节,就能切换小车的 7 种行为。这个协议表(0x00~0x06)就是手机 APP 和单片机之间唯一的”接口文档”——协议越简单,联调越不容易出幺蛾子。
5.2 一条指令的完整链路(数据流全链路)
以”手机上按直行键”为例,数据从手机到车轮,一共经过 7 站:
1 | 手机蓝牙APP 单片机内部 |
关键点:整条链路上,唯一的”人机接口”是中断里的 RxData,唯一的”执行接口”是 Set_Car_Speed()。 中间每一个环节都是独立模块,任何一个坏了都能单独排查。
六、核心算法详解
6.1 电机控制:为什么一个函数能表达所有运动
Set_Car_Speed(Left_speed, Right_speed) 的参数范围是 -7200~7200,正负号表达方向。看看它怎么把”正负号”翻译成硬件动作(以左电机为例):
1 | if(Left_speed > 0) // 正转:方向位 AIN1=0/AIN2=1 |
设计很巧妙:
- 速度 = 数值的绝对值,直接写进 TIM3 的比较寄存器
CCR,变成 PWM 占空比(5kHz 方波,占空比越大电机越有力); - 方向 = 数值的正负号,翻译成 TB6612 的方向引脚 AIN1/AIN2 的高低电平;
- 差速 = 左右两个数不同。左转 = 左轮反转右轮正转(原地转),拐弯 = 一快一慢。
所以”直行、后退、左转、右转”四个动作在代码里只是四个不同的二元组,一条指令一个 case,简单粗暴但极其直观。
6.2 超声波测距:TIM4 中断”掐表”法
HC-SR04 的原理是声波往返计时:
1 | MCU ──Trig 高电平 45μs──► 模块发射超声波 |
代码的实现方式值得细看——用 TIM4 的更新中断来掐表:
1 | void TIM4_IRQHandler(void) // 每 0.1ms 进一次 |
测量时:Trig 拉高 45μs 触发超声波 → Time=0 启动 TIM4 → 中断里盯着 Echo,高电平就 Time++ → 等 100ms 后关定时器:
1 | uint16_t HCSR04_GetValue(void) |
时间 → 距离的换算很直白:一格 0.1ms = 0.0001s × 34000cm/s ÷ 2 = 1.7cm,所以 Time 的值 ×1.7 就是厘米数。大概 20cm 左右会误差 ±1cm。
6.3 避障:舵机带超声波”左中右”三向扫描
这个工程的功能逻辑在 Avoidance():
1 | 前方距离 |
代码骨架:
1 | void Avoidance(void) |
三向扫描靠的是舵机:
1 | void HCSR04_getData(void) |
这套”先看前方,近了再扫左右,往开阔一侧走”的逻辑,就是最简单的壁障式避障——不建图、不规划路径,只做局部决策。够用,但谈不上聪明,后文会分析它的代价。
6.4 循迹:四路红外组合判断
四个红外传感器横向排开(X1~X4),每个返回 0/1。代码用一组 if 判断”线在车底下哪个位置”,然后差速修正:
1 | // 白地 = 1,压到黑线 = 0(极性以你的模块实测为准) |
直角弯是循迹的难点,教程用”原地转圈直到出线”来处理:
1 | // 右直角弯:右侧两个传感器都出线 |
七、架构分析:优点与不足
优点(值得保留的)
| # | 优点 | 说明 |
|---|---|---|
| 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 |
但整个工程里根本没有 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。
应该怎么做:
- 立即修复:删掉 HCSR04.c 里这行
#include "TIME.h"——第 4 行的delay.h已经提供了它用到的Delay_ms/Delay_us,这行本来就是多余的,删完重新编译烧录验证 - 以后写 include 精确拼写:文件名、大小写必须和实际文件完全一致,写错就要当场报错,而不是被编译器”好心”兜住
- 排查工具:
gcc -H xxx.c会把实际包含的头文件树全部打印出来,怀疑多包含/少包含时用它核对
九、下一步:HAL 重写架构预告
标准库版已完整跑通,接下来按计划用 CubeMX + HAL 重写。重写不是翻译代码,而是按上面的不足逐条解决,目标架构:
1 | app/ # 应用层:状态机(遥控/避障/循迹 三个状态) |
三样规范:
- **业务不直接调
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,静默通过 |
标准库版的价值在于让人完整理解一辆车所有外设的原始协作方式——它不够优雅,但足够透明。
参考资源
- B站up陈加油嗯STM32智能小车入门教程:https://www.bilibili.com/video/BV1CAe5zjEBv/?spm_id_from=333.337.search-card.all.click&vd_source=5a111e8d49f70e532d36ddb73be3d0aa
- 上篇:Keil 工程迁移到 VS Code + EIDE(Keil_to_VSCode_EIDE_Migration)
- 波特律动文档站:https://docs.baud-dance.com/docs/stm32/intro
