Featured image of post 《操作系统真象还原》第六章 —— 完善内核

《操作系统真象还原》第六章 —— 完善内核

本文介绍了汇编层面的函数调用过程以及内联汇编,同时完成了内核的第一个功能——打印字符及字符串

本章节所有代码托管在miniOS_32

章节任务介绍

任务简介

上一节,我们介绍了程序的基本结构以及程序是如何加载到内存的,同时成功的加载了内核到内存

本节我们将正式书写内核代码

本章的主要任务有:

  1. 实现内核的单个字符打印
  2. 实现内核字符串打印功能

前置知识

汇编层间的函数调用

函数调用涉及两个问题

  1. 参数传递的方式
  2. 参数传递的顺序

先回答第一个问题,参数传递的方式

首先,我们需要知道,现代编译器在处理函数调用进行参数传递有两种方式

  • 寄存器传递
  • 栈空间传递

以下是当前的64位操作系统的寄存器概览

image-20241215221217541

  • 如果参数的个数小于7个,则采用寄存器传递,因为寄存器传递参数速度较快。这7个寄存器分别是
    • rdi,传递第一个参数
    • rsi,传递第二个参数
    • rdx,传递第三个参数
    • rcx,传递第四个参数
    • r8,传递第五个参数
    • r9,传递第六个参数
  • 参数个数大于等于7个,多余的参数则使用栈空间传递

当然,我们目前要做的是32位操作系统(如果想了解更多有关现代编译器过程调用细节可参看《深入理解计算机系统》),其调用约定还相对没那么多,当参数个数小于等于5个的时候使用寄存器传递,当参数个数大于5的时候使用栈空间传递,以下是我们目前使用的参数传递寄存器约定

  • ebx存储第一个参数
  • ecx存储第二个参数
  • edx存储第三个参数
  • esi存储第四个参数
  • edi存储第五个参数

接下来,我们回答第二个问题,参数传递的顺序

  • 调用者按照逆序存储参数
  • 被调用者顺序取出参数

我们以一段代码说明

1
2
int subtract(int a,int b);  //被调用者
int sub=subtract(3,2);      //调用者

以下是调用者的汇编代码,这里为了说明问题用的是栈空间传递参数

1
2
3
4
5
;调用者
push 2          ;压入参数b
push 3          ;压入参数a
call subtract   ;调用subtract函数
add esp,8       ;回收栈空间

以下是被调用者的汇编代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
;被调用者,也就是subtract
subtract:
    push ebp
    mov ebp,esp

    mov eax,[ebp+8]     ;取出第一个参数a
    add eax,[ebp+0xc]   ;取出第二个参数b

    pop ebp
    ret

需要知道,call指令在调用函数时做了两件事

  • 将call指令的下一条指令的地址压入栈中,作为函数的返回地址
  • 修改eip寄存器(指令寄存器,用于取出指令送给cpu执行)的值为subtract符号的地址

相对的,ret指令也做了两件事

  • 弹出调用者压在栈中的返回地址
  • 修改eip寄存器的值为弹出的返回地址

因此,其调用过程如下所示

image-20241215222714975

由此可以看到,计算机正是通过callret这两条指令完成函数的调用和恢复的,但需要知道的是,从本质上说,这两天指令仅仅只是修改eip寄存器用的,并不能保证恢复环境的时候正确,因此有了以下框架

1
2
3
4
5
6
7
    push ebp
    mov ebp,esp

	;函数体

    pop ebp
    ret

这个框架是每个函数都要做的,事实上,在高级语言中

  • 函数的左括号对应的就是前两条指令
  • 函数的右括号对应的是最后两个指令

c语言与汇编语言的混合调用

如果想了解更多有关函数调用的细节可参考《深入理解计算机系统》第三章的内容,这里也推荐一个网站,供大家学习使用

Compiler Explorer

有了上述知识,我们知道高级语言的底层其实就是汇编语言,因此本质上两者是想通的,接下来我们实战以作说明

1
2
3
4
5
#include<unistd.h>
int main(){
    write(1,"hello world\n",13);
    return 0;
}

以上是一段简单的使用c语言调用系统函数write,接下来我们看如果使用汇编语言怎么写

syscall_write.S

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
section .data
    sys_call db 'Hello from syscall write!', 0xa  ; 0xa 为换行符
    sys_call_len equ $ - sys_call

    str_c db 'Hello from simu write!',0xa
    str_c_len equ $ - str_c

section .text
    global _start

_start:
;------- 模拟c语言的系统调用write --------
    push str_c_len
    push str_c
    push 1

    call simu_write
    add esp,12
    
