汇编语言的实验5——编写、调试具有多个段的程序,几乎是每个初学者第一次真正和“段”正面交锋。前几个实验里,代码、数据、栈全挤在一个代码段里,写起来很随意;到了实验5,程序必须把数据段、栈段、代码段分开定义,段寄存器的赋值、栈顶指针的初始化、内存偏移的计算全都要自己管。这篇博文把我做实验5时从编译、调试到踩坑的完整过程整理出来,给正在啃这本经典的汇编教材、卡在多个段上的朋友一个可复现的参考。不管是计算机专业的学生,还是准备补汇编基础的爱好者,这个实验都是一道绕不过去的坎。
1. 实验5的核心目标与为什么难
1.1 从单段到多段,难在哪
8086处理器的内存模型和现在X86保护模式不太一样,它靠“段地址:偏移地址”来定位物理地址。一个段在16位实模式下最多64KB,程序完全可以把所有东西都塞进一个64KB的段里,前面放代码,中间放数据,后面留一块当栈。前面几个实验基本就是这个套路,代码段里摆数据、预留栈空间,写起来省事,但问题也很明显:数据和栈挤在一起,稍不注意就互相覆盖;程序一复杂,偏移量算到头晕。
实验5把这个痛点直接摆上台面。程序开始用独立的段来装代码、装数据、装栈,这既是8086分段模型的常规用法,也是后来学习模块化程序的基础。难的地方不是指令本身,而是思维要切换:代码默认通过CS:IP取指,数据默认通过DS寻址,栈操作固定通过SS:SP,三套寄存器各管一摊,必须在程序的入口处自己把它们初始化对。
很多人卡在这个实验,不是因为不会写push、pop,而是不知道assume和实际段寄存器赋值之间的区别,也不知道程序加载后Debug里那些寄存器为什么和自己想的不一样。这些问题恰恰是实验5真正想让你搞清楚的。
1.2 实验5让你真正掌握的四个能力
这个实验做到位,其实是在练四件事。
第一,掌握多段的定义语法。段名 segment和段名 ends成对出现,段可以叫data、stack、code,也可以叫ABC,名字只是符号。数据段用dw、db定义数据,栈段也是普通内存,只是约定由SS:SP来管理,代码段里写指令。
第二,掌握assume伪指令的作用。assume ds:data只是告诉汇编器“我接下来打算让DS寄存器指向data段”,它本身不生成任何指令。段寄存器不会因为这个声明就自动装好,必须用mov ax, data; mov ds, ax这类指令当场赋值。这是新手最容易懵的地方。
第三,掌握栈段的初始化和使用。栈段定义多大,SP初始就该指到栈底加段大小的位置,因为栈是向下增长的。push指令先把SP减2,再把字数据写到SS:SP指向的内存;pop先读数据,再把SP加2。理解了方向,就不会把SP设成0。
第四,掌握用Debug观察整个过程。实验5很多思考题不是问“程序结果是什么”,而是问“程序加载后,DS、SS、SP、CS分别是多少”,这必须通过Debug查看,光在脑子里推是推不出来的。
2. 环境准备与一个可直接运行的多段程序骨架
2.1 用masm、link、debug搭一个最小实验环境
实验5的代码还是16位实模式程序,现在的主流系统不能直接跑,需要准备一个DOS环境。我习惯的做法是用一个DOS模拟器或虚拟机,里面放好masm.exe、link.exe、debug.exe三个文件,把它们和要编写的asm源文件放在同一个目录下,这样编译时不用折腾路径,对新手最稳。
编译流程是很原始的三步。第一步,运行masm test.asm,汇编器会提示输入目标文件名、源列表、交叉引用,直接按回车接受默认值即可,生成test.obj。第二步,运行link test.obj,连接器会提示输入可执行文件名、映射文件、库,同样一路回车,生成test.exe。第三步,运行debug test.exe,进入调试界面。
如果汇编时报错,先看源文件扩展名是不是.asm,再看是不是把标点和指令写成了全角字符。我见过太多人在这上面浪费半小时,汇编器报错行号指向一个看似无辜的冒号,其实是全角冒号。保持所有输入法符号为半角,这是最基本的习惯。
2.2 典型程序:用栈逆序数据段
实验5里有一个非常经典的题目:定义一个数据段,里面放8个字型数据;再定义一个栈段;用栈把数据段中的8个字逆序存放回来。正好把数据段、栈段、代码段都用到,还能观察压栈弹栈的过程。我直接给出一个可完整运行的程序:
; 实验5典型程序:利用栈将数据段中的数据逆序存放 assume cs:code, ds:data, ss:stack data segment ; 定义8个字型数据,共16字节 dw 0123h, 0456h, 0789h, 0abch, 0defh, 0fedh, 0cbah, 0987h data ends stack segment ; 栈段,8个字,共16字节 dw 0, 0, 0, 0, 0, 0, 0, 0 stack ends code segment start: ; 1. 数据段地址送DS mov ax, data mov ds, ax ; 2. 栈段地址送SS,SP指向栈底末尾 mov ax, stack mov ss, ax mov sp, 16 ; 3. 将数据段8个字依次压栈 mov cx, 8 mov si, 0 push_all: mov ax, ds:[si] push ax add si, 2 loop push_all ; 4. 依次弹出,写回数据段开头 ; 出栈顺序与入栈相反,数据被逆序 mov cx, 8 mov di, 0 pop_all: pop ds:[di] add di, 2 loop pop_all mov ax, 4c00h int 21h code ends end start这段程序可以直接复制到test.asm里编译运行。注意最后一行是end start,start是代码段里的入口标号,它告诉连接器程序该从哪条指令开始执行。
2.3 代码逐段拆解:段定义、assume和SP的来龙去脉
逐段看这个程序,能理清很多细节。
data segment到data ends定义了一个数据段,段内用dw定义了8个字。内存里这16个字节依次是23 01 56 04 89 07 bc 0a ...,因为8086是小端存储,高字节放高地址。用Debug看内存时看到的是“反着”的字节序,别以为是数据定义错了。
stack segment到stack ends定义了一个16字节的栈段。这段内存初值都是0,名字叫stack,本质上和data段没有区别,只是程序约定用SS:SP去操作它。
assume cs:code, ds:data, ss:stack是汇编期信息。它让汇编器在遇到ds:[si]这类寻址时知道DS对应哪个段,从而计算正确的偏移。但它不会给mov ds, ax帮忙,段寄存器的装载必须靠代码完成。
代码里先执行mov ax, data; mov ds, ax,把数据段的段地址装进DS。接着mov ax, stack; mov ss, ax; mov sp, 16,这里mov ss, ax和mov sp, 16之间有一个微妙的地方:8086在执行某些指令时会屏蔽中断,但mov ss, ax之后最好马上设置SP,否则中间如果来了中断,SS是新的、SP还是旧的值,栈就乱了。所以写程序时,mov ss, ax和mov sp, 16要连着写。
mov sp, 16是因为栈段总共16字节。栈空时SP应该指向栈顶“再往上”的位置,也就是段内偏移16。第一次执行push ax时,SP先变成14,然后把数据写到SS:14处,这正好是栈段的最后两个字。栈方向向下,这个概念后面调试时会特别明显。
3. 从编译到调试:一步步验证多个段
3.1 编译和连接的正确姿势
我把上面的代码存成test.asm后,执行masm test.asm。如果一切正常,会看到类似0 Severe Errors的提示,并生成test.obj。如果报错,需要回看源文件,常见错误是段名拼写不一致、标号重复、end start漏写,以及指令助记符和操作数之间没打空格。
接着执行link test.obj,连接器会生成test.exe。这时可以顺手看一下生成的EXE文件大小,虽然实验5的项目很小,但已经能看出多段程序的EXE不像COM文件那么“裸”,它带有一个文件头,里面记录了程序入口、栈段偏移等信息,这也是后面Debug能自动设置SS、SP的原因。
真正调试时,我建议进入Debug后不要急着用g跑完,而是用u和t一步步看。先输入u,反汇编从CS:IP开始的几条指令,确认代码段内容无误。这一步有一个好处:能看到mov ax, data指令后的立即数,比如mov ax, 076A,这个076A就是数据段地址,用它可以手动查看数据段内存。
3.2 程序加载后,那些寄存器到底是怎么回事
实验5有一个经典问题:程序加载后,DS、CS、SS、SP各指向哪里?直接运行debug test.exe后输入r,会看到类似这样的寄存器状态:
| 寄存器 | 常见加载值 | 说明 |
|---|---|---|
| CS | 076B | 代码段段地址 |
| IP | 0000 | 指向代码段中start标号处 |
| SS | 0769 | 栈段段地址 |
| SP | 0010 | 栈段总长度16字节,由EXE头自动设置 |
| DS | 0759 | 不是data段,而是程序段前缀PSP所在段 |
| ES | 0759 | 和DS一样,指向PSP |
关键点就在这里:程序一加载,CS、SS、SP已经被系统设置好了,只有DS和ES还指向PSP。PSP是DOS为可执行程序分配的一块256字节前缀,里面存了命令行参数等信息。如果你的代码不执行mov ax, data; mov ds, ax,那么后面访问ds:[si]时,DS指向的是PSP,读到的东西根本不是你的数据。
那能不能让系统自动把DS指向data段?不能。因为一个程序可以定义好几个数据段,EXE文件头并没有规定哪个是“主”数据段,所以只能靠代码里手动加载。这就是实验5强调段寄存器初始化的原因。
3.3 用Debug单步观察压栈和弹栈
在Debug里,我习惯这样一步步走:
先输入u查看代码,确定各标号对应的地址。然后按t单步执行。执行完第一条mov ax, data后,AX里就变成了数据段的段地址;执行完mov ds, ax后,DX看错了,是DS变成数据段段地址。这时候输入d ds:0,就能看到数据段里初始的16字节。
继续单步到mov ss, ax和mov sp, 16之后,输入r看SP,SP应该等于0010。接着进入push_all循环。按一次t,看到mov ax, ds:[si]中AX变成了0123h;再按一次t,执行push ax。此时输入r,SP变成了000E,输入d ss:0,栈段末尾处出现了23 01。
每次压栈,SP减2,数据从栈段的高地址向低地址填。当8个数据全部压完后,SP回到0000?不对,仔细算:初始SP=16,压入第1个字后SP=14,第2个后SP=12,第8个后SP=0。所以压栈结束后SP=0000,栈段里16个字节被填满。这一点要特别留意,否则你会以为SP应该还在某个中间位置。
继续单步执行到pop_all,第一次pop ds:[di]会把刚才最后压入的0987h弹出来写到数据段偏移0处,SP变成0002。然后mov ax, ds:[si]?当然这里已经换成pop,流程是弹一个写一个。弹完后,数据段前8个字变成逆序,SP又回到0010,栈段内容依旧留在那里,只是SP不再指向它们。
如果用g直接运行到int 21h之前,也能看到最终结果。但单步执行更能让人看清“后进先出”到底是怎么回事。我建议至少完整按t走过一遍循环,不要用p跳过。
3.4 一张表看清压栈过程中栈的变化
把压栈过程整理成表格,一目了然:
| 操作 | 压栈前SP | 压栈后SP | 栈顶两个字节内容 |
|---|---|---|---|
| push 0123h | 0010 | 000E | 23 01 |
| push 0456h | 000E | 000C | 56 04 |
| push 0789h | 000C | 000A | 89 07 |
| push 0abch | 000A | 0008 | bc 0a |
| push 0defh | 0008 | 0006 | ef 0d |
| push 0fedh | 0006 | 0004 | ed 0f |
| push 0cbah | 0004 | 0002 | ba 0c |
| push 0987h | 0002 | 0000 | 87 09 |
注意,栈顶内容在内存里是小端序。比如0123h,内存里是23 01,Debug的d ss:0显示出来的会是87 09 ba 0c ed 0f ef 0d bc 0a 89 07 56 04 23 01,也就是压栈后的完整内容。顺序刚好和原数据相反。
弹栈时SP从0000往上走,依次把87 09弹到数据段偏移0处,把ba 0c弹到偏移2处,最终数据段变成87 09 ba 0c ed 0f ef 0d bc 0a 89 07 56 04 23 01。写成字型数据就是0987h, 0cbah, 0fedh, 0defh, 0abch, 0789h, 0456h, 0123h,逆序完成。
4. 常见问题与排查技巧实录
4.1 数据段地址没送到DS,程序读到的是PSP
现象:程序运行后结果完全不对,用Debug查看数据段,发现里面的值和初始定义不一样,甚至全是0。原因大概率是少写了mov ax, data; mov ds, ax。前面说过,程序加载后DS指向PSP,不初始化DS,ds:[si]读的就是PSP里的数据。
排查方法很直接:单步执行几条指令后r看DS,如果DS和程序运行前一样,说明没赋值。另外,也可以在执行mov ds, ax前后各看一次d ds:0,对比数据变化。这个错误几乎人人都会犯一次,犯了之后印象最深。
4.2 SP初始值不对,栈直接盖到代码段
栈段定义的是16字节,SP却设成0,会发生什么?第一次push ax先把SP减2,SP变成FFFE,数据写到SS:FFFE。这个地址其实已经到了栈段之前的内存区域,很可能就是别的段,甚至覆盖到程序的其他部分。单步调试时会发现,栈段里看不到数据,程序执行一会儿就乱了。
正确的SP值应该是“栈段字节总数”。如果栈段里写的是dw 0,0,0,0,0,0,0,0,一共16字节,SP就设16。如果写的是db 16 dup (0),SP也设16。如果写了dw 8 dup (0),同样是16字节。记住一个原则:SP初始值等于栈段长度,别去想着栈底在哪里。栈底是段内偏移0,栈顶才是段内偏移SP。
还有个小细节:如果栈段定义了但代码里没设mov sp, 16,系统会按EXE头里的信息自动设SP。但如果程序是实验题里那种“有意考你”的写法,SP可能被设成0,这时候你就要警惕,题目可能在考察你对栈方向的理解。
4.3 字和字节混用,偏移量全错位
dw定义的是16位字,访问时偏移量要加2;db定义的是8位字节,访问时偏移量要加1。实验5的题目经常把db和dw混着出,比如让你把8个字节逆序,和把8个字逆序,代码完全不同。
如果该用字节却写成add si, 2,你会跳着读数据,把两个字节当成一个字处理,最后结果看起来“好像反了”,但数据细节全是错的。调试时可以用d ds:0逐字节看内存,对照源码里的数据,确认偏移步长。
4.4 assume只是“声明”,不是“赋值”
assume ds:data常被误解为一个实际加载段寄存器的指令。不是。汇编器看到assume ds:data后,在汇编mov ax, ds:[si]时知道应该按data段的偏移去计算,但运行时DS里是什么值,完全由机器代码决定。
如果代码里没有mov ax, data; mov ds, ax,assume也不会帮你填值。这一点和高级语言很不一样,高级语言里的声明和定义是链接器帮你安排的,而汇编里段寄存器是CPU物理寄存器,程序一跑起来就是裸状态,不手动装载就没法用。
4.5 调试多个段时的小技巧
调试这种程序,我常用的招数是:
- 先用
u看前几条指令,从mov ax, 立即数里找出data段地址,再用d 地址:0查看数据段,不用等程序跑完。 - 单步进入循环前,先用
r记下SP和CX的初始值,每按几次t就对比一次,能直观看到SP逐步减少、CX逐步减少。 - 不想一步步跑完整个循环时,可以在
pop_all循环开始处设置一个断点,用g 地址直接跑到那里,再单步观察。计算地址时用u查看pop_all标号前的指令偏移,注意Debug显示的是段内偏移,比如在代码段偏移0034处。 - 如果执行到
int 21h时按t,可能会一头扎进DOS中断的内部,界面变得很奇怪。遇到int 21h,直接按p让中断返回,或者用g停在它前面,别用t硬跳。
这些技巧都是我在反复调试中总结的,能节省大量时间。
5. 从实验5延伸出去的一点思考
5.1 为什么DS不能像CS、SS那样自动指向数据段
程序加载时,CS自动指向代码段,SS自动指向栈段,DS却不指向任何一个数据段,这个不对称让很多人困惑。原因是EXE文件头里明确记录了代码段入口和栈段偏移,加载器可以直接写CS、IP、SS、SP;但数据段不唯一,程序可能有多个,所以没有“默认识别”的机制。CS和SS可以靠头部信息确定,DS只能靠程序自己确定。
这也解释了“段的本质是逻辑划分”这句话。8086的段寄存器只是四组地址参考点,你可以让DS指向数据段,也可以让它指向代码段,甚至可以指向栈段。实验5的练习虽然简单,但多段结构的灵活性已经从这些寄存器分配里体现出来了。
5.2 多段程序的内存布局和重定位
EXE文件加载到内存时,并不是像COM文件那样原样搬过去就完事。EXE头里有重定位表,加载器会根据程序被加载到哪个基地址,动态修正代码中所有“段地址”相关的立即数。比如源码里写mov ax, data,编译后这个data在EXE里是一个可重定位的值,加载时会加上一个基址偏移。
所以每次运行同一个EXE,DS、CS、SS的段地址可能不一样,但它们的相对关系不变。这也是为什么你在Debug里看到的段地址和我这里举例的076A未必相同,但程序行为完全一样。多个段的程序,跳转、栈操作、数据访问用的都是相对段地址,重定位不影响逻辑。
5.3 一个更贴近实战的扩展练习
如果实验5做完还有余力,我建议尝试一个变体:定义两个数据段,一个装着8个字,另一个装着8个字,把两者对应位置相加,结果放回第一个数据段。这个练习看起来只是多了些指令,但你会遇到一个实际痛点:DS只有一个,访问第二个数据段时,需要先让DS指向第二个数据段,访问完再切回第一个。
一开始我写的代码是直接来回改DS,结果发现循环里数据访问错位得厉害。正确做法是把第一段的段地址先压栈保存,需要时再弹出来;或者用两个通用寄存器分别保存两个段地址,然后用段超越前缀es:[si]和ds:[si]同时访问两个段。这两种做法都指向一个核心:段寄存器的分配和管理,是多段程序最需要设计好的地方。写完这个练习,你对实验5的理解会比只抄一遍示例程序深得多。
6. 我做这个实验的个人经验
实验5我前后做了三遍,第一遍是照着样例改,第二遍是空手重写,第三遍是故意写错几个地方再调。最大收获不是会背那个逆序程序,而是终于看懂了Debug里CS、DS、SS、SP是怎么配合的。以前调程序全凭感觉,现在拿到一个EXE,先看反汇编第一条指令的立即数,再看SP初始值,心里基本就有数了。
如果你也在做这个实验,我真心建议不要只满足于把代码跑通。试着在Debug里单步走完每一次push和pop,把栈段内存的变化画在纸上,标出SP每次指向哪里。这个动作看起来很笨,但做完之后,多个段的程序对你来说就不再是一堆零散的传送指令,而是一幅清晰的内存地图。以后学到中断、子程序、模块化设计,很多问题都能从这幅地图里找到答案。