操作系统真象还原 学习笔记07--完善内核

selph
selph
发布于 2021-01-29 / 1363 阅读
0
0

操作系统真象还原 学习笔记07--完善内核

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

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

本篇难点:

  • 内联汇编的用法(AT&T)

本篇坑点:

  • 书上print.S代码中出现了错误代码导致死循环(已解决)

函数调用约定

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

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

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

在这里插入图片描述

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分组中的寄存器,其他的就不管了:

image-20210128163416006

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

实现单个字符打印

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

lib/kernel/stdint.h:

#ifndef __LIB_STDINT_H
#define __LIB_STDINT_H
typedef signed char 		int8_t;
typedef signed short	int	int16_t;
typedef signed int		int32_t;
typedef signed long long int 	int64_t;
typedef unsigned char 		uint8_t; 
typedef unsigned short int 	uint16_t;
typedef unsigned int		uint32_t;
typedef unsigned long long int 	uint64_t;
#endif

lib/kernel/print.S

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

  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
;-------------------------------------------
; Function: put_char
;
; Description: 把栈中的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位
        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 就是光标在显存中的相对地址

        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处理,光标切换到下一行的行首
; 这里的处理是:将光标移动到当前行首
        xor dx, dx              ;dx是被除数的高 16 位
        mov ax, bx              ;ax是被除数的低 16 位
        mov si, 80
        div si                  ;光标位置除 80 的余数便是取整
        sub bx, dx              ;dx里存放的是余数

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

.is_line_feed_end:
        jl .set_cursor

;滚屏处理
.roll_screen:
        ;先将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

        ;将第24行填充为空白
        mov ebx, 3840
        mov ecx, 80

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

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

        mov dx, 0x03d5
        mov al, bh
        out dx, al

        ;再设置低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.S 中的 put_char 函数对于其他程序来说,属于外部函数,都写在一个头文件里会比较方便:

#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci);
#endif

kernel/source/main.c

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

#include "print.h"
int main(){
        put_char('\n');
        put_char('k');
        put_char('e');
        put_char('r');
        put_char('n');
        put_char('e');
        put_char('l');
        put_char('3');
        put_char('2');
        put_char('\n');
        put_char('v');
        put_char('0');
        put_char('\b');
        put_char('\n');
        put_char('1');
        put_char('\n');
        while(1);
        return 0;
}

运行 Bochs

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

nasm -f elf -o lib/kernel/print.o lib/kernel/print.S && \
gcc -m32 -I lib/kernel/ -c -o kernel/main.o kernel/source/main.c && \
ld -m elf_i386 -Ttext=0xc0001500 -e main -o kernel/kernel.bin kernel/main.o lib/kernel/print.o && \
strip --remove-section=.note.gnu.property ./kernel/kernel.bin && \
dd if=kernel/kernel.bin of=hd60M.img bs=512 count=200 seek=9 conv=notrunc

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

运行:

image-20210128165435027

实现字符串打印

lib/kernel/print.S

;-------------------------------------------
; Function: put_str
;
; Description: 通过put_char来打印以0结尾的字符串
; Param:待打印的字符串
;------------------------------------------
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       
        jz .str_over
        push ecx                ;put_char 参数
        call put_char
        add esp, 4
        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("\nhello kernel\n");
        while(1);
        return 0;
}

运行 Bochs

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

image-20210128171255229

实现整数打印

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

lib/kernel/print.S

;-------------------------------------------
; Function: put_int
;
; Description: 将小端序数字变成对应的ascii后,倒置
; Param_Input:待打印的数字
; Output:打印十六进制数字到屏幕
;------------------------------------------

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 位数字从高位到低位逐个处理
.based16_4bits:
        and edx, 0x0000000F     ;取出最后一个16进制数
        cmp edx, 9              ;判断是否大于9
        jg .is_A2F
        add edx, '0'            ;转换成ascii
        jmp .store
.is_A2F:
        sub edx, 10
        add edx, 'A'

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

;此时 buffer 中是 ascii 了,打印之前把高位连续数字去掉
.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;.skip_prefix_0       ;等于0就跳转,判断下一位是否是字符0
        ;dec edi                                ;书上说:反之就恢复前面指向的下一个字符,就是转到非 0 字符的第一个
                                                ;实际上这么跳转是死循环,书上写的有问题,跳转也有问题!
        jmp .put_each_num

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

.put_each_num:
        push ecx
        call put_char
        add esp, 4
        inc edi
        mov cl, [put_int_buffer + edi]
        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("\nhello kernel\n");
        put_int(0);
        put_char('\n');
        put_int(0x00001500);
        put_char('\n');
        put_int(0x11112345);
        put_char('\n');
        put_int(9);
        put_char('\n');
        put_int(0x0);
        put_char('\n');
        while(1);
        return 0;
}

运行 Bochs

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

image-20210128181806567

内联汇编

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

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

AT&T 汇编语法简介

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

image-20210129002030560

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。

参考资料


评论