王爽《汇编语言》第九章实验九我当年做到这里的时候第一次有了一种我不是在做题而是在写程序的感觉。实验要求不算复杂——在屏幕中间分别用绿色、绿底红色、白底蓝色显示字符串welcome to masm!但如果你没搞懂显存地址、属性字节和偏移计算这三件事代码半天憋不出来。这篇文章我把一份能直接编译运行的源码完整放出来再把每一条指令为什么这么写、踩坑点在哪、怎么用调试器验证都讲透适合正在学第九章、或者卡在实验九想对照参考的同学。代码可以直接抄但更建议你先看完原理再动手这样后面第十章、第十一章才不会继续犯迷糊。1. 实验九的题面与它真正想考的东西1.1 题面拆解三个颜色条件和一个位置条件先把这个实验的要求拆开看。屏幕上要显示三行内容内容都是同一个字符串welcome to masm!区别在于颜色第一行绿色也就是黑底绿字。第二行绿底红色。第三行白底蓝色。同时还有位置要求三行都要出现在屏幕中间而不是左上角、右下角或者随便什么地方。屏幕在文本模式下是80列×25行所以这里的中间指的是水平方向字符串居中、垂直方向三行整体居中。算下来三行放在第12、13、14行行号从0开始数最合适每行的起始列是第32列因为字符串一共16个字符在80列里居中就是 (80-16)/2 32。这三个颜色条件最终会翻译成三个十六进制属性字节02H、24H、71H。这三个数字是实验九的核心代码里所有的颜色控制都靠它们。1.2 为什么这个实验叫分水岭第九章的章节主题叫转移指令的原理但实验九做的却是一件看起来和转移指令关系不大的事直接往内存地址写数据让屏幕显示彩色字符。很多初学者做到这里会蒙——刚刚还在学jmp、loop、offset突然就要操作显存了。其实这正是这本教材的套路。前面几章在教你怎么访问内存里的数据实验九则是把访问内存这件事延伸到一个非常具体的场景内存中的一个特定区域你往里面写什么屏幕就显示什么。它考的不是某个新指令而是你对段地址、偏移地址、循环嵌套和数据布局的综合掌握。我见过不少同学在这里抄代码抄得飞快第二天再问他为什么屏幕中间是第12行不是第13行就答不上来了。这个实验真正值得学的不是最后显示出来的三行彩色文字而是从需求到内存操作的整个思考过程。这部分想明白了后面的实验十、实验十一会顺畅很多。2. 写代码前必须搞清楚的三件事2.1 显示缓冲区B8000H 是什么文本模式下CPU 会把屏幕上的每一个字符映射到内存里这块内存叫显示缓冲区显存。对于 80×25 彩色文本模式显存从物理地址 B8000H 开始到 BFFFFH 结束一共 32KB。这里特别容易混的一个点是段地址和物理地址的写法。物理地址 B8000H 由段地址 ×16 偏移地址得到所以你在代码里给段寄存器赋的值应该是 0B800H不是 0B8000H。写成mov ax, 0b8000h汇编阶段就会直接报错因为 0B8000H 超过了一个 16 位寄存器能装下的范围。这个坑几乎每届都有人踩我当年也在这卡了十分钟。用 debug 看显存的时候输入d b800:0000这里的 b800:0000 代表的物理地址就是 B8000H。段地址是 b800偏移是 0000。2.2 每个字符占用两个字节ASCII码 属性80×25 彩色文本模式下的显存布局是每行 80 个字符但每个字符不是只占一个字节而是占两个字节。第一个字节是字符的 ASCII 码第二个字节是属性字节控制这个字符的颜色、背景色以及是否闪烁。也就是说屏幕上显示一个w需要往显存里写两个字节第一个写 57Hw 的 ASCII 码第二个写颜色属性。整个屏幕一行 80 个字符实际上要占 80×2160 字节。这也是后面计算偏移的基础从一行的某个位置到下一行的同一列偏移要加 160也就是十六进制的 0A0H。这一步我多说一句很多人一开始容易把显存想象成像二维数组一样一个格子一个字符然后直接往格子里写字符。但机器并不认识格子它只认连续的字节。字符和属性交替排列所以写入的时候一定是写一个字符字节 写一个属性字节这样成对地写。2.3 属性字节高四位背景、低四位前景属性字节的 8 个 bit 分成两半低 4 位控制前景色字符自身的颜色高 4 位控制背景色。每种颜色用 4 位二进制表示其中位0是蓝色、位1是绿色、位2是红色。所以各颜色的编码是颜色红色(位2)绿色(位1)蓝色(位0)对应数值黑0000H蓝0011H绿0102H红1004H白1117H实验九要求的三种效果分别这样算黑底绿字背景色为 0前景色为 2属性 00H 02H 02H。绿底红字背景色为绿要放进高4位所以背景部分是 24 20H前景色为红是 4。属性 20H 04H 24H。白底蓝字背景色为白高4位是 70H前景色为蓝是 01H。属性 70H 01H 71H。所以数据段里定义的三个属性字节就是02h, 24h, 71h。这三个数字不是凭空来的把属性字节的位结构搞清楚你不但能看懂这个实验以后想换成红底黄字之类也随时会算。2.4 行列偏移计算160字节/行与居中位置现在算偏移地址。显示缓冲区每一行占 160 字节第 n 行开头的偏移就是 n×160。如果行号从 0 开始数第 12 行的行偏移是 12×1601920十六进制是 0780H第 13 行是 13×1602080也就是 0820H第 14 行是 2240即 08C0H。这只是行偏移。字符串要水平居中字符串 16 个字符占 32 字节所以起始列是第 32 列列偏移是 32×264十六进制是 40H。最终每行的起始偏移要加上这个 40H行号行偏移加列偏移后十六进制第12行0780H07C0H07C0H第13行0820H0860H0860H第14行08C0H0900H0900H代码里把起始偏移定为 07C0H每次处理完一行就加 0A0H就能依次指向第 13 行、第 14 行的同一列。这样写的好处是以后你想把三行显示到第 5、6、7 行只需要改一个数。3. 完整源码与运行环境3.1 可直接编译运行的完整源码下面这份源码用 MASM 语法编写在 DOSBox 里用 masm 汇编、link 链接后可以直接运行assume cs:code, ds:data data segment str db welcome to masm! att db 02h, 24h, 71h data ends code segment start: mov ax, data mov ds, ax mov ax, 0b800h mov es, ax mov di, 07c0h ; 起始显存偏移第12行居中 mov si, 0 ; si指向att数组的当前属性 mov cx, 3 ; 外层循环共3行 rows: push cx push di ; 保存本行起始偏移 mov cx, 16 mov bx, 0 ; bx指向str的当前字符 cols: mov al, str[bx] ; 取字符 mov es:[di], al ; 写入字符字节 mov al, att[si] ; 取属性 mov es:[di1], al ; 写入属性字节 inc bx add di, 2 loop cols pop di add di, 0a0h ; 下一行相同列 inc si ; 切换属性 pop cx loop rows mov ax, 4c00h int 21h code ends end start代码里我用str和att两个标签把字符串、属性分开这样逻辑非常清晰。字符串占 16 字节属性数组占 3 字节数据段总共 19 字节。3.2 运行前的准备编辑器、MASM、DOSBox这个实验是 16 位实模式程序现代的 64 位 Windows 通常没法直接运行最省事的办法是用 DOSBox 搭一个 DOS 环境。你需要准备三样东西一个纯文本编辑器写汇编源码保存成.asm文件。MASM 汇编器一般包括masm.exe和link.exe。DOSBox用来模拟 DOS 环境运行编译后的 exe。DOSBox 的配置不复杂把包含masm.exe、link.exe和你写的.asm文件的目录挂载为一个盘符比如mount c: d:\asm然后进入 C 盘操作就行。这个环节本身不难但经常有人卡在文件路径找不到或者编译报错找不到 masm.exe上所以我把完整流程放在后面第 6 章专门讲。4. 逐段拆解每一行代码为什么这么写4.1 段定义与assume让CPU知道段寄存器怎么用源码开头assume cs:code, ds:data data segment ... data ends code segment start: ... code ends end startassume不是一条真正的机器指令它只是告诉汇编器在后面的代码里cs寄存器对应code段ds寄存器对应data段。汇编器在编译mov al, str[bx]这种指令时知道str在data段会自动生成基于ds的寻址。end start表示程序入口在start标号处链接器和调试器都靠它找到程序开始执行的位置。4.2 ds指向数据段es指向显存段程序一开始的两条赋值是这个实验的精髓mov ax, data mov ds, ax mov ax, 0b800h mov es, ax第一条把数据段地址放进ds这样后面mov al, str[bx]才能取到字符串。第二条把显存段地址放进es这样mov es:[di], al才能写屏幕。为什么不用ds直接指显存段因为ds一次只能指向一个段。如果让ds0B800H写显存倒是方便了可字符串数据在 data 段里再想取字符就得先把ds切回去来回切非常麻烦。用es专门负责显存ds继续负责数据两个段各司其职是实模式汇编里非常典型的做法。4.3 内外层循环与寄存器分工这段代码用了两层循环外层rows负责行数循环 3 次每次处理一行。内层cols负责这一行的 16 个字符循环 16 次每次处理一个字符。寄存器分工是这样的寄存器作用bx当前字符串中字符的下标每处理一个字符加1si当前属性数组的下标每换一行加1di当前显存偏移每处理一个字符加2每换一行加0A0Hcx循环计数器内层用完后外层需要恢复这里有个关键细节内层loop cols会把cx减到 0外层loop rows还需要用cx计数。所以在进入内层循环之前外层先把当前的cx压栈保存内层循环结束后再从栈里恢复。rows: push cx push di ... pop di ... pop cx loop rowsdi也要压栈保存。因为内层循环每写一个字符di就加 2循环结束后di已经指向这行末尾右侧的位置了。不恢复的话下一行直接从错误位置开始写屏幕输出就会乱七八糟。压栈恢复是一个非常通用的技巧后面很多实验都会用到。4.4 写显存的三条核心指令整个程序的核心代码是这三条mov al, str[bx] ; 取字符 mov es:[di], al ; 写入字符字节 mov al, att[si] ; 取属性 mov es:[di1], al ; 写入属性字节第一行执行完al里放的是当前要显示的字符 ASCII 码第二行把它写到显存偏移di处第三行取当前颜色属性第四行写到di1处。注意di1用的是寄存器相对寻址它不影响di本身的值所以不需要额外做减法。写完一个字符后inc bx让字符指针往后走一位add di, 2让显存指针跳到下一个字符的位置因为每字符占 2 字节。这个成对写入的节奏一定要养成肌肉记忆漏写属性字节或者写错位置屏幕显示就会完全不对。4.5 用 4C00H 中断正常退出程序最后mov ax, 4c00h int 21h这是 DOS 环境下结束程序的固定写法。ah4C表示程序终止al00表示返回码 0int 21h触发 DOS 中断执行退出。如果不写这两行程序执行完loop rows后会继续往下走落到 data 段数据上当成指令执行结果就是屏幕乱码甚至死机。这个细节我在很多初学者代码里看到过循环写对了屏幕也能显示但程序一结束就卡死最后发现就是没写中断退出。代码里这两行不能省。4.6 另一种常见写法对照网上还有一种很常见的参考写法不在数据段里用str、att标签而是把数据和属性都放在段开头然后用数字偏移访问data segment db welcome to masm! db 02h, 24h, 71h data ends这样字符串从偏移 0 开始属性从偏移 16 开始代码里用一个寄存器指向 16另一个寄存器指向 0。两种写法原理完全一样。我推荐你理解有标签的写法因为str[bx]和att[si]一眼就能看出是在访问哪个数据调试的时候不容易晕。等你有把握了再看无标签的写法就会觉得很简单。5. 最容易翻车的五个错误与排查思路5.1 屏幕没反应段地址和偏移地址是重灾区现象程序运行完屏幕干干净净什么也没出现。最常见的原因是把段地址写错比如mov ax, 0b8000h。这个数超过 16 位寄存器范围编译直接报错属于想起来就疼的错误。另一个隐蔽的原因是偏移算错——比如把起始偏移写成了07c0h之外的数字数据被写到了屏幕当前不可见的区域。文本模式 25 行的显示缓冲区一共需要 80×25×2 4000 字节也就是 0FA0H 字节。如果你的偏移在 0FA0H 之外写入的内容不在屏幕范围内自然看不见。这种问题我建议直接查es和di的值。用 debug 单步执行执行到写显存那条指令前看一眼es0B800H、di07C0H是否符合预期基本能定位。5.2 颜色对不上属性字节的位运算反了现象内容显示出来了但颜色不是题目要求。比如第一行该绿的不绿第二行该红的不红。这是属性字节高低四位搞反了。24H 是绿底红字42H 是红底绿字两者看起来差不多在屏幕上效果完全不同。还有的人把 71H 写成 17H白底蓝字就变成了蓝底白字。排查思路回到属性字节的二进制一位一位对。02H 0000 0010背景 0黑、前景 2绿24H 0010 0100背景 2绿、前景 4红71H 0111 0001背景 7白、前景 1蓝。别靠背靠算。5.3 只显示一行或显示到屏幕外外层循环忘记恢复 di现象屏幕上只有第一行有字或者第二行出现在莫名其妙的位置。如果内层循环结束之后没有pop di外层第二次循环用的是已经走到行尾的di再继续写就是下一行行尾后面的位置效果五花八门。正确做法是进入外层循环体时push di保存本行起始偏移内层消耗完di之后pop di恢复再add di, 0a0h往下走一行。这里我建议你在纸上模拟一遍三行循环的di变化第一次是 07C0H → … → 内层结束 → 恢复 07C0H → 加0A0H → 0860H第二次同上变到 0900H第三次结束后 cx0 退出。把寄存器值变化画出来比盯着屏幕瞎猜有效率得多。5.4 第二行开始乱码字符串指针没有重置现象第一行正常第二行开始出现莫名其妙的字符。原因基本只有一个内层循环每行开始时bx没有重置为 0。字符串只有 16 个字符第一行写完bx已经等于 16第二行继续用就取到 data 段里 16 号偏移之后的数据——那里是属性字节把它们当字符写进显存自然乱码。我写的版本里内层cols开头固定写mov bx, 0就是为了保证每行都从字符串头部重新取。这个 bug 特别隐蔽因为它不是一眼能看出的语法错误而是逻辑错误。5.5 程序卡死loop 的 cx 被冲掉现象程序运行后屏幕有输出但程序不退出DOSBox 里像死循环一样。原因通常是内层循环和外层循环共用了cx但没有压栈保护。进入外层时cx3进入内层后cx16内层循环结束cx0回到外层loop rows时判断cx0直接退出——看起来只是少循环两次但如果代码里哪里多写了一个loop就会导致计数器减到负数或者跳到错误位置。解决方法和 5.3 一样外层循环体开头push cx结尾pop cx。这是处理嵌套循环的标准姿势不是可选项。5.6 常见错误速查表现象可能原因排查重点屏幕无任何变化段地址/偏移地址错误es、di 初值颜色不对属性字节高位低位写反02H、24H、71H 二进制值只显示一行di 没有恢复或没加0A0Hpush/pop diadd di,0a0h第二行乱码字符串指针未重置内层循环开头 bx 置0程序卡死/不退出cx 嵌套没有保护push/pop cx退出中断这五类问题几乎覆盖了实验九九成以上的报错。你在实际操作中遇到问题时逐个对照这张表比自己瞎试快得多。6. 从编译到运行DOSBox MASM 完整流程6.1 DOSBox MASM 环境配置先说工具链。MASM 是老汇编器典型文件是masm.exe和link.exe。DOSBox 模拟出来的环境可以直接运行这两个程序。我的建议是建一个干净的目录比如D:\asm把这三个东西放进去masm.exelink.exetest.asm你写的源码启动 DOSBox 后执行mount c d:\asm c:这样 DOSBox 里的 C 盘就对应你电脑上的D:\asm目录所有文件都在里面路径清晰不会出现找不到文件的问题。6.2 编译、链接、运行的三个命令编译源码生成目标文件masm test.asm;回车后它默认生成test.obj。分号表示后面所有提示都用默认值不用一个一个回车确认。链接生成可执行文件link test.obj;这一步生成test.exe。如果 link 提示找不到模块或者入口检查一下源码里有没有end start。运行test正常情况下屏幕上会出现三行不同颜色的 welcome to masm!。第一行绿色、第二行绿底红字、第三行白底蓝字位置在屏幕中间偏下一点。这个效果一出来实验就算通了。6.3 用 debug 验证显存内容如果你不满足于看到了三个字想确认程序是不是真的按预期写入了显存用 debug 看一眼最直观。debug test.exe在 debug 提示符下输入g程序会运行到int 21h退出debug 会提示Program terminated normally。这时输入d b800:07c0你会看到显存第一行右侧的数据57 02 65 02 6C 02 ...。其中 57H 是字符 w 的 ASCII 码紧跟着的 02H 是绿色属性。再查第二行d b800:0860属性字节变成 24H第三行d b800:0900属性字节变成 71H。这就从内存层面验证了程序的正确性比单纯用眼睛看屏幕更可靠也是以后调试更复杂程序时非常好用的方法。6.4 从一行到三行的分步推进策略最后分享一个调试思路不要一上来就写三层逻辑。第一步先写死只显示一行cx直接设成 1di固定 07C0H。确认屏幕中间能出现16个绿色字符。第二步把属性改成第二行的 24H 或者第三行的 71H确认颜色切换逻辑正确。第三步再加外层循环让三行都显示出来。如果这时候出问题你就可以确定问题集中在外层循环和di的行偏移上排查范围小了很多。这个先把简单版本跑通再逐步增加复杂度的习惯在做实验九的时候特别受用。不要小看这三步它省下来的调试时间绝对比多写几行代码的时间多。最后再讲一个我自己的体会实验九的代码量虽然不大但它是整本教材里第一个让我认真坐下来画寄存器变化图的实验。画完之后我才真正理解程序就是在操作寄存器这句话什么意思。你如果现在卡在循环嵌套这里也建议拿张纸把 bx、si、di、cx 在每一轮循环里的变化一步步写出来比盯着屏幕看一个小时后放弃要有效得多。