参考:
1.正点原子
前言:
IIC一般用在比较低速的外围器件上,如EEPROM,ADC采样芯片,等。很基础的一种通信总线,学习总结很有必要。
1.IIC的总线及时序。
2.通过IIC读写24C02这款EEPROM芯片。

36.1 IIC 及 24C02 介绍

36.1.1 IIC 简介

IIC(Inter-Integrated Circuit 内部集成电路)总线是一种由 PHILIPS 公司开发的两线式串行总线,用于连接微控制器以及其外围设备。它是由数据线 SDA 和时钟线 SCL 构成的串行总线,可发送和接收数据,在 CPU 与被控 IC 之间、IC 与 IC 之间进行双向传送。
IIC 总线有如下特点:
①总线由数据线 SDA 和时钟线 SCL 构成的串行总线,数据线用来传输数据,时钟线用来同步数据收发。
②总线上每一个器件都有一个唯一的地址识别,所以我们只需要知道器件的地址,根据时序就可以实现微控制器与器件之间的通信。
③数据线 SDA 和时钟线 SCL 都是双向线路,都通过一个电流源或上拉电阻连接到正的电压,所以当总线空闲的时候,这两条线路都是高电平。
④总线上数据的传输速率在标准模式下可达 100kbit/s 在快速模式下可达 400kbit/s,在高速模式下可达 3.4Mbit/s。
⑤总线支持设备连接。在使用 IIC 通信总线时,可以有多个具备 IIC 通信能力的设备挂载在上面,同时支持多个主机和多个从机,连接到总线的接口数量只由总线电容 400pF 的限制决定。IIC 总线挂载多个器件的示意图,如下图所示:
在这里插入图片描述

下面来学习 IIC 总线协议,IIC 总线时序图如下所示:
在这里插入图片描述

为了便于大家更好的了解 IIC 协议,我们从起始信号、停止信号、应答信号、数据有效性、数据传输以及空闲状态等 6 个方面讲解,大家需要对应图 36.1.1.2 的标号来理解。
① 起始信号
当 SCL 为高电平期间,SDA 由高到低的跳变。起始信号是一种电平跳变时序信号,而不是一个电平信号。该信号由主机发出,在起始信号产生后,总线就处于被占用状态,准备数据传输。
② 停止信号
当 SCL 为高电平期间,SDA 由低到高的跳变。停止信号也是一种电平跳变时序信号,而不是一个电平信号。该信号由主机发出,在停止信号发出后,总线就处于空闲状态。
③ 应答信号
发送器每发送一个字节,就在时钟脉冲 9 期间释放数据线,由接收器反馈一个应答信号。应答信号为低电平时,规定为有效应答位(ACK 简称应答位),表示接收器已经成功地接收了该字节;应答信号为高电平时,规定为非应答位(NACK),一般表示接收器接收该字节没有成功。
观察上图标号③就可以发现,有效应答的要求是从机在第 9 个时钟脉冲之前的低电平期间将 SDA 线拉低,并且确保在该时钟的高电平期间为稳定的低电平。如果接收器是主机,则在它收到最后一个字节后,发送一个 NACK 信号,以通知被控发送器结束数据发送,并释放 SDA线,以便主机接收器发送一个停止信号。
④ 数据有效性
IIC 总线进行数据传送时,时钟信号为高电平期间,数据线上的数据必须保持稳定,只有在时钟线上的信号为低电平期间,数据线上的高电平或低电平状态才允许变化。数据在 SCL 的上升沿到来之前就需准备好。并在下降沿到来之前必须稳定。
⑤ 数据传输
在 I2C 总线上传送的每一位数据都有一个时钟脉冲相对应(或同步控制),即在 SCL 串行时钟的配合下,在 SDA 上逐位地串行传送每一位数据。数据位的传输是边沿触发。
⑥ 空闲状态
IIC 总线的 SDA 和 SCL 两条信号线同时处于高电平时,规定为总线的空闲状态。此时各个器件的输出级场效应管均处在截止状态,即释放总线,由两条信号线各自的上拉电阻把电平拉高。

了解前面的知识后,下面介绍一下 IIC 的基本的读写通讯过程,包括主机写数据到从机即写操作,主机到从机读取数据即读操作。

下面先看一下写操作通讯过程图,见图 36.1.1.3 所示:
在这里插入图片描述

主机首先在 IIC 总线上发送起始信号,那么这时总线上的从机都会等待接收由主机发出的数据。主机接着发送从机地址+0(写操作)组成的 8bit 数据,所有从机接收到该 8bit 数据后,自行检验是否是自己的设备的地址,假如是自己的设备地址,那么从机就会发出应答信号。主机在总线上接收到有应答信号后,才能继续向从机发送数据。注意:IIC 总线上传送的数据信号是广义的,既包括地址信号,又包括真正的数据信号。

接着讲解一下 IIC 总线的读操作过程,先看一下读操作通讯过程图,见图 36.1.1.4 所示。
在这里插入图片描述

