GD32H759 RT-Thread CAN工控实战:位时序与Bus-Off恢复
2026/9/17 8:03:00 网站建设 项目流程

搞工控现场的朋友对 CAN 总线都不陌生,它就像设备之间那条"看不见的传送带",谁发谁收、优先级怎么排、出错怎么兜底,全在这一对差分线上完成。这次我拿 GD32H759 这颗 Cortex-M7 内核的高性能 MCU,配上 RT-Thread 这套国产实时操作系统,把一个典型的 CAN 通信节点从零搭起来,顺手把踩过的坑和实测数据整理出来。整篇是"GD32H759 + RT-Thread 工控实战"系列的第一篇,只聊 CAN 总线这一件事,后面还会有 Modbus、以太网、文件系统那些。适合已经会点 C 语言、摸过单片机、但没在工控场景里认真做过 CAN 的朋友,也适合正在选型、纠结用中断还是 DMA 收报文的人。我不会讲太多教科书定义,重点是"为什么这么配、参数怎么算、现场会出什么问题",能直接抄作业的地方我都把代码和配置贴出来了。

1. 为什么这个工控节点我选了 GD32H759 加 RT-Thread 这套组合

1.1 选型时我到底在权衡什么

工控设备跟消费电子最大的区别,是"它得连轴转好几年,还不能挂"。所以选 MCU 我第一看外设够不够硬、第二看主频有没有余量、第三看生态能不能省事。GD32H759 这颗片子用的是 Cortex-M7 内核,主频拉到 600MHz,带双精度浮点单元和指令数据缓存,片上 SRAM 给得也大方,跑 RTOS 加上协议栈完全不虚。CAN 这块它给到多路独立的 CAN-FD 控制器,兼容经典 CAN,收发 FIFO 深度也够,做多节点组网时不用外扩控制器,这一点对工控板卡很关键——PCB 面积和 BOM 成本都能压下来。

RT-Thread 这边我选它的原因更实际:它的设备驱动框架把 CAN 抽象成了标准设备,rt_device_findrt_device_openrt_device_write一套下来,业务代码动都不用动就能换底层。现场调试的时候你经常要临时改过滤规则、改波特率,有这层框架顶着,改起来就是改几个参数的事,不用把整个底层驱动翻一遍。这套组合放到工控场景里,等于"硬件性能有冗余、软件维护成本低",长期看是划算的。

1.2 GD32H759 的 CAN 资源摊开来看

不说虚的,直接把跟 CAN 相关的资源列出来。GD32H759 集成了多路 CAN-FD 控制器,每一路都有自己的发送 FIFO、接收 FIFO 和一组可配置的过滤器。CAN-FD 相比经典 CAN 最大的改动是数据段可以变速、单帧最多 64 字节数据,这在工控里很有用——比如一次要下发一批参数,经典 CAN 得拆成七八帧,CAN-FD 一帧就搞定,总线负载和延迟都下来了。

不过要注意,CAN-FD 是向下兼容经典 CAN 的,但你不能指望一条总线上既有 CAN-FD 节点又有纯经典 CAN 节点还能跑满性能。仲裁段大家用同一个速率,数据段如果 FD 节点提速了,经典 CAN 节点会直接报格式错误。所以现场规划总线的时候,要么整条线统一用经典 CAN,要么统一上 CAN-FD,别混着来。我一般做新项目就直接按 CAN-FD 规划,速率留够余量,后面加节点不慌。

过滤器这块值得单独说。GD32 的 CAN 过滤器支持掩码模式和列表模式,可以按 ID 精确匹配也可以按位屏蔽匹配。工控现场往往一条总线上挂十几二十个节点,如果不过滤全收,CPU 光处理中断就够呛。所以过滤器配置是必须认真做的一步,后面第 2 章我会细讲怎么算、怎么配。

1.3 RT-Thread 的 CAN 设备框架为什么省事

RT-Thread 的 CAN 框架本质上是一层"翻译官"。你的业务逻辑只管调用标准接口发数据、收数据,具体是哪个寄存器、哪路 FIFO、怎么触发中断,全交给底层驱动。这带来的好处是代码可移植性极强——今天用 GD32H759,明天换个别的国产 MCU,只要把底层驱动对接好,上层业务一行不用改。

