FLASH 闪存-stm32入门

本节我们学习的内容是 STM32 的 FLASH,闪存。

当然闪存是一个通用的名词,表示的是一种非易失性,也就是掉电不丢失的存储器。比如,我们之前学习 SPI 的时候,用的 W25Q64 芯片,就是一种闪存存储器芯片。

而本节,我们所说的闪存,则特指 STM32 的内部闪存,也就是我们下载程序的时候,这个程序所存储的地方。我们下载的程序掉电后肯定不会消失,这说明程序存储在了一个非易失性的存储器中,这个存储器,也是一种闪存,那我们本节,就来学习一下,如何利用程序,来读写存储程序的这个存储器。

那开始本节之前,我们还是看一下本节代码的现象。本节课主要有两个代码,第一个是读写内部 FLASH,第二个是读取芯片 ID。

先看一下第一个,这个代码的目的就是,利用内部 FLASH 程序存储器的剩余空间,来存储一些掉电不丢失的参数,如果你有一些配置参数,需要掉电不丢失的保存,再外挂一个存储器芯片的话,显然会增加硬件成本;那 STM32 本身不就是有掉电不丢失的程序存储器吗,我们直接把参数存在这里,是不是就又方便、又节省成本啊。

所以这里的程序是,按下 Key1,变换一下测试数据,然后存储到内部 FLASH 里,按下 Key2,把所有参数清零,最后 OLED 显示一下。

我们下载看一下,这里 OLED 显示了 Flag 和 Data,Flag 当作标志位,内容随便定义,这里定义的是 A5A5,Flag 的作用就是判断一下之前是不是存储过数据,如果存储过数据,就直接读取,如果没存储过,就先初始化一下,这个思路和之前 RTC 的那一节的代码是一样的。然后下面的 Data 就是要掉电存储的数据了,我们按下 Key1,可以变换一下测试数据,每变换一次,这个数据就更新存储到 FLASH 里了。比如现在,4 个数据分别是 5、A、F、14,我们直接把整个芯片断电,再重新上电,可以看到,数据仍然存在,和之前保存的一样;继续变换几次呢,再断电重启,可以看到,数据也还是存在的;继续测试,按复位键,可以看到,这个数据也不会丢失。那为了能清零这个数据呢,我们可以按 Key2 按键,这样就把所有参数归零了。这就是利用内部 FLASH 存储掉电不丢失数据的现象。

可以看到,整个电路,我不需要外挂任何存储芯片,在电路上,也不需要有任何新增设备,所以利用内部 FLASH 实现这个功能,是一个非常灵活和节省的方案,这就是第一个代码的现象。

然后继续看第二个代码,读取芯片 ID。这个代码非常简单,就顺便讲一下。那在 STM32 里,指定的一些地址下,存储的有原厂写入的 ID 号,我们直接使用指针的操作方式读取,就可以得到 ID 号了。

那下载看一下现象。可以看到,这里有两个 ID 号,第一个是 F_SIZE,表示芯片 FLASH 的容量,0040 表示 FLASH 容量,单位是 KB,换成十进制就是 64 KB,当然有的芯片 FLASH 容量会大于 64K B,这个数据有些不一样,也是正常的;然后第二个是 U_ID,是 96 位的芯片唯一 ID 号,每个芯片的唯一 ID 号都不一样,目前这个芯片读取出来是 FF32 066E 这个数据,大家可以自己读取看看,肯定和这个是不一样的。好,这就是本节程序的现象。

1. FLASH 闪存

接下来,我们看一下本节的内容。首先看一下简介。

1.1 FLASH 简介

STM32F1 系列的 FLASH 包含程序存储器、系统存储器和选项字节三个部分,通过闪存存储器接口(外设)可以对程序存储器和选项字节进行擦除和编程

那首先,FLASH 包含程序存储器、系统存储器和选项字节三个部分,这个我们之前介绍过。我们回顾一下,在 DMA 这一节我们介绍过存储器映像,STM32 内部的存储空间主要有这些部分,其中 ROM 区,就是掉电不丢失的,存储介质是 FLASH 闪存;RAM 区,掉电丢失,存储介质是 SRAM。闪存主要有程序存储器、系统存储器和选项字节 3 个部分,这就是我们本节要学习的内容。

  • 其中程序存储器是这三者之中,空间最大、最主要的部分,所以也称作主存储器,起始地址是 0800 开头的,用途是存储程序代码。系统存储器起始地址是 1FFF F000,用途是存储 BootLoader,用于串口下载。选项字节起始地址是 1FFF F800,用途是存储一些独立的配置参数。
  • 然后下面的地址也看一下,运行内存的起始地址是 2000 开头的;外设寄存器的起始地址是 4000;内核外设寄存器的起始地址是 E000。
  • 这些起始地址要记一下,要做到,当你看到一个存储器的地址时,一眼就能知道它处于什么区域,有什么特性,大概是做什么的,这是这一块内容,我们再回顾一下,等会儿还会用到的。

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

读写FLASH的用途:

  1. 利用程序存储器的剩余空间来保存掉电不丢失的用户数据

像我们刚才演示的代码,就是这个用法。对于我们这个 C8T6 芯片来说,它的程序存储器容量是 64K,一般我们写个简单的程序,可能就只占前面的很小一部分空间,剩下的大片空余空间,我们就可以加以利用。比如存储一些我们自定义的数据,这样就非常方便,而且可以充分利用资源。不过这里要注意,我们在选取存储区域时,一定不要覆盖了原有的程序,要不然程序自己把自己给破坏了,之后程序就运行不了了。
一般存储少量的参数,我们就选最后几页存储就行了。关于如何查看程序所占用空间的大小,这个我们下一小节也会介绍,那这就是第一个用途。

  1. 通过在程序中编程(IAP),实现程序的自我更新

刚才说了,我们在存储用户数据时,要避开程序本身,以免破坏程序,但如果,我们就非要修改程序本身,这会发送什么呢?那这就是第二点提到的功能,在程序中编程,利用程序,来修改程序本身,实现程序的自我更新。
这个在程序中编程,就是 IAP,在数码圈,也有个可能大家更熟悉的技术,叫 OTA,这两是类似的东西,都是用来实现程序升级的。当然这个 IAP 升级程序的功能比较复杂,我们本节暂时就不涉及了。

在线编程(In-Circuit Programming – ICP)用于更新程序存储器的全部内容,它通过JTAG、SWD协议或系统加载程序(Bootloader)下载程序

英文直译过来叫在电路中编程,意思就是下载程序你只需要留几个引脚就行,不用拆芯片了,就叫在电路中进行编程。
这个 JTAG、SWD,就是仿真器下载程序,就是我们目前用的 STLINK 使用 SWD 下载程序,每次下载,都是把整个程序完全更新掉。那系统加载程序,就是系统存储器的 BootLoader,也就是串口下载,串口下载,也是更新整个程序。这就是我们一直在用的 ICP 下载方式。
之后,更高级的下载方式,就是在程序中编程。

在程序中编程(In-Application Programming – IAP)可以使用微控制器支持的任一种通信接口下载程序

怎么实现呢?我们首先需要自己写一个 BootLoader 程序,并且存放在程序更新时,不会覆盖的地方,比如我们放在整个程序存储器的后面。然后,需要更新程序时,我们控制程序跳转到这个自己写的 BootLoader 里来,在这里面,我们就可以接收任意一种通信接口传过来的数据,比如串口、USB、蓝牙转串口、WIFI 转串口等等,这个传过来的数据,就是待更新的程序,然后我们控制 FLASH 读写,把收到的程序,写入到整个程序存储器的前面程序正常运行的地方,写完之后,再控制程序跳转回正常运行的地方,或者直接复位,这样程序就完成了自我升级。
这个过程,其实就是和系统存储器这个的 BootLoader 一样,因为程序要实现自我升级,在升级过程中,肯定需要布置一个辅助的小机器人来临时干活。只不过是系统寄存器的 BootLoader 写死了,只能用串口下载到指定位置,启动方式也不方便,只能配置 BOOT 引脚触发启动;而我们自己写 BootLoader 的话,就可以想怎么收怎么收,想写到哪就写到哪,想怎么启动就怎么启动,并且这整个升级过程,程序都可以自主完成,实现在程序中编程。更进一步,就可以直接实现远程升级了,非常灵活方便。
那有关 IAP 的内容,我就介绍这么多,更进一步的内容,大家再自己研究。

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

好,简介就是这些,接着我们继续,看一下这个闪存模块的组织。

1.2 闪存模块组织

在这里插入图片描述
这个表是中容量产品的闪存分配情况。我们 C8T6 芯片的闪存容量是 64K,属于中容量产品。对于小容量产品和大容量产品,闪存的分配方式有些区别,这个可以参考一下手册。

那首先提醒一下,闪存这一章的内容,在手册里是单独列出来的,并不在之前的参考手册里。我们需要打开闪存编程参考手册,打开之后,可以看到,这个文档也不是很多,其实就是单独一章的内容,这个注意一下,别找错位置了。

那我们打开 1.2 这一节,这里就是闪存模块组织。首先是小容量产品,这个页数少一些,总共 32 页,每页 1K;然后中容量产品,页数多一些,总共 128 页,每页 1K;最后大容量产品,页数更多,总共 256 页,并且每页容量也更大,是 2K。这是小中大容量产品闪存分配方式的不同,其他地方,基本都是一样的。

那回到本节,这里以中容量产品为例来讲解。首先看一下第一列的几个块,这里分为了 3 个块,第一个是主存储器,也就是我们刚才说的程序存储器,用来存放程序代码的,这是最主要,也是容量最大的一块。第二个是信息块,里面又可以分为启动程序代码和用户选择字节,其中启动程序代码就是刚才说的系统存储器,存放的是原厂写入的 BootLoader,用于串口下载,这个手册的名称经常会有不同的表述方式,但是大家要知道,某些名称,描述的其实是一个东西;然后下面这个用户选择字节,也就是刚才说的选项字节,存放一些独立的参数,这个选项字节,在手册里一直都称作选择字节,可能是翻译的问题,英文是 Option Bytes,我们一般都叫选项字节,大家也知道是一个东西就行。然后最后一块,是闪存存储器接口寄存器,这一块的存储器,实际上并不属于闪存,你看它的地址就知道,地址都是 40 开头的,说明这个存储器接口寄存器就是一个普通的外设,和之前介绍的 GPIO、定时器、串口等等,都是一个性质的东西,这些存储器,它们的存储介质,也都是 SRAM。这个闪存存储器接口,就是上面这些闪存的管理员,这些寄存器,就是用来控制擦除和编程这个过程的。

那到这里,这个表的整体我们就清楚了,上面是真正的闪存,分为三部分,主存储器(就是程序存储器),启动程序代码(就是系统存储器),用户选择字节(就是选项字节)。其中系统存储器和选项字节,又可以合称为信息块,这一点,和刚才 FLASH 简介里介绍的闪存分为三部分是对应的,没问题。

然后下面一部分,是闪存的管理员,我们擦除和编程,就通过读写这些寄存器来完成,这一点,和刚才 FLASH 简介里介绍的闪存存储器接口可以对闪存进行擦除和编程是对应的,当然这里只有擦除和编程,并没有读取,这是因为,读取指定存储器,直接使用指针读即可,用不到这个外设。

