ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DOS汇编入门:段模型、int 21h调用与编译连接全流程

DOS汇编入门:段模型、int 21h调用与编译连接全流程

1. 为什么今天还要学 DOS 下的汇编?——不是怀旧,是理解计算机的“骨骼”

你打开一个现代 IDE,点下运行,几毫秒后窗口弹出,程序跑起来了。背后是 LLVM 编译器、Windows 子系统、图形驱动、内存管理单元……层层抽象像奶油蛋糕一样叠得又厚又香。但如果你真想搞懂“代码怎么变成 CPU 能执行的指令”“为什么 printf 要调用系统接口”“栈帧到底长什么样”,那 DOS 下的.asm文件就是最干净的解剖台。

这不是复古表演,而是回归第一性原理。我带过几届某高校嵌入式方向的实训课,发现一个现象:学生能熟练写 Python 爬虫、用 React 做前端,但一看到mov ax, 0x1234就发怵;能背出 TCP 三次握手流程,却说不清int 21h这条指令在硬件层面触发了什么动作。问题不在智力,而在整个学习路径跳过了“机器语言如何被真实执行”这一环。DOS 汇编恰好卡在这个临界点上——它足够原始(无虚拟内存、无进程隔离、直接操作寄存器和中断向量),又足够完整(有输入/输出、文件读写、内存分配等基本服务),是连接高级语言与硅基物理世界的最佳桥梁。

关键词里虽然没填,但标题本身已锁定三个不可绕开的核心:汇编程序基本结构(段定义、标号、伪指令)、DOS 系统调用机制(特别是int 21h的功能号体系)、传统编译连接流程(.asm→.obj→.exe)。这三者不是并列关系,而是因果链:结构决定能否被识别,识别后才能生成目标码,目标码必须经连接器处理才能成为 DOS 可加载的.exe映像。很多人卡在“写完代码却无法运行”,根本原因往往不是语法错,而是没理解.model small和end start这两个看似简单的伪指令,实际在告诉连接器:“请把这段代码按 16 位实模式组织,入口点是 label start,数据段和代码段要分别对齐到段边界”。

我试过让学员跳过 DOS 直接上 Linux x86_64 汇编,结果 70% 的人在syscall参数传递规则和栈对齐上反复栽跟头。因为 Linux 的 ABI 太重,而 DOS 的int 21h是裸露的、可触摸的。比如ah=09h输出字符串,你必须确保ds:dx指向以$结尾的内存地址——这个约束直白得像小学算术题,但它强迫你思考:内存地址是谁给的?段寄存器怎么设置?$是 ASCII 码 24h,CPU 怎么知道这是结束符?这些问题的答案,就藏在 DOS 的内部设计逻辑里。

所以,别把这当成过时技术。它是一把手术刀,用来解剖所有现代系统的底层共识。当你在调试一个嵌入式固件死循环时,当你要优化一段实时音频处理的内联汇编时,当你要逆向分析某个闭源驱动的初始化流程时,你在 DOS 下练出来的“寄存器直觉”和“中断敏感度”,会突然变得无比珍贵。

2. 汇编程序的骨架:段、标号、伪指令——不是语法糖,是内存布局说明书

很多人以为汇编就是一堆mov、add、jmp,其实真正决定程序能否跑起来的,反而是那些不生成机器码的“伪指令”(assembler directives)。它们不指挥 CPU,而是指挥汇编器——告诉它:“这块内存归数据用”“那段代码从这里开始执行”“这个符号代表地址 0x100”。忽略它们,就像盖楼不看地基图纸,钢筋水泥再结实也会塌。

我们拆解一个最简但完整的 DOS 汇编程序骨架:

; hello.asm - 最小可执行 DOS 程序 .model small .stack 100h .data msg db 'Hello, DOS!$' .code start: mov ax, @data mov ds, ax mov dx, offset msg mov ah, 09h int 21h mov ah, 4Ch int 21h end start

别急着看int 21h,先盯住四行核心伪指令:.model、.stack、.data、.code。它们共同定义了程序在内存中的物理布局,这才是 DOS 实模式下.exe文件的强制契约。

2.1.model small:告诉连接器“我要用哪种内存模型”

