☰
STM32 HAL库内部Flash操作与固件大小读取方法详解
2026/10/16 7:38:05 网站建设 项目流程

1. 内容整体设计与思路拆解

1.1 HAL库学习到这个阶段,为什么要碰内部Flash

做单片机开发的人,不管是玩STM32还是其他ARM Cortex-M内核的芯片,迟早都要面对一个问题:程序运行时的数据保存在哪里。RAM一掉电就没了,外挂EEPROM或者Flash虽然能存,但要么占IO口,要么增加成本。其实芯片自带的内部Flash就是一个现成的非易失存储区,读速度够快,写入寿命虽然有限但大多数应用场景绰绰有余。

很多人在学HAL库的时候,前面玩过GPIO、串口、定时器、中断,觉得差不多了,一听到内部Flash就觉得是不是很难。实际上内部Flash的操作远没有想象中复杂,就三个动作:解锁、擦除、写入,读反倒是最简单的——直接当普通内存访问就行。HAL库把这些底层寄存器操作都封装好了,我们只需要搞清楚几个函数的参数含义,再注意一下Flash的扇区/页划分,就能把它用起来。

标题里提到了“反应单片机烧录进去的程序大小”,这其实就是内部Flash最典型的一个应用场景:通过读取芯片Flash中某个地址的内容,反推当前固件实际占用了多少空间。特别是我们做BootLoader或者OTA升级的时候,经常需要知道App固件到底写到哪了,才能决定下一帧数据往哪个地址放。这篇内容的核心就是两部分:第一部分是HAL库操作内部Flash的完整流程,第二部分是拿到bin文件、分析固件大小的实用方法。

1.2 为什么选“读取Flash内容”来反推程序大小

先想一个问题:Keil或者IAR编译完之后,生成的hex文件里带地址信息,我们用烧录器下载到芯片里,程序从0x08000000开始逐字节写入。如果我想知道自己写的代码到底占了多少字节,最直接的办法就是看编译输出窗口里显示的Code、RO-data、RW-data、ZI-data总和,但那个值是编译器的静态估算,实际烧进去的bin大小会因为对齐、填充等原因略有出入。而且很多时候我们手里只有一个烧录好的板子,没有工程文件,这时候就只能从芯片内部去“量”程序的长度。

这时候内部Flash就派上用场了。程序烧录进去之后,Flash里从起始地址开始,连续存放的就是固件的机器码和数据。只要我们从头开始读,一直往后读,找到“空数据”的位置——也就是Flash擦除状态的值0xFF,那第一个出现整段0xFF的地址基本就是固件的边界。当然这里有个细节:程序末尾可能还有一小段未使用的对齐填充,但误差通常只有几个字节到几十个字节,对于估算固件大小来说足够用了。

标题里说的“教得到bin文件的方法”,其实指的是两种东西:一是从Keil或者STM32CubeIDE里直接导出bin文件,二是在没有工程的情况下,直接从芯片内部Flash读出来新鲜的bin内容。这两种方法各有适用场景,我在后面会演示一条完整的实操路径:先用HAL库写一个简单的Flash读取程序,把固件区域前几百字节读出来通过串口发到电脑上,再对比bin文件,验证一下哪一段是自己真正的程序代码。

1.3 内部Flash操作的基本心智模型

很多初学者一看到“Flash操作需要解锁”“擦除前必须先读出来保存”就发怵。其实你可以把内部Flash想象成一块写字板:字写上去之后,如果想改动某个字,不能用橡皮擦擦掉重写,必须把整页纸撕掉换成新纸,然后再重新把没改的内容抄一遍。Flash的物理特性决定了它只能把1写成0,不能把0写成1,所以写之前必须先擦除,而擦除的最小单位是扇区(或页),不是字节。这就是为什么你明明只想改一个字节,却要把整个扇区都先读出来备份,擦掉,再把备份数据和修改数据一起写回去。

HAL库对这一整套流程做了封装,核心就是几个函数:

  • HAL_FLASH_Unlock():解锁Flash控制器,禁止意外写入。
  • HAL_FLASH_Program():指定地址和数据类型,写入数据。
  • HAL_FLASH_Erase():初始化擦除结构体,按扇区擦除。
  • HAL_FLASH_Lock():重新锁定Flash,防止程序跑飞误改Flash内容。

