江科大STM32终端开发教程

目录

  • SPI协议
  • 10.1 SPI简介
  • W25Q64简介
  • 10.3 SPI软件读写W25Q64
  • 10.4 SPI硬件外设读写W25Q64
  • BKP备份寄存器、PER电源控制器、RTC实时时钟
  • 11.0 Unix时间戳
  • 代码示例:读写备份寄存器BKP
  • 11.2 RTC实时时钟
  • 十二、PWR电源控制
  • 12.1 PWR简介
  • 代码示例:修改主频
  • 12.3 串口数据收发+睡眠模式
  • 12.4 停止模式
  • 12.5 待机模式:
  • 十三、看门狗WDG
  • 13.1 WDG简介
  • 13.2 窗口看门狗WWDG
  • 代码示例:实现IWDG
  • 13.4 实现WWDG
  • STM32内部FLASH闪存
  • STM32内部FLASH闪存简介
  • 示例代码:读写内部FLASH&读取芯片ID
  • SPI协议

    10.1 SPI简介

    I2C和SPI两者是各有优势和劣势的,在某些芯片呢我们用I2C更好,在另一些芯片呢我们用spi更好,上一节我们学习I2C的时候,可以发现,I2C无论是硬件电路还是软件时序,设计的都是相对比较复杂的,硬件上我们要配置为开漏外加上拉的模式,软件上我们有很多功能和要求,比如一根通信线兼具数据收发,应答位的收发寻址机制的设计等等,最终通过这么多的设计,就使得I2C通信的性价比非常高,I2C可以在消耗最低硬件资源的情况下,实现最多的功能,在硬件上无论挂载多少个设备,都只需要两根通讯线,在软件上数据双向通信,应答位都可以实现,既实现硬件上最少的通讯线,又实现软件上最多的功能,也隐藏了一个缺点,就是I2C开漏外加上拉电阻的电路结构,使得通信线高电平的驱动能力比较弱,这就会导致通信线由低电平变到高电平的时候,这个上升沿耗时比较长,这会限制I2C的最大通讯速度,所以I2C的标准模式只有100KHz的时钟频率,I2C的快速模式也只有400KHz,虽然I2C协议之后通过改进电路的方式,设计出高速模式可以达到3.4MHz,但是高速模式目前普及程度不是很高,一般情况下我们认为I2C的时钟速度最多就是400KHz,这个速度相比较spi而言还是慢了很多的。

    简单概括几点spi相对于I2C的优缺点
    首先spi传输更快,spi协议并没有严格规定最大传输速度,这个最大传输速度取决于芯片厂商的设计需求,比如说我们这个w25q64 存储器芯片,手册里写的spi时钟频率最大可达80MHz,这比stm32f1的主频还要高,其次spi的设计比较简单粗暴,实现的功能I2C那么多,所以学习起来spi还是比I2C简单很多的,最后spi的硬件开销比较大,通信线的个数比较多。

    spi的基本特征是同步全双工,首先这是同步时序,肯定就得有时钟线了,所以这个sck硬件就是用来提供时钟信号的,数据位的输出和输入,都是在sck的上升沿或下降沿进行的,这样数据位的收发时刻就可以明确的确定,并且同步时序时钟快点慢点或者中途暂停一会儿都是没问题的,这是同步时序的好处。

    硬件电路:
    所有SPI设备的SCK、MOSI、MISO分别连在一起
    主机另外引出多条SS控制线,分别接到各从机的SS引脚

    然后我们看一下这几根通讯线,首先scl时钟线,时钟线完全由主机掌控,所以对于主机来说,时钟线为输出,对于所有从机来说,时钟线都为输入,这样主机的同步时钟就能送到各个从机了,然后下一个mosi主机输出从机输入,这边左边是主机,所以就对应mo主机输出,下面三个都是从机,所以就对应si从机输入,数据传输方向是主机通过mosi输出,所有从机通过mosi输入,接着下一个miso主机输入从机输出,左边是主机对应mi,下面三个是从机,对应so,数据传输方向是三个从机,通过miso输出,主机通过miso输入。
    从机选择,为了确定通信的目标,主机就要另外引出多条SS控制线,分别接到各从机的SS引脚下面
    下面这里有三个从机,需要主机另外引出三根SS选择线,主机的SS线都是输出,从机的SS线都是输入,SS线是低电平有效,主机想指定谁,就把对应的SS输出线置低电平就行了,比如主机初始化之后,所有的SS都输出高电平,这样就是谁也不指定,当主机需要和比如从机1进行通信了,主机就把SS1线输出低电平,不需要像I2C一样进行寻址,是不是挺简单的。

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

    移位示意图


    这个移位示意图是spi硬件电路设计的核心,只要你把这个移位示意图搞懂了,那无论是上面的硬件电路,还是我们等会学习的软件时序,理解起来都会更加轻松,我们看一下spi的基本收发电路,就是使用了这样一个移位的模型,左边是spi主机,里面有一个八位的移位寄存器,右边是spi从机,里面也有一个八位的移位寄存器,这里移位计算器有一个时钟输入端,因为spi一般都是高位先行,所以每来一个时钟,移位寄存器就会向左进行移位,从机也是同理,然后移位寄存器的时钟源是由主机提供的,这里叫做波特率发生器,它产生的时钟驱动主机的移位寄存器进行移位,同时这个时钟也通过SCK引脚进行输出接到从机的移位寄存器里,之后上面移位寄存器的接法是主机移位寄存器左边移出去的数据,通过mosi引脚输入到从机移位寄存器的右边,从机移位寄存器左边移出去的数据通过miso引脚输入到主机移位寄存器的右边,这样组成一个圈。

    接下来我来演示一下这个电路如何工作,首先我们规定波特率发生器时钟的上升沿,所有移位寄存器向左移动一位,移出去的位放在引脚上,波特率发射器时钟的下降沿,引脚上的位采样输入到移位寄存器的最低位,接下来假设主机有个数据,10101010要发生到从机,同时从机有个数据01010101要发送到主机,那我们就可以驱动时钟,先产生一个上升沿,这时所有的位就会像这样往左移动一次,那从最高位移出去的数据,就会这样放到通信线上,数据放到通信线上啊,实际上是放到了输出数据寄存器,可以看到此时mosi数据是1,所以mosi的电平就是高电平,miso的数据是0,所以miso的电平就是低电平,就是第一个时钟上升沿执行的结果,就是把主机和从机中移位寄存器的最高位,分别放到mosi和miso的通信线上,这就是数据的输出,之后时钟继续运行,上升沿之后,下一个边沿就是下降沿,在下降沿时,主机和从机内都会进行数据采样输入,也就是mosi的1,会采样输入到从机这里的最低位,miso的0会采样输入到主机这里的最低位,这是第一个时钟结束后的现象,那时钟继续运行下一个上升沿,同样的操作,移位输出,主机现在的最高位,也就是原始数据的次高位输出到miso,从机现在的最高位输出到miso,随后下降沿数据采样输入,mosi数据到这里,miso数据到这里,一直到第八个时钟都是同样的过程,就实现了主机和从机一个字节的数据交换,实际上spi的运行过程就是这样,spi的数据收发都是基于字节交换,当主机需要发送一个字节,并且同时需要接收一个字节时,就可以执行一下字节交换的时序,这样主机要发送的数据跑到从机,主机要从从机接收的数据跑到主机,这就完成了发送同时接收的目的。

    那你可能会问,如果只想发送,不想接收怎么办呢,其实很简单,我们仍然调用交换字节的时序,发送同时接收,只是这个接收到的数据,我们不看它就行了,那如果我只想接收,不想发送怎么办呢,同理我们还是调用交换自己的时序,发送同时接收,只是我们会随便发送一个数据,只要能把从机的数据置换过来就行了,我们读取置换过来的数据不就是接收了吗,这里我们随便发过去的数据啊,从机也不会去看它,当然这个随便数据我们不会真的随便发啊,一般在接收的时候,我们会统一发送0x00或0x ff,以上就是spi的基本原理。

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

    SPI时序基本单元:

    接下来就是数据传输的基本单元了,这个基本单元什么时候开始移位,是上升沿移位还是下降沿移位,spi并没有限定死,可以配置选择,这样的话spi就可兼容更多的芯片,那在这里spi有两个可以配置的位,分别叫做cpol、cpha,每一位可以配置为一或零,总共组合起来就有模式零模式一模式二模式三这四种模式,当然模式虽然多,但是它们的功能都是一样的,在实际使用的时候,我们主要学习其中一种就可以了,剩下的模式你知道有这些东西可以配置,如果到时候真的需要用,再过来了解一下就行了。


    那么先看一下模式一,因为这个模式和我们刚才讲的移位模型是对应的,这个时序的基本功能是交换一个字节,也就是刚在这里我们展示的现象,这里cpol等于零,表示空闲状态时sck为低电平,下面可以看到在ss未被选中时,sck默认是低电平的,然后cpha等于1,表示sck第一个边沿移出数据,第二个边缘移入数据,但这句话也有不同的描述方式,有的地方写的是cpha等于1表示sck的第二个边沿进行数据采样,或者是sck的偶数边缘进行数据采样,这些不同的描述意思都是一样,我这里为了照应刚才的移位模型,我就写的是sck第一个边缘移出数据,第二个边沿移入数据,来看一下下面的时序图,第一个ss从机选择,在通信开始前,ss为高电平,在通信过程中ss始终保持低电平,通信结束ss恢复高电平,然后最下面一个miso,这是主机输入从机输出,刚才说了这里因为有多个从机输出连在了一起,如果同时开启输出会造成冲突,所以我们的解决方法是在ss未被选中的状态,从机的miso引脚必须关断输出,即配置输出为高阻状态,那在这里ss高电平时miso用一条中间的线表示高阻态,ss下降沿之后,从机的miso被允许开启输出,ss上升沿之后呢,从机的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,这个模式0和模式1的区别,就是模式0的cpha等于0,模式1的cpha等于1
    在时序上的区别对比一下,模式0的数据移出移入的时机会提前半个时钟,也就是相位提前了,我们看一下模式0 cpha等于0,表示sck第一个边沿移入数据,第二个边沿移出数据,模式0在sck第一个边缘就要移入数据,但数据总得先移出才能移入对吧,所以在模式0的配置下,sck第一个边沿之前就要提前开始移出数据了,或者把它称作是在第零个边沿移出在第一个边缘移入,看一下时序,首先ss下降沿开始通信,现在sck还没有变化,但是sck一旦开始变化,就要移入数据了,所以此时趁sck还没有变化,ss下降沿时就要立刻触发移位输出,所以这里mosi和miso的输出,是对齐到ss的下降沿的,或者说这里把ss的下降沿也当作时钟的一部分了,那ss下降沿触发的输出,sck上升沿就可以采样输入数据了,这样b7 就传输完毕,之后sck下降沿移出b6 ,sck上升沿移入b6 ,然后继续,下降沿移出数据
    ,上升沿移入数据,最终在第八个上升沿时,b0位移入完成整个字节交换完成,之后sca还有一个下降沿,如果主机只需要交换一个字节就结束,那在这个下降沿时mosi可以置回默认电平或者不去管它,miso也会变化一次,这一位实际上是下一个字节的b7,因为这个相位提前了,所以下一个字节的b7会露个头,如果不需要的话,ss上升沿之后从机miso置回高阻态,这是交换一个字节就结束,如果主机想交换多个字节的话,那就继续调用从这里到这里的时序,在最后一个下降沿主机放下一个字节的b7 ,从机也放下一个字节的b7, skc上升沿正好接着采样第二个字节的b7,这样时序才能拼接得上,就是spi交换一个字节模式零,模式零和模式一的区别就在于,模式零把这个数据变化的时机给提前了,在实际应用中,模式零的应用是最多的,所以我们重点掌握模式零即可,后续的程序都是基于spi模式零来讲解的,不过这里我感觉模式一是不是更符合常理,但实际确实是模式零用的最多,可能是spi设计的时候,为了兼容现存设备吧,或者是模式0在实际应用时确实有什么优势,或者因为模式零排在最前面,大家都默认最前面的模式吗,这个原因大家感兴趣的话可以调研一下。


    这个cpha表示的是时钟相位,决定是第一个时钟采样移入,还是第二个时钟采样移入,并不是规定上升沿采样还是下降沿采样的,当然在cpol确定的情况下,cpha确实会改变采样时刻的上升沿和下降沿
    ,比如模式0的时候是sck上升沿采样移入,模式1的时候是sck下降沿采样移入,cpha决定的是第几个边沿采样,并不能单独决定是上升沿还是下降沿,在这四种模式里,模式零和模式三都是sck上升沿采样,模式一和模式二都是sck下降沿采样。

    看几个SPI完整波形

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

    发送指令,向SS指定的设备,发送指令(0x06)

    指令0x06到底是什么意思呢,可以由芯片厂商自己规定,在w25q64 型面里,这个0x06 代表的是写使能,我们看一下这个模型,在这里我们使用的是spi模式0,在空闲状态是ss为高电平,sck为低电平,mosi和miso的默认电平没有严格规定,然后ss产生下降沿时序开始,在这个下降沿时刻,mosi和miso就要开始变换数据了,mosi由于指令码最高位仍然是0,所以这里保持低电平不变,miso从机现在没有数据发给主机,引脚电平没有变化,实际上w25q64不需要回弹数据时,手册里规定的是miso仍然是高阻态,从机并没有开启输出,不过这也没问题,反正这个数据我们也不要看,那这里因为stm32的miso是上拉输入,所以这里miso呈现高电平,之后sck第一个上升沿进行数据采样,我这里画了一条绿线,从机采样输入得到零,主机采样输入得到一,之后继续第二个时钟,主机数据仍然是零,所以波形仍然没有变化,然后这样一位一位的发送接收发送接收,到这一位数据才开始变化,主机要发送数据一,下降沿数据移出,主机将一移出到mosi,mosi变为高电平,这里因为是软件模拟的时序,所以mosi的数据变化有些延迟,没有紧贴sck的下降沿,不过这也没关系,时钟是主机控制的,我们只要在下一个sck上升沿之前完成变化就行了,然后sck上升沿数据采样输入,在最后一位呢下降沿数据变化mosi变为零,上升沿数据采样,从机接收数据0,sck低电平是变化的时期,高电平是读取的时期,这一块是不是和I2C差不多,那时序sck最后一个上升沿结束,一个字节就交换完毕了,因为写使能是单独的指令,不需要跟随数据,spi只需要交换一个字节就完事了,所以最后在sck下降沿之后,ss置回高电平结束通信,那这个交换我们统计一下,mos i和miso的电平,总结一下就是,主机用0x06换来了从机的0xff,但实际上从机并没有输出,这个0xf f是默认的高电平,不过这个0xf f没有意义,我们不用管,那整个时序的功能就是发送指令,指令码是0x06 ,从机一比对事先定义好的指令集,发现0x06是写使能的指令,那从机就会控制硬件进行写使能,这样一个指令从发送到执行就完成了,就是发送单字节指令的时序。


    指定地址写
    向SS指定的设备,发送写指令(0x02),随后在指定地址(Address[23:0])下,写入指定数据
    (Data)

    我们这个w25q64 芯片有8M字节的存储空间,一个字节的八位地址肯定不够,所以这里地址是24位的
    分三个字节传输,我们看一下时序,首先ss下降沿开始时序,mosi空闲时是高电平,所以在下降沿之后,sck第一个时钟之前可以看到mosi变换数据由高电平变为低电平,然后sck上升沿数据采样输入,后面还是一样下降沿变换数据上升沿采样数据,八个时钟之后一个字节交换完成,我们用0x02换来了0xff,其中发送的0x02是一条指令,代表这是一个写数据的时序,接收到0x ff不需要看,那既然是写数据的时序,后面必然还要跟着写的地址和数据,所以在最后一个下降沿时刻,因为我们后续还需要继续交换字节,所以在这个下降沿,我们要把下一个字节的最高位放到mosi上,当然下一个字节的最高位仍然是零,所以这里数据没有变化,最后还是同样的流程交换一个字节,第二个字节我们用0x12换来了0xff,根据w25q64 芯片的规定,写指令之后的字节定义为地址高位,所以这个0x12 就表示发送地址的23~16位,继续看一下交换一个字节,发送的是0x34 这个就表示发送地址的15~8位,最后还是交换一个字节发送的是0x56 ,这个表示发送地址的7~0位,通过三个字节的交换,24位的地址就发送完毕了,从机收到的24位地址是0x123456 ,那三位地址结束后,就要发送写入指定地址的内容了,我们继续调用交换一个字节,发送数据这里的波形是0x55 ,这个表示我要在0x123456 地址下,写入0x55 这个数据,最后如果只想写出一个数据的话,就可以ss置高电平结束通信了,当然这里也可以继续发送数据
    ,spi里也会有和I2C一样的地址指针,每读写一个字节地址指针自动加一,如果发送一个字节之后不终止继续发送的字节就会依次写入到后续的存储空间里,这样就可以实现从指定地址开始写入多个字节了,这就是spi写入的时序,由于spi没有应答机制,所以交换一个字节后,就立刻交换下一个字节就行了,然后这条指令我们还可以看出啊,由于整个流程我们只需要发送的功能,并没有接收的需求,所以miso这条接收的线路就始终处于挂机的状态,我们并没有用到,当然不同的芯片肯定有不同的规定,我们这个存储器的容量大,所以需要连续制定三个字节的地址,如果容量小的话,可能一个字节的地址就够了,或者有的芯片会直接把地址融合到指令码里去,这也是可以的哈,至于具体怎么操作的,还是得仔细分析一下手册。




    指定地址读
    向SS指定的设备,发送读指令(0x03),随后在指定地址(Address[23:0])下,读取从机数据
    (Data)

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



    W25Q64简介

    低成本,也就是说这个芯片一般也就几块钱。更换不通过型号,我们的硬件电路和底层驱动程序都不需要更改,所以我们学会了其中一个型号,在应用同系列的其他型号就很容易上手了。

    字库存储这个可以应用到一些显示屏上,比如我们这个oled显示屏或者lcd液晶屏,你如果想在屏幕上显示汉字,就得把汉字的点阵数据存起来,当然简单的方法是,把字库直接存在stm32内部
    这样适合少量汉字显示的情况,如果汉字非常多,再直接存在s t m32 中就不合适了,所以我们可以用这个芯片来存储汉字,在显示某个汉字之前,先读取芯片查询字库,再在显示屏上显示对应的点阵数据。
    固件程序存储,这个就相当于直接把程序文件下载到外挂芯片里,需要执行程序的时候,直接读取外挂芯片的程序文件来执行,这就是xip就地执行,比如我们电脑里的bios固件,就可以存储在这个系列的芯片里。

    这个芯片的存储介质是Nor Flash,flash就是闪存存储器,像我们stm32的程序存储器、u盘、电脑里的固态硬盘等使用的都是flash闪存,闪存分为Nor Flash和Nand Flash,两者各有优势和劣势,适用领域不同,这个感兴趣的话可以百度了解一下。

    时钟频率,我们这个芯片使用的是spi通信,其中spi的sck线就是时钟线,这个时钟线的最大频率是80MHz,这个频率相比较stm32是非常快的,所以我们在写程序的时候翻转引脚就不用再加延时了,即使不延时这个GPIO的翻转频率也不可能达到80MHz,所以可以放心使用,然后后面这还有两个频率,分别是160MHz这个是双重spi模式等效的频率,320MHz这个是四重spi模式等效的频率,这个双重spi和四重spi大家了解一下即可,我们本课程不会用到,那他们是什么意思呢,就是我们之前说的mosi用于发送,miso用于接收,是全双工通信,在只发或只收时有资源浪费,但是这个w25q芯片的厂商不忍心浪费,所以就对spi做出了一些改进,就是我在发的时候,我可以同时用mosi和miso发送,在收的时候也可以同时用mosi和miso接收,mosi和miso同时兼具发送和接收的功能,一个sck时钟我同时发送或接收两位数据,就是双重spi模式,那你一个时钟收发两位相比较一位一位的普通spi数据传输率就是二倍了,所以这里写的是在双重spi模式下,等效的时钟频率就是80MHz的二倍就是16MHz,但实际上这个频率最大还是80MHz,只是我一个时钟发两位而已,然后四重spi模式,很显然就是一个时钟发送或接收四位,等效的频率就是80×4=320MHz,在我们这个芯片里啊,除了spi通信引脚,还有两个引脚,一个是wp写保护,另一个是hold,这两个引脚如果不需要的话,也可以拉过来充当数据传输引脚,加上mosi和miso就可以四个数据位同时收发了,就是四重spi,其实这就有点并行传输的意思了,串行是根据时钟一位一位的发,并行是一个时钟八位同时发送,所以这个四重spi模式,其实就是四位并行的模式,这个大概了解一下就行。

    这个芯片使用的是24位的地址,24位地址是三个字节,因为我们在进行读写的时候,肯定得把每个字节都分配一个地址,这样才能找到它们,上小节讲时序的时候也提到过,这里在指定地址时需要一次性指定三个字节,24位的地址,然后我们可以用计算器算一下,24位的地址最大能分配多少个字节呢
    ,这里2的24次方等于这么多个字节数,那除1024等于这么多kb,再除1024=16MB,所以24位地址的最大寻址空间是16MB,那ppt中w25q40到q128使用三字节24位的地址都是足够的,但是这个w25q256就比较尴尬了,24位地址对于32MB来说是不够的,所以这最后一个型号比较特殊,根据手册里描述w25q256分为三字节地址模式和四字节地址模式,在三字节地址模式下,只能读写前16MB的数据,后面16MB 3个字节的地址够不着,要想读写到所有存储单元,可以进入四字节地址的模式这样就行了。

    看一下这个芯片的硬件电路,当我们拿到这个八脚的芯片后,怎么把它和stm32连接在一起呢,我们看一下,左边这个图是我们这个小模块的原理图,右上角这个图就是这个芯片的引脚定义,右下角这个表就是每个引脚定义的功能呢,首先看一下引脚定义,VCC、GND是电源供电,引脚供电电压是2.7~3.6V,是一个典型的3.3V供电设备不能直接接入5V电压,然后1号脚cs,这个cs左边画了个斜杠代表是低电平有效,或者这边cs上面画了个横线也是低电平有效,那这里cs对应之前我们讲spi的名称就是SS,意思是spi的片选引脚,6号脚clk对应就是sck,是spi的时钟线,然后5号引脚di对应mosi,是spi主机输出从机输入,2号do对应miso,是spi主机输入从机输出,这四个引脚就是spi通信的四个引脚。

    然后这个芯片还有两个引脚,3号引脚wp,他的意思是写保护配合内部的寄存器器配置,可以实现硬件的写保护,写保护低电平有效,wp接低电平保护住不让写,wp接高电平不保护可以,最后7号hold意思就是数据保持哈,低电平有效,这个用的不多了解一下,就是如果你在进行正常读写时突然产生中断,然后想用spi通信线去操控其他器件,这时如果把cs置回高电平,那时序就终止,但如果你又不想终止总线,又想操作其他器件,这就可以hold引脚置低电平,这样芯片就hold住了,芯片释放总线,但是芯片时序也不会终止,它会记住当前的状态,当你操作完其他器件时,可以回过来哈,hold置回高电平,然后继续hold之前的时序,相当于spi总线进来一次中断,并且在中断里还可以用spi干别的事情,这就是hold的功能,然后最后我们注意到,这个di、do、wp、和HOLD,旁边都有括号,写了lO0、lO1、lO2、lO3 ,这个就对应我们刚才这里说的双重spi和四重spi,如果是普通的spi模式,那括号里的都不用看,如果是双重spi,那di和do就变成lO0和lO1,也就是数据同时收和同时发的两个数据位,如果是四重spi,那就再加上wp当做lO2 ,HOLD当做lO3 ,这四个引脚都作为数据收发引脚,一个时钟四个数据位,了解一下即可暂时不用。

    最后看一下左边模块原理图,这个U1就是W25QXX的芯片,J1是一个六脚的排针,然后芯片的vcc电源正极,通过vcc引脚标号接到排针6号脚,芯片gnd电源负极,通过gnd编号接到排针3号脚,然后芯片spi通信的四个脚,就直接通过排针引出来就行了,之后hold的和wp这两个都是直接接到的vcc,低电平有效,那都接到vcc就这两个功能我们都不用,然后这个C1直接接到vcc和gnd,显然是一个电源滤波,R1和D1也是直接接到vcc和gnd,显然是一个电源指示灯,通电就亮,那这些就是这个芯片的硬件电路了。

    W25Q64框图:

    我们看一下w25q64 是怎么划分的,首先右边这一整个矩形空间里是所有的存储器,存储器以字节为单位,每个字节都有唯一的地址,这样说了w25q64的地址宽度是24位3个字节,所以可以看到左下角第一个字节,它的地址是00 00 00h,h代表16进制,之后的空间地址依次自增,直到最后一个字节,地址是7F FF FF h,那最后一个字节为啥是7f开头,不是f f开头呢,因为24位地址最大寻址范围是16MB,我们这个芯片只有8MB,所以地址空间我们只用了一半,8MB排到最后一个字节,就是7F FF FF h,那这是整个地址空间,从000000~7F FF FF,然后在这整个空间里,我们以64kb为一个基本单元,把它划分为若干的块block,从前往后依次是块0块1块2等等,一直分到最后一块,那整块蛋糕是8MB,以64kb为一块进行划分,最后分得的快数就是8MB除以64kB,这里可以分得128块,那块序号就是块0一直到最后一个是块127,然后观察一下块内地址值的变化规律,比如块0的起始地址是000000,结束地址是00f f f f,之后块31起始是1f0000 ,结束是1f f f f f,之后的都观察一下,可以发现在每一块内,它的地址变化范围就是最低的两个字节,每个块的起始地址是XX0000,结束是XXf f f f,这是块内地址的变化规律,到这里这一块大蛋糕我们就分好块了,64kb为一块总共128块,之后看一下左边这个示意图,我们还要再对每一块进行更细的划分,分为多个扇区sector,这里的虚线看到没指向了右边的各个块,也就是告诉你每一块里面都是这个样子的,那在每个块里,它的起始地址是XX0000,结束地址是XXf f f f,在一块里我们再以4kb为一个单元进行切分,一块是64kb,我4kb一切总共16份,所以在每一块里都可以分为扇区0一直到扇区15,观察一下地址规律,可以发现每个扇区内的地址范围是XXX000到XXXf f f,地址划分啊到扇区就结束了,但是当我们在写入数据时啊,还会有个更细的划分,就是页Page,页是对整个存储空间划分的,当然你也可以把它看作在扇区里再进行划分都一样,那页的大小是256个字节,一个扇区是4kb,所以一个扇区里可以分为16页,然后页的地址规律呢我们也看一下,在这里每一行就是一页,左边这里指了个箭头,写的是页地址的开始,右边这里也指了个箭头,写的是页地址的结束,在一页中,地址变化范围是XXXX00到XXXXFF,一页内的地址变化,仅限于地址的最低一个字节,这就是页的划分,那这个存储器的地址划分啊我就讲完了
    ,我们需要记住的是一整个存储空间,首先划分为若干块,对于每一块又划分为若干扇区,然后对于整个空间会划分为很多很多页,每页256字节,这个我们需要记住。

    左下角,这是spi控制逻辑,是芯片内部进行地址锁存、数据读写等操作,都可以由控制逻辑来自动完成,这个不用我们操心,控制逻辑就是整个芯片的管理员,我们有什么事只需要告诉这个管理员就行了。

    然后控制逻辑左边就是spi的通信引脚,有wp、HOLD、CLK、CS、DI和DO,这些引脚就和我们的主控芯片相连,主控芯片通过spi协议,把指令和数据发给控制逻辑,控制逻辑就会自动去操作内部电路
    来完成我们想要的功能,然后去看控制逻辑上面有个状态寄存器器,这个状态寄存器器是比较重要的
    ,比如芯片是否处于忙状态,是否写使能,是否写保护,都可以在这个状态寄存器器里体现,这个我们等会看手册的时候再来分析,然后上面是写控制逻辑和外部的wp引脚相连,显然这个是配合wp引脚实现硬件写保护的,然后继续右边这里是一个高电压生成器,这个是配合flash进行编程的,因为flash是掉电不丢失的,如何实现掉电不丢失呢,比如你点亮一个led表示1,熄灭led表示0,但如果整个系统电都没有,那1和0就无从说起了,所以要想掉电不丢失,就要我们在存储器里产生一些刻骨铭心的变化,比如一个led我给他加很高的电压,那led就烧坏了,我们用烧坏的led表示1没烧坏的led表示0
    然后再断电,烧坏的led还是烧坏的,有电没电它都是坏的,这个烧没烧坏的状态,不受有电还是没电的影响,所以它就是掉电不丢失,那对于我们的非易失性存储器来说也是一样,我们要让它产生即使断电也不会消失的状态,一般都需要一个比较高的电压去刺激它,所以这种掉电不丢失的存储器,一般都需要一个高压源,那这里芯片内部集成了高电压发生器,所以就不需要我们在外接高电压了,比较方便哈,当然我这里只是举例简单描述一下掉电不丢失的存储原理,至于flash的原理,大家可以再例行研究。

    然后继续看下面,这里是页地址锁存计数器,然后下面还有一个字节地址锁存计数器,这两个地址锁存和记数器就是用来指定地址的,我们通过spi总共发过来三个字节的地址,因为一页是256字节,所以一页内的字节地址就取决于最低一个字节,而高位的两个字节就对应的是页体质,所以在这里我们发的三个字节地址,前两个字节会进到这个页地址锁存计数器里,最后一个字节会进到这个字节地址锁存计数器里,然后页地址通过这个写保护和行解码来选择我要操作哪一页,字节地址通过这个列解码和256字节页缓存,来进行指定地址的读写操作,那就因为我们这个地址锁存,都是有个计数器的,所以这个地址指针在读写之后可以自动加1,这样就可以很容易实现从指定地址开始,连续读写多个字节的目的了,那最后右边这里有个256字节的页缓存区,它其实是一个256字节的ram存储器,这个稍微留个印象,等会儿还会提到,然后我们数据读写,就是通过这个ram缓冲区域来进行的,我们写入数据会先放到缓存区里,然后在时序结束后,芯片再将缓冲区的数据复制到对应的flash里,进行永久保存,那为啥要弄个缓冲区呢,我们直接往flash里写不好吗,那这是因为我们的spi写入的频率是非常高的,而flash的写入由于需要掉电不丢失,留下刻骨铭心的印象,他就比较慢,所以这个芯片的设计思路就是你写入的数据,我先放在缓存区里存着,因为缓存区是ram,所以它的速度非常快啊,可以跟得上spi总线的速度,但这里有个小问题,就这个缓冲区只有256字节,所以写入的时序有个限制条件,就是写入了一个时序,连续写入的数据量不能超过256字节,然后等你写完了,我芯片再慢慢的把数据从缓冲区转移到flash存储器里,那么数据从缓存区转到flash里,需要一定的时间哈,所以在写入时序结束后,芯片会进入一段忙的状态,在这里它就会有一条线哈,通往状态寄存器给状态接容器的busy位置1表示芯片当前正在搬砖呢很忙,那在忙的时候,芯片就不会响应新的读写时序了哈,就是写入的执行流程,然后我们读取数据,虽然这里画的话应该也是会通过缓冲区来读句,但是由于读取只看一下电路的状态就行了,它基本不花时间,所以读取的限制就很少了,速度也非常快。

    flash的写入和读取并不像ram那样简单直接 ,ram是指哪打哪,想在哪写就在哪写,想写多少就写多少,并且ram是可以覆盖写入的,但是flash并没有这个特性啊,总之flash的读写有很多要求,其中写入的要求是非常多的,需要我们掌握,读取的要求就比较少了,还是那个原因,因为读取啊只是看一下电路的状态,不对电路做出实质性的改变,所以读取一般都比较快,而且没有什么限制,那我们看一下flash写入操作时需要注意些什么呢

    第一点写入操作前必须先进行写使能,这个是一种保护措施,防止你误操作的,就像我们使用手机一样,先解锁再操作,这样可以防止手机在你裤兜里到处点点点对吧,写使能的话我们就使用spi发送一个写使能的指令,就可以完成了,然后下一条每个数据位只能由1改写为0,不能由0改写为1,这个意思就是说,flash并没有像ram那样的直接完全覆盖改写的能力,比如在某一个字节的存储单元里面,存储了0xAA这个数据,对应的二进制位就是10101010,如果我直接再次在这个存储单元写入一个新的数据,比如我再次写入一个0x55 ,那写完之后这个存储单元里存的是x55,实际上并不是,因为0x55的二进制是01010101,当这个01010101要覆盖原来的10101010时,就会受到这里第二条规定的限制,每个数据位只能由一改写为零,不能由零改写为一,你要问为啥会有这个限制,那只能说是成本原因或者技术原因,所以这里写入01010101之后,依次来看啊,最高位由原来的1改写为0是可以的
    ,所以写出之后新的最高位就是零,但是第二位原来是零,现在我要改写成1,这是不行的,所以写入之后,新的第二位仍然是零,之后第三位要改写为零,可以,结果为零,第四位零改写为1,不可以
    ,结果仍然是零,那以这个规律进行下去,0xaa在覆盖写入0x55 之后,这个存储单元最终的数据是什么啊,0x00也就是八位全为零,这就出现问题了对吧,所以为了弥补这个只能1改0,不能0改1的缺陷
    ,我们就引出了第三条规定,就是写入数据前必须先擦除,擦除后所有数据位变为一,在这里flash是有一个擦除的概念的,擦除会有专门的擦除电路进行,我们只要给他发送擦除的指令就行了,那通过擦除电路擦除之后,所有的数据位都变成一,这样我们是不是就可以弥补第二条限制的缺陷了,当我们写出一个数据之前,无论原来存的是什么,我直接给它擦除掉,擦除之后所有的位变成1,也就是16进制的f f,这样我无论再写入什么样的数据,就都可以正确的写入了。

    那总结一下就是flash中数据位为一的数据,拥有单项改成零的权利,一旦改写为0之后,就不能反悔
    再改写成1了,要想反悔就必须得先擦除,所有的位先统一都变成一,然后再重新来过,这是flash改写的特性。

    如果你说我非不擦除,直接改写,这样的操作可以执行,但是存储的数据极有可能是错的,这个注意一下,那拆除之后所有的位变1,就是16进制的ff,所以有时候你读取flash会发现数据全是f f,那就说明这一段有可能是擦除之后,还没有写入数据的空白空间,在flash中ff代表空白,那这个改写和擦除的注意事项我们就了解了。

    接下来下一条擦除必须按最小拆除单元进行,这个应该也是为了成本而做出的妥协,就是说你写入前要进行擦除,这我知道,所以如果我想在00这个地址下写入数据,那我就先把00地址擦除,再写入数据到00地址不就行了吗,但是这个方案有个问题啊,flash的擦除有最小擦除单元的限制,你不能指定某一个直接去擦除,要擦就得一大片一起擦,那在我们这个芯片里,你可以选择整个芯片擦除,也可以选择按块擦除或者按扇区擦除,然后再小就没有了,所以最小的擦除单元就是一个扇区,刚才我们看了一个扇区是4kb就是4096个字节,所以你擦除最少就得4096个字节一起擦,我只想查出某一个字节怎么办呢,这没办法你只能把那个字节所在扇区的4096个字节全都擦掉,那你又说这个扇区其他的地方我还存的有数据怎么办呢,这也没办法,要想不丢失数据,你只能先把4096个字节都读出来,再把4096个字节的扇区擦掉,改写完读出来的数据后,再把4096个字节全都写回去,这感觉是不是挺麻烦的哈,但是如果你确实就想单独改写某一个字节,那只能这样来操作,当然实际情况下,我们还有别的方法可以优化一下这个流程,比如上电后,我先把flash的数据读出来放到ram里,当有数据变动时,我再统一把数据备份到flash里,或者我把使用频繁的扇区放在ram里,当使用频率降低时,我再把整个扇区备份到flash里,或者如果你的数据量确实非常少,只想存几个字节的参数就行了,那直接一个字节占一个扇区不就行了吗,尽显奢靡之风啊。

    连续写入多字节时,最多写入一页的数据,超过页尾位置的数据会回到页首覆盖写入,这个意思就是说你在写入的时候,一次性不能写太多了,一个写入时序最多只能写一页的数据也就是256字节,为什么有这个限制呢,这是因为在这里有一个页缓冲区,它只有256字节,为什么有缓冲区呢,这是因为flash的写入太慢了,跟不上spi的频率,所以写入的数据会先放在ram里暂存,等时序结束后,芯片再慢慢的把数据写入到flash里,所以这里会有个限制,每个时序最多写入一页的数据,你再写多缓冲区存不下了,如果你非要写,那超过页尾位置的数据会回到页首覆盖写入,另外我们这个页缓存区是和flash的页对应的,你必须得从页起始位置开始写,才能最大写入256字节,如果你从页中间的地址开始写,那写到页尾时,这个地址就会跳回到页首,这会导致地址错乱哈,所以我们在进行多字节写入时,一定要注意这个地址范围不能跨越页的边缘,否则会地址错乱。

    然后写入操作结束后,芯片进入忙状态,不响应新的读写操作,我们的写入操作都是对缓存区进行的,等时序结束后芯片还要搬砖一段时间,所以每次写入操作后,都有一段时间的忙状态,在这个状态下不要进行新的读写操作,否则芯片是不会响应我们的,要想知道芯片什么时候结束盲状态,我们可以使用读状态寄存器器的指令,看一下状态寄存器的busy位是否为1,为0时芯片就不忙了,我们再进行操作,另外注意这个写入操作,包括上面的擦除,在发出擦除指令后,芯片也会进入忙状态,我们也得等盲状态结束后才能进行后续操作。

    继续看读取操作的注意事项,这个就相对宽松很多了,在读取时我们直接调用读取时序,无需使能
    没有页的限制,也就是这一条连续读取多个字节时,想读多少就读多少,不用担心地址错位或者覆盖的问题,读取操作结束后不会进入忙状态,但不能在盲状态时读取。

    flash这种非易失性存储器,目前的市场竞争力还是非常大的,尽管它有这么多不方便,但是这些不方便可以用软件来弥补,而它的优点是其他存储器比不了的,比如容量大价格低。

    芯片手册





    看出来这个flash芯片的写入时间一般情况下大概处于一个毫秒的时间级别。

    10.3 SPI软件读写W25Q64

    接线图:

    软件模拟的spi这四根线是可以接到stm32的任意GPIO口,软件模拟的通信端口灵活性高,这里我是这样来接的,cs片选接到PA4 ,DO从机输出接到PA6 ,CLK时钟接到PA5 ,DI从机输入接到PA7,当然我这里引脚其实并不是任意选的,实际上是接到了硬件spi的引脚上,这样的话软件spi和硬件spi都可以任意切换。


    代码:

    main.c

    MySPI.h

    #ifndef __MYSPI_H
    #define __MYSPI_H
    
    void MySPI_Init(void);
    void MySPI_Start(void);
    void MySPI_Stop(void);
    uint8_t MySPI_SwapByte(uint8_t ByteSend);
    
    #endif
    

    MySPI.c

    #include "stm32f10x.h"                  // Device header
    
    /*引脚配置层*/
    
    /**
      * 函    数:SPI写SS引脚电平
      * 参    数:BitValue 协议层传入的当前需要写入SS的电平,范围0~1
      * 返 回 值:无
      * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SS为低电平,当BitValue为1时,需要置SS为高电平
      */
    void MySPI_W_SS(uint8_t BitValue)
    {
    	GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);		//根据BitValue,设置SS引脚的电平
    	//SPI的操作速度非常快,暂时不需要加延时
    }
    
    /**
      * 函    数:SPI写SCK引脚电平
      * 参    数:BitValue 协议层传入的当前需要写入SCK的电平,范围0~1
      * 返 回 值:无
      * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SCK为低电平,当BitValue为1时,需要置SCK为高电平
      */
    void MySPI_W_SCK(uint8_t BitValue)
    {
    	GPIO_WriteBit(GPIOA, GPIO_Pin_5, (BitAction)BitValue);		//根据BitValue,设置SCK引脚的电平
    }
    
    /**
      * 函    数:SPI写MOSI引脚电平
      * 参    数:BitValue 协议层传入的当前需要写入MOSI的电平,范围0~0xFF
      * 返 回 值:无
      * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置MOSI为低电平,当BitValue非0时,需要置MOSI为高电平
      */
    void MySPI_W_MOSI(uint8_t BitValue)
    {
    	GPIO_WriteBit(GPIOA, GPIO_Pin_7, (BitAction)BitValue);		//根据BitValue,设置MOSI引脚的电平,BitValue要实现非0即1的特性
    }
    
    /**
      * 函    数:I2C读MISO引脚电平
      * 参    数:无
      * 返 回 值:协议层需要得到的当前MISO的电平,范围0~1
      * 注意事项:此函数需要用户实现内容,当前MISO为低电平时,返回0,当前MISO为高电平时,返回1
      */
    uint8_t MySPI_R_MISO(void)
    {
    	return GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6);			//读取MISO电平并返回
    }
    
    /**
      * 函    数:SPI初始化
      * 参    数:无
      * 返 回 值:无
      * 注意事项:此函数需要用户实现内容,实现SS、SCK、MOSI和MISO引脚的初始化
      */
    void MySPI_Init(void)
    {
    	/*开启时钟*/
    	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
    	
    	/*GPIO初始化*/
    	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);					//将PA4、PA5和PA7引脚初始化为推挽输出
    	
    	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);					//将PA6引脚初始化为上拉输入
    	
    	/*设置默认电平*/
    	MySPI_W_SS(1);											//SS默认高电平 不选中
    	MySPI_W_SCK(0);											//SCK默认低电平 SPI模式0
    }
    
    /*协议层*/
    
    /**
      * 函    数:SPI起始
      * 参    数:无
      * 返 回 值:无
      */
    void MySPI_Start(void)
    {
    	MySPI_W_SS(0);				//拉低SS,开始时序
    }
    
    /**
      * 函    数:SPI终止
      * 参    数:无
      * 返 回 值:无
      */
    void MySPI_Stop(void)
    {
    	MySPI_W_SS(1);				//拉高SS,终止时序
    }
    
    /**
      * 函    数:SPI交换传输一个字节,使用SPI模式0
      * 参    数:ByteSend 要发送的一个字节
      * 返 回 值:接收的一个字节
      */
    uint8_t MySPI_SwapByte(uint8_t ByteSend)//有的地方定义成 readwrite()
    {
    	uint8_t i, ByteReceive = 0x00;					//定义接收的数据,并赋初值0x00,此处必须赋初值0x00,后面会用到
    	
    	for (i = 0; i < 8; i ++)						//循环8次,依次交换每一位数据
    	{
    		MySPI_W_MOSI(ByteSend & (0x80 >> i));		//使用掩码的方式取出ByteSend的指定一位数据并写入到MOSI线
    		MySPI_W_SCK(1);								//拉高SCK,上升沿移出数据
    		if (MySPI_R_MISO() == 1){ByteReceive |= (0x80 >> i);}	//读取MISO数据,并存储到Byte变量
    																//当MISO为1时,置变量指定位为1,当MISO为0时,不做处理,指定位为默认的初值0
    		MySPI_W_SCK(0);								//拉低SCK,下降沿移入数据
    	}
    	
    	return ByteReceive;								//返回接收到的一个字节数据
    }
    

    W25Q64_Ins.h

    #ifndef __W25Q64_INS_H
    #define __W25Q64_INS_H
    
    #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
    

    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"
    
    /**
      * 函    数:W25Q64初始化
      * 参    数:无
      * 返 回 值:无
      */
    void W25Q64_Init(void)
    {
    	MySPI_Init();					//先初始化底层的SPI
    }
    
    /**
      * 函    数:MPU6050读取ID号
      * 参    数:MID 厂商ID,使用输出参数的形式返回
      * 参    数:DID 设备ID,使用输出参数的形式返回
      * 返 回 值:无
      */
    void W25Q64_ReadID(uint8_t *MID, uint16_t *DID)
    {
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_JEDEC_ID);			//交换发送读取ID的指令
    	*MID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//交换接收MID,通过输出参数返回  随便发送一个数据oxFF,交换过来就行了
    	*DID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//交换接收DID高8位
    	*DID <<= 8;									//高8位移到高位
    	*DID |= MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//或上交换接收DID的低8位,通过输出参数返回
    	MySPI_Stop();								//SPI终止
    }
    
    /**
      * 函    数:W25Q64写使能
      * 参    数:无
      * 返 回 值:无
      */
    void W25Q64_WriteEnable(void)
    {
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_WRITE_ENABLE);		//交换发送写使能的指令 0x06
    	MySPI_Stop();								//SPI终止
    }
    
    /**
      * 函    数:W25Q64等待忙   读状态寄存器1
      * 参    数:无
      * 返 回 值:无
      */
    void W25Q64_WaitBusy(void)
    {
    	uint32_t Timeout;
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1);				//交换发送读状态寄存器1的指令 0x05
    	Timeout = 100000;							//给定超时计数时间
    	while ((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01)	//寄存器最低位busy位表示忙碌  循环等待忙标志位
    	{
    		Timeout --;								//等待时,计数值自减
    		if (Timeout == 0)						//自减到0后,等待超时
    		{
    			/*超时的错误处理代码,可以添加到此处*/
    			break;								//跳出等待,不等了
    		}
    	}
    	MySPI_Stop();								//SPI终止
    }
    
    /**
      * 函    数:W25Q64页编程
      * 参    数:Address 页编程的起始地址,范围:0x000000~0x7FFFFF
      * 参    数:DataArray	用于写入数据的数组
      * 参    数:Count 要写入数据的数量,范围:0~256
      * 返 回 值:无
      * 注意事项:写入的地址范围不能跨页
      */														 //一页256字节 uint8_t最大255 所以定义成uint16_t
    void W25Q64_PageProgram(uint32_t Address, uint8_t *DataArray, uint16_t Count)
    {
    	uint16_t i;
    	
    	W25Q64_WriteEnable();						//写使能
    	
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_PAGE_PROGRAM);		//交换发送页编程的指令
    	MySPI_SwapByte(Address >> 16);				//交换发送地址23~16位
    	MySPI_SwapByte(Address >> 8);				//交换发送地址15~8位
    	MySPI_SwapByte(Address);					//交换发送地址7~0位
    	for (i = 0; i < Count; i ++)				//循环Count次
    	{
    		MySPI_SwapByte(DataArray[i]);			//依次在起始地址后写入数据
    	}
    	MySPI_Stop();								//SPI终止
    	
    	W25Q64_WaitBusy();							//等待忙	事后等待,也可以放到函数最开始:事前等待
    }
    
    /**
      * 函    数:W25Q64扇区擦除(4KB)  其他擦除都是类似的
      * 参    数:Address 指定扇区的地址,范围:0x000000~0x7FFFFF
      * 返 回 值:无
      */					  //所在字节的整个扇区都擦除
    void W25Q64_SectorErase(uint32_t Address)
    {
    	W25Q64_WriteEnable();						//写使能
    	
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB);	//交换发送扇区擦除的指令  
    	MySPI_SwapByte(Address >> 16);				//交换发送地址23~16位
    	MySPI_SwapByte(Address >> 8);				//交换发送地址15~8位
    	MySPI_SwapByte(Address);					//交换发送地址7~0位
    	MySPI_Stop();								//SPI终止
    	
    	W25Q64_WaitBusy();							//等待忙
    }
    
    /**
      * 函    数:W25Q64读取数据
      * 参    数:Address 读取数据的起始地址,范围:0x000000~0x7FFFFF
      * 参    数:DataArray 用于接收读取数据的数组,通过输出参数返回
      * 参    数:Count 要读取数据的数量,范围:0~0x800000
      * 返 回 值:无
      */							
    void W25Q64_ReadData(uint32_t Address, uint8_t *DataArray, uint32_t Count)
    {
    	uint32_t i;
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_READ_DATA);			//交换发送读取数据的指令	0X03
    	MySPI_SwapByte(Address >> 16);				//交换发送地址23~16位
    	MySPI_SwapByte(Address >> 8);				//交换发送地址15~8位
    	MySPI_SwapByte(Address);					//交换发送地址7~0位
    	for (i = 0; i < Count; i ++)				//循环Count次 可以跨页读 想读多少读多少
    	{
    		DataArray[i] = MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//存储器芯片内部地址指针自动自增  依次在起始地址后读取数据
    	}
    	MySPI_Stop();								//SPI终止
    }
    

    main.c

    #include "stm32f10x.h"                  // Device header
    #include "Delay.h"
    #include "OLED.h"
    #include "W25Q64.h"
    
    uint8_t MID;							//定义用于存放MID号的变量
    uint16_t DID;							//定义用于存放DID号的变量
    
    uint8_t ArrayWrite[] = {0x01, 0x02, 0x03, 0x04};	//定义要写入数据的测试数组
    uint8_t ArrayRead[4];								//定义要读取数据的测试数组
    
    int main(void)
    {
    	/*模块初始化*/
    	OLED_Init();						//OLED初始化
    	W25Q64_Init();						//W25Q64初始化
    	
    	/*显示静态字符串*/
    	OLED_ShowString(1, 1, "MID:   DID:");
    	OLED_ShowString(2, 1, "W:");
    	OLED_ShowString(3, 1, "R:");
    	
    	/*显示ID号*/
    	W25Q64_ReadID(&MID, &DID);			//获取W25Q64的ID号
    	OLED_ShowHexNum(1, 5, MID, 2);		//显示MID
    	OLED_ShowHexNum(1, 12, DID, 4);		//显示DID
    	
    	
    	/*W25Q64功能函数测试*/
    	W25Q64_SectorErase(0x000000);					//扇区擦除  字节所在扇区,最好传入扇区起始地址
    	W25Q64_PageProgram(0x000000, ArrayWrite, 4);	//将写入数据的测试数组写入到W25Q64中
    	
    	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)
    	{
    		
    	}
    }
    

    不擦除直接写,读出的数据=原始数据&写入的数据

    跨页写,回到此页页首,覆盖页的最前面写入。
    如果你确实有一个很大的数字要连续写入,那就只能自己从软件上分批次进行写入了,就是先计算你的数据总共需要跨多少页,然后该擦除的擦除,最后再分批次一页一写,这个操作可以封装成一个函数,之后调用封装的函数就可以跨页连续写入了。

    10.4 SPI硬件外设读写W25Q64

    跟之前I2C的思路一样,软件spi就是我们用代码手动翻转电平来实现时序,硬件spi就是使用stm32内部的spi外设来实现时序,两种实现方法各有优势,软件实现主打的是方便灵活,硬件实现主打的是高性能、节省软件资源。

    stm32内部spi外设的一些功能和技术参数,其实手册里介绍这些功能还是比较繁杂的,这是因为硬件电路不像软件那么灵活,硬件电路一旦设计出来,它的功能基本上就定死了,之后只能通过一些开关电路、数据选择器等等来微调电路的运行,不像软件那样改代码就行,所以stm32 设计时就要考虑最全面的应用场景,把各种可能的结构都设计出来放在那,以免你用的时候找不到,那这样就会导致外设电路的结构和知识点非常多,而且有很多功能我们基本上很少用到,所以stm32我们要使用主线加分支的学习方法,我们先把最常用最简单的主线知识点给贯通给他学会了,然后再逐渐细化在实践中去慢慢探索这些分支,这样学习起来才是比较容易,所以大家在看手册,有一些感觉非常偏又非常难的知识点,可以先不必深究,先把主线任务学习好,其他的可以之后再研究。

    时钟频率就是sck波形的频率,一个sck时钟交换一个bit,所以时钟频率一般体现的是传输速度,单位是Hz或者bit/s,那这里的时钟频率是fPCLK除以一个分频系数,分频系数可以配置为2或4或8、16、32、64、128、256,所以可以看出来,spi的时钟其实就是由pclk分频得来的,pclk就是外设时钟,APB2的pclk就是72MHz,APB1的pclk是36MHz,比如我们的spi1是APB2的外设,pclk等于72MHz
    ,那它的spi时钟频率最大就是只进行二分频=36MHz,像我们之前I2C的频率最大就只有400KH,所以这里spi的最大频率比I2C快了90倍,然后这里频率有些注意事项,一是这个频率数值并不是任意指定的,它只能是pclk执行分频后的数值就只有这八个选项,最低频率是pclk的256分频,二是spi1和spi2 挂载的总线是不一样的,spi1挂载在APB2,pclk是72MHz,spi1挂载在APB1,pclk是36MHz,所以同样的配置,spi1的时钟频率要比spi2的大一倍。

    SPI框图:

    接下来看一下spi的框图,我们可以大致把它分成两部分,左上角这一部分就是数据寄存器和移位寄存器打配合的过程,这个和串口、I2C那里的设计思路都是异曲同工的,主要是为了实现连续的数据流,一个个数据前仆后继的一个效果,然后剩下右下角这一部分,就是一些控制逻辑,寄存器的哪些位控制哪些部分会产生哪些效果,这个可以通过手册的寄存器描述来得知,至于执行细节,这里也没详细画,我们就知道功能就行了。

    那我们接着来详细看一下每部分的功能,首先左上角核心部分就这个移位寄存器,右边的数据低位,一位一位的从mosi移出去,然后miso的数据一位一位的移入到左边的数据高位,显然移位寄存器应该是一个右移的状态,所以目前图上表示的是低位先行的配置,对应右下角有一个LSBFIRST的控制位,这一位可以控制是低位先行还是高位先行,手册里寄存器描述可以查一下,这里LSBFIRST帧格式,给0先发送msb,msb就是高位的意思,给1先发送lsb ,lsb就是低位的意思,那ppt这里目前的状态LSBFIRST的应该是1,低位先行,如果LSBFIRST给零高位先行的话,这个图还要变动一下,就是移位寄存器变为左移,输出,从左边移出去,输入,从右边移进来,这样才符合逻辑,然后继续看左边这一块,这里画了个方框,里面把mosi和miso做了个交叉,这一块主要是用来进行主从模式引脚变换的,我们这个spi外设可以做主机,也可以做从机,做主机时这个交叉就不用,mosi为mo,主机输出,miso为mi,主机输入,这是主机的情况,如果我们stm32作为从机的话,mosi为si,从机输入
    这时他就要走交叉的这一路,输入到移位寄存器,同理miso为so,从机输出,这时输出的数据也走交叉的这一路输出到miso,但这里如果这样理解没错的话,这个箭头可能是画错方向了,应该是往下走的,这样才符合逻辑,那这就是这个交叉的作用,简而言之就是主机和从机的输入输出模式不同,如果要切换主机和从机的话,线路就需要交叉一下,当然如果我们始终做主机的话,那这个交叉就不用看了。

    接下来上下两个缓冲区,就还是我们熟悉的设计,这两个缓冲区实际上就是数据寄存器DR,下面发送缓冲区就是发送数据寄存器TDR,上面接收缓冲区就是接收数据寄存器RDR,和串口那里一样,TDR和RDR占用同一个地址,统一叫做DR,写入DR时数据从这里写入到TDR,读取DR时,数据从这里从RDR读出,数据寄存器和移位寄存器打配合,可以实现连续的数据流,具体流程就是比如我们需要连续发送一批数据,第一个数据写入到TDR,当移位寄存器没有数据移位时,TDR的数据会立刻转入移位寄存器,开始移位,这个转入时刻,会置状态寄存器的TXE为1,表示发送寄存器空,当我们检查TXE置1后,紧跟着下一个数据就可以提前写入到TDR里侯着了,一旦上个数据发完,下一个数据就可以立刻跟进,实现不间断的连续传输,然后移位寄存器,这里一旦有数据过来了,它就会自动产生时钟将数据移出去,在移出的过程中,miso的数据也会移入,一旦数据移出完成,数据移入是不是也完成了,这时移入的数据就会整体的从移位计算器,转入到接收缓冲区RDR,这个时刻会置状态寄存器器的RXNE为1,表示接收计寄存器器非空,当我们检查RXNE置1后,就要尽快把数据从RDR读出来
    在下一个数据到来之前,读出RDR就可以实现连续接收,否则如果下一个数据已经收到了,上个数据还没从RDR读出来,那RDR的数据就会被覆盖,就不能实现连续的数据流了。

    和之前串口、I2C的都差不多的,当然这三者也是有一些区别的,比如这里spi全双工发送和接收同步进行,所以它的数据寄存器发送和接收是分离的,而移位寄存器发送和接收可以共用,然后看一下前面iphone c的框图,因为I2C是半双工,发送和接收不会同时进行,所以它的数据寄存器和移位寄存器
    ,发送和接收都可以是共用的。串口是全双工,并且发送和接收可以异步进行,所以这就要求它的数据寄存器,发送和接收是分离的,移位寄存器发送和接收也得是分离的。

    然后接下来我们看一下右下角这些内容,这就是一些控制逻辑,首先是波特率发生器,这个主要就是用来产生sck时钟的,它的内部主要就是一个分频器,输入时钟是pclk72M或36M,经过分频器之后输出到sck引脚,当然这里生成的时钟肯定是和移位寄存器同步的,每产生一个周期的时钟移入移出一个bit,然后右边CR1寄存器的三个位BR0、BR1、BR2,用来控制分频系数,从这里可以看一下,手册这里看到BR[2:0] 是波特率控制,这三位写入下面这些值,可以对pclk时钟执行2~ 256的分频,分频之后就是sck时钟,所以这一块就对于来之前这里说的时钟频率是fpclk的2~256分频,那这就是波特率发生器的部分。

    接着后面这些通信电路和各种寄存器,都是一些黑盒子电路,如果你要具体研究,可以看一下这些位的寄存器描述,我挑几个重点的讲一下,比如lsb first的刚才说过,决定高位先行还是低位先行,spe是spi使能,就是SPI_Cmd函数配置的位,BR配置波特率,就是sck时钟频率,MSTR(Master),配置主从模式,1是主模式,0是从模式,我们一般用主模式,cpul和cpha,这个之前讲过,用来选择spi的四种模式,然后这里sr状态计算器,最后两个txe发送寄存器空,rxne接收寄存器非空,这两个比较重要,我们发送接收数据的时候需要关注这两位,最后CR2寄存器就是一些使能位了,比如中断使能dma使能等,然后剩下的一些位用的不多,大家可以在自行研究,那最后这里还有一个NSS引脚,ss就是从机选择,低电平有效,所以这里前面加了个n,这个nss和我们想象的从机选择可能不太一样,我们想象的应该是用来指定某个从机对吧,但是根据手册里的描述,我也研究了一下,这里的nss设计,可能更偏向于实现这里说的多主机模型,总的来说啊,这个NSS我们并不会用到,SS引脚我们直接使用一个gpo模拟就行,因为ss引脚很简单,就置一个高低电平就行了,而且从机的情况下,ss还会有多个,这里硬件的nss也完成不了我们想要的功能,那这个nss是如何实现多主机切换的功能呢,我简单介绍一下啊,大家听一听就行,不用掌握,假如这里有三个stm32设备,我们需要把这三个设备的nss全都连接在一起,首先这个nss可以配置为输出或者输入,当配置为输出时,可以输出电平告诉别的设备,我现在要变为主机,你们其他设备都给我变从机,不要过来捣乱,当配置为输入时,可以接收别设备的信号,当有设备是主机拉低nss后,我就无论如何也变不成主机了,这就是它的作用,然后内部电路的设计,当这里这个ssoe等于1时,nss作为输出引脚,并在当前设备变为主设备时,给nss输出低电平,这个输出的低电平,就是告诉其他设备,我现在是主机了,当主机结束后,ssoe要清0,nss变为输入,这时输入信号就会跑到右边这里,这个数据选择器ssm位决定选择哪一路,当选择上面一路时是硬件nss模式,也就是说这时外部如果输入了低电平,那当前的设备就进入不了主模式了,因为nss低电平肯定是,外部已经有设备进入了主模式,他已经提前告诉我他是主模式了,我就不能再跟大家抢了,当数据选择器选择下面一路时,是软件管理nss输入,nss是1还是2,由这一位SSI来决定,这个就是nss实现多主机的思路,但这个设计是n s s作为多从机选择的作用消失了,揪出所有人的小辫子之后,主机发送的数据就只能是广播发送给所有人的,如果想实现指定设备通信,可能还需要再加入寻址机制,所以实现起来还是比较复杂的,但我自己其实也没试过这种玩法,这里是根据我看手册的理解,我觉得应该是这样玩的哈,不过spi最多的情况还是一主多从或者一主一从,我们掌握一主多从就行,多主机的情况了解即可好。

    SPI基本结构:

    那看完了详细的框图,我们再看一下,这里我总结了一个简化结构图,这个结构我把上面这个框图
    无关的东西都去掉了,这样看起来是不是就更容易理解,其中核心部分,当然就是这个数据寄存器和移位寄存器,这里发送和接收我直接叫做发送数据寄存TDR器,和接收数据寄存器RDR了,因为我觉得这样表示更清晰,之前串口框图里也是这样表示的哈,但是spi框图这里,它又叫发送缓冲区和接收缓冲区,命名可能不太统一,因为这个手册可能是多个人分工写,最后整合到一起的,所以有时候我就发现手册不同的章节,描述手法和词汇可能都不一样,但是大家要有自己的判断,知道他们其实是一个东西就行,然后这里移位寄存器,我画的是左移,高位移出去,通过GPIO到MOSI,从MOSI输出,显然这是spi的主机对,之后引入的数据从miso进来,通过gpio到移位寄存器的低位,这样循环八次,就能实现主机和从机交换一个字节,然后tdr和rdr的配合,可以实现连续的数据流,这刚才和之前的课程已经分析过很多次了,另外tdr数据,整体转入移位寄存器的时刻,置txe标志位,移位寄存器数据整体转入RDR的时刻,置RXNE标志位,tdr、txe 、rxne这几个词再记一下,等会儿会经常提到,然后剩下的波特率发生器,产生时钟输出到sck引脚,数据控制器就看成是一个管理员,它控制着所有电路的运行,最后开关控制就是SPI_Cmd,初始化之后给个ENABLE初始化整个外设,另外这里我并没有画ss从机选择引脚,这个引脚我们还是使用普通的GPIO来模拟即可,在一主多从的模型下,GPIO模拟的ss是最佳选择,这就是spi的系统框图和简化的结构了,我们在写代码的时候,会用一个结构体来统一配置这些部分。

    那初始化部分解决之后,我们就要来看一些运行控制的部分了,如何来产生具体的时序呢,什么时候写dr,什么时候读dr呢,这是我们接下来学习的知识点,读写dr产生时序的流程,我们主要看这两个时序图即可。

    第一个是主模式全双工连续传输,这个图演示的是借助缓冲区数据前仆后继,实现连续数据流的过程,但是这个流程稍微比较复杂,也不太方便封装,所以在实际过程中,如果对性能没有极致的追求
    ,我们更倾向使用下面这个非连续传输的示意图,这个非连续传输使用起来更加简单,实际用的话只需要四行代码就能完成任务了,那参考网上别人的代码呢,基本上都是非连续传输的方式,我们课程也使用非连续传输的代码,非连续传输的好处就是容易封装好理解好用,但是会损失一丢丢性能,连续传输呢传输更快,但是操作起来相对复杂,那我们来分别具体分析一下。

    先看一下主模式全双工连续传输的意图,首先第一行是sck时钟线,这里cpol等于1,cpha等于1,示例使用的是spi模式三,所以sck默认是高电平,然后在第一个下降沿mosi和miso移出数据,之后上升沿引入数据,依次这样来进行,那下面第二行是mosi和mi o输出的波形,跟随sck时钟变化,数据位依次出现,这里从前到后依次出现的是b0b1一直到b7 ,所以这里示例演示的是低位先行的模式啊,实际spi高位先行用的多一些,最后第三行是txe发送寄存器空标志位,波形是这样的,等会儿再分析,下面继续看,是发送缓冲器括号写入SPI_DR,实际上就是这里的TDR然后bsy busy是由硬件自动设置和,清除的,当有数据传输时,busy置1那上面这部分演示的就是输出的流程和现象,然后下面是输入的流程和现象,第一个是miso/mosi的输入数据,之后是RXNE接收数据寄存器非空标志位最后是接收,缓冲器读出SPI_DR,显然就是这里的RDR了,了解完各个信号的定义了。

    我们来从左到右依次分析,首先ss置低电平开始时序,这个没画但是必须得有的,在刚开始时TXE为1
    ,表示TDR空可以写入数据开始传输,然后下面指示的第一步就是软件写入0xf1至SPI_DR,0xf1就是要发送的第一个数据,之后可以看到写入之后TDR变为0xf1 ,同时txe变为0,表示tdr已经有数据了,那此时d r是等候区,移位寄存器才是真正的发送区,移位寄存器刚开始肯定没有数据,所以在等候区TDR里的f1 ,就会立刻转入移位寄存器开始发送转入瞬间置txe标志位为1,表示发送寄存器空,然后移位寄存器有数据了,波形就自动开始生成,当然我感觉这里画的数据波形时机可能有点早,应该是在这个时刻b0的波形才开始产生,在这之前数据还没有转入移位进器,所以感觉b0出现的可能过早了,不过这个也不影响我们理解,大家知道这意思就行好了,这样数据转入移位寄存器之后,数据F1的波形就开始产生了,在移位产生f1波形的同时,等候区tdr是空的,为了移位完成时,下一个数据能不间断的跟随,这里我们就要提早把下一个数据写入到TDR里等着了,所以下面只是第二步的操作,是写入F1之后,软件等待TXE等于1,在这个位置,一旦tdr空了,我们就写入F2至SPI_DR,写入之后可以看到tdr的内容就变成F2了,也就是把下一个数据放到tdr里,后者之后的发送流程也是同理,最后在这里如果我们只想发送三个数据,F3转入移位寄存器之后,TXE等于1,我们就不需要继续写入了,txe之后一直是1,注意在最后一个TXE等于1之后,还需要继续等待一段时间,f3的波形才能完整发送完,等波形全部完整发送之后,busy的标志由硬件清除,这才表示波形发送完成了,那这些就是发送的流程,然后继续看一下下面接收的流程,SPI是全双工,发送的同时还有接收,所以可以看到在第一个字节发送完成后,第一个字节的接收也完成了,接收到的数据1是A1 ,这时移位寄存器的数据整体转入RDR,RDR随后存储的就是A1 ,转入的同时按RXNE标志位也置1,表示收到数据了,我们的操作是下面这里写的,软件等待RXNE等于1,=1表示收到数据了,然后从SPI_DR也是RDR读出数据A1 ,这是第一个接收到的数据,接收之后软件清除RXNE标志位,然后当下一个数据2收到之后,RXNE重新置1,我们监测到RXNE等于1时就继续读出RDR,这是第二个数据A2 ,最后在最后一个字节时序完全产生之后,数据三才能收到,所以数据3,直到这里才能读出来,然后注意,一个字节波形收到后,移位寄存器的数据自动转入RDR,会覆盖原有的数据,所以我们读出rdr要及时,比如A1这个数据收到之后,最迟你也要在这里把它读走,否则下一个数据A2覆盖A1,就不能实现连续数据流的接收了,这是整个发送和接收的流程这个交换的流程是交错的,对我们程序设计不太友好,总之如果你对效率要求很高,就研究下这个,否则的话,我们更推荐下面这个非连续传输。

    非连续传输对于程序设计非常友好,只需要四行代码就可以完成,那它是怎么执行的呢,我们来看一下,这个就是非连续传输发送的示意图,下面这里只有发送的一些波形,接收部分的波形没画出来,但是我们也可以想象得到接收是什么样子的,等会儿我也会给大家展示一下接收的波形,那我们看一下这个非连续传输和连续传输,有什么区别呢,首先这个配置还是spi模式三,sck默认高电平,我们想发送数据时如果检测到TXE等于1了,TDR为空,就软件写入0xF1至SPI_DR,这时TDR的值变为F1
    TXE变为0,目前移位寄存器也是空,所以这个F1会立刻转入移位寄存器,开始发送,波形产生并且,TXE置回1,表示你可以把下一个数据放在tdr里侯着了,但是现在区别就来了,在连续传输这里一旦,TXE等于1了,我们就会把下个数据写到tdr里侯着这样是为了连续传输数据衔接更紧密,但是刚才说了
    这样的话,流程就比较混乱,程序写起来比较复杂,所以在非连续传输这里,TXE等于1了,我们不着急把下一个数据写进去,而是一直等待,等第一个字节时序结束,在这个位置时序结束了,意味着接收第一个字节也完成了,这时接收的RXNE会置一,我们等待RXNE置1后,先把第一个接收到的数据读出来,之后再写入下一个字节数据,也就是这里的软件等待TXE等于1,但是较晚写入0xf2SPI_DR
    较晚写入TDR后,数据二开始发送,我们还是不着急写数据三,等到了这里,先把接收的数据二收着
    再继续写入数据3,数据3时序结束后,最后再接收数据三置换回来的数据,你看按照这个流程的话,我们的整个步骤就是第一步等待TXE为一,第二步写入发送的数据至TDR,第三步等待RXNE为一
    第四步读取RDR接收的数据,之后交换第二个字节,重复这四步,那这样我们就可以把这四部分装到一个函数,调用一次交换一个字节,这样程序逻辑是不是就非常简单了,和之前软件spi的流程基本上是一样的,我们只需要稍作修改,就可以把软件spi改成硬件spi,那非连续算出缺点,就是在这个位置没有及时把下一个数据写入TDR侯着,所以等到第一个字节时序完成后,第二个字节还没有送过来,那这个数据传输就会在这里等着,所以这里时钟和数据的时序,在字节与字节之间会产生间隙,拖慢了整体数据传输的速度这个间隙在sck频率低的时候影响不大,但是在sck频率非常高时隙拖后腿的现象就比较严重了,比如我这里用示波器看了一下,不同sck频率间隙的影响。

    这里有四个波形,sck分频系数分别是264、128、28、56,先看一下最慢的,256分频,这个SCK频率是72M/26大概280k,图示上面是sk信号,这里使用spi模式0,所以默认低电平,下面是SS信号,低电平表示选中重击,这个波形是spi,非连续传输交换五个字节的时序,主要看一下sck线,这里连续交换了五个字节,但是你几乎看不出字节与字节之间的间隙对,因为这个时钟频率比较慢,间隙时长也不大,所以在这个比较慢的波形看来,间隙对它的影响就可以忽略了。

    下一个图是128分频,sck频率大概560k,这时就更明显的看出来字节之间的间隙了,字节和字节之间并不是严丝合缝的,这会降低整体的字节传输速度,但是从这个比例上看啊,这一点点间隙也可以忽略不计的。

    继续加快时钟,64分频SCK频率大概1M多点,因为频率增大,时间尺度缩小,这样看来间隙就更加明显了,进一步加快时钟频率,我们直接看一下最快的二分频,最后一张图,这个sck时钟频率是72M/2=36M,频率非常快了,已经超过这个示波器的采样频率,所以每个字节的时钟已经看不完整了
    ,但是哪里在传输,哪里是间隙还是可以区分的,这里可以看到间隙所占的时间比例,已经是数据传输的好几倍了,这时再忽略间隙就不合适了,如果你忽略了间隙,那计算一下二分频的数据传输速率
    ,应该是256分频的128倍,当你实测一下,它肯定达不到这么高,因为这个二分频虽然干活效率高,但他每干一个时间单位,就要休息好几个时间单位,这怎么能达到它所生成的效率呢,所以通过看这个波形我们就清楚了,如果你想在极限频率下,进一步提高数据传输速率,追求最高性能,那最好使用连续传输的操作逻辑,或者还要进一步采用dma自动转运,这些方法效率都是非常高的。

    简单看一下软硬件波形对比,这里上面是软件波形,下面是硬件波形,这些和I2C的软件件波形对比其实都是差不多的,首先他们的数据变化趋势肯定是一样的,采样得到的数据也是一样的,区别就是硬件波形,数据线的变化是紧贴sck边沿的,而软件波形数据线的变化在边沿后有一些延迟,实际上我们还可以发现哈,I2C所描述的scl低电平期间数据变化,高电平期间数据采样和spi所描述的sck下降沿数据移出,上升沿数据移入最终波形的表现形式都是一样的,无论是下降沿变化还是低电平期间变化,它们都是一个意思,下降沿和低电平期间都可以作为数据变化的时刻,,只是硬件波形一般会紧贴边缘,软件波形一般只能在电平期间,不过最终都不会影响数据传输,不过软件波形如果能贴近边缘,我们还是贴近边缘,否则如果你等太久比较靠近下一个边沿,那数据也容易出错。

    手册



    接线图(和软件SPI一样):

    这个nss上小节说过,我们一般可以继续使用软件模拟的方式来实现,所以nss没必要必须接在PA4,其他三个引脚的话就必须得是PA567。

    如果SPI1的复用引脚被占用了,可以重定义到这个位置。

    常用库函数:

    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使能
    void SPI_I2S_SendData(SPI_TypeDef* SPIx, uint16_t Data); //写DR数据寄存器
    uint16_t SPI_I2S_ReceiveData(SPI_TypeDef* SPIx); //读DR
    //状态标志
    FlagStatus SPI_I2S_GetFlagStatus(SPI_TypeDef* SPIx, uint16_t SPI_I2S_FLAG);//获取TXE和RXNE标志位的状态
    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);
    

    代码:

    MySPI.h

    #ifndef __MYSPI_H
    #define __MYSPI_H
    
    void MySPI_Init(void);
    void MySPI_Start(void);
    void MySPI_Stop(void);
    uint8_t MySPI_SwapByte(uint8_t ByteSend);
    
    #endif
    
    

    MySPI.c

    #include "stm32f10x.h"                  // Device header
    
    /**
      * 函    数:SPI写SS引脚电平,SS仍由软件模拟
      * 参    数:BitValue 协议层传入的当前需要写入SS的电平,范围0~1
      * 返 回 值:无
      * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SS为低电平,当BitValue为1时,需要置SS为高电平
      */
    void MySPI_W_SS(uint8_t BitValue)
    {
    	GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);		//根据BitValue,设置SS引脚的电平
    }
    
    /**
      * 函    数:SPI初始化
      * 参    数:无
      * 返 回 值:无
      */
    void MySPI_Init(void)
    {
    	/*开启时钟*/
    	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
    	RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);	//开启SPI1的时钟
    	
    	/*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);					//将PA4引脚初始化为推挽输出软件模拟SS
    	
    	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;			//Input Pull Up
    	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
    	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA5和PA7引脚初始化为复用推挽输出
    	
    	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);					//将PA6引脚初始化为上拉输入 MISO
    	
    	/*SPI初始化*/
    	SPI_InitTypeDef SPI_InitStructure;						//定义结构体变量
    	SPI_InitStructure.SPI_Mode = SPI_Mode_Master;			//模式,选择为SPI主模式
    	SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;	//方向,选择双线全双工
    	SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;		//数据宽度,选择为8位
    	SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;		//先行位,选择高位先行
    	SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128;	//波特率分频,选择128分频
    	SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low;				//SPI极性,选择空闲默认低极性 SPI模式0
    	SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge;			//SPI相位,选择第一个时钟边沿采样(数据移入),极性和相位决定选择SPI模式0
    	SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;				//NSS,选择由软件控制
    	SPI_InitStructure.SPI_CRCPolynomial = 7;				//CRC多项式,暂时用不到,给默认值7
    	SPI_Init(SPI1, &SPI_InitStructure);						//将结构体变量交给SPI_Init,配置SPI1
    	
    	/*SPI使能*/
    	SPI_Cmd(SPI1, ENABLE);									//使能SPI1,开始运行
    	
    	/*设置默认电平*/
    	MySPI_W_SS(1);											//SS默认高电平
    }
    
    /**
      * 函    数:SPI起始
      * 参    数:无
      * 返 回 值:无
      */
    void MySPI_Start(void)
    {
    	MySPI_W_SS(0);				//拉低SS,开始时序
    }
    
    /**
      * 函    数:SPI终止
      * 参    数:无
      * 返 回 值:无
      */
    void MySPI_Stop(void)
    {
    	MySPI_W_SS(1);				//拉高SS,终止时序
    }
    
    /**
      * 函    数:SPI交换传输一个字节,使用SPI模式0
      * 参    数:ByteSend 要发送的一个字节
      * 返 回 值:接收的一个字节
      */
    uint8_t MySPI_SwapByte(uint8_t ByteSend)
    {
    	while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) != SET);	//等待发送数据寄存器空
    																	//这里一直卡死的概率不大,我们就不加超时机制了
    	
    	SPI_I2S_SendData(SPI1, ByteSend);								//写入数据到发送数据寄存器,开始产生时序
    	
    	while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) != SET);	//等待接收数据寄存器非空
    	
    	return SPI_I2S_ReceiveData(SPI1);								//读取接收到的数据并返回
    }
    

    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"
    
    /**
      * 函    数:W25Q64初始化
      * 参    数:无
      * 返 回 值:无
      */
    void W25Q64_Init(void)
    {
    	MySPI_Init();					//先初始化底层的SPI
    }
    
    /**
      * 函    数:MPU6050读取ID号
      * 参    数:MID 工厂ID,使用输出参数的形式返回
      * 参    数:DID 设备ID,使用输出参数的形式返回
      * 返 回 值:无
      */
    void W25Q64_ReadID(uint8_t *MID, uint16_t *DID)
    {
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_JEDEC_ID);			//交换发送读取ID的指令
    	*MID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//交换接收MID,通过输出参数返回
    	*DID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//交换接收DID高8位
    	*DID <<= 8;									//高8位移到高位
    	*DID |= MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//或上交换接收DID的低8位,通过输出参数返回
    	MySPI_Stop();								//SPI终止
    }
    
    /**
      * 函    数:W25Q64写使能
      * 参    数:无
      * 返 回 值:无
      */
    void W25Q64_WriteEnable(void)
    {
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_WRITE_ENABLE);		//交换发送写使能的指令
    	MySPI_Stop();								//SPI终止
    }
    
    /**
      * 函    数:W25Q64等待忙
      * 参    数:无
      * 返 回 值:无
      */
    void W25Q64_WaitBusy(void)
    {
    	uint32_t Timeout;
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1);				//交换发送读状态寄存器1的指令
    	Timeout = 100000;							//给定超时计数时间
    	while ((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01)	//循环等待忙标志位
    	{
    		Timeout --;								//等待时,计数值自减
    		if (Timeout == 0)						//自减到0后,等待超时
    		{
    			/*超时的错误处理代码,可以添加到此处*/
    			break;								//跳出等待,不等了
    		}
    	}
    	MySPI_Stop();								//SPI终止
    }
    
    /**
      * 函    数:W25Q64页编程
      * 参    数:Address 页编程的起始地址,范围:0x000000~0x7FFFFF
      * 参    数:DataArray	用于写入数据的数组
      * 参    数:Count 要写入数据的数量,范围:0~256
      * 返 回 值:无
      * 注意事项:写入的地址范围不能跨页
      */
    void W25Q64_PageProgram(uint32_t Address, uint8_t *DataArray, uint16_t Count)
    {
    	uint16_t i;
    	
    	W25Q64_WriteEnable();						//写使能
    	
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_PAGE_PROGRAM);		//交换发送页编程的指令
    	MySPI_SwapByte(Address >> 16);				//交换发送地址23~16位
    	MySPI_SwapByte(Address >> 8);				//交换发送地址15~8位
    	MySPI_SwapByte(Address);					//交换发送地址7~0位
    	for (i = 0; i < Count; i ++)				//循环Count次
    	{
    		MySPI_SwapByte(DataArray[i]);			//依次在起始地址后写入数据
    	}
    	MySPI_Stop();								//SPI终止
    	
    	W25Q64_WaitBusy();							//等待忙
    }
    
    /**
      * 函    数:W25Q64扇区擦除(4KB)
      * 参    数:Address 指定扇区的地址,范围:0x000000~0x7FFFFF
      * 返 回 值:无
      */
    void W25Q64_SectorErase(uint32_t Address)
    {
    	W25Q64_WriteEnable();						//写使能
    	
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB);	//交换发送扇区擦除的指令
    	MySPI_SwapByte(Address >> 16);				//交换发送地址23~16位
    	MySPI_SwapByte(Address >> 8);				//交换发送地址15~8位
    	MySPI_SwapByte(Address);					//交换发送地址7~0位
    	MySPI_Stop();								//SPI终止
    	
    	W25Q64_WaitBusy();							//等待忙
    }
    
    /**
      * 函    数:W25Q64读取数据
      * 参    数:Address 读取数据的起始地址,范围:0x000000~0x7FFFFF
      * 参    数:DataArray 用于接收读取数据的数组,通过输出参数返回
      * 参    数:Count 要读取数据的数量,范围:0~0x800000
      * 返 回 值:无
      */
    void W25Q64_ReadData(uint32_t Address, uint8_t *DataArray, uint32_t Count)
    {
    	uint32_t i;
    	MySPI_Start();								//SPI起始
    	MySPI_SwapByte(W25Q64_READ_DATA);			//交换发送读取数据的指令
    	MySPI_SwapByte(Address >> 16);				//交换发送地址23~16位
    	MySPI_SwapByte(Address >> 8);				//交换发送地址15~8位
    	MySPI_SwapByte(Address);					//交换发送地址7~0位
    	for (i = 0; i < Count; i ++)				//循环Count次
    	{
    		DataArray[i] = MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//依次在起始地址后读取数据
    	}
    	MySPI_Stop();								//SPI终止
    }
    

    这里的硬件spi必须是发送同时接收,要想接收必须得先发送,因为只有你给TDR写东西才会触发时序的生成,如果你不发送只调用接收函数,那时序是不会动的,然后还有一个注意事项,TXE和RXNE是不是会自动清楚的问,我在手册这个图上,看到这里写的是TXE标志,由硬件设置并由软件清除,下面RXNE写的也是由硬件设置由软件清除,这个有软件清除就比较迷惑,是不是要求我们在标志位置1之后,还需要我们手动调用ClearFlag函数清除呢,实际上这个并不需要我们手动清除,我们可以参考一下手册,在状态标志这一节,这里写了发送缓冲器空闲标志TXE,此标志为1时,表明发送缓冲器为空,可以写下一个待发送的数据进入缓冲器中,当写入SPI_DR时,TXE标志被清除,所以在程序这里我们等待TXE标志置1之后,不需要再手动调用一下ClearFlag函数,清除TXE标志位
    因为写入DR时,会顺便执行清除TXE的操作,而我们下一句代码就正好是写入DR,所以这个标志位不需要我们手动清除了,然后RXNE标志位也是一样,不确定就看下手册就行了。

    main.c

    #include "stm32f10x.h"                  // Device header
    #include "Delay.h"
    #include "OLED.h"
    #include "W25Q64.h"
    
    uint8_t MID;							//定义用于存放MID号的变量
    uint16_t DID;							//定义用于存放DID号的变量
    
    uint8_t ArrayWrite[] = {0x01, 0x02, 0x03, 0x04};	//定义要写入数据的测试数组
    uint8_t ArrayRead[4];								//定义要读取数据的测试数组
    
    int main(void)
    {
    	/*模块初始化*/
    	OLED_Init();						//OLED初始化
    	W25Q64_Init();						//W25Q64初始化
    	
    	/*显示静态字符串*/
    	OLED_ShowString(1, 1, "MID:   DID:");
    	OLED_ShowString(2, 1, "W:");
    	OLED_ShowString(3, 1, "R:");
    	
    	/*显示ID号*/
    	W25Q64_ReadID(&MID, &DID);			//获取W25Q64的ID号
    	OLED_ShowHexNum(1, 5, MID, 2);		//显示MID
    	OLED_ShowHexNum(1, 12, DID, 4);		//显示DID
    	
    	/*W25Q64功能函数测试*/
    	W25Q64_SectorErase(0x000000);					//扇区擦除
    	W25Q64_PageProgram(0x000000, ArrayWrite, 4);	//将写入数据的测试数组写入到W25Q64中
    	
    	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)
    	{
    		
    	}
    }
    

    BKP备份寄存器、PER电源控制器、RTC实时时钟

    实时时钟这个东西本质上是一个定时器,但这个定时器是专门用来产生年月日时分秒,这种日期和时间信息的,所以学会了stm32的RTC,你就可以在stm32内部拥有一个独立运行的钟表,想要记录或读取日期和时间,就可以通过操作RTC来实现,那rtc这个外设呢比较特殊,它和备份寄存器BKP、电源控制器PWR这两章的关联性比较强,在rtc这一章,bkp和pwr也会经常来串门,所以我们这节就把bkp和rtc放在一起讲,这样整体思路会比较清晰,pwr电源控制我们下一节再讲。

    实验现象:
    读写备份寄存器

    这里我们要在stlink上再引出一根3.3v的电源,接到VBAT引脚,这根线就模拟一个电池的电源一般情,况下VBAT是电池供电口需要接备用电池但是我们目前套件里没有电池,所以就直接引出一根3.3v电源线了也是一样的效果,那看一下显示屏,这个程序的目的是在bkp备份进器写入两个数据,然后再把它们读出来显示一下。
    bkp备份寄存器和上一节学的flash存储器类似,都是用来存储数据的,只是flash的数据是真正的掉电不丢失,而bkb的数据是需要VBAT引脚接上备用电池来维持的,本质是RAM,只要VBAT有电池供电,即使stm32主电源断电,bkp的值也可以维持原状,如果VBAT断电了那备份寄存器的数据就清零了。

    其实备份寄存器和VBAT引脚的存在,更多的是为了服务RTC的,所以我们接着看第二个代码,实时时钟下载看一下。

    11.0 Unix时间戳

    32位有符号数所能表示的最大数字是2^32/2-1这个数是21亿多,这其实是有溢出风险的,因为目前到2023年时间戳已经计到16亿了,32位有符号数的时间戳会在2038年的1月19号溢出,64位的时间戳能存储的时间范围非常非常的大,看下手册STM32它核心的计时部分,是一个32位的可编程计数器,这说明我们这款stm32 ,它的时间戳是32位的数据类型,这表示我们这个s t m32 也会在2038年出现bug吗,实际上并不会啊,因为根据我的研究,这个时间戳在stm32程序中定义的其实是无符号的,要到2106年才会溢出。


    为什么说GMT是以前的时间标准呢这是因为GMT有一个棘手的问题,就是地球自转一周的时间其实是不固定的,由于潮汐力、地球活动等原因,地球目前是越转越慢的。

    原子钟是当前计时最精确的装置,上千万年才误差一秒,那现在问题又来了,我们以一个恒定不变的秒来计时,但是地球自转越来越慢,这样记下去,计时的一天和自转的一天就会出现偏差,时间长一些,可能中午12点太阳就不是最高的位置,所以在原子钟计时系统的基础上,我们得加入闰秒的机制。

    time.h

    #include <stdio.h>
    #include <stdlib.h>
    #include <time.h>
    // struct tm {
    //     int tm_sec;   /* 秒,范围从0 到59       */
    //     int tm_min;   /* 分,范围从0 到59       */
    //     int tm_hour; /* 小时,范围从0 到23       */
    //     int tm_mday; /* 一月中的第几天,范围从1 到31   */
    //     int tm_mon;   /* 月,范围从0 到11       */
    //     int tm_year; /* 自1900 年起的年数      */
    //     int tm_wday; /* 一周中的第几天,范围从0 到6   */
    //     int tm_yday; /* 一年中的第几天,范围从0 到365   */
    //     int tm_isdst; /* 夏令时              */
    // };
    int main(int argc, char const *argv[])
    {
          time_t Time; // 时间戳
          struct tm Date;
          // Time = time(NULL);
      	  time(&Time);
          printf("Time = %ld\n", Time); // Time = 1695695610
          Date = *localtime(&Time);
          printf("%d\n", Date.tm_year + 1900);
          printf("%d\n", Date.tm_mon + 1);
          printf("%d\n", Date.tm_mday);
          printf("%d\n", Date.tm_hour);
          printf("%d\n", Date.tm_min);
          printf("%d\n", Date.tm_sec);
          printf("%d\n", Date.tm_wday);
          printf("%d\n", Date.tm_yday);
          printf("%s\n", ctime(&Time));
          printf("%s\n", asctime(&Time));
          puts(asctime(&Date));
          char t[50];
          strftime(t, 50, "%H-%M-\%S", &Date);
          printf(t);
          return 0;
    }
    

    BKP简介

    TAMPER引脚产生的侵入事件将所有备份寄存器内容清除,TAMPER是一个接到stm32外部的引脚,它的位置可以看一下引脚定义,这个TAMPER是一个安全保障设计,比如如果你做一个安全系数非常高的设备,设备需要有防拆功能,然后bkp里也存储了一些敏感数据,这些数据不能被别人窃取或者篡改,那你就可以使用这个TAMPER引脚的侵入检测功能,设计电路时,TAMPER引脚可以先加一个默认的上拉或者下拉电阻,然后引一根线到你的设备外壳的防拆开关或触点,别人一拆开你的设备触发开关,就会在TAMPER引脚产生上升沿或者下降沿,这样STM32就检测到侵入事件了,这时BKP的数据会自动清零,并且申请中断,你在中断里还可以继续保护设备,比如清除其他存储器数据,然后设备锁死,这样来保障设备的安全,另外主电源断电后,侵入检测仍然有效,这样即使设备关机也能防拆。

    RTC引脚输出RTC校准时钟、RTC闹钟脉冲或者秒脉冲,RTC引脚刚才看过了,也是在PC13这个位置
    ,这就是RTC时钟输出的功能,RTC的校准时钟,闹钟或者秒脉冲的信号,可以通过RTC引脚输出,其中外部用设备测量RTC校准时钟,可以对内部RTC微小的误差进行校准,然后闹钟脉冲或者秒脉冲可以输出出来,为别的设备提供这些信号,这是RTC时钟输出的功能,因为PC13、temple和RTC这三个引脚共用一个端口,所以这三个功能同一时间只能使用一个。

    接下来存储RTC时钟校准寄存器,这个可以配合上面这个校准时钟输出的功能,结合一些测量方法,可以对RTC进行校准,那这两个功能实际上就是RTC的配置,我觉得放在RTC那个外设的地方应该比较合适,当然RTC和BKP关联程度比较高,设计者目前就是把这两个RTC的功能放在BKP里了。

    最后看一下BKP中用户数据的存储容量,在中容量和小容量设备里,BKP是20个字节,在大容量和互联型设备里,BKP是84个字节,我们使用的c8t6是中容量设备,所以可以看出BKP的容量其实非常小,一般只能用来存储少量的参数。

    BKP基本结构:


    看一下BKP的基本结构,这个图中橙色部分我们可以叫做后备区域,BKP处于后备区域,但后备区域不只有BKP,还有RTC的相关电路也位于后备区域,STM32后备区域的特性就是当VDD主电源掉电时
    ,后备区域仍然可以由VBAT的备用电池供电,当VDD主电源上电时,后备区域供电会切换到VDD,主电源有电时VBAT不会用的,这样可以节省电池电量,然后BKP是位于后备区域的,BKP里主要有数据寄存器、控制寄存器、状态寄存器和RTC时钟校准寄存器这些东西,其中数据寄存器是主要部分,用来存储数据的,每个数据寄存器都是16位的,也就是一个数据寄存器可以存两个字节,那对于中容量和小容量的设备,里面有dr1、dr 2一直到dr10总共十个数据寄存器,那一个寄存器存两个字节,所以容量是20个字节。

    然后BKP还有几个功能,就下面这里的侵入检测,可以从PC13位置的TAMPER引脚引入一个检测信号
    ,当TAMPER产生上升沿或者下降沿时,清除BKP所有的内容以保证安全,时钟输出,可以把RTC的相关时钟,从pc13位置的RTC引脚输出数据供外部使用,其中输出较准时钟时,再配合这个校准寄存器
    ,可以对RTC的误差进行校准。


    20位的可编程预分频器,可适配不同频率的输入时钟,保证分频器输出给计数器的频率为1Hz,这样计时才正确。

    记住H开头是高速,L开头是低速,E结尾是外部,I结尾是内部,这里高速时钟一般供内部程序运行和主要外设使用,低速时钟一般供RTC看门狗这些东西使用,那对于我们本节的RTC呢,我们可以看到这一块,这里指向的箭头通往RTC,就是RTCCLK,RTCCLK有三个来源,第一个是OSC引脚接的HSE外部高速晶振,这个晶振是主晶振,我们一般都用的8MHZ,通过128分频可以产生RTCCLK信号,为什么要先128分频,这是因为这个8MHz的晶振太快了,如果不提前分频,直接给RTCCLK,后续即使再通过RTC的20位分频器,也分不到1Hz这么低的频率。
    然后中间这一路时钟来源是LSE,外部低速晶振,我们在oc32这两个引脚,接上外部低速晶振,这个晶振产生的时钟,可以直接提供给RTCCLK,这个osc32的晶振,是内部RTC的专用时钟,这个晶振的值也不是随便选的,通常跟RTC有关的晶振都是统一的数值,就是32.768KHz,为什么选择这个数值呢,一方面是32.768KHz这个值附近的频率,是晶振工艺比较合适的频率,你要说非要做一个1Hz的晶振,那可能是做不出来或者做出来了但体积很大性能很差,另一方面是32768这是一个二的次方数
    2的15次方等于32768,所以32.768KHz,经过一个15位分频器的自然溢出,就能很方便地得到1Hz的频率,自然溢出的意思就是设计一个15位的计数器,这个计数器不用设置计数目标,直接从0计到最大值,就计得32767,计满后自然溢出,这个溢出信号就是1Hz,自然溢出的好处就是不用再额外设计一个计数目标了,也不用比较计数是不是计到目标值,这样可以简化电路设计,所以目前在RTC电路中
    基本都是清一色的32.768KHz的晶振,你只要看到32.768KHz的晶振八成就是提供给rtc的,这是第二路,最后看第三路时钟源,来自于LSI,内部低速rc振荡器,LSI固定是40KHz,如果选择LSI当做RTCCLK,后续再经过40k的分频,就能得到1Hz的计数时钟了,当然内部的RC振荡器,一般精度没有外部晶振高,所以LSI给RTCCLK,l可以当做一个备选方案,另外LSI还可以提供给看门狗,我们最常用的就是中间这一路,外部32.768KHz的晶振,提供RTCCLK的时钟,第一个原因就是中间这一路
    32.768KHz的晶振本身就是专供rtc使用的,上下这两路其实是有各自的任务,上面这一路主要作为系统主时钟,下面这一路主要作为看门狗时钟,他们只是顺带可以备选当做rdc的时钟,另外一个更重要的原因,只有中间这一路的时钟可以通过VBAT备用电池供电,上下两路时钟在主电源断电后是停止运行的,所以要想实现rtc主电源掉电继续走时的功能,必须得选择中间这一路的rtc专用时钟,如果选择的是上下两路时钟,主电源断电后时钟就暂停了,这显然会导致走时出错。

    接下来我们来看一下这个rtc的框图,先整体上划分一下,左边这一块是核心的分频和计数计时部分,右边这一块是中断输出使能和NVIC部分,上面这一块是APB1总线读写部分,下面这块是PWR关联的部分,意思就是RTC的闹钟可以唤醒设备,退出待机模式,然后在图中我们看到有灰色填充的部分,都处于后备区域,这些电路在主电源掉电后,可以使用备用电池维持工作,另外这里还写了,这些模块在待机时都会继续维持供电,其他未被填充的部分就是待机时不供电,有关睡眠停机待机在低功耗相关的内容,我们下节学pwr的时候再来细讲。

    然后我们依次详细看一下,首先看分频和计数器计数部分,这一块的输入时钟是RTCCLK,这刚才说过RTCCLK的来源需要在RCC里进行配置,可以选择的选项是这三个,我们主要选择中间一路,那因为这三路时钟频率各不相同,而且都远大于我们所需要的1Hz的秒计数频率,所以RTCCLK进来,首先需要经过RTC预分频器进行分频,这个分频器由两个计算器组成,上面这个是重装载寄存器RTC_PRL,下面这个RTC_DIV手册里叫做余数寄存器,但实际上这一块跟我们之前定时器时机单元里的计数器CNT和重装值ARR是一样的,可能是右边已经有一个计数器cnt了,所以这个名字就比较奇怪,叫做余数寄存器,但实际上它还是计数器的作用,分频器其实就是一个计数器,记几个数溢出一次就几分频,所以对于可编程的分频器来说,需要有两个寄存器,一个寄存器用来不断的计数,另一个寄存器我们写入一个计数目标值,用来配置是几分频,那在这里上面这个PRL就是计数目标,我们写入六那就是七分频,写九那就是十分频,因为计数指包含了零,然后下面这个DIV就是每来一个时钟计一个数的用途了,当然这个DIV计数器啊是一个自减计数器,每来一个输入时钟,DIV的值自减一次
    自减到0时再来一个输入时钟,DIV输出一个脉冲产生溢出信号,同时DIV从PRL获取重装值,回到重装值继续自减。

    然后看一下计数计时部分,这一块就比较简单了,32位可编程计数器,RTC_CNT就是计时最核心的部分,我们可以把这个计数器看作是Unix时间戳的秒计数器,这样借助time.h的函数,就可以很方便地得到年月日时分秒了,然后在下面这里,这个RTC还设计的有一个闹钟寄存器RTC_ALR,这个ALR也是一个32位的寄存器,和上面这个cnt是等宽的,它的作用顾名思义就是设置闹钟,我们可以在ALR写一个秒数设定闹钟,当cnt的值跟ALR设定的闹钟值一样时,也是这里画了等号啊,如果他俩值相等就代表闹钟响了,这时就会产生RTC_Alarm闹钟信号,通往右边的中断系统,在中断函数里你可以执行相应的操作,同时这个闹钟还兼具一个功能,就下面这里的闹钟信号,可以让STM32退出待机模式
    这个就可以对应一些用途,比如你设计一个数据采集设备,需要在环境非常恶劣的地方工作,比如海底高原深井这些地方,然后要求是每天中午12点采集一次环境数据,其他时间为了节省电量避免频繁换电池,芯片都必须处于待机模式,这样的话我们就可以用这个rtc自带的闹钟功能,定一个中午12点的闹钟,闹钟一响芯片唤醒采集数据完成后继续待机,另外这个闹钟值是一个定值,只能响一次,所以如果你想实现周期性的闹钟,大家每次闹钟响之后都需要再重新设置一下下一个闹钟时间,就是这个闹钟和闹钟唤醒的一个用途。

    那继续往右看,这是中段部分的,在左边这里有三个信号可以触发中断,第一个是RTC_Second秒中断,它的来源就是cnt的输入时钟,如果开启这个中断,那么程序就会每秒进一次rtc中断,第二个是RTC_Overflow溢出中断,它的来源是cnt的右边,意思就是cnt的32位计数器计满溢出来了,会触发一次中断,所以这个中段一般不会触发,我们上一节说过,这个cnt定义的是无符号数,到2106年才会溢出,所以这个中段在2106年会触发一次,如果你想程序更完善一些,可以开启这个中段,到2106年就是一溢出,为了避免不必要的错误,你可以让芯片罢工,然后提示当前设备过老,请及时更换,但在2106年之后这个stm32的rtc就不太好用了,到时候或许可以通过打补丁的方式继续运行,或者直接淘汰32位的时间戳,这个问题就留给后人解决吧。

    来继续看下面第三个RTC_Alarm闹钟中断,刚才说过,当计数器和闹钟值相等时触发中断,同时闹钟信号可以把设备从待机模式唤醒,这是这三个中断信号,中断信号到右边这里,这一块就是中断标志位和中段输出控制,这些f结尾的是对应的中断标志位,ie结尾(Interrupt ENABLE)的是中断使能,最后三个信号通过一个或门汇聚到NVIC中断控制器,这个地方是不是漏画了一根线,中间这个应该也是要通过或门的,好这是右边的中断部分,然后上面这部分APB1总线和APB1接口,就是我们程序读写寄存器的地方,读写计算器可以通过APB1总线来完成,另外也可以看出,RTC是APB1总线上的设备,最后下面这一块退出待机模式,还有一个wake up引脚,闹钟信号和wake up引脚都可以唤醒设备
    ,wake up引脚可以看一下接线图,就这里PA0的位置它兼具唤醒的功能,这个我们下一节再学习。

    RTC基本结构:

    接下来看一下我这里给的基本结构,再总结一下,rtc的核心部分如图所示,最左边是RTCCLK时钟来源,这块需要在RCC里配置,三个时钟选择一个当做RTCCLK,之后RTCCLK先通过预分频器对时钟进行分频,余数寄存器是一个自减计数器,存储当前的计数值,重装寄存器是计数目标,决定分频值
    分频之后得到1Hz的秒计数信号通向32位计数器,一秒自增一次,下面还有个32位的闹钟值,可以设定闹钟,如果不需要闹钟的话,下面这一块可以不用管,然后右边有三个信号可以触发中断,分别是秒信号、计数器溢出信号和闹钟信号,三个信号先通过中断输出控制进行中断使能,使能的中断才能通向NVIC,然后向cpu申请中断,在程序中我们配置这个数据选择器,可以选择时钟来源,配置重装寄存器可以选择分频系数,配置32位计数器可以进行日期时间的读写,需要闹钟的话,配置32位闹钟值即可,需要中断的话,先允许中断,再配置nvic,最后写对应的中断函数即可,这是RTC外设的主要内容。

    为了配合stm32 ,rtc外部还是需要有一些电路的,在最小系统电路上,外部电路还要额外加两部分
    ,第一部分就是备用电池,第二部分就是外部低速晶振,首先备用电池供电部分,我这里给了两个参考电路,第一个是简单连接,就使用一个3v的电池,负极和系统工地,正极直接接到stm32 的VBAT引脚,这样就行了,这个供电方案非常简单,参考来源是stm32的数据手册,在5.1.6供电方案这里就给出来这个图,图上画的就是直接建一个1.8~3.6v的电池到VBAT就行了,另外也可以看到,在内部是有一个供电开关的,当vdd有电时,开关拨到下面,后备电路由vdd供电,当vdd没电时,开关拨到上面
    后备电路由VBAT供电,然后vbat供电的设备,在这里写了,vbat供电的后备电路有32KHz振荡器、rtc、唤醒电路和后备寄存器,那这就是根据数据手册里设计的VBAT供电方案,这个设计非常简单一般来说也没问题,然后我这里还给了第二种方案是推荐连接,这种连接方法是电池通过二极管D1向VBAT供电,另外主电源的3.3V,也通过二极管D2向VBAT供电,最后VBAT再加一个0.1uf的电源滤波电容,这个供电方案的参考来源是stm32的参考手册,在这个4.1.2电池备份区域这一节有这样描述
    大家可以都看看,其中手册里有几个建议,一个是在这些这些情况下,电流可能通过vdd和vbat之间的内部二极管注入到vbat,如果与vbat连接的电源或者电池,不能承受这样的注入电流,强烈建议在外部,vbat和电源之间连接一个低压降的二极管,另一个是如果在应用中没有外部电池,建议vbat在外部连接到vdd,并连接一个100nf的陶瓷滤波电容,所以综合这两条建议,我们可以设计出右边的推荐连接,电池和主电源都加一个二极管,防止电流倒灌,vbat加一个0.1uf的电源滤波电容,0.1uf就是100nf,如果没有备用电池,就3.3V的主电源供电,如果接了备用电池,3.3v没电时,就是备用电池供电,这是根据参考手册设计的推荐电路,如果你只是进行实验,那使用左边的简单连接就行了,如果你要画板子设计产品,那还是推荐使用右边的连接,这样更保险,这是vbat供电部分。

    然后继续看一下右边的外部低速晶振部分,这是一个典型的晶振电路了,这里X1是一个32.768KHz的rtc晶振,这个晶振不分正负极,两端分别接在osc32 这两个引脚上,然后进这两端再分别接一个启动电容到GND,这个电路的设计,参考来源还是stm32的数据手册,在5.3.6外部时钟源特性这里有参考电路,使用一个晶体或陶瓷谐振器产生的低速外部时钟,下面这里就是典型电路,晶振是32.768KHz
    ,CL1和CL2上面这里写了,对于CL1和CL2,建议使用高质量的5pF~15pF之间的瓷介电容器,所以对于硬件电路的设计,但还是得多看看手册,手册看多了自然就会了,所以在这里我给出的晶振电路是这样的,起振电容给的是10pF。

    最后看一下右边的图片,这个备用电池,我们一般可以选择这样的3v纽扣电池,型号是CR2032,这是一个非常常用的纽扣电池型号,另外注意这个纽扣电池印制的这一面是正极,这里也有个正号标注
    ,另一面比较小的那个电极是负极,然后32.768KHz的晶振,我们可以选择这样的一个金属壳柱状体的晶振,这个晶振也是比较常见,大家拆开钟表电子表基本上都能找到这样一个元件,这是32.768KHz的晶振,晶振的全称是石英晶体振荡器,所以我们常说的石英钟,名称就来源于这样一个元件,然后下面这个是我们的最小系统板,这个板子自带的有rtc晶振电路,这里这个黑色的元件写的有32.768k,这个也是一种样式的RTC晶振,然后旁边这个金属壳柱状体是8MHz的外部高速晶振,不过我们这个板子没有自带备用电池,vbat引脚直接通过右上角的这个端口引出来了,如果需要备用电池的话,可以接在这里,以上就是RTC的硬件电路部分。

    最后我们再看一些RTC的一些操作注意事项,这些注意事项都是从手册里复制过来的文字,写程序的时候需要注意这些问题。

    设置RCC_APB1ENR的PWREN和BKPEN,使能PWR和BKP时钟,设置PWR_CR的DBP,使能对BKP和RTC的访问。
    这几条就提醒一下,正常的外设第一步开启时钟就能用了,但是BKP和RTC这两个外设,开启稍微复杂一些,如果你要使用BKP或者RTC,都要先执行这两步,第一步开启PWR和BKP的时钟,第二步使用PWR使能BKP和RTC的访问,这个我们在初始化的时候需要注意一下,按照这个流程来就行了。

    若在读取RTC寄存器时,RTC的APB1接口曾经处于禁止状态,则软件首先必须等待RTC_CRL寄存器中的RSF位(寄存器同步标志)被硬件置1
    这一步对应代码里的一个库函数就是RTC等待同步,一般在刚上电的时候调用一下这个函数就行了,为什么要有这一步呢,可以看看框图,在这里会有两个时钟,PCLK1和PCLK2,PCLK1在主电源掉电时会停止,所以为了保证RTC主电源掉电正常工作,RTC里的寄存器,都是在RTCCLK的同步下变更的,当我们用PCLK驱动的总线去读取RTCCLK驱动的寄存器时,就会有个时钟不同步的问题,RTC寄存器只有在RTCCLK的上升沿更新,但是PCLK1的频率36MHz远大于RTCCLK的频率32KHz,如果我们在APB1刚开启时,就立刻读取RTC寄存器,有可能RTC寄存器还没有更新到APB1总线上,这样我们读到的值就是错误的,通常来说就读取到0,所以这就要求我们在APB1总线刚开机时,要等一下RTCCLK,只要RTCCLK来一个上升沿,rtc把它的寄存器的值同步到APB1总线上,这样之后读取的值就都是没问题的了,这是设计细节的一个问题,当然我们其实也不用管那么多了,只需要在初始化时调用一个等待同步的函数就行了。

    必须设置RTC_CRL寄存器中的CNF位,使RTC进入配置模式后,才能写入RTC_PRL、RTC_CNT、RTC_ALR寄存器
    这一条其实比较简单,就是RTC会有一个进入配置模式的标志位,把这一位置1才能设置时间,其实这个操作在库函数中,每个写寄存器的函数,它都自动帮我们加上了这个操作,所以我们就不用再单独调用函数进入配置模式了。

    对RTC任何寄存器的写操作,都必须在前一次写操作结束后进行。可以通过查询RTC_CR寄存器中的RTOFF状态位,判断RTC寄存器是否处于更新中。仅当RTOFF状态位是1时,才可以写入RTC寄存器
    这个操作也是调用一个等待的函数就行了,跟我们之前读写flash芯片是类似的,就写入之前要等待一下,如果上一次的写入还没完成,你就别急着写下一次了,或者说每次写入之后,你要等待RTOFF为1,只有RTOFF为1才表示写完成,为什么要有这个操作呢,其实还是因为这里的PCLK1和RTCCLKk时钟频率不一样,你用PCLK1的频率写入之后,这个值还不能立刻更新到RTC的寄存器里,因为RTC寄存器是由RTCCLK驱动的,所以PCLK1写完之后,得等一下RTCCLK的时钟,RTCCLK来个上升沿
    值更新到RTC寄存器里,整个写作过程才算结束了,这个操作了解一下,在代码里也就是调用一个等待函数的事。

    手册:

    代码示例:读写备份寄存器BKP

    读写备份寄存器接线图:

    常用库函数:

    void BKP_DeInit(void); // 恢复缺省配置,将所有配置清0  纽扣电池一直有电后不会自动清0的
    // 用于Tamper侵入检测功能
    void BKP_TamperPinLevelConfig(uint16_t BKP_TamperPinLevel); // 侵入检测功能 配置有效电平高电平、低电平触发
    void BKP_TamperPinCmd(FunctionalState NewState);            // 是否开启侵入检测功能
    void BKP_ITConfig(FunctionalState NewState);                // 配置中断
    void BKP_RTCOutputConfig(uint16_t BKP_RTCOutputSource);     // 时钟输出功能的配置 可以选择在RTC引脚上输出时钟信号  输出RTC校准时钟RTC闹钟脉冲或者秒脉冲
    void BKP_SetRTCCalibrationValue(uint8_t CalibrationValue);  // 设置RTC校准值(写入RTC校准寄存器)
    
    // 读写BKP
    void BKP_WriteBackupRegister(uint16_t BKP_DR, uint16_t Data);
    uint16_t BKP_ReadBackupRegister(uint16_t BKP_DR);
    FlagStatus BKP_GetFlagStatus(void);
    void BKP_ClearFlag(void);
    ITStatus BKP_GetITStatus(void);
    void BKP_ClearITPendingBit(void);
    
    void PWR_DeInit(void);
    void PWR_BackupAccessCmd(FunctionalState NewState); //备份寄存器访问使能  前面讲的设置PWR_CR的DBP,使能对BKP和RTC的访问
    void PWR_PVDCmd(FunctionalState NewState);
    void PWR_PVDLevelConfig(uint32_t PWR_PVDLevel);
    void PWR_WakeUpPinCmd(FunctionalState NewState);
    void PWR_EnterSTOPMode(uint32_t PWR_Regulator, uint8_t PWR_STOPEntry);
    void PWR_EnterSTANDBYMode(void);
    FlagStatus PWR_GetFlagStatus(uint32_t PWR_FLAG);
    void PWR_ClearFlag(uint32_t PWR_FLAG);
    

    代码:
    main.c

    #include "stm32f10x.h"                 // Device header
    #include "Delay.h"
    #include "OLED.h"
    #include "Key.h"
    uint16_t Write[] = {0x1234, 0x5678};
    uint16_t Read[2];
    uint8_t KeyNum;
    int main(void)
    {
        OLED_Init();
        KEY_Init();
        OLED_ShowString(1, 1, "W:");
        OLED_ShowString(2, 1, "R:");
        // BKP初始化
        RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
        RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
        // 备份寄存器访问使能
        PWR_BackupAccessCmd(ENABLE);
        while (1)
        {
            KeyNum = Key_GetNum();
            if (KeyNum == 1)
            {
                BKP_WriteBackupRegister(BKP_DR1, Write[0]); // DR 1-10 c8t6
                BKP_WriteBackupRegister(BKP_DR2, Write[1]);
                Write[0]++;
                Write[1]++;
                OLED_ShowHexNum(1, 3, Write[0], 4);
                OLED_ShowHexNum(2, 3, Write[0], 4);
            }
            Read[0] = BKP_ReadBackupRegister(BKP_DR1);
            Read[1] = BKP_ReadBackupRegister(BKP_DR2);
            OLED_ShowHexNum(1, 3, Read[0], 4);
            OLED_ShowHexNum(2, 3, Read[1], 4);
        }
    }
    

    11.2 RTC实时时钟

    实时时钟接线图:

    RTC这个程序我们暂时没用到按键,然后RTC的外部低速晶振上小节说过,晶振电路板上自带的就有,所以RTC晶振部分我们接线就不用管了。

    常用函数:

    void RCC_LSEConfig(uint8_t RCC_LSE);              // 启动LSE时钟
    void RCC_LSICmd(FunctionalState NewState);        // 配置LSI内部低速时钟
    void RCC_RTCCLKConfig(uint32_t RCC_RTCCLKSource); // CLK时钟源数据选择器
    void RCC_RTCCLKCmd(FunctionalState NewState);     // 启动RCCCLK
    void RCC_GetClocksFreq(RCC_ClocksTypeDef *RCC_Clocks);
    void RCC_AHBPeriphClockCmd(uint32_t RCC_AHBPeriph, FunctionalState
                                                           NewState);
    void RCC_APB2PeriphClockCmd(uint32_t RCC_APB2Periph, FunctionalState
                                                             NewState);
    void RCC_APB1PeriphClockCmd(uint32_t RCC_APB1Periph, FunctionalState
                                                             NewState);
    FlagStatus RCC_GetFlagStatus(uint8_t RCC_FLAG); // 获取标志位(LSE时钟不是调用完就立马启动的)
    
    
    void RTC_ITConfig(uint16_t RTC_IT, FunctionalState NewState); // RTC中断
    void RTC_EnterConfigMode(void);                               // 进入配置模式 前面讲的必须设置RTC_CRL寄存器中的CNF位,使RTC进入配置模式后,才能写入RTC_PRL、RTC_CNT、RTC_ALR寄存器
    RTC_CRL_CNF = 1 void RTC_ExitConfigMode(void);                // 退出配置模式
    uint32_t RTC_GetCounter(void);                                // 获取CNT计数器的值
    void RTC_SetCounter(uint32_t CounterValue);                   // 写入CNT计数器的值设置时间
    void RTC_SetPrescaler(uint32_t PrescalerValue);               // PSC 预分频器分频系数
    void RTC_SetAlarm(uint32_t AlarmValue);                       // 闹钟值
    uint32_t RTC_GetDivider(void);                                // 读取余数寄存器 为了得到更细致的时间,因为CNT计数间隔最短就是1S,分秒、毫秒要用到
    void RTC_WaitForLastTask(void);                               // 等待上一次操作完成(前面讲的等待RTOFF状态为1)
    void RTC_WaitForSynchro(void);                                // 等待同步(前面讲的等待RSF标志位置1)
    FlagStatus RTC_GetFlagStatus(uint16_t RTC_FLAG);
    void RTC_ClearFlag(uint16_t RTC_FLAG);
    ITStatus RTC_GetITStatus(uint16_t RTC_IT);
    void RTC_ClearITPendingBit(uint16_t RTC_IT);
    

    代码:
    main.c

    #include "stm32f10x.h"                  // Device header
    #include "Delay.h"
    #include "OLED.h"
    #include "MyRTC.h"
    
    int main(void)
    {
    	/*模块初始化*/
    	OLED_Init();		//OLED初始化
    	MyRTC_Init();		//RTC初始化
    	
    	/*显示静态字符串*/
    	OLED_ShowString(1, 1, "Date:XXXX-XX-XX");
    	OLED_ShowString(2, 1, "Time:XX:XX:XX");
    	OLED_ShowString(3, 1, "CNT :");
    	OLED_ShowString(4, 1, "DIV :");
    	
    	while (1)
    	{
    		MyRTC_ReadTime();							//RTC读取时间,最新的时间存储到MyRTC_Time数组中
    		
    		OLED_ShowNum(1, 6, MyRTC_Time[0], 4);		//显示MyRTC_Time数组中的时间值,年
    		OLED_ShowNum(1, 11, MyRTC_Time[1], 2);		//月
    		OLED_ShowNum(1, 14, MyRTC_Time[2], 2);		//日
    		OLED_ShowNum(2, 6, MyRTC_Time[3], 2);		//时
    		OLED_ShowNum(2, 9, MyRTC_Time[4], 2);		//分
    		OLED_ShowNum(2, 12, MyRTC_Time[5], 2);		//秒
    		
    		OLED_ShowNum(3, 6, RTC_GetCounter(), 10);	//显示32位的秒计数器CNT值
    		OLED_ShowNum(4, 6, RTC_GetDivider(), 10);	//显示余数寄存器
    	}
    }
    

    MyRTC.h

    #ifndef __MYRTC_H
    #define __MYRTC_H
    
    extern uint16_t MyRTC_Time[];
    
    void MyRTC_Init(void);
    void MyRTC_SetTime(void);
    void MyRTC_ReadTime(void);
    
    #endif
    

    MyRTC.c

    #include "stm32f10x.h"                  // Device header
    #include <time.h>
    
    uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};	//定义全局的时间数组,数组内容分别为年、月、日、时、分、秒  刷新到RTC外设里
    //注意数组里这些数据前面不要补0,C语言会默认为八进制  if(123 == 0123)这就不成立
    
    void MyRTC_SetTime(void);				//函数声明
    
    /**
      * 函    数:RTC初始化
      * 参    数:无
      * 返 回 值:无
      */
    void MyRTC_Init(void)
    {
    	/*开启时钟*/
    	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);		//开启PWR的时钟
    	RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);		//开启BKP的时钟
    	
    	/*备份寄存器访问使能*/
    	PWR_BackupAccessCmd(ENABLE);							//使用PWR开启对备份寄存器的访问
    	
    	if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5)			//通过写入备份寄存器的标志位,判断RTC是否是第一次配置   不然每次复位,时间都会重置
    															//if成立则执行第一次的RTC配置
    	{
    		RCC_LSEConfig(RCC_LSE_ON);							//开启LSE时钟(默认是关闭的,省电)  这里有个可选参数:LSE_Bypass表示LSE时钟旁路,时钟旁路的意思就是不要接晶振,直接从OSE32_IN这个引脚输入一个指定频率的信号,这样也可以当做时钟源,比较方便,不过用的不多
    		while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);	//等待LSE振荡器时钟启动完成
    		//我实测有些板子是有问题就是RTC晶振启动不了,不起振,这个程序就会卡死在这个位置
    		//一直等待LSERDY标志位,解决方案:时钟源改为备选内部晶振LSI
    		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);				//选择RTCCLK时钟来源为LSE
    		RCC_RTCCLKCmd(ENABLE);								//RTCCLK使能
    		//因为这个RTC比较简单,所以库函数并没有使用结构体来配置,开启时钟就能自动运行了
    		
    		RTC_WaitForSynchro();								//等待同步	防止时钟不同步造成bug
    		RTC_WaitForLastTask();								//等待上一次操作完成
    		
    		RTC_SetPrescaler(32768 - 1);						//设置RTC预分频器,预分频后的计数频率为1Hz,LSE的频率是32.768KHz
    		RTC_WaitForLastTask();								//等待上一次操作完成
    	
    		//RTC_SetCounter(1672588795);
    		//RTC_WaitForLastTask();
    		MyRTC_SetTime();									//设置时间,调用此函数,全局数组里时间值刷新到RTC硬件电路
    		
    		BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);			//在备份寄存器写入自己规定的标志位,用于判断RTC是不是第一次执行配置
    	}
    	else													//RTC不是第一次配置
    	{
    		RTC_WaitForSynchro();								//等待同步
    		RTC_WaitForLastTask();								//等待上一次操作完成
    	}
    }
    
    //如果LSE无法起振导致程序卡死在初始化函数中
    //可将初始化函数替换为下述代码,使用LSI当作RTCCLK
    //LSI无法由备用电源供电,故主电源掉电时,RTC走时会暂停
    /* 
    void MyRTC_Init(void)
    {
    	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
    	RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
    	
    	PWR_BackupAccessCmd(ENABLE);
    	
    	if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5)
    	{
    		RCC_LSICmd(ENABLE);
    		while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) != SET);
    		
    		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);
    		RCC_RTCCLKCmd(ENABLE);
    		
    		RTC_WaitForSynchro();
    		RTC_WaitForLastTask();
    		
    		RTC_SetPrescaler(40000 - 1);
    		RTC_WaitForLastTask();
    		
    		MyRTC_SetTime();
    		
    		BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);
    	}
    	else
    	{
    		RCC_LSICmd(ENABLE);				//即使不是第一次配置,也需要再次开启LSI时钟
    		while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) != SET);
    		
    		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);
    		RCC_RTCCLKCmd(ENABLE);
    		
    		RTC_WaitForSynchro();
    		RTC_WaitForLastTask();
    	}
    }*/
    
    /**
      * 函    数:RTC设置时间
      * 参    数:无
      * 返 回 值:无
      * 说    明:调用此函数后,全局数组里时间值将刷新到RTC硬件电路
      */
    void MyRTC_SetTime(void)
    {
    	time_t time_cnt;		//定义秒计数器数据类型
    	struct tm time_date;	//定义日期时间数据类型
    	
    	time_date.tm_year = MyRTC_Time[0] - 1900;		//将数组的时间赋值给日期时间结构体
    	time_date.tm_mon = MyRTC_Time[1] - 1;
    	time_date.tm_mday = MyRTC_Time[2];
    	time_date.tm_hour = MyRTC_Time[3];
    	time_date.tm_min = MyRTC_Time[4];
    	time_date.tm_sec = MyRTC_Time[5];
    	
    	time_cnt = mktime(&time_date) - 8 * 60 * 60;	//调用mktime函数,将日期时间转换为秒计数器格式
    													//- 8 * 60 * 60为东八区的时区调整
    	
    	RTC_SetCounter(time_cnt);						//将秒计数器写入到RTC的CNT中
    	RTC_WaitForLastTask();							//等待上一次操作完成
    }
    
    /**
      * 函    数:RTC读取时间
      * 参    数:无
      * 返 回 值:无
      * 说    明:调用此函数后,RTC硬件电路里时间值将刷新到全局数组
      */
    void MyRTC_ReadTime(void)
    {
    	time_t time_cnt;		//定义秒计数器数据类型
    	struct tm time_date;	//定义日期时间数据类型
    	
    	time_cnt = RTC_GetCounter() + 8 * 60 * 60;		//读取RTC的CNT,获取当前的秒计数器
    													//+ 8 * 60 * 60为东八区的时区调整
    	
    	time_date = *localtime(&time_cnt);				//使用localtime函数,将秒计数器转换为日期时间格式
    	
    	MyRTC_Time[0] = time_date.tm_year + 1900;		//将日期时间结构体赋值给数组的时间
    	MyRTC_Time[1] = time_date.tm_mon + 1;
    	MyRTC_Time[2] = time_date.tm_mday;
    	MyRTC_Time[3] = time_date.tm_hour;
    	MyRTC_Time[4] = time_date.tm_min;
    	MyRTC_Time[5] = time_date.tm_sec;
    }
    

    最后一个DIV正在快速的自减,自减的范围是32767~0,DIV每自减一轮,CNT秒数加1,有了这个数我们就可以对秒数进行更细的划分,获取分秒厘秒毫秒这些参数了。

    十二、PWR电源控制

    实验现象
    1、修改主频
    修改主频不属于三种低功耗模式,但是也是降低STM32功耗的一种方法。

    2、睡眠模式+串口发送+接收

    睡眠模式加串口的发送和接收,这个程序就是从串口那一节直接复制过来的,这个代码的功能就是当收到一个字节时,中断触发,置标志位,主循环查询到标志位时读取数据,并用串口发送数据,在这个功能后面,又新加了一段代码,这个就是用来配置睡眠模式的代码,执行芯片就进入睡眠,睡眠的目的是,如果STM32一直没收到数据,那这个主循环也会一直查询标志位,那不如就让它睡眠,收到数据后自动退出睡眠模式,执行一遍任务后继续睡眠,这样在空闲时芯片一直在睡眠,可以降低系统功耗。
    另外还要重点提醒一下,芯片在三种低功耗模式下,是没法直接下载程序的,如果直接点下载,就会提示报错,不会理你调试端口了,解决方法也很简单,需要我们有一些操作,第一步我们按住复位键不放,第二步点下载按钮,第三步及时松开复位键,这样就能下载成功了,在我们本节三种低功耗模式下,都需要这样下载程序,大家注意一下,另外如果你不小心禁用了调试端口,其实也可以这样来解决。

    只有在我们发送数据时刻,OLED才会显示一次running,在空闲时芯片一直都在睡眠,这样就是在不影响程序功能的前提下,使用睡眠模式节约电量。

    3、停止模式+对射式红外传感器计次

    每次遮挡一次,执行一次记次,也显示一下running,在没有外部中断信号时,STM32处于停止模式,可以省电。

    4、待机模式+实时时钟

    这个程序是在实时时钟的基础上,加入了待机模式,目前这个程序我使用的是LSE外部低速时钟,如果你没有RTC晶振或者RTC晶振不起振,也可以使用LSI内部低速时钟,LSI在待机模式下可以继续工作,然后在这个位置可以加入唤醒后要执行的功能,在进入待机模式之前,可以关闭各个外部连接的模块,以最大化省电,我目前是用Oled_Clear模拟了一下,那这个程序会用实时时钟设定闹钟,每隔一段时间会自动唤醒一次,这里我演示的是每隔十秒唤醒一次,唤醒之后执行一遍程序任务,然后继续待机。

    可以看到OLED上显示了当前时钟和闹钟,随后进入待机,然后等一会儿,闹钟触发之后自动唤醒一次,设定新的那种执行程序功能之后继续待机,等待下一次唤醒,这是使用RTC和闹钟配合待机模式的自动唤醒程序,非常适合那种需要每隔一段时间操作一次,空闲时间又需要最大化省电的设备。

    12.1 PWR简介


    可编程电压监测器(PVD)可以监控VDD电源电压,当VDD下降到PVD阀值以下或上升到PVD阀值之上时,PVD会触发中断,用于执行紧急关闭任务。
    这个功能预想的场景应该是使用电池供电,或者对安全要求比较高的设备,如果供电电压在逐渐下降
    ,在电压过低的情况下,可能会导致内部或外部电路发生不确定的错误,为了避免不确定的因素,在电源电压低于设定的阈值时,我们可以主动出击,提前发出警告,并且关闭比较危险的设备,这是这个PVD的设计,不过PVD这个功能不是我们本节课的重点哈,我们暂时也不演示代码。

    在低功耗模式下,我们也需要保留必要的唤醒电路,比如串口接收数据的中断唤醒,外部中断唤醒
    RTC闹钟唤醒等,在需要设备工作时,STM32能够立刻重新投入工作,如果你只考虑进入低功耗,而不考虑唤醒STM32,那不就跟直接断电没区别了吗,所以低功耗模式我们要考虑关闭哪些硬件,保留哪些硬件以及如何去唤醒,当然关闭越多的硬件设备越省电,唤醒就越麻烦。

    电源框图:

    这个图就是STM32内部的供电方案,整体上看这个图可以分为三个部分,最上面是模拟部分供电叫做VDDA,中间是数字部分供电,包括两块区域,VDD供电区域和1.8v供电区域,下面是后备供电,叫做VBAT。

    依次看一下,VADDA供电区域,主要负责模拟部分的供电,其中包括AD转换器、温度传感器、复位模块、PLL锁相环,这些电路的供电正极是VDDA,负极是VSSA,其中AD转换器还有两个参考电压的供电脚,叫做VREF+和VREF-,这两个脚在引脚多的型号里会单独引出来,在引脚少的型号,比如我们这个C8T6,VREF+和VREF-在内部就已经分别接到了VDDA和VSSA了。

    然后看中间部分的供电,这一块由两部分组成,左边部分是VDD供电区域,其中包括IO电路、待机电路、唤醒逻辑和独立看门狗,右边部分是VDD通过电压调节器降压到1.8V,提供给后面这一块的1.8V供电区域,1.8V区域包括CPU核心、存储器和内置数字外设,可以看出来STM32内部的大部分关键电路,CPU、存储器和外设其实都是以1.8V的低电压运行的,当这些外设需要与外界进行交流时,才会通过IO电路转换到3.3V,所以我们从外部看,好像STM32内部全是3.3V,但实际上它内部的CPU、外设等都是以1.8V供电运行,使用低电压运行的主要目的是降低功耗,电压越低内部电路运行的功耗就相对越低。

    然后这个电压调节器,它的作用是给1.8V区供电,因为我们后面会提到这个1.8V区域和电压调节器,最下面就是我们上一节提到的VBAT后备供电区域了,其中包括LSE 32K晶体振荡器、后备寄存器,RCC BDCR计寄存器和RTC,RCC BDCR是RTC的寄存器啊,叫做备份域控制寄存器,也是和后备区域有关的寄存器,所以也可以有VBAT供电,然后这里有个低电压检测器,可以控制这个开关,VDD有电时由VDD供电,VDD没电时由VBAT供电。

    上电复位和掉电复位:

    上电复位和掉电复位,还有可编程电压监测器这,两个内容了解即可,首先是上电复位和掉电复位,这个意思是当VDD或者VDDA电压过低时,内部电路直接产生复位,让STM32复位住不要乱操作,这个复位和不复位的界限之间,设置了一个40毫伏的迟滞电压,大于上限POR(Power On Reset)时解除复位,小于下限PDR(Power Down Reset)时复位,这是一个典型的迟滞比较器,设置两个阈值的作用
    ,就是防止电压在某个阈值附近波动时,造成输出也来回抖动,下面的复位信号reset是低电平有效的
    ,所以在前面和后面电压过低时是复位的,中间电压正常的时候不复位,那这个电压上限和下限具体是多少伏呢,还有这里解除复位,还有个滞后时间是多久呢,这些参数可以看一下STM32数据手册
    ,在5.3.3内嵌复位和电源控制模块特性里有这个表,这里写了上电或掉电复位阈值,下降沿,也就是PDR掉电复位的阈值下限,典型值是1.88V,上升沿,也就是POR上电复位的阈值上限,典型值是1.92V,1.92-1.88就是迟滞的阈值40毫伏,所以如果忽略迟滞的话,简单来说就是大于1.9V上电,低于1.9V掉电,然后最后一行就是TRSTTEMPO,复位持续时间,典型值是2.5ms,就是这个上电复位和掉电复位,知道一下就行了,也不需要我们操作啥的。

    可编程电压监测器:

    然后下面这个是可编程电压监测器,简称PVD,他的工作流程和上面这个差不多哈都是监测VDD和,VDDA的供电电压,但是PVD的区别就是,首先它这个阈值电压是可以使用程序指定的,可以自定义调节,调节的范围可以看一下数据手册,在这个表的上面就是PVD的阈值,配置PLS寄存器的3个位
    可以选择右边这么多的阈值,因为这里也同样是迟滞比较,所以有两个阈值,可选范围是2.2V到2.9V左右,PVD上限和下限之间的迟滞电压是100毫伏,可以看到PVD的电压是比上电掉电复位的电压要高的,画个图就是3.3伏是正常的供电,当这个电压降低在2.9伏到2.2伏之间,属于PVD监测的范围
    ,可以通过PVD设置一个警告线,之后再降低到1.9伏,就是复位电路的检测范围,低于1.9伏直接复位住不让动,就是这两个电压监测的工作任务,那当然PVD触发之后,芯片还是能正常工作的,只不过是电源电压过低,该提醒一下用户了,所以看一下下面这个PVD输出,这个是正逻辑哈,电压过低时为1,电压正常值为0,这个信号可申请中断,在上升沿或者下降沿时触发中断,一是提醒程序进行适当的处理,另外这个PVD的中断申请,是通过外部中断实现的,我们可以看一下外部中断这一节,这个图(EXTI基本结构图)可以看到PVD输出的信号是跑到这里来了,所以如果要使用PVD的话,记得要配置外部中断,然后下面这里还有RTC(EXTI基本结构图),这个是RTC的闹钟信号,也有接到外部中断,其实RTC自己是有中断的,那为啥还要借到外部中断,这个等会就知道了,因为低功耗模式设计的是,只有外部中断可以唤醒停止模式,其他这些设备也想唤醒停止模式的话,都可以通过借道外部中断来实现,其实后面这两个USB和ETH,也都只有他们的wake up唤醒信号接过来了,目的也是为了唤醒停止模式,这个了解一下。

    低功耗模式:

    接着我们来看看本节课的重点
    低功耗模式,这三种模式从上到下关闭的电路越来越多,对应的从上到下是越来越省电,同时从上到下也是越来越难唤醒的,首先看一下睡眠模式,这是浅睡眠,如何进入呢,这里写了直接调用WFI或者WFE即可进入,这两个东西是内核的指令,对应库函数里也有对应的函数,直接调用函数即可,其中WFI的意思是wait for interrupt等待中断,意思就是我先睡了,如果有中断发生的话再叫我起来,所以对应的唤醒条件是任意中断,调用WIFI进入的睡眠模式,任何外设发生任何中断时,芯片都会立刻醒来,因为中断发生了,醒来之后的第一件事一般就是处理中断函数,然后下面WFE意思是wait for event等待事件,对应的唤醒条件是唤醒事件,这个事件可以是外部中断配置为事件模式,也可以是使能了中断,但是没有配置NVIC,调用WFE进入的睡眠模式,产生唤醒事件时会立刻醒来,醒来之后一般不需要进中断函数,直接从睡的地方继续运行,这是WFI和WFE的作用,相同点是调用任意一个之后,芯片都进入睡眠,不同点是WFI进入的得用中断唤醒,WFE进入的得用事件唤醒,最后看一下睡眠模式对电路的影响,对1.8V区域时钟的影响是,CPU时钟关,对其他时钟和ADC时钟无影响,对VDD区域时钟的影响是无,对电压调节器的操作是开,所以睡眠模式对电路的影响就是,只把CPU时钟关了,对其他电路没有任何操作,CPU时钟关了,程序就会暂停,不会继续运行了,CPU不运行芯片功耗就会降低,另外这里还可以看出,关闭电路通常有两个做法,一个是关闭时钟,另一个是关闭电源,关闭时钟,所有的运算和涉及时序的操作都会暂停,但是寄存器和存储器里面保存的数据还可以维持不会消失,关闭电源就是电路直接断电,电路的操作和数据都会直接丢失,所以关闭电源比关闭时钟更省电,这个表里的这两点,就对1.8V区域和VDD区域的时钟控制,然后这个电压调节器刚才看,它实际上就是1.8V区域的电源,如果电压调节器关,就代表直接把1.8V区域断电,这个了解一下
    它唤醒条件也是比较宽松,任何的风吹草动,CPU都会醒来开始干活,所以睡眠模式相当于大佬打了个盹儿,身体还在工作,在省电程度上评级为一般省电。

    然后就看第二个停机模式,如何进入停机模式呢,首先sleepdeep位设置为1,告诉CPU你可以放心的睡进入深度睡眠模式,另外PDDS这一位用来区分它是停机模式,还是下面的待机模式,PDDS等于0进入停机模式,PDDS等于1进入待机模式,之后LPDS用来设置最后这个电压调节器,是开启还是进入低功耗模式,RPDS等于0电压调节器开启,RPDS等于1电压调节器进入低功耗,最后当我们把这些位提前设置好了,最后再调用WFA或者WFE,芯片就可以进入停止模式了,然后停止模式的唤醒,因为这个模式下芯片睡得更深,关的东西更多,所以唤醒条件就苛刻一些,是任一外部中断,刚才睡眠模式是任一中断,所有外设的中断都行,现在停止模式,要求就是只有外部中断才能唤醒,其他中断唤醒不了,刚才我们还提到了,PVD、RTC闹钟、USB唤醒、ETH唤醒借道了外部中断,所以这四个信号也可以唤醒停止模式,因为这里并没有区分WFI和WFE,其实也可以想象得到,WFI要用外部中断的中断模式唤醒,WFE要用外部中断的事件模式唤醒,这是对应的,最后看停止模式对电路有哪些操作呢,首先关闭所有1.8伏区域的时钟,这意思就是不仅CPU不能运行,外设也运行不了,定时器在定时的会暂停,串口收发数据也会暂停,不过由于没关闭电源,所以CPU和外设的寄存器数据都是维持原状的,之后下一个HSI和HSE的振荡器关闭,既然CPU和外设时钟都关了,那这两个高速时钟显然也没用了,所以HSI内部高速时钟和HSE外部高速时钟会关闭,当然他没提到的,LSI内部低速时钟和LSE外部低速时钟,这两个并不会主动关闭,如果开启过这两个时钟还可以继续运行,最后电压调节器,这里可以选择是开启或者处于低功耗模式,刚才说了,这个电压调节器是由这个LPDS位控制的
    ,这个开启和低功耗模式有啥区别呢,其实区别也不大,电压调节器无论是开启还是低功耗,都可以维持1.8伏区域寄存器和存储器的数据内容,区别就是低功耗模式更省电一些,同时低功耗模式在唤醒时要花更多的时间,相反电压调压器开启的话就是更耗电一些唤醒更快了,那这些就是停止模式的介绍,主要操作就是把运行的高速时钟都关了,CPU和外设都暂停工作,但是电压调节器并没有关,存储器和寄存器数据可以维持原样,它的唤醒条件比较苛刻,只能通过外部中断唤醒,所以停止模式相当于整个人都罢工了,脑子不工作,身体也不工作,只有有人用外部中断过来敲我,我才会醒来干活
    ,在省电程度上为非常省电。

    最后我们看第三种待机模式,进入的话和停机模式差不多,首先sleep deep也是置1即将深度睡眠,然后PDDS置1表示即将进入待机模式,最后调用WFI或者WFE就可以进入待机模式了,然后看一下唤醒条件,普通外设的中断和外部中断都无法唤醒待机模式,待机模式只有这几个指定的信号才能唤醒,第一个是wake up引脚的上升沿,wake up引脚,可以看一下引脚定义,这里PA0-WKUP指示了引脚的位置,就是PA0的位置,第二个是RTC闹钟事件,这个我们的示例代码和上一节RTC提到过,RTC闹钟可以唤醒待机模式,应用场景就是芯片每隔一段时间自动工作一次,第三个是NRST引脚上的外部复位,意思就是按一下复位键,它也是能唤醒的,最后一个IWDG独立看门狗复位,这个了解一下就行了,看门狗我们最后介绍,可以看出待机模式,只有这指定的四个信号能唤醒,其他信号都唤醒不了
    唤醒条件最为苛刻,之后待机模式对电路的操作基本上是能关的全都关了,1.8伏区域的时钟关闭,两个高速时钟关闭,电压调节器关闭,这意味着1.8伏区域的电源关闭,内部的存储器和寄存器的数据全部丢失,但是和停止模式一样,它并不会主动关闭LSI和LSE两个低速时钟,因为这两个时钟还要维持RTC和独立看门狗的运行,所以不会关闭,这是待机模式的介绍,主要操作就是把能关的全都关掉,只保留几个唤醒的功能,当然配合RTC和独立看门狗的低速时钟,也可以正常工作,所以待机模式相当于这个人直接下班回家睡觉了,没有指定的这几个事,他是不会轻易回来工作的,在省电程度上,待机模式评级为极为省电。

    模式选择:

    接下来我们对这里的一些细节问题,再额外补充和总结一下,首先是模式选择的问题,刚才这里的表述出现了很多寄存器的位,其中这些模式又有一些更细的划分,比如睡眠模式有SLEEP-NOW和SLEEP-ON-EXIT的区别,停机模式有电压调节器开启和处于低功耗的区别,我们如何配置才能指定某个模式呢,那看这个图就比较清晰了,当然这些寄存器,实际上库函数已经帮我们封装好了,不用我们自己配置的,但是多了解一些,对我们理解程序还是有很大帮助的,首先这里有一句,执行WFI等待中断或者WFE等待事件指令后,STM32进入低功耗模式,就说这两个指令是最终开启低功耗模式的触发条件,配置其他的寄存器都要在这两个指令之前,看下面这个图,首先一旦WFI或者WFE执行了,芯片咋知道他要进入哪种低功耗模式呢,那它就会按照这个流程来判断,首先看看sleep deep位是1还是0,如果sleep deep等于0 ,就是浅睡眠,对应的就是睡眠模式,如果sleep deep等于1,表示要进入深度睡眠模式,对应的是停机或者待机模式,停机和待机都可以叫做深度睡眠模式,在普通的睡眠模式,还有个细分的功能,通过SLEEPONEXIT位来决定,这一位等于0时,无论程序在哪里调用WFI或WFE都会立刻进入睡眠,这位等于1时执行WFI或WFE之后,它会等待中断退出,等所有中断处理完成之后再进入睡眠,这个可能考虑到中断还有一些紧急的任务,最好不要被睡眠打断了,所以先等等也无妨,当然这两个细分模式我们一般可以不用管,只要我们不在中断函数里调用WFI或WFE,那其实它们的效果是一样的,我们WFI、WFE可以放在主程序里,如果主程序执行到了,自然也代表中断处理完成了,如果你想在中断函数里调用WFI、WFE,并且想中断结束后再睡眠,才需要考虑下面这个模式,然后继续看,进入深度睡眠模式,它会继续判断PDDS这一位,如果PDDS等于0,就进入的是停机模式,如果PDDS等于1,就进入的是待机模式,在停机模式下,它会继续判断LPDS位,如果LPDS等于0,就是停机模式且电压调节器开启,如果LPDS等于1,就是停机模式且电压调节器低功耗
    电压调节器低功耗的特性就是更省电,但是唤醒延迟更高,那这些就是模式选择的一个判断流程。

    这个事件唤醒还是有点麻烦的,你要是觉得麻烦,直接使用中断唤醒的方式也是可以的。

    当一个中断或唤醒事件导致退出停止模式时,HSI被选为系统时钟,我们的程序默认在SystemInit函数里的配置,是使用的HSE外部高速时钟,通过PLL倍频得到72MHz主频,但是进入停止模式后,PLL和HSE都停止了,而现在退出停止模式时,它并不会再自动帮速时钟,通过PLL倍频得到8MHz直接作为主频,所以如果你忽略了这个问题,那么就会出现一个现象,你程序刚上电是72MHz的主频,但是进入停止模式,在唤醒之后就变成8MHz的主频了,所以我们一般在停止模式唤醒后,第一时间就是重新启动HSE,配置主频为72MHz,这个操作也不麻烦,配置的函数他都帮我们写好了,我们只需要再调用下SystemInit就行。

    数据手册和参考手册:




    代码示例:修改主频

    接线图:修改主频

    接线图:睡眠模式+串口发送+接收

    接线图:停止模式+对射式红外传感器计次

    接线图:待机模式+实时时钟

    PA0这里我引出来了一根线,这个线的意思就是,我们可以手动把PA0接到3.3V或者断开,因为PA0还有一个功能wake up,可以唤醒待机模式,wake up引脚上升沿有效,但是这个地方也不好接按键的,所以我们可以直接用一根线短接到3.3V,来手动产生一个上升沿,测试wake up引脚是不是有效果,然后实时时钟还有一个备用电源VBAT,这个接不接都行,因为我们需要在待机模式下唤醒,唤醒之后没有主电源程序运行不了,所以主电源是不能断电的,那主电源不断电,备用电源接不接就都是一样的现象了。

    system_stm32f10x.h、system_stm32f10x.c文件是用来配置系统时钟的,也就是配置RCC时钟树,这个RCC时钟树我们可以看一下PPT(前面的RCC时钟树),在这里这个图就是RCC时钟树的全部电路,左边是四个时钟源,HSI、HSE、LSE、LSI用于提供时钟,右边就是各个外设,就是使用时钟的地方,我们用的最多的就是AHB时钟、APB1时钟、APP2时钟,另外还有一些时钟,它们的来源不是AHB和APB,比如I2S的时钟直接来源于system clock,USB的时钟直接来源于PLL,当然这些特例我们就不过多关心了,我们主要关心的就是这个外部的8MHz晶振,它如何进行选择,如何倍频才能得到这个72MHz的SYSCLK系统主频,然后系统主频如何去分配,才能得到AHB、APB1和APB2的时钟频率,在我们之前的课程里,我们一直保持着默认的配置,就是晶振接的是8M,主频是72M,AHB和APB2是72M,APP1是36M,但其实这些都不是绝对的,可以根据自己的需求进行更改,当然我建议一般还是不要改,毕竟目前绝大多数程序都是按照默认的配置来写的,随意更改可能会造成一些问题,回到程序,我们来看一下它这里是怎么配置,这个RCC时钟树的,这个点C文件最开始写了一堆注释,这个注释就是对这个system文件的介绍,读懂这些注释就差不多能理解这个文件了。详细看视频讲解…

    代码:

    #include "stm32f10x.h"                 // Device header
    #include "Delay.h"
    #include "OLED.h"
    int main(void)
    {
        OLED_Init();
        OLED_ShowString(1, 1, "SYSCLK:");
        OLED_ShowNum(1, 8, SystemCoreClock, 8);//显示当前主频
        while (1)
        {
            OLED_ShowString(2, 1, "Running...");
            Delay_ms(500);
            OLED_ShowString(2, 1, "         ");
            Delay_ms(500);
        }
    }
    

    12.3 串口数据收发+睡眠模式

    代码:

    #include "stm32f10x.h"                 // Device header
    #include "Delay.h"
    #include "OLED.h"
    #include "Serial.h"
    #include "LED.h"
    uint8_t RxData;
    uint8_t Pin_9, Pin_10;
    int main(void)
    {
        OLED_Init();
        Serial_Init();
        OLED_ShowString(1, 1, "RxData:");
        while (1)
        {
            if (Serial_GetRxFlag() == 1)
            {
                RxData = Serial_GetRxData();
                Serial_SendByte(RxData);
                OLED_ShowHexNum(1, 8, RxData, 2);
            }
            // 没有数据要发送但代码一直执行所以可以采用睡眠模式
            OLED_ShowString(2, 1, "Running...");
            Delay_ms(500);
            OLED_ShowString(2, 1, "         ");
            Delay_ms(500);
            __WFI(); // 进入睡眠,中断唤醒
            //执行WFI这时CPU会立刻睡眠,程序就停在了WFI指令这里,但是各个外设比如USRT还是工作状态
    		//等到我们用串口助手发送数据时,USRT外设收到数据产生中断,唤醒CPU之后程序在暂停的地方继续运行
        }
    }
    

    12.4 停止模式

    停止模式只能通过外部中断触发(唤醒),所以和停止模式相关的代码肯定得用外部中断。

    因为这个代码可以使用外部中断触发唤醒,所以我们可以让它进入更为省电的停止模式,在停止模式下1.8V区域的时钟关闭,CPU和外设都没有时钟了,但是外部中断的工作是不需要时钟的,这一点从代码里也可以看出来,你看初始化的时候,根本就没有开启EXTI时钟的参数,这也是EXTI能在时钟关闭的情况下工作的原因。

    刚才讲的睡眠模式其实都只是内核的操作,睡眠模式涉及的几个寄存器也都是在内核里,跟PWR外设关系不大,所以刚才我们都没用到PWR的库函数,不过现在停止模式涉及到内核之外的电路操作,这就需要用到PWR外设了,我们看一下库函数。

    常用函数:

    void PWR_DeInit(void);
    void PWR_BackupAccessCmd(FunctionalState NewState); // 使能后备区域的访问
    // 配置PVD使能电压
    void PWR_PVDCmd(FunctionalState NewState);	   //使能PVD功能
    void PWR_PVDLevelConfig(uint32_t PWR_PVDLevel);//配置PVD的阈值电压
    // 使能WKUP引脚唤醒功能在待机模式下使用
    void PWR_WakeUpPinCmd(FunctionalState NewState);//使能位于PA0位置的WKUP引脚 配合待机模式使用
    // 进入停止模式
    void PWR_EnterSTOPMode(uint32_t PWR_Regulator, uint8_t PWR_STOPEntry);
    // 进入待机模式
    void PWR_EnterSTANDBYMode(void);
    FlagStatus PWR_GetFlagStatus(uint32_t PWR_FLAG);
    void PWR_ClearFlag(uint32_t PWR_FLAG);
    
    #include "stm32f10x.h"                  // Device header
    #include "Delay.h"
    #include "OLED.h"
    #include "CountSensor.h"
    
    int main(void)
    {
    	/*模块初始化*/
    	OLED_Init();			//OLED初始化
    	CountSensor_Init();		//计数传感器初始化
    	
    	/*开启时钟*/
    	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);		//开启PWR的时钟
    															//停止模式和待机模式一定要记得开启
    	
    	/*显示静态字符串*/
    	OLED_ShowString(1, 1, "Count:");
    	
    	while (1)
    	{
    		OLED_ShowNum(1, 7, CountSensor_Get(), 5);			//OLED不断刷新显示CountSensor_Get的返回值
    		
    		OLED_ShowString(2, 1, "Running");					//OLED闪烁Running,指示当前主循环正在运行
    		Delay_ms(100);
    		OLED_ShowString(2, 1, "       ");
    		Delay_ms(100);
    		
    		PWR_EnterSTOPMode(PWR_Regulator_ON, PWR_STOPEntry_WFI);	//STM32进入停止模式,并等待中断唤醒
    		SystemInit();										    //唤醒后,要重新配置时钟,重启HSE配置72M主频
    		//退出停止模式时,HSI被选为系统时钟,也就是在我们首次复位后,SystemInit函数里配置的是HSE*9倍频的72M主频
    		//所以复位后第一次Running闪烁很快,而之后进入停止模式,再退出时默认时钟就变成HSI了,HSI是8M,所以唤醒之后的程序运行就会明显变慢
    	}
    }
    

    12.5 待机模式:

    实时时钟+待机

    #include "stm32f10x.h"                  // Device header
    #include "Delay.h"
    #include "OLED.h"
    #include "MyRTC.h"
    
    int main(void)
    {
    	/*模块初始化*/
    	OLED_Init();		//OLED初始化
    	MyRTC_Init();		//RTC初始化
    	
    	/*开启时钟*/
    	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);		//开启PWR的时钟
    															//停止模式和待机模式一定要记得开启,虽然MyRTC_Init里开启了,多次开启无所谓,防止其他没调用MyRTC_Init的场景   但时钟没开启外设就不会工作
    	
    	/*显示静态字符串*/
    	OLED_ShowString(1, 1, "CNT :");//秒计数器
    	OLED_ShowString(2, 1, "ALR :");//闹钟值
    	OLED_ShowString(3, 1, "ALRF:");//闹钟标志位
    	
    	/*使能WKUP引脚*/
    	PWR_WakeUpPinCmd(ENABLE);						//使能位于PA0的WKUP引脚,WKUP引脚上升沿唤醒待机模式
    	//手册里PWR_CSR的寄存器描述,这里写了使能wake up引脚后,wake up引脚被强制为输入下拉的配置,所以不用再GPIO初始化了
    	
    	/*设定闹钟*/
    	uint32_t Alarm = RTC_GetCounter() + 10;			//闹钟为唤醒后当前时间的后10s
    	RTC_SetAlarm(Alarm);							//写入闹钟值到RTC的ALR寄存器 这个寄存器只写不可读,所以使用变量Alarm显示到OLED上
    	OLED_ShowNum(2, 6, Alarm, 10);					//显示闹钟值
    	
    	while (1)
    	{
    		OLED_ShowNum(1, 6, RTC_GetCounter(), 10);	//显示32位的秒计数器
    		OLED_ShowNum(3, 6, RTC_GetFlagStatus(RTC_FLAG_ALR), 1);		//显示闹钟标志位
    		
    		OLED_ShowString(4, 1, "Running");			//OLED闪烁Running,指示当前主循环正在运行
    		Delay_ms(100);
    		OLED_ShowString(4, 1, "       ");
    		Delay_ms(100);
    		
    		OLED_ShowString(4, 9, "STANDBY");			//OLED闪烁STANDBY,指示即将进入待机模式
    		Delay_ms(1000);
    		OLED_ShowString(4, 9, "       ");
    		Delay_ms(100);
    		
    		OLED_Clear();								//OLED清屏,模拟关闭外部所有的耗电设备,以达到极度省电
    													
    		PWR_EnterSTANDBYMode();						//STM32进入停止模式,并等待指定的唤醒事件(WKUP上升沿或RTC闹钟)
    		/*待机模式唤醒后,程序会重头开始运行*/
    		//待机模式之后的代码执行不到,下次继续从头开始 在程序刚开始的时候自动调用SystemInit初始化时钟,所以待机模式我们就不用像停止模式那样,自己调用SystemInit了
    		//并且这个while循环,实际上也只有执行一遍的机会,把这个while循环去掉也是可以的
    	}
    }
    



    最后的最后我还进行了一个小实验,就是验证一下待机模式到底是不是像手册里说的那样省电,手册里说待机模式的电流只有三微安左右,这个是不是真的呢,为此我进行了测试,这里我单独找了个板子,把这个电源供电线正极给剪断,串联一个万用表测电流,当然最开始我直接测试哈,待机模式的电流高达一点多毫安,远超手册里说的3微安,但首先很明显这个电源指示灯肯定是耗电的,所以我先把这个电源指示灯去掉了,再测试电流仍然有几百微安,那说明还有别的东西耗电,之后我就把板子背面的这个LDO稳压器去掉了,这个LDO虽然我们并没有用到哈,但接上它就会有电流损耗,最后去掉电源指示灯和LDO之后,待机模式的电流就下降到3微安了。

    十三、看门狗WDG

    13.1 WDG简介

    独立看门狗(IWDG):独立工作,对时间精度要求较低
    窗口看门狗(WWDG):要求看门狗在精确计时窗口起作用
    独立看门狗,它的特点就是独立运行,对时间精度要求较低,独立运行就是独立看门狗的时钟是专用的,LSI内部低速时钟,即使主时钟出现问题了,看门狗也能正常工作,这也是独立看门狗独立的得名原因,对时间精度要求较低,就是独立看门狗只有一个最晚时间界限,你喂狗间隔只要不超过这个最晚界限就行了,你说很快的喂、疯狂的喂、连续不断的喂,那都没问题,之后另一个是窗口看门狗,它相比较独立看门狗就严格一些了,要求看门狗在精确计时窗口起作用,意思就是喂狗的时间有个最晚的界限,也有个最早的界限,必须在这个界限的窗口内喂狗,这是窗口开门口窗口的得名原因,因为对于独立看门狗来说,可能程序就卡死在喂狗的部分了,或者程序跑飞,但是喂狗代码也意外执行了,或者程序有时候很快喂狗,有时候又比较慢喂狗,那这些状态独立看门狗就检测不到了,但是窗口看门狗是可以检测到这些问题的,因为它对喂狗的时间窗口可以卡的很死,快了慢了都不行,最后窗口开门口使用的是APB1的时钟,它没有专用的时钟,所以不算是独立。

    IWDG框图:

    它的结构和定时器是非常相似的,只不过是定时器溢出产生中断,而看门狗定时器溢出直接产生复位信号,然后喂狗操作其实也就是重置这个计数器,这是一个递减计数器,减到零之后就复位,那程序正常运行时,为了避免复位,就得在这个计数器减到零之前,及时把记数值加大点,这个操作就是喂狗,如果你程序卡死了,没有及时加大这个记数值,那减到零之后就自动复位了,就是看门狗的工作逻辑。

    这个框图大家可以类比定时器的时机单元来看,我们看一下定时器,这一块是时机单元,由预分频器、计数器和重装载寄存器组成,左边是输入时钟,比如这里是72M,首先经过分频,比如现在2分频
    那么计数器的驱动时钟就是72M/2=36M,这个计数器可以自增,也可以自减,看门狗使用的是自减运行,那自减到零后,定时器产生更新事件和中断,而看门狗是直接产生复位,另外重装值,定时器是在更新事件重装,而看门狗需要我们在自减到零之前手动重装,因为减到零就复位了,那这个手动重装计数器的操作就是喂狗,看完了定时器,接着来看这个独立看门狗,这一块是预分频器,这一块是计数器,这一块是重装寄存器,这基本就是一样的结构,那预分频器之前输入时钟是LSI内部低速时钟
    时钟频率为40KHz,之后时钟进入预分频器进行分频,这个预分频器只有8位,所以它最大只能进行256分频,上面这个预分频寄存器,IWDG_PR可以配置分频系数,这个PR和定时器的PSC是一个意思,他们都是Prescaler的缩写,可能不是一个人设计的,所以这手册里很多缩写都不太一样,不过大家要知道他们其实是一个意思,后面经过预分配器分频之后,时钟驱动递减计数器,每来一个时钟自减一个数,另外这个计数器是12位的,所以最大值是2^12-1=4095,然后当自减到0之后,产生IEDG复位,正常运行时,为了避免复位,我们可以提前在重装寄存器写个值,IWDG_RLR和定时器的ARR是一样的,RLR是reloader,ARR是auto reloader,那当我们预先写好值之后,在运行过程中,我们在这个键寄存器里写个特定数据,控制电路进行喂狗,这时重装值就会复制到当前的计数器中,这样计数器就会回到重装值,重新自减运行了,然后这里有个状态寄存器SR,就是标志电路运行的状态了,其实这个SR里没什么东西,就只有两个更新同步位,基本不用看,最后上面这些寄存器位于1.8V供电区,下面主要的工作电路都位于VDD供电区,所以这下面写了看门狗功能处于vdd供电区,即在停机和待机模式时仍能正常工作,上节我们也说过,独立开门口也是唤醒待机模式的四个条件之一。

    IWDG键寄存器:

    键寄存器本质上是控制寄存器,用于控制硬件电路的工作,比如我们刚才说的喂狗操作,就是通过在键寄存器写入0XAAAA完成的,那为什么要用键寄存器呢,我直接定义一个控制寄存器,其中再定义一个位,这一位写入1就喂狗,这样不也行吗,我们继续看第二条,在可能存在干扰的情况下,一般通过在整个键寄存器写入特定值来代替控制电容器写入移位的功能,以降低硬件电路受到干扰的概率,为什么能降低干扰呢,你看独立看门狗工作的环境是什么,是程序可能跑飞,可能受到电磁干扰,程序做出任何操作都是有可能的,如果你只在寄存器中设置一个位,那这1位就有可能在误操作中变成1,或者变成0,这个概率是比较大的,所以单独设置1位就来执行控制,在这里比较危险,这时我们就可以通过,在整个寄存器写入一个特定值来代替,写入1个位的操作,比如这里键寄存器是16位的,只有在键寄存器写入0XAAAA这个特定的数,才会执行喂狗的操作,这样就会降低误操作的概率。

    最后两条是写保护的逻辑啊,意思就是执行指令必须写入指定的键值,所以指令抗干扰能力是很强的,但这里(上面框图里)还有PR、SR和RLR 3个寄存器,他们也要有防止误操作的功能,SR是只读的这个不用保护,剩下的PR和RLR的写操作,可以设置一个写保护措施,然后只有在键寄存器写入5555
    才能解除写保护,一旦写入其他值,PR和RLR再次被保护,这样PR和RLR就跟随键寄存器一起,被保护了起来,防止误操作,这是键寄存器设计的用途。

    IWDG超时时间:

    13.2 窗口看门狗WWDG

    窗口看门狗从功能上来说,和独立看门狗还是比较像的,大体上来看只是比独立看门狗多个最早喂狗时间的限制,但是等会儿学的时候,你就会发现,这个窗口看门狗无论是框图的设计,还是寄存器的分布和命名规则,或者程序的操作流程,和独立看门狗都不是一个思路,可能是两个看门狗侧重点不一样吧,当然我感觉应该还是因为这两个外设不是同一个人设计的,所以设计的思路有所不同。

    左下角是时钟源部分,这个时钟源是PCLK1,右边这个是预分频器,它这个预分频器名字又变了
    叫WDGTB,实际上和独立开门狗的PR,定时器的PSC都是一个东西,上面这个是6位递减计数器CNT,这个计数器是位于控制寄存器CR里的,计数器和控制寄存器合二为一了,然后窗口看门狗没有重装寄存器,那如何重装计数器进喂狗呢,这个我们直接在CNT写个数据就行了,想写多少就写多少,这上面这一块是窗口值,由此喂狗的最早时间界限就写到这里存起来,最后左边就是输出信号的操作逻辑,什么情况下会产生复位,就这几个逻辑门来确定。

    我们来详细看一下它的工作流程,首先还是从左下角开始看,时钟来源是PCLK1,也就是APB1的时钟,这个时钟默认是36MHz,所以就是36MHz的时钟进来,之后还是先经过一个预分频器进分频,这个和独立看门狗的预分频器,定时器的预分频器都是一个作用,就是灵活的调节后面计数器的时钟频率,同时预分频系数也是计算计数器溢出时间的重要参数,那接着分频之后的时钟驱动这个计数器进行计数,这个计数器和独立看门狗一样,也是一个递减计数器,每来一个时钟自减一次,不过这个计数器比较特殊,从图上来看,这里写了T6到T0,总共是七个位,但下面却写的是六位递减计数器,这是为什么呢,那这其实是因为这个计数器只有T5到T0这六位是有效的就值,最高位T6这里用来当做溢出标志位,第六位等于1时,表示计数器没溢出,T6位等于0时表示计数器溢出,不过对于硬件电路来说,T6位其实也是计数器的一部分,只不过是T6位被单独拎出来,当做标志位了而已。
    总结一下,就是如果你把T6位看作是计数器的一部分,那要是整个计数器值减到0X40之后移出,而如果你把T6位当成溢出标志位,低6位当做计数器,那就是低6位的计数值减到0之后溢出,这一点尤其要搞清楚。

    左边的复位信号输出部分,首先这个WDGA是窗口看门狗的激活位,也就是使能,WDGA写入1启用窗口看门狗,使能位作用于这个与门。

    下面这一路或门,T6位一旦等于零(小圆圈取反),就表示计数器溢出,产生复位信号,那在程序正常运行状态下,我们必须始终保证T6位为一,这样才能避免复位,下面这一块实现的功能和独立看门狗基本是一样的,如果不及时喂狗,6位的计数器减到0后就产生复位。

    接下来看门狗时间的最早界限,由上面这一块来实现,首先我们要计算一个最早界限的计数值,写到这里的W6~W0中,写入之后是固定不变的,在这里一旦我们执行写入CR操作时,这个与门开关就会打开,写入CR其实就是写入计数器,也就是喂狗,在喂狗时,这个比较器开始工作,一旦它比较我们当前的计数器,T6:0>窗口值W6:0,比较结果就等于1,这个一通过或门也可以去申请复位,这是喂狗最早时间窗口的实现流程,就是喂狗的时候,我把当前记数值和预设的窗口值进行比较,如果发现你的狗余粮还非常充足,你喂的这么频繁,那肯定是有问题,我要给你复位一下,不让你喂太早了。

    递减计数器T[6:0]等于0x40时可以产生早期唤醒中断(EWI),用于重装载计数器以避免WWDG复位
    这里的意思就是,减到0X40时产生中断,然后再减一个数到0X3F时产生复位,那这个中断其实就是在溢出的前一刻发生,所以这个中断也可以称作死前中断,马上就要溢出复位了,再提醒一下你要不要干点啥,我们一般可以用来执行一些紧急操作,比如保存重要数据,关闭危险设备等等,或者还有一种写法,就虽然超时喂狗了,但是我们可以在中断里执行一些代码,进行解决,或者这个任务不是很危险,超时了我就只想做一些提示,不想让他复位了,这样的话我们就可以在这个早期唤醒中断里直接执行喂狗,阻止系统复位,然后提示一下信息就完事儿了。

    WWDG超时时间:

    这里要多乘一个4096,是因为这里PCLK1进来之后,其实是先执行了一个固定的4096分频,这里框图没画出来,实际上是有的,因为36M的频率还是太快了,先来个固定分频给降一降。

    手册:



    代码示例:实现IWDG

    接线图:按键用于阻塞喂狗。

    根据我们上小节的知识点,我们总结一下独立看门狗的配置流程,首先看一下这个框图,从左到右,第一步应该就是开启时钟了,只有这个LSI时钟开启了独立看门狗才能运行,所以初始化独立看门狗之前,LSI必须得开启,但是这个开启LSI的代码并不需要我们来写,我们看一下手册6.2.9,如果我们开启了独立看门狗那么LSI就会跟随强制打开,等LSI稳定后就可以自动为独立看门狗提供时钟了,所以我们这里的第一步开启LSI的时钟就不需要我们再写代码来执行了,下一步我们就是写入预分频器和重装寄存器了,当然在写入这两个寄存器之前,不要忘了这里的写保护,首先写入这个键值0X5555解除写保护,然后再写入预分频和重装值,所以这里的第二步就是解除写保护,第三步是写入预分频和重装值,预分频和重装值具体写入多少,我们可以通过这里的超时时间公式来计算,最后当这些配置工作做完之后,我们就可以执行这条指令来启动独立看门狗了(写入0xCCCC),然后在主循环里,我们可以不断执行这条指令来进行喂狗(0xAAAA),这是独立看门狗的配置流程。

    常用函数:

    void IWDG_WriteAccessCmd(uint16_t IWDG_WriteAccess); // 写使能控制 写入0x5555/0x0000
    void IWDG_SetPrescaler(uint8_t IWDG_Prescaler);      // 写预分频器PR寄存器
    void IWDG_SetReload(uint16_t Reload);                // 写重装值RLR寄存器
    void IWDG_ReloadCounter(void);                       // 重新装载寄存器0xAAAA 喂狗
    void IWDG_Enable(void);                              // 启用IWDG 0xCCCC
    FlagStatus IWDG_GetFlagStatus(uint16_t IWDG_FLAG);
    // 查看看门狗标志位
    
    //rcc.h里查看标志位函数  上电复位、软件复位、独立看门狗、窗口看门狗复位等
    FlagStatus RCC_GetFlagStatus(uint8_t RCC_FLAG);
    void RCC_ClearFlag(void);
    

    代码:

    #include "stm32f10x.h"                  // Device header
    #include "Delay.h"
    #include "OLED.h"
    #include "Key.h"
    
    int main(void)
    {
    	/*模块初始化*/
    	OLED_Init();						//OLED初始化
    	Key_Init();							//按键初始化
    	
    	/*显示静态字符串*/
    	OLED_ShowString(1, 1, "IWDG TEST");
    	
    	/*判断复位信号来源*/
    	if (RCC_GetFlagStatus(RCC_FLAG_IWDGRST) == SET)	//如果是独立看门狗复位
    	{
    		OLED_ShowString(2, 1, "IWDGRST");			//OLED闪烁IWDGRST字符串
    		Delay_ms(500);
    		OLED_ShowString(2, 1, "       ");
    		Delay_ms(100);
    		
    		RCC_ClearFlag();							//清除标志位
    	}
    	else											//否则,即为其他复位
    	{
    		OLED_ShowString(3, 1, "RST");				//OLED闪烁RST字符串
    		Delay_ms(500);
    		OLED_ShowString(3, 1, "   ");
    		Delay_ms(100);
    	}
    	
    	/*IWDG初始化*/
    	IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable);	//独立看门狗写使能
    	IWDG_SetPrescaler(IWDG_Prescaler_16);			//设置预分频为16
    	IWDG_SetReload(2499);							//设置重装值为2499,独立看门狗的超时时间为1000ms
    	IWDG_ReloadCounter();							//重装计数器,喂狗
    	IWDG_Enable();									//独立看门狗使能
    	//喂狗或使能的时候,会在键寄存器写入5555之外的值,这时就顺便给寄存器写保护了,就不用再手动执行写保护了
    	
    	while (1)
    	{
    		Key_GetNum();								//调用阻塞式的按键扫描函数,模拟主循环卡死  按住按键不放,主循环就会阻塞,不能执行后面喂狗
    		
    		IWDG_ReloadCounter();						//重装计数器,喂狗
    		
    		OLED_ShowString(4, 1, "FEED");				//OLED闪烁FEED字符串
    		Delay_ms(200);								//喂狗间隔为200+600=800ms
    		OLED_ShowString(4, 1, "    ");
    		Delay_ms(600);
    	}
    }
    

    13.4 实现WWDG


    先看一下窗口看门狗的框图,这里因为窗户看门狗的时钟来源是PCLK1,所以第一步我们需要开启窗户看门狗APB1的时钟,这个第一步需要我们自己来执行,不会像独立看门狗自动开启,第二步就是配置各个寄存器了,比如预分频和窗口值窗口看门狗没有写保护,所以第二步就可以直接写这些寄存器了,第三步写入控制性器CR,控制寄存器包含看门狗使能位、计数器溢出标志位和计数器有效位,这些东西需要一起设置,放在第三步统一执行,之后在运行过程中,我们不断向计数器写入想要的重装值,这样就可以进行喂狗了,这是窗口看门狗的操作流程。

    常用函数:

    void WWDG_DeInit(void);
    void WWDG_SetPrescaler(uint32_t WWDG_Prescaler);//写入预分频器
    void WWDG_SetWindowValue(uint8_t WindowValue);	//写入窗口值
    void WWDG_EnableIT(void);						//使能中断
    void WWDG_SetCounter(uint8_t Counter);			//写入计数器(喂狗)
    void WWDG_Enable(uint8_t Counter);				//使能启动窗口看门狗
    FlagStatus WWDG_GetFlagStatus(void);
    void WWDG_ClearFlag(void);
    

    函数WWDG_Enable()为何要传递一个参数?为了使能的同时顺便喂一下狗

    #include "stm32f10x.h"                  // Device header
    #include "Delay.h"
    #include "OLED.h"
    #include "Key.h"
    
    int main(void)
    {
    	/*模块初始化*/
    	OLED_Init();						//OLED初始化
    	Key_Init();							//按键初始化
    	
    	/*显示静态字符串*/
    	OLED_ShowString(1, 1, "WWDG TEST");
    	
    	/*判断复位信号来源*/
    	if (RCC_GetFlagStatus(RCC_FLAG_WWDGRST) == SET)	//如果是窗口看门狗复位
    	{
    		OLED_ShowString(2, 1, "WWDGRST");			//OLED闪烁WWDGRST字符串
    		Delay_ms(500);
    		OLED_ShowString(2, 1, "       ");
    		Delay_ms(100);
    		
    		RCC_ClearFlag();							//清除标志位
    	}
    	else											//否则,即为其他复位
    	{
    		OLED_ShowString(3, 1, "RST");				//OLED闪烁RST字符串
    		Delay_ms(500);
    		OLED_ShowString(3, 1, "   ");
    		Delay_ms(100);
    	}
    	
    	/*开启时钟*/
    	RCC_APB1PeriphClockCmd(RCC_APB1Periph_WWDG, ENABLE);	//开启WWDG的时钟 PCLK1时钟
    	
    	/*WWDG初始化*/
    	WWDG_SetPrescaler(WWDG_Prescaler_8);			//设置预分频为 8
    	WWDG_SetWindowValue(0x40 | 21);					//设置窗口值,窗口时间为30ms  T6位也要设置成1,所以或上0x40
    	WWDG_Enable(0x40 | 54);							//使能并第一次喂狗,超时时间为50ms T6位也要设置成1,所以或上0x40
    	
    	while (1)
    	{
    		Key_GetNum();								//调用阻塞式的按键扫描函数,模拟主循环卡死
    		
    		OLED_ShowString(4, 1, "FEED");				//OLED闪烁FEED字符串
    		Delay_ms(20);								//喂狗间隔为20+20=40ms
    		OLED_ShowString(4, 1, "    ");
    		Delay_ms(20);
    		
    		WWDG_SetCounter(0x40 | 54);					//重装计数器,喂狗
    	}
    }
    

    STM32内部FLASH闪存

    程序现象:

    1、读写内部FLASH
    这个代码的目的,就是利用内部flash程序存储器的剩余空间,来存储一些掉电不丢失的参数,如果你有一些配置参数需要掉电不丢失的保存,再外挂一个存储器芯片的话,显然会增加硬件成本,那STM32本身不就是有掉电不丢失的程序存储器吗,我们直接把参数存在这里,是不是就又方便又节省成本,所以这里的程序是按下K1变换一下测试数据,然后存储到内部FLASH,按下K2把所有参数清0,最后OIED显示一下。


    2、

    STM32内部FLASH闪存简介

    我们本节的任务就是对这些存储器进行读写,那我们怎么操作这些存储器呢,这需要用到这个闪存存储器接口,闪存存储器接口是一个外设,是这个闪存的管理员,毕竟闪存的操作很麻烦,涉及到擦除、编程、等待、解锁等等操作,所以这里我们需要把我们的指令和数据,写入到这个外设的相应寄存器,然后这个外设就会自动去操作对应的存储空间,那后面写的是这个外设可以对程序存储器和选项字节,这两部分进行擦除和编程对比上面的三个部分呢少了系统存储器这个区域,因为系统处理器是原厂写入的BOOTLOADER程序,这个是不允许我们修改的。

    读写FLASH的用途:
    利用程序存储器的剩余空间来保存掉电不丢失的用户数据
    通过在程序中编程(IAP),实现程序的自我更新
    第一个用途,对于我们这个C8T6芯片来说,它的程序存储器容量是64K,一般我们写个简单的程序,可能就只占前面的很小一部分空间,剩下的大片空余空间我们就可以加以利用,比如存储一些我们自定义的数据,这样就非常方便,而且可以充分利用资源,不过这里要注意我们在选取存储区域时,一定不要覆盖了原有的程序,要不然程序自己把自己给破坏了,一般存储少量的参数,我们就选最后几页存储就行了,关于如何查看程序所占用空间的大小,这个我们下小节也会介绍,然后第二个用途就是通过在程序中编程IAP,实现程序的自我更新,刚才说了,我们在存储用户数据时要避开程序本身
    以免破坏程序,但如果我们就非要修改程序本身,这会发生什么呢,那这就是第二点提到的功能,在程序中编程,利用程序来修改程序本身,实现程序的自我更新,这个在程序中编程就是IAP,在数码圈也有个可能大家更熟悉的技术叫OTA,这俩是类似的东西,都是用来实现程序升级的,但这个IAP升级程序的功能比较复杂,我们本课程暂时就不涉及了,之后有缘再说吧。

    在线编程(In-Circuit Programming – ICP)用于更新程序存储器的全部内容,它通过JTAG、SWD协议或系统加载程序(Bootloader)下载程序
    ICP英文直译过来也可以叫在电路中编程,意思就是下载程序,你只需要留几个引脚就行,不用拆芯片了,就叫在电路中进行编程,ICP的作用是用于更新程序存储器的全部内容,它通过JTAG SWD协议或系统加载程序BOOTLOADER下载程序,这个JTAG SWD就是仿真器下载程序,就是我们目前用的stlink,使用SWD下载程序,每次下载都是把整个程序完全更新掉,那系统加载程序就是系统存储器的BOOTLOADER,也就是串口下载,串口下载也是更新整个程序,这就是我们一直在用的ICP下载方式,之后更高级的下载方式就是在程序中编程(In-Application Programming – IAP),简称IAP,它可以使用微控制器支持的任意一种通信接口下载程序,怎么实现呢,那比如这是整个程序存储器,我们首先需要自己写一个BOOTLOADER程序,并且存放在程序更新时不会覆盖的地方,比如我们放在最后面,然后需要更新程序时,我们控制程序跳转到这个自己写的BOOTLOADER这里来,在这里面我们就可以接收任意一种通讯接口传过来的数据,比如串口、USB、蓝牙转串口、WIFI转串口等等,这个传过来的数据就是待更新的程序,然后我们控制flash读写,把收到的程序写入到前面程序正常运行的地方,写完之后再控制程序跳转回正常运行的地方,或者直接复位,这样程序就完成了自我升级,这个过程其实就是和系统存储器这个的BOOTLOADER一样,因为程序要实现自我升级,在升级过程中,肯定需要布置一个辅助的小机器人来临时干活,只不过是系统存储器的BOOTLOADER写死了,只能用串口下载到指定位置,启动方式也不方便,只能配置boot引脚触发启动,而我们自己写BOOTLOADER的话,就可以想怎么收怎么收,想写到哪就写到哪,想怎么启动就怎么启动,并且在整个升级过程,程序都可以自主完成,实现在程序中编程,更进一步就可以直接实现远程升级了,非常灵活方便。

    接下来的内容,我们就只涉及最基本的对flash进行读写,这也是实现IAP的基础。

    我们C8T6芯片的闪存容量是64K,属于重容量产品,对于小容量产品和大容量产品,闪存的分配方式有些区别,这个可以参考一下手册,那首先提醒一下闪存这一章的内容在手册里是单独列出来的,并不在之前的参考手册里,我们需要打开这个闪存编程参考手册,这里以中容量产品为例来讲解,首先看一下第一列的几个块,这里分为了三个块,第一个是主存储器,也是我们刚才说的程序存储器,用来存放程序代码的,这是最主要也是容量最大的一块,下面第二个是信息块,里面又可以分为启动程序代码和用户选择字节,其中启动程序代码就是刚才说的系统存储器,存放的是原厂写入BOOTLOADER用于串口下载,这个手册的名称经常会有不同的表述方式,但大家要知道某些名称描述的其实是一个东西,然后下面这个用户选择字节,就是刚才说的选项字节存放一些独立的参数,这个选项字节在手册里一直都称作选择字节,可能是翻译的问题,英文是option bytes,我们一般都叫选项字节,然后最后一块是闪存存储器接口寄存器,这一块的存储器实际上并不属于闪存,你看那个地址就知道地址都是40开头的,说明这个存储器接口寄存器就是一个普通的外设,和之前讲的GPIO定时器、串口等等都是一个性质的东西,这些存储器它们的存储介质也都是sram,这个闪存存储器接口就上面这些闪存的管理员,这些寄存器就是用来控制擦除和编程这个过程的,那到这里这个表的整体我们就清楚了,我们擦除和编程就通过读写这些寄存器来完成,当然这里只有擦除和编程,并没有读取,这是因为读取指定存储器直接使用指针读即可,用不到这个外设。

    继续看这个表,对于主存储器,这里对它进行了分页,分页是为了更好的管理闪存,擦除和写保护都是以页为单位的,这点和之前W25Q64芯片的闪存一样,同为闪存它们的特性基本一样,写入前必须擦除,擦除必须以最小单位进行,擦除后数据位全变为1,数据只能1写0,不能0写1,擦除和写入之后都需要等待忙,这些都是一样的,学习这节之前,大家可以再复习一下W25Q64,再学这一节就会非常轻松了,那W25Q64的分配方式是先分为块block,再分为扇区sector比较复杂,这里就比较简单了,它只有一个基本单位就是页,每一页的大小都是1K,0到127总共128页,总量就是128K,对于C8T6来说,它只有64K,所以C8T6的页只有一半0~63总共64页共64K,然后看一下页的地址范围
    第一个页的起始地址就程序存储器的起始地址0x08000000,之后就是一个字节一个地址依次线性分配的,看一下每页起始地址的规律,首先是0000然后0400、0800、0400,再之后1000、1400、1800
    最后一直到1FC00,所以地址只要以000、400、800、400,结尾的都一定是页的起始地址,这个稍微记一下。

    然后继续系统存储器,它的起始地址是0x1FFFF000,这个之前介绍过的,它的容量是2K,下面选项字节起始地址是0x1FFFF800,容量是16个字节,里面只有几个字节的配置参数,这个后面还会继续说的,那这里还可以发现我们平时说的芯片闪存容量是64K128K,它指的只是主存储器的容量,下面信息快的这两个东西虽然也是闪存,但是并不统计在这个容量里,这是闪存的分配方式,那最后就是这个闪存接口寄存器,里面包括KEYR键寄存器,SR状态寄存器,CR控制寄存器,外设的起始地址是0X4002 2000,每个寄存器都是四个字节也就是32位。


    接下来看一下我总结的这个基本结构图,整个闪存分为程序存储器、系统存储器和选项字节三部分,这里程序存储器为以C8T6为例,它是64K的,所以总共只有64页,最后一页的起始地址是0800FC00,左边这里是闪存存储器接口,手册里还有个名称,闪存编程和擦除控制器LPEC,大家也知道这两个名称其实是一个东西就行,然后这个控制器就是闪存的管理员,他可以对程序存储器进行擦除和编程,也可以对选项字节进行擦除和编程,系统容器是不能擦除和编程的,这个选项字节里面有很大一部分配置位,其实是配置主程序存储器的读写保护的,所以右边画的写入选项字节,可以配置程序存储器的读写保护,当然选项字节还有几个别的配置参数,这个待会再讲,那这就是整个闪存的基本结构。

    接下来我们来看一下细节问题,如何操作这个控制器FPEC,来对程序存储器和选项字节进行擦除和编程。


    首先第一步是flash解锁,这和之前W25Q64一样,W25Q64操作之前需要写使能,这个flash操作之前需要解锁,目的都是为了防止误操作,那这里解锁的方式和之前独立看门狗一样,都是通过在键寄存器写入指定的键值来实现,使用键寄存容器的好处就是更能防止误操作,每一个指令必须输密码才能完成,通过英文名称也能看出来,键的英文是KEY,直译是不是钥匙的意思,所以这个更形象的翻译我们可以把它叫做钥匙寄存器,密钥寄存器,首先LPEC共有三个键值,也就是三把开锁的钥匙,RDPRT键是解除读保护的密钥,值是0XA5,KEY1键值是0X45670123,KEY2键值是0XCDEF89AB,为什么是这些值呢,实际上是随便定义的,只要你定义的不是很简单就行
    继续看怎么解锁呢,第一个是复位后FPEC被保护,不能写入FLASH_CR,也就是复位后
    flash默认是锁着的,然后在FLASH_KEYR键寄存器中,先写入KEY1,再写入KEY2解锁,我们找到了锁,这个锁是KEYR寄存器,怎么解呢,要先用K1钥匙解,再用K2钥匙解,最终才能解锁成功,所以这个锁的安全性非常高,有两道锁,即使程序跑飞了,歪打正着正好写入了KEY1,那也难以保证下一次又歪打正着写入了KEY2,所以非人为情况下基本不可能解锁,然后第三条还有进一步的保护措施
    就是错误的操作序列会在下次复位前锁死FPEC和FLASH_CR,于是他发现有程序在尝试撬锁时,一旦没有先写入KEY1,再写入KEY2,整个模块就会完全锁死,除非复位,这是整个解锁操作,可以看到安全性非常高,接着继续看,解锁之后如何加锁呢,我们操作完成之后,要尽快把flash重新加锁,以防止意外情况,加锁的操作是设置FLASH_CR中的LOCK位锁住FPEC和FLASH_CR,这个比较简单,就是控制寄存器里面有个LOCK位,我们在这一位写1就能重新锁住闪存。

    接着看下一个知识点,这个地方我们要学习的是,如何使用指针访问存储器,因为STM32内部的存储器是直接挂在总线上的,所以这时在读写某个存储器就非常简单了,直接使用C语言的指针来访问即可。

    如果你这个地址写的是SRAM的地址,比如0X20000000,那可以直接写入了,因为SRAM在程序运行时是可读可写的,这是使用指针访问存储器的C语言代码,0X08000000,其中读取可以直接读,写入需要解锁,并且执行后面的流程。

    接下来就来看一下下面这三个流程图,第一个是编程,也就是写入,第二个是页擦除,STM32的闪存也是写入前必须擦除,擦除之后所有的数据位变为1,擦除的最小单位就一页,1K,1024字节,第三个是全删除,把所有页都给擦除掉,那首先说一下这个详细的流程,库函数已经帮我们都写好了,我们直接调用一个整体的函数就行,非常简单,这里我们只大概的了解一下详细步骤,研究得越深,操作越得心应手。


    全擦除
    第一步是读取lock位,看一下芯片锁没锁,下面如果lock位等于1锁住了,就执行解锁过程,解锁过程就是在KEYR寄存器先写入KEY1,再写入KEY2,这里如果它当前没锁住,就不用解锁了,这是流程图里给的解锁步骤,如果锁住了就解锁,如果没锁住就不用解锁,但是在库函数中并没有这个判断,库函数是直接执行解锁过程,管你锁没锁都执行解锁,这个比较简单直接,不过效果都一样,然后继续解锁之后,首先置控制寄存器里的MER(Mass Erase)位为1,然后再置STRT(Start)位为1,其中STRT为1是触发条件,STRT为1之后芯片开始干活,然后现在看到MER位是1,它就知道接下来要干的活就是全删除,这样内部电路就会自动执行全擦除的过程,然后继续擦除也是需要花一段时间的,所以擦除过程开始后,程序要执行等待,判断状态寄存器的BSY(Busy)位是否为1,BSY位表示芯片是否处于忙状态,BSY位为1表示芯片忙,所以这里如果判断BSY位等于1,就跳转回来继续循环判断,直到BSY位等于0跳出循环,最后一步这里写的是读出并验证所有页的数据,这个是测试程序才要做的,正常情况下全删除完成了,我们默认就成功了,如果还要再全读出来验证一下,这个工作量太大了,所以这里的最后一步我们就不管了,这是全擦除的流程。

    然后看一下页擦除,这个也是类似的过程,第一步一样的是解锁的流程,第二步,这个方框里的置控寄存器的PER(Page Erase)位为1,然后在AR(Address Register)地址寄存器中选择要擦除的页,最后置控制寄存器的STRT位为1,也是触发条件,芯片开始干活,然后芯片看到PER等于1,它就知道接下来要执行页擦除,然后闪存不止一页,页擦除芯片就要知道要具体擦哪一页,所以它会继续看AR寄存器的数据,AR寄存器我们要提前写入一个页的起始地址,这样芯片就会把我们指定的一页给擦除掉
    ,然后擦除开始之后,我们也要等待BSY位,最后读出并验证数据,这个就不用看了。


    最后看一下闪存的写入,擦除之后我们就可以执行写入的流程了,另外说明一下,STM32的闪存,在写入之前会检查指定地址有没有擦除,如果没有擦除就写入STM32则不执行写入操作,除非写入的全是0,这个数据是例外,因为不擦除就写入,可能会写入错误,但全写入0的话,写入肯定是没问题的
    ,来看一下流程图,写入的第一步也是解锁,然后第二步我们需要置控制寄存器的PG(Programming)位为1,表示我们即将写入数据,第三步就在指定的地址写入半字,这一步我们需要用到刚才说的这句代码,使用指针在指定地址写入数据,想写入什么数据,在这里指定即可,另外这里注意一下,写入操作只能以半字的形式写入,在STM32中有几个术语,字、半字和字节,其中字word就是32位数据
    ,半字half word就是16位数据,字节byte就是8位数据,那这里只能以半字写入,意思就是只能以16位的形式写入,一次性写入两个字节,如果你要写入32位,就分两次完成,如果你只要写入八位,这个就比较麻烦了,如果你想单独写入一个字节,还要保留另一个字节的原始数据的话,那只能把整页数据都读到SRAM,再随意修改SRAM数据修改全部完成之后,再把整页都擦除,最后再把整页都写回去,所以如果你想像SRAM一样随心所欲的读写,那最好的办法就先把闪存的一页读到SRAM中,读写完成后再擦除一页,整体写回去,那回到流程图这里,写入数据这个代码就触发开始的条件,不需要像擦除一样置STRT位了,写了半字之后,芯片会处于忙状态,我们等待一下BUSY清0,这样写入数据的过程就完成了,那每执行这样一个流程,只能写入一个半字,如果要写出很多数据,要不断循环调用这个流程就可以了。

    接下来我们再介绍一下选项字节这块内容,大概了解一下就行了,首先这里是选项字节的组织和用途
    图里的起始地址,就是我们刚才说的选项字节的起始地址,1FFF800,这块的这些数据,就前面这里这个表的这一行,里面总共只有16个字节,把这些存储器给展开,就这个图,这里是对应的16个字节
    ,其中有一半的名称前面都带了个N,比如RDP和nRDP ,USER和nUSER等,这个意思就是你在写入RDP数据时,要同时在NRDP写入数据的反码,其他的这些都是一样,写这个存储器时,要在带N的对应的存储器写入反码,这样写入操作才是有效的,如果芯片检测到这两个存储器不是反码的关系,那就代表数据无效有错误,对应的功能就不执行,这是一个安全保障措施,但这个写入反码的过程
    ,硬件会自动计算并写入,不需要我们操心,使用库函数的话,那就更简单了,函数都给我们分装好了
    ,直接调用函数就行,那然后看一下每个存储器的功能,去掉所有带n的就剩下八个字节存储器了,第一个RDP(Read Protect)是读保护配置位,下面有解释,在RDP存储器写入RDPRT键,就刚才说的A5
    ,然后解除读保护,如果RDP不是A5,那闪存就是读保护状态,无法通过调试器读取程序,避免程序被别人窃取,接着看第二个字节USER,这个是一些零碎的配置位,可以配置硬件看门狗和进入停机待机模式是否产生复位,这个了解即可,然后第三个和第四个字节data0和data1,这个在芯片中没有定义功能,用户可自定义使用,最后四个字节,WRP(Write Protect)0、1、2、3这四个字节配置的是写保护,在中容量产品里是每一个位对应保护四个存储页,四个字节总共32位,一位对应保护四页,总共保护32×4等于128页,正好对应中容量量的最大128页,那对于小容量和大容量产品呢,可以看一下手册,2.5选项字节说明这里,对于小容量产品,也是每一位对应保护四个存储页,但小容量产品最大只有32K,所以只需要一个字节WRP0就行,4×8=32,其他三个字节没用到,然而对于大容量产品,每一个位只能保护两个存储页,这样的话四个字节就不够用了,所以这里规定WRP3的最高位,这一位直接把剩下的所有页一起都保护了,这是写保护的定义。

    然后看一下如何去写入这些位呢,这里两页PPT展示的就是选项字节的擦除和编程,因为选项字节本身也是闪存,所以它也得擦除,这里参考手册并没有给流程图,我们看一下这个文字流程,这个文字流程和流程图细节上有些出入,我们知道关键部分就行。

    先看一下选项字节擦除,第一步其实也是解锁闪存,这里文字并没有写,然后第二步这里文字版的流程多了一步,检查SR的BSY位,以确认没有其他正在进行的闪存操作,这个实际上就是事前等待,如果当前已经在忙了,我先等一下,这一步在刚才的流程图里并没有体现,然后下一步解锁CR的OPTWRE(Option Write Enable)位,这一步是选项字节的解锁,选项字节里面还有一个单独的锁
    在解锁闪存后,还需要再解锁选项字节的锁,之后才能操作选项字节,解锁选项字节的话看一下前面的寄存器(前面闪存模块组织图),整个闪存的锁是KEYR,里面选项字节的小锁是下面的OPTKEYR(Option Key Register),解锁这个小锁也是类似的流程,我们需要在OPTKEYR里先写入KEY1,再写入KEY2,这样就能解锁选项字节的小锁了,然后继续解除小锁之后和之前的擦除类似,先设置CR的OPTER(Option Erase)位为1,表示即将擦除选项字节,之后设置CR的STRT位为1,触发芯片开始干活,这样芯片就会启动擦除选项字节的工作,之后等待BUSY位变为0,擦除选项字节就完成了,擦除之后就可以看写入了。


    和普通的闪存写入也差不多,先检测BSY,然后解除小锁,之后设置CR的OPTPG(Option Programming)位为1,表示即将写入选项字节,再之后写入要编程的半字到指定的地址,这个是指针写入操作,最后等待忙,这样写入选项字节就完成了。


    最后我们花几分钟学一下器件电子签名,这个非常简单,既然讲到闪存了,就顺便学习一下吧
    看一下电子签名存放在闪存存储器模块的系统存储区域,包含的芯片识别信息在出厂时编写不可更改
    ,使用指针读指定地址下的存储器,可获取电子签名,电子签名其实就是STM32的id号,它的存放区域是系统存储器,它不仅有BOOTLOADER程序,还有几个字节的id号,系统存储器起始地址是1FFFF000,看下这里,这里有两段数据,第一个是闪存容量存储器,基地址是1FFF F7E0,通过地址也可以确定它的位置,就是系统存储器,这个存储器的大小是16位,它的值就是闪存的容量单位是KB,然后第二个是产品唯一身份标识寄存器,就是每个芯片的身份证号,这个数据存放的基地址是1FFFF7E8,大小是96位,每一个芯片的这96位数据都是不一样的,使用这个唯一id号可以做一些加密的操作,比如你想写入一段程序,只能在指定设备运行,那也可以在程序的多处加入id号判断,如果不是指定设备的id号,就不执行程序功能,这样即使你的程序被盗,在别的设备上也难以运行,这是STM32的电子签名。

    手册:






    在编程过程中,任何读写闪存的操作都会使CPU暂停,直到此闪存编程结束,这是读写内部闪存存储数据的一个弊端,忙的时候代码执行会暂停,因为执行代码需要读闪存,闪存在忙没法读,所以CPU也就没法运行了,程序就会暂停,这会导致什么问题呢,假如你使用内部闪存存储数据,同时你的中断代码又在频繁执行,这样读写闪存的时候,中断代码就无法执行了,这可能会导致中断无法及时响应。

    示例代码:读写内部FLASH&读取芯片ID

    物联沃分享整理
    物联沃-IOTWORD物联网 » 江科大STM32终端开发教程

    发表评论