Skip to content

Repository files navigation

CMSIS Runtime Watchpoint

一个用于 Cortex-M 设备脱离调试器运行时检测内存踩踏和非法访问的独立 C99 模块。它利用处理器 DWT 数据观察点和 DebugMonitor,在目标地址被读写时捕获 PC、LR、xPSR 等现场,再由主循环或 RTOS 诊断任务完成日志、符号化和处置。

为什么需要它

内存踩踏经常表现为“某个变量偶尔被改坏”,真正的写入位置未知,而且问题可能只在 设备运动、无线连接、传感器工作、休眠唤醒或长时间运行等真实环境中出现。 Ozone、GDB 等常规调试方式通常需要设备持续连接 SWD/JTAG 调试器或飞线,这会限制 设备移动,也可能改变系统时序、功耗和运行环境,难以复现只在真实使用状态发生的 问题。

本模块把 DWT 观察点配置和事件采集直接放进固件。设备可以断开 halting debugger, 在正常形态下独立运行;命中事件由静态队列保存,并可交给 FreeRTOS worker task 异步记录。因此它特别适合:

  • 在可疑变量、对象字段或内存地址已知时,定位未知代码从哪里执行了越界或非法写入;
  • 分析非法读取、非预期状态修改、释放后仍访问以及并发时序导致的数据破坏;
  • 在运动、无线、传感器和低功耗切换等真实设备场景下持续检测;
  • 对低概率、长时间后才出现的问题进行多设备批量压测,而不需要为每台设备持续连接调试器;
  • 在线上复现固件或诊断版本中保留轻量现场,事后结合对应 ELF/MAP 定位函数和源码。

典型使用方式是:先根据损坏结果确定需要保护的 1/2/4 字节地址,再设置 WRITE watchpoint;未知代码写入该地址时无需调用本模块,也不需要插入 __DSB()。模块会在 Runtime 自动捕获异常返回现场。

它与 Ozone/GDB 是互补关系,而不是替代关系:本模块负责脱机、长时间和批量运行时 取证,捕获后仍建议使用对应 ELF、addr2line、反汇编以及常规调试器完成源码分析。 DWT 是访问后的审计机制,不能阻止踩踏;记录的 stacked_pc 也不保证恰好等于 STR/LDR 指令地址。

核心实现不依赖 RTOS、不使用动态内存,并且不在 DebugMonitor 异常中执行 I/O; FreeRTOS 仅作为可选下半段适配层。

当前发布版本:0.3.0。STM32F407 + FreeRTOS V11.3.0 真机闭环已经验证通过。 快速移植请阅读 适配指南,原始板端日志见 真机验证记录

当前范围

  • DWTv1:Cortex-M3/M4/M7(Armv7-M/Armv7E-M)。
  • DWTv2:Cortex-M33/M35P/M52/M55/M85(Armv8-M Mainline/Armv8.1-M)。
  • 单地址 1/2/4 字节读、写、读写观察点。
  • 固定资源池、代际 handle、启停/删除、DebugMonitor 事件队列。
  • 空闲、指定掩码和独占三种 DWT 比较器认领策略。
  • 保存并恢复模块接管的比较器、DEMCR 位和 DebugMonitor 优先级。
  • DWTv2 按 FUNCTION.ID 探测数据地址能力,DWTv1 使用安全回读探测。
  • 裸机优先;模块本身不包含 FreeRTOS/CMSIS-RTOS 依赖。
  • 可选非法读取判定层:按异常 PC 和授权代码范围分类,不在异常中执行策略。
  • 系统无关事件就绪回调,以及可选 FreeRTOS task-notification 下半段适配层。

Cortex-M0/M0+/M23 没有本模块所需的完整 DebugMonitor 路径,当前明确不支持。 地址范围、数据值匹配、SMP、多核和 TrustZone 跨安全域也不在第一版范围内。

目录与依赖

发布包不内置 CMSIS-Core、芯片 Device Header 或 FreeRTOS Kernel。当前版本使用官方 CMSIS 6.3.0 和 FreeRTOS Kernel V11.3.0 完成验证,集成时也可以使用目标 SDK 自带的 兼容版本。

实际 MCU 工程应包含厂商 device header(推荐),因为它会间接包含正确的 core_cm*.h。编译 src/cwp.c 时定义:

target_compile_definitions(your_target PRIVATE
    CWP_CMSIS_HEADER="stm32f4xx.h"
    CWP_PROVIDE_DEBUGMON_HANDLER=1)
target_include_directories(your_target PRIVATE
    path/to/cmsis-runtime-watchpoint/include)
target_sources(your_target PRIVATE
    path/to/cmsis-runtime-watchpoint/src/cwp.c
    path/to/cmsis-runtime-watchpoint/src/cwp_debugmon_gcc.c)

若用本目录的 CMake,也可以直接 add_subdirectory(...) 后链接源码型目标:

target_link_libraries(your_target PRIVATE cwp::core)

