热身问答
- 什么是机器语言?
- 由二进制数字组成的, CPU可以执行的语言, 也叫做原生代码。
- 原生代码: Native Code
- 通常把标识内存或I/O中存储单元的数字称做什么?
- 标识内存或 I/O 中存储单元的数字叫作“地址”。
- 内存中有多个数据存储单元。计算机用从 0 开始的编号标识每个存储单元,这些编号就是地址(Address)。I/O 中的寄存器也可以用地址来标识。哪个寄存器对应哪个地址,取决于 CPU 和 I/O 之间的布线方式。
- CPU中的标志寄存器(Flags Register)有什么用?
- 用于在运算指令执行后,存储运算结果的某些状态。
- Flag 的本意是“旗子”,这里引申为“标志”。一旦执行了算术运算、逻辑运算、比较运算等指令后,标志寄
存器并不会存放运算结果的值,而是会把运算后的某些状态存储起来,例如运算结果是否为 0、是否产生了负数、是否有溢出(Overflow)等。
3.1 从程序员的角度看硬件
首先, 从下面七个方面了解硬件信息:
CPU(处理器)信息
- CPU 的种类
- 可以使用哪种机器语言取决于CPU的种类, 虽然机器语言只是0和1书写的编程语言, 但是不同的CPU种类有不同的解释方法。 例如 01010011, 有的CPU会把它解释成是执行加法运算, 有的会把它解释成向I/O输出。
- 时钟信号的频率
- 是由时钟发生器发送给CPU的电信号的频率。 单位是MHz(兆赫兹 = 100万回/秒)。
- 微型计算机使用的是 2.5MHz 的时钟信号。时钟信号是在 0 和 1 两个数之间反复变换的电信号,就像滴答滴答左右摆动的钟摆一样。通常把发出一次滴答的时间称作一个时钟周期。
内存信息
- 地址空间
- 每个地址都标示着一个内存中的数据存储单元, 而内存的鹅地址空间就是这些地址所构成的范围。
- 每个地址中可以存储多少比特的信息
- 在我们的微型计算机中,地址空间为0~255,每一个地址中可以存储 8 比特(1 字节)的指令或数据。
I/O 信息
- I/O 的种类
- 指连接着微型计算机和外部设备的I/O的种类。
- 在微型计算机中,只安装了一个 I/O,即上面带有 4 个 8 比特寄存器的 Z80 PIO。只要用 CPU 控制 I/O 的寄存器,就可以设定 I/O 的功能,与周边设备进行数据的输入输出。
- 地址空间
- 用于指定I/O寄存器的地址范围。
- 在Z80 PIO 上,地址空间为 0~3,每一个地址对应一个寄存器。
- 连接着何种周边设备
- 指拨开关和LED
地址的功能在内存和I/O中有什么不一样?
-
在内存中,每个地址的功能都一样,既可用于存储指令又可用于存储数据。
-
而 I/O 则不同,地址编号不同(即寄存器的类型不同),功能也就不同。在微型计算机中,是这样分配 Z80 PIO 上的寄存器的:
- 端口 A 数据寄存器对应 0 号地址
- 端口 B 数据寄存器对应 1 号地址
- 端口 A 控制寄存器对应 2 号地址
- 端口 B 控制寄存器对应 3 号地址
- 端口 A 数据寄存器和端口 B 数据寄存器存储的是与周边设备进行输入输出时所需的数据。其中,端口 A 连接用于输入数据的指拨开关,端口B 连接用于输出数据的 LED。
- 而端口 A 控制寄存器和端口 B 控制寄存器则存储的是用于设定 Z80 PIO 功能的参数。
3.2 机器语言和汇编语言
回想我们在第二章结尾提到的机器语言程序, 目的是将由指拨开关输入的数据输入CPU, 然后CPU把这些数据输出到LED。 也就是说我们可以通过指拨开关控制LED的亮与灭。
这段由8比特二进制数构成的程序一共 23字节(23行), 若把这些字节一个接一个依次写入内存中, 那么所占据的内存空间就是 00000000 ~ 00010110. (因为二进制10110就等于十进制22).一旦重置了 CPU,CPU 就会从 0 号地址开始顺序执行这段程序。
机器语言中, 每个由0和1组成的组合都是有特定含义的指令或者数据, 所以这段代码对于机器来说很方便, 但是人很难记住各个组合的含义。
因此, 我们给这些组合赋予我们能理解的语言昵称, 这些“昵称”叫做“助记符”, 而使用助记符的编程语言叫做“汇编语言”。
若将上面的机器语言程序用汇编语言来表示:
- 标签:给该行代码对应的内存地址命名。 如本处使用了名为“LOOP”的标签。
- 操作码:表示要做什么的指令。 这里一般是英文单词的缩写, 比如此处的LD就是Load(加载)的缩写。
- 汇编语言中提供了多少种助记符,CPU 就有多少种功能。Z80 CPU 的指令全部加起来有 70 条左右, 如下图:
- 注意, 按照功能划分这些指令可以分为 运算、与内存的输入输出和与I/O的输入输出三类。
- 操作数:表示指令执行的对象。 CPU 的寄存器、内存地址、I/O地址或者直接给出的数字都可以作为操作数。如果某条指令需要多个操作数,那么它们之间就要用逗号分割。操作数的个数取决于指令的种类。也有不需要操作数的指令,比如用于停止 CPU 运转的 HALT指令。
而标签、操作码和操作数结合起来可以理解为“把什么动作作用到什么上”。
注意:
-
构成机器语言的是二进制数,而在汇编语言中,则使用十进制数和十六进制数记录数据。若仅仅写出 123 这样的数字,表示的就是十进制数;而像 123H 这样在数字末尾加上了一个 H(H 表示 Hexadecimal,即十六进制数),表示的就是十六进制数。
-
在第二章中我们说过, Z80 CPU 的 MREQ 引脚和 IORQ 引脚实现了一种能区分输入输出对象的机制,可以区分出使用着相同内存地址的内存和 I/O。而在汇编语言中,读写内存的指令不同于读写 I/O 的指令。
- 一旦执行了读写内存的指令,比如 LD 指令,MREQ 引脚上的值就会变为 0,于是内存被选为输入输出的对象;
- 而一旦执行了读写 I/O 的指令,比如 IN 或 OUT 指令,IORQ 引脚上的值就会变为 0,于是 I/O(这里用的是 Z80 PIO)被选为输入输出的对象。
3.3 Z80 CPU的寄存器结构
计算机的硬件有三个基本要素,CPU、内存和 I/O。CPU 负责解释、执行程序,从内存或 I/O 输入数据,在内部进行运算,再把运算结果输出到内存或 I/O。内存中存放着程序,程序是指令和数据的集合。I/O 中临时存放着用于与周边设备进行输入输出的数据。
既然数据的运算是在CPU内部进行的, 那么CPU内部应该也有存储数据的地方!这个地方就是“寄存器”。 寄存器不仅拥有存储数据的功能, 还可以对数据进行运算。 CPU 带有什么样的寄存器取决于 CPU 的种类。Z80 CPU 所带有的寄存器如图 3.2 所示A。A、B、C、D 等字母是寄存器的名字。在汇编语言当中,可以将寄存器的名字指定为操作数。
IX、IY、SP、PC这四个寄存器的大小是16比特, 其余寄存器的大小是8比特。 寄存器的用途取决于它的类型。
- A寄存器叫做“累加器”, 是运算的核心。 所以连接到它上面的导线也一定会比其他寄存器的多。 [Accumulator register]
- F寄存器叫做“标志寄存器”, 用于存储运算结果的状态, 比如是否发生了进位, 数字大小的比较结果等。 [Flag register]
- PC寄存器叫做“程序指针”/“程序计数器”, 存储着指向CPU下一条要执行的指令的地址。 [Program Counter register]
- PC 寄存器的值会随着滴答滴答的时钟信号自动更新,可以说程序就是依靠不断变化的 PC 寄存器的值运行起来的。
- SP寄存器叫作“栈顶指针”,用于在内存中创建出一块称为“栈”的临时数据存储区域。[Stack Pointer register]
熟悉了寄存器的功能后, 我们开始了解代码清单3.2的内容:
从操作码可以看出, 这段程序大体上可分为两部分: “设定Z80 PIO”和“与Z80 PIO进行输入输出。 我们已经知道Z80 PIO有A、B两个端口用于与周边设备输入输出数据。 所以我们首先需要为每个端口设定输入输出模式。这里端口 A 用于接收由指拨开关输入的数据,为了实现这个功能,需要如下的代码。
LD A, 207
OUT (2), A
LD A, 255
OUT (2), A
这里的 207 和 255 是连续向 Z80 PIO 的端口 A 控制寄存器(对应该 I/O 的地址编号为 2)写入的两个数据。虽然使用 OUT 指令可以向I/O 写入数据,但是不能直接把 207、255 这样的数字作为 OUT 指令的操作数。操作数必须是已存储在 CPU 寄存器中的数字,这是汇编语言的规定。 所以我们需要先LD再OUT。
一旦把207写入到端口A控制寄存器, Z80 PIO就明白我们想要设定端口A的输入输出模式, 然后我们选择了 OUT输出模式, 所以Z80 PIO就知道要把端口A设定为输入模式了。
将端口B设定为输出模式:
LD A, 207
OUT (3), A
LD A, 0
OUT (3), A
先把 207 写入到端口 B 控制寄存器(对应的 I/O 地址为 3 号),然后写入 0。这个 0 表示要把端口 B 设定为输出模式。应该使用什么样的数字设定端口,在 Z80 PIO 的资料上都有说明。用 207、255、0 这样的数字来表示功能设定参数,这也是为了适应计算机的处理方式。
完成了 Z80 PIO 的设定后,就进入了一段死循环处理,用于把由指拨开关输入的数据输出到 LED。为了实现这个功能,需要如下的代码。
LOOP: IN A, (0)
OUT (1), A
JP LOOP
-
IN A, (0): 目的是把数据由端口A数据寄存器输入到CPU的寄存器A。
- 端口 A 数据寄存器(连接在指拨开关上,对应的 I/O 地址为 0 号)
-
OUT (1), A: 目的是把寄存器A的值输出到端口B数据寄存器上。
- 端口 B 数据寄存器上(连接在 LED上,对应的 I/O 地址为 1 号)。
-
JP LOOP: 目的是使程序的流程跳转到LOOP标签所标识的指令上。 实现跳转功能。 JP是Jump的缩写。
“IN A, (0)”所在行的开头有一个标签“LOOP:”,代表着这一行的内存地址。正如刚才所讲的那样,在用汇编语言编程时,如果老想着“这一行对应的内存地址是什么来着?”就会很不方便,所以就要用“LOOP:”这样的标签代替内存地址。当把标签作为 JP 指令的操作数时,标签名的结尾不需要冒号“:”,但是在设定标签时,标签名的结尾则需要加上一个冒号。
3.4 追踪程序的运行过程
上图代码清单3.3展示了机器语言和汇编语言的对应关系。 从图上我们可以知道, 有的指令对应着一字节的机器语言, 而有的指令则对应着多个字节的机器语言。 所以, 汇编语言中的一条指令能转换成多少条机器语言取决于指令的种类以及操作数的个数。
且注意, 此图中第一个内存地址是 00000000(0 号地址),下一个地址是 00000010(2 号地址),中间隔了 2 个地址,这说明如果从 0 号地址开始存储一条 2 字节的机器语言,那么下一条机器语言就从 2 号地址开始存储。
下面我们来看看CPU是如何解释、执行机器语言程序的吧。
- 重置CPU后, 顺序执行指令。
- 一旦重置了 CPU,00000000 就会被自动存储到 PC 寄存器中,这意味着接下来 CPU 将要从 00000000 号地址读出程序。首先 CPU 会从00000000 号地址读出指令 00111110,判断出这是一条由 2 个字节构成的指令,于是接下来会从下一个地址(即 00000001,1 号地址,代码清单 3.3 中并没有标记出该地址本身)读出数据 11001111,把这两个数据汇集到一起解释、执行。
- 执行的指令是把数值 207 写入到寄存器 A,用汇编语言表示的话就是“LD A, 207”。这时,由于刚刚从内存读出了一条 2 字节的指令(占用 2 个内存地址),所以 PC 寄存器的值要增加2,并接着从 00000010 号地址读出指令,解释并执行。
- 因为PC寄存器存储了CPU要执行的吓一跳指令的内存地址。
- 反复进行“读取指令”、“解释、执行指令”、“更新PC寄存器的值”这三个操作, 使程序运行。
- 一旦执行完最后一行的 JP LOOP 所对应的机器语言,PC 寄存器的值就会被设为标签 LOOP 对应的地址 00010000,这样就可以循环执行同样的操作。
3.5 尝试手工汇编
我们知道CPU只可以直接执行机器语言, 而不能执行方便我们理解的汇编语言, 但是在CPU 的资料中,明确写有所有可以使用的助记符,以及助记符转换成机器语言后的数值。所以只要查看这些资料,就可以把用汇编语言编写的程序手工转换成机器语言的程序,这样的工作称为“手工汇编”。
下面我们开始转换:
LD A, 207
OUT(2), A
LD A, 255
OUT(2), A
LD A, 207
OUT(3), A
LD A,0
OUT(3), A
LOOP: IN A, (0)
OUT(1), A
JP LOOP
-
第一行匹配LD A,num这个模式, 所以我们先转换为 00111110num。 然后将十进制数207转换为8比特的二进制数“11001111”.(在计算器中进行转换)。 于是LD A, 207 就转换成了机器语言00111110 11001111
-
-
因为这条指令存储在内存最开始的部分, 也就是00000000号地址, 所以要把这条指令和内存地址像
下面这样并排写下来。地址 汇编语言 机器语言 00000000 LD A, 207 00111110 11001111
-
-
第二条指令匹配OUT(num), A这个模式, 所以先转换为11010011, 然后把十进制2转换为00000010, 最后得到机器语言“11010011 00000010”. 内存地址加二。
-
地址 汇编语言 机器语言 00000010 OUT (2), A 11010011 00000010
-
-
然后第三行到第八行中, LD指令和OUT指令以相同的模式出现了3次, 所以我们以相同的步骤转换为:
-
地址 汇编语言 机器语言 00000100 LD A, 255 11010011 00000010 00000110 OUT(2), A 11010011 00000010 00001000 LD A, 207 11010011 11001111 00001010 OUT(3), A 11010011 00000011 00001100 LD A, 0 11010011 00000000 00001110 OUT(3), A 11010011 00000011
-
-
接下来第九行我们将其匹配IN A, (num)这个模式,也就是先转换为了 11011011num, 然后将0转换为00000000, 最终得到机器语言:11011011 00000000. 第十行和前面一样。
-
地址 汇编语言 机器语言 00010000 IN A, (0) 11010011 00000010 00010010 OUT(1), A 11010011 00000001
-
-
最后一句 JP LOOP匹配模式 “JP num”, 先转换为“11000011num”。 注意这里要用 16 比特的二进制数替代作为内存地址的 num。在微型计算机中是以 8 比特为单位指定内存地址的,但在Z80 CPU 中用于设定内存地址的引脚却有 16 个,所以在机器语言中也要用 16 比特的二进制数设定内存地址。
JP 指令跳转的目的地为00010000,即“LOOP:”标签所标示的第九行的语句“LD A, 0”对应的内存地址。把这个地址扩充为 16 比特就是“00000000 00010000”。要扩充到16 位,只需要把高 8 位全部设为 0 就可以了。
-
地址 汇编语言 机器语言 00010100 JP LOOP 11000011 00010000 00000000
-
注意:
在将一个 2 字节的数据存储到内存时,存储顺序是低 8 位在前、高 8 位在后(也就是逆序存储)。这样的存储
顺序叫作“小端序”(Little Endian),与此相反,将数据由高位到低位顺序地存储到内存的存储顺序则叫作“大端序”(Big Endian)。根据CPU 种类的不同,有的 CPU 使用大端序,有的 CPU 使用小端序。Z80 CPU 使用的是小端序,因此 JP LOOP 对应的机器语言为“11000011 00010000 00000000”。
3.6 尝试估算程序的执行时间
我们从表3.2可以看到四种指令的机器语言的时钟周期数, 那么下面我们来估算一下本次程序的执行时间。
- 将每条指令的时钟周期数累加起来。
- 前8条指令(4次LD和OUT指令的执行)共需要 4 * (7 + 11) = 72个时钟周期。
- LOOP标签后的最后三条指令共需要11+11+10=32个时钟周期。
- 因为微型计算机采用的是 2.5MHz 的晶振,也就是 1 秒可以产生 250 万个时钟周期,所以每个时钟周期是 1 秒 ÷250万 = 0.0000004 秒 = 0.4 微秒。72 个时钟周期就是 72×0.4 = 28.8 微秒。32 个时钟周期就是12.8 微秒。 这段程序是用 LED 的亮或灭来表示指拨开关的开关状态,所以 LOOP 标签之后所执行的操作“输入、输出、跳转”每 1 秒可以反复执行 1 秒 ÷12.8 微秒 / 次 = 78125 次之多。
总结
本章的重点话题是汇编语言。
-
汇编语言不能被CPU直接执行, 而是帮助我们理解机器语言的语言。
-
汇编语言转换到机器语言。