EXAMPLE
Intro(汇编语言(第4版)王爽-阅读笔记) #
9.1 复制指令(8BC3)到指定位置 #
有如下程序段,添写两条指令,使该程序在运行中将。处的一条指令复制到s0处。
9.2 利用jcxz指令查找内存中的内容 #
补全程序,利用 jcxz 指令,实现在内存2000H 段中查找第一个值为0的字节,找到后,将它的偏移地址存储在 dx 中。
9.3 使用loop指令查找内存中的内容 #
补全程序,利用 loop指令,实现在内存2000H 段中查找第一个值为0的字节,找到后,将它的偏移地址存储在 dx 中
实验8 分析一个奇怪的程序 #
分析下面的程序,在运行前思考:这个程序可以正确返回吗?
运行后再思考:为什么是这种结果?
通过这个程序加深对相关内容的理解。实验9 根据材料编程 #
Note
80×25 彩色字符模式显示缓冲区(以下简称为显示缓冲区)的结构: 内存地址空间中,B8000H~BFFFFH共 32KB的空间,为 80×25 彩色字符模式的显示缓冲区。向这个地址空间写入数据,写入的內容将立即出现在显示器上。
在80×25彩色字符模式下,显示器可以显示25行,每行80个字符,每个字符可以有 256 种属性(背景色、前景色、闪烁、高亮等组合信息)。
这样,一个字符在显示缓冲区中就要占两个字节,分别存放字符的 ASCII 码和属性。80×25模式下,一屏的内容在显示缓冲区中共占4000个字节。显示缓冲区分为8页,每页 4KB(≈4000B),显示器可以显示任意一页的内容。一般情况下,显示第0页的内容。也就是说通常情况下,B8000H〜B8F9FH 中的4000个字节s的内容将出现在显示器上。

10.2 执行自定义汇编代码 #
下面的程序执行后,ax中的数值为多少?

通过汇编代码(p10.1)跳转到指定位置,然后通过e 1000:0写入汇编指令,验证执行效果。
10.8 mul/div 指令 #
a*b 8bit同乘 16bit同乘 8bit 16bit 16bit 32bit a al ax b reg reg rst AX DX * 1000H + AX (1)两个相乘的数:两个相乘的数,要么都是8位,要么都是 16位。如果是8位,一个默认放在AL中,另一个放在8位 reg或内存字节单元中;如果是 16位,一个默认在 AX中,另一个放在 16位 reg 或内存字单元中。(2)结果:如果是 8位乘法,结果默认放在 AX 中;如果是 16位乘法,结果高位默认在 DX 中存放,低位在 AX 中放。
a/b 8bit除数 16bit除数 8bit 16bit 16bit 32bit a ax DX * 1000H + AX b reg reg rst AH(余数)、AL(商) DX(余数)、AX(商) (1)除数:有8位和16位两种,在一个 reg 或内存单元中。(2)被除数:默认放在 AX或 DX和 AX中,如果除数为8位,被除数则为 16 位, 默认在AX 中存放;如果除数为 16位,被除数则为32位,在 DX和AX中存放,DX 存放高16位,AX 存放低16位。(3)结果:如果除数为 8位,则 AL存储除法操作的商,AH 存储除法操作的余数;如果除数为16位,则AX存储除法操作的商,DX存储除法操作的余数。
10.10 call/ret 指令 #
编程,计算 data 段中第一组数据的3次方,结果保存在后面一组 dword 单元中。

