处理器、汇编器和编程语言的工作原理(入门版)






4.94/5 (59投票s)
用简单的语言解释计算机的基本工作原理。
引言
你可能想知道你的电脑是如何工作的:当你编写一个程序然后编译它时会发生什么?什么是汇编器,以及用它进行编程的基本原理是什么?本教程将为你解释这一点,它并不旨在教授你汇编编程本身,而是为你提供理解幕后实际发生情况所需的知识。它还故意简化了一些内容,以免你被额外的信息淹没。但是,我假设你对高级编程(C/C++、Visual Basic、Python、Pascal、Java 等等……)有一定的了解。
我也希望那些更熟练的读者能原谅我在这里的大量简化,我的目的是让那些对此话题一无所知的人能够清晰简单地理解。
注意:我将非常感谢任何反馈。为那些对主题不太了解的人编写解释是很困难的,所以可能遗漏了一些重要的事情,或者没有充分解释清楚,如果有什么不清楚的地方,请不要担心提问。
处理器(CPU)是如何工作的?
你可能知道 CPU(中央处理器,或简称处理器)是计算机的“大脑”,它控制着计算机的所有其他部分,并执行各种数据计算和操作。但它是如何实现的呢?
处理器是一个用于执行单个指令的电路:实际上是一系列指令,一条接一条地执行。要执行的指令存储在内存中,在 PC 中,就是运行内存。将内存想象成一个由单元组成的巨大网格。每个单元可以存储一个小数字,并且每个单元都有自己独特的编号——地址。处理器告知内存单元的地址,内存则响应存储在该单元中的值(数字,但它可以代表任何东西——字母、图形、声音……一切都可以转换为数值)。当然,处理器也可以告诉内存向给定单元存储一个新数字。
指令本身基本上也是数字:每个简单的操作都有自己独特的数字代码。处理器检索这个数字并决定做什么:例如,数字 35 会导致处理器将数据从一个内存单元复制到另一个单元,数字 48 可以告诉它将两个数字相加,而数字 12 可以告诉它执行一个称为 OR 的简单逻辑操作。
哪些操作分配给哪个数字是由设计给定处理器的工程师决定的,或者说更好的说法是处理器架构:他们决定将哪些数字代码分配给各种操作(当然,他们还决定处理器的其他方面,但现在这不重要)。这套规则被称为架构。这样,制造商就可以创建支持给定架构的各种处理器:它们在速度、功耗和价格上可能有所不同,但它们都以相同的方式理解相同的代码作为相同的指令。
一旦处理器完成由代码(指令)决定的操作,它就会简单地请求下一条指令并重复整个过程。有时它还可以决定跳转到内存中的不同位置,例如跳转到某个子程序(函数),或者跳回前面的几行指令并再次执行相同的序列——基本上形成一个循环。构成程序的数字代码序列称为机器码。
什么是指令以及如何使用它们?
如我之前提到的,指令是处理器可以执行的非常简单的任务,每个指令都有其独特的代码。构成处理器的电路设计方式是根据它从内存中加载的代码来执行给定的操作。数字代码通常称为操作码(opcode)。
指令执行的操作通常非常简单。只有通过编写这些简单操作的序列,才能让处理器执行特定的任务。然而,编写数字代码序列非常繁琐(尽管编程很久以前就是这样做的),因此创建了汇编编程语言。它为操作码(数字代码)分配一个符号——一个描述它做什么的名称。
根据之前的例子,数字 35 使处理器将数据从一个内存单元移动到另一个内存单元,我们可以将此指令命名为 `MOV`,它是 MOVe 的缩写。数字 48,即同时将两个数字相加的指令,命名为 `ADD`,而 12,执行 OR 逻辑操作的指令,命名为 `ORL`。
程序员使用这些名称编写一系列指令——处理器可以执行的简单操作,这些名称比纯数字代码更容易阅读。然后他执行一个名为汇编器(assembler)的工具(但“assembler”这个术语也经常用于编程语言,尽管技术上它指的是工具),它会将这些符号转换为处理器可以执行的相应数字代码。
然而,在大多数情况下,指令本身并不足够。例如,如果要将两个数字相加,显然需要指定它们,逻辑操作或将数据从内存单元移动到另一个内存单元也是如此:需要指定源地址和目标地址。这可以通过在指令中添加所谓的“操作数”来实现——简单地一个或多个值(数字),它们为指令执行给定操作所需提供额外信息。操作数也与指令操作码一起存储在内存中。
例如,如果要将地址为 1000 的位置的数据移动到地址为 1258 的位置,可以这样写:
MOV 1258, 1000
第一个数字是目标地址,第二个是源地址(在汇编中,通常先写目标,再写源,这是很常见的)。汇编器(将源转换为机器码的工具)也会存储这些操作数,因此当处理器首次加载指令操作码时,它会知道它必须将数据从一个位置移动到另一个位置。当然,它还需要知道从哪个位置移动到哪个目标,因此它也会从内存中加载操作数值(它们可能位于指令操作码之后的地址),一旦拥有所有必要的数据,它就会执行操作。
小例子
让我们看一些简短的代码并描述它的作用。请注意,这是伪代码,不是为特定架构或语言设计的,各种符号可能不同,但原理保持不变。
MOV A, 2000
LOOP:
ADD A, #5
JNL A, #200, LOOP
MOV 2001, A
第一条指令会将内存地址为 2000 的单元中的数字移动到寄存器 A——这是一个临时位置,处理器在那里存储数字。它可以拥有许多这样的寄存器。第二行包含一个称为“标签”(label)的内容:它不是指令,它只是源代码中的一个标记,我们以后可能会使用它(你将看到如何使用)。
第三行是 `ADD` 指令,它将两个数字相加。操作数是寄存器 A 和数字 5(前面的 # 符号告诉汇编器这是数字五,而不是地址为 5 的内存单元中的数字)。还记得吗?我们将内存位置 2000 的值存储在 A 寄存器中,所以无论该值是多少,此指令都会在其中加上 5。
接下来的指令称为条件跳转(conditional jump):处理器将测试某个条件,并根据结果决定是否跳转。在这种情况下,条件是给定数字是否不大于另一个数字(`JNL` = Jump (if) Not Larger)。正在比较的数字是寄存器 A 中的数字,与数字 200(同样,# 符号表示这是直接数字,而不是地址为 200 的内存位置中的数字)。在这种情况下,A 中的数字小于 200(因此不大于 200——条件为真),处理器将跳转到第三个操作数指定的指令,这就是我们的标签发挥作用的地方:汇编器工具(翻译器)会将“`LOOP`”替换为该标记后的指令的内存地址。
因此,如果数字小于,处理器将跳转回 `ADD` 指令,再次将值 5 加到数字 A 上(它已经比之前的计算结果大),然后返回到 `JNL` 指令。如果数字仍然小于 200,它将再次跳转回;但是,如果它大于 200,则条件将不再为真,因此不会发生跳转,并且将执行下一条指令。该指令将值从寄存器 A 移动到地址为 2001 的内存单元,基本上将结果数字存储在那里。重要的是要注意,地址为 2000 的内存单元仍然包含原始值,因为我们在寄存器 A 中创建了它的副本,并没有修改原始值。
这段代码实际上没有太多目的;它只是用于演示,也用于一些假设的架构。真实世界的程序由数百、数千甚至数十万条指令组成。
架构和汇编语言
我之前已经提到过“架构”这个词:它描述了某些处理器的特性。它描述了处理器可以执行哪些简单的操作(有些只能执行十几个,有些可以执行数百种各种操作),以及每条指令的操作码是什么。它还指定了许多其他内容:它有多少个寄存器(处理器内部的小存储位置,程序员可以在其中临时存储数据),它如何与其他芯片和设备(如内存、芯片组、显卡)通信,以及其功能的其他特性。
这意味着每个处理器都有自己的汇编语言,因为它的指令是不同的。因此,汇编语言(或简单地说汇编,尽管技术上不正确)不仅仅是一种语言,而是一整套语言。它们都很相似,但指令、操作数和其他特定于处理器的功能有所不同。然而,它们的基本原理是相同的(除非它是我的 WPU 实验处理器之一 :-))),所以如果你理解了给定架构的汇编器的原理,学习其他汇编器将是轻而易举的事。
所以重要的是要理解:汇编语言总是用于特定的架构。例如,大多数个人电脑使用称为 x86 的架构,或者在 64 位系统和应用程序的情况下,使用其扩展 x64,因此如果你想为该架构编程,你将使用 x86 汇编语言。许多移动设备使用称为 ARM 的架构,因此如果你用汇编器为这些处理器编程,你将使用 ARM 汇编语言。如果你想为某些旧游戏机(如 Sega Genesis)编程,你将使用 68000 汇编语言,因为它使用 Motorola 68000 处理器,依此类推。有数百种各种架构可用于各种目的。
此外,如我所说,市场上有很多处理器,速度、价格和功耗各不相同,但许多处理器支持相同的架构——因此用给定汇编语言编写的程序将在它们上运行,只是运行速度更快或更慢。
然而,为一种架构创建的程序通常无法在另一种架构上运行,因为处理器根本不同:操作码不同,支持的指令和其他功能也不同,因此 x86 架构的机器码(程序——数字代码集)对 ARM 架构来说将是乱码。这也意味着当你用汇编语言为一种处理器架构编写程序时,它将无法在另一种架构上运行:你需要将其完全重写为目标架构的汇编语言。
高级编程语言和编译器的必要性
导致你很可能已经知道的高级编程语言的出现,有两个紧迫的问题。首先,创建复杂的程序需要将其分解成大量的简单指令,因此要实现更复杂的操作,你需要编写大量的指令:这既繁琐又耗时,更不用说更难理解了。第二个问题已经提到:为一种架构编写的程序在没有完全重写的情况下无法在另一种架构上运行。高级语言解决了这两个问题。
程序的复杂性
让我们想象一下,你想执行一个更复杂的计算,例如,你想计算 A = 2 + ( 7 – 3 ) * 2 的结果。然而,处理器不支持这样的操作,它只能执行非常简单的操作。所以如果你想用汇编编写代码,你需要将这个计算分解成处理器支持的简单操作。对于数学表达式,这和你自己在数学课上手动计算它时一样,例如:首先需要计算括号内的值(7 减去 3),然后将结果乘以 2,最后将其加到数字 2 上。结果将存储在寄存器 A 中。所以汇编代码将看起来像这样(“;”表示注释——不是代码的一部分,只是对它作用的说明)
SUB #3, #7 ; Subtract 7 from 1 and store the result in register A
MUL A, #2 ; Multiply value in register A (containing the result of previous operation)
; by two and again, store the result in register A
ADD #2, A ; Add the result of previous operation (in register A) to the number 2
; and store it in the register A, so A now contains the result
当然,你很少会在计算中使用固定的数字,你更可能是在内存中计算值,所以让我们通过以下更改方程来使整个过程复杂化:@250 = @200 + ( @201 - @202 ) * @203。这里的 @(数字) 表示“在地址”——存储在给定地址内存单元中的数字。要完成计算,我们需要加载内存中的值,因为处理器不允许直接对给定内存地址中的数字进行计算,但它提供了另一个寄存器 B。
MOV B, 202 ; Move the value from memory address 202 to register B
MOV A, 201 ; Move the value from memory address 201 to register A
SUB A, B ; Subtraction (@201 - @202) and store the result in A
MOV B, 203 ; Move the value from memory address 203 to register B
MUL A, B ; Multiply the value from previous calculation with number at the address 203 stored in B
MOV B, 200 ; Move the value from memory address 200 to register B
ADD A, B ; Add value in A (( @201 - @202 ) * @203) to B (@200) and store the result in A
MOV 250, A ; Store the result in the memory cell with address 250
如你所见,即使是简单的表达式也会变得复杂,需要几行代码,这非常繁琐。更不用说从代码中很难理解它实际在做什么,除非你在注释中解释它。但遗憾的是,别无选择,这就是处理器的工作方式。你可以想象,对于更复杂的代码,指令的数量和繁琐程度会迅速增加。
另一个问题是,你处理的值(基本上可以看作是变量)只是数字(地址),处理起来并不容易。你可以为地址指定名称,但这只是稍微推迟了问题:你仍然需要说明地址 204 将被称为 `MYVARIABLE`,如果变量数量增加,它很快就会变得麻烦,尽管分配确切地址通常是自动化的。更不用说你可能只在短时间内使用某个地址作为特定变量,然后将其重新用作另一个变量,但你还必须确保两者(甚至更多)的使用不会冲突。
编译器在此介入
好的,那么问题来了:如果你可以将表达式和任务分解成一系列简单的指令,并且如果你可以为变量分配内存位置,为什么不能由程序来完成呢?而这正是编译器所做的。编程语言指定你可以编写什么类型的语句以及如何编写,编译器必须支持它们。所以你可以简单地编写以下代码(这是 C 语言风格的代码)
int a, b, c, d, e;
a = 2;
b = 7;
c = 3;
d = 2;
e = a + (b - c) * d;
当你编译这段代码时,编译器会分析(解析)这段代码,并发现你想要五个变量。你不需要决定为这些变量分配哪个内存单元:这一切都由编译器为你处理。例如,编译器可能会决定变量“`a`”的内容将存储在地址为 200 的内存单元中,“`b`”存储在 201 中,依此类推。编译器会跟踪这些分配,因此无论你在哪里使用给定的变量,它都会确保使用正确的内存地址。实际上,这个过程通常比这更复杂一些,但原理是相同的。
在示例代码中,有几个赋值语句,以“`a = 2;`”开头。编译器会读取这个,根据编程语言的规则,这意味着变量被赋值为 2。编译器知道名为“`a`”的变量对应的内存地址,因此它会为你生成正确的指令:记住,处理器不理解“a = 2;”这样的表达式,它只能处理简单的指令。但将这些高级语句转换为处理器理解的指令是编译器的任务。
MOV 200, #2 ; Move number 2 to the memory location 500, which corresponds with variable “a”
MOV 201, #7 ; b = 7
MOV 202, #3 ; c = 3
MOV 203, #2 ; d = 2
这希望足够简单;每个赋值对应一条处理器指令(我已将其写成汇编语言,编译器当然会为每条指令生成相应的数字代码——机器码)。然而,当涉及到最后一条语句,将表达式“a + (b - c) * d”的结果赋值给变量“`e`”时,正如你之前所见,这无法通过一条指令完成。但是,你所需要做的就是编写这个表达式,编译器会读取它并自行将其分解为一系列简单的指令,而你甚至不知道(至少直到现在 :-))。例如,它可能会生成以下指令:
MOV B, 202 ; Move the value from memory address 202 (variable “c”) to register B
MOV A, 201 ; Move the value from memory address 201 (variable “b”) to register A
SUB A, B ; Subtraction (b - c) and store the result in A
MOV B, 203 ; Move the value from memory address 203 (variable “d) to register B
MUL A, B ; Multiply the value from previous calculation with value of variable “d” stored in B
MOV B, 200 ; Move the value from memory address 200 (variable “a”) to register B
ADD A, B ; Add value in A (( b - c ) * d) to B (a) and store the result in A
MOV 250, A ; Store the result in the memory cell with address 250 (variable “e”)
我认为不需要解释,简单地写“e = a + (b - c) * d”比编写一系列指令容易得多,并且相同的原理适用于所有内容。高级编程语言允许你以更清晰、更简单、更易于理解的方式表达要执行的操作,编译器将负责将其转换为处理器理解的一系列简单指令,并为你处理所有其他细节。这被称为抽象,解决了程序复杂性问题:你可以编写和管理更复杂的程序,因为你不必处理所有细节:它们会自动为你处理。
提及一些基本编程构造的处理方式可能很重要。例如,“`if`”语句。让我们考虑以下 C 代码:
if(a > 2)
b = 3;
else
{
b = 5;
c = 8;
}
a = 8;
处理器不理解“`if`”语句是什么,但它有一个条件跳转指令:如果条件为真,它将跳转到另一条指令。因此,这段代码将被翻译成以下汇编代码:
MOV A, 200 ; Move the value of variable “a” (address 200) to register A
LGR #2 ; If value in register A is larger than number 2, store number 1
; in the register A, if not, store number 0 in register A
JZ ELSE ; If value in A (result of the comparation) is zero,
; jump at the instruction after the “ELSE:” label,
; instead execute the following instruction
MOV 201, #3 ; Move number 3 to memory location 201 (variable “b”), b = 3
JMP END ; Skip to the “END:” label so the instructions of the else clause are not executed
ELSE:
MOV 201, #5 ; b = 5;
MOV 202, #8 ; c = 8;
END:
MOV 201, #8 ; a = 8
循环也是如此,除了跳转指令会导致在序列中向后跳转,从而重复一部分代码。在高级语言编程时,你不必担心循环是如何用跳转指令精确构建的,处理器实际上可能有几种指令,每种都有不同的条件:编译器会为你处理这种安排,并选择适当的跳转指令并执行额外的工作,以便执行条件跳转:在这个例子中,我们假设处理器没有一个条件指令可以比较哪个数字更大:相反,它有一个单独的指令来测试这个,所以编译器首先使用指令比较两个数字,然后使用结果配合适当的条件跳转指令。
如今汇编的必要性
你可能会想:如果编译器可以为你处理所有这些事情,那么今天了解汇编有什么意义?有几个答案:首先,编译器不一定总是生成最优指令:有些操作可以用更少的指令以一种编译器不知道的非标准方式完成。当你需要榨取每一丝性能时,这是一个问题;在这种情况下,你可以自己编写性能关键部分的指令,确保它完全按照你想要的方式工作。
这对于为资源有限的小型嵌入式设备编程来说可能是一个更大的问题,在这种情况下,你根本无法承受任何开销(比实际需要的指令多,以及解决问题的次优方法)。另一个原因是编译器的局限性:你仅限于它支持的内容,如果你想使用编译器无法生成指令的处理器的一些功能,例如新的特殊指令,你将需要自己编写它们。
如果你想分析现有软件、破解(改变其行为)或逆向工程,了解汇编是绝对必须的。正如你已经知道的,程序由一系列简单的指令组成——代表各种操作的数字代码。当你没有源代码时,反编译现有程序很容易:数字代码只需替换为它们相应的名称,从而生成汇编语言代码,因此如果你想分析和修改它们,你必须了解汇编语言。
反编译程序——将其转换回高级源代码——要困难得多:这需要对指令及其结构进行广泛的分析,并且生成的源代码仍然与原始代码相差甚远:变量、函数和注释等重要内容在编译过程中会丢失(并非所有语言都如此),因为处理器并不需要它们:它只需要内存地址,而内存地址只是一个数字。
可移植性
汇编编程的第二个主要问题是可移植性:要将你的程序移植到具有不同处理器架构的另一个平台,你需要完全用目标平台的汇编语言重写它。使用高级语言可以很容易地解决这个问题。高级语言中的代码通常是平台无关的:生成适当指令的是编译器,而不是你。
因此,如果你想让你的代码在 x86 架构的 PC 上运行,你将源代码交给一个为 x86 生成指令的编译器。如果你想为 ARM 架构的移动设备创建二进制文件(机器码),你将相同的源代码交给 ARM 编译器,它将为该架构生成指令,而你无需做任何事情。
这当然需要有针对给定架构的编译器;如果某个特定架构没有编译器,那么你将只能进行汇编编程,除非你自己编写编译器。
解释型语言
到目前为止,我们只讨论了所谓的“原生代码”:生成原生代码的语言会产生直接由给定处理器执行的指令。如果你创建一个二进制文件(包含给定处理器的原始指令),它只会在给定架构上运行;如果你想在另一个架构上使用它,你需要为该架构编译它:生成给定架构处理器能理解的相应机器码。
然而,存在一种称为“解释型语言”的东西,它使可移植性更加容易。我只简要提及这一点,因为这个主题可以扩展成一篇很长的文章。使用解释型编程语言,源代码保持不变,或者被编译成“通用”汇编代码(Java 就是这样——生成的通用汇编代码称为字节码)。如果你想运行这样的程序,你需要一个解释器:它是一个原生代码程序——处理器直接理解的东西,它可以读取这种通用代码并即时将其翻译成目标架构的指令——在程序运行时。
这种方法的优点是易于移植、安全和灵活:你可以编写一次程序,然后它可以在解释器可用的任何架构上运行,而无需更改程序的任何内容。因为解释器控制着程序可以做什么,所以安全性也得到了提高,因为它可以选择阻止某些操作,而这对于原生代码来说非常困难,甚至不可能。你也可以快速测试和修改你的程序,而无需每次都重新编译。
主要缺点之一是速度降低:例如,对于“a = 5”这样的赋值,解释型语言可能需要处理器执行几十条指令,这些指令会读取该语句,决定其含义,然后最终执行它,而对于编译型语言(生成原生代码),一条指令通常可以处理这个任务。
结论
如果你读到这里:恭喜!我希望我已经帮助揭示了处理器工作原理的一些秘密,以及它与汇编(低级)和高级编程的关系。虽然这并没有教你如何用汇编编程以及如何破解/分析现有程序,但它有望为你开始学习这些东西提供必要的知识,并知道会遇到什么。
请注意,为了便于理解,许多描述的内容都被简化了。这里涵盖的许多主题足以写几本书,而我目前并不打算写任何一本,至少不是关于这些主题的 :-)
如果你能对这篇文章表示赞赏并提供任何反馈,我将不胜感激,无论是关于文章的可理解性还是我方面的任何错误(主要是语法和拼写,请忽略事实上的刻意简化)。
感谢阅读。