Linux 汇编初体验与计算的目的

文章目录
  1. 1. 环境部署
    1. 1.1. 工具链
    2. 1.2. 开始之前
    3. 1.3. 汇编编程模型
  2. 2. 编写代码
    1. 2.1. 编译调试
    2. 2.2. 计算

说起来真的让人难以置信,我校汇编教材是老师自己写自己印的,拿到手的时候不由得想起高中在学校自己的印刷室里帮老师装订复习资料的场景……

好吧,Windows 系统下 的 DEBUG 只能说是勉强够用。先在记事本里写好程序,之后一行行敲入 DEBUG 下,调试起来甚至会简单一些。更进一步,用 MASA 和 LINK 也可以直接将汇编源文件编译成可执行文件。不过这里我以 Linux 为基础,如果对汇编有兴趣,可以尝试着写几个有用的程序,比如说~嗯, 0A0D 0A 间自由转换的转换器?

> 从字节层面看文本文件,Windows 操作系统以 0D 0A(16进制)作为换行标记(End of Line,EOL),而 Linux 则以 0A 作为 EOL。于是,Linux 下编写的文本文件在 Windows 的记事本中会丢失换行符。乱码?噢,不不不,那是因为 Linux 以 Unicode 保存汉字,而 Windows 记事本以 GBK 编码去解析造成的,对于中文世界,情况大多如此。

环境部署

编写 Linux 汇编程序,首先需要一个你有一个基于 Intel CPU 的 Linux 系统。如果可以满足这点,恭喜,您已经达到基本的硬件要求了。这里我使用 Ubuntu 14.04 LTS 64 位发行版,它运行在一台超便宜的洋垃圾笔记本上:D

工具链

  • NASM 汇编编译器
  • ld 连接器
  • Gdb 调试器

为了更好的编辑与调试,我使用 Kate 文本编辑器,安装 KWrite 文本编辑器后在 Kate 中添加终端插件(为了使用一个编辑器的插件需要另一个编辑器……呃,若终端插件无法工作,请安装 Konsole)。

Kate
(我想了半天风筝和它的联系,结果一点没有 OAO)

最后使用 Insight 作为 Gdb 的前端,如果感觉安装麻烦,使用 Kdbg 似乎也不错,只是对于汇编的支持差一些。好极了,现在我们有一个 GUI 的调试窗口用了。

Kdbg

insight

开始之前

在编写汇编代码之前稍稍暂停一下,汇编语言到底是什么?在高级语言中,我们可以直接定义变量和函数,但是汇编语言中,没有那种方便的东西,我们能操纵的东西只有数字,除了数字……还是数字。

不过实际上我们还是有类似变量的东西,寄存器。在过去 Intel 的 CPU 还是 8080 的时候,操作系统(CP/M-80)简单的安装在内存的顶部,内存底部的 256 字节用做程序段前缀(Program Segment Prifix,PSP),存储一些杂碎。程序从软盘中读入,加载到内存地址 0x0100 处,当程序运行时,操作系统直接对 0x0100 进行寻址。

CPU 从内存中寻址,得到机器指令,然后依照指令进行运算。8080 CPU 有 7 个寄存器用来存放运算数,它们的大小只有 8 位(没错,也就是说它的 CPU 是 8 位的)。它有 16 根地址线,8 根数据线。与地址线连接的是一个 16 位的地址缓存(Address Buffer),意味着它的寻址大小是 64K(0 – 65536)。

64K 内存

对地址 0x0100 进行寻址时,CPU 将地址缓存设置为 0000 0001 0000 0000,也就是将对应的存储芯片通低电压或高电压,啊,令人头大的晶体管。16 根地址线将选择信号加在芯片的选择器上,数字电路会给指定地址的存储单元通电,拿出这 1 个 bit 的数据,当 8 个芯片串在一起时,我们便得到了 8 bit (1 字节) 的数据。这个过程仅需要不可思议的几纳秒。

RAM 芯片

得到机器指令后,CPU 执行出厂时烧录好的指令集中的一条,而汇编语言就是直接或间接操纵(从内存拿出)数据,命令 CPU 执行计算的语言。实际上汇编语言编译后的程序代码(机器指令)也是一些 0 和 1。说白了,计算机就是在 0 和 1 之间跳舞,数据和可执行代码并没有本质的区别(想出利用缓冲区溢出漏洞攻击的人简直是天才)。

后来的 8086 有 20 根地址线,8 根数据线,可以在操纵 1MB 的内存(更准确的说,是 1MiB,但这里不再特意区分 MB 与 MiB)。而今天,32 位 CPU 可以寻址 4GB 内存,64 位 CPU 的寻址大小为 2^64 = 16777216TB。当然,CPU 和内存的技术也在变化,目前内存(DIMM)的存储单元堆叠在一起,存储单元的存储方式也不是按列存储。而多核 CPU 的出现以及多级缓存的应用使得在大脑中构建一个计算机运行的模型愈发困难,若实际去考量程序的执行效率,就不可避免的需要考虑到内存存取,缓存命中,内存一致性的诸多问题。考虑目前 CPU 的执行速度和内存存取的速度,在 CPU 不是瓶颈的场景中做到尽量少的存取内存,并且提高缓存命中率(顺序存储/压缩数据),可有效提高程序效率。

噢天啊~让生活简单一点吧,尽管访问内存的细节很复杂,但我们通常将它抽象成一个非常长的书架,一个物理地址对应着一个字节。不过一定要头脑清楚,除非你知道自己在做什么,否则不要试着在这个抽象层面上做任何‘优化’。

汇编编程模型

好像差不多了,不过再等一下。是的,如果你为 8 位的 Intel 8080 CPU 编写汇编程序,对 64K 内存直接寻址是可行的(实模式平面模型,real mode flat model);若为 32位机编写程序,你也可以直接对 4GB 内存进行寻址(保护模式平面模型,protected mode flat model)。

为 16 位 Intel 8086 编写程序呢?它和 8080 一样使用 16 位的地址缓存,但却有 20 条数据线,可对 1MB 内存进行寻址。如何用 16 位寄存器生成 20 位地址呢?答案是出乎意料的,CPU 使用两个 16 位寄存器来计算物理地址。将 1MB 的内存分成许多段(segment),段开始的地址就是段地址,它存储在段寄存器。任何能被 16 整除的地址都可以作为段地址,范围为 0x0 – 0xFFFF。将段地址乘 16 再加上另一个寄存器中存储的偏移地址,就得到了一个 20 位的物理地址,常常这样表示 0042:0042。(实模式段模型,real mode segmented model)

当我们使用十六进制来看时,段的含义会更清楚。0x42 乘 16 等同于 0000 0000 0400 0010 左移四位,得到 0x4200000 0000 0400 0010 0000),再加上一个 16 位偏移地址 0x42 等于 0x4620000 0000 0100 0110 0010)。细心的朋友已经发现了,尽管段地址加偏移地址可以实现对 1MB 内存的寻址,但实际上物理地址所对应的实际地址并不唯一,这是由于段地址之间间隔 16 个字节,将段地址平移之后也可能会有合法的偏移地址来代表某个物理地址。而这也告诉了我们段的本质,它只是一个特殊的地址及其后面的若干字节,段的大小至多为 65535 字节(64K),段地址为 0xFFFF 的段的大小只有 16 字节。

Intel 的 CPU 有一个良好的特性(恐怕也有人对此不是很赞同),即向后兼容。目前的 CPU 的架构都是在早期的架构上不断改进,即使架构有重大的变化,暴露给系统的那一部分始终是一样的。这也就是为什么你把之前 32 位的操作系统更换为 64 位而没有任何错误发生的原因(如果有错,你也不能怪它,因为它太旧了 XD)。哦,对了,当在 Windows 下运行 DOS 时,实际上就是进入了虚拟 86 模式,因此进入 DEBUG 后就像是回到了 8086 的时代一样,我们只有 64K 内存可以使用。

听说 DOS 时代有很多有趣的事情可以做,比如将数据直接写入内存中与显存存在映射关系的地方,操纵内存便可以制作出一些动画。现在的操作系统采用保护模式平面模型(Protected mode flat model),它将段地址保护起来,仅由操作系统控制,系统利用它可以实现虚拟内存,保护操作系统内核。目前很多 CPU 已经寄存器都扩大为 64 位,采用长模式,除了将过去 80x86 CPU 的寄存器加长到 64 位,还增加了几个新的寄存器,它们没有提供向后兼容的特性,即只能以 64 位进行读取。除此之外,目前的 CPU 还提供了 128 位的 SSE 寄存器,可加速数学/图形计算,比如多个四元数运算(SIMD,Single Instruction Multiple Data)。

编写代码

现在我们可以来编写汇编程序了,尽管在 Ubuntu 64 位系统下编写,但我们还是使用 32 位汇编编译器。所以我们手里有几个 32 位寄存器,还有一些 Linux 系统调用。

hello.asm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
;  Program: Hello World
;
; Usage:
; nasm -f elf -g -F dwarf hello.asm
; ld -m elf-i386 -o hello hello.o

section .data
helloMsg: db "Hello, world!", 10
helloLen: equ $-HelloMsg

section .bss
section .text

global _start:

_start:
nop
mov eax, 4 ; sys_write
mov ebx, 1 ; stdout
mov ecx, helloMsg ; message to print
mov edx, helloLen ; message length
int 80H ; system call

mov eax, 1 ; exit
mov ebx, 0 ; return value
int 80H ; system call
nop

首先来看数据段。helloMsg 为字符串,db 意为 define byte,引号用来标记字符串,数字 10 是换行符标记(还记得 0xOA 吗)。helloLen 为字符串长度,equ 指令指示编译时计算它的值,$ 令牌标识记录位置,在这里,它记录着 0x0A 后的位置,减去 helloMsg(字符串开始位置),得到的就是字符串长度。

之后我们使用 MOV 命令(mov dest, source)将源操作数存储在目的操作数中。在这里,我们将数字 4 放置在寄存器 EAX (32 位通用寄存器,除 EAX 外,还有 EBX,ECX,EDX)中;将数字 1 放在 EBX 中;将字符串的起始地值放置在 ECX 中;将字符串长度放置在 EDX 中。INT 80H 将执行一个软件中断,它首先将下一条代码的地址压入堆栈,接着跳转到中断向量表,中断向量引导程序跳转到 Linux 系统调用服务(执行内核空间中的代码),约定 EAX 寄存器存储服务编号,4 代表 sys_write 系统调用,EBX 指定输出为 stdout(0 代表 stdin,2 代表 stderr),ECX 和 EDX 提供字符串的开始地址和长度。当系统调用完成后,经过一些我们都不知道的操作后,数据就被打印到 stdout 中(一般为 TTY,可重定向),之后将下一条代码的地址从堆栈中弹出并跳转到这个地址(IRET 指令),接着执行下一条指令。

类似的,服务编号 1 代表退出系统调用,EBX 存储返回值 0。若不这样结束程序,系统会抛出一个段错误(Segment fault)。至于 NOP,它不做任何事情,加在代码首尾是为了方便调试。

对于 DOS 汇编,字符串输出服务编号为 09H,字符串以 $ 符号结束(0x24)。也就是说,汇编程序不能移植。特别的,由于内核不同,Linux 汇编程序不能运行在 BSD Unix 下。

编译调试

insight

使用 NASM 和 ld 完成源代码的编译链接,如果是 32 位机,ld 命令可省略 -m elf_i386。编译完成后,在终端运行程序,便会得到打印结果 “Hello, world!”。

我们使用 insight 进行调试,可以看到程序的结构,内存由低到高排列,首先是 .text 段;其次是 .data 段;接下来是 .bss 段,尽管它是一个段,但在程序加载入内存后才会被分配空间,用来存放未初始化变量。通过 insight 还可以看到编译得到的机器指令:MOV EAX 4 编译为 0x00000004B8,采用小端对齐。这里我使用另一个字符串,可以看到它以 0x0A结尾,共 14 字节。

计算

对于计算有一个很有趣的论断,不知道计算机同不同意。

计算的目的是洞察事物本质,而不是获得数字
— Richard Hamming

嘛~尽管这样说,恐怕很多事物的本质还是不能用计算去得到。有时候我也会去想象一个没有计算机的世界,是不是会更好呢。美丽的大自然?