操作系统(OS)通常会为程序按一个大致的布局映射内存:
| 地址 | 内存区域 |
|---|---|
| 高地址 | 栈 |
| ... | |
| 堆 | |
| 可读写段 | |
| 代码/只读段 | |
| 低地址 | 保留区 |
内存按段划分,段内又划分为节,各节拥有不同的权限。
到目前为止,我们定义的函数全都位于**.text 节**中。 这个节存放只读的可执行数据。 其他节用于声明数据变量,这些变量可以是只读的,也可以是可读写的,但它们不可执行。
已初始化的数据在**.data 节**中声明。
在 NASM(The Netwide Assembler,本 track 使用的汇编器)中,一个已初始化的变量包含名称、指明数据大小的伪指令,以及一组用逗号分隔的值。
这三部分之间用空格分隔,标签后面还可以(可选地)跟一个 :。
主要的伪指令及其对应的数据大小如下:
| 伪指令 | 大小 |
|---|---|
| db | 1 字节 |
| dw | 2 字节 |
| dd | 4 字节 |
| dq | 8 字节 |
例如,下面声明了一个名为 space 的字节变量,其值为 10:
section .data
space db 10
在 section .data 中声明的变量是可变的,也就是说,它们可读可写。
它们还具有_静态_存储期,这意味着它们在程序的整个运行期间都存在。
.rodata 节与 section .data 类似。
两个节都包含已初始化的数据,声明方式相同,存储期也相同。
它们的主要区别在于,section .rodata 中的数据是不可变的,也就是只读的。
用 equ 定义的常量与在 section .rodata 中定义的常量不同。
用 equ 定义的常量不占用内存空间,汇编器会直接用它的值进行替换。
它实际上只是该值的一个占位符。
另一方面,在 section .rodata 中定义的常量则真正存储在内存中,拥有自己的地址。
声明的数据必须有一个与之关联的名称。 这个名称称为标签。
标签是一个符号,它记录了数据在内存中的具体地址。 x86-64 中的地址是 64 位的值。
在 NASM 中,直接用标签访问数据得到的不是所分配的内存,而是它的地址:
section .data
example dq 27 ; this declares a 8-byte variable initialized with 27
section .text
fn:
mov rax, example ; this stores the address of the declared variable in rax, not its contents
...
要访问某个内存地址中的内容,必须对它进行_解引用_。 这称为间接寻址。
在 NASM 中,这通过[]完成:
section .data
example dq -27 ; this declares a 8-byte variable initialized with -27
section .text
fn:
mov rax, [example] ; this dereferences example and access the value stored in memory (-27)
...
不过,有些情况下,被解引用的内存大小可能存在歧义。 这时必须使用一个指定该大小的前缀。
下面是典型的 x86-64 程序中最常用的前缀及其大小:
| 前缀 | 大小 |
|---|---|
| byte | 1 字节 |
| word | 2 字节 |
| dword | 4 字节 |
| qword | 8 字节 |
同一次加载也可以明确写出大小:
mov rax, qword [example] ; same dereference, size stated explicitly
解引用内存时,始终加上前缀是个好习惯。
写入内存的方式相同,也是通过解引用一个地址:
section .data
example1 db 10 ; example1 is a 1-byte memory location initialized with value 10
example2 dq -456 ; example2 is a 8-byte memory location initialized with value -456
example3 dd 54 ; example3 is a 4-byte memory location initialized with value 54
section .text
fn:
mov byte [example1], 20 ; example1 now has value 20
mov qword [example2], rdx ; example2 now has value equal to the contents in rdx
mov dword [example3], eax ; example3 now has value equal to the contents in eax
注意,在大多数指令中,你可以直接使用内存操作数,而不必先把内容加载到寄存器里。 不过,通常不能把内存操作数同时用于源操作数和目标操作数,只能用在其中一边:
section .data
example4 dw 4
example5 dq -8
example6 dd 15
section .text
fn:
add word [example4], 5 ; example4 is now a 2-byte memory location with the value 4 + 5 = 9
imul rax, qword [example5] ; rax = rax * (-8)
; this is not possible -> sub dword [example6], dword [example6]
虽然可以用 mov 把变量的地址存进寄存器,但还有一条专门用于此目的的指令:lea。
这条指令使用内存形式的操作数,但它_不_读取内存。 相反,它计算有效地址表达式,并把结果写入目标操作数:
lea rax, [example] ; this stores the address of 'example' in rax
用 lea 计算内存地址并将其存入寄存器,是更地道的写法。
访问内存位置时,NASM 的默认行为是生成绝对地址,也就是固定的内存地址。
出于安全考虑,可执行文件通常构建为PIE(位置无关可执行文件),其中的内存区域会被放在随机化的位置。
在PIE中,变量的最终地址在链接时并不确定。
所以,代码改为把地址计算为相对于一个特殊寄存器 rip 中值的偏移量,这个寄存器指向下一条将要执行的指令。
这通常称为RIP 相对寻址。
在 NASM 中,可以用rel运算符请求 RIP 相对寻址:
mov rax, qword [rel variable]
也可以在源文件顶部用default rel把相对寻址设为默认方式。
本 track 中的所有练习都以PIE形式编译和链接,因此应使用rel来生成相对地址。
在任何节(例如.text、.data、.rodata)中定义的标签(函数和数据)在该源文件内可见。
如果声明为 global,它们对其他源文件也可见。
反过来,其他源文件中定义的标签如果声明为 extern,则对当前源文件可见。
这种情况下,汇编代码中不会标明数据大小,必须事先知道它。
default rel
section .data
global number1 ; 'number1' is a variable visible to other source files
number1 db 200
extern number2 ; 'number2' is a variable visible to the current source file, but defined in another
section .text
extern sum ; sum is a function visible to the current source file, but defined in another
fn:
mov dil, byte [number1]
mov sil, byte [number2]
call sum
...
你的朋友 José 是当地一所学校的老师。 他想到了一些有趣的实验,用来展示不同的颜色如何组合产生新的颜色。
他请你帮忙完成这些实验。
在本练习中,颜色用一个 32 位(4 字节)的数字来表示,其中编码了它的RGB值。
一个RGB值由 3 个通道组成,即_红_、绿_和_蓝,每个通道占 8 位(1 字节)。
第四个字节通常保留给 Alpha 通道,但在本练习中它的值会是空的(0)。
每种颜色的值已经存放在一张表中,这张表定义在另一个源文件里。 颜色通过它在表中的唯一地址来标识。
定义一个函数get_color_value,它返回某个颜色的 32 位值。
这个函数接受该颜色在颜色表中的有效地址作为形参。
get_color_value(black)
// => 0
提示 - 32 位等于 4 字节。
为了混合不同的颜色,José 会先固定一个基础颜色,然后只改变与它组合的次要颜色。
定义一个函数add_base_color,它把某个颜色的 32 位值保存到变量base_color中,以便之后使用。
这个函数没有返回值,它接受该颜色在颜色表中的地址作为形参。
变量base_color由你定义,并且必须能从其他源文件访问。
同一时间最多只有一个基础颜色。 如果添加了新的基础颜色,旧的那个就会被丢弃。
默认情况下,在程序开始时,base_color应该被初始化为 白色 的 32 位值,即0xFFFFFF00。
提示 - NASM 接受用0x开头定义的十六进制数字,例如0xFFFFFF00。
José 打算用原色做很多组合,所以他希望把它们单独列出来,以便快速访问。
因为他用RGB来表示颜色,所以原色有:
RED,值为0xFF000000。GREEN,值为0x00FF0000。BLUE,值为0x0000FF00。为每种颜色各定义一个常量。 这些常量必须能从其他源文件访问。
颜色应当按照另一个源文件中定义的combining_function来组合。
这个函数接受base_color和要与之混合的次要颜色的 32 位值作为形参。
它返回组合后颜色的 32 位值。
定义一个函数make_color_combination,它组合两种颜色,并把结果保存到内存中。
这个函数没有返回值,它按以下顺序接受这些形参:
注意,combining_function可能会修改你正在使用的寄存器中的值。
在调用这个函数之前,一定要把需要用到的变量保存到内存中。