专栏:STM32F103入门教程
环境:Keil‑MDK + STM32F103 标准库3.5版本
一、I2C
I2C 全称 Inter‑Integrated Circuit,集成电路总线,由 NXP(原飞利浦)在 1980 年代开发,只需要 SDA(数据线) 和 SCL(时钟线) 两根信号线即可完成通信。广泛用于单片机连接各类低速外设传感器、EEPROM、OLED、MPU6050、RTC 时钟芯片等。

I2C 的主要特点
两线制 :只有 SDA 和 SCL,节省引脚和 PCB 布线空间。
同步通信 :数据传输由主设备产生的 SCL 时钟同步,属于同步串行通信。
半双工 :SDA 一根线承担收发,某一时刻只能单向传输。
主从式通信 :每个通信周期由主设备发起,从设备被动响应;支持一主多从、多主机系统。
地址寻址:每个从设备有唯一地址,主设备通过发送从设备地址来选择通信对象;标准地址为 7 位,扩展地址为 10 位。
速率等级
标准模式 :100 kbit/s,兼容性最好,调试优先用这个。
快速模式 :400 kbit/s
I2C 不适合长距离走线,主要用于板内芯片之间通信。
核心通信机制
主从模式
主机 (Master) :一般是单片机,产生 SCL 时钟、发起通信。
从机 (Slave) :传感器、EEPROM 等外设,每个从机拥有唯一 7bit 设备地址;主机发送地址,选中总线上要对话的设备,其余设备忽略总线数据。
注意:代码中地址经常需要左移 1 位,最低位作为读 / 写控制位。
时序信号
起始信号 START :SCL 为高时,SDA 由高变低,通信开始。

停止信号 STOP :SCL 为高时,SDA 由低变高,结束通信。

应答无应答 ACK/NACK:每传输完 1 字节(8bit,高位先发),接收方在第 9 个时钟拉低 SDA = ACK 应答成功;保持高电平 = NACK,代表无应答、设备未响应。
通信流程
主机发送起始 → 发送从机地址 + 读写位 → 等待从机 ACK → 收发数据(每字节后等待 ACK) → 发送停止信号结束。
两种实现方式(单片机)
硬件 I2C :片上外设硬件控制器,由硬件产生时序,CPU 负担小。
软件模拟 I2C:普通 GPIO 引脚,软件模拟 SCL/SDA 时序。优点:兼容性极强,不受硬件外设 bug 影响;缺点占用 CPU 时间,不适合 RTOS 高抢占场景。
二、AT24C02
AT24C02 是I2C 接口的串行 EEPROM,非常常用的掉电非易失存储器,单片机项目用来保存参数、配置、掉电需要保存的数据(阈值、计数、设备地址等)。
02 代表容量:2Kbit = 256 字节,地址范围:0‑255。
1、基础参数
容量:2Kbit,256 Bytes
接口:标准 I2C,支持 100kHz /400kHz
供电:1.8V‑5.5V,3.3V、5V 单片机都可以直接用
写入寿命:10 万次擦写;数据保存时间>100 年
器件 I2C 从机地址:0b1010 A2 A1 A0
A2/A1/A0 是芯片硬件引脚,用来修改设备地址,一条 I2C 总线最多挂载 8 片 AT24C02。
引脚全部悬空 / 接 GND 时,7 位地址:0x50。
⚠️代码注意:I2C 发送时地址需要左移一位,所以写地址是0xA0,读地址0xA1。
2、引脚定义

A0、A1、A2:地址引脚,接 GND/VCC,修改芯片 I2C 设备地址。
SCL:I2C 时钟
SDA:I2C 数据
WP:写保护引脚
WP 接 GND:可以正常读写;
WP 接 VCC:写保护开启,只能读,不能写入,防止误擦除数据。
VCC:电源
GND:地
SDA、SCL 必须外接 上拉电阻;STM32F103 硬件 I2C 必须外部上拉。
3、读写逻辑
***字节写(单字节写入) ***
I2C 起始 → 发送器件写地址 (0xA0) → 发送芯片内部存储地址 (0~255) → 发送要写入的数据 → STOP 停止。
⚠️写完之后必须等待写入周期! AT24C02 内部烧录需要时间,典型 5ms。
写完立刻读,会读取失败。可以延时 5ms,或者轮询 I2C ACK 等待芯片内部烧写完成。

