软件模拟I2C案例(寄存器实现)

引言

在经过前面对I2C基础知识的理解,对支持I2C通讯的EEPROM芯片M24C02的简单介绍以及涉及到的时序操作做了整理。接下来,我们就正式进入该案例的实现环节了。本次案例是基于寄存器开发方式 通过软件模拟I2C通讯协议,然后去实现相关的需求。

阅读本篇文章前,建议初次接触的朋友先理解一下几篇文章,然后再来阅读本篇文章可能会更加容易。

I2C基础知识-CSDN博客

软件模拟I2C案例前提须知------EEPROM芯片之M24C02_24c02 i2c-CSDN博客

模拟I2C通讯之时序图整理-CSDN博客


一、需求描述

EEPROM芯片最常用的通讯方式就是I2C协议, 本次使用的芯片是M24C02

我们向E2PROM写入 一段数据,再读取 出来,最后发送到串口,核对是否读写正确。

二、硬件电路设计

2.1 EEPROM电路原理图

根据M24C02芯片的电路连接可知,其设备地址为7位,已经固定为1010000。由于进行I2C通讯时传递的设备地址码后面还会紧跟一位读写方向位WR(写-0 读-1) ,因此易知最终传输的设备地址码为**【写地址】0xA0** 和**【读地址】0xA1**两种。
WC#端口:写保护,可看做写入使能,低电平有效。有图可知已经固定低电平,即一直可写
I2C相关端口:SCL与SDA引脚,连接主机(STM32芯片)I2C相关端口,可见引脚网络名I2C2...

2.2 端口原理图

由端口原理图可见,涉及到的GPIO口为PB10与PB11,PB10对应SCL,PB11对应SDA 。由于本次案例软件模拟I2C,故不会用到STM32芯片内置硬件I2C模块,即只使用GPIO引脚的通用输入输出功能给高低电平即可。
同时由于I2C通讯方式为总线连接方式,即多个设备同时挂在一根总线上进行通讯,因此GPIO工作模式将使用通用功能的开漏输出模式

三、软件设计

3.1 工程创建

按照以往工程创建方式应该算是轻车熟路了,这里不再赘述。值得注意的是,本次案例本质上是借助模拟出来的I2C通讯协议实现STM32与EEPROM间的数据传递,所以I2C通讯协议模拟部分代码属于硬件层实现,而与EEPROM通讯的过程实际上是直接调用I2C协议接口的逻辑,这部分属于接口层实现 。故本次将在工程目录中多增加一个目录【Interface】,放调用相关接口的代码文件。

创建好后的效果如下

3.2 工程配置

在本地创建好工程后,在keil中打开此工程进行相关配置。

首先,在【品】中添加【group】和【file】,主要是我们本次工程新增的目录和文件

效果如下:

其次,进入**【魔法棒】** ,在【C/C++】中的**【include path】** 添加新增文件路径,以及配置**【debug】**调试工具

如上图效果即可。这样,本工程就配置完毕了。


3.3 程序实现

接下来,在VSCode中打开该工程,开始编写代码。

3.3.1 I2C协议部分

首先,编写I2C部分的代码,主要是通过软件模拟出I2C通讯相关时序操作。

3.3.1.1 i2c.h

1、头文件基本格式不要忘

防止头文件重复编译,通常编写头文件内容时初始会有统一的框架,然后在内部添加代码。

复制代码
#ifndef __I2C_H
#define __I2C_H


#endif

2、引用必要头文件

(1)进行32寄存器开发,势必使用到32中的一些宏定义,故stm32f10xx.h要引入;

(2)模拟I2C通讯的一些时序,会涉及到高低电平的维持,通常会用到延时来实现"维持"效果,故Delay.h要引入。

复制代码
#include "stm32f10x.h"
#include "Delay.h"

3、实现I2C协议的一些基本宏定义

宏定义起到一个全局替换的效果,经过宏定义,我们可以将某些复杂代码利用简洁移动的语句进行代替,增强代码可读性和编写效率。

(1)由于I2C协议涉及到应答ACK和非应答NACK响应,分别由低电平0和高电平1表示,为增强可读性,这里选择使用宏定义代替。

复制代码
#define ACK 0
#define NACK 1

(2)由于后面模拟I2C时序操作时,会频繁涉及到SCL和SDA线上信号的拉低/拉高 ,而这些电平的产生涉及到PB10和PB11端口的输出由于语句较长,故这里将对相关代码利用简洁易懂的宏定义 。同时防止与其他语句共用时出现执行歧义,我们用括号括起来进行替换。

复制代码
// SCL、SDA线拉低拉高
#define SCL_LOW (GPIOB->ODR &= ~GPIO_ODR_ODR10)
#define SCL_HIGH (GPIOB->ODR |= GPIO_ODR_ODR10)

#define SDA_LOW (GPIOB->ODR &= ~GPIO_ODR_ODR11)
#define SDA_HIGH (GPIOB->ODR |= GPIO_ODR_ODR11)

(3)后面主机(STM32)获取从机(EEPROM)的数据或者发出的响应时,需要在SDA线上进行数据采样获得,此时相当于读取PB11端口输出的电平,这里也是进行简单的宏定义。

复制代码
// 主机读取从机信号
#define READ_SDA (GPIOB->IDR & GPIO_IDR_IDR11)

(4)I2C协议模拟时用到的延时调用也可用宏定义I2C_DEALY替换。本次模拟I2C通讯的传输速率使用标准模式的100kbit/s,反映在时序图上相当于以100k的频率进行电平的传递,换算为时间周期即1/100k = 10^(-5) s,也就是10us的延时即可。

复制代码
// I2C通讯基本延时
#define I2C_DELAY (Delay_us(10))

4、可能用到的函数声明

(1)I2C的初始化函数。任何模块的调用都少不了起初的配置,由于将借助GPIO引脚输出不同电平模拟I2C时序,故GPIO相关配置少不了,我们把配置部分归于I2C的初始化部分。

复制代码
// 初始化
void I2C_Init(void);

(2)I2C通讯的起始信号和停止信号函数。从I2C协议所涉及的时序操作思考,首先会有主机发出的起始信号以及最后的停止信号时序需要模拟实现。

复制代码
// 起始信号
void I2C_Start(void);
// 停止信号
void I2C_Stop(void);

(3)主机发出的I2C应答响应和非应答响应函数。其次还涉及到I2C通讯的响应时序操作的模拟实现,即主机向从机发出ACK和NACK响应信号。

复制代码
// 主机发出应答响应
void I2C_Ack(void);
// 主机发出非应答响应
void I2C_Nack(void);

(4)主机等待从机的响应信号函数。既然有主机向从机发出的,则也会有从机发给主机的响应,以主机32为视角,我们是接收从机响应,这个过程相当于等待从机发出的响应信号,直到读取到则结束。

复制代码
// 主机等待从机发出响应
uint8_t I2C_Wait4Ack(void);

(5)主机向从机写入/读取一个字节数据函数。最后,进行I2C通讯目的就是数据传递,所以还有写入和读取数据的函数,而I2C通讯规定了位传输和响应,一般每传1个字节就会进行一次响应过程,故这里只要读写单字节数据的函数即可。

复制代码
// 主机向从机写入一个字节的数据(发送)
void I2C_SendByte(uint8_t byte);
// 主机向从机读取一个字节的数据(接收)
uint8_t I2C_ReadByte(void);

这样,i2c头文件就编写完毕。

3.3.1.2 i2c.c

编写完i2c头文件后,接下来编写I2C的源文件,对其中的函数进行实现。

1、初始化函数I2C_Init()

前面说了,I2C初始化部分就是一些配置,这里软件模拟I2C就是配置一下相关的GPIO端口即PB10和PB11就OK了,涉及到两部分:GPIO时钟配置和工作模式的配置。

(1)GPIO时钟配置

不记得对应寄存器的可以去查查STM32F10xx系列参考手册的存储器地址映像,容易发现用到的寄存器是RCC的APB2ENR寄存器。

参考代码如下:

复制代码
 RCC->APB2ENR |= RCC_APB2ENR_IOPBEN;

(2)GPIO工作模式

分析硬件电路设计的时候说到了,用到的PB10和PB11两个端口,由于I2C通讯是一种总线的连接方式,故均使用高速通用开漏输出模式就行。涉及的寄存器可在参考手册中查阅,即端口配置寄存器

参考代码如下:

复制代码
    GPIOB->CRH |= (GPIO_CRH_MODE10 | GPIO_CRH_MODE11);
    GPIOB->CRH &= ~(GPIO_CRH_CNF10_1 | GPIO_CRH_CNF11_1);
    GPIOB->CRH |= (GPIO_CRH_CNF10_0 | GPIO_CRH_CNF11_0);

所以I2C初始化函数参考如下:

复制代码
// 初始化
void I2C_Init(void)
{
    // 1. 配置时钟
    RCC->APB2ENR |= RCC_APB2ENR_IOPBEN;

    // 2. 设置GPIO工作模式 通用开漏输出 cnf-01 mode-11
    GPIOB->CRH |= (GPIO_CRH_MODE10 | GPIO_CRH_MODE11);
    GPIOB->CRH &= ~(GPIO_CRH_CNF10_1 | GPIO_CRH_CNF11_1);
    GPIOB->CRH |= (GPIO_CRH_CNF10_0 | GPIO_CRH_CNF11_0);

}

2、I2C起始信号I2C_Start() 和停止信号函数I2C_Stop()

起始信号和停止信号函数的实现我们需要根据对应时序操作图实现,如下图

(1)根据时序图可知,主机发出起始信号的过程为:

【SDA拉高、SCL拉高,等待数据翻转】->【维持10us】->【SDA拉低、SCL保持不变】->【维持10us】-> 起始信号产生

(2)主机发出停止信号的过程为:

【SDA拉低、SCL拉高,等待数据翻转】->【维持10us】->【SDA拉高、SCL保持不变】->【维持10us】-> 停止信号产生

参考代码如下

复制代码
// 主设备发出起始信号
void I2C_Start(void)
{
    // 1. SCL、SDA拉高
    SDA_HIGH;
    SCL_HIGH;
    I2C_DELAY;

    // 2. SCL保持不变、SDA拉低,发出起始信号
    SDA_LOW;
    I2C_DELAY;
}

// 主设备发出停止信号
void I2C_Stop(void)
{
    // 1. SCL拉高、SDA拉低
    SDA_LOW;
    SCL_HIGH;
    I2C_DELAY;

    // 2. SCL保持不变、SDA拉高
    SDA_HIGH;
    I2C_DELAY;
}

3、主机发出I2C应答I2C_Ack()或非应答响应函数I2C_Nack()

I2C响应对应时序操作图如下

如上图前两个时序为不同状态下的数据总线SDA第三条时序为主控制的时钟时序SCL。此时主机发送响应给从机,则此时主机控制后两条时序操作。

也就是说此时SCL先为低电平,不进行SDA线上信号的采样,然后SDA线先是默认高电平,一段时间后主机发出响应被拉低/拉高一段时间,接着SCL拉高一段时间进行SDA线上的信号采样,最后SCL拉低结束信号采样,一段时间后SDA拉高,释放数据总线即可。

(1)主机发出应答的过程:

【SDA拉高、SCL拉低】->【维持10us】->【SDA拉低、SCL保持不变】->【维持10us】->【SDA不变、SCL拉高,从机开始采集主机发出的应答信号】->【维持10us】->【SDA不变、SCL拉低,结束信号采集】->【维持10us】->【SDA拉高、SCL保持不变,释放数据总线】->【维持10us】->过程结束

(2)主机发出非应答的过程:

【SDA拉高、SCL拉低】->【维持10us】->【SDA不变、SCL拉高,从机开始采集主机发出的应答信号】->【维持10us】->【SDA不变、SCL拉低,结束信号采集】->【维持10us】->过程结束

参考代码如下:

复制代码
// 主设备发出应答响应
void I2C_Ack(void)
{
    // 1. SDA拉高、SCL拉低
    SDA_HIGH;
    SCL_LOW;
    I2C_DELAY;

    // 2. SCL保持不变、SDA拉低,主机发出应答
    SDA_LOW;
    I2C_DELAY;

    // 3. SCL拉高、SDA保持不变,开始信号采样
    SCL_HIGH;
    I2C_DELAY;

    // 4. SCL拉低、SDA保持不变,结束信号采样
    SCL_LOW;
    I2C_DELAY;

    // 5. SDA拉高,释放数据总线
    SDA_HIGH;
    I2C_DELAY;
}

// 主设备发出非应答响应
void I2C_Nack(void)
{
    // 1. SDA拉高、SCL拉低
    SDA_HIGH;
    SCL_LOW;
    I2C_DELAY;

    // 2. SDA保持不变、SCL拉高,开始非应答信号采样
    SCL_HIGH;
    I2C_DELAY;

    // 3. SDA保持不变、SCL拉低,结束信号采样
    SCL_LOW;
    I2C_DELAY;
}

4、主机等待从机发出响应uint8_t I2C_Wait4Ack()

仍是响应,不过角色互换了,这时候相当于主机采集从机发出的响应信号,这时候就会出现两种情况,可能是应答响应,也可能是非应答响应。

我们总以主机32为视角,由于从机发出响应信号,因此这时候数据总线SDA上的信号不受主机32控制,所以这时候主机应该释放数据总线,然后控制SCL的变化就行。

