1. 汇编、机器码、寄存器和栈

1.1 什么是汇编语言(Assembly)?

  • 电脑最底层其实只能执行“机器码”(machine code),即一连串的二进制数(0 和 1),每组固定长度代表一个指令。
  • 汇编 就是人类可读形式的机器码,用助记符(mnemonic)把机器指令翻译成类似 mov eax, 1call 0x1000 的文本。
  • 在 x86/x64 架构下,每一个助记符(比如 movaddcall)实际上对应底层一个或多个字节(十六进制形式)编码。
  • 当 CPU 运行时,先把这些十六进制字节读到指令译码器里,再执行相应动作(把数字放到寄存器、改写内存、跳转到别的地方……)。

1.2 寄存器(Registers)和栈(Stack)的概念

  • 寄存器:CPU 内部的“小抽屉”,用来存放临时数据或地址。x86/x64 常见的寄存器有 RAX/EAXRBX/EBXRSP/ESP(栈指针)等等。
  • 栈(Stack):它是一块特殊的内存区域,遵从“后进先出”(LIFO)原则。常见的操作有“压栈”(push)和“弹栈”(pop)。
  • 栈通常与 RSP(在 64 位下,用 64 位的 RSP;在 32 位下,用 32 位的 ESP)寄存器配合:
    • 当执行 push xx 时,CPU 会先把 RSP 减去相应字节数(在 x64 下通常是减 8),然后把数据写入新 RSP 指向的内存;
    • 当执行 pop xx 时,CPU 先从 RSP 指向的内存读出一个值,再把 RSP 增加相应字节数(x64 下通常是加 8),完成“弹出”操作。

1.3 为什么要用栈?

  • 最常见的场景是在函数调用时保存“返回地址”以及函数内部的局部变量。
  • CALL 指令会把“下一条指令的地址”压到栈里,作为返回地址;然后跳到函数入口执行。
  • 在函数尾部必须执行 RET,它会从栈顶弹出一个地址(当做返回地址),跳回到那个地方继续执行外层代码。

如果你省略了 RET,或者你让 “CALL 把地址压进去” 之后,却没有与之对应的 “从栈里弹出来” 操作,CPU 的栈指针 RSP(或 ESP)就会“向下”(数值变小)而没有“向上”弹回原位。反过来,如果把某个值放到栈里之后,反汇编器没有找到对应的“弹出”动作,就会认为“栈不平衡”–– 这就是 IDA 说“positive sp value has been found”的根本原因(我们后面会具体解释)。


2. x86/x64 里的 CALLRET 指令

2.1 CALL rel32 指令

在 x86-64 架构下,最常见的函数调用指令形式是:

1
E8 xx xx xx xx    ; CALL rel32
  • 其中 E8 是这个指令的 opcode(操作码)。
  • 紧跟的 4 个字节 xx xx xx xx 是一个有符号 32 位立即数(signed 32-bit immediate),我们叫它 rel32
  • 执行这条指令时,CPU 会做两件事:
    1. 压栈:把“当前指令末尾下一条要执行的指令地址”(RIP+5)压入栈顶;(压栈动作对应 push (RIP+5),RIP 是当前 instruction pointer)
    2. 跳转:计算跳转目标地址 = (当前指令末尾地址 RIP+5) + rel32,然后把 RIP 指向那个地址开始执行。

举个简单例子,如果当前 RIP = 0x1000,代码是 E8 FC FF FF FF

  1. 指令长度是 5 个字节(E8 + 4 字节 immediate)。
  2. 下一条指令地址就是 0x1000 + 5 = 0x1005
  3. immediate = FC FF FF FF,这是一个有符号数,十六进制的 0xFFFFFFFC 等于十进制 -4。
  4. 跳转目标 = 0x1005 + (-4) = 0x1001
  5. CPU 把 0x1005 压入栈,然后把 RIP 改成 0x1001,从 0x1001 开始执行下面的指令。

注意:这条 CALL 里面,压到栈里的“返回地址”是 0x1005,后面必须要有一个对应的 RET,才会从栈里弹出 0x1005 并返回到 0x1005 处继续执行。

2.2 RET 指令

  • 在 x86-64 下,最基本的 RET(有时会写成 C3)表示“弹出一个 8 字节(64 位)的值做为返回地址,然后跳到那个地址继续执行”。
  • 如果你在 CALL 之后没有紧跟一个 RET,那栈里就会多留一个返回地址。反汇编器就会报错:它“看见 CALL 把一个返回地址压进来,可是源码里没看到 RET 会把它弹回去”。

这就造成了“栈指针(RSP)”的“正/负”变化不平衡的问题,IDA 反编译器对此非常敏感,它一旦发现“在函数体里出现了多余的压栈却没有匹配的弹栈”,就会放弃生成伪代码,并提示“positive sp value has been found”(意思是“发现栈指针向上增长后并未恢复”)。


