1. 项目概述与需求分析
STM32F103 的 CAN 通信,对很多刚接触嵌入式通信的人来说,是既熟悉又陌生的一块。熟悉是因为它是目前工业控制、车载电子里用得最多的总线之一,任何招聘嵌入式工程师的岗位描述里几乎都会写“熟悉 CAN 总线优先”;陌生是因为大部分教程只讲了初始化寄存器、发个报文,真到了双机对接、波形异常、总线错误的时候,就完全不知道怎么下手了。
这篇实战记录要解决的,正是从“会点灯”到“CAN 通起来”之间的那道坎。我以 STM32F103C8T6 最小系统板为平台,配合两片最常见的 TJA1050 收发器,完整跑了一遍双机 CAN 通信全流程:从硬件连接、波特率计算、过滤器配置,到双机互相收发、错误处理与排查。整个过程不涉及复杂的 RTOS 或 Bootloader,就是最纯粹的双机裸机通信,但这套流程是所有 CAN 高级应用的基础。
适合看这篇文章的人,主要分三类:第一类是刚学完 STM32 基础外设、想接触 CAN 但被各种资料绕晕的新手;第二类是已经在用 CAN 做项目,但遇到总线通信不稳定、丢帧、错误帧不知道怎么排查的开发者;第三类是准备用 CAN 做多节点组网,但还没彻底搞明白过滤器怎么配的人。如果你属于其中任何一类,这篇文章应该能帮你省下不少自己踩坑的时间。
在这套项目中,我的关注重点不是“怎么把代码调通”,而是“为什么这样设计”。比如为什么外部收发器要选 TJA1050 而不是直接用芯片内置的?为什么双机通信里面,发送节点的终端电阻必不可少?为什么波特率算出来是 500kbps,但示波器量出来的实际位时间对不上?这些才是实际开发中真正卡人的地方。
2. 双机通信的整体方案设计
2.1 为什么选择 STM32F103 + TJA1050 这套组合
先聊硬件选型。STM32F103 系列的 CAN 控制器是 bxCAN,全称是 Basic Extended CAN,基本型扩展 CAN 控制器。它支持 CAN 协议 2.0A 和 2.0B,标准帧和扩展帧都能处理,内置了 3 个发送邮箱、2 个接收 FIFO(每个 FIFO 有 3 个邮箱深度),以及 28 个滤波器组。对于绝大多数中小规模的双机通信或者少节点组网,这个外设规格完全够用,而且 STM32F103 的生态资料非常丰富,哪怕是完全没有接触过 CAN 的人,从零开始也不会特别吃力。
但有一个关键点必须说明:STM32F103 内部只有 CAN 控制器,没有 CAN 收发器。这是所有嵌入式初学者的第一个认知误区——CAN 控制器负责的是协议层,包括报文封装、仲裁、错误检测、位填充这些逻辑;而真正把逻辑电平转换成差分信号、驱动总线传输的,是外部的 CAN 收发器芯片。这就好比一个人负责起草信件内容(控制器),另一个人负责把信寄出去、收回来(收发器),两者缺一不可。
TJA1050 是恩智浦(NXP)推出的高速 CAN 收发器,最高支持 1Mbps 的通信速率。它兼容 ISO 11898 标准,能与 TJA1040、PCA82C250 互相替代,引脚在大多数场景下直接兼容。我选择 TJA1050 而不是更老的 PCA82C250,主要原因是它的电磁辐射更低,而且在总线短路、热关断这些异常保护上做得更完善。实测下来,在同样 500kbps 的波特率下,TJA1050 的差分输出波形边沿更干净,这对排查总线问题是很大的优势。
2.2 硬件连接的几个关键设计点
下面列出我实际使用的连接方案,这部分是很容易出问题的地方。
CAN 控制器与收发器之间的连接相对固定:STM32F103 的 PA11 是 CAN_RX,PA12 是 CAN_TX,分别接到 TJA1050 的 RXD 和 TXD 引脚。TJA1050 的 VCC 接 3.3V 或 5V 都可以,但要注意:STM32F103 的 GPIO 是 3.3V 逻辑,而 TJA1050 的 TXD/RXD 引脚逻辑电平范围是兼容 3.3V 的,所以电源直接统一用 3.3V 就能正常工作,不需要做电平转换。很多新手在这里会画蛇添足加个电平转换电路,反而是多余的。
总线侧的连接才是真正的重点。CANH 和 CANL 之间需要接一个 120Ω 的终端电阻,而且这个电阻并不是随便接的。在双机通信的场景下,总线的两端各需要接一个终端电阻,也就是说如果你有两个节点,那么每个节点的 CANH 和 CANL 之间都要接 120Ω。如果你用的是现成的带收发器模块(比如市面上常见的 CAN 模块),有些模块上已经焊好了 120Ω 电阻,此时要确认一下自己接了几个模块,避免重复并联导致终端电阻变成 60Ω 甚至更低,那样会加大收发器负载,通信距离和稳定性都会下降。
供电方面有个容易被忽略的问题。TJA1050 的工作电流虽然不大,但在总线活动频繁时会有一定的电流波动。如果你直接用 STM32 板子上的 3.3V LDO 给收发器供电,而这时候板子还接着其他大电流外设,就有可能导致 3.3V 纹波偏大,间接影响 CAN 信号质量。我建议给收发器独立供电,或者至少确保 3.3V 电源有足够的余量和去耦电容。这个细节在短距离、低速率下可能影响不大,但在长线或者高温环境下就会被放大。
2.3 波特率计算:从时钟树到寄存器配置
CAN 波特率不是随便填一个数就能用的,它由 CAN 控制器的时钟频率、预分频器(BRP)、时间段(TSEG1、TSEG2)和同步跳转宽度(SJW)共同决定。STM32F103 的 CAN 外设挂在 APB1 总线上,而 APB1 时钟最大是 36MHz。如果你使用的是外部 8MHz 晶振,并且系统时钟设置为 72MHz,那么 APB1 分频器通常设为 2,所以 APB1 时钟就是 36MHz。但是请注意,CAN 外设自己还有一个独立的预分频器,它是在 APB1 时钟的基础上再次分频的。
标准位时间的计算公式是:
波特率 = CAN 时钟频率 / (BRP × (1 + TSEG1 + TSEG2))其中,BRP 是波特率预分频器的分频系数,TSEG1 和 TSEG2 是时间段长度,单位都是 CAN 时钟周期。对于 500kbps 的目标波特率,36MHz 的 CAN 时钟,常见的一组参数是:BRP = 4,TSEG1 = 13,TSEG2 = 2。代入公式就是:
500000 = 36000000 / (4 × (1 + 13 + 2)) = 36000000 / (4 × 16) = 36000000 / 64 = 562500算出来是 562.5kbps,不对。这就说明 36MHz 时钟下,BRP 和位时间的可选项无法精确得到 500kbps。所以实际应用中更常见的配置是 BRP = 9,TSEG1 = 6,TSEG2 = 1,此时:
500000 = 36000000 / (9 × (1 + 6 + 1)) = 36000000 / (9 × 8) = 36000000 / 72 = 500000这组参数刚好是整的,所以 STM32 标准库例程里的 CAN 初始化代码,500kbps 用的就是这组数字。我建议你在做任何波特率相关的调整时,先用这个公式自己手算一遍,不要直接抄例程里的数字。因为如果你换了外部晶振(比如 12MHz 或 25MHz),或者改了系统时钟配置,同样的寄存器参数对应的实际波特率就会完全变掉。
另外还需要注意位时间的采样点位置。采样点是总线电平被真正采样的时刻,它位于位时间的末端附近。工业界常用的推荐值是 75% 到 80%,也就是让 TSEG1 占整个位时间的比例在 75% 左右。在上面的配置中,TSEG1 = 6,整个位时间为 8 个周期,采样点位置是 (1 + 6) / 8 = 87.5%。这个值偏高了,在极短距离、低负载的实验中问题不大,但在长距离、多节点场景下容易因为在位末尾采样到毛刺而出错。如果你要组一个超过 10 个节点的网络,建议把采样点调到 80% 附近,比如 BRP = 9,TSEG1 = 5,TSEG2 = 2,这样采样点位置是 (1 + 5) / 8 = 75%。
3. 核心代码逻辑与配置细节
3.1 初始化流程:GPIO 到 CAN 外设的配置顺序
CAN 的初始化顺序是有讲究的,很多人一上来就配置 CAN 外设,结果发现 TX/RX 引脚根本没使能。正确的顺序是:先开启 GPIO 和 CAN 外设的时钟,然后配置 PA11 和 PA12 的复用功能,最后再操作 CAN 控制器的寄存器。
PA11 和 PA12 默认是普通 GPIO,必须设置为复用推挽输出(AF_PP)和复用开漏输入(AF_OD),才能把它们交给 CAN 外设控制。这里有个经验:PA11 是 RX,也就是 CAN 控制器的数据输入脚,TX 引脚 PA12 设置为复用推挽输出,RX 引脚设置为浮空输入或复用开漏输入都可以,只要不配置成普通推挽输出就行,因为普通推挽输出会把引脚强制拉高或拉低,直接破坏总线上的隐性电平。
下面是一段基于标准库的初始化代码,我用的是常见的库函数风格,方便对照自己的工程:
void CAN_Init_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; CAN_InitTypeDef CAN_InitStructure; CAN_FilterInitTypeDef CAN_FilterInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); 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); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); CAN_DeInit(CAN1); CAN_InitStructure.CAN_TTCM = DISABLE; CAN_InitStructure.CAN_ABOM = ENABLE; CAN_InitStructure.CAN_AWUM = DISABLE; 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_6tq; CAN_InitStructure.CAN_BS2 = CAN_BS2_1tq; CAN_InitStructure.CAN_Prescaler = 9; CAN_Init(CAN1, &CAN_InitStructure); }这段配置里有两个选项值得单独解释。CAN_ABOM(自动离线恢复)我设置为 ENABLE,意思是当 CAN 控制器因为总线错误进入 Bus-Off 状态后,它会自动等待 128 个总线空闲信号,然后重新恢复通信。在双机通信实验中,如果对端设备断电或者拔出,总线长期处于错误状态,有 ABOM 的控制器能自动恢复,不用人工重启。而 CAN_NART(非自动重传)保持 DISABLE,意思是当发送失败时控制器会自动重传,直到成功或进入 Bus-Off。这在普通数据上报场景下非常有用,但在某些对数据时效性要求极高的控制场景下,可能需要打开 NART,避免旧数据反复占用总线。
3.2 滤波器配置:接收不迷路的关键
CAN 的滤波器是外设的一个独立硬件模块,它决定了哪些报文能够进入接收 FIFO,哪些报文会被硬件直接丢弃。这个机制能大幅减轻 CPU 的负担,也是 CAN 总线能够支撑多节点同时通信的基础之一。
在双机通信的简单场景里,最直接的方式是使用屏蔽位模式,也就是让滤波器接受所有报文,然后在中断里用软件判断帧 ID。这种方式的好处是简单,不管对端发的什么 ID 都能收到;坏处是如果总线上有很多节点在通信,CPU 会被无关的中断频繁打扰。
更规范的做法是使用标识符列表模式,在硬件层面只放行你想要的那个帧 ID。注意,STM32F103 的 bxCAN 滤波器有两种工作模式——屏蔽位模式和列表模式。屏蔽位模式可以理解为一个“通配符”,它指定哪些位必须精确匹配、哪些位无关紧要;列表模式则是精确指定一个或两个帧 ID,只有完全一致的才能通过。
以下是列表模式过滤单个 ID 的配置,过滤 ID 为 0x123:
void CAN_Filter_Config(void) { CAN_FilterInitTypeDef CAN_FilterInitStructure; CAN_FilterInitStructure.CAN_FilterNumber = 0; CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdList; CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterIdHigh = 0x123 << 5; CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x123 << 5; CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_FilterInit(CAN1, &CAN_FilterInitStructure); }这里有一个很多教程没讲清楚的地方:在 32 位滤波器配置里,帧 ID 不是直接填进寄存器的,它需要根据标准帧还是扩展帧做移位。对于标准帧,帧 ID 占据寄存器的高 11 位,也就是需要左移 5 位(因为标准帧 ID 后面还有 RTR 位和 IDE 位等控制位)。这也是为什么0x123 << 5而不是直接填0x123的原因。
如果你觉得手动移位容易出错,还有一个更省事的办法:使用标准外设库提供的CAN_FilterInit配合结构体时,很多版本的库函数已经帮你处理好了,但在寄存器级别操作时一定要搞清楚这一点。我的建议是,写完后打印一下寄存器值,确认 FilterIdHigh 里存的是不是0x123 << 5的结果,这一步能避免大量排查时间的浪费。
3.3 发送与接收中断:两种常用做法对比
发送部分的代码有两种主流写法。第一种是轮询方式,也就是把报文放入发送邮箱后,不断查询发送状态寄存器,直到确认发送完成。第二种是中断方式,发送完成后触发 TX 中断,在中断回调里做后续处理。双机通信实验中,我推荐先用轮询方式,逻辑链路简单,容易确认是否真的发出去了;等到你确实需要非阻塞发送时,再切换成中断不迟。
发送一封标准帧的核心代码如下:
uint8_t CAN_SendFrame(uint32_t id, uint8_t *data, uint8_t len) { CanTxMsg TxMessage; TxMessage.StdId = id; TxMessage.ExtId = 0; 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); uint8_t timeout = 0; while (CAN_TransmitStatus(CAN1, mailbox) != CAN_TxStatus_Ok && timeout < 200) { timeout++; } if (CAN_TransmitStatus(CAN1, mailbox) == CAN_TxStatus_Ok) { return 1; } else { return 0; } }接收我建议直接用中断方式。因为一个健壮的通信节点不能一直轮询接收,否则会错过总线上的突发报文。CAN 接收中断的配置思路是:在 CAN 初始化完成后,配置 NVIC 的 CAN1_RX0_IRQn 通道并设置优先级,然后在中断服务函数里调用库提供的接收函数把 FIFO 里的报文取出来。
void CAN1_RX0_IRQHandler(void) { CanRxMsg RxMessage; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) != RESET) { CAN_Receive(CAN1, CAN_FIFO0, &RxMessage); // 处理收到的报文 process_can_message(&RxMessage); CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); } }一个细节:FMP0 是 FIFO0 的“报文挂起”中断标志,它表示 FIFO0 里至少有一封报文等待读取。读出来之后标志不会自动清除,需要手动调用CAN_ClearITPendingBit,否则即使 FIFO 已经空了,中断标志仍然置位,会反复进入中断。这个坑我遇到过不止一次。
4. 双机联调的完整实操过程
4.1 实验平台与环境准备
这次双机通信实验,我用了两块 STM32F103C8T6 最小系统板,每块板上外接一个独立的 TJA1050 CAN 收发器模块。两块板子的 CAN_TX 和 CAN_RX 分别接到各自收发器的 TXD 和 RXD,然后两块收发器的 CANH 接 CANH、CANL 接 CANL,接线颜色尽量统一,避免长距离飞线导致信号串扰。
实验中的通信速率为 500kbps,一帧标准数据帧的负载是 8 字节。A 机(节点 A)作为主发节点,周期性地向 B 机(节点 B)发送一帧 ID 为 0x11 的报文,内容是 0 到 7 递增的字节序列;B 机收到报文后,解析数据并将接收到的字节数回传给 A 机,ID 设为 0x22,实现一个最简单的“请求—应答”握手。
我用逻辑分析仪同时抓取了 CAN_TX 引脚上的 TTL 信号和 CANH/CANL 之间的差分电压波形。因为逻辑分析仪不能直接解析差分总线信号,我选择观察收发器输入侧的 TXD 信号,这是一个与 CAN 控制器侧电平完全一致的 TTL 信号,能直观看出报文的帧起始、仲裁字段、数据字段和 CRC 字段。
4.2 上电后的联调步骤
整个联调过程我按照下面的顺序推进,每一步都确认无误后再进下一步。
先做静态检查。用万用表测量每个节点 CANH 和 CANL 之间的电阻,确认是 120Ω。如果测出来是 60Ω,说明两个节点各接了 120Ω 电阻并联了;如果是接近 0Ω,说明 CANH 和 CANL 之间有短路。这一步在系统上电前一定要做,能避免上电后总线驱动过流。
然后是单节点回环测试。所谓回环就是让 A 机自己发自己收,用来验证控制器和收发器本身是否工作正常。方法很简单:把两个节点的总线断开,只保留 A 机自己的收发器,并在软件里把 A 机的接收滤波器设为接受所有帧,然后周期发送报文。如果 A 机能收到自己发的报文,说明控制器、收发器、滤波器、中断这一整条链路都没问题。这一步能帮你快速把“硬件接线问题”和“软件逻辑问题”切分开。
双机对接时,先上电 A 机,观察它的 CAN 控制器是否进入正常状态;再上电 B 机,通过逻辑分析仪抓取总线空闲时的隐性电平。正常情况下 CANH 对地约为 2.5V,CANL 对地约为 2.5V,CANH 和 CANL 之间的差分电压接近 0V,此时总线处于隐性状态。当 A 机开始发送报文,CANH 会被拉到约 3.5V,CANL 被拉到约 1.5V,差分约为 2V,这是显性状态。抓到的波形里,如果这些特征出现,说明物理层已经通了一半。
4.3 通信验证与现象观测
当 A 机周期发送、B 机中断接收回传时,我在“PC 端串口调试助手”里实时打印了两个节点的收发计数。实测结果如下表:
| 节点 | 发送帧数 | 接收帧数 | 错误帧数 | 状态 |
|---|---|---|---|---|
| A 机 | 1000 | 1000 | 0 | 正常 |
| B 机 | 1000 | 1000 | 0 | 正常 |
我连续跑了 30 分钟,收发计数完全一致,说明硬件连接、波特率配置、过滤器设置、收发逻辑这几大块都没问题。此时再观察逻辑分析仪抓到的完整报文,标准帧的波形结构很清晰,SOF 位(帧起始)的下降沿之后是 12 位的仲裁字段,然后依次是控制字段、数据字段、CRC 字段、ACK 槽和 EOF。把逻辑分析仪解析出的位流和代码里填写的 ID、数据对比,完全吻合。
这里有个值得记录的细节:在双机正常通信时,ACK 槽在发送端的波形上会出现一个“毛刺”或者短暂的电平变化。这是因为 CAN 是“多主”总线,发送节点在发出 ACK 槽后会释放总线,而接收节点会在 ACK 槽期间拉低总线表示确认。如果你在逻辑分析仪上看到自己的发送帧里 ACK 位一直是隐性电平(没有拉低),说明总线上根本没有其他节点在正常接收你的报文。这个细节在排查“为什么发出去没收不到”时非常有用。
5. 实际踩坑与问题排查实录
5.1 常见问题速查表
下面把我在实验过程中遇到的最典型的问题列成表格,供你对照排查。
| 现象 | 可能原因 | 排查方案 |
|---|---|---|
| 发送超时,CAN_TxStatus 一直不是 Ok | 总线上没有其他节点,或终端电阻缺失 | 先做回环测试确认控制器链路;检查总线是否接了第二个节点或电阻 |
| 能收到自己的报文,但收不到对端报文 | 滤波器配置错误,把对端 ID 过滤掉了 | 暂时关闭滤波器或改为接受所有帧,确认对端报文能否进入 FIFO |
| 波特率不对,双方都收不到 | 晶振频率和初始化参数不匹配 | 用示波器测量 TXD 引脚的实际位宽度,反推实际波特率 |
| 报文偶尔丢失,错误计数器递增 | 总线没有终端电阻,或者线缆过长 | 确认两端各接一个 120Ω 电阻;检查布线是否形成环路 |
| 接收中断标志无法清除,反复进中断 | 没有调用 CAN_ClearITPendingBit,或 FIFO 没有读空 | 在中断末尾手动清除挂起位,并确认每次中断都实际读出了报文 |
| 通信时出现连续错误帧,随后控制器进入 Bus-Off | 波特率不匹配或者 CANH/CANL 接反 | 检查波特率计算值;确认所有节点 CANH 接 CANH、CANL 接 CANL |
5.2 一个典型的自收发误判
我在双机通信的一开始犯了这样一个错误:A 机在没有任何对端节点的情况下,用回环模式发了一帧数据,逻辑分析仪确实抓到了波形,A 机自己也通过中断收到了这帧数据。我当时以为一切正常,就把 B 机接上,结果 A 机发出去的报文 B 机死活收不到。
排查过程是这样的:我先确认 B 机的中断有没有触发,结果没有。然后我把 B 机的滤波器改成接受所有帧,还是收不到。最后我用逻辑分析仪直接抓总线上的差分波形,发现 A 机发送时总线上根本没有显性电平的变化。原因很快就水落石出了——A 机在回环模式下测试后,代码里忘了把 CAN 模式从回环改回正常模式。回环模式下,控制器发出的报文只是在芯片内部绕了一圈,收发器根本没有驱动总线。
这个错误的典型性在于:回环模式是 CAN 控制器内部把发送数据直接送回接收路径,不经过收发器外部引脚。所以回环测试只能证明“控制器逻辑没问题”,不能证明“物理层没问题”。你在自己做联调时,一定要在进入双机对接之前检查当前使用的是 CAN_Mode_LoopBack 还是 CAN_Mode_Normal,这个标志位藏在初始化结构体的 CAN_Mode 字段里,不留意就会漏掉。
5.3 波特率微调的一个实战思路
如果你在做一套需要兼容多个节点的系统,而且总线上不同节点的晶振精度不一致,波特率会产生微小偏差,长时间运行后错误帧会逐渐增多。这时候不要盲目改采样点,先量一下每个节点实际发出的位时间宽度。
用逻辑分析仪或示波器,抓取节点在发送报文时的 SOF 位到第二位之间的宽度,这个宽度就是一个位时间。比如 500kbps 的波特率,位时间理论上是 2μs。如果量出来是 1.98μs 或 2.02μs,说明实际波特率有 ±1% 的偏差,这在短报文下问题不大,但如果报文长度达到 8 字节、而且总线很长,误差累积后就会导致采样点错误。
此时优先调整的是 SJW(同步跳转宽度)。SJW 的作用是允许控制器在总线边沿处调整位时间,以补偿节点之间的时钟偏差。把 SJW 从 1tq 调整为 2tq 或 3tq,能显著提升系统抵抗时钟偏移的能力。代价是位同步的灵活性增加,但在稳定总线上不会造成其他负面影响。
6. 进阶方向与扩展思路
6.1 从双机到多节点组网
双机通信跑通之后,很多人会自然想到一个问题:如果总线上接 5 个、10 个节点,代码还能这样写吗?答案是可以,但要注意两个变化。
第一个变化是仲裁机制。CAN 总线允许多个节点同时发送报文,通过帧 ID 的位仲裁决定优先级。ID 数值越小,优先级越高。在多节点系统里,你需要为每个节点分配独立的帧 ID 范围,并设计好优先级策略。比如紧急控制帧用低 ID(如 0x001),普通状态帧用高 ID(如 0x500),这样即使总线上有冲突,紧急帧也能优先通过。
第二个变化是过滤器的作用会变得更重要。双机通信时你只需要过滤一个 ID,多节点时你可能需要过滤多个 ID,或者用屏蔽位模式把一组相关 ID 全部放行。比如只接收 ID 在 0x100 到 0x1FF 范围内的报文,可以用一个屏蔽位模式,让高 7 位精确匹配0x100这段范围的公共前缀,低 4 位忽略。这个设计能大幅减少中断次数,而单片机只需要处理与自己相关的报文。
6.2 用 CAN 实现简单的传感器数据采集应用
如果你手头有一块带 CAN 接口的传感器模块(比如 CAN 接口的温湿度传感器或气压传感器),完全可以基于现有的双机通信框架,把它改造成一个传感器数据采集系统。传感器作为从节点,周期性上报数据;主节点接收数据,解析出温度、湿度、气压等物理量,并通过串口打印到调试助手。
这种场景下,最需要关注的是数据长度和数据类型转换。CAN 一帧最多只能携带 8 字节数据,如果你要上报的物理量超过 8 字节,比如一组 3 轴加速度数据加上时间戳,就需要拆分成多帧发送,并在每一帧里面加上帧序号,接收端再按照序号拼接。这是从双机通信迈向实际项目时绕不开的一个细节:CAN 是面向帧的协议,不是面向流的协议——它没有像 UART 那样的字节流连续性,每一帧都是独立的,所以应用层要自己处理好分包和重组逻辑。
6.3 结合 DMA 的批量发送思路
STM32 的 CAN 外设是没有 DMA 通道直接支持的,很多人被这一点卡住。但实际上,如果你的 CPU 还有富余,可以自己管理一个发送缓冲区,把要发的报文排成队列,在定时器中断里逐个喂给 CAN 发送邮箱。这种软件队列的方式,效果上能近似实现非阻塞发送,同时避开 CAN 外设没有 DMA 的硬件限制。
实现思路如下:定义一份全局环形缓冲区,每个元素是一组 CanTxMsg 结构体;上层业务只负责往缓冲区里写,底层定时器中断负责从缓冲区读出来并调用 CAN_Transmit。只要缓冲区设计得合理,你就能在业务代码里告别轮询等待,把 CAN 发送变成一个“提交即返回”的操作。在我的一个轻量级传感器上报项目里,我用这个方法做到了 500kbps 速率下接近满负载的连续发送,CPU 占用率依然很低。
结尾:一点个人经验
在反复折腾这套双机通信之后,我最深的一个体会是:CAN 相比 UART、SPI 这些简单总线,最需要建立的是“总线思维”。UART 是点对点的,发出去就完了;SPI 是主从的,主机说了算;而 CAN 是真正的多主总线,所有节点共享一对信号线,因此你要时刻关心总线上其他节点在干什么——它们会不会同时发?会不会总线错误?自己的过滤器会不会把别人的帧也收进来?这些才是 CAN 开发里真正花时间的地方。
最后分享一个我在实际调试中反复用到的技巧:无论你的代码逻辑多复杂,先把波特率、终端电阻和总线波形这三大基础验证扎实,再谈业务逻辑。很多让人抓狂的“偶发丢帧”“时好时坏”问题,根源往往不在代码,而在物理层或配置层。先把波形抓干净,后面所有问题都会变得简单。这套流程跑通之后,你再去看那些复杂的 CANopen、J1939 协议栈,也会觉得它们不过是建筑在这套基础通信机制上的规则约定而已。