使用C编译器产生清晰的二进制文件 (i386+)

来源:互联网 发布:中考物理辅导软件 编辑:程序博客网 时间:2024/05/18 05:03

Cornelis Frank
April 10, 2000
翻译:湖北武汉华中师范大学(CCNU) 城市与环境科学学院 陈斌
Email:sunwen@aspcn.com 邮编:430079
引言:
这是一篇深入剖析GCC编译器产生的汇编码的文章,非常精彩.对于初学者来说,这篇文章是进阶的好材料,它可以让你深刻地体会到C的内部机制.对于高级开发者和系统开发人员来说,这是非常好的资料,因为它可以让你更加紧密地结合汇编和C语言,提高工作效率.


我写这些文章的原因是在因特网上没有足够的关于这方面的资料,但是我需要这些资料用做EduOS工程.

对其它人使用本文做其它的坏事,本人不负任何责任.

所以如果因为我的很差的“英语”而使你的电脑爆炸,那是你的问题而不是我的.

1 我们需要什么工具
l 一台i386或更高的电脑
l 一个像Red Hat或是Slackware的Linux
l GNU GCC编译器.这个C编译器一般由Linux附带.可以在提示符下输入以下的命令来测试你是否已经有了一个GCC

gcc –version

它应当输出为:

2.7.2.3
数学可能和上面的不一样,但这并不是问题.
l For Linux 的 binutils.
l NASM 0.97或更高.Netwide Assembler,NASM,是一个在80x86下的专为模块化和可移植性而做的汇编器.它支持一系列的目标文件格式,包括Linux ‘a.out’和ELF,NetBSD/FreeBSD,COFF,Microsoft 16-bit OBJ 和Win32.它同样也可以输出平二进制文件.它的语法被设计成非常简单并且非常易懂,与Intel的相似但是更简单.它支持Pentium,P6和MMX指令,并且有宏功能.
一般来说,你的机器上没有NASM.从这里下载:
http://sunsite.unc.edu/pub/Linux/devel/lang/assemblers/
l 一个像pico或是emascs的文字编辑器

1.1 安装Netwide Assembler

假设nasm-0.97.tar.gz在当前目录下面,然后打入:

gunzip nasm-0.97.tar.gz
tar -vxf nasm-0.97.tar

它将会建立一个名为nasm-0.97的目录.进入那个目录.下面我们通过输入以下的命令来编译它:
./configure
make

这将会建立可执行的nasm和ndisasm.你可以把这两个文件copy到你的/usr/bin目录下以使它们更容易访问.现在你可以把nasm-0.97从你的系统上删除了.我在Red Hat 5.1和Slackware 3.1下成功编译,所以这应当不是什么大问题.

2 用C写第一个二进制文件

建立一个名为test.c的文件,如下所示:
int main () {
}

用下面的命令来编译它:
gcc -c test.c
ld -o test -Ttext 0x0 -e main test.o
objcopy -R .note -R .comment -S -O binary test test.bin

这建立了一个称为test.bin的二进制文件.我们可以使用ndisasm来查看这个二进制文件.像下面这样做:
ndisasm –b 32 test.bin

它将会产生下面这样的输出:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 C9 leave
00000004 C3 ret

我们得到了三列.第一列包括了指定的内存地址.第二列包含了指定的字节码,最后一列包含了指定本身.

2.1 解剖test.bin

我们得到的代码建立了一个函数的框架.为了使用函数的参数,ebp寄存器被保存了.你可以看到,代码是32位的.GNU GCC只能够建立32位的代码.所以如果你要运行这段代码,首先你要有一个32位的环境,像Linux.你必须进入保护模式.
你也可以使用ld直接建立一个二进制文件.对于test.c来说是这样的:
gcc -c test.c
ld test.o -o test.bin -Ttext 0x0 -e main -oformat binary

这将和前面的那个产生完全一样的二进制码.

3 使用一个本地变量的程序

下面我们将看一看GCC如何处理保留一个本地变量的程序.我们将建立一个新的test.c像这样:

int main () {
int i; /* declaration of an int */
i = 0x12345678; /* hexadecimal */
}

