基于libopencm3的STM32F103 I2C从站状态机实现与调试
2026/9/16 2:36:21 网站建设 项目流程

简介:基于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。一帧完整事务的典型顺序是:START,7 位从机地址 + 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 后再写 CR1
AF主机回 NACK,或地址未匹配读 SR1 后再读 SR2
BERR总线错误,如 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,外设就完全不拉 SCL,DR 里的数据会被下一字节覆盖,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 为例,默认引脚是 PB6(SCL)和 PB7(SDA)。这两个引脚必须配置为复用开漏输出,同时外部加 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_disableenable来复位外设,那也能清,但会把中断使能位一起清掉,得重新设置。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 现场最棘手的问题,因为所有设备都动不了。排查顺序我一般固定如下:

  1. 用逻辑分析仪看 SDA 电平,确认是被谁拉低的。断开从机的 SDA,如果主机立刻恢复,说明是从机拉的。
  2. 恢复从机连接,复位从机。如果复位后总线恢复,问题大概率是从机在上一帧没收到 STOP 或没清 AF,外设一直认为总线忙。
  3. 如果复位也不恢复,检查硬件设计:SDA 外部上拉是否存在、上拉电阻是否过大、I2C 总线上是否有电平转换器引入了额外电容。
  4. 如果总线上有多个从机,一个一个摘下来,找出同时响应同一地址的设备。

最常见的原因是我们自己的从机状态机还停在上一次事务的某个状态,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_idxreg_addr的边界改掉即可。

NOSTRETCH 位在这类模拟 EEPROM 场景下建议保持默认,因为页写提交如果放在中断里,时间不可控,允许时钟延展能给这段代码留出执行窗口。如果你的主机不支持时钟延展,那就要把页写提交挪到主循环里做,中断只负责接收,这属于另一种优化方向,代码结构会差很多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询