王爽汇编课程设计2简化版:8086输入排序去重完整实现

发布时间:2026/10/6 19:15:07
王爽汇编课程设计2简化版:8086输入排序去重完整实现 你要是在学王爽那本《汇编语言》多半是冲着“把计算机底层摸一遍”去的。前面十六章做实验、写中断例程都还能咬牙跟住但到了第十七章最后的课程设计2很多人第一次真正卡住。这个课程设计原题要你把程序写进软盘引导扇区开机直接运行用户敲字符回车后按字母顺序打印不重复的字符。听起来不难但真要在虚拟机里玩软盘镜像、反复重启测试光是环境就能折腾掉大半天。所以我这篇用的是“不涉及软盘版本”把引导启动的部分去掉代码作为普通DOS可执行程序跑核心的输入、存储、排序、去重、显示逻辑全部保留。跑在DOSBox里编译、调试、看结果都是即时反馈非常适合拿来把这道题彻底吃透。这篇文章适合正在啃王爽教材的在校生、自学8086汇编的爱好者以及想重新复习一遍汇编基础的中级开发者。我会把题目拆开揉碎讲清为什么这么设计再给出一份可以原样编译运行的代码最后附上我自己调试时踩过的坑和解决办法。1. 先看清题目到底要解决什么问题1.1 原题与“不涉及软盘”版本的差异王爽《汇编语言第3版》第十七章的课程设计2原题要求写一个程序从键盘接收用户输入的字符每输入一个字符按回车确认最后程序把用户输入过的那些字符按ASCII码从小到大的顺序显示出来而且重复的字符只显示一次。原版多了一步“将程序安装到软盘0面0道1扇区”也就是说程序要自己写引导代码开机后从软盘启动直接进入这个程序没有DOS环境所以也不能调用DOS中断只能自己处理键盘和显示。不涉及软盘版本做了什么取舍核心逻辑一个字没少只是把“从软盘引导”换成了“在DOS环境下运行”因此键盘输入和屏幕输出都可以直接调用int 21h提供的中断功能。这个改动把环境搭建成本降到了最低不需要做软盘镜像不需要反复重启虚拟机写完代码编译链接DOSBox里直接跑。原题要求的“程序不能退出”在简化版里也做了合理调整简化版采用可持续输入的死循环用户按CtrlC或直接关闭窗口退出既保留了主循环的交互模型又符合DOS程序的正常习惯。那么做这个简化版还能不能算通过了课程设计我的看法是这道题真正想考核的是三件事一是能不能熟练使用循环、分支、栈操作和内存寻址二是能不能在汇编层面实现一个完整的算法三是能不能理解中断调用和程序与硬件之间的交互关系。这三件事在简化版里全部覆盖到了引导扇区那一层其实属于“操作系统启动”的扩展知识跟汇编教学主线关系不大。换句话说Nand to Tetris的路可以后面再走先把汇编基本功练扎实才是关键。1.2 拆开之后其实只有四个环节把程序交给一个从来没有写过的人他很容易被“开机启动、不能退出、自己处理键盘”这些外围要求吓住。但把这个题目当成一个纯粹的数据处理问题其实就是四个环节第一步接收输入。用户每敲一个字符程序把它的ASCII码保存起来。这里要注意的是如果用户敲了退格键我们应该把之前保存的最后一个字符从缓冲区里删掉同时屏幕上也要做出对应的回退清空效果。第二步存储。输入的字符数量不确定少则两三个多则几十个所以存储方案要能适应动态增长。王爽这本书学到第十七章你应该对栈已经很熟了——栈就是一个天然的、支持动态压入弹出的缓冲区所以这个版本用栈来存输入字符。第三步排序。输入完成后缓冲区里是原始输入顺序要把它调整成ASCII码升序。这需要一个排序算法用来训练的版本我建议用选择排序代码简单逻辑清晰最适合展示汇编里的双层循环和内存比较。第四步去重显示。排序完成后重复的字符会聚到一起这时候只要在显示的时候跳过与前一个相同的字符就可以实现“只显示一次”。这一点是执行层面的取巧排序时不动数据显示时跳过重复。整个程序就是这四个环节串成的一个死循环。每一轮结束后清屏、重新提示、继续下一轮输入。把这个框架先想明白后面写代码就有了主心骨。2. 关键设计数据怎么存、怎么排、怎么去重2.1 为什么用栈而不是数组当输入缓冲区第一反应是直接开一块数据区当数组来一个字符就往里放数组下标用计数器维护。这个方案没有任何错误代码也很好理解但有一个小问题用户输错了要删掉重来用数组写删除逻辑就要同时维护一个索引弹栈式的操作写起来不够自然。而栈天然就是“先进后出”的动态结构用户每输入一个字符就push一次退格就pop一次操作和数据结构完全对应。还有一个让我决定用栈的理由书上从第十章开始反复强调“栈在子程序调用、中断处理、参数传递中的作用”这道题是全书最后一道综合训练用栈作为主缓冲区是最能体现汇编风格的解法。比如用户输入了字符“a”“c”“b”栈中从栈顶到栈底依次是“b”“c”“a”后续排序之前我们把这个栈里的数据全部pop到一个内存数组中正好恢复成输入顺序。这一步看似多了一次拷贝实际上让排序和显示部分的代码少掉很多麻烦——直接对着线性数组操作省得用指针绕着栈内存转圈。也要提醒一下8086的push和pop都是以字为单位操作的。我们往栈里压入的是ax寄存器虽然只关心低八位的ASCII码但每一次push实际占用两个字节。所以理论上栈空间的上限会减半好在DOS程序默认栈区足够用输入几十个字符完全不会出问题。如果你打算支持超长输入再考虑把判满逻辑加上。2.2 排序用选择排序怎么在纯汇编里写排序算法在高级语言里几行就写完换成汇编要面对寄存器分配、循环嵌套、内存寻址和比较跳转这恰恰是这个课程设计最有价值的部分。我用选择排序作为主算法理由很直白它不需要像冒泡那样频繁交换每一轮只交换一次逻辑上“找到最小值位置然后和当前轮起始位置交换”在汇编里可以把交换次数降到最低减少出错点。选择排序的伪代码是for i 0 to n-2: min i for j i1 to n-1: if buffer[j] buffer[min]: min j if min ! i: swap(buffer[i], buffer[min])翻译成汇编的时候核心是寄存器怎么分配。我用si保存外层循环的i用bx保存内层循环的j用di保存当前发现的最小值位置min。这些寄存器全部有讲究si和di是变址寄存器天然适合做数组索引bx可以作为基址寄存器参与复杂寻址cx作为循环计数器要小心嵌套覆盖。这里我没用loop指令做内层循环因为loop会隐式使用cx而外层我还要靠cx来终止循环嵌套使用loop容易导致计数混乱。我的处理方式是外层用si做循环变量和内层用bx做循环变量显式比较、显式跳转寄存器来源一目了然。这也是汇编调试中最实用的经验寄存器够用的情况下能不用loop就不多用尤其是嵌套场景。2.3 去重不在排序时删元素而在显示时跳过排序完成后数据里还可能存在重复字符比如“a”“b”“b”“c”。题目要求重复的字符只显示一次。很多初学者会想那我排序完之后把重复的元素从数组里删掉剩下的再显示。想法没错但在汇编里删除数组元素是一个O(n)级别的搬移操作需要把右边的元素全部往左挪代码复杂且容易越界。更聪明的做法是只在显示阶段做相邻比较。因为排序后相同的字符必定相邻所以从头到尾扫描数组记录上一个已经输出的字符如果当前字符和上一个相同就跳过不同才输出。用一个bl寄存器保存上一个输出的字符初始设置为一个正常情况下不会出现的值比如0。这样一遍扫描就能完成去重没有任何多余的数据搬移代价只是每次循环多一次cmp和je。这种思路在工程上叫“延迟处理”排序时不修改数据显示时按需过滤。汇编代码短逻辑也更直接。这里还要说一个容易踩的坑用bl当“上一个字符”的哨兵值0理论上有用户真的输入了ASCII码为0的字符会被误判为重复。键盘常规输入不会产生ASCII 0所以这个方案站得住脚。如果你追求极端严谨可以单独用一个标志位记录“是否已经输出过第一个字符”但代码会多几行。对课程设计来说常规做法就够了在文档里提一句边界情况反而显得你有思考。3. 完整可运行的代码与逐段精讲3.1 初始化与主循环框架下面这段代码是完整版直接用MASM编译、链接即可运行。我先贴整段然后分段解释关键部分。; 课程设计2 简化版不涉及软盘引导 ; 功能接收键盘输入回车后按ASCII码升序打印不重复字符 ; 运行环境DOS / DOSBox MASM 5.0 assume cs:code, ds:code code segment start: mov ax, cs mov ds, ax mov es, ax restart: ; 清屏使用BIOS的03号功能切换回80x25文本模式 mov ax, 0003h int 10h ; 显示输入提示 lea dx, msg_input mov ah, 09h int 21h ; cx 记录已经接收的字符个数 xor cx, cx程序开头的段寄存器初始化非常关键。这个程序用exe格式编译代码段和数据段同属一个段但DS寄存器在DOS装载程序时指向的是PSP段地址不是代码段地址。如果不执行mov ax, cs和mov ds, ax后面所有访问缓冲区、字符串的操作都会定位到错误的内存位置打印出来的提示语大概率是乱码。这个知识点在书里反复强调但第一次写的时候十有八九会漏。int 10h清屏那段用的是BIOS显示中断的03号功能把显示模式重设为80x25文本模式同时清空屏幕。这个方案比逐字符清屏快速也更符合DOS程序的习惯。提示字符串以$结尾配合int 21h的09号功能输出是DOS环境下最标准的打印方式。3.2 键盘输入与退格处理input_loop: ; 从键盘读一个字符带回显AL字符ASCII码 mov ah, 01h int 21h cmp al, 0dh je do_sort ; 回车结束输入进入排序 cmp al, 08h je do_backspace ; 退格删除最近一个字符 push ax ; 普通字符压栈保存 inc cx ; 字符个数加一 jmp input_loop do_backspace: ; 如果当前已经没有任何字符忽略退格 test cx, cx jz input_loop pop ax ; 丢弃栈顶最近输入的字符 dec cx ; 在屏幕上把光标回退并清空当前字符 mov dl, 08h mov ah, 02h int 21h mov dl, mov ah, 02h int 21h mov dl, 08h mov ah, 02h int 21h jmp input_loop输入部分用的是int 21h的01号功能。这个功能会从键盘读取一个字符并且自动回显到屏幕上。注意这里的两个特殊字符回车键返回0dh退格键返回08h。回车是输入结束标志这个好理解。退格处理是很多同学第一次写会翻车的地方。你以为用户按退格键01号功能会让你在屏幕上直接看到光标回退吗并不是。它返回了08h这个ASCII码但是光标位置和屏幕上已经显示的字符不会自动帮你恢复。你需要手动做一次“光标回退一格、输出空格覆盖原字符、再回退一格”的操作。这也是为什么我在代码里连续调用了三次02号功能输出。第一次输出08h把光标左移一格第二次输出空格把那个位置的字符覆盖掉第三次再输出08h把光标留在覆盖后的位置。少了中间那个空格你就会看到退格后屏幕上留下一个删不掉的“残影”。这里还有个隐藏细节test cx, cx配合jz input_loop是检查当前是否有字符可删。假如用户还没输入任何字符就按退格栈里没有东西可pop直接pop会把错误的垃圾数据弹出栈导致后续逻辑错乱。这个保护是必须写的不能凭感觉省略。3.3 排序与去重显示do_sort: mov count, cx ; 保存字符个数 ; 从栈里弹出所有字符到buffer数组 lea si, buffer mov cx, count copy_to_buffer: jcxz sort_start pop ax mov [si], al inc si loop copy_to_buffer sort_start: ; 少于2个字符无需排序 mov cx, count cmp cx, 2 jb display_result ; 选择排序升序排列buffer的前count个字节 xor si, si ; si i外层索引 outer_loop: mov ax, count dec ax cmp si, ax jae sort_done mov di, si ; di min先假设当前i最小 mov bx, si inc bx ; bx j i 1 inner_loop: cmp bx, count jae check_swap mov al, buffer[di] cmp al, buffer[bx] jbe inner_next ; buffer[bx] buffer[min] 时跳过 mov di, bx ; 更新最小值位置 inner_next: inc bx jmp inner_loop check_swap: cmp di, si je outer_next mov al, buffer[si] mov ah, buffer[di] mov buffer[si], ah mov buffer[di], al outer_next: inc si jmp outer_loop sort_done: display_result: ; 输出结果提示 lea dx, msg_result mov ah, 09h int 21h ; 扫描buffer跳过与前一个相同的字符 lea si, buffer mov cx, count mov bl, 0 ; bl 上一个输出字符0作为哨兵 disp_loop: jcxz disp_done mov al, [si] cmp al, bl je disp_next mov dl, al mov ah, 02h int 21h mov bl, al disp_next: inc si loop disp_loop disp_done: ; 输出换行标记本轮结束 mov dl, 0dh mov ah, 02h int 21h mov dl, 0ah mov ah, 02h int 21h ; 回到新一轮输入 jmp restart msg_input db Input chars, Enter to sort: $ msg_result db 0dh, 0ah, Result: $ count dw 0 buffer db 128 dup(0) code ends end start从栈往buffer拷贝这一步本质上是在做顺序还原。由于push是倒序存放pop出来正好恢复成用户输入的原始顺序。这一步之后buffer[0]到buffer[count-1]就是用户依次输入的字符序列后续排序和显示都基于这个数组逻辑清爽很多。选择排序的双层循环是这段代码需要认真读的部分。外层循环变量是si从0走到count-2内层循环变量是bx从i1走到count-1。di始终指向当前扫描到的最小字符位置。比较的时候用buffer[di]和buffer[bx]两个内存操作数这在8086指令集里是合法的。jbe是无符号小于等于跳转ASCII码的排序用无符号比较是常识但很多人写汇编时会误用jle导致字符码大于127时排序错误。由于ASCII码表从0到255都能出现这里必须用无符号跳转指令这一条建议直接写在注释里。交换最小值和起始位置的时候两种写法都行可以用寄存器间接寻址也可以像代码里这样直接用buffer[si]和buffer[di]。注意用al和ah两个八位寄存器作为中转一次能交换两个字节比用两个mov通过内存中转更高效。显示去重的逻辑很直白bl里保存上一个已经输出的字符jcxz在count为0时直接跳过循环因为这种情况下buffer里没有任何内容。还有一个边界情况需要知道如果用户只输入了一个字符排序部分会直接跳过显示部分照常输出逻辑自洽。4. 运行测试与答疑4.1 编译运行环境配置这个程序是为DOS环境写的推荐直接用DOSBox配合MASM 5.0或者TASM都行。DOSBox的安装和挂载目录要是还不熟悉记一下最常用的一组命令mount c d:\asm c: masm cds2.asm; link cds2.obj; cds2.exe第一行把Windows下的d:\asm目录挂载成DOS的C盘后面就是在C盘里编译、链接、执行。MASM后面的分号代表使用默认文件名如果你把代码保存成cds2.asm编译后自动生成cds2.obj和cds2.exe。链接器可能会提示no stack segment之类的警告这是因为这个程序没有显式定义堆栈段DOS在装载可执行文件时会分配默认堆栈警告不影响运行。如果你手里没有MASM也可以用TASM加TLINK命令格式略有差异但整体流程一样。实在不想装编译器还有一个快速验证的思路把代码里的DOS中断换成BIOS中断键盘读取功能然后用NASM编译成COM文件不过那样就会偏离简化版的初衷这里不多展开。4.2 测试用例与预期结果我给你准备三组测试用例按难度从易到难。第一组输入完全不重复的字符比如逐个输入d、a、c、b然后回车。预期结果是输出abcd。这个用例验证基本排序是否正确。第二组输入包含若干重复字符比如b、a、b、c、a、a回车后预期输出abc。这个用例验证去重是否生效同时检验排序后相同字符是否真的相邻。如果输出的a和b中间插入了其他字符说明排序或者去重的比较逻辑有问题。第三组故意按退格。比如输入d、a、退格、c、b回车后预期输出bcd。退格删掉了a所以最终字符集是d、c、b三个排序后为bcd。同时注意观察屏幕上退格的过程光标应该回退原来的字符被清掉不留残影。按我实测的经验第三组用例最容易暴露问题因为退格处理牵扯到屏幕操作和栈操作两套逻辑任何一个环节出错都会导致程序行为异常。4.3 常见错误与排查方法第一类问题提示信息或结果全是乱码。这个几乎可以肯定是段地址初始化没做对。老老实实在start标签后写上mov ax, cs、mov ds, ax确保DS指向代码段否则msg_input和buffer的偏移地址全是错的。第二类问题排序结果前几个字符正确后面出现乱码。原因一般是buffer越界。检查count是否在输入阶段被正确维护尤其注意退格之后count有没有减一。还有一个隐蔽原因push ax压栈之后栈向低地址增长如果你输入了大量字符接近栈区上限再pop时可能取到别的数据。这种场景下限制单轮输入的最大字符数是有效手段。第三类问题程序执行一会儿后直接死机或卡住。优先怀疑无限jmp导致指令跑飞。比如do_backspace里的跳转方向写反或者outer_loop里没有正确递增si外层循环永远无法结束。用DWARF式的排错思路把循环变量用debug打印出来逐个确认。这里我特别推荐用DEBUG工具做单步跟踪。假设程序已经编译成cds2.exe运行debug cds2.exe进入DEBUG后用g命令设置断点比如在do_sort的位置设断点执行到断点时用d ds:偏移查看buffer内存区的数据。这样做的好处是能一眼确认输入阶段的数据是否完整到达把问题精确二分数据有问题就查输入数据正常就查排序。5. 指令小抄与实操心得5.1 本文用到的关键指令速查; 输出单个字符 mov dl, 字符 mov ah, 02h int 21h ; 输出以$结尾的字符串DX指向字符串首地址 lea dx, msg mov ah, 09h int 21h ; 从键盘读字符自动回显AL返回ASCII码 mov ah, 01h int 21h ; 无条件跳转和条件跳转 jmp 目标 je 目标 ; 相等跳转 jne 目标 ; 不等跳转 jb / jbe ; 无符号小于 / 无符号小于等于 ja / jae ; 无符号大于 / 无符号大于等于 jz 目标 ; 结果为0跳转 jc 目标 ; 进位标志为1跳转 ; 循环控制 loop 目标 ; 等价于 DEC CX JNZ 目标 jcxz 目标 ; CX为0时跳转常用来做兜底 ; 测试寄存器是否为零 test cx, cx jz 目标这些指令是这门课后半段出现频率最高的基础元素。很多同学到课程设计阶段还在翻指令表完全不丢人但要学会一个技巧弄清楚每条指令会改变哪些标志位。比如cmp会改变CF、ZF、OF、SF而test只做按位与不会像cmp那样影响CF。这些细微差别会影响后续跳转指令的行为写错一个标志依赖逻辑就会莫名其妙地偏掉。5.2 做完这个课程设计后我的体会我做完这个课程设计的简化版之后最大的感受是汇编这门课只看书真的学不会。你以为push和pop的原理懂了但当你亲眼看到栈指针随着输入动态伸缩当你在DEBUG里看到buffer里那些十六进制数据被排序算法一点点理顺才真正理解为什么教科书要从栈讲起、为什么中断调用是程序的骨架。这个简化版虽然没有引导扇区的“完整仪式感”但对于把汇编基础打牢这件事已经绰绰有余。最后再分享一个小技巧如果你想把这道题往深做可以尝试不加int 21h的输入功能改成自己操作键盘缓冲区或者用桶排序把时间复杂度降到O(n)。这些扩展方向既不碰软盘引导又能让你在这个课程设计的基础上走得更远。说到底课程设计不是终点它只是逼着你把前十六章学的东西全部拿出来遛一遛。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询