OS1:Rust for x86上的原始内核。 第2部分。VGA,GDT,IDT

第一部分


第一篇文章还没有时间冷静下来,但我决定不要让您着迷并编写续集。


因此,在上一篇文章中,我们讨论了链接,加载内核文件和主初始化。 我提供了一些有用的链接,告诉了加载的内核如何位于内存中,如何在启动时比较虚拟和物理地址,以及如何启用对页面机制的支持。 最后,控制权传递给用Rust编写的内核的kmain函数。 现在该继续前进,找出兔子洞有多深!


在这部分说明中,我将简要描述Rust配置,一般而言,我将讨论VGA中的信息输出,以及有关设置段和中断的详细信息 。 我请所有有兴趣的人减薪,然后我们开始。


防锈设置


通常,此过程没有什么特别复杂的,有关详细信息,您可以联系Philippe博客 。 但是,我会在某些时候停止。


稳定的Rust仍然不支持低级开发所必需的某些功能,因此,要禁用标准库并在Bare Bones上构建,我们每晚需要Rust。 注意,更新到最新版本后,我得到了一个完全无法使用的编译器,不得不回滚到最近的稳定版本。 如果您确定编译器昨天可以正常工作,但是已更新并且不起作用,请运行命令,替换所需的日期


rustup override add nightly-YYYY-MM-DD 

有关该机制的详细信息,您可以在此处联系。


接下来,配置我们要使用的目标平台。 我是基于Philip Opperman的博客撰写的,所以本节中的许多内容都是从他那里摘取的,经过骨头分解,可以适应我的需要。 Philip在他的博客中正在为x64开发,我最初选择了x32,所以我的target.json会稍有不同。 我把它完全带


 { "llvm-target": "i686-unknown-none", "data-layout": "em:ep:32:32-f64:32:64-f80:32-n8:16:32-S128", "arch": "x86", "target-endian": "little", "target-pointer-width": "32", "target-c-int-width": "32", "os": "none", "executables": true, "linker-flavor": "ld.lld", "linker": "rust-lld", "panic-strategy": "abort", "disable-redzone": true, "features": "-mmx,-sse,+soft-float" } 

这里最难的部分是“ data-layout ”参数。 LLVM文档告诉我们这些是数据布局选项,以“-”分隔。 最初的“ e”字符是负责印第安人的行为-在我们的示例中,按照平台的要求,该字符为小尾数。 第二个字符是m,即“失真”。 负责布局期间的字符名称。 由于我们的输出格式为ELF(请参阅构建脚本),因此我们选择“ m:e”。 第三个字符是指针的大小(以位和ABI(应用程序二进制接口))。 这里的一切都很简单,我们有32位,因此我们大胆地输入“ p:32:32”。 接下来是浮点数。 我们报告说,我们根据ABI 32支持带有对齐方式64的64位数字-“ f64:32:64”,以及默认情况下带有对齐方式的80位数字-“ f80:32”。 下一个元素是整数。 我们从8位开始,然后移至平台最多32位-“ n8:16:32”。 最后是堆栈对齐。 我什至需要128位整数,所以让它成为S128。 无论如何,LLVM可以安全地忽略此参数,这是我们的偏爱。


关于其余参数,您可以看一下Philip,他解释得很好。


我们还需要cargo-xbuild-一种工具,可以在不熟悉的目标平台上构建时交叉编译rust-core。
安装。


 cargo install cargo-xbuild 

我们将像这样收集它。


 cargo xbuild -Z unstable-options --manifest-path=kernel/Cargo.toml --target kernel/targets/$(ARCH).json --out-dir=build/lib 

我需要一个清单文件来正确执行Make,因为它从根目录开始,并且内核位于内核目录中。


在清单的功能中,我只能突出显示crate-type = [“ staticlib”] ,这将一个可链接的文件提供给输出。 我们将用法军喂他。


kmain和初始设置


根据Rust的约定,如果我们创建静态库(或“平面”二进制文件),则包装箱的根目录必须包含文件lib.rs,这是入口点。 其中,借助属性配置了语言功能,并找到了重要的kmain。


因此,在第一步中,我们将需要禁用std库。 这是通过宏完成的。


 #![no_std] 

