3. 汇编语言程序
文章目录
- 参考视频:烟台大学贺利坚老师的网课《汇编语言程序设计系列专题》,或者是B站《汇编语言程序设计 贺利坚主讲》,大家一起看比较热闹。
- 中文教材:《汇编语言-第3版-王爽》(课程使用)、《汇编语言-第4版-王爽》(最新版)。
- 老师的博客:《迂者-贺利坚的专栏-汇编语言》
- 检测点答案参考:《汇编语言》- 读书笔记 - 各章检测点归档
本篇笔记对应课程第三章(下图倾斜),章节划分和教材对应关系如下。
3.0 导学
对应教材第四章:介绍汇编语言程序结构、及程序调试手段。
【3.1 用汇编语言写的源程序】源程序结构。
【3.2 由源程序到程序运行】编译、链接。
【3.3 用Debug跟踪程序的执行】观察程序运行时的微观变化。对应教材第五章:用程序设计的方法解决问题,也就是各种控制结构,如分支结构、循环结构等。
【3.4 […]和(…)】
【3.5 Loop指令】循环结构。
【3.6 Loop指令使用再例】
【3.7 段前缀的使用】介绍如何使用“段前缀”大段地引用内存数据,主要是DS+ES+BX偏移地址
。对应教材第六章:解决代码和数据的程序结构问题。
【3.8 在代码段中使用数据】
【3.9 在代码段中使用栈】
【3.10 将数据、代码、栈放入不同段】最清晰的程序结构。
3.1 用汇编语言写的源程序
汇编程序是包含“汇编指令”和“伪指令”的文本。如上图给出一个汇编程序的示例,下面是具体的含义介绍:
- 伪指令:指导编译器完成编译工作的指令,没有对应的机器码,不会被CPU所执行。下面是几种伪指令。
assume
:假设某一个“段寄存器”和程序中的某一个用 segment … ends 定义的“段”相关联,无需深入理解。比如assume cs:codesg
是指 CS寄存器 与 codesg段 相关联,将定义的codesg当作程序的代码段使用。- 段定义:一个汇编程序是多个段组成的,每个段都有段名,这些段被用来存放代码、数据或当作栈空间来使用。一个有意义的汇编程序中至少要有一个“代码段”。
end
:汇编程序的结束标记。注意和“段”的结束标志ends
区分开来。;
为汇编程序的注释符号。
- 汇编指令:对应有机器码的指令,可以被编译为机器指令,最终会被CPU执行。
- 程序返回:固定套路,程序结束运行后,将CPU的控制权交还给使它得以运行的程序(常为DOS系统)。
【编程示例】编程求 23。编程步骤如下图所示:
编写汇编语言程序的两种方法:
- Debug直接写入指令:适用于功能简单、短小精悍的程序,只需要包含汇编指令即可。
- 单独编写成源文件:编写后再编译为可执行文件,适用于编写大程序。源程序由一些段构成,包括汇编指令、伪指令。
程序编程中可能犯的错误,可以用于指导debug思路:
- 语法错误:程序在编译时被编译器发现的错误,比较容易发现。如下左图,第一行
assume
、第五行sx
拼写错误。- 逻辑错误:程序在编译时不能表现出来的、在运行时发生的错误,不易发现。比如下右图第五行寄存器名称拼错。
3.2 由源程序到程序运行
- 编辑源程序:编辑工具有 edit.com(见工具包,经典但不好用)、EditPlus(老师)、VScode等,可以按照兴趣选择。
- 编译:使用工具包中的 masm.exe 进行编译,生成最终的 *.OBJ 文件。下面是中间文件的介绍、三种编译格式、编译错误的演示。
- *.LST 文件:是一种列表文件,它包含了源代码以及编译器生成的详细信息,如行号、源代码、机器代码、符号表等。
- *.CRF 文件:是交叉引用文件,它列出了程序中的符号(变量、标签、函数等)及其在源代码中的位置。
- *.OBJ 文件:是目标文件,它是汇编器将源代码翻译成机器码后的产物,包含了可重定位的机器代码和符号信息。
注:编译结束时,编译器输出的最后两行会输出“警告错误 Warning Errors”、“必须要改正的错误 Severe Errors”。
- 连接:使用工具包中的 link.exe 进行连接,生成最终的 *.EXE 文件。“连接”的三种格式和上述“编译”相同,中间文件的含义如下。
- *.MAP 文件:是内存映射文件,包含了程序中各个符号和段的地址分布情况,可用于了解程序在内存中的布局。
- *.LIB 文件:是库文件,描述了不同库函数之间的调用关系。它包含了一组OBJ文件,这些模块可以在不同的程序中重用。
- *.EXE 文件:是最终的可执行文件,包含了可以直接在操作系统上运行的机器代码。
注:当源程序中没有“栈段”时,“连接”会警告错误
no stack segment
,程序简单时无需理会。
- 运行:直接输入生成的可执行文件名,即可执行。
工具包见:“8086汇编工作环境.zip(1.76MB)”。
【实机演示】使用图3-1所示的源代码,生成可执行文件并执行。
3.3 用Debug跟踪程序的执行
DOS系统加载EXE的过程如上图所示。主要找一段内存存储整个程序,并将前256个字节专门用于存储“程序段前缀(PSP)”,主要是为了DOS和程序进行通信,然后继续存放要执行的指令。于是程序加载后,关键的三个寄存器内容为:
- DS:整个程序的起始地址,前256个字节专门存储“程序段前缀(PSP)”。
- CS:固定为 DS+0010H,存储要执行的指令。
- IP:初始时加载为0。注意 CS:IP 表示第一个要执行的指令地址。
- CX:表示当前程序的长度(字节)。
我们想跟踪程序每一步的执行过程,就可以使用Debug装载运行。主要有以下三种调试方式:
- t(Trace):逐条指令执行,适合详细调试每个指令,相当于“单步执行-步进”
- p(Procedure step):逐过程执行,适合调试过程中不想深入子过程,相当于“单步执行-步出”。比如下面要介绍的 loop 循环中,
p
会直接跳过具体的循环过程,而直接给出循环的最后结果。- g(Go):连续执行程序,适合运行程序到下一个断点或结束。可以加上地址。
在DOS系统启动后,计算机由“命令解释器”(程序command.com)控制,也就是命令行中的 c:\>
,等待用户输入要执行的指令。最后总结程序运行的两种方式,也体现了操作系统的运行细节:
- 在DOS中直接运行:程序执行的“常态”。运行EXE文件时,command将程序加载入内存,并设置CPU的CS:IP指向程序的第一条指令(即程序的入口),使程序得以运行。程序运行结束后,会再返回到“命令解释器”。
- 在Debug中运行:程序执行处于开发周期的运行方式。在command程序中加载Debug.exe,然后将程序加载入内存,程序运行结束后,还是返回到Debug中。
3.4 […]和(…)
为了后续的表述方便,我们特别做出如下约定:
[...]
:里面是一个数,表示偏移地址,是汇编语法。(...)
:里面只能是寄存器名/物理地址/偏移地址,表示该寄存器或内存单元中的内容,是人为约定,而非汇编语法。当里面的内容为偏移地址时,默认取 DS 为段地址。idata
:表示常量,当不关心具体的数据内容时用此表示,是人为规定,而非汇编语法。
3.5 Loop指令
Loop指令实现计数型循环,语法为 loop 标号
,循环次数为 CX。具体代码就是进入循环之前先给 CX 赋值为循环次数,然后每次执行 loop
时会判断cx中的值,若 CX 不为零则转至“标号”处执行程序、若 CX 为零则向下执行。于是总结使用 CX 和 loop 指令相配合实现循环功能的三个要点:
- 在 CX 中存放循环次数。
- 用“标号”指定循环开始的位置。
- 在“标号”和 loop 指令的中间,写上要循环执行的程序段(循环体)。
下面按左图所示代码进行 Debug 跟踪,观察 loop 执行时,CX 和循环体的执行细节:
【代码示例】使用 loop 循环计算 212。
注:asm文件中的数字没有后缀时,默认为十进制。
注:若使用p
指令,会直接跳过循环体,给出最后的循环结果。
3.6 Loop指令使用再例
【代码示例】计算 ffff:0006 字节单元中的数乘以3,并将结果存储在 DX 中。
难点:字节单元是8位,乘以3不会超过16位,使用DX存储合理,但注意不能使用低八位的 DL 存储。
注:在asm文件中,数据不能以字母开头,若有需要则需在前面加0。
3.7 段前缀的使用
首先介绍一个编译的小bug。那就是对于指令 mov al,[idata]
,若在 Debug 模式下直接使用 a
命令写入,那么执行时会将存储单元 DS:[0] 的数据存入到 AL。但如果是在源程序中,在进行编译和连接后,就等价于 mov al,idata
!这显然不符合我们的期望,解决方法是加上段前缀 mov al,ds:[idata]
,就不会出错了。这些出现在访问内存单元的指令中,用于显式地指明内存单元的段地址的“ds:”、“cs:”、“ss:”、“es:”,在汇编语言中称为 段前缀。
那既然有了段前缀,于是 loop 和 [bx] 联手就可以连续地访问内存单元:
【代码示例1】计算 ffff:0 ~ ffff:b 单元(字节)中的数据的和,结果存储在 dx 中。
分析:11个8位数字相加,肯定不会超过16位,所以结果存储在 DX 中不会溢出。assume cs:code code segment mov ax,0ffffh mov ds,ax mov dx,0 ; 结果初始为0 mov bx,0 ; 内存单元索引初始为0 mov cx,12 ; 循环12次 s: mov al,[bx] mov ah,0 add dx,ax inc bx ; 内存单元索引加1 loop s mov ax,4c00h int 21h code ends end
注:关键在于不能直接使用指令
add dx,ds:[bx]
,否则会进行16位加法运算。【代码示例2】将内存 ffff:0~ffff:b 中的数据拷贝到 0:200~0:20b 单元中。
难点:体会“段前缀”的作用,体会“附加段寄存器ES”作为“段前缀”带来的性能提升——循环体更小、程序执行更快。
注:CX在循环中是12递减到1,而不是0~11,所以不能使用CX作为内存单元的偏移地址。
3.8 在代码段中使用数据
上一小节的程序中,段地址都是直接给定的。但实际上,直接在程序中指定段地址存放数据是危险的,因为每一段都有特定含义,比如 0020:0000H 实际上存放中断相关程序。如果我们想在汇编程序中安全地存放数据,就需要在程序的“段”中存放数据,就可以在程序运行时由操作系统分配空间。“段”分为“数据段”、“代码段”、“栈段”,各种段中均可以有数据。我们可以将数据、代码、栈安置到同一个段中,也可以分别放入不同的段。
【代码示例】编程计算以下8个数据的和,结果存在 ax 寄存器中
0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987H
难点:如何让程序从第一条指令执行——在第一条指令位置使用标号。、
注:
dw
定义一个字、db
定义一个字节、dd
定义一个双字。
注:start
只是大家约定俗成的标号,也可以换成别的字符,比如begin
。
3.9 在代码段中使用栈
本小节介绍如何在代码段中使用“栈”。“栈”需要的内存空间,在程序中通过定义“空”数据来取得。比如下面程序中,定义了16个字 0000H 来作为栈空间:
【代码示例】利用栈,将下面的数据逆序存放。
0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987H
难点:先入栈、再出栈。assume cs:code code segment ; 在代码段中定义数据(define word) dw 0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987H ; 定义栈空间——16word dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 start: mov ax,cs mov ss,ax mov sp,30h ; 栈底,也就是第一条指令的偏移地址 ; 入栈 mov bx,0 ; 数据的索引 mov cx,8 s0: push cs:[bx] add bx,2 ; 以字节为单位 loop s0 ; 出栈 mov bx,0 ; 数据的索引 mov cx,8 s1: pop cs:[bx] add bx,2 ; 以字节为单位 loop s1 mov ax,4c00h int 21h code ends end start
注:程序运行后,栈的最后数据是乱码,这是因为出栈时 sp 可能会乱掉会有一些其他干扰,不是错误。
注:每个循环的标号命名不能相同。
3.10 将数据、代码、栈放入不同段
上一小节的代码中,数据、代码、栈都放到了同一个“代码段”中,显然会使得程序混乱、可读性不高。于是我们改进上一小节的代码,将数据、代码、栈分别存放:
【代码示例】利用栈,将下面的数据逆序存放。
0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987H
难点:将数据、代码、栈分别存放到各自的栈。assume ds:data,ss:stack,cs:code ; 数据段 data segment dw 0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987H data ends ; 栈段——16字 stack segment dw 0,0,0,0,0,0,0,0,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,20h ; 注意这里只考虑栈空间 ; 入栈 mov bx,0 mov cx,8 s0: push ds:[bx] ; 默认DS,可以不写 add bx,2 loop s0 ; 出栈 mov bx,0 mov cx,8 s1: pop ds:[bx] ; 默认DS,可以不写 add bx,2 loop s1 mov ax,4c00h int 21h code ends end start
注:汇编程序至少包含一个段,也就是代码段,所以无需给 CS 初始化赋值。
注:数据段和栈段的段地址最好通过u
指令 查看代码。