操作系统——打印函数(十二)
操作系统——打印函数(十二)
2020-09-25 20:32:00 hawk
概述
这章主要完善一下内核的相关功能,实现一个简单的打印功能
函数调用
实际上,函数调用约定,即调用函数时的一套约定,是被调用函数的接口,体现在
1. 参数的传递方式
2. 参数的传递顺序
3. 保存寄存器环境的方式
为了确保调用者和被调用者可以正常的进行调用,双方需要提前就以上问题达成一致。我们采用的是cdecl(C declaration)调用方式,即参数在栈中进行传递;函数参数从右到左进行传递;EAX、ECX和EDX需要调用者自己进行保存,其余的寄存器由被调用者进行保存,函数的返回值存储在EAX寄存器中,并且由调用者清理栈空间。
打印函数
显卡控制
前面可以看到,我们是直接向显存中写入数据,基本没有与显卡的IO接口——端口进行通信。这里按照惯例,我们列出VGA上主要的端口,如下所示
寄存器分组 | 寄存器子类 | 读端口 | 写端口 | 说明 |
CRT Controller Registers |
Address Register |
3x4h | 这里x的值取决下面Externel Registers组Miscellaneous Output Reigster的Input/Output Address Select字段 | |
Data Register | 3x5h | |||
External(General) Registers | Miscellaneous Output Register | 3cch | 3c2h | |
Feature Control Register | 3cah | 3xah | 写端口为3BAh(mono模式)或3DAh(color模式) | |
Input Status #0 Register | 3c2h | 不可用 | ||
Input Status #1 Register | 3xah | 不可用 | 读端口为3BAh(mono模式)或3DAh(color模式) |
首先,整个端口实际上被分为了两大类——Address Register和Data Register,这个原因是硬件上的。因为整个计算机端口独立编址范围就0-65535,如果直接每一个端口都进行分配,那么肯定地址是不够用的。因此,计算机工程师通过Address Register和Data Register进行拓展,有点类似于计算机网络中的NAT:虽然进行独立编址的只有有限个端口,但是如果我在有限个独立地址的段端口中拿出两个,其中一个用来指定提前给定的寄存器数组的下标,另一个寄存器用来获取该指定下标的寄存器的值,从而可以访问到更多的端口。所以对于这类端口来说,首先在Address Register中指定相关的索引,然后再在Data Register对所索引的寄存器进行读写即可。
上面我们提到了,实际上CRT Controller Registers寄存器的地址根据下面的Externel Registers组Miscellaneous Output Reigster的Input/Output Address Select字段影响的。这里我们不需要了解那么多,一般情况下(也就是我们后面的实验情况),CRT Controller Registers寄存器的Address Register的端口地址为0x3d4,Data Register的端口地址为0x3d5。当然,正如前面介绍的,显卡的IO接口对应的端口自然不仅仅这么几个,其指向了寄存器数组,重要的寄存器下标和功能如下所示
索引 | 寄存器 |
0eh | Cursor Location High Register |
0fh | Cursor Location Low Register |
实验
下面我们将在实验中简单描述一下这个打印函数的实现。仓库链接点击进入
数据类型
类似于Linux,为了开发方便,我们需要提前定义一些数据类型,方便直接进行使用,其主要内容和linux下的/usr/include/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
显卡通信
实际上,我们一直习惯了光标是跟随着字符串的位置,也就是字符始终是在当前光标的位置处进行显示,并且光标的位置会一直更新。那么我们实现的打印函数,自然也需要实现这个功能。首先,我们需要获取当前的光标位置,通过与显卡端口进行通信完成。前面介绍的CRT Controller Registers寄存器组中下标为0xe和0xf,分别表示光标地址的低8位和高8位,从而最终获取了相关的光标地址值,其代表着下一个待打印的字符位置。当然,我们还需要实现一下不可打印字符的光标位置,比如’\n'字符等。这里不进行赘述了。这里需要额外提醒一下,光标位置指的是字符的索引,但是实际内存是需要 * 2的,因为一个字符2字节大小
滚屏
这里说明一下,如果我们新的光标超出了屏幕右下角最后一个字符的位置;或者最后一行中任意位置有回车或换行符,则我们都需要让屏幕之外的内容显示在屏幕上,这也就是滚屏。我们实现换行的思路也很简单,VGA模式下屏幕显示80 * 25,也就是25行,每行80个字符。则我们只需要将第1~24行的数据搬移到第0~23行;然后将最后一行的字符使用空格覆盖;并且将光标移动到第24行行首即可。
打印字符
下面就是简单的打印字符函数的源代码,如下所示
; 这里实现打印函数 ;----------------------------------------------------------------------------------------------------------------------------- ; 这是前面实现的include/boot.inc中的定义,这里直接复制后使用 GDT_SECT equ 0000000000000_0_00b ;选择子格式 GDT_SECT_TI_GDT equ GDT_SECT GDT_SECT_RPL_0 equ GDT_SECT GDT_SECT_VIDEO equ ((0x0003 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0) ;描述符索引值为0x3;在GDT中索引;请求权限为0特权级 [bits 32] SECTION .text ;--------------------------------------实现put_char函数-------------------------------------------------------------------------- ; 将当前栈中的字符打印到当前的光标处,字符默认为黑底白字 ; 可打印字符直接进行打印 ; 对于'\n',其功能是回车换行 ; 对于'\b‘,其功能是删除前一个字符,并且将光标移动到前一个字符处 ;-------------------------------------------------------------------------------------------------------------------------------- global put_char put_char: pushad ;这里是将所有寄存器双字寄存器入栈 ;先后入栈顺序为eax、ecx、edx、ebx、原始esp、ebp、esi和edi mov ax, GDT_SECT_VIDEO mov gs, ax ;将显存段选择子赋值给gs,之后通过gs进行段基址:段偏移进行访问 ; 首先获取当前的光标位置,将其值保存到ax中 mov dx, 0x3d4 mov al, 0xe out dx, al ;向CRT Controller Registers的Address Register写入0xe索引,即读写Cursor Location High Register寄存器 mov dx, 0x3d5 in al, dx ;从CRT Controller Registers的Data Register读入数据,也就是读取Cursor Location High Register寄存器的值到al中 mov ah, al mov dx, 0x3d4 mov al, 0xf out dx, al ;向CRT Controller Registers的Address Register写入0xf索引,即读写Cursor Location High Register寄存器 mov dx, 0x3d5 in al, dx ;从CRT Controller Registers的Data Register读入数据,也就是读取Cursor Location Low Register寄存器的值到al中,则此时ax中保存着光标的完整位置坐标 mov bx, ax ;将光标位置保存到bx寄存器中 mov ecx, [esp + 36] ;读取栈上传入的字符 ;压入了8个寄存器,还压入了返回地址,则传入字符偏移为4 * (8 + 1) = 36 ;-------------------------这里需要判断一下字符类型,对于特殊字符,进行特殊处理 ; 回车符CR,即carriage_return,'\r' ascii:0xd 功能:其将光标移动到行首 ; 换行符LF,即line_feed,'\n' ascii:0xa 功能:将光标跳转到下一行 ; 这里类似于c语言,直接将回车符或者换行符都执行回车换行功能 ; 退格符b,即back_space,'\b' ascii:0x8 功能:将上一个字符置为空格,并且光标回退一个字符 cmp cl, 0xd jz .IS_CARRIAGE_RETURN cmp cl, 0xa jz .IS_LINE_FEED cmp cl, 0x8 jz .IS_BACK_SPACE jmp .IS_OTHER ;----------------------------处理输出退格符------------------------------------- .IS_BACK_SPACE: dec bx ;自减1,获取上一个光标的位置 shl bx, 1 ;将bx内容左移1,也就是bx * 2,获取显存中偏移 mov byte [gs:bx], 0x20 ;置为空格 inc bx mov byte [gs:bx], 0x07 ;即设置为黑底白字 shr bx, 1 ;重新回复光标的位置 jmp .SET_CURSOR ;将光标的位置重新写入显卡IO接口中 .IS_OTHER: shl bx, 1 ;将bx内容左移1,也就是bx * 2,获取显存中偏移 mov byte [gs:bx], cl ;置为传递的字符值 inc bx mov byte [gs:bx], 0x07 ;即设置为黑底白字 shr bx, 1 inc bx ;将光标移动到下一个位置 cmp bx, 2000 jl .SET_CURSOR ;如果当前字符已经超过了80 * 25,则进行滚屏,向上滚动1行 ; 这里主要获取当前字符所在的行数 .IS_CARRIAGE_RETURN: .IS_LINE_FEED: xor dx, dx ;dx是被除数的高16位 mov ax, bx ;ax是被除数的低16位 mov si, 80 div si ;32位被除数 / 16位除数,商保存在ax中,余数保存在dx中 sub bx, dx ;此时获取到当前行首 .IS_CARRIAGE_RETURN_END: add bx, 80 ;光标移动到下一行处 .IS_LINE_FEED_END: cmp bx, 2000 jl .SET_CURSOR ;如果当前字符已经超过了80 * 25,则进行滚屏,向上滚动1行 .ROLL_SCREEN: cld ;设置eflags寄存器,确保遍历地址向高地址增长 mov ecx, 960 ;后面一次复制4字节指令,从而需要复制(24 - 1 + 1) * 80 * 2 / 4 = 960次 mov esi, 0xc0000000 + 0xB8000 + 160 ;我们在分页机制中将低端1MB内存映射到了0xc00000000上,这里是内核空间中内存地址的第1行地址 mov edi, 0xc0000000 + 0xB8000 ;我们在分页机制中将低端1MB内存映射到了0xc00000000上,这里是内核空间中内存地址的第0行地址 rep movsd ;每次复制4字节,计数器为ecx ; 下面将最后一行清为空格 mov edx, 3840 ;最后一行在显存中的偏移,即24 * 80 * 2 = 3840 mov ax, 0x0720 ;黑底白字的空格 mov ecx, 80 .CLEAR_LAST_LINE: mov word [gs:edx], ax add edx, 2 loop .CLEAR_LAST_LINE mov bx, 1920 ;将光标重新设置为最后一行的行首,也就是2000 - 80 ; 这里重新移动光标,也就是将光标地址重新写入显卡的IO接口中 .SET_CURSOR: mov dx, 0x3d4 mov al, 0xe out dx, al ;向CRT Controller Registers的Address Register写入0xe索引,即读写Cursor Location High Register寄存器 mov dx, 0x3d5 mov al, bh out dx, al ;向CRT Controller Registers的Data Register写入数据,也就是将移动后的光标的高8位写入Cursor Location High Register寄存器中 mov dx, 0x3d4 mov al, 0xf out dx, al ;向CRT Controller Registers的Address Register写入0xf索引,即读写Cursor Location High Register寄存器 mov dx, 0x3d5 mov al, bl out dx, al ;向CRT Controller Registers的Data Register写入数据,也就是将移动后的光标的低8位写入Cursor Location Low Register寄存器中 .put_char_done: popad ret
实际上整体思路还是比较清晰的——如果是退格符,就将光标向前移动;如果是回车/换行,就将光标移动到下一行的行首;如果是其他字符,就简单的打印出来。如果此时光标位置已经超出了屏幕范围(即光标值>= 2000,索引从0开始),则将1-24行覆盖到0-23行,并且将光标重新移动到第24行行首即可。这里说明一下,好像书上代码有错误(其覆盖的时候显存段第0行和第1行在内核地址中的偏移不正确,从而导致覆盖不正确,这里注意一下)
打印字符串
既然我们已经实现了打印字符的函数,那么打印字符串函数就很简单了——对于字符串中每一个字符,调用打印字符进行打印即可,直到碰到'\x00'字符,终止打印即可。源代码如下所示
;--------------------------------------实现put_str函数-------------------------------------------------------------------------- ; 将当前栈指向的地址处的字符串输出到当前光标,并且当前光标跟随字符串,字符默认为黑底白字 ; 字符串以'\x00'结尾 ;-------------------------------------------------------------------------------------------------------------------------------- global put_str put_str: push ebx push ecx ;这里由于仅仅使用到了ebx和ecx寄存器,将其进行保存 xor ecx, ecx mov ebx, [esp + 12] ;获取栈上的字符串地址 ;压入了2个寄存器,还压入了返回地址,则传入字符偏移为4 * (2 + 1) = 12 .PUT_STR_IS_END: mov byte cl, [ebx] cmp cl, 0 ;判断是否到字符串结果 jz .PUT_STR_END ;如果当前字符为'\x00',则表示已经到字符串结尾 push ecx call put_char ;将当前字符入栈,并且调用put_char函数,将字符输出到终端上 add esp, 4 ;恢复栈平衡 inc ebx jmp .PUT_STR_IS_END .PUT_STR_END: pop ecx pop ebx ret
可以看到,就是简单的一个遍历和判断即可。
打印整形(hex)
将输入的整数以16进制的形式进行打印。也就是整数转换为字符串的代码,然后在调用put_str将该字符串进行输出即可。这里就不在赘述了,直接放出源代码,如下所示
;--------------------------------------实现put_uHex函数-------------------------------------------------------------------------- ; 将当前栈中的无符号整形打印到光标所在的位置上,光标跟随字符,字符默认为黑底白字 ; 整形就是当前默认的4字节 ;-------------------------------------------------------------------------------------------------------------------------------- global put_uHex put_uHex: pushad ;这里是将所有寄存器双字寄存器入栈 ;先后入栈顺序为eax、ecx、edx、ebx、原始esp、ebp、esi和edi mov eax, [esp + 36] ;读取栈上传入的整形 ;压入了8个寄存器,还压入了返回地址,则传入字符偏移为4 * (8 + 1) = 36 mov ebp, esp sub esp, 0x18 mov byte [ebp], 0 ;实际上,这里[esp, ebp - 1]就是对应的缓冲区地址。对于4字节的整形,其16进制最多8位,不妨直接设置缓冲区大小为0x18 - 2 .PUT_UHEX_IS_ZERO: cmp eax, 0 jz .PUT_UHEX_ZERO ;判断栈上整型是否为0 .PUT_UHEX_LOOP: cmp eax, 0 jz .PUT_UHEX_END ;判断当前处理的整形是否为0 mov bl, al shr eax, 4 and bl, 0xf ;获取当前低4位 dec ebp ;调整带输入的缓冲区地址 cmp bl, 0xa jl .PUT_UHEX_029 ;如果当前最后4位属于0-9,则单独进行处理 add bl, 55 ;即'A' - 10的值,确保a对应的字符就是'A' mov [ebp], bl ;将处理过的数据直接放入缓冲区中 jmp .PUT_UHEX_LOOP .PUT_UHEX_029: add bl, '0' mov [ebp], bl jmp .PUT_UHEX_LOOP .PUT_UHEX_ZERO: dec ebp mov byte [ebp], '0' .PUT_UHEX_END: push ebp call put_str add esp, 0x18 + 4 ;调整栈平衡 popad ret
这里我的思路稍稍和书上的不太一样。这里我将缓冲区放置在栈上——也就是首先[原始esp - 0x18, 原始esp]当做缓冲区,然后ebp始终指向当前字符串的头,这样实现起来我个人认为比较简单。
打印整形(Dec)
这里顺带着在实现以下以十进制输出对应的4字节整形数据,实际上大体思路是一样的,只不过带输出的字符是通过div命令中的余数获取。其源代码如下所示
;--------------------------------------实现put_uDec函数-------------------------------------------------------------------------- ; 将当前栈中的无符号整形以10进制打印到光标所在的位置上,光标跟随字符,字符默认为黑底白字 ; 整形就是当前默认的4字节 ;-------------------------------------------------------------------------------------------------------------------------------- global put_uDec put_uDec: pushad ;这里是将所有寄存器双字寄存器入栈 ;先后入栈顺序为eax、ecx、edx、ebx、原始esp、ebp、esi和edi mov eax, [esp + 36] ;读取栈上传入的整形 ;压入了8个寄存器,还压入了返回地址,则传入字符偏移为4 * (8 + 1) = 36 mov ebp, esp sub esp, 0x18 mov byte [ebp], 0 ;实际上,这里[esp, ebp - 1]就是对应的缓冲区地址。对于4字节的整形,其16进制最多8位,不妨直接设置缓冲区大小为0x18 - 2 .PUT_UDEC_IS_ZERO: cmp eax, 0 jz .PUT_UDEC_ZERO ;判断栈上整型是否为0 mov esi, 10 .PUT_UDEC_LOOP: cmp eax, 0 jz .PUT_UDEC_END ;判断当前处理的整形是否为0 mov edx, 0 ;确保eax保存当前值的低32位,edx保存当前值的高32位 div esi ;商在eax中,余数在edx中 add dl, '0' dec ebp mov [ebp], dl jmp .PUT_UDEC_LOOP .PUT_UDEC_ZERO: dec ebp mov byte [ebp], '0' .PUT_UDEC_END: push ebp call put_str add esp, 0x18 + 4 ;调整栈平衡 popad ret
好了,到这里我们就基本上完成了打印函数的试验了。下面我们要进行测试,这里就直接在内核程序中调用数据即可,内核源代码作如下修改
#include "print.h" int main(void) { put_str("Now is put_uHex\n"); put_uHex(0xf); put_char('\n'); put_str("Now is put_uDec\n"); put_uDec(0xf); while(1); return 0; }
然后进行编译和链接,首先进行编译,指令如下所示
nasm -f elf -o kernel/lib/kernel/print.o kernel/lib/kernel/print.S gcc kernel/main.c -m32 -I kernel/lib/kernel/ -c -o kernel/main.o ld -melf_i386 -Ttext 0xc0002000 -e main -o kernel/kernel.bin kernel/main.o kernel/lib/kernel/print.o dd if=kernel/kernel.bin of=../hawk.img bs=512 seek=10 count=200 conv=notrunc
结果如图所示
然后我们在虚拟机上进行运行,指令如下所示
~/bochs/bin/bochs -f bochsrc.disk
最后结果如图所示