DOS 时代没有平坦内存(flat memory),只有分段(segment)+偏移(offset)的寻址方式。.model伪指令声明程序使用的内存模型,small是最常用的一种,含义非常具体:

  • 代码段(CS)和数据段(DS)共用同一个段地址
  • 堆栈段(SS)也指向同一段地址
  • 整个程序被限制在 64KB 内存空间中

为什么必须声明?因为连接器link.exe需要据此生成正确的.exe头部信息。.exe文件开头有 28 字节的 DOS 头,其中e_cs(代码段偏移)和e_ds(数据段偏移)字段的值,就由.model类型决定。small模型下,连接器会把 CS、DS、SS 全部设为同一个段值,这样mov ax, @data才能把数据段地址装入 AX,再传给 DS 寄存器。

对比其他模型:.model tiny用于.com文件(CS=DS=SS,且所有段合并为一个),.model medium允许代码段大于 64KB 但数据段仍受限。选错模型,轻则链接失败,重则程序加载后 DS 指向错误内存区域,msg字符串根本读不出来——你看到的不是乱码,而是程序直接崩溃。

2.2.stack 100h:为运行时预留“呼吸空间”

.stack不是定义堆栈内容,而是向操作系统申请一块内存作为运行时堆栈。100h(256 字节)是经验值,足够存放返回地址、局部变量和寄存器现场。关键点在于:堆栈段(SS)和堆栈指针(SP)的初始值由 DOS 加载器根据此声明自动设置。

实测验证:若删掉.stack 100h,汇编器ml.exe会报错error A2006: undefined symbol : @stack。因为@stack是一个预定义符号,代表堆栈段的起始地址,.stack伪指令正是为它赋值。没有它,连接器找不到 SS 的来源,自然无法生成合法.exe。

更深层意义:堆栈是函数调用的生命线。call指令会把返回地址压栈,ret则弹出。如果 SP 没被正确初始化(比如指向内存垃圾区),ret就会跳到随机地址,程序立刻失控。我在某次调试中遇到一个诡异问题:程序在int 21h后总跳转到错误位置。最后发现是.stack声明太小,int 21h内部调用时压栈超出了分配空间,覆盖了代码段的某条指令——这就是没理解.stack物理意义的代价。

2.3.data和.code:内存的“功能分区”铁律

DOS 实模式下,内存不是一锅粥,而是被严格划分为不同功能区。.data段存放初始化数据(如msg db 'Hello'),.code段存放可执行指令。这种分离不是可选项,而是硬件强制要求:

  • CS 寄存器只能指向代码段,否则 CPU 执行jmp时会触发异常
  • DS、ES、SS 寄存器只能指向数据/堆栈段,否则mov ax, [bx]会读取错误内存

.data和.code伪指令的作用,就是告诉汇编器:“接下来的语句,请分别放入数据段和代码段的缓冲区”。汇编器据此生成两个独立的目标块(.obj文件中的段表),连接器再将它们映射到.exe文件的不同节(section)中。

特别注意offset msg:offset是一个运算符,计算msg符号在.data段内的偏移量(不是物理地址!)。mov dx, offset msg的本质是dx = 0x0000(假设 msg 在数据段开头),而mov dx, msg则是dx = 'He'(把字符串前两个字节当数值读)。初学者常混淆这两者,根源在于没吃透“段:偏移”二维寻址的本质。

2.4end start:程序入口的“法律认证”

end start是整个骨架的收口指令,它干了两件事:

  1. 标记源文件结束:汇编器停止扫描后续文本
  2. 指定程序入口点:start是一个标号(label),end start告诉连接器:“.exe文件加载后,第一条执行的指令是start:标号处的代码”

为什么不能写end?因为 DOS.exe文件头必须包含入口点偏移(e_ip字段)。如果只写end,连接器不知道从哪开始执行,会默认用e_ip=0,导致 CPU 从代码段开头(通常是mov ax, @data)执行——看起来能跑,但一旦涉及堆栈操作就会崩。start标号必须位于.code段内,且不能是proc过程名(那是高级语法),必须是裸标号。

