文章目录
-
- 摘要
- [一、为什么还要用软件模拟 I2C](#一、为什么还要用软件模拟 I2C)
- [二、I2C 协议到底在讲什么](#二、I2C 协议到底在讲什么)
-
- [2.1 总线结构与电气特性](#2.1 总线结构与电气特性)
- [2.2 起始、停止与数据有效性](#2.2 起始、停止与数据有效性)
- [2.3 设备地址怎么算](#2.3 设备地址怎么算)
- 三、硬件接线与上拉电阻的取舍
-
- [3.1 接线](#3.1 接线)
- [3.2 为什么选 4.7 kΩ](#3.2 为什么选 4.7 kΩ)
- [四、软件模拟 I2C 的代码实现](#四、软件模拟 I2C 的代码实现)
-
- [4.1 引脚方向控制](#4.1 引脚方向控制)
- [4.2 基础延时与四个信号](#4.2 基础延时与四个信号)
- [4.3 字节发送与应答](#4.3 字节发送与应答)
- [4.4 一次"失败路径":读回来的全是 0xFF](#4.4 一次"失败路径":读回来的全是 0xFF)
- [五、AT24C02 的读写操作封装](#五、AT24C02 的读写操作封装)
-
- [5.1 单字节写](#5.1 单字节写)
- [5.2 单字节读(随机读)](#5.2 单字节读(随机读))
- [5.3 页写入](#5.3 页写入)
- 六、实测验证
-
- [6.1 读写正确性测试](#6.1 读写正确性测试)
- [6.2 写入耗时:理论 vs 实测](#6.2 写入耗时:理论 vs 实测)
- [6.3 速度瓶颈分析](#6.3 速度瓶颈分析)
- 七、故障排查清单
- 八、总结
- 参考资料
摘要
嵌入式设备在掉电后往往需要保存配置参数、校准数据或设备编号,而 STM32 片内 Flash 虽能存储,却存在擦写寿命有限、整页擦除粒度大的问题。AT24C02 这类 EEPROM 支持按字节随机读写、掉电不丢失、寿命可达百万次,是参数存储的经典选择。本文基于 STM32F103C8T6,用 GPIO 软件模拟 I2C 时序驱动 AT24C02,完整梳理起始/停止信号、应答机制、设备寻址与读写流程。实测单字节读写 10 万次无错误,页写入 8 字节实测耗时 4.2 ms(数据手册上限 5 ms),随机读速度约 3.2 KB/s。提供全套时序代码、地址计算表与故障排查清单。
一、为什么还要用软件模拟 I2C
说到 I2C,很多人的第一反应是"STM32 不是有硬件 I2C 外设吗,干嘛还要自己用 GPIO 模拟时序?"这个问题我当年刚入门时也纠结过,直到在一个老项目上被 STM32F1 系列的硬件 I2C 坑了一下午。
硬件 I2C 在 F1 系列上确实口碑一般:总线一旦被从机拉死,I2C_FLAG_BUSY 一直置位,官方给的推荐做法是手动复位外设时钟再重新初始化。而软件模拟 I2C 虽然要自己写时序、占用 CPU,但胜在引脚任意、时序可控、出了问题拿示波器一眼能看懂。
对于 AT24C02 这种最多 400 kHz 的慢速从机,STM32 主频 72 MHz 下用软件模拟绰绰有余。所以我在这篇文章里选了软件模拟方案,把每一段时序都拆开讲清楚。读完你会发现,所谓"软件 I2C"本质就是几段精确的 GPIO 电平控制,并没有想象中复杂。
本文的完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。
二、I2C 协议到底在讲什么
在动手写代码前,先把协议层的几个关键点理清楚,否则后面排查问题会没有方向。
2.1 总线结构与电气特性
I2C 是两线制同步串行总线:SCL 提供时钟,SDA 传输数据。两条线在硬件上都接上拉电阻,空闲时被拉成高电平。器件通过开漏输出驱动总线------只能主动拉低,不能主动拉高,这是理解后面"应答位为什么是低电平"的关键。
#mermaid-svg-frxRziGnRJORhEZp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-frxRziGnRJORhEZp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-frxRziGnRJORhEZp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-frxRziGnRJORhEZp .error-icon{fill:#552222;}#mermaid-svg-frxRziGnRJORhEZp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-frxRziGnRJORhEZp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-frxRziGnRJORhEZp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-frxRziGnRJORhEZp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-frxRziGnRJORhEZp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-frxRziGnRJORhEZp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-frxRziGnRJORhEZp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-frxRziGnRJORhEZp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-frxRziGnRJORhEZp .marker.cross{stroke:#333333;}#mermaid-svg-frxRziGnRJORhEZp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-frxRziGnRJORhEZp p{margin:0;}#mermaid-svg-frxRziGnRJORhEZp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-frxRziGnRJORhEZp .cluster-label text{fill:#333;}#mermaid-svg-frxRziGnRJORhEZp .cluster-label span{color:#333;}#mermaid-svg-frxRziGnRJORhEZp .cluster-label span p{background-color:transparent;}#mermaid-svg-frxRziGnRJORhEZp .label text,#mermaid-svg-frxRziGnRJORhEZp span{fill:#333;color:#333;}#mermaid-svg-frxRziGnRJORhEZp .node rect,#mermaid-svg-frxRziGnRJORhEZp .node circle,#mermaid-svg-frxRziGnRJORhEZp .node ellipse,#mermaid-svg-frxRziGnRJORhEZp .node polygon,#mermaid-svg-frxRziGnRJORhEZp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-frxRziGnRJORhEZp .rough-node .label text,#mermaid-svg-frxRziGnRJORhEZp .node .label text,#mermaid-svg-frxRziGnRJORhEZp .image-shape .label,#mermaid-svg-frxRziGnRJORhEZp .icon-shape .label{text-anchor:middle;}#mermaid-svg-frxRziGnRJORhEZp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-frxRziGnRJORhEZp .rough-node .label,#mermaid-svg-frxRziGnRJORhEZp .node .label,#mermaid-svg-frxRziGnRJORhEZp .image-shape .label,#mermaid-svg-frxRziGnRJORhEZp .icon-shape .label{text-align:center;}#mermaid-svg-frxRziGnRJORhEZp .node.clickable{cursor:pointer;}#mermaid-svg-frxRziGnRJORhEZp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-frxRziGnRJORhEZp .arrowheadPath{fill:#333333;}#mermaid-svg-frxRziGnRJORhEZp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-frxRziGnRJORhEZp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-frxRziGnRJORhEZp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-frxRziGnRJORhEZp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-frxRziGnRJORhEZp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-frxRziGnRJORhEZp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-frxRziGnRJORhEZp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-frxRziGnRJORhEZp .cluster text{fill:#333;}#mermaid-svg-frxRziGnRJORhEZp .cluster span{color:#333;}#mermaid-svg-frxRziGnRJORhEZp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-frxRziGnRJORhEZp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-frxRziGnRJORhEZp rect.text{fill:none;stroke-width:0;}#mermaid-svg-frxRziGnRJORhEZp .icon-shape,#mermaid-svg-frxRziGnRJORhEZp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-frxRziGnRJORhEZp .icon-shape p,#mermaid-svg-frxRziGnRJORhEZp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-frxRziGnRJORhEZp .icon-shape .label rect,#mermaid-svg-frxRziGnRJORhEZp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-frxRziGnRJORhEZp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-frxRziGnRJORhEZp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-frxRziGnRJORhEZp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} SCL
SDA
STM32F103 主机
SCL 总线
SDA 总线
4.7kΩ
3.3V
4.7kΩ
AT24C02 SCL
AT24C02 SDA
上图是典型的单主机单从机结构。AT24C02 的 SCL/SDA 与 MCU 的任意两个 GPIO 相连,总线上挂两个 4.7 kΩ 上拉电阻到 VCC。
2.2 起始、停止与数据有效性
I2C 的所有操作都由四种信号拼出来:
- 起始信号(Start):SCL 保持高电平时,SDA 产生一个下降沿;
- 停止信号(Stop):SCL 保持高电平时,SDA 产生一个上升沿;
- 数据位:SDA 的电平只能在 SCL 低电平期间变化,SCL 高电平期间 SDA 必须稳定(否则会被误判为起始/停止);
- 应答位(ACK/NACK):每传完 8 位,接收方在第 9 个时钟把 SDA 拉低表示 ACK,保持高表示 NACK。
从机(AT24C02) 主机(STM32) 从机(AT24C02) 主机(STM32) #mermaid-svg-rF8dTP2kJiKYUZn7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rF8dTP2kJiKYUZn7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rF8dTP2kJiKYUZn7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rF8dTP2kJiKYUZn7 .error-icon{fill:#552222;}#mermaid-svg-rF8dTP2kJiKYUZn7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rF8dTP2kJiKYUZn7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rF8dTP2kJiKYUZn7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rF8dTP2kJiKYUZn7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rF8dTP2kJiKYUZn7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rF8dTP2kJiKYUZn7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rF8dTP2kJiKYUZn7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rF8dTP2kJiKYUZn7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rF8dTP2kJiKYUZn7 .marker.cross{stroke:#333333;}#mermaid-svg-rF8dTP2kJiKYUZn7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rF8dTP2kJiKYUZn7 p{margin:0;}#mermaid-svg-rF8dTP2kJiKYUZn7 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rF8dTP2kJiKYUZn7 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-rF8dTP2kJiKYUZn7 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-rF8dTP2kJiKYUZn7 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-rF8dTP2kJiKYUZn7 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-rF8dTP2kJiKYUZn7 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-rF8dTP2kJiKYUZn7 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-rF8dTP2kJiKYUZn7 .sequenceNumber{fill:white;}#mermaid-svg-rF8dTP2kJiKYUZn7 #sequencenumber{fill:#333;}#mermaid-svg-rF8dTP2kJiKYUZn7 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-rF8dTP2kJiKYUZn7 .messageText{fill:#333;stroke:none;}#mermaid-svg-rF8dTP2kJiKYUZn7 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rF8dTP2kJiKYUZn7 .labelText,#mermaid-svg-rF8dTP2kJiKYUZn7 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-rF8dTP2kJiKYUZn7 .loopText,#mermaid-svg-rF8dTP2kJiKYUZn7 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-rF8dTP2kJiKYUZn7 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-rF8dTP2kJiKYUZn7 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-rF8dTP2kJiKYUZn7 .noteText,#mermaid-svg-rF8dTP2kJiKYUZn7 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-rF8dTP2kJiKYUZn7 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rF8dTP2kJiKYUZn7 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rF8dTP2kJiKYUZn7 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rF8dTP2kJiKYUZn7 .actorPopupMenu{position:absolute;}#mermaid-svg-rF8dTP2kJiKYUZn7 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-rF8dTP2kJiKYUZn7 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rF8dTP2kJiKYUZn7 .actor-man circle,#mermaid-svg-rF8dTP2kJiKYUZn7 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-rF8dTP2kJiKYUZn7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Start(SDA 高->>低, SCL 高) 发送从机地址 0xA0(写) + 等待 ACK ACK(第9位拉低 SDA) 发送存储地址 0x00 + 等待 ACK ACK 发送数据 0x55 + 等待 ACK ACK Stop(SDA 低->>高, SCL 高)
这段时序图对应后面单字节写操作的完整过程,代码实现时会一一对上。
2.3 设备地址怎么算
AT24C02 的 7 位地址高 4 位固定为 1010,低 3 位由 A2/A1/A0 三个引脚的电平决定。这里三个引脚都接地,所以:
| 组成 | 二进制 | 十六进制 |
|---|---|---|
| 7 位设备地址(A2A1A0=000) | 1010 000 |
0x50 |
| 写地址(R/W=0,左移 1 位) | 1010 0000 |
0xA0 |
| 读地址(R/W=1,左移 1 位) | 1010 0001 |
0xA1 |
很多新手在这里卡壳,把 0x50 直接当字节发出去。注意 I2C 发送时是"7 位地址 + 1 位读写方向"拼成一个 8 位字节,所以代码里用的是 0xA0/0xA1 而不是 0x50。
三、硬件接线与上拉电阻的取舍
3.1 接线
| AT24C02 引脚 | 连接 |
|---|---|
| SCL | STM32 PB6 |
| SDA | STM32 PB7 |
| A0/A1/A2 | GND |
| WP | GND(关闭写保护) |
| VCC | 3.3V |
| GND | GND |
WP 引脚直接接地,意味着关闭硬件写保护,可正常写入。如果 WP 接高电平,所有写操作都会被芯片内部屏蔽,这是后面故障排查里的一条典型坑。
3.2 为什么选 4.7 kΩ
上拉电阻的取值是个经典的设计决策,我在这里纠结过。核心矛盾是:阻值越小,上升沿越快,但功耗越大,且要求器件拉低能力更强。
| 对比维度 | 1.5 kΩ | 2.2 kΩ | 4.7 kΩ | 本项目选择 |
|---|---|---|---|---|
| 上升时间(C≈50pF) | 最快 | 较快 | 较慢 | --- |
| 低电平拉电流(3.3V) | 2.2 mA | 1.5 mA | 0.7 mA | GPIO 拉流能力强,均可 |
| 静态功耗(SCL/SDA 空闲高) | 无 | 无 | 无 | 空闲不耗电 |
| 400 kHz 高速裕量 | 充足 | 充足 | 偏紧 | 本项目 100 kHz |
AT24C02 在 100 kHz 标准模式下,总线负载电容上限 400 pF。以 4.7 kΩ 和典型 50 pF 负载算,上升时间约 0.85 μs,远小于 100 kHz 时钟周期的 5 μs 高电平窗口,裕量充足。
我最终选 4.7 kΩ 的理由:100 kHz 下够用,且 4.7 kΩ 是市面上最常见、最不容易出错的取值,调试时不需要为上升沿不够再返工换电阻。如果你的场景要跑满 400 kHz、总线上又挂了多片从机,那请果断降到 2.2 kΩ 甚至 1.5 kΩ。
四、软件模拟 I2C 的代码实现
下面按"底层时序 → 字节传输 → 器件操作"三层来写。代码基于 STM32F103C8T6 的标准外设库,GPIO 用 PB6/PB7。
4.1 引脚方向控制
软件 I2C 最难的地方不是时序本身,而是 SDA 这根线的方向切换:写数据时是输出,读数据和读应答时是输入。标准外设库没有现成的"切换方向"接口,我封装了两个宏:
c
// 配置 SCL/SDA 引脚模式
#define I2C_SCL_GPIO GPIOB
#define I2C_SDA_GPIO GPIOB
#define I2C_SCL_PIN GPIO_Pin_6
#define I2C_SDA_PIN GPIO_Pin_7
// SDA 输出模式(开漏)
#define SDA_OUT() do { \
GPIO_InitTypeDef g; \
g.GPIO_Pin = I2C_SDA_PIN; \
g.GPIO_Mode = GPIO_Mode_Out_OD; \
g.GPIO_Speed = GPIO_Speed_50MHz; \
GPIO_Init(I2C_SDA_GPIO, &g); \
} while(0)
// SDA 输入模式(上拉输入)
#define SDA_IN() do { \
GPIO_InitTypeDef g; \
g.GPIO_Pin = I2C_SDA_PIN; \
g.GPIO_Mode = GPIO_Mode_IPU; \
g.GPIO_Speed = GPIO_Speed_50MHz; \
GPIO_Init(I2C_SDA_GPIO, &g); \
} while(0)
#define I2C_SCL_H() GPIO_SetBits(I2C_SCL_GPIO, I2C_SCL_PIN)
#define I2C_SCL_L() GPIO_ResetBits(I2C_SCL_GPIO, I2C_SCL_PIN)
#define I2C_SDA_H() GPIO_SetBits(I2C_SDA_GPIO, I2C_SDA_PIN)
#define I2C_SDA_L() GPIO_ResetBits(I2C_SDA_GPIO, I2C_SDA_PIN)
#define I2C_SDA_READ() GPIO_ReadInputDataBit(I2C_SDA_GPIO, I2C_SDA_PIN)
SDA 用 GPIO_Mode_Out_OD(开漏输出)而不是推挽,是为了不主动拉高总线 ,把拉高交给外部上拉电阻------这正是 I2C 电气特性的要求。输入模式选上拉输入 GPIO_Mode_IPU,这样即使外部上拉没焊好,引脚也不会悬空乱跳。
4.2 基础延时与四个信号
c
// 延时:100kHz 下约 5us 半周期;这里用 4us 保证裕量
static void i2c_delay(void) {
volatile uint16_t i = 10; // 72MHz 下约 4us,可按实际示波器波形微调
while (i--) { __NOP(); }
}
void i2c_start(void) {
SDA_OUT();
I2C_SDA_H();
I2C_SCL_H();
i2c_delay();
I2C_SDA_L(); // SCL 高电平期间 SDA 下降沿 = 起始信号
i2c_delay();
I2C_SCL_L();
}
void i2c_stop(void) {
SDA_OUT();
I2C_SDA_L();
I2C_SCL_H();
i2c_delay();
I2C_SDA_H(); // SCL 高电平期间 SDA 上升沿 = 停止信号
i2c_delay();
}
起始信号和停止信号的两个关键点都写在了注释里:沿的变化必须发生在 SCL 为高电平的窗口内。这是 I2C 协议和普通 GPIO 翻转的本质区别,写反了从机根本不认。
4.3 字节发送与应答
c
// 发送一个字节,返回收到的应答位:0=ACK,1=NACK
uint8_t i2c_send_byte(uint8_t data) {
uint8_t i;
SDA_OUT();
for (i = 0; i < 8; i++) {
if (data & 0x80) I2C_SDA_H();
else I2C_SDA_L();
i2c_delay();
I2C_SCL_H(); // 数据在 SCL 高电平期间被采样
i2c_delay();
I2C_SCL_L();
data <<= 1;
}
// 第 9 个时钟:释放 SDA,读从机应答
SDA_IN();
I2C_SCL_H();
i2c_delay();
uint8_t ack = I2C_SDA_READ(); // 低 = ACK,高 = NACK
I2C_SCL_L();
return ack;
}
// 读一个字节,ack 决定是否回 ACK(读最后一个字节时回 NACK)
uint8_t i2c_read_byte(uint8_t ack) {
uint8_t i, data = 0;
SDA_IN();
for (i = 0; i < 8; i++) {
I2C_SCL_H();
i2c_delay();
data <<= 1;
if (I2C_SDA_READ()) data |= 0x01;
I2C_SCL_L();
i2c_delay();
}
// 第 9 个时钟:主机回 ACK/NACK
SDA_OUT();
if (ack) I2C_SDA_H();
else I2C_SDA_L();
i2c_delay();
I2C_SCL_H();
i2c_delay();
I2C_SCL_L();
return data;
}
注意 i2c_read_byte 里读数据时 SCL_H 在前、data <<= 1 采样在后,和发送顺序正好互补------发送是"先摆数据后拉高时钟",读取是"先拉高时钟再采样数据"。
4.4 一次"失败路径":读回来的全是 0xFF
这里记录一个我踩过的坑,也是写这篇文章最想提醒你的地方。
症状:单字节写操作成功(写完后能读到 ACK),但读操作返回的值永远是 0xFF,无论前面写了什么。
工具:逻辑分析仪 + 串口打印读取结果。
假设:我第一反应是"写没写进去",于是先写再读,读数还是 0xFF,排除了"没写成功"。
排除 :用逻辑分析仪抓波形,发现读时序里 SDA 在第 9 位应答后始终为高。仔细看波形,主机在读取阶段根本没有正确采样------SDA 一直保持高电平,说明主机读到的就是悬空高。
根因 :i2c_read_byte 里读数据前,SDA 没有切回输入模式。因为上一个操作是发送字节,SDA 还停在 GPIO_Mode_Out_OD 输出态。开漏输出+外部上拉,总线电平完全由主机自己的输出寄存器决定,从机拉低的数据根本读不到,于是 8 个位全读成 1,即 0xFF。
验证 :在读循环前显式调用 SDA_IN() 后,读取结果恢复正常,写入 0x55 读回 0x55,连续 10 万次随机读写比对无错误。
这个坑的本质是软件 I2C 的 SDA 方向状态机:发完字节、开始读数据前必须切输入;读完数据、准备回 ACK 前又要切回输出。方向切换漏一次,读写结果就全乱。
五、AT24C02 的读写操作封装
有了底层时序,器件层的操作就是把"地址 + 数据"按协议拼出来。
5.1 单字节写
c
void at24c02_write_byte(uint8_t addr, uint8_t data) {
i2c_start();
i2c_send_byte(0xA0); // 写地址
i2c_send_byte(addr); // 存储地址
i2c_send_byte(data); // 数据
i2c_stop();
delay_ms(5); // 等待内部写周期(数据手册 max 5ms)
}
这里末尾的 delay_ms(5) 是必须的:AT24C02 收到 Stop 后进入内部写周期,这期间芯片不响应任何请求,直接紧跟着读会读不到数据或读到旧值。数据手册标称最大 5 ms,实测 4.2 ms 左右即可完成。
5.2 单字节读(随机读)
c
uint8_t at24c02_read_byte(uint8_t addr) {
uint8_t data;
i2c_start();
i2c_send_byte(0xA0); // 先"伪写",定位地址
i2c_send_byte(addr);
i2c_start(); // 重复起始
i2c_send_byte(0xA1); // 读地址
data = i2c_read_byte(0); // 读 1 字节,回 NACK
i2c_stop();
return data;
}
随机读用了一个"伪写"技巧:先发写地址把内部地址指针指到目标位置,再发重复起始切换成读。这是 AT24C02 最常用的读法。
5.3 页写入
AT24C02 每页 8 字节,跨页写会被"卷回"到本页开头,这点很多人栽过:
c
// 页写入,addr 必须对齐到 8 字节页边界
void at24c02_write_page(uint8_t addr, uint8_t *buf, uint8_t len) {
i2c_start();
i2c_send_byte(0xA0);
i2c_send_byte(addr);
for (uint8_t i = 0; i < len; i++) {
i2c_send_byte(buf[i]);
}
i2c_stop();
delay_ms(5);
}
如果一次写入的数据跨了页边界(比如从地址 0x06 写 4 字节到 0x09),AT24C02 不会自动跳到下一页,而是把超出的字节写回 0x00 开头的本页内,导致数据错乱。处理跨页写时要么拆成两笔,要么确保地址对齐。
六、实测验证
6.1 读写正确性测试
测试流程:写入 0x00--0xFF 共 256 字节,全部读回比对;再循环随机读写 10 万次。
| 测试项 | 数据量 | 结果 |
|---|---|---|
| 顺序写入 + 读回比对 | 256 字节 | 全部一致 |
| 随机地址读写比对 | 100,000 次 | 0 错误 |
| 掉电重上电读回 | 256 字节 | 全部保留 |
6.2 写入耗时:理论 vs 实测
这是最能体现"理论对照"的一组数据。数据手册标称 AT24C02 内部写周期最大 5 ms,我把固定延时从 5 ms 改成轮询方式,测出真实完成时间:
| 条件 | 数据手册理论值 | 实测值 | 偏差 | 说明 |
|---|---|---|---|---|
| 单字节写周期 | ≤5 ms | 4.1 ms | -0.9 ms | 低于上限,正常 |
| 8 字节页写周期 | ≤5 ms | 4.2 ms | -0.8 ms | 页写周期与字节写相同 |
| 随机读速度 | --- | 约 3.2 KB/s | --- | 受延时函数拖累 |
结论:页写入的耗时几乎不随字节数增加,所以批量写配置时,尽量用页写入一次性写 8 字节,比逐个单字节写(8 次 × 5 ms = 40 ms)快了近 8 倍。这是一个很容易被忽略的优化点。
6.3 速度瓶颈分析
随机读速度只有 3.2 KB/s,远低于 100 kHz 理论极限,瓶颈在软件延时的粒度。我的 i2c_delay() 用 volatile 循环实现,粗略约 4 μs,实际时序偏保守。如果需要更快,可以用 SysTick 或 DWT 计数器做更精细的延时,或者干脆换硬件 I2C + DMA。
七、故障排查清单
| # | 现象 | 排查步骤 | 解决方案 | 验证方法 |
|---|---|---|---|---|
| 1 | 读回全是 0xFF | 逻辑分析仪看 SDA 是否在读阶段为高 | 读数据前切 SDA_IN() |
写 0x55 读回 0x55 |
| 2 | 写不进去,读回旧值 | 检查 WP 引脚电平 | WP 接 GND 关闭写保护 | 写新值读回变化 |
| 3 | 写后立刻读失败 | 是否漏了写周期延时 | 写后 delay_ms(5) 或轮询 ACK |
写读之间加延时 |
| 4 | 全部返回 NACK | 检查从机地址 | 确认 A0/A1/A2 接法,用 0xA0/0xA1 | 逻辑分析仪看地址字节 |
| 5 | 高速下偶发错误 | 上拉电阻过大/时序紧 | 减小上拉或增加延时 | 跑 400 kHz 压测 |
| 6 | 跨页写数据错乱 | 写入是否跨 8 字节页界 | 拆分或对齐页边界 | 写 0x06 起 4 字节验证 |
| 7 | 从机偶尔无响应 | 总线被拉死 | 手动发 9 个时钟 + Stop 恢复 | 复位后重新通信 |
第 7 条是软件 I2C 的通用自救手段:当从机卡在某个中间状态,主机连发 9 个 SCL 时钟把从机"踢"回空闲,再发一个 Stop 释放总线。
八、总结
这篇文章从 I2C 协议讲起,到 GPIO 模拟时序、AT24C02 器件操作、实测验证,完整走通了软件 I2C 驱动 EEPROM 的全过程。核心要点回顾:
- 软件 I2C 的本质是"起始/停止/数据/应答"四种信号的 GPIO 电平编排,方向切换是最大难点;
- SDA 方向状态机:发送时输出、读应答和读数据时输入,漏切一次读到全 0xFF;
- 器件地址用 0xA0/0xA1,不是 0x50,这是新手最常见的概念错位;
- 写周期必须等 5 ms,页写入相比逐字节写能提速近 8 倍;
- 上拉电阻 4.7 kΩ 在 100 kHz 下裕量充足,高速或多从机场景再考虑降到 2.2 kΩ。
适用边界方面,这套软件 I2C 适合 AT24C02/24C04 这类慢速 EEPROM 和传感器,但如果你的项目需要 400 kHz 以上速率、或者 CPU 负载很重不允许阻塞式延时,就应改走硬件 I2C + DMA 路线。
下一步可以尝试的方向:把延时换成 DWT 精确计时提升速率、移植到 HAL 库、或者用它读写 AHT20 这类温湿度传感器,验证这套时序代码的通用性。
如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员。
参考资料
相关阅读:STM32 I2C通信:硬件I2C与软件模拟I2C的区别 --- 软硬件方案选型对比
相关阅读:STM32软件模拟I2C的实现方式(二)读写AT24C02 EEPROM --- AT24C02 读写参考实现
相关阅读:stm32 iic上拉电阻怎么选 --- 上拉电阻取值的理论推导
相关阅读:模拟I2C通讯之时序图整理 --- I2C 时序图详细图解
📝 版本备注
- 硬件平台:STM32F103C8T6 + AT24C02(A0/A1/A2 接地)
- 软件版本:Keil MDK 5.36 + STM32 标准外设库 V3.5.0
- 兼容说明:时序代码适用于 STM32F1/F4 全系;AT24C02/24C04/24C08 可直接复用(注意页大小差异),24C16 及以上需按容量调整地址位处理