汇编语言入门四:打通C和汇编语言

时间:2021-11-01 01:09:27

回顾

上回我们把汇编里涉及到的寄存器和内存访问相关的内容说了。先来梳理一下:

  • 寄存器是一些超级小的临时存储器,在CPU里面,存放CPU马上就要用到的数据或者刚处理完的结果
  • 要处理的数据太多,寄存器装不下了,需要更多寄存器,但是这玩意贵啊
  • 内存可以解决上述问题,但是内存相比寄存器要慢,优点是相对便宜,容量也大

插曲:C语言与汇编语言的关系

还有一些疑虑,先暂时解释一下。首先,C语言里编程里,我们从来没有关心过寄存器。汇编语言里突然冒出这么一个东西,学起来好难受。接下来的内容,我们先把C语言和汇编语言的知识,来一次大一统,帮助理解。

首先我们来看一个C语言程序:

int x, y, z;

int main() {
    x = 2;
    y = 3;
    z = x + y;
    return z;
}

考虑到我们的汇编教程才刚开始,我这里尽可能先简化C程序,这样稍后涉及到等价的汇编内容时所需的知识都是前面介绍过的。

保存为test01.c文件,先编译运行这个程序:

(注意,这里的gcc带了一个参数-m32,因为我们要编译出32位(x86)的可执行文件)

$ gcc -m32 test01.c -o test01
$ ./test01 ; echo $?
5

好了,在这里,我们的程序返回了一个值:5。

好的,接下来我们看看如果我们要用汇编实现几乎相同的过程,该怎么做?

首先,三个全局变量:

int x, y, z;

总得有吧。(这里之所以会用全局变量,是考虑到局部变量相关的汇编知识还未介绍,先将就一下,后续再说局部变量的内容)

首先,在C语言里,你可以认为每个变量都会占用一定的内存空间,也就是说,这里的x、y、z分别都占用了一个“整型”也就是4字节的存储空间。

上次我们介绍过在汇编里面访问内存的知识,当然,我们也知道了怎么在数据区划出一定的空间,这次我们就照搬前面提及的方法:

global main

main:
    mov eax, 0
    ret

section .data

x    dw    0
y    dw    0
z    dw    0

这个程序就等价于下面的C代码:

int x, y, z;

int main() {
    return 0;
}

也就是现在有了三个全局变量,只是现在汇编程序什么都没做,仅仅返回了0而已。

这里的C代码和上述汇编代码从某种程度上来说,就是完全等价的。甚至,我们的C语言编译器就可以直接把C代码,翻译成上述的汇编代码,余下的工作交给nasm再编译一次,把汇编转化为可执行文件,就能够得到最后的程序了。当然,理论上可以这么做,实际上有的编译器也就是这么做的,只是人家生成的汇编格式不是nasm,而是其它的类型,但是道理都差不多。

也就是说,一个足够精简的C编译器,只需要能够把C代码翻译成汇编代码,剩下的交给汇编器完成,也就能实现完整的C语言编译器了,也就能得到最后的可执行文件了。实际上C编译器是完全可以这么做的,甚至有的就是这么做的。

好了,先不扯这些,我们先把前面的程序补充完整,达到和最前面的C代码等价为止。接下来,我们要关注这个:

x = 2;
y = 3;

也就是要把数字2和3,分别放到x和y对应的内存区域中去。很简单,我们可以这么做:

mov eax, 2
mov [x], eax
mov eax, 3
mov [y], eax

也就是先把2扔到寄存器eax中去,然后把eax中的内容放回到x对应的内存中。同理,y也这样处理。

好了,接下来的加法语句:

z = x + y;

也可以做了:

mov eax, [x]
mov ebx, [y]
add eax, ebx
mov [z], eax

好了,这段代码应该可以看懂吧,简单说一下思路:

  • 把x和y对应的内存中的内容分别放到eax和ebx中去
  • 进行形如eax = eax + ebx的加法,最终的和存放在eax中
  • 再将eax中的内容存放到z对应的内存中去

最后,我们还有一个事情需要处理,也就是返回语句:

return z;

这个也很好办,按照约定,eax中的值,就是函数的返回值:

mov eax, [z]
ret

整个程序就算完了,我们已经完整地将C代码的汇编语言等价形式写出来了,最终的代码是这样的:

global main

main:
    mov eax, 2
    mov [x], eax
    mov eax, 3
    mov [y], eax
    mov eax, [x]
    mov ebx, [y]
    add eax, ebx
    mov [z], eax
    mov eax, [z]
    ret


section .data
x       dw      0
y       dw      0
z       dw      0

