STM32单片机学习(6)

1. 通信 — 模拟 I2C

1.1 关于 I2C

1.1.1 I2C 协议

  • I²C(Inter-Integrated Circuit),常读作“I方C”,它是一种多主从架构串行通信总线。在1980年由飞利浦公司设计,用于让主板、嵌入式系统或手机连接低速周边设备。如今在嵌入式领域是非常常见通信协议,常用于MPU/MCU与外部设备连接通信、数据传输。
    I²C由两条线组成,一条双向串行数据线SDA,一条串行时钟线SCL。每个连接到总线的设备都有一个独立的地址,主机可以通过该地址来访问不同设备。
  • 因为I²C协议比较简单,常常用GPIO来模拟I²C时序,这种方法称为模拟I²C。如果使用MCU的I²C控制器,设置好I²C控制器, I²C控制器就自动实现协议时序,这种方式称为硬件I²C。因为I²C设备的速率比较低,通常两种方式都可以,模拟I²C方便移植,硬件I²C工作效率相对较高。
  • 关于I²C协议,通过下面例子进行一个形象的比喻方便大家理解,如图 1.1.1 所示,老师(MCU)将球(数据)传给众多学生中的一个(众多外设设备中的一个)。
  • 图1.1.1I²C 协议比喻
    在这里插入图片描述
  • 首先老师将球踢给某学生,即主机发送数据给从机,步骤如下:
    1)老师:开始了(start);
    2)老师:A!我要发球给你!(地址/方向);
    3)学生A:到!(回应);
    4)老师把球发出去(传输);
    5)A收到球之后,应该告诉老师一声(回应);
    6)老师:结束(停止);
  • 接着老师让学生把球传给自己,即从机发送数据给主机,步骤如下:
    1)老师:开始了(start);
    2)老师:B!把球发给我!(地址/方向);
    3)学生B:到!
    4)B把球发给老师(传输);
    5)老师收到球之后,给B说一声,表示收到球了(回应);
    6)老师:结束(停止)。
    从上面的例子可知,都是老师(主机)主导传球,按照规范的流程(通信协议),以保证传球的准确性,
    收发球的流程总结如下:
    ① 老师说开始了,表示开始信号(start);
    ② 老师提醒某个学生要发球,表示发送地址和方向(address/read/write);
    ③ 该学生回应老师(ack);
    ④ 老师发球/接球,表示数据的传输;
    ⑤ 收到球要回应:回应信号(ACK);
    ⑥ 老师说结束,表示IIC传输结束(P)。
  • 以上就是I²C的传输协议,如果是软件模拟I²C,需要依次实现每个步骤。因此,还需要知道每一步的具体细节,比如什么时候的数据有效,开始信号怎么表示。
  • 数据有效性
    1.I²C由两条线组成,一条双向串行数据线SDA,一条串行时钟线SCL。SDA线上的数据必须在时钟的高电平周期保持稳定,数据线的高或低电平状态只有在 SCL 线的时钟信号是低电平时才能改变。
    换言之,SCL为高电平时表示有效数据,SDA为高电平表示“1”,低电平表示“0”;SCL为低电平时表示无效数据,此时SDA会进行电平切换,为下次数据表示做准备。数据有效性示意图如图 1.1.2 所示。
  • 图1.1.2数据有效性

在这里插入图片描述

  • 开始信号 和 结束信号
    I²C起始信号(S):当SCL高电平时,SDA由高电平向低电平转换;
    I²C停止信号(P):当SCL高电平时,SDA由低电平向高电平转换;

  • 图 1.1.3 开始信号和结束信号
    在这里插入图片描述

  • 应答信号
    I²C每次传输的8位数据,每次传输后需要从机反馈一个应答位,以确认从机是否正常接收了数据。当主机发送了8位数据后,会再产生一个时钟,此时主机放开SDA的控制,读取SDA电平,在上拉电阻的影响下,此时SDA默认为高,必须从机拉低,以确认收到数据。

  • 图 1.1.4 数据传输格式和应答信号
    在这里插入图片描述

  • 完整传输流程

  • I²C完整传输流程如下:
    ① SDA和SCL开始都为高,然后主机将SDA拉低,表示开始信号;
    ② 在接下来的8个时间周期里,主机控制SDA的高低,发送从机地址。其中第8位如果为0,表示接下来是写操作,即主机传输数据给从机;如果为1,表示接下来是读操作,即从机传输数据给主机;另外,数据传输是从最高位到最低位,因此传输方式为MSB(Most Significant Bit)。
    ③ 总线中对应从机地址的设备,发出应答信号;
    ④ 在接下来的8个时间周期里,如果是写操作,则主机控制SDA的高低;如果是读操作,则从机控制SDA的高低;
    ⑤ 每次传输完成,接收数据的设备,都发出应答信号;
    ⑥ 最后,在SCL为高时,主机由低拉高SDA,表示停止信号,整个传输结束;

  • 图 1.1.5 I2C 传输时序
    在这里插入图片描述

1.1.2 EEPROM 介绍

  • EEPROM的全称是“电可擦除可编程只读存储器”,即Electrically Erasable Programmable Read-OnlyMemory。通常用于存放用户配置信息数据,比如在开发板首次运行时,需要屏幕校准,校准后的配置信息就可以保存在EEPROM里,开发板断电后配置信息不丢失,下次启动,开发板自动读取EEPROM的校准配置信息,就不需要重新校准。
  • EEPROM和Flash的本质上是一样的,Flash包括MCU内部的Flash和外部扩展的Flash,本开发板就有一个SPI接口的外部Flash(W25Q64),在后面SPI接口再讲解。从功能上,Flash通常存放运行代码,运行过程中不会修改,而EEPROM存放用户数据,可能会反复修改。从结构上,Flash按扇区操作,EEPROM通常按字节操作。两者区别这里不再过多赘述,读者理解EEPROM在嵌入式中扮演的角色即可。
  • 结构组成
    1.EEPROM类型众多,其中比较常见是AT24Cxx系列,从命名上看,AT24Cxx中xx的单位是K Bit,如AT24C08,其存储容量为8K Bit。本开发板上的EEPROM型号为AT24C02,其存储容量为2K Bit,2*1024=2048Bit。
    2.对于AT24C01/02,每页大小为8 Byte,对于AT24C04/08/16,每页大小为16 Byte。如图 19.1.6 所示,AT24C02由32页(Page)组成,每一页由8个字节(Byte)组成,每个Byte由8位(Bit)组成,Bit为最小存储单位,存放1个0或1。
  • 图 1.1.6 AT24C02 结构示意图在这里插入图片描述
  • 设备地址
    I²C设备都会有一个设备地址,不同容量的AT24C02,设备地址定义会有所差异,由芯片数据手册《AT24Cxx.pdf》可知,如图 1.1.7 所示。
  • 图 1.1.7 AT24Cxx 设备地址定义
    在这里插入图片描述
    1.AT24C02的容量为2K,对应上图中的第一行,高四位固定为“1010”,中间三位由A2、A1、A0引脚的电平决定,比如A2~0引脚全接地,则值为“000”,最后的最低位为读写位,0代表写命令,1代表读命令。
    2.A2、A1、A0引脚电平需要由原理图决定,假设全接电源地,则如果需要向AT24C02写数据,则发送地址“1010 0000”,如果需要向AT24C02读数据,则发送地址“1010 0001”。
    3.假设开发板有多个AT24C02挂在同一I²C总线上,通过这个规则,只需设计电路时,让A2、A1、A0引脚电平不同,即可区分两个AT24C02。
    4.对于容量再大一点的AT24Cxx系列,比如AT24C04,器件地址由A2、A1引脚决定,数据空间有P0决定。比如对AT24C04的0-2K空间操作,则P0为0,对2K~4K空间操作,则P0为1。
  • 写AT24Cxx
    1.AT24Cxx支持字节写模式和页写模式。字节写模式是一个地址一个数据的写;页写模式是连续写数据,一个地址多个数据的写,但是页写模式不能自动跨页,如果超出一页长度,超出的数据会覆盖原先写入的数据。
    2.如图 1.1.8 所示,为AT24Cxx字节写模式的时序,在MCU发出开始信号(Start)后,发出8 Bit的设备地址信息(图中读写位为低电平,即写数据),待收到AT24Cxx应答信号后,再发出要写的数据地址,再次等待AT24Cxx应答,最后发出8 Bit数据写数据,待AT24Cxx应答后,发出停止信号(Stop),完成一次单字节写数据。
  • 图 1.1.8 AT24Cxx 字节写模式时序
    在这里插入图片描述
  • AT24C02容量为2K,因此数据地址范围为0x000xFF,即0255,每个数据地址每次写1Byte,即8bit,也就刚好2568=2048Bit。对于1K容量的产品,数据地址范围为0x00~0x7F,最高位不会用到,因此图中数据地址的最高位为“”,意思是对于1K容量的产品,该位无需关心。
  • 图 1.1.9 单字节写模流程图
    在这里插入图片描述
  • 图 1.1.10 为AT24Cxx的页写模式时序,与字节写模式的差异在于,不是只发送1Byte数据,而是任意多个。需要注意,该模式不能跨页写,遇到跨页时,需要重新发送完整的时序。
  • 图 1.1.10 AT24Cxx 页写模式时序在这里插入图片描述
  • 值得一提的是,《AT24Cxx.pdf》里提到每次写完之后,再到下次写之前,需要间隔5ms时间,以确保上次写操作在芯片内部完成,如图 1.1.11 所示。
    图 1.1.11 AT24Cxx 写间隔
    在这里插入图片描述
  • 读AT24Cxx
    1.AT24Cxx支持当前地址读模式、随机地址读模式和顺序读模式。当前地址读模式就是在上一次读/写操作之后的最后位置,继续读出数据,比如上次读/写在地址n,接下来可以直接从n+1处读出数据;随机地址读模式是指定数据地址,然后读出数据;顺序读模式是连续读出多个数据。
    2.在当前地址读模式下,无需发送数据地址,数据地址为上一次读/写操作之后的位置,时序如图 1.1.12所示,注意在结尾,主机接收数据后,无需产生应答信号。
  • 图 1.1.12 AT24Cxx 当前地址读模式
    -

3.在随机地址读模式下,需要先发送设备地址,待读的数据地址,接着再重新发出开始信号,设备地址,读出数据,时序如图 1.1.13 所示。

  • 图 1.1.13 AT24Cxx 随机地址读模式
    在这里插入图片描述
    4.在顺序读模式下,需要先从当前地址读模式或随机地址读模式启动,随后便可连续读多个数据,时序如图 1.1.14 所示
  • 图 1.1.14 AT24Cxx 顺序读模式
    在这里插入图片描述

1.2 硬件设计

  • 如图 1.2.1 为开发板EEPROM部分的原理图,U4为AT24C02芯片,它的A0、A1、A2都接地,因此该设备地址为“1010 000X”,当读该设备时,X为1,写该设备时,X为0。
  • U4的7脚为写保护引脚(Write Protect,WP),当该引脚为高,则禁止写AT24C02,这里直接拉低WP,任何时候都可直接写AT24C02。
  • 此外,I 2 C的两个脚SCL和SDA都进行了上拉处理,从而保证I 2 C总线空闲时,两根线都必须为高电平。
  • 如果没有上拉,在主机发送完数据后,放开SDA,此时SDA的电平状态不确定,可能为高,也可能为低,无法确定是从机拉低给出应答信号。
  • 结合原理图可知,PB6作为了I 2 C1的SCL,PB7作为了I 2 C1的SDA。
  • 图 1.2.1 EEPROM 模块原理图
    在这里插入图片描述

1.3 软件设计

1.3.1 软件设计思路

实验目的:本实验通过GPIO模拟I 2 C总线时序,对EEPROM设备AT24C02进行读写操作。

  1. 引脚初始化:GPIO端口时钟使能、GPIO引脚设置为输入/输出模式(PB6、PB7);
  2. 封装I 2 C每个环节的时序函数:起始信号、响应信号、读写数据、停止信号;
  3. 使用I 2 C协议函数,实现对AT24C02的读写;
  4. 主函数,每按一次按键,写一次AT24C02,接着读出来验证是否和写的数据一致;

1.3.2软件设计讲解

  1. GPIO 选择与接口定义
    首先定义SCL和SDA引脚,引脚的高低电平宏定义,如代码段 1.3.1 所示。

代码段 1.3.1 模拟 I2C 引脚相关定义(driver_i2c.h)

/************************* I2C 硬件相关定义 *************************/
#define ACK (0)
#define NACK (1)

#define SCL_PIN GPIO_PIN_6
#define SCL_PORT GPIOB
#define SCL_PIN_CLK_EN() __HAL_RCC_GPIOB_CLK_ENABLE()

#define SDA_PIN GPIO_PIN_7
#define SDA_PORT GPIOB
#define SDA_PIN_CLK_EN() __HAL_RCC_GPIOB_CLK_ENABLE()

#define SCL_H() HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET)
#define SCL_L() HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_RESET)
#define SCL_INPUT() HAL_GPIO_ReadPin(SCL_PORT, SCL_PIN)

