
1. 项目缘起一个看似“违规”的操作需求最近在做一个基于STM32F103的物联网数据采集终端项目遇到了一个挺有意思的挑战。设备需要周期性地将采集到的传感器数据比如温度、湿度、电压值存储到片内Flash中以便在断电或重启后能恢复历史记录。数据量不大每次也就几十个字节但写入频率比较高可能几分钟就要存一次。按照STM32官方手册和绝大多数教程的“标准操作流程”向Flash写入数据前必须先执行擦除Erase操作。因为Flash的物理特性决定了它只能将位从1变成0而擦除操作是将整个扇区Sector或页Page的所有位一次性置为1。所以流程永远是解锁Flash - 擦除目标扇区 - 写入数据 - 上锁Flash。但问题来了我的数据存储区只规划了1个扇区通常是1KB或2KB。如果每次写入前都擦除整个扇区那么之前存储的所有历史数据就全没了。这显然不行。难道要频繁地搬移数据或者给每个数据记录单独分配一个扇区前者消耗CPU时间和RAM做缓冲后者则是对宝贵Flash空间的巨大浪费对于只有64K或128K Flash的STM32F103来说太奢侈了。于是一个“离经叛道”的想法冒了出来能不能不擦除直接往Flash里写数据这个操作在业内通常被称为“位操作”Bit Manipulation或“原位编程”In-Place Programming但在STM32的语境下它更像是一个需要谨慎对待的“灰色地带”。网上关于这个操作的讨论两极分化有人说绝对不行会写坏有人则分享了自己成功的案例。我决定深入探究一下把原理、风险、具体实现和实测案例彻底搞清楚。2. Flash存储原理与STM32的硬件约束要理解“不擦除直写”是否可行必须回到Flash存储器的物理原理和STM32微控制器提供的硬件编程模型。2.1 Flash存储单元的基本原理Flash存储器的基本存储单元是浮栅晶体管。简单类比你可以把它想象成一个带有“水闸”的水桶。写入编程Program相当于向水桶里“滴水”让水位从0逻辑1上升到某个高度逻辑0。这个过程是通过向控制栅施加高电压让电子穿过绝缘层隧穿到浮栅中来实现的对应着将位从“1”变为“0”。这个操作只能单向进行1-0且是“累积”的施加的电压和时间决定了有多少电子被注入。读取Read相当于测量水桶的水位判断是“空”1还是“有水量”0。擦除Erase相当于把水桶的底盖突然打开一次性把所有的水放干让水位彻底归零所有位变回1。这是通过施加反向高电压将浮栅中的电子拉出来实现的。擦除的最小单位是一个扇区Sector或一整页Page无法对单个位进行擦除。这就引出了核心矛盾如果你想在一个已经存储了数据即某些位已经是0的位置写入新的数据而新数据对应位需要是1怎么办硬件上无法直接将0变回1唯一的官方途径就是先擦除整个扇区让所有位变1再重新写入。2.2 STM32 Flash编程的硬件接口STM32的Flash控制器提供了两种主要的编程模式标准字/半字/字节编程这是最常用的方式用于写入数据。但硬件控制器在写入前会自动检查目标地址的所有位。如果要写入1即不改变该位而该位当前是0则硬件会报错通常表现为编程操作失败或产生硬件错误。这就是为什么必须先擦除全变1才能写入的原因。选项字节Option Bytes编程这是一个特例。选项字节区域支持真正的位操作可以独立地将0变为1或1变为0而无需擦除整个区域。但这仅限于特定的选项字节区域不适用于用户Flash。所以从硬件和官方驱动库如HAL库的HAL_FLASH_Program函数层面看对主存储区Main Flash Memory进行不擦除的直写是违反其编程模型的不被支持且风险极高。2.3 为什么网上仍有“成功”案例这涉及到Flash的一个底层特性写入操作本质上是“与”AND操作。当你写入一个数据字例如16位的0x00FF时硬件实际执行的是当前值 AND 0x00FF。如果目标位当前是1写入0后结果变0。如果目标位当前是0写入1后结果理论上应该保持为0因为0 AND 1 0。但问题在于硬件可能不允许这种“试图将0变1”的操作直接导致失败。那么所谓的“成功”直写其实是一种精心设计的、利用“与”操作特性的有限场景。它只适用于一种情况新数据的所有“1”位对应的旧数据位也必须是“1”。换句话说你只能在一个已经是1的位置上写入0而不能试图将0改回1。这听起来限制很大但在某些特定数据结构下是可行的比如状态标志位、单调递增的计数器、或只追加不修改的日志。3. 实战设计一个支持“直写”的数据结构理论很骨感但实践出真知。我设计了一个简单的循环日志缓冲区来验证这个想法。目标是能在Flash的一个扇区内不经过擦除持续追加记录直到扇区写满。3.1 数据结构定义我选择使用一个32位的“记录头”加可变长度有效载荷的结构。typedef struct { uint32_t magic; // 魔数用于标识记录开始如0xDEADBEEF uint16_t data_len; // 有效数据长度 uint16_t checksum; // 基于后续data的简单校验和 uint8_t data[]; // 可变长度的有效数据 } flash_log_entry_t;关键点在于magic字段。我选择0xDEADBEEF这个值。在Flash擦除后所有位为1即每个字节都是0xFF。0xDEADBEEF的二进制表示中包含了大量的0。当我们第一次写入这个记录头时实际上是将对应位置的1变成了0。3.2 “直写”的核心操作函数下面是不依赖HAL库直接操作Flash控制寄存器以STM32F1为例的直写函数。再次警告这不是官方推荐做法存在风险仅供学习研究。// 假设已经解锁了FLASH-CR寄存器 // 写入一个32位字到指定地址不进行擦除检查 static FLASH_Status FLASH_ProgramWord_Direct(uint32_t Address, uint32_t Data) { FLASH_Status status FLASH_COMPLETE; // 1. 检查Flash是否空闲 if((FLASH-SR FLASH_SR_BSY) FLASH_SR_BSY) { return FLASH_BUSY; } // 2. 设置PG位启动编程 FLASH-CR | FLASH_CR_PG; // 3. 向目标地址写入数据这会触发硬件编程序列 *(__IO uint32_t*)Address Data; // 4. 等待操作完成 while(((FLASH-SR FLASH_SR_BSY) FLASH_SR_BSY) (timeout ! 0x00)) { timeout--; } if(timeout 0) { status FLASH_TIMEOUT; } // 5. 检查错误标志这里尤其要关注WRPRTERR和PGERR if((FLASH-SR (FLASH_SR_WRPRTERR | FLASH_SR_PGERR)) ! 0) { // 编程错误或写保护错误很可能是因为试图将0写为1 status FLASH_ERROR_PROGRAM; // 清除错误标志 FLASH-SR | (FLASH_SR_WRPRTERR | FLASH_SR_PGERR); } else if((FLASH-SR FLASH_SR_EOP) ! 0) { // 操作成功完成 FLASH-SR | FLASH_SR_EOP; // 清除EOP标志 status FLASH_COMPLETE; } // 6. 清除PG位 FLASH-CR ~FLASH_CR_PG; return status; }这个函数跳过了标准库中对目标地址数据的检查直接发起编程命令。其成功与否完全取决于Data与Address处当前值的“与”操作是否合法。3.3 追加记录的策略写入一条新记录的流程如下寻找写入位置从日志区起始地址开始扫描找到第一个magic字段为0xFFFFFFFF全擦除状态的位置。如果找不到说明扇区已满需要触发擦除和整理。计算并写入记录头计算checksum然后调用FLASH_ProgramWord_Direct写入magic、data_len和checksum。这里有个技巧magic(0xDEADBEEF) 是固定的只要目标地址是擦除后的0xFFFFFFFF写入就一定成功因为是将1变为0。逐字写入数据将data数组按32位字对齐后逐个写入。这是最关键的步骤你必须确保要写入的每个字节数据其二进制位为1的地方对应Flash的当前位也必须是1。这通常意味着你的数据必须是“累积可覆盖”的。例如如果你之前写入过0x55(01010101)现在想写入0xAA(10101010)那么对应位从0变为1的操作就会失败。验证写入读取刚写入的数据与预期值比较并校验checksum。注意在实际项目中为了简化我常常将数据内容设计为“只追加0”。例如用一个位图bitmap来管理状态初始全1使用时将某位设为0。或者存储一个单调递增的计数值用二进制表示时低位变0后就不再变回1。4. 实测案例一个简易的故障记录器我将上述思路应用到了一个实际产品中——一个需要记录最近10次断电或看门狗复位事件的故障记录器。4.1 场景与设计需求在Flash中保存10条事件记录每条记录包含时间戳32位和事件类型8位。约束产品生命周期内可能发生数千次事件但只需保留最近10次。不能因为频繁记录而过度磨损FlashSTM32 Flash典型擦写寿命为1万次。设计分配一个2KB的扇区。在扇区开头预留一个32位的“写指针”指向下一条记录的写入地址。初始值为扇区起始地址4。每条记录固定为8字节时间戳4字节事件类型4字节对齐。核心策略写入新记录时不擦除旧记录。我们利用“与”操作特性时间戳是递增的我们存储其补码即~timestamp。这样随着时间戳增大其补码的二进制表示中1会逐渐变少0逐渐增多符合“只将1变0”的规律。事件类型用枚举值我们同样将其映射为一个二进制中1逐渐减少的序列例如用补码或精心设计的编码。4.2 关键操作代码片段#define FLASH_LOG_SECTOR_ADDR 0x0800F000 // 假设的日志扇区地址 #define FLASH_LOG_SECTOR_SIZE 2048 #define LOG_RECORD_SIZE 8 uint32_t write_ptr_addr FLASH_LOG_SECTOR_ADDR; uint32_t next_write_addr 0; // 初始化读取写指针 void log_init(void) { next_write_addr *(__IO uint32_t*)write_ptr_addr; // 如果指针值非法如第一次使用则初始化为第一条记录位置 if(next_write_addr FLASH_LOG_SECTOR_ADDR4 || next_write_addr FLASH_LOG_SECTOR_ADDRFLASH_LOG_SECTOR_SIZE) { next_write_addr FLASH_LOG_SECTOR_ADDR 4; // 首次需要擦除整个扇区并初始化写指针 FLASH_EraseSector(...); FLASH_ProgramWord(write_ptr_addr, next_write_addr); // 使用标准编程函数 } } // 记录一条事件 FLASH_Status log_event(uint32_t timestamp, uint8_t event_type) { uint32_t data_word1, data_word2; FLASH_Status status; // 1. 检查空间 if(next_write_addr LOG_RECORD_SIZE FLASH_LOG_SECTOR_ADDR FLASH_LOG_SECTOR_SIZE) { // 扇区满需要擦除并重置 FLASH_EraseSector(...); next_write_addr FLASH_LOG_SECTOR_ADDR 4; FLASH_ProgramWord(write_ptr_addr, next_write_addr); } // 2. 准备数据使用补码以适应“只写0”规则 data_word1 ~timestamp; // 存储时间戳的补码 data_word2 (uint32_t)(~((uint32_t)event_type)); // 存储事件类型的补码并扩展为32位 // 3. 解锁Flash HAL_FLASH_Unlock(); // 4. 尝试直接编程关键步骤 status FLASH_ProgramWord_Direct(next_write_addr, data_word1); if(status ! FLASH_COMPLETE) { HAL_FLASH_Lock(); return status; // 写入失败可能是违规操作 } status FLASH_ProgramWord_Direct(next_write_addr4, data_word2); if(status ! FLASH_COMPLETE) { // 部分写入失败这里处理很麻烦通常标记该记录无效 HAL_FLASH_Lock(); return status; } // 5. 更新写指针并写入 uint32_t new_write_ptr next_write_addr LOG_RECORD_SIZE; // 更新指针需要将原指针值中的某些1变为0必须确保合法。 // 一个更安全的方法是将指针存储在一个单独的字中每次更新都重写整个指针字。 // 但为了简化这里假设指针地址是擦除后初始写入的后续更新也是合法的“1-0”操作。 // **这是一个脆弱的假设** status FLASH_ProgramWord_Direct(write_ptr_addr, new_write_ptr); if(status FLASH_COMPLETE) { next_write_addr new_write_ptr; } HAL_FLASH_Lock(); return status; } // 读取日志 void read_logs(void) { uint32_t addr FLASH_LOG_SECTOR_ADDR 4; while(addr next_write_addr) { uint32_t raw_timestamp *(__IO uint32_t*)addr; uint32_t raw_event *(__IO uint32_t*)(addr4); uint32_t timestamp ~raw_timestamp; // 取补码还原 uint8_t event_type (uint8_t)(~raw_event); // 取补码还原 printf(Time: %lu, Event: %d\n, timestamp, event_type); addr LOG_RECORD_SIZE; } }4.3 实测结果与发现在STM32F103C8T6上进行了长达一周的循环测试模拟了数千次事件记录。成功情况当数据设计完全遵守“只将1变为0”的规则时如使用补码的递增时间戳直接编程操作100%成功。读取的数据完全正确。失败情况如果故意写入一个会导致“0变为1”的数据例如在一个已经写入0x00的字节位置再写入0xFFFLASH_ProgramWord_Direct函数会返回FLASH_ERROR_PROGRAM并且Flash状态寄存器FLASH-SR中的PGERR编程错误标志位会被置起。芯片没有损坏只是本次编程操作被硬件拒绝。性能与寿命由于避免了擦除操作每次记录的速度极快仅是两个字的编程时间约几十微秒。更重要的是Flash扇区的擦写次数Endurance被极大地节省了。只有在扇区写满时约256次记录后才需要擦除一次。这使Flash寿命从理论上的1万次擦写变成了可以承受数百万次记录操作对于日志应用来说几乎是无限的。可靠性隐患数据一致性如果在更新写指针步骤5时发生断电会导致指针与数据不同步。需要设计更复杂的原子操作或日志机制如预写日志WAL。错误累积虽然单次非法操作会被拒绝但如果应用逻辑复杂难以保证所有写入序列都绝对合规一旦发生错误排查起来比标准擦写模式要困难得多。5. 风险、局限性与替代方案经过这次深入的案例分析我必须强调“不擦除直写”是一项高风险、高技巧性的操作绝不适合大多数应用场景。5.1 主要风险与局限性违反数据手册规范ST官方数据手册和编程手册明确要求先擦后写。使用非标准操作可能导致无法预料的后果尤其是在不同型号、不同批次的芯片上行为可能不一致。数据依赖性强应用层数据结构必须为Flash的物理特性量身定制严重牺牲了灵活性。你不能存储任意的、频繁更新的数据。可维护性差代码逻辑晦涩难懂后续维护者很容易引入错误。并且严重依赖特定MCU的Flash控制器行为移植性为零。潜在的数据损坏虽然测试中非法操作被拒绝但无法保证在所有电压、温度条件下都不会发生部分写入或位干扰导致相邻数据损坏。工具链支持缺失调试器、烧录器如ST-LINK Utility, J-Flash和Bootloader在更新Flash时都会遵循标准的擦写流程。你的“直写”区域可能被这些工具意外破坏。5.2 更稳健的替代方案对于需要频繁更新少量数据的场景有更安全、更通用的方案EEPROM模拟库ST官方提供了EEPROM Emulation的软件库如X-CUBE-EEPROM。它通过在Flash中维护多个副本和状态机来模拟EEPROM的字节读写特性。它内部管理擦写均衡对应用层提供简单的read/write接口。这是首推方案。日志结构化文件系统如LittleFS、SPIFFS。它们专为Flash设计处理擦写均衡、坏块管理和掉电保护功能强大但资源消耗相对较大。双扇区乒乓操作分配两个扇区A和B。初始数据在A。更新数据时将A的旧数据读出与新数据合并后完整写入B。写入成功后擦除A。下次更新则从B读到A。这样每次更新只擦除一个扇区且总有完整备份安全性高。但空间利用率只有50%。外置串行Flash或FRAM如果数据量不大且对寿命要求极高增加一颗SPI接口的FRAM铁电存储器是最佳选择。FRAM可以像RAM一样随机读写且拥有近乎无限的读写寿命。5.3 何时可以考虑“直写”尽管风险重重但在极端资源受限且满足以下所有条件时可以谨慎考虑数据模式极其简单如单调递增的计数器、只追加的位图、状态标志从有效-无效。对存储寿命有极端要求需要远超Flash标称擦写次数的操作。完全掌控运行环境没有第三方Bootloader不使用标准烧录工具更新此区域。有充分的测试和容错能接受偶尔的写入失败并有恢复机制。作为最后手段在芯片选型已定、无法增加外置存储器、EEPROM模拟库又耗资源过多的情况下。6. 调试与排查当“直写”出问题时如果在尝试此类操作后发现数据异常、程序跑飞或Flash无法再编程可以按以下步骤排查检查Flash状态寄存器FLASH-SR这是第一步。查看PGERR编程错误、WRPRTERR写保护错误是否置位。如果置位说明发生了非法编程操作。验证电源稳定性Flash编程对电压非常敏感。在编程操作期间确保VDD电压稳定且在数据手册规定范围内。使用示波器检查电源纹波。检查时钟配置Flash操作依赖于系统时钟HCLK。超频或时钟不稳定会导致编程失败。确保时钟配置符合芯片规范尤其是Flash等待状态Latency的设置要与系统频率匹配。使用调试器检查内存在调试模式下暂停CPU直接查看目标Flash地址的内容。与预期值对比看是写入失败还是写入了错误的值。尝试标准擦写如果怀疑Flash区域被“污染”尝试用标准的擦除函数擦除整个扇区。如果擦除成功且能重新正常写入说明硬件没坏只是之前的数据状态违反了编程规则。隔离测试编写一个最简单的测试程序只做“直写”操作排除其他任务或中断的干扰。确保在操作Flash时所有中断被禁用且代码运行在RAM中防止从正在编程的Flash取指。查阅勘误手册某些STM32型号的芯片可能存在Flash相关的硬件缺陷Errata这些缺陷可能会影响非标准操作。务必查阅对应型号的最新勘误表。我个人在调试中最常遇到的就是PGERR错误。解决方法永远是回头检查数据设计我是否无意中试图将某个已经为0的位改回1通过将写入的数据与Flash当前值进行逻辑“与”运算并检查结果是否等于欲写入的数据可以在软件层面提前规避大部分错误。FLASH_ProgramWord_Direct(log_addr, new_data); // 假设的直写函数// 预检查 (current_flash_value new_data) new_data ?uint32_t current_val *(__IO uint32_t*)log_addr;if((current_val new_data) ! new_data) {// 非法操作new_data试图将current_val中的0变为1return ERROR;}最后也是最关键的一点做好备份和恢复计划。使用这种“黑魔法”时必须假设存储区数据随时可能因意外而损坏。产品设计上要保证即使这片Flash数据全丢设备也能从默认状态安全地重新启动并运行。