主机向从机读取数据的操作,一开始的操作与写操作有点相似,观察两个图也可以发现,都是由主机发出起始信号,接着发送从机地址+1(读操作)组成的 8bit 数据,从机接收到数据验证是否是自身的地址。 那么在验证是自己的设备地址后,从机就会发出应答信号,并向主机返回 8bit 数据,发送完之后从机就会等待主机的应答信号。假如主机一直返回应答信号,那么从机可以一直发送数据,也就是图中的(n byte + 应答信号)情况,直到主机发出非应答信号,从机才会停止发送数据。
24C02 的数据传输时序是基于 IIC 总线传输时序,下面讲解一下 24C02 的数据传输时序。

36.1.2 24C02 简介

24C02 是一个 2K bit(2048/8=256) 的串行 EEPROM 存储器,内部含有 256 个字节。在 24C02 里面还有一个 8 字节的页写缓冲器。该设备的通信方式 IIC,通过其 SCL 和 SDA 与其他设备通信,芯片的引脚图如图 36.1.2.1 所示。
在这里插入图片描述

上图中有一个 WP,这个是写保护引脚,接高电平只读,接地允许读和写,我们的板子设计是把该引脚接地。每一个设备都有自己的设备地址,24C02 也不例外,但是 24C02 的设备地址是包括不可编程部分和可编程部分,可编程部分是根据上图的硬件引脚 A0、A1 和 A2 所决定。
设备地址最后一位用于设置数据的传输方向,即读操作/写操作,0 是写操作,1 是读操作,具体格式如下图 36.1.2.2 所示:
在这里插入图片描述

根据我们的板子设计,A0、A1 和 A2 均接地处理,所以 24C02 设备的读操作地址为:0xA1(1010 0001);写操作地址为:0xA0(1010 0000)。
在前面已经说过 IIC 总线的基本读写操作,那么我们就可以基于 IIC 总线的时序,理解 24C02 的数据传输时序。
下面把实验中用到的数据传输时序讲解一下,分别是对 24C02 的写时序和读时序。

24C02 写时序图如图 36.1.2.3 所示。
在这里插入图片描述

上图展示的主机向 24C02 写操作时序图,主机在 IIC 总线发送第 1 个字节的数据为 24C02的设备地址 0xA0,用于寻找总线上找到 24C02,在获得 24C02 的应答信号之后,继续发送第 2个字节数据,该字节数据是 24C02 的内存地址,再等到 24C02 的应答信号,主机继续发送第 3字节数据,这里的数据即是写入在第 2 字节内存地址的数据。主机完成写操作后,可以发出停止信号,终止数据传输。
上面的写操作只能单字节写入到 24C02,效率比较低,所以 24C02 有页写入时序,大大提高了写入效率,
下面看一下 24C02 页写时序图,图 36.1.2.4 所示。
在这里插入图片描述

在单字节写时序时,每次写入数据时都需要先写入设备的内存地址才能实现,在页写时序中,只需要告诉 24C02 第一个内存地址 1,后面数据会按照顺序写入到内存地址 2,内存地址 3等,大大节省了通信时间,提高了时效性。因为 24C02 每次只能 8bit 数据,所以它的页大小也就是 1 字节。页写时序的操作方式跟上面的单字节写时序差不多,所以不作过多解释了。参考以上说明去理解页写时序。

下面看一下图 36.1.2.5 关于 24C02 的读时序。
在这里插入图片描述

24C02 读取数据的过程是一个复合的时序,其中包含写时序和读时序。先看第一个通信过程,这里是写时序,起始信号产生后,主机发送 24C02 设备地址 0xA0,获取从机应答信号后,接着发送需要读取的内存地址;在读时序中,起始信号产生后,主机发送 24C02 设备地址 0xA1,获取从机应答信号后,接着从机返回刚刚在写时序中内存地址的数据,以字节为单位传输在总线上,假如主机获取数据后返回的是应答信号,那么从机会一直传输数据,当主机发出的是非应答信号并以停止信号发出为结束,从机就会结束传输。

目前大部分 MCU 都带有 IIC 总线接口,STM32F407 也不例外。但是这里我们不使用STM32F407 的硬件 IIC 来读写 24C02,而是通过软件模拟。ST 为了规避飞利浦 IIC 专利问题,将 STM32 的硬件 IIC 设计的比较复杂,而且稳定性不怎么好,所以这里我们不推荐使用。有兴趣的读者可以自行研究 STM32F407 的硬件 IIC 的使用。
用软件模拟 IIC,最大的好处就是方便移植,同一个代码兼容所有 MCU,任何一个单片机只要有 IO 口,就可以很快的移植过去,而且不需要特定的 IO 口。而硬件 IIC,则换一款 MCU,基本上就得重新移植,这也是我们推荐使用软件模拟 IIC 的另外一个原因。

36.2 硬件设计

1. 例程功能
通过串口指令写数据到24C02 和 通过串口指令读取24C02的数据。
2. 硬件资源
1)EEPROM AT24C02
2)串口 1(PA9/PA10 连接在板载 USB 转串口芯片 CH340 上面)(USMART 使用)
3. 原理图
我们主要来看看 24C02 和开发板的连接,如下图所示:
在这里插入图片描述

