且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

带你读《C指针原理揭秘:基于底层实现机制》之三:AT&T汇编概述

更新时间:2022-01-07 01:56:00

点击查看第一章
点击查看第二章

第3章

AT&T汇编概述

3.1 AT&T汇编基础

3.1.1 IA-32指令

当计算机处理应用程序,运行其中的二进制指令码时,数据指针将指示处理器如何在内存的数据区域寻找要处理的数据,这块区域称为堆栈;指令码放在另外的指令区,并通过指令指针机制管理当前运行中的指令,当处理器完成一个指令码的处理之后,指令指针将指向下一条指令码。
IA-32指令码(Intel、AMD的CPU使用的指令码)由二进制码组成,格式如图3-1所示。

带你读《C指针原理揭秘:基于底层实现机制》之三:AT&T汇编概述

其中,指令前缀(Instruction Prefixes)可包含1到4个修改操作码行为的1字节前缀,它们分别是锁定前缀和重复前缀、段覆盖前缀和分支提示前缀、操作数长度覆盖前缀、地址长度覆盖前缀等。操作码(Opcode)定义了处理器执行的功能;修饰符包括ModR/M(寻址方式说明符)、SIB(比例–索引–基址)、Displacement(移位),定义执行的功能中涉及存取的具体寄存器和内存位置;数据元素(Immediate)是完成功能所需要使用的数据,这些数据既可以是直接的数据值,也可以是数据在内存中的地址。
当数据元素被放入到堆栈中时,数据指针在内存中“向下”(地址减少)移动,而当数据从堆栈中读取时,数据指针则在内存中“向上”(地址增加)移动;指令指针可让处理器了解哪些指令码已经处理过了,接下来需要处理的指令码是哪条。数据指针的移动过程如图3-2中的左图所示,指令指针的移动如图3-2中的右图所示。

带你读《C指针原理揭秘:基于底层实现机制》之三:AT&T汇编概述

3.1.2 汇编的作用

C语言看似简单,但要想真正驾驭它难度却很大,需要非常小心和谨慎。C语言非常接近底层硬件,极具灵活性,在实际工作中,C程序员往往需要比Java程序员掌控更多的底层细节,他们通常要亲自控制内存的分配与回收、控制接口通信、优化文件系统和网络协议、调用操作系统内核函数,等等,这对C代码的质量提出了较高的要求,代码质量越高,编译后运行的效率就越高,代码质量太低,将会使运行效率也变得低下,可能还不及完成同样功能的Java程序,诸如内存泄漏等问题甚至会造成操作系统的漏洞和崩溃。
汇编语言(Assembly language)是一种用于电子计算机、微处理器、单片机或其他可编程器件的低级语言,采用助记符代表特定低级机器语言的操作,特定的汇编语言和特定的机器语言指令集是一一对应的,因此,相对C等高级语言而言,汇编语言的移植性较差,一种汇编语言通常专用于某种计算机系统结构,而且不可以在不同的系统平台之间移植。使用汇编语言编写的源代码,需要使用相应的汇编程序将它们转换成可执行的机器代码,这一过程被称为汇编过程。汇编语言的优势在于:速度快,可以对硬件底层直接进行操作,这对诸如图形处理、高性能运算、底层接口操作等关键应用是非常重要的。
汇编语言与C语言是相辅相成的,对编译后的C程序进行反汇编,剖析生成的汇编代码,能够更好地理解编译过程、指针原理、内存分配、代码优化等关键问题,从而提高C代码的质量;此外,汇编速度快,可以直接对硬件进行操作,这对诸如图形处理等关键应用是非常重要的,可以将汇编语言直接嵌入C代码中,运用汇编这一底层语言优化程序的性能。

3.1.3 AT&T汇编语言的特点

