【STM32】 内部 Flash 详解:寄存器逐位拆解 + 工作流程顺序全讲 + EEPROM再实现


内部 Flash 详解:寄存器逐位拆解 + 工作流程顺序全讲

一、基础概念(回顾)

1.1 什么是内部 Flash

Flash(闪存) 是 STM32 芯片内部的非易失性存储器:

  • 掉电不丢失:断电后数据依然保留;
  • 片内集成:同一颗芯片内,总线直接访问;
  • 可擦写:可反复擦除和编程(寿命约 1 万次)。

1.2 Flash vs SRAM vs EEPROM

对比项 内部 Flash SRAM EEPROM(外部)
易失性 非易失 易失 非易失
访问速度 快(可取指执行) 最快 慢(I2C/SPI)
擦写单位 页/扇区擦除 无需擦除 按字节
擦写寿命 ~10,000 次 无限 ~1,000,000 次
主要用途 存代码、常量、数据 变量、栈、堆 小量掉电数据

1.3 内部 Flash 能干什么

  1. 存代码(上电从 0x08000000 取指执行);
  2. 存常量(const 数组、字符串);
  3. 掉电保存数据(模拟 EEPROM);
  4. OTA 升级(Bootloader 写新固件);
  5. 配置芯片行为(选项字节)。

二、Flash 内部结构

2.1 整体布局(F103 为例)

cpp 复制代码
STM32F103 内部 Flash 总览
┌─────────────────────────────────────────────┐
│ 主存储块(Main Memory)                       │
│  0x0800_0000 ──► 页0  (1KB)                  │
│  0x0800_0400 ──► 页1  (1KB)                  │
│  ...                                        │
│  0x0801_FC00 ──► 页127 (1KB)                │
│  共 128 页 × 1KB = 128KB                    │
├─────────────────────────────────────────────┤
│ 系统存储器(System Memory) 2KB Bootloader   │
│  0x1FFF_F000 ~ 0x1FFF_F7FF                  │
├─────────────────────────────────────────────┤
│ 选项字节(Option Bytes) 16B                 │
│  0x1FFF_F800 ~ 0x1FFF_F80F                  │
├─────────────────────────────────────────────┤
│ OTP 区域(一次性可编程)                      │
│  0x1FFF_7800 ~ 0x1FFF_7A0F                  │
└─────────────────────────────────────────────┘

2.2 F1 页式 vs F4 扇区式

系列 结构 擦除单位 编程单位 典型容量
F1(F103) 页式 1KB/页 半字(16bit) 64KB~512KB
F4(F407) 扇区式 32KB/128KB/256KB 字(32bit)/双字 512KB~2MB

F4 扇区:

Sector03 各 32KB、Sector4 128KB、Sector511 各 256KB(1MB 容量时用前 12 个扇区)。


三、寄存器详解(逐位拆解)

STM32F1 的 Flash 接口一共 8 个寄存器。本章是重点,逐个展开。

3.1 FLASH_ACR --- 访问控制寄存器(地址 0x4002_2000 + 0x00)

作用:配置 Flash 读取时序(等待状态、预取缓冲)。

位 名称 类型 复位值 作用
bit2:0 LATENCY RW 000 等待状态(见下表)
bit3 PRFTBE RW 0 预取缓冲使能:1=开启,0=关闭
bit4 PRFTBS R 0 预取缓冲状态:1=缓冲已满
bit5 HLFCLK RW 0 半周期配置:1=半周期(仅 8MHz 以下且关闭预取时用)
bit31:6 保留 --- --- ---

LATENCY 等待状态表(F103,HCLK 频率决定):

HCLK 频率 电压 2.7V~3.6V 电压 2.4V~2.7V
0 < HCLK ≤ 24MHz 0 个等待 0 个等待
24MHz < HCLK ≤ 48MHz 1 个等待 1 个等待
48MHz < HCLK ≤ 72MHz 2 个等待 2 个等待

为什么需要等待状态:

Flash 读取速度是固定的(约 24MHz 一个周期),CPU 主频 72MHz 时 CPU 每 13.9ns 就要取一次指,Flash 跟不上,必须插入 2 个等待周期让数据稳定。

等待状态配错 = 程序随机跑飞。

3.2 FLASH_KEYR --- 密钥寄存器(偏移 0x04)

**作用:解锁 Flash 编程/擦除。**只写寄存器,读无意义。

写入值 作用
第一次写 0x45670123 密钥序列第一部分
第二次写 0xCDEF89AB 密钥序列第二部分,写对后解锁

解锁细节:

  • 两把密钥必须按顺序连续写入,中间不能隔其他 Flash 操作;
  • 写错任意一把:本次解锁失败,需要重新从第一把开始;
  • 解锁成功后,FLASH_CR 的 LOCK 位自动清 0;
  • 硬件复位或置 LOCK=1 后,Flash 重新上锁,需重新解锁。

3.3 FLASH_OPTKEYR --- 选项字节密钥寄存器(偏移 0x08)

作用:解锁选项字节的编程/擦除(写 OB 区域前必须先解锁)。

写入值 作用
第一次写 0x08192A3B 选项字节密钥第一部分
第二次写 0x4C5D6E7F 选项字节密钥第二部分

和 KEYR 同样的机制:

两把密钥、按序写、写错重来。

只有要修改选项字节(读保护/写保护/硬件狗配置)时才用这个寄存器。

3.4 FLASH_SR --- 状态寄存器(偏移 0x0C)