编译它:
gcc -c test.c
ld -o test -Ttext 0x0 -e main test.o
objcopy -R .note -R .comment -S -O binary test test.bin

编译完成之后,我们得到了下一个二进制文件:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 83EC04 sub esp,byte +0x4
00000006 C745FC78563412 mov dword [ebp-0x4],0x12345678
0000000D C9 leave
0000000E C3 ret

3.1 解剖test.bin

前两个和最后两个指令与前面的例子是一样的.在老指令之间只插入了两个新的指令.第一个指定把esp减4.这就是GCC保留一个int的方法,在栈上一个int是4个字节大小.下面的指令告诉我们ebp寄存器的用处.这个寄存器的内容仍然没有变只不过它的用下是指向栈中的本地变量.在栈中本地变量存主的地方通常被称作本地栈帧.在这种情况下ebp寄存器被称为帧指针.
下一条指令把整型值0x12345678放进栈了.注意处理器存放数据的顺序.在第二列,第四行,我们看到了…78563412.这种现象叫做反序存储!
注意你也可以直接使用ld直接建立一个二进制文件.所以也可以这样编译:

gcc -c test.c
ld -o test.bin -Ttext 0x0 -e main -oformat binary test.o

这可以得到和前面一样的二进制文件.

3.2 直接赋值

当我们把下面的代码

int i;
i = 0x12345678;

变成:

int i = 0x12345678;

时,我们得到了完全相同的二进制文件.这非常重要,因为我们在使用全局变量的时候情况并不是这样的.

4 使用全局变量的程序

下面我们将看一下GCC如何处理全局变量,这将由下一个test.c程序来完成.

int i; /* declaration of global variable */
int main () {
i = 0x12345678;
}

像下面这样编译:

gcc -c test.c
ld -o test -Ttext 0x0 -e main test.o
objcopy -R .note -R .comment -S -O binary test test.bin

这导致了下面的二进制码:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 C705101000007856 mov dword [0x1010],0x12345678
-3412
0000000D C9 leave
0000000E C3 ret

4.1 解剖test.bin

代码中间的那个指令将会把我们赋的值写入到内存中的某个地方,在我们当前的情况下是地址0x1010.这是因为默认情况下ld连接器把数据段按页对齐.我们也可以通过向ld加上-N参数来关闭这个功能.这将会给我们如下的二进制码:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 C705100000007856 mov dword [0x10],0x12345678
-3412
0000000D C9 leave
0000000E C3 ret

正如我们所见,数据正好跟在代码后面.我们也可以自己指定数据段.把test.c像下面这样编译:

gcc -c test.c
ld -o test -Ttext 0x0 -Tdata 0x1234 -e main -N test.o
objcopy -R .note -R .comment -S -O binary test test.bin

它将会产生如下的二进制码:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 C705341200007856 mov dword [0x1234],0x12345678
-3412
0000000D C9 leave
0000000E C3 ret

现在全局变量被放在了我们自己定义的地址0x1234上.因此,如果我们使用-Tdata参数,我们可以自己指定数据段.否则数据段正好在代码段后面.把数据放在数据内存中使我们在main函数外仍然可以访问这个变量.这就是我们叫 i 为全局变量的原因.我们同样也可以直接使用ld加参数-oformat来建立二进制文件.

4.2 直接赋值

我的经验指出直接对全局变量赋值可以和普通的全局变量一样对待,或者说可以在二进制文件后面作为数据存储.当已经是常数使用时ld把全局变量当作数据.
看下面的程序:

const int c = 0x12345678;
int main () {
}

像这样编译:
gcc -c test.c
ld -o test.bin -Ttext 0x0 -e main -N -oformat binary test.o

它将会产生如下的二进制文件:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 C9 leave
00000004 C3 ret
00000005 0000 add [eax],al
00000007 007856 add [eax+0x56],bh
0000000A 3412 xor al,0x12

你可以看到在我们的二进制文件尾部有了额外的字节.这是一个只读的数据段,以4字节对齐,它们包含了我们的全局常数变量.

4.2.1 objdump的用法

使用objdump我们可以得到更多的信息.

objdump --disassemble-all test.o

这给我们了如下信息:

test.o: file format elf32-i386
Disassembly of section .text:
00000000 <main>:
0: 55 pushl %ebp
1: 89 e5 movl %esp,%ebp
3: c9 leave
4: c3 ret
Disassembly of section .data:
Disassembly of section .rodata:
00000000 <c>:
0: 78 56 js 58 <main+0x58>
2: 34 12 xorb $0x12,%al

我们可以清楚地看到只读数据段包括了我们的全局常量c.现在我们看一看下面的这个程序:

int i = 0x12345678;
const int c = 0x12346578;
int main () {
}

然后我们编译这个程序并且用objdump来观查这个程序,得到如下结果:

test.o: file format elf32-i386
Disassembly of section .text:
00000000 <main>:
0: 55 pushl %ebp
1: 89 e5 movl %esp,%ebp
3: c9 leave
4: c3 ret
Disassembly of section .data:
00000000 <i>:
0: 78 56 js 58 <main+0x58>
2: 34 12 xorb $0x12,%al
Disassembly of section .rodata:
00000000 <c>:
0: 78 56 js 58 <main+0x58>
2: 34 12 xorb $0x12,%al

我们可以看到int i 在数据段中而我们的常数c在只读段中.所以当ld要使用全局常量的时候它自动地使用数据段去存储全局变量.

5 指针

现在我们看一看GCC如何处理指向变量的指针.所以我们使用下面的程序.

int main () {
int i;
int *p; /* a pointer to an integer */
p = &i; /* let pointer p points to integer i */
*p = 0x12345678; /* makes i = 0x12345678 */
}

这个程序将产生如下的二进制码:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 83EC08 sub esp,byte +0x8
00000006 8D55FC lea edx,[ebp-0x4]
00000009 8955F8 mov [ebp-0x8],edx
0000000C 8B45F8 mov eax,[ebp-0x8]
0000000F C70078563412 mov dword [eax],0x12345678
00000015 C9 leave
00000016 C3 ret

5.1 解剖test.bin

现在我们又得到了和以前一样的前两个和最后两个指令.然后我们得到了:

sub esp,byte +0x8

这条指令将在栈里为本地变量保存8个字节.看起来一个指针占4个字节空间.此时的栈看起来像图1.


图1
正如你所见,lea指令将会得到int i的有效地址.然后这个地址将会存到int *p中去.在此之后int *p的值变成为了指向0x12345678存储单元的双字指针.

6 调用一个函数

现在我们看看GCC如何处理函数的调用.看下一个例子:

void f (); /* function prototype */
int main () {
f (); /* function call */
}
void f () { /* function definition */
}

它将会产生如下的二进制代码:
00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 E804000000 call 0xc
00000008 C9 leave
00000009 C3 ret
0000000A 89F6 mov esi,esi
0000000C 55 push ebp
0000000D 89E5 mov ebp,esp
0000000F C9 leave
00000010 C3 ret

6.1 解剖test.bin

在main函数里我们可以清楚地看到有一个call指定调用在地址0xC处的空函数.这个空函数和main函数一样有着基本的结构.这意味着进入点函数与其它的所有的函数没有结构上的区别.当你使用ld进行连接的时候,加上-M >mem.txt参数你将得到一个文本文件,在这个文件里你可以找到关于程序是如何连接的和如何储存进内存的有用的信息.在mem.txt里你可以找到像下面的两行:

Address of section .text set to 0x0
Address of section .data set to 0x1234

这意味着二进制代码在0x0处开始并且放着全局变量的数据段存储在0x1234处.你也可以找到像下面这样的东西:

.text 0x00000000 0x11
*(.text)
.text 0x00000000 0x11 test.o
0x0000000c f
0x00000000 main

第一列包含了段名.此时它是一个.text段.第二列包含了段的起点.第三列包括了段的长度,最后一列包括了一些额外的信息,像函数名和使用的目标文件.我们现在可以清楚地看到函数f在偏移0xC处开始,main函数是二进制文件的入口.程序的长度是0x11这也是正确的,因为最后一个指令ret在0x10处,而它正好是1个字节.

6.2 objdump的用法

