前言:
自我们上次成功编写内核代码并嵌入操作系统后,我们遍开始了正式的内核编程之旅,老实说其实现在才是真正操作系统编写的开始,今天我们要灵活运用C语言以及汇编,是他们融合起来编写我们的操作系统。
本日参考资料:
《操作系统真象还原》
一,函数调用约定
平常大家写高级语言的时候,有没有想过这么一个问题,我们定义一个函数,系统是怎么知道我们函数中参数的地址以及函数结束后我们要返回的地址呢?
int function(int a,int b);
首先我们知道参数的地址以及返回的地址肯定是要存储在某一个地方中的,这个地方就是我们内存中的栈。存储的问题解决了,那还有参数压入顺序的问题,到底是从左往右压入还是从右往左压入呢,貌似都可以,但是我写代码从左往右压入,你写代码从右往左压入,同时函数调用结束需要清空栈中的参数,到底是调用的人清理,还是被调用的清理呢,这乱七八糟的可不行,于是便有了函数调用约定
这一大堆不要求大家全部知道,我们这次使用的是cdecl,在此之前我们先看一下stdcall
stdcall
- 调用者所有参数从右向左入栈
- 被调用者清理参数所占栈空间
int subtract(int a, int b); //被调用者
int sub= subtract (3,2); //主调用者
主调用者
;从右到左将参数入校
1 push 2 ;压入参数 b
2 push 3 ;压入参数 a
3 call subtract ;调用函数 subtract
被调用者
1 push ebp ;压入 ebp 备份
2 mov ebp,esp ;esp 赋值给 ebp
;用 ebp 作为基址来访问校中参数
3 mov eax, [ebp+Ox8] ;偏移 字节处为第 1个参数
4 add eax, [ebp+Oxc] ;偏移 Oxc 字节处是第 2个参数;参数 ab 相加后存入 eax
5 mov esp,ebp ;为防止中间有入栈操作,属于通用代码
6 pop ebp ;ebp恢复
7 ret 8 ;数字 表示返回后使 esp+8
;函数返回时由被调函数清理栈中参数
#ps 这里的ret 8是将栈指针上移动8位,相当于回到函数被调用前栈指针的位置
cdecl
- 调用者将所有参数从右向左压入栈
- 调用者清理参数所占的栈空间
主调用者
;从右到左将参数入校
1 push 2 ;压入参数 b
2 push 3 ;压入参数 a
3 call subtract ;调用函数 subtract
4 add esp ,8 ;回收栈空间
被调用者
1 push ebp ;压入 ebp 备份
2 mov ebp,esp ;esp 赋值给 ebp
;用 ebp 作为基址来访问校中参数
3 mov eax, [ebp+Ox8] ;偏移 字节处为第 1个参数
4 add eax, [ebp+Oxc] ;偏移 Oxc 字节处是第 2个参数;参数 ab 相加后存入 eax
5 mov esp,ebp ;为防止中间有入栈操作,属于通用代码
6 pop ebp ;ebp恢复
7 ret
可以看出cdecl和stdcall之间的区别就是执行栈空间清理的操作对象不同,有的人可能就会问了,我平常写代码的时候压根就没有碰到入栈出栈的操作啊,你不会是骗我们的吧,其实这些操作都是由编译器帮你做完了,所以我们本身是看不到,但是现在我们要让C和汇编结合起来,就需要自己来遵守约定编写了。
二,系统调用
系统调用是基于系统中断来进行的,但是中断是靠中断描述表来实现的(之后会说),所以我们提供了一个0x80号中断,去进行系统调用,子功能在寄存器eax中单独指定。
- 将系统调用指令封装成C库函数,通过库函数进行系统调用
- 不依赖库函数,直接使用汇编指令int与系统通信。
三,实现自己的打印函数
之前我们有说过,操作显卡是通过访问显卡上面内存来实现的,但这是访问硬件还有一个方式,不知道大家还记得吗,没错就是IO端口,端口即寄存器,我们先来看看有哪些寄存器。
这个表看上去有很多,但是不用慌张,实际上这只是目录还有更多,但是我们现在只需知道他们被分成了两类寄存器。
- Address Register: VGA中寄存器的地址
- Data Register:对应寄存器的窗口,所写数据全部对应在该寄存器上
寄存器地址:
首先大家要先明白一点,寄存器地址!=内存地址,计算机系统为寄存器统一编址,一个寄存器赋予一个地址,寄存器的地址范围是0~65535
上面对address register还有data register的描述,大家可能还不是太明白,其实很好理解,你想想你显卡中有这么多寄存器端口,不可能每个端口都分配一个地址吧,那其他硬件该怎么办呢,所以我们把众多寄存器分类形成一个数组,address register就相当于下标,data register就相当于对应的寄存器,这样只需使用几个端口就可以表达所有寄存器端口
CRT controller
本次我们开发只使用CRT中的寄存器,但是要明确一点的就是,CRT controllerd的地址是随Miscellaneous Register而变化的
其他的大家对照着英文翻译一下吧,然后书上对某些寄存器的解释我截个图,这里不想花太多笔墨去讲这个毕竟不是显卡编程,大家了解了解即可。
CRT controller寄存器组
好的废话不多说,我们先写一个自己的put_char方法,我在项目下创建了一个lib文件夹用来专门存储库文件的,还创建了一个lib/kernel文件夹用来存储内核库函数。
lib/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
① 首先我们先获取光标
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0
[bits 32]
section .text
pushad
mov ax,SELECTOR_VIDEO
mov gs,ax
;获取当前光标位置
mov dx,0x03d4 ;索引寄存器位置
mov al,0x0e ;提供光标位置的高8位
out dx,al
mov dx,0x03d5 ;读取数据端口
in al,dx
mov ah,al
mov dx,0x03d4 ;索引寄存器位置
mov al,0x0f ;提供光标位置的低8位
out dx,al
mov dx,0x03d5
in al,dx
mov bx,ax ;将光标位置存入bx
;判断字符
mov ecx,[esp+36] ;pushad 压入了 4*8 32字节 + 返回地址 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
② 根据特殊字符完成相应操作
首先我们使用的是80*25 文本模式,也就是一页可以存访2000个字符,占4000B(属性+字符),32KB显存可以显示8页
滚屏:
即当一个屏幕中字符的数量达到一页的上限,则需要滚屏。
产生滚屏的情况有以下两种:
- 一页的字符写满了,刚好达到2000(80*25模式下)
- 在最后一行使用回车或者换行符
实现滚屏有两种方法:
- 使用两个寄存器,分别表示地址高8位和低8位,屏幕从该地址开始,向后显示2000字符
- 寄存器始终为0,代表着起始地址不变,只显示2000字符,把1~24行内容搬到0~23行
很显然我们可以分辨两种方法的优缺点:
第一种方法可以缓存16KB的字符,但是如果超出还是要wrap around的
第二种方法不会产生wrap around,相对简单,但是只能显示2000字符
因为我这个人怕麻烦所以采用第二种方法
; 字符 字符 字符
; 光标
;------------回退后------------
; 字符 字符 空格
; 光标
;切记 虽然屏幕上只有三个字符,但是实际上在显卡内容中是有6个字节(字符+属性)的,所以操作显存的时候要注意 bx*2
.is_backspace:
dec bx
shl bx,1 ;光标左移1位
mov byte [gs:bx],0x20 ;置为空格
inc bx
mov byte [gs:bx],0x07
shr bx,1
jmp .set_cursor
.put_other
shl bx,1
mov [gs:bx],cl
inc bx
mov byte [gs:bx],0x07
shr bx,1
inc bx
cmp bx,2000
jl .set_cursor ;判断是否超出屏幕字符大小(2000),超出则采用换行处理。
;我们采用的是 80*25 的文本模式,所以换行即把 光标坐标移到下一行的开头,如果 bx为 第二行第三列,即83,那就应该移到第三行 83-3+80 = 160
.is_line_feed:
.is_carriage_return:
xor dx,dx ;dx是被除数的高16位
mov ax,bx ;ax是被除数的低16位
mov si,80
div si
sub bx,dx
.is_carriage_return_end:
add bx,80
cmp bx,2000
.is_line_feed_end:
jl .set_cursor
③ 实现滚屏
.roll_screen:
cld
mov ecx,960
mov esi,0xc00b80a0 ;第1行行首
mov edi,0xc00b800 ;第0行行首
rep movsd
;;; 将最后一行填充为空白
mov ebx,3840 ;第一个字符的偏移 1920*2
mov ecx,80
.cls:
mov word [gs:ebx]
add ebx,2
loop .cls
mov bx,1920
.set_cursor:
;高八位
mov dx,0x03d4
mov al,0x0e
out dx,al
mov dx,0x03d5
mov al,bh ;把bx光标位置给al
out dx,al
mov dx,0x03d4
mov al,0x0f
out dx,al
mov dx,0x03d5
mov al,bl
out dx,al
.put_char_done:
popad
ret
④ 等级设置
section .text
pushad
mov ax,SELECTOR_VIDEO
mov gs,ax
在开头我们有一段这个代码,相信很多人都只是认为这个是指向当前显存段的,实则不然,这里可是有大学问
首先我们要明白一点,在CPU中是分为0,1,2,3几个特权级的,用户进程是特权3,内核进程是特权0,而这个特权是由当前的选择子来决定的,CPU每次会检查选择子与当前DX,ES,FS等“数据”段寄存器的等级是否平级,如果发现不平级,那么将会把当前的选择子置为0,选择子置为0代表将来会在GDT中索引到第0位,而GDT第0位恰好是为空的,CPU就会因此抛出异常不继续执行
那问题来了,我们的用户进程一开始特权是3,但是显卡的特权是0,这该咋办呢?
① 直接再创建一个段描述符,特权为3的显存地址专门给用户操作
② 直接再调用时,将用户进程的特权级改为0,然后进行访问
第一个方法不仅占用了空间,还使得用户随意更改内存十分危险,所以我们采用第二个方法。
⑤ 打印字符串
很简单其实大家可以自己尝试着实验一下,当cl为0时就代表没有字符了
global put_str
put_str:
push ebx
push ecx
xor ecx,ecx
mov ebx,[esp+12]
.goon:
mov cl,[ebx]
cmp cl,0
jz .str_over
push ecx
call put_char
add esp,4 ;回收空间
inc ebx
jmp .goon
.str_over:
pop ecx
pop ebx
ret
⑥ 打印32整数
这里打印的是 16进制的,也就是put_int(0x00001234)这样子的 输出16进制的数字。
;-------------------- 将小端字节序的数字变成对应的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位数字中,16进制数字的位数是8个
mov ebx, put_int_buffer
;将32位数字按照16进制的形式从低位到高位逐个处理,共处理8个16进制数字
.16based_4bits: ; 每4位二进制是16进制数字的1位,遍历每一位16进制数字
and edx, 0x0000000F ; 解析16进制数字的每一位。and与操作后,edx只有低4位有效
cmp edx, 9 ; 数字0~9和a~f需要分别处理成对应的字符
jg .is_A2F
add edx, '0' ; ascii码是8位大小。add求和操作后,edx低8位有效。
jmp .store
.is_A2F:
sub edx, 10 ; A~F 减去10 所得到的差,再加上字符A的ascii码,便是A~F对应的ascii码
add edx, 'A'
;将每一位数字转换成对应的字符后,按照类似“大端”的顺序存储到缓冲区put_int_buffer
;高位字符放在低地址,低位字符要放在高地址,这样和大端字节序类似,只不过咱们这里是字符序.
.store:
; 此时dl中应该是数字对应的字符的ascii码
mov [ebx+edi], dl
dec edi
shr eax, 4
mov edx, eax
loop .16based_4bits
;现在put_int_buffer中已全是字符,打印之前,
;把高位连续的字符去掉,比如把字符000123变成123
.ready_to_print:
inc edi ; 此时edi退减为-1(0xffffffff),加1使其为0
.skip_prefix_0:
cmp edi,8 ; 若已经比较第9个字符了,表示待打印的字符串为全0
je .full0
;找出连续的0字符, edi做为非0的最高位字符的偏移
.go_on_skip:
mov cl, [put_int_buffer+edi]
inc edi
cmp cl, '0'
je .skip_prefix_0 ; 继续判断下一位字符是否为字符0(不是数字0)
dec edi ;edi在上面的inc操作中指向了下一个字符,若当前字符不为'0',要恢复edi指向当前字符
jmp .put_each_num
.full0:
mov cl,'0' ; 输入的数字为全0时,则只打印0
.put_each_num:
push ecx ; 此时cl中为可打印的字符
call put_char
add esp, 4
inc edi ; 使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_Hc
#include "stdint.h"
void put_char(uint8_t char_asci);
void put_str(char* message);
void put_int(uint32_t num);
#endif
main.c
#include "print.h"
void main(void) {
put_str("Hello GeniusOS\n");
put_int(2022);
while (1) {
}
return 0;
}
⑧ 运行
nasm -I ./include/ -o mbr.bin mbr.S
dd if=mbr.bin of=../geniusos.img bs=512 count=1 conv=notrunc
echo "disk write success!!"
nasm -I ./include/ -o loader.bin loader.S
dd if=loader.bin of=../geniusos.img bs=512 count=3 seek=2 conv=notrunc
nasm -f elf -o './lib/kernel/print.o' './lib/kernel/print.S'
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=./kernel/kernel.bin of=../geniusos.img bs=512 count=200 seek=6 conv=notrunc
如果出现运行错误,比如无法编译之类的
/usr/include/stdint.h:26:10: fatal error: bits/libc-header-start.h: No such file or directory
请安装gcc 32位环境进行编译。
sudo apt-get install gcc-multilib
紧接着运行结果图如下:
🎉 !!!!congratulations!!!!🎉
今天的任务就到这里吧,我们明天继续。