问题

如何理解ByteCode、IL、汇编等底层语言与上层语言的对应关系?

回答
想象一下,我们人类需要沟通,但我们使用的语言(比如中文、英文)千差万别。计算机的世界里也是如此,不同层级的“语言”之间也存在着微妙而清晰的对应关系。理解这一点,就像是掌握了一套解码器,能够窥探程序运行的深层机制。

我们常说的“上层语言”,比如我们熟悉的Python、Java、C,它们就像是人类使用的自然语言一样,易于理解,表达能力强,可以轻松地描述复杂的逻辑和概念。开发者用它们来编写代码,就像我们用中文写一封信,内容丰富,情感充沛。

但是,计算机的处理器(CPU)并不直接理解这些“自然语言”。CPU只懂得一种非常基础、非常直接的语言,那就是“机器码”。机器码是一串串二进制的0和1,它们对应着CPU能够执行的最基本的操作,比如加载数据、进行加法、跳转到某个地址等等。这就像CPU只能听懂最简单的指令,比如“把这个数字放进去”、“做个加法”、“去那个地方”。

那么,上层语言是如何变成CPU能理解的机器码的呢?这里就需要一些“中间翻译官”了。

首先,我们接触到的上层语言,在执行之前,需要经过一个叫做“编译”或者“解释”的过程。

编译型语言(比如C、C++)更像是直接的翻译。有一位叫做“编译器”的翻译官,它会把我们用C++写好的代码,一股脑地翻译成CPU能够理解的机器码。这个翻译过程就像是把一本中文小说,全部翻译成英文小说的原文,而且这个翻译工作是提前做好的。编译完成后,你就得到了一份直接可以运行的机器码文件。

解释型语言(比如Python、JavaScript)则不同,它们更像是边翻边说。有一位叫做“解释器”的翻译官,它不会一次性把所有代码都翻译成机器码。而是逐行读取我们的Python代码,然后把当前这一行翻译成CPU能懂的指令,让CPU执行。执行完这一行,再继续下一行。这个过程就像是一个口译员,一边听着中文讲话,一边实时翻译成英文。

而我们要深入探讨的 ByteCode 和 IL (Intermediate Language),它们就扮演着中间翻译官的角色,是上层语言和底层机器码之间一座重要的桥梁。

ByteCode(字节码)是一个非常重要的概念,尤其是在Java这样的平台上。当你用Java写完代码后,并不是直接编译成针对某个特定CPU的机器码,而是编译成了一种叫做Java ByteCode的中间格式。你可以把它想象成是一种“通用语言”,它不像机器码那样直接与某个CPU绑定,但又比Java这种上层语言更接近硬件。

Java ByteCode就像是一份“跨平台的设计图纸”。这份设计图纸可以被发送到任何安装了Java虚拟机(JVM)的计算机上。JVM就像是一个专门的“解析和执行引擎”,它负责将这份通用的ByteCode翻译成目标计算机CPU能够理解的机器码,然后执行。

这个翻译过程,JVM可以根据不同的CPU架构和操作系统,采用不同的策略。它可以一次性将ByteCode全部翻译成机器码(类似 AheadOfTime 编译),也可以边翻译边执行(类似 JustInTime 编译)。这种设计的好处是,Java程序可以“一次编译,到处运行”,大大提高了跨平台的能力。

IL (Intermediate Language) 的概念与ByteCode非常相似,但它更宽泛一些,并且是 .NET 平台(比如C)的核心。当你用C或其他 .NET 语言编写程序后,会被编译成一种叫做 Common Intermediate Language (CIL),也称为MSIL (Microsoft Intermediate Language) 的中间语言。

CIL就像是Java ByteCode一样,是一种与平台无关的中间表示。它包含了一系列指令,描述了如何操作数据、调用方法、进行控制流等。然后,.NET 运行时(CLR Common Language Runtime)会负责将CIL编译成特定于当前运行环境的机器码。CLR同样也采用了JIT编译技术,即在程序运行时,动态地将CIL翻译成机器码并执行。

所以,从上层语言到机器码,通常会经历这样的流程:

1. 上层语言 (如 Java, C): 我们编写的代码,表达清晰、易于维护。
2. 编译成中间语言 (ByteCode / CIL): 编译器将上层语言翻译成一种中间格式,这种格式既不完全是人能读懂的上层语言,也不是直接针对某个CPU的机器码。它是一种“通用指令集”,包含了程序逻辑,但又独立于具体的硬件。
3. 运行时环境 (JVM / CLR): 这个环境中的“即时编译器” (JIT Compiler) 负责将中间语言翻译成最终的机器码。这个翻译过程是动态的,发生在程序运行的时候。

现在,我们再来看 汇编语言。汇编语言是比机器码稍微高一点的语言。如果说机器码是CPU的“粗言细语”,那么汇编就是CPU的“简洁陈述”。每条汇编指令通常对应着一条机器码指令,只不过汇编使用了助记符(比如 `MOV`、`ADD`、`JMP`)来代替一串串的0和1,并且可以使用符号名来代替内存地址。

汇编语言的特点是:

接近硬件: 它直接映射到CPU的指令集,非常贴近CPU的工作方式。
效率高: 理论上,通过汇编可以直接控制硬件,实现最高效的操作。
可读性差: 相对于上层语言,汇编非常难读难写,需要深刻理解CPU架构。

那么,汇编语言是如何与ByteCode/IL以及上层语言关联的呢?

编译器和汇编: 像C/C++这样的编译型语言,编译器在将C/C++代码翻译成机器码的过程中,通常会先将其翻译成汇编语言,然后再由一个叫做“汇编器”的工具将汇编语言翻译成最终的机器码。所以,如果你用C/C++写代码,最终生成的可执行文件里,最底层的内容就是机器码,而汇编语言可以看作是机器码的一种“人类可读”的表示。

