如今,几乎不需要用纯汇编程序编写代码,但是我绝对推荐给对编程感兴趣的任何人。 您会从不同的角度来看事情,并且在调试其他语言的代码时,技能会派上用场。
在本文中,我们将从头开始编写纯x86汇编器中的
反向波兰表示法(RPN)计算器。 完成后,我们可以像这样使用它:
$ ./calc "32+6*"
本文的所有代码都
在这里 。 它被大量注释掉,并且可以作为已经知道汇编程序的人的教材。
让我们开始编写基本的
Hello world程序! 检查环境设置。 然后,继续进行系统调用,调用堆栈,堆栈帧和x86调用约定。 然后,为了进行练习,我们将在x86汇编器中编写一些基本功能-并开始编写RPN计算器。
假定读者具有一定的C语言编程经验和计算机体系结构的基本知识(例如,什么是处理器寄存器)。 由于我们将使用Linux,因此您还应该能够使用Linux命令行。
环境设定
如前所述,我们使用Linux(64位或32位)。 上面的代码在Windows或Mac OS X上不起作用。
对于安装,您仅需要
binutils
的GNU
ld
链接器(它已预先安装在大多数发行版中)和NASM汇编器中。 在Ubuntu和Debian上,您可以使用一个命令来安装这两者:
$ sudo apt-get install binutils nasm
我还建议您方便
使用ASCII表 。
世界,您好!
要验证环境,请将以下代码保存在
calc.asm
文件中:
; _start ; . global _start ; .rodata ( ) ; , section .rodata ; hello_world. NASM ; , , ; . 0xA = , 0x0 = hello_world: db "Hello world!", 0xA, 0x0 ; .text, section .text _start: mov eax, 0x04 ; 4 eax (0x04 = write()) mov ebx, 0x1 ; (1 = , 2 = ) mov ecx, hello_world ; mov edx, 14 ; int 0x80 ; 0x80, ; mov eax, 0x01 ; 0x01 = exit() mov ebx, 0 ; 0 = int 0x80
注释解释了一般结构。 有关寄存器列表和一般说明,请参见《
弗吉尼亚大学x86汇编程序指南》 。 随着对系统调用的进一步讨论,这将变得更加必要。
以下命令将汇编程序文件收集到目标文件中,然后编译可执行文件:
$ nasm -f elf_i386 calc.asm -o calc $ ld -m elf_i386 calc.o -o calc
启动后,您应该看到:
$ ./calc Hello world!
生成文件
这是一个可选部分,但是您可以制作一个
Makefile
来简化将来的构建和布局。 将其保存在与
calc.asm
相同的目录中:
CFLAGS= -f elf32 LFLAGS= -m elf_i386 all: calc calc: calc.o ld $(LFLAGS) calc.o -o calc calc.o: calc.asm nasm $(CFLAGS) calc.asm -o calc.o clean: rm -f calc.o calc .INTERMEDIATE: calc.o
然后,代替上面的说明,只需运行make。
系统调用
Linux系统调用告诉OS为我们做些事情。 在本文中,我们仅使用两个系统调用:
write()
将行写入文件或流(在我们的示例中,这是标准输出设备和标准错误),而
exit()
退出程序:
syscall 0x01: exit(int error_code) error_code - 0 ( 1) syscall 0x04: write(int fd, char *string, int length) fd — 1 , 2 string — length —
通过将系统调用号存储在
eax
寄存器中,然后将其参数依次存储在
ebx
,
ecx
和
edx
中,来配置系统调用。 您可能会注意到
exit()
仅
exit()
一个参数-在这种情况下ecx和edx无关紧要。
ax | 埃克斯 | ecx | edx |
---|
系统电话号码 | arg1 | arg2 | arg3 |
调用堆栈

