STM32 软件模拟 I2C 驱动 AT24C02 EEPROM:从时序原理到实测读写全记录

文章目录

    • 摘要
    • [一、为什么还要用软件模拟 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 的全过程。核心要点回顾:

  1. 软件 I2C 的本质是"起始/停止/数据/应答"四种信号的 GPIO 电平编排,方向切换是最大难点;
  2. SDA 方向状态机:发送时输出、读应答和读数据时输入,漏切一次读到全 0xFF;
  3. 器件地址用 0xA0/0xA1,不是 0x50,这是新手最常见的概念错位;
  4. 写周期必须等 5 ms,页写入相比逐字节写能提速近 8 倍;
  5. 上拉电阻 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 及以上需按容量调整地址位处理
相关推荐
振南的单片机世界2 小时前
终端电阻硬核拆解:不接120Ω,RS485信号“撞墙反弹”乱码
arm开发·stm32·单片机·嵌入式硬件
恒锐丰科技韩生2 小时前
SS6625E|3.0‑20V 单通道 16A 峰值 H 桥直流电机驱动 SOP8 门锁玩具机器人专用
嵌入式硬件·硬件工程
aspirant-complete2 小时前
MAC(Apple Silicon)嵌入式基于Clion(stm32CubeMX)环境搭建
stm32·macos
国产HT1621B2 小时前
外挂 YL1621 SSOP48 芯片 vs MCU 内置 LCD 控制器深度对比
科技·单片机·嵌入式硬件·mcu·lcd驱动
zlinear数据采集卡2 小时前
数据采集卡从入门到精通(42):项目实战三——设备预测性维护系统,从布点到预警
开发语言·单片机·嵌入式硬件·安全·fpga开发
嵌入式阿蔡13 小时前
传感器_03:电机驱动——让 GPIO 当指挥官,别让 GPIO 当电源
网络·stm32·单片机·嵌入式硬件·嵌入式实时数据库
国科安芯16 小时前
ASL706S:让 MCU 不再“失忆跑飞“的守护芯片
分布式·单片机·嵌入式硬件·安全·fpga开发·架构
树的枝18 小时前
【STM32】03.中断和EXTI外设
笔记·stm32·单片机·嵌入式硬件
单片机杂货铺19 小时前
【单片机毕业设计选题】基于stm32单片机儿童公路车速度检测报警
stm32·单片机·嵌入式硬件·51单片机·硬件工程·课程设计