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

调用约定

在进程中都会有自己的栈,有些参数会保存在栈中,例如我们在调用函数时会将参数压入栈中,被调用函数在栈中获取参数然后执行,那么参数存放在栈中了,需要有来负责回收这部分空间的,由谁来回收就得看是哪种调用约定

c语言使用的是cdecl调用,此处我们只了解cdecl即可

实现自己的打印函数

显卡的端口控制

实现打印字符我们需要通过端口来获取光标位置,在光标位置处显示字符,然后移动光标,获取光标位置需要用到VAG寄存器中的CRT Controller Registers组中索引号为0Eh与0Fh的寄存器

头文件

为了方便开发,我们先写个头文件,定义一些数据类型(/lib/stdint.h)

cpp 复制代码
#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

字符打印

print.s

我们用汇编来实现字符打印

(1)备份寄存器现场。

(2)获取光标坐标值,光标坐标值是下一个可打印字符的位置。

(3)获取待打印的字符。

(4)判断字符是否为控制字符,若是回车符、换行符、退格符三种控制字符之一,则进入相应的处理流程。否则,其余字符都被粗暴地认为是可见字符,进入输出流程处理。

(5)判断是否需要滚屏。

(6)更新光标坐标值,使其指向下一个打印字符的位置。

cpp 复制代码
TI_GDT equ  0                                    ;定义显存段段描述符的段选择子
RPL0  equ   0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0


[bits 32]                                        ;32位编译
section .text                                    ;表示为代码段
                                                 
                                                 
                                                 
global put_char                                  ;将put_char导出为全局符号,其他文件也可使用
put_char:
    pushad	                                     ;保存32位寄存器环境
                                                 
    mov ax, SELECTOR_VIDEO	                     ;将视频段选择子赋值给gs
    mov gs, ax


                                                 
                                                 
    mov dx, 0x03d4                               ;装备向该端口写入索引值
    mov al, 0x0e	                             ;指定接下来要访问的寄存器是光标位置的高 8 位
    out dx, al
    mov dx, 0x03d5                               ;准备通过此端口读取光标高 8 位寄存器的值
    in al, dx	                                 ;此时读取的是光标位置的高 8 位值
    mov ah, al                                   ;将高 8 位暂存,以便与后续读取的低 8 位组合成
                                                 ;完整的 16 位光标位置


                                                 
    mov dx, 0x03d4                               ;准备向索引端口写入索引值
    mov al, 0x0f                                 ;指定接下来要访问的寄存器是光标位置的低 8 位。
    out dx, al                                   ;选中光标低 8 位寄存器,后续对数据端口的读写将
                                                 ;针对该寄存器。
    mov dx, 0x03d5                               ;准备通过此端口读取光标低 8 位寄存器的值。
    in al, dx                                    ;此时读取的是光标位置的低 8 位值。
    mov bx, ax	                                 ;将完整的 16 位光标位置存入 bx,以便后续使用。
                                                 ;光标位置值范围 0~1999,对应 80×25 文本模式的 
                                                 ;2000 个字符单元。
                                                 
                                                 ;下行是在栈中获取待打印的字符
    mov ecx, [esp + 36]	                         ;pushad压入了8*4=32字节,加上函数返回地址就是
                                                 ;36字节,因此esp+36
    cmp cl, 0xd				                     ;判断是否是CR(回车)0x0d
    jz .is_carriage_return
    cmp cl, 0xa                                  ;判断是否是LF(换行)0x0a
    jz .is_line_feed


    cmp cl, 0x8				                     ;判断是否是BS(backspace退格)的asc码8
    jz .is_backspace
    jmp .put_other	   