;------ 调用 sys_write ---------
    mov eax, 4              ; sys_write 系统调用号
    mov ebx, 1              ; 文件描述符 1(标准输出)
    mov ecx, sys_call       ; 消息的地址
    mov edx, sys_call_len   ; 消息的长度
    int 0x80                ; 执行系统调用

    ; 程序结束
    mov eax, 1              ; sys_exit 系统调用号
    xor ebx, ebx            ; 退出码 0
    int 0x80                ; 执行系统调用

simu_write:
    push ebp
    mov ebp,esp
    
    mov eax,4
    mov ebx,[ebp+8]
    mov ecx,[ebp+12]
    mov edx,[ebp+16]
    int 0x80

    pop ebp
    ret

注意,所有的系统调用最底层的入口只有一个,那就是0x80号中断,针对不同的功能有不同的功能号,如4号功能代表write调用,1号功能代表退出,这些功能号像是参数,由操作系统通过eax传递

编译链接上述代码,即可验证结果

1
2
nasm -f elf32 syscall_write.S -o syscall_write.o
ld -m elf_i386 syscall_write.o -o syscall_write.bin

接下来我们看c语言和汇编语言是如果相互调用的

C_with_S_c.c

1
2
3
4
5
6
extern void asm_print(char*,int);
void c_print(char* str){
    int len=0;
    while(str[len++]);
    asm_print(str,len);
}

这段代码想必大家都能看懂,c_print函数通过将传入的参数str传递给asm_print函数进行打印,只不过接下来我们将通过汇编实现asm_print函数,同时我们在汇编代码中传递字符串参数给c_print

所以本质上二者的调用关系应该是

  • 汇编代码首先传递字符串参数给c_print
  • 然后c_print将参数再回传给汇编代码中的asm_print函数
  • asm_print函数最终负责将字符串打印出来

image-20241215225005546

C_with_S_S.S

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
section .data
str: db "asm print says:hello wolrd!",0xa,0
str_len equ $ - str

section .text
extern c_print
global _start
_start:
    push str
    call c_print
    add esp,4

    mov eax,1
    int 0x80

global asm_print
asm_print:
    push ebp
    mov ebp,esp

    mov eax,4
    mov ebx,1
    mov ecx,[ebp+8]
    mov edx,[ebp+12]
    int 0x80

    pop ebp
    ret

编译

1
2
nasm -f elf32 ./C_with_S_S.S -o C_with_S_S.o
gcc-4.4 -m32 -c ./C_with_S_c.c -o C_with_S_c.o

链接

1
ld -m elf_i386 ./C_with_S_c.o C_with_S_S.o -o main

运行

1
2
minios@robot:~/osCode/miniOS_32/ch6/task1/test/asm_with_c$ ./main
asm print says:hello wolrd!

内核初步完善

代码目录结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.
├── bin							//所有二进制文件目录
│   ├── kernel.bin
│   ├── loader
│   ├── main.o
│   ├── mbr
│   └── print.o
├── boot						//计算机启动目录
│   ├── include					//定义mbr和loader进行初始化工作需要的宏
│   │   └── boot.inc
│   ├── loader.S
│   └── mbr.S
├── kernel						//内核源文件
│   └── main.c
├── lib							//库文件
│   ├── kernel					//内核库文件
│   │   ├── print.h
│   │   └── print.S
│   └── stdint.h
├── Makefile
├── start.sh					//编译链接脚本
└── test						//章节知识学习测试代码
    ├── asm_print
    │   ├── start.sh
    │   └── syscall_write.S
    ├── asm_with_c
    │   ├── C_with_S_c.c
    │   ├── C_with_S_S.S
    │   └── start.sh
    └── main.c

实现单字符打印

接下来我们将实现内核的第一个功能——单个字符打印,本节字符打印的源代码依旧采用汇编形式

数据类型定义

/lib/std_int.h

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#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.h

1
2
3
4
5
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"               //stdint.h中定义了数据类型
void put_char(uint8_t char_asci); // 在stdint.h中uint8_t的定义是unsigned char
#endif

字符打印源文件实现

/lib/kernel/print.S

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0;定义显存段选择子

[bits 32]
section .text
global put_char                     ;通过global关键字将put_char函数定义为全局符号
                                    ;使其对外部文件可见
;================= put_char函数实现 =================
put_char:
    pushad                          ;push all double,压入所有双字长的寄存器
                                    ;入栈顺序为eax->ecx->edx->ebx->esp->ebp->esi->edi
 
    mov ax,SELECTOR_VIDEO
    mov gs,ax                       ;为gs寄存器赋予显存段的选择子

;---------------- 获取光标的坐标位置 ----------------
    ; 以下代码用于获取光标的坐标位置(一维索引,如第一行的坐标范围为0~79,第二行为80~159)
    ; 其中光标的坐标位置存放在光标坐标寄存器中
    ; 其中索引为0eh的寄存器和索引为0fh的寄存器分别存放光标高8位和低8位
    ; 访问CRT controller寄存器组的寄存器,需要先往端口地址为0x03d4的寄存器写入索引
    ; 从端口地址为0x03d5的数据寄存器中读写数据
    mov dx,0x03d4                   ;将0x03d4的端口写入dx寄存器中
    mov al,0x0e                     ;将需要的索引值写入al寄存器中
    out dx,al                       ;向0x03d4端口写入0x0e索引
    mov dx,0x03d5                   
    in al,dx                        ;从0x03d5端口处获取光标高8位
    mov ah,al                       ;ax寄存器用于存放光标坐标,
                                    ;因此将光标坐标的高8位数据存放到ah中
;同上,以下代码获取光标坐标的低8位
    mov dx,0x03d4
    mov al,0x0f
    out dx,al
    mov dx,0x03d5
    in al,dx                        ;此时ax中就存放着读取到的光标坐标值

    mov bx,ax                       ;bx寄存器不仅是光标坐标值,同时也是下一个可打印字符的位置
                                    ;而我们习惯于bx作为基址寄存器,以后的处理都要基于bx寄存器
                                    ;因此才将获取到的光标坐标值赋值为bx
                                    
;---------------- 参数(待打印的字符)传递 ----------------
    mov ecx,[esp+36]                ;前边已经压入了8个双字(一个字2个字节)的寄存器,
                                    ;加上put_char函数的4字节返回地址
                                    ;所以待打印的字符在栈顶偏移36字节的位置

    cmp cl,0xd                      ;回车符处理
    jz .is_carriage_return
 
    cmp cl,0xa                      ;换行符处理
    jz .is_line_feed
 
    cmp cl,0x8                      ;退格键处理
    jz .is_backspace                
    jmp .put_other                  ;正常字符处理
 
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;---------------- 退格键处理 ----------------
    ;处理思路:
        ;1.将光标位置减一
        ;2.将待删除的字符使用空格字符(ASCII:0x20)代替
.is_backspace:
    dec bx                          ;bx中存储光标的坐标位置,将光标坐标位置减去一,即模拟退格
    shl bx,1                        ;由于文本模式下一个字符占用两个字节(第一个字节表示字符的ASCII码,第二个字节表示字符的属性),
                                    ;故光标位置乘以2(shl左移指令)就是光标处字符的第一个字节的偏移量
    mov byte[gs:bx],0x20            ;将空格键存入待删除字符处
    inc bx                          ;此时bx中存储的是字待删除字符的第一个字节位置,
                                    ;使用inc指令将bx加1后就是该字符的第二个字节的位置
    mov byte[gs:bx],0x07            ;将黑底白字(0x07)属性加入到该字符处
    shr bx,1                        ;bx除以2,恢复光标坐标位置
    jmp .set_cursor                 ;去设置光标位置, 这样光标位置才能真正在视觉上更新
 
;将cx指向的字符放入到光标处
.put_other:
    shl bx,1                        ;将光标坐标转换为内存偏移量
    mov byte[gs:bx],cl              ;将cx指向的字符放入到光标处
    inc bx                          ;bx指向当前字符的下一个字节处,存放当前字符属性
    mov byte[gs:bx],0x07            ;存放字符属性
    shr bx,1                        ;将内存偏移量恢复为光标坐标值
    inc bx                          ;bx指向下一个待写入字符位置
    cmp bx,2000                     ;80*25=2000,判断是否字符已经写满屏了
    jl .set_cursor                  ;更新光标坐标值
 
;---------------- 换行处理 ----------------
    ;思路:首先将光标移动到本行行首,之后再将光标移动到下一行行首
.is_line_feed:
.is_carriage_return:
    xor dx,dx
    ;将光标移动到本行行首
    mov ax,bx                       ;将光标值给ax,以作除法之用
    mov si,80                       
    div si                          ;除法操作,ax/si,结果ax存储商,dx存储余数
                                    ;每行80个字符,光标值除以80得出的余数便是当前光标所在行的字符个数
    sub bx,dx                       ;光标值减去光标当前行的字符个数,就将光标移动到本行行首的位置
 
    .is_carriage_return_end:
        add bx,80                   ;将光标移动到下一行行首
        cmp bx,2000                 ;屏幕的每屏的可显示80*25=2000个字符(25行,每行80个字符)
                                    ;这行代码和接下来jl .set_cursor这段代码的作用是
                                    ;判断当前光标值是否小于2000
                                    ;如果小于2000说明当前光标仍旧在本屏内,则代码直接跳转到.set_cursor更新光标值即可
                                    ;否则说明需要滚动屏幕了,继续向下执行滚屏部分的代码.roll_screen
    .is_line_feed_end:
        jl .set_cursor