好,那我们继续看这个表。对于主存储器,这里对它进行了分页,分页是为了更好的管理闪存。擦除和写保护,都是以页为单位的,这一点和之前 W25Q64 那一节的闪存一样,同为闪存,它们的特性基本一样,写入前必须擦除,擦除必须以最小单位进行,擦除后数据位全变为 1,数据只能 1 写 0,不能 0 写 1,擦除和写入之后都需要等待忙啊,这些都是一样的。学习这节之前,大家可以再复习一下 W25Q64 那一节,相信你学过 W25Q64 之后,再学这一节,就会非常轻松了。那 W25Q64 的分配方式是,先分为块 Block,再分为扇区 Sector,比较复杂;这里,就比较简单了,它只有一个基本单位,就是页,每页的大小都是 1K,0~127,总共 128 页,总容量就是 128K,对于 C8T6 来说,它只有 64K,所以 C8T6 的页只有一半,0~63,总共 64 页,共 64K。

然后看一下页的地址范围,第一个页的起始地址就是程序存储器的起始地址,0x0800 0000,之后就是一个字节一个地址,依次线性分配了。看一下每页起始地址的规律,首先是 0000,然后 0400、0800、0C00,再之后,1000,后面按照规律,就是 1400、1800、1C00、2000、2400、2800、2C00 等等等等,最后一直到 1 FC00,所以地址只要以 000、400、800、C00 结尾的都一定是页的起始地址,这个稍微记一下。之后如果想要给一个页的起始地址,就需要用到这个规律。

然后继续,系统存储器它的起始地址是 0x1FFF F000,这个之前介绍过的,它的容量是 2K,这个就不用多说了。

在下面,选项字节,起始地址是 0x1FFF F800,容量是 16 个字节,里面只有几个字节的配置参数,这个后面还会继续说的。

那这里还可以发现,我们平时说的,芯片闪存容量是 64K、128K,它指的只是主存储器的容量,下面信息块的两个东西,虽然也是闪存,但是并不统计在这个容量里,这就是闪存的分配方式。

那最后,就是这个闪存接口寄存器了。里面包括 KEYR 键寄存器,SR 状态寄存器,CR 控制寄存器等等,外设的起始地址是 0x4002 2000,每个寄存器都是 4 个字节,也就是 32 位,这就是这个外设的寄存器。

好,按这个表就看到这里。

接下来看一下总结的 FLASH 基本结构图

1.3 FLASH 基本结构图

在这里插入图片描述
整个闪存分为程序存储器、系统存储器和选项字节三部分。

这里程序存储器以 C8T6 为例,它是 64K 的,所以总共只有 64 页,最后一页的起始地址是 0800 FC00。

左边是闪存存储器接口,手册里还有个名称,闪存编程和擦除控制器(FPEC),大家也知道这两个名称是一个东西就行。然后,这个控制器,就是闪存的管理员,它可以对程序存储器进行擦除和编程,也可以对选项字节进行擦除和编程。当然系统存储器是不能擦除和编程的。

之后选项字节,里面有很大一部分配置位,其实是配置主程序存储器的读写保护的,所以右边画的,写入选项字节可以配置程序存储器的读写保护。当然选项字节还有几个别的配置参数,这个待会儿再讲。

那这就是整个闪存的基本结构,大概就是这样。

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

1.4 FLASH 解锁

首先,第一步是 FLASH 解锁。

这个和之前 W25Q64 一样,W25Q64 操作之前需要写使能;这个 FLASH,操作之前需要解锁,目的都是为了防止误操作。

那这里,解锁的方式和之前独立看门狗一样,都是通过在键寄存器写入指定的键值来实现。使用键寄存器的好处就是,更能防止误操作,每一个指令,必须输密码才能完成。通过英文名称也能看出来,键的英文是 KEY,KEY 直译是不是钥匙的意思啊,所以这个更形象的翻译,我们可以把它叫作钥匙寄存器、密钥寄存器。那看一下,首先

  • FPEC共有三个键值(也就是 3 把开锁的密钥):
  1. RDPRT键 = 0x0000 00A5

是解除读保护的密钥。

  1. KEY1 = 0x4567 0123
  2. KEY2 = 0xCDEF 89AB

为什么是这些值呢?通过它的值也可以看出来,实际上是随便定义的,只要你定义的不是很简单,不是随便就能把这个锁翘了就行。
继续看,怎么解锁呢?

  • 解锁:
  1. 复位后,FPEC被保护,不能写入FLASH_CR

也就是复位后,FLASH 默认是锁着的。

  1. 在 FLASH_KEYR 键寄存器中,先写入 KEY1,再写入 KEY2,解锁

我们找到了锁,这个锁是 KEYR 寄存器,怎么解呢?要先用 KEY1 钥匙解,再用 KEY2 钥匙解,最终才能解锁成功。所以这个锁的安全性非常高,有两道锁,即使你程序跑飞了,歪打正着,正好写入了 KEY1,那也难以保证,下一次又歪打正着,写入了 KEY2,所以非人为情况下,基本不可能解锁。
然后第三条,还有进一步的保护措施。

  1. 错误的操作序列会在下次复位前锁死 FPEC 和 FLASH_CR

也就是它发现有程序在尝试撬锁时,一旦没有先写入 KEY1,再写入 KEY2,整个模块就会完全锁死,除非复位。这就是整个解锁操作,可以看到,安全性非常高。
接着继续看,解锁之后,如何加锁呢?我们操作完成之后,要尽快把 FLASH 重新加锁,以防止意外情况。

  • 加锁:
  1. 设置 FLASH_CR 中的 LOCK 位锁住 FPEC 和 FLASH_CR

这个比较简单。就是控制寄存器里面有个 LOCK 位,我们在这一位写 1,就能重新锁住闪存了。
好,这就是解锁和加锁。

我们操作闪存的第一步,就是解锁;操作完成后,就是加锁,这个我们就清楚了。

接着看下一个知识点。

1.5 使用指针访问存储器

这个地方我们要学习的是如何使用指针访问存储器。

因为 STM32 内部的存储器,是直接挂在总线上的,所以这时,再读写某个存储器,就非常简单了,直接使用 C 语言的指针,来访问即可。那看一下这里的操作。

  • 使用指针读指定地址下的存储器:
uint16_t Data = *((__IO uint16_t *)(0x08000000));

其中:
	#define    __IO    volatile

我们需要用到这样一个格式的语句。这句代码什么意思呢,我们来一一分析。

  1. 我们需要给定要读取存储器的地址。

比如我这里以 0800 0000 为例,我想读取这个地址下的数据,那就把这个地址写到这里。另外这个括号,因为目前里面只有一个数,所以也可以不写,但是如果你要对这个地址进行加减,那就必须加上括号,并在括号里面进行加减,否则运行的优先级会有问题。总之,如果你不敢肯定各个运算符的优先级,那多加点括号,肯定是最保险的。

好,这是第一步,把地址写到这里。

  1. 在这个地址前面,加上强制类型转换。

这里把这个变量强制转换为了 uint16_t 的指针类型,如果你想以 16 位的方式读出指定地址的数据,那就转换成 uint16_t*;如果你想以 8 位的方式读出来呢,就转换成 uint8_t*;想 32 位,就转为 uint32_t*;想浮点类型,就转为 float* 或者 double*,这个根据你的读取形式来。

然后这个指针类型前面,还加了个 __IO,在 STM32 库函数中,这是一个宏定义。下面写了,这个宏定义,对应 C 语言的关键字,volatile,volatile 直译就是易变的数据,在这个数据类型前面加上 volatile,是一个安全保障措施,在程序逻辑上,没有作用,加上这个关键字的目的,用一句话来说,就是防止编译器优化。

首先说一下,Keil 编译器默认情况下是最低优化等级,这时,加不加这个 volatile,都没有影响;如果你要提高编译器优化等级,这时就会有这个问题了,那编译器优化有什么用呢,用途就是,可以去除无用的繁杂代码,降低代码空间,提升运行效率,但优化之后,编译器在某些地方可能会弄巧成拙。比如你想用变量计数空循环的方式实现延时函数,把编译器优化的时候,可能会说你这段延时函数好像没用啊,还白白浪费时间,我直接给你优化掉,不让你浪费时间了,这就弄巧成拙了。因为我们的本意就是靠浪费时间来延时,这时,我们就可以在延时的变量定义前面,加上 volatile,告诉编译器,我无论对这个变量干什么,你都原封不动的去执行,别给我优化掉了。

另外,编译器还会利用缓存来加速代码,比如如果你要频繁读写内存的某个变量,那最常见的优化方式就是先把变量转移到高速缓存里来,在 STM32 内核里,有一个类似缓存的工作组寄存器,这些寄存器的访问速度最快,我先把变量放在缓存里,需要读写的时候,直接访问缓存就行了,用完之后,再写回内存,这是一个优化方案。但是,如果你的程序里有多个线程,比如中断函数,在中断函数里,你改变了这个原始变量,那可能缓存并不知道你更改了,下次程序还看缓存的变量,就会造成数据更改不同步的问题,这时,我们的做法也是,读取变量定义的前面,加上一个 volatile,告诉编译器这个变量是易变的,每次读取你都得执行到位,要直接从内存里找,不要再用缓存优化了。

所以总结一下就是,如果开启了编译器优化,在无意义加减变量,多线程更改变量,读写与硬件相关的存储器时,都需要加上 volatile,防止被编译器优化。如果你默认,不开启编译器优化,那就无所谓了,加不加都一样。

所以这里,我们要直接读取存储器,为了严谨,可以加上 volatile,告诉编译器,我要直接了当的读取指定存储器,不要给我优化,或者绕弯子。

好,第二步完成之后,这最外层括号里面的部分,就是一个指针变量,并且这个指针,已经指向了 0x0800 0000 这个位置。然后最后一步,就是

  1. 使用 * 号,指针取内容。

把这个指针指向的存储器取出来了。这个值,就是指定存储器的值,取出来之后,我们可以把它赋值给自定义的变量 Data,这样就完成了指定地址读的任务了。另外说一下,对于闪存的读取来说,是不需要进行解锁的,因为读取只是看看寄存器,不对寄存器进行更改,所需权限很低,不用解锁,直接就能读。

那接下来继续看

  • 使用指针写指定地址下的存储器:
*((__IO uint16_t *)(0x08000000)) = 0x1234;

其中:
	#define    __IO    volatile

这个语句的意思就很明显了。左边和上面一样,先给定地址,再强转为指针,最后指针取内容,这样就是指定地址的值。我们直接对它赋值,比如 0x1234,这样就能完成指定地址写的功能了。

另外这里也有注意事项,因为这个语句是写入数据,并且指定的是闪存的地址,闪存在程序运行时,是只读的,不能轻易更改,而我们本节需要对闪存进行更改,这个所需的权限就比较高,需要提前解锁,并且还有套个程序存储器编程的流程,这个待会再说。

那如果你这个地址写的是 SRAM 的地址,比如 0x2000 0000,那就可以直接写入了。因为 SRAM 在程序运行时是可读可写的,好,这就是使用指针访问存储器的 C 语言代码。

