因为想在寒假手写一下操作系统玩玩,所以提前学一学汇编,到时候放假就可以直接上手写了。
由于处理器只能理解机器语言指令,即 0 和 1 组成的字符串。然而,机器语言对软件开发来说过于晦涩和复杂。因此,低级汇编语言是为特定的处理器系列而设计的,通过符号代码和更易于理解的形式表示各种指令。
学习使用汇编语言可以让人深入了解计算机体现结构和底层硬件工作原理,提供对计算机内部操作的更详细的了解。
我们可以收获:
更加深入了解计算机的体系结构、寄存器、指令集和内存管理
了解程序是如何在计算机上执行的
有助于我们编写更高效的代码,可以直接控制底层硬件资源,使你能够优化代码以提高程序的性能
提高我们调试代码的能力
PC 的主要硬件由处理器、存储器和寄存器组成。寄存器是保存数据和地址的处理器组件。为了执行程序,系统将其从外部设备复制到内部存储器中。处理器负责执行程序指令。
处理器支持以下数据大小:
单词:2 字节数据项
双字:4 字节(32 位)数据项
四字:8 字节(64 位)数据项
段落:16 字节(128 位)区域
千字节:1024 字节
兆字节:1,048,576 字节
处理器控制指令执行的过程称为——执行周期,它通常包含以下几个阶段:
取指令:处理器从内存中读取下一条指令的地址,并将指令加载到指令寄存器中
译码:处理器对取得的指令进行译码,确定指令的操作类型和操作数
执行:处理器执行指令的操作,可能涉及算术运算、逻辑运算、数据传输等
写回:将执行结果写回到寄存器或内存中,更新存储器或寄存器内容
在计算机中,处理器在访问内存时以字节为单位进行操作。考虑一个十六进制数 0725H,它需要两个字节的内存来存储。其中,高位字节或最高有效字节为 07,低位字节为 25.
:::warning
需要注意的是,处理器以相反的字节顺序存储数据,即低位字节存储在低内存地址中,高位字节存储在高内存地址中。因此,当处理器将值 0725H 从寄存器传输到内存时,它首先将 25 传输到较低的内存地址,然后将 07 传输到下一个内存地址。
当处理器从内存获取数字数据到寄存器时,它会再次反转字节。这个过程中,有两种内存地址形式:
绝对地址:这是具体位置的直接引用,表示数据存储在内存的特定地址。
例如:如果 x 表示内存地址,则数据 0725H 存储在 x 地址和 x + 1 地址上,分别对应低位字节 25 和高位字节 07.
段地址(或偏移量):这是具有偏移值的内存段的起始地址。段地址与偏移量相结合,给出了实际的内存地址。
例如:如果有一个段地址 y,那么 x 表示内存中的偏移地址。在这种情况下,数据 0725H 存储在地址 y:x 和 y:x+1 上,分别对应低位字节 25 和 高位字节 07.
:::
在学习过程中,我们需要使用 NASM 汇编器,因为它免费、有据可查,并且可以在 Linux 和 Windows 上使用
首先要验证是否已安装 NASM,可以使用下面的方法验证:
打开 Linux 终端
输入 whereis nasm 并按 Enter
如果已安装会出现类似 nasm: /usr/bin/nasm
的行,否则只能看到 nasm:
如果没有安装,就需要安装 NASM。
我的机器是 centos7 的虚拟机,我是直接在命令行中进行安装的,安装步骤如下:
打开终端并以 root 用户身份登录
运行以下命令更新 yum 软件包列表:
yum update
运行以下命令安装 NASM:
yum install nasm
过程中的询问,输入 y 并按 Enter 键继续
等待安装完成后,使用以下命令验证是否成功安装:
nasm -v
汇编程序可以分为三个部分:
数据部分(data section)
未初始化数据部分(bss section)
文本部分(text section)
数据部分通常用于存储程序中需要初始化的数据。这可以包括常量、变量和其他静态数据。这个部分的数据在程序运行之前被初始化,并且在整个程序的执行过程中保持不变。
声明数据部分的语法如下:
section .data
未初始化数据部分用于存储程序中未初始化的全局和静态变量。与数据部分不同,bss 部分的变量在程序加载时不会被初始化,而是在运行时由系统初始化为零或空值。这样可以节省可执行文件的大小,因为在文件中只需要记录这些变量的名称和大小,而不需要存储它们的实际值。
声明 bss 部分的语法如下:
section .bss
文本部分包含程序的实际代码。这是程序的主要执行部分,包括机器指令和指令的地址。在这个部分,汇编程序将源代码翻译成机器可执行的指令,使得计算机能够按照特定的算法执行相应的操作。
声明 文本部分的语法如下:
section .text
汇编语言中的注释以分号;
开头。注释可以独立一行存在,也可以与指令在同一行。例如:
; This is a line of comments add eax, ebx ; adds ebx to eax
汇编语言程序由三种类型的语句组成:
可执行指令:告诉处理器要执行的操作,每条指令包括操作码和操作数
汇编器指令或伪操作:用于影响汇编过程的方面,它们不会生成机器语言指令
宏:一种文本替换机制
汇编语言语句每行输入一个语句,每个语句都遵循以下格式:
[label] ? mnemonic ? [operands] ? [;comment]
方括号中的字段是可选的。
基本指令由两部分组成,第一部分是哟啊执行的指令名词(或助记符),第二部分是命令的操作数或参数。
以下是一些典型汇编语言语句的示例:
MOV指令(数据传送):
MOV AX, 42 ? ? ? ; 将值42存储到寄存器AX中 MOV BX, AX ? ? ? ; 将寄存器AX的值传送到寄存器BX中
ADD和SUB指令(加法和减法):
ADD AX, BX ; 将寄存器AX和BX中的值相加,并将结果存储在AX中 SUB CX, 10 ; 从寄存器CX中减去值10,并将结果存储在CX中
CMP和JMP指令(比较和跳转):
CMP AX, BX ; 比较寄存器AX和BX的值 JE label ; 如果相等,则跳转到标签label处 JG another_label ; 如果大于,则跳转到另一个标签another_label处 JL target_label ; 如果小于,跳转到目标标签
INC和DEC指令(递增和递减):
INC SI ; 将寄存器SI中的值递增1 DEC CX ; 将寄存器CX中的值递减1
LOOP指令(循环):
MOV CX, 5 ; 设置循环计数器CX的初始值为5 loop_start: ; 循环开始标签 ; 循环体代码 DEC CX ; 循环计数器递减1 JNZ loop_start; 如果计数器不为零,则跳转到循环开始标签
section .data msg db 'Hello, world!', 0xa ; 要打印的字符串,0xa 是换行符 len equ $ - msg ; 字符串的长度 section .text global _start ; 必须为链接器(ld)声明的全局入口点 _start: ; 告诉链接器入口点 ; write message to stdout mov eax, 4 ; 系统调用号(sys_write) mov ebx, 1 ; 文件描述符(标准输出) mov ecx, msg ; 要写入的消息 mov edx, len ; 消息的长度 int 0x80 ; 调用内核 ; exit the program mov eax, 1 ; 系统调用号(sys_exit) xor ebx, ebx ; 返回码为0 int 0x80 ; 调用内核
上面的代码被编译并执行后,会输出如下内容:
Hello, world!
为了能让上面的程序运行起来,我们需要按下面的步骤编译和链接上述程序:
使用文本编译器输入上述代码并将其保存为 hello.asm,后续的操作都在该目录下进行
输入 nasm -f elf hello.asm
编译汇编程序
-f elf:
这是 NASM 的一个选项,用于指定生成的目标文件的格式。在这里,elf
表示目标文件将采用 ELF(Executable and Linkable Format)格式。
ELF 是一种通用的二进制文件格式,用于可执行文件、目标文件和共享库。
如果程序没有问题,就会程序名为 hello.o 的程序目标文件
输入 ld -m elf_i386 -s -o hello hello.o
命令,链接目标文件并创建名为 hello 的可执行文件
ld
: 这是链接器的命令。链接器的作用是将多个目标文件链接在一起,解析符号引用,生成最终的可执行文件。在执行该命令时,链接器会将系统库和其他必要运行时库链接到目标文件 hello.o
中。我们的代码中由于程序只是在标准输出上打印一条消息,因此系统库中的一些 I/O 相关的函数可能被链接进来,以便程序能够正确地执行。
-m elf_i386
: 这个选项告诉链接器使用 ELF (Executable and Linkable Format) 文件格式,并且生成 32 位 x86 架构的可执行文件。elf_i386
表示生成的可执行文件是面向 32 位 x86 架构的 ELF 文件。
-s
: 这个选项用于剥离(strip)可执行文件中的符号表信息。符号表包含了程序中定义的各种符号(如变量、函数名等)的信息。在生产环境中,剥离符号表可以减小可执行文件的大小,但同时也会使得可执行文件不易调试。
-o hello
: 这个选项指定生成的可执行文件的输出名称为 hello
。-o
是指定输出文件的选项,后面跟着输出文件的名称。
hello.o
: 这是输入的目标文件,它是由 NASM 编译器生成的,包含了汇编代码的机器代码。
最后通过 ./hello
执行程序
上面讨论的汇编程序的三个部分,也代码各种内存段。
有趣的是,如果将 section 关键字替换为 segment,将会得到相同的结果,这是因为对于汇编器而言,这两个关键字在某些上下文中是可以互相使用的,这两个关键字都是为了告诉汇编器下面的代码是代码段。
在分段内存模型中,系统内存被划分为不同的独立段组,每个段组由位于段寄存器中的指针引用。
每个段用于包含特定类其型的数据。其中一个段用于包含指令代码,另一个段用于存储数据元素,第三个段用于保存程序堆栈。
这种划分使得程序可以更灵活地管理内存,有选择地引用不同类型的数据和指令,从而更有效地执行各种计算任务。
因此,我们可以将各种内存段指定为:
数据段:由 .data 部分和 .bss 部分表示。 .data 部分用于声明内存区域,其中为程序存储数据元素,该部分在数据元素声明后无法扩展,并且在整个程序中保持静态。.bss 部分也是一个静态内存部分,其中包含稍后在程序中声明的数据的缓冲区。该缓冲区被零填充。
代码段:它由 .text 部分表示。这定义了内存中存储指令代码的区域。这也是一个固定区域。
堆栈:该段包含传递给程序内的函数和过程的数据值。
处理器操作主要涉及对数据的处理,而数据通常存储在内存中。然而,内存访问可能会降低处理器速度,因为它需要通过控制总线发送请求并进行复杂的内存访问。
为了提高速度,处理器包含一些内部存储位置,称为寄存器。
IA-32架构中包含 10 个 32 位和 6 个 16 位的处理器寄存器,主要分为三类:
通用寄存器:通用寄存器用于存储临时数据,进行算术、逻辑运算等操作。
控制寄存器:控制寄存器用于控制和反映处理器的状态。
段寄存器:段寄存器用于存储各个段的起始地址,实现内存访问和管理。
通用寄存器进一步可以分为:
数据寄存器
指针寄存器
索引寄存器
在IA-32架构中,有四个32位的数据寄存器,分别是EAX、EBX、ECX、EDX。这些寄存器可以按照不同的位数划分为更小的寄存器,具体如下:
作为完整的32位数据寄存器:EAX、EBX、ECX、EDX。
32 位寄存器的下半部分可用作四个 16 位数据寄存器:AX、BX、CX 和 DX。
上述4个16位寄存器的下半部分和上半部分可以用作8个8位数据寄存器:AH、AL、BH、BL、CH、CL、DH和 DL 。
一些数据寄存器在算术运算中具有特定用途:
AX: 主累加器,用于输入/输出和大多数算术指令。例如,在乘法运算中,根据操作数的大小将一个操作数存储在EAX或AX或AL寄存器中。
BX: 被称为基址寄存器,用于索引寻址。
CX: 被称为计数寄存器,与ECX一样,存储迭代操作中的循环计数。
DX: 数据寄存器,用于输入/输出操作,与AX寄存器和DX一起使用,用于涉及大值的乘法和除法运算。
指针寄存器是指 32 位的 EIP、ESP 和 EBP 寄存器以及相应的 16 位 右部分 IP、SP 和 BP。
指针寄存器可以分为三类:
指令指针(IP):16 位 IP 寄存器存储下一条要执行的指令的偏移地址。 IP 与 CS 寄存器(代码段)(如CS : IP)关联,给出了代码段中当前指令的完整地址。
堆栈指针(SP): 16 位 SP 寄存器提供程序堆栈内的偏移值。 SP 与 SS 寄存器(堆栈段)(SS:SP)相关,指的是程序堆栈中数据或地址的当前位置。
基址指针(BP): 16 位 BP 寄存器主要帮助引用传递给子程序的参数变量。 SS 寄存器中的地址与 BP 中的偏移量相结合,得到参数的位置。 BP 还可以与 DI、SI(索引寄存器) 组合作为基址寄存器进行特殊寻址。
索引寄存器包括32位的 ESI 和 EDI 以及它们的 16 位最右边的部分。SI 和 DI 通常用于索引寻址,并有时用于执行加法和减法操作。这两个索引指针分别是:
来源索引 (SI): 用作字符串操作的源索引。在字符串处理中,SI通常用于指向源字符串的当前位置。
目的地索引 (DI): 用作字符串操作的目标索引。DI通常用于指向目标字符串的当前位置,特别是在字符串复制等操作中。
控制寄存器包括 32 位指令指针寄存器和 32 位标志寄存器,用于管理程序的执行流程和状态。其中的常见标志位有:
溢出标志 (OF): 表示有符号算术运算后数据的高位(最左位)是否溢出。
方向标志 (DF): 确定移动或比较字符串数据的左或右方向。DF为0时,字符串操作从左到右;DF为1时,字符串操作从右到左。
中断标志 (IF): 决定是否忽略或处理外部中断,如键盘输入。IF为0时禁用外部中断,为1时启用中断。
陷阱标志 (TF): 允许将处理器设置为单步模式,以便一次执行一条指令,常用于调试。
符号标志 (SF): 显示算术运算结果的符号,由最左边位的高位表示。SF为0表示正结果,为1表示负结果。
零标志 (ZF): 表示算术或比较运算的结果是否为零。ZF为1表示零结果,为0表示非零结果。
辅助进位标志 (AF): 包含算术运算后从位 3 到位 4 的进位,用于特殊的算术操作。
奇偶校验标志 (PF): 表示算术运算结果中1位的总数,用于奇偶校验。PF为1表示奇数个1位,为0表示偶数个1位。
进位标志 (CF): 包含算术运算后从高位(最左边)的进位,也存储shift或rotate操作的最后一位内容。
段在计算机内存中是为了组织和管理存储空间而引入的概念。在汇编编程中,处理器通过段寄存器来访问内存位置。以下是关于段的主要信息:
代码段(CS):
包含要执行的指令的区域。
由 16 位代码段寄存器(CS 寄存器)存储代码段的起始地址。
数据段(DS):
包含数据、常量和工作区的区域。
由 16 位数据段寄存器(DS 寄存器)存储数据段的起始地址。
堆栈段(SS):
包含过程或子例程的数据和返回地址,实现为堆栈数据结构。
由16位堆栈段寄存器(SS 寄存器)存储堆栈的起始地址。
其他段寄存器:
额外段(ES): 提供额外的段来存储数据。
FS 和 GS: 提供额外的段用于特定目的。
在汇编编程中,程序需要访问内存位置。段内的所有内存位置都相对于段的起始地址。段从可被 16 整除的地址开始,因此所有这类内存地址中最右边的十六进制数字通常是 0。为了引用段中的任何内存位置,处理器将段寄存器中的段地址与该位置的偏移值组合起来。
下面的程序会在代码中输出 9 个连续的星号。
section .text global _start ;必须为链接器声明(gcc) _start: ;告诉链接器入口点 mov edx,len ;消息长度 mov ecx,msg ;要写入的消息 mov ebx,1 ;文件描述符(stdout) mov eax,4 ;系统调用号(sys_write) int 0x80 ;调用内核 mov edx,9 ;消息长度 mov ecx,s2 ;要写入的消息 mov ebx,1 ;文件描述符(stdout) mov eax,4 ;系统调用号(sys_write) int 0x80 ;调用内核 mov eax,1 ;系统调用号(sys_exit) int 0x80 ;调用内核 section .data msg db 'Displaying 9 stars',0xa ;一条消息 len equ $ - msg ;消息的长度 s2 times 9 db '*' ;9个星号
我们使用以下命令进行编译和执行:
nasm -f elf nine_stars.asm ld -m elf_i386 -s -o nine_stars nine_stars.o
输出结果如下:
Displaying 9 stars *********
系统调用是用户空间和内核空间之间接口的 API。我们之前已经使用过了 sys_write 和 sys_exit 这两个系统调用,分别用于写入屏幕和退出程序。
我们在汇编程序中使用系统调用,需要按照如下步骤:
将系统调用号放入 EAX 寄存器中;
将系统调用的参数存储在 EBX、ECX 等寄存器中
调用相关中断(0x80),然后执行 EAX 中的系统调用号对应的程序
结果通常返回 EAX 寄存器中
可以存储系统调用参数的存储器有 基址寄存器 EBX、计数寄存器 ECX、数据寄存器 EDX、源索引寄存器 ESI、目标索引寄存器 EDI、基址指针寄存器 EBP。
下面给大家演示一下几个示例:
(1)使用 sys_exit:
mov eax, 1 ; 系统调用号 sys_exit int 0x80 ; 调用内核
(2)使用 sys_write:
mov edx, 4 ; 消息长度 mov ecx, msg ; 要写入的消息 mov ebx, 1 ; 文件描述符 mov eax, 4 ; 系统调用号 int 0x80 ; 调用内核
%eax | Name | %ebx | %ecx | %edx | %esx | %edi |
---|---|---|---|---|---|---|
1 | sys_exit | int | - | - | - | - |
2 | sys_fork | struct pt_regs | - | - | - | - |
3 | sys_read | unsigned int | char * | size_t | - | - |
4 | sys_write | unsigned int | const char * | size_t | - | - |
5 | sys_open | const char * | int | int | - | - |
6 | sys_close | unsigned int | - | - | - | - |
下面举一个复杂一点的例子,包含了之前我们讲过的 data、bss、text 三个部分,也希望通过这个例子,加深一下大家对 data 部分和 bss 部分的区别
section .data ; 数据段 userMsg db '请输入一个数字: ' ; 提示用户输入数字的消息 lenUserMsg equ $-userMsg ; 消息的长度 dispMsg db '您输入的是: ' lenDispMsg equ $-dispMsg section .bss ; 未初始化的数据 num resb 5 ; 用于存储用户输入的变量,5字节 section .text ; 代码段 global _start ; 声明程序入口点 _start: ; 程序入口 ; 输出提示消息 '请输入一个数字: ' mov eax, 4 mov ebx, 1 mov ecx, userMsg mov edx, lenUserMsg int 80h ; 读取并存储用户输入 mov eax, 3 mov ebx, 2 mov ecx, num mov edx, 5 ; 读取5字节的信息(数字和符号,1字节) int 80h ; 输出消息 '您输入的是: ' mov eax, 4 mov ebx, 1 mov ecx, dispMsg mov edx, lenDispMsg int 80h ; 输出用户输入的数字 mov eax, 4 mov ebx, 1 mov ecx, num mov edx, 5 int 80h ; 退出程序 mov eax, 1 mov ebx, 0 int 80h
同样,我们需要通过下述命令来编译运行:
nasm -f elf get_num.asm # 将汇编程序编译成机器码 ld -m elf_i386 -s -o get_num get_num.o # 将目标文件和其他必要的文件组合成可执行文件 ./get_num # 运行可执行文件
输出结果如下:
请输入一个数字: 123 您输入的是: 123
下面来介绍一个汇编语言中三种基本寻址方式:
寄存器寻址
立即寻址
内存寻址
寄存器寻址模式,其中操作数直接存储在寄存器中,而不涉及内存。这种寻址模式在处理数据时提供了高效的速度,因为它是直接从寄存器中读取或向寄存器中写入数据,而无需涉及到主存储器。
在此模式下,根据指令的不同,寄存器可能是第一个操作数,也有可能是第二个操作数,或者两个操作数都是,如下:
MOV DX, TAX_RATE MOV COUNT, CX MOV EAX, EBX
立即寻址模式,其中一个操作数是常量或者表达式,而不是从内存中获取的。
我们可以通过这种方式定义变量、更改变量值、赋值等操作,例如:
BYTE_VALUE DB 150 ADD BYTE_VALUE, 65 MOV AX, 45H
直接内存寻址用于操作内存中的数据。在该模式下,偏移值直接指定为指令的一部分,通常由变量名指示。这种寻址方式涉及两个操作:定位内存位置和执行操作。
举例如下:
ADD BYTE_VALUE DL ; 将寄存器 DL 中的值加到内存位置 BYTE_VALUE 的字节值上 MOV BX, WORD_VALUE ; 将内存中的操作数直接赋值给 BX 寄存器
上述两种情况下,汇编器会维护一个符号表,其中存储了程序中所使用的所有变量的偏移值,这些偏移值用于在运行时计算实际的内存地址。这种方式使用了一种简单而直接的方法来引用内存中的数据,但相对寄存器寻址或间接寻址来说,它可能导致访问效率稍低。
直接偏移寻址是一种在汇编语言中用于访问数据表的寻址模式。通过使用算术运算符,你直接可以直接计算或指定相对于数据表起始地址的偏移量,从而访问表格中的特定数据。
我们先定义以下数据表,以供我们后续的操作:
BYTE_TABLE DB 14, 15, 22, 23
然后我们可以通过索引和偏移量的方式操作数据表中的数据:
MOV CL, BYTE_TABLE[2] ; 元素索引方式 MOV CL, BYTE_TABLE + 2 ; 偏移量方式
间接内存寻址是一种利用计算机的段:偏移寻址能力的寻址模式。通常,基址寄存器(例如 EBX、EBP,或简写为 BX、BP)和索引寄存器(DI、SI)被包含在方括号内,用于存储器引用,从而实现对内存中数据的访问。这种寻址模式通常用于处理包含多个元素的变量,比如数组。在数组的情况下,数组的起始地址通常存储在基址寄存器中。
通过下面的代码,演示一下如何通过间接内存寻址访问数组的不同元素:
MY_TABLE TIMES 10 DW 0 ; 分配了10个字,每个字2字节并初始化为0 MOV EBX, [MY_TABLE] ; 将 MY_TABLE 的有效地址存储到 EBX 寄存器中 MOV [EBX], 110 ; 将值 110 存储到 MY_TABLE 的第一个有效地址 ADD EBX, 2 ; EBX = EBX + 2 MOV [EBX], 123 ; 将值 123 存储到 MY_TABLE 的第二个元素
:::warning
用 [] 和 不用 [] 的区别?
对于 MOV [EBX], 110
和 MOV EBX, 110
来说:
MOV [EBX], 110
:是一条间接寻址指令,它将立即数 110 存储到 EBX 寄存器中存储的内存地址指向的位置。
MOV EBX, 110
:这是一条直接寻址指令,它将立即数 100 直接加载到 EBX 寄存器中,此时 EBX 中存储的是一个数,而不是内存地址。
:::
MOV 指令是 x86 汇编语言中用于将数据从一个存储空间移动到另一个存储空间的指令,它通常需要两个操作数,语法如下:
MOV destination, source
MOV 指令可能有以下五种形式,register(寄存器)、immediate(立即数)、memory(内存):
MOV register, register MOV register, immediate MOV memory, immediate MOV register, memory MOV memory, register
:::warning
需要注意的是:
两个操作数的大小必须同
源操作数的值是不变的
我们上面写的代码其实是存在一些问题的:
MY_TABLE TIMES 10 DW 0 ; 分配了10个字,每个字2字节并初始化为0 MOV EBX, [MY_TABLE] ; 将 MY_TABLE 的有效地址存储到 EBX 寄存器中 MOV [EBX], 110 ; 将值 110 存储到 MY_TABLE 的第一个有效地址
由于 x86 架构中内存访问是按字节寻址的,MOV [EBX], 110
这条指令可能会被解释为存储到 MY_TABLE 的第一个字节;但可能程序员的目的是存储一个整数值 110 到 MY_TABLE,并且 MY_TABLE 中的每个元素是字,那就会有歧义。
因此,我们需要使用类型说明符来明确指令操作的数据类型和占用的字节数,于是可以像下面这样写:
MOV [EBX], WORD 110 ; 将一个字(两个字节)的值 110 存储到 MY_TABLE[0] 中
常见的类型说明符如下:
类型说明符 | 寻址字节数 |
---|---|
BYTE | 1 |
WORD | 2 |
DWORD | 4 |
QWORD | 8 |
TBYTE | 10 |
:::
在汇编语言中,变量的定义和数据的存储通常涉及到不同的指令和规则。
NASM 提供了不同的 define 指令,用于为变量分配存储空间。这些指令用于在数据段中保留和初始化一个或多个字节,常见的有:
指令 | 用途 | 存储空间 |
---|---|---|
DB | 定义 Byte | 分配1个字节 |
DW | 定义 Word | 分配2个字节 |
DD | 定义 Doubleword | 分配4个字节 |
DQ | 定义 Quadword | 分配8个字节 |
DT | 定义十个字节 | 分配10个字节 |
使用示例如下:
choice DB 'y' number DW 12345 neg_number DW -12345 big_number DQ 123456789 real_number1 DD 1.234 real_number2 DQ 123.456
:::warning
字符的每个字节都以其十六进制的 ASCII 值存储:
每个字符都有一个对应的 ASCII 值,它是一个唯一的数值表示。例如,字母 'A' 的 ASCII 值是 65(十六进制为 41),而字母 'B' 的 ASCII 值是 66(十六进制为 42)。
当你在程序中定义一个字符变量,它的每个字节将被存储为对应字符的 ASCII 值的十六进制表示。
每个十进制值都会自动转换为其 16 位二进制等效值并存储为十六进制数:
当你在程序中定义一个十进制值,汇编器会将其自动转换为其 16 位的二进制等效值,并以十六进制形式存储。
例如,十进制值 10 会被转换为二进制值 1010,然后以十六进制形式存储为 "A"。
处理器使用小尾数字节排序:
处理器采用小尾数(Little Endian)字节排序,这意味着较低有效字节(最低位字节)存储在内存中的较低地址处,而较高有效字节(最高位字节)存储在内存中的较高地址处。
例如,对于十六进制值 0x12345678,在内存中的存储顺序是:78 56 34 12。
负数将转换为其 2 的补码表示形式:
在计算机中,负数通常以 2 的补码形式表示。这种表示方式使得在计算中可以统一处理加法和减法,而不需要额外的逻辑。
2 的补码表示形式是通过将正数的二进制表示取反然后加 1 得到的。例如,-5 的二进制表示是将 5 的二进制表示(0000 0101)取反得到(1111 1010),然后加 1 得到(1111 1011)。
短浮点数和长浮点数分别使用 32 位或 64 位表示:
浮点数表示采用 IEEE 754 标准,其中短浮点数(float)通常使用 32 位表示,而长浮点数(double)通常使用 64 位表示。
32 位浮点数包括符号位、8 位指数和 23 位尾数。64 位浮点数包括符号位、11 位指数和 52 位尾数。
:::
下面程序演示了 define 指令的使用:
section .text global _start section .data choice db 'y', 0xa len equ $ - choice _start: mov edx, len mov ecx, choice mov ebx, 1 mov eax, 4 int 80h mov eax, 1 int 80h
编译运行后输出如下:
y
在程序中,有时我们需要为一些数据保留一些存储空间,但不需要为它们初始化,而是在后续程序运行过程中被赋予实际值。
这个时候,我们就需要用到汇编语言中的保留指令,这些指令用于在内存中分配指定大小的空间,但不对其中的数据进行初始化。
常见的保留指令有:
指令 | 用途 |
---|---|
RESB | 保留一个 Byte(一个字节) |
RESW | 保留一个 Word(两个字节) |
RESD | 保留一个 Doubleword(四个字节) |
RESQ | 保留一个 Quadword(八个字节) |
REST | 保留十个字节空间(十个字节) |
一个程序可以定义多个数据定义的语句,例如:
choice DB 'y' number DW 123 bigbumber DQ 123456789
这样定义的语句,编辑器会为这些变量分配连续的内存。
times 指令允许对同一值进行多次初始化,这样在定义数组和表示非常有用。可以使用如下语句,创建名为 stars、数据元素类型为DW 的数组,包含 9 个元素,每个初始化为 0.
stars TIMES 9 DW 0 ;
我们重温一下上面输出 9 个 * 的汇编程序:
section .text global _start section .data stars times 9 db '*' _start: mov edx, 9 mov ecx, stars mov ebx, 1 mov eax, 4 int 0x80 mov eax, 1 int 0x80
同样编译运行后,会输出:
*********
NASM 提供了几个定义常量的指令,在上面我们使用过的有 EQU 指令,下面我们来重点介绍三个指令:
EQU
%assign
%define
EQU 指令常用于定义常量,其语法如下:
CONSTANT_NAME EQU expression
例如:
MY_NAME equ 'YinKai1'
然后我们可以在代码中使用这个常量值,例如:
mov ecx, MY_NAME
EQU 语句的操作数也可以是表达式,如下:
length equ 20 width equ 10 AREA equ length * width
下面示例演示了 EQU 指令的使用:
SYS_EXIT equ 1 SYS_WRITE equ 4 STDIN equ 0 STDOUT equ 1 section .data msg1 db 'Hello, programmers!', 0xA, 0xD len1 equ $ - msg1 msg2 db 'Welcome to the world of,', 0xA, 0xD len2 equ $ - msg2 msg3 db 'Linux assembly programming!', 0xA, 0xD len3 equ $ - msg3 section .text global _start _start: mov eax, SYS_WRITE mov ebx, STDOUT mov ecx, msg1 mov edx, len1 int 0x80 mov eax, SYS_WRITE mov ebx, STDOUT mov ecx, msg2 mov edx, len2 int 0x80 mov eax, SYS_WRITE mov ebx, STDOUT mov ecx, msg3 mov edx, len3 int 0x80 mov eax, SYS_EXIT int 0x80
上述程序会输出:
Hello, programmers! Welcome to the world of, Linux assembly programming!
%assign 指令是在预处理阶段中用于定义数字常量的汇编指令。它类似于 EQU 指令,与 EQU 指令不同的是,%assign 允许在后续代码中重新定义常量的值,这对于在程序的不同部分或不同文件中使用相同的符号名但不同的值很有用。
下面是一个使用样例:
%assign TOTAL 10
然后在代码的后面,还可以再次定义:
%assign TOTAL 20
%define 指令是在汇编语言预处理阶段用于定义宏的指令,类似于 C 语言中的 #define 预处理指令,允许定义数字、字符串常量以及宏。
使用示例如下,它会将 PTR 替换成 [EBP+4]
%define PTR [EBP+4] section .text mov eax, PTR
:::warning
上述三种指令都是区分大小写的。
:::
INC 指令用于将操作数加一,它适用于可以位于寄存器或内存中的单个操作数。
语法如下:
INC destination
destination 是要增加的操作数,可以是 8 位、16 位 或 32 位的寄存器或内存地址。
使用示例:
INC EBX ; 增加 32 位寄存器 EBX 中的值 INC DL ; 增加 8 位寄存器 DL 中的值 INC [count] ; 增加存储在变量 count 的内存位置中的值
DEC 指令用于将操作数减一,它适用于可以位于寄存器或内存中的单个操作数。
语法如下:
DEC destination
destination 是要减小的操作数,可以是 8 位、16 位 或 32 位的寄存器或内存地址。
使用示例:
segment .data count dw 0 ; 16位的变量 count,初始化为0 value db 15 ; 8位的变量 value,初始化为15 segment .text inc word [count] ; 将变量 count 的值增加1 dec byte [value] ; 将变量 value 的值减少1 mov ebx, count ; 将变量 count 的地址存入寄存器 ebx inc word [ebx] ; 通过寄存器 ebx 增加变量 count 的值 mov esi, value ; 将变量 value 的地址存入寄存器 esi dec byte [esi] ; 通过寄存器 esi 减少变量 value 的值
ADD 和 SUB 指令用于执行字节、字和双字大小的二进制数据的加法和减法,它们分别用于8位、16位或32位操作数的加法和减法。
语法如下:
ADD/SUB destination, source
destination
是目标操作数,source
是源操作数。这两个操作数可以是寄存器、内存地址或常数。
使用示例:
section .data value1 dd 10 ; 双字(32位)大小的变量,初始化为10 value2 dw 5 ; 字(16位)大小的变量,初始化为5 result db 0 ; 字节(8位)大小的变量,用于存储结果 section .text ; 32位加法 mov eax, [value1] ; 将value1的值加载到寄存器eax add eax, 20 ; 将20加到eax中 mov [value1], eax ; 将结果存回value1 ; 16位减法 mov bx, [value2] ; 将value2的值加载到寄存器bx sub bx, 3 ; 从bx中减去3 mov [value2], bx ; 将结果存回value2 ; 8位加法 mov al, 30 ; 将30加载到寄存器al add al, 15 ; 将15加到al中 mov [result], al ; 将结果存入result
下面再给一个复杂一点的例子:
; 定义系统调用号 SYS_EXIT equ 1 SYS_READ equ 3 SYS_WRITE equ 4 STDIN equ 0 STDOUT equ 1 section .data ; 提示用户输入第一个数字的消息 msg1 db "Enter a digit ", 0xA,0xD len1 equ $- msg1 ; 提示用户输入第二个数字的消息 msg2 db "Please enter a second digit", 0xA,0xD len2 equ $- msg2 ; 提示计算结果的消息 msg3 db "The sum is: " len3 equ $- msg3 section .bss ; 存储用户输入的第一个数字 num1 resb 2 ; 存储用户输入的第二个数字 num2 resb 2 ; 存储计算结果 res resb 1 section .text global _start ; 声明为gcc使用的程序入口 _start: ; 输出提示信息,要求用户输入第一个数字 mov eax, SYS_WRITE mov ebx, STDOUT mov ecx, msg1 mov edx, len1 int 0x80 ; 从标准输入读取用户输入的第一个数字,存储在num1中 mov eax, SYS_READ mov ebx, STDIN mov ecx, num1 mov edx, 2 int 0x80 ; 输出提示信息,要求用户输入第二个数字 mov eax, SYS_WRITE mov ebx, STDOUT mov ecx, msg2 mov edx, len2 int 0x80 ; 从标准输入读取用户输入的第二个数字,存储在num2中 mov eax, SYS_READ mov ebx, STDIN mov ecx, num2 mov edx, 2 int 0x80 ; 输出提示信息,指示即将显示计算结果 mov eax, SYS_WRITE mov ebx, STDOUT mov ecx, msg3 mov edx, len3 int 0x80 ; 将第一个数字转换为数字并存储在eax寄存器中 mov eax, [num1] sub eax, '0' ; 将第二个数字转换为数字并存储在ebx寄存器中 mov ebx, [num2] sub ebx, '0' ; 将eax和ebx相加,得到和,存储在res中 add eax, ebx ; 将和转换为ASCII并存储在res中 add eax, '0' mov [res], al ; 使用SYS_WRITE将计算得到的和输出到标准输出 mov eax, SYS_WRITE mov ebx, STDOUT mov ecx, res mov edx, 1 int 0x80 exit: ; 使用SYS_EXIT系统调用退出程序 mov eax, SYS_EXIT xor ebx, ebx int 0x80
上述代码向用户询问两个数字,分别将数字存储在 EAX 和 EBX 寄存器中,将值相加,将结果存储在内存位置 res 中,最后显示结果。
编译运行后,输出如下:
Enter a digit 4 Please enter a second digit 5 9
再来一个带有硬编码的例子:
section .text global _start ;must be declared for using gcc _start: ;tell linker entry point mov eax,'3' sub eax, '0' mov ebx, '4' sub ebx, '0' add eax, ebx add eax, '0' mov [sum], eax mov ecx,msg mov edx, len mov ebx,1 ;file descriptor (stdout) mov eax,4 ;system call number (sys_write) int 0x80 ;call kernel mov ecx,sum mov edx, 1 mov ebx,1 ;file descriptor (stdout) mov eax,4 ;system call number (sys_write) int 0x80 ;call kernel mov eax,1 ;system call number (sys_exit) int 0x80 ;call kernel section .data msg db "The sum is:", 0xA,0xD len equ $ - msg segment .bss sum resb 1
上述代码编译运行后会输出:
The sum is: 7
有两条指令用于将二进制数据相乘,MUL 指令处理无符号数据,IMUL 指令处理有符号数据。
语法如下:
MUL/IMUL multiplier
两种情况下的被乘数都位于累加器中,具体取决于被乘数和乘数的大小,并且生成的乘积也取决于操作数的大小,存储在两个寄存器中。
下面我们来看看三种不同情况下的 MUL 指令:
序号 | 场景 |
---|---|
1 | 当两个字节相乘时 被乘数在 AL 寄存器中,乘数是内存中或另一个寄存器中的一个字节。结果存放在 AX 寄存器中,乘积的高 8 位存放在 AH 中,乘积的低 8 位存放在 AL 中 |
2 | 当两个单字值相乘时 被乘数应该在 AX 寄存器中,乘数是内存或另一个寄存器中的一个字。 生成的结果是双字,需要两个寄存器。高位(最左边)部分存储在 DX 中,低位(最右边)部分存储在 AX 中 |
3 | 当两个双字相乘时 当两个双字值相乘时,被乘数应位于 EAX 中,乘数是存储在内存或另一个寄存器中的双字值。 生成的乘积存储在 EDX:EAX 寄存器中,即高位 32 位存储在 EDX 寄存器中,低位 32 位存储在 EAX 寄存器中。 |
示例:将 3 与 2 相乘,并显示结果:
section .text global _start ; 必须声明为gcc使用的程序入口 _start: ; 将字符 '3' 转换为数字并存储在 AL 中 mov al, '3' sub al, '0' ; 将字符 '2' 转换为数字并存储在 BL 中 mov bl, '2' sub bl, '0' ; 使用 MUL 指令将 AL 和 BL 相乘,结果存储在 AX 中 mul bl ; 将结果转换为字符并存储在 AL 中 add al, '0' ; 将结果存储在内存位置 res 中 mov [res], al ; 准备输出消息 mov ecx, msg mov edx, len mov ebx, 1 ; 文件描述符 (stdout) mov eax, 4 ; 系统调用号 (sys_write) int 0x80 ; 调用内核输出消息到标准输出 ; 准备输出计算结果 mov ecx, res mov edx, 1 mov ebx, 1 ; 文件描述符 (stdout) mov eax, 4 ; 系统调用号 (sys_write) int 0x80 ; 调用内核输出计算结果到标准输出 ; 退出程序 mov eax, 1 ; 系统调用号 (sys_exit) int 0x80 ; 调用内核退出程序 section .data msg db "The result is:", 0xA,0xD ; 输出消息 len equ $- msg section .bss res resb 1 ; 存储计算结果的空间
编译输出结果如下:
The result is: 6
除法运算也有两个指令,DIV(除法)指令用于无符号数据,IDIV(整数除法)指令用于有符号数据。
:::warning
除法运算生成两个元素 - 一个商和一个余数。 在乘法的情况下,不会发生溢出,因为使用双倍长度寄存器来保存乘积。 然而,在除法的情况下,可能会发生溢出。 如果发生溢出,处理器会产生中断。
:::
使用的语法如下:
DIV/IDIV divisor
下面根据不同操作数大小分为三种不同的情况:
序号 | 场景 |
---|---|
1 | 当除数为1 byte时 ?假定被除数位于 AX 寄存器(16 位)中。 除法后,商存入 AL 寄存器,余数存入 AH 寄存器。 |
2 | 当除数为 1 个 word 时 ?假定被除数为 32 位长,位于 DX:AX 寄存器中。 高 16 位在 DX 中,低 16 位在AX中。 除法后,16 位商进入 AX 寄存器,16 位余数进入 DX 寄存器。 |
3 | 除数为 doubleword 时 ?假设被除数为 64 位长并位于 EDX:EAX 寄存器中。 高阶 32 位在 EDX 中,低阶 32 位在 EAX 中。 除法后,32 位商进入 EAX 寄存器,32 位余数进入 EDX 寄存器。 |
使用示例:
section .text global _start ; 必须声明为gcc使用的程序入口 _start: ; 将字符 '8' 转换为数字并存储在 AX 中 mov ax, '8' sub ax, '0' ; 将字符 '2' 转换为数字并存储在 BL 中 mov bl, '2' sub bl, '0' ; 使用 DIV 指令将 AX 除以 BL,商存储在 AL 中,余数存储在 AH 中,默认操作累加器(通常为 AX) div bl ; 将商(AL)转换为字符并存储在 AX 中 add ax, '0' ; 将结果存储在内存位置 res 中 mov [res], ax ; 准备输出消息 mov ecx, msg mov edx, len mov ebx, 1 ; 文件描述符 (stdout) mov eax, 4 ; 系统调用号 (sys_write) int 0x80 ; 调用内核输出消息到标准输出 ; 准备输出计算结果 mov ecx, res mov edx, 1 mov ebx, 1 ; 文件描述符 (stdout) mov eax, 4 ; 系统调用号 (sys_write) int 0x80 ; 调用内核输出计算结果到标准输出 ; 退出程序 mov eax, 1 ; 系统调用号 (sys_exit) int 0x80 ; 调用内核退出程序 section .data msg db "The result is:", 0xA,0xD ; 输出消息 len equ $- msg section .bss res resb 1 ; 存储计算结果的空间
编译运行后的结果如下:
The result is: 4
处理器指令集提供了 AND、OR、XOR、TEST、NOT 这几个布尔逻辑指令,以满足程序的需求,其中包括对位进行测试、设置和清除。
这些指令的使用格式如下:
序号 | 说明 | 格式 |
---|---|---|
1 | AND | AND operand1, operand2 |
2 | OR | OR operand1, operand2 |
3 | XOR | XOR operand1, operand2 |
4 | TEST | TEST operand1, operand2 |
5 | NOT | NOT operand1 |
在这些指令中,第一个操作数可以是内存或寄存器中的内容,第二个操作数可以是内存或寄存器或立即数(常量)。
:::warning
直接在内存之间的操作是不被允许的!!!
:::
这些指令比较或匹配操作数的位,并根据结果设置 CF(进位标志)、OF(溢出标志)、PF(奇偶校验标志)、SF(符号标志)和 ZF(零标志),这样使得程序能够进行灵活的逻辑运算,同时提供了对操作结果的状态标志进行监测的能力。
AND 指令是用于执行按位 AND(与)运算的指令。按位与运算返回一个结果并将结果存储到第一个操作数中,其中仅当两个操作数的相应位都为 1 时,结果位才为 1;反之则结果位为 0.
使用示例:假设 BL 寄存器包含 0011 1010
,我们想要把高位清零,可以使用 AND 指令与 OFH(0000 1111
)进行运算,就可以达到高位清零的效果:
AND BL, OFH
如果我们想要检验一个数是奇数还是偶数,可以通过对 AL 寄存器中的数字与 01H(0000 0001)
进行 AND 运算,可以有效检查低位:
AND AL, 01H ; AND 操作,检查最低有效位 JZ EVEN_NUMBER ; 如果结果为零,跳转到 EVEN_NUMBER(偶数)
JZ:条件跳转指令,含义是 'Jump if Zero',即如果结果为零(ZF 标志位为 1),则执行跳转。
下面再来看一个完整的示例:演示了如何使用 AND 指令来检查和显示奇偶性
section .text global _start _start: ; 将数字 8 放入寄存器 ax mov ax, 8h ; 对寄存器 ax 与 1 进行 AND 操作,检查最低位 and ax, 1 ; 如果结果为零,跳转到 evnn(偶数) jz evnn ; 显示奇数消息 mov eax, 4 ; 系统调用号(sys_write) mov ebx, 1 ; 文件描述符(stdout) mov ecx, odd_msg ; 要写入的消息 mov edx, len2 ; 消息长度 int 0x80 ; 调用内核 jmp outprog evnn: ; 显示偶数消息 mov ah, 09h mov eax, 4 ; 系统调用号(sys_write) mov ebx, 1 ; 文件描述符(stdout) mov ecx, even_msg ; 要写入的消息 mov edx, len1 ; 消息长度 int 0x80 ; 调用内核 outprog: ; 退出程序 mov eax, 1 ; 系统调用号(sys_exit) int 0x80 ; 调用内核 section .data even_msg db 'Even Number!' ; 显示偶数消息 len1 equ $ - even_msg odd_msg db 'Odd Number!' ; 显示奇数消息 len2 equ $ - odd_msg
OR 指令用于执行按位或运算,支持逻辑表达式。按位或运算的规则是,如果任一操作数的相应位为1,或者两个操作数的相应位都为1,则结果位为1。如果两个位都为0,则结果位为0。同样,运算的结果是存储在第一个操作数中,操作数可以是寄存器或内存中的值。
下面来看一个完整的使用样例:我们将 3 与 5 进行 OR 操作,并输出结果
section .bss res resb 1 ; 用于存储 OR 运算的结果 section .text global _start _start: ; 将值5存储在寄存器AL中 mov al, 5 ; 将值3存储在寄存器BL中 mov bl, 3 ; 对AL和BL寄存器执行按位或运算,结果应为7 or al, bl ; 将结果转换为ASCII码 add al, byte '0' ; 将结果存储在res变量中 mov [res], al ; 调用sys_write系统调用,将结果写入stdout mov eax, 4 mov ebx, 1 mov ecx, res mov edx, 1 int 0x80 outprog: ; 调用sys_exit系统调用,退出程序 mov eax, 1 int 0x80
上述代码编译运行后,输出的结果如下:
7
XOR(异或)指令用于执行按位异或运算。按位异或的规则是,当且仅当操作数中的位不同时,结果位被设置为1。如果操作数的位相同(均为0或均为1),则结果位被清零为0。
对于指令 XOR EAX, EAX
,它将寄存器 EAX 中的值与自身进行异或操作,这实际上会将寄存器清零。因为任何值与自身进行异或运算的结果都是0。
TEST 指令的工作方式类似于 AND 指令,但与 AND 指令不同的是,它不会更改第一个操作数的内容。该指令执行按位与运算,并根据结果设置条件标志(CF、OF、PF、SF 和 ZF)。这使得 TEST 指令非常适合用于检查某个值的特定位状态,而不更改该值。
使用示例:
TEST AL, 01H JZ EVEN_NUMBER
上述代码演示了如何使用 TEST 指令来检查 AL 寄存器中的数字是否为偶数。如果 AL 寄存器中的值与 01H 进行按位与运算后结果为零(ZF 标志被设置),则跳转到标签 EVEN_NUMBER。
NOT 指令执行按位 NOT 运算,即反转操作数中的每一位。操作数可以位于寄存器或存储器中。
示例:
Operand1: 0101 0011 After NOT -> Operand1: 1010 1100
上述示例展示了如何使用 NOT 指令对二进制数字进行按位取反操作。
在汇编语言中,实现条件执行的机制主要通过多个循环和分支指令完成,这些指令能够改变程序的控制流程。
条件执行一般分为两种情况:
无条件跳转:
无条件跳转是通过 JMP 指令实现的,在这种情况下,条件执行涉及将程序的控制转移到不是紧随当前正在执行指令的指令的地址上。这种跳转转移可以是向前的,以执行一组新的指令,也可以是向后的,以程序执行相同的步骤。
条件跳转
条件跳转是通过一组跳转指令 j<condition> 来实现的,其中条件是根据特定条件而定。这些条件指令通过中断正常的指令执行流程来转移控制,通过修改指令指针寄存器(IP)中的偏移值来实现。
在讨论条件指令前,我们先来看看 CMP 指令。
CMP 指令是一种用于比较两个操作数的指令,通常在条件执行中使用。该指令主要通过对一个操作数与另一个操作数进行减法运算来实现比较,以确定这两个操作数是否相等。
值得注意的是,CMP 指令执行比较操作,但不会影响目标或源操作数的值。
语法如下:
CMP destination, source
其中目标操作数可以是位于寄存器或内存中,而源操作数可以是常量(立即数)数据、寄存器或内存。
使用示例:
CMP DX, 00 JE L7 ... L7: ...
比较 DX 寄存器的值与零,如果相等,则跳转到标签 L7.
CMP 指令通常用于比较计数器值是否达到执行循环所需的次数,以下是一个经典应用的示例:
INC EDX CMP EDX, 10 JLE LP1
比较计数器 EDX 是否达到 10,如果未达到 10 则跳转到 LP1 标签。
无条件跳转是通过 JMP 指令实现的,该指令使程序控制流立即转移到指定标签的地址。
JMP 指令的语法如下:
JMP label
使用示例:
MOV AX, 00 ; 将 AX 初始化为 0 MOV BX, 00 ; 将 BX 初始化为 0 MOV CX, 01 ; 将 CX 初始化为 1 L20: ADD AX, 01 ; 将 AX 递增 ADD BX, AX ; 将 AX 加到 BX 中 SHL CX, 1 ; 将 CX 左移一位,从而使 CX 值倍增 JMP L20 ; 重复执行上述语句
上面程序中的 SHL 指令用于将二进制数向左移指定的位数,以达到倍增的效果。在这个例子中,JMP 指令用于无条件跳转到标签 L20,从而创建一个无限循环,反复执行 MOV、ADD 和 SHL 指令。
条件跳转是指在程序执行过程中,根据特定条件的满足与否,控制流会转移到指定的目标指令。
条件跳转指令的选择取决于不同的条件和数据状态。
(1)以下是用于有符号数据上的算术运算的条件跳转指令,以及它们所检查的标志:
说明 | 描述 | 已测试标志 |
---|---|---|
JE/JZ | Jump Equal or Jump Zero | 当零标志位(ZF)被设置时跳转 |
JNE/JNZ | Jump not Equal or Jump Not Zero | 当零标志位(ZF)未被设置时跳转 |
JG/JNLE | Jump Greater or Jump Not Less/Equal | 当溢出标志(OF)、符号标志(SF)和零标志(ZF)符合条件时跳转 |
JGE/JNL | Jump Greater/Equal or Jump Not Less | 当溢出标志(OF)和符号标志(SF)符合条件时跳转 |
JL/JNGE | Jump Less or Jump Not Greater/Equal | 当溢出标志 (OF) 和符号标志 (SF) 符合条件时跳转。 |
JLE/JNG | Jump Less/Equal or Jump Not Greater | 当溢出标志 (OF) 、符号标志 (SF) 和零标志 (ZF) 符合条件时跳转。 |
(2)以下是用于无符号数据的逻辑运算的条件跳转指令,以及它们所检查的标志:
说明 | 描述 | 已测试标志 |
---|---|---|
JE/JZ | Jump Equal or Jump Zero | 当零标志位 (ZF) 被设置时跳转。 |
JNE/JNZ | Jump not Equal or Jump Not Zero | 当零标志位 (ZF) 未被设置时跳转。 |
JA/JNBE | Jump Above or Jump Not Below/Equal | 当进位标志 (CF) 和零标志 (ZF) 符合条件时跳转。 |
JAE/JNB | Jump Above/Equal or Jump Not Below | 当进位标志 (CF) 符合条件时跳转。 |
JB/JNAE | Jump Below or Jump Not Above/Equal | 当进位标志 (CF) 符合条件时跳转。 |
JBE/JNA | Jump Below/Equal or Jump Not Above | 当辅助进位标志 (AF) 和进位标志 (CF) 符合条件时跳转。 |
(3)另外,以下条件跳转指令具有特殊用途并检查相应标志的值:
说明 | 描述 | 已测试标志 |
---|---|---|
JXCZ | Jump if CX is Zero | 当 CX 寄存器的值为零时跳转。 |
JC | Jump If Carry | 当进位标志 (CF) 被设置时跳转。 |
JNC | Jump If No Carry | 当进位标志 (CF) 未被设置时跳转。 |
JO | Jump If Overflow | 当溢出标志 (OF) 被设置时跳转。 |
JNO | Jump If No Overflow | 当溢出标志 (OF) 未被设置时跳转。 |
JP/JPE | Jump Parity or Jump Parity Even | 当奇偶标志 (PF) 被设置时跳转。 |
JNP/JPO | Jump No Parity or Jump Parity Odd | 当奇偶标志 (PF) 未被设置时跳转。 |
JS | Jump Sign (negative value) | 当符号标志 (SF) 被设置时跳转。 |
JNS | Jump No Sign (positive value) | 当符号标志 (SF) 未被设置时跳转。 |
先来看一个简单的示例:
CMP AL, BL JE EQUAL CMP AL, BH JE EQUAL CMP AL, CL JE EQUAL NON_EQUAL:... EQUAL:...
上述代码在执行过程中会根据 AL 寄存器和 BL、BH、CL 寄存器的比较结果,若相等则跳转到标签 EQUAL,否则执行标签 NON_EQUAL 后续的指令。
下面的程序通过比较三个两位数变量,找到其中的最大值,并将其结果输出到标准输出:
section .text global _start ; 必须声明为使用gcc _start: ; 告诉链接器入口点 mov ecx, [num1] ; 将num1的值加载到ecx寄存器 cmp ecx, [num2] ; 将ecx与num2的值比较 jg check_third_num ; 如果ecx大于num2,跳转到check_third_num标签 mov ecx, [num2] ; 如果跳转到check_third_num,将num2的值加载到ecx寄存器 check_third_num: cmp ecx, [num3] ; 将ecx与num3的值比较 jg _exit ; 如果ecx大于num3,跳转到_exit标签 mov ecx, [num3] ; 如果跳转到_exit,将num3的值加载到ecx寄存器 _exit: mov [largest], ecx ; 将最大值存储在largest变量中 mov ecx, msg ; 将消息字符串的地址加载到ecx寄存器 mov edx, len ; 将消息字符串的长度加载到edx寄存器 mov ebx,1 ; 文件描述符(标准输出) mov eax,4 ; 系统调用号(sys_write) int 0x80 ; 调用内核 mov ecx, largest ; 将存储最大值的变量地址加载到ecx寄存器 mov edx, 2 ; 将输出的字节数加载到edx寄存器 mov ebx,1 ; 文件描述符(标准输出) mov eax,4 ; 系统调用号(sys_write) int 0x80 ; 调用内核 mov eax, 1 ; 将系统调用号设置为退出程序 int 80h ; 调用内核 section .data msg db "The largest digit is: ", 0xA,0xD ; 定义包含消息字符串的数据段 len equ $- msg ; 计算消息字符串的长度 num1 dd '47' ; 定义包含两位数值的数据段 num2 dd '22' ; 定义包含两位数值的数据段 num3 dd '31' ; 定义包含两位数值的数据段 segment .bss largest resb 2 ; 定义一个字节的空间,用于存储最大值
上面提供了对每个指令和标签的解释,帮助理解代码的功能和执行流程。
程序编译运行后会输出结果如下:
he largest digit is: 47
在汇编语言中循环可以用 JMP 指令实现,如下代码演示了如何使用 JMP 指令执行循环体 10 次:
MOV CL, 10 L1: <LOOP-BODY> DEC CL JNZ L1
处理器指令集还有专门用于实现循环的循环指令——LOOP,其使用语法如下:
LOOP label
这里的 label 是目标标签,用于标识跳转指令中的目标位置,LOOP 指令假定 ECX 寄存器中包含循环计数,执行 LOOP 指令时,ECX 寄存器中的值递减,控制跳转到目标标签,知道 ECX 寄存器中的值达到零为止。
因此上面的 JMP 实现的循环,可以改写为:
MOV ECX, 10 l1: <LOOP-BODY> loop l1
下面看一个比较复杂的样例,我们利用 loop 指令输出 1~9 的数字:
section .text ? global _start ? ? ? ?;必须声明以供gcc使用 _start: ? ? ? ? ? ? ? ?;告诉链接器入口点 ? mov ecx, 10 ? ? ? ? ?;将计数器初始化为10,表示要输出的数字个数 ? mov eax, '1' ? ? ? ? ;将ASCII码为'1'的字符赋值给寄存器eax l1: ? mov [num], eax ? ? ? ;将eax中的字符存储到变量num所指的内存位置 ? mov eax, 4 ? ? ? ? ? ;将系统调用号4(sys_write)存储到寄存器eax ? mov ebx, 1 ? ? ? ? ? ;将文件描述符1(标准输出)存储到寄存器ebx ? push ecx ? ? ? ? ? ? ;将计数器值保存到栈中,以备后续循环使用 ? mov ecx, num ? ? ? ? ;将变量num的地址存储到寄存器ecx ? mov edx, 1 ? ? ? ? ? ;表示要写入的字节数,此处为1 ? int 0x80 ? ? ? ? ? ? ;触发系统调用,将字符输出到标准输出 ? mov eax, [num] ? ? ? ;将变量num所指的内存位置的值加载到eax ? sub eax, '0' ? ? ? ? ;将字符转换为数字 ? inc eax ? ? ? ? ? ? ?;增加数字 ? add eax, '0' ? ? ? ? ;将数字转换回字符 ? pop ecx ? ? ? ? ? ? ?;从栈中恢复计数器值 ? loop l1 ? ? ? ? ? ? ?;循环,直到计数器为零 ? mov eax, 1 ? ? ? ? ? ;系统调用号1(sys_exit) ? int 0x80 ? ? ? ? ? ? ;调用内核退出程序 section .bss num resb 1 ? ? ? ? ? ? ?;定义一个字节的未初始化数据,用于存储字符
上述代码编译运行后,输出的结果如下:
123456789
数值数据通常以二进制表示,算术指令对二进制数据进行运算,当数字显示在屏幕上或从键盘输入时,它们都是 ASCII 形式。
到目前为止,我们已经使用过这些 ASCII 形式的输入数据转换为二进制进行算术计算,并将结果转换回二进制。
示例如下:
section .text global _start ;必须声明以便使用gcc _start: ;告诉链接器入口点 mov eax, '3' sub eax, '0' mov ebx, '4' sub ebx, '0' add eax, ebx add eax, '0' mov [sum], eax mov ecx, msg mov edx, len mov ebx, 1 ;文件描述符(stdout) mov eax, 4 ;系统调用号(sys_write) int 0x80 ;调用内核 mov ecx, sum mov edx, 1 mov ebx, 1 ;文件描述符(stdout) mov eax, 4 ;系统调用号(sys_write) int 0x80 ;调用内核 mov eax, 1 ;系统调用号(sys_exit) int 0x80 ;调用内核 section .data msg db "The sum is:", 0xA,0xD len equ $ - msg segment .bss sum resb 1
上面的代码编运行后会输出的结果如下:
The sum is: 7
虽然此类转换可以达到一样的效果,但这种转换会产生开销,并且汇编语言编程允许以更有效的方式处理二进制形式的数字。
十进制有两种形式表示:
ASCII 形式
BCD 或 二进制编码的十进制形式
在 ASCII 表示中,二进制数字存储为 ASCII 字符串,例如十进制数 1234 存储为
31 32 33 34H
其中 31H 为 1 的 ASCII 值,32H 为 2 的 ASCII 值,以此推类。
有四个指令用于处理 ASCII 表示的数字:
AAA:添加后 ASCII 调整
AAS:减法后调整 ASCII
AAM:乘法后的 ASCII 调整
ADD:除法前的 ASCII 调整
这些指令不接受任何操作数,并假定所需的操作数位于 AL 寄存器中。
下面给出一个使用示例:这段程序的主要目的是执行BCD减法,将 ASCII 字符 '9' 减去 '3',并将结果输出到终端
section .text global _start ; 必须声明为使用 gcc _start: ; 告诉链接器入口点 sub ah, ah ; 清零 AH 寄存器 mov al, '9' ; 将 '9' 存入 AL 寄存器 sub al, '3' ; 用 '3' 减去 AL 寄存器中的值 aas ; 进行 ASCII 调整,将 AH:AL 转换为两位的十进制数值 or al, 30h ; 将 AL 寄存器的值与 0x30 进行逻辑或操作,转换为 ASCII 码 mov [res], ax ; 将调整后的结果存储到 res 变量中 mov edx, len ; 消息长度 mov ecx, msg ; 要写入的消息 mov ebx, 1 ; 文件描述符 (stdout) mov eax, 4 ; 系统调用号 (sys_write) int 0x80 ; 调用内核 mov edx, 1 ; 消息长度 mov ecx, res ; 要写入的消息 mov ebx, 1 ; 文件描述符 (stdout) mov eax, 4 ; 系统调用号 (sys_write) int 0x80 ; 调用内核 mov eax, 1 ; 系统调用号 (sys_exit) int 0x80 ; 调用内核 section .data msg db 'The Result is:',0xa ; 消息字符串,包括换行符 len equ $ - msg ; 计算消息长度 section .bss res resb 1 ; 用于存储结果的变量
BCD 是一种用于二进制表示十进制数字的方法。有两种表示形式
在解压缩的 BCD 表示形式中,每个字节存储十进制数字的二进制等价物。例如数字 1234 以解压缩的 BCD 表示存储为:
01 02 03 04H
每个字节的低四位和高四位分别表示一个十进制数字。这种表示法中,每个字节都直接对应于一个十进制数位。对于解压缩的 BCD 表示,有两个相关的指令:
AAM(ASCII Adjust After Multiplication): 乘法后的 ASCII 调整指令,用于在乘法后调整结果,以确保结果的每个字节表示一个有效的十进制数字。
AAD(ASCII Adjust Before Division): 除法前的 ASCII 调整指令,用于在除法前调整输入,以确保输入的每个字节表示一个有效的十进制数字。
在压缩 BCD 表示形式中,每个数字使用四位二进制数存储。两个十进制数字被打包成一个字节。例如,数字 1234 以压缩 BCD 表示存储为:
12 34H
每个字节的高四位和低四位分别表示一个十进制数字。压缩 BCD 表示形式不支持乘法和除法,但有两个相关的指令:
DAA(Decimal Adjust After Addition): 加法后小数调整指令,用于在加法后调整结果,以确保结果的每个字节表示一个有效的十进制数字。
DAS(Decimal Adjust After Subtraction): 减法后小数调整指令,用于在减法后调整结果,以确保结果的每个字节表示一个有效的十进制数字。
BCD 表示法提供了一种有效地在计算机中存储和处理十进制数字的方法。解压缩的 BCD 适用于支持乘法和除法的场景,而压缩 BCD 使用于加法和减法的场景。
下面程序将两个 5位十进制数相加,并显示总和:
section .text ? global _start ? ? ? ?; 必须声明为使用 gcc ? _start: ? ? ? ? ? ? ? ?; 告诉链接器入口点 ? ? mov ? ? esi, 4 ? ? ? ; 指向右边的数字(从低位开始) ? mov ? ? ecx, 5 ? ? ? ; 数字的位数 ? clc ? ? ? ? ? ? ? ? ?; 清除进位标志 CF ? add_loop: ? ? mov ? ? al, [num1 + esi] ?; 从 num1 中加载一个数字的 ASCII 字符到 AL 寄存器 ? adc ? ? al, [num2 + esi] ?; 将 num2 中对应位置的 ASCII 字符加到 AL 寄存器,考虑进位 CF ? aaa ? ? ? ? ? ? ? ? ? ? ? ?; ASCII 调整,将 AL 寄存器的值调整为两位的十进制数值 ? pushf ? ? ? ? ? ? ? ? ? ? ?; 将标志寄存器的值(包括 CF)推送到栈上 ? or ? ? ?al, 30h ? ? ? ? ? ?; 将 AL 寄存器的值转换为 ASCII 字符 ? popf ? ? ? ? ? ? ? ? ? ? ? ; 恢复标志寄存器的值 ? mov ? ? [sum + esi], al ? ?; 将结果存储到 sum 中 ? dec ? ? esi ? ? ? ? ? ? ? ?; 移动到下一位 ? loop ? ?add_loop ? ? ? ? ? ; 循环直到所有位都处理完 ? ? mov ? ? edx, len ? ? ? ? ? ; 消息长度 ? mov ? ? ecx, msg ? ? ? ? ? ; 要写入的消息 ? mov ? ? ebx, 1 ? ? ? ? ? ? ; 文件描述符 (stdout) ? mov ? ? eax, 4 ? ? ? ? ? ? ; 系统调用号 (sys_write) ? int ? ? 0x80 ? ? ? ? ? ? ? ; 调用内核输出消息 ? ? mov ? ? edx, 5 ? ? ? ? ? ? ; 消息长度 ? mov ? ? ecx, sum ? ? ? ? ? ; 要写入的消息 ? mov ? ? ebx, 1 ? ? ? ? ? ? ; 文件描述符 (stdout) ? mov ? ? eax, 4 ? ? ? ? ? ? ; 系统调用号 (sys_write) ? int ? ? 0x80 ? ? ? ? ? ? ? ; 调用内核输出结果 ? ? mov ? ? eax, 1 ? ? ? ? ? ? ; 系统调用号 (sys_exit) ? int ? ? 0x80 ? ? ? ? ? ? ? ; 调用内核退出程序 ? section .data msg db 'The Sum is:', 0xa ? ? ; 消息字符串,包括换行符 len equ $ - msg ? ? ? ? ? ? ? ; 计算消息长度 num1 db '12345' ? ? ? ? ? ? ? ; 第一个数字 num2 db '23456' ? ? ? ? ? ? ? ; 第二个数字 sum ?db ' ? ? ' ? ? ? ? ? ? ? ; 用于存储结果的变量
编译运行后的输出结果如下:
The Sum is: 35801
在汇编语言中处理字符串时,我们可以采用两种方式来指定字符串的长度:
显示存储字符串长度:
我们可以使用 $
位置计算器符号来显示存储字符串长度,该符号表示位置计数器的当前值。如下例:
msg db 'Hello, world!', 0xa len equ $ - msg
或者我们也可以显示设置字符串的长度,如:
msg db 'Hello, world!', 0xa len equ 13
使用哨兵字符:
这种方式是存储带有尾随哨兵字符的字符串,而不是显示存储字符串的长度。哨兵是一个特殊字符,不会出现在字符串中,用于分隔字符串的结束。如下:
message db 'I am loving it!', 0
字符串的长度由尾随的零字符标志着,这种方法的优势在于不需要显示存储字符串的长度,而是依赖于遇到的第一个特殊字符串来确定字符串的结束。
字符串指令用于处理字符串数据,其中包括移动、加载、存储和比较等操作。这些指令涉及源操作数和目标操作数。
在 32 位段中,通常使用 ESI 和 EDI 寄存器分别指向源和目标;而在 16 位段中,相应地使用 SI 和 DI 寄存器。
处理字符串有五个基本的指令,它们是:
MOVS:该指令将 1 个字节、字或双字的数据从内存位置移动到另一个位置
LODS:该指令从内存中加载,如果操作数是一个字节,则加载到 AL 寄存器;如果操作数是一个字,则加载到 AX 寄存器;如果操作数是双字,则加载到 EAX 寄存器。
STOS:该指令将数据从寄存器 (AL、AX 或 EAX)存储到内存。
CMPS:该指令比较内存中的两个数据项。数据可以是字节大小、字或双字。
SCAS:该指令将寄存器(AL、AX 或 EAX)的内容与内存中项目的内容进行比较。
这些指令可以通过使用重复前缀来进行重复操作,例如REP MOVS
将连续地执行MOVS指令,直到ECX寄存器的值为零。
在这些指令中,ES:DI(或EDI)和 DS:SI(或ESI)寄存器分别指向目标操作数和源操作数,其中 SI 通常与 DS(数据段)相关联,而DI则与ES(额外段)相关联。
对于 16 位地址,使用 SI 和 DI 寄存器;而对于 32 位地址,使用 ESI 和 EDI 寄存器。
MOVS 指令用于将数据项(字节、字或双字)从源字符串复制到目标字符串。 DS:SI 指向源字符串,ES:DI 指向目标字符串。
如下例:
section .text global _start ; 必须为了使用 gcc 声明 _start: ; 告诉链接器入口点 mov ecx, len ; 将字符串 s1 的长度加载到 ECX 寄存器 mov esi, s1 ; 将源字符串 s1 的起始地址加载到 ESI 寄存器 mov edi, s2 ; 将目标字符串 s2 的起始地址加载到 EDI 寄存器 cld ; 清除方向标志 DF,确保 rep 指令中的字符串操作向高地址方向进行 rep movsb ; 使用 rep 前缀,将字符串从 s1 复制到 s2 mov edx, 20 ; 指定要写入的字节数 mov ecx, s2 ; 指定要写入的字符串的地址 mov ebx, 1 ; 文件描述符(stdout) mov eax, 4 ; 系统调用号(sys_write) int 0x80 ; 调用内核 mov eax, 1 ; 系统调用号(sys_exit) int 0x80 ; 调用内核 section .data s1 db 'Hello, world!', 0 ; 字符串 1,以零结尾 len equ $-s1 ; 计算字符串 s1 的长度 section .bss s2 resb 20 ; 目标字符串 s2,分配 20 个字节的空间
编译运行后输出结果如下:
Hello, world!
我们通过模拟凯撒密码加密的方式,来熟悉一下 LDOS 指令的用法:将数据中的每个字母替换为两个字母的位移来加密数据,即 a 将被 c 替换,b 与 d 等。
section .text global _start ; 必须为了使用 gcc 声明 _start: ; 告诉链接器入口点 mov ecx, len ; 将字符串 s1 的长度加载到 ECX 寄存器 mov esi, s1 ; 将源字符串 s1 的起始地址加载到 ESI 寄存器 mov edi, s2 ; 将目标字符串 s2 的起始地址加载到 EDI 寄存器 loop_here: lodsb ; 加载 AL 寄存器中的字节到 AL,同时将 SI 递增 add al, 2 ; 将 AL 中的值增加 2 stosb ; 存储 AL 寄存器的值到目标地址中的字节,同时将 DI 递增 loop loop_here ; 通过 ECX 寄存器的计数来重复上述过程,直到计数为零 cld ; 清除方向标志 DF,确保 rep 指令中的字符串操作向高地址方向进行 rep movsb ; 使用 rep 前缀,将剩余的字符串从 s1 复制到 s2 mov edx, 20 ; 指定要写入的字节数 mov ecx, s2 ; 指定要写入的字符串的地址 mov ebx, 1 ; 文件描述符(stdout) mov eax, 4 ; 系统调用号(sys_write) int 0x80 ; 调用内核 mov eax, 1 ; 系统调用号(sys_exit) int 0x80 ; 调用内核 section .data s1 db 'password', 0 ; 源字符串,以零结尾 len equ $-s1 ; 计算字符串 s1 的长度 section .bss s2 resb 10 ; 目标字符串 s2,分配 10 个字节的空间
STOS 指令将数据项从 AL(对于字节 - STOSB)、AX(对于字 - STOSW)或 EAX(对于双字 - STOSD)复制到内存中 ES:DI 指向的目标字符串。
下面示例演示如何使用 LODS 和 STOS 指令将大写字符串转换为其小写值。
section .text global _start ; 必须为了使用 gcc 声明 _start: ; 告诉链接器入口点 mov ecx, len ; 将字符串 s1 的长度加载到 ECX 寄存器 mov esi, s1 ; 将源字符串 s1 的起始地址加载到 ESI 寄存器 mov edi, s2 ; 将目标字符串 s2 的起始地址加载到 EDI 寄存器 loop_here: lodsb ; 加载 AL 寄存器中的字节到 AL,同时将 SI 递增 or al, 20h ; 使用按位或运算将大写字母转换为小写字母 stosb ; 存储 AL 寄存器的值到目标地址中的字节,同时将 DI 递增 loop loop_here ; 通过 ECX 寄存器的计数来重复上述过程,直到计数为零 cld ; 清除方向标志 DF,确保 rep 指令中的字符串操作向高地址方向进行 rep movsb ; 使用 rep 前缀,将剩余的字符串从 s1 复制到 s2 mov edx, 20 ; 指定要写入的字节数 mov ecx, s2 ; 指定要写入的字符串的地址 mov ebx, 1 ; 文件描述符(stdout) mov eax, 4 ; 系统调用号(sys_write) int 0x80 ; 调用内核 mov eax, 1 ; 系统调用号(sys_exit) int 0x80 ; 调用内核 section .data s1 db 'HELLO, WORLD', 0 ; 源字符串,以零结尾 len equ $-s1 ; 计算字符串 s1 的长度 section .bss s2 resb 20 ; 目标字符串 s2,分配 20 个字节的空间
CMPS 指令比较两个字符串。 该指令比较 DS:SI 和 ES:DI 寄存器指向的一个字节、一个字或一个双字的两个数据项,并相应地设置标志。 您还可以将条件跳转指令与此指令一起使用。
下面代码演示了如何使用 CMPS 指令比较两个字符串是否相等:
section .text global _start ; 必须为了使用 gcc 声明 _start: ; 告诉链接器入口点 mov esi, s1 ; 将源字符串 s1 的起始地址加载到 ESI 寄存器 mov edi, s2 ; 将目标字符串 s2 的起始地址加载到 EDI 寄存器 mov ecx, lens2 ; 将字符串 s2 的长度加载到 ECX 寄存器 cld ; 清除方向标志 DF,确保比较字符串的方向是从高地址到低地址 repe cmpsb ; 重复比较 ESI 和 EDI 指向的字节,直到不相等或者 ECX 变为零 jecxz equal ; 如果 ECX 为零,说明字符串相等,跳转到 equal 标签 ; 如果不相等,则执行以下代码 mov eax, 4 ; 系统调用号 (sys_write) mov ebx, 1 ; 文件描述符 (stdout) mov ecx, msg_neq ; 要写入的字符串 "Strings are not equal!" mov edx, len_neq ; 字符串长度 int 80h ; 调用内核 jmp exit ; 跳转到 exit 标签 equal: mov eax, 4 ; 系统调用号 (sys_write) mov ebx, 1 ; 文件描述符 (stdout) mov ecx, msg_eq ; 要写入的字符串 "Strings are equal!" mov edx, len_eq ; 字符串长度 int 80h ; 调用内核 exit: mov eax, 1 ; 系统调用号 (sys_exit) mov ebx, 0 ; 退出码 int 80h ; 调用内核 section .data s1 db 'Hello, world!', 0 ; 第一个字符串 lens1 equ $-s1 ; 计算字符串 s1 的长度 s2 db 'Hello, there!', 0 ; 第二个字符串 lens2 equ $-s2 ; 计算字符串 s2 的长度 msg_eq db 'Strings are equal!', 0xa ; 相等时输出的字符串 len_eq equ $-msg_eq ; 相等时字符串的长度 msg_neq db 'Strings are not equal!' ; 不相等时输出的字符串 len_neq equ $-msg_neq ; 不相等时字符串的长度
SCAS 指令用于搜索字符串中的特定字符或字符集。 要搜索的数据项应位于 AL(对于 SCASB)、AX(对于 SCASW)或 EAX(对于 SCASD)寄存器中。 要搜索的字符串应该在内存中并由 ES:DI(或 EDI)寄存器指向。
下面的代码演示了如何在字符串中查找是否存在某个字符:
section .text global _start ; 必须为了使用 gcc 声明 _start: ; 告诉链接器入口点 mov ecx, len ; 将字符串 my_string 的长度加载到 ECX 寄存器 mov edi, my_string ; 将目标字符串 my_string 的起始地址加载到 EDI 寄存器 mov al, 'e' ; 设置要查找的字符为 'e' cld ; 清除方向标志 DF,确保 scasb 指令向前比较 repne scasb ; 从 EDI 指向的内存位置开始,逐个比较每个字节与 AL 寄存器的值,直到找到相等的字节或者 ECX 为零 je found ; 如果找到相等的字节,跳转到 found 标签 ; 如果没有找到,则执行以下代码 mov eax, 4 ; 系统调用号 (sys_write) mov ebx, 1 ; 文件描述符 (stdout) mov ecx, msg_notfound ; 要写入的字符串 "not found!" mov edx, len_notfound ; 字符串长度 int 80h ; 调用内核 jmp exit ; 跳转到 exit 标签 found: mov eax, 4 ; 系统调用号 (sys_write) mov ebx, 1 ; 文件描述符 (stdout) mov ecx, msg_found ; 要写入的字符串 "found!" mov edx, len_found ; 字符串长度 int 80h ; 调用内核 exit: mov eax, 1 ; 系统调用号 (sys_exit) mov ebx, 0 ; 退出码 int 80h ; 调用内核 section .data my_string db 'hello world', 0 ; 字符串 len equ $-my_string ; 计算字符串 my_string 的长度 msg_found db 'found!', 0xa ; 找到时输出的字符串 len_found equ $-msg_found ; 找到时字符串的长度 msg_notfound db 'not found!' ; 未找到时输出的字符串 len_notfound equ $-msg_notfound ; 未找到时字符串的长度
下表提供了各种版本的字符串指令和假定的操作数空间。
基本指令 | 操作数位于 | Byte 操作 | Word 操作 | Double word 操作 |
---|---|---|---|---|
MOVS | ES:DI, DS:SI | MOVSB | MOVSW | MOVSD |
LODS | AX, DS:SI | LODSB | LODSW | LODSD |
STOS | ES:DI, AX | STOSB | STOSW | STOSD |
CMPS | DS:SI, ES: DI | CMPSB | CMPSW | CMPSD |
SCAS | ES:DI, AX | SCASB | SCASW | SCASD |
当在字符串指令之前设置 REP 前缀时,例如 - REP MOVSB,会导致基于 CX 寄存器中的计数器重复指令。 REP 执行该指令,将 CX 减 1,并检查 CX 是否为零。 它重复指令处理,直到 CX 为零。
方向标志(DF)决定操作的方向:
CLD(清楚方向标志,DF = 0)进行从左到右的操作
STD(设置方向标志,DF = 1)使操作从右到左
REP 前缀还有一些变体,如下:
REP:无条件重复,它重复该操作,直到 CX 为零。
REPE 或 REPZ:有条件重复,当零标志指示等于/零时,它会重复该操作。当 ZF 指示不等于/零或 CX 为零时,它会停止。
REPNE 或 REPNZ:也是有条件重复。 当零标志指示不等于/零时,它会重复该操作。 当 ZF 指示等于/零或 CX 递减至零时,它会停止
汇编器的数据定义指令用于为变量分配存储空间,该变量也可以用一些特定值进行初始化,初始化值可以以十六进制、十进制或二进制形式指定。
例如我们可以使用下面任一方式定义单词变量 “months”
months dw 12 months dw ocH months dw o110B
数据定义指令也可以用于定义一维数组,下面是一个一维数组的定义:
numbers dw 34, 45, 56, 67, 78, 89
上面定义了六个字组成的数组,每个字都用数字 34, 45, 56, 67, 78, 89 进行了初始化。分配了 2x6 = 12 字节的连续内存空间。第一个数字的符号地址为 numbers,第二个为 numbers + 2,以此推类。
对同一值进行多次初始化还可以用 TIMES 指令:
numbers times 8 dw 0 = numbers dw 0, 0, 0, 0, 0, 0, 0, 0
下面的程序将数组中的 3 个字节的值求和输出:
section .text global _start ; 必须为了使用链接器 (ld) _start: mov eax, 3 ; 要求将3个字节进行求和 mov ebx, 0 ; EBX 将存储总和 mov ecx, x ; ECX 将指向要进行求和的当前元素 top: add ebx, [ecx] ; 将当前元素的值加到总和上 add ecx, 1 ; 移动指针到下一个元素 dec eax ; 计数器递减 jnz top ; 如果计数器不为0,继续循环 done: add ebx, '0' ; 将结果转换为ASCII字符 mov [sum], ebx ; 完成,将结果存储在 "sum" 中 display: mov edx, 1 ; 消息长度 mov ecx, sum ; 要写入的消息 mov ebx, 1 ; 文件描述符 (stdout) mov eax, 4 ; 系统调用号 (sys_write) int 0x80 ; 调用内核 mov eax, 1 ; 系统调用号 (sys_exit) int 0x80 ; 调用内核 section .data global x x: db 2 db 4 db 3 sum: db 0 ; 用于存储总和的地方,初始化为0
编译运行后的结果为:
9
过程或子例程在汇编语言中非常重要,它们有助于组织和模块化代码,提高代码的可读性和可维护性。
过程通常以一系列的指令组成,用于完成特定的任务。这些过程可以有参数、局部变量,也可以返回一个值。
过程定义的语法如下:
proc_name: procedure body ... ret
使用 CALL 指令从另一个函数调用该过程,被调用过程的名称应作为 CALL 指令 的参数,如下:
CALL proc_name
示例:下面的程序将 ECX 和 EDX 寄存器中存储的变量相加,并将结果总和返回到 EAX 寄存器中,并显示
section .text global _start ; 必须为了使用链接器 (gcc) _start: mov ecx, '4' ; 将字符 '4' 的 ASCII 值加载到 ECX 寄存器 sub ecx, '0' ; 将 '0' 的 ASCII 值从 ECX 中减去,以获得数字 4 mov edx, '5' ; 将字符 '5' 的 ASCII 值加载到 EDX 寄存器 sub edx, '0' ; 将 '0' 的 ASCII 值从 EDX 中减去,以获得数字 5 call sum ; 调用 sum 过程,将结果存储在 EAX 中 mov [res], eax ; 将结果存储在 res 变量中 ; 输出 "The sum is:" 到标准输出 mov ecx, msg mov edx, len mov ebx, 1 ; 文件描述符 (stdout) mov eax, 4 ; 系统调用号 (sys_write) int 0x80 ; 调用内核 ; 输出结果到标准输出 mov ecx, res mov edx, 1 mov ebx, 1 ; 文件描述符 (stdout) mov eax, 4 ; 系统调用号 (sys_write) int 0x80 ; 调用内核 ; 退出程序 mov eax, 1 ; 系统调用号 (sys_exit) int 0x80 ; 调用内核 sum: mov eax, ecx ; 将 ECX 中的值移动到 EAX add eax, edx ; 将 EDX 中的值加到 EAX add eax, '0' ; 将 '0' 的 ASCII 值加到 EAX,以将数字转换回字符 ret ; 返回 section .data msg db "The sum is:", 0xA,0xD ; 输出消息 len equ $- msg section .bss res resb 1 ; 用于存储结果的变量,初始化为 1 个字节
编译运行后输出的结果如下:
The sum is: 9
堆栈是一种内存中的数据结构,类似于数组,用于存储和检索数据。数据可以通过"推入"到堆栈中进行存储,而通过"弹出"从堆栈中取出。堆栈采用后进先出(Last In First Out,LIFO)的原则,即最先存储的数据最后取出。
在汇编语言中,我们可以使用两种堆栈操作指令来进行操作:PUSH 和 POP。这些指令的语法如下:
PUSH operand
: 将操作数推入堆栈。
POP address/register
: 从堆栈中弹出数据并存储到指定地址或寄存器中。
堆栈的实现依赖于堆栈段中预留的内存空间。寄存器 SS 和 ESP(或 SP)用于管理堆栈。栈顶指针(ESP)指向最后插入到堆栈中的数据项,其中 SS 寄存器指向堆栈段的开头。堆栈的增长方向是向低内存地址增加,而栈顶指向最后插入的一项,指向插入的最后一个字的低字节。
堆栈的一些特点包括:
只有字(words)或双字(doublewords)可以保存到堆栈中,而不是字节。
堆栈向相反方向增长,即向低内存地址增加。
栈顶指针指向栈中最后插入的一项,它指向插入的最后一个字的低字节。
在使用寄存器的值之前,我们可以先将其存储到堆栈中,如下:
PUSH AX PUSH BX MOV AX, VALUE1 MOV BX, VALUE2 MOV VALUE1, AX MOV VALUE2, BX POP BX POP AX
示例:下面程序利用循环输出整个 ascii 字符集
section .text global _start ? ; 必须为了使用链接器 (gcc) ? _start: ? call display ? ; 调用 display 过程 ? ? mov eax, 1 ? ? ?; 系统调用号 (sys_exit) ? int 0x80 ? ? ? ? ; 调用内核 ? display: ? mov ecx, 256 ? ? ; 设置循环计数器,控制输出字符的次数 ? next: ? push ecx ? ? ? ? ; 保存循环计数器的值 ? ? mov eax, 4 ? ? ? ; 系统调用号 (sys_write) ? mov ebx, 1 ? ? ? ; 文件描述符 (stdout) ? mov ecx, achar ? ; 输出字符的地址 ? mov edx, 1 ? ? ? ; 输出字符的长度 ? int 80h ? ? ? ? ?; 调用内核进行输出 ? ? pop ecx ? ? ? ? ?; 恢复循环计数器的值 ? mov dx, [achar] ?; 将当前字符的 ASCII 值加载到 DX 寄存器 ? cmp byte [achar], 0dh ?; 比较当前字符是否为回车符 '\r' ? inc byte [achar] ? ? ? ; 将字符 '0' 到 '9' 逐个增加 ? loop next ? ? ? ?; 继续循环 ? ? ret ? ? ? ? ? ? ?; 返回 ? section .data achar db '0' ? ? ? ; 存储当前输出的字符
编译运行后的结果输出如下:
0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}??¢£¤¥|§¨?a????ˉ°±23′μ?·?1o?????A????ˋ??ё?????????稨?�? ... ...
递归过程是一种调用自身的过程。递归又分为两种:直接递归和间接递归。直接递归是过程调用自身;间接递归是第一个过程调用第二个过程,第二个过程又调用第一个过程。
下面我们用汇编以递归的方式实现一个阶乘,计算阶乘 3:
section .text global _start ? ; 必须为了使用链接器 (gcc) ? _start: ? mov bx, 3 ? ? ?; 设置 bx 为 3,用于计算 3 的阶乘 ? call proc_fact ; 调用 proc_fact 过程计算阶乘 ? add ax, 30h ? ?; 将结果转换为 ASCII 码 ? mov [fact], ax ; 将结果存储在 fact 变量中 ? ? ; 输出 "Factorial 3 is:" ? mov edx, len ? ; 设置消息长度 ? mov ecx, msg ? ; 设置消息内容 ? mov ebx, 1 ? ? ; 文件描述符 (stdout) ? mov eax, 4 ? ? ; 系统调用号 (sys_write) ? int 0x80 ? ? ? ; 调用内核进行输出 ? ? ; 输出计算结果 ? mov edx, 1 ? ? ; 设置消息长度 ? mov ecx, fact ?; 设置消息内容 ? mov ebx, 1 ? ? ; 文件描述符 (stdout) ? mov eax, 4 ? ? ; 系统调用号 (sys_write) ? int 0x80 ? ? ? ; 调用内核进行输出 ? ? ; 退出程序 ? mov eax, 1 ? ? ; 系统调用号 (sys_exit) ? int 0x80 ? ? ? ; 调用内核退出 ? proc_fact: ? cmp bl, 1 ? ? ?; 比较 bl 是否为 1 ? jg do_calculation ?; 如果 bl 大于 1,则进行计算 ? mov ax, 1 ? ? ?; 如果 bl 等于 1,则结果为 1 ? ret ? do_calculation: ? dec bl ? ? ? ? ; 减少 bl 的值 ? call proc_fact ; 递归调用 proc_fact 过程 ? inc bl ? ? ? ? ; 恢复 bl 的值 ? mul bl ? ? ? ? ; 计算阶乘,ax = al * bl ? ret ? section .data msg db 'Factorial 3 is:', 0xa ; 消息内容 len equ $ - msg ? ? ? ? ? ? ? ? ; 消息长度 ? section .bss fact resb 1 ? ? ? ? ? ? ? ? ? ? ; 存储计算结果的变量
编译运行后的结果为:
Factorial 3 is: 6
编写宏是汇编语言实现模块化编程的另一种方式:
宏是一系列指令,由名词指定,可以在程序中的任意位置使用
在 NASM 中,宏使用 %macro 和 %endmarro 指令定义,以前者开头,后者结尾
宏定义的语法 :
%macro macro_name number_of_params <macro body> %endmacro
其中,number_of_params指定参数数量,macro_name指定宏的名称。
通过使用宏名称以及必要的参数来调用宏。 当您需要在程序中多次使用某些指令序列时,可以将这些指令放入宏中并使用它
下面的示例演示了如何定义宏和使用宏:
%macro write_string 2 mov eax, 4 mov ebx, 1 mov ecx, %1 mov edx, %2 int 0x80 %endmacro section .data msg1 db 'Hello World' len1 equ $ - msg1 section .text global _start _start: write_string msg1, len1 mov eax, 1 int 0x80
上面的程序编译运行后的结果如下:
Hello World
系统将任何输入或输出数据视为字节流,标准的文件流有 3 种:
标准输入(stdin)
标准输出(stdout)
标准错误(stderr)
文件描述符作文文件 ID 分配给文件的 16 位整数。当创建文件或打开现有文件时,文件描述符用于访问文件
标准文件流的文件描述符 - stdin、stdout 和 stderr 分别为 0、1 和 2。
下表简要描述了与文件处理相关的系统调用 ?
%eax | Name | %ebx | %ecx | %edx |
---|---|---|---|---|
2 | sys_fork | struct pt_regs | - | - |
3 | sys_read | unsigned int | char * | size_t |
4 | sys_write | unsigned int | const char * | size_t |
5 | sys_open | const char * | int | int |
6 | sys_close | unsigned int | - | - |
8 | sys_creat | const char * | int | - |
19 | sys_lseek | unsigned int | off_t | unsigned int |
使用系统调用所需的步骤与我们之前讨论的相同 ?
将系统调用号放入EAX寄存器中。
将系统调用的参数存储在寄存器 EBX、ECX 等中。
调用相关中断(80h)。
结果通常返回到 EAX 寄存器中。
将系统调用 sys_creat()
编号 8 放入 EAX 寄存器。
将文件名放入 EBX 寄存器。
将文件权限放入 ECX 寄存器。
系统调用返回 EAX 寄存器中创建的文件的文件描述符,错误代码存储在 EAX 寄存器中。
将系统调用 sys_open()
编号 5 放入 EAX 寄存器。
将文件名放入 EBX 寄存器。
将文件访问模式放入 ECX 寄存器。
将文件权限放入 EDX 寄存器。
系统调用返回 EAX 寄存器中打开的文件的文件描述符,错误代码存储在 EAX 寄存器中。
常用的文件访问模式包括:只读(0)、只写(1)和读写(2)。
将系统调用 sys_read()
编号 3 放入 EAX 寄存器。
将文件描述符放入 EBX 寄存器。
将指向输入缓冲区的指针放入 ECX 寄存器。
将缓冲区大小(即要读取的字节数)放入 EDX 寄存器。
系统调用返回在 EAX 寄存器中读取的字节数,错误代码存储在 EAX 寄存器中。
将系统调用 sys_write()
编号 4 放入 EAX 寄存器。
将文件描述符放入 EBX 寄存器。
将指向输出缓冲区的指针放入 ECX 寄存器。
将缓冲区大小(即要写入的字节数)放入 EDX 寄存器。
系统调用返回 EAX 寄存器中实际写入的字节数,错误代码存储在 EAX 寄存器中。
将系统调用 sys_close()
编号 6 放入 EAX 寄存器。
将文件描述符放入 EBX 寄存器。
如果出现错误,系统调用将返回 EAX 寄存器中的错误代码。
将系统调用 sys_lseek()
编号 19 放入 EAX 寄存器。
将文件描述符放入 EBX 寄存器。
将偏移值放入 ECX 寄存器。
将偏移的参考位置放入 EDX 寄存器。
参考位置可以是文件开头(值 0)、当前位置(值 1)或文件结尾(值 2)。
如果出现错误,系统调用将返回 EAX 寄存器中的错误代码。
下面用一个复杂的例子演示一下如何使用系统调用:
section .text ? global _start ? ? ? ? ; 必须声明以供使用gcc ? _start: ? ? ? ? ? ? ? ? ?; 告诉链接器入口点在这里 ? ; 创建文件 ? mov ?eax, 8 ? ? ? ? ? ; 使用 sys_creat() 系统调用,编号为 8 ? mov ?ebx, file_name ? ; 文件名存储在 ebx 寄存器中 ? mov ?ecx, 0777o ? ? ? ; 文件权限,八进制表示,为所有用户设置读、写和执行权限 ? int ?0x80 ? ? ? ? ? ? ; 调用内核 ? ? mov [fd_out], eax ? ? ; 存储文件描述符以供后续使用 ? ? ; 写入文件 ? mov edx, len ? ? ? ? ?; 要写入的字节数 ? mov ecx, msg ? ? ? ? ?; 要写入的消息 ? mov ebx, [fd_out] ? ? ; 文件描述符 ? mov eax, 4 ? ? ? ? ? ?; 使用 sys_write() 系统调用,编号为 4 ? int 0x80 ? ? ? ? ? ? ; 调用内核 ? ? ; 关闭文件 ? mov eax, 6 ? ? ? ? ? ?; 使用 sys_close() 系统调用,编号为 6 ? mov ebx, [fd_out] ? ? ; 文件描述符 ? int 0x80 ? ? ? ? ? ? ?; 调用内核 ? ? ; 写入表示文件写入结束的消息 ? mov eax, 4 ? ? ? ? ? ?; 使用 sys_write() 系统调用,编号为 4 ? mov ebx, 1 ? ? ? ? ? ?; 文件描述符为标准输出 ? mov ecx, msg_done ? ? ; 要写入的消息 ? mov edx, len_done ? ? ; 要写入的字节数 ? int ?0x80 ? ? ? ? ? ? ; 调用内核 ? ? ; 以只读方式打开文件 ? mov eax, 5 ? ? ? ? ? ?; 使用 sys_open() 系统调用,编号为 5 ? mov ebx, file_name ? ?; 文件名存储在 ebx 寄存器中 ? mov ecx, 0 ? ? ? ? ? ?; 以只读方式打开 ? mov edx, 0777o ? ? ? ?; 文件权限,八进制表示,为所有用户设置读、写和执行权限 ? int ?0x80 ? ? ? ? ? ? ; 调用内核 ? ? mov [fd_in], eax ? ? ; 存储文件描述符以供后续使用 ? ? ; 从文件中读取 ? mov eax, 3 ? ? ? ? ? ?; 使用 sys_read() 系统调用,编号为 3 ? mov ebx, [fd_in] ? ? ?; 文件描述符 ? mov ecx, info ? ? ? ? ; 存储读取的数据的缓冲区 ? mov edx, 26 ? ? ? ? ? ; 要读取的字节数 ? int 0x80 ? ? ? ? ? ? ?; 调用内核 ? ? ; 关闭文件 ? mov eax, 6 ? ? ? ? ? ?; 使用 sys_close() 系统调用,编号为 6 ? mov ebx, [fd_in] ? ? ?; 文件描述符 ? int 0x80 ? ? ? ? ? ? ?; 调用内核 ? ? ; 打印信息 ? mov eax, 4 ? ? ? ? ? ?; 使用 sys_write() 系统调用,编号为 4 ? mov ebx, 1 ? ? ? ? ? ?; 文件描述符为标准输出 ? mov ecx, info ? ? ? ? ; 要写入的消息 ? mov edx, 26 ? ? ? ? ? ; 要写入的字节数 ? int 0x80 ? ? ? ? ? ? ?; 调用内核 ? ? mov eax, 1 ? ? ? ? ? ? ; 使用 sys_exit() 系统调用,编号为 1 ? int 0x80 ? ? ? ? ? ? ?; 调用内核 ? section .data file_name db 'myfile.txt' ?; 文件名 msg db 'Welcome to Tutorials Point' ?; 要写入文件的消息 len equ $-msg ? ? ? ? ? ?; 计算消息的字节数 ? msg_done db 'Written to file', 0xa ?; 文件写入结束的消息 len_done equ $-msg_done ? ; 计算消息的字节数 ? section .bss fd_out resb 1 ? ? ? ? ? ? ; 存储文件描述符的变量(写入文件用) fd_in ?resb 1 ? ? ? ? ? ? ; 存储文件描述符的变量(读取文件用) info resb ?26 ? ? ? ? ? ? ; 存储从文件读取的数据的缓冲区
上述程序创建并打开名为 myfile.txt 的文件,并在此文件中写入文本"Welcome to Tutorials Point"。 接下来,程序从文件中读取数据并将数据存储到名为 info 的缓冲区中。 最后,它显示存储在 info 中的文本。
sys_brk()
系统调用由内核提供,用于在应用程序映像的数据部分之后分配内存,而无需在稍后移动它。此调用允许设置数据部分的最高可用地址。系统调用的唯一参数是需要设置的最高内存地址,该值存储在EBX寄存器中。
这个程序使用 sys_brk()
系统调用分配了16 KB的内存:
assemblyCopy codesection .text ? global _start ? ? ? ?;必须为使用gcc而声明 _start: ? ? ? ? ? ? ? ? ;告知链接器入口点 ? ? mov eax, 45 ? ? ? ? ?;sys_brk ? xor ebx, ebx ? int 80h ? ? add eax, 16384 ? ? ? ;要保留的字节数 ? mov ebx, eax ? mov eax, 45 ? ? ? ? ?;sys_brk ? int 80h ? cmp eax, 0 ? jl exit ? ? ? ? ? ? ?;如果出错则退出 ? mov edi, eax ? ? ? ? ;EDI = 最高可用地址 ? sub edi, 4 ? ? ? ? ? ;指向最后一个DWORD ? ? mov ecx, 4096 ? ? ? ?;已分配的DWORD数 ? xor eax, eax ? ? ? ? ;清空eax ? std ? ? ? ? ? ? ? ? ? ;反向 ? rep stosd ? ? ? ? ? ?;对整个分配区域重复 ? cld ? ? ? ? ? ? ? ? ? ;将DF标志设置回正常状态 ? mov eax, 4 ? mov ebx, 1 ? mov ecx, msg ? mov edx, len ? int 80h ? ? ? ? ? ? ? ;打印一条消息 ? exit: ? mov eax, 1 ? xor ebx, ebx ? int 80h ? section .data msg ? ? db "分配了16 KB的内存!", 10 len ? ? equ $ - msg
历时两周,差不多算是对汇编入了个门,也算是收获不少,在未来有时间一定会更加深入学习汇编。