STM32中的I2C通信

I2C简介

I2C是由数据线SDA和时钟线SCL构成的串行总线,用于发送或接收数据。其中SDA用于传输数据。SCL用于同步时钟信号。I2C还有以下特征:

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

I2C时序和读写操作

时序信号

I2C总线时序图

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

写操作

I2C写操作通讯

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

读操作

I2C读操作通讯

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

若主机一直返回应答信号,那么从机可以一直发送数据,直到主机发送NACK信号为止。

24C02时序

24C02 是一个 2K bit 的串行 EEPROM 存储器,内部含有 256 个字节。在 24C02 里面还有一个 8 字节的页写缓冲器。该设备的通信方式为 IIC,通过其 SCL 和 SDA 与其他设备通信。

24C02引脚

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

24C02设备地址格式

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

24C02读写操作

24C02写时序

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

24C02页写时序

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

24C02读时序

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
/** 
@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使用开漏模式时,必须外界上拉电阻。

延时

1
2
3
4
5
6
7
8
9
/** 
@brief:IIC延时函数
@param:NULL
@return:NULL
**/
static void iic_delay(void)
{
delay_us(2); /* 读写速度在250Khz以内 */
}

为获得稳定的读写结果,需限制读写速度,此处通过延时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
/** 
@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();
}

START和STOP时序

根据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
/** 
@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发送时序

发送字节时,由于一个时钟周期内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
/** 
@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)

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
/** 
@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。

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
/** 
@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 驱动

写操作

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
/**
* @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和独特设计,用来节省资源。

读操作

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
/**
* @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来代替。

检测工作状态

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
/**
* @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首地址内容,串口打印读取结果

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
/* 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 */

串口打印结果

Github项目地址


STM32中的I2C通信
http://akichen891.github.io/2024/12/07/STM32中的I2C通信/
作者
Aki Chen
发布于
2024年12月7日
更新于
2024年12月13日
许可协议