框架里收发模型是这么设计的:发送走rt_device_write,底层把报文塞进硬件发送 FIFO 就返回,真正的发送完成可以配回调;接收一般走信号量或者消息队列,中断里把报文取出来挂到消息队列上,再由专门的接收线程去消费。这种"中断干活少、线程干活多"的设计是 RTOS 编程的经典套路,目的就是把中断处理时间压到最短,避免高优先级中断阻塞整个系统。

我在实际用下来发现,RT-Thread 的 CAN 框架对错误处理的支持需要自己补一些东西。比如总线关闭(Bus-Off)之后怎么自动恢复,框架本身给的不多,得自己在驱动里加恢复逻辑。这块是工控现场最容易翻车的地方,第 5 章会重点讲。

2. CAN 总线在工控现场的核心参数到底怎么定

2.1 位时序和波特率我是这么算的

CAN 是异步总线,没有时钟线,靠位时序来采样。一个位被切成若干时间份额(Time Quantum,简称 tq),通常分成四段:同步段(SYNC_SEG)、传播段(PROP_SEG)、相位缓冲段 1(BS1)、相位缓冲段 2(BS2)。采样点就落在 BS1 和 BS2 的交界处,采样点的位置直接决定通信稳不稳。

计算公式很简单:

  • 波特率 = CAN 时钟频率 / (BRP × (1 + BS1 + BS2))
  • 采样点位置 = (1 + BS1) / (1 + BS1 + BS2)

拿 GD32H759 举例,假设 CAN 外设时钟配到 50MHz,我目标是 500kbps。50MHz / 500kHz = 100,也就是一个位要 100 个 tq。BRP 取 5,那么每个位就是 20 个 tq,再拆:SYNC_SEG=1,BS1=15,BS2=4,加起来刚好 20。采样点 =(1+15)/20 = 80%,落在推荐范围内。

目标波特率CAN 时钟BRPBS1BS2采样点
1Mbps50MHz57280%
500kbps50MHz515480%
250kbps50MHz531880%
125kbps50MHz1031880%

采样点一般建议放在 75%~87.5% 之间。80% 是个万能值,绝大多数场景都能跑稳。如果现场线缆特别长、干扰大,可以把采样点往后挪一点,比如 85%,给传播延迟留更多余量。但别挪太靠后,否则相位缓冲段 2 太短,重同步能力会变差。

注意:总线上所有节点的波特率必须完全一致,采样点也建议尽量统一。如果一端 80% 一端 87.5%,短距离没事,长距离就会零星出现错误帧,非常难查。

2.2 过滤器配置:别让无关报文冲垮你的 CPU

工控现场最不缺的就是报文。一条总线上可能有 PLC、伺服、传感器、HMI 同时发数据,如果你的节点不过滤全收,CPU 会被大量无关中断拖垮。所以过滤器的核心思路是"只收我关心的,其余硬件直接丢"。

GD32 的 CAN 过滤器有两种模式:掩码模式(Mask)和列表模式(List)。掩码模式是"ID 里的某些位必须匹配,其余位不管",适合收一组连续 ID;列表模式是"必须精确等于列表里的某个 ID",适合收几个离散的固定 ID。

举个实际例子。假设我这条总线上,伺服状态帧的 ID 是 0x180~0x187,我只需要收这一段。配置思路是把 ID 的高 7 位(0x180 到 0x187 只差最低 3 位)作为匹配域,低 3 位作为屏蔽域:

/* 掩码模式:只关心 ID 的高位,低 3 位不比较 */ can_filter_parameter_struct filter; filter.filter_number = 0; filter.filter_mode = CAN_FILTERMODE_MASK; filter.filter_bits = CAN_FILTERBITS_32BIT; filter.filter_list_high = 0x180 << 5; /* 标准帧 ID 左移 */ filter.filter_list_low = 0x0000; filter.filter_mask_high = 0x7F8 << 5; /* 高 7 位匹配,低 3 位屏蔽 */ filter.filter_mask_low = 0x0000; filter.filter_fifo_number = CAN_FIFO0; filter.filter_enable = ENABLE; can_filter_init(&filter);

