位数扩展

  • 有符号数的符号位(most significant number)为 1 时,扩展时高位补 1
  • 有符号数的符号位(most significant number) 为 0 时,扩展时高位补 0
  • 无符号数的扩展时高位补 0

寄存器知道存储数值是有符号还是无符号的吗

答案是不知道,寄存器只会存储数值,不会存储数值的符号,符号是由汇编码的不同操作指令来决定的,比如 LDURSW 就是有符号数,LDUR 就是无符号数

例子

比如将一般的 int 进行 bit shift, 那么 1 >> 2 = 0, 而 -1 >> 2 = -1

数字存储顺序

Endian 这个词出自Jonathan Swift书写的《格列佛游记》。这本书根据将鸡蛋敲开的方法不同将所有的人分为两类,从圆头开始将鸡蛋敲开的人被归为Big Endian,从尖头开始将鸡蛋敲开的人被归为Littile Endian(这句话最为形象)。小人国的内战就源于吃鸡蛋时是究竟从大头(Big-Endian)敲开还是从小头(Little-Endian)敲开

注:存储顺序是内存的,寄存器没有这个说法!

一般来说,二者的不同只有 lw 和 sw 的时候会出现

大端存储 Big Endian (反)

一个数的最高位字节(最重要的部分,称为 “big end”)被存储在内存的最低地址处
也就是最靠前的位置,例如 0x12345678 在内存中的存储顺序是 12 34 56 78

小端存储 Little Endian (正)

一个数的最低位字节(最不重要的部分,称为 “little end”)被存储在内存的最低地址
也就是最靠前的位置,例如 0x12345678 在内存中的存储顺序是 78 56 34 12

  1. 在字节内的位顺序是不变的,只是字节的顺序发生了变化
  2. 大端存储更加符合人类的阅读习惯,在网络协议中比较常见
  3. 小端存储由于符合算术从小位到高位的顺序,因此在PC (x86) 中更加常见
  4. leg, arm 有两种可能的存储顺序,取决于硬件的设计,但是默认小端存储

例子

假设在内存中有连续四个字节的数字为 0x12, 0x34, 0x56, 0x78, 从低到高排列,那么如果我现在读取 LDURSW 这四个字节,那么我们会得到寄存器中:

  • Big Endian: 0x12345678
  • Little Endian: 0x78563412
    也就是说,虽然按照字节顺序在内存中数值一样,但是由于大小端不同,其实际存储的数值也是不相同的,在寄存器中的数据就没有这种大小端的区别,其值就是绝对数值,因此大小端存储只会在 ld 和 st 的时候涉及

内存对齐 memory alignment

对于一个结构体而言,里面有多个 不同数据类型的变量,如 char, short, double, int 等变量这些变量如果直接存储不对齐,那么会导致内存访问效率低下
在后面我们会知道,内存会被分配到固定尺寸的 chunk 里面,每个chunk 一般是 32位,且读取 chunk 的时候是一次性全部读取,所以如果一个变量跨越了两个 chunk,那么就会导致读取两次
对于一个 char 类型的变量,它可以存储在一个 chunk 中任意的位置,因为如果我们要读取之,只需要 ldurb 或者 read 整个 word 然后丢弃其他部分就行

对齐规则

  • 每一个变量的开始地址一定能被其尺寸整除
  • padding 表示填充,也就是说如果一个变量的大小不是 4 的倍数,那么就会在其后面填充一些字节,使得下一个变量的地址是 4 的倍数

结构体的特殊对齐规则

即使将一个结构体通过 padding 进行填充使得满足 字节整除,但是如果前面有别的变量比如 char 使得结构体的开始位置并没有满足 字节整除,那么这个结构体的地址就是这个 char 的地址,而不是填充后的地址
这样的内存分布会有什么问题吗?
这样如果结构体形成了一个数组,那么不同index的每个子元素的偏移量并不是一个固定数, 从而导致程序使用效率低下。例如,MyStruct myst[0].elem 和 myst[3].elem 的偏移量并不相同,这样每次还要额外记录偏移量的大小,占用的空间比较大,并不划算,所以,我们对结构体的存储,统一采用以下规则:
将所有成员变量的内存统一设置成结构体中内存最大的基本元素的内存大小
例如结构体

1
2
3
4
5
struct MyStruct{
char cha;
int elem;
long man;
};

这个结构体需要每个元素的开头都在 被 8 (sizeof(long)) 整除的地址上,两者之间用 padding 进行填充

函数的汇编形式

在C 的程序中,一个函数的定义只需要出现一次,无论被调用多少次,都会return 到被调用的地方,这个“被调用”的是一个变量,因此,我们并不能在实现 汇编的时候只写一次,这样我们返回的地址只会是一个 hard-coded address
这个 返回地址多元 的特性和for if 等结构的循环跳转效果不同,所以这里要有一种目标地址区分方式

特殊跳转指令

在函数调用的时候使用这个指令
在汇编中调用 BL 2500, 实际上是两个指令的结合: X30 = PC + 4 ; PC + 1000

  1. X30 是一个存储返回地址的惯用寄存器,PC + 4 存储下一步的地址
    1. 在p1中lc2k isa的 jalr 指令存储在 regB 的是 PC+1, 这是因为我们模拟的情况下通过 +1 达到了下一行的指令地址,但是在实际内存中是通过指针偏移 32 位 (4位)
  2. PC + 1000: 由于上面是 BL 2500 也就是 跳转到 2500个指令后面,实际上为 2500 * 4B = 1000 B 的地址

寄存器跳转指令 BR branch to register