10.11 批量数据的传递 #
10.12 寄存器冲突的问题 #
设计一个子程序,功能:将一个全是字母,以 0 结尾的字符串,转化为大写。程序要处理的字符串以 0 作为结尾符,这个字符串可以如下定义:
db 'conversation',0。
分析:不使用长度参数,以末尾的 0 来判断当前字符串结束,可以使用 jcxz,借助cx比较,而上一个转换子程序内部的 loop 指令也会用到cx,从而导致内外cx冲突。
解决:一种有效的解决办法是,在子程序中将子程序内部需要用到的寄存器入栈,返回时出栈到相应的寄存器,达到恢复现场的目的。
这样做的好处是:外部不需要关心子程序内部实现细节,子程序也不用关心外部如何调用。
实验10 编写子程序 #
Note
二刷此书,第一次距今已然十载,当时写的这个程序的笔记还在博客 【汇编语言(王爽)实验十 编写子程序】里。期间也偶尔翻到,只剩成就感了,至于里面的东西,别说代码了,就是格式也显得陌生,正好利用这次机会重拾一下,顺便把里面的小问题尽量修复。
1.显示字符串 #
问题:显示字符串是现实工作中经常要用到的功能,应该编写一个通用的子程序来实现这个功能。我们应该提供灵活的调用接口,使调用者可以决定显示的位置(行、列)、内容和颜色。
子程序描述
名称: show str
功能:在指定的位置,用指定的颜色,显示一个用0结束的字符串。
参数: (dh)=行号(取值范围0~24), (dl)=列号(取值范围0~79), (cl)=颜色,ds:si指向字符串的首地址
返回:无
应用举例:在屏幕的8行3列,用绿色显示 data 段中的字符串。
提示
(1)子程序的入口参数是屏幕上的行号和列号,注意在子程序内部要将它们转化为显存中的地址,首先要分析一下屏幕上的行列位置和显存地址的对应关系:
(2) 注意保存子程序中用到的相关寄存器:
(3)这个子程序的内部处理和显存的结构密切相关,但是向外提供了与显存结构无关的接口。通过调用这个子程序,进行字符串的显示时可以不必了解显存的结构,为编程提供了方便。在实验中,注意体会这种设计思想。
2. 解决除法溢出的问题 #
问题:前面讲过,div 指令可以做除法。当进行8位除法的时候,用 al 存储结果的商,ah 存储结果的余数;进行16位除法的时候,用 ax 存储结果的商,dx 存储结果的余数。可是,现在有一个问题,如果结果的商大于 al或ax 所能存储的最大值,那么将如何? 比如
11000H / 1H,11000H 在 AX 中放不下。将引发 CPU 的一个内部错误,这个错误被称为:除法溢出。
子程序描述
名称:divdw
功能:进行不会产生溢出的除法运算,被除数为 dword 型,除数为 word型,结果为 dword 型。
参数:(ax)=dword 型数据的低16位
(dx)=dword 型数据的高16位
(cx)=除数
返回:(dx)=结果的高 16位,(ax)结果的低 16位 (cx)一余数
应用举例:计算1000000/10(F4240H/OAH)
mov ax,4240H
mov dx,000FH
mov cx, 0AH
call divdw
结果:(dx)=0001H, (ax)=86A0H, (cx)=0Warning
编码貌似很简单,但是里面的逻辑比较复杂,书中给出了提示,使用公式即可:\(X/N = int(H/N)*65536+[rem(H/N)*65536+L]/N\),附录里面也有推导过程,对我来说比较晦涩,看了之前的记录里面提到了小甲鱼大佬视频 052第十章 Call和ret指令05 里面的一个简单示例,豁然开朗。