我曾见过一个学员的代码,start:标号写在.data段里,汇编通过,链接通过,但运行时黑屏。调试发现e_ip指向数据段,CPU 把'Hello'当作机器码执行,48 65 6C 6C 6F解码成dec ax、push bp、mov bp, sp……完全不可预测。这就是end start的严肃性——它不是语法点缀,而是程序合法性的签名。

3. DOS 系统调用:int 21h不是魔法,是标准化的“服务菜单”

很多初学者觉得int 21h神秘莫测,仿佛 DOS 内部有个黑箱在响应中断。其实它就是一个高度规范化的服务接口,类似现代操作系统的系统调用(syscall)。int 21h本身只是触发软中断,真正的服务由 DOS 内核的中断处理程序提供,而服务类型由AH寄存器的值(功能号)决定。

3.1 中断调用的“三步握手”协议

DOS 系统调用遵循固定流程,我称之为“三步握手”:

  1. 准备参数:将功能号存入AH,其他参数按约定存入AL、DX、CX等寄存器
  2. 触发中断:执行int 21h,CPU 保存当前状态,跳转到 DOS 中断向量表中21h号入口
  3. 获取返回:DOS 执行完毕,将结果存入约定寄存器(如AX),并恢复原状态返回用户程序

这个协议的关键在于约定。比如功能号09h(显示字符串)的约定是:

  • AH = 09h(功能号)
  • DX = 字符串首地址的偏移量(必须是DS:DX形式)
  • DS = 数据段地址(必须提前设置好)
  • 字符串必须以$(ASCII 24h)结尾

违反任一约定,结果都是未定义行为。我调试过一个案例:学员把mov dx, offset msg写成mov dx, msg,DX得到的是'He'(0x6548),DOS 就去DS:0x6548地址读字符串,那里是内存垃圾,程序直接锁死。这不是 DOS 的 bug,而是没遵守协议。

3.2 功能号分类:从“生存必需”到“进阶能力”

DOSint 21h提供约 100 个功能号,按实用性可分为四类:

类别功能号示例用途关键寄存器
基础 I/O01h,02h,09h键盘输入、字符输出、字符串输出AL,DL,DX
文件操作3Dh,3Eh,3Fh,40h打开、关闭、读、写文件AX,CX,BX,DX
内存管理48h,49h,4Ah分配、释放、调整内存块BX,AX
程序控制4Ch,4Bh终止程序、加载执行新程序AL

对初学者,掌握前两类足矣。01h(读单字符)和02h(写单字符)是调试神器:mov ah, 01h+int 21h后,AL中就是按键的 ASCII 码,比09h更适合做交互式程序。而4Ch(终止程序)是必用项,它比00h(直接返回)更安全——4Ch会清理堆栈、释放内存、返回 DOS 提示符;00h可能导致 DOS 状态混乱。

提示:功能号是十六进制,必须用h后缀(如mov ah, 09h)。写成mov ah, 9会被汇编器当作十进制 9,功能完全不同(09h=9是字符串输出,9是“创建目录”,需要额外参数)。

3.3 错误处理:CF标志位是你的“故障灯”

DOS 系统调用失败时,不会弹窗报错,而是通过CF(Carry Flag)标志位通知。成功时CF=0,失败时CF=1,且AX中存放错误码(如02h表示“文件未找到”)。

这是最容易被忽视的细节。很多教程代码直接int 21h后就往下走,从不检查CF。实际项目中,文件操作必须加错误分支:

mov ah, 3Dh ; 打开文件功能 mov al, 0 ; 只读模式 mov dx, offset filename int 21h jc open_error ; CF=1 跳转到错误处理 mov bx, ax ; 成功:AX 返回文件句柄,存入 BX ... open_error: ; 显示错误信息或退出

我在某图像处理 Demo 中遇到过:程序在一台机器上正常,在另一台报错。查了半天发现是3Dh打开文件失败(CF=1),错误码05h(拒绝访问)。原来目标机 DOS 版本较老,不支持长文件名,而filename是IMAGE.BMP,DOS 认为是非法格式。如果没检查CF,程序会用BX=0当作有效句柄去读文件,结果读出全零数据——这种 bug 极难定位。

3.4int 21h的物理实现:从软件中断到硬件响应