其中,读取,可以直接读;写入,需要解锁,并且执行后面的流程。

那我们接下来,就来看一下下面这三个流程图。那首先说一下这些详细的流程,库函数已经帮我们都写好了,我们直接调用一个整体的函数就行,非常简单。这里我们只大概的了解一下详细步骤,研究的越深,操作的越得心应手。那我们从下往上看,先看擦除,再看编程。首先看一下全擦除。

第三个是全擦除。
在这里插入图片描述
把所有页,都给擦除掉。

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

这就是全擦除的流程。然后我们看一下页擦除。

第二个是页擦除。
在这里插入图片描述
STM32 的闪存也是写入前必须擦除。擦除之后,所有的数据位变为 1,擦除的最小单位就是一页,1K,1024 字节。

这个也是类似的过程。

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

这就是页擦除的过程。最后,看一下闪存的写入,擦除之后,我们就可以执行写入的流程了。

第一个是编程,也就是写入。
在这里插入图片描述

另外说明一下,STM32 的闪存再写入之前会检查指定地址有没有擦除,如果没有擦除就写入,STM32 则不执行写入操作,除非写入的全是 0,这一个数据是例外,因为不擦除就写入,可能会写入错误,但全写入 0 的话,写入肯定是没问题的。

那看一下流程图。

  1. 写入的第一步,也是解锁。
  2. 然后第二步,我们需要置控制寄存器的 PG(Programming)位为 1,表示我们即将写入数据。
  3. 之后第三步,就是在指定的地址写入半字,这一步,我们需要用到刚才说的 *((__IO uint16_t *)(0x08000000)) = 0x1234; 这句代码,使用指针,在指定地址写入数据,想写入什么数据,在这里指定即可。另外这里注意下,写入操作,只能以半字的形式写入。在 STM32 中,有几个术语,字、半字和字节,其中字,Word,就是 32 位数据;半字,HalfWord,就是 16 位数据;字节,Byte,就是 8 位数据,这个了解一下。那这里只能以半字写入,意思就是只能以 16 位的形式写入,一次性,写入两个字节;如果你要写入 32 位,就分两次完成;如果你只要写入 8 位,这个就比较麻烦了,如果你想单独写入一个字节,还要保留另一个字节的原始数据的话,那就只能把整页数据都读到 SRAM,再随意修改 SRAM 数据,修改全部完成之后,再把整页都擦除,最后再把整页都写回去。所以,如果你想像 SRAM 一样随心所欲地读写,那最好的办法就是先把闪存的一页读到 SRAM 中,读写完成后,再擦除一页,整体写回去。那回到流程图这里,写入数据这个代码,就是触发开始的条件,不需要像擦除一样,置 STRT 位了。
  4. 写入半字之后,芯片会处于忙状态,我们等待一下 BSY 清零,这样写入数据的过程就完成了。

那每执行这样一个流程,只能写入一个半字,如果要写入很多数据,那就不断循环调用这个流程,就可以了。

好,到这里。闪存的解锁、指针读写数据、擦除和编程的流程,我们就学完了。

接下里我们再介绍一下选项字节。

1.6 选项字节

这一块内容,大概了解一下就行。
在这里插入图片描述
RDP:写入RDPRT键(0x000000A5)后解除读保护
USER:配置硬件看门狗和进入停机/待机模式是否产生复位
Data0/1:用户可自定义使用
WRP0/1/2/3:配置写保护,每一个位对应保护4个存储页(中容量)

首先这里是选项字节的组织和用途。图里的起始地址,就是我们刚才说的选项字节的起始地址 1FFF F800,这一块的这些数据,就是前面闪存模块组织这个表的用户选择字节这一行,里面总共只有 16 个字节,把这些存储器给展开,就是这个图。

这里是对应的 16 个字节,其中有一半的名称,前面都带了个 n,比如 RDP 和 nRDP,USER 和 nUSER,等等,这个意思就是你在写入 RDP 数据时,要同时在 nRDP 写入数据的反码,其他的这些都是一样,写入这个存储器时,要在带 n 的对应的存储器写入反码,这样写入操作才是有效的,如果芯片检测到这两个存储器不是反码的关系,那就代表数据无效,有错误,对应的功能就不执行,这是一个安全保障措施。

当然这个写入反码的过程,硬件会自动计算,并写入,不需要我们操心,使用库函数的话,那就更简单了,函数都给我们封装好了,直接调用函数就行。

那然后看一下每个存储器的功能,去掉所有带 n 的,就剩下 8 个字节存储器了。

  1. 第一个 RDP(Read Protect),是读保护配置位,下面有解释,在 RDP 寄存器写入 RDPRT 键,就是刚才说的 A5,然后解除读保护;如果 RDP 不是 A5,那闪存就是读保护状态,无法通过调试器读取程序,避免程序被别人窃取。
  2. 接着看第二个字节 USER,这个是一些零碎的配置位,可以配置硬件看门狗和进入停机/待机模式是否产生复位,这个了解即可。
  3. 然后第三个和第四个字节,Data0 和 Data1,这个在芯片中没有定义功能,用户可自定义使用。
  4. 最后四个字节,WRP(Write Protect)0、1、2、3,这四个字节,配置的是写保护。
    在中容量产品里,是每一个位对应保护 4 个存储页,4 个字节,总共 32 位,一位对应保护 4 页,总共保护 32*4 = 128 页,正好对应中容量的最大 128 页。
    那对于小容量和大容量产品呢?可以看一下手册,2.5 选项字节说明这里,对于小容量产品,也是每一位对应保护 4 个存储页,但是小容量产品最大只有 32K,所以只需要一个字节 WRP0 就行,4*8 = 32,其他 3 个字节没用到。然后对于大容量产品,每一个位只能保护 2 个存储页,这样的话 4 个字节就不够用了,所以这里规定 WRP3 的最高位,这一位直接把剩下的所有页一起都保护了,这是写保护的定义。

这样选项字节,有哪些东西,都是干啥的,我们就清楚了,然后看一下,如何去写入这些位呢?

接下来展示的就是选项字节的擦除和编程。因为选项字节本身也是闪存,所以它也得擦除,这里,参考手册并没有给流程图,我们看一下文字流程。

这个文字流程和流程图细节上有一些出入,我们知道关键部分就行,先看一下选项字节擦除。

1.6.1 选项字节的擦除

  1. 检查FLASH_SR的BSY位,以确认没有其他正在进行的闪存操作
  2. 解锁FLASH_CR的OPTWRE位
  3. 设置FLASH_CR的OPTER位为1
  4. 设置FLASH_CR的STRT位为1
  5. 等待BSY位变为0
  6. 读出被擦除的选择字节并做验证

第一步,其实也是解锁闪存,这里文字并没有写。

然后第二部,这里文字版的流程,多了一步,检查 SR 的 BSY 位,以确认没有其他正在进行的闪存操作,这个实际上是事前等待,如果当前已经在忙了,我先等一下,这一步在刚才的流程图里并没有体现。

然后下一步,解锁 CR 的 OPTWRE(Option Write Enable)位,这一步是选项字节的解锁,选项字节里面还有一个单独的锁,在解锁闪存后,还需要再解锁选项字节的锁,之后才能操作选项字节。解锁选项字节的话,看一下闪存模块组织的寄存器,整个闪存的锁是 KEYR,里面选项字节的小锁,是下面的 OPTKEYR(Option Key Register),解锁这个小锁,也是类似的流程,我们需要在 OPTKEYR 里,先写入 KEY1,再写入 KEY2,这样就能解锁选项字节的小锁了。

然后继续,解除小锁之后,和之前的擦除类似,先设置 CR 的 OPTER(Option Erase)位为 1,表示即将要擦除选项字节。

之后设置 CR 的 STRT 位为 1,触发芯片,开始干活,这样芯片就会启动擦除选项字节的工作。

之后,等待 BSY 位变为 0,擦除选项字节就完成了。

擦除之后,就可以看写入了。

1.6.2 选项字节的编程

  1. 检查FLASH_SR的BSY位,以确认没有其他正在进行的编程操作
  2. 解锁FLASH_CR的OPTWRE位
  3. 设置FLASH_CR的OPTPG位为1
  4. 写入要编程的半字到指定的地址
  5. 等待BSY位变为0
  6. 读出写入的地址并验证数据

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

好,那有关选项字节的内容,就介绍这么多。至此,闪存的整个的介绍,我们就介绍完了。

最后还是大概看一下手册。

首先看一下闪存编程手册。

最开始这里,是 ICP 和 IAP 的一些介绍,对闪存编程,实现 IAP,在做产品时,还是一个非常方便的功能,有利于便捷的进行程序升级。

然后往后看,这里有一些术语,小容量、中容量、大容量是什么意思;字、半字、字节的定义,等等名词,在这里有介绍。

然后是概述,这里闪存模块组织,是一个比较重要的内容,要读写闪存,首先你得知道它是怎么分配的,这里,小容量、中容量、大容量的分配方式,都在这个表里体现出来了。然后下面是一些说明文字,比如,正在执行写或擦除操作的时候,不能同时进行读取,然后写或擦除时,必须打开 HSI 时钟,这个 SystemInit 第一步就已经打开了,不要去动它就行。

然后下面读操作这里,这一页是涉及内核和 CPU 运行的内容,理解起来还是有点抽象的,大家可以不用过多关心,也不需要我们自己干什么的。比如这个预取缓冲器,可以提起读取程序来加快代码执行,还有主频比较低的时候,可以开启半周期访问加快代码执行,这些东西随便看看,不用过多了解。

然后就是闪存编程和擦除控制器 FPEC 了。键值和解锁,这些我们都介绍过。然后主闪存编程,里面有些说明,比如一次只能写入一个半字,任何非半字的数据,都会产生总线错误。

还有一个比较重要的注意事项就是:在编程过程中,任何读写闪存的操作都会使 CPU 暂停,直到此次闪存编程结束。这其实是读写内部闪存存储数据的一个弊端,就是闪存忙的时候,代码执行会暂停,因为执行代码需要读闪存,闪存在忙,没法读,所以 CPU 也就没法运行了,程序就会暂停,这会导致什么问题呢?假如你使用内部闪存存储数据,同时你的中断代码又是在频繁执行的,这样,读写闪存的时候,中断代码就无法执行了,这可能会导致中断无法及时响应。比如一个项目,STM32 驱动一个点阵屏,这个点阵屏需要用定时器中断,不断地去扫描刷新,否则屏幕就不会亮,同时程序里,我又使用了内部闪存来存储一些配置参数,然后测试的时候就会出现一个问题,就是一旦内部闪存进行读写,整个屏幕就会快速的闪一下,这个虽然不是大问题,但非常影响用户体验,问题的原因就是,读写闪存会导致中断扫描点阵的代码暂停,扫描暂停,屏幕就会闪一下,所以最终只能放弃内部闪存存储数据了。这是闪存存储的一大弊端,如果你的程序里有需要频繁执行,且对时间要求严格的中断函数,那就要慎用这个内部闪存来存储用户数据,这是这个注意事项。