通过这样一个简单的步骤,我们立即忘记了多线程,动态内存和标准库的其他功能。 而且,我们甚至剥夺了自己的println!,宏,因此我们将不得不自己实现它。 下次我会告诉你该怎么做。


这个地方的许多教程都以“ Hello World”的输出结尾,而没有说明如何生存。 我们将走另一条路。 首先,我们需要为保护模式设置代码和数据段,配置VGA,配置中断,我们将这样做。


 #![no_std] #[macro_use] pub mod debug; #[cfg(target_arch = "x86")] #[path = "arch/i686/mod.rs"] pub mod arch; #[no_mangle] extern "C" fn kmain(pd: usize, mb_pointer: usize, mb_magic: usize) { arch::arch_init(pd); ...... } #[panic_handler] fn panic(_info: &PanicInfo) -> ! { println!("{}", _info); loop {} } 

这是怎么回事 正如我所说,我们关闭了标准库。 我们还将宣布两个非常重要的模块-调试(将在屏幕上写在其中)和拱形(所有与平台有关的魔术都将存在于其中)。 我将Rust功能与配置结合使用,以在不同的体系结构实现中声明相同的接口,并充分利用它们。 在这里,我仅在x86上停止,然后我们仅谈论它。


我声明了一个完全原始的紧急处理程序,Rust需要。 然后就可以对其进行修改。


kmain接受三个参数,并且也以C表示法导出,而不会导致名称失真,因此链接器可以将函数与_loader的调用正确关联,这在上一篇文章中已有介绍。 第一个参数是PD页表的地址,第二个参数是GRUB结构的物理地址,从中可以获取存储卡,第三个参数是幻数。 将来,我想同时实现对Multiboot 2的支持和我自己的Bootloader,所以我使用一个魔术数字来标识启动方法。


第一个kmain调用是特定于平台的初始化。 我们进去 arch_init函数位于arch / i686 / mod.rs文件中,是公共的,特定于32位x86的文件,看起来像这样:


 pub fn arch_init(pd: usize) { unsafe { vga::VGA_WRITER.lock().init(); gdt::setup_gdt(); idt::init_idt(); paging::setup_pd(pd); } } 

如您所见,对于x86,将按顺序初始化输出,分段,中断和分页。 让我们从VGA开始。


VGA初始化


每个教程都认为打印Hello World是他们的职责,因此您将在各处找到如何使用VGA的方法。 出于这个原因,我将尽可能简短地介绍,仅关注自己制作的芯片。 关于lazy_static的使用,我会将您发送到Philippe的博客,并且不会详细解释。 const fn尚未发布,因此精美的静态初始化尚未完成。 而且,我们将添加一个自旋锁,以免造成混乱。


 use lazy_static::lazy_static; use spin::Mutex; lazy_static! { pub static ref VGA_WRITER : Mutex<Writer> = Mutex::new(Writer { cursor_position: 0, vga_color: ColorCode::new(Color::LightGray, Color::Black), buffer: unsafe { &mut *(0xC00B8000 as *mut VgaBuffer) } }); } 

如您所知,屏幕缓冲区位于物理地址0xB8000上,大小为80x25x2字节(屏幕的宽度和高度,每个字符的字节数和属性:颜色,闪烁)。 由于我们已经启用了虚拟内存,因此访问该地址将崩溃,因此我们增加了3 GB。 我们还取消引用了不安全的原始指针-但我们知道我们在做什么。
该文件中有趣的事情可能只是Writer结构的实现,该结构不仅允许连续显示字符,而且还可以滚动,滚动到屏幕上的任何位置以及其他令人愉悦的地方。


