ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

GD32F407硬件I2C双机通信实战:从寄存器状态机到中断接收排错全解析

GD32F407硬件I2C双机通信实战:从寄存器状态机到中断接收排错全解析 如果你玩过STM32的硬件I2C大概听过那句流传已久的话——“硬件I2C不如软件模拟好用”。这句话放到GD32F407上我得先给个不全认同的结论硬件I2C本身没有原罪真正坑人的是很多人没搞懂外设的状态机就跑来写应用踩了坑就把锅甩给外设。这次我做的项目就是典型的双机通信一块GD32F407作为主机定时往总线上丢数据另一块GD32F407作为从机靠中断把数据收下来。主机这边用的是最常规的轮询发送从机那边则是纯中断接收一个字节都不靠主循环轮询。这个组合看起来基础但把I2C的两种典型工作模式都覆盖了而且踩坑点非常密集特别适合想把手头GD32硬件I2C跑明白的人。整个调试过程里我先后撞上了从机地址不匹配、ADDSEND标志清不掉导致中断进死、主机发完没有正确收尾总线卡死等问题。每一个都让我花了几个小时翻手册对寄存器。这篇文章我会按“先摸透硬件特性再写发送最后攻克接收中断”的顺序来写最后把我记录下来的全套排错链路也放出来希望你能少走点弯路。1. 动手前必须摸清的硬件底细1.1 两块开发板之间的物理连接GD32F407内部集成了两个I2C控制器I2C0的引脚可以映射到PB6/PB7或者PB8/PB9I2C1通常用PB10/PB11。我用的是两块GD32F407开发板同一个控制器不能同时又是主机又是从机所以主机板用I2C0从机板也用I2C0两边引脚接一起。连线非常简单主机SDAPB7接从机SDAPB7主机SCLPB6接从机SCLPB6两块板子共地GND必须接否则电平参考不一致通信会很诡异很多人忽略的一个点I2C总线必须接上拉电阻。GD32的开发板一般已经在PB6/PB7上放了4.7kΩ上拉到3.3V但如果你是自己画的板子一定要补上。没有上拉电阻SCL和SDA的电平就拉不起来示波器看波形就是一条平线或者像半吊子的锯齿波。1.2 为什么选择硬件I2C而不是GPIO模拟可能有人会说既然都在传硬件I2C坑多为什么不直接用GPIO模拟时序我的项目里数据量不大模拟I2C确实也能跑但做产品要考虑的维度不一样硬件I2C有完整的时序状态机起始条件、停止条件、应答信号都由外设自动生成软件只需要管“发什么数据”和“下一个操作是什么”CPU负担小很多。从机模式必须要用硬件中断。I2C从机接收的特点是异步突发主机随时可能发数据过来模拟从机要不停检测总线电平变化这在工程上既浪费CPU又容易漏数据。GD32F407的I2C外设其实具备完整的从机事件标志和中断源把这些利用好代码结构清晰可维护性高。一句话结论做一个双机通信小项目GPIO模拟用来理解协议很棒但真正做产品、做复杂通信搞定硬件I2C是绕不开的功课。1.3 I2C协议里你必须记住的四个瞬间I2C通信不靠时钟线传数据而是靠SCL上的电平变化配合SDA上的数据来定义事件。整个协议里最重要的就四个瞬间起始条件STARTSCL为高电平期间SDA从高电平跳变到低电平。主机要发数据时首先就要拉出这个起始信号。停止条件STOPSCL为高电平期间SDA从低电平跳变到高电平。主机发完数据后用这个信号释放总线。地址字节起始条件之后主机发送的第一个字节是7位从机地址加1位读写标志。写是0读是1。比如从机地址0x30那地址字节就是0x600x30左移一位如果加读标志就是0x61。数据字节每个字节8位MSB最高位先发。每个字节发送完成后接收方要回一个ACK低电平确认收到发送方如果收到ACK就继续发下一个字节收不到ACK就知道对方不想再要数据了可以发停止条件。对于双机I2C通信来说这四个瞬间抓住之后后面看寄存器标志位就有方向了不会被一长串名字绕晕。2. 主机端发送寄存器级流程拆解2.1 I2C时钟与GPIO的初始化细节主机端初始化看似简单但顺序错了也会出问题。我测试通过的初始化代码如下void i2c_master_init(void) { rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_AF); rcu_periph_clock_enable(RCU_I2C0); gpio_init(GPIOB, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, GPIO_PIN_6 | GPIO_PIN_7); gpio_pin_remap_config(GPIO_I2C0_REMAP, DISABLE); i2c_clock_config(I2C0, 100000, I2C_DTCY_2); i2c_enable(I2C0); i2c_ack_config(I2C0, I2C_ACK_ENABLE); }几个关键点我单独说GPIO_MODE_AF_OD是复用开漏模式。I2C协议要求SDA和SCL都是开漏输出这是为了让总线上的设备都可以直接拉低电平同时避免多个设备同时输出不同电平导致短路。这个和普通SPI、UART的推挽输出完全不同新手最容易在这里写错。gpio_pin_remap_config用来重映射引脚。如果你的板子I2C引脚默认就是PB6/PB7DISABLE就行。但有些板子设计会把I2C引到PB8/PB9这个时候就必须把重映射使能否则怎么初始化都没反应。i2c_clock_config(I2C0, 100000, I2C_DTCY_2)里这个100000是标准模式100kHz。GD32的I2C还支持400kHz快速模式但双机通信、线缆长的场景建议先用100kHz跑通协议再提速不然波形变形排错会很痛苦。2.2 主机发送一帧数据的完整状态序列主机的发送流程不是一个i2c_data_transmit就完事了。硬件I2C本质上是一个状态机软件要做的是在正确的时机给外设下达正确的命令。发送一帧数据的核心代码如下void i2c_master_send_data(uint8_t slave_addr, uint8_t* buffer, uint8_t len) { while(i2c_flag_get(I2C0, I2C_FLAG_I2CBSY)); i2c_start_on_bus(I2C0); while(!i2c_flag_get(I2C0, I2C_FLAG_SBSEND)); i2c_master_addressing(I2C0, slave_addr, I2C_TRANSMITTER); while(!i2c_flag_get(I2C0, I2C_FLAG_ADDSEND)); i2c_flag_clear(I2C0, I2C_FLAG_ADDSEND); for(uint8_t i 0; i len; i) { i2c_data_transmit(I2C0, buffer[i]); while(!i2c_flag_get(I2C0, I2C_FLAG_TBE)); } i2c_stop_on_bus(I2C0); }这段代码的执行逻辑是第一步等待总线空闲。上电后或者上一次通信结束总线上可能还有电平在稳定直接发起始条件容易冲突。I2C_FLAG_I2CBSY是总线忙标志等它变0再操作。第二步出发起始条件。i2c_start_on_bus命令外设在总线上产生一个START信号。第三步等待SBSEND标志。这个标志的中文名叫“起始条件发送完成”意思是START信号已经在总线上产生下一步可以写入从机地址了。第四步写入地址。注意i2c_master_addressing函数的最后一个参数是I2C_TRANSMITTER表示本次是发送数据硬件会自动把地址字节的最低位置0也就是组成写地址。第五步等待ADDSEND标志。这是地址发送完成的标志。这里有个容易忽略的点地址发送完成后如果是从机模式ADDSEND置位的同时I2C_CTL0的START位会自动清零也就是状态机会自动进入数据传输阶段。第六步清除ADDSEND标志。这一步非常关键不清会导致后续状态判断永远错误。GD32库函数提供了i2c_flag_clear但如果你看数据手册会发现有些情况下是通过读I2C_STAT0和I2C_DATA寄存器来清除的。用库函数封装好的接口就行。第七步循环发送数据。i2c_data_transmit把数据写进数据寄存器写完立即检查TBE也就是发送缓冲区空标志。只有这个位置1才能继续填下一个字节。如果忽略这个标志直接把下一个字节写进去可能会因为数据寄存器还没腾空而丢数据。第八步发送停止条件。所有数据发送完毕主机必须产生STOP信号否则从机不知道本次传输结束总线也一直被占用。2.3 发送过程中收不到ACK的定位思路主机发送的过程中从机会在每收到一个字节后返回ACK。如果从机没返回ACK主机的I2C_FLAG_ACKFAIL标志会置位。这种情况最常见的三个原因从机地址不匹配总线上没有设备认领这个地址自然没人回ACK。从机还在初始化I2C外设没有进入使能状态或者中断没打开其实它自己也不知道主机在喊它。总线上的上拉电阻有问题SDA线拉不回来导致应答信号畸变。我调试时遇到过一次地址不匹配导致的总线卡死现象是主机卡在等SBSEND超时逻辑分析仪上看到主机反复发START但总线上没有ACK。后来检查发现是地址写错了从机配置的是0x31主机发的却是0x30。查地址一定要把主机和从机的地址都对一遍最好直接用宏定义统一管理避免两边手写数字不一致。3. 从机端中断接收事件标志才是主角3.1 从机初始化与主机有什么区别从机端初始化大体上和主机类似但有几个关键配置不同void i2c_slave_init(uint8_t slave_addr) { rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_AF); rcu_periph_clock_enable(RCU_I2C0); gpio_init(GPIOB, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, GPIO_PIN_6 | GPIO_PIN_7); gpio_pin_remap_config(GPIO_I2C0_REMAP, DISABLE); i2c_clock_config(I2C0, 100000, I2C_DTCY_2); i2c_ack_config(I2C0, I2C_ACK_ENABLE); i2c_addr_config(I2C0, I2C_ADDFORMAT_7BITS, slave_addr); i2c_enable(I2C0); i2c_interrupt_enable(I2C0, I2C_INT_ERR | I2C_INT_BUF | I2C_INT_EV); NVIC_EnableIRQ(I2C0_EV_IRQn); NVIC_EnableIRQ(I2C0_ER_IRQn); }从机不需要调用i2c_start_on_bus因为它永远不应该主动发起通信。它需要配置的是自己的从机地址以及最重要的——使能中断。i2c_interrupt_enable这个函数控制了哪些事件可以触发中断。我使能了三个类别I2C_INT_ERR错误中断总线出错时能够第一时间进中断处理。I2C_INT_BUF缓冲区中断收发缓冲区有数据时触发。I2C_INT_EV事件中断地址匹配、停止条件这些事件触发。从机端还使能了两个独立的中断通道I2C0_EV_IRQn是事件中断I2C0_ER_IRQn是错误中断。这两个要分开处理错误中断里的逻辑和事件中断完全不同。3.2 中断服务函数里的事件判断题从机中断接收的代码看起来不复杂但每一个判断分支的顺序都是有讲究的uint8_t rx_buffer[16]; volatile uint8_t rx_len 0; volatile uint8_t rx_complete_flag 0; void I2C0_EV_IRQHandler(void) { if(i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_ADDSEND)) { i2c_interrupt_flag_clear(I2C0, I2C_INT_FLAG_ADDSEND); rx_len 0; } if(i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_RBNE)) { rx_buffer[rx_len] i2c_data_receive(I2C0); } if(i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_STPDET)) { rx_complete_flag 1; i2c_enable(I2C0); } } void I2C0_ER_IRQHandler(void) { if(i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_ACKFAIL)) { i2c_interrupt_flag_clear(I2C0, I2C_INT_FLAG_ACKFAIL); } if(i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_BUSERR)) { i2c_interrupt_flag_clear(I2C0, I2C_INT_FLAG_BUSERR); } }这个中断处理函数里有三个核心分支我来逐一拆解。ADDSEND分支地址匹配后的先遣处理当主机发出起始条件和地址字节后从机如果检测到地址和自己的配置地址一致硬件就会置位ADDSEND标志并触发中断。这个时候说明主机已经“喊”从机了从机应该准备接收后续数据。我在这段代码里做了两件事清标志以及把接收长度清零准备接收一帧新数据。清标志的动作要特别注意。GD32的手册上写的是ADDSEND标志清除方式是读取I2C_STAT0和I2C_DATA寄存器。i2c_interrupt_flag_clear这个库函数内部会对不同标志做不同处理所以直接用就行。但如果不用库函数自己写寄存器操作就得读两个寄存器才能清掉。RBNE分支真正收到一个字节RBNE是接收缓冲区非空标志意思是数据寄存器里已经收到一个完整的字节软件需要尽快把它读走。需要注意的是读走数据用i2c_data_receive这个函数执行后RBNE标志会被硬件自动清除不需要再调清理函数。所以分支顺序上ADDSEND分支一定要放在RBNE前面因为ADDSEND产生时数据寄存器里可能还是旧内容直接判断RBNE会读到脏数据。STPDET分支停止条件意味着结束主机发送完所有数据后会产生STOP信号从机检测到停止条件后STPDET标志置位。这个分支意味着本帧数据接收结束可以置位一个完成标志让主循环知道数据已经收齐了。清STPDET标志比较特殊需要对I2C_CTL0寄存器的SS位先写0再写1或者用i2c_enable重新使能从机模式硬件才能复位这个标志。我直接用i2c_enable(I2C0)实现的实测有效。不清这个标志的后果是下次停止条件到来时进入中断后会第一时间进入STPDET分支导致上一帧的完成标志被提前置位接收逻辑混乱。3.3 事件标志清除的优先级和顺序陷阱在写中断服务函数时我发现一个很隐蔽的坑I2C_INT_FLAG_ADDSEND和I2C_INT_FLAG_RBNE有时会同时置位。如果先处理RBNE读出来的数据可能是不正确的——因为在地址刚匹配的瞬间数据寄存器里的值还没有更新读到的可能是地址字节本身或者上一帧残留的数据。所以必须保证ADDSEND分支永远在RBNE分支之前判断。这也是我上面代码里把ADDSEND放在最前面的原因。另一个陷阱是STPDET分支和其他分支的冲突。当STPDET置位时说明一帧传输结束了此时不应该再有RBNE数据要读。如果RBNE分支放在STPDET后面理论上也没问题但保险起见我在收到STPDET后就没有再读取RBNE的动作了。只要有停止条件数据就已经完整收完不需要画蛇添足。我建议在中断服务函数里加一个防错机制在RBNE分支读取数据之前判断一下当前的rx_len是否已经超过接收缓冲区长度。如果超过直接丢弃这次数据并复位长度避免数组越界。if(i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_RBNE)) { if(rx_len sizeof(rx_buffer)) { rx_buffer[rx_len] i2c_data_receive(I2C0); } else { i2c_data_receive(I2C0); rx_len 0; } }4. 主机从机联调一次完整的收发链路验证4.1 测试代码怎么设计配套的测试代码我这样写的。主机端每1秒通过定时器触发一次发送发送的报文是一个固定帧头加递增计数uint8_t tx_buffer[4] {0xA5, 0x00, 0x00, 0x00}; void timer_callback(void) { tx_buffer[1] tx_counter 8; tx_buffer[2] tx_counter 0xFF; tx_buffer[3] tx_counter; i2c_master_send_data(0x30, tx_buffer, 4); }从机端接收到数据后在完成标志置位时把rx_buffer里的数据打印出来while(1) { if(rx_complete_flag) { rx_complete_flag 0; printf(RX: %02X %02X %02X %02X\r\n, rx_buffer[0], rx_buffer[1], rx_buffer[2], rx_buffer[3]); } }我先把主机发送频率调成1秒一次方便观察从机收数有没有丢帧。调通后再把频率提高测试高速场景下的稳定性。4.2 用逻辑分析仪验证时序波形写代码时可能觉得逻辑没问题但实际跑起来有没有问题必须靠逻辑分析仪或者示波器来确认。我用的逻辑分析仪采样率20MHz接在SDA和SCL上然后手动触发一次主机发送。抓到的波形上应该能看到一个明显的START条件SCL高电平期间SDA由高变低。之后有9个SCL高电平脉冲实际是9个SCL周期第一个字节是地址字节0x60。注意看地址字节的第8位是0说明是写操作。地址字节发送完成后在第9个SCL周期SDA被从机拉低这个低电平就是ACK。紧接着的四个字节数据0xA5、计数高、计数低、计数每个字节后都跟着一个ACK低电平。最后是STOP条件SCL高电平期间SDA由低变高。如果抓到的波形里START后面直接跟了STOP多半是主机发送流程没走完比如某个标志位等待超时提前执行了i2c_stop_on_bus。这种问题通过看波形远比靠猜来得快。4.3 联调中发现的粘包问题第一次联调时我发现从机偶尔会把两帧数据合成一帧打印比如收到0xA5 0 0 0xA5 0 0 20这种。这就是典型的粘包问题。原因是主机端发送完上一帧数据后从机还没来得及处理STPDET事件或者从机主循环打印太慢占用了中断响应时间主机下一帧的START和地址就来了。从机的ADDSEND标志一置位就会把rx_len清零重新接收按理说不会粘包。那问题出在哪后来我发现是我的打印语句太耗时在rx_complete_flag置位后主循环里执行串口打印的过程中定时器又触发了下一次发送。但I2C从机的硬件在收到新的START后确实会再次触发ADDSEND中断rx_len会被清零。可打印的是旧数据新数据还在往里存。这个时候从机打印出来的是“旧数据已经读出来的副本”和“新数据混在一起”的效果。解决方法是在检测到rx_complete_flag后先把数据拷贝到临时数组再清标志然后再打印。这样接收中断可以继续干活打印也不影响接收。while(1) { if(rx_complete_flag) { uint8_t tmp[16]; uint8_t len rx_len; memcpy(tmp, rx_buffer, len); rx_complete_flag 0; for(uint8_t i 0; i len; i) { printf(%02X , tmp[i]); } printf(\r\n); } }5. 实测中必须注意的五个坑与完整排查链路5.1 坑一从机清ADDSEND后RBNE没数据现象从机中断服务函数里能进ADDSEND分支但等了很久RBNE也不置位数据接收不到。排查过程我首先用逻辑分析仪看主机发出的波形确认数据字节确实在总线上。然后加打印看主机端发送数据时TBE标志的等待是否超时。发现主机端卡在等TBE而TBE不置位通常意味着从机没有发送ACK应答。根本原因从机虽然初始化了但I2C_ACK_ENABLE没有配置导致从机不产生应答信号主机以为从机不在总线上就一直等待。修复从机初始化时确认调用了i2c_ack_config(I2C0, I2C_ACK_ENABLE)。I2C通信中接收方必须在每收到一个字节后产生ACK这是协议规定的收发双方的ACK配置都要打开。5.2 坑二STPDET标志清不掉导致重复进入现象从机收完一帧后rx_complete_flag可能瞬间置位两次造成一帧数据被当成两帧处理。排查过程我在STPDET分支里加了打印和计数发现同一个STOP信号会导致两次进入STPDET分支。查数据手册发现STPDET标志清除需要特殊时序不是简单写0。根本原因GD32手册中描述清除STPDET的方法是向I2C_CTL0的SS位写0再写1。如果直接尝试用i2c_flag_clear清理某些库版本可能不生效。修复在STPDET分支里调用i2c_enable(I2C0)。这个函数会把SS位置1加上之前中断进入时硬件已经把SS清0正好满足手册要求的清除顺序。实测完全可靠。5.3 坑三总线上有设备没上电现象主机初始化完成后一旦调用i2c_start_on_bus程序就卡在等待SBSEND超时总线异常占用。排查过程断开主机和从机的物理连接把上拉电阻摘掉测电平发现SCL被某个设备拉低。检查设备供电才发现从机板子根本就没上电但它的I2C引脚通过开发板上的上拉电阻连到了主机的电源线上导致从机引脚处于半供电状态锁死了总线。根本原因I2C设备如果没上电其引脚保护二极管会把总线拉到一个不确定电平导致总线忙。修复保证从机正常供电后再通信或者通信前先读取总线状态确认总线空闲再发起起始条件。i2c_flag_get(I2C0, I2C_FLAG_I2CBSY)就是干这个用的。5.4 坑四主机发送过快从机来不及处理现象把主机定时器改成10ms发送一次后从机开始丢数据接收到的数据帧残缺。排查过程加打印查看中断进不去还是数据被覆盖。最后发现在主循环处理rx_complete_flag时打印耗时约20ms而定时器10ms就触发一次此时从机不断进入ADDSEND中断rx_len被不断清零导致数据覆盖。根本原因接收侧处理速度跟不上发送侧速度缓冲区重叠。修复优化从机端数据处理缩短打印时间或者用双缓冲。双缓冲的做法是定义两个接收缓冲区一个让中断往里面写另一个让主循环读交替使用。中断收到STOP时只是把缓冲区的索引切换一下不复制数据效率高很多。5.5 坑五中断优先级配置不当导致I2C事件丢失现象在I2C中断处理过程中串口中断优先响应导致I2C事件标志没有被及时清除后续事件无法触发。排查过程我在中断服务函数里打了断点发现I2C中断能进但执行到一半被串口中断打断。串口中断服务函数里有个比较长的字符串格式化过程导致I2C中断响应时间超过了一个字节的传输时间。根本原因从机模式接收数据是有时效的一个字节接收完成后硬件会产生中断但如果软件响应不及时下一个字节的时钟周期已经来了数据就会被覆盖。修复调整NVIC中断优先级把I2C事件中断优先级设为高于串口中断。在GD32中可以用nvic_irq_enable设置抢占优先级和子优先级确保关键通信中断不被其他中断阻塞。6. 进阶建议中断服务函数里少做事整个项目跑通之后我回顾了一下代码发现从机中断服务函数里做的事情其实已经算精简了但还是有优化空间。核心原则是中断服务函数里只做最必要的操作读数据、存缓冲区、置标志位。任何耗时的操作比如数据处理、打印、调用用户回调函数都应该放到主循环里去干。我见过一些代码在I2C中断里直接调用printf这在开发调试阶段问题不大但生产环境千万别这么干。串口打印本身就是阻塞的打印一长串字符的时间可能比I2C的整个数据帧传输时间还长中断服务函数被卡住后续的数据全丢。更好的做法是volatile uint8_t rx_buffer0[16]; volatile uint8_t rx_buffer1[16]; volatile uint8_t rx_active_buf 0; volatile uint8_t rx_len 0; volatile uint8_t rx_ready 0; void I2C0_EV_IRQHandler(void) { if(i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_ADDSEND)) { i2c_interrupt_flag_clear(I2C0, I2C_INT_FLAG_ADDSEND); rx_len 0; } if(i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_RBNE)) { if(rx_active_buf 0) rx_buffer0[rx_len] i2c_data_receive(I2C0); else rx_buffer1[rx_len] i2c_data_receive(I2C0); } if(i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_STPDET)) { rx_ready 1; i2c_enable(I2C0); } }主循环里检测到rx_ready后根据rx_active_buf的值决定读哪个缓冲区读完立即把rx_active_buf取反开始新一轮接收。这样中断里完全没有复制和格式化的操作速度可以提得比较高。我在实际测试中把发送间隔压缩到2ms从机依然能稳定接收不丢帧。这个性能对于大多数应用场景已经完全够用了。最后再分享一个小技巧如果你有示波器把SDA和SCL分别接到两个通道上触发方式设置为下降沿触发这样每次主机发起通信都能稳定抓到起始条件看时序方便很多。如果没有示波器一台几十块钱的逻辑分析仪也完全够用采样率10MHz以上就行它能帮你快速定位大部分I2C通信问题。
返回列表