.is_backspace:		      
                                                 ;backspace本质上是将光标在显存上前移一个显存
                                                 ;位置,后面再输入的字符自然会覆盖此处的字符
                                                 ;但是如果我们不输入字符覆盖的话会残留字符
                                                 ;所以此处添加了空格或空字符0
                                                                                                  
    dec bx                                       ;光标位置-1
    shl bx,1                                     ;bx左移,相当于乘以2,因为bx是光标位置也就是第几            
                                                 ;个字符位置,但是显存中我们知道一个字是分俩字节
                                                 ;存储,一个是字符一个是字符属性,此处乘2就是为了
                                                 ;转换成对应偏移
    mov byte [gs:bx], 0x20		                 ;将待删除的字节补为0或空格,0x20是ASCII码空格
    inc bx                                       ;指向这个字符的属性位置
    mov byte [gs:bx], 0x07                       ;黑底白字
    shr bx,1                                     ;右移一位,相当于除以2,恢复成光标位置
    jmp .set_cursor                              ;设置光标位置


 .put_other:
    shl bx, 1				                     ;光标位置是用2字节表示,将光标值乘2,表示对应显
                                                 ;存中的偏移字节
    mov [gs:bx], cl			                     ;ascii字符本身
    inc bx                                       
    mov byte [gs:bx],0x07		                 ;字符属性
    shr bx, 1				                     ;恢复原来的光标值
    inc bx				                         ;下一个光标值
    cmp bx, 2000		                         
    jl .set_cursor			                     ;若光标值小于2000,则未写到显存的最后,去设置新
                                                 ;的光标值,若超出屏幕字符数大小(2000)则换行处理
					                             
 .is_line_feed:				                     ;换行符LF(\n)
 .is_carriage_return:			                 ;回车符CR(\r)
					                             
    xor dx, dx				                     ;要进行16位除法,高16位置会放在dx中,要先清零
    mov ax, bx				                     ;ax是被除数的低16位.
    mov si, 80				                     ;用si寄存器来存储除数80,此处是效仿的linux,所
                                                 ;以\n就是下一行行首
    div si				                         
    sub bx, dx				                     ;光标值减去除80的余数
					                             


 .is_carriage_return_end:		                 ;回车符CR处理结束
    add bx, 80
    cmp bx, 2000
 .is_line_feed_end:			                     ;若是LF(\n),则将光标移+80
    jl .set_cursor


                                                 ;屏幕行范围是0~24,滚屏的原理是将屏幕的1~24行
                                                 ;搬运到0~23行,再将第24行用空格填充
.roll_screen:				                     ;如果超出屏幕大小,就开始滚屏
    cld                                          
    mov ecx, 960				                 ;一共有2000-80=1920个字符,1920*2=3840字
                                                 ;节,3840/4=960次
    mov esi, 0xb80a0			                 ;第一行行首
    mov edi, 0xb8000			                 ;第零行行首
    rep movsd				                     


                                                 
    mov ebx, 3840			                     ;最后一行首字符的第一个字节偏移= 1920 * 2
    mov ecx, 80				                     ;一行是80字符(160字节),每次清空1字符(2字节),
                                                 ;一行需要移动80次
 .cls:
    mov word [gs:ebx], 0x0720		             ;0x0720是黑底白字的空格键
    add ebx, 2
    loop .cls 
    mov bx,1920				                     ;将光标值重置为1920,最后一行的首字符


.set_cursor:   
					                             
                                                 
    mov dx, 0x03d4			                     
    mov al, 0x0e				                 
    out dx, al
    mov dx, 0x03d5			                     
    mov al, bh
    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

print.h

为了方便其他函数调用,我们写一个print.h头文件

cpp 复制代码
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"     
void put_char(uint8_t char_asci);      
#endif

main.c

cpp 复制代码
#include "print.h"
void main(void)
{
    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);
    
}

调试

