《操作系统真象还原》第六章

《操作系统真象还原》第六章

本篇对应书籍第六章的内容

本篇介绍了汇编语言与C语言的混合编程,并实现了打印字符的内核函数,以及简单介绍了使用AT&T语法进行内联汇编。

函数调用约定

函数调用约定,是调用函数的一套约定,体现在:

  • 参数的传递方式
  • 参数的传递顺序
  • 寄存器环境由谁来保存

调用约定是为了解决汇编语言的问题提出的,调用约定有很多:

在这里插入图片描述

在这里插入图片描述

Windows API用的是stdcall,这里用的是cdecl

这两者都是参数从右往左入栈,区别在于stdcall被调用者清理栈空间cdecl调用者清理

汇编语言和 C 语言混合编程

混合编程可以分为两种:

  1. 单独的汇编代码文件和 C 文件,在分别编译成目标文件之后一起链接成可执行程序。
  2. C 语言中嵌入汇编代码,直接编译成可执行程序,也就是内联汇编。

本节的重点是第一种。

系统调用

本节以 Linux 系统的系统调用为例介绍;

Linux 系统调用的入口只有一个,即 0x80 号中断,子功能号在寄存器 eax 中单独指定。

调用“系统调用”有两种方式:

  • 将系统调用指令封装为 C 库函数,通过库函数进行系统调用
  • 不依赖任何库函数,直接通过汇编指令 int 与操作系统通信

当输入的参数小于等于 5 个时,Linux 用寄存器传递参数;当参数个数大于 5 个时,把参数按照顺序放入连续的内存区域,并将该区域的首地址放到 ebx 寄存器。

系统调用的过程类似函数调用的过程,不过是系统提供的函数,同样遵守函数调用约定

汇编与 C 共同协作

在汇编语言中导出符号名用 global 关键字,引用外部符号时用 extern 声明。

在 C 代码中只要将符号定义为全局便可被外部引用,引用外部符号时用 extern 声明即可。

实现自己的打印函数

这里是通过直接写显存来完成一个打印函数

显卡的端口控制

本节重点:我们主要要用到哪个端口?那个端口的地址是什么?

显卡寄存器:

在这里插入图片描述

显卡端口非常多,但是计算机系统提供的寄存器寻址范围很少,只有0~65535个

所以显卡硬件也使用数据结构的方式来提供寄存器的访问,如图,上面4个寄存器被分为了2组,Address 寄存器和 Data 寄存器,前者存储寄存器组的索引,后者是该索引对应的寄存器

这里主要用到的寄存器组是CRT Controller Register,这里的端口地址取决于 Miscellaneous Output Register 寄存器中的 Input/Output Address Select 字段。

在这里插入图片描述

默认情况下, Miscellaneous Output Register寄存器的值为0x67, 其他字段不管, 咱们只关注这最重要的I/OAS位, 其值为1。 也就是说:

  • CRT controller寄存器组的Address Register的端口地址为0x3D4,Data Register的端口地址0x3D5。
  • Input Status # l Register寄存器的端口地址被设置为0x3DA。
  • Feature Control register寄存器的写端口是0x3DA。

由于这里涉及到的显卡操作只用到了CRT Controller Registers分组中的寄存器,其他的就不管了:

在这里插入图片描述

这里的 0x0e0x0f存储的是光标位置的高、低8位。

实现单个字符打印

为了开发方便,需要定义一些数据类型

lib/kernel/stdint.h:
#ifndef __LIB_STDINT_H
#define __LIB_STDINT_H

typedef signed char int8_t;

typedef signed short int16_t;

typedef signed int   int32_t;

typedef signed long long int int64_t;

typedef unsigned char uint8_t;

typedef unsigned short uint16_t;

typedef unsigned int   uint32_t;

typedef unsigned long long int uint64_t;

#endif
lib/kernel/print.asm

打印相关的函数都在这个文件里实现,打印的处理流程:

  1. 备份寄存器现场。
  2. 获取光标坐标值,光标坐标值是下一个可打印字符的位置。
  3. 获取待打印的字符。
  4. 判断字符是否为控制字符, 若是回车符、换行符、 退格符三种控制字符之一,则进入相应的处理流程。否则, 其余字符都被粗暴地认为是可见字符, 进入输出流程处理。
  5. 判断是否需要滚屏。
  6. 更新光标坐标值, 使其指向下一个打印字符的位置。
  7. 恢复寄存器现场, 退出。

