编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

初识x86_64汇编-栈(初识x86_64汇编-栈)

wxchong 2024-10-03 04:19:53 开源技术 67 ℃ 0 评论

栈是LIFO(后进先出)的一块内存区域。在此章描述了更加详细栈。

在X86_64我们有16个用于临时数据存储的通用寄存器。分别是RAX, RBX, RCX, RDX, RDI, RSI, RBP, RSP 和 R8-R15。对于一些程序这些寄存器显得太少了。所以我们使用栈来存储数据。经常在方法调用中将返回地址放入栈中,当方法执行的时候完成后,栈中的地址会放入到RIP寄存器中,这样CPU就能继续执行之前调用的方法。

例如:

global _start

section .text

_start:
		mov rax, 1
		call incRax
		cmp rax, 2
		jne exit
		;;
		;; Do something
		;;

incRax:
		inc rax
		ret

这个程序执行中,RAX寄存器的值为1,当我们通过使用call指令调用incRax方法后,RAX寄存器的值增加为2。在第8行,我们只用CMP指令将RAX寄存器和2进行比较,不相等则退出,相等就继续执行后面的指令。

从System V AMD64 ABI规范中,我们可以知道一个函数调用可以最多使用6个寄存器来传递参数,它们分别是:

  • rdi - 第1个参数
  • rsi - 第2个参数
  • rdx - 第3个参数
  • rcx - 第4个参数
  • r8 - 第5个参数
  • r9 - 第6个参数

那么如果我们需要传递更多的参数,那应该怎么办呢?这里就引出了我们这里导论的。我们使用栈来传递更多参数,例如:

int foo(int a1, int a2, int a3, int a4, int a5, int a6, int a7)
{
    return (a1 + a2 - a3 - a4 + a5 - a6) * a7;
}

这个函数前6个参数会通过寄存器来传递,第7个参数通过栈来传递。

栈指针

就像我们前面所说的,我们有16个通用寄存器,其中有两个比较特殊的寄存器用于特殊的目的,分别是RSP和RBP。RSP寄存器表示栈指针,它总是指向栈的顶部。而RBP则指向当前栈的底部。这两个寄存器的用途和32位系统上的不一样。

我们通过使用两个指令来操作栈:

  • push operand - 减少栈指针(RSP)并将操作数存放在栈指针指向的位置
  • pop operand - 将栈指针指向的数据拷贝到操作数,并增加栈指针

下面我们先看一个例子:

global _start

section .text

_start:
		mov rax, 1
		mov rdx, 2
		push rax
		push rdx

		mov rax, [rsp + 8]

		;;
		;; Do something
		;;

这段程序将1赋值为rax,将2赋值为rdx,并将这两个寄存器放入栈中。栈是一个LIFO(后进先出)的队列,栈通常从高地址向地址方向移动。当执行这个程序后,栈中的布局如下:

|              |          高地址
+--------------+
|              |            |
+--------------+            |
|     1        |           \ /
+--------------+  <------ RSP+8
|     2        |
+--------------+  <------ RSP
|              |
+--------------+
|              |          低地址

然后,我们从内存地址为rsp+8的值加载到rax寄存器中,这里由于我们执行push rdx后,rsp指向的栈顶,这里指向的就是存放2的内存地址,我们在这里加上8后指向的就是存放1的内存地址,所以最后rax的值为1。

例子

让我们看另外一个例子。这里例子从命令行中获取两个参数,将两个参数相加,然后打印出结果:

section .data
		SYS_WRITE equ 1
		STD_IN    equ 1
		SYS_EXIT  equ 60
		EXIT_CODE equ 0

		NEW_LINE   db 0xa
		WRONG_ARGC db "Must be two command line argument", 0xa

首先我们需要定义 .data节,并在其中定义一些值。这些常量表示系统调用的偏移量。我们同时也定义了2个字符串:第一个表示一个转移字符表示换行符,也是我们通常看到的"\n",第二个表示错误信息。

定义好数据后,让我们来看看代码段:

section .text
        global _start

_start:
		pop rcx
		cmp rcx, 3
		jne argcError

		add rsp, 8
		pop rsi
		call str_to_int

		mov r10, rax
		pop rsi
		call str_to_int
		mov r11, rax

		add r10, r11

下面我们来看看这里发生了什么:_start标签后的指令从栈中获取数据放入到rcx寄存器中。我们运行带参数的程序时,栈中的布局如下:

    [rsp] - top of stack will contain arguments count.
    [rsp + 8] - will contain argv[0]
    [rsp + 16] - will contain argv[1]
    and so on...

所以这段指令的意思是从栈中弹出表示多少个参数的值放入到rcx寄存器中。我们将rcx和3进行比较,如果不相等,那么程序打印错误信息后退出:

argcError:
    ;; sys_write syscall
    mov     rax, 1
    ;; file descritor, standard output
	mov     rdi, 1
    ;; message address
    mov     rsi, WRONG_ARGC
    ;; length of message
    mov     rdx, 34
    ;; call write syscall
    syscall
    ;; exit from program
	jmp exit

