STM32双节点CAN通信实战:从原理到代码
2026/9/11 20:38:35 网站建设 项目流程

做嵌入式开发的基本都有同感:只要碰过车载、工控或者机器人相关的通信,CAN总线是绕不开的一道坎。而“CAN双结点控制”这个题目,听起来规模不大,实际上是把两个CAN节点组到同一根总线上,一个节点发报文,另一个节点收报文,再根据报文内容去执行动作并把状态回传,整个控制链路形成闭环。别小看这个最小系统——总线仲裁、波特率采样点、收发器电路、滤波器配置、Bus Off恢复策略,这些CAN开发里的核心问题,在这个阶段基本都要亲手趟一遍。

这篇文章就用我实际调试过的一套STM32F103双结点控制项目为例,把思路、原理、硬件接线、代码配置和排雷过程完整写出来。适合刚接触CAN、想弄明白“两个结点到底怎么通信”的同学参考,也适合已经在做多节点项目、想回头把底层细节补扎实的工程师。我尽量说人话,涉及参数的地方把计算过程也摊开讲。

1. 项目整体设计思路:为什么要用双结点做最小闭环

1.1 单结点只能自测,双结点才叫真正跑总线

很多人第一次玩CAN,第一步是把STM32的CAN配置成LoopBack回环模式,自己发自己收——这是标准的单片自测手段,也能验证MCU的CAN外设有没有初始化成功。但自测通过不代表你能搞定真实通信,因为LoopBack模式下报文根本没有走上物理总线,不存在差分信号传输、没有第二个节点参与仲裁,ACK应答也是外设自己回给自己的。

一旦把两个真实节点挂在同一对CANH/CANL线上,情况就完全不同了。发送方发出的显性电平必须在总线上被另一个节点的收发器感应到,接收方收到完整报文后会在ACK槽回一个显性电平,发送方检测不到ACK就会一直重发,直到错误计数超过阈值进入Bus Off。这些机制只有双结点甚至多节点场景下才会真正触发。

所以我的判断很明确:入门CAN通信,双结点是成本最低、逻辑最完整的闭环实验。一个节点下发控制指令,另一个节点接收并执行,再把执行结果回发,这不光验证了CAN发收链路,也把“控制—反馈”的工程模型顺带跑通了。

1.2 方案选型:两块独立板还是单芯片双CAN?

开始之前我纠结过“双结点”到底怎么做,因为不同语境下这词含义不一样,至少可以拆出三种方案。

第一种是用两颗独立MCU,各带一个CAN控制器和收发器,分别烧录不同程序,一个当主节点,一个当从节点。这是最贴近真实设备间通信的做法,也是我最终采用的方案。第二种是单颗MCU内部有两个CAN控制器,比如STM32F105/F107系列自带双CAN,节点之间通过MCU内部转发,这更像网关的雏形,但F103系列只有一路CAN控制器,做不了。第三种是用一块USB转CAN分析仪冒充第二个节点,但分析仪只是观察工具,它不会主动执行业务逻辑,用来验证收发可以,用来做“控制”闭环就偏了。

考虑到项目标题写的是“P3”,应该是一个循序渐进系列里的第三篇,前面大概率已经做过CAN协议解析、单节点收发之类的实验。我直接选择两块STM32F103C8T6核心板加两个TJA1050收发器,成本低、接线简单、代码也好维护,是最稳的起步组合。

1.3 双结点控制的具体功能划分

这套系统的功能我设计得非常直白:节点A是主控端,周期性向总线上发送ID为0x101的标准数据帧,数据域8个字节,其中第0字节是控制指令;节点B是从控端,接收0x101后解析指令,控制板载LED亮灭和闪烁模式,然后回发一帧ID为0x102的状态报文,告诉节点A“我收到了、当前状态是什么”。

再加上一个心跳维度:节点A每隔500毫秒发一次控制帧,节点B回发一次状态帧,总线上始终能看到两个ID交替出现。如果节点A连续多次没有收到0x102,就可以认为节点B掉线,主控端给出报警或者重发。这样一个简单的双结点系统,已经把周期上报、指令下发、状态回传、超时告警这些工程里最常见的交互模式全都覆盖了。

2. CAN总线核心原理与关键参数:动手之前先把底层吃透

2.1 物理层:一根双绞线怎么把0和1传出去