#define SDA_H() HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_SET)
#define SDA_L() HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_RESET)
#define SDA_INPUT() HAL_GPIO_ReadPin(SDA_PORT, SDA_PIN)
  • 接着将两个GPIO引脚初始化,使能引脚时钟,先默认设置为输出模式。SCL引脚为时钟信号,始终为输出模式,SDA引脚为数据引脚,可能输出或者输入,因此还需要编写函数实现输入、输出的切换,如代码段1.3.2 所示。

代码段 1.3.2 I2C 引脚初始化(driver_i2c.c)

/*
* 函数名:void I2C_Init(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:初始化模拟 I2C 的引脚为输出状态且 SCL/SDA 都初始为高电平
*/
void I2C_Init(void)
{
	GPIO_InitTypeDef GPIO_InitStruct = {0};
	
	SCL_PIN_CLK_EN();
	SDA_PIN_CLK_EN();
	
	GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
	GPIO_InitStruct.Pull = GPIO_NOPULL;
	GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
	
	GPIO_InitStruct.Pin = SCL_PIN;
	HAL_GPIO_Init(SCL_PORT, &GPIO_InitStruct);
	
	GPIO_InitStruct.Pin = SDA_PIN;
	HAL_GPIO_Init(SDA_PORT, &GPIO_InitStruct);
	
	SCL_H();
	SDA_H();
}

/*
* 函数名:static void I2C_SDA_OUT(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:配置 SDA 引脚为输出
*/
static void I2C_SDA_OUT(void)
{
	GPIO_InitTypeDef GPIO_InitStruct = {0};
	
	GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
	GPIO_InitStruct.Pull = GPIO_PULLUP;
	GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
	
	GPIO_InitStruct.Pin = SDA_PIN;
	HAL_GPIO_Init(SDA_PORT, &GPIO_InitStruct);
}
/*
* 函数名:static void I2C_SDA_IN(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:配置 SDA 引脚为输入
*/
static void I2C_SDA_IN(void)
{
	GPIO_InitTypeDef GPIO_InitStruct = {0};
	
	GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
	GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
	
	GPIO_InitStruct.Pin = SDA_PIN;
	HAL_GPIO_Init(SDA_PORT, &GPIO_InitStruct);
}
  1. I 2 C
  • 开始信号/ 结束信号
  • 参考前面图 1.1.3 所示的开始信号和结束信号编写程序。对于开始信号,首先将SDA和SCL都拉高,随后SDA拉低,再SCL拉低。对于结束信号,首先拉低SDA,拉高SCL,再拉低SDA,代码如代码段 1.3.3所示。

代码段 1.3.3 I2C 开始信号和结束信号(driver_i2c.c)

/*
* 函数名:void I2C_Start(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:I2C 开始信号
*/
void I2C_Start(void)
{
	I2C_SDA_OUT();
	
	SCL_H();
	I2C_Delay();
	
	SDA_H();
	I2C_Delay();
	
	SDA_L();
	I2C_Delay();
	
	SCL_L();
	I2C_Delay();
}
/*
* 函数名:void I2C_Stop(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:I2C 停止信号
*/
void I2C_Stop(void)
{
	I2C_SDA_OUT();
	
	SDA_L();
	I2C_Delay();
	
	SCL_H();
	I2C_Delay();
	
	SDA_H();
	I2C_Delay();
}
  • 应答信号/ 非应答信号/ 等待应答信号
  • 参考前面图 1.1.4 所示,编译应答信号,如代码段 1.3.4 所示。

代码段 1.3.4 应答/非应答/等待应答信号(driver_i2c.c)

/*
* 函数名:void I2C_ACK(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:I2C 发出应答信号
*/
void I2C_ACK(void)
{
	I2C_SDA_OUT();
	
	SCL_L();
	I2C_Delay();
	
	SDA_L();
	I2C_Delay();
	
	SCL_H();
	I2C_Delay();
	
	SCL_L();
	I2C_Delay();
}
/*
* 函数名:void I2C_NACK(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:I2C 发出非应答信号
*/
void I2C_NACK(void)
{
	I2C_SDA_OUT();
	
	SCL_L();
	I2C_Delay();
	
	SDA_H();
	I2C_Delay();
	
	SCL_H();
	I2C_Delay();
	
	SCL_L();
	I2C_Delay();
}
/*
* 函数名:uint8_t I2C_GetACK(void)
* 输入参数:
* 输出参数:无
* 返回值:1 无应答,0 有应答
* 函数作用:I2C 等待从机的应答信号
*/
uint8_t I2C_GetACK(void)
{
	uint8_t time = 0;
	I2C_SDA_IN();
	
	SCL_L();
	I2C_Delay();
	
	SDA_H();
	I2C_Delay();
	
	SCL_H();
	I2C_Delay();
	
	while(SDA_INPUT())
	{
		time++;
		if(time>250)
		{
		SCL_L();
		return 1;
		}
	}
	SCL_L();
	
	return 0;
}
  1. 8~23行:应答信号,在一个SDA时钟周期里,将SCL拉低;
  2. 32~47行:非应答信号,在一个SDA时钟周期里,将SCL拉高;
  3. 56~82行:等待应答信号,拉高SDA后放开SDA,读取SDA是否被拉低,如果拉低返回0,否则返回1;
  • 发送/ 接收 函数
  • 最后还剩发送/接收函数,如代码段 1.3.5 所示。对于发送函数,控制SDA产生8个时钟周期,每个时钟周期里控制SDA高低电平发送1位数据。对于接收函数,控制SDA产生8个时钟周期,每个时钟周期里读取SDA高低电平接收1位数据。

代码段 1.3.5 发送/接收函数(driver_i2c.c)

/*
* 函数名:void I2C_SendByte(uint8_t data)
* 输入参数:data->发送的数据
* 输出参数:无
* 返回值:无
* 函数作用:I2C 发送一个字节
*/
void I2C_SendByte(uint8_t data)
{
	uint8_t cnt = 0;
	I2C_SDA_OUT();
	
	for(cnt=0; cnt<8; cnt++)
	{
		SCL_L();
		I2C_Delay();
		
		if(data & 0x80)
		{
			SDA_H();
		}
		else
		{
			SDA_L();
		}
		data = data<<1;
		SCL_H();
		I2C_Delay();
	}
	
	SCL_L();
	I2C_Delay();
	I2C_GetACK();
}

/*
* 函数名:uint8_t I2C_ReadByte(uint8_t ack)
* 输入参数:ack->发送的应答标志,1 应答,0 非应答
* 输出参数:无
* 返回值:返回读到的字节
* 函数作用:I2C 读出一个字节
*/
uint8_t I2C_ReadByte(uint8_t ack)
{
	uint8_t cnt;
	uint8_t data = 0xFF;
	
	SCL_L();
	I2C_Delay();
	
	for(cnt=0; cnt<8; cnt++)
	{
	SCL_H(); //SCL 高(读取数据)
	I2C_Delay();
	
	data <<= 1;
	if(SDA_INPUT())
	{
		data |= 0x01; //SDA 高(数据为 1)
	}
	SCL_L();
	I2C_Delay();
	}
	//发送应答信号,为低代表应答,高代表非应答
	if(ack == 0)
	{
		I2C_ACK();
	}
	else
	{
		I2C_NACK();
	}
	return data; //返回数据
}
  1. 14~31行:循环8次,每次循环:
    16行:先拉低SCL;
    19~26行:将输入的数据data与0x08且运算,得到最高位的值,从而控制SDA输出对应的高、低电平;
    27行:将data左移一位,得到次高位;
    29行:拉高SCL,让SDA处于稳定期,从设备即可获取SDA的值;
  2. 35行:等待从设备的应答信号;
  3. 53~65行:循环8次,每次循环:
    55行:先拉高SCL,此时认为从设备控制SDA电平,处于稳定期;
    58行:将data左移1位,以确保收到数据按最高位在前存放;
    59~62行:读取SDA电平,如果为高,保存到data当前最低位,否则data最低位默认为0;
    63行:SCL拉低,此时从设备继续控制SDA电平变化
  4. 66~74行:根据传入的参数,决定是否发送应答信号;
  • 整个I 2 C协议函数中,经常用到“ I2C_Delay()”来实现SCL时钟周期。对于AT24Cxx,由其芯片手册可知,时钟脉冲宽度(Clock Pulse Width)需要大于5us,也就是SCL如果刚变为高电平,需要等待至少5us才能变为低电平,因此定义“ I2C_Delay()”为5us以上即可。
#define I2C_Delay() us_timer_delay(5) // Clock Pulse Width >5us
  • 这里的“us_timer_delay()”可以由定时器提供,也可以使用循环提供,前者精度更高,效果更好。延时函数的两者方式如代码段 1.3.6 所示。

代码段 1.3.6 延时函数的实现(driver_timer.c)

#if 0
/*
* 函数名:void us_timer_delay(uint16_t t)
* 输入参数:t-延时时间 us
* 输出参数:无
* 返回值:无
* 函数作用:定时器实现的延时函数,延时时间为 t us,为了缩短时间,函数体使用寄存器操作,用户可对照手册查看每个寄存器每一位的意义
*/
void us_timer_delay(uint16_t t)
{
	uint16_t counter = 0;
	__HAL_TIM_SET_AUTORELOAD(&htim, t);
	__HAL_TIM_SET_COUNTER(&htim, counter);
	HAL_TIM_Base_Start(&htim);
	while(counter != t)
	{
	counter = __HAL_TIM_GET_COUNTER(&htim);
	}
	HAL_TIM_Base_Stop(&htim);
}
#else
/*
* 函数名:void us_timer_delay(uint16_t t)
* 输入参数:t-延时时间 us
* 输出参数:无
* 返回值:无
* 函数作用:延时粗略实现的延时函数,延时时间为 t us
*/
void us_timer_delay(uint16_t t)
{
	uint16_t counter = 0;
	
	while(t--)
	{
		counter=10;
		
	while(counter--) ;
	}
}
#endif
  1. AT24C02读写 读写 函数
  • 编写好I 2 C协议函数后,参考AT24C02手册编写读写数据函数,如代码段 1.3.7 所示。

代码段 1.3.7 读写 AT24C02 一字节数据(driver_eeprom.c)

/*
* 函数名:uint8_t EEPROM_WriteByte(uint16_t addr, uint8_t data)
* 输入参数:addr -> 写一个字节的 EEPROM 初始地址
* data -> 要写的数据
* 输出参数:无
* 返回值:无
* 函数作用:EEPROM 写一个字节
*/
void EEPROM_WriteByte(uint16_t addr, uint8_t data)
{
	/* 1. Start */
	I2C_Start();
	
	/* 2. Write Device Address */
	I2C_SendByte( EEPROM_DEV_ADDR | EEPROM_WR );
	
	/* 3. Data Address */
	if(EEPROM_WORD_ADDR_SIZE==0x08)
	{
		I2C_SendByte( (uint8_t)(addr & 0x00FF) );
	}
	else
	{
		I2C_SendByte( (uint8_t)(addr>>8) );
		I2C_SendByte( (uint8_t)(addr & 0x00FF) );
	}
	
	/* 4. Write a byte */
	I2C_SendByte(data);
	
	/* 5. Stop */
	I2C_Stop();
}

/*
* 函数名:uint8_t EEPROM_ReadByte(uint16_t addr, uint8_t *pdata)
* 输入参数:addr -> 读一个字节的 EEPROM 初始地址
* data -> 要读的数据指针
* 输出参数:无
* 返回值:无
* 函数作用:EEPROM 读一个字节
*/
void EEPROM_ReadByte(uint16_t addr, uint8_t *pdata)
{
	/* 1. Start */
	I2C_Start();
	
	/* 2. Write Device Address */
	I2C_SendByte( EEPROM_DEV_ADDR | EEPROM_WR );
	
	/* 3. Data Address */
	if(EEPROM_WORD_ADDR_SIZE==0x08)
	{
		I2C_SendByte( (uint8_t)(addr & 0x00FF) );
	}
	else
	{
		I2C_SendByte( (uint8_t)(addr>>8) );
		I2C_SendByte( (uint8_t)(addr & 0x00FF) );
	}
	
	/* 4. Start Again */
	I2C_Start();
	
	/* 5. Write Device Address Read */
	I2C_SendByte( EEPROM_DEV_ADDR | EEPROM_RD );

	/* 6.Read a byte */
	*pdata = I2C_ReadByte(NACK);
	
	/* 7. Stop */
	I2C_Stop();
}
  • 参加前面图 1.2.1 和图 1.1.13 所示的介绍时序,编写AT24C02一字节读写程序。
  1. 9~33行:写AT24C02一字节数据;
    12行:发送I 2 C开始信号;
    15行:发送AT24C02的设备地址,最后一位表示写操作;
    18~26行:根据EEPROM型号,调用不同的数据地址长度设置函数(AT24C01/02为8位,AT24C04/08/16为16位);
    29行:发送数据;
    32行:发送I 2 C停止信号;
  2. 43~73行:读AT24C02一字节数据;
    46行:发送I 2 C开始信号;
    49行:发送AT24C02的设备地址,最后一位表示写操作(接下来要写数据地址);
    52~60行:根据EEPROM型号,调用不同的数据地址长度设置函数(AT24C01/02为8位,AT24C04/08/16为16位);
    63行:再次发送I 2 C开始信号;
    66行:发送AT24C02的设备地址,最后一位表示读操作;
    69行:读取AT24C02数据,且无需ACK;
    72行:发送I 2 C停止信号;
  • 实现了对AT24C02单字节的读写,还需要实现多字节的读写。多字节读写可以通过AT24Cxx的页写模式和顺序读模式,实现多个数据的连续读写。在页写模式时,需要程序上设置,不能跨页写,这里简单处理,直接多次调用前面的单次读写即可,如代码段 1.3.8 所示。