即首先SDA线会空闲,SCL会拉低一段时间,然后SCL被拉高,主机32就要开始采集数据总线上的信号了,一段时间后结束信号采样,SCL就被拉低一段时间,然后返回获取到的信号就OK了。

主机等待从机响应的过程为:

【SCL拉低、SDA拉高,主机释放数据总线】->【维持10us】->【SCL拉高,主机开始采集SDA线上的信号】->【存采集到的数据】->【维持10us】->【SCL拉低,结束数据采样】->【维持10us】-> 返回采集的信号

需要注意的是,我们采集到的响应信号是16位的数据,而实际的响应只是一位的数据,所以最后返回的值我们将借助**三元条件运算【exp1 ? exp2 : exp3】**区分出应答于非应答信号后返回理应的一位数据。

参考代码如下

复制代码
// 主机等待从机发出响应
uint8_t I2C_Wait4Ack(void)
{
    // 1. SCL拉低、SDA拉高、主机释放数据总线 
    SCL_LOW;
    SDA_HIGH;
    I2C_DELAY;

    // 2. SCL拉高、开始信号采样 从机控制SDA,主机不用管其状态
    SCL_HIGH;
    I2C_DELAY;

    // 3. 获取采集的响应
    uint16_t ack = READ_SDA;  

    // 4. SCL拉低,结束信号采样 数据总线由从机控制,主机设备不用管SDA线上的情况
    SCL_LOW;
    I2C_DELAY;

    return ack ? NACK : ACK;
}

5、主机向从机写入一个字节数据I2C_Sendbyte(uint8_t byte)

I2C通讯进行数据的读写时存在数据的有效性时序操作图,如下图所示

前面介绍时序图时说过,数据的有效性指的是在SCL线为高电平时,SDA线上的信号要维持周期稳定。由于I2C通讯的数据传输时一种位传输的形式,且为高位先行。

那么如何获取一个字节数据的高位呢?可以利用位与运算,由于一个字节是8位的数据,所以只需要让数据和1000 0000作位与运算即可得到,即byte & 0x80。

所以传输一个字节的数据就意味着要循环8次去恰好在满足以上时序的情况下进行才有效。理解了时序图,其实代码也比较好写的。

主机写入单字节数据的过程为:

【SDA拉低、SCL拉低,EEPROM准备数据采样】->【维持10us】->【开始写入数据,获取单字节高位高位】->【转换成SDA线上的高低电平信号】->【维持10us】->【数据左移一位,获取低1位数据】->【SCL拉高,SDA保持不变,EEPROM开始数据采样】->【维持10us】->【SCL拉低,结束数据采样】->【维持10us】->循环过程8次后,主机写入单字节完成

参考代码如下:

复制代码
// 主机向从机写入一个字节的数据(发送)
void I2C_SendByte(uint8_t byte)
{
    for (uint8_t i = 0; i < 8; i++)
    {
        // 1. SCL拉低、SDA拉低,准备数据采样
        SDA_LOW;
        SCL_LOW;
        I2C_DELAY;

        // 2. 获取单字节数据最高位
        if (byte & 0x80)
        {
            SDA_HIGH;
        }
        else
        {
            SDA_LOW;
        }
        I2C_DELAY;

        // 3. SCL拉高,开始数据采样
        SCL_HIGH;
        I2C_DELAY;

        // 4. SCL拉低,结束数据采样
        SCL_LOW;
        I2C_DELAY;

        // 5. 左移一位
        byte <<= 1;
    }
}

6、主机向从机读取一个字节的数据uint8_t I2C_ReadByte()

读取操作同样会涉及到数据有效性,所以时序图与写入时一样如下

主机读取从机一个字节的数据,就是相当于主机不是给数据的一方,而是接收数据的一方。换句话说,主机读取一个字节的数据就是在有效数采样过程中主机逐位读取从机发在SDA线上产生的信号,也就相当于是读取端口PB10上的电平,此时SDA线上数据的传递可理解为由EEPROM控制,所以此时我们只需控制时钟线SCL来采集从机传递的数据就行。读取和写入的区别主要就是在于数据采用时操作的不同,其他基本类似。

主机读取从机单字节数据的过程如下:

创建8位数据类型的变量byte临时存放采集数据,【SCL拉低,等待数据翻转】->【维持10us】->【SCL拉高,开始采集从机发在SDA线上的信号】->【byte左移一位】->【byte从低位开始逐个存放获取的位数据】->【维持10us】->【SCL拉低,结束采样】-> 【维持10us】-> 前面循环8次后,返回byte即可

