每隔一段时间,讨论区里就会冒出这样一条帖子:贴出来的是几十行编译报错,或者一段跑到某个地址就黑屏退出的日志,配文只有一句"按教程走完了,卡在这"。这类问题看着五花八门,实际上八成落在同一批位置——间接跳转没被解析、函数边界切错了、字节序在一处漏了转换。N64Recomp 这类静态重编译工具把主机程序翻译成 C 源码,本质上是把一份"运行时才知道答案"的程序,硬生生在不运行的前提下拆解成可读代码,信息缺口是它天生的属性。这篇内容围绕社区里被问得最多的那批难题展开,讲清楚静态重编译在每个环节会遇到什么、为什么会遇到、以及从业者实际是怎么绕过去的。适合已经跑通基础流程但卡在具体报错上的朋友,也适合刚接触重编译、想先建立整体地图的读者。
1. 静态重编译跑的是哪条链路,缺口又出在哪
先把整条链路摊开讲,后面所有问题的定位都依赖这张地图。很多人一上来就盯着报错行看,其实先搞清楚"我现在处在第几阶段",效率会高一个数量级。
1.1 从一份二进制到一个能编译的 C 工程
静态重编译的处理流程大致分四段。第一段是装载与分段:把二进制按头部信息切开,分出代码段、数据段、只读数据段,同时识别它的字节序形态——同一份内容在不同分发格式下可能是大端、小端或半字交换的,工具如果没识别对,后面全是乱码。第二段是反汇编与函数发现:从入口点和已知符号出发做递归下降反汇编,沿着直接跳转和调用指令一路走下去,把可达的地址标记成指令,剩下的标记成数据。第三段是中间表示与翻译:把每条机器指令映射成一段 C 语句,寄存器变成结构体成员或局部变量,分支变成goto标签或switch分派。第四段是产物组织:生成一堆.c文件、一个统一的符号表、一份跳转目标映射表,再配一个可编译的构建脚本。
这四个阶段里,真正"确定"的只有第一段和第三段——指令语义是死的,翻译规则写死就行。第二段和第四段才是活的,也是所有麻烦的来源。因为"这段字节到底是指令还是数据",静态分析给出的永远是一个猜测,而不是事实。工具猜错一个函数起点,后面几条指令的翻译就整体错位,最终表现为编译期语法错误或者运行期莫名其妙的非法地址访问。
所以看报错时我的习惯是:先判断它属于"翻译期就对不上"还是"翻译期看着对、运行期才崩"。前者多半是函数发现出了问题,后者多半是间接控制流或者内存语义没补全。
1.2 为什么"静态"这两个字决定了难度上限
解释执行的模拟器不需要知道函数边界,它一条一条取指、解码、执行,遇到跳转就跳,运行时天然正确。静态重编译没有这个奢侈——它必须在编译期就把控制流图定下来,因为 C 语言的分支目标必须是编译期可见的标签。
这就带来一个硬性矛盾:程序里存在大量目标地址要到运行时才能算出来的跳转。典型形式是先从一个表里取出目标地址,再执行一条寄存器跳转指令。这个表可能在只读数据段里,也可能是运行时才填进去的,静态分析只能看到"这里有个寄存器跳转",看不到它可能去哪些地方。
业界的通行解法是给每个可能被间接跳转到的目标建一张运行时映射表,翻译时把寄存器跳转改写成一次查表加一次分派。这个思路本身不复杂,但落地时会牵出一堆细节:表里要放哪些地址、地址怎么和函数指针关联、查不到时怎么办。后面第 2 章会专门展开。
1.3 社区提问里反复出现的三类困惑
把近期讨论区的帖子粗粗归个类,大致是三堆。
第一堆是**"编译过了但一运行就崩"**。报错通常是一个看起来很像代码地址的值,或者干脆是段错误。这类问题九成和间接跳转、函数边界、栈帧布局有关。
第二堆是**"画面出来了但不对"**。模型正常、贴图错位、颜色发紫、帧率抖动。这类问题基本都在图形子系统的对接层,跟重编译出来的逻辑代码关系不大,但排查难度反而更高,因为没有崩溃点可以下断。
第三堆是**"能跑但撑不了多久"**。跑几分钟后音频开始爆音、内存占用缓慢上涨、某段过场直接卡死。这类问题最考验耐心,往往要盯着内存写入和 DMA 时序看很久。
下面四章按这个分类逐个拆。每一章我都会先讲清机制,再给排查路径,最后附上我自己踩过或者帮别人定位过的具体案例。
2. 间接跳转与函数边界:绝大多数崩溃的第一现场
如果只能挑一个主题讲透,我会选这个。社区里那些"跑到某个地址就挂"的帖子,十有七八能追到这两件事上。
2.1 跳转表:静态分析拿不到的那块拼图
编译器的优化器很喜欢把多路分支编译成跳转表。源码里一个switch,落到机器码就是"把索引左移两位、加上表基址、取出目标地址、跳过去"。静态重编译工具看到的是取地址和寄存器跳转这两条指令,它不知道表在哪里、表有多长、表里装的是什么。
处理办法是在翻译阶段把这类模式识别出来,然后在生成的 C 代码里改成一次显式查表。识别逻辑通常是模式匹配:先看前面几条指令有没有"索引乘以 4 再加基址"的形态,再看基址是不是一个可解析的常量地址。命中之后,把表的内容读出来,逐个验证目标地址是否落在已知代码区间内,落在区间内的登记为跳转目标。
这里有个坑特别值得说:表里的地址不一定全是代码地址。有些表混着数据指针,甚至有的是负数哨兵值表示"默认分支"。如果工具不加校验就把所有条目都当成代码目标去递归反汇编,会在数据区里一路"反汇编"下去,产出大量垃圾函数。我见过一个案例,工具硬是把一段文本资源反汇编成了三百多个函数,最终生成的 C 文件里全是语法错误。
校验的写法可以很朴素,做个区间判断就够:
/* 示意:仅在地址落在已知代码区间时才登记为跳转目标 */ static bool is_code_addr(uint32_t addr) { for (int i = 0; i < g_code_seg_count; i++) { if (addr >= g_code_segs[i].start && addr < g_code_segs[i].end) return true; } return false; } /* 表项登记:命中才入表,未命中记录日志人工复核 */ for (uint32_t i = 0; i < table_len; i++) { uint32_t target = read_u32_be(table_addr + i * 4); if (is_code_addr(target)) { add_jump_target(target); } else { log_warn("跳转表 0x%08X 第 %u 项 0x%08X 未落在代码段,已跳过", table_addr, i, target); } }那段log_warn别嫌吵,它是我排查时最有用的东西——如果某个游戏的日志里刷出几百条"未落在代码段",说明要么表长度估错了,要么基址算错了,问题定位会快很多。
2.2 函数边界误判的两种典型症状
函数发现的本质是"从哪些地址开始反汇编"。起点判断错了,产出的 C 函数就整体错位。两种症状最好认。
症状一:函数开头缺了几条指令。表现为寄存器初值不对,常见的是栈指针没被正确调整,一进函数就写坏内存。原因通常是递归下降时把函数入口前的填充字节当成了函数的一部分,或者把前一个函数的尾部错误地合并了进来。排查方法是把生成的 C 和反汇编列表并排看前 8 条指令,逐条对照语义。
症状二:一个函数被切成了好几个。表现是明明是一个完整逻辑,却被拆成三个 C 函数,中间还隔着莫名其妙的返回。这通常是因为中间出现了"跳转目标恰好落在函数体内"的情况,工具以为那是新函数入口。解决方式是维护一张"已知非入口地址"的排除表,把循环回边、switch分支落点这些东西排除掉。
我的经验是:宁可多标几个入口,也不要漏标。多标的后果是代码冗余、可能重复翻译,但语义通常还是对的;漏标的后果是反汇编错位,产出的代码看起来像模像样,实际全是错的,这种 bug 最难查。
2.3 寄存器跳转的运行时兜底
处理完静态可识别的那部分,总会剩下一些真的算不出来的间接跳转——目标地址依赖运行时数据,比如从存档里读出来的状态机编号。这时候只能靠运行时兜底。
通行做法是给每个翻译后的函数登记一个"地址到函数指针"的映射,间接跳转在运行时查这张表。查表函数的骨架大概是这样:
/* 示意:运行时地址到函数的映射与分派 */ typedef void (*recomp_fn)(cpu_ctx *ctx); static recomp_fn g_addr_map[ADDR_MAP_SIZE]; static bool g_addr_valid[ADDR_MAP_SIZE]; void register_fn(uint32_t addr, recomp_fn fn) { uint32_t idx = (addr >> 2) & (ADDR_MAP_SIZE - 1); /* 简化示意,真实实现需要处理冲突 */ g_addr_map[idx] = fn; g_addr_valid[idx] = true; } /* 翻译产物里的寄存器跳转改写为此函数 */ void dispatch_jump(cpu_ctx *ctx, uint32_t target) { uint32_t idx = (target >> 2) & (ADDR_MAP_SIZE - 1); if (g_addr_valid[idx] && g_addr_map[idx]) { g_addr_map[idx](ctx); return; } /* 查不到:打印现场,落到这里说明覆盖范围不够 */ log_error("未登记的跳转目标 0x%08X,PC=0x%08X", target, ctx->pc); ctx->halted = true; }这里的关键不是哈希表怎么写,而是查不到时的行为。最差的做法是静默跳过,那样你会得到一个跑得很欢但逻辑全错的程序。我的建议是立刻停机并打印完整现场,包括当前 PC、寄存器快照、最近几十条已执行地址。有了这个现场,回头补登记就是几分钟的事。
顺便说一句,做映射表的时候别用完美哈希的思路去省内存,先把覆盖率做上去。等程序能完整跑通了,再考虑把它换成更紧凑的结构。顺序反了会浪费大量时间。
3. 字节序、非对齐访问与内存映射的隐性陷阱
这一章的问题特点是:它们几乎不会在编译期报错,全部潜伏到运行期,而且症状高度分散——有时是数值算错,有时是贴图错乱,有时干脆是崩溃。
3.1 字节序转换应该放在哪一层
主机平台是大端,现代 PC 是小端,这个转换是绕不过去的。争点在放在哪一层:是加载资源时统一转成主机序,还是保持原始大端字节流、每次访问时按需转换。
两种都有实际项目在用,选哪种取决于你的资源类型分布。
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 加载时转换 | 资源进内存前整体翻转字节序 | 后续访问零开销,代码干净 | 需要完整资源边界信息,改动会破坏原始数据校验 |
| 访问时转换 | 保持原始字节流,读写时按大小翻转 | 不需要资源边界信息,还原度高 | 每次访问有开销,容易漏转 |
| 混合 | 代码与关键数据转换,图形资源保持原样 | 兼顾正确性与性能 | 需要明确划分哪些走哪条路 |
我自己的做法偏混合:逻辑层的整数数据在加载时统一转,图形和音频这类大块资源保持原始字节流,交给渲染层按格式解析。理由很实在——图形资源的字节序规则和它的纹理格式强绑定,不同格式的翻转方式不一样,统一转反而容易出错;而逻辑层的数据结构简单,加载时转一遍就再也不操心了。
最容易漏转的地方有两个,值得单独记一笔。一是指针型字段,尤其是存着绝对地址的表,翻转之后还要做重定位,很多人只记得翻转忘了重定位。二是立即数拼接,有些代码用两条指令拼一个 32 位常量,翻转要在拼接之后做,做反了就得到完全不同的值。
3.2 非对齐访问:在 x86 上能跑,换个平台就崩
主机处理器支持非对齐内存访问,用成对的读写指令实现。翻译到 C 之后,最直觉的写法是把两个 16 位读写拼成一个 32 位,但如果直接对着指针做强转,行为就依赖平台了。
x86 上非对齐访问基本没问题,很多人本地测试一路顺畅,等到有人拿着别的架构来编译,一跑就挂。这是个非常典型的"只在别人机器上坏"的问题。
稳妥的写法是全部走memcpy,让编译器去优化:
/* 示意:与平台无关的非对齐读写 */ static inline uint32_t ld_u32_unaligned(const uint8_t *p) { uint32_t v; memcpy(&v, p, sizeof(v)); return v; } static inline void st_u16_unaligned(uint8_t *p, uint16_t v) { memcpy(p, &v, sizeof(v)); }别担心性能。现代编译器在能看到对齐信息的情况下会把这类memcpy优化成一条普通mov;看不到的时候,它也至少是安全的。我在项目里统一把非对齐访问收敛到这几个内联函数,之后再没出现过"换平台就崩"的情况。
还有一类更隐蔽的非对齐:跨页边界的访问。有些代码会故意做跨 4KB 边界的非对齐读,用于探测内存布局。如果你的内存映射实现是"先算出页号再查表",就得保证跨页时两段分别取值再拼接。这种地方我一般会加个断言,只在调试构建里生效,用于捕捉跨页访问。
3.3 内存映射不是一张大数组
刚上手时最容易犯的错,是把整个地址空间申请成一整块数组,然后假设所有访问都能落进去。问题在于主机内存是多段拼起来的:主存、保留区、寄存器区、卡带空间,每段的语义都不一样。寄存器区尤其麻烦——读一次可能有副作用,比如读状态寄存器会清标志位。
所以内存访问必须走带语义的映射层,而不是裸数组。映射层至少要区分三类:
- 普通内存:直接读写缓冲区,无副作用。
- 寄存器区:读写触发回调,回调里做相应的状态变更。
- 未映射区:记录访问日志,返回一个约定值而不是直接崩。
第三类最容易被省略,但它恰恰是排查利器。很多时候程序在某个地址读到 0 之后走了一条奇怪的分支,如果你能在未映射访问时打一条日志,一眼就能看出问题。我的做法是这类日志默认关闭,通过环境变量打开,避免刷屏。
另外提一句 DMA。主机上的 DMA 是把数据在内存和图形/音频子单元之间搬运,很多逻辑依赖 DMA 完成的时机。如果你的映射层把 DMA 实现成同步拷贝,绝大多数情况下没问题,但涉及双缓冲的地方可能出竞态。稳妥的做法是给 DMA 加一个"完成标记 + 周期占位",让依赖时序的代码有个合理的等待。
4. 图形与音频对接:黑屏和爆音背后是另一套账
逻辑层跑通之后,下一个坎就是画面和声音。这一章的问题有个共同点:没有崩溃点,程序活着,只是输出不对,所以排查必须换一套方法。
4.1 显示列表的拦截位置与格式翻译
主机的图形管线是"逻辑层生成命令流,图形单元消费命令流"。静态重编译只翻译逻辑层,命令流的消费必须重新实现。这里第一个决策是在哪一层拦截。
一种做法是拦截到命令流级别,逐条解析命令,翻译成现代图形 API 的调用。优点是保真度高,缺点是工作量巨大,命令种类多,边界情况更多。另一种做法是拦截到"提交"级别,把整段命令流丢给一个独立的转换器。两种做法的取舍取决于你想覆盖多少款作品。
无论选哪种,第一个要解决的是命令流的定位。逻辑层往内存里写命令缓冲,然后把缓冲地址交给图形单元。你的拦截点就要落在这个"交地址"的动作上。这个动作通常是一次对特定寄存器的写,所以在寄存器回调里截获最自然:
/* 示意:在寄存器写回调里截获命令流提交 */ static void on_reg_write(uint32_t reg, uint32_t val) { switch (reg) { case REG_GFX_CMD_START: g_gfx.cmd_start = val & 0x00FFFFFF; break; case REG_GFX_CMD_END: g_gfx.cmd_end = val & 0x00FFFFFF; /* 交给渲染线程异步消费,避免阻塞逻辑推进 */ gfx_submit_list(g_gfx.cmd_start, g_gfx.cmd_end); break; default: handle_other_reg(reg, val); break; } }拦截位置选错的后果很典型:画面完全不出来,或者出来一帧之后就再也不更新。前者通常是地址算错(少掩了几个高位),后者通常是提交时机不对(逻辑层还没写完你就去读了)。
4.2 从"颜色发紫"到"贴图错位"的定位方法
渲染结果不对,最有效的排查手段不是看代码,是做帧对比。找一台参照环境(通常是原始硬件或者高保真的参照实现),在同一个场景截一帧,然后把你自己的输出和它一像素一像素地比。
比的时候按这个顺序看,能快速收敛:
- 整体色调。如果整屏偏紫或偏绿,八成是颜色通道顺序错了,或者每个通道的位宽映射错了。
- 几何轮廓。如果轮廓正确但内部填色错,问题在纹理采样或者调色板索引。
- 纹理接缝。如果只有贴图边界处有错位,是环绕模式或者 UV 定点精度的问题。
- 深度关系。如果前后遮挡错了,是深度缓冲的写入规则或者比较函数反了。
- 透明区域。如果半透明变成了全透明或全黑,是混合方程或者 alpha 通道来源不对。
这个顺序的好处是每一步都能把范围砍掉一大块,不需要通读渲染代码。我自己最有用的一个技巧是:把每个绘制调用的输入命令流 dump 出来,和参照实现同一帧的命令流做 diff。前几条不一致的命令就是问题源头,通常五分钟内能定位。
4.3 音频爆音与时序账
音频问题看起来玄学,其实核心就一件事:采样率对齐。逻辑层按固定频率往缓冲里填样本,音频后端按另一个频率往外取,中间如果没有正确的重采样,就会出现爆音、变调或者断续。
常见的爆音有三类原因。缓冲欠载:逻辑层填得慢,后端取空了,输出一段静音然后突然接上,听感就是"咔"一声。采样率不匹配:输出频率比生成频率高,播放速度就快,反之就慢。整数溢出:样本累加时用 16 位存中间结果,超过范围就翻转,听感是尖锐的爆裂声。
第一类的处理是给音频缓冲加一点延迟容忍,同时监控欠载次数,欠载计数是判断逻辑层是否跟得上的关键指标。第二类老老实实做重采样,别偷懒做线性插值之外的近似。第三类把中间累加全换成 32 位甚至 64 位,最后再饱和截断到目标位宽。
/* 示意:饱和截断,避免样本溢出造成的爆裂 */ static inline int16_t sat_s16(int32_t v) { if (v > 32767) return 32767; if (v < -32768) return -32768; return (int16_t)v; }顺带说一个我踩过的坑:音频 DMA 的完成中断有时候会被逻辑层用来推进状态机。如果你的实现把音频完全异步化、不产生完成通知,逻辑层可能永远停在"等待音频就绪"上。表现是画面卡住但 CPU 占用很低——这个组合很有辨识度,看到就该往中断方向想。
5. 构建系统与增量重编译:让产物能被人复现
一个能跑的产物和一个能被别人复现的产物,中间隔着的就是这一章。社区里很多"我这边好的,你那边崩"的争论,根源都在构建的不确定性上。
5.1 生成物要分目录管理,别混进源码树
重编译产物动辄几百个 C 文件,加上符号表、跳转映射表、资源索引,全部堆在一个目录里会迅速失控。我的建议是严格三分:
generated/:工具产出的所有文件,整个目录可以随时删掉重建,不进版本控制。src/:手写的对接代码,包括图形层、音频层、平台适配。data/:提取出来的资源与索引,按内容哈希命名,避免重复。
这条规矩的价值在于把"工具能重建的东西"和"人写的东西"彻底分开。一旦出问题,第一步就是删掉generated/重新生成,能排除掉绝大多数"生成物过期"导致的诡异现象。
5.2 符号表的版本对齐
符号表是工具和手写代码之间的契约。工具生成的函数名、全局变量名、跳转目标编号,手写代码都要引用。一旦符号表版本和生成代码版本对不上,症状是编译期一堆未定义符号,或者更糟——链接通过但调用到错误的函数。
处理办法很直接:给符号表打版本号,并在生成代码里写入同一个版本号,编译期断言两者一致。
/* 生成代码头部自动写入,手写代码里也声明一份 */ #define RECOMP_SYMBOL_VERSION 20240617 /* 链接期检查:版本不匹配直接编译失败,而不是运行期出错 */ #if RECOMP_SYMBOL_VERSION != EXPECTED_SYMBOL_VERSION #error "符号表版本与生成代码不匹配,请重新生成" #endif这个检查看起来小题大做,但它救过我至少两次——一次是拉取了别人的分支忘了重新生成,一次是工具升级后只生成了部分文件。两次都是这个断言第一时间拦下来的。
5.3 常见构建错误对照
下面这张表是我整理的高频报错和对应处理,贴在新人常看的文档里,能省下大量重复问答。
| 报错形态 | 大概率原因 | 处理方向 |
|---|---|---|
未定义引用func_8xxxxxxx | 函数发现阶段漏标入口,或符号表未更新 | 重新生成符号表,检查该地址是否落在代码段 |
case标号重复 | 两个跳转目标被翻译进同一个switch且标签冲突 | 检查跳转目标去重逻辑 |
| 编译通过但链接时符号重复 | 同一函数被多个源文件重复生成 | 加函数归属去重,或把重复函数收进公共文件 |
| 运行时报"未登记跳转目标" | 间接跳转覆盖不完整 | 按日志里的地址补登记,或扩大跳转表识别范围 |
| 栈溢出或栈帧异常 | 函数边界错位导致sp调整重复 | 对照反汇编检查函数前几条指令 |
用表格不如用日志。我在构建脚本里加了一条规则:任何警告都必须显式分类,未分类的警告直接让构建失败。一开始很痛苦,跑几次之后就清净了,因为每类警告都被处理过一遍。
6. 把问题问清楚:社区协作里的最小信息集
最后聊点软的,但实际影响很大。同一类问题,有人贴出来十分钟就有人给出方向,有人贴出来三天没人理,差别往往不在技术水平,在信息组织。
6.1 一个能被回答的问题需要哪些信息
最少要包含五样。工具版本:静态重编译工具迭代很快,不同版本的函数发现策略可能完全不同,版本不写等于让对方猜。输入文件的基本属性:大小、字节序形态、有没有做过预处理,这些决定了工具走哪条解析路径。完整报错或日志:不要只贴最后一行,前后至少各二十行,最好带上被访问的那个地址。复现步骤:从干净状态开始,第几步出现异常。你已经排除的可能:这一条最能体现提问质量,也最能避免别人重复你的工作。
我见过一个正面例子,提问者附了一段十几行的表格,列了他试过的五种假设和对应的验证结果,最后一句是"现在怀疑是跳转表长度估错,但我不知道工具里这个参数从哪里调"。这种帖子基本半小时内就有人回。
6.2 提之前先做三件事
第一,删掉generated/重新生成一遍。大概三成的问题在这一步就消失了,剩下七成的日志也会因为重新生成而变得干净,便于阅读。
第二,把问题缩小到一个最小场景。能用一个特定操作触发的问题,比"跑一会儿就崩"好查十倍。如果实在缩不小,至少记录下崩溃前的最后几十条执行地址,这个序列本身就是线索。
第三,确认你的构建是可复现的。同一份输入、同一个工具版本、同样的命令,连续两次产出应当完全一致。如果不一致,先修构建的确定性,再谈其他。这个检查我用一个很土的办法:把生成的 C 文件做哈希,两次对比,不一致就说明有非确定性因素混进来了,常见的是并行生成时的顺序依赖。
提示:不要在提问里附带任何受版权保护的原始数据片段。描述现象用地址、大小、错误码这些元信息就够了,既不涉及内容本身,也足够定位问题。
说个我自己的体会。静态重编译这类工作,难点从来不在于某一条指令怎么翻译,而在于你要在信息不完整的前提下,不断做出"这里大概是函数入口""这张表大概有这么长"的判断,然后设计出错了也能被发现的机制。我现在的习惯是每做一个猜测,就同时写一条能验证这个猜测的日志或者断言。前面几周会觉得自己在写一堆没用的检查,等到某个半夜被一条精准的断言日志直接指到问题所在,就知道这笔账划算了。