代码段 1.3.8 读写 AT24C02 多字节数据(driver_eeprom.c)

/*
* 函数名:void EEPROM_Write_NBytes(uint16_t addr, uint8_t *pdata, uint16_t sz)
* 输入参数:addr -> 写一个字节的 EEPROM 初始地址
* data -> 要写的数据指针
* sz -> 要写的字节个数
* 输出参数:无
* 返回值:无
* 函数作用:EEPROM 写 N 个字节
*/
void EEPROM_Write_NBytes(uint16_t addr, uint8_t *pdata, uint16_t sz)
{
	uint16_t i = 0;
	
	for(i=0; i<sz; i++)
	{
		EEPROM_WriteByte(addr, pdata[i]);
		addr++;
		HAL_Delay(10); // Write Cycle Time 5ms
	}
}

/*
* 函数名:void EEPROM_Read_NBytes(uint16_t addr, uint8_t *pdata, uint16_t sz)
* 输入参数:addr -> 读一个字节的 EEPROM 初始地址
* data -> 要读的数据指针
* sz -> 要读的字节个数
* 输出参数:无
* 返回值:无
* 函数作用:EEPROM 读 N 个字节
*/
void EEPROM_Read_NBytes(uint16_t addr, uint8_t *pdata, uint16_t sz)
{
	uint16_t i = 0;
	
	for(i=0; i<sz; i++)
	{
		EEPROM_ReadByte(addr, &pdata[i]);
		addr++;
	}
}
  • 需要注意的是,AT24Cxx每次写操作后,有一个写间隔,需要间隔5ms以上,因此在写多个字节时,每次写完都需要延时5ms以上。
  1. 主函数控制逻辑
  • 在主函数里,每按一下按键,调用“EEPROM_Write_Nbytes()”对AT24C02写一串数据,再调用
    “EEPROM_Read_Nbytes()”读出该数据,如代码段 1.3.9 所示。

代码段 1.3.9 主函数控制逻辑(main.c)

	// 初始化 I2C
	I2C_Init();
	
	while(1)
	{
		if(key_flag) // 按键按下
		{
			key_flag = 0;
			
			printf("\n\r");
			printf("Start write and read eeprom.\n\r");
			
			// 读写一串字符,并打印
			EEPROM_Write_NBytes(0, tx_buffer, sizeof(tx_buffer)); // 写数据
			HAL_Delay(1);
			
			EEPROM_Read_NBytes(0, rx_buffer, sizeof(tx_buffer)); // 读数据
			HAL_Delay(1);
			
			printf("EEPROM Write: %s\n\r", tx_buffer);
			printf("EEPROM Read : %s\n\r", rx_buffer);
			
			memset((uint8_t*)rx_buffer, 0, sizeof(rx_buffer)); // 清空接收的数据
		}
	}

2. 通信 — 硬件 I2C

2.1关于I2C

2.1.1 I2C 控制器

  • STM32F103系列的I²C控制器,可作为通信主机或从机,因此有四种工作模式可选择:主机发送模式、主机接收模式、从机发送模式、从机接收模式。
  • 传输速度上,支持标准模式(Standard mode,最高速度100kHz)和快速模式(Fast mode,最高速度400kHz)。
  • 同时,还支持SMBus2.0(System Management Bus,系统管理总线)和PMBus (Power Management Bus,电源管理总线)。
  • I²C控制器结构如图 2.1.1 所示,可以看作四部分组成。
    ①引脚:I²C协议只需要两个引脚(SDA和SCL),SMBA引脚仅用于SMBus模式的Alert引脚,通常不用管。
    ②数据收发:主要涉及到数据寄存器(Data Register,DR)和数据移位寄存器(Data Shift Register, DSR)。当发送数据时,将发送的字节写入DR寄存器,硬件会把DR中的字节搬到DSR中,然后在时钟信号的配合下,把DSR最高位的数据放到数据线SDA上,并对DSR进行移位操作。
    当接收数据时,数据控制器(Data Control)根据时钟信号,把SDA线上的高低电平转换为“1”或“0”的数据,写到DSR的最低位,同时DSR移位操作,当接收完一个字节的8位数据后,把DSR中的数据搬到DR寄存器中。
    ③时钟信号:时钟控制器(Clock Control)用于驱动同步时钟信号线SCL。通过配置时钟控制寄存器(ClockControl Register,CCR),可以调整SCL的频率。
    ④控制逻辑:有两个控制寄存器(Control Register 1,CR1)和(Control Register 2,CR2)用于控制逻辑。通过它们可以触发起始和停止信号,做出ACK响应,配置外设时钟频率,开启DMA和中断的功能。同时控制逻辑的状态会反馈到(Status Register 1,SR1)和(Status Register 2,SR2)两个状态寄存器上,根据它们可以知道当前总线是否被占用,本机是主设备还是从设备,数据是否发送完毕等。
  • 图 2.1.1 I2C 控制器模块图
    在这里插入图片描述

2.1.2 AP3426介绍

  • AP3426芯片集成了光强传感器(ALS:AmbientLight Sensor)、接近传感器(PS: Proximity Sensor)、红外LED(IR LED),最常见的应用就是手机。当我们接听电话时,耳朵靠近手机前置扬声器附近,也就靠近了该传感器,此时距离传感器就告诉CPU可以关闭屏幕显示,以防误触。光强传感器能识别当前环境光,告诉CPU对应调节屏幕亮度,手机部分传感器如图 2.1.2 所示。

  • 图 2.1.2 手机部分传感器
    在这里插入图片描述

  • 图 2.1.3 AP3426 结构框图
    在这里插入图片描述

  • AP3426的结构如图 2.1.3 所示,左边两个光电二极管采集光照的强度,右边一个发光二极管发射940nm的红外光。
    -图 2.1.4 AP3426 光电二极管频谱响应在这里插入图片描述

  • 由图 2.1.4 可知,两个光电二极管的频谱响应,ALS光电二极管对450nm-700nm波长光有响应,PS光电二极管对850nm~1000nm波长的光有响应。

  • 图 2.1.5 光谱图

在这里插入图片描述

  • 由图 2.1.5 可知,450nm~700nm波长的光在可见光范围内,而850nm-1000nm波长的光属于红外线。
  • 在明亮环境中,环境光直接照射在ALS和PS上,当物体遮住AP3426,光电二极管的光照强度则会降低,即可判断物体接遮住。
    在黑暗环境中,AP3426发出红外线照射在靠近物体上,反射到PS光电二极管上,当物体遮住AP3426,PS光电二极管的光照强度则会降低,即可判断物体遮住。
  • 图 2.1.6 AP3426 工作示意图

在这里插入图片描述

2.2 硬件设计

  • 如图 2.2.1 为开发AP3426部分的原理图,U5为AP3426芯片。不同于AT24CXX可以电路设置设备地址,AP3426的设备地址是固定的,由芯片手册可以知为0x1E。
    I2C1的SCL使用的PB6引脚,SDA使用的PB7引脚,此外,AP3426的中断引脚连接的PE5。
    图 2.2.1 AP3426 模块原理图
    在这里插入图片描述

2.3 软件设计

2.3.1 软件设计思路

实验目的:本实验通过使用MCU的硬件I 2 C,获取AP3426的数据。

  1. 初始化I 2 C协议相关参数:设置速度、寻址长度模式等;
  2. 初始化I 2 C硬件相关参数:I 2 C时钟使能、GPIO端口时钟使能、GPIO引脚设置为I 2 C复用;
  3. 使用HAL提供的I 2 C对AP3426读写,封装AP3426初始化函数、数据读取函数;
  4. 主函数编写控制逻辑:按下按键KEY1(KEY_U),读取一次AP3426数据,并将数据通过串口打印;

2.3.2 软件设计讲解

  1. GPIO 选择与接口定义
  • 首先定义使用的哪一个I 2 C、SCL和SDA引脚,如代码段 2.3.1 所示。

代码段 2.3.1 模拟 I2C 引脚相关定义(driver_i2c.h)

/************************* I2C 硬件相关定义 *************************/
#define I2Cx I2C1
#define I2Cx_CLK_EN() __HAL_RCC_I2C1_CLK_ENABLE()

#define I2Cx_ClockSpeed (400000)
#define I2Cx_FORCE_RESET() __HAL_RCC_I2C1_FORCE_RESET()
#define I2Cx_RELEASE_RESET() __HAL_RCC_I2C1_RELEASE_RESET()

#define SCL_PIN GPIO_PIN_6
#define SCL_PORT GPIOB
#define SCL_PIN_CLK_EN() __HAL_RCC_GPIOB_CLK_ENABLE()

#define SDA_PIN GPIO_PIN_7
#define SDA_PORT GPIOB
#define SDA_PIN_CLK_EN() __HAL_RCC_GPIOB_CLK_ENABLE()
/************************* I2C 硬件相关定义结束 *************************/
  1. 初始化I 2 C
  • I2C初始化和前面UART初始化类似,包含两部分:协议部分和硬件部分。

  • 协议部分初始化如代码段 2.3.2 所示。

代码段 2.3.2 I2C 协议初始化(driver_i2c.c)

I2C_HandleTypeDef hi2c;

/*
* 函数名:void I2C_Init(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:初始化 I2C 速率和地址格式
*/
void I2C_Init(void)
{
	hi2c.Instance = I2Cx;
	
	hi2c.Init.ClockSpeed = I2Cx_ClockSpeed; // 设置 SCL 时钟频率(最高 400000)
	hi2c.Init.DutyCycle = I2C_DUTYCYCLE_2; // 设置 I2C 的 SCL 时钟的占空比(都可以)
	hi2c.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; // 设置广播呼叫模式(关闭)
	hi2c.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; // 设置禁止时钟延长模式(关闭)
	hi2c.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; // 设置 I2C 寻址长度模式(通常 7bit)
	hi2c.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; // 是否使用两个 STM32 的设备地址(关闭)
	hi2c.Init.OwnAddress1 = 0x0A; // STM32 的设备地址 1(支持 7bit 或 10bit)
	hi2c.Init.OwnAddress2 = 0; // STM32 的设备地址 2(只支持 7bit)
	
	if(HAL_I2C_Init(&hi2c) != HAL_OK)
	{
		Error_Handler();
	}
}
  1. 14~21行:设置I 2 C协议参数;
    14行:设置I 2 C的传输速率,最高不超过400kHz;
    15行:设置SCL时钟的占空比,即低电平时间比高电平时间,可设置有I2C_DutyCycle_2(2:1)I2C_DutyCycle_16_9(16:9),一般要求不高,任意即可;
    16行:I 2 C作为从机模式时,广播呼叫模式设置,通常用不上,关闭即可;
    17行:I 2 C作为从机模式时,禁止时钟延长,通常用不上,关闭即可;
    18行:设置I 2 C寻址长度模式,需要根据所接设备的地址长度决定,通常为7bit;
    19行:STM32作为从机模式时,支持同时对两个设备地址作出响应,这里作为主机,关闭即可;
    20行:设置STM32的设备地址1,这里作为主机,只要设备地址不与从机一样即可;
    21行:设置STM32的设备地址2,没用到,不需要设置;
  2. 23行:使用“HAL_I2C_Init()”初始化前面的“hi2c”“HAL_I2C_Init()”会调用“HAL_I2C_MspInit()”
    进行硬件相关初始化,“HAL_I2C_MspInit()”的内容需要自己编写,如代码段 2.3.3 所示;

代码段 2.3.3 I2C 硬件初始化(driver_i2c.c)