24C02 的 SCL 和 SDA 分别连接在 STM32 的 PB8 和 PB9 上。本实验通过软件模拟 IIC 信号建立起与 24C02 的通信,进行数据发送与接收。

36.3 程序设计

IIC 实验中使用的是软件模拟 IIC,所以用到的是 HAL 中 GPIO 相关函数,前面也有介绍到,这里就不做展开了。下面介绍一下使用 IIC 传输数据的配置步骤:

使用 IIC 传输数据的配置步骤
1) 使能 IIC 的 SCL 和 SDA 对应的 GPIO 时钟。
本实验中 IIC 使用的 SCL 和 SDA 分别是 PB8 和 PB9,因此需要先使能 GPIOB 的时钟,代码如下:
__HAL_RCC_GPIOB_CLK_ENABLE(); /* 使能 GPIOB 时钟 */
2) 设置对应 GPIO 工作模式(开漏输出)
本实验 GPIO 使用开漏输出模式(硬件已接外部上拉电阻,对于 F4 以上板子也可以用内部的上拉电阻),通过函数 HAL_GPIO_Init 设置实现。
3) 参考 IIC 总线协议,编写信号函数(起始信号,停止信号,应答信号)
起始信号:SCL 为高电平时,SDA 由高电平向低电平跳变。
停止信号:SCL 为高电平时,SDA 由低电平向高电平跳变。
应答信号:接收到 IC 数据后,向 IC 发出特定的低电平脉冲表示已接收到数据。
4) 编写 IIC 的读写函数
通过参考时序图,在一个时钟周期内发送 1bit 数据或者读取 1bit 数据。读写函数均以一字节数据进行操作。
有了读和写函数,我们就可以对外设进行驱动了。

36.3.1 程序解析

本实验中,我们通过 GPIO 来模拟 IIC,所以不需要使用 Drivers/STM32F4xx_HAL_Driver分组下添加 HAL 库文件支持。实验工程中,我们新增了 myiic.c 和24cxx.c文件。
1. IIC 底层驱动代码
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。IIC 驱动源码包括两个文件:myiic.c 和 myiic.h。
下面我们直接介绍 IIC 相关的程序,首先先介绍 myiic.h 文件,其定义如下:

#ifndef __MYIIC_H
#define __MYIIC_H

#include "periodic_user.h"
#include "main.h"

/******************************************************************************************/
/* 引脚 定义 */

#define IIC_SCL_GPIO_PORT               EEPROM_IIC_SCL_GPIO_Port
#define IIC_SCL_GPIO_PIN                EEPROM_IIC_SCL_Pin
//#define IIC_SCL_GPIO_CLK_ENABLE()       do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0)   /* PB口时钟使能 */

#define IIC_SDA_GPIO_PORT               EEPROM_IIC_SDA_GPIO_Port
#define IIC_SDA_GPIO_PIN                EEPROM_IIC_SDA_Pin
//#define IIC_SDA_GPIO_CLK_ENABLE()       do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0)   /* PB口时钟使能 */

/******************************************************************************************/

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


/* IIC所有操作函数 */
void iic_init(void);            /* 初始化IIC的IO口 */
void iic_start(void);           /* 发送IIC开始信号 */
void iic_stop(void);            /* 发送IIC停止信号 */
void iic_ack(void);             /* IIC发送ACK信号 */
void iic_nack(void);            /* IIC不发送ACK信号 */
uint8_t iic_wait_ack(void);     /* IIC等待ACK信号 */
void    iic_send_byte(uint8_t txd);/* IIC发送一个字节 */
uint8_t iic_read_byte(unsigned char ack);/* IIC读取一个字节 */

#endif

我们通过宏定义标识符的方式去定义 SCL 和 SDA 两个引脚,同时通过宏定义的方式定义了 IIC_SCL() 和 IIC_SDA()设置这两个管脚可以输出 0 或者 1,主要还是通过 HAL 库的 GPIO操作函数实现的。另外方便在 iic 操作函数中调用读取 SDA 管脚的数据,这里直接宏定义IIC_READ_SDA 实现,在后面 iic 模拟信号实现中会频繁调用。
接下来我们看一下 myiic.c 代码中的初始化函数,代码如下:

/**
* @brief 初始化 IIC
* @param 无
* @retval 无
*/
void iic_init(void)
{
    // GPIO_InitTypeDef gpio_init_struct;

    // IIC_SCL_GPIO_CLK_ENABLE();  /* SCL引脚时钟使能 */
    // IIC_SDA_GPIO_CLK_ENABLE();  /* SDA引脚时钟使能 */

    // 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);/* SCL */

    // 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);/* SDA */
    /* SDA引脚模式设置,开漏输出,上拉, 这样就不用再设置IO方向了, 开漏输出的时候(=1), 也可以读取外部信号的高低电平 */

    //iic_stop();     /* 停止总线上所有设备 */

    IIC_SDA(1);
    IIC_SCL(1);
}

