操作寄存器和读写内存
寄存器基础知识
CPU和计算器的其它部件通过外部总线(包括地址总线、控制总线、数据总线)相连。CPU内部由运算器、控制器、寄存器等部件组成,这些CPU内部的部件之间使用内部总线相连:
- 运算器处理数据
- 寄存器存储数据
- 控制器控制各种部件
- 内部总线连接各部件
程序员可直接操作的是寄存器。不同CPU的寄存器个数、结构不同,8086 CPU的寄存器是16位,每个寄存器可存放2字节数据。
8086 CPU总共有14个寄存器:AX BX CX DX SI DI SP BP IP CS SS DS ES PSW
。其中AX BX CX DX
是通用寄存器,可由程序员决定在通用寄存器中存放哪些数据,其它寄存器都有特殊意义,修改特殊寄存器可能会影响CPU的工作流程。
例如,AX寄存器的逻辑结构:
8086 CPU的上一代寄存器都是8位,为兼容原来基于上代寄存器编写的程序能直接在8086上运行,8086CPU的AX BX CX DX
这四个寄存器可分为为两个可独立的8位寄存器来使用:
- AX可分为AH和AL(H表示AX中的高位8bit,L表示低位的8bit)
- BX可分为BH和BL
- CX可分为CH和CL
- DX可分为DH和DL
例如,AX寄存器中存储十进制数值20000(对应的二进制为:0100 1110 0010 0000):
如果存储了20000的AX寄存器看作是AH和AL两个独立的寄存器,那么AH中的二进制位0100 1110表示十进制数值78,AL存储的二进制位0010 0000表示十进制数值32。
关于机器的位数和字长(word)这个单位
数据总线宽度决定一次性最多能传输多少数据。如果CPU有16根数据总线,那么它一次性可以传输2个字节数据,如果有32根数据总线,则一次性可传输4字节,64根数据总线,则一次性可传输8字节。
以数据总线一次性传输的数据量为传输单位,这个单位称为字(word),能传输的数据量称为字长。所以,数据总线的宽度决定了字长。
内存的操作单位是字节,为什么要用字长做CPU的数据单位?主要是为了CPU在和外界传输信息时不浪费任何一根数据总线。
既然CPU和外界部件通信时的数据单位是字长,那么CPU的寄存器大小也应当是一个字长大小,这样才能直接容下一次数据传输事务中涉及的数据量。
此外,为了能尽可能对内存地址空间进行大范围寻址,或者说为了识别更大的内存,地址总线的宽度通常也会设计为字长大小,即地址总线宽度和数据总线宽度一样大。比如数据总线宽度32,字长是4字节,地址总线最多能携带4字节的地址信息,能寻址的内存范围为0-(2^32-1)个内存单元,即只识别4G内存。
编程语言中的指针存储的就是内存地址,所以通常来说一个指针的大小就是一个字长大小。
这样的话,关于多少位的说法就统一了:64位机器,数据总线64根,地址总线64根,寄存器大小8字节,指针8字节。现代64位机器基本都这样。但是以前的机器就不一定遵守这样的规律了。
物理寻址的方式
以前有些CPU数据总线宽度和地址总线宽度并不一致。
比如8086 CPU是16位的,它的数据总线是16根,寄存器大小2字节,但是地址总线是20根。这样的话,8086的可寻址范围就比16根地址总线大一些。如果是16根地址总线,最多可寻址64KB大小的内存,而20根地址总线,可寻址1MB大小的内存。只是要注意,寄存器大小只有16位,无法存放20位的地址信息,所以要描述20位的地址信息,需要使用两个16位地址合成一个20位大地址,即需要两个寄存器的参与。
实际上,对物理内存寻址的方式是物理地址=段地址 * 16 + 偏移地址
。CPU将段寄存器中的段地址和偏移地址寄存器中的偏移地址取出来,通过地址加法器按照上面的公式计算出真实的物理内存地址。
比如你距离图书馆2826米,有人问你图书馆在哪里,你可以告诉它在前方2826米处。但加上限制条件,只给两张纸条,且每张纸条最多只能写三位数,这时两张纸条可以分别写200和826,如果两者此前已经协商好计算方式200 * 10 + 826
,经过运算就得到了4位数的地址。
段地址 * 16
中乘16而不是乘其它数值也是有原因的,因为给出的地址来自于16位的寄存器,是16位的,乘16的效果是直接补一个0,相当于直接16进制左移一位,即进一位,相当于十进制的乘10。
例如,要寻址5位3字节的123C8(Hex)
,超出了2字节大小,它等价于12300 + 00C8
,所以给出段地址1230,再给出基于该段的偏移00C8,即可寻址到123C8。
段
的概念并不是真的对内存分了段,而是CPU为了描述地址而使用的一个逻辑概念。一个逻辑地址段代表一个逻辑地址范围,在这个范围内,以段首为参照进行偏移,可描述出这个段中的所有地址。所以,只要给出段首地址,再给出该段的偏移地址,就可以计算出实际的物理地址。
因为偏移地址也是16位的(假设寄存器的大小是2字节),它能描述的最大偏移值为65535,即偏移的变化范围是0-FFFF(Hex)
。换句话说,每个段最大的范围是64KB。
CPU可以使用不同的段地址和偏移地址形成同一个物理地址。例如:
1 | 物理地址(Hex) 段地址(Hex) 偏移地址(Hex) |
因为段地址*16
是16的倍数,段的范围是64KB,也是16的倍数,所以每个段的段首地址是16的倍数,即段地址用16进制描述时必须是0结尾的。
所以,可根据需要,将起始地址为16的倍数的地址,且地址连续的一组内存单元定义为一个段。
读取指令并执行的过程
CPU计算物理地址的方式为物理地址 = 段地址 * 16 + 偏移地址
。段地址来自于段地址寄存器,偏移地址来自于偏移地址寄存器。
指令也存放在内存中,所以CPU要执行指令需要先寻址并从内存中读取到寄存器中。对指令寻址时,指令的段地址保存在CS寄存器中,指令的偏移地址保存在IP中。所以在任意时刻,这两个寄存器中的值CS:IP都可以描述CPU将要执行的指令。
例如,CPU当前的状态:
要执行第一条指令mov ax,0123(Hex)
,CPU将通过地址加法器根据公式2000(Hex) * 16(Dec) + 0(Hex)
运算得到地址20000,于是将地址20000送入输入输出电路并通过地址总线发送出去,然后从内存中读取3个字节(B8 23 01,即mov ax,0123(Hex)
)放入数据总线传输到输入输出电路,并将其缓存在指令缓冲器中。于此同时,IP寄存器的偏移值随之增加3字节。所以得到如下状态:
最后,执行控制器从指令缓冲器中取得指令并执行。执行完成后,AX寄存器中将存放数据0123(Hex)。
所以,CPU执行指令的工作过程为:根据CS:IP计算将要执行的指令所在的地址,读取指令后将其放入指令缓冲器,同时更新IP寄存器的值,最后执行指令。并重复上述过程。
之后继续按照这个逻辑取指并执行。比如接下来,计算得到20003地址放入地址总线,并读取3字节的指令通过数据总线存入指令缓冲器等待执行,此时IP寄存器的偏移值变成6,指令执行后,BX寄存器的值为0003(Hex)。再随后读取2字节指令mov ax,bx
,读取完成后IP寄存器的值为5,执行完成后AX寄存器将保存BX寄存器中的值0003(Hex),最后读取2字节指令add ax,bx
,读取完成后IP寄存器的值为7,执行后完成后AX寄存器的值为0006(Hex)。
修改CS、IP寄存器的值
mov
指令可以修改通用寄存器,但是无法修改CS、IP寄存器的值。要修改CS和IP,需使用jmp
指令:
jmp 段地址:偏移地址
:同时修改CS和IP寄存器的值jmp 通用寄存器(ax/bx...)
:只修改IP寄存器的值
例如:
1 | jmp 2AE3:3 # 执行后,CS=2AE3(Hex),IP=0003(Hex) |
内存中字的存储:大端和小端
在一个16位的CPU寄存器中,字长为2字节。寄存器在存放一个字长数据时,高8位存放高位字节,低8位存放低位字节。
但是在内存中,内存被划分成存储单元,每个存储单元只能存放1字节数据。所以,存放一个字需要2个存储单元。在存放时,有两种存放方式:
- 小端(little endian):字的高位字节–>高位地址单元,字的低位字节–>低位地址单元中(小端高对高)
- 大端(big endian):字的高位字节–>低位地址单元,字的低位字节–>高位地址单元中
比如,16位寄存器中存放一个16进制为0x0123的2字节int数据,该值在寄存器中的存放方式为:
1 | 高位字节 低位字节 |
如果要将这2个字节存放到内存,假设从内存单元0开始存放,那么按照这两种存放方式存储时,内存中的存放结果为:
1 | # 内存编址方式 |
人在阅读或书写一个二进制或十六进制数字时,总是高位在左边,低位在右边,这符合寄存器中的高低位顺序,但是内存编址方式(或数组索引)是低位在前,高位在后,两者相反的顺序使得高低位交叉的大端存储法更适合人类阅读,而小端则『反人类』。
采用哪种存储方式并不影响计算机的运行速度,只是影响人的理解,也影响跨主机数据通信。因为不同CPU在将寄存器数据存放到内存(或从内存读取到寄存器)时可能采用不同的方式,所以如果数据跨主机后将可能会出现反序现象。
为了保证跨主机网络通信不会出现数据反序问题,TCP/IP规定了网络字节序为大端方式,即数据在经过网络传输出去时和接收进来时必须采用大端方式。
读写内存单元
CPU要读写内存单元,需要先确定该内存单元的地址。寻址方式仍然是物理地址=段地址*16 + 偏移地址
。
8086CPU中,对数据寻址时的段地址保存在DS寄存器中,偏移地址通过索引方式[N]
来指定。
注意,前面曾使用过的CS寄存器和IP寄存器是用来对指令进行寻址的。此处的DS寄存器是用来对数据进行寻址的,是准备读写内存单元的。
例如,从10000H(即1000:0)处读取数据保存到al中,并将cx的数据写入内存单元10003H处:
1 | mov bx, 1000H # 将段地址保存在中间寄存器bx |
上面的一系列mov指令说明,可以将数据送入寄存器,将寄存器A数据送入寄存器B,将内存单元的数据送入寄存器以及将寄存器数据写入内存。
为什么不直接将数据1000H送入ds段地址寄存器?这是CPU是否支持决定的,8086 CPU不支持这种操作。
所以,对于8086CPU,如果想要读写内存单元,总会有如下两个指令来设置ds寄存器中的段地址:
1 | mov bx, 1000H |
问题(1):
1 | # 内存单元数据 |
问题(2):
1 | # 内存单元数据 |
CPU的栈
现在的CPU都提供了栈,8086CPU也提供了栈,这意味着在基于8086CPU编程的时候,可以将一段内存当作栈来使用(栈也在内存,只不过可以用特殊方式访问这部分内存单元)。
8086CPU提供了push
入栈指令和pop
出栈指令,它们都以字为单位。
1 | push ax # 将ax中的数据送入栈中 |
例如:
1 | mov ax, 0123H |
从图可知,一段当作栈的连续内存,栈顶在低位,栈底在高位,且栈顶可能是空余一部分数据的,即栈顶是从高位向低位增长的。
如何知道这段连续内存是一个栈空间,如何知道当前的栈顶位置?
8086CPU使用两个寄存器来描述栈:SS寄存器保存栈的段地址,SP寄存器保存栈顶的偏移。
所以,**SS寄存器中的值指向的地址就是栈空间的段首地址(低位),SP寄存器中的值对应的就是从高位不断扩展到低位的偏移地址(空栈时在最高位)**。
因为push和pop以字为操作单位,所以每次push压栈时,SP寄存器的值都会先减2再向内存单元写入数据,每次pop弹栈时,都会先读取栈顶数据再将SP寄存器的值加2。
例如,将10000H-1000FH这段空间当作栈,初始化为栈的过程:
1 | mov ax, 1000H |