/*
* 函数名:void HAL_I2C_MspInit(I2C_HandleTypeDef *hi2c)
* 输入参数:hi2c-I2C 句柄
* 输出参数:无
* 返回值:无
* 函数作用:使能 I2C 的时钟,使能引脚时钟,并配置引脚的复用功能
*/
void HAL_I2C_MspInit(I2C_HandleTypeDef *hi2c)
{

	GPIO_InitTypeDef GPIO_InitStruct;
	
	if(hi2c->Instance==I2Cx)
	{
		I2Cx_CLK_EN();
		
		SCL_PIN_CLK_EN();
		SDA_PIN_CLK_EN();
		
		GPIO_InitStruct.Pin = SCL_PIN;
		GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
		GPIO_InitStruct.Pull = GPIO_NOPULL;
		GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
		
		HAL_GPIO_Init(SCL_PORT, &GPIO_InitStruct);
		
		GPIO_InitStruct.Pin = SDA_PIN;
		HAL_GPIO_Init(SDA_PORT, &GPIO_InitStruct);
		
		I2Cx_FORCE_RESET(); // 强制复位
		I2Cx_RELEASE_RESET(); // 释放复位
	}
}
  • I2C硬件初始化的内容比较简单,依旧是先使能时钟,然后设置引脚复用,最后还需要复位下I2C。初始化后,便可使用HAL库提供的I 2 C发送/接收函数,HAL提供三种主机收发函数:
    1.HAL_I2C_Master_Receive()/HAL_I2C_Master_Transmit():I 2 C收发数据,使用超时管理模式;
    2.HAL_I2C_Master_Receive_IT()/HAL_I2C_Master_Transmit_IT():I 2 C收发数据,使用中断模式;
    3.HAL_I2C_Master_Receive_DMA()/HAL_I2C_Master_Transmit_DMA():I 2 C收发数据,使用DMA模式;
  • 这里三种收发函数都可满足需求,这里简单处理,没有使用中断和DMA,因此使用超时管理模式。
  1. 初始化和读写AP3426
  • 由AP3426数据手册,可知AP3426写时序如图 2.3.1 所示,首先发送设备地址,其次是命令代码(寄存器地址),最后是数据内容。根据时序,编写代码如代码段 20.3.4 所示。
    图 2.3.1 AP3426 写时序
    在这里插入图片描述

代码段 2.3.4 AP3426 写时序(driver_ap3426.c)

/*
* 函数名:void AP3426_WriteOneByte(uint8_t reg, uint8_t data)
* 输入参数:reg 待写 AP3426 寄存器地址 data 待写数据
* 输出参数:无
* 返回值:无
* 函数作用:写 AP3426 一字节数据
*/
void AP3426_WriteOneByte(uint8_t reg, uint8_t data)
{
	uint16_t write_data = reg | (data<<8);
	
	if(HAL_I2C_Master_Transmit(&hi2c, (AP3426_ADDR << 1) | AP3426_W , (uint8_t*)&write_data, 2, 300) != HAL_OK)
	{
		Error_Handler();
	}
	while (HAL_I2C_GetState(&hi2c) != HAL_I2C_STATE_READY);
}

1.10行:将寄存器地址和待发送的数据,组成一个数据;
2. 12行:使用“HAL_I2C_Master_Transmit()”发送数据,该函数需要五个参数:
第一个:指定哪一个I 2 C;
第二个:指定设备地址,最后一位为读/写位;
第三个:待传输的数据所指向的指针;
第四个:指定数据大小,前面将寄存器地址和待发送数据组在了一次,因此这里数据长度为2;
第五个:指定超时时间,多长时间没有收到响应信号则表示传输失败;
3. 16行:等待正常传输完成;

  • 由AP3426数据手册,可知AP3426读时序如图 2.3.1 所示,首先发送设备地址,其次是命令代码(寄存器地址),然后重新启动,发送设备地址,最后读取数据内容。根据时序,编写代码如图 2.3.2 所示。
  • 图 2.3.2 AP3426 读时序
    在这里插入图片描述

代码段 20.3.5 AP3426 读时序(driver_ap3426.c)

/*
* 函数名:uint8_t AP3426_ReadOneByte(uint8_t reg)
* 输入参数:reg 待读 AP3426 寄存器地址
* 输出参数:无
* 返回值:读取的 AP3426 数据
* 函数作用:读 AP3426 一字节数据
*/
uint8_t AP3426_ReadOneByte(uint8_t reg)
{
	uint8_t read_data = 0;
	
	if(HAL_I2C_Master_Transmit(&hi2c, (AP3426_ADDR << 1) | AP3426_W , (uint8_t*)&reg, 1, 300) != HAL_OK)
	{
		Error_Handler();
	}
	while (HAL_I2C_GetState(&hi2c) != HAL_I2C_STATE_READY);
	
	if(HAL_I2C_Master_Receive(&hi2c, (AP3426_ADDR << 1) | AP3426_R , (uint8_t*)&read_data, 1, 300) != HAL_OK)
	{
		Error_Handler();
	}
	
	return read_data;
}

1.12行:使用“HAL_I2C_Master_Transmit()”发送寄存器地址;
2.18行:使用“HAL_I2C_Master_Receive ()”读取寄存器数据;

  • AP3426的读写时序,分别与前面AT24Cxx随机读、字节写时序非常类似。对于其它I 2 C设备/模块,也差不多。
  • 有了读写AP3426的函数,便可以操作寄存器,初始化和读取AP3426数据。AP3426的初始化比较简单,流程如下:
    ① 复位:设置System Control寄存器(地址:0x00)为“SW reset”(值:0x04);
    ② 设置工作模式:设置System Control寄存器(地址:0x00)为“ALS and PS+IR functions active”(值:0x03),即IR+PS+ALS三个都激活使用;
    ③ 设置中断(这里没使用中断);
  • 图 2.3.3 AP3426 系统控制寄存器
    在这里插入图片描述
    代码段 2.3.6 AP3426 初始化(driver_ap3426.c)
/*
* 函数名:uint8_t AP3426_Init(void)
* 输入参数:无
* 输出参数:无
* 返回值:0 成功 1 失败
* 函数作用:初始化 AP3426
*/
uint8_t AP3426_Init(void)
{
	uint8_t ret_value = 0;
	AP3426_WriteOneByte(SYS_CONFIG_ADDR, SYS_SW_RESET); // 系统软件复位
	HAL_Delay(50);
	
	AP3426_WriteOneByte(SYS_CONFIG_ADDR, SYS_ALS_PS_IR_ACT); // IR+PS+ALS 三个都激活使用
	HAL_Delay(50);
	ret_value = AP3426_ReadOneByte(SYS_CONFIG_ADDR); // 读取配置寄存器值
	if(ret_value != SYS_ALS_PS_IR_ACT) // 判断是否与设置的一致
	{
		return 1;
	}
	printf("I2C 配置寄存器:0x%x\n\r", SYS_CONFIG_ADDR);
	printf("I2C 配置值为:0x%x\n\r", SYS_ALS_PS_IR_ACT);
	printf("I2C 读取到的配置值:0x%x\n\r", ret_value);
	
	return 0;
}

1.11行:复位AP3426;
2.14行:设置AP3426的IR(环境红外光)、PS(距离感应)、ALS(光照强度)都激活使用;
3.16~20行:读取System Control寄存器的值,以便确认是否设置正确;
4. 21~23行:打印调试信息;

  • 初始化配置完后,就可以读取AP3426寄存器数据,如图 2.3.4 所示,分别是IR、ALS、PS的数据寄存器,每个数据占据相邻两位。以IR为例,分别读取IR_DATA_LOW(0x0A)的8位和IR_DATA_HIGH(0x0B)寄存器的低两位,再合并就得到IR的数据,同理可以得到ALS、PS的数据,如代码段 2.3.7 所示。
  • 图 2.3.4 AP3426 数据寄存器
    在这里插入图片描述

代码段 2.3.7 获取 AP3426 数据(driver_ap3426.c)

/*
* 函数名:void AP3426_Read_IR_Data(uint16_t *pIR)
* 输入参数:无
* 输出参数:IR 数据
* 返回值:无
* 函数作用:读 AP3426 IR(环境红外光)数据
*/
void AP3426_Read_IR_Data(uint16_t *pIR)
{
	uint8_t ir_l = 0, ir_h = 0;
	
	ir_l = AP3426_ReadOneByte(IR_DATA_LOW);
	ir_h = AP3426_ReadOneByte(IR_DATA_HIGH);
	
	if( (ir_l&0x80) == 0x80) // Invalid IR and PS data
	{
		*pIR = 0;
	}
	else // ir_l Bit[1:0] is data, ps_l bits[3:0] ans ps_h bits[5:0] is data
	{
		*pIR = (ir_h<<8) | (ir_l&0x03);
	}
}

/*
* 函数名:void AP3426_Read_PS_Data(uint16_t *pPS)
* 输入参数:无
* 输出参数:PS 数据
* 返回值:无
* 函数作用:读 AP3426 PS(距离)数据
*/
void AP3426_Read_PS_Data(uint16_t *pPS)
{
	uint8_t ps_l = 0, ps_h = 0;
	
	ps_l = AP3426_ReadOneByte(PS_DATA_LOW);
	ps_h = AP3426_ReadOneByte(PS_DATA_HIGH);
	
	if( (ps_l&0x40)==0x40) // Invalid IR and PS data
	{
		*pPS = 0;
	}
	else // ir_l Bit[1:0] is data, ps_l bits[3:0] ans ps_h bits[5:0] is data
	{
		*pPS = ((ps_h&0x1F)<<8) | (ps_l&0x0F);
	}
}

/*
* 函数名:vvoid AP3426_Read_ALS_Data(uint16_t *pALS)
* 输入参数:无
* 输出参数:ALS 数据
* 返回值:无
* 函数作用:读 AP3426 ALS(光照)数据
*/
void AP3426_Read_ALS_Data(uint16_t *pALS)
{
	uint8_t als_l = 0, als_h = 0;
	
	als_l = AP3426_ReadOneByte(ALS_DATA_LOW);
	als_h = AP3426_ReadOneByte(ALS_DATA_HIGH);
	
	*pALS = (als_h<<8) | (als_l);
}
  • 然后将三个读取函数,封装并打印读取结果。
    代码段 2.3.8 AP3426 测试函数(driver_ap3426.c)
/*
* 函数名:void AP3426_ReadDataTest(void)
* 输入参数:无
* 输出参数:无
* 返回值:无
* 函数作用:测试读取 AP3426 所有数据
*/
void AP3426_ReadDataTest(void)
{
	uint16_t ir = 0, ps = 0, als = 0;
	
	AP3426_Read_IR_Data(&ir);
	AP3426_Read_PS_Data(&ps);
	AP3426_Read_ALS_Data(&als);
	
	printf("\n\r");
	printf("AP3426 IR = 0x%x\n\r", ir);
	printf("AP3426 PS = 0x%x\n\r", ps);
	printf("AP3426 ALS = 0x%x\n\r", als);
}
  • 最后还需设置调试串口和用户按键,相关代码这里不在赘述。
  1. 主函数控制逻辑
  • 在主函数里,每按一下按键,调用“AP3426_ReadDataTest()”获取一次AP3426数据,如代码段 2.3.9所示。
    代码段 2.3.9 主函数控制逻辑(main.c)
	// 初始化按键
	KeyInit();
	
	// 在 windows 下字符串\n\r 表示回车
	// 如果工程在编译下面这句中文的时候报错,请在“Option for target”->"C/C++"->"Misc Controls"添加“ --locale=english”
	printf("**********************************************\n\r");
	printf("-->xxx:www.xxx.net\n\r");
	printf("-->硬件 I2C 读取 AP3426 实验\n\r");
	printf("**********************************************\n\r");
	
	// 初始化 I2C
	I2C_Init();
	// 初始化 AP3426
	AP3426_Init();
	
	while(1)
	{
		if(key_flag) // 按键按下
		{
			key_flag = 0;
			AP3426_ReadDataTest(); // 读取 AP3426 数据
		}
	}

3. 通信 — 模拟 SPI

3.1 关于 SPI

3.1.1SPI 协议

  • SPI(Serial Peripheral Interface,串行外设接口)是由摩托罗拉(Motorola)在1980前后提出的一种全双工同步串行通信接口,它用于MCU与各种外围设备以串行方式进行通信以交换信息,通信速度最高可达25MHz以上。

  • SPI接口主要应用在EEPROM、FLASH、实时时钟、网络控制器、OLED显示驱动器、AD转换器,数字信号处理器、数字信号解码器等设备之间。

  • SPI通常由四条线组成,一条主设备输出与从设备输入(Master Output Slave Input,MOSI),一条主设备输入与从设备输出(Master Input Slave Output,MISO),一条时钟信号(Serial Clock,SCLK),一条从设备使能选择(Chip Select,CS)。与I²C类似,协议都比较简单,也可以使用GPIO模拟SPI时序。

  • SPI和I²C对比如表 3.1.1 所示。SPI可以同时发出和接收数据,因此SPI的理论传输速度比I²C更快。SPI通过片选引脚选择从机,一个片选一个从机,因此在多从机结构中,需要占用较多引脚,而I²C通过设备地址选择从机,只要设备地址不冲突,始终只需要两个引脚。

  • 表 3.1.1 SPI 和 I2C 对比
    在这里插入图片描述

  • 物理拓扑结构
    SPI可以一个主机连接单个或多个从机,每个从机都使用一个引脚进行片选,物理连接示意图如图3.1.1 和 图 3.1.2 所示。

  • 图 3.1.1 SPI 一主一从示意图
    在这里插入图片描述
    图 3.1.2 SPI 一主多从示意图
    在这里插入图片描述

  • 数据交换
    在SCK时钟周期的驱动下,MOSI和MISO同时进行,如图 21.1.3 所示,可以看作一个虚拟的环形拓扑结构。

  • 图 3.1.3 虚拟环形拓扑结构
    在这里插入图片描述

  • 主机和从机都有一个移位寄存器,主机移位寄存器数据经过MOSI将数据写入从机的移位寄存器,此时从机移位寄存器的数据也通过MISO传给了主机,实现了两个移位寄存器的数据交换。无论主机还是从机,发送和接收都是同时进行的,如同一个“环”。

  • 如果主机只对从机进行写操作,主机只需忽略接收的从机数据即可。如果主机要读取从机数据,需要主机发送一个空数据来引发从机发送数据。

  • 传输模式
    1.SPI有四种传输模式,如表 3.1.2 所示,主要差别在于CPOL和CPHA的不同。
    2.CPOL(Clock Polarity,时钟极性)表示SCK在空闲时为高电平还是低电平。当CPOL=0,SCK空闲时为低电平,当CPOL=1,SCK空闲时为高电平。
    3.CPHA(Clock Phase,时钟相位)表示SCK在第几个时钟边缘采样数据。当CPHA=0,在SCK第一个边沿采样数据,当CPHA=1,在SCK第二个边沿采样数据。
    表 3.1.2 SPI 传输模式
    SPI 模式 CPOL CPHA 说明
