朝花夕拾—GNU 汇编笔记一

最近在Linux下使用有些蹩脚的Thunk技术将C++类成员函数变成可回调函数时(供libevent调用),发现好多汇编语法都忘记了, 干脆花点时间重新熟悉一下,查漏补缺,温故知新。


汇编语言主要由三个组件构成:

  • 操作码助记符: 例如push, mov, sub, call等等。
  • 数据段: 程序声明的静态数据区。数据段通常可分为普通数据段,只读数据段和bss段。
  • 命令: 汇编器保留的专门关键字用于在助记符被转换成指令码时,指示汇编器如何执行 专门的函数。因为这些指令(directive)只是用来指导汇编器如何生成汇编代码,它没有真正 的硬件指令码与之对应,所以常称之为伪指令。GNU中的命令之前有一个点号(.),它的名称对 多数平台来说是不区分大小写的,但通常将其写成小写形式。 关于详细介绍,可在 这里 查阅。

    汇编语言程序中,最为重要的命令之一就是.section。这个命令定义内存段。 汇编语言通常至少声明: 数据段bss段文本段


Intel与AT&T语法的主要区别:

  • AT&T使用$标示立即操作数,而Intel的立即操作数必须要做特殊界定。
  • AT&T在寄存器名称前加上%前缀,Intel不需要这样做。例如,AT&T语法 引用EAX寄存器写作: %eax
  • AT&T语法处理源和目标操作数时,与Intel使用相反的顺序。例如把十进制 值4传送到EAX寄存器,AT&T的语法是 mov $4, %eax; Intel语法是 mov eax, 4.
  • AT&T语法在助记符后面使用一个单独的字符来引用操作中使用的数据长度, 例如b, l, q等; Intel语法中数据长度被声明为单独的操作数。 AT&T的指令 movl $test, %eax 等同于 Intel语法的 mov eax, dword ptr test.
  • 长调用和跳转使用不同语法定义段和偏移值。AT&T语法使用ljmp $section, $offset; Intel使用 jmp section:offset.

  • _start标签: GNU汇编器默认使用此标签来标明程序的起始点。_start标签用于 标明程序应该从这条指令开始运行。如果连接器找不到这个标签,它会生成一条错误消息。

也可在使用除_start 之外的其他标签作为起时点。可以使用连接器的 -e 参数定义 新的起时点名称.

  • .globl命令: 此命令用于声明外部程序可以访问的程序标签。如果编写被外部 汇编语言或C语言程序使用的库函数,就应该使用此命令声明函数段。

汇编程序的段落布局大致如下:

	.section .data
	    # TODO: initialized data here
	 
	 .section .bss
	    # TODO: uninitialized data here

	 .section .text
	    # TODO: code here 

	 .globl _start
	 _start:
	    # instruction code goes here

一个简单的demo
	.section .data # 定义数据段
	output:
	    .ascii "The processor vendor ID is 'xxxxxxxxxxxx'\n" # 'x'占位

	.section .text # 定义代码段
	.global _start # 导出入口点标号

	_start:
		movl $0, %eax 	# EAX寄存器传参
		cpuid 		# 执行cpuid指令

		# 从EBX, EDX, ECX寄存器中取值,并填充到 output数据段中
		movl $output, %edi 
		movl %ebx, 28(%edi)
		movl %edx, 32(%edi)
		movl %ecx, 36(%edi)

		movl $4, %eax 		# Linux write 系统调用号
		movl $1, %ebx 		# 传入标准输出描述符
		movl $output, %ecx 	# 传入buf地址
		movl $42, %edx 		# 传入长度
		int $0x80 		# 陷入中断

		movl $1, %eax 		# Linux exit 系统调用号
		movl $0, %ebx 		# 传入退出码0
		int $0x80 		# 执行 exit(0)

将_start标号改为main, 则可由gcc自动帮我们完成编译和链接步骤

稍微改造一下, 使用printf
	.section .data
	output:
	    .asciz "The processor vendor ID is '%s'\n"
	
	.section .bss
	    .lcomm buffer, 12

	.section .text # 定义代码段
	.global _start # 导出入口点标号

	_start:
		movl $0, %eax 	# EAX寄存器传参
		cpuid 		# 执行cpuid指令

		# 从EBX, EDX, ECX寄存器中取值,并填充到buffer中
		movl $buffer, %edi 
		movl %ebx, (%edi)
		movl %edx, 4(%edi)
		movl %ecx, 8(%edi)
		
		pushl $buffer 	# buffer入栈
		pushl $output 	# 格式化字符串入栈
		call printf 	# 调用printf
		addl $8, %esp	# 清理栈,保持栈平衡
		pushl $0		# 退出码入栈
		call exit		# 调用exit

几点说明:

  • .asciz 伪指令声明其后的字符串以\0结束。
  • .lcomm 用来预留一定长度的字节空间。它的一般带两个参数 [.lcomm symbol, length]。 预留出来的字节可用symbol来引用。某些汇编器可能允许它带第三个参数。
  • 当 $buffer, $output入栈之后,使用call指令调用C库的printf函数。
IA32平台下存在多种函数调用约定(function call convention)。

众多的调用约定旨在告诉编译器两个重要责任:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
a.函数参数的入栈顺序问题。
b.谁来负责函数调用栈恢复问题。

cdecl调用约定:
  这是在C/C++中默认使用的约定方式。它告诉编译器: 参数要从右向左压栈,并且由调用者恢复栈   这主要是为了保证C的灵活性。像C库中的printf这种变参参数,函数体中是无法知道调用栈中究竟有多少个   参数的,这只有调用者心里清楚。所以将维护栈平衡的任务交给调用者。
 	
stdcall调用约定:
  stdcall与cdecl约定方式最大区别在于,stdcall约定由被调函数来恢复堆栈。显而易见,这种约定方式   可以使代码的体积要小一些。

fastcall调用约定:
  这种调用约定与stdcall差不多,区别在于,fastcall规定第一个和第二个比双字节小的参数通过寄存器传递   参数,而不通过压栈。寄存器要比内存快,固有fast之称。

thiscall调用约定:
  thiscall是C++成员函数默认的调用约定。由于成员函数中隐含this指针,所以对这个特殊参数做了特殊处理   规定:
a. 参数从右向左入栈。
b. 如果参数个数确定,this指针通过ecx传递给函数,并且函数自己恢复堆栈,类似stdcall方式。
c. 如果参数个数不确定,this指针在所有参数压栈后被压入堆栈,这相当于 T* const this是第一个参数,   调用者恢复堆栈,类似cdecl方式。

明白了这些常见的调用约定,再来看以上的汇编。要想调用变参的printf,需要遵循cdecl调用约定。 当我们写下 printf(“%d\n”, “hello world”) 时,先将”hello world”也即buffer入栈,然后是格式化字符串 output入栈。调用结束,由我们自己将栈保持平衡。