Skip to Content
Shadow Cheat Engine功能分类用户态硬件断点

用户态硬件断点

User-mode Hardware Breakpoints 是 Shadow CE 的 “Find what writes / accesses / executes” 模块的标准后端。路径是:Python UI → BreakpointEngine → TCP → usr_hwbp.hperf_event_open(PERF_TYPE_BREAKPOINT) → Linux 调试子系统 → ARM64 DBGBVR/BCR/WVR/WCR_EL1 硬件寄存器。

这条路径没有任何 kernel module 自定义 hook,也不接管 VBAR_EL1。它用的是 Linux 内核合法的 perf_event_open API,配合 perf mmap ring buffer 把命中事件传回用户态。对比 ptrace(PTRACE_SETREGSET, NT_ARM_HW_BREAKPOINT),它最大的优势是目标进程的 TracerPid 永远是 0。

本页只覆盖这条 perf_event 路径。与之并列的两条路径:


1. ARM64 HWBP 硬件基础

1.1 断点 / 观察点寄存器组

ARMv8-A 在 EL1 提供两套 per-CPU 调试寄存器,分别对应指令断点 (BP)数据观察点 (WP)

寄存器功能数量 (实现定义)
DBGBVRn_EL1Breakpoint Value Register (监视的虚拟地址)2 – 16 (SM8750 = 6)
DBGBCRn_EL1Breakpoint Control Register (类型 / EL / 使能)与 BVR 同数量
DBGWVRn_EL1Watchpoint Value Register (监视的数据地址)2 – 16 (SM8750 = 4)
DBGWCRn_EL1Watchpoint Control Register (R/W mask / LEN / EL)与 WVR 同数量
MDSCR_EL1全局调试状态 (KDE, MDE, SS)1
OSLAR_EL1OS Lock Access (内核维护,用户态不触)1

骁龙 SM8750(OnePlus Ace 5 Pro 的 SoC)的资源:nr_bp = 6, nr_wp = 4。Linux 内核启动时读 ID_AA64DFR0_EL1 得到这组数量,放进 arch/arm64/kernel/hw_breakpoint.ccore_num_brps / core_num_wrps,后续所有 perf_event 分配都受它约束。

1.2 DBGBCRn_EL1 位域 (指令断点控制)

63 24 23 20 19 16 15 14 13 9 8 5 4 3 2 1 0 RES0 BT LBN SSC HMC BAS PMC RES E
位域含义
E (bit 0)Enable: 1 = 本 slot 激活
PMC[2:1]Privilege Mode Control: 0b10 = EL0, 0b01 = EL1
BAS[8:5]Byte Address Select: 4 bit 掩码,选中 8 字节窗口内哪几个字节触发 (典型 0xF = 4 字节全选)
HMC (bit 13)Higher Mode Control,与 PMC/SSC 组合一起筛 EL
SSC[15:14]Security State Control (Secure / Non-Secure)
LBN[19:16]Linked Breakpoint Number (context-ID 链断点时指向另一个 BP slot)
BT[23:20]Breakpoint Type: 0b0000 = unlinked address match (Shadow CE 用的),0b0010 = linked context-ID,0b0100 = address mismatch (anti-step)

Shadow CE 全部用最简单的 address match:BT = 0, PMC = 0b10, HMC = 0, SSC = 0b01 → EL0-only exact-address BP。

1.3 DBGWCRn_EL1 位域 (数据观察点控制)

63 21 20 19 18 16 15 12 11 10 9 5 4 3 2 1 0 RES0 MASK WT LBN BAS HMC SSC LSC PAC E
位域含义
E (bit 0)Enable
PAC[2:1]Privilege Access Control: 0b10 = EL0, 0b01 = EL1
LSC[4:3]Load/Store Control: 0b01 = Load 命中 (读),0b10 = Store 命中 (写),0b11 = Load+Store
BAS[12:5]8 bit 字节掩码,对应 8 字节对齐窗口中各字节是否监视
HMC (bit 13)同 BCR
SSC[15:14]同 BCR
LBN[19:16]Linked BP index (context-ID 链)
WT (bit 20)0 = unlinked,1 = linked to a context-ID BP
MASK[28:24]地址掩码:2^MASK 字节对齐窗口(比如 MASK=3 监视 8 字节,MASK=12 监视 4 KB)

Shadow CE 不用 MASK,全部走”精确地址 + BAS byte mask”路线,粒度是字节。

1.4 EL0 vs EL1 断点

PMC / PACHMCSSC 的组合决定断点在哪个 EL 生效。Shadow CE 捕的永远是 EL0 的目标进程,配置固定:

PMC (BCR) / PAC (WCR) = 0b10 → EL0 HMC = 0 SSC = 0b01 → Non-Secure

这是 Linux HW_BREAKPOINT_R/W/RW/X + attr.exclude_kernel = 1arch/arm64/kernel/hw_breakpoint.c:encode_ctrl_reg() 里展开出的位型,用户态看不到但值相同。

1.5 Linux 调试子系统的抽象层

内核把 DBG* 寄存器抽象成两层:

  1. per-thread (thread_struct.debug.hbp_break[] / hbp_watch[]) — 一个 perf_event 挂在某个 task_struct 上,记录 BP/WP 的配置,调度器切换到这个 task 时加载到寄存器。
  2. per-CPU 硬件寄存器arch_install_hw_breakpoint()__switch_to_user 的末尾,根据当前 task 的 thread.debug 数组,通过 msr DBGBVRn_EL1, xN 等指令把值写进硬件。

这意味着应用层只需要关心 per-thread 配置;硬件寄存器的加载/卸载由调度器路径自动完成。一旦线程被 migrate 到另一颗 CPU,新 CPU 会在下次切到这个 task 时重新写一遍。

