读书随想:机器级代码
这部分内容在多个层面简单地介绍了一些有关于机器级代码的信息,包括机器级编程时的两种重要抽象、编译器的工作、机器级代码的特点和它对内存的视角。它好像没有涉及什么实质的知识,但是拥有很多可以发散联想的关键点。在遇到这些关键点时脑海里有所知,感觉很好。
机器级代码
机器级编程时的两种抽象
计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。
关于抽象模型,平常遇到的所谓抽象模型和实现细节通常距离并不遥远,至少看上去两者可以直观地联系在一起,但这两种抽象模型却有些令人细思极恐,被隐藏的实现细节仿佛海面下的冰山。感觉对“高度抽象”这个词有了具体的感受。
- 第一种是由指令集体系结构或指令集架构(Instruction Set Architecture,ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数 ISA,包括 x86-64,将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与 ISA 指定的顺序执行的行为完全一致。
- 第二种是机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
- 关于第一种抽象,文中用了“指令的顺序执行”作为对“定义程序的行为”的一个例子,那么何谓定义“处理器状态”、“指令的格式”和“每条指令对状态的影响”呢?
- 处理器状态是指在执行程序时,CPU 的各种寄存器和内存中数据的当前值。
- 指令的格式是指机器指令的组成部分,即操作码(opcode)和操作数(operand)。每个指令会由不同的字段构成,每个字段可能代表不同的信息(例如操作的类型、操作的寄存器、操作数的地址等)。
- 每条指令对状态的影响是指指令执行时应该如何改变处理器状态,即各种寄存器和内存中的数据。
- 关于第二种抽象,文中提及了存储器系统,虚拟地址到存储器系统的跳跃也太大了,中间还隔着物理地址呢。这部分背后的信息量很大啊,如果不是回头来看,应该不好理解吧?说实话,个人认为文中的说法有一点点容易让人多想,在物理地址的视角里就已经“将内存视为一个非常大的字节数组”,虚拟地址的设计关注的是其他方面。
操作码通常为 1 个字节,加上操作数的话指令长度从 1 到 15 个字节不等。从编码角度,操作码的个数似乎限制为 256,但在现代架构中,通过前缀字节,操作码可以扩展为多个字节。想起曾经对编码一知半解,不然也不会对 CPU 如何识别机器指令感到头疼吧。
编译器的工作
在整个编译过程中,编译器会完成大部分的工作,将把用 C 语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。
一条机器指令只执行一个非常基本的操作,例如将存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。
这部分真是让人惊叹,编译器好像将一座摩天大楼的建造过程分解为一系列最基本的步骤,从汇编代码开始,你已经看不到关于局部变量名或数据类型的信息了;而处理器通过执行这一系列最基本的操作重现摩天大楼,实现最终复杂的程序。就好像积跬步以至千里。
机器级代码的特点
汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点时它用可读性更好的文本格式表示。
机器级代码可读性差,但通过理解相近的汇编代码就可以理解计算机是如何执行程序的。因此尽管如今我们几乎不需要手写汇编代码,但是理解它以及它与原始的 C 代码的联系仍然很关键。读汇编代码就如同在读机器代码。
x86-64 的机器代码和原始的 C 代码差别非常大。一些通常对 C 语言程序员隐藏的处理器状态都是可见的:
- 程序计数器(通常称为 PC,在 x86-64 中用
%rip
表示)给出将要执行的下一条指令在内存中的地址。 - 整数寄存器文件包含 16 个命名的位置,分别存储 64 位的值,可以存储地址或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
- 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息,用来实现控制或数据流中的条件变化,比如说用来实现
if
和while
语句。 - 一组向量寄存器可以存放一个或多个整数或浮点数值。
上述内容再次提到了处理器状态。
- 关于程序计数器,我是从 Java 中的程序计数器开始认识这个概念的。两者概念相似,实现和应用环境却并不相同。前者是由硬件直接管理,与程序的运行紧密相关;后者是由 JVM 管理,指向字节码指令的地址,主要用于字节码执行和多线程的调度。说实话,感觉如今对前者的本质认识得很清楚了,但对后者的认识却还在表面。
- 关于整数寄存器,保存临时数据很好理解,而“有的被用来记录某些重要的程序状态”,栈指针寄存器应该是一个例子。
- 关于条件码寄存器,最初我并没有正确认识到一件事,CPU 在执行一些指令的同时,会根据结果设置这些寄存器。
机器级代码对内存的视角
C 语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。C 语言中的聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。
理解这种模型似乎并不困难,但定义并实现这种模型并非易事。如今“见多了猪跑”,回头看这个模型好像理所当然,当初自己可不是一点就通的。如果能方便地“透视”内存中的数据,很多东西学起来会容易很多吧。
不仅仅是“机器代码只是简单地将内存看成一个很大的、按字节寻址的数组”,而是“机器代码看内存只能看到一个很大的、按字节寻址的数组”,就好像内存只给机器代码留了一个至简的接口。这么看,存储器系统的实现真是太棒了啊。
机器代码对内存数据的使用很好地诠释了(0 & 1) + interpretation = everything
。再次强调,在机器代码的视角中是没有数据类型相关的信息的。
程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如用 malloc
库函数分配的)。
准确的描述,每一部分都会让人想起一些知识点,就好像“我知道你想让我知道什么”的感觉,而后会突然意识到,每一部分的知识点,都好厚重啊。。。
- 想起之前好像不能理解“可执行机器代码和数据都在内存里”这件事情,如今想来竟有种“轻舟已过万重山”的感觉,当时是因为不能理解 CPU 如何识别指令和数据吗?
- 操作系统需要的一些信息,主要指的是操作系统内核所需要的信息吗?内核对我来说也曾遮了一副厚厚的面纱呢,现在也只是面纱变薄了一些。
- 栈真是一个美妙的数据结构啊,我竟然曾经因为 Java 的
Stack
类,对它产生过“你已经 out 了”的错误印象,我真是该死啊 Orz。- 内存分配,任重而道远啊。
程序内存用虚拟地址来寻址。
曾经我以为地址就是物理地址,我很高兴我理解了“内存是一个非常大的字节数组”这个基本抽象。后来我理解了存储器系统,我感觉内存的奥秘向我展开了,设计太棒了吧。直到我注意到地址其实指的是虚拟地址,我脑子里飘过了一连串的问号,还需要抽象什么吗?这是为了啥呢?我咋不太能理解它带来的益处呢?最后,我理解了虚拟地址,真妙不可言。
最终的可执行代码文件,不仅包含了相关过程的代码,还包含了用来启动和终止程序的代码,以及用来与操作系统交互的代码。
这部分还是未知的。
反汇编可执行代码文件之后会发现其中的代码片段和直接反汇编单独的目标代码文件中的代码片段几乎完全一样,但是指令 callq
调用函数的地址不同——链接器将函数代码的地址移到了一段不同的地址范围中,同时它也会为函数调用找到匹配的函数的可执行代码的位置。
最初我非常不理解为什么编译器能确定可执行代码在内存中的位置,程序每次运行时难道都能在内存中占据固定位置吗?如果不理解第 9 章中的虚拟地址,在第 7 章的链接部分,难道不会感到奇怪吗?
在可执行代码文件中,代码片段最后可能会插入 nop
指令。这些指令对程序没有影响,插入它们是为了使函数代码变成 16 字节(对齐),使得就存储器系统性能而言,能更好地放置下一个代码块。
数据类型的对齐取决于表示各个数据类型所需的字节,而指令的对齐要求 16 字节对齐。编译器会根据硬件架构的对齐要求自动进行对齐优化(是编译器实现的哦)。
参考文章
- 《深入理解计算机系统》