CWP_CMSIS_HEADER 必须定义在最终 your_target 上;模块不能在不知道具体 MCU device header 的情况下预编译成通用二进制库。

如果启动文件已经定义 DebugMon_Handler,不要设置 CWP_PROVIDE_DEBUGMON_HANDLER;在已有处理器中按示例汇编取得 MSP/PSP 和 EXC_RETURN,再调用 cwp_debugmon_dispatch()。提供的 handler 适用于 Arm 目标上的 GCC/Clang。

最小用法

static volatile uint32_t state;

cwp_watchpoint_config_t cfg = {
    .address = (uintptr_t)&state,
    .size = sizeof(state),
    .access = CWP_ACCESS_WRITE,
    .user_data = 42
};
cwp_handle_t handle;

cwp_init(3);
cwp_create(&cfg, &handle);

for (;;) {
    cwp_event_t event;
    while (cwp_poll_event(&event)) {
        /* event.pc 是触发访问后的返回现场,用 ELF/map 文件解析。 */
    }
}

cwp_init() 是兼容便捷入口,默认只认领初始化时空闲且支持数据地址匹配的比较器, 不会清空已经启用的外部比较器。需要固定资源边界时使用扩展入口:

cwp_init_config_t init = {
    .struct_size = sizeof(cwp_init_config_t),
    .api_version = CWP_INIT_CONFIG_VERSION,
    .debug_monitor_priority = 3,
    .claim_policy = CWP_CLAIM_MASK,
    .comparator_mask = (1UL << 1) | (1UL << 2)
};

cwp_status_t status = cwp_init_ex(&init);

认领策略:

  • CWP_CLAIM_FREE_ONLY:认领所有空闲且有能力的比较器,默认且最安全。
  • CWP_CLAIM_MASK:只检查和认领掩码内的比较器;任一繁忙或不支持则整体失败。
  • CWP_CLAIM_EXCLUSIVE:接管全部有能力的比较器,退出时恢复初始化前的配置。

cwp_get_capabilities() 可查询已实现、支持数据地址、初始繁忙及本模块已认领的位图。 若调试器在初始化后抢占了一个尚未配置的已认领比较器,创建操作会返回 CWP_ERROR_RESOURCE_BUSY,不会覆盖它。

DebugMonitor 优先级必须结合芯片的 __NVIC_PRIO_BITS 和应用中断优先级规划。 若芯片实现了 DWT Lock Access Register,需在编译时令 CWP_PLATFORM_PREPARE() 完成厂商规定的解锁。调试器也可能占用 DWT 比较器;产品 运行时使用前应规定“调试器观察点”和本模块的所有权边界。处理器处于 halting debug(C_DEBUGEN=1)时,匹配通常交给调试器而不是 DebugMonitor,因此不要把 “连接调试器”和“脱机运行”的行为视为完全等价。

主机测试

cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
CMSIS_CORE_INCLUDE=/path/to/CMSIS/Core/Include ./tests/cross_compile.sh

测试不会声称模拟 Cortex-M 指令流水线;它验证 DWTv1/v2 寄存器编码、资源管理、 异常栈解析、事件队列与溢出行为。交叉编译脚本还会用 CMSIS 6.3.0 的真实 core_cm4.h/core_cm33.h 验证 DWTv1、DWTv2 和 GCC DebugMon handler。最终仍需 在目标芯片验证 DebugMonitor 是否实现、 比较器数量、MATCHED 清除语义,以及调试器连接时的行为。

M2:非法读取判定

DWT 只能报告“受监控地址发生了读取”,不知道业务上的合法与非法。M2 的 cwp_policy 在主循环取出 cwp_event_t 后,用异常 PC 是否落在授权代码区进行分类:

READ watchpoint -> DebugMonitor -> event.pc
                                  |
                    authorized section range?
                         yes / no
                     allowed / unauthorized

不要用“函数入口地址 + 猜测长度”计算函数结束地址,也不要用下一个函数的入口作为 结束地址。编译优化、LTO、链接排序、尾调用和对齐填充都会使这种方法不可靠。

推荐把授权读取函数放入专用 section:

#include "cwp_policy.h"

static uint32_t read_secret(const volatile uint32_t *address)
    CWP_AUTHORIZED_CODE;

static uint32_t read_secret(const volatile uint32_t *address)
{
    uint32_t value = *address;
    __DSB(); /* 让异步数据 watchpoint 在返回授权区前被接收。 */
    return value;
}

链接脚本在普通 .text 之前增加:

.cwp_authorized :
{
    . = ALIGN(4);
    __cwp_authorized_start__ = .;
    KEEP(*(.cwp_authorized))
    KEEP(*(.cwp_authorized.*))
    . = ALIGN(4);
    __cwp_authorized_end__ = .;
} > FLASH

应用使用链接器自动生成的半开区间 [begin, end)

extern const uint8_t __cwp_authorized_start__[];
extern const uint8_t __cwp_authorized_end__[];

