
最近在整理一个嵌入式方向的实践项目编号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.5VCAN_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标准库配置里同步段固定为1TqBS1对应传播段加相位缓冲段1BS2对应相位缓冲段2。Tq是最小时间单位一个位时间由多少个Tq组成直接决定总线上的比特率。波特率计算公式是波特率 APB1时钟 /预分频 ×1 BS1 BS2。以STM32F103系统时钟72MHz、APB1总线时钟36MHz为例我实际配置目标是500kbps预分频 4BS1 13tqBS2 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就立刻退出发送转为接收。这个过程中高优先级报文完全不受影响低优先级报文自己退避。仲裁依据是帧IDID数值越小显性位越早出现优先级越高。所以设计双结点控制的报文ID时要考虑优先级。比如控制指令用ID0x11状态回传用ID0x12如果两个节点同时发送控制指令会赢得仲裁。如果状态帧需要更高优先级就把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模块。接线如下结点APA12CAN_TX接到模块TXDPA11CAN_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但如果你在代码里把系统时钟配置成了72MHzAPB1就是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、诊断协议这些方向扩展基础就已经打牢了。