SPI 通信-stm32入门

本节我们将继续学习下一个通信协议 SPI,SPI 通信和我们刚学完的 I2C 通信差不多。两个协议的设计目的都一样,都是实现主控芯片和各种外挂芯片之间的数据交流,有了数据交流的能力,我们主控芯片就可以挂载并操纵各式各样的外部芯片,来实现一个功能更加强大的控制系统。那本节 SPI 通信的安排和上一节 I2C 的也是一样,我们先学习 SPI 协议的软硬件规定,先用软件模拟的 SPI,实现读写 W25Q64 Flash 存储器;之后,我们再学习 STM32 中的 SPI 外设,再用硬件 SPI 实现同样的功能。

W25Q64 是一个 Flash 存储器芯片,它内部可以存储 8 M 字节的数据,并且是掉电不丢失的。如果你之后的项目中,需要存储大量的数据,就可以考虑一下外挂这个芯片来实现。

那在这里,我们用 4 根 SPI 通信线把 W25Q64 和 STM32 连接在一起,STM32 操作引脚电平,实现 SPI 通信的时序,进而实现读写存储器芯片的目的。

那在 OLED 上,我们可以看到程序测试的现象:第一行,显示的是 ID 号,MID 是厂商 ID,读出来是 0xEF,DID 是设备 ID,读出来是 0x4017,这些 ID 号都是固定的数值,在手册里有写,我们用 SPI 读写 ID 号,就可以进行最简单的测试了,如果读取 ID 号和手册里 一样,说明 SPI 通信基本没问题。之后,既然是存储器芯片,我们肯定就是写几个数据,再读出来,看看对不对了,这里第二行,W,写的内容是 4 个字节:0x01,02,03,04。然后第三行,R,就是读到的内容了,显示出来,可以看到也是 0x01,02,03,04,读出来和写入的一样,这说明读写存储器芯片没问题。当然更进一步的测试,比如读写更多的数据、写入的数据是不是掉电不丢失,这些我们之后写程序的时候再来验证。程序现象就看到这里。

1. SPI 通信简介

1.1 SPI 的基本功能

SPI(Serial Peripheral Interface,串行外设接口)是由Motorola公司开发的一种通用数据总线

和 I2C 一样,它们都是通用的数据总线。同时,它们也都是用于主控和外挂芯片之间的通信,应用领域非常相似。当然,I2C 和 SPI,两者是各有优势和劣势的。在某些芯片呢,我们用 I2C 更好,在另一些芯片呢,我们用 SPI 更好。
上一节我们学习 I2C 的时候,可以发现 I2C,无论是硬件电路,还是软件时序,设计的都是相对比较复杂的。硬件上,我们要配置为开漏外加上拉的模式,软件上,我们有很多功能和要求,比如,一根通信线兼顾数据收发,应答位的收发、寻址机制的设计等等,最终,通过这么多的设计,就使得 I2C 通信的性价比非常高,I2C 可以在消耗最低硬件资源的情况下,实现最多的功能。在硬件上,无论挂载多少个设备,都只需要两根通信线,在软件上,数据双向通信、应答位,都可以实现。如果把通信协议比作是一个人的话,那 I2C 就属于精打细算、思维灵活这类型的人,既要实现硬件上最少的通信线,又要实现软件上最多的功能。最终,I2C 通过精心的设计,也确实实现了这么多功能,可以说是非常的优雅。当然,在这些优雅之中,也隐藏了一个缺点,就是我们上节说的,由于 I2C 开漏外加上拉电阻的电路结构,使得通信线高电平的驱动能力比较弱,这就会导致,通信线由低电平变到高电平的时候,这个上升沿耗时比较长,这会限制 I2C 的最大通信速度,所以,I2C 的标准模式,只有 100 KHz 的时钟频率,I2C 的快速模式,也只有 400 KHz,虽然 I2C 协议之后又通过改进电路的方式,设计出了高速模式,可以达到 3.4 MHz,但是高速模式目前普及程度不是很高。所以一般情况下,我们认为 I2C 的时钟速度最多就是 400 KHz,这个速度,相比较 SPI 而言,还是慢了很多,那了解完 I2C 的优势和缺点,我们就来看一下 SPI。
在学习之前,简单概括几点 SPI 相对于 I2C 的优缺点:
首先,SPI 传输更快,SPI 协议并没有严格规定最大传输速度,这个最大传输速度取决于芯片厂商的设计需求,比如说,我们这个 W25Q64 存储器芯片,手册里写的 SPI 时钟频率,最大可达 80 MHz,这比 STM32F1 的主频还要高。
其次,SPI 的设计比较简单粗暴,实现的功能没有 I2C 那么多,所以学习起来,SPI 还是比 I2C 简单很多的。
最后,SPI 的硬件开销比较大,通信线的个数比较多,并且通信过程中,经常会有资源浪费的现象,如果继续把通信协议比作一个人的话,那 SPI 就属于富家子弟、有钱任性这类型的人,SPI 说,我不在乎我花了多少钱,我只在乎我的任务有没有最简单、最快速的完成,这就是 SPI 的风格。
好,经过这么多的对比和铺垫,大家对 SPI 应该就有了一个第一印象了吧。

四根通信线:SCK(Serial Clock,串行时钟线)、MOSI(Master Output Slave Input,主机输出从机输入)、MISO(Master Input Slave Output,主机输入从机输出)、SS(Slave Select,从机选择)

这是 SPI 通信典型的引脚名称。当然在实际情况下,这些名称可能会有别的表述方式。比如,SCK,有的地方可能叫作 SCLK、CLK、CK;MOSI 和 MISO,有的地方可能直接叫作 DO(Data Output)和 DI(Data Input);SS,有的地方也可能叫作 NSS(Not Slave Select)、CS(Chip Select),这些不同的名称都是一个意思,大家了解一下。那这里,就以 SPI 官方文档的名称为准,统一都用这几个名词来表示。
那这四个引脚的意义和作用是什么呢?我们继续往后看

SPI 基本特性是:同步,全双工

首先既然是同步时序,肯定就得有时钟线了,所以 SCK 引脚,就是用来提供时钟信号的,数据位的输出和输入,都是在 SCK 的上升沿或下降沿进行的,这样,数据位的收发时刻就可以明确的确定。并且,同步时序,时钟快点慢点,或者中途暂停一会儿,都是没问题的,这就是同步时序的好处。那对照 I2C 总线,这个 SCK,就相当于 I2C 的 SCL,两者作用相同。
之后,SPI 是全双工的协议。全双工:就是数据发送和数据接收单独各占一条线,发送用发送的线路,接收用接收的线路,两者互不影响。所以这里,MOSI 和 MISO,就是分别用于发送和接收的两条线路,MOSI 线,是主机输出从机输入,如果是主机接在这条线上,那就是 MO,主机输出;如果是从机接在这条线上,那就是 SI,从机输入,意思就是一条通信线,如果主机接在上面配置为输出,那从机肯定得配置为输入,才能接收主机的数据对吧。主机和从机不能同时配置为输出或输入,要不然就没法通信了。所以这条 MOSI,就是主机向从机发送数据的线路,那同理,下面这条 MISO,就是主机从从机接收数据的线路,这就是全双工通信的两根通信线,那这两根通信线,加在一起,就相当于 I2C 总线的 SDA,当然 I2C 是一根线兼具发送和接收,是半双工。这里 SPI 是一根发送、一根接收,是全双工。全双工的好处就是简单高效,输出线就一直输出,输入线就一直输入,数据流的方向不会改变,也不用担心发送和接收没协调好冲突了,但是坏处就是多了一根线,会有通信资源的浪费,这就是全双工。

支持总线挂载多设备(使用的是 一主多从 的模型)

SPI 仅支持一主多从,不支持多主机,这一点,SPI 从功能上,没有 I2C 强大。那 I2C,实现一主多从的方式是在起始条件之后,主机必须先发送一个字节进行寻址,用来指定我要跟哪个从机进行通信,所以 I2C 这里,要涉及分配地址和寻址的问题。但是 SPI 表示,你这太麻烦了,我直接大手一挥,再开辟一条通信线,专门用来指定我要跟哪个从机进行通信,所以,这条专门用来指定从机的通信线,就是这里的 SS,从机选择线。并且,这个 SS 可能不止一条,SPI 的主机表示,我有几个从机,我就开几条 SS,所有从机,一人一根,都别抢,我需要找你的时候,我就控制接到你那一根的 SS 线,给你低电平,就说明我要找你了;给你高电平,就说明我不跟你玩了,那这样一来,指定从机不就是动动手指就能完成的事了么,哪还需要什么分配地址,先发一个字节寻址的操作啊。那这就是 SPI 实现一主多从,指定从机的方式,好处就是方便,坏处就是得加钱(线)。

那最后这里,SPI,没有写应答机制的介绍,SPI 没有应答机制的设计。发送数据就发送,接收数据就接收,至于对面是不是存在,SPI 是不管的。

然后看一下下面的图片,这些都是采用了 SPI 通信的芯片和模块。
在这里插入图片描述
第一个图,就是我们本节使用的芯片,型号是 W25Q64,是一个 Flash 存储器,这个模块的引脚,可以看到和刚才说的并不一样。这里 CLK 就是 SCK,DI 和 DO 就是 MOSI 和 MISO。那 DI 到底是 MOSI 还是 MISO 呢,我们要看一下这个芯片的身份,显然,这个芯片接在 STM32 上,应该是从机的身份,所以这里的 DI,数据输入,就是从机的数据输入 SI,对应需要接在主机的 MO 上,所以这里的 DI 就是 MOSI,那另一个 DO,就是 MISO 了,一般在这种始终作为从机的设备上,可能会用 DI 和 DO 的简写。像 STM32 这种可以进行身份转换的设备,一般都会把 MOSI、MISO 的全程写完整,当然,即使它简写了,只要明确了它的身份,是主机还是从机,之后再辨别这两个引脚,应该就好判断了。那最后一个 CS 片选,其实就是 SS 从机选择了。

然后继续下一个模块,这个是利用 SPI 通信的 OLED 屏幕,上面的引脚也不是标准的名称,所以这个模块要查一下手册,在手册里有写的。

之后下一个,这个是一个 2.4 G 无线通信模块,芯片型号是 NRF24L01,这个芯片使用的就是 SPI 通信协议,要想使用这个芯片来进行无线通信,那就需要利用 SPI,来读写这个芯片。

然后最后一个图片,就是常见的 Micro SD 卡了,这个 SD 卡,官方的通信协议是 SDIO,但是它也是支持 SPI 协议的,我们可以利用 SPI,,对这个 SD 卡进行读写操作。

那到这里,我们这个 SPI 通信的大体介绍,就完成了。

接下来我们来看一下 SPI 的硬件和软件规定。

1.2 SPI 硬件规定

  • 所有SPI设备的SCK、MOSI、MISO分别连在一起
  • 主机另外引出多条SS控制线,分别接到各从机的SS引脚
  • 输出引脚配置为推挽输出,输入引脚配置为浮空或上拉输入

首先是硬件电路
在这里插入图片描述
这个图,就是 SPI 一个典型的应用电路。我们看一下

  1. 左边这里,是 SPI 主机,主导整个 SPI 总线。主机,一般都是控制器来作,比如 STM32,下面这里,SPI 从机 1、2、3,就是挂载在主机上的从设备了,比如存储器、显示屏、通信模块、传感器等等等等。
  2. 左边 SPI 主机实际上引出了 6 根通信线,因为有 3 个从机,所以 SS 线需要 3 根,再加 SCK、MOSI、MISO,就是 6 根通信线,当然 SPI 所有通信线都是单端信号,它们的高低电平都是相对 GND 的电压差。所以,单端信号,所有的设备还需要供电,这里 GND 的线没画出来,但是是必须要接的。然后如果从机没有独立供电的话,主机还需要再额外引出电源正极 VCC,给从机供电,这两根电源线 VCC 和 GND,也要注意接好。
  3. 然后我们看一下这几根通信线,首先,SCK,时钟线,时钟线完全由主机掌控,所以对于主机来说,时钟线为输出,对于所有从机来说,时钟线都为输入,这样主机的同步时钟,就能送到各个从机了。然后下一个,MOSI,主机输出从机输入,这里左边是主机,所以就对应 MO,主机输出,下面三个都是从机,所以就对应 SI,从机输入,数据传输方向是,主机通过 MOSI 输出,所有从机通过 MOSI 输入。接着下一个,MISO,主机输入从机输出,左边是主机,对应 MI,下面三个都是从机,对应 SO,数据传输方向是,三个从机通过 MISO 输出,主机通过 MISO 输出。

那到这里,SCK、MOSI、MISO 的连接方式我们就清楚了。这就是上面写的第一条,所有SPI设备的SCK、MOSI、MISO分别连在一起,就是上面图示的这样,每条线的数据传输方向,图中都用箭头标出来了,可以看一下,应该都挺明确的。

之后我们继续看,时钟和数据传输没问题了。最后要解决的就是从机的选择问题了,为了确定通信的目标,主机就要另外引出多条 SS 通信线,分别接到各从机的 SS 引脚,上面图中有 3 个从机,我们需要在主机另外引出 3 根 SS 选择线,分别接到每个从机的 SS 输入端。主机的 SS 线都是输出,从机的 SS 线都是输入,SS 线是低电平有效的,主机想指定谁,就把对应的 SS 输出线置低电平就行了。比如,主机初始化之后,所有的 SS 都输出高电平,这样就是谁也不指定。当主机需要和,比如从机 1,进行通信了,主机就把 SS1 线输出低电平,这样从机 1 就知道,主机在找我,然后主机在数据引脚进行的传输,就只有从机 1 会响应,其他从机的 SS 线是高电平,所以它们都会保持沉默。当主机和从机 1 通信完成之后,就会把 SS1 置回高电平,这样从机 1 就知道,主机结束了和我的通信。之后主机需要和从机 2 和从机 3 通信时,也是同理,需要找谁通信,就置谁的 SS 为低电平。当然同一时间,主机只能置一个 SS 为低电平,只能选中一个从机,否则,如果主机同时选中多个从机,就会导致数据冲突,这就是 SPI 实现选择从机的方式。不需要像 I2C 一样进行寻址,是不是挺简单的。

