上一篇我们把控制模块拆完了。control模块算出方向盘角度、油门开度、刹车压力,打包成ControlCommand发出去。然后呢?然后就是一个巨大的问号——你的车又不是服务器,它听不懂protobuf。
车听懂的是CAN。0x64这串十六进制丢上线缆,方向盘才转。这中间的翻译官,就是canbus模块。

四层架构,各管各的事
canbus模块的源码分布在两个位置:modules/canbus/是应用层,modules/drivers/canbus/是通信层和驱动层。我从上往下捋。
CanbusComponent是顶层的CyberRT组件,继承自TimerComponent。它干两件事:订阅control模块的ControlCommand,发布底盘状态Chassis。中间的翻译全靠它协调。启动的时候,它读配置文件里的车型品牌,让VehicleFactory造出对应的工厂,再由工厂造出VehicleController和MessageManager,最后拿CanClientFactory造出CanClient。所有零件装配完毕,canbus才算活了。
下一层是车型适配层,藏在modules/canbus_vehicle/里。三个核心类:VehicleController管控制逻辑,MessageManager管消息解析,AbstractVehicleFactory把它们组装起来。Lincoln有LincolnController和LincolnMessageManager,Lexus有自己的一套,DevKit又有另一套。以Lincoln为例,它的目录下光是protocol/子目录就有十几个协议文件:brake_60.cc管刹车控制(发送ID 0x60),brake_61.cc管刹车反馈(接收ID 0x61),throttle_62.cc和throttle_63.cc管油门,steering_64.cc和steering_65.cc管转向,gear_66.cc和gear_67.cc管档位,wheelspeed_6a.cc读轮速,gyro_6c.cc读陀螺仪。控制报文和反馈报文成对出现,ID挨着递增,干净利落。
再往下是通信层,在modules/drivers/canbus/can_comm/。CanSender跑独立线程往总线上写帧,CanReceiver跑另一个独立线程从总线上读帧。两个线程各干各的,互不阻塞。ProtocolData是协议数据的基类,所有车型的具体信号都从它派生。基类定义了UpdateData()(编码信号到raw bytes)和Parse()(从raw bytes解码信号)两个核心方法。
最底层是驱动层,modules/drivers/canbus/can_client/。CanClient是纯虚基类,定义了Init()、Send()、Receive()三个核心接口。具体实现有四种:EsdCanClient对应德国ESD公司的NTCAN库,走PCIe接口,支持PCI和USB两种卡型,初始化时调canOpen()打开设备、canSetBaudrate()设波特率,默认500kbps,需要编译时启用USE_ESD_CAN宏。SocketCanClientRaw走Linux内核的SocketCAN,开源方案,不需要额外硬件驱动,开发者最常用。HermesCanClient基于Zynq FPGA平台的BCAN协议,支持最多8个端口,适合高带宽多通道场景。还有个FakeCanClient给仿真用,不发真帧,只模拟收发。这四种实现通过CanClientFactory注册,配置文件里写brand: SOCKET_CAN_RAW就自动创建对应的驱动实例。

CAN报文:车的母语
Apollo跟车说话,说的是CAN 2.0协议。一帧标准CAN报文的格式是这样的:11位的CAN ID决定消息优先级——ID越小优先级越高,总线仲裁时先占线。4位的DLC指示数据域长度,0到8字节。数据域里塞的就是信号,一个转向角度信号可能占16位,一个校验和占8位,挤在同一个8字节里。CRC校验15位,ACK应答2位,加上帧头帧尾的开销,一帧标准报文在总线上占大约108个bit位。500kbps的波特率下,一帧来回不到0.3毫秒,够快了。

这些信号怎么定义的?靠DBC文件。DBC(Database CAN)是Vector公司搞的一套标准格式,用来描述CAN总线上的消息和信号。Apollo提供了一个工具链——把DBC文件丢进modules/tools/gen_vehicle_protocol/,配好yml文件(填dbc文件名、车型名、发送方名称),跑python gen.py ge3_conf.yml,自动生成整套车型适配代码模板。GE3的例子,脚本跑完终端会显示生成了5个控制协议和11个反馈协议,输出目录里有proto/和vehicle/两个文件夹,直接拷进Apollo源码树就能用。
DBC里定义一条转向命令大概长这样:SG_ SteeringAngle : 7|16@1- (0.1,-500) [0|0] "deg"。翻译一下:起始位7,长度16位,Intel字节序(@1),有符号(-),缩放因子0.1,偏移-500,单位度。这意味着原始值0对应-500度,原始值10000对应500度,0.1度的分辨率。Apollo生成的代码会把这个信号自动映射到ProtocolData子类的Parse()和UpdateData()方法里,开发者不需要手动计算位偏移和缩放。