objdump可以显示目标文件的信息.这些信息对于得到目标文件的内部结构是非常有用的.用如下方式使用objdump:

objdump --disassemble-all test.o

我们将得到下面的输出:

test.o: file format elf32-i386
Disassembly of section .text:
00000000 <main>:
0: 55 pushl %ebp
1: 89 e5 movl %esp,%ebp
3: e8 04 00 00 00 call c <f>
8: c9 leave
9: c3 ret
a: 89 f6 movl %esi,%esi
0000000c <f>:
c: 55 pushl %ebp
d: 89 e5 movl %esp,%ebp
f: c9 leave
10: c3 ret
Disassembly of section .data:

同样这对于学习GCC产生的二进制文件也是一个非常有用的.注意他们并没有使用INTEL的语法去显示结构.他们使用像pushl和muvl这样的结构.在指令后面的l表明指令使用32位(long)的操作数.另一个与INTEL语法不同的是,它的操作数的顺序与INTEL的恰恰相反.下一个例子向我们展示了把数据从EBX移动到EAX的指令符号.

MOV EAX,EBX ; Intel syntax
movl %ebx,%eax ; ’GNU’ syntax

对于INTEL来说第一个操作数是目的操作数而第二个是源操作数.

7 返回代码

你可能已经注意到了我一直使用int main()作为我的函数定义,但是我从没有真正地返回一个整数.所以,让我们试一试.

int main () {
return 0x12345678;
}

这段代码将产生如下的二进制码:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 B878563412 mov eax,0x12345678
00000008 EB02 jmp short 0xc
0000000A 89F6 mov esi,esi
0000000C C9 leave
0000000D C3 ret

7.1 解剖test.bin

正如我们所见值由寄存器eax返回了.由于它是一个寄存器,我们没有必要显示地给它一个返回值,所以我们也可以什么也不返回.还有另外一个优点.由于返回值是存储在一个寄存器中的,我们也不必去显地读返回值.当我们使用ANSI C函数printf向屏幕打印一些东西时,我们一直在用这个.我们一直用:

printf (...);

而实际上printf向调用者返回一个int.当然如果返回的值比4个字节大,编译器就不能够用这种方法了.在下一节中我们将详细说明当这种事情发生的情况.


7.2 返回一个数据结构

考虑下一个程序,

typedef struct {
int a,b,c,d;
int i [10];
} MyDef;
MyDef MyFunc (); /* function prototype */
int main () { /* entry point */
MyDef d;
d = MyFunc ();
}
MyDef MyFunc () { /* a local function */
MyDef d;
return d;
}

这个程序产生如下的二进制代码:

00000001 89E5 mov ebp,esp
00000003 83EC38 sub esp,byte +0x38
00000006 8D45C8 lea eax,[ebp-0x38]
00000009 50 push eax
0000000A E805000000 call 0x14
0000000F 83C404 add esp,byte +0x4
00000012 C9 leave
00000013 C3 ret
00000014 55 push ebp
00000015 89E5 mov ebp,esp
00000017 83EC38 sub esp,byte +0x38
0000001A 57 push edi
0000001B 56 push esi
0000001C 8B4508 mov eax,[ebp+0x8]
0000001F 89C7 mov edi,eax
00000021 8D75C8 lea esi,[ebp-0x38]
00000024 FC cld
00000025 B90E000000 mov ecx,0xe
0000002A F3A5 rep movsd
0000002C EB02 jmp short 0x30
0000002E 89F6 mov esi,esi
00000030 89C0 mov eax,eax
00000032 8D65C0 lea esp,[ebp-0x40]
00000035 5E pop esi
00000036 5F pop edi
00000037 C9 leave
00000038 C3 ret

解剖

在main函数地址0x3处我们可以看见编译器在栈上保留了0x38个字节.这是结构MyDef的大小.在地址0x6到0x9处我们可以看见这个问题的解决方案.由于MyDef比4字节大,编译器传给在0x14处的函数MyFunc结构d的指针.这个函数而后可以利用此指针填充d.请注意一个参数正在向MyFunc传递,而这个函数实际上在它的C函数声明中并没有任何的参数.为了填充数据结构,MyFunc使用了32位的数据移动指令.