随机读(指定地址读)
I2C 起始,发送器件写地址 0xA0,发送要读取的内部地址;
重新发起起始,发送器件读地址 0xA1;
读取 1 字节数据,主机发送 NACK;
STOP 结束。

4、工程高频坑点(STM32F103 调试必看)
写完忘记等待 5ms 写周期,写完马上读,读出来乱码。
I2C 地址忘记左移,一直 NACK 无应答。AT24C02 7bit 地址 0x50,I2C 发送写地址要 0xA0。
WP 引脚接 VCC,开启写保护,写不进去,读正常。
STM32F103 硬件 I2C 本身存在卡死,也可以通过检查超时,复位I2C总线等方法或推荐软件模拟 I2C 驱动 AT24C02。
不要频繁循环擦写同一个地址,10 万次寿命,高频循环写会把芯片写坏。
5、典型使用场景
保存设备配置参数:阈值、波特率、设备编号;
6、同系列型号快速区分
AT24C01:128 字节
AT24C02:256 字节(最常用)
AT24C04:512 字节
AT24C08:1K
AT24C16:2K
三、接线

AT24C02 三个地址线A0,A1,A2全部接地。对应设备地址的bit1,bit2,bit3全为0。
SCL和SDA模块已经自带上拉电阻,否则要加接。
WP接到GND,数据才能写进去。
STM32F103C8T6 --PB6 <---------->AT24C02 ---SCL
STM32F103C8T6 --PB7 <---------->AT24C02 ---SDA
四、工程与程序编写
4.1工程复制
复制UART工程文件夹为I2C文件夹,UART.uvprojx复制为I2C.uvprojx。
4.2 添加驱动文件
1.Hardware文件夹下面新建I2c和At24c02两个文件夹。
2.工程添加i2c_sim.h,i2c_sim.c,at24c02.h,at24c02.c文件。

