学习这么长时间,一直在C语言这一层面上钻研和打拼,日积月累,很多关于C的疑惑在书本和资料中都难以找到答案。程序员是追求完美的一个种群,其头 脑中哪怕是存在一点点的思维黑洞都会让其坐卧不宁。不久前在itput论坛上偶得《Computer Systems A Programmer's Perspective》(以下称CS.APP)这本经典好书,中文有翻译的《深入理解计算机系统》。是遂连夜拜读以求解惑。虽说书中没有能正面的回答我的一些疑惑,但是它却为我指明了一条通向 “无惑”之路 -- 这就是打开汇编之门。
汇编语言是一门非常接近机器语言的语言,其语句与机器指令之间的对应关系更加简单和清晰。打开汇 编之门不仅仅能解除高级语言给你带来的疑惑,它更能让你更加的理解现代计算机的运行体系,还有一点更加重要的是它给你带来的是一种自信的感觉,减少了你在 高处摇摇欲坠的恐惧,响应了侯捷老师的“勿在浮沙筑高台”的号召。现在学习汇编的目的已与以前大大不同了。正如CS.APP中所说那样“程序员学习汇编的 需求随着时间的推移也发生了变化,开始时是要求程序员能直接用汇编编写程序,现在则是要求能够阅读和理解优化编译器产生的代码”。能阅读和理解,这也恰恰 是我的需求和目标。
以前接触过汇编,主要是Microsoft MASM宏汇编,不过那时的认识高度不够加上态度不端正,错失了一个很好的学习机会。现在绝大部分时间是使用GCC在Unix系列平台上工作,选择汇编语 言当然是GNU汇编了,恰好CS.APP中使用的也是GNU的汇编语法。由于学习汇编的主要目的还是“解惑”,所以形式上多是以C代码和汇编代码的比较。
1、汇编让你看到更多
随 着你使用的语言的层次的提高,你眼中的计算机将会越来越模糊,你的关注点也越来越远离语言本身而靠近另一端“问题域”,比如通过JAVA,你更多看到的是 其虚拟机,而看不到真实的计算机;通过C,你看到的也仅仅是内存一层;到了汇编语言,你就可以深入到寄存器一层自由发挥了。汇编程序员眼里的“独特风景” 包括:
a) “程序计数器(%eip)” -- 一个特殊寄存器,其中永远存储下一条将要执行的指令的地址;
b) 整数寄存器 -- 共8个,分别是%eax、%ebx、%ecx、%edx、%esi、%ebi、%esp和%ebp,它们可以存整数数据,可以存地址,也可以记录程序状态 等。早期每个寄存器都有其特殊的用途,现在由于像linux这样的平台多采用“平面寻址[1]”,寄存器的特殊性已经不那么明显了。
c) 条件标志寄存器 -- 保存最近执行的算术指令的状态信息,用来实现控制流中的条件变化。
d) 浮点数寄存器 -- 顾名思义,用来存放浮点数。
虽说寄存器的特殊性程度已经弱化,但是实际上每个编译器在使用这些寄存器时还是遵循一定的规则的,以后再说。
2、初窥汇编
下面是一个简单的C函数:
void dummy() {
int a = 1234;
int b = a;
}
我们使用gcc加-S选项将之转换成汇编代码如下(省略部分内容):
movl $1234, -4(%ebp)
movl -4(%ebp), %eax
movl %eax, -8(%ebp)
看 了一眼又一眼,还是看不懂,只是发现些熟悉的内容,因为上面提过如%ebp、%eax等。这只是个引子,让我们感性的认识一下汇编的“容貌”。我们一点点 地来看。咋看一眼汇编代码长得似乎很相似,没错,汇编代码就是一条一条的“指令+操作数”的语句的集合。汇编指令是固定的,每条指令都有其固定的用途,而 操作数表示则有多种类型。
1) 操作数表示
大部分汇编指令都有一个或多个操作数,包括指令操作中的源和目的。一条标准的指令格式大 致是这样的:“指令 + 源操作数 + 目的操作数”,其中源操作数可以是立即数、从寄存器中读出的数或从内存中读出的数;而目的操作数则可以是寄存器或内存。按这么一分类,操作数就大致有三 种:
a) 立即数表示法 -- 如“movl $1234, -4(%ebp)”中的“$1234”,就是一个立即数作为操作数,按照GNU汇编语法,立即数表示为“$+整数”。立即数常用来表示代码中的一些常数, 如上例中的“$1234”。注意一点的是立即数不能作为目的操作数。
b) 寄存器表示法 -- 这种比较简单,它就是表示寄存器之内容。如上面的“movl -4(%ebp), %eax”中的%eax就是使用寄存器表示法作源操作数,而“movl %eax, -8(%ebp)”中的%eax则是使用寄存器表示法作目的操作数。
c) 内存引用表示法 -- 计算出的该操作数的值表示的是相应的内存地址。汇编指令根据这个内存地址访问相应的内存位置。如上例“movl -4(%ebp), %eax”中的“-4(%ebp)”,其表示的内存地址为(%ebp寄存器中的内容-4)得到的值。
2) 数据传送指令
汇编语言中最最常用的指令 -- 数据传送指令,也是我们接触的第一种类别的汇编指令。其指令的格式为:“mov 源操作数, 目的操作数”。
mov 系列支持从最小一个字节到最大双字的访问与传送。其中movb用来传送一字节信息,movw用来传送二字节,即一个字的信息,movl用来传送双字信息。 这些不详说了。除此以外mov系列还提供两个带位扩展的指令movsbl和movzbl
==============================================================
汇编语言作为一种高效的,而且紧密结合硬件平台的编程语言,在操作系统,嵌入式开发等领域都有着十分重要的作用。正因为汇编依赖于硬件结构(CPU指令码),因此不同体系结构上的汇编语言也大相径庭。本文简单介绍了Linux下的AT&T语法(即GNU as 汇编语法),以及在Linux下汇编的基本方法。
AT&T语法起源于AT&T贝尔实验室,是在当时用于实现Unix系统的处理器操作码语法之上而形成的,AT&T语法和Intel语法主要区别如下:
AT&T使用$表示立即数,Intel不用,因此表示十进制2时,AT&T为$2,而Intel就是2
AT&T在寄存器前加%,比如eax寄存器表示为%eax
AT&T 处理操作数的顺序和Intel相反,比如,movl %eax, %ebx是将eax中的值传递给ebx,而Intel是这样的mov ebx, eax
AT&T在助记符的后面加上一个单独字符表示操作中数据的长度,比如movl $foo, %eax等同于Intel的mov eax, word ptr foo
长跳转和调用的格式不同,AT&T为ljmp $section, $offset,而Intel为jmp section:offset
主要的区别就是这些,其他的细节还有很多,下面给出一个具体的例子来说明
#cpuid.s Sample program
.section .data
output:
.ascii "The processor Vendor ID is 'xxxxxxxxxxxx'\n"
.section .text
.globl _start
_start:
movl $0, %eax
cpuid
movl $output, %edi
movl %ebx, 28(%edi)
movl %edx, 32(%edi)
movl %ecx, 36(%edi)
movl $4, %eax
movl $1, %ebx
movl $output, %ecx
movl $42, %edx
int $0x80
movl $1, %eax
movl $0, %ebx
int $0x80
这个程序的作用是查询CPU的厂商ID,其中:
,ascii定义字符串(和Intel格式完全不同).section是声明段的语句,.data和.text是段名,分别为数据段和代码段, _start是gas(GNU汇编器)的默认入口标签,表示程序从这里开始执行。.globl将_start声明成了外部程序访问的标签。cpuid为指令请求CPU的指定信息,该指令用eax作为输入,ebx,edx,ecx作为输出,这里将0作为cpuid的输入指令,请求返回CPU的厂商ID字符串。返回的结果,一个12字节的字符串,分别存储在三个寄存器中,其中ebx存放低4位,edx中间4位,ecx高4位(注意顺序!)。接下来定义一个指针edi,edi指向output的开始地址,然后接着的3条语句将output里的x替换为厂商信息。28(%edi)中的28表示偏移量,即整个地址为%edi里的地址加上28个字节,这个地址正好是output里第一个x的地址。再接下来就是打印结果了,这里用到了Linux的一个系统调用(int 0x80),该系统调用的参数分别为:eax 系统调用号,ebx 要写入的文件描述符,ecx 字符串首地址,edx 字符串长度,程序里这些个参数的值分别为4,1(标准输出),output的地址和42。最后再次调用1号系统调用-退出函数,返回shell,这次 ebx中的值是返回给shell的退出代码,0表示无异常
然后汇编连接运行程序:
[root@zieckey-laptop src]# as -o cpuid.o cpuid.s
[root@zieckey-laptop src]# ld cpuid.o -o cpuid
[root@zieckey-laptop src]# ./cpuid
The processor Vendor ID is 'GenuineIntel'
[root@zieckey-laptop src]#
本人的电脑是Pentium M的CPU所以返回的结果是GenuineIntel。
几点说明:
1)Linux的标准汇编环境为as,ld,gdb,gprof,objdump等GNU开发调试工具,除了gdb外,其他全部随binutils包发布。其中as使用的是AT&T语法。在Linux下也可以使用Nasm来进行Intel格式的汇编程序编写
2)Linux下汇编的系统调用为int 0x80,和DOS下的int 21h大同小异,只不过传递参数不同
3)段声明语句.section不需要像Intel格式那样在段结尾的时候加上段结束标志(SEGMENT/ENDS),下一个段的开始自动标志着上个段的结束
4)简单程序的入口标签不是必须要定义的,ld会自己判断入口,但是会给出警告
===========================================例子2
例 2. 求一组数的最大值的汇编程序
#PURPOSE: This program finds the maximum number of a # set of data items. # #VARIABLES: The registers have the following uses: # # %edi - Holds the index of the data item being examined # %ebx - Largest data item found # %eax - Current data item # # The following memory locations are used: # # data_items - contains the item data. A 0 is used # to terminate the data # .section .data data_items: #These are the data items .long 3,67,34,222,45,75,54,34,44,33,22,11,66,0 .section .text .globl _start _start: movl $0, %edi # move 0 into the index register movl data_items(,%edi,4), %eax # load the first byte of data movl %eax, %ebx # since this is the first item, %eax is # the biggest start_loop: # start loop cmpl $0, %eax # check to see if we've hit the end je loop_exit incl %edi # load next value movl data_items(,%edi,4), %eax cmpl %ebx, %eax # compare values jle start_loop # jump to loop beginning if the new # one isn't bigger movl %eax, %ebx # move the value as the largest jmp start_loop # jump to loop beginning loop_exit: # %ebx is the status code for the exit system call # and it already has the maximum number movl $1, %eax #1 is the exit() syscall int $0x80
汇编、链接、执行:
$ as max.s -o max.o $ ld max.o -o max $ ./max $ echo $?
这个程序在一组数中找到一个最大的数,并把它作为程序的退出状态。这组数在.data
段给出:
data_items: .long 3,67,34,222,45,75,54,34,44,33,22,11,66,0
.long
指示声明一组数,每个数占32位,相当于C语言中的数组。这个数组开头有一个标号data_items
,汇编器会把数组的首地址作为data_items
符号所代表的地址,data_items
类似于C语言中的数组名。data_items
这个标号没有用.globl
声明,因为它只在这个汇编程序内部使用,链接器不需要知道这个名字的存在。除了.long
之外,常用的数据声明还有:
.byte
,也是声明一组数,每个数占8位
.ascii
,例如.ascii "Hello world"
,声明了11个数,取值为相应字符的ASCII码。注意,和C语言不同,这样声明的字符串末尾是没有'\0'字符的,如果需要以'\0'结尾可以声明为.ascii "Hello world\0"
。
data_items
数组的最后一个数是0,我们在一个循环中依次比较每个数,碰到0的时候让循环终止。在这个循环中:
edi
寄存器保存数组中的当前位置,每次比较完一个数就把edi
的值加1,指向数组中的下一个数。
ebx
寄存器保存到目前为止找到的最大值,如果发现有更大的数就更新ebx
的值。
eax
寄存器保存当前要比较的数,每次更新edi
之后,就把下一个数读到eax
中。
_start: movl $0, %edi
初始化edi
,指向数组的第0个元素。
movl data_items(,%edi,4), %eax
这条指令把数组的第0个元素传送到eax
寄存器中。data_items
是数组的首地址,edi
的值是数组的下标,4表示数组的每个元素占4字节,那么数组中第edi
个元素的地址应该是data_items + edi * 4
,从这个地址读数据,写成指令就是上面那样,这种地址的表示方式在下一节还会详细解释。
movl %eax, %ebx
ebx
的初始值也是数组的第0个元素。下面我们进入一个循环,在循环的开头用标号start_loop
表示,循环的末尾之后用标号loop_exit
表示。
start_loop: cmpl $0, %eax je loop_exit
比较eax
的值是不是0,如果是0就说明到达数组末尾了,就要跳出循环。cmpl
指令将两个操作数相减,但计算结果并不保存,只是根据计算结果改变eflags
寄存器中的标志位。如果两个操作数相等,则计算结果为0,eflags
中的ZF位置1。je
是一个条件跳转指令,它检查eflags
中的ZF位,ZF位为1则发生跳转,ZF位为0则不跳转,继续执行下一条指令。可见条件跳转指令和比较指令是配合使用的,前者改变标志位,后者根据标志位做判断,如果参与比较的两数相等则跳转,je
的e就表示equal。
incl %edi movl data_items(,%edi,4), %eax
将edi
的值加1,把数组中的下一个数传送到eax
寄存器中。
cmpl %ebx, %eax jle start_loop
把当前数组元素eax
和目前为止找到的最大值ebx
做比较,如果前者小于等于后者,则最大值没有变,跳转到循环开头比较下一个数,否则继续执行下一条指令。jle
也是一个条件跳转指令,le表示less than or equal。
movl %eax, %ebx jmp start_loop
更新了最大值ebx
然后跳转到循环开头比较下一个数。jmp
是一个无条件跳转指令,什么条件也不判断,直接跳转。loop_exit
标号后面的指令用exit
系统调用退出程序。