汇编语言允许程序员方便地创建指令码程序,但不是用那些二进制编码的格式,而是使用助记符,助记符使用不同的单词表示不同的指令码。有了助记符,程序员可以用英语来编写在目标机器上执行的指令码,而不用记忆那些无趣的二进制编码。
绝大多数程序员以前只接触过DOS/Windows下的汇编语言,这些汇编代码都是Intel风格的,在Linux系统中,更多采用的还是AT&T汇编语言,因此,本书将以AT&T汇编语言为例进行讲解。
AT&T汇编语法主要包含如下特点。
1)程序源文件一般以“.s”作为后缀文件名,以“#”开头表示注释。
2)寄存器名以“%”作为前缀。例如,下面的代码表示将eax寄存器的内容复制到ebx中:
movl %eax,%ebx
3)立即操作数以“$”前缀表示。例如,下面的代码表示将1复制到eax内存地址中(eax用括号包围,表示操作数的内存位置,而不是操作数本身):
movl $1, (%eax)
4)目标操作数在源操作数的右边。例如,下面的代码表示将寄存器eax的内容复制到ebx中:
movl %eax,%ebx
5)操作数的字长由操作符的最后一个字母决定,后缀“b”、“w”、“l”分别表示操作数为字节(byte,8 比特)、字(word,16 比特)和长字(long,32比特)。
下面以复制指令mov为例进行说明。
movl对32位进行操作,下面的代码表示将eax寄存器32位的内容复制到ebx中:
movl %eax, %ebx
movw对16位进行操作,下面的代码表示将ax寄存器的内容复制到bx中:
movw %ax, %bx
movb对8位进行操作,下面的代码表示将al寄存器的内容复制到bl中:
movb %al, %bl
下面再来看一下入栈指令push(如下面的代码所示,“#”后的注释是对本行代码的说明):
pushl %ecx # 32位ecx的内容入栈
pushw %cx # 16位ecx的内容入栈
pushl $180 # 180作为一个32位整数入栈
pushl data # data变量内容入栈,长度为32位
pushl $data # 这个操作很特别,在变量前面加上"$"表示获取变量的地址,这里是将data变量的地址入栈
6)远程转移指令和远程子调用指令的操作码分别为ljump和lcall。

3.1.4 第一个AT&T汇编

学习汇编最有效的方法就是动手实践,下面就来开始编写第一个汇编程序吧!本程序需要完成的功能是:将66与20相加,相加的结果(88)是字母“B”的ASCII码,将“B”与后面跟随的换行符(换行符的ASCII码为10)一起输出到屏幕上。不同的操作系统中,汇编代码会稍有不同,下面将分别以FreeBSD与Ubuntu系统为例进行讲解。
1. FreeBSD系统
FreeBSD系统可通过Vim或ee编辑文件3-1.s,输入如程序3-1所示的代码:

带你读《C指针原理揭秘:基于底层实现机制》之三:AT&T汇编概述

