I2C简介
I2C是由数据线SDA和时钟线SCL构成的串行总线,用于发送或接收数据。其中SDA用于传输数据。SCL用于同步时钟信号。I2C还有以下特征:
- 主从架构
- 主机(Master):控制通信过程,生成时钟信号并启动通信
- 从机(Slave):响应主机指令,按地址匹配参与通信
- 地址:每个从设备具有唯一的7位或10位地址,用于识别通信目标。主设备在通信开始时发送从设备地址,从设备响应后开始数据传输
- 半双工通信: 数据线支持双向通信,但同一时刻只允许数据单向传输
- 数据速率:
- 标准模式(Standard Mode):最大速率100 kbps
- 快速模式(Fast Mode):最大速率400 kbps
- 高速模式(High-Speed Mode):最大速率3.4 Mbps
- 开漏设计:信号线通常采用开漏驱动,需要外部上拉电阻将信号线拉高到逻辑高电平。因此总线空闲时,SDA和DCL都为高电平。
- 多设备连接:可以有多个具备 IIC 通信能力的设备挂载在上面,同时支持多个主机和多个从机,连接到总线的接口数量只由总线电容 400pF 的限制决定。
I2C时序和读写操作
时序信号

- 起始信号:主机发出,为电平跳变信号而非恒电平信号。SCL为高电平期间,SDA由高电平跳变至低电平,总线被占用,准备数据传输
- 停止信号:主机发出,为电平跳变信号而非恒电平信号。SCL为高电平期间,SDA由低电平跳变至高电平,总线空闲
- 应答信号:发送器每发送一个字节,就在时钟脉冲 9 期间释放数据线,由接收器反馈一个应答信号。应答信号为低电平时,规定为有效应答位(ACK 简称应答位),表示接收器已经成功地接收了该字节。应答信号为高电平时,规定为非应答位(NACK),一般表示接收器接收该字节没有成功
- 数据有效性:总线进行数据传送时,时钟信号为高电平期间,数据线上的数据必须保持稳定,只有在时钟线上的信号为低电平期间,数据线上的高电平或低电平状态才允许变化。数据在 SCL 的上升沿到来之前就需准备好。并在下降沿到来之前必须稳定
- 数据传输:总线上传送的每一位数据都有一个时钟脉冲相对应(或同步控制),即在 SCL 串行时钟的配合下,在 SDA 上逐位地串行传送每一位数据。数据位的传输是边沿触发
- 空闲状态:SDA 和 SCL两条信号线同时处于高电平时,规定为总线的空闲状态。此时各个器件的输出级场效应管均处在截止状态,即释放总线,由两条信号线各自的上拉电阻把电平拉高
写操作

- 主机发送起始信号,令总线上的所有从机等待接收数据
- 主机发送从机地址+‘0’(写操作)组成的8位数据。从机接收到地址后,比对该地址是否为本机地址。若为本机地址,从机发送应答信号
- 主机向从机发送数据
读操作

- 主机发送起始信号,令总线上的所有从机等待接收数据
- 主机发送从机地址+‘1’(读操作)组成的8位数据。从机接收到地址后,比对该地址是否为本机地址。若为本机地址,从机发送应答信号
- 从机向主机发送数据
若主机一直返回应答信号,那么从机可以一直发送数据,直到主机发送NACK信号为止。
24C02时序
24C02 是一个 2K bit 的串行 EEPROM 存储器,内部含有 256 个字节。在 24C02 里面还有一个 8 字节的页写缓冲器。该设备的通信方式为 IIC,通过其 SCL 和 SDA 与其他设备通信。

WP为写保护引脚,高电平只读,低电平开放读写功能。24C02的设备地址共8位,包含不可编程部分和可编程部分,可编程部分根据硬件Pin A0、A1、A2决定;设备地址最后一位用于设置是读操作还是写操作。具体为:

本文中A0、A1、A2均接地,故24C02设备读操作地址为0xA1,写操作地址为0xA0。
24C02读写操作

主机在 IIC 总线发送第 1 个字节的数据为24C02的设备地址0xA0,用于寻找总线上的24C02,在获得24C02的应答信号之后,继续发送第 2 个字节数据,该字节数据是 24C02 的内存地址,再等到 24C02 的应答信号,主机继续发送第 3 字节数据,这里的数据即是写入在第 2 字节内存地址的数据。主机完成写操作后,可以发出停止信号,终止数据传输。这种写操作每次只能写入1字节数据。

