☰
16位汇编实验5解析:多段程序内存布局与段寄存器初值
2026/10/13 12:04:23 网站建设 项目流程

先说我个人的看法:实验5是整个16位汇编课程里最容易被“误解为简单”的一节。前面几章你还在单一段里写代码,数据和指令挤在一起,偏移地址靠数数就能算出来;到了实验5,“段”突然变成了三个,数据段、栈段、代码段各管各的。很多同学代码抄对了,运行结果也对,但是被问一句“为什么加载后DS不在数据段?”就卡住了。这篇文章不打算复述一遍答案,而是把实验5真正要验证的东西拆开讲清楚,从内存布局、段寄存器初值的来龙去脉,到一次典型的翻车排查过程,都按我实际调试时的顺序写出来。如果你正准备做这个实验,或者已经在实验报告上栽过跟头,这篇应该能帮上忙。

1. 实验5到底在验证什么:一个多段程序在内存里怎么“落位”

1.1 单段程序与多段程序的本质差异

在进入多段程序之前,你写的程序一般只有一个代码段,需要的数据就顺手定义在代码段里,访问的时候用段内偏移直接找。这种做法对几十行的小程序没问题,但一旦程序变大,数据和代码混在一起就会非常难受:你既要记住哪个偏移是变量,又要小心别让指令把数据区当成代码执行。

实验5把问题从“怎么写几条指令”拉到了“程序怎么被加载进内存”的层面。一个包含数据段、栈段、代码段的程序,编译连接成EXE之后,DOS加载它时不是简单地把整个文件塞进内存,而是按照EXE文件头里的信息,把各个段放到它们该放的位置,同时设置好一部分段寄存器。这才是实验5最核心的考点:你必须说清楚加载之后DS、CS、SS、SP为什么是那些值,而不是仅仅会敲汇编指令。

从学习节奏上看,这也是一个分水岭。前面的程序更像是在一个房间里摆家具,所有东西都在同一个空间里;实验5开始变成一套房子分了几个房间,你进门之后得先找到对应的房间,再干对应的事。后面的子程序、中断、端到端程序,全部建立在这个“多个段如何协同工作”的基础上。

1.2 EXE加载时,DS、CS、SS、SP到底谁来定

写实验5之前,有必要先知道一个事实:程序加载完成后,寄存器的初值并不是你凭空想象的。对于EXE程序,加载过程大致是这样的:

  • DS和ES指向程序段前缀(PSP),PSP是DOS为程序开辟的256字节区域,里面保存命令行参数、环境信息等。
  • CS:IP指向程序的入口点,这个入口点是end start里的start标号决定的。
  • SS:SP指向栈段的顶部,也是栈段最后一个字节后面的位置。
  • 数据段的地址虽然被连接器安排好了,但不会自动进入任何寄存器,必须由你自己的代码把它加载到DS里。

很多初学者在Debug里敲R,看到DS的值就下意识认为那是数据段地址,这是实验5里最常见的理解错误。DS在程序刚加载时其实是PSP段地址,你定义的数据段并没有得到任何“默认寄存器”。想要访问数据段,必须写上两行:

mov ax, data mov ds, ax

我把加载后的寄存器来源整理了一个表,做实验报告时可以对照着写:

寄存器程序加载后的初值由谁决定
DSPSP段地址DOS加载器设置
ESPSP段地址DOS加载器设置
CS:IP程序入口地址end start指定的入口
SS:SP栈段顶部位置EXE文件头中的栈段信息
数据段地址不在寄存器里需要代码手动装入DS

这里有一个很反直觉的点:你的源代码里明明写了assume ds:data,为什么DS不自动指向数据段?因为这个assume根本不是给CPU看的,而是给编译器看的。它告诉编译器:代码里没有显式写段前缀的符号访问,默认用哪个段寄存器。但段寄存器的实际值,必须由mov指令来设置。这两件事的分工,就是下一节要展开的内容。

2. 动手前必须搞懂的三个底层细节:assume、EXE文件头、空栈状态

2.1 assume不是执行指令,它只是编译器的“上下文声明”

assume cs:code, ds:data, ss:stack这三行写在程序最前面,看起来像是把段地址装入了寄存器,实际上它一个字节的机器码都不会生成。它的作用是告诉编译器:code这个段的代码用CS来寻址,data段里出现的变量默认用DS来寻址,stack段的内容默认用SS来寻址。

