目录
1. 项目概览
这份程序控制一辆基于 STM32 的智能小车。小车可以通过手机端 Blynk 远程控制,也可以通过本地按键控制;同时,它还包含 OLED 显示、密码验证、继电器触发、超声波避障、定位数据读取与上传等功能。
远程控制
ESP8266 连接 WiFi 后接入 Blynk,手机端通过 V1 到 V5 控制小车运动和避障模式。
本地控制
两个模拟按键输入口分别负责密码输入和方向控制。按键通过不同电压范围区分不同功能。
密码与继电器
默认密码为 1324。密码正确时,继电器引脚短时间动作,可用于开锁或触发外部设备。
避障与定位
超声波模块检测前方障碍物,定位模块通过串口返回坐标信息,程序再把坐标上传到 Blynk。
程序的核心思路
程序上电后先执行 setup(),初始化串口、OLED、WiFi、Blynk 和各个引脚。之后一直执行 loop(),不断处理定位数据、扫描按键、刷新 OLED、运行 Blynk 网络服务,并在避障模式开启时执行自动避障。
可以把这套程序理解成一个“小型控制系统”:STM32 不断观察外部输入,例如手机按钮、实体按键、超声波距离和定位模块返回值;然后根据这些输入改变输出,例如电机转动、OLED 显示、蜂鸣器提示、继电器动作和 Blynk 数据上传。
2. 嵌入式基础概念
如果之前没有接触过嵌入式,可以先把嵌入式系统理解成“运行在具体硬件上的程序”。普通电脑程序主要处理文件、窗口、网络请求;嵌入式程序更关注硬件本身,例如某个引脚是高电平还是低电平、电机是否转动、传感器有没有返回数据。
单片机 / STM32
STM32 是一类单片机,也就是小型控制芯片。它内部有 CPU、内存、定时器、串口、ADC 等外设资源,可以直接控制引脚,也可以和其他模块通信。
引脚 / GPIO
引脚是单片机和外部世界连接的“接口”。GPIO 是通用输入输出口,可以被配置成输入读取按键,也可以被配置成输出控制蜂鸣器、继电器或电机驱动板。
高低电平
HIGH 和 LOW 表示引脚输出的两种电平状态。简单理解就是开关的开和关,但具体高电平触发还是低电平触发,要看外接模块电路。
PWM
PWM 是一种快速开关信号。电机速度控制常用 PWM:不是输出一个真正连续变化的电压,而是用很快的开关比例来模拟“强一点”或“弱一点”的驱动力。
ADC
ADC 用来把模拟电压转换成数字值。代码中的 analogRead() 就是在读取电压大小,然后用数值范围判断按下的是哪个键。
串口 UART
串口是一种常见通信方式,一次传一串字符或字节。本项目里 ESP8266 使用 Serial3,定位模块使用 Serial2,电脑调试使用 Serial。
I2C
I2C 是另一种常见通信总线,通常只需要两根信号线。这里 OLED 屏幕通过 I2C 与 STM32 通信,代码中用 U8g2 库完成底层操作。
主循环
嵌入式程序通常不会执行完就退出,而是在主循环里一直运行。它每一轮都检查输入、更新状态、控制输出,这就是 loop() 的作用。
读这份代码时可以抓住三件事
第一,看输入从哪里来:手机 Blynk、两个按键、超声波模块、定位模块。第二,看程序保存了哪些状态:例如 set_mode、key_str、myWiFi_state。第三,看输出控制了什么:电机、OLED、蜂鸣器、继电器和 Blynk 虚拟引脚。只要沿着“输入、状态、输出”这条线看,代码会清楚很多。
3. 系统组成
这个项目由多个模块共同完成。STM32 是主控,负责做判断和控制;ESP8266 负责联网;OLED 负责显示;超声波模块负责测距;电机驱动模块负责让车轮运动;定位模块通过串口提供位置数据。
对没有嵌入式经验的读者来说,可以把 STM32 看成整辆小车的“大脑”,但它本身不能直接完成所有事情。它需要通过不同接口和外部模块配合:有的模块负责提供信息,例如按键、超声波、定位模块;有的模块负责执行动作,例如电机驱动、蜂鸣器、继电器和 OLED 显示屏。
主要数据流
手机 Blynk 控件
-> ESP8266
-> STM32 BLYNK_WRITE 回调
-> 电机控制函数或模式变量 set_mode
按键输入
-> analogRead 读取电压
-> 判断按键编号
-> 输入密码或直接控制电机
定位模块
-> Serial2 返回字符串
-> 程序按逗号截取坐标字段
-> Blynk.virtualWrite 上传到 V11/V12
这几条数据流说明了项目的基本逻辑:外部输入先进入 STM32,STM32 根据代码做判断,再控制外部设备。远程按钮、本地按键和传感器数据虽然来源不同,但最后都会变成程序里的函数调用或变量变化。
4. 引脚与外设 源代码第 1 到 10 行,第 425 到 426 行
代码开头使用 #define 给硬件引脚起了易读的名字,后续控制外设时直接使用这些名字。
引脚名里的 PA6、PB0 代表 STM32 的端口和编号。字母 A、B 表示不同端口组,后面的数字表示该端口组里的具体引脚。代码中把这些硬件编号重新命名为 left_dir、key_1 这样的业务名称,是为了让读代码的人一眼知道它连接了什么。
输入引脚和输出引脚
输入引脚用于“读外部状态”,例如按键是否按下、超声波有没有回波。输出引脚用于“控制外部设备”,例如让蜂鸣器响、让继电器动作、让电机驱动板改变方向。代码在 setup() 中用 pinMode() 指定每个引脚的工作方向。
| 名称 | 引脚 | 连接对象 | 作用 |
|---|---|---|---|
left_dir |
PA6 | 左电机方向端 | 输出高低电平,控制左电机方向。 |
left_speed |
PA0 | 左电机速度端 | 输出 PWM 信号,控制左电机速度。 |
right_speed |
PA1 | 右电机速度端 | 输出 PWM 信号,控制右电机速度。 |
right_dir |
PA7 | 右电机方向端 | 输出高低电平,控制右电机方向。 |
key_1 |
PA4 | 密码输入按键 | 通过 ADC 数值判断输入的是 1、2、3、4。 |
key_2 |
PA5 | 本地方向按键 | 通过 ADC 数值判断前进、后退、左转、右转。 |
MYBEEP |
PB5 | 蜂鸣器 | Blynk 断开或连接时用于状态提示。 |
rely_pin |
PB0 | 继电器 | 密码正确时短暂触发。 |
PB8 |
PB8 | 超声波 Trig | 向超声波模块发送触发脉冲。 |
PB9 |
PB9 | 超声波 Echo | 接收超声波回响信号。 |
analogWrite() 输出的是 PWM,占空比越高不一定在所有驱动板上都表示越快,具体还要看电机驱动模块的接线和有效电平。
5. 依赖库与对象 源代码第 11 到 14 行,第 22 行,第 36 行
嵌入式开发经常会使用现成库。库的作用是把复杂的底层通信细节封装起来,让代码可以用较简单的函数操作模块。例如 OLED 实际需要按照显示芯片协议发送很多字节,但使用 U8g2 后,程序只需要设置字体、坐标并调用 print()。
| 代码 | 用途 |
|---|---|
ESP8266_Lib.h |
让 STM32 可以通过串口控制 ESP8266 模块。 |
BlynkSimpleShieldEsp8266.h |
把 ESP8266 和 Blynk 平台连接起来,支持手机远程控制。 |
U8g2lib.h |
驱动 SSD1306 OLED,负责文字显示和页面刷新。 |
Wire.h |
I2C 通信库,OLED 使用 I2C 总线通信。 |
ESP8266 对象
ESP8266 wifi(&Serial3);
这行表示 ESP8266 模块接在 Serial3 上。程序后面通过这个对象配置 WiFi 模式,并交给 Blynk 使用。
这里的 &Serial3 可以理解为告诉库:“请通过 STM32 的第三个串口去找 ESP8266”。如果硬件接线换到了别的串口,这里也需要对应修改。
OLED 对象
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
这行创建了一个 SSD1306、128x64 分辨率、硬件 I2C 的 OLED 显示对象。所有 OLED 显示都通过 u8g2 完成。
128x64 表示屏幕有 128 列、64 行像素。代码里用 setCursor(x, y) 指定文字从哪个位置开始显示,其中 x 是横向位置,y 是纵向位置。
未使用的 playlist
第 16 行定义了一个 13 字节数组 playlist,但当前程序没有使用它。它可能是某个串口模块的指令帧,也可能是之前功能留下的代码。
6. 全局变量 源代码第 18 到 35 行
全局变量用于保存整个程序都会用到的状态。比如 WiFi 是否连接、当前控制模式、输入中的密码、超声波距离、定位字符串等。
普通程序也会有变量,但嵌入式程序里的变量经常代表“设备当前状态”。例如 set_mode 不是一个普通数字,它决定小车现在是手动控制还是自动避障;key_str 不是随便保存字符串,它记录用户已经输入了几位密码。
| 变量 | 含义 | 主要用途 |
|---|---|---|
auth、ssid、pass |
Blynk 授权信息和 WiFi 配置。 | 传给 Blynk.begin() 建立连接。文档中不展开具体值。 |
myWiFi_state |
记录 Blynk 是否连接成功。 | 决定是否运行 Blynk.run(),以及是否上传数据。 |
vpin_value |
Blynk 虚拟引脚传来的数值。 | 远程控制按钮按下或松开时使用。 |
set_mode |
当前模式。1 表示普通控制,2 表示避障模式。 | 由 Blynk V5 或本地按键改变,loop() 根据它决定是否避障。 |
csb |
超声波测得的距离,单位近似为厘米。 | 判断前方是否有障碍物。 |
pass_str |
正确密码,初始化为 1324。 |
和用户输入的 key_str 比较。 |
key_str |
用户当前输入的密码。 | 每按一次密码键就追加一个数字,达到 4 位后进行判断。 |
time_ms |
上一次请求定位数据的时间。 | 配合 millis() 实现每 10 秒请求一次定位。 |
read_str、read_char |
串口读取定位数据时使用。 | 把 Serial2 收到的字符拼成完整字符串。 |
log_str、lat_str |
解析出来的两个坐标字段。 | 打印到串口,并上传到 Blynk V11、V12。 |
7. Blynk 远程控制 源代码第 38 到 94 行,第 103 到 116 行
Blynk 使用虚拟引脚和手机端控件通信。手机上某个按钮状态变化后,对应的 BLYNK_WRITE(Vx) 函数会被调用。
这里的“虚拟引脚”不是 STM32 板子上的真实引脚,而是 Blynk 平台提供的一种软件通道。手机端控件绑定 V1、V2 这样的编号,单片机程序也监听同样的编号,二者就能通过网络交换数据。
| 虚拟引脚 | 功能 | 收到非 0 值 | 收到 0 |
|---|---|---|---|
| V1 | 前进 | 调用 forward() |
调用 stop_motor() |
| V2 | 后退 | 调用 back() |
停止电机 |
| V3 | 左转 | 调用 Turn_left() |
停止电机 |
| V4 | 右转 | 调用 Turn_right() |
停止电机 |
| V5 | 模式切换 | set_mode = 2,开启避障模式 |
set_mode = 1,回到普通模式 |
| V11、V12 | 定位数据显示 | 程序将解析出的两个坐标字段上传到这两个虚拟引脚。 | |
控制回调示例
BLYNK_WRITE(V1) {
vpin_value = param.asInt();
if (vpin_value) {
forward();
set_mode = 1;
} else {
stop_motor();
set_mode = 1;
}
}
以上代码表示:V1 按钮按下时小车前进,松开时停止。无论按下还是松开,都会把模式设回普通控制模式。
param.asInt() 的作用是把 Blynk 传来的值转成整数。对于按钮控件来说,通常按下是 1,松开是 0,所以代码用 if (vpin_value) 判断按钮当前是否处于按下状态。
联网状态提示
BLYNK_CONNECTED() 中把蜂鸣器引脚写 LOW,BLYNK_DISCONNECTED() 中把蜂鸣器引脚写 HIGH。实际表现取决于蜂鸣器模块是高电平触发还是低电平触发。
需要注意的是,Blynk 的回调依赖 Blynk.run() 持续运行。如果主循环长时间被 delay() 或 while 阻塞,远程控制响应就可能变慢。
8. 电机控制 源代码第 96 到 155 行
左右两个电机都由方向引脚和速度引脚控制。方向引脚使用 digitalWrite() 输出高低电平,速度引脚使用 analogWrite() 输出 PWM。
STM32 的引脚一般不能直接带动电机,因为电机需要的电流比单片机引脚能提供的大得多。所以中间通常会有电机驱动模块。STM32 只负责给驱动模块发送控制信号,真正给电机供电和换向的是驱动模块。
小车转向依赖左右轮的速度和方向差。两边轮子都向前转,小车前进;两边都向后转,小车后退;一边向前、一边向后,小车就会原地或近似原地转向。
| 函数 | 动作 | 实现方式 |
|---|---|---|
forward() |
前进 | 左右方向引脚写 HIGH,速度引脚写 255 - MOTOR_SPEED。 |
back() |
后退 | 左右方向引脚写 LOW,速度引脚写 MOTOR_SPEED。 |
Turn_left() |
左转 | 左右电机方向相反,让小车向左旋转或转弯。 |
Turn_right() |
右转 | 左右电机方向相反,让小车向右旋转或转弯。 |
stop_motor() |
停止 | 两个速度引脚都写 0。 |
Turn_left_90()、Turn_right_90() |
定时转向 | 先停止,再转动约 1.2 秒,再停止,用固定时间近似 90 度。 |
MOTOR_SPEED 为 255,所以 255 - MOTOR_SPEED 等于 0。这个写法通常与具体电机驱动板的控制方式有关,实际方向和速度需要结合硬件验证。
9. 超声波避障 源代码第 157 到 177 行,第 324 到 335 行
超声波测距模块的工作方式类似“发声再听回声”。模块发出一束超声波,声波碰到障碍物后反射回来。程序测量从发出到收到回波经过了多久,再根据声速换算距离。
距离检测
float checkdistance_PB8_PB9() {
digitalWrite(PB8, LOW);
delayMicroseconds(2);
digitalWrite(PB8, HIGH);
delayMicroseconds(10);
digitalWrite(PB8, LOW);
float distance = pulseIn(PB9, HIGH) / 58.00;
delay(10);
return distance;
}
PB8 负责给超声波模块发送触发脉冲,PB9 负责读取回波。pulseIn(PB9, HIGH) 测量回波高电平持续时间,除以 58 后得到近似厘米数。
代码中先让 PB8 保持低电平,再输出 10 微秒高电平,这是很多超声波模块要求的触发方式。PB9 上高电平持续时间越长,说明声音来回走过的距离越远,也就是障碍物越远。
避障判断
void obstacles_mode() {
csb = checkdistance_PB8_PB9();
Serial.println(csb);
if (20 > csb) {
Avoid_objects();
} else {
forward();
}
}
当前距离小于 20 cm 时执行绕障动作,否则继续前进。
绕障动作
Avoid_objects() 使用固定动作绕开障碍:右转、前进、左转、前进、左转、前进、右转。它不判断左右两侧是否还有障碍物,因此属于比较简单的避障策略。
这种避障方式容易理解,也容易实现,但它没有真正“看见”周围环境。它只是在检测到前方太近时执行一套预设动作,所以如果障碍物形状复杂,或者地面摩擦导致转向角度不准,实际路线可能和预想不同。
10. 按键密码与本地控制 源代码第 179 到 322 行
key_procedure() 负责处理两个模拟按键输入。key_1 用于输入密码,key_2 用于直接控制小车运动。
这部分代码比较长,是因为它既要识别不同按键,又要处理按键抖动,还要等待按键松开。实体按键不是理想开关,按下和松开的瞬间电压会短暂抖动,因此代码需要多次确认。
按键识别方式
按键不是通过多个数字引脚区分,而是通过一个模拟引脚读取不同电压值。程序根据 ADC 数值范围判断按下的是哪个键。
这种做法常见于“电阻分压按键”。多个按键接在同一个模拟输入上,不同按键按下时形成不同分压,单片机读到的 ADC 数值就不同。好处是节省引脚,代价是需要设置合理的电压判断范围。
| ADC 范围 | key_1 功能 | key_2 功能 |
|---|---|---|
| > 3900 | 输入数字 1 | 前进 |
| 2800 到 3800 | 输入数字 2 | 后退 |
| 2100 到 2700 | 输入数字 3 | 右转 |
| 1000 到 2000 | 输入数字 4 | 左转 |
密码流程
每识别到一次有效按键,程序就把对应数字追加到 key_str。当 key_str 长度达到 4 位时,程序把它与 pass_str 比较。
- 密码正确:继电器引脚
rely_pin拉低,OLED 显示成功信息,约 3 秒后继电器恢复高电平。 - 密码错误:OLED 显示错误信息,约 3 秒后清空输入。
按键消抖
每次检测到按键后,代码会延时 20 ms 再确认一次,并在 while 中等待按键松开。这样可以避免按键抖动导致一次按压被识别成多次。
本地方向控制
key_2 的逻辑是按住就运动,松开就停止。在等待松开的过程中,如果 WiFi 已连接,代码仍会调用 Blynk.run(),避免远程通信长时间没有被处理。
11. OLED 显示 源代码第 337 到 358 行,第 385 到 396 行,第 457 到 460 行
OLED 使用 U8g2 库显示文字。程序启动时显示 initialize...,运行时主要显示密码输入页面。
OLED 可以看作小车的人机界面。没有屏幕时,用户只能通过串口或蜂鸣器猜测设备状态;有了 OLED 后,程序可以直接显示初始化状态、密码输入位数、密码正确或错误等信息。
初始化显示
setup() 中先设置 OLED I2C 地址,再调用 u8g2.begin() 初始化显示屏,然后显示一行 initialize...。
密码页面
page1() 显示两行提示文字,并根据已经输入的密码位数显示星号。例如输入 1 位显示 *,输入 2 位显示 **。这样可以提示用户当前输入长度,同时不暴露真实密码。
屏幕坐标从左上角开始计算,横向是 x,纵向是 y。代码中的 setCursor(0, 16) 表示文字从左侧第 0 列、纵向第 16 像素附近开始显示。
u8g2.firstPage();
do {
page1();
} while (u8g2.nextPage());
这是 U8g2 常见的页面刷新方式,所有绘图代码放在 firstPage() 和 nextPage() 之间。
这类刷新方式的特点是:每次刷新都重新画一遍需要显示的内容,而不是只改某一个字符。因此 page1() 中要把提示文字和星号都写完整。
12. 定位数据处理 源代码第 431 到 454 行
程序每 10 秒通过 Serial2 向定位模块发送一次命令:
定位模块和 STM32 之间使用串口通信。串口通信可以简单理解为双方按约定速度发送字符:STM32 发送命令,定位模块返回一段文本,程序再从这段文本里取出需要的字段。
Serial2.println("config,get,lbsloc");
如果 Serial2 收到数据,程序会逐字节读取并拼接成 read_str。随后使用逗号位置截取两个字段:
int firstComma = read_str.indexOf(',');
int secondComma = read_str.indexOf(',', firstComma + 1);
int thirdComma = read_str.indexOf(',', secondComma + 1);
int fourthComma = read_str.indexOf(',', thirdComma + 1);
log_str = read_str.substring(thirdComma + 1, fourthComma);
lat_str = read_str.substring(fourthComma + 1);
从命名来看,log_str 可能想表示经度字段,lat_str 表示纬度字段。解析完成后,程序把这两个值打印到调试串口,并在 WiFi 正常连接时上传到 Blynk 的 V11 和 V12。
这段解析逻辑依赖返回字符串格式稳定。如果模块返回内容少了逗号、顺序改变,或者混入错误提示,当前代码仍会尝试截取字符串,所以后续维护时最好增加格式检查。
13. 程序运行流程 源代码第 369 到 467 行
Arduino 风格程序通常由两个核心函数组成:setup() 和 loop()。setup() 只在上电或复位后执行一次,用来做初始化;loop() 会一直重复执行,用来持续响应外部变化。
setup 初始化
setup()
1. 打开 Serial、Serial2、Serial3
2. 设置默认变量和默认密码
3. 初始化 OLED 并显示 initialize...
4. 配置 ESP8266,连接 Blynk
5. 设置电机、按键、蜂鸣器、继电器、超声波引脚
6. 如果 Blynk 已连接,清空 V1 到 V5、V11、V12 的显示状态
loop 主循环
loop()
1. 每 10 秒请求一次定位数据
2. 如果 Serial2 有数据,解析坐标并上传
3. 处理本地按键和密码逻辑
4. 刷新 OLED 密码输入页面
5. 调用 Blynk.run() 处理远程通信
6. 如果 set_mode == 2,执行超声波避障
这类程序的特点是不会运行一次就结束,而是在 loop() 中反复检查各个模块的状态。远程控制、按键输入、定位数据和避障逻辑都通过主循环持续工作。
主循环的顺序会影响响应速度。例如程序正在执行绕障动作或等待按键松开时,其他功能也要等当前代码让出执行机会后才能继续处理。因此代码中有些延时函数会在等待期间调用 Blynk.run(),这是为了尽量保持远程连接不掉线。
初次阅读这类程序时,不需要把每一行都记住。更重要的是理解每一轮循环会检查哪些输入、更新哪些变量、触发哪些输出。按照这个顺序看,整份代码的运行路径会更清晰。
14. 当前代码注意事项
下面是根据当前源码整理出的注意点,主要用于后续维护、交付或继续开发时参考。
| 位置 | 说明 | 建议 |
|---|---|---|
| 第 18 到 20 行 | 源码中直接写入了 Blynk 授权信息和 WiFi 信息。 | 对外分享前建议替换为占位符,或改为单独配置文件。 |
| 第 189、201、212、223、228、243、442 行附近 | 部分中文注释或串口提示在源码中显示为乱码。 | 把源码统一保存为 UTF-8,并把提示文字改回正常中文。 |
| 第 235 行、第 343 行 | OLED 打印字符串疑似受乱码影响,字符串没有正常结束。 | 建议改成合法字符串,例如 u8g2.print("Password is correct"); 和 u8g2.print("password");。 |
| 第 163 行 | pulseIn(PB9, HIGH) 没有设置超时时间。 |
如果没有收到回波,程序可能等待较久。可以增加超时参数。 |
| 第 431 到 448 行 | 解析定位字符串前没有检查逗号是否存在。 | 建议判断 indexOf() 是否返回 -1,避免异常数据导致截取错误。 |
| 整体 | 部分变量当前未使用,例如 playlist、run_state、weight_value_g、my_count。 |
如果后续没有对应功能,可以删除,减少阅读成本。 |
| 整体 | 转弯和绕障主要依赖固定延时,属于开环控制。 | 如果需要更稳定的运动效果,可以加入编码器、陀螺仪或更完整的避障策略。 |
建议的最小修正
// OLED 成功提示
u8g2.print("Password is correct");
// OLED 密码输入提示
u8g2.print("password");
// 超声波读取增加超时示例
float distance = pulseIn(PB9, HIGH, 30000) / 58.00;