1
0
mirror of https://github.com/chai2010/advanced-go-programming-book.git synced 2025-05-24 12:32:21 +00:00
2018-08-14 22:08:04 +08:00

184 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 3.2 计算机结构
汇编语言是直面计算机的编程语言因此理解计算机结构是掌握汇编语言的前提。当前流行的计算机基本采用的是冯·诺伊曼计算机体系结构在某些特殊领域还有哈佛体系架构。冯·诺依曼结构也称为普林斯顿结构采用的是一种将程序指令和数据存储在一起的存储结构。冯·诺伊曼计算机中的指令和数据存储器其实指的是计算机中的内存然后在配合CPU处理器就组成了一个最简单的计算机了。
汇编语言其实是一种非常简单的编程语言因为它面向的计算机模型就是非常简单的。让人觉得汇编语言难学主要有几个原因不同类型的CPU都有自己的一套指令即使是相同的CPU32位和64位的运行模式依然会有差异不同的汇编工具同样有自己特有的汇编指令不同的操作系统和高级编程语言和底层汇编的调用规范并不相同。本节将描述几个有趣的汇编语言模型最后精简出一个适用于AMD64架构的精简指令集以便于Go汇编语言的学习。
## 3.2.1 图灵机和BF语言
图灵机是由图灵提出的一种抽象计算模型。机器有一条无限长的纸带,纸带分成了一个一个的小方格,每个方格有不同的颜色,这类似于计算机中的内存。同时机器有一个探头在纸带上移来移去,类似于通过内存地址来读写内存上的数据。机器头有一组内部计算状态,还有一些固定的程序(更像一个哈佛结构)。在每个时刻,机器头都要从当前纸带上读入一个方格信息,然后根据自己的内部状态和当前要执行的程序指令将信息输出到纸带方格上,同时更新自己的内部状态并进行移动。
图灵机虽然不容易编程但是非常容易理解。有一种极小化的BrainFuck计算机语言它的工作模式和图灵机非常相似。BrainFuck由Urban Müller在1993年创建的简称为BF语言。Müller最初的设计目标是建立一种简单的、可以用最小的编译器来实现的、符合图灵完全思想的编程语言。这种语言由八种状态构成早期为Amiga机器编写的编译器第二版只有240个字节大小
就象它的名字所暗示的brainfuck程序很难读懂。尽管如此brainfuck图灵机一样可以完成任何计算任务。虽然brainfuck的计算方式如此与众不同但它确实能够正确运行。这种语言基于一个简单的机器模型除了指令这个机器还包括一个以字节为单位、被初始化为零的数组、一个指向该数组的指针初始时指向数组的第一个字节、以及用于输入输出的两个字节流。这是一种按照图灵完备的语言它的主要设计思路是用最小的概念实现一种“简单”的语言。BrainFuck 语言只有八种符号,所有的操作都由这八种符号的组合来完成。
下面是这八种状态的描述,其中每个状态由一个字符标识:
| 字符 | C语言类比 | 含义
| --- | ----------------- | ------
| `>` | `++ptr;` | 指针加一
| `<` | `--ptr;` | 指针减一
| `+` | `++*ptr;` | 指针指向的字节的值加一
| `-` | `--*ptr;` | 指针指向的字节的值减一
| `.` | `putchar(*ptr);` | 输出指针指向的单元内容ASCⅡ码
| `,` | `*ptr = getch();` | 输入内容到指针指向的单元ASCⅡ码
| `[` | `while(*ptr) {}` | 如果指针指向的单元值为零,向后跳转到对应的 `]` 指令的次一指令处
| `]` | | 如果指针指向的单元值不为零,向前跳转到对应的 `[` 指令的次一指令处
下面是一个 brainfuck 程序,向标准输出打印"hi"字符串:
```
++++++++++[>++++++++++<-]>++++.+.
```
理论上我们可以将BF语言当作目标机器语言将其它高级语言编译为BF语言后就可以在BF机器上运行了。
## 3.2.2 人力资源机器游戏
《人力资源机器》Human Resource Machine是一款设计精良汇编语言编程游戏。在游戏中玩家扮演一个职员角色来模拟人力资源机器的运行。通过完成上司给的每一份任务来实现晋升的目标完成任务的途径就是用游戏提供的11个机器指令编写正确的汇编程序最终得到正确的输出结果。人力资源机器的汇编语言可以认为是跨平台、跨操作系统的通用的汇编语言因为在macOS、Windows、Linux和iOS上该游戏的玩法都是完全一致的。
人力资源机器的机器模型非常简单INBOX命令对应输入设备OUTBOX对应输出设备玩家小人对应一个寄存器临时存放数据的地板对应内存然后是数据传输、加减、跳转等基本的指令。总共有11个机器指令:
| 名称 | 解释 |
| -------- | ---
| INBOX | 从输入通道取一个整数数据,放到手中(寄存器)
| OUTBOX | 将手中(寄存器)的数据放到输出通道,然后手中将没有数据(此时有些指令不能运行)
| COPYFROM | 将地板上某个编号的格子中的数据复制到手中(手中之前的数据作废),地板格子必须有数据
| COPYTO | 将手中(寄存器)的数据复制到地板上某个编号的格子中,手中的数据不变
| ADD | 将手中(寄存器)的数据和某个编号对应的地板格子的数据相加,新数据放到手中(手中之前的数据作废)
| SUB | 将手中(寄存器)的数据和某个编号对应的地板格子的数据相减,新数据放到手中(手中之前的数据作废)
| BUMP+ | 自加一
| BUMP- | 自减一
| JUMP | 跳转
| JUMP =0 | 为零条件跳转
| JUMP <0 | 为负条件跳转
除了机器指令外游戏中有些环节还提供类似寄存器的场所用于存放临时的数据人力资源机器游戏的机器指令主要分为以下几类
- 输入/输出(INBOX, OUTBOX): 输入后手中将只有1份新拿到的数据, 输出后手中将没有数据
- 数据传输指令(COPYFROM/COPYTO): 主要用于仅有的1个寄存器手中和内存之间的数据传输传输时要确保源数据是有效的
- 算术相关(ADD/SUB/BUMP+/BUMP-)
- 跳转指令: 如果是条件跳转寄存器中必须要有数据
主流的处理器也有类似的指令除了基本的算术和逻辑预算指令外再配合有条件跳转指令就可以实现分支循环等常见控制流结构了
下图是某一层的任务将输入数据的0剔除非0的数据依次输出右边部分是解决方案
![](../images/ch3.2-1-arch-hsm-zero.jpg)
*图 3.2-1 人力资源机器*
整个程序只有一个输入指令一个输出指令和两个跳转指令共四个指令
```
LOOP:
INBOX
JUMP-if-zero LOOP
OUTBOX
JUMP LOOP
```
首先通过INBOX指令读取一个数据包然后判断包裹的数据是否为0如果是0的话就跳转到开头继续读取下一个数据包否则将输出数据包然后再跳转到开头以此循环无休止地处理数据包裹直到任务完成晋升到更高一级的岗位然后处理类似的但更复杂的任务
## 3.2.3 X86-64体系结构
X86其实是是80X86的简称后面三个字母包括Intel 80868028680386以及80486等指令集合因此其架构被称为x86架构x86-64是AMD公司于1999年设计的x86架构的64位拓展向后兼容于16位及32位的x86架构X86-64目前正式名称为AMD64也就是Go语言中GOARCH环境变量指定的AMD64如果没有特殊说明的话本章中的汇编程序都是针对64位的X86-64环境
在使用汇编语言之前必须要了解对应的CPU体系结构下面是X86/AMD架构图
![](../images/ch3.2-2-arch-amd64-01.ditaa.png)
*图 3.2-2 AMD64架构*
左边是内存部分是常见的内存布局其中text一般对应代码段用于存储要执行指令数据代码段一般是只读的然后是rodata和data数据段数据段一般用于存放全局的数据其中rodata是只读的数据段而heap段则用于管理动态的数据stack段用于管理每个函数调用时相关的数据在汇编语言中一般重点关注text代码段和data数据段因此Go汇编语言中专门提供了对应TEXT和DATA命令用于定义代码和数据
中间是X86提供的寄存器寄存器是CPU中最重要的资源每个要处理的内存数据原则上需要先放到寄存器中才能由CPU处理同时寄存器中处理完的结果需要再存入内存X86中除了状态寄存器FLAGS和指令寄存器IP两个特殊的寄存器外还有AXBXCXDXSIDIBPSP几个通用寄存器在X86-64中又增加了八个以R8-R15方式命名的通用寄存器因为历史的原因R0-R7并不是通用寄存器它们只是X87开始引入的MMX指令专有的寄存器在通用寄存器中BP和SP是两个比较特殊的寄存器其中BP用于记录当前函数帧的开始位置和函数调用相关的指令会隐式地影响SP的值SP则对应当前栈指针的位置和栈相关的指令会隐式地影响SP的值而某些调试工具需要BP寄存器才能正常工作
右边是X86的指令集CPU是由指令和寄存器组成指令是每个CPU内置的算法指令处理的对象就是全部的寄存器和内存我们可以将每个指令看作是CPU内置标准库中提供的一个个函数然后基于这些函数构造更复杂的程序的过程就是用汇编语言编程的过程
## 3.2.4 Go汇编中的伪寄存器
Go汇编为了简化汇编代码的编写引入了PCFPSPSB四个伪寄存器四个伪寄存器加其它的通用寄存器就是Go汇编语言对CPU的重新抽象该抽象的结构也适用于其它非X86类型的体系结构
四个伪寄存器和X86/AMD64的内存和寄存器的相互关系如下图
![](../images/ch3.2-3-arch-amd64-02.ditaa.png)
*图 3.2-3 Go汇编的伪寄存器*
在AMD64环境伪PC寄存器其实是IP指令计数器寄存器的别名伪FP寄存器对应的是函数的帧指针一般用来访问函数的参数和返回值伪SP栈指针对应的是当前函数栈帧的底部不包括参数和返回值部分一般用于定位局部变量伪SP是一个比较特殊的寄存器因为还存在一个同名的SP真寄存器真SP寄存器对应的是栈的顶部一般用于定位调用其它函数的参数和返回值
当需要区分伪寄存器和真寄存器的时候只需要记住一点伪寄存器一般需要一个标识符和偏移量为前缀如果没有标识符前缀则是真寄存器比如`(SP)``+8(SP)`没有标识符前缀为真SP寄存器`a(SP)``b+8(SP)`有标识符为前缀表示伪寄存器
## 3.2.5 X86-64指令集
很多汇编语言的教程都会强调汇编语言是不可移植的严格来说汇编语言是在不同的CPU类型或不同的操作系统环境或不同的汇编工具链下是不可移植的而在同一种CPU中运行的机器指令是完全一样的汇编语言这种不可移植性正是其普及的一个极大的障碍虽然CPU指令集的差异是导致不好移植的较大因素但是汇编语言的相关工具链对此也有不可推卸的责任而源自Plan9的Go汇编语言对此做了一定的改进首先Go汇编语言在相同CPU架构上是完全一致的也就是屏蔽了操作系统的差异同时Go汇编语言将一些基础并且类似的指令抽象为相同名字的伪指令从而减少不同CPU架构下汇编代码的差异寄存器名字和数量的差异是一直存在的)。本节的目的也是找出一个较小的精简指令集以简化Go汇编语言的学习
X86是一个极其复杂的系统有人统计x86-64中指令有将近一千个之多不仅仅如此X86中的很多单个指令的功能也非常强大比如有论文证明了仅仅一个MOV指令就可以构成一个图灵完备的系统以上这是两种极端情况太多的指令和太少的指令都不利于汇编程序的编写但是也从侧面体现了MOV指令的重要性
通用的基础机器指令大概可以分为数据传输指令算术运算和逻辑运算指令控制流指令和其它指令等几类因此我们可以尝试精简出一个X86-64指令集以便于Go汇编语言的学习
因此我们先看看重要的MOV指令其中MOV指令可以用于将字面值移动到寄存器字面值移到内存寄存器之间的数据传输寄存器和内存之间的数据传输需要注意的是MOV传输指令的内存操作数只能有一个可以通过某个临时寄存器达到类似目的最简单的是忽略符号位的数据传输操作386和AMD64指令一样不同的124和8字节宽度有不同的指令
| Data Type | 386/AMD64 | Comment |
| --------- | ----------- | ------------- |
| [1]byte | MOVB | B => Byte |
| [2]byte | MOVW | W => Word |
| [4]byte | MOVL | L => Long |
| [8]byte | MOVQ | Q => Quadword |
MOV指令它不仅仅用于在寄存器和内存之间传输数据而且还可以用于处理数据的扩展和截断操作当数据宽度和寄存器的宽度不同又需要处理符号位时386和AMD64有各自不同的指令
| Data Type | 386 | AMD64 | Comment |
| --------- | ------- | ------- | ------------- |
| int8 | MOVBLSX | MOVBQSX | sign extend |
| uint8 | MOVBLZX | MOVBQZX | zero extend |
| int16 | MOVWLSX | MOVWQSX | sign extend |
| uint16 | MOVWLZX | MOVWQZX | zero extend |
比如当需要将一个int64类型的数据转为bool类型时则需要使用MOVBQZX指令处理
基础算术指令有ADDSUBMULDIV等指令其中ADDSUBMULDIV用于加除运算最终结果存入目标寄存器基础的逻辑运算指令有ANDOR和NOT等几个指令对应逻辑与或和取反等几个指令
| 名称 | 解释 |
| ------ | ---
| ADD | 加法
| SUB | 减法
| MUL | 乘法
| DIV | 除法
| AND | 逻辑与
| OR | 逻辑或
| NOT | 逻辑取反
其中算术和逻辑指令是顺序编程的基础通过逻辑比较影响状态寄存器再结合有条件跳转指令就可以实现更复杂的分支或循环结构
控制流指令有CMPJMP-if-xJMPCALLRET等指令CMP指令用于两个操作数做减法根据比较结果设置状态寄存器的符号位和零位可以用于有条件跳转的跳转条件JMP-if-x是一组有条件跳转指令常用的有JLJLZJEJNEJGJGE等指令对应小于小于等于等于不等于大于和大于等于等条件时跳转JMP指令则对应无条件跳转将要跳转的地址设置到IP指令寄存器就实现了跳转而CALL和RET指令分别为调用函数和函数返回指令
| 名称 | 解释 |
| -------- | ---
| JMP | 无条件跳转
| JMP-if-x | 有条件跳转JLJLZJEJNEJGJGE
| CALL | 调用函数
| RET | 函数返回
无条件和有条件调整指令是实现分支和循环控制流的基础指令理论上我们也可以通过跳转指令实现函数的调用和返回功能不过因为目前函数已经是现代计算机中的一个最基础的抽象因此大部分的CPU都针对函数的调用和返回提供了专有的指令和寄存器
其它比较重要的指令有LEAPUSHPOP等几个其中LEA指令将标准参数格式中的内存地址加载到寄存器而不是加载内存位置的内容)。PUSH和POP分别是压栈和出栈指令通用寄存器中的SP为栈指针栈是向低地址方向增长的
| 名称 | 解释 |
| ------ | ---
| LEA | 取地址
| PUSH | 压栈
| POP | 出栈
当需要通过间接索引的方式访问数组或结构体等某些成员对应的内存时可以用LEA指令先对目前内存取地址然后在操作对应内存的数据而栈指令则可以用于函数调整自己的栈空间大小
最后需要说明的是Go汇编语言可能并没有支持全部的CPU指令如果遇到没有支持的CPU指令可以通过Go汇编语言提供的BYTE命令将真实的CPU指令对应的机器码填充到对应的位置完整的X86指令在 https://github.com/golang/arch/blob/master/x86/x86.csv 文件定义同时Go汇编还正对一些指令定义了别名具体可以参考这里 https://golang.org/src/cmd/internal/obj/x86/anames.go