与C程序需要编译才能运行一样,汇编程序不能直接执行,也需要先进行汇编,并且要在链接后才能执行,具体过程如下。
首先,进行汇编:
%as -o 3-1.o 3-1.s
然后,链接:
%ld -o 3-1 3-1.o
最后,运行测试:
% ./3-1
B
下面对程序3-1进行剖析,初步熟悉一下AT&T汇编。
1)AT&T汇编代码通过.section声明不同的段,程序3-1声明了两个段,它们分别是.section .data段和.section .text段。.section .data段为数据段,用于存放可供汇编程序读写的数据;.section .text段为代码段,用于存放汇编程序代码,程序在运行时会为这两个段分配相应大小的内存,当程序结束时,这些内存会被自动释放。
2)程序3-1中的数据段中存放了1个字节(byte)大小的数字66和同样大小的换行符(数字10表示换行符的ASCII码),代码片断如下:
.section .data
output:
.byte 46
.byte 10
上述汇编代码完成的功能相当于下面这条C语句:
unsigned char output[2]={60,10};
3)程序3-1的代码段的开头处有这样一条语句“globl _start”,这条语句标注了程序的起始点(相当于C语言的main函数)。globl标记用于指示外部程序可访问的程序标签,_start标签是ld链接器进行链接时默认程序的起始点,它们组合在一起的含义是:当汇编程序运行时,指令指针将指向_start标签处(即代码段的开头,第一条汇编代码处),从_start标签指向的汇编代码开始运行。注意,“_start”是默认的名字,如果要使用其他名字,则需要在链接时使用“-e”选项指定起始点名称。
4)紧接着_start标签的程序可分为如下两块。
第一块是显示字符“B”及换行符,代码片断如下:
movl $output,%edx
addl $20,(%edx)
pushl $2 # 参数三:字符串长度,包括换行符共2个字符
pushl $output # 参数二:要显示的字符串
pushl $1 # 参数一:文件描述符(stdout)
movl $4, %eax # 系统调用号(sys_write)
pushl %eax
int $0x80 # 调用内核功能显示字符及回车
上述代码片断的前2行将output标记指向的数字66所在的内存地址送入edx寄存器,然后调用addl指令完成66+20的运算,addl指令的目标操作数为(%edx),而不是%edx,用括号包围表示目标操作数在%edx指向的内存地址中。余下几行则通过将参数依次入栈,然后使用UNIX内核的系统调用从内核访问控制台显示,最后一行int $0x80表示使用int指令码,生成具有0x80值的软件中断,要求内核执行的具体操作由eax寄存器决定,这个内核函数省去了将每个输出字符亲自送到显示器的I/O地址的过程。该代码片断相当于执行以下C语句:
output[0]+=20;
printf("%s",output);
第二块是退出程序,代码片断如下:
pushl $0 # 参数一:退出代码
movl $1,%eax # 系统调用号(sys_exit)
int $0x80 # 调用内核功能
上述代码片断以退出代码0作为参数入栈,并以系统调用号1来调用内核,从而正常退出程序,这段代码相当于执行以下C语句:
return 0;
以上两块代码均使用了int $0x80软中断语句来访问内核,软中断指令通常要运行一个切换CPU至内核态(Ring 0)的子例程,这个过程用于实现系统调用,它触发了内核事件,实现了宏观的异步执行,与硬中断类似,也与信号有些类似。这意味着,当执行int $0x80时,通过软中断触发内核事件,进而调用内核函数。调用函数通常需要传入参数,内核函数也不例外,栈就是用户程序与内核函数的交换空间。
与Ubuntu不同的是,FreeBSD 内核默认使用 C 语言的调用规范,因为作为一个类Unix操作系统,它遵守Unix规范,该规范允许任何语言所写的程序访问内核,也就是说FreeBSD访问内核的方式是先将参数压入栈中,然后再执行 int $0x80调用内核中断,执行内核函数。程序3-1也是这样做的:它首先通过push指令,将调用参数将压入栈中,然后通过int $0x80指令触发软件中断,执行内核函数,最后内核函数从栈中将参数取出,执行完毕后,由内核态返回用户态。
2. Ubuntu 系统
Ubuntu等Linux系统与FreeBSD等类UNIX系统在调用内核函数时略有不同。Linux内核在传递参数的时候,使用了与MS-DOS/Windows相同的系统调用规范,例如,在 UNIX 的规范中,代表内核函数的数字存放在eax中,而在Linux中,调用参数并未压入栈中,而是存放在ebx,ecx,edx,esi,edi,ebp等寄存器中。因此在Ubuntu下,程序3-1需要稍做修改,修改后的代码如程序3-2所示:

带你读《C指针原理揭秘:基于底层实现机制》之三:AT&T汇编概述
带你读《C指针原理揭秘:基于底层实现机制》之三:AT&T汇编概述

