1. 项目缘起与整体设计思路
1.1 为什么想到用赛车方向盘控制树莓派小车
这个项目的起点其实特别简单:我手头有一台吃灰很久的罗技 G29 赛车方向盘,还有一台用树莓派 4B 搭的差速小车。某天突发奇想——既然赛车模拟器能把方向盘转角、油门、刹车、离合这些信号实时读出来,那为什么不用它来开真车(物理小车)呢?这个念头一冒出来就压不住了。
传统树莓派小车的控制方式无非几种:手机 App 蓝牙遥控、网页端虚拟摇杆、PS2/Xbox 手柄,或者干脆跑个 ROS 导航栈让它自己走。这些方案我都试过,但都有一个共同的问题——操控手感太"数字"了。手机触屏没有力反馈,手柄摇杆行程短、精度差,自动导航又缺少"人车合一"的参与感。而赛车方向盘不一样,它有 900 度转向行程、有霍尔传感器级别的精度、有踏板模拟量输入,最关键的是——开起来像那么回事。
所以这个项目的核心目标就三条:第一,把 G29 的转向、油门、刹车信号实时读出来;第二,把这些信号通过无线链路发给树莓派小车;第三,让小车根据方向盘角度做差速转向、根据油门刹车控制速度。听起来不复杂,但真做起来坑不少,后面会一个个说。
1.2 系统架构的选型与取舍
整个系统我拆成了三块:方向盘端(PC)、通信链路、小车端(树莓派)。这个拆分方式不是拍脑袋定的,而是基于一个很现实的约束——G29 的力反馈和踏板信号必须通过 USB 接到一台有完整 USB 主机控制器的设备上,树莓派的 USB 口虽然也能接,但供电和驱动稳定性都不如 PC。
方向盘端跑一个 Python 脚本,用evdev库直接读/dev/input/eventX设备节点,拿到原始的轴数据。这里有个关键决策:不用 pyglet 或 pygame 的 joystick 模块,因为那两个库在 Linux 下对力反馈设备的轴映射经常出错,而evdev是直接读内核输入子系统,最底层、最可靠。
通信链路我选了 UDP 而不是 TCP。原因很直接:遥控场景下实时性比可靠性重要。TCP 的重传机制在丢包时会导致控制指令延迟累积,方向盘转过去了小车还在执行旧指令,那手感就废了。UDP 丢一两个包顶多是小车抖一下,下一帧立刻纠正回来。实测在 5GHz 频段下,局域网 UDP 丢包率低于 0.1%,完全够用。
小车端用树莓派 4B,跑一个 UDP 接收脚本,解析出转向角和油门值后,通过 GPIO 的 PWM 控制 L298N 电机驱动模块。这里没有用 ROS,因为整个逻辑就几十行代码,上 ROS 属于杀鸡用牛刀,反而增加调试复杂度。
1.3 数据流与控制映射的核心逻辑
数据流是这样的:G29 的转向轴输出 -32767 到 32767 的整数值,对应方向盘从左打死到右打死。我在 PC 端把它归一化成 -1.0 到 1.0 的浮点数,然后乘以一个转向灵敏度系数再发出去。这个系数很关键,后面调车部分会详细讲。
油门和刹车踏板各是一个 0 到 255 的轴(G29 的踏板是霍尔式,实际分辨率更高,但 evdev 报上来是 8 位)。我把油门映射成前进速度 0 到 100,刹车映射成减速或倒车。这里有个细节:刹车和油门同时踩时以刹车优先,这是模拟真实驾驶逻辑,也防止误操作把电机烧了。
小车的运动模型是差速驱动,左右轮速度分别计算:
左轮速度 = 基础速度 × (1 - 转向量) 右轮速度 = 基础速度 × (1 + 转向量)当转向量为 0 时两轮同速直行;转向量为 1 时左轮停右轮全速,实现原地右转。这个模型简单但有效,实测转向响应很跟手。
2. 核心细节解析与实操要点
2.1 G29 方向盘在 Linux 下的设备识别
把 G29 插上 PC 后,第一件事是确认系统认出了它。打开终端执行:
ls /dev/input/event* cat /proc/bus/input/devices在输出里找 "Logitech G29 Driving Force Racing Wheel" 这一段,记下它对应的event编号。我这边是event23,但你的机器上可能是别的数字,每次插拔或重启后编号都可能变,所以代码里不能写死。
更稳妥的做法是用evdev的list_devices()按名字匹配:
import evdev def find_wheel(): for path in evdev.list_devices(): dev = evdev.InputDevice(path) if "G29" in dev.name: return dev return None这里有个坑:G29 在 Linux 下会枚举出两个设备节点,一个是方向盘本体,一个是踏板。有些内核版本会把它们合并成一个,有些不会。我遇到的情况是分开的,所以代码里要同时打开两个设备,分别读转向轴和踏板轴。判断方法是看设备的capabilities()里有没有EV_ABS的ABS_X(转向)和ABS_Y/ABS_Z(踏板)。
注意:如果你的系统是 Ubuntu 22.04 以上,可能需要把当前用户加入
input组,否则读/dev/input/eventX会报权限错误。命令是sudo usermod -aG input $USER,然后重新登录。
2.2 轴数据的读取与归一化处理
evdev读出来的原始数据是InputEvent对象,类型是EV_ABS时event.code表示哪个轴,event.value是原始值。G29 的转向轴范围是 -32767 到 32767,但实际物理行程可能只用到 -30000 到 30000 左右,两端有死区。
归一化的时候我做了三件事:去死区、限幅、平滑。去死区是因为方向盘正中间有大约 2% 的机械虚位,不处理的话小车会自己微微跑偏。限幅是把超出 ±30000 的值截断,防止个别异常值导致转向突变。平滑是用一个简单的滑动平均滤波,窗口大小 3,既能去掉抖动又不会明显增加延迟。
class AxisFilter: def __init__(self, deadzone=0.02, window=3): self.deadzone = deadzone self.buf = [] self.window = window def process(self, raw, max_val=30000): val = max(-max_val, min(max_val, raw)) / max_val if abs(val) < self.deadzone: val = 0.0 self.buf.append(val) if len(self.buf) > self.window: self.buf.pop(0) return sum(self.buf) / len(self.buf)踏板轴的处理类似,但不需要去死区,因为踏板默认就是 0(松开)。需要注意的是 G29 的踏板在 Linux 下默认是反的——踩下去值变小,松开值变大。所以归一化后要用1.0 - val翻转一下。
2.3 UDP 通信协议的设计
协议设计我走了极简路线,每个数据包就 8 个字节:4 字节 float 转向量 + 4 字节 float 油门量。用struct.pack('<ff', steer, throttle)打包,小车端struct.unpack('<ff', data)解包。没有包头、没有校验和、没有序列号。
为什么不加校验?因为控制指令是幂等的——收到一个坏包顶多让小车抖一帧,下一帧好包立刻覆盖。加校验和反而增加了解包开销。至于序列号,UDP 本身不保证顺序,但在局域网内乱序概率极低,而且控制场景下旧指令晚到还不如丢掉,所以也不需要。
发送频率我定在50Hz,也就是每 20ms 发一包。这个频率是权衡的结果:太低(比如 10Hz)方向盘转过去小车要等 100ms 才响应,手感明显滞后;太高(比如 200Hz)树莓派端解析压力大,而且无线链路容易拥塞。50Hz 实测下来既跟手又稳定。
提示:如果你的 PC 和小车不在同一台路由器下,需要确认 UDP 端口没有被防火墙拦截。我在 Windows 上测试时就被 Defender 拦过一次,后来在入站规则里放行了对应端口才通。
3. 实操过程与核心环节实现
3.1 PC 端方向盘读取脚本的完整实现
PC 端脚本的主循环逻辑是:读事件 → 更新轴值 → 打包发送。但evdev的read_loop()是阻塞的,如果直接用它就没法按固定频率发送。我的做法是用select做非阻塞读取,主循环里先处理所有待读事件,再统一发一包。
import evdev, select, socket, struct, time wheel = find_wheel() sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) DEST = ('192.168.1.100', 5005) steer_filter = AxisFilter() throttle = 0.0 brake = 0.0 while True: r, _, _ = select.select([wheel.fd], [], [], 0.02) if r: for event in wheel.read(): if event.type == evdev.ecodes.EV_ABS: if event.code == evdev.ecodes.ABS_X: steer = steer_filter.process(event.value) elif event.code == evdev.ecodes.ABS_Y: throttle = 1.0 - event.value / 255.0 elif event.code == evdev.ecodes.ABS_Z: brake = 1.0 - event.value / 255.0 # 刹车优先 final_throttle = throttle if brake < 0.1 else -brake packet = struct.pack('<ff', steer, final_throttle) sock.sendto(packet, DEST)这里select的超时设成 0.02 秒,正好对应 50Hz 的发送周期。如果 20ms 内有事件就读,没有就跳过直接发上一帧的值,保证发送频率恒定。
3.2 树莓派端接收与电机控制的实现
树莓派端我用的是RPi.GPIO库,PWM 频率设成 1000Hz。L298N 的 ENA、ENB 接 PWM 引脚,IN1-IN4 接普通 GPIO 控制方向。核心代码如下:
import socket, struct, RPi.GPIO as GPIO GPIO.setmode(GPIO.BCM) ENA, IN1, IN2 = 12, 20, 21 ENB, IN3, IN4 = 13, 19, 26 for pin in [ENA, IN1, IN2, ENB, IN3, IN4]: GPIO.setup(pin, GPIO.OUT) pwm_a = GPIO.PWM(ENA, 1000) pwm_b = GPIO.PWM(ENB, 1000) pwm_a.start(0) pwm_b.start(0) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(('0.0.0.0', 5005)) BASE_SPEED = 60 # 基础 PWM 占空比 while True: data, _ = sock.recvfrom(64) steer, throttle = struct.unpack('<ff', data) speed = abs(throttle) * BASE_SPEED left = speed * (1 - steer) right = speed * (1 + steer) # 限幅 left = max(-100, min(100, left)) right = max(-100, min(100, right)) set_motor(pwm_a, IN1, IN2, left if throttle >= 0 else -left) set_motor(pwm_b, IN3, IN4, right if throttle >= 0 else -right)set_motor函数负责根据正负值设置 IN 引脚电平并调整 PWM 占空比。这里有个细节:PWM 占空比不能低于某个阈值,因为电机在低占空比下扭矩不足,会发出嗡嗡声但不转。我实测下来阈值大概在 25% 左右,低于这个值直接给 0,避免电机堵转发热。
3.3 转向灵敏度与速度曲线的调参过程
这是整个项目里最花时间的部分。第一版代码跑通后,小车能动了,但开起来像醉汉——方向盘稍微一动小车就猛拐,直行时又左右画龙。问题出在两个地方:转向系数太大和速度曲线太线性。
转向系数我一开始设的是 1.0,也就是方向盘转 10% 小车就 10% 差速。但实际驾驶中,高速时不需要那么大的转向量,低速时才需要大转向。所以我加了一个速度相关的转向衰减:
steer_gain = 1.0 - 0.5 * abs(throttle) # 油门越大转向越柔和 effective_steer = steer * steer_gain这样全油门时转向量只有一半,直行稳定性明显提升。速度曲线方面,我把线性的speed = throttle * 60改成了带指数的方式:
speed = (throttle ** 1.5) * 80指数 1.5 让低速段更细腻,高速段更猛,符合真实驾驶的油门手感。这两个参数我调了大概两个晚上,最后定在steer_gain系数 0.5、速度指数 1.5,实测手感最接近真车。
实操心得:调参时建议先用小油门(20% 左右)在空旷地面测试转向响应,确认不画龙后再逐步加大油门。直接全油门调参很容易撞墙,别问我怎么知道的。
4. 常见问题与排查技巧实录
4.1 方向盘无响应或轴值乱跳的排查
最常见的问题是插上 G29 后脚本读不到数据。排查顺序是这样的:先ls /dev/input/确认设备节点存在;再cat /proc/bus/input/devices确认设备名匹配;然后检查用户权限是否在input组;最后用evtest工具直接看原始事件流。
如果evtest能看到数据但脚本读不到,八成是设备节点选错了——G29 有两个节点,你可能打开了踏板那个。如果轴值乱跳,先检查 USB 线是否接触不良,G29 对供电比较敏感,前置 USB 口经常供电不足,换到主板后置口通常能解决。
还有一种情况是方向盘自动回正力反馈导致轴值漂移。G29 在通电后会有一个自检回正动作,这时候读到的值是不准的。我的做法是脚本启动后先等 2 秒,让方向盘完成自检再开始读数据。
4.2 小车延迟高、操控跟手性差的优化
延迟问题我遇到过两次。第一次是 PC 端用了read_loop()阻塞读取,导致发送频率不稳定,改成select非阻塞后解决。第二次是树莓派端recvfrom阻塞在等包,但 PC 端发送频率是 50Hz,理论上不应该有延迟。
后来用tcpdump抓包发现,树莓派端的 WiFi 省电模式在作怪——默认情况下 WiFi 芯片会在空闲时进入省电状态,导致收包延迟忽高忽低。关闭省电模式的命令是:
sudo iw dev wlan0 set power_save off执行后延迟从平均 80ms 降到了 15ms 左右,手感立刻跟手了。这个坑我查了很久,网上关于树莓派遥控延迟的讨论很少提到 WiFi 省电模式,但实测影响非常大。
4.3 电机干扰导致树莓派重启的解决
这个问题最隐蔽:小车一加速树莓派就重启,减速就正常。一开始以为是电源功率不够,换了 3A 的降压模块还是不行。后来用示波器看电源纹波才发现,电机启动瞬间的电流冲击通过共地回路串到了树莓派的 5V 供电上,导致电压瞬间跌到 4.5V 以下触发欠压保护。
解决办法有三个,我全上了:第一,电机电源和树莓派电源完全分开,用两块电池分别供电,只共地不共电源;第二,在电机端子并联一个 1000uF 的电解电容吸收冲击;第三,在树莓派电源输入端加一个肖特基二极管防止反向电流。改完之后再没重启过。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 方向盘无响应 | 设备节点错误 | evtest测试 | 按名称匹配设备 |
| 轴值乱跳 | USB 供电不足 | 换后置 USB 口 | 使用带供电的 USB Hub |
| 操控延迟高 | WiFi 省电模式 | tcpdump抓包 | 关闭 power_save |
| 小车一加速就重启 | 电机干扰供电 | 示波器看纹波 | 分开供电+加电容 |
| 直行画龙 | 转向死区未处理 | 观察静止轴值 | 加死区+滑动滤波 |
| 低速电机不转 | PWM 占空比过低 | 逐步提高占空比 | 设置最低启动阈值 |
4.4 长时间运行后的稳定性维护
连续跑一两个小时后,偶尔会出现控制失灵。排查下来是两个原因:一是 PC 端脚本的evdev缓冲区溢出,因为主循环里read()没有读干净就进入下一轮,积压的事件越来越多。解决办法是每轮循环用while True把read()读空为止。
二是树莓派端的内存泄漏,RPi.GPIO在频繁ChangeDutyCycle时会有轻微泄漏。虽然一小时也就泄漏几 MB,但跑久了还是会出问题。我的做法是加一个计数器,每处理 10 万包就重新初始化一次 PWM 对象,相当于手动回收。这个技巧比较土,但实测有效,连续跑 8 小时没再出过问题。
注意:如果你的小车用的是树莓派 Zero 2W,CPU 性能较弱,50Hz 的 UDP 解析可能会有压力。建议把发送频率降到 30Hz,或者用 C 写接收端。我用 Python 在 Zero 2W 上实测 CPU 占用在 40% 左右,4B 上只有 8%。
5. 进阶玩法与扩展方向
5.1 加入力反馈让操控更有代入感
G29 的力反馈目前是闲置的,因为 PC 端只读不写。但evdev支持通过upload_effect()向设备发送力反馈指令。理论上可以根据小车的实际转向阻力(比如电机电流)反向计算一个回正力矩,让方向盘有"路感"。这个我还在折腾,难点在于力反馈的更新频率要求很高(至少 100Hz),而 Python 的 GIL 在 50Hz 主循环下已经有点吃力了。
一个折中方案是用evdev的write()直接发简单的常力效果,根据转向量给一个反向阻力。虽然粗糙,但比完全没有力反馈强。代码大概是这样:
effect = evdev.ff.Effect( type=evdev.ff.EffectType.FF_CONSTANT, id=-1, direction=0, trigger=evdev.ff.Trigger(0, 0), replay=evdev.ff.Replay(length=1000, delay=0), constant=evdev.ff.Constant(level=steer * 100) ) effect_id = wheel.upload_effect(effect) wheel.write(evdev.ecodes.EV_FF, effect_id, 1)5.2 用摄像头做第一人称视角回传
现在开小车是"盲开",只能看到小车本身,看不到它前面的路。加一个树莓派摄像头做 FPV 回传会好玩很多。方案是用picamera2采集 MJPEG 流,通过另一个 UDP 端口发回 PC,PC 端用 OpenCV 解码显示。
延迟是最大的挑战。MJPEG 在 640x480 分辨率下,局域网延迟大概 100-150ms,对于慢速小车够用,但高速下会明显感觉"画面滞后于操作"。要降低延迟可以降到 320x240 分辨率,或者用 H.264 硬编码,但树莓派的硬件编码器配置比较麻烦,我还没深入搞。
5.3 录制轨迹与自动回放
既然所有控制指令都是结构化的 float 数据,那录制和回放就很简单了——把每帧的(timestamp, steer, throttle)存成 CSV,回放时按时间戳重发即可。这个功能可以用来做"示教再现":手动开一遍赛道,然后让小车自动重复。
实现上要注意时间戳的精度,Python 的time.time()在 Windows 上精度只有 15ms 左右,建议用time.perf_counter()。回放时不要用sleep精确控制间隔,因为sleep的精度也不够,更好的做法是忙等:
while time.perf_counter() < next_time: pass这样回放的时间误差能控制在 1ms 以内,轨迹重复精度很高。
5.4 从树莓派 4B 迁移到 Pico 的可能性
最近在考虑把小车端从树莓派 4B 换成 RP2040(比如 Pico W)。动机是 4B 对于这个任务来说太浪费了,而且启动慢、功耗高。Pico W 有 WiFi、有 PWM、有足够的 GPIO,跑 MicroPython 或者 C 都行。
难点在于 Pico W 的 WiFi 栈不如 Linux 完善,UDP 收包的稳定性需要验证。另外 MicroPython 的socket模块功能有限,可能要用 C SDK 写。这个迁移我还在评估阶段,如果做成会再写一篇。不过从原理上说,控制逻辑就这么点,移植过去应该不难,主要工作量在 WiFi 通信的稳定性调优上。
6. 个人实操体会与几个关键建议
这个项目从起意到跑通大概花了三个周末,其中调参占了一半时间。最大的体会是:遥控小车的体验瓶颈不在代码,而在机械和电源。代码逻辑再优雅,电源一抖全白搭;参数调得再细,电机扭矩不够照样转不动。所以如果你打算复现,我建议先把供电和电机驱动这部分做扎实,再折腾软件。
另一个建议是先用有线测试再上无线。我一开始就直接用 WiFi,结果延迟和丢包问题混在一起,根本分不清是代码问题还是网络问题。后来改成先用网线直连 PC 和树莓派,确认控制逻辑没问题后再切 WiFi,排查效率高很多。
最后分享一个调参的小技巧:在 PC 端脚本里加一个按键(比如方向盘的某个按钮),按下时把当前steer和throttle打印到终端。这样调参时不用盯着代码看,直接看终端输出就知道当前值是多少,比盲调快得多。这个功能我后来一直留着,每次改参数都靠它。