STM32双结点CAN控制实战:硬件搭建、位时序计算与Bus Off排查 把两个STM32控制板用CAN总线连起来让其中一个发指令另一个收到后执行控制动作再把状态回传这就是“CAN双结点控制”这个项目最典型的形态。我在开发板和实验室工装上都搭过这套东西表面上只是两根差分线但真正从收发器、120Ω终端电阻、位时序参数到报文过滤和Bus Off恢复每一步都有能让人折腾半天的细节。这篇文章不是帮你无脑复制代码而是把选型逻辑、参数计算过程、软件框架和实测踩坑一起整理出来给准备入门CAN通信或者做双结点联调的人一条能直接落地的路线。1. 为什么双结点控制选CAN而不是串口通信协议选型背后的真实考量1.1 双结点控制这个场景到底在控制什么很多初学者一听“双结点控制”会误以为CAN天生就该用在几十个ECU的大型总线上两个节点随便用串口就行。这种判断忽略了一个关键点双结点控制只是“当前只有两个节点”但项目的环境和目标往往是确定的、实时的控制需求。在我实际布置的项目里双结点控制最常见的形态是这样的节点A带按钮、旋钮或上位机指令解析后生成控制命令节点B负责驱动继电器、电机调速器、加热棒或显示面板执行命令后把电流、温度、运行状态回传。两边之间可能有1米到几十米的线缆现场有电机启停、变频器干扰、继电器火花噪声。这时候如果选UART线短、距离近、无干扰、单对单通信没问题但一旦把线拉到5米以上或者旁边有变频器UART的弱抗干扰能力和无差错反馈机制就会让通信变得非常不稳。所以双结点控制的真正诉求不是“两点通信”本身而是“在恶劣电磁环境下仍然保证实时、确定、可诊断的通信”。CAN就是为此设计的。它天然支持差分信号、带硬件错误检测、有多主仲裁机制这些特性对控制类项目是加分项而不是多余的复杂度。1.2 与UART、RS485、I2C的对比不是越简单越好而是越匹配越好我把几个常见方案放在一起对比方便刚接触的人理解为什么最终会选CAN方案物理层多主能力典型距离抗干扰错误检测节点扩展UART单端/差分无1-2m差无需自加不支持I2C开漏单端半双工时钟同步1m内差ACK扩展有限RS485差分需要主从轮询协议100m较好无需自加支持CAN差分原生多主仲裁根据波特率可达几十米到上千米好CRC15硬件错误帧支持RS485其实也很常用但它的多节点是靠应用层做主从分配来完成的通常是一个主机轮询各个从机。轮询就意味着从机不能主动上报紧急事件只能等主机问到自己。这在一对多的数据采集场景还能忍但在“一个节点发生故障需要立刻通知对端”的控制场景里轮询周期的存在就是延迟风险。CAN的非破坏性仲裁机制则完全不同多个节点同时发报文时ID小的优先发送网络会自动裁决不需要主机分配时间片。双结点场景里这个机制可以合理利用控制命令报文的ID设置得比状态反馈报文低那么即使两边同时发控制命令也一定优先被网络上传输这种确定性对控制项目非常重要。1.3 为什么我只谈“经典CAN”而不是CANFD网上搜索“canfd和can的区别”的人非常多。对于F103这类入门芯片它内置的是bxCAN控制器只支持经典CAN 2.0A/2.0B不支持CANFD。CANFD的主要变化是数据场可以超过8字节最多可达64字节仲裁段和数据段可以分开使用不同波特率CRC校验也更强。双结点控制原型阶段8字节数据场足够承载控制字、设定值、状态字、温度、电流等一系列变量。CANFD带来的大报文能力在车厂前装、固件升级、大数据上传等场景才有明显优势。如果项目合同里没有特别约定使用CANFD我不建议在F103上硬上CANFD选型后果是控制器不支持还得换成带CANFD外设的芯片。经典CAN在协议兼容性、资料丰富度、实测定性上都更成熟。2. 硬件链路MCU、CAN收发器与120Ω终端电阻怎么搭才稳2.1 器件选型F103配TJA1050这套经典组合STM32F103内部已经集成了CAN控制器但它不能直接挂到总线上去。CAN控制器输出的是“发送逻辑电平”需要外部CAN收发器转换成总线上的差分电压。这是新手最容易漏掉的一点。我常用的组合是STM32F103C8T6PA11接CAN_RXPA12接CAN_TX配TJA1050收发器。TJA1050供电电压建议5V和3.3V的MCU相连时TJA1050的TXD、RXD逻辑引脚可以直接兼容3.3V电平但最好还是确认一下具体器件的规格。如果系统整体是3.3V供电可以选SN65HVD230这类3.3V收发器如果希望总线和系统之间做隔离就直接用带隔离的CTM1050模块。这里有一个实际经验双结点联调时很多问题不是CAN控制器的问题而是收发器供电不足。TJA1050的电气特性依赖稳定的5V电源电源纹波大、走线细波形就会圆角严重时根本无法建立正常通信。我给收发器的电源脚旁边放了100nF和10µF两级去耦电容实测波形质量会好很多。2.2 接线与终端电阻两个节点也要在总线两端各放一颗CAN总线接线非常简单所有节点的CAN_H连CAN_HCAN_L连CAN_L然后所有节点的GND连在一起。很多双结点项目先跑通了换一根长线后发现报文时好时坏问题往往出在终端电阻。CAN总线物理层要求总线两端各接一个120Ω终端电阻用来匹配阻抗、减少信号反射。在双结点场景里“两端”就是节点A和节点B所以这两个节点上各接一个120Ω而不是只在其中一端接一个也不是用一个60Ω跨接在CAN_H和CAN_L之间那是两端120Ω并联后的等效结果。如果两个节点离得非常近只有十几厘米的杜邦线一个120Ω也能凑合跑但只要距离变长就必须按标准来。2.3 物理层信号CAN_H和CAN_L的电压差是怎么形成和测量的经常有人搜“can总线的电压差是怎么改变的”实质是想理解CAN差分信号的原理。CAN收发器在隐形状态时会让CAN_H和CAN_L都处于约2.5V两者差分电压接近0V在显性状态时收发器驱动CAN_H到约3.5V、CAN_L到约1.5V差分电压约为2V。接收端通过判断这个差分电压的正负大小来读取0和1。这里有个调试技巧测量CAN波形时不要只把示波器探头地夹在GND、然后去测CAN_H对地电压。虽然这样也能看到波形但更容易受到共模干扰影响。你把示波器CH1接CAN_H、CH2接CAN_L然后在数学通道里做CH1-CH2的差分计算看到的波形才是接收器真正在判断的信号。这个习惯在排查干扰和信号完整性问题时特别有用。还可以观察总线空闲时的CAN_H、CAN_L对地电压是否都在2.5V附近。如果其中一个偏得厉害说明收发器第一次驱动能力有问题或某一边短路了。3. 位时序手工计算BS1、BS2与SJW如何在F103上落地为500kbps3.1 波特率不是给个数字而是由tq和采样点共同决定CAN通信的波特率配置不像UART那样填一个“9600”就结束它要配置的是每一位对应多少个时间量子。F103的bxCAN控制器把每一位的时间分成四个部分同步段SYNC_SEG、传播段PROP_SEG、相位缓冲段1 PHASE_SEG1、相位缓冲段2 PHASE_SEG2。在标准库里SYNC_SEG固定为1个tqPROP_SEG和PHASE_SEG1被合并到CAN_BS1PHASE_SEG2就是CAN_BS2。采样点的位置就在BS1结束时。采样点太靠前或太靠后都会影响对总线上信号的判定尤其在不同节点晶振有偏差、线缆长度导致信号延时时采样点就更加关键。工业上一般建议采样点落在75%到80%之间这就是我们配置BS1和BS2时的主要约束。3.2 基于STM32F103的完整计算过程F103的CAN控制器时钟来源于APB1。这个前提必须先确定如果你的外部晶振是8MHzPLL里SYSCLK跑到72MHzAPB1分频为2那么APB1时钟就是36MHz。这一点每个工程的时钟树不同不能一概而论但代码里只要用到RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE)CAN外设时钟就由APB1决定。我目标波特率是500kbps也就是每一位持续2微秒。先做整数分解APB1时钟 36MHz。设预分频值Prescaler 4则tq周期 4 / 36MHz 约111ns。 每位tq数量 2μs / 111ns 18个tq。 因为SYNC_SEG已经占1个tq剩下17个tq要分给BS1和BS2。 为了采样点约78%取BS1 13tqBS2 4tq。 采样点 (1 13) / (1 13 4) 14 / 18 77.8%。这个配置落在75%~80%的推荐区间内低速高速都比较常用。如果你需要125kbps每位要持续8微秒可以取Prescaler 16这样tq 16 / 36MHz ≈ 444ns每位18个tqBS1和BS2仍然用13和4算出来采样点还是77.8%。3.3 SJW同步跳跃宽度处理“早到”和“迟到”的容错窗口SJW同步跳跃宽度是另一个必须理解透彻的参数。总线上每个节点都有自己的晶振晶振精度再高也有偏差加上信号在线上传输有延迟节点看到的边沿不可能刚好落在同步段。CAN控制器通过重同步机制在收到边沿时把相位缓冲段长度做调整边沿比预期早到时延长-等待边沿比预期晚到时缩短。SJW限制的就是一次性最多能加或减多少个tq。F103标准库里CAN_SJW可以配置为1tq、2tq、3tq、4tq。SJW并不是越大越好。SJW越大理论上对晶振偏差和干扰的容错能力越强但过大的SJW会在噪声沿到来时过度调整相位反而增加误码风险。常规项目中SJW取1tq就够如果两块开发板用的都是无源晶振而非有源晶振并且环境温度变化大可以把SJW放到2tq作为容错余量。3.4 初始化代码配置项逐行解释以下是标准外设库下的CAN初始化核心代码void CAN1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; CAN_InitTypeDef CAN_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); /* CAN_RX: PA11, 推挽输入带上拉 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); /* CAN_TX: PA12, 复用推挽输出 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; 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; /* 发送优先级由报文ID决定 */ 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; if (CAN_Init(CAN1, CAN_InitStructure) ! CANINITOK) { /* 初始化失败一般是时钟配置和参数不匹配 */ Error_Handler(); } }CAN_TXFP这个配置很多人忽略。它设成DISABLE时发送邮箱的优先级由报文的标识符决定ID越小越优先设成ENABLE则是按邮箱编号顺序发送。控制类场景里我建议保持DISABLE这样紧急命令报文可以通过小ID获得优先发送权这也是CAN仲裁机制的精华所在。4. 双结点的软件分工初始化、滤波器与报文收发状态机4.1 结点A与结点B的职责划分和帧规划开始写代码前先定义好两个节点的身份和报文内容否则联调时会陷入“两边各写各的”的混乱。我做双结点控制时通常把节点A定义为主控端节点B定义为执行端。节点A发送ID0x1A的命令报文Data[0]是控制字例如0x01启动、0x02停止、0x03设定速度Data[1]、Data[2]是速度设定值我用大端排列Data[3]是前面几个字节的累加和校验。节点B发送ID0x2A的状态报文Data[0]是状态字例如0x10空闲、0x11运行、0x12故障Data[1]、Data[2]是电流回读值Data[3]是温度值。节点A只过滤并接收0x2A节点B只过滤并接收0x1A。这样两边收到的都是对端发来的有效报文不会收到自己发出去的回环帧。4.2 字节序问题CAN报文里的大端和小端约定CAN协议本身没有定义数据场内部的字节序网上搜“can大端小端”的人多是因为两边没有提前约定导致解析混乱。我的固定做法是帧里的多字节数值统一使用大端也就是高字节在前。比如速度值0x1234Data[1]0x12Data[2]0x34。为了保证两端不会读错我会封装一对打包/解析函数static void PackU16(uint8_t *p, uint16_t val) { p[0] (uint8_t)(val 8); p[1] (uint8_t)(val 0xFF); } static uint16_t UnpackU16(uint8_t *p) { return ((uint16_t)p[0] 8) | p[1]; }这段代码会减少很多低级错误。两端都用同样的函数即使后面对协议做扩展也不会出现“这边按大端发、那边按小端收”的尴尬问题。4.3 滤波器配置两个节点也要认真设置FilterF103有14个过滤器组每组可以配置成屏蔽模式或列表模式。对于双结点项目虽然理论上两个节点可以不做过滤、把收到的帧全部接收再由软件判断ID但那会在报文量大时占用CPU。更标准的方法是配置硬件过滤器。节点A要接收ID0x2A的标准帧配置如下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 ((uint16_t)(0x2A 5)); CAN_FilterInitStructure.CAN_FilterIdLow 0; CAN_FilterInitStructure.CAN_FilterMaskIdHigh ((uint16_t)(0x7FF 5)); CAN_FilterInitStructure.CAN_FilterMaskIdLow 0; CAN_FilterInitStructure.CAN_FilterFIFOAssignment CAN_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation ENABLE; CAN_FilterInit(CAN1, CAN_FilterInitStructure);这里的关键是把掩码设为0x7FF左移5位的结果表示标准ID的11个位全部需要匹配。如果一开始没接滤波收到的帧很多可以先只用一个Filter、一个FIFO等逻辑跑通后再继续深挖屏蔽模式和列表模式的组合。双结点没有复杂的ID分配压力但是把滤波逻辑写清楚对以后往多节点扩展非常有价值。4.4 发送流程、接收中断与数据解析发送时我封装了一个简单的发送函数避免在主循环里反复写CAN_Transmit的细节static uint8_t CAN_SendFrame(uint16_t stdid, uint8_t *buf, uint8_t len) { CanTxMsg TxMsg; uint8_t mailbox; uint32_t timeout 0xFFFF; if (len 8) len 8; TxMsg.StdId stdid; TxMsg.IDE CAN_Id_Standard; TxMsg.RTR CAN_RTR_Data; TxMsg.DLC len; for (uint8_t i 0; i len; i) { TxMsg.Data[i] buf[i]; } mailbox CAN_Transmit(CAN1, TxMsg); while (CAN_TransmitStatus(CAN1, mailbox) ! CAN_TxStatus_Ok timeout--); return (timeout 0) ? 1 : 0; }这个函数里有几个细节要提醒。首先CAN_Transmit返回的是邮箱号之后轮询CAN_TransmitStatus确认发送成功。如果总线上没有任何其他节点应答ACK这个函数会一直等超时所以在正常模式下必须保证对端在线。其次不要在中断服务函数里长时间轮询发送否则会拖垮接收中断。我的习惯是发送放在主循环或事件处理里如果遇到重传超时就置一个错误标志。接收采用FIFO0消息挂起中断在中断里把数据复制到全局缓冲区void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg RxMessage; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) ! RESET) { CAN_Receive(CAN1, CAN_FIFO0, RxMessage); rx_ready 1; rx_id RxMessage.StdId; rx_dlc RxMessage.DLC; for (uint8_t i 0; i rx_dlc; i) { rx_data[i] RxMessage.Data[i]; } CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); } }主循环里只需要检测rx_ready标志然后消费数据。中断里不做业务逻辑只做数据搬运这是避免CAN中断频繁导致死锁的基本原则。在节点B上收到0x1A命令报文后软件会进入一个简单的状态机解析控制字再决定启动、停止还是调速执行结束后把新的状态字段打包成0x2A报文发回节点A。这里用到的最基本状态机状态切换建议不要直接在中断里做而是设置一个cmd_pending标志主循环里跑状态机这样状态切换可以做延时、滤波、防抖也不会被中断嵌套问题困扰。5. 联调实测与Bus Off恢复常见故障现象和完整排查链路5.1 第一阶段先用环回模式自测跳过物理层干扰双结点联调时我最推荐的开局是“先环回后总线”。环回模式下CAN控制器发出的报文会被自己接收不需要其他节点连接也不需要收发器配合。这能先把初始化、发送函数、接收中断、滤波器逻辑全部验证一遍。配置变化只有一处CAN_InitStructure.CAN_Mode CAN_Mode_LoopBack;环回模式跑通后标志性现象是节点A发0x1A报文自己的接收中断立刻会进收到的报文ID和数据与发送一致。这一阶段重点确认代码里没有位误配、DLC设置错误、FIFO配置错误等问题。物理层尚且没有参与所以如果是差分线接反、终端电阻缺失、波特率不一致这类问题环回模式是查不出来的必须回到正常模式测。5.2 接成双结点后最常见的三类问题第一类是发送一直不成功。在正常模式下一个节点发送报文需要总线上至少有一个其他节点对帧做ACK应答。如果总线上只有节点A一个在发或者对端的收发器根本没接到总线上发送节点会不断重发直到超时。遇到这种情况先确认两端是否都上电、CAN_H是否对CAN_H、CAN_L是否对CAN_L、GND是否共地。第二类是波特率不一致。两边都填了500kbps但一个晶振是8MHz配出来的72MHz主频另一个工程可能用的是内部HSI或者改了PLL倍频导致实际CAN时钟不是36MHz。两边波特率对不上表现就是接收中断偶发进入、报文错误、错误计数器快速升高。最直接的检查方式是用逻辑分析仪或示波器测一位的宽度正常500kbps下一位正好是2微秒。第三类是波形畸变。如果报文时好时坏、缩短线缆就正常、拉长线缆就丢包大概率是终端电阻缺失或总线上有反射。终端电阻有没有接、位置是不是在两端直接影响信号边沿质量。用示波器看CAN_H-CAN_L差分波形如果边沿有过冲、振铃、上升沿圆角就从这个方向查。5.3 Bus Off是什么、什么时候触发、恢复策略怎么写CAN控制器内部维护发送错误计数器和接收错误计数器。如果发送错误计数超过255控制器就进入Bus Off状态主动脱离总线。进入Bus Off后控制器不会再发送任何报文相当于整个节点从总线上“隐身”。常见触发原因包括CAN_H和CAN_L短路、总线一直处于显性状态、波特率完全不匹配、收发器损坏、外部强干扰导致错误帧风暴。Bus Off并不是一种“可恢复就不管”的状态。如果故障根源还在比如有一根线短路造成总线持续显性即使硬件自动恢复正常新的发送还是会立刻触发再一次Bus Off形成反复断开、接入的循环。我通常的做法是把CAN_ABOM留着打开让芯片具备自动恢复能力同时软件层监听Bus Off中断记录次数。如果短时间内连续发生就说明不是偶发干扰而是链路本身有问题这时候应该停止发送把故障报给上层直到人工确认链路恢复。中断里只置标志void USB_LP_CAN1_RX0_IRQHandler(void) { if (CAN_GetITStatus(CAN1, CAN_IT_BUSOFF) ! RESET) { bus_off_count; CAN_ClearITPendingBit(CAN1, CAN_IT_BUSOFF); /* 不在中断里重新初始化CAN主循环里再处理 */ bus_off_pending 1; } /* 接收处理省略 */ }主循环里再根据bus_off_pending执行恢复逻辑。最简单可靠的恢复方式是调用CAN_DeInit后再重新初始化整个CAN外设清空错误计数器重新进入正常发送状态。但要注意连续Bus Off时不要盲目循环恢复加一个退避时间比如等待500ms再恢复给外部故障一点稳定时间。这个“退避重试”的思路和以太网的重试机制是相通的。5.4 排查链路从波形、错误寄存器到上报记录做CAN联调我最依赖的排查链路是“先分物理层再看协议层”。物理层有问题时协议层怎么改代码都白搭。排查顺序大概是万用表量CAN_H和CAN_L之间是否有约60Ω的终端电阻两个120Ω并联后的等效值。确认CAN_H对GND电压、CAN_L对GND电压是否在正常范围。空闲时两者都应该接近2.5V有一边明显偏低或偏高优先查收发器和走线。用示波器看差分波形确认位宽和波特率匹配确认边沿没有严重振铃。代码里读取CAN错误寄存器看是发送错误还是接收错误区分是“发不出去”还是“收不进来”。往上位机或串口打印接收到的原始ID和DLC检查过滤是否生效。联调过程中养成打印记录的习惯很有帮助。我在每个节点里都加了一行串口打印格式类似“时间戳 方向 ID DLC 数据”保存出来就是一套简易的ASC格式日志。这个格式不完全等同于CAN分析仪的.asc文件但信息结构一致后续要换用canalyzer这类工具做离线分析时字段对齐不会太麻烦。最后一个实际经验双结点控制项目看着小但它是理解多节点CAN网络的最佳切入点。节点少排查链路短物理层、数据链路层逻辑完整滤波器、仲裁、错误处理这些关键机制都会被触发一遍。把这个项目彻底调透再去做带CANFD、带诊断协议、带多ECU协同的项目就不会被陌生的总线字节流和时间参数吓到。哪怕以后扩展成三五个节点只要通讯ID规划清晰、报文字节序统一、Bus Off恢复策略有退避逻辑整个通讯骨架都能平稳地复用过去。