然后我们继续看下一条,输出引脚配置为推挽输出,输入引脚配置为浮空或上拉输入,这就是 SPI 引脚的配置。在上图里,输出引脚和输入引脚都用箭头标出来了,哪个是输出哪个是输入,应该很好判断。对于输出,我们配置推挽输出,推挽输出,高低电平具有很强的驱动能力,这将使得 SPI 引脚信号的下降沿,非常迅速,上升沿,也非常迅速,不像 I2C 那样,下降沿非常迅速,但是上升沿,就比较缓慢了。那得益于推挽输出的驱动能力,SPI 信号变化的快,那自然它就能达到更高的传输速度,一般 SPI 信号都能轻松达到 MHz 的速度级别。然后这里 I2C 并不是不想使用更快的推挽输出,而是 I2C 要实现半双工,经常要切换输入输出,另外 I2C 又要实现多主机的时钟同步和总线仲裁,这些功能,都不允许 I2C 使用推挽输出,要不然一不小心,就电源短路了,所以 I2C 选择了更多的功能,自然就要放弃更强的性能了。对于 SPI 来说,首先 SPI 不支持多主机,然后 SPI 又是全双工,SPI 的输出引脚始终是输出,输入引脚始终是输入,基本不会出现冲突,所以 SPI 可以大胆地使用推挽输出。不过当然,SPI 其实还是有一个冲突点的,就是图上的 MISO 引脚,在这个引脚上,可以看到主机一个是输入,但是三个从机全都是输出,如果三个从机都始终是推挽输出,势必会导致冲突。所以在 SPI 协议里,有一条规定,就是当从机的 SS 引脚为高电平,也就是从机未被选中时,它的 MISO 引脚,必须切换为高阻态,高阻态就相当于引脚断开,不输出任何电平,这样就可以防止,一条线有多个输出,而导致的电平冲突的问题了。在 SS 为低电平时,MISO 才允许变为推挽输出,这就是 SPI 对这个可能的冲突做出的规定,当然这个切换过程都是在从机里,我们一般都是写主机的程序,所以我们主机的程序中,并不需要关注这个问题。

好,那有关 SPI 的硬件电路,就介绍到这里。

接下来,我们来看一下移位示意图
在这里插入图片描述
这个移位示意图是 SPI 硬件电路设计的核心。只要你把这个移位示意图搞懂了,那无论是上面的硬件电路,还是我们等会学习的软件时序,理解起来都会更加轻松。我们看一下:

  1. SPI 的基本收发电路,就是使用了这样一个移位的模型,左边是 SPI 主机,里面有一个 8 位的移位寄存器;右边是 SPI 从机,里面也有一个 8 位的移位寄存器。这里移位寄存器有一个时钟输入端,因为 SPI 一般都是高位先行的,所以,每来一个时钟,移位寄存器都会向左进行移位,从机中的移位寄存器也是同理,然后呢,移位寄存器的时钟源,是由主机提供的,这里叫作波特率发生器,它产生的时钟驱动主机的移位寄存器进行移位,同时,这个时钟也通过 SCK 引脚进行输出,接到从机的移位寄存器里。之后,上面移位寄存器的接法是,主机移位寄存器左边移出去的数据,通过 MOSI 引脚,输入到从机移位寄存器的右边;从机移位寄存器左边移出去的数据,通过 MISO 引脚,输入到主机移位寄存器的右边,这样组成一个圈。
  2. 接下来,我来演示一下这个电路如何工作。首先,我们规定波特率发生器时钟的上升沿,所有移位寄存器向左移动一位,移出去的位放到引脚上,波特率发生器时钟的下降沿,引脚上的位,采样输入到移位寄存器的最低位。接下来,假设主机有个数据 1010101 要发送给从机,同时,从机有个数据 01010101 要发送到主机,那我们就可以驱动时钟,先产生一个上升沿,这时所有的位,就会往左移动一次,那从最高位移出去的数据,就会放到通信线上,数据放到通信线上,实际上是放到了输出数据寄存器。可以看到,此时 MOSI 数据是 1,所以 MOSI 的电平就是高电平,MISO 的数据是 0,所以 MISO 的电平就是低电平,这就是第一个时钟上升沿执行的结果,就是把主机和从机中,移位寄存器的最高位,分别放到 MOSI 和 MISO 的通信线上,这就是数据的输出。之后,时钟继续运行,上升沿之后,下一个边沿就是下降沿,在下降沿时,主机和从机内,都会进行数据采样输入,也就是,MOSI 的 1,会采样输入到从机这里的最低位;MISO 的 0,会采样输入到主机这里的最低位,这就是第一个时钟结束后的现象。那时钟继续运行,下一个上升沿,同样的操作,移位输出,主机现在的最高位,也就是原始数据的次高位,输出到 MOSI,从机现在的最高位,输出到 MISO;随后,下降沿,数据采样输入,MISO 数据到从机这里的最低位,MOSI 数据到主机这里的最低位。之后时钟继续运行,第三个时钟开始,上升沿,移位,主机输出,从机输出;下降沿,采样,主机输入,从机输入。之后,第 4 个时钟,第 5 个时钟,等等,一直到第 8 个时钟,都是同样的过程。最终 8 个时钟之后,原来主机里的 10101010 跑到从机里了,原来从机里的 01010101 跑到主机里了,这就实现了,主机和从机一个字节的数据交换,实际上,SPI 的运行过程就是这样。SPI 的数据收发,都是基于字节交换,这个基本单元来进行的。当主机需要发送一个字节,并且同时需要接收一个字节时,就可以执行一下字节交换的时序,这样,主机要发送的数据,跑到从机,主机要从从机接收的数据,跑到主机,这就完成了发送同时接收的目的。
    那你可能会问,如果我只想发送,不想接收,怎么办呢?其实很简单,我们仍然调用交换字节的时序,发送,同时接收,只是,这个接收到的数据,我们不看它就行了。那如果我只想接收,不想发送,怎么办呢?同理,我们还是调用交换字节的时序,发送,同时接收,只是,我们会随便发送一个数据,只要能把从机的数据置换过来就行了,我们读取置换过来的数据,不就是接收了嘛。这里我们随便发过去的数据,从机也不会去看它,当然这个随便的数据,我们不会真的随便发,一般在接收的时候,我们会统一发送 0x00 或 0xFF,去跟从机换数据。

好,以上就是 SPI 的基本原理。总结一下就是,SPI 通信的基础是交换一个字节,有了交换一个字节,就可以实现,发送一个字节、接收一个字节和发送同时接收一个字节,这三种功能。可以看出,SPI 在只执行发送或只执行接收的时候,会存在一些资源浪费现象。不过全双工的通信,本来就会有浪费的情况发生,SPI 表示:我不在乎。

那了解完这个移位示意图,再看一下硬件电路,是不是这三个引脚的功能,就很容易理解了。另外再加几根 SS 从机选择线,这就是 SPI 通信。

那硬件部分看完,我们来看一下 SPI 的时序。只要你把移位示意图理解了,SPI 时序其实也很简单。

1.3 SPI 软件规定

1.3.1 起始条件与结束条件

首先是 SPI 的起始和终止。其中:
起始条件:SS从高电平切换到低电平

也就是左边这个图,SS 是低电平有效的。那 SS 从高变到低,是不是就代表刚刚选中了某个从机了。这就是通信的开始。

终止条件:SS从低电平切换到高电平

也就是右边这个图,SS 从低电平变到高电平,就是结束了从机的选中状态。就是通信的结束。

在这里插入图片描述
那在从机的整个选中状态中,SS 要始终保持为低电平。就是 SS 低电平选中,高电平未选中,那低电平期间就代表正在通信,下降沿是通信的开始,上升沿是通信的结束,这个相比较 I2C,还是简单很多的,这就是起始条件和终止条件。

1.3.2 交换一个字节

接下就是数据传输的基本单元了。这个基本单元,就是建立在我们刚才说的移位模型上的,并且这个基本单元,什么时候开始移位?是上升沿移位还是下降沿移位?SPI 并没有限定死,给了我们可以配置的选择,这样的话,SPI 就可以兼容更多的芯片。

那在这里,SPI 有两个可以配置的位,分别叫做 CPOL(Clock Polarity)时钟极性和 CPHA(Clock Phase)时钟相位,每一位可以配置为 1 或 0。总共组合起来,就有模式 0、模式 1、模式 2、模式3 这 4 种模式。当然模式虽然多,但是它们的功能都是一样的,在实际使用的时候,我们主要学习其中一种就可以了剩下的模式,你知道有这些东西可以配置,如果到时候真的需要用,再过来了解一下就行了.

那我们先看一下模式 1,因为这个模式和我们刚才讲的移位模型是对应的,我们看一下。

这个时序的基本功能是交换一个字节,也就是移位示意图中我们展示的现象。
CPOL=0:空闲状态时,SCK为低电平

下面图中可以看到,在 SS 未被选中时,SCK 默认是低电平的。

CPHA=1:SCK第一个边沿移出数据,第二个边沿移入数据

当然这句话也有不同的描述方式,有的地方写的是,CPHA = 1 表示 SCK 的第二个边沿进行数据采样,或者是 SCK 的偶数边沿进行数据采样,这些不同的描述意思都是一样。这里为了照应刚才的移位模型,就写的是SCK第一个边沿移出数据,第二个边沿移入数据。

在这里插入图片描述
看一下上面的时序图。

第一个 SS,从机选择,在通信开始前,SS 为高电平在通信过程中,SS 始终保持低电平,通信结束,SS 恢复高电平,这是 SS 信号。

然后最下面一个 MISO,这是主机输入,从机输出,因为有多个从机输出连在了一起,如果同时开启输出,会造成冲突。所以我们的解决方法是,在 SS 未选中的状态,从机的 MISO 引脚必须关断输出,即配置输出为高阻状态。因此在这里,SS 高电平时,MISO 用一条中间的线表示高阻态,SS 下降沿之后,从机的 MISO 被允许开启输出,SS 上升沿之后,从机的 MISO 必须置回高阻态,这是 MISO 的设计。

然后我们看一下移位传输的操作,因为 CPHA = 1,SCK 第一个边沿移出数据,所以这里可以看出,SCK 第一个边沿就是上升沿,主机和从机同时移出数据,主机通过 MOSI 移出最高位,此时 MOSI 的电平就表示了主机要发送数据的 B7,从机通过 MISO 移出最高位,此时 MISO 表示从机要发送数据的 B7。然后时钟运行,产生下降沿,此时主机和从机同时移入数据,也就是进行数据采样,这里主机移出的 B7,进入从机移位寄存器的最低位,从机移出的 B7,进入主机移位寄存器的最低位。这样一个时钟脉冲产生完毕,一个数据位传输完毕,接下来就是同样的过程,上升沿,主机和从机同时输出当前移位寄存器的最高位,第二次的最高位,就是原始数据的 B6,然后下降沿,主机和从机移入数据,B6 传输完成,之后时钟继续运行,数据依次移出、移入、移出、移入。最后一个下降沿,数据 B0 传输完成,至此,主机和从机就完成了一个字节的数据交换。

如果主机只想交换一个字节,那这时就可以置 SS 为高电平,结束通信了。在 SS 的上升沿,MOSI 还可以再变化一次,将 MOSI 置到一个默认的高电平或低电平,当然也可以不去管它,因为 SPI 也没有硬性规定 MOSI 的默认电平。然后 MISO,从机必须得置回高组态,此时如果主机的 MISO 为上拉输入的话,那 MISO 引脚的电平就是默认的高电平,如果主机 MISO 为浮空输入,那 MISO 引脚的电平不确定,这是交换一个字节就结束的流程。

那如果主机还想继续交换字节呢?在此时,主机就不必要把 SS 置回高电平了,直接重复一下交换一个字节的时序,这样就可以交换多个字节了,这就是 SPI 传输数据的流程。和我们之前介绍的移位流程是对应的。

之后我们继续看一下模式 0

CPOL=0:空闲状态时,SCK 为低电平
CPHA=0:SCK 第一个边沿移入数据,第二个边沿移出数据
在这里插入图片描述
这个模式 0 和 模式 1 的区别,就是模式 0 的 CPHA = 0,模式 1 的 CPHA = 1,在时序上的区别呢,对比一下,就是这个模式 0 的数据移出移入的时机会提前半个时钟,也就是相位提前了。

我们看一下,模式 0,CPHA = 0,表示 SCK 第一个边沿移入数据,第二个边沿移出数据,模式 0 在 SCK 第一个边沿就要移入数据,但是数据总得先移出,才能移入,所以在模式 0 的配置下,SCK 第一个边沿之前,就要提前开始移出数据了,或者把它称作是在第 0 个边沿移出,第 1 个边沿移入。

看一下时序,首先,SS 下降沿开始通信,现在 SCK 还没有变化,但是 SCK 一旦开始变化就要移入数据了,所以此时趁 SCK 还没有变化,SS 下降沿时,就要立刻触发移位输出,所以这里 MOSI 和 MISO 的输出,是对齐到 SS 的下降沿的,或者说,这里把 SS 的下降沿,也当作时钟的一部分,那 SS 下降沿触发了输出,SCK 上升沿,就可以采样输入数据了,这样 B7 就传输完毕。之后,SCK 下降沿,移出 B6,SCK 上升沿,移入 B6,然后继续,下降沿移出数据,上升沿移入数据,最终在第 8 个上升沿时,B0 位移入完成,整个字节交换完成。之后,SCK 还有一个下降沿,如果主机只需要交换一个字节就结束,那在这个下降沿时,MOSI 可以置回默认电平,或者不去管它,MISO 也会变化一次,这一位,实际上是下一个字节的 B7,因为这个相位提前了,所以下一个字节的 B7 会露个头,如果不需要的话,SS 上升沿之后,从机 MISO 置回高阻态,这时交换一个字节就结束。

如果主机想交换多个字节的话,那就继续调用,从 SCK 的第一个上升沿到最后一个下降沿的时序,在最后一个下降沿,主机放下一个字节的 B7,从机也放下一个字节的 B7,SCK 上升沿正好接着采样第二个字节的 B7,这样时序才能拼接的上,这就是 SPI 交换一个字节模式 0。模式 0 和模式 1 的区别就在于模式 0 把这个数据变化的时机给提前了,在实际应用中,模式 0 的应用是最多的,所以我们重点掌握模式 0 即可,我们等会儿的介绍和后续的程序,都是基于 SPI 模式 0 来讲解的,不过这里感觉模式 1 是不是更符合常理,但是实际确实是模式 0 用的最多,可能是 SPI 设计的时候为了兼容现存设备吧,或者是模式 0 在实际应用时确实有什么优势,或者是因为模式 0 排在最前面,大家都默认用最前面的模式么,这个原因大家感兴趣的话可以调研一下。这就是模式 0 和模式 1。

那了解完模式 0 和模式 1,下面的模式 2 和模式 3 就非常简单了。

模式 2
CPOL=1:空闲状态时,SCK为高电平
CPHA=0:SCK第一个边沿移入数据,第二个边沿移出数据
在这里插入图片描述
其中模式 0 和模式 2 的区别,就是模式 0 的 CPOL = 0,模式 2 的 CPOL = 1。两者的波形就是 SCK 的极性取反一下,剩下的流程上的东西,完全一致。

模式 3
CPOL=1:空闲状态时,SCK为高电平
CPHA=1:SCK第一个边沿移出数据,第二个边沿移入数据
在这里插入图片描述
然后模式 1 和 模式 3 的区别,也是模式 1 的 CPOL = 0,模式 3 的 CPOL = 1。两者的波形,也是 SCK 的极性取反一下,其他地方,没有变化。

这就是这 4 种模式,然后最后提醒一下,这个 CPHA 表示的是时钟相位,决定是第一个时钟采样移入还是第二个时钟采样移入,并不是规定上升沿采样还是下降沿采样的。当然在 CPOL 确定的情况下,CPHA 确实会改变采样时刻的上升沿和下降沿。比如,模式 0 的时候,是 SCK 上升沿采样移入;模式 1 的时候,是 SCK 下降沿采样移入,这个了解一下,CPHA 决定是第几个边沿采样,并不能单独决定是上升沿还是下降沿。然后在这 4 种模式里,模式 0 和 模式 3,都是 SCK 上升沿采样;模式 1 和 模式 2,都是 SCK 下降沿采样,这就是时序基本单元,我们就介绍完了。