这里0x7F8是二进制11111111000,意思是 ID 的高 7 位必须匹配,最低 3 位随便。这样 0x180 到 0x187 的帧都会被硬件收进 FIFO0,其余 ID 全部丢弃。CPU 一次中断都没被吵到,效率高很多。

2.3 负载率算清楚再上总线

工控项目里我见过太多人拍脑袋挂节点,结果总线负载率飙到 80% 以上,通信偶尔丢帧还找不到原因。负载率一定要提前算。

经典 CAN 标准帧一帧大约 111 位(不含位填充),加上位填充,最坏情况要考虑放大到 130 位左右。扩展帧约 131 位,最坏约 150 位。计算方法:

  • 单帧位时间 = 帧位数 / 波特率
  • 每帧占用率 = 单帧位时间 × 帧频率
  • 总负载率 = Σ 所有帧的占用率

举个例子:500kbps 总线上,有 10 个节点,每个每秒发 100 帧标准帧。每帧按 130 位算,单帧时间 = 130 / 500000 = 260µs。每节点每秒 100 帧,占用 26ms,10 个节点就是 260ms。负载率 = 260 / 1000 = 26%,还算健康。

如果负载率超过 50%,延迟就开始明显;超过 70%,低优先级帧延迟可能到几十毫秒;超过 80%,基本就是在临界线跳舞,丢帧是必然的。

负载率状态建议
< 30%健康可长期运行
30%~50%正常注意低优先级帧延迟
50%~70%偏高需优化帧频率或拆分总线
> 70%危险必须重新设计

我在实际项目中一般把负载率控制在 40% 以内,留足余量应对突发。工控设备偶尔会有事件触发一堆报文,如果平时就 60% 了,突发时直接堵死。

3. 从裸机到 RT-Thread:CAN 驱动对接全流程

3.1 时钟树和引脚复用先理清

GD32H759 的 CAN 外设挂在 APB 总线上,用之前得先把时钟打开,还要把对应的 GPIO 复用到 CAN 功能上。CAN 收发是差分信号,RX 和 TX 两个引脚,通常配成复用推挽输出(TX)和浮空输入或上拉输入(RX)。

/* 打开 CAN0 和 GPIO 时钟 */ rcu_periph_clock_enable(RCU_CAN0); rcu_periph_clock_enable(RCU_GPIOB); /* PB8 -> CAN0_RX, PB9 -> CAN0_TX (具体按你板子调整) */ gpio_af_set(GPIOB, GPIO_AF_2, GPIO_PIN_8 | GPIO_PIN_9); gpio_mode_set(GPIOB, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_8 | GPIO_PIN_9); gpio_output_options_set(GPIOB, GPIO_OTYPE_PP, GPIO_OSPEED_60MHZ, GPIO_PIN_8 | GPIO_PIN_9);

这里有个坑我踩过:GPIO 的复用编号(AF 号)一定要查对应型号的手册,不同型号同一个引脚可能复用号不一样。另外 RX 引脚的上拉建议开着,总线空闲时能保证是隐性电平,减少误触发。

3.2 CAN 外设初始化和过滤器落地代码

时钟和引脚弄好,接下来初始化 CAN 控制器本体。关键是位时序参数、工作模式、自动重传这些配置。

can_parameter_struct can_init_struct; can_deinit(CAN0); can_struct_para_init(&can_init_struct); can_init_struct.time_triggered = DISABLE; can_init_struct.auto_bus_off_recovery = ENABLE; /* 自动恢复 */ can_init_struct.auto_wake_up = DISABLE; can_init_struct.no_auto_retrans = DISABLE; /* 允许自动重传 */ can_init_struct.rec_fifo_overwrite = DISABLE; /* FIFO 满不覆盖 */ can_init_struct.trans_fifo_order = DISABLE; can_init_struct.working_mode = CAN_NORMAL_MODE; can_init_struct.resync_jump_width = CAN_BT_SJW_1TQ; can_init_struct.time_segment_1 = CAN_BT_BS1_15TQ; can_init_struct.time_segment_2 = CAN_BT_BS2_4TQ; can_init_struct.prescaler = 5; can_init(CAN0, &can_init_struct);

