1.写在前面
我之前在看操作系统的书,但是看到第二章的时候,发现一大堆的汇编的语言,这个时候想死的心都是有的,大学的汇编课都是睡觉的,然后考试的还是刚刚及格过的,悔恨呀!然后大学的那本汇编的教材也不是很好,导致自己不太想学,现在想学操作系统,这门课又要重新捡起来了。这次我看的教材是《汇编语言》第三版-王爽。主要是参考这本书来学习的,博客也是用来记录,学到的一些知识。
2.本篇博客的概述
3.基础知识
学习任何一门知识之前,我们都需要一些前置的知识,我们都知道汇编语言是直接在硬件之上工作的语言。我们首先要了解硬件系统的结构,才能有效的应用汇编语言对其编程。所以本章主要介绍的硬件的一些知识。
3.1机器语言
提到汇编语言,就不得不提下机器语言了。机器语言是机器指令的集合。机器指令展开来讲就是一台机器可以正确执行的指令,电子计算机的机器指令是一列二进制的数字。计算机将之转变为一列高低电平,以使计算机的电子器件受到驱动,进行运算。
上面所说的计算机指的是可以执行机器指令,进行运算的机器。这是早期计算机的概念。现在完成计算的就是CPU。
每一种微处理器,由于硬件设计和内部结构的不同,就需要用不同的电平脉冲来控制,使它工作。所以每一种微处理都有自己的机器指令集,也就是机器语言。
由于机器的语言的可读性差,同时出错了也不易于排查,不易于记忆等等缺点。于是就诞生了汇编语言。
3.2汇编语言的产生
汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上。汇编指令是机器指令便于记忆的书写格式。
汇编指令虽然发明出来了,但是机器只认识机器指令,那么汇编指令怎么在机器上运行呢?这个时候需要一个能够将汇编指令转换成机器指令的翻译程序,这样的程序我们称其为编译器。
程序员用汇编语言写出源程序,再用汇编编译器将器编译成机器码,由计算机最终执行。
3.3汇编语言的组成
- 汇编指令:机器码的助记符,有对应的机器码。
- 伪指令:没有对应的机器码,由编译器执行,计算器并不执行。
- 其他符合:如+、-、*、/等,由编译器识别,没有对应的机器码。
3.4存储器
我们都知道CPU是计算机的核心,但是想让一个CPU工作的话,我们需要向它提供指令和数据。这些指令和数据都是存储在存储器中的。
3.5指令和数据
从存储器的角度来看,指令和数据没有任何区别的,都是二进制信息。
从CPU的角度来看,有些信息是指令,有些信息是数据。
3.6存储单元
存储器被划分成若干个存储单元,每个存储单元从0开始顺序编号的。我们知道电子计算机的最小信息单位是Bit,也就是一个二进制位。8个bit组成一个Byte,也就是通常讲的一个字节。
3.7CPU对存储器的读写
CPU要进行数据的读写,必须和外部器件进行下面3类的信息交互。
- 存储单元的地址(地址信息)
- 器件的选择,读或写的命令(控制信息)
- 读或写的数据(数据信息)
于是我们将CPU分成三类总线,地址总线、控制总线、数据总线。
CPU读取数据的过程:
- CPU通过地址线将地址信息发出
- CPU通过控制线发出内存读命令,选中存储器芯片,并通知它,将要从中读取数据
- 存储器将对应的单元中的数据通过数据线送入CPU
CPU写数据的过程:
- CPU通过地址线将地址信息发出
- CPU通过控制线发出内存写命令,选中存储器芯片,并通知它,将要从中写入数据
- CPU通过数据线将数据送入对应的内存单元中。
3.8地址总线
现在我们知道,CPU是通过地址总线来指定存储器单元的。可见地址总线上能传送多少不同的信息,CPU就可以对多少个存储单元进行寻址。
一个CPU有N根地址线,则可以说这个CPU的地址总线的宽度为N。这样的CPU最多亦可以寻找2的N次方个内存单元。
3.9数据总线
CPU与内存或其他器件之间的数据传送是通过数据总线来进行的。数据总线的宽度决定了CPU和外界的数据传送速度。
3.10控制总线
CPU对外部器件的控制是通过控制总线来进行的。在这里控制总线是个总称。控制总线是一些不同控制线的集合。CPU提供了对外部器件的多少种控制。所以,控制总线的宽度决定了CPU对外部器件的控制能力。
3.11内存地址空间(概述)
如果一个CPU的地址总线宽度为10,那么可以寻址1024个内存单元,这1024个可寻到的内存单元就构成这个CPU的内存地址空间。
3.12主板
每台PC机上,都有一个主板,主板上有核心器件和一些主要器件,这些器件通过总线相连。
3.13接口卡
CPU通过总线向接口卡发送命令,接口卡根据CPU的命令控制外设进行工作。
3.14各类存储器芯片
-
随机存储器
用于存放供CPU使用的绝大部分程序和数据,主随机存储器一般由两个位置上的RAM组成,装在主板上RAM和插在扩展槽上的RAM。
-
装有BIOS的ROM
BIOS是由主板和各类接口卡厂商提供的软件系统,可以通过它利用该硬件设备进行最基本的输入输出。
-
接口卡上的RAM
某些接口卡需要对大批量输入、输出数据进行暂时存储,在其上装有RAM。
3.15内存地址空间
上面提到的存储器在物理上是独立的器件,但是以下两点上相同
- 都和CPU的总线相连
- CPU对它们的读或写的时候都通过控制线发出内存的读写命令。
最终运行程序的是CPU,我们用汇编语言编程的时候,必须要从CPU的角度考虑问题。对CPU来讲,系统中的所有存储器中的存储单元都处于一个统一的逻辑存储器中,它的容量受CPU寻址能力的限制,这个逻辑存储器即是我们所说的内存地址空间。
4.寄存器
一个典型的CPU由运算器、控制器、寄存器等器件构成,这些器件靠内存总线相连。
- 运算器进行信息处理。
- 寄存器进行信息存储。
- 控制器控制各种器件进行工作。
- 内存总线连接各种器件,在它梦之间进行数据的传送。
这儿学的8086CPU有14个寄存器,每个寄存器有一个名称。分别是:AX、BX、CX、DX、SI、DI、SP、BP、IP、CS、SS、DS、ES、PSW。
4.1通用寄存器
8086CPU的所有的寄存器都是16位的,可以存放两个字节。主要是AX、BX、CX、DX。
由于8086CPU的上一代CPU中的寄存器都是8位的。为了保证兼容,使原来基于上代的CPU编写的程序稍加修改就可以运行在8086之上,8086CPU的AX、BX、CX、DX这4个寄存器都可分为独立使用的8位寄存器来用:
- AX可分为AH和AL
- BX可分为BH和BL
- CX可分为CH和CL
- DX可分为DH和DL
AX的低8位的构成AL寄存器,高8位构成了AH寄存器。
4.2字在寄存器中的存储
字节:一个字节由8个bit组成,可以存在8位寄存器中。
字:一个字由两个字节组成,这两个字节分别称为这个字的高位字节和低位字节。
一个字可以存在一个16位寄存器中,这个字的高位字节和低位字节自然就存在这个寄存器的高8位寄存器和低8位寄存器中。
4.3几条汇编指令
汇编指令 | 控制CPU完成的操作 | 高级语言描述 |
---|---|---|
mov ax,18 | 将18送入寄存器AX | AX=18 |
Add ax,8 | 将寄存器AX中的数值加上8 | AX=AX+8 |
4.4物理地址
CPU访问内存单元时,要给出内存单元的地址。所有的内存单元构成存储空间是一个一维的线型空间,每一个内存单元在这个空间中都有唯一的地址,我们将这个唯一的地址称为物理地址。
4.5 16位结构的CPU
- 运算器一次最多可以处理16位的数据
- 寄存器的最大宽度为16位
- 寄存器和运算器之间的通路为16位
4.6 8086CPU给出物理地址的方法
- CPU中的相关部件提供两个16位的地址,一个称为段地址,另一个称为偏移地址
- 段地址和偏移地址通过内部总线送入一个称为地址加法器的部件
- 地址加法器将两个16位地址合成一个20位的物理地址
- 地址加法器通过内部总线将20物理地址送入输入输出控制电路
- 输入输出控制电路将20位物理地址送上地址总线
- 20位物理地址呗地址总线传送到存储器。
地址加法器采用物理地址=段地址*16+偏移地址
4.7物理地址=段地址*16+偏移地址的本质含义
CPU在访问内存时,用一个基础地址和一个相对于基础地址的偏移地址相加,给出内存单元的物理地址。
4.8段的概念
内存没有分段,段的划分来自于CPU,由于8086CPU用物理地址=段地址*16+偏移地址的方式给出内存单元的物理地址,使得我们可以用分段的方式来管理内存。
在编程的时候,将若干地址连续的内存单元看作一个段,用段地址*16定位的起始地址,用偏移地址定位段中的内存单元。
4.9段寄存器
8086CPU中只有CS、DS、SS、ES这几个段寄存器。
4.10CS和IP
CS和IP是8086CPU中两个最关键的寄存器,它们指示了CPU当前要读取指令的地址。CS为代码段寄存器,IP为指令指针寄存器。
8086CPU中,任意时刻,CPU将CS:IP指向的内容当作指令执行。
-
8086CPU当前状态:CS中内容为2000H,IP中内容为000H
-
内存20000H~20009H单元存放着可执行的机器码
-
内存20000H~20009H单元中存放的机器码对应的汇编指令如下:
mov ax,0123H mov bx,0003H mov ax,bx add ax,bx
具体的执行过程如下:
简述:
- 从CS:IP指向的内存单元读取指令,读取的指令进入指令缓冲区
- IP=IP+锁读取指令的长度,从而指向吓一条指令
- 指令指令,转到步骤1,重复这个过程。
4.11修改CS、IP的指令
jmp 段地址:偏移地址 指令的功能为:用指令中给出的段地址修改CS,偏移地址值修改IP。
若想仅修改IP的内容,可用形如 jmp 某一个合法的寄存器。
4.12代码段
这段内存是用来存放代码的,从而定义了一个代码段。
5.寄存器(内存访问)
5.1内存中的字的存储
在内存中存储时,由于内存单元时字节单元,则一个字要用两个连续的内存单元来存放,这个字的低位字节存放在低地址单元中,高位字节存放在高地址单元中。
5.2DS和[address]
CPU要读写一个内存单元的时候,必须先给出这个内存单元的地址,在8086PC中,内存地址由段地址和偏移地址组成。
8086CPU中有一个DS寄存器,通常用来存放要访问的数据的段地址。比如我们要读取10000H单元的内容,可以用如下的程序段进行
mov bx,10000H
mov ds,bx
mov al,[0]
前面提到的mov的功能:将数据直接送入寄存器。将一更寄存器中的内容送入另一个寄存器。也可以将一更内存单元中内容送入一个寄存器中。
[…]表示一个内存单元。[…]中的0表示内存单元的偏移地址。
10000H用段地址和偏移地址表示1000:0,我们先将段地址1000H放入DS,然后用mov al,[0]完成传送。mov 指令中的[0]说明操作对象一个内存单元,[]中的0说明这个内存单元的偏移地址是0,它的段地址默认放在ds中。
5.3字的传送
前面我们用mov指令在寄存器和内存之间进行字节型数据的传送。因为8086CPU是16位结构,有16根数据线,所以,可以一次性传送16位的数据,也就是说可以一次传送一个字。只要在mov指令中给出16位的寄存器就可以进行16位数据的传送了。
5.4 mov add sub 指令
指令格式 | 举例 |
---|---|
mov 寄存器,数据 | mov ax,8 |
mov 寄存器,寄存器 | mov ax,bx |
mov 寄存器,内存单元 | mov ax,[0] |
mov 内存单元, 寄存器 | mov [0],ax |
mov 段寄存器,寄存器 | mov ds,ax |
add 寄存器,数据 | add ax,8 |
add 寄存器,寄存器 | add ax,bx |
add 寄存器,内存单元 | add ax,[0] |
add 内存单元, 寄存器 | add [0],ax |
sub 寄存器,数据 | sub ax,9 |
sub 寄存器,寄存器 | sub ax,bx |
sub 寄存器,内存单元 | sub ax,[0] |
sub 内存单元, 寄存器 | sub [0],ax |
5.5数据段
将一段内存当做数据段,我们可以写出如下代码,比如,将123B0H~123B9H的内存单元定义成数据段。现在要累加这个数据段中前3个单元中的数据,代码如下:
mov ax,123BH
mov ds,ax ;将123H送入ds中,作为数据段的段地址
mov al,0 ;用al存放累加结果
add al,[0] ;将数据段第一个单元(偏移地址为0)中的数值加到al中
add al,[1] ;将数据段第二个单元(偏移地址为1)中的数值加到al中
add al,[2] ;将数据段第三个单元(偏移地址为2)中的数值加到al中
5.6栈
先进后出,栈有两个基本的操作,入栈和出栈,入栈就是将一更新的元素放到栈顶,出栈就是从栈顶取出一更元素。
5.7CPU提供的栈机制
上图有两个问题,CPU如何知道一个指定的地址为栈的?CPU如何知道哪个是栈顶。这个时候,需要引入我们的新的段寄存器了SS,任意时刻,SS:SP指向栈顶元素。
5.8栈顶超界的问题
当栈满的时候再使用push指令入栈,或者栈空的时候再使用pop指令出栈,都将发生栈顶超界问题。这个问题可能会导致,我们其他地方书写的代码被覆盖。而8086CPU只考虑栈顶在何处。没有保证栈顶一定不会超界。所以我们在写代码的时候一定需要注意。
5.9 push、pop指令
push 寄存器 ;将一个寄存器中的数据入栈
pop 寄存器 ;出栈,用一个寄存器接收出栈的数据
push 段寄存器 ;将一个段寄存器中的数据入栈
pop 段寄存器 ;出栈,用一个段寄存器接收出栈的数据
push 内存单元 ;将一更内存单元中的数据入栈
pop 内存单元 ;出栈,用一个内存单元接收出栈的数据
5.10栈段
前面提到了代码段,数据段,这儿提到的栈段,和前面的含义是一样,就是选择内存中的一组连续的空间当成代码段。
5.11代码段、数据段、栈段总结
我们可以用一个段存放数据,将它定义为数据段
我们也可以用一个段存放代码,将它定义为代码段
我们可以用一个段当做栈,将它定义为栈段
对于数据段,将它的段地址放在DS中,用mov、add、sub等访问内存单元的指令时,CPU就将我们定义的数据段中的内容当做数据来访问。
对于代码段,将它的段地址放在CS中,将段中第一条指令的偏移地址放在IP中,这样CPU就将执行我们定义的代码段中的指令;
对于栈段,将它段地址放在SS中,将栈顶单元的偏移地址放在SP中,这样CPU在需要进行栈操作的时候,比如执行push、pop指令等,就将我们定义的栈段当做栈空间来用。
可见,不管我们如何安排,CPU将内存中的某段内容当做代码,是因CS:IP指向了哪里;CPU将某段内存当做栈,是因为SS:SP指向了哪里。我们一定要清楚,什么是我们的安排,以及如何让CPU按我们的安排行事。要非常清楚CPU的工作机理,才能在控制CPU按照我们的安排运行的时候做到游刃有余。
6.第一个程序
6.1一个源程序从写出到执行的过程
- 编写汇编源程序
- 对源程序进行编译连接
- 执行可执行文件中的程序
6.2源程序
assume cs:codesg
codesg segment
mov ax,0123H
mov bx,0456H
add ax,bx
add ax,ax
mov ax,4c00H
int 21H
codes ends
end
在前面我们就说过汇编有两种指令,一种是汇编指令,一种是伪指令。汇编指令是有对应的机器码的指令,可以被编译为机器指令,最终为CPU所执行。而伪指令没有对应的机器指令,最终不被CPU所执行。伪指令是由编译器来执行的指令,编译器根据伪指令来进行相关的编译工作。
上面的代码中出现了三种伪指令
- segment和ends是一对成对使用的伪指令,这是在写可被编译器编译的汇编程序时,必须要用到的一对伪指令。segment和ends的功能是定义一个段,segment说明一个段开始,ends说明一个段结束。一个段必须有一个名称来标识。一个汇编程序是由多个段组成的,这些段被用来存放代码、数据或当作栈空间来使用。
- end 是一个汇编程序的结束标记,编译器在编译汇编程序的过程中,如果碰到了伪指令end,就结束对源程序的编译。所以,在我们写程序的时候,如果程序写完了,要在结尾处加上伪指令end。否则,编译器在编译程序时,无法知道程序在何处结束。
- assume 这条伪指令的含义为假设,它假设某一段寄存器和程序中某一个用segment…ends定义段相关联。通过assume说明这种关联,在需要的情况下,编译程序可以将段寄存器和某一个具体的段相联系。assume并不是一条非要深入理解不可的伪指令,以后我们编程时,记得用assume将有特定用途的段和相关的段寄存器关联起来即可。
6.3编写第一个源程序
具体的代码如下:
assume cs:codesg
codesg segment
mov ax,0123H
mov bx,0456H
add ax,bx
add ax,ax
mov ax,4c00H
int 21H
codesg ends
end
最后将这个文件保存成1.asm
注意:mov ax,4c00H int 21H
这两条指令表示程序的返回。
6.4编译
环境:DosBox 0.73 3-3 masm5
至于怎么安装,我这儿就不赘述了,可以自行查找资料
先进入DOS,然后运行masm.exe,如下图
输入要编译的源程序文件名后,按enter键,如下图
这个时候编译器要我们输入要编译出的目标文件的名称,目标文件是我们对一个源程序进行编译要得到的最终结果。注意已经给了默认的文件名[1.obj],当然我们也可以指定到其他路径的,只需要全路径加文件名即可,这儿我们就默认吧。
确认好目标文件的名称后,如下图
编译程序提示输入列表文件的名称,这个文件是编译器将源程序编译为目标文件的过程中产生的中间结果。可以让编译器不生成这个文件,直接enter即可
忽略了列表文件的生成后,如下图
编译程序提示输入交叉引用文件的名称,这个文件同列表文件一样,是编译器将源程序为目标文件过程中产生的中间结果,可以让编译器不生成这个文件,直接回车即可。
忽略了交叉引用文件的生成后,如下图
可以看到我们的程序已经编译成功了没有错误。
6.5连接
在对源程序进行编译得到的目标文件后,我们需要对目标文件进行连接,从而得到可执行文件。
进入dos,进入masm目录下,运行link.exe,如下图
输入要连接的目标文件名后,按回车,如下图
在输入目标文件名后,程序集训提示我们输入要生成的可执行文件名称,可执行文件是我们对一个程序进行连接要得到的最终结果。
确定了可执行文件的名称后,如下
连接程序提示输入映像文件的名称,这个文件是连接程序将目标文件连接为可执行文件过程中产生的中介结果,可以让连接程序不生成这个文件,直接按回车即可
忽略了映像文件的生成后,如下
连接程序提示输入库文件的名称,库文件里面包含了一些可以调用的子程序,如果程序中调用了某一个库文件的子程序,就需要在连接时候,将这个库文件和目标文件连接到一起,生成可执行文件。但是,这个程序中没有调用任何子程序,所以,这里忽略库文件名的输入,直接回车即可。如下
这里提示没有栈帧,这里我们可以不用理会这个错误
至此连接就做完了,那么连接的作用是什么?
- 当源程序很大时,可以将它分为多个源程序文件来编译,每个源程序编译成为目标文件后,再用连接程序将它梦连接到一起,生成一个可执行的文件。
- 程序中调用了某个库文件中的子程序,需要将这个库文件和该程序生成的目标文件连接到一起,生成一个可执行文件。
- 一个源程序编译后,得到了存有机器码的目标文件,目标文件中的有些内容还不能直接用来生成可执行文件,连接程序将这些内容处理为最终的可执行信息。所以,在只有一个源程序文件,而又不需要调用某个库中的子程序的情况下,也必须用连接程序对目标文件进行处理,生成可执行文件。
6.6以简化的方式进行编译和连接
前面的编译和连接太过于麻烦,有没有什么简单的方法,有的,具体的如下
编译
这样编译,是直接在当前路径下生成目标文件1.obj,并在编译的过程中自动忽略中间文件的生成。
连接
这样连接,是直接在当前路径下生成目标文件1.exe,并在编译的过程中自动忽略中间文件的生成。
6.7执行
程序运行后,竟然没有任何结果,就和没有运行一样,其实这儿是运行过了。
为什么屏幕上没有显示任何信息呢?是因为我们没有向屏幕写入任何东西,那么怎么写呢?后续的博客会告诉大家。
6.8谁将可执行文件中的程序装入内存并使它运行?
要回答这个问题,我们先来说下操作系统。
操作系统是由多个功能模块组成的庞大、复杂的软件系统。任何通用的操作系统,都要提供一个称为shell的程序,用户使用这个程序来操作计算机系统进行工作。
DOS中有个一个程序command.com,这个程序在DOS中称为命令解释器,也就是DOS系统的shell
DOS启动时,先完成其他重要的初始化工作,然后运行ccommand.com,command.com运行后,执行完其他的相关任务后,在屏幕上显示出当前盘符和当前路径组成的提示符。然后等待用户输入。
用户可以输入所执行的命令,这些命令由command执行,command执行完这些命令后,再次显示由当前盘符和当前路径组成提示符,等待用户的输入。
如果用户要执行一个程序,则输入改程序的可执行文件的名称,command首先根据文件名找到可执行文件,然后将这个可执行文件中的程序加载入内存,设置CS:IP指向程序的入口。此后,command暂停运行,CPU运行程序。程序运行结束后,返回到command中,command再次显示由当前盘符和当前路径组成的提示符,等待用户的输入。
在DOS中,command处理各种输入:命令和要执行的程序的文件名。我们就是通过command来进行工作的。
到此,完成了一个汇编程序从写出到执行的全部过程,我们经历了这样的一个历程
6.9程序执行过程的跟踪
我们可以使用debug来看1.exe 的执行的过程,如下:
可以看到,debug将程序从可执行文件加载入内存后,cx中存放的是程序的长度。1.exe中程序的机器码共有15个字节,所有这儿是000FH。
这个时候需要讲解一下在DOS系统.exe文件中程序加载的过程。如下
从上面的图,我们知道以下的信息
-
程序加载后,ds中存放着程序所在内存区的段地址,这个内存区的偏移地址为0,则程序所在的内存区的地址为ds:0
-
这个内存区的钱256个字节中存放的是PSP,DOS用来和程序进行通信。从256字节处向后的空间存放的是程序。
所以,从ds中可以得到PSP的段地址SA,PSP的偏移地址为0,则物理地址为SA*16+0
因为PSP占256个字节,所以程序的物理地址是
SA*16+0+256=SA*16+16*16+0=(SA+16)*16+0
可用段地址和偏移地址表示为SA+10H:0
现在我们来看下原来的图,DS=75A,则PSP的地址75A:0,程序的地址为76A:0(即 75A+10:0)。而图中的CS=76A,IP=0000,CS:IP指向程序的第一条指令。这个时候我们可以用U命令看一下其他指令,如下:
可以看到,76A:0000~76A:000E都是程序的机器码。
现在,我们可以开始跟踪了,用T命令但不执行程序中每一条指令,并观察每条质量的执行结果,到了int 21,我们要用P命令执行,如下
最后出现Program terminated normally
表示程序正常结束。
7.写在最后
本篇博客大概的介绍了一下汇编,同时学了一些简单的指令,同时手写了一个简单的汇编程序。