值得注意的是,读取单字节数据时,我们需要先左移再存放,原因是避免第八次左移时将最高位数据移出缓冲区而出现错误,大家可以自己简单琢磨一下。

参考代码如下:

复制代码
// 主机向从机读取一个字节的数据(接收)
uint8_t I2C_ReadByte(void)
{
    uint8_t byte = 0;
    
    for (uint8_t i = 0; i < 8; i++)
    {
        // 1. SCL拉低,等待数据翻转
        SCL_LOW;
        I2C_DELAY;

        // 2. SCL拉高,开始从机的数据采样
        SCL_HIGH;
        I2C_DELAY;

        // 3. 读取从机数据 
        byte <<= 1;
        if (READ_SDA)
        {
            byte |= 0x01; 
        }
        
        // 4. SCL拉低,结束数据采样
        SCL_LOW;
        I2C_DELAY;
    }
    
    return byte;
}

这样,I2C通讯协议就实现完成了。


3.3.2 M24C02部分

3.3.2.1 m24c02.h

接下来,我们来借助模拟的I2C协议实现32与m24c02直接的数据传递,首先是编写一下头文件。

1、头文件基本格式不要忘

防止头文件重复编译,通常编写头文件内容时初始会有统一的框架,然后在内部添加代码。

复制代码
#ifndef __M24C02_H
#define __M24C02_H


#endif

2、引用必要头文件

由于M24C02是直接借助模拟的I2C协议即可,同时i2c.h中已经引入了32的头文件,所以这里我们只需要引入I2C的头文件即可。

复制代码
#include "i2c.h"

3、增加M24C02用到的宏定义

根据前面对M24C02的读写时序操作介绍我们知道,对其进行读写操作时涉及到传递内部地址(byte address),用来指明写入数据到EEPROM的那一块内存单元或者从哪一块地址读取数据给主设备。由于读地址和写地址根据前面硬件电路的介绍已知已经固定下来,所以这里我们使用宏定义R_ADDR和W_ADDR来分别表示固定不变的读地址和写地址。

复制代码
// 宏定义
#define W_ADDR (0xA0)
#define R_ADDR (0xA1)

4、可能调用的函数声明

首先肯定会有一个M24C02的初始化函数 。其次既然我们是用STM32作为主机与M24C02进行数据传递,那么自然会涉及到读写操作,也就是主机向M24C02写入/读取数据函数(包括单字节和多字节)。关于读写操作在M24C02的芯片手册中以及前面介绍M24C02中的读写操作时序时也是有所提到过的。

总结一下涉及到的M24C02函数声明总共有5个,分别是**【M24C02的初始化】、【向M24C02写入一个字节数据】、【向M24C02读取一个字节数据】、【向M24C02连续写入多个字节的数据】、【向M24C02连续读取多个字节的数据】**。

参考代码如下:

复制代码
// 初始化
void M24C02_Init(void);

// 写入一个字节的数据
void M24C02_Writebyte(uint8_t innerAddr, uint8_t byte);

// 读取一个字节的数据
uint8_t M24C02_Readbyte(uint8_t innerAddr);

// 连续写入多个字节的数据(页写)
void M24C02_Writebytes(uint8_t innerAddr, uint8_t * bytes, uint8_t size);

// 连续读取多个字节的数据
void M24C02_Readbytes(uint8_t innerAddr, uint8_t * buffer, uint8_t size);

这样,关于M24C02的头文件就完成了。

m24c02.h参考代码如下

复制代码
#ifndef __M24C02_H
#define __M24C02_H

#include "i2c.h"

// 宏定义
#define W_ADDR (0xA0)
#define R_ADDR (0xA1)

// 初始化
void M24C02_Init(void);

// 写入一个字节的数据
void M24C02_Writebyte(uint8_t innerAddr, uint8_t byte);

// 读取一个字节的数据
uint8_t M24C02_Readbyte(uint8_t innerAddr);

// 连续写入多个字节的数据(页写)
void M24C02_Writebytes(uint8_t innerAddr, uint8_t * bytes, uint8_t size);

// 连续读取多个字节的数据
void M24C02_Readbytes(uint8_t innerAddr, uint8_t * buffer, uint8_t size);

#endif

3.3.2.2 m24c02.c

接下来,我们开始在M24C02源文件中完善这些函数。当然了,由于这些函数都是读写操作,所以均会涉及到相关时序,故编写过程中将不断对照M24C02读写操作的时序图,因此笔者建议在这之前一定要先理解清楚相关时序图的含义,然后再往下阅读!!!

1、M24C02的初始化M24C02_Init()

因为M24C02和STM32间的通讯只是依赖I2C通讯协议,并没有使用其他硬件模块,因此其初始化只需要初始化一下I2C即可。

复制代码
// 初始化
void M24C02_Init(void)
{
    I2C_Init();
}

2、向M24C02写入单字节数据M24C02_Writebyte(uint8_t innerAddr, uint8_t byte)

M24C02芯片手册中关于字节写入操作提供了相应的时序操作图如下

首先,WC写保护,这里前面硬件设计为固定一直保持可写状态,所以不用管;其次看写入的操作时序,在介绍时序图文章中对上图也做了比较详细的讲述,还算简单。

由图可知,主机32发出起始信号 后,最先传递的是设备地址 ,用于从机的匹配作用,对应的从机会自动对应上,同时紧跟写信号0 表示此时对从机进行写入操作。然后等待从机应答 ,然后再传输内部地址 给出写入数据的内存单元并等待从机应答 。接着主机开始传输向从机写入的一字节具体数据 ,最后等待从机不应答 结束数据写入,然后主机发出停止信号 结束本次写入操作,最后延时5ms保证写入周期结束即可。

参考代码如下

复制代码
// 主机写入一个字节的数据
void M24C02_Writebyte(uint8_t innerAddr, uint8_t byte)
{
    // 1. 主机发出起始信号
    I2C_Start();

    // 2. 主机传输设备地址,从机对应
    I2C_SendByte(W_ADDR);

    // 3. 等待m24c02应答
    uint8_t ack = I2C_Wait4Ack();

    if (ack == ACK)
    {
        // 4. 主机传输内部地址
        I2C_SendByte(innerAddr);

        // 5. 等待从机应答
        I2C_Wait4Ack();

        // 6. 主机写入具体数据
        I2C_SendByte(byte);

        // 7. 等待应答
        I2C_Wait4Ack();

        // 8. 主机发出停止信号,结束写入数据
        I2C_Stop();
    }
    
    // 9. 延时等待字节写入周期结束
    Delay_ms(5);
}

大家会发现,关于等待从机应答并没有做详细的判断,主要原因如下:

这里我们简单起见,并没有对从机发出的应答信号做检查,也就是一致认为应答信号是没有问题的。因为实际山我们没有比较合适的调试方式去进行判断,同时及时出现响应异常主要是受自己控制,我们程序认为其没有问题即可,因此这里我们默认认为从机来的响应是正确的。

3、向M24C02读取单字节数据uint8_t M24C02_Readbyte(uint8_t innerAddr)

同理这里放一个读取单字节的时序操作图以及相关解释**(图中右数第二个ACK解释有误,应该是从机应答而不是主机应答)**

如上图,可见的是M24C02读操作会麻烦一些,但过程理解起来并不难。这是一个随机地址读取 方式,主要是为了实现读取咱指定的内部地址的数据 ,所以在真正开始读取前要进行一个"假写"操作,即给出内部地址,使地址计数器(address counter)指向给的内部地址,但并不进行具体数据的写入。然后然后开始进行实际读取操作 。即**"假写真读"**的操作。

需要注意的是,读取操作是从机把数据给到主机,这意味着这个过程从机会控制数据总线然后主机响应是否收到从机传到数据总线上的信号。

整个过程按照前面理解时序的思路可以很快的进行代码实现,这里图中也进行了详细解释,故直接放代码如下:

复制代码
// 读取一个字节的数据
uint8_t M24C02_Readbyte(uint8_t innerAddr)
{
    // 1. 主机发出起始信号 
    I2C_Start();

    // 2. 主机传输设备地址(假写),从机对应
    I2C_SendByte(W_ADDR);

    // 3. 等待m24c02应答
    uint8_t ack = I2C_Wait4Ack();

    // 4. 主机传输内部地址
    I2C_SendByte(innerAddr);

    // 5. 等待m24c02应答
    I2C_Wait4Ack();

    // 6. 主机再次发出起始信号 
    I2C_Start();

    // 7. 主机传输设备地址(真读),m24c02对应
    I2C_SendByte(R_ADDR);

    // 8. 等待m24c02应答,m24c02开始控制数据总线
    I2C_Wait4Ack();

    // 9. 获取m24c02读取的数据
    uint8_t data = I2C_ReadByte();

    // 10. 主机发出非应答,m24c02释放数据总线
    I2C_Nack();

    // 11. 主机发出停止信号,结束数据读取
    I2C_Stop();

    return data;
}