在 iic_init 函数中主要工作就是对于 GPIO 的初始化,用于 iic 通信(我这里GPIO是通过是stm32CubeMX配置的,所以在main函数里面就初始化了GPIO引脚),不过这里需要注意的一点是 GPIO 的模式要使用开漏模式,把时钟和数据线拉高就行。

注意:

  • ‌SCL线‌:可以配置为推挽输出或开漏输出。由于SCL不需要兼具输入扫描功能,推挽输出也可以使用,但开漏输出更为常见‌。
  • ‌SDA线‌:必须配置为开漏输出,因为SDA需要兼具输入扫描功能。如果配置为推挽输出,当进行输入检测时,之前的输出电平会影响输入电路,导致短接等现象‌

接下来介绍在上面已经在文字上说明过的 IIC 模拟信号:起始信号、停止信号、应答信号,下面以代码方法实现,大家可以对着图去看代码,有利于理解。

/**
* @brief IIC 延时函数,用于控制 IIC 读写速度
* @param 无
* @retval 无
*/
static void iic_delay(void)
{
    delay_us(2); /* 2us 的延时, 读写速度在 250Khz 以内 */
}

/**
* @brief 产生 IIC 起始信号
* @param 无
* @retval 无
*/
void iic_start(void)
{
    IIC_SDA(1);
    IIC_SCL(1);
    iic_delay();
    IIC_SDA(0); /* START 信号: 当 SCL 为高时, SDA 从高变成低, 表示起始信号 */
    iic_delay();
    IIC_SCL(0); /* 钳住 I2C 总线,准备发送或接收数据 */
    iic_delay();
}

/**
* @brief 产生 IIC 停止信号
* @param 无
* @retval 无
*/
void iic_stop(void)
{
    IIC_SDA(0); /* STOP 信号: 当 SCL 为高时, SDA 从低变成高, 表示停止信号 */
    iic_delay();
    IIC_SCL(1);
    iic_delay();
    IIC_SDA(1); /* 发送 I2C 总线结束信号 */
    iic_delay();
}

在这里首先定义一个 iic_delay 函数,目的就是控制 IIC 的读写速度,通过示波器检测读写速度在 250KHz 内,所以一秒钟传输 500Kb 数据,换算一下即一个 bit 位需要 2us,在这个延时时间内可以让器件获得一个稳定性的数据采集。
为了大家更加清晰了解代码实现的过程,下面单独把起始信号和停止信号从 iic 总线时序图中抽取出来,见图 36.2.1.1。
在这里插入图片描述

iic_start 函数中,通过调用 myiic.h 中宏定义好的可以输出高低电平的 SCL 和 SDA 来模拟iic 总线中起始信号的发送,在 SCL 时钟线为高电平的时候,SDA 数据线从高电平状态转化到低电平状态,最后拉低时钟线,准备发送或者接收数据。
iic_stop 函数中,也是按着模拟 iic 总线中停止信号的逻辑,在 SCL 时钟线为高电平的时候,SDA 数据线从低电平状态转化到高电平状态。

接下来讲解一下 iic 的发送函数,其定义如下:

/**
* @brief 
IIC 发送一个字节
* @param data: 要发送的数据
* @retval 无
*/
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; /* 左移 1 位,用于下一次发送 */
    }
     
    IIC_SDA(1); /* 发送完成, 主机释放 SDA 线 */
}

在 iic 的发送函数 iic_send_byte 中,我们把需要发送的数据作为形参,形参大小为 1 个字节。在 iic 总线传输中,一个时钟信号就发送一个 bit,所以该函数需要循环八次,模拟八个时钟信号,才能把形参的 8 个位数据都发送出去。这里使用的是形参 data 和 0x80 与运算的方式,判断其最高位的逻辑值,假如为 1 即需要控制 SDA 输出高电平,否则为 0 控制 SDA 输出低电平。为了更好说明,数据发送的过程,单独拿出数据传输时序图,见图 36.2.1.2。
在这里插入图片描述

通过上图就可以很清楚了解数据传输时的细节,经过第一步的 SDA 高低电平的确定后,接着需要延时,确保 SDA 输出的电平稳定,在 SCL 保持高电平期间,SDA 线上的数据是有效的,此过程也是需要延时,使得从设备能够采集到有效的电平。然后准备下一位的数据,所以这里需要的是把 data 左移一位,等待下一个时钟的到来,从设备进行读取。把上述的操作重复 8 次就可以把 data 的 8 个位数据发送完毕,循环结束后,把 SDA 线拉高,等待接收从设备发送过来的应答信号。

接着讲解一下 iic 的读取函数 iic_read_byte,它的定义如下:

/**
* @brief IIC 读取一个字节
* @param ack: ack=1 时,发送 ack; ack=0 时,发送 nack
* @retval 接收到的数据
*/
uint8_t iic_read_byte(uint8_t ack)
{
    uint8_t i, receive = 0;
    for (i = 0; i < 8; i++ ) /* 接收 1 个字节数据 */
    {
        receive <<= 1; /* 高位先输出,所以先收到的数据位要左移 */
        IIC_SCL(1);
        iic_delay();
         
        if (IIC_READ_SDA)
        {
            receive++;
        }
        IIC_SCL(0);
        iic_delay();
    }
     
    if (!ack)
    {
        iic_nack(); /* 发送 nACK */
    }
    else
    {
        iic_ack(); /* 发送 ACK */
    }
     
    return receive;
}

