最近在整理一个嵌入式方向的实践项目,编号P3,内容是CAN双结点控制。说白了就是两块STM32板子通过CAN总线互通数据,一块板子发出控制指令,另一块板子收到后执行动作,再把执行结果回传,形成完整的“发指令—收指令—回报状态”闭环。这个项目看起来结构简单,但它几乎把CAN总线的核心知识点全部串起来了:差分物理层、位时序与波特率、总线仲裁、过滤器、错误处理。我把整个项目的设计过程、代码细节和联调中踩过的坑完整记录下来。
这个项目比较适合三类人参考:刚写完串口收发、想往现场总线方向迈一步的嵌入式入门者;需要在校赛或课程设计里快速搭建CAN通信demo的学生;以及第一次接触CAN总线调试、正在跟错误帧较劲的工程师。我尽量把原理讲清楚,代码也给全,读完你应该可以自己复刻出一套双结点通信。
1. 项目整体设计与思路拆解
1.1 双结点控制的核心需求拆解
在项目最开始,先别急着写代码,必须把“双结点控制”到底要做什么拆清楚。通常有两种玩法:一种是单向控制,结点A周期性发送数据帧,结点B接收后判断,控制LED、电机、继电器这类执行件;另一种是双向握手,结点A发控制指令,结点B收到后回传一个状态帧,A根据状态帧确认执行结果。
P3的完整版本一般要求做成双向握手,因为多了一个“控制”和“反馈”,更贴近实际现场总线的应用方式。我在这个项目里用一个LED模拟执行对象,控制字段0x01表示点亮,0x00表示熄灭;状态帧里0xAA表示成功,0x55表示失败。数据字段虽然短,但整个流程涉及CAN发送、接收中断、滤波器、状态机,每一环都不能出错。
这种一问一答模型在实际系统中非常常见,比如BMS里充电桩与电池管理单元的握手,电梯控制系统里轿厢板与召唤板的通信。P3项目就是把这个模型透明化地搬到了桌面上,让你亲眼看到指令怎么从一个节点跑到另一个节点。
1.2 为什么选用CAN而不是UART或RS485
很多学生会问:双结点通信我直接用串口不就行了?但串口只有1对1,如果要扩展成多个节点,主从结构非常麻烦,而且没有优先级概念。RS485虽然支持多节点,本质还是主从轮询,实时性和容错能力都不如CAN。CAN总线的设计目标就是为汽车这种电磁干扰严重、连接节点多、实时性要求高的环境服务,特点非常明显:
- 多主节点,任意节点在总线空闲时都可以发起发送;
- 报文ID天然就是优先级,仲裁时低数字ID优先;
- 双线差分传输,抗干扰能力强;
- 内置错误检测、错误计数、故障隔离,出错的节点会自动离线,不影响整个总线。
对于双结点控制这类项目,用CAN是在正式接触工业现场总线。这个选型不是炫技,是为了把“为什么CAN在汽车、工业领域一直被沿用”这件事讲明白。后面你去接触CANopen、J1939、UDS这些协议,底层都是这套东西。
1.3 方案选型:STM32F103 + TJA1050的组合逻辑
MCU选择STM32F103C8T6,原因是它的bxCAN外设基本不用外部扩展,门槛低、资料多、出问题也容易找到人问。收发器选择TJA1050,这是经典的CAN高速收发器,最高支持1Mbps速率,逻辑输入与3.3V兼容,跟STM32配对非常成熟。要清楚一点:STM32内部集成的CAN控制器只负责协议层,收发器负责把逻辑电平转换成CAN_H和CAN_L上的差分信号,两者必须配合使用。
购买模块时,建议别买那种不带终端电阻的散装板,直接买板载120Ω且能用跳线控制的模块,调试会省事很多。我实际用的就是两块TJA1050模块,板载终端电阻通过跳线帽选择,非常方便在“带电阻”和“不带电阻”之间切换验证。
2. CAN总线关键机制拆解
2.1 物理层:差分信号与终端电阻
CAN物理层用CAN_H和CAN_L两根线传输。显性位对应逻辑0,此时驱动器主动把CAN_H拉高到约3.5V,CAN_L拉低到1.5V,差分电压约2V;隐性位对应逻辑1,此时总线释放,两根线都在2.5V附近,差分电压接近0V。显性位和隐性位在总线上相遇时,显性位会覆盖隐性位,这也是后面讲仲裁机制的基础。
这种差分设计天然抑制共模干扰,两根线受到的电磁干扰在接收端相减后基本被抵消,这是CAN能在车内恶劣电气环境中稳定工作的核心原因。实际观察中,如果发现CAN_H和CAN_L电压差始终为0,或者两根线波形完全相同,那多半是接线错误,把同一根信号接到了CAN_H和CAN_L上。
传输线两端必须接120Ω电阻,目的是匹配线路特性阻抗,防止信号反射。两个结点形成最短总线时,两端各放一个120Ω电阻,从任意一个结点往里看,等效阻抗是60Ω,这正是CAN收发器输出驱动需要的负载。这个电阻经常被新手忽略,结果就是波形畸变、数据错误帧频发。反过来,如果抱着“电阻越多越好”的想法,在多个结点上接了三个、四个终端电阻,总线负载过低,信号幅度同样不足。
值得一提的是,现在不少工业板会把CAN和RS485设计成复用同一对差分接口,通过收发器使能脚切换两种模式。这种电路在项目初期不用碰,但知道有这回事,后面遇到硬件设计时不会懵。
2.2 位时序:波特率、采样点与SJW的计算
一个CAN位时间由四部分组成:同步段、传播段、相位缓冲段1、相位缓冲段2。STM32标准库配置里,同步段固定为1Tq,BS1对应传播段加相位缓冲段1,BS2对应相位缓冲段2。Tq是最小时间单位,一个位时间由多少个Tq组成,直接决定总线上的比特率。
波特率计算公式是:波特率 = APB1时钟 /(预分频 ×(1 + BS1 + BS2))。
以STM32F103系统时钟72MHz、APB1总线时钟36MHz为例,我实际配置目标是500kbps:
- 预分频 = 4
- BS1 = 13tq
- BS2 = 4tq
- 位时间Tq总数 = 1 + 13 + 4 = 18
- 波特率 = 36MHz / (4 × 18) = 500kHz
采样点就是节点在每个位时间里真正判断电平高低的时刻,位置 = (1 + BS1) / (1 + BS1 + BS2) = 14 / 18 = 77.8%。这个值偏向于位时间的后半段,符合采样点尽量靠后的习惯。为什么采样点不能太靠前?因为总线信号经过传输线延迟、收发器延迟后,真正的电平稳定需要一定时间,采样点太靠前容易采到边沿抖动区域。但也不能无限后移,否则留不下足够的相位缓冲去吸收时钟漂移。高速CAN一般推荐采样点75%到90%,CAN FD甚至推荐87.5%附近。
SJW是重新同步时的最大补偿范围,用来容忍各节点晶振频率不一致带来的相位积累误差。一般场景取1Tq就够,如果总线中有多个节点,或者用了误差较大的内部时钟,可以适当增加到2Tq或3Tq。注意SJW不是越大越好,它表示重同步时最多能跳变多少个Tq,设置过大会向错误方向过度补偿。
这里必须提醒一句,换用不同的APB1时钟时,同样一组BS1、BS2、预分频值会产生完全不同的波特率。很多人从网上抄来一套配置发现通信失败,一查时钟树,APB1根本不是36MHz。在做任何配置前,先仔细看RCC配置函数里APB1分频是多少。
2.3 总线仲裁与应答机制
汽车上几十个ECU共用一对总线,如果两个节点同时开始发送,怎么决定谁先用总线?CAN的处理方式是逐位仲裁。发送节点一边发一边回读总线电平,当发现自己发送的是隐性位(1),而总线上是显性位(0),就立刻退出发送转为接收。这个过程中高优先级报文完全不受影响,低优先级报文自己退避。仲裁依据是帧ID,ID数值越小,显性位越早出现,优先级越高。
所以设计双结点控制的报文ID时要考虑优先级。比如控制指令用ID=0x11,状态回传用ID=0x12,如果两个节点同时发送,控制指令会赢得仲裁。如果状态帧需要更高优先级,就把ID设为0x08这样的较小值。这个细节在只有两个节点时影响不大,但一旦扩展到多节点,ID分配混乱会导致关键指令被阻塞。
应答机制同样重要。一个节点发送数据帧,帧尾的ACK槽会被至少一个接收节点拉成显性位,表示“我收到了”。如果总线上挂着的所有节点都没有成功接收,发送方就认为无应答,触发错误处理并重传。这个机制解释了双结点调试中的经典问题:只有一个通电节点时,发送节点因为没有第二个节点响应,误帧和重传会不断发生,看起来就像“发送失败”。这时候最好接上另一个结点,或者暂时开启回环模式做验证。
2.4 CAN与CAN FD的差异
很多人在搜索CAN资料时会看到CAN FD。CAN FD是在CAN 2.0基础上发展的扩展协议,保留了经典CAN的物理层和仲裁机制,但在数据段支持最高64字节有效载荷,并且数据段可以采用更高的比特率,比如2Mbps、5Mbps。它和经典CAN的区别主要体现在帧格式上,经典CAN帧和CAN FD帧不能简单混用,同一总线上要么都用经典CAN,要么都用CAN FD。
STM32F103的bxCAN不支持CAN FD,做P3项目时不需要考虑。但了解这个区别对后面接触汽车电子有好处,因为新车型的网关、域控制器基本都在往CAN FD甚至车载以太网迁移。如果项目要支持CAN FD,通常需要选择STM32G4、STM32H7这类新一代MCU,外围收发器也要换成支持CAN FD的型号,比如TJA1044。
3. 双结点通信的完整实现
3.1 硬件连接与终端电阻布置
我用的是两块STM32F103C8T6最小系统板加两块TJA1050模块。接线如下:
结点A:
- PA12(CAN_TX)接到模块TXD
- PA11(CAN_RX)接到模块RXD
- 模块CANH接到总线CANH
- 模块CANL接到总线CANL
结点B的接法完全相同,两边CANH对CANH、CANL对CANL,千万注意不要接反。TJA1050需要5V电源,但它的TXD和RXD逻辑电平与3.3V兼容,可以直接接STM32的引脚。如果换成MCP2551这类老收发器,逻辑电平兼容性就需要额外确认。
终端电阻我选择两个模块都打开板载120Ω。因为整个总线只有两个节点,两个节点的物理位置就是总线两端,各一个120Ω正好匹配。如果以后扩展到三个节点,中间节点的120Ω必须关掉。
3.2 结点A发送端的软件实现
结点A要做两件事:通过USART接收上位机命令,把命令解析成CAN帧从PA12发出;同时接收结点B回传的状态帧,从串口打印出来。完整的USART中断处理这里不展开,重点看CAN初始化。
void CAN_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; CAN_InitTypeDef CAN_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); // PA11: CAN_RX,配置为上拉输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA12: CAN_TX,配置为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); CAN_DeInit(CAN1); CAN_StructInit(&CAN_InitStructure); CAN_InitStructure.CAN_TTCM = DISABLE; CAN_InitStructure.CAN_ABOM = ENABLE; // 自动离线恢复 CAN_InitStructure.CAN_AWUM = ENABLE; CAN_InitStructure.CAN_NART = DISABLE; // 自动重传 CAN_InitStructure.CAN_RFLM = DISABLE; // FIFO不锁定 CAN_InitStructure.CAN_TXFP = DISABLE; // 按优先级发送 CAN_InitStructure.CAN_Mode = CAN_Mode_Normal; CAN_InitStructure.CAN_SJW = CAN_SJW_1tq; CAN_InitStructure.CAN_BS1 = CAN_BS1_13tq; CAN_InitStructure.CAN_BS2 = CAN_BS2_4tq; CAN_InitStructure.CAN_Prescaler = 4; CAN_Init(CAN1, &CAN_InitStructure); }这里几个关键点解释一下。波特率就是前面计算的500kbps,如果系统时钟不是72MHz,或者APB1分频不同,一定要按公式重新算。CAN_ABOM置为ENABLE,可以让节点在检测到连续错误进入Bus Off后自动恢复,这对联调阶段特别有用,否则节点掉线一次,之后再也不发数据了。
发送一帧数据的函数:
uint8_t CAN_SendFrame(uint32_t id, uint8_t *data, uint8_t len) { CanTxMsg TxMessage; uint16_t timeout = 0; TxMessage.StdId = id; TxMessage.IDE = CAN_Id_Standard; TxMessage.RTR = CAN_RTR_Data; TxMessage.DLC = len; for (uint8_t i = 0; i < len; i++) { TxMessage.Data[i] = data[i]; } uint8_t mailbox = CAN_Transmit(CAN1, &TxMessage); if (mailbox == CAN_TxStatus_NoMailBox) { return 0; } while (CAN_TransmitStatus(CAN1, mailbox) != CAN_TxStatus_Ok && timeout < 200) { timeout++; } return (timeout < 200) ? 1 : 0; }这个发送函数刻意做了简单的超时保护,避免“一直在等待空邮箱”导致程序卡死。实际发送失败的原因通常是总线无应答或总线忙,返回值可以帮助定位问题。主循环收到串口指令后,把指令字节封装成CAN帧发送,同时挂起接收中断等待结点B回包。
3.3 结点B接收端与状态回传
结点B的CAN初始化与A完全一致,关键在接收中断。开启接收中断的代码:
CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE); NVIC_SetPriority(CAN1_RX0_IRQn, 0); NVIC_EnableIRQ(CAN1_RX0_IRQn);接收中断处理函数:
void CAN1_RX0_IRQHandler(void) { CanRxMsg RxMessage; uint8_t status = 0x55; CAN_Receive(CAN1, CAN_FIFO0, &RxMessage); if (RxMessage.StdId == 0x11) { if (RxMessage.Data[0] == 0x01) { GPIO_SetBits(GPIOC, GPIO_Pin_13); status = 0xAA; } else if (RxMessage.Data[0] == 0x00) { GPIO_ResetBits(GPIOC, GPIO_Pin_13); status = 0x55; } CAN_SendFrame(0x12, &status, 1); } }这里演示了一个简化场景:结点B收到ID为0x11的控制帧,根据Data[0]的值点亮或熄灭板载LED,并回传一个字节的状态帧。一个更严谨的工程写法是把接收到的报文放进环形队列,在主循环里解析并构造响应帧,避免在中断里做耗时操作。但P3项目数据量小,直接在中断里处理也可以,这也是简单demo和正式产品之间的典型差别。
有一点要提醒:使用标准库时,CAN_Receive函数会处理FIFO释放,中断标志FMP0会自动降低,所以不需要像串口那样手动清标志。如果使用HAL库,接收回调函数是CAN_RxFIFO0MsgPendingCallback,逻辑一致。
3.4 滤波器配置要点
STM32的bxCAN有多个滤波器组,默认情况下如果不配置滤波器,所有报文都会进入FIFO0。双结点控制里,最常见的问题是结点B收到了结点A的帧,但也可能会收到总线上其他无关帧。更规范的做法是配置标识符掩码模式,让结点B只放行ID等于0x11的帧。
CAN_FilterInitTypeDef CAN_FilterInitStructure; CAN_FilterInitStructure.CAN_FilterNumber = 0; CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterIdHigh = (((uint32_t)0x11 << 21) & 0xFFFF0000) >> 16; CAN_FilterInitStructure.CAN_FilterIdLow = ((uint32_t)0x11 << 21) & 0x0000FFFF; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0xFFFF; CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0xFFFF; CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_Init(CAN1, &CAN_InitStructure);标准帧ID占11位,在32位滤波寄存器中存放在ID[28:18],所以左移21位后,高低16位分别赋值。掩码全为1表示必须完全匹配,如果只想匹配ID的高几位,把对应位置0即可。这个寄存器位段经常让初学者头疼,但只要对着参考手册的寄存器图看一遍就通透了。
4. 联调过程与常见问题排查
4.1 用示波器观察总线波形
双结点联调的第一步,不应该急着写代码,而是先把两个节点的CAN_H、CAN_L接到示波器上,看有没有通信波形。正常帧发出时,应该能看到一串幅值跳变的差分脉冲,CAN_H在隐性2.5V和显性3.5V之间跳变,CAN_L在2.5V和1.5V之间跳变。如果波形幅度很低,形似正弦波,多半是终端电阻配置不对;如果两根线波形完全一样,说明把同一根信号同时接到了CAN_H和CAN_L上。
有逻辑分析仪的话更简单,很多工具带CAN解码插件,能直接解析出帧ID和数据,比肉眼分辨波形快得多。记得把上位机工具里的波特率设置正确,否则解码结果完全不可信。
4.2 频繁Bus Off与自动离线恢复
联调中遇到最多的现象:开始还能通信,运行几秒后就彻底没反应。查看寄存器发现CAN处于Bus Off状态。原因多半是错误帧过多,错误计数达到256。触发条件包括波特率不一致、总线缺少终端电阻、收发器供电异常、接线接触不良。
STM32的错误状态寄存器是CAN_ESR,代码里可以直接读取:
uint32_t esr = CAN1->ESR; if (esr & CAN_ESR_BOFF) { // 总线关闭 }排查时先固定波特率一致,然后断开其他干扰源,用示波器看波形质量。开启了CAN_ABOM之后,节点会自动退出Bus Off状态,但如果在硬件层面没有修复,它会进入“离线-恢复-再离线”的循环。这时候不要只盯着软件,把万用表拿起来量一下供电和线缆通断。
4.3 两边配置完全一致却通信失败
如果波特率、ID、滤波器看起来都一致,但就是收不到数据,先检查时钟源。两个节点如果分别使用外部晶振和内部RC,实际波特率会有百分之几的偏差。CAN要求总线所有节点的位时间误差在一定范围内,短距离低速时问题不大,一旦波特率上到500k以上,偏差就可能超过容忍范围。解决方法:两个节点都使用外部晶振,或者增大SJW值,同时把采样点适当后移。
另一个很容易忽略的点是APB1时钟频率。很多F103板默认外部晶振是8MHz,但如果你在代码里把系统时钟配置成了72MHz,APB1就是36MHz;如果外部晶振换成了12MHz,时钟树就完全不一样了。所以我在项目中习惯在每个节点的初始化函数里加一段串口打印时钟频率的调试代码,让“实际运行的时钟”暴露出来,而不是靠猜。
4.4 借助USB转CAN工具与上位机调试
实际工程中,真正调试CAN设备很少只靠两块板子互发,一般会接一个USB转CAN工具,把总线数据直接显示在电脑上,或者用电脑模拟一个节点参与通信。市面上的工具不少,CANable、创芯、致远这些我都用过。接进总线时要注意它的终端电阻设置,有些工具自带120Ω跳线,如果总线上已经有正确阻值,记得关掉工具的电阻,避免多一个终端负载。
用上位机调试时,串口助手打不开COM口是常见问题。原因无非三种:USB驱动没装好、COM口号被其他程序占用、USB线只有充电没有数据。别急着重装软件,先换一根确认能传数据的USB线,再到设备管理器里看端口是否识别到。识别到了但打开失败,就把占用端口的进程找出来关掉。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 两个节点都能发送但互收不到 | 滤波器配置错误、ID不匹配 | 先关闭滤波器,再核对ID和IDE |
| 一上电就出现Bus Off | 波特率不一致或无终端电阻 | 用示波器看波形,检查终端电阻 |
| 发送函数一直超时 | 总线上无接收节点或无法应答 | 另一个节点是否上电,是否在回环模式 |
| 波形正常但报文解码失败 | 逻辑分析仪波特率设置错误 | 核对采样点和波特率 |
| 长时间运行后偶发错误帧 | 时钟漂移、干扰、线缆过长 | 增大SJW,检查双绞屏蔽,缩短线长 |
| CAN_H和CAN_L反接 | 完全无法通信 | 用万用表测两线电压关系 |
| 上位机打不开COM口 | 驱动缺失或端口被占用 | 换USB线,检查设备管理器 |
| CAN初始化后一直报错 | 收发器供电异常或RS脚配置错误 | 检查模块供电和模式引脚 |
这张表是我实际调试时最常用的定位思路。遇到问题不要乱改代码,先确认物理层有没有数据,再用排除法缩小范围。
做这个P3项目的过程中,我自己最深的感受是:CAN、串口这类通信协议,单纯看文档和动手调通完全是两码事。看一百遍位时序图,不如在示波器上亲眼看到一次波形畸变印象深。建议拿到项目后先把硬件连接和终端电阻确认好,再把波特率计算过程写下来,最后才打开代码编辑器。很多时候问题不在代码,而在你对总线的理解。
实际测试时,我用上位机串口助手向结点A发送0x01,结点B的LED点亮,并回传0xAA,整个过程一气呵成。后面我还把结点B换成CAN转USB工具,让PC直接与结点A通信,效果也一样。可以说,双结点控制一旦跑通,再往多节点、CAN FD、诊断协议这些方向扩展,基础就已经打牢了。