然后继续看是编程过程。流程也介绍过,有个注意事项就是如果指定地址没有擦除,那就不会执行编程,同时提出警告,唯一的例外是写入 0000,这个没问题。然后如果指定地址为写保护,也是不执行编程,并提出警告。之后是文字版的流程。

然后闪存页擦除,文字版的流程和下面的流程图。整片擦除,文字版的流程和流程图,这些我们也介绍过,其中有个说明是,整片擦除,信息块的内容不受影响。

接下来就是选项字节的编程,选项字节,在 FPEC 解锁后,还要再在 OPTKEYR,写入 KEY1 和 KEY2,解锁单独的小锁。然后下面写的是,FPEC 会自动计算高字节的反码,所以反码不用我们操心。然后编程文字版的流程,我们介绍过,这里有个说明是,当闪存由保护变为未保护时,会自动执行整片擦除,防止代码被盗,之后擦除过程,我们介绍过。

再之后,就是保护相关的内容了,写保护和读保护,可以再自己看看这些说明。

然后选项字节的组织和每个字节的定义,这一块可以详细看看,我们也介绍过。当然有个注意事项就是这里的位,都使用的是反逻辑,1 表示无效,0 表示有效,因为闪存擦除之后都是 1,所以 1 会用来作为默认情况,比如这里的写保护,1,是默认的不实施写保护,而 0,才是实施写保护,这个注意一下。

最后,就是寄存器说明了。第一个闪存访问控制寄存器,这个是和内核执行代码有关的东西,不用了解。之后 FPEC 寄存器和 OPT 键寄存器,分别是闪存锁和选项字节的小锁,写入 KEY1,再写 KEY2,解锁。状态寄存器,表示电路的工作状态,重点了解的就是这个 BSY,忙标志位。控制寄存器,用于控制电路运行,重点了解这个 LOCK,加锁,STRT,开始,OPTER,选项字节擦除,OPTPG,选项字节编程,MER,全擦除,PER,页擦除,PG,编程,这些位,我们之前都介绍过。之后地址寄存器,这个是配合页擦除,指定擦除哪一页的。然后选项字节寄存器,这些寄存器会把闪存里的选项字节的内容加载进来,里面的内容和选项字节是对应的,这个也是一样。

最后就是寄存器总表了。好,这就是闪存的手册内容,大家可以再自己仔细看看。

最后,我们学一下器件电子签名。这个非常简单。既然介绍到闪存了,就顺便学习一下吧。

2. 器件电子签名

电子签名存放在闪存存储器模块的系统存储区域,包含的芯片识别信息在出厂时编写,不可更改,使用指针读指定地址下的存储器可获取电子签名。

电子签名,其实就是 STM32 的 ID 号,它的存放区域是系统存储器,就是 FLASH 基本结构图中的系统存储器。它不仅有 BootLoader 程序,还有几个字节的 ID 号,系统存储器,起始地址是 1FFF F000。

看一下这里,这里有两段数据:

  1. 闪存容量寄存器:
    • 基地址:0x1FFF F7E0
    • 大小:16位

通过地址,也可以确定,它的位置,就是系统存储器。它的值就是闪存的容量,单位是 KB。

  1. 产品唯一身份标识寄存器:
    • 基地址: 0x1FFF F7E8
    • 大小:96位

也就是每个芯片的身份证号。每一个芯片的这 96 位数据,都是不一样的,使用这个唯一 ID 号,可以做一些加密的操作。比如你想写入一段程序,只能在指定设备运行,那就可以在程序的多处加入 ID 号判断,如果不是指定设备的 ID 号,就不执行程序功能,这样即使你的程序被盗,在别的设备上也难以运行。

这就是 STM32 的电子签名。总共就是这么多内容,非常简单。我们也顺便学一下。好,到这里,我们本节的内容,差不多就介绍完了。

然后还有一小点内容,是器件电子签名的手册。这个看一下参考手册,第 28 章,器件电子签名,这个内容非常少。

上面这里写了,电子签名是出厂编写的,包含芯片识别信息。之后直接是寄存器,闪存容量寄存器,基地址,也就是起始地址,指示了这个数据存在什么地方了,里面有 16 个只读位,表示闪存容量。然后产品唯一身份标识寄存器,有什么用呢?比如,作为序列号,作为密码,用来激活带安全机制的自举过程,这 96 位,可以以字节为单位读取,也可以以半字或全字来读取,然后基地址是 0x1FFF F7E8,下面就是所有位的内容了,就是一串 ID 号数据,这就是器件电子签名的内容。

那到这里,本小节的内容就全部结束了,我们下一小节,来学习闪存的代码部分。

3. 两个读写内部闪存的功能案例

本小节我们来学习一下读写内部闪存的代码部分。

3.1 读写内部 FLASH

3.1.1 硬件电路

先看一下本节的接线图。
在这里插入图片描述
本节的接线也是非常的简单。右下角是 OLED,然后左上角在 PB1 和 PB11 两个引脚插上两个按键,用于控制,这样就行了。

然后看一下面包板,右下角 OLED,左上角两个按键,这就是接线部分。

3.1.2 代码整体框架

好,那在开始代码之前,我们先规划一下工程结构。

这个工程,计划是建两个底层模块:

  1. 最底层计划叫 MyFLASH,在这里面,我们要实现闪存最基本的 3 个功能,也就是读取、擦除和编程。
  2. 之后,在此模块之上,计划再建一个模块,叫 Store,主要实现参数数据的读写和存储管理。

因为我们最终应用层想要实现的功能是:

  • 任意读写参数,并且这些参数是掉电不丢失的。至于你存在什么地方,怎么擦除,采用什么读写策略,这并不是应用层所关心的,所以在 Store 这一层,我们会定义一个 SRAM 数组,需要掉电不丢失的参数就写入到 SRAM 数组里,之后调用保存的函数,这个 SRAM 数组就自动备份到闪存里。
  • 上电后,Store 初始化,会自动再把闪存里的数据读回到 SRAM 数组里。

这是闪存管理策略,这部分主要就在 Store 这一层实现。

那最后,就是 main.c 里面的应用层部分了。想要保存参数,就写到 Store 层的数组,再调用保存函数,备份到闪存,这样就能实现最终功能了。

那这就是这个代码的整体规划。待会儿我们就来一步步的实现。

然后,本节的代码调试,我们还有一个非常强大的辅助软件可以使用,就是 STM32 ST-LINK Utility。

这个软件在之前软件使用那个章节里也介绍过,它的获取和安装方法,可以参考一下软件使用章节。

那安装好之后,在桌面有这样的图标,我们打开它,在使用之前,我们需要用 STLINK,把 STM32 连接好,然后我们点击连接这个按钮,连接,可以看到,下面这个窗口里,显示的就是闪存里面存储的数据了。也就是说,闪存里面每个地址下到底存储了啥,我们通过这个软件,就能直接清晰地看到,这就非常方便调试了。像之前 W25Q64 的闪存芯片,芯片里面存了啥,我们看不到,这样我们就只能,先写入再读取,整个过程都没问题,才算测试成功,那一旦读取的数据和写入的不同,其实我们也不清楚到底是写入错了还是读取错了,这对调试来说就不友好。而我们本节,有了这个软件辅助,芯片里存了啥,我们一目了然,这样就可以单独测试读取、擦除和编程,验证程序功能也非常方便,所以我们本节,要充分利用好这个方便的软件。

看一下,首先 Address 这个地方我们可以指定想要查看的起始地址,目前是 0800 0000,也就是整个闪存的起始地址,所以下面的界面,就是从 0800 0000 开始的,这里存的就是我们的程序代码。之后第二个 Size,就是从起始地址开始,总共查看多少个字节,目前 0x10000,就是查看 64KB 的字节数,0x10000 为什么是 64KB,计算方法就是 0x10000 先转为十进制,再除 1024,就是 64KB,这是基础的计算。最后第三个框,是数据宽度,可以指定是以 32 位的形式显示,还是以 16 位或 8 位的形式显示,这个就分别对应的是字、半字和字节,现在是 32 位,所以下面是 32 位,4 个字节,在一个框里;如果改成 16 位,那就是 16 位,两个字节,一个框;改成 8 位,那就是一个字节一个框,这就是显示模式,也好理解吧。

然后还有一个更强大的功能是这个软件可以直接任意修改闪存的数据。比如我看这个数据不爽,可以先选中,再单击一下,这样就可以直接更改数据,比如改成 66,Enter,这样这个数据就直接变成 66 了。当然,目前改的是程序文件,正常的程序大家不要随便改,要不然破坏了程序,可能运行就会出问题。那这里,从效果上来看,它可以任意更改数据,但是也可以想到,底层其实也是有先擦除再写入的过程,这是闪存的基本特性。好,这就是这个读取闪存和写入闪存的操作,通过这个软件,不用写代码,就能实现。

然后还有一个功能,就是选项字节的读写。打开 Target,Option Bytes,这里就是选项字节配置界面,读保护、用户配置部分、自定义的两个字节和写保护,这四个部分的配置,也是在这里,随便点,就能配置了。另外你看这里写保护,点一下就是 4 页同时打勾,是不是对应选项字节里的一个位配置 4 个闪存页啊。那配置完之后,点 Apply,这样选项字节就自动配置好了,也不用写任何代码,非常方便。

好,这就是这个软件,闪存的读写,选项字节的读写,都可以在这里进行可视化的操作。待会儿程序的功能,实际上实现的就是这个效果,测试的时候,也可以在这里对照验证一下。

那然后,我们看一下 FLASH 基本结构,梳理一下流程。闪存的基础代码呢,其实也不难,首先,闪存并不需要初始化,直接操作即可。然后第一个,读取数据,我们直接把 uint16_t Data = *((__IO uint16_t *)(0x08000000)); 这一句代码封装一下就完事了。之后第二个,擦除,我们要使用这个步骤,全擦除或者页擦除,这两个功能,分别对应一个库函数,都封装好了,详细流程不用我们写,当然要记得,执行之前,手动调用一下解锁,执行之后,一般要再加锁一下。最后第三个,编程,也是有对应的函数,调用函数就行,当然解锁和加锁,也不要忘了。这就是闪存的三个操作,读取、擦除和编程。

之后,选项字节的擦除和编程和主闪存的擦除和编程类似,也都对应的有函数。当然选项字节的操作,我们不作要求,如果你有少量的需要,那用这个软件配置,不是更方便快捷嘛。而且,用代码配置读写保护的话,容易造成芯片自锁,比如你把闪存给写保护了,但是程序里并没有预留解除写保护的代码,这样锁住之后,芯片就没法下载程序了。这时,靠代码自己,肯定是救不活芯片的,但是,我们还可以用这个软件来配置,在这个选项字节配置里,把读写保护都去掉,再 Apply,就能救活芯片了。所以如果你的芯片,因读写保护,而无法下载程序,那就记得到这里,来救它。

好,流程清楚了,我们再看一下库函数。本节用到的库是 flash.c 和 flash.h,打开 flash.h 看一下,最下面这里,就是 flash 的所有函数了。然后,我们依次来看一下函数。

