CAN-FD选型与STM32G474 FDCAN消息RAM配置避坑
简介:这份面向STM32嵌入式开发者的FDCAN应用笔记,对应AN5348 Rev1(2021年7月),适合已具备CAN基础、希望迁移到CAN-FD或调试FDCAN外设的工程师使用。全文围绕CAN-FD协议概述、相较传统CAN 2.0的改进、STM32G0/G4/H7/L5及STM32MP1中的FDCAN实现,以及外设模式与特性展开,可帮助读者厘清仲裁阶段与数据阶段的比特率切换、EDL/BRS/ESI等控制位含义。资源包共1个PDF文件,约554KB,篇幅紧凑,便于随查随用。已有698人学习下载。文档内含标准CAN-FD帧结构图、CAN-FD与CAN 2.0帧架构对比、有效负载长度代码表,以及17位/21位CRC校验和位填充机制说明,并附适用产品表格;对需要配置数据段波特率、将单帧扩展至最多64字节、排查错误状态与帧格式差异的开发者,可作为对照寄存器配置和协议时序的速查资料。
1. 一条8字节的旧账:stm32 CANFD 在我看来改的不是速率
我在一块 G474 的板子上跑过很长一段时间的传统 CAN,一包 64 字节的标定数据要拆成 8 帧发,接收端再拼回来,中间还得自己维护序号和超时重传。后来切到 FDCAN,同样一包数据一帧走完,但真正让我觉得值回票价的不是带宽数字,而是软件层整整一层分片重组代码连同它的边界 bug 一起消失了。这篇不讲协议教科书,只讲两件事:CAN-FD 相比 CAN 2.0 在什么条件下值得换、G474 上那 10KB 消息 RAM 该怎么规划才不浪费。正在做选型的、配寄存器被地址算错的、准备把老 CAN 节点往 FD 迁的,都能直接抄结论。
2. CAN-FD 还是 CAN 2.0:四个维度给出选型结论
2.1 有效负载与 DLC 编码:一帧 64 字节省下的是整条链路
选型第一个要看的从来不是「最高 8Mbit/s」这个宣传口径,而是你的应用一包数据到底多长。传统 CAN 单帧有效负载被死死钉在 8 字节,CAN-FD 把它抬到 64 字节,代价只是 DLC 那 4 位从「线性编码」变成了「阶梯编码」。
| DLC(十进制) | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CAN 2.0 字节数 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
| CAN-FD 字节数 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 20 | 24 | 32 | 48 | 64 |
这张表有个容易忽略的细节:CAN-FD 的 9 到 15 不是连续增长,而是 12、16、20、24、32、48、64 跳着来。所以你写代码时如果按「DLC 乘 4」去算长度,从 DLC=9 开始就全错。我一般会写一个查找表把 DLC 映射成字节数,而不是用乘法。
账面收益可以粗算一下。传 64 字节,传统 CAN 要 8 帧,每帧标准数据帧的固定开销约 47 位,加 64 位数据,合计约 888 位;CAN-FD 一帧的仲裁段加控制段约 22 位,数据 512 位,CRC 21 位加界定符,再加 ACK、EOF、IFS 约 13 位,量级在 600 位上下。总线占用降了三成左右,但更关键的是仲裁次数从 8 次降到 1 次——仲裁次数决定了你的最坏延迟抖动,而不是平均带宽。做实时控制的人应该盯这个数,而不是盯着 8Mbit/s。
反过来说,如果你的应用每周期就发两三个字节的状态量,帧率也不高,那 CAN-FD 的收益接近于零,还要多付收发器和时钟精度的成本。这是我见过的第一个选型误判:为了「先进」而上 FD,结果总线上跑的全是 8 字节小包。
2.2 帧结构里的三个新位:EDL、BRS、ESI 各自管什么
CAN-FD 的帧被切成三段:第一仲裁阶段、数据阶段、第二仲裁阶段。第一仲裁阶段包含帧起始、ID 和格式位,第二仲裁阶段包含 ACK 和帧结束,中间的数据阶段才是 FAST 模式真正提速的地方。整帧里新增了三个控制位,各自职责完全不同,但在寄存器配置时最容易被一锅烩。
EDL(扩展数据长度)是「这帧是不是 FD」的身份位。它在帧里是隐性时表示这是 CAN-FD 帧,显性时对应传统 CAN 2.0 帧里的 R0。这一位决定了总线上所有节点能不能听懂这帧。
BRS(比特率切换)是「数据段要不要换速率」的开关。置位时,仲裁阶段用标称比特率,数据阶段切到数据比特率,最高可以到 8 倍。这里有个硬件前提常被漏掉:收发器的环路延迟必须撑得住数据段的位宽,时钟源的精度也必须够,否则一切换速率就在采样点上翻车。我在第一版硬件上就吃过这个亏,仲裁段跑得好好的,一开 BRS 误码率就上去了,最后查出来是晶振精度和收发器延迟共同作用的结果。
ESI(错误状态指示)是「我处于错误主动还是错误被动」的广播位。它让总线上其他节点提前知道某个节点已经带病工作,这个信息在功能安全场景里比多出来的 56 字节更有价值——你可以据此做降级策略,而不是等它彻底掉线才发现。
还有一个结构变化必须记住:CAN-FD 只支持数据帧,远程帧被砍掉了,原来 RTR 的位置变成了显性的 RRS。如果你的老代码里还有「发远程帧请求数据」的逻辑,迁到 FD 时这段逻辑要整个重写成请求-应答模式,这是工作量估算里最容易漏的一块。
2.3 CRC 与完整性:17 位和 21 位的分界卡在哪
CAN-FD 把 CRC 从 15 位扩到了 17 位或 21 位,分界线是有效负载长度:不超过 16 字节用 17 阶多项式,16 到 64 字节用 21 阶多项式。同时 CRC 计算里包含了位填充位(位填充机制),传统 CAN 的 CRC 是不含填充位的。
这两条改动的意义在于:帧变长之后,如果还用 15 位 CRC 且不含填充位,漏检概率会明显上升。所以这不是「顺手加长」,而是帧长增加后的必要补偿。做选型时,如果你的总线环境恶劣、节点多、线束长,这一条应该单独列一项来比。
实际配置层面你几乎不用管 CRC 的计算过程,硬件全包了。但有一个连带影响要知道:数据段长度超过 16 字节之后,帧在总线上的最坏传输时间会跳一档,你的看门狗周期和通信超时阈值如果按 8 字节帧算的,得重新核。
2.4 兼容性与迁移成本:混合总线上谁能听懂谁
选型结论里最容易被跳过的一节,恰恰是决定项目能不能按时交付的一节。
CAN-FD 节点在协议上是兼容的,它能收发传统 CAN 报文,也能被配置成只跑传统格式。但反过来不成立:一个只懂 CAN 2.0 的老节点收到 EDL 为隐性的 FD 帧,会把它当成格式错误,通常的反应是报错甚至进错误被动。也就是说,同一条总线上不可能既有一堆老节点全速跑 2.0,又有一个新节点发 FD 帧——除非所有 FD 帧都退化成 2.0 格式发。
| 对比项 | CAN 2.0 | CAN-FD |
|---|---|---|
| 兼容 CAN-FD | 不支持 | 支持 CAN 2.0 A/B |
| 仲裁比特率 | 最高 1 Mbit/s | 最高 1 Mbit/s |
| 数据比特率 | 同上 | 最高 8 Mbit/s |
| 单帧最大数据 | 8 字节 | 64 字节 |
| BRS / EDL / ESI | 无 | 有 |
| CRC 含填充位 | 否 | 是 |
| 远程帧 | 支持 | 不支持 |
我的结论是这样落的:如果整条总线上的节点都能重刷固件,且应用里存在大于 8 字节的周期性数据块,就上 FD;如果总线上有不可替换的老节点,那就把 FDCAN 外设配置成传统模式跑,先保住硬件平台的延续性,等老节点自然退役;如果数据全是短帧小包,别为了参数好看硬上,收益抵不上调试成本。
3. G474 的 FDCAN 硬件账本:10KB RAM 怎么花
3.1 元素大小公式:为什么这笔账必须自己算
G474 上的 FDCAN 外设有一块约 10KB 的专用消息 RAM,可访问的部分是 2560 个 32 位字。发送和接收的所有报文都不是放在寄存器里,而是放在这块 RAM 里,你在初始化时唯一要做的事,就是把 2560 个字切给滤波区、接收区、发送区和触发区。
切之前先得知道一个「元素」占多少字。公式很朴素:
元素大小(字) = 2(消息头) + 数据场字节数 / 4
消息头固定 2 个字,装的是 ID、DLC、控制位(ESI、XTD、RTR、BRS、FDF)和收发专用的位域;数据部分按 4 字节一个字向上取整。注意那个隐含的向上取整:如果数据场在 0 到 8 字节之间,数据部分一律按 2 个字算。
| 数据场(字节) | 元素大小(RAM 字) | 占字节数 |
|---|---|---|
| 0 至 8 | 4 | 16 |
| 12 | 5 | 20 |
| 16 | 6 | 24 |
| 20 | 7 | 28 |
| 24 | 8 | 32 |
| 32 | 10 | 40 |
| 48 | 14 | 56 |
| 64 | 18 | 72 |
这张表是后面所有地址计算的底座。我见过不止一个人把 64 字节元素按 16 字去算,结果最后一算总账发现挤出了几十个字,回头看是数据场除法没向上取整。
写个脚本先算,比在寄存器里试错快得多:
# 按 FDCAN 元素大小规则估算占用,输入是各区元素个数和每个元素的数据场字节数 import math def elem_words(data_bytes: int) -> int: """元素大小(32位字) = 2 字消息头 + ceil(数据场/4)""" data_words = math.ceil(data_bytes / 4) return 2 + data_words # 数据场 <= 8 字节时自动等于 4 字 def region_words(count: int, data_bytes: int) -> int: return count * elem_words(data_bytes) # 一个典型配置:128 个 11 位滤波器 + 64 个 64 字节 FIFO 元素 # + 64 个 64 字节 Rx 缓冲区 + 32 个 8 字节 Tx 缓冲区 plan = { "11位滤波器": 128 * 1, # 每个滤波器元素占 1 字 "Rx FIFO0": region_words(64, 64), # 64 * 18 = 1152 "Rx Buffer": region_words(64, 64), # 64 * 18 = 1152 "Tx Buffer": region_words(32, 8), # 32 * 4 = 128 } total = 0 for name, words in plan.items(): print(f"{name:<12} 起始偏移 {total:>5} 占用 {words:>5} 字") total += words print(f"合计 {total} 字 / 可用 2560 字,剩余 {2560 - total} 字")跑完的结果是 128 + 1152 + 1152 + 128 = 2560,刚好排满,一个不剩。这个例子很适合拿来当模板,因为它把四种区都用上了,而且没有任何浪费。你换成自己的配置时,只要保证各区的起点不重叠、总和不超 2560 就行。这里有个设计上的小陷阱:CAD 消息 RAM 的分配是动态连续的,硬件不会帮你检查重叠,两个区起始地址配到同一处,表现是数据互相踩踏,而不是报错。
3.2 滤波区:128 个 11 位和 64 个 29 位怎么摆
滤波区的起始地址由两个寄存器决定:11 位标准 ID 滤波器区看 FDCAN_SIDFC 里的 FLSSA 字段,29 位扩展 ID 滤波器区看 FDCAN_XIDFC 里的 FLESA 字段。每个滤波器元素占 1 个字,标准 ID 最多 128 个,扩展 ID 最多 64 个。
滤波器元素有三种类型,选择靠 SFT 两位:
- 范围滤波器:匹配 ID 落在两个边界之内的所有报文;
- 双 ID 滤波器:匹配一个或两个特定 ID;
- 传统位屏蔽滤波器:第一个 ID 是匹配值,第二个 ID 是掩码,掩码为 0 的位不参与比较,掩码全 0 就匹配所有报文。
把这三种类型组合起来,可以做出相当复杂的硬件过滤,而且整个过程不占 CPU。举一组实际配置,需求是:拒绝 ID 在 0x16 到 0x20 之间的报文;接受 0x15 或 0x120 并存进 FIFO 1;接受 0x130 并存进 Rx 缓冲区索引 4;接受形如 0b11100XX0000 的一族 ID 并存进 FIFO 0。
| 顺序 | SFT 类型 | SFEC 配置 | SFID1 | SFID2 |
|---|---|---|---|---|
| 第 1 个 | 00 范围滤波器 | 011 拒绝 | 0x16 | 0x20 |
| 第 2 个 | 01 双 ID | 010 存入 FIFO 1 | 0x15 | 0x120 |
| 第 3 个 | 任意 | 111 存入 Rx 缓冲区 | 0x130 | 0x04(缓冲区索引) |
| 第 4 个 | 10 位屏蔽 | 001 存入 FIFO 0 | 0x700 | 0x7CF |
第四行的掩码 0x7CF 拆成二进制是 0b11111001111,把低 4 位和高 2 位屏蔽掉,正好匹配 0x700、0x710、0x720、0x730 这四个 ID。这就是位屏蔽滤波器的典型用法:用一条规则吃掉一族 ID。
第三个滤波器有一个必须记住的规则:当 SFEC 配成「存入 Rx 缓冲区」时,SFT 的配置会被忽略。所以表里那一格写「任意」是有依据的,不是你少填了。
3.3 接收侧:两个 FIFO 加 64 个专用缓冲区怎么选
接收侧有两种机制,先说清楚它们的本质差别,选型才好落地。
Rx FIFO 0 和 Rx FIFO 1 各自最多存 64 个元素,元素大小通过 FDCAN_RXESC 里的 F0DS、F1DS 字段配置,起始地址和元素个数分别由 FDCAN_RXF0C 的 F0SA/F0S 和 FDCAN_RXF1C 的 F1SA/F1S 决定。FIFO 的位置由硬件用获取索引和放入索引自动管理,你只管读。
专用 Rx 缓冲区最多 64 个,每个只存一个元素,起始地址由 FDCAN_RXBC 的 RBSA 配置,元素大小由 FDCAN_RXESC 的 RBDS 配置。它的特点是位置由你指定的索引决定,滤波器直接引用缓冲区编号。
| 特征 | 专用 Rx 缓冲区 | Rx FIFO |
|---|---|---|
| 可配置区数量 | 64 个独立缓冲区 | 2 个 FIFO |
| 每区元素数 | 固定 1 个 | 最多 64 个 |
| RAM 中的位置 | 用户按索引指定 | 硬件用索引自动管理 |
| 满了以后 | 缓冲区被锁定,丢弃新报文 | 拦截模式丢弃,或覆盖模式挤掉最早的 |
选择依据我给一条:如果报文是「按 ID 分工况」的,每个 ID 在软件里有独立的处理上下文,用专用缓冲区,索引天然就是上下文编号,读的时候不需要判断是谁的数据;如果是「同一类报文高频突发」,长度不可预测,用 FIFO,让硬件帮你排队。我自己的项目里通常是两者混用:控制指令走专用缓冲区保证不丢,状态上报走 FIFO 消化突发。
读取流程要按顺序走,少一步就拿不到数据。以 FIFO 1 为例:
- 读 FDCAN_RXF1S 拿到状态和获取索引 F1GI;
- 算地址:消息 RAM 基址 + F1SA + F1GI × FIFO 元素大小;
- 从该地址把元素读出来;
- 读完最后一个元素后,把它的缓冲区索引写进 FDCAN_RXF1A 做应答。
第四步最容易被忘。忘了应答,FIFO 的获取索引不动,你会一直读到同一个元素,表现是「数据卡住不更新」。专用缓冲区的对应规则是:读完之后要把 FDCAN_NDAT1 或 FDCAN_NDAT2 里的对应位清零,否则那个缓冲区处于锁定状态,后续匹配的报文进不来。
3.4 发送侧:专用缓冲区、FIFO、队列三选一
发送侧的结构比接收侧复杂一点,因为它有三种机制:专用 Tx 缓冲区、Tx FIFO、Tx 队列。关键是这句:专用 Tx 缓冲区可以和后面两者之一共存,但 Tx FIFO 和 Tx 队列之间只能选一个,不能混搭。
专用 Tx 缓冲区的数量由 FDCAN_TXBC 的 NTDB 字段配置,最多 32 个,起始地址由同一寄存器的 TBSA 配置。每个专用缓冲区对应一条报文,发送请求通过 FDCAN_TXBAR 的置位触发,之后由硬件按 ID 优先级参与总线仲裁。注意最后这句:多个专用缓冲区如果配了相同的 ID,先请求的先发,但不同 ID 之间还是按优先级走。
Tx FIFO 和 Tx 队列通过 FDCAN_TXBC 的 TFQM 位选择:写 0 是 FIFO,写 1 是队列。这是两者最本质的差别所在——FIFO 按你写入的顺序往外发,谁先进谁先走,跟 ID 优先级无关;队列按 ID 优先级出队,急的报文可以插到前面。所以如果你的报文里有安全相关的紧急帧,别用 FIFO,它会老老实实排在一条 64 字节的大包后面。
Tx FIFO 的空闲水平可以从 FDCAN_TXFQS 的 TFFL 字段读出来,它是获取索引和放入索引之间的差值,也就是还能塞几个元素。下一个可写位置的地址这样算:
下一个空闲 Tx FIFO 缓冲区地址 = 消息 RAM 基址 + TBSA + TFQPI × Tx FIFO 元素大小
发送还有个可选件:Tx 事件 FIFO,最多 32 个元素,起始地址由 FDCAN_TXEFC 的 EFSA 配置。每次发送成功后,如果该 Tx 缓冲区元素里的 EFC 位为 1,硬件就把消息 ID 和本地时间戳写进事件 FIFO。要读的时候用 FDCAN_TXEFS 的 EFGI 算地址,读完写 FDCAN_TXEFA 的 EFAI 应答。它满了之后新事件会被直接丢弃,所以要么开个水印中断提前取,要么干脆别开——只在需要做发送时序分析的场景才值得占这几十个字。
4. 把 RAM 规划落到寄存器:一次从 0 到 2560 字的完整演算
4.1 一个排满 2560 字的配置样例
拿 3.1 节算过的那个配置动手配一遍。需求是:128 个 11 位滤波器、Rx FIFO 0 收 64 条 64 字节报文、专用 Rx 缓冲区 64 个各装 64 字节、专用 Tx 缓冲区 32 个各 8 字节。
| 区 | 起始偏移(字) | 元素数 | 元素大小(字) | 占用(字) |
|---|---|---|---|---|
| 11 位滤波器 | 0 | 128 | 1 | 128 |
| Rx FIFO 0 | 128 | 64 | 18 | 1152 |
| Rx 缓冲区 | 1280 | 64 | 18 | 1152 |
| Tx 缓冲区 | 2432 | 32 | 4 | 128 |
| 合计 | — | — | — | 2560 |
落到寄存器上大致是这样:
/* 消息RAM基址以器件参考手册为准,这里用宏占位,不写死 */ #define FDCAN_MSG_RAM_BASE (0x0000UL) /* 实际基址查手册对应章节 */ /* 各区起始偏移,单位是 32 位字,直接对应寄存器里的 SA 字段 */ #define OFF_SIDF 0U /* 11位滤波器区 128 字 */ #define OFF_RXF0 128U /* Rx FIFO 0 1152 字 */ #define OFF_RXBUF 1280U /* Rx 缓冲区 1152 字 */ #define OFF_TXBUF 2432U /* Tx 缓冲区 128 字 */ void fdcan_ram_init(void) { /* 1. 滤波器区起始地址 */ FDCAN_SIDFC = (OFF_SIDF << 2); /* FLSSA 字段,低两位保留 */ /* 2. Rx FIFO 0:先定元素大小,再定元素数与起始地址 */ FDCAN_RXESC &= ~(0x7U << 0); /* 清 F0DS */ FDCAN_RXESC |= (0x5U << 0); /* 数据场 64 字节对应的编码,查手册 */ FDCAN_RXF0C = (OFF_RXF0 << 2) | (64U << 16); /* F0SA + F0S=64 */ /* 3. 专用 Rx 缓冲区:元素大小 RBDS + 起始地址 RBSA */ FDCAN_RXESC &= ~(0x7U << 8); FDCAN_RXESC |= (0x5U << 8); FDCAN_RXBC = (OFF_RXBUF << 2); /* 4. 专用 Tx 缓冲区:数量 NTDB=32,起始地址 TBSA */ FDCAN_TXBC = (OFF_TXBUF << 2) | (32U << 16); /* NTDB 在 [21:16] */ }几处参数说一下。F0DS、RBDS 这类「元素大小编码」不是直接写字节数,而是写一个编码值,具体取值必须查对应型号的参考手册,我这里用 0x5 只是占位,照抄会翻车。起始地址字段一般是左移两位后写入,因为低两位保留。NTDB 和 F0S 这类计数字段在寄存器的高半部分,写入时要按位域位置左移对齐。最后一步永远是把外设的初始化请求置位,等硬件把分配好的区域清零后再退出初始化,这一步不做,读出来的全是脏数据。
4.2 滤波器顺序为什么决定成败
硬件做接受过滤时,永远从 0 号滤波器元素开始,逐个比对,在第一个匹配的元素处就停下,后面的元素不再评估。这条规则决定了一件事:滤波器元素的先后顺序本身就是逻辑。
上面那组配置里,拒绝 0x16 到 0x20 的范围滤波器排在第 1 个,是因为它必须先拦住这批报文;如果它排在最后,前面任何一条能匹配 0x18 的规则都会先命中,拒绝规则永远执行不到。反过来,如果两条规则都会命中同一个 ID,你希望哪条生效,就把它排前面。
这个机制在扩展成 128 个滤波器之后会变得很难维护,因为顺序一改,行为就变。我的做法是把滤波器配置写成一张静态表,每条规则带一个显式的优先级序号,初始化时按序号排序后依次写入硬件,而不是靠代码里的书写顺序。
顺便把一个常见误配说清楚:给某个滤波器元素把 SFEC 配成 0 就是禁用这个元素。禁用之后它仍然占着 1 个字的位置,但不会参与匹配。所以你觉得「我明明配了 128 个滤波器,怎么只生效了 30 个」的时候,先数一下有多少是禁用的。
4.3 多实例共享 RAM:2/3 与 1/3 的划分法
G474 这类器件通常不止一个 FDCAN 实例,而所有实例共享同一块消息 RAM。划分靠起始偏移地址:第一个实例从偏移 0 开始,占一部分;第二个实例从一个新的偏移开始,占剩下的部分。
假设两个实例,第一个用三分之二,第二个用三分之一,总量 2560 字,也就是 1706 和 854。第一个实例从 0 开始,各区的起始偏移都按上一节的方式在 1706 字之内排布;第二个实例的起始偏移从 1707 附近取整开始,各区的 FxSA 字段全部相对于这个新起点计算,内部再二次分配。
这里有个我踩过的坑:两个实例的地址是相对各自的起点算的,但 RAM 是同一块,所以你必须自己保证第一个实例的所有区加起来不越过第二个实例的起点。硬件不会拦你,越界了就是两个实例互相踩数据,现象是「偶尔」丢帧或者收到别人的报文,非常难查。我的习惯是在初始化代码里做一个静态断言,把每个实例的规划总量加起来,编译期就报错。
5. 排查与避坑:五条把我按在调试台上的具体记录
5.1 配置期三个不到最后不显形的坑
坑一:所有区都配了,就是一条报文都收不到。现象:初始化返回成功,总线上的波形用示波器看也正常,但接收中断一次都没进。 原因:滤波器区的起始地址配了,但滤波器元素本身没使能——SFEC 字段还是 0,等于 128 个元素全部禁用。也有另一种可能,是把标准 ID 滤波器的起始地址写进了扩展 ID 滤波器的字段,硬件按你的配置去一个空区域找规则,永远不匹配。 解决:先把滤波器数量减到 1 个,SFEC 配成「存入 FIFO 0」,确认能收到再往上加。永远从最小可工作配置开始,不要一上来就配 128 条。
坑二:帧收到了,数据长度和内容对不上。现象:能进中断,读出来的 ID 是对的,但数据只取到前 8 个字节,后面全是 0 或乱码。 原因:元素大小配置和数据场长度不匹配。接收侧按 64 字节元素存了 18 个字,你按 8 字节元素去读,只读了前 4 个字,剩下的当然读不到。反过来更隐蔽:元素按 8 字节配,报文来 64 字节,硬件截断,你读到的数据永远是残缺的,而且不报错。 解决:把 RXESC 里的元素大小编码、实际报文的最大 DLC、读取代码里的结构体长度这三者写在同一条注释里对齐,改一个就改三个。
坑三:发送请求置位了,报文永远发不出去。现象:TXBAR 写进去了,Tx 状态寄存器里的请求位一直不消失,总线上一条报文都没有。 原因:Tx 缓冲区数量和实际配置不匹配。NTDB 写了 32,但 TBSA 指向的地址区间已经被 Rx 缓冲区占了,写入的报文内容覆盖了别人的数据,硬件读到的是一条非法报文。还有一种情况是 TBSA 的起始偏移和 Rx FIFO 重叠,表现同样是「发不动」。 解决:先把 NTDB 改成 1,TBSA 指到一段绝对空闲的地址,确认单帧能发出去,再逐个往上加。每次加数量都重新算一遍总账。
5.2 运行期两个静默丢数据的坑
坑四:跑了十几分钟一切正常,然后接收突然停了,重启就好。现象:长时间运行后不再进接收中断,但总线上的报文照常,其他节点工作正常。 原因:Rx FIFO 默认是拦截模式,满了之后新报文直接被丢弃,而 FIFO 满的标志位没有进你的轮询或中断,所以你完全感知不到。等你终于读到数据时,获取索引已经落后很多,看上去像是「卡死」。 解决:把 FIFO 的水印中断打开,在还剩几个空位的时候就提前取;或者在主循环里定期读一次 FIFO 状态寄存器,把使用率当成健康指标打印出来。要求高吞吐又允许丢旧数据的场景,可以考虑开覆盖模式,但要想清楚覆盖掉的报文业务上能不能忍。
坑五:专用 Rx 缓冲区只收到第一帧,之后再也不更新。现象:某个缓冲区里第一次的数据是对的,之后该 ID 再来报文,内容一直是老的。 原因:读完之后没有把 FDCAN_NDAT1/NDAT2 里的对应位清零。只要这一位置着,对应的 Rx 缓冲区就是锁定状态,硬件会拒绝往里面写新报文,也不会通知你。 解决:读数据的函数和清标志的动作绑在一起写,读一个清一个,别靠「等会儿统一清」。我后来干脆把这两步封在同一个函数里,返回值就是数据,调用方没有机会忘。
6. 收在一个上线前必须走完的核对动作上
上面这些内容摊开写了不少,但真到项目里,能救命的往往不是记住多少个公式,而是每次改完 FDCAN 配置都强制走一遍固定的核对动作。我现在的习惯是把下面这张表贴在调试记录的最前面,改一个参数就从头对一遍,哪怕只改了一个元素大小。
| 核对项 | 检查方式 | 出问题的典型表现 |
|---|---|---|
| 各区起始偏移不重叠 | 手算一遍累计偏移,和规划表对 | 数据互相踩踏,偶发丢帧 |
| 各区总和不超 2560 字 | 用脚本把每个区加起来 | 越界写到别人的区 |
| 多实例划分不越过边界 | 静态断言,编译期报错 | 两个实例互相覆盖 |
| 元素大小与实际 DLC 匹配 | 结构体长度和编码写在同一处注释 | 数据截断或读不全 |
| 滤波器顺序与优先级一致 | 静态表带序号,排序后写入 | 拒绝规则不生效 |
| FIFO 读完有应答 | 读和应答写在同一个函数 | 一直读到同一帧 |
| NDAT 位读后清零 | 封装成单一函数 | 缓冲区只更一次 |
| Tx 事件 FIFO 溢出水印 | 开中断或直接不用 | 事件静默丢失 |
这张表里有一行是我交了学费才加进去的,就是「多实例划分不越过边界」。当时两个 FDCAN 实例共用一块 RAM,第一个实例的 Rx 缓冲区规划大了,悄悄侵入了第二个实例的滤波区,现象是第二个实例偶尔收到一些莫名其妙的 ID,重启一次又好,查了整整两天。从那以后我每次配 FDCAN,不管是单实例还是多实例,都强制先把所有区的偏移和大小写成一张表,用编译期断言把总账卡死,然后才动寄存器。慢十分钟,省两天。
如果你手上也正拿着一份 FDCAN 的配置改来改去,建议先别碰寄存器,把那张偏移表填完、脚本跑通、把 2560 这个数对上,再开始写代码。这份应用笔记里那些 RAM 映射图和元素大小表,价值也正在这里——它不是让你读懂协议,而是让你在动手之前就能把账算平。希望帮到你。
本文还有配套的精品资源,点击获取