当处理器执行你的程序时,它不太可能会从第一条指令顺序执行到最后一条指令,中间肯定会有些跳转分支或者循环之类的,这样才能实现程序的逻辑。汇编语言为程序员提供了些指令可以完成这类跳转和循环操作...

    本文由zengl.com站长对
    http://pan.baidu.com/share/link?shareid=3717576860&uk=940392313 汇编教程英文版相应章节进行翻译得来。
    另外再附加一个英特尔英文手册的共享链接地址:
    http://pan.baidu.com/share/link?shareid=2345340326&uk=940392313 (在某些例子中会用到)

    本篇翻译对应汇编教程英文原著的第158页到第167页 (注意这里的页数不是页脚的页数,而是pdf电子档顶部,分页输入框中的页数,也就是包含了目录,前言部分的总页数,pdf电子档总页数是577,当前已翻译完的页数为167 / 577)。

    当处理器执行你的程序时,它不太可能会从第一条指令顺序执行到最后一条指令,中间肯定会有些跳转分支或者循环之类的,这样才能实现程序的逻辑。汇编语言为程序员提供了些指令可以完成这类跳转和循环操作。

    在讲解跳转分支之类的指令之前,首先要了解处理器是根据什么来判断下一条要执行的指令,下面就先讲解这个问题。

The Instruction Pointer (指令指针寄存器):

    指令指针寄存器即处理器中的EIP寄存器,就是用来指示处理器下一条该执行什么指令的,如下图所示:


图1
    
    上面EIP指向的0x08048079位置处的movl $10,%edi指令就是处理器下一次将要执行的指令。

    这里有个问题需要澄清,前面的硬件篇中,我们提到过现代的处理器有个out-of-order无序执行引擎,该引擎会超前缓存并执行很多指令,很多人可能会困惑既然已经超前并且无序的执行了很多指令,那么是否和EIP相冲突呢?

    其实并不冲突,就好像浏阳蒸菜馆,他们一开始并不知道客人会点什么菜,不过会按平时的统计情况,将最常点的菜事先做好,这样客人来的时候就直接上菜就可以了,除了个别的没事先做的小菜可能要临时做一下,总体上效率要比一般的所有菜都临时去炒的饭馆要高很多,我们上面讨论的无序执行引擎就好比蒸菜馆,事先将可能会执行的指令在内部执行了,并将结果缓存起来,等EIP这位顾客需要执行哪条指令时,就将结果直接获取出来即可,只有少部分没事先缓存执行的指令需要临时执行一次(就像蒸菜馆临时炒的小菜一样),整体来说效率比原始的有序执行引擎要高了很多,所以无序执行引擎并不会和EIP相冲突,相反,它只是加快了下一条要执行的指令而已。

    另外需要注意的是,EIP在递增指向下一条指令时,并不是将EIP简单的加1就完事了,而是要根据当前执行指令的字节数来增加(不同指令代码所占的内存字节数可以不一样,如上面图1中有的指令占5个字节,有的只占2个字节等),这样才能准确的指向下一条要执行的指令,如果遇到跳转分支之类的指令,那么EIP就会指向一个完全不同的地址。

    你的程序中不可以直接修改EIP指令指针寄存器,即你不可以通过MOV指令将某个内存地址直接加载覆盖到EIP中,但是你可以使用branches即分支指令来修改EIP的值。

    有两种分支指令:无条件分支指令和条件分支指令,下面就详细的讲解下这两种分支指令。

Unconditional Branches (无条件分支指令):

    只要遇到无条件分支指令,EIP指令指针寄存器就会无条件强制指向目标代码位置,有如下三种无条件分支指令:
  • Jumps 无条件跳转指令
  • Calls 函数调用指令
  • Interrupts 中断指令
    下面具体的描述这几种指令。

Jumps (无条件跳转指令):

    无条件跳转指令是汇编语言中最基本的分支指令类型,就像BASIC语言中的GOTO语句。

    在像BASIC这种结构化编程中,GOTO语句是一种不被提倡的语句,因为过多的使用GOTO会导致代码维护变得困难,然而在汇编程序中,无条件跳转指令却是实现各种逻辑功能的重要指令,无条件跳转指令的格式如下:

jmp location

    location是目标代码所在的内存地址,通过jmp指令就可以让EIP指向location,然后程序就会执行location里的指令代码了,在汇编代码中,通常都是用标签名来表示需要跳转的位置,下图演示了jmp指令的原理:


图2

    上图中通过jmp end指令让EIP跳转到end标签所在的内存位置处继续执行代码(这里end标签引用的内存地址为0x0804808B)。

    jmp指令根据对应的二进制操作码又可以分为三种类型:
  • Short jump 短跳转
  • Near jump 邻近跳转
  • Far jump 长跳转
    这三种跳转类型是根据跳转的目标位置与当前指令位置的距离来确定的:
  • 当要跳转的目标指令位置距离当前指令位置的偏移量小于128字节时,就属于短跳转。
  • 当要跳转的目标位置在另一个段中时,就属于长跳转。
  • 所有介于两者之间的其他跳转都属于邻近跳转。
    当然,你在写汇编代码时,并不需要太在意这些,因为在汇编代码助记符中只有一个jmp指令,没有什么短跳转,长跳转之类的助记符,所以你只用将需要跳转的目标位置写到jmp后面即可,汇编器会自动帮你确定到底是哪种跳转,然后生成对应类型的二进制操作码。

    下面是jmp指令的完整例子:

# jumptest.s – An example of the jmp instruction
.section .text
.globl _start
_start:
    nop
    movl $1, %eax
    jmp overhere
    movl $10, %ebx
    int $0x80
overhere:
    movl $20, %ebx
    int $0x80

    上面代码在movl $1,%eax执行完后,就直接跳到了overhere标签处,然后将20作为退出码设置到EBX中,中间的movl $10,%ebx和int $0x80部分被跳过去了,你可以在shell命令行中通过检查程序的退出码,就可以知道是否发生了跳转:

$ as -o jumptest.o jumptest.s
$ ld -o jumptest jumptest.o
$ ./jumptest
$ echo $?

20
$

    echo $?输出了jumptest程序的退出码为20,证明跳转确实发生了,还可以通过objdump和gdb进一步了解跳转的过程,下面是objdump输出的反汇编情况:

$ objdump -D jumptest

jumptest:	file format elf32-i386

Disassembly of section .text:

08048074 <_start>:
8048074:	90			nop
8048075:	b8 01 00 00 00		mov $0x1,%eax
804807a:	eb 07			jmp 8048083 <overhere>
804807c:	bb 0a 00 00 00		mov $0xa,%ebx
8048081:	cd 80			int $0x80

08048083 <overhere>:
8048083:	bb 14 00 00 00		mov $0x14,%ebx
8048088:	cd 80			int $0x80

$

    从反汇编输出中可以看到跳转标签的内存地址,然后我们在gdb调试器中运行jumptest:

$ as -gstabs -o jumptest.o jumptest.s
$ ld -o jumptest jumptest.o
$ gdb -q jumptest
(gdb) break *_start+1

Breakpoint 1 at 0x8048075: file jumptest.s, line 5.
(gdb) run
Starting program: /home/rich/palp/chap06/jumptest

Breakpoint 1, _start () at jumptest.s:5
5    movl $1, %eax
Current language: auto; currently asm

(gdb) print/x $eip
$1 = 0x8048075
(gdb)

    我们给第一条指令nop下断点,程序在nop处停下来后,print/x $eip输出的EIP寄存器值0x8048075就是下一条指令movl $1,%eax的内存地址,该指令的内存地址可以在前面的objdump输出中查看到,继续调试程序,看下跳转指令执行后,EIP寄存器值的变化情况:

(gdb) step
_start () at jumptest.s:6
6     jmp overhere

(gdb) step
overhere () at jumptest.s:10
10     movl $20, %ebx

(gdb) print $eip
$2 = (void *) 0x8048083
(gdb)

    通过两个step单步调试命令执行完jmp跳转指令后,输出的EIP值0x8048083对应的就是overhere标签所在的内存地址,说明jmp指令确实将EIP指向了目标代码位置,从而让程序的执行流程发生了跳转。

Calls (函数调用指令):

    call和jmp指令类似,不过call跳转到的目标位置是汇编程序中定义的函数的起始位置,并且在函数执行完后,程序还可以回到call所在的位置,继续执行call后面的指令。

    所谓函数,就是指一段写好的代码,在程序的其他地方如果需要使用该代码里的功能时,就直接调用该段代码对应的函数名即可,不需要重新写一遍代码,所以函数增强了代码的重利用率,而call指令就是调用函数的指令,call指令的格式如下:

call address

    call只有一个操作数:address即要跳转的目标代码位置,它通常是用标签名来代替,该标签名引用的是一个函数第一条指令的内存地址,这个标签名也可以被称作函数名。

    call指令进入的函数在执行完代码后,如果想返回到call的下一条指令继续执行,就必须用到ret指令,ret就是函数返回指令,它会根据call在栈中保留的EIP信息,让程序回到原来call的下一条指令位置。

    call和ret指令的原理图如下:


图3

    CALL指令执行时,会先将EIP寄存器的值存放到栈中,然后修改EIP寄存器的值,让其指向调用函数的目标地址,当被调用函数执行完后,RET指令就会从栈中将原来的EIP值读取出来,然后让程序跳转回原来的CALL后面继续执行。

    函数返回到主程序,以及主程序给函数传递参数等,都是通过栈来实现的,前面提到过可以使用PUSH和POP来操作栈,下面是汇编代码中函数编写的一个模板样式:

function_label:
    pushl %ebp
    movl %esp, %ebp
    < normal function code goes here>
    movl %ebp, %esp
    popl %ebp
    ret

    function_label为函数的标签名,简称函数名,代码中先将原始的EBP值压入栈,接着将原始的ESP值加载到EBP中,这样在程序结束时就可以使用EBP来恢复ESP的值,只有恢复了原始的ESP栈顶指针寄存器,才能保证后面的POP指令及RET指令的正确执行,在normal function code goes here部分可以填写具体的函数代码,在代码中可以用EBP作为基址来访问函数的参数及函数其他的栈数据,还可以使用PUSH往栈中压入更多的数据等,程序结束时,通过EBP来恢复ESP,再由POP指令恢复原来的EBP,最后RET就可以返回到主程序了。

    注意:本章只对函数做个简单的介绍,有关函数如何利用栈来存储数据的详情将在后面的章节中给出。

    下面是call指令的完整例子:

# calltest.s - An example of using the CALL instruction
.section .data
output:
    .asciz "This is section %d\n"
.section .text
.globl _start
_start:
    pushl $1
    pushl $output
    call printf
    add $8, %esp     # should clear up stack
    call overhere
    pushl $3
    pushl $output
    call printf
    add $8, %esp     # should clear up stack
    pushl $0
    call exit
overhere:
    pushl %ebp
    movl %esp, %ebp
    pushl $2
    pushl $output
    call printf
    add $8, %esp     # should clear up stack
    movl %ebp, %esp
    popl %ebp
    ret

    代码中定义了一个overhere的函数,该函数通过printf这个C标准库函数来打印一行文本信息,_start主体程序中先通过printf打印一条语句,接着call调用overhere打印出另一条语句,overhere函数通过ret返回主程序后,主程序最后再打印一条语句,然后退出程序。

    定义函数时,必须注意在开头保存原始的EBP和ESP值,并且在函数结束之前必须恢复原始的ESP和EBP值。

    程序在命令行中的输出情况如下:

$ ./calltest
This is section 1
This is section 2
This is section 3

$

Interrupts (中断):

    第三种无条件分支指令就是中断,中断是处理器用于暂停当前执行代码,并切换到另一个执行体去执行代码的一种方式,有两种中断类型:
  • Software interrupts 软件中断
  • Hardware interrupts 硬件中断
    硬件中断是由硬件产生,当发生某个硬件事件时,相应的硬件设备就会向处理器发送一个中断请求,告诉处理器外围的某个设备发生了某个事件,处理器根据请求的优先级等,判断是否需要处理该事件,如果需要处理,就暂停当前正在执行的代码,并将控制权交给对应的中断处理例程,在将中断事件处理完后,处理器再将控制权交回原来的执行代码,让原来暂停的代码恢复继续执行,所以硬件中断是异步的,被动的触发方式。

    软件中断则是同步的,由程序代码通过相应指令主动调用的。系统通过软件中断的方式让程序可以切换到系统内核中,然后调用内核里的相关代码。例如微软的DOS系统下,很多功能都是通过0x21软件中断来提供的,Linux系统中,则是通过0x80软件中断来向程序提供底层的内核功能,例如前面的例子中就大量用到了int $0x80指令,该指令就是一个软件中断,程序执行到该指令时,就会切换到linux内核中,在内核代码里再通过EAX里的值判断需要执行哪个子功能,如当EAX为1时则退出程序等。

    小提示:后面章节会详细的介绍linux的0x80软件中断所提供的所有功能。

    这里要注意的是,当要调试的代码中包含软件中断指令时,使用单步调试指令是没办法进入中断,查看中断里的内核代码的,因为这些中断内核代码的调试信息并没编译进程序中。

    限于篇幅,本篇就到这里,下一篇介绍条件分支指令。

    OK,到这里,休息,休息一下 o(∩_∩)o~~
上下篇

下一篇: 汇编流程控制 (二) 条件分支及汇编循环

上一篇: Moving Data 汇编数据移动 (四) 结束篇 栈操作

相关文章

使用内联汇编 (二)

汇编开发相关工具 (一)

调用汇编模块里的函数 (二)

优化汇编指令 (一)

汇编数据处理 (三) 浮点数

汇编开发示例 (一)