CAN物理层的载体是两根线,分别叫CANH和CANL,数据以差分电压的形式在两根线之间传递。发送逻辑0时,收发器让CANH驱动到约3.5V、CANL驱动到约1.5V,两者差压约2V,这叫显性电平;发送逻辑1时,CANH和CANL都处于约2.5V的静态电平,差压接近0V,这叫隐性电平。

为什么选差分而不是单端?因为CAN经常用在发动机舱、工业现场这类电磁干扰严重的地方,干扰通常以共模形式同时叠加在两根线上,接收端只做减法,CANH减CANL,共模干扰就被抵消掉了。这跟把两根线拧成双绞线是同一个道理——线上感应的噪声在相邻绞环上方向相反,进一步抵消。可以用一个生活类比来理解:两个人抬水,显性电平就像两个人都往中间使劲,桶里明显受力;隐性电平就是两个人都松手,桶悬空但看不出谁用没用力。接收端关心的是“两股力的差值”,而不是某一边的绝对力气。

两端为什么非要接120欧姆终端电阻?因为电缆本身有特征阻抗,如果不匹配,信号到达线缆末端会反射回来,造成波形振铃,接收端采样时采到不稳定的电平就会出现误码。120欧姆是CAN标准规定的特征阻抗值,所以双结点场景下通常每个节点或总线物理两端各接一个120欧姆电阻。注意,不是说每个节点都要接,而是总线最末端才需要。两个节点彼此就是总线的两端,所以两端各接一个120欧姆是标准接法。

2.2 波特率与采样点:为什么两边配置一样还是不通

CAN标准并没有规定死必须用哪个波特率,但同一个总线上的所有节点必须约定一致。常见的有125kbps、250kbps、500kbps,甚至1Mbps。STM32F103的CAN外设挂在APB1时钟上,最高36MHz,波特率由预分频和位时间共同决定。

这里必须先讲清楚位时间结构。CAN把每一个bit的时间划分成若干份,每一份叫一个Tq(Time Quantum)。一个完整的位时间由三部分组成:同步段SYNC_SEG固定为1个Tq,用于对齐总线上的跳变沿;BS1段,等效于传统CAN里的传播段加相位缓冲段1,主要补偿物理延迟和相位误差;BS2段,等效于相位缓冲段2,为下降沿提供缓冲。采样点就落在BS1和BS2的交界处。

波特率计算公式为:CAN波特率 = APB1时钟 / CAN_Prescaler / (1 + BS1 + BS2),单位是bps。这里特别注意,标准外设库的初始化结构体里填的CAN_Prescaler就是实际分频值,库内部写寄存器时会自动减1。很多新手直接看寄存器看到BRP字段是3,以为分频是3,其实硬件分频是4,就栽在这个减一上。

我实际用的配置是APB1=36MHz,目标波特率500kbps。先把36MHz除以500kbps,得到72,意味着时钟与波特率的比值是72。如果我把位时间分成18个Tq,那预分频就取4:36MHz/4=9MHz,9MHz/18=500kbps。18Tq的结构我取SYNC=1Tq、BS1=13Tq、BS2=4Tq,采样点=(1+13)/18=77.8%。这个采样点位置在CAN标准推荐的75%~85%范围内,属于比较稳的值。

我再把常用配置整理成了下表,APB1按36MHz计算,统一用18Tq位时间结构。

目标波特率CAN_Prescaler(标准库填值)CAN时钟位时间结构采样点
125 kbps162.25 MHzSYNC=1、BS1=13、BS2=477.8%
250 kbps84.5 MHzSYNC=1、BS1=13、BS2=477.8%
500 kbps49 MHzSYNC=1、BS1=13、BS2=477.8%

有人会问,为什么非要纠结采样点?两个节点如果只是标称波特率一样,但位时间Tq划分完全不同,比如一边BS1=8、BS2=9,另一边BS1=13、BS2=4,虽然算出来的标称波特率可能接近,但采样点差距很大,一旦总线长度增加或者环境干扰变强,就会出现“平时能通信,一上电稍快点就错帧”的怪毛病。所以CAN开发里有一个不成文的规定:所有节点最好使用相同的采样点配置,而不只是相同的标称波特率。

2.3 总线仲裁:两个节点同时发会怎样

双结点场景里有一个很有意思的机制是总线仲裁。当两个节点同时往总线上发数据时,CAN不会把总线搞崩,而是靠着“显性覆盖隐性”的电气特性做无损仲裁。规则很简单:ID值小的帧优先级高。