代码文件:

TI_GDT equ 0
RPL0   equ 0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0

[bits 32]
section .text
;------------------------   put_char   -----------------------------
; 功能描述:把栈中的1个字符写入光标所在处
;-------------------------------------------------------------------  
global put_char
put_char:
    pushad                              ; 备份32位寄存器环境

    ; 保险起见,每次打印都给 gs 寄存器赋值, 因为要操作硬件, 这里初始化gs的时候手动把DPL置零来执行内核代码
    mov ax, SELECTOR_VIDEO
    mov gs, ax

    ;----------获取当前光标位置----------
    ; 先设置索引寄存器的值选中要用的数据寄存器,然后从相应的数据寄存器进行读写操作
    ; 先获得高8位
    mov dx, 0x03d4                      ; 索引寄存器
    mov al, 0x0e                        ; 提供索引光标位置的高8位, 对应索引为0x0e
    out dx, al
    mov dx, 0x03d5                      ; 通过读写数据端口获取/设置光标位置
    in al, dx                           ; 获取光标位置高8位
    mov ah, al

    ; 获取低8位
    mov dx, 0x03d4  
    mov al, 0x0f
    out dx, al
    mov dx, 0x03d5
    in al, dx

    ; 将光标存入 bx
    mov bx, ax

    ; 在栈中获取待打印字符
    mov ecx, [esp + 36]                 ; pushad 压入 4*8=32 字节, 主调函数返回地址 4 字节, 所以 +36

    ; 判断是不是控制字符
    cmp cl, 0x0d                        ; CR 是 0x0d 回车符
    jz .is_carriage_return

    cmp cl, 0x0a                        ; LF 是 0x0a 换行符
    jz .is_line_feed

    cmp cl, 0x8                         ; BS 是 0x08 退格符
    jz .is_backspace

    jmp .put_other


    ; 退格键处理
    .is_backspace:
        dec bx                          ; bx 是下一个字符的位置, 减 1 则指向当前字符
        shl bx, 1                       ; 光标值 *2(即左移1位) 就是光标在显存中的相对地址

        mov byte [gs:bx], 0x20          ; 将待删除的字节补为0, 低字节是 ascii 码
        inc bx
        mov byte [gs:bx], 0x07          ; 将待删除的字节属性设置为0x07(黑屏白字),高字节是属性
        shr bx, 1                       ; 还原光标值, 删除掉的字符本身就是下一个可打印字符的光标位置
        jmp .set_cursor


    ; 输入字符处理
    .put_other:
        shl bx, 1

        mov [gs:bx], cl                 ; cl里存放的是待打印的 ascii 码
        inc bx
        mov byte [gs:bx], 0x07          ; 字符属性
        shr bx, 1                       ; 恢复老的光标值
        inc bx                          ; 下一个光标值
        cmp bx, 2000
        jl  .set_cursor                 ; 若光标值小于2000, 则没有写满, 则设置新的光标值, 反之则换行


    ; 换行/回车处理
    .is_line_feed
    .is_carriage_return:
        ; \r \n 都按 \n处理, 光标切换到下一行的行首
        ; 这里的处理是: 1. 将光标移动到当前行首(回车)
        xor dx, dx                      ; dx是被除数的高 16 位
        mov ax, bx                      ; ax是被除数的低 16 位
        mov si, 80              

        div si                          ; 光标位置除 80 的余数便是取整
        sub bx, dx                      ; dx里存放的是余数, 现在 bx 中存放的是行首坐标


    .is_carriage_return_end:
        ; 2.将光标移动到下一行的同位置(换行)
        add bx, 80
        cmp bx, 2000

    
    .is_line_feed_end:
        jl .set_cursor


    ; 滚屏处理
    .roll_screen:
        ; 1.先将 1-24 行搬运到 0-23 行里
        cld                             ; 方向标志清零
        mov ecx, 960                    ; 2000-80=1920, 1920*2=3840 个字节要搬运, 一次搬运4字节,搬运 3840/4=960 次
        mov esi, 0xc00b80a0             ; 第1行行首
        mov edi, 0xc00b8000             ; 第0行行首
        rep movsd

        ; 2.将第24行填充为空白
        mov ebx, 3840                   ; 最后一行首字符偏移 = 1920 * 2 = 3840
        mov ecx, 80                     ; 一行80字符(160字节), 每次清空1字符(2字节), 一行需要移动80次

        .cls:
            mov word [gs:ebx], 0x0720   ; 0x0720是黑底白字的空格
            add ebx, 2
            loop .cls
            mov bx, 1920                ; 将光标重置到最后一行行首


    ; 设置光标
    .set_cursor:
        ; 1.先设置高8位
        mov dx, 0x03d4                  ; 索引寄存器
        mov al, 0x0e                    ; 光标高8位
        out dx, al

        mov dx, 0x03d5                  ; 通过读写数据端口 0x03d5 来获取或设置光标位置
        mov al, bh          
        out dx, al                      ; 设置光标位置高8位

        ; 2.再设置低8位
        mov dx, 0x03d4
        mov al, 0x0f
        out dx, al

        mov dx, 0x03d5
        mov al, bl
        out dx, al

    
    .put_char_done:
        popad
        ret