nasm -o /home/chipfesen/bochs/study/print.o -f elf /home/chipfesen/bochs/study/kernel/print.s
gcc-4.4 -o /home/chipfesen/bochs/study/main.o -c -m32 -I/home/chipfesen/bochs/study/include/ /home/chipfesen/bochs/study/kernel/main.c
ld -o /home/chipfesen/bochs/study/kernel.bin -m elf_i386 -Ttext 0xc0001500 -e main /home/chipfesen/bochs/study/main.o /home/chipfesen/bochs/study/print.o
dd if=/home/chipfesen/bochs/study/kernel.bin of=/home/chipfesen/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc

正常执行后如下图所示

字符串打印

ptint.s

在c语言中定义的字符串,编译器会自动在末尾添加\0,作为字符串的结束标记用来判断字符串长度,\0的ASCII码是0

在priht.s文件中加入下列代码

cpp 复制代码
[bits 32]
section .text
                                                            

global put_str
put_str:
                                                            ;只用到了ebx和ecx,只备份这两个
   push ebx
   push ecx
   xor ecx, ecx		                                        ;清空ecx
   mov ebx, [esp + 12]	                                    ;从栈中得到待打印的字符串地址 
.goon:
   mov cl, [ebx]                                            ;ebx是字符串的地址,对地址进行取地
                                                            ;址操作,然后取出一字节的数据
   cmp cl, 0		                                        ;如果处理到字符串尾,跳到结束处返回
   jz .str_over
   push ecx		                                            ;给put_char函数传递参数
   call put_char
   add esp, 4		                                        ;回收参数所占的栈空间
   inc ebx		                                            ;使ebx指向下一个字符
   jmp .goon
.str_over:
   pop ecx
   pop ebx
   ret

print.h

print.h中加入下列代码

cpp 复制代码
void put_str(char* messags);

main.c

创建新的内核文件来验证

cpp 复制代码
#include "print.h"
void main(void) {
   put_str("I am kernel\n");
   while(1);
}

调试

指令与之前一样,成功则为下图所示

打印整数

此处实现的只是整数,不包含浮点数,用于将数字转换成对应的字符

1.十六进制与二进制的关系

一个十六进制数字对应4位二进制(半字节),32位整数共有8个十六进制数字(从高位到低位),例如:0x1234ABCD 在内存中由8个十六进制数字组成1 2 3 4 A B C D

2.数字到ASCII字符的转换

每个4位组的值范围是0~15,需要映射到可见字符:

0~9→字符'0'~'9',ASC1l码分别为48~57。

10~15→字符'A'~'F',ASCIl码分别为65~70。

3.逐位处理与存储顺序

代码从整数的低位开始处理(通过循环右移4位),但为了打印时符合阅读习惯(高位在左),需要将先处理的低位字符放在缓冲区的末尾,后处理的高位字符放在缓冲区的前面

4. 跳过前导零

转换完成后,缓冲区中可能包含前导零(如0x00123ABC会存为0,0,1,2,3,A,B,C)

打印前需要跳过开头的连续字符,但若所有位均为0,则至少打印一个

代码中通过edi索引从0开始检查,直到遇到非0字符,然后从该位置开始逐个字符调用put_char输出。

print.s

print.s加入如下代码

cpp 复制代码
section .data
put_int_buffer dq 0                                         ;定义8字节缓冲区用于数字到字符的转换

global put_int
put_int:
   pushad
   mov ebp, esp
   mov eax, [ebp+4*9]		                                ;call的返回地址占4字节+pushad的8个4字节,现在eax中就是要显示的32位数值
   mov edx, eax                                             ;edx中现在是要显示的32位数值
   mov edi, 7                                               ;指定在put_int_buffer中初始的偏移量,也就是把栈中第一个字节取出放入buffer最后一个位置,第二个字节放入buff倒数第二个位置
   mov ecx, 8			                                    ;32位数字中,16进制数字的位数是8个
   mov ebx, put_int_buffer                                  ;ebx现在存储的是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:
   mov [ebx+edi], dl		                                ;此时dl中是数字对应的字符的ascii码
   dec edi                                                  ;edi是表示在buffer中存储的偏移,现在向前移动
   shr eax, 4                                               ;eax中是完整存储了这个32位数值,现在右移4位,处理下一个4位二进制表示的16进制数字
   mov edx, eax                                             ;把eax中的值送入edx,让ebx去处理
   loop .16based_4bits

                                                            ;现在put_int_buffer中已全是字符,打印之前,
                                                            ;把高位连续的字符去掉,比如把字符00000123变成123