作用:查询 Flash 操作状态。这是编程时查得最多的寄存器。

位 名称 类型 复位值 作用
bit0 BSY R 0 忙标志:1=正在擦除/编程,任何新操作前必须等它=0
bit2 PGERR RW0 0 编程错误:1=编程操作失败(未擦除/地址未对齐/越界),写 1 清除
bit4 WRPRTERR RW0 0 写保护错误:1=试图写被保护的页,写 1 清除
bit5 EOP RW0 1 操作完成:1=擦除/编程成功结束,写 1 清除
bit1,3,6,7 保留 --- --- ---

各标志的使用场景:

cpp 复制代码
while(FLASH->SR & FLASH_SR_BSY);      // ① 操作前:等上一次忙完
// ... 发起擦除/编程 ...
while(FLASH->SR & FLASH_SR_BSY);      // ② 操作后:等本次完成

if(FLASH->SR & FLASH_SR_PGERR) {      // ③ 查编程错误
    // 处理:一般是没擦除就写 / 地址没对齐
}
if(FLASH->SR & FLASH_SR_WRPRTERR) {   // ④ 查写保护错误
    // 处理:目标页被选项字节写保护
}
FLASH->SR |= FLASH_SR_EOP;            // ⑤ 清除完成标志(写 1 清 0)

为什么 EOP 复位值是 1:

芯片设计时让"未发生过操作"也表现为完成,方便读取。

真正判断"本次操作完成"要看 BSY 从 1 变 0,EOP 只作为可选的完成确认。

3.5 FLASH_CR --- 控制寄存器(偏移 0x10)

作用:控制 Flash 的模式(编程/擦除/选项字节)和启动。这是最核心的寄存器。

位 名称 类型 复位值 作用
bit0 PG RW 0 编程使能:1=进入编程模式(置位后写地址才触发编程)
bit1 PER RW 0 页擦除****使能:1=进入页擦除模式
bit2 MER RW 0 整片擦除****使能:1=进入全片擦除模式(危险!)
bit3 OPTPG RW 0 选项字节编程使能
bit4 OPTER RW 0 选项字节擦除使能
bit5 STRT RW 0 启动擦除:写 1 启动当前模式的擦除(仅擦除需要,编程不需要)
bit6 LOCK RW 1 **锁定:1=Flash 锁定;写 1 上锁,**只能由硬件复位或成功解锁清 0
bit7 OPTWRE RW 0 选项字节写使能:1=允许写选项字节(解锁 OPTKEYR 后自动置 1)

CR 的关键设计:

  • 模式位(PG/PER/MER)是"预设置"------先选好要干什么,再触发;
  • STRT 只对擦除有效------擦除需要"按启动按钮",编程不需要(写数据动作本身触发);
  • LOCK 只能写 1 不能写 0------上锁容易,解锁必须走密钥流程,防误操作。

3.6 FLASH_AR --- 地址寄存器(偏移 0x14)

作用:指定擦除的目标地址。

位 名称 类型 作用
bit31:0 FAR RW 页擦除时存放目标页地址

只有页擦除用到 AR;

整片擦除(MER)不需要地址。

编程也不通过 AR------编程地址就是你要写数据的那个地址本身(直接对 Flash 地址写)。

3.7 FLASH_OBR --- 选项字节状态寄存器(偏移 0x1C)

作用:只读,反映当前选项字节的配置状态。

位 名称 类型 作用
bit0 OPTERR R 选项字节错误:1=选项字节校验失败
bit1 RDPRT R 读保护级别:00=Level 0,01=Level 1,10/11=Level 2
bit2 IWDG_SW R 看门狗配置:1=软件看门狗,0=硬件看门狗
bit4:3 BOR_LEV R 掉电检测阈值等级
bit9:8 nBOOT1 R 启动模式配置位

读这个寄存器就能知道芯片当前处于什么保护/启动状态,不用回读选项字节本身。

3.8 FLASH_WRPR --- 写保护寄存器(偏移 0x20)

作用:标记哪些页被写保护(只读,实际配置要写选项字节区)。

位 名称 作用
bit127:0 WRP 每一位对应 4 页(F103 中每 bit 保护 4 个 1KB 页),1=该组被写保护

被写保护的页无法擦除和编程,但可以正常读取。

用于保护 Bootloader 等重要区域不被误写。


四、Flash 三大特性

4.1 特性一:只能写 0,不能写 1

复制代码
Flash 位状态:
 1(未编程)──编程──► 0(已编程)
 0(已编程)──擦除──► 1(未编程)

4.2 特性二:擦除是"按块"的

  • 擦除把整页/整扇区恢复成全 1(0xFF);
  • 不能按字节擦除;
  • 修改数据三步曲:① 读出到 SRAM ② 擦整页 ③ 重写。

4.3 特性三:写前必须擦除,且要等 BSY

  • 每次操作前必须查 FLASH_SR.BSY=0;
  • 编程单位:F1 半字(16bit)、F4 字(32bit)/双字。

五、Flash 工作流程顺序(重点,逐步拆解)

核心总序:

解锁 → 等待空闲 → 配置模式 → 指定地址 → 触发操作 → 等待完成 → 清除标志 → 退出模式 → 加锁。

下面按"为什么是这个顺序"逐阶段讲。

5.0 工作流程总览

cpp 复制代码
阶段1  解锁         FLASH->KEYR 连写两把密钥
   │               (必须先于一切操作,否则 CR 被锁,操作无效)
   ▼