在这里插入图片描述

其中打印输出的控制字符,比如回车、换行、退格等,都是需要我们手动添加处理程序的,电脑并不认识这些控制字符

lib/kernel/print.h

print.asm 中的 put_char 函数对于其他程序来说,属于外部函数,都写在一个头文件里会比较方便:

#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"

void put_char(uint8_t char_asci);

#endif
main.c

接下来可以来使用我们的内核文件来测试一下这个函数的功能了:

#include "print.h"

int main(){
        put_char('k');
        put_char('e');
        put_char('r');
        put_char('n');
        put_char('e');
        put_char('l');
        put_char('\n');
        put_char('1');
        put_char('2');
        put_char('\b'); // 退格符
        put_char('3');

        while(1);

        return 0;
}

运行 Bochs

接下来,编译,运行,测试一下效果看看:

nasm -f elf -o lib/kernel/print.o lib/kernel/print.asm

gcc -m32 -I lib/kernel -c -o kernel/main.o kernel/main.c

ld -m elf_i386 -Ttext=0xc0001500 -e main -o kernel/kernel.bin kernel/main.o lib/kernel/print.o

dd if=/home/steven/source/os/bochs/code/kernel/kernel.bin of=/home/steven/source/os/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc

为了避免链接符号出现问题导致起始虚拟地址不准确,链接参数的顺序最好是调用在前,实现在后

运行:

在这里插入图片描述

实现字符串打印

lib/kernel/print.asm
;--------------------------------------------
;put_str 通过put_char来打印以0字符结尾的字符串
;--------------------------------------------
;输入:栈中参数为打印的字符串
;输出:无
global put_str
put_str:
    ; 只用到了 ebx 和 ecx,所以仅备份这两个寄存器
    push ebx
    push ecx
    xor ecx, ecx
    mov ebx, [esp + 12]                 ; 获取待打印的地址

    .goon:
        mov cl, [ebx]                   ; cl是8位, 1 个字节, 读取待打印地址的字符串
        cmp cl, 0                       ; 到字符串结尾 \0 结束返回
        jz .str_over
        push ecx                        ; put_char 参数
        call put_char
        add esp, 4                      ; 回收 put_char 参数的栈空间
        inc ebx                         ; 循环遍历字符串
        jmp .goon


    .str_over:
        pop ecx
        pop ebx
        ret

在这里插入图片描述

lib/kernel/print.h
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"

void put_char(uint8_t char_asci);

void put_str(char* message);

#endif
kernel/source/main.c
#include "print.h"

int main(){
        put_str("I am kernel\n");

        while(1);

        return 0;
}

运行 Bochs

还是刚才的编译、链接、写入硬盘的操作,参数不变,运行:

在这里插入图片描述

实现整数打印

准备一个 8 字节的缓冲区,然后将要打印的整数从右往左依次取出 4 个字节,转换成 Ascii 码,然后填充到里面