i2c_sim.h
c
#ifndef __I2C_SIM_H
#define __I2C_SIM_H
#include "stm32f10x.h"
// 引脚定义 PB6 SCL PB7 SDA
#define I2C_SCL_PIN GPIO_Pin_6
#define I2C_SDA_PIN GPIO_Pin_7
#define I2C_GPIO_PORT GPIOB
void i2c_sim_init(void);
void i2c_sim_start(void);
void i2c_sim_stop(void);
uint8_t i2c_sim_write_byte(uint8_t dat);
uint8_t i2c_sim_read_byte(uint8_t ack);
#endif
i2c_sim.c
c
#include "i2c_sim.h"
#include "delay.h"
/*
简单微延时,根据系统主频72MHz,微调延时保证I2C时序(100K)
Cortex-M3:for 循环每次迭代:
i 自增、比较跳转,一次循环大约 3~4 个 CPU 周期
循环 100 次:
周期数 ≈ 100 × 4 = 400 个 CPU 时钟周期
CPU 时钟 72MHz,1 个周期 = 1/72M ≈13.889ns
总时间 ≈ 400 ×13.889ns ≈ 5.55μs
不开编译优化(level 0),i2c_delay() 大概 5~6μs
标准 100kHz I2C:SCL 周期 10μs,高低电平最小建立 / 保持时间典型要求:
tSU;STA(起始建立) ≥4.7μs
tHD;STA(起始保持) ≥4.0μs
tLOW SCL 低电平 ≥4.7μs
tHIGH SCL 高电平 ≥4.0μs
*/
static void i2c_delay(void)
{
uint32_t i;
for(i=0;i<100;i++);
}
//初始化GPIO
void i2c_sim_init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitStruct.GPIO_Pin = I2C_SCL_PIN | I2C_SDA_PIN;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; //推挽输出
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStruct);
//总线空闲 SCL=1 SDA=1
GPIO_SetBits(I2C_GPIO_PORT,I2C_SCL_PIN);
GPIO_SetBits(I2C_GPIO_PORT,I2C_SDA_PIN);
}
// I2C_SDA_PIN 设置为推挽输出
void sda_out(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitStruct.GPIO_Pin = I2C_SDA_PIN;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; //推挽输出
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStruct);
}
//I2C_SDA_PIN 浮空输入
void sda_in(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitStruct.GPIO_Pin = I2C_SDA_PIN;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING; //浮空输入
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStruct);
}
//I2C起始信号:SCL高,SDA拉低
void i2c_sim_start(void)
{
sda_out();
GPIO_SetBits(I2C_GPIO_PORT,I2C_SDA_PIN);
GPIO_SetBits(I2C_GPIO_PORT,I2C_SCL_PIN);//SDA=1,SCL=1 (总线空闲状态)
i2c_delay();
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SDA_PIN);//SDA 拉低 → 完成起始信号边沿,告诉所有从机:通信要开始了
i2c_delay();
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SCL_PIN);//把时钟线拉低,为主机输出第 1 位数据做准备
//***SCL 高的时候,SDA 不能随便变化,否则会误触发起始 / 停止***
}
//I2C停止信号:SCL高,SDA拉高
void i2c_sim_stop(void)
{
sda_out();
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SDA_PIN); //SDA=0
GPIO_SetBits(I2C_GPIO_PORT,I2C_SCL_PIN); //SCL=1
i2c_delay();
GPIO_SetBits(I2C_GPIO_PORT,I2C_SDA_PIN); //SDA=1
i2c_delay();
}
//写1字节,返回ACK(0=应答,1=NACK无应答)
uint8_t i2c_sim_write_byte(uint8_t dat)
{
uint8_t i;
uint8_t ack;
sda_out();
for(i=0;i<8;i++)//一个字节8位
{
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SCL_PIN);//先拉低SCL,为后面的发送SDA的位数据作准备
i2c_delay();
if(dat & 0x80)//先发送高位
{
GPIO_SetBits(I2C_GPIO_PORT,I2C_SDA_PIN);//置高
}
else
{
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SDA_PIN);//置低
}
dat <<=1;
i2c_delay();
GPIO_SetBits(I2C_GPIO_PORT,I2C_SCL_PIN);//SCL 拉高 SDA数据有效
i2c_delay();
}
//释放SDA,读取应答:切输入!!
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SCL_PIN);//把时钟线拉到低电平,准备进入 ACK 阶段
sda_in(); //重点!切输入读取从机应答 SCL 高电平期间不允许随便切换 SDA 输入输出
i2c_delay();
GPIO_SetBits(I2C_GPIO_PORT,I2C_SCL_PIN); //SCL拉高,这就是ACK的第9个时钟!
i2c_delay();
if(GPIO_ReadInputDataBit(I2C_GPIO_PORT,I2C_SDA_PIN))//读取从机应答
ack = 1; //NACK
else
ack = 0; //ACK
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SCL_PIN);//次拉低SCL,结束ACK时序
sda_out(); //读完切回输出
return ack;
}
/*
写 1bit:先输出 bit,再左移
读 1bit:先左移,再存入 bit
*/
//读1字节: ack=0 发送ACK继续读;ack=1发送NACK结束读取
uint8_t i2c_sim_read_byte(uint8_t ack)
{
uint8_t i;
uint8_t dat = 0;
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SCL_PIN);
GPIO_SetBits(I2C_GPIO_PORT,I2C_SDA_PIN);
sda_in();
for(i=0;i<8;i++)//先读到的是高位
{
dat <<= 1;//左移1位,放在前面,读一个字节只需求移位7位,放在最后面时读bit0时就不能再移位了。
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SCL_PIN);
i2c_delay();
GPIO_SetBits(I2C_GPIO_PORT,I2C_SCL_PIN);
i2c_delay();
if(GPIO_ReadInputDataBit(I2C_GPIO_PORT,I2C_SDA_PIN))
{
dat |= 0x01;
}
}
//准备发送应答位
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SCL_PIN);
sda_out();
if(ack)
{
GPIO_SetBits(I2C_GPIO_PORT,I2C_SDA_PIN); //NACK 不应答
}
else
{
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SDA_PIN); //ACK
}
i2c_delay();
GPIO_SetBits(I2C_GPIO_PORT,I2C_SCL_PIN);//第9个时钟,ACK/NACK 有效
i2c_delay();
GPIO_ResetBits(I2C_GPIO_PORT,I2C_SCL_PIN);
return dat;
}
at24c02.h
```c
#ifndef __AT24C02_H
#define __AT24C02_H
#include "i2c_sim.h"
#include "delay.h"
/*
器件地址字节的第 8 位(最低 bit0)是读写选择位:
- 该位 = 1:发起读操作
- 该位 = 0:发起写操作
> I2C 标准:bit0 → R/W 位。
*/
#define AT24C02_ADDR_WR 0xA0 //1010 000 0
#define AT24C02_ADDR_RD 0xA1 //1010 000 1
void at24c02_write_byte(uint8_t addr,uint8_t data);
uint8_t at24c02_read_byte(uint8_t addr);
void at24c02_test(void);
#endif
at24c02.c
c
#include "at24c02.h"
#include <stdio.h>
//写单个字节
void at24c02_write_byte(uint8_t addr,uint8_t data)
{
uint8_t ack;
i2c_sim_start();
ack = i2c_sim_write_byte(AT24C02_ADDR_WR);
if(ack){
i2c_sim_stop();
printf("at24c02_write_byte ack 0:%d ",ack);
return;
}
ack = i2c_sim_write_byte(addr);
if(ack){
i2c_sim_stop();
printf("at24c02_write_byte ack 1:%d ",ack);
return;
}
ack = i2c_sim_write_byte(data);
if(ack){
i2c_sim_stop();
printf("at24c02_write_byte ack 2:%d ",ack);
return;
}
i2c_sim_stop();
delay_ms(10); //AT24C02内部烧录等待时间,必须!
}
//读单个字节
uint8_t at24c02_read_byte(uint8_t addr)
{
uint8_t res;
i2c_sim_start(); //起始信号
i2c_sim_write_byte(AT24C02_ADDR_WR);//从机地址写数据
i2c_sim_write_byte(addr); //要读对应数据的地址
i2c_sim_start(); //起始信号
i2c_sim_write_byte(AT24C02_ADDR_RD);//从机地址读数据
res = i2c_sim_read_byte(1); //NACK结束
i2c_sim_stop();
return res;
}
void at24c02_test(void)
{
uint8_t i,data;
printf("写之前读 \r\n");
for(i=0;i<10;i++){
data = at24c02_read_byte(i);
printf("add[%d]: %d \r\n",i,data);
}
for(i=0;i<10;i++){
data = i+10;
at24c02_write_byte(i,data);
printf("写入 add[%d]: %d \r\n",i,data);
}
printf("写之后读 \r\n");
for(i=0;i<10;i++){
data = at24c02_read_byte(i);
printf("add[%d]: %d \r\n",i,data);
}
}
AT24Cxx 型号和地址
| 型号 | 容量 | 页大小 | 地址位数 | 器件地址格式 (7位+R/W) | A2 A1 A0 引脚作用 | 内存地址发送 |
|---|---|---|---|---|---|---|
| AT24C01 | 128 Byte | 8 Byte | 8位 | 1010 A2 A1 A0 |
全部用于片选 | 1字节 0x00~0x7F |
| AT24C02 | 256 Byte | 8 Byte | 8位 | 1010 A2 A1 A0 |
全部用于片选 | 1字节 0x00~0xFF |
| AT24C04 | 512 Byte | 16 Byte | 9位 | 1010 A2 A1 P0 |
A2/A1 片选,A0=NC | 1字节,P0=地址 bit8 |
| AT24C08 | 1K Byte | 16 Byte | 10位 | 1010 A2 P1 P0 |
A2 片选,A1/A0=NC | 1字节,P1/P0=地址 bit9/8 |
| AT24C16 | 2K Byte | 16 Byte | 11位 | 1010 P2 P1 P0 |
全部 NC,内部做地址高位 | 1字节,P2/P1/P0=地址 bit10/9/8 |
AT24C02 随机读 1 字节流程
| 步骤 | 操作 | 发送方 | 说明 |
|---|---|---|---|
| ① | START | 主机 | SCL 高电平时,SDA 下降沿 |
| ② | 发送设备地址 0xA0 | 主机 | 0xA0 = 0b1010 0000,R/W=0(写模式) |
| ③ | ACK | 从机 | AT24C02 拉低 SDA 应答 |
| ④ | 发送内存地址(Word Address) | 主机 | 比如 0x20,告诉从机读哪个位置 |
| ⑤ | ACK | 从机 | 从机应答,地址已接收 |
| ⑥ | Repeated START (Sr) | 主机 | 不释放总线,直接重新开始 |
| ⑦ | 发送设备地址 0xA1 | 主机 | 0xA1 = 0b1010 0001,R/W=1(读模式)⚡ |
| ⑧ | ACK | 从机 | 从机准备发送数据 |
| ⑨ | 读取 1 字节数据 | 从机→主机 | 从机发送指定地址的数据,MSB 先 |
| ⑩ | NACK | 主机 | 主机不拉低SDA = 不再读了 |
| ⑪ | STOP | 主机 | SCL 高电平时,SDA 上升沿,结束通信 |
AT24C02:2Kbit → 256 字节存储空间,存储地址范围 0x00 ~ 0xFF
AT24C02 器件地址:写地址0xA0,读地址0xA1(A2/A1/A0 全部接地的情况)
随机读:读取任意指定存储地址,不是顺序接着上一次读。
I2C 随机读核心关键点:
先「写寄存器地址」:发送起始 + EEPROM 写地址 + ACK + 要读的存储单元地址 + ACK
不发停止信号,发送重复起始信号 Repeated Start
发送 EEPROM 读地址,读取从机 ACK
读取 1 字节数据,读完主机输出 NACK
发送停止信号 Stop 结束通信
⚠重点:随机读不能发 Stop,再发 Start;必须用重复起始信号。如果发 Stop,EEPROM 会丢失内部地址指针。
AT24C02 随机写 1 字节流程
| 步骤 | 操作 | 发送方 | 说明 |
|---|---|---|---|
| ① | START | 主机 | SCL 高电平时,SDA 下降沿 |
| ② | 发送设备地址 0xA0 | 主机 | 0xA0 = 0b1010 0000,R/W = 0(写模式) |
| ③ | ACK | 从机 | AT24C02 拉低 SDA 应答 |
| ④ | 发送内存地址(Word Address) | 主机 | 8 位地址,如 0x20,指定写入位置 |
| ⑤ | ACK | 从机 | 地址已接收 |
| ⑥ | 发送 1 字节数据 | 主机 | 要写入的数据,如 0x55,MSB 先 |
| ⑦ | ACK | 从机 | 数据已接收 |
| ⑧ | STOP | 主机 | SCL 高电平时,SDA 上升沿,结束通信 |
i2c_sim_start() I2C 起始信号 Start
i2c_sim_write_byte(0xA0) 发送 EEPROM写器件地址
判断 ACK,如果 NACK,直接 Stop 退出,通信失败
i2c_sim_write_byte(mem_addr) 发送要写入的存储地址(0‑0xFF)
判断 ACK
i2c_sim_write_byte(write_data) 发送需要写入的数据字节
判断 ACK
i2c_sim_stop() 发送停止信号 Stop,Stop 之后 EEPROM 才启动内部页烧写
⏳延时 5~10ms,等待 EEPROM 内部编程完成!这一步非常容易漏掉!
重点:
Stop 信号才触发 EEPROM 烧写,没有 Stop 不会保存数据;
Stop 之后必须等待烧写时间,期间 EEPROM 不响应 I2C,此时去读 / 写会收到 NACK;
随机写不需要重复起始信号。
main.c
c
#include "stm32f10x.h"
#include "led.h"
#include "delay.h"
#include "key.h"
#include "usart.h"
#include "exti.h"
#include "time.h"
#include <stdio.h>
#include "i2c_sim.h"
#include "at24c02.h"
int main(void)
{
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);// 设置中断优先级分组4
led_init();
delay_init();
usart1_init(115200);
i2c_sim_init();
at24c02_test();
while(1)
{
}
}
4.3 添加文件包含路径

4. 4 程序的编译和烧录


测试结束写入和读出是相同才正确!
模拟I2C 程序错误也能读写,要读写测试判断.
资料下载
参考资料网盘 :
链接 : https://pan.baidu.com/s/1BrH0JbUY6z7j7gAihTa4Dw
提取码: qnws
本节课完整工程例程网盘:
链接: https://pan.baidu.com/s/1rl6EIqi9m6kQ1JFjuSqINg
提取码: bumc