Part 5. 进阶内容⚓︎
约 3574 个字 190 行代码 预计阅读时间 20 分钟
函数⚓︎
定义⚓︎
函数(function) 又称过程(procedure)。在 x86 中,根据调用和返回指令的不同,有近函数(过程)和远函数(过程)之分。一共有 2 种定义函数的方式:
-
用标号定义函数(常用)
-
用
proc
定义函数
调用和返回⚓︎
搭配使用call
类指令和ret
类指令,大致原理为(这里以近函数为例,具体功能和使用方法可参见 Part 4 对应部分
- 函数调用:
call
指令后面的指令的偏移地址(ip)被存在栈内,然后根据操作数(标号、寄存器等等)跳转到指定函数部分,执行指令 - 函数返回:在函数的最后使用
ret
指令,获取栈内被保存的 ip,跳转到call
指令后一条指令的地址上,从而实现返回功能
参数和返回值的传递⚓︎
函数传参的方式:
- 用寄存器传参
- 小技巧:如果批量传递连续的一组数据(比如字符串
) ,此时不需要传递完整的字符串,只需要传递这组数据的首地址(即第一个元素的地址) ,以及这组数据的长度即可(很像 C 语言的处理) - 局限:寄存器数量较少,可能不够用,而且还可能存在(调用者与被调用者之间的)冲突
- 避免冲突的方法:在函数正式执行前,先将函数用到的寄存器压入栈中;在函数返回前将栈中元素弹出,从而恢复寄存器原来的值(注意顺序
! )
- 小技巧:如果批量传递连续的一组数据(比如字符串
- 用(全局)变量传参
- 局限:当函数是一个递归函数时,函数多次自我调用会破坏变量中的参数值
-
用堆栈传参,有以下几种规范:
-
__cdecl
(常用)- 参数按从右到左的顺序压入堆栈
- 参数的清理由调用者(caller) 负责
- 当函数值是整数时由 eax 返回,是小数时则由 st(0) 返回
- eax、ecx、edx 由调用者负责保存和恢复
- ebx、ebp、esi、edi 由被调用者 (callee) 负责保存和恢复
- 这是 C 语言的参数传递规范
-
__pascal
- 参数按从左到右的顺序压入堆栈
- 参数的清理由被调用者负责
- 这是 Pascal 语言的参数传递规范
-
__stdcall
- 参数按从右到左的顺序压入堆栈
- 参数的清理由被调用者负责
- 这是 Windows API 函数的参数传递规范
-
返回值的传递方式与传参类似(也可以把返回值放在(曾经)作为参数的寄存器或变量内
动态变量和堆栈框架⚓︎
- 在函数的开头,需要用
push bp
及mov bp, sp
这两条指令来保护bp
,同时构造堆栈框架(stack frame) - 构造好堆栈框架后,接着执行指令
sub sp, idata
就可以在函数内部定义宽度为idata
的动态变量或数组。这些动态变量是作用域在函数内的局部变量,因此在函数结束后会被丢掉(因此不能拿这些变量作为函数的返回值) 。 -
函数相关的堆栈框架如下所示(采用
_cdecl
规范) :sp
与bp
之间的空间用于存放局部变量,而bp
下面的空间存放的是参数。它们都可以借助bp
来表示,其中局部变量可通过[bp - ...]
被访问;而参数可通过[bp + ...]
被访问,比如[bp + 4]
表示第一个参数,[bp + 6]
是第二个参数,以此类推
-
在函数退出时先
mov sp, bp
,此时sp
回落bp
的位置,局部变量全部失效,然后pop bp
取出原来的bp
值,再ret
,此时pop
出返回地址返回,然后在调用者处情况堆栈中的参数 - 在函数中,除了要保护
bp
外,还要保护bx
、si
、di
这些偏移地址寄存器的值(在函数使用这些寄存器之前,将它们压入栈中;在函数返回前再弹出以恢复原值) - 综上,我们总结出一般的函数写法:
f:
push bp
mov bp, sp
sub sp, ...
push bx
push si
push di
... ; [bp+?] 为参数
... ; [bp-?] 为局部变量
mov ax, ... ; 设置返回值
pop di
pop si
pop bx
mov sp, bp
pop bp
ret
递归函数⚓︎
例子:求累加和
强烈建议读者自己按照汇编指令的执行顺序过一遍,同时画画堆栈框架,相信只要弄清楚这个函数的运行逻辑后,就能掌握这部分的知识了!
code segment
assume cs:code
;Input: n=[bp+4]
;Output: ax=1+2+3+...+n
f proc near
push bp ; (3)(6)(9)
mov bp, sp
mov ax, [bp+4]
cmp ax, 1
je done
dec ax
push ax ; (4)(7)
call f ; (5)(8)
there:
add sp, 2 ; (12)(15)
add ax, [bp+4]
done:
pop bp ; (10)(13)(16)
ret ; (11)(14)(17)
f endp
main:
mov ax, 3
push ax ; (1)
call f ; (2)
here: ; f(3)的返回值在ax中, 值为6
add sp, 2 ; (18)
mov ah, 4Ch
int 21h
code ends
end main
中断⚓︎
- 中断:通俗理解为,CPU 不再继续往下执行,而是转去处理来自 CPU 内部或外部设备的特殊信息。
-
分类:
- 根据中断的来源:
- 软件中断:在代码显示地用
int n
指令来调用中断例程(属于内中断,来自程序员) - 硬件中断:由硬件的某个事件触发,并由 CPU 自动插入一个隐式的
int n
指令来调用中断例程(来自硬件)
- 软件中断:在代码显示地用
- 根据中断的硬件来源:内中断、外中断
- 根据中断的来源:
-
中断信息包含用于识别来源的编码,称为中断类型码,它是一个字节型数据,可以表示 256 种中断信息的来源(尽管实际上并没有 256 种中断)
- 中断向量表:存放一系列中断向量(即中断处理程序的入口地址)的列表。
- 在 8086PC 机中,它被存在内存 0000:0000~0000:03FF 的 1024 个字节中
- 每个中断向量占 2 个字(4 字节
) ,高地址字存放段地址,低地址字存放偏移地址 - 一般情况下,中断向量表中 0000:0200~0000:02FF 的 256 字节空间是空的,操作系统和其他应用程序不会占用,因此可利用这块空间来自定义中断
-
中断处理程序 / 中断例程:用于处理中断信息的程序,被中断向量定位。执行步骤为:
- 保存用到的寄存器
- 处理中断
- 恢复用到的寄存器
-
用
iret
指令返回iret
指令的等价操作:
-
中断过程:
- 取得中断类型码 N
pushf
- tf = 0, if = 0
push cs
push ip
- ip = N * 4, cs = N * 4 + 2
-
自定义中断,大致过程为:
- 编写中断处理程序(与编写一般函数类似)
-
安装中断处理程序:将中断处理程序存在不太可能被覆写的内存中(一般存在 0000:0200~0000:02FF 这个空的中断向量表空间内
) ,一般借助rep movsb
指令完成这一步骤- 例子:
n
号中断处理程序的标号为int_handler
,其入口地址设为seg_addr:ofs:addr
,那么安装过程为:
assume cs:code code segment main: ; 设置ds:si指向源地址(中断处理程序) mov ax, cs mov ds, ax mov si, offset int_handler ; 设置es:di指向目标地址(某个安全的内存块) mov ax, seg_addr mov es, ax mov bi, ofs_addr ; cx的值设为中断处理程序的长度,通过两个标号的地址之差计算得到 mov cx, offset int_handler_end - offset int_handler cld ; 令 df = 0 rep movsb ; copy! ;设置中断向量表 mov ax, 4C00h int 21h ; 中断处理程序 int_handler: ; ... ; ... int_handler_end: nop code ends end main
- 例子:
-
修改中断向量表:将中断处理程序的入口地址存在中断向量表的对应表项中
- 例子:修改后的
n
号中断处理程序的入口地址为seg_addr:ofs:addr
,中断向量表的修改过程如下所示:
- 例子:修改后的
-
有些情况下,CPU 不会响应中断
- 比如执行向 ss 寄存器传送数据的指令时,中断不会发生,这是为了避免对 ss:sp 整体的破坏
内中断⚓︎
内中断的几种情况:
- 除法错误
- 中断类型码:0
- 单步执行
- 中断类型码:1
- 中断发生条件:当陷阱标志位 tf = 1 时,CPU 在每执行完一条指令后,会自动在该条指令与下条指令之间插入一条
int 1h
指令并执行它 - 功能:在 Debug 中,t 命令起到单步中断的功能,执行该命令后会显示各个寄存器的状态并等待继续输入(中断处理程序的作用)
- 为了不让程序一直陷入单步中断的循环中,所以中断过程中要将 tf 设为 0
-
执行
into
指令(溢出中断)- 中断类型码:4
- 等价操作:
-
执行
int n
指令- 中断类型码:n(自己指定的中断码,一个字节型立即数)
- 等价操作:
old_fl = fl; if = 0; tf = 0; sp -= 6; word ptr ss:[sp] = ip + 2; word ptr ss:[sp+2] = cs; word ptr ss:[sp+4] = old_fl; ip = word ptr 0000:[idata8 * 4] cs = word ptr 0000:[idata8 * 4 + 2]
- 格式:
- 该指令的机器码为 2 字节:
0CDh, idata8
,其中idata8
是中断号 - 该指令的目标地址是一个 32 位的远指针,称为中断向量(interrupt vector),被保存在 0000:idata8*4 处
- [0000:0000, 0000:03FFh] 这个内存区间称为中断向量表,一共存放了从
int 00h
到int 0FFh
共 256 个中断向量 - BIOS 和 DOS 为程序员提供了诸多中断功能,下面将会列出几种常见功能(其他功能参见中断大全)
BIOS 和 DOS 中断例程的安装过程
- 开机时 CPU 通电后,初始化 cs = 0FFFFH,ip = 0,因此执行 0FFFFH:0 处上的跳转指令,执行该指令后转去执行硬件系统检测和初始化程序
- 初始化程序将建立 BIOS 所支持的中断向量表(注意中断例程固化在 ROM 中,是一直在内存中存在的)
- 完成上述步骤后,调用
int 19h
进行操作系统的引导,控制权交给操作系统(DOS) - DOS 系统会将中断例程装入内存,并建立相应的中断向量表
DOS 中断⚓︎
-
int 03h
- 功能:软件断点中断
- 等价操作:
-
int 21h
-
输入输出相关:
ah = 01h
号功能:输入字符al
保存读入的字符
ah = 02h
号功能:输出字符dl
保存待输出的字符,如果是数字则看作 ASCII 码
ah = 09h
号功能:输出字符串- ds:dx 指向一个以
$
为结尾的字符串的首地址,显示的字符串不包含这个$
- ds:dx 指向一个以
ah = 0Ah
号功能:输入字符串- ds:dx 指向一个 buf,buf 的第一个字节为允许输入的最多字符数,第二个字节为实际输入的字符数,从第三个字节开始才是输入的字符内容
- 如果输入超过最大字符数,则会发出铃声,并且光标不再移动
-
文件操作相关:
ah = 3Ch
号功能:创建文件cx =
文件属性(0:可写,1:只读) ,ds:dx 指向文件名的首地址- 返回值:
- 成功:
ax =
句柄,cf = 0 - 失败:
ax =
错误码,cf = 1
- 成功:
ah = 3Dh
号功能:打开文件al =
打开方式(0:只读,1:只写,2:可读可写) ,ds:dx 指向文件名的首地址- 返回值:
- 成功:
ax =
句柄,cf = 0 - 失败:
ax =
错误码,cf = 1
- 成功:
ah = 3Eh
号功能:关闭文件bx =
句柄- 返回值:
- 成功:cf = 0
- 失败:
ax =
错误码,cf = 1
ah = 3Fh
号功能:读文件bx =
句柄,cx =
待读字节数,ds:dx 指向一块 buf,用于存储读入的数据- 返回值:
- 成功:
ax =
已读字节数,cf = 0 - 失败:
ax =
错误码,cf = 1
- 成功:
ah = 40h
号功能:写文件bx =
句柄,cx =
待写字节数,ds:dx 指向一块 buf,用于存储写出的数据- 返回值:
- 成功:
ax =
已写字节数,cf = 0 - 失败:
ax =
错误码,cf = 1
- 成功:
ah = 42h
号功能:移动文件指针bx =
句柄,al =
移动的参照点(0:文件首字节的位置,1:文件指针当前位置,2:EOF,即文件末字节位置 + 1) ,cx:dx = 移动的距离(可正可负,正数表示指针向右移)- 返回值:
- 成功:ds:ax = 当前文件指针与文件首字节的距离,cf = 0
- 失败:
ax =
错误码,cf = 1
-
内存分配相关:
ah = 48h
号功能:分配内存bx =
待分配内存块的节长度- 返回值:
- 成功:
ax =
段地址,cf = 0 - 失败:
ax =
错误码,cf = 1,bx =
最大内存块的节长度
- 成功:
ah = 49h
号功能:释放内存es =
待释放内存块的段地址- 返回值:
- 成功:cf = 0
- 失败:
ax =
错误码,cf = 1
ah = 4Ah
号功能:重分配内存bx =
重分配内存块的节长度- 返回值:
- 成功:cf = 0
- 失败:
ax =
错误码,cf = 1,bx =
最大内存块的节长度
-
ah = 4Ch
号功能:程序返回(控制权交给父程序,比如 DOS)al
用于表示返回值,一般设为 0
-
外中断⚓︎
外中断分为 2 类:
-
可屏蔽中断:CPU 可以不响应的外中断
-
当中断标志位 if = 1 时,响应中断,否则不响应此类中断(可以用
sti
或cli
指令分别设置 if 的值为 1 或 0)- 小技巧:用
cli
和sti
包围起来的指令不会被中断
- 小技巧:用
-
除了中断类型码是通过 CPU 的数据总线传进来的之外,其余中断过程与内中断相同
- 大多数由外设引起的外中断属于此类中断
-
-
不可屏蔽中断:CPU 必须相应的外中断
- 中断类型码固定为 2
- 中断过程为:
- 标志寄存器入栈,if = 0, tf = 0
- cs、ip 入栈
- ip = 8, cs = 0AH
BIOS 中断⚓︎
BIOS 里有什么?
- 硬件系统的检测和初始化程序
- 外部中断和内部中断的中断例程
- 用于对硬件设备进行 I/O 操作的中断例程
- 其他和硬件系统相关的中断例程
int 09h
:处理键盘输入- 通过
60h
号端口读取键盘输入的扫描码,并转化为相应的 ASCII 码以及状态信息,存在内存的指定空间中(键盘缓冲区或状态字节)中
- 通过
int 10h
ah = 00h
号功能:切换显示模式al = 03h
表示 80*25 文本模式al = 13h
表示 320200256 图形模式
ah = 02h
号功能:设置光标位置bh
用于设置光标所在页数,1 页即为文本模式的显示缓冲区,共 8 页dh
用于设置光标所在行号dl
用于设置光标所在列号
ah = 09h
号功能:在光标位置显示字符al
用于表示字符bl
用于设置字符颜色,每个位都有不同的含义,具体可见硬件基础知识中的“显卡地址映射”部分的文本模式cx
用于表示字符重复个数
-
int 16h
:从键盘缓冲区读取一个键盘输入,并将其从缓冲区中删除- 需要先设置
ah = 0
-
ah
保存扫描码,al
保存 ASCII 码(其实也可以读取方向键、功能键、PgUp 等键,但不能读取单独的 Ctrl 键) -
配合
int 09h
实现键盘读取
- 需要先设置
混合语言编程⚓︎
不想学了(应该不会考吧)...
保护模式⚓︎
不想学了(应该不会考吧)...
对此感兴趣的读者可以去看 TonyCrane 老师的笔记 ~
评论区