.ready_to_print:
   inc edi			                                        ;此时edi退减为-1(0xffffffff),加1使其为0
.skip_prefix_0:                                             ;跳过前缀的连续多个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                                                ;当edi=8时,虽然不会去打印,但是实际上已经越界访问缓冲区了
   jl .put_each_num
   popad
   ret

print.h

加入下列声明

cpp 复制代码
void put_int(uint32_t num);	        // 以16进制打印

main.c

cpp 复制代码
#include "print.h"
void main(void) {
   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);
}

调试

内联汇编

Intel与AT&A风格对比

C语言不支持寄存器操作,但是汇编语言可以,所以在C语言中嵌入汇编就可以使得能实现更多功能

内联汇编分为基础内联汇编和扩展内联汇编,但是内联汇编使用的语法是AT&T,而我们前面所使用的是Intel格式的汇编语言

无 base_address,无 offset_address:

movl %eax,(%esi,2)

将寄存器 %eax 中的值写入到 esi * 2所指向的内存单元中
无 base_address,有 offset_address:

movl %eax,(%ebx,%esi,2)

功能是将eax的值写入ebx+esi*2所指向的内存
有 base_address,无 offset_address:

movl %eax,base_value(,%esi,2)

功能是将eax的值写入base_value+esi*2所指向的内存。
有 base_address,有 offset_address:

movl %oeax,base_value(%ebx,esi,2)

功能是将eax的值写入base_value+ebx+esi*2所指向的内存。

基本内联汇编

基本内联汇编是最简单的内联形式,其格式为: asm [volatile] ("assembly code")

asm 是关键字(也可用 asm 避免与某些标识符冲突)。

volatile 是可选的,用于告诉编译器不要优化这段汇编代码(例如删除或移动它)。

括号内是一个字符串常量,包含一条或多条汇编指令,多条指令间通常用 \n\t 分隔。

特点:

不能指定输入、输出操作数,也不能声明可能被修改的寄存器(clobber 列表)。

汇编代码与 C 变量没有直接交互,只能操作寄存器、内存地址或全局符号。

由于缺少与 C 变量的连接,编译器不会为它做任何寄存器分配或数据流分析,容易出错。

常用于执行与 C 环境无关的简单指令,如内存屏障、关中断、开中断等。

扩展内联汇编

扩展内联汇编提供了更强大的功能,允许汇编代码与 C 变量进行交互,明确指定输入、输出操作数,并列出可能被破坏的寄存器,使编译器能够正确优化并生成安全的代码。

asm [volatile] ("assembly code":output : input : clobber/modify)

"assembly code":汇编指令模板

是一个字符串,包含一条或多条汇编指令。多条指令通常用 \n\t 分隔,以保证在生成的汇编代码中格式良好。

模板中可以引用操作数占位符,如 %0、%1、......,对应第 0、1、......个操作数(操作数按顺序编号,从输出操作数开始,然后输入操作数)。

也可以使用命名占位符,格式为 %[name],此时需要在操作数列表中为操作数指定名字,如 [name] "r"(var)。
output:输出操作数列表

格式:[操作数名] "约束修饰符 约束"(C表达式)

操作数名(可选):为操作数指定一个名字,用于在模板中通过 %[name] 引用。

约束修饰符:描述操作数的访问方式,常见的有:

=:只写(输出操作数)。表示该操作数在指令执行前不包含有用值,执行后会被写入。