举个例子,节点A发ID=0x101,节点B发ID=0x102,两者几乎同时开始。在发送ID的每一个位时,每个节点一边发送一边监听总线,如果自己发的是隐性位1,却在总线上读到显性位0,说明有别的节点在发更高优先级的数据,自己立刻退出发送,转为接收状态。0x101的二进制是001 0000 0001,0x102是001 0000 0010,两者在前面几位相同,到了某一位出现0x101是0、0x102是1的情况,节点B读到总线为0就知道自己不占优先级,主动让路。整个过程对上层完全透明,没有任何数据被破坏,赢的节点把帧发完,输的节点等总线空闲后自动重发。

所以双结点甚至多节点系统的ID分配是有讲究的。主控节点、实时性要求高的控制帧,ID要偏小;从节点、普通状态帧,ID可以偏大。我在这个项目里把0x101分配给节点A的控制帧,把0x102分配给节点B的状态帧,其实是故意做出“控制优先于状态上报”的语义。

3. 实操过程:STM32F103双结点收发与控制实现

3.1 硬件准备与连接:两根数据线加两端120欧姆

硬件清单不复杂,我列一下实际用到的物料:

  • 两块STM32F103C8T6最小系统板
  • 两个CAN收发器模块,我用的是TJA1050,也可以选SN65HVD230
  • 杜邦线若干,尽量短
  • 两个120欧姆电阻,或使用带120欧跳线电阻的收发器模块

接线方式非常简单。STM32F103的PA11是CAN1_RX,PA12是CAN1_TX,分别接到收发器模块的RXD和TXD引脚上;收发器模块的CANH和CANL再引出去,节点A的CANH接节点B的CANH,节点A的CANL接节点B的CANL。特别注意节点之间要共地,也就是两块板的GND必须连在一起,CAN是差分信号不假,但收发器内部和MCU的参考电位必须一致,不然共模电压超出范围就会大量误码甚至烧芯片。

这里有一个特别容易踩的坑:TJA1050的VCC一般是5V,而STM32F103的IO电平是3.3V。TJA1050的TXD输入阈值兼容3.3V逻辑,STM32的PA12可以直接驱动,但TJA1050的RXD输出高电平接近5V,直接怼到PA11上有可能超过STM32的引脚耐压。稳妥做法是选带电平转换的模块,或者在RXD到PA11之间串电阻分压。市面上很多成品TJA1050模块已经把这个问题处理好了,买之前最好看一下原理图,没把握就选明确标着支持3.3V MCU的模块。

3.2 软件初始化:时钟、GPIO、CAN外设、滤波器

先用标准外设库把初始化顺序写清楚。第一步使能时钟,CAN1挂在APB1上,GPIOA挂在APB2上:

RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

第二步配置GPIO。PA11是RX输入,配成上拉输入;PA12是TX输出,配成复用推挽:

GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure);

第三步配置CAN外设。我直接给出正常模式下的初始化参数,按上一节计算的500kbps配置来:

CAN_InitTypeDef CAN_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; 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);

几个参数说下理由。CAN_ABOM置为ENABLE,让芯片在出现Bus Off后能自动恢复,这个后面排雷时再展开。CAN_TXFP置为DISABLE,意思是发送优先级按报文ID决定,ID小的先发,这样仲裁规则和我们的设计意图保持一致。CAN_SJW取1Tq,因为两个节点用的都是同一款芯片、同一时钟源,晶振偏差很小,1个Tq的重同步能力足够。

第四步配滤波器。调试阶段我建议先全通接收,让节点B能收到总线上所有报文,等链路确认没问题再按ID过滤。全通配置是把掩码寄存器全部写成0:

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 = 0x0000; CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_FilterInit(&CAN_FilterInitStructure);

如果你确认链路已经通了,只想让节点B收0x102这个ID,可以把过滤改为掩码模式,ID左移5位填入FilterIdHigh的高11位,掩码对应位写1。新手在这里容易搞错ID在寄存器里的位置,我建议先全通,确认一切正常再优化。

3.3 报文发送与接收:控制指令闭环

节点A的发送逻辑写成一个通用函数,方便两边复用:

void CAN_SendMsg(uint16_t id, uint8_t *data) { CanTxMsg TxMessage; TxMessage.StdId = id; TxMessage.IDE = CAN_Id_Standard; TxMessage.RTR = CAN_RTR_Data; TxMessage.DLC = 8; for (uint8_t i = 0; i < 8; i++) { TxMessage.Data[i] = data[i]; } CAN_Transmit(CAN1, &TxMessage); }

