GD32F103的I2C外设很多人上手第一版都是轮询。我一开始也是这么干的单挂一片EEPROM时没啥感觉主循环里等几个标志位而已简单直接。后来把一根I2C总线上做成双机系统——主机是GD32F103从机是一块做采集的GD32E230轮询写法就原形毕露了主机在while里死等从机应答从机稍微忙一点就NACK主循环里其他任务全被拖慢严重时整条总线挂死必须复位才能恢复。这篇文章就围绕GD32的I2C中断通信实战来写把用中断状态机改造主机与从机高效数据交换的完整方案记录下来包括代码结构、上拉电阻选型、中断服务函数的写法以及实测中踩过的坑。适合正在用GD32做一主一从或多设备I2C通信、觉得轮询方案不够稳的开发者。1. 轮询方案为什么在双机I2C通信中容易翻车1.1 轮询的本质是阻塞式等待I2C的轮询写法思路很直接发起START之后主循环里不停读事件标志位等到对应的位被硬件置1再做下一步。代码写起来确实短i2c_start_on_bus(I2C0); while (!i2c_flag_get(I2C0, I2C_FLAG_SBSEND)); i2c_data_transmit(I2C0, (slave_addr 1) | I2C_WRITE); while (!i2c_flag_get(I2C0, I2C_FLAG_ADDSEND));问题在于这个while循环是纯粹的忙等待。CPU在执行这段代码时被完全钉死在I2C状态查询上。主机和从机之间的交互如果顺畅每个标志位都在几个微秒内置位感觉不出来但一旦从机内部在处理其他任务比如ADC采样、Flash写入、按键扫描从机可能拉低SCL进行时钟拉伸或者干脆延迟应答主机这边的while循环就会一直卡在那里。此时整个系统的实时性全部取决于从机的响应速度而这个速度不可控。1.2 双机通信中最容易出现的三个故障我在实际调试中遇到的典型故障基本可以归为三类。第一类是CPU空转导致的其他任务异常。主机在主循环里等待I2C事件时无法及时处理定时器标志、按键扫描、显示刷新等操作表现出来就是LED闪烁不均匀、按键反应迟钝。不是I2C本身坏了而是调度被阻塞了。第二类是从机NACK导致的总线卡死。主机向从机发地址字节时如果从机处于忙碌状态会返回NACK。轮询代码如果没有针对NACK做停止条件和重新发送的处理总线会一直保持在一个半开状态下一次START发出去后从机可能完全不理你。第三类是缺少超时机制的死等。SCL被从机长时间拉低或者总线异常导致某个标志位永远不来轮询代码就永久卡死。加了超时变量也只能缓解不可能根除主循环调度被破坏的问题。1.3 中断方案带来的收益和代价换成中断方案本质是把I2C的事件处理从主循环的同步等待变成外设中断驱动。CPU不需要在while里空转主循环可以继续处理其他任务I2C事件到来时硬件会自动进入中断服务函数处理。收益非常明显对比维度轮询方案中断方案CPU占用通信期间100%忙等仅事件触发时短暂占用主循环调度通信期间被阻塞完全不阻塞从机慢响应容易导致系统卡顿不影响其他任务多字节传输代码嵌套深、维护困难状态机清晰可扩展错误恢复需要额外代码实现错误中断统一处理代价则是代码结构变复杂了通信流程被拆散到多个中断回调里需要用一个状态机来串联整个通信过程。如果没有理顺状态迁移调试起来比轮询费劲。但从双机通信的稳定性和可维护性来看这份复杂度是值得的。1.4 什么场景可以继续用轮询不是所有场景都需要上中断。如果只是单主机向传感器写入几个配置寄存器且系统主循环本身没有实时性要求轮询依然是快速可靠的方案。中断方案主要解决的是“通信与业务并发执行”和“多字节大数据量交换”的场景。文章后面的方案都是基于这个前提设计的。2. 硬件连接、上拉电阻与开发环境准备2.1 双板连接的基本组网方式我用的是两块GD32板子主机用GD32F103的I2C0从机用GD32E230的I2C0两者通过SCL和SDA两根线直连再共地。GD32F103的I2C0映射在PB6和PB7上GD32E230的I2C0同样有对应的复用引脚具体引脚以芯片数据手册为准。连接时需要注意几个细节。首先是两根线的长度短跳线连接时一切正常但如果超过10厘米甚至更长总线电容增加对上升沿的影响会立刻反映在波形上。其次是必须共地I2C信号是相对电平不共地会导致SDA/SCL的参考电位不一致轻则通信出错重则损坏引脚。最后是上拉电阻的位置物理上应该靠近主机端但如果从机也带有上拉两个上拉并联后阻值会变小需要算一下是否超出I2C规范允许的范围。2.2 上拉电阻阻值到底怎么选很多初学者会忽略上拉电阻的计算直接抄别人的4.7kΩ就用。4.7k在低速模式100kHz下、总线很短的时候确实能工作一旦总线电容偏大或者速率调到400kHz上升沿就会变得很慢SDA/SCL信号的上升时间超出规格从机就无法正确采样数据位。一个稳妥的计算方法是根据I2C规范来推导。快速模式400kHz下输出低电平允许的最大灌电流是3mA所以上拉电阻的最小值约等于Rmin (VDD - 0.4V) / 3mA按3.3V供电计算Rmin约等于967Ω取整就是1kΩ。电阻不能小于这个值否则低电平时灌电流超标。最大值取决于总线的上升沿时间约束。快速模式要求信号上升时间不超过300ns总线电容越大允许的上拉电阻就越小。如果总线电容按200pF估算Rmax约等于300ns / (0.8473 × 200pF)算下来约1.77kΩ所以2.2k属于一个保守又稳定的选择如果总线电容只有几十pF4.7k也可以凑合但余量不大。我实际测试的结论是短跳线连接时2.2k和4.7k都能跑通400kHz但换成杜邦线拉长到15厘米后4.7k的波形上升沿明显变缓偶发通信错误开始出现换回2.2k就稳定了。最终固定使用2.2kΩ这也是很多GD32开发板上的默认配置。2.3 GD32的I2C引脚需要配置成什么模式这里有一个非常容易被忽略的点I2C引脚必须配置成开漏复用输出而不是推挽复用输出。推挽输出在输出高电平时会主动拉高总线和上拉电阻形成竞争关系严重时可能损坏引脚或造成电平冲突。GD32的库函数配置如下rcu_periph_clock_enable(RCU_GPIOB); gpio_init(GPIOB, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, GPIO_PIN_6 | GPIO_PIN_7);GPIO_MODE_AF_OD就是复用开漏模式。在这种模式下引脚输出低电平时主动拉低输出高电平时释放总线由外部上拉电阻将电平拉高。这才是I2C协议要求的电气特性。2.4 GD32 Embedded Builder与Keil工程的选择开发环境方面GD32官方提供的GD32 Embedded Builder是基于Eclipse的免费IDE芯片支持包做得比较全用J-Link或者GD-Link都能直接烧录调试。我的主机工程最初是在Keil MDK里建的安装GD32F10x的器件支持包后也能正常编译J-Link烧录时需要在Flash Download页面选择正确的GD32F103型号和烧录算法否则会出现“Flash Timeout”或者“Error: Flash Download failed”的报错。如果你的电脑上不方便装Keil或者想用免费的方案GD32 Embedded Builder是个合适的选择。它的工程生成向导会自动处理启动文件和时钟配置省去了手动移植标准外设库的繁琐步骤。唯一要适应的是它基于Eclipse的界面快捷键习惯从Keil切过来需要一两天适应期。烧录时我习惯用J-Link配合SWD接口只连SWDIO、SWCLK、GND三根线。GD32的SWD引脚默认是复用的如果之前烧录过把SWD引脚配置成普通GPIO的程序J-Link会连接不上芯片解决办法是按住复位键的同时连接连接成功后先擦除整个芯片再烧录。3. 主机端中断收发代码实现3.1 状态机定义与初始化配置主机的中断方案用一个状态机来串联通信流程。状态机的每个状态对应I2C通信的一个阶段中断服务函数根据当前状态和硬件事件标志位决定下一步动作。先定义通信帧的基本结构。我的从机地址设为0x32命令字0x01表示读取温度数据从机收到后返回4字节数据。所以主机这边需要一个发送缓冲区和一个接收缓冲区以及对应的长度计数。#define SLAVE_ADDR 0x32 #define CMD_READ_TEMP 0x01 #define RX_BUFF_SIZE 8 typedef enum { HOST_IDLE 0, HOST_START_SENT, HOST_TX_ADDR_SENT, HOST_TX_DATA_SENT, HOST_RESTART_SENT, HOST_RX_ADDR_SENT, HOST_RX_DATA_RECEIVED, HOST_STOP_SENT, HOST_ERROR } host_i2c_state_t; static host_i2c_state_t host_state HOST_IDLE; static uint8_t host_tx_buff[4]; static uint8_t host_tx_len 0; static uint8_t host_tx_idx 0; static uint8_t host_rx_buff[RX_BUFF_SIZE]; static uint8_t host_rx_len 0; static uint8_t host_rx_idx 0; static volatile uint8_t host_i2c_done 0; static volatile uint8_t host_i2c_error 0;初始化函数需要完成时钟使能、GPIO配置、I2C外设配置和NVIC中断配置void host_i2c_init(void) { rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_I2C0); rcu_periph_clock_enable(RCU_AF); gpio_init(GPIOB, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, GPIO_PIN_6 | GPIO_PIN_7); i2c_clock_config(I2C0, 400000, I2C_DTCY_2); i2c_enable(I2C0); i2c_ack_config(I2C0, I2C_ACK_ENABLE); nvic_irq_enable(I2C0_EV_IRQn, 2, 0); nvic_irq_enable(I2C0_ERR_IRQn, 2, 1); }注意先把I2C外设使能再配置ACK最后打开中断。如果先开中断再使能外设有可能在初始化过程中触发一次虚假中断事件。3.2 主机发送流程的中断驱动逻辑主机要向从机发一个命令先调用发送启动函数把数据放入发送缓冲区然后通过写I2C_CTL0寄存器的START位发起起始条件uint8_t host_i2c_send_command(uint8_t cmd) { if (host_state ! HOST_IDLE) { return 0; } host_tx_buff[0] cmd; host_tx_len 1; host_tx_idx 0; host_i2c_done 0; host_i2c_error 0; host_state HOST_START_SENT; i2c_start_on_bus(I2C0); return 1; }之后所有推进都在中断服务函数里完成void I2C0_EV_IRQHandler(void) { if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_SBSEND)) { if (host_state HOST_START_SENT) { i2c_data_transmit(I2C0, (SLAVE_ADDR 1) | I2C_WRITE); host_state HOST_TX_ADDR_SENT; } else if (host_state HOST_RESTART_SENT) { i2c_data_transmit(I2C0, (SLAVE_ADDR 1) | I2C_READ); host_state HOST_RX_ADDR_SENT; } } if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_ADDSEND)) { if (host_state HOST_TX_ADDR_SENT) { if (host_tx_idx host_tx_len) { i2c_data_transmit(I2C0, host_tx_buff[host_tx_idx]); host_state HOST_TX_DATA_SENT; } else { host_state HOST_STOP_SENT; i2c_stop_on_bus(I2C0); host_i2c_done 1; } } else if (host_state HOST_RX_ADDR_SENT) { if (host_rx_len 1) { i2c_ack_config(I2C0, I2C_ACK_DISABLE); } } } if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_TBE)) { if (host_state HOST_TX_DATA_SENT) { if (host_tx_idx host_tx_len) { i2c_data_transmit(I2C0, host_tx_buff[host_tx_idx]); } else { host_state HOST_RESTART_SENT; i2c_start_on_bus(I2C0); } } } if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_RBNE)) { if (host_state HOST_RX_ADDR_SENT) { host_rx_buff[host_rx_idx] i2c_data_receive(I2C0); if (host_rx_idx host_rx_len) { i2c_stop_on_bus(I2C0); host_i2c_done 1; } else if (host_rx_idx host_rx_len - 1) { i2c_ack_config(I2C0, I2C_ACK_DISABLE); } } } }这段代码的核心思路是每个I2C硬件事件触发中断中断里先判断当前状态再执行对应的动作并迁移状态。SBSEND表示START已经发送ADDSEND表示从机地址已发送且收到ACKTBE表示发送数据寄存器为空RBNE表示接收数据寄存器非空。这些标志位的含义和轮询版本完全一样只是触发方式从轮询等待变成了中断驱动。3.3 重复起始条件与多字节读取流程上面的代码里有一个关键设计主机发送完命令后不是直接发STOP而是再次调用i2c_start_on_bus()。这是在I2C协议中常见的“重复起始条件”RESTART用于在同一帧内从写模式切换为读模式。完整的数据读取流程如下主机发起START。主机发送从机地址加写位0x64从机返回ACK。主机发送命令字节0x01从机返回ACK。主机发起RESTART。主机发送从机地址加读位0x65从机返回ACK。主机连续读取4个数据字节每收到一个字节主机硬件自动回复ACK直到最后一个字节前改为NACK。主机发送STOP。用RESTART而不是“STOP后重新START”的原因在于RESTART能保证整次传输的原子性期间总线不会被其他主机抢占。在双机系统中这个区别不明显但在多主机总线上就很重要。3.4 错误中断与超时保护事件中断解决正常流程错误中断负责异常情况。GD32的I2C错误中断涵盖了NACK接收、总线仲裁丢失、总线错误、溢出等多种情况void I2C0_ERR_IRQHandler(void) { if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_AERR)) { i2c_interrupt_flag_clear(I2C0, I2C_INT_FLAG_AERR); } if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_LOSTARB)) { i2c_interrupt_flag_clear(I2C0, I2C_INT_FLAG_LOSTARB); } if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_BERR)) { i2c_interrupt_flag_clear(I2C0, I2C_INT_FLAG_BERR); } if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_OUERR)) { i2c_interrupt_flag_clear(I2C0, I2C_INT_FLAG_OUERR); } i2c_stop_on_bus(I2C0); host_state HOST_IDLE; host_i2c_error 1; host_i2c_done 1; }错误处理的第一原则是先停止总线再清状态回归空闲保证下一次通信可以正常发起。如果不发STOP就直接复位状态机总线可能处于SDA被拉低的状态下一次START可能无法产生正确的时序。此外即使有中断机制业务层仍然建议保留一个超时判断。因为如果硬件中断本身出了问题或者从机死机导致ACK永远不来中断状态机也会卡住。我在主循环里加了一个超时变量超过50毫秒没等到host_i2c_done置位就复位整个I2C外设并重新初始化#define I2C_TIMEOUT_MS 50 uint8_t host_i2c_wait_done(void) { uint32_t timeout 0; while (host_i2c_done 0) { delay_1ms(1); timeout; if (timeout I2C_TIMEOUT_MS) { i2c_disable(I2C0); host_i2c_init(); host_state HOST_IDLE; return 0; } } host_i2c_done 0; return host_i2c_error ? 0 : 1; }这个超时复位逻辑在长时间运行的设备里非常重要。I2C总线上的干扰、从机程序跑飞、供电波动都可能造成通信挂起没有兜底机制设备只能断电重启。4. 从机端中断响应与多字节数据回传4.1 从机地址配置与中断使能从机端的代码结构和技术栈与主机类似但中断逻辑完全不同。从机不需要主动发起通信它只需要检测总线上是否有匹配自己地址的访问然后响应即可。从机初始化时关键配置是设置自身地址和应答模式void slave_i2c_init(void) { rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_I2C0); rcu_periph_clock_enable(RCU_AF); gpio_init(GPIOB, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, GPIO_PIN_6 | GPIO_PIN_7); i2c_clock_config(I2C0, 400000, I2C_DTCY_2); i2c_own_address_config(I2C0, I2C_I2CMODE_ENABLE, 0x32); i2c_enable(I2C0); i2c_ack_config(I2C0, I2C_ACK_ENABLE); nvic_irq_enable(I2C0_EV_IRQn, 2, 0); nvic_irq_enable(I2C0_ERR_IRQn, 2, 1); }从机地址可以通过硬件引脚或代码配置。GD32的I2C外设支持7位和10位地址模式默认使用7位地址。地址匹配后SADDR寄存器会和总线上的地址字节进行比较匹配时自动产生ADDSEND事件。4.2 从机接收数据的中断时序当主机发起写操作时从机的地址匹配中断ADDSEND会先触发然后主机发来的每个数据字节都会触发RBNE中断。从机在RBNE中断里从I2C_DATA寄存器读取收到的字节#define SLAVE_TX_BUFF_SIZE 8 #define SLAVE_RX_BUFF_SIZE 8 static uint8_t slave_rx_buff[SLAVE_RX_BUFF_SIZE]; static uint8_t slave_rx_len 0; static uint8_t slave_tx_buff[SLAVE_TX_BUFF_SIZE]; static uint8_t slave_tx_len 0; static uint8_t slave_tx_idx 0; void I2C0_EV_IRQHandler(void) { if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_ADDSEND)) { slave_rx_len 0; slave_tx_idx 0; } if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_RBNE)) { if (slave_rx_len SLAVE_RX_BUFF_SIZE) { slave_rx_buff[slave_rx_len] i2c_data_receive(I2C0); } else { (void)i2c_data_receive(I2C0); } } if (i2c_interrupt_flag_get(I2C0, I2C_INT_FLAG_TBE)) { if (slave_tx_idx slave_tx_len) { i2c_data_transmit(I2C0, slave_tx_buff[slave_tx_idx]); } } }这里有一个容易被踩的细节ADDSEND事件在从机模式下需要先读I2C_STAT0寄存器再读I2C_STAT1寄存器才能清除标志。库函数i2c_interrupt_flag_get内部已经读取了这两个寄存器所以直接用这个判断函数就能正确清除标志。但如果在中断里手动操作寄存器时忽略了这一步ADDSEND会一直置位导致中断反复触发。4.3 从机响应主机读请求的发送逻辑当主机发起读操作时从机同样会先收到ADDSEND事件然后硬件自动切换到发送模式。此时从机检测到TBE事件就可以往数据寄存器写入要发送的第一个字节后续每发一字节硬件会在总线时钟驱动下自动移位输出发送完成后再次触发TBE中断继续填充下一个字节。在我的采集板代码里收到命令0x01后会准备4字节的温度数据放入发送缓冲区。主机的读操作到来时从机在中断里自动把这些数据依次发送出去。完整处理逻辑是在RBNE中断里解析命令if (slave_rx_len 1) { if (slave_rx_buff[0] CMD_READ_TEMP) { uint16_t temp read_temperature(); slave_tx_buff[0] (uint8_t)(temp 8); slave_tx_buff[1] (uint8_t)(temp 0xFF); slave_tx_len 2; } else { slave_tx_len 0; } }这里需要特别注意从机切到发送模式后可以正常发送的字节数等于从机发送缓冲区的长度。主机读多少字节是由主机在接收端控制ACK/NACK决定的。主机在读最后一个字节之前把ACK配置成NACK从机收到NACK后就不会再触发TBE中断发送链路自然终止。4.4 简单帧协议避免数据错位如果通信内容不只是单命令建议在应用层定义一套简单的帧协议。我的方案是每个帧固定包含3个字段字段字节数说明帧头1固定为0xAA用于校验同步命令字10x01读取温度0x02读取电压0x03写配置数据N命令对应的参数或返回值从机在接收完整个帧后再执行命令而不是收到一个字节就执行。这样可以避免数据字节错位时执行错误命令。比如主机发送过程中如果丢了一个字节从机收到的不再是合法的“帧头命令”组合直接丢弃整个帧即可。帧协议虽然增加了一点代码量但换来的是通信结构清晰、错误帧容易识别尤其适合后续扩展更多从机或更多命令的场景。5. 实测过程中的波形分析与踩坑记录5.1 逻辑分析仪抓到的完整交互时序第一次把主机和从机都改成中断方案后我用逻辑分析仪抓了总线波形核对实际时序是否和预期一致。以“主机读取温度”为例完整的波形顺序起始条件S地址0x32加写位0应答位命令字节0x01应答位重复起始条件Sr地址0x32加读位1应答位数据字节高字节数据字节低字节停止条件P。这里有个值得说的细节从机发送两个数据字节之间SCL出现了一个明显的低电平拉伸段。这是I2C协议允许的“时钟同步”机制从机发现发送缓冲区还没准备好会把SCL拉低从而暂停主机时钟等缓冲区就绪后释放SCL传输继续。中断方案下从机从TBE事件触发到写入下一个数据寄存器间隔通常在几十微秒内不会造成明显的时钟拉伸但如果中断优先级设置不当从机的中断被其他更高优先级中断抢占时钟拉伸时间就会被拉长。5.2 只配外部上拉不配开漏输出通信必然异常这个问题在第一次调试时浪费了我将近两个小时。外部上拉电阻已经加上引脚也配置成了复用功能但总线波形始终不正常SDA被拉低后恢复高电平很慢从机偶尔能收到地址数据字节总是错。排查到最后发现GPIO配置成了GPIO_MODE_AF_PP也就是复用推挽输出。推挽输出在高电平时会通过P-MOS管主动驱动总线到VDD外部上拉电阻在这个时候反而成了一个额外的负载两者叠加后总线高电平虽然也能到3.3V但输出级驱动能力远大于上拉波形切换时产生明显的振铃和台阶。换成开漏复用输出后问题立刻消失。这是一个典型的“看着引脚配置没错实际电气特性完全不对”的案例。I2C引脚必须是开漏这一点在任何芯片上都是硬性要求。5.3 中断服务函数里千万别做耗时操作第一次改造从机代码时我在RBNE中断里直接调用了一个printf函数输出十六进制日志用来调试数据内容。结果通信正常但整个I2C时序完全被打乱。原因很简单printf在串口重定向后往串口发送一个字符都需要等待发送完成在中断里打印4字节数据相当于每收到一个字节就在中断里阻塞几百个微秒。这段时间内I2C硬件还在继续接收后续字节接收数据寄存器会被新数据覆盖造成丢字节。即便不会溢出长时间的时钟拉伸也让主机端400kHz的总线速率名存实亡。中断服务函数的原则是能不放就不放能少做就少做。我最后的做法是中断里只把数据放进缓冲区并置一个标志主循环里检测到标志后再统一处理数据、输出日志或执行业务逻辑。5.4 从机NACK导致主机状态机卡住双机通信时有一个很诡异的现场从机上电后第一次通信正常之后就再也不响应了主机一直处于等待状态逻辑分析仪能看到主机在反复发START但从机连ACK都不给。排查后确认是从机程序自己出了问题。从机在RBNE中断里收完一个字节后如果buffer已经满了我当时的代码直接丢弃数据但没做处理。下一次主机发来地址时从机认为总线状态不对拒绝应答。修复方案是在RBNE中断里无论缓冲区是否已满都要执行一次i2c_data_receive()把数据寄存器读空否则接收数据寄存器会一直保持有数据的状态硬件状态机无法进入下一轮接收。这提醒我从机端的每一笔数据都必须被消费无论你是否需要。硬件状态和数据读取之间是严格的消费关系读晚了或没读后续一切通信都会畸形。5.5 编译器优化与volatile的经典问题在Keil MDK中编译主机代码时我一开始定义了一个全局状态变量host_state并在中断和主循环里同时使用。默认的-O0优化级别下程序一切正常后来把优化等级调到-O2通信就变得时好时坏。问题出在编译器对普通全局变量的优化上。主循环里判断host_state时编译器可能把这个变量优化到寄存器里之后每次循环都从寄存器读取而不再重新从内存加载。中断里修改了内存中的值后主循环读到的仍然是旧值。修复方法很简单所有在中断和主循环之间共享的变量全部加上volatile修饰。static volatile host_i2c_state_t host_state HOST_IDLE; static volatile uint8_t host_i2c_done 0; static volatile uint8_t host_i2c_error 0;同理发送和接收缓冲区的索引变量也应该加volatile因为它们同样在中断里被修改。这个问题在轮询方案中不存在因为轮询方案里变量只在主循环里读写。这是中断方案独有的坑排查时容易被忽视。6. 后续扩展DMA搬运与多主机总线设计6.1 I2C加DMA进一步降低中断频率中断方案已经比轮询高效很多但如果通信频率很高每个数据字节都触发一次RBNE/TBE中断中断开销仍然可观。这时可以把数据搬运工作交给DMA。GD32的I2C外设支持DMA请求配置方式是在I2C控制寄存器里使能发送DMA请求和接收DMA请求。DMA控制器会在数据寄存器空/满时自动搬运数据CPU只需要在完成中断里处理整个块结束后的事件。主机端接收场景的配置思路配置DMA通道外设地址设为I2C0_DATA寄存器地址内存地址设为接收缓冲区地址。传输方向设为外设到内存传输长度设为要接收的字节数。使能I2C的接收DMA请求。接收完成后DMA产生传输完成中断在中断里发STOP并置标志。使用DMA后比如接收4字节数据CPU原本要进4次RBNE中断现在只需要在DMA完成时进1次中断。CPU占用进一步降低对于高频采集系统尤其有用。代价是DMA通道资源有限如果多个外设同时使用DMA需要合理分配优先级。6.2 多主机挂在同一条总线上的仲裁思路如果是多主机共享同一条I2C总线还需要考虑总线仲裁。GD32的I2C硬件自带多主机仲裁功能核心原则是“发送方在发送数据的同时持续采样SDA线如果发现SDA电平与自己发送的电平不一致说明有其他主机同时在发送自己竞争失败”。仲裁失败的标志是LOSTARB中断此时当前主机必须停止发送等待总线空闲后重试。我的一个项目里有三个主机定期读取同一个从机的数据最简单的协调策略是给每个主机分配不同的启动时隙但总线空闲时仍可能产生竞争。处理方案是在LOSTARB中断里清理标志然后在应用层安排一个随机延迟后重试。随机延迟的窗口设置在1到10毫秒之间实测重试成功率接近100%。多主机场景还需要特别注意RESTART的使用。使用RESTART可以让一个主机在同一个事务里完成“写命令读数据”期间总线不会释放给其他主机逻辑更清晰。但这也意味着持有总线的时间变长其他主机的等待时间增加需要在系统设计时权衡。6.3 从从机扩展为从机群组总线上的从机数量是可以扩展的。每个从机设备分配一个唯一的7位地址理论上最多可以挂载112个设备保留地址除外。多从机时要注意两点一是总线上电容增大上拉电阻需要重新计算必要时降低总线速率二是每个从机的地址匹配逻辑都相同区别只体现在地址值上代码复用性很高只需要从机地址可配置即可。我在扩展第二块从机时只改了从机初始化里的0x32为0x33以及从机的数据采集逻辑主机端的通信函数完全复用。这也算是中断状态机方案带来的额外收益通信流程是通用的业务数据不同的部分通过帧协议中的命令字来区分。最后再分享一个调这类代码的体感经验第一次把I2C通信改成中断方案时别急着把轮询代码直接删掉。先在工程里保留轮询版本在中断版本无法正常工作时用轮询函数做参照用逻辑分析仪对比两种方案的波形差异。定位问题会快很多。我调试第3章和第4章代码时就是用轮询版波形做基准照着修正中断状态机的顺序最终把整个方案稳定下来。从那以后我在这类GD32嵌入式项目里凡是涉及I2C主从通信都直接采用中断方案虽然写起来费点心思但运行稳定扩展起来也省心。