工厂模式:换车不换代码
Apollo支持的车型不少:Lincoln MKZ、Lexus、GE3、WEY、Transit、Gem、DevKit、Neolix、Zhongyun……每家的CAN协议都不一样。怎么解耦?
AbstractVehicleFactory是抽象基类,定义了CreateVehicleController()和CreateMessageManager()两个工厂方法。LincolnVehicleFactory造LincolnController和LincolnMessageManager,LexusVehicleFactory造自己那套。上层代码只认抽象接口,不关心底下是哪辆车。
VehicleFactory是更高一层的工厂——工厂的工厂。它的RegisterVehicleFactory()方法把所有品牌注册进一个unordered_map<CANCardBrand, function<CanClient*()>>:
Register(VehicleParameter::LINCOLN_MKZ, []() -> AbstractVehicleFactory* {
return new LincolnVehicleFactory();
});
配置文件里写brand: LINCOLN_MKZ,运行时动态加载对应的工厂类。新增车型只需要三步:实现VehicleController子类,实现MessageManager子类,在VehicleFactory里注册。核心代码一行不用动。

VehicleController用模板方法模式定义了控制流程框架。基类的Update()方法里:
if (driving_mode() == Chassis::COMPLETE_AUTO_DRIVE) {
Gear(command.gear_location());
Throttle(command.throttle());
Brake(command.brake());
Steer(command.steering_target());
}
Gear、Throttle、Brake、Steer全是纯虚函数,子类必须实现。DevkitController的Steer()往0x102报文里写角度,LincolnController的Steer()往0x64里写,协议完全不同,但上层调用的都是同一个接口。纯虚函数列表里还有几个不常被提到的方法:EnableAutoMode()进自动驾驶,DisableAutoMode()退出,Emergency()紧急停车,VerifyID()验证VIN码。不同车型进入自动驾驶的手续不一样,有的发一条命令就行,有的要握手三次再发校验和,全靠子类各自实现。
控制命令:从算法到执行器
整条链路串起来是这样的:control模块算出ControlCommand,通过Cyber RT的channel发给canbus。CanbusComponent的OnControlCommand()回调收到后,调vehicle_controller_->Update(command)。VehicleController根据当前驾驶模式,把命令分发到Gear、Throttle、Brake、Steer各个方法。这些方法把信号写进ProtocolData,CanSender线程以固定周期(通常10ms)把所有待发送的ProtocolData打包成CAN帧,通过CanClient::Send()丢到物理总线上。ECU收到帧,执行动作。

驾驶模式的切换也在这条链路上。ControlCommand里有个pad_msg字段,action == START进完全自动驾驶模式,action == RESET退回手动模式。Apollo定义了四种驾驶模式:COMPLETE_AUTO_DRIVE、AUTO_STEER_ONLY、AUTO_SPEED_ONLY、COMPLETE_MANUAL。只有COMPLETE_AUTO_DRIVE下才同时控制转向和速度。AUTO_STEER_ONLY只控方向盘,AUTO_SPEED_ONLY只控油门刹车——这个设计是为了应对部分车型只开放了部分线控权限的场景。
状态反馈:车说了什么
控制是单向的,但开车不能蒙眼。车辆的速度、方向盘转角、档位、刹车状态,全靠CAN回传。
CanReceiver线程不停从总线上读帧,拿到message_id和raw data,调MessageManager::Parse()。Parse()根据message_id查到对应的ProtocolData——MessageManager内部维护了一个unordered_map<uint32_t, ProtocolData*>,ID到协议数据的映射。找到ProtocolData后调它的Parse()方法解码信号。解码结果存进ChassisDetail结构体。CanbusComponent再从ChassisDetail里提取关键信息,组装成Chassis proto发布出去。control模块订阅Chassis,才知道车当前到底跑多快、轮子朝哪偏。
这整条反馈链路的安全保障靠两件事:CanReceiver的读超时检测,和VehicleController里的SecurityDogThreadFunc()。后者是个看门狗线程,一旦检测到底盘报错或者长时间无响应,直接调Emergency()把车刹停。ChassisDetail里的错误码会被映射到Chassis的ErrorCode字段,control模块读到就知道该不该继续发命令了。