也许您会好奇为什么我们使用了2个参数,从栈中却得到3个参数,这是因为操作系统除了传递参数外,默认将执行的程序名称作为第一个参数。如果我们正好传递了2个参数,那么程序将会继续运行。将rsp增加8,这样就跳过了第一个参数(程序名称),此时,rsp指向了我们在命令行中传递的第一个参数,将其放入到rsi寄存器,然后调用转换函数将这个指针指向的值转换为整数。并将转换后的值放入到r10寄存器中,同理,第二个参数也是一样,只不过转换最后的结果放入了r11寄存器中。最后,将r10和r11相加就是最后运算的结果并存入r10中,然后将其打印出来。将结果打印出来之前必须将整数转换为字符串:

mov rax, r10
;; number counter
xor r12, r12
;; convert to string
jmp int_to_str

这里将相加的结果放入rax寄存器,将r12设置为0后调用int_to_str。到此,我们整个程序已经完成,还差转换函数的实现。下面我们详细描述两个转换函数str_to_intint_to_str

接下来我们就看看str_to_int的实现,先看看它的调用过程:

str_to_int:
            xor rax, rax
            mov rcx,  10
next:
	    cmp [rsi], byte 0
	    je return_str
	    mov bl, [rsi]
            sub bl, 48
	    mul rcx
	    add rax, rbx
	    inc rsi
	    jmp next

return_str:
	    ret

在函数的开始,我们首先将rax设置为0,rcx设置为10。然后执行next标签,就像我们之前描述的,我们在调用这个函数前,将参数1放入了rsi寄存器中。我们通过使用rsi指向的内存单元取值同0比较(这是因为字符串结束以NULL表示,而NULL就等于0),如果不等于0,我们就拷贝这个值到bl寄存器并将其减去48。为什么减去48?这是因为在ascii表中,数字和字符之间相差48,我们通过简单的将字符减去48就是对应的整数。我们每次循环10进制作为基数,将rax乘于rcx(rcx值为10)。执行一次循环后,将增加rsi以便于指向下一个字节,直到发现NULL字符,然后终止这个函数。这个算法很简单,例如如果rsi指向'5''7''6''\000',其执行步骤如下:

    rax = 0
    get first byte - 5 and put it to rbx
    rax * 10 --> rax = 0 * 10
    rax = rax + rbx = 0 + 5
    Get second byte - 7 and put it to rbx
    rax * 10 --> rax = 5 * 10 = 50
    rax = rax + rbx = 50 + 7 = 57
    and loop it while rsi is not \000

str_to_int函数将字符串转换为数字存放到rax寄存器中,接下来,我们看一下int_to_str:

		mov rdx, 0
		mov rbx, 10
		div rbx
		add rdx, 48
		add rdx, 0x0
		push rdx
		inc r12
		cmp rax, 0x0
		jne int_to_str
		jmp print

这个函数是和str_to_int相反的功能,从代码上也可以看出是str_to_int的反向实现。将rdx设置为0,rbx设置为10。rax中保存了之前代码中相加的结果。将rax除于rbx,rdx保存了相除了余数,将rdx加上48得到对应的字符,然后将转换后的值压入栈,增加r12(在之前我们将r12设置为了0),并将得到的商rax和0比较,如果不等于0,那么继续循环,否则调用print打印结果。例如我们打印数字123:

    123 / 10. rax = 12; rdx = 3
    rdx + 48 = "3"
    push "3" to stack
    compare rax with 0 if no go again
    12 / 10. rax = 1; rdx = 2
    rdx + 48 = "2"
    push "2" to stack
    compare rax with 0, if yes we can finish function execution and we will have "2" "3" ... in stack

我们实现了2个有用的转换函数。并将两个相加的结果转换为字符串放入了栈中,之后我们就可以打印结果了:

print:
	;;;; calculate number length
	mov rax, 1
	mul r12
	mov r12, 8
	mul r12
	mov rdx, rax

	;;;; print sum
	mov rax, SYS_WRITE
	mov rdi, STD_IN
	mov rsi, rsp
	;; call sys_write
	syscall

    jmp exit

我们已经知道如何使用sys_write系统调用打印字符串,但是我们现在还不知道需要打印的字符数量。所以我们必须计算打印字符串的长度。

int_to_str函数中,每一次迭代过程使用了r12寄存器统计了我们需要打印的数字量。必须将其乘以8(由于我们每一次都压入了1个8字节的寄存器)。最后,我们使用sys_write系统调用打印最终结果后执行以下代码退出:

exit:
	mov rax, SYS_EXIT
	exit code
	mov rdi, EXIT_CODE
	syscall

总结

我们这里详细描述了栈的使用过程,利用一个简单的例子从控制台读取两个参数,然后将其转换为数字后相加,最后将结果转换为字符打印出来,在这里编写的程序并没有考虑节省空间和提升效率,这里只是为了更好地演示,我们在编写程序时需要考虑更多的事情,例如提升空间使用率,提高效率,以及考虑各种异常情况,比如数字越界等等。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表