VGA作家
 pub struct Writer { cursor_position: usize, vga_color: ColorCode, buffer: &'static mut VgaBuffer, } impl Writer { pub fn init(&mut self) { let vga_color = self.vga_color; for y in 0..(VGA_HEIGHT - 1) { for x in 0..VGA_WIDTH { self.buffer.chars[y * VGA_WIDTH + x] = ScreenChar { ascii_character: b' ', color_code: vga_color, } } } self.set_cursor_abs(0); } fn set_cursor_abs(&mut self, position: usize) { unsafe { outb(0x3D4, 0x0F); outb(0x3D5, (position & 0xFF) as u8); outb(0x3D4, 0x0E); outb(0x3D4, ((position >> 8) & 0xFF) as u8); } self.cursor_position = position; } pub fn set_cursor(&mut self, x: usize, y: usize) { self.set_cursor_abs(y * VGA_WIDTH + x); } pub fn move_cursor(&mut self, offset: usize) { self.cursor_position = self.cursor_position + offset; self.set_cursor_abs(self.cursor_position); } pub fn get_x(&mut self) -> u8 { (self.cursor_position % VGA_WIDTH) as u8 } pub fn get_y(&mut self) -> u8 { (self.cursor_position / VGA_WIDTH) as u8 } pub fn scroll(&mut self) { for y in 0..(VGA_HEIGHT - 1) { for x in 0..VGA_WIDTH { self.buffer.chars[y * VGA_WIDTH + x] = self.buffer.chars[(y + 1) * VGA_WIDTH + x] } } for x in 0..VGA_WIDTH { let color_code = self.vga_color; self.buffer.chars[(VGA_HEIGHT - 1) * VGA_WIDTH + x] = ScreenChar { ascii_character: b' ', color_code } } } pub fn ln(&mut self) { let next_line = self.get_y() as usize + 1; if next_line >= VGA_HEIGHT { self.scroll(); self.set_cursor(0, VGA_HEIGHT - 1); } else { self.set_cursor(0, next_line) } } pub fn write_byte_at_xy(&mut self, byte: u8, color: ColorCode, x: usize, y: usize) { self.buffer.chars[y * VGA_WIDTH + x] = ScreenChar { ascii_character: byte, color_code: color } } pub fn write_byte_at_pos(&mut self, byte: u8, color: ColorCode, position: usize) { self.buffer.chars[position] = ScreenChar { ascii_character: byte, color_code: color } } pub fn write_byte(&mut self, byte: u8) { if self.cursor_position >= VGA_WIDTH * VGA_HEIGHT { self.scroll(); self.set_cursor(0, VGA_HEIGHT - 1); } self.write_byte_at_pos(byte, self.vga_color, self.cursor_position); self.move_cursor(1); } pub fn write_string(&mut self, s: &str) { for byte in s.bytes() { match byte { 0x20...0xFF => self.write_byte(byte), b'\n' => self.ln(), _ => self.write_byte(0xfe), } } } } 

倒带时,只需向后复制屏幕宽度大小的内存部分,用空白填充新行(这就是我执行的清洁操作)。 Outb调用更有趣-除了使用I / O端口外,别无其他方法无法移动光标。 但是,我们仍然需要通过端口进行输入/输出,因此它们以单独的包装交付并包装在安全的包装纸中。 下面的破坏者是汇编代码。 现在,足以知道以下几点:


  • 显示绝对光标偏移,而不是坐标。
  • 您一次可以向控制器输出一个字节
  • 一个字节的输出发生在两个命令中-首先我们将命令写入控制器,然后将数据写入。
  • 用于命令的端口是0x3D4,数据端口是0x3D5
  • 首先,使用命令0x0F打印该位置的底部字节,然后使用命令0x0E打印顶部的字节

out.asm

注意在堆栈上使用传递的变量。 由于堆栈从空间的末尾开始,并在调用函数时减少堆栈指针,以获取参数,返回点等,因此需要将与堆栈对齐方式对齐的参数大小添加到ESP寄存器中(在我们的示例中为4个字节)。


 global writeb global writew global writed section .text writeb: push ebp mov ebp, esp mov edx, [ebp + 8] ;port in stack: 8 = 4 (push ebp) + 4 (parameter port length is 2 bytes but stack aligned 4 bytes) mov eax, [ebp + 8 + 4] ;value in stack - 8 = see ^, 4 = 1 byte value aligned 4 bytes out dx, al ;write byte by port number an dx - value in al mov esp, ebp pop ebp ret writew: push ebp mov ebp, esp mov edx, [ebp + 8] ;port in stack: 8 = 4 (push ebp) + 4 (parameter port length is 2 bytes but stack aligned 4 bytes) mov eax, [ebp + 8 + 4] ;value in stack - 8 = see ^, 4 = 1 word value aligned 4 bytes out dx, ax ;write word by port number an dx - value in ax mov esp, ebp pop ebp ret writed: push ebp mov ebp, esp mov edx, [ebp + 8] ;port in stack: 8 = 4 (push ebp) + 4 (parameter port length is 2 bytes but stack aligned 4 bytes) mov eax, [ebp + 8 + 4] ;value in stack - 8 = see ^, 4 = 1 double word value aligned 4 bytes out dx, eax ;write double word by port number an dx - value in eax mov esp, ebp pop ebp ret 

细分设置


我们感到最困惑,但同时又是最简单的话题。 正如我在上一篇文章中所说的那样,内存的页面和段组织混杂在我的脑海中,我将页面表的地址加载到GDTR中并抓住了我的头。 我花了几个月的时间来阅读足够的材料,消化它并能够实现它。 我可能是Peter Abel教科书《汇编器》的受害者。 “ IBM PC的语言和编程”(一本好书!)描述了Intel 8086的分段。在那段令人愉快的时期,我们将20位地址的高16位加载到了段寄存器中,这就是内存中的地址。 事实证明,以保护模式下的i286开始,一切都是错误的,这令人非常失望。


因此,纯粹的理论是x86支持分段内存模型,因为较旧的程序只能突破640 KB,然后突破1 MB的内存。


程序员必须考虑如何放置可执行代码,如何放置数据以及如何维护其安全性。 页面组织的出现使分段组织成为不必要,但出于兼容性和保护(内核空间和用户空间特权分离)的目的而保留,因此没有它就无处可去。 当特权级别低于0时,将禁止某些处理器指令,并且程序段和内核段之间的访问将导致分段错误。


让我们再次(希望在最后一遍)关于地址翻译的操作
行地址[0x08:0xFFFFFFFF]->验证段权限0x08->虚拟地址[0xFFFFFFFF]->页面表+ TLB->物理地址[0xAAAAFFFF]


段仅在处理器内部使用,存储在特殊的段寄存器(CS,SS,DS,ES,FS,GS)中,并且专门用于检查执行代码和传输控制的权限。 这就是为什么您不能仅从用户空间获取并调用内核函数的原因。 具有0x18描述符的段(我有一个,您的不同)具有3级权限,具有0x08描述符的段具有0级权限。根据x86约定,为了防止未经授权的访问,特权较小的段不能直接调用具有较大权限的段。通过jmp 0x08享有的权利:[EAX],但必须使用其他机制,例如陷阱,门控,中断。


段和它们的类型(代码,数据,梯形图,门)必须在GDT全局描述符表中描述,其虚拟地址及其大小已加载到GDTR寄存器中。 在段之间进行转换时(为简单起见,我假设可以进行直接转换),必须调用指令jmp 0x08:[EAX],其中0x08是第一个有效描述符相对于表开头偏移量,以字节为单位 ,而EAX是包含转换地址的寄存器。 偏移量(选择器)将被加载到CS寄存器中,而相应的描述符将被加载到处理器的影子寄存器中。 每个描述符都是8字节结构。 它有充分的文档记录,其说明可以在OSDev和英特尔文档中找到(请参阅第一篇文章)。


我总结一下。 当我们初始化GDT并执行jmp 0x08:[EAX]转换时,处理器状态如下:


  • GDTR包含虚拟 GDT地址
  • CS包含值0x08
  • 地址[GDTR + 0x08]的句柄已从内存复制到影子寄存器CS
  • EIP寄存器包含EAX寄存器中的地址

零描述符必须始终未初始化,并且禁止对其进行访问。 在讨论多线程时,我将更详细地介绍TSS描述符及其含义。 我的GDT表现在如下所示:


 extern { fn load_gdt(base: *const GdtEntry, limit: u16); } pub unsafe fn setup_gdt() { GDT[5].set_offset((&super::tss::TSS) as *const _ as u32); GDT[5].set_limit(core::mem::size_of::<super::tss::Tss>() as u32); let gdt_ptr: *const GdtEntry = GDT.as_ptr(); let limit = (GDT.len() * core::mem::size_of::<GdtEntry>() - 1) as u16; load_gdt(gdt_ptr, limit); } static mut GDT: [GdtEntry; 7] = [ //null descriptor - cannot access GdtEntry::new(0, 0, 0, 0), //kernel code GdtEntry::new(0, 0xFFFFFFFF, GDT_A_PRESENT | GDT_A_RING_0 | GDT_A_SYSTEM | GDT_A_EXECUTABLE | GDT_A_PRIVILEGE, GDT_F_PAGE_SIZE | GDT_F_PROTECTED_MODE), //kernel data GdtEntry::new(0, 0xFFFFFFFF, GDT_A_PRESENT | GDT_A_RING_0 | GDT_A_SYSTEM | GDT_A_PRIVILEGE, GDT_F_PAGE_SIZE | GDT_F_PROTECTED_MODE), //user code GdtEntry::new(0, 0xFFFFFFFF, GDT_A_PRESENT | GDT_A_RING_3 | GDT_A_SYSTEM | GDT_A_EXECUTABLE | GDT_A_PRIVILEGE, GDT_F_PAGE_SIZE | GDT_F_PROTECTED_MODE), //user data GdtEntry::new(0, 0xFFFFFFFF, GDT_A_PRESENT | GDT_A_RING_3 | GDT_A_SYSTEM | GDT_A_PRIVILEGE, GDT_F_PAGE_SIZE | GDT_F_PROTECTED_MODE), //TSS - for interrupt handling in multithreading GdtEntry::new(0, 0, GDT_A_PRESENT | GDT_A_RING_3 | GDT_A_TSS_AVAIL, 0), GdtEntry::new(0, 0, 0, 0), ]; 

这是初始化,我在上面已经谈到太多了。 GDT地址和大小的加载是通过一个单独的结构完成的,该结构仅包含两个字段。 该结构的地址传递给lgdt命令。 在数据段寄存器中,以0x10的偏移量加载以下描述符。


 global load_gdt section .text gdtr dw 0 ; For limit storage dd 0 ; For base storage load_gdt: mov eax, [esp + 4] mov [gdtr + 2], eax mov ax, [esp + 8] mov [gdtr], ax lgdt [gdtr] jmp 0x08:.reload_CS .reload_CS: mov ax, 0x10 ; 0x10 points at the new data selector mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax mov ax, 0x28 ltr ax ret 

然后,一切都会变得容易一些,但同样有趣。


打断


实际上,是时候让我们有机会与我们的核心进行互动(至少看看我们在键盘上按了什么)。 为此,您必须初始化中断控制器。


关于代码样式的抒情离题。


感谢社区的努力,特别是Philip Opperman的努力,x86中断调用约定已添加到Rust中,这使您可以编写执行iret的中断处理程序。 但是,我有意识地决定不走这条路,因为我决定将汇编器和Rust分离到不同的文件中,从而分离出函数。 是的,我不合理地使用了堆栈内存,我知道这一点,但是它仍然很有趣。 我的中断处理程序是用汇编器编写的,并且只做一件事:它们调用几乎与Rust编写的中断处理程序相同的中断处理程序。 请接受这一事实并放纵自己。


通常,初始化中断的过程类似于初始化GDT,但更易于理解。 另一方面,您需要大量的统一代码。 Redox OS的开发人员使用该语言的所有优点做出了一个漂亮的决定,但是我“走在前额”并决定允许代码重复。


根据x86约定,我们会有中断,但是有特殊情况。 在这种情况下,我们的设置实际上是相同的。 唯一的区别是,当引发异常时,堆栈可能包含其他信息。 例如,当使用一堆文件时,我用它来处理缺少页面的问题(但一切都有时间)。 中断和异常都是在同一张表中处理的,您和我都需要填写该表。 还必须对PIC(可编程中断控制器)进行编程。 还有APIC,但我还没有弄清楚。


在使用PIC时,我不会给出太多评论,因为网络上有许多使用PIC的示例。 我将从汇编器中的处理程序开始。 它们都是完全相同的,因此我将删除扰流板的代码。


问卷
 global irq0 global irq1 ...... global irq14 global irq15 extern kirq0 extern kirq1 ...... extern kirq14 extern kirq15 section .text irq0: pusha call kirq0 popa iret irq1: pusha call kirq1 popa iret ...... irq14: pusha call kirq14 popa iret irq15: pusha call kirq15 popa iret 

如您所见,Rust函数的所有调用均以前缀“ k”开头-为区别和方便起见。 异常处理完全相同。 对于汇编器功能,选择前缀“ e”,对于Rust,选择前缀“ k”。 Page Fault处理程序有所不同,但与此不同-在有关内存管理的说明中。


例外情况
 global e0_zero_divide global e1_debug ...... global eE_page_fault ...... global e14_virtualization global e1E_security extern k0_zero_divide extern k1_debug ...... extern kE_page_fault ...... extern k14_virtualization extern k1E_security section .text e0_zero_divide: pushad call k0_zero_divide popad iret e1_debug: pushad call k1_debug popad iret ...... eE_page_fault: pushad mov eax, [esp + 32] push eax mov eax, cr2 push eax call kE_page_fault pop eax pop eax popad add esp, 4 iret ...... e14_virtualization: pushad call k14_virtualization popad iret e1E_security: pushad call k1E_security popad iret 

我们声明汇编程序处理程序:


 extern { fn load_idt(base: *const IdtEntry, limit: u16); fn e0_zero_divide(); fn e1_debug(); ...... fn e14_virtualization(); fn e1E_security(); fn irq0(); fn irq1(); ...... fn irq14(); fn irq15(); } 

我们定义了我们在上面调用的Rust处理程序。 请注意,要中断键盘,我只显示从端口0x60获得的接收到的代码-这是键盘在最简单模式下的工作方式。 我希望,将来,它会转变为成熟的驱动程序。 每次中断后,您需要将处理0x20结束的信号输出到控制器,这一点很重要! 否则,您将不会获得更多的中断。


 #[no_mangle] pub unsafe extern fn kirq0() { // println!("IRQ 0"); outb(0x20, 0x20); } #[no_mangle] pub unsafe extern fn kirq1() { let ch: char = inb(0x60) as char; crate::arch::vga::VGA_WRITER.force_unlock(); println!("IRQ 1 {}", ch); outb(0x20, 0x20); } #[no_mangle] pub unsafe extern fn kirq2() { println!("IRQ 2"); outb(0x20, 0x20); } ... 

IDT和PIC的初始化。 关于PIC及其重新映射,我发现了大量详细程度各异的教程,从OSDev开始到业余网站结束。 由于编程过程以恒定的操作序列和恒定的命令进行操作,因此我将给出此代码而无需进一步说明。 , 0x20-0x2F , 0x20 0x28, 16 IDT.


 unsafe fn setup_pic(pic1: u8, pic2: u8) { // Start initialization outb(PIC1, 0x11); outb(PIC2, 0x11); // Set offsets outb(PIC1 + 1, pic1); /* remap */ outb(PIC2 + 1, pic2); /* pics */ // Set up cascade outb(PIC1 + 1, 4); /* IRQ2 -> connection to slave */ outb(PIC2 + 1, 2); // Set up interrupt mode (1 is 8086/88 mode, 2 is auto EOI) outb(PIC1 + 1, 1); outb(PIC2 + 1, 1); // Unmask interrupts outb(PIC1 + 1, 0); outb(PIC2 + 1, 0); // Ack waiting outb(PIC1, 0x20); outb(PIC2, 0x20); } pub unsafe fn init_idt() { IDT[0x0].set_func(e0_zero_divide); IDT[0x1].set_func(e1_debug); ...... IDT[0x14].set_func(e14_virtualization); IDT[0x1E].set_func(e1E_security); IDT[0x20].set_func(irq0); IDT[0x21].set_func(irq1); ...... IDT[0x2E].set_func(irq14); IDT[0x2F].set_func(irq15); setup_pic(0x20, 0x28); let idt_ptr: *const IdtEntry = IDT.as_ptr(); let limit = (IDT.len() * core::mem::size_of::<IdtEntry>() - 1) as u16; load_idt(idt_ptr, limit); } 

IDTR GDTR — . STI — — , , ASCII- -.


 global load_idt section .text idtr dw 0 ; For limit storage dd 0 ; For base storage load_idt: mov eax, [esp + 4] mov [idtr + 2], eax mov ax, [esp + 8] mov [idtr], ax lidt [idtr] sti ret 

后记


, , . setup_pd, . , , , .


- GitLab .


感谢您的关注!


UPD: 3

Source: https://habr.com/ru/post/zh-CN445584/


All Articles