还有一个隐藏的细节,STM32的Flash读写是有等待状态要求的。当CPU主频比较高的时候,读Flash需要插入等待周期,否则会读到乱码。HAL库在SystemClock_Config()里会通过FLASH_SetLatency()自动设置,一般不用我们管,但如果你自己写裸机程序或者把HAL库的时钟初始化代码删掉了,就容易在这上面踩坑。

2. 核心细节解析与实操要点

2.1 STM32内部Flash的地址划分与容量对照

不同型号的STM32,内部Flash的地址范围、扇区大小、擦除粒度都不一样。以最常用的STM32F103系列为例,Flash从0x08000000开始,大小有16KB到512KB不等。F103的小容量芯片扇区是1KB一个,中大容量芯片则是前4个扇区16KB(总共64KB),后面扇区是64KB一个。F407又是另一种划分方式:前4个扇区16KB,第4个扇区64KB(注意这里编号从0开始,第4个其实是指扇区4),后面7个扇区128KB。F4系列跟F1系列完全不一样,操作函数也变了,千万不要拿F1的扇区表去套F4的擦除函数。

我整理了一个常见对照表,方便大家写代码前先确认自己手上的芯片属于哪种划分:

芯片系列Flash起始地址扇区/页大小擦除最小单位典型容量
STM32F103C80x080000001KB(小容量)/ 前4个扇区16KB(中容量)按扇区擦除64KB
STM32F103ZET60x08000000前4个扇区16KB,后11个扇区64KB按扇区擦除512KB
STM32F407VET60x08000000扇区0~3为16KB,扇区4为64KB,扇区5~11为128KB按扇区擦除512KB
STM32G4310x080000002KB一页按页擦除128KB

从这张表可以看出,同样是“按扇区擦除”,不同芯片的扇区大小天差地别。比如F103ZET6最后那几个扇区是64KB一个,如果你只想存一个4KB的配置数据,硬是把整个64KB扇区擦一遍,那写入时间就是毫秒级到几十毫秒级的差异,而且Flash寿命损耗也不划算。所以选存储区域的时候,尽量选尺寸匹配的扇区,宁可多花点心思算偏移,也不要用大扇区存小数据。

2.2 HAL库Flash操作的三个关键函数深入解析

HAL库的Flash操作在stm32f1xx_hal_flash.c或stm32f4xx_hal_flash.c这些文件里实现,不同系列函数名几乎一样,但内部实现细节差别很大。我这里以F1系列为例子讲,F4系列的差别我会单独标注。

先看写入函数:

HAL_StatusTypeDef HAL_FLASH_Program(uint32_t TypeProgram, uint32_t Address, uint64_t Data);

TypeProgram有两个取值:FLASH_TYPEPROGRAM_HALFWORD和FLASH_TYPEPROGRAM_WORD。F1系列还支持FLASH_TYPEPROGRAM_BYTE,但F4系列不支持字节写入,至少是半字(16位)。为什么要这样设计?因为F4的Flash控制器硬件上就是按32位或者16位粒度写的,你直接写一个字节,它会把你给的8位数据放到一个半字的低位,高8位填充0xFF,这样反而可能把相邻字节的内容搞坏。

还有一个关键点:这个函数的Data参数是uint64_t,但实际上用它表示数据时,低32位有效就够了。比如写入一个32位数据:

HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, (uint64_t)data);

这里的data会被强制扩展成64位,高32位自动补0,不用自己手动转。有些人喜欢先定义一个uint64_t buffer再传进去,没必要,直接传(uint64_t)强转就行。

再看擦除函数:

FLASH_EraseInitTypeDef EraseInitStruct; EraseInitStruct.TypeErase = FLASH_TYPEERASE_SECTORS; EraseInitStruct.Sector = 4; // 从扇区4开始擦 EraseInitStruct.NbSectors = 1; // 擦几个扇区 EraseInitStruct.VoltageRange = VoltageRange_3; // F4系列需要这个参数 uint32_t SectorError = 0; HAL_FLASHEx_Erase(&EraseInitStruct, &SectorError);

注意F1系列没有VoltageRange这个字段,F4系列才需要。如果你的代码在F1上编译报错说结构体里没有这个成员,检查一下是不是误用了F4的例程。擦除执行的时间取决于扇区大小和芯片主频,通常在20ms到2秒不等。擦除期间CPU会被阻塞,如果有中断频繁进来,要么临时关闭中断,要么把擦除操作放到不影响实时性的时机去执行。