来先保存成文件test02.asm,编译运行看看效果:

$ nasm -f elf test02.asm -o test02.o 
$ gcc -m32 test02.o -o test02
$ ./test02 ; echo $?
5

搞定。结果完全和前面的C代码一致。

揭开C程序的庐山真面目

你以为自己YY出等价的汇编代码就完事儿了?图样,接下来我们继续用工具一探究竟,玩真的。

先说一下准备工作,首先有下面两个文件:

test01.c  test02.asm

其中一个为上面提到的完整C代码,一个为上述完整的汇编代码。然后按照前面的指示,都编译成可执行文件,编译完成后是这样的:

$ gcc -m32 test01.c -o test01
$ nasm -f elf test02.asm -o test02.o
$ gcc -m32 -fno-lto test02.o -o test02
$ ls
test01  test01.c  test02  test02.asm  test02.o

(注意,要按照这里的编译命令来做)

其中的test01是C代码编译出来的,test02是汇编代码编译出来的。

祭出gdb

好,接下来有请我们的大将军gdb登场。

先来看看我们的C编译后的程序,反汇编之后是什么鬼样子:

gdb ./test01

然后输入命令查看反汇编代码:

(gdb) set disassembly-flavor intel
(gdb) disas main
Dump of assembler code for function main:
   0x080483ed <+0>: push   ebp
   0x080483ee <+1>: mov    ebp,esp
   0x080483f0 <+3>: mov    DWORD PTR ds:0x804a024,0x2
   0x080483fa <+13>:    mov    DWORD PTR ds:0x804a028,0x3
   0x08048404 <+23>:    mov    edx,DWORD PTR ds:0x804a024
   0x0804840a <+29>:    mov    eax,ds:0x804a028
   0x0804840f <+34>:    add    eax,edx
   0x08048411 <+36>:    mov    ds:0x804a020,eax
   0x08048416 <+41>:    mov    eax,ds:0x804a020
   0x0804841b <+46>:    pop    ebp
   0x0804841c <+47>:    ret    
End of assembler dump.
(gdb) quit
$

好,别急,先退出,我们再看看我们汇编程序的反汇编代码:

gdb ./test02
(gdb) set disassembly-flavor intel
(gdb) disas main
   0x080483f0 <+0>: mov    eax,0x2
   0x080483f5 <+5>: mov    ds:0x804a01c,eax
   0x080483fa <+10>:    mov    eax,0x3
   0x080483ff <+15>:    mov    ds:0x804a01e,eax
   0x08048404 <+20>:    mov    eax,ds:0x804a01c
   0x08048409 <+25>:    mov    ebx,DWORD PTR ds:0x804a01e
   0x0804840f <+31>:    add    eax,ebx
   0x08048411 <+33>:    mov    ds:0x804a020,eax
   0x08048416 <+38>:    mov    eax,ds:0x804a020
   0x0804841b <+43>:    ret    
   0x0804841c <+44>:    xchg   ax,ax
   0x0804841e <+46>:    xchg   ax,ax
End of assembler dump.
(gdb) quit

好了,我们都看到反汇编代码了。先来检查一下这里test02的反汇编代码,和我们写的汇编代码是不是一致的:

   0x080483f0 <+0>: mov    eax,0x2
   0x080483f5 <+5>: mov    ds:0x804a01c,eax
   0x080483fa <+10>:    mov    eax,0x3
   0x080483ff <+15>:    mov    ds:0x804a01e,eax
   0x08048404 <+20>:    mov    eax,ds:0x804a01c
   0x08048409 <+25>:    mov    ebx,DWORD PTR ds:0x804a01e
   0x0804840f <+31>:    add    eax,ebx
   0x08048411 <+33>:    mov    ds:0x804a020,eax
   0x08048416 <+38>:    mov    eax,ds:0x804a020
   0x0804841b <+43>:    ret

直接和前面写的汇编进行比对便是,由于格式问题,里面的部分地址和标签已经面目全非,但是我们只要能够辨识出来就行了,不需要全部都搞得明明白白。这是前面的汇编代码:

    mov eax, 2
    mov [x], eax
    mov eax, 3
    mov [y], eax
    mov eax, [x]
    mov ebx, [y]
    add eax, ebx
    mov [z], eax
    mov eax, [z]
    ret

数一下行数就知道,是相同的。再仔细看看每一条指令,基本也是差不多的。当然x、y、z这些东西不见了,变成了一些奇奇怪怪的符号,在此暂不深究。

