文章目录
- 前言
- 计算机系统基础
-
- 概述
- 微处理器/中央处理器(CPU)
-
- 概述
- 性能指标与总线
-
- 前端总线(Front Side Bus)
- 带宽
- 数据总线DB/地址总线AB/控制总线CB
- CPU软件特性与指令集
-
- 复杂指令集(CISC)
- 精简指令集(RISC)
- 其他技术
- 多核CPU
- 主板
-
- CPU
- 芯片组
-
- 主板控制芯片组
-
- 南北桥体系结构
- 单芯片组体系结构
- BIOS芯片
- CMOS芯片
- 多通道内存技术
- 关于外置适配器
- 插槽
- 储存器原理
-
- 高低的概念
- 储存方式
-
- 前后缀
- 储存顺序
-
- 例题
- 中央处理器的发展历史
- 微处理器管理模式
-
- 微处理器基本结构
- CPU工作模式
-
- 实模式
- 保护模式
- 虚拟8086模式(V86模式)
- 64位模式
- 寄存器
-
- 通用寄存器
- 专用寄存器
-
- 指令指针寄存器(IP/EIP/RIP)
- 堆栈指针寄存器(SP/ESP/RSP)
- 标志寄存器
- 段寄存器与16位CPU的内存寻址
- 控制寄存器
- 调试寄存器
- 全局描述符表寄存器(GDTR)与段选择符
- 局部描述符表寄存器(LDTR)
- 中断描述符表寄存器(IDTR)
- 任务寄存器(TR,Task Register)
- 内存管理
-
- 实模式下分段管理
- 保护模式下分段管理
-
- 段描述符
- 32位CPU系统中三者的转换
- 页式内存管理
-
- 分页
- 线性地址转换为物理地址的过程
- 任务
-
- 任务执行环境
- 任务状态段(TSS)
- 门
-
- 调用门
- 任务门
- 中断门和陷阱门
- 任务切换
-
- 直接任务切换
- 间接任务切换
- 保护
-
- 数据访问保护
- 对程序的保护
-
- 直接转移保护
- 间接转移保护
- 输入输出保护
- 特权级综合例题
- 指令系统(重点)
-
- 数据寻址方式
-
- CPU操作数寻址
-
- 立即寻址
- 寄存器寻址
- 储存器操作数寻址
-
- 直接寻址(两种写法)
- 寄存器间接寻址
- 寄存器相对寻址(两种写法)
- 基址变址寻址方式(重点考实模式)
- 相对基址变址方法
- 比例变址寻址方法
- 小结
- 数据运算指令
-
- 数据传送指令
-
- 通用数据传送
- 堆栈操作指令
- IO指令
- 地址传送指令
- 二进制算数运算指令
-
- 类型转换指令
- 加法指令
- 减法指令
- 多字节相加例子
- 乘法指令
- 除法指令
- 位运算指令
-
- 逻辑运算指令
- 位测试指令
- 基本移位指令
- 循环移位指令
- 程序控制指令
-
- 程序寻址
-
- 段内直接转移
- 段内间接跳转
- 段间直接转移
- 段间间接转移
- 转移指令
-
- 无条件转移JMP
- 条件转移
- 举例
- 循环指令
- 子程序调用及返回指令
-
- 段内调用
- 段间调用
- 返回
- 中断调用及返回指令
-
- 中断
- 中断调用
- 中断返回
- 处理机控制指令
-
- 标志位控制指令
- 其他(略)
- 块操作指令
-
- MOVS示例
- LODS、STOS示例
- 汇编程序开发
-
- 汇编代码初步实践
-
- dos虚拟机与挂载
- 实模式Hello World
-
- 代码解析
- 汇编
- 链接
- 重新汇编与批处理
- 反汇编与debug
- 保护模式Hello World
- VS2017+masm32环境配置
- 汇编基础
-
- 语言概述
- 汇编环境
- 汇编语言语句格式
- 常用伪指令
-
- 数据定义
- 符号定义
- 操作符
-
- \$
- OFFSET
- 算术操作符
- 逻辑操作符
- 关系操作符
- 框架定义伪指令
- 汇编语言程序格式
-
- 用户界面
- 控制台界面
-
- 模式定义
- 库文件以及函数声明
- 数据部分
- 代码部分
- Windows界面
- 输入输出相关API
- 分支与循环程序设计
-
- 分支
-
- 仅if
- if-else
- 分支综合:折半查找
- switch
- 循环
-
- loop指令
- 单层循环:do while、while、for
- 嵌套循环
- 浮点运算
-
- 浮点数与浮点寄存器栈
- 示例代码1
- 浮点数据/指令细究
-
- 数据定义
- 寻址方式
- 指令
- 示例代码2
- 性能优化
-
- 时间优化
- 空间优化
- 子程序设计
-
- 子程序基本知识
-
- 子程序定义
- 堆栈
- 子程序的返回地址
- 参数传递
-
- C语言函数的传参方式
- 汇编子程序传参方式
- 带参数子程序定义
- 子程序中局部变量
- 子程序特殊应用
-
- 子程序嵌套
- 子程序递归
- 缓冲区溢出
-
- 堆栈溢出
- 数据区溢出
- 杀毒软件原理
- 子程序模块化设计与通信
- C语言反汇编(重点)
-
- 基本框架与栈帧
- 选择结构
- 循环结构
- 变量定义
- 指针
- 函数
- C语言汇编混合编程
- 北京理工大学汇编大作业:坦克大战魔改版
前言
学校这学期开了一门《汇编语言与接口技术》,这篇文章是微机与汇编语言部分。
为什么要先学计组知识呢,因为汇编是和硬件紧密结合的语言,没有硬件,哪来汇编?所以先学一些硬件非常有助于汇编的学习。
汇编语言笔记
接口技术笔记TODO
计算机系统基础
概述
冯诺依曼框架的计算机系统核心在于计算和信息储存分离,具体如下:
- 总的来说,CPU是核心,负责计算,处理内
- 存相当于草稿纸,储存临时用的数据
- 芯片组负责大量辅助功能
- I/O接口负责与外部设备通信
- 而总线则负责连通各种部件。
接下来就对这个框架进行介绍。
微处理器/中央处理器(CPU)
概述
处理器一般有32位和64位,分别使用不同的架构。
从下图可以看出,x86和32位并没有必然关系。
平时说的x86,或者64位处理器,都是通用微处理器。
通用微处理器功能比较多,而专用微处理器可能更多的针对某一类任务,比如单片机(MCU)通常用于控制,数字信号处理器(DSP)用于精确地操控数字信号。
性能指标与总线
前端总线(Front Side Bus)
总线不仅仅是一条,曾经有一种南北桥架构,使用前端总线,连接顺序为:
CPU——总线——前端总线(FSB)——北桥——内存
问题来了,总线和前端总线的区别?
前端总线是总线的一个分支,可以采用一些技术(HT,QPI)把总线频率放大,实现前端总线的加速(比如4倍速)。
带宽
位宽:一次传输的bit位数量
频率:单位时间(秒)内的传输次数
带宽就是信息传输的速度,带宽=位宽×频率
算的时候还要注意bit和byte,一般来说都要÷8把bit变成byte
下图中,FSB频率就是4倍外频。
计算峰值性能=FSB频率×位宽÷8
数据总线DB/地址总线AB/控制总线CB
系统总线具体说,有三种功能的总线。
DB(Data Bus),AB(Address Bus ),CB(Control Bus)
数据总线传输各种数据,一般用位宽衡量,表示一次可以传输的bit位数。
地址总线专门用于传输地址,和寻址操作密切联系。具体说,寻址空间=2ABByte寻址空间=2^{AB} Byte寻址空间=2ABByte,每一个二进制组合对应一个Byte。如果是20位AB那可寻址空间就是220=1MB2^{20}=1MB220=1MB
如果一台电脑的AB是32位,即可寻址空间为232=4GB2^{32}=4GB232=4GB,但是其内存是16GB的,那么将会有12G的内存被浪费,因为根本找不过去。
CPU软件特性与指令集
CPU有三种工作模式,实模式,保护模式,虚拟实模式,表面上是三种模式,实际上是三种不同的指令执行模式。
CPU的工作内容就是一条一条的指令,自然CPU就有指令集,好比编程语言之于函数库。好的指令集能让CPU工作效率变高。
复杂指令集(CISC)
Complex Instruction Set Computing
复杂指令集的设计理念比较朴素:
- 一个程序中,指令是顺序执行的
- 一条指令中,操作是顺序串行执行的
很显然,这样执行起来简单,且不容易出错,但是运行效率比较低,资源利用率差。
Intel生产的x86和(IA-32架构)和其他CPU,比如AMD,VIA,x86-64都是CISC架构。
典型的CISC技术如下:
- MMX技术。一条指令可以对多条数据操作(SIMD,Single Instruction Multiple Data)。同时增加了针对多媒体的指令。
- SSE技术。是MMX的扩展,指令可以对浮点数操作
- 3DNOW技术。是MMX的扩展,支持浮点数的矢量计算,隶属于AMD,与SSE互不兼容
精简指令集(RISC)
Reduced Instruction Set Computing
对指令集进行简化,目标就是快速,简单,适合流水线操作。
经典的RISC是MIPS
其他技术
- 超线程技术。把一个物理处理器内核虚拟成两个独立的逻辑处理器内核进行并行操作。
- 超标量。同时执行多条指令,物理基础是多个执行硬件,是真正地并行。
- 超长指令字。VIEW技术,用多个相同部件拼接执行超长指令。其衍生出EPIC架构,这是64位架构。
- 动态执行技术。让CPU执行更加智能。
多核CPU
为什么多核?单核发展到了极限。
- 功耗限制
- 互连线延时
- 设计复杂度
多核的物理实现比较简单,就在一个CPU上堆核心就好了,但是其配套的软硬件支持才是难点。
- 并行编程模型。多核之间如何进行并行?
- 片上网络(Networks on Chip,NOC)。总线速度太慢,多核之间如何进行快速通信?
- 存储层次等。
主板
计算机内部的功能组件都是集中在主板上的。
主板最开始是AT标准,后面更新换代,变成了ATX(AT eXtended),后面Intel想更新BTX,但是碍于兼容问题以及更新成本,只能作罢。
接下来就逐一介绍主板上的内容
CPU
略过。
芯片组
芯片组是主板复杂功能的基石,和主板的性能上限密切相关联。
主板控制芯片组
芯片组的核心。控制局部总线、内存和各种扩展卡。
南北桥体系结构
CPU通过前端总线链接北桥芯片(MCH,Memory Control Hub),北桥芯片连接内存和GPU以及南桥芯片(ICH,I/O Control Hub)。北桥芯片负责控制主板内部的空间。
南桥芯片再链接I/O设备和硬盘。
南桥芯片相当于控制连接主板外的空间。
单芯片组体系结构
因为北桥芯片是控制主板内部空间的,所以随着CPU越来越强,北桥芯片的功能被集成到了CPU内去。
微型计算机的体系结构模式变成了CPU+南桥的单芯片组体系结构模式,称平台控制中心PCH(Platform Controller Hub)。
BIOS芯片
BIOS(Basic Input Output System)
BIOS本身是一个固定程序,这些都是很关键的程序。比如开机的时候就会启动BIOS芯片中的程序。
以前的BIOS都是ROM,不可修改,现在是Flash ROM,可以用软件修改。
CMOS芯片
CMOS记录了电脑中的关键信息。
CMOS不是永久储存,所以没电以后信息就没了。
如果需要清除CMOS中的信息,比如忘记了开机密码而无法启动系统等。一般可以通过主板上有专门的跳线来解决这个问题。一般的方法是先关闭电源,把CMOS跳线短接一会儿,然后还原,重新开机即可
多通道内存技术
当CPU和内存之间的通道只有一个,增加再多的内存也不会影响到读写速度。
如果同时增加通道数和内存数,比如2通道2内存,就可以实现并行访问,相当于带宽翻倍。
关于外置适配器
I/O总线与I/O设备之间是要有一个关卡的,这个关卡一般是主板内部的芯片,但是有时候也会是适配器。
适配器是插在主板上的卡,如果你嫌弃主板芯片组不太行,就可以插适配器,相当于扩充芯片组的性能。
但是无论是适配器还是芯片组,负责的都是控制着在I/O总线与I/O设备之间传递信息。
插槽
主板上除了芯片组外,还包括多种跳线、开关、电池、电容、电阻以及各种插槽。主板上的插槽主要包括CPU插槽、内存插槽、扩展槽以及各种I/O接口等。
储存器原理
储存器中储存的数据,从1bit,到字节,字,双字,四字,占据不同大小的储存单元,但是其储存原则相同。
高低的概念
最高位/最低位:在任何储存都适用
MSB,Most Significant Bit, LSB ,Least Significant Bit
高字节/第字节:适用于字。
高字/低字:适用于双字
储存方式
出于效率的考虑,储存的最小单元不是Bit而是Byte,一个Byte对应于一个储存器地址。
地址和内容都是用16进制来表示。
前后缀
可以看到,这个0ED66025H,实际上他是ED66025
前面加0,只是在编程或者交流的时候和变量名做区分,并不会影响到实际储存。
同理,后面加H也只是告诉你是16进制,实际储存不会吧H存进去。
储存顺序
而储存的顺序,Intel采用小端方案,低字节在低地址,高字节在高地址。也就是说,内存中是先读到低字节的。
比如下图,虽然平时在纸上写的时候是先写高字节,但是存的时候以及你读的时候都是先读低字节。
例题
读起来挺别扭,但是记住两个原则:
- 两位16进制=1Byte
- 先找到基地址,然后选定区间,最后倒着读。实际上程序也是这么读的。
中央处理器的发展历史
略
微处理器管理模式
微处理器基本结构
CPU内部也是分单元的。
80386大体分成三部分:总线接口单元(BIU),中央处理单元(CPU),储存器管理单元(MMU)。
由此可见,CPU和处理器并不等同,好比GPU之于显卡,变成了一个通用的混用名词。
具体到结构图,如下。比较复杂,分块解释。
1、总线接口部件(Bus Interface Unit,BIU):
与外部环境联系,包括从存储器中预取指令、读写数据,从I/O端口读写数据,以及其他的控制功能。
2、中央处理部件(Central Process Unit,CPU):
指令部件:完成指令预取和指令译码。
执行部件:执行从已译码指令队列中取出的指令。
3、存储管理部件(MMU):
分段部件:实现段式存储管理。
分页部件:实现保护模式下的分页模型。
CPU工作模式
实模式
这是比较古老的模式了。
两个重大特点:
- 不管实际地址线有多宽,只使用低20位地址线,即寻址空间为1M,很小。
- 内存分区,用内存段首地址+偏移访问。
- 任何区域都可以被访问,分区但不设禁区
- 不支持并行
保护模式
现在最常用的就是保护模式了。
保护模式基本是把实模式的缺点都优化了。
- 使用32位地址线,支持4G内存,PentiumCPU以后扩展到36位,64G内存
- 内存分页+分段
- 提供基于权限的内存保护机制
- 支持并行
- 引入虚拟内存(即用磁盘虚拟出内存来,虽然速度慢,但是可以保证很多大内存程序的运行)
虚拟8086模式(V86模式)
类似于虚拟机。
实际上运行在保护模式上,只是虚拟出一个实模式。你即使把实模式搞坏了,也只是搞坏了我规定出来的区域。
64位模式
现在CPU都是64位的了,主流的就是AMD64以及Intel EM64T和IA-64。
IA-64不兼容32位,市场占用不多。
EM64T基于IA-32,所以可以兼容32位,16位程序。这种兼容本质上是把多出来的位屏蔽,浪费掉了。
EM64T有两个子模式:
- 兼容模式。
- 纯64位模式。最高效,但是需要纯64位操作系统,64位基础应用程序,驱动,且开发出的程序还要进行一些修改以适应64位。
寄存器
寄存器在CPU里,和CPU是同频的,速度最快。
程序设计人员能接触到的都是可见寄存器组。这里说的也都是可见的。
通用寄存器
通用指可以传送和暂存数据,参与运算,保存结果。
除此之外,还是有一些特殊功能的。
由此可见,RAX这种R**只是一个总称,比如RAX下面有RAX,EAX,AX,分别对应64,32,16位。而那个X也是总称,继续把这一个字分解为高低字节:AH(High),AL(Low),这两个是8位寄存器。
下面英文我是这么理解的:
- A:Add,加(实际上不止加)
- B:Base,基地址
- C:Counter,技术
- S/D:Destination,代表目标,Start代表起始
- P:这个是结尾,代表pos偏移量,很显然,偏移是不可能有8位的
- I:Instruction,代表和指令相关
比如RSI和RDI,一个是指令的起始位置,一个是指令的最终位置。
上面的寄存器是一脉相承过来的,而64位系统里又进行了扩展,R8-R15,这些寄存器都是64位的,但是其32,16位版本没有标志符,所以访问的时候要额外加控制字区分。
专用寄存器
指令指针寄存器(IP/EIP/RIP)
指向下一条指令。
根据系统以及模式的不同,三个寄存器中会进行选择性的使用一种。
堆栈指针寄存器(SP/ESP/RSP)
指向栈顶单元。
同样根据系统和模式不同会使用不同的寄存器。
标志寄存器
一个寄存器,但是每一位都有意义。
很多人会迷惑,这0和1到底怎么记**,其实0就是正常情况,1是非正常情况。0是常态,只有发生了特定变化才会变成1。**,我个人猜测这种情况和电路本身有关,我将1看做激活态,这个状态是要费电的。
- D0:进位标志CF(Carry Flag),0代表没有进位(正常情况),1代表执行结果进位
- D2:奇偶标志PF(Parity Flag),Intel微处理器采用奇校验。0代表执行结果的低八位中有奇数个1(正常情况)。比如11011010B有5个1,则PF=0
- D6:零标志ZF(Zero Flag),0代表结果非0(正常情况),1代表结果为0
- D7:符号标志SF(Signal Flag),0代表非负(正常情况),1代表结果是负数。D6和D7配合起来可以精确判断结果是正负还是0。
- D10:方向标志DF(Direction Flag),针对字符串操作指令中地址变化方向。0代表增址(正常情况),1代表减址
- D11:溢出标志OF(Overflow Flag),带符号数运算的时候,不溢出为0,溢出为1
- D21:微处理器标识标志ID(Identification),相当于一个CPU指针,使用CPUID指令可以获取CPU信息,常有软件利用CPUID作为机器码,可以用于保护版权,但是虚拟机出现后这种方法就被破解了。
段寄存器与16位CPU的内存寻址
段寄存器都是XS的写法,S代表Segment,表示段。一个段对应内存中的一段空间,段寄存器中存放其基地址。
自从80386开始,增加了GS和FS两个。所以程序可以同时访问6个段。
- CS:Code 代码段寄存器
- DS:Data 数据段寄存器
- SS:Stack 堆栈段寄存器
- ES:Extra 附加数据段寄存器
- GS:
- FS:
实模式下,段寄存器只有16位(64K),但是其地址总线是20位(最大寻址空间1M),那如何用16位表示20位?
两个16位就可以表示一个20位。
实模式下,段寄存器存放段基址的高16位地址,然后加上第二个16位就行。
刚开始没有保护模式的时候,用的是16位段寄存器,配合低四位地址构成物理地址
等地址总线拓宽以后,32位CPU出来以后,保护模式也就有了,但是考虑到兼容,16位段寄存器还得保留,但是可以做一些改进,总的来说,就是用另一些结构(GDT,LDT)作为中介,形成间接的段访问。
记住,在保护模式下,段寄存器的目标一直都是在LDT/GDT中选择段描述符。你现在可能不懂,后面如果迷惑了,请回来看一看这句话。
控制寄存器
调试寄存器
全局描述符表寄存器(GDTR)与段选择符
(Global Descriptor Table Registr)
GDTR伴随着32位CPU以及保护模式而出现。保护模式下,16位段寄存器发挥了另外的作用。
我们从物理内存开始倒着讲,这个顺序和前面比较连贯。
首先是段描述符。
段描述符是一个数据结构,用于向处理器提供有关一个段的位置大小访问控制的信息状态信息。很多全局的信息都是存在GDT中的
段描述符是曾经的16位段寄存器的加强版(但是不是段寄存器,也不是寄存器,只是内存中的数据结构)每个段描述符SD(Segment Descripter)的长度是8个字节,含有3个主要字段:段基地址、段限长、段属性。SD加起来有64位,其中有32位的基地址,也就是说,SD的寻址空间有232=4GB2^{32}=4GB232=4GB,这才符合32位系统的威力。
其次是DT,GDT,LDT。
段描述符存在哪里呢,在DT(Descriptor Table)中,DT可以有很多,根据共有还是私有分为GDT(Global)和LDT(Local)
一个DT最多可以储存213=81962^{13}=8196213=8196个段描述符,换算成字节,就是216B=64KB2^{16}B=64KB216B=64KB的空间,即一个DT最多占用64K的内存空间,用于给内存分段。(GDT只有一个,LDT可以多个)
终于轮到段寄存器了。
在保护模式中,段寄存器又叫段选择子(段选择符)。DT里面储存了对应于4G内存的段信息,但是程序运行的时候,具体要选择哪个段读取成了问题。这个时候,通过段寄存器就可以选择DT中的一个段描述符。理论上,2132^{13}213个段描述符,只需要13位Index位就可以描述,那剩下三位肯定是要利用一下。于是,有一位T1位用于区分GDT还是LDT,有两位RPL位用于标示权限(0,1,2,3四级权限)
这里还需要具体说一下如何把段选择子的13位映射到DT中去。因为一个段描述符是8个字节,所以实际选择的时候,段选择子的13位二进制数还要乘以8才行。
最后再说一下GDTR和LDTR。
这两个东西和段寄存器有什么关系?实际上,段寄存器用于选择DT上的一个段描述符,但是DT去哪里找呢?所以,GDTR储存了GDT的基地址,LDTR同理。有人会困惑,为什么只能有2132^{13}213个段描述符(64KB),看一眼GDTR的48位都有什么:
有32位的DT基地址,用于找到DT位置,有16位的DT限长,表示DT最大只能有2162^{16}216即64KB大,是不是对应前面的空间。
不得不说,这真是一个神奇的巧合,13位的段选择基址+2132^13213个段描述符+16位的段描述符表限长,刚好匹配
所以,整个寻址模式有如下步骤:
- 寻找GDTR。使用LGDT(Load GDT)指令将GDT的基地址装入GDTR,前32位就是基址,同时后16位还可以确定GDT有多大,可以存多少个段描述符。注意,是后(16位+1)/8个段描述符。之所以要+1,是因为限长为0总得有意义吧,所以就统一加一,这样限长为0代表长度实际是1,最大0xFFFF实际是0x10000,还能凑个二进制整。
- 寻找段描述符。使用段选择子,生成段描述符在GDTR上的偏移量(要×8),用这个偏移量+GDTR基地址就是段描述符基地址。
- 寻找内存段。读取8字节的段描述符,用32位找到内存基地址,搭配其他32位辅助信息使用这块内存。
用这道题举例:
首先注意+1
其次就是16进制除法,建议变成二进制21023=27=80H\dfrac{2^10}{2^3}=2^7=80H23210=27=80H。
GDTR是唯一的,但是位置不固定,所以才有地址指向,LGDT命令需要在进入保护模式前就运行,进入以后GDT的位置就不能变了,这也是LGDT必须在进入保护模式前执行的原因。
局部描述符表寄存器(LDTR)
GDTR给全局使用,LDTR,每一个任务都有一个。可见LDT的数量远大于GDT,所以LDTR中并不直接储存LDT的基址与大小,与GDTR完全不同。
LDTR是16位,从位数上看像是段寄存器,实际上的使用和段寄存器一样(从GDTR中找描述符)。但是他的宏观目标和GDTR一样:都是寻找一个DT的位置和大小,只不过LDTR是间接寻找,GDTR是直接寻找。
LDTR中也是存了一个段选择符,从GDT中选择LDT描述符(没错,LDT可以说是嵌套在GDT体系中的),获取LDT的基地址和大小等信息。也就是说,相比于GDTR直接锁定一个GDT,LDTR可以通过GDT间接锁定一个LDT。
下图给出了流程:
- GDTR确定了GDT的位置和大小
- 用LDTR作为段选择符,选择GDT中的一个LDT描述符
- 用LDT描述符确定LDT的位置和大小。
- 用段寄存器在LDT中选择内存的描述符(图里没有,我补充的)
- 用LDT中的段描述符寻找物理内存
整个流程比GDTR寻址过程多了两步,这是因为LDTR是以GDT作为跳板间接指定LDT的,前三步合起来实现了这一个宏观目标。
LLDT命令在保护模式中执行,每次切换程序,LDTR都要切换GDT中到该程序对应的LDT描述符。
中断描述符表寄存器(IDTR)
IDTR类似于GDTR,也是48位,在保护模式下用。
基地址32位,指向IDT
与GDTR的区别在于,虽然有16位限长,但是实际上用不了那么多。因为CPU最多支持256个中断,所以是浪费了一些位的。也就是说,IDT最多存256个门描述符(相比于段描述符就是换了个说法),这不是物理的限制,而是逻辑的限制。
每一个门描述符,都指向中断门
这里有一个问题,段寄存器只负责在LDT和GDT中选段,所以似乎是没有IDT的段选机制的。实际上,系统中断不需要你用寄存器选,自有其他机制实现中断描述符的选择。
实模式是如何使用系统中断的呢,是用中断向量表。区别在于,IDT位置可变,中断向量表固定在00000H。
中段描述符表和GDT一样只有一个,所以LIDT也是进入保护模式前装入的。
任务寄存器(TR,Task Register)
TR用于保护模式的任务切换机制。
TR是16位的,也是在GDT上选段,只不过选的是TSS描述符。一个TSS描述符对应内存中一片TSS区域,储存其基址与大小。TR比较纯粹,16位全用了,所以进行GDT偏移的时候,不需要乘8(但段寄存器的13位Index还要乘8),如下:
TSS(Task State Segment)是任务状态段,储存在内存中,一个TSS对应一个程序,储存了启动任务所需的各种信息。
TR用于多任务切换,每次任务切换,TR就重新装载。
内存管理
其实内存管理和寄存器是紧密相关的,能把前面的理解了,内存管理其实已经没什么障碍了。
实模式下分段管理
8086有20位地址总线,但是段寄存器只有16位。
所以段寄存器实际上储存了物理地址的高16位,搭配16位的偏移量实现1M空间的寻址。因此,每一个内存段的首地址必须是16的倍数,而且段的最大长度只能是64K(因为16位偏移量的限制)
计算方式也很简单,从这里可以看出,段与段之间其实是有可能重叠,相邻的,因为段基址之间的差距只有16,但是段长却可以达到64K
实际寻址中,CS存放代码段基址,DS存放数据段基址,SS存放堆栈段基址。
16位偏移量从何而来?可能存在某个寄存器中,或者通过操作数寻址得来。
如果是寄存器,一定是配套的,CS对应IP,SS对应SP
保护模式下分段管理
保护模式下的逻辑地址同样是段基址:偏移量。
只不过段基址变成了段选择子,用于在GDT中选择描述符,因此这种地址表示方式叫做虚拟地址,对应的空间叫做虚拟地址空间。
解释一下我们之前说的64TB空间怎么来的。比如 CS:EIP,CS是16位,EIP有32位,去掉两位RPL,剩下46位,246=64TB2^{46}=64TB246=64TB。
CS:EIP整体寻址流程(假设T1=0,默认GDT)
段描述符
GDT/LDT中都会有段描述符,一个段描述符有8字节,共64位。分32位基址,20位限长,8位访问权限,4位属性。
- 为什么这么稀碎,可能有历史原因,出于兼容性考虑。
- 段限长+1才是真正的限长。
- S(System),S=0代表系统段描述符,用户不可用,1代表代码段、数据段、堆栈段,用户可用
- E(Executable),在S=1的前提下,E=1代表代码段,可执行,E=0代表数据段、堆栈段,不可执行
段实际限长=(段限长值+1)×粒度-1
因为粒度为0的时候,+1-1抵消了,所以看起来就是段限长值。
表格内容很多,考试会给出表格,只需要会读就可以了,一定要区分好某个符号是占几位以及其含义,接下来举例:
出题的时候,一般会给出一大堆段寄存器,从CS到GS,然后给一个GDTR,再给出GDT的局部内容。
分析CS:
- CS为001B,即0000 0000 0001 1011,Index=3,T1=0代表GDT,RPL=3
- Index×8以后得出0x18H,这就是GDT内的偏移量了,配合GDT的基址可以得到该段的位置:E003F018H,从GDT中找出如下段描述符,对应段描述符结构表进行解析:
- 段基址和限长是小端表示,注意看好了顺序。
- 限长需要结合G(粒度)判断段大小。
- 之后判断其他段,重点是D,P,DPL,S,E。
32位CPU系统中三者的转换
下图就是我们上面各种参数的流程化。
首先把16位段选择符转成段描述符,解析出线性地址。
之后根据G是0还是1,判断是否分页
页式内存管理
分页
分页可以理解为将段再次细化,而且每个页的大小都是固定的,一般是4KB。
这样做有两个好处:
- 储存管理粒度更小,效率更高,可以选择性将一段的部分页装入内存,而段只能一次性全部装入。
- 免去一些太过精细的操作,提高操作效率。
缺点就是会浪费一点内存,但是显然好处更多。
线性地址转换为物理地址的过程
可以看到,这是逐层递进的寻址。
举例子:
- 页目录索引解析:取前10位0000 0010 00 即008H,然后×4偏移(20H)+页目录基址就可以得到页表描述符。其为32位,这也是前面×4偏移的原因,前20位为页表基址,其他位记录了各种描述信息。
- 从页表描述符中取前20位(前5个16进制数)基址+页表索引×4可得页描述符,页描述符同样是32位,所以和页表描述符统称为页表项。
- 从页描述符再取20位基址+页内索引=物理地址。注意业内索引不用乘,因为一个页内索引只对应1字节。
任务
任务执行环境
在多任务环境下,每个任务都有属于自己的任务状态段TSS,TSS中包含启动任务所必需的信息,如用户可访问的寄存器等 。
回顾前面的知识,一个16位的TS从GDT中选择一个TSS描述符,一个TSS描述符对应一个TSS。
TSS是任务状态段,但并不等同于任务执行环境,里面包含了一个共享的代码段,共享数据段,但是不同的优先级有不同的堆栈段:
任务状态段(TSS)
基本和别的没啥区别,本质上他就是GDT中的一个段,只不过有些值是固定的,比如S=0,还有就是B字段。
门
门用于转换,比如不同任务之间的调用,比如用户程序调用系统程序,转换过程中会考虑特权级。
门使用门描述符描述,是描述控制转移的入口点,是系统描述符,所以S=0。
调用门
描述符中,类型字段为4或者C表示调用门
调用们描述的是子程序的入口,所谓程序入口,就是程序的某一行代码,比如c语言的main。
所以调用门的选择符必须实现代码段选择符,同时偏移记录了代码段内偏移,两个结合可以定位到某一代码段的某一行代码去。
通过Call,可以实现段间,段内的同级甚至高级调用。比如系统调用。
任务门
任务门用于任务切换,任务门中的选择子指向TSS描述符。
中断门和陷阱门
略过,在第7章会讲到。
任务切换
直接任务切换
直接用
段间跳转指令JMP X:Y,或者段间调用指令CALL X:Y,这两个都可以直接指定一个TSS,但是需要进行权限检查,如果权限不足,那就只能通过任务门间接跳转
还可以使用中断。
间接任务切换
通过任务门实现。
保护
保护模式的保护,就在这里。
数据访问保护
- 类型检查。检查段描述符的段类型是否与目标一致。比如装入CS的时候,段描述符的E必须是1,表示为可执行段,这才能装入CS,而装入DS,SS,E必须为0。还有就是只有可写的数据段选择符,才能被加载到SS中。
- 限长,类型和属性检查。
- 特权级检查,重点。
举例:
下图中,最上面的不可以访问,其他两个可以访问。
注意这里DPL是数据段的DPL。
对程序的保护
DPL指代码段的DPL。
直接转移保护
不考虑RPL,因为RPL和CPL一致。
高优先级不直接调用低优先级。同优先级可以直接调用,低优先级调用高优先级之前要经过一致性检验。
举例:
C=1。很多系统调用就是C=1,因此可以被优先级更低的用户程序调用。
间接转移保护
略过
输入输出保护
略过
特权级综合例题
JMP命令对应CS代码段寄存器。取CS的最后两位得到CPL=3
目标的段基址为000A,解析出RPL=2,Index=1,T1=0,所以取GDT的第二个段描述符。
经过解析,得到该代码段的DPL=0,即CPL>DPL,所以这个相当于低优先级代码调用高优先级代码,所以要检查C(此时E=1,对应代码段,存在C这个字段),是否一致。结果C=0,所以不能跳转。
指令系统(重点)
指令一般分为三部分,操作符(操作码),目标操作数,源操作数。大多数情况下操作数只是给出地址,具体的内容需要寻址。
数据寻址方式
CPU操作数寻址
操作数来自于CPU内部。所以是没有物理地址的,因为都在CPU。
立即寻址
可以理解为常数。因为操作数直接就包含在指令中,其实不用寻址。
寄存器寻址
操作数储存在寄存器中,指定寄存器名字可以寻址确定操作数。
寄存器可以是通用8-32位寄存器,也可以是段寄存器。但是不能对CS赋值(代码段寄存器不能作为目标)
储存器操作数寻址
操作数来自于RAM,内存。
在写法上,与CPU的不同在于间接寻址都要在外面套括号。比如MOV AX, [BX]
直接寻址(两种写法)
把操作数的地址包含在指令中,就是直接寻址。这个地址是逻辑地址,是段超越前缀格式——段寄存器名:储存器寻址。
需要注意的是,段基址和寄存器偏移量需要配合。不过如果偏移量不是寄存器,那就随意指定段寄存器了。
具体做法就是用【】将地址数值括起来,如果不指定段寄存器,就默认DS,即从DS段中取00404011H偏移量开始,对应的4字节数据送到EAX里。之所以是4字节,是因为EAX有4字节,程序会从给定物理地址开始读取相应数量的字节。
因为直接给数字比较麻烦,参考C语言中的变量思想,于是可以用内存变量名储存地址数字。
VAR变量大致有DB,DW,DD三种,分别对应Byte,Word,Double Word,1,2,4字节。
实际上,没人会用数字直接去写,只有反汇编的时候才会有数字出现。
注意,要么写成MOV AX,[10H],要么写成MOV AX,VAR
不存在MOV AX,[VAR]这种写法。
寄存器间接寻址
将寄存器中的数据视作地址,去内存中寻找操作数。
注意区分:
- MOV AX,BX ,用BX中的值作为操作数(CPU级别 寄存器寻址)
- MOV AX,[BX],去内存中间接寻找操作数(储存器级别 寄存器间接寻址)
偏移量可用寄存器:
16位寻址的时候,寄存器只能是基址寄存器BX,BP(对SS),以及变址寄存器SI,DI,给出一个偏移,会根据寄存器对应关系确定其段寄存器。
32位寻址可以使用任意通用寄存器。
偏移量寄存器与段寄存器的搭配:
BP、EBP、ESP,则默认与SS段寄存器配合。
使用其它通用寄存器,则默认与DS段寄存器配合。
均允许使用段超越前缀。
寄存器相对寻址(两种写法)
MOV AX,TABLE [寄存器]
在寄存器间接寻址的基础上,再加一个相对偏移量(下图为8),所以可用寄存器以及寄存器搭配,与寄存器间接寻址方法一样:
但是从另一种角度看,实际也可以看做以8为段内基地址,在加上BX代表的偏移。一维数组就是这么工作的,TABLE给出数组首地址,寄存器储存了数组下标×元素长度(元素相对于数组首地址的字节偏移量)。
从上面看,TABLE可以是常数,自然也可以是VAR型。
注意,如果TABLE是使用VAR变量而不是常数,那么VAR变量在作为相对偏移的同时,还确定了目标空间的长度。
比如TABLE是DW类型的,那么就会去取一个字(2字节)的空间送到目标操作数,所以目标操作数应当是一个字(2字节)
基址变址寻址方式(重点考实模式)
相当于把前面的TABLE用寄存器内容代替。
基址寄存器——TABLE,可以用BX,BP
变址寄存器——前面的寄存器,可以用SI,DI
注意,一定是前面写基址寄存器,后面写变址寄存器。
可选寄存器:
16位情况下,就前面提到的,BX,BP,SI,DI
32位可以使用除ESP以外任何两个通用寄存器。
相对基址变址方法
经常用于二维数组。
如果ARY是2字节,第一个元素为00,第二个元素就得02,20也可以。
比例变址寻址方法
这种也很适合表示数组。系数就代表了元素的大小。
小结
AX不能当偏移,得BX,EX
(2)
溢出了(1280是10进制,1280H是16进制,但是即使只有10进制,1280也不是一字节可以放得下的)
(3)
两个都不能判断字节数,BYTE PTR[BX]
(4)
立即数不能给段寄存器,只能给通用寄存器。万一没有立即数这个空间。
所以只能用AX这类的。
(5)
[BX]指向内存,VAR是存在内存的,两个内存之间不可以直接赋值
(6)
和5一样
(7)
常数不作为目标操作数
(8)
基址+变址,都是变址就错了
(9)
CS不可直接赋值,只能用JMP
(10)
还没学TODO
数据运算指令
数据传送指令
通用数据传送
解释:
- 常数肯定不能被赋值
- 不可以随意用常数指定段寄存器,至少应该先送到寄存器中(段寄存器不可以给段寄存器赋值)
- CS是代码段,不可写
- 两个内存不能传
如果两个操作数都不能确定大小,就需要显示指定,比如WORD PRT表示,BX指向内存为WORD。或者用movq这种指令。此外还有movsx,movzx。
MOV AL,5
MOV DS,AX
MOV [BX],AX
MOV ES:VAR,12
MOV WORD PTR [BX],10
MOV EAX,EBX
mov其实就是赋值,有时候也会用到交换,汇编给出了最快的交换指令XCHG,两个操作数不能是立即数,也不能都是内存,其他情况都可以。
堆栈操作指令
但是实际上我们不直接对这些寄存器做加减,甚至不直接和段寄存器以及内存中段打交道,而是对操作数进行push和pop。
push是直接push一个数(寄存器或者立即数,但是立即数的大小难以确定,在286以上才支持),而不是操作SS和SP。pop一样,将一定字节的数存在DST中。
SRC和DST都可以是寄存器/储存器。
常见指令序列:PUSH AXPUSH BX……PUSH 1234H ;80286以上可用POP DX……POP BXPOP AX 注意堆栈的初始设置/堆栈异常。
IO指令
IO指令用于外设与内部之间的数据传送。
(1).输入指令 IN
格式: IN ACR,PORT
功能:把外设端口(PORT)的内容传送给累加器(ACR)。
说明:可以传送8位、16位、32位,相应的累加器选择AL、AX、EAX。
ACR只能是累加器,AL,AX,EAX,RAX
PORT是数值,或者寄存器,与MOV不同,这里用数值本身作为端口地址,效果是地址,但是形式是数值。所以端口用起来更像寄存器。
如果端口号超过一个字节,就得先放到DX寄存器中,再写到IN指令中。
IN AL,61H ;AL ←(61H端口)
IN AX,20H ;AX ←(20H端口)
MOV DX,3F8H ,超过一个字节的PORT,必须用DX
IN AL,DX ;AL ←(3F8H端口)
IN EAX,DX ;EAX ←(DX所指向的端口)
(2).输出指令 OUT
格式:OUT PORT,ACR
功能:把累加器的内容传送给外设端口。
说明:对累加器和端口号的选择限制同IN指令。
例.
MOV AL,0
OUT 61H,AL ;61H端口← (AL);关掉PC扬声器
MOV DX,3F8H
OUT DX,AL ;3F8H端口 ← (AL);向COM1端口输出一个字符
地址传送指令
传送有效地址指令 LEA
格式:LEA REG,SRC
功能:把源操作数的有效地址送给指定的寄存器。
说明:源操作数必须是存储器操作数。
因为要计算SRC的地址送到目标寄存器中,所以SRC必须是内存寻址,DST一定是寄存器。可以理解为先把地址计算出来丢到寄存器,以后用的时候就很方便。LEA实际上是计算,但是不会影响标志位。
例. LEA SI,TABLEA BX,TAB [SI]LEA DI,ASCTAB [BX] [SI]
二进制算数运算指令
类型转换指令
一般是低位到高位的扩展。将符号位扩展到高位:
符号位为0,扩展的就是0,符号位为1,扩展的就是1,总之是带符号的扩展。符号扩展可以用movs代替,如果想要零扩展,前面有movz命令。
字节扩展成字指令 CBW
格式:CBW
功能:把AL寄存器中的符号位值扩展到AH中
MOV AL,FFH
例. MOV AL,5CBW ;(AH)= 0,AL值不变MOV AL,98HCBW ;(AX)= 0FF98H,AL值不变
除了上面这种,还可以进行各种类型的转换。
(2).字扩展成双字指令 CWD
格式: CWD
功能:把AX的符号位值扩展到DX中
(3).双字扩展成四字指令 CDQ
格式:CDQ (386以上)
功能: EAX符号位扩展到EDX中
(4).AX符号位扩展到EAX指令 CWDE
格式: CWDE (386以上)
功能: AX寄存器符号位扩展到EAX高16位
加法指令
二进制加减法适用于有符号和无符号数。也就是说,ADD命令不管二进制数代表有符号数还是无符号数,这是人规定的,而如何判断就要通过标志寄存器了。
(1).加法指令 ADD
格式: ADD DST,SRC
功能:(DST)+(SRC)→ DST
说明:对操作数的限定同MOV指令。
标志:影响OF、SF、ZF、AF、PF、CF标志
例. ADD AL,BLADD CL,6ADD WORD PTR[BX],56ADD EDX,EAX
从下图中看,CF=1的时候,代表无符号数溢出,OF=1的时候,代表有符号数溢出。
(2).带进位加法指令 ADC
格式: ADC DST,SRC
功能:(DST)+(SRC)+ CF → DST
说明: 对操作数的限定同MOV指令,该指令适用于多字节或多字的加法运算。
标志: 影响OF、SF、ZF、AF、PF、CF标志
例. ADC AX,35;(AX)= (AX)+35+CF
ADC就是在加法的式子里增加了CF项。可以用多个ADC指令,将很长的几节无符号数看成整体递推相乘。
(3).加1指令 INC
格式:INC DST
功能:(DST)+1→DST
标志:除不影响CF标志外,影响其它五个算术运算特征标志。
例. INC BX例.实现+2操作:ADD AX,2 与INC AX INC AX 不等同,如果溢出,两次递增的CF位会在第二次丢失
inc不影响CF,这一点就和AX不等同。但是对其他标志位是有影响的。
(4).互换并加法指令 XADD(486以上)
格式:XADD DST,SRC
功能: (DST)+(SRC)→TEMP
(DST)→SRC
TEMP→DST
说明:TEMP是临时变量。该指令执行后,原DST的内容在SRC中,和在DST中。
标志:影响OF、SF、ZF、AF、PF、CF。
其实就是在求和的同时保留DST到SRC中。
减法指令
减法类似加法。
- SUB正常减,如果无符号数溢出(减出负数),则CF=1,有符号数溢出(上下都有可能),则OF=1。
- SBB带借位减法,(DST)-(SRC)-CF → DST,和ADC类似,一般配合多字节无符号数使用。
- DEC为自减,同样不影响CF。
(4).比较指令 CMP
格式:CMP DST,SRC
功能:(DST)-(SRC),影响标志位。
说明:这条指令执行相减操作后只根据结果设置标志位,并不改变两个操作数的原值,其它要求同SUB。CMP指令常用于比较两个数的大小。
标志:影响OF、SF、ZF、AF、PF、CF标志
(5).求补指令 NEG
格式: NEG DST(DST应当代表有符号数)
功能:对目标操作数(含符号位)求反加1,并且把结果送回目标。即:实现0-(DST)→DST
说明:利用NEG指令可实现求一个数的相反数。
标志:影响OF、SF、ZF、AF、PF、CF标志。
对CF和OF的影响如下:
a.对操作数所能表示的最小负数(例若操作数是8位则为-128)求补,原操作数不变,但OF被置1(对-128求反会出现有符号溢出,即OF=1。之所以源操作数不变,是因为1000 0000取反加一还是1000 0000)
b.当操作数为0时,清0 CF。
c.对非0操作数求补后,置1 CF。
例. 实现0 -(AL)的运算NEG AL例. EAX中存放一负数,求该数的绝对值NEG EAX
多字节相加例子
下面程序中,整体思路就是,对最低字节使用ADD指令,其他字节使用ADC。个人感觉,可以先把CF置零,之后直接用循环把所有字节用ADC处理。
具体到代码,sum,first,second分别指向三个数的最高字节,是VAR变量,用于储存器直接寻址。
关于写法:
- SECOND+2,而不是[SECOND+2],即使其+2,也仍然是VAR直接寻址,不可以加方括号。(存疑?TODO)
- 另一个点,为什么不能写成SECOND+BX?存疑?TODO
- INC,DEC可否换成ADD?不可以,因为不能影响CF
最后,这个程序的缺陷在于最高位进位没有考虑到,理论做法应该是给SUM留4字节。或者,3字节情况下可以判断最后一个字节计算后的OF/CF,来判断溢出。
LEA DI,SUM ;把和的地址指针送给DI寄存器
ADD DI,2 ;+2偏移后DI指向和的低字节
MOV BX,2 ;BX负责偏移MOV AL,FIRST[BX] ;取FIRST的低字节(本例为33H)
ADD AL,SECOND+2 ;两个低字节相加,和①在AL中,进位反映在CF中
MOV [DI],AL ;把低字节和存到DI指向的单元(本例为SUM+2单元)
DEC DI ;修改和指针,使其指向中字节
DEC BX;修改加数指针,使其指向中字节
MOV AL,FIRST[BX] ;取FIRST的中字节(本例为22H)
ADC AL,SECOND+1 ;两个中字节相加且加CF,和②在AL中,进位反映在CF中
MOV [DI],AL ;把中字节和存到DI指向的单元(本例为SUM+1单元)
DEC DI ;修改和指针,使其指向高字节
DEC BX;修改加数指针,使其指向高字节
MOV AL,FIRST[BX] ;取FIRST的高字节(本例为11H)
ADC AL,SECOND ;两个高字节相加且加CF,和③在AL中,进位反映在CF中
MOV [DI],AL ;把高字节和存到DI指向的单元(本例为SUM单元)
乘法指令
(1).无符号乘法指令 MUL
格式:MUL SRCreg/m
功能:实现两个无符号二进制数乘。
说明:该指令只含一个源操作数, 另一个乘数必须事前放在累加器中。可以实现8位、16位、32位无符号数乘。
具体操作为:
字节型乘法:(AL)×(SRC)8→AX
字型乘法: (AX)×(SRC)16→DX:AX
双字型乘法:(EAX)×(SRC)32→EDX:EAX
标志比较特殊,仅仅是代表高半部分有没有被影响:对CF和OF的影响是:若乘积的高半部分(例字节型乘法结果的AH)为0则对CF和OF清0,否则置CF和OF为1,代表高半部分被影响,结果如果还保持原来的长度,那就要进行截断了。
最关键的事情就是如何区分不同位的乘。在汇编中,不会给你自动匹配累加器的大小,所以即使你累加器指定了AL,然后再把立即数当MUL的操作数,也是不可以的。即,操作数和累加器大小要匹配。
累加器指定大小后,操作数只能有两种可能,立即数是不被允许的:
- 要么是同大小的寄存器
- 要么就是声明指向区域大小的储存器寻址。
例. MOV AL,8MUL BL ;(AL)×(BL),结果在AX中MOV AX,1234HMUL WORD PTR [BX] ;(AX)×([BX]),结果在DX:AX中MOV AL,80HSUB AH,AH ;清0 AH(清零方法之一,还有MOV AH,0,逻辑运算,甚至可以用0去乘)MUL BX ;(AX)×(BX),结果在DX:AX中
(2).带符号乘法指令 IMUL
功能:实现两个带符号二进制数乘。
格式1:IMUL SRCreg/m
说明:这种格式的指令除了是实现两个带符号数相乘且结果为带符号数外,其它与MUL指令相同。所有的80X86 CPU都支持这种格式。
其实MUL和IMUL的区别仅仅是处理与解释方式不同,传进来的操作数是一视同仁的。
除法指令
进行二进制除法前,需要先将被除数的低半段放到累加器中,高半段放到另一个指定的辅助寄存器中。
之后指定SRC即可计算结果。
结果的商存放在低半段中(累加器),余数放在高半段中(辅助寄存器)
关于指定宽度的要求,对于DIV和IDIV来说,与乘法是一样的。
结果的标志位是不确定的,无意义。若除数为0或商超出操作数所表示的范围(其实也是除数太小导致的,所以统一叫除零错误)(例如字节型除法的商超出8位)会产生除法错中断,尽量提前判断避免这种情况,防止除数为0或者除数太小。
//单字节
例. 实现1000÷25的无符号数除法。MOV AX,1000MOV BL,25DIV BL;(AX)÷(BL)、商在AL中、余数在AH中
//字
例. 实现1000÷512的无符号数除法。MOV AX,1000SUB DX,DX ; 清0 DXMOV BX,512DIV BX;(DX:AX)÷(BX)、商在AX中、余数在DX中例. 实现(-1000)÷(+25)的带符号数除法。MOV AX,-1000MOV BL,25IDIV BL;(AX)÷(BL)、商在AL中、余数在AH中
例. 实现1000÷(-512)的带符号数除法。MOV AX,1000CWD ; AX的符号扩展到DXMOV BX,-512IDIV BX;(DX:AX)÷(BX)、商在AX中、余数在DX中
位运算指令
逻辑运算指令
逻辑非是一元运算符,其实不算计算,所以不会影响标志位。
但是其他二元计算,都会影响标志位。因为按位的逻辑运算肯定不会溢出,所以CF和OF一定是0。但是其他位不定。
AND指令和TEST都是按位与,但是逻辑与最后把结果存在DST,而TEST仅做计算,不影响操作数。
XOR指令玩法很多:
- 加密解密。x对一个key连用两次XOR指令,结果还是x,所以异或经常用于加密和解密。
- 与1异或相当于按位取反
- 自己与自己异或相当于清零
- 异或可以用来交换两个数,其不需要第三个数承载
位测试指令
基本移位指令
SHL和SAL一样。SHR和SAR不同,SH代表shift,逻辑,SA代表arithmetic,算数移位。
关于DST和CNT的限制:
- DST一般都是寄存器,如果是储存器操作数可能需要指定大小。
- CNT,1就直接写,不是1就存CL后再移动(286以后的可以直接写立即数)
例. 设无符号数X在AL中,用移位指令实现X×10的运算。
MOV AH,0 ;为了保证不溢出,将AL扩展为字
SHL AX,1 ;求得2X
MOV BX,AX ;暂存2X
MOV CL,2 ;设置移位次数
SHL AX,CL ;求得8X
ADD AX,BX ;10X=8X+2X
循环移位指令
循环移动因为不存在补位,所以也就不存在逻辑还是算数了。所以循环移位就是ROL和ROR(Rotation)
带进位和普通循环的区别是,普通循环是直接把移出位弄到补充位上了,带进位的是先把这一次移出的位放到CF,然后把上一次的CF放到补充位上。是用CF做一个缓冲。
例. 把CX:BX:AX一组寄存器中的48位数据左移一个二进制位。SHL AX,1 ;此时CF是这次移出的位RCL BX,1 ;此时将CF中位(上一次移出的位)补充到这一次的末尾RCL CX,1在没有溢出的情况下,以上程序实现了2×( CX:BX:AX)→CX:BX:AX的功能。
程序控制指令
以前的寻址是数据寻址。本章的寻址是程序寻址,对应的是CS段与IP指针的变化。
程序寻址
段内直接转移
分为短转移和近转移。两种转移的机制都是相对跳转。区别仅仅在于给出的立即数是8位或者16位的。
段内间接跳转
把有效地址存在寄存器或者内存中,跳转到这个内容指向的地方。
相比于段内直接跳转,间接跳转是直接把16位的转向地址赋给了IP,而不是偏移。
具体说明。
设:(DS)= 2000H,(BX)=0300H (IP)=0100H,(20300H)=000BH (20301H)=0005H
例1. JMP BX ;
执行后(IP)=(BX)= 0300H
例2. JMP WORD PTR [BX]
说明:式中WORD PTR [BX]表示BX指向一个字型内存单元。
- 这条指令执行时,先按照操作数寻址方式得到存放转移地址的内存单元:10H ×(DS)+(BX)= 20306H
- 再从该单元中得到转移地址:
EA=(20306H)= 050BH - 于是,(IP)=EA=050BH,下一次便执行CS:50BH处的指令,实现了段内间接转移。
10H ×(CS)+(050bh)= 20306H
段间直接转移
段间转移肯定要一次性把CS和IP都赋值,所以需要32位立即数。
请注意赋值顺序。字节0是JMP,字节1到字节4从低到高,先储存偏移量低再储存偏移量高。之后段基址也是如此,符合小端法储存。
如果把4个字节反写,就是CS:IP了。比如JMP 12 34 56 78实际上对应78 56: 34 12
段间间接转移
类似于段内间接,就是从寄存器或者内存中去找跳转目标。
具体数据说明:
设:(DS)= 2000H,(BX)= 0300H
(IP)= 0100H,
(20300H)= 0
(20301H)= 05H
(20302H)= 10H
(20303H)= 60H
则: JMP DWORD PTR [BX]
说明:式中DWORD PTR [BX]表示BX指向一个双字变量。
这条指令执行时,先按照与操作数有关的寻址方式得到存放转移地址的内存单元:
10H×(DS)+(BX)= 20300H
再把该单元中的低字送给IP,高字送给CS,即0500H →IP,6010H→CS,下一次便执行6010:0500H处的指令,实现了段间间接转移。
转移指令
无条件转移JMP
JMP DST
如我们前面所描述的,DST是程序寻址的任何一种方式。
- 段内转移(IP)
- 段内直接短转移 JMP SHORT LABEL
- 段内直接转移 JMP LABEL 或 JMP NEAR PTR LABEL
- 段内间接转移 JMP REG/M
- 段间转移(CS:IP)
- 段间直接转移 JMP FAR PTR LABEL
- 段间间接转移 JMP DWORD PTR M
举例:
可以看到如下规律:
- 段内短转移和近转移,都是偏移量,对应有符号数。其他转移是地址,对应无符号数。
- JMP后跟立即数/LABEL默认为近转移,JMP后跟寄存器或者内存,默认段内间接转移。
- 段间间接转移不可以用寄存器寻址(段内可以)。段内和段间都不可以用立即数寻址。
- 段间转移以及非默认段内转移(SHORT)都需要声明,比如FAR PTR对应LABEL,DWORD PTR对应内存
① 段内直接短转移格式:JMP SHORT LABEL例.JMP SHORT B1 ;无条件转移到B1标号处
A1: ADD AX,BX
B1: …② 段内直接转移格式:JMP LABEL或: JMP NEAR PTR LABEL 例.JMP B2A2: ADD AX,CX…B2: SUB AX,CX③ 段内间接转移格式:JMP REG/M例. LEA BX,B2JMP BXA2: ADD AX,CX…B2: SUB AX,CX④ 段间直接转移
例. CODE1 SEGMENT…JMP FAR PTR B3…CODE1 ENDSCODE2 SEGMENT…B3: SUB AX,BX …CODE2 ENDS ⑤ 段间间接转移
例. V DD B3C1 SEGMENT…JMP DWORD PTR V…C1 ENDSC2 SEGMENT…B3: SUB AX,CX …C2 ENDS
条件转移
在直接转移前,针对符号位进行条件判断。
条件转移的写法是JCC LABEL,其中的Condition有多种,但是主要还是S,Z/E(以及后面的复杂组合),因为条件大多数是判断大小的。
例. 比较AX和BX寄存器中的内容,若相等执行ACT1,不等执行ACT2。CMP AX,BX JE ACT1 ACT2: . . . ACT1: …
对于有符号数跳转,用G和L,对应Greater和Less。
对于无符号数跳转,用A和B,对应Above和Below。
这里要单独拿出来测试条件进行分析,比如CMP B,A后用JG跳转,为什么ZF=0且SF=OF就是大于了?
当ZF=0,则说明cmp结果非0,必然有大有小
当SF=OF,如果SF=OF=0,则说明A-B非负,而且没有溢出,这是正常的结果。如果SF=OF=1,说明结果为负,但是OF=1代表这是溢出后为负,那么说明溢出前为正,必然是因为A是大正数,B是负数,导致相减后溢出的,这也是A>B的情况。
综上,JG命令考虑到了正常的相减以及极端情况下带溢出的相减。其他命令的分析也类似,考试的时候会给出测试条件,让你分析合理性。
最后就是CX测试指令,一般只会在循环中判断CX的值,如果是0就转移,如果不是0就继续。且只能用于短转移。
具体有JCXZ和JECXZ。
举例
例.设M=(EDX:EAX),N=(EBX:ECX),比较两个64位数,若M>N,则转向DMAX,否则转向DMIN。
要区分有符号和无符号,对应的JCC要变。
首先判断高位数大小,如果大就直接走MAX,小就走MIN,否则高位相等。
再比较低位,因为高位是相等的,无论是正数还是负数,后半截始终是和整体大小保持一致的。
循环指令
LOOP,使用CX或者ECX作为默认计数器,隐形地执行DO LOOP循环。
MOV CX,2
MOV AX,1
LAB:ADD AX,AXLOOP LAB
LOOP看起来和CX没关系,但是实际上每次执行LOOP语句都会把CX-1,然后判断CX是否为0,如果是0就退出循环。这实际上就是个do loop,CX定多少,就执行多少次循环。
那么有一种有趣的现象出现了,假如赋值CX=0,那么就会执行很多次:
第一次执行后,CX=CX−1=216−1CX=CX-1=2^{16}-1CX=CX−1=216−1,之后继续执行216−12^{16}-1216−1次,总共就是2162^{16}216次,很有趣,这是do loop的特殊性质,先执行,再减,再判断。
MOV CX,0
MOV AX,1
LAB:ADD AX,AXLOOP LAB
最后给出一个带特殊跳转判断的累乘:
×N其实就是N次累加。
这个例子还想说明的事情就是溢出,乘法很容易溢出,还有就是N是负数的情况(CX默认为正),总的来说,其实程序可能出现的漏洞还是挺多的,破坏一个程序很容易,但是要想让他为你所用,就需要一定的技术含量了。
例. 用累加的方法实现M×N,并把结果保存到RESULT单元。MOV AX,0 ;清0累加器MOV BX,MCMP BX,0 JZ TERM ;被乘数为0转MOV CX,NJCXZ TERM ;乘数为0转L1: ADD AX,BXLOOP L1TERM: MOV RESULT,AX ;保存结果
子程序调用及返回指令
跳转和调用的区别在于,调用要先跳转,再返回。
调用使用CALL命令。基本和跳转用法类似,但是有一点点不同。
段内调用
与JMP相同,例外是不支持SHORT寻址,也就是说都是NEAR PTR或者16位寄存器。
在跳转前,先会将16位IP压栈。
具体无非就是用LABEL(直接)或者REG/M(间接),和JMP用法相同,CALL默认NEAR PTR。
直接跳转:
CALL LABEL
CALL NEAR PTR LABEL
间接:
例. 可以把子程序入口地址的偏移量送给通用寄存器或内存单元,通过它们实现段内间接调用。
CALL WORD PTR BX
;子程序入口地址的偏移量在BX中
CALL WORD PTR [BX]
;子程序入口地址的偏移量在数据段的BX所指
;向的内存单元中
CALL WORD PTR [BX][SI]
;子程序入口地址的偏移量在数据段的BX+SI
;所指向的内存单元中
段间调用
段间调用就是在压栈的时候多压入CS字。
用法同样如JMP
直接调用:
CALL FAR PTR PROCEDURE间接调用:(段间不可以用寄存器寻址,所以只能内存寻址)
CALL DWORD PTR VAR
CALL DWORD PTR [BX]
返回
一般返回用RET即可,会恢复IP和CS。
至于段内返回还是段间返回(决定了是否额外弹出CS字段),不需要人去管,汇编会产生不同的机器码。你也可以手动写成RETF(F:far)
除RET以外,还有RET imm16imm_{16}imm16
这是因为,有时候在执行调用前会构造栈帧传参(用栈传参),那么要想恢复原来的状态,不仅仅要把栈中的返回地址弹出到IP和CS中,还要把参数清理掉,即给RET增加偏移量,这个偏移量是16位的。比如使用栈传入了2个int型数,那么在ret的时候就要用 RET 8
中断调用及返回指令
中断
所谓中断,就是对当前程序的中断。之所以要中断,可能是因为缺少数据(比如Input函数等待输入),也可能是某个资源被占用了,现在卡住了。总之,中断场景无处不在,中断与等待密切相关,先中断,再恢复。
中断本身也都是程序,被称作中断子程序,一般有256个,这些程序可以对当前程序可能面临的各种异常情况进行处理,每一类中断程序都可以处理一种异常,比如标号为3的中断用于处理断点。
这里给出一些概念:
- 中断向量:中断处理子程序的入口地址。在PC机中规定中断处理子程序为FAR型,所以每个中断向量占用4个字节,写法同JMP的段间跳转。
- 中断类型号:IBM PC机共支持256种中断,相应编号为0~255,把这些编号称为中断类型号。
- 256种中断有256个中断向量。把这些中断向量按照中断类型号由小到大的顺序排列,形成中断向量表。表长为4×256= 1024字节,该表从内存的0000:0000地址开始存放,从内存最低端开始存放
可见,n类中断的IP位置为4n,CS位置为4n+2
中断调用
INT n
n对应中断类型号。中断调用需要保留断点信息,即FLAGS,CS,IP。
注意INT 80和INT 50H是等价的,注意区分立即数的进制。
INT 21H21H为系统功能调用中断,执行时把当前的FLAGS、CS、IP值依次压入堆栈,并从中断向量表的84H处取出21H类中断向量,其中(84H)→IP,(86H)→CS,转去执行中断处理子程序。例. INT 3 ;断点中断
中断返回
IRET(I:interrupt)
弹出栈内保存的信息,返回断点。这个基本不用,因为中断返回是写在中断程序中的,不用你操心。
处理机控制指令
指令可以控制处理机状态,不用细究
标志位控制指令
标志位是不可以直接赋值的,只能通过指令处理。
STX/CLX命令。X可以是D(对应DF),C(CF)。CL是清0,ST是置1。
其他(略)
略过
块操作指令
块操作指令和串指令,分不清,总之就是批量处理数据,而且是在两个内存之间批量倒。比如以前一个mov只能转移一个数据,现在可以连续转移100个数据。
具体过程为,先确定两个段基址,然后确定两个段起始偏移。之后确定重复次数,重复搬运。
这一块不太重要,只需要掌握几个基本命令即可。
MOVS示例
串传送指令 MOVS:
显式格式:MOVS DST,SRC(一般不用显示指令,因为这个和寄存器寻址看起来一样,容易混淆,实际上在串指令中,这两个都对应内存地址)
隐含格式:MOVSB MOVSW MOVSD
功能:源→目标,即([SI])→ES:[DI],且自动修改SI、DI指针。
修改SI,DI指针,方向由DF决定,0就是正常,1就是反向;大小由MOVSX的X决定,如果是MOVSB使SI、DI各减1; MOVSW使SI、DI各减2; MOVSD使SI、DI各减4。
REP指令,为什么不用LOOP?实际上这两个感觉没啥区别,大概REP比较方便,不需要指定标签,当然也只针对一个命令。
例:把自AREA1开始的100个字数据传送到AREA2开始的区域中。MOV AX,SEG AREA1)//先把段基址以AX为介质,加载到DS,ES中MOV DS,AXMOV AX,SEG(AREA2)MOV ES,AXLEA SI,AREA1 //将段偏移初始化LEA DI,AREA2MOV CX,100 //循环次数CLD //保证DF=0REP MOVSW //重复移动,每次移动一个word;100个字数据传送完毕后执行下一条指令
LODS、STOS示例
MOVS是直接把一个内存中数据送到另一个区域,一般搭配REP指令。
如果想要在中间对数据进行处理,就需要寄存器缓冲。先从SRC取出到寄存器,处理后再从寄存器送到DST,搭配LOOP指令。
取串指令 LODS
显式格式:LODS SRC
隐含格式:LODSB LODSW LODSD
功能:源→累加器,即([SI]) →AL(或AX、EAX),且自动修改SI指针。
说明:若DF=0,则LODSB(或LODSW、LODSD)使SI加1(或2、4);若DF=1,则LODSB(或LODSW、LODSD)使SI减1(或2、4)。若地址长度是32位的,则SI相应为ESI。
存串指令 STOS
显式格式:STOS DST
隐含格式:STOSB STOSW STOSD
功能:累加器→目标,即(AL(或AX、EAX))→ES:[DI],且自动修改DI指针。
说明:若DF=0,则STOSB(或STOSW、STOSD)使DI加1(或2、4);若DF=1,则STOSB(或STOSW、STOSD)使DI减1(或2、4)。若地址长度是32位的,则DI相应为EDI。
例:把自NUM1开始的未压缩BCD码字符串转换成ASCII码,并放到NUM2中,字符串长度为8字节。设DS、ES已按要求设置。LEA SI,NUM1LEA DI,NUM2MOV CX,8 //循环8次CLD //确保DF=0LOP: LODSB //取Byte到ALOR AL,30H STOSB //把AL送到DSTLOOP LOP
汇编程序开发
(1)【重点讲解】汇编语言编程基本知识、Windows汇编语言程序设计
(2)【重点讲解】分支与循环程序设计、浮点运算
(3)【一般性讲解】程序优化
汇编代码初步实践
dos虚拟机与挂载
实模式比较危险,所以用虚拟机。
首先准备dos box工具,这个相当于在我们电脑上新建一个dos模拟器。
准备好工作目录,建议不要用中文路径,我已经把路径名修改成英文的了,说不准会出什么问题:
先把计算机中的工作目录挂载到dos虚拟机中,挂载点为虚拟机的C盘。
说白了就是把实际计算机的文件导入到虚拟机中。
mount c D:\assembly-tool\real
之后切到虚拟机C盘,dir列出目录看一下,导入成功:
C:
dir
实模式Hello World
代码解析
目录中,PROG1.asm就是我们的第一个汇编程序,现在主机中用记事本或者vscode打开,先看看代码是怎么写的:
其实程序架构还是比较固定的,SEGMENT与ENDS中间的代码表示这是一段。
DATA就是数据段:
- 从MSG标签开始的内存里,排列了Byte型的若干数据
CODE就是代码段,代码段先做出假设(ASSUME),把CS和CODE关联起来,然后再把DS与DATA段关联起来。
代码段里目前只有一个主函数MAIN。至于MAIN PROC FAR这个写法是什么意义暂且不谈:
- 先把DATA(数据段地址)送到AX中,再把AX送到DS中。这两步不能合并
- 配置AX,配置DX,其中DX储存了要打印信息的首地址
- INT命令调用21号中断,参数就是前面的寄存器。
- 之后再次配置,再次调用
汇编
现在相当于编好了程序,我们来汇编一下:
- masm是汇编命令,只需要指定源代码即可
- 之后会问你三个问题:
- 目标代码名字,默认与源码一样
- lst中间文件,默认不生成,这里我们改名让他生成
- crf中间文件,默认不生成,这里我们让她生成
看一眼lst文件,可见,这个文件显示了汇编的详细信息
crf不用看了,没什么可用信息。
Microsoft (R) Macro Assembler Version 5.00 11/25/22 12:02:35Page 1-11 0000 DATA SEGMENT 2 0000 54 48 45 20 46 49 52 MES DB 'THE FIRS$T PROGRAM!','$' 3 53 24 54 20 50 52 4F 4 47 52 41 4D 21 24 5 0014 DATA ENDS 6 7 0000 CODE SEGMENT 8 ASSUME CS:CODE,DS:DATA 9 0000 MAIN PROC FAR 10 0000 B8 ---- R MOV AX,DATA 11 0003 8E D8 MOV DS,AX 12 ;MOV DS,DATA 13 14 0005 B0 AB MOV AL,0ABH 15 0007 B4 09 MOV AH,09H 16 0009 8D 16 0000 R LEA DX,MES 17 000D CD 21 INT 21H 18 19 000F B8 4C00 MOV AX,4c00H 20 0012 CD 21 INT 21H 21 0014 MAIN ENDP 22 0014 CODE ENDS 23 END MAIN
Microsoft (R) Macro Assembler Version 5.00 11/25/22 12:02:35Symbols-1Segments and Groups:N a m e Length Align Combine ClassCODE . . . . . . . . . . . . . . 0014 PARA NONE
DATA . . . . . . . . . . . . . . 0014 PARA NONE Symbols: N a m e Type Value AttrMAIN . . . . . . . . . . . . . . F PROC 0000 CODE Length = 0014
MES . . . . . . . . . . . . . . L BYTE 0000 DATA@FILENAME . . . . . . . . . . . TEXT prog1 21 Source Lines21 Total Lines6 Symbols50672 + 465872 Bytes symbol space free0 Warning Errors0 Severe Errors
链接
现在我们有obj文件,要把他变成exe文件,就要链接。
同样是默认默认默认。
不过这里有个warning,说没定义堆栈段,虽然我们这里不用堆栈,但是规范的代码应该定义。
去windows主机上找到prog1.exe,双击运行,跑不了。很正常,因为这是在16位虚拟机上汇编出来的,平台不同。
结果有点奇怪是不是,我们当时定义的可比这个长多了,为什么截止了?
一看代码,里面有$符号,在汇编中,这个符号是字符串结束的表示,类似于C语言中的\0,所以删去中间的符号即可完全打印。
注意,$是21号中断的输出截止符,而\0是字符串的截止符,和$不是一个东西。
# MES DB 'THE FIRS$T PROGRAM!','$'
MES DB 'THE FIRST PROGRAM!','$'
但是问题来了,我们又要跑一次流程,挺麻烦的,有没有一次性输入一大堆命令的工具呢?就是批处理
重新汇编与批处理
BAT文件是批处理文件,用于批量输入规定好的命令,也就是所谓的批量处理。
打开prog1.BAT文件就可以看到之前的编译命令。
运行批处理文件,然后一个劲按回车就可以直接走完全部流程,可以看到我们完全打印出来了结果:
反汇编与debug
将代码修改一下,增加一些数据,ABC三个数,目标是用A+B算出C,之后我们用反汇编玩一玩这些数据:
有一个注意点:
- 实模式是16位的,所以要用DW,AX
DATA SEGMENT
MES DB 'THE FIRST PROGRAM!','$'
A DW 123
B DW 456
C DW ? ;先啥都不放,后面算出来放过来
DATA ENDSCODE SEGMENTASSUME CS:CODE,DS:DATA
MAIN PROC FARMOV AX,DATAMOV DS,AX;MOV DS,DATAMOV AL,0ABHMOV AH,09HLEA DX,MESINT 21HMOV AX,A ;计算C=A+BADD AX,BMOV C,AXMOV AX,4c00HINT 21H
MAIN ENDP
CODE ENDSEND MAIN
如果你直接执行,会发现和前面那个没啥区别,这是因为,我们压根就没输出,想输出就调用21号中断,但是我们debug更多地是用debug工具去跟踪调试。
对一个exe文件使用debug命令进入分析模式:
u命令列出等待执行的所有指令,执行完的指令不会出现在列表里:
可以看到:
- ABC这三个VAR变量,其实就是直接寻址。
- 所有label都已经被变成了地址,比如DS变成了076AH。
- 指令中的立即数是小端记法
t命令执行一条指令,并且打印出执行后的各种寄存器,状态位,以及下一条待执行指令:
可以看到,AX已经变成了076AH(DATA)
再执行一步,就可以看到DS已经被赋值。
后面的代码就是打印一句话,对我们的调试没啥用,我们关心的是ABC数据,所以我们先把ABC打印出来。
d命令,给定地址(段基址:偏移),打印从该地址开始的一系列内容。我们把数据段地址给他,他会把数据段内容打印出来:
- 首先是一串问候语
- 24就是$,代表字符串结尾
- 7B 00其实就是007BH,即123,C8 01其实就是01C8H,即456。后面的C是00 00,其实就是?
看完数据以后,建议直接跳过21号中断,不然可能跟踪地太深入出不来
g命令就是goto,跳转到目标指令:
调试会一次性执行完中间的部分,跳到我们的目标位置。
从现在开始计算C=A+B,连走三步,看看过程:
指令里都出现了立即数寻址,默认段寄存器是DS:
- 指令先把DS:013H的数丢到AX中
- 之后把DS:015H的数加到AX中
- 最后把AX赋给DS:017H。
如果你感觉这三个数字比较奇怪,那你可以在数据段中数字节,A就是13,14字节,B是15,16,C是17,18。
用d命令看看运算结果:
可以看到,C的位置已经被43 02填充,转换成数字就是0243H=007BH+01C8H,即579=123+456。
完美。
保护模式Hello World
保护模式在protect目录中,保护模式可以直接在windows上跑。因为保护模式对地址有一些隐蔽,所以更加简单一些。而实模式要跟踪地址,费心费力。
我们的知识点基本都是实模式的,比较好讲,但是实际开发都是保护模式,效率比较高,省心。
保护模式下的prog3.asm:
- .386是伪指令,告诉你这是在386CPU上跑
- option可选项,casemap:none代表不区分大小写
- includelib是导入汇编库
- printf这一句定义了其函数原型(参数列表)
看一下执行这段代码的批处理文件写法:
汇编和链接环节是一样的,只不过用的命令不一样,参数写法也不一样。
最后cmd进入目录执行一下,注意要用cmd,直接点的话他不会保持控制台输出。
VS2017+masm32环境配置
详见课本的附录,或者自行去搜索TODO
汇编基础
语言概述
机器语言是二进制的命令,汇编是机器语言的符号化表示,高级语言建立在汇编之上,更像人类语言。
C语言最开始出名是其可移植性(STL标准库),后面和其他高级语言对比才显现出其速度优势。
汇编环境
平 台:
Intel 80X86/Pentium
DOS/虚拟8086模式(V86)
Windows/保护模式
MASM5.1 MASM6.11 MASM32
实模式:
上机过程:masm→link→.exe / .com
编辑:temp.asm
汇编:masm temp.asm→temp.obj
连接:link temp.obj→temp.exe
调试方式:Debug
保护模式(VS集成开发环境)
上机过程:ml→link
编辑:temp.asm
汇编:ml /c /coff hello.asm→hello.obj
连接:link /subsystem:console[windows] hello.obj→hello.exe
调试方式:WinDebug
汇编语言语句格式
汇编语句分为三种:
- 指令: 实际的指令。每条指令语句都生成机器代码,各对应一种CPU操作,在程序运行时执行。
- 伪指令: 汇编期间计算的指令。伪指令语句由汇编程序在汇编过程中执行,数据定义语句分配存储空间,其它伪指令不生成目标码。
- 宏指令: 宏替换的指令。宏指令是用户按照宏定义格式编写的一段程序,可以包含指令、伪指令、甚至其他宏指令。
汇编语言的4个成分:
- 名字:其实就是label,是以符号形式储存的地址,起名格式类似于变量名,用的时候也有一种变量名的感觉,但是似是而非,并不等同。
- 助记符:指令固定的名字,比如MOV,SUB
常用伪指令
数据定义
格式:[变量名] 助记符 操作数
功能:为变量分配单元,并为其初始化或者只预留空间。
类似于声明一个变量,这是唯一分配空间的伪指令。
- 变量名:实际上是一个地址,用于引用数据。当然也可以没有,因为还可以通过其他变量名(标签)引用这个数据。这和C语言的变量名就很不相同,本质上来说,是因为我们这里的数据是顺序排列的,而且变量名的地址配合偏移可以访问任意数据。
- 助记符:声明数据类型,即一个数据的空间。DB,DW,DD,DQ最常用
- 操作数:形式很多。
- 数字常量,数值表达式。默认10进制,其他进制要加后缀
- 字符串常量。汇编不区分字符和字符串,统一用单引号。每个字符占1字节空间
- 地址表达式。如果变量储存地址表达式,那这个变量实际就是个指针。需要注意的是,如果只储存偏移,就用DW,如果还要储存段基址,就用DD。offset+标签很神奇,他会根据你的数据类型自动转换出对应长度的地址。比如VAR DW offset LAB就是只有偏移量的情况。VAR DD offset LAB就是偏移量和基址的情况。
- ?代表不确定,只预留空间,但是不赋初值。
- <n> dup(重复内容),将重复内容重复n次。dup可以嵌套
下面举例:
- M1:除了字符串和DUP以外,其他的,每用逗号分割就是一个数,只要不越界,就会占用一个空间
- M2,M4:立即数是反序的,符合小端法表示,但是字符串是正序的(因为一次储存一个字节,不会有反序问题),而且字符串也不存在越界问题。字符串的助记符一般固定为DB,不需要考虑特殊情况。
- M5:DUP重复
定义了数据,如何使用?关键在于认识到标签的本质是VAR型变量,储存的是地址。
LEA DX, M4+2 LEA计算地址,DX储存的就是M4本身的值+2(地址)
MOV CX, M4+2 MOV这里使用内存直接寻址方法,CX储存的是M4+2对应的空间。这里看起来和变量用法一样,但是注意这里本质上是直接寻址。
给出例子:
可直接通过变量名引用变量(立即寻址),但要注意类型匹配。例如以下程序片段:MOV AL,M1 ;(AL)= 15MOV BX,M3 ;(BX)= 20ADD M3,6 ;(M3)= 26 ;这里M3是一个DW PTRMOV AL,M2 ;(AL)=’1’=31H MOV BL,M2+2 ;(BL)=’A’=41HMOV M1+3,BL ;(M1+3)= 41H
关于空间的讨论,这里给出例题:
符号定义
等值EQU伪指令
格式:符号名 EQU 表达式
功能:用符号名代表表达式或表达式的值。
说明:表达式可以是任何有效的操作数格式。例如常数、数值表达式、另一符号名或助记符。
注意:用EQU定义的符号在同一个程序中不能再定义,比如下图就是错的
效果上来说,我感觉这个就是汇编期间的计算+符号宏替换。有些操作是直接计算,尤其是加了运算伪指令的(下面的msg例子),也有的是直接替换,比如那个B操作,直接替换成了[BP+6]
例.
CR EQU 0DH ;回车符的ASCII值
LF EQU 0AH ;换行符的ASCII值
BEL EQU 07H ;响铃符的ASCII值
PORT_B EQU 61H ;定义PORT_ B端口
B EQU [BP+6] ;[BP+6]用B表示
程序中可以通过符号引用这些值,例如:
MOV AL,CR ;等价于 MOV AL, 0DH
ADD BL,B ;等价于 ADD BL,[BP+6])
OUT PORT_B,AL ;输出到61H端口
EQU还可以用于计算字符串长度(注意是在汇编期间),$的含义是当前语句的首地址,所以$-msg就是字符串的空间长度。假设中间是DW的几个数据,那就写($-msg)/2,总之可以在汇编期间计算出长度。
MSG DB ‘This is first string.’Count equ $-msgMov cl,count ;(CL)=MSG的串长=21
等号(=)伪指令
格式:符号名 = 数值表达式
功能:用符号名代替数值表达式的值
等号伪指令与EQU伪指令功能相似,其区别:
- 等号伪指令的表达式只能是常数或数值表达式。
- 可以再定义。通常在程序中用“=”定义常数。
DPL1 = 20H ;只能是常数
K = 1
K = K+1 ;可以反复定义
操作符
操作符伪指令也有+,-,AND,OR。和真正的指令区别在于,操作符伪指令是汇编期间计算的,而真正的操作符指令是运算时期计算的。
操作符伪指令可以嵌入到data和code段。
$
给出当前语句的首地址
MSG DB ‘This is first string.’Count equ $-msgMov cl,count ;(CL)=MSG的串长=21wVar WORD 0102h, 1000, 100*100BYTESOFWVAR EQU $-wVar ;值等于6MOV EAX, $ ;将伪指令嵌入code段
OFFSET
平时要是想取出一个地址赋值到寄存器,需要用LEA命令。用伪指令修饰的OFFSET VAR提供了另一种直接用地址的方法
格式:offset [变量|标号]
功能:offset操作符用来取出变量或标号的地址(在段中的偏移量)。在32位编程环境中,地址是一个32位的数。
MOV EBX, dVar2 ;直接寻址
MOV EBX, offset dVar2 ;将地址送到EBX,相当于LEA
LEA EBX, dVar2 ;等价
算术操作符
+、-、*、/和MOD,可以用在数值表达式或地址表达式中。
例.X DW 12,34,56CT EQU ($-X)/2MOV CX ,CT ;(CX)= 3MOV AX ,XADD AX ,X+2 ;(AX)= 46
逻辑操作符
逻辑操作符包括AND、OR、XOR和NOT。逻辑操作符是按位操作的,它只能用在数值表达式中。 仍然是汇编期间计算,汇编后是看不到这些伪指令的。
PORT EQU 0FH
AND DL,PORT AND 0FEH
汇编后: AND DL,0EH
关系操作符
关系操作符包括EQ、NE、LT、LE、GT、GE。其操作结果为一个逻辑值,若关系成立结果为真(全1),否则结果为假(0)。
注意是全1,比如0FFH。
例.指令 MOV AL,CH LT 20的汇编结果:MOV AL,0FFH ;当CH<20时或:MOV AL,0 ;当CH≥20时
框架定义伪指令
框架定义了汇编程序运行的环境,处理器,以及程序框架。
比较重要的伪指令有model指令和stack指令。
TINY和FLAT都是将代码和数据放在一个段中,只不过TINY是16位空间,FALT是32位空间。
汇编语言程序格式
用户界面
分为CUI和GUI:
- C:character
- G:graph
这里不介绍,重点还是在控制台界面。
控制台界面
这是一段非常经典的框架,要求背会,烂熟,可以直接手写的那种。具体的,会在后面逐一解释。
.386
.model flat, stdcalloption casemap:noneincludelib msvcrt.libprintf PROTO C :ptr sbyte,:VARARG
.dataszMsg byte “Hello World! %c”,0ah,0a byte 'Y' b byte "hello"
.code
start: invoke printf,offset szMsg,ainvoke printf,offset szMsg,offset bret
end start
上面的程序,符合下面的框架,以后写的时候基本也按照这个方法写就行。
模式定义
.386
.model flat,stdcall
option casemap:none
386指的是程序用386指令集,并不代表我们的电脑就是386的。
.model定义储存模型,TINY和FLAT都是将代码和数据放在一个段中,只不过TINY是16位空间,FALT是32位空间。
最后,option代表可选项,win32中需要定义casemap,用于说明程序中的变量和子程序名是否对大小写敏感(数据对大小写敏感)
option中还有language,segment等选项。
库文件以及函数声明
include类似于C语言中的include。今天的汇编比以前强很多,可以调用很多的库,甚至是open-GL这种大型库。
include 的都是.inc文件
includelib 的都是.lib文件
include语句格式:include 文件名
include kernel32.inc
include user32.inc
导入库后有两种连接方式,比如导入了msvcrt(micro soft visual c++ runtime)库,动态链接就会在执行的时候将msvcrt.dll导入内存,而静态链接在链接过程中直接把代码的指令封入可执行文件。
动态链接效率较高,静态链接保密性较好,不容易出问题。
函数名称 PROTO [调用规则] :[第一个参数类型] [,:后续参数类型]
在汇编语言中,用ptr sbyte代表const char *
printf PROTO C :ptr sbyte, :VARARG
函数声明后,就可以用INVOKE伪指令来调用
数据部分
从.data开始。
常规数据都放在这里,有一些不会被修改的常量可以放在code段里,但是大家还是习惯于放在数据段中。
代码部分
从.code开始。所有真正的指令必须写在code段里(但是伪指令可以出现在不同的地方)。
code段比较神奇的一点是end。在code段里会看到:
start:;代码
end start
这个end是程序的结束,但是跟在end后的标签其实是程序的入口,记录了装载第一条指令的地址。
invoke是一个伪指令,起到call的作用,但是还兼顾参数传递和清理的功能。invoke指令可能会比较长,一般是因为调用函数参数过多,可以用反斜杠换行:
invoke MessageBox, \NULL,\ ;HWND hWnd offset szMsg,\ ;LPCSTR lpTextoffset szTitle,\ ;LPCSTR lpCaptionMB_OK ;UINT uType
Windows界面
Windows界面和控制台界面差不多只是增加了一些图像相关的库和调用。
.386
.model flat, stdcall
option casemap:none
MessageBoxA PROTO :dword, :dword, :dword, :dword
MessageBox equ <MessageBoxA>
Includelib user32.lib
NULL equ 0
MB_OK equ 0
.stack 4096
.data
SzTitle byte 'Hi!', 0
SzMsg byte 'Hello World!' ,0
.code
start:invoke MessageBox, NULL, ; HWND hWndoffset szMsg, ; LPCSTR lpTextoffset szTitle, ; LPCSTR lpCaptionMB_OK ; UINT uType ret
end start
输入输出相关API
printf
includelib msvcrt.lib
printf PROTO C :ptr sbyte,:vararg
printf PROTO C :dword,:vararg
dword和ptr sbyte都是一样长度,所以可以互用。不过,sbyte信息更多,代表了有符号。
还有就是,调用的时候,送入的参数是数据段里的,甚至第一个参数也要在数据段中定义。
这个和我们的习惯略有不同,我们在C语言中,这种字符串都是类似于立即数的东西,而在汇编中,需要先在数据段定义用于匹配输出的字符串。
举个例子,szOut就需要提前在数据段定义,而我们C语言一般都是直接写的。
.data
szOut byte 'x=%d n=%d x(n)=%d', 0ah, 0.code
invoke printf, offset szOut, x, n, p
我又寻思了一下,貌似C语言也可以这么写,可以理解为C语言的常量字符串在底层上会先存在数据段。
scanf
includelib msvcrt.lib
scanf PROTO C :dword,:vararg
MessageBox
includelib user32.lib
MessageBoxA PROTO :DWORD, :DWORD, :DWORD, :DWORD
这里有一个很重要的区别,就是MessageBoxA的PROTO后没有C。
printf和scanf都是CDEC声明(C decline)
MessageBoxA是STDCALL声明
具体的区别后面会讲。
分支与循环程序设计
分支和循环全部使用jxx和jmp跳转实现,对于if else ,switch,for ,while等操作,汇编中的写法是有套路的,本节直接通过代码分析这些套路。
分支
if结构有两种,一种是只有if,另一种是if+else结构。
仅if
if伪代码:cmp条件判断jxx here,此处和if要反写if里的代码jmp here ;可有可无,写了更直观
here:
熟悉C语言的人可能会一下反应不过来,理论上应该是满足条件才执行目标代码,而不是满足条件就跳过目标代码。非也,此条件只是jxx的判断条件,并不是你if里写的条件,实际上,这个jxx和你if的条件是反过来的,如果jxx满足,也就意味着if条件不满足,所以就要跳过。
if-else
那if-else怎么写呢?看下面伪代码,如果jxx判断正确,就会跳到else,如果失败,就会顺序执行if部分代码,执行完if后要用jmp here跳到分支结尾。
需要注意的是,建议在每个分支的执行语句最后加上jmp here这种指令,here代表分支判断结束,要继续执行顺序语句的那个起点。
if-else伪代码:cmp比较指令jxx labelif部分指令jmp here ;if-else二选一,执行完if后要跳到结尾
label:else部分指令 jmp here ;可有可无,写了就比较规范
here:
这是一个真实的if-else if-else的三分支例子,求带符号数x的符号,如果大于0,就给SIGNX赋值1,等于0就赋值0,小于0就赋值-1。
- 程序中,x是有符号的双字-45,SIGNX是有符号但是未知值的双字。
- 首先清零SIGNX初始化,之后用X和0比较,注意JGE是针对有符号数的,JA是针对无符号数的
- 如果X≥0,就跳到XisPositive标签,否则(x<0)就顺序执行,把SIGNX赋值-1,赋值结束后要jmp到HERE,HERE就是分支结束的位置
- x≥0的时候,还要继续分支,所以又进行cmp和JE,如果x=0,跳转到XisZero部分赋0,否则就说明x>0,就赋1,赋值后jmp HERE跳转到分支结束位置。
- 理论上,为了最好的视觉效果,在XisZero部分也应该加jmp HERE,但是人们为了方便和效率,常常是不加的,反正下一步也就执行完了。
;PROG0503
数据:X SDWORD -45SIGNX SDWORD ?
指令:MOV SIGNX, 0 CMP X, 0 JGE XisPostive ; X≥0,跳转MOV SIGNX, -1 JMP HERE ; 分支结束
XisPostive: CMP X,0JE XisZeroMOV SIGNX, 1 JMP HERE ;分支结束
XisZero: MOV SIGNX,0JMP HERE ;可有可无,分支结束
HERE:
分支综合:折半查找
二分查找的原理很直观,就是通过移动上下界不断分割区间,直到搜索到目标数为止:
在这个流程图中没有出现循环,便于我们在汇编中实现。
PROG0505
这个程序使用了一点技巧,他专门开了一个Compare段用于执行各种各样的跳转比较,执行if elseif的多分支结构。
由此可得,在汇编中,if else(if else)这种嵌套结构和if-else if-else的实现思路还是不同的。在if–else if-else结构中,不论你开多少个分支,只需要一个Compare以及在每个分支结尾加上对应的分支结束指令即可完成任意数量的if-else if-else分支结构。
分支结束指令有两种,一种是jmp here这种写法,一般用于跳转到分支末尾。另一种是jmp compare这种写法,这其实已经是循环了。
switch
PROG0507
这个程序,先打印出提示信息,等你输入。
如果输入不在1-5之间,就非法,提示重新输入,如果在1-5之间,就用输入-1得到对应功能的索引,然后用比例变址法计算出跳转表中储存对应label的地址。然后通过内存寻址获取label值,这样就获取了对应功能的入口地址,直接jmp过去。可以合二为一,直接jmp+内存寻址。
从C语言的角度来说,跳转表其实就是一个指针数组,跳转表的label就是一个二级指针。第一次访问跳转表是获得了目标代码的label,也就是获取了一个指针,第二次用jmp去跳到label,即跳到这个指针指向的位置,这才真正执行了case的代码。
由此可得,switch本质上就是将功能编号,放到跳转表中,我们通过输入的case计算出编号,就可以直接通过switch跳转。其实中断表就是一种switch。
回顾c语言的switch,要求必须是整数或者字符,实际上,正因为switch底层要用case做跳转表索引,所以只能是整数或者字符这种离散有序的表达。
再宏观一点,其实这种跳转表的思路是被应用到很多的地方的,比如插件就对应一个插件表,环境变量PATH其实也是一个跳转表,多级寻址也是跳转表。再往深理解一点,跳转表本质上还是指针的应用,此指针并不是指C语言的指针,而是一种地址+寻址的理念,指针可以说是计算机领域乃至其他领域的灵魂,是一种哲学。
循环
loop指令
汇编中有现成的循环指令。通过loop来实现for循环。
loop示例:mov ecx,10 ;循环10次
label:循环体loop label;
需要注意的是,loop会先-1,再判断结果是否等于0,等于0则不再跳转到开头。如果你ecx初始值为0,那么就会出现溢出,导致执行大量的循环。所以用loop的时候,一定要保证ecx大于0。
这里给出阶乘的程序,PROG0507
单层循环:do while、while、for
虽然有现成的指令,但是一些复杂的情况还是需要自己编写循环。
do while是最简单的循环结构:
do-while结构start:循环体cmp判断jxx start;如果满足条件,就继续跳回start部分
while通过do while修改而来。do while的执行流程是 do-判断-do-判断。如果在最开始前加一个判断,就变成了判断-do-判断-do-判断-do-判断,这就是典型的while。为了实现while,有两种思路:
- 跳过第一次do,直接跳到第一次cmp
- 在第一次do前加cmp
while ,跳到cmp例子:jmp test
start:do循环体
test:cmp判断jxx start
here:
另一种就是提前加判断:
while ,提前加判断:cmp判断
jxx here ;这个jxx和test里的jxx要反着写
start:do循环体cmp判断jxx start
here:
for循环通过while修改而得。for和while的区别就在于,for额外多了初始化和自增:
for例子:mov ecx,4
jmp test
start:do自增
test:cmp判断jxx start
here:
嵌套循环
单层循环解决不了的事情,就用多层循环。多层循环不同于函数调用,没有默认的寄存器保护机制,自然也就没有所谓的局部变量了。所以多层循环关键在于,不能互相干扰。
为此,嵌套循环一般内层用loop,外层全部用单独的寄存器作为变量。例子如下,内层用cx+loop,外层用BL。每次进入循环之前都要先初始化循环次数:
两层循环函数DELAY:DELAY PROCMOV BL,20 ;置外循环次数DELAY10: MOV CX,2801 ;置内循环次数
WT: ;内循环执行部分 LOOP WT ;loop判断DEC BL ;修改外循环次数JNZ DELAY10 ;外循环控制RET
DELAY ENDP
如果外层也用LOOP,就会出现严重问题:当内存LOOP结束,此时cx=0,然后外层loop先-1再判断,就又会跳到DELAY10处,将cx初始化,又是一轮内循环,由此就永不停歇。
如果实在想用两个cx,那就手动书写push和pop的寄存器保护即可。
浮点运算
浮点数与浮点寄存器栈
浮点数的表达是老生常谈了,有单精度1+8+32,双精度1+11+52,扩展精度1+15+63,就此略过。
在此之前,我们都是用的整数。其实浮点数运算和整数运算没太大区别,毕竟数据在计算机中的储存原理都是一样的,只是我们解释的不同罢了,你把它当浮点数,就给他用浮点数空间(4,8,10字节),以及对应的浮点数指令。
说白了,数据无非就是的储存与处理两大方面,储存就是规定了他的空间大小,处理就是使用的指令。
说完共性,该说特性了。浮点数有专用的寄存器,而且是排成一排的,所以又叫浮点数寄存器栈FPU。访问的时候用st(i)就相当于使用索引为i的寄存器。需要注意的是,这是一个栈,而不是数组,比如你st(0)储存了1.2,此时你再push进来一个数2.3,则1.2就会被挤到st(1)的位置,而st(0)永远代表栈顶。
示例代码1
给一个示例代码,计算表达式f=a+b*m,具体解析放在注释里。
这个程序大体展示了如何使用浮点寄存器栈,以及栈的运行规则,还有一些浮点指令。
;PROG0409.asm
.586
.model flat, stdcall
option casemap:none
includelib msvcrt.lib
printf PROTO C :ptr sbyte, :VARARG
.dataszMsg byte "%f", 0ah, 0a real8 3.2 ;real8代表8字节的浮点数,也就是double,如果是float,就用real4,扩展精度就是real10b real8 2.6m real8 7.1f real8 ?
.code
start:finit ;finit为FPU栈寄存器的初始化fld m ;fld为浮点值入栈fld b fmul st(0),st(1) ;fmul为浮点数相乘,结果保存在目标操作数中fld afadd st(0),st(1) ;,注意,此时a已经变成了st(0),其余两个被挤到上面去一位。fmul为浮点数相加,结果保存在目标操作数中fst f ;fst将栈顶数据保存到内存单元invoke printf, offset szMsg, fret
end start
浮点数据/指令细究
数据定义
realx中的x代表你要用的位数,也就是float还是double又或者扩展80位的浮点数。后面的立即数有多种写法,和c语言的写法基本类似。
那问题来了,real8和qword有啥区别呢?都是8字节。使用了realx,那就一定会以浮点数格式储存,但是qword不带有类型信息,怎么储存取决于你后面的立即数写法,如果是3,那就是正数,如果是3.0(3.),那就是浮点数。
a real8 3.2 ;定义64位浮点数变量a,初始化为3.2
b real10 100.25e9 ;定义80位浮点数变量b,初始化为100.25e9
c qword 3. ;定义64位浮点数变量c,初始化为3.0
d qword 3 ;定义64位整型变量d,初始化为3
寻址方式
浮点数使用浮点寄存器栈,也可以使用内存寻址。
指令
首先是数据传送。
- FLD与FST。入栈用FLD(load),出栈用FST(store)。
- FSTP。FST不等同于pop,只是将数据store到内存,并没有pop操作,所以FSTP相当于FST+POP。
- FLDPI。数据传送有一种特殊的情况,就是我们要将一些特殊常数传入栈中,比如π,我们肯定不能手写,必须用特殊指令:FLDPI加载到栈中。还有一些类似的无理数,略过。
其次是算数运算指令。
指令看起来很复杂,其实就是加减乘除4大类二元运算,就是在整数指令前加个F,每一类有5种写法:
- FADD dst,src。这是最常用的,相加,送到dst中。
- FADD src。这是次常用的,默认将结果送到s(0)中
- FADD和FADDP。这两个都会执行pop,将新的结果送到栈顶,也就是原来的st(1)
比较指令,FCOM,类似于cmp,略过。
最后就是超越函数指令,其可以将st(0)变成sin(st(0)),sin,cos,tan,atan写法都一样。
示例代码2
学完指令以后,再重新做道题,思路如下:
- finit初始化
- 获取r到内存
- 将两个r压入栈,之后用fmulp,计算r方,pop掉一个r,结果存在st(0)中
- 压入一个pi,之后用fmulp,pop掉r方,将结果存在st(0)中
- fst,将结果送到内存中,之后输出。
; PROG0410.asm例4.37 输入圆的半径,计算圆面积。
.dataszMsg1 byte "%lf", 0 ;要储存到double中,所以要用lf读。szMsg2 byte "%lf", 0ah, 0r real8 ?;圆半径S real8 ?;圆面积
.code
start:finit; finit为FPU栈寄存器的初始化invoke scanf, offset szMsg1, offset rfld rfld rfmulp st(1), st(0)fldpifmulp st(1), st(0)fst S;fst将栈顶数据保存到内存单元invoke printf, offset szMsg2, Sret
end start
性能优化
仅做了解,不考。
时间优化
- 将费时指令转换为省时指令。比如用移位代替一部分乘法
- 分支优化。将分支转换为非分支,或者条件传送,防止流水线中出现bubble
- 提高cache命中率。尽量减少内外存交换,比如写循环的时候,外i内j比外j内i好。
空间优化
- 使用短指令。让目标代码更短。
- 使用联合。减少内存空间的占用。
子程序设计
子程序基本知识
子程序定义
这是最基本的定义方式,这种声明仅仅告诉你这是一个子程序,并没有直接传入参数。想要传参,就要通过寄存器传参。
过程名 PROC…
过程名 ENDP
实际上,完整的定义如下:
子程序名 PROC [C | stdcall] :[第一个参数类型] [,:后续参数类型]
- PROC表示你这是一个过程定义(声明是PROTO)
- C/STDCALL涉及到传参与清理参数
- 定义参数类型后,汇编语言就会以更加自动化的方式管理传入的参数
我们最开始仅仅是用初始定义,以便学习概念,尤其是堆栈的变化。现代程序使用的数据越来越多,传参和局部变量等等基本都是用堆栈储存,所以时刻注意堆栈的push与pop平衡是非常重要的。
实际场景中,我们定义子程序是用完整方式定义的,这就使得很多管理都是由汇编语言自动实现的,这也就是说,汇编其实相比于指令来说还是有一些抽象的,并不完全与指令一对一。我们也不会那么费劲地就用最原始方式书写。
最后,要养成时刻打注释的好习惯,包括在子程序开始也要加上功能,参数,返回的说明。
堆栈
堆栈用处很多,主要是进行先进后出的储存,其有三大特点:
- 临时性。push后pop,可见是临时储存
- 动态扩展性。push就是动态扩展,只要不爆栈,就可以一直扩展
- 快速性。?
具体到场景,堆栈有如下用途:
- 子程序调用时传递参数。现代程序基本都通过堆栈传参。
- 子程序调用和返回,保护和恢复调用现场。比如call的时候,push返回地址,刚进入子程序还要压入被调用者保护的寄存器值。子程序调用结束后,恢复被调用者保护寄存器,然后将返回地址pop到IP指针。
- 子程序局部变量,临时数据的储存。程序刚开始,处理完1,2的事情后,会再开一些空间,将局部变量放进去。
总的来说,这三个用途,归根结底就是子程序调用过程中的事情,理解堆栈与理解子程序调用是同步的。
举个例子,说明一下临时数据的用途,这个程序要求将十进制数字转换为字符串。比如8192变成“8192”。程序的思路是先对8192不断除10,取余数,这样就可以把一个数拆成4个数,按2918的顺序压入栈中,然后出栈的时候就是8192,只需要将每一次出栈的数字+'0’就可以变成字符,将这些字符依次放入szStr即可。
- szStr是存放结果的地方
- 初始化数据:8192存在eax里,先清零edx,ecx,再把ebx赋值10,这是每次除法的除数
- 入栈:每次用EDX:EAX除以EBX,EDX是余数,直接push,同时给ECX自增,记录压入几个数。之后清理一下EDX,同时判断一下是否已经除尽,如果不尽就继续除,直到商=0。将szStr地址存入EDI备用。
- 出栈:ECX已经记录了数的个数,直接用LOOP指令。每次pop,都把一个数取到EAX中,加上’0’,送到EDI中。之后EDI移动一个字节,如此循环。最后再补个\0结尾。
.data
szStr BYTE 10 DUP (0) .codeMOV EAX, 8192XOR EDX, EDXXOR ECX, ECXMOV EBX, 10
a10:DIV EBX ;EDX:EAX除以10PUSH EDX ;余数在EDX中, EDX压栈INC ECX ;ECX表示压栈的次数XOR EDX, EDX ;EDX:EAX=下一次除法的被除数CMP EAX, EDX ;被除数=0? JNZ a10 ;如果被除数为0,不再循环MOV EDI, OFFSET szStr
a20:POP EAX ;从堆栈中取出商ADD AL, '0' ;转换为ASCII码MOV [EDI], AL ;保存在szStr中INC EDILOOP a20 ;循环处理MOV BYTE PTR [EDI], 0
子程序的返回地址
前面说到,在栈帧构造过程(调用子程序的时候)中,先传入参数,再压入返回地址,之后进行被调用者保护,最后放入局部变量。其中,返回地址值得探讨。
到底是压入几个字节呢?这个比较玄学,下面有个调用程序,虽然是程序内调用,但是返回地址占用4字节,有两种可能,一种是EIP,另一种是CS:IP。这个不需要太注意,因为后面这些不需要你自己手动维护的。
参数传递
参数可以通过寄存器传递,也可以通过数据区的变量传递,但是,现在主流的方式已经变成了堆栈传递。
C语言函数的传参方式
从C语言这种高级语言开始,传参就都是从堆栈传递了。(在此之前,有一种做法是6个参数通过寄存器,超出的部分通过堆栈)
- 参数入栈顺序:都是从右到左送入,这样我们从栈上取参数的时候,就可以顺序取了
- 参数出栈:cdecl由调用者清理参数,子程序直接ret即可,主程序会移动esp清理栈,其他的都是子程序清理栈,经典的写法需要用ret n
- 三种特殊调用:5种调用,最主要的就是cdecl和stdcall,其他三种中:
- fastcall用两个寄存器传参,速度更快
- this调用将this指针用ECX传入
- naked略。
至于如何在C语言中告诉编译器你使用什么方式调用,可以按如下方式声明:
int _stdcall subproc(int a,int b)
汇编子程序传参方式
本节用具体的代码解释一下cdecl和stacall的区别:
;第一个子程序,使用cdecl方式调用
SubProc1 proc ;使用堆栈传递参数push ebpmov ebp,espmov eax,dword ptr [ebp+8] ;取出第1个参数sub eax,dword ptr [ebp+12] ;取出第2个参数pop ebp ret
SubProc1 endp;第二个子程序,使用stdcall方式调用
SubProc2 proc ;使用堆栈传递参数push ebpmov ebp,espmov eax,dword ptr [ebp+8] ;取出第1个参数sub eax,dword ptr [ebp+12] ;取出第2个参数pop ebp ret 8 ;平衡主程序的堆栈
SubProc2 endpstart: push 10 ;第2个参数入栈push 20 ;第1个参数入栈call SubProc1 ;调用子程序add esp, 8 ;cdecl方式清理堆栈push 100 ;第2个参数入栈push 200 ;第1个参数入栈call SubProc2 ;调用子程序 ;stdcall方式不需要主程序清理堆栈ret
end start
以上是cdecl的调用模式,在子程序里直接ret,在主程序中对esp进行add操作。如果是stdcall,经典写法是在子程序中ret n;主程序不进行操作。n取决于你压入栈中的参数,n最终要将压入的参数恰好清理完。如果是2个dword,那就是n=8。
下图展示了调用过程的栈变化,先是参数压栈,之后是返回地址ESP入栈,之后是EBP的被调用者保护以及EBP重新赋值,因为没用局部变量,所以就没有再开内存了。
再给一个大点的程序,文件是.c,所以生成的汇编代码会默认使用cdecl模式调用,注释比较清楚:
//PROG0502.c
int subproc(int a, int b)
{ return a-b;
}
int r,s;
int main( )
{r=subproc(30, 20);s=subproc(r, -1);
}
生成的汇编代码如下:
;子程序subproc
00401000 PUSH EBP ;被调用者保护
00401001 MOV EBP,ESP ;EBP保存ESP没开局部变量内存时的初始值,后面的操作都用EBP,ESP不会轻易使用
00401003 MOV EAX,DWORD PTR [EBP+8] ;调用第一个参数需要+8,EBP占用4字节,说明返回地址占用了4字节。
00401006 SUB EAX,DWORD PTR [EBP+0CH] ;第一个参数的区间是ebp+8到ebp+11,第二个参数的区间是ebp+12到ebp+15,两个参数都是DWORD类型
00401009 POP EBP ;恢复被调用者保护
0040100A RET ;注意,直接ret只能说cdecl的可能性更大,其他调用在一定情况下也可以直接ret
;主程序
0040100B PUSH EBP ;被调用者保护
0040100C MOV EBP,ESP
0040100E PUSH 14H ;传参,默认占用4字节,int型
00401010 PUSH 1EH ;传参
00401012 CALL 00401000
00401017 ADD ESP,8 ;调用者清理栈,cdecl实锤
0040101A MOV [00405428],EAX ;r存到数据区
0040101F PUSH 0FFFFFFFFH ;压栈传参,-1
00401021 MOV EAX,[00405428]
00401026 PUSH EAX ;压栈传参
00401027 CALL 00401000
0040102C ADD ESP,8 ;清理栈
0040102F MOV [0040542C],EAX ;s存到数据区
00401034 POP EBP ;恢复被调用者保护
00401035 RET
你可能会感觉这么写很麻烦,不用担心,实际中你会通过更加高级的写法书写,编译器会自动给你生成进入(被调用者保护)和退出代码(恢复保护和ret n)。你要是就想手写,那就naked调用(这名字起的真形象)
带参数子程序定义
这一步是跨越性的。
前面说了,编译器会自动生成ret n的代码,但是你会看到,程序中还有00401003 MOV EAX,DWORD PTR [EBP+8]这种写法,这种写法也非常的费脑子,有没有一种更简单的方式呢?就是给子程序也带参数。看下面代码:
- 首先看到stacall和cdecl都是直接用ret的,不必惊讶stdcall为啥没ret n,最后汇编成指令后,SubProc2会自动变成ret n的形式。
- 其次,用了a:dword,b:dword的写法,用了这两个写法以后,子程序就不用再在esp上用偏移寻找参数了,a和b已经将栈上的参数地址记住了,a和b本质上就是var变量,对应前面的内存直接寻址法。而且子程序中也不需要写保护了,最后会自动生成指令
- 最后,主程序中也不需要你去push参数,不需要add esp了,这些维护通通在汇编后会自动生成。
可以看到,程序变得非常间接,一大堆流程用一个invoke统统搞定,而我们前面看到的那些很复杂的其实都是反汇编出来的最底层的指令。
SubProc1 proc C a:dword, b:dword ; 使用C规则mov eax, a ; 取出第1个参数sub eax, b ; 取出第2个参数ret ; 返回值=a-b
SubProc1 endp
SubProc2 proc stdcall a:dword, b:dword ; 使用stdcall规则mov eax, a ; 取出第1个参数sub eax, b ; 取出第2个参数ret ; 返回值=a-b
SubProc2 endpstart:invoke SubProc1, 20, 10 ;这一步,包括了参数压栈,被调用保护,子程序处理,恢复被调用,返回,清理参数invoke printf, offset szMsgOut, 20, 10, eaxinvoke SubProc2, 200, 100invoke printf, offset szMsgOut, 200, 100, eaxretend start
子程序中局部变量
这一步同样是自动化。
曾经,我们在子程序之初,需要在栈上开空间,最后还要清理局部变量,这些都需要你手动计算
现在,有了局部变量声明,我们就可以把地址的偏移直接转换成var变量的用法
LOCAL变量名1[重复数量][:类型], 变量名2[重复数量][:类型]……LOCAL TEMP[3]:DWORD
LOCAL TEMP1, TEMP2:DWORD
注意,LOCAL伪指令必须紧接在子程序定义的伪指令PROC之后,也就是在程序之初就声明要用多少局部变量。回想C语言,我们是可以在任意地方创建局部变量的,其实这些局部变量都会被编译器转化成local声明,统一放在PROC之后。
下面程序使用了上一节的参数声明,并且使用了局部变量。
可以看到,堆栈传参和放局部变量全都变成了自动维护的情况,a,b其实在堆栈上,temp1,temp2也是堆栈上,但是他们用起来和C中的局部变量几乎一样。
注意,参数和局部变量都是栈上的,所以他们本质上还是内存寻址,所以[a]这种用法是错误的,不可以嵌套内存直接寻址,代码中先赋给eax,才能[eax]
swap proc C a:ptr dword, b:ptr dword ;使用堆栈传递参数local temp1,temp2:dwordmov eax, a mov ecx, [eax]mov temp1, ecx ;temp1=*amov ebx, b mov edx, [ebx]mov temp2, edx ;temp2=*bmov ecx, temp2mov eax, a mov [eax], ecx ;*a=temp2mov ebx, b mov edx, temp1mov [ebx], edx ;*b=temp1ret
swap endpstart procinvoke printf, offset szMsgOut, r, sinvoke swap, offset r, offset s invoke printf, offset szMsgOut, r, sret
start endp
end start
子程序特殊应用
子程序嵌套
在我们使用了最简洁的写法后,子程序嵌套变得十分容易,基本和C语言一模一样,只要不爆栈,就可以随便嵌套。
子程序递归
下面的程序展示了如何实现阶乘,注意,不可以invoke factorial,n-1,因为指令中不能嵌套计算,你得先送到ebx中进行减法,再invoke。
在递归过程中,栈不断加深,直到n=1,此时赋值eax=1,然后开始回退,每次都用当前层次的n×EAX,最后的EAX就是结果。
factorial proc C n:dwordcmp n, 1jbe exitrecursemov ebx, n ;EBX=ndec ebx ;EBX=n-1invoke factorial, ebx ;EAX=(n-1)!imul n ;EAX=EAX * nret ;=(n-1)! * n=n!
exitrecurse: mov eax, 1 ;n=1时, n!=1ret
factorial endpstart proclocal n,f:dwordmov n, 5invoke factorial,n;EAX=n!mov f, eaxinvoke printf, offset szOut, n, f ret
start endp
缓冲区溢出
缓冲区溢出是最常见的安全问题,本节展示一下如何利用缓冲区溢出获得系统的控制权,总的来说,核心就是修改堆栈上的返回地址。
堆栈溢出
回顾一下堆栈结构,最开始是参数,然后是返回地址,之后是寄存器保护,然后就是局部变量,看这个程序:
int main(int argc, char **argv)
{char buf [80];strcpy(buf, argv[1]);
}
这个程序将argv[1]对应的字符串送入缓冲区中,缓冲区大小是80,假设寄存器保护的是EBP,占4字节,也就是说,ESP+84就是栈上EIP的位置。
如果我构造一个长度为88的字符串,前84个随意,最后4字节自定义一个返回地址,我此时用strcpy方法将这个字符串写入堆栈的局部变量区,前80字节会把缓冲区填满,4字节覆盖EBP,最后4字节覆盖EIP。
关键来了,在这个子程序结束后,子程序理论上应该返回调用者,但是这个EIP已经被我们篡改了,我们可以直接跳转到我们自己写的攻击程序入口,相当于我们接管了这个程序。
数据区溢出
请记住,数据溢出的核心目标就是修改EIP。看下面的程序,里面有一个call dword ptr[fn],fn本来储存的是函数f的地址,如果我们把fn那片地址空间修改了,是不是相当于把call的目标地址改成我们自己的函数了。
具体来说,scanf读取字符串送到buf里,buf有40字节,我们可以构造一个44字节的字符串。前40字节将buf覆盖,剩下的4字节用我们自己的函数的地址,这样就可以将fn覆盖掉。
;PROG0508.asm
.386
.model flat,stdcall
includelib msvcrt.lib
printf PROTO C:dword,:vararg
scanf PROTO C:dword,:vararg.data
szMsg byte 'f is called. buf=%s', 0ah, 0
szFormat byte '%s', 0
buf byte 40 dup (0)
fn dword offset f.code
f procinvoke printf, offset szMsg, offset bufret
f endp
start:invoke scanf, offset szFormat, offset bufcall dword ptr [fn]
invalidarg: ret
end start
杀毒软件原理
杀毒软件有多种策略,其中杀毒软件主动防御策略如下:
将计就计用虚拟环境跑病毒,如果程序试图获取电脑控制权,就会造成虚拟环境失控。当主环境检测到虚拟环境失控,就会判定为软件是病毒。
子程序模块化设计与通信
实际中,肯定不会用一个源文件把所有代码都写了,一定是分模块的。模块之间分别编译,最后链接成一个可执行文件。
如图所示,系统由模块A、模块B、模块C组成,而模块B中的部分功能又可以进一步分解成为模块D、模块E,整个系统包括了5个模块。模块中的代码设计为子程序,能够相互进行调用。
模块之间如何通信?早期是用文件,全局变量通信,这种通信很容易被干扰,破坏,现在都是用public和extrn方法通信,于此对应的,要区分定义和声明。
- 定义。规定了一个程序或者一个数据具体是什么,怎么执行的
- 声明。表明我要用某种类型的程序,但是程序在不在我这里无所谓。
public和extrn写法:
PUBLIC 名字[,…]
EXTRN 变量名:类型[,…]
子程序名 PROTO [C | stdcall] :[第一个参数类型] [,:后续参数类型]
- public和extrn必须在data区之前就写好。
- public可以对变量和函数使用,都不需要带类型。注意,变量必须是data区里的变量(有点像c语言中的全局变量),绝对不能是子程序里的局部变量
- extern只能对变量使用,不能用于函数(是不能还是像C语言一样默认extrn?TODO)
- 如果想调用其他文件的函数,就是用PROTO方法声明。注意,声明用PROTO(函数原型),定义用PROC。
回顾一下C语言的extern用法,和汇编进行对比,总的来说,C语言更开放,默认项目内所有文件全局,而汇编比较封闭,你不声明public,就只是默认文件内全局:
- c语言中没有public,但是你把数据放到全局变量区就相当于public,函数是默认public的
- 使用外部数据:C语言在其他程序中,想用全局变量就用extern声明,函数不需要用extern声明。
- 使用外部函数:汇编同C语言一样,函数声明只需要给出参数类型即可,不用写参数名字。
- 文件内全局变量:使用static声明的C语言变量,相当于汇编中data区不主动声明public的变量,仅在一个文件中具有全局特性。
下面给个例子,两个程序,一个用另一个的result数据,另一个用前一个的SubProc函数:
;PROG0509.asm
.386
.model flat,stdcall
option casemap:none
includelib msvcrt.lib
printf PROTO C :dword,:vararg
SubProc PROTO stdcall :dword, :dword ; SubProc位于其他模块中
public result ;允许其他模块使用result
.data
szOutputFmtStr byte '%d?%d=%d', 0ah, 0 ;输出结果
oprd1 dword 70 ;被减数
oprd2 dword 40 ;减数
result dword ? ;差
.code
main proc C argc, argvinvoke SubProc, oprd1, oprd2 ;调用其他模块中的函数invoke printf, offset szOutputFmtStr, \ ;输出结果oprd1, \oprd2, \result ;result由SubProc设置ret
main endpend
;PROG0510.asm
.386
.model flat,stdcall
public SubProc ;允许其他模块调用SubProc
extrn result:dword ;result位于其他模块中
.data
.code
SubProc proc stdcall a, b ;减法函数, stdcall调用方式mov eax, a ;参数为a,bsub eax, b ;EAX=a-bmov result, eax ;减法的结果保存在result中ret 8 ;返回a-b
SubProc endpend
最后经过如下处理,就可以变成可执行文件。
C语言反汇编(重点)
这才是学汇编的最终目标:看懂反汇编代码。
C语言编译出来的,以及用高级方式写的汇编代码,都不是计算机最终的指令。经过汇编,变成可执行文件后,才是最终的指令,而反汇编过来的,就是这种最原始的指令结构。所以说,虽然我们不会用原始方式写汇编,但是一定要回看原始方式的汇编代码。
本节给出一些经典的汇编代码套路,其实前面的程序也给的差不多了,这里只是简单总结一下。
基本框架与栈帧
重点在于,重新审视栈帧与esp,ebp。我又打开CSAPP,从里面截了张图出来:
可以看到,栈帧构造,是先构造参数,之后压入返回地址,然后压入ebp与其他被调用者保护,最后开局部变量空间。
下图代码基本展示了这一过程。图中从29开始到36,这几句是栈上的局部变量区初始化过程。其他的就都是我们上面描述的。
最开始push ebp(被调用者保护)。需要特别注意的是那句mov ebp,esp,ebp是esp刚将ebp压入后的值,esp后面开了内存以后还会移动。我们在程序中,取参数,取局部变量都是要用ebp的,而不是esp,esp随着栈帧结构变化而变化。最后子程序结束,要恢复esp的时候会mov esp,ebp,将esp恢复到刚压入ebp的时候,之后再pop掉栈里的ebp(被调用者保护恢复)与返回。
如何用ebp取参数和局部变量呢?首先要明确栈的方向,地址减小方向是栈顶方向,也是局部变量方向。
- ebp+n是取参数,n越大,代表参数越靠后。注意,ebp取参数要跳过被调用保护寄存器和IP指针。在前面的很多程序中,IP指针一般是压EIP,占4字节,还有一个保护EBP,总计8字节,所以取的第一个参数通常是ebp+8。
- ebp-n是取局部变量。奇妙的是,局部变量也是n越大,代表声明顺序越靠后。
举个例子分析一下顺序问题:
假设有两个局部变量,两个参数,都是DWORD,那么第一个参数就是ebp+8到ebp+11,第二个参数是ebp+12到ebp+15。第一个局部变量是ebp-4到ebp-1,第二个局部变量是。ebp-8到ebp-5。总之就是,越靠近ebp的,就越是先定义的,绝。
选择结构
这个就是典型的if-else结构,判断条件反写,如果满足反写条件,就跳转到else部分,否则执行if部分,在if部分结尾添加jmp here跳到分支结尾。
这个程序在每个分支都要调用一下函数,invoke伪指令翻译过来变成底层代码就是push+call+清理栈空间。
循环结构
i是局部变量,存在栈中ebp-4的位置,区间为ebp-4到ebp-1。
这个for循环是基于while循环的,while循环采用了直接跳转到测试部分的方案,绿色部分第一句就是跳转到cmp部分。绿色部分剩下三句执行的是i++操作,需要先取出来,自增,再放回内存。
理论上,这三句可以直接用add dword ptr [ebp-4],1合并,立即数+内存的组合是符合流水线要求的。
变量定义
这一部分对应前面的模块间通信。其中提到三类变量,C语言和汇编中是一一对应的:
- 全局。C语言直接声明,汇编中用_i1这种带下划线的标号,且要public声明
- 静态全局。C语言中加static,汇编中用i2这种正常标号,放在程序data段
- 局部变量。C语言中在函数中声明,汇编中使用ebp进行偏移寻址。
可以看到,静态全局和全局变量的位置并不是连续的。
指针
汇编中的指针和变量没什么区别,只不过是储存了地址的变量罢了。
下面的程序中,有两个局部变量,一个是a,另一个是p。a因为声明在后,所以是ebp-8(n比较大),p声明较早,所以是ebp-4。变量赋值是mov,指针赋值要取地址,所以用lea(理论上也可以用offset,但是底层汇编代码是没有offset这种伪指令的)
这里就比较有意思了,指针竟然只有4个字节,我们C语言里指针不是8字节吗?不必惊讶,指针储存地址,而地址空间本身就和计算机密切相关,在32位机器里,指针长度就是4字节,64位机器(我们现在都是)里就是8字节。你的C语言是跑在64位机器上的,所以指针是8字节,而你的汇编是跑在masm32保护模式下的,所以是4字节。
函数
这是一段主程序,可以一眼看到有两个call,call后还有add esp,8。说明传入了8字节的参数,而且是cdecl调用。
在调用之前会有两个push,所以我们也可以很轻松的算出传入的参数是多少。
跟着地址看一眼子程序,一般看到ebp+8这种,你就知道这是在取参数了,所以大概率就是一个子程序的初始部分。
最后看看输出部分的汇编代码。可以看到,三个参数是从右往左倒着push的。
最后add esp,0ch。12对应三个4字节参数。
C语言汇编混合编程
一路学下来,你会感觉到C语言非常底层,在某些方面往往和汇编有着非常接近的对应关系。因此,C语言和汇编能混合编程也就不足为奇了。
- 当C语言需要更有效率的优化,或者要求直接控制硬件,就需要加汇编
- 当汇编需要完成一些复杂的任务,就需要调用更高级的C语言
这一部分在实践中有用,但是这里仅做了解:
- C中加汇编——内联嵌入。使用_asm{汇编代码}直接嵌入
- 互相调用。C函数和汇编子程序可以互相用对方的变量,函数
- C++与汇编联合编程。略
北京理工大学汇编大作业:坦克大战魔改版
汇编要做游戏,我们想了很多游戏,有的像贪吃蛇,2048这种,都已经被做烂了,就算了,有的游戏有点脑残,也算了,后面机缘巧合,一边讨论游戏,一边扯犊子,我突然想到是不是可以做坦克大战,一搜,果然做的人很少,于是就开始搞坦克大战了。可以说,坦克大战非常有意思,扩展性很强,甚至可以考虑做成Q版泡泡堂。
TODO:详见github。