反编译与反汇编: 有时候,我们也可以进行“反向操作”。比如,可以将机器码“反汇编”成汇编语言,从而了解程序在底层是如何工作的。而一些工具,甚至可以尝试将ByteCode/CIL反编译成更接近上层语言的代码(虽然不一定是原始的上层语言,但逻辑上是等价的)。

总结一下,它们之间的对应关系是这样一种层层递进、抽象程度逐渐降低的过程:

上层语言 (Java, C, Python): 最高级别的抽象,最接近人类自然语言,表达能力最强,但效率可能不是最高的。
中间语言 (ByteCode, CIL): 中间抽象层,平台无关,为不同平台上的运行时提供了一个统一的入口。它比上层语言更接近机器,但又比汇编或机器码更具通用性。
汇编语言: 更低的抽象层,非常接近硬件,指令直接映射到CPU的操作,可读性较差。
机器码: 最底层,CPU直接能够理解和执行的二进制指令。

可以把这个过程想象成一个翻译链条:

你用 上层语言 写好了一份合同(比如合同的条款、内容)。
这个合同首先被翻译成一份 中间语言 的“翻译本”(ByteCode/CIL)。这份翻译本是通用的,可以在任何装有相应翻译工具(JVM/CLR)的地方被阅读。
当这个翻译本到了某个特定国家(比如你的电脑),那里的“特许翻译官”(JIT编译器)会把它翻译成这个国家才能理解的 汇编语言(一种方便当地官员理解的正式文件格式)。
最后,这份汇编文件经过一次最后的精简和格式化,变成只有当地政府(CPU)才能直接执行的 机器码(政府的官方命令)。

理解了这一点,我们就能明白为什么Java和C具有跨平台性,也知道当我们需要优化性能或者调试一些难以捉摸的底层问题时,为什么需要去了解汇编甚至更低层面的东西。它们之间不是孤立的,而是构成了一个从人类意图到机器执行的完整链条。

网友意见

user avatar

其实就IL而言,其对应关系是非常明确的。

说白了在ByteCode和MSIL里面有完整的类、方法、调用、实例化和继承等等现成的语法,所以Java和C#语言在很大程度上来说就是好大一坨糖。

或者用

@RednaxelaFX

大大的话来说就是:

Java的字节码本质上是Java AST通过后序遍历的线性序列化形式

这比起C++编译器的魔改来说简直是小儿科。


我觉得对于IL这种东西,理解的最好办法就是多看,很多反编译器(如ILSpy)都可以把DLL反编译成多种语言,对照着IL和C#版本多看几次,自然就能掌握里面的对应关系和套路。

当然,C#有些语法糖在编译后也是非常头疼的,譬如说yield和yield的衍生(async/await),套路上倒是大同小异,但是编译出来的辅助类型的名字通常是自动生成的,看起来和混淆了一样。


作为一个普通的程序员,手译没有yield和闭包的C#/Java代码变为IL是很容易掌握的一项装逼技能。

user avatar

蟹腰。写了一段之后觉得这种东西应该发去看雪,发知乎有多少人会看呢orz

长文警告:一不小心把前面介绍背景的部分写太长了,废话太多,大家伙儿凑合着看吧⋯

这个问题的答案相当大程度取决于:最终的目标文件里保存了多少符号信息、编译器做了多少优化、目标文件是否经过混淆或随机化(randomize)。

===================================================================

Java篇

原先的问题讨论的是Java,这里先以Java为例来讨论。同时假设我们既有Java的源码也有对应的Java Class文件,只是想了解两者之间的映射关系。

从Java源码到Java字节码

Java的字节码本质上是Java AST通过后序遍历的线性序列化形式。几乎每个Java字节码opcode都有对应的Java语法结构。

只要熟悉Java的语法,能够在看到Java源码时想像出其解除语法糖之后的样子,然后对应的AST的样子,然后对这个AST后序遍历就能得到Java字节码。我在这里给过一个动画例子:

虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩

<- 请前读完这篇再继续向下读本回答。Java字节码就是一种“零地址指令”。

Java最主流的源码编译器,

javac

,基本上不对代码做优化,只会做少量由Java语言规范要求或推荐的优化;也不做任何混淆,包括名字混淆或控制流混淆这些都不做。这使得javac生成的代码能很好的维持与原本的源码/AST之间的对应关系。换句话说就是javac生成的代码容易反编译。

Java Class文件含有丰富的符号信息。而且javac默认的编译参数会让编译器生成行号表,这些都有助于了解对应关系。

关于Java语法结构如何对应到Java字节码,在JVM规范里有相当好的例子:

Chapter 3. Compiling for the Java Virtual Machine

好好读完这章基本上就能手码字节码了。

记住一个要点就好:“运算”全部都在“操作数栈”(operand stack)上进行,每个运算的输入参数全部都在“操作数栈”上,运算完的结果也放到“操作数栈”顶。在多数Java语句之间“操作数栈”为空。

从Java源码对应到Java字节码的例子

题主之前说“从来不觉得阅读底层语言很容易,无论是汇编还是ByteCode还是IL”。我是觉得只要能耐心读点资料,Charles Nutter的

JVM Bytecodes for Dummies

,然后配合

The Java Virtual Machine Instruction Set

,要理解Java字节码真的挺容易的。

口说无凭,举些简单的例子吧。把这些简单的例子组装起来,就可以得到完整方法的字节码了。

每个例子前半是Java代码,后面的注释是对应的Java字节码,每行一条指令。每条指令后面我还加了注释来表示执行完该指令后操作数栈的状态,就像

JVM规范的记法

一样,左边是栈底右边是栈顶,省略号表示不关心除栈顶附近几个值之外操作数栈上的值。

读取一个局部变量用<type>load系指令。

       local_var_0  //           // ...         -> // iload_0   // ..., value0     