那我们观察到,这里的函数被分为了 3 个部分。第一部分,注释写的是,这些是所有 F10x 设备都可以使用的函数;第二部分,是所有设备都可以使用的、新的函数;最后,第三部分,是只有 XL 加大容量的设备才可以使用的、新的函数;并且第三部分的函数有个预编译,只有定义了 XL 这个宏,这些函数才是有效的。

首先说一下,本节我们只需要使用上面第一部分的函数,下面两部分都用不到,那为什么要再加下面这两部分呢,新的函数是什么意思呢,这个,在 c 文件的前面也有说明,大家可以自己翻译看看。简单来说,就是因为在最开始的时候,这个系列只有小容量 LD、中容量 MD 和大容量 LD,之后,加大容量 XL 才推出,XL 直接又加了一块新的、独立的闪存,所以 XL 产品总共有两块闪存。为了区分它们,设计者命名,新加的这一块,叫做 Bank2;与之对应,原来小、中、大容量的这一块,叫做 Bank1,所以我们就知道了,Bank1、Bank2 是加大容量产品才有的概念,加大容量产品推出后,这些函数又对 XL 系列进行了适配更新,至于每个函数做出了哪些更改,可以看 .c 文件的表,后面还新增了一些函数,这些函数里都带了 Bank1 和 Bank2 的指定,是加大容量才会用到的。好,这就是三部分函数的解释,大家不要被这个 Bank 所迷惑了。

/*------------ Functions used for all STM32F10x devices -----*/
//首先,前面 3 个函数,这些是和内核运行代码有关的,不用我们过多了解,也不需要我们调用,所以不用看。
void FLASH_SetLatency(uint32_t FLASH_Latency);
void FLASH_HalfCycleAccessCmd(uint32_t FLASH_HalfCycleAccess);
void FLASH_PrefetchBufferCmd(uint32_t FLASH_PrefetchBuffer);
void FLASH_Unlock(void);//显然,是用来解锁的。转到定义看一下,是不是在 KEYR 寄存器先写入 KEY1,再写入 KEY2 啊,这是解锁流程,对应 FLASH 解锁里的第二点。
void FLASH_Lock(void);//加锁。它就是把 CR 寄存器的 LOCK 位设置为 1,也就对应 FLASH 解锁里的加锁操作,非常的直观。
//下面就是对主闪存和选项字节进行擦除和编程的函数了
FLASH_Status FLASH_ErasePage(uint32_t Page_Address);//闪存擦除某一页,参数给一个页的起始地址,函数执行完后,指定的一页就被擦除了。返回值是这个操作的完成状态,可以看到,下面这些操作,都有返回值来告知状态,转到定义看一下,这是一个枚举,在执行完后,会返回执行状态,如果执行完全没问题,则返回这个 COMPLETE,表示完成,如果返回第一个 BUSY,表示芯片当前忙,返回第二个 ERROR_PG,表示编程错误,返回第三个 ERROR_WRP,表示写保护错误,返回第五个 TIMEOUT,表示等待超时,这就是函数返回状态的功能。如果你调用完成之后,想看看这个操作到底有没有落实到位,就可以接收一下这个返回值,当然如果你不想知道状态的话,也可以不用管,这就是页擦除。可以转到定义看看内部,首先,XL 的不看,我们看 else 里的,可以看到,第一步是等待忙,参数可以指定等待的超时时间,之后这三步,PER 置 1,AR 写入指定地址,STRT 置 1 开始,是不是就对应页擦除的这三步啊;之后,STRT 开始后,继续等待忙,所以库函数这里,是事前事后都有等待,最保险的策略;最后等待结束后,他还把 PER 给清零了,这是为了方便后续操作的执行,这就是页擦除的过程。 
FLASH_Status FLASH_EraseAllPages(void);//这个就是全擦除了。转到定义看一下操作,第一步,事前等待;第二步,MER 置 1,STRT 置 1,开始全擦除;第三步,事后等待;最后 MER 归位,防止影响后续操作。这是不是对应全擦除的流程啊,这就是全擦除的函数。
FLASH_Status FLASH_EraseOptionBytes(void);//这个是擦除选项字节。看一下,第一步,事前等待;第二步,解除选项字节的小锁;第三步,置 OPTER,然后 STRT 开始;第四步,事后等待。这是关键的步骤。下面还有一段,这个简单说一下,下面的这段程序的目的是维持读保护的原始状态,这个大概了解一下就行了。
//下面两个分别就是在指定地址写入字和写入半字了。
FLASH_Status FLASH_ProgramWord(uint32_t Address, uint32_t Data);//之后看写入全字呢,这里可以看到,它就是写两次,首先在 Address 下,写入 Data 的低 16 位;然后 Address 加 2,再写入 Data 的高 16 位,这个我们就清楚了。
FLASH_Status FLASH_ProgramHalfWord(uint32_t Address, uint16_t Data);//先看一下写入半字的,XL 的不看,这里关键的步骤:第一步,事前等待;第二步,PG 位置 1;第三步,Address 强转为指针,再指针取内容,赋值为 Data,也就是在指定地址 Address 下,写入指定数据 Data;第四步,事后等待,最后 PG 位恢复为 0。这一段是不是就对应编程的过程啊,其中这个写入半字的操作,就是上面这里的 *((__IO uint16_t *)(0x08000000)) = 0x1234; 使用指针写入,看,是不是就和库函数里是一样的。这是写入半字。
//下面四个,就是选项字节的写入了。选项字节的四个部分,自定义的 Data0 和 Data1,写保护,读保护,用户选项的 3 个配置位,就分别用这 4 个函数来写入,这个了解一下,函数内部感兴趣的话自己看看。
FLASH_Status FLASH_ProgramOptionByteData(uint32_t Address, uint8_t Data);
FLASH_Status FLASH_EnableWriteProtection(uint32_t FLASH_Pages);
FLASH_Status FLASH_ReadOutProtection(FunctionalState NewState);
FLASH_Status FLASH_UserOptionByteConfig(uint16_t OB_IWDG, uint16_t OB_STOP, uint16_t OB_STDBY);
//之后下面这三个读取的函数,就是获取选项字节当前的状态。
uint32_t FLASH_GetUserOptionByte(void);//获取用户选项的三个配置位,
uint32_t FLASH_GetWriteProtectionOptionByte(void);//获取写保护状态。
FlagStatus FLASH_GetReadOutProtectionStatus(void);//获取读保护状态。
//当然还有个获取自定义的 Data0 和 Data1。这个没给函数,使用指针访问就行了。
FlagStatus FLASH_GetPrefetchBufferStatus(void);//获取预取缓冲区状态,这个是对应上面 FLASH_PrefetchBufferCmd 这个函数的,不用了解。
void FLASH_ITConfig(uint32_t FLASH_IT, FunctionalState NewState);//中断使能
FlagStatus FLASH_GetFlagStatus(uint32_t FLASH_FLAG);//获取标志位
void FLASH_ClearFlag(uint32_t FLASH_FLAG);//清除标志位
FLASH_Status FLASH_GetStatus(void);//获取状态
FLASH_Status FLASH_WaitForLastOperation(uint32_t Timeout);//等待上一次操作。这个函数就是等待忙,等待 BSY 为 0,但是刚才我们看上面这些函数,在执行耗时操作时,这些函数内部就已经调用了等待忙的函数了,所以最后这个函数,并不需要我们单独调用。

/*------------ New function used for all STM32F10x devices -----*/
void FLASH_UnlockBank1(void);
void FLASH_LockBank1(void);
FLASH_Status FLASH_EraseAllBank1Pages(void);
FLASH_Status FLASH_GetBank1Status(void);
FLASH_Status FLASH_WaitForLastBank1Operation(uint32_t Timeout);

#ifdef STM32F10X_XL
/*---- New Functions used only with STM32F10x_XL density devices -----*/
void FLASH_UnlockBank2(void);
void FLASH_LockBank2(void);
FLASH_Status FLASH_EraseAllBank2Pages(void);
FLASH_Status FLASH_GetBank2Status(void);
FLASH_Status FLASH_WaitForLastBank2Operation(uint32_t Timeout);
FLASH_Status FLASH_BootConfig(uint16_t FLASH_BOOT);
#endif

好,这就是闪存的库函数介绍。接下来,我们就可以开始写代码了。

首先在 System 目录下快速新建一个模块,模块名叫 MyFLASH。模块建好,我们要实现最开始规划的 3 个功能,就是读取、擦除和编程。

FLASH 不需要初始化,所以我们直接开始写读取的部分,先来个读取 32 位的字。

uint32_t MyFLASH_ReadWord(uint32_t Address) {//返回值是读取的数据,肯定是 uint32_t 了。参数,需要指定一个地址,在 STM32 中,所有的地址都是 32 位宽度的,所以参数是 uint32_t Address。
	//在这里面,我们只需要写一句代码就可以了。
	return *((__IO uint32_t *)(Address));//使用指针访问存储器的格式。指定的地址,就用形参的 Address 来指定。然后,目前这个函数,我们想以 32 位,字的形式读取,所以指针,要强转为 uint32_t*。前面这个 __IO,转到定义,可以看到是 volatile,上面还有 __O,也是 volatile,__I,如果定义了 C++,也是 volatile,否则,是 volatile const,这些是为了严谨加的东西,并且也可以表现出来变量的读写特性,对吧,你看它下面的寄存器,也都是指定了 __IO,__O 或者 __I,当然在这里,我们加不加都行。那这样,指定地址的内容就以 32 位的形式访问到了,我们直接 return,就完事了。
}

之后,我们再复制两份。

如果我们想以半字 16 位的形式都出来呢,就来个 ReadHalfWord,形参的地址,必须还是 32 位的,访问时,强转类型,改成 16 位的指针,返回值,对应也改成 16 位,这就是读取半字。

最后一个,我们想以字节 8 位的形式读取,就是 ReadByte,强转为 8 位指针,返回 8 位数据,这就是读取字节。

/**
  * 函    数:FLASH读取一个16位的半字
  * 参    数:Address 要读取数据的半字地址
  * 返 回 值:指定地址下的数据
  */
uint16_t MyFLASH_ReadHalfWord(uint32_t Address)
{
	return *((__IO uint16_t *)(Address));	//使用指针访问指定地址下的数据并返回
}

/**
  * 函    数:FLASH读取一个8位的字节
  * 参    数:Address 要读取数据的字节地址
  * 返 回 值:指定地址下的数据
  */
uint8_t MyFLASH_ReadByte(uint32_t Address)
{
	return *((__IO uint8_t *)(Address));	//使用指针访问指定地址下的数据并返回
}

也挺简单吧,当然这个要求你对指针的操作非常熟悉,要不然不好理解。

解释一个可能会有疑惑的点,就是这个地址,必须都是 32 位的,这个 32 位,与指针的类型是 32 位、16 位还是 8 位无关,因为地址是门牌号,比如 FLASH 的起始地址 0800 0000,只有 32 位的变量,才足够存的下这么大的门牌号。

好,目前,读取的功能我们就实现了。接下来可以进行测试,我们把这 3 个函数,放到头文件声明,编译一下,没问题。

接下来,到主函数测试。

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

int main(void)
{
	/*模块初始化*/
	OLED_Init();				//OLED初始化
	
	OLED_ShowHexNum(1, 1, MyFLASH_ReadWord(0x08000000), 8);	//读取一下闪存的起始地址
	OLED_ShowHexNum(2, 1, MyFLASH_ReadHalfWord(0x08000000), 4);	//读取一下闪存的起始地址
	OLED_ShowHexNum(3, 1, MyFLASH_ReadByte(0x08000000), 2);	//读取一下闪存的起始地址
	//我们都从 0800 0000 开始读,看一下,以不同形式读出来,有什么区别。
	
	while (1)
	{
	}
}

那测试程序就是这样。目前这个测试程序,也非常有意思,nabianyi就是用程序把程序代码本身给读出来,我读我自己。那编译,下载,

这个注意一下,使用完 STLINK 这个软件,要及时点第四个按钮给断开连接,要不然设备占用了,Keil 下载就会出错,断开之后,再下载。

看一下,可以看到,OLED 第一行,读取字,显示 2000 0660;第二行,读取半字,显示 0660;第三行,读取字节,显示 60。这个数据对不对呢,我们就可以用这个软件来验证了,先连接,起始地址,从 0800 0000 开始,大小,0x10000,查看 64K 的全部内容,数据宽度,先看一下 32 位的,可以看到,这个软件清晰的显示了 0800 0000 起始地址下,第一个 32 位的字,数据是 2000 0660,是不是和 OLED 显示的一样啊。这说明字的读取没问题。然后数据宽度设置 16 位,这样就是半字读取的效果,可以看到第一个,0800 0000 起始的第一个半字是 0660,与 OLED 第二行一致。再改成 8 位字节的形式呢,第一个是 60,与 OLED 第三行一致。同时我们发现,这个数据是以小端模式存储的,也就是低位字节存储在低位地址,如果以字节读取,第一个就是 60;以半字读取,前两个字节组合到一起,由高位到低位,是 06 60;以字读取,前四个字节组合到一起,由高位到低位,是 20 00 06 60,这个是反着来的,就是小端存储。

那回到代码,我们换个地址,再测试一下。比如 0800 0010,试一下看看,当然软件里先断开连接,然后下载。可以看到 0800 0010 起始的数据,以字的方式读取,是 0800 0351,按照字节存储的顺序,就是反过来,51 03 00 08,对吧。到这个软件验证一下,连接,可以看到 0800 0010 起始的数据,按照字节的顺序是 51 03 00 08,没问题;以 16 位读取,就是 0351 0800;以 32 位读取,就是 0800 0351,这个可以理解吧。这就是读取的功能,我们就验证通过了。

接下来我们回到代码,继续来写下一个功能,擦除。

  • 首先我们先实现一个全擦除的功能。所以来个:
void MyFLASH_EraseAllPages(void) {//参数,返回值都不需要
//在这里面,我们要执行全擦除的过程。
//1. 我们要对 FLASH 进行解锁。
	FLASH_Unlock();					//解锁
//2. 我们就直接调用库里的全擦除函数。
	FLASH_EraseAllPages();			//全擦除
	//这个函数执行完后,FLASH 全擦除就完成了。
//3.执行完后,我们最好再给 FLASH 锁上。
	FLASH_Lock();					//加锁
}

这样就完成了,还是非常简单的吧。得益于库函数的封装,实施的具体流程,比如置控制位,置 STRT,等待忙,等等,这些具体操作,在这一个函数里,已经写好了,当然这个函数里并不包含解锁和加锁。所以解锁和加锁,需要我们另外调用函数实现,然后这个擦除函数也有个返回值,表示执行的状态,如果你要严谨一些,就可以判断一下返回值,如果执行出错了,可以进行一个提醒,那这里,我们就先不管返回值了,这就是全擦除的流程。

  • 然后我们再实现一下页擦除。来个:
void MyFLASH_ErasePage(uint32_t PageAddress) {//参数需要给定指定页的地址。
//在这里面,类似的
//1. 执行解锁
	FLASH_Unlock();					//解锁
//2. 我们调用库里的页擦除函数
	FLASH_ErasePage(PageAddress);	//页擦除,参数是 Page_Address,页地址
//3. 执行加锁
	FLASH_Lock();					//加锁
}

这样,页擦除函数就完成了。

接下来,我们进行测试,把这两个函数,放到头文件声明一下。编译看看没问题,然后到主函数,执行测试。

首先我们把按键的功能拿过来,按下按键,再进行擦除。

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyFLASH.h"
#include "Key.h"

uint8_t KeyNum;					//定义用于接收按键键码的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();				//OLED初始化
	Key_Init();					//按键初始化
	
	OLED_ShowHexNum(1, 1, MyFLASH_ReadWord(0x08000000), 8);	//读取一下闪存的起始地址
	OLED_ShowHexNum(2, 1, MyFLASH_ReadHalfWord(0x08000000), 4);	//读取一下闪存的起始地址
	OLED_ShowHexNum(3, 1, MyFLASH_ReadByte(0x08000000), 2);	//读取一下闪存的起始地址
	//我们都从 0800 0000 开始读,看一下,以不同形式读出来,有什么区别。
	
	while (1)
	{
		KeyNum = Key_GetNum();		//获取按键键码
		
		if (KeyNum == 1)			//按键1按下,那我们就执行把整个闪存都擦除
		{
			MyFLASH_EraseAllPages();
		}
		
		if (KeyNum == 2)			//按键2按下,那我们就执行页擦除
		{
			MyFLASH_ErasePage(0x08000000);//指定页地址,给个 0x0800 0000。也就是把闪存的第一页,前 1K,1024 个字节擦掉。
		}
		
	}
}

好,程序就是这样。测试一下看看,下载看一下,目前显示的是读取测试的数字,按一下复位键,可以看到,每次复位,OLED 都刷新了一次,目前没什么问题;接下来,我们试一下,按下 Key1,我已经按下了,好像没有任何反应,但是,我们再按复位键,可以看到 OLED 没有刷新了,实际上,刚才按下 Key1 的时候,整个闪存都已经擦除了,程序文件已经不复存在了,当前 OLED 显示的数值并没有消失,是因为 OLED 里面有显存,可以保存最后一次显示的内容,那我们断电,重新上电,可以看到 OLED 没有任何显示,无论按什么按键,都没有反应,这就是闪存全擦除的现象。执行了全擦除,就相当于程序做了个自我了结,现在整个芯片是空白的,没有任何程序,我们用软件验证一下看看,连接,可以看到,闪存里面全都是 FF,是空白的,这就是全擦除的现象。全擦除,一般不要随便调用,要不然整个程序都没有了,还得重新下载程序,但是如果你想设计一个安全保障措施,在设备被拆时,可以触发全擦除,这样程序肯定就不会被盗了。像读保护,是把程序放在保险箱里;全擦除,那就直接把程序销毁了,销毁肯定比放保险箱还要安全,对吧,不过这个,要根据你的使用场景来选择,毕竟全擦之后,程序是没法恢复的。

之后我们继续测试,试一下页擦除。先重新下载程序,然后我们按下 Key2 键,擦除第一页,擦完了,复位,可以看到 OLED 也不刷新,因为程序的第一页没了,现在程序是损坏的,没法运行,那用软件验证一下呢,连接,可以看到前面都是 FF,然后往后翻,可以看到,到第二页的时候,数据还是存在的,并且这个起始地址,正好是第二页的起始地址 0800 0400,页地址的分布规律,我们上一小节研究过,地址以 000、400、800、C00 结尾的,都是页起始地址,FLASH 基本结构里也看到,0800 0400 就是页 1,也就是第二页的起始地址,所以程序里执行页擦除的函数,它只会把第一页擦除,其他页不受影响,然后继续测试,我们如果想让它擦第二页呢,就把这个地址改成 0400,这是擦除第二页,试一下,下载,按下 Key2,之后到软件看看,可以看到,第一页没有被擦,往下翻,0400 开始的第二页,被擦除了,之后继续,0800 开始的第三页,没有被擦,这就是页擦除的现象,我们就玩明白了。

那回到程序,我们继续,完成下一个任务,编程,先来个函数。

void MyFLASH_ProgramWord(uint32_t Address, uint32_t Data) {//写入一个字。参数要有两个,第一个指定,我们要在哪里写,是 32 位的地址;第二个指定,我们要写什么,是 32 位的数据。
//在这里面,还是类似的过程
//1. 解锁
	FLASH_Unlock();							//解锁
//2. 我们调用库里的编程字函数
	FLASH_ProgramWord(Address, Data);		//编程字。参数一样,我们分别把地址和数据传进去。
//3. 再加锁,就完成了
	FLASH_Lock();							//加锁
}

其实可以看出,这些函数我们就是再包装一下,并没有太多的操作,因为库函数已经集成的很完善了。

那继续,我们再来个编程半字。

void MyFLASH_ProgramHalfWord(uint32_t Address, uint16_t Data) {//参数第一个,指定 32 位的 Address;参数第二个,因为只需要写入半字,所以指定 16 位的 Data 即可。
//在这里面
//1. 解锁
	FLASH_Unlock();							//解锁
//2. 我们调用库里的编程半字函数
	FLASH_ProgramHalfWord(Address, Data);	//编程半字。参数把 Address 和 Data 传进去
//3. 加锁
	FLASH_Lock();							//加锁
}

这样就完事了。当然对于编程来说,我们就只能写到这了,只有写入字和写入半字的函数,暂时就没有写入字节的了,因为写入字节比较麻烦,最好用缓存区的方式来实现,所以函数就没有写入字节的。

好,那我们来测试一下,把这两个函数放在头文件声明。编译一下,接下来执行测试,那我们在写入的时候,尽量就不要再破坏程序本身了。所以我们可以在闪存的最后一页写入测试数据,这样不会影响程序。那看一下 FLASH 基本结构,对于 64K 的闪存,最后一页的起始地址,就是 0800 FC00,所以我们就在 FC00 的位置写一点测试数据看看,在这里测试。

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyFLASH.h"
#include "Key.h"

uint8_t KeyNum;					//定义用于接收按键键码的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();				//OLED初始化
	Key_Init();					//按键初始化
	
	OLED_ShowHexNum(1, 1, MyFLASH_ReadWord(0x08000000), 8);	//读取一下闪存的起始地址
	OLED_ShowHexNum(2, 1, MyFLASH_ReadHalfWord(0x08000000), 4);	//读取一下闪存的起始地址
	OLED_ShowHexNum(3, 1, MyFLASH_ReadByte(0x08000000), 2);	//读取一下闪存的起始地址
	//我们都从 0800 0000 开始读,看一下,以不同形式读出来,有什么区别。
	
	//写入之前,一定要先执行擦除,所以我们先调用页擦除,页地址,指定最后一页 0x0800 FC00
	MyFLASH_ErasePage(0x0800FC00);//页擦除
	//之后,就可以写入了,比如
	MyFLASH_ProgramWord(0x0800FC00, 0x12345678);//写入 32 位的字,在最后一页的起始地址 FC00 下,随便写一个 32 位数据,比如 0x12345678;
	//之后再来个
	MyFLASH_ProgramHalfWord(0x0800FC10, 0xABCD);//写入 16 位的半字,地址往后加点,在 0x0800FC10 的位置,随便写一个 16 位数据,比如 0xABCD。
	
	while (1)
	{
		KeyNum = Key_GetNum();		//获取按键键码
		
		if (KeyNum == 1)			//按键1按下,那我们就执行把整个闪存都擦除
		{
			MyFLASH_EraseAllPages();
		}
		
		if (KeyNum == 2)			//按键2按下,那我们就执行页擦除
		{
			MyFLASH_ErasePage(0x08000000);//指定页地址,给个 0x0800 0000。也就是把闪存的第一页,前 1K,1024 个字节擦掉。
		}
		
	}
}