0 0 0 时钟空闲状态为低电平;在时钟第一个边沿(上升沿)采样数据
1 0 1 时钟空闲状态为低电平;在时钟第二个边沿(下降沿)采样数据
2 1 0 时钟空闲状态为高电平;在时钟第一个边沿(下降沿)采样数据
3 1 1 时钟空闲状态为高电平;在时钟第二个边沿(上升沿)采样数据

  • 如图 3.1.4 所示,CPHA=0时,表示在时钟第一个时钟边沿采样数据。当CPOL=1,即空闲时为高电平,从高电平变为低电平,第一个时钟边沿(下降沿)即进行采样。当CPOL=0,即空闲时为低电平,从低电平变为高电平,第一个时钟边沿(上升沿)即进行采样。
    在这里插入图片描述

  • 图 3.1.4 SPI 时序(CPHA=0)如图 3.1.5 所示,CPHA=1时,表示在时钟第二个时钟边沿采样数据。当CPOL=1,即空闲时为高电平,从高电平变为低电平再变为高电平,第二个时钟边沿(上升沿)即进行采样。当CPOL=0,即空闲时为低电平,从低电平变为高电平再变为低电平,第二个时钟边沿(下降沿)即进行采样。

  • 图 3.1.5 SPI 时序(CPHA=1)
    在这里插入图片描述

  • 有了以上基础知识,基本可以想象出如何使用GPIO模拟SPI通信时序。首先主机和从机都选择同一传输模式。然后主机片选拉低,选中从机。接着在时钟的驱动下,MOSI发送数据,同时MISO读取接收数据。最后完成传输,取消片选。

3.1.2 FLASH 介绍

  • 关于Flash,前面EEPROM章节,有过简单介绍。EEPROM和Flash的本质上是一样的,都用于保存数据,Flash包括MCU内部的Flash和外部扩展的Flash,本开发板的W25Q64就是一颗SPI接口的外部Flash。从功能上,Flash通常存放运行代码,运行过程中不会修改,而EEPROM存放用户数据,可能会反复修改。从结构上,Flash按扇区操作,EEPROM通常按字节操作。

  • 结构组成
    1.Flash类型众多,其中比较常见是W25Qxx系列,从命名上看,W25Qxx中xx的单位是M Bit,如W25Q16,其存储容量为16M Bit。本开发板上的Flash型号为W25Q64,其存储容量为64M Bit。
    2.对于W25Q64,每页大小为256 Byte。如图 3.1.6 所示,W25Q64由128块(Block)组成,每块由16扇区(Sector)组成,每个扇区由16页(Page)组成,每一页由256个字节(Byte)组成,每个Byte由8位(Bit)组成,Bit为最小存储单位,存放1个0或1。
    3.Flash有个物理特性:只能写0,不能写1。如果把Flash的每个Bit,都看作一张纸,bit=1表示纸没有内容,bit=0表示纸写入了内容。
    4.当纸为白纸时(bit=1),这时往纸上写东西是可以的,写完后纸的状态变为bit=0。当纸有内容时(bit=0),这时往纸上写东西只能让数据越乱,也就无法正常写数据。此时需要橡皮檫,进行擦除操作,将有内容的纸(bit=0)变为白纸(bit=1),使得以后可以重新写入数据。

  • 因此,对Flash写数据前,通常需要擦除操作。对于W25Q64,数据擦除可以以Sector为单位也可以以Block为单位。数据写入只能按照Page来写入,也就一次最多只能写256个Byte。

  • 图 3.1.6 W25Q64 结构示意图
    在这里插入图片描述

  • 读写 、擦除 操作

  • 通过SPI向W25Q64发送指令,即可操作W25Q64,完整指令表,部分指令如图 3.1.7 所示。第一列是功能说明,后续几列为SPI收/发的数据。

  • 图 3.1.7 W25Q64 指令表(部分)
    在这里插入图片描述

  • 以读数据为例,时序如图 3.1.8 所示。首先拉低CS片选,在8个CLK时钟周期里,DI(从机Data Input,主机MOSI)发出指令0x03,在随后的24个CLK时钟周期里,发送24 Bit的地址,最后8个CLK里,DO(从机Data Output,主机MISO)收到8 Bit数据。

  • 图 3.1.8 W25Q64 读数据时序
    在这里插入图片描述

  • 以页编程(写数据)为例,时序如图 3.1.9 所示。首先拉低CS片选,在8个CLK时钟周期里,DI(从机Data Input,主机MOSI)发出指令0x02,在随后的24个CLK时钟周期里,发送24 Bit的地址,最后256*8个CLK里,发送256 Byte数据。

  • 图 3.1.9 W25Q64 页编程(写数据)时序
    在这里插入图片描述

  • 以扇区擦除为例,时序如图 3.1.10 所示。首先拉低CS片选,在8个CLK时钟周期里,DI (从机Data Input,主机MOSI)发出指令0x20,在随后的24个CLK时钟周期里,发送24 Bit的地址。W25Q64会将所指定扇区的数据全设置为0x01。

  • 图 3.1.10 W25Q64 扇区擦除时序
    在这里插入图片描述

理解了这三个指令的时序,其它指令大同小异,根据芯片手册指令表,可对W25Q64进行其它所需操作。

3.2 硬件设计

  • 如图 3.2.1 为开发板Flash部分的原理图。U6为W25Q64芯片,1脚CS为片选引脚,拉低有效,6脚为SPI时钟CLK,2脚、3脚、4脚、5脚在不同的SPI工作方式下,具体不同的功能。

  • 图 3.2.1 W25Q64 模块原理图
    在这里插入图片描述

  • W25Q64支持三种SPI工作方式:Standard SPI、Dual SPI、Quad SPI。Standard SPI即标准SPI,在数据传输时,DI/DO分别负责收发,此时为全双工状态;Dual SPI即双线SPI,对于Flash外设,全双工效率反而不高,因此扩展了SPI用法,让其工作在半双工模式,DI/DO作为双向IO,加倍数据传输;Quad SPI即四线SPI,类似双线SPI的工作模式,此时再加两个IO,最高同时四个IO传输数据,再次加倍数据传输。

  • 在标准SPI工作方式,DO用于输出数据,3脚为写保护引脚(Write Protect,WP),拉低则禁止修改W25Q64的寄存器,7脚为设备暂停引脚(HOLD),拉低暂停W25Q64的数据传输;在双线SPI工作方式,DO和DI都用于输出数据,WP和HOLD与标准SPI功能一致;在四线SPI工作方式,DO、DI、WP、HOLD都用于输出数据。不同工作方式下,引脚功能如表 3.2.1 所示。

  • 表 3.2.1 不同 SPI 工作模式下引脚功能
    SPI 方式 2 脚 3 脚 4 脚 5 脚 说明
Standard SPI DO /HOLD /WP DI 2脚输出数据
Dual SPI IO1 /HOLD /WP IO0 2脚、5脚输出数据
Quad SPI IO1 IO2 IO3 IO0 2脚、3脚、4脚、5脚输出数据

  • 如图 3.2.1 所示,使用不同的指令对W25Q64进行读,会有不同的效果。①为标准SPI读,8 Bits数据需要8个时钟周期;②为双线SPI读,8 Bits数据只需4个时钟周期;③为四线SPI读,8 Bits数据只需要2个时间周期。

  • 图 3.2.2 W25Q64 支持的三种 SPI 方式
    在这里插入图片描述
    由原理图可知,SPI的四个引脚分别为PA5(SCK)、PA6(MISO)、PA7(MOSI)、PA4(CS0)。

3.3 软件设计

3.3.1软件设计思路

实验目的:本实验通过GPIO模拟SPI总线时序,对Flash设备W25Q64进行读写操作。

  1. 引脚初始化:GPIO端口时钟使能、GPIO引脚设置为输入/输出模式(PA4、PA5、PA6、PA7);
  2. 封装SPI的读写时序函数;
  3. 使用SPI协议函数,实现对W25Q64的初始化、擦除、读写;
  4. 主函数,每按一次按键,写一次W25Q64,接着读出来验证是否和写的数据一致;

3.3.2 软件设计讲解

  1. GPIO 选择与接口定义
  • 首先定义SPI传输涉及的四个引脚PA4、PA5、PA6、PA7,其中MISO(PA6)为输入引脚,其它全为输出引脚,如代码段3.3.1 所示。

代码段 3.3.1 SPI 硬件相关宏定义(driver_spi.h)

/************************* SPI 硬件相关定义 *************************/
#define SPIx SPI1
#define SPIx_CLK_ENABLE() __HAL_RCC_SPI1_CLK_ENABLE()
#define SPIx_SCK_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
#define SPIx_MISO_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
#define SPIx_MOSI_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
#define W25_CS_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()

#define SPIx_FORCE_RESET() __HAL_RCC_SPI1_FORCE_RESET()
#define SPIx_RELEASE_RESET() __HAL_RCC_SPI1_RELEASE_RESET()

#define SPIx_SCK_PIN GPIO_PIN_5
#define SPIx_SCK_GPIO_PORT GPIOA

#define SPIx_MISO_PIN GPIO_PIN_6
#define SPIx_MISO_GPIO_PORT GPIOA

#define SPIx_MOSI_PIN GPIO_PIN_7
#define SPIx_MOSI_GPIO_PORT GPIOA

#define W25_CS_PIN GPIO_PIN_4
#define W25_CS_GPIO_PORT GPIOA

#define SPI_CLK(level) HAL_GPIO_WritePin(SPIx_SCK_GPIO_PORT, SPIx_SCK_PIN, level?GPIO_PIN_SET:GPIO_P
IN_RESET)
#define SPI_MISO() HAL_GPIO_ReadPin(SPIx_MISO_GPIO_PORT, SPIx_MISO_PIN)
#define SPI_MOSI(level) HAL_GPIO_WritePin(SPIx_MOSI_GPIO_PORT, SPIx_MOSI_PIN, level?GPIO_PIN_SET:GPIO
_PIN_RESET)
#define W25_CS(level) HAL_GPIO_WritePin(W25_CS_GPIO_PORT, W25_CS_PIN, level?GPIO_PIN_SET:GPIO_PIN_R
ESET)
/************************* SPI 硬件相关定义结束 *************************/
  • 随后将四个GPIO引脚初始化,使能引脚时钟,设置输入/输出模式。SCK、MOSI、CS引脚,始终为输出模式,MISO引脚为数据输入引脚,始终为输入模式,如代码段所示。

代码段 3.3.2 SPI 硬件初始化(driver_spi.c)

/*
* 函数名:void SPI_Init(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:初始化 SPI 的四根引脚
*/
void SPI_Init(void)
{
	GPIO_InitTypeDef GPIO_InitStruct;
	
	SPIx_SCK_GPIO_CLK_ENABLE();
	SPIx_MISO_GPIO_CLK_ENABLE();
	SPIx_MOSI_GPIO_CLK_ENABLE();
	W25_CS_GPIO_CLK_ENABLE();
	
	GPIO_InitStruct.Pin = SPIx_SCK_PIN | W25_CS_PIN | SPIx_MOSI_PIN;
	GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
	GPIO_InitStruct.Pull = GPIO_PULLUP;
	GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
	HAL_GPIO_Init(SPIx_SCK_GPIO_PORT, &GPIO_InitStruct); // SCK CS MOSI 为输出

	GPIO_InitStruct.Pin = SPIx_MISO_PIN;
	GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
	GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
	HAL_GPIO_Init(SPIx_MISO_GPIO_PORT, &GPIO_InitStruct); // MISO 为输入
	
	W25_CS(1); // CS 初始化高
	SPI_CLK(0); // CLK 初始化低
}
  1. SPI 读写函数
  • W25Q64芯片手册提到只支持表 3.1.2 中的SPI模式0和3,这里假设SPI主机工作在模式0,参考前面的SPI时序,SPI读/写一字节数据如代码段 3.3.3 所示。

代码段 3.3.3 模拟 SPI 读/写一字节数据(driver_spi.c)

/*
* 函数名:void SPI_WriteByte(uint8_t data)
* 输入参数:data -> 要写的数据
* 输出参数:无
* 返回值:无
* 函数作用:模拟 SPI 写一个字节
*/
void SPI_WriteByte(uint8_t data)
{
	uint8_t i = 0;
	uint8_t temp = 0;
	
	for(i=0; i<8; i++)
	{
		temp = ((data&0x80)==0x80)? 1:0;
		data = data<<1;
		
		SPI_CLK(0); //CPOL=0
		SPI_MOSI(temp);
		SPI_Delay();
		SPI_CLK(1); //CPHA=0
		SPI_Delay();
	}
	SPI_CLK(0);
}

/*
* 函数名:uint8_t SPI_ReadByte(void)
* 输入参数:
* 输出参数:无
* 返回值:读到的数据
* 函数作用:模拟 SPI 读一个字节
*/
uint8_t SPI_ReadByte(void)
{
	uint8_t i = 0;
	uint8_t read_data = 0xFF;
	
	for(i=0; i<8; i++)
	{
		read_data = read_data << 1;
		
		SPI_CLK(0);
		SPI_Delay();
		SPI_CLK(1);
		SPI_Delay();
		if(SPI_MISO()==1)
		{
			read_data = read_data + 1;
		}
	}
	SPI_CLK(0);
	return read_data;
}
  1. 13~23行:SPI写1 Byte,循环8次,每次发送1 Bit;
    15行:将data最高位保存到temp;
    16行:data左移一位,将次高位变为最高位,用于下次取最高位;
    18行:拉低时钟,即空闲时钟为低电平,CPOL=0;
    19行:根据temp值,设置MOSI引脚的电平;
    20行:简单延时,可以定时器或延时函数实现;
    21行:拉高时钟,W25Q64只支持SPI模式0或1,即会在时钟上升沿采样MOSI数据;
  2. 24行:最后SPI发送完后,拉低时钟,进入空闲状态;
    3.39~51行:SPI读1 Byte,循环8次,每次接收1 Bit;
    40行:“腾空”read_data最低位,8次循环后,read_data将高位在前;
    41行:拉低时钟,即空闲时钟为低电平;
    44~49行:拉高时钟,此时从设备会发送数据,主机稍微延时一会,再读取MISO电平,得知传入数据;
  3. 52行:最后SPI读取完后,拉低时钟,进入空闲状态;
  • 前面提到SPI传输可以看作一个虚拟的环形拓扑结构,即输入和输出同时进行。在前面“SPI_WriteByte()”函数里,发送了1 Byte,也应该接收1 Byte,只是代码中忽略了接收引脚MISO的状态;在前面“SPI_ReadByte()”函数里,接收了1 Byte,也应该发送1 Byte,只是代码中忽略了发送引脚MOSI的内容。有些场景,SPI需要同时读写,因此还需要编写SPI同时读写函数,如代码段 3.3.4 所示。

代码段 3.3.4 模拟 SPI 读写一字节数据(driver_spi.c)

/*
* 函数名:uint8_t SPI_WriteReadByte(uint8_t data)
* 输入参数:data -> 要写的一个字节数据
* 输出参数:无
* 返回值:读到的数据
* 函数作用:模拟 SPI 读写一个字节
*/
uint8_t SPI_WriteReadByte(uint8_t data)
{
	uint8_t i = 0;
	uint8_t temp = 0;
	uint8_t read_data = 0xFF;
	
	for(i=0;i<8;i++)
	{
		temp = ((data&0x80)==0x80)? 1:0;
		data = data<<1;
		read_data = read_data<<1;
		
		SPI_CLK(0);
		SPI_MOSI(temp);
		SPI_Delay();
		SPI_CLK(1);
		SPI_Delay();
		if(SPI_MISO()==1)
		{
			read_data = read_data + 1;
		}
	}
	SPI_CLK(0);
	return read_data;
}
  1. 14~31行:SPI读和写1 Byte,循环8次,每次发送和接收1 Bit;
    16行:将data最高位保存到temp;
    17行:data左移一位,将次高位变为最高位,用于下次取最高位;
    18行:“腾空”read_data最低位,8次循环后,read_data将高位在前;
    20行:拉低时钟,即空闲时钟为低电平;
    21行:根据temp值,设置MOSI引脚的电平;
    23行:拉高时钟,此时从设备会读取MOSI的数据,并写数据到MISO;
    25~28行:读取MISO上的数据,保存到当前read_data最低位;
  2. 32行:最后SPI读写完后,拉低时钟,进入空闲状态;
  1. W25Q64操作 操作 函数
  • 前面介绍提到W25Q64有很多指令,擦除就包含四种: 扇区擦除4KB(0x20)、块擦除32KB(0x52)、块擦除64KB(0xD8)、整片擦除(0xC7/0x60)。这里每种操作只讲解一种方式,其它方式读者可以参考示例代码和芯片手册。
  • 获取设备ID
    1.W25Q64主要有三个ID:制造商编号ID(Manufacturer)、设备ID(Device ID)、唯一标识ID(UniqueID)。
    2.制造商编号ID表示制造商名字,Winbond生产的,该值MF[7:0]为0xEF。
    3.设备ID包含两部分:ID[7:0]表示存储容量,W25Q64系列都为0x16;ID[15:0]表示芯片类型,为0x4017或0x7017。
    4.唯一标识ID表示该Flash芯片唯一性,常用于加密。
  • 代码段 3.3.5 W25Q64 ID 信息
    在这里插入图片描述
    在这里插入图片描述
  • 这里使用0xAB指令获取设备ID[7:0],使用0x9F指令获取JEDEC ID(MF[7:0]+ ID[15:0]),如代码段 3.3.6所示。

代码段 3.3.6 获取 W25Q64 ID(driver_w25qxx.c)

// 函数重定义
#define W25_CS_ENABLE() {W25_CS(0); us_timer_delay(10);}
#define W25_CS_DISABLE() {W25_CS(1); us_timer_delay(10);}
#define W25_RW_Byte(data) SPI_WriteReadByte(data)

/*
* 函数名:uint32_t FLASH_ReadDeviceID(void)
* 输入参数:
* 输出参数:无
* 返回值:读到外部 FLASH 的设备 ID
* 函数作用:读外部 FLASH 的设备 ID
*/
uint32_t FLASH_ReadDeviceID(void)
{
	uint32_t temp[4];
	
	W25_CS_ENABLE();
	
	W25_RW_Byte(W25X_DeviceID);
	temp[0] = W25_RW_Byte(Dummy_Byte);
	temp[1] = W25_RW_Byte(Dummy_Byte);
	temp[2] = W25_RW_Byte(Dummy_Byte);
	temp[3] = W25_RW_Byte(Dummy_Byte); //deviceID
	
	W25_CS_DISABLE();
	
	return temp[3];
}

/*
* 函数名:uint32_t Flash_ReadFlashID(void)
* 输入参数:
* 输出参数:无
* 返回值:读到外部 FLASH 的芯片 ID
* 函数作用:读外部 FLASH 的芯片 ID
*/
uint32_t Flash_ReadFlashID(void)
{
	uint32_t temp[4];
	
	W25_CS_ENABLE();
	
	W25_RW_Byte(W25X_JedecDeviceID);
	temp[0] = W25_RW_Byte(Dummy_Byte);
	temp[1] = W25_RW_Byte(Dummy_Byte);
	temp[2] = W25_RW_Byte(Dummy_Byte);
	
	W25_CS_DISABLE();
	
	temp[3] = (temp[0] << 16) | (temp[1] << 8) | temp[2];
	
	return temp[3];
}

17行:片选W25Q64;
19行:向W25Q64发送W25X_DeviceID(0xAB)指令;
20~23行:连续获取4 Byte数据,其中前3 Byte都为填充数据,第4 Byte为设备ID;
25行:释放WA25Q64片选;
43行:向W25Q64发送W25X_JedecDeviceID(0x9F)指令;
20~23行:连续获取3 Byte数据,第1 Byte为制造商ID,后2 Byte为设备ID;

  • 扇区擦除
  • 参考芯片手册,得知先发送0x20指令,再发送24位的扇区地址,即可对该扇区进行擦除,如代码段3.3.7所示。

代码段 3.3.7 W25Q64 扇区擦除(driver_w25qxx.c)

/*
* 函数名:void FLASH_SectorErase(uint32_t SectorAddr)
* 输入参数:SectorAddr -> 要擦的扇区地址
* 输出参数:无
* 返回值:无
* 函数作用:扇区擦除
*/
void FLASH_SectorErase(uint32_t SectorAddr)
{
	Flash_WritenEN();
	FLASH_WaitForWriteEnd();
	
	W25_CS_ENABLE();
	W25_RW_Byte(W25X_SectorErase);
	W25_RW_Byte((SectorAddr & 0xFF0000) >> 16);
	W25_RW_Byte((SectorAddr & 0xFF00) >> 8);
	W25_RW_Byte(SectorAddr & 0xFF);
	W25_CS_DISABLE();
	
	FLASH_WaitForWriteEnd();
}
  • 这里有一个等待Flash写完成函数“FLASH_WaitForWriteEnd()”,其内容如代码段 3.3.8 所示。
    W25Q64有三个状态寄存器,其中状态寄存器1的bit[0]为擦除/写入状态标志位,如图3.3.1 所示。如果该位为1,表示正在擦除或写数据,如果该位为0,表示可以对W25Q64进行擦除/写数据操作。
  • 图3.3.1 W25Q64 状态寄存器 1
    在这里插入图片描述

代码段 3.3.8 W25Q64 等待写完成(driver_w25qxx.c)

/*
* 函数名:static void FLASH_WaitForWriteEnd(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:等待写完成
*/
static void FLASH_WaitForWriteEnd(void)
{
	uint8_t flash_status = 0;
	
	W25_CS_ENABLE();
	W25_RW_Byte(W25X_ReadStatusReg);
	
	do
	{
		flash_status = W25_RW_Byte(Dummy_Byte);
	}
	
	while ((flash_status & WIP_Flag) == SET);
	W25_CS_DISABLE();
}

13行:发送指令W25X_ReadStatusReg(0x05),获取状态寄存器1数据;
15~19行:循环获取状态寄存器1的值,直到bit[0]的值不为1,跳出循环;

  • 页写W25Q64
  • W25Q64只能按页写数据,每页大小为256字节,因此一次最多写256字节,如代码段3.3.9 所示。

代码段3.3.9 页写 W25Q64(driver_w25qxx.c)

/*
* 函数名:void FLASH_PageWrite(uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite)
* 输入参数:pBuffer -> 要写的数据指针; WriteAddr -> 要写的 FLASH 初始地址; NumByteToWrite -> 要写的字节个数
* 输出参数:无
* 返回值:无
* 函数作用:页写
*/
void FLASH_PageWrite(uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite)
{
	Flash_WritenEN();
	
	W25_CS_ENABLE();
	W25_RW_Byte(W25X_PageProgram);
	W25_RW_Byte((WriteAddr & 0xFF0000) >> 16);
	W25_RW_Byte((WriteAddr & 0xFF00) >> 8);
	W25_RW_Byte(WriteAddr & 0xFF);
	
	if(NumByteToWrite > SPI_FLASH_PerWritePageSize)
	{
		NumByteToWrite = SPI_FLASH_PerWritePageSize;
	}
	
	while (NumByteToWrite--)
	{
		W25_RW_Byte(*pBuffer);
		pBuffer++;
	}
	
	W25_CS_DISABLE();
	
	FLASH_WaitForWriteEnd();
}

10行:写数据前,需要先发送指令W25X_WriteEnable(0x06),设置写使能;
12行:片选W25Q64;
13行:发送页写指令W25X_PageProgram(0x02);
14~16行:发送24位地址;
18~21行:判断发送数据不超过256字节;
23~27行:依次发送数据;
29行:取消此次片选;
31行:等待页写完成;

  • 读W25Q64
  • 读取W25Q64没有什么限制,发送指令、地址后,正常读取即可,如代码段3.3.10 所示。

代码段3.3.10 读 W25Q64(driver_w25qxx.c)

/*
* 函数名:void FLASH_BufferRead(uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite))
* 输入参数:pBuffer -> 要读的数据指针; WriteAddr -> 要读的 FLASH 初始地址; NumByteToWrite -> 要读的字节个数
* 输出参数:无
* 返回值:无
* 函数作用:读 N 个字节出来
*/
void FLASH_BufferRead(uint8_t* pBuffer, uint32_t ReadAddr, uint16_t NumByteToRead)
{
	W25_CS_ENABLE();
	
	W25_RW_Byte(W25X_ReadData);
	
	W25_RW_Byte((ReadAddr & 0xFF0000) >> 16);
	
	W25_RW_Byte((ReadAddr& 0xFF00) >> 8);
	
	W25_RW_Byte(ReadAddr & 0xFF);
	
	while (NumByteToRead--)
	{
		*pBuffer = W25_RW_Byte(Dummy_Byte);
		pBuffer++;
	}
	
	W25_CS_DISABLE();
}

10行:片选W25Q64;
12行:发送页写指令W25X_ReadData(0x03);
14~18行:发送24位地址;
20~24行:循环读取指定长度的数据;
26行:取消此次片选;

  1. 主函数控制逻辑
  • 在主函数里,首先读取设备ID,以验证是否通信正常。随后每按一下按键KEY_1(UP),调用
    “FLASH_SectorErase ()”擦除数据,然后调用“FLASH_PageWrite ()”写数据,最后调用“FLASH_BufferRead()”读出数据,如代码段 3.3.11 所示。