<type>是类型前缀,有

  • b: byte
  • s: short
  • c: char
  • i: int
  • l: long
  • f: float
  • d: double
  • a: 引用类型

<type>load后面跟的参数是局部变量所在的位置(slot number)。其中对0到3的slot有特化的简短指令,例如iload_0。4和以上就用通用的load指令,例如iload 4。

存储一个局部变量用<type>store系指令。

       local_var_0 = ...  //            // ..., value0 -> // istore_0   // ...     

合并起来:

       local_var_1 = local_var_0;  //            // ...         -> // iload_0    // ..., value0 -> // istore_1   // ...     

二元算术运算:

       ... + ...  //            // ..., value1, value2 -> // iadd       // ..., sum     

结合读取局部变量:

       local_var_0 + local_var_1  //            // ...                 -> // iload_0    // ..., value0         -> // iload_1    // ..., value0, value1 -> // iadd       // ..., sum     

结合保存到局部变量:

       local_var_2 = local_var_0 + local_var_1;  //            // ...                 -> // iload_0    // ..., value0         -> // iload_1    // ..., value0, value1 -> // iadd       // ..., sum            -> // istore_2   // ...     

连续加两次:

       local_var_3 = local_var_0 + local_var_1 + local_var_2  //            // ...                 -> // iload_0    // ..., value0         -> // iload_1    // ..., value0, value1 -> // iadd       // ..., sum1           -> // iload_2    // ..., sum1, value2   -> // iadd       // ..., sum2           -> // istore_3   // ...     

返回结果:

       return ...;  //            // ..., value -> // ireturn    // ...     

返回一个局部变量:

       return local_var_0;  //            // ...         -> // iload_0    // ..., value0 -> // ireturn    // ...     

返回一个加法:

       return local_var_0 + local_var_0  //            // ...                 -> // iload_0    // ..., value0         -> // dup        // ..., value0, value0 -> // iadd       // ..., sum            -> // ireturn    // ...     

<type>const_<val>、bipush、sipush、ldc这些指令都用于向操作数栈压入常量。例如:

       1    // iconst_1 true // iconst_1    // JVM的类型系统里,整型比int窄的类型都统一带符号扩展到int来表示 127  // bipush 127  // 能用一个字节表示的带符号整数常量 1234 // sipush 1234 // 能用两个字节表示的带符号整数常量 12.5 // ldc 12.5    // 较大的整型常量floatdouble字符串常量用ldc     

创建一个对象,用空参数的构造器:

       new Object()  //                                           // ...           -> // new java/lang/Object                      // ..., ref      -> // dup                                       // ..., ref, ref -> // invokespecial java/lang/Object.<init>()V  // ..., ref     

关于这段字节码的解释,请用下面两个传送门:

关键点在于:new指令只复制分配内存与默认初始化,包括设置对象的类型,将对象的Java字段都初始化到默认值;调用构造器来完成用户层面的初始化是后面跟着的一条invokespecial完成的。

使用this:

       this  //            // ...       -> // aload_0    // ..., this     

这涉及到Java字节码层面的“方法调用约定”(calling convention):参数从哪里传出和传入,通过哪里返回。读读

这里

这里

就好了。

静态方法,方法参数会从局部变量区的第0~(n-1)个slot从左到右传入,假如有n个参数;

实例方法,方法参数会从局部变量区的第1~n个slot从左到右传入,假如有n个显式参数,第0个slot传入this的引用。所以在Java源码里使用this,到字节码里就是aload_0。

在被调用方看有传入的东西,必然都是在调用方显式传出的。传出的办法就是在invoke指令之前把参数压到操作数栈上。当然,“this”的引用也是这样传递的。

方法真正的局部变量分配在参数之后的slot里。常见的不做啥优化的Java编译器会按照源码里局部变量出现的顺序来分配slot;如果有局部变量的作用域仅在某些语句块里,那么在它离开作用域后后面新出现的局部变量可以复用前面离开了作用域的局部变量的slot。

这方面可以参考我以前写的一个演示稿的第82页:

Java 程序的编译,加载 和 执行

继续举例。

调用一个静态方法:

       int local_var_2 = Math.max(local_var_0, local_var_1);  //                                       // ...                 -> // iload_0                               // ..., value0         -> // iload_1                               // ..., value0, value1 -> // invokestatic java/lang/Math.max(II)I  // ..., result         -> // istore_2                              // ...     

调用一个公有实例方法:

       local_var_0.equals(local_var_1)  // aload_0   // 压入对象引用,作为被调用方法的“this”传递过去 // aload_1   // 压入参数 // invokevirtual java/lang/Object.equals(Ljava/lang/Object;)Z     

Java字节码的方法调用使用“符号引用”(symbolic reference)来指定目标,非常容易理解,而不像native binary code那样用函数地址。

读取一个字段:

       this.x // 假设this是mydemo.Point类型,x字段是int类型  //                            // ...        -> // aload_0                    // ..., ref   -> // getfield mydemo.Point.x:I  // ..., value     

写入一个字段:

       this.x = local_var_1 // 假设this是mydemo.Point类型,x字段是int类型  //                            // ...             -> // aload_0                    // ..., ref        -> // iload_1                    // ..., ref, value -> // putfield mydemo.Point.x:I  // ...     

循环的代码生成例子,我在

对C语义的for循环的基本代码生成模式

发过一个。这里就不写了。

其它控制流,例如条件分支与无条件分支,感觉都没啥特别需要说的…

异常处理…有人问到再说吧。

从Java字节码到Java源码

上面说的是从Java源码->Java字节码方向的对应关系,那么反过来呢?

反过来的过程也就是“反编译”。反编译Java既有现成的反编译器(

Procyon

JD

JAD

之类,

这里

有更完整的列表),也有些现成的资料描述其做法,例如:

两本书里前一本靠谱一些,后一本过于简单不过入门读读可能还行。

论文是日文的不过写得还挺有趣,可读。它的特点是通过

dominator tree

来恢复出Java层面的控制流结构。

它的背景是当时有个用Java写的研究性Java JIT编译器叫

OpenJIT

,先把Java字节码反编译为Java AST,然后再对AST应用传统的编译技术编译到机器码。

这种做法在90年代末的JIT挺常见,JRockit最初的JIT编译器也是用这个思路实现。但很快大家就发现干嘛一定要费力气先反编译Java字节码到AST再编译到机器码呢,直接把Java字节码转换为基于图的、有显式控制流和基本块的IR不就好了么。所以比较新的Java JIT编译器都不再做“反编译”这一步了。

这些比较老的资料从现在的角度看最大的问题是对JDK 1.4.2之后的javac对try...catch...finally生成的代码的处理不完善。由于较新的javac会把finally块复制到每个catch块的末尾,生成了冗余代码,在复原源码时需要识别出重复的代码并对做tail deduplication(尾去重)才行。以前老的编译方式则是用jsr/ret,应对方式不一样。

从Java字节码对应到Java源码的例子

首先,我们要掌握一些工具,帮助我们把二进制的Class文件转换(“反汇编”)为比较好读的文本形式。最常用的是JDK自带的

javap

。要获取最详细的信息的话,用以下命令:

       javap -cp <your classpath> -c -s -p -l -verbose <full class name>     

例如,要看java.lang.Object的Class文件的内容,可以执行:

       javap -c -s -p -l -verbose java.lang.Object     

提取其中java.lang.Object.equals(Object)的部分出来:

         public boolean equals(java.lang.Object);     Signature: (Ljava/lang/Object;)Z     flags: ACC_PUBLIC     Code:       stack=2, locals=2, args_size=2          0: aload_0                 1: aload_1                 2: if_acmpne     9          5: iconst_1                6: goto          10          9: iconst_0               10: ireturn              LineNumberTable:         line 150: 0       StackMapTable: number_of_entries = 2            frame_type = 9 /* same */            frame_type = 64 /* same_locals_1_stack_item */           stack = [ int ]      

(为了演示方便我删除了一些重复输出的属性表)

可以看到这里不但有Java字节码,还有丰富的元数据(metadata)描述这段代码。

让我们先从Java字节码的部分看起。在Class文件里,Java字节码位于方法的Code属性表里。

       0: aload_0     

javap的这个显示格式,开头的数字就是bci(bytecode index,字节码偏移量)。bci是从该方法的字节码起始位置开始算的偏移量。后面跟的是字节码指令,以及可选的字节码参数。

如何把字节码转换回成Java代码呢?有些不错的算法可以机械地复原出Java AST。这个例子我们先用比较简单的思路人肉走一遍流程。

下面用一种新的记法来跟踪Java程序的局部变量与表达式临时值的状态,例如:

       [ 0: this, 1: x, 2: undefined | this, null ]     

这个记法用方括号括住一个Java栈帧的状态。中间竖线是分隔符,左边是局部变量区,右边是操作数栈。局部变量区每个slot有标号,也就是slot number,这块可以随机访问;操作数栈的slot则没有标号,通常只能访问栈顶或栈顶附近的slot。

跟之前用的记法类似,操作数栈也是靠左边是栈底,靠右边是栈顶。

局部变量区里如果有slot尚未赋初始值的话,则标记为undefined。

让我们试着用这个记法来跟踪一下Object.equals(Object)的程序状态。

根据上文提到的Java calling convention,从该方法的signature(方法参数列表类型和返回值类型。

Method Signature

是Java层面的叫法;在JVM层面叫做

Method Descriptor

)——(Object)boolean,或者用JVM内部表现方式 (Ljava/lang/Object;)Z——我们可以知道在进入该方法的时候局部变量区的头两个slot已经填充上了参数——实例方法的slot 0是this,slot 1是第一个显式参数。

局部变量区有多少个slot是传入的参数可以看javap输出的“args_size”属性,此例为2;局部变量区总共有多少个slot可以看“locals”属性,此例为2,跟args_size一样说明这个方法没有声明任何具名的局部变量;操作数栈最高的高度可以看“stack“属性,此例为2。

我们先不管具体的参数名,后面再说;先用arg0来指代“第一个参数”。

                          // [ 0: this, 1: arg0 | ]  0: aload_0        // [ 0: this, 1: arg0 | this ]  1: aload_1        // [ 0: this, 1: arg0 | this, arg0 ]  2: if_acmpne  9   // [ 0: this, 1: arg0 | ]           // if (this != arg0) goto bci_9  5: iconst_1       // [ 0: this, 1: arg0 | 1 ]  6: goto       10  // [ 0: this, 1: arg0 | 1 ]         // goto bci_10  9: iconst_0       // [ 0: this, 1: arg0 | 0 ] 10: ireturn        // [ 0: this, 1: arg0 | phi(0, 1) ] // return phi(0, 1)      

这要如何理解呢?

  • 当指令使值从局部变量压到操作数栈的时候,我们只是记下栈的变化,其它什么都不用做。
  • 当指令从操作数栈弹出值并且进行运算的时候,我们记下栈的变化并且记下运算的内容。
  • 当指令是控制流(跳转)时,记录下跳转动作。
  • 当指令是控制流交汇处(例如这里的bci 10的位置,既可以来自bci 6也可以来自bci 9),用“phi”函数来合并栈帧中对应位置的值的状态。这里例子里,phi(0, 1)表示这个slot既可能是0也可能是1,取决于前面来自哪条指令。
  • 正统的做法应该把基本块(basic block)划分好并且构建出控制流图(CFG,control flow graph)。这个例子非常简单所以先偷个懒硬上。

其实上述过程就是一种“抽象解释”(

abstract interpretation

):我们实际上对字节码做了解释执行,只不过不以“运算出最终结果”为目的,而是以“提取出代码的某些特点”为目的。