3. IDA 反编译报错 “positive sp value has been found” 的含义

当 IDA 试图把一段汇编翻译成 C 风格的伪代码时,它背后要做的事情包括:

  1. 控制流分析:跟踪所有可能的跳转(jmp)、调用(call)、条件/无条件分支(je、jne、jz 等),把整个函数的“执行流程”连成一张箭头图。
  2. 栈平衡检查:在进入函数时,IDA 假定函数的栈是平衡的(RSP 指向某个固定位置)。对于每条 push/popcall/retsub rsp, xxx/add rsp, xxx,IDA 都会更新它对栈指针的“当前预期值”。
  3. 生成伪代码:当控制流和栈平衡都满足“合法函数”的条件时,IDA 才会把小块汇编转换成类似 C 语法的语句。

如果 IDA 在函数体里看到一条 CALL 指令,然后到函数尾部都没找到相应的 RET,就会认为“栈指针向上(push)了却没有向下(pop)”,也就是“positive sp value has been found”(直译:找到了正的栈指针偏移),它就会中断反编译,并给出错误提示。原因是:IDA 认为反编译器无法在这样的不平衡栈里安全地生成正确的伪代码。


4. 你所看到的那段真实汇编 / 机器码

让我们回到你的三张截图对应的位置和字节流(以十六进制表示):

1
2
3
4
地址 0x14000185F (文件偏移 0xE5F)处的原始字节:
E8 FC FF FF FF C6 05 A2 78 00 00 26 C6 05 8D 78

[其余可能还有其他字节,但主要就看这些]

把它拆分开来,逐字节对照:

  1. E8 FC FF FF FF
    • E8:CALL rel32 的 opcode。
    • 接下来的 4 字节 FC FF FF FF 组成有符号 32 位立即数 0xFFFFFFFC,等于十进制 -4。
    • 如前面所说,如果当前指令在 0x14000185F,它会把“返回地址”0x14000185F+5 = 0x140001864压栈,然后跳转到 0x140001864 + (-4) = 0x140001860 处。
  2. C6 05 A2 78 00 00 26
    • 这是一条 mov byte ptr [0x000078A2], 0x26
    • 拆解:
      • C6 05:这两个字节的前缀是“mov 一个字节到绝对内存地址”的编码,后面紧跟 4 字节代表内存地址,再跟 1 字节表示要写入的立即数。
      • A2 78 00 00:低字节在前,所以是地址 0x000078A2
      • 26:要写入的数值(0x26)。
  3. C6 05 8D 78 00 00 ??
    • 类似地,也是 mov byte ptr [0x0000788D], XX 形式,只是 XX 可能是另一常量。

如果把这条 CALL 当作普通指令执行,执行流程会是:

1
2
3
4
5
0x14000185F: CALL 0x140001860       ; 压栈返回地址 0x140001864,跳转到 0x140001860
0x140001860: mov [0x78A2], 0x26 ; 写内存
0x140001867: mov [0x788D], XX ; 写内存
……
0x14000186x: RET ; 或者后续代码里才有 ret

但是注意,CALL 立刻跳到 0x140001860并不会先把下面的 C6 05 … 当成真正指令去线性执行(在 CPU 看到这行字节时,的确会跳到 0x140001860 开始执行那里的 mov [0x78A2],0x26)。唯一问题在于:CPU 在执行 CALL 时把返回地址 0x140001864 压到栈上,可是如果在 0x140001860 那里执行完 mov [0x78A2],0x26 这类写内存指令后,直接就往下走,却从未在合适位置“用 RET 把 0x140001864 弹回去”; 最后如果程序遇到的第一个 RET(假设在更后面)并不会把它弹到原来调用处,而是把栈顶当做另一个地址来跳,可能导致崩溃或不可预知的行为。这是程序员有意“搞”的一种“控制流迷惑”或“反调试手段”。

从 IDA 角度看,它在反编译时会说:“好像在这里有一次 CALL,把返回地址压栈了,但我找不到对应的 RET 把它弹出去,这就意味着栈平衡被破坏了”。于是它标记 “positive sp value has been found”,拒绝继续生成伪代码。

补充:什么是“控制流迷惑”?

  • 很多加密保护或者作者为了防止静态分析,会故意故弄玄虚地用“CALL → 跳到自己内部写数据 → 再跳回来”这种技巧:看起来像一个正常的函数调用,但实际上它跳到自己后面的数据区,把那些数据解释成指令来执行,再靠某个跳转或其他手段回到正常流程。
  • 对分析者来说,CALL 后面没有 RET,就报错;对程序本身来说,如果它自己在写好的二进制里偷偷“跳回”并用别的方法平衡栈,也可以正常运行,但 IDA 这儿跟不清楚它是什么逻辑。