那最后,就是看几个 SPI 完整的时序波形了,当然每个芯片对 SPI 时序字节流功能的定义不一样,在这里,以本节使用的芯片 W25Q64 它的时序为例进行介绍,SPI 对字节流功能的规定,不像 I2C 那样。I2C 的规定一般是,有效数据流第一个字节是寄存器地址,之后依次是读写的数据,使用的是读写寄存器的模型;而在 SPI 中,通常采用的是指令码加读写数据的模型,这个过程就是,SPI 起始后,第一个交换发送给从机的数据,一般叫做指令码,在从机中,对应的会定义一个指令集,当我们需要发送什么指令时,就可以在起始后第一个字节发送指令集里面的数据,这样就能指导从机完成相应的功能了。不同的指令,可以有不同的数据个数,有的指令,只需要一个字节的指令码就可以完成,比如 W25Q64 的写使能、写失能等指令,而有的指令,后面就需要再跟要读写的数据,比如 W25Q64 的写数据、读数据等,写数据,指令后面就得跟上,我要在哪里写,我要写什么;读数据,指令后面就得跟上,我要在哪里读,我读到的是什么,这就是指令码加读写数据的模型。在 SPI 从机的芯片手册里,都会定义好指令集,什么指令对应什么功能,什么指令,后面得跟上什么数据,这些内容等我们学习芯片的时候再具体分析。

那这里,简单的抓了几个指令的波形,我们先来看一下这些波形是什么样的。

首先是 SPI 发送指令
这个时序的功能是:向SS指定的设备,发送指令(0x06)

指令 0x06,到底代表什么意思呢,可以由芯片厂商自己规定。在 W25Q64 芯片里,这个 0x06 代表的是写使能。

在这里插入图片描述
我们看一下这个波形,在这里,使用的是 SPI 模式 0,在空闲状态时,SS 为高电平,SCK 为低电平,MOSI 和 MISO 的默认电平没有严格规定。然后,SS 产生下降沿,时序开始,在这个下降沿时刻,MOSI 和 MISO 就要开始变换数据了。MOSI 由于指令码最高位仍然是 0,所以这里保持低电平不变;MISO,从机现在没有数据发给主机,引脚电平没有变化。实际上 W25Q64 不需要回传数据时,手册里规定的是 MISO 仍然是高阻态,从机并没有开启输出,不过这也没问题,反正这个数据我们也不要看。那这里,因为 STM32 的 MISO 是上拉输入,所以这里 MISO 呈现高电平。

之后,SCK 第一个上升沿,进行数据采样,时序图中画了一条绿线。从机采样输入,得到 0,主机采样输入,得到 1;之后继续,第二个时钟,主机数据位仍然是 0,所以波形仍然没有变化;然后这样一位一位的发送、接收、发送、接收;到第 6 位数据才开始变化,主机要发送数据 1,下降沿,数据移出,主机将 1 移出到 MOSI,MOSI 变为高电平,然后 SCK 上升沿,数据采样输入。

这里因为是软件模拟的时序,所以 MOSI 的数据变化有些延迟,没有紧贴 SCK 的下降沿。不过这也没关系,时钟是主机控制的,我们只要在下一个 SCK 上升沿之前完成变化就行了。

在最后一位呢,下降沿,数据变化,MOSI 变为 0;上升沿,数据采样,从机接收数据 0。SCK 低电平是变化的时期,高电平是读取的时期,这一块是不是和 I2C 差不多,那时序 SCK 最后一个上升沿结束,一个字节就交换完毕了,因为写使能是单独的指令,不需要跟随数据,SPI 只需要交换一个字节就完事了,所以最后,在 SCK 下降沿之后,SS 置回高电平,结束通信。那这个交换,我们统计一下 MOSI 和 MISO 的电平,总结一下就是,主机用 0x06 换来了从机的 0xFF,这个 0xFF 是默认的高电平,不过这个 0xFF,没有意义,我们不用管。

那整个时序的功能,就是发送指令,指令码是 0x06,从机一比对事先定义好的指令集,发现 0x06 是写使能的指令,那从机就会控制硬件,进行写使能,这样一个指令从发送到执行,就完成了,这就是发送单字节指令的时序。

之后我们再看一条指令,这条指令是指定地址写
功能是:向 SS 指定的设备,先发送写指令(0x02),写指令在指令集中,规定是 0x02,随后在指定地址下(Address[23:0]),写入指定数据(Data)。因为 W25Q64 芯片有 8 M 字节的存储空间,一个字节的 8 位地址肯定不够,所以这里地址是 24 位的,分 3 个字节传输,我们看一下时序。
在这里插入图片描述
首先,SS 下降沿,开始时序。这里,MOSI 空闲时是高电平,所以在下降沿之后,SCK 第一个时钟之前,可以看到,MOSI 变换数据,由高电平变为低电平,然后 SCK 上升沿,数据采样输入;后面还是一样,下降沿变换数据,上升沿采样数据;8 个时钟之后,一个字节交换完成,我们用 0x02,换来了 0xFF。其中发送的 0x02,是一条指令,代表这是一个写数据的时序,接收的 0xFF,不需要看,那既然是写数据的时序,后面必然还有跟着写的地址和数据了,所以在最后一个下降沿时刻,因为我们后续还要继续交换字节,所以在这个下降沿,我们要把下一个字节的最高位放到 MOSI 上,当然下一个字节的最高位仍然是 0,所以这里数据没有变化。之后还是同样的流程,交换一个字节,第二个字节,我们用 0x12 换来了 0xFF。根据 W25Q64 芯片的规定,写指令之后的字节,定义为地址高位,所以这个 0x12,就表示发送地址的 23~16 位。之后继续,看一下,交换一个字节,发送的是 0x34,这个就表示发送地址的 15~8 位,之后还是交换一个字节,发送的是 0x56,这个表示发送地址的 7~0 位,通过 3 个字节的交换,24 位地址就发送完毕了,从机收到的 24 位地址是 0x123456。那 3 位地址结束后,就要发送写入指定地址的内容了,我们继续调用交换一个字节,发送数据,这里的波形是 0x55,这就表示,我要在 0x123456 地址下,写入 0x55 这个数据。最后,如果只想写入一个数据的话,就可以 SS 置高电平,结束通信了。

当然这里,也可以继续发送数据,SPI 里,也会有和 I2C 一样的地址指针,每读写一个字节,地址指针自动加 1,如果发送一个字节之后,不终止,继续发送的字节就会依次写入到后续的存储空间里,这样就可以实现从指定地址开始,写入多个字节了。这就是 SPI 写入的时序。

由于 SPI 没有应答机制,所以交换一个字节后,就立刻交换下一个字节就行了。然后这条指令,我们还可以看出,由于整个流程,我们只需要发送的功能,并没有接收的需求,所以 MISO 这条接收的线路,就始终处于“挂机”的状态,我们并没有用到,这就是 SPI 指定地址写的逻辑。当然不同的芯片肯定有不同的规定了,我们这个存储器的容量大,所以需要连续指定 3 个字节的地址,如果容量小的话,可能一个字节的地址就够了,或者有的芯片会直接把地址给融合到指令码里去,这也是可以的,至于具体怎么操作的,还是得仔细分析一下手册,那这个时序我们就看到这里。

接着下一个时序。这个是指定地址读。
功能是:向SS指定的设备,先发送读指令(0x03),这里芯片定义,0x03,为读指令, 随后在指定地址(Address[23:0])下,读取从机数据(Data)。
在这里插入图片描述
我们看一下时序,时序的玩法和之前都一样,我们主要看一下这里交换的数据。起始之后第一个字节,主机发送指令 0x03,代表我要读取数据了。之后还是一样,主机再依次交换 3 个字节,分别是 0x12、0x34、0x56,组合到一起就是 0x123456,代表 24 位地址。最后这个地方,就是关键点了,因为我们是读取数据,指定地址之后,显然我们要开始接收数据,所以这里,3 个字节地址交换完之后,我们要把从机的数据搞过来,怎么搞过来呢?我们还是交换一个数据,来个抛砖引玉,我们随便给从机一个数据,当然一般给 FF 就行了;然后从机,此时不传,更待何时啊,这时从机就会乖乖的把 0x123456 地址下的数据通过 MISO 发给主机,可以看到,这样的波形,就表示指定地址下的数据是 0x55,这样主机就实现了指定地址读一个字节的目的。然后如果我们继续抛砖引玉,那么从机内部的地址指针自动加 1,从机就会继续把指定地址下一个位置的数据发过来,这样依次进行,就可以实现指定地址接收多个字节的目的了。然后最后,数据传输完毕,SS 置回高电平,时序结束。当然,时序这里也会有一些细节,比如,由于 MISO 是硬件控制的波形,所以它的数据变化,都可以紧贴时钟下降沿;另外我们可以看到,MISO 数据的最高位,实际上是在上一个字节,最后一个下降沿,提前发生的,因为这时 SPI 模式 0,所以数据变化都要提前半个周期。

好,这就是 W25Q64 规定的读时序了,当然芯片还规定了很多其它功能的时序,这个我们介绍芯片时再来分析,不过再多的时序,它们的格式都是大同小异,都是起始,交换,交换,交换,停止,我们只需要关注一下每一个字节的功能定义。就能很方便的利用 SPI 控制设备了。

好,那以上就是本节的全部内容了,我们本节介绍的是 SPI 协议的软硬件规定,并且也提前介绍了几个 W25Q64 的指令和时序。

2. W25Q64 简介

那我们接下来就来学习 W25Q64 这个芯片的知识点。它是如何存储数据的,他总共有哪些指令以及如何利用 SPI 去读写数据。另见 STM32 外设介绍章节。

3. 一个 软件 SPI 读写 W25Q64 功能案例

我们来正式开始写 SPI 读写 W25Q64 的代码,本小节主要使用软件 SPI 的方式来实现功能。

3.1 硬件电路

那还是先看一下接线图。
在这里插入图片描述
左边这里,是 W25Q64 的模块,我们把它插在面包板合适的位置,然后 VCC 电源正极,我们接在 3.3V 供电孔;GND 电源负极,接到 GND 供电孔,这样供电就接好了。之后就是 SPI 的四根通信线,CS、DO、CLK 和 DI,因为我们目前是软件模拟的 SPI,所以这 4 根线,其实是可以接到 STM32 的任意 GPIO 口,接哪个都行,软件模拟的通信端口灵活性很高,那这里,我是这样来接的:CS,片选,接到 PA4;DO,从机输出,接到 PA6;CLK 时钟,接到 PA5;DI,从机输入,接到 PA7。当然我这里引脚其实并不是任意选的,我实际上是接在了硬件的 SPI 的引脚上,这样的话,软件 SPI 和硬件 SPI 都可以任意切换,如果你也想留有切换的余地,那接在硬件 SPI 脚上是最好的,如果你只想使用软件 SPI,那就没这个必要了。

好,我们看一下面包板,来接一下线。接线完成后,通上电,模块的电源指示灯点亮,说明供电这块基本没问题。这样,硬件电路我们就完成了。

3.2 代码整体框架

我们先规划一下程序的整体框架,我们的规划和上一节 I2C 的差不多。

我们先建一个 MySPI 的模块,在这个模块里,主要包含通信引脚封装、初始化以及 SPI 通信的 3 个拼图:起始、终止和交换一个字节。这是 SPI 通信层的内容。

然后基于 SPI 层,我们再建一个 W25Q64 的模块,在这个模块里,调用底层 SPI 的拼图,来拼接各种指令和功能的完整时序,比如写使能、擦除、页编程、读数据等等。所以这一块可以叫做 W25Q64 的硬件驱动层。

最后,在主函数里,我们调用驱动层的函数,来完成我们想要实现的功能,这就是程序的整体框架。

3.2.1 软件 SPI 模块

那我们首先在 Hardware 目录下建立模块,模块名称叫 MySPI。

  • 模块建好后,先来个初始化

引脚初始化应该选择什么模式呢?在 SPI 硬件规定中输出引脚配置为推挽输出,输入引脚配置为浮空或上拉输入。

对于主机来说,时钟、主机输出和片选,都是输出引脚,所以这 3 个脚是推挽输出模式。

然后剩下一个主机输入,是输入引脚,所以这一个脚是浮空或上拉,我们选择上拉输入就行了。从机的 DO 输出,对应主机的 PA6,就是主机输入;所以引脚配置 PA6 为上拉输入,其他 3 个为推挽输出,这样就行了。

void MySPI_Init(void) {//在这里面,我们来初始化 SPI 的通信引脚
	//开启时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
	
	//引脚初始化
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;//推挽输出
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;//上拉输入
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平
	
//然后初始化这里,我们还有一个工作要做,就是置一下初始化之后引脚的默认电平。
	MySPI_W_SS(1);//在初始化之后,我们的 SS 应该置为高电平,默认不选中从机
	MySPI_W_SCK(0);//SCK,我们计划使用 SPI 模式 0,所以默认是低电平
	//之后,MOSI 没有明确规定,可以不管。MISO 是输入引脚,不用输出电平
	//这样,初始化之后的默认电平,就置好了
}

这样引脚初始化就完成了。

那接下来,我们还像之前 I2C 那样,把置引脚高低电平的函数都封装换个名字。

//首先是从机选择,这个是输出引脚
void MySPI_W_SS(uint8_t BitValue) {//这里叫 CS 也行,目前 CS 这个名字用的也挺多的
	//CS 对应的是 PA4 引脚
	GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
}
//这样这条写 GPIO 的函数就换了个名字,叫 MySPI_W_SS,表示写 SS 的引脚

//之后还有两个输出引脚
//第二个是 SCK
void MySPI_W_SCK(uint8_t BitValue) {
	//CLK 就是 SCK,对应的是 PA5 引脚
	GPIO_WriteBit(GPIOA, GPIO_Pin_5, (BitAction)BitValue);
}

void MySPI_W_MOSI(uint8_t BitValue) {
	//DI 就是 MOSI,对应的是 PA7 引脚
	GPIO_WriteBit(GPIOA, GPIO_Pin_7, (BitAction)BitValue);
}

//最后还有一个 MISO,是输入引脚
uint8_t MySPI_R_MISO(void) {
	return GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6);
}

这样,这 4 个 SPI 的通信引脚,我们就包装换好名字了。当然,你也可以用宏定义来完成相同的功能,但是这里我用函数包装一下,之后如果要移植到其他类型单片机里,或者要加一个延时,会更方便一些。

那这里,SPI 的速度非常快,所以我们操作完引脚之后,暂时就不需要加延时了。

那这是引脚的封装,我们就完成了。

好,接下来,我们来开始写 SPI 的 3 个时序基本单元。

  • 首先是起始信号

起始信号,我们直接把 SS 置低电平就完事了,所以这里,Start 函数里,就一句代码,写 SS,为 0,就行了

void MySPI_Start(void) {
	MySPI_W_SS(0);
}
  • 之后终止信号

终止信号,就是 SS 置高电平,所以这里,终止也很简单,里面就是把 SS 置高电平就行了

void MySPI_Stop(void) {
	MySPI_W_SS(1);
}

至此,我们 3 块拼图就完成两块了。然后最后一块,也是 SPI 的核心部分,就是交换一个字节。

  • 交换一个字节

总共有 4 种模式,我们 W25Q64 芯片支持模式 0 和模式 3,那我们一般选择实现模式 0 这一种就行了。

这个中间,我们来实现时序。在 SS 下降沿之后,我们开始交换字节,所以我们目前处于 SS 下降沿之后的位置

那看时序,SS 下降沿之后,第一步,主机和从机同时移出数据,就是主机移出我的数据最高位放到 MOSI 上,从机移出它的数据最高位放到 MISO 上,当然 MISO 数据变化是从机的事,不归我们管。

  1. 所以这里第一步是,写 MOSI
MySPI_W_MOSI(ByteSend & 0x80);//发送的位,是 ByteSend 的最高位,&0x80

当然这里要保证这个函数是非 0 即 1 的特征。要不然你这个 &0x80 之后,要把数据位右移到最低位才行。

好,然后继续看,数据位放好了。

第二步,SCK 上升沿,上升沿之后,主机和从机同时移入数据,当然还是一样,从机会自动把这个 B7 读走,从机那边的移入不归我们管,我们主机只需要读取 MISO 的数据就行了。

  1. 所以程序这里,就是写 SCK,为 1,产生上升沿。
MySPI_W_SCK(1);

上升沿时,从机自动把 MOSI 的数据读走,主机的任务,就是在这个上升沿后,把从机刚才放到 MISO 的数据位读进来。

  1. 所以这里,我们要读 MISO,读到的数据是接收的最高位,那我们还是使用之前的做法
if (MySPI_R_MISO() == 1) {
	ByteReceive |= 0x80;//这样就把最高位存在 ByteReceive 里了
}

然后看时序,时钟继续运行,接着就是 SCK 产生下降沿,主机和从机移出下一位。

  1. 所以这里,之后是写 SCK,为 0,产生下降沿
MySPI_W_SCK(0);
  1. 那在这个下降沿后,主机的任务是,移出 B6
MySPI_W_MOSI(ByteSend & 0x40);//把次高位放在 MOSI 上

然后就进入循环了,SCK 给上升沿,主机把从机的次高位接收进来,再下降沿,移出下一位,这就是流程。

那这里需要解释一下,在时序图上画的是 SS 下降沿和数据移出是同时发生的,包括后面,这个 SCK 下降沿和数据移出也是同时的,但这并不代表我们程序上要同时执行两条代码,当然这也做不到,这里实际上,它们是有先后顺序的。是先 SS 下降沿或 SCK 下降沿,再数据移出,这个下降沿是触发数据移出这个动作的条件,先有了下降沿,之后才会有数据移出这个动作,它们有个因果关系。
对于硬件 SPI 来说,由于使用了硬件的移位寄存器电路,所以这两个动作几乎是同时发生的,而对于软件 SPI 来说,由于程序是一条条执行的,我们不可能同时完成两个动作。所以软件 SPI,我们就直接躺平,直接把它看成先后执行的逻辑。那这个流程就是:先 SS 下降沿,再移出数据;再 SCK 上升沿,再移入数据;再 SCK 下降沿,再移出数据,以这个具有先后顺序的流程来执行,这样才能对应一条条依次执行的程序代码,对吧。这个说明一下。

函数代码:

函数名称就直接叫交换字节了,有的地方也叫 WriteReadByte 就是读写一个字节,也是一个意思。

uint8_t MySPI_SwapByte(uint8_t ByteSend) {
	//这个 ByteSend 是我们传进来的参数,要通过交换一个字节的时序。
	//返回值是 ByteReceive,是通过交换一个字节接收到的数据,通过返回值,传递给调用函数的地方

//1.之后,函数内部,我们先定义给一个 ByteReceive,用于接收
	uint8_t ByteReceive = 0x00;
	uint8_t i;
	
//2.那显然,这里用个 for 循环套起来就可以了
	for (i = 0; i < 8; i++) {//循环 8 次
		MySPI_W_MOSI(ByteSend & (0x80 >> i));//写 MOSI,发送 ByteSend 的位
		MySPI_W_SCK(1);//产生上升沿
		if (MySPI_R_MISO() == 1) {
			ByteReceive |= 0x80 >> i;//接收,这样就把位存在 ByteReceive 里了
		}
		MySPI_W_SCK(0);//产生下降沿
	}
	
	return ByteReceive;//最后返回出去
}

那这就是 SPI 模式 0 交换一个字节的时序了,在函数结束时,SCK 是 0,对应时序图。所以我们循环 8 次,就完整的产生了这个时序波形了。之后,想继续交换字节,可以;想终止,也可以。

然后这个程序,我目前是用 0x80 >> i 的方式,依次取出 ByteSend 的每一位,或者依次给 ByteReceive 的每一位置 1,可以看出,这个 0x80 >> i 的作用就是,用来挑出数据的每一位或者某几位;或者换种描述方式就是,用来屏蔽其他的无关位。那我们就可以把这种类型的数据,叫做掩码。所以这里演示的方法是,通过掩码,依次挑出每一位进行操作,这是使用掩码的操作方式。

当然这里的流程,其实还可以进行一些优化,就是使用移位示意图演示的移位模型,我们对照这个移位模型的流程,来完成程序的设计,设计方法给大家演示一下。

uint8_t MySPI_SwapByte(uint8_t ByteSend) {
	uint8_t i;
	
	for (i = 0; i < 8; i++) {//循环 8 次
		//第一步,移出数据,我们直接这样
		MySPI_W_MOSI(ByteSend & 0x80);
		ByteSend <<= 1;
		//那这两句加一起,效果就是把 ByteSend 的最高位移出到 MOSI,ByteSend 左移了一位,它的最低位会自动补 0,最低位空出来了。
		MySPI_W_SCK(1);//产生上升沿
		if (MySPI_R_MISO() == 1) {
			ByteSend |= 0x01;//那我们之后再接收的时候,就不需要 ByteReceive 这个变量了。把收到的数据放在 ByteSend 的最低位。
		}
		MySPI_W_SCK(0);//产生下降沿
}
//然后下个循环,我们继续发送 ByteSend 的最高位。因为上个循环已经向左移位了一次,所以第二次的最高位,就是原始数据的次高位,之后,数据左移。
//第二次循环接收时,因为左移了,所以数据仍然放在最低位,这样依次进行 8 次,数据交换就完成了,交换接收到的数据,就存在 ByteSend 里。
	
	return ByteSend;//最终 return ByteSend 就行了,就没必要再定义 ByteReceive 了
}

那可以看出,这种方法是不是比上面的方法效率更高。第一种方法,使用掩码依次提取数据每一位,好处就是,不会改变传入参数本身,之后如果还想用 ByteSend,可以继续使用。第二种方法,我们是用移位数据本身来进行的操作,好处就是效率更高;但是 ByteSend 这个数据,在移位过程中改变了,for 循环执行完,原始传入的参数就没有了,如果在这里想继续使用最开始传入的 ByteSend,那就没办法了。

然后,第二种方法,在实现思路上,是不是更契合我们这个移位的模型,它们基本是一一对应的。那这里,两种方法我都给大家演示了一下。使用哪种都可以,看你的喜好,那我就保留第一种方法了。

然后最后,我再给大家演示一下 SPI 模式 1、模式 2、模式 3 的修改方法,目前这里是模式 0,如果你想修改为模式 1 的话。看一下时序图,模式 1 这里是 SS 下降沿之后,先 SCK 上升沿、再移出数据、再 SCK 下降沿、再移入数据,所以我们只需要对程序的相位进行一些小修改,就能换成模式 1 了。怎么改呢?

//SS 下降沿,先 SCK 上升沿、再移出数据、再 SCK 下降沿、再移入数据
uint8_t MySPI_SwapByte(uint8_t ByteSend) {
	//之后,函数内部,我们先定义给一个 ByteReceive,用于接收
	uint8_t ByteReceive = 0x00;
	uint8_t i;
	
	//那显然,这里用个 for 循环套起来就可以了
	for (i = 0; i < 8; i++) {//循环 8 次
		MySPI_W_SCK(1);//产生上升沿
		MySPI_W_MOSI(ByteSend & (0x80 >> i));//写 MOSI,发送 ByteSend 的位
		MySPI_W_SCK(0);//产生下降沿
		if (MySPI_R_MISO() == 1) {
			ByteReceive |= 0x80 >> i;//接收,这样就把位存在 ByteReceive 里了
		}
	}
	
	return ByteReceive;//最后返回出去
}

这样是不是就完事了,这就是 SPI 模式 1 的流程,是不是很简单。之后 SPI 模式 2 和模式 3,就更简单了。比如现在这里是模式 1,改成模式 3,SPI 模式 1 和模式 3 的区别,就是时钟极性不一样,所以就把 SCK 极性翻转一下就行了,就是所有出现 SCK 的地方,0 改成 1(初始化中),1 改成 0,0 改成 1。那现在就是 SPI 模式 3,是不是也很简单。

然后 SPI 模式 2,同理,就是在模式 0 的基础上,把所有 SCK,0 改成 1,1 改成 0,就行了。

这就是 SPI 的 4 种模式,怎么改,就给大家演示好了。

现在回到最开始演示的模式 0,我们本代码使用的是 SPI 模式 0,如果你需要用别的模式,就在这个基础上进行一下小修改,改变相位,就是把这两条代码提前一下;改变极性,就是把 SCK,0 改 1,1 改 0。

好,到这里,SPI 的通信层代码,我们就写完了。

MySPI.h

#ifndef __LED_H
#define __LED_H

//模块化声明,函数可以被外部调用
void LED_Init(void);//LED 初始化函数
void LED1_ON(void);//LED1 亮
void LED1_OFF(void);//LED1 灭
void LED1_Turn(void);//LED1 翻转
void LED2_ON(void);//LED2 亮
void LED2_OFF(void);//LED2 灭
void LED2_Turn(void);//LED2 翻转

#endif

MySPI.c

#include "stm32f10x.h"                  // Device header

//我们还像之前 I2C 那样,把置引脚高低电平的函数都封装换个名字。
//首先是从机选择,这个是输出引脚
void MySPI_W_SS(uint8_t BitValue) {//这里叫 CS 也行,目前 CS 这个名字用的也挺多的
	//CS 对应的是 PA4 引脚
	GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
}
//这样这条写 GPIO 的函数就换了个名字,叫 MySPI_W_SS,表示写 SS 的引脚

//之后还有两个输出引脚
//第二个是 SCK
void MySPI_W_SCK(uint8_t BitValue) {
	//CLK 就是 SCK,对应的是 PA5 引脚
	GPIO_WriteBit(GPIOA, GPIO_Pin_5, (BitAction)BitValue);
}

void MySPI_W_MOSI(uint8_t BitValue) {
	//DI 就是 MOSI,对应的是 PA7 引脚
	GPIO_WriteBit(GPIOA, GPIO_Pin_7, (BitAction)BitValue);
}

//最后还有一个 MISO,是输入引脚
uint8_t MySPI_R_MISO(void) {
	return GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6);
}
//这样,这 4 个 SPI 的通信引脚,我们就包装换好名字了。

void MySPI_Init(void) {//在这里面,我们来初始化 SPI 的通信引脚
	//开启时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
	
	//引脚初始化
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;//推挽输出
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;//上拉输入
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平
	
	//然后初始化这里,我们还有一个工作要做,就是置一下初始化之后引脚的默认电平。
	MySPI_W_SS(1);//那在初始化之后,我们的 SS 应该置为高电平,默认不选中从机
	MySPI_W_SCK(0);//然后 SCK,我们计划使用 SPI 模式 0,所以默认是低电平
	//之后,MOSI 没有明确规定,可以不管,MISO 是输入引脚,不用输出电平,这样,初始化之后的默认电平,就置好了
}
//这样引脚初始化就完成了

//好,接下来,我们来开始写 SPI 的 3 个时序基本单元
//首先是起始信号
void MySPI_Start(void) {
	//起始信号,我们直接把 SS 置低电平就完事了,所以这里,Start 函数里,就一句代码,写 SS,为 0,就行了
	MySPI_W_SS(0);
}

//之后终止信号
void MySPI_Stop(void) {
	//终止信号,就是 SS 置高电平,所以这里,终止也很简单,里面就是把 SS 置高电平就行了
	MySPI_W_SS(1);
}

//交换一个字节,总共有 4 种模式,我们 W25Q64 芯片支持模式 0 和模式 3,那我们一般选择实现模式 0 这一种就行了。
uint8_t MySPI_SwapByte(uint8_t ByteSend) {
//这个 ByteSend 是我们传进来的参数,要通过交换一个字节的时序,
//返回值是 ByteReceive,是通过交换一个字节接收到的数据,通过返回值,传递给调用函数的地方
	
	//1.之后,函数内部,我们先定义给一个 ByteReceive,用于接收
	uint8_t ByteReceive = 0x00;
	uint8_t i;
	
	//2.那显然,这里用个 for 循环套起来就可以了
	for (i = 0; i < 8; i++) {//循环 8 次
		MySPI_W_MOSI(ByteSend & (0x80 >> i));//写 MOSI,发送 ByteSend 的位
		MySPI_W_SCK(1);//产生上升沿
		if (MySPI_R_MISO() == 1) {
			ByteReceive |= 0x80 >> i;//接收,这样就把位存在 ByteReceive 里了
		}
		MySPI_W_SCK(0);//产生下降沿
	}

	return ByteReceive;//最后返回出去
}

3.2.2 W25Q64 模块

然后接下来,我们就按照计划,继续写下一个模块,在 SPI 通信层之上,我们要建立 W25Q64 的驱动层。

  • 那模块建好,我们先来个初始化
#include "MySPI.h"
//作为 SPI 上层的 W25Q64 模块,它的初始化,显然要调用底层的 MySPI_Init,先把底层初始化好,这个上层才能正常工作。
void W25Q64_Init(void) {
	MySPI_Init();
}

当然要调用底层的函数,也别忘了包含 MySPI 的头文件。那由于这个 W25Q64 也不需要再初始化其他东西了,所以初始化这里,我们只需要调用一下 MySPI_Init,就行了。

  • 获取 ID 号

之后呢,我们就可以来实现业务代码了,也就是拼接完整的时序,这个我们要参考一下手册,主要参考的部分就是这个指令集的表格。

比如,我们先实现这个获取 ID 号的时序,先把 ID 号读出来,看看对不对,以此验证底层的 SPI 协议写的有没有问题。从手册中的表格可以看到,读取 ID 号的时序就是,起始,先交换发送指令 9F,随后连续交换接收 3 个字节,停止。

那我哪知道哪个是交换发送,哪个是交换接收呢。