之前有另外一个问题:

如何理解抽象解释(abstract interpretation)? - 编程语言

,这就是抽象解释的一个应用例子。

Wikipedia的

Decompiler

词条也值得一读,了解一下大背景。

把上面记录下的代码整理出来,就是:

       if (this == arg0) {   tmp0 = 1; } else { // bci_9:   tmp0 = 0; } // bci_10: return tmp0;     

这里做了几项“整理”:

  • 把if的判断条件“反过来”,跳转目标也“反过来。这是因为javac在为条件分支生成代码时,通常把then分支生成为fall through(直接执行下一条指令而不跳转),而把else分支生成为显式跳转。这样跳转的条件就正好跟源码相反。既然我们要从字节码恢复出源码,这里就得再反回去。
  • 把操作数栈上出现了phi函数的slot在恢复出的源码里用临时变量tmp来代替。这样就可以知道到底哪个分支里应该取哪个值。

现在这个源码已经挺接近真正的源码。我们还需要做少许修正:

  • 通过方法的signature,我们知道Object.equals(Object)boolean返回值是boolean类型的。前面提到了JVM字节码层面的类型系统boolean是提升到int来表示的,所以这里的1和0其实是true和false。
  • if (compare) { true } else { false },其实就是compare本身。只不过JVM字节码指令集没有返回boolean结果的比较指令,而只有带跳转的比较指令,所以生成出的代码略繁琐略奇葩。这样可以化简出tmp0 = this == arg0;
  • 所有在我们的整理过程中添加的tmp变量在原本的源码里肯定不是有名字的局部变量,而是没有名字的临时值。在恢复源码时要尽量想办法消除掉。例如说return tmp0;就应该尽量替换成return ...,其中...是计算tmp0的表达式。

结合上述三点修正,我们可以得到:

       public boolean equals(Object arg0) {   return this == arg0; }     

而这跟Object.equals(Object)boolean真正的源码几乎一样了:

           public boolean equals(Object obj) {         return (this == obj);     }      

如何?小试牛刀感觉还不错?

我们可以再试一个简单的算术运算例子。假如有下述字节码(及signature):

         public static java.lang.Object add3(int, int, int);     Code:       stack=2, locals=4, args_size=3          0: iload_0                 1: iload_1                 2: iadd                    3: istore_3                4: iload_3                 5: iload_2                 6: iadd                    7: istore_3                8: iload_3                 9: invokestatic  #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;         12: areturn      

跟前面的例子一样,我们先根据方法的signature创建出初始的栈帧状态,然后再一条条指令抽象解释下去。

这是个静态方法,没有隐含参数this。根据args_size=3可知slot 0-2是传入的参数,locals=4所以有一个显式声明的局部变量,stack=2所以操作数栈最高高度为2。

                     // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | ]  0: iload_0   // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | arg0 ]  1: iload_1   // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | arg0, arg1 ]  2: iadd      // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | tmp0 ] // tmp0 = arg0 + arg1  3: istore_3  // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | ]           // int loc3 = tmp0  4: iload_3   // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | loc3 ]  5: iload_2   // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | loc3, arg2 ]  6: iadd      // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | tmp1 ]      // tmp1 = loc3 + arg2  7: istore_3  // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | ]           // loc3 = tmp1  8: iload_3   // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | loc3 ]  9: invokestatic  #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;               // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | tmp2 ]      // tmp2 = Integer.valueOf(loc3) 12: areturn   // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | ]           // return tmp2      

这个抽象解释过程的原理跟上一例基本一样,跟踪压栈动作,记录弹栈和运算动作。

只有两点新做法值得留意:

  • 显式声明的局部变量,在还没有进入作用域之前还没有值,记为undefined。当抽象解释到某个局部变量slot首次被赋值,也就是从undefined变为有意义的值的时候,把记录下的代码写成局部变量声明,类型就用赋值进来的值的类型。后面我们会看到局部变量的声明的类型有可能还要受后面代码的影响而需要调整,现在可以先不管。
  • 每当从操作数栈弹出值,进行运算后要把结果压回到操作数栈上。为了方便记录,我们把运算用临时变量记着,并把临时变量压回到栈上。这样就不用把栈里的状态写得那么麻烦。

把记录下的代码整理出来,得到:

       tmp0 = arg0 + arg1 int loc3 = tmp0 tmp1 = loc3 + arg2 loc3 = tmp1 tmp2 = Integer.valueOf(loc3) return tmp2     

上一例也提到过,我们要尽量消除掉新添加的tmp临时变量,因为它们不是原本源码里存在的局部变量。修正后得到:

       public static Object add3(int arg0, int arg1, int arg2) {   int loc3 = arg0 + arg1;   loc3 = loc3 + arg2;   return Integer.valueOf(loc3); }     

留意:包装类型的valueOf()方法可能是源码里显式调用的,也可能是编译器给自动装箱(autoboxing)生成代码时生成的。所以遇到Integer.valueOf(loc3)的话,反编译出loc3也正确,让编译器区做自动装箱。

整理出来的代码跟我原本写的源码一致:

         public static Object add3(int x, int y, int z) {     int result = x + y;     result = result + z;     return result;   }      

就差参数/局部变量名和行号了。

其次,我们要充分利用Java Class文件里包含的符号信息。

如果我们用的是debug build的JDK,那么javap得到的信息会更多。还是以java.lang.Object.equals(Object)为例,

         public boolean equals(java.lang.Object);     Signature: (Ljava/lang/Object;)Z     flags: ACC_PUBLIC     Code:       stack=2, locals=2, args_size=2          0: aload_0                 1: aload_1                 2: if_acmpne     9          5: iconst_1                6: goto          10          9: iconst_0               10: ireturn              LineNumberTable:         line 150: 0       LocalVariableTable:         Start  Length  Slot  Name   Signature                0      11     0  this   Ljava/lang/Object;                0      11     1   obj   Ljava/lang/Object;       StackMapTable: number_of_entries = 2            frame_type = 9 /* same */            frame_type = 64 /* same_locals_1_stack_item */           stack = [ int ]     

Class文件里每个方法可以有许多元数据,里面可以包含丰富的符号信息。

其中有3个属性表含有非常重要的符号信息:

  • LineNumberTable:行号表。顾名思义,它记录了 源码里的行号 -> 该行的代码的起始bci 的映射关系。javac默认会生成该属性表,也可以显式通过-g:lines参数指定生成。
  • LocalVariableTable:局部变量表。它记录了 源码里的变量名和类型 -> 局部变量区的slot number以及作用域在什么bci范围内。javac默认不会生成该属性表,需要通过-g:vars或-g参数来指定生成。该属性表记录的类型是“擦除泛型”之后的类型。
  • LocalVariableTypeTable:局部变量类型表。这是泛型方法才会有的属性表,用于记录擦除泛型前源码里声明的类型。javac默认也不会生成该属性表,跟上一个表一样要用参数指定。

这三个属性表通常被称为“调试符号信息”。事实上,Java的调试器就是通过它们来在某行下断点、读取局部变量的值并映射到源码的变量的。放几个传送门:

为什么有时候调试代码的时候看不到变量的值。 LocalVariableTable有点迷糊
LocalVariableTable属性、LineNumberTable属性

换句话说,如果没有LocalVariableTable,调试器就无法显示参数/局部变量的值(因为不知道某个名字的局部变量对应到第几个slot);如果没有LineNumberTable,调试器就无法在某行上下断点(因为不知道行号与bci的对应关系)。

Oracle/Sun JDK的product build里,rt.jar里的Class文件都只有LineNumberTable而没有LocalVariableTable,所以只能下断点调试却不能显示参数/局部变量的值。

我是推荐用javac编译Java源码时总是传-g参数,保证所有调试符号信息都生成出来,以备不时之需。像Maven的Java compiler插件默认配置<debug>true</debug>,实际动作就是传-g参数给javac,如果想维持可调试性的话请不要把它配置为false。这些调试符号信息消耗不了多少空间,不会影响运行时性能,不要白不要——除非您的目的是想阻挠别人调试⋯

这个例子不是泛型方法所以没有LocalVariableTypeTable,只有LineNumberTable和LocalVariableTable。

LineNumberTable只有一项,说明这个方法只有一行有效的源码,第150行映射到bci [0, 11)这个半开区间。

LocalVariableTable有两项,正好描述的都是参数。它们的作用域都是bci [0, 11)这个半开区间;start和length描述的是 [start, start+length) 范围。它们的类型都是引用类型java.lang.Object。它们的名字,slot 0 -> this,slot 1 -> obj。

应用上这些符号信息,我们就可以把前面例子中反编译得到的:

       public boolean equals(Object arg0) {   return this == arg0; }      

修正为:

       public boolean equals(Object obj) {   return this == obj; // line 150 }      

与原本的源码完美吻合。

终于铺垫了足够背景知识来回过头讲讲题主原本在

java.lang.NullPointerException为什么不设计成显示null对象的名字或类型? - RednaxelaFX 的回答

下的疑问了。

假如一行源码有多个地方要解引用(dereference),每个地方都有可能抛出NullPointerException,但由此得到的stack trace的行号都是一样的,无法区分到底是哪个解引用出了问题。假如stack trace带上bci,问题就可以得到完美解决——前提是用户得能看懂bci对应到源码的什么位置。

于是让我们试一个例子。我先不说这是什么方法,只给出一小段字节码以及相关的调试符号信息:

                   44: aload_1                    45: aload_0                    46: getfield      #12                 // Field elementData:[Ljava/lang/Object;             49: iload_2                    50: aaload                     51: invokevirtual #31                 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z             54: ifeq          59            LineNumberTable:             line 302: 44             line 303: 57            LocalVariableTable:             Start  Length  Slot  Name   Signature                36      29     2     i   I                 0      67     0  this   Ljava/util/ArrayList;                 0      67     1     o   Ljava/lang/Object;            LocalVariableTypeTable:             Start  Length  Slot  Name   Signature                 0      67     0  this   Ljava/util/ArrayList<TE;>;      

从LineNumberTable可以知道,源码第302行对应到bci [44, 57)的半开区间。

从LocalVariableTable可以知道,在这段字节码的范围内每个slot到局部变量名的映射关系。

仅凭以上信息无法知道当前操作数栈的高度,不过这种上下文里通常我们可以不关心它的初始高度,暂时忽略就好。

然后让我们来抽象解释一下这段字节码:

                     // [ 0: this, 1: o, 2: i | ... ] 44: aload_1   // [ 0: this, 1: o, 2: i | ..., o ] 45: aload_0   // [ 0: this, 1: o, 2: i | ..., o, this ] 46: getfield      #12 // Field elementData:[Ljava/lang/Object;               // [ 0: this, 1: o, 2: i | ..., o, tmp0 ] // tmp0 = this.elementData 49: iload_2   // [ 0: this, 1: o, 2: i | ..., o, tmp0, i ] 50: aaload    // [ 0: this, 1: o, 2: i | ..., o, tmp1 ] // tmp1 = tmp0[i] 51: invokevirtual #31 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z               // [ 0: this, 1: o, 2: i | ..., tmp2 ]    // tmp2 = o.equals(tmp1) 54: ifeq          59               // [ 0: this, 1: o, 2: i | ... ]          // if (tmp2) goto bci_59     

整理出来:

       tmp0 = this.elementData // bci 46 tmp1 = tmp0[i]          // bci 50 tmp2 = o.equals(tmp1)   // bci 51 if (tmp2) goto bci_59   // bci 54     

可以很明显的看到这行代码有3处解引用,分别位于bci 46、50、51。当然,Java的实例方法的语义保证了此处this不会是null,所以能抛NPE的只能是bci 50和51两处。

消除掉临时变量恢复出源码,这行代码是:

       if (o.equals(this.elementData[i])) { // ...     

实际源码在此:

hg.openjdk.java.net/jdk

是 java.util.ArrayList.indexOf(Object)int 的其中一行。

假如有NullPointerException的stack trace带有bci,显示:

       java.lang.NullPointerException         at java.util.ArrayList.indexOf(ArrayList.java:line 302, bci 51)         ...      

那么我们很容易就知道这里o是null,而不是elementData是null。

通常大家会写在一行上的代码都不会很多,很少会有复杂的控制流所以通常可以不管它,用这种简单的人肉分析法以及足以应付分析抛NPE时bci到源码的对应关系。

爽不?

实际的Java Decompiler是怎么做的,可以参考开源的

Procyon

的实现。

上面的讨论都是基于“要分析的字节码来自javac编译的Java源码”。如果不是javac或者ecj这俩主流编译器生成的,或者是经过了后期处理(各种优化和混淆过),那就没那么方便了,必须用更强力的办法来抵消掉一些优化或混淆带来的问题。

===================================================================

.NET篇

题主所说的IL多半说的是MSIL,而不是泛指“中间语言”吧?

.NET的纯托管程序里的MSIL(或称为CIL)的分析方法跟前面Java篇所说的类似。

不同的是,Java世界里大家主要用javac编译出Class文件,程序怎么被优化;而C#程序的发布版通常会用csc /o优化编译,代码与原本的源码的对应关系可能会受到影响,所以一般.NET反编译出来的源码相对原本的源码的差距,可能会比一般Java程序反编译出来的大一些。这只是一般论。

关于MSIL的知识,去看

ECMA-335

规范自然好,另外也有不少现成的书可读:

Inside Microsoft .NET IL Assembler Expert .NET 2.0 IL Assembler
微软.NET程序的加密与解密

<- 看雪论坛的大大们写的书。开头有一章是介绍MSIL的。

研究MSIL的工具方面,ildasm(IL disassembler) 与 ilasm(IL assembler) 的组合完爆JDK的javap。前者能实现汇编-反汇编-汇编的roundtrip,使得实现学习MSIL非常顺手;而后者只能反汇编,不能再汇编成Class文件。

公平的说,Java也有许多第三方工具/库可以手写字节码。比较老的有例如

Jasmin

,比较新的有例如

bitescript

jitescript

,但它们有些很老了跟不上时代的步伐,而且全部都只能汇编而不能反汇编,无法达成roundtrip,总之就是略麻烦。

更新:2016年的现在OpenJDK项目里有jasm/jdis工具,终于可以跟.NET的iladm/ildasm一样roundtrip了,简直赞!请跳传送门:

是否有工具能够直接使用JVM字节码编写程序? - RednaxelaFX 的回答

反编译器方面.NET也有若干选择。以前很长一段时间

.NET Reflector

都是大家的不二之选,但自从它彻底商业化不再免费之后,大家又要寻找新的选择了。

ILSpy

似乎是新的主流选择,免费开源;

JetBrains dotPeek

免费不开源。

===================================================================

待续⋯

类似的话题

  • 回答
    想象一下,我们人类需要沟通,但我们使用的语言(比如中文、英文)千差万别。计算机的世界里也是如此,不同层级的“语言”之间也存在着微妙而清晰的对应关系。理解这一点,就像是掌握了一套解码器,能够窥探程序运行的深层机制。我们常说的“上层语言”,比如我们熟悉的Python、Java、C,它们就像是人类使用的自.............
  • 回答
    这句话“文官的衣服上绣的是禽,武官的衣服上绣的是兽。披上了这身皮,我们哪一个不是衣冠禽兽”融合了历史、文化、隐喻和讽刺,需要从多个层面进行解析: 一、历史背景与服饰象征1. 古代官服制度 在中国历史上,官服的纹饰(如禽鸟、兽类)是等级制度和身份象征的重要标志。 文官:常以“禽”为纹.............
  • 回答
    “自称迪士尼在逃公主”的现象在网络上出现后,引发了广泛讨论。这一说法通常指一些女性在社交媒体、论坛或网络社区中自称是“迪士尼公主”,并可能涉及身份扮演、文化认同、心理需求等多重层面。以下从多个角度详细分析这一现象的可能内涵和背景: 一、文化符号的再诠释:迪士尼公主的象征意义1. 迪士尼公主的原始形象.............
  • 回答
    自由主义和新自由主义是两种重要的思想体系,它们在政治哲学、经济学和社会政策等领域具有深远的影响。以下是对这两个概念的详细解析: 一、自由主义的定义与核心特征自由主义(Liberalism)是一种以个人自由、法治、民主和理性为价值基础的政治哲学思想体系,其核心在于保障个体权利和限制国家权力。自由主义的.............
  • 回答
    无政府主义(Anarchism)是一种深刻批判国家权力、追求个体自由与社会平等的政治哲学和实践运动。它并非主张“混乱”或“无序”,而是反对一切形式的强制性权威,尤其是国家对个人生活的控制。以下从多个维度深入解析这一复杂的思想体系: 一、核心定义与本质特征1. 对国家的彻底否定 无政府主义者认.............
  • 回答
    “爱国家不等于爱朝廷”这句话在理解中国古代政治和文化时非常重要。它揭示了国家与政权(即朝廷)之间的区别,以及臣民对这两者的情感和责任的不同层面。要理解这句话,我们需要先拆解其中的概念: 国家(Guó Jiā): 在古代,我们通常将其理解为国家的疆土、人民、文化、民族认同和长期的历史延续。它是根植.............
  • 回答
    理解中国人民银行工作论文中提到的“东南亚国家掉入中等收入陷阱的原因之一是‘文科生太多’”这一论断,需要从多个层面进行深入分析,因为这是一个相对复杂且具有争议性的议题。下面我将尽量详细地解释其背后的逻辑和可能含义:一、 背景:中等收入陷阱首先,我们需要理解什么是“中等收入陷阱”。 定义: 中等收入.............
  • 回答
    郭主席对房地产的表述“不希望房地产剧烈波动”可以从多个层面来理解,这背后反映了他对中国经济稳定和健康发展的深切关切。要详细理解这一点,我们需要从房地产在中国经济中的地位、波动可能带来的影响、以及“不剧烈波动”的具体含义等角度进行分析。一、 房地产在中国经济中的特殊地位:首先,理解为什么房地产会引起如.............
  • 回答
    如何理解科幻小说《时间的二分法》? 详细解读科幻小说《时间的二分法》(英文原名:The Time Machine),由英国著名作家赫伯特·乔治·威尔斯(H.G. Wells)于1895年创作,是科幻文学史上的经典之作。这部小说不仅为我们描绘了一个令人着迷的未来世界,更通过其深刻的社会寓言和哲学思考,.............
  • 回答
    尹建莉老师关于“延迟满足是鬼话,孩子要及时满足”的观点,确实在教育界引发了不少讨论。要理解她的观点,我们需要深入探讨她为什么会提出这样的论断,以及她所强调的“及时满足”的真正含义。首先,我们来拆解一下“延迟满足”这个概念及其传统理解。传统理解的“延迟满足”:延迟满足(Delayed Gratific.............
  • 回答
    理解外交部发言人陆慷的说法,即“《中英联合声明》作为一个历史文件,不再具有任何现实意义”,需要从几个关键角度来解读:1. 历史文件的定义与性质: 历史文件是过去的产物: 陆慷的表述首先强调了《中英联合声明》的“历史文件”属性。这意味着它是在特定历史时期、基于当时国际政治格局和两国关系背景下签署的.............
  • 回答
    杨振宁先生作为一位享誉世界的物理学家,他关于中美教育的评论引起了广泛关注和讨论。理解他的话需要从多个角度进行深入剖析,包括他所处的时代背景、他对教育本质的理解、以及他观察到的中美教育体系的差异。一、 杨振宁先生评论的时代背景与个人经历:首先,要理解杨振宁先生的话,必须考虑到他所处的时代背景和他的个人.............
  • 回答
    “中国是发达国家的粉碎机”这个说法,虽然带有一定的情绪化和夸张色彩,但其核心要表达的是:中国凭借其独特的经济模式、庞大的市场规模、强大的制造能力和不断进步的科技创新,对传统发达国家在经济和产业领域构成了前所未有的挑战,并在一定程度上“粉碎”了它们原有的竞争优势和发展路径。为了详细理解这一说法,我们可.............
  • 回答
    “爱国主义是流氓的最后一块遮羞布”这句话,最早出自塞缪尔·约翰逊(Samuel Johnson),一位杰出的18世纪英国作家和评论家。这句话的含义深刻且复杂,通常被用来讽刺和批评那些打着爱国旗号,但实际上在追求个人利益、制造分裂或煽动仇恨的人。要理解这句话,我们可以从以下几个层面来深入剖析:1. 字.............
  • 回答
    “Control is Dead”这句话的含义非常丰富且具有多层次的解读,它不是一个简单的字面陈述,而是对当前社会、技术、政治、经济等领域中一种普遍的失控感、权力分散化、个体自主性增强以及传统权威式微的深刻反映。要理解这句话,我们需要从不同的角度去剖析:一、 字面含义与引申含义: 字面含义: 最.............
  • 回答
    “小孩子才分对错,成年人只看利弊”这句话,乍一听可能有些功利甚至冷酷,但深入剖析,它揭示了一种关于成长、认知和处世态度的深刻变化。这句话并不是说成年人完全泯灭了道德感,而是强调在复杂的社会现实中,判断的侧重点会发生微妙而重要的转移。我们来详细地理解这句话的各个层面:一、 “小孩子才分对错”:儿童的认.............
  • 回答
    这句话以一种诗意且深刻的方式,阐述了科学与宗教(在此特指佛学)在追求真理和理解宇宙本质上可能存在的殊途同归。要理解它,我们可以从几个层面进行剖析:一、 表象的理解:科学探索的艰难与佛学智慧的超前 科学探索的“爬山”隐喻: 科学研究是一个漫长、艰辛、充满挑战的过程。科学家们如同登山者,需要克服无数.............
  • 回答
    “Don't judge”(别评判)这句简单的话语,却蕴含着深刻的道理,它不仅仅是一个简单的行为准则,更是一种生活态度和哲学。要理解它,需要从多个层面去深入剖析。核心含义:停止对他人进行预设的、带有偏见的、负面判断。“评判”(judge)这个词在中文里可以有几种理解: 审判(legal cont.............
  • 回答
    这句话, "对他们的伟大人物忘恩负义,这是伟大民族的标志",是一句富有争议且深刻的论断。要理解它,我们需要从多个层面进行剖析,包括字面含义、潜在的哲学思想、历史现实以及它可能带来的积极或消极影响。核心解读:反思与进步的动力从最核心的角度来看,这句话并非在鼓吹忘恩负义的行为本身是值得赞扬的,而是指向了.............
  • 回答
    「看山是山,看山不是山,看山还是山」,这句禅语,通常被称为“禅宗三境界”或者“悟道三阶段”,意境深远,历久弥新。它并非指代实际的山,而是用“山”这个意象来比喻一个人对事物、对真理、对自我的认知过程。理解这三层境界,能帮助我们更深刻地认识自己,认识世界。下面我将详细阐述这三层境界的含义:第一层境界:看.............

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有