擦除完成之后,可以用一个简单方法验证:读一下擦除区域的第一个地址,如果返回0xFFFFFFFF,说明擦除成功了。HAL库的HAL_FLASHEx_Erase()本身会返回状态,但我们做产品的时候我还是习惯自己再读一遍确认,防止因为电压不稳或者时序问题导致擦除不干净。

2.3 读取Flash区域数据的正确姿势

Flash读取不叫“读Flash函数”,不需要解锁,也不需要调用任何HAL库API,直接像访问普通内存一样操作指针就行。比如我想读地址0x08010000处的4字节数据:

uint32_t data = *(__IO uint32_t *)0x08010000;

这里的__IO是volatile修饰,告诉编译器这个地址的内容可能随时变化,别做优化缓存。如果你要读一段连续的固件数据,可以用指针循环遍历:

uint8_t *p = (uint8_t *)0x08000000; uint8_t buf[256]; for (int i = 0; i < 256; i++) { buf[i] = p[i]; }

这种方式可以直接用来扫描整个Flash,找到从哪个地址开始连着几十个字节都是0xFF,那基本就是程序结束的位置。不过要注意,程序在运行的时候,Flash里有些区域可能是中断向量表、只读常量、函数代码,这些内容不会是0xFF。真正的空区域是那些从来没有被写入过、或者被擦除过的区域。所以扫描方向一定要从固件起始地址0x08000000往后扫,而不是随便找一个地址乱扫。

还有一个容易忽略的地方:STM32的Flash读取要保证地址对齐。用uint32_t *指针读的时候,地址必须是4的倍数;用uint8_t *指针逐字节读就没有这个问题。如果你要用memcpy把Flash里的数据拷到RAM数组,建议用memcpy而不是自己写指针循环,编译器会帮你做优化并且处理对齐问题。

2.4 解锁与上锁:安全操作的完整边界

用HAL库操作Flash之前,必须先调用HAL_FLASH_Unlock()。这个函数做的事情是往Flash控制器的KEYR寄存器依次写入两个密钥值:0x45670123和0xCDEF89AB。写入顺序不能反,而且必须在解锁状态下才能继续擦写操作。如果顺序错了或者上一次操作出现错误标志没清除,解锁会失败,后续函数返回HAL_ERROR。

很多人写完擦除和编程之后忘记重新上锁,虽然程序跑起来一般不会出问题,但这是一个很严重的安全隐患。一旦Flash控制器处于解锁状态,如果程序因为电压波动、指针越界等原因往Flash地址执行了写操作,轻则改写配置数据,重则把应用程序本身擦掉,直接变砖。所以写完Flash之后,立刻调用HAL_FLASH_Lock()重新锁上,这是一个好习惯。

还有一种情况是写Flash过程中出现错误,HAL库把错误标志记录在Flash控制器的状态寄存器里。如果你不做处理直接进行下一步操作,错误标志会一直存在,导致后续的擦写操作被阻塞。遇到这种情况,调用一下__HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_WRPERR | FLASH_FLAG_PGAERR)把标志清干净,再重新尝试。

3. 实操过程与核心环节实现

3.1 制作一个“Flash内容扫描器”来算固件大小

为了把标题里的需求落地,我先写了一个非常小的验证程序。它的功能很简单:通过串口接收上位机指令,收到指令后从Flash起始地址0x08000000开始,每次读取256字节,检查里面如果连续128字节都是0xFF,就认为已经越过了固件边界,把边界地址和估算大小返回给上位机。

这个程序不需要任何外部存储,也不需要BootLoader配合,只要烧录一次,它就能读自己的Flash来估算自己的固件大小。当然你会说,编译输出窗口不是直接显示Program Size吗?没错,但我们的目的是理解内部Flash读取的方法,以后做BootLoader或者程序自检就能直接复用这套逻辑。

代码核心部分如下:

uint32_t find_firmware_end(void) { uint32_t addr = 0x08000000; uint32_t end_addr = 0x08000000; uint8_t zero_cnt = 0; while (addr < FLASH_END_ADDR) { uint8_t byte = *(__IO uint8_t *)addr; if (byte == 0xFF) { zero_cnt++; if (zero_cnt >= 128) { // 连续128个0xFF,认为是空闲区域 end_addr = addr - 128; return end_addr; } } else { zero_cnt = 0; } addr++; } return addr; // 没找到边界就返回Flash末尾 }

这里为什么选128字节作为判断阈值?因为程序结尾处可能有一些对齐填充,比如编译器为了4字节对齐,在后面填了0~3个字节的0x00或者0xFF,但绝不会填128个连续0xFF。所以128这个阈值既能避开对齐填充的干扰,又不会因为误判导致把程序截短。当然,如果你程序的只读数据区末尾恰好有一段未初始化的全0xFF数据,那这个判断就会提前结束,但这种情况非常少见,通常只出现在使用__attribute__((section(".ARM.__at_0x08000000")))自定义段数据时出现。普通工程不会有这个问题。

拿到边界地址之后,程序大小就是:

firmware_size = end_addr - 0x08000000;

比如找出来的边界是0x0800A200,那程序大小就是0xA200= 41472字节 ≈ 40.5KB。

3.2 从Keil工程导出bin文件的详细步骤

标题里特意提到“得到bin文件的方法”,我猜不少人是被开发环境里默认生成的hex文件搞懵了。hex文件是带地址信息的文本格式,里面每一行都包含起始地址和数据长度,烧录器能识别,但如果你想直接看这个固件占用多少字节、想把它合并到BootLoader里,就必须要一个纯粹的bin文件——说白了就是机器码的裸数据,没有地址也没有校验,烧录的时候必须告诉它烧到哪个地址。

Keil MDK导出bin文件的方法是设置一个用户自定义命令,在编译完成后自动调用fromelf.exe。具体步骤:

  1. 打开Keil,点击魔法棒(Options for Target)。
  2. 切换到“User”选项卡。
  3. 在“After Build/Rebuild”下面的“User Command”输入框里,勾选“Run #1”。
  4. 填写命令:
    fromelf.exe --bin --output .\Objects\你的工程名.bin .\Objects\你的工程名.axf
  5. 点击OK,重新编译一次,然后去Objects文件夹找bin文件。

这里必须注意路径和工程名不能写错。fromelf.exe是Keil自带的转换工具,它能把axf文件转成bin。如果你的工程是中文名或者路径里有空格,命令里的引号要加好:

fromelf.exe --bin --output ".\Objects\我的工程.bin" ".\Objects\我的工程.axf"

还有一种更简单的办法是不写命令,直接在工程里生成hex文件后用第三方工具转bin,但那就多了一步,不推荐。

STM32CubeIDE用户则是在Project Properties里设置:

  1. 右键工程 -> Properties。
  2. 选择C/C++ Build -> Settings -> Build Steps。
  3. 在Post-build steps的Command输入框里填写:
    arm-none-eabi-objcopy -O binary -S 你的工程名.elf 你的工程名.bin
  4. 重新Build,bin文件就会生成在Debug或Release目录下。

bin文件生成之后,用十六进制编辑器打开,开头应该是00 01 02 03 ...这样的中断向量表数据(取决于芯片),从第0字节到第8字节是初始SP指针,接着是复位向量地址。这个特征可以用来验证你读出来的Flash数据是不是真的固件。

3.3 直接把芯片里的固件读出来保存成bin

如果你手里没有工程文件,只有一块烧录好程序的板子,也别慌。用ST-Link或者J-Link自带的工具就能把Flash内容读出来。最方便的是配合STM32CubeProgrammer:

  1. 连接ST-Link和板子。
  2. 打开STM32CubeProgrammer,选择ST-LINK,设置连接方式为“Under reset mode”,防止芯片里跑着的程序干扰调试。
  3. 点击“Connect”连接成功后,切到“Memory & File”页面,在地址输入框填0x08000000,大小填芯片Flash容量,点击“Read”按钮。
  4. 读取出来后点击“Save”按钮,选择保存为.bin格式,文件路径自己定。

这样得到的bin文件就是包含芯片全部Flash内容的镜像,包括所有已写入的程序数据。如果你只是想看固件截断后的有效部分,可以用我前面说的扫描算法把边界算出来,然后在保存的时候把Size改成固件实际大小,这样导出的bin就只包含有意义的程序代码,后面做逆向分析或者做差分包都很方便。

这里有个实操心得:读Flash的时候,最好用“Under reset mode”也就是复位模式下连接。否则如果程序里初始化了调试引脚或者进入了低功耗模式,调试器可能连不上。我遇到过好几块板子,程序跑飞把SWD引脚复用掉了,直接连接连不上,必须复位模式下才能握手成功。

3.4 验证自己的读取结果与bin是否一致

我们前面做了两步:一是用内部Flash扫描器找固件边界,二是用调试器导出bin。现在可以做一个交叉验证,确保你的扫描算法没问题。

操作方法是:用STM32CubeProgrammer读取整个Flash,保存成flash_backup.bin,然后在电脑上用Python写个简单脚本,检查从0x08000000开始有多少个字节不是0xFF:

with open('flash_backup.bin', 'rb') as f: data = f.read() last_non_ff = 0 for i, b in enumerate(data): if b != 0xFF: last_non_ff = i print('固件实际占用(不含尾部填充): 0x%X bytes' % (last_non_ff + 1))

注意这里用的是“最后一个非0xFF字节的索引”,不是连续0xFF判断。它是基于整个bin文件的,所以如果固件末尾恰好有一段全0xFF的有意义数据,这个结果会把它们当成“空”忽略。不过对于绝大多数普通程序来说,这个误差可以忽略。

再把之前扫描器算出来的firmware_size和这个last_non_ff + 1比较一下,如果差值在几百字节以内,说明扫描算法和实测基本吻合。为什么要允许几百字节的误差?因为扫描器在发现连续128个0xFF时会把前面128字节也算成空闲,如果程序末尾真的有128字节的连续0xFF(比如大量未初始化的全局变量?不,变量在RAM不在Flash),但比如的的确确有一张图片数组或者字库数据全是0xFF,那就误判了。所以扫描器的准确度取决于内容分布,正常情况下误差不会超过几个扇区。

从实用性角度,当你做BootLoader跳转App时,你需要知道App固件有多少字节,才能决定是从串口一次收完还是分包写Flash。用我上面说的扫描法,在App运行时读它自己的Flash,把边界算出来,再通过串口反馈给上位机,这个方案的可靠性已经够高了。

4. 常见问题与排查技巧实录

4.1 写入Flash之后程序直接跑飞或者HardFault

这是新手最容易踩的坑。写完Flash后,如果程序跑飞,大概率是因为你写的地址本身是非法的,或者你在写Flash的时候中断里也访问了Flash导致总线冲突。

排查步骤如下:

  1. 检查写入地址是否落在Flash有效范围内。比如F103C8容量是64KB,有效范围0x08000000到0x0800FFFF,写0x08010000就是超范围了。
  2. 检查是否在擦除/写入过程中被中断打断。Flash编程期间,CPU会暂停执行,但中断可能会挂起,等Flash操作结束才进入ISR。这通常没问题,但如果你在中断里也调用了Flash操作函数,就会产生互斥问题。我的建议是,Flash操作期间关中断:
    __disable_irq(); // 擦除和写入 __enable_irq();
  3. 检查数据对齐。F4系列写Flash必须半字对齐,如果你传了一个奇数地址进去,会返回HAL_ERROR并置位PGAERR错误标志。

4.2 擦除返回HAL_ERROR或者擦除后读出来还是旧数据

擦除失败大多数人都是因为没解锁。记住擦除和写之前都要解锁,HAL库的解锁函数只操作Key寄存器,不检查Flash是否已经处于解锁状态。如果你在程序初始化时多次调用HAL_FLASH_Unlock(),第一次成功,第二次其实也是成功的,因为它的实现是往KEYR寄存器写密钥,覆盖写不影响状态。但如果你前面调用了HAL_FLASH_Lock()又忘了重新解锁,那函数就会失败。

另一种情况是Flash操作过程中产生了写保护错误(WRPERR)。某些芯片支持Flash写保护,如果你开启了读保护或者写保护,直接操作Flash会失败。检查方法是读FLASH->CR寄存器的WRPERR位。如果置位了,先调用__HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_WRPERR)清除标志。

关于读保护多说一句,STM32CubeProgrammer里如果设置了RDP级别1,调试器默认无法读取Flash内容,会导致你读出来的全是0xFF。这种不是Flash真的空了,而是保护机制生效。解决办法是执行“Remove readout protection”,但这会触发整片擦除,你之前的数据就没了。所以做产品千万别随便开启读保护,除非你确认自己能接受芯片变空的风险。

4.3 计算出来的固件大小和Keil显示的差异很大

Keil编译输出里的Program Size = Code + RO-data + RW-data + ZI-data。其中Code是代码段,RO-data是只读常量,RW-data是需要初始化到RAM的变量初值,ZI-data是零初始化变量,不占用Flash(编译后它只记录大小,不实际写入)。所以实际上烧到Flash里的内容是Code + RO-data + RW-data之和,ZI-data只在RAM里清零。

我遇到过有人直接把四个数加起来算固件大小,那就会偏大几个KB到几十KB。比如RW-data很大,意味着你要把一坨初值从Flash拷贝到RAM,这部分在Flash里是实实在在占空间的,但它跟ZI-data没关系。所以严格按照资料上的公式:

Flash占用 = Code + RO-data + RW-data

再加上一些段对齐和填充,可能会多出几十字节。如果你的程序里用了__attribute__((aligned(64)))这样的对齐属性,那填充会更多。扫描器算出来的值通常会比编译器的静态值略微大一点,因为扫描器是把所有写完的扇区都算进去了,包括编译器为了对齐产生的填充字节,这个差异是正常的,不需要纠结。

我之前遇到一个极端情况:把整个字库数组放在Flash里,数组大小256KB,但其中大段都是0xFF数据。扫描器会提前判定固件结束,导致算出来的大小比实际小了很多。这种场景下你就不能用“连续0xFF”这个判据,而是要看编译器的map文件里_erode或者__initial_sp的值。一句话总结:扫描法适合普通程序固件的估算,不适合包含大段全0xFF数据的特殊固件。

4.4 写入失败后Flash中的数据丢失了怎么办

这是个残酷的话题。Flash写操作如果失败,最典型的后果是那个扇区的部分数据被写坏,而且Flash不支持“局部恢复”,除非你之前有备份,否则数据就是永久丢了。所以在操作Flash之前,务必备份目标扇区的数据。

一个常用备份思路是:把需要修改的扇区内容整体读入RAM缓冲区,然后在RAM里修改,再擦除扇区、写回。注意F1/F4的Flash扇区大小都不小,F407扇区最小16KB,RAM不大的芯片可能放不下16KB缓冲区。这时候可以考虑先把数据暂存到另一个Flash扇区,但那样逻辑就复杂了。更好的方案是把配置数据放在专门的EEPROM仿真区,用两个扇区轮流写,实现磨损均衡和掉电保护。不过那属于进阶话题,这里只提醒一句:任何涉及Flash的写入操作,都必须考虑掉电中断和写失败情况下的数据恢复策略。

我个人的习惯是:配置文件使用固定格式,头部有魔数、版本号、CRC校验,写入时先写备份扇区,确认成功后再更新主扇区,最后再更新标志位。这套做法看起来很繁琐,但产品量产之后出现莫名其妙的数据丢失问题的概率会急剧降低。

4.5 bin文件生成失败:fromelf报错或者生成的是空的

Keil的fromelf命令如果输出为空,最常见的原因是工程名或者路径写错了。你可以在电脑上打开cmd,手动跑到工程目录执行fromelf命令试错。还有一点要注意,fromelf生成bin是依赖axf文件的,axf文件路径如果改变了,命令里的路径也要跟着改。

如果是STM32CubeIDE生成bin失败,先确认一下Post-build steps里的命令是否包含了正确的工程名。很多人把工程重命名之后忘了改命令里的elf文件名,自然就生成失败。

另外一个非常关键的点:bin文件生成后,用烧录器烧录时必须指定起始地址。如果你拿的是一个纯bin文件,直接像烧hex那样用默认地址烧,烧录器会把它放在0x08000000,这没错。但如果你想把这个bin作为App烧到偏移地址,比如0x08010000,那就必须设置烧录地址为0x08010000。很多人在这里翻车,原因就是bin文件不带地址,烧录软件不知道往哪里放。

5. 扩展应用:把内部Flash玩出更多花样

5.1 用内部Flash存储用户配置参数

内部Flash最常见的应用场景就是存用户配置。比如一个温控器,用户设定的目标温度、PID参数、报警阈值,这些都要掉电保存。用EEPROM当然可以,但省掉一颗外挂芯片也是成本优势。设计思路是这样的:

规划一个独立扇区(或者扇区中的一小块),存储结构体:

typedef struct { uint32_t magic; // 魔数,用于校验是否已初始化 uint32_t version; // 配置版本号 uint16_t temp_limit; uint16_t temp_alarm; // 其他参数 uint32_t crc32; // 校验值 } SystemConfig;

每次读取配置时,先检查magic是否符合预设值,再计算CRC,全部通过才使用。修改配置时,先备份旧配置到RAM,修改完再擦除整个扇区写回。如果写入过程中掉电,重启后magic或CRC不正确,程序自动恢复默认配置并做一次完整配置写入。这种设计比简单地把HAL_FLASH_Program调用几次要可靠得多。

5.2 做一个简易BootLoader的跳转边界管理

很多做OTA的人都会接触到BootLoader。BootLoader放在0x08000000,App放在0x08010000或者0x08080000,App烧录前,BootLoader需要知道App固件的真实大小,这样才能决定是否擦除足够的扇区。比如App只占20KB,但你擦除了两个64KB扇区,虽然不影响功能,浪费的擦除时间会让升级体验变差。此时就可以用我前面讲的扫描方法,在App包里读取App起始地址后面多少字节是0xFF,然后只擦必要的扇区。

如果你在BootLoader里做这件事,读取的是App区域,而不是正在运行的BootLoader自身区域。流程是:收到App包,先把包存到RAM缓冲区或者外置Flash,然后检查App区域从0x08010000开始的连续0xFF长度,计算出实际固件大小,再根据这个大小决定擦除哪些扇区、写多少数据。这比直接按最大扇区擦除高效得多,也减少了对Flash寿命的无谓消耗。

5.3 Flash磨损均衡的简化实现

内部Flash写入寿命通常在1万次到10万次之间看着不少,但如果你每秒钟存一次数据,一天就是86400次,几天就报废了。所以做存储类功能必须考虑磨损均衡。

最简单的策略是“轮流写”:准备两个扇区,逻辑上叫A和B,每次写入先写A,A写满再写B,B写满又回头擦A。A和B都有最新数据标记(比如一个自增计数),上电时比较哪一个扇区里的计数大,就用哪一个。虽然两个扇区相比单扇区只是延长了一倍寿命,但从算法角度已经入门了。

更复杂的方案是用多个扇区组成环形缓冲区,每次只写一个固定大小的记录块,每个块包含序列号和CRC,启动时扫描所有块找到最新有效记录。这种方法实现起来稍微麻烦一点,但可以在极端情况下让Flash寿命延长几倍到几十倍。后续如果你对OTA和参数存储都有需求,很值得深入研究一下。

5.4 利用bin文件的边界做固件差分升级

拿到bin文件之后,除了估算大小,还能做很多事情。比如两套固件之间可能只改了几百字节,如果我们能定位差异位置,就可以做一个差分包,只上传变化的数据,显著减少升级数据量。这种技术在蜂窝网络的设备OTA里特别有用,因为流量费不便宜。

具体做法也不复杂:把新旧两份bin分别读进内存,按块比较,找出哪些块不同,然后生成一个自定义格式的差分包,里面记录差块地址和差块数据。接收端拿到差分包后,按地址直接写入对应Flash即可。当然,这套流程需要BootLoader侧配合,而且要考虑Flash擦除边界的问题——如果差块跨越了扇区边界,就得额外处理。但核心思路就是从bin文件出发,搞清楚固件内容,制作差异补丁。

6. 最后的实操心得

回到HAL库学习本身,内部Flash这一章的价值不在于你会调用几个函数,而在于你真的理解了芯片存储结构的工作方式。我最初学的时候也觉得“Flash嘛,就是存程序的”,直到做BootLoader时被贴脸考了一轮:为什么擦除前要备份?为什么写完后要上锁?为什么bin文件烧录要选地址?这些问题一个个逼着我去翻参考手册,最后才把整个逻辑串通。

建议如果你手头有开发板,别光看这篇博客就完事,最好动手做做这个小实验:

  1. 写一个程序,从0x08000000开始读256字节,用串口打印出来,对照编译生成的hex或bin文件,你会看到完全一致的数据。
  2. 修改程序大小,比如加一张数组或者多写几个函数,重新编译并烧录,然后扫描器算出来的固件大小应该会跟着变化。
  3. 把写好的配置数据放在Flash的某个扇区,断电重启后再读出来,验证掉电保存是否生效。

这些实验做完,你对内部Flash的信任感和掌控感绝对是看资料体会不到的。踩过几次坑之后,我特别感谢那些“为什么不对”的时刻,它们逼着我去理解Flash的物理特性。希望这篇内容能让你少走点弯路,把时间省下来去玩更有意思的功能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询