0000002A F3A5 rep movsd

7.3 返回一个数据结构II

当然我们现在可以问自己这样一个问题:如果我们不需要存储返回的数据结构,我们要传递给函数MyFunc什么样的指针呢?所以看下面的程序.

typedef struct {
int a,b,c,d;
int i [10];
} MyDef;

MyDef MyFunc (); /* function prototype */
int main () { /* entry point */
MyFunc ();
}
MyDef MyFunc () { /* a local function */
MyDef d;
return d;
}

它将产生以下的二进制代码:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 83EC38 sub esp,byte +0x38
00000006 8D45C8 lea eax,[ebp-0x38]
00000009 50 push eax
0000000A E805000000 call 0x14
0000000F 83C404 add esp,byte +0x4
00000012 C9 leave
00000013 C3 ret
00000014 55 push ebp
00000015 89E5 mov ebp,esp
00000017 83EC38 sub esp,byte +0x38
0000001A 57 push edi
0000001B 56 push esi
0000001C 8B4508 mov eax,[ebp+0x8]
0000001F 89C7 mov edi,eax
00000021 8D75C8 lea esi,[ebp-0x38]
00000024 FC cld
00000025 B90E000000 mov ecx,0xe
0000002A F3A5 rep movsd
0000002C EB02 jmp short 0x30
0000002E 89F6 mov esi,esi
00000030 89C0 mov eax,eax
00000032 8D65C0 lea esp,[ebp-0x40]
00000035 5E pop esi
00000036 5F pop edi
00000037 C9 leave
00000038 C3 ret

解剖

以上的代码告诉我们------尽管没有任何的本地变量,0x0处的main函数仍然在栈上保存了0x38字节的空间供本地变量使用.然后指向这个数据结构的指针被传给了位于0x14处的MyFunc函数,和前一个例子一样.MyFunc函数同样没有改变.

8 向函数传递参数

这一节我们将看一看如何传递参数给函数.看一下下面的这个例子:
char res; /* global variable */
char f (char a, char b); /* function prototype */
int main () { /* entry point */
res = f (0x12, 0x23); /* function call */
}
char f (char a, char b) { /* function definition */
return a + b; /* return code */
}

它将会产生如下的二进制代码:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 6A23 push byte +0x23
00000005 6A12 push byte +0x12
00000007 E810000000 call 0x1c
0000000C 83C408 add esp,byte +0x8
0000000F 88C0 mov al,al
00000011 880534120000 mov [0x1234],al
00000017 C9 leave
00000018 C3 ret
00000019 8D7600 lea esi,[esi+0x0]
0000001C 55 push ebp
0000001D 89E5 mov ebp,esp
0000001F 83EC04 sub esp,byte +0x4
00000022 53 push ebx
00000023 8B5508 mov edx,[ebp+0x8]
00000026 8B4D0C mov ecx,[ebp+0xc]
00000029 8855FF mov [ebp-0x1],dl
0000002C 884DFE mov [ebp-0x2],cl
0000002F 8A45FF mov al,[ebp-0x1]
00000032 0245FE add al,[ebp-0x2]
00000035 0FBED8 movsx ebx,al
00000038 89D8 mov eax,ebx
0000003A EB00 jmp short 0x3c
0000003C 8B5DF8 mov ebx,[ebp-0x8]
0000003F C9 leave
00000040 C3 ret

8.1 C调用惯例

我们首先注意到参数是以反序压入栈中的.这就是C调用惯例.在32位下的C调用惯例和下面列出的一样.在下面的描述中,caller和callee这两个词用于表示呼叫者函数和被呼叫的函数.

