很多人学汇编学到第六章就开始卡壳。前面几章的练习还停留在单个代码段里折腾指令,到了实验5,程序突然有了data段、stack段、code段三个段,一堆段寄存器、伪指令、入口标号混在一起,不亲手跑一遍很容易晕。这篇笔记就是我自己做实验5的完整复盘,把每一行代码的作用、每一个填空的来龙去脉、Debug里怎么验证都记了一遍。如果你正在读《汇编语言(第4版)》并做到这个实验,或者打算提前预习,这篇内容可以直接当作操作手册来用。
先说结论:实验5表面上是三道编程题,实际上只围绕三件事转——靠assume和mov完成段寄存器的初始化和关联、靠栈的后进先出机制搬数据、靠ASCII码的位运算做大写转换。把这三件事吃透,后面再学字符处理、课程设计都会顺畅很多。
1. 实验5在练什么:从单个代码段到三个段的跨越
1.1 为什么一个程序会需要多个段
前面几章写过的程序通常只有一个代码段,最多在里面放几个字节的数据。但实际应用不可能这么干:代码、全局数据、临时变量堆在一起,互相干扰不说,连“用栈保存现场”这种基本操作都做不了。8086的内存是分段模型,按照段的粒度来组织程序是最自然的方式,于是就有了数据段、栈段、代码段并存的情况。
data段用来存放有名字的全局数据,stack段用来给push/pop提供临时存储区域,code段放真正的指令。三者的分工和高级语言里的全局变量区、函数调用栈、可执行代码区是同一个思路。实验5故意把三个段都塞进一个程序,就是要逼你直面一个问题:段寄存器不会自己指向你定义的段,机器上电或程序加载后,CS、SS、DS各自指向哪里,必须自己心里有数。
这里有个容易踩的坑:很多人以为写了assume就完事了。assume只是告诉汇编器“我打算让某个段寄存器关联某个段”,它不生成任何机器指令,也不会真的给段寄存器赋值。DS、SS到底指向哪里,必须用mov指令手动设置。CS不需要你设,程序加载时由系统直接指向代码段入口。
用一张表说清楚:
| 段寄存器 | 程序加载后的初始指向 | 是否需要手动设置 | 设置方式 |
|---|---|---|---|
| CS | 代码段 | 不需要 | 由加载器设置,程序无法直接mov |
| SS | 系统分配的临时栈 | 通常需要 | mov ax, stack / mov ss, ax |
| DS | 程序段前缀PSP | 需要 | mov ax, data / mov ds, ax |
| ES | 程序段前缀PSP | 按需使用 | mov ax, data / mov es, ax |
这张表直接对应实验5第一题的填空题。记住DS的初始值不是data段,而是PSP,这一点后面调试时帮了我大忙。
1.2 段定义、入口标号和end伪指令的关系
多个段的定义语法很简单:用segment和ends把段包起来,每个段都要起个名字。名字只是个标号,编译连接后,连接器会为每个段分配一个段地址。代码段里还要写一个入口标号,通常写成start,然后在程序末尾用“end start”告诉编译器入口在哪里。
我见过不少初学者把“end start”理解成“结束start这个段”,这是不对的。end是整个源程序的结束标志,后面的start是可选参数,用来指定程序入口。如果省略不写,连接器会把程序入口设成0,也就是从代码段的第一条指令开始执行。对于实验5第一题这种入口恰好是代码段第一条指令的简单程序,省略start也许还能跑;但一旦代码段开头有数据或者其他初始化代码,IP从0开始执行就会完全跑偏。所以规范写法永远是end start。
段寄存器初始化的顺序也值得说一句。设置SS和SP时有个硬件特性:8086在执行mov ss,ax和紧接着的下一条指令之间会暂时屏蔽外部中断,防止栈指针还没设好就被中断程序压栈。所以写代码时一定要让“mov ss,ax”和“mov sp,立即数”两条指令连着写,中间不要插入其他操作。虽然我们现在调试时感觉不到中断的影响,但这个习惯必须养成。
2. 搭好调试环境,先把第一题的代码跑起来
2.1 现代系统上怎么跑16位程序
《汇编语言(第4版)》配套的MASM和Debug都是16位程序,直接放在Windows的64位环境里跑,系统一般会拒绝运行或者报错。最省事的方案是装一个DOSBox模拟器,再挂载一个目录当DOS工作盘。DOSBox对汇编实验的兼容性很好,MASM、LINK、Debug都能正常工作,而且单步调试时不会出现Windows下那种权限限制。
我自己的做法是解压一份masm工具集到一个目录,比如d:\dos,里面放masm.exe、link.exe、debug.exe三个文件。启动DOSBox后执行挂载命令:
mount c d:\dos c:然后就可以正常使用masm、link、debug了。编译连接一条龙:
masm test.asm; link test.obj; debug test.exe命令结尾的分号表示文件名都用默认值,避免交互式提问。Debug加载test.exe之后会停在整个程序的第一条指令之前,接下来就可以用r、d、t、g这些命令操作了。
2.2 第一题完整代码与逐句分析
实验5第一题给出的代码大致是下面这样的。我加了一些注释,方便后面对照分析。
assume cs:code, ds:data, ss:stack data segment dw 0123h, 0456h, 0789h, 0abch, 0defh, 0fedh, 0cbah, 0987h data ends stack segment dw 0, 0, 0, 0, 0, 0, 0, 0 stack ends code segment start: mov ax, stack mov ss, ax mov sp, 16 mov ax, data mov ds, ax push ds:[0] push ds:[2] pop ds:[2] pop ds:[0] mov ax, 4c00h int 21h code ends end start前面三行是设置栈段:先把stack段的地址放进ax,再通过ax送给ss,然后把sp设为16。这里为什么是16?因为stack段定义的是8个字型数据,一个字占2字节,一共16字节。8086的栈是向下生长的,push时sp先减2再写入数据,所以sp初始应该指向栈顶的下一个单元,也就是栈段底部地址再加16。这样第一次push会把数据写到栈段的最高两个字节。
接下来两行是把data段地址送入ds。这里注意,不能直接写“mov ds,data”,因为8086不允许把一个立即数段地址直接mov到段寄存器,必须先经过ax之类的通用寄存器中转。
真正让人思考的是最后四句push/pop。我一开始想当然地以为这几句会交换data段前两个字的数据,后来用Debug一跑才发现不是那么回事。顺着栈的后进先出走一遍:
- push ds:[0]:把0123h压入栈,sp变成14。
- push ds:[2]:把0456h压入栈,sp变成12。
- pop ds:[2]:弹出的是0456h,写回ds:[2],这个位置本来不就是0456h吗?没变。
- pop ds:[0]:弹出的是0123h,写回ds:[0],也没变。
所以四句指令跑完之后,data段的数据跟原来一模一样,一个字都没改。这就是第一题的核心考点:要能看出看似压栈出栈的操作,实际上等价于什么都没做。如果想真正交换ds:[0]和ds:[2],正确的写法应该是把最后两条pop的顺序调换一下:
push ds:[0] push ds:[2] pop ds:[0] pop ds:[2]先弹出的数据回到了低地址单元,后弹出的数据回到了高地址单元,两个字就互换了。这个对比放在一起,对栈的理解会一下清晰很多。
2.3 第一题的填空答案应该怎么填
第一题通常还跟着几道填空题,问程序返回前CS、SS、DS、SP分别是多少。这些数值在不同机器、不同DOS环境下不完全一样,所以不能死记硬背,要明白它们之间的关系。
程序被Debug加载后,DS初始指向PSP,也就是程序段前缀,它占据了256字节的系统信息区。CS指向代码段的起始地址。data段和stack段的段地址则取决于连接器为它们分配的位置。用Debug的r命令可以看到各寄存器的实际值,比如在某个环境下CS=076A,SS=077D,DS=0770,SP=16,那就能推断出code段在076A、stack段在077D、data段在0770附近。
SP的值值得单独说。如果一路用t单步执行到int 21h之前,SP应该是16,因为程序执行了两次push和两次pop,栈指针又回到了初始位置。但如果你继续按t执行int 21h,CPU会进入DOS的中断处理程序,系统会压入标志寄存器、CS、IP等返回信息,SP就会变成别的值。所以填空题里问的“程序返回前SP等于多少”,指的是用户程序最后一条指令执行前、即int 21h刚要执行时的SP,也就是16。这个细节在做题和调试时都很容易搞混。
另外,回答“data段中的数据是否改变”时,别只看理论推导,最好亲手在Debug里面验证。方法是在执行int 21h之前,用命令:
-d ds:0查看data段前16字节,应该看到一串 23 01 56 04 89 07 bc 0a ef 0d ed 0f ba 0c 87 09。注意这里是小端存储:一个字如0123h,在内存里是低字节在前、高字节在后,显示为23 01。很多初学者第一次看这串数据会对不上号,其实只是字节序问题。
3. 第二题:让栈把数据“倒过来”
3.1 题目要求与解题思路
第二题同样给了data段8个字型数据,要求编写code段代码,用栈把这8个字逆序存放回data段。原始数据从低地址到高地址依次是0123h、0456h、0789h、0abch、0defh、0fedh、0cbah、0987h,最后期望data段变成0987h、0cbah、0fedh、0defh、0abch、0789h、0456h、0123h。
思路其实非常直接:栈是后进先出,把8个字依次push进栈,再依次pop出来写回data段,弹出顺序就正好是压入顺序的逆序,天然满足题目要求。这一步也是对栈机制的一次完整实操:连续压栈、连续出栈、栈指针的变化规律,全部都要用到。
要实现这个功能,需要设置好三个要素:data段寄存器指向data段、stack段寄存器指向stack段、sp指向栈顶初始位置。剩下的就是循环控制。8个字是固定数量,用loop循环配合cx寄存器非常合适。关键点在于压栈时要把内存单元地址算准,一个字占2字节,所以bx每次要加2,而不是加1。
3.2 完整代码与压栈出栈过程推演
assume cs:code, ds:data, ss:stack data segment dw 0123h, 0456h, 0789h, 0abch, 0defh, 0fedh, 0cbah, 0987h data ends stack segment dw 0, 0, 0, 0, 0, 0, 0, 0 stack ends code segment start: mov ax, data mov ds, ax mov ax, stack mov ss, ax mov sp, 16 mov bx, 0 mov cx, 8 s_push: push ds:[bx] add bx, 2 loop s_push mov bx, 0 mov cx, 8 s_pop: pop ds:[bx] add bx, 2 loop s_pop mov ax, 4c00h int 21h code ends end start我建议第一次跑的时候不要一口气执行完,而是在push循环里用t单步走几圈,观察SP怎么从16变成14、12、10,直到变成0。这一步能直观看到栈向低地址方向增长。8次push完成后,栈段里的数据从低地址到高地址依次是0987h、0cbah、0fedh、0defh、0abch、0789h、0456h、0123h。注意,这和data段原始顺序正好相反,而且栈段内部存放的顺序是从栈底到栈顶倒排的。
然后开始pop循环。第一次pop从SS:0000取出0987h,写入ds:[0];第二次pop从SS:0002取出0cbah,写入ds:[2];依次下去。8次pop完成后,data段已经变成逆序,而SP重新回到16,栈再次空出来。整个过程严格对称,非常适合用Debug观察。
这里有个小细节值得注意:pop操作直接写内存单元,所以pop ds:[bx]的意思是“把栈顶数据弹出,存入ds段偏移bx处的字单元”。它等价于先读取SS:SP指向的字,然后SP加2,再把读到的值写入目标单元。写成这样在汇编语法上是合法的,也是这个实验要求的核心写法。
3.3 第二题最容易翻车的几个地方
第一坑是忘记初始化ds。如果少了“mov ax,data;mov ds,ax”,那么push ds:[bx]会从PSP区域取数据,压进栈里的根本不是data段的内容,最后写回data段的数据也是一堆莫名其妙的系统信息。
第二坑是sp设置不对。stack段只有16字节,sp必须设为16才能保证8次push刚好用完全部16字节。如果sp设成0,第一次push就会把数据写到栈段地址之前,也就是SS:0000-2处的内存,也就是栈段前面一个段的末尾。在DOS环境下这通常是一些系统数据区,直接往那里写轻则数据错乱,重则整个DOSBox崩溃。如果sp设成32,8次push只会用到低16字节,浪费空间倒还好,但栈指针最终停在16而不是0,整个栈的平衡感就没了。
第三坑是bx递增写错。有人会把add bx,2写成add bx,1,结果第二次push时读取的其实是同一个字的低字节和高字节,整个数据全乱。这个错误在Debug里看栈数据时会发现每一个字都变成两个字节的拼接,非常诡异。
第四坑是循环计数寄存器冲突。push和pop循环都用了cx,写完第一个loop后cx已经是0,再开始第二个循环前必须重新mov cx,8。如果忘记重置,第二个循环一次都不会执行。这个错误很隐蔽,因为编译连接都不会报错,只有运行结果不对,一定要靠调试才能发现。
我用Debug实际验证过的操作顺序是:编译连接后,先用g命令运行到int 21h之前,然后用d命令看data段和stack段。data段显示87 09 ba 0c ed 0f ef 0d bc 0a 89 07 56 04 23 01,和预期逆序一致。注意这里又是小端显示,0987h显示成87 09。如果看到的数据和题目答案对不上,先检查自己是不是按小端读的。
4. 第三题:一个大写转换背后的位运算思想
4.1 ASCII码里藏着一位开关
第三题的data段换成了字符串:
data segment db 'ibm ' db 'dec ' db 'dos ' db 'vax ' data ends每个字符串固定16字节,前3个是小写字母,后面13个是空格,用空格补齐到16字节。这样设计的目的是让每一行长度一致,寻址的时候可以按16的倍数跳转。
题目要求把每个单词的头一个字母改成大写。这里的关键知识点是小写字母和大写字母在ASCII码中的关系。'a'的ASCII码是61h,'A'是41h,两者相差20h。同理,'b'(62h)与'B'(42h)、'c'(63h)与'C'(43h)都相差20h。小写转大写,最直接的做法是减20h,但对已经是大写的字母再减20h就会变成控制字符,完全错误。
用二进制看会更清楚:'a'是0110 0001b,'A'是0100 0001b,两者只有第5位不同。小写字母第5位是1,大写字母第5位是0。所以只要把第5位清零,其他位保持不动,就能把小写字母变成大写字母。这个操作用一个and指令就能完成:
and al, 11011111b11011111b就是0DFh,把al的第5位强制清零。对已经是大小写字母的字符都不会造成额外破坏。
4.2 只改首字母的完整代码
assume cs:code, ds:data data segment db 'ibm ' db 'dec ' db 'dos ' db 'vax ' data ends code segment start: mov ax, data mov ds, ax mov bx, 0 mov cx, 4 s: mov al, [bx] and al, 11011111b mov [bx], al add bx, 16 loop s mov ax, 4c00h int 21h code ends end start这段代码的思路很简单:data段的第0、16、32、48字节分别是四个单词的首字母,用bx从0开始,每处理完一行加16,循环4次。mov al,[bx]把当前行的首字母取出来,and之后写回去,就完成了转换。
为什么是加16而不是加3?因为每行定义是16字节,下一行的首字母在第16个字节处。如果数据段里每行只精确写了3个字节而不补空格,那寻址就只能加3。这里补空格的好处就是把行长度固定下来,访问规则变得非常规整,这也算是一点工程上的经验。
用Debug验证时,执行完程序后查看data段,能看到每个字符串首字母变成大写:'Ibm'、'Dec'、'Dos'、'Vax'。在Debug的d命令右侧字符显示区可以非常直观地看到这种变化。
4.3 扩展:把整个单词都改成大写的做法
实验5只要求改首字母,但把整个单词都改成大写是非常好的进阶练习。这里会碰到一个经典的嵌套循环问题:外层循环控制行数,内层循环控制每行的3个字母,而loop指令只会用cx计数,内外层共用cx必然冲突。
解决办法有很多,最符合本章主题的是用栈保护外层计数。思路是进入内层循环前先把cx压栈保存,内层循环结束后再弹出来继续外层循环。这样一来,栈的运用和第3题完全呼应上了。
assume cs:code, ds:data data segment db 'ibm ' db 'dec ' db 'dos ' db 'vax ' data ends code segment start: mov ax, data mov ds, ax mov bx, 0 mov cx, 4 s0: push cx mov si, 0 mov cx, 3 s1: mov al, [bx + si] and al, 11011111b mov [bx + si], al inc si loop s1 pop cx add bx, 16 loop s0 mov ax, 4c00h int 21h code ends end start内层用si作为行内偏移,从0到2处理3个字母。外层用bx定位行首,处理完一行后bx加16。push cx和pop cx分别安排在内层循环前后,外层cx的值被完整保护下来。这个模式在以后的汇编编程里会反复出现,务必要吃透。
如果不用栈保护,也可以把外层循环计数放到dx里,或者用内存单元保存。但栈保护法在这章里自然、顺手,还能加深对栈临时保存数据这一作用的理解。
5. 实验5调试实录与高频问题排查
5.1 调试时的完整操作流程
我强烈建议做实验5时不要只在纸上写代码然后编译一次看结果,而是把Debug的看家本领全部用上。下面是针对第二题最有效的调试路径。
编译连接后进入Debug:
debug test.exe先输入r,观察CS、SS、DS、SP、IP。加载完成后,CS应该指向code段,IP指向start标号对应的那条指令。DS指向PSP,SP指向系统初始的栈位置。
输入g命令运行到我们关心的程序入口之前,比如直接输入:
g 地址地址可以用-u反汇编后看到的指令地址,也可以用相对偏移。不过对实验5来说,直接单步走更直观。输入t执行一条指令后,观察寄存器变化:执行mov ax,data后ax变成data段地址;执行mov ax,stack后ax变成stack段地址;执行mov ss,ax后SS变化;执行mov sp,16后SP变成16。每一步都对应源代码的一行,这种对应关系对理解汇编指令的执行机制特别有效。
在push循环里,每次按t前可以先用d ss:0查看栈段当前内容,看数据是怎么从高地址往低地址填的。在pop循环里再查看ds:0,看data段是怎么被从逆序覆盖回来的。
要查看程序运行结果时,千万别等整个程序跑完退出Debug再重新加载。因为int 21h执行后程序返回DOS,DS等寄存器的值会被系统改掉,那时候再看内存就不是程序的数据段了。正确做法是使用g命令让程序停在int 21h这一行之前,然后再用d命令查看data段和stack段。
比如先反汇编:
-u找到“int 21h”那条指令的地址,比如076D:0012,然后执行:
-g 076d:0012程序就会运行到int 21h前停下来,此时查看内存正是程序执行完push/pop之后、尚未返回DOS的状态,和实验题里问的“程序返回前”完全对应。
5.2 高频问题速查表
做实验5时我踩过不少坑,也帮别人排查过类似问题,下面这些现象是最常见的。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 编译时报错,提示符号未定义 | 标号写错、end写成ends | 检查start标号和end start拼写 |
| 编译时报错,提示行尾有垃圾字符 | 行尾多打了中文符号,或使用了全角空格 | 把源文件中的标点全部改成英文半角 |
| 程序一运行就卡死或无响应 | 栈越界,sp初始值设置错误 | 确认stack段大小,sp指向栈顶的下一个单元 |
| 程序能运行但data段并没有逆序 | 忘记初始化ds,bx增量写成1 | 检查mov ax,data和mov ds,ax,add bx改2 |
| data段数据变成了系统信息 | 程序退出后才查看内存,DS已经变了 | 在int 21h之前用g停住,再查看内存 |
| debug中看到的数据和答案对不上 | 把小端存储当成大端读 | 记住一个字在内存中低字节在前,如0123h显示为23 01 |
| 只写了end,没有end start,程序跑飞 | IP从0开始执行,可能执行了非指令数据 | 写end start指定入口,或确认代码段开头就是start |
这些坑大多数不是编译层面的,而是运行和调试层面的,不亲手做一遍根本意识不到。
5.3 几个越用越顺手的调试技巧
调试汇编程序时,我习惯把寄存器窗口和内存窗口配合着看。每次执行完一条指令,先用r看寄存器的变化,再用d看对应内存段的变化。因为8086的寄存器数量有限,对照起来很快就能定位是哪个环节出了问题。
定位循环类问题最有效的招数是“缩小循环次数”:临时把cx改成2或者1,跑完看内存变化对不对,确认逻辑没问题后再改回8。这样做的好处是把复杂循环的执行路径先简化,避免一次出错不知道是循环体的问题还是循环次数的问题。
另外,我每次修改源程序后,会把旧的obj文件删掉再重新编译连接。MASM和LINK在遇到重名文件时有时候会用旧的obj继续连接,导致Debug里运行的程序根本不是新编译的版本。这个操作虽然简单,但能省掉很多莫名其妙的“为什么改了没效果”的困扰。
还有一个小技巧:保存一份“源文件加注释”的副本,专门用来记录每个版本改了哪里。做实验5的时候,我在第一题、第二题、第三题之间来回切换代码,经常改完这个忘了那个,有版本记录就能随时回退。
最后说点实际的
做完实验5,我重写了三遍代码,每一遍的收获完全不同。第一遍拿着书上的代码照抄,跑通后只觉得“原来如此”;第二遍不看答案自己写第二题,写到push循环时才发现sp该设成16这个值并不是理所当然的,要自己算一遍才能理解;第三遍尝试把所有三段代码合并成一个程序,调试时被cx冲突问题折磨了半天,才真正体会到为什么内层循环计数要用栈保存。
如果你正在做这个实验,我建议你也试试这个流程:先不看任何参考把第一题的程序从零敲出来,用Debug单步跑完,回答出每个填空;再独立写出第二题的循环,用d命令验证逆序;最后给第三题写一个能同时处理首字母和全部字母的版本。这一套做完,多个段、栈、地址计算、循环控制这些知识点基本就焊死在脑子里了。
附上我调试时常用的几条Debug命令,初次接触的人可以先抄下来,边用边记:r查看寄存器,d查看内存,u反汇编,t单步执行,g运行到指定地址,q退出Debug。所有的分析都要落实到内存变化上,这是汇编学习和高级语言学习最大的不同。