这样测试程序就写好了。我们先擦除最后一页,然后在 FC00 写入字,在 FC10 写入半字。那试试看,下载看一下,打开软件,连接,往后划,找到 0800 FC00 的位置,可以看到,FC00 的位置,写入了 1234 5678,FC10 的位置,写入了 ABCD,当然现在是 32 位一个格子,所以高位有 4 个 F。然后换个位置试试看,比如 FC20 写入字,FC30 写入半字,再试试,连接,找一下,可以看到 FC20 和 FC30 的写入,没问题,但是也可以看到,之前 FC00 和 FC10 的数据没有了,这是因为,我们在写入新数据之前,又执行了页擦除,所以原来的数据,就丢失了,那这就是写入数据的测试。

好,至此,FLASH 的底层代码,读取,擦除,编程,我们就写完了,接下来,我们就来完成更上层的业务代码。现在,我想实现的功能是参数掉电不丢失的存储,那基于这个 MyFLASH 层,我们就可以再来建立一个 Store 模块。

所以在 System 右键,快速新建一个 Store 模块,那在 Store 模块,我们要用 SRAM 缓存数组来管理 FLASH 的最后一页,实现参数的任意读写和保存,因为闪存每次都是擦除,再写入,擦除之后,还容易丢失数据,所以要想灵活管理数据,还是得靠 SRAM 数组,需要备份的时候,我们再统一转到闪存里,这才是一个比较好的方案。

  • 所以在 Store 模块里,我们先定义一个 SRAM 数组。

数组类型,可以定义为 8 位,但是为了方便,我们还是跟闪存的半字统一一下,所以来个:

#define STORE_COUNT				512				//存储数据的个数
uint16_t Store_Data[STORE_COUNT];				//定义SRAM数组

512 个数据,每个数据 16 位,2 字节,正好对应闪存的一页 1024 字节。

然后这个代码继承于 MyFLASH,所以别忘了 #include "MyFLASH.h"

  • 之后,我们来个初始化函数。

首先,我们要把闪存初始化一下。比如你第一个使用这个代码,那闪存默认全是 FF,而参数和 SRAM 一般都默认 0。所以对于第一次使用,我们要给闪存的各个参数都置 0,怎么判断是不是第一次使用的,我们就定义一个标志位,把闪存最后一页的第一个半字,当作标志位,判断。

#define STORE_START_ADDRESS		0x0800FC00		//存储的起始地址

void Store_Init(void) {
	/*判断是不是第一次使用*/
	if (MyFLASH_ReadHalfWord(STORE_START_ADDRESS) != 0xA5A5)	//读取第一个半字的标志位。这个 A5A5 是自己随便规定的一个标志位。如果第一个半字不是 A5A5,就说明是第一次使用,if成立,则执行第一次使用的初始化
	{
		//1. 先把最后一页给擦除
		MyFLASH_ErasePage(STORE_START_ADDRESS);					//擦除指定页
		//2. 在这个起始地址,也就是第一个半字的位置。写入规定的标志位 0xA5A5
		MyFLASH_ProgramHalfWord(STORE_START_ADDRESS, 0xA5A5);	//在第一个半字写入自己规定的标志位,用于判断是不是第一次使用
		//3. 把剩余的存储空间全都置为默认值 0,来个 for 循环。注意这里 i 要从 1 开始,而不是 0,因为第一个半字是标志位,剩下的才是有效数据。
		for (uint16_t i = 1; i < STORE_COUNT; i ++)				//循环STORE_COUNT次,除了第一个标志位
		{
			MyFLASH_ProgramHalfWord(STORE_START_ADDRESS + i * 2, 0x0000);		//除了标志位的有效数据全部清0
			//剩下的每一个半字的地址是什么呢,首先要从起始地址开始,然后每个循环,加上 i,因为一个半字占用两个地址,所以 i 要乘 2,这就是后续每个半字的起始地址,写入的半字,我们给 0x0000。
		}
	}
	
	/*上电时,将闪存数据加载回SRAM数组,实现SRAM数组的掉电不丢失*/
	for (uint16_t i = 0; i < STORE_COUNT; i ++)					//循环STORE_COUNT次,包括第一个标志位
	{
		Store_Data[i] = MyFLASH_ReadHalfWord(STORE_START_ADDRESS + i * 2);		//将闪存的数据加载回SRAM数组
	}
}

这就是初始化闪存过程。没初始化的时候,闪存最后一页应该全是 FF,初始化之后,闪存最后一页,闪存最后一页的第一个半字是标志位 A5A5,剩下的数据全是 0,那这就是 Store_Init 的第一大步,第一次使用的时候,对闪存进行初始化。

接着还有第二大步,就是上电的时候,把闪存的数据,全都转存到 SRAM 数组里,转到数组里这个过程,就是上电的时候恢复数据,实现数据掉电不丢失。所以这里同样来个 for 循环,这个 for 循环,i 要从 0 开始,标志位也要转到数组里,要不然后续备份数据的时候,标志位就丢失了。那在这里面,我们调用 MyFLASH_ReadHalfWord,地址依次是,这个起始地址 + i * 2,读出来之后,我们就依次放到 Store_Data 的第 i 个位置,这个第二部分,就是在上电的时候,把闪存备份的数据,恢复到 SRAM 数组里。

之后,我们想存储掉电不丢失的参数的时候,就先任意更改这个数组,除了标志位的其他数据,更改好之后,我们把这个数组,整体备份到闪存的最后一页。所以下面,我们再来个函数:

void Store_Save(void) {//备份保存
//在这里面,两个步骤
	//1. 擦除最后一页
	MyFLASH_ErasePage(STORE_START_ADDRESS);				//擦除指定页
	//2. 把数组完全备份保存到闪存。同样来个 for 循环,当然这里,存储方向就变了,每一个数据的地址,分别是起始地址 + i * 2,存储的内容,是每个数组的数据。
	for (uint16_t i = 0; i < STORE_COUNT; i ++)			//循环STORE_COUNT次,包括第一个标志位
	{
		MyFLASH_ProgramHalfWord(STORE_START_ADDRESS + i * 2, Store_Data[i]);	//将SRAM数组的数据备份保存到闪存
	}
}

这样就是把 SRAM 的所有内容备份到闪存的最后一页。到这里,我们这个模块实现的就差不多了。

给大家再梳理一下,比如,闪存的最后一页,直接对它进行读写的话,那肯定不方便,效率低,还容易丢失数据,所以我们在 SRAM 中,定义一个数组,它就是闪存的 “分身”,我们再读写任何数据,就直接对 SRAM 操作,这样就非常方便了,但是 SRAM 掉电丢失,所以我们需要闪存来配合。SRAM 每次更改的时候,都把数组整体备份到闪存里,而在上电的时候,我们再把闪存里的数据,初始化加载,回到 SRAM 里,这样 SRAM 数组,是不是就相当于掉电不丢失了。

另外为了判断这个闪存是不是之前保存过数据,我们还需要一个标志位来配合,如果标志位是 A5A5,就说明闪存已经保存过了。我们上电就直接加载回备份的数据,如果标志位不是 A5A5,就说明闪存是第一次使用,我们就先初始化一下,再加载数据,这就是这个参数保存,整体的思路。

当然这个思路并不是固定的,如果你有别的,可以说得通的思路,那都可以实践试一下。

最后,这个模块我们再加一个 Clear 函数。因为这个数组实现了掉电不丢失,正常情况下不太方便把所有数据清零,所以为了方便使用,我们来个:

void Store_Clear(void) {
//在这里,来个 for 循环,注意 i 要从 1 开始,不要把标志位清零了。之后,我们把数组的每一个用户数据,都归零。
	for (uint16_t i = 1; i < STORE_COUNT; i ++)			//循环STORE_COUNT次,除了第一个标志位
	{
		Store_Data[i] = 0x0000;							//SRAM数组有效数据清0
	}
//归零之后,一定要记住,调用 Save,把这个更改更新到闪存里。
	Store_Save();										//保存数据到闪存
}

我们每次修改完数组后,都要 Save 一下,保证数组和闪存数据一样,如果你不 Save,那下次上电,加载的就是以前保存的数据了。

好,到这里,我们这个模块就写完了。接下来进行测试,先在头文件声明一下函数,另外,这个数组,我们也直接声明为外部可调用,加个 extern,数组数量可以不写。编译一下没问题,然后到主函数,进行测试,现在,我们有了更上层的 Store 模块,就不再需要直接操作闪存了,所以我们可以直接把 #include "MyFLASH.h",改成 #include "Store.h",下面,有关闪存的操作,全都删掉。

之后,上电后先来个:

Store_Init();				//参数存储模块初始化,在上电的时候将闪存的数据加载回Store_Data,实现掉电不丢失

这个函数的作用是,第一个使用的时候,初始化闪存,然后把闪存备份的数据,加载回 SRAM 数组,实现 SRAM 数组的掉电不丢失。

之后,比如你有一些按键配置的参数,在按键按下后,就可以存储了,存放的位置,先放到 Store_Data 里,数组第 0 个是标志位,千万不要使用。我们放到数组第 1 个里,比如放个 0x1234;然后再来个,数组第 2 个,放个 0xABCD,这样想怎么放参数都可以。

Store_Data[1] = 0x1234;
Store_Data[2] = 0xABCD;

放完之后,一定要记着,来个:

Store_Save();			//将Store_Data的数据备份保存到闪存,实现掉电不丢失

将 SRAM 数组备份到闪存,如果现在断电,那数组内容丢失,但是闪存已经保存好了,在下次上电的时候,Store_Init,就会读回掉电前保存的数据。那现在,我们测试一下看看,下载,看一下,当然,现在 OLED 还没有显示,我们先看一下闪存里的内容,是不是符合预期,翻到软件最后一页,可以看到,第一个数据是 A5A5 标志位,其他数据,都初始化为 0 了,那断开,现在,按一下 Key1 按键,备份了两个数据,看一下闪存里有没有,连接,翻一下可以看到,第一个半字 A5A5,第二个半字 1234,第三个半字 ABCD,完全符合预期,测试没问题了。

接下来,我们就可以实现最终程序的现象了。

首先,OLED 显示两个字符串。之后,如果 Key1 按下,我们就保存参数数据,这个数据可以根据你的实际需求来,这里就用自增来随便变换一下了,比如 Data[1]++,Data[2]+= 2,等等,随便变换一下,最后变换好之后,Save 保存,这样就行了;之后如果 Key2 按下,我们就把所有参数清零一下,直接调用 Store_Clear 就行了。好,最后,我们用 OLED 显示一下所有数据。

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Store.h"
#include "Key.h"