我们再看看C程序的汇编代码:

   0x080483ed <+0>: push   ebp
   0x080483ee <+1>: mov    ebp,esp
   0x080483f0 <+3>: mov    DWORD PTR ds:0x804a024,0x2
   0x080483fa <+13>:    mov    DWORD PTR ds:0x804a028,0x3
   0x08048404 <+23>:    mov    edx,DWORD PTR ds:0x804a024
   0x0804840a <+29>:    mov    eax,ds:0x804a028
   0x0804840f <+34>:    add    eax,edx
   0x08048411 <+36>:    mov    ds:0x804a020,eax
   0x08048416 <+41>:    mov    eax,ds:0x804a020
   0x0804841b <+46>:    pop    ebp
   0x0804841c <+47>:    ret 

这里,先撇开下面几个指令(这几个指令本身是有用的,但是在这个例子里,可以暂时先去掉,具体它们是干啥的,后面说),去掉它们:

push ebp
mov ebp, esp
....
pop ebp

于是C程序反汇编变成了这样子:

   0x080483f0 <+3>: mov    DWORD PTR ds:0x804a024,0x2
   0x080483fa <+13>:    mov    DWORD PTR ds:0x804a028,0x3
   0x08048404 <+23>:    mov    edx,DWORD PTR ds:0x804a024
   0x0804840a <+29>:    mov    eax,ds:0x804a028
   0x0804840f <+34>:    add    eax,edx
   0x08048411 <+36>:    mov    ds:0x804a020,eax
   0x08048416 <+41>:    mov    eax,ds:0x804a020
   0x0804841c <+47>:    ret

还是看起来不太明朗,怎么办?我们追踪里面的数字2、3和add指令,把那些稀奇古怪的符号换成我们认识的标签x、y、z再看看:

   0x080483f0 <+3>: mov    [x],0x2
   0x080483fa <+13>:    mov    [y],0x3
   0x08048404 <+23>:    mov    edx,[x]
   0x0804840a <+29>:    mov    eax,[y]
   0x0804840f <+34>:    add    eax,edx
   0x08048411 <+36>:    mov    [z],eax
   0x08048416 <+41>:    mov    eax,[z]
   0x0804841c <+47>:    ret

对比前面我们自己写的汇编代码看看呢?是不是基本是八九不离十了?仅仅有两个地方不一样:1. 使用的寄存器顺序不太一样,但是这个无妨;2. 有两条汇编指令,在C编译后的反汇编代码中对应的是一条指令。

这里我们发现了,原来

mov eax, 2
mov [x], eax

可以被精简为一条语句:

mov [x], 2

好的,按照C编译器给我们提供的信息,我们的汇编程序还可以简化成这样:

global main

main:
    mov [x], 0x2
    mov [y], 0x3
    mov eax, [x]
    mov ebx, [y]
    add eax, ebx
    mov [z], eax
    mov eax, [z]
    ret


section .data
x       dw      0
y       dw      0
z       dw      0

然而,当我们把汇编写成这样自己编译的时候,却出错了,这里并不能完全这么写,得做一些小修改,把前两条指令改成:

    mov dword [x], 0x2
    mov dword [y], 0x3

这样再编译,就没有问题了。通过研究,我们用汇编写出了和前面的C程序编译后代码等价的汇编程序:

global main

main:
    mov dword [x], 0x2
    mov dword [y], 0x3
    mov eax, [x]
    mov ebx, [y]
    add eax, ebx
    mov [z], eax
    mov eax, [z]
    ret

section .data
x       dw      0
y       dw      0
z       dw      0

总结

好了,到这里,我们通过nasm、gcc和gdb,将一个简单的C程序,用汇编语言等价地实现出来了。

说一下这一段内容的重点:

  • C程序在编译阶段,在逻辑上,会被转化成等价的汇编程序
  • 汇编程序经过编译器内置(或外置)的汇编器,编译成机器指令(到可执行文件的过程中还有一个链接阶段,后面再提)
  • 我们可以通过gdb反汇编得知一个C程序的汇编形式

其实,学习汇编语言的目的,并非主要是为了今后用汇编语言编程,而是借助于对汇编语言的理解,进一步地去理解高级语言在底层的一些细节,一个C语言的赋值语句,一个C语言的加法表达式,在编译后运行的时候,到底在做些什么。也就是通过汇编认识到计算机中,程序执行的时候到底在做些什么,CPU到底在干什么,借助于此,理解计算机程序在CPU眼里的本质。

后续通过这个,结合各种资料学习汇编语言,将是一个非常不错的选择。在对汇编进行实践和理解的过程中,也能更清楚地知道C语言里的各种写法,到底代表什么含义,加深对C语言的认识。