4、向M24C02连续写入多个字节数据 M24C02_Writebytes(uint8_t innerAddr, uint8_t * bytes, uint8_t size) (也称页写)

同理,对照M24C02芯片手册中提供的连续写入操作时序图如下

可以看出,连续写入实际上就是写入具体数据的过程被循环了N次,这个N代表了字节数。由于从机的响应这里简单默认视作都是对的,所以均等待从机响应就OK了。然后其余部分基本类似,没有啥变化,不好理解的话可以回头再看看写入单字节过程。

这里参考代码如下

复制代码
// 连续写入多个字节的数据(页写)
void M24C02_Writebytes(uint8_t innerAddr, uint8_t * bytes, uint8_t size)
{
    // 1. 主机发出起始信号
    I2C_Start();

    // 2. 主机传输设备地址,从机对应
    I2C_SendByte(W_ADDR);

    // 3. 等待m24c02应答
    uint8_t ack = I2C_Wait4Ack();

    if (ack == ACK)
    {
        // 4. 主机传输内部地址
        I2C_SendByte(innerAddr);

        // 5. 等待从机应答
        I2C_Wait4Ack();

        
        for (uint8_t i = 0; i < size; i++)
        {
            // 6. 主机写入具体数据
            I2C_SendByte(bytes[i]);

            // 7. 等待应答
            I2C_Wait4Ack();
        }
    
        // 8. 主机发出停止信号,结束写入数据
        I2C_Stop();
    }
    
    // 9. 延时等待字节写入周期结束
    Delay_ms(5);
}

5、向M24C02连续读取多个字节数据 M24C02_Readbytes(uint8_t innerAddr, uint8_t * buffer, uint8_t size)

读取连续多字节数据的函数,我们不采用返回值的方式,因为字符串返回值是传指针的形式,相对毕竟麻烦容易出错,所以这里我们利用形参传递缓冲区buffer[]地址,实现字符串的获取。

同理,这里对照连续读取操作的时序图

很显然,连续读取和读取单字节的区别就在于从机在SDA线上传的次数不同,连续就是重复的去传,即使用循环 实现。不过这里要注意的是:要连续传的话主机要给出应答,使得从机知道还要继续传数据。直到主机给出非应答,从机才停止传输,然后释放数据总线,最后主机控制SDA并发出停止信号结束连续读取操作。

参考代码如下

复制代码
// 连续读取多个字节的数据
void M24C02_Readbytes(uint8_t innerAddr, uint8_t * buffer, uint8_t size)
{
    // 1. 主机发出起始信号 
    I2C_Start();

    // 2. 主机传输设备地址(假写),从机对应
    I2C_SendByte(W_ADDR);

    // 3. 等待m24c02应答
    uint8_t ack = I2C_Wait4Ack();

    // 4. 主机传输内部地址
    I2C_SendByte(innerAddr);

    // 5. 等待m24c02应答
    I2C_Wait4Ack();

    // 6. 主机再次发出起始信号 
    I2C_Start();

    // 7. 主机传输设备地址(真读),m24c02对应
    I2C_SendByte(R_ADDR);

    // 8. 等待m24c02应答,开始控制数据总线
    I2C_Wait4Ack();

    for (uint8_t i = 0; i < size; i++)
    {
        // 9. 获取m24c02读取的数据
        buffer[i] = I2C_ReadByte();

        // 10. 主机发出响应
        if (i < size - 1)
        {
            I2C_Ack();
        }
        else
        {
            // 11. 主机发出非应答,m24c02释放数据总线
            I2C_Nack();
        }
        
    }
    
    // 12. 主机发出停止信号,结束数据读取
    I2C_Stop();
}

到这里的话,关于M24C02的代码也就完成了。


3.3.3 main中测试

各个功能代码都写完了,接下来直接进入main.c中进行测试,该引入的头文件要引入,因文章篇幅有限,这里不在赘述。

本次主要按照需求将一些功能进行测试一下:

1、读写一个字节的数据并发送到串口打印

2、读写多个字节数据并到串口输出打印

3、测试写入超过页的范围的情况是否符合手册所述

要注意的是,我们这个工程是经过前面printf重定向工程进行改编 的,所以关于串口输出打印的功能代码并没有直接展示,大家如果不清楚的可以参考下面文章中展示的寄存器实现代码STM32调试手段:重定向printf串口_printf 重定义-CSDN博客https://blog.csdn.net/2301_79475128/article/details/145305160?spm=1001.2014.3001.5501

