《汇编语言》- 读书笔记 - 附注
- 附注1:Intel 系列微处理器的3种工作模式
- 1. 实模式
- 2. 保护模式
- 保护模式 与 实模式 的主要区别
- 寻址能力
- 内存保护
- 特权级别
- 任务管理和虚拟内存
- 为何需要保护模式
- 访问受保护资源
- 3. 虚拟 8086 模式
- 4. 长模式(Long Mode)
- 64位CPU + 32位系统上运行32位应用
- 64位CPU + 64位系统上运行32位应用
- 附注2:补码
- 附注3:汇编编译器(masm.exe)对jmp 的相关处理
- 1. 向前转移
- 1.1. 当 disp ∈ [-128, 127]
- 书中描述
- 上机验证:与书中描述并不符
- 1.2. 当 disp ∈ [-32768, 32767]
- 书中描述
- 上机验证:与书中描述一至
- 2. 向后转移
- 2.1. 当 disp ∈ [-128, 127]
- 书中描述
- 上机验证:与书中描述并不符
- 2.2. 当 disp ∈ [-32768, 32767]
- 书中描述
- 上机验证:与书中描述一至
- 3. 总结
- 向前跳转的处理
- 向后跳转的处理
- 附注4:用栈传递参数
- C 语言示例
- 附注5:公式证明
- 分析:
- 原理:分而治之各各击破
- 公式分解
- 证明过程
- 伪代码分析验证
附注1:Intel 系列微处理器的3种工作模式
微机中常用的 Intel 系列微处理器的主要发展过程是:8080,8086/8088,80186,80286,80386,80486,Pentium,PentiumII,PentiumIII,Pentium4。
- 8086 与 8088 微处理器的主要区别
- 数据总线宽度:8086具有16位外部数据总线,而8088为8位,这直接影响了数据传输的速度和效率。
- 指令队列:8086拥有6字节指令队列,相比8088的4字节,能更高效地预取指令。
- 系统兼容性:8088设计上偏向于与8位系统及外设兼容,更适合当时市场的需求。
- 性能:因总线宽度和指令队列的差异,8086通常提供更高的处理性能,而8088在某些应用场景下可能因系统集成的便利性和成本优势而被选用。
- 硬件接口:两者的控制信号和引脚配置有细微差别,需针对性设计硬件接口。
总体而言,8086是高性能选择,而8088则是成本效益与兼容性更好的方案。
1. 实模式
当Intel推出80286及后续的x86系列处理器时,为了确保向后兼容性,使这些新处理器能够运行为8086/8088
设计的软件,它们在启动时都进入了实模式。
实模式是CPU在物理层
面上按照类似于8086/8088处理器的工作方式来操作的一种模式。在实模式下:
- CPU
地址总线
访问限制在20位
,因此寻址能力最大为1MB
(即1,048,576字节)的物理地址空间。
覆盖从00000H
到FFFFFH
的地址范围。 - 使用
16位寄存器
。段寄存器与偏移地址组合形成20位地址
,用于访问内存。 - 没有现代处理器的页表和分页机制,内存管理较为简单。
- 中断和异常处理机制较为基础,不支持像保护模式下的高级特性。
- 操作系统直接与硬件交互,没有现代操作系统中的硬件抽象层。
实模式
的存在使得老的16位操作系统
和应用程序
可以在更先进的32位
或64位
处理器上运行,无需修改。
补充:
VMware
,VirtualBox
等虚拟机也可以提供一个高度仿真的环境来运行DOS,但这种模拟是在软件层次完成的,而非物理CPU直接进入或模拟8086的工作模式。
虚拟化技术的意义之一就在于,它允许宿主机的CPU保持在高效的保护模式下运行现代操作系统,同时在虚拟机内部模拟出一个包含8086/8088兼容环境的平台,来运行16位的操作系统如DOS以及为其设计的软件。这种方式不仅保留了对旧软件的兼容性,还确保了宿主机系统的安全性和资源的有效隔离,同时也充分利用了现代硬件的高性能特性。因此,用户可以在享受最新技术带来的便利的同时,无缝兼容和利用遗留的软件资源,这是虚拟化技术在保持向后兼容性方面的一个重要贡献。
2. 保护模式
保护模式 与 实模式 的主要区别
寻址能力
- 实模式: 受限于16位
段寄存器
加偏移量
的方式,地址总线为20
,仅能访问最多1MB
内存。 - 保护模式: 利用更先进的地址转换机制,如分段和分页,可支持远大于1MB的地址空间,如
32位
处理器可达4GB
。
内存保护
- 实模式: 内存访问无任何保护措施,任何程序均可自由读写内存的任何位置,容易引发系统崩溃或安全漏洞。
- 保护模式: 通过引入内存管理和权限控制,确保程序只能访问其权限内的内存区域,有效隔离了不同程序及用户程序与操作系统内核,大大增强了系统的稳定性和安全性。
特权级别
- 实模式: 没有权限概念,用户应用与操作系统平起平坐。(用户程序出点什么错很轻松就能把系统一起搞死)
- 保护模式: 设置了多个特权级别(如Ring 0至Ring 3),操作系统核心运行在最高权限级别,用户程序则在较低级别,这样的设计进一步限制了用户程序对系统资源的直接访问,提升了安全性。
任务管理和虚拟内存
- 实模式: 系统没有内建的任务管理和虚拟内存机制。但可以通过中断、常驻内存程序(TSR)、直接硬件操作、手动内存管理以及程序间合作等不同的策略,来实现类似多个应用程序协同工作的功能。
- 保护模式: 支持多任务和虚拟内存,操作系统通过分页机制将虚拟地址转换为物理地址,使得每个程序享有独立的虚拟地址空间,感觉像是独占了整个内存空间,而实际物理内存的分配和管理则由操作系统统一处理,提高了资源利用效率和程序的独立性。
为何需要保护模式
保护模式的引入主要是为了解决实模式下存在的安全、稳定性和资源管理效率等问题。它通过提供内存保护、特权级划分、虚拟地址空间和多任务支持,不仅保护了系统和用户数据免受恶意或错误程序的侵害,还促进了操作系统设计的复杂性和灵活性,支撑了现代计算系统的发展需求。
访问受保护资源
在保护模式下,应用程序无法直接访问受保护的内存或硬件资源。为了与这些资源交互,应用程序需通过操作系统
提供的API
进行请求。API作为安全的中介,会对请求进行权限验证
,并以一种控制和监管的方式执行相应操作,确保了所有访问都在安全和有序的框架内进行,进一步巩固了保护模式的核心优势。
3. 虚拟 8086 模式
虚拟8086模式是一种特殊的处理器运行模式,它是在保护模式下模拟实模式8086处理器行为的一种机制。这一模式的意义在于,它允许在较新的操作系统和硬件平台上,仍能运行为16位实模式设计的老式应用程序和DOS程序。在虚拟8086模式下,尽管处理器实际上处于保护模式,但它能够为每个任务模拟出一个隔离的16位环境,从而保持与早期软件的兼容性。
然而进入64位时代后,虽然CPU硬件
还在继续支持虚拟 8086 模式
,
但64位版本的Windows操作系统已经不直接支持16位应用程序了。这是因为64位Windows操作系统主要运行在长模式
(Long Mode)下,该模式不兼容
实模式和虚拟8086模式,从而无法直接运行16位代码。
64位系统
下运行16位应用
的推荐方案是:通过虚拟化技术,在安装有16、32位操作系统的虚拟机中运行这些旧程序。
4. 长模式(Long Mode)
在支持64位运算的处理器(如AMD的Opteron和Intel的Xeon处理器)中引入,它允许CPU使用更大的地址空间(理论上可达16 EB),并扩展了通用寄存器的大小到64位,同时保持了保护模式下的大部分特性。
长模式
是保护模式
在64位
计算环境中的扩展和升级自然演进,而不是替代。
64位CPU + 32位系统上运行32位应用
-
32位操作系统
32位操作系统
根据32位架构
的设计规范
来构建和运行,它有一套固定的规则和接口来与硬件交互,这些规则不依赖于CPU的具体位数,而是基于32位系统的标准。
当这套操作系统部署在64位CPU上时,CPU能够识别它正在与一个32位操作系统交互,并相应地调整其行为来兼容和支持该操作系统。64位CPU通过特定的保护模式(所谓的兼容模式),向下兼容32位的指令集和寻址方式,从而确保32位操作系统可以正常运行。这意味着CPU不仅
知道
它正在服务于一个32位系统,而且还会主动
调整其工作模式和功能,以满足该系统的需求。
尽管CPU具有更高级的功能(如64位运算和寻址),但在运行32位系统时,它会限制这些功能的使用,以维持与32位系统软件的兼容性。 -
32位应用程序
32位应用程序
在由操作系统
营造的32位兼容环境
中无障碍地运行,对底层硬件的实际位宽并不知情也不需关心。
64位CPU + 64位系统上运行32位应用
跑在64位CPU
上的64位操作系统
上运行32位应用程序
时,并不需要完全离开长模式。相反,CPU和操作系统共同协作,创造一个仿真的32位环境给这个应用程序,使其认为自己正运行在一个32位系统上。这包括限制地址空间到32位范围、使用32位指令集等,而所有这一切都是在长模式的框架内实现的。简而言之,CPU确实是依然工作在长模式下,只是通过特定的子模式和操作系统的辅助,巧妙地“伪装”出一个适合32位程序执行的环境。
补充: 64位CPU在运行32位操作系统或应用程序时,会切换到一个
特定
的保护模式
下,这个模式全面兼容32位环境。这意味着CPU会限制其自身,以模拟32位处理器的行为,包括使用32位地址空间、寄存器以及指令集,从而确保与32位操作系统和应用程序的完全兼容。尽管CPU本身是64位的,但在这种模式下,它能够有效地回退到32位时代的技术要求,保证软件的正常运行。这种能力让64位系统具备了良好的向后兼容性,使得用户可以在现代硬件上继续运行较老的32位软件资源。
附注2:补码
这个之前写过,直接看 笑虾:学习笔记:原码, 反码, 补码
附注3:汇编编译器(masm.exe)对jmp 的相关处理
1. 向前转移
s: :::
jmp s ( jmp short s, jmp near ptr s, jmp far ptr s )
编译器中有一个地址计数器
(AC ) ,
编译器在编译程序过程中,每读到一个字节AC
就+1
。
当编译器遇到一些伪操作的时候,也会根据具体情况使AC
增加,如db
、dw
等。
在向前转移时,编译器可以在读到标号s
后记下 AC 的值 as
,
在读到jmp … s
后记下AC的值aj
。
然后用as – aj
算出位移量disp
。
1.1. 当 disp ∈ [-128, 127]
书中描述
此时无论是 jmp s
, jmp short s
, jmp near ptr s
, jmp far ptr s
都会转为 jmp short s
。
assume cs:code
code segment
s: jmp sjmp short sjmp near ptr sjmp far ptr s
code ends
end s
上机验证:与书中描述并不符
Debug查看,显示结果与书中描述并不一样。
简单的说 jmp s
转成了 jmp short s
,而 jmp near ptr s
、jmp far ptr s
原样不变。
可能与作者当时的 masm 版本有关???疑问中。。。
assume cs:code
code segment
s: jmp s ; 076E:0000 EBFE JMP 0000 此时IP已是2,所以位移-2(补码 FE)jmp short s ; 076E:0002 EBFC JMP 0000 此时IP已是4,所以位移-4(补码 FC)jmp near ptr s ; 076E:0004 E9F9FF JMP 0000 此时IP已是7,所以位移-7(补码 FFF9)jmp far ptr s ; 076E:0007 EA00006E07 JMP 076E:0000 far 是绝对地址跳转。段地址:偏移地址
code ends
end s
使用指令 | 机器码 | 机器码对应指令 |
---|---|---|
jmp s | EB disp (disp 1字节,共占2字节) | jmp short s |
jmp short s | EB disp (disp 1字节,共占2字节) | jmp short s |
jmp near ptr s | E9 disp (disp 2字节,共占3字节) | jmp near ptr s |
jmp far ptr s | EA disp:disp (disp 4字节,共占5字节) | jmp far ptr s |
1.2. 当 disp ∈ [-32768, 32767]
书中描述
指令 | 结果 | 占空间 |
---|---|---|
jmp short s | 编译报错 | |
jmp s jmp near ptr s | 生成 jmp near ptr s 对应机器码 E9 disp | disp 2字节,共占3 字节 |
jmp far ptr s | 生成 jmp far ptr s 对应机器码 EA disp:disp | 2个disp 4字节,共占5 字节 |
编译以下程序 jmp short s
将产生编译错误,去掉后再编译即可成功。
assume cs:code
code segment
s: db 100 dup (0b8h, 0, 0) ; 重复 (0b8h , 0 , 0 ) 100次,共占 300 字节jmp short s ; disp 已经超出 short 的偏移范围,将产生编译错误jmp sjmp near ptr sjmp far ptr s
code ends
end s
上机验证:与书中描述一至
用 Debug 进行反汇编查看如下图:
使用指令 | 机器码 | 机器码对应指令 |
---|---|---|
jmp short s | 产生编译错误 | |
jmp s | E9 disp (disp 2字节,共占3字节) | jmp near ptr s |
jmp near ptr s | E9 disp (disp 2字节,共占3字节) | jmp near ptr s |
jmp far ptr s | EA disp:disp (两个disp 各2字节,共占5字节) | jmp far ptr s |
2. 向后转移
jmp s ( jmp short s, jmp near ptr s, jmp far ptr s )::
s: :
在这种情况下,编译器先读到 jmp ... s
指令。由于还没读到标号s
,不知道它的 AC值,也就无法算出位移量 disp
的大小。
此时,编译器将记下当前 jmp
指令的位置和 AC 值 aj
。并生成相应机器码
和对应数量的占位符
:
指令 | 机器码 | 预留 |
---|---|---|
jmp short s | EB nop | 预留1字节空间,存放8位 disp |
jmp s | EB nop nop | 预留2字节空间,存放16位 disp |
jmp near ptr s | EB nop nop | 预留2字节空间,存放16位 disp |
jmp far ptr s | EB nop nop nop nop | 预留4字节空间,存放段地址 和偏移地址 |
然后编译器继续工作,向后读到 标号s
时,记下 AC 的值as
,并计算出转移的位移量: disp = as-aj
。
此时,编译器作如下处理:
2.1. 当 disp ∈ [-128, 127]
书中描述
填充占位:
对于jmp s
和 jmp near ptr s
,机器码 EB disp
后还有1条 nop
指令
对于 jmp far ptr s
格式,在机器码 EB disp
后还有3条 nop
指令。
指令 | 机器码 | 预留空间 |
---|---|---|
jmp short s | EB disp | 预留1字节空间,存放8位 disp |
jmp s | EB disp nop | 预留2字节空间,存放16位 disp |
jmp near ptr s | E8 disp nop | 预留2字节空间,存放16位 disp |
jmp far ptr s | E8 disp nop nop nop | 预留4字节空间,存放段地址 和偏移地址 |
编译,连接以下程序,用 Debug 进行反汇编查看。
assume cs:code
code segment
begin:jmp short sjmp sjmp near ptr sjmp far ptr s
s: mov ax,0
code ends
end begin
上机验证:与书中描述并不符
Debug查看效果如下图:
这是 jmp s
最终生成的是 EB08
与书描述的预留 EB nop nop
不符,可能原因有2:
- 书上说错了。
- 编译器对结果进行了优化,去掉了多出来的那个
nop
。
(继续向下看,有新发现)
2.2. 当 disp ∈ [-32768, 32767]
书中描述
编译以下程序将产生编译错误,错误是由 jmp short s
引起的,去掉后再编译就可以通过。
assume cs:code
code segment
begin:jmp short s ; disp 已经超出 short 的偏移范围,将产生编译错误jmp sjmp near ptr sjmp far ptr sdb 100 dup (0b8h, 0, 0) ; 重复 (0b8h , 0 , 0 ) 100次,共占 300 字节
s: mov ax,2
code ends
end begin
用 Debug 进行反汇编查看:
上机验证:与书中描述一至
从这里可以看到 jmp s
最终生成的是 E93401
与书描述的预留两个 nop
相符,
我们可以合理推测当 disp ∈ [-128, 127] 时,编译器对最终结果时行了优化,去掉了多余的 nop
。
3. 总结
向前跳转的处理
-
位移量计算:在向前跳转时,编译器能够直接计算出从
跳转指令
到目标标号
的位移量(disp
),因为它已经遇到了目标标号并记录了其位置。此偏移量基于代码段内的地址差值。 -
位移量超出短跳转范围:若计算得出的位移量超出了短跳转所能覆盖的范围(即不在
[-128, 127]
内),编译器会生成适合的跳转指令。jmp short s
会导致编译错误,jmp near ptr s
,会生成E9 disp
格式,其中disp
是位移量的补码形式,占2字节,支持更广泛的地址空间。jmp far ptr s
,则涉及段间跳转,生成EA disp:disp
格式,包含段地址2字节,偏移地址2字节。
向后跳转的处理
-
占位符使用:
由于编译器在遇到跳转指令时尚未读到目标标号,它会先生成跳转指令的占位符,并预留足够的空间以待后续填写实际的位移量。对于不同的jmp类型,预留空间大小不同。 -
位移量确定与回填:
- 一旦所有指令长度确定,编译器会回过头来计算实际的位移量。对于向后的
jmp near ptr s
,如果计算出的位移在短跳范围内,尽管理论上可以编码为EB disp
,但根据指令原意,通常会保持为E9 disp
格式,确保代码兼容、稳定和语义的一至性。 - 对于
jmp far ptr s
,无论位移大小,最终都会根据实际计算填写完整的远跳转指令,即EA disp:disp
,确保段间正确跳转。
- 一旦所有指令长度确定,编译器会回过头来计算实际的位移量。对于向后的
通过这种方式,无论是向前还是向后跳转,编译器都能确保生成正确的机器码,满足程序执行时的跳转需求。
附注4:用栈传递参数
调用者将参数入栈,子程序从栈中取参数。
栈操作的基本单位是字
(2字节)。
所以调用者压栈n
个参数,子程序使用完后,返回时就 ret 2n
指令 ret n
的含义用汇编语法描述为:
pop ip
add sp, n
assume cs:code
code segmentmov ax,1 ; 参数 bpush axmov ax,3 ; 参数 apush axcall difcubemov ax,4c00hint 21h;说明: 计算(a-b)^3,a、b 为字型数据 word
;参数: 进入子程序时,栈顶存放IP,后面依次存放a、b
;结果: (ds:ax)=(a-b)^3difcube:push bpmov bp,spmov ax,[bp+4] ; 将栈中a的值送入ax中sub ax,[bp+6] ; 减栈中b的值mov bp,axmul bpmul bppop bpret 4code ends
end
C 语言示例
通过一个 C 语言程序编译后的汇编语言程序,看一下栈在参数传递中的应用。
在C语言中,局部变量也在栈中存储。
void add (int,int,int);main()
{int a=1;int b=2;int c=0;add(a,b,c);c++;printf("c = %d", c);
}void add(int a,int b,int c)
{c=a+b;
}
不知道书上是用什么编译的,我这里用 tcc -S demo.c
编译的和书上不太一样:
(略掉部分无关代码)
_TEXT segment byte public 'CODE'
_main proc nearpush bp ; 备份调用者栈帧基址mov bp,sp ; 创建 main 当前栈帧sub sp,2 ; 开辟 2 字节局部空间push si ; 备份寄存器push dimov di,1 ; int a=1;mov word ptr [bp-2],2 ; int b=2;xor si,si ; int c=0;push si ; 参数 c 压栈push word ptr [bp-2] ; 参数 b 压栈push di ; 参数 a 压栈call near ptr _add ; add(a,b,c); add sp,6 ; add 返回后清掉栈中的3个参数inc si ; c++;pop di ; 还原寄存器pop simov sp,bp ; 销毁 main 当前栈帧pop bp ; 恢复调用者栈帧基址ret ; 返回
_main endp_add proc nearpush bp ; 备份调用者栈帧基址mov bp,sp ; 创建 add 当前栈帧mov ax,word ptr [bp+4]add ax,word ptr [bp+6]mov word ptr [bp+8],axpop bp ; 恢复调用者 main 栈帧基址ret ; 返回
_add endp
_TEXT endspublic _main ; 声明为公共public _add ; 声明为公共
end
附注5:公式证明
证明公式:X/N = int(H/N) * 65536 + [ rem(H/N) * 65536 + L ] / N
不会溢出
分析:
原理:分而治之各各击破
- 一个
32位
数除以16位
数的结果,对于16位
寄存器并不是总能装下。如:
1111 1111 1111 1111 1111 1111 1111 1111 ; 32位
0111 1111 1111 1111 1111 1111 1111 1111 ; 除以2相当于右移一位。; 这 31 位的结果 16 位寄存器肯定是存不下的
- 但是如果把
高位
,低位
拆分开来处理,就是16位对16位了,无论如何都能存下。
1111 1111 1111 1111 ; 16位
1111 1111 1111 1111 ; 除以 1 够狠了吧,16位寄存器还是能装下
- 最后再将
高位
,低位
的结果各自放对应位置上就组成了正确的结果。
公式分解
让我们逐步分析这个公式的各个部分,以及它是如何避免溢出的。
公式为:X/N = int(H/N) * 65536 + [ rem(H/N) * 65536 + L ] / N
- (X) 是
32
位的被除数
,范围是[0
,FFFFFFFF
](即0 ~ 4,294,967,295
)。 - (N) 是
16
位的除数
,范围是[0
,FFFF
](即0 ~ 65535
)。 - (H) 是(X)的高
16
位,范围是[0
,FFFF
]。 - (L) 是(X)的低
16
位,范围也是[0
,FFFF
]。
证明过程
-
处理高16位(H):
int(H/N) * 65536
int(H/N)
:这部分计算H
除以N
的整数部分,结果范围是[0
,65535
],
因为H
最大为65535
,除以最大除数65535
时的整数商 =1
。
1
再乘以65536
(即2^16
)后,结果范围 [0
,65536
] 在 [0
,4,294,967,295
] 之内,不会溢出32
位。 -
处理高16位余数 + 低16位(L):
[ rem(H/N) * 65536 + L ] / N
低16位
要和高16位
的余数
合并处理,[ rem(H/N) * 65536 + L ]
这部分最大 [0
,4,294,901,759
]
仍然小于(2^32
即4,294,967,296
) 属于安全范围。计算余数 取值范围 10进制表示 16进制表示 rem(H/N)
[ 0
,N-1
]= [ 0
,65535-1
]
= [0
,65534
][ 0
,FFFE
]rem(H/N) * 65536
[ 0
,(N-1) * 65536
]= [ 0
,65534 * 65536
]
= [0
,4,294,836,224
][ 0
,FFFE 0000
]rem(H/N) * 65536 + L
[ 0
,(N-1) * 65536 + 65535
]= [ 0
,4,294,836,224 + 65535
]
= [0
,4,294,901,759
][ 0
,FFFE FFFF
]
- 余数性质之一:
余数 = 被除数 - 除数 × 商
。根据性质可知余数取值范围:0 <= 余数 < 除数
) - 这里公式中的乘以
65536
,并不需要真的在16位寄存器中去乘。
比如将结果
放到代表高16位
的dx
中,就相当于x 65536
了(也就是左16位,从低16位挪到高16位去了)。
伪代码分析验证
; 先处理32位被除数的高16位,【商】和【余数】一次 div 就到手mov ax,0ffffh ; dword 被除数的高 16位mov dx,0 ; 32位被除数,高16位放到 ax 去了,dx要补 0mov cx,2 ; 16位除数div cx ; 执行后,对应公式中这两段:; AX = int(H/N) * 65536 = 高16位商; DX = rem(H/N) * 65536 = 高16位余数push ax ; 暂存高 16 位的商; 再处理32位被除数低16位; 因为【高16位余数】已经在 dx 里(相当于已经 x 65536)mov ax,0ffffh ; 现在只要将低16位装进 ax 即完成了 [ 高16位余数 + L ]div cx ; 执行后,对应公式中这两段:; AX = [ 高16位余数 + L ] / N = 低16位商; DX = [ 高16位余数 + L ] / N = 低16位余数; 调整一下位置即可得到最终结果mov cx,dx ; 余数归位pop dx ; 高 16 位的商归位
寄存器 | 被除数 | 除数 | 商 | 余数 |
---|---|---|---|---|
ax | 低16位 | 低16位 | ||
dx | 高16位 | 高16位 | ||
cx | 16位 | 16位 |