1.6 与 ptrace(NT_ARM_HW_BREAKPOINT) 的关系

Linux 正规的用户态 HWBP 接口是:

ptrace(PTRACE_ATTACH, tid, ...); waitpid(tid, ...); struct user_hwdebug_state st; iovec.iov_base = &st; iovec.iov_len = sizeof(st); ptrace(PTRACE_SETREGSET, tid, NT_ARM_HW_BREAKPOINT, &iovec); ptrace(PTRACE_CONT, tid, ...); /* ... SIGTRAP delivered on hit ... */

同一个 thread.debug 数组,但使用 ptrace 会把 TracerPid 设为 attacher 的 pid。TerSafe 等反作弊模块会扫 /proc/self/statusTracerPid: 字段,一旦非零立刻封号。

Shadow CE 的 usr_hwbp.h 走的是 perf_event_open(PERF_TYPE_BREAKPOINT, ..., pid=tid, cpu=-1),它也写 thread.debug,但不设 TracerPid、不发 SIGTRAP、不暂停目标线程。事件走 perf mmap ring 出去。这是两种 API 最核心的差异。


2. 为什么 Shadow 不走 ptrace

除了 TracerPid 之外,ptrace 还有一串次生问题:

问题细节
TracerPid: 可见TerSafe 直接 cat /proc/self/status
wchan = ptrace_stop/proc/<pid>/wchan/proc/<pid>/stack 能看到
双 attach 互斥目标进程调 ptrace(PTRACE_TRACEME, 0) 自检,如果已经被 attach,返回 EPERM
SIGTRAP 抢占命中后目标线程 SIGTRAP + stop,attacher 必须 waitpid + PTRACE_CONT 才继续 — 对帧率敏感的游戏是灾难
无法监视所有线程每个 tid 都要单独 attach,attach 期间线程会被 STOP
PR_SET_DUMPABLE(0) 阻止 attach一些 App 启动早期会 set 它

perf_event_open(PERF_TYPE_BREAKPOINT) 这些都没有:

  • TracerPid 保持 0
  • 不 STOP 目标线程(采样是 async 推到 mmap ring)
  • 不发 SIGTRAP
  • 一个 fd 一个 tid,并行开多个不会互相排斥

这是 Shadow CE 把 perf backend 设为默认 HWBP 后端的根本原因。


3. perf_event_open backend (usr_hwbp.h)

文件:/root/shadow/shadow_ce/server/usr_hwbp.h (409 行)。

它是一个 header-only 模块,被 usr_server.c #include。所有状态保存在一个 file-scope static 的 g_hwbp 里,靠 pthread_mutex 序列化 SPSC 写入。

3.1 状态结构

static struct { /* 当前配置 */ uint64_t bp_addr; uint32_t bp_type; /* HW_BREAKPOINT_R/W/RW/X */ uint32_t bp_len; /* HW_BREAKPOINT_LEN_1/2/4/8 */ int bp_pid; /* 目标进程 TGID */ /* per-thread perf 监控 */ struct hwbp_thread threads[HWBP_MAX_THREADS]; /* 256 */ int thread_count; volatile int monitoring; /* hit SPSC 环 */ struct hwbp_hit events[HWBP_MAX_EVENTS]; /* 4096 */ volatile uint32_t write_idx; volatile uint32_t read_idx; pthread_mutex_t write_lock; } g_hwbp;

每个被监视线程对应一个 hwbp_thread

struct hwbp_thread { int tid; int fd; /* perf_event_open 返回的 fd */ void *mmap_addr; size_t mmap_size; pthread_t thread; /* 监控 pthread */ volatile int active; };

3.2 设置流程 hwbp_start()

static int hwbp_start(int pid, uint64_t addr, uint32_t bp_type, uint32_t bp_len) { hwbp_stop_all(); /* 清场(单 BP 语义)*/ g_hwbp.bp_pid = pid; g_hwbp.bp_addr = addr; g_hwbp.bp_type = bp_type; g_hwbp.bp_len = bp_len; g_hwbp.write_idx = g_hwbp.read_idx = 0; int tids[HWBP_MAX_THREADS]; int n = hwbp_get_threads(pid, tids, HWBP_MAX_THREADS); g_hwbp.monitoring = 1; for (int i = 0; i < n; i++) { g_hwbp.threads[i].tid = tids[i]; g_hwbp.threads[i].active = 1; pthread_create(&g_hwbp.threads[i].thread, NULL, hwbp_monitor_thread, &g_hwbp.threads[i]); } g_hwbp.thread_count = n; return n; }

关键点:

  • 每个被监视的 tid 起一个 独立的监控 pthread (hwbp_monitor_thread),在 monitor 线程内 perf_event_open + mmap + poll 循环
  • 监控线程跑在服务端进程(usr_server),不是目标进程
  • 返回实际监视的 tid 数量(某些线程可能 perf_event_open 失败,例如被 blacklist 过滤)

3.3 线程枚举与黑名单 hwbp_get_threads()

static int hwbp_get_threads(int pid, int *tids, int max) { char path[256]; snprintf(path, sizeof(path), "/proc/%d/task", pid); DIR *dir = opendir(path); if (!dir) return 0; static const char *blacklist[] = { "RenderThread", "FinalizerDaemon", "RxCachedThreadS", "mali-", "hwuiTask", "NDK MediaCodec_", NULL }; int count = 0; struct dirent *ent; while ((ent = readdir(dir)) && count < max) { int tid = atoi(ent->d_name); if (tid <= 0) continue; char name[64] = {0}; snprintf(path, sizeof(path), "/proc/%d/task/%d/comm", pid, tid); FILE *fp = fopen(path, "r"); if (fp) { fgets(name, sizeof(name), fp); fclose(fp); } int skip = 0; for (int i = 0; blacklist[i]; i++) if (strstr(name, blacklist[i])) { skip = 1; break; } if (!strncmp(name, "binder:", 7)) skip = 1; if (skip) continue; tids[count++] = tid; } closedir(dir); return count; }

黑名单的理由:

线程名为什么不监视
RenderThreadAndroid Framework 渲染线程,每帧高频访问内存,会淹掉命中列表
mali-*GPU driver worker,和游戏逻辑无关
hwuiTask硬件加速 UI,同上
NDK MediaCodec_*解码器线程,纯粹噪声
binder:*IPC 线程,大量 kernel 交互
FinalizerDaemonJVM GC,会扫整个堆
RxCachedThreadS*RxJava 调度池,和游戏逻辑无关

这份黑名单是项目过去踩坑总结的:这些线程接上 HWBP 会把 ring buffer 在几秒内打满,覆盖真正的游戏线程命中。

3.4 单个线程的 perf_event 配置

这是 hwbp_monitor_thread() 的核心 (usr_hwbp.h:113-146):

struct perf_event_attr attr; memset(&attr, 0, sizeof(attr)); attr.size = sizeof(attr); attr.type = PERF_TYPE_BREAKPOINT; attr.bp_type = g_hwbp.bp_type; /* HW_BREAKPOINT_R/W/RW/X */ attr.bp_addr = g_hwbp.bp_addr; attr.bp_len = g_hwbp.bp_len; /* HW_BREAKPOINT_LEN_1/2/4/8 */ attr.sample_type = PERF_SAMPLE_TID | PERF_SAMPLE_REGS_USER; attr.sample_period = 1; /* 每次命中都采样 */ attr.sample_regs_user = ((1ULL << 33) - 1); /* x0..pc 全 33 个 */ attr.wakeup_events = 1; /* 每条 sample 都唤醒 poll */ attr.disabled = 1; /* 先停再开,能稳地 enable */ attr.exclude_kernel = 1; /* 只监视 EL0 */ attr.exclude_hv = 1; attr.precise_ip = 2; /* 要求 PC 精确到指令 */ attr.mmap = attr.comm = attr.mmap_data = attr.mmap2 = 1; int fd = syscall(__NR_perf_event_open, &attr, tp->tid, /* cpu */ -1, /* group_fd */ -1, PERF_FLAG_FD_CLOEXEC); ioctl(fd, PERF_EVENT_IOC_RESET, 0); ioctl(fd, PERF_EVENT_IOC_ENABLE, 0);

字段逐项解释:

字段含义
typePERF_TYPE_BREAKPOINT让 perf 创建 HW breakpoint 事件而不是 counter
bp_typeHW_BREAKPOINT_R/W/RW/X读/写/读写/执行 (对应 LSC 或 BCR 用法)
bp_addr64 bit VA要监视的地址
bp_lenHW_BREAKPOINT_LEN_{1,2,4,8}监视长度,对应 BAS 掩码
sample_typeTID | REGS_USER每条 sample 包含 tid 和全套用户寄存器
sample_period = 1每次命中就采一次(没有下采样)
sample_regs_user = 0x1_FFFF_FFFF33 位全 1,对应 PERF_REG_ARM64 的 x0..x28, fp, lr, sp, pc
wakeup_events = 1每条 sample 立刻触发 poll(延迟最低)
exclude_kernel = 1EL0 监视,和 DBGWCR.PAC=0b10 对应
precise_ip = 2PERF_RECORD_SAMPLE.ip 精确到命中指令

perf_event_open 语义:

long syscall(__NR_perf_event_open, struct perf_event_attr *attr, pid_t pid, int cpu, int group_fd, unsigned long flags);
  • pid > 0, cpu = -1 — 监视特定 tid任意 CPU上的事件。这是 Shadow CE 用的模式
  • pid = -1, cpu >= 0 — 监视特定 CPU所有进程(system-wide,需 root)
  • pid = 0, cpu = -1 — 监视当前进程

PERF_FLAG_FD_CLOEXEC 保证 fd 在 execve 后关闭。

内核侧的调用链(用户看不到但要理解):

perf_event_open(attr, tid) → perf_event_alloc(attr, cpu=-1, task=find_task_by_vpid(tid)) → hw_breakpoint_event_init() → hw_breakpoint_parse() — 把 attr 翻译成 arch_hw_breakpoint_ctrl → arch_validate_hwbkpt_settings() — 硬件参数 sanity check → bp->hw.task = task, 挂进 thread.debug.hbp_watch[] → ioctl(fd, PERF_EVENT_IOC_ENABLE) → perf_event_enable_on_exec() → __switch_to_user hooks 到 arch_install_hw_breakpoint() → msr DBGWVRn_EL1, addr; msr DBGWCRn_EL1, ctrl

3.5 Ring buffer 结构

perf_event_open 返回的 fd 可以 mmap 出一段共享内存,内核写采样数据到这段内存的环形缓冲区:

size_t page_size = sysconf(_SC_PAGESIZE); /* 4096 */ size_t mmap_size = (1 + HWBP_RING_PAGES) * page_size; /* 1 + 16 = 17 pages */ void *mmap_addr = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

布局(来自 linux/perf_event.h):

+0x0000 struct perf_event_mmap_page (metadata, 4 KB) ├─ data_head : 内核写指针 (mod data_size 后的绝对 offset) ├─ data_tail : 用户读指针 (用户更新) ├─ data_offset : ring 起点 = 0x1000 └─ data_size : ring 大小 = 16 * 4096 = 64 KB +0x1000 ring buffer 实际数据 +0x11000 ring 结尾

采样流:

  1. 内核收到 BP/WP 命中 → perf_event_overflow() → 格式化 PERF_RECORD_SAMPLE → 写 ring
  2. data_head += record_size(原子递增)
  3. 如果 wakeup_events = 1,内核 wake_up(&ring->poll) 唤醒用户态 poll
  4. 用户态读 [data_tail, data_head) 区间的记录
  5. 用户态更新 data_tail(告诉内核这段可以覆盖了)

3.6 采样记录解析 hwbp_monitor_thread() 读循环

struct perf_event_mmap_page *meta = mmap_addr; uint64_t data_offset = meta->data_offset; /* 0x1000 */ uint64_t data_size = meta->data_size; /* 64 KB */ uint64_t read_pos = 0; uint8_t tmp[1024]; struct pollfd pfd = { .fd = fd, .events = POLLIN }; while (g_hwbp.monitoring && tp->active) { int ret = poll(&pfd, 1, 200); /* 200 ms 超时给 stop 机会 */ if (ret <= 0) continue; while (meta->data_head != read_pos) { uint64_t ring_base = (uint64_t)mmap_addr + data_offset; /* 读 header(可能跨 ring 边界)*/ struct perf_event_header hdr; uint64_t pos = read_pos % data_size; if (pos + sizeof(hdr) <= data_size) { memcpy(&hdr, (void *)(ring_base + pos), sizeof(hdr)); } else { size_t first = data_size - pos; memcpy(&hdr, (void *)(ring_base + pos), first); memcpy((char *)&hdr + first, (void *)ring_base, sizeof(hdr) - first); } if (hdr.size == 0 || hdr.size > data_size) { read_pos = meta->data_head; break; } if (hdr.type == PERF_RECORD_SAMPLE) { /* payload: [pid:4][tid:4][abi:8][regs:33*8] = 280 字节 */ uint64_t spos = (read_pos + sizeof(hdr)) % data_size; size_t payload = hdr.size - sizeof(hdr); /* ... 跨边界 memcpy ... */ if (payload >= 8 + 8 + 33 * 8) { uint32_t hit_pid, hit_tid; uint64_t regs[33]; memcpy(&hit_pid, tmp + 0, 4); memcpy(&hit_tid, tmp + 4, 4); /* tmp + 8..16 是 abi,跳过 */ memcpy(regs, tmp + 16, 33 * 8); hwbp_store_hit(hit_pid, hit_tid, regs); } } read_pos += hdr.size; meta->data_tail = read_pos; } }

几个工程细节:

  • 跨 ring 边界 — ring 是裸字节缓冲,一条 record 可能从末尾回绕到开头,header 和 payload 的拷贝都要分两段
  • 合法性校验hdr.size == 0 || hdr.size > data_size 时,说明 ring 被覆盖或数据错乱,直接 read_pos = data_head 跳过
  • 只处理 PERF_RECORD_SAMPLEPERF_RECORD_MMAP/COMM/MMAP2 这些是因为 attr.mmap/comm 打开才顺带收到的,这里全部丢弃(但要计入 read_pos
  • data_tail 是 feedback channel — 必须及时更新,否则 ring 写满后内核会丢 sample

3.7 SPSC 事件环 hwbp_store_hit()

解析出的 hit 进一个独立的 SPSC 队列(4096 项),供 CMD_HWBP_POLL 取走:

struct hwbp_hit { uint32_t pid; uint32_t tid; uint64_t regs[33]; /* PERF_REG_ARM64_MAX */ }; /* 272 字节 */ static void hwbp_store_hit(uint32_t pid, uint32_t tid, const uint64_t regs[33]) { pthread_mutex_lock(&g_hwbp.write_lock); /* 序列化多个 monitor 线程 */ uint32_t idx = g_hwbp.write_idx % HWBP_MAX_EVENTS; g_hwbp.events[idx].pid = pid; g_hwbp.events[idx].tid = tid; memcpy(g_hwbp.events[idx].regs, regs, 33 * 8); g_hwbp.write_idx++; pthread_mutex_unlock(&g_hwbp.write_lock); }

为什么要二级队列(perf ring → SPSC)?

  • 每个 tid 有自己的 perf mmap ring,但 TCP poll 只有一条
  • CMD_HWBP_POLL 要按 FIFO 顺序返回所有 tid 的命中,合并到单一 ring
  • SPSC 写入侧是 N 个 monitor 线程(多生产者),读出侧是 server handler 线程(单消费者),pthread_mutex 保护写端

3.8 停止 hwbp_stop_all()

static void hwbp_stop_all(void) { g_hwbp.monitoring = 0; /* 广播给所有 monitor 线程 */ for (int i = 0; i < g_hwbp.thread_count; i++) { if (g_hwbp.threads[i].active) { g_hwbp.threads[i].active = 0; pthread_join(g_hwbp.threads[i].thread, NULL); } } g_hwbp.thread_count = 0; }

每个 monitor 线程退出前自己清理:

/* hwbp_monitor_thread 末尾 */ ioctl(fd, PERF_EVENT_IOC_DISABLE, 0); munmap(mmap_addr, mmap_size); close(fd); /* 关 fd 会让内核调 arch_uninstall_hw_breakpoint() */ tp->active = 0;

close(fd) 是关键:它触发内核释放 perf_eventperf_event_release_kernel()arch_uninstall_hw_breakpoint() → 在目标线程下次调度时清掉 DBGWCR_EL1.E 位。目标线程的 thread.debug 数组里的 slot 也随之释放。


4. TCP 协议

4.1 CMD 编号(perf_event backend 专用)

来自 usr_hwbp.h:30-35

CMD方向含义
CMD_HWBP_SET0xCC→ server安装 BP/WP
CMD_HWBP_CLEAR0xCD→ server清掉当前 BP
CMD_HWBP_CLEAR_ALL0xCE→ server等同 CLEAR(单 BP 语义)
CMD_HWBP_POLL0xCF→ server拉 hit 事件

4.2 CMD_HWBP_SET (0xCC)

→ [u8 0xCC] [u32 pid] [u64 addr] [u32 bp_type] [u32 bp_len] ← [u32 thread_count]
  • pid = 0 时用 server 的 opened_pid
  • bp_typeHW_BREAKPOINT_R=1, _W=2, _RW=3, _X=4
  • bp_lenHW_BREAKPOINT_LEN_1=1, _2=2, _4=4, _8=8
  • 响应 thread_count 是成功装上 perf_event_open 的 tid 数量

4.3 CMD_HWBP_CLEAR (0xCD) / CMD_HWBP_CLEAR_ALL (0xCE)

→ [u8 0xCD] ← [u8 ok]

二者行为相同:遍历 monitor 线程,逐个 disable + munmap + close。

4.4 CMD_HWBP_POLL (0xCF)

→ [u8 0xCF] [u32 max_events] ← [u32 count] [hit0 (272B)] [hit1 (272B)] ... 每条 hit: [u32 pid] [u32 tid] [u64 regs[33]]

max_events 是客户端一次最多取几条(默认 512);server 从 SPSC 环里 FIFO 取 min(avail, max_events) 条返回。

4.5 客户端 Python wrapper (ce_client.py)

class CEClient: def hwbp_set(self, pid, addr, bp_type, bp_len): with self._lock: self._send(bytes([0xCC])) self._send(struct.pack("<I", pid)) self._send(struct.pack("<Q", addr)) self._send(struct.pack("<II", bp_type, bp_len)) return struct.unpack("<I", self._recv(4))[0] def hwbp_clear(self): with self._lock: self._send(bytes([0xCD])) return self._recv(1)[0] == 1 def hwbp_poll(self, max_events=256): with self._lock: self._send(bytes([0xCF])) self._send(struct.pack("<I", max_events)) count = struct.unpack("<I", self._recv(4))[0] hits = [] for _ in range(count): pid = struct.unpack("<I", self._recv(4))[0] tid = struct.unpack("<I", self._recv(4))[0] regs = list(struct.unpack("<33Q", self._recv(33 * 8))) hits.append((pid, tid, regs)) return hits

全部加 self._lock 序列化 —— 同一条 socket 不能同时被 _send_recv 交错(TCP 流协议要求)。


5. 客户端:BreakpointEngine 的 perf backend

文件:/root/shadow/shadow_ce/client/bp_engine.py (213 行)。

BreakpointEngine 是一个全进程单例,统一管理三种 backend,对上层 UI 屏蔽差异。这里只讲 hwbp 分支。

5.1 单例与连接

class BreakpointEngine: _instance = None _lock = threading.Lock() def __init__(self, host, port, pid): self._host = host self._port = port self._pid = pid self._callbacks = {} # {slot_id: callable(hits)} self._running = True self._backend = load_settings().get("bp_engine", "hwbp") self._next_hwbp_slot = 0 # Control socket — set/clear/query (GUI 主线程用) self._ctl = CEClient() self._ctl.connect(host, port) self._ctl.open_process(pid) # Poll socket — 后台线程专用 self._poll_client = CEClient() self._poll_client.connect(host, port) self._poll_client.open_process(pid) self._thread = threading.Thread( target=self._poll_loop, daemon=True, name="bp-poll") self._thread.start()

两条独立 TCP socket 是为了避免主线程的 hwbp_set() 和后台线程的 hwbp_poll() 互相等对方 recv()

5.2 set_breakpoint()

def set_breakpoint(self, addr, bp_type, size=4): _type_map = {'r': 1, 'w': 2, 'rw': 3, 'x': 4} if isinstance(bp_type, str): bp_type = _type_map.get(bp_type, bp_type) if self._backend == "hwbp": self._ctl.hwbp_set(self._pid, addr, bp_type, size) slot_id = self._next_hwbp_slot self._next_hwbp_slot += 1 return slot_id elif self._backend == "extreme_hwbp": ... # 见 [极致优化硬件断点](extreme-hwbp) else: ... # 见 [内核 PTE 断点](kernel-pte-bp)

perf backend 单 BP 限制usr_hwbp.h 的全局 g_hwbp 一次只持一组 (bp_addr, bp_type, bp_len)。客户端在 set_breakpoint() 返回递增的 slot_id,这是一个本地虚拟 slot,不是内核的 slot。后端层面多次 CMD_HWBP_SET 会先 hwbp_stop_all() 再装新 BP —— 同一时间只能监视一个地址

这是 perf backend 的主要局限(其它 backend 见交叉链接)。

5.3 _poll_loop() — 后台轮询

def _poll_loop(self): while self._running: try: if self._backend == "hwbp": raw = self._poll_client.hwbp_poll(512) if not raw: time.sleep(0.05) continue # 补齐成统一格式 (pid, tid, slot_id=0, type=0, addr=0, regs) hits = [] for pid, tid, regs in raw: hits.append((pid, tid, 0, 0, 0, regs)) for cb in list(self._callbacks.values()): try: cb(hits) except Exception: pass else: ... # 其它 backend 见交叉链接 except Exception: if not self._running: break time.sleep(0.2)
  • hwbp_poll(512) 一次最多取 512 条
  • 没有 hit 时 sleep(0.05) 避免 busy loop
  • hit 被广播给所有注册的 callback(perf backend 一次只有一个 BP,不需要按 slot_id 分桶)

5.4 clear_breakpoint()

def clear_breakpoint(self, slot_id): try: if self._backend == "hwbp": self._ctl.hwbp_clear() # 全清,单 BP 语义 ... except Exception: pass self._callbacks.pop(slot_id, None)

5.5 register() / unregister()

def register(self, slot_id, callback): self._callbacks[slot_id] = callback def unregister(self, slot_id): self._callbacks.pop(slot_id, None)

Callback 签名是 callable(hits_list),在后台 bp-poll 线程中被调用。UI 层收到后必须用 Qt 信号桥到主线程(见 §7.3)。


6. BpManagerDialog (bp_manager.py)

文件:/root/shadow/shadow_ce/client/bp_manager.py (148 行)。

Ctrl+B 打开的管理对话框,两张表:

表名数据源
Kernel PTE Breakpoint Slots#, PID, Address, Type, Pageclient.ptebp_query()(PTE backend 专用,见 内核 PTE 断点
Capture Windows#, Address, Type, Hits活着的 HwbpWindow 实例列表

后者对 perf backend 特别有意义 —— perf 后端只能有一个 BP,但 UI 上用户可能打开多个 “Find what writes” 窗口(实际只有最后一个真正装在硬件上)。管理对话框让用户看到这个冲突并 Stop 掉旧的。

核心刷新代码:

def _refresh(self): ... type_names = {1: "READ", 2: "WRITE", 3: "RW", 4: "EXEC"} alive = [w for w in self._hwbp_wins if w.isVisible()] self.tbl_wins.setRowCount(len(alive)) for i, w in enumerate(alive): self.tbl_wins.setItem(i, 0, QTableWidgetItem(str(i))) self.tbl_wins.setItem(i, 1, QTableWidgetItem(f"0x{w.watch_addr:X}")) self.tbl_wins.setItem(i, 2, QTableWidgetItem( type_names.get(w.bp_type, str(w.bp_type)))) total = sum(e["count"] for e in w.hit_map.values()) self.tbl_wins.setItem(i, 3, QTableWidgetItem(str(total))) ...

QTimer 每 2 秒自动刷新。按钮:

  • Refresh — 立即刷
  • Stop Selected — 选中的 capture window 执行 _stop() + close()
  • Clear All — 所有 window 都 stop
  • OK — 仅关对话框,不动后端状态

7. UI: hwbp_window.py

文件:/root/shadow/shadow_ce/client/hwbp_window.py (466 行)。

7.1 布局

┌──────────────────────────────────────────┬──────────────┐ │ hit_list (QListWidget, vertical stretch) │ Show disasm │ │ count address hex asm │ Save all data│ │ ─────────────────────────────────────── │ │ │ 1234 libue4.so+0x1440 C8001AB9 str… │ │ │ 456 libue4.so+0x1448 E8001AB9 ldr… │ (stretch) │ │ │ │ ├──────────────────────────────────────────┤ │ │ info_memo (QTextEdit) │ │ │ libue4.so+0x1440: │ │ │ >>str w8, [x0, #0x120] │ │ │ probable ptr: 0x76F1234000 │ │ │ ... x0..pc dump ... │ │ │ ◀ [Hit 1/37] ▶ │ Stop │ └──────────────────────────────────────────┴──────────────┘

7.2 构造

class HwbpWindow(QWidget): _hit_signal = pyqtSignal(list) def __init__(self, engine, client_host, client_port, pid, handle, watch_addr, bp_type, bp_len, modules=None, open_memview_fn=None, parent=None): super().__init__(parent) ... # Read client — 只给反汇编用,不做 polling self._read_client = CEClient() self._read_client.connect(client_host, client_port) self._read_handle = self._read_client.open_process(pid) # Capstone for ARM64 反汇编 from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM self._cs = Cs(CS_ARCH_ARM64, CS_MODE_ARM) self._cs.detail = True # 通过 engine 安装断点(复用 ctl socket) self._kf_slot_id = engine.set_breakpoint(watch_addr, bp_type_int, bp_len) engine.register(self._kf_slot_id, self._on_engine_hits) self._hit_signal.connect(self._process_hits)

7.3 跨线程信号桥

def _on_engine_hits(self, hits): """Called from bp-poll thread — emit signal to Qt main thread.""" if hits: self._hit_signal.emit(hits)

这是 Qt 标准模式:后台 bp-poll 线程调 _on_engine_hitspyqtSignal.emit 把事件 queue 到主线程的事件循环,_process_hits 在主线程执行。原因:

  • QListWidget 等 GUI 对象只能在主线程操作
  • 后台线程直接 self.hit_list.addItem(...) 会触发 Qt 的”对象被非法线程访问”断言

7.4 Hit 聚合 _process_hits

def _process_hits(self, hits): for hit in hits: if len(hit) == 6: pid, tid, _slot_id, _bp_type, _watch_addr, regs = hit else: pid, tid, regs = hit pc = regs[32] # regs[32] = PC if pc in self.hit_map: self.hit_map[pc]["count"] += 1 self.hit_map[pc]["regs"] = regs self.hit_map[pc]["tid"] = tid self.hit_map[pc]["history"].append((tid, regs)) else: # 首次命中 — 拉指令 + Capstone 反汇编 insn_bytes = self._read_insn(pc) asm_str = "" if insn_bytes and self._cs: insns = list(self._cs.disasm(insn_bytes, pc)) if insns: asm_str = f"{insns[0].mnemonic} {insns[0].op_str}" probable = None if insn_bytes: probable = _decode_probable(insn_bytes, regs, self.watch_addr) self.hit_map[pc] = { "count": 1, "regs": regs, "tid": tid, "asm": asm_str, "probable": probable, "addr_str": self._resolve(pc), "insn_bytes": insn_bytes, "history": [(tid, regs)], } self._refresh_list()

按 PC 聚合,原因是同一个 PC 会被不同对象实例反复命中(比如 str w8, [x0, #0x120] 对所有玩家的 HP 字段都有效)。聚合后一条显示 “count=1234”,展开 history 才看每个实例的寄存器快照。

7.5 Probable pointer 解码

def _decode_probable(insn_bytes, regs, watched_addr): """ARM64 probable pointer: 从 STR/LDR 指令解码 base+offset。""" try: md = Cs(CS_ARCH_ARM64, CS_MODE_ARM) md.detail = True insns = list(md.disasm(insn_bytes[:4], 0)) if not insns: return None insn = insns[0] mem_op = next((op for op in insn.operands if op.type == ARM64_OP_MEM), None) if not mem_op: return None base_name = insn.reg_name(mem_op.mem.base) if mem_op.mem.base else None if not base_name: return None disp = mem_op.mem.disp name_map = {f"x{i}": i for i in range(29)} name_map.update({"fp": 29, "lr": 30, "sp": 31}) idx = name_map.get(base_name) if idx is None: return None return { "base_reg": base_name, "base_idx": idx, "offset": disp, "probable_ptr": (watched_addr - disp) & 0xFFFFFFFFFFFFFFFF, "asm": f"{insn.mnemonic} {insn.op_str}", } except Exception: return None

原理:对 str w8, [x0, #0x120],如果命中地址是 Y,那么 x0 = Y - 0x120。这就是 CE “Find out what writes” 的核心卖点 —— 让用户直接拿到”容器对象”的地址,不必手动算偏移。info_memo 里会显示:

libue4.so+0x1440: >>str w8, [x0, #0x120] pointer → 0x76F12340E0 base x0 = 0x76F1234000, offset = 0x120

7.6 反汇编 + 指令读取

_read_insn(pc) 通过单独的 CE 连接读 4 字节指令:

def _read_insn(self, addr): try: return self._read_client.read_memory(self._read_handle, addr, 4) except: return None

注意 self._read_client 是独立 socket,不会和 BP engine 的 ctl/poll 互相阻塞。

7.7 “Show disasm” / “Save all data”

  • Show disasm — 调 open_memview_fn(pc) 跳到反汇编视图(具体实现见 反汇编 / Hexview
  • Save all data — 把 hit_map 按 count 降序导出到目录,每个 PC 一个 txt 文件:
000123_libue4.so+0x1440.txt 000045_libue4.so+0x1448.txt _summary.txt

每个 txt 包含:

  1. PC / Asm / Bytes / Watched addr / history 条数
  2. LR histogram — 把所有 history 里 regs[30] 按频率排序,让你看”哪个调用栈最多”
  3. 每条 hit 的完整 33 寄存器快照

对”从 ammo 字段反向追 Character → PlayerController”这类调用树搜索非常有用。

7.8 _stop() 的两段式

def _stop(self): sid = getattr(self, '_kf_slot_id', -1) if self._bp_engine and sid >= 0: self._bp_engine.clear_breakpoint(sid) self._kf_slot_id = -1 self.btn_stop.setText("Close") self.btn_stop.clicked.disconnect() self.btn_stop.clicked.connect(self.close)

第一次点 Stop —— 调 engine.clear_breakpoint(sid)窗口保留,按钮文字变 “Close”。捕到的 hit 数据仍然可见,可以继续 Save all data / Show disasm。第二次点 Close 才真正销毁窗口。避免手滑点一次就把辛苦捕到的数据全丢。


8. 断点类型与尺寸

8.1 类型映射

字符串整数HW_BREAKPOINT_*消耗槽位DBGWCR.LSC / DBGBCR
"r"1HW_BREAKPOINT_RWP slotLSC=0b01
"w"2HW_BREAKPOINT_WWP slotLSC=0b10
"rw"3HW_BREAKPOINT_RWWP slotLSC=0b11
"x"4HW_BREAKPOINT_XBP slotBCR (无 LSC)

客户端 _type_map = {'r': 1, 'w': 2, 'rw': 3, 'x': 4} 这组整数顺便直接就是 TCP 协议里 bp_type 的值。

8.2 尺寸

bp_len常量BAS 掩码典型场景
1HW_BREAKPOINT_LEN_10b0000_0001uint8 (HP / ammo byte)
2HW_BREAKPOINT_LEN_20b0000_0011uint16
4HW_BREAKPOINT_LEN_4 (默认)0b0000_1111int / float
8HW_BREAKPOINT_LEN_80b1111_1111int64 / double / 指针

perf_event_attr.bp_len 直接填 1/2/4/8,不需要转成掩码。内核 arch/arm64/kernel/hw_breakpoint.c:hw_breakpoint_parse() 会根据 bp_len 自己算出 BAS。

8.3 对齐要求

硬件约束:

  • DBGWVRn_EL1[1:0] == 0 — 4 字节对齐
  • DBGBVRn_EL1[1:0] == 0 — 4 字节对齐(AArch64 指令总是 4 字节对齐,自然满足)
  • BAS 字段让你在 8 字节窗口内挑选若干字节触发,所以 LEN_1 的 watch 实际上”定位在所在 8 字节对齐块上”,只是硬件只对目标那一字节响应

跨 8 字节边界的 4/8 字节监视需要两个 slot 拼接(一半在前 8 字节、一半在后 8 字节)。Linux hw_breakpoint_parse() 对此不自动拼接:非对齐的请求直接返回 -EINVAL。用户必须把 watch_addr 放在合适的对齐上(一般 4 字节对齐就够)。


9. 限制与反检测

9.1 单 BP 限制

usr_hwbp.hg_hwbp 只保存一组 (bp_addr, bp_type, bp_len),每次 hwbp_start() 会先 hwbp_stop_all()不支持并发多 BP。实际效果:

  • 用户开第二个 “Find what writes” 窗口时,第一个 BP 被替换
  • UI 层不会报错,只是第一个窗口的 hit 列表冻结
  • BpManagerDialog 显示还有多个 capture window “活着”,但只有最新的那个真正收到事件

这是 perf backend 的天生约束。要多槽并发请切其它 backend(见底部对比矩阵 + 交叉链接)。

9.2 硬件槽位上限

即使单 BP 也可能失败:某些系统进程(比如 init、surfaceflinger)的线程已经被 Android 框架占掉了 BP/WP 槽,新的 perf_event_open 返回 ENOSPChwbp_monitor_threadfprintf(stderr, ...) 打印到 server log 但不会报错回客户端,表现是某些线程上 BP 装不上。

9.3 噪声线程

即使过滤掉 RenderThread / mali-* / hwuiTask 等常见线程(见 §3.3),某些游戏的 Lua / 脚本引擎、事件调度器线程仍然会高频访问同一地址。表现是 hit_list 被某个 PC 占满。对策:

  • 在 info_memo 里看 thread comm(tid → /proc/<pid>/task/<tid>/comm)确认是哪个线程
  • 把该线程名加到 usr_hwbp.h:248blacklist[],重编 usr_server 部署

9.4 反检测姿态

检测面perf backend 状态
/proc/<pid>/status TracerPid:始终为 0
/proc/<pid>/stat wchan不卡 ptrace_stop
/proc/<pid>/syscallperf 的 syscall 在 monitor 进程(server),目标进程看不到
prctl(PR_GET_DUMPABLE)不被改
SIGTRAP 自检不发 SIGTRAP(perf 走 mmap ring,不经信号)
EL0 读 DBG* 寄存器EL0 mrs DBGBVRn_EL1 会触发 UNDEF,目标进程读不到,也不触发 ptrace 机制
/proc/<pid>/fd/ 里多出 perf fdfd 在 server 进程,不在目标进程,游戏扫不到

9.5 命中延迟

perf backend 的单次命中代价 ~160 ticks(SM8750 @3 GHz 约 8 μs),主要开销:

  1. EL0 → EL1 同步异常
  2. kernel_entry / context 保存
  3. do_mem_abortwatchpoint_handlerperf_event_overflow
  4. 写 ring buffer + 可能的 SS 单步 + ring buffer 写完成
  5. EL1 → EL0 kernel_exit / ERET
  6. Server 进程 poll 唤醒 + memcpy + TCP write

对高频 write 地址(比如每帧更新的 ammo 字段)这个延迟会导致目标游戏轻微掉帧。需要更低延迟请切更激进的后端,见 极致优化硬件断点


10. 对比矩阵

Shadow CE 三种断点后端对照:

维度perf_event_open (本页)extreme_hwbpptebp
实现位置用户态内核 ko内核 ko
核心 syscallperf_event_openioctl to shadow_ce.koioctl to shadow_ce.ko
细节本页extreme-hwbp.mdkernel-pte-bp.md
命中延迟~160 ticks见交叉链接见交叉链接
并发 BP 数1 (单 bp_addr)见交叉链接见交叉链接
粒度字节 (BAS)字节 (BAS)页级 4 KB + sub-page filter
跨线程per-tid monitor pthreadper-task perf_event共享 mm,一次生效
反检测无 TracerPid / SIGTRAP同左同左
适用场景单个精确 watch、开发/调试首选高频 write、多 watch 并发大范围扫描、指令执行断点、槽位多

perf backend 在开发阶段最友好 —— 失败都有 dmesg / errnoperf_event_open 是公开 API 文档齐全。性能敏感场景或需多槽并发时切其它后端。


附录 A: 代码索引

文件主要职责
shadow_ce/server/usr_hwbp.hperf_event_open backend 全部实现 (409 行)
shadow_ce/server/usr_server.cCMD 0xCC-0xCF 路由到 hwbp_handle_*
shadow_ce/client/bp_engine.pyBreakpointEngine 单例 + backend 切换 + poll 线程
shadow_ce/client/bp_manager.pyCtrl+B 管理对话框
shadow_ce/client/hwbp_window.py”Find what writes/accesses” 主窗口 + hit 聚合 + Capstone
shadow_ce/client/ce_client.pyhwbp_set / hwbp_clear / hwbp_poll TCP wrapper

附录 B: 关键寄存器速查

寄存器位域Shadow CE 默认
DBGBCRn_EL1E=1, PMC=0b10, HMC=0, SSC=0b01, BAS=0xF, BT=0b0000EL0 exact-address BP
DBGWCRn_EL1E=1, PAC=0b10, LSC=0b01/10/11, BAS 按 len, MASK=0EL0 exact-address WP
MDSCR_EL1.MDE必须为 1(内核启动时常开)由内核维护
DBGBVRn_EL1目标 VAarch_install_hw_breakpoint
DBGWVRn_EL1目标 VA同上

附录 C: PERF_REG_ARM64 index

数组下标寄存器
0–28x0–x28
29fp (x29)
30lr (x30)
31sp
32pc
Last updated on