跟PX4的对比:CAN vs MAVLink
做飞控的时候,我天天跟MAVLink打交道。PX4飞控用MAVLink协议,走串口或者UDP。消息格式是定长的,一个HEARTBEAT消息6字节,一个ATTITUDE消息28字节,明确定义,跨平台通用。PX4内部用uORB做进程间通信,发布-订阅模式,跟Apollo的Cyber RT channel一个思路。uORB是嵌入式级的,零拷贝、无锁队列,延迟微秒级;Cyber RT channel面向x86,走共享内存和protobuf序列化,延迟毫秒级。两者的取舍跟硬件平台直接相关。
Apollo用CAN,是因为车上跑的就是CAN总线。CAN 2.0标准帧8字节载荷,CAN FD扩展到64字节,速率1到5Mbps。MAVLink走串口或UDP,带宽取决于物理层,921600bps串口或者百兆以太网都行。
本质区别在于拓扑。CAN是多主总线,所有ECU挂同一根双绞线上,靠仲裁机制抢线,优先级高的先发。MAVLink是点对点,飞控和地面站之间一条链路,没有仲裁问题。自动驾驶车上动辄70到100个ECU,总线拓扑天然适配CAN。无人机上传感器少,点对点够了。
还有个细节差异:MAVLink的消息ID是功能语义的,ID 0就是HEARTBEAT,所有飞控一样。CAN的ID是车厂自定义的,0x64在Lincoln上是转向,在GE3上可能完全是另一个意思。这直接导致MAVLink生态的开放性和CAN生态的碎片化。

争论一:CAN带宽到顶了,为什么还在用?
CAN 2.0最大1Mbps,CAN FD到5Mbps。而车载以太网100BASE-T1起步就是100Mbps,1000BASE-T1直接1Gbps。一辆L4自动驾驶车每天产生的数据量超过4TB,12个摄像头加LiDAR加雷达,光传感器数据就几百Mbps。CAN明显扛不住。
但CAN还在用,原因很现实。一是成本。CAN收发器50到100毫瓦功耗,以太网PHY 300到500毫瓦。CAN双绞线加120欧终端电阻就行,以太网要交换机。一辆车上几十个ECU,每个节点省几块钱,整车成本差出数量级。二是确定性。CAN的优先级仲裁是硬件级的,最高优先级的消息一定能按时发出去,延迟是可预测的。以太网要靠TSN(时间敏感网络)扩展才能达到类似的确定性,复杂度直线上升。三是存量。全球年产上亿辆车,ECU供应链、诊断工具、产线测试全围绕CAN建的,换协议不是发个版本的事。
所以当前的趋势是共存。域控制器之间走以太网骨干网,域内ECU之间继续走CAN。自动驾驶计算平台接摄像头和LiDAR用以太网,控制方向盘和刹车还是CAN。CAN也在进化——CAN FD把载荷从8字节扩到64字节,CAN XL计划到10Mbps加2048字节载荷,试图在以太网和传统CAN之间卡一个中间档位。Apollo的canbus模块只负责CAN这一段,高带宽数据走别的驱动,分工明确。
争论二:VehicleFactory真解耦了吗?
工厂模式在代码层面确实解耦了——LincolnController和DevkitController互不干扰,新增车型不改核心逻辑。但问题出在协议层面。
每家车厂的CAN协议都是私有的。Lincoln的转向控制用0x64,DevKit用0x102。同一条"转向角度"信号,Tesla可能占16位小端,BMW可能占12位大端。有的厂商还给关键控制信号加了加密和校验和,逆向工程难度极高。openpilot项目花了大量精力做DBC逆向,维护了250多个车型的协议文件,靠社区人力硬啃。大众MQB平台甚至用了种子-密钥交换机制保护转向信号,openpilot团队得逆向加密算法才能适配。
Apollo的解法是DBC代码生成。但DBC文件从哪来?要么车厂提供,要么自己逆向。车厂提供的DBC通常只包含基础信号,高级控制协议(比如自动驾驶模式切换、线控转向使能)根本不会公开。所以VehicleFactory解耦的是代码,不是协议。代码层加一辆新车一周搞定,协议层搞清楚怎么跟这辆车的ECU对话,可能要一个月。
更深层的问题是,这种一对一适配的模式本身就不具备规模效应。每加一辆车就要写一套ProtocolData、一套Controller、一套MessageManager。openpilot用DBC文件做运行时解析,不需要为每个车型编译代码,灵活性更高。Apollo选择了编译时绑定,性能好但维护成本大。两种路线各有取舍,没有标准答案。还有一种更激进的思路是AUTOSAR的ADI(Automated Driving Interfaces),试图用ISO 23150标准化自动驾驶传感器接口——标准化传感器容易,标准化车辆控制接口,每家车厂都把线控协议当核心资产,短期内看不到突破的可能。
canbus模块的代码量在Apollo里不算大,但它是软件和硬件的交界。上头全是抽象——proto、channel、算法;下头全是具体——寄存器、位域、时序。VehicleController那几个纯虚函数,Gear、Throttle、Brake、Steer,名字朴素,每一行背后都是某个ECU的协议文档和一整周的调试。
夜雨聆风