用户态硬件断点
User-mode Hardware Breakpoints 是 Shadow CE 的 “Find what writes / accesses / executes” 模块的标准后端。路径是:Python UI → BreakpointEngine → TCP → usr_hwbp.h → perf_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_EL1 | Breakpoint Value Register (监视的虚拟地址) | 2 – 16 (SM8750 = 6) |
DBGBCRn_EL1 | Breakpoint Control Register (类型 / EL / 使能) | 与 BVR 同数量 |
DBGWVRn_EL1 | Watchpoint Value Register (监视的数据地址) | 2 – 16 (SM8750 = 4) |
DBGWCRn_EL1 | Watchpoint Control Register (R/W mask / LEN / EL) | 与 WVR 同数量 |
MDSCR_EL1 | 全局调试状态 (KDE, MDE, SS) | 1 |
OSLAR_EL1 | OS Lock Access (内核维护,用户态不触) | 1 |
骁龙 SM8750(OnePlus Ace 5 Pro 的 SoC)的资源:nr_bp = 6, nr_wp = 4。Linux 内核启动时读 ID_AA64DFR0_EL1 得到这组数量,放进 arch/arm64/kernel/hw_breakpoint.c 的 core_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 / PAC、HMC、SSC 的组合决定断点在哪个 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 = 1 在 arch/arm64/kernel/hw_breakpoint.c:encode_ctrl_reg() 里展开出的位型,用户态看不到但值相同。
1.5 Linux 调试子系统的抽象层
内核把 DBG* 寄存器抽象成两层:
- per-thread (
thread_struct.debug.hbp_break[] / hbp_watch[]) — 一个perf_event挂在某个task_struct上,记录 BP/WP 的配置,调度器切换到这个 task 时加载到寄存器。 - 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/status 的 TracerPid: 字段,一旦非零立刻封号。
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;
}黑名单的理由:
| 线程名 | 为什么不监视 |
|---|---|
RenderThread | Android Framework 渲染线程,每帧高频访问内存,会淹掉命中列表 |
mali-* | GPU driver worker,和游戏逻辑无关 |
hwuiTask | 硬件加速 UI,同上 |
NDK MediaCodec_* | 解码器线程,纯粹噪声 |
binder:* | IPC 线程,大量 kernel 交互 |
FinalizerDaemon | JVM 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);字段逐项解释:
| 字段 | 值 | 含义 |
|---|---|---|
type | PERF_TYPE_BREAKPOINT | 让 perf 创建 HW breakpoint 事件而不是 counter |
bp_type | HW_BREAKPOINT_R/W/RW/X | 读/写/读写/执行 (对应 LSC 或 BCR 用法) |
bp_addr | 64 bit VA | 要监视的地址 |
bp_len | HW_BREAKPOINT_LEN_{1,2,4,8} | 监视长度,对应 BAS 掩码 |
sample_type | TID | REGS_USER | 每条 sample 包含 tid 和全套用户寄存器 |
sample_period = 1 | 每次命中就采一次(没有下采样) | |
sample_regs_user = 0x1_FFFF_FFFF | 33 位全 1,对应 PERF_REG_ARM64 的 x0..x28, fp, lr, sp, pc | |
wakeup_events = 1 | 每条 sample 立刻触发 poll(延迟最低) | |
exclude_kernel = 1 | EL0 监视,和 DBGWCR.PAC=0b10 对应 | |
precise_ip = 2 | PERF_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, ctrl3.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 结尾采样流:
- 内核收到 BP/WP 命中 →
perf_event_overflow()→ 格式化PERF_RECORD_SAMPLE→ 写 ring data_head += record_size(原子递增)- 如果
wakeup_events = 1,内核wake_up(&ring->poll)唤醒用户态poll - 用户态读
[data_tail, data_head)区间的记录 - 用户态更新
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_SAMPLE—PERF_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_event,perf_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_SET | 0xCC | → server | 安装 BP/WP |
CMD_HWBP_CLEAR | 0xCD | → server | 清掉当前 BP |
CMD_HWBP_CLEAR_ALL | 0xCE | → server | 等同 CLEAR(单 BP 语义) |
CMD_HWBP_POLL | 0xCF | → 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_pidbp_type取HW_BREAKPOINT_R=1,_W=2,_RW=3,_X=4bp_len取HW_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, Page | client.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_hits,pyqtSignal.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 = 0x1207.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 包含:
- PC / Asm / Bytes / Watched addr / history 条数
- LR histogram — 把所有 history 里 regs[30] 按频率排序,让你看”哪个调用栈最多”
- 每条 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" | 1 | HW_BREAKPOINT_R | WP slot | LSC=0b01 |
"w" | 2 | HW_BREAKPOINT_W | WP slot | LSC=0b10 |
"rw" | 3 | HW_BREAKPOINT_RW | WP slot | LSC=0b11 |
"x" | 4 | HW_BREAKPOINT_X | BP slot | BCR (无 LSC) |
客户端 _type_map = {'r': 1, 'w': 2, 'rw': 3, 'x': 4} 这组整数顺便直接就是 TCP 协议里 bp_type 的值。
8.2 尺寸
bp_len | 常量 | BAS 掩码 | 典型场景 |
|---|---|---|---|
| 1 | HW_BREAKPOINT_LEN_1 | 0b0000_0001 | uint8 (HP / ammo byte) |
| 2 | HW_BREAKPOINT_LEN_2 | 0b0000_0011 | uint16 |
| 4 | HW_BREAKPOINT_LEN_4 (默认) | 0b0000_1111 | int / float |
| 8 | HW_BREAKPOINT_LEN_8 | 0b1111_1111 | int64 / 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.h 的 g_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 返回 ENOSPC。hwbp_monitor_thread 会 fprintf(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:248的blacklist[],重编 usr_server 部署
9.4 反检测姿态
| 检测面 | perf backend 状态 |
|---|---|
/proc/<pid>/status TracerPid: | 始终为 0 |
/proc/<pid>/stat wchan | 不卡 ptrace_stop |
/proc/<pid>/syscall | perf 的 syscall 在 monitor 进程(server),目标进程看不到 |
prctl(PR_GET_DUMPABLE) | 不被改 |
SIGTRAP 自检 | 不发 SIGTRAP(perf 走 mmap ring,不经信号) |
| EL0 读 DBG* 寄存器 | EL0 mrs DBGBVRn_EL1 会触发 UNDEF,目标进程读不到,也不触发 ptrace 机制 |
/proc/<pid>/fd/ 里多出 perf fd | fd 在 server 进程,不在目标进程,游戏扫不到 |
9.5 命中延迟
perf backend 的单次命中代价 ~160 ticks(SM8750 @3 GHz 约 8 μs),主要开销:
- EL0 → EL1 同步异常
kernel_entry/ context 保存do_mem_abort→watchpoint_handler→perf_event_overflow- 写 ring buffer + 可能的 SS 单步 + ring buffer 写完成
- EL1 → EL0
kernel_exit/ ERET - Server 进程
poll唤醒 + memcpy + TCP write
对高频 write 地址(比如每帧更新的 ammo 字段)这个延迟会导致目标游戏轻微掉帧。需要更低延迟请切更激进的后端,见 极致优化硬件断点。
10. 对比矩阵
Shadow CE 三种断点后端对照:
| 维度 | perf_event_open (本页) | extreme_hwbp | ptebp |
|---|---|---|---|
| 实现位置 | 用户态 | 内核 ko | 内核 ko |
| 核心 syscall | perf_event_open | ioctl to shadow_ce.ko | ioctl to shadow_ce.ko |
| 细节 | 本页 | 见 extreme-hwbp.md | 见 kernel-pte-bp.md |
| 命中延迟 | ~160 ticks | 见交叉链接 | 见交叉链接 |
| 并发 BP 数 | 1 (单 bp_addr) | 见交叉链接 | 见交叉链接 |
| 粒度 | 字节 (BAS) | 字节 (BAS) | 页级 4 KB + sub-page filter |
| 跨线程 | per-tid monitor pthread | per-task perf_event | 共享 mm,一次生效 |
| 反检测 | 无 TracerPid / SIGTRAP | 同左 | 同左 |
| 适用场景 | 单个精确 watch、开发/调试首选 | 高频 write、多 watch 并发 | 大范围扫描、指令执行断点、槽位多 |
perf backend 在开发阶段最友好 —— 失败都有 dmesg / errno,perf_event_open 是公开 API 文档齐全。性能敏感场景或需多槽并发时切其它后端。
附录 A: 代码索引
| 文件 | 主要职责 |
|---|---|
shadow_ce/server/usr_hwbp.h | perf_event_open backend 全部实现 (409 行) |
shadow_ce/server/usr_server.c | CMD 0xCC-0xCF 路由到 hwbp_handle_* |
shadow_ce/client/bp_engine.py | BreakpointEngine 单例 + backend 切换 + poll 线程 |
shadow_ce/client/bp_manager.py | Ctrl+B 管理对话框 |
shadow_ce/client/hwbp_window.py | ”Find what writes/accesses” 主窗口 + hit 聚合 + Capstone |
shadow_ce/client/ce_client.py | hwbp_set / hwbp_clear / hwbp_poll TCP wrapper |
附录 B: 关键寄存器速查
| 寄存器 | 位域 | Shadow CE 默认 |
|---|---|---|
DBGBCRn_EL1 | E=1, PMC=0b10, HMC=0, SSC=0b01, BAS=0xF, BT=0b0000 | EL0 exact-address BP |
DBGWCRn_EL1 | E=1, PAC=0b10, LSC=0b01/10/11, BAS 按 len, MASK=0 | EL0 exact-address WP |
MDSCR_EL1.MDE | 必须为 1(内核启动时常开) | 由内核维护 |
DBGBVRn_EL1 | 目标 VA | 由 arch_install_hw_breakpoint 写 |
DBGWVRn_EL1 | 目标 VA | 同上 |
附录 C: PERF_REG_ARM64 index
| 数组下标 | 寄存器 |
|---|---|
| 0–28 | x0–x28 |
| 29 | fp (x29) |
| 30 | lr (x30) |
| 31 | sp |
| 32 | pc |