聊到GD32,很多从51或者STM32转过来的朋友,第一反应都是“这不就是个国产替代嘛”。确实,GD32在很多引脚和底层寄存器上跟STM32有千丝万缕的关系,但只要真上手做过项目,你会发现区别远不止“换个Logo”这么简单,尤其是CAN总线这种对时序和稳定性要求很高的外设。
我最早接触GD32的CAN总线,是在一个车载仪表盘配套项目里。当时手里的样片是GD32F103系列,主控负责跟车身控制器通信,跑的是500kbps的CAN总线。说实话,第一次调的时候还是踩了不少坑——不是移植了STM32的库就万事大吉,时钟树不一样、复用映射不一样、库函数细节也有差异。这篇文章我把从环境搭建到源码配置的完整思路写出来,结合我自己实测过的工程配置,希望能帮你少走点弯路。
这篇文章适合谁?手里有GD32开发板、想跑通CAN通信但还没头绪的人;准备评估GD32的性价比、想看看CAN外设是否好上手的工程师;以及项目进度紧、需要快速从零把CAN调通的单片机开发者。我会按从硬件到软件、从原理到代码的顺序讲,尽量让不同基础的读者都能拿着直接用。
1. 内容整体设计与思路拆解
先定一个总体规划。玩转CAN总线,不正确理解为你只是调试一个“串口高级版”。CAN总线的难点在于:它是一套完整的、多层协议栈支撑的通信方案,涉及硬件收发器、控制器寄存器配置、位时序计算、报文过滤、错误处理等等。如果你的思路只是“发数据、收数据”,那后面遇到问题会非常难查。
1.1 项目功能定位与系统组成
一个最小的GD32 CAN通信系统,包括这样几部分:
- 一个带CAN控制器的GD32芯片,比如GD32F103、GD32F303、GD32F450
- 一个CAN收发器芯片,典型的是TJA1050、MCP2551或者SN65HVD230
- 一根双绞线,两端匹配120欧终端电阻
- 一个上位机或另一个CAN节点,用来验证收发
单片机的CAN控制器负责协议处理、滤波、错误检测,但它的电气信号是TTL级别的CAN_TX和CAN_RX,不是差分信号,所以必须外接CAN收发器,把TTL电平转成CAN_H和CAN_L的差分信号。很多新手在这里栽跟头——拿示波器去点CAN_TX引脚,看到了方波就以为信号是对的,其实总线上的波形完全不一样。
1.2 技术选型解析:为什么选GD32而不直接用STM32
既然很多代码都是兼容的,为什么要专门用GD32做项目?我个人的看法是:成本、供货和本地化支持这三个因素排在最前面。
GD32F103系列在功能上与同封装的STM32F103高度相似,但主频更高,比如GD32F103的最高主频能到108MHz(STM32F103是72MHz),Flash和SRAM的配比也有差异。更重要的是,GD32的生态已经比较完善了,官方提供了GD32F10x固件库、GD32 Embedded Builder集成环境,还有适配Keil、IAR的Pack包,基本能无缝上手。
当然,这里要强调一句:不能把GD32和STM32当成孪生兄弟看,在所有开发中都直接套用。我先提三个自己踩过的坑,后面再详细讲:
- 时钟树不同。GD32的PLL倍频配置跟ST的库函数不通用,系统主频不同会导致CAN位时序的预分频设置完全不同。
- GPIO复用映射有差异。CAN的某些引脚在这个型号上映射到了别的复用功能上,你如果不查数据手册,盲目按STM32的引脚配置,可能发不出去数据。
- 库函数API细节有变化。比如GD32的can_parameter_struct、can_trasmit_message_struct这些结构体字段,和ST的HAL库有明显差别,直接替换不了。
所以这篇文章里的所有源码,我都会用GD32官方的标准外设库来写,不是拿ST改改名就贴上来,这点请你放心。
2. 硬件准备与协议原理先行:CAN总线的基础认知
软件写得再好,硬件接错了,信号也出不去。反过来,如果对CAN协议没有基本认知,你也看不懂发送和接收状态寄存器到底在报什么错。这两个部分必须结合起来学。
2.1 硬件连接的常见做法
给一个非常标准的节点连接方式,以GD32F103 + TJA1050为例:
GD32_PB8 (CAN0_RX) -> TJA1050 RXD GD32_PB9 (CAN0_TX) -> TJA1050 TXD TJA1050 CANH -> 总线CANH线 TJA1050 CANL -> 总线CANL线 TJA1050 VCC -> 5V(注意有的板子是3.3V版本) TJA1050 GND -> 公共地两个终端电阻,每个节点不一定都要放,但在总线两端必须各有一个120欧电阻。很多实验板会把终端电阻直接做在板子上,如果你用两个带120欧电阻的板子相互通信,就等于并了60欧,会导致信号反射,通信质量反而变差。这时候你就得飞线把其中一个电阻断开。
收发器选型上,我做了一个简单对照:
| 型号 | 供电电压 | 速率 | 特点 |
|---|---|---|---|
| TJA1050 | 5V | 最高1Mbps | 最经典,应用广泛,但要注意5V与MCU电平匹配 |
| MCP2551 | 5V | 最高1Mbps | Microchip家的老牌产品,耐压高 |
| SN65HVD230 | 3.3V | 最高1Mbps | 适合3.3V单片机,省去电平转换 |
| TJA1042 | 3.3V/5V | 最高1Mbps | 低功耗待机改进版 |
GD32的IO耐压情况跟具体型号有关,如果你用5V的TJA1050,建议确认GPIO配置为开漏输出并接上拉,或者干脆选3.3V的收发器,更省心。我自己做实验最常用SN65HVD230,一根杜邦线连GD32开发板,不用操心电平问题。
2.2 CAN协议中的关键概念
有人说CAN协议复杂,我倒觉得它的核心逻辑特别像公司开会发言规则:
- 所有人都能说话,但发言前先“听”总线上有没有人在说
- 如果同时有人抢麦,优先级最高的ID获胜,其他人自动闭嘴等下一轮
- 消息发出去之后,说的人要同时听,如果听到的内容和自己说的对不上,就知道出错了
技术上对应几个概念:
帧类型。日常用得最多的是数据帧,用来发真实数据。远程帧用来请求某个节点发送数据,有点像“点对点催更”。错误帧和过载帧是节点在异常时主动发出的,你可以不会主动构造它们,但要能通过错误状态寄存器判断是什么情况导致了错误帧。
仲裁机制。CAN总线上每个帧都有ID,ID数值越小优先级越高。当两个节点同时发数据,CAN控制器逐位比较电平,显性电平(逻辑0)会覆盖隐性电平(逻辑1),所以ID小的节点最终抢占总线。这也是为什么工程上通常把控制类消息的ID设得很小,比如0x001这种。
报文过滤。接收方不需要处理总线上所有报文,可以通过验收屏蔽寄存器,只接收关心ID的报文。这个功能放到GD32里就是CAN_FIFO的filter配置,可以配置成列表模式或掩码模式。
错误处理。CAN节点有三个状态:主动错误、被动错误、离线。如果一个节点错误累计值过高,它会自己退出总线,不影响其他节点通信。很多朋友调试时发现“只有我这块板子收不到数据,其他节点正常”,最可能就是这个节点进入了Bus-Off状态。
以上概念不必一次全懂,先知道这些名词对应的大概场景,后面结合寄存器配置再回头看,会感觉通透得多。
3. 工程环境搭建:从零跑通第一个CAN实例
这部分我不讲太多理论,直接把从开箱到第一个CAN Demo跑通的流程过一遍。环境不同,细节会有点差别,但通用逻辑一致。
3.1 开发环境与固件库准备
我常用的方案是Keil MDK配合GD32官方固件库。很多人问为什么不用GD32 Embedded Builder,其实那个工具官方做了很久,界面类似STM32CubeMX,生成代码的速度很快,但初学阶段我还是推荐Keil,原因很简单:网上资料多、调试器兼容性好、出问题好查。
需要准备的软件包:
- Keil MDK 5.3x以上版本,装好对应器件支持包
- GD32F10x固件库(从GD官网下载,或者直接在Gitee/GitHub上找官方镜像)
- J-Link或者DAP-Link驱动,建议先用DAP-Link,便宜且供电方便
安装GD32器件Pack这一步,很多人会卡住。我建议不要直接双击Pack文件,而是打开Keil的Pack Installer,在“File -> Import”里导入GD32官方提供的Pack,这样可以自动处理依赖。有些旧版本Pack在导入时提示缺CMSIS版本,你就顺手把CMSIS的Pack也装一下。
3.2 新建工程时的关键配置
我在Keil里新建GD32工程的固定套路:
- 在Project窗口里新建两个组:
Library(存放固件库源文件和核心文件)、User(存放main.c等应用代码)。 - 把固件库里的
gd32f10x_can.c、gd32f10x_gpio.c、gd32f10x_rcu.c、gd32f10x_usart.c(调试用)添加进去。 - 在C/C++选项卡里添加宏定义,这个很关键,比如我用的是GD32F103系列,那么需要定义
GD32F10X_MD或者根据型号选择,具体看固件库手册,不定义这个宏会导致system_gd32f10x.c编译出错。 - 选择调试器的Flash下载算法,在“Utilities -> Settings”里加载对应型号的Programming Algorithm,如果算法不对,下载时就会提示
No Algorithm found,不解决这个问题后面烧程序都是空谈。
3.3 J-Link烧录GD32的注意事项
用J-Link给GD32烧录,有一个小坑:J-Link默认可能将芯片识别成STM32F103,此时能连上内核,但写到Flash时会出错。解决办法是给J-Link添加GD32的Device描述,或者在Keil的Flash Download里配置GD32的算法文件。
如果你只是想快速测试,也可以用串口烧录。GD32支持串口ISP模式,BOOT0拉高,通过串口把HEX文件送进去。但这种方式适合固化程序,调试阶段还是得用调试器,否则printf是个大麻烦。
4. 核心源码解析:GD32的CAN外设配置与数据收发
下面进入正题。这段代码我直接给了完整的初始化、发送和接收逻辑。其实代码本身不复杂,复杂的是理解这个外设的工作逻辑。我会把每一步涉及到的寄存器构造讲清楚,而不是只让你抄代码。
4.1 初始化GPIO与CAN外设时钟
在GD32里,外设使用前必须先开时钟。很多问题就是漏掉了这一步,导致寄存器写不进值。
void can_gpio_config(void) { rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_CAN0); gpio_af_set(GPIOB, GPIO_AF_9, GPIO_PIN_8); gpio_af_set(GPIOB, GPIO_AF_9, GPIO_PIN_9); gpio_mode_set(GPIOB, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_8); gpio_mode_set(GPIOB, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_9); }注意,GPIO_AF_9这个复用号不是所有GD32系列都一样。GD32F10x系列里PB8/PB9复用CAN0,AF号是9;但如果你用的是GD32F30x或者GD32F4xx系列,可能要用PD0/PD1等引脚,且AF号不同。最稳妥的办法是查芯片数据手册里的“Alternate Function Mapping”表格。
另外,GPIO模式要设置成复用推挽,不要配成普通推挽输出。很多人在这里偷懒,直接配成GPIO_MODE_OUTPUT,结果CAN总是进不了正常通信状态,因为CAN控制器没法通过正确的电气特性操作总线。
4.2 CAN控制器初始化与位时序配置
这是整个配置中最重要的部分,直接决定波特率对不对、通信稳不稳。
void can_config(void) { can_parameter_struct can_parameter; can_deinit(CAN0); can_struct_para_init(CAN_INITIAL_STRUCT, &can_parameter); can_parameter.time_triggered = DISABLE; can_parameter.auto_bus_off_recovery = ENABLE; can_parameter.auto_wake_up = DISABLE; can_parameter.auto_retransmit = ENABLE; can_parameter.rec_fifo_overwrite = DISABLE; can_parameter.trans_fifo_order = DISABLE; can_parameter.working_mode = CAN_NORMAL_MODE; /* 位时序设置 */ can_parameter.resync_jump_width = CAN_BT_SJW_1TQ; can_parameter.time_segment_1 = CAN_BT_BS1_13TQ; can_parameter.time_segment_2 = CAN_BT_BS2_2TQ; can_parameter.prescaler = 9; can_init(CAN0, &can_parameter); }这里如果你用的是500kbps的波特率、APB1经过分频后是72MHz,那总线位时间计算方式为:CAN时钟 = APB1时钟 / prescaler = 72MHz / 9 = 8MHz。一个位时间由同步段(1TQ) + BS1(13TQ) + BS2(2TQ) = 16TQ,所以实际波特率 = 8MHz / 16TQ = 500kHz,采样点 = (同步段 + BS1) / 整个位时间 = (1 + 13) / 16 = 87.5%。
有的朋友图省事,喜欢直接用库函数里的CAN_BAUDRATE参数,比如:
can_parameter.resync_jump_width = CAN_BT_SJW_1TQ; can_parameter.time_segment_1 = CAN_BT_BS1_13TQ; can_parameter.time_segment_2 = CAN_BT_BS2_2TQ; can_parameter.prescaler = 4;如果你APB1不是72MHz,这个prescaler就不能照抄。最典型的错误就是:主频倍频配置不同,导致APB1是54MHz或者36MHz,但你仍然用prescaler为9的配置,实际波特率就完全不对。所以一定要先搞清楚自己主频是多少,再按公式反推prescaler,别做只会抄代码的“CV工程师”。
4.3 数据发送:检查邮箱、填入内容、请求发送
GD32的CAN外设和STM32一样,有3个发送邮箱。发送前需要找一个空闲邮箱,然后往里塞数据。
uint8_t can_send_data(uint32_t id, uint8_t *data, uint8_t len) { can_trasmit_message_struct transmit_message; transmit_message.tx_sfid = id; transmit_message.tx_efid = 0; transmit_message.tx_ff = CAN_FT_DATA; transmit_message.tx_ft = CAN_FT_STANDARD; transmit_message.tx_dlen = len; for (uint8_t i = 0; i < len; i++) { transmit_message.tx_data[i] = data[i]; } /* 返回邮箱号,0-2表示发送成功,255表示没有空闲邮箱 */ uint8_t mailbox = can_message_transmit(CAN0, &transmit_message); if (mailbox == CAN_MAILBOX0 || mailbox == CAN_MAILBOX1 || mailbox == CAN_MAILBOX2) { return 1; } return 0; }这里有一个需要特别注意的点:can_message_transmit函数在标准库里返回的是邮箱编号,有的老版本返回状态,如果你用旧库,最好是实际打印一下这个返回值,确保它命中了邮箱号区间。
发送完成不代表总线上的其他节点已经正确接收了。GD32的CAN外设会自己处理仲裁和错误重发,除非发送失败,否则你不太需要关心总线细节。但如果你把auto_retransmit设置为DISABLE,发送失败后就不会自动重发,需要手动处理,这在高实时性冗余设计中是有意义的。
4.4 数据接收:轮询方式与中断方式
接收部分,我建议直接使用中断方式。虽然轮询代码看起来简单,但实际项目里你无法预测报文什么时候来,轮询会占用CPU大量时间。
先看轮询方式的接收核心代码:
void can_poll_receive(void) { can_receive_message_struct receive_message; if (can_receive_message_length_get(CAN0, CAN_FIFO0) != 0) { can_message_receive(CAN0, CAN_FIFO0, &receive_message); printf("Get ID: 0x%x, Len: %d\n", receive_message.rx_sfid, receive_message.rx_dlen); for (uint8_t i = 0; i < receive_message.rx_dlen; i++) { printf("0x%02x ", receive_message.rx_data[i]); } printf("\n"); } }5. 中断接收还是DMA接收:我的实测结论与建议
很多初学者在CAN接收上会纠结“用中断还是DMA”。我直接用实测经验说结论:如果你不是在做高带宽持续接收,用中断就够了;如果要把CPU解放出来,再考虑DMA模式。
5.1 什么情况下选中断接收
CAN报文一个帧最多8字节数据,即使1ms一帧,每秒也就1000帧,对MCU来说中断压力其实不大。中断服务函数只需要把数据拷贝到应用层缓冲区,然后置一个标志位让主循环去处理。这种模式下功耗和实时性都很好。
void CAN0_RX0_IRQHandler(void) { can_receive_message_struct receive_message; can_message_receive(CAN0, CAN_FIFO0, &receive_message); /* 简单处理:直接把收到的数据写到一个全局结构体里 */ last_rx_id = receive_message.rx_sfid; last_rx_len = receive_message.rx_dlen; for (uint8_t i = 0; i < receive_message.rx_dlen; i++) { last_rx_data[i] = receive_message.rx_data[i]; } rx_flag = 1; }这里我要提一个很多教程都没讲清楚的细节:CAN0_RX0_IRQHandler里的RX0指的是FIFO0的接收中断,不是第0通道。如果你配置了多个FIFO,那中断函数有CAN0_RX0_IRQHandler和CAN0_RX1_IRQHandler两个,别只开FIFO1却只在RX0中断里收数据,那就永远收不到。
5.2 什么情况下选DMA接收
DMA的核心价值是“搬运数据不占CPU”。但CAN控制器的接收流程是:总线数据进入CAN控制器内部FIFO,再由你通过寄存器读出到内存。DMA做的只是后一半搬运工作。
问题在于,CAN的报文是不定长的,DMA搬运完成后你可能无法预知应该读多少字节。所以在DMA模式下,常见做法是先把固定长度的头部信息读出来,根据数据长度再次触发DMA搬运剩余数据。这大大增加了复杂度。
我的个人建议:低于1Mbps的CAN总线应用,根本不需要DMA。只有当你有多个CAN通道、同时接收大量报文,CPU忙不过来时,才需要认真设计DMA接收流程。否则引入DMA反而会加大调试难度。
6. 常见问题与排查技巧实录:GD32 CAN调试避坑指南
这部分我整理了自己和身边朋友实际踩过的坑,比任何理论都值钱。你按照这个顺序查,基本能解决90%的CAN不通信问题。
6.1 连不上总线,示波器看不到波形
先确认GPIO复用配置正确,再确认CAN收发器供电正常。
我遇到过一次非常诡异的情况:代码在STM32上跑得好好的,移植到GD32上之后,CAN_H和CAN_L之间就是没有差分信号。后来查数据手册发现,该型号的PB9如果完全按STM32F103的配置,配置成了普通推挽输出,而不是复用功能,导致CAN_TX信号根本没连接到收发器。所以你要检查两件事:
- 有没有调用
gpio_af_set配置复用功能? - 有没有把GPIO模式设置成
GPIO_MODE_AF?
这两个配置缺一个,收发器都收不到来自MCU的CAN信号。
6.2 用USB-CAN分析仪能收到自己发的数据,但另一个MCU节点收不到
这种现象多半是波特率不一致,或者采样点设置差别太大。
用逻辑分析仪抓一下总线上实际波形,测量一个位的实际时间,如果500kbps下实测位时间是2.5us,说明波特率确实不对。重新按APB1时钟计算prescaler即可。
另一个隐蔽的坑是:两个节点用了相同的波特率,但一个采样点设在50%,一个设在87.5%。在总线较长、信号上升沿不够陡时,就可能出现一个节点能正确采样、另一个节点频繁报错。调试时尽量统一采样点在75%到87.5%之间。
6.3 两个节点通信,一个节点报错,提示Bus-Off
Bus-Off的本质是发送错误计数超过255次,节点自动退出总线。
常规排查顺序:
- 先看这两块板子的地线是否共地?CAN总线虽然是差分传输,但收发器之间必须有公共参考地,否则共模电压会飘,导致总线干扰。
- 再查终端电阻是否匹配?两边总线端各一个120欧,不要多加,也不要漏加。
- 然后查波特率配置。把两个节点的CAN_BT寄存器值对比一下,看看是不是一个用8MHz CAN时钟,一个用9MHz CAN时钟。数值一样,但寄存器配置不同,算出来波特率就可能不一样。
- 最后检查节点是不是同时用两个ID发送?如果一个节点发送的ID恰好和另一个节点的应答帧冲突,也可能导致仲裁失败。
6.4 错误帧数量很多,通信间歇性失败
这通常和硬件走线、电磁环境有关。CAN总线要用双绞线,而且最好屏蔽层接地。实验阶段用杜邦线虽然能通,但距离长了以后输出波形质量急剧下降。
如果你只是做开发板调试,建议把CAN_H和CAN_L线绕在一起,做成简易双绞线,同时缩短总线长度。个人经验是:只要线尾的120欧电阻焊接牢靠,双绞线不合理也能通,但不稳定就是它带来的。
6.5 烧录配置的杂项问题
很多人会在工程里遇到Cannot access target或者No Cortex-M SW Device Found,这个和CAN本身关系不大,但几乎每个人都会遇到一次。先看调试器是否被正确识别,再看目标板供电是不是正常。如果之前有程序把SWD引脚复用了,确实会导致连不上调试器,这时候按住复位键再点下载,靠运气抢时间。
7. 实战扩展:多节点组网与数据解析
CAN的魅力在于多节点组网。我直接把经验再延伸一步,帮你把系统串起来。
7.1 多节点组网的地址分配
不要把ID理解为主机地址。CAN是广播式的,每个节点都能收到总线上所有报文,关键在于ID的优先级和内容属性。一般的工程习惯是:
- 0x000~0x07F:控制类高优先级报文,周期发送
- 0x080~0x0FF:状态类报文,周期发送
- 0x100~0x1FF:诊断类报文,按需发送
例如发动机转速报文固定ID为0x0A1,每10ms发送一次,里面包含转速、水温、油量等参数。另一个节点想用这个数据,就配置CAN过滤器只接收0x0A1。
7.2 数据解析与PDU设计技巧
CAN帧里最多8字节的数据,如果数据量超过8字节,你需要自己定义拆包组包的规则。常见做法是采用类似UDS的协议:每一帧的前两个字节是服务ID和数据长度,后面最多6字节是有效数据。多帧传输时加序列号。
比如要传一个字符串“HelloCAN”,可以定义:
- Byte0: 0x01 表示握手开始
- Byte1: 0x07 表示长度
- Byte2~Byte7: “HelloC”前6个字节
第二帧:
- Byte0: 0x02 表示续帧
- Byte1: 0x01 表示还剩余1个字节
- Byte2: “AN”
接收端按这个规则重新拼接,就还原出原始字符串。
8. 调试心得收尾
玩GD32的CAN总线,说到底就是把三件事做好:时钟算准、GPIO复用对、位时序不出错。只要这三件事没问题,后面的数据收发就是水到渠成。如果你踩到问题,先用逻辑分析仪看物理波形,再查寄存器配置,不要一开始就怀疑芯片本身,GD32的CAN外设经过这么多项目验证,稳定性是没什么大问题的。
我个人在实际项目里的习惯是:每个板卡的CAN初始化代码里,预留一个自检模式。上电后如果检测到BOOT引脚为高,就进入CAN回环测试,自发自收,把接收到的数据通过串口打印出来。这样即使没有外部节点,也能快速验证硬件通路是否正常。建议你也把这个小功能做成模板,排查问题会方便很多。
最后再分享一个小技巧:调试CAN时别忘了多看错误状态寄存器。GD32库里有can_error_state_get之类的函数,能直接返回当前错误状态。通过它判断节点是在主动错误、被动错误还是离线状态,比你在那瞎猜快得多。后面如果你想往深处玩,可以继续研究多CAN通道网关、利用过滤器的掩码模式做复杂路由,这些都是从今天这个Demo能自然延伸出去的方向。