举个例子。你在数据段定义了一个变量:

data segment x dw 1234h data ends code segment start: mov ax, x code ends end start

如果有了assume ds:data,编译器会默认把x翻译成ds:[0]这种寻址方式。但这只是“翻译”层面的约定,它并没有把数据段的真实段地址放进DS。真正让DS指向数据段,依然需要:

mov ax, data mov ds, ax

第一行把数据段的段地址作为一个16位立即数送入AX,第二行再送入DS。8086不允许直接把立即数送入段寄存器,所以必须经过通用寄存器中转。这个限制本身也是实验5里值得写进报告的知识点。

我见过不少同学在完成实验时,觉得反正有assume ds:data,于是直接mov ax, [bx]访问数据,结果读出来的是PSP里的内容,调试半天才发现DS没设置。记住:assume是写给人看、写给编译器看的,执行流里的段地址全部要靠指令自己搬。

2.2 EXE文件头里的CS、IP、SS、SP与内存重定位

实验5要求你把程序编译成EXE,再放进Debug里调试,这不是为了走过场,而是因为EXE的加载方式和COM不同。COM文件是个朴素的内存镜像,加载地址固定,CS、DS、ES、SS四个段寄存器全都指向同一个段;EXE文件则不同,它有一个文件头,文件头里记录了入口点、栈段位置以及一张重定位表。

这里可以简单理解成:编译器生成的目标文件里,所有段地址都不是写死的实际物理地址,而是一个个“待填项”。连接器把这些段排好序,并且在EXE文件头里记下哪些地方需要在加载时修正。DOS把程序加载到内存后,会根据实际加载到的起始地址,把入口点、栈段等信息修正成真实地址,再设置CS:IP和SS:SP。你在Debug里看到的CS、SS已经是修正后的结果。

这也是为什么同一个EXE,在DOS的不同位置加载,显示出的段地址可能不同,但程序都能正常运行。调试时不要纠结“为什么CS是这样一个数”,而应该关注“它和DS、SS之间的关系”。数据段的段地址会被写入到mov ax, data这条指令的立即数位置,由加载器修正。Debug里执行到这条指令后,AX里就会显示一个可用的段地址,这就是后面用D命令查看数据段内容的依据。

2.3 “空栈”状态到底长什么样

实验5里的程序往往要压栈、出栈,于是栈段怎么初始化就成了必考项。很多人对“空栈”的理解是:SP等于0。这个理解放到8086上是不对的。

8086的压栈操作是先把SP减2,再把数据写入SP指向的地址。也就是说,一个16字节的栈段,如果定义成:

stack segment dw 0, 0, 0, 0, 0, 0, 0, 0 stack ends

栈段一共16个字节,当你把SP设为16(十六进制就是10h),表示栈顶在栈段末尾之后的一个字位置。此时执行第一次push,SP会先变成14(0Eh),然后把数据写到栈段偏移14的位置。随着不断压栈,SP一路向下走,直到用完整个栈段。

如果SP被错误地设为0,第一次push就会先把SP变成0FFFCh,再往SS:FFFC处写入数据。这个地址早就跑出栈段范围了,可能落在内存里任何位置,轻则数据错乱,重则把其他程序的数据盖掉。

我建议你在实验报告里写清楚一组对应关系:栈段定义了多少字节,SP的初值就是多少。如果想偷懒用16进制,也要先换算明白。这个细节在后面的排查章节还会再出现。

3. 完整流程:从编写源代码到用Debug验证结果

3.1 一份典型的三段式模板代码

实验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, data mov ds, ax mov ax, stack mov ss, ax mov sp, 10h mov bx, 0 mov cx, 8 s: push ds:[bx] add bx, 2 loop s mov bx, 0 mov cx, 8 s2: pop ds:[bx] add bx, 2 loop s2 mov ax, 4c00h int 21h code ends end start

这段代码的逻辑是:第一次循环把数据段偏移0、2、4……一直到14的8个字依次压入栈;第二次循环再把它们弹回数据段的相同偏移位置。因为栈是后进先出的,所以最后数据段里原来的顺序会变成逆序。初始是0123h, 0456h, ……, 0987h,运行后应当变成0987h, 0cbah, 0fedh, 0defh, 0abch, 0789h, 0456h, 0123h。