l 呼叫者函数把函数的参数一个一个地按反序压入栈(从右向左,所以第一个被指定的函数参数最后一个被压进去).
l 然后呼叫者函数执行一个近CALL指令把控制权交给被呼叫的函数.
l 被呼叫的函数收到控制权,一般地(这并不是必要的,因为有的函数不必去访问它们的参数)先把ESP中的值放入EBP中,以使EBP成为一个基址指针,从而用EBP去访问栈中的参数.然而,呼叫者函数可能也做了这个,所以惯例是EBP必须被任何C函数保存.因此被呼叫者,如果它要把EBP做为一个帧指针,必须先把以前的值压入栈.
l 被呼叫者然后就可以用EBP去访问它的参数了.在[EBP]处的双字存放了先前压入的EBP的值;下一个双字,即[EBP+4],存入了返回地址,它是由CALL隐示地压入的.真正的参数从[EBX+8]开始.最左边的参数,由于是最后压入的,可用这个偏移量进行访问;余下的参数,在余下的更高的偏移量上.因此,在像printf这样的函数里,它可以跟可变的函数参数,但我们可以找到它的第一个参数,从而知道余下的参数的个数和类型.
l 被呼叫者可能也希望减低ESP的大小,以给局部变量分配空间,这些局部变量将用EBP的负偏移量来进行访问.
l 被呼叫者如果想返回一个值给呼叫者,应当把其值放在AL,AX或EAX中,这取决于返回值的大小.浮点数一般放在ST0中.
l 一旦被呼叫者完成了任务,如果它已经分配了本地栈空间,它把ESP的值从EBP中恢复然后强出原来的EBP的值,最后通过RET返回(和RETN一样)
l 当呼叫者从被呼叫者重新得到控制权的时候,函数的参数仍然在栈中,所以一般可以把ESP加上一个常数来移除它们(而不是选用一系列的慢的POP指令).所以,如果一个函数偶然地输入了与原型不一样的错误的参数个数,栈仍然会回到一个智能的状态,因为呼叫者知道压入了多少个参数,所以它也能正确地移掉它们.

8.2 解剖

在压入两个字节的参数到栈中后,随即有一个调用在0x1c处的函数的CALL.这个函数首先将esp减4个字节,作为本地变量使用.然后这个函数把函数参数拷到本地.然后a + b被计算并存在eax中返回.

9 32位栈对齐

请注意------即便两个参数是以字节压入栈中的------函数仍然把它们从栈中以双字读出来!这看起来像是处理器把字节当作32位的双字压入栈中,这是因为栈是32位对齐的.当你以C调用惯例用汇编写32位函数时这相当重要.

10 其它的语句

当然我们也可以分析GCC是如何处理循环,while循环,if-else语句和case指令,当是这些东西东西由你自己写也不会有什么问题.如果你不想自己写也没有关系,因为你不会因为它而烦恼.

11 基础数据类型之间的转换

在这个部分我们将更近一步地观察C编译器如何把进行基本数据类型的转换.这些类型是:

l signed char和unsigned char(1字节)
l signed short和unsigned short(2字节)
l signed int和unsigned int(4字节)

首先让我们来看一看计算机如何处理有符号数据类型.

11.1 2的补码

在INTEL架构IA-32中有符号数是以2的补码的形式表现的.一个非负数n的二进制补码表示法就是把n以2为基数写成的位串.如果我们把这个位串取反后加一,我们就得到了-n的2的补码表示.对整数使用在内存中的2的补码的表示法的机器被称为2的补码机.注意在2的补码表示中,0和-0是一样的,都是0.比如:



(…)x代表的是一个以x为基数的数.同样注意负数的共同特点,高位是打开的.当然你不必去自己做一个数字的负转换.IA-32架构有一个专门用于这个的指令,就是NEG.表1向我们展示了一个char的2的补码表示法.


表1

2的补码表示法的优点是你可以对负数进行和整数一样的运算.

11.2 赋值

下面我们将看一看一些C的赋值语句在汇编中是什么样子.我们使用下面的C程序.

main () {
unsigned int i = 251;
}

当我们把这个编译成平的二进制文件时,我们得到:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 83EC04 sub esp,byte +0x4
00000006 C745FCFB000000 mov dword [ebp-0x4],0xfb
0000000D C9 leave
0000000E C3 ret

当我们把赋值语句改成:

unsigned int i = -5;

我们在地址0x6处得到了下面的指令

00000006 C745FCFBFFFFFF mov dword [ebp-0x4],0xfffffffb

现在让我们看一看有符号整数.语句

int i = 251;

产生

00000006 C745FCFB000000 mov dword [ebp-0x4],0xfb

一个使用负数的语句