写操作时,24C02可以使用页写时序,其和普通写时序的区别是页写时序只需要告知一次内存地址1,后面的数据会按照写入顺序存入内存地址2、内存地址3等,节省通信时间。

24C02读取数据的过程是一个复合的时序,其中包含写时序和读时序。通常第一个通信过程为写时序,起始信号产生后,主机发送24C02设备地址0xA0,获取从机应答信号后,接着发送需要读取的内存地址;在随后的读时序中,起始信号产生后,主机发送24C02设备地址0xA1, 获取从机应答信号后,从机返回刚刚在写时序中传递的内存地址的数据,以字节为单位传输在总线上,假如主机获取数据后返回的是应答信号,那么从机会一直传输数据,当主机发出的是非应答信号并以停止信号发出为结束,从机结束传输。
软件模拟I2C
HAL库自带的硬件I2C函数比较复杂,多采用软件模拟I2C的方式操作GPIO以获得I2C时序。
custom_i2c.h(头文件)
/** **************************************************************************************************** * @file custom_i2c.h * @author Aki * @version V1.0 * @date 2024-12-11 * @brief 软件IIC头文件 **************************************************************************************************** */
#ifndef __MYIIC_H#define __MYIIC_H
#include "sys.h"
/******************************************************************************************//* 引脚定义 */
#define IIC_SCL_GPIO_PORT GPIOH#define IIC_SCL_GPIO_PIN GPIO_PIN_4#define IIC_SCL_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOH_CLK_ENABLE(); }while(0) /* PH口时钟使能 */
#define IIC_SDA_GPIO_PORT GPIOH#define IIC_SDA_GPIO_PIN GPIO_PIN_5#define IIC_SDA_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOH_CLK_ENABLE(); }while(0) /* PH口时钟使能 */
/******************************************************************************************//* IO操作 */
#define IIC_SCL(x) do{ x ? \ HAL_GPIO_WritePin(IIC_SCL_GPIO_PORT, IIC_SCL_GPIO_PIN, GPIO_PIN_SET) : \ HAL_GPIO_WritePin(IIC_SCL_GPIO_PORT, IIC_SCL_GPIO_PIN, GPIO_PIN_RESET); \ }while(0) /* SCL */
#define IIC_SDA(x) do{ x ? \ HAL_GPIO_WritePin(IIC_SDA_GPIO_PORT, IIC_SDA_GPIO_PIN, GPIO_PIN_SET) : \ HAL_GPIO_WritePin(IIC_SDA_GPIO_PORT, IIC_SDA_GPIO_PIN, GPIO_PIN_RESET); \ }while(0) /* SDA */
#define IIC_READ_SDA HAL_GPIO_ReadPin(IIC_SDA_GPIO_PORT, IIC_SDA_GPIO_PIN) /* 读取SDA电平 */
/******************************************************************************************/
void iic_init(void);void iic_start(void);void iic_stop(void);void iic_ack(void);void iic_nack(void);uint8_t iic_wait_ack(void);void iic_send_byte(uint8_t data);uint8_t iic_read_byte(unsigned char ack);
#endif这里宏定义利用了两个小技巧:
- 三目运算符:x ? … : … 是一个条件(三目)运算符。它的工作原理是:根据 x 的值(x 是一个表达式),判断执行哪一部分代码。如果 x 为 true(非零),执行冒号前的表达式;如果 x 为 false(零),执行冒号后的表达式。
do { ... } while(0):确保宏只执行一次且不会干扰外部代码结构的惯用法。这样做可以确保宏在使用时,代码不会因缺少括号而引发语法错误。
custom_i2c.c
初始化
/** @brief:IIC初始化 @param:NULL @return:NULL**/void iic_init(void){ GPIO_InitTypeDef gpio_init_struct;
IIC_SCL_GPIO_CLK_ENABLE(); /* SCL引脚时钟使能 */ IIC_SDA_GPIO_CLK_ENABLE(); /* SDA引脚时钟使能 */
/* SCL */ gpio_init_struct.Pin = IIC_SCL_GPIO_PIN; gpio_init_struct.Mode = GPIO_MODE_OUTPUT_PP; /* 推挽输出 */ gpio_init_struct.Pull = GPIO_PULLUP; /* 上拉 */ gpio_init_struct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; HAL_GPIO_Init(IIC_SCL_GPIO_PORT, &gpio_init_struct);
/* SDA */ gpio_init_struct.Pin = IIC_SDA_GPIO_PIN; gpio_init_struct.Mode = GPIO_MODE_OUTPUT_OD; /* 开漏输出 */ HAL_GPIO_Init(IIC_SDA_GPIO_PORT, &gpio_init_struct);
iic_stop(); /* 停止总线上所有设备 */}这里SDA线使用开漏输出,这意味着当SDA引脚输出低电平时,设备直接将线拉低(输出低电平);而当设备输出高电平时,它并不直接将线拉高,而是将SDA引脚设置为高阻态(即不驱动该引脚。当一个设备希望发送数据时,如果它需要输出0(低电平),它会直接将SDA引脚拉低。当它需要发送1(高电平),它不会直接将SDA引脚拉高,而是让SDA引脚处于高阻态,让总线上的其他设备(如果有)通过拉高SDA线来实现高电平。这么做可以有效避免连接多个从机时发生电平冲突。STM32F429使用开漏模式时,必须外界上拉电阻。
延时
/** @brief:IIC延时函数 @param:NULL @return:NULL**/static void iic_delay(void){ delay_us(2); /* 读写速度在250Khz以内 */}为获得稳定的读写结果,需限制读写速度,此处通过延时2us的形式实现。
起始信号与停止信号
/** @brief:IIC起始信号 @param:NULL @return:NULL**/void iic_start(void){ IIC_SDA(1); IIC_SCL(1); iic_delay(); IIC_SDA(0); /* START信号 */ iic_delay(); IIC_SCL(0); /* 钳住总线,准备发送或接收数据(SCL只有低电平期间允许改变SDA状态) */ iic_delay();}
/** @brief:IIC停止信号 @param:NULL @return:NULL**/void iic_stop(void){ IIC_SDA(0); /* STOP信号 */ iic_delay(); IIC_SCL(1); iic_delay(); IIC_SDA(1); /* 总线结束信号 */ iic_delay();}
根据IIC时序图,起始信号和停止信号分别为:
- START:SCL高电平,SDA从1跳变至0
- STOP:SCL高电平,SDA从0跳变至1
上面的两个函数实现的就是这个功能,同时加入了延时来保证能得到稳定的电平。需要注意,停止信号发出时,SCL需要在SDA电平开始跳变前就保持高电平。
发送字节
/** @brief:IIC发送一个字节 @param:data:要发送的数据 @return:NULL**/void iic_send_byte(uint8_t data){ uint8_t t;
for (t = 0; t < 8; t++) { IIC_SDA((data & 0x80) >> 7); /* 提取高位 */ iic_delay(); IIC_SCL(1); /* 拉高SCL并维持高电平,开始发送 */ iic_delay(); IIC_SCL(0); /* 发送结束,拉低SCL,准备下一位数据 */ data <<= 1; /* 左移1位,循环发送 */ } IIC_SDA(1); /* 发送完成,释放SDA线 */}
发送字节时,由于一个时钟周期内I2C只能发送一位(bit)数据,因此发送需要循环8次,模拟8个时钟信号,才能把形参的8位数据都发送出去。也就是我们需要提取出形参的每一位并发送。这里使用了(data & 0x80) >> 7的方式。(data & 0x80)是将形参与0x80,也就是10000000进行与运算,然后右移7位,提取出形参的最高位。如果最高位是1,那么SDA就拉高;反之SDA则拉低。每个周期完成后,data左移1位,将原先是次高位的数移到最高位,重复这个过程8次。全部发送完成后,释放SDA线。
读取字节
/** @brief:IIC读取一个字节 @param:ack: ack=1时,发送ack; ack=0时,发送nack @return:接收到的数据**/uint8_t iic_read_byte(uint8_t ack){ uint8_t i, receive = 0x00;
/* 1个字节共8位,单次接收1位,循环8次 */ for (i = 0; i < 8; i++) { receive <<= 1; /* 输出时高位先输出,因此接收时先收到的数据是高位,要左移 */ IIC_SCL(1); /* 拉高SCL,SDA准备接收 */ iic_delay();
if (IIC_READ_SDA) /* 如果SDA为高电平 */ { receive++; /* 第0位(低位)置1 */ }
IIC_SCL(0); /* 接收结束,拉低SCL */ iic_delay(); }
if (!ack) { iic_nack(); /* 发送nACK信号 */ } else { iic_ack(); /* 发送ACK信号 */ }
return receive;}在时序方面,I2C读取字节和写入字节是一致的。
应答信号(ACK)
/** @brief:等待应答信号 @param:NULL @return:1=失败;0=成功**/uint8_t iic_wait_ack(void){ uint8_t waittime = 0; uint8_t rack = 0;
IIC_SDA(1); /* 主机释放SDA(外部器件此时可拉低SDA电平) */ iic_delay(); IIC_SCL(1); /* 拉高SCL,从机此时可返回ACK */ iic_delay();
while (IIC_READ_SDA) /* 等待应答 */ { waittime++;
if (waittime > 250) { iic_stop(); rack = 1; break; } }
IIC_SCL(0); /* SCL=0,结束ACK检查 */ iic_delay(); return rack;}等待应答信号一般用在写时序中,在iic_send_byte后调用。当读取到SDA为低电平时,表示ACK信号;SDA为高电平则为NACK信号。若等待超时,则主机会直接发出停止信号。若正常接收到ACK信号,主机拉低SCL线,返回flag。这一段内容对应总线时序图中的红圈3,也就是脉冲9。
/** @brief:产生ACK应答 @param:NULL @return:NULL @note:ACK应答时,SDA拉低,SCL=0->1->0**/void iic_ack(void){ IIC_SDA(0); iic_delay(); IIC_SCL(1); iic_delay(); IIC_SCL(0); iic_delay(); IIC_SDA(1); iic_delay();}
/** @brief:不产生ACK应答(NACK) @param:NULL @return:NULL @note:NACK应答时,SDA拉高,SCL=0->1->0**/void iic_nack(void){ IIC_SDA(1); iic_delay(); IIC_SCL(1); iic_delay(); IIC_SCL(0); iic_delay();}以上两个函数用于主机作为接收端时,在接收完数据后向从机返回ACK或NACK信号。二者对应的也是线时序图中的红圈3,也就是脉冲9。
24CXX EEPROM 驱动
写操作
/*** @brief 向 AT24CXX 指定地址写入一个数据* @param addr: 写入数据的目的地址* @param data: 要写入的数据* @retval 无*/void at24cxx_write_one_byte(uint16_t addr, uint8_t data){ iic_start(); /* IIC起始信号 */
if (EE_TYPE > AT24C16) /* 容量大于24C16时分2个字节发送目标内存地址 */ { iic_send_byte(0xA0); /* 发送写命令,从机设备地址为0xA0,最低位为0表示写入 */ iic_wait_ack(); /* 发送完一个字节后等待ACK */ iic_send_byte(addr >> 8); /* 发送高位内存地址 */ } else { iic_send_byte(0xA0 + ((addr >> 8) << 1)); /* 发送器件0xA0 + 高位a8/a9/a10地址,写数据 */ }
iic_wait_ack(); iic_send_byte(addr % 256); /* 发送低位内存地址 */ iic_wait_ack();
iic_send_byte(data); /* 发送1个字节 */ iic_wait_ack(); iic_stop(); delay_ms(10); /* EEPROM写入速度慢,必须等待写入完成再写下一个 */}24CXX的写操作主要包含三个步骤:写入设备地址,写入目标内存地址,写入待传输数据。设备地址之前已经介绍过,高7位为固定地址,低1位为'0'时表示写操作。
- 若EEPROM容量大于24C16,比如24C32和24C64,EEPROM总的字节数分别为4096和8192,对应寻址线为12根和13根,即内存地址是12位和13位的,显然发送内存地址时就需要分高字节和低字节两次发送。
- 若EEPROM容量小于或等于24C16,则寻址线最多为11根,内存地址最多是11位的。通过
iic_send_byte(0xA0 + ((addr >> 8) << 1)),可以让主机下发读写命令时自带3位高地址,剩下的8位地址只需要合在一起在低位发送即可,也就是iic_send_byte(addr % 256)。
这种设备地址低位和内存地址高位相结合的设计可以看作是EEPROM和独特设计,用来节省资源。
读操作
/*** @brief 在 AT24CXX 指定地址写入一个数据* @param addr: 写入数据的目的地址* @param data: 要写入的数据* @retval 无*/uint8_t at24cxx_read_one_byte(uint16_t addr){ uint8_t temp = 0; iic_start();
/* 根据不同的 24CXX 型号, 发送高位地址 * 1, 24C16 以上的型号, 分 2 个字节发送地址 * 2, 24C16 及以下的型号, 分 1 个低字节地址 + 占用器件地址的 bit1 ~ bit3 位 * 用于表示高位地址, 最多 11 位地址 * 对于 24C01/02, 其器件地址格式(8bit)为: 1 0 1 0 A2 A1 A0 R/W * 对于 24C04, 其器件地址格式(8bit)为: 1 0 1 0 A2 A1 a8 R/W * 对于 24C08, 其器件地址格式(8bit)为: 1 0 1 0 A2 a9 a8 R/W * 对于 24C16, 其器件地址格式(8bit)为: 1 0 1 0 a10 a9 a8 R/W * R/W : 读/写控制位 0,表示写; 1,表示读; * A0/A1/A2 : 对应器件的 1,2,3 引脚(只有 24C01/02/04/8 有这些脚) * a8/a9/a10: 对应存储整列的高位地址, 11bit 地址最多可以表示 2048 个位置, * 可以寻址 24C16 及以内的型号 */
if (EE_TYPE > AT24C16) /* 24C16以上型号分2个字节发送地址 */ { iic_send_byte(0xA0); /* 最低位为0,表示写入*/ iic_wait_ack(); /* 发送完一个字节后等待ACK信号 */ iic_send_byte(addr >> 8); /* 发送高字节地址 */ } else { /* 发送器件0xA0 + 高位a8/a9/a10地址,写数据 */ iic_send_byte(0xA0 + ((addr >> 8) << 1)); }
iic_wait_ack(); iic_send_byte(addr % 256); /* 发送低位地址*/ iic_wait_ack();
iic_start(); /* 重新发送起始信号*/ iic_send_byte(0xA1); /* 进入接收模式,最低位为1,表示读取*/ iic_wait_ack(); temp = iic_read_byte(0); /* 接收一个字节数据 */ iic_stop();
return temp;}iic_send_byte(addr % 256)用来提取内存地址的低8位。也可以用addr &= 0x00FF来代替。
检测工作状态
/*** @brief 检查 AT24CXX 是否正常* @note 检测原理: 在器件的末地址写入 0X55, 然后再读取,* 如果读取值为 0X55 则表示检测正常. 否则,则表示检测失败.* @param 无* @retval 检测结果* 0: 检测成功* 1: 检测失败*/uint8_t at24cxx_check(void){ uint8_t temp; uint16_t addr = EE_TYPE; /* 目标内存地址为EEPROM末地址 */ temp = at24cxx_read_one_byte(addr); /* 避免每次开机都写入*/
if (temp == 0x55) /* 读取数据正常 */ { return 0; } else /* 第一次初始化时 */ { at24cxx_write_one_byte(addr, 0x55); /* 先写入 */ temp = at24cxx_read_one_byte(255); /* 再读取 */ if (temp == 0x55) return 0; }
return 1;}这种操作和RTC实验很类似,利用EEPROM掉电后内存不丢失的特性,固定在第一次写入时于内存末地址写入0x55,然后再去读一下看看是否写入成功,以此来检测芯片是否正常工作。
应用
实现一个简单的应用:按下KEY1,向EEPROM的首地址写入字符串,写入成功串口打印“write ok”;按下KEY0,读取EEPROM首地址内容,串口打印读取结果
/* USER CODE BEGIN 2 */ while (at24cxx_check()) { led_red_toggle(); delay_ms(500); } /* USER CODE END 2 */
/* USER CODE BEGIN WHILE */ while (1) { /* USER CODE END WHILE */
/* USER CODE BEGIN 3 */ uint8_t key; uint8_t writeok[] = "write ok\n"; uint8_t txbuf[] = "mua\n"; uint8_t rxbuf[5]; uint8_t readok[15];
key = key_scan(0);
if (key == KEY1_pressed) /* 如果KEY1被按下 */ { at24cxx_write(0, txbuf, sizeof(txbuf)); HAL_UART_Transmit_IT(&huart1, writeok, sizeof(writeok)); delay_ms(50); }
if (key == KEY0_pressed) /* 如果KEY0被按下 */ { at24cxx_read(0, rxbuf, sizeof(txbuf)); //sprintf(readok, "result is %d\n", rxbuf[0]); HAL_UART_Transmit_IT(&huart1, rxbuf, sizeof(rxbuf)); }
led_green(1); delay_ms(10); } /* USER CODE END 3 */
小结
总的来说,通过I2C操作EEPROM时的要点有:
- 写入时:先写入设备地址+写操作指示符,然后写入目标内存地址,最后写入待写入数据
- 读出时:起始信号,先写入设备地址+写操作指示符,从机ACK后写入目标内存地址;然后重新发出起始信号(因为需要从写模式切换到读模式),写入设备地址+读操作指示符,从机ACK后读出待读数据。读取完毕后,主机发送NACK,提示从机读取结束