注意CAN_Transmit只是把报文交给发送邮箱,不是立刻发完就返回。它返回三个状态:0表示没有可用邮箱,发送失败;1到3表示报文进入对应邮箱。如果发送邮箱满了,新报文会排队等待。高速周期发送时,最好判断一下返回值,别一股脑往邮箱里塞。

节点A主循环里每500毫秒改变一次控制字并发送:

uint8_t txData[8] = {0, 0, 0, 0, 0, 0, 0, 0}; while (1) { txData[0] = (txData[0] == 0) ? 1 : 0; CAN_SendMsg(0x101, txData); delay_ms(500); }

节点B这边用接收中断来实现实时响应。F103的CAN1接收中断入口分新旧两种,老固件库叫USB_LP_CAN1_RX0_IRQHandler,新固件库叫CAN1_RX0_IRQHandler,具体看你工程启动文件里中断向量表的名字。中断服务函数里做接收和应答:

void CAN1_RX0_IRQHandler(void) { CanRxMsg RxMessage; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) != RESET) { CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); CAN_Receive(CAN1, CAN_FIFO0, &RxMessage); if (RxMessage.StdId == 0x101) { if (RxMessage.Data[0] == 1) { GPIO_SetBits(GPIOC, GPIO_Pin_13); } else { GPIO_ResetBits(GPIOC, GPIO_Pin_13); } txData[0] = RxMessage.Data[0]; txData[1] = 0x01; // 状态标志,表示指令已执行 CAN_SendMsg(0x102, txData); } } }

这里还要在初始化阶段使能接收中断和NVIC:

CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE); NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = CAN1_RX0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);

实际跑起来后,节点A的板载LED每500毫秒翻转一次,节点B的LED跟着节点A的指令同步亮灭,同时节点A如果收到0x102状态帧,就认为链路状态正常。如果你从逻辑分析仪或者示波器上看CANH和CANL之间的差分波形,能看到一帧0x101和一帧0x102交替出现的清晰方波。

3.4 用CAN分析仪抓包验证双结点行为

双结点跑通之后,建议挂一个USB转CAN分析仪在总线上,用配套的上位机软件观察总线流量。你会看到总线上有两类帧,一类是0x101,节点A发出的周期控制帧,一类是0x102,节点B回发的状态帧,两者的ID和DLC都符合预期。

这个验证步骤很关键。很多人调完代码,看到LED亮灭正常就以为大功告成,其实可能CAN总线一直在重发错误帧,应用层碰巧把错误帧忽略掉了,所以业务逻辑“看似正常”。挂在分析仪上看,如果总线上持续出现错误帧、Bus Off恢复、重复发送等异常,才能提前发现底层问题。

如果手头没有分析仪,也可以先用节点A的接收功能做反向验证——让节点A也开接收中断,把收到的0x102状态帧打印到串口,确认内容正确。分析仪的优势是能看到总线层面的细节,比如帧间隔、错误帧计数、具体报文内容,所以这东西做CAN开发几乎人手一个,预算允许的话建议备上。

4. 常见问题与排查技巧:双结点调试实录

4.1 收不到数据?从电气和配置两层查

双结点调不通,最常见的就是“节点A发了,节点B没反应”。我的排查顺序固定如下,已经帮我解决过很多次问题。

先看物理层。用示波器或万用表测CANH和CANL之间的静态电压,正常情况下应为2.5V左右;发送时能看到差压跳到约2V的显性电平。如果电平不对,先检查收发器VCC和GND有没有接反,再检查CANH和CANL有没有接反。CANH接CANH、CANL接CANL,这看似废话,但我见过太多次两块板接线交叉的情况,换上就直接通。

再查共地。两块板GND必须相连,很多新手只接了CANH和CANL,结果偶发能通信,频繁丢帧,最后发现是共地问题。接着确认终端电阻。总线两端各有一个120欧姆,少了的话波形会振铃,表现为长距离通信时偶发错误帧。

物理层没问题就查配置。波特率是否双方一致,这里的一致不只是“都填了500k”,还要确认总线时钟频率、预分频和BS1/BS2的Tq分配是否相同。滤波器有没有配置成全通?如果滤波器掩码配错,报文可能根本进不了接收FIFO。中断有没有使能?很多人在初始化里配置了CAN但忘了开CAN_IT_FMP0和对应的NVIC通道。