注意几个设计点:数据是字类型,所以每次偏移加2;循环次数是8,用CX控制;push ds:[bx]里的ds:一定要有,因为这时DS已经通过前面的两条指令指向数据段了。最后的mov ax, 4c00h和int 21h是返回DOS的标准写法,不能漏。

3.2 用MASM工具链生成EXE

源代码写好后,用配套的汇编器和连接器处理。常用的命令是:

masm E5.asm;

分号表示后面的提示都用默认回答,会生成E5.obj文件。然后再执行:

link E5.obj;

生成E5.exe。如果编译报错,先检查段定义有没有写错、标点是不是英文状态下的、end start是不是写在最后一行。连接时如果提示栈相关的问题,多半是栈段定义有问题,回到源码检查栈段是否真的存在。

这一步最重要的是理解为什么需要两步:汇编器把源文件翻译成目标文件,此时段之间的相对位置还没确定;连接器负责把各个段组织成最终的EXE,并生成文件头。你要调试的“多段程序”,其实是连接器排好序之后的结果。实验报告里如果只写了源代码,没写汇编、连接过程,等于只走了一半。

3.3 Debug里的完整验证顺序

生成EXE后,在Debug里加载:

debug E5.exe

先执行R,记录寄存器初值。此时DS指向PSP,CS指向程序入口所在的代码段,SS指向栈段,SP值为10h。注意这里的SP已经是加载器根据EXE文件头设置好的,不需要你手动算。

接着用T单步执行第一条mov ax, data。执行完这条后,AX里会出现数据段地址。然后继续T执行mov ds, ax。走到这一步,DS才会真正变成数据段地址。此时可以用D命令查看数据段内容,命令格式是:

D 数据段段址:0

对照偏移0到15,应该能看到初始的8个字。继续单步到压栈循环,每压一个数,可以顺便用R看一下SP的变化,会发现SP每次减2。压完8个后,用D SS:0查看栈段,栈段里应该保存着数据段的8个字,顺序是反的。

再继续单步到弹栈循环,最后再看数据段,此时数据已经逆序。需要注意:执行到int 21h之前,最后一条指令不要用T单步进去,否则会进入DOS中断内部,很难出来。对int 21h这条,用P让它直接返回,程序正常结束,回到Debug提示符。

我用表格整理一下验证点:

操作命令期望看到的现象
查看初始寄存器RDS指向PSP,CS:IP指向入口,SS:SP指向栈顶
执行mov ax,dataTAX变成数据段段地址
执行mov ds,axTDS变成数据段段地址
查看数据段初值D 段址:08个字按源码顺序排列
单步压栈过程TSP每次减2,栈段内容逐步增多
查看栈段D SS:0数据被逆序保存在栈段
单步弹栈过程TSP每次加2,数据段逐步变化
查看最终数据段D 段址:08个字变成倒序
结束程序P回到Debug提示符

4. 一次“代码看起来没问题,数据却全乱掉”的排查过程

4.1 现象:栈段比预想中小一号,后面的数据全错

有一回我帮同学调实验5,代码逻辑看起来完全正常:DS设置了,循环次数对了,压栈和弹栈也是对称的。但是程序跑完后,数据段前4个字是对的,后4个字变成了莫名其妙的值。用D命令查看数据段,前8个字节还能看出规律,后面全是乱码。

最后定位到的原因就一行:

mov sp, 8

这个同学认为栈段定义了8个字,所以SP就应该是8。实际上栈段8个字占16个字节,SP应当是16,也就是10h。他设成8之后,第一次压栈SP变6,第二次变4,第三次变2,第四次变0,第五次压栈时SP直接溢出到0FFFEn,数据写到了栈段之外。

所以前4个压栈的字是正常的,后4个字写到别处去了;之后再弹出数据段,前半段看着没问题,后半段读到的是栈段外的垃圾值。这种问题在源码静态检查时特别不容易发现,因为指令看着都是对的,只有把执行过程拆开看SP的变化才能看出来。

4.2 用逐步观察寄存器的方式定位根因

排查过程其实不复杂:先在压栈循环前设置断点,用R看SP初值,确认是不是16;然后每压一个数执行一次T,同步看SP的值。正确的变化序列应该是:

16 -> 14 -> 12 -> 10 -> 8 -> 6 -> 4 -> 2 -> 0

而错误的版本从第五次开始变成了:

16 -> 14 -> 12 -> 10 -> 8 -> 6 -> 4 -> 2 -> 0 -> FFFE -> FFFC ...

看到SP变成FFFE基本就不用再怀疑别的了,问题一定出在SP初值比栈段实际容量小。再回头检查栈段定义和SP初始化的对应关系,很快就能锁定代码。

这个坑在教材的课后题里也经常被拿来考:给你一段代码,让你指出为什么执行后数据不正确。答案往往就是SP初始化和栈段长度不匹配。如果你只是背答案,不亲手调一次,永远感受不到SP溢出是个什么场景。

4.3 同一类翻车:DS没有真正指向数据段

另一类非常类似的问题是:assume写得好好的,但程序里漏掉了对DS的赋值。比如记忆里总觉得ambience as ifassume ds:data已经完成了使命,于是循环里直接写:

push [bx]

由于DS在程序加载时指向PSP,[bx]读到的就是PSP里的内容,根本不是数据段定义的数据。Debug里检查时,可以先执行到第一次push之前,用R看DS的值,再和之前mov ax, data后AX的值对比;如果对不上,说明DS没被正确赋值。

解决方法是把两行赋值补上:

mov ax, data mov ds, ax

这类问题的共同特征是:只看源码“能不能看懂”,看不出毛病;必须把寄存器的实际值和代码意图对照起来,才能找到真正的根因。实验5训练的就是这个能力。

5. 实验报告与自测中最值得写的几个验证点

5.1 加载后先别急着运行,记录寄存器初值

实验报告的第一个亮点,应该是加载完成、还没执行任何指令时,用R记录下来的寄存器状态。别小看这一步,它能直观证明你知道哪些寄存器是由DOS加载器设置的。DS指向PSP、CS:IP指向入口、SS:SP指向栈顶,这三个结论如果能在报告里写出来,并用Debug截图佐证,那实验5的核心部分就达标了。

还有一种考法是把end start改成end,问你程序还会不会从start开始执行。答案是:缺了入口标号,IP会被设为代码段偏移0处。如果start在代码段的最开始,程序确实能跑;如果start前还有其他数据或指令,程序就会从错误位置开始执行。这个点很适合写进实验报告,体现对入口地址的理解。

5.2 用D命令证明数据段发生了“逆序变化”

运行模板程序后,数据段应该是从正序变成逆序。验证时不能只看程序“正常返回”,一定要用D命令把数据段最终内容列出来,再和初始内容做对比。报告里可以画一张简单的对照表,左边是原始8个字,右边是运行后的8个字。这样老师一眼就能看出你确实理解栈的后进先出特性。

如果做的是压栈再弹回原位置的变体,那最终数据段不变,但中间过程里栈段会发生变化。无论哪种变体,都要抓住一个本质:栈段的修改是临时的,数据段是最终验证对象。

5.3 观察SS:SP在压栈和弹栈过程中的变化规律

实验5的另一个隐含考点是:SS:SP在压栈时每次减2,弹栈时每次加2。很多同学把栈的“向下增长”挂在嘴边,却没有真正用R观察过SP的变化。在Debug里压一次栈看一次,记录一行SP的值,报告里放10行左右的变化序列,比任何文字说明都有说服力。

这里要注意的是Debug里T单步执行mov ss, ax时,有些环境下下一条指令会立刻执行,这是8086处理器的特性:修改SS之后会暂时屏蔽中断,并把下一条指令也一起执行完。遇到这种情况不要慌,跟着SP的值判断就行,不要误以为Debug坏了。

5.4 用自检清单尽量避免低级错误

最后分享一个我长期使用的自检清单,实验五代码写完提交前对照一遍,能消掉绝大多数低级错误:

检查项正确做法出错表现
SP初值等于栈段字节数栈溢出,数据段被无关内存覆盖
DS赋值mov ax,data+mov ds,ax访问到PSP内容,数据错乱
循环次数CX等于数据字个数多循环或少循环,结果不对
偏移步长字操作每次加2数据被拆成字节处理
程序返回mov ax,4c00h+int 21h程序无法正常结束
入口指定end startIP可能指向代码段偏移0处

个人经验是,这六项里最容易出错的是SP初值,其次是DS赋值。现在很多集成开发环境会自动处理初始化,但教材实验让你用Debug手动调,就是希望你能掌握寄存器和段地址之间的真实关系。环境替你代劳的部分,恰恰是最需要自己亲手确认的部分。实验5真正的收获,不是你跑通了一个程序,而是你终于能解释清楚程序在内存里的每一步变化了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询