iic_read_byte 函数具体实现的方式跟 iic_send_byte 函数有所不同。首先可以明确的是时钟信号是通过主机发出的,而且接收到的数据大小为 1 字节,但是 IIC 传输的单位是 bit,所以就需要执行 8 次循环,才能把一字节数据接收完整。
具体实现过程:首先需要一个变量 receive 存放接收到的数据,在每一次循环开始前都需要对 receive 进行左移 1 位操作,那么 receive 的 bit0 位每一次赋值前都是空的,用来存放最新接收到的数据位,然后在 SCL 线进行高低电平切换时输出 IIC 时钟,在 SCL 高电平期间加入延时,确保有足够的时间能让数据发送并进行处理,使用宏定义 IIC_READ_SDA 就可以判断读取到的高低电平,假如 SDA 为高电平,那么 receive++即在 bit0 置 1,否则不做处理即保持原来的0 状态。当 SCL 线拉低后,需要加入延时,便于从机切换 SDA 线输出数据。在 8 次循环结束后,我们就获得了 8bit 数据,把它作为返回值返回,然而按照时序图,作为主机就需要发送应答或者非应答信号,去回复从机。上面提及到应答信号和非应答信号是在读时序中发生的,此外在写时序中也存在有一个信号响应,当发送完 8bit 数据后,这里是一个等待从机应答信号的操作,这里我们也定义了,下面看一下它们的定义:

/**
* @brief 等待应答信号到来
* @param 
无
* @retval 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=1, 此时从机可以返回 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;
}
/**
* @brief 产生 ACK 应答
* @param 无
* @retval 无
*/
void iic_ack(void)
{
    IIC_SDA(0); /* SCL 0 -> 1 时 SDA = 0,表示应答 */
    iic_delay();
    IIC_SCL(1); /* 产生一个时钟 */
    iic_delay();
    IIC_SCL(0);
    iic_delay();
    IIC_SDA(1); /* 主机释放 SDA 线 */
    iic_delay();
}
/**
* @brief 
不产生 ACK 应答
* @param 无
* @retval 无
*/
void iic_nack(void)
{
    IIC_SDA(1); /* SCL 0 -> 1 时 SDA = 1,表示不应答 */
    iic_delay();
    IIC_SCL(1); /* 产生一个时钟 */
    iic_delay();
    IIC_SCL(0);
    iic_delay();
}

首先先讲解一下 iic_wait_ack 函数,该函数主要用在写时序中,当启动起始信号,发送完8bit 数据到从机时,我们就需要等待以及处理接收从机发送过来的响应信号或者非响应信号,一般就是在 iic_send_byte 函数后面调用。
具体实现:首先先释放 SDA,把电平拉高,延时等待从机操作 SDA 线,然后主机拉高时钟线并延时,确保有充足的时间让主机接收到从机发出的 SDA 信号,这里使用的是
IIC_READ_SDA 宏定义去读取,根据 IIC 协议,主机读取 SDA 的值为低电平,就表示“应答信号”;读到 SDA 的值为高电平,就表示“非应答信号”。在这个等待读取的过程中加入了超时判断,假如超过这个时间没有接收到数据,那么主机直接发出停止信号,跳出循环,返回等于 1的变量。在正常等待到应答信号后,主机会把 SCL 时钟线拉低并延时,返回是否接收到应答信号。
当主机作为接收端时,调用 iic_read_byte 函数之后,按照 iic 通信协议,需要给从机返回应答或者是非应答信号,这里就是用到了 iic_ack 和 iic_nack 函数。
具体实现:从上面的说明已经知道了 SDA 为低电平即应答信号,高电平即非应答信号,那么还是老规矩,首先先根据返回“应答”或者“非应答”两种情况拉低或者拉高 SDA,并延时等待 SDA 电平稳定,然后主机拉高 SCL 线并延时,确保从机能有足够时间去接收 SDA 线上的电平信号。然后主机拉低时钟线并延时,完成这一位数据的传送。最后把 SDA 拉高,呈高阻态,方便后续通信用到。

2. 24C02 驱动代码
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。24CXX 驱动源码包括两个文件:24cxx.c 和 24cxx.h。
在上一小节已经对 IIC 协议中的需要用到的信号都用函数封装好了,那么现在就要定义符合 24C02 时序的函数。为了使代码功能更加健全,所以在 24cxx.h 中宏定义了不同容量大小的24C 系列型号,具体定义如下:

#define AT24C01 127
#define AT24C02 255
#define AT24C04 511
#define AT24C08 1023
#define AT24C16 2047
#define AT24C32 4095
#define AT24C64 8191
#define AT24C128 16383
#define AT24C256 32767

/* 开发板使用的是 24c02,所以定义 EE_TYPE 为 AT24C02 */
#define EE_TYPE AT24C02