阶段2  等空闲       while(SR.BSY)  ← 每次都查
   │
   ▼
阶段3  配置模式     CR 置 PG / PER / MER(先选模式)
   │
   ▼
阶段4  指定地址     AR = 目标页(仅页擦除需要)
   │
   ▼
阶段5  触发操作     擦除:置 STRT=1 启动
   │              编程:直接向 Flash 地址写数据(写动作即触发)
   ▼
阶段6  等完成       while(SR.BSY)  ← 操作期间 CPU 停在这
   │
   ▼
阶段7  清标志       SR 写 1 清 EOP(及错误标志)
   │
   ▼
阶段8  退出模式     CR 清 PG / PER(防止误触发)
   │
   ▼
阶段9  加锁         CR 置 LOCK=1(防跑飞误擦写)

5.1 阶段一:解锁------为什么必须先做?

顺序原因:

FLASH_CR 的 LOCK 位复位值为 1,Flash 默认处于锁定状态。

锁定时,CR 的所有模式位(PG/PER/MER)写不进,一切操作无效。

复制代码
FLASH->KEYR = 0x45670123;   // 密钥 1
FLASH->KEYR = 0xCDEF89AB;   // 密钥 2 → 硬件校验通过 → LOCK 清 0

细节:

  • 两把密钥必须连续、按序写入,中间不能插入其他 Flash 相关操作;
  • 写错任意一把:解锁失败,CR 保持锁定,必须从密钥 1 重来;
  • 解锁只解除"这一次"的锁------如果之后你置 LOCK=1 或发生复位,需要重新解锁。

为什么设计成两把密钥:

跑飞后 PC 随机写寄存器,连续写对两个 32 位特定值(0x45670123 和 0xCDEF89AB)的概率为 1/2^64,几乎不可能误解锁------防止程序失控后把自己的代码擦没。

5.2 阶段二:等待空闲------每次操作前后都要查

顺序原因:

Flash 是单接口设备,同一时间只能做一个操作。

上一次擦除/编程没结束(BSY=1)时,发起新操作会导致本次操作无效甚至错误。

复制代码
while(FLASH->SR & FLASH_SR_BSY);   // 忙则死等

细节:

  • BSY 由硬件在操作开始置 1、结束清 0;
  • 解锁、加锁不需要等 BSY(它们不访问存储阵列);
  • 擦除、编程前后都要查:前面等上一次完成,后面等本次完成。

5.3 阶段三:配置模式------先选模式再触发

顺序原因:

CR 是"预设置寄存器"。

必须先把模式位(PG/PER/MER)置 1,再触发,硬件才知道接下来要做什么。

反过来(先触发再设模式)无效。

cs 复制代码
// 页擦除模式
FLASH->CR |= FLASH_CR_PER;     // 选页擦除

// 整片擦除模式
FLASH->CR |= FLASH_CR_MER;     // 选全片擦除(危险)

// 编程模式
FLASH->CR |= FLASH_CR_PG;      // 选编程

细节:

  • PG、PER、MER 互斥使用------一次只能选一个模式,做完要清掉再换下一个;
  • 选完模式后,硬件在下一次触发/写数据时按模式执行;
  • 模式位不清除的话,后续任何写 Flash 地址的操作都会被当成编程/擦除触发------用完必须清。

5.4 阶段四:指定地址------只有页擦除需要 AR

顺序原因:擦除需要知道"擦哪一页",地址先给硬件,触发后才执行。

复制代码
FLASH->AR = page_addr;          // 页擦除目标地址(必须按 1KB 页对齐)

细节:

  • 页擦除:必须写 AR,且地址要页对齐(F1 按 1KB 对齐,即地址低 10 位为 0);
  • 整片擦除(MER):不需要 AR;
  • 编程:不需要 AR------编程目标地址就是你写数据的那个地址(*(volatile uint16_t*)addr = data)。

5.5 阶段五:触发操作------擦除与编程的触发方式不同

擦除需要"按启动按钮",编程不需要。这是最容易混的点:

cpp 复制代码
// 擦除触发:写 STRT=1
FLASH->CR |= FLASH_CR_STRT;

// 编程触发:直接向 Flash 地址写数据,写动作本身即触发
*(volatile uint16_t*)addr = data;

为什么编程不用 STRT:

编程的触发条件是"向 Flash 地址执行一次写总线操作"。

硬件看到 PG=1 且出现 Flash 写访问,就自动开始编程------不需要额外的启动位。

而擦除没有"写地址"这个动作(擦除是整页操作),必须用 STRT 手动启动。

5.6 阶段六:等待完成------操作期间 CPU 只能等

复制代码
while(FLASH->SR & FLASH_SR_BSY);

细节:

  • 擦除 1KB 页:典型耗时 20~40ms;
  • 编程 1 个半字:典型耗时 40~50µs;
  • 这段时间内不要再发起任何 Flash 操作;
  • 擦除期间(F1)不能从被擦除的 Flash 执行代码------因为 Flash 接口正忙,取指会失败。擦除/编程函数本身要放在 SRAM 里跑,或者保证代码不在被操作的页上。

5.7 阶段七:清除标志------写 1 清 0

cpp 复制代码
FLASH->SR |= FLASH_SR_EOP;          // 清除完成标志
FLASH->SR |= FLASH_SR_PGERR;        // 若有编程错误,清除(也便于下次判断)
FLASH->SR |= FLASH_SR_WRPRTERR;     // 若有写保护错误,清除

为什么必须清:

EOP、PGERR、WRPRTERR 是粘性标志(置 1 后保持)。