;---------------- 滚屏处理 ----------------
    ;思路:屏幕行范围是0~24,滚屏的原理是将屏幕的1~24行搬运到0~23行,再将第24行用空格填充

    ; mosd指令会按照指定的次数复制每次 4 字节
    ; 源地址:由 SI(源数据位置寄存器)指向。
    ; 目标地址:由 DI(目标位置寄存器)指向。
    ; 计数器:由 CX(在 16 位模式下)或 ECX(在 32 位模式下)寄存器控制,表示要复制的数据块的元素个数(每个元素是 4 字节)。
    ; 每执行一次 movsd,SI 和 DI 会分别递增 4(因为是复制 32 位数据),ECX 会减 1,直到 ECX 变为 0。
.roll_screen:
    cld                             ;清除eflags寄存器的方向标志位DF,使得内存移动地址从低地址向高地址移动
                                    ;若方向标志位被设置,则字符串的内存移动地址从高地址向低地址移动
    mov ecx,960                     ;共移动2000-80=1920个字符,每个字符占2个字节,故共需移动1920*2=3840个字节
                                    ;movsd指令每次移动4个字节,故共需执行该指令3840/4=960次数
    mov esi,0xb80a0                 ;第1行行首地址,要复制的起始地址
    mov edi,0xb8000                 ;第0行行首地址,要复制的目的地址
    rep movsd                       ;rep(repeat)指令,重复执行movsd指令,执行的次数在ecx寄存器中
 
    ;将最后一行填充为空白
    mov ebx,3840                    ;更新光标位置为最后一行首字符第一个字节的位置
    mov ecx,80
    .cls:
        mov word[gs:ebx],0x0720     ;0x0720是黑底白字的空格键
        add ebx,2
        loop .cls
        mov bx,1920                 ;将光标值重置为1920,最后一行的首字符.
;---------------- 更新光标值 ----------------
.set_cursor:
    					                                    ;将光标设为bx值
    ;1 先设置高8位
    mov dx, 0x03d4			                                ;索引寄存器
    mov al, 0x0e				                            ;用于提供光标位置的高8位
    out dx, al
    mov dx, 0x03d5			                                ;通过读写数据端口0x3d5来获得或设置光标位置 
    mov al, bh
    out dx, al
 
    ;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

内核主函数

/kernel/main.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#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;
}

编译链接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
mkdir -p bin
#编译mbr
nasm -o $(pwd)/bin/mbr -I $(pwd)/boot/include/ $(pwd)/boot/mbr.S
dd if=$(pwd)/bin/mbr of=~/bochs/hd60M.img bs=512 count=1 conv=notrunc

#编译loader
nasm -o $(pwd)/bin/loader -I $(pwd)/boot/include/ $(pwd)/boot/loader.S
dd if=$(pwd)/bin/loader of=~/bochs/hd60M.img bs=512 count=4 seek=2 conv=notrunc
 
#编译print函数
nasm -f elf32 -o $(pwd)/bin/print.o $(pwd)/lib/kernel/print.S

#编译main函数
gcc-4.4 -o $(pwd)/bin/main.o -c -m32 -I $(pwd)/lib/kernel/ $(pwd)/kernel/main.c
#将main函数与print函数进行链接
ld -m elf_i386 -Ttext 0xc0001500 -e main -o $(pwd)/bin/kernel.bin $(pwd)/bin/main.o $(pwd)/bin/print.o
 
#将内核文件写入磁盘,loader程序会将其加载到内存运行
dd if=$(pwd)/bin/kernel.bin of=~/bochs/hd60M.img bs=512 count=200 conv=notrunc seek=9
 
#rm -rf bin/*

运行

仍旧进入bochs安装目录,运行

1
 ./bin/bochs -f boot.disk

结果如下所示

image-20241216002752584

实现字符串打印

字符串打印函数的实现就比较简单了,直接遍历字符串的每一个字符,然后调用上述我们的字符打印函数即可

在/lib/kernel/print.S文件中添加如下代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

;================= 字符串打印函数 ================= 
[bits 32]
section .text
global put_str

put_str:
    push ebx
    push ecx
    xor ecx,ecx
    mov ebx,[esp+12]                ;ebx存放字符串首地址
.goon:
    mov cl,[ebx]                    ;取出字符串的第一个字符,存放到cx寄存器中
    cmp cl,0                        ;判断是否到了字符串结尾,字符串结尾标志符'\0'即0
    jz .str_over
    push ecx                        ;压入put_char参数,调用put_char函数
    call put_char
    add esp,4                       ;回收栈空间
    inc ebx                         ;指向字符串的下一个字符
    jmp .goon
.str_over:
    pop ecx                         ;回收栈空间
    pop ebx                         ;回收栈空间
    ret

/kernel/main.c文件中测试如下

1
2
3
4
5
6
7
8
#include "print.h"
int main(void)
{
    put_str("I am kernel\n");
    while (1)
        ;
    return 0;
}

编译运行之后结果如下

image-20241216003805960

网站已运行
发表了17篇文章 · 总计 118,129字