+:可读可写(输入输出操作数)。表示该操作数既作为输入也作为输出,编译器会分配一个寄存器同时用于输入和输出。

&:早期破坏修饰符。表示该输出操作数在指令执行结束前就可能被修改,因此不能与任何输入操作数共用同一个寄存器,避免冲突。

约束:指定操作数应该存放的位置(寄存器、内存、立即数等)。常用约束包括:

r:任意通用寄存器。

m:内存地址。

i:立即整数操作数(常量表达式)。

g:任意通用、内存或立即数(通用约束)。

a、b、c、d:分别表示 eax、ebx、ecx、edx 寄存器(x86 特有)。

q:eax、ebx、ecx、edx 之一(x86 的字节可访问寄存器)。

其他架构也有专用约束。

示例:

复制代码
int result;
asm ("movl $1, %0" : "=r"(result));

"=r"(result):输出操作数,使用 = 表示只写,约束 r 表示任意通用寄存器,将结果存入变量 result
input:输入操作数列表

格式与输出类似:[操作数名] "约束"(C表达式)

输入操作数不需要修饰符(不能使用 = 或 +),因为它们是只读的。

约束同样指定存放位置,但编译器会根据约束将表达式的值放入相应位置(寄存器、内存或立即数)

示例

复制代码
int a = 10, b = 20, sum;
asm ("addl %1, %0" : "=r"(sum) : "r"(a), "0"(b));

输入操作数:"r"(a) 表示将 a 放入某个寄存器,"0"(b) 表示 b 与第 0 个操作数(即输出 sum)使用相同位置(注意约束 "0" 不是寄存器,而是与第 0 个操作数相同的位置)。这里 "0"(b) 表明 b 既作为输入,又希望与输出共用同一个寄存器(因为 sum 最终会覆盖该寄存器)
clobber/modify:破坏描述列表

破坏列表告知编译器在执行这段汇编代码期间,哪些寄存器、内存或状态被修改,以便编译器进行相应的保护(如保存、恢复或重新加载)。

列出可能被修改的寄存器名称,如 "eax"、"ecx"、"edx"、"memory"、"cc"。

"memory" 表示汇编代码可能修改了内存,要求编译器在汇编前将所有内存值同步到内存(即完成写操作),并在汇编后重新加载必要的内存值。常用于实现内存屏障。

"cc" 表示条件码寄存器(标志寄存器)被修改。

示例

复制代码
asm volatile (
    "lock; cmpxchgl %1, %0"
    : "+m"(*ptr), "+a"(old)
    : "r"(new)
    : "cc", "memory"
);

这是一个原子 compare-and-exchange 操作,它修改了标志寄存器 "cc" 和内存 "memory",同时 *ptr 和 old 被标记为输入输出(+m 和 +a)。

相关推荐
古译汉书2 小时前
【IoT死磕系列】Day 6:工业控制底层大动脉—CAN总线
linux·网络·arm开发·单片机·物联网·tcp/ip
戴西软件2 小时前
PreSys在爆炸与多介质流固耦合中的建模方法:从ALE到SPH的工程实践
linux·python·程序人生·cae
feng68_2 小时前
Web服务基础理论
linux·运维·服务器·web服务
序安InToo2 小时前
第4课|程序结构与编译流程
后端·操作系统·嵌入式
柳鲲鹏2 小时前
LINUX下载编译libosmscout
linux·运维·服务器
czxyvX2 小时前
018-Linux-Socket编程-UDP
linux·udp
十五年专注C++开发2 小时前
tiny-process-library:一个用 C++ 编写的轻量级、跨平台(支持 Windows、Linux、macOS)的进程管理库
linux·c++·windows·进程管理
学不完的2 小时前
Nginx
linux·运维·nginx·运维开发
汇智信科2 小时前
汇智信科网络考试系统:以技术赋能,重构在线测评新范式
linux·数据库·mysql·oracle·sqlserver·java技术