这个下面写了,圆括号括起来的,就是我们需要交换接收的数据,这里 3 个字节都是括起来的,所以这 3 个字节都需要我们接环接收。另外,通过业务逻辑,我们应该也可以很容易的看出来哪个是需要接收的。你都发送读取 ID 号的指令了,之后该干啥,不是很明显嘛,那连续接收 3 个字节。第一个字节是厂商 ID,表示了是那个厂家生产的,后两个字节是设备 ID,其中设备 ID 的高 8 位,表示存储器类型,低 8 位,表示容量。

好,ID 号的读取方法和意义清楚了,我们回到代码。

  1. 由于我们计划这个函数是有两个返回值的,所以我们还是使用指针来实现多返回值,原来的返回值就不要了,在参数列表里写上两个输出参数。
void W25Q64_ReadID(uint8_t* MID, uint16_t* DID) {//一个是输出 8 位的厂商 ID,另一个是输出 16 位的设备 ID
  1. 在这里面,拼接时序,首先,要开始一条时序,显然,要先调用 MySPI_Start,SS 引脚置低,开始传输
MySPI_Start();
  1. 之后,先交换发送一个字节 9F

参数是交换发送的,给 0x9F,这里的纯数字可以替换为对应的字符表示了,这样我们就一眼看出来这是读 ID 的指令了

MySPI_SwapByte(W25Q64_JEDEC_ID);

返回值是交换接收的,但是这里,接收的数据没有意义,所以返回值就不要了。那我们这条代码就是,抛玉引砖,对吧。抛过去是 9F,代表读 ID 号的指令,那从机收到读 ID 号的指令后,它就会严格按照手册里约定的规则来,下一次交换,就会把 ID 号返回给主机了。所以我们再来一次 MySPI_SwapByte();

  1. 接收 MID

参数,我要给从机一个东西,此时我的目的是接收,所以给它抛的东西就没有意义,我们可以随便给它抛一个垃圾,但一般,为了体验素质,我们会给个 0xFF。

*MID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);

这个 FF,没有意义,是无用的数据,我们使用对应的 W25Q64_DUMMY_BYTE 替换到这里,这样代码的意义就更明显一些。它的目的就是给对面有意义的数据置换过来,置换之后,这个返回值就是我们想要的 ID 号了。第一个置换回来的是厂商 ID,我们把它存在 MID 指向的变量里,这样厂商 ID 就收到了,那我们这条代码就是抛砖引玉了,对吧。给它抛一个垃圾,它返回一个有用的数据。

  1. 接收 DID

之后,根据手册,我们再交换一次,收到的就是设备 ID 的高 8 位了。

*DID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//再抛一次砖,它返回的是设备 ID 的高 8 位,我们把返回值存在 DID 指向的变量里。

当然这里说明一下,虽然我们连续调用了两个相同的函数,但是它们的返回值并不是一样的,你别说我这连续调用了两个一模一样的函数,它的返回值肯定是一样的啊,这个想法不对。因为我们是在通信,通信是有时序的,不同时间调用相同的函数,它的意义就是不一样的,这个注意一下。

然后继续,之后。第三次交换,根据手册的规定,这是返回的是设备 ID 的低 8 位,我们也把它存在 DID 里。

*DID <<= 8;
*DID |= MySPI_SwapByte(W25Q64_DUMMY_BYTE);

当然,要想把两次的读取分别放在 DID 的高 8 位和低 8 位,我们需要在第一次读取之后 *DID <<= 8; 把第一次读到数据运到 DID 的高 8 位去,之后,第二次读取,需要变为 |=,不能直接写等于,否则高 8 位就又置 0 了,这样,我们的两个 ID 号就读好了。

  1. 最后,时序结束,我们来个 MySPI_Stop
	MySPI_Stop();
}

这样获取 ID 号的时序就拼接完成了。是不是也不难理解啊,就是起始、交换、交换、交换、最后停止。至于交换每个字节的意义,查一下手册里的指令表,就很容易理解了。

函数代码:

void W25Q64_ReadID(uint8_t* MID, uint16_t* DID) {//由于我们计划这个函数是有两个返回值的,所以我们还是使用指针来实现多返回值,原来的返回值就不要了,在参数列表里写上两个输出参数。一个是输出 8 位的厂商 ID,另一个是输出 16 位的设备 ID
	//1.首先,要开始一条时序,显然,要先调用 MySPI_Start,SS 引脚置低,开始传输
	MySPI_Start();
	//2.之后,先交换发送一个字节 9F
	MySPI_SwapByte(W25Q64_JEDEC_ID);//参数是交换发送的,给 0x9F,这里的纯数字可以替换为对应的字符表示了,这样我们就一眼看出来这是读 ID 的指令了
	//3.接收 MID;
	*MID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//参数,我要给从机一个东西,此时我的目的是接收,所以给它抛的东西就没有意义,我们可以随便给它抛一个垃圾,但一般,为了体验素质,我们会给个 0xFF。
	//4.之后,根据手册,我们再交换一次,收到的就是设备 ID 的高 8 位了。
	*DID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//再抛一次砖,它返回的是设备 ID 的高 8 位,我们把返回值存在 DID 指向的变量里。
	*DID <<= 8;
	*DID |= MySPI_SwapByte(W25Q64_DUMMY_BYTE);//然后继续,之后。第三次交换,根据手册的规定,这是返回的是设备 ID 的低 8 位,我们也把它存在 DID 里。
	//5.最后,时序结束,我们来个 MySPI_Stop
	MySPI_Stop();
}

那我们写到现在,可以来进行一下测试,看看写到目前的代码都对不对。

main.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "W25Q64.h"

//定义两个存 ID 号的变量
uint8_t MID;
uint16_t DID;

int main(void) {
	OLED_Init();
	W25Q64_Init();
	
	W25Q64_ReadID(&MID, &DID);//把两个变量的地址传递进去,等函数执行完,ID 号我们就拿到了
	
	OLED_ShowHexNum(1, 1, MID, 2);
	OLED_ShowHexNum(1, 8, DID, 4);
	
	while(1){
	}
}

这样就行了,编译下载测试一下。

可以看到,屏幕上显示了两个数,厂商 ID 是 EF,设备 ID 是 4017,那我们看一下手册,这个数对不对呢,在手册的指令表上面,有写 ID 号。其中厂商 ID 是 EF,没问题,设备 ID,我们目前使用的是 9F 指令,所以读出来是 4017,也没问题。这说明,我们的程序,写到这里,现象都是符合预期的。

如果你在这一步,不能正确的读取 ID 号,那说明你之前写的代码是有问题的,或者硬件接线是有问题的,再仔细对照检查一下,走一步测一步,确保之前的代码没问题。再往后写,这样写程序心里才有底,对吧。

好那我们继续来写程序,接下来我们的任务,就是把这个指令集里,标黄色的这些指令时序给实现出来。那这个指令比较多,每个指令都对应一个指令码,如果你总是在程序里,直接像这样写一个数字,那意义就不太明显,可读性不高,别人看着你这个程序,也不知道 9F 到底代表啥意思。所以我们的做法和上一节 MPU6050 的寄存器地址一样,我们把每个指令码也用宏定义替换一下,那指令比较多,我们还是单独建一个头文件存放一下。

W25Q64_Ins.h

#ifndef __W25Q64_INS_H
#define __W25Q64_INS_H

//在这里面,大家可以根据手册,把这所有的指令名称和指令码抄过来,当然这里,我也是提前写好了
//就是这个样子,define 宏定义,将每个指令名称,替换为对应的指令码
#define W25Q64_WRITE_ENABLE							0x06
#define W25Q64_WRITE_DISABLE						0x04
#define W25Q64_READ_STATUS_REGISTER_1				0x05
#define W25Q64_READ_STATUS_REGISTER_2				0x35
#define W25Q64_WRITE_STATUS_REGISTER				0x01
#define W25Q64_PAGE_PROGRAM							0x02
#define W25Q64_QUAD_PAGE_PROGRAM					0x32
#define W25Q64_BLOCK_ERASE_64KB						0xD8
#define W25Q64_BLOCK_ERASE_32KB						0x52
#define W25Q64_SECTOR_ERASE_4KB						0x20
#define W25Q64_CHIP_ERASE							0xC7
#define W25Q64_ERASE_SUSPEND						0x75
#define W25Q64_ERASE_RESUME							0x7A
#define W25Q64_POWER_DOWN							0xB9
#define W25Q64_HIGH_PERFORMANCE_MODE				0xA3
#define W25Q64_CONTINUOUS_READ_MODE_RESET			0xFF
#define W25Q64_RELEASE_POWER_DOWN_HPM_DEVICE_ID		0xAB
#define W25Q64_MANUFACTURER_DEVICE_ID				0x90
#define W25Q64_READ_UNIQUE_ID						0x4B
#define W25Q64_JEDEC_ID								0x9F
#define W25Q64_READ_DATA							0x03
#define W25Q64_FAST_READ							0x0B
#define W25Q64_FAST_READ_DUAL_OUTPUT				0x3B
#define W25Q64_FAST_READ_DUAL_IO					0xBB
#define W25Q64_FAST_READ_QUAD_OUTPUT				0x6B
#define W25Q64_FAST_READ_QUAD_IO					0xEB
#define W25Q64_OCTAL_WORD_READ_QUAD_IO				0xE3

#define W25Q64_DUMMY_BYTE							0xFF

#endif

这样,指令替换就做好了,上面这些,就依次对应每个指令表里的每个指令,另外下面我还多定义了一个叫做 DUMMY_BYTE,它表示 0xFF,意思就是我们在接收时交换过去的无用数据。

那这些就是指令集的宏定义。

好,我们继续,来依次实现这些指令的时序。

  • 写使能

首先是 Write Enable,写使能,这个指令很简单,只需要发送一个指令码 06 就行了,那在这里,我们可以定义函数。

void W25Q64_WriteEnable(void) {
	//在这里面,就很简单了。
	//1.Start 起始
	MySPI_Start();
	//2.交换发送一个字节
	MySPI_SwapByte(W25Q64_WRITE_ENABLE);//指令码就是 WRITE_ENABLE,写使能
	//3.这个指令后续不需要跟任何数据,所以直接 Stop
	MySPI_Stop();
}

这里芯片规定的就是,SPI 起始之后的第一个字节,都是指令码,我们要发送的指令呢,就是 WRITE_ENABLE,放到这里

这个指令后续不需要跟任何数据,所以直接 Stop,这样就完成了。我们只要调用一次这个函数,就可以向 W25Q64 发送一次写使能指令。

  • 等待忙

然后我们继续,下一个指令,是读状态寄存器 1,指令码是 05,发完指令码就可以接收状态寄存器了。当然我们读状态寄存器的主要用途,就是判断芯片是不是忙状态。

状态寄存器 1 每一位的定义在手册里有说明,我们要读取它的最低位,BUSY,看看是不是还是 1,1 表示芯片还在忙,0 表示芯片不忙了。另外我们最好要实现一个等待 BUSY 为 0 的函数,这样更符合我们的需求对吧。

我们调用一下这个函数,要是 BUSY 为 1,就进入等待,等函数执行完了,BUSY 肯定就是 0 了,然后我们再看一下下面的指令详细介绍。这个读状态寄存器 1 或 2 的介绍。这里画的是,起始之后,先发送指令码,再接收状态寄存器,之后如果你时序不停,还要继续接收的话,这个芯片就会继续把最新的状态寄存器发过来。

也就是这里写的,状态寄存器可以被连续读出,这个连续读出的特性,就方便我们执行等待的功能。

具体做法就是:

void W25Q64_WaitBusy(void) {//这个函数我们就直接叫 WaitBusy,就是等待 BUSY 为 0 的意思
	uint32_t Timeout = 100000;//循环之前,给 Timeout 赋个初始值,这个初始值可以自己实测一下,稍微给大点,比如 100000
	
	//1.在这里面,先 Start
	MySPI_Start();
	
	//2.然后发送指令
	MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1);//指令码是读状态寄存器 1
	
	//3.随后我们发送 W25Q64_DUMMY_BYTE 接收数据,返回值是 状态寄存器 1,我们把它 &0x01,用掩码取出最低位
	while((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01) {
		Timeout--;//然后在 while 循环里,每循环一次,Timeout--
		if (Timeout == 0) {//如果 Timeout 自减到 0 了
			//在 break 之前,你也可以跳到自定义的错误处理函数,这个看你的需求加就行。
			break;//就 break 超时退出,不等了
		}
	}
	
	//4.最后,我们来个 Stop,终止时序
	MySPI_Stop();
}

如果它 == 0x01,就是 BUSY 为 1,这时我们进入 while 死循环,进行等待。当然这里前面的计算套个括号,避免优先级的问题;那这样,如果 BUSY 为 1,就会进入循环,再次读出一次状态寄存器,继续判断,直到 BUSY 为 0,跳出循环。

这就是利用连续读出状态寄存器,实现等待 BUSY 的功能。当然这里如果你觉得死循环等待在意外的情况下,可能会导致程序卡死,我们还是可以来个 Timeout。

好,这里,我们这个等待忙的函数就写好了,我们调用这个函数,如果不忙,函数就会很快退出;如果忙,就会卡在函数里面等待,等不忙了,就会退出。

  • 页编程

之后继续,下一个,我们来写这个页编程的函数,格式是先发一个指令码 02,再发 3 个字节的地址,最后发数据,这里的数据是发送方向的,之前说,括号表示接收数据。

所以手册这里应该是打错了,不应该画括号的,然后看一下详细介绍上面有一堆描述,我们应该都介绍过,大家有时间自己看一下。主要看一下下面的时序:

手册这里画的是,先发送指令,然后连发 3 个字节,就是 24 位地址,之后继续发送 DataByte1、DataByte2、DataByte3,最大是 DataByte256,如果继续发,它就会覆盖这里的 DataByte1,这是注意事项。

void W25Q64_PageProgram(uint32_t Address, uint8_t* DataArray, uint16_t Count) {
//1.写使能
	W25Q64_WriteEnable();
	
	uint16_t i = 0;
//2.然后函数内部,拼接时序,先 Start
	MySPI_Start();
//3.再发送指令
	MySPI_SwapByte(W25Q64_PAGE_PROGRAM);//指令码是页编程
//4.之后,我们还要继续交换发送 3 个字节的地址,地址是高位字节先发送
	MySPI_SwapByte(Address >> 16);//第一次发的,是 Address,3 个字节里的最高位字节。
	MySPI_SwapByte(Address >> 8);//第二次,就是中间的字节
	MySPI_SwapByte(Address);//第三次,就是最低位的字节。
	//这样连续发送 24 位地址就完成了
//5.看一下手册,根据指令规定,地址发完,就可以依次发送写入的数据了
	for (i = 0; i < Count; i++) {//那我们要写入 Count 个数据,显然来个循环,就再合适不过了。
		MySPI_SwapByte(DataArray[i]);//继续交换发送,第 i 次写入的,是 DataArray,第 i - 1 个数据
	}
	//这样依次来进行,即可在指定地址开始,写入多个字节
//6.最后写完,来个 Stop
	MySPI_Stop();
	
//7.事后等待
	W25Q64_WaitBusy();
}

  • C语言没有 24 位数据类型,所以 Address 定义 32 位的就行。如果我们只想指定地址写一个字节,就是 uint8_t Data,这样就行。但是我们一般存储的东西比较多,每次都调用存储一个字节,效率太低了,所以我们可以传递一个数组 DataArray,数组,我们得通过指针传递,数据类型定义为指针,最后再加一个 uint16_t Count,表示一次写多少个。至于如何使用指针传递数组,还不会的话,可以看看我们指针教程。另外,由于这个页编程,它一次性写入数据的数量范围是 0~256,所以 Count 要定义为 16 位的,如果你只定义为 8 位的,那只能存 0~255,这样当你需要写入 256 个数据时,就会出问题。
  • 交换发送 3 个字节的地址,地址是高位字节先发送
    MySPI_SwapByte(Address >> 16); 第一次发的,是 Address,3 个字节里的最高位字节。那我们直接把 Address 右移 16 位,如果地址是 0x123456,右移 16 位,就是 0x12 了,就是最高位的字节
    MySPI_SwapByte(Address >> 8); 第二次,我们把 Address 右移 8 位,如果地址是 0x123456,右移 8 位,就是 0x1234 了,但是交换字节函数只能接收 8 位数据,所以高位舍弃,实际发送 0x34,就是中间的字节
    MySPI_SwapByte(Address); 第三次,我们直接把 Address 放里面,如果地址是 0x123456,舍弃高位,实际发送 0x56,就是最低位的字节。
    这样连续发送 24 位地址就完成了