我习惯把这些排查点整理成一张速查表,实际调试时从上往下过一遍。

排查层级检查项典型现象
物理层收发器供电、CANH/CANL接线无任何波形,报文完全收不到
物理层节点共地偶发通信失败,长线尤其明显
物理层两端120欧姆终端波形振铃,错误帧增多
链路层波特率与采样点配置一致能通但错帧,或完全不通
链路层滤波器掩码应用层收不到特定ID
软件层CAN接收中断和NVIC状态标志不触发

4.2 采样点不准导致的偶发错帧

这类问题最隐蔽,因为它不是“完全不通”,而是“时好时坏”。典型表现是:两块板放桌面上,杜邦线短接,怎么测都正常;把线拉长到几十厘米,或者旁边加个开关电源,就开始偶发错帧、重发、甚至进入Bus Off。

原因通常是两个节点虽然标称波特率一致,但位时间的Tq划分差很多,导致采样点一个靠前一个靠后。坐在总线同一边的节点各自都能读懂自己发出去的帧,但互相读对方的帧时,采样点恰好落在电平跳变的边缘,抗干扰能力骤降。

解决思路也很直接:所有节点统一采样点。把位时间固定为18Tq,BS1取13Tq、BS2取4Tq,采样点约77.8%,这是业内比较通用的位置。如果晶振偏差较大或者总线距离较长,可以适当把SJW从1Tq提高到2Tq甚至4Tq,让重同步能力更强,但SJW不能超过BS2,否则采样点会错乱。

4.3 Bus Off恢复策略:不能一错就死

CAN控制器内部有发送错误计数TEC和接收错误计数REC,当TEC超过255时,节点会进入Bus Off状态,彻底脱离总线,不再参与任何收发。这是CAN的保护机制,防止故障节点持续向总线灌错误帧,拖垮整条总线。

但这个保护机制对业务系统来说是双刃剑。如果节点进入Bus Off后一直傻等,业务就僵死了。所以工程上一定要主动做恢复。恢复有两种方式:一是硬件自动恢复,控制器检测到128次总线空闲后自动退出Bus Off,这要求在初始化时把CAN_ABOM置为ENABLE;二是软件手动恢复,重新把CAN外设的INRQ位置1再清0,相当于软复位CAN控制器。

我在这个双结点项目里把CAN_ABOM置为ENABLE,原因很简单:节点B是执行端,如果因为一次总线干扰进入Bus Off,整个控制链路就断了,而自动恢复能让它在总线恢复空闲后自动重新参与通信,对业务透明。如果你的项目对安全性要求更高,不希望故障节点频繁自动上线,那就关掉ABOM,由应用层决定什么时候再让它恢复上线。

4.4 双结点控制的实战注意点

最后说几个只有实际调过才会留意的细节。

第一,F103的CAN1和USB共用PA11、PA12,如果你同时初始化了USB或者用到了这两个引脚复用功能,CAN会起冲突。最小系统板上如果还有USB转串口之类的芯片,注意确认它没有占用PA11/PA12。

第二,报文ID不要随便乱选。虽然0x101、0x102在双结点里都能跑,但如果你后续要接入真实车载网络或者工业CAN网络,ID范围和优先级都需要提前规划。一般原则是把实时性要求高的控制类ID排在前面,状态类ID排在后面,同时避开网络中已存在的ID。

第三,周期发送间隔不要设得太小。500kbps波特率下,一帧8字节标准帧大概占130微秒左右,理论上总线容量够,但如果还有别的节点在发,周期太短容易占满总线。双结点做验证,500毫秒甚至1秒的周期足够了,想看频繁点就100毫秒,没必要追求极致的总线占用。

第四,热插拔的问题。CAN总线是支持不断电接节点的,但前提是接入的节点不能处于错误状态,否则可能把总线拉死。我在调试时习惯先让被接入的节点上电完成初始化,再接入总线,这个习惯能省掉很多莫名其妙的问题。

这套双结点系统调通之后,往后扩展就顺了。想加节点,就往总线上再挂一块板子,给它分配一个新的ID;想做网关,就把两个CAN控制器接起来做转发;想更贴近工业现场,就把收发器换成带隔离的型号,比如CTM1050,顺便把电源隔离也做了。CAN这条线走得越深,越会发现当初把双结点这个最小系统吃透有多值得。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询