《汇编语言》- 读书笔记 - 附注
- 附注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 sjmp 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位 |