这样 PageProgram 时序就实现完成了,我们调用这个函数,给一个指定的起始地址,再给要写入的数组和数量,它就能帮我们写入数据了。

  • 扇区擦除

那接着继续看,下一个,我们来实现擦除的功能,手册里有 4 个擦除的选项,就只演示扇区擦除,其他的擦除,都是非常类似的,应该好理解。

那要执行扇区擦除,需要先发送指令 20,再发送 3 个字节的地址,就行了,这样指定地址所在的整个扇区,就会被擦除。我们来写一下。

void W25Q64_SectorErase(uint32_t Address) {//只需要指定一个 24 位地址就行了
	//1.写使能
	W25Q64_WriteEnable();
	//2.先起始
	MySPI_Start();
	//3.发送指令
	MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB);//指令码是扇区擦除
	//4.之后指定地址
	MySPI_SwapByte(Address >> 16);
	MySPI_SwapByte(Address >> 8);
	MySPI_SwapByte(Address);
	//5.最后停止
	MySPI_Stop();
	//6.事后等待
	W25Q64_WaitBusy();
}

那这个函数就完成了,调用这个函数,指定地址所在扇区就被擦除

另外大家注意到手册里有很多的 dummy,也就是无用数据,它这里写了 dummy,你就给它发个 FF 就行了,它返回值也是无用数据,相当于抛砖引砖了,发送和接收的数据都没有意义,它这么干,有的情况是为内部电路延时做准备的,就是交换几个无用数据,相当于有一段延时。当然我推测也可能是通过 dummy 的这段时序,给内部电路产生一些额外的时钟,也可以做一些准备工作,或者是其他原因,咱也不用管。总之见到这个 dummy,你就按规定交换一个无用数据就行了,这个说一下。

  • 读取数据

之后继续,我们最后一个指令,就是 ReadData,读取数据。流程是,交换发送指令 03,再发送 3 个字节地址,随后转入接收,就可以依次接收数据了。

看一下详细介绍,这里时序可以看到,之前 DO 一直是高阻态,在发送完 3 个字节地址后,DO 开启输出,此时主机就可以接收有用数据 DataOut1 了,在接收时,DI 的波形是 xxx,表示这个数据无所谓,这是抛砖引玉。之后如果你连续接收多次,那就是 DataOut2、DataOut3 等等,读取没有 256 字节限制,可以跨页,一直连续读。

读取数据的数组是输出参数,读到的数据通过数组输出,前面这个写数据的数组是输入参数,要写的数据通过数组输入,这个了解一下。

那我们这个读取数据,没有页的限制,所以读取的 Count,范围可以非常大,16 位数据最大 65535,可能不够,我们改成 32 位的类型,这样就没问题了。

void W25Q64_ReadData(uint32_t Address, uint8_t* DataArray, uint32_t Count) {
	uint32_t i = 0;
//1.先起始
	MySPI_Start();
//2.发送指令
	MySPI_SwapByte(W25Q64_READ_DATA);//指令码是读取数据
//3.之后指定地址
	MySPI_SwapByte(Address >> 16);
	MySPI_SwapByte(Address >> 8);
	MySPI_SwapByte(Address);
//4.然后根据协议规定 ,从这里开始,我们就要开始读了,怎么读呢?
	for (i = 0; i < Count; i++) {//那这个过程,我们还是套一个 for 循环。
		DataArray[i] = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//抛砖引玉的代码,发送 FF,置换会有用的数据。它的返回值,就是读到的数据,我们把它放到 DataArray 的第 i 个位置
	}
	//这样依次来进行,在每次调用交换读取之后,存储器芯片内部地址指针自动自增,依次返回指定地址开始,往后线性区域地址下的数据。
	//在 STM32 里,我们肯定也是把这一个个的数据流,依次存在一个线性地址的数组里,对吧。
	
//5.读完之后,来个 Stop
	MySPI_Stop();
}

这样读取数据的时序就完成了。我们调用这个函数,给一个指定的起始地址,再给接受读取数据的数组和数量。它就能帮我们读取数据了。

那到这里,我们基本的函数就写完了。

最后,在这每一条时序写完之后,我们还可以做一项工作,来方便我们之后的使用,就是注意事项中第一条,写入操作前,必须先进行写使能,在我们这里,涉及写入操作的时序有扇区擦除和页编程。

既然每次写入之前都必须得写使能。那我们直接在这函数前面,就自带一个写使能吧。省得我们再在每次调用的时候,加写使能的代码,方便一些,那我们就复制上面的写使能函数,放在扇区擦除和页编程前面。

这样之后我们再调用写入的函数,就不用再额外调用写使能了。然后上一小节介绍过,这个写使能仅对之后跟随的一条时序有效,一条时序结束后,会顺手关门。

所以我们在每次写入之前都加一条写使能,写完之后,就不用再写失能了。这就是写使能的操作。

之后,还有一个要做的就是,这里的写入操作结束后,芯片进入忙状态,所以我们可以在每次写操作时序结束后,调用一下 WaitBusy,当然这里还涉及一个事前等待还是事后等待的考虑。

我们可以选择,在每次写入后,都等 BUSY 清零,再退出。这样是最保险的,函数结束后,芯片肯定是不忙的状态,这是事后等待。就是写入后,立刻等待,不忙了再退出。

另外我们还可以选择事前等待,就是写入时序结束后,我们不进行等待,而是在每次操作之前,就在函数最开始,进行等待,我们只要在每次写入之前,等一下 BUSY,不忙的时候,再写入,就没问题了。这是事前等待,就是写入前,先等待,等不忙了,再写入。

这两者的区别就是事后等待,最保险,在函数之外的地方,芯片肯定是不忙的状态;事前等待,效率会高一些,因为你写完之后不等,程序可以执行其他代码。那就正好利用执行其他代码的时间来消耗我等待的时间,说不定我下一次事前等待的时候,时间被执行其他代码耗过去了,我就不用等了。最后一个区别就是,事后等待,只需要在写入操作之后调用;而事前等待,在写入操作和读取操作之前,都得调用。因为在忙的时候,读取也是不行的,所以如果采取事前等待,那么读取操作之前,也得等待,这就是两种等待方式的区别。

大家可以根据自己的喜好和需求选择事前等待还是事后等待。那这里我就选择事后等待了,那最后读取数据时,事前事后都不用等待。因为每次耗时操作之后,我们都已经等过,所以调用 ReadData 时,肯定不会忙。

好,到这里,我们整个驱动模块就写好了。接下来我们来进行一下测试,那把最后 3 个函数,放在头文件里声明一下,写使能和等待忙的,就不用外部调用了。

W25Q64.h

#ifndef __W25Q64_H
#define __W25Q64_H

void W25Q64_Init(void);
void W25Q64_ReadID(uint8_t* MID, uint16_t* DID);
void W25Q64_PageProgram(uint32_t Address, uint8_t* DataArray, uint16_t Count);
void W25Q64_SectorErase(uint32_t Address);
void W25Q64_ReadData(uint32_t Address, uint8_t* DataArray, uint32_t Count);
		
#endif

W25Q64.c

#include "stm32f10x.h"                  // Device header
#include "MySPI.h"
#include "W25Q64_Ins.h"

//那模块建好,我们先来个初始化
void W25Q64_Init(void) {//作为 SPI 上层的 W25Q64 模块,它的初始化,显然要调用底层的 MySPI_Init,先把底层初始化好,这个上层才能正常工作。
	MySPI_Init();
}

//读取 ID 号
void W25Q64_ReadID(uint8_t* MID, uint16_t* DID) {//由于我们计划这个函数是有两个返回值的,所以我们还是使用指针来实现多返回值,原来的返回值就不要了,在参数列表里写上两个输出参数。一个是输出 8 位的厂商 ID,另一个是输出 16 位的设备 ID
	//1.首先,要开始一条时序,显然,要先调用 MySPI_Start,SS 引脚置低,开始传输
	MySPI_Start();
	//2.之后,先交换发送一个字节 9F
	MySPI_SwapByte(W25Q64_JEDEC_ID);//参数是交换发送的,给 0x9F,这里的纯数字可以替换为对应的字符表示了,这样我们就一眼看出来这是读 ID 的指令了
	//3.接收 MID;
	*MID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//参数,我要给从机一个东西,此时我的目的是接收,所以给它抛的东西就没有意义,我们可以随便给它抛一个垃圾,但一般,为了体验素质,我们会给个 0xFF。
	//4.之后,根据手册,我们再交换一次,收到的就是设备 ID 的高 8 位了。
	*DID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//再抛一次砖,它返回的是设备 ID 的高 8 位,我们把返回值存在 DID 指向的变量里。
	*DID <<= 8;
	*DID |= MySPI_SwapByte(W25Q64_DUMMY_BYTE);//然后继续,之后。第三次交换,根据手册的规定,这是返回的是设备 ID 的低 8 位,我们也把它存在 DID 里。
	//5.最后,时序结束,我们来个 MySPI_Stop
	MySPI_Stop();
}

//写使能
void W25Q64_WriteEnable(void) {
	//在这里面,就很简单了。
	//1.Start 起始
	MySPI_Start();
	//2.交换发送一个字节
	MySPI_SwapByte(W25Q64_WRITE_ENABLE);//指令码,就是 WRITE_ENABLE,写使能
	//3.这个指令后续不需要跟任何数据,所以直接 Stop,这样就完成了。
	MySPI_Stop();
}

//等待忙
void W25Q64_WaitBusy(void) {//这个函数我们就直接叫 WaitBusy,就是等待 BUSY 为 0 的意思
	uint32_t Timeout = 100000;//循环之前,给 Timeout 赋个初始值,这个初始值可以自己实测一下,稍微给大点,比如 100000
	
	//1.在这里面,先 Start
	MySPI_Start();
	
	//2.然后发送指令
	MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1);//指令码是读状态寄存器 1
	
	//3.随后我们发送 W25Q64_DUMMY_BYTE 接收数据,返回值是 状态寄存器 1,我们把它 &0x01,用掩码取出最低位
	while((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01) {
		Timeout--;//然后在 while 循环里,每循环一次,Timeout--
		if (Timeout == 0) {//如果 Timeout 自减到 0 了
			//在 break 之前,你也可以跳到自定义的错误处理函数,这个看你的需求加就行。
			break;//就 break 超时退出,不等了
		}
	}
	
	//4.最后,我们来个 Stop,终止时序
	MySPI_Stop();
}

//页编程
void W25Q64_PageProgram(uint32_t Address, uint8_t* DataArray, uint16_t Count) {
//1.写使能
	W25Q64_WriteEnable();
	
	uint16_t i = 0;
//2.然后函数内部,拼接时序,先 Start
	MySPI_Start();
//3.再发送指令
	MySPI_SwapByte(W25Q64_PAGE_PROGRAM);//指令码是页编程
//4.之后,我们还要继续交换发送 3 个字节的地址,地址是高位字节先发送
	MySPI_SwapByte(Address >> 16);//第一次发的,是 Address,3 个字节里的最高位字节。
	MySPI_SwapByte(Address >> 8);//第二次,就是中间的字节
	MySPI_SwapByte(Address);//第三次,就是最低位的字节。
	//这样连续发送 24 位地址就完成了
//5.看一下手册,根据指令规定,地址发完,就可以依次发送写入的数据了
	for (i = 0; i < Count; i++) {//那我们要写入 Count 个数据,显然来个循环,就再合适不过了。
		MySPI_SwapByte(DataArray[i]);//继续交换发送,第 i 次写入的,是 DataArray,第 i - 1 个数据
	}
	//这样依次来进行,即可在指定地址开始,写入多个字节
//6.最后写完,来个 Stop
	MySPI_Stop();
	
//7.事后等待
	W25Q64_WaitBusy();
}

//扇区擦除
void W25Q64_SectorErase(uint32_t Address) {//只需要指定一个 24 位地址就行了
	//1.写使能
	W25Q64_WriteEnable();
	//2.先起始
	MySPI_Start();
	//3.发送指令
	MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB);//指令码是扇区擦除
	//4.之后指定地址
	MySPI_SwapByte(Address >> 16);
	MySPI_SwapByte(Address >> 8);
	MySPI_SwapByte(Address);
	//5.最后停止
	MySPI_Stop();
	//6.事后等待
	W25Q64_WaitBusy();
}

//读取数据
void W25Q64_ReadData(uint32_t Address, uint8_t* DataArray, uint32_t Count) {
	uint32_t i = 0;
//1.先起始
	MySPI_Start();
//2.发送指令
	MySPI_SwapByte(W25Q64_READ_DATA);//指令码是读取数据
//3.之后指定地址
	MySPI_SwapByte(Address >> 16);
	MySPI_SwapByte(Address >> 8);
	MySPI_SwapByte(Address);
//4.然后根据协议规定 ,从这里开始,我们就要开始读了,怎么读呢?
	for (i = 0; i < Count; i++) {//那这个过程,我们还是套一个 for 循环。
		DataArray[i] = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//抛砖引玉的代码,发送 FF,置换会有用的数据。它的返回值,就是读到的数据,我们把它放到 DataArray 的第 i 个位置
	}
	//这样依次来进行,在每次调用交换读取之后,存储器芯片内部地址指针自动自增,依次返回指定地址开始,往后线性区域地址下的数据。
	//在 STM32 里,我们肯定也是把这一个个的数据流,依次存在一个线性地址的数组里,对吧。
	
//5.读完之后,来个 Stop
	MySPI_Stop();
}

//那到这里,我们基本的函数就写完了。

最后到主函数来执行测试。

main.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "W25Q64.h"

//定义两个存 ID 号的变量
uint8_t MID;
uint16_t DID;

//定义两个数组
uint8_t ArrayWrite[] = {0xA1, 0xB2, 0xC3, 0xD4};//要写入的数组,里面可以给一些数据,这里给 4 个数,测试一下就行。
uint8_t ArrayRead[4];//读取的数组,我们给 4 个内容

