本章为补充章节,了解即可,可跳过
汇编不可怕。
机器代码一般人读不懂。例如,某种计算机一条表示加法的指令,为 10001010,减法指令为 00010011。看都看不懂,更别说要实现可编程的目的了。汇编就是对机器代码的封装。让人能够勉强读懂。
与此同时,汇编语言在一定程度上,还表达了与寄存器的交互过程。寄存器的作用大概如下,对比这个表,我们来简单分析一个汇编中的加法。
寄存器 | 概述 |
---|---|
eax | 累加器,可用于加减乘除等操作,使用频率高 |
ebx | 基地址寄存器,可以作为存储器指针来使用 |
ecx | 计数寄存器,在循环和字符串操作时,用它来控制循环次数 |
edx | 数据寄存器,在进行乘除运算时,可作为默认操作数参与运算,可存放I/O的端口地址 |
esi/edi | 变址寄存器,用于存放存储单元的地址偏移量,可实现多种寻址方式 |
ebp | 基指针寄存器,用于存取栈中的数据 |
esp | 栈指针寄存器,用于实时记录栈内存的栈顶位置 |
ees/efs/egs | 附加段寄存器,其值为附加数据段的段值 |
ecs | 代码段寄存器,其值为代码段的段值 |
ess | 栈段寄存器,其值为栈段的段值 |
eds | 数据段寄存器,其值为数据段的段值 |
eip | 指令指针寄存器,用于存放下次将要执行的指令在代码段中的偏移量 |
EFlags | 标志寄存器,有多种标志,例如进位标志,溢出标志等 |
1// 将数字5,传送到寄存器 eax 中2mov eax, 5;3// eax 寄存器加6,此时 eax 得到新的结果4add eax, 6;
通过这个简单的案例,我们发现,汇编其实就是使用约定好的一些指令,对寄存器进行各种操作。
mov 指令,表示数据传送。或者也可以理解为赋值
1mov eax, 1; // eax = 12mov ebx, 2; // ebx = 23mov ecx, ebx; // ecx = ebx
add 指令,表示累加
1mov eax, 1; // eax = 12add eax, 3; // eax = eax + 33add ebx, eax; // ebx = ebx + eax
sub 指令,表示减法
1mov eax, 10; // eax = 102sub eax, 2; // eax = eax - 23sub ebx, eax; // ebx = ebx - eax
感受了一下,和 JavaScript 差不太多。我们可以把寄存器理解为一个一个的变量,然后指令是对这些变量的一些操作。当然,只能这样类比,实际上寄存器是真实存在的容器,而变量只是地址。
我们来写一个简单的函数
1global main23main:4mov eax, 1;5mov ebx, 2;6add eax, ebx;7mov ecx, eax;89ret; // 返回指令,作用类似于 return
最终寄存器 ecx 结果是多少呢?
我们把这段代码翻译成 JavaScript
1var eax = 12var ebx = 23eax = eax + ebx4var ecx = eax5return;
所以运行这段代码之后,ecx 最终的结果就是 3。
寄存器的容量非常小,因此如果数值大一点就搞不定了。因此就需要把数值存到内存中去。也是利用 mov 指令进行数据传输。
1// 将 eax 中的值,2// 传送到地址为 0x1299 的内存空间中去3mov [0x1299], eax;45// 当然也可以从内存地址中读取数据6// 此时表示把 0x1299 中的值后 4 个字节7// 取出来放到 eax 中去8mov eax, [0x1299];
但是我们知道,并不能直接操作内存中的值,因此这种写法理论上是正确的,但是并不被支持。于是我们可以通过定义变量的形式来达到目的
10mov eax, 1; // eax = 120mov ebx, 2; // ebx = 230add ebx, eax; // ebx = ebx + eax4050mov [temp], ebx; // temp = ebx60mov eax, [temp]; // eax = temp7080section .data90// 此处的 dw「double word」 表示4个字节10temp dw 0
学习函数之前,我们要多了解几个相关的指令。
push 指令。将值压入栈内存中。这里我们需要明确的一个点是,栈内存只是内存中的一段内存空间,因此 CPU 需要通过一种方式来确认这一段内存空间,就是栈内存。有两个寄存器在帮助 CPU 做这个事情
段寄存器 ess,存放栈顶的段地址,可以理解为栈空间的起点位置
栈寄存器 esp,存放栈顶的偏移地址,当前的栈顶元素所在的位置
有了起点,并约定栈空间的长度,那么 CPU 就可以知道这一段内存空间是栈空间。
关于段地址和偏移地址:因为寄存器中,无法存储太多的数据,例如最多只能存储4位,但是物理地址又不止4位数,于是寄存器就无法完整的表达物理地址。此时就将一个 5 位的物理地址,拆分为两个 4 位的地址,一个叫做段地址,一个叫做偏移地址
例如物理地址为 21F60,那么如果我们的段地址表示为 2000,那么偏移地址就是 1F60。
21F60 -> 2000 + 1F60 21F60 -> 2100 + 0F60 21F60 -> 21F0 + 0060 21F60 -> 21F6 + 0000
因此,栈顶元素的真实物理位置,是由 ess + esp 共同确定的。
在 push 指令的执行过程中,ess 寄存器的值始终保持不变,但是 esp 会随着栈顶元素的变化而发生改变。
call 指令
call 指令用于调用函数。call 指令会引起以下几个寄存器的值发生变化
eip 寄存器指向下一个指令的偏移量,用于告诉 CPU 应该执行新函数里的逻辑了。因此当 call 调用别的函数时,eip 寄存器会指向新的函数内的指令地址偏移量
ebp 寄存器用于记录当前函数的调用进度情况,当进入到新的函数时,需要 ebp 寄存器记录当前函数执行到哪里了,因此,在函数跳转之前,此时 ebp 寄存器的值,等于当前栈顶元素的位置 mov ebp, esp
pop 指令
pop 为出栈指令。pop 指令会引起以下几个寄存器的值发生变化,pop ebx 通常表示为让函数出栈
esp pop 指令执行之前,esp 指令仍然指向当前的栈顶,我们现在要做的事情是回到上一个函数的执行,然后继续执行上一个函数,因此 pop 指令会修改 esp 指令的偏移量,让其回到上一个函数的执行位置,而我们在调用 call 指令时,已经将上一个函数的执行位置记录在了 ebp 寄存器中,因此此时就是将 ebp 中的偏移量信息传递给 esp。esp 寄存器改变的同时,系统会自动释放刚才那个函数所占用的栈内存,这就是出栈操作
eip 寄存器会重新调整,回到上一个函数的位置,告诉 CPU 继续执行
ret 指令
终止当前函数的执行,相当于调用了 pop 指令。
理解了这几个指令,基本上就理解了,栈内存空间的工作原理。
我们可以定义多个函数
10global main2030// 定义一个简单的函数,让eax的值加140eaxAdd:50add eax, 1;60ret;7080// 定义一个简单的函数,让ebx的值加190ebxAdd:10add ebx, 1;11ret;1213main:14mov eax, 0;15mov ebx, 0;16call eaxAdd; // call指令表示调用函数17call eaxAdd;18call ebxAdd;19add eax, ebx;20ret;
下一篇文章,我们用图例来进一步给大家讲解栈内存空间的工作原理。
参考资料:汇编入门系列