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(头文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| /** **************************************************************************************************** * @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
初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
void iic_init(void) { GPIO_InitTypeDef gpio_init_struct;
IIC_SCL_GPIO_CLK_ENABLE(); IIC_SDA_GPIO_CLK_ENABLE();
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);
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使用开漏模式时,必须外界上拉电阻。
延时
1 2 3 4 5 6 7 8 9
|
static void iic_delay(void) { delay_us(2); }
|
为获得稳定的读写结果,需限制读写速度,此处通过延时2us的形式实现。
起始信号与停止信号
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
void iic_start(void) { IIC_SDA(1); IIC_SCL(1); iic_delay(); IIC_SDA(0); iic_delay(); IIC_SCL(0); iic_delay(); }
void iic_stop(void) { IIC_SDA(0); 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电平开始跳变前就保持高电平。
发送字节
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
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); iic_delay(); IIC_SCL(0); data <<= 1; } IIC_SDA(1); }
|
发送字节时,由于一个时钟周期内I2C只能发送一位(bit)数据,因此发送需要循环8次,模拟8个时钟信号,才能把形参的8位数据都发送出去。也就是我们需要提取出形参的每一位并发送。这里使用了(data & 0x80) >> 7
的方式。(data & 0x80)
是将形参与0x80
,也就是10000000
进行与运算,然后右移7位,提取出形参的最高位。如果最高位是1,那么SDA就拉高;反之SDA则拉低。每个周期完成后,data左移1位,将原先是次高位的数移到最高位,重复这个过程8次。全部发送完成后,释放SDA线。
读取字节
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
|
uint8_t iic_read_byte(uint8_t ack) { uint8_t i, receive = 0x00;
for (i = 0; i < 8; i++) { receive <<= 1; IIC_SCL(1); iic_delay();
if (IIC_READ_SDA) { receive++; }
IIC_SCL(0); iic_delay(); }
if (!ack) { iic_nack(); } else { iic_ack(); }
return receive; }
|
在时序方面,I2C读取字节和写入字节是一致的。
应答信号(ACK)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
uint8_t iic_wait_ack(void) { uint8_t waittime = 0; uint8_t rack = 0;
IIC_SDA(1); iic_delay(); IIC_SCL(1); iic_delay();
while (IIC_READ_SDA) { waittime++;
if (waittime > 250) { iic_stop(); rack = 1; break; } }
IIC_SCL(0); iic_delay(); return rack; }
|
等待应答信号一般用在写时序中,在iic_send_byte
后调用。当读取到SDA为低电平时,表示ACK信号;SDA为高电平则为NACK信号。若等待超时,则主机会直接发出停止信号。若正常接收到ACK信号,主机拉低SCL线,返回flag。这一段内容对应总线时序图中的红圈3,也就是脉冲9。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
void iic_ack(void) { IIC_SDA(0); iic_delay(); IIC_SCL(1); iic_delay(); IIC_SCL(0); iic_delay(); IIC_SDA(1); iic_delay(); }
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 驱动
写操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
void at24cxx_write_one_byte(uint16_t addr, uint8_t data) { iic_start();
if (EE_TYPE > AT24C16) { iic_send_byte(0xA0); iic_wait_ack(); iic_send_byte(addr >> 8); } else { iic_send_byte(0xA0 + ((addr >> 8) << 1)); }
iic_wait_ack(); iic_send_byte(addr % 256); iic_wait_ack();
iic_send_byte(data); iic_wait_ack(); iic_stop(); delay_ms(10); }
|
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和独特设计,用来节省资源。
读操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
|
uint8_t at24cxx_read_one_byte(uint16_t addr) { uint8_t temp = 0; iic_start();
if (EE_TYPE > AT24C16) { iic_send_byte(0xA0); iic_wait_ack(); iic_send_byte(addr >> 8); } else { iic_send_byte(0xA0 + ((addr >> 8) << 1)); }
iic_wait_ack(); iic_send_byte(addr % 256); iic_wait_ack();
iic_start(); iic_send_byte(0xA1); iic_wait_ack(); temp = iic_read_byte(0); iic_stop();
return temp; }
|
iic_send_byte(addr % 256)
用来提取内存地址的低8位。也可以用addr &= 0x00FF
来代替。
检测工作状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
uint8_t at24cxx_check(void) { uint8_t temp; uint16_t addr = EE_TYPE; 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首地址内容,串口打印读取结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| while (at24cxx_check()) { led_red_toggle(); delay_ms(500); }
while (1) {
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) { at24cxx_write(0, txbuf, sizeof(txbuf)); HAL_UART_Transmit_IT(&huart1, writeok, sizeof(writeok)); delay_ms(50); }
if (key == KEY0_pressed) { 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); }
|
Github项目地址