不清除的话,下次判断会误以为"出错"或"完成"。

写 1 清 0,写 0 无效。

5.8 阶段八:退出模式------用完必清

复制代码
FLASH->CR &= ~FLASH_CR_PER;   // 退出页擦除模式
FLASH->CR &= ~FLASH_CR_PG;    // 退出编程模式

为什么必须退:

模式位不清,之后任何一次误写 Flash 地址都会触发编程/擦除,可能毁掉数据。

用完即清,是 Flash 驱动的基本素养。

5.9 阶段九:加锁------最后一道保险

复制代码
FLASH->CR |= FLASH_CR_LOCK;

为什么最后加锁:

加锁后所有模式位写不进去,Flash 回到"只读"状态。

此时即使程序跑飞乱写寄存器,也无法触发擦除/编程------把"能破坏 Flash"的窗口缩到最小。

5.10 完整带错误处理的工作流程代码(F1 寄存器版)


六、HAL 库操作(对照)

6.1 F1 页擦除

cpp 复制代码
FLASH_EraseInitTypeDef erase;
uint32_t page_error = 0;

HAL_FLASH_Unlock();                      // ① 解锁(内部帮你写了 KEYR 两密钥)
erase.TypeErase   = FLASH_TYPEERASE_PAGES;
erase.PageAddress = 0x08080000;
erase.NbPages     = 1;
HAL_FLASHEx_Erase(&erase, &page_error);  // ②③④⑤ 擦除(内部完成全部流程)
HAL_FLASH_Lock();                        // ⑥ 加锁

6.2 F4 扇区擦除

cpp 复制代码
FLASH_EraseInitTypeDef erase;
uint32_t sector_error = 0;

HAL_FLASH_Unlock();
erase.TypeErase    = FLASH_TYPEERASE_SECTORS;
erase.Sector       = FLASH_SECTOR_6;
erase.NbSectors    = 1;
erase.VoltageRange = FLASH_VOLTAGE_RANGE_3;
HAL_FLASHEx_Erase(&erase, &sector_error);
HAL_FLASH_Lock();

6.3 编程

cpp 复制代码
HAL_FLASH_Unlock();
HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, 0x08080000, 0x1234);  // F1
// HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, 0x08080000, 0x12345678); // F4
HAL_FLASH_Lock();

HAL 库帮你包好了"等 BSY、清 EOP、模式切换"这些脏活,但内部流程和我们手写的完全一致。


七、选项字节(Option Bytes)

7.1 配置项总表

配置项 作用
RDP(读保护) 防调试器/程序读出 Flash
WRP(写保护) 防误擦写指定页
IWDG_SW 软件看门狗 / 硬件看门狗
BOR 级别 掉电检测阈值
nBOOT1 启动模式配置

7.2 硬件看门狗(与 IWDG 联动)

cpp 复制代码
IWDG_SW = 0:硬件看门狗 → 上电后 IWDG 自动启动,软件关不掉
IWDG_SW = 1:软件看门狗 → 需软件写 KR=0xCCCC 启动

7.3 读保护级别

级别 含义
Level 0 无保护
Level 1 禁止调试口读 Flash(SWD/JTAG)
Level 2 永久锁定,无法降级(变砖)

八、实战:掉电保存数据

cpp 复制代码
#define SAVE_ADDR  0x08080000   // 保存区(Flash 最后 1KB 页,避开程序)

uint16_t Flash_ReadData(void)
{
    return *(volatile uint16_t*)SAVE_ADDR;   // 读(无需解锁,读不受锁影响)
}

void Flash_SaveData(uint16_t data)
{
    // 完整 9 阶段流程(见 5.10 完整代码)
    Flash_WriteHalfWord(SAVE_ADDR, data);
}

int main(void)
{
    uint16_t counter = Flash_ReadData();
    counter++;
    // ... 业务 ...
    Flash_SaveData(counter);   // 掉电前保存
}

注意:保存区必须编译链接时排除(改分散加载/链接脚本),否则程序代码会写进这一页。


九、工程坑位总结

坑 原因 解决
写前不擦除 → 数据不对 Flash 只能 1→0 必须先擦整页
擦除时卡死 Flash 接口忙 + 不能执行 Flash 内代码 等 BSY;擦写函数放 SRAM
中断里操作 Flash 擦写时间长,中断打断 关中断或避开
WRPERR 目标区被 WRP 写保护 / 未解锁 检查选项字节
PGERR 未擦除 / 地址未对齐 先擦后写、按半字/字对齐
模式位没清 之后误写地址触发编程 用完即清 PG/PER
Flash 越用越慢/坏 寿命 ~1 万次 模拟 EEPROM 磨损均衡
调试改了 Flash SWD 直接写 生产前设读保护

面试题(30 道,答案同页)

一、基础概念(原 20 道保留)

Q1. Flash 和 SRAM 的本质区别?

A:

Flash 非易失(掉电保留),存代码和长期数据;

SRAM 易失,存运行变量。

Flash 需擦除才能重写且寿命有限。


Q2. 为什么 Flash 写之前必须擦除?

A:

编程只能把位从 1 变成 0,不能 0→1。

只有擦除能把整页恢复成全 1,所以修改已为 0 的位必须先擦除。


Q3. F1 和 F4 的 Flash 擦除单位区别?

A:

F1 页式 1KB/页;F4 扇区式 32KB/128KB/256KB。

F4 改一个字节也得擦整个扇区。


Q4. 解锁 Flash 为什么要写两把密钥?

A:

防止程序跑飞后误擦 Flash。

连续写对 0x45670123 和 0xCDEF89AB 概率极低,几乎不可能误解锁。


Q5. FLASH_SR 有哪些标志位?

A:

BSY(忙)、PGERR(编程错误)、WRPRTERR(写保护错误)、EOP(完成)。操作前查 BSY,出错查 PGERR/WRPRTERR,完成后清 EOP。


Q6. 擦除和编程的操作流程?

A:

解锁→等 BSY=0→设 CR 模式(PER/PG)→设 AR→触发(擦除写 STRT,编程直接写地址)→等 BSY=0→清 EOP→退出模式→加锁。


Q7. 擦除/编程时为什么不能执行 Flash 内代码?

A:

操作期间 Flash 接口被占用,无法响应取指。

F1 从 Flash 取指会停顿卡死。

解决:擦写函数放 SRAM 执行。


Q8. 什么是选项字节?能配置什么?

A:

Flash 独立小区域,配置硬件行为:

RDP 读保护、WRP 写保护、IWDG_SW 硬件看门狗、BOR 阈值、启动模式。

上电自动读取。


Q9. 硬件看门狗选项是什么意思?

A:

IWDG_SW=0 时 IWDG 上电自动启动,无需软件写 0xCCCC,软件也关不掉。

保证代码没跑起来狗已在监控。


Q10. 读保护 RDP 的三个级别?

A:

Level 0 无保护;

Level 1 禁止调试口读 Flash;

Level 2 永久锁定无法降级(变砖)。


Q11. 模拟 EEPROM 是什么?为什么需要?

A:

用 Flash 模拟按字节写的 EEPROM。

因 Flash 擦除单位大、寿命短,通过"写满一页才擦"、磨损均衡、日志式写入延长寿命。


Q12. Flash 擦写寿命多少?

A:

典型 10,000 次。

频繁擦写区域要设计磨损均衡。


Q13. 掉电保存数据的一般步骤?

A:

① 规划程序用不到的页;

② 保存:读旧值→擦整页→写新值;

③ 上电从固定地址读回。


Q14. 为什么写 0xFF 等于"无操作"?

A:

0xFF 全位为 1,编程只能 1→0。

写 0xFF 不改变任何位,所以对全 1 区域写无效,必须先擦除。


Q15. 编程地址为什么要对齐?

A:

F1 按半字(16bit)对齐,F4 按字(32bit)。

未对齐触发 PGERR。


Q16. 预取缓冲和等待状态是什么?

A:

CPU 主频高时 Flash 读取跟不上,需插等待周期(WS)。

预取缓冲提前取指减少等待。

ACR 配置。


Q17. 程序能直接从 Flash 执行吗?

A:

能。

Flash 在统一编址空间(0x08000000),CPU 经 ICode 总线直接取指(XIP)。

比外部 SPI Flash 快。


Q18. OTP 区域是什么?

A:

一次性可编程区,只能写一次不可擦除。存序列号、MAC、密钥。


Q19. 手撕:解锁和加锁。

A:

cpp 复制代码
void Flash_Unlock(void) {
    FLASH->KEYR = 0x45670123;
    FLASH->KEYR = 0xCDEF89AB;
}
void Flash_Lock(void) {
    FLASH->CR |= FLASH_CR_LOCK;
}

二、寄存器专题

Q21. FLASH_ACR 的 LATENCY 是干什么的?F103 在 72MHz 下应设多少?

A:

Flash 读取等待状态。

CPU 主频高过 Flash 速度时插入等待周期。

F103 在 72MHz(2.7~3.6V)下应设 2 个等待。

设错会导致取指错乱、程序跑飞。


Q22. FLASH_CR 的 PG、PER、MER 分别是什么?能同时置位吗?

A:

PG=编程模式、PER=页擦除模式、MER=整片擦除模式。

三个互斥,一次只能选一个,用完必须清除。


Q23. STRT 位是什么?为什么编程不需要它?

A:

STRT 是擦除启动位,写 1 启动当前模式的擦除。

编程不需要,因为编程的触发条件是"向 Flash 地址写数据"这个总线动作本身。


Q24. LOCK 位为什么只能写 1 不能写 0?

A:

上锁容易,解锁必须走 KEYR 两密钥流程。

防止程序跑飞后直接写 LOCK=0 解锁然后乱擦写。


Q25. FLASH_AR 什么时候用?什么时候不用?

A:

页擦除时用(指定目标页,需页对齐);

整片擦除和编程不用------整片擦除不需要地址,编程地址就是写数据的地址本身。


Q26. EOP 标志为什么复位值是 1?

A:

芯片设计让"未发生操作"也表现为完成。

真正判断操作完成要看 BSY 从 1 变 0,EOP 只作可选确认。

用完写 1 清除。


Q27. OPTKEYR 的密钥是什么?什么时候用?

A:

0x08192A3B 和 0x4C5D6E7F。

修改选项字节(读保护、写保护、硬件看门狗)前必须先解锁 OPTKEYR。


Q28. OBR 寄存器能读出什么?

A:

只读,反映当前选项字节状态:OPTERR 校验错误、RDPRT 读保护级别、IWDG_SW 看门狗模式、BOR_LEV 掉电阈值、nBOOT1 启动配置。


Q29. WRPR 写保护寄存器和选项字节 WRP 什么关系?

A:

WRPR 是只读反映位,真正的写保护配置在选项字节里。