lib/kernel/print.asm
;--------------------   将小端字节序的数字变成对应的ascii后, 倒置   -----------------------
;输入:栈中参数为待打印的数字
;输出:在屏幕上打印16进制数字,并不会打印前缀0x, 如打印10进制15时,只会直接打印f,不会是0xf
;------------------------------------------------------------------------------------------
global put_int
put_int:
    pushad
    mov ebp, esp
    mov eax, [ebp + 4 * 9]              ; call 的返回地址 4 字节,pushad 占 8 个 4 字节
    mov edx, eax                        ; 参数数字备份
    mov edi, 7                          ; 指定在put_int_buffer中的初始偏移量
    mov ecx, 8                          ; 32位数字中, 十六进制数字的位数是8个
    mov ebx, put_int_buffer

    ; 将 32 位数字从高位到低位逐个处理, 每4位二进制是16进制的一位, 共处理8个16进制数字
    .16based_4bits:
        and edx, 0x0000000F             ; 取出最后一个16进制数
        cmp edx, 9                      ; 判断是否大于9
        jg .is_A2F
        add edx, '0'                    ; 转换成ascii
        jmp .store


    .is_A2F:
        sub edx, 10                     ; A~F - 10 + A = A~F 对应的 ascii
        add edx, 'A'


    ; 数字转换成 ascii 之后, 按大端顺序存储到 buffer 中
    .store:
        ; 此时 dl 中是数字对应的 ascii码
        mov [ebx + edi], dl             ; 加上7个字节存储, 也就是存储在最前面的位置上
        dec edi
        shr eax, 4                      ; 将参数数字最后一个字符去掉
        mov edx, eax
        loop .16based_4bits


    ; 此时 buffer 中是 ascii 了, 打印之前把高位连续数字去掉, 比如把字符 000123 变成 123
    .ready_to_print:
        inc edi                         ; 让 edi+1 变成 0

    .skip_prefix_0:
        cmp edi, 8                      ; 若已经是第九个字符了
        je .full0                       ; 表示全是 0

    ; 找出连续的 0 字符, edi 作为非 0 最高位偏移
    .go_on_skip:
        mov cl, [put_int_buffer + edi]
        cmp cl, '0'
        je .ready_to_print              ; 等于0就跳转, 判断下一位是否是字符0
        
        jmp .put_each_num


    .full0:
        mov cl, '0'                     ; 当全 0, 只打印一个 0

    .put_each_num:
        push ecx                        ; 此时cl中为可打印的字符
        call put_char
        add esp, 4                      ; 清除栈空间
        inc edi                         ; 指向下一个字符
        mov cl, [put_int_buffer + edi]  ; 获取下一个字符到 cl 寄存器
        cmp edi, 8
        jl .put_each_num
        
        popad
        ret
        

在这里插入图片描述

lib/kernel/print.h
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"

void put_char(uint8_t char_asci);

void put_str(char* message);

void put_int(uint32_t num);

#endif
kernel/source/main.c
#include "print.h"

int main() {

        put_str("I am kernel\n");
        put_int(0);
        put_char('\n');
        put_int(9);
        put_char('\n');
        put_int(0x00021a3f);
        put_char('\n');
        put_int(0x12345678);
        put_char('\n');
        put_int(0x00000000);

        while (1);

        return 0;
}

运行 Bochs

还是之前的编译链接命令,运行:

在这里插入图片描述

内联汇编

另一种汇编和 C 语言混合编程的方式便是在 C 语言里面写汇编语言。

gcc 默认支持的是 AT&T 语法风格的汇编语言

AT&T 汇编语法简介

AT&T 语法风格与 Intel 对比:

在这里插入图片描述

AT&T 中数字被优先认为是内存地址。

AT&T 内存寻址:

segreg(段基址): base_address(offset_address, index, size)

base_address 是基地址,可以为整数、变量名,可正可负。

offset_address 是偏移地址,index 是索引值,这两个必须是 8 个通用寄存器之一。

size 是个长度,只能是 1、2、4、8。

基本内联汇编

基本内联汇编是最简单的内联形式,其格式为:

asm [volatile] ("assembly code")

asm 是必须的,表示是内联汇编;

volatile 表示让编译器不要修改我的代码 ;

assembly code 的原则:

  • 指令必须用双引号引起来,无论双引号中是一条指令或多条指令。
  • 一对双引号不能跨行,如果跨行需要在结尾用反斜杠 ‘\’ 转移。
  • 指令之间用分号’;’、换行符’\n’或换行符加制表符’\n’’\t’分隔。

在基本内联汇编中,若要引用 C 变量,只能将它定义为全局变量。如果定义为局部变量,链接时会找不到这两个符号。

扩展内联汇编

