Aarch64 汇编速览
目录
本文为读者提供简单的 ARM64 汇编语言的参考,旨在让读者在本博客其它文章中看到 ARM64 汇编代码时能够快速找到语言说明,以便理解文章中出现的内容。
文本要求读者对计算机组成有很基础的了解,且具有一定的高级程序语言基础, 知道变量、分支、循环、函数的概念与使用方法。
在本文的最后会给出一份汇编样例代码,让读者对一份完全使用汇编编写的程序有大体的认知。 如果你希望跟着样例的步骤进行代码编写的话,你需要对命令行操作有一定的熟悉。
基本
AArch64 也叫 ARM64,是一个定长指令集,所有指令都是 32 位长。
汇编语言的基本形式是一个指令名跟随指令执行所需要的参数。 指令名用于确定该步骤需要进行的操作,而参数则用于提供进行操作所需要的数据,比如:
mov x0, #0
每条指令指示 CPU 进行一步操作,指令在内存中储存的形式是二进制编码。
比如说,上述指令的编码为: 0xd2 80 00 00。在实际储存时,ARM64 通常是小端储存,
因此如果检阅实际的可执行文件,会看到其中写的是 0x00 00 80 d2。
至于二进制编码是如何使得 CPU 进行一步运算的,是数字电路涉及的内容,此处不进行展开。
指令参数大体有以下几种类型:
-
寄存器,通常使用字母加编号的形式出现,比如
x0。 -
立即数,即高级程序语言中的数字字面量,使用带有
#前缀的数字表示。 -
地址,用于作为内存读写目标、跳转位置。大多数时候地址是用前两种参数形式组合来表示的, 但也有时在汇编文件中以标签名形式出现,用于表示一个需要由编译器、链接器进行计算的地址值。
尽管地址值实质上是寄存器与立即数,但是在概念上将其分开是有帮助的。
汇编语言中没有变量的概念,在以汇编语言进行思考时,需要关注的是数据从哪来,需要 去哪里。
上述 3 种参数的类型,就是对应 3 种数据储存的位置:在寄存器里、直接编码在指令中、 在内存里。
所有程序语言的基础几乎都可以划分为数据的读写操作、数据结构的管理、执行流程的控制 这几个方面。在汇编层面,数据的结构影响并不明显,本文的讲解会侧重另外两方面。
本文在指令表中将会使用如下符号:
D表示作为结果储存目的地的参数,这一参数总是一个寄存器S表示提供数据源的参数,该参数可以是寄存器也可以是立即数R表示一个用于作为参数的寄存器O1、O2分别用于表示指令的操作数 1 与操作数 2,可以是寄存器了也可以是立即数。
指令参数
立即数
#16、#0x10、#0b10000 都是立即数。
立即数是会被直接编码在指令二进制编码中的内容。所以单个立即数的大小,是受到指令 长度的限制的,每条指令能够在其 32 位之中划分给立即数使用的大小亦有不同。
当需要用于操作的立即数位数很多时,通常会把操作拆分成多条指令进行。
比如说将寄存器初始化为一个 64 位整数。这一过程可能就会被拆分成以 16 位为单位进行 的 4 条赋值指令。
通常编译器会对实际操作进行优化与选择,在编写汇编代码时通常不需要程序员对立即数进行 处理。
寄存器
ARM64 提供 31 个 64 位泛用寄存器,它们可以使用 64 位或 32 位模式来使用。
在指令中,寄存器使用一个类型字母前缀加上寄存器编号来表示(编号从 0 开始)。
比如 x0。
w 为 32 位,x 为 64 位。当一个通用寄存器使用 w 指代时,只会使用到该寄存器
低位侧的 32 位。并且使用 32 位模式进行运算时,寄存器高位侧的 32 位会被清零。
另有 32 个用于进行浮点或向量运算的 128 位寄存器,引用的前缀字母包含:b(8 位)、 h(16 位)、s(33 位)、d(64 位)、q(128 位)、v(向量形式)。
xzr 或 wzr 是数值总为 0 的寄存器。对该寄存器进行的写入操作会被忽略,使用该
寄存器作为指令结果储存位置表示计算结果可以忽略(如更新 ALU 的状态标记时)。
ARM64 中还提供了一些专用于设置硬件行为的系统寄存器。系统寄存器均使用名称进行指代, 这些寄存器不能直接用于常规的运算指令。本文中不对这部分寄存器进行展开。
需要对系统寄存器的数值进行读、写时,都必须使用泛用寄存器进行中转:
mrs x0, <system-register> // 将系统寄存器数据写入 x0,随后使用 x0 的值。
msr <system-register>, x0 // 将配置值在 x0 中计算好后,写入到系统寄存器。
内存
下面是部分和内存相关的寄存器:
-
sp是栈指针寄存器,但是并不能作为泛用寄存器来使用。ARM64 中有多个指令权限等级,每个运行等级都有自己的栈指针寄存器,被使用的栈指针会 根据运行等级切换。
sp在指令中出现时,表示当前正在使用的寄存器。 -
程序计数器(PC)在 ARM64 中不以通用寄存器的形式出现,当需要使用 PC 值时,需要 将其读入到通用寄存器中:
adr x0, . // `.` 是表示当前位置的标签 -
x30也可以通过lr来指代,被用作链接寄存器。在执行跳转命令时,该寄存器可以用于 自动储存下一指令的地址;执行ret指令时会自动将 PC 修改为该寄存器中的值。
CPU 与内存的交互是基于地址进行的,在 ARM64 中,地址有以下几种表示方式:
-
[x0],使用寄存器中的数值作为地址值。 -
[x0, #imm],偏移寻址,使用某一寄存器的值加上偏移量立即数作为地址,偏移量的单位是字节。该形式还有以下几种变体:
[x0, x1] // 使用寄存器表示偏移量 [x0, x1, lsl #imm] // 将寄存器中的值左移指定位数后作为偏移量 [base, w0, (s|u)xt(x|w) {#imm}]` // 将 `w0` 中的值进行位扩展和左移后,作为偏移量 -
[x0, #0x10]!,前索引模式,将寄存器中的值与偏移量相加作为目标地址,并将该地址 值返写回寄存器。常用于函数调用时环境数据进栈操作。 -
[x0], #0x10,后索引模式,将寄存器中的值用于内存寻址,再将寄存器值叠加偏移量后 的结果写入到寄存器中,偏移量可以使用寄存器的值。常用于函数调用时环境数据出栈的操作。
为 CPU 提供数据是内存的主要任务,以下命令用于实现寄存器与内存的数据交换:
ldr D, [address]str S, [address]
其中 D 与 S 指定的寄存器尺寸决定加载到寄存器中的数据位大小。
ldr 与 str 可以通过添加位数后缀 B(8 位)、H(16 位)、W(32 位)来对寄存器
中限定的一部分进行操作。比如 strb 表示将寄存器中低位侧的一个字节存入内存。
默认情况下,ldr 向寄存器中加载不占满整个寄存器的数据时,会把没用到的位清零,
如果希望在加载时对被加载的数据进行带符号扩展,可以使用 S 后缀。比如 ldrsb 表示
向寄存器中加载一个字节的数据,并将其通过带符号扩展占满整个寄存器。
下面两个指令可以一次性进行两个寄存器的数据交换:
ldp D1, D2, [address]stp S1, S2, [address]
以 64 位寄存器为例,ldp 会将 [address] 的数据加载到 D1,将 [address + 8]
的数据加载到 D2。
数据处理指令
数据处理相关的指令基本可以分成四则运算与逻辑运算两大类。
| 指令 | 含义 |
|---|---|
| 赋值 | |
mov D, S |
D = S |
| 运算 | |
add D, O1, O2 |
D = O1 + O2 |
sub D, O1, O2 |
D = O1 - O2 |
mul D, O1, O2 |
D = O1 * O2 |
div D, O1, O2 |
D = O1 / O2 |
| 移位运算 | - |
lsl D, O1, O2 |
D = O1 « O2 |
lsr D, O1, O2 |
D = O1 » O2 高位空缺部分补 0 |
asr D, O1, O2 |
D = O1 » O2 高位空缺部分以原符号位的值填充 |
| 位扩展 | |
sxtx D, S |
将 S 中的数作为带符号数扩展到 D 寄存器对应的位数 |
uxtx D, S |
将 S 中的数作为无符号数扩展到 D 寄存器对应的位数 |
| 逻辑运算 | |
mvn D, S |
D = ~S |
and D, O1, O2 |
D = O1 & O2 |
orr D, O1, O2 |
D = O1 | O2 |
eor D, O1, O2 |
D = O1 ^ O2 |
bic D, O1, O2 |
D = O1 & (~O2),使用 O2 指示要对 O1 数值中归零的位 |
整数四则运算指令加上 f 前缀表示对浮点寄存器进行的运算,如 fadd。
位扩展相关详情可参考整数的二进制表示小节的说明。
执行流程控制
条件与循环
首先是汇编实现条件分支与循环的代码样例:
// 条件
cmp x0, 1 // if (x0 == 1) {
b.ne skip
// ... ...
// }
skip:
// ...
// 循环
loop:
cbz x0, skip // while (x0 != 0) {
// ... ...
b loop // }
skip:
// ...
接下来说明与之相关的指令。
无条件跳转,b 与 br。
其中 b 使用标签作为参数,进行相对于当前执行位置的偏移跳转;
br 使用寄存器作为参数,跳转到绝对地址。
下列命令可以用于带条件的跳转:
b.cond label,cond是条件码,表示 ALU 标记符合该条件码时进行跳转,条件码在 下文中列出。cbz S, label,compare and branch on zero,在S寄存器数值为 0 时进行跳转。cbnz S, label,compare and brach on nonzero,在S寄存器数值不为 0 时跳转。tbz S, #imm, label,当S寄存器的第imm位为 0 时进行跳转。tbnz S, #imm, label,当S寄存器的第imm位不为 0 时进行跳转。
条件在 CPU 层面是通过运算逻辑单元(ALU)上的状态标记来表示的。
flowchart TD
compute[第一条指令指示进行运算]
--> flag_update[ALU 根据运算结果更新状态标记]
--> flag_check[下一指令使用状态标记作为条件检查的输入]
在所有的四则运算指令、逻辑运算指令后加上 s 后缀,就表示在进行运算的同时对 ALU
的状态标记进行更新,比如 adds。反之,这些指令没有加 s 后缀时,运算结果是不会
影响状态标记的内容的。
同时,上文中的 cbz 与 cbnz 命令是不会改变状态标记的。
有一部分命令是专门用于进行 ALU 状态标记的更新的:
cmp O1, O2相当于subs xzr, O1, O2tst O1, O2相当于ands xzr, O1, O2
ARM64 的 ALU 提供了 4 个标记:
- Z:更新运算的结果是否为 0。
- N:更新运算的结果是否为负数。
- C:更新运算的结果是否包含有进位(无符号溢出)
- V:更新运算的结果是否有(带符号)溢出
ARM64 中的条件有以下几种:
| 条件 | ZNCV 标记 | 用于整数 | 用于浮点数 |
|---|---|---|---|
| EQ | Z == 1 | 相等 | 相等 |
| NE | Z == 0 | 不等 | 不等或不可比较 |
| CS 或 HS | C == 1 | 有进位 | 大于等于或不可比较 |
| CC 或 LO | C == 0 | 无进位 | 小于 |
| MI | N == 1 | 负数 | 小于 |
| PL | N == 0 | 非负 | 大于等于或不可比较 |
| VS | V == 1 | 溢出 | 不可比较 |
| VC | V == 0 | 无溢出 | 可比较 |
| HI | C == 1 && Z == 0 | 无符号大于 | 大于或不可比较 |
| LS | !(C == 1 && Z == 0) | 无符号小于等于 | 小于等于 |
| GE | N == V | 带符号大于等于 | 大于等于 |
| LT | N != V | 带符号小于 | 小于或不可比较 |
| GT | Z == 0 && N == V | 带特号大于 | 小于等于或不可比较 |
| LE | !(Z == 0 && N == V) | 带特号小于等于 | 小于等于或不可比较 |
| AL | 任意 | 无条件 | 无条件 |
| NV | 任意 | 无条件 | 无条件 |
注:不可排序说明参与比较的值中至少有一个是 NaN。
函数调用
bl 与 blr 命令与 b、br 类似,这两个命令在进行跳转时会自动把下一指令的地址值
写入到 x30 中。
这两个命令通常作为函数调用的起始指令。
而 ret 命令会自动将当前的 PC 修改成 x30 中的值,用于从函数中返回原来的执行位置。
为了协调不同来源的代码,函数调用需要以下的规范:
-
x0到x7用于传递函数的前 8 个参数,如果还有更多参数,则将它们放到栈上。函数 的返回值也使用这 8 个寄存器存放。 -
x8用于存放由调用方传入的结构体指针,函数直接对该指针的内存位置进行修改,作为 函数返回值之一。 -
x16与x17用于作为跳转的临时寄存器。当编译器与链接器发现函数调用位置距离 函数所在位置太远时,可以使用这两寄存器先跳转到中转代码位置,再从中转位置跳转到函数。这两个寄存器在函数体中可以被覆写。
-
x18是平台寄存器,具体使用方式由操作系统定义。 -
x19到x28如果需要在函数体中使用,则需要在使用前将其值保存到栈上,并在函数 返回前将值恢复到函数调用前的状态。 -
x29栈帧地址寄存器,记录了当前栈帧的位置。 -
x30返回地址寄存器,记录了函数调用返回时需要跳转到的位置。
异常与系统调用
当程序要进行 I/O 操作时,就得请求操作系统为其将特定的内容传递到其它进程的内存 空间中。
这样中断当前进程的执行唤起 OS 执行的行为,是抛出异常的一种。ARM64 中 svc、hvc、
smc 三条指令可以用于触发这样的异常。这三条指令触发的异常等级逐条升高,一般唤起
OS 的介入使用的是 svc 指令。
这 3 条指令都需要一个立即数作为参数,这一数字的作用由操作系统决定。
不同的操作系统所使用的系统调用流程规范略有不同,但是大体上都符合下面的流程:
flowchart TD
load_args[将参数值加载到参数寄存器]
--> write_sys_call_no[在特殊寄存器中写入目标系统调用的编号]
--> waking_os[使用异常指令唤起 OS 介入]
以 Linux 系统为例,在 ARM64 下,用于存放系统调用编号的寄存器是 x8,参数寄存器是
x0 到 x5,返回值寄存器是 x0。
Linux 不会使用异常指令中指定参数值,因此异常调用的参数总固定为 0,比如 svc #0。
这些内容以及完整的系统调用编号表可以在 The Chromium Projects 提供的 Linux 系统调用表 中可以查到。
实例
接下来我们使用 ARM64 汇编编写一个阶乘函数,并将其结果输出到标准输出流。本文中使用 的例子是在 Linux 上编译运行的,因此也会使用 GNU 编译器中要求的汇编格式。
起步
首先是代码的基本结构:
.text
.align 4
.global _start
_start:
// ...
在这段代码中,使用 . 开头的指令,都是编译器指令,是用于指示编译器应该如何将源
代码转换为编译结果的。
-
.text指令,是指示编译器接下来的内容是程序的 text 段,也就是可执行指令所在的 位置。类似的段还有用于存放只读数据的.data段等。 -
.align指令用于指示接下来的各条指令需要使用什么地址对齐方式。此处使用 4 字节 对齐,也就是各个指令的首字节地址是 4 的倍数。这一指令并不是必须的,但是编译器可能会要求所有的函数标签都使用某一特定的地址对齐, 到了遇上这样的要求时就必须函数标签前添加对齐指令了。
-
.global用于声明一个全局符号,_start是汇编文件的执行入口,相当于高级程序 语言中的 main 函数。 -
_start:这是一个标签定义,在跳转指令中可以使用标签名对标签所在的地址进行引用。
将上述代码保存到 main.s 中。再创建一个叫 Makefile 的文件,将下面内容写入其中:
all: main.s
as -o main.o main.s
ld -s -o main main.o
run:
./main
接下来在保存有这两个文件的目录下,执行 make 命令,就能看到目录中出现了 main.o
与 main 两个作为编译结果的新文件。
main 就是我们的可执行文件,不过它现在还什么功能都没有。
程序的退出
首先需要处理的是,在我们提供的任务全部完成之后,程序能够正常结束。这是一个通知 操作系统将进程关闭并返回退出码的简单系统调用。
查表
寻找 exit 系统调用的编号,得到 93:
// void exit(int code);
exit:
mov x8, #93
svc 0
ret
接下来在 _start 函数中进行 exit 的调用:
_start:
mov x0, #1
bl exit // exit(1);
此时,如果编译并运行 main 程序,就可以在命令行中看到该程序的退出码是 1。
阶乘函数
使用简单的循环就可以实现阶乘了。这里我们只处理输入为正数的情况:
_start:
mov x0, #3
bl factorial
bl exit
// int factorial(n)
factorial:
mov x1, #1 // int result = 1;
factorial_loop:
cmp x0, #0 // for (; n > 0; n--) {
b.le factorial_skip
mul x1, x1, x0 // result *= n;
sub x0, x0, #1
b factorial_loop // }
factorial_skip:
mov x0, x1
ret
- 由于不会在函数体中调用其它函数。此处为了方便所有的变量操作都使用
x0到x7进行。
这段程序使用阶乘函数的输出作为程序的退出码,可以直接在退出码中看到阶乘的结果。 但是需要注意的是,Linux 上的退出码最多只到 255,所以使用这一方式无法看到超过 7 阶乘的结果。
输出文本
如果有在自己的程序中创建过子进程,就知道子进程里向标准输出流中写入的内容,不过 是将数据传到了父进程的内存中罢了。
内容能够显示在屏幕上也不过是父进程再将内容传递到了其它进程的内存,委托其完成文本 到字形,再从字形到显存、屏幕像素的转化罢了。
即使是到了汇编层面,想要向屏幕输出内容也不难实现。
write 系统调用(在 Linux 上),编号是 64,接收 3 个参数,依次是:文件描述符
ID、目标内容的数据起点地址、需要写入内容的字节长度。
下面的代码能向屏幕上输出 Hello World:
_start:
ldr x0, =msg
ldr x1, =msg_len
bl print
// void print(char *msg, int len)
print:
mov x2, x1
mov x1, x0
mov x0, #1 // stdout
mov x8, #64
svc #0
ret
.data
msg: .asciz "Hello World\n"
msg_len = . - msg
- 此处使用到了
.data段,来定义程序的只读代码内容 .asciz表示定义一个以空字符结尾的 ASCII 字符串。msg_len = . - msg表示msg_len的取值为标签所在地址位置与msg标签地址之 差。mov x0, #1这一指令,表示将1提供给write系统调用作为文件描述符的 ID。 这在 POSIX 接口定义中是标准输出流所对应的文件描述符。顺带一提标准输入流是0, 标准错误流是2。
输出阶乘的结果
要是想要输出阶乘的结果,就需要将其转化为字符串。在这里我们定义一个用于输出整数 的函数,并直接在栈上创建一个字符数组作为字符串使用。
由于阶乘函数的输出只有正数,为了方便起见此处不对负数的输出进行处理。
首先在函数正文开始之前,先对函数所处的调用环境进行保存,并在离开函数前进行环境恢复:
// void print_int(int n)
print_int:
stp x29, x30, [sp, #-16]!
stp x20, x21, [sp, #-16]!
mov x29, sp
ldp x20, x21, [sp], 16
ldp x29, x30, [sp], 16
ret
x29是栈帧寄存器,用于记录当前函数开始执行时栈指针所在的位置,是用于进行局部 变量的访问的。高级程序语言中的局部变量在汇编层面将会以类似于[x29, #4]这样的 形式出现。str x0, [x29, #4]是一次对局部变量进行的赋值。x30是函数调用的返回位置记录指针,如果在函数体中还需要进行其它函数的调用,就 一定要对x30进行保存,防止丢失返回地址。x20与x21是本次将会在函数体中使用的两个寄存器,在正式进行使用之前,要对 其原值进行保存。- 在
ret语句前,不要忘记将进入函数时保存的环境恢复回原来的样子。
虽然在本次的实现中,
print_int并不会对其它的函数进行调用,也不需要使用x29进行栈上局部变量的访问,但是为了展示惯用操作,此处还是添加了保存x29与x30的操作。
接下来在栈上分配字符数组:
print_int:
// ...
sub sp, sp, #32
mov x20, sp // unsigned char buffer[32];
add sp, sp, #32
// ...
ret
- 其实展示一个 64 位整数最多只需要 20 个字符的空间就足够了,但是在 ARM64 中栈指针 的地址必须是 16 字节对齐的,要移动栈指针也只能以 16 为单位进行移动,所以此处索性 分配了 32 字节的字符串。
- 在进入函数时增加了栈上占用,在离开函数时一定要记得把栈缩回去。不然会在后续执行 中,错误的栈指针位置会引起违规的内存访问。
在 print_int 收到的参数为 0,我们采用专门的处理形式:
print_int:
// ...
cmp x0, #0 // if (x > 0) {
b.eq pi_is_zero
// ...
// } else {
pi_is_zero:
mov w2, #'0'
str w2, [x20] // buffer[0] = '0';
mov x1, #1 // length = 1;
// }
pi_result:
mov x0, x20
bl print
// ...
ret
如果需要打印的数字大于 0,就将数字中的各位依次转为字符:
print_int:
// ...
b.le pi_is_zero
mov x1, #0 // int length = 0;
mov x2, #10 // int factor = 10;
mov x3, x0 // int n_copy = x0;
pi_length_loop:
cmp x3, #0 // while (n_copy > 0) {
b.le pi_length_loop_end
add x1, x1, #1 // length++;
udiv x3, x3, x2 // n_copy /= 10;
b pi_length_loop // }
pi_length_loop_end:
add x21, x20, x1 // unsigned char *buffer_end = buffer + length
sub x21, x21, #1 // buffer_end--;
pi_loop:
cmp x0, #0 // while (x0 > 0) {
b.le pi_result
udiv x3, x0, x2 // x3 = x0 / factor
msub x4, x3, x2, x0 // x4 = x0 - (x3 * factor)
add x4, x4, #'0' // x4 += '0';
strb w4, [x21] // *buffer_end = x4;
sub x21, x21, #1 // buffer_end--;
mov x0, x3
b pi_loop // }
pi_is_zero:
// ...
ret
此处使用了两个循环来进行字符串的转换。一个循环用于确定字符串的长度,另一个循环 用于进行数字转换为字符的操作。
先获取字符串的总长度,是为了能够从字符串的末端开始进行字符的填充。 若是从数字的低位开始,向字符串基地址一位一位向后填充字符转换结果,在输出前就还 需要进行字符串内容的颠倒。
颠倒字符串需要对数组中的元素逐个进行内存读写,确定字符有多少位的流程则只需要进行 寄存器访问。如果不论如何都需要两个循环的话,那不如直接写成执行速度更快的形式。
最终结果
将上述代码片段全部整合,就是下面的结果:
.text
.align 4
.global _start
_start:
mov x0, #10
bl factorial
bl print_int
mov x0, #0
bl exit
// int factorial(n)
factorial:
mov x1, #1
factorial_loop:
cmp x0, #0
b.le factorial_skip
mul x1, x1, x0
sub x0, x0, #1
b factorial_loop
factorial_skip:
mov x0, x1
ret
// void print_int(int n)
print_int:
stp x29, x30, [sp, #-16]!
stp x20, x21, [sp, #-16]!
mov x29, sp
sub sp, sp, #32
mov x20, sp // unsigned char buffer[32];
cmp x0, #0
b.le pi_is_zero
mov x1, #0 // int length = 0;
mov x2, #10 // int factor = 10;
mov x3, x0 // int n_copy = x0;
pi_length_loop:
cmp x3, #0 // while (n_copy > 0) {
b.le pi_length_loop_end
add x1, x1, #1 // length++;
udiv x3, x3, x2 // n_copy /= 10;
b pi_length_loop // }
pi_length_loop_end:
add x21, x20, x1 // unsigned char *buffer_end = buffer + length
sub x21, x21, #1 // buffer_end--;
pi_loop:
cmp x0, #0 // while (x0 > 0) {
b.le pi_result
udiv x3, x0, x2 // x3 = x0 / factor
msub x4, x3, x2, x0 // x4 = x0 - (x3 * factor)
add x4, x4, #'0' // x4 += '0';
strb w4, [x21] // *buffer_end = x4;
sub x21, x21, #1 // buffer_end--;
mov x0, x3
b pi_loop // }
pi_is_zero:
mov w2, #'0'
str w2, [x20]
pi_result:
mov x0, x20
bl print
add sp, sp, #32
ldp x20, x21, [sp], 16
ldp x29, x30, [sp], 16
ret
// void print(char *msg, int len)
print:
mov x2, x1
mov x1, x0
mov x0, #1 // stdout
mov x8, #64
svc #0
ret
exit:
mov x8, #93
svc #0
ret
附录 - 数字的二进制展示
本文中使用了 Lua 作为概念展示所使用的语言。
如果对文中使用的 Lua 语法有不理解的地方,可以在Lua 速查手册一文中查找对应的解释。
二进制展开
当涉及到对不同进制的数字的讨论时,常常使用下标的形式指示该数字的进制,本小结也 会采用同样的标记。
从 10 进制入手,以 \(378_{10}\) 为例,该数字的含义为:
$$ 378 = 3 \times 10^2 + 7 \times 10 + 8 \times 10^0 $$
所谓二进制表示,就是将一个数写成 2 的不同幂次之和的形式,并且保证和式中各个 2 的 幂次前的系数,绝对值都小于 2。
$$ 378_{10} = \sum_{n = 0}^{\infty}x_n 2^n $$
如果在上述展开中,使得 \(x_n\) 不为 0 的最大 \(n\) 取值为 \(N\),则序列 \(x_N x_{N - 1} \cdots x_1 x_0\) 就是 \(378_{10}\) 的二进制表示。
$$ 378_{10} = 2^8 + 2^6 + 2^5 + 2^4 + 2^3 + 2 = 101111010_{2} $$
下述 Lua 代码可以用来计算一个不小于 1 的数的二进制展示:
local function binary(n)
local result = {}
while n > 0 do
table.insert(result, n % 2)
n = math.floor(n / 2)
end
return result -- 索引从小到大,对应二进制展示中的低位到高位
end
对于落在 \((0, 1)\) 之内的数,其中二进制展开也是类似的:
$$ y = \sum_{n = 1}^{\infty} y_n 2^{-n} $$
如果要计算 \(y_1\) 的值,就可以利用下面的式子:
$$ 2y = y_1 + \sum_{n = 2}^{\infty} y_n 2^{1 - n} $$
如果 \(2y \geqslant 1\),那么 \(y_1\) 就是 1;反之若 \(0 \leqslant 2y \lt 1\) 则说明 \(y_1\) 为 0。
下述 Lua 代码用于计算 \((0, 1)\) 之间的小数的二进制表示:
local function binary(n)
local result = {}
-- 最多计算 23 位小数
for _ = 1, 23 do
n = n * 2
if n >= 1 then
n = n - 1
table.insert(result, 1)
else
table.insert(result, 0)
end
end
return result
end
二进制整数
无符号整数的二进制储存方式是直接使用其二进制展开,如 4 储存为 0b0100
在带符号整数的二进制表示中,使用数据的最高位作为符号位。该位为 0 时表示正数,为 1 时表示负数
在非符号部分,正数与负数采用完全不同的策略。
对于非负数而言,该数字的二进制展开就是其数据段内容。
对于负数而言,则是使用补码形式来储存,计算方式如下:
- 取绝对值
- 取对其绝对值的二进制展开
- 将得到的二进制展开加 1,作为其数据内容。
这样做的目的是让 0 与其相反数使用完全相同的二进制表示方式,并且使得一个数与其相反 数之和在二进制展示层面为等于 0。
比如说 -7,想要在 8 位整数中进行二进制展示:
- 首先将 7 的二进制展示取反,得到
0b1111 1000 - 再加 1,得到
0b1111 1001。
可以看到 \(7 + (-7)\) 的结果是 0b1 0000 0000。该结果在 8 位整数的范围下,将
多出来的一位进位舍去,就是 0。
作为对比,其它两种简单的处理方式的效果是:
- 正负数均使用原码表示,7 为
0b0000 0111,-7 为0b1000 0111。0 可以表示为0b0000 0000或0b1000 0000。 - 正数使用原码,负数使用反码表示,7 为
0b0000 0111,-7 为0b111 1000。0 可以表示为0b0000 0000或0b1111 1111。
这两种方法中 0 都有两种表示方式,并且带符号数的加法可能需要特殊处理,不然无法保证 相反数相加结果为 0。
当需要将一个位数较少的整数扩展成一个位数更大的整数时,比如将 32 位整数转换为 64 位整数,原数据是否带符号会有不同的操作。
无符号数扩展时,只需要在高位侧加 0 即可,7 从 8 位扩展成 16 位时是由 0b0000 0111
变为 0b0000 0000 0000 0111。
带符号数扩展时,高位侧多出来的位,需要使用原数据中的符号位的数值来填充,如 -7 从
8 位扩展到 16 位是由 0b1111 1001 变为 0b1111 1111 1111 1001。
二进制浮点数
二进制浮点数的表示是将数字转为二进制的科学计数法形式:
$$ x = (-1)^{s} \times (1 + f) \times 2^{e} $$
其中,\(s\) 的取值为 \(0\) 或 \(1\) 是数字的符号位;\(f\) 是科学计数法中的小数部分, 因为科学计数法中有效数的首位不会为 \(0\),而在二进制中“不为 \(0\)”也意味着“就是 \(1\)”,故而 不需要将有效数的整数部分记录在二进制数据中;\(e\) 是科学计数法的指数部分。
落实到具体展示时常见的安排如下:
- 32 位浮点数:1-bit 符号 + 8-bit 指数 + 23-bit 小数
- 64 位浮点数:1-bit 符号 + 11-bit 指数 + 53-bit 小数
指数部分采用带符号的偏移记法,以 8 位指数段为例,使用 127(0b0111 1111)表示 0 次幂,
该指数段所表示的整数与 127 的差值即为指数值,比如 0b0111 1000 表示 -7 次。
比如 \(0.1\) 的 32 位展示为:
指数段
╭───┴──╮
0 01111011 10011001100110011001101
│ ╰─────────┬───────────╯
符号位 小数段
一个值得注意的现象是,一些在 10 进制下有限的小数,转换为二进制展开之后就会变成 无限小数,比如 \(0.1_{10}\):
$$ 0.1 \times 2 = 0.2 \\ 0.2 \times 2 = 0.4 \\ 0.4 \times 2 = 0.8 \\ 0.8 \times 2 = 1.6 \\ (1.6 - 1) \times 2 = 1.2 \\ (1.2 - 1) \times 2 = 0.4 \\ \cdots $$
可以看到 \(0.1_{10}\) 的二进制小数展示才计算到第 6 位,就已经开始出现循环了。
对于像 \(0.1_{10}\) 这样的二进制无限小数,其浮点数展示就会存在误差。如果不想 在算钱时或者向用户展示数字时,在小数点后很多位的地方冒出来一点误差值的话,就要 留意这一现象。