5. 为什么把 E8 改成 90(NOP)就能让 IDA 看到伪代码?

回想一下:原先的字节流是:

1
E8 FC FF FF FF   C6 05 A2 78 00 00   26   C6 05 8D 78 …

把第一个字节 E8(CALL)的 opcode 换为 90,就变成:

1
90 FC FF FF FF   C6 05 A2 78 00 00   26   C6 05 8D 78 …
  • **90** 在 x86/x64 里是 **NOP**(No Operation) 的意思,表示“什么都不做,仅仅走过这一字节,执行下一个地址”。
  • 换掉之后,IDA 就会从 0x14000185F 开始把这几字节当作:
    1. 90 → 执行 NOP,不管栈;
    2. 然后看到 FCFFFFFF……这几个字节就会被当成零散的“数据”或“立即数”去继续配合下一个合法的指令前缀解析,最终可能就匹配成 mov [0x78A2], 0x26 这样的指令。
  • 关键是:此时并没有任何“CALL”把返回地址压到栈,所以 IDA 就不会说“栈不平衡”了。
  • 反过来,只要栈操作对称(每次压栈都有弹栈),IDA 的反编译器就可以顺利进行“控制流和栈平衡”分析,最终输出伪 C 代码。

用一句话总结:

E8 改成 90,就相当于把那条“压栈又跳走到后面数据区再执行”的“反调试陷阱”给干掉了,底层变成了“正常线性编码”,IDA 得以继续往下看,伪代码自然就跑出来了。


6. 从头到尾的“知识点串讲”

下面我再把上面几个关键点按逻辑顺序串起来,帮助你从零基础理解:

  1. CPU 执行过程
    • CPU 按照 RIP(指令指针)中记录的地址去取下一条指令的机器码(字节流)。
    • 取到一个操作码(opcode)后,CPU 会解析后面若干字节做为“立即数”或“操作数地址”。
    • 比如看到 E8 XX XX XX XX,CPU 就知道:“我要做一次相对调用(CALL rel32)。”
  2. CALL rel32 是如何工作的
    • CPU 先把“下一条要执行的指令地址”压到栈(RSP 寄存器指向的位置),让这次调用能记得“该跳回来这里”。
    • 然后再把 RIP = (下一指令地址) + rel32 跳转到新的位置执行。
  3. RET 是如何工作的
    • CPU 看到 RET 后,就从栈顶“弹出”一个值(当成新的 RIP),于是程序就跳回到那个值处继续执行。
    • 这样,CALL 和 RET 才配成“一对”,保证函数调用结束后返回正确的地方,栈顶同时平衡回到调用前的状态。
  4. 什么是“栈平衡被破坏”
    • 如果程序里有一个 CALL,却找不到对应的 RET;或者对比 push/pop不对称;IDA 反编译器会认为“栈指针向下(压栈)后并未向上(弹栈)回去”,出现“positive sp value” 或者“negative sp value”之类的警告。
    • 它会直接拒绝生成伪代码,因为不确定下一步栈里是什么,也不知道正确的控制流走向。
  5. 作者为什么要这么写?
    • 这段二进制的原作者(可能是一个加壳、混淆工具或人为加的“反调试陷阱”),刻意用 CALL (rel=-4) 这种“跳回自己附近再执行数据”的写法,使 IDA 无法追踪,屏蔽了函数内容。
    • 当你在 IDA 里看到 “createMaze:label0↑p” 这种红色的标签时,就是在告诉你:“这里有一个本地跳转目标,你要非常小心,这里并不是正常的从函数入口到函数出口的直线流程。”
  6. 如何破解?
    • 最简单粗暴的方法,就是把那条 E8 FC FF FF FF 指令的第一个字节(E8)改成 90(NOP)。
    • **90**(NOP)含义是“无操作”,让 CPU 把它忽略掉,不做任何跳转。后面的 FC FF FF FF 就剩下“孤零零”的数据,但这时 IDA 可以把它跟后面的字节一起解析,看出下一条合法的 mov [0x78A2], 0x26 指令。
    • 这样一来,就没有“压栈”动作,IDA 校验栈时发现没有异常,反编译即可继续往下,成功输出伪代码。

