Java反编译器剖析
本文由 ImportNew - 邬柏 翻译自 javacodegeeks。如需转载本文,请先参见文章末尾处的转载要求。
Importnew注:如果你也对Java技术翻译分享感兴趣,欢迎加入我们的Java开发小组。参与方式请查看小组简介。
反编译器(或者解码器),简而言之,就是将目标程序码反转成源代码。但是其中的过程却比较复杂,也很有意思——Java源码是结构化的,字节码却不是。而且,转换不是一一对应的:两段完全不同的Java程序也可能生成完全相同的字节码,有时需要一些试探才能更加接近源码。
(一段简短的)字节码教程
为了更好的理解反编译器如何工作,现在有必要理解一下字节码基础。如果你对此非常熟悉,可以略过此处直接跳到下一部分。
(不同于基于寄存器 register-based 的方式)JVM运行基于栈。这就意味着指令会在 evaluation stack(计算堆栈)上执行。操作对象可能先出栈,进行一些操作,然后再把结果入栈来进行接下来的操作。考虑如下场景:
1
2
3
4
|
public
int
int
int
return
} |
注:本文所有的相关的字节码都是由 javap
产生,例如执行命令 javap
。
-c -p MyClass
1
2
3
4
5
6
7
8
9
|
public
int , int ); Code: stack= 2 , 3 , 2 0 : // 1 : // 2 : // 3 : // 4 : // 5 : // |
方法中的本地变量(包括方法声明)被寄存在所谓的JVM本地变量数组中。为了简单起见,在这里我们将一个存放在本地变量数组位置 #x
处的变量称为 slot#x
(参见JVM规范3.6.1)。
对于示例方法,slot#0
的值一般是 this
指针。然后从左到右依次是方法中的各个变量,接下来是方法中声明的本地变量。在上面的示例中,由于方法是静态的,所以没有 this
指针。相应的 slot#0
存放的是参数 x
,slot#y
存放的是参数 y
,本地变量 sum
存放在 slot#2
中。
有意思的是,每个方法的栈大小和本地变量存储空间都有最大值的限制。二者都是在编译时决定。
目前为止,所有内容都是非常直白的,仅有一点没有达到你的预期:编译器一直没有尝试去优化这些代码。事实上,javac
几乎从未支持字节码优化。这样有很多好处,比如几乎可以在任何地方设置断点:一旦移除
load/store 操作,就会失去这种特性。所以,大部分压力都转移到了运行时JIT编译器(just-in-time compiler)。
反编译
那么,怎样才能将一个非结构化、基于栈的字节码转换为结构化的Java代码呢?通常,第一步要先摈弃操作对象栈。可以通过映射栈的值成变量,并插入合适的 load/store
操作来实现这个步骤。
如果一个“栈变量”仅仅分配并使用一次,你会发现这将产生非常多的重复变量——而且接下来会生成的重复变量会更多!反编译器会将这些字节码缩减成更简单的指令集。这里对此不作深究。
我们使用 s0
代表栈变量, v0
代表原始的字节码在本地的真实引用(存在
slot 上)。
字节码 | 栈变量 | 复制传播 | |
---|---|---|---|
0 1 2 3 4 5 |
iload_0 iload_1 iadd istore_2 iload_2 ireturn |
s0 = v0 s1 = v1 s2 = s0 + s1 v2 = s2 s3 = v2 return s3 |
v2 = v0 + v1
return v2 |
通过为 push
或 pop
的每个值分配一个标识符,可以将字节码转换为本地变量。比如 iadd
是将两个操作数出栈并、相加,并将结果入栈。
然后,使用一种复制传播(copy propagation)的技术,可以消除一些重复变量。复制传播是内联的一种形式,可以将变量简单替换为指定值,前提是这种转换是有效的。
如何定义”有效性“?这里包含了一些重要准则。考虑下面这种情况:
1
2
3
|
0 : 1 : 2 : |
在这里,如果将 s0
替换为 v1
结果将大不相同。因为 v1
的值在 s0
被指定之后改变了,虽然此时 v1
的值却还没有被使用(译注:原文这里是V0
,根据注释可以确认为笔误)。为了避开这种复杂的情形,这里复制传播只考虑仅被赋值一次的内联变量(inline
variable)。
译注:一个简单的(C语言)内联变量手动解析示例,来自Wikipedia:inline
expansion
1
2
3
4
5
6
|
int
int
if
return
else return
} |
进行 inline 操作前:
1
2
3
|
int
int
return
} |
进行 inline 操作以后:
1
2
3
4
5
6
7
|
int
int
int
if
else
/* if
else
/* if
else
/* return
} |
一种改进的方案——跟踪所有非栈变量的存储空间。比如,我们知道 v1
在 #0
赋值给 v1
0,同时在 #2
被赋值给 v1
1。当对 v1
赋值超过一次,则不能进行复制传播。
不过我们最初的那个例子没有这么复杂,因而我们得到如下的优美精确的结果:
1
2
|
v2 return
|
画外音:存储变量名
如果变量在字节码中被简化为 slot 的引用,那么接下来怎样才能知道原来对象的名称呢?很有可能无法知道。为了改变这情况,改进调试的用户体验,每个方法的字节码都包含有一个特殊的部分——本地变量表。这个表中记录了原代码中每个变量的名称、slot 编号和变量名对应的字节码。通过 javap
的 -v
选项可以把本地变量表(以及其他有用的元数据)包含到反汇编代码。对于上面示例中的 plus()
方法,它的本地变量表看起来像下面这样:
1
2
3
4
|
Start 0 0 4 |
可以看到 v2
是 int
类型的变量,原来变量名为 c
,偏移位于字节码 #4-5
。
如果编译的类没有包含本地变量表(也可能被混淆器删掉),必须自己生成变量名。处理这种情况有很多办法:聪明的方法会根据变量的使用情况定义合适的名字。
栈分析
前面的示例中,在任何时刻都可以确保栈顶的变量,因此可以依次命名为 s0
、s1
等。
目前为止,在处理变量的时候都是比较直接的,因为我们仅仅采用一种代码路径来探索方法。在真实的应用环境里,多数的方法都不是那么”善解人意“。每当为方法增加一个循环或者判断,就会增加了很多可能的调用情况。让我们来看一下改进版的示例:
1
2
3
4
|
public
boolean
int
int
int
return
} |
现在情况更加复杂,如果按照之前的分配方式操作,将会遇到很大的问题。
字节码 | 栈变量 | |
---|---|---|
0 1 4 5 8 9 10 11 |
iload_0 ifeq 8 iload_1 goto 9 iload_2 istore_3 iload_3 ireturn |
s0 = v0 if (s0 == 0) goto #8 s1 = v1 goto #9 s2 = v2 v3 = {s1,s2} s4 = v3 return s4 |
我们需要对如何用栈标识符赋值更加谨慎。由于可能有多个路径能够到达,因此仅考虑每个指令自身是不够的,需要对给定的位置查看整个栈的情况。
在我们检查 #9
的时候,看到 istore_3
出栈了一个值。但是这个值可能有两个来源,可能来自于 #5
或者 #8
。栈顶 #9
的值可能是 s1
也可能是 s2
,这取决于它是来自于 #5
还是 #8
。因此,我们认为这可能是同一个变量——因此我们将其合并,所有引用 s1
或者 s2
的地方都指向这个无歧义的变量 s{1,2}
。”重新标记“(relabeling)后,可以安全地进行复制传播。
重新标记后 | 复制传播后 | |
---|---|---|
0 1 4 5 8 9 10 11 |
s0 = v0 if (s0 == 0) goto #8 s{1,2} = v1 goto #9 s{1,2} = :v2 v3 = s{1,2} s4 = v3 return s4 |
if (v0 == 0) goto #8 s{1,2} = v1 goto #9 s{1,2} = v2 v3 = s{1,2}return v3 |
值得注意的是:在 #1
处的条件分支:如果 s0
的值是0,就跳到 else
块;否则,继续当前的路径。有趣的是,与原始代码相比,这里测试条件是取反的。
接下来将我们进行更深入的研究……
在上一篇文章中,我们介绍了翻译器的功能、简单的字节码知识回顾、反编译和栈分析。本文将继续讨论反编译器中对条件表达式、变量类型分析、短路运算符和方法调用在反编译器中的处理。
条件表达式
在这里可以决定我们的代码是否使用了三元运算符(?:
):有一个判断条件,条件的每个分支都对同一个栈变量 s{1,2}
进行一次赋值,赋值后两条路径会进行合并。
一旦确定了这个模式,就可直接使用三元表达式。
复制传播后 | 合并三元表达式 | |
---|---|---|
0 1 4 5 8 9 10 11 |
if (v0 == 0) goto #8 s{1,2} = v1 goto 9 s{1,2} = v2 v3 = s{1,2}return v3 |
v3 = v0 != 0 ? v1 : v2 return v3 |
值得注意的是,作为转换的一部分,我们对 #9
处的条件进行了取反。可以看出 javac
生成的代码对判断条件取反这一行为是有规律的。因此,如果将转换后的条件取反,就可以更加接近原来的代码。
画外音:类型是什么?
当处理栈值时,JVM使用了一个比 Java
代码更为简单的类型系统。特别是 boolean
、char
和short
的值都被作为 int
值使用同一指令处理。因此, v0!
可以翻译成:
= 0
1
|
v0 false
|
或者
1
|
v0 0
|
甚至还可以翻译为
1
|
v0 false
true
true |
……还有很多其它的翻译结果!
在这个例子中,我们很幸运地知道 v0
的精确类型,这个类型包含在方法描述中:
1
2
|
descriptor: flags: |
方法签名由此可以知形式如下:
1
|
public
boolean , int , int ) |
通过签名还可以知道,v3
是 int
型(而不是 boolean
型)。因为它是返回值,通过描述符已经知道了返回值类型。接下来,还需要翻译:
1
2
|
v3 return
|
另外,如果 v0
是一个本地变量(不是形参),可能无法知道其类型是 boolean
而不是 int
。还记得我们之前提到的本地变量表,就是包含了原始本地变量名的那个表吗?除了变量名,它还记录了有变量的类型。因此,如果编译时带有debug信息,就可以从本地变量表中知道变量的类型。此外,还有一张 LocalVariableTypeTable 表,此表也包含类似的信息。两者的主要区别在于 LocalVariableTypeTable
包含了泛型信息。然而,由于LocalVariableTypeTable
中的信息是未经验证的元数据,因此不能完全依赖这些数据。一些非常规的混淆器(obfuscator)会在这些表中填入假信息,但是修改后的字节码却依然可以执行!所以请自行决定如何使用这些表。
短路运算符(‘&&’
和 ’||’
)
1
2
3
|
public
boolean
boolean
boolean
return
} |
怎么能更简单呢?不幸的是,关于字节码的理解总是有一点痛苦……
字节码 | 栈变量 | 复制传播后 | |
---|---|---|---|
0 1 4 5 8 9 12 13 16 17 |
iload_0 ifne #12 iload_1 ifeq #16 iload_2 ifeq #16 iconst_1 goto #17 iconst_0 ireturn |
s0 = v0 if (s0 != 0) goto #12 s1 = v1 if (s1 == 0) goto #16 s2 = v2 if (s2 == 0) goto #16 s3 = 1 goto 17 s4 = 0 return s{3,4} |
if (v0 != 0) goto #12 if (v1 == 0) goto #16 if (v2 == 0) goto #16 s{3,4} = 1 goto 17 s{3,4} = 0 return s{3,4} |
根据选择的路径不同,位于 #17
位置的 ireturn
指令可能返回 s3
或者 s4
。我们为其分别命名,然后使用复制传播来消除 s0
、s1
和 s2
。
接下来,在 #1
、#5
和 #7
位置有三个连续的条件。如之前提到的那样,条件分支要么跳转,要么接着执行下一条指令。
上面的字节码包含了一组遵循特定的使用模式,这些模式非常实用:
条件与(&&) | 条件或(||) |
---|---|
T1: if (c1) goto L1 if (c2) goto L2 L1: … 变成了 if (!c1 && c2) goto L2 L1: … |
T1: if (c1) goto L2 if (c2) goto L2 L1: … 变成了 if (c1 || c2) goto L2 L1: … |
如果考虑上面表中的临近条件组,#1
… #5
不遵循上面任何一种模式,但 #5
… #9
却是一个条件或(||),因此可以进行如下转换:
1
2
3
4
5
6
|
1 : if
0 ) goto
12 5 : if
0
0 ) goto
16 12 : 3 , 4 } 1 13 : goto
17 16 : 3 , 4 } 0 17 : return
3 , 4 } |
注意:每次转换都可能引入新的转换。这种情况下,可以应用 ||
对条件进行重组。现在可以对 #1...#5
应用 &&
模式!通过将这些代码合并为单个条件分支可以进一步简化方法:
1
2
3
4
5
|
1 : if
0
0
0 )) goto
16 12 : 3 , 4 } 1 13 : goto
17 16 : 3 , 4 } 0 17 : return
3 , 4 } |
这是不是看起来和其他地方很类似?是的,现在这个字节码就符合之前的三元操作符(? :
)规则了。我们可以将 #1...#16
缩减为一个独立的表达式,再使用复制传播将 s{3,4}
内联到为 #17
的 return
语句。
1
|
return
0
0
0 )) 0
1 ; |
利用方法描述符和本地变量类型表可以推断变量类型,这样缩减后的表达式如下:
1
|
return
false
false
false )) false
true ; |
好吧,现在的结果比反编译的内容更加精炼了,但是仍然不够美观。让我们看看可以做点什么。首先,折叠比较运算符,比如把 x==true
和 x==false
简写为 x
和 !x
。还可以消除三元操作符,比如把 x
简写为
? false:true!x
。
1
|
return
|
如果你还记得你高中的离散数学,那么根据德摩根定理,更进一步可以缩写为:
1
2
|
!(a !(a |
因此,
1
|
return
|
可以变为,
1
|
return
|
接着变成,
1
|
return
|
……最终会变成:
1
|
return
|
万岁!
处理方法调用
我们已经了解调用方法的流程:先将参数“存入”本地数组;要进行方法调用,必须将参数推到栈上,并且紧跟一个指向实例方法的 this
指针。方法调用的字节码正如你预想的那样:
1
2
3
|
push push invokevirtual |
在上面的代码中可以看到 invokevirtual
,该指令可以用来调用大多数的实例方法。JVM有一组方法调用的指令,每个指令都有特定的功能:
invokeinterface
:调用接口方法。invokevirtual
:调用使用virtual
语义的实例方法,比如调用的方法在运行时根据重载分派到不同的实例方法。invokespecial
:调用一个具体的实例方法(非virtual
语义)。该指令常用来调用构造器(constructor),但也可以调用类似super.method()
这样的方法。invokestatic
:调用静态方法。invokedynamic
:使用“引导方法”(bootstrap)启动自定义调用点,该命令(在Java中)很少使用。引入该命令是为了支持动态语言,在Java8中被用来实现lambda表达式。
反编译器有一个重要细节,class的常量池中包含了所有方法调用的信息,包括参数的数量、类型和返回值类型。调用的类会记录这些信息,运行时会确保该方法在调用时已存在,并对方法签名进行检查。如果调用的是第三方代码的函数,并且函数的签名发生了改变,任何试图对旧版本的调用都会抛出错误(而不是产生不可预知的行为)。
回到上面的例子,从 invokevirtual
操作码可以得知目标方法是一种实例方法。因此,需要将 this
指针作为隐含的第一参数。常量池中的
METHODREF 告诉我们该这个方法有一个形参,所以除了实例方法的指针还需要从栈上弹出一个参数。接下来代码可以重写为:
1
|
arg_0.METHODREF(arg_1) |
当然,不是所有的字节码看起来都如此“友好”。栈中的参数并不要求一个接一个排列整齐。假如参数中有一个三元表达式,那么中间就会有加载、存储和分支指令,这些都需要单独转换。混淆器可能会将方法重写成为一种特别复杂的指令序列。优秀的反编译器需要足够灵活,才能处理很多有趣的边界情形。这些已经超出了本文的讨论内容。
下一篇我们会继续探讨反编译器的更多细节和流程控制。
更多细节
目前为止,我们的分析仅限于一个单独的代码序列——以一个简单指令列表开始,经过一系列转换产生更高级别的指令。如果你认为这些都太过简化,你的看法是对的。因为Java是一种高度结构化的编程语言,包含的概念比如范围(scope)、块(block),以及更加复杂的控制流。为了处理一些更加复杂的指令,比如 if/else
块和循环(loop),我们需要对代码进行更加深入的分析,关注各种可能被选取的代码路径。这就是所谓的控制流分析。
我们首先将代码分解成连续的块,确保这些代码块会从头至尾依次执行。这些分解后的代码称作基本块(basic block)。通过在指令跳转的地方将指令列表进行分割,由此划分这些基本块。指令跳转可以是跳转到别的块,也可以是跳转到块本身。
通过在块之间连上边,就可以得到一个代表所有可能分支的控制流图(CFG,control
flow graph)。应该注意的是,这些边界可能并不十分明确,如果块中包含的指令抛出异常,那么控制流就会转到对应的异常处理程序。虽然我们不会在这里详细讨论如何构建CFG,但是为了帮助理解如何利用这些图解析类似循环这种代码结构,需要理解一些比较高层的概念。
控制流图实例
我们对控制流图最感兴趣的角度是支配关系(domination relationship):
- 若所有通向节点N的路径都经过D,那么称节点D支配了节点N。所有节点都支配自身;如果D和N是不同的节点,那么D被称为严格支配了节点N。
- 如果D严格支配了N,但严格支配节点N的其它节点不受D的严格支配,那么D可以称作直接支配N。
- 支配树(dominator tree)上的节点有这样的特性,所有子节点都是受该树节点直接支配。
- D的支配边界(dominance frontier)是一组类型N的节点集合。D直接支配类型N的前一节点,但不是完全支配N。换言之,到该集合为止节点D的支配关系结束。
译注:关于此处的概念,可以参考Wikipedia:
Dominator (graph theory)。
基本的循环和控制流
考虑如下Java方法:
1
2
3
4
5
|
public
int
for
int
0 ; System.out.println(i); } } |
反汇编结果如下:
1
2
3
4
5
6
7
8
9
10
11
|
0 : 1 : 2 : 3 : 4 : 20 7 : 2
10 : 11 : 3
14 : 1 , 1 17 : goto
20 : return |
接下来,我们应用先前讨论的内容将其转为更加可读的形式。首先引入栈变量,然后执行复制传播。
字节码 | 栈变量 | 复制传播后 | |
---|---|---|---|
0 1 2 3 4 7 10 11 14 17 20 |
iconst_0 istore_1 iload_1 iload_0 if_icmpge 20 getstatic #2 iload_1 invokevirtual #3 iinc 1, 1 goto 2 return |
s0 = 0 v1 = s0 s2 = v1 s3 = v0 if (s2 >= s3) goto 20 s4 = System.out s5 = v1 s4.println(s5) v1 = v1 + 1 goto 2 return |
v1 = 0 if (v1 >= v0) goto 20 System.out.println(v1) v1 = v1 + 1 goto 4 return |
我们注意到 #4
的条件分支和 #17
的 goto
创建了一个逻辑循环。从控制流图上可以更容易发现这个循环:
在上图中,从 goto
语句跳转回条件判断形成了一个循环。在这个例子中,条件分支作为循环入口(loop
header),可定义为循环边的支配者。循环入口支配了循环体内所有节点。
通过寻找形成循环的边,我们可以确定一个条件分支是不是循环入口。但是要如何才能做到这一点?一个简单的办法是,判断测试条件是否在其自身的控制边界内。一旦确定了循环入口,我们需要找出哪些节点应当放在循环体内。通过找出入口支配的所有节点可以达到这个目的。算法的伪代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
findDominatedNodes(header) q new
r new
q.enqueue(header) while
n if
r.add(n) for
q.enqueue(n) return
|
一旦确定了循环体,就可以将代码转换成循环了。请记住,循环入口也许是一个判断跳出循环条件语句。这种情况下需要对这个条件取反。
1
2
3
4
5
6
|
v1 0 while
System.out.println(v1) v1 1 } return |
瞧,现在我们得到了一个前置条件循环!包括while
、for
以及for-each
的大部分循环,编译后都遵循一种基本模式,这里我们都将其作为简单的 while
循环。一般来讲,我们很难完全确定原来的程序到底写的是哪一种循环。但是,for
循环和 foreach
循环都遵循着一种非常特殊的模式。这里我们不对细节进行追究。但如果对比一下上面的 while
循环,就可以发现原来的 for
循环是如何在循环开始之前对循环条件进行初始化的 (v1
,也可以了解迭代器
= 0)(v1 = v1 + 1)
如何被加到循环体的结尾。这个就当做把 while
转换为 for
和 foreach
的一个练习吧。还有一个很有意思的问题,如果要把循环改为后置条件循环 (do
/ while
)
又该怎么做呢?
我们可以使用类似的技术对 if/else
语句进行反编译。if/else
的字节码非常直观:
1
2
3
4
5
6
7
8
9
10
11
12
|
begin: iftrue(!condition) goto
else // ... goto
else : // ... end: // |
上面的代码中,我们使用 iftrue
伪指令取代条件分支:测试条件,如果通过则进入分支;否则,继续测试。我们知道, if
后面紧跟着条件,else
开始跳转。找出
‘if/else’ 块的内容与找出起始点的支配节点一样简单,执行之前的算法即可达成。
现在完成了基本的流控制机制介绍,当然还有些其他内容(比如错误处理和子程序等等),这些已经超出了本文的讨论范围。
总结
写一个反编译器不是一件简单的工作,涉及内容足以写一本甚至是一个系列的书!很明显,在一篇博客中不能覆盖所有的内容。而且即使我们这么做,也许你都不愿意读。我们希望,通过一些最普通的构造——逻辑运算、条件判断以及基本的流控制,能让你对反编译器的开发有一点有趣的了解。
现在,不如开始动手写一个自己的Java反编译器吧 :)
原文链接: javacodegeeks 翻译: ImportNew.com- 邬柏
译文链接: http://www.importnew.com/9248.html
Java反编译器剖析的更多相关文章
- 【转】推荐一款Java反编译器,比较好用
转自:http://www.blogjava.net/xmatthew/archive/2008/10/28/237203.html 推荐一款Java反编译器,也使用了挺久的了,感觉还是很好用,就拿出 ...
- Java反编译器安装及各版本介绍
JAVA语言是1995年5月由SUN公司发布的,由于其安全性高.代码优化.跨平台等特性,迅速取代了很多传统高级语言,占据了企业级网络应用开发等诸多领域的霸主地位. 不过,JAVA最突出 ...
- Java基础-使用JAVA代码剖析MD5算法实现过程
Java基础-使用JAVA代码剖析MD5算法实现过程 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任.
- 推荐一款Java反编译器,比较好用
转自:http://www.blogjava.net/xmatthew/archive/2008/10/28/237203.html 推荐一款Java反编译器,也使用了挺久的了,感觉还是很好用,就拿出 ...
- java 多线程剖析
问题的缘由源自于一道简单的面试题:题目要求如下: 建立三个线程,A线程打印10次A,B线程打印10次B,C线程打印10次C,要求线程同时运行,交替打印10次ABC. 解决问题前我们前补充一些基本知识: ...
- java浮点数剖析
定点数表达法的缺点在于其形式过于僵硬,固定的小数点位置决定了固定位数的整数部分和小数部分,不利于同时表达特别大的数或者特别小的数.计算机系统采纳了所谓的浮点数表达方式.这种表达方式利用科学计数法来表达 ...
- 【52】java多线程剖析
线程的状态: 线程共有下面4种状态: 新建状态(New): 新创建了一个线程对象,当你用new创建一个线程时,该线程尚未运行. 就绪状态(Runnable): 线程对象创建后,其他线程调用了该对象的s ...
- 【50】java 匿名内部类剖析
匿名内部类介绍: 匿名内部类也就是没有名字的内部类 正因为没有名字,所以匿名内部类只能使用一次,它通常用来简化代码编写 但使用匿名内部类还有个前提条件:必须继承一个父类或实现一个接口 匿名内部类的声明 ...
- 【49】java内部类剖析
什么是内部类: 定义在其他类(outer class)中的类被称作内部类.内部类可以有访问修饰服,甚至可以被标记为 abstract 或 final. 内部类与外部类实例有特殊的关系,这种关系允许内部 ...
- java反编译器
一时手残,把java工程中的源文件给删了,幸亏还有.class文件,想起java可以反编译,所以试一试. JD-Eclipse 如果是使用Eclipse的话,可以用Eclipse插件JadClipse ...
随机推荐
- 【Docker教程系列】Docker学习5-Docker镜像理解
通过前面几篇文章的学习,我们已经安装好了Docker,也学会使用一些常用的命令.比如启动命令.镜像命令.容器命令.常用命令分类后的第二个就是镜像命令.那么镜像是什么?拉取镜像的时候为什么是一层一层的? ...
- three.js添加3d模型
three官方的几何体也就那么几个,想要生成各种各样的模型,其难度十分之大,这时引入外部模型也不失为一种选择.具体引入办法如下. 导入依赖 点击查看代码 import * as THREE from ...
- FastGPT 正式接入 Flux,准备好迎接 AI 绘画的狂风了么?
Flux 大家最近都听说了吧?它是一款新推出的 AI 绘画模型,拳打 Stable Diffusion 3,脚踢 Midjourney,整个 AI 绘画界都沸腾了. Flux 的主创团队来自由 Sta ...
- 【合合TextIn】智能文档处理系列—电子文档解析技术全格式解析
一.引言 在当今的数字化时代,电子文档已成为信息存储和交流的基石.从简单的文本文件到复杂的演示文档,各种格式的电子文档承载着丰富的知识与信息,支撑着教育.科研.商业和日常生活的各个方面.随着信息量的爆 ...
- Angular 17+ 高级教程 – Routing 路由 (功能篇)
前言 这篇只讲功能不讲原理.没有循序渐进,没有由浅入深,一个主题讲到底. Route 目录 上一篇 Angular 17+ 高级教程 – Routing 路由 (原理篇) 下一篇 Angular 17 ...
- RxJS 系列 – Error Handling Operators
前言 前几篇介绍过了 Creation Operators Filter Operators Join Creation Operators 这篇继续介绍 Error Handling Operato ...
- 七、Scrapy框架-案例1
1. 豆瓣民谣Top排名爬取 1.1 构建scrapy项目 安装Scrapy库 pip install scrapy 创建Scrapy项目 通过cmd进入命令窗口,执行命令scrapy startpr ...
- 系统编程-进程-vfork使用、浅析
1. 先贴代码 #include <stdio.h> #include <stdlib.h> #include <unistd.h> int globvar = 6 ...
- TX御加固脱壳
示例APP某小说 其实脱这个有好几个方法,我使用了两个方法都可以脱掉. 首先使用Y佬的APK测试: 上传文件后经过等待提示任务成功,把给的ZIP包下载下来. 解压后得到两个文件,txt文件是脱壳后的a ...
- px 、em、rem 的选取依据
1. px 像素(Pixel).绝对单位.像素px是相对于显示器屏幕分辨率而言的,是一个虚拟长度单位,是计算机系统的数字化图像长度单位,如果 px要换算成物理长度,需要 指定精度 DPI. 2. em ...