
这问题我太有共鸣了。几乎每个玩 STM32H5 系列的工程师只要做过参数存储、日志记录、OTA 升级都迟早会撞上一个类似的疑问程序跑着跑着往片内 FLASH 写了一批数据但我当时没单独记录“写了多少字节”事后想在内存里把这个字节数找出来到底有没有办法先说结论免得浪费大家时间STM32H5 家族的 FLASH 控制器本身没有一个“已写入字节计数器”寄存器你不能像读温度传感器一样直接读出一个数。但是这不代表这事完全没戏。“从内存地址信息反推写入量”这个思路本身没有错只是需要换一个角度根据你手上掌握的信息多少有几条完全不同的路子可走。这篇文章我就把这几条路线全部摊开讲从最基础的 FLASH 原理讲起到用调试器导出内存反推再到工程上怎么一劳永逸地让“写入字节数”变成可查询的东西。内容适合刚接触 H5 系列的嵌入式新手也适合正在排查存量问题的老手参考。1. 先彻底搞明白FLASH 里到底有什么“地址信息”可用在动手查之前必须把 STM32H5 的 FLASH 硬件机制弄清楚。很多朋友在这里有个误区以为 FLASH 控制器会像文件系统那样记录每个文件的长度实际上完全不是这么回事。1.1 为什么 FLASH 没有“写入字节计数”寄存器先纠正一个概念STM32H5 片内 FLASH 就是一个 NOR Flash控制器负责把 CPU 或 DMA 的数据搬运到存储阵列里。它关心的只有三样东西要写入的地址、要写入的数据、写操作的控制命令。写完一个编程操作状态寄存器里置一个“编程完成”标志或者如果出错置一个错误标志然后就没有然后了。硬件设计者完全没有必要在芯片里统计“你这个地址写了多少字节”。因为对于控制器来说它执行的是离散的编程指令比如 HAL_FLASH_Program(FLASH_TYPEPROGRAM_QUADWORD, addr, data) 这种一次写一条每条固定字节数它压根不关心你的“业务数据”从哪里开始、到哪里结束更不可能帮你攒一个累计值。提示这是嵌入式 FLASH 和电脑 U 盘/SSD 的关键区别。U 盘和 SSD 主控会记录写入量是因为它们要做垃圾回收和磨损均衡需要知道每个块的“脏”程度。MCU 片内 FLASH 没有这个机制也不需要。那么 STM32H5 的 FLASH 控制器到底给了我们哪些寄存器其实就几大类控制寄存器 FLASH_CR、状态寄存器 FLASH_SR、地址寄存器 FLASH_AR以及一组关于擦除、编程、校验的配置位。状态寄存器里能读到编程/擦除完成标志、写保护错误、编程错误等但没有任何一个寄存器能告诉你“这个扇区里已经写了多少有效字节”。1.2 真正可用的“地址信息”是什么虽然硬件没有计数器但 FLASH 自身有一个非常关键的物理特性可以当“地址信息”来用擦除后的状态是所有位都为 1也就是每个字节都是 0xFF而编程操作只能把 1 写成 0不能把 0 写成 1。这意味着什么假设你有一个扇区擦除之后全片都是 0xFF。你从起始地址开始写数据写入的区域会变成数据值其中可能有 0xFF 也有其他值但你写到的位置之后的区域只要没被程序再次改写过就依然是 0xFF。换句话说如果你能确定一段 FLASH 的“起始地址”和“结束地址”并且中间数据末尾之后是一片连续的 0xFF那你就能通过扫描“0xFF 边界”推算出数据大概写了多长。这是所有“事后反推”方法的理论基础。但这里有个隐藏前提你的数据末尾不能恰好是 0xFF否则边界会被混淆。后面我会专门讲这个坑。1.3 先确认两件最基础的事否则后面全白搭在动手尝试任何“查找字节数”的方法之前强烈建议先做两个确认第一确认你用的具体型号。标题里写的是 STM32H55实际上 ST 官方 H5 系列常见型号是 STM32H503 / H562 / H563 / H573 等没有严格意义上的“STM32H55”。不过这不影响思路H5 全系列的 FLASH 控制器设计逻辑是一致的都是 Cortex-M33 内核 双 Bank 结构。后面的方法对这些芯片都适用。第二确认你的数据的“写入起始地址”。这个地址可以从链接脚本.icf / .ld、map 文件、或者代码里 HAL_FLASH_Program 第一个参数看出来。如果连起始地址都不知道那反推就无从谈起只能去翻固件符号表或者反汇编工作量会大很多。2. 没有任何主动记录时如何从 FLASH 内容反推写入长度如果芯片现在就在你手里而且里面已经写了一部分数据但程序里没有保存“写入了多少字节”的变量那最现实的办法就是“内存取证”把 FLASH 内容导出来用脚本分析。2.1 最通用的办法找“全 0xFF”边界这个方法是我在实际项目里用得最多的条件也很简单已知写入起始地址。已知数据末尾之后是擦除态0xFF。数据本身的最后几个字节不是 0xFF或者 0xFF 不会连续出现一大段。操作分成三步。第一步用调试器把内存倒出来。假设你用 JLink Commander连接后直接执行savebin dump.bin 0x08040000 0x10000意思是从 0x08040000 开始读取 64KB 内容保存到 dump.bin。要是用 STM32CubeProgrammer连接后在 Memory 视图里输入地址和长度点导出也行。OpenOCD 用户可以用dump_image dump.bin 0x08040000 0x10000第二步用 Python 扫描这个 bin 文件找到“连续 N 个 0xFF”的位置。这个 N 很关键不能太小否则数据里偶尔的 0xFF 会干扰判断。我自己一般取 32 或者 64 个字节连续为 0xFF 才认为是空白区。def find_end_of_data(data, start_offset0, threshold64): ff_run 0 for i in range(start_offset, len(data)): if data[i] 0xFF: ff_run 1 if ff_run threshold: return i - ff_run 1 else: ff_run 0 return len(data)第三步用算出来的偏移量减去起始地址就是写入的数据长度。比如 dump.bin 是从 0x08040000 开始导出的扫描到第 0x1234 个字节开始是连续 0xFF那写入长度就是 0x1234 字节。这种方法有个非常实用的变体如果你的数据区是按“扇区”管理的可以一个扇区一个扇区地扫把所有“有数据的尾部偏移”列出来一眼就能看清楚每个扇区占了多少。2.2 知道数据结构时用模式匹配来精确统计0xFF 边界法比较粗糙如果数据末尾本身就包含 0xFF 字节或者你写入的是多条记录每条的尾部都带着一些 0xFF 填充那就得靠“数据结构”来精确判断了。举一个我踩过的真实例子。之前做一款电池供电设备需要往 FLASH 里按固定格式存采样数据每条记录是 32 字节字段偏移字节数说明record_header02固定魔数 0xA55Atimestamp24时间戳data_length62该条记录实际数据长度payload820数据内容crc16282整条记录校验reserved302保留位这时候想统计总共写入了多少条记录直接扫描魔数 0xA55A 即可。每遇到一个魔数就跳过 32 字节最后统计遇到的魔数个数总字节数就是 记录数 × 32精确到字节。我当时写的扫描逻辑大概是这样import struct def scan_records(data, offset0): count 0 while offset 32 len(data): if data[offset:offset2] b\x5a\xa5: count 1 # 可选解析 data_length 校验记录是否完整 offset 32 else: break return count * 32对于文本日志这种更松散的数据方法是类似的只是判断条件变成“可打印 ASCII 字符 换行符”区域扫描。沿着起始地址往后遍历只要发现一个字节不在可打印范围内就认为日志结束。这个方法的误判率取决于日志里是否包含二进制数据。2.3 带校验和时还能“试算长度”反向验证还有一种思路适用于你知道 FLASH 里存的数据带 CRC32 或 CRC16 校验值但校验值放在哪里不固定比如数据头部或尾部想通过“逐个长度试算”来确定真实写入长度。思路是这样假设有效数据从 offset 0 开始对前 1 个字节、前 2 个字节……一直到整个扇区分别计算 CRC然后和某一个候选位置的 CRC 值比对。如果某次试算得到的 CRC 和 FLASH 中实际存储的 CRC 一致那这个候选长度就很可能是真实写入长度。这个方法听起来巧妙但实际用起来限制不少你必须知道 CRC 的初值、多项式、输入输出是否反转这些参数错一个什么都对不上。如果 CRC 值和数据放在同一个扇区试算范围会非常大性能堪忧。如果数据长度超过几千字节试算的候选数量太大纯 Python 在 PC 上还好在板子上跑就完全不现实。我的建议是这个方法只适合“离线验证”已经用别的方法推断出的长度不要在嵌入式环境里依赖它。毕竟它更像一个辅助手段而不是主方案。2.4 反推方案的局限心里要有数反推听起来很美好但说实话它有三个硬伤碰上一个结果就不可靠。第一个硬伤是起始地址不确定。如果你连数据从哪个地址开始写的都不知道扫描就会变成在全片 FLASH 里大海捞针。虽然可以通过查找固件里的符号表定位写入函数再通过反汇编算出写入地址但这个工作量已经接近逆向工程了不适合日常工作。第二个硬伤是数据本身存在“整片写入”的情况。有些应用是启动时一次性把一个结构体写入扇区写入前先做整扇区擦除那么整个扇区都是数据尾部没有 0xFF。这时候用 0xFF 边界法会得到“整个扇区都是有效数据”的错误结论。第三个硬伤是曾经写过多个区域。比如扇区里既有 A 数据又有 B 数据两个数据块之间还有一些空白区那单靠扫描很难区分哪块是哪块必须结合业务逻辑去猜。所以我的个人结论是反推法适合“一次性取证”比如现场设备出问题了你需要快速了解写入状况但不适合作为产品中长期依赖的手段。工程上真正稳定的方案还得靠下面两章的内容。3. 借助调试器和固件符号直接“实时读取”写入量如果目标板现在还能连上调试器而且你的程序里其实已经有一个变量在记录写入字节数只是没存到 FLASH 里那完全不用反推直接读变量就行。这一章聊的就是怎么最高效地拿到这个值。3.1 先从 map 文件里找符号很多朋友写代码的时候都会顺手写一个全局变量比如 g_flash_write_total每次调用 FLASH 写入就累加。但因为没把它存进非易失区断电就丢了结果调完现场想起来要查。这时候只要板子还连着调试器这事就很简单。从编译生成的 .map 文件里找到这个符号的地址g_flash_write_total 0x20001234 0x4 Data这个 0x20001234 就是变量在 RAM 里的地址。你可以在调试器命令行里直接读GDB 环境p *(unsigned int *)0x20001234JLink Commander 环境mem32 0x20001234 1Keil / IAR 的 Watch 窗口里直接添加表达式 g_flash_write_total 也行。这里有个细节值得注意如果你是 Release 编译编译器可能把这个变量优化掉了map 文件里就找不到了。我遇到过好几次这种情况最后只能改代码加 volatile 重新编译烧录。所以如果一开始就打算用调试器查看变量定义时加 volatile 是个好习惯。3.2 断点法在写入函数里看每次调用的长度有时候你其实想知道的是“这轮运行总共往某段地址写入了多少字节”而不是历史累计值。这时候可以在 HAL_FLASH_Program 或者自己的写入封装函数上下断点每次断下来查看传入的地址和数据长度自己对着算。比如你的封装函数长这样void my_flash_write(uint32_t addr, uint8_t *buf, uint32_t len);在函数入口下个断点每次停下来以后用调试器查看 len 的值。如果是用 GDB可以写一个简单的命令脚本自动累计set $total 0 break my_flash_write commands silent set $total $total len printf wrote %u bytes, total%u\n, len, $total continue end这样每跑一次写入GDB 都会自动打印本次写入长度和累计长度跑完一轮测试总字节数清清楚楚。这个方法不占用任何芯片资源也不影响 FLASH 内容是我调试存储模块时最常用的方式。3.3 用 JLink / OpenOCD 导出完整内存现场如果板子已经跑到了某个异常状态你怀疑数据写入量不对但一时半会定位不到问题那就把现场内存完整导出来回头慢慢分析。JLink 命令savebin ram_dump.bin 0x20000000 0x40000OpenOCD 命令dump_image ram_dump.bin 0x20000000 0x40000把 RAM dump 和 FLASH dump 都导出来在 PC 上一起分析。这样可以同时看到运行时的变量值RAM和持久化的数据内容FLASH有时候能直接发现“变量记录的写入量”和“FLASH 里实际写入的长度”对不上那问题就出在写入逻辑里。注意连接调试器之前先确认芯片的读保护等级RDP。如果 RDP 等级是 1 或 2普通调试器可能无法读取 RAM 和 FLASH 内容或者读取时会被强制全片擦除。ST 的文档对这个讲得很清楚操作前一定先看一眼选项字节。3.4 离线设备从烧录镜像里逆向出写入逻辑还有一种极端场景板子已经不在手边只有当时烧录的 HEX/BIN 文件和源代码或者是别人的二进制。这时候想“知道写了多少字节”本质上就是对着固件做轻量级逆向。思路是以烧录镜像为线索找到调用 FLASH 编程函数的地方。先把固件放到 Ghidra / IDA 里搜 FLASH 寄存器地址比如 0x40022000 附近的访问指令定位到编程函数再往上追调用者就能看到每次写入的地址和长度。这个方案很费时间但也并不是没有价值。有一次我接手一个老项目的维护原作者留下了一段奇怪的写 FLASH 逻辑文档也没写清楚我就是靠这种方式把几处写入长度梳理出来的。不过说实话这个工作属于“下下策”。除非你真的是在维护无文档的存量代码否则我更推荐直接把时间花在下一章的设计方案上。4. 真正靠谱的方案设计时把“写入字节数”做成可查询的元数据前面讲的都是“事后补救”这章才是正经的工程方案。所有踩过坑的人都会同意在写 FLASH 之前多花几分钟设计一个简单的元数据格式后面能省下数不清的排查时间。所谓元数据就是“描述数据的数据”。对我们这个场景来说只需要在数据前面或者记录头部放几个字段魔数、版本、数据长度、校验值。读的时候先校验魔数和校验值再读长度字段写入量一目了然。4.1 最少成本的头部结构Magic Length CRC直接给一个我在很多项目里用的最小结构体typedef struct __attribute__((packed)) { uint32_t magic; // 固定魔数比如 0xA5A5A5A5 uint16_t version; // 数据格式版本 uint16_t length; // payload 有效数据长度单位字节 uint32_t crc32; // 对 payload 计算的 CRC32 uint8_t payload[]; // 实际数据 } flash_record_t;总共 12 字节头信息换来的是任何时候只要知道这条记录的起始地址就能读 length 得到写入字节数读 crc32 验证数据是否完好读 version 知道格式是否兼容。写入流程大概是擦除目标扇区。计算 payload 的 CRC32。组织头部结构体和 payload一次性写入。回读头部和关键校验字段确认写入正确。读取流程更简单读到头部检查 magic如果不对就说明这条记录不存在或已被破坏如果对length 就是当前记录的实际字节数。4.2 为什么把长度放头部而不是放尾部一个值得展开的设计细节是为什么长度字段要放在数据块的头部而不是尾部答案很简单FLASH 编程只能把 1 改成 0而且擦除以扇区为单位。如果你把长度字段放在尾部要更新长度时必须擦掉整个扇区才能改这个尾部字段这在很多场景下不可接受。而放在头部的话读取时先读到长度再决定往后读多少非常自然。当然头部也有它的注意事项。因为头部字段本身也是放在 FLASH 里的你写入时如果采用部分填充的方式比如先不全擦除就写头部那么已经写过的位不能再改回 1。所以更新记录时正确的姿势是“整扇区擦除后重写”或者设计多个固定槽位轮换使用。4.3 日志/多记录场景块头 顺序扫描如果你的应用是持续往 FLASH 里追加日志或采集数据用单个“头部 payload”的结构就不够了需要改成“一条记录一个头”的格式。每条记录定义typedef struct __attribute__((packed)) { uint16_t magic; // 记录起始魔数 uint16_t length; // 本条记录 payload 长度 uint32_t sequence; // 记录序号方便排序和查重 uint32_t timestamp; // 时间戳 uint8_t payload[]; // 可变长数据 } log_entry_t;每次追加新日志时从扇区的“当前写指针”开始写入一条新记录。那么问题来了怎么知道当前写指针在哪答案是启动时扫描一遍整个扇区从扇区头开始对每条记录做校验遇到校验失败或全 0xFF 的空白位置就是当前写指针。这样设计之后统计“总共写入了多少字节”就变成了一次顺序扫描遍历所有有效记录累加每一条的 length再加每条记录的头部长度。这个函数我建议做成内建工具函数调试时随时调用。4.4 OTA 场景镜像长度字段让升级更可控OTA 固件升级是另一个频繁需要“写入字节数”的场景。Bootloader 在把 App 固件从外部存储复制到内部 FLASH 时必须知道固件镜像有多长否则既不知道该写多少也无法判断是否写完了。所以 App 镜像里通常会有固定的镜像头。一个典型的镜像头放在 App 的起始区域紧跟在中断向量表后面typedef struct __attribute__((packed)) { uint32_t magic; // 0x4649524D FIRM之类 uint32_t image_size; // 固件镜像总长度 uint32_t version; // 固件版本号 uint32_t crc32; // 整个镜像的CRC uint32_t reserved[4]; } image_header_t;Bootloader 拿到这个头直接读 image_size就可以用循环把整个镜像从外部 Flash 搬到片内。这种设计下任何时刻想知道“这一个 App 镜像写了多少字节”读头字段就行既可靠又高效。4.5 对存量设备先写一个“体检工具函数”如果你手头已经有一批没有元数据设计的存量设备又不方便马上改存储格式那还有一个过渡办法在调试固件里加一个“扫描工具函数”专门统计特定区域内有效数据的位置。这个函数的基本逻辑就是 2.1 节的算法在板子上运行把区域内所有“数据到 0xFF 边界”的位置打印出来。我建议输出格式类似Region 0x08040000: data ends at 0x08041234, length 0x1234 Region 0x08044000: data ends at 0x08044580, length 0x580这样至少能让你的排查工作从“完全盲猜”升级到“有据可依”。等后续版本升级时再把元数据结构正式引入逐步替换。5. 调试 FLASH 时的高频故障与排查实录既然聊到 FLASH 调试顺手把我这些年碰到最多的几个故障场景一并说一下。这些错误提示在网络上也经常被搜到但大部分帖子只贴现象不给原因这里我把完整排查思路写出来。5.1 “erase failed! cannot access memory” 这类下载失败怎么破“erase failed! cannot access memory internal command error flash download failed”是调试器下载程序时最常见的报错之一通常出现在点击烧录、芯片擦除阶段。我总结下来常见原因和对应排查顺序是这样的SWD 时钟频率太高。开发板上线比较长或者干扰大的时候4MHz 的 SWD 时钟很容易失败。先降到 1MHz 或 500kHz 试试八成能成。目标板供电不足。尤其是不用开发板自带调试器而是外接 ST-Link/J-Link且目标板由调试器供电时擦除瞬间电流陡增电压跌落就会导致擦除失败。解决办法是目标板单独供电调试器只接信号线。复位电路问题。有些板子的复位引脚接了复杂的外围器件调试器拉低复位时不稳定。可以尝试把复位线拔掉只接 SWDIO/SWCLK/GND。读保护等级设置。如果芯片 RDP 等级被设到 1 以上调试器无法访问 FLASH且下载前需要先解除保护。解除操作会触发全片擦除如果有数据没备份那损失就大了所以点击“unprotect”之前一定三思。TrustZone 隔离。H5 系列支持 TrustZone如果目标代码运行在安全/非安全模式而调试器访问了被隔离的区域也会出现访问失败。这时需要检查 TrustZone 的 SAU/IDAU 配置以及调试器是否被配置为允许访问安全区。遇到这个报错我的标准处理动作是先拔掉复位线降频到 1MHz 试一次不行就单独供电再不行就查 RDP 等级。按照这个顺序90% 的问题都能解决。5.2 存储空间不足的“嵌入式版本”网络上有个常见的 PHP 报错“file_put_contents(): write of 471 bytes failed with errno28 no space left on device”这是服务器磁盘满了。很多不熟悉嵌入式的朋友觉得这跟 MCU 没关系但它的孪生兄弟在嵌入式里非常常见FLASH 空间满了写入失败。区别只在于服务器会返回 errno28而 MCU 的 HAL_FLASH_Program 通常会直接返回 HAL_ERROR或者更隐蔽地表现为“写入后回读校验失败”。所以在设计存储方案时我强烈建议在写入入口做一次容量检查uint32_t remaining SECTOR_END - g_write_ptr; if (remaining required_len HEADER_SIZE) { log_error(flash space not enough: require %u, remain %u, required_len HEADER_SIZE, remaining); return FLASH_STATUS_NO_SPACE; }一旦返回空间不足别继续写而是走“扇区轮换”或“整体归档”逻辑。这比写完以后发现数据校验错误再补救要省心太多。5.3 写 FLASH 时死机/跑飞的一个隐蔽元凶还有一个特别容易翻车的场景程序在 FLASH 里跑着同时又对同一块 FLASH 区域执行编程/擦除操作。Cortex-M33 从 FLASH 取指令的时候如果遇到了正在擦除/编程的 Bank读回来的数据可能是错的或者不稳定轻则取到错误的指令重则总线错误 HardFault。解决办法有两个方向。一是把用于执行擦写操作的关键代码放到 RAM 里跑。二是保证你在操作 FLASH 时中断服务函数不会去访问同一 Bank 的 FLASH 区必要时在擦写前后做临界区保护。H5 系列有双 Bank如果你的代码在 Bank 1 里执行只擦写 Bank 2通常没问题但如果你只有一个 Bank 的型号那就必须特别注意。有些老工程师会开玩笑说“写 FLASH 死机是入门仪式”其实说白了就是没注意执行位置问题。5.4 几个排查工具的快速对照表平时我调试存储问题会同时准备几个工具按需切换。这里整理一个对照表场景推荐工具关键命令/操作实时读变量Keil/IAR Watch 或 GDBp(unsigned int)addr导出 FLASH 内容JLink Commandersavebin dump.bin 0x08040000 0x10000导出 RAM 现场OpenOCD GDBdump_image ram.bin addr len查看扇区状态STM32CubeProgrammerMemory 视图直接浏览批量运行数据分析Python bin 文件自写扫描脚本逆向无文档固件Ghidra / IDA搜索 FLASH 寄存器地址下载失败排查ST-Link Utility / CubeProgrammer调低 SWD 频率、查 RDP这个表不用全记只要知道“遇到什么情况用哪个”即可具体命令用到的时候再查手册都来得及。写在最后的一点建议我在实际项目中摸索下来最大的体会是FLASH 的“写入字节数”这个问题事后找答案的成本永远比事前三行代码要高得多。哪怕只是在写 FLASH 的函数里顺手维护一个全局计数器或者更稳妥地在数据结构头部放一个 length 字段后面排查问题的难度会直接下降一个数量级。如果现在你手上已经有一块写了一半数据的板子又没有留下任何记录那也别慌按第二、三章的方法一步步来——先用调试器导出内存再用脚本扫边界、扫魔数大概率能把写入量找出来。这个能力本身也是嵌入式工程师的硬功夫多练几次你会发现自己对存储结构的敏感度会提高不少。最后再分享一个调试小技巧写 FLASH 的代码里如果条件允许把“写入地址 写入长度 时间戳”这三样东西每次都以小体积日志的形式放在另一个扇区的环形缓冲里。这样哪怕主数据区出了问题这个日志扇区也能帮你还原出完整的写入历史。“写入字节数”这个问题从那以后就再也不是问题了。