
简介基于STM32F103的I2C从站源码工程使用OpenCM3开源固件库编写面向嵌入式开发学习者与需要快速上手I2C从机通信的工程师。工程实现了一个可交互的“计算器”协议主设备发送两个整数和运算类型加法、减法或乘法从站接收后通过中断服务完成解析、计算并经I2C把结果返回完整展现了从机地址配置、GPIO复用、寄存器初始化、中断事件处理及数据收发流程。资源包共8个文件以C源码main.cpp、链接脚本、meson构建脚本和清理脚本为主附带README说明整体压缩后仅约5KB结构紧凑、易于对照阅读和二次移植。目前已有541人学习下载适合用来理解I2C时序异常处理、学习如何在STM32裸机环境下借助OpenCM3库开发外设通信模块也可作为扩展轮询或DMA模式的改造起点。1. 为什么 STM32F103 的 I2C 从站比主机更难写stm32f103 的 i2c 从站代码网上找得到的大多是主机侧读传感器、写 EEPROM 的示例基于 libopencm3 从零搭一个能进中断、能响应主机读写的从站反而少见。原因很好理解主机侧永远是主动方START、地址、数据、STOP 都是自己发出的状态机再复杂也是在自己掌控里。从站则完全被动要在主机给出的 9 个 SCL 脉冲内判断方向、准备数据、处理 NACK一个事件没接住整帧就错位了。用 libopencm3 写从站本质上就是把这些硬件事件一个个接住。它不像 HAL 那样把 I2C 回调封装成一层套一层的函数指针也不像标准外设库 3.5 那样用 EV5/EV6/EV7 这套宏去拼事件流。libopencm3 直接让你面对 SR1、SR2、DR 这三个寄存器配好 IRQ 后中断里怎么读、怎么清、按什么顺序处理全是你的代码说了算。这篇文章会把从站的时序模型、F1 的事件标志、初始化代码、寄存器读写状态机、以及调试时最容易卡住的几个现场问题按顺序讲清楚。适合要写真实从机设备的工程师比如模拟 EEPROM、给传感器做寄存器接口、或者把 STM32F103 挂在别人主机下面当协处理器。2. 从机视角的 I2C 时序与 STM32F1 硬件事件位2.1 从机侧的 I2C 时序主机永远在点菜I2C 协议本身是同步串行协议SCL 由主机产生从机只负责在对应位时间窗口内准备好 SDA。一帧完整事务的典型顺序是START7 位从机地址 1 位方向位0 表示写1 表示读从机在第 9 个时钟拉低 SDA 表示 ACK之后要么主机发数据写方向要么从机发数据读方向最后是 STOP。方向位 WR 是相对主机而言的所以从机看到方向位为 1 时意味着自己要被读。从机这边真正要关心的不是“我要发什么”而是“下一个字节该由谁驱动 SDA”。主机发地址之后从机要立刻判断地址是否匹配匹配后要读硬件给出的方向位然后进入数据阶段。任何一步慢了主机不会等你它只会收到一个 NACK 或者干脆产生超时。这就是 I2C 和 UART 最大的区别UART 有波特率容错I2C 从机的时间预算以字节为单位错过一个事件后续整个事务的字节边界全部错位。所以排查这类问题逻辑分析仪比示波器好用。示波器看的是模拟波形逻辑分析仪可以直接按 I2C 协议解码把地址、ACK、NACK、数据、STOP 一帧一帧列出来。调从站的第一步永远是抓一份正常的 i2c 时序图确认主机发出的地址和方向位是否符合预期再谈软件逻辑。2.2 STM32F1 的 I2C 事件标志与两个中断入口STM32F103 的 I2C 外设把状态全部放在 SR1 和 SR2 两个寄存器里。从机模式下核心事件是下面这几个事件标志触发时机清除方式ADDR地址匹配含方向位读 SR1 后再读 SR2一次性清除TXE数据寄存器为空可写下一字节写 DR 自动清除RXNE收到一个字节等待读走读 DR 自动清除STOPF从机接收方向收到 STOP读 SR1 后再写 CR1AF主机回 NACK或地址未匹配读 SR1 后再读 SR2BERR总线错误如 START/STOP 位置错误读 SR1 后再写 CR1注意 ADDR 的清除条件在从机里是“读 SR1、再读 SR2”。很多人第一次写从机就在这里踩坑只读了 SR1 以为清了结果 ADDR 一直挂着中断不断进来后面的事件永远处理不到。SR2 的低位里还带着两个重要信息TRA 位表示当前传输方向1 为主机读从机0 为主机写从机最后一位表示是否作为从机被寻址。方向判断必须在这一步完成因为 ADDR 清了之后硬件会立刻进入数据阶段。F1 的 I2C 中断向量有两个入口I2C1_EV_IRQn 处理事件包括地址匹配、字节发送、字节接收I2C1_ER_IRQn 处理错误BERR、ARLO、OVR。两个中断向量要分别使能事件中断里不要处理错误标志错误标志也别堆在事件中断里。这个分工在标准外设库时代就有写 libopencm3 从站时同样要保持。2.3 时钟延展从机唯一能“慢下来”的硬件手段I2C 从机在物理上有一个特殊能力当它没准备好时可以把 SCL 拉低主机必须等待。这个机制叫时钟延展clock stretching由 F1 I2C 外设的 NOSTRETCH 位控制默认是允许延展的。对从机软件来说这其实是保命机制。比如主机连续写很多字节而你的中断里有别的事情要处理DR 里的字节没被及时读走外设会自动把 SCL 拉低直到软件读走数据。读方向也一样如果 TXE 已经置位但你还没写下一字节SCL 会被拉低主机干等。这个机制让从站代码即使偶尔慢一点也不会直接导致数据丢失。但时钟延展不是万能的。如果 NOSTRETCH 被置 1外设就完全不拉 SCLDR 里的数据会被下一字节覆盖RXNE 也没机会清。所以调从站时如果发现主机总在某个位置超时先确认 NOSTRETCH 是不是被误置位了。大部分场景保持默认即可只有做 DMA 或追求极限吞吐时才考虑关掉它。3. libopencm3 初始化最小工程GPIO、地址、两根中断线3.1 为什么选 libopencm3 而不是 HAL 或标准库F1 的 I2C 从机选型无非三条路ST 标准外设库 3.5、STM32CubeMX 生成的 HAL 代码、libopencm3。标准库的 I2C 从机代码几乎没给现成方案得自己拼事件HAL 虽然 I2C 从机有回调但封装层多中断里回调套回调出了问题不好定位libopencm3 只做寄存器封装不替你做逻辑代码写起来像是直接对着参考手册编程。HAL 还有一个具体问题它把 I2C 事件中断和错误中断统一收集后在 HAL_I2C_EV_IRQHandler 里分发回调里还会帮你清一部分标志。这套逻辑对主机模式够用但对从机这种需要严格按序处理事件、经常要预装载首字节的应用HAL 的通用状态机会让你很难插进去做“在 ADDR 清除瞬间马上写 DR”这种动作。libopencm3 把中断向量表直接暴露给你函数名就是硬件中断名写起来最直接。另外从体积上看libopencm3 只链接用到的外设代码整个从站工程 flash 占用通常只有十几 KB对 STM32F103C8T6 这种 64KB flash 的芯片非常友好。libopencm3 的文档偏薄API 名字有时要翻头文件确认但 I2C 这部分寄存器宏定义和手册完全对齐实际编码成本不高。3.2 GPIO、时钟与从机地址初始化以 I2C1 为例默认引脚是 PB6SCL和 PB7SDA。这两个引脚必须配置为复用开漏输出同时外部加 4.7kΩ 上拉到 3.3V。STM32 的内部上拉在开漏输出模式下不生效如果板子上没有外部上拉SDA 和 SCL 都会是低电平主机根本发不出 START。#include libopencm3/stm32/rcc.h #include libopencm3/stm32/gpio.h #include libopencm3/stm32/i2c.h #include libopencm3/cm3/nvic.h #define I2C_SLAVE_ADDR 0x32 void i2c_slave_setup(void) { rcc_periph_clock_enable(RCC_GPIOB); rcc_periph_clock_enable(RCC_AFIO); rcc_periph_clock_enable(RCC_I2C1); i2c_reset(I2C1); /* 复用开漏外部上拉到 3.3V */ gpio_set_mode(GPIOB, GPIO_MODE_OUTPUT_50_MHZ, GPIO_CNF_OUTPUT_ALT_OPENDRAIN, GPIO6 | GPIO7); gpio_set(GPIOB, GPIO6 | GPIO7); /* 关闭外设再配置避免残留状态干扰 */ i2c_peripheral_disable(I2C1); I2C_OAR1(I2C1) (uint32_t)((I2C_SLAVE_ADDR 0x7F) 1); /* ACK 使能 事件/缓冲/错误中断 */ I2C_CR1(I2C1) | I2C_CR1_ACK; I2C_CR1(I2C1) | I2C_CR1_ITEVTEN | I2C_CR1_ITBUFEN | I2C_CR1_ITERREN; i2c_peripheral_enable(I2C1); nvic_enable_irq(NVIC_I2C1_EV_IRQ); nvic_enable_irq(NVIC_I2C1_ER_IRQ); }直接写I2C_OAR1而不是调用库函数是为了把地址摆放规则暴露出来7 位地址从 bit7 到 bit1 排列所以左移一位。0x32 在总线上实际看到的是 0x64。很多人在这个地方对着主机代码查半天以为自己地址设错了其实是 7 位和 8 位表示的差异。如果使用 10 位地址还要额外置位 ADDMODE这里不展开。I2C_CR1_ITBUFEN这位置位后TXE 和 RXNE 才允许触发中断。只开 ITEVTEN 的话地址匹配和 STOP 事件能进中断但每个字节的收发不会产生中断这是新手写 libopencm3 从站最常见的遗漏。ACK 位在从机模式下一旦被清掉整个设备对主机就是“哑巴”所以I2C_CR1_ACK要在外设使能前写好。3.3 两个中断函数先跑通再填逻辑初始化完成后先在中断函数里写一个空壳用 GPIO 翻转确认事件确实进来了。void i2c1_isr(void) { gpio_toggle(GPIOC, GPIO13); /* 调试脚示波器看频率 */ uint32_t sr1 I2C_SR1(I2C1); (void)sr1; } void i2c1_error_isr(void) { uint32_t sr1 I2C_SR1(I2C1); (void)sr1; }GPIO 翻转之后用主机去扫描这个地址如果 PC13 有波形说明初始化链路通了。这一步比直接写完整中断逻辑再调试要快得多。注意sr1声明为局部变量后会“消耗掉”一次读取但 SR1 的清除条件不是靠单纯读完成的所以这里不会产生副作用。真正的清标志操作在下一章状态机里按顺序处理。I2C1_EV_IRQn 和 I2C1_ER_IRQn 必须分别使能只开一个会导致事件进来了但代码没跑或者总线错误后状态机卡死。F103 的 I2C2 同理EV 和 ER 各自独立。4. 从站状态机用 SR1/SR2 实现寄存器读写的完整代码4.1 寄存器模型从机的本质是一块“内存”绝大多数 I2C 从设备比如 EEPROM、传感器、IO 扩展芯片暴露给主机的都是寄存器模型主机先写一个寄存器地址然后再写数据或者连续读数据。从机协议栈真正要维护的就是这个寄存器视图。STM32F103 从站在软件里要做的事是把硬件字节流翻译成“地址 数据”的操作。状态机可以拆成三个状态等待寄存器地址、接收数据、发送数据。还有一个关键变量是“上一次主机写的寄存器地址”因为主机读数据时不会在同一个事务里再写一遍地址而是在写完地址后发一个 ReSTART然后改为读方向。从机必须在第二次 ADDR 事件时记住刚才写到了哪个寄存器。这是寄存器模型从机最核心的细节。4.2 中断里的完整事件处理下面的代码是一个可直接跑的从站事件处理框架支持随机读、顺序读和页写默认寄存器空间 256 字节。enum { ST_IDLE 0, ST_RX_ADDR, /* 等待寄存器地址 */ ST_RX_DATA, /* 接收数据 */ ST_TX_DATA /* 发送数据 */ }; static volatile uint32_t slave_state ST_IDLE; static volatile uint8_t reg_addr; static volatile uint8_t reg_base[256]; static volatile uint8_t tx_idx; void i2c1_isr(void) { uint32_t sr1 I2C_SR1(I2C1); /* 1. 地址匹配优先处理 */ if (sr1 I2C_SR1_ADDR) { uint32_t sr2 I2C_SR2(I2C1); /* 读 SR2 清除 ADDR同时取方向 */ if (sr2 I2C_SR2_TRA) { /* 主机读从机立即预装载第一个字节 */ slave_state ST_TX_DATA; tx_idx reg_addr; I2C_DR(I2C1) reg_base[reg_addr]; if (reg_addr 255) { reg_addr; } } else { /* 主机写从机先收寄存器地址 */ slave_state ST_RX_ADDR; } return; } /* 2. 发送方向字节已经移出补下一个 */ if (sr1 I2C_SR1_TXE) { if (slave_state ST_TX_DATA) { if (tx_idx 256) { I2C_DR(I2C1) reg_base[tx_idx]; } else { /* 超过寄存器范围不写 DR主机收到 NACK 后发 STOP */ slave_state ST_IDLE; } } return; } /* 3. 接收方向主机写的字节 */ if (sr1 I2C_SR1_RXNE) { uint8_t byte I2C_DR(I2C1); if (slave_state ST_RX_ADDR) { reg_addr byte; slave_state ST_RX_DATA; } else if (slave_state ST_RX_DATA) { reg_base[reg_addr] byte; } return; } /* 4. STOP主机结束本次事务 */ if (sr1 I2C_SR1_STOPF) { slave_state ST_IDLE; /* 清除 STOPF先读 SR1再写 CR1 */ (void)I2C_SR1(I2C1); I2C_CR1(I2C1) I2C_CR1(I2C1); return; } /* 5. AF主机回 NACK读方向结束 */ if (sr1 I2C_SR1_AF) { slave_state ST_IDLE; (void)I2C_SR1(I2C1); (void)I2C_SR2(I2C1); } }这段代码有几个顺序不能乱。ADDR 必须最先处理因为它是整个事务的起点而且读 SR2 这个动作会同时完成“清 ADDR”和“取方向”两件事。主读分支里I2C_DR(I2C1) reg_base[reg_addr]写在清除 ADDR 之后这是预装载第一个字节的标准做法。如果不在这里预装载等 TXE 中断再写主机在读第二个字节时会空一拍虽然时钟延展能兜住但会拖慢整个事务。STOPF 的清除是“读 SR1 后再写一次 CR1”写回原值不会改变任何配置因为所有位都是写前的值。有人在这里用i2c_peripheral_disable和enable来复位外设那也能清但会把中断使能位一起清掉得重新设置。AF 出现在读方向下主机读够长度后回 NACK从机要回到 IDLE否则下一笔事务的状态会被污染。注意清除 AF 的时序是读 SR1 再读 SR2和 ADDR 的清除动作一样。4.3 读方向第一个字节的位置最容易写错很多从站实现会在 TXE 中断里才填充第一个字节这在主读方向会引入一次额外等待。ADDR 清除后 DR 是空的TXE 此时已经置位但如果你不主动写 DR外设不会自动产生 TXE 中断——TXE 只在你读走 SR1 之后触发一次。结果就是主机在等第一个字节你的中断还没来得及触发SCL 被时钟延展拉住延迟虽然只有几个微秒但高频率连续读时可能触发主机侧超时。预装载的处理方式在这段代码里已经体现ADDR 分支里写 DR后续 TXE 中断都补下一字节。还有一个边界如果主读请求的寄存器地址已经越界预装载时也要做判断否则会把 0x00 当作数据发出去。生产代码里一般会在 reg_base 后面放一个溢出标记主机读到越界地址时主动返回 0xFF 或者重挂 IDLE而不是裸奔。5. 从站调试边界中断不进来、总线被拉死、标志清不掉的排查顺序5.1 先分清“中断没进”和“事件没处理”从站表现异常时第一件事不是看逻辑而是确认中断是否真的在跑。最简单的方式就是前面提到的 GPIO toggle 空壳把 PC13 接到示波器主机发一帧看看有没有电平翻转。没翻转问题在 NVIC 配置、GPIO 复用或者外设时钟有翻转但功能不对问题在事件处理顺序或标志清除。有一种隐蔽情况是中断函数一直在进但读 SR1 时事件标志已经被硬件自动清了。I2C 的 RXNE 读 DR 即清、TXE 写 DR 即清这类标志不需要额外操作。但 ADDR、STOPF、AF 这类粘性标志必须由软件清而且清除动作本身有顺序要求。如果某个粘性标志没清中断会反复触发你会看到 GPIO 翻转频率极高但实际业务逻辑完全没走。5.2 三个最容易踩的坑STOPF 清除、AF 残留、SR1/SR2 顺序STOPF 只看 SR1 不够还得写一次 CR1这个很多人知道。容易被忽略的是 STOF 标志只在从机接收方向有效——读方向结束时主机发的是 NACK STOP从机看到的是 AF 而不是 STOPF。所以状态机复位逻辑必须同时处理这两条路漏掉 AF下一次事务的地址阶段会被当成数据字节处理。另一个顺序问题出现在读 SR1 和读 SR2 的组合操作上。清除 ADDR 的硬件要求是先读 SR1 再读 SR2代码里写成两条独立语句没问题。但如果你在两条语句之间插入了别的 SR1 写操作比如为了让 GPIO 翻转更快先写了一下 CR1就可能被硬件理解成错误的清除序列导致 ADDR 清不掉。调试时出现“中断风暴”优先查这种插入操作。5.3 SCL/SDA 被拉死时的排查路径总线拉死是 I2C 现场最棘手的问题因为所有设备都动不了。排查顺序我一般固定如下用逻辑分析仪看 SDA 电平确认是被谁拉低的。断开从机的 SDA如果主机立刻恢复说明是从机拉的。恢复从机连接复位从机。如果复位后总线恢复问题大概率是从机在上一帧没收到 STOP 或没清 AF外设一直认为总线忙。如果复位也不恢复检查硬件设计SDA 外部上拉是否存在、上拉电阻是否过大、I2C 总线上是否有电平转换器引入了额外电容。如果总线上有多个从机一个一个摘下来找出同时响应同一地址的设备。最常见的原因是我们自己的从机状态机还停在上一次事务的某个状态RXNE 里的数据没读走外设持续拉低 SCL 等我们读 DR。这种时候主机那边看到的现象是“地址能应答但第一个数据字节永远发不完”。加一个看门狗式的兜底逻辑就能缓解在 STOPF 和 AF 分支里无条件清 DR 并把状态置回 IDLE。5.4 逻辑分析仪抓 I2C 时序图的参数建议抓 I2C 不需要高采样率但参数要设对参数建议值理由采样率4MHz 以上100kHz/400kHz 总线至少留 10 倍余量触发电平1.5V兼容 3.3V 和 5V 系统的中间电平触发通道SDA先等 START 条件比等 SCL 更直观协议解析I2C 解码开启直接看地址字节和 ACK/NACK 状态抓到的波形重点看三处地址字节的低 7 位是不是你设的地址左移后的值方向位是不是主机预期的那一位数据阶段是谁在驱动 SDA。如果地址匹配但主机收到 NACK基本可以断定从机的 ACK 位没使能或者地址配置不对如果数据阶段全是 0xFF检查从机 TXE 分支里有没有在超界后继续发数据。6. 把从机改造成 256B 模拟 EEPROM 的最小骨架EEPROM 类从机和通用寄存器模型只差一个参数页写。AT24C02 这类器件允许一个写事务里连续写最多 8 字节从机把页边界内的数据缓存下来然后一次性落盘。在 STM32F103 上模拟 EEPROM无非是把这个页写逻辑搬到 RAM 数组里。#define EEPROM_PAGE_SIZE 8 static volatile uint8_t eeprom_mem[256]; static volatile uint8_t page_buf[EEPROM_PAGE_SIZE]; static volatile uint8_t page_pos; static void eeprom_page_commit(uint8_t start, uint8_t len) { for (uint8_t i 0; i len; i) { eeprom_mem[start i] page_buf[i]; } }在上一章的状态机里把 ST_RX_DATA 分支的写操作改成先写入 page_buf并在收到 STOPF 时调用 commit。要注意页边界如果主机跨页写EEPROM 会把超出页尾的部分回卷到页头这是 AT24C 的真实行为模拟时别自作主张改成“自动跨页连续写”那会让依赖这个特性的驱动代码出错。做模拟 EEPROM 时还有两个参数要实际调写周期时间和时钟延展的配合。真实 EEPROM 在写周期内不响应任何命令STM32F103 模拟时不需要真等 5ms但如果主机驱动是按真实 EEPROM 时序写的它会在每次写后加延时你的从机只要保证 STOPF 后状态机立刻回 IDLE 就行。另一个是读方向的地址自增上一章代码里读方向已经做了自动加一但很多 EEPROM 的读操作在读到 0xFF 后会回卷到 0x00如果你的应用不需要把tx_idx和reg_addr的边界改掉即可。NOSTRETCH 位在这类模拟 EEPROM 场景下建议保持默认因为页写提交如果放在中断里时间不可控允许时钟延展能给这段代码留出执行窗口。如果你的主机不支持时钟延展那就要把页写提交挪到主循环里做中断只负责接收这属于另一种优化方向代码结构会差很多。本文还有配套的精品资源点击获取