WRPR 每一位对应 4 页(F1),1=该组被写保护,不可擦写但可读。


Q30. 手撕:说出 Flash 解锁失败后如何恢复。

A:

解锁失败(密钥写错)不会损坏 Flash,只是保持锁定。

只需重新按顺序写两把密钥即可,不需要复位。

若操作中产生 PGERR/WRPRTERR,先清标志再重新走流程。


完整实现EEPROM(F103 寄存器版)

头文件 eeprom_sim.h

cpp 复制代码
#ifndef __EEPROM_SIM_H
#define __EEPROM_SIM_H

#include <stdint.h>

/* 参数配置 */
#define EEPROM_PAGE_SIZE   1024          /* F103 一页 1KB */
#define EEPROM_PAGE0       0x08080000    /* 页0(避开程序区,需链接排除) */
#define EEPROM_PAGE1       0x08080400    /* 页1 */
#define EEPROM_SLOT_SIZE   4             /* 每条记录 4 字节:数据(2B)+状态(2B) */
#define EEPROM_SLOTS       (EEPROM_PAGE_SIZE / EEPROM_SLOT_SIZE)  /* 每页 256 槽 */

/* 状态字节 */
#define STATUS_EMPTY       0xFFFF        /* 空槽(擦除态) */
#define STATUS_VALID       0xAAAA        /* 有效记录 */
#define STATUS_INVALID     0x0000        /* 无效记录 */

/* 对外接口 */
void     EEPROM_Init(void);
uint16_t EEPROM_Read(uint16_t default_val);
void     EEPROM_Write(uint16_t data);

#endif

底层 Flash 驱动

cpp 复制代码
/* Flash 底层:解锁、擦页、写半字 */
static void Flash_Unlock(void)
{
    FLASH->KEYR = 0x45670123;
    FLASH->KEYR = 0xCDEF89AB;
}

static void Flash_Lock(void)
{
    FLASH->CR |= FLASH_CR_LOCK;
}

/* 擦除一页 */
static void Flash_ErasePage(uint32_t page_addr)
{
    while(FLASH->SR & FLASH_SR_BSY);
    FLASH->CR |= FLASH_CR_PER;            /* 页擦除模式 */
    FLASH->AR  = page_addr;               /* 目标页 */
    FLASH->CR |= FLASH_CR_STRT;           /* 启动 */
    while(FLASH->SR & FLASH_SR_BSY);
    FLASH->SR |= FLASH_SR_EOP;
    FLASH->CR &= ~FLASH_CR_PER;           /* 退出模式 */
}

/* 编程一个半字 */
static void Flash_ProgramHalfWord(uint32_t addr, uint16_t data)
{
    while(FLASH->SR & FLASH_SR_BSY);
    FLASH->CR |= FLASH_CR_PG;             /* 编程模式 */
    *(volatile uint16_t*)addr = data;     /* 写数据即触发 */
    while(FLASH->SR & FLASH_SR_BSY);
    FLASH->SR |= FLASH_SR_EOP;
    FLASH->CR &= ~FLASH_CR_PG;            /* 退出模式 */
}

模拟 EEPROM 核心实现 eeprom_sim.c

cpp 复制代码
#include "eeprom_sim.h"
#include "stm32f10x.h"   /* 按你的工程改头文件 */

static uint32_t g_cur_page;     /* 当前写页 */
static uint16_t g_last_valid;   /* 缓存最新值(加速读取 + 翻页用) */

/* ---------- 槽位访问辅助 ---------- */
static uint16_t slot_data(uint32_t page, uint16_t idx)
{
    return *(volatile uint16_t*)(page + idx * EEPROM_SLOT_SIZE);
}

static uint16_t slot_status(uint32_t page, uint16_t idx)
{
    return *(volatile uint16_t*)(page + idx * EEPROM_SLOT_SIZE + 2);
}

/* 写槽位数据(此时状态还是空,读不到) */
static void slot_write_data(uint32_t page, uint16_t idx, uint16_t data)
{
    Flash_ProgramHalfWord(page + idx * EEPROM_SLOT_SIZE, data);
}

/* 数据写完后,把状态 0xFFFF → 0xAAAA(标记有效,1→0 无需擦除) */
static void slot_mark_valid(uint32_t page, uint16_t idx)
{
    Flash_ProgramHalfWord(page + idx * EEPROM_SLOT_SIZE + 2, STATUS_VALID);
}

/* ---------- 核心查询:从页尾向前找最后一条有效记录 ---------- */
static int find_last_valid(uint32_t page, uint16_t *idx, uint16_t *data)
{
    int i;
    for (i = EEPROM_SLOTS - 1; i >= 0; i--) {
        if (slot_status(page, i) == STATUS_VALID) {
            *idx  = (uint16_t)i;
            *data = slot_data(page, i);
            return 1;
        }
    }
    return 0;   /* 页里没有有效记录 */
}

/* 找第一个空槽;页满返回 -1 */
static int find_first_empty(uint32_t page)
{
    int i;
    for (i = 0; i < EEPROM_SLOTS; i++) {
        if (slot_status(page, i) == STATUS_EMPTY)
            return i;
    }
    return -1;
}