注意auto_bus_off_recovery这个参数,开了之后硬件在检测到总线关闭时会自动尝试恢复,省了我们不少事。但硬件自动恢复有个问题:它不告诉你"什么时候恢复的、恢复失败没有",如果现场总线持续故障,节点会反复进出 Bus-Off,你根本不知道。所以我在关键项目里会关掉硬件自动恢复,改由软件自己管,这样每次恢复都能记录日志,第 5 章细说。

3.3 挂到 RT-Thread CAN 设备框架上

裸机的 CAN 初始化写完了,还得让它跟 RT-Thread 的设备框架接上。RT-Thread 里 CAN 设备通过rt_can_device结构体注册,核心是提供几个回调:配置、发送、接收。

static rt_err_t drv_can_configure(rt_can_device_t *can, void *cfg) { struct can_configure *conf = (struct can_configure *)cfg; /* 根据 conf->baud_rate 重新配置位时序 */ can_baudrate_set(CAN0, conf->baud_rate); return RT_EOK; } static rt_size_t drv_can_sendmsg(rt_can_device_t *can, const void *buf, rt_uint32_t boxno) { const struct rt_can_msg *msg = (const struct rt_can_msg *)buf; /* 把 msg 塞进硬件发送邮箱 */ can_transmit(CAN0, msg->id, (can_ide_enum)(msg->ide), (can_rtre_enum)(msg->rtr), msg->len, msg->data); return 1; } static int drv_can_recvmsg(rt_can_device_t *can, void *buf, rt_uint32_t boxno) { /* 从硬件 FIFO 取出报文填到 buf */ /* 通常在这里做中断里的搬运 */ return 1; }

注册的时候把这些回调填进rt_can_ops,然后调用rt_hw_can_register。之后上层就能用rt_device_find("can0")找到设备,用rt_device_write发送。

3.4 收发的线程模型怎么设计

这块是我最想强调的。很多人从裸机转 RTOS,习惯在中断里把活全干完,结果系统一忙就出问题。RT-Thread 下的正确姿势是:中断里只做"搬运",即把硬件 FIFO 的报文读到一个缓冲区,然后释放信号量或往消息队列里扔一条消息,真正的解析和处理放到专门的接收线程里。

发送端则相反,发送一般不需要专门的线程,业务线程拿到数据直接rt_device_write就行。但如果发送频率很高,建议加一个发送队列,由一个发送线程统一出队发送,避免多个线程同时抢 CAN 发送邮箱。

/* 接收线程示例 */ static void can_rx_thread_entry(void *param) { struct rt_can_msg rxmsg; while (1) { /* 阻塞等信号量,超时用 RT_WAITING_FOREVER */ rt_sem_take(&can_rx_sem, RT_WAITING_FOREVER); while (drv_can_recvmsg(&can0_dev, &rxmsg, 0) > 0) { can_frame_dispatch(&rxmsg); /* 业务分发 */ } } }

这套模型的好处是把中断处理时间压到最短,即使业务处理慢,也不会丢帧——只要消息队列够大、接收线程优先级够高。我在实际项目中给接收线程的优先级一般设得比较高,但低于系统关键任务,保证它能及时消费。

4. 中断接收还是 DMA 接收:实测数据说话

4.1 中断接收的写法与实测

这是问得最多的一个问题。先说结论:在 500kbps 及以下、报文量中等(每节点每秒几百帧以内)的工控场景,中断接收完全够用,没必要为了 DMA 而 DMA。DMA 适合的是高波特率、大报文量的场景,比如 CAN-FD 下 5Mbps 数据段,那个量级中断真扛不住。

中断接收的写法很直接:在 CAN 接收中断服务函数里读 FIFO,读一条释放一次信号量。关键是中断优先级和中断服务时间要控制好。

void CAN0_RX0_IRQHandler(void) { rt_interrupt_enter(); if (can_interrupt_flag_get(CAN0, CAN_INT_FLAG_RF0N)) { can_interrupt_flag_clear(CAN0, CAN_INT_FLAG_RF0N); rt_sem_release(&can_rx_sem); /* 只通知,不搬运 */ } rt_interrupt_leave(); }

