跳转至

Part 5. 进阶内容⚓︎

3574 个字 190 行代码 预计阅读时间 20 分钟

函数⚓︎

定义⚓︎

函数(function) 又称过程(procedure)。在 x86 中,根据调用和返回指令的不同,有近函数(过程)远函数(过程)之分。一共有 2 种定义函数的方式:

  • 标号定义函数(常用)

    ; 近函数定义1
    标号名:
        ...
        retn  ; 可简写为 ret
    
    
    ; 近函数定义2
    标号名 label near
        ...
        retn  ; 可简写为 ret
    
    ; 远函数定义
    标号名 label far
        ...
        retf
    
  • proc定义函数

    ; 近函数定义
    函数名 proc near
        ...
        retn    ; 可简写为 ret
    函数名 endp
    
    ; 远函数定义
    函数名 proc far
        ...
        retf
    函数名 endp
    

调用和返回⚓︎

搭配使用call类指令和ret类指令,大致原理为(这里以近函数为例,具体功能和使用方法可参见 Part 4 对应部分

  • 函数调用:call指令后面的指令的偏移地址(ip)被存在栈内,然后根据操作数(标号、寄存器等等)跳转到指定函数部分,执行指令
  • 函数返回:在函数的最后使用ret指令,获取栈内被保存的 ip,跳转到call指令后一条指令的地址上,从而实现返回功能

参数和返回值的传递⚓︎

函数传参的方式:

  • 寄存器传参
    • 小技巧:如果批量传递连续的一组数据(比如字符串,此时不需要传递完整的字符串,只需要传递这组数据的首地址(即第一个元素的地址,以及这组数据的长度即可(很像 C 语言的处理
    • 局限:寄存器数量较少,可能不够用,而且还可能存在(调用者与被调用者之间的)冲突
    • 避免冲突的方法:在函数正式执行前,先将函数用到的寄存器压入栈中;在函数返回前将栈中元素弹出,从而恢复寄存器原来的值(注意顺序
  • 用(全局)变量传参
    • 局限:当函数是一个递归函数时,函数多次自我调用会破坏变量中的参数值
  • 堆栈传参,有以下几种规范:

    • __cdecl(常用)

      • 参数按从右到左的顺序压入堆栈
      • 参数的清理由调用者(caller) 负责
      • 当函数值是整数时由 eax 返回,是小数时则由 st(0) 返回
      • eax、ecx、edx 由调用者负责保存和恢复
      • ebx、ebp、esi、edi 由被调用者 (callee) 负责保存和恢复
      • 这是 C 语言的参数传递规范
      f:
          push bp
          mov bp, sp
          ; ...
          pop bp
          ret
      
      main:
          ...
          push a1         ; 压入参数
          push a0
          call f
      back:
          add sp, 4       ; 清理堆栈
      
    • __pascal

      • 参数按从左到右的顺序压入堆栈
      • 参数的清理由被调用者负责
      • 这是 Pascal 语言的参数传递规范
      f:
          push bp
          mov bp, sp
          ; ...
          pop bp
          ret 4           ; 假设a0、a1都是字数据
      
      main:
          ...
          push a0         ; 压入参数
          push a1         
          call f
      back:
      
    • __stdcall

      • 参数按从右到左的顺序压入堆栈
      • 参数的清理由被调用者负责
      • 这是 Windows API 函数的参数传递规范
      f:
          push bp
          mov bp, sp
          ; ...
          pop bp
          ret 4           ; 假设a0、a1都是字数据
      
      main:
          ...
          push a1         ; 压入参数
          push a0
          call f
      back:
      

返回值的传递方式与传参类似(也可以把返回值放在(曾经)作为参数的寄存器或变量内,故不再赘述。

动态变量和堆栈框架⚓︎

  • 在函数的开头,需要用push bpmov bp, sp这两条指令来保护bp,同时构造堆栈框架(stack frame)
  • 构造好堆栈框架后,接着执行指令sub sp, idata就可以在函数内部定义宽度为idata动态变量或数组。这些动态变量是作用域在函数内的局部变量,因此在函数结束后会被丢掉(因此不能拿这些变量作为函数的返回值
  • 函数相关的堆栈框架如下所示(采用_cdecl规范

    • spbp之间的空间用于存放局部变量,而bp下面的空间存放的是参数。它们都可以借助bp来表示,其中局部变量可通过[bp - ...]被访问;而参数可通过[bp + ...]被访问,比如[bp + 4]表示第一个参数,[bp + 6]是第二个参数,以此类推
  • 在函数退出时先mov sp, bp,此时sp回落bp的位置,局部变量全部失效,然后pop bp取出原来的bp值,再ret,此时pop出返回地址返回,然后在调用者处情况堆栈中的参数

  • 在函数中,除了要保护bp外,还要保护bxsidi这些偏移地址寄存器的值(在函数使用这些寄存器之前,将它们压入栈中;在函数返回前再弹出以恢复原值)
  • 综上,我们总结出一般的函数写法:
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 字节空间是空的,操作系统和其他应用程序不会占用,因此可利用这块空间来自定义中断
  • 中断处理程序 / 中断例程:用于处理中断信息的程序,被中断向量定位。执行步骤为:

    1. 保存用到的寄存器
    2. 处理中断
    3. 恢复用到的寄存器
    4. iret指令返回

      • iret指令的等价操作:
      ip = word ptr ss:[sp];
      cs = word ptr ss:[sp+2];
      fl = word ptr ss:[sp+4];
      sp += 6;
      
  • 中断过程

    1. 取得中断类型码 N
    2. pushf
    3. tf = 0, if = 0
    4. push cs
    5. push ip
    6. 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,中断向量表的修改过程如下所示:
      mov ax, 0
      mov es, ax
      mov word ptr es:[n*4], ofs_addr
      mov word ptr es:[n*4+2], seg_addr
      
  • 有些情况下,CPU 不会响应中断

    • 比如执行向 ss 寄存器传送数据的指令时,中断不会发生,这是为了避免对 ss:sp 整体的破坏

内中断⚓︎

内中断的几种情况:

  • 除法错误
    • 中断类型码:0
  • 单步执行
    • 中断类型码:1
    • 中断发生条件:当陷阱标志位 tf = 1 时,CPU 在每执行完一条指令后,会自动在该条指令与下条指令之间插入一条int 1h指令并执行它
    • 功能:在 Debug 中,t 命令起到单步中断的功能,执行该命令后会显示各个寄存器的状态并等待继续输入(中断处理程序的作用)
    • 为了不让程序一直陷入单步中断的循环中,所以中断过程中要将 tf 设为 0
  • 执行into指令(溢出中断)

    • 中断类型码:4
    • 等价操作:
    if (of == 1) {
        old_fl = fl;
        if = 0;
        tf = 0;
        sp -= 6;
        word ptr ss:[sp] = ip + 1;
        word ptr ss:[sp+2] = cs;
        word ptr ss:[sp+4] = old_fl;
        ip = word ptr 0000:[0010h]
        cs = word ptr 0000:[0012h]
    }
    
  • 执行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]
    
    • 格式:
    int idata8
    
    • 该指令的机器码为 2 字节:0CDh, idata8,其中idata8是中断号
    • 该指令的目标地址是一个 32 位的远指针,称为中断向量(interrupt vector),被保存在 0000:idata8*4
    • [0000:0000, 0000:03FFh] 这个内存区间称为中断向量表,一共存放了从int 00hint 0FFh 256 个中断向量
    • BIOS DOS 为程序员提供了诸多中断功能,下面将会列出几种常见功能(其他功能参见中断大全
BIOS DOS 中断例程的安装过程
  1. 开机时 CPU 通电后,初始化 cs = 0FFFFH,ip = 0,因此执行 0FFFFH:0 处上的跳转指令,执行该指令后转去执行硬件系统检测和初始化程序
  2. 初始化程序将建立 BIOS 所支持的中断向量表(注意中断例程固化在 ROM 中,是一直在内存中存在的)
  3. 完成上述步骤后,调用int 19h进行操作系统的引导,控制权交给操作系统(DOS)
  4. DOS 系统会将中断例程装入内存,并建立相应的中断向量表

DOS 中断⚓︎

  • int 03h

    • 功能:软件断点中断
    • 等价操作:
    old_fl = fl;
    if = 0;
    tf = 0;
    sp -= 6;
    word ptr ss:[sp] = ip + 1;
    word ptr ss:[sp+2] = cs;
    word ptr ss:[sp+4] = old_fl;
    ip = word ptr 0000:[000Ch]
    cs = word ptr 0000:[000Eh]
    
  • int 21h

    • 输入输出相关:

      • ah = 01h号功能:输入字符
        • al保存读入的字符
      • ah = 02h号功能:输出字符
        • dl保存待输出的字符,如果是数字则看作 ASCII
      • ah = 09h号功能:输出字符串
        • ds:dx 指向一个以$为结尾的字符串的首地址,显示的字符串不包含这个$
      • ah = 0Ah号功能:输入字符串
        • ds:dx 指向一个 bufbuf 的第一个字节为允许输入的最多字符数,第二个字节为实际输入的字符数,从第三个字节开始才是输入的字符内容
        • 如果输入超过最大字符数,则会发出铃声,并且光标不再移动
    • 文件操作相关:

      • 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,即文件末字节位置 + 1cx: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 时,响应中断,否则不响应此类中断(可以用sticli指令分别设置 if 的值为 1 0

      • 小技巧:用clisti包围起来的指令不会被中断
      cli
      ; ...
      ; instructions
      ; ...
      sti
      
    • 除了中断类型码是通过 CPU 的数据总线传进来的之外,其余中断过程与内中断相同

    • 大多数由外设引起的外中断属于此类中断
  • 不可屏蔽中断:CPU 必须相应的外中断

    • 中断类型码固定为 2
    • 中断过程为:
      1. 标志寄存器入栈,if = 0, tf = 0
      2. cs、ip 入栈
      3. 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 老师的笔记 ~

评论区

如果大家有什么问题或想法,欢迎在下方留言~