第四讲:算术运算
category:
- 汇编语言
第四讲:算术运算
负数
前面描述的所有算术运算符都假设输入值为正。那么如果输入有负数该如何处理?一般来说, 有四种处理办法:
符号位(源码): 在十进制中,我们用
-
符号表示负数, 那么为什么不用一个位来表示这个数字是负数呢?其实,数字的最高位就是用来做这个事情。例如 00000011b 是 3,但 10000011b 是 -3。虽然这个办法对我们来说很容易理解,但是在实现时确有几个缺点:- 现在0有两种表示,一个是正数0,一个是负数0。
- 值的算术更加复杂,因为我们必须检查两个值的符号位。如果我们忘记并执行无符号运算,结果将是无意义的。
- CPU 必须根据符号位的值在执行加法运算和执行减法运算之间切换。也就是说,CPU 无法将自己设置为执行加法/减法,直到它也知道正在操作的值。 浮点值使用符号位,部分原因是它们希望同时具有正零和负零。
偏置表示法(biasd)。这表示所有值(不仅仅是负值)都叠加一个固定量。所以0不是0,而是0+127(=01111111b)什么的。 3是3+127=10000010b,-3是-3+127=01111100b。请注意,高位用作一种"正号位";如果已设置,则该数字为正数(大于 0)。
加法和减法(在某种程度上)正常工作,只是我们必须在执行正常运算后“消除”值的偏差。例如,添加 3 和 -3 得出:
10000010 + 01111100 ───────────── 11111110 = 127 (254-127)
我们必须再次从结果中减去127,因为添加了两个127的“副本”,所以最终的结果实际上是
11111110
01111111 ───────────── 01111111 = 0 (127-127)
- 正数看起来很奇怪。零看起来很奇怪。 - 检测非负数 (≥ 0) 很棘手。
反码(Ones-complement):这将负值表示为相应数字的二进制逆(翻转所有位)。所以 3 是 00000011b,而 -3 是 11111100b。请注意,我们可以通过检查高位来确定数字是否为负数。如果已设置,则该数字为负数。但高位不是符号位。我们不能通过简单地翻转高位来使负数变为正数,我们必须翻转所有位。 如果我们执行+3和-3的二进制加法,我们会得到:
00000011 + 11111100 ───────────── 11111111 = -0
与符号位表示一样,0 有两种表示形式,正数(如 00000000b)和负数(如 11111111b)。然而,我们可以使用普通的二进制加法来添加有符号数,并且当解释为补码数时,结果将是正确的。 减法有点困难:
111111 00000011 = 3 - 00000100 = 4 ───────────── 11111110 = -1
当借用的 1 到达最左边时,它会“环绕”并从答案的低位借用。这称为末端借用。当这种情况发生时,我们必须做出调整。
补码(Twos-complement):将负数的值按位取反再加1。8位数字的的补码可以被定义为。 所以 3 是 00000011b 而 -3 是 11111101b。请注意,高位仍可用于检测负值。你可以认为补码是反码加上1。
所有的算术运算符都正常工作,无需传入值的符号。我们可以进行正常的加法,无论其中一个或两个输入是否为负,结果都会"有意义"。例如:
1111111 00000011 + 11111101 ───────────── 00000000
所有算术运算都正常工作,无需知道传入值的符号。我们可以进行正常的加法,无论其中一个或两个输入是否为负,结果都会“有意义”。例如。
1111111 00000011 + 11111101 ───────────── 00000000
0 只有一种表示形式,而不是两种,而且就是 0b。
表示的值的范围(通过字节)为 -128 到 +127。
这里不需要在二进制补码中进行结束借用/进位。
x86-x64系统(以及许多其他系统)使用二进制补码,因为它不需要任何额外的电路来表示或操作负数。你只需进行“正常”的二进制加法,结果就是正确的。
(这里原文中的介绍相对比较笼统,细节可以参考维基百科,原码, 反码,补码。
乘法是一种非常昂贵的运算,我们通常不会费心尝试在数字表示“内部”进行它。相反,我们只是将两个操作数都设为正数,然后相乘,然后根据需要对结果取负。
另一个例子:
11 1
01110110 = 118
+ 11100101 = -27
────────────
01011011 = 91
一个数(正数或者负数)进行取反:
- 翻转每个比特
- 加1
无论输入值是正值还是负值,这都有效。例如:
11100101 = -27
00011010 (flip all bits)
00011011 = 27 (add 1)
00011011 = 27
11100100 (flip all bits)
11100101 = -27 (add 1)
数据尺寸的延展
假设我们有一个8位的数字,我们希望将其存储到16位的空间中。如果这个数字是无符号的,那么这很容易。直接将数字复制到低8位,然后用0填充到高8位。但是如果该值是有符号的(二进制补码)怎么办? 在这种情况下,为了获取等效的值,我们需要对扩展后的数字添加符号位,将原本的数字中的高位填充扩充后的数字的高位。如果高位原来为1,则高8位必须全部为1,否则应为0。
许多可以“混合”不同字长值的算术运算有两种形式:“零扩展”(用 0 填充)的无符号形式和符号扩展(复制高位)的有符号形式。
内存中的表示
对于单字节值,使用上述表示法。然而,对于多字节值,有几个选项。考虑 16 位值。当我们将其放入内存地址 a 时,可以通过两种方式完成:
我们可以将低字节(8 位)放入地址 a,将高字节放入 a+1。这称为小端字节序,因为低字节在前。
我们可以将低字节放入地址a+1,将高字节放入a。这称为大端字节序,因为高字节在前。
如果大尾数法看起来很疯狂,请考虑一下这就是从左到右写入 16 位值的方式,假设内存地址向右增加:
high byte low byte
addr addr + 1
Intel系统使用Little-endian,因此我们不需要担心big-endian。 Big-endian 由一些微控制器 (AVR32) 和一些 big-iron 处理器使用。如果您正在编写文件格式(或网络协议),那么您必须定义“标准”字节顺序,并且必须确保在必要时在软件中完成正确的翻译。但对于我们来说,如果要访问内存中一个16位值的高字节,可以在地址+1处找到。
访问内存
在 64 位模式下,所有地址都是64位的,因此必须使用完整寄存器(rax
、rbx
等)来存储地址。正如我们所见,.data
部分中用于定义字符串的标签实际上是该字符串的地址,因此我们可以将字符串 my_text
的地址加载到 rax
中:
mov rax, my_text
您可以将这种用法中的 rax
视为指针类型变量,保存某个变量的地址。
我们可以通过将内存地址放在方括号中来执行解引用操作:
mov al, byte [my_text]
这里并不严格要求带上字节限定符,但带上它是一个很好的做法。
下面展示一个容易犯错误的例子:
mov rax, [my_text] ; Read one *qword* from my_text
它读取的不是一个字节而是八个字节(qword)。
当你增加字节限定符,而不修改寄存器时进行汇编将会报错。因为限定符和寄存器的大小不匹配。
mov rax, byte [my_text] ; Read one byte from my_text
下面是一个完整的例子:
section .data
msg: db "Hello, world!"
MSGLEN: equ $-msg
section .text
global _start
_start:
mov rax, byte [newline]
mov rax, 60 ; Syscall code in rax
mov rdi, 0 ; First parameter in rdi
syscall ; End process
; Normal exit syscall...
进行编译:
yasm -g dwarf2 -f elf64 hello.s -l hello.lst
报错内容如下所示:
error: invalid size for operand 2
这里特别要注意mov
要求源操作数和目的操作数大小相等。如果操作数的大小不相等,就需要使用movzx
。
实际上,当我们正在处理一个字符串时,大概率我们会想要迭代它,而不仅仅是访问第一个字节。将地址 my_text
放入寄存器然后"解引用"会更有用:
mov rsi, my_text
mov al, byte [rsi]
然后我们可以通过 inc rsi
来增加 rsi
的值从而访问字符串中的下一个字节。因为 my_text
是立即数,所以我们不能递增它。 (同样,[rsi]
上的字节限定符不是必需的,因为它可以从 al
的大小推断出来。)
算术运算
加法和减法
add
和sub
在两个相同大小的操作数之间执行加法和减法。sub
最终会转换为加法,只是将第二个操作数执行取反再加1的操作。
add
和sub
指令的格式如下所示:
add dest, src ; dest += src
sub dest, src ; dest -= src
与许多操作一样,add
和 sub
是二元的,它们采用两个操作数:目标和源,目标既充当第二个输入,又充当操作输出的目标。
标记
add
和sub
会设置/取消设置 OF、SF、ZF、AF、CF 和 PF 标志:
- 对于有符号运算,OF 标志指示发生了上溢/下溢。如果结果的符号位不正确,则设置该位,因为正确的结果对于目标来说太大/太小而无法保存。例如,有符号 127+127 将产生溢出。 (无符号 127+127 = 254 仍然可以容纳在无符号字节中,因此不会设置进位标志。) 结果的正确符号很容易确定:如果两个输入均为正,则结果应为正;如果两者均为负数,则结果应为负数。如果一个为正,另一个为负,那么结果的正确符号就更难确定,但在这种情况下事实证明这并不重要。正值和负值相加不可能溢出。减法的处理方式类似,只是第二个操作数的符号被翻转(即,a - b 被视为 a + (-b))。 如果输入无符号,则 OF 标志仍会设置/取消设置,但其值毫无意义。
- 对于无符号运算,CF 标志表示末尾"剩余"了额外的进位/借位。这表明操作结果太大/太小而无法容纳目的地。例如,255+127 对于一个字节来说太大,并且会设置进位标志。它不会设置溢出标志,因为 255 无符号 = -1 有符号,而 127-1 = 126 适合有符号字节。 请注意,在无符号减法 a - b 之后,如果设置了进位标志(CF),则表示 b > a。
- 如果结果为 0(全零位),则设置 ZF 标志。
- SF 标志设置为符号位的副本(对于有符号运算,如果结果为负则设置)。对于无符号运算,它只是结果高位的副本。
- 如果结果的低字节中设置的(=1)位的数量是奇数,则PF标志被设置。奇偶校验标志是历史产物,使用不多,部分原因是它不给出整个结果的奇偶校验,只给出它的最低字节。
- 我们将忽略 AF 标志,因为它仅在 BCD 算术上下文中有意义。
请注意,所有标志都会在所有操作中设置/清除,但某些标志仅对有符号/无符号操作有意义。加/减指令不知道您是否正在执行有符号或无符号的操作,因此您需要确保检查正在执行的操作类型的正确标志。
111 11
10110011 = 179 (unsigned) = -77 (signed)
+ 01100110 = 102 (unsigned) = 102 (signed)
────────────
1 00011001 = 25 (unsigned) = 25 (signed)
- 解释为有符号值时,一个输入为负,另一个输入为正,因此结果的符号保证是正确的。
OF= 0
。 - 解释为无符号,加法产生一个额外的进位,
CF = 1
。 - 结果不为 0,因此 ZF = 0。
- 结果中低字节为1的位数为奇数,因此 PF = 1
- 高位未置位(结果为正),因此 SF = 0。
下面是两个表格,总结了 OF、CF、ZF 和 SF 标志的加法和减法结果:https://stackoverflow.com/a/8982549/197699
ADD
A B A + B Flags
--------------- ---------------- --------------- -----------------
h | ud | d | h | ud | d | h | ud | d | OF | SF | ZF | CF
---+------+-------+----+------+-------+----+------+-------+----+----+----+---
7F | 127 | 127 | 0 | 0 | 0 | 7F | 127 | 127 | 0 | 0 | 0 | 0
FF | 255 | -1 | 7F | 127 | 127 | 7E | 126 | 126 | 0 | 0 | 0 | 1
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0
FF | 255 | -1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 1
FF | 255 | -1 | 0 | 0 | 0 | FF | 255 | -1 | 0 | 1 | 0 | 0
FF | 255 | -1 | FF | 255 | -1 | FE | 254 | -2 | 0 | 1 | 0 | 1
FF | 255 | -1 | 80 | 128 | -128 | 7F | 127 | 127 | 1 | 0 | 0 | 1
80 | 128 | -128 | 80 | 128 | -128 | 0 | 0 | 0 | 1 | 0 | 1 | 1
7F | 127 | 127 | 7F | 127 | 127 | FE | 254 | -2 | 1 | 1 | 0 | 0
SUB
A B A - B Flags
--------------- ---------------- --------------- -----------------
h | ud | d | h | ud | d | h | ud | d || OF | SF | ZF | CF
----+------+-------+----+------+-------+----+------+-------++----+----+----+----
FF | 255 | -1 | FE | 254 | -2 | 1 | 1 | 1 || 0 | 0 | 0 | 0
7E | 126 | 126 | FF | 255 | -1 | 7F | 127 | 127 || 0 | 0 | 0 | 1
FF | 255 | -1 | FF | 255 | -1 | 0 | 0 | 0 || 0 | 0 | 1 | 0
FF | 255 | -1 | 7F | 127 | 127 | 80 | 128 | -128 || 0 | 1 | 0 | 0
FE | 254 | -2 | FF | 255 | -1 | FF | 255 | -1 || 0 | 1 | 0 | 1
FE | 254 | -2 | 7F | 127 | 127 | 7F | 127 | 127 || 1 | 0 | 0 | 0
7F | 127 | 127 | FF | 255 | -1 | 80 | 128 | -128 || 1 | 1 | 0 | 1
递增和递减
inc
和 dec
递增/递减其单个操作数,该操作数可以是寄存器或内存位置。 inc
和 dec
不会像 add r, 1
或 sub r, 1
指令那样修改进位标志。标志 OF、SF、ZF、AF 和 PF 按预期设置/清除。当用于有符号值时,行为仍然是正确的(增加负值使其更接近 0,减少负值使其更负)。
inc dest ; 类似于C/C++中的++dest
dec dest ; 类似于C/C++中的--dest
大于64位数字的加法/减法
我们拥有的最大寄存器是 64 位 (qword)。如果我们想对 128 位操作数(表示为 rdx:rax
)执行加法/减法怎么办?让我们考虑一下,如果我们本机可以执行的唯一加法是字节大小的,那么我们将如何执行字大小的加法:
111111← 1111
00101101 11001101
+ 00010010 10101011
─────────────────────
01000000 01111000
添加低字节会产生一个额外的进位 (CF = 1),然后我们用它来开始添加高字节。我们实际上需要两种加法:
低字节加法,不以进位开头(忽略CF)
高字节加法,使用CF开始加法。
这就是我们执行大于qword加法的方式,还有另一种加法操作,add-with-carry,adc
它使用进位标志CF的状态作为第一位加法的输入。
adc dest, src ; dest = dest + src + CF
对于减法,有 sbb,subtract-with-borrow。
因此,要将双 qword rdx:rax 添加到 rcx:rbx,我们会这样做
add rax, rbx
adc rdx, rcx
减法的类似物是 sbb,即借位减法。
试了下128bit加法,对应的汇编中的确使用了adc
指令进行128bit的加法。
乘法和除法
乘法和除法比加法/减法更复杂。我们稍后将更详细地介绍它们,但现在:
- 两个 n 位值相乘的结果最多可达 2n 位。因此,当将两个 qword 值相乘时,我们需要在某个地方存储双四字结果。
- 除法有相反的问题,我们可能想要将双 qword 值除以 qword 除数。
- 有符号乘法与无符号乘法不同,除法也类似。每个都使用不同的指令。
- 如您所料,乘法指令以双操作数形式 (dest *= src) 存在,但也以单操作数形式存在,其中 rax 寄存器隐式用作操作的目标,甚至是三操作数形式,相当于dest = src * 立即数。除法指令仅采用单个操作数,并且始终将其结果存储到 rax 和 rdx 的组合中。
为了存储双 qword(128 位)结果,我们使用 rax 和 rdx 的组合:rax 存储低 qword,而 rdx 存储高 qword。我们将此组合写为 rdx:rax。 (使用类似的符号,我们可以说 ax = ah:al。)较小的乘法不需要此扩展。
无符号/有符号乘法指令分别为 mul
和 imul
指令 | 描述 |
---|---|
mul rm | rdx:rax *= rm, unsigned |
imul rm | rdx:rax *= rm, signed |
imul r, rm | r *= rm, signed |
imul r, rm, imm | r = rm * imm, signed |
如果结果的符号不正确,则 CF 和 OF 标志会一起设置/清除。如果乘法结果不适合目标,则结果将被截断(丢弃高位)。其他标志中的值未定义。
除法只有一个操作数形式,其中操作数包含除数;目的地(也是股息)位于 rdx:rax 中。 div/idiv 的结果既是 rax 中的向下舍入结果,也是 rdx 中的余数(即模或 %)。与 C++ 不同,在 C++ 中,我们用 / 表示整数除法,用 % 表示整数模,而在汇编中,一条指令就给出了两个结果。
指令 | 描述 |
---|---|
div rm | rax = rdx:rax / rm and rdx = rdx:rax % rm, unsigned |
idiv rm | rax = rdx:rax / rm and rdx = rdx:rax % rm, signed |
除法溢出不是通过设置进位标志来指示的,而是通过除法错误异常 #DE 来指示的,该异常作为信号 SIGFPE 发送到我们的进程
目前,这会立即使我们的程序崩溃,但稍后我们将看到如何编写信号处理程序以更优雅的方式处理它。 (当然,我们也可以通过在执行除法之前检查操作数来避免溢出。)
简单的循环
因为做任何有趣的事情都需要循环,所以我们将介绍loop
指令。loop
采用单个操作数,一个要跳转到的标签(在内部,循环存储标签地址相对于当前指令地址的偏移量)。loop
的操作是执行以下步骤:
- 对
rcx
进行递减 - 如果
rcx != 0
,跳转到标签处。 - 如果
rcx == 0
,则往下继续执行。
因此,基本循环的结构如下所示:
mov rcx, init ; Initialize rcx > 0
.start_loop:
; ... Perform loop operation using rcx
loop .start_loop
; ... Continue after end of loop
这大致相当于 C/C++ 风格的 do-while
循环:
rcx = init;
do {
// ... Perform loop operation
--rcx;
} while(rcx != 0);
请注意,因为 rcx
是允许系统调用会修改的寄存器之一,所以如果您在循环内执行任何系统调用,则需要在调用之前保存 ```rcx````,然后在调用之后恢复它。
作为一个演示,我们可以修改"Hello, world"程序来反向打印"Hello, world!",从末尾到开头一次打印一个字符。(我们仍然会使用 write
系统调用,我们只是告诉它打印单个字符而不是整个字符串。)
首先我们先考虑如何在C/C++语言中实现。下面时最基本的"Hello, world!"程序。
int main()
{
char* msg = "Hello, world!";
const int MSGLEN = 13;
cout.write(msg,MSGLEN); // equiv. to write syscall
}
要一次写入一个字符,我们需要一个从字符串末尾开始的循环,一次向后写入一个字符,如下所示:
int main()
{
char* msg = "Hello, world!";
const int MSGLEN = 13;
int c = MSGLEN;
do {
char* addr = msg + c - 1;
cout.write(addr,1);
--c;
} while(c != 0);
}
我特意以镜像循环指令执行的方式编写 do-while 循环,以便更容易转换为汇编。
我们原始的HelloWorld程序是这样的:
section .data
msg: db 10, "Hello, world!"
MSGLEN: equ $-msg
section .text
;; Program code goes here
global _start
_start:
mov rax, 1 ; Syscall code in rax
mov rdi, 1 ; 1st arg, file desc. to write to
mov rsi, msg ; 2nd arg, addr. of message
mov rdx, MSGLEN ; 3rd arg, num. of chars to print
syscall
;; Terminate process
mov rax, 60 ; Syscall code in rax
mov rdi, 0 ; First parameter in rdi
syscall ; End process
我已从文本中删除了尾随的 10 (\n),并将其移至开头,因此它仍会在“末尾”打印。
第一个系统调用将在循环内,因此我们可以添加:
section .data
msg: db 10, "Hello, world!"
MSGLEN: equ $-msg
section .text
;; Program code goes here
global _start
_start:
mov rdi, 1 ; 1st arg, file desc. to write to
mov rdx, 1 ; 3rd arg, num. of chars to print
.begin_loop
mov rax, 1 ; Syscall code in rax
mov rsi, msg ; 2nd arg, addr. of message
;other code
syscall
loop .begin_loop
;; Terminate process
mov rax, 60 ; Syscall code in rax
mov rdi, 0 ; First parameter in rdi
syscall ; End process
请注意,系统调用保留了 rdi
和 rdx
,因此我们可以在循环外设置它们。然而,rax
用于返回值,因此我们应该每次循环时都设置它,而rsi
是字符串开头的地址,它会随着我们在字符串中移动而改变。
我们需要将 rcx
初始化为字符串的长度:
然后我们将 rsi
(要写入的地址)设置为 rcx + msg - 1
mov rsi, rcx
add rsi, msg-1
(add a、b
执行加法,a += b
和 dec a
减量 --a
。两者都受到通常的限制:没有内存到内存的操作、两个操作数大小相同等。因为 msg
是常量,msg-1
在汇编时执行。)
最后,请注意,rcx
是允许系统调用会修改的寄存器之一(r11
是另一个),因此我们必须在系统调用之前将其保存到另一个安全的寄存器中,然后在系统调用之后恢复它:
mov r15, rcx
syscall
mov rcx, r15
最终形成的代码如下:
section .data
msg: db 10, "Hello, world!"
MSGLEN: equ $-msg
section .text
global _start
_start:
mov rdi, 1 ; 1st arg, file desc. to write to
mov rdx, 1 ; 3rd arg, num. of chars to print
mov rcx, MSGLEN ; loop counter = MSGLEN
.begin_loop
; Print 1 char at [msg + rcx - 1]
mov rax, 1 ; Syscall code in rax
mov rsi, rcx ; rsi = addr to print
add rsi, msg
dec rsi ;[msg + rcx - 1]
mov r15, rcx ; Save rcx before syscall
syscall
mov rcx, r15 ; Restore rcx
loop .begin_loop
;; Terminate process
mov rax, 60 ; Syscall code in rax
mov rdi, 0 ; First parameter in rdi
syscall ; End process
yasm -g dwarf2 -f elf64 hello2.s -l hello2.lst
ld -g -o hello2 hello2.o
执行结果如下:
./hello
!dlrow ,olleH
本地标签
当编写函数内部存在的循环或其他标签时,通过以句号开头将它们编写为本地标签非常有用。本地标签实际上是以最近的非本地标签命名的,因此 .begin_loop
的全名实际上是 _start.begin_loop
。标签通常每个文件只能定义一次,因此如果没有本地标签,我们编写的其他函数就无法使用标签 begin_loop
。
负的rcx
如果您好奇,让我们考虑一下如果 rcx
为负并且我们将其递减会发生什么。例如,如果 rcx = 11111111 (= -1)
,并且我们递减:
11111111
- 00000001
────────────
11111110 = -2
换句话说,结果正是您所期望的(但与循环一起使用时不是特别有用)。
loop
的变化
循环指令有两种变体,用于测试零标志 (ZF) 以及 rcx 的值:
loope
: 循环相等, 递减rcx
,如果rcx != 0
且ZF == 1
则循环loopne
: 循环不等于;递减rcx
,如果rcx != 0
且ZF == 0
则循环
零标志与(不)等式的概念相关,因为如果我们执行减法:
sub a, b
并且 a == b
,则将设置零标志,否则将取消设置。
包含文件
与 C/C++ 一样,yasm
有一种简单的机制将一个 .s
文件的内容包含到另一个文件中:
%include "source.s"
将 source.s
的内容复制到当前的汇编文件中。例如,我们可以开始集中许多系统调用定义,包括一个包含文件:
;;;
;;; sysdefs.s
;;;
[section .data]
SYS_write equ 1
SYS_exit equ 60
SYS_stdin equ 0
SYS_stdout equ 1
...
__SECT__
[section .data]
和 __SECT__
的东西很“神奇”,可以暂时切换到数据部分,然后切换回我们之前所在的任何部分。
简单的函数
正如我们稍后将看到的,从汇编调用 C 函数,或者使我们的汇编函数可从 C/C++ 调用,需要一些额外的步骤来正确设置堆栈。然而,只要我们纯粹停留在"汇编领域",我们就不需要担心额外的复杂性;我们基本上可以让函数按照我们喜欢的方式工作。唯一的要求是我们能够从函数返回并回到原来的位置。
处理函数的两条指令是call和ret。两者都在内部使用堆栈:
call 接受一个地址(.text 部分中的标签)并执行两个步骤:将 rip(指令指针)压入堆栈,然后跳转到给定的地址。请记住,rip 指向要执行的下一条指令,因此压入堆栈的值实际上是函数的返回地址,即函数返回时应恢复执行的地址。
ret 弹出栈顶元素并跳转到该元素。 rip 自动更新为以下指令。
它们协同工作如下(地址只是虚构的):
地址 | 指令 | 地址 | 指令 |
---|---|---|---|
_start: | my_func: | ||
0x100 | call my_func | 0x200 | mov eax, ... |
0x108 | mov rbx,rax | 0x208 | ... |
... | 0x280 | ... |
当 my_func
执行时,堆栈包含 0x108,即返回地址。当执行 ret 时,该地址将从堆栈中弹出,我们从该点恢复执行。(稍后,我们会看到这意味着如果您将堆栈用于其他任何用途,则必须确保在返回之前已弹出所有内容,因此此时堆栈上唯一的内容就是退货地址。)
尽管我们可以使用任何我们喜欢的"调用约定",但在传递参数和返回结果方面,您应该尝试坚持最终成为调用函数的约定:
- 将前六个参数传递到寄存器 rdi、rsi、rdx、rcx、r8 和 r9 中。请注意,这与系统调用约定(rcx 而不是 r10)略有不同。
- 以 rax 格式返回结果
作为示例,让我们编写一个函数来打印字符串(以地址和长度形式给出)并在末尾添加换行符。这将结束对我们一直在使用的 write 系统调用的调用。
section .data
newline: db 10
section .text
write_ln:
; rdi = address
; rsi = length
mov rax, 1
mov rdx, rsi
mov rsi, rdi
mov rdi, 1
syscall
mov rax, 1
mov rdi, 1
mov rsi, newline
mov rdx, 1
syscall
ret
section .data
msg: db "Hello, world!"
MSGLEN: equ $-msg
section .text
mov rdi, msg
mov rsi, MSGLEN
call write_ln
; Normal exit syscall...
sys_write:
; rdi = address
; rsi = length
mov rax, 1
mov rdx, rsi
mov rsi, rdi
mov rdi, 1
syscall
ret
函数指针
传递给 call 的地址可以是寄存器,而不仅仅是标签:
mov r11, my_function
call r11
这相当于通过函数指针调用函数。
附录
原文中存在错误,原文中的write_ln是这样的,rdi表示的是字符串的地址,但是这里已经被立即数1进行了覆盖。并且rsi存放的内容也被rdi覆盖。
mov rax, 1
mov rdi, 1
mov rsi, rdi
mov rdx, rsi
因此需要调整顺序:
mov rax, 1
mov rdx, rsi
mov rsi, rdi
mov rdi, 1
原文链接: https://staffwww.fullcoll.edu/aclifton/cs241/lecture-arithmetic-functions.html