我实测下来,500kbps 下满负载(负载率约 70%),中断方式接收丢帧率为 0,CPU 占用大概 8%~12%,取决于报文大小和线程调度。这个开销对 600MHz 的 M7 来说压力很小。真正的问题不在中断本身,而在于你中断里干了多少事。如果中断里直接解析报文、做业务运算,那就很容易丢帧了。所以核心原则永远是"中断搬运、线程处理"。

提示:中断接收的 FIFO 深度有限,如果接收线程消费不及时,FIFO 会满。配置rec_fifo_overwrite决定满了之后是覆盖旧数据还是丢弃新数据。工控里我更倾向丢新保旧,因为旧数据往往是状态基线,新数据刷新一下还有。

4.2 DMA 接收的写法与实测

再来看 DMA。DMA 接收的思路是让 DMA 把 CAN 接收 FIFO 的数据直接搬到内存缓冲区,CPU 只在缓冲区满或半满时被通知一次,然后批量处理。这样中断次数大幅降低,适合高频场景。

不过 CAN 的 DMA 比串口、SPI 那些麻烦,因为 CAN 是"消息"型外设,不是连续字节流,每条报文长度还不一样(标准帧最长 8 字节数据,FD 帧最长 64 字节)。所以 DMA 搬的时候得按固定步长搬,或者配合 CAN 的 FIFO 管理寄存器一起用。GD32H759 这边,CAN 接收可以触发 DMA 请求,把报文按邮箱单位搬到一块 SRAM 缓冲区。

/* DMA 配置思路(伪代码,具体寄存器按手册) */ dma_deinit(DMA0, DMA_CH3); dma_single_data_parameter_struct dma_init; dma_init.periph_addr = (uint32_t)&CAN_RFIFO0(CAN0); /* 接收 FIFO */ dma_init.memory0_addr = (uint32_t)can_dma_buffer; dma_init.direction = DMA_PERIPH_TO_MEMORY; dma_init.number = CAN_DMA_BUF_FRAMES; dma_init.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init.periph_width = DMA_PERIPH_WIDTH_32BIT; dma_init.memory_width = DMA_MEMORY_WIDTH_32BIT; dma_init.priority = DMA_PRIORITY_HIGH; dma_single_data_mode_init(DMA0, DMA_CH3, &dma_init); dma_circulation_enable(DMA0, DMA_CH3); dma_channel_enable(DMA0, DMA_CH3);

实测下来,在 1Mbps 负载率 60% 的场景里,DMA 接收的 CPU 占用能从中断方式的 20% 左右降到 6%~8%,效果确实明显。但代价是代码复杂度上去了,缓冲区管理、半满中断处理、报文边界对齐这些都要额外处理,出错几率也高。

4.3 两种方式的取舍

我整理了一张对比表,实际选型的时候直接看这个:

维度中断接收DMA 接收
适用波特率≤ 1Mbps≥ 500kbps,尤其 CAN-FD
适用报文密度中低(< 每秒 500 帧)高(每秒 1000 帧以上)
CPU 占用8%~20%6%~10%
代码复杂度
调试难度
内存开销需要一块 DMA 缓冲区
推荐场景常规工控节点网关、数据采集密集节点

我的建议是:新项目没特殊需求就先用中断,跑起来测一下 CPU 和丢帧,如果都达标就别上 DMA。只有当报文量真的把 CPU 顶到 30% 以上,或者你要做 CAN 网关需要转发大量报文时,再上 DMA。为了"看起来高级"就上 DMA,后期调试的苦头只有自己吃。

5. 错误帧、总线关闭和恢复:现场最容易翻车的地方

5.1 错误计数器和错误帧到底怎么回事

CAN 有个很强的设计:每个节点都维护两个错误计数器,发送错误计数器(TEC)和接收错误计数器(REC)。每发错一次 TEC 加 8,每收错一次 REC 加 1,成功一次相应减 1。TEC 超过 255 节点进入 Bus-Off,也就是彻底断开总线,不再发送。

错误帧本身是一段特殊格式:由 6 个连续的显性位组成,故意破坏位填充规则,让总线上所有节点都能检测到"有人出错了"。错误帧分主动错误帧和被动错误帧,主动错误的节点会发 6 个显性位强提醒大家,被动错误的节点只能发 6 个隐性位。错误计数超过 127 就变成被动错误状态。

工控现场错误帧频繁,通常就几个原因:终端电阻没接或阻值不对(应该是 120Ω 两端各一个)、波特率不一致、线缆屏蔽没做好、共模干扰大。我见过一个现场,两个节点距离 80 米,用了普通双绞线还没加隔离,干扰一来错误帧一堆,最后加了共模电感和隔离收发器才稳定。

注意:终端电阻必须加在总线的物理两端,中间节点不要加。很多人以为每个节点都加一个更稳,其实错了,中间节点加电阻会降低总线阻抗,反而更容易出错。

5.2 Bus-Off 恢复的软件实现

硬件自动恢复省事但不可控,我更喜欢软件自己管。思路是:进 Bus-Off 中断后,设置一个标志,由一个监控线程在延迟一段时间后尝试恢复。恢复前先检查总线状态,连续若干次恢复失败就上报故障。

/* Bus-Off 中断里只置标志 */ void can_busoff_isr(void) { rt_event_send(&can_event, CAN_EVT_BUSOFF); } /* 监控线程处理恢复 */ static void can_monitor_thread(void *param) { rt_uint32_t evt; while (1) { if (rt_event_recv(&can_event, CAN_EVT_BUSOFF, RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR, RT_WAITING_FOREVER, &evt) == RT_EOK) { rt_thread_mdelay(100); /* 等总线安静下来 */ if (can_recover(CAN0) != RT_EOK) { LOG_E("CAN bus-off recovery failed"); /* 上报故障、点亮故障灯 */ } } } }

恢复的关键是延迟时间。总线持续短路或严重干扰时,立即恢复会立刻再次 Bus-Off,反复进出反而不利于排查。100ms 是个经验值,够总线安静,又不至于影响业务太久。

5.3 常见问题速查表

现场排查我总结了这张表,基本覆盖了 90% 的问题:

现象可能原因排查方法
完全收不到报文终端电阻缺失、接线反了万用表测终端电阻,正常约 60Ω
零星错误帧波特率不一致、屏蔽没接示波器看波形,核对两端位时序
通信时好时坏干扰大、地线没共地检查屏蔽层单点接地、加隔离
一段时间后失联Bus-Off 未恢复检查 TEC/REC、加恢复逻辑
高频时丢帧FIFO 溢出、线程消费慢查看 FIFO 溢出标志、提升线程优先级
发送总失败无应答、自动重传耗尽查总线上是否有其他节点应答
CAN-FD 帧报格式错误总线上有经典 CAN 节点统一总线协议版本

排查顺序我一般是"先物理、后参数、最后软件"。物理层(电阻、线缆、接地)出问题的概率远远高于软件,先花五分钟量一下电阻,比盯半天代码管用。

5.4 几个反直觉的实操细节

最后分享几个我踩坑踩出来的细节。第一,CAN 的发送邮箱只有三个,如果你短时间连续发多条,第四帧开始就要等待,rt_device_write会阻塞或者失败,所以高频发送一定要配发送队列而不是循环直发。第二,接收 FIFO 溢出标志你最好定期读一下并清零,这个标志能帮你量化丢帧情况,比事后猜要靠谱。第三,调试初期建议把 CAN 设为回环模式(Loopback),一个节点自己收发自己,验证驱动写对没有,避免一上来就是硬件问题干扰判断。第四,屏蔽双绞线的屏蔽层要单点接地,两端都接地容易形成地环流,反而引入干扰——这个和很多人的直觉相反,但我在几个现场验证过,单点接地确实更稳。

我个人在实际操作中的体会是,CAN 这东西"硬件占七分、软件占三分"。驱动写得再漂亮,物理层没弄好一样白搭。所以每次新板子回来,我第一件事就是量终端电阻、看波形,确认物理层没问题再动软件。这套习惯帮我省了无数个加班的夜晚。下一篇我会接着聊 GD32H759 上 RT-Thread 的定时器和消息队列怎么做多任务调度,到时候再细说。

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

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

立即咨询