简介:面向2021年越南STEAM挑战赛设计的自动驾驶机器人系统源码包,适合嵌入式竞赛选手、机器人爱好者与Python开发者,有助于快速掌握基于ESP32CAM的图像采集、WiFi图像传输、车道线识别与自动转向控制的完整方案,同时支持通过键盘进行手动遥控。压缩包共25个文件,涵盖6个Python脚本(图像读取、车道线检测、自动行驶、键盘控制)、2个C++固件源码(ESP32CAM与VIA MakerBot电机驱动)、依赖清单与配置文件,以及多张实物接线图和PDF/Word版环境搭建指南,整体大小仅1.81MB,轻量且便于部署。目前已有59人学习浏览。资源按examples、auto_drive、firmware、docs、images等模块清晰组织,既可直接运行键盘控制与自动驾驶示例,也能结合文档和接线图还原越南STEAM挑战赛的赛题场景。通过阅读源码可理清UDP网络通信、图像处理和电机控制之间的协作关系,后续也可作为无人车项目的改造模板,为二次开发节省时间。
1. 用ESP32和Python搭一套自动驾驶机器人系统,先看闭环怎么形成
一辆四轮小车,底盘上堆着电机驱动板、几路灰度传感器和一枚超声波模块,就是最常见的自动驾驶机器人实验平台。把它从“上电原地转圈”带到“沿黑线巡航、遇到纸箱自动绕开”,核心不是换更贵的硬件,而是把Python写的控制逻辑跑在ESP32的MicroPython环境里,让感知、决策、执行在一个主循环中闭合。这套组合的吸引力在于分工明确:传感器读取、PWM输出交给machine底层驱动,路径规划、转向PID、避障状态机全用Python表达,改动一行逻辑立刻就能在REPL里验证。
适合的人群很具体:做毕设或机器人竞赛的学生、从Web后端转硬件的工程师、以及想给玩具车加“脑子”的业余玩家。需要的基础是懂一点Python语法、认识VCC/GND/SDA这类引脚名称,不需要会写寄存器级驱动,也不用先啃完嵌入式内核源码。下面按自己搭一套底盘的实际顺序写:先选型与接线,再烧录固件,然后把偏差计算和PID调通,最后用日志和排查表把系统调到能连续跑完一整圈。
2. 底盘选型与接线:ESP32自动驾驶机器人的硬件清单和引脚分配
2.1 主控对比:选ESP32是因为执行与调试循环更短
做自动驾驶小车的主控有三条常见路线:Arduino、树莓派、ESP32。Arduino开发简单但单线程跑不了复杂逻辑,RAM只有2KB,连双路PID加超声波滤波都会捉襟见肘。树莓派性能强,可以把摄像头、激光雷达都压上去,但启动慢、供电要求高、一块板子价格够买三块ESP32,用在轮式小车上是拿大炮打蚊子。ESP32落在两者之间:双核240MHz,520KB SRAM,4MB Flash,自带WiFi和蓝牙,MicroPython固件跑起来后,你既能直接操作GPIO,又能用socket收发数据,这是其他两块板子给不了的组合。
另一个常被忽略的点是ESP32的调试成本低。Arduino改程序要重新编译烧录,树莓派维依赖也很费精力。ESP32跑MicroPython可以交互式执行,输入from machine import Pin,马上就能点亮一个LED,这种反馈速度对调PID这种试错密集的任务非常重要。如果后续要把小车接进ROS 2生态,ESP32上还能跑micro-ROS组件,把里程计和传感器作为话题发布出去——这是从单机小车走向机器人操作系统的自然过渡。
2.2 电机驱动、传感器与供电:三张清单和一张引脚表
底盘建议买常见的2驱或4驱亚克力小车套件,配两个带编码器的直流减速电机。电机驱动选DRV8833或TB6612,它们比L298N轻、压降小,10A以内的堵转电流也能扛住。传感器方面,循迹用3路或5路灰度模块(原理是红外发射管照射地面,黑色赛道反射率低,模拟量输出高),避障用HC-SR04超声波,有预算再加一个MPU6050做转向姿态参考。供电是整个系统最容易翻车的环节,建议两节18650串联给电机驱动,再通过DC-DC降压到5V给ESP32和传感器供电,电机电源和逻辑电源从源头分开,只把GND连到一起。
| 外设 | 推荐型号 | ESP32引脚 | 说明 |
|---|---|---|---|
| 左电机PWM | DRV8833 | GPIO 13 | 接AIN1/PWM |
| 右电机PWM | DRV8833 | GPIO 12 | 接BIN1/PWM |
| 左编码器A相 | Hall编码器 | GPIO 34 | 输入模式,上拉 |
| 右编码器A相 | Hall编码器 | GPIO 35 | 输入模式,上拉 |
| 左侧灰度 | 3路红外模块 | GPIO 36 (VP) | ADC通道 |
| 中间灰度 | 3路红外模块 | GPIO 39 (VN) | ADC通道 |
| 右侧灰度 | 3路红外模块 | GPIO 32 | ADC通道 |
| 超声波Trig | HC-SR04 | GPIO 15 | 输出10us脉冲 |
| 超声波Echo | HC-SR04 | GPIO 14 | 输入,3.3V电平 |
| I2C数据线 | 可选OLED | GPIO 21 | SDA |
| I2C时钟线 | 可选OLED | GPIO 22 | SCL |
接线有个容易踩的坑:HC-SR04的Echo输出是5V电平,直接接到ESP32的3.3V引脚上可能烧GPIO,串一个1K电阻分压最省事。灰度传感器是模拟量输出,必须接支持ADC的引脚,ESP32的ADC1(GPIO 36/39/34/35/32)在MicroPython里可以直接用machine.ADC读取,ADC2的引脚在WiFi启用后会部分失效,布线时优先避开。
2.3 感知-决策-执行主循环:代码分层不能乱
硬件接好后,先写一个能体现控制回路的最小程序,不用考虑算法,只为确认每个模块的数据方向正确:
from machine import Pin, ADC, PWM import time motor_a = PWM(Pin(13), freq=1000) adc_left = ADC(Pin(36)) adc_left.atten(ADC.ATTN_11DB) while True: left_val = adc_left.read() if left_val > 2000: motor_a.duty(512) else: motor_a.duty(0) time.sleep_ms(20)PWM(Pin(13), freq=1000)把GPIO13配置成1kHz的PWM输出,freq选1000Hz对直流减速电机足够,高于电机能响应的频率只会增加驱动管损耗。atten(ADC.ATTN_11DB)将ADC量程扩展到0~3.3V,读取值范围是0~4095,黑色赛道反射红外少,电压更高,具体阈值要在实际路面上采集后再定。time.sleep_ms(20)决定了主循环周期是20ms,也就是50Hz控制频率,本文后面所有PID参数都基于这个频率设计,改循环周期必须同步改PID参数。
这段代码的价值不在于“能让车跑”,而在于它建立了正确的数据流认知:传感器的模拟值经过比较变成状态,状态决定PWM输出,输出驱动车轮改变传感器读数,形成一个反馈环。后面做PID和避障,只是把这个循环里的“比较器”换成更聪明的决策逻辑。
3. 在ESP32上烧录MicroPython并跑通最小系统:从命令行到无线调试
3.1 固件烧录:esptool.py擦除与写入命令
MicroPython固件可以从官方下载页面挑支持你芯片版本的文件,ESP32、ESP32-S3、ESP32-C3各有对应镜像。烧录工具推荐用esptool.py,它是乐鑫官方Python工具,pip安装后即可使用。
pip install esptool esptool.py --chip esp32 --port /dev/ttyUSB0 erase_flash esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 460800 write_flash -z 0x1000 ESP32_GENERIC-20240602-v1.23.0.bin--chip esp32指定目标芯片,如果用的是ESP32-S3或C3要改成对应型号,写错会在连接阶段报错。erase_flash不是可选项,旧固件里的分区表会和新镜像冲突,不擦除轻则启动崩溃,重则烧完仍进不了REPL。write_flash -z 0x1000后面的0x1000是烧录起始地址,ESP32的引导程序固定从0x1000开始,移到别处开不了机。Windows用户把/dev/ttyUSB0换成设备管理器里的COM口,macOS上一般是/dev/cu.usbserial-*。
写完固件后按住板子上的BOOT键再上电,或者用--before default_reset让esptool自动复位,避免进入下载模式失败。烧录完成后用串口工具打开115200波特率,如果看到>>>提示符,就说明MicroPython已经活了。
3.2 REPL与代码部署:Thonny、VS Code和main.py的关系
MicroPython的交互式REPL是这套开发流程最大的效率来源。推荐先用Thonny:菜单里选择“解释器”为MicroPython (ESP32),它会自动识别串口并建立REPL连接。此时在下方的Shell里输入print(1+1),板子会立即返回结果,和本地Python解释器体验一致。调试传感器时,我习惯在REPL里逐行敲adc.read()观察数值变化。
正式的项目源码不建议全部写在REPL里,而是按模块组织文件,用Thonny的“保存到”功能把脚本传到板子Flash。文件结构通常这样安排:
boot.py # 开机先执行:配置WiFi、启动WebREPL main.py # 再执行:主控制循环 lib/ pid.py # PID控制器类 sensor.py # 灰度/超声波读取封装 motor.py # 电机驱动封装main.py里的while True会阻塞整个进程,所以开机后如果有WiFi连接、文件同步、日志上传这类操作,全部放进boot.py。Thonny里也可以配置VS Code环境:用Pymakr扩展或直接用串口监视器配合ampy命令行工具上传文件,例如ampy --port COM3 put main.py。写大型工程时VS Code的代码补全和Git集成更舒服,小项目用Thonny一次就能完成编辑、上传、调试三件事,选哪个取决于你手头项目的复杂度。
3.3 无线化:WebREPL、OTA与手机蓝牙控制
反复插拔USB线调试小车很影响心情,MicroPython原生提供WebREPL方案。在boot.py里写入以下代码,开发板每次上电都会自动连接WiFi并启动WebREPL服务:
import network, webrepl, time sta = network.WLAN(network.STA_IF) sta.active(True) sta.connect("你的SSID", "密码") for _ in range(20): if sta.isconnected(): break time.sleep(0.5) if sta.isconnected(): webrepl.start(password="12345678") print("IP:", sta.ifconfig()[0]) else: print("WiFi连接失败")用network.WLAN(network.STA_IF)进入Station模式,connect传入2.4G频段的WiFi账号,5G频段默认连不上。webrepl.start(password=...)会监听8266端口,手机或电脑浏览器打开WebREPL页面填入IP和密码,就能远程操作REPL,webrepl_cli.py命令还能直接上传下载文件。调试小车时,电脑放在赛道边上连WiFi,车在赛道上跑,所有print输出都能实时看到。
OTA升级的思路是在此基础上叠加一层:先下载新固件到Flash的备用分区,校验成功后切换分区并重启。社区常见做法是用micro_ros_espidf_component配合ROS 2做更结构化的通信,但演示级别的OTA,WebREPL通过WiFi传.bin文件也能完成,只是Flash分区布局要预留空间。手机遥控则更直接,ESP32的BLE模块广播一个Service,手机App连接后发三个字节控制左右电机速度和方向,这块逻辑和主控制循环跑在同一个线程里,网络接收要考虑超时,不能因为蓝牙断了就让小车失去控制。
4. 自动驾驶机器人的巡线与避障:用Python写PID和状态机
4.1 灰度循迹:从AD值到车道偏差
把三个灰度传感器横排在车头下方,中间对准黑线,左右各偏一个车宽。理想情况下,车在黑线正上方时左、右侧传感器读数对称,中间传感器数值明显区别于两侧。定义偏差error时,常见做法是计算左右传感器归一化差值:
from machine import ADC, Pin adc_left = ADC(Pin(36)); adc_left.atten(ADC.ATTN_11DB) adc_right = ADC(Pin(39)); adc_right.atten(ADC.ATTN_11DB) def read_line_error(): left = adc_left.read() right = adc_right.read() if left + right == 0: return 0 error = (left - right) / (left + right) return errorerror被归一化到-1到1之间,正值表示左传感器检测到更多反射(车偏右),需要左转修正。为什么要归一化?因为灰度模块读到的绝对值受环境光、地面材质、电池电压影响,同一个地面早上和下午差500都有可能。用除法消掉共模量,阈值和曝光变化就不会直接炸掉你的转向逻辑。5路灰度同理,把每路按位置加权再除以总和,输出的误差更平滑,但计算量对ESP32双核来说也可忽略。
4.2 转向控制:位置式PID实现与三参数调优
有了误差信号,下一步是决定左右轮的速度差。比例项P是最直观的做法:误差大就猛打方向,误差小就微调。但纯P控制在弯道会持续存在稳态误差,因为车必须有一个固定转角才能过弯,而P控制为了维持这个转角需要不断修正,表现就是蛇形走线。加入积分项I消除稳态误差,加入微分项D抑制过冲,三者相加就是完整的PID控制器:
class PID: def __init__(self, kp=0.8, ki=0.05, kd=0.15): self.kp = kp self.ki = ki self.kd = kd self.last_error = 0 self.integral = 0 def update(self, error, dt=0.02): self.integral += error * dt if self.integral > 100: self.integral = 100 elif self.integral < -100: self.integral = -100 derivative = (error - self.last_error) / dt self.last_error = error return self.kp * error + self.ki * self.integral + self.kd * derivativeupdate输入归一化误差,输出是转向量,叠加到左右电机的PWM占空比上。积分限制在±100是抗饱和的关键,不然小车在发车台被挡住时积分会一直累积,松开后原地转圈。dt=0.02必须与主循环实际周期一致,如果你在循环里加了超声波读取导致周期变成30ms,这里必须同步修改,否则微分项计算出的数值会整体偏大。
调参按P到I到D的顺序来。先把ki、kd调成0,kp从0.3开始加,观察直线段是否小幅震荡;震荡后把kp降回稳定值,再每次加0.01的ki,直到弯道不跑偏;最后加kd消除入弯时的过冲。这个表是我在实际小车上的调试顺序参考:
| 参数 | 调大后的直观效果 | 调过头的现象 |
|---|---|---|
| kp | 转向更有力,响应变快 | 直线上左右画龙,电机发热 |
| ki | 弯道跟随更紧,直线更直 | 过弯过冲,停车后车头持续抖动 |
| kd | 入弯平滑,抑制震荡 | 传感器噪声被放大,电机高频嗡嗡响 |
4.3 超声波避障:测距时序与状态机切换
超声波模块测距的原理是发射10us的TTL脉冲,Echo引脚输出与声波往返时间等长的电平,用time_pulse_us测量这个高电平持续时间就能换算出距离:
from machine import Pin, time_pulse_us import time trig = Pin(15, Pin.OUT) echo = Pin(14, Pin.IN) def measure_distance(): trig.value(0) time.sleep_us(2) trig.value(1) time.sleep_us(10) trig.value(0) duration = time_pulse_us(echo, 1, 30000) if duration < 0: return None return duration * 0.0343 / 2声速在空气中约为343m/s,往返时间除以2就是单程距离,得到的是厘米。time_pulse_us的第三个参数30000是超时上限,对应约510cm量程,如果返回-1说明没有收到回波,可能是目标太近(模块盲区约2cm)或接线松动,直接返回None让上层逻辑做超时处理,不能让系统拿一个非法值去决策。
避障决策用有限状态机比一堆if else嵌套可靠。定义三个状态:LINE_FOLLOW(默认循迹)、OBSTACLE_STOP(检测到障碍)、AVOID_TURN(绕行动作)。状态转移条件放在一个函数里,每次循环先查距离,小于25cm就切到停车状态,停车200ms后再根据障碍在左还是在右决定转向方向,完成绕行后重新回到循迹状态。状态机的好处是把“现在该干什么”和“怎么干”分离开,加一个掉头逻辑不需要改动循迹代码,只要在转移条件里加一个语句。
5. 联调验证与排错:让ESP32小车从能跑变成跑得可靠
5.1 一个完整的main.py骨架
import time from pid import PID from sensor import read_line_error, measure_distance from motor import set_motor BASE_SPEED = 640 pid = PID(kp=0.8, ki=0.05, kd=0.15) state = "LINE_FOLLOW" while True: distance = measure_distance() if distance is not None and distance < 25: set_motor(0, 0) state = "OBSTACLE_STOP" else: error = read_line_error() steer = pid.update(error) set_motor(int(BASE_SPEED - steer), int(BASE_SPEED + steer)) state = "LINE_FOLLOW" time.sleep(0.02)控制逻辑按优先级排列:安全检测永远先于循迹,因为传感器读到障碍物时车头可能已经离目标很近了。int()转换很重要,PWM占空比接受整数,直接把浮点传进去会报类型错误。
5.2 校准值掉电保存与启动自检
每次开机重新校准灰度阈值很烦。把校准结果用JSON存进Flash,上电后直接从传感器模块读取:
import json, os calib = {"black": 2500, "white": 800} with open("/calib.json", "w") as f: json.dump(calib, f)启动自检时读回/calib.json,如果文件不存在或字段缺失,就让小车原地转一圈采样,取所有读数的中位数作为阈值,这样换场地后不需要连电脑重新配参数。自检还能把超声波模块的供电状态测一下,连续三次测量全返回None就给车上的LED一个快闪提示。
5.3 回传数据看PID曲线
建一个UDP的socket,把误差和转向量发到局域网里的电脑:
import socket, json, time sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.sendto(json.dumps({"error": error, "steer": steer}), ("192.168.1.100", 9000))电脑上跑一个Python脚本监听9000端口,把收到的数据画成曲线。跑完整圈之后观察曲线,如果误差频繁穿越零线且幅度大,说明kp偏大;如果曲线长时间偏离零线贴在上方,说明ki不够或积分被限幅压住。这个可视化过程比盯着车跑要直观得多。日常遇到最顽固的几个问题,按这个方向排查:
| 现象 | 排查方向 |
|---|---|
| 小车开机不执行任何程序 | boot.py或main.py语法崩溃,串口看Traceback |
| 循迹过弯冲出赛道 | 灰度传感器离地太高,应控制在1~2cm;弯道前减速 |
| 超声波距离值乱跳 | Echo分压电阻缺失,或电池压降导致供电纹波大 |
| 左右电机转速不一致 | 编码器计数方向反了,把A/B相接反再验证 |
| 电池满电但车速下降 | PWM频率选太高,降到1000Hz以下试试 |
调试到后期,最值得做的事是把主循环的实际周期打印出来。很多莫名其妙的抖动不是PID参数问题,而是time_pulse_us在近距离会阻塞太久,导致控制周期从20ms漂到50ms。在关键路径上打ticks_ms()时间戳,你会看到传感器读取顺序对时序的影响比想象中大得多。
本文还有配套的精品资源,点击获取