ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

自动驾驶纵向控制入门:用PID在CARLA与ROS2中实现稳定车速与精确停车

自动驾驶纵向控制入门:用PID在CARLA与ROS2中实现稳定车速与精确停车 1. 纵向控制到底在控制什么为什么自动驾驶的第一行代码是PID而不是神经网络很多人刚接触自动驾驶仿真时脑子里想的都是传感器融合、路径规划、端到端学习这些听着很高级的名词。但真把一辆车丢进CARLA里让它自己跑起来你会发现最先卡住你的不是那些玄乎的东西而是最基础的一个问题车怎么稳稳地停在红绿灯前面而不是冲出去或者反复点头。这就是纵向控制。它是自动驾驶控制层里最不起眼、但又最不能出错的一环。横向控制管方向盘让车沿着规划好的路径走纵向控制管油门和刹车让车速精确跟踪目标速度并且在需要停车时停在准确的位置上。很多交叉学科背景的开发者上手CARLA和ROS2时第一直觉是去翻那些复杂的模型预测控制MPC、线性二次调节器LQR的资料。我的建议很直接第一版纵向控制用PID就够了而且应该用PID。理由有三个PID不需要精确的车辆动力学模型。在CARLA里你拿不到实车那种精确的轮胎力、空气阻力参数PID的误差反馈本质上是把整个车辆当成一个黑箱来处理只要误差在收敛控制量就在修正不需要你精确建模。ROS2生态里PID的调试工具链最成熟。从rqt到Topic可视化从bag录制到参数动态调优每个环节都有现成的工具出问题你能一步步拆出来是哪里的毛病换成MPC大概率排查效率低好几个量级。车辆纵向动力学本身有很强的惯性特征和滞后特性但PID的积分项和微分项恰好可以处理这两个问题前提是参数整定得当。这里说的纵向控制在工程上拆开来看其实包含三个子任务速度跟踪——在巡航时维持设定车速位置停车——在路口或目标点精确刹停起步跟车——从静止状态平滑加速到目标速度。三个子任务对PID的要求是有冲突的速度跟踪要求响应快位置停车要求不超调起步跟车要求无抖动。一套固定参数打天下是行不通的这也是后文要单独讲参数整定的原因。2. 环境准备CARLA与ROS2版本适配是第一道大坑先泼一盆冷水CARLA和ROS2联调版本匹配是决定你半天入门还是两周开不了张的分水岭。我在论坛里见过太多人问为什么我的CARLA启动后bridge连不上最后排查一圈就是CARLA版本和ROS2 bridge版本不匹配。2.1 版本对应关系以目前最常用的组合为例CARLA版本配套ROS2版本Ubuntu版本说明CARLA 0.9.13ROS2 Foxy / HumbleUbuntu 20.04 / 22.04最成熟稳定的组合资料最多CARLA 0.9.14ROS2 Foxy / HumbleUbuntu 20.04 / 22.04改进了部分传感器模拟精度CARLA 0.9.15ROS2 HumbleUbuntu 22.04增加了部分新地图非线性刷新率适配CARLA 0.10.x及以上需确认对应bridge分支需确认社区支持较少不推荐新手这里的关键不是选最新版本而是选carla-ros-bridge官方release中明确支持的那个版本。CARLA的Python API和仿真内核迭代极快bridge的接口往往滞后版本不匹配时最常见的症状是CARLA服务端正常启动但你订阅/odometry时长时间收不到数据或者收到数据但时间戳错乱。2.2 安装步骤里的隐藏细节环境安装本身不复杂但有几个细节值得单独拿出来说。这些细节是我自己踩过坑之后才意识到的常规教程不会写。第一显卡驱动与CARLA的兼容性。CARLA本质是一个基于Unreal Engine的仿真器对GPU要求极高。如果你是双显卡笔记本核显独显必须在启动CARLA时强制使用独显否则画面撕裂且仿真帧率极低。在启动命令前加__NV_PRIME_RENDER_OFFLOAD1 __GLX_VENDOR_LIBRARY_NAMEnvidia是最直接的方式。第二CARLA服务端启动参数。纵向控制调试前期不需要高质量画面渲染建议用低画质模式启动cd /opt/carla-simulator ./CarlaUE4.sh -RenderOffScreen -carla-rpc-port2000-RenderOffScreen参数让仿真器不弹出渲染窗口大幅降低GPU负载让仿真能够稳定跑在较高频率上。这在批量跑仿真和调参阶段特别重要等你要可视化看效果时再去掉这个参数即可。第三ROS2 bridge的多机通信配置。如果你打算在另一台机器上跑CARLA比如一台GPU工作站跑仿真一台CPU机器跑控制算法ROS2的ROS_DOMAIN_ID必须两端保持一致且RMW_IMPLEMENTATION要一致。不少人在这一步被卡住以为bridge挂了其实只是域ID不同导致的暂态连接失败。2.3 验证环境是否就绪环境装好后先别急着写控制代码先用两个命令确认链路通了# 终端1启动CARLA服务端前置准备 cd /opt/carla-simulator ./CarlaUE4.sh -RenderOffScreen # 终端2启动bridge ros2 launch carla_ros_bridge carla_ros_bridge.launch.py然后新开终端检查ros2 topic list | grep -E ego_vehicle|carla如果你能看到/carla/ego_vehicle/odometry、/carla/ego_vehicle/vehicle_status、/carla/ego_vehicle/vehicle_control_cmd等关键话题说明链路已经通了。到这里环境准备才算真正完成。3. 控制架构怎么搭从传感器数据到油门/刹车指令的完整链路环境通了之后接下来要设计的是控制节点的数据流。很多初学者容易犯的一个错误是一上来就闷头写PID节点把传感器数据、规划结果、控制输出全部揉在一个节点里。这在仿真里能跑但换到实车或者换场景时所有逻辑都要重写。我建议的架构是功能分节点每个节点只干一件事用Topic解耦。3.1 数据流总览整个纵向控制链路从数据流向来看大概是这样的CARLA仿真器 ↓ /carla/ego_vehicle/odometry车辆位姿与速度 ↓ /carla/ego_vehicle/vehicle_status速度、挡位等 ↓ /carla/ego_vehicle/vehicle_control_cmd控制指令写入 carla_ros_bridge ↓ 话题转发 path_planner节点规划目标速度 ↓ 发布 /planning/target_speed 和 /planning/target_stop_point longitudinal_controller节点PID控制 ↓ 发布 /carla/ego_vehicle/vehicle_control_cmd包含油门/刹车/转向 carla_ros_bridge ↓ 仿真器执行 CARLA仿真器这个架构看起来简单但信息流设计上有三个容易被忽略的点目标速度的来源。在很多入门项目中目标速度是写死的常量或者用键盘控制。但在实际工程中目标速度来自上游的路径规划模块或行为决策模块。所以我在设计接口时把目标速度定义为一个独立话题/planning/target_speed类型用std_msgs/Float32。这样后续你接纯跟踪、行为树规划器甚至人工设定都只需要改发布端控制端不用动。停车位置与前馈距离。当需要精确停车时比如红灯前PID只知道目标速度是0不知道你离停车线还有多远。这个问题在控制学界称为位置跟踪单纯靠速度PID无法解决。工程上常用的做法是上游发布停车点坐标控制节点实时计算与停车点的距离当距离小于某个阈值时把控制器切换为位置-速度双环模式。时间戳同步。PID控制对数据时效性非常敏感。CARLA bridge发布的/carla/ego_vehicle/odometry自带时间戳但如果你用message_filters做同步订阅一旦某个话题断流或者延迟过大控制节点会直接卡住。我的做法是不用同步机制每个回调各自取最新的值缓存下来在控制周期里统一读取。这种缓存最新快照模式在低延迟控制场景中比synchronizer更稳。3.2 我实测下来好用的Topic和消息设计CARLA bridge自带的消息类型有点复杂为了让控制层的代码干净好维护我习惯在控制节点内部做一次数据转换对外只暴露三个接口订阅/carla/ego_vehicle/odometry提取线速度作为反馈速度。订阅/planning/target_speed提取目标速度。发布/carla/ego_vehicle/vehicle_control_cmd的CarlaEgoVehicleControl消息。CarlaEgoVehicleControl里的核心字段如下# CARLA vehicle_control_cmd 核心字段 # throttle: 0.0 到 1.0油门踏板开度 # brake: 0.0 到 1.0刹车踏板开度 # steer: -1.0 到 1.0转向角度纵向控制时置0 # reverse: bool是否倒车 # hand_brake: bool手刹纵向控制时置False这里有个细节需要注意CARLA的throttle和brake是独立量PID输出的控制量必须先做一次正负分离才能映射到这两个字段上。正控制量对应油门负控制量对应刹车。3.3 控制节点的整体代码框架我用Python写了一个清晰可扩展的PID节点骨架。Python在CARLA和ROS2生态中集成最容易调试迭代速度也快适合原型验证后续要上实车再迁移到C也不难核心算法是一样的。import rclpy from rclpy.node import Node from nav_msgs.msg import Odometry from std_msgs.msg import Float32 from carla_msgs.msg import CarlaEgoVehicleControl class LongitudinalPIDController(Node): def __init__(self): super().__init__(longitudinal_pid_controller) # 订阅车辆状态 self.odom_sub self.create_subscription(Odometry, /carla/ego_vehicle/odometry, self.odom_callback, 10) # 订阅目标速度 self.target_sub self.create_subscription(Float32, /planning/target_speed, self.target_callback, 10) # 发布控制指令 self.control_pub self.create_publisher(CarlaEgoVehicleControl, /carla/ego_vehicle/vehicle_control_cmd, 10) # PID参数后续通过配置文件或动态参数传入 self.kp 1.0 self.ki 0.1 self.kd 0.05 self.prev_error 0.0 self.integral 0.0 self.last_time self.get_clock().now() # 当前状态 self.current_speed 0.0 self.target_speed 0.0 # 控制频率100Hz self.timer self.create_timer(0.01, self.control_loop) def odom_callback(self, msg): # 从四元数/速度消息中提取当前车速m/s self.current_speed msg.twist.twist.linear.x def target_callback(self, msg): self.target_speed msg.data def control_loop(self): now self.get_clock().now() dt (now - self.last_time).nanoseconds / 1e9 if dt 0.0: dt 0.01 self.last_time now error self.target_speed - self.current_speed # PID计算 self.integral error * dt # 积分限幅anti-windup integral_max 10.0 self.integral max(-integral_max, min(integral_max, self.integral)) derivative (error - self.prev_error) / dt if dt 0 else 0.0 output self.kp * error self.ki * self.integral self.kd * derivative self.prev_error error # 输出映射正数-油门负数-刹车 control_cmd CarlaEgoVehicleControl() if output 0.0: control_cmd.throttle min(output, 1.0) control_cmd.brake 0.0 else: control_cmd.throttle 0.0 control_cmd.brake min(-output, 1.0) control_cmd.steer 0.0 # 纵向控制不涉及转向 self.control_pub.publish(control_cmd) def set_pid_params(self, kp, ki, kd): self.kp kp self.ki ki self.kd kd def main(argsNone): rclpy.init(argsargs) node LongitudinalPIDController() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ __main__: main()代码只用了不到70行但已经包含了PID闭环最核心的东西。如果你对ROS2的QoS策略有经验会发现在create_subscription里我没指定QoS。这里有个坑CARLA bridge部分话题用的是SystemDefaultQoS而部分话题特别是/carla/ego_vehicle/vehicle_control_cmd用的是SensorDataQoS。如果你在订阅端用了默认的Reliable QoS某些情况下会因为策略不兼容直接收不到消息。稳妥的做法是订阅/carla/ego_vehicle/odometry时用rclpy.qos.QoSProfile(depth10, reliabilityQoSReliabilityPolicy.BEST_EFFORT)。4. PID为什么能控车速从公式到工程落地的完整推演很多教程会说PID就是比例积分微分然后抛出一个公式就宣布万事大吉。但对于写进控制循环里的PID你不能只懂公式你得知道每一个项在车上到底起什么作用否则调参只能靠穷举法碰运气。4.1 PID三项的物理意义控制系统的标准误差定义是[ e(t) v_{target}(t) - v_{current}(t) ]即目标速度和当前速度之差。比例项P做的事情最直觉误差大输出大误差小输出小。但对车辆纵向控制来说单靠P有两个严重后果。第一当误差趋近于零时输出也趋近于零此时车辆无法克服各种阻力滚动阻力、坡度分量、空气阻力最终停在目标速度附近但始终差一点——这就是稳态误差。第二如果P增益调得过大输出会剧烈震荡车辆表现为急加速急减速的点头状态。积分项I专门解决稳态误差。它把过去所有时间段的误差累积起来即使当前误差很小只要历史上有过误差积分项就会持续输出一个修正量来抵消系统阻力。但这个项在纯电动车或燃油车上有风险响应滞后时误差符号刚反转积分值却还处在正向饱和区导致车辆已经超过目标速度了系统还在加油——这就是典型的超调和积分饱和。所以代码里我特意加了一个integral_max的限幅这是工程必须项。微分项D预测误差的变化趋势。误差在快速减小说明车辆已经在加速接近目标速度微分项会提前踩刹车抑制过冲误差在快速增大说明车在减速且偏离目标微分项会提前补偿。它对高频噪声极度敏感/carla/ego_vehicle/odometry消息在低速时速度噪声较大我能不调太高就不调太高。4.2 位置式PID与增量式PID的工程选型逻辑PID有两套最常见的实现形式位置式和增量式。位置式计算的是当前应该给多少控制量公式是[ u_k K_p e_k K_i \sum_{i0}^{k} e_i \Delta t K_d \frac{e_k - e_{k-1}}{\Delta t} ]增量式计算的是当前应该在上一次基础上增加多少控制量表达式是[ \Delta u_k K_p (e_k - e_{k-1}) K_i e_k \Delta t K_d \frac{e_k - 2e_{k-1} e_{k-2}}{\Delta t} ]增量式的一个好处在控制量是增量即使你算错了系统最终的控制量总是收敛在一个有界范围内不容易出现积分饱和坏处是它需要一个额外的积分叠加过程当控制对象本身存在死区特性时比如刹车踏板的空行程增量式在低速跟车时容易产生极限环震荡。面向自动驾驶纵向控制这个具体场景我建议直接写位置式PID理由有两个车辆油门/刹车有明确的物理边界0到1位置式输出的饱和特性可以方便地通过min/max直接限幅而且限幅误差能立即反映到积分项的处理上逻辑清晰。位置式的控制量物理含义明确这一点在调参时非常重要。你输出0.4就知道是约40%的油门开度这对标定非常有帮助增量式的输出是增量你不经过叠加根本不知道当前实际给到车上的是多少排障时多绕一圈。4.3 油门/刹车死区补偿CARLA的车辆动力学模型存在一个不小的死区现象当你的throttle指令低于约0.15时车辆可能完全不动。这意味着当目标速度低且误差不是很大时PID计算出的微小油门输出会被车辆模型的死区吃掉车会停在原地不动积分项持续积累等积分项累积到超过死区阈值时车又会突然窜出去——这是很多新手第一次仿真时发现车一抽一抽的根本原因。处理死区有几种常见做法我在实践中比较推荐的是停车起步逻辑当目标速度为0或当前速度极低小于0.2m/s时采用独立的小油门起步控制逻辑也就是说将PID输出映射到油门时加入一个死区补偿偏移量# 死区补偿示例 deadzone 0.15 if output 0 and output deadzone: compensated_throttle deadzone elif output 0: compensated_throttle 0.0 else: compensated_throttle output注意这种补偿会破坏PID的线性数学预期所以只在低速起步阶段启用车速一旦超过0.5m/s就切换回原始线性映射。有人问为什么不统一加偏移量因为那会在刹车时也一样引入误踩油门的风险。4.4 前馈控制把PID从事后纠错升级为提前动作单靠PID本质上永远是等误差出现了再纠偏。车辆这种大惯性系统误差出现到纠偏生效之间有一段响应延迟所以纯PID在急加速急减速场景下总是存在滞后。工程上的常规做法是叠加一个前馈项[ u_{total} u_{PID} u_{feedforward} ]其中前馈项一般来自对目标加速度的估算。如果在CARLA里你能拿到规划器给出的目标加速度a_target那么前馈油门可以近似表达为[ u_{ff} \frac{m \cdot a_{target} f_{resistance}}{T_{max}} ]这里m是车辆质量CARLA的车辆参数里可以查询f_resistance是阻力项T_max是最大驱动力矩。虽然在实际调参时很难把f_resistance算得很准但只做粗略估计就能明显减少PID的负担尤其在上坡和下坡场景。5. 调参实录从原地癫痫到丝滑跟车我都经历了什么写代码本身不难真正耗时间的是参数整定。我把整个调试过程完整复盘一遍这个过程比任何数学推导都珍贵。每一个现象背后都对应着一组具体的参数问题和物理原因你按照这个思路走能少走90%的弯路。5.1 第一次运行车在原地癫痫代码写完第一次跑起来我的CARLA车表现非常诡异——油门和刹车以极高的频率交替触发车辆在原地微微颤抖前进不了半米。用rqt_graph和rqt_plot看了一眼数据曲线我立刻发现两个问题第一控制频率过高且油门/刹车切换没有迟滞区间。CARLA的CarlaEgoVehicleControl消息是不要紧的但仿真器的物理引擎在低帧率下响应不够快。当控制频率为100Hz时上一次指令还没被仿真器完全执行下一次指令又来了而这两次指令的符号可能已经完全相反因为误差在零点附近抖动导致油门刹车交替。解决方法是给控制器设置一个死区区间——当output绝对值小于0.02时不做任何输出并增加滞后比较策略如果当前处于油门输出状态则只有误差方向确实反转超过阈值时才切换为刹车。# 输出死区与滞环 deadband 0.02 if abs(output) deadband and self.current_speed 0.0: output 0.0第二微分项在噪声下过度反应。odometry在低速时速度值有±0.05m/s的噪声这个噪声被Kd放大后直接导致了控制量的高频抖动。把dt从0.01改为0.05并且对速度反馈做一个简单的一阶低通滤波alpha0.2抖动立刻缓解。5.2 调参顺序先调固定速度下的稳态精度很多教程建议按照P→I→D的顺序来。这个顺序是对的但有一个前提先在一个恒定目标速度下调出稳态精度再去管动态响应。你在CARLA里设置一段直道目标速度设为10m/s依次按以下步骤操作先把Ki和Kd设为0只留Kp。从一个小值比如0.3开始逐步增加Kp观察车辆最终静止速度离目标速度差多少。如果差得远比如只到6m/s说明Kp还不够。继续增大直到速度误差缩小到5%以内。然后引入Ki从0.05开始慢慢加刷掉稳态误差。这里要观察的是积分项是否导致超调如果超调明显说明Ki过大或者需要更小的Kp配合。最后引入Kd专门用来压制超调和震荡。从0.01开始往上加直到车辆到达目标速度的过程中无明显过冲。我只花了十分钟就得到一组基本可用的参数Kp0.8Ki0.15Kd0.1在CARLA默认的普通轿车模型下。5.3 速度越级跳变动态响应下的减速过冲固定速度稳住后下一个测试场景是让目标速度从10m/s直接阶跃到3m/s。这个测试模拟的是前车突然减速或限速变化的场景。第一次测试车辆在从10m/s减速到3m/s时冲到了2.2m/s又加速回来形成一次明显的超调然后轻微震荡了两次才稳定。追踪过程曲线我定位到问题在积分项上当目标速度阶跃下降时误差值瞬间从接近0变为-7此时的积分项还在正向累积因为之前为了保持10m/s的速度积分项维持了一个较大的正值来克服阻力。减速过程中积分项需要很长时间才能把正向积累释放掉这个残余积分把车辆多往前推了一段距离。这就是经典的积分饱和导致减速超调。除了限幅还有两个常用处理方案积分清零逻辑当检测到目标速度发生大幅变化差值大于某个阈值时直接把积分项清零让PID以纯比例微分的方式快速响应急变。条件积分法conditional integration只在误差较小且控制量未饱和时才积分。误差很大时关闭积分项误差进入小范围才恢复。我用条件积分法后减速超调从0.8m/s下降到0.2m/s效果立竿见影。5.4 精确停车位置-速度双环与前馈刹车的配合纵向控制在路口停车场景下的要求是最苛刻的。你要让车准确停在停车线前误差尽量控制在±0.3米以内且减速过程要平缓不能像急刹车那样让人点头。停车场景的一个关键问题是当你以10m/s速度接近停车线时应该在哪个距离开始刹车如果只在距离很近了才切换到停车控制模式物理上根本刹不住如果距离很远就开始刹车车辆会在离停车线还有一米多的地方就完全停住然后只能靠极其缓慢的蠕动去够停车线。我实测下来比较有效的方法是提前规划一个参考减速度。假设当前速度为10m/s要求在停车线前停下采用恒定减速度模型[ d \frac{v^2}{2a_{dec}} ]如果取舒适减速度( a_{dec} 2.5 m/s^2 )那么需要的停车距离是20米。所以上游规划节点可以在距离停车线20米的位置开始发布一个线性递减的目标速度曲线比如按剩余距离比例来给定目标速度[ v_{target} \sqrt{2 \cdot a_{dec} \cdot d_{remaining}} ]控制器仍然用PID去跟踪这个动态下降的目标速度这样PID自身只负责跟速度不直接感知距离但整个系统从行为上完成了精确停车。我把这个逻辑放在规划节点里控制节点不需要区分巡航还是停车极大简化了控制器的复杂度。在这个场景下我额外加了一个刹车前馈# 当目标加速度为负值减速时给一个基础刹车量 if a_target -0.5: feedforward_brake 0.1 * abs(a_target) output - feedforward_brake加了前馈后刹车的建立时间明显加快PID的负担减轻减速度曲线更平滑。5.5 起步跟车逻辑在红绿灯前停稳后下一个挑战是绿灯起步。此时车辆从速度0加速到目标速度如果直接用巡航PID很容易出现两种极端要么起步太慢被后车滴要么起步猛窜让人不舒服。工程上做一个直接的起步预置油门首先给一个固定的初始油门比如0.25当车速超过1m/s后再切换为正常PID控制。这个方法虽然粗糙但在CARLA的仿真环境下表现非常稳定且逻辑简单不易出错。6. 调试方法论沉淀一些值得带走的经验与技巧到这里整个纵向控制的核心闭环已经跑通了。最后沉淀几条我认为最值得带走的经验供你在做类似项目时参考。第一条状态可视化比任何日志都重要。调参过程中只用print打印数值很难建立直觉一定要把目标速度、当前速度、PID输出、油门/刹车值同时画在rqt_plot或PlotJuggler里。当你把多条曲线叠在一起看的时候很多问题的原因一眼就能发现——比如油门刹车高频交替、积分项在减速时还维持正向等等。用开源工具不花钱省下的是最宝贵的时间。第二条场景自动化是反复调试的前提。手动用键盘控制CARLA车辆跑固定的调试路线非常低效。建议从一开始就写一个简单的场景脚本定义一条直道、一个弯道、一个红绿灯路口用Python脚本控制场景循环执行。每次改完参数都可以一键重复同一场景这样才能量化对比参数变化对控制效果的影响。第三条从仿真到实车的降级意识。CARLA的仿真环境比实车友好得多没有传感器噪声、没有通信延迟、没有执行器迟滞。但正因为这一点仿真里调好的参数拿到实车上大概率过于激进。设计软件架构时建议把PID计算频率、滤波器参数、死区和滞环阈值全部做成可配置项为后续移植预留空间。我在这类项目中坚持用YAML配置参数并通过ROS2的动态参数rclpy.parameter支持运行时修改这样就避免了每次调参都重新编译或者重启节点的痛苦。第四条版本记录要扎实。调参过程中很容易陷入改了Kp、跑了效果好了、又改Ki、忘了之前在哪个基础上改的的混乱。我的做法是每跑一次实验就把参数值和曲线截图以日期命名保存下来并记录当时的场景描述和目标现象。坚持一周后回头看你会发现自己对系统行为的理解远超凭感觉调参的阶段。写到最后再分享一个小技巧CARLA的vehicle_control_cmd是允许你在仿真运行过程中随时修改的所以你可以用ROS2的命令行工具在调参时手动发送一条控制指令来探车辆的响应特性。比如ros2 topic pub --once /carla/ego_vehicle/vehicle_control_cmd carla_msgs/msg/CarlaEgoVehicleControl {throttle: 0.3, brake: 0.0, steer: 0.0}这条命令会发送一个30%油门踏板开度的指令观察车辆的加速度响应。花几分钟做几次这种开环测试你对车辆模型的理解会远超看任何文档的效果。纵向控制的本质不是复杂算法的堆砌而是对系统特性深深理解后的精细匹配。
返回列表