int i = -5;

产生了

00000006 C745FCFBFFFFFF mov dword [ebp-0x4],0xfffffffb

看起来像是有符号和符号的赋值被一样对待了.

11.3 把signed char转换成signed int

这里我们将学习下面这个小程序:

main () {
char c = -5;
int i;
i = c;
}

产生了如下的二进制文件:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 83EC08 sub esp,byte +0x8
00000006 C645FFFB mov byte [ebp-0x1],0xfb
0000000A 0FBE45FF movsx eax,byte [ebp-0x1]
0000000E 8945F8 mov [ebp-0x8],eax
00000011 C9 leave
00000012 C3 ret

解剖

首先我们看到在地址0x3处在栈中为本地变量c和i保留了8个字节.编译器使用8个字节以使其对齐整数i.然后我们可以看到char c被0xfb填充,也就是-5(0xfb = 251, 251 - 256 = -5).注意编译器使用[ebp-0x1]而不是[ebp-0x4].这是由于小端表示法的缘故.下一个movsx才真正做了从signed char到signed integer的转换.MOVSX指令对源操作数进行符号扩展,然后拷贝到目标操作数.在leave前的最后一个指令把存储在eax中的数据传到int i中.

11.4 将signed int转换成signed char

让我们来看一看相反的转换.

main () {
char c;
int i = -5;
c = i;
}

注意c=i这句只有在值在-128到127之间的时候有意义.因为它必须要在signed char的范围内.它将产生如下的二进制代码.

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 83EC08 sub esp,byte +0x8
00000006 C745F8FBFFFFFF mov dword [ebp-0x8],0xfffffffb
0000000D 8A45F8 mov al,[ebp-0x8]
00000010 8845FF mov [ebp-0x1],al
00000013 C9 leave
00000014 C3 ret

解剖

0xfffffffb实际上就是-5.当我们只看那个不太重要的0xfb字节并把它变成一个signed char时,我们同样也得到-5.所以从一个signed int转换成一个signed char我们可以只用一个简单的mov指令.

11.5 把unsigned char转换为unsigned int

看下面的这个C程序:

main () {
unsigned char c = 5;
unsigned int i;
i = c;
}

它将会产生如下的二进制文件:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 83EC08 sub esp,byte +0x8
00000006 C645FF05 mov byte [ebp-0x1],0x5
0000000A 0FB645FF movzx eax,byte [ebp-0x1]
0000000E 8945F8 mov [ebp-0x8],eax
00000011 C9 leave
00000012 C3 ret

解剖

除了在0xA处的指令外,我们得到了和从signed char到signed int一样的代码.这里我们的指令是movzx.MOVZX将源操作数零扩展,然后拷贝到目标操作数.

11.6 把unsigned int转向unsigned char

现在我们使用这个文件:

main () {
unsigned char c;
unsigned int i = 251;
c = i;
}

请重新注意整数的值要严格限制在0到255之间.这是因为一个unsigned char不能够处理更大的数了.产生的二进制文件是:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 83EC08 sub esp,byte +0x8
00000006 C745F8FB000000 mov dword [ebp-0x8],0xfb
0000000D 8A45F8 mov al,[ebp-0x8]
00000010 8845FF mov [ebp-0x1],al
00000013 C9 leave
00000014 C3 ret

解剖

在0xD处的mov是真正做转换的指令,它和从signed integer到signed char的转换是一样的.

11.7 将signed int转换成unsigned int

程序:

main () {
int i = -5;
unsigned int u;
u = i;
}

二进制:

00000000 55 push ebp
00000001 89E5 mov ebp,esp
00000003 83EC08 sub esp,byte +0x8
00000006 C745FCFBFFFFFF mov dword [ebp-0x4],0xfffffffb
0000000D 8B45FC mov eax,[ebp-0x4]
00000010 8945F8 mov [ebp-0x8],eax
00000013 C9 leave
00000014 C3 ret

解剖

在signed和unsigned整数之间没有什么特别的转换,唯一的区别在你使用整数进行运算的时候.对于signed int将会使用像idiv,imul等指令,而unsigned int将会使用无符号版本的div,mul.


原创粉丝点击