int main(void) {
	OLED_Init();
	W25Q64_Init();
	
	OLED_ShowString(1, 1, "MID:   DID:");
	OLED_ShowString(2, 1, "W:");//代表写的数据
	OLED_ShowString(3, 1, "R:");//代表读的数据
	
	W25Q64_ReadID(&MID, &DID);//把两个变量的地址传递进去,等函数执行完,ID 号我们就拿到了
	OLED_ShowHexNum(1, 5, MID, 2);
	OLED_ShowHexNum(1, 12, DID, 4);
	
	//然后我们执行写入测试,我们要在 00 位置开始写。
//1.那写之前,先擦除扇区
	W25Q64_SectorErase(0x000000);//参数指定地址,给个 0x000000,表示第 0 个地址,这里地址虽然是精确到某个字节了,我们最好还是把它对齐到扇区的起始地址。
//2.那继续,擦除之后,我们可以开始写入了。
	W25Q64_PageProgram(0x000000, ArrayWrite, 4);//起始地址,写入数组,写入数量
//3.这样写入就完成了,写完之后,我们再给它读出来
	W25Q64_ReadData(0x000000, ArrayRead, 4);//起始地址,读取数组,读取数量
	//这样读写测试就写好了,我们显示看一下
	
	OLED_ShowHexNum(2, 3, ArrayWrite[0], 2);
	OLED_ShowHexNum(2, 6, ArrayWrite[1], 2);
	OLED_ShowHexNum(2, 9, ArrayWrite[2], 2);
	OLED_ShowHexNum(2, 12, ArrayWrite[3], 2);
	//这样写入数据就显示完成了
	
	OLED_ShowHexNum(3, 3, ArrayRead[0], 2);
	OLED_ShowHexNum(3, 6, ArrayRead[1], 2);
	OLED_ShowHexNum(3, 9, ArrayRead[2], 2);
	OLED_ShowHexNum(3, 12, ArrayRead[3], 2);
	//这样读取到的数据就显示完成了
	
	while(1){

	}
}

上一小节,我们总结了扇区地址的规律,每个扇区的地址都是 xxx000 到 xxxFFF,所以只要末尾 3 个十六进制数是 0,它就肯定是扇区的起始地址,这里后面 3 位,是扇区内的地址,这 3 位无论怎么变,它都是在一个扇区里的,所以后面 3 位你随便写,擦除的都是同一个扇区。但一般,我们最好指定扇区起始地址来擦除,这样意义更明确。

我们编译下载看一下,可以看到,第 2 行,写入 1、2、3、4;第 3 行,读取也为 1、2、3、4,没问题,我们改一个数据试一下。比如写入数组改成 A1、B2、C3、D4,下载看一下,读出 A1、B2、C3、D4,没问题,这证明我们的读写基本没问题。

接着我们进一步来进行一些测试。

  1. 首先验证掉电不丢失,我们把两条擦除和写入的代码注释掉。先编译下载一遍看看。现在只有读取,数据目前还是 A1、B2、C3、D4,我们断电,重启,可以看到,读取的仍为 A1、B2、C3、D4,说明数据是掉电不丢失的。
  2. 之后我们再验证一下 Flash 擦除之后变为 FF 的特性,我们解除擦除的注释,只擦除,不写入。看一下,下载,可以看到,读取的数据全是 FF,说明 Flash 擦除之后变为 FF。
  3. 之后我们再验证一下 Flash 只能 1 写 0,不能 0 写 1 的特性。我们解除写入的注释,先写入 AA、BB、CC、DD,下载看一下,这是有擦除的写入,写入什么,读出什么,数据不会出错,然后我们继续测试,注释掉这里的擦除,我们尝试一下不擦除,直接改写,会发生什么现象呢。原来这里写入的是 AA、BB、CC、DD,我们想直接改写为 55、66、77、88,会发生什么呢?下载看一下,可以看到,我们写入 55、66、77、88,读出来的却是 00、22、44、88,是不是就出错了,那 AA 直接改写为 55,为什么会变成 00,上一小节已经解释过了。至于后面这 3 个数据,为什么是这 3 个数,这个就留给大家自己验证了。实际上如果不执行擦除,读出的数据 = 原始的数据&写入的数据,大家可以验证一下。

那通过这个实验我们就知道了,写入数据前必须擦除,否则,直接覆盖改写的数据,大概率是错的。

  1. 最后,我们再来验证一下,写入数据不能跨页的现象。擦除注释解除。上一小节我们也研究了页地址的规律,页地址的范围是 xxxx00 到 xxxxFF,所以这里,最后两个十六进制数是页内地址,前面四位是页地址,我们可以给最后两位 FF,这就是一页的最后一个地址,从最后一个地址开始写,看看能不能成功跨页到下一页的 0x000100,之后读取数据,也是从 FF 开始读。编译下载看一下,这时可以看到,写入 55、66、77、88,读出的却是 55、FF、FF、FF,这说明写入并没有跨页,这个 55 写入到地址 0FF,后面的 66 并没有跨页到下一个地址 100,而是返回第一页的页首 000 了,又因为读取是能跨页的,所以读到的这 3 个 FF 是第二页的数据。第二页是擦除的,没有写入,所以默认是 FF,那我们再把读取,改到第一页的起始位置 00。看一下,之前写入的后 3 个字节,是不是在第一页的起始位置。编译下载看一下,可以看到,果然,这 3 个字节是在第一页的起始位置。

这些步骤表示就是:存储器的存储空间,第一页开始地址是 00,第一页结尾地址是 FF;第二页开始地址是 100,我现在从第一页的最后一个字节 FF 开始写,连续写入 4 个字节,第一个数据 55,肯定是在指定位置了,关键就是下一个字节,由于地址 FF 到 100 这之间,跨越了页边沿,这个存储器是不能跨页写入的,所以下一个数据,不是按照地址连续的关系,写到 100 里,而是会回到第一页的页首,也就是 00 位置开始写,这里 66、77、88 这样来执行的,这就是页编程的注意事项,不能跨页写入。

如果你确实有一个很大的数组要连续写入,那就只能自己从软件上,分批次进行写入了。就是先计算,你的数组总共需要跨多少页,然后该擦除的擦除,最后再分批次,一页一写。这个操作可以封装成一个函数,之后调用封装的函数,就可以跨页连续写入了。这个功能,就留给大家自己进阶完成了。

那本小节我们的代码部分,到这里就要结束了。这个代码,先恢复成我们最开始演示的样子。

好,那我们本小节就到这里,下一小节,我们来开始学习 STM32 的硬件 SPI 外设,用硬件 SPI 的方式,来实现相同的功能

4. 一个 硬件 SPI 读写 W25Q64 功能案例

本小节我们来学习硬件 SPI 的代码部分。

4.1 硬件电路

那首先还是先看一下本节代码的接线图。
在这里插入图片描述
这个是硬件 SPI 的接线图,线路的连接,和之前软件 SPI 的是一样的。因为之前软件 SPI 的接线也是放在了硬件 SPI 的引脚上,所以这里线路就不用变化了。当然,软件 SPI 的引脚可以任意选择,而这里硬件 SPI 的引脚就不能任意选择了。硬件 SPI 的引脚怎么选择呢?我们还是得看一下引脚定义表,但凡涉及 STM32 内部硬件外设的引脚,基本上都得参考这个表,不能任意选择,因为硬件没有软件那么灵活,对吧。那在这个表,默认复用功能这一栏,我们找一下 SPI 相关的引脚,首先可以看到,SPI1 的相关引脚,SPI1 的 NSS,复用在了 PA4;SPI1 的 SCK,复用在了 PA5;MISO,复用在了 PA6;MOSI,复用在了 PA7。所以,如果你想使用 SPI1 这个外设,就得把相应的通信线接在 PA4、5、6、7 这四个引脚。当然这个 NSS,上小节说过,我们一般可以继续使用软件模拟的方式来实现,所以,NSS 没必要必须接在 PA4;其他 3 个引脚的话,就必须得是 PA5、6、7 了,这就是 SPI1 的引脚复用关系。

接着继续看,下面这一块,是 SPI2 的引脚,其中 SPI2 的 NSS,复用在了 PB12;SCK 是 PB13;MISO 是 PB14;MOSI 是 PB15。所以如果你要使用 SPI2 的外设,就必须得选这些引脚,这就是 SPI2 的引脚复用关系。最后在重定义这一栏,我们还可以看到,SPI1 还可以引脚重定义,如果你 SPI1 原来的 PA5、6、7 这些引脚正好被别的资源占用了,那你就可以考虑,把 SPI1 的引脚重定义到这个位置,重定义之后,SPI1 的 NSS 转移到了 PA15;SCK 转移到 PB3;MISO 转移到 PB4;MOSI 转移到 PB5,这就是重定义的功能,可以在引脚资源冲突的情况下转移外设复用的引脚。当然还要提醒一下,这个 PA15、PB3、PB4 这里并没有加粗,因为它们默认情况下,是作为 JTAG 的调试端口使用的,如果要使用它们原本的 GPIO 功能,或者是使用重定义的外设引脚功能,都需要先解除调试端口的复用,否则,GPIO 或者外设引脚,都不会正常工作。解除调试端口的方法,在 PWM 驱动那一节介绍过,不会的话,可以再看看。

好,那这些,就是 SPI1 和 SPI2 引脚的复用情况。我们计划使用 SPI1,所以 SCK 接 PA5,MISO 接 PA6,MOSI 接 PA7,NSS 可以接 PA4,当然也可以接到其他位置。

那看一下接线图,这里,PA5 是 SPI1 的 SCK,我们接到 SCK 引脚;PA6 是 MISO,我们接到 DO 引脚;PA7,是 MOSI,我们接到 DI 引脚;这几个引脚一定要接对,一一对应,互相也不要搞混了。然后,CS,我们就接到 PA4,这也是可以的。最后,VCC 和 GND 接上电源,给模块供电,这就是接线图。

好,到这里,硬件线路部分我们就完成了。接下来就是软件程序。

4.2 代码整体框架

我们的任务,就是修改软件 SPI 读写 W25Q64 工程底层的 MySPI.c 文件,把初始化和时序的执行步骤,由软件实现改成硬件实现。之后,基于通信层的这些业务代码,我们不需要进行任何更改,因为这些部分,只是调用底层的通信函数来实现功能,具体通信是怎么实现的,这个地方是不用管的。所以,我们把底层的实现,由软件改到硬件,也是不会影响到上层代码的,这就是代码隔离,封装的好处。

然后由于这个 SPI 的硬件实现,我们计划使用非连续传输的方案。这个方案非常简单,也容易封装,所以我们还是保留这个 MySPI 的模块,直接在里面更改代码即可。像上一节 I2C 的代码,我们是直接把 MyI2C 的模块移除了。那模块是不是要移除,是 分层独立,还是都放在一起,每一层代码都有哪些东西,这个得看你对工程的规划、还有你的喜好了。那这里保留这个 MySPI 的模块是非常方便的,所以就在这里,直接进行修改了。

那如何修改呢,我们看一下。

  1. 首先 SS 引脚,我们还是使用软件模拟,所以这个写 SS 的函数留着。然后下面三个软件读写 SPI 通信引脚的函数,我们就可以删掉了。

  2. 之后 MySPI 初始化这里,我们可以全都删掉,替换为 SPI 外设的初始化。

  3. 接着,软件写 SS 引脚,产生起始和停止信号的,这两个可以留着。

  4. 最后,交换字节函数里面的内容,我们全都删掉。

这样软件 SPI 操作时序的部分,我们就删掉了。接着,我们写上硬件 SPI 的代码,就行了。

硬件 SPI 的代码,实际上就是两部分。

  • 在初始化函数内部,写上 SPI 外设的初始化代码。
  • 在交换字节函数内部,写上 SPI 外设操作时序,完成交换一个字节的流程。

这就是我们的任务。那任务清楚了,我们看一下 SPI 基本结构,对于 SPI 初始化的流程,我们分为几步。

  1. 开启时钟,开启 SPI 和 GPIO 的时钟

  2. 初始化 GPIO 口。

其中 SCK 和 MOSI 是由硬件外设控制的输出信号,所以配置为复用推挽输出;MISO,是硬件外设的输入信号,我们可以配置为上拉输入,因为输入设备可以有多个,所以不存在复用输入这个东西,直接上拉输入就行。普通 GPIO 口可以输入,外设也可以输入;最后,还有 SS 引脚,SS 是软件控制的输出信号,所以配置为通用推挽输出,这就是 GPIO 口的初始化配置。

  1. 配置 SPI 外设。

这一块,是用一个结构体选参数即可。调用一下 SPI_Init,这里面的各种参数,比如,8 位/16 位数据帧、高位先行/低位先行、SPI 模式几、主机还是从机,等等等等,就都配置好了。

  1. 开关控制。

调用 SPI_Cmd,给 SPI 使能即可,这就是初始化的流程。

初始化之后,我们参考非连续传输的时序来执行运行控制的代码,这样就能产生交换字节的时序了。这些操作,涉及的具体函数,主要就是写 DR、读 DR 和获取状态标志位这些。

下一步,我们就是看一下库函数了。SPI 外设相关的库函数就是 spi.h 了。当然这里有很多函数都带了个 I2S,因为 SPI 和 I2S 是共用的一套电路,那我们不用 I2S,就当它不存在就行了。然后依次看一下,这些函数我们应该都已经很熟悉了。

void SPI_I2S_DeInit(SPI_TypeDef* SPIx);//恢复缺省配置
void SPI_Init(SPI_TypeDef* SPIx, SPI_InitTypeDef* SPI_InitStruct);//初始化*
void I2S_Init(SPI_TypeDef* SPIx, I2S_InitTypeDef* I2S_InitStruct);
void SPI_StructInit(SPI_InitTypeDef* SPI_InitStruct);//结构体变量初始化
void I2S_StructInit(I2S_InitTypeDef* I2S_InitStruct);
void SPI_Cmd(SPI_TypeDef* SPIx, FunctionalState NewState);//外设使能*
void I2S_Cmd(SPI_TypeDef* SPIx, FunctionalState NewState);
void SPI_I2S_ITConfig(SPI_TypeDef* SPIx, uint8_t SPI_I2S_IT, FunctionalState NewState);//中断使能
void SPI_I2S_DMACmd(SPI_TypeDef* SPIx, uint16_t SPI_I2S_DMAReq, FunctionalState NewState);//DMA 使能
//写 DR 和读 DR 的函数
void SPI_I2S_SendData(SPI_TypeDef* SPIx, uint16_t Data);//写 DR 数据寄存器。它把参数传进去的 Data 赋值给 DR,就是写数据到发送数据寄存器 TDR
uint16_t SPI_I2S_ReceiveData(SPI_TypeDef* SPIx);//读 DR 数据寄存器。SPI 的 DR 读出来,直接通过 return 返回,所以返回值就是接收数据寄存器 RDR。
//这些函数,我们都用得很少,了解即可
void SPI_NSSInternalSoftwareConfig(SPI_TypeDef* SPIx, uint16_t SPI_NSSInternalSoft);//NSS 引脚的配置
void SPI_SSOutputCmd(SPI_TypeDef* SPIx, FunctionalState NewState);
void SPI_DataSizeConfig(SPI_TypeDef* SPIx, uint16_t SPI_DataSize);//8位/16位数据帧的配置
void SPI_TransmitCRC(SPI_TypeDef* SPIx);//CRC 校验的一些配置
void SPI_CalculateCRC(SPI_TypeDef* SPIx, FunctionalState NewState);
uint16_t SPI_GetCRC(SPI_TypeDef* SPIx, uint8_t SPI_CRC);
uint16_t SPI_GetCRCPolynomial(SPI_TypeDef* SPIx);
void SPI_BiDirectionalLineConfig(SPI_TypeDef* SPIx, uint16_t SPI_Direction);//半双工时,双向线的方向配置
//获取标志位和清除标志位的函数
FlagStatus SPI_I2S_GetFlagStatus(SPI_TypeDef* SPIx, uint16_t SPI_I2S_FLAG);
void SPI_I2S_ClearFlag(SPI_TypeDef* SPIx, uint16_t SPI_I2S_FLAG);
ITStatus SPI_I2S_GetITStatus(SPI_TypeDef* SPIx, uint8_t SPI_I2S_IT);
void SPI_I2S_ClearITPendingBit(SPI_TypeDef* SPIx, uint8_t SPI_I2S_IT);

