代码仓库:
bash
git clone https://gitee.com/jumayusi/unit11-gd32-iic.git
前言:IIC的基本知识这篇笔记不会再详细介绍。
11.2 EEPROM存储芯片AT24C02

这个分类并不严谨,FeRAM\ReRAM\MRAM都是已经在市场上量产的非易失性存储介质,FeRAM是现在AI存算一体的主流解决方案,这里不做过多展开了。
EEPROM的主要特点:

Flash的主要特点:

24C02不仅支持按字节写入,也支持按页写入。

11.3 11.3 软件模拟IIC驱动24C02-起始、停止、应答
引脚初始化
GPIO口模拟I2C的基石是用拉高IO口作为SET信号线,拉低IO口作为CLR信号线,因此我们可以直接用宏定义声明:
cpp
#define GET_I2C_SDA() gpio_input_bit_get(GPIOB, GPIO_PIN_7) // 读取SDA端口
#define SET_I2C_SCL() gpio_bit_set(GPIOB, GPIO_PIN_6) // 时钟线SCL输出高电平
#define CLR_I2C_SCL() gpio_bit_reset(GPIOB, GPIO_PIN_6) // 时钟线SCL输出低电平
#define SET_I2C_SDA() gpio_bit_set(GPIOB, GPIO_PIN_7) // 数据线SDA输出高电平
#define CLR_I2C_SDA() gpio_bit_reset(GPIOB, GPIO_PIN_7) // 数据线SDA输出低电平
由于我们只使用了GPIO这一个peripheral,因此I2C的初始化就是:
cpp
static void GpioInit(void)
{
rcu_periph_clock_enable(RCU_GPIOB);
gpio_init(GPIOB, GPIO_MODE_OUT_OD, GPIO_OSPEED_10MHZ,
GPIO_PIN_6 | GPIO_PIN_7);
}
void EepromDrvInit(void)
{
GpioInit();
}
I2CStart
下面实现I2C的起始信号:

代码示范:
cpp
/**
*******************************************************************
* @function 产生IIC起始时序,准备发送或接收数据前必须由起始序列开始
* @param
* @return
* @brief SCL为高电平时,SDA由高电平向低电平跳变,开始传输数据
* 生成下图所示的波形图,即为起始时序
* 1 2 3 4
* __________
* SCL : __/ \_____
* ________
* SDA : \___________
*******************************************************************
*/
static void I2CStart(void)
{
SET_I2C_SDA(); // 1#数据线SDA输出高电平
SET_I2C_SCL(); // 2#时钟线SCL输出高电平
DelayNus(4); // 延时4us
CLR_I2C_SDA(); // 3#数据线SDA输出低电平
DelayNus(4); // 延时4us
CLR_I2C_SCL(); // 4#时钟线SCL输出低电平,保持I2C的时钟线SCL为低电平,准备发送或接收数据
DelayNus(4); // 延时4us
}
(1# & 2#)我们知道,I2C在总线空闲时期SCL和SDA都需要保持高电平(硬件实现上SCL/SDA两个引脚都接上了上拉电阻),但为了避免IO口处于不确定状态,我们需要先操作两个IO拉高。
tHD;STA是I2C能够正常建立通信的一个重要指标,它的中文全称叫做起始保持时间,飞利浦在制定IIC规则时约束这个时间典型值为4us,我们实际写代码时必须参考芯片的时序约束,3V3的Vio下只需要0.25us,我们这里规定4us是绰绰有余。

(3#)同样的,在完成SDA由高拉低的操作之后,我们同样提供4us的相位冗余,这是为了满足tHD;STA。
(4#)最后拉低SCL电平,因为正常通信时SDA只可以在SCL低电平时发生跳变,这是为了数据传输做准备。
I2CStop
cpp
/**
*******************************************************************
* @function 产生IIC停止时序
* @param
* @return
* @brief SCL为高电平时,SDA由低电平向高电平跳变,结束传输数据
* 生成下图所示的波形图,即为停止时序
* 1 2 3 4
* _______________
* SCL : ______/
* __ ____________
* SDA: \______/
*******************************************************************
*/
static void I2CStop(void)
{
CLR_I2C_SDA(); // 2#数据线SDA输出低电平
DelayNus(4); // 延时4us
SET_I2C_SCL(); // 3#时钟线SCL输出高电平
DelayNus(4);
SET_I2C_SDA(); // 4#数据线SDA输出高电平,发送I2C总线结束信号
}
就不介绍的那么细了,非常推荐初学者用示波器实操一下加深印象。

I2CSendByte
代码示范:
cpp
/**
*******************************************************************
* @function 发送一字节,数据从高位开始发送出去
* @param byte
* @return
* @brief 下面是具体的时序图
* 1 2 3 4
* ______
* SCL: ________/ \______
* ______________________
* SDA: \\\___________________
*******************************************************************
*/
static void I2CSendByte(uint8_t byte)
{
for (uint8_t i = 0; i < 8; i++) // 循环8次,从高到低取出字节的8个位
{
if ((byte & 0x80)) // 2#取出字节最高位,并判断为'0'还是'1',从而做出相应的操作
{
SET_I2C_SDA(); // 数据线SDA输出高电平,数据位为'1'
} else {
CLR_I2C_SDA(); // 数据线SDA输出低电平,数据位为'0'
}
byte <<= 1; // 左移一位,次高位移到最高位
DelayNus(4); // 延时4us
SET_I2C_SCL(); // 3#时钟线SCL输出高电平
DelayNus(4); // 延时4us
CLR_I2C_SCL(); // 4#时钟线SCL输出低电平
DelayNus(4); // 延时4us
}
}
其实很好理解,核心就是参考SCL的电平,**低电平改变数据,高电平采样数据。**在时钟信号实现跳变之前,把SDA要输出的0/1塞到总线上。
I2CReadByte
cpp
/**
*******************************************************************
* @function 读取一字节数据
* @param
* @return 读取的字节
* @brief 下面是具体的时序图
* ______
* SCL: ______/ \___
* ____________________
* SDA: \\\\______________\\\
*******************************************************************
*/
static uint8_t I2CReadByte(void)
{
uint8_t byte = 0; // byte用来存放接收的数据
SET_I2C_SDA(); // 释放SDA
for (uint8_t i = 0; i < 8; i++) // 循环8次,从高到低读取字节的8个位
{
SET_I2C_SCL(); // 时钟线SCL输出高电平
DelayNus(4); // 延时4us
byte <<= 1; // 左移一位,空出新的最低位
if (GET_I2C_SDA()) // 读取数据线SDA的数据位
{
byte++; // 在SCL的上升沿后,数据已经稳定,因此可以取该数据,存入最低位
}
CLR_I2C_SCL(); // 时钟线SCL输出低电平
DelayNus(4); // 延时4us
}
return byte; // 返回读取到的数据
}
和上文加粗字体一样,主机(MCU)先释放总线,然后控制SCL切换到高电平之后读取SDA上从机发来的数据。循环内最后切换SCL到低电平并且钳住4us保证Tlow的时序完整性。
I2CWaitAck/I2CSendAck/I2CSendNack
cpp
/**
*******************************************************************
* @function 等待接收端的应答信号
* @param
* @return 1,接收应答失败;0,接收应答成功
* @brief 当SDA拉低后,表示接收到ACK信号,然后,拉低SCL,
* 此处表示发送端收到接收端的ACK
* _______|____
* SCL: | \_________
* _______|
* SDA: \_____________
*******************************************************************
*/
static bool I2CWaitAck(void)
{
uint8_t errTimes = 0;
SET_I2C_SDA(); // 释放SDA总线,很重要
DelayNus(4); // 延时4us
SET_I2C_SCL(); // 时钟线SCL输出高电平
DelayNus(4); // 延时4us
while (GET_I2C_SDA()) // 读回来的数据如果是高电平,即接收端没有应答
{
errTimes++; // 计数器加1
if (errTimes > 250) // 如果超过250次,则判断为接收端出现故障,因此发送结束信号
{
I2CStop(); // 产生一个停止信号
return false; // 返回值为1,表示没有收到应答信号
}
}
CLR_I2C_SCL(); // 表示已收到应答信号,时钟线SCL输出低电平
DelayNus(4); // 延时4us
return true; // 返回值为0,表示接收应答成功
}
/**
*******************************************************************
* @function 发送应答信号
* @param
* @return
* @brief 下面是具体的时序图
* 1 2 3 4 5
* ______
* SCL: ________/ \____________
* __ ______
* SDA: \___________________/
*******************************************************************
*/
void I2CSendAck(void)
{
CLR_I2C_SDA(); // 2#数据线SDA输出低电平
DelayNus(4); // 延时4us
SET_I2C_SCL(); // 3#时钟线SCL输出高电平,在SCL上升沿前就要把SDA拉低,为应答信号
DelayNus(4); // 延时4us
CLR_I2C_SCL(); // 4#时钟线SCL输出低电平
DelayNus(4); // 延时4us
SET_I2C_SDA(); // 5#数据线SDA输出高电平,释放SDA总线,很重要
}
/**
*******************************************************************
* @function 发送非应答信号
* @param
* @return
* @brief 下面是具体的时序图
* 1 2 3 4
* ______
* SCL: ________/ \______
* __ ___________________
* SDA: __/
*******************************************************************
*/
void I2CSendNack(void)
{
SET_I2C_SDA(); // 2#数据线SDA输出高电平
DelayNus(4); // 延时4us
SET_I2C_SCL(); // 3#时钟线SCL输出高电平,在SCL上升沿前就要把SDA拉高,为非应答信号
DelayNus(4); // 延时4us
CLR_I2C_SCL(); // 4#时钟线SCL输出低电平
DelayNus(4); // 延时4us
}
在 I2C 里,第 9 个时钟(每个字节 8 位之后的应答位)是收/发双方唯一的握手机制。谁接收数据,谁就在第 9 个时钟给出应答。所以主机(MCU)处于不同数据收发方向时,用的是不同函数接口:
主机发数据(写 EEPROM)→ 从机应答 → 主机用 I2CWaitAck();
主机收数据(读 EEPROM)→ 主机应答 → 主机用 I2CSendAck() / I2CSendNack()。