参考代码如下

复制代码
#include "usart.h"
#include "m24c02.h"
#include <string.h>

int main(void)
{
	// 1. 初始化
	USART_Init();
	M24C02_Init();

	printf("software I2C will start...\n");

	// 2. 向m24c02中写入单字符
	M24C02_Writebyte(0x00, 'a');
	M24C02_Writebyte(0x01, 'b');
	M24C02_Writebyte(0x02, 'c');

	// 3. 向m24c02读取数据
	uint8_t byte1 = M24C02_Readbyte(0x00);
	uint8_t byte2 = M24C02_Readbyte(0x01);
	uint8_t byte3 = M24C02_Readbyte(0x02);

	// 4. 串口输出打印
	printf("byte1 = %c\t byte2 = %c\t byte3 = %c\n", byte1, byte2, byte3);

	// 5. 向m24c02写入字符串
	M24C02_Writebytes(0x00, "123456", 6);

	// 6. 向m24c02读取数据
	uint8_t buffer[100] = {0};
	M24C02_Readbytes(0x00, buffer, 6);

	// 7. 串口输出打印
	printf("buffer = %s\n", buffer);

	// 8. 测试页写超过数据范围
	// 缓冲区清零
	memset(buffer, 0, sizeof(buffer));

	M24C02_Writebytes(0x00, "1234567890abcdefghijk", 21);
	M24C02_Readbytes(0x00, buffer, 21);
	printf("test -> buffer = %s\n", buffer);

	// 死循环保持状态
	while(1)
	{		
		
	}
}

测试代码中可能用到了C语言相关的语法和函数,大家不清楚的自行去查阅,这里不再赘述。还需多多自己动手才能有所收获!

然后,编译了在串口助手看看效果吧:

三种测试显然是成功了。

对第三个测试,主要是为了验证手册中关于页写的相关描述

大致意思就是说:对于同一页进行写入的时候,一次最多写入16个字节,一旦超过16个字节,那么剩余的字节将从该页最前面开始继续逐字节覆盖写入。

我们看看第三个的测试现象:我们对某一页写入了1234567890abcdefghijk这21个字节的数据,然后读取后打印到串口助手上显示的仍然时16个字节,其中超过16字节后面的ghijk覆盖从头数的5字节数据,成功验证了手册中所述的结论。


四、总结

(1)本次案例基于STM32寄存器开发方式,用软件成功模拟I2C通讯协议;

(2)并实现了STM32与EEPROM间的I2C通讯,实现了一个字节或多个字节的写入和读取操作;

(3)进一步理解了I2C通讯的底层原理和时序操作过程,熟悉了STM32寄存器开发流程和编码步骤。

最后,欢迎各位在评论区分享自己的问题和思考,共同学习,谢谢!


以上便是本次文章的所有内容,欢迎各位朋友在评论区讨论,本人也是一名初学小白,愿大家共同努力,一起进步吧!

鉴于笔者能力有限,难免出现一些纰漏和不足,望大家在评论区批评指正,谢谢!

相关推荐
智商偏低4 小时前
单片机之helloworld
单片机·嵌入式硬件
青牛科技-Allen5 小时前
GC3910S:一款高性能双通道直流电机驱动芯片
stm32·单片机·嵌入式硬件·机器人·医疗器械·水泵、
森焱森7 小时前
无人机三轴稳定控制(2)____根据目标俯仰角,实现俯仰稳定化控制,计算出升降舵输出
c语言·单片机·算法·架构·无人机
白鱼不小白7 小时前
stm32 USART串口协议与外设(程序)——江协教程踩坑经验分享
stm32·单片机·嵌入式硬件
S,D8 小时前
MCU引脚的漏电流、灌电流、拉电流区别是什么
驱动开发·stm32·单片机·嵌入式硬件·mcu·物联网·硬件工程
芯岭技术11 小时前
PY32F002A单片机 低成本控制器解决方案,提供多种封装
单片机·嵌入式硬件
youmdt11 小时前
Arduino IDE ESP8266连接0.96寸SSD1306 IIC单色屏显示北京时间
单片机·嵌入式硬件
嘿·嘘12 小时前
第七章 STM32内部FLASH读写
stm32·单片机·嵌入式硬件
Meraki.Zhang12 小时前
【STM32实践篇】:I2C驱动编写
stm32·单片机·iic·驱动·i2c
几个几个n14 小时前
STM32-第二节-GPIO输入(按键,传感器)
单片机·嵌入式硬件