主要描述三方面的内容:第一是汇编语言的程序模版,以及模版涉及到的一些知识点;第二是如何调试汇编语言;第三是如何在汇编语言中调用C库函数。
1. 汇编语言的组成
汇编语言由段(section)组成,一个程序中执行的代码,叫文本段(text),程序还可能有定义变量,有付给初始值的变量放在数据段(data)中,没有赋初值或者付给零初值的放在bss段中。text段一定是要有的,data和bss可以没有。
2. 段的定义
用.section语法定义段。比如:
.section .text定义文本段,
.section .data定义数据段,
.section .bss定义bss段。
顺序没有必须的要求,但为了便于别人接手和理解你的程序,书中建议采用从上到下按照data,bss,text段的顺序定义。
3. 定义程序起始点
文本段必须要定义一个程序执行的起始点,ld默认为_start;而gcc默认会连接标准的库代码,该代码入口为_start,执行一段程序后跳到main执行,因此gcc默认要求外部源程序定义main,且不能定义_start,但如果使用参数-nostdlib,那么就不会默认连接标准的库代码,此时入口点用_start也没问题;此外,ld和gcc都支持-e参数来指定入口点,此时任意的标号都可以用作入口点。
下面以例子来说明。以汇编语言程序设计读书笔记(2)- 相关工具64位系统篇中的cpuid2.s为例子进行说明,目前可能读者还不理解该程序的细节,但本文后面会论述,读完本文后,理解该程序不是问题。目前只要清楚该程序用于输出CPU的ID厂商的字符串。源程序入口为_start。如下:
cpuid2.s# cpuid2.s file .section .data output: .asciz "CPUID is ‘%s‘\n" .section .bss .lcomm buffer, 12 .section .text .globl _start _start: nop movl $0, %eax cpuid movl $buffer, %edi movl %ebx, (%edi) movl %edx, 4(%edi) movl %ecx, 8(%edi) pushl $buffer pushl $output call printf addl $8, %esp pushl $0 call exit
下面分别对入口为_start,main,xxxx(任意标签)这三种情况下,用as,ld和gcc汇编cpuid2.s,生成可执行文件。
1). 入口为_start
用as,ld生成可执行文件(入口为_start),如下图所示:
gcc生成可执行文件(入口为_start),如下图:
2). 入口为main
源程序把标签_start改为main。此时ld可以使用-e main参数,那么就会以main为程序起始点;gcc则有两种方式,一是使用默认的编译方式,即不带-nostdlib参数,此时gcc会连接库代码,所以生成的执行文件的大小较大,另一种方式是使用-nostdlib,但是用-e main指定入口点。
用as,ld生成可执行文件(入口为main),如下图所示:
gcc按第一种方式生成可执行文件(入口为main),如下图:
gcc按第二种方式生成可执行文件(入口为main),如下图:
第一种方法生成的大小为4799,第二生成的大小为2133。
3). 入口为xxxx(任意的标号)
cpuid2.s源程序把_start改为xxxx,即入口为xxxx。此时必须使用-e xxxx来汇编或者编译。
用as,ld生成可执行文件(入口为xxxx),如下图所示:
gcc生成可执行文件(入口为xxxx),如下图:
可见入口为xxxx的方式包含了以上入口为_start和main的情况。
综合以上情况,无论入口点是什么标签,无论是运行在32位系统或者64位系统,都可以按照以下的命令来汇编和编译汇编程序。
# 假设要汇编或编译的汇编程序有n个,为input_file1.s,input_file2.s,...,_filen.s。n大于等于1。
# 输入文件列表input_file1.s,...input_filen.s使用{input_file.s}表示,同样,{input_file.o}表示一系列的.o文件
# 输出可执行文件为output_file。
# libc.so所在的绝对路径用/libc_path表示,ld-linux.so.2所在的绝对路径用/ld-linux_path表示。
# 入口点标签为entry_point。
# []括起来的是调用了C库函数才需要的部分,去过没有调用C库,则不需要。
# 那么as,ld生成可执行文件的命令如下:
as --32 -o input_file1.o input_file1.s
as --32 -o input_file2.o input_file2.s
...
as --32 -o input_filen.o input_filen.s
ld -m elf_i386 -e entry_point [-dynamic-linker /ld-linux_path/ld-linux.so.2] -o output_file [-L/libc_path -lc] {input_file.o}
# gcc生成可执行文件的命令如下:
gcc -m32 -e entry_point -nostdlib -o output_file [-L/libc_path -lc] {input_file.s}
#注:如果源程序没有调用C库函数而又使用了[]中的指令连接或编译,那么会产生错误“/usr/lib/libc.so.1: bad ELF interpreter: 没有那个文件或目录”
4. 外部程序标签声明
如果一个汇编程序文件中的代码调用了另一个汇编程序文件的标号或者函数,那么必须声明这个标号或函数为.globl(应该是global的缩写,全局的,可以跨文件调用)的。
比如汇编程序test3.s和test4.s,test3.s调用了test4.s的函数fun4,如果test4.s没有.globl fun4这行,那么编译会提示错误,加上这行则没有任何问题。
test3.s.section .text .globl _start _start: call fun4 movl $1, %eax movl $0, %ebx int $0x80
test4.s.section .text #.globl fun4 fun4: movl $1, %eax movl $2, %ebx addl %ebx, %eax ret
#符号把.globl fun4注释了,那么汇编成test3.o和test4.o后连接,会提示在函数_start中,没有定义fun4。如下图:
把#去掉后,让.globl fun4起作用,则没有任何问题,如下图:
综上,任何的标号或者函数,如果要准备给别的文件调用,那么一定要用.globl声明。
5. 汇编程序模版
经过上面的论述后,很容易得到了汇编程序的模版,如下:
.section .data
<初始化值的数据在这里>
.section .bss
<未初始化的数据在这里>
.section .text
.globl entry_point
entry_point:
<代码指令在这里>
其中,entry_point为程序起始点。
6. 汇编程序范例
书中的范例是用CPUID汇编指令去读取CPU的厂商ID(Vendor ID)。了解这个程序之前先简单说明几个知识点。
1). 关于CPUID指令
输入参数通过寄存器EAX传入,执行CPUID后,输出通过EBX,ECX,EDX传出。这里只要了解EAX=0时,ECX,EDX,EBX分别得到厂商ID的字符串的高4字节,中间4字节,低4字节。厂商ID的字符串按照小端排列,即先放低字节,即厂商ID为[EBX][EDX][ECX]。
这可以通过对CPUID的测试代码test_cpuid.s来进一步了解,如下代码:
test_cpuid.s# test_cpuid.s program .section .text .globl _start _start: nop movl $0, %eax CPUID movl $1, %eax movl $0, %ebx int $0x80
带调试参数-gstabs生成执行文件后用kdbg调试,在CPUID指令后设置断点,如下图:
寄存器ebx,edx,ecx,每个字节都是ASCII的编码,把这些字节编码按照ebx,edx,ecx从低字节到高字节的排列翻译成字符串,如下图,显示如下:
即厂商ID是“GenuineIntel”。
2). 关于Linux系统调用
通过0x80号的软件中断(int $0x80),可以调用Linux的内核函数,具体是哪个内核函数,由EAX寄存器决定,而传递给函数的参数则根据调用的函数而有不同的含义,一般由EBX,ECX,EDX来传递。书籍要到第十二章才会进一步讨论。目前使用到的两个系统调用先要简单了解。
第一个是第1号调用,调用的是退出函数sys_exit(ret),EAX=1表示调用号,EBX=ret传递第一个参数,表示返回给父进程的返回值,即sys_exit(ret)相当于如下的汇编代码:
# sys_exit(ret)系统调用的汇编代码
movl $1, %eax
movl $ret, %ebx
int $0x80
第二个调用是第4号调用,调用的是函数sys_write(int fd, const void *buf, size_t count),三个参数分别用ebx,ecx,edx传入,分别代表文件描述符,要写的缓冲区首地址,缓冲区字节长度。众所周知,Linux中用文件描述符1用于表示标准输出(stdout),默认即显示终端,因此要往显示终端打印长度为length的字符串str,即sys_write(1, str, length)相当于以下的汇编代码:
# sys_write(1, str, length)系统调用的汇编代码
movl $4, %eax
movl $1, %ebx
movl $str, %ecx
movl $length, %edx
int $0x80
3). 完整的代码范例cpuid.s
如下的代码cpuid.s,读取CPU的厂商ID,然后打印到屏幕上。代码为:
cpuid.s# cpuid.s程序,打印CPU的厂商ID .section .data output: .ascii "CPU ID is ‘xxxxxxxxxxxx‘\n" #在data段定义字符串,最后的结果取代xxxxxxxxxxxx后就是要输出的内容 .section .text .globl _start _start: movl $0, %eax #获取CPU厂商ID到ebx,edx,ecx cpuid movl $output, %edi movl %ebx, 11(%edi) movl %edx, 15(%edi) movl %ecx, 19(%edi) #更新xxxxxxxxxxxx,在字符串中是第11个字节开始 movl $4, %eax movl $1, %ebx movl $output, %ecx movl $25, %edx int $0x80 #输出字符串,包括换行在内长度为25字节 movl $1, %eax movl $0, %ebx int $0x80 #退出程序,返回0值
生成可执行文件以及执行的结果如下图:
理解了上述的1)和2)所述的知识,加上代码中的注释,这段代码不难理解,前提是需要有一点点汇编语言的基础(不会movl都不知道吧?),不再赘述。
7. 调试汇编程序
用kdbg调试程序可以不用记gdb的指令,kdbg整个操作界面十分明朗,要单步运行,设置断点,观察变量,寄存器,内存,都没有问题,如下图:
但有一个特别需要注意的地方,_start之后的第一个nop指令,如果没有,没法观察内存,会提示超出范围,后来把nop加上则一切正常,该书上提到没有这个nop会造成无法在_start处设置断点,为gdb的bug,而我在kdbg中则是无法擦看内存,断点倒是可以设置。所以还是建议调试时,先增加这个nop指令。
8. 调用C库函数
上面的cpuid.s代码范例使用软中断调用linux内核函数来实现打印和退出程序,还有另外的方法实现打印和退出,那就是调用C语言的标准库函数,打印是printf,退出是exit。
假如CPUID=”GenuineIntel”,那么可以使用printf(“CPU ID is ‘%s’\n”,CPUID)来打印和cpuid.s一样的输出信息。下面就以printf为例来描述汇编程序怎么调用C库函数。
调用子程序的指令是call,那么调用printf就是用call printf,如果要实现printf(“CPU ID is ‘%s’\n”,CPUID),那里面的两个参数怎么传递?这个要使用堆栈(stack),一般而言,C语言采用从右到左的顺序入栈,即字符串CPUID的首地址先入栈,而后“CPU ID is ‘%s’\n”的首地址入栈。入栈的汇编代码是pushl。因此,可以按照如下的代码实现printf(“CPU ID is ‘%s’\n”,CPUID)。
# 假设"CPU ID is ‘%s‘\n"的首地址是output,读回来的CPUID字符串首地址为buffer
# 那么以下代码实现C库函数调用:printf(output, buffer)
pushl $buffer
pushl $output
call printf
注意,C语言堆栈底部用高地址,pushl入栈后堆栈指针esp寄存器变小了4,因此,如果在call printf后,buffer和output不再使用了,可以把esp设置为指向入栈前的地址,以便这两个参数占用的堆栈空间可以使用。即pushl两次后,esp减少了8,因此esp需要加上8才能回复原来的堆栈位置,即addl $8, %esp。
这里可以简单的理解:对于C库函数func(param1, param2, …, paramn),调用的方法是先参数从右到左的顺序入栈,然后call func。对于要深入研究的话,提供abi接口文档下载。
exit(ret)的调用方式轻易实现:
# exit(ret)的汇编调用代码
pushl $ret
call exit
32位系统的abi和64位的abi(application binary interface)是不一样的,因此x64的系统不能如此调用,所以在64位系统上运行32位的程序,必须按照32位的汇编和连接,否则会发生错误。具体可参考汇编语言程序设计读书笔记(2)- 相关工具64位系统篇一文。
9. 通过调用C库函数改写cpuid.s-cpuid2.s范例
其实这个范例就是“3. 定义程序起始点”内容中的范例。理解了cpuid.s的意思,理解了怎么调用C库函数,那么这个范例就可以轻易的理解了。另外对于这个范例还需说明两点。
.asiz: 因为printf打印的是0结尾的字符串,因此需要定义0结尾的字符串,所以用.asciz,而不用.ascii。
.lcomm: 声明留出一块本地内存,这里.lcomm buffer, 12表示留出12个字节大小的一块本地内存,首地址用buffer表示。
代码很明了,先是读取CPUID到ebx,edx,ecx寄存器,然后通过edi寄存器为索引,把读到的的字符串拼接到buffer中,然后分别以output和buffer为参数调用printf,相当于调用了printf(“CPU ID is ‘%s’\n”, buffer)来打印,最后调用exit(0)返回。
10. 结束语
通过本篇的描述,应该知道怎样设计一个汇编程序,包括系统调用和C库调用怎样使用,最后在32位系统或64位系统上汇编连接运行。