先说点实在的。做自动驾驶控制算法的人,大概率都遇到过这个窘境:实车测试成本高、周期长,纯软件仿真又总觉得隔了一层,车辆动力学不够真实。后来圈子里慢慢流行起一套组合——把Apollo的控制模块和CarSim车辆动力学模型联合起来跑仿真,既能用上Apollo里现成的LQR、MPC控制器,又能获得接近真实车辆的底盘响应特性。这套方案最大的痛点在环境:Apollo原生是Linux的玩法,而很多人手头的工作机是Windows,想在Windows里把Apollo控制模块单独编译出来、再和CarSim做联合仿真,中间的坑绝对不是一两个。
我这次就把自己从零搭起来的“Apollo控制模块Windows独立编译 + CarSim联合仿真”完整过程整理出来。整个过程不涉及整车的复杂CAN协议,也不碰Apollo整套庞大工程,只提炼出“控制模块独立编译”和“CarSim联合仿真”这条最小可行链路。无论你是刚接触自动驾驶仿真的学生,还是在公司里做控制算法预研的工程师,只要手里有一台Windows电脑、装好了CarSim和MATLAB,就能照着这条链路跑起来。
1. 项目整体设计与思路拆解
1.1 为什么要拿这套组合做控制仿真
先聊清楚底层逻辑。控制算法开发最怕的就是“算法在仿真里好好的,一上车就拉胯”。原因在于很多仿真工具把车辆模型做成理想化运动学模型,轮胎没有滑移、悬架没有侧倾、转向系统没有延迟——这样的模型验证不了控制算法的鲁棒性。CarSim最大的价值就是它的车辆动力学模型足够细,轮胎、悬架、转向、制动都有实测数据标定,跑出来的车辆响应跟实车非常接近。Apollo控制模块的价值则相反,它已经经历了大量实车验证,里面的横纵向控制逻辑、超速保护、转向限幅这些工程细节都是现成的,比从零手写一个PID控制器要成熟得多。
把两者联合起来,本质上就是在做“硬件在环”之前的一道关键验证:用CarSim充当虚拟底盘,用Apollo控制模块充当“大脑”,验证控制算法在接近真实的车辆响应下是否还能稳得住。这套组合还能顺带完成控制参数的初步标定,为后续实车测试省下大量时间。
1.2 Windows独立编译的动机与整体架构
为什么要单独编译控制模块,而不是直接装整套Apollo?原因很直接:Apollo整套系统依赖太多(Docker、CyberRT、各个感知模块、地图模块、注释生成器等),对Windows的适配又很差,要把整套工程在Windows上跑通,付出的精力足够写完一个控制器了。但控制模块本身相对独立,它依赖的主要是CyberRT通信框架和Eigen等数值库,所以把控制模块这一层单独拽出来编译,是Windows环境下性价比最高的方案。
我采用的整体架构分三块:
- Apollo控制模块侧(Windows独立编译):只保留控制模块的代码、消息定义、CyberRT通信库,对外通过UDP收发车辆状态和控制指令。
- 桥接层(自定义程序):负责协议转换。CarSim输出的车辆状态是Simulink模型里的变量,Apollo控制模块输入输出的是Proto消息,桥接层负责在两者之间做格式转换和UDP收发。
- CarSim + Simulink侧:CarSim提供车辆动力学模型,Simulink负责和外部程序通信、运行控制指令。
这三个模块通过UDP连接,各自独立运行,互不阻塞。这也是整个方案的关键设计思路:不追求在Windows里完美复刻Apollo整套系统,只做一条简洁的“数据通道”,让控制模块和车辆模型能对上话。
注意:这套方案中UDP通信频率是核心参数。控制模块运行在100Hz,CarSim本身可以跑到1kHz或更高,但UDP通信如果频率不匹配,很容易出现数据毛刺或丢包。我在桥接层里统一把通信周期设定为10ms,也就是100Hz,和Apollo控制模块的内部运行频率保持一致。
2. 核心细节解析与实操要点
2.1 Apollo控制模块依赖清单
控制模块的代码体量不大,但它依赖的几个库是硬要求:
- Eigen3:矩阵运算库,Apollo的LQR和MPC控制器全部靠它做矩阵求逆和求解。版本必须至少3.3.0,推荐3.4.0。
- CyberRT:Apollo的通信中间件,负责消息的发布和订阅。Windows下没法直接编译完整CyberRT,但CyberRT的核心代码其实是跨平台的,只需要把其中依赖的absl、protobuf、fastrtps这几个库拉下来编译。
- protobuf:消息序列化库,Apollo的Proto文件需要用它生成C++代码。
- osqp / osqp-eigen:MPC控制器求解器。Apollo的MPC控制器用的就是OSQP,没有它MPC部分编译不过。
这里有个很实际的建议:不要一开始就想着把控制模块的所有控制器全部编译通过。Apollo的控制模块里包含LQR、MPC、纵向控制、横向控制、轨迹跟随等一堆控制器,每个控制器依赖不同库。我建议第一步只编译“横向LQR + 纵向PID”这一条最小链路,跑通后再考虑加MPC。这样依赖少、编译快、排查问题容易。
2.2 CarSim接口机制与仿真步长
CarSim本身不是实时仿真软件,但它的车辆模型解算精度很高。CarSim和Simulink的联合仿真有两种典型路径:
- 一种是CarSim生成S函数,嵌入Simulink模型里跑;
- 另一种是CarSim以独立进程运行,通过共享内存或UDP和Simulink交换数据。
推荐选择CarSim生成S函数嵌入Simulink的方式,原因是它最稳定。CarSim在配置界面里把车辆模型导出成Simulink模块,模块的输入是四个车轮的制动压力、油门开度、前轮转角,输出是车辆的位置、速度、横摆角、质心侧偏角、纵向加速度等十几个状态量。
仿真步长的设定是个细节坑。CarSim模型本身的解算步长可以设定到1ms,但Simulink和UDP通信环节如果把步长也设成1ms,会产生巨大的数据量,而且Apollo控制器根本跑不了那么快。我在实践中把Simulink的固定步长设为10ms,CarSim内部解算步长设为1ms,这样CarSim模型在单个仿真步长内完成5次内部积分,输出给Simulink的信号相当于经过一阶滤波的平滑值,控制指令的响应也比较连续。实测下来这个配置的数值稳定性最好。
2.3 通信方案选型:UDP vs 共享内存
联合仿真时,Apollo控制模块(C++独立进程)和CarSim(Simulink仿真环境)之间的通信方案我对比过两种:
| 通信方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| UDP | 跨进程简单、实现成本低、调试方便 | 有轻微延迟抖动、需自行处理拆包粘包 | 本机联合仿真、快速验证 |
| TCP | 可靠传输、不乱序 | 连接管理复杂、延迟高于UDP | 跨机器远程仿真 |
| 共享内存 | 延迟极低、节省拷贝 | 实现复杂度高、清位图逻辑易出错 | 高频率实时仿真(>500Hz) |
对于跑100Hz的控制仿真,UDP完全够用。它的延迟在0.1ms级别,远小于10ms的控制周期。我也试过共享内存方案,性能确实更好,但在Windows下调试起来非常麻烦,而且共享内存通常是一个进程读一个进程写,需要额外的同步机制,如果控制模块崩溃了,共享内存段不会自动清理,直接导致下一次启动失败。对于“先跑通、再优化”这个目标来说,UDP是投入产出比最高的方案。
3. 实操过程与核心环节实现
3.1 环境准备清单
这是我的实际环境,作为参考:
- Windows 10 专业版 22H2
- Visual Studio 2019(用于C++编译,需安装“使用C++的桌面开发”工作负载)
- MATLAB R2021a + Simulink
- CarSim 2020.1
- CMake 3.20以上
- Git
需要特别提醒,Visual Studio版本和MATLAB版本的兼容性一定要提前确认。我一开始用的是VS2022,编出来的C++动态库在MATLAB R2021a里加载时直接报“应用程序无法启动”,折腾了一天才发现是运行时库版本不匹配。后来换了VS2019 + v142工具集,问题才解决。如果你用的是新版本MATLAB,那VS2022可能没问题,但如果MATLAB版本偏旧,最好就用VS2019。
3.2 CarSim车辆模型配置
在CarSim主界面里,需要确认这几个关键设定:
- 车辆型号选择:推荐选择“Sedan”或“SUV”这类具有标准参数的量产车模型,不要选带主动悬架、四轮转向等特殊配置的模型,那些模型在控制仿真中反而容易引入干扰因素。
- 仿真步长:在“Time Step”里设置,主步长1ms或2ms都可以,关键是Simulink侧的步长要和它匹配。
- I/O接口配置:这是最容易出错的地方。CarSim里需要把“External Data”接口打开,并且定义哪些变量是从Simulink输入、哪些变量是输出到Simulink。我的输入通道定为:
IMP_STEER_SW(方向盘转角)、IMP_THROTTLE_ENGINE(油门)、IMP_BRAKE_MASTER_CYL(制动主缸压力);输出通道定为:Vx(纵向速度)、Vy(横向速度)、YawRate(横摆角速度)、X(全局X坐标)、Y(全局Y坐标)、Yaw(横摆角)、Ax(纵向加速度)、Ay(横向加速度)、Steer_SW(转向角反馈)。这些变量都是控制模块的输入输出接口上最常用的。
配置完成后,在CarSim里选择“Send to Simulink”,它会自动生成一个以carsim_开头的S函数模块,这个模块就是和CarSim模型沟通的唯一通道。
3.3 Simulink桥接模型搭建
在Simulink里,整个桥接模型分四部分:
- CarSim S-Function模块:这是CarSim导出的车辆模型。输入是前轮转角(注意这里是车轮转角,不是方向盘转角,中间有个转向传动比换算)、油门百分比、制动压力;输出是车辆状态量。
- UDP发送模块(Output):将CarSim输出的车辆状态打包成UDP报文,发送给Apollo控制模块。
- UDP接收模块(Input):接收Apollo控制模块发出的控制指令,解析出目标前轮转角、目标油门、目标制动压力。
- 信号预处理模块:对信号做限幅和单位换算。
这部分的信号预处理很多人在一开始会忽略,但它恰恰是关键。举个例子:CarSim的方向盘转角输入范围通常是正负540度,而Apollo控制模块输出的前轮转角单位是弧度,你需要先换算成方向盘转角(乘以转向传动比),再做一个饱和度限制,防止超过物理限位。油门和制动同理,CarSim的油门输入是0-1的百分数,制动压力是MPa,Apollo输出的则可能是0-100的百分比,需要做一个线性映射。
我搭的UDP发送模块基本逻辑如下(用MATLAB Function实现):
function send_data = pack_vehicle_state(u) % 输入u包含CarSim输出的状态量 % 输出send_data为uint8类型数组,按字节序封装 vx = u(1); vy = u(2); yaw_rate = u(3); yaw = u(4); ax = u(5); ay = u(6); x = u(7); y = u(8); % 将double数转换为Little-Endian字节序列 send_data = zeros(64, 1, 'uint8'); send_data(1:8) = typecast(single(vx), 'uint8'); send_data(9:16) = typecast(single(vy), 'uint8'); send_data(17:24) = typecast(single(yaw_rate), 'uint8'); send_data(25:32) = typecast(single(yaw), 'uint8'); send_data(33:40) = typecast(single(ax), 'uint8'); send_data(41:48) = typecast(single(ay), 'uint8'); send_data(49:56) = typecast(single(x), 'uint8'); send_data(57:64) = typecast(single(y), 'uint8'); end这里用single类型而不是double,原因是控制仿真里这个精度完全够用,而且字节数减半,UDP报文更紧凑。实测单精度浮点引起的误差在10的负7次方量级,对控制算法毫无影响。
3.4 Apollo控制模块的Windows编译
这一步是整个项目里最需要耐心的环节。C++控制模块的代码依赖CyberRT库和protobuf生成的代码,Windows下没有现成的预编译包,需要自己拉源码编译。
我的编译步骤如下:
- 用Git拉取Apollo控制模块源码(控制模块有几个核心目录:
modules/control、cyber、common、third_party)。 - 单独拉取依赖库源码:protobuf(版本至少3.6)、Eigen3、osqp-eigen、absl。
- 用CMake生成工程时,把
modules/control的源码直接作为目标工程加入,而不是加入整个Apollo工程。 - 对于proto文件,需要用protoc工具先生成C++代码。
- 编译时选择Release模式,配置动态库的输出目录。
编译完成的控制模块最终是一个可执行文件或动态库,我这边是编译成了可执行文件control_standalone.exe,它会监听UDP端口8000接收车辆状态,在内部运行控制算法后,把控制指令通过UDP发送到端口8001。
关键编译参数如表所示:
| 参数 | 设定值 | 说明 |
|---|---|---|
| 编译器 | MSVC v142 | 需与MATLAB运行时库兼容 |
| C++标准 | C++17 | Apollo较新代码需要C++17 |
| 优化选项 | /O2 | 保证运行速度 |
| 运行库 | /MD | 动态链接,便于和MATLAB共存 |
| 宏定义 | EIGEN_NO_DEBUG | 关闭Eigen调试断言,提升性能 |
另一个容易踩坑的细节是proto消息的定义。Apollo控制模块内部使用的是apollo.control.ControlCommand和apollo.localization.LocalizationEstimate这些消息类型。独立编译时,不需要引入完整的Apollo消息库,只需要把和这两个消息相关的proto文件提出来编译即可。我保留的文件是control_cmd.proto和localization.proto以及它们依赖的header.proto、pnc_point.proto,这样生成的代码量小很多,编译速度也快。
3.5 桥接程序的编写
桥接程序是连接Simulink和Apollo控制模块的“翻译官”,它的核心任务有三件:
- 从UDP 8000端口读取Simulink发来的车辆状态数据包,解析成Apollo控制模块需要的
LocalizationEstimate消息。 - 调用控制模块内部的控制逻辑(可以理解为调用控制库的函数)。
- 把控制结果打包成CarSim能认的报文,转发到UDP 8001端口。
桥接程序我用标准C++编写,核心循环体逻辑大致如下:
while (true) { // 1. 接收车辆状态 char buf[64]; size_t len = udp_recv(8000, buf, sizeof(buf)); if (len == 0) continue; // 2. 解析车辆状态为Apollo消息 apollo::localization::LocalizationEstimate localization; localization.mutable_pose()->mutable_position()->set_x(decode_double(&buf[48])); localization.mutable_pose()->mutable_position()->set_y(decode_double(&buf[56])); localization.mutable_pose()->set_heading(decode_double(&buf[24])); localization.mutable_pose()->mutable_linear_velocity()->set_x(decode_double(&buf[0])); localization.mutable_pose()->mutable_linear_velocity()->set_y(decode_double(&buf[8])); localization.mutable_pose()->mutable_angular_velocity()->set_z(decode_double(&buf[16])); // 3. 调用控制算法 apollo::control::ControlCommand cmd; controller_.ComputeControlCommand(localization, &cmd); // 4. 将控制指令打包发送 char out_buf[48]; encode_double(out_buf, cmd.steering_target(), 0); encode_double(out_buf, cmd.throttle(), 8); encode_double(out_buf, cmd.brake(), 16); udp_send(8001, out_buf, 24); }这里把Apollo的ComputeControlCommand函数直接封装成接口,传入定位消息、输出控制指令,这样最干净。控制模块内部的“轨迹跟随”逻辑需要一条预设轨迹作为参考,我是在程序启动时读入一条静态轨迹(可以用之前仿真录制的期望路径),存成vector后传给控制模块。
3.6 联合仿真启动流程
启动顺序有讲究,错了会出现数据丢失或者首次控制指令为零的情况。我的启动流程是:
- 先启动Simulink模型,让CarSim模型就绪,等待外部连接。
- 再启动Apollo控制模块,它会主动向CarSim侧发送“握手连接”信号。
- 最后启动桥接程序,此时数据链路才是通的。
先说个实测数据。我跑的一段典型工况是:车辆直行,目标速度为80km/h,第一步做一次侧向正弦扫频转向,频率从0.2Hz到1Hz,用于验证横向LQR控制器的频域响应。CarSim输出的横摆角速度在0.5Hz处出现了明显跟随误差,最大偏差0.5rad/s。换成MPC控制器后再跑,同样的工况下0.5Hz跟随误差降到0.2rad/s。这说明MPC的预测能力在中等频段确实优于LQR,这也是为什么现代自动驾驶系统越来越倾向于用MPC。
再记录一组不同控制参数下车辆横向响应对比:
| 控制器 | 横摆角速度跟随误差 | 质心侧偏角峰值 | 横向位移误差 |
|---|---|---|---|
| LQR (Q=1, R=0.1) | 0.51 rad/s | 2.8° | 0.23 m |
| LQR (Q=5, R=0.1) | 0.36 rad/s | 2.1° | 0.18 m |
| MPC (预测时域=3s) | 0.21 rad/s | 1.5° | 0.09 m |
这组数据让我对MPC的调试有了直观认知:预测时域拉长,控制精度明显改善,但计算量也对应增长。在普通PC上,MPC单步计算耗时约12ms,虽然没超过100Hz的10ms周期,但余量已经很小。如果后续要跑更高的控制频率,就得考虑OSQP配置的更紧凑矩阵格式来优化求解速度。
4. 常见问题与排查技巧实录
4.1 编译阶段的坑
问题1:protobuf和absl的链接冲突
现象:编译控制模块时,报一堆LNK2005重复定义错误。
原因:CyberRT库本身编译时依赖了一个版本的protobuf,控制模块的proto生成的代码又链接了另一个版本的protobuf,两个版本在符号表里发生了冲突。
解决方案:统一所有依赖库的protobuf版本,并且保证“所有依赖库用同一个版本的protoc生成代码、所有静态库用同一个版本的libprotobuf链接目标”。我最终把全局protobuf固定在3.19.4,absl固定在20210324.0,编译通过。
问题2:缺少Windows下的原生命函数
现象:std::this_thread::sleep_for在Windows上编译报unrecognized错误。
原因:使用频率高的代码片段需要适配Windows的sleep替代方案。这个直接在编译时加一个适配宏即可。
4.2 UDP通信中的数据不同步
UDP的坑主要集中在频率抖动和数据错位。一次实测中,我让Simulink每10ms发一包数据,但Apollo控制模块接收时发现相邻两包的时间间隔在8ms到15ms之间波动。原因是Simulink的实时性并不严格,它受系统调度影响较大,尤其在笔记本电脑上跑,Windows系统的高精度定时器本身就是不稳定的。
解决思路是:在桥接层加入带有时间戳的队列。每个数据包都带独立的发送时间,Apollo控制模块在收到数据包后,用自己的时钟为参考,对数据进行时间对齐。具体做法是记录每包的接收时间,使用插值法,将车辆状态线性插值到和控制模块同步的时刻。
同一阶段我还尝试过用Windows高精度计时器控制Simulink的UDP发送节奏,逻辑上可行,但实现复杂度指数级上升。后来想明白了:仿真本来就不追求“物理时间完全同步”,控制算法只关心“状态采样是有效的”,所以与其纠结每包是否严格间隔10ms,不如直接确保后续控制决策时使用的车辆状态是按时间序列插值出来的。
4.3 仿真发散与实物吹飞
问题4:CarSim跑一会儿就发散
现象:车辆某一时刻开始横向位置狂跳,Simulink显示数值达到10的16次方。
原因:UDP数据在发送过程中出现小数错误,某个字节被误改成NaN,CarSim的积分器接收到NaN后,后续解算直接发散。
排查过程很费时间:一开始怀疑是CarSim模型刚度有问题,调了轮胎模型、悬架参数完全不见好转。最后在桥接层加入数据合法性检查,在发送前判断所有double是否为NaN或Inf,如果是就直接丢弃这一帧,保留上一帧数据。改完后,仿真再没有发生过发散。
这个经历让我养成了一个习惯:所有进入模型的数据都必须做合法性校验。不光是联合仿真,只要涉及通信的数据,都应该先验证再使用。
问题5:Matlab Function里输出类型的坑
现象:MATLAB Function里打包UDP数据时,只把double转成了uint8数组,但Simulink报了“输出尺寸不匹配”。
原因:Matlab Function模块必须在“Ports and Data Manager”里明确声明输出信号的类型和维度。如果只写了代码而没声明端口属性,Simulink默认推断不出输出尺寸,直接报错。
解决方案:打开Function模块的“Edit Data”,把输出端口send_data的类型设为uint8(64),尺寸为向量。
问题6:CarSim的S函数被占用
现象:第一次跑Simulink模型没有问题,第二次启动时CarSim报错,提示端口被占用。
原因:上一个仿真进程没有完全退出,CarSim的共享内存句柄没有释放。
解决方案:任务管理器里结束所有carsim开头的进程,同时删除CarSim生成的临时文件,再重新启动。这点每次都要记得检查,否则会浪费很多时间排查“明明什么都没改,怎么就跑不了”。
5. 实操经验总结与调度思路
5.1 时间调度的策略
联合仿真中,时间调度是整个系统中的“地基”,地基不稳,上面的楼层全会歪。我先梳理一下整个链路的时间分配:CarSim模型每10ms解算一次,桥接程序每10ms发送一包状态数据,Apollo控制模块每10ms计算一次控制指令,桥梁再每10ms发回一包控制指令。
这个过程中可能存在一个误区:认为只要每个环节都是10ms,它们就能完美对齐。实际测试中,由于Simulink解算耗时不稳定,桥接程序的网络收发也存在微小的阻塞,这些累加在一起,最终会导致控制模块的输入频率在9-12Hz之间波动。
针对这个情况,慢慢摸索出一个“双缓冲加时间戳”的调度方案:桥接层的两个缓存区分别缓存发送和接收的数据包,每次控制模块取数据时,会检查数据的时间戳是否大于上一次的时间戳,如果有跳跃,就直接用最新帧,如果数据没有更新,就先沿用上一帧数据。这套方案在代码上多写了二十几行,但彻底避免了控制指令乱序和空转的问题。
5.2 提高运行效率的取舍
这套联合仿真跑起来后,CPU占用率实测在25%-35%之间,内存占用约2.5GB。不是特别吃资源,但如果你同时在电脑上开浏览器、IDE、音乐软件,Simulink的实时性会明显变差。
一个非常有效的优化是:将Simulink模型中的“代码生成加速”模式切换为“Normal”模式。代码生成模式虽然能加速仿真执行,但在联合仿真场景下会导致每步计算的耗时高度不稳定,反而破坏了时间节奏。Normal模式下仿真速度稍慢一点,但时间序列平稳得多,控制算法的运行频率更稳定。
如果做完这些优化还是觉得仿真不够流畅,可以尝试把Simulink的固定步长从10ms调到20ms,但这样CarSim的模型精度会有所下降。我建议除非景中的故障删除,否则尽量保持10ms步长,这是精度和稳定性最均衡的档位。
5.3 从这套环境能学到什么
这套环境的价值,还原到最本质,就是给了你一个可重复、低成本、接近实车动态响应的控制算法试验场。它可以用来:
- 对比不同控制器(LQR、PID、MPC)在相同车辆模型下的性能表现;
- 调节控制器的权重参数,理解每个参数对车辆瞬态响应的影响;
- 验证控制算法面对轮胎饱和、转向限位等情况时的鲁棒性;
- 为后续硬件在环仿真乃至实车测试提供前期的控制参数初选。
踩过几次坑之后,我对“仿真结果不能全信”这句话有了更深的理解。仿真里的控制指标再好看,也只是候选参数,等真要上实车前,还需要在硬件在环台架上用更精细的车辆模型验证一遍。但反过来看,如果仿真里都稳不住,就别提实车了。
5.4 后续可以怎么扩展
这条链路搭好后,有几条现成的扩展方向,想走的同学可以参考:
- 把控制模块替换成MATLAB/Simulink里自己写的控制器:CarSim模型完全不用动,只需要在桥接层换一种数据协议,就能同时验证自研控制器的效果。
- 加入ROS环境:将UDP协议替换成ROS的topic,同时保留CarSim模型,这样控制算法的输入输出就可兼容整个ROS生态。
- 引入场景编辑器:在CarSim里建立带有障碍物和交通流的仿真场景,把控制模块的输入从单条静态轨迹切换为“参考轨迹+前方障碍物”,向城市工况仿真迈进一步。
扩展的时候有一个核心建议:每一次扩展只改一个变量。比如这次只换控制器,下次只换通信中间件,再次才换场景,不要一次改三个点,否则出问题根本不知道怎么排查。
测试到底,这套“Apollo控制模块 + CarSim联合仿真”方案的核心价值在于:它让你在没有实车、没有整套自动驾驶系统的条件下,用最简的工程链路,就能验证控制算法的设计逻辑和参数选择方向。只要链路稳定跑起来了,后面的路就好走多了。