汇编并运行程序3-2:
$as -o 3-2.o 3-2.s
$ld -o 3-2 3-2.o
$ ./3-2
B
程序3-2与程序3-1的结构类似。首先,在可读写数据段(.section .data)中存放66以及10(换行符的ASCII码);然后,在程序段(.section .text)的起始处,使用“.global _start ”声明入口程序名;最后,在_start程序中,先后使用两次“int $0x80”语句调用内核函数,显示字符串后退出程序。但有一个“陷阱”,程序段的前2行看似完成了66+edx的操作,但却忽略了一点,%edx表示一个操作数,而不是操作数的内存位置。因此输出的是66对应的“B”,而不是ASCII码86对应的“√”。
程序3-2与程序3-1的主要区别在于:程序3-2并没有像程序3-1那样将调用参数推入栈中,而是将参数放在edx、ecx、ebx、eax寄存器中,然后调用内核功能输出字符串。
从以下代码片断可以看出:字符串长度2被放置在edx寄存器中,字符串的首地址放置在ecx寄存器中,输出设备stdout的描述符放置在ebx寄存器中,系统调用号4放置在eax寄存器中,最后一行调用了内核功能函数。
movl $2, %edx # 参数三:字符串长度
movl $output, %ecx # 参数二:要显示的字符串
movl $1, %ebx # 参数一:文件描述符(stdout)
movl $4, %eax # 系统调用号(sys_write)
int $0x80 # 调用内核功能

3.2 程序运行机制

C程序运行机制与Python、Lua等脚本语言的运行机制不同,脚本语言由解释程序读取后运行,由解释程序负责运行脚本语言的指令,而不是由CPU直接运行脚本语言的指令。虽然某些脚本语言解释器具有JIT(just-in-time compiler)功能,可将脚本语言转换成能被处理器直接执行的指令,但是,转化的过程实质上也是一个编译的过程,这个编译过程仍然需要编译器的帮忙,因此,从某种角度上来说,此类脚本语言解释器可称为“脚本语言编译器”。而C语言则不同,它属于编译型语言,当然,汇编语言也是可编译运行的,但C语言相比汇编语言而言更简洁,在完成同样任务的情况下,C程序的编码量要少很多,这对汇编语言程序员来说也许是一种解脱。
C语言将生成机器语言的工作托付给编译器执行,机器语言是计算机能够直接解读、运行的语言,C语言编译器将源程序作为输入,翻译成目标语言机器的二进制执行文件,在Linux平台下,GCC是使用最多的编译器,GCC原名为GNU C 语言编译器(GNU C Compiler),经过后期的不断改进,目前GCC可用于编译C、C++、Fortran、Pascal、Objective-C、Java、Ada等,此外,GCC还能编译汇编语言。Unix平台默认的编译器是cc,使用方式与GCC类似。
C语言编译生成的二进制可执行文件通常分为应用程序和库文件两种,其中,应用程序可以直接执行,库文件是多个目标文件的组合,通常来说不能直接执行,但其提供了多个功能的调用接口。在编译C语言时,链接进应用程序的称为静态库;在系统运行时,调用应用程序的称为动态库。
GCC等 C语言编译器简化了C程序员的工作,让他们能够将大部分精力放在处理程序与算法逻辑上来,但美中不足的是:C语言编译生成的二进制程序比汇编器生成的程序要大,包含的指令也更多,因此程序执行效率要比汇编语言低,虽然GCC编译器拥有优化编译的功能,可提高生成机器代码的执行效率,但是其仍然无法与汇编代码汇编生成后的应用程序相比,因此,在执行效率要求很高的场合,仍然需要全部使用汇编语言编写或将汇编代码嵌入到C语言中。

3.3 小结

汇编语言与C语言是相辅相成的,汇编语言能够帮助C程序员提高代码质量,更好地参与数十万行以上C代码的复杂项目的开发;同时,C语言代码中可以内嵌汇编语言,将程序中的关键部分用汇编语言来实现,从而进一步提高效率。本章首先简要介绍了IA-32指令构造、AT&T汇编的作用与语法特点,然后以输出单个字符为例,讲解了AT&T汇编的编写、汇编以及链接过程,最后解说了程序的运行机制。