/* ---------- 初始化:扫描两页,确定当前写页和最新值 ---------- */
void EEPROM_Init(void)
{
    uint16_t d0, d1, i0, i1;
    int f0 = find_last_valid(EEPROM_PAGE0, &i0, &d0);
    int f1 = find_last_valid(EEPROM_PAGE1, &i1, &d1);

    if (!f0 && !f1) {
        /* 全新芯片:两页都空,用页0 */
        g_cur_page   = EEPROM_PAGE0;
        g_last_valid = 0;
    } else if (f0 && !f1) {
        g_cur_page   = EEPROM_PAGE0;
        g_last_valid = d0;
    } else if (!f0 && f1) {
        g_cur_page   = EEPROM_PAGE1;
        g_last_valid = d1;
    } else {
        /* 两页都有有效记录:取"槽序号更大"的一页(更新的日志) */
        if (i0 >= i1) {
            g_cur_page   = EEPROM_PAGE0;
            g_last_valid = d0;
        } else {
            g_cur_page   = EEPROM_PAGE1;
            g_last_valid = d1;
        }
    }
}

/* ---------- 读取:优先当前页,找不到再查另一页 ---------- */
uint16_t EEPROM_Read(uint16_t default_val)
{
    uint16_t data, idx;
    if (find_last_valid(g_cur_page, &idx, &data))
        return data;
    /* 当前页无有效记录(可能上次翻页途中掉电),查另一页 */
    {
        uint32_t other = (g_cur_page == EEPROM_PAGE0) ? EEPROM_PAGE1 : EEPROM_PAGE0;
        if (find_last_valid(other, &idx, &data))
            return data;
    }
    return default_val;   /* 从未写过 */
}

/* ---------- 写入 ---------- */
void EEPROM_Write(uint16_t data)
{
    int idx = find_first_empty(g_cur_page);   /* 找当前页空槽 */

    if (idx < 0) {
        /* 页满 → 页翻转 */
        uint32_t other = (g_cur_page == EEPROM_PAGE0) ? EEPROM_PAGE1 : EEPROM_PAGE0;

        /* ① 先擦备用页 */
        Flash_Unlock();
        Flash_ErasePage(other);
        Flash_Lock();

        /* ② 把最新值写入新页槽0(先数据后状态,掉电安全) */
        Flash_Unlock();
        slot_write_data(other, 0, g_last_valid);
        slot_mark_valid(other, 0);
        Flash_Lock();

        /* ③ 再擦旧页(此时最新值已在 other 页,旧页可安全擦除) */
        Flash_Unlock();
        Flash_ErasePage(g_cur_page);
        Flash_Lock();

        /* ④ 切换当前写页,新页槽0已被占用,下一个空槽是槽1 */
        g_cur_page = other;
        idx = 1;
    }

    /* 写入新记录:先写数据,再标有效 */
    Flash_Unlock();
    slot_write_data(g_cur_page, (uint16_t)idx, data);
    slot_mark_valid(g_cur_page, (uint16_t)idx);
    Flash_Lock();

    g_last_valid = data;   /* 更新缓存 */
}

测试 main.c

cpp 复制代码
#include "eeprom_sim.h"
#include <stdio.h>

int main(void)
{
    uint16_t boot_count;

    EEPROM_Init();                        /* ① 初始化(扫描两页) */

    boot_count = EEPROM_Read(0);          /* ② 读回上次的计数值 */
    boot_count++;                         /* ③ 本次开机 +1 */
    printf("boot count: %d\n", boot_count);

    EEPROM_Write(boot_count);             /* ④ 保存(写一个新槽) */

    while(1);
}

为什么 Flash 有半字、单字、双字写入

一句话答案:

"最小写入单位"由 Flash 存储阵列的物理结构决定------编程不是"写一个字节",而是"沿一行字线、给整行单元并行注入电荷"。

这行有多宽,最小编程单位就是多宽。

F1 的编程行是 16bit,所以只能半字写;

F4 的行是 32bit,所以是字写;F7/H7 的行更宽,支持 64bit 双字。

一、先分清三个粒度

操作 本质 F1 (F103) F4 (F407)
读取 译码寻址,读任意字节 按字节读 按字节读
编程(写) 向浮栅注入电荷 半字 16bit 字 32bit(大容量型号支持双字 64bit)
擦除 从浮栅抽取电荷 整页 1KB 整扇区 32KB+

读可以按字节,写不行,擦除更是一整块------因为三者是完全不同的物理过程。


二、编程最小单位的物理本质

Flash 存储单元是浮栅晶体管,阵列组织是"字线 × 位线":

cpp 复制代码
Flash 存储阵列(示意,每行 = 一次编程操作的单位)

       位线0   位线1   位线2  ...  位线15   ← 一"行"共 16 条位线(F1 半字)
         │       │       │          │
字线0  ──┼─[单元]─┼─[单元]─┼──[单元]──┼──  ──► 编程时整行一起注入电荷
字线1  ──┼─[单元]─┼─[单元]─┼──[单元]──┼──
  │      │       │       │          │

关键机制:

  1. 编程 = 在字线上加高压,同时把数据锁存到这一行的**编程缓冲器(write buffer/latch)**里;
  2. 缓冲器里是 1 的位不注入电荷(保持浮栅空),是 0 的位注入电荷(浮栅充电 → 阈值电压升高 → 读出为 0);
  3. 整行单元是一次性并行操作的------硬件只提供"一行"的电荷注入通路,这就是最小编程单位;
  4. 这一行连了多少条位线,最小单位就是多少 bit。

三、为什么不同系列不一样宽

因素 F1 (F103) F4 (F407) F7/H7
内核 Cortex-M3(32bit) Cortex-M4(32bit) Cortex-M7(双发射,取指 64bit)
制造工艺 较老(90nm 级) 较新(65nm 级) 更新(40nm 级)
编程最小单位 半字 16bit 字 32bit(低容量)/ 双字 64bit(高容量) 双字 64bit(部分 256bit)
擦除单位 页 1KB 扇区 32KB+ 扇区 32KB/64KB/128KB

具体原因:

  1. 总线与内核宽度:CPU 数据总线是 32bit,F1 时代 Flash 接口按 16bit 组织(半字),一次写操作对应一条物理编程行;F4 把编程行加宽到 32bit,与总线对齐;M7 内核取指是 64bit,Flash 接口随之加宽到 64bit------接口越宽,编程行越宽,最小单位越大。
  2. 工艺与面积成本:编程行加宽意味着同一时刻并行注入电荷的单元更多,需要更大的电荷泵(charge pump)输出电流、更粗的电源/地走线、更大的存储单元面积。老工艺做不了太宽的编程行,新工艺可以。
  3. 性能权衡:行越宽,一次编程写入的数据越多(编程时间几乎不变),写入吞吐率越高。F7 双字写入比 F1 半字写入快 4 倍。

四、为什么不能按字节写?(物理限制)

**你可能想问:**为什么 EEPROM 能按字节写,Flash 不行?

  1. 编程干扰(Program Disturb):给一行加高压时,同一字线上的其他单元也会受到应力,可能被误编程。行越窄,受影响的邻单元越少。Flash 追求密度,用"整行并行"来换取面积,代价就是最小单位大。
  2. 电荷泵能力:按字节写要单独给一个单元加高压,而 Flash 单元的写入电压要靠片内电荷泵从 1.8V/3.3V 升到 ~10V。一次驱动整行的电流预算固定,并行写整行比逐个写字节更高效(总时间更短)。
  3. 架构继承:NOR Flash 最初就是按"行(word line)"组织的,半导体厂的 Flash IP 行宽固定,MCU 只是把这一行宽暴露给用户:F1 暴露 16bit、F4 暴露 32bit。

外部 I2C EEPROM 按字节写,是因为它的存储密度低、单元大、工艺容忍逐字节操作------代价是容量小(Kbit 级)、速度慢。

内部 Flash 追求高密度大容量,就得接受"整行写、整块擦"的物理约束。


五、对开发者的实际影响

影响点 说明
地址对齐 F1 半字写必须 2 字节对齐;F4 字写必须 4 字节对齐;未对齐 → PGERR
模拟 EEPROM 槽位设计 槽位宽度必须 ≥ 编程单位:F1 槽位 4B(2B 数据+2B 状态)没问题;F4 上要按 4/8B 设计,状态字段最好用完整字
写数组/结构体 不能直接 memcpy 到 Flash------必须按半字/字逐个编程
跨行边界 一个"数据项"不能横跨两个编程行边界,否则要拆成两次操作

面试题

Q1. F1 和 F4 的 Flash 编程最小单位分别是什么?

A:

F1 半字 16bit(2 字节对齐),F4 字 32bit(4 字节对齐),大容量 F4 及 F7 支持双字 64bit。


Q2. 为什么编程单位是半字/字而不是字节?

A:

编程是物理上"整行并行注入电荷",行宽由存储阵列的位线数量决定。

Flash 以行组织追求密度,最小编程单位 = 行宽,无法像 EEPROM 那样逐字节操作。


Q3. 编程单位跟什么硬件有关?

A:

内核总线宽度(M3 32bit / M7 64bit)、制造工艺(新工艺能支撑更宽的编程行和更大的电荷泵)、Flash IP 的存储阵列组织。


Q4. 未对齐写会怎样?

A:

触发编程错误标志 PGERR,数据不写入。

F1 写 3 字节对齐地址、F4 写 2 字节对齐地址都会报错。


Q5. F4 上做模拟 EEPROM 要注意什么?

A:

槽位按 4/8 字节对齐设计;

扇区最小 32KB,擦除代价大,两页翻转要占 64KB;

编程单位是字,状态字段也要用完整字写入。


Q6. 为什么读取可以按字节而写入不行?

A:

读取是低电压译码寻址,不改变电荷,可以任意寻址任意字节;

写入需要升压、整行并行注入电荷,只能按行(编程单位)操作。


相关推荐
木子单片机1 小时前
基于单片机的DS18B20温度传感器测温系统设计
单片机·嵌入式硬件·51单片机·keil
华清远见IT开放实验室1 小时前
实验室建设案例 | 安徽三联学院物联网实验室
stm32·单片机·嵌入式硬件·物联网·华清远见
一只鹿鹿鹿2 小时前
智算算力、AI-芯片与产品解析(PPT文件)
大数据·运维·网络·人工智能·物联网·1024程序员节
Zw-awa2 小时前
串口为什么会丢字节:从轮询到中断和环形缓冲区
stm32
xwz小王子2 小时前
Science Advances 让机器人触觉在重压下继续分辨微小变化
机器人·1024程序员节
小小AK3 小时前
聚水潭售后单到金蝶退货单同步实战:从增量拉取到目标单据落库的全流程拆解
1024程序员节
深圳光特通信豆子3 小时前
TTL 光模块在工业控制与串口通信中的应用指南(10M BIDI 单模系列)
单片机·嵌入式硬件
桀人3 小时前
SPI概念引入
stm32·单片机·嵌入式硬件
离凌寒4 小时前
一、基于stm32+freertos下LD2410D和R60ADB1两款雷达的使用效果
stm32·单片机·嵌入式硬件