调用堆栈是一种数据结构,用于存储有关对函数的每次调用的信息。 每个调用在堆栈中都有其自己的部分-“框架”。 它存储有关当前调用的一些信息:此函数的局部变量和返回地址(函数执行后程序应到达的位置)。
我立即注意到一个不明显的事情:堆栈
减少了内存。 当您将某些内容添加到堆栈的顶部时,它会插入到比上一项低的内存地址中。 换句话说,随着堆栈的增长,堆栈顶部的内存地址会减少。 为避免混淆,我将始终提醒您这个事实。
push
指令将某些东西
push
堆栈顶部,然后
pop
从那里弹出数据。 例如,
push
在堆栈的顶部分配一个位置,并将
eax
寄存器中的值放在此处,而
pop
将所有数据从堆栈的顶部传送到
eax
并释放此内存区域。
esp
寄存器的目的是指向堆栈的顶部。 超过
esp
任何数据都
esp
认为不会到达堆栈,这是垃圾数据。 执行
push
(或
pop
)语句会移动
esp
。 如果对操作进行报告,则可以直接操作
esp
。
ebp
寄存器与
esp
相似,只是它总是始终指向当前堆栈帧的中间,紧接当前函数的局部变量之前(我们将在后面讨论)。 但是,调用另一个函数不会自动移动
ebp
,必须每次手动完成。
X86体系结构调用约定
在x86中,没有像高级语言中的内置函数概念。
call
goto
基本上只是到另一个内存地址的
jmp
(
goto
)。 要将例程用作其他语言的函数(可以接受参数并返回数据),则需要遵循调用约定(有很多约定,但我们使用CDECL,这是C编译器和汇编程序员中x86最受欢迎的约定)。 它还可以确保在调用另一个函数时不会混淆常规寄存器。
来电者规则
在调用函数之前,调用者必须:
- 将调用者需要保存的寄存器保存到堆栈中。 被调用的函数可以更改某些寄存器:为了不丢失数据,调用者必须将其保存在内存中,直到将其压入堆栈为止。 这些是
eax
, ecx
和edx
。 如果您不使用它们中的任何一个,那么您将无法保存它们。 - 以相反的顺序将函数参数写入堆栈(第一个最后一个参数,最后一个第一个参数)。 此顺序可确保被调用函数以正确的顺序从堆栈接收其参数。
- 调用子程序。
如果可能,该函数会将结果保存在
eax
。
call
呼叫者应立即:
- 从堆栈中删除函数参数。 这通常是通过简单地将字节数添加到
esp
。 不要忘了堆栈会变小,因此要从堆栈中删除,必须添加字节。 - 通过以相反的顺序从堆栈弹出保存的寄存器来恢复它们。 被调用的函数不会更改任何其他寄存器。
下面的示例演示如何应用这些规则。 假定
_subtract
函数采用两个整数(4字节)参数,并返回第一个参数减去第二个参数。 在
_mysubroutine
子例程中
_mysubroutine
使用参数
10
和
2
调用
_subtract
:
_mysubroutine: ; ... ; - ; ... push ecx ; ( eax) push edx push 2 ; , push 10 call _subtract ; eax 10-2=8 add esp, 8 ; 8 ( 4 ) pop edx ; pop ecx ; ... ; - , eax ; ...
被调用程序的规则
在调用之前,子例程必须:
- 通过将前一帧的
ebp
基址寄存器指针写入堆栈来保存它。 - 将前一帧的
ebp
调整为当前(当前esp
值)。 - 在堆栈上为局部变量分配更多空间,如有必要,请移动
esp
指针。 随着堆栈的减少,您需要从esp
减去丢失的内存。 - 将被调用例程的寄存器保存到堆栈中。 这些是
ebx
, edi
和esi
。 不必保存未计划更改的寄存器。
步骤1之后调用堆栈:

步骤2之后的呼叫堆栈:

在第4步之后调用堆栈:

在这些图中,在每个堆栈帧中都指示了返回地址。 它通过
call
语句自动推入堆栈。
ret
从堆栈顶部检索地址并跳转到该地址。 我们不需要此指令,我只是说明了为什么函数的局部变量比
ebp
4个字节,而函数的参数比
ebp
低8个字节。
在最后一张图中,您还可以注意到该函数的局部变量总是从
ebp-4
地址的
ebp-4
上方开始4个字节(此处减去,因为我们正在向上移动堆栈),并且该函数的参数总是从
ebp+8
地址的
ebp+8
下方8个字节开始
ebp+8
(此外,因为我们要向下移动堆栈)。 如果您遵循此约定的规则,那么任何函数的变量和参数都将遵循这种约定。
函数完成并要返回时,如果需要,必须首先将
eax
设置为函数的返回值。 此外,您还需要:
- 通过以相反的顺序从堆栈弹出保存的寄存器来恢复它们。
- 如有必要,释放由局部变量在步骤3中分配的堆栈上的空间:只需在
esp
中安装esp
- 通过从堆栈中弹出来恢复前一帧的
ebp
基本指针。 - 退货退货
现在我们从示例中实现
_subtract
函数:
_subtract: push ebp ; mov ebp, esp ; ebp ; , ; , ; ; mov eax, [ebp+8] ; eax. ; ebp+8 sub eax, [ebp+12] ; ebp+12 ; ; , eax ; , ; , pop ebp ; ret
出入境
在上面的示例中,您会注意到该函数始终以相同的方式运行:
push ebp
,
mov ebp
,
esp
和局部变量的内存分配。 x86集具有执行所有操作的便捷指令:
enter ab
,其中
a
是要分配给局部变量的字节数,
b
是“嵌套级别”,我们将始终将其设置为
0
。 此外,该函数始终以
pop ebp
和
mov esp
,
ebp
指令结尾(尽管它们仅在为局部变量分配内存时才是必需的,但在任何情况下都没有害处)。 也可以用一个语句代替:
leave
。 我们进行更改:
_subtract: enter 0, 0 ; ebp ; , ; ; mov eax, [ebp+8] ; eax. ; ebp+8 sub eax, [ebp+12] ; ebp+12 ; ; , eax ; , leave ; ret
编写一些基本功能
掌握了调用约定后,就可以开始编写一些例程了。 为什么不对显示“ Hello world!”的代码进行泛化呢?要输出任何行:
_print_msg
函数。
在这里,我们需要另一个
_strlen
函数来计算字符串的长度。 在C中,它可能看起来像这样:
size_t strlen(char *s) { size_t length = 0; while (*s != 0) {
换句话说,从该行的最开始,我们为除零之外的每个字符的返回值加1。 一旦注意到空字符,我们将返回循环中累积的值。 在汇编程序中,这也非常简单:您可以使用先前编写的
_subtract
函数作为基础:
_strlen: enter 0, 0 ; ebp ; , ; ; mov eax, 0 ; length = 0 mov ecx, [ebp+8] ; ( ; ) ecx ( ; , ) _strlen_loop_start: ; , cmp byte [ecx], 0 ; . ; 32 (4 ). ; . ; ( ) je _strlen_loop_end ; inc eax ; , 1 add ecx, 1 ; jmp _strlen_loop_start ; _strlen_loop_end: ; , eax ; , leave ; ret
已经不错了吧? 首先编写C代码可能会有帮助,因为大多数代码都直接转换为汇编程序。 现在,您可以在
_print_msg
使用此函数,我们将在其中应用所获得的所有知识:
_print_msg: enter 0, 0 ; mov eax, 0x04 ; 0x04 = write() mov ebx, 0x1 ; 0x1 = mov ecx, [ebp+8] ; , ; edx . _strlen push eax ; ( edx) push ecx push dword [ebp+8] ; _strlen _print_msg. NASM ; , , , . ; dword (4 , 32 ) call _strlen ; eax mov edx, eax ; edx, add esp, 4 ; 4 ( 4- char*) pop ecx ; pop eax ; _strlen, int 0x80 leave ret
在完整的程序“ Hello,world!”中使用此功能,可以看到我们辛勤工作的成果。
_start: enter 0, 0 ; ( ) push hello_world ; _print_msg call _print_msg mov eax, 0x01 ; 0x01 = exit() mov ebx, 0 ; 0 = int 0x80
信不信由你,我们涵盖了编写基本x86汇编程序所需的所有主要主题! 现在我们已经有了所有的入门材料和理论,因此我们将完全专注于代码并应用所学到的知识来编写我们的RPN计算器。 函数将更长,甚至会使用一些局部变量。 如果您想立即查看完成的程序,
则为 。
对于不熟悉反向波兰表示法(有时称为反向波兰表示法或后缀表示法)的人,此处的表达式是使用堆栈求值的。 因此,您需要创建一个堆栈以及
_pop
和
_push
来操作该堆栈。 您还将
_print_answer
函数,该函数将在计算结束时输出数字结果的字符串表示形式。
堆栈创建
首先,我们为堆栈定义内存空间,以及全局变量
stack_size
。 建议更改这些变量,以使它们不属于
.rodata
部分,而属于
.data
。
section .data stack_size: dd 0 ; dword (4 ) 0 stack: times 256 dd 0 ;
现在,您可以实现
_push
和
_pop
:
_push: enter 0, 0 ; , push eax push edx mov eax, [stack_size] mov edx, [ebp+8] mov [stack + 4*eax], edx ; . ; dword inc dword [stack_size] ; 1 stack_size ; pop edx pop eax leave ret _pop: enter 0, 0 ; dec dword [stack_size] ; 1 stack_size mov eax, [stack_size] mov eax, [stack + 4*eax] ; eax ; , leave ret
号码输出
_print_answer
更为复杂:您必须将数字转换为字符串并使用其他几个功能。 您将
_putc
函数(输出一个字符),
mod
函数(用于计算两个参数的除法(模块)的余数)和
_pow_10
以10的幂为单位)。 这很简单,下面是代码:
_pow_10: enter 0, 0 mov ecx, [ebp+8] ; ecx ( ) ; mov eax, 1 ; 10 (10**0 = 1) _pow_10_loop_start: ; eax 10, ecx 0 cmp ecx, 0 je _pow_10_loop_end imul eax, 10 sub ecx, 1 jmp _pow_10_loop_start _pow_10_loop_end: leave ret _mod: enter 0, 0 push ebx mov edx, 0 ; mov eax, [ebp+8] mov ebx, [ebp+12] idiv ebx ; 64- [edx:eax] ebx. ; 32- eax, edx ; . ; eax, edx. , ; , ; . mov eax, edx ; () pop ebx leave ret _putc: enter 0, 0 mov eax, 0x04 ; write() mov ebx, 1 ; lea ecx, [ebp+8] ; mov edx, 1 ; 1 int 0x80 leave ret
那么,我们如何得出数字中的单个数字? 首先,请注意,数字的最后一位是除以10的余数(例如
123 % 10 = 3
),下一位是除以100并除以10的余数(例如
(123 % 100)/10 = 2
)。 通常,您可以通过找到
( % 10**n) / 10**(n-1)
来找到数字的特定位数(从右到左),其中单位数为
n = 1
,十位数为
n = 2
依此类推。
使用此知识,您可以找到数字的所有位,从
n = 1
到
n = 10
(这是有符号4字节整数中的最大位数)。 但是,从左到右移动要容易得多-因此我们可以在找到每个字符后立即打印每个字符,并消除左侧的零。 因此,我们对
n = 10
到
n = 1
的数字进行排序。
在C语言中,程序将如下所示:
#define MAX_DIGITS 10 void print_answer(int a) { if (a < 0) { // putc('-'); // «» a = -a; // } int started = 0; for (int i = MAX_DIGITS; i > 0; i--) { int digit = (a % pow_10(i)) / pow_10(i-1); if (digit == 0 && started == 0) continue; // started = 1; putc(digit + '0'); } }
现在您了解了我们为什么需要这三个功能。让我们在汇编器中实现这一点: %define MAX_DIGITS 10 _print_answer: enter 1, 0 ; 1 "started" C push ebx push edi push esi mov eax, [ebp+8] ; "a" cmp eax, 0 ; , ; jge _print_answer_negate_end ; call putc for '-' push eax push 0x2d ; '-' call _putc add esp, 4 pop eax neg eax ; _print_answer_negate_end: mov byte [ebp-4], 0 ; started = 0 mov ecx, MAX_DIGITS ; i _print_answer_loop_start: cmp ecx, 0 je _print_answer_loop_end ; pow_10 ecx. ebx "digit" C. ; edx = pow_10(i-1), ebx = pow_10(i) push eax push ecx dec ecx ; i-1 push ecx ; _pow_10 call _pow_10 mov edx, eax ; edx = pow_10(i-1) add esp, 4 pop ecx ; i ecx pop eax ; end pow_10 call mov ebx, edx ; digit = ebx = pow_10(i-1) imul ebx, 10 ; digit = ebx = pow_10(i) ; _mod (a % pow_10(i)), (eax mod ebx) push eax push ecx push edx push ebx ; arg2, ebx = digit = pow_10(i) push eax ; arg1, eax = a call _mod mov ebx, eax ; digit = ebx = a % pow_10(i+1), almost there add esp, 8 pop edx pop ecx pop eax ; mod ; ebx ( "digit" ) pow_10(i) (edx). ; , idiv edx, eax. ; edx , - ; push esi mov esi, edx push eax mov eax, ebx mov edx, 0 idiv esi ; eax () mov ebx, eax ; ebx = (a % pow_10(i)) / pow_10(i-1), "digit" C pop eax pop esi ; end division cmp ebx, 0 ; digit == 0 jne _print_answer_trailing_zeroes_check_end cmp byte [ebp-4], 0 ; started == 0 jne _print_answer_trailing_zeroes_check_end jmp _print_answer_loop_continue ; continue _print_answer_trailing_zeroes_check_end: mov byte [ebp-4], 1 ; started = 1 add ebx, 0x30 ; digit + '0' ; putc push eax push ecx push edx push ebx call _putc add esp, 4 pop edx pop ecx pop eax ; putc _print_answer_loop_continue: sub ecx, 1 jmp _print_answer_loop_start _print_answer_loop_end: pop esi pop edi pop ebx leave ret
这是一个艰难的考验!希望评论有助于解决。如果您现在认为:“您为什么不能编写printf("%d")
?”,那么您将喜欢本文的结尾,在此我们将函数替换为!现在,我们拥有所有必需的功能,剩下的就是在其中实现基本逻辑了,仅此而已_start
!反向波兰语符号计算
如前所述,反向波兰语表示法是使用堆栈进行计算的。读取时,将数字压入堆栈,读取时,将运算符应用于堆栈顶部的两个对象。例如,如果我们要计算84/3+6*
(此表达式也可以以形式编写6384/+*
),则过程如下:步骤 | 记号 | 叠前 | 叠后 |
---|
1个 | 8 | [] | [8] |
2 | 4 | [8] | [8, 4] |
3 | / | [8, 4] | [2] |
4 | 3 | [2] | [2, 3] |
5 | + | [2, 3] | [5] |
6 | 6 | [5] | [5, 6] |
7 | * | [5, 6] | [30] |
, — , . 30.
C:
int stack[256];
, , .
_start: ; _start , . ; esp argc ( ), ; esp+4 argv. , esp+4 ; , esp+8 - mov esi, [esp+8] ; esi = "input" = argv[0] ; _strlen push esi call _strlen mov ebx, eax ; ebx = input_length add esp, 4 ; end _strlen call mov ecx, 0 ; ecx = "i" _main_loop_start: cmp ecx, ebx ; (i >= input_length) jge _main_loop_end mov edx, 0 mov dl, [esi + ecx] ; ; edx. edx . ; edx = c = input[i] cmp edx, '0' jl _check_operator cmp edx, '9' jg _print_error sub edx, '0' mov eax, edx ; eax = c - '0' (, ) jmp _push_eax_and_continue _check_operator: ; _pop b edi, a b - eax push ecx push ebx call _pop mov edi, eax ; edi = b call _pop ; eax = a pop ebx pop ecx ; end call _pop cmp edx, '+' jne _subtract add eax, edi ; eax = a+b jmp _push_eax_and_continue _subtract: cmp edx, '-' jne _multiply sub eax, edi ; eax = ab jmp _push_eax_and_continue _multiply: cmp edx, '*' jne _divide imul eax, edi ; eax = a*b jmp _push_eax_and_continue _divide: cmp edx, '/' jne _print_error push edx ; edx, idiv mov edx, 0 idiv edi ; eax = a/b pop edx ; eax _push_eax_and_continue: ; _push push eax push ecx push edx push eax ; call _push add esp, 4 pop edx pop ecx pop eax ; call _push inc ecx jmp _main_loop_start _main_loop_end: cmp byte [stack_size], 1 ; (stack_size != 1), jne _print_error mov eax, [stack] push eax call _print_answer ; print a final newline push 0xA call _putc ; exit successfully mov eax, 0x01 ; 0x01 = exit() mov ebx, 0 ; 0 = int 0x80 ; _print_error: push error_msg call _print_msg mov eax, 0x01 mov ebx, 1 int 0x80
error_msg
.rodata
:
section .rodata ; error_msg. db NASM ; , ; . 0xA = , 0x0 = error_msg: db "Invalid input", 0xA, 0x0
! , . , , , , , RollerCoaster Tycoon!
. ! , .
, :
- segfault , .
- .
- .
- .
_strlen
C , _print_answer
printf
.