代码段 3.3.11 主函数控制逻辑(main.c)

// 初始化 SPI
SPI_Init();

// 读取外部 FLASH 的 ID
HAL_Delay(400);
device_id = FLASH_ReadDeviceID();
printf("-->读取的外部设备 ID: 0x%x\n\r", device_id);
flash_id = Flash_ReadFlashID();
printf("-->读取外部 FLASH ID: 0x%x\n\r", flash_id);
printf("-->实际外部 FLASH ID: 0x%x\n\r", FLASH_ID);
printf("\n\r");

if(flash_id != FLASH_ID)
{
	printf("设备错误!\n\r");
	Error_Handler();
}

while(1)
{
	if(rw_flag)
	{
		rw_flag = 0;
		
		FLASH_SectorErase(FLASH_SECTOR_TO_ERASE);
		
		printf("写入的数据:%s\n\r", tx_buffer);
		FLASH_PageWrite(tx_buffer, FLASH_WRITEADDR, 256);
		
		FLASH_BufferRead(rx_buffer, FLASH_READADDR, 256);
		printf("读出的数据:%s\n\r", rx_buffer);
	}
}

4. 通信 — 硬件 SPI

4.1 关于 SPI

4.1.1 SPI 控制器

  • STM32F103系列的SPI控制器,可作为通信主机或从机,支持完整的四种传输模式,支持8位或16位数据长度。每个SPI控制器都有一个片选引脚NSS,使用该引脚作为片选时,称为硬件片选模式,也可以使用任意GPIO作为片选引脚,称之为软件片选模式。
  • SPI控制器结构如图 4.1.1 所示,可以看作四部分组成。
    ①引脚:SPI协议需要四个个引脚:MOSI、MISO、SCK、NSS。其中NSS可以使用任意GPIO代替,通过软件控制电平实现手动片选。
    ②数据收发:SPI的传输可以看成一个环形拓扑结构。发送数据时,总线上的数据写入发送缓冲区,由发送缓冲区进入移位寄存器,最后从MOSI输出。同时,MISO的数据也进入移位寄存器,传入接收缓冲区,最后达到总线。
    ③时钟信号:SPI的时钟来源于PCLK,通过配置SPI_CR1寄存器的BR[2:0]实现对PCLK进行分频,最高为f PCLK /2。
    ④主控制逻辑:有两个控制寄存器(Control Register 1,CR1)和(Control Register 2,CR2)用于控制逻辑。通过它们可以修改SPI的工作模式(CPOL、CPHA),配置外设时钟频率,DMA使能等。同时控制逻辑的状态会反馈到(Status Register ,SR)这个状态寄存器上,得知SPI工作状态。
  • 图 4.1.1 SPI 控制器模块图
    在这里插入图片描述

4.1.2ICM- 20608- G 介绍

  • ICM-20608-G是InvenSense面向大众市场的一款六轴姿态传感器,包含三轴加速度传感器、三轴角速度传感器和温度传感器。三轴加速度传感器(Accelerometer、G-Sensor)也叫重力感应器,可以感知任意方向上的加速度(重力加速度则只是地表垂直方向加速度),加速度传感器主要用于测量设备的受力情况,相对外部参考物的运动。三轴角速度传感器(Gyroscope、GYRO-Sensor)也叫陀螺仪,通过测量三维坐标系内陀螺转子的垂直轴与设备之间的夹角,并计算角速度,角速度传感器主要用于测量设备的自身的旋转运动,
  • 擅长感知自身运动。通过六轴数据,基本可以计算出物体的运动状态,常用手机、手环、无人机、云台等领域。
  • ICM-20608-G的内部框图如图 4.1.2 所示,三轴加速度传感器①、三轴角速度传感器②、温度传感器③经过ADC转换保存到内部寄存器④,最后通过I 2 C或SPI接口⑤,向外提供数据。
  • 图 4.1.2 ICM-20608-G 结构框图
    在这里插入图片描述

4.2 硬件设计

  • 如图 4.2.1 为开发板ICM-20608-G部分的原理图。U7为ICM-20608-G芯片,支持I 2 C或SPI接口,这里使用SPI与其通信时,2脚SCLK为SPI时钟引脚(PA5),3脚SDA为SPI的MOSI接口(PA7),4脚SCL为SPI的MISO接口(PA6),5脚CS为SPI片选引脚(PC4,GPIO模拟片选),7脚INT为中断,接在PC1,本实验没有使用。
  • 图 4.2.1 ICM-20608-G 模块原理图
    在这里插入图片描述

4.3 软件设计

4.3.1软件设计思路

实验目的:本实验通过使用MCU的硬件SPI,获取ICM-20608-G的数据。

  1. 初始化SPI协议相关参数:设置工作模式、时钟极性、片选等;
  2. 初始化SPI硬件相关参数:SPI时钟使能、GPIO端口时钟使能、GPIO引脚设置为SPI复用;
  3. 使用HAL提供的SPI对ICM-20608-G读写,封装ICM-20608-G初始化函数、数据读取函数;
  4. 主函数编写控制逻辑:按下按键KEY1(KEY_U),读取一次ICM-20608-G数据,并将数据通过串口打印;

4.3.2 软件设计讲解

  1. GPIO 选择与接口定义
  • 首先定义SPI传输涉及的四个引脚PA5、PA6、PA7、PC4,其中PC4为片选引脚,为普通GPIO,需要自己控制,如代码段 4.3.1 所示。

代码段 4.3.1 SPI 硬件相关宏定义(driver_spi.h)

/************************* SPI 硬件相关定义 *************************/
#define SPIx SPI1
#define SPIx_CLK_ENABLE() __HAL_RCC_SPI1_CLK_ENABLE()
#define SPIx_SCK_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
#define SPIx_MISO_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
#define SPIx_MOSI_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
#define ICM_CS_GPIO_CLK_ENABLE() __HAL_RCC_GPIOC_CLK_ENABLE()

#define SPIx_FORCE_RESET() __HAL_RCC_SPI1_FORCE_RESET()
#define SPIx_RELEASE_RESET() __HAL_RCC_SPI1_RELEASE_RESET()

#define SPIx_SCK_PIN GPIO_PIN_5
#define SPIx_SCK_GPIO_PORT GPIOA

#define SPIx_MISO_PIN GPIO_PIN_6
#define SPIx_MISO_GPIO_PORT GPIOA

#define SPIx_MOSI_PIN GPIO_PIN_7
#define SPIx_MOSI_GPIO_PORT GPIOA

#define ICM_CS_PIN GPIO_PIN_4
#define ICM_CS_GPIO_PORT GPIOC

#define ICM_CS(level) 							{HAL_GPIO_WritePin(ICM_CS_GPIO_PORT, ICM_CS_PIN, level?GPIO_PIN_SET:GPIO_PIN_
RESET);us_timer_delay(10);}
/************************* SPI 硬件相关定义结束 *************************/
  1. 初始化SPI
  • SPI的初始化,依旧包含两部分:协议部分和硬件部分。
    协议部分初始化如代码段4.3.2 所示。

代码段 4 .3.2 SPI 协议初始化(driver_spi.c)

/*
* 定义全局变量
*/
SPI_HandleTypeDef hspi;

/*
* 函数名:void SPI_Init(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:初始化 SPI
*/
void SPI_Init(void)
{
	hspi.Instance = SPIx;
	hspi.Init.Mode = SPI_MODE_MASTER; // 设置 SPI 模式(主机模式)
	hspi.Init.Direction = SPI_DIRECTION_2LINES; // 设置 SPI 工作方式(全双工)
	hspi.Init.DataSize = SPI_DATASIZE_8BIT; // 设置数据格式(8bit 长度)
	hspi.Init.CLKPolarity = SPI_POLARITY_LOW; // 设置时钟极性(CPOL=0)
	hspi.Init.CLKPhase = SPI_PHASE_1EDGE; // 设置时钟相位(CPHA=0)
	hspi.Init.NSS = SPI_NSS_SOFT; // 设置片选方式(软件片选,自定义 GPIO)
	hspi.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16; // 设置 SPI 时钟波特率分频(16 分频,SPI1:72/16=4.5MHz)
	hspi.Init.FirstBit = SPI_FIRSTBIT_MSB; // 设置大小端模式(MSB,高位在前)
	hspi.Init.TIMode = SPI_TIMODE_DISABLE; // 设置帧格式(关闭 TI 模式)
	hspi.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; // 设置 CRC 校验(关闭 CRC 校验)
	hspi.Init.CRCPolynomial = 7; // 设置 CRC 校验多项式(范围:1~65535)
	
	if(HAL_SPI_Init(&hspi) != HAL_OK)
	{
		Error_Handler();
	}
	__HAL_SPI_ENABLE(&hspi);
}
  1. 15~26行:设置SPI协议参数;
    15行:设置哪一个SPI控制器,这里为SPIx对应SPI1;
    16行:设置SPI的工作模式,支持SPI_MODE_MASTER(主模式)和SPI_MODE_SLAVE(从模式);
    17行:设置SPI工作方式,支持SPI_DIRECTION_2LINES(双线全双工,即Standard SPI,MOSI和MISO一发一收)、SPI_DIRECTION_2LINES_RXONLY(双线只接收,即Dual SPI,MOSI和MISO两个都收),SPI_DIRECTION_1LINE(单线只接收);
    18行:设置数据格式,支持SPI_DATASIZE_8BIT(8位,常用)和SPI_DATASIZE_16BIT(16位);
    19行:设置时钟极性,支持SPI_POLARITY_LOW(CPOL=0)和SPI_POLARITY_HIGH(CPOL=1);
    20行:设置时钟相位,支持SPI_PHASE_1EDGE(CPHA=0)和SPI_PHASE_2EDGE(CPHA=1);ICM-20608-G的数据手册要求SPI应该为模式0或3,这里CPOL和CPHA都设置为0,即在模式0模式;
    21行:设置片选方式,支持SPI_NSS_SOFT(使用任一GPIO作为片选,需用户自行控制,可实现一主多从)、SPI_NSS_HARD_OUTPUT(作为主机时,使用硬件NSS引脚作为片选,SPI控制器自动控制,只能一主一从)、SPI_NSS_HARD_INPUT(作为从机时,使用硬件NSS引脚作为片选输入);
    22行:设置SPI时钟波特率分频,支持SPI_BAUDRATEPRESCALER_2/4/8/16/32/64/128/256。前面提到,SP1工作最高频率为f PCLK2 /2=36MHz,SPI2、SPI3工作最高频率为f PCLK1 /2=18MHz,但STM32的《数据手册》SPI章节提到SPI最高为18MHz,也就是追求稳定建议为18MHz,如果使用36MHz,需要加强测试、验证;此外,ICM-20608-G的数据手册要求,SPI最高频率为8MHz,因此这里设置16分频,设置为4.5MHz;
    23行:设置大小端模式,支持SPI_FIRSTBIT_MSB(高位在前)和SPI_FIRSTBIT_LSB(低位在前);
    24行:设置帧格式,通常关闭即可;
  2. 24~26行:设置CRC校验,通常关闭即可;
    3.28行:使用“HAL_SPI_Init()”初始化前面的“hspi”,“HAL_SPI_Init()”会调用“HAL_SPI_MspInit()”
    进行硬件相关初始化,“HAL_SPI_MspInit”的内容需要自己编写,如代码段 4.3.3 所示;

代码段 4.3.3 SPI 硬件初始化(driver_spi.c)

/*
* 函数名:void HAL_SPI_MspInit(SPI_HandleTypeDef *hspi)
* 输入参数:hspi-SPI 句柄
* 输出参数:无
* 返回值:无
* 函数作用:使能 SPI 的时钟,使能引脚时钟,并配置引脚的复用功能
*/

void HAL_SPI_MspInit(SPI_HandleTypeDef *hspi)
{
	GPIO_InitTypeDef GPIO_InitStruct;
	
	if(hspi->Instance==SPIx)
	{
		SPIx_SCK_GPIO_CLK_ENABLE(); // SPI 相关引脚时钟使能
		SPIx_MISO_GPIO_CLK_ENABLE();
		SPIx_MOSI_GPIO_CLK_ENABLE();
		ICM_CS_GPIO_CLK_ENABLE();
		
		SPIx_CLK_ENABLE(); // SPI 时钟使能
		
		GPIO_InitStruct.Pin = SPIx_SCK_PIN | SPIx_MISO_PIN | SPIx_MOSI_PIN;
		GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
		GPIO_InitStruct.Pull = GPIO_NOPULL;
		GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_MEDIUM;
		HAL_GPIO_Init(SPIx_SCK_GPIO_PORT, &GPIO_InitStruct);
		HAL_GPIO_Init(SPIx_MISO_GPIO_PORT, &GPIO_InitStruct);
		HAL_GPIO_Init(SPIx_MOSI_GPIO_PORT, &GPIO_InitStruct); // SPI 相关引脚复用
		
		GPIO_InitStruct.Pin = ICM_CS_PIN;
		GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
		GPIO_InitStruct.Pull = GPIO_PULLUP;
		GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_MEDIUM;
		HAL_GPIO_Init(ICM_CS_GPIO_PORT, &GPIO_InitStruct); // SPI 软件片选引脚输出
		
		ICM_CS(1); // CS 初始化高
	}
}
  • 硬件初始化的内容比较简单,依旧是先使SPI能时钟、相关引脚时钟,然后设置引脚复用。因为使用的是软件片选,这里需要设置该引脚为输出。初始化后,便可使用HAL库提供的SPI发送/接收/同时收发函数,HAL提供三种主机收发函数:
    1.HAL_SPI_Receive()/HAL_SPI_Transmit()/HAL_SPI_TransmitReceive():SPI收发数据,使用超时管理模式;
    2.HAL_SPI_Receive_IT() /HAL_SPI_Transmit_IT()/HAL_SPI_TransmitReceive_IT():SPI收发数据,使用中断模式;
    3.HAL_SPI_Receive_DMA()/HAL_SPI_Transmit_DMA() /HAL_SPI_TransmitReceive_DMA():SPI收发数据,使用DMA模式;
  • 这里三种收发函数都可满足需求,这里简单处理,没有使用中断和DMA,因此使用超时管理模式。最后在封装一下SPI读写函数,方便使用,如代码段 4.3.4 所示。