在 24cxx.c 文件中,读/写操作函数对于不同容量大小的 24Cxx 芯片都有相对应的代码块解决处理。下面先看一下 at24cxx_write_one_byte 函数,实现在 AT24Cxx 芯片指定地址写入一个数据,代码如下:

/**
* @brief 在 AT24CXX 指定地址写入一个数据
* @param addr: 写入数据的目的地址
* @param data: 要写入的数据
* @retval 无
*/
void at24cxx_write_one_byte(uint16_t addr, uint8_t data)
{
    /* 原理说明见:at24cxx_read_one_byte 函数, 本函数完全类似 */
    iic_start(); /* 发送起始信号 */
    
    if (EE_TYPE > AT24C16) /* 24C16 以上的型号, 分 2 个字节发送地址 */ 
    {
        iic_send_byte(0XA0); /* 发送写命令, IIC 规定最低位是 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(); /* 每次发送完一个字节,都要等待 ACK */ 
    iic_send_byte(addr % 256); /* 发送低位地址 */
    iic_wait_ack(); /* 等待 ACK, 此时地址发送完成了 */
     
    /* 因为写数据的时候,不需要进入接收模式了,所以这里不用重新发送起始信号了 */
    iic_send_byte(data); /* 发送 1 字节 */
    iic_wait_ack(); /* 等待 ACK */
    iic_stop(); /* 产生一个停止条件 */
    delay_ms(10); /* 注意: EEPROM 写入比较慢,必须等到 10ms 后再写下一个字节 */
}

该函数的操作流程跟前面已经分析过的 24C02 单字节写时序一样,首先调用 iic_start 函数产生起始信号,然后调用 iic_send_byte 函数发送第 1 个字节数据设备地址,等待 24Cxx 设备返回应答信号;收到应答信号后,继续发送第 2 个 1 字节数据内存地址 addr;等待接收应答后,最后发送第 3 个字节数据写入内存地址的数据 data,24Cxx 设备接收完数据,返回应答信号,主机调用 iic_stop 函数产生停止信号终止数据传输,最终需要延时 10ms,等待 eeprom 写入完毕。
我们的函数兼容 24Cxx 系列多种容量,就在发送设备地址处做了处理,这里说一下为什么需要这样子设计。大家请看一下 24Cxx 芯片内存组织表,见表 36.2.2.1 所示。
在这里插入图片描述

主机发送的设备地址和内存地址共同确定了要写入的地方,这里分析一下 24C16 的使用的是 iic_send_byte(0XA0+((addr>>8)<<1))和 iic_send_byte(addr % 256)确定写入位置,由于它内存大小一共 2048 字节,所以只需要定义 11 个寻址地址线,2048 = 2^11。主机下发读写命令的时候带了 3 位,后面再跟 1 个字节(8 位)的地址,正好 11 位,就不需要再发后续的地址字节了。
而容量大于 24C16 的芯片,需要单独发送 2 个字节(甚至更多)的地址,如 24C32,它的大小为 4096,需要 12 个寻址地址线支持,4096 = 2^12。24C16 是 2 个字节刚刚好,而它需要三个字节才能确定写入的位置。24C32 芯片规定设备写地址 0xA0/读地址 0xA1,后面接着发送8 位高地址,最后才发送 8 位低地址。与函数里面的操作是一致。
接下来看一下 at24cxx_read_one_byte 函数,其定义如下:

/**
* @brief 在 AT24CXX 指定地址读出一个数据
* @param readaddr: 开始读数的地址
* @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); /* 发送写命令, IIC 规定最低位是 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(); /* 每次发送完一个字节,都要等待 ACK */
    iic_send_byte(addr % 256); /* 发送低位地址 */
    iic_wait_ack(); /* 等待 ACK, 此时地址发送完成了 */
    iic_start(); /* 重新发送起始信号 */
    iic_send_byte(0XA1); /* 进入接收模式, IIC 规定最低位是 0, 表示读取 */
    iic_wait_ack(); /* 每次发送完一个字节, 都要等待 ACK */
    temp = iic_read_byte(0); /* 接收一个字节数据 */ 
    iic_stop(); /* 产生一个停止条件 */
     
    return temp;
}

这里的函数的实现跟前面第 36.1.2 小节 24C02 数据传输中的读时序一致,主机首先调用iic_start 函数产生起始信号,然后调用 iic_send_byte 函数发送第 1 个字节数据设备写地址,使用iic_wait_ack 函数等待 24Cxx 设备返回应答信号;收到应答信号后,继续发送第 2 个 1 字节数据内存地址 addr;等待接收应答后,重新调用 iic_start 函数产生起始信号,这一次的设备方向改变了,调用 iic_send_byte 函数发送设备读地址,然后使用 iic_wait_ack 函数去等待设备返回应答信号,同时使用 iic_read_byte 去读取从从机发出来的数据。由于 iic_read_byte 函数的形参是 0,所以在获取完 1 个字节的数据后,主机发送非应答信号,停止数据传输,最终调用 iic_stop函数产生停止信号,返回从从机 addr 中读取到的数据。