在函数执行完成之后跳转到 return 部分执行这个指令
汇编中调用 BR X30 就是将当前指针指向寄存器 X30 中存储的地址,这个和我们上面BL时将 pc+4 存入 X30 相呼应,也就是我们将下一帧的地址存入后在用 BR 回到这个地址
BR 指令会默认返回到 PC + 4, 所以并不需要一个额外的 label 标注返回的地址
这个指令能允许我们返回不同的地址 (X30 实际上是每次调用都会存入不同的 PC + 4) 但是我们还差一个问题: 嵌套调用的覆盖问题

函数栈 Function Stack

在 arm 架构中,调用函数的时候,会申请一块内存为 call stack
这块内存中会存储函数调用所需要的数据

内存栈和堆

栈是内存中存储函数调用的结构,堆是内存中存储动态分配的内存的空间,二者分别位于内存的两端。堆的前端还有一块空间存储 static 变量 (在 cpp 中指全局变量和局部 static 变量) 和 text 文本 (里面保存了代码文本)
注意一个动态指针申请的内存是存储在 heap 中,但是这个指针本身是在栈内部的
所以如果在一个函数中进行申请动态内存,那么在函数return的时候只会销毁指针但是并不会把heap中对应的内存进行销毁,从而导致内存泄漏

函数栈指针 SP stack pointer

一般会保存在寄存器 X28X28 中,这个寄存器会指向当前栈顶的位置,每次调用函数的时候会将 SP 的值存入栈中,然后将 SP 指向新的栈顶

寄存器的两种暂存方式

由于不同函数能够使用的变量数量都是有限的,即函数都只能看到本层 stack 中的数据进行操作,但是由于调用新的函数的时候,数据仍然保存在寄存器中,如果进入新栈的数据直接存入寄存器,就会有可能覆盖掉一些上一层栈已有数据,所以我们需要将现在的数据存入函数栈,并且在函数结束的时候回来再从内存中取出这部分数据。

Caller-Save 调用函数保存

在每一层函数栈中,在调用其他函数的前后发生,函数调用前,将当前层本地定义的变量存入栈,函数调用后,将栈中的数据取出存入寄存器
当代编译器存在对 Caller-Save 的优化,即观察其中 live at the end 的变量才会存入寄存器,如果一个变量在处理过程中并不会被调用,即使其存在赋值操作,编译器也不会给它分配寄存器
这也是为什么 -Wall 的时候会报错没有使用的变量

Callee-Save 被调用函数保存

在被调用函数的开头和结尾调用,函数开头将寄存器中的数据存入栈,函数结尾将栈中的数据取出存入寄存器
当代编译器优化 Callee-Save 时,会观察其中 assign 的变量才会存入栈,如果一个变量在处理过程中并不会被赋值,仅仅发生初始化,编译器也不会给它分配栈空间,这也就是为什么 -Wall 的时候会报错没有初始化的变量

寄存器的分配

  • X30: 存储返回地址
  • X28: 存储栈指针
  • X0 - X7: 存储函数参数
  • X19 - X27: Callee - Saved 存储函数内部变量
  • X0 - X15: Caller - Saved 存储函数调用变量
    • 这里的两种存储方式是混合使用的,完全看编译器的心(算)情(法)
  • X0: 存储函数返回值

LEGv8 条件指令 conditional instructions

零判断 Zero Flag

  • CBZ: conditional branch if zero, CBZ X1, 1000 表示如果 X1 == 0,那么跳转到 1000 个指令后面 (relative), 注意这里 1000 个 指令 = PC + 4000 Byte
    • CBNZ: conditional branch if not zero, CBNZ X1, 1000 表示如果 X1 != 0,那么跳转到 1000 个指令后面

符号判断 Sign Flag

我们一般会将 Flag 分成 NZVC 四类,即 Zero, Negative, Overflow (溢出位), Carry (进位位)

  • ADDS: add and set flag, ADDS X1, X2, X3 表示 X1 = X2 + X3 并且设置 flag (4bits)
  • SUBS: substract and set flag, SUBS X1, X2, X3 表示 X1 = X2 - X3 并且设置 flag

Branch 的条件 (共15种)

  • MI N == 1 小于零
  • PL N == 0 正数
  • EQ Z == 1 等于
  • NE Z == 0 不等于
  • VS V == 1 溢出(即实际数值相反数)
  • VC V == 0 不溢出
  • HS C == 1 进位
  • LO C == 0 不进位
  • AL Always
  • NV Never
  • HI C==1&&Z==0C==1 \&\& Z == 0 无符号整数大于
  • LS C==0Z==1C==0 || Z == 1 无符号整数小于等于
复合条件
  1. GE条件,其逻辑是基于有符号比较。其结果可以用下面的逻辑表示:
  • N == V

这意味着如果两个数相等(N 和 V 都为 0 或者 N 和 V 都为 1),则条件成立。这里的逻辑是:

  1. N == 0, V == 0:两个正数相比或一个负数减去一个更大的负数(没有溢出),结果是非负的。
  2. N == 1, V == 1:一个负数减去一个较小的负数导致溢出,或者正数相减导致溢出,结果是负的,但在有符号逻辑中仍然认为是正确的比较结果(因为实际比较值是正确的)。
  3. LT 条件,其逻辑是基于有符号比较。其结果可以用下面的逻辑表示:
  • N != V
  • N0V1 表示 相减得到正数但是超模,说明在有符号的情况下是相反的数值,!N = 1 说明还是负数
  1. GT 条件,其逻辑是基于有符号比较。其结果可以用下面的逻辑表示:
  • Z == 0 && N == V
  • 在 GE 上面添加了一个 Z == 0 的条件,表示不等于 0
  1. LE 条件,其逻辑是基于有符号比较。其结果可以用下面的逻辑表示:
  • Z == 1 || N != V
  • 在 LT 上面添加了一个 Z == 1 的条件,表示等于 0
    总结: Signed 看 V, Unsigned 看 C, 等于看 Z, 大小看 N