我们主要会用到 GetFlagStatus 来获取 TXE 和 RXNE 标志位的状态,再配合写 DR 和读 DR 的函数,这样就能控制时序的产生了。好,库函数就看到这里。

那目前铺垫的工作就完成了,我们来开始写程序。

  • 首先是初始化函数里面的代码
  1. 开启时钟。

我们要开启 SPI 和 GPIO 的时钟,目前使用的 GPIO 都是 A 端口,所以开启 GPIOA 的时钟。之后,我们计划使用 SPI1 这个外设,SPI1 也是 APB2 的外设,所以开启 SPI1 的时钟。这样第一步开启时钟就完成了。

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
  1. 配置相应的 GPIO 口。

首先,SS 从机选择引脚,是 PA4,我们计划还是使用软件模拟,所以 PA4,还是配置为通用推挽输出。下一步是 SCK 和 MOSI,这两个是外设控制的输出,我们要配置为复用推挽输出,看一下引脚定义,SCK 和 MOSI 分别是 PA5 和 PA7。最后一个引脚,是 MISO,要配置为上拉输入模式,看一下引脚定义,MISO 是 PA6 引脚。

GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;//通用推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平
	
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;//复用推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平
	
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;//上拉输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平

那到这里,GPIO 的模式就配置好了,这个模式种类还是比较多的,大家不要搞混了。

  1. 初始化 SPI 外设

这个函数名称,写多了就能记住了,我就直接写了。

SPI_InitTypeDef SPI_InitStructure;
SPI_InitStructure.SPI_Mode = SPI_Mode_Master;//选择 SPI 的模式。这个参数决定当前设备是 SPI 的主机还是从机。Master,指定当前设备为主机;Slave,指定当前设备为从机。那我们肯定是选择主机了。
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;//用来配置 SPI 裁剪引脚这个功能的。可选的参数有 4 个,分别是单线半双工的接收模式;单线半双工的发送模式;双线全双工和双线的只接收模式。那当然,我们使用最多的,就是标准模式,双线全双工。
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;//配置 8 位还是 16 位数据帧。
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;//配置高位先行还是低位先行。最常用的就是 8 位数据帧、高位先行。
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128;//波特率预分频器,这个可以用来配置 SCK 时钟的频率。参数有很多分频系数,分别是 2、4、8、16、32、64、128、256 这 8 种分频系数。我们上一小节也介绍过。
	//PCLK 的频率/分频系数,就是 SCK 的时钟频率。分频系数越大,SCK 时钟频率越小,传输越慢。这个可以根据你的实际需求来选,那我们这里就选一个慢一点的参数,比如 128。目前 SCK 的时钟频率就是 72MHz/128,大家可以算一下,大概是 500多 MHz。
	//当然我们这是 SPI1 的外设,所以是 72 MHz/128 计算频率,如果你是 SPI2 的外设,同样的参数,就得是 36 MHz / 128 计算频率了。因为 SPI1 是 APB2 的外设,SPI2 是 APB1 的外设,那这就是时钟频率。
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low;//时钟极性,用来配置 SPI 模式的。参数有两个,一个是 High,默认高电平;一个是 Low,默认低电平。High,其实就是 CPOL = 1;Low,其实就是 CPOL = 0。那我们计划选择 SPI 模式 0,空闲默认是低电平,所以选择 Low 这个参数。
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge;//时钟相位,用来配置 SPI 模式的。参数也是两个,一个是 1Edge,就是第一个边沿开始采样;一个是 2Edge,就是第二个边沿开始采样。1Edge 就是 CPHA = 0,2Edge 就是 CPHA = 1.
	//可以发现,这些 SPI 的设备都只喜欢说第几个边沿采样,而不说第几个边沿输出数据。当然我便于理解,说的是第一个边沿移出,第二个边沿移入。这里我说的移入就相当于它们说的采样,是一个意思,大家注意理解。
	//那这里,我们选择 SPI 模式 0,CPHA = 0,所以选择 1Edge 这个参数,这就是 SPI 4 种模式的选择。当然这里选择模式 0 和模式 3 都可以,我们目前选择的是模式 0.
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;//NSS 模式。参数里有 Hard,硬件 NSS 模式和 Soft,软件 NSS 模式。那 NSS 引脚,我们计划是使用 GPIO 模拟,这个外设的 NSS 引脚,我们并不会用到。所以这个参数一般选择软件 NSS 就行了。这个不用过多关心。
SPI_InitStructure.SPI_CRCPolynomial = 7;//CRC 校验的多项式。这里参数需要我们填一个数,填多少都可以,反正我们不用。我们就填它给的一个默认值 7,就行了。这个也不用过多了解。
//好,到这里,我们这个结构体的参数就选好了。其实可以看到,大多数的参数,都是一些常用的配置,基本上是不用改的,需要改的,可能也就是 SCK 时钟频率和 SPI 的模式这 3 个参数吧。所以这个初始化,应该也不难理解吧
SPI_Init(SPI1, &SPI_InitStructure);

把参数取值列出来你肯定就知道了。当然有可能有一些参数其实并不是这个结构体的参数,是和别的函数的参数重名了。

初始化函数根据结构体的参数来自动配置相应的寄存器,这就是 SPI 初始化的步骤。

  1. 使能 SPI 外设
SPI_Cmd(SPI1, ENABLE);

这样 SPI 外设就准备就绪了。当然开启 SPI 之后,我们还要做一个事,就是调用这个

MySPI_W_SS(1);

默认给 SS 输出高电平,默认是不选中从机的。

整体代码:

void MySPI_Init(void) {//在这里面,我们来初始化 SPI 的通信引脚
	
	//我们要开启 SPI 和 GPIO 的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
	
	//配置相应的 GPIO 口。
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;//通用推挽输出
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;//复用推挽输出
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;//上拉输入
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);//GPIO 配置好默认低电平
	
	//SPI 初始化
	SPI_InitTypeDef SPI_InitStructure;
	SPI_InitStructure.SPI_Mode = SPI_Mode_Master;//选择 SPI 的模式。
	SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;//用来配置 SPI 裁剪引脚这个功能的。
	SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;//配置 8 位还是 16 位数据帧。
	SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;//配置高位先行还是低位先行。最常用的就是 8 位数据帧、高位先行。
	SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128;//波特率预分频器。
	SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low;//时钟极性,用来配置 SPI 模式的。
	SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge;//时钟相位,用来配置 SPI 模式的。
	SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;//NSS 模式。
	SPI_InitStructure.SPI_CRCPolynomial = 7;//CRC 校验的多项式。
	SPI_Init(SPI1, &SPI_InitStructure);
	
	//使能 SPI 外设
	SPI_Cmd(SPI1, ENABLE);
	MySPI_W_SS(1);//默认给 SS 输出高电平,默认是不选中从机的。
}

这样,我们整个 SPI 初始化函数,就写好了,就是这么多内容。那初始化之后,SPI 外设就绪,我们就可以来完成这个交换字节的函数了,当我们调用这个交换字节的函数,硬件的 SPI 外设就要自动控制 SCK、MOSI、MISO 这 3 个引脚来生成时序了。

怎么生成呢?我们看一下非连续传输的是时序图。上一小节也说过,通常情况下,就是 4 步。

  1. 第一步,等待 TXE 为 1,发送寄存器为空。如果发送寄存器不为空,我们就先不要着急写。

所以代码这里,第一步,就是调用函数

while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE)!= SET);

那要实现等待 TXE 的效果,我们就还是套一个 while 循环。如果读取 TXE 标志位,它 != SET,while 条件为真,进入循环等待,这样就能实现等待 TXE 为 1 的功能了。另外这里,你也可以写 == RESET;或者后面什么都不写,函数前面加个 ! 号,这些写法都行,都可以实现功能。

那这个 while 循环呢,只要 SPI 外设电路不损坏,基本上是不会一直处于卡死的状态,因为只要 TDR 有数据,它就会自动转到移位寄存器开始发送,过一会肯定就发完了,不会受外部电路的影响。所以这个 while 循环,一直卡死的概率不大,我们就不加超时等待的机制了。当然你要是不放心,加一下超时等待,那也是完全没问题的。

好,那等待 TXE 为 1 的代码就是这样。

  1. 第二步,我们就执行软件写入数据至 SPI_DR。

如何写入数据呢,我们刚才才看的库函数对吧。显然是调用这里的

SPI_I2S_SendData(SPI1, ByteSend);

第二个参数,是要写入到 DR,也就是 TDR 的数据,TDR 是要发送的数据,所以显然,我们要把这个 ByteSend 参数传进去。传入 ByteSend 之后,ByteSend 写入到 TDR,之后,ByteSend 自动转入移位寄存器,一旦移位寄存器有数据了,时序波形就会自动产生。这个波形生成,不需要我们再调用个什么函数,说让它开始传输的,我们只管写入数据到 TDR,之后转移到移位寄存器生成波形这个过程是自动完成的,那自动生成之后,ByteSend 这个数据,就会通过 MOSI 一位一位的移出去,在 MOSI 线上就会自动产生这个发送的时序波形。

然后由于我们这个是非连续传输,所以时序产生的这段时间,我们就不必提前把下一个数据放到 TDR 里了,这一段时间我们就直接等死过去就行,那得等到什么时候,这一个字节的时序才会完成呢?这个,我们可以注意到,在发送的同时,MISO 还会移位进行接收,发送和接收是同步的,到接收移位完成了是不是也就代表发送移位完成了,接收移位完成时会收到一个字节数据,这时会置标志位 RXNE。 所以

  1. 第三步,我们只需等待 RXNE 出现就行了。
while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE)!= SET);

等待的标志位,是 RXNE,等 RXNE 为 1 了,表示收到一个字节,同时也表示发送时序产生完成了。那既然 RXNE = 1 了 ,显然

  1. 第四步,就是读取 DR。

从 RDR 里,把交换接收的数据读出来。所以,代码,我们就是调用函数

SPI_I2S_ReceiveData(SPI1);

这个函数有个返回值,返回值就是 RDR 接收的数据,我们直接 return,把这个置换接收的数据,通过这个返回值输出出去。

整体代码:

uint8_t MySPI_SwapByte(uint8_t ByteSend) {
	
	//第一步,等待 TXE 为 1
	while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE)!= SET);
	
	//第二步,写发送的数据至 TDR
	SPI_I2S_SendData(SPI1, ByteSend);
	
	//第三步,等待 RXNE 为 1
	while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE)!= SET);
	
	//第四步,读取 RDR 接收的数据
	return SPI_I2S_ReceiveData(SPI1);
}

这样,通过硬件 SPI,交换一个字节的程序是不是就写完了。总共四步:第一步,等待 TXE 为 1;第二步,写发送的数据至 TDR,一旦 TDR 写入数据了,时序就会自动生成;第三步,等待 RXNE 为 1,发送完成,即接收完成,RXNE 置 1;第四步,读取 RDR 接收的数据,就是置换接收的一个字节。

这样简单的 4 步,就完成了 SPI 一个字节的交换,在这里面,我们并不需要像软件 SPI 那样,手动给 SCK,MOSI 置高低电平,也不用关系怎么把数据位一个个取出来,这些工作,硬件电路会自动帮我们完成。

另外,还有个注意事项就是,这里的硬件 SPI,必须是发送,同时接收,要想接收,必须得先发送,因为只有你给 TDR 写东西,才会触发时序的生成;如果你不发送,只调用接收函数,那时序是不会动的。

然后还有个注意事项就是,TXE 和 RXNE,是不是会自动清除的问题,因为我在手册的主模式全双工连续传输的时序图上,看到写的是 TXE 标志由硬件设置并由软件清除;下面 RXNE 写的也是,由硬件设置,由软件清除。这个由软件清除就比较迷惑,是不是要求我们在标志位置 1 之后,还需要我们手动调用 ClearFlag 函数清除呢?
实际上这个并不需要我们手动清除。我们可以参考一下手册,在状态标志这一节,这里写了发送缓冲器空闲标志(TXE),此标志为 1 时表明发送缓冲器为空,可以写下一个待发送的数据进入缓冲器中,当写入 SPI_DR 时,TXE 标志被清除。所以在程序这里,我们等待 TXE 标志位置 1 之后,不需要再手动调用一个 ClearFlag 函数清除 TXE 标志位了,因为写入 DR 时,会顺便执行清除 TXE 的操作,而我们下一句代码,就正好是写入 DR,所以这个标志位不需要我们手动清除了。然后 RXNE 标志位,也是一样,读 SPI 数据寄存器可以清除此标志,在程序这里,等待 RXNE 为 1 之后,下一个操作,就正好是读取 DR,所以 RXNE 标志位也不需要我们手动清除了。这一个功能,其实之前的串口和 I2C 都是一样的,写入 DR,顺便清除 TXE;读取 DR,顺便清除 RXNE,程序中,不需要我们额外手动清除这些标志位了,这个理解一下。

因为这个 STM32 中,大多数标志位其实都还是需要我们手动清除的,比如中断标志位,进中断之后,必须得清除,否则中断就会一直进入,导致主程序不能执行,而少部分标志位,在执行顺序操作的时候,可以顺便清除,这个可以方便我们操作,至于哪些标志位可以顺便清除,这个还是得仔细看一下手册。

好,这就是这个程序,我们其实就已经写完了。整体看下来,应该也还好理解吧。

那我们就来验证一下,看看这个代码,能不能和软件 SPI 一样,仍然实现读写 W25Q64 的功能。编译下载看一下,读取 ID 号,EF,4017,下面写入 01、02、03、04,读取也是 01、02、03、04,这说明我们硬件 SPI,也是完全没问题的。好,到这里,我们这个硬件 SPI 的程序就修改完成了。这小节的任务,还是比较轻松的,那我们 SPI 的章节到这里也就全部结束了。

  • 17
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值