为了方便检测 24Cxx 芯片是否正常工作,在这里也定义了一个检测函数,代码如下:

/**
* @brief 检查 AT24CXX 是否正常
* @note 检测原理: 在器件的末地址写如 0X55, 然后再读取, 如果读取值为 0X55
* 则表示检测正常. 否则,则表示检测失败.
* @param 无
* @retval 检测结果
* 0: 检测成功
* 1: 检测失败
*/
uint8_t at24cxx_check(void)
{
    uint8_t temp;
    uint16_t addr = EE_TYPE;
    temp = at24cxx_read_one_byte(addr); 
    /* 避免每次开机都写 AT24CXX */
    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 芯片掉电不丢失的特性,在第一次写入了某个值之后,再去读一下是否写入成功,这种方式去检测芯片是否正常工作。

此外方便多字节写入和读取,还定义了在指定地址读取指定个数的函数以及在指令地址写入指定个数的函数,代码如下:

/**
* @brief 在 AT24CXX 里面的指定地址开始读出指定个数的数据
* @param addr : 开始读出的地址 对 24c02 为 0~255
* @param pbuf : 数据数组首地址
* @param datalen : 要读出数据的个数
* @retval 无
*/
void at24cxx_read(uint16_t addr, uint8_t *pbuf, uint16_t datalen)
{
    while (datalen--)
    {
        *pbuf++ = at24cxx_read_one_byte(addr++);
    }
}

/**
* @brief 在 AT24CXX 里面的指定地址开始写入指定个数的数据
* @param addr : 开始写入的地址 对 24c02 为 0~255
* @param pbuf : 数据数组首地址
* @param datalen : 要写入数据的个数
* @retval 无
*/
void at24cxx_write(uint16_t addr, uint8_t *pbuf, uint16_t datalen)
{
    while (datalen--)
    {
        at24cxx_write_one_byte(addr, *pbuf);
        addr++;
        pbuf++;
    }
}

对于这两个函数都是调用前面的单字节操作函数去实现的,利用 for 循环,连续调用单字节操作函数去实现,这里就不多讲。

3. main.c 代码
在 main.c 里面编写如下代码:

int main(void)
{

  /* USER CODE BEGIN 1 */

  /* USER CODE END 1 */

  /* MCU Configuration--------------------------------------------------------*/

  /* Reset of all peripherals, Initializes the Flash interface and the Systick. */
  HAL_Init();

  /* USER CODE BEGIN Init */

  /* USER CODE END Init */

  /* Configure the system clock */
  SystemClock_Config();

  /* USER CODE BEGIN SysInit */

  /* USER CODE END SysInit */

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_DMA_Init();
  MX_USART1_UART_Init();
  MX_TIM6_Init();
  MX_RTC_Init();
  MX_ADC1_Init();
  MX_TIM7_Init();
  /* USER CODE BEGIN 2 */
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
    uart_debug_task();

    XL_TIME6_time_show();

    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }
  /* USER CODE END 3 */
}

4.24c02测试接口

void uart_debug_task(void)
{
    uint8_t len;
    static uint32_t once_flag = 0;
    uint8_t cmd_buf[64] = {0};
    uint32_t para_1 = 0;
    uint32_t para_2 = 0;
    uint32_t para_3 = 0;
    uint32_t ret = 0;

    if (0 == once_flag)
    {
        once_flag = 1;
        //HAL_UART_Receive_IT(&huart1, (uint8_t *)g_rx1_buffer, RX1BUFFERSIZE); 
        HAL_UART_Receive_DMA(&huart1, (uint8_t *)g_rx1_buffer, RX1BUFFERSIZE);
    }
    
    if (g_usart1_rx_sta & 0x8000)         /* 接收到了数据? */
    {
        if (1)//for debug
        {
            len = g_usart1_rx_sta & 0x3fff;  /* 得到此次接收到的数据长度 */
            printf("\r\nThe message you sent is:\r\n");
            //HAL_UART_Transmit(&huart1, (uint8_t*)g_usart1_rx_buf, len, 1000);    /* 发送接收到的数据 */
            HAL_UART_Transmit_DMA(&huart1, (uint8_t*)g_usart1_rx_buf, len);
            while(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) != SET);           /* 等待发送结束 */
            printf("\r\n");             /* 插入换行 */
        }

        ret = sscanf((void*)g_usart1_rx_buf, "%s %d %d %d", cmd_buf, &para_1, &para_2, &para_3);
        if (1 <= ret)
        {
            printf("cmd:%s, ret:%d\r\n", cmd_buf, ret);

            uint8_t cmd_1[32] = "cmd_RTC_set";
            if (0 == strncmp((void*)cmd_1, (void*)cmd_buf, strlen((void*)cmd_1)))
            {
                uint32_t year;
                uint32_t Month;
                uint32_t WeekDay;
                uint32_t Date;
                uint32_t Hours;
                uint32_t Minutes;
                uint32_t Seconds;
                sscanf((void*)g_usart1_rx_buf, "%s %d %d %d %d %d %d %d", cmd_buf, &year, &Month, &WeekDay, &Date,  &Hours,  &Minutes,  &Seconds);

                XL_RTC_set_time( year, Month, WeekDay, Date, Hours, Minutes, Seconds);

                printf("XL_RTC_set_time success!!\r\n");
            }

            uint8_t cmd_2[32] = "cmd_test";
            if (0 == strncmp((void*)cmd_2, (void*)cmd_buf, strlen((void*)cmd_2)))
            {
                extern uint32_t g_count;
                
                at24cxx_init();
                uint8_t tmp_value = at24cxx_check();
                printf("g_count:%d, tmp_value:%d\r\n", g_count, tmp_value);

                at24cxx_test();
            }

            uint8_t cmd_3[32] = "cmd_EEPROM_test";
            if (0 == strncmp((void*)cmd_3, (void*)cmd_buf, strlen((void*)cmd_3)))
            {
                sscanf((void*)g_usart1_rx_buf, "%s %d %d %d", cmd_buf, &para_1, &para_2, &para_3);
                
                at24cxx_init();

                if (1 == para_1)    /* 读数据 */
                {
                    uint8_t read_value = at24cxx_read_one_byte(para_2);
                    printf("read:%d\r\n", read_value);
                }

                if (2 == para_1)    /* 写数据 */
                {
                    at24cxx_write_one_byte(para_2, para_3);
                    printf("write:%d\r\n", para_3);
                }
            }

            g_usart1_rx_sta = 0;
            memset((void*)g_usart1_rx_buf, 0, sizeof(g_usart1_rx_buf));

        }
        /* Clear the receiving cache */
        g_usart1_rx_sta = 0;
        memset((void*)g_usart1_rx_buf, 0, sizeof(g_usart1_rx_buf));
    }
    else
    {
    }
    
    return;
}

