3、通信协议栈解析(上):CAN协议栈详解——物理层、数据链路层、应用层(CANopen)的软件实现

各位同学,咱们今天来啃一块硬骨头——CAN协议栈。说实话,很多工程师做了好几年嵌入式,对CAN的理解还停留在“发个报文、收个报文”的层面。但真正遇到总线竞争、错误帧、同步问题的时候,就抓瞎了。

我个人习惯是,不管用哪家MCU的CAN控制器,先把协议栈的物理层、数据链路层、应用层这三层拆开吃透。你想想看,芯片设计的时候,这三层其实是硬件、固件、软件的分界线。搞懂了,你写驱动、调bug、甚至选型芯片,心里都有底。

3.1 物理层:从差分信号到CAN收发器

CAN的物理层,说白了就是一对差分线——CAN_H和CAN_L。为什么用差分?抗干扰啊。我在一个电机驱动项目里遇到过,单端信号在强电磁环境下根本没法看,换成CAN差分,稳如老狗。

物理层的核心器件是CAN收发器。MCU里的CAN控制器输出的是TTL电平的TX和RX,收发器把它转成差分信号。常见的型号有TJA1050、SN65HVD230这些。

关键参数:

  • 显性电平(Dominant):CAN_H ≈ 3.5V,CAN_L ≈ 1.5V,逻辑0
  • 隐性电平(Recessive):CAN_H ≈ 2.5V,CAN_L ≈ 2.5V,逻辑1
  • 总线终端电阻:120Ω,两端各一个,用于阻抗匹配

嗯,这里要注意:终端电阻不是随便焊的。我见过有人为了省成本,只在一端焊了120Ω,结果总线反射严重,长距离通信丢包率飙升。后来我建议他老老实实两端各焊一个,问题就解决了。

软件层面,物理层其实没什么好写的。但你要知道,CAN控制器的采样点位置、位时序(Bit Timing)的配置,直接决定了物理层的通信质量。比如,采样点设在75%左右比较稳妥,太靠前容易采到信号跳变沿,太靠后又可能错过有效数据。

3.2 数据链路层:帧结构、仲裁与错误处理

数据链路层是CAN协议的精髓。我刚开始学CAN的时候,觉得帧结构好复杂,后来发现,其实就几种帧:数据帧、远程帧、错误帧、过载帧。最常用的是数据帧。

标准CAN数据帧长这样:

SOF + 11位ID + RTR + IDE + r0 + 4位DLC + 0~8字节数据 + 15位CRC + CRC分隔符 + ACK + ACK分隔符 + EOF

为什么CAN能实现多主通信?靠的是非破坏性位仲裁。说白了,就是多个节点同时发数据时,ID小的(显性位多)优先获得总线控制权。我做过一个测试,12个节点同时抢总线,仲裁机制从来没出过错——前提是ID分配要合理。

避坑指南:我曾经在一个项目中,把两个节点的ID设成了相同的值。结果总线上一旦同时发送,就出现位错误,然后两个节点都进入错误被动状态。后来我强制要求:同一总线上,每个节点的CAN ID必须唯一。这是铁律。

错误处理这块,CAN控制器有发送错误计数器和接收错误计数器。当错误计数超过255,节点就进入总线关闭状态,彻底离线。我建议你在驱动层加一个错误状态监控,一旦发现节点进入被动或关闭状态,及时复位或切换冗余通道。

数据链路层的软件实现,其实就是配置CAN控制器的寄存器。以STM32的bxCAN为例:

// 初始化CAN控制器
CAN_InitTypeDef CAN_InitStruct;
CAN_InitStruct.CAN_TTCM = DISABLE;      // 时间触发模式
CAN_InitStruct.CAN_ABOM = ENABLE;       // 自动离线管理
CAN_InitStruct.CAN_AWUM = ENABLE;       // 自动唤醒
CAN_InitStruct.CAN_NART = DISABLE;      // 自动重传
CAN_InitStruct.CAN_RFLM = DISABLE;      // 接收FIFO锁定
CAN_InitStruct.CAN_TXFP = DISABLE;      // 发送优先级由ID决定
CAN_InitStruct.CAN_Mode = CAN_Mode_Normal;
CAN_InitStruct.CAN_SJW = CAN_SJW_1tq;
CAN_InitStruct.CAN_BS1 = CAN_BS1_8tq;
CAN_InitStruct.CAN_BS2 = CAN_BS2_7tq;
CAN_InitStruct.CAN_Prescaler = 6;       // 假设APB1=36MHz,得到500kbps
CAN_Init(&CAN1, &CAN_InitStruct);

这段配置里,BS1和BS2决定了采样点位置。我习惯把BS1设大一点,BS2设小一点,这样采样点靠后,抗干扰能力更强。

3.3 应用层:CANopen协议栈的软件实现

CANopen是基于CAN的高层协议。说白了,它定义了一套标准化的对象字典和通信机制,让不同厂家的设备能互相理解。你想想看,如果没有CANopen,每个设备都得自己定义报文格式,那系统集成就是一场噩梦。

CANopen的核心概念有三个:

  • 对象字典(OD): 每个设备都有一个索引表,比如0x6000+是数字量输入,0x6200+是数字量输出
  • PDO(过程数据对象): 用于实时传输,比如传感器值、控制指令,优先级高
  • SDO(服务数据对象): 用于配置参数,比如修改PID系数,可靠性高但速度慢

我在一个工业机器人项目中,用CANopen管理6个伺服驱动器。每个驱动器分配一个节点ID,PDO映射了位置、速度、电流三个关键参数。SDO用来在启动时下发运动曲线参数。整个系统跑下来,总线负载率控制在30%以内,非常稳定。

软件实现上,CANopen协议栈通常以源码形式提供。我推荐用CANopenNode这个开源栈,轻量、稳定。核心代码就几个文件:

// CANopen对象字典条目示例
const OD_entry_t OD_entries[] = {
    // 索引0x1000:设备类型
    { 0x1000, 0x00, OD_DATA_TYPE_UINT32, &device_type, NULL },
    // 索引0x1017:心跳生产者时间
    { 0x1017, 0x00, OD_DATA_TYPE_UINT16, &heartbeat_time, NULL },
    // 索引0x6000:数字量输入
    { 0x6000, 0x01, OD_DATA_TYPE_UINT8, &digital_input_1, NULL },
    // ... 更多条目
};

嗯,这里要注意:对象字典的索引和子索引必须严格遵循CiA 301标准。我见过有人把0x6000的用途改成了模拟量输出,结果跟其他设备对接时,数据全乱了。标准就是标准,别乱改。

重要提醒:CANopen的PDO映射需要小心配置。每个PDO最多映射8字节数据,而且映射顺序必须与对端设备一致。我曾经因为PDO映射顺序搞反了,导致位置和速度数据互换,电机直接飞车。从那以后,我每次配置PDO映射,都会用CAN分析仪抓包确认一遍。

最后说说心跳机制。CANopen要求每个节点定期发送心跳报文(0x700 + 节点ID)。主站通过心跳超时检测节点是否离线。我建议心跳时间设为100ms~1000ms之间,太短了增加总线负载,太长了故障响应慢。

好了,这一章的内容就到这里。CAN协议栈的三层结构——物理层、数据链路层、应用层,每一层都有它的设计哲学和实现细节。下一章我们继续聊CAN协议栈的下半部分,重点讲CAN FD和实际调试技巧。

本章要点回顾:

  • 物理层:差分信号、终端电阻、位时序配置
  • 数据链路层:帧结构、非破坏性仲裁、错误处理机制
  • 应用层:CANopen对象字典、PDO/SDO、心跳机制
  • 避坑:ID唯一性、PDO映射顺序、终端电阻不可省