计算机的可编程性主要是指对中央处理器的编程。通过预设的指令集,和储存设备记录的指令序列,实现可编程性。
本文广泛借鉴了 《大话处理器处理器基础知识读本》,更准确的来说是对其中的某些细节进行丰富,并连贯在一起,从另一个角度理解程序到物理电路的关系。
体系架构
冯·诺伊曼结构:将存储设备与中央处理器分开的概念。 哈佛架构:将程序数据与普通数据分开存储的设计概念,但是它并未完全突破冯.诺伊曼架构。 修正哈佛架构:允许将指令存储器的内容作为数据进行访问。
指令集1
早期的计算机没有指令集这种东西,都是软硬件绑定耦合。每次用户购买就是整套产品,直到 IBM 秉持着“加一层的设计理念”,弄出了 ISA,用于抽离程序和底层 CPU 的强耦合,随后 CPU 的内部实现,被称之为 微架构(Microarchitecture),而对上层暴露的接口称之为 架构(Architecture)。
OISC/URISC:(One/Ultimate Reduced Instruction Set Computer) 单一指令集/最简指令集计算机
CISC: (Complex Instruction Set Computer)复杂指令集,对程序逻辑的高级封装,通过对精简指令的复合,加速程序执行
- 计算机发展早期,人们用汇编语言进行编程,自然喜好强大好用的指令集(类似高阶语言的各种语法糖)。
- CISC 指令集中包含高级语言的某些特性,如复杂的寻址模式,直接对应指针的运算。
- 那时的存储器速度慢且昂贵,因此 CISC 的指令是变长指令,以节约存储空间。由一条指令完成很多功能,对内存访问减少了。
RISC: (Reduced Instruction Set Computing)精简指令集
- IBM 发现,CISC 中的大量复杂寻址方式和大量指令不会被经常用到。常用的指令只占 20%。
- RISC 的指令大部分时间能在一个 cycle 内完成,因此处理器频率得到大幅度提升。
- RISC 利于扩展,但 RISC 采用定长指令,使得存储空间变大。程序空间较大会降低 cache 的命中率,降低程序的执行效率。
举个例子
在 CISC 架构和 RISC 实现加法,会有如下差异
add (reg) 0x08 // 直接将存储器单元(reg)中的值加0x08,结果仍然存放在该存储器单元中
ld reg2 (reg) // 将存储器单元 (reg) 中的值加载到寄存器 reg2 中
add reg2 0x08 // 将寄存器 reg2 中的值加 0x08,结果存到在寄存器 reg2 中
store (reg) reg2 // 将寄存器 reg2 中的值放回存储器单元 (reg) 中
然后对上面 2 种指令集从以下 2 个角度理解
第一种,RISC 能满足所有需求,但是 CISC 提供了一系列通过硬件提升性能的拓展指令。
第二种,CISC 是高阶的抽象,对 RISC 进一步封装,所以我们也可以在上层完成这个阶段,通过预编译将 CISC 预先转化为 RISC,然后进一步执行。(Intel 就是这么整的)
ZISC:(Zero Instruction Set Computing) 零指令集,用于教学环境下的 CPU 模拟。
机器字长
在各种架构之下,我们常常能听到 x86, x64 这种对 CPU 架构的简称,x64 实际上是 x86-64,而这里就包含一个叫机器字长的特性。
这里有相对完整的CPU 架构比较表
可以清晰的发现各种架构的共同点和差异,比如:
- x86 存在 16->32->64 位
- 而 ARM 在 32 和 64 则是 2 套,A32 和 A64
- MIPS 则只有 32->64
- RISC-V 非常 nb,但是不知道咋回事的东西
其 32 就是每个寄存器可以储存的数据长度,以及 CPU 单次处理的数据量。
高阶语言 -> 汇编语言
int main() {
int a = 19;
int b = 918;
int c = a + b * b / a;
return c;
}
首先使用 [Compilter Explorer]: (https://gcc.godbolt.org/) 瞅一眼他们的生成的汇编代码。
最好是先弄出来编译生成的二进制可执行文件,并使用逆向工具 IDA,获取它的机器码和汇编吗。
在此使用 dockcross 这个项目来处理这个问题。这也是为了符合我自己定义的环境分发的理念。
最后得到如下一堆文件
main.sgcc -S 生成的汇编代码a.out目标文件a.strip.out经过 strip 处理后的目标文件a.strip.asm通过 IDA 逆向得到的汇编文件
把最重要的抽出来,大概是下面这堆东西;为了提升可读性,和 main.s 和 a.strip.asm 均有巨大差异。
因为汇编和编译器和物理硬件有着强依赖性,所以这个简单的例子还是太复杂;我们从更简单的 Intel 8086 开始简单入门汇编。
拿阮一峰的这篇文章来看看 汇编语言入门教程,i++ 这是如何执行的。
# int i[] = [2];
push 2
loopStart:
# ax = i[0];
pop AX
# tmp = 1;
push 1
pop BX
# i = i + tmp;
add AX, BX
# i = tmp
push AX
# goto
jmp loopStart
大概就是这么回事,这样看起来好像写个模拟器很简单的样子。
接下来分析一下上面通过反汇编得到的汇编代码 (GAS / AT & T)。
# $19 立即数寻址
# -4(%rbp) = *(rbp - 4) (基址 + 偏移值) 寻址
# movl S,D 对 4 字节整数进行传送 D = S
movl $19, -4(%rbp)
movl $918, -8(%rbp)
movl -8(%rbp), %eax
# 无符号 64位乘
# R[%edx]:R[%eax] = S * R[%eax]
# 高 32 位放在 edx,低 32 位放在 eax
imull -8(%rbp)
# https://www.cnblogs.com/zuoxiaolong/p/computer17.html
cltd
# 有符号除法,保存余数和商
# eax 商,edx 余数
idivl -4(%rbp)
movl %eax, %edx
movl -4(%rbp), %eax
# 求和赋值,addl S,D; D = D + S
addl %edx, %eax
movl %eax, -12(%rbp)
movl -12(%rbp), %eax
好像也不是很难,看起来写个模拟器不是很难的样子。
汇编语言 -> 机器语言
查看 Objdump 生成的汇编代码(main.s.dec),左侧是对应的机器码。右侧的汇编代码均以 16 进制表示。所以下面的代码等效于上面的 main.s
664: c7 45 fc 13 00 00 00 movl $0x13,-0x4(%rbp)
66b: c7 45 f8 96 03 00 00 movl $0x396,-0x8(%rbp)
672: 8b 45 f8 mov -0x8(%rbp),%eax
675: 0f af 45 f8 imul -0x8(%rbp),%eax
679: 99 cltd
67a: f7 7d fc idivl -0x4(%rbp)
67d: 89 c2 mov %eax,%edx
67f: 8b 45 fc mov -0x4(%rbp),%eax
682: 01 d0 add %edx,%eax
684: 89 45 f4 mov %eax,-0xc(%rbp)
687: 8b 45 f4 mov -0xc(%rbp),%eax
因为左侧的机器码都是机器相关的,所以需要参照 Intel 的参考文档才能阅读。
比如:movl $0x13, -0x4(%rbp) 为什么会编译成 c7 45 fc 13 00 00 00,而 movl $0x396,-0x8(%rbp) 为什么编译成 c7 45 f8 96 03 00 00。
姑且猜测一下:
c7是操作符movlfc/f8是-0x4(%rbp)13 00是19的 16 进制96 03是918的 16 进制8b是mov <reg>,第二个地址可能存在一个默认值%eax0f af是imulfc是idivl
不猜了,还是看官方的定义吧。
下面参考的那文章当时是 1, 2A, 2B, 2C, 3A, 3B, and 3C,不知道过去了多少年,现在已经是 1, 2A, 2B, 2C, 3A, 3B, 3C, 3D and 4,也从 3k 页变成了近 5k 页;所以目录是个好东西,比如下图在 Vol 2, 2.1 INSTRUCTION FORMAT...
关于操作符的定义都在 Vol 2, 3-5 下面,对应的 A-L, M-U, V-Z,比如: mov 就在 Vol 2, 4.3 INSTRUCTIONS (M-U) 的 MOVE 下面。
但是上面我们得到的汇编代码是 GAS 语法的,而 Intel 手册提供的是 Intel 的 NASM 语法。
因为花了几秒钟没找到自动转换的工具,所以对此人肉转化一下
664: c7 45 fc 13 00 00 00 mov [rbp - 4], 13
66b: c7 45 f8 96 03 00 00 mov [rbp - 8], 396
672: 8b 45 f8 mov eax, [rbp - 8]
675: 0f af 45 f8 imul [rbp - 8], eax
679: 99 cltd
67a: f7 7d fc idiv [rbp - 4]
67d: 89 c2 mov edx, eax
67f: 8b 45 fc mov eax, [rbp - 4]
682: 01 d0 add edx, eax
684: 89 45 f4 mov [rbp - 0xc], eax
687: 8b 45 f4 mov eax, [rbp - 0xc]
先对必要的 Opcode 定义进行摘要
| Opcode | Instruction | Description |
|---|---|---|
| 89 /r | MOV r/m32,r32 | Move r32 to r/m32 |
| 8B /r | MOV r32,r/m32 R | Move r/m32 to r32. |
| C7 /0 id | MOV r/m32, imm32 | Move imm32 to r/m32. |
| REX.W + C7 /0 id | MOV r/m64, imm32 | Move imm32 sign extended to 64-bits to r/m64. |
| 0F AF /r | IMUL r32, r/m32 | doubleword register ← doubleword register ∗ r/m32. |
| F7 /7 | IDIV r/m32 | Signed divide EDX:EAX by r/m32, with result stored in EAX ← Quotient, EDX ← Remainder. |
| 01 /r | ADD r/m32, r32 | Add r32 to r/m32 |
- /digit — A digit between 0 and 7 indicates that the ModR/M byte of the instruction uses only the r/m (register or memory) operand. The reg field contains the digit that provides an extension to the instruction's opcode.
- /r — Indicates that the ModR/M byte of the instruction contains a register operand and an r/m operand.
- ib, iw, id, io — A 1-byte (ib), 2-byte (iw), 4-byte (id) or 8-byte (io) immediate operand to the instruction that follows the opcode, ModR/M bytes or scale-indexing bytes. The opcode determines if the operand is a signed value. All words, doublewords and quadwords are given with the low-order byte first.
通用寄存器
| Register Type | Without REX | With REX |
|---|---|---|
| Byte Registers | AL, BL, CL, DL, AH, BH, CH, DH | AL, BL, CL, DL, DIL, SIL, BPL, SPL, R8L - R15L |
| Word Registers | AX, BX, CX, DX, DI, SI, BP, SP | AX, BX, CX, DX, DI, SI, BP, SP, R8W - R15W |
| Doubleword Registers | EAX, EBX, ECX, EDX, EDI, ESI, EBP, ESP | EAX, EBX, ECX, EDX, EDI, ESI, EBP, ESP, R8D - R15D |
| Quadword Registers | N.A. | RAX, RBX, RCX, RDX, RDI, RSI, RBP, RSP, R8 - R15 |
举个例子:
664: c7 45 fc 13 00 00 00 mov [rbp - 4], 13
首先找到 MOVE,有个对应的 MOV r/m64, imm32,rbp 是寄存器上表中的关键字,imm64 是 32 位立即数,即 13.
所以选中的 Opcode是 REX.W + C7 /0 id,因为没有 SIB,所以忽略 REX.W;其中 /0 是在下一个阶段 ModR/W 中使用。
这块咋回事等等把 A Beginners’ Guide to x86-64 Instruction Encoding 翻译了再看。
参照最开始的 指令语句格式,在 Reg/Opcode 中,当 Opcode 段包含 /digit 时,其 Reg/Opcode 为拓展的 Opcode;
而 Mod 段为 [RBP - 4],在 Intel 的手册中并没有关于 64 位寄存器的表格,所以通过 Vol. 2A 3-2 下面的 Reg Field 进行推导,也就是 R/M 为 101,而 Mod 因为-4 所为 01;之后加上
Displacement 字段进行偏移,即 -4 的补码 1111 1100,即 FC。
最后是 Immediate 段,也就是 13H。
最后把几个段,拼接起来就是 C7 45 FC 13 00 00 00。
分析不动了,直接看 Intel汇编指令格式解析2 下面的例子比较靠谱。**
机器语言 -> 物理电路
首先是通过规范化(布尔函数),解决电压不稳定的问题,转化为 01,赋予了电路表达状态的能力;并随后通过逻辑门的组合进行计算。
简要概括 CPU 的作用原理就是 Clock 驱动 Control Unit,从 RAM 中读取指令,调度 ALU 计算结果,操作 Register,并写入 RAM,整个流程称之为一个时钟周期(1Hz)。
通过上面这句话就不难发现 CPU 在物理层面上,至少包含 Clock, Control Unit, ALU, Register。以及在计算时需要一个外部的 RAM,甚至在哈佛架构,需要更多个 RAM。
Clock 就是晶体振荡器;给予电路有节律的脉冲信号,这信号赋予电路改变其状态的能力。
Register 和 RAM 本是一家,只是按照需求被分割 2 地,都是对寄存器和多路复用器的封装,用于将可变的脉冲信号持久化。寄存器来源于锁存器,锁存器能把用户的输入储存下来,而寄存器对他增加了可编辑的能力;多路复用器是为了简化对寄存器矩阵的操作。
Control Unit 是 CPU 的调度器,它加载和写入 RAM 的数据,并调度 ALU 计算结果。
ALU 是 CPU 的计算核心,在我们通过 Control Unit 选择了合适的 ALU 之后,就会把需要处理的 Register,立即数 丢进去处理。
举个例子:
无力拓展,这个视频讲的无比形象,《8、中央处理器 CPU(The Central Processing Unit (CPU))》
算数逻辑单元 -> 逻辑门
通过晶体管控制信号流动,来改变输出。再通过组合实现下面几个基本逻辑开关。
AND,OR(并联), NOT(接地短路), XOR
A XOR B = (NOT A AND B) AND (A OR B)
之后再通过上述 4 个基本逻辑,实现下面的组合逻辑。
例子:加法器
19 + 918 = 0 × 13 + 0 × 396 = 0001 0011 + 0011 1001 0110 = ( 0011 ) ( 0001 + 1001 ) ( 0011 + 0110 ) = 0011 1010 1001 = 0 × 3 A 9 = 937 " role="presentation">19+918=0×13+0×396=0001 0011+0011 1001 0110=(0011)(0001+1001)(0011+0110)=0011 1010 1001=0×3A9=937 19 + 918 = 0 × 13 + 0 × 396 = 0001 0011 + 0011 1001 0110 = ( 0011 ) ( 0001 + 1001 ) ( 0011 + 0110 ) = 0011 1010 1001 = 0 × 3 A 9 = 937
HALF ADDER(XOR + AND 组合)
FULL ADDER(HALF ADDER + OR + HALF ADDER 组合)
8-BIT RIPPLE CARRY ADDER(HALF ADDER + 7 * FULL ADDER 组合)
例子:检查是否为 0?
逻辑门 -> 晶体管
最早的计算机,所有的开关均是使用机械结构,通电控制开关的闭合;直到真空管解决的机械结构的损耗和速率问题;随后二极管出现,推进了计算机小型化和性能的巨大提升。
无论采用什么方案,最原始的机械结构的作用一直没有发生变化。
当控制输入输入高电平,会形成磁场,吸引周围的开关闭合,形成通路。
最后的二极管,也只是改变了开关的材料和原理,具体原理参见 晶体管的原理。
参考
附录
MCU(Microcontroller) VS Microprocessor
MCU 微型控制器,包含 Microprocessor(CPU), ROM, RAM 和 I/O 设备,但是被封装在单个封装中。
What is the difference between microprocessor and microcontroller?