36.4 下载验证

将程序下载到开发板后,通过串口输入“cmd_test”,调用检查函数,测试24C02.把24c02全地址写入再读取。
通过串口指令“cmd_EEPROM_test 1 0 0”,1:读指令,第一个0:读取的地址,第二个0:无意义。
通过串口指令“cmd_EEPROM_test 2 0 55”,1:写指令,第一个0:读取的地址,55:向0地址写入的值。
在这里插入图片描述

36.5 STM32CubeMX

1.IIC_SDA引脚配置
在这里插入图片描述

2.IIC_SCL引脚配置
在这里插入图片描述

3.TIME7配置 (延时需要定时器)
TIME7 时钟源是84M, 这里配置周期中断的时间为1us.
在这里插入图片描述

4.TIME7中断使能
在这里插入图片描述

5.TIME7中断优先级配置
在这里插入图片描述

36.6 移值IIC驱动注意点

1.IIC若是软件实现的,就肯定需要延时函数,一般延时函数有us级别的和ms级别的。需要实现定时器中断计时,达到1us中断。

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM6)
    {
        LED_blink();

        g_time_1s++;
        g_time_10s++;
    }
    
    if (htim->Instance == TIM7)
    {
        g_count++;

        if (g_time_1us > 0)
        {
            g_time_1us--;
        }
    
        if (g_time_1ms > 0)
        {
            g_time_1ms--;
        }
    }
}

2.IIC的数据和时钟引脚需要和自己板子的匹配

#define IIC_SCL_GPIO_PORT               EEPROM_IIC_SCL_GPIO_Port
#define IIC_SCL_GPIO_PIN                EEPROM_IIC_SCL_Pin
//#define IIC_SCL_GPIO_CLK_ENABLE()       do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0)   /* PB口时钟使能 */

#define IIC_SDA_GPIO_PORT               EEPROM_IIC_SDA_GPIO_Port
#define IIC_SDA_GPIO_PIN                EEPROM_IIC_SDA_Pin
//#define IIC_SDA_GPIO_CLK_ENABLE()       do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0)   /* PB口时钟使能 */

3.对IIC和外部器件的时序要了解。24C02的EEPROM存储空间很小,一个字节就可以表示存储地址,若是存储空间比较大,那么需要多个字节来表示存储地址。和上述的驱动就有点区别,但是都是基于IIC,启始 停止 数据 等基本都是一样的,区别在IIC传输数据的时序有区别。

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);    /* 发送写命令, IIC规定最低位是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();             /* 每次发送完一个字节,都要等待ACK */
    iic_send_byte(addr % 256);  /* 发送低位地址 */
    iic_wait_ack();             /* 等待ACK, 此时地址发送完成了 */
    
    iic_start();                /* 重新发送起始信号 */ 
    iic_send_byte(0xA1);        /* 进入接收模式, IIC规定最低位是1, 表示读取 */
    iic_wait_ack();             /* 每次发送完一个字节,都要等待ACK */
    temp = iic_read_byte(0);    /* 接收一个字节数据 */
    iic_stop();                 /* 产生一个停止条件 */
    return temp;
}

36.7 源码路径

git clone git@gitee.com:xiaoliangliangcong/stm32.git
STM32F407ZGT6/13.IIC

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