代码段 4.3.4 SPI 收发函数 driver_spi.c)

/*
* 函数名:void SPI_WriteBytes(uint8_t *pdata, uint16_t sz)
* 输入参数:pdata -> 要写的数据指针; sz->要写的字节个数
* 输出参数:无
* 返回值:无
* 函数作用:SPI 发送一个字节
*/
void SPI_WriteBytes(uint8_t *pdata, uint16_t sz)
{
	HAL_SPI_Transmit(&hspi, pdata, sz, 20);
}
	
	/*
	* 函数名:uint8_t SPI_RWOneByte(uint8_t pdata)
	* 输入参数:pdata -> 要写的数据
	* 输出参数:
	* 返回值:读到的数据
	* 函数作用:SPI 读写一个字节
	*/
	uint8_t SPI_RWOneByte(uint8_t pdata)
	{
	uint8_t temp = 0;
	
	HAL_SPI_TransmitReceive(&hspi, &pdata, &temp, 1, 20);
	
	return temp;
	}
	/*
	* 函数名:void SPI_ReadBytes(uint8_t *pdata, uint16_t sz)
	* 输入参数:pdata -> 要读的数据指针; sz -> 要读的数据个数
	* 输出参数:
	* 返回值:
	* 函数作用:SPI 读 N 个字节
	*/
	void SPI_ReadBytes(uint8_t *pdata, uint16_t sz)
	{
	HAL_SPI_Receive(&hspi, pdata, sz, 20);
	}
  1. 初始化和读写ICM-20608-G
  • 有了基本的SPI读写函数,还需要针对ICM-20608-G再封装一下,由《ICM-20608-G数据手册》可知操作寄存器需要发16位数据,如图 4.4.1 所示。首先发送7位寄存器地址,其中最高位为读写标志,接着便可发送/接收8位数据。由此,读写ICM-20608-G寄存器如代码段 4.3.5 所示。
  • 图 4.3.1 读写 ICM-20608-G 寄存器格式
    在这里插入图片描述

代码段 4.3.5 读写 ICM-20608-G 寄存器(driver_icm20608g.c)

#define Dummy_Byte (0xFF)

#define ICM_CS_ENABLE() ICM_CS(0)
#define ICM_CS_DISABLE() ICM_CS(1)

#define ICM_WriteByte(data) SPI_WriteByte(data)
#define ICM_ReadByte() SPI_ReadByte()
#define ICM_RW_Byte(data) SPI_RWOneByte(data)

/*
* 函数名:uint16_t ICM_RW_Register(uint8_t reg, uint8_t data)
* 输入参数:reg 寄存器地址,data 数据内容
* 输出参数:无
* 返回值:读到的数据
* 函数作用:读写 ICM-20608-G 寄存器数据
*/
uint16_t ICM_RW_Register(uint8_t reg, uint8_t data)
{
	uint16_t ret0 = 0, ret1 = 0;
	uint16_t temp = 0;
	
	ICM_CS_ENABLE(); // 片选使能
	ret0 = ICM_RW_Byte(reg); // 先发送寄存器地址
	ret1 = ICM_RW_Byte(data); // 再发送数据
	ICM_CS_DISABLE(); // 片选失能

	temp = (ret0<<8) | (ret1);
	
	return (temp);
}
  • 这里两次调用“ICM_RW_Byte()”先后发送寄存器地址和数据,也可以通过修改SPI数据格式,将
    SPI_DATASIZE_8BIT(8位)改为SPI_DATASIZE_16BIT(16位),这样SPI一次性发送16位,不用两次调用“ICM_RW_Byte()”
  • ICM-20608-G初始化的内容比较多,依次为复位、选择时钟、获取ID测试、设置六轴详细参数(分辨率、量程、是否使用滤波器等)、使能、关闭低功耗模式和FIFO等。每个寄存器的功能、设置的值含义,读者可以参考《ICM-20608-G寄存器映射》对应理解,这里不再赘述,如代码段 4.3.6 所示。

代码段 4.3.6 ICM-20608-G 初始化(driver_icm20608g.c)

/*
* 函数名:void ICM_Init(void)
* 输入参数:无
* 输出参数:无
* 返回值:无
* 函数作用:ICM-20608-G 初始化
*/
void ICM_Init(void)
{
	uint16_t ret = 0;
	
	// 复位 ICM
	ICM_RW_Register(ICM_PWM_MGMT_1 | ICM_WRITE, 0x80);
	HAL_Delay(50);
	
	// 选择时钟
	ICM_RW_Register(ICM_PWM_MGMT_1 | ICM_WRITE, 0x01);
	HAL_Delay(50);
	// 获取设备 ID
	ret = ICM_RW_Register(ICM_WHO_AM_I | ICM_READ, Dummy_Byte);
	printf("ICM ID: 0x%x\n\r", ret);
	
	ICM_CS_ENABLE();
	// 输出速率:内部采样率
	ICM_RW_Register(ICM_SMPLRT_DIV | ICM_WRITE, 0x00);
	// 陀螺仪 2000dps
	ICM_RW_Register(ICM_GYRO_CONFIG | ICM_WRITE, 0x18);
	// 加速度 16G 量程
	ICM_RW_Register(ICM_ACCEL_CONFIG | ICM_WRITE, 0x18);
	// 陀螺仪低通滤波 20Hz
	ICM_RW_Register(ICM_CONFIG | ICM_WRITE, 0x04);
	// 加速度低通滤波 21.2Hz
	ICM_RW_Register(ICM_ACCEL_CONFIG_2 | ICM_WRITE, 0x04);
	
	// 打开加速度计和陀螺仪所有轴
	ICM_RW_Register(ICM_PWM_MGMT_1 | ICM_WRITE, 0x00);
	
	// 关闭低功耗
	ICM_RW_Register(ICM_LP_MODE_CFG | ICM_WRITE, 0x00);
	
	// 关闭 FIFO
	ICM_RW_Register(ICM_FIFO_EN | ICM_WRITE, 0x00);
}
  • 初始化完后,就可以读取数据,每个数据占两位,读取完后需要高低字节拼接处理。获取到寄存器原始值后,还需要做换算处理,比如温度,手册上写的换算公式为:
  • “Room Temperature Offset”可从手册查到典型值为25℃,“Temp_Sensitivity”典型值为
    326.8LSB/℃。
  • 对于角速度,前面初始化的时候将量程设置了为±2000dps,读出来的原始数据是带符号的16位,则范围为-32767~+32767,+32767对应+2000,即灵敏度为16.4。
  • 对于加速度,前面初始化的时候将量程设置了为±16g,出来的原始数据是带符号的16位,则范围为-32767~+32767,+32767对应+16,即灵敏度为2048。
  • 最后将三轴加速度传感器、三轴角速度传感器、温度传感器的寄存器数据读出,拼接、处理,并打印原始值和处理值,如代码段 4.3.7 所示。

代码段 4.3.7 获取 IMC-20608-G 值并打印(driver_icm20608g.c)

/*
* 函数名:void ICM_ReadGyroAccel(void)
* 输入参数:无
* 输出参数:无
* 返回值:无
* 函数作用:获取 ICM-20608-G 数据并打印
*/
void ICM_ReadGyroAccel(void)
{
	int16_t temp = 0;
	float temp_value = 0.0;
	uint8_t temp_h = 0, temp_l = 0;
	
	int16_t xa = 0, ya = 0, za = 0;
	float xa_act = 0, ya_act = 0, za_act = 0.0;
	uint8_t xa_l = 0, ya_l = 0, za_l = 0;
	uint8_t xa_h = 0, ya_h = 0, za_h = 0;
	
	int16_t xg = 0, yg = 0, zg = 0;
	float xg_act = 0, yg_act = 0, zg_act = 0.0;
	uint8_t xg_l = 0, yg_l = 0, zg_l = 0;
	uint8_t xg_h = 0, yg_h = 0, zg_h = 0;
	
	temp_h = ICM_RW_Register(ICM_TEMP_OUT_H | ICM_READ, Dummy_Byte); // 获取温度寄存器数据
	temp_l = ICM_RW_Register(ICM_TEMP_OUT_L | ICM_READ, Dummy_Byte);
	temp = (temp_h<<8) | temp_l;
	temp_value = (temp - 25)/326.8 + 25; // 将原始数据转换
	
	xa_l = ICM_RW_Register(ICM_ACCEL_XOUT_L | ICM_READ, Dummy_Byte); // 获取三轴加速度寄存器数据
	xa_h = ICM_RW_Register(ICM_ACCEL_XOUT_H | ICM_READ, Dummy_Byte);
	ya_l = ICM_RW_Register(ICM_ACCEL_YOUT_L | ICM_READ, Dummy_Byte);
	ya_h = ICM_RW_Register(ICM_ACCEL_YOUT_H | ICM_READ, Dummy_Byte);
	za_l = ICM_RW_Register(ICM_ACCEL_ZOUT_L | ICM_READ, Dummy_Byte);
	za_h = ICM_RW_Register(ICM_ACCEL_ZOUT_H | ICM_READ, Dummy_Byte);
	xa = xa_l + (xa_h<<8); xa_act = xa / 2048.0; // 将原始数据转换
	ya = ya_l + (ya_h<<8); ya_act = ya / 2048.0;
	za = za_l + (za_h<<8); za_act = za / 2048.0;
	
	xg_l = ICM_RW_Register(ICM_GYRO_XOUT_L | ICM_READ, Dummy_Byte); // 获取三轴角速度寄存器数据
	xg_h = ICM_RW_Register(ICM_GYRO_XOUT_H | ICM_READ, Dummy_Byte);
	yg_l = ICM_RW_Register(ICM_GYRO_YOUT_L | ICM_READ, Dummy_Byte);
	yg_h = ICM_RW_Register(ICM_GYRO_YOUT_H | ICM_READ, Dummy_Byte);
	zg_l = ICM_RW_Register(ICM_GYRO_ZOUT_L | ICM_READ, Dummy_Byte);
	zg_h = ICM_RW_Register(ICM_GYRO_ZOUT_H | ICM_READ, Dummy_Byte);
	xg = xg_l + (xg_h<<8); xg_act = xa / 16.4; // 将原始数据转换
	yg = yg_l + (yg_h<<8); yg_act = ya / 16.4;
	zg = zg_l + (zg_h<<8); zg_act = za / 16.4;
	
	printf("--------------------------------------------\n\r");
	printf("Temperature Original value: 0x%x \n\r", temp);
	printf("Temperature Converted value: %.2f℃\n\r", temp_value);
	printf("\r\n");
	printf("Accelerometer Original value: xa:0x%x ya:0x%x za:0x%x \n\r", xa, ya, za);
	printf("Accelerometer Converted value: xa_act:%.2fg ya_act:%.2fg za_act:%.2fg \n\r", xa_act, ya_act, za_act);
	printf("\r\n");
	printf("Gyroscope Original value: xg:0x%x yg:0x%x zg:0x%x \n\r", xg, yg, zg);
	printf("Gyroscope Converted value: xg_act:%.2fdeg/s yg_act:%.2fdeg/s zg_act:%.2fdeg/s \n\r", xg_act, yg_act, zg_ac
	t);
	printf("---------------------------------------------\n\r");
}
  • 注意读出的数据包含正负,因此数据类型应该为“int16_t”,而不是“uint16_t”
  1. 主函数控制逻辑
  • 在主函数里,每按一下按键,调用“ICM_ReadGyroAccel()”获取一次ICM-20608-G数据,如代码段
    4.3.8 所示。

代码段 4.3.8 主函数控制逻辑(main.c)

	printf("**********************************************\n\r");
	printf("-->xxx www.xxxx.net\n\r");
	printf("-->硬件 SPI 读写六轴传感器 ICM20608G 实验\n\r");
	printf("**********************************************\n\r");
	
	// 初始化 SPI
	SPI_Init();
	// 初始化 ICM
	ICM_Init();
	
	while(1)
	{
		if(rw_flag)
		{
			rw_flag = 0;
			
			ICM_ReadGyroAccel();
		}
	}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值