3.数值显示 #
问题:编程,将 data 段中的数据以十进制的形式显示出来。
data segment
dw 123,12666,1,8,3,38
data ends
这些数据在内存中都是二进制信息,标记了数值的大小。要把它们显示到屏幕上,成为我们能够读懂的信息,需要进行信息的转化。比如,数值 12666,在机器中存储为二进制信息:0011000101111010B(317AH),计算机可以理解它。而要在显示器上读到可以理解的数值 12666,我们看到的应该是一串字符:“12666”。由于显卡遵循的是 ASCII 编码,为了让我们能在显示器上看到这串字符,它在机器中应以 ASCII 码的形式存储为:31H、32H、36H、36H、36H(字符 “0”~“9” 对应的 ASCII 码为 30H~39H)。
通过上面的分析可以看到,在概念世界中,有一个抽象的数据 12666,它表示了一个数值的大小。在现实世界中它可以有多种表示形式,可以在电子机器中以高低电平(二进制)的形式存储,也可以在纸上、黑板上、屏幕上以人类的语言“12666”来书写。现在,我们面临的问题就是,要将同一抽象的数据,从一种表示形式转化为另一种表示形式。可见,要将数据用十进制形式显示到屏幕上,要进行两步工作:
(1) 将用二进制信息存储的数据转变为十进制形式的字符串;
(2) 显示十进制形式的字符串。
第二步我们在本次实验的第一个子程序中已经实现,在这里只要调用一下 show_str 即可。我们来讨论第一步,因为将二进制信息转变为十进制形式的字符串也是经常要用到的功能,我们应该为它编写一个通用的子程序。
子程序描述
名称:dtoc
功能:将 word型数据转变为表示十进制数的字符串,字符串以0为结尾符。
参数:(ax)=word 型数据 ds:si指向字符串的首地址
返回:无
应用举例:编程,将数据 12666 以十进制的形式在屏幕的 8 行 3 列,用绿色显示出来。在显示时我们调用本次实验中的第一个子程序 show_str。
提示:下面我们对这个问题进行一下简单的分析。
(1) 要得到字符串“12666”,就是要得到一列表示该字符串的 ASCII 码:31H、32H、 36H、 36H、36Hj。十进制数码字符对应的ASCII码 = 十进制数码值+30H。要得到表示十进制数的字符串,先求十进制数每位的值。例:对于 12666,先求得每位的值:1、2、6、6、6。再将这些数分别加上 30H,便得到了表示 12666 的 ASCII 码串:31H、32H、36H、36H、36H。
(2)那么,怎样得到每位的值呢?采用下面的方法:
(4) 对(3)的质疑。在已知数据是 12666 的情况下,知道进行5次循环。可在实际问题中,数据的值是多少程序员并不知道,也就是说,程序员不能事先确定循环次数。那么,如何确定数据各位的值已经全部求出了呢?我们可以看出,只要是除到商为0,各位的值就已经全部求出。可以使用jcxz 指令来实现相关的功能。Caution
需要注意除法溢出,新版使用 16bit 除法,老版是上面的 divdw。但是老版本偏硬编码,5位数好使,其他位就不行了,比如 1024,13等。

研究试验3 使用内存空间 #
(2)编一个程序,用一条 C语句实现在屏幕的中间显示一个绿色的字符“a” #

(3)分析下面程序中所有函数的汇编代码,思考相关的问题。 #
问题:C 语言将全局变量存放在哪里?将局部变量存放在哪里?每个函数开头的 “push bp mov bp sp”有何含义?
分析:首先进入函数后先push bp,说明后续函数会使用到这个寄存器,把原来的值保存到栈里面,返回时恢复即可。
然后mov bp sp,让 bp 和 sp 指向同一内存单元,为后续分配栈空间做准备。下面的指令执行分配后 sp 会置顶,函数内部会使用基址 bp + 偏移的方式寻址。
接着sub sp,+06,栈空间的分配从高到底,3个 int,总共分配 3*2 = 6 个内存单元。
对于mov word ptr [01A6], 00A1来说,是全局变量/静态变量,采用 ds:[01A6] 的直接寻址方式进行操作。
对于mov word ptr [BP-06], 00B1而言,是局部变量,采用刚才的 bp 基址 + 偏移(-06)的方式操作。而且 -6=b1,-4=b2,-2=b3,如下图:
友情提示
在 Turbo C 2.0(16 位 DOS 环境)中,
int类型的大小是:2 字节 (16bit)
(4)分析下面程序的汇编代码,思考相关的问题 #
问题:C 语言将函数的返回值存放在哪里?
从汇编代码 figure01 中可以分析得知:1)、先将相加的结果存放到数据段中ab的地址处,然后复制到 AX(原来就是这个值,好像有点多余,应该是编译器的规范?),返回后将 AX 传送到c变量所在的栈地址。2)、如何使用的是 C代码注释中的局部变量 rst 的话,依旧是用 AX 寄存器返回,如图 figure02。3)、XOR SI,SI是一种高效清零的方式。友情提示
添加代码
int flag = 0x1A2B3C4D;是为了使用 debug 子命令 s 快速从代码段搜索需要的代码,上面介绍过这种环境下 sizeof(int)=2,但是此处给了四个字节,会导致前面的两个被丢弃。最终变成int flag = 0x3C4D;,从汇编代码中也可以看出来。如下 附注4-用栈传递参数 使用 long 型就没问题。

研究试验4 不用main 函数编程 #
在本研究试验中,我们看看如何不用 main 函数,编写可以正确运行的程序。我们用一个简单的程序来进行研究。
下面,我们研究如何用 tc.exe 对 f.c 进行编译,连接,生成可正确运行的 f.exe。我们用 c:\minic 下的 tc.exe 完成以下试验。(1) 把程序 f.c 保存在 c:\minic 下,对其进行编译,连接。思考相关的问题。 #
- ① 编译和连接哪个环节会出问题?
- ② 显示出的错误信息是什么?
- ③ 这个错误信息可能与哪个文件相关?
编译成功,但是链接失败。使用
objconv -ds NOMAIN.OBJ命令查看符号,发现只有一个 _f 符号。
(2) 用学习汇编语言时使用的 link.exe 对 tc.exe 生成的 f.obj 文件进行连接,生成 f.exe。用 Debug 加载 f.exe,察看整个程序的汇编代码。思考相关的问题。 #
- ① f.exe 的程序代码总共有多少字节?
- ② f.exe 的程序能正确返回吗? ❌
- ③ f 函数的偏移地址是多少?
f.exe 的程序的返回结果是随机的,有可能正确,但是大部分是错误的,有异常,因为程序没有正常中断返回。详情查看下方 异常追踪。
另外如果把 f 换成 main ,则文件大小变为了 4305B

异常追踪
刚开始我以为卡住或者 dosbox 奔溃是因为死循环引起的,出现这种意识是由于使用了 debug nomain.exe 进行调试,
每次执行完 ‘076C:001C’ 处代码 ret 的时候就会重新从“076C:0000”开始执行。
后面查阅资料反思到,可能是由于 debug 程序本身的干扰。但是没有 debug 我又调试不了。
换种思路?要是程序内部可以直接打印出 ret 之后跳转的 IP,也就是当前栈顶的那个值,不也可以继续追踪流程,判断问题嘛,
说干就干,写代码的时候发现原来的是 c 程序,应该怎么写汇编打印啊?内嵌汇编?不,我们可以直接使用将 c 反汇编后的代码,照抄过来,形成如下:
现在我们就可以在里面编写汇编将栈顶数据放入到 AX 里面,然后通过实验10里面的数值显示程序,将 AX的值,也就是 ret 之后跳转的地址以十进制的方式打印出来。
拿到地址了,现在怎么办?是错的?是对的?没法判断跟当前卡住的状态有什么关系?
反向思考一下,原来编写的汇编要想正常退出,一般都会有`int 21`之类的中断,如果我们给一个正确的退出地址,里面包含就是正常退出的代码,又会是什么效果?
继续干,修改代码如下:
最终发现脱缰的野马被牵回家了......
所以综上所述,就是因为没有调用到正常的退出中断导致的,一个随机或者固定的栈顶值,把我们的 CPU 陷入到了非法空间中
(没有看到无效指令之类的提示,这或许和 dosbox 模拟器的实现有关,就好像除法溢出没有报错书中的提示一样。然而这些已然不在我们的学习范畴了)。(3) 写一个程序 m.c #
用 tc.exe 对 m.c 进行编译,连接,生成 m.exe,用 Debug 察看 m.exe 整个程序的汇编代码。思考相关的问题。
- ① m.exe 的程序代码总共有多少字节?
- ② m.exe 能正确返回吗? ✅
- ③ m.exe 程序中的 main 函数和 f.exe 中的 f 函数的汇编代码有何不同?
使用 tc 编译链接的 main.exe 可以正常执行并且返回,但是如果使用 tc 编译,如上 link 链接的话,效果一样(卡住,碰到非法指令)。

(4) 用Debug 对m.exe 进行跟踪: #
- ① 找到对 main 函数进行调用的指令的地址;
- ② 找到整个程序返回的指令。注意;使用 g 命令和 p 命令。
一、先找到 main 函数汇编代码,可以通过 特征 或者 打flag 来搜索。一般 tc 编译链接的基本从
01F*开始。当前的为01FA。
然后重新启动debug main.exe,通过g 1fa执行到马上开始调用了,找被调用代码地址的方式有如下:
1、查看上一条 call 指令压入的栈下一条指令地址,也就是call XXX,011D中的011D。然后向上三个字节(011A)就是调用地址了。
2、使用 p 命令(不要进入函数内部)继续执行到 ret,这条执行完之后就会跳到被调用方下一条指令地址。然后向上三个字节(011A)就是调用地址了。二、找整个程序的返回地址也有如下方式:
1、找到调用地址后,一直使用u命令翻新后续汇编代码,直到找到mov ah,4c ... int 21代码。
2、用s cs:0 l ffff b4 4c搜索整个代码段,B44C = MOV AH,4C表示要终止当前程序并返回 DOS 了,而不能使用CD21 = INT 21的汇编代码来搜索,因为程序中可能包含很多个系统中断。

(5) 思考如下几个问题: #
- ① 对 main 函数调用的指令和程序返回的指令是哪里来的?
- ② 没有 main 函数时,出现的错误信息里有和“c0s”相关的信息;而前面在搭建开发环境时,没有 c0s.obj 文件 tc.exe 就无法对程序进行连接。是不是 tc.exe 把cOs.obj 和用户程序的.obj 文件一起进行连接生成.exe 文件? ✅
- ③ 对用户程序的 main 函数进行调用的指令和程序返回的指令是否就来自 c0s.obj 文件?
- ④ 我们如何看到 c0s.obj 文件中的程序代码呢?
- ⑤ c0s.obj 文件里有我们设想的代码吗?
根据原来的编译,链接 f.c 那个文件的时候报的错:未定义符号
_main在 C0S 模块中可知,应该是在 c0s.obj 中对 main 函数进行引用的。或者通过命令objconv -ds C0S.OBJ | grep main查看 c0s.obj 文件中引用的符号也可猜测一二,再加上后文中出现的各种 c0s 关键字,基本可以确定来自 c0s.obj。
至于 c0s.obj 里面到底是怎么样的,还得想办法处理。目前可以了解到的就是它是中间文件,格式是 MS OMF。对于目前的状态,有两种处理方式:一、使用 objconv 直接转换成 masm 风格汇编代码,还是比较厉害的(如下左):
objconv -fmasm c0s.obj objconv.c0s.asm二、使用 Trubo debugger 1.5 环境中的小工具 tdump,目前没有找到反编译的功能,只能解析文件:
解析 obj 文件:tdump -o C0S.OBJ > C0S.txt,生成 C0S.TXT
根据 FIXUPP 中对 EI[1] 的引用(因为 _main 是 EXTDEF 1 → EI[1])得知需要修正的地址在代码段偏移11b处。
我们将代码段通过 dd 命令提取出来:dd if=C0S.OBJ of=output.bin bs=1 skip=1164 count=504,偏移和长度在 txt 文件中都有注明。
反汇编二进制文件:ndisasm -b 16 -p intel output.bin | grep -C 10 '11A'

(6) 用link.exe 对 c:\minic 目录下的 c0s.obj 进行连接,生成 c0s.exe。 #
用 Debug 分别察看 c0s.exe 和 m.exe 的汇编代码。注意:从头开始察看,两个文件中的程序代码有何相同之处?

(7) 对两处的指令进行对比。 #
用 Debug 找到 m.exe 中调用 main 函数的 call指令的偏移地址,从这个偏移地址开始向后察看 10 条指令;然后用 Debug 加载 c0s.exe,从相同的偏移地址开始向后察看 10 条指令。对两处的指令进行对比。

(8) 从上我们可以看出,tc.exe 把c0s.obj 和用户.obj 文件一同进行连接,生成.exe 文件。按照这个方法生成的.exe 文件中的程序的运行过程如下。 #
- ① c0s.obj 里的程序先运行,进行相关的初始化,比如,申请资源、设置 DS、SS等寄存器:
- ② c0s.obj 里的程序调用main函数,从此用户程序开始运行;
- ③ 用户程序从main 函数返回到 c0s.obj 的程序中:
- ④ c0s.obj 的程序接着运行,进行相关的资源释放,环境恢复等工作;
- ⑤ c0s.obj 的程序调用DOS 的int 21h 例程的4ch号功能,程序返回。
看来,C 程序必须从 main 函数开始,是 C语言的规定,这个规定不是在编译时保证的(tc.exe 对 f.c 的编译是可以通过的),也不是连接的时候保证的(虽然,tc.exe 文件对 f.obj 文件不能连接成 f.exe,但 link.exe 却可以),而是用如下的机制保证的。
首先,C 开发系统提供了用户写的应用程序正确运行所必须的初始化和程序返回等相关程序,这些程序存放在相关的 .obj 文件(比如, c0s.obj)中。
其次,需要将这些文件和 用户.obj 文件一起进行连接,才能生成可正确运行的.exe文件。
第三,连接在 用户.obj 文件前面的由 C 语言开发系统提供的 .obj 文件里的程序要对 main 函数进行调用。基于这种机制,我们只要改写 c0s.obj,让它调用其他函数,编程时就可以不写 main 函数了。也可以多种方式:比如
一、最简单的,直接将 c0s.obj 文件中的符号
_main,改成其他四个字母的符号,比如:_fain,因为过长过短都会影响整个文件的解析,形成一个没法识别的脏文件。
二、写一个更简于书里面的 c0s.asm,编译后生成 c0s.obj,覆盖 tc.exe 自带的那个。主要用来对比,学习。发现 link.exe 好使,tc 编译链接也没得问题。

(9) 在 c:\minic 目录下,用 tc.exe 将 f.c 重新进行编译,连接,生成 f.exe。这次能通过连接吗?f.exe 可以正确运行吗?用 Debug 察看 f.exe 的汇编代码 #
从(8)中第二种方式就可以看出来是完全可以编译,链接,运行的。下面给出 debug 汇编代码。

研究试验5 函数如何接收不定数量的参数 #
(3) 实现一个简单的printf函数,只需要支持“%c、%d”即可。 #

附注4 用栈传递参数 #
这种技术和高级语言编译器的工作原理密切相关。我们下面结合 C 语言的函数调用,看一下用栈传递参数的思想。
用栈传递参数的原理十分简单,就是由调用者将需要传递给子程序的参数压入栈中,子程序从栈中取得参数。我们看下面的例子。
下面,我们通过一个 C语言程序编译后的汇编语言程序,看一下栈在参数传递中的应用。要注意的是,在C语言中,局部变量也在栈中存储。
从汇编代码中可以分析得知:1)、有些局部变量放在寄存器里面了(a->di,c->si),有些放在栈里面了(b->stack)。2)、在调用方法之前所有的参数会入栈,相当于变量复制,拷贝供函数使用,所以修改这些值并不会影响到原来的值。3)、XOR SI,SI是一种高效清零的方式。