7. 回答你几个“为什么”细节

  • 为什么“CALL rel32” 会把返回地址压栈?
    这是 x86/x64 硬件调用约定的一部分:当你调用一个函数时,CPU 需要知道“函数执行完毕后应该跳回哪儿继续”。CPU 就把“下一条指令的地址”压到栈(RSP 寄存器)里,让 RET 去弹这个地址。
  • 什么叫“rel32 = -4” 为什么跳到自己身上?
    E8 FC FF FF FF 里,FC FF FF FF(小端存储)等价于十进制 -4
    • 如果这条 CALL 位于 0x14000185F,那么压栈的“返回地址”是 0x14000185F + 5 = 0x140001864
    • 跳转目标 = (0x14000185F + 5) + (-4) = 0x140001860
    • 注意 0x140001860 正好是这条 CALL 之后第 2 个字节的位置 —— 也就是说,这条 CALL 实际就是“压栈后回到自己后面的位置继续执行”,以此把后面几字节当作真正要执行的指令。
  • 为什么 IDA 会提示“positive sp value” 并无法反编译?
    IDA 看到这条 CALL,它会把 RSP 想象成“从 A 变到 A−8”(x64 下每个 call/ret 会自动减/加 8 字节)。如果在正常函数体里要平衡,IDA 必须找到一个与之配对的 RET 才行。但在你的二进制里,从 0x140001860 开始执行 mov [0x78A2],0x26mov [0x788D],XX ……一直到函数结束,这条最早的 CALL 压的那 8 字节从没被弹出,IDA 检测到“函数体里出现了一个正的 SP 偏移(压栈多了 8)却没看到对应的弹出”,就干脆放弃反编译。
  • 为什么单纯改成 **NOP****90**)就能让 IDA 反编译通过?
    E8 换成 90 以后,IDA 跟踪到:
    1. 0x14000185F: 90 → 做什么都不做,RSP 不变
    2. 0x140001860: FC 这时 FCFFFFFF 会被 IDA 当作“剩余的字节”去尝试与下一条合法指令前缀匹配,很快就能拼出 mov [0x78A2],0x26C6 05 A2 78 00 00 26)。
    3. 接下来 mov [0x78A2],0x26 → 并不会影响栈;mov [0x788D],XX → 也不影响栈。
    4. 如此一来,栈从头到尾都没被动过(IDA 看到的都是正常写内存指令、也许最后才有一次 RET),完全“栈平衡”,反编译器就可以把这些指令逐行推导出伪 C 代码。

8. 结合截图说明那些红色的提醒

在你贴出的第二张截图里,IDA 把 createMaze:label0↑p 这行显示成红色:

  • createMaze 是函数名。
  • label0 是 IDA 自动生成的“本地标签”,表示函数体里某个“跳转目标”。
  • ↑p 是 IDA 另一个提示,意味着这儿有一次“尾调用” 或“push-prologue” 等不常规的控制流。
  • IDA 用 红色 来标记:
    1. 这是一个“本地标签”,不是函数入口(函数入口通常是蓝色或绿色)。
    2. 背后可能有不对称的栈操作(如这例中那条奇怪的 CALL → 跳到自己),需要开发者额外注意。

当你把 E8 换成 90 以后,那一行对应的“跳转”就没有了,IDA 自然不会再把 label0 标成红色,因为此时不会再有“异常的栈偏移”或“奇怪的跳转”了。


9. 总结

  • CALL(E8):会把“返回地址”压栈,然后按相对偏移跳转。一定要有后面的 RET 才能保持栈平衡。
  • RET(C3):会把栈顶的地址弹出来,跳回到那个地址。配合 CALL 确保函数调用后回到正确位置。
  • 栈平衡:IDA 反编译时会追踪每次“压栈”和“弹栈”,如果不对称,就会报 “positive sp value has been found” 并中断。
  • 反调试/混淆技巧:有人会用 CALL rel = -4(比如 E8 FC FF FF FF)跳到自己后面当作真正指令段,一来让 IDA 的“压栈”与“弹栈”不平衡,二来降低静态分析的可读性。
  • 破解思路:直接把那条 “CALL” 的 opcode(E8)改成 “NOP”(90),这样就没有“压栈”动作,IDA 反编译器一看到栈平衡了,就顺利把后续指令还原成伪代码。

至此,你应该可以把所有步骤串起来了:

  1. 看到 IDA 报 error,说“positive sp value” → 意味着它在这儿发现了一个“压栈”但没找到匹配的“弹栈”。
  2. 定位到那条 E8 FC FF FF FF → 这是一个“CALL 跳回自己后面的数据区”,只要删掉或改成 NOP,IDA 就能恢复正常分析。
  3. E8(CALL)改 90(NOP) → 原本的“压栈+跳转”就被干掉了 → 栈平衡恢复 → IDA 成功反编译并显示伪代码。

希望这段从最基础原理到具体例子的讲解,能帮助你彻底搞懂“为什么这条 CALL 会让 IDA 报错”,以及“把它改成 NOP 为何可以让伪代码重现”。如果你还有任何更细节的疑问,欢迎继续提问!