asm [volatile] ("assembly code": output : input : clobber/modify)
  • assembly code: 用户写入得汇编指令。
  • output: 用来指定汇编代码得数据如何输出给 C 代码使用。
  • input: 用来指定 C 中数据如何输入给汇编使用。
  • clobber/modify: 汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据得破坏,这样 gcc 就知道哪些寄存器或内存需要提前保护起来。

在扩展汇编指令中,%被用作占位符,寄存器前面要用%%

这些要求在扩展内联汇编中称为“约束”,作用就是把 C 代码中的操作数映射为汇编中使用的操作数,约束分 4 大类:

  • 寄存器约束:要求 gcc 使用哪个寄存器

    a: 表示寄存器 eax/ax/al 
    b: 表示寄存器 ebx/bx/bl 
    c: 表示寄存器 ecx/cx/cl 
    d: 表示寄存器 edx/dx/dl
    D:表示寄存器edi/di
    S:表示寄存器esi/si
    q:表示任意这4个通用寄存器之一:eax/ebx/ecx/edx
    r:表示任意这6个通用寄存器之一:eax/ebx/ecx/edx/esi/edi
    g:表示可以存放到任意地点(寄存器和内存)。相当千除了同q一样外,还可以让gee安排在内存中
    A:把eax和edx组合成64位整数
    f:表示浮点寄存器
    t:表示第1个浮点寄存器
    u:表示第1个浮点寄存器
    

    使用举例:

    #include<stdio.h>
    int main(){
    	int in_a = 1, in_b = 2, out_sum; 
    	asm("addl %%ebx, %%eax":"=a"(out_sum) :"a"(in_a),"b"(in_b)); 
        //input的“a”(in_a):表示eax = in_a
        //output的“=a”(out_sum):表示eax = out_sum
        printf("sum is %d\n",out_sumJ; 
    }
    
  • 内存约束:直接将 input 和 output 中的 C 变量的内存地址作为内联汇编代码的操作数,直接进行内存读写

    m:表示操作数可以使用任意一种内存形式。
    o:操作数为内存变量,但访问它是通过偏移量的形式访问,即包含 offset—address 的格式。
    

    使用举例:

    #include<stdio.h>
    int main(){
    	int in_a = 1, in_b = 2; 
    	printf("in_b is %d\n", in_b); 
    	asm("movb %b0, %1;"::"a"(in_a),"m"(in_b));
        //%1:序号占位符,就是 in_b 的地址
        //%b0:b表示低8位,0表示 in_a 的地址
        printf("in_b now is %d\n", in_b); 
    }
    
  • 立即数约束:要求 gcc 直接传递立即数给代码,不通过寄存器或内存,只能作为右值,只能放在 input 中

    i:表示操作数为整数立即数
    F:表示操作数为浮点数立即数
    I:表示操作数为0~31之间的立即数
    J:表示操作数为0~63之间的立即数
    N:表示操作数为0~255之间的立即数
    0:表示操作数为0~32之间的立即数
    X:表示操作数为任何类型立即数
    
  • 通用约束:0~9:此约束只用在input部分, 但表示可与output和input中第n个操作数用相同的寄存器或内存。

占位符:

  • 序号占位符是对在 output 和 input 中的操作数,按照它们从左到右出现的次序从 0 开始编号,一直到 9。

  • 名称占位符与序号占位符不同,需要在 output 和 input 中把操作数显式地起个名字:

    [名称] "约束名" (C 变量)
    
  • 操作数类型修饰符用来修饰所约束的操作数:内存、寄存器:

    • output
      • =,表示操作数是只写
      • +,表示操作数可读写
      • &,表示此output中的操作数要独占所约束的寄存器,任何 input 中所分配的寄存器不能与之相同
    • input
      • %,该操作数可以和下一个操作数互换

机器模式简介

操作码就是指定操作数为寄存器中的哪个部分,初步了解h、b、W、K这几个操作码就够了。

寄存器按是否可单独使用,可分成几个部分,拿eax举例:

  • 低部分的一字节:al
  • 高部分的一字节:ah
  • 两字节部分:ax
  • 四字节部分:eax

h:输出寄存器高位部分中的那一字节对应的寄存器名称,如ah、bh、ch、dh。

b:输出寄存器中低部分1字节对应的名称,如al、bl、cl、d1。

w:输出寄存器中大小为2个字节对应的部分,如ax、bx、ex、dx。

k:输出寄存器的四字节部分,如eax、ebx、ecx、edx。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值