汇编入门
- 1: 汇编语言入门一:环境准备
- 2: 汇编语言入门二:环境有了先过把瘾
- 3: 汇编语言入门三:是时候上内存了
- 4: 汇编语言入门四:打通C和汇编语言
- 5: 汇编语言入门五:流程控制(一)
- 6: 汇编语言入门六:流程控制(二)
- 7: 汇编语言入门七:函数调用(一)
- 8: 汇编语言入门八:函数调用(二)
- 9: 汇编语言入门九:总结与后续(闲扯)
1 - 汇编语言入门一:环境准备
现阶段,找个方便好使的编程环境还是比较蛋疼的,对于部分想过瘾或者想从学习实践中学习的小伙伴来说,略显蛋疼。不过,仔细琢磨,还是能够自己折腾出一个好用的环境来的。开搞。
环境
- Ubuntu
- gcc/nasm
也就是说,你先安装一个能正常使用的Ubuntu再说吧,然后顺便熟悉一些相关的概念和操作。
后面若没有特殊说明,那我们讨论的问题都是在这个软件环境下。
环境检查
先打开终端,安装所需软件(注意$开头的才是命令,并且$并不属于命令的一部分):
$ sudo apt-get install gcc nasm vim gcc-multilib -y
在终端中分别执行which nasm和which gcc,得到如下结果,则表示环境已经安装完毕。
$ which nasm
/usr/bin/nasm
$ which gcc
/usr/bin/gcc
开始第一个程序
在汇编语言环境下,我们先别急着搞什么Hello World,在这里要打印出Hello World还不是一个简单的事情,这也算是初入汇编比较让人不解的地方,成天都在扯什么寄存器寻址啥的,说好的变量分支循环函数呢?
别说话,先按照我的套路把环境配好,程序跑起来了再说。注意,不是Hello World。先亮出第一个程序的C语言等价代码:
int main() {
return 0;
}
不好意思,大括号没换行。你以为接下来我要gcc -S吗?Too naive。我这可是正宗手工艺,非机械化生产。
说正事,先一股脑啥都不知道地把代码敲完,跑起来再说:
首先准备个文件,暂且叫做first.asm吧,然后把下面的代码搞进去:
global main
main:
mov eax, 0
ret
好了程序写完了,你能感受到这里的0就是上面C代码里的0,说明你有学习汇编的天赋。
OK接下来就要编译运行了。来一堆命令先:
$ nasm -f elf first.asm -o first.o
$ gcc -m32 first.o -o first
这下,程序就编译好了,像这样:
$ ls
first first.asm first.o
好了我们运行一下:
$ ./first ; echo $?
别问我为何上面的命令后面多了一串奇怪的代码,你自己把它删掉之后再看就能猜出来是干啥的了。如果还有疑惑,可以再次做实验确认,比如把代码里的0改成1。变成这样:
global main
main:
mov eax, 1
ret
再按照同样的套路来编译运行:
$ nasm -f elf first.asm -o first.o
$ gcc -m32 first.o -o first
$ ./first ; echo $?
1
OK,咱们的环境准备工作大功告成,后面再细说该怎么搞事情(心情好的话还有ARM版的哦,准备好ARM环境或者买个树莓派吧)。
2 - 汇编语言入门二:环境有了先过把瘾
上回说到,咱们把环境搭好了,可以开始玩耍汇编了。
寄存器是啥玩意儿?
开始学C的时候,有没有一种感觉,变量?类型?我可是要改变世界的男人,怎么就成天在跟i++较劲啊?这黑框程序还只能用来算数学,跟说好的不一样呢???想必后来,见得多了,你的想法也不那么幼稚了吧。
好了,接下来汇编也会给你同样一种感觉的。啥玩意儿?寄存器?寻址?说好的变量类型循环函数呢?。
好了,我想先刻意避开这些晦涩难懂的东西,找到感觉了,再回头来研究条条框框。在此先把基本的几个简单的东西弄熟练,过早引入太多概念容易让人头昏眼花。
这里就说寄存器,通俗地来解释一下。回到上小学的时候,现在有一大堆计算题:
99+10=
32-20=
14+21=
47-9=
87+3=
86-8=
...
正常来讲,要算出这个么多题目,你需要一支笔,一边计算的同时一边把结果写下来。
好了,到这里,我就来做个类比,助你大致理解寄存器是干啥用的。首先,我们把CPU和大脑做一个类比。
你在纸上进行计算的时候,需要不断往纸上写下计算结果,一边在脑子里进行计算。大致过程就像:
1. 在纸上找一个题目,先看清两个数字,并迅速记下来
2. 在脑子里对这两个数字进行计算,计算出的结果也是记在脑子里的
3. 将计算结果写在纸上,继续做下一个题目
好了,这个过程就和计算机执行的过程有几分相似。草稿纸就相当于是个内存,脑子就是CPU。
在计算的时候,你需要知道计算的两个数字,而且还得知道是做什么运算,这些信息都是从草稿纸上看见之后,短暂记忆在脑子里的。
CPU在计算的时候也是一样,需要知道要计算的数据是什么,还得知道是做什么运算,这些信息也需要临时保存在CPU的某个地方。这个地方就是寄存器。
好了到这里,不知道你有没有看明白?也就是说CPU里头的寄存器的作用,就像我们在做计算的时候会临时在脑子里记住数字一样。当然你的脑子能记住不止一个数据,CPU也不止一个寄存器。
为啥C语言里没有说这些?
就是因为写汇编语言的时候,要在有限的寄存器情况下,编写复杂的程序,还要考虑灵活性、性能、正确性等等乱七八糟的问题,对于程序员是一个超级大的负担。
因此有人专门发明了许多更方便好用的“高级语言”,然后还专门写了个配套的程序能够把用这个“高级语言”写的东西翻译成汇编语言,再将汇编语言翻译成机器能执行的指令。其中之一就是C语言。也就是C语言发明出来就是奔着比汇编语言好用的目标去的。所以啊,相比汇编这种繁琐复杂的编程方式,高级语言不知道高级到哪里去了。
那学习汇编语言有用吗?
没有。
开始一顿乱写
好了,先介绍个程序,运行完了能够开心一下:
global main
main:
mov eax, 1
mov ebx, 2
add eax, ebx
ret
老套路,保存成文件,比如叫做nmb.asm,然后编译运行:
$ nasm -f elf nmb.asm -o nmb.o
$ gcc -m32 nmb.o -o nmb
$ ./nmb ; echo $?
3
如果你能看出来这里面的端倪,说明你是一个聪明伶俐的天才。不就是做了个算术题1+2=3么。
好了我们来看一下这个程序。里面的eax就是指代的寄存器。同理ebx也是一个寄存器。也就是这个CPU在做计算题的时候至少能够记住两个数字,实际上,它有更多寄存器,稍后再慢慢说。
OK。既然找到一些感觉了,就继续胡乱地拍出一大堆程序来先玩个够吧:
global main
main:
mov eax, 1
add eax, 2
add eax, 3
add eax, 4
add eax, 5
ret
global main
main:
mov eax, 1
mov ebx, 2
mov ecx, 3
mov edx, 4
add eax, ebx
add eax, ecx
add eax, edx
ret
至于这两个程序是什么结果,你自己玩吧。不动手练练怎么学得好。
指令
指令就像是你发给CPU的一个个命令,你让它做啥它就做啥。当然了,前提是CPU得支持对应的功能,CPU是没有“吃饭”功能的,你也写不出让它”吃饭“的指令来。
前面我们共用到了三个指令:分别是mov、add、ret。
我来逐个解释这些指令:
- mov
数据传送指令,我们可以像下面这样用mov指令,达到数据传送的目的。
mov eax, 1 ; 让eax的值为1(eax = 1)
mov ebx, 2 ; 让ebx的值为2(ebx = 2)
mov ecx, eax ; 把eax的值传送给ecx(ecx = eax)
- add
加法指令
add eax, 2 ; eax = eax + 2
add ebx, eax ; ebx = ebx + eax
- ret
返回指令,类似于C语言中的return,用于函数调用后的返回(后面细说)。
为啥指令长得这么丑?和我想的不一样?
首先,CPU里是一坨电路,有的功能对于人来说可能很简单,但是对于想要用电路来实现这个功能的人来说,就不一定简单了。这是需要明白的第一个道理。
所以啊,这长得丑是有原因的。其中一个原因就是,某些长的漂亮的功能用电路实现起来超级麻烦,所以干脆设计丑一点,反正到时候这些古怪的指令能够组合出我想要的功能,也就足够了。
所以,汇编语言蛋疼就在这些地方:
- 为了迁就电路的设计,很多指令不一定会按照朴素的思维方式去设计
- 需要知道CPU的工作原理,否则都不知道该怎么组织程序
- 程序复杂之后,连我自己都看不懂了,虽然能够运行得到正确的结果
- …
按道理,随着技术的发展,指令应该越来越好看,越来越符合人的思考方式才对啊。然而,世事难料,自从出现了高级语言,多数编程场景下,已经不需要关心指令和寄存器到底长啥样了,这个事情已经由编译器代劳了,99%甚至更多的程序员不需关心寄存器和指令了。所以,长得不好看就算了,反正也没什么人看。
好了,按照前面的介绍,接下来再继续了解一些东西:
更多指令、更多寄存器
- sub
减法指令(用法和加法指令类似)
sub eax, 1 ; eax = eax - 1
sub eax, ecx ; eax = eax - ecx
乘法和除法、以及更多的运算,这里就不再介绍了,这里的重点是为汇编学习带路。
- 更多寄存器
除了前面列举的eax、ebx、ecx、edx之外,还有一些寄存器:
esi
edi
ebp
其中eax、ebx、ecx、edx这四个寄存器是通用寄存器,可以随便存放数据,也能参与到大多数的运算。而余下的三个多见于一些访问内存的场景下,不过,目前,你还是可以随便抓住一个就拿来用的。
总结
到这里,赶紧根据前面了解的东西,多写几遍吧,加深一下印象。
前面说的学习汇编没用,是瞎说的。学习汇编有用,后面想起来了再说。
3 - 汇编语言入门三:是时候上内存了
上回说到了寄存器和指令,这回说下内存访问。开始之前,先来复习一下。
回顾
寄存器
- 寄存器是在CPU里面
- 寄存器的存储空间很小
- 寄存器存放的是CPU马上要处理的数据或者刚处理出的结果(还是热乎的)
指令
- 传送数据用的指令mov
- 做加法用的指令add
- 做减法用的指令sub
- 函数调用后返回的指令ret
指针和内存
高能预警
高能预警,后面会涉及到一些高难度动作,请提前做好以下准备:
- 精通2进制和16进制加减法
- 精通2进制表示与16进制表示之间的关系
- 精通8位、16位、32位、64位二进制数的16进制表示
举个例子,一个16进制数0BC71820,其二进制表示为:
00001011 11000111 00011000 00100000
你能快速地找到它们之间的对应关系吗?不会的话快去复习吧。
寄存器宽度
现在,为了简便,我们只讨论32位宽的寄存器。也就是说,目前我们讨论的寄存器,它的宽度都是32位的,也就是里面存放了一个32位长的2进制数。
通常,一个字节为8个二进制比特位,那么一个32位长的二进制数,那么它的大小就应该是4个字节。也就是把32位长的寄存器写入到内存里,会覆盖掉四个字节的存储空间。
内存
想必内存大家心里都比较有数,就是暂时存放CPU计算所需的指令和数据的地方。
诶?那前面说好的寄存器呢?寄存器也是类似的功能啊。对的,寄存器有类似功能,理论上一个最小的计算系统只需要寄存器和CPU的计算部件(ALU)就够了。不过,实际情况更加复杂一些,还是拿计算题举例,这次更复杂了:
(这里的例子只够说明寄存器和内存的角色区别,而非出现内存和寄存器这样角色的根本原因)
( 847623785 * 12874873 + 274632 ) / 999 =
好了,这个题目就不像前面的那么简单了,首先你肯定没法直接在脑子里三两下就算出来,还是得需要一个草稿纸了。
计算过程中,你还是会把草稿纸上正在计算的几个数字记在脑子里,然后快速地算完并记下来,然后往草稿纸上写。
最后,在草稿纸上演算完毕后,你会把最终结果写到试卷上。
好了,这里的草稿纸就相当于是内存了。它也充当一个临时记录数据的作用,不过它的容量就比自己的脑子要大得多了,而且一旦你把东西写下来,也就不那么担心忘记了。
诶?我不能多做点寄存器,就不需要单独的内存了呀?是的,理论上是这样,然而,实际上如果多做一点寄存器的话,CPU就要卖$9999999一片了,贵啊(具体原因可以了解SRAM与DRAM)。
也就是说,在计算机系统里,寄存器和内存都充当临时存储用,但是寄存器太小也太少了,内存就能帮个大忙了。
指针
在C语言里面,有个神奇的东西叫做指针,它是初学者的噩梦,也是高手的天堂。
这里不打算给不明白指针的人讲个明白,直接进入正题。首先,内存是一个比较大的存储器,里面可以存放非常非常多的字节。
好了,现在我们来为整个内存的所有字节编号,为了方便,咱们首先考虑按照字节为单位连续编号:
0 1 2 3 4 5 6 7 ...
......................... ......................
|12|b7|33|e8|66|4c|87|3c| ... |cc|cc|cc|cc|cc|cd|cd|
````````````````````````` ``````````````````````
大概意思一下,你可以想象每一个格子就是一个字节,每个格子都有编号,相邻的格子的编号也是相邻的。这个编号,你就可以理解为所谓的指针或者地址(这里不严格区分指针与地址)。那么当我需要获取某个位置的数据时,那么我们只需要一个编号(也就是地址)就知道在哪些格子里获取数据了,当然,写入数据也是一样的道理。
到这里,我们大概清楚了访问内存的时候需要一些什么东西:
- 首先得有内存
- 要访问内存的哪个位置(编号,地址)
那,我哪知道地址是多少呢?别介,这不是重点,你不需要知道地址具体是多少,你只需要知道它是个地址,按照正确的方式去思考和使用就行了。继续。
mov指令还没完
前面说到,寄存器可以临时存储计算所需数据和结果,那么,问题来了,寄存器也就那么几个,用完了咋办?你能发现这个问题,说明你有成为大佬的潜质。接下来,说正事。
前面说到了mov指令,可以将数据送入寄存器,也可以将一个寄存器的数据送到另一个寄存器,像这样:
mov eax, 1
mov ebx, eax
好了,这还没完,mov指令可谓是x86中花样比较多的指令了,前面的两种情形都还是比较简单的情形,今天我们来扯一下更复杂的。
寄存器不够用了
现在,某个很复杂的运算让你感觉寄存器不够用了,怎么办?按照前面说的意思,要把寄存器的东西放到内存里去,把寄存器的空间腾出来,就可以了。
好的思路有了,可是,怎么把寄存器的数据丢到内存里去呢?还是使用mov指令,只是写法不同了:
mov [0x5566], eax
好了,现在,请全神贯注。这条指令就是将寄存器的数据丢到内存里去。再多看几眼,免得看得不够顺眼:
mov [0x0699], eax
mov [0x0998], ebx
mov [0x1299], ecx
mov [0x1499], edx
mov [0x1999], esi
好了,应该已经脸熟了。
现在,我告诉你,最前面那个指令mov [0x5566], eax的作用:
将eax寄存器的值,保存到编号为0x5566对应的内存里去,按照前面的说法,一个eax需要4个字节的空间才装得下,所以编号为0x5566 0x5567 0x5568 0x5569这四个字节都会被eax的某一部分覆盖掉。
好了,我们已经了解了如何将一个寄存器的值保存到内存里去,那么我怎么把它取出来呢?
mov eax, [0x0699]
mov ebx, [0x0998]
mov ecx, [0x1299]
mov edx, [0x1499]
mov esi, [0x1999]
反过来写就是了,比如mov eax, [0x0699]就表示把0x0699这个地址对应那片内存区域中的后4个字节取出来放到eax里面去。
到此
到这,我们已经学会了如何把寄存器的数据临时保存到内存里,也知道怎么把内存里的数据重新放回寄存器了。
动手编程
接下来,该动手操练了。先来一个题目:
假设我们现在有一个比较蛋疼的要求,就是把1和2相加,然后把结果放到内存里面,最后再把内存里的结果取出来。(好无聊的题目)
那么按理说,我们就应该这么写代码:
global main
main:
mov ebx, 1
mov ecx, 2
add ebx, ecx
mov [0x233], ebx
mov eax, [0x233]
ret
好了,编译运行,假如程序是danteng,那么运行结果应该是这样:
$ ./danteng ; echo $?
3
实际上,并不能行。程序挂了,没有输出我们想要的结果。
这是在逗我呢?别急,按理说,前面说的都是没问题的,只是这里有另外一个问题,那就是“我们的程序运行在一个受管控的环境下,是不能随便读写内存的”。这里需要特殊处理一下,至于具体为何,后面有机会再慢慢叙述,这不是当下的重点,先照抄就是了。
程序应该改成这样才行:
global main
main:
mov ebx, 1
mov ecx, 2
add ebx, ecx
mov [sui_bian_xie], ebx
mov eax, [sui_bian_xie]
ret
section .data
sui_bian_xie dw 0
好了这下运行,我们得到了结果:
$ ./danteng ; echo $?
3
好了,有了程序,咱们来梳理一下每一条语句的功能:
mov ebx, 1 ; 将ebx赋值为1
mov ecx, 2 ; 将ecx赋值为2
add ebx, ecx ; ebx = ebx + ecx
mov [sui_bian_xie], ebx ; 将ebx的值保存起来
mov eax, [sui_bian_xie] ; 将刚才保存的值重新读取出来,放到eax中
ret ; 返回,整个程序最后的返回值,就是eax中的值
好了,到这里想必你基本也明白是怎么一回事了,有几点需要专门注意的:
- 程序返回时eax寄存器的值,便是整个程序退出后的返回值,这是当下我们使用的这个环境里的一个约定,我们遵守便是
与前面那个崩溃的程序相比,后者有一些微小的变化,还多了两行代码
section .data
sui_bian_xie dw 0
第一行先不管是表示接下来的内容经过编译后,会放到可执行文件的数据区域,同时也会随着程序启动的时候,分配对应的内存。
第二行就是描述真实的数据的关键所在里,这一行的意思是开辟一块4字节的空间,并且里面用0填充。这里的dw(double word)就表示4个字节,前面那个sui_bian_xie的意思就是这里可以随便写,也就是起个名字而已,方便自己写代码的时候区分,这个sui_bian_xie会在编译时被编译器处理成一个具体的地址,我们无需理会地址具体时多少,反正知道前后的sui_bian_xie指代的是同一个东西就行了。
疯狂的写代码
好了,有了这一个程序作铺垫,我们继续。趁热打铁,继续写代码,分析代码:
global main
main:
mov ebx, [number_1]
mov ecx, [number_2]
add ebx, ecx
mov [result], ebx
mov eax, [result]
ret
section .data
number_1 dw 10
number_2 dw 20
result dw 0
好了,自己琢磨着写代码,运行程序,然后分析程序每一条指令都在干什么。还有,这个程序本身还可以精简,如果你已经发现了,那说明你老T*棒了。
global main
main:
mov eax, [number_1]
mov ebx, [number_2]
add eax, ebx
ret
section .data
number_1 dw 10
number_2 dw 20
好了,好好分析比较上面的几个程序,基本这一块就了解得差不多了。随着了解的逐渐深入,我们后续还会介绍更多更复杂,更全面的内容。
反汇编
这里插播一段反汇编的讲解。引入调试器和反汇编工具,我们后续将有更多机会对程序进行深入的分析,现阶段,我们先找一个简单的程序上手,熟悉一下操作和工具。
先安装gdb:
$ sudo apt-get install gdb -y
然后,我们把这个程序,保存为test.asm:
global main
main:
mov eax, 1
mov ebx, 2
add eax, ebx
ret
然后编译:
$ nasm -f elf test.asm -o test.o ; gcc -m32 test.o -o test
运行:
$ ./test ; echo $?
3
OK,到这里,程序是对的了。开始动刀子,使用gdb:
$ gdb ./test
启动之后,你会看到终端编程变成这样了:
(gdb)
OK,说明你成功了,接下来输入,并回车:
这里要先run一次,才能开辟真正的内存空间,只要不退出本次gdb,不论run多少次,都还是占用这些内存地址
(gdb) run
Starting program: /home/vagrant/code/asm/03/test
[Inferior 1 (process 408757) exited with code 03]
(gdb) set disassembly-flavor intel
这一步是把反汇编的格式调整称为intel的格式,稍后完事儿后你可以尝试不用这个设置,看看是什么效果。好了,继续,反汇编,输入命令并回车:
(gdb) disas main
Dump of assembler code for function main:
0x080483f0 <+0>: mov eax,0x1
0x080483f5 <+5>: mov ebx,0x2
0x080483fa <+10>: add eax,ebx
0x080483fc <+12>: ret
0x080483fd <+13>: xchg ax,ax
0x080483ff <+15>: nop
End of assembler dump.
(gdb)
好了,整个程序就在这里被反汇编出来了,请你先仔细看一看,是不是和我们写的源代码差不多?(后面多了两行汇编,你把它们当成路人甲看待就行了,不用理它)。
动态调试
后面将继续介绍动态调试,帮助更加深入地理解汇编中的一些概念。现在先提示一些概念:
- 断点:程序在运行过程中,当它执行到“断点”对应的这条语句的时候,就会被强行叫停,等着我们把它看个精光,然后再把它放走
- 注意看反汇编代码,每一行代码的前面都有一串奇怪的数字,这串奇怪的数字指它右边的那条指令在程序运行时的内存中的位置(地址)。注意,指令也是在内存里面的,也有相应的地址。
好了,我们开始尝试一下调试功能,首先是设置一个断点,让程序执行到某一个地方就停下来,给我们足够的时间观察。在gdb的命令行中输入:
(gdb) break *0x080483f5
后面那串奇怪的数字在不同的环境下可能不一样,你可以结合这里的代码,对照着自己的实际情况修改。(使用反汇编中<+5>所在的那一行前面的数字)
然后我们执行程序:
(gdb) run
Starting program: /home/vagrant/code/asm/03/test
Breakpoint 1, 0x080483f5 in main ()
(gdb)
看到了吧,这下程序就被停在了我们设置的断点那个地方,对比着反汇编和你的汇编代码,找一找现在程序是停在哪个位置的吧。run后面提示的内容里,那一串奇怪的数字又出现了,其实这就是我们前面设置断点的那个地址。
好了,到这里,我们就把程序看个精光吧,先看一下eax寄存器的值:
(gdb) info register eax
eax 0x1 1
刚好就是1啊,在我们设置断点的那个地方,它的前面一个指令是mov eax, 1,这时候eax的内容就真的变成1了,同样,你还可以看一下ebx:
info register ebx
ebx 0xf7fce000 -134422528
ebx的值并不是2,这是因为mov ebx, 2这个语句还没有执行,所以暂时你看不到。那我们现在让它执行一下吧:
(gdb) stepi
0x080483fa in main ()
好了,输入stepi之后,到这里,程序在我们的控制之下,向后运行了一条指令,也就是刚刚执行了mov ebx, 2,这时候看下ebx:
(gdb) info register ebx
ebx 0x2 2
看到了吧,ebx已经变成2了。继续,输入stepi,然后看执行了add指令后的各个寄存器的值:
(gdb) stepi
0x080483fc in main ()
(gdb) info register eax
eax 0x3 3
执行完add指令之后,eax跟我们想的一样,变成了3。如果我不知道程序现在停在哪里了,怎么办?很简单,输入disas之后,又能看到反汇编了,同时gdb还会标记出当前断点所在的位置:
(gdb) disas
Dump of assembler code for function main:
0x080483f0 <+0>: mov eax,0x1
0x080483f5 <+5>: mov ebx,0x2
0x080483fa <+10>: add eax,ebx
=> 0x080483fc <+12>: ret
0x080483fd <+13>: xchg ax,ax
0x080483ff <+15>: nop
End of assembler dump.
现在刚好就在add执行过后的ret那个地方。这时候,如果你不想玩了,可以输入continue,让程序自由地飞翔起来,直到GG。
(gdb) continue
Continuing.
[Inferior 1 (process 1283) exited with code 03]
看到了吧,程序已经GG了,而且返回了一个数字03。这刚好就是那个eax寄存器的值嘛。
完整的过程
注意进入gdb后一定要先run一次,这样才会开辟真正的内存空间,之后再看内存分配和设置断点才好用
koala@koala:~/桌面/DesktopHelper/汇编语言入门/03demo$ gdb ./test
GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./test...
(No debugging symbols found in ./test)
(gdb) run
Starting program: /media/koala/data/WinUserHome/DesktopFile/DesktopHelper/汇编语言入门/03demo/test
[Inferior 1 (process 408757) exited with code 03]
(gdb) set disassembly-flavor intel
(gdb) disas main
Dump of assembler code for function main:
0x565561a0 <+0>: mov eax,0x1
0x565561a5 <+5>: mov ebx,0x2
0x565561aa <+10>: add eax,ebx
0x565561ac <+12>: ret
0x565561ad <+13>: xchg ax,ax
0x565561af <+15>: nop
End of assembler dump.
(gdb) break *0x565561a5
Breakpoint 1 at 0x565561a5
(gdb) run
Starting program: /media/koala/data/WinUserHome/DesktopFile/DesktopHelper/汇编语言入门/03demo/test
Breakpoint 1, 0x565561a5 in main ()
(gdb) info register eax
eax 0x1 1
(gdb) info register ebx
ebx 0x0 0
(gdb) stepi
0x565561aa in main ()
(gdb) info register ebx
ebx 0x2 2
(gdb) stepi
0x565561ac in main ()
(gdb) info register eax
eax 0x3 3
(gdb) disas
Dump of assembler code for function main:
0x565561a0 <+0>: mov eax,0x1
0x565561a5 <+5>: mov ebx,0x2
0x565561aa <+10>: add eax,ebx
=> 0x565561ac <+12>: ret
0x565561ad <+13>: xchg ax,ax
0x565561af <+15>: nop
End of assembler dump.
(gdb) continue
Continuing.
[Inferior 1 (process 409281) exited with code 03]
总结
好了,这次就到这里结束,内容有点多,没关系可以慢慢来,没事的时候就翻出来,把目前学的汇编语言和gdb都好好玩一下,最好是能玩出花来,这样才能有更多的收获。清点一下今天的内容:
- 通过mov指令可以把内存的数据放到寄存器中,也可以把寄存器的数据放回到内存
- 在操作系统的保护下,程序是不能随便到处访问内存的,乱搞的话会GG
- gdb的功能很牛逼
若读者对文中部分内容有疑惑或是有表达不当或是有疏漏,欢迎指正。
4 - 汇编语言入门四:打通C和汇编语言
回顾
上回我们把汇编里涉及到的寄存器和内存访问相关的内容说了。先来梳理一下:
- 寄存器是一些超级小的临时存储器,在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语言的认识。
废话
本节内容涉及的代码和操作就多一些了,当然能够耐心做完是最好的,一天两天不够就三天五天,也是值得的。
文中若有疏漏,欢迎指正。
5 - 汇编语言入门五:流程控制(一)
回顾
前面说到过这样几个内容:
- 几条简单的汇编指令
- 寄存器
- 内存访问
对应到C语言的学习过程中,无非就是这样几个内容:
- 超级简单的运算
- 变量
好了,到这里,我们继续接下来的话题,程序中的流程控制。
文中涉及一些汇编代码,建议读者自行编程,通过动手实践来加深对程序的理解。
顺序执行
首先,最简单也最好理解的程序流程,便是从前往后的顺序执行。这个非常简单,还是举出前面的例子:
现在有1000个计算题:
99+10=
32-20=
14+21=
47-9=
87+3=
86-8=
...
需要你一个个地从前往后计算,计算结果需要写在专门的答题卡上。当你每做完一个题,你需要继续做下一个题(这不是废话么)。
那么问题来了,我每次计算完一个题目,回头寻找下一个题目的时候,到底哪一个题是我接下来要计算的呢?
你可能会说:瞄一眼答题卡就知道了呀。这就尴尬了,计算机其实是比较傻的,它可没有“瞄一眼”这样的功能。
那这样的话,如果是自己做1000个题目,为了保证做题的时候每一个动作都不是多余的,有一个比较好的办法,就是强行在脑子里记住刚刚那个题目的位置。一会儿回头的时候,就立马知道该继续做哪个题了。
好了,那对于计算机来说呢?前面说到,你做计算题的时候临时留在脑子里的东西,就对应CPU里寄存器的数据。寄存器就充当了临时记住一些东西的功能。那么,在这里,CPU也是用的这个套路,在内部有一个寄存器,专门用来记录程序执行到哪里了。
CPU中的顺序执行过程
前面已经有了一个初步的结论,CPU里有一个寄存器专门存放“程序执行到哪里了”这样一个信息,而且这么做也是说得过去的,那就是:必须有一个东西记录当前程序执行到的位置,否则CPU执行完一条指令之后,就不知道接下来该干什么了。
在x86体系结构的CPU里面,这个执行位置的信息,是保存在叫做eip的寄存器中的。不过很遗憾,这个寄存器比较特殊,无法通过mov指令进行修改,也就是说,这么写mov eip, 0x233是行不通的。
(不要问我为什么,我也不知道,这都是人做出来的东西,支不支持就看人家的心情。反正Intel的CPU做出来就是这个样子的,你可以认为,Intel在做CPU的时候压根就没支持这个功能,他们觉得做了也没什么卵用。虽然你可能觉得有这个功能不是更好么,但是实际上,有时候刻意对功能施加一些限制,可以减少程序员写代码误操作的机会,eip这个东西,很关键)
好了,介绍完eip的作用之后,再说一下细节的东西。在执行一条指令的时候,eip此时代表的是下一条指令的位置,eip里保存的就是下一条指令在内存中的地址。这样,CPU在执行完成一条指令之后,就直接根据eip的值,取出下一条指令,同时还要修改eip,往eip上加一个指令的长度,让它继续指向后一条指令。
有了这样一个过程,CPU就能自动地去从前往后执行每一条指令了。而且,上述过程是在CPU中自动发生的,你写代码的时候根本不需要关心这个东西,只需要按照自己的思路从前往后写就是了。
好了,这一段更多的是讲故事,明白CPU里面有个eip寄存器,它的功能很专一,就是用来表示程序现在执行到哪儿了。说得精确一点,eip一直都指向下一个要执行的指令,这一点是由CPU自己保证的。总之,只要CPU没坏,它就能给你保证eip的精确。
事情没那么简单
前面说了eip能记住程序执行的位置,那么CPU就能顺溜溜地一路走下去了。然而,世界并不是这么美好。因为:
if( a < 1 ){
// some code ...
} else if( a >= 10 ) {
// yi xie dai ma ...
}
实际上有时候我们需要程序有一定的流程控制能力。就是有时候它不是老老实实按照顺序来执行的,中间可能会跳过一些代码,比如上述C代码中的a的值为100的时候。
那么这时候怎么搞呢?照这样说,程序就得具备“修改eip”的能力了,可是前面说了,mov指令不顶用啊?
放心,那帮做CPU的人没那么傻,他们早就想好了怎么办了。他们在设计CPU的时候是这么考虑的:
- 更改eip和更改别的寄存器产生的效果不一样,所以应该特殊对待
- 要更改有着特殊用途的eip,就用特殊的指令来完成,虽然都是在更改寄存器,但是代码写出来,表达给人的意思就不一样了
首先,我们需要更改eip来实现程序突然跳转的效果,进而灵活地对程序的流程进行控制。这里不得不祭出一套新的指令了:跳转指令。
不说了,铺垫也都差不多了,还是直接上代码,直观体验一把,然后再扯别的。先来一份正常的代码:
global main
main:
mov eax, 1
mov ebx, 2
add eax, ebx
ret
如果前面好好学习的话,对这个一定不陌生。还是大致解释一下吧:
eax = 1
ebx = 2
eax = eax + ebx
所以,按照正常逻辑理解,最后eax为3,整个程序退出时会返回3。
好的,到这里,我们来引入新的指令,通过前后对比的变化,来理解新的指令的作用:
global main
main:
mov eax, 1
mov ebx, 2
jmp gun_kai
add eax, ebx
gun_kai:
ret
这段代码相比前面的代码,多了两行:
...
jmp gun_kai
...
gun_kai:
...
好了,这段代码其实没什么功能,存粹是为了演示,运行这个代码,得到的返回结果为1。
好了,最后的结果告诉我们,中间的那一条指令:
add eax, ebx
根本就没有执行,所以最后eax的值就是1,整个程序的返回值就是1。
好了,这里也没什么需要解释的,动手做,稍微对比分析一下就能够知道结论了。程序中出现了一条新的指令jmp,这是一个跳转指令,不解释。这里直接用一个等价的C语言来说明上述功能吧:
int main() {
int a = 1;
int b = 2;
goto gun_kai;
a = a + b;
gun_kai:
return a;
}
实际上,C语言中的goto语句,在编译后就是一条jmp指令。它的功能就是直接跳转到某个地方,你可以往前跳转也可以往后跳转,跳转的目标就是jmp后面的标签,这个标签在经过编译之后,会被处理成一个地址,实际上就是在往某个地址处跳转,而jmp在CPU内部发生的作用就是修改eip,让它突然变成另外一个值,然后CPU就乖乖地跳转过去执行别的地方的代码了。
这玩意有啥用?
不对啊,这跳转指令能用来干啥?反正代码都直接被跳过去了,那我编程的时候干脆直接不写那几条指令不就得了么?使用跳转指令是不是有种脱了裤子放屁的感觉?
并不是,继续。
if在汇编里的样子
前面说到了跳转,但是仿佛没卵用的样子。接下来我们说这样一个C语言程序:
int main() {
int a = 50;
if( a > 10 ) {
a = a - 10;
}
return a;
}
这个程序,最后的返回值是40,这没什么好解释的。那对应的汇编程序呢?其实也非常简单,先直接给出代码再分析:
global main
main:
mov eax, 50
cmp eax, 10 ; 对eax和10进行比较
jle xiaoyu_dengyu_shi ; 小于或等于的时候跳转
sub eax, 10
xiaoyu_dengyu_shi:
ret
这段汇编代码很关键的地方就在于这两条陌生的指令:
cmp eax, 10 ; 对eax和10进行比较
jle xiaoyu_dengyu_shi ; 小于或等于的时候跳转
先细细解释一下:
- 第一条,cmp指令,专门用来对两个数进行比较
- 第二条,条件跳转指令,当前面的比较结果为“小于或等于”的时候就跳转,否则不跳转
到这里,至少上面这个程序,每一条指令都是很清楚的。只是你关心的是下面的问题:
- 我会写a > 10的情况了,那么a < 10怎么办呢?a == 10怎么办呢?a <= 10怎么办呢?a >= 10怎么办呢?
凉拌炒鸡蛋。
别急,先说套路。上面的C语言代码是这样的:
if ( a > 10 ) {
a = a - 10;
}
这是表示:“比较a和10,a大于10的时候,进入if块中执行减法”
而汇编代码:
cmp eax, 10
jle xiaoyu_dengyu_shi
sub eax, 10
xiaoyu_dengyu_shi:
表示的是:“比较eax和10,eax小于等于10的时候,跳过中间的减法”
注意这里最关键的两个表述:
- C语言中:a大于10的时候,进入if块中执行减法
- 汇编语言中:eax小于等于10的时候,跳过中间的减法
C语言和汇编语言中的条件判断,其组织的思路是刚好相反的。这就在编程的时候带来一些思考上的困难,不过这都还是小事情,实在困难你可以先画出流程图,然后对流程图进行改造,就可以了。
有了上面if的套路,接下来趁热打铁,再做一个练习:
int main() {
int x = 1;
if ( x > 100 ) {
x = x - 20;
}
x = x + 1;
return x;
}
好了,这里按照前面的思路,在汇编语言里面,关键就是下面几点:
- 对x对应的东西与100进行比较
- 何时跳过if块中的减法
- x = x + 1是无论如何都会执行的
按照前面的代码,稍作类比,很容易地就能写出下面的代码来:
global main
main:
mov eax, 1
cmp eax, 100
jle xiao_deng_yu_100
sub eax, 20
xiao_deng_yu_100:
add eax, 1
ret
把程序结合着前面的C代码进行对比,参考前面说的if在汇编里组织的套路,这个程序就很容易理解了。你还可以尝试把
mov eax, 1
更改为:
mov eax, 110
试试程序的执行逻辑是不是发生了变化?
再来套路
前面说到了if在汇编中的组织方式,接下来,问题就更加复杂了:
- 我会写a > 10的情况了,那么a < 10怎么办呢?a == 10怎么办呢?a <= 10怎么办呢?a >= 10怎么办呢?
凉拌炒鸡蛋。
前面实际上只提到了两个流程控制相关的指令:
- jmp
- jle
以及一个比较指令:
- cmp
专门用来对两个操作数进行比较。
先从这里入手,总结套路。首先,这两条跳转指令是人想出来的,所以,你很容易想到,仅仅是这两条跳转指令好像还不够。其实,人家做CPU的人早也就想到了。所以,还有这样一些跳转指令:
ja 大于时跳转
jae 大于等于
jb 小于
jbe 小于等于
je 相等
jna 不大于
jnae 不大于或者等于
jnb 不小于
jnbe 不小于或等于
jne 不等于
jg 大于(有符号)
jge 大于等于(有符号)
jl 小于(有符号)
jle 小于等于(有符号)
jng 不大于(有符号)
jnge 不大于等于(有符号)
jnl 不小于
jnle 不小于等于
jns 无符号
jnz 非零
js 如果带符号
jz 如果为零
好了,这就是一些条件跳转指令,将它们配合着前面的cmp指令一起使用,就能够达到if语句的效果。
What?这该不会都得记住吧?其实不用,这里面是有套路的:
- 首先,跳转指令的前面都是字母j
- 关键是j后面的的字母
比如j后面是ne,对应的是jne跳转指令,n和e分别对应not和equal,也就是“不相等”,也就是说在比较指令的结果为“不想等”的时候,就会跳转。
- a: above
- e: equal
- b: below
- n: not
- g: greater
- l: lower
- s: signed
- z: zero
好了,这里列出来了j后面的字母所对应的含义。根据这些字母的组合,和上述大概的规则,你就能清楚怎么写出这些跳转指令了。当然,这里有“有符号”和“无符号”之分,后面有机会再扯,读者也可以自行了解。
那么,接下来,就可以写出这样的程序所对应的汇编代码了:
int main() {
int x = 10;
if ( x > 100 ) {
x = x - 20;
}
if( x <= 10 ) {
x = x + 10;
}
x = x + 1;
return 0;
}
这个程序没什么卵用,存粹是为了演示。按照前面的套路,其实写出汇编代码也就不难了:
global main
main:
mov eax, 10
cmp eax, 100
jle lower_or_equal_100
sub eax, 20
lower_or_equal_100:
cmp eax, 10
jg greater_10
add eax, 10
greater_10:
add eax, 1
ret
至于更多可能的写法,那就可以慢慢玩了。
if都有了,那else if和else怎么办呢?
这里就不再赘述了,理一下思路:
- 首先根据你的需要,画出整个程序的流程图
- 按照流程图中的跳转关系,通过汇编表达出来
也就是说,在汇编里面,实际上没有所谓的if或else的说法,只是前面为方便说明,使用了C语言作类比,实际上汇编还可以写得比C语言的判断更加灵活。
事实上,C语言里面的几种常见的if组织结构,都有对应的汇编语言里的套路。说白了,都是套路。
那你怎么才能知道这些套路呢?很简单,用C语言写一个简单的程序,编译后按之前文章所说的内容,使用gdb去反汇编然后就能知道这里面的具体做法了。
下面来尝试下一下:
int main() {
register int grade = 80;
register int level;
if ( grade >= 85 ){
level = 1;
} else if ( grade >= 70 ) {
level = 2;
} else if ( grade >= 60 ) {
level = 3;
} else {
level = 4;
}
return level;
}
(程序中有一个register关键字,是用来限定这个变量在编译后只能用寄存器来进行表示,方便我们进行分析。读者可以根据需要,去掉register关键字后比较一下反汇编代码有何不同。)
这是一个很经典的多分支程序结构。先编译运行,程序返回值为2。
$ gcc -m32 grade.c -o grade
$ ./grade ; echo $?
2
好了,接下来,用gdb进行反汇编:
$ gdb ./grade
(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>: push ebx
0x080483f1 < +4>: mov ebx,0x50
0x080483f6 < +9>: cmp ebx,0x54
0x080483f9 <+12>: jle 0x8048402 <main+21>
0x080483fb <+14>: mov ebx,0x1
0x08048400 <+19>: jmp 0x804841f <main+50>
0x08048402 <+21>: cmp ebx,0x45
0x08048405 <+24>: jle 0x804840e <main+33>
0x08048407 <+26>: mov ebx,0x2
0x0804840c <+31>: jmp 0x804841f <main+50>
0x0804840e <+33>: cmp ebx,0x3b
0x08048411 <+36>: jle 0x804841a <main+45>
0x08048413 <+38>: mov ebx,0x3
0x08048418 <+43>: jmp 0x804841f <main+50>
0x0804841a <+45>: mov ebx,0x4
0x0804841f <+50>: mov eax,ebx
0x08048421 <+52>: pop ebx
0x08048422 <+53>: pop ebp
0x08048423 <+54>: ret
篇幅有限,这里就留给读者练习分析了。其中有几个需要注意的地方:
- 部分无关指令可以直接忽略掉,如:push、pop等
- 跳转指令后的<main+21>,就对应的是反汇编指令前是<+21>的指令
根据上述反汇编代码,分析出程序的流程图,与C语言程序的代码进行比较。仔细分析,你应该就发现jmp指令有什么用了吧。
状态寄存器
到这里,有一个问题出现了,在汇编语言里面实现“先比较,后跳转”的功能时,后面的跳转指令是怎么利用前面的比较结果的呢?
这就涉及到另一个寄存器了。在此之前,先想一下,如果自己在脑子里思考同样的逻辑,是怎么样的?
- 先比较两个数
- 记住比较结果
- 根据比较结果作出决定
好了,这里又来了一个“记住”的动作了。CPU里面也有一个专用的寄存器,用来专门“记住”这个cmp指令的比较结果的,而且,不仅是cmp指令,它还会自动记住其它一些指令的结果。这个寄存器就是:
eflags
名为“标志寄存器”,它的作用就是记住一些特殊的CPU状态,比如前一次运算的结果是正还是负、计算过程有没有发生进位、计算结果是不是零等信息,而后续的跳转指令,就是根据eflags寄存器中的状态,来决定是否要进行跳转的。
cmp指令实际上是在对两个操作数进行减法,减法后的一些状态最终就会反映到eflags寄存器中。
总结
这回着重说到了汇编语言中与流程控制相关的内容。其中主要包括:
- eip寄存器指示着CPU接下来要执行哪里的代码
- 一系列跳转指令,跳转指令根本上就是修改了eip
- 比较指令,比较指令实际上是在做减法,然后把结果的一些状态放到eflags寄存器中
- eflags寄存器的作用
- 条件跳转指令也就是根据eflags中的信息来决定是否跳转
当然,这里讲述的仅仅是一部分相关的指令,带领读者对这部分内容有一个直观的认识。实际上汇编语言中与流程相关的指令不止这些,读者可自行查阅相关的资料:
- x86标志寄存器
- x86影响标志寄存器的指令
- x86跳转指令
本文内容相比之前要更多一些,若想要完全理解,也需要仔细阅读,多思考、多尝试,多验证,也可以参考更多其它方面的资料。
文中若有疏漏之处,欢迎指正。
6 - 汇编语言入门六:流程控制(二)
回顾
前面说到在汇编语言中实现类似C语言if-else if-else这样的结构,
实际上,在汇编里面,我们并不关心if了,取而代之的是两种基本的指令:
- 比较
- 跳转
这两种指令即可组成最基本的分支程序结构,虽然跳转指令非常多,但是我们已经有套路了,怎么跳转都不怕了。当然,在编程环境中仅有分支还不够的,我们知道C语言中除了分支结构之外,还有循环这个最基本也是最常用的形式。正好,这也是本节话题的主角。
文中涉及一些汇编代码,建议读者自行编程,通过动手实践来加深对程序的理解。
拆散循环结构
上回说到C语言中if这样的结构,在汇编里对应的是怎么回事,实质上,这就是分支结构的程序在汇编里的表现形式。
实际上,循环结构相比分支结构,本质上,没有多少变化,仅仅是比较合跳转指令的组合的方式与顺序有所不同,所以形成了循环。
当然,这个说法可能稍微拗口了一点。说得简单一点,循环的一个关键特点就是:
- 程序在往回跳转
细细想,好像有道理哦,如果程序每到一个位置就往前跳转,那就是死循环,如果是在这个位置根据条件决定是否要向前跳转,那就是有条件的循环了。
口说无凭,还是先来分析一下一个C语言的while循环:
(Talk is chip, show your code!)
int sum = 0;
int i = 1;
while( i <= 10 ) {
sum = sum + i;
i = i + 1;
}
想必这段程序多数人都非常熟悉了,当年自己第一次学习循环的时候就碰到这个题目,脑子短路了,心里总想着这不就是一个等差数列公式么,题目却强行出现在循环一章的后面,最后结果让人大跌眼睛,这是要我老老实实像SHAB一样去加啊。
跑题了,先大致总结一下这个程序的关键部分到底在干什么:
- 1. 比较i和10的大小
- 2. 如果i <= 10则执行代码块,并回到(1)
- 3. 如果不满足 i <= 10,则跳过代码块
好了,按照这个逻辑,在C语言中不使用循环怎么实现?其实也非常简单:
int sum = 10;
int i = 1;
_start:
if( i <= 10 ) {
sum = sum + i;
i = i + 1;
goto _start;
}
这还不够,我们还得做一次变形,为什么呢?回想一下前面说的分之程序在汇编里的情况:
if ( a > 10 ) {
// some code
}
上述C代码,暂且成为“正宗C代码”,等价的汇编大致结构如下:
cmp eax, 10
jle out_of_block
; some code
out_of_block:
再等价变换回C语言,这里把这种风格叫做“山寨C代码”,实际上就是这样的:
if( a <= 10 ) goto out_of_block;
// some code
out_of_block:
经过比较,我们可以发现“山寨C代码”和“正宗C代码”之间的一些区别:
- 山寨版中,if块里只需要放一条跳转语句即可
- 山寨版中,if里的条件是反过来的
- 山寨版中,跳转语句的功能是跳过“正宗C代码”的if块
相当于是:不满足条件就跳过if中的语句块。
那循环呢?咱们把循环的C等价代码做一次变换,也就是把只含有goto和if的“正宗C代码”变换为“山寨C代码”的形式:
int sum = 10;
int i = 1;
_start:
if( i > 10 ) {
goto _end_of_block;
}
sum = sum + i;
i = i + 1;
goto _start;
_end_of_block:
大致看一下流程,再对比源代码:
int sum = 0;
int i = 1;
while( i <= 10 ) {
sum = sum + i;
i = i + 1;
}
自己在脑子里面模拟一遍,是不是就能发现什么了?这俩货分明就是一个东西,执行的顺序和过程完全就是一样的。
到这里,我们的循环结构,全都被拆散成了最基本的结构,这种结构有一个关键的特点:
- 所有if块中都仅有一条goto语句,别的啥都没了
到这里,本段就到位了。
用汇编写出循环
前面已经介绍了“如何把一个循环拆解成只有if和goto的结构”,有了这个结构之后,其实要写出汇编就非常容易了。
继续看山寨版的循环:
int sum = 10;
int i = 1;
_start:
if( i > 10 ) {
goto _end_of_block;
}
sum = sum + i;
i = i + 1;
goto _start;
_end_of_block:
其实,稍微仔细一点就能发现,把这玩意儿写成汇编,就是逐行翻译就完事儿了。动手:
global main
main:
mov eax, 0
mov ebx, 1
_start:
cmp ebx, 10
jg _end_of_block
add eax, ebx
add ebx, 1
jmp _start
_end_of_block:
ret
这里面其实有一个套路:
- 单条goto语句可以直接用jmp语句替代
- if和goto组合的语句块可以用cmp和j*指令的组合替代
最后,其它语句该干啥干啥。
这?竟然?就?用汇编?写出?循环?来了?
嗯,是的。不需要任何一个新的指令,全都是前面提及过的基本指令,只是套路不一样了而已。
其实这就是一个套路,稍微总结一下就能发现,一个将while循环变换为汇编的过程如下:
- 将while循环拆解成只有if和goto的形式
- 将if形式的语句拆解成if块中仅有一行goto语句的形式
- 从前往后逐行翻译成汇编语言
其它循环呢?
那while循环能够搞定了,其它类型的呢?do-while循环、for循环呢?
其实,在C语言中,这三种循环之间都是可以相互变换的,也就是说for循环可以变形成为while循环,while循环也可以变成for循环。举个例子:
int i = 1;
int sum = 0;
for(i = 0; i <= 10; i ++) {
sum = sum + i;
}
int sum = 0;
int i = 1;
while( i <= 10 ) {
sum = sum + i;
i = i + 1;
}
上述两个片段的代码,其实就是等价的,仅仅是形式不同。只是有的循环思路用for循环写出来好看一些,有的思路用while循环写出来好看一些,别的没什么本质区别,经过编译器一倒腾之后,就更没有任何区别了。
总结
在汇编中,分支和循环结构,都是通过两类基本的指令实现的:
- 比较
- 跳转
只是,分支结构的程序中,所有的跳转目标都是往后,程序一去不复返。而循环结构中,程序会根据条件往前跳转,跳回去执行已经执行过的代码,在绕圈圈,就成循环了。到汇编层面,本质上,没啥区别。
好了,汇编语言中的流程控制,基本就算完事儿了,实际上,在汇编语言中,抓住根本的东西就行了,剩下的就是靠脑子想象了。
文中若有疏漏之处,欢迎指正。
7 - 汇编语言入门七:函数调用(一)
最近忙了一阵,好几天没更了,不好意思,我来晚了。
转入正题,当在汇编中进行函数调用,是一种什么样的体验?
想象
想象你在计算一个非常复杂的数学题,在算到一半的时候,你需要一个数据,而这个数据需要套用一个比较复杂的公式才能算出来,怎么办?
你不得不把手中的事情停下来,先去套公式、代入数值然后…最后,算出结果来了。
这时候你继续开始攻克这个困难题目的剩下部分。
用脑子想
刚刚说的这个过程,可能有点小问题,尤其是对脑子不太好使的人来说。想象你做题目做到一半的时候,记忆力已经有点不好使了,中间突然停下来去算一个复杂的公式,然后回来,诶?我刚刚算到哪了?我刚刚想到哪了?我刚刚算了些什么结果?
在你工作切换的时候,很容易回头来就忘记了刚刚做的部分事情。这时候,为了保证你套完复杂的公式,把结果拿回来继续算题目的时候不会出差错,你需要把刚才计算题目过程中的关键信息写在纸上。
用CPU想
刚刚去套用一个复杂的公式计算某个数据的情景,就类似在计算机里进行函数调用的情景。
程序需要一个结果,这个结果需要通过一个比较复杂的过程进行计算。这时候,编程人员会考虑将这个独立的复杂过程提取为单独的函数。
而在发生函数调用的时候,CPU就像是先暂停当前所做的事情,转去做那个复杂的计算,算完了之后又跳回来继续整个计算。就像你做题的过程中去套了一个公式计算数据一样。
但是在去套用公式之前,你需要做一些准备。首先,默默记下现在这个题目算到哪一步了,一会套完公式回来接着做;默默记下现在计算出来的一些结果,一会可能还会用到;套用公式需要些什么数据,先记下来,代公式的时候直接代入计算,算出来的结果也需要记在脑子里,回头需要使用。
在CPU里面,也需要这几个过程。
第一个,记下自己现在做事情做到哪里了,一会儿套完公式回来接着做,这也就是CPU在进行函数调用时的现场保存操作,CPU也需要记下自己当前执行到哪里了。
默默记下一些在套用公式的时候需要用到的数据,然后去套公式了。这也就是程序中在调用函数的时候进行参数传递的过程。
然后开始执行函数,等函数执行完了,就需要把结果记下来,回去继续刚才要用到数据的那个地方继续算。这也就是函数调用后返回的动作,这个记下的结果就是返回值。
开撸
说了那么多故事,那么函数调用要干些啥应该就说清楚了。总结一下大概就这么几个事:
- 保存现场(一会好回来接着做)
- 传递参数(可选,套公式的时候需要些什么数据)
- 返回(把计算结果带回来,接着刚才的事)
到这里,我们先来一个事例代码,就着代码去发现函数调用中的套路:
global main
eax_plus_1s:
add eax, 1
ret
ebx_plus_1s:
add ebx, 1
ret
main:
mov eax, 0
mov ebx, 0
call eax_plus_1s
call eax_plus_1s
call ebx_plus_1s
add eax, ebx
ret
首先,运行程序,得到结果:3。
上面的代码其实也比较简单,先从主干main这个地方梳理:
- 让eax和ebx的值都为0
- 调用eax_plus_1s,再调用eax_plus_1s
- 调用ebx_plus_1s
- 执行eax = eax + ebx
上述的两个函数也非常简单,分别就是给eax和ebx加了1。所以,这个程序其实也就是换了个花样给寄存器增加1而已,纯粹演示。
这里出现了一个陌生指令call,这个指令是函数调用专用的指令,从程序的行为上看应该是让程序的执行流程发生跳转。前面说到了跳转指令jmp,这里是call,这两个指令都能让CPU的eip寄存器发生突然变化,然后程序就一下子跳到别的地方去了。但是这两个有区别:
很简单,jmp跳过去了就不知道怎么回来了,而通过call这种方式跳过去后,是可以通过ret指令直接回来的
那这是怎么做到的呢?
其实,在call指令执行的时候,CPU进行跳转之前还要做一个事情,就是把eip保存起来,然后往目标处跳。当遇到ret指令的时候,就把上一次call保存起来的eip恢复回来,我们知道eip直接决定了CPU会执行哪里的代码,当eip恢复的时候,就意味着程序又会到之前的位置了。
一个程序免不了有很多次call,那这些eip的值都是保存到哪里的呢?
有一个地方叫做“栈(stack)”,是程序启动之前,由操作系统指定的一片内存区域,每一次函数调用后的返回地址都存放在栈里面
好了,我们到这里,就明白了函数调用大概是怎么回事了。总结起来就是:
- 本质上也是跳转,但是跳到目标位置之前,需要保存“现在在哪里”的这个信息,也就是eip
- 整个过程由一条指令call完成
- 后面可以用ret指令跳转回来
- call指令保存eip的地方叫做栈,在内存里,ret指令执行的时候是直接取出栈中保存的eip值,并恢复回去达到返回的效果
何为栈?
前面说到call指令会先保存eip的值到栈里面,然后就跳转到目标函数中去了。
这都好说,但是,如果是我在函数里面调用了一个函数,在这个函数里面又调用了一个函数,这个eip是怎么保存来保证每一次都能正确的跳回来呢?
好的,这个问题才是关键,这也说到了栈这样一个东西,我们先来设想一些场景,结合实际代码理解一下CPU所对应的栈。
首先,这个栈和数据结构中的栈是不一样的。数据结构中的栈是通过编程语言来形成程序执行逻辑上的栈。而这里的栈,是CPU内硬件实现的栈。当然了,两者在逻辑上都差不多的。
在这里,先回想一下数据结构中基于数组实现的栈。里面最关键的就是需要一个栈顶指针(或者是一个索引、下标),每次放东西入栈,就将指针后移,每一次从栈中取出东西来,就将指针前移。
到这里,我们先从逻辑上分析下CPU在发生函数调用的过程中是如何使用栈的。
假设现在程序处在一个叫做level1的位置,并调用了函数A,在调用的跳转发生之前,会将当前的eip保存起来,这时候,栈里面就是这样的:
---------- <= top
level1
----------
现在,程序处在level2的位置,又调用了函数B,同样,也会保存这次的eip进去:
---------- <= top
level2
----------
level1
----------
再来,程序这次处在level3,调用了C函数,这时候,整个栈就是这样的:
---------- <= top
level3
----------
level2
----------
level1
----------
好了,这下程序执行到了ret,会发生什么事,是不是就回到level3了?在level3中再次执行ret,是不是就回到level2了?以此类推,最终,程序就能做到一层层的函数调用和返回了。
实际的CPU中
在实际的CPU中,上述的栈顶top也是由一个寄存器来记录的,这个寄存器叫做esp(stack pointer),每次执行call指令的时候。
这里还有一个小细节,在x86的环境下,栈是朝着低地址的方向伸长的。什么意思呢?每一次有东西入栈,那么栈顶指针就会递减一个单位,每一次出栈,栈顶指针就会相应地增加一个单位(和数据结构中一般的做法是相反的)。至于为什么会这样,我也不知道。
eip在入栈的时候,大致就相当于执行了这样一些指令:
sub esp, 4
mov dword ptr[esp], eip
翻译为C语言就是(假如esp是一个void*类型的指针):
esp = (void*)( ((unsigned int)esp) - 4 )
*( (unsigned int*) esp ) = (unsigned int) eip
也就是esp先移动,然后再把eip的值写入到esp指向的内存中。那么,ret执行的时候该干什么,也就非常的清楚了吧。无非就是上述过程的逆过程。
同时,eip寄存器的长度为32位,即4字节,所以每一次入栈出栈的单位大小都是4字节。
动手
没有代码,说个锤子。先来一个简单的程序:
global main
eax_plus_1s:
add eax, 1
ret
main:
mov eax, 0
call eax_plus_1s
ret
这个程序中只有一个函数调用,但不影响我们分析。先编译,得到一个可执行文件,这里先起名为plsone。
然后载入gdb进行调试,进行反汇编:
$ gdb ./plsone
(gdb) disas main
Dump of assembler code for function main:
0x080483f4 <+0>: mov $0x0,%eax
0x080483f9 <+5>: call 0x80483f0 <eax_plus_1s>
0x080483fe <+10>: ret
0x080483ff <+11>: nop
End of assembler dump.
好了,找到反汇编中<+5>所在那一行,对应着的指令是call 0x80483f0,这个指令的地址为:0x080483f9(不同的环境有所不同,根据实际情况来)。按照套路,在这个call指令处打下一个断点,然后运行程序。
(gdb) b *0x080483f9
Breakpoint 1 at 0x80483f9
(gdb) run
Starting program: /home/vagrant/code/asm/07/plsone
Breakpoint 1, 0x080483f9 in main ()
(gdb)
好了,程序执行到断点处,停下来了。再来看反汇编,这次有一个小箭头指向当前的断点了:
(gdb) disas main
Dump of assembler code for function main:
0x080483f4 <+0>: mov $0x0,%eax
=> 0x080483f9 <+5>: call 0x80483f0 <eax_plus_1s>
0x080483fe <+10>: ret
0x080483ff <+11>: nop
End of assembler dump.
接下来,做这样一个事情,看看现在eip的值是多少:
(gdb) info register eip
eip 0x80483f9 0x80483f9 <main+5>
正好指向这个函数调用指令。这里的call指令还没执行,现在的CPU处在上一条指令刚执行完毕的状态。前面说过,CPU中的eip总是指向下一条会执行的指令。在这里,珍惜机会,我们把想看的东西全都看个遍吧:
- esp的值,这个很关键
(gdb) info register esp
esp 0xffffd6ec 0xffffd6ec
- esp所指向的栈顶的东西
(gdb) p/x *(unsigned int*)$esp
$1 = 0xf7e40ad3
该看的都看过了,让程序走吧,让它先执行完了call指令,我们再回头看看什么情况:
(gdb) stepi
0x080483f0 in eax_plus_1s ()
根据提示,程序现在已经执行到函数里面去了。可以直接反汇编看看:
(gdb) disas
Dump of assembler code for function eax_plus_1s:
=> 0x080483f0 <+0>: add $0x1,%eax
0x080483f3 <+3>: ret
End of assembler dump.
现在正等着执行那条加法指令呢。别急,现在函数调用已经发生了,再来看看上面我们看过的一些东西:
- esp的值,这个很关键
(gdb) info register esp
esp 0xffffd6e8 0xffffd6e8
看到了,上次查看esp的时候是0xffffd6ec,进入函数后的esp值是0xffffd6e8。少了个4。
实际上这就是eip被保存到栈里去了,CPU的栈的伸长方向是朝着低地址一侧的,所以每次入栈,esp都会减少一个单位,也就是4。
- esp所指向的栈顶的东西
(gdb) p/x *(unsigned int*)$esp
$2 = 0x80483fe
这次,我们看看栈顶到底是个什么东西,打印出来0x80483fe这么一个玩意儿,这是蛤玩意儿?别急,回头看看main函数的反汇编:
(gdb) disas main
Dump of assembler code for function main:
0x080483f4 <+0>: mov $0x0,%eax
0x080483f9 <+5>: call 0x80483f0 <eax_plus_1s>
0x080483fe <+10>: ret
0x080483ff <+11>: nop
End of assembler dump.
在里面找找0x80483fe呢?刚好在<+10>所在的那一行。这不就是函数调用指令处的后一条指令吗?
对的,也就是说,一会函数返回的时候,就会到<+10>这个地方来。也就是在执行了eax_plus_1s函数里的ret之后。
是不是和前面描述的过程一模一样?
好了,到这里,探究汇编中的函数调用的过程和方法基本就有了,读者可以根据需要自行编写更加奇怪的代码,结合gdb,来探究更多你自己所好奇的东西。
附加一个代码,自己玩耍试试(在自己的环境中玩耍哦):
global main
hahaha:
call hehehe
ret
hehehe:
call hahaha
ret
main:
call hahaha
ret
总结
这回,我们说到这样一些东西:
- 汇编中发生函数调用相关的指令call和ret
- call指令会产生跳转动作,与jmp不同的是,call之后可以通过ret指令跳回来
- call和ret的配合是依靠保存eip的值到栈里,返回时恢复eip实现的
- esp记录着当前栈顶所在的位置,每次call和ret执行都会伴随着入栈和出栈,也就是esp会发生变化
函数调用最基本的”跳转“和”返回“就这么回事了,下回咱们继续分析”函数调用中的参数传递、返回值和状态“相关的问题。
文中若有疏漏或是不当之处,欢迎指正。
8 - 汇编语言入门八:函数调用(二)
回顾
上回说道,x86汇编中专门提供了两个指令call和ret,用于实现函数调用的效果。实际上函数调用就是程序跳转,只是在跳转之前,CPU会保存当前所在的位置(即返回地址),当函数返回时,又可以从调用的位置恢复。返回地址保存在一个叫做“堆栈”的地方,堆栈中可以保存很多个返回地址,同时借助于堆栈的进出逻辑,还能实现函数嵌套、递归等效果。
同时前面还简单地提到了函数调用过程中的参数和返回值的传递过程。实际上,在汇编语言中,函数调用的参数和返回值均可以通过寄存器来传送,只要函数内外相互配合,就可以精确地进行参数和返回值传递。
没那么简单
到这里,看起来好像函数调用的基本要素都有了,但实际上还是有一些问题的。比如说递归调用这样的场景。通过对递归的研究,你也就能明白前面说到的函数调用机制存在什么样致命的问题。
好了,先说下,这部分内容,很关键。
举个例子,通过递归调用来计算斐波那契数列中的某一项,用高级语言编写已经非常容易:
int fibo(int n) {
if(n == 1 || n == 2) {
return 1;
}
return fibo(n - 1) + fibo(n - 2);
}
我们来进行一波改造,改造成接近汇编的形式:
int fibo(int n) {
if(n == 1) {
return 1;
}
if(n == 2) {
return 1;
}
int x = n - 1;
int y = n - 2;
int a = fibo(x);
int b = fibo(y);
int c = a + b;
return c;
}
拆分成这样之后,就能够比较方便地和汇编对应起来了,再改造一下,把变量名全都换成寄存器名,就能够看得更清楚了(先约定eax寄存器作为函数的第一个参数,通过eax也用来传递返回值):
int fibo(int eax) {
int ebx, ecx;
if(eax == 1) {
return eax;
}
if(eax == 2) {
eax = 1;
return eax;
}
int edx = eax;
eax = edx - 1;
eax = fibo(eax);
ebx = eax;
eax = edx - 2;
eax = fibo(eax);
ecx = eax;
eax = ebx + ecx;
return eax;
}
因为eax会被用作参数和返回值,所以进入函数后就需要将eax保存到别的寄存器,一会需要的时候才能够更方便地使用。
看起来,这里的fibo函数已经比较完美了,这个函数在C语言下是能够正常运行的。接下来把它翻译成汇编:
fibo:
cmp eax, 1
je _get_out
cmp eax, 2
je _get_out
mov edx, eax
sub eax, 1
call fibo
mov ebx, eax
mov eax, edx
sub eax, 2
call fibo
mov ecx, eax
mov eax, ebx
add eax, ecx
ret
_get_out:
mov eax, 1
ret
然而,当你使用这个C语言代码翻译出来的汇编的时候,却发现结果怎么都不对了。
那么,问题出在哪里呢?
问题就出在从C语言翻译到汇编的过程中。
警惕作用域
在C函数中,虽然我们把各个变量名换成寄存器名,把复杂的语句拆分成简单语句,最后就能够和汇编语句等同起来,但是,在将C代码翻译到汇编的过程中,出现了不等价的变换。其中,变量的作用域便是引起不等价的原因之一。这个C代码:
int fibo(int eax) {
int ebx, ecx;
if(eax == 1) {
return eax;
}
if(eax == 2) {
eax = 1;
return eax;
}
int edx = eax;
eax = edx - 1;
eax = fibo(eax);
ebx = eax;
eax = edx - 2;
eax = fibo(eax);
ecx = eax;
eax = ebx + ecx;
return eax;
}
本身是没有任何问题的。但是,翻译后的汇编就有问题了,实际上上述汇编语言等价为这样的C代码:
int ebx, ecx, edx;
void fibo() {
if(eax == 1) {
eax = 1;
return;
}
if(eax == 2) {
eax = 1;
return;
}
edx = eax;
eax = edx - 1;
eax = fibo(eax);
ebx = eax;
eax = edx - 2;
eax = fibo(eax);
ecx = eax;
eax = ebx + ecx;
}
原因很简单,CPU中的寄存器是全局可见的。所以使用寄存器,实际上就是在使用一个像全局变量一样的东西。
那么,到这里,通过这个例子,你应该能够发现问题了,现有的做法,无法实现递归或者嵌套的结构。
到底需要什么
实际上,要实现递归,那么就需要函数的状态是局部可见的,只能在当前这一层函数内访问。递归中会出现层层调用自己的情况,每一层之间的状态都应当保证局部性,不能相互影响。
在C语言的环境下,函数内的局部变量,抽象来看,实际上就是函数执行时的局部状态。在汇编环境下,寄存器是全局可见的,不能用于充当局部变量。
那怎么办呢?
堆栈
前面说到,堆栈是用来保存函数调用后的返回地址。其实在这里,函数的返回地址,其实就是当前这一层函数的一个状态,这个状态对应的是这一层函数当前执行到哪儿了。
借鉴call指令保存返回地址的思路,如果,在每一层函数中都将当前比较关键的寄存器保存到堆栈中,然后才去调用下一层函数,并且,下层的函数返回的时候,再将寄存器从堆栈中恢复出来,这样也就能够保证下层的函数不会破坏掉上层函数的状了。
也就是,当下要解决这样一个问题:被调用函数在使用一些寄存器的时候,不能影响到调用者所使用的寄存器值,否则函数之间就很难配合好了,也很容易乱套。
入栈与出栈
实际上,CPU的设计者们已经考虑过这个问题了,所以还专门提供了对应的指令来干这个事。入栈与出栈分别是两个指令:
push eax ; 将eax的值保存到堆栈中去
pop ebx ; 将堆栈顶的值取出并存放到ebx中
有了这两个玩意儿,递归调用这个问题就可以解决了。注意了,这里发生了入栈和出栈的情况,那么,进行栈操作的时候对应的栈顶指针也会发生相应的移动,这里也一样。
搞一个不会影响全世界的函数
先来试一试堆栈的使用,我就不废话了,举个例子,一个通过循环来计算1+2+3+4+5+6+7+…+n的函数(这里还是约定eax为第一个参数,同时eax也是返回值,暂不考虑参数不合法的情况),直接上代码:
sum_one_to_n:
mov ebx, 0
_go_on:
cmp eax, 0
je _get_out:
add ebx, eax
sub eax, 1
jmp _go_on
_get_out:
mov eax, ebx
ret
你可以发现,在这个函数中,不可避免地需要使用到eax之外的寄存器。但是有一个很致命的问题,调用方或者更上层的函数如果使用了ebx寄存器,这里又拿来用,最终,这个sum_one_to_n不小心把上层函数的状态给改了,最后结果和前面的递归例子差不多,总之不是什么好结果。
那么,这里就需要在使用ebx之前,先把ebx保存起来,使用完了之后,再把ebx恢复回来,就不会产生上述问题了。好了,接下来就需要调整代码了,只需要加一行push和pop就能完事儿了。像这样:
sum_one_to_n:
push ebx
mov ebx, 0
_go_on:
cmp eax, 0
je _get_out:
add ebx, eax
sub eax, 1
jmp _go_on
_get_out:
mov eax, ebx
pop ebx
ret
在函数的第一行和倒数第二行分别加入了push ebx和pop ebx指令。
通过push ebx,将当前的ebx寄存器保存起来。
通过pop ebx,堆栈中保存的ebx寄存器恢复回来。
当然了,进行push和pop的时候也得稍加小心,破坏了call指令保存到堆栈中的返回地址,也会坏事的。不过好在,函数内的入栈和出栈操作是保持一致的,不会影响到call指令保存的返回地址,也就不会影响到ret指令的正常工作。
再来递归
那么,我们就已经解决了函数内保存局部状态的问题了,其中的套路之一便是,让函数在使用某个寄存器之前,先把旧的值保存起来,等用完了之后再恢复回去,那么这个函数执行完毕后,所有的寄存器都是干干净净的,不会被函数玷污。
有了push和pop的解决方案,那么前面那个递归的问题也可以解决了。
先来分析下:
fibo:
cmp eax, 1
je _get_out
cmp eax, 2
je _get_out
mov edx, eax
sub eax, 1
call fibo
mov ebx, eax
mov eax, edx
sub eax, 2
call fibo
mov ecx, eax
mov eax, ebx
add eax, ecx
ret
_get_out:
mov eax, 1
ret
这段代码中使用到了除eax之外的寄存器有ebx、ecx、edx三个。为了保证这三个寄存器不会在不同的递归层级串场,我们需要在函数内使用它们之前将其保存起来,等到不用了之后再还原回去(注意入栈和出栈的顺序是需要反过来的),像这样:。
fibo:
global main
fibo:
cmp eax, 1
je _get_out
cmp eax, 2
je _get_out
push ebx
push ecx
push edx
mov edx, eax
sub eax, 1
call fibo
mov ebx, eax
mov eax, edx
sub eax, 2
call fibo
mov ecx, eax
mov eax, ebx
add eax, ecx
pop edx
pop ecx
pop ebx
ret
_get_out:
mov eax, 1
ret
main:
mov eax, 7
call fibo
ret
编译运行一看,第7项的值为13,诶,这下结果可靠了。我们得到了一个汇编语言实现的、通过递归调用来计算斐波那契数列某一项值的函数。
写在后面
前面扯了这么多,我们说到了这样一些东西:
- 函数调用相关指令
- 通过寄存器传递参数和返回值
- 函数调用后的返回地址会保存到堆栈中
- 函数的局部状态也可以保存到堆栈中
C语言中的函数
在C语言中,x86的32位环境的一般情况下,函数的参数并不是通过寄存器来传递的,返回值也得视情况而定。这取决于编译器怎么做。
实际上,一些基本数据类型,以及指针类型的返回值,一般是通过寄存器eax来传递的,也就是和前面写的汇编一个套路。而参数就不是了,C中的参数一般是通过堆栈来传递的,而非寄存器(当然也可以用寄存器,不过需要加一些特殊的说明)。这里准备了一个例子,供大家体会一下C语言中通过堆栈传递参数的感觉:
(在32位环境下编译)
#include <stdio.h>
int sum(int n, int a, ...) {
int s = 0;
int *p = &a;
for(int i = 0; i < n; i ++) {
s += p[i];
}
return s;
}
int main() {
printf("%d\n", sum(5, 1, 2, 3, 4, 5));
return 0;
}
编译运行:
$ gcc -std=c99 -m32 demo.c -o demo
$ ./demo
15
函数的参数是逐个放到堆栈中的,通过第一个参数的地址,可以挨着往后找到后面所有的参数。你还可以尝试把参数附近的内存都瞧一遍,还能找到混杂在堆栈中的返回地址。
若读者想要对C的函数机制一探究竟,可以尝试编写一些简单的程序,进行反汇编,研究整个程序在汇编这个层面,到底在做些什么。
好了,汇编语言的函数相关部分就可以告一段落了。这部分涉及到一个非常重要的东西:堆栈。这个需要读者下来多了解一些相关的资料,尝试反汇编一些有函数调用的C程序,结合相关的资料不断动手搞事情,去实实在在地体会一下堆栈。
文中若有疏漏或不当之处,欢迎指正。
9 - 汇编语言入门九:总结与后续(闲扯)
回顾
前面扯了一些个汇编语言的内容,想必读者也应该有了大致的了解。笔者比打算写全面的汇编相关的内容,毕竟目前已经有不少相关的资料了。本入门系列的目的就在于:入门。
完成了入门的任务,入门系列就暂告一段落了。在此,先来对前面提及的内容做一些回顾。前面说到的各项内容大概涉及:
- 环境配置
- 寄存器
- 内存访问
- 流程控制
- 函数调用
- 反汇编
- 调试
我想,学习汇编中比较容易犯难的几个环节,大致也都覆盖到了。并且教程中也提供了可运行的实例,供读者在学习之后用于验证。
学汇编到底学什么
我想,很多新手在了解汇编语言的时候,难免会遇到各种蛋疼的问题。大致有这样一些情况:
- 有参考书,虽然书中的知识体系全面,但是内容多却晦涩难懂
- 内容老旧,一时找不到合适的试验环境来验证,仅仅停留于书本,缺乏强烈直观的感受
- 各种规范各种环境乱七八糟不统一,讨论汇编时太依赖于特定环境,进一步加大了动手验证的难度
这无疑是给学习者泼来一盆冷水,本来只有3分的热情被灭掉了余下0.3分。
其实学习汇编语言,和学习C语言就有所不同了。你不用想着以后用汇编进行编程,学习汇编语言的首要目标是理解CPU运行程序的时候到底在干什么,你编写的C程序或者其他什么代码在CPU的眼里到底是个什么玩意儿,你能够通过汇编去分析程序的行为,解释一些高级语言下无法解释的现象,等等,才是学习的目标。
也就是说,学习汇编语言,应该抱着理解的目标去学习,理解透彻便足矣,无需做到能够流利地用汇编进行编程。
即便如此,笔者前面所述的各方面入门内容也仅仅是入门。不同人有不同的学习历程、只是背景,笔者很难保证自己觉得足够的入门教程,能够让每个读者都刚好受用。鉴于此,笔者将列出在了解了入门内容后需要关心的内容。
后续
前面也有提到,学习汇编所需要侧重的是理解,而非熟练地编代码。汇编语言更像是一套理论知识,用于分析和解释程序在足够底层时的行为和现象。
与其说学习汇编,不如说是揭开高级语言的面纱,深入到更底层的地方去了解计算机原理,靠近计算机程序的本质。深入理解底层原理,有助于建立对计算机更系统的更深入的认识,面对一些看似诡异的问题时能心中有数,有方法有思路有理据去分析和解决。
要做到这一点,是需要循序渐进慢慢学习的。读者可在后续关注这样一些内容:
继续学习汇编语言
笔者前面所述的入门内容只能保证覆盖了核心内容,并未覆盖到汇编语言相关的方方面面。读者至少要让自己学习汇编知识覆盖面达到足以形成图灵完备的最小集合。
可以从分析高级语言(比如C)去学习汇编语言,如前面所述的反汇编。关注这样一些内容:
- 指令
- 寄存器
- 内存访问
- 条件跳转
- 堆栈
- 程序状态
这些概念不仅在x86体系下,在ARM或是MIPS体系下也适用。仅仅是在不同环境下有不同的思路,有不同的表现形式,但是,核心的概念都是一致的。
计算机组成原理
到这里,便是让你对所有计算机系统的认识有一个大统一。上面说到不同CPU平台下都有相当的共性。组成原理便是对所有计算机系统的大统一,不同平台只是在根据自己的目标特点和偏好在对计算机原理进行应用而已。
组成原理会告诉你,计算机在电路这个层面的本质是什么。
有了上述的汇编、组成原理的认识,同时也还应该去了解计算机操作系统。这里所谓的操作系统是指站在专业的角度,讨论操作系统本质上是在做什么事情,解决什么问题。
这些基础知识将会作为今后进一步学习计算机的坚实基础,基于对计算机、对操作系统的理解,计算机中的一切都将不再神秘,其本质都不过如此,基于自己所学去分析、去理解即可。
学习方法
笔者在学习的过程中,也尝试过总结适合自己的方法,在此也谈一谈自己学习的方式。
虽然微积分揭示了自然现象和数学之间很多本质的东西,但是让一个小学生直接学习的话,难免会困难重重。这里的问题并不在于知识体系不够全面、不够严谨,而在于学习者对微积分没有基本的感性认识,根本不知道是个啥玩意儿(计算期末考试分数的新方法?),即使记住了这些公式和证明,但自身难以去自行演绎,这其实是无效的学习。
所以,借助此例,结合自身经历,笔者认为学习的时候应该先关注下面一些方面:
寻求感性认识
学习一个陌生的东西,我都会尝试先寻求一个感性的认识,感性认识达到什么程度呢?就是做到自己能够用一些简洁的白话,把自己接下来要学习的东西到底是什么玩意儿,给解释清楚,最好能够做到让不明白的人也能听明白。
比如什么是计算机?你能想到的就是你面对的那一台电脑,里面有丰富的的软件,可以做很多有趣的事情。
了解边界
笔者所谓的边界,即接下来要学习的这个东西,能做什么,不能做什么。学习之前明白这一点也非常重要,基于此,便能有一个清晰的目标,对学习后会面对的问题也有一定的底。
比如计算机是无法直接驱动一个火箭上天的,但是能够对航天器的行为进行控制。
让学习可验证
学习过程中自己会思考,会想出很多问题,但是这些问题并非都能从书中找到答案。而在学习过程中要刻意的去养成对知识、对自己的新想法进行验证的习惯。这里面大致是这样的过程:
- 脑子里冒出来一个想法,可能是疑惑,可能是矛盾
- 猜想一种最可能的情况来解释
- 通过实践去验证自己的猜想
- 总结、回顾(验证后的结果不能解释自己的猜想,会去重新猜)
这里的动手验证不是说真的得去拿个锤子砸钉子,而是说这里的分析、思考、试验、演绎等过程需要落到实处,而不是看到了某个模糊的说法,就这样糊弄过去了。
学习时尽量去给自己建立一个足以验证自己所学的环境,或者至少保证自己有了问题知道怎么样去验证。这将是支撑持续而有效学习的可能性和动力的重要基础。
比如通过编程、调试这样的手段去面对语言学习时的各种不解、各种矛盾的问题。通过编程看到结果、进行试验,透过调试去分析、简化学习中的稳题。有搞不明白的代码了,就赶紧编个程序来验证,尽量根据自己的想法把程序写出花儿来,看看到底都发生了些什么。
Over
关于汇编的专门介绍到此就告一段落了,后续笔者将会出更多相关话题的内容,不过内容的深度和难度都会有所上升,也可能更加抽象。
文中若有疏漏或不当之处,欢迎指正。