深入一层:int 21h如何从一条汇编指令变成屏幕上的文字?过程如下:

  1. CPU 执行int 21h,硬件自动将FLAGS、CS、IP压入堆栈
  2. CPU 从内存地址0x0084(21h * 4)读取 4 字节,得到 DOS 中断处理程序的CS:IP
  3. CPU 跳转到该地址,开始执行 DOS 内核代码
  4. DOS 根据AH值查表,调用对应服务子程序(如AH=09h→PRINT_STR)
  5. PRINT_STR逐字节读DS:DX,调用 BIOSint 10h输出到显存,遇$停止
  6. DOS 清理现场,iret返回用户程序

这个链条揭示了一个事实:int 21h的效率取决于 DOS 内核实现。早期 DOS 1.x 的09h功能很慢,因为它要逐字调用 BIOS;DOS 3.x 后优化为批量写显存。这也是为什么同样代码,在不同 DOS 版本下运行速度差异明显——你调用的不是 CPU 指令,而是操作系统的服务。

4. 编译-连接-执行全流程:手把手复现 DOS 工具链的“三步炼金术”

现代开发环境一键编译,掩盖了工具链的复杂性。DOS 汇编的ml.exe(Microsoft Macro Assembler)和link.exe是经典组合,理解它们的协作逻辑,才能摆脱“复制粘贴教程”的被动状态。

4.1 第一步:汇编(Assemble)——.asm→.obj

命令:ml /c hello.asm
关键参数/c:只进行汇编,不连接,生成目标文件hello.obj。

.obj文件不是可执行文件,而是“半成品”。它包含:

  • 重定位信息:标记哪些地址需要连接器修正(如offset msg的偏移值)
  • 外部引用:记录未定义符号(如@data,需连接器解析)
  • 段定义:.data、.code等段的大小和属性

汇编器ml.exe的核心任务是语法翻译 + 符号管理。它把mov ax, @data翻译成机器码B8 ?? ??(B8是mov ax, imm16操作码),但?? ??部分留空,因为@data的真实值要等连接时才知道。这就是“重定位”的起点。

常见错误排查:

  • error A2008: syntax error : .data:伪指令拼写错误(如写成.date)
  • error A2006: undefined symbol : msg:msg定义在.code段,但引用在.data段(段内引用才有效)
  • warning A4001: no stack segment:缺少.stack,虽不致命但危险

注意:ml.exe默认生成 16 位目标文件。若用/coff参数,会生成 32 位 COFF 格式,与 DOS.exe不兼容——这是新手常踩的坑,以为新版本更好,结果链接失败。

4.2 第二步:连接(Link)——.obj→.exe

命令:link hello.obj
无参数时,link.exe使用默认配置:生成.exe文件,入口点为start,内存模型为small。

连接器link.exe干三件大事:

  1. 解析符号:将@data替换为实际数据段地址,start替换为代码段内偏移
  2. 合并段:把.obj中的.data、.code、.stack段合并到.exe的对应节中
  3. 填充头部:写入 DOS.exe头(28 字节),包括e_cs、e_ip、e_ss、e_sp等字段

.exe文件结构精妙:前 28 字节是 DOS 头,之后是重定位表,再之后才是真正的代码和数据。你可以用十六进制编辑器打开hello.exe,看到开头是MZ(Mark Zbikowski,DOS 开发者),接着是00 00(e_cblp字段),这就是 DOS 识别可执行文件的魔数。

连接失败典型场景:

  • fatal error L1021: no stack segment:.obj中无堆栈段定义(.stack伪指令缺失)
  • error L2029: 'start' : unresolved external:end start的start标号在.code段中未定义,或拼写不一致(如Start:大写)
  • warning L4021: no stack segment:有.stack但未初始化,连接器用默认值,可能不稳定

4.3 第三步:执行(Execute)——.exe→ 屏幕输出

命令:hello(在 DOS 命令行输入)
DOS 加载器工作流程:

  1. 读取.exe头,验证MZ标志
  2. 分配内存:根据e_minalloc和e_maxalloc字段申请内存块
  3. 加载段:将.exe中的代码、数据、堆栈节复制到内存
  4. 设置寄存器:CS=e_cs,IP=e_ip,SS=e_ss,SP=e_sp
  5. 跳转执行:jmp CS:IP,程序开始运行

此时start:标号处的mov ax, @data才真正获得数值。@data是连接器填入的段地址,比如0x1234,所以ax=0x1234,mov ds, ax就让 DS 指向数据段。

实测技巧:用debug hello.exe可以单步调试。debug是 DOS 下的瑞士军刀:

  • -r查看寄存器(确认CS,IP,DS值)
  • -u反汇编(查看start处机器码)
  • -t单步执行(观察AX,DX变化)
  • -d ds:0查看数据段内容(确认msg字符串存在)

我教学生时,让他们先用debug运行一次,再修改msg为'Test$',重新编译连接,再用debug对比ds:0内容——这种眼见为实的方式,比讲一百遍“段地址”都管用。

4.4 全流程命令链与批处理自动化

手动敲四条命令太繁琐,用批处理封装:

@echo off echo === 汇编阶段 === ml /c %1.asm if errorlevel 1 goto error echo === 连接阶段 === link %1.obj if errorlevel 1 goto error echo === 执行阶段 === %1.exe goto end :error echo 编译/连接失败! :end

保存为build.bat,运行build hello即可一键完成。批处理中if errorlevel 1是关键:ml.exe和link.exe成功时返回 0,失败时返回非零值,这是判断流程是否继续的唯一可靠依据。

提示:DOS 下文件名限 8.3 格式(如HELLO.ASM),长文件名会被截断。建议全程用小写,避免大小写混淆。

5. 从“Hello World”到实用工具:三个渐进式实战项目拆解

光懂理论不够,必须动手做。我设计了三个项目,难度递进,覆盖 DOS 汇编的核心能力:字符串处理、文件操作、内存管理。

5.1 项目一:大小写转换器(强化字符串与循环)

需求:从键盘读入一行字符串(以回车结束),将其中小写字母转为大写,再输出。

关键点突破:

  • 读字符串:int 21h无直接“读一行”功能,需用0Ah功能(带缓冲区的输入)
  • 缓冲区结构:0Ah要求缓冲区首字节存最大长度,第二字节存实际长度,之后才是字符
  • 循环逻辑:用cx作计数器,si指向字符,cmp判断'a'-'z'范围

核心代码片段:

; 定义缓冲区 buffer db 20, 0, 20 dup(0) ; 最大20字,实际长度0,20字节空间 ; 读入 mov ah, 0Ah mov dx, offset buffer int 21h ; 转换:buffer+2 是字符串起始,buffer+1 是实际长度 mov cl, buffer+1 mov ch, 0 mov si, offset buffer+2 convert_loop: cmp byte ptr [si], 'a' jb next_char cmp byte ptr [si], 'z' ja next_char sub byte ptr [si], 32 ; 小写转大写 ASCII 差32 next_char: inc si loop convert_loop

经验心得:0Ah功能会把回车(0Dh)存入缓冲区,但输出时09h遇$才停,所以需在转换后手动加$:mov byte ptr [si], '$'。这个细节不查文档根本想不到。

5.2 项目二:文件复制器(掌握文件操作全流程)

需求:copyfile src.txt dst.txt,将源文件完整复制到目标文件。

核心步骤:

  1. 打开源文件:3Dh,模式0(只读)
  2. 创建目标文件:3Ch,模式0(普通文件)
  3. 循环读写:3Fh读100h字节,40h写同等字节数
  4. 关闭文件:3Eh

关键陷阱:

  • 3Ch创建文件时,CX必须为0(普通文件),否则创建隐藏/只读文件
  • 3Fh返回实际读取字节数,可能小于请求值(文件末尾),需用CX更新下次读取量
  • 40h写入字节数必须等于3Fh返回值,否则目标文件多出垃圾字节

我曾因忽略3Fh返回值,用固定100h字节写入,导致目标文件比源文件大——因为最后一次读取不足100h,3Fh返回23h,但40h仍写100h,后面0DDh字节是内存垃圾。

5.3 项目三:内存探测器(理解 DOS 内存管理)

需求:显示当前可用常规内存(640KB 以下)总量。

技术要点:

  • 调用48h:分配内存块,BX存需分配的段数(1 段=16 字节)
  • 错误码08h:内存不足,此时BX返回最大可分配段数
  • 计算公式:可用内存(字节)=BX * 16

代码逻辑:

; 尝试分配 1000h 段(最大可能) mov bx, 1000h mov ah, 48h int 21h jc out_of_memory ; 成功:BX 是实际分配段数,但我们要的是最大可用,所以故意失败 out_of_memory: ; BX 现在是最大可分配段数 mov ax, bx mov dx, 10h ; 16 mul dx ; AX = BX * 16 ; AX 即为可用内存字节数,转为十进制输出...

这个项目教会我:DOS 的内存管理是“试探法”。没有 API 直接告诉你剩余多少,只能通过分配失败来反推。这是资源受限时代的典型设计哲学。

6. 常见故障全景图:从“黑屏”到“乱码”的 12 个真实排错链路

DOS 汇编调试没有 IDE 断点,全靠逻辑推理。我把多年踩过的坑整理成故障树,按现象反推根因。

6.1 现象:程序运行后黑屏,无任何输出,也无错误提示

排查链路:

  1. 检查end start:start标号是否存在?是否在.code段?拼写是否一致?
  2. 检查DS初始化:mov ax, @data+mov ds, ax是否遗漏?用debug的-r命令看DS值是否为0000(DOS 默认值,错误)
  3. 检查int 21h参数:AH是否为09h?DX是否为offset msg?msg是否以$结尾?
  4. 检查.exe头:用十六进制编辑器看开头是否为MZ,若为4D 5A则是 DOS 文件,若为7F 45 4C 46(ELF)则是 Linux 文件,说明工具链用错

真实案例:某学员代码完全正确,但黑屏。用debug发现DS=0000,查源码发现mov ds, ax写成mov ds, bx,BX未初始化。改后立即输出。

6.2 现象:输出乱码(如╠╠╠╠╠╠╠╠),而非预期字符串

排查链路:

  1. 检查msg定义:是否在.data段?是否以$结尾?db 'Hello'缺$会一直输出到内存垃圾
  2. 检查DS:DX指向:DX是偏移量,DS是段地址,两者缺一不可。mov dx, msg是错的,应为mov dx, offset msg
  3. 检查int 21h前AH值:是否被前面指令意外修改?加mov ah, 09h强制赋值

关键技巧:用debug的-d ds:0查看数据段开头,确认msg字符串是否在0000偏移处,且结尾是24($的 ASCII)。

6.3 现象:程序运行后立即返回 DOS,无输出

排查链路:

  1. 检查int 21h调用:是否漏写?mov ah, 09h后是否跟int 21h?
  2. 检查end start:是否写成end?导致入口点为0000,执行垃圾指令后崩溃
  3. 检查4Ch调用:是否在int 21h后立即执行?应放在所有输出之后

避坑经验:在int 21h后加nop(空操作)指令,用debug单步到nop,确认前面指令已执行。

6.4 现象:文件操作失败(CF=1),错误码02h(文件未找到)

排查链路:

  1. 检查文件路径:DOS 默认当前目录,src.txt是否在当前目录?用dir命令确认
  2. 检查文件名格式:是否为 8.3 格式?SOURCE.TXT正确,source_long_name.txt会被 DOS 截断
  3. 检查DX指向:mov dx, offset filename中filename是否定义在.data段?是否以0结尾(db 'src.txt',0)?

终极验证:用debug加载.exe,-u反汇编,看DX加载的偏移量是否指向正确的字符串地址。

提示:所有故障排查,第一步永远是debug。它比任何文档都诚实。

7. 现代价值延伸:DOS 汇编思维在当代开发中的隐性迁移

有人问:学 DOS 汇编对写 Python 或 React 有什么用?我的回答是:它培养的是一种“系统级直觉”,这种直觉在现代开发中无处不在,只是被封装得更深了。

7.1 内存视角的迁移:从DS:DX到malloc+pointer

DOS 时代,mov dx, offset msg强迫你思考“这个数据在内存的哪个位置”。今天写 C 语言char *p = malloc(100),p就是offset,malloc返回的地址就是DS的某种抽象。当p为NULL时,就像int 21h的CF=1,是资源耗尽的明确信号。很多 C 程序员崩溃,是因为忘了检查malloc返回值——他们缺失的,正是 DOS 下对“内存分配失败”的条件反射。

7.

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进