STM32内存扫描:SWD/DAP调试通路与变量定位实战

发布时间:2026/11/2 13:52:09
STM32内存扫描:SWD/DAP调试通路与变量定位实战 简介这是一份面向嵌入式开发与逆向调试学习者的驱动级 Cheat Engine 工具包侧重解决 Windows 驱动保护环境下的内存访问查看、断点调试与进程分析问题也可配合 STM32、ARM 相关实验理解底层调试链路。压缩包共 330 个文件约 14.67MB类型覆盖 98 个 C/C 头文件、50 个 po 本地化文本、43 个 Lua 脚本、29 个 DLL、11 个 C 源文件以及 exe、frm、png、txt、sln 等可运行组件、界面资源、说明文档和工程配置。资源还保留 lib、exp、sig、dat 等编译与运行辅助文件便于复现实验环境、排查依赖问题。可运行程序、源码工程、脚本与文档相互配套方便读者从界面操作、脚本调用到底层驱动交互逐层对照目录层次也便于按模块检索。已有 2995 人学习下载适合具备一定 Windows 底层与单片机基础、希望深入理解调试器实现与驱动交互的中高级学习者。1. 在 PC 上给内存扫描工具挂一条能直接读写目标内存的通路行话里叫“驱动”做出来的形态就是 CE 那类工具的内核部分。把这套思路原样搬到 stm32 这类 arm 单片机上通路的形态完全变了没有进程、没有虚拟地址、没有页保护取而代之的是一条 SWD 两线、一个 DAP、一个挂在 AHB 总线上的 MEM-AP 地址窗口。所以嵌入式语境里的“驱动 CE”真正要回答的只有一件事——怎么让上位机在单片机跑着或者被摁停的时候稳定地读出一段 RAM再写回去并且知道自己在读哪个变量。这套东西适合手上已经有 arm 调试探针、又想把固件里的中间量、状态机变量、缓冲区内容翻出来看的嵌入式硬件工程师也适合从 PC 侧内存调试转过来、第一次碰 Cortex-M 的人。2. 访问通路拆解SWD、DAP 与 AHB-AP 是怎么把 4 个字节搬进 RAM 的在写任何脚本之前得先搞清楚这条通路上到底有哪几层。搞不清楚层级后面所有“读出来全是 0”“读出来全是 0xFFFFFFFF”的怪现象都只能靠猜。Cortex-M 的调试访问是一条严格分层的链路物理层是 SWD 或 JTAG协议层是 DAP 里的 DP 和 AP总线层是 MEM-AP 翻译出来的 AHB 读写事务最后才落到芯片内部的 SRAM 或外设寄存器上。每一层都有可能断断在不同层表现出来的症状完全不一样。2.1 两线时序与 DP/AP 的分层SWD 只用两根线SWCLK 和 SWDIO外加必须共地的 GND 和一根参考电平 VTref。它的报文是打包的一个 8 位的请求头跟着 ACK 和若干数据周期由 SWCLK 同步。这条链路上最容易翻车的是线长和上拉。常见的杜邦线飞 20 厘米以上、SWCLK 跑到 4 MHz 以上读回来的数据偶尔错一位表现出来的现象就是扫描结果里随机冒出几个不像话的地址。血泪经验是先用低频跑通再逐步往上提。DAP 这一层拆成 DPDebug Port和 APAccess Port。DP 管的是调试口本身的状态比如上电、复位、选择当前要访问哪个 APAP 才是真正挂到芯片内部的窗口。Cortex-M 上一般至少有一个 AHB-AP它把 AP 的寄存器操作翻译成对系统总线的读写这就是为什么你往 AP 的 DRW 里写一个值最终会出现在 0x20000000 那块 SRAM 上。有的芯片还会多出 APB-AP专门访问调试组件CTI、ETM 之类做普通变量扫描时基本用不到。理解这一点很关键上位机工具从来没有“直接访问内存”的能力它只能往 AP 寄存器里写地址和数据剩下的由硬件完成。所有的读写延迟、对齐要求、批量效率问题根源都在这一层。2.2 AHB-AP 的 CSW、TAR、DRW 三个寄存器AHB-AP 上真正干活的就三个寄存器这是整条链路的核心黑匣子寄存器作用关键位CSW控制/状态字决定传输宽度和自增方式Size[2:0] 选 8/16/32 位AddrInc 选单次或自增TAR目标地址寄存器放要访问的 32 位地址直接写物理地址DRW数据读写寄存器读它触发一次总线读写它触发一次总线写读写后 CSW 的 WDATAERR/STICKYERR 会置位一段典型的传输流程是写 TAR 设定地址写 CSW 设定 32 位宽、单次传输然后读或写 DRW。如果 CSW 里开了地址自增AddrInc 0b01那么每访问一次 DRWTAR 自动 4连续读 DRW 就能把一整块 RAM 顺序搬出来。这就是“块读”比“逐字读”快一个数量级的原因——省掉的是每次重新设地址的开销。还有一个细节值得记住CSW 里的 STICKYERR 一旦置位说明上一次访问在总线上出错了比如地址没映射、访问了被保护的区域这个位是粘滞的不清掉后面所有访问都会报错。很多工具读越界地址之后“再也读不出东西”就是没清这个位。2.3 用 OpenOCD 验证通路是否真的打通在写 Python 脚本之前先用 OpenOCD 把自己手上的探针和目标板跑通这一步能把“工具问题”和“板子问题”彻底分开。# 以 CMSIS-DAP 探针 某 F4 系列目标为例 # interface 选探针类型target 选芯片系列两个 cfg 缺一不可 openocd -f interface/cmsis-dap.cfg \ -f target/stm32f4x.cfg \ -c adapter speed 1000 \ -c init; reset halt启动成功会看到两个端口在监听3333 给 GDB 用4444 是 telnet 命令行。先用 telnet 直连绕开 GDB 这层中间件telnet localhost 4444 reset halt mdw 0x20000000 8 mww 0x20000000 0x12345678 mdw 0x20000000 1mdw是 memory display word按 32 位读并打印mww是 memory write word。第二条命令写完立刻回读如果读回来的不是 0x12345678那问题一定在硬件链路或目标状态上跟你的扫描算法没有半点关系。提示reset halt之后 CPU 停在复位向量此时 SRAM 里是上一次运行的残留内容不是全 0。如果你看到一片 0x00先怀疑是 halt 没生效、CPU 还在跑或者读的地址压根没映射。到这里通路的物理层和协议层就算验证完了。剩下的事情才是“往哪个地址读、读多少、怎么判断读到的值有意义”那是软件层面的活。3. 环境搭建OpenOCD、GDB、pyOCD 三件套的可复现配置OpenOCD 适合手动验证和排错但做扫描这种需要循环几百上千次的操作得靠脚本。pyOCD 是纯 Python 实现直接调 libusb 跟探针通信不用挂后台进程做批量和自动化最顺手。GDB 则介于两者之间适合单点精确定位和符号级调试。三件套各管一段不要指望一个工具干完所有事。3.1 接口配置与 target 选择一行 cfg 决定成败OpenOCD 的配置分两半interface/*.cfg描述探针target/*.cfg描述芯片。前者决定用什么协议跟探针说话后者决定芯片的 Flash 算法、内存映射、复位方式。最常踩的坑是 target 选错系列。同一个厂商的不同系列Flash 起始地址、SRAM 大小、复位行为都可能不同。选了一个 SRAM 只有 64K 的 target 去读 128K 的边界后半段读回来的全是总线错误。稳妥的做法是显式写死自己要读的范围不依赖 cfg 里的默认值# 显式声明扫描范围避免依赖 target cfg 的默认内存映射 openocd -f interface/cmsis-dap.cfg -f target/stm32f4x.cfg \ -c adapter speed 2000 \ -c init \ -c reset halt \ -c echo {RAM start 0x20000000, size 128K}adapter speed的单位是 kHz写 2000 就是 2 MHz。长排线降到 1000 以下更稳。这个参数在批量扫描时值得单独调因为它直接决定全 RAM 快照的耗时。3.2 GDB 侧的内存读写与 halt 时机GDB 的价值在于它能解析符号能设断点和观察点还能在 halt 状态下精确地读一个变量。# 连上 OpenOCD 暴露的 gdb server target extended-remote :3333 monitor reset halt # 按符号读前提是加载了带调试信息的 elf file build/firmware.elf print g_state print g_state # 按地址读一段x 后面跟数量、格式、单位 x/16xw 0x20000000 # 直接写一个 32 位值回去 set {unsigned int}0x20000000 0xDEADBEEFmonitor开头的命令是透传给 OpenOCD 的不归 GDB 管。monitor reset halt是复位并停机monitor reset run是复位后继续跑。这两条的区别在扫描场景里极其重要CPU 在跑的时候读内存读到的是一个撕裂的快照可能出现半新半旧的 64 位变量先 halt 再读才能拿到一致的视图。x/16xw里的三段分别表示数量 16、十六进制、字4 字节。想按字节看就换成xb按半字就xh。这个命令在用得顺手之后排查“这个结构体到底占多少字节”非常快。3.3 pyOCD 脚本化把读写做成可循环的函数批量扫描必须脚本化。下面这段是最小可运行版本做三件事连上探针、复位停机、把整块 RAM 一次性读出来。from pyocd.core.helpers import ConnectHelper # options 里的 frequency 是 SWD 时钟单位 Hz session ConnectHelper.session_with_chosen_probe(options{frequency: 2000000}) session.open() target session.target RAM_START 0x20000000 RAM_SIZE 128 * 1024 # 按实际型号改小容量型号可能只有 64K RAM_WORDS RAM_SIZE // 4 # 复位并停在复位向量保证 RAM 内容处于一致状态 target.reset_and_halt() # 块读一次事务搬一整段比逐字读快一个数量级 words target.read_memory_block32(RAM_START, RAM_WORDS) print(first word:, hex(words[0])) print(total words:, len(words)) session.close()参数上有三点值得说明。frequency设 2 MHz 是个折中值探针质量好、排线短可以提到 4 MHz 以上读全 128K RAM 的时间能从几百毫秒压到一百毫秒出头。read_memory_block32的第二个参数是字数量而不是字节数写错一个倍数就会读到越界地址然后触发粘滞错误。reset_and_halt之后再读读到的是复位时刻的 RAM如果目标是热复位不掉电SRAM 内容会保留这时候读到的其实是上一次运行的现场做差分扫描时要特别注意这一点。3.4 目标端要配合留一个不会被优化掉的观测变量扫描算法再好目标端没留观测点也是白搭。编译器优化会把只写不读的变量直接删掉把频繁访问的变量提到寄存器里你在 RAM 里根本找不到它。/* 强制放到 RAM、禁止优化掉、禁止被塞进寄存器 */ static volatile uint32_t g_probe_a __attribute__((used, section(.data))); static volatile uint32_t g_probe_b __attribute__((used, section(.data))); /* 一个中等复杂度的结构体用来练结构体定位 */ typedef struct { uint16_t id; uint16_t flags; uint32_t counter; uint8_t payload[16]; } probe_record_t; volatile probe_record_t g_record __attribute__((used)); void probe_update(void) { static uint32_t tick; tick; g_probe_a tick; g_probe_b tick * 3u; g_record.counter tick; g_record.flags (uint16_t)(tick 0xFFFFu); }volatile保证每次访问都真的走内存编译器不会把它缓存进寄存器__attribute__((used))保证链接器不因为“没被引用”把它扔掉。这两个属性少一个扫描结果里就少一个目标。用-O0编译也能达到类似效果但那不是长久之计工程里不可能为了调试把优化全关掉。目标端准备好之后RAM 里就至少有了三个已知形状的观测点两个 32 位标量、一个带固定字段排列的结构体。后面所有扫描技巧都是拿这几个已知答案去验证方法对不对。4. 内存扫描落地从全 RAM 快照到变量地址收敛有了通路和环境扫描本身反而变成最机械的一步。核心思想跟 PC 上那类工具完全一致先拍一张全量快照让目标改变某个值再拍第二张取差集。区别在于嵌入式里 RAM 小、快照便宜可以直接全量对全量不需要那套分代扫描的复杂结构。128K 的 RAM 一次快照几百毫秒扫三轮也就一两秒效率压力远没有 PC 侧那么大。4.1 首次快照读全 RAM 的边界与耗时第一次快照的目的是拿到基线。这里有个容易忽略的前提快照必须在 CPU 停机状态下拍否则你拍到的是一张正在被改写的画面。import time from pyocd.core.helpers import ConnectHelper RAM_START 0x20000000 RAM_SIZE 128 * 1024 def take_snapshot(target, startRAM_START, sizeRAM_SIZE): 停机拍一张全 RAM 快照返回 32 位字列表 target.halt() t0 time.time() data target.read_memory_block32(start, size // 4) cost (time.time() - t0) * 1000 print(snapshot: %d words, %.1f ms % (len(data), cost)) return data session ConnectHelper.session_with_chosen_probe(options{frequency: 2000000}) session.open() target session.target target.reset_and_halt() base take_snapshot(target) print(非零字数量:, sum(1 for w in base if w ! 0))“非零字数量”这个统计很有用。复位后如果这个数字接近 0说明你的目标板压根没跑起来或者读的地址范围不对如果它接近总字数的三分之一说明 RAM 里堆了东西可以继续。这个数字在每次换板子、换工程时都值得看一眼属于成本极低的自检。耗时打印出来是为了建立体感。2 MHz 下读 128K 通常在 300 到 600 毫秒之间如果飙到好几秒八成是探针时钟没生效或者块读被拆成了单字循环。4.2 变动过滤两次快照做 diff有了基线让目标跑一小段再拍第二张然后逐字比。def diff_snapshots(old, new, startRAM_START, max_hits64): 逐字比对两次快照返回 (地址, 旧值, 新值) 列表 hits [] for i, (a, b) in enumerate(zip(old, new)): if a ! b: hits.append((start i * 4, a, b)) if len(hits) max_hits: break return hits target.resume() time.sleep(0.5) # 让固件跑一会儿产生变化 snap2 take_snapshot(target) hits diff_snapshots(base, snap2) for addr, old, new in hits[:20]: print(0x%08X: 0x%08X - 0x%08X % (addr, old, new))max_hits是个实用参数。不设上限的话一个正在跑 RTOS 的目标可能有几千个地址在变——栈指针附近、堆管理结构、各种计数器全打出来根本看不过来。先限制到 64 条看快速浏览一下模式再决定要不要放开。比完之后你会发现变化最剧烈的一般是栈区。栈上全是局部变量和函数返回地址每次调用都在动。真正有价值的全局变量往往混在中间变化幅度小、地址固定。怎么把它们挑出来靠第二轮。4.3 从地址到结构体按步长和类型二次收敛两轮 diff 之后候选地址会少很多但仍然不够。第二轮的策略是让目标再跑一次用相同的候选地址集合重新比对把“只变了一次”的噪声过滤掉。# 第三张快照仍然只保留之前命中的地址 target.resume() time.sleep(0.5) snap3 take_snapshot(target) candidates [addr for addr, _, _ in hits] still_hit [] for addr in candidates: idx (addr - RAM_START) // 4 if snap2[idx] ! snap3[idx]: still_hit.append((addr, snap2[idx], snap3[idx])) print(连续两轮都在变:, len(still_hit), /, len(candidates))连续两轮都在变的地址基本就是真正的计数器或者状态变量了。这时候再回头对照固件里那几个volatile观测点验证方法是否有效——如果你读到的地址跟g_probe_a完全一致说明整条链路是干净的如果不一致先别怀疑算法去看编译产物里的符号地址。结构体的定位稍微麻烦一点。思路是先找到其中一个字段的地址然后按已知的结构体布局反推起点。比如你发现某个地址每 500 毫秒 1而结构体里counter在偏移 4 的位置那么结构体起点就是该地址 - 4。往下再验证flags是否等于counter 0xFFFF两个字段都对得上基本可以确认。def find_struct_base(hit_addr, field_offset, ram_startRAM_START): 由字段地址反推结构体起始地址 return hit_addr - field_offset这个函数简单到只有一行但它代表了扫描工作的收尾动作从“一个在变的地址”变成“一个我能解释的结构”。4.4 硬件观察点DWT 比较器的正确用法扫描是被动的得反复拍快照。硬件观察点是主动的只要 CPU 访问了某个地址立刻停下来或者打标记。这在追“谁改了这个变量”的时候无可替代。Cortex-M 的数据观察点靠 DWTData Watchpoint and Trace里的比较器实现。不同内核数量不一样M3/M4 上通常有 4 个M0 上少一些甚至没有具体要看芯片对应的参考手册。用之前必须先开 TRCENA/* 在固件侧配置DWT 比较器 0 监控对 g_record.counter 的写访问 */ CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; /* 总开关不开后面全白搭 */ /* COMP0 放目标地址低两位必须清零按字对齐 */ DWT-COMP0 ((uint32_t)g_record.counter) ~0x3u; DWT-MASK0 0; /* 0 表示精确匹配全部 32 位 */ DWT-FUNCTION0 0x6u; /* FUNCTION0b0110数据写访问命中时产生调试事件 */FUNCTION0 0x6这个值是按 Cortex-M3/M4 的 DWT 定义写的0b0110 表示“数据地址写匹配”。改成 0b0101 就是只监控读0b0111 是读写都管。写错成 0b0001 的话只会产生 trace packet不会停下来现象就是“设了观察点但什么都没发生”这种错误最难查。注意DWT 比较器是稀缺资源用完记得把 FUNCTION0 清 0 释放。同时开着多个比较器还开着 ITM容易互相挤占最后表现出来的现象是观察点时灵时不灵。GDB 侧的watch g_record.counter底层走的就是这套机制但它需要目标在 halt 状态下设好再 continue而且不同 OpenOCD 版本的翻译质量参差不齐。真正要追复杂时序问题时直接在固件里写 DWT 更可控。5. 避坑与排查五条让扫描结果全错的细节5.1 现象、原因、解决现象一整片 RAM 读回来全是 0x00 或全是 0xFF。原因基本不在扫描代码而在链路层。全 0 通常是读的地址没有映射到任何存储体总线返回 0 且置了 STICKYERR全 0xFF 一般意味着 DAP 没连上工具在读一个悬空的默认值。解决顺序是固定的先用 telnet 的mdw直读 0x20000000通了再上脚本不通就把时钟降到 500 kHz缩短排线检查 VTref 有没有接、目标有没有供电。这一步没通后面所有调试都是自欺欺人。现象二变量明明改了扫描却找不到。最常见的原因是编译器把这个变量提到了寄存器里或者干脆优化掉了。判断方法很直接看 map 文件和 elf 符号表如果nm出来的地址在 0x20000000 范围内它就在 RAM如果压根找不到符号那就是被优化没了。解决是加volatile和__attribute__((used))重新编译再看。别指望关掉优化能解决根本问题工程里迟早要开回-O2。现象三两次快照之间只隔了几十毫秒却有一大片地址在变。这是扫码时 CPU 没停。很多工具在resume之后拍快照或者 halt 请求还没生效就开始读。表现出来的特征是一片连续的高位地址在跳那通常是栈指针附近。解决是强制 halt 并确认状态读完再 resume。另外注意低功耗模式目标进了 sleep 之后 DAP 可能掉线读回来的数据是陈旧的需要在固件里打开调试保持位比如__HAL_DBGMCU_ENABLE_DBGSLEEP让内核在睡眠时仍保持调试连接。现象四连续三轮都在变的地址最后发现是个栈上临时变量。栈上变量的地址在每次函数调用时都可能不同但因为栈指针相对固定同一个偏移位置会被反复复用看起来就像“一个稳定的地址在变”。区分方法很简单看这个地址是不是落在 SP 附近。把当前 SP 读出来落在 SP 到 SP1K 范围内的一律先当噪声处理。真正的全局变量地址通常远离栈区或者干脆查符号表确认。现象五块读的时候报对齐或者越界错误读到一半就断。块读对起始地址和长度都有对齐要求。read_memory_block32的起始地址必须是 4 字节对齐长度按字数算。更隐蔽的是跨区域读取从 0x20000000 连读 128K如果芯片的 SRAM 只有 96K 且后面接着一段未映射区间读到最后会触发总线错误前面读到的数据也一起废掉。解决办法是先查芯片手册确认连续可读区间的长度分段读每段读之前清一次错误标志。5.2 一份最小自检清单遇到“读出来不对”的时候按固定顺序走一遍比东摸西摸快得多探针能不能枚举到工具里的 probe list 有没有设备。reset halt之后能不能读到复位向量所在地址的对齐值。读一个已知常量地址比如 Flash 起始值是否正确。写一个值到已知 RAM 地址立刻回读是否一致。加载 elf用符号地址读一个已知变量跟预期值比对。最后才跑快照和 diff。前五步任何一步失败都不要往下走。这套顺序看起来笨但能省掉大量“怀疑算法”的时间——绝大多数“扫描算法不准”的问题根子都在第三步或第四步。6. 进阶用符号表和 DWT 计数器替代盲目扫描扫描这套方法威力大但它本质上是启发式的你通过“值在变”去猜“这是哪个变量”猜对了靠运气猜错了得重来。真正成熟的工程做法是反过来——先知道变量在哪再去观测它。地址从哪来从编译产物里来。elf 文件里带着完整的符号表nm能列出来Python 侧用pyelftools也能读。把这个环节接上扫描就从“搜索”变成“校验”from elftools.elf.elffile import ELFFile def load_symbols(elf_path, ram_low0x20000000, ram_high0x20040000): 从 elf 里提取落在 RAM 范围内的全局符号名与地址 syms {} with open(elf_path, rb) as f: elf ELFFile(f) table elf.get_section_by_name(.symtab) if table is None: return syms for sym in table.iter_symbols(): addr sym[st_value] size sym[st_size] if ram_low addr ram_high and size 0: syms[sym.name] (addr, size) return syms syms load_symbols(build/firmware.elf) for name, (addr, size) in list(syms.items())[:10]: print(%-24s 0x%08X size%d % (name, addr, size))这段跑完你手上就有一张“变量名到地址”的表。之后读g_record.counter根本不需要扫描直接syms[g_record][0] 4就拿到了。扫描在这里的角色退化成了兜底手段——用于那些编译器生成的匿名符号、栈上对象、或者运行时动态分配的缓冲区。再进一步把周期采样和变化率判断结合起来就得到了一个“常驻观测器”每隔固定时间读一次候选变量的值算差值差值稳定说明是计数器差值随机说明是状态量长期不变说明是配置项。这个分类自动化之后调试一个陌生的固件就不再是从零开始翻。手段需要什么适用场景局限全 RAM 快照 diff只要探针无需 elf完全不了解固件时摸底噪声多栈区干扰大elf 符号表 地址直读需要带符号的 elf有源码、有构建产物优化后符号可能被合并或消除DWT 观察点内核带 DWT 比较器追“谁改了这个值”比较器数量有限M0 上可能没有周期采样 变化率探针 时间窗口长时间挂机观测采样间隔内的高频变化会漏掉我自己现在的习惯是新拿到一块板子先花十分钟把符号表跑出来能直接读的变量就直接读读不到的才退回快照扫描。这个顺序反过来之后调试时间大概能砍掉一半。从那以后我每次接新固件都强制先导一遍.symtab再动手——省下的不是那十分钟而是后面反复怀疑“是不是扫描算法写错了”的几个小时。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询