mov和movq
mov指令在汇编中有很多种,这里主要讨论两种:mov和movq。
- mov:可以操作不同大小的数据,
- movq:移动特定四字节的数据,q是QuadWord的意思。movq指令通常用于64位寄存器
寄存器
在x86中有16个寄存器,其中8个是沿用的8086的命名:
新增的寄存器其名字就是%r8~%r15。
在这些寄存器中,最特殊的寄存器就是%rsp:该寄存器存储的是栈指针,用于表示程序执行到了何处。
栈指针我们不能去随意的操控它,因为栈中会有很多控制信息。
我们能看到,还有个寄存器在16个寄存器外被列出,它就是%rip,这个寄存器存放的是指令指针,
即:当前正在执行的指令的地址,%rip也不是能够以正常方式访问的寄存器,但是有一些技巧能够让我们访问它。该寄存器只是告诉我们:程序执行到哪了,正在执行程序的哪个部分。
寄存器与数据存储
首先,通常来说,整数和浮点数存储于不同的寄存器中(两套寄存器),这点在后面会说。
函数参数与寄存器
参数参数总是存于寄存器中,并且这个规律是固定的:第一个参数存储于%rdi,第二个参数存储于%rsi,第三个参数在%rdx,而函数的返回值存储在%rax中。
这里可以看看我们在C语言中的swap函数是怎么用汇编实现的:
swap:
movq (%rdi), %rax # t0 = *xp
movq (%rsi), %rdx # t1 = *yp
movq %rdx, (%rdi) # *xp = t1
movq %rax, (%rsi) # *yp = t0
ret
看得不是很明白,这里来说说movq的用法:
首先一点,上面已经说了movq是专门操作四字的:
movq source, destination
# source是数据来源
# destination是数据目的地
- %rdi是一个寄存器,它存储的是一个内存地址
- (%rdi)表示使用%rdi所存储的地址去访问内存中的内容
函数参数的存放
可以看到,函数的前六个参数都存储与从上至下的六个寄存器中,这些寄存器没有什么规律,只能记住它们。
若是函数的参数超过6个,多出来的参数将放入栈中。
需要注意的是:只有整数存储在这些寄存器中,也就是说,上面的寄存器只适用于整数,而浮点数放在另一组寄存器中。
浮点数的存放
上面说到,浮点数单独放在一系列寄存器中,即:XMM寄存器组,由16个寄存器组成,每个大小都为16字节,它的指令集被称为SIMD(单指令多数据操作),它的命名也相对简单很多,仅仅是%xmm0~%xmm15。
[!资料]
XMM 寄存器是 Intel x86 架构中的一种特殊寄存器,用于执行 SIMD(单指令多数据)操作。SIMD 允许在单个指令中处理多个数据元素,从而加速许多数值计算和图形处理操作。XMM 寄存器是一组 128 位的寄存器,每个寄存器可以容纳不同类型的数据,如单精度浮点数、双精度浮点数或整数。在多媒体和科学计算等应用中,XMM 寄存器的使用非常普遍。
对于使用了SIMD的函数来说,返回值会放在%xmm0中,而若是没使用SIMD,则放在通用寄存器中(%rax)。
在函数传参中,若是一个指针或是int等类型,会放在%rXX寄存器中,而若是一个浮点数则会放在%xmmX寄存器中:
数组和索引偏移值
在汇编中,我们也有专门的写法用于数组:
![[…/图片资源/汇编/数组的表示.png]]
主要就是记住“()”的作用(间接寻址):用于指示内存引用。在访问内存时,括号内放置的是内存地址或地址表达式,“()”会引用该地址所存储的数据。
使用汇编计算x*12
我们使用C编写一个函数将传进来的数值乘以12:
long m12(long x) {
return x * 12;
}
其相对应的汇编版本为:
leaq (%rdi, %rdi, 2), %rax # t <- x+x+x
salq $2, %rax # return
lea与leaq
lea指令用于地址计算,它的作用是:将一个有效地址加载到目的操作数中,而leaq和lea没什么大的差别,q是quadword的意思,表示对64位数据进行操作,所有的结尾有q的指令都是这个意思:
# 将源操作数的地址计算结果存储到目标操作数中,而不会实际访问内存
leaq source, destinaion
需要注意的是:leaq不执行内存访问,只是计算有效地址将其加载到目标寄存器中,而mov则是将数据从一个位置复制到另一个位置。
该指令常用于创建指针。
[!对代码的疑惑]
虽然说leaq不涉及内存访问,但是此处确实涉及了内存访问,只是此处,%rdi被视为一个地址而不是内存。
sal和sar
sal用于算数左移操作,而sar是算数右移(Shift Arithmetic Left/Right)。
在CMU15-213的课件中使用的是salq,但是GPT说没有这个指令,我猜测q也是quadword的意思吧。
salq $2, %rax
这个意思就是:对%rax中的数据使用算数左移,移动2位。
这里就有必要说一下什么是算数位移和逻辑位移了。
算数位移和逻辑位移
算数位移主要是针对于有符号类型,而逻辑位移是对于无符号类型的。
- 算数位移:对于右移操作,位移操作会保持符号位不变,被移出的位会被丢弃,空出的位置使用符号位进行填充,这样能够保证运算时,数据的正负不会改变,同时,运算是正确的。
- 逻辑位移:逻辑位移不担心符号位的情况,它只是简单的位移操作,移出的位被丢弃,而空着的位单独使用0进行填充。
更多算数操作
Two Operand Instruction
One Operand Instruction
Example
汇编代码:
arith:
leaq (%rdi, %rsi), %rax
addq %rdx, %rax
leaq (%rsi, %rsi, 2), %rdx
salq $4, %rdx
leaq 4(%rdi, %rdx), %rcx
imulq %rcx, %rax
ret
对应的C语言代码:
long arith(long x, long y, long z) {
long t1 = x + y;
long t2 = z + t1;
long t3 = x + 4;
long t4 = y * 48;
long t5 = t3 + t4;
long rval = t2 * t5;
return rval;
}
在这段代码中可以发现,函数的第三个参数存储与寄存器rdx中。
条件码(condition code)
条件代码一共有八个,条件代码我们不能够直接设置,而是在指令执行后,根据指令操作的结果设置的。
在x86中,条件码被存储在特殊的寄存器中,我们通常称其为标志寄存器,正如上面所说,我们不能够直接对其操作,但是条件码在jmp等指令的使用中,具有至关重要的作用。
这里我们先看看其中的四个条件代码:
条件代码 | 含义 |
---|---|
CF | Carry Flag(For unsigned) |
SF | Sign Flag(for signed) |
ZF | Zero Flag |
OF | Overflow Flag(For signed) |
与条件代码相关的指令
cmpq比较大小
cmpq src2, src1
cmpq b, a => a-b
cmpq的行为就跟减法(subq)差不多,但是两者还是有区别的:
subq x, y
该指令的结果即为:x-=y。
cmpq跟它的不同在于:cmpq只会对这两个值做减法,而不会对结果做任何操作,同时,cmpq会设置上述提到的四个条件代码标志。
使用testq设置条件标志
test指令的唯一目的就是设置条件标志,它拥有两个参数,但是使用的时候常常使用两个相同的参数:
testq src2, src1s
[!资料]
testq 指令将两个操作数进行按位与操作,并更新相应的标志位。与 test 指令类似,如果结果为零,则设置零标志位(ZF),表示结果为零;否则清除零标志位,表示结果不为零。
setX Instructions
set指令的作用是将单个寄存器的单个字节设置为0或1。
而判断将其设置为1还是0是基于条件码的值(最近的一条指令干了什么)
需要注意的是:有的set指令只能操作8位寄存器,而有的能够操作16位和32位,因此,在操作寄存器的时候需要注意寄存器的大小。
比较函数compare
C语言版本:
int gt(long x, long y) {
return x > y;
}
汇编:
cmpq %rsi, %rdi # compare x : y
setg %al # Set when >
movzbl %al, %eax # Zero reset of %rax
ret
[!解释]
cmpq之前说到:它的行为类似于减法,但是它不会对计算的结果进行任何操作,而是设置条件码。然后我们使用setg对%al进行操作,%al是AX寄存器的一部分,在x86架构中,AX寄存器即为%rax,是AX的64位版本。因此,在x86中,%ah和%al分别表示其高八位和第八位,%ax则表示的是低16位。在setX中有一个表格,我们能够看到setg在何时设为0、1,即注释所述。
而setg对这个单字节寄存器(%al)进行设置,即:%rax的最低字节为1。movzbl(Move Zero-extend Byte to Longword)指令可以将单字节拓展至四字节,并且左侧的空余高位使用0进行填充。
在此处,%al是个8位寄存器·,我们将其值复制到%eax中,然后对其进行拓展,以免出现问题。
但是,我们能够发现:%eax好像也不是16个寄存器中的任意一个,实际上,%eax是%rax的低32位。
x86的奇怪特性(不是很理解)
在x86机器中,虽然寄存器有64位,但是所有的计算结果都是32位的,这都被设置在寄存器的低32位,而寄存器的其它位会被设置为0(高32位)。
但是字节级操作仅会影响要设置的那个字节的位置,而不会设置其它位的。(没看懂)。
[!课上老师所述]
如果操作类型是short,它只会对short所占的2字节进行操作,但如果是四字节,寄存器的高32位将会被设置为0.
条件分支(condition branches)
jX Instructions
Jump指令可以将程序跳转至程序的不同地方,跳转条件通过条件码进行控制:
Example
实现一段代码,可以计算x和y相差的绝对值:
long absdiff(long x, long y) {
long result;
if(x > y)
result = x - y;
else
result = y - x;
return result;
}
我们使用条件分支就能够实现对应的汇编语言版本:
absdiff:
cmpq %rsi, %rdi # x:y
jle .L4
movq %rdi, %rax
subq %rsi, %rax
ret
.L4: # x <= y
movq %rsi, %rax
subq %rdi, %rax
ret
在汇编代码中,若是一个名称后面带有一个冒号(”:“),则说明这是一个标签,标签这个东西在目标程序中是不存在的,这只是方便我们对程序的行为进行理解。
但是,上面的汇编代码是十分理论的,编译器在实际运行的过程中会进行优化,例如:此处的if_else,它不会等到它确切需要执行if中的语句还是else中的语句再跳转到其中进行执行,而是会将if中的计算结果和else中的计算结果都先计算后储存起来,最后再去改变实际输出的值。
以下是实际的编译器编译获得的汇编代码:
absdiff:
movq %rdi, %rax # x
subq %rsi, %rax # result = x - y
movq %rsi, %rdx
subq %rdi, %rdx # eval = y - x
cmpq %rsi, %rdi # x:y
cmovle %rdx, %rax # if <= , result = eval
ret
cmovle
在上面的Example中,大多数指令都是先前学过的,没学过的是这里的cmovle,它的意思是Condition Move Less Or Equal,从这个名字就能够理解是什么意思了,它一样是对条件码进行分析,因此在此之前我们先使用了cmp对条件码进行了设置,该指令运行起来就跟move差不多。
但是我们还是不能够大量使用cmovel,例如:当执行任一分支的结果可能会改变程序其它部分的状态。因此,使用它需要特别注意不能有“副作用”。
循环(Loops,but not using loop)
汇编中的循环其实跟C语言中的goto语句是差不多的。
循环其实很简单,在教授的课件中展示了所有的循环:while、do_while、for,这里先给出一段简单的C语言函数:
long pcount_do(unsigned long x) {
long result= 0;
do {
result += x & 0x1;
x >>= 1;
} while(x);
return result;
}
这个函数用于计算参数x的二进制形式拥有多少个比特1,我们还能够写对应的goto语句版本:
long pcount_do(unsigned long x) {
long result = 0;
loop:
result += x & 0x1;
x >>= 1;
if(x)
goto loop;
return result;
}
我们将这个goto版本更新为汇编版本:
movl $0, %eax # result = 0
.L2:
movq %rdi, %rdx
andl $1, %eax # t = x & 0x1
addq %rdx, %rax # result += t
shrq %rdi # x >>= 1
jne .L2 # if(x) goto loop
rep;ret # 这里我感觉有问题,但是不敢确定
写循环对我来说是个很难的过程,需要多加训练。
可以知道,C语言中所有的循环在汇编中都能够使用相同的方法进行实现,而实现的原理也很简单:condition code + jump。
同时,汇编中也有属于自己的Loop,但是课程中似乎是没有提及,以原理为主。
switch语句
在PPT中,有这么一张图,我觉得非常好,它向我们展示了switch的原理:
[!switch的原理]
首先,switch语句中的所有内容会被放入内存中,然后程序会根据case构建出一个“跳转表”(Jump Table),其中存储的是一个地址,指向的是内存中case中的对应代码段的开始。
在switch语句运行到相应的case的时候,就会查找这个跳转表,根据其中所存储的目的代码段的地址进行跳转。
先来看看switch的C语言核心代码:
long switch_eg(long x, long y, long z) {
long w = 1;
switch(x) {
...
}
return w;
}
对应的汇编代码为:
switch_eg:
movq %rdx, %rcx
cmpq $6, %rdi # x:6
ja .L8 # use default
jmp *.L4(, %rdi, 8) # goto *JTab[x]
[!疑惑]
对上述汇编代码有个疑惑:就是在核心代码中使用了“*”,而在此之前,我不太能够理解这个“*”的作用。
它的作用就是将后续的地址中所存储的值作为要跳转的值:jmp *adress
这行代码的作用就是跳转至address这个地址中所储存的值所指向的内存。
同时还有一个值得注意的地方:在上面的代码中,我们使用ja来判断是否在范围内,通过查看之前的笔记能够发现,这是Jump Above的意思,它是针对unsigned的,除此之外还有个jg(Jump Greater),jg是对于signed的。那么,为什么这里使用ja而不是jg呢?
[!解答]
虽然我们在写switch语句的时候,case往往会是个负数,但是它会通过偏置处理(或者做类似的事情,避免出现负数索引),因此,无论case的最小值是多少,都会通过增加偏置值的方式让第一个case变为0,因此我们能够使用unsigned进行比较,即:使用ja而不是jg。
下面这个图片也很有意思,它展示了汇编代码所构建的跳转表和C语言中的switch语句对应的case之间的关系:
过程控制
在C语言中,我们常常用到函数,在我们调用这个函数的时候,我们需要将程序的控制权转移至该函数,同时,若是该函数具有返回值和参数,我们需要对这些参数进行传递,这都是我们需要了解的内容。
而能够实现这些,归功于“栈”。
stack——栈
内存中的栈其实并不是一块特殊的区域,它仅仅是普通内存中的一小块区域:
在上图中,我们能发现几个奇怪的现象:
[!奇怪的现象]
首先我们能够发现,这个栈是倒过来的:栈顶在下,而栈底在上。(这似乎是一个约定)
同时,在向栈中添加东西的时候,需要递减栈指针。
栈是从高地址向低地址增长的,因此当向栈中添加数据时,栈指针实际上是向栈底移动的,指向新添加的数据。
跟数据结构中的栈一样,它同样是个“后入先出”的结构,它们在思想上是完全一致的。
在栈中,%rsp(栈指针)永远指向栈顶,同时,在汇编层面,我们一样使用pushq和popq来对栈进行操作,其中的q是可选操作:
pushq Src
push指令中,寄存器、立即数都能够作为src进行使用。
popq Dest
pop指令用于从栈中读取数据,并将其存储在目标中,对于该指令而言其目标必须是个寄存器。
pop用于读取数据,其读取的地址由当前的栈指针给出,这点很好理解。
汇编中的popq其实就是stack的pop()+top()。
callq和ret
callq label
callq会跳转至label,并将返回的地址推入stack中。
而ret则是从栈中删除地址,并发生跳转,即:ret是call的效果逆转。
注意:rep和retq是一样的指令。
callq和ret都没有完成过程调用和返回的全部任务,它们只完成了控制部分。
现在细说下call和ret的细节:
[!细节说明]
在细节说明之前,先说说相关的寄存器吧:%rsp、%rip,前者已经学过了,是栈指针寄存器,那么%rip呢?
%rip:指令指针寄存器(也叫做程序计数器,其实就是计算机组成原理中的PC),它存储了当前正在执行的指令的地址,即指向正在执行的指令的内存位置。在红框部分,我们使用了callq指令,对mult2这个函数进行调用,我们看看调用前后的差别。
调用前:
- %rip中地址应该是400544,也就是call指令正准备执行
- 栈指针指向0x120这个位置
调用后:
- 栈指针递减8(向栈中推入),来到了0x118,栈顶指向call的下一条指令,即400549
- %rip指向mult2的第一条指令
所以,当等到mult2中的所有指令执行完毕(ret)之后,rip从栈中取走栈顶的元素(即mult中,call的下一条指令)继续执行。
栈帧(stack frame)
栈帧是一个概念性的东西,它它在内存中被动态地创建和销毁,用于支持函数调用和返回的过程。每个函数调用都会在栈上分配一个新的栈帧,当函数执行完毕后,其对应的栈帧会被销毁,释放相应的内存空间。栈帧的创建和销毁过程由函数调用和返回过程中的栈操作完成。
main() {
func1();
}
func1() {
func2();
}
func2() {
// Some code
}
上述代码就是一个常见的函数调用,我们来看看栈帧的分布是啥样的:
+--------------+
| func2() | <- 栈顶
+--------------+
| func1() |
+--------------+
| main() | <- 栈底
+--------------+
在大多数情况下,栈帧由两个指针维护,它们分别是:基指针(帧指针,%rbp)和栈顶指着(%rsp),但基指针不是必须的,而是可选的。
也就是说,栈帧的位置由一个或者两个指针来表示。
在一次函数调用中,基指针会始终指向栈帧的起始地址;而在函数嵌套调用的时候,基指针会不断地移动、发生改变。
我们无法知道关于栈帧的任何信息,我们能知道的只有栈顶指针和栈的顶部是对应于最顶层函数的顶部栈帧。
在之前我们提到:当函数需要六个以上的参数时,哪些无法被寄存器存储的参数将会被存储在栈中,具体来说是对应的函数的栈帧中:
当函数返回的时候,会执行ret指令,ret会始终将栈指针指向的地址作为它的返回地址,因此%rsp在ret之前就回到上一层函数调用的位置是十分关键的。
一些约定(caller-saved和callee-saved)
int yoo() {
...
who();
...
}
根据上图,我们可知:根据caller参数存储和callee存储两种方式的不同,其实还有特殊的数据存储方式,不仅限于之前在[[#函数参数的存放]]所说的,因此再进行个补充:
-
caller存储:
-
callee存储:
callee-saved的示例
[!解释]
上述图片中的代码可能看起来会有点迷糊,这里再解释一下:其实incr2就是一个callee。
在该函数中&rbx寄存器被使用了,因此需要对它的状态进行保存。而图中主要展示的是如何通过栈对寄存器的值进行保存。
当然,如果不需要对该寄存器进行任何操作,也就是假如%rbx不做任何的修改,也就不需要将其修改前的值保存在栈上了。
数据在内存中的存储
数组(Array)
其实这部分在先前已经[[#数组和索引偏移值|说过一点了]],只是在此处我们对其进行一个加深理解,我们先来一段很容易理解的C语言代码:
#define ZLen 5
typedef int zip_dig[ZLen];
int get_digit(zip_dig z, int digit) {
return z[digit];
}
上述函数的意思很好理解,我们看看汇编代码如何实现其核心内容:
# %rdi = z
# %rsi = digit
mov1 (%rdi, %rsi, 4), %eax # z[digit]
结合先前的笔记,这里想必也还是很好理解的。
假如现在我有一个二维数组:T A[R][C]:
- 该数组类型为T
- R:Rows,C:columns
- 类型T需要K字节
该二维数组的内存排布如下,它是线性的,但实际上,我们能将其看作一个矩阵(即初始化的时候那般):
那么,内存是怎么来计算二元数组的元素位置的呢?
还是这么个结构。先来看一个公式,用于计算二维数组第二维的起始地址:
也还是很好理解的。
那么假如我现在要定位到一个特定的元素呢?例如:A[i][j],又应该如何表达呢?
PPT中最底下的红色公式即为最直观、最好理解的。
现在,我们知道了二维数组在内存中的分配和它的地址计算理论,现在,我们实际来看看汇编代码是如何对二维数组中的特定元素进行操作的:
可以说就是按照公式来的,没什么看不懂的地方。
结构(Struct)
结构体的原理是怎样的呢?
[!解释]
对于一个最简单的结构体,它的内存拜访类似与数组:我们想要访问结构体中的成员,是通过计算地址偏移量+结构体所处的内存的首地址完成的。
这只是基本原理,实际上的情况要比这个更复杂,首先就要提及内存对齐这个概念。
内存对齐
内存对齐实际上是个硬件问题,内存对齐能够提高硬件的执行效率,能够减少一些不必要的步骤。
当一个结构被分配内存空间时,编译器实际上会在分配空间时,在数据结构中插入一些空白的、不被使用的字节。它这么做只是为保持对齐,即:K字节的数据结构放在内存地址为K的整数倍的地方。
同时,内存对齐还有下层对齐要求:
说白了就是:在此示例中,整个数据结构必须在8字符边界上对齐,因为在此示例中,S2中有一个double类型,它是该结构体中最大的数据结构。
还是上面这个结构体,假如我有个结构体数组,它在内存中的排布应该是什么样的呢?
[!注意]
在上图中有一点没有体现:数组在内存中其实是不存在的,内存对齐不会以数组大小定界,而是以内存中的基本数据结构,如:char,short,int,float,double等。
即:内存对齐仅对最原始的数据结构而言,不包括聚合数据结构。
所以,我们在声明结构体的时候就应该考虑下它的字段声明,以浪费最小的空间:
x86 Linux机器的内存布局
虽然x84机器名义上是有64位的,但是实际上会限制只使用47位的地址。
[!资料]
在x86架构的机器上,通常我们能够使用的只有47位,这是由于x86架构中采用了一种称为"虚拟地址扩展"或者"地址扩展"的技术。这种技术是为了提高系统的内存管理能力和安全性而设计的。
在x86架构中,虚拟地址空间通常是2^64(64位)大小的,但实际上操作系统一般只允许应用程序访问其中的一部分,而不是全部。这部分被称为"用户空间",一般情况下是47位。剩下的部分则是"内核空间",用于操作系统的内核代码和数据。
因此,内存的最大地址为:0007FFFFFFFFFF(二进制表示为44个1),而在Linux x86机器中,该地址就是栈的地址,位于整个空间的最顶部。
在大多数的x86机器中,栈被限定在8MB,这就意味着如果试图用栈指针去访问一个超过8MB这个范围的地址,将会产生一个段错误。
- stack(栈):大小限制为8MB,存放的数据例如本地变量(local variables)
- Heap(堆):动态分配的内存
- Data(数据区):存放程序开始时分配的数据,用于存放全局变量等
- Text/Shared Libraries(代码段):用于存放来源于可执行文件的代码,它是只读的