static const cwp_code_range_t allowed[] = {{
    (uintptr_t)__cwp_authorized_start__,
    (uintptr_t)__cwp_authorized_end__
}};

cwp_read_policy_t policy;
cwp_read_policy_init(&policy, allowed, 1);

while (cwp_poll_event(&event)) {
    cwp_policy_result_t result;
    cwp_read_policy_classify(&policy, &event, &result);
    if (result.verdict == CWP_POLICY_READ_UNAUTHORIZED) {
        /* 延迟日志、统计、复位或上报。 */
    }
}

用于非法读取判定的观察点必须配置 CWP_ACCESS_READ。DWTv1 的 READ_WRITE 匹配只 表明发生了其中一种访问,事件无法反推出实际是哪一种,策略层会将其标记为 CWP_POLICY_NOT_READ,避免误报。

这是一种审计和告警机制:读取指令已经执行。需要在读取发生前阻止访问时,应由 MPU 或 TrustZone 提供权限隔离,DWT 用于补充诊断。

M3:RTOS 下半段适配

核心层通过 cwp_set_event_ready_callback() 提供系统无关唤醒点。回调在 DebugMonitor 异常上下文、事件写入静态队列之后执行,只能进行有界且异常安全的 通知操作。未配置回调时,原来的轮询方式保持不变。

FreeRTOS 适配层位于 ports/freertos,不进入核心源文件,也不会自动创建任务:

static cwp_freertos_notifier_t notifier;

static void cwp_worker(void *argument)
{
    cwp_freertos_notifier_bind_current(&notifier);
    cwp_init(8); /* 必须满足 FreeRTOS FromISR 优先级约束。 */

    for (;;) {
        cwp_freertos_wait(portMAX_DELAY);
        while (cwp_poll_event(&event)) {
            /* 当前 task/PSP:分类、日志和处置。 */
        }
    }
}

适配层默认占用 task notification slot 0。支持 notification array 的工程可以定义:

#define CWP_FREERTOS_NOTIFICATION_INDEX 2U

并保证它小于 configTASK_NOTIFICATION_ARRAY_ENTRIES。notifier 对象必须比绑定关系存活 更久;删除 worker task 前先调用 cwp_freertos_notifier_unbind()

DebugMonitor 回调调用 FreeRTOS FromISR API 时,其数值优先级必须不小于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。例如最大系统调用优先级为 5 时,可用 5~15,示例选择 8。内核临界区可能暂时延迟该异常,这是允许调用 RTOS API所需的 代价。

M3 还提供 cwp_access_policy_t,分别配置 READ 和 WRITE 授权范围。调用者必须分别 创建 CWP_ACCESS_READCWP_ACCESS_WRITE 观察点;READ_WRITE 在 DWTv1 上不能说明 实际触发类型,因此返回 CWP_ACCESS_POLICY_NOT_APPLICABLE

PC 字段语义

cwp_event_t.pc 为硬件异常栈中的异常返回 PC,并带有 CWP_EVENT_FLAG_PC_IS_EXCEPTION_RETURN。它用于判断最终落在哪个授权代码区,但硬件 不保证它就是 LDR/STR 指令地址;数据 watchpoint 可能在后续 DSB 等同步点被接收。 因此日志应写作 stacked_pc,不要宣称为精确 access_instruction_pc。授权函数应在 访问后、离开授权 section 前执行 DSB,确保范围判定稳定。

STM32F407 + FreeRTOS V11.3.0 实板验证结果:授权 READ、非法 READ、授权 WRITE、 非法 WRITE 各产生一次事件;FreeRTOS worker 通过 task notification 被唤醒 4 次, 分类全部正确且 dropped=0。可独立构建的验证工程位于 examples/stm32f407_freertos,原始日志已随发布包保存。

设计边界

  • API 只在主循环/线程上下文创建和销毁观察点;DebugMonitor 只生产事件。
  • 队列满时保留已有事件并增加 cwp_dropped_event_count(),不会阻塞异常处理。
  • 浮点扩展异常栈依据 EXC_RETURN 自动跳过 18 个字;如平台启用了非标准上下文 保存,应由平台 handler 传入正确的硬件栈帧。
  • cwp_deinit() 只撤销由本模块首次设置的 TRCENA/MON_EN;初始化前已经开启的位 保持不变,并恢复原 DebugMonitor 优先级。
  • DWTv1 与 DWTv2 的访问编码不同,后端会把统一 API 映射到各自架构编码;调用者 不应把 cwp_access_t 的数值直接写入 DWT FUNCTION。

代码采用 Apache-2.0;CMSIS_6 由 Arm 以 Apache-2.0 发布,版权归各自作者。 第三方依赖不会打入发布包,版本与许可证边界见 THIRD_PARTY_NOTICES.md。版本变更见 CHANGELOG.md

后续生产化增强、优先级与验收标准见 OPTIMIZATION_ROADMAP.md

About

Portable CMSIS-Core runtime DWT watchpoints with optional FreeRTOS deferred event handling

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages