反汇编 / Hexview
本页专讲 Shadow CE 客户端的两个核心阅读视图 —— Hex view 与 Disasm view —— 以及承载它们的 Memory View window。重点是 widget 的渲染实现、交互、与 cache 的协作。
- 全局 UI 布局(主窗口、菜单、dock)在 UI 设计 一章。
- 页级读取、fade 检测、TCP 往返由 内存扫描 一章覆盖共用
组件
mem_cache.MemoryCache,本页不重复。 - 从 hex view 右键出的断点窗口行为见 用户态硬件断点 / 内核 PTE 断点 / 极致优化硬件断点。
1. 定位与关系
Shadow CE 的 Memory View 仿照 Cheat Engine,把”看代码”和”看字节”做成 上下分栏的两个独立 widget:
| 视图 | 文件 | 主要用途 | 粒度 | 可写 |
|---|---|---|---|---|
| DisasmView | disasm_view.py | ARM64 反汇编阅读、跳转追踪、分支可视化 | 4 字节一行 | 否(当前只读) |
| HexView | hex_view.py | 查看/修改字节、fade 高亮最近变化、选中范围下断点 | 16 字节一行 | 是(hex 与 ASCII 双模式) |
两者 不共享父类。它们分别直接继承 QWidget,只在外部(MemoryViewWindow)
被组合。共用的只有:
- 同一份
MemoryCache(mem_cache.py,页粒度 4096 字节) - 同一份字体
Fonts.mono()(fonts.py) - 同一份颜色表
MEMVIEW_COLORS(style.py:216)
也就是说,memory_view.py 不是抽象基类,而是 宿主窗口:它实例化 disasm
和 hex 两个 widget,把它们塞进 QSplitter,再建立两条信号(地址联动、断点
请求)把它们串起来。
两条 TCP 连接(_refresh_client 走 500ms 定时重扫、_fetch_client 走按需
补页)是 Memory View 不卡顿的关键,见 memory_view.py:58-75。
2. Hex view 实现
hex_view.py(593 行)整体是一个 自绘 QWidget(不是 QTextEdit),
渲染走 offscreen QPixmap 双缓冲,同时有一整套”零每帧分配”的预计算表。
2.1 行布局
每行固定 16 字节,分三段:
012ABCDEF000 00 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF | ASCII....
└── 12位hex地址 ─┘└──── 8字节 ─────┘ └──── 8字节 ─────┘ └─16 ASCII─┘几何参数在 _calc_geometry()(hex_view.py:105)里按 QFontMetrics 算出:
_cw= 单字符宽度('0'的 horizontalAdvance)_lh= 行高 =fm.height() + 4_addr_w=_cw * 13 + 8_hex_x起点 =_addr_w- 8 字节处插一条竖线分隔符(
_pen_sep) - hex 与 ASCII 之间隔
_cw * 2
16 字节的 x 位置在进入渲染循环前 一次性预计算好(_hex_col_x[]),
循环里只做查表。
2.2 预计算表
模块级单例,启动时就算好:
| 常量 | 位置 | 用途 |
|---|---|---|
_c | hex_view.py:11 | MEMVIEW_COLORS dict 翻译成 QColor 对象 |
_HEX | hex_view.py:12 | [f"{i:02X}" for i in range(256)] — 256 个 2 字符字符串 |
_ASCII | hex_view.py:13 | 256 个 ASCII 单字符(不可打印→.) |
_FADE_COLORS | hex_view.py:15-21 | 21 步”最近改动”→“正常”的渐隐颜色 |
实例级预缓存(__init__ 里):
_pen_normal / _pen_zero / _pen_addr / _pen_ascii / _pen_dot / _pen_hdr / _pen_sel / _pen_edit / _pen_cursor / _pen_sep / _pen_code
目的就一个:paintEvent 里不 new QColor、不 new QPen、不 new QFont。
2.3 自绘 paintEvent — offscreen buffer 双缓冲
def paintEvent(self, event):
p = QPainter(self)
p.setFont(self._font)
self._render(p)
p.end()实际工作在 _render()(hex_view.py:128)里。它支持两种模式:
- 如果传入了
QPainter(正常路径),直接画到 widget。 - 如果
p is None,创建_buf: QPixmap离屏画布,画完可复用。 (当前 paintEvent 每帧都传 painter,离屏 buffer 保留给mark_dirty()优化。)
渲染循环里的”绘色最小化”:
cur_pen_id 追踪上一次 setPen 的类别,只有类别变了才真的 setPen。对
16 × N 行的密集绘图,这省掉绝大多数 pen 切换。
2.4 Fade(最近改动高亮)
MemoryCache.get_change_alphas(base, size)(mem_cache.py:111)一次性返回
[alpha_0, alpha_1, ...]。_render 拿到这个列表后,每字节查一次,映射到
_FADE_COLORS 的 21 级渐隐 pen。
alpha == 0.0→ 正常颜色(白色或执行段绿)alpha > 0.0→ pen_id ='fade',从_FADE_COLORS[idx]取色
FADE 生命期 = MemoryCache.FADE_MS = 1000ms(mem_cache.py:11)。注意
fade 计算只调一次 time.time() —— 这是刻意的,见 mem_cache.py:112 注释。
2.5 选中、光标、编辑状态机
_cursor是当前字节的 offset(相对_base_addr),-1 表示无。_sel_start / _sel_end构成选中范围,鼠标按下时跟_cursor一致,拖动 时_sel_end跟着走。_edit_nibble ∈ {0, 1}跟踪 hex 模式下当前在改字节的高位还是低位半字节。_edit_region ∈ {'hex', 'ascii'}区分光标在哪一侧 —— 按 Tab 切换。
渲染时:
- 光标所在 byte → 填
edit_bg,pen 切到edit;ascii 那列也一起染。 - 选中范围内 byte → 填
sel_bg,pen 切到sel。 - 执行段(
set_executable(True))→ 正常 byte 改用绿色 pen(_pen_code), ASCII 也绿。
光标竖线由 _pen_cursor(红色 2px)画在相应列的左边缘。
2.6 编辑写入(write-through)
HexView 自己不连 TCP,它持有 write_fn(由宿主传入,memory_view.py:180
给的是 lambda a, d: self._own_client.write_memory(self.handle, a, d))。
两条编辑路径:
Hex 键入(_edit_hex,hex_view.py:472):
- 读当前字节(blocking=True —— 必须实时)
- 替换对应 nibble
write_fn(addr, bytes([b]))cache.invalidate_page(addr)让下次 refresh 真的 re-readself._dirty = True、self.update()- 第二个 nibble 写完后
_cursor += 1自动前进
ASCII 键入(_edit_ascii):单字符 UTF-8 → 直接写一字节 → 前进。
双击对话框(mouseDoubleClickEvent,hex_view.py:375):
弹 QInputDialog.getText,允许一次改多字节的 hex string(空格允许但会剥掉)。
注意 不缓存写入 —— 写完马上 invalidate 再靠 refresh timer 拉新数据, 保证”屏幕上看到的等于游戏进程里的”。
2.7 键盘与右键菜单
keyPressEvent(hex_view.py:393)支持的组合:
| 按键 | 行为 |
|---|---|
| ↑↓←→ | 移动光标(自动翻页) |
| PageUp / PageDown | 按 _visible_lines - 1 翻页 |
| Tab | 切换 hex / ASCII 编辑区 |
| Esc | 清掉光标与选区 |
| Space | Follow pointer(把光标处 8 字节当小端地址跳过去) |
| Ctrl+G | Goto 对话框 |
| Ctrl+C | 拷贝选区(hex mode 输出空格分隔 hex,ASCII mode 输出可见字符) |
| 0-9 a-f | 编辑 hex |
| 可打印 ASCII | 编辑 ASCII |
| Ctrl+Shift+F | 打印字体调试信息(开发用) |
右键菜单(contextMenuEvent,hex_view.py:337):
- Edit(等同双击)
- Goto Address (Ctrl+G)
- Copy to clipboard (Ctrl+C)
- Data Breakpoint 子菜单:
- Find what writes → 发
bp_requested(addr, size, 'w') - Find what accesses →
'rw' - Break and trace →
'x'
- Find what writes → 发
bp_requested 由宿主 MemoryViewWindow._on_bp_requested
(memory_view.py:358)接住并打开 HwbpWindow。
2.8 滚动
自带 QScrollBar(right side,宽 16px),range 0-100,value 固定在 50:
每次滚一格就改 _base_addr,然后把 value 重置回 50(避免”拖到底”的概念)。
wheelEvent 同理:一次 angleDelta/40 行 × 16 字节。
这是一种典型的 infinite scroll 模型 —— 地址空间太大,没法直接映射到 scrollbar 刻度,所以只借 scrollbar 当”连续滚动输入”。
3. Disasm view 实现
disasm_view.py(731 行)同样是自绘 QWidget,比 hex 多了:
Capstone 反汇编、跳转箭头、列头可拖拽、中英文指令提示。
3.1 反汇编引擎 = Capstone
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM
self._cs = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
self._cs.detail = True位置:disasm_view.py:181-186。带 try/except ImportError 兜底 —— capstone
没装时 _instructions = [],整个 view 空白但不崩。不支持模式切换(没有
Thumb、没有 x86),因为 Shadow CE 的靶机就是 ARM64 Android。
detail = True 用来拿 insn.groups、insn.operands,判断分支/调用和抽
branch target。
3.2 逐条 decode(而不是一次流式 disasm)
关键细节在 _decode()(disasm_view.py:247):
for i in range(0, min(len(raw), n * 4), 4):
chunk = raw[i:i+4]
decoded = list(self._cs.disasm(chunk, addr))
if decoded:
insn = decoded[0]
...
else:
val = int.from_bytes(chunk, 'little')
self._instructions.append(DisasmLine(
addr=addr, mnemonic="db", op_str=f"0x{val:08X}", ...))Capstone 遇到非法指令会整体停下来。如果一段 ROM 里混了跳转表、字符串、
导入表,流式 disasm 会在第一条非法 insn 截断,后面全看不见。所以这里
强制一次只喂 4 字节,非法就标 db 0x....(CE 风格),保证每行都有东西。
代价是循环多了点,但因为只 decode 可见行 + 4 条 padding,体感无差。
3.3 分支检测与箭头
DisasmLine 的 dataclass(disasm_view.py:15)记录:
is_branch: bool # CS_GRP_JUMP ∈ groups
is_call: bool # CS_GRP_CALL ∈ groups
is_conditional: bool # is_branch and mn not in (b, br, ret)
branch_target: int # 首个 ARM64_OP_IMM 操作数
comment: str # resolve_fn(target) → "UE4+0x1234"_build_arrows()(disasm_view.py:303)再扫一遍 _instructions,构造
JumpArrow,dst_idx 语义:
>= 0:可见范围内的行索引-1:目标在可见上方-2:目标在可见下方
颜色按类别映射:arrow_call='#ccaa44'、arrow_cond='#cc4444'、
arrow_uncond='#008000'(style.py:226-228)。条件跳转用虚线
(Qt.PenStyle.DashLine)。
_draw_arrows()(disasm_view.py:463)画成 L 形:水平 → 垂直 → 水平,
带三角箭头。slot(slot = counter % 5)让多条重叠的跳转在 x 方向错开。
3.4 三列布局(百分比 + 拖拽)
与 hex 的固定几何不同,disasm 的列宽是 百分比 + 用户可拖拽:
| 列 | 字段 | 默认占比 | 范围 |
|---|---|---|---|
| Address | _col_addr_pct | 25% | 10%-50% |
| Bytes | _col_bytes_pct | 15% | 5%-40% |
| Opcode | 剩余 | - | - |
拖拽触发点:_hit_col_sep(disasm_view.py:641)—— 鼠标在列分隔线附近
5px 时,光标切 SplitHCursor,按下后进入 _drag_col 模式,移动时按实时
x 重算 pct。
Header 还有 hover / pressed 高亮(_hover_col / _pressed_col),用
QLinearGradient 画”上深下浅”的按钮感,见 disasm_view.py:354-372。
3.5 选中行
单选(不是多选):_selected 是行索引。
- 鼠标点击非 header 区域 → 设
_selected、emitinsn_selected(desc)(disasm_view.py:673) - ↑↓ 键移动
_selected,到边界时滚 4 字节
被选中的行画蓝底(_sel_bg = #2A82DA)+ 虚线黄框(#e0c030)。
insn_selected 信号把助记符的人类可读解释(中英文各一份表)送到
MemoryViewWindow 的 lbl_insn_hint 那条横条上显示,见
memory_view.py:182。中英文切换由 i18n.get_lang() 决定
(disasm_view.py:128-135)。
3.6 语法着色
操作数着色走正则 tokenize:
r'([xwqbhsdv]\d{1,2}|sp|lr|xzr|wzr|fp|pc|nzcv)|' # register
r'(#-?0x[0-9a-fA-F]+|#-?\d+)|' # immediate
r'([\[\]{}!,\s]+)|' # punctuation
r'([^\s,\[\]{}!#]+)' # other (labels)每种 token 绑一种 pen color:register 红、immediate 蓝、符号/标签绿、标点
白。实现在 _draw_operands()(disasm_view.py:441)。
助记符本身用 bold 字体 画(_bold_font),分支类用
branch_mn='#ffdd66'(暖黄),其余白色。
3.7 符号解析
resolve_fn: addr -> str 由宿主注入(memory_view.py:178
→ MemoryViewWindow.resolve_module,memory_view.py:256)。
实现是 二分 module 列表(按 base 排序),命中返回
libUE4+0x1234 这样的短名 + 文件内偏移。Module 列表在窗口打开时
一次性从 server 拉下来(module_list,memory_view.py:112)。
两处会用到:
- Address 列:优先显示
module+offset,fallback 原始十六进制 - 分支 comment:
;后边的跳转目标名,仅当目标落在某 module 内
3.8 跳转 / 历史栈
_back_stack: list[int] 记录每次 follow 前的 _base_addr。
| 操作 | 行为 |
|---|---|
双击带 branch_target 的行 | 压栈,跳过去 |
| Space | 同上 |
| Backspace | 弹栈,回到上一处 |
| Ctrl+G | 弹对话框输入 hex 地址,压栈跳 |
| G(无 modifier) | 等同 Ctrl+G |
历史栈是 disasm 自己的。和 MemoryViewWindow._history 不是同一个 ——
后者是整个窗口的地址历史,被 toolbar 的 < > 按钮用。双轨历史在
宿主里靠 _on_disasm_nav 桥接(memory_view.py:349)。
3.9 不支持编辑
Disasm view 当前只读。右键菜单没实现,下断点要走 hex view 的 Data Breakpoint 菜单。原因:汇编级 patch 需要 assembler(keystone 或 手写 encoder),整个项目目前不做 instrumentation,所有断点是 HW-BP 或 内核 PTE-BP —— 不改目标字节。
4. 宿主 MemoryViewWindow 与视图联动
memory_view.py 的 MemoryViewWindow(只 420 行)基本只干三件事:
- 开两条独立 TCP 连接(refresh / fetch 不互相阻塞)
- 把 disasm + hint + hex 塞进
QSplitter(垂直) - 串信号
4.1 双连接策略
# Connection 1: background refresh (500ms timer)
self._refresh_client = CEClient()
self._refresh_handle = self._refresh_client.open_process(pid)
# Connection 2: on-demand fetch (click / paint cache miss)
self._fetch_client = CEClient()
self._fetch_handle = self._fetch_client.open_process(pid)MemoryCache 构造时传两个读函数:
self.cache = MemoryCache(_fetch_read, refresh_read_fn=_refresh_read)两条连接各自带断线自动重连(memory_view.py:83-89 / 97-103)。
4.2 三大信号
self.disasm.address_changed.connect(self._on_disasm_nav)
self.hex_view.address_changed.connect(self._on_hex_nav)
self.hex_view.bp_requested.connect(self._on_bp_requested)
self.disasm.insn_selected.connect(lambda desc: self.lbl_insn_hint.setText(desc))disasm.address_changed→ 同步 hex view 地址、更新 toolbar、压历史栈hex_view.address_changed→ 更新底部DataInspectorhex_view.bp_requested(addr, size, type)→ 打开HwbpWindowdisasm.insn_selected(desc)→ 填中间 hint bar
4.3 DataInspector
memory_view.py:15 的小 QLabel,显示当前光标 8 字节解读成
byte / word / int32 / int64 / float / double 的所有值,CE 经典功能。
选区有多字节时显示范围 XXXX - YYYY (N bytes)。
4.4 刷新循环
_start_timer(memory_view.py:214)起一个 500ms QTimer + 一个长驻
reader thread。每 tick:
- 取两 view 的
visible_range()(hex 按_visible_lines+2、 disasm 按_visible_lines+4) queue.put_nowait(ranges)(队列大小 1,drop if full —— 防抖)- reader thread 调
cache.refresh(ranges)(TCP 往返、fade 检测) - 主线程
disasm.mark_dirty()+hex.mark_dirty()触发重绘 - 刷新 DataInspector
这种 “定时 refresh + 画面查 cache” 的分离让 paint 永远不走 TCP, rendering 延迟只受本地 CPU 影响。
4.5 Goto 与 module 解析
Toolbar 的 goto 支持三种输入(_on_goto,memory_view.py:320):
| 输入 | 行为 |
|---|---|
libUE4+0x1234 | 找 module,跳到 base+offset |
libUE4 | 找 module,跳 base |
0x1234ABCD / 1234ABCD | 纯十六进制地址 |
Module 查找(_find_module)支持精确、partial、stem 三档 fallback。
5. 数据访问路径
视图本身 从不调 CEClient.read_memory,全走 cache。
关键字段(ce_client.py:104-136):
read_memory(handle, addr, size)— 单次读(命令0x09)read_memory_batch(handle, requests)— 批量读(命令0xCB,一次 round-trip N 对(addr,size))write_memory(handle, addr, data)— 单次写(命令0x0A)
HexView 的 blocking=True 读(_edit_hex 读当前字节)是唯一一个
走 blocking 路径的地方 —— 因为要保证 nibble 编辑是 read-modify-write 原子。
除此之外 所有读都是非阻塞 + 返回零 placeholder,靠 cache fetch thread
兜底。
Page 粒度与大小
MemoryCache.PAGE_SIZE = 4096、MAX_PAGES = 128
(mem_cache.py:9-10)—— 整个 cache 占用上限约 512KB,LRU 淘汰。
_merge_runs() 会把连续页合并成一次 TCP 请求,减少 syscall 和 round-trip。
6. 性能与渲染策略
| 目标 | 实现 |
|---|---|
| 零每帧分配 | 模块级 _HEX / _ASCII / _FADE_COLORS;实例级 pen 全部在 __init__ 缓存 |
| 最小 setPen 切换 | cur_pen_id 追踪,类别相同就不切 |
| 预计算列 x | _hex_col_x 在循环前一次 build |
| 一次 time.time() | get_change_alphas 批量算 fade |
| Offscreen buffer | QPixmap _buf,仅当 _dirty 或尺寸变化时重画 |
| TCP 解耦 | Paint 只查 cache;refresh 在独立 thread + queue |
| Queue drop-if-full | maxsize=1,防刷新累积 |
| LRU page cache | 128 × 4KB,连续页合并成一次 round-trip |
| Refresh 只覆盖可见范围 | visible_range() 返回 viewport + 小量 overscan |
字体是 Fonts.mono()(fonts.py)—— 统一用一个 monospace family,跨视图
对齐到同一 baseline。Hex view 用 NoAntialias style strategy(hex_view.py:45),
让像素网格更清晰;disasm 用默认策略,bold 的助记符更软。
7. 快捷键与右键菜单总表
Hex view
| 按键 | 动作 |
|---|---|
| ←↑→↓ | 移动光标(跨页自动翻) |
| PageUp/Down | 翻可见高度 - 1 行 |
| Tab | 切 hex / ASCII 编辑区 |
| Esc | 清光标 + 选区 |
| Space | Follow pointer(小端 8 字节) |
| Ctrl+G | Goto address 对话框 |
| Ctrl+C | 复制选区(hex 或 ASCII 格式随当前 region) |
| 0-9 a-f | 编辑当前 nibble |
| 32-126 ASCII | 编辑 ASCII |
| Ctrl+Shift+F | Dump 字体诊断到 stdout |
| 双击 | 弹对话框编辑 4 字节 hex |
| 右键 | Edit / Goto / Copy / Data Breakpoint (w/rw/x) |
Disasm view
| 按键 | 动作 |
|---|---|
| ↑↓ | 选中上/下一行(跨边界滚 4 字节) |
| PageUp/Down | 翻 visible-1 行 |
| Space 或双击 | Follow 分支目标(压栈) |
| Backspace | 回到上一个 follow 点 |
| Ctrl+G / G | Goto 对话框 |
| Ctrl+C | 复制选中行(含地址 + 助记符 + 操作数) |
| Ctrl+Shift+F | Dump 字体诊断到 stdout |
| 拖 header 分隔线 | 调整列宽 |
8. 与其它功能的挂钩
- Address list → Memory View:主窗口地址行右键 “Browse memory region”
(
main_window.py:1380 / 1607)调_open_memory_view(addr)(main_window.py:2156)。 - 扫描结果 → Memory View:扫描结果表右键 “Browse this address”
(
main_window.py:1379-1380)同样_open_memory_view(addr)。 - Hex view → HW/PTE 断点:Data Breakpoint 菜单发
bp_requested, 被MemoryViewWindow._on_bp_requested接住打开 HwbpWindow,同时支持 w / rw / x 三种类型。 - Disasm view → 指令解释:
insn_selected在 hint bar 显示 helper 文本,中英文根据i18n.get_lang()切换。 - 符号解析:
resolve_fn依赖主窗口module_list(memory_view.py:112), 所以 Memory View 打开时会先拉一次 module 清单。
相关交叉阅读: