GD32H759 RT-Thread CAN工控实战:位时序与Bus-Off恢复

📅 发布时间:2026/9/17 8:03:01
GD32H759 RT-Thread CAN工控实战:位时序与Bus-Off恢复
搞工控现场的朋友对 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_find、rt_device_open、rt_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、相位缓冲段 1BS1、相位缓冲段 2BS2。采样点就落在 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_SEG1BS115BS24加起来刚好 20。采样点 115/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 帧占用 26ms10 个节点就是 260ms。负载率 260 / 1000 26%还算健康。如果负载率超过 50%延迟就开始明显超过 70%低优先级帧延迟可能到几十毫秒超过 80%基本就是在临界线跳舞丢帧是必然的。负载率状态建议 30%健康可长期运行30%~50%正常注意低优先级帧延迟50%~70%偏高需优化帧频率或拆分总线 70%危险必须重新设计我在实际项目中一般把负载率控制在 40% 以内留足余量应对突发。工控设备偶尔会有事件触发一堆报文如果平时就 60% 了突发时直接堵死。3. 从裸机到 RT-ThreadCAN 驱动对接全流程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%中断方式接收丢帧率为 0CPU 占用大概 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 的定时器和消息队列怎么做多任务调度到时候再细说。