uint8_t KeyNum;					//定义用于接收按键键码的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();				//OLED初始化
	Key_Init();					//按键初始化
	Store_Init();				//参数存储模块初始化,在上电的时候将闪存的数据加载回Store_Data,实现掉电不丢失
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "Flag:");
	OLED_ShowString(2, 1, "Data:");
	
	while (1)
	{
		KeyNum = Key_GetNum();		//获取按键键码
		
		if (KeyNum == 1)			//按键1按下
		{
			Store_Data[1] ++;		//变换测试数据
			Store_Data[2] += 2;
			Store_Data[3] += 3;
			Store_Data[4] += 4;
			Store_Save();			//将Store_Data的数据备份保存到闪存,实现掉电不丢失
		}
		
		if (KeyNum == 2)			//按键2按下
		{
			Store_Clear();			//将Store_Data的数据全部清0
		}
		
		OLED_ShowHexNum(1, 6, Store_Data[0], 4);	//这个是显示Store_Data的第一位标志位
		OLED_ShowHexNum(3, 1, Store_Data[1], 4);	//显示Store_Data的有效存储数据
		OLED_ShowHexNum(3, 6, Store_Data[2], 4);
		OLED_ShowHexNum(4, 1, Store_Data[3], 4);
		OLED_ShowHexNum(4, 6, Store_Data[4], 4);
	}
}

那这样,最终的测试程序就写好了。下载试试看,看一下,OLED 第一个显示标志位,这个一直都是 A5A5,不要去改它,剩下的,现在已经显示了 1234 和 ABCD,这是之前保存的。按下 Key2,可以清零所有参数;按下 Key1 ,可以变换测试数据。之后,我们断电,再重新上电,数据仍然保持原样,按复位键,数据也不会丢失,这就是最终程序的现象。

那回到程序这里,这边还有几个问题可以说说。首先,程序可以提取一下参数,比如 Store.c 源程序中页起始地址出现了非常多次,如果你想换一页存储,那就得改很多地方,所以对于这种出现很多次又可能修改的数据,那最好就用宏定义替换一下。比如在头部,我们定义 #define STORE_START_ADDRESS 0x0800FC00,之后的所有这个数据,就可以用这个宏代替了,这样再改参数的话,就方便了。然后 SRAM 数组 uint16_t Store_Data[512];中这个数量,也可以修改,这里最大是 512,如果你没那么多参数,也可以改小的,十几二十个,都可以,这样程序效率会高一些,所以我们把这个数据也提取成宏定义,#define STORE_COUNT 512,下面所有的 512,用这个宏来替换,这就是这个代码优化的部分。宏定义大家也要学会使用,一般大量出现的可配置参数,或者为了强调某个数据的特殊意义,就可以用宏定义替换一下,这是这一点。

然后下一个问题,就是目前的闪存。前面一部分,存储的是程序文件,最后一页,存储的是用户数据,目前我们的假设,是程序文件比较小,最后一页肯定是没有用到的,所以我们放心地使用最后一页。但是,如果程序比较大,触及到了最后一页,那程序和用户数据存储的位置就冲突了,或者说你的参数非常多,最后 10 页,很大一部分都是留着存储用户数据的,这样,如果前面的程序文件长一些,那就非常容易和用户数据冲突,并且这种冲突,如果我们没发现,就会产生非常隐蔽的 bug。那如何解决这个问题呢?这时我们可以给程序文件,限定一个存储范围,不让它分配到后面我们用户数据的空间来。我们打开工程选项,在 Device Target 下面,就是编译器给各个数据分配的空间地址和范围了。比如,片上的 ROM,起始地址是 0800 0000,注意这个最高位的 0 省略了,别看成 8000 开头的了,这是 0800 开头,然后,它的 Size,是 0x10000,默认全部的 64K 闪存都是程序代码分配的空间。如果你想把闪存的尾部空间留着自己用,那就可以把这个程序空间的 Size 改小点就,比如,我们改成 0xFC00,这样,编译后的代码,无论如何,也不会分配到最后一页了;如果 Size 过小,那编译的时候也会报错,所以如果你计划把闪存尾部的很多空间都留着自己用,那就把这个程序代码的空间改小点,以免冲突。然后这个下载程序的起始地址,也可以改,比如你想写一个 BootLoader 程序放在闪存尾部,那就可以在 IROM1 这里修改下载到闪存的起始位置。之后右边 IRAM1 这里是片上 RAM 的起始地址和大小,2000 开始,大小 5000,对应就是 20K,这就是这个问题。

下一个问题是 Debug->Settings->Flash Download 这里,在这里是配置下载选项,其中 Download Function 这个选项,我们要选择第二个 Erase Sectiors,擦除扇区,也就是页擦除。第一个是每次下载代码,都全擦除,再下载。第二个是用到多少页,就擦多少页,这个下载速度更快一些,如果你想在闪存尾部存储数据,那也最好选择页擦除的下载,要不然每次下载程序,芯片都全擦除了,这是这个问题。

然后再下一个问题是,我们想知道目前这个程序编译之后,到底占用了多大的空间,这个怎么看呢?我们可以全部编译一下,在 Build Output 下面有一行信息,就显示了 Program Size,程序大小,其中有 4 个数,这 4 个数分别是什么意思,这里就不细说了,感兴趣的话,可以网上搜搜。这里,大家只需要记住,前三个数相加,得到的就是程序占用闪存的大小,后两个数相加,得到的是占用 SRAM 的大小,这个程序大小,我们也可以在 Project 的 Target1 这里,双击,会打开一个 .map 文件,这就是详细的编译信息,感兴趣的话,可以研究一下,我们就看最后面,这里,也有写程序的大小,并且有计算结果,倒数第二行,是占用 SRAM 的大小,这里结果是 2664 字节,2.6 kB,最后一行,是占用闪存的大小,这里是 4576 字节,4.47 kB,那对不对呢?我们可以验证一下,目前闪存占用是 4576 字节,记一下,然后这个 map 文件看完就关掉,要不然每次编译后,都会弹出个窗口问你是不是要重新加载,非常烦人。那我们下载到 STM32 里,之后用软件验证一下,这是程序文件,一直往后翻,可以看到,程序的终止位置,这个地方的地址是 11E0,我们用计算器转换一下,十六进制,11E0,对应十进制,就是我们刚才看的 4576,我们观察的数据,和编译结果相符,没问题。

好到这里,我们这个读写内部 FLASH 的程序和几点注意事项,就全部介绍完了。

接下来,我们来写第二个程序。

3.2 读写芯片 ID

3.2.1 硬件电路

在这里插入图片描述
这个也是,接上一个 OLED 能显示测试数据,就可以了。

3.2.2 代码整体框架

第二个程序非常简单。

在器件电子签名里,我们知道这些数据的存储位置,那就非常简单了。直接用指针读指定地址下的数据,就可以获取 ID 号了。看一下手册,也画出了各个存储器,我们先读一下 F_SIZE,闪存存储器容量,所以在程序这里,先显示一下字符串。

OLED_ShowString(1, 1, "F_SIZE:");

然后直接来个:

OLED_ShowHexNum(1, 8, *((__IO uint16_t *)(0x1FFFF7E0)), 4);		//使用指针读取指定地址下的闪存容量寄存器

这里要给指定数据(F_SIZE)的起始地址。然后,以多少位的形式读取呢?我们看到,这个存储器就是 16 位的,所以我们以 16 位的形式读出,最后显示长度为 4,这样就行了。

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

int main(void)
{
	OLED_Init();						//OLED初始化
	
	OLED_ShowString(1, 1, "F_SIZE:");	//显示静态字符串
	OLED_ShowHexNum(1, 8, *((__IO uint16_t *)(0x1FFFF7E0)), 4);		//使用指针读取指定地址下的闪存容量寄存器
	
	while (1)
	{
		
	}
}

我们测试看一下。下载,可以看到,目前,显示闪存容量为 0040,也就是 64K,有的芯片容量可能会更大些,这个数可能会更大,也都是正常的。

那回到程序,继续来写,接下来,我们来显示这个 U_ID。在程序里,先显示字符串,在 2 行 1 列,显示 U_ID;之后,显示十六进制,读取的地址,我们使用手册里的基地址,然后读取的形式,手册里写了,可以以字节,半字或者全字读出,想怎么读,都可以,那手册里寄存器画的是 16 位,所以就先 16 位读,这是第一个 16 位,显示在 2 行 6 列;然后继续,在 2 行 11 列,看一下,第二个 16 位,是基地址,加上 0x02 的地址偏移,这个也可以想到的,所以第二次读,地址是基地址 + 0x02,这里注意一下,这个地址有加减,就一定得加括号,在括号里进行了,否则,运算优先级会出问题,这就是第二个 16 位;之后继续,在 3 行 1 列,下一个存储器,地址偏移是 0x04,并且,手册里是 32 位画在一起了,所以我们就也按 32 位读取吧,当然实际上,并不一定要 32 位读,8 位、16 位也都可以,这里就多演示几种方式,作为示例,那这里,地址偏移是 0x04,我们以 32 位的方式读出,显示长度是 8;最后再复制一下,在 4 行 1 列,看一下,最后 32 位 ID 号,地址偏移是 0x08,也以 32 位的方式读出吧,所以这里,地址偏移给 0x08,以 32 位形式读出。

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

int main(void)
{
	OLED_Init();						//OLED初始化
	
	OLED_ShowString(1, 1, "F_SIZE:");	//显示静态字符串
	OLED_ShowHexNum(1, 8, *((__IO uint16_t *)(0x1FFFF7E0)), 4);		//使用指针读取指定地址下的闪存容量寄存器
	
	OLED_ShowString(2, 1, "U_ID:");		//显示静态字符串
	OLED_ShowHexNum(2, 6, *((__IO uint16_t *)(0x1FFFF7E8)), 4);		//使用指针读取指定地址下的产品唯一身份标识寄存器
	OLED_ShowHexNum(2, 11, *((__IO uint16_t *)(0x1FFFF7E8 + 0x02)), 4);
	OLED_ShowHexNum(3, 1, *((__IO uint32_t *)(0x1FFFF7E8 + 0x04)), 8);
	OLED_ShowHexNum(4, 1, *((__IO uint32_t *)(0x1FFFF7E8 + 0x08)), 8);
	
	while (1)
	{
		
	}
}

那这样,读取 F_SIZE 和 U_ID 的程序,实际上就写完了,就是指针读内容的一个实践。编译下载试一下,可以看到,下面显示的 U_ID 是这些数。那 ID 号到底对不对呢?我们还可以用软件来验证,连接,查看的起始地址,我们直接给 U_ID 的起始地址,这里复制一下 0x1FFFF7E8,放到 Address 里,Enter,可以看到,这些就是 U_ID 了,注意要以小端模式来看。第一个,16 位,FF32,对照一下,没问题;第二个,16 位,066E,没问题;第三个,32 位,3435 5234,没问题;第四个,32 位,57133559,没问题,这说明我们的 U_ID 读取,是没问题的。然后可以看到,这个 ID 号后面,还有一些数据,目前手册里,暂时没找到它们的描述,可能是有别的作用,这个我们就不用管了。

好,那我们第二个程序,到这里也就写完了。本节到这里也就全部结束了

  • 24
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值