编写你自己的操作系统笔记-全-

编写你自己的操作系统笔记(全)

001:一小时内编写你的操作系统

在本教程中,我们将学习如何从零开始编写一个简单的操作系统内核。我们将使用C++和汇编语言,并通过GRUB引导加载程序来启动我们的内核。教程将涵盖从设置开发环境、理解计算机启动过程,到编写内核代码并将其加载到内存中的完整流程。

准备工作与开发环境

上一节我们介绍了本教程的目标,本节中我们来看看开始编写操作系统前需要做的准备工作。

首先,你需要准备以下软件和开发环境。以下是所需的工具列表:

  • GNU编译器集合:包括C++编译器 g++
  • GNU汇编器as
  • 开发库libc6-dev-i386 包。

本教程选择使用C++而非C语言进行开发。C++在保持高性能的同时,提供了面向对象等现代编程范式,是性能与现代特性的良好折中。

计算机启动流程

在开始编码之前,理解计算机从开机到执行我们代码的完整流程至关重要。

当你启动计算机时,主板上的BIOS(基本输入输出系统)会首先运行。BIOS是一段固件,它执行硬件自检,然后将控制权交给引导加载程序。

具体流程如下:

  1. BIOS将其自身代码从ROM复制到RAM中。
  2. BIOS指示CPU从特定内存地址开始执行,即运行BIOS固件代码。
  3. BIOS以低级方式与硬盘通信,读取硬盘主引导记录(MBR)中的前512字节(引导扇区)到内存。
  4. BIOS跳转到引导加载程序代码。本教程使用GRUB作为引导加载程序。
  5. GRUB比BIOS更复杂,它能理解分区表和文件系统。它会读取配置文件(如 /boot/grub/grub.cfg),并在屏幕上显示可引导的操作系统列表。
  6. 当你选择一个条目(例如我们的自制系统)后,GRUB会根据配置找到内核文件(例如 kernel.bin),将其加载到内存中。
  7. 最后,GRUB跳转到我们内核的入口点,此时我们的代码开始执行。

内核入口与栈指针问题

上一节我们了解了引导流程,本节中我们来看看编写内核时遇到的第一个技术挑战。

当GRUB将控制权交给我们的内核时,存在一个关键问题:GRUB不会设置栈指针(ESP寄存器)。而C/C++程序在开始运行时期望栈指针已被正确设置,以便进行函数调用、局部变量存储等操作。

为了解决这个问题,我们需要编写两个文件:

  1. loader.S:一个汇编文件,负责设置栈指针,然后跳转到我们的C++内核主函数。
  2. kernel.cpp:我们的C++内核主文件。

编译和链接过程如下:

  • 使用汇编器 as 编译 loader.S,生成 loader.o
  • 使用C++编译器 g++ 编译 kernel.cpp,生成 kernel.o
  • 使用链接器 ld 将两个目标文件 loader.okernel.o 合并,生成最终的可引导内核文件 kernel.bin

此外,需要注意的是,计算机启动时CPU处于32位兼容模式。因此,我们的内核也将被编写为32位程序以兼容此模式。

创建项目文件与Makefile

现在,让我们开始创建项目。首先在空目录中创建以下文件:

  • loader.S
  • kernel.cpp
  • linker.ld
  • Makefile

Makefile 用于自动化编译和链接过程。其核心规则如下:

# 定义编译器和参数
CXXFLAGS = -m32 -nostdlib -fno-builtin -fno-rtti -fno-exceptions -fno-leading-underscore
ASFLAGS = --32
LDFLAGS = -m elf_i386 -T linker.ld

# 从.cpp生成.o
%.o: %.cpp
    g++ $(CXXFLAGS) -c $< -o $@

# 从.S生成.o
%.o: %.S
    as $(ASFLAGS) -c $< -o $@

# 链接所有.o文件生成kernel.bin
kernel.bin: linker.ld loader.o kernel.o
    ld $(LDFLAGS) -o $@ loader.o kernel.o

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_7.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_9.png)

# 安装内核到/boot目录
install: kernel.bin
    cp kernel.bin /boot/mykernel.bin

编写汇编引导器 (loader.S)

loader.S 文件是内核的入口点,它主要完成三件事:定义多引导头、设置栈空间、跳转到C++主函数。

其核心代码如下:

# 定义多引导头,使GRUB识别本文件为合法内核
.set MAGIC, 0x1BADB002
.set FLAGS, 0
.set CHECKSUM, -(MAGIC + FLAGS)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_11.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_13.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_15.png)

.section .multiboot
    .long MAGIC
    .long FLAGS
    .long CHECKSUM

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_17.png)

# 设置栈空间(2MB)和栈指针
.section .bss
.align 16
stack_bottom:
.skip 2 * 1024 * 1024 # 2 MiB
stack_top:

.section .text
.global _start
.type _start, @function
_start:
    # 设置栈指针
    mov $stack_top, %esp

    # 调用C++内核主函数
    call kernel_main

    # 内核主函数不应返回,若返回则进入无限循环
    cli
loop:
    hlt
    jmp loop

代码解释

  • .multiboot 节包含GRUB所需的“魔数”和校验和,这是一个约定。
  • .bss 节预留了2MB的未初始化空间作为内核栈。
  • _start 是入口点:它将栈顶地址 stack_top 加载到ESP寄存器,然后调用 kernel_main 函数。

编写C++内核 (kernel.cpp)

kernel.cpp 中,我们将实现内核的主函数。一个关键点是,在独立环境中(没有操作系统支持),我们不能使用标准库函数(如 printf),因为它们是依赖操作系统和C库的。

因此,我们需要自己实现一个向屏幕输出文本的函数。在文本模式下,屏幕内容映射到内存地址 0xB8000。每个字符占用两个字节:低字节是ASCII码,高字节是颜色属性。

以下是 kernel.cpp 的初始实现:

// 定义视频内存地址
volatile unsigned short* video_memory = (unsigned short*)0xB8000;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_19.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_21.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_23.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/59eaeaecbe2491e5aee4b05d60ae8c6f_25.png)

// 自定义字符串打印函数
void print_string(const char* str) {
    for(int i = 0; str[i] != '\0'; ++i) {
        // 写入字符,保留原有的颜色属性(高字节)
        video_memory[i] = (video_memory[i] & 0xFF00) | str[i];
    }
}

// 内核主函数
extern "C" void kernel_main() {
    // 在屏幕左上角打印欢迎信息
    print_string("Hello from My Operating System!");

    // 内核永不退出,进入无限循环
    while(true) {
        // 空循环
    }
}

代码解释

  • video_memory 是一个指向视频内存的指针。
  • print_string 函数遍历字符串,将每个字符的ASCII码写入视频内存的对应位置,同时通过 & 0xFF00 操作保留该位置原有的颜色属性。
  • extern "C" 用于防止C++编译器对函数名进行修饰(name mangling),确保汇编代码可以正确调用 kernel_main
  • 内核主函数打印一条消息后,进入无限循环。

链接器脚本 (linker.ld)

链接器脚本 linker.ld 指导链接器如何将不同目标文件(.o)中的各个段(section)组合到最终的可执行文件(.bin)中。这对于控制内核在内存中的布局至关重要。

一个基础的链接器脚本如下:

/* 指定入口点为 _start(在loader.S中定义) */
ENTRY(_start)

/* 输出文件格式和架构 */
OUTPUT_FORMAT(elf32-i386)
OUTPUT_ARCH(i386)

SECTIONS {
    /* 内核从1MB地址开始加载(GRUB的约定) */
    . = 1M;

    /* 首先放置多引导头,确保GRUB能正确识别 */
    .multiboot : {
        *(.multiboot)
    }

    /* 接着是代码段(.text) */
    .text : {
        *(.text)
    }

    /* 然后是只读数据段(.rodata) */
    .rodata : {
        *(.rodata)
    }

    /* 接着是已初始化的数据段(.data) */
    .data : {
        *(.data)
    }

    /* 最后是未初始化的数据段和栈空间(.bss) */
    .bss : {
        *(COMMON)
        *(.bss)
    }
}

配置GRUB并测试运行

所有代码编写完成后,最后一步是配置GRUB来引导我们的内核。

  1. 编译:在项目目录运行 make 命令,生成 kernel.bin
  2. 安装:运行 sudo make install,将 kernel.bin 复制到 /boot/mykernel.bin
  3. 配置GRUB:编辑GRUB配置文件 /boot/grub/grub.cfg,在文件末尾添加一个新的菜单项:
    menuentry "My Operating System" {
        multiboot /boot/mykernel.bin
        boot
    }
    
  4. 重启测试:保存配置文件后,重启计算机。在GRUB启动菜单中,你应该能看到“My Operating System”选项。选择它,如果一切顺利,屏幕上将显示“Hello from My Operating System!”。

总结

本节课中我们一起学习了如何编写一个最简单的操作系统内核。我们从理解计算机启动流程和GRUB引导加载程序的作用开始,然后解决了独立环境编程的核心挑战——设置栈指针和实现基本输出。通过创建汇编引导器 (loader.S)、C++内核 (kernel.cpp)、链接器脚本 (linker.ld) 和自动化构建脚本 (Makefile),我们成功构建了一个能被GRUB加载并能在屏幕上打印信息的最小内核。这为后续深入探索操作系统开发(如中断处理、内存管理、进程调度等)奠定了坚实的基础。在下一个阶段,建议在虚拟机中进行开发测试,以提升效率。

002:补充说明 🔧

在本节课中,我们将学习如何修复一个在之前视频中遗漏的关键步骤:显式调用全局和静态对象的构造函数。这对于确保C++程序中的复杂对象能够正确初始化至关重要。

上一节我们介绍了链接脚本和内核入口点。本节中我们来看看如何确保所有全局对象的构造函数被正确调用。

问题概述

在之前的设置中,链接脚本将所有构造函数地址收集到了特定的内存区域(由符号 __CTOR_LIST____CTOR_END__ 界定)。然而,我们只存储了这些函数的地址,却从未实际调用它们。这导致了一个问题:任何具有构造函数的全局或静态类实例(以及可能的结构体实例)都不会被初始化。

这种情况之所以在之前的教程中没有引发问题,是因为通常我们不会大量使用静态复合对象。更常见的做法是使用指向它们的指针,而指针是基本数据类型,不受此问题影响。

核心概念与修复步骤

构造函数在C++中是一种特殊的成员函数,用于初始化对象。对于全局对象,编译器会生成一个匿名函数来封装对构造函数的调用,并将该匿名函数的地址放入构造函数列表中。

我们需要做的是:遍历这个构造函数列表,并逐一调用其中的每个函数指针。

以下是实现此功能的核心代码逻辑:

typedef void (*constructor)();

extern "C" constructor start_ctors;
extern "C" constructor end_ctors;

extern "C" void call_constructors() {
    for (constructor* i = &start_ctors; i != &end_ctors; i++) {
        (*i)(); // 调用构造函数
    }
}

代码解释

  1. constructor 是一个指向无参数、无返回值的函数的指针类型定义。
  2. start_ctorsend_ctors 是在链接脚本中定义的外部符号,分别指向构造函数列表的起始和结束地址。
  3. call_constructors 函数遍历从 start_ctorsend_ctors 的每一个函数指针,并执行它,从而初始化所有全局对象。

集成到启动流程中

修复步骤不仅涉及C++代码,还需要修改汇编启动代码。

我们需要在进入内核的 main 函数之前,先调用 call_constructors 函数。这通常在设置好栈之后进行。

以下是在汇编启动文件(例如 loader.s)中需要添加的调用:

; 设置栈指针等初始化代码之后...
call call_constructors ; 调用所有全局对象的构造函数
call kernel_main       ; 跳转到内核主函数

通过以上两步,我们确保了在 kernel_main 执行之前,所有全局和静态C++对象都已通过其构造函数正确初始化。

总结

本节课中我们一起学习了如何修复操作系统引导过程中遗漏的构造函数调用问题。我们了解到:

  1. 问题根源:链接脚本收集了构造函数地址但未调用它们。
  2. 解决方案:编写一个 call_constructors 函数来遍历并执行构造函数列表。
  3. 集成方法:在汇编启动流程中,于设置栈之后、跳转至 kernel_main 之前调用此函数。

这个补充步骤对于构建一个能够完全支持C++特性的操作系统内核是必要的。下一节,我们将探讨全局描述符表(GDT),这是进入保护模式并开启更多有趣功能的关键一步。

003:在虚拟机中安装你的操作系统 🖥️

在本节课中,我们将学习如何将你编写的操作系统安装到虚拟机中。这样做的好处是,你无需在每次修改代码后都重启你的物理计算机,从而极大地提高开发效率。

上一节我们介绍了如何编写一个简单的内核并生成可引导的磁盘镜像。本节中我们来看看如何配置一个虚拟机来运行这个镜像。

准备工作

首先,你需要安装一个虚拟机软件。本教程将使用 VirtualBox 作为示例。同时,我们还需要 GRUB 引导加载器来帮助我们创建一个可引导的CD镜像。

修改 Makefile 以生成 ISO 镜像

为了在虚拟机中运行,我们需要将内核打包成一个可引导的CD镜像(ISO文件)。以下是修改 Makefile 的步骤。

首先,我们添加一个目标来创建ISO镜像。这个目标依赖于编译好的内核文件。

mykernel.iso: mykernel.bin

接下来,我们需要为ISO镜像创建正确的目录结构。这与我们之前使用GRUB引导时的结构类似。

以下是创建目录结构和配置文件的步骤:

  1. 创建一个临时工作目录 iso
  2. iso 目录下创建子目录 boot/grub
  3. 将编译好的内核文件 mykernel.bin 复制到 iso/boot/ 目录下。
  4. iso/boot/grub/ 目录下手动创建一个 grub.cfg 配置文件。

grub.cfg 文件的内容非常简单,因为我们只有一个启动项。

set timeout=0
set default=0

menuentry "My Operating System" {
    multiboot /boot/mykernel.bin
    boot
}

配置完成后,我们使用 grub-mkrescue 命令将这个目录打包成ISO镜像文件。之后,可以删除临时的 iso 目录。

mykernel.iso: mykernel.bin
    mkdir -p iso/boot/grub
    cp mykernel.bin iso/boot/
    echo 'set timeout=0' > iso/boot/grub/grub.cfg
    echo 'set default=0' >> iso/boot/grub/grub.cfg
    echo 'menuentry "My Operating System" {' >> iso/boot/grub/grub.cfg
    echo '    multiboot /boot/mykernel.bin' >> iso/boot/grub/grub.cfg
    echo '    boot' >> iso/boot/grub/grub.cfg
    echo '}' >> iso/boot/grub/grub.cfg
    grub-mkrescue -o mykernel.iso iso
    rm -rf iso

在 VirtualBox 中创建并运行虚拟机

现在,我们有了 mykernel.iso 镜像文件,可以在VirtualBox中创建一个新的虚拟机来运行它。

  1. 打开VirtualBox,点击“新建”。
  2. 为虚拟机命名(例如“MyOS”),类型选择“Other”,版本选择“Unknown/Other”。
  3. 为虚拟机分配少量内存(例如64MB),这已经足够我们的简单内核运行。
  4. 在创建虚拟硬盘的步骤中,选择“不添加虚拟硬盘”,因为我们目前只需要从ISO镜像启动。
  5. 创建完成后,选中新虚拟机,点击“设置”。
  6. 在“存储”设置中,为“控制器: IDE”添加一个光驱,并选择我们生成的 mykernel.iso 文件。
  7. 启动虚拟机,你应该能看到内核输出的“Hello, Kernel World!”信息。

优化 Makefile:添加一键运行命令

为了进一步提升开发体验,我们可以在 Makefile 中添加一个 run 目标,让它自动完成编译、生成ISO并启动虚拟机的全过程。

这个目标需要先构建 mykernel.iso,然后启动VirtualBox。为了避免重复启动虚拟机导致冲突,我们会在启动新实例前尝试终止可能正在运行的旧虚拟机进程。

run: mykernel.iso
    killall VirtualBoxVM || true
    sleep 1
    VirtualBox --startvm "My Operating System" &

现在,你只需要在终端中输入 make run,Make工具就会处理所有步骤,并自动在虚拟机中运行你的操作系统。

引入精确数据类型

在结束本节之前,我们需要讨论一个与硬件通信相关的重要预备知识:数据类型的精确性

当软件内部通信时,编译器会处理 intshort 等类型的具体大小。但当操作系统内核与硬件直接通信时,必须精确知道每一个数据占用的字节数。例如,汇编代码可能期望一个4字节的整数,如果C编译器认为 int 是8字节,就会导致错误。

为了解决这个问题,常见的做法是定义一个专用的头文件来确保数据类型的宽度。

我们创建一个 types.h 文件:

#ifndef TYPES_H
#define TYPES_H

typedef char int8_t;
typedef unsigned char uint8_t;

typedef short int16_t;
typedef unsigned short uint16_t;

typedef int int32_t;
typedef unsigned int uint32_t;

typedef long long int64_t;
typedef unsigned long long uint64_t;

#endif

然后,在内核源码(例如 kernel.cpp)中,我们不再使用原生的 intunsigned int,而是使用这些明确指定大小的类型,如 uint32_t

#include “types.h”

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/c36e8c7c75eaec6465c53d8b9aaa7537_35.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/c36e8c7c75eaec6465c53d8b9aaa7537_36.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/c36e8c7c75eaec6465c53d8b9aaa7537_37.png)

// 之前: unsigned int magic_number;
// 之后:
uint32_t magic_number;

这样,无论在哪一种编译器下,uint32_t 都明确表示一个32位无符号整数,保证了与硬件或其他底层代码交互时的精确性。

总结

本节课中我们一起学习了如何将操作系统内核部署到虚拟机中。我们修改了 Makefile 来自动生成可引导的ISO镜像,配置了VirtualBox虚拟机来运行它,并添加了一键运行的便捷命令。最后,我们引入了精确数据类型的概念,为下一节与硬件通信打下了重要基础。

下一节,我们将开始探索操作系统开发中一个关键且具有挑战性的部分:与硬件通信(例如读取键盘输入)。虽然初始设置较为复杂,但一旦打通这个环节,后续添加鼠标、显示器等支持就会变得相对容易。

004:内存段与全局描述符表

在本节课中,我们将学习操作系统中的内存分段概念,并动手创建一个全局描述符表。这是实现硬件通信(例如处理键盘输入)前必须完成的关键步骤。

概述:为什么需要内存段?

上一节我们实现了向屏幕输出字符。与硬件通信,发送数据相对简单,但接收数据(例如处理键盘输入)则复杂得多。在开始处理硬件中断之前,我们首先需要理解内存分段。

想象你的内存空间。这里运行着你的内核代码和数据,那里可能运行着用户程序。几年前,一种常见的攻击方式是让程序将恶意代码加载到数据段,然后跳转执行它。现代操作系统的防护机制之一,就是告诉处理器:数据段是不可执行的。这有效消除了此类安全威胁。

此外,操作系统通过内存段实现了权限隔离。内核运行在具有高权限的“内核空间”,而用户程序运行在权限受限的“用户空间”。

中断与段切换

现在,假设处理器正在用户空间执行代码。此时,你按下了键盘上的一个键。键盘控制器会向CPU发送一个中断信号。CPU需要暂停当前工作,跳转到处理键盘中断的内核代码处。

但问题来了:CPU当前处于用户空间,其访问权限被限制在用户内存段内,它“看不到”也“跳不到”内核空间的代码。为了解决这个问题,我们需要设置一个中断描述符表。这个表可以告诉CPU:“当发生键盘中断时,请切换到内核的内存段,并跳转到这个地址执行。”

然而,在创建IDT之前,我们必须先定义这些内存段是什么。这就是全局描述符表的作用。

全局描述符表简介

GDT是一个表,其中的每一项都定义了一个内存段。每个段描述符主要包含以下信息:

  • 基地址:段在内存中的起始位置。
  • 段界限:段的长度。
  • 标志位:描述段的属性,例如是代码段(可执行)还是数据段(不可执行),以及访问权限等。

从概念上看,这并不复杂。但实际实现却颇具挑战,因为GDT的条目格式为了向后兼容早期的处理器而变得非常复杂。

GDT条目结构解析

一个GDT条目长度为8字节,其结构分散且不直观:

  • 段界限被拆分存放在3个地方。
  • 基地址被拆分存放在4个地方。
  • 标志位其他属性填充在剩余位置。

这种布局对程序员不友好,我们必须手动进行位操作来组装和解析这些条目。

以下是描述一个GDT条目结构的类定义概要:

class GlobalDescriptorTable {
public:
    class SegmentDescriptor {
    private:
        uint16_t limit_lo;
        uint16_t base_lo;
        uint8_t base_hi;
        uint8_t type;
        uint8_t flags_limit_hi;
        uint8_t base_vhi;
    public:
        SegmentDescriptor(uint32_t base, uint32_t limit, uint8_t type);
        uint32_t Base();
        uint32_t Limit();
    } __attribute__((packed));
private:
    SegmentDescriptor nullSegmentSelector;
    SegmentDescriptor unusedSegmentSelector;
    SegmentDescriptor codeSegmentSelector;
    SegmentDescriptor dataSegmentSelector;
public:
    GlobalDescriptorTable();
    ~GlobalDescriptorTable();
    uint16_t CodeSegmentSelector();
    uint16_t DataSegmentSelector();
};

注意 __attribute__((packed)) 确保编译器不会为了内存对齐而改变结构体布局,这对硬件交互至关重要。

实现GDT

接下来我们看看GDT构造函数的核心实现逻辑。它需要处理棘手的界限计算和字段分布。

构造函数接收32位的基地址和界限,但硬件只允许在条目中存储20位的界限值。为了支持更大的段,当界限值的低12位不全为1时,需要进行调整:先将界限右移12位(相当于除以4096),然后减1,最后将条目中的低12位设为全1。这样,处理器在解析时会自动将其左移12位,从而还原出接近原始值的大段界限。

以下是分布基地址和界限到各字节的简化逻辑:

// 将 limit 的低16位放入 limit_lo
limit_lo = limit & 0xFFFF;
// 将 limit 的16-19位放入 flags_limit_hi 的低4位
flags_limit_hi |= (limit >> 16) & 0x0F;

// 将 base 的低24位分布到 base_lo 和 base_hi
base_lo = base & 0xFFFF;
base_hi = (base >> 16) & 0xFF;
// 将 base 的最高8位放入 base_vhi
base_vhi = (base >> 24) & 0xFF;

Base()Limit() 方法则执行相反的操作,从分散的字节中重组出完整的基地址和界限值。

加载GDT到处理器

创建GDT实例后,我们需要告诉CPU使用它。这需要两步:

  1. 准备一个6字节的数据结构,前2字节是GDT大小减一,后4字节是GDT的起始地址。
  2. 执行一条特殊的汇编指令 lgdt 来加载这个结构。

在我们的代码中,这是在 GlobalDescriptorTable 构造函数中完成的。

当前实现与展望

目前,我们只定义了两个覆盖整个内存空间的段:一个代码段和一个数据段。这虽然简化了初期的开发,但并未实现真正的内存保护。安全性的提升将是后续课程的内容。

总结

本节课我们一起学习了内存分段的概念及其在操作系统安全中的重要性。我们详细剖析了全局描述符表条目的复杂结构,并成功实现了一个能够创建和加载GDT的C++类。这为下一步创建中断描述符表并最终实现与键盘等硬件的交互通信奠定了坚实的基础。

下一节,我们将着手创建IDT,真正的硬件通信即将开始。

005:硬件通信与端口 🖥️

在本节课中,我们将学习如何与计算机硬件进行通信。上一节我们介绍了操作系统开发的基础,本节中我们来看看与硬件交互的核心机制——端口。

概述

CPU与键盘、鼠标等硬件设备通信,需要通过一种称为“端口”的机制。端口是硬件设备的地址,CPU通过向特定端口发送或接收数据来与设备交互。本节将创建一个面向对象的端口类,用于封装这种通信,并改进我们的屏幕输出功能。

端口通信原理

当你在键盘上按下一个键时,一个信号会发送到可编程中断控制器。默认情况下,PIC会忽略这个信号。为了接收按键信息,我们必须通过端口告诉PIC不要忽略它。

因此,在与硬件进行双向通信之前,我们需要一种方法来发送和接收数据。中断(将在下一节讨论)会通知CPU何时有数据需要接收。

从技术上讲,CPU通过一个多路复用器和解复用器连接到不同的硬件。你可以向这个系统输入一个端口号,它就会将数据发送到或从该端口接收数据。例如,PIC的端口号是 0x20

在汇编语言中,使用 out 指令向端口发送数据。它接受两个参数:端口号和要发送的数据。然而,我们使用C++编程,需要一种更优雅的方式。

创建端口类

直接内联汇编代码调用 out 指令的方式不够面向对象。理想的设计是创建一个 Port 对象,它知道自己的端口号和带宽(8位、16位或32位),并提供 ReadWrite 方法。这样,使用者就无需关心底层细节。

以下是创建端口类的步骤:

  1. 创建基类Port 基类存储端口号,并定义纯虚的读写接口。
  2. 创建派生类:创建 Port8BitPort16BitPort32Bit 等派生类,实现特定带宽的读写操作。
  3. 处理慢速端口:某些8位端口写入后需要等待,因此可以创建一个 Port8BitSlow 类来继承 Port8Bit 并重写 Write 方法,在写入后添加空操作指令以延迟。

代码实现

首先,创建头文件 port.h

#ifndef PORT_H
#define PORT_H

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_1.png)

#include “types.h”

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_3.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_5.png)

class Port {
protected:
    Port(uint16_t portnumber);
    ~Port();
    uint16_t portnumber;
};

class Port8Bit : public Port {
public:
    Port8Bit(uint16_t portnumber);
    ~Port8Bit();
    virtual void Write(uint8_t data);
    virtual uint8_t Read();
};

class Port8BitSlow : public Port8Bit {
public:
    Port8BitSlow(uint16_t portnumber);
    ~Port8BitSlow();
    virtual void Write(uint8_t data);
};

class Port16Bit : public Port {
public:
    Port16Bit(uint16_t portnumber);
    ~Port16Bit();
    void Write(uint16_t data);
    uint16_t Read();
};

class Port32Bit : public Port {
public:
    Port32Bit(uint16_t portnumber);
    ~Port32Bit();
    void Write(uint32_t data);
    uint32_t Read();
};

#endif

接着,在 port.cpp 中实现这些类:

#include “port.h”

Port::Port(uint16_t portnumber) {
    this->portnumber = portnumber;
}
Port::~Port() {}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_7.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_9.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_11.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_12.png)

Port8Bit::Port8Bit(uint16_t portnumber) : Port(portnumber) {}
Port8Bit::~Port8Bit() {}
void Port8Bit::Write(uint8_t data) {
    __asm__ volatile(“outb %0, %1” : : “a”(data), “Nd”(portnumber));
}
uint8_t Port8Bit::Read() {
    uint8_t result;
    __asm__ volatile(“inb %1, %0” : “=a”(result) : “Nd”(portnumber));
    return result;
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_14.png)

Port8BitSlow::Port8BitSlow(uint16_t portnumber) : Port8Bit(portnumber) {}
Port8BitSlow::~Port8BitSlow() {}
void Port8BitSlow::Write(uint8_t data) {
    __asm__ volatile(“outb %0, %1\njmp 1f\n1: jmp 1f\n1:” : : “a”(data), “Nd”(portnumber));
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_16.png)

Port16Bit::Port16Bit(uint16_t portnumber) : Port(portnumber) {}
Port16Bit::~Port16Bit() {}
void Port16Bit::Write(uint16_t data) {
    __asm__ volatile(“outw %0, %1” : : “a”(data), “Nd”(portnumber));
}
uint16_t Port16Bit::Read() {
    uint16_t result;
    __asm__ volatile(“inw %1, %0” : “=a”(result) : “Nd”(portnumber));
    return result;
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/255810a0e158f70dcd8fb72cf29aa6ac_18.png)

Port32Bit::Port32Bit(uint16_t portnumber) : Port(portnumber) {}
Port32Bit::~Port32Bit() {}
void Port32Bit::Write(uint32_t data) {
    __asm__ volatile(“outl %0, %1” : : “a”(data), “Nd”(portnumber));
}
uint32_t Port32Bit::Read() {
    uint32_t result;
    __asm__ volatile(“inl %1, %0” : “=a”(result) : “Nd”(portnumber));
    return result;
}

最后,将 port.cpp 添加到 Makefile 的编译源文件中。

改进 Makefile:添加清理功能

为了方便开发,我们在 Makefile 中添加一个标准的 clean 目标,用于删除所有生成的目标文件和可执行文件。

clean:
    rm -f *.o
    rm -f mykernel.bin
    rm -f mykernel

运行 make clean 后,可以重新运行 make run 来构建一切。

改进屏幕输出:实现光标和换行

我们当前的 printf 函数有一个问题:每次调用都从屏幕左上角开始写入,会覆盖之前的内容。我们需要实现一个光标系统来跟踪下一个字符的写入位置。

屏幕是80字符宽、25行高。光标位置可以通过公式计算:

内存位置 = 80 * y + x

其中 x 是列号,y 是行号。

以下是改进 printf 的逻辑:

  1. 根据光标位置 (x, y) 计算视频内存地址。
  2. 写入字符后,x 增加。
  3. 如果 x >= 80,则执行换行:x = 0, y++
  4. 如果 y >= 25(屏幕已满),则清屏(将所有位置写入空格),并将光标重置到 (0, 0)
  5. 支持 \n 换行符:当遇到 \n 时,直接执行换行操作。

改进后的 printf 函数将表现得更加符合预期。

总结

本节课中我们一起学习了硬件通信的基础。我们创建了一个面向对象的端口类库,用于封装不同带宽的端口读写操作,这为后续与键盘等硬件交互打下了基础。同时,我们改进了 Makefile 并实现了带光标和换行功能的屏幕输出,使我们的操作系统雏形更加实用。下一节,我们将开始与可编程中断控制器通信,并通过中断真正接收来自硬件的数据。

006:中断处理 🛠️

在本节课中,我们将开始与硬件进行真正的交互,学习如何设置中断描述符表(IDT)来处理来自硬件(如键盘和定时器)的中断信号。这是操作系统能够响应外部事件的关键一步。

概述

上一节我们介绍了与硬件通信的基本概念。本节中,我们来看看如何通过设置中断描述符表来让CPU能够接收并处理硬件中断。我们将创建一个中断管理器,编写汇编处理程序,并最终配置可编程中断控制器(PIC)。

中断描述符表(IDT)的作用

当硬件(例如键盘)产生一个事件时,它会通过可编程中断控制器(PIC)向CPU发送一个中断信号。然而,计算机启动时,PIC默认不会将这些信息传递给CPU。我们需要先设置好中断描述符表,然后才能告诉PIC开始传递中断信息。

如果没有设置IDT,中断会导致一个通用保护错误,可能使计算机重启或虚拟机终止。

IDT中的每个条目(称为门描述符)需要包含以下信息:

  • 中断号:一个8位整数(uint8_t),用于标识是哪个中断(例如,键盘中断是1,定时器中断是0)。
  • 处理程序地址:一个指向内存中中断处理函数(handler)的指针(void*)。
  • 代码段选择子:告诉处理器在执行处理程序前切换到哪个内存段(例如,从用户空间切换到内核空间)。
  • 访问权限:一个0到3的数字,表示特权级别(0是内核空间,3是用户空间)。
  • 一些标志位

中断处理程序的挑战与设计

在高级语言(如C++)中,我们可能希望中断处理函数能直接接收中断号作为参数,例如 void handleInterrupt(uint8_t number)。但CPU无法直接做到这一点,因为它不能假设当前栈是否安全可用(例如,栈可能仍指向用户空间)。

因此,CPU无法将中断号压入栈。解决方案是为每一个中断号编写不同的处理程序代码。这样,中断0、中断1等都有各自独立的入口点。

这些处理程序最好用汇编语言编写,因为CPU在跳转到处理程序时处于不确定状态,而C++编译器生成的代码可能会改变寄存器状态,影响之前执行的代码。我们的设计是:

  1. 用汇编宏为每个中断生成一个独立的处理程序。
  2. 该处理程序将其中断号压栈,然后跳转到一个统一的C++函数。
  3. 在C++函数中,我们可以进行更高级别的处理。

实现步骤

以下是实现中断处理机制的核心步骤。

1. 创建汇编中断处理桩

我们首先创建几个文件:interruptstubs.s(汇编桩代码),interrupts.h(头文件)和interrupts.cpp(C++实现)。

interrupts.h 中,我们定义一个 InterruptManager 类,它包含一个静态方法 handleInterrupt。这个方法目前只是接收中断号和当前栈指针,并原样返回栈指针(为后续的任务切换做准备)。

// interrupts.h 示例片段
class InterruptManager {
public:
    static uint32_t handleInterrupt(uint8_t interruptNumber, uint32_t stackPointer);
};

2. 编写汇编宏生成处理程序

interruptstubs.s 中,我们编写一个汇编宏 INTERRUPT_REQUEST,它为每个中断号生成特定的处理程序标签(如 InterruptRequest0x00)。

每个生成的处理程序会:

  • 保存所有寄存器的值。
  • 将其对应的中断号移入一个特定变量。
  • 跳转到统一的 interrupt_common 桩代码。

interrupt_common 桩代码会:

  • 将中断号和当前栈指针压栈。
  • 调用C++函数 InterruptManager::handleInterrupt
  • 用函数的返回值(可能是一个新的栈指针)更新栈指针。
  • 恢复所有寄存器的值。
  • 执行 iret 指令从中断返回。
; interruptstubs.s 示例片段(NASM语法)
%macro INTERRUPT_REQUEST 1
global InterruptRequest%1
InterruptRequest%1:
    mov byte [interruptNumber], %1 + IRQ_BASE ; IRQ_BASE 通常为 0x20
    jmp interrupt_common
%endmacro

interrupt_common:
    ; 保存所有寄存器...
    pushad
    ; ...
    ; 调用C++处理函数
    call InterruptManager_handleInterrupt
    ; 恢复所有寄存器...
    popad
    ; ...
    iret

3. 构建中断描述符表(IDT)

InterruptManager 类中,我们定义一个描述IDT条目的结构体 GateDescriptor,并创建一个包含256个条目的数组。

// interrupts.h 示例片段
struct GateDescriptor {
    uint16_t handlerAddressLowBits;
    uint16_t gdt_codeSegmentSelector;
    uint8_t reserved;
    uint8_t accessRights;
    uint16_t handlerAddressHighBits;
} __attribute__((packed));

构造函数会初始化这个表:

  1. 将所有条目默认设置为一个“忽略中断”的处理程序,以防止未处理的中断导致系统崩溃。
  2. 为特定的中断(如0x20, 0x21)设置正确的处理程序地址、代码段选择子和访问权限(内核特权级,中断门类型)。

4. 加载IDT并配置PIC

创建并填充IDT后,我们需要告诉CPU使用它。这通过 lidt 汇编指令完成。

// interrupts.cpp 示例片段
struct InterruptDescriptorTablePointer {
    uint16_t size;
    uint32_t base;
} __attribute__((packed));

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/37d40cf8bcd5725d9e95d729ea0e833b_37.png)

InterruptDescriptorTablePointer idtPointer;
idtPointer.size = sizeof(GateDescriptor) * 256 - 1;
idtPointer.base = (uint32_t)idt;
asm volatile("lidt (%0)" : : "r" (&idtPointer));

仅仅设置IDT还不够,我们还需要告诉可编程中断控制器(PIC)开始向CPU发送中断。计算机通常有一个主PIC和一个从PIC。我们需要通过特定的端口(0x20, 0x21等)向它们发送初始化命令字(ICW),包括设置中断号的偏移量(例如,让主PIC的中断从0x20开始),以避免与CPU内部异常使用的中断号冲突。

// 初始化PIC的示例代码
Port8Bit masterCommandPort(0x20);
Port8Bit masterDataPort(0x21);
// ... 初始化从PIC端口

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/37d40cf8bcd5725d9e95d729ea0e833b_41.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/37d40cf8bcd5725d9e95d729ea0e833b_43.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/37d40cf8bcd5725d9e95d729ea0e833b_44.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/37d40cf8bcd5725d9e95d729ea0e833b_45.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/37d40cf8bcd5725d9e95d729ea0e833b_46.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/37d40cf8bcd5725d9e95d729ea0e833b_47.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/37d40cf8bcd5725d9e95d729ea0e833b_48.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/37d40cf8bcd5725d9e95d729ea0e833b_49.png)

// 发送初始化命令序列
masterCommandPort.Write(0x11); // 初始化开始
masterDataPort.Write(0x20);    // 主PIC中断向量偏移
// ... 其他配置

5. 启用中断

最后,我们通过 sti 汇编指令启用CPU的中断响应。通常,我们会在所有硬件初始化完成、IDT准备就绪后,再调用这个启用中断的方法。

void InterruptManager::Activate() {
    asm volatile("sti");
}

测试与下一步

完成以上步骤后编译运行,如果看到收到了第一个硬件中断(很可能是定时器中断),就证明我们的IDT和PIC配置成功了。目前我们只收到一次中断,因为还没有告诉PIC中断处理已经完成(需要发送“中断结束”EOI命令)。这将是下一节课的内容。

总结

本节课中我们一起学习了操作系统中断处理的核心机制。我们了解了中断描述符表的结构与作用,设计了通过汇编桩代码与C++函数协作的中断处理流程,并实现了IDT的构建、加载以及PIC的初始化配置。现在,我们的操作系统已经具备了接收硬件中断信号的基础能力。下一节,我们将学习如何应答中断,并开始与键盘进行实际交互,获取真实的按键信息。

007:键盘驱动 🎹

在本节课中,我们将学习如何响应硬件中断,并实现一个键盘驱动程序,使我们能够从键盘接收输入并在屏幕上显示出来。

概述

上一节我们成功建立了CPU与可编程中断控制器(PIC)的连接,并接收到了来自硬件时钟的第一个中断。然而,系统在接收到中断后停止了运行,因为我们没有向PIC发送中断处理完成的确认信号。本节中,我们将解决这个问题,并构建一个面向对象的键盘驱动框架。

从静态函数回到对象

我们面临的问题是,当硬件中断发生时,CPU会从C++世界跳转到汇编代码,最终进入一个静态的C++处理函数。为了利用面向对象的特性,我们需要从这个静态函数回到InterruptManager对象实例中,以便访问与PIC通信的端口。

以下是解决方案:我们在interrupts.h中定义一个指向当前活动中断管理器的静态指针。

class InterruptManager {
    ...
    static InterruptManager* ActiveInterruptManager;
    ...
};

interrupts.cpp中初始化这个静态指针,并在InterruptManagerActivateDeactivate方法中设置它。

InterruptManager* InterruptManager::ActiveInterruptManager = 0;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/97221e766c222cd97bf1e14d0769de1c_10.png)

InterruptManager::InterruptManager(...) {
    ...
    if(ActiveInterruptManager != 0)
        ActiveInterruptManager->Deactivate();
    ActiveInterruptManager = this;
    ...
}

现在,在汇编代码调用的静态处理函数HandleInterrupt中,我们可以通过这个静态指针调用对象实例的DoHandleInterrupt方法。

extern "C" void HandleInterrupt(...) {
    if(InterruptManager::ActiveInterruptManager != 0) {
        InterruptManager::ActiveInterruptManager->DoHandleInterrupt(...);
    }
}

这样,我们就成功地从静态环境回到了对象实例中,可以访问PIC的端口了。

向PIC发送中断结束信号

DoHandleInterrupt方法中,我们现在可以向PIC发送中断处理完成的确认信号(EOI - End Of Interrupt)。这是通过向PIC的命令端口(主PIC为0x20,从PIC为0xA0)写入特定值(0x20)来实现的。

我们需要为所有硬件中断(我们已将其重映射到0x20到0x2F)发送EOI。如果中断号大于等于0x28(即来自从PIC的中断),我们需要同时向主PIC和从PIC发送EOI。

void InterruptManager::DoHandleInterrupt(...) {
    // ... 处理中断 ...

    // 发送EOI给PIC
    if(0x20 <= interruptNumber && interruptNumber < 0x30) {
        commandPort.Write(0x20); // 主PIC EOI
        if(0x28 <= interruptNumber)
            commandPort.Write(0x20); // 从PIC EOI
    }
}

完成这一步后,系统将能够持续接收和处理中断,而不会停止。

创建中断处理器基类

为了以面向对象的方式处理不同的硬件中断(如键盘、鼠标),我们创建一个InterruptHandler基类。每个具体的驱动程序(如键盘驱动)都将继承自这个类。

以下是InterruptHandler基类的定义:

class InterruptHandler {
protected:
    InterruptManager* interruptManager;
    uint8_t interruptNumber;

    InterruptHandler(InterruptManager* manager, uint8_t num);
    ~InterruptHandler();
public:
    virtual uint32_t HandleInterrupt(uint32_t esp);
};

InterruptManager类需要维护一个InterruptHandler指针数组。在构造函数中,InterruptHandler将自己注册到管理器的这个数组中。在DoHandleInterrupt方法中,管理器会查找并调用对应中断号的处理器的HandleInterrupt方法。

// 在 InterruptManager::DoHandleInterrupt 中
if(handlers[interruptNumber] != 0) {
    esp = handlers[interruptNumber]->HandleInterrupt(esp);
} else {
    // 打印未处理的中断信息
}

实现键盘驱动程序

现在我们可以创建具体的键盘驱动类KeyboardDriver,它继承自InterruptHandler

键盘使用两个I/O端口:

  • 数据端口0x60,用于读取按键扫描码。
  • 命令端口0x64,用于向键盘控制器发送命令。

以下是KeyboardDriver构造函数的关键步骤,用于初始化和激活键盘:

KeyboardDriver::KeyboardDriver(InterruptManager* manager)
    : InterruptHandler(manager, 0x21), // 键盘中断号为0x21
      dataPort(0x60),
      commandPort(0x64) {

    // 清空可能存在的旧按键数据
    while(dataPort.Read() & 0x1);
    // 激活键盘中断
    commandPort.Write(0xAE); // 激活中断
    commandPort.Write(0x20); // 获取当前状态
    uint8_t status = (dataPort.Read() | 1) & ~0x10;
    commandPort.Write(0x60); // 设置状态
    dataPort.Write(status);
}

HandleInterrupt方法中,我们从数据端口读取扫描码,并将其转换为可显示的字符。

uint32_t KeyboardDriver::HandleInterrupt(uint32_t esp) {
    uint8_t key = dataPort.Read();

    // 忽略某些状态码和NumLock等特殊键
    if(key < 0x80) {
        switch(key) {
            case 0x1E: // 'A' 键的扫描码(示例,因键盘布局而异)
                printf("a");
                break;
            case 0x15: // 'Z' 键的扫描码(在德语键盘上是Y)
                printf("z");
                break;
            // ... 为其他键添加更多case ...
            default:
                printf("KEYBOARD 0x%X", key);
                break;
        }
    }
    return esp;
}

请注意:从键盘读取的原始扫描码与物理按键对应,而不是字符。不同布局的键盘(如美式、德式)对同一扫描码的解释不同。因此,你需要根据你的键盘布局创建一个完整的扫描码到字符的映射表。

在真实硬件上运行

一个重要的注意事项是,本教程中使用的PS/2风格键盘通信方式,在现代计算机上可能无法直接工作,因为大多数现代键盘通过USB连接。

  • 在虚拟机(如VirtualBox)中,虚拟硬件模拟了PS/2接口,因此代码可以正常工作。
  • 在真实硬件上,你可能需要在BIOS/UEFI设置中启用“Legacy USB Support”或“USB Keyboard Emulation”等选项。该选项会让主板芯片组将USB键盘模拟成PS/2键盘,从而使你的操作系统能够识别。
  • 未来,当我们实现USB驱动后,才能原生支持USB键盘。

总结

本节课中我们一起学习了操作系统开发的关键一步:实现硬件中断的完整处理流程和键盘输入。

  1. 中断确认:我们解决了上一节遗留的问题,学会了在中断处理完成后必须向PIC发送EOI信号,否则系统将无法接收后续中断。
  2. 面向对象设计:通过引入静态指针和InterruptHandler基类,我们将中断处理从静态函数迁移到了灵活、可扩展的面向对象框架中。
  3. 键盘驱动:我们创建了KeyboardDriver类,它通过读写特定I/O端口与键盘控制器通信,将原始的按键扫描码转换为字符输出到屏幕。
  4. 现实挑战:我们了解了扫描码映射的复杂性以及USB键盘在真实硬件上带来的兼容性挑战。

至此,最复杂的基础设施(引导加载程序、GDT、IDT、汇编胶水代码)已经搭建完毕。从下一节开始,我们将基于这个稳固的基础,更快地实现更多功能(例如鼠标驱动),真正开始构建操作系统的上层功能。

008:鼠标驱动 🖱️

在本节课中,我们将学习如何为我们的操作系统添加鼠标支持。鼠标与键盘类似,都是通过中断与系统通信。我们将了解如何接收鼠标数据包、解析鼠标移动和按键信息,并在屏幕上显示一个简单的光标。

上一节我们介绍了键盘驱动,本节中我们来看看如何实现鼠标驱动。

准备工作与键盘功能完善

在开始鼠标驱动之前,我们先回顾一下键盘功能的改进。为了处理大小写字母,我们添加了一个静态布尔变量 shift 来跟踪 Shift 键的状态。

以下是关键代码逻辑:

static bool shift = false;
// ... 在键盘中断处理中 ...
case 0x2A: // 左Shift按下
case 0x36: // 右Shift按下
    shift = true;
    break;
case 0xAA: // 左Shift释放
case 0xB6: // 右Shift释放
    shift = false;
    break;
case 0x1E: // 'A' 键
    if(shift) {
        // 输出大写 'A'
    } else {
        // 输出小写 'a'
    }
    break;

此外,为了避免编译器警告,我们在 Makefile 的 GCC 参数中添加了 -Wno-builtin-declaration-mismatch

鼠标驱动概述

鼠标驱动与键盘驱动非常相似。主要区别在于,鼠标每次事件(移动或按键)会发送一个由三个字节组成的数据包。我们需要完整接收这三个字节才能正确解析鼠标状态。

我们将创建一个三字节的缓冲区来存储数据包,并使用一个偏移量来跟踪当前接收的是第几个字节。

实现鼠标驱动

首先,我们创建鼠标驱动的头文件,其结构与键盘驱动类似。

以下是鼠标驱动的核心数据结构:

class MouseDriver : public InterruptHandler {
    Port8Bit dataPort;
    Port8Bit commandPort;
    uint8_t buffer[3];
    uint8_t offset;
    uint8_t buttons;
    int8_t x, y;
public:
    MouseDriver(InterruptManager* manager);
    ~MouseDriver();
    virtual uint32_t HandleInterrupt(uint32_t esp);
};

初始化鼠标

在构造函数中,我们需要初始化鼠标并启用中断。

以下是初始化步骤:

  1. 向命令端口写入 0xA8 以启用鼠标。
  2. 向命令端口写入 0x20 请求当前状态。
  3. 读取数据端口,将第二位(0x02)置为1以启用鼠标中断。
  4. 将新状态写回命令端口 0x60
  5. 向命令端口写入 0xD4 后,再向数据端口写入 0xF4 以激活鼠标。

处理鼠标中断

鼠标使用中断号 0x2C。在中断处理程序中,我们需要读取状态端口,检查是否有数据可读(状态的第6位为1)。

以下是中断处理流程:

  1. 从数据端口读取一个字节,存入 buffer[offset]
  2. 偏移量 offset 加1,对3取模,确保在0、1、2之间循环。
  3. 当完成一个数据包的接收(即 offset 再次为0)时,解析 buffer 中的三个字节。

解析鼠标数据包

一个完整的三字节数据包包含以下信息:

  • 字节0 (buffer[0]): 按键状态和标志位。
  • 字节1 (buffer[1]): X轴移动量(有符号)。
  • 字节2 (buffer[2]): Y轴移动量(有符号,方向与直觉相反)。

以下是解析和更新光标位置的代码:

if(offset == 0) {
    // 恢复旧光标位置的颜色
    // ...

    // 更新光标位置
    x += buffer[1];
    y -= buffer[2]; // Y轴方向相反

    // 限制光标在屏幕范围内 (0-79, 0-24)
    if(x < 0) x = 0;
    if(x >= 80) x = 79;
    if(y < 0) y = 0;
    if(y >= 25) y = 24;

    // 在新光标位置翻转颜色以显示光标
    // ...

    // 处理按键状态变化
    for(uint8_t i = 0; i < 3; i++) {
        if((buffer[0] & (1<<i)) != (buttons & (1<<i))) {
            // 第 i 个按键状态发生了变化
            // 可以在这里添加按键处理逻辑
        }
    }
    // 更新旧的按键状态
    buttons = buffer[0];
}

在屏幕上显示光标

我们通过翻转屏幕上光标所在位置字符的前景色和背景色来模拟光标。

以下是翻转颜色的公式:

// 假设 videoMemory 是 uint16_t* 类型,指向 0xB8000
uint16_t* location = videoMemory + (80 * y + x);
uint8_t character = (*location) & 0xFF; // 低字节是字符
uint8_t attribute = (*location >> 8) & 0xFF; // 高字节是属性

// 翻转前景色和背景色
uint8_t newAttribute = ((attribute & 0xF0) >> 4) | ((attribute & 0x0F) << 4);

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/3e7b57a65ebc69688a6a55c003bd59af_40.png)

// 写回视频内存
*location = character | (newAttribute << 8);

注意事项与调试技巧

在实现过程中,你可能会遇到鼠标行为异常的问题。一个常见的原因是数据包接收的起始偏移量 offset 不正确。它可能不是从0开始。

如果鼠标行为异常,首先尝试将 offset 的初始值在0、1、2之间切换。这是一个快速的调试方法。

另外,在虚拟机中运行时,鼠标可能默认被主机捕获。你需要按键盘右侧的 Ctrl 键将鼠标控制权释放给客户操作系统。

代码结构与设计思考

目前,我们在鼠标驱动中断处理程序中直接操作了视频内存。这在设计上是不好的,因为它将硬件驱动与具体的用户界面逻辑紧密耦合。

更好的设计是创建一个 MouseEventHandler 类,其中包含诸如 OnMouseMoveOnButtonDownOnButtonUp 等虚方法。然后,在鼠标驱动中持有该处理器的一个指针或引用,并在适当的时候调用这些方法。这样可以将输入事件的处理逻辑与底层驱动分离开来。

总结与展望

本节课中我们一起学习了如何为操作系统实现鼠标驱动。我们了解了鼠标数据包的格式,学会了如何接收和解析鼠标的移动与按键信息,并在屏幕上实现了一个简单可见的光标。

至此,我们的操作系统已经具备了基本的人机交互能力:可以启动、向屏幕输出、接收和处理中断、响应键盘输入以及跟踪鼠标。

在接下来的课程中,我们将暂时停下功能开发,转而整理项目结构,使其更清晰、更易于扩展,为后续实现更复杂的功能(如网络、图形模式等)打下坚实的基础。

009:驱动程序的抽象 🚀

在本节课中,我们将对项目进行整理和抽象。首先,我们将为驱动程序创建一个通用的基类和管理器,以简化代码结构。接着,我们将重构键盘和鼠标驱动程序的输出逻辑,使其更加模块化和可扩展。

项目整理的必要性

随着项目代码量的增加,代码结构会变得越来越混乱。为了避免未来难以维护,我们需要尽早进行整理。本节中,我们将首先关注驱动程序的抽象。

创建驱动程序基类

上一节我们介绍了具体的键盘和鼠标驱动程序。本节中,我们来看看如何为它们创建一个通用的基类。

我们首先创建两个新文件:driver.hdriver.cpp。在基类中,我们将定义几个核心方法。

以下是驱动程序基类的核心方法:

class Driver {
public:
    Driver();
    ~Driver();

    virtual void Activate();
    virtual int Reset();
    virtual void Deactivate();
};
  • Activate() 方法用于激活硬件。
  • Reset() 方法用于重置硬件,并返回需要等待的毫秒数,以确保硬件进入已知状态。
  • Deactivate() 方法用于停用硬件。

实现驱动程序管理器

为了统一管理所有驱动程序,我们需要一个驱动程序管理器。由于目前还没有动态内存管理,我们将使用一个固定长度的数组来存储驱动程序指针。

以下是驱动程序管理器的核心结构:

class DriverManager {
private:
    Driver* drivers[256];
    int numDrivers;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/92dddb41e74c356cbdf24295a45e4617_6.png)

public:
    DriverManager();
    void AddDriver(Driver* driver);
    void ActivateAll();
};

驱动程序管理器将负责存储所有驱动程序实例,并在系统启动时统一激活它们。

集成键盘和鼠标驱动程序

现在,我们需要让键盘驱动程序和鼠标驱动程序继承自这个新的 Driver 基类。

以下是集成步骤:

  1. 修改键盘和鼠标驱动程序的类定义,使其继承自 Driver
  2. 将硬件初始化的代码移动到基类的 Activate() 方法中。
  3. 在驱动程序管理器中添加键盘和鼠标驱动程序的实例。

完成集成后,内核只需调用驱动程序管理器的 ActivateAll() 方法,即可按顺序初始化所有硬件。

重构输出逻辑

目前,键盘驱动程序直接将按键打印到屏幕,这不利于未来的扩展(例如切换到图形模式)。我们将采用面向对象的方式处理这个问题。

我们将创建一个 KeyboardEventHandler 类,用于处理键盘事件。

以下是事件处理器的核心结构:

class KeyboardEventHandler {
public:
    KeyboardEventHandler();
    virtual void OnKeyDown(char c);
    virtual void OnKeyUp(char c);
};

键盘驱动程序将持有一个事件处理器的指针。当按键事件发生时,驱动程序不再直接打印,而是调用事件处理器的对应方法(如 OnKeyDown)。默认的事件处理器可以什么都不做。如果我们希望恢复打印功能,可以创建一个派生类并重写这些方法。

对鼠标驱动程序进行同样的重构

我们将对鼠标驱动程序进行类似的重构,创建一个 MouseEventHandler 类。

以下是鼠标事件处理器的核心方法:

class MouseEventHandler {
public:
    MouseEventHandler();
    virtual void OnMouseMove(int xOffset, int yOffset);
    virtual void OnMouseButtonDown(uint8_t button);
    virtual void OnMouseButtonUp(uint8_t button);
};

鼠标坐标等状态信息应该存储在事件处理器中,而不是驱动程序里。这样,驱动程序只负责报告移动偏移量和按钮状态,由事件处理器决定如何响应。

测试与验证

完成上述重构后,我们进行测试以确保所有功能正常工作。键盘按键和鼠标移动应能通过新的事件处理器正确响应。

总结

本节课中我们一起学习了如何对操作系统项目进行代码抽象和整理。我们创建了通用的驱动程序基类和管理器,使硬件初始化更加有序。更重要的是,我们重构了键盘和鼠标的输入处理逻辑,引入了事件处理器模式,将具体的业务逻辑(如屏幕打印)与底层驱动分离,极大地提高了代码的模块化和可扩展性,为未来实现图形界面等功能打下了良好的基础。在下一节课中,我们将进一步整理项目目录结构并引入命名空间。

010:整理项目结构 📁

在本节课中,我们将学习如何整理操作系统的项目结构。我们将创建清晰的目录,将源代码、头文件和目标文件分别存放,并使用命名空间来组织代码。这能让项目随着规模增长而保持整洁,避免混乱。

上一节我们介绍了中断处理程序的实现,本节中我们来看看如何优化项目的组织结构。

创建目录结构

首先,我们需要创建三个主要目录来存放不同类型的文件。

以下是需要创建的目录及其用途:

  • source:用于存放所有的 .cpp 源代码文件。
  • include:用于存放所有的 .h 头文件。
  • object:用于存放编译过程中生成的 .o 目标文件。

此外,在 include 目录下,我们还将创建更细分的子目录来对应不同的功能模块。

以下是 include 目录下的子目录规划:

  • common:存放通用类型定义和基础工具。例如,types.h 文件将放在这里。未来也可以存放类似标准库中的 iostreamvectormap 等通用组件。
  • drivers:存放设备驱动相关的类。例如,驱动基类、键盘驱动和鼠标驱动。
  • hardwarecommunication:存放底层硬件通信相关的代码。这类似于网络架构中的底层协议,负责与硬件进行原始数据层面的交互。

这种分层结构(hardwarecommunication -> drivers)类似于网络模型,底层负责原始通信,上层驱动则解释设备使用的“语言”。

移动文件并修改代码

创建好目录后,我们需要将现有的文件移动到对应的位置,并更新代码以反映新的结构。

主要修改包括以下三个方面:

  1. 移动文件:将所有 .cpp 文件移至 source 目录,将所有 .h 文件根据其功能移至 include 下的相应子目录。
  2. 更新包含指令:在头文件中,将 #ifndef 等条件编译指令的宏名称加上目录名前缀,以避免命名冲突。例如,types.h 中的宏可能从 TYPES_H 改为 COMMON_TYPES_H
  3. 添加命名空间:为类添加对应的命名空间,命名空间名称通常与所在目录名一致。例如,hardwarecommunication 目录下的类将放入 myos::hardwarecommunication 命名空间。

完成文件移动和代码修改后,需要在文本编辑器中重新加载整个项目。

更新 Makefile

由于文件位置发生了变化,我们必须更新 Makefile 来适应新的目录结构。

以下是需要修改的 Makefile 关键部分:

  • 目标文件路径:将目标文件的输出路径从当前目录改为 object 目录。例如,规则可能从 kernel.o: kernel.cpp 改为 object/kernel.o: source/kernel.cpp
  • 创建目录:在编译规则前,添加创建 object 目录的指令,确保目录存在。可以使用 mkdir -p object 命令。
  • 清理命令:更新 clean 规则,改为直接删除整个 object 目录,而不是逐个删除 .o 文件。命令为 rm -rf object
  • 包含路径:在编译指令中添加 -I include 选项,告诉编译器在 include 目录中查找头文件。这也解释了为什么我们需要将头文件中的 #include “…” 改为 #include <…> 形式,以使用系统/指定的包含路径。

修复链接器错误

完成上述修改并尝试编译后,链接器可能会报错,提示某些中断处理函数(如 handleInterrupt)找不到。

这是因为我们为类添加了命名空间,导致编译器生成的内部函数名(mangled name)发生了变化。在汇编文件(如 interruptstubs.s)中,我们通过硬编码的函数名来调用这些 C++ 方法。

我们需要根据新的命名空间结构,更新汇编代码中的函数名。C++ 编译器生成的内部名称遵循特定格式:以 _Z 开头,然后是表示名称长度的数字和名称本身,最后是参数信息。

例如,对于 myos::hardwarecommunication::InterruptManager 类中的方法,其名称长度需要相应计算并更新。修改正确后,重新编译链接即可成功。

成果与展望

经过整理,我们的项目结构变得清晰、整洁,便于未来管理和扩展。我们将不同功能的代码归入不同的目录和命名空间,使得添加新的驱动程序或协议变得更加容易。

本节课中我们一起学习了如何构建一个清晰的操作系统项目目录结构,包括创建目录、移动文件、使用命名空间以及更新 Makefile 和修复由此引发的链接问题。

现在,我们拥有了一个良好的基础,可以在此基础上继续开发,例如在下一节中实现外围组件互连(PCI)总线驱动。整洁的项目结构将帮助我们更高效地组织越来越多的代码模块。

011:外设组件互连 (PCI) 🧩

概述

在本节课中,我们将要学习外设组件互连,即PCI。我们将了解为什么操作系统需要一种标准化的方式来发现和管理硬件设备,以及如何通过编程与PCI控制器通信来枚举系统中的所有设备。

为什么需要PCI?

上一节我们介绍了键盘和鼠标这类相对简单的设备,它们有固定的中断和端口。但对于像网卡、显卡这样的设备,情况就复杂得多。不同的计算机可能配备不同的硬件,甚至可能安装多个相同类型的设备。如果为每种设备都硬编码地址和中断,将无法适应多样的硬件配置。

因此,人们设计了PCI标准。它允许操作系统在启动时动态地查询系统中有哪些设备、它们位于何处以及使用哪些资源。这是一种非常巧妙的设计,使得内核能够自动配置,无需用户手动指定驱动或中断。

PCI的寻址结构

PCI控制器采用一种层次化的寻址方案来定位设备。

以下是其结构:

  • 总线:一个PCI控制器最多可以管理8条总线
  • 设备:每条总线上最多可以连接32个设备
  • 功能:每个设备内部可以包含最多8个功能。一个功能代表设备的一个独立逻辑单元。例如,一个声卡设备可能包含音频播放和音频捕获两个不同的功能。

因此,我们需要三个数字来唯一标识一个PCI功能:

  • 总线号:3位编码(0-7)
  • 设备号:5位编码(0-31)
  • 功能号:3位编码(0-7)

如何与PCI设备通信

要与PCI控制器通信,我们需要通过两个特定的I/O端口:

  • 命令端口:地址为 0xCF8。我们向这个端口写入一个32位的标识符,来指定我们想查询哪个总线、设备、功能的哪个寄存器。
  • 数据端口:地址为 0xCFC。写入命令后,我们可以从这个端口读取32位数据,或者向它写入数据来配置设备。

构造命令标识符

我们需要将总线、设备、功能和寄存器偏移量组合成一个32位的数,然后写入命令端口。其格式如下:

Bit 31: 必须设置为1(启用标志)
Bits 30-24: 保留位,设为0
Bits 23-16: 总线号 (0-255,但通常只用0-7)
Bits 15-11: 设备号 (0-31)
Bits 10-8: 功能号 (0-7)
Bits 7-0: 寄存器偏移量 (按4字节对齐,所以最后两位总是0)

在代码中,构造这个标识符的公式如下:

uint32_t id = 0x1 << 31
            | ((bus & 0xFF) << 16)
            | ((device & 0x1F) << 11)
            | ((function & 0x07) << 8)
            | (offset & 0xFC);

读取设备信息

每个PCI功能都有一段标准化的配置空间,其中包含了识别设备所需的关键信息。我们通过读取特定偏移量的寄存器来获取它们。

以下是几个关键信息的偏移地址:

  • 厂商ID:偏移量 0x00。这是一个16位的数字,由PCI-SIG分配给每个硬件厂商。例如,0x8086 代表英特尔。
  • 设备ID:偏移量 0x02。这是一个16位的数字,由厂商定义,用于标识具体的产品型号。
  • 类别代码:偏移量 0x0B。这是一个8位的数字,表示设备的通用类别(如显示控制器、网络控制器等)。
  • 子类别代码:偏移量 0x0A。这是一个8位的数字,在类别代码下进行更细的划分。

通过厂商ID和设备ID,我们可以为特定硬件加载精确的驱动程序。而通过类别和子类别代码,我们可以加载通用的兼容性驱动(例如,标准的VGA显卡驱动)。

枚举所有PCI设备

现在,我们来实现代码以发现系统中所有的PCI设备。思路是遍历所有可能的总线、设备和功能组合,并读取其厂商ID。如果厂商ID是 0xFFFF,则表示该位置没有设备,可以跳过。

在开始遍历一个设备的所有功能前,有一个优化技巧:我们可以先检查设备是否支持多功能。这可以通过读取功能0的配置空间偏移 0x0E 寄存器的第7位来得知。如果该位为1,则表示此设备有多个功能,我们需要遍历所有8个功能;如果为0,则只需检查功能0。

以下是枚举过程的核心逻辑:

for(int bus = 0; bus < 8; bus++) {
    for(int device = 0; device < 32; device++) {
        int numFunctions = deviceHasFunctions(bus, device) ? 8 : 1;
        for(int function = 0; function < numFunctions; function++) {
            PCIDeviceDescriptor dev = GetDeviceDescriptor(bus, device, function);
            if(dev.vendor_id == 0x0000 || dev.vendor_id == 0xFFFF)
                break; // 无此设备,跳出循环

            // 打印或处理设备信息
            printf(dev.vendor_id, dev.device_id, ...);
        }
    }
}

运行这段代码后,我们将得到一个类似 lspci 命令输出的设备列表。这个列表对于硬件调试和驱动开发至关重要。当你在网上寻求硬件问题帮助时,提供这个列表是常见的第一步。

总结

本节课中我们一起学习了PCI的基本概念。我们了解了PCI采用总线-设备-功能的层次化寻址模型,并学会了如何通过I/O端口与PCI控制器通信来读取设备的厂商ID、设备ID等关键信息。最后,我们实现了枚举所有PCI设备的功能,这是操作系统自动检测和配置硬件的基石。

在下一节,我们将探讨PCI设备的基地址寄存器。这些寄存器会告诉我们设备希望使用哪段内存地址进行内存映射I/O操作。一旦获取了这些信息,我们就能够真正地与硬件设备通信,并为其编写具体的驱动程序。这将使我们的操作系统能力获得一次巨大的飞跃。

012:基地址寄存器 (BAR) 🔧

在本节课中,我们将学习PCI配置空间中的一个关键概念:基地址寄存器。我们将了解它们的作用、两种不同类型,并编写代码来读取它们,为后续的设备驱动开发打下基础。

上一节我们介绍了如何枚举PCI总线上的设备。本节中,我们来看看如何与这些设备进行通信,这就要用到基地址寄存器。

前期修正与补充

在深入之前,需要对上一节的内容做两点修正和补充。

首先,关于在Linux中使用的lspci命令。上次我犯了一个小错误。正确的命令是lspci -n,这个参数能让命令同时显示供应商ID和设备ID。

其次,你还可以使用lspci -x命令。这个命令会显示你所有设备的整个PCI配置空间。在这里,你会看到设备ID和供应商ID,但字节顺序是翻转的。

另一个需要修正的地方是关于PCI设备枚举的代码。在之前的代码中,当我们遇到一个不存在的功能时,我使用了break语句。但后来我了解到,功能编号之间实际上可能存在间隔。例如,你可能拥有功能1和功能5,但没有功能2、3、4。使用break会阻止我们找到这个间隔之后的其他功能。因此,我们需要将break改为continue

什么是基地址寄存器?

现在,让我们来讨论基地址寄存器。它们是用来做什么的?

之前我们为键盘和鼠标编写了设备驱动,它们使用了固定的、硬编码的端口号和中断号。这种方式对于键盘鼠标是合理的,因为一台机器通常不会连接多个键盘,即使有,也只有一个焦点窗口,所以来自不同键盘的输入最终都会到达同一个进程和窗口。此外,计算机拥有键盘是相对标准的情况,因此以相对标准化的方式硬编码这些值是可行的。

但对于PCI设备,情况就不同了。你可能拥有多个显卡来驱动多个显示器,也可能有多个网卡。这种固定端口和中断的方法就不适用了。例如,如果你向一个固定端口发送数据,它应该显示在哪一个屏幕上呢?这没有意义。

解决这个问题的方案就是使用基地址寄存器。

基地址寄存器的位置与作用

基地址寄存器只是PCI配置空间中的一些寄存器。它们位于偏移量0x10开始的位置。每个基地址寄存器占用4个字节(32位)。

这些寄存器用于与设备通信并配置通信参数。例如,我们可以通过配置告诉设备:“如果发生了某事,请触发42号中断”。或者,我们可以设置另一个基地址寄存器,通过23号端口进行通信。不过,我个人并没有实际去设置这些值,我只是读取了GRUB引导程序已经设置好的值,并且它在我编写的代码中工作正常。目前,我们只对读取这些值感兴趣。

基地址寄存器的类型

基地址寄存器有两种类型,由寄存器值的最低有效位决定。

1. I/O 映射基地址寄存器
当最低位为1时,表示这是一个I/O映射的BAR。这种类型用于以传统方式与设备通信,就像我们与键盘和鼠标通信一样,通过独立的端口逐个字节地发送和接收数据。

在这种情况下,最低位是1,次低位是保留位。其余位表示端口号。需要注意的是,这个端口号必须是4的倍数,因为最低两位被用于其他用途。这种设计在硬件中很常见,但确实给编程带来了不便。

2. 内存映射基地址寄存器
当最低位为0时,表示这是一个内存映射的BAR。这种通信方式不同,你不需要逐个字节地发送和接收数据。相反,在内存映射中,你告诉设备:“使用这块内存区域(比如2KB)”。你只需将数据写入这个内存地址,设备就从那里读取;设备要发送数据给你,也写入这个地址,你从那里读取。这种方式性能更好,因为硬件可以在后台操作,而处理器可以腾出手来处理其他任务。

在内存映射类型中,最低位是0。接下来的两位(第2、3位)用于指示内存地址的宽度:

  • 00:32位地址
  • 01:20位地址(已过时)
  • 10:64位地址

第4位是“预取”位。例如,键盘是不可预取的,你不能在读到一个键击之前就读取它。而硬盘是可预取的,操作系统可以预估程序将来需要的数据并提前读取。

在本系列视频中,我们实际上只会使用I/O映射的基地址寄存器。我将向你展示如何为其编程。如果你对内存映射版本感兴趣,可以在lowlevel.eu上找到一些优秀的源代码,我稍后会进行解释。

编程实现:读取基地址寄存器

现在,让我们开始为这个主题编写代码。

首先,我们创建一个枚举来区分内存映射和I/O映射。

enum BaseAddressRegisterType
{
    MemoryMapping = 0,
    InputOutput = 1
};

然后,定义一个类来表示基地址寄存器。

class BaseAddressRegister
{
public:
    bool prefetchable;
    BaseAddressRegisterType type;
    uint32_t size;
    uint64_t address;
};

PCIController类中,我们将添加一个方法来获取指定BAR的实例。这个方法需要总线、设备、功能号和BAR编号作为参数。

BaseAddressRegister GetBaseAddressRegister(uint16_t bus, uint16_t device, uint16_t function, uint16_t bar);

我们还需要修改SelectDrivers方法,使其能接收一个中断管理器的引用,以便后续将驱动与中断关联。

void SelectDrivers(DriverManager* driverManager, InterruptManager* interrupts);

SelectDrivers方法的循环内部,我们现在将读取每个设备的BAR。

for(int barNum = 0; barNum < 6; barNum++)
{
    BaseAddressRegister bar = GetBaseAddressRegister(bus, device, function, barNum);
    if(bar.address && (bar.type == InputOutput))
    {
        deviceDescriptor.portBase = (uint32_t)bar.address;
    }
    // 获取并激活驱动...
}

GetBaseAddressRegister方法的实现核心是读取PCI配置空间中指定偏移量的值。BAR从偏移量0x10开始,每个占4字节。

uint32_t headerType = Read(bus, device, function, 0x0E) & 0x7F;
uint32_t maxBARs = 6 - (4 * headerType); // 根据头部类型计算最大BAR数
if(barNum >= maxBARs) return result; // 请求的BAR超出范围,返回未初始化的result

uint32_t bar_value = Read(bus, device, function, 0x10 + 4 * barNum);
result.type = (bar_value & 0x1) ? InputOutput : MemoryMapping;

对于I/O映射的BAR,我们需要清除最低两位来得到实际的端口基地址。

if(result.type == InputOutput)
{
    result.address = (uint8_t*)(bar_value & ~0x3);
    result.prefetchable = false;
}

对于内存映射的BAR,处理更复杂,涉及判断地址宽度和计算可映射区域大小。我们暂时不深入实现。如果你感兴趣,可以参考lowlevel.eu上的代码,其原理是:向BAR写入全1,再读回,设备会将不可写的位清零,从而得到一个掩码,用于计算区域大小和对齐要求。





设备驱动的获取与匹配

接下来,我们实现一个GetDriver方法,根据设备描述符来返回对应的驱动实例。目前我们没有从硬盘加载驱动的能力,所以先进行硬编码。

Driver* GetDriver(DeviceDescriptor device, InterruptManager* interrupts)
{
    switch(device.class_id)
    {
        case 0x03: // 图形设备
            switch(device.subclass_id)
            {
                case 0x00: // VGA兼容设备
                    // 未来返回VGA驱动
                    break;
            }
            break;
    }
    return 0; // 未找到驱动
}

SelectDrivers中,调用GetDriver,如果成功获取到驱动,就将其添加到驱动管理器中。

Driver* driver = GetDriver(deviceDescriptor, interrupts);
if(driver != 0)
{
    driverManager->AddDriver(driver);
}

编译并运行我们的代码,它应该能成功识别出VGA设备和网卡。







总结

本节课中我们一起学习了PCI基地址寄存器的核心概念。我们了解到BAR是设备与CPU通信的地址窗口,主要有I/O映射和内存映射两种类型,并通过最低位进行区分。我们实现了读取BAR信息的代码,并搭建了根据设备信息匹配驱动的框架。

现在,我们的抽象层已经足够厚实,可以开始在更高的抽象层级上工作,这正是我最喜欢的部分——告诉后端“我想做什么”,而后端的职责是知道如何在具体硬件上实现它。

下一节课将会非常有趣,我们将进入VGA图形显示模式。虽然不会太深入,但它将真正帮助你起步。请记得订阅,我们下节课再见!

013:图形模式(VGA) 🖥️

在本节课中,我们将学习如何将操作系统切换到VGA图形模式。我们将实现一个简单的320x200像素、256色的图形显示,为后续构建图形用户界面(GUI)框架打下基础。

概述

上一节我们介绍了文本模式下的显示驱动。本节中,我们来看看如何切换到图形模式。与文本模式类似,图形模式也涉及向特定的内存地址写入数据,但首先需要将显卡设置为图形模式。我们将绕过传统的BIOS中断调用,直接与显卡通信来完成模式设置。

从文本模式到图形模式

在文本模式中,我们通过向内存地址 0xB8000 写入字符和属性来控制屏幕。图形模式的工作原理类似,但写入的数据直接对应屏幕上的像素颜色。然而,切换到图形模式本身是一个挑战。

许多教程建议使用BIOS中断 int 0x13 来设置图形模式。这个方法在DOS或受保护的操作系统环境下有效,因为操作系统会处理这个调用。但在我们自写的操作系统中,尤其是在GRUB引导程序已将CPU置于32位保护模式后,这些基于16位实模式的BIOS中断已被禁用。虽然可以切换回16位模式,但这个过程复杂且并非理想方案。

因此,我们需要直接与显卡硬件通信,发送特定的初始化代码来设置图形模式。

VGA硬件与内存布局

VGA显卡的设计有其历史局限性。对于较小的分辨率,其显存(Frame Buffer)大小可能不足。为了解决这个问题,VGA采用了“存储体切换”(Bank Switching)技术,即为不同的分辨率分配多个内存地址。我们需要知道向哪个地址写入数据。

幸运的是,对于我们要实现的320x200 256色模式,其帧缓冲区的内存位置是固定的,类似于文本模式的 0xB8000。这意味着我们暂时不需要复杂的动态内存管理或PCI设备枚举来定位它,可以直接像处理文本模式一样处理它。

核心内存写入概念(伪代码):

// 假设 frame_buffer_segment 是帧缓冲区的起始地址
char* pixel_address = frame_buffer_segment + (y * 320 + x);
*pixel_address = color_index; // 写入颜色索引值

实现VGA图形驱动

我们将创建一个 VGADriver 类。由于缺乏动态内存管理,我们暂时在内核中直接实例化这个驱动。

以下是驱动需要包含的主要部分和端口定义:

  • 杂项端口 (Miscellaneous): 0x3C2
  • 序列发生器索引/数据端口 (Sequencer): 0x3C4, 0x3C5
  • CRT控制器索引/数据端口 (CRT Controller): 0x3D4, 0x3D5
  • 图形控制器索引/数据端口 (Graphics Controller): 0x3CE, 0x3CF
  • 属性控制器索引/读写/复位端口 (Attribute Controller): 0x3C0, 0x3C1, 0x3DA

setMode 方法是核心,它负责将显卡切换到目标模式。我们将参考 OSDev.org 上提供的现成初始化代码序列。对于320x200x256色模式,我们只需将这些代码序列发送到上述对应的显卡端口。

警告: 直接操作硬件存在风险。错误的指令可能损坏硬件。虽然现代LCD显示器风险较低,但操作仍需谨慎。

writeRegisters 方法负责将初始化代码数据写入各个端口。流程如下:

  1. 向杂项端口写入一个值。
  2. 对于序列发生器、CRT控制器、图形控制器:先向索引端口写入寄存器号,再向数据端口写入要设置的值。
  3. 对于属性控制器:先读取复位端口,然后向索引端口写入寄存器号,再向写端口写入数据,最后再次读取复位端口并设置索引。

在操作CRT控制器时,需要注意解锁和锁定操作,这是一种安全特性,防止错误数据损坏显示器。

绘制像素

设置好图形模式后,我们就可以在屏幕上绘制像素了。

VGA的256色模式使用一个颜色查找表(Color Look-Up Table, CLUT)。我们写入帧缓冲区的不是一个具体的RGB颜色值,而是这个颜色在表中的索引(0-255)。我们的驱动需要提供抽象。

我们将实现一个 putPixel 方法,它接收x、y坐标和一个24位的RGB颜色值。驱动内部会通过 getColorIndex 方法将这个RGB值转换为最接近的8位颜色索引。目前,我们做一个简单的映射,例如将一种特定的蓝色映射到索引 0x01

实际的像素写入操作,是通过计算像素在帧缓冲区中的线性地址,然后将颜色索引值写入该地址完成的。公式为:地址 = 帧缓冲区段地址 + (y * 宽度 + x)

getFrameBufferSegment 方法用于确定当前模式下帧缓冲区的起始内存地址。这需要查询图形控制器的特定寄存器。

测试与运行

kernel.cpp 中,我们初始化VGA驱动,调用 setMode 切换到320x200x256色模式,然后使用循环调用 putPixel 将整个屏幕填充为蓝色。

编译并运行后,如果一切正常,你将看到一个纯蓝色的屏幕。这不仅在虚拟机中有效,在真实硬件上同样可以运行。成功在自有操作系统中激活图形模式并控制显示,是一个重要的里程碑。

总结

本节课我们一起学习了如何为自写操作系统实现VGA图形支持。我们了解了绕过BIOS中断、直接与显卡通信以设置图形模式的必要性,并剖析了VGA硬件的基本编程模型。通过实现一个简单的VGA驱动,我们成功将显示模式切换为320x200 256色,并实现了在屏幕上绘制像素的基本功能。这为我们下一节课构建图形用户界面(GUI)框架奠定了基础。

014:GUI框架基础 🖥️

在本节课中,我们将学习如何为我们的操作系统构建一个图形用户界面框架的基础。上一节我们成功进入了图形模式,这是一个重要的里程碑。本节中,我们来看看如何利用这个图形模式来创建一个基本的GUI框架。

概述

GUI框架是操作系统中用户交互的核心部分。虽然在实际操作系统中,桌面环境通常是独立于内核的进程,但理解其工作原理对于构建完整的操作系统体验至关重要。我们将设计一个基础的、面向对象的GUI框架,其核心思想与许多常见的框架相似。

核心概念与基础类

图形上下文

首先,我们需要一个图形上下文类。它定义了在屏幕上绘制的基本操作。在实际设计中,VGA类应该派生自一个像GraphicsContext这样的基类。

GraphicsContext基类会定义如下方法:

  • put_pixel(x, y, color): 在指定坐标绘制一个像素。
  • fill_rectangle(x, y, width, height, color): 填充一个矩形。
  • draw_line(x1, y1, x2, y2, color): 绘制一条线。

一个关键的设计原则是:像fill_rectangledraw_line这样的复杂方法,应该有一个默认实现,这个实现仅依赖于基础的put_pixel方法。这样,具体的图形驱动(如我们的VGA驱动)只需要实现put_pixel,就能自动获得所有高级绘图功能。

例如,绘制一条高质量的直线可以使用Bresenham算法

此外,颜色应该被封装成一个独立的Color类,而不是将红、绿、蓝分量作为单独参数传递。

基础部件

GUI框架的起点是一个名为Widget的基础类。它代表屏幕上可绘制和交互的一个元素。

Widget类应包含以下核心属性和方法:

  • 坐标与尺寸x, y, width, height。这些坐标通常是相对于其父部件的。
  • 父部件指针parent,指向其父Widget
  • 绘制方法draw(graphics_context)。该方法接收一个图形上下文,并利用其方法(如put_pixel, fill_rectangle)将自己绘制出来。
  • 坐标转换model_to_screen(&x, &y)。此方法将部件自身的相对坐标转换为屏幕上的绝对坐标。其实现是递归调用父部件的model_to_screen,并加上自身的偏移量。
    void Widget::model_to_screen(int &x, int &y) {
        if (parent != nullptr) {
            parent->model_to_screen(x, y);
        }
        x += this->x;
        y += this->y;
    }
    
  • 命中测试contains(x, y)。判断给定的坐标是否位于此部件区域内。这对于处理鼠标事件至关重要。
    bool Widget::contains(int point_x, int point_y) {
        return (point_x >= x && point_x < x + width) &&
               (point_y >= y && point_y < y + height);
    }
    
  • 事件处理方法:如on_mouse_down(x, y), on_mouse_up(x, y), on_mouse_move(old_x, old_y, new_x, new_y), on_key_down(key), on_key_up(key)。这些是部件响应交互的接口。
  • 获取焦点get_focus()。当部件被点击时,它需要通知上层窗口自己获得了焦点。默认实现是将请求传递给父部件。

复合部件与事件传递

复合部件

CompositeWidgetWidget的一个重要子类。它包含一个子部件数组,并负责管理它们。

以下是CompositeWidget需要重写或实现的关键方法:

  • 绘制draw方法首先绘制自己的背景,然后按从后到前的顺序遍历并绘制所有子部件。这样,索引靠后的子部件会覆盖靠前的,形成正确的层叠效果。
  • 鼠标事件on_mouse_down等方法会遍历子部件(从最前面的开始,即索引从大到小),对第一个包含事件坐标的子部件调用相应的事件处理方法,然后停止遍历。
  • 键盘事件on_key_downon_key_up通常直接传递给当前获得焦点的子部件(focused_child)。

窗口与桌面

Window类可以派生自CompositeWidget。它代表一个可移动、可调整大小的应用程序窗口。

Window类需要重写get_focus方法,不再将请求传递给父部件,而是将自己设置为父窗口(或桌面)的active_windowfocused_widget

Desktop类也派生自CompositeWidget,它是所有窗口的容器,是部件树的根。

  • 它维护一个active_window指针,指向当前活动的窗口。
  • 它的get_focus方法会将传入的窗口设为active_window,并可能将其在Z轴顺序中移到最前面。
  • 键盘事件会发送给active_window
  • 鼠标点击某个窗口会使其成为新的active_window

代码结构示例

以下是一个简化的代码结构框架,展示了上述类的部分关键实现:

// 基础部件类
class Widget {
protected:
    int x, y, width, height;
    Widget* parent;
    bool focusable = true;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/bdb1077a8ca4413ce5db853e8f3b755b_11.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/bdb1077a8ca4413ce5db853e8f3b755b_13.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/bdb1077a8ca4413ce5db853e8f3b755b_15.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/bdb1077a8ca4413ce5db853e8f3b755b_16.png)

public:
    virtual void draw(GraphicsContext* gc);
    virtual void model_to_screen(int &x, int &y);
    virtual bool contains(int point_x, int point_y);
    virtual void on_mouse_down(int x, int y);
    virtual void get_focus(Widget* widget);
    // ... 其他事件方法
};

// 复合部件类
class CompositeWidget : public Widget {
protected:
    Widget* children[100];
    int num_children;
    Widget* focused_child = nullptr;

public:
    void draw(GraphicsContext* gc) override {
        // 1. 绘制自身背景
        gc->fill_rectangle(x, y, width, height, background_color);
        // 2. 从后向前绘制子部件
        for (int i = num_children - 1; i >= 0; --i) {
            children[i]->draw(gc);
        }
    }

    void on_mouse_down(int x, int y) override {
        for (int i = num_children - 1; i >= 0; --i) {
            if (children[i]->contains(x, y)) {
                children[i]->on_mouse_down(x - children[i]->x, y - children[i]->y);
                break; // 仅最前面的部件接收事件
            }
        }
    }

    void on_key_down(char key) override {
        if (focused_child != nullptr) {
            focused_child->on_key_down(key);
        }
    }

    void get_focus(Widget* widget) override {
        focused_child = widget;
        // 可以继续向上传递,通知窗口或桌面
        if (parent != nullptr) {
            parent->get_focus(this);
        }
    }
};

// 桌面类
class Desktop : public CompositeWidget {
private:
    Window* active_window = nullptr;

public:
    void get_focus(Widget* widget) override {
        // 假设widget是一个Window*
        active_window = (Window*)widget;
        // 将活动窗口移到子部件数组前端以实现置顶
        // ... 重新排序 children 数组的代码
    }

    void on_key_down(char key) override {
        if (active_window != nullptr) {
            active_window->on_key_down(key);
        }
    }
};

总结

本节课我们一起学习了构建一个简单GUI框架的基础知识。我们介绍了核心的Widget类及其坐标转换、命中测试功能,探讨了CompositeWidget如何管理子部件并传递事件,并概述了WindowDesktop类在管理焦点和窗口层叠秩序中的作用。虽然GUI框架的开发深度可以无限延伸,但这个基础结构为我们实现可交互的桌面环境奠定了重要的第一步。在下一节中,我们将尝试实现窗口类,并让它能够响应鼠标事件进行拖动。

015:桌面与窗口 🖥️

在本节课中,我们将继续完成图形用户界面框架,具体实现桌面和窗口类。我们将学习如何创建可交互的桌面环境,并实现窗口的拖拽功能。

概述

上一节我们介绍了GUI框架的基础结构,但尚未实现桌面和窗口类。本节我们将完成这些核心组件的实现,构建一个简单的图形界面。

继续GUI框架的实现

上一节我们介绍了GUI框架的基础结构,本节中我们来看看如何实现具体的桌面和窗口类。首先,我们需要解决编译问题,因为之前未将相关对象文件加入编译。

修复编译错误

在编译时,我们遇到了几个错误。主要问题在于Widget类缺少ContainsCoordinate方法的实现,并且CommonGraphicsContext的引用方式有误。

以下是修复方法:

bool Widget::ContainsCoordinate(int32_t x, int32_t y)
{
    return (x >= this->x) && (x < (this->x + this->w)) &&
           (y >= this->y) && (y < (this->y + this->h));
}

这个方法检查给定的坐标是否在部件的边界框内。需要注意的是,这里传入的坐标是相对于父部件的相对坐标。

优化事件处理

当前CompositeWidget中的鼠标移动事件处理代码有些冗余。更优雅的方式是检查鼠标是否从一个部件移到了另一个部件,并相应地触发OnMouseLeaveOnMouseEnter事件。不过,为了简化,我们暂时保持现有实现。

改进键盘和鼠标事件处理

为了让部件能够处理键盘和鼠标事件,我们进行以下改进:

  1. Widget类继承自KeyboardEventHandler,这样部件可以直接处理键盘事件
  2. Desktop类继承自MouseEventHandler,以便处理鼠标事件
  3. CompositeWidget添加添加子部件的方法

以下是关键代码修改:

class Widget : public KeyboardEventHandler {
    // ... 现有代码
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/ea253ac465ac5a1fbfb1430ecea87b67_5.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/ea253ac465ac5a1fbfb1430ecea87b67_7.png)

class Desktop : public CompositeWidget, public MouseEventHandler {
    // ... 现有代码
};

实现桌面类 🖥️

桌面是一个特殊的复合部件,它需要处理鼠标移动并将其转换为绝对坐标,同时负责绘制鼠标光标。

桌面类的构造函数

桌面类的构造函数初始化鼠标位置为屏幕中心:

Desktop::Desktop(uint32_t w, uint32_t h, uint8_t color) 
    : CompositeWidget(0, 0, 0, w, h, color),
      MouseX(w/2), MouseY(h/2) {
}

鼠标事件处理

桌面需要处理鼠标的移动、按下和释放事件:

  1. 鼠标移动:将相对移动转换为绝对坐标,并确保光标不超出屏幕边界
  2. 鼠标按下/释放:将事件传递给相应的部件处理

以下是鼠标移动处理的核心逻辑:

void Desktop::OnMouseMove(int8_t x, int8_t y) {
    // 调整移动速度
    x /= 4;
    y /= 4;
    
    // 确保不超出屏幕边界
    int32_t newMouseX = MouseX + x;
    if(newMouseX < 0) newMouseX = 0;
    if(newMouseX >= w) newMouseX = w - 1;
    
    int32_t newMouseY = MouseY + y;
    if(newMouseY < 0) newMouseY = 0;
    if(newMouseY >= h) newMouseY = h - 1;
    
    // 处理鼠标移动事件
    CompositeWidget::OnMouseMove(MouseX, MouseY, newMouseX, newMouseY);
    
    // 更新鼠标位置
    MouseX = newMouseX;
    MouseY = newMouseY;
}

绘制桌面

桌面的绘制包括两个部分:

  1. 调用父类的Draw方法绘制所有子部件
  2. 在鼠标位置绘制光标

以下是绘制方法:

void Desktop::Draw(GraphicsContext* gc) {
    CompositeWidget::Draw(gc);
    
    // 绘制白色十字光标
    for(int i = -2; i <= 2; i++) {
        PutPixel(gc, MouseX + i, MouseY, 0xFF);  // 水平线
        PutPixel(gc, MouseX, MouseY + i, 0xFF);  // 垂直线
    }
}

需要注意的是,在绘制像素前需要检查坐标是否合法:

void PutPixel(GraphicsContext* gc, int32_t x, int32_t y, uint8_t color) {
    if(x >= 0 && x < gc->Width && y >= 0 && y < gc->Height) {
        gc->FrameBuffer[y * gc->PixelsPerScanLine + x] = color;
    }
}

实现窗口类 🪟

窗口类允许用户通过拖拽来移动窗口位置。我们通过跟踪鼠标状态来实现这一功能。

窗口类的状态管理

窗口类需要跟踪是否正在被拖拽:

class Window : public CompositeWidget {
private:
    bool Dragging;
    
public:
    Window(Widget* parent, int32_t x, int32_t y, 
           int32_t w, int32_t h, uint8_t color);
    
    void OnMouseDown(int32_t x, int32_t y, uint8_t button);
    void OnMouseUp(int32_t x, int32_t y, uint8_t button);
    void OnMouseMove(int32_t oldX, int32_t oldY, 
                     int32_t newX, int32_t newY);
};

鼠标事件处理

窗口的鼠标事件处理逻辑如下:

  1. 鼠标按下:如果按下左键,开始拖拽
  2. 鼠标释放:停止拖拽
  3. 鼠标移动:如果正在拖拽,更新窗口位置

以下是关键实现:

void Window::OnMouseDown(int32_t x, int32_t y, uint8_t button) {
    CompositeWidget::OnMouseDown(x, y, button);
    Dragging = (button == 1);  // 左键
}

void Window::OnMouseMove(int32_t oldX, int32_t oldY, 
                         int32_t newX, int32_t newY) {
    CompositeWidget::OnMouseMove(oldX, oldY, newX, newY);
    
    if(Dragging) {
        this->x += newX - oldX;
        this->y += newY - oldY;
    }
}

集成到内核中 🔧

现在我们将桌面和窗口集成到操作系统中:

创建桌面和窗口

以下是创建桌面和两个窗口的示例代码:

// 创建桌面
Desktop* desktop = new Desktop(320, 200, 0x00);

// 创建第一个窗口(红色)
Window* window1 = new Window(desktop, 10, 10, 100, 50, 0x04);
desktop->AddChild(window1);

// 创建第二个窗口(绿色)
Window* window2 = new Window(desktop, 40, 40, 120, 60, 0x02);
desktop->AddChild(window2);

连接输入设备

将桌面连接到鼠标和键盘驱动程序:

// 连接鼠标
mouseDriver->SetEventHandler(desktop);

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/ea253ac465ac5a1fbfb1430ecea87b67_36.png)

// 连接键盘
keyboardDriver->SetEventHandler(desktop);

更新显示

在系统主循环中定期重绘桌面:

while(1) {
    desktop->Draw(graphicsContext);
    // ... 其他系统任务
}

性能优化考虑 ⚡

当前实现存在明显的性能问题,主要是频繁重绘整个屏幕导致的闪烁。以下是几种优化思路:

1. 局部更新机制

更好的方法是只更新屏幕上发生变化的部分:

  • 维护一个无效区域列表
  • 只重绘这些无效区域
  • 使用内存复制来移动窗口内容

2. 从后向前绘制

当前实现从后向前绘制(先桌面后窗口),但更高效的方式可能是:

  • 从前向后绘制
  • 使用矩形裁剪避免重复绘制

3. 图形硬件加速

利用图形硬件的特性:

  • 使用硬件矩形绘制功能
  • 批量操作像素数据
  • 直接内存访问(DMA)

4. 双缓冲技术

使用双缓冲可以消除闪烁:

  • 在后台缓冲区绘制
  • 完成后交换到前台显示

文本渲染支持 📝

要在图形模式下显示文本,我们需要位图字体。可以使用公开领域的8x8像素字体:

以下是使用位图字体渲染文本的基本思路:

void DrawChar(GraphicsContext* gc, int32_t x, int32_t y, 
              char c, uint8_t color) {
    uint8_t* font = &font8x8_basic[c * 8];
    
    for(int row = 0; row < 8; row++) {
        uint8_t rowData = font[row];
        for(int col = 0; col < 8; col++) {
            if(rowData & (1 << col)) {
                PutPixel(gc, x + col, y + row, color);
            }
        }
    }
}

总结

本节课中我们一起学习了如何实现操作系统的图形用户界面。我们完成了以下内容:

  1. 修复了编译问题,实现了缺失的方法
  2. 创建了桌面类,能够处理鼠标移动和绘制光标
  3. 实现了窗口类,支持基本的拖拽功能
  4. 将GUI集成到内核中,创建了可交互的桌面环境
  5. 讨论了性能优化的各种可能性
  6. 介绍了文本渲染的基本方法

虽然当前实现存在性能问题,但框架设计具有良好的抽象性。通过改进图形驱动程序,可以获得更好的性能和分辨率,而无需修改上层GUI代码。

这个GUI框架为创建更复杂的界面元素(如按钮、文本框等)奠定了基础。下一节课,我们将开始学习多任务处理,让多个程序能够同时运行。


关键要点回顾:

  • GUI框架采用分层设计,便于扩展和维护
  • 桌面负责坐标转换和光标绘制
  • 窗口支持基本的交互操作
  • 性能优化是GUI开发的重要考虑因素
  • 良好的抽象设计使得更换底层驱动程序变得容易

016:多任务处理 🧵

在本节课中,我们将学习如何为我们的操作系统实现多任务处理。多任务处理是操作系统的核心功能之一,它允许系统同时运行多个任务,并在它们之间快速切换,从而创造出并行执行的假象。

多任务处理的基本原理

上一节我们介绍了中断处理。本节中我们来看看如何利用中断机制来实现任务切换。

中断与任务切换

处理器正在执行一段代码,同时使用一个栈(我们称之为内核栈)。当一个定时器中断发生时,处理器会自动将一些寄存器的值(如指令指针EIP)压入当前栈中,然后跳转到中断处理程序。在中断处理程序中,我们也会手动保存其他寄存器的状态。

中断处理完成后,这些保存的寄存器值会被恢复,处理器跳回中断发生前的指令继续执行。这是单任务环境下的正常流程。

为了实现多任务,我们需要为每个任务分配独立的栈空间。当创建新任务时,我们为其栈空间预置一个初始的CPU状态结构,其中包含了所有寄存器的初始值,最关键的是将指令指针(EIP)设置为该任务的入口函数地址。

任务切换的关键

任务切换的核心发生在中断处理程序返回时。以下是关键步骤:

  1. 当中断发生时,当前任务的CPU状态(寄存器值)被保存在其专属栈上。
  2. 在中断处理程序中,调度器决定下一个要运行的任务。
  3. 中断处理程序返回时,不再返回到旧任务的栈指针(ESP),而是返回新任务栈顶的CPU状态结构指针
  4. 处理器将这个新指针加载到ESP,然后按照中断返回的流程,将栈上的值弹出到各个寄存器中。
  5. 由于EIP被设置成了新任务的入口点,处理器接下来就开始执行新任务的代码了。

这样,通过巧妙地操纵中断返回时的栈指针,我们就实现了从一个任务到另一个任务的上下文切换。

代码实现详解

理解了原理后,我们来看看具体的代码实现。我们将创建几个核心的数据结构和类。

CPU状态结构体

首先,我们需要定义一个结构体来保存CPU的寄存器状态。这个结构体的布局必须与中断发生时值被压入栈的顺序完全一致。

struct CPUState
{
    // 以下寄存器由处理器在中断时自动压栈
    uint32_t eip;
    uint32_t cs;
    uint32_t eflags;
    uint32_t esp;
    uint32_t ss;

    // 以下寄存器由我们在中断处理程序中手动压栈
    uint32_t eax;
    uint32_t ebx;
    uint32_t ecx;
    uint32_t edx;
    uint32_t esi;
    uint32_t edi;
    uint32_t ebp;
    // 注意:对于硬件中断(非异常),error_code位置需要手动填充一个值(如0)
    // uint32_t error_code;
};

任务类

接下来,我们创建Task类,它代表一个独立的执行单元。

class Task
{
    friend class TaskManager; // 允许TaskManager访问私有成员
private:
    uint8_t stack[4096]; // 每个任务拥有4KB的栈空间
    CPUState* cpustate;  // 指向栈顶CPU状态结构的指针

public:
    Task(GlobalDescriptorTable* gdt, void entrypoint());
    ~Task();
};

Task的构造函数中,我们需要初始化它的栈和CPU状态:

  1. 计算栈顶地址,并预留出CPUState结构的大小。
  2. cpustate指针指向这个预留区域。
  3. cpustate->eip设置为任务的入口函数地址。
  4. cpustate->cs设置为全局描述符表(GDT)中代码段的选择子。
  5. 将其他寄存器初始化为0或合适的默认值。

任务管理器类

TaskManager类负责管理所有任务并进行调度。

class TaskManager
{
private:
    Task* tasks[256]; // 任务指针数组
    int numTasks;     // 当前任务数量
    int currentTask;  // 当前正在运行的任务索引,初始为-1

public:
    TaskManager();
    bool AddTask(Task* task); // 添加新任务
    CPUState* Schedule(CPUState* cpustate); // 核心调度函数
};

以下是调度函数Schedule的工作流程:

  1. 如果currentTask为-1(例如内核首次被中断),则直接返回传入的cpustate,不进行切换。
  2. 否则,将传入的cpustate(即旧任务的CPU状态)保存回tasks[currentTask]中。
  3. 使用轮转调度算法选择下一个任务:currentTask = (currentTask + 1) % numTasks
  4. 返回新任务的cpustate指针。

集成到中断处理流程

最后,我们需要修改中断处理程序,使其调用任务管理器。

  1. 在中断管理器中引用任务管理器:让InterruptManager持有一个TaskManager*指针。
  2. 修改中断服务例程:在定时器中断的处理函数中,调用taskManager->Schedule(),并将其返回值作为新的栈指针。
    CPUState* new_cpustate = taskManager->Schedule(cpustate);
    // 这个new_cpustate会被后续汇编代码加载到ESP,从而实现任务切换
    
  3. 调整汇编代码:确保中断入口和出口的汇编代码能够正确地保存和恢复CPUState结构体中定义的所有寄存器,并且为硬件中断预留error_code的位置。

重要注意事项与总结

本节课中我们一起学习了多任务处理的基本原理和实现方法。

  • 内核初始化顺序:激活中断(interrupts.Activate())应该是内核main函数中做的最后一件事。因为一旦开始任务调度,处理器可能永远不会返回到内核的主线程栈。
  • 当前实现的局限:目前所有任务都运行在内核模式,拥有最高权限。一个完整的操作系统还需要实现用户模式,通过特权级保护来限制用户任务的权限,这是系统安全性的基础。
  • 轮转调度:我们实现的是最简单的轮转调度。更复杂的调度算法(如基于优先级)可以在此基础上进行扩展。

多任务处理是操作系统开发中的一个重要里程碑。你现在已经掌握了通过中断机制进行上下文切换的核心技术。在接下来的课程中,我们将探讨内存管理等其他核心主题,为构建更复杂的系统功能打下基础。

017:动态内存管理 / 堆 🧠

在本教程中,我们将学习如何实现动态内存管理,即堆。目前,我们所有的内存分配都是静态的,例如在栈上分配对象。然而,有时我们无法预先知道需要多少内存,例如在遍历PCI设备并找到需要驱动程序的设备时。因此,我们需要一种方法来动态分配内存,并跟踪已分配和未分配的内存区域,以防止它们相互重叠和干扰。

内存布局与设计思路

上一节我们介绍了静态内存分配的局限性,本节中我们来看看如何设计一个简单的堆管理器。

我们的内存布局大致如下:我们有视频内存、文本内存、BIOS、引导加载程序、内核及其栈。之后,有大量可用的空闲空间供我们使用。我们将从栈指针之后的一个固定偏移量(例如10MB)开始,将这片区域作为我们的堆。

虽然这种方法不够优雅(理想情况下,引导程序应告知我们可用内存区域),但它是目前一个可行的起点。

核心数据结构:内存块

我们的堆管理器将基于双向链表实现。以下是核心数据结构:

MemoryChunk 结构体

struct MemoryChunk {
    MemoryChunk* next;
    MemoryChunk* prev;
    bool allocated;
    size_t size; // 此块之后可供用户使用的数据区域大小
};

size_t 类型定义

typedef uint32_t size_t; // 在32位系统上

每个 MemoryChunk 结构体(或称“头”)位于其管理的内存块起始处。size 字段表示紧随头之后、可供用户程序使用的内存字节数。

MemoryManager

class MemoryManager {
public:
    MemoryManager(size_t start, size_t size);
    void* malloc(size_t size);
    void free(void* ptr);

    static MemoryManager* activeMemoryManager;
private:
    MemoryChunk* firstChunk;
};

管理器负责初始化第一个内存块,并处理分配 (malloc) 和释放 (free) 请求。

初始化堆管理器

在构造函数中,我们进行初始化:

  1. activeMemoryManager 设置为当前实例。
  2. 在给定的起始地址 (start) 处创建第一个 MemoryChunk
  3. 这个初始块标记为未分配 (allocated = false),其 size 为整个堆区域的大小减去头本身的大小。它的 nextprev 指针都设为 nullptr

MemoryManager::MemoryManager(size_t start, size_t size) {
    activeMemoryManager = this;
    // 确保有足够空间容纳至少一个 MemoryChunk
    if(size < sizeof(MemoryChunk)) {
        firstChunk = nullptr;
        return;
    }
    firstChunk = (MemoryChunk*)start;
    firstChunk->allocated = false;
    firstChunk->prev = nullptr;
    firstChunk->next = nullptr;
    firstChunk->size = size - sizeof(MemoryChunk);
}

内存分配算法

当请求分配内存时,malloc 函数会遍历链表,寻找第一个足够大且未被分配的块。

以下是分配过程的关键步骤:

  1. 遍历查找:从 firstChunk 开始,遍历链表,寻找 allocated == falsesize >= 请求大小 的块。
  2. 决定分割:如果找到的块远大于请求大小(即 块size >= 请求size + sizeof(MemoryChunk) + 1),我们分割该块。
    • 原块被分割为两部分:一个已分配块(满足请求)和一个新的未分配块(剩余空间)。
    • 需要调整新旧块的指针和大小字段。
  3. 直接分配:如果找到的块大小不足以进行分割,则直接将整个块标记为已分配。
  4. 返回指针:返回给用户的是内存块头之后的数据区域起始地址,即 (void*)((size_t)chunk + sizeof(MemoryChunk))

void* MemoryManager::malloc(size_t size) {
    MemoryChunk* result = nullptr;
    // 遍历链表寻找合适的空闲块
    for(MemoryChunk* chunk = firstChunk; chunk != nullptr; chunk = chunk->next) {
        if(!chunk->allocated && chunk->size >= size) {
            result = chunk;
            break;
        }
    }
    if(result == nullptr) return nullptr; // 内存不足

    // 检查是否需要并可以分割块
    if(result->size >= size + sizeof(MemoryChunk) + 1) {
        // 创建新块(剩余空间)
        MemoryChunk* newChunk = (MemoryChunk*)((size_t)result + sizeof(MemoryChunk) + size);
        newChunk->allocated = false;
        newChunk->size = result->size - size - sizeof(MemoryChunk);
        newChunk->prev = result;
        newChunk->next = result->next;

        // 更新原块(分配块)
        result->size = size; // 分配块只管理请求大小的数据区
        result->next = newChunk;
        if(newChunk->next != nullptr) {
            newChunk->next->prev = newChunk;
        }
    }
    result->allocated = true;
    // 返回数据区指针(头之后)
    return (void*)((size_t)result + sizeof(MemoryChunk));
}

内存释放与块合并

释放内存 (free) 时,我们不仅要将块标记为未分配,还要尝试与相邻的未分配块合并,以防止内存碎片化。

以下是释放与合并的过程:

  1. 获取块头:用户传入的是数据区指针,我们需要减去 sizeof(MemoryChunk) 来找到对应的 MemoryChunk 头。
  2. 与前一块合并:如果当前块的前一块存在且未分配,则将当前块合并到前一块。
    • 将前一块的 size 增加(当前块 size + sizeof(MemoryChunk))。
    • 调整链表指针,绕过当前块。
    • 将当前块指针指向前一块,以便后续操作。
  3. 与后一块合并:如果(合并后的)当前块的下一块存在且未分配,则将其合并到当前块。
    • 将当前块的 size 增加(下一块 size + sizeof(MemoryChunk))。
    • 调整链表指针,移除下一块。
void MemoryManager::free(void* ptr) {
    if(ptr == nullptr) return;
    // 通过数据指针找到块头
    MemoryChunk* chunk = (MemoryChunk*)((size_t)ptr - sizeof(MemoryChunk));
    chunk->allocated = false;

    // 与前一个空闲块合并
    if(chunk->prev != nullptr && !chunk->prev->allocated) {
        chunk->prev->size += chunk->size + sizeof(MemoryChunk);
        chunk->prev->next = chunk->next;
        if(chunk->next != nullptr) {
            chunk->next->prev = chunk->prev;
        }
        chunk = chunk->prev; // 将指针移到合并后的块
    }

    // 与后一个空闲块合并
    if(chunk->next != nullptr && !chunk->next->allocated) {
        chunk->size += chunk->next->size + sizeof(MemoryChunk);
        chunk->next = chunk->next->next;
        if(chunk->next != nullptr) {
            chunk->next->prev = chunk;
        }
    }
}

集成到C++的new/delete运算符

为了让动态内存分配更符合C++习惯,我们可以重载全局的 newdelete 运算符,让它们调用我们的堆管理器。

注意:标准的 new 在分配失败时应抛出 std::bad_alloc 异常。由于我们尚未实现异常处理,这里简化为返回 nullptr。在内核模式下,对 nullptr 的解引用是允许的(访问地址0),这很危险。因此,更安全的做法是使用“placement new”在预先分配好的内存上构造对象。

以下是运算符重载的示例:

// 全局 new/delete 运算符,调用我们的内存管理器
void* operator new(size_t size) {
    if(MemoryManager::activeMemoryManager == nullptr) return nullptr;
    return MemoryManager::activeMemoryManager->malloc(size);
}
void operator delete(void* ptr) {
    if(MemoryManager::activeMemoryManager != nullptr) {
        MemoryManager::activeMemoryManager->free(ptr);
    }
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/78de33d16cf722ce44753100b4fc2326_29.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/78de33d16cf722ce44753100b4fc2326_30.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/78de33d16cf722ce44753100b4fc2326_31.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/78de33d16cf722ce44753100b4fc2326_32.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/78de33d16cf722ce44753100b4fc2326_34.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/78de33d16cf722ce44753100b4fc2326_35.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/78de33d16cf722ce44753100b4fc2326_36.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/78de33d16cf722ce44753100b4fc2326_38.png)

// Placement new 运算符,用于在指定内存地址构造对象
void* operator new(size_t, void* ptr) {
    return ptr;
}
void operator delete(void*, void*) { /* placement delete 通常为空 */ }

在实际使用时,可以先调用 malloc 分配原始内存,检查指针非空后,再用 placement new 构造对象:

// 示例:动态创建驱动程序对象
AmdPcNetDriver* driver = (AmdPcNetDriver*)MemoryManager::activeMemoryManager->malloc(sizeof(AmdPcNetDriver));
if(driver != nullptr) {
    driver = new(driver) AmdPcNetDriver(...); // 使用 placement new 调用构造函数
}

堆的初始化与地址

在实例化 MemoryManager 时,我们需要确定堆的起始地址和大小。一个简单但不够优雅的方法是将起始地址硬编码为内核栈之后的一个固定值(例如10MB)。大小可以从GRUB传递的多引导信息结构中获取(内存大小以KB为单位),然后减去堆的起始地址和一部分预留空间。

// 示例:从 multiboot 信息获取内存大小(KB),并转换为字节
size_t memorySizeKB = *((uint32_t*)((size_t)multibootStructPtr + 8));
size_t heapSize = (memorySizeKB * 1024) - heapStartAddress - 10*1024; // 减去10KB预留

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/78de33d16cf722ce44753100b4fc2326_50.png)

MemoryManager memoryManager(heapStartAddress, heapSize);

总结与展望

本节课中我们一起学习了如何实现一个简单的动态内存管理器(堆)。我们基于双向链表设计了一个内存块结构来跟踪内存的使用情况,实现了 mallocfree 的基本功能,包括内存分割与合并以减少碎片。最后,我们将其与C++的 new/delete 运算符进行了集成,并讨论了安全分配对象的注意事项。

这个实现虽然简单且效率不高(例如分配需要线性遍历链表),但它是一个重要的起点,为操作系统提供了动态内存分配的能力。有了堆,我们就可以在运行时创建对象,例如接下来为检测到的PCI设备动态实例化驱动程序。

在后续章节中,我们将利用这个堆管理器来支持网络驱动程序的加载,并开始探索网络通信功能。更高级的改进可以包括引入虚拟内存和分页机制,以更高效、安全地管理内存。

018:网络设备驱动

概述

在本节课中,我们将学习如何与虚拟机提供的网络设备(AMD PCnet网卡)进行通信。这是一个相对复杂的设备,我们将为其编写驱动程序,包括初始化、中断处理以及为后续的数据收发做好准备。

上一节我们介绍了PCI总线枚举,本节中我们来看看如何为具体的PCI设备(网卡)编写驱动。

设备与初始化概述

AMD PCnet网卡是一个虚拟化设备,其初始化过程代码量较大。驱动程序类将继承自 DriverInterruptHandler,并从PCI配置空间中获取中断号和端口号。

以下是驱动类的基本框架和需要初始化的关键部分:

class AMDPCnet : public Driver, public InterruptHandler {
private:
    Port16Bit mac0Port;
    Port16Bit mac2Port;
    Port16Bit mac4Port;
    Port16Bit registerDataPort;
    Port16Bit registerAddressPort;
    Port16Bit resetPort;

    struct InitializationBlock {
        // ... 初始化块结构体成员
    } __attribute__((packed));

    InitializationBlock initBlock;

    // 发送和接收缓冲区(需要16字节对齐)
    uint8_t sendBufferDescrMemory[2048+15];
    uint8_t recvBufferDescrMemory[2048+15];
    // ... 其他成员和方法
};

缓冲区对齐问题

该设备要求数据缓冲区的起始地址是16字节的倍数。我们采用的方法是分配比实际需要(例如2KB)多15字节的内存,然后找到一个满足对齐要求的地址。

核心对齐操作可以通过以下公式/代码实现:

// 假设 buffer 是原始分配的数组,大小为 SIZE + 15
uint8_t buffer[SIZE + 15];
// 找到第一个16字节对齐的地址
uint8_t* alignedBuffer = (uint8_t*)(( (uint32_t)buffer + 15 ) & ~15);

详细的初始化步骤

初始化过程涉及大量寄存器操作。以下是关键步骤的分解:

首先,在构造函数中,我们设置中断处理并初始化端口:

AMDPCnet::AMDPCnet(PCIDeviceDescriptor* dev, InterruptManager* interrupts)
    : InterruptHandler(interrupts, dev->interrupt + 0x20), // 硬件中断偏移
      mac0Port(dev->portBase),
      mac2Port(dev->portBase + 0x02),
      // ... 初始化其他端口
{
    // 1. 读取MAC地址
    // 2. 重置设备
    // 3. 配置初始化块并写入设备
}

以下是初始化设备的具体操作列表:

  1. 读取MAC地址:通过三个16位端口读取48位的MAC地址。
  2. 重置设备:向复位端口写入命令,并等待一小段时间。
  3. 设置32位模式:向特定寄存器写入 0x102
  4. 配置初始化块:设置模式、缓冲区数量(例如8个发送和8个接收缓冲区)及其描述符的物理地址。
  5. 写入初始化块:将构建好的初始化块数据结构告知设备。
  6. 激活设备:在 Activate() 方法中,设置相关寄存器位以启用设备功能。

中断处理

设备通过中断来通知驱动数据到达、发送完成或发生错误。在 HandleInterrupt 方法中,我们需要读取中断状态寄存器并处理不同的事件。

中断状态寄存器的位具有不同含义,例如:

  • 0x8000:一般错误。
  • 0x2000:数据冲突错误。
  • 0x1000:帧丢失(数据过快)。
  • 0x0100:接收到数据。
  • 0x0200:数据发送成功。
  • 0x0004:初始化完成。

处理中断时,可能需要同时处理多个置位位。

uint32_t AMDPCnet::HandleInterrupt(uint32_t esp) {
    uint16_t status = registerDataPort.Read();
    // 检查多个可能同时发生的状态位
    if(status & 0x0004) { /* 处理初始化完成 */ }
    if(status & 0x0100) { /* 处理数据接收 */ }
    if(status & 0x0200) { /* 处理数据发送成功 */ }
    // ... 处理错误位
    return esp;
}

集成与测试

将驱动集成到PCI驱动管理器中。在PCI控制器检测到AMD PCnet设备后,实例化我们的驱动并将其加入驱动管理器。驱动管理器随后会调用所有驱动的 Activate() 方法。

测试时,如果系统没有崩溃并且收到了来自该设备的中断(即使未明确显示中断类型),也表明设备的初始化和激活基本成功。这为下一阶段实现数据收发奠定了基础。

总结

本节课中我们一起学习了为AMD PCnet网络设备编写驱动程序的核心过程。我们解决了缓冲区16字节对齐的技术问题,完成了包含端口初始化、配置初始化块、设置中断处理在内的复杂初始化流程,并成功将驱动集成到系统中,使其能够响应设备中断。虽然我们尚未实现实际的数据包发送和接收,但已经搭建好了必要的底层通信框架。下一节,我们将在此基础上实现以太网帧的收发,并逐步构建网络协议栈。

019:网络通信(续)

在本节课中,我们将继续完成网络驱动程序的编写,实现数据的发送与接收功能,并简要介绍网络协议栈的基本架构。

发送数据

上一节我们介绍了网络通信的初始化,本节中我们来看看如何发送数据。发送方法的核心是选择一个发送缓冲区,将数据复制进去,然后通过向网卡寄存器写入命令来触发发送。

以下是发送方法 send 的关键步骤:

void send(void* data, unsigned short size) {
    // 1. 根据当前发送缓冲区索引获取目标缓冲区地址
    unsigned int buffer_index = current_send_buffer;
    // 2. 循环移动到下一个缓冲区,以便并行处理
    current_send_buffer = (current_send_buffer + 1) % SEND_BUFFER_COUNT;
    // 3. 检查数据大小是否超过缓冲区限制(1518字节)
    if(size > MAX_FRAME_SIZE) {
        size = MAX_FRAME_SIZE; // 简单截断,实际应处理错误
    }
    // 4. 将数据从源地址复制到网卡发送缓冲区
    memcpy(send_buffers[buffer_index], data, size);
    // 5. 标记该缓冲区为“使用中”
    send_buffer_status[buffer_index] = BUFFER_IN_USE;
    // 6. 配置发送描述符,包括数据长度和状态位
    send_descriptors[buffer_index].length = size;
    send_descriptors[buffer_index].status = 0;
    // 7. 向网卡的命令寄存器写入“发送”指令
    outportl(io_base + REG_COMMAND, CMD_TRANSMIT);
}

代码中的 MAX_FRAME_SIZE 对应以太网帧的最大尺寸。向寄存器 REG_COMMAND 写入 CMD_TRANSMIT 是通知硬件开始发送的关键操作。

接收数据

发送功能完成后,我们需要处理数据的接收。接收方法 receive 会轮询接收缓冲区,检查是否有新数据到达,并进行处理。

以下是接收方法的关键逻辑:

void receive() {
    // 遍历所有接收缓冲区
    for(int i = 0; i < RECV_BUFFER_COUNT; i++) {
        // 检查当前缓冲区是否有数据(状态位不为空)
        if((recv_descriptors[current_recv_buffer].status & EMPTY_FLAG) == 0) {
            // 获取数据包长度
            unsigned short length = recv_descriptors[current_recv_buffer].length;
            // 以太网帧长度检查,移除4字节的帧校验序列(FCS)
            if(length > 64) {
                length -= 4;
            }
            // 获取数据缓冲区指针
            void* data = recv_buffers[current_recv_buffer];
            // 此处可以处理数据,例如打印或传递给上层协议
            // process_packet(data, length);
            // 处理完成后,清除缓冲区状态,将其归还给硬件
            recv_descriptors[current_recv_buffer].status = EMPTY_FLAG;
            // 循环移动到下一个缓冲区
            current_recv_buffer = (current_recv_buffer + 1) % RECV_BUFFER_COUNT;
        } else {
            // 如果遇到空缓冲区,则跳出循环
            break;
        }
    }
}

在实际操作系统中,process_packet 函数会根据以太网帧头部的协议类型字段,将数据分发给不同的上层协议处理程序(如ARP、IP)。

网络协议栈架构

现在我们已经能够发送和接收原始的以太网帧数据。这就像学会了字母表,但要进行有效沟通,还需要理解语言(即网络协议)。以下是通信所必需的核心协议层:

  • 以太网帧:最底层的数据帧格式。它包含源和目标的MAC地址(各6字节)以及一个16位的协议类型字段。例如,0x0806 表示ARP协议,0x0800 表示IPv4协议。
  • 地址解析协议:用于根据IP地址查询对应的MAC地址。如果一台计算机不响应ARP请求,网络上的其他设备将无法与其通信。
  • 网际协议:在以太网帧之上,提供逻辑上的IP地址。IP数据包头部包含源和目标IP地址。在IP头部中,有一个8位的协议号字段,用于指示上层协议。
  • 上层协议:根据IP头部的协议号,数据会被传递给不同的传输层协议。
    • 协议号 1:对应 ICMP协议。例如,ping 命令就使用ICMP。操作系统需要响应ICMP回显请求,否则其他主机会认为该主机不可达。
    • 协议号 6:对应 TCP协议。它提供可靠的、面向连接的字节流服务,实现较为复杂。
    • 协议号 17:对应 UDP协议。它提供无连接的简单报文传输,比TCP更易于实现。

这种分层结构就是著名的网络OSI模型的简化体现。实现一个基本的网络栈,至少需要处理Ethernet、ARP、IP和ICMP。UDP是一个不错的进阶目标,而TCP的实现则是一个更大的挑战。

虚拟网络环境配置

在VirtualBox等虚拟化环境中进行网络开发时,了解以下地址会很有帮助:

  • 宿主机地址:通常为 10.0.2.2。这是虚拟机内部访问宿主机(运行VirtualBox的物理机)的IP地址。
  • 默认网关:通常也是 10.0.2.2。当虚拟机需要访问外部网络(如互联网)时,数据包会发送到此地址。
  • 客户机地址:可以配置为同一子网下的地址,例如 10.0.2.15

子网掩码的作用

子网掩码(如 255.255.255.0)用于界定网络边界。其工作原理通过按位与运算实现:

公式(源IP地址 & 子网掩码) == (目标IP地址 & 子网掩码)

  • 如果上述等式成立,说明目标主机在同一本地子网内。数据可以直接通过交换机发送,无需经过网关。
  • 如果等式不成立,说明目标主机在外部网络。数据包应首先发送到默认网关,由网关负责将其路由到更广阔的网络中。

总结

本节课中我们一起学习了如何完成网络驱动程序的数据发送与接收功能。我们实现了 sendreceive 方法,并了解了数据在网卡缓冲区中的管理机制。此外,我们还概述了构建一个基本网络协议栈所需的核心协议层次:从最底层的以太网帧,到ARP、IP,再到ICMP或UDP等上层协议。最后,我们讨论了在虚拟环境中进行网络配置的要点以及子网掩码的工作原理。在接下来的课程中,我们将开始探索硬盘驱动相关的知识。

020:硬盘驱动器 💾

在本节课中,我们将学习如何与硬盘驱动器进行通信。硬盘是计算机中用于长期存储数据的关键部件。我们将了解其基本概念、历史背景,并实现一个简单的程序化输入/输出(PIO)模式来读写硬盘扇区。

概述

硬盘驱动器技术历史悠久,从早期的IDE标准发展到现代的SATA。操作系统需要与硬盘控制器通信来读写数据。我们将重点介绍相对简单的PIO模式,并实现硬盘识别、读取和写入扇区的基本功能。

硬盘技术简史

上一节我们介绍了课程目标,本节中我们来看看硬盘技术的发展历程。硬盘驱动器在计算机中已存在很长时间。大约20年前,主流标准是IDE(集成设备电子部件)。后来发展为ATA(高级技术附件),而如今我们使用的是SATA(串行ATA)。从阅读资料可知,SATA设备通常兼容ATA或AHCI(高级主机控制器接口)模式。ATA与IDE基本相同。

在VirtualBox等虚拟机中,为了使用我们将要编写的ATA代码,需要添加IDE控制器,而不是SATA控制器。

硬盘访问模式

有两种主要的硬盘访问方式:

  • 程序化输入/输出(PIO):这种方式相对较慢,但实现简单。处理器需要持续与控制器通信,逐个字节地请求或发送数据。速度大约在16MB/s。
  • 直接内存访问(DMA):这种方式性能更好。处理器告诉控制器将数据直接写入内存的特定位置,完成后通过中断通知处理器。这样处理器在数据传输期间可以处理其他任务。

由于PIO模式更简单,我们将在教程中实现它。

寻址模式:CHS 与 LBA

与硬盘通信时,你会遇到几个术语:

  • CHS(柱面-磁头-扇区):这是一种较老的寻址方式。操作系统需要知道硬盘的物理结构(柱面、磁头、扇区数量)并指定这三个值来定位扇区。
  • LBA(逻辑块地址):这是一种更现代的寻址方式。操作系统只需提供一个扇区编号,由硬盘内部负责将其转换为物理位置。这更合理,尤其是对于固态硬盘这类没有传统机械结构的设备。

LBA地址与CHS地址可以通过公式转换。在现代操作系统中,我们主要使用LBA寻址。

PIO模式支持28位和48位LBA寻址:

  • 28位模式:可寻址最多 2^28 个扇区。每个扇区通常为512字节,因此最大支持约 2^28 * 512 bytes ≈ 137 GB。历史上由于符号整数等问题,实际限制更低(如2GB或4GB)。
  • 48位模式:可寻址最多 2^48 个扇区,支持高达128PB的容量。

本教程中,我们将实现28位模式,其原理与48位模式相似。

硬盘控制器端口

要与硬盘控制器通信,我们需要通过I/O端口。虽然现代系统最好从PCI控制器读取这些端口号,但它们通常是标准化的。

一个ATA控制器总线通过一组I/O端口进行通信,主要端口如下:

以下是主要端口及其功能列表:

  • 数据端口:16位端口,用于读取或写入扇区数据。
  • 错误端口:读取错误信息。
  • 扇区计数端口:指定要读取或写入的扇区数量。
  • LBA低位/中位/高位端口:用于传输28位LBA地址的三个部分。
  • 设备端口:用于选择主盘或从盘,并传输LBA地址的最高4位(在28位模式下)。
  • 命令端口:发送具体操作指令(如读、写、识别)。
  • 状态/控制端口:读取设备状态或发送控制命令。

通常有多个ATA总线(如主通道、次通道)。每个通道可以连接两个设备:一个主设备和一个从设备。它们的端口基地址是固定的,例如:

  • 主通道:基地址 0x1F0
  • 次通道:基地址 0x170

实现硬盘驱动

现在,我们开始实现一个简单的硬盘驱动类。核心操作包括识别硬盘、读取扇区和写入扇区。

构造函数与初始化

在构造函数中,我们设置端口基地址、设备类型(主/从)以及每扇区字节数(默认为512)。

class IDEChannel {
public:
    IDEChannel(uint16_t portBase, bool master);
    void identify();
    void read28(uint32_t sector, uint8_t* data, int count);
    void write28(uint32_t sector, uint8_t* data, int count);
    void flush();

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/wrt-yrwn-sys/img/47624b3337b5d790adb393b819a99da1_13.png)

private:
    uint16_t portBase;
    bool master;
    uint16_t bytesPerSector;
    // ... 其他成员和端口偏移量定义
};

识别硬盘

identify 方法用于检测硬盘是否存在并获取其信息。我们向命令端口发送 0xEC 指令,然后等待状态就绪,最后从数据端口读取512字节的识别信息。

void IDEChannel::identify() {
    // 1. 选择设备(主/从)
    // 2. 发送识别命令 (0xEC)
    // 3. 轮询状态端口,等待设备就绪 (BSY=0, DRQ=1) 或出错 (ERR=1)
    // 4. 如果就绪,从数据端口连续读取256次(每次16位),共512字节数据
    // 5. 处理或打印识别信息
}

读取扇区

read28 方法用于从指定LBA扇区读取数据。步骤与识别类似,但命令是 0x20。我们需要确保读取完整的扇区数据。

以下是读取扇区的步骤列表:

  1. 检查LBA地址是否有效(28位内)。
  2. 设置设备端口,选择主/从盘并写入LBA高4位。
  3. 设置扇区数量(通常为1)。
  4. 将28位LBA地址的低24位写入对应的三个端口。
  5. 发送读取命令 0x20
  6. 等待设备就绪(DRQ标志置位)。
  7. 从数据端口循环读取数据到提供的缓冲区。
  8. 如果请求的字节数不足一个扇区,仍需读取并丢弃剩余数据,以清空控制器缓冲区。

写入扇区

write28 方法用于向指定LBA扇区写入数据。命令是 0x30。写入操作必须写满整个扇区,如果数据不足,需要用零填充。

以下是写入扇区的步骤列表:

  1. 检查LBA地址是否有效。
  2. 设置设备端口和LBA地址(与读取相同)。
  3. 发送写入命令 0x30
  4. 立即将数据通过数据端口循环写入控制器。
  5. 如果数据不足512字节,用零填充剩余部分。
  6. 注意:写入的数据首先被缓存在硬盘的内部缓冲区中。

刷新缓存

由于写入操作会被缓存,必须使用 flush 方法将缓存数据真正写入磁盘介质,以防止断电时数据丢失。我们发送 0xE7 命令并等待操作完成。

void IDEChannel::flush() {
    // 1. 选择设备
    // 2. 发送缓存刷新命令 (0xE7)
    // 3. 等待设备不再繁忙 (BSY=0)
}

数据完整性考量

简单的写入后刷新策略在断电时可能导致数据丢失。更高级的文件系统会使用日志技术来保证一致性。其基本思想是:

  1. 在真正修改磁盘数据前,先将修改意图记录在日志区域。
  2. 分步更新日志和主数据区,并确保至少有一个副本始终是完整可用的。
  3. 系统启动时检查日志,恢复未完成的操作,避免扫描整个磁盘。

测试与演示

在虚拟机中添加一个IDE硬盘(例如,作为主通道从盘),然后运行代码。我们可以:

  1. 调用 identify() 读取硬盘信息并打印。
  2. 调用 write28() 向扇区0写入一段测试数据。
  3. 调用 flush() 确保数据落盘。
  4. 调用 read28() 从扇区0读取数据,验证是否与写入的一致。

通过串口或屏幕输出,我们可以看到操作的成功执行。

总结

本节课中我们一起学习了硬盘驱动的基础知识。我们回顾了硬盘从IDE到SATA的发展,理解了PIO和DMA两种访问模式的区别,以及CHS和LBA两种寻址方式。我们重点实现了基于PIO模式和28位LBA寻址的硬盘驱动,包含了识别、读取、写入和刷新缓存等核心功能。虽然这是一个简单的驱动,但它为操作系统的存储层奠定了基础。

目前我们只能以扇区为单位进行读写。在后续课程中,我们将在此基础上探讨分区表和文件系统(如FAT),并最终通过系统调用为用户程序提供安全、抽象的存储访问接口。下一节课,我们将学习如何实现系统调用。

021:系统调用与POSIX合规性

概述

在本节课中,我们将学习操作系统中的一个核心概念:系统调用。我们将探讨为何需要系统调用,它们如何工作,以及如何通过它们实现用户空间程序与内核的安全通信。我们还将了解系统调用与POSIX标准的关系。

在前面的19节教程中,我们已经构建了一个功能相对完整的操作系统内核。它能够启动、从硬盘加载数据、读写网络、分配内存、打印到屏幕、与PCI硬件通信、读取鼠标输入等。内核还可以分配内存,从硬盘加载可执行二进制文件,并将其附加到任务管理系统中并行执行。

然而,我们面临一个问题:我们希望运行在用户模式的程序不能直接访问某些硬件或系统资源。例如,我们不希望一个用户程序能直接向硬盘的特定扇区写入数据,因为这可能破坏系统文件。因此,我们需要一种机制,既能允许程序执行必要的操作(如读写文件),又能防止它们进行危险操作。

系统调用的原理

系统调用就是解决上述问题的机制。其核心思想是:用户空间的程序不能直接执行特权指令(如直接操作硬件的outb汇编命令),而是通过一个预定义的“门”向内核请求服务。

具体工作流程如下:

  1. 用户程序将代表特定操作的编号(例如,5代表“打开文件”)放入EAX寄存器。
  2. 用户程序将操作所需的参数(例如,指向文件名字符串的指针)放入其他寄存器(如EBX)。
  3. 用户程序执行一条软件中断指令(通常是int 0x80)。
  4. CPU接收到中断,切换到内核模式,并跳转到内核预先设置好的中断处理函数。
  5. 内核的中断处理函数检查EAX中的编号,判断用户请求的操作。
  6. 内核根据当前进程的权限(例如,用户ID)进行安全检查。
  7. 如果检查通过,内核代表用户程序执行请求的操作(如打开文件)。
  8. 操作完成后,内核将结果(成功或错误码)放入寄存器(如EAX),然后返回到用户程序。

通过这种方式,内核完全控制了用户程序对系统资源的访问。用户程序无法绕过内核直接操作硬件。

实现一个简单的系统调用

以下我们将通过代码演示如何实现一个简单的print系统调用。

首先,我们需要一个能够处理0x80号中断的中断处理程序。这个处理程序需要能访问中断发生时保存的CPU状态(寄存器值)。

#include “multitasking.h” // 为了使用CPUState结构体

class SyscallHandler : public InterruptHandler
{
public:
    SyscallHandler(InterruptManager* interruptManager, uint8_t interruptNumber);
    ~SyscallHandler();
    virtual uint32_t HandleInterrupt(uint32_t esp);
};

HandleInterrupt方法中,我们通过esp指针获取保存的CPU状态,并检查EAX寄存器中的系统调用编号。

uint32_t SyscallHandler::HandleInterrupt(uint32_t esp)
{
    CPUState* cpu = (CPUState*)esp;

    switch(cpu->eax) // 检查系统调用编号
    {
        case 4: // 假设编号4是我们的print系统调用
            printf((char*)cpu->ebx); // ebx中存储了要打印的字符串指针
            break;
        // 可以在这里添加更多case来处理其他系统调用,如打开文件(5)、读取文件(3)等
        default:
            break;
    }
    return esp;
}

接下来,我们需要在中断描述符表(IDT)中注册这个处理程序,使其能够响应int 0x80。注意,0x80是软件中断,不是来自可编程中断控制器(PIC)的硬件中断,因此不需要像硬件中断那样添加偏移量(0x20)。

// 在kernel.cpp或类似的初始化函数中
InterruptManager interruptManager(0x20, &gdt, &taskManager);
SyscallHandler syscallHandler(&interruptManager, 0x80); // 直接使用0x80

最后,用户空间的程序(我们的任务)需要通过汇编指令来触发这个系统调用。

// 在用户任务函数中
void taskA()
{
    while(1)
    {
        // 使用内联汇编发起系统调用
        asm volatile(“mov $4, %%eax\n\t”   // 将系统调用号4(print)放入eax
                     “mov %0, %%ebx\n\t”   // 将字符串指针参数放入ebx
                     “int $0x80”           // 触发0x80号中断
                     :
                     : “r” (“Hello from Task A!\n”)
                     : “eax”, “ebx”);
        // ... 其他代码
    }
}

taskA执行int $0x80时,CPU会跳转到我们注册的SyscallHandler::HandleInterrupt函数。该函数看到eax为4,便从ebx取出字符串指针,并调用内核的printf函数进行打印。这样,用户程序就通过系统调用安全地完成了屏幕输出。

系统调用与POSIX标准

POSIX(可移植操作系统接口)是一系列标准,它定义了操作系统应该为应用程序提供的接口。我们刚才实现的系统调用机制,正是内核侧满足POSIX标准的基础。

在POSIX标准中,定义了大量的函数,如open()read()write()fork()等。这些函数在用户空间的C库(如glibc)中实现。当应用程序调用printf()时,C库的内部实现最终会通过类似int 0x80的系统调用方式,请求内核的实际服务。

因此,内核需要实现一套系统调用,其编号和参数约定与POSIX标准兼容。例如:

  • eax = 5 (SYS_open):打开文件,ebx指向路径名。
  • eax = 3 (SYS_read):读取文件描述符,ebx是文件描述符。
  • eax = 4 (SYS_write):写入文件描述符。

通过实现这些后端系统调用,并提供一个实现了所有POSIX标准函数的C库,我们的操作系统就能够运行绝大多数符合标准的C程序。

总结与展望

本节课我们一起学习了系统调用的核心概念和实现方法。系统调用是用户空间程序与内核安全通信的桥梁,它通过软件中断机制,让内核代表用户程序执行特权操作,从而实现了资源的受控访问。

我们实现了一个简单的打印系统调用,并理解了系统调用编号与参数传递的约定。此外,我们还看到了系统调用是实现POSIX合规性的关键,是运行标准应用程序的基础。

目前我们的“任务”仍然运行在内核模式,它们可以直接调用printf。一个真正完整的系统还需要最后一步:将任务强制切换到用户模式,并配置CPU,使得在用户模式下执行特权指令(如直接outb或访问内核内存)会触发异常,从而迫使所有程序必须通过系统调用接口来请求内核服务。这将是完善我们操作系统的下一个重要步骤。

022:以太网帧处理 🖧

概述

在本节中,我们将学习如何为操作系统中的网络设备驱动添加以太网帧处理功能。我们将创建一个框架,用于接收原始网络数据,并根据其类型(如ARP或IPv4)将其分发给相应的协议处理器。


以太网帧结构

上一节我们介绍了网络设备驱动的基础。本节中,我们来看看如何处理驱动接收到的原始数据。这些数据遵循以太网帧格式。

一个以太网帧包含以下部分:

  • 目标MAC地址:6字节,指定数据包的目的地。如果地址是 0xFFFFFFFFFFFF,则表示这是一个广播包,发送给网络中的所有设备。
  • 源MAC地址:6字节,标识数据包的发送者。
  • 以太类型:2字节,用于标识帧内承载的上层协议类型(如ARP或IPv4)。这些值采用大端字节序存储。
  • 数据载荷:帧的实际内容。
  • 帧校验序列:4字节,用于错误检测。

我们的驱动已经可以发送和接收这种原始数据。现在的目标是附加一个处理器来检查这些数据,并决定将其传递给哪个上层协议。


设计处理器框架

我们将采用一种面向对象的方式来实现这个处理器框架,使用控制反转模式,这在面向对象编程中很常见。

我们将创建一个处理器数组。每个处理器可以将自己注册到这个数组中。当收到数据时,我们会遍历这个数组,找到能处理该以太类型的处理器。

以下是核心类的设计思路:

  1. EthernetFrameHandler(以太网帧处理器基类):所有具体协议处理器(如ARP、IPv4)都将继承自这个类。它包含一个处理接收帧的虚方法。
    class EthernetFrameHandler {
    public:
        EthernetFrameHandler(EthernetFrameProvider* backend, uint16_t etherType);
        ~EthernetFrameHandler();
        virtual bool OnEthernetFrameReceived(uint8_t* payload, uint32_t size);
        void Send(uint8_t* destMac, uint32_t size);
    };
    

  1. EthernetFrameProvider(以太网帧提供者):作为网络设备驱动的后端接口。它管理一个EthernetFrameHandler指针数组,并负责将接收到的帧分发给正确的处理器。它还需要提供发送数据的能力。
    class EthernetFrameProvider {
    public:
        EthernetFrameProvider();
        ~EthernetFrameProvider();
        bool HandleFrame(uint8_t* buffer, uint32_t size);
        void SendFrame(uint8_t* destMac, uint16_t etherType, uint8_t* buffer, uint32_t size);
        uint64_t GetMACAddress();
    };
    

实现帧处理流程

以下是处理接收到的数据帧的主要步骤:

  1. 解析帧头:将接收到的数据缓冲区映射到一个帧头结构体上,以便访问目标地址、源地址和以太类型字段。

    struct EthernetFrameHeader {
        uint8_t dstMAC[6];
        uint8_t srcMAC[6];
        uint16_t etherType;
    } __attribute__((packed));
    
  2. 检查目标地址:判断该帧是否是发送给本机的(匹配本机MAC地址)或是一个广播帧。如果不是,则忽略。

  1. 查找处理器:根据帧头中的etherType字段,在已注册的处理器数组中查找对应的处理器。

  1. 分发数据:如果找到匹配的处理器,则将帧的数据载荷部分(即去除帧头后的数据)传递给该处理器的OnEthernetFrameReceived方法。

  2. 处理回复:如果处理器方法返回true,表示需要发送回复。此时,我们需要:

    • 交换帧头中的源MAC地址和目标MAC地址(将原发送者设为目标,将本机MAC设为源)。
    • 将修改后的整个帧(包含新的帧头和处理后/新的载荷)交还给网络驱动发送出去。

实现数据发送

处理器也可能需要主动发送数据。EthernetFrameHandlerSend方法将利用其后端提供者EthernetFrameProvider来发送帧。

发送数据时,需要:

  1. 分配内存,大小为 以太网帧头大小 + 数据载荷大小
  2. 填充帧头:设置目标MAC、源MAC(本机地址)和以太类型。注意字节序转换。
  3. 拷贝数据载荷到帧头之后的内存中。
  4. 调用后端提供者的发送函数,将完整的帧传递给网络设备驱动。


总结

本节课中我们一起学习了如何为操作系统的网络子系统构建一个基础的以太网帧处理框架。我们定义了EthernetFrameHandlerEthernetFrameProvider两个核心类,实现了接收帧的解析、处理器分发以及回复发送的流程。这个框架为后续实现ARP、IPv4等具体网络协议处理器奠定了基础。下一节,我们将利用这个框架来实现地址解析协议。

023:地址解析协议 (ARP) 🧩

概述

在本节中,我们将学习如何实现地址解析协议(ARP)。ARP是网络通信的基础协议之一,用于将IP地址解析为对应的MAC地址。我们将编写一个ARP处理器,集成到之前实现的以太网帧处理框架中,使我们的操作系统能够进行基本的网络地址查询。


背景回顾

上一节我们编写了处理以太网帧的代码,这些帧来自网络驱动程序。以太网帧处理器会检查接收到的帧,判断其目标MAC地址是否为本机地址或广播地址。如果是,则根据帧头中的“以太类型”字段,将数据块转发给对应的协议处理器。目前,我们还没有为任何以太类型注册处理器。本节我们将为ARP协议编写一个处理器,因为它是相对简单的协议。

ARP主要用于计算机之间的通信。当一台计算机想知道另一台计算机的MAC地址时,就会使用ARP。维基百科上关于此协议的描述非常详尽。

ARP数据结构解析

一个ARP数据块的结构如下:

以下是ARP数据包各字段的详细说明:

  • 硬件类型:占2字节,大端序编码。表示使用的硬件类型,例如以太网对应值为 1,实际存储为 0x0001
  • 协议类型:占2字节,大端序编码。表示要映射的协议地址类型,IPv4对应值为 0x0800
  • 硬件地址长度:占1字节。表示硬件地址的长度,对于MAC地址,此值为 6
  • 协议地址长度:占1字节。表示协议地址的长度,对于IPv4地址,此值为 4
  • 操作码:占2字节。表示此消息的目的,例如请求(1)或响应(2)。
  • 发送方MAC地址:占6字节。
  • 发送方IP地址:占4字节。
  • 目标MAC地址:占6字节。
  • 目标IP地址:占4字节。

需要注意的是,操作码之后的数据长度取决于前面定义的硬件和协议地址长度。在我们的实现中,我们将硬编码为以太网(硬件地址长度6)和IPv4(协议地址长度4)。

ARP处理器的设计

ARP类需要知道本机的IP地址,以便能够响应关于自身IP地址的查询。我们将在网络卡驱动程序的初始化块中设置IP地址,并传递给ARP处理器。

在以太网帧处理器中,ARP将作为一个具体的帧处理器被注册。我们将定义一个结构体来表示ARP消息。

你可能会有疑问:为什么ARP数据包中已经包含了发送方和目标方的MAC地址,而外层的以太网帧头也包含了这些信息?这是因为通信路径上可能存在多个中间设备(如路由器)。外层以太网帧头的地址总是在当前直接相连的两个设备之间变化,而ARP数据包内的地址则始终表示通信的原始发起者和最终目标者。

代码实现步骤

现在,让我们开始具体的代码实现。

1. 定义ARP消息结构体

首先,我们需要定义一个结构体来映射ARP数据包。

typedef struct {
    uint16_t hardware_type;
    uint16_t protocol_type;
    uint8_t hardware_addr_len;
    uint8_t protocol_addr_len;
    uint16_t opcode;
    uint8_t sender_mac[6];
    uint8_t sender_ip[4];
    uint8_t target_mac[6];
    uint8_t target_ip[4];
} arp_message_t;

2. 创建ARP处理器类

ARP处理器继承自以太网帧处理器。其构造函数需要接收后端驱动和本机IP地址。

class ARPHandler : public EthernetFrameHandler {
private:
    uint32_t my_ip; // 本机IP地址,大端序
    arp_cache_entry_t cache[128]; // 简单的ARP缓存
    int cache_entries;

public:
    ARPHandler(NetworkDriverBackend* backend, uint32_t ip_addr);
    bool onEthernetFrameReceived(uint8_t* buffer, uint32_t size) override;
    void requestMAC(uint32_t ip);
    uint64_t getMACFromCache(uint32_t ip);
    uint64_t resolveMAC(uint32_t ip); // 请求并等待解析
};

在构造函数中,我们调用基类构造函数,以太类型设置为 0x0806(ARP),并初始化ARP缓存。

3. 实现MAC地址请求

当我们需要知道某个IP地址对应的MAC时,就发送一个ARP请求。

以下是发送ARP请求的步骤:

  1. 在栈上构造一个ARP消息结构体。
  2. 设置硬件类型为 1(以太网),协议类型为 0x0800(IPv4)。
  3. 设置硬件地址长度为 6,协议地址长度为 4
  4. 设置操作码为 1(请求)。
  5. 填充发送方(本机)的MAC地址和IP地址。
  6. 将目标MAC地址设置为广播地址(FF:FF:FF:FF:FF:FF),目标IP地址设置为要查询的IP。
  7. 调用继承自基类的 send 方法,将整个ARP消息作为数据负载发送出去。

4. 处理接收到的ARP消息

当接收到以太网帧时,如果以太类型是ARP,则会调用 onEthernetFrameReceived 方法。

以下是处理ARP消息的逻辑:

  1. 将数据缓冲区转换为 arp_message_t 指针(需先检查数据大小是否足够)。
  2. 验证硬件类型、协议类型、地址长度等字段是否符合预期(以太网/IPv4)。
  3. 检查操作码:
    • 如果是 响应0x0200):将发送方的IP和MAC地址对存储到本地ARP缓存中。
    • 如果是 请求0x0100)并且目标IP是本机IP:将操作码改为响应(0x0200),交换发送方和目标方的地址信息(将原发送方信息作为新目标,将本机信息作为新发送方),然后返回 true 以指示需要发送回复。
  4. 如果消息不是给本机的,目前我们不做任何处理(更复杂的实现如网关可能会代为应答或转发)。

5. 实现地址解析方法

我们提供一个 resolveMAC 方法,它首先查询缓存,如果找不到,则发送请求并循环等待直到缓存中出现对应的条目。请注意,这是一个简单的实现,缺乏超时机制,如果目标主机不存在,此循环可能永远不会结束。

集成与测试

现在,我们将ARP处理器集成到系统中并进行测试。

以下是集成步骤:

  1. 在Makefile中添加ARP处理器的编译选项。
  2. 在主程序初始化网络驱动时,手动设置本机的IP地址(例如 10.0.2.15)和网关IP地址(例如 10.0.2.2)。
  3. 创建ARP处理器实例,并将其注册到以太网帧处理器中。
  4. 在启用中断后,尝试解析网关的MAC地址。
  5. 为了观察过程,我们可以在接收数据时打印出原始的以太网帧数据。

运行测试后,我们成功观察到以下过程:

  1. 本机发送了一个ARP请求广播帧。
  2. 收到了网关发回的ARP响应单播帧。
  3. 响应帧中的信息正确无误:操作码为响应,包含了网关自身的MAC和IP地址,目标地址是本机的MAC和IP。

这证明我们编写的网络驱动、以太网帧处理和ARP协议代码全部协同工作正常,操作系统已经能够进行基本的网络层通信。

总结

本节课我们一起学习了地址解析协议(ARP)的原理与实现。我们成功将ARP处理器集成到操作系统的网络栈中,实现了IP地址到MAC地址的查询与响应功能,并通过实际测试验证了通信的成功。这是构建完整网络协议栈的关键一步,为我们后续实现更复杂的协议(如IPv4、ICMP、UDP)奠定了坚实的基础。下一节,我们将开始探索IPv4协议。

024:互联网协议 (IPv4) 🌐

在本节课中,我们将学习如何为IPv4协议实现一个处理器。这是构建网络功能的关键一步,它将允许我们的操作系统发送和接收IP数据包。

概述

在之前的课程中,我们为网络驱动编写了处理器,并实现了地址解析协议(ARP)。现在,我们将处理以太网帧中类型为 0x0800 的数据,这对应着IPv4协议。IPv4是互联网通信的基础,它负责将数据包从源地址路由到目标地址。

IPv4数据包结构

IPv4数据包由头部和有效载荷组成。头部包含多个字段,用于控制数据包的传输。以下是IPv4头部的结构:

struct IPv4Header {
    uint8_t version_ihl;      // 版本(4位) + 头部长度(4位)
    uint8_t tos;              // 服务类型
    uint16_t total_length;    // 总长度
    uint16_t identification;  // 标识
    uint16_t flags_fragment;  // 标志(3位) + 分片偏移(13位)
    uint8_t ttl;              // 生存时间
    uint8_t protocol;         // 协议
    uint16_t checksum;        // 头部校验和
    uint32_t src_ip;          // 源IP地址
    uint32_t dest_ip;         // 目标IP地址
};
  • 版本和头部长度:版本固定为4。头部长度以4字节为单位,最小值为5(20字节)。
  • 服务类型:通常设置为0,表示普通消息。
  • 总长度:整个IP数据包(头部+数据)的长度,以字节为单位。
  • 标识、标志和分片偏移:用于处理数据包分片。在我们的简单实现中,我们发送小数据包,不进行分片。
  • 生存时间:数据包每经过一个路由器(一跳)就减1,减到0时被丢弃,防止数据包在网络中无限循环。
  • 协议:指示有效载荷中数据的类型(例如,1=ICMP,6=TCP,17=UDP)。
  • 校验和:仅针对IP头部进行计算,用于检测传输错误。
  • IP地址:32位的源地址和目标地址。

实现IPv4处理器

上一节我们介绍了ARP处理器,它负责将IP地址解析为MAC地址。本节中,我们来看看如何构建IPv4处理器,它将在网络协议栈中扮演承上启下的角色。

我们将创建一个 IPv4Provider 类,它继承自 EtherFrameHandler。它的核心工作是检查接收到的以太网帧,如果类型是 0x0800,则将其作为IPv4数据包处理。

以下是 IPv4Provider 的关键方法:

构造函数:需要后端驱动、ARP处理器指针、网关IP和子网掩码。

IPv4Provider(EtherFrameProvider* backend,
             AddressResolutionProtocol* arp,
             uint32_t gatewayIP,
             uint32_t subnetMask);

接收数据OnEtherFrameReceived 方法检查以太网帧类型,如果是IPv4,则解析头部,检查目标IP是否为本机,然后根据协议号将有效载荷传递给相应的处理器(如未来的ICMP、TCP处理器)。

bool OnEtherFrameReceived(uint8_t* etherframePayload, uint32_t size);

发送数据Send 方法负责构建一个完整的IPv4数据包并发送出去。

void Send(uint32_t destIP, uint8_t protocol, uint32_t dataSize, uint8_t* data);

在发送数据时,需要确定下一跳的MAC地址:

  1. 判断目标IP是否在本地子网内(使用 (destIP & subnetMask) == (myIP & subnetMask))。
  2. 如果在本地子网,则通过ARP解析目标IP的MAC地址。
  3. 如果不在本地子网,则解析网关IP的MAC地址,将数据包发给网关。

关键实现细节

在实现过程中,有几个细节需要特别注意:

数据长度处理:从以太网帧中提取IPv4数据时,不能直接使用以太网帧的长度。必须使用IPv4头部中的 total_length 字段,并且要与以太网帧的剩余长度取最小值,以防止类似“心脏滴血”的攻击,即攻击者伪造一个过大的长度值来读取内存后续的数据。

uint32_t ipDataLength = min(ntohs(ipHeader->total_length), size);

校验和计算:IPv4头部校验和的计算有些特殊。算法是将头部每16位作为一个数字相加(如果头部长度为奇数,则最后一个字节补0),然后将加法过程中产生的所有进位(即高16位)再加回到低16位上,重复此过程直到高16位为0,最后对结果取反。

uint16_t CalculateChecksum(uint16_t* data, uint32_t lengthInBytes) {
    uint32_t sum = 0;
    for (uint32_t i = 0; i < lengthInBytes / 2; i++) {
        sum += ((data[i] & 0xFF00) >> 8) | ((data[i] & 0x00FF) << 8); // 转换为大端序相加
    }
    if (lengthInBytes % 2) {
        sum += ((uint16_t)((uint8_t*)data)[lengthInBytes - 1]) << 8;
    }
    while (sum >> 16) {
        sum = (sum & 0xFFFF) + (sum >> 16);
    }
    return (uint16_t)(~sum);
}

虚函数:确保基类中的 OnRawDataReceived 等方法声明为 virtual,这样在派生类中重写它们时,多态性才能正确工作,否则数据无法正确传递到子类处理器。

测试与验证

完成编码后,我们进行测试。当发送一个IPv4数据包时,使用网络调试工具可以看到:

  1. 首先发出一个ARP请求(如果网关MAC地址未知)。
  2. 收到ARP回复,获得网关MAC地址。
  3. 成功发送一个类型为 0x0800 的以太网帧,其中包含了我们构建的IPv4头部和数据。

这表明我们的IPv4发送功能已经基本实现。目前我们还没有处理接收到的IPv4数据包(如下一节的ICMP Ping),但发送通道已经打通。

总结

本节课中我们一起学习了IPv4协议的基本结构,并成功实现了一个IPv4处理器。我们了解了IP头部各个字段的含义,实现了数据包的封装、发送、目标地址判断(本地或网关)以及头部校验和的计算。这是实现网络协议栈的重要一步,为后续实现ICMP、TCP、UDP等高级协议奠定了基础。在下一课中,我们将基于IPv4来实现ICMP协议,并让我们的操作系统能够响应Ping请求。

025:互联网控制消息协议 (ICMP) 🛰️

在本节课中,我们将学习如何为互联网控制消息协议(ICMP)实现一个处理器。ICMP是一个相对简单的网络协议,主要用于诊断和错误报告,例如我们熟悉的ping命令。我们将基于之前实现的IPv4处理器来构建它。

上一节我们介绍了IPv4协议及其处理器。它负责查看数据包中的协议字段,并将数据传递给相应的上层协议处理器。到目前为止,我们还没有为这些上层协议编写任何处理器,但今天我们将改变这一点。

ICMP协议简介

ICMP协议结构简单。根据维基百科,一个ICMP消息至少包含8字节的头部。其核心字段包括:

  • 类型 (Type):一个8位整数,指示消息的类型(例如,请求或回复)。
  • 代码 (Code):一个8位整数,提供关于类型的更详细信息。
  • 校验和 (Checksum):一个16位整数,用于验证数据的完整性。
  • 数据 (Data):可变长度的附加信息,对于ping(回显请求)而言,通常包含一个标识符和序列号。

对于ping操作,当收到一个类型为8的ICMP回显请求时,我们只需将类型改为0(回显应答),重新计算校验和,然后将数据原样发回即可。

实现ICMP处理器

以下是实现ICMP处理器的核心步骤。我们将创建一个继承自InternetProtocolHandler的类。

1. 定义ICMP消息结构

首先,我们需要一个结构体来表示ICMP头部。

struct ICMPHeader {
    uint8_t type;
    uint8_t code;
    uint16_t checksum;
    uint32_t data; // 对于ping请求/应答,通常包含标识符和序列号
} __attribute__((packed));

2. 创建ICMP处理器类

我们的ICMPHandler类将继承自InternetProtocolHandler,并处理协议号1(ICMP)。

class ICMPHandler : public InternetProtocolHandler {
public:
    ICMPHandler(InternetProtocolProvider* backend);
    ~ICMPHandler();

    // 重写基类的数据接收处理方法
    virtual bool OnInternetProtocolReceived(uint32_t srcIP_BE, uint32_t dstIP_BE, uint8_t* internetprotocolPayload, uint32_t size);

    // 发送一个ping请求
    void RequestEchoReply(uint32_t ip_be);
};

3. 实现构造函数

构造函数非常简单,只需调用基类构造函数并指定ICMP的协议号(1)。

ICMPHandler::ICMPHandler(InternetProtocolProvider* backend)
: InternetProtocolHandler(backend, 0x01) // ICMP的协议号是1
{
}

4. 实现发送Ping请求

要发送一个ping请求,我们需要构建一个ICMP消息。

void ICMPHandler::RequestEchoReply(uint32_t ip_be) {
    ICMPHeader icmp;
    icmp.type = 8; // ICMP Echo Request
    icmp.code = 0;
    icmp.data = 0x1337; // 可以设置为任意值,此处是一个示例标识
    icmp.checksum = 0;
    // 计算校验和(可以使用IPv4中的相同方法)
    icmp.checksum = InternetProtocolProvider::Checksum((uint16_t*)&icmp, sizeof(ICMPHeader));

    // 通过基类方法发送消息
    Send(ip_be, (uint8_t*)&icmp, sizeof(ICMPHeader));
}

5. 处理接收到的ICMP数据

这是处理器的核心。当收到数据时,我们判断其类型并作出响应。

bool ICMPHandler::OnInternetProtocolReceived(uint32_t srcIP_BE, uint32_t dstIP_BE, uint8_t* payload, uint32_t size) {
    if(size < sizeof(ICMPHeader))
        return false; // 数据太小,不是有效的ICMP消息

    ICMPHeader* msg = (ICMPHeader*)payload;

    switch(msg->type) {
        case 0: // ICMP Echo Reply
            // 这是我们发出的ping请求的回复
            // 可以在这里处理回复,例如打印信息
            break;
        case 8: // ICMP Echo Request
            // 收到其他主机发来的ping请求,需要回复
            msg->type = 0; // 改为回复类型
            msg->checksum = 0; // 重置校验和以便重新计算
            // 重新计算整个ICMP消息的校验和
            msg->checksum = InternetProtocolProvider::Checksum((uint16_t*)msg, sizeof(ICMPHeader));
            // 返回true,表示需要将此回复数据发送出去
            return true;
    }
    return false; // 不处理其他类型的消息
}

处理ARP缓存问题

在测试时,你可能会发现网关(或目标机器)因为不知道我们的MAC地址而无法直接回复ICMP应答。它会先发送一个ARP请求来查询我们的地址。

为了解决这个问题,我们可以在ARPHandler中添加一个方法,主动向目标发送我们的MAC和IP地址信息(类似于一个未经请求的ARP应答),这样目标机器的ARP缓存中就有了我们的记录。

void ARPHandler::RequestMACAddress(uint32_t IP_BE) {
    // ... 先发送正常的ARP请求 ...
    // 收到ARP回复后,可以再主动发送一次包含自身信息的ARP消息
    ARPMessage arp;
    arp.hardwareType = 0x0100; // 以太网
    arp.protocol = 0x0008;     // IPv4
    // ... 设置操作码为应答,填充自身MAC和IP ...
    // 发送这个ARP消息
}

在发送ping之前,先调用此方法告知网关我们的地址,可以确保ping流程更顺畅。

测试与验证

完成代码后,进行测试。你应该能看到以下流程:

  1. 发送ARP请求以获取网关MAC地址。
  2. 收到ARP回复。
  3. (可选)主动发送自身信息给网关。
  4. 发送ICMP Echo Request (ping)。
  5. 收到ICMP Echo Reply。

在输出日志中,注意观察ICMP消息的类型字段从8(请求)变为0(应答),并且校验和正确更新。

总结

本节课中我们一起学习了ICMP协议的基本原理,并成功实现了一个ICMP协议处理器。我们完成了以下工作:

  • 理解了ICMP报文的结构,特别是用于ping命令的回显请求与回显应答。
  • 创建了ICMPHandler类,继承自我们之前构建的网络协议处理框架。
  • 实现了发送ping请求和处理ping请求/应答的逻辑。
  • 解决了实际网络中可能遇到的ARP缓存问题,使ping测试得以成功。

这个实现相对简单,但它为操作系统添加了重要的网络诊断能力。下一节课,我们可能会探讨更复杂的传输层协议,如UDP,或者转向文件系统相关的主题。

026:用户数据报协议 (UDP) 📡

在本附录中,我们将学习用户数据报协议(UDP)。UDP是一种传输层协议,与TCP类似,但更简单、更快。我们将了解它的工作原理,并动手实现一个基本的UDP协议栈,包括发送、接收数据以及创建一个简单的UDP服务器。

概述:什么是UDP?

UDP与TCP用途相似,但存在一些关键区别。TCP负责大量错误恢复工作,确保发送的数据能按正确顺序到达接收方。UDP则不提供这些保证。使用UDP发送数据,数据包可能到达,也可能丢失,还可能乱序到达。但UDP的优势在于速度更快。

因此,在语音通话、视频会议和多媒体流等对实时性要求高、能容忍少量数据丢失的场景中,通常使用UDP。例如,丢失一个视频数据包可能只会导致短暂的花屏或图像错位,影响不大。

然而,对于电子邮件等关键数据,不应使用UDP。因为如果包含关键信息(例如“不要攻击”中的“不要”)的数据包丢失,接收方可能收到完全相反的信息(“攻击”),造成严重后果。因此,对于离散的关键数据,应使用TCP;对于连续的、可容忍丢失的密集数据,可以使用UDP。

网络分层模型回顾

在深入UDP之前,我们先回顾一下OSI网络七层模型。这是一个网络通信栈,每层只与相邻层通信。

  • 物理层:实际的硬件。
  • 数据链路层:对应我们的以太网帧(EthernetFrame)。
  • 网络层:对应我们的IPv4类(IPv4)。
  • 传输层:TCP和UDP都属于这一层。
  • 会话层、表示层、应用层:位于传输层之上,通常由应用程序(如浏览器)负责,不属于操作系统内核的核心范畴。

目前,我们已经实现了前三层(数据链路、网络)。现在,我们需要至少一个传输层协议(UDP或TCP)来完成我们的基础网络栈。传输层之上的功能将由运行在用户空间的应用程序来处理。

UDP协议头解析

UDP的协议头非常简单。我们可以参考维基百科的图表,其结构如下:

struct UDPHeader {
    uint16_t sourcePort;     // 源端口
    uint16_t destPort;       // 目标端口
    uint16_t length;         // 长度(头部+数据)
    uint16_t checksum;       // 校验和
};

与复杂的IPv4头部相比,UDP头部主要包含源端口、目标端口、长度和校验和。端口号允许应用程序通过特定端口进行通信,实现数据的多路复用和解复用。

核心概念:套接字 (Socket)

当应用程序想要与另一台机器通信时,UDP协议会创建一个称为套接字(Socket)的对象。由于端口号是16位整数,理论上我们需要管理多达65535个套接字。虽然很多,但现代计算机内存可以承受。

我们的UserDatagramProtocol类将维护一个套接字数组。每个套接字保存连接的相关信息:本地端口、远程端口、远程IP地址。套接字的作用是将接收到的数据传递给一个UDPHandler处理程序。应用程序可以继承UDPHandler,并通过套接字将其连接到UDP协议。

实现UDP协议栈

上一节我们介绍了UDP和套接字的概念,本节我们来看看如何具体实现。我们将创建几个核心类。

类结构设计

以下是实现所需的主要类:

// UDP处理程序基类,应用程序可继承此类
class UDPHandler {
public:
    virtual void HandleUDPMessage(uint8_t* data, uint32_t size) {}
};

// UDP套接字,管理单个连接
class UDPSocket {
    // ... 成员变量:本地/远程端口、IP、处理程序指针等
public:
    bool Connect(uint32_t ip, uint16_t port);
    void Disconnect();
    bool Send(uint8_t* data, uint32_t size);
    void HandleData(uint8_t* data, uint32_t size); // 被UDP协议调用
};

// 主UDP协议类
class UserDatagramProtocol : public IPv4Handler {
    UDPSocket* sockets;
    uint32_t numSockets;
    uint16_t freePort; // 用于分配本地端口
public:
    // 从IPv4层接收数据包
    virtual bool OnIPv4PacketReceived(uint8_t* buffer, uint32_t size);
    // 应用程序接口:连接、断开、发送
    UDPSocket* Connect(uint32_t ip, uint16_t port);
    void Disconnect(UDPSocket* socket);
    bool Send(UDPSocket* socket, uint8_t* data, uint32_t size);
};

接收数据流程

UserDatagramProtocol从IPv4层(协议号0x11)收到数据包时,会执行以下操作:

  1. 解析UDP头部。
  2. 遍历所有套接字,寻找匹配的套接字。匹配条件是:数据包的目标端口等于套接字的本地端口,并且数据包的源IP和端口与套接字记录的远程IP和端口一致(对于已连接的套接字)。
  3. 如果找到匹配的套接字,则剥离UDP头部,将有效载荷(payload)数据传递给该套接字的HandleData方法。
  4. 套接字再将数据转发给其关联的UDPHandler处理程序。

发送数据流程

当应用程序通过套接字调用Send方法时:

  1. 计算总长度:数据长度 + UDP头部长度。
  2. 分配内存,构建UDP头部:设置源端口(套接字的本地端口)、目标端口(套接字的远程端口)、长度字段。
  3. 校验和字段可以设置为0,以禁用错误检查(UDP允许这样做,可以简化实现)。
  4. 将数据拷贝到头部之后。
  5. 调用父类(IPv4Handler)的Send方法,指定目标IP地址和包含完整UDP数据包(头+数据)的缓冲区。
  6. 发送完成后,释放分配的内存。

连接与断开连接

  • 连接(Connect):应用程序调用Connect方法,指定远程IP和端口。协议会创建一个新的UDPSocket实例,为其分配一个本地端口(例如从1024开始递增,因为0-1023是保留端口),设置远程IP和端口,并将套接字加入管理列表。
  • 断开(Disconnect):从套接字列表中查找并移除指定的套接字。通常用列表最后一个元素覆盖要删除的元素,然后减少套接字计数。

实现UDP服务器(监听模式)

除了主动连接,UDP还需要支持服务器端的监听模式。我们扩展套接字的概念:

  1. 创建一个处于“监听”状态的套接字,只设置本地端口,不设置远程IP和端口。
  2. 当收到发往该本地端口的数据包时,如果找到一个处于监听状态的匹配套接字,则“接受”这个连接:将该套接字的远程IP和端口设置为数据包的源IP和端口,并将其状态改为“已连接”。
  3. 这样,后续就可以通过这个套接字与客户端进行双向通信。

通过虚拟机的端口转发规则,我们可以从外部主机向这个UDP服务器发送数据,验证其功能。

测试与验证

我们编写一个简单的测试程序:创建一个UDP处理程序,连接到外部网络的一个服务器(例如netcat监听的端口),发送一条消息,并接收和打印服务器的回复。同时,我们也测试UDP服务器模式,从外部客户端向其发送数据。

运行测试后,我们能够在操作系统的输出中看到发送的数据和接收到的回复,这证明我们的UDP协议栈实现了双向通信。通过配置端口转发,外部客户端也能成功向我们实现的UDP服务器发送数据。

总结

本节课中,我们一起学习了用户数据报协议(UDP)。我们从UDP与TCP的对比开始,理解了其无连接、不可靠但快速的特性。接着,我们回顾了网络分层模型,明确了UDP在传输层的位置。

然后,我们深入探讨了UDP协议头的简单结构,并引入了套接字(Socket) 这一核心概念,用于管理端口和连接。在实现部分,我们设计了UDPHandlerUDPSocketUserDatagramProtocol等类,详细说明了接收数据、发送数据、建立连接和断开连接的逻辑流程。我们还补充了如何实现UDP服务器监听模式。

最后,通过实际的代码测试,我们验证了所实现的UDP协议栈能够成功进行双向网络通信,并能作为服务器接受外部连接。虽然UDP的实现比预想的代码量多一些,但它为操作系统提供了基础的网络通信能力,是一个重要的里程碑。在下一个视频中,我们将讨论TCP与UDP的差异,并概述TCP的实现思路。

027:传输控制协议 (TCP) 🧩

在本节课中,我们将要学习传输控制协议(TCP)的基础理论,并开始动手实现一个简化的、兼容TCP的协议栈。TCP是网络协议中最重要的协议之一,它解决了数据包可能丢失或乱序到达的问题,提供了可靠的、面向连接的通信。

概述

TCP与UDP不同,它在不可靠的IP网络之上构建了一个可靠的通信通道。通过序列号、确认应答和重传机制,TCP确保了数据能够按序、完整地到达目的地。本节课我们将探讨TCP的核心概念,包括其报文头结构、三次握手建立连接、四次挥手断开连接,以及状态机模型。

TCP与UDP的核心区别

上一节我们介绍了UDP协议,它简单快速但不保证可靠性。本节中我们来看看TCP如何解决UDP的痛点。

UDP工作在IPv4之上,而IP网络本身是不可靠的。UDP不保证数据包一定到达,也不保证数据包按发送顺序到达。这对于视频会议等应用是可以接受的,丢失一个数据包可能只导致屏幕出现一点小故障,乱序到达的数据包也可能只是导致画面部分区域错位。

TCP同样使用IPv4,但它通过额外的机制实现了可靠性。以下是TCP的核心行为:

  • 发送方 发送带有序列号(例如1, 2, 3, 4, 5)的数据包,并需要将这些数据在内存中保留一段时间。
  • 接收方 收到数据包后,会发送一个确认(ACK)信息,告知发送方“第X个数据包已收到”。
  • 发送方 收到ACK后,就可以从内存中删除对应的已发送数据。
  • 如果数据包乱序到达(例如先到4,后到2),接收方会根据序列号在缓冲区中重新排序。
  • 如果数据包丢失(例如3未到达),发送方在等待超时后,会重新发送该数据包。

这就是TCP的要点:通过序列号告知数据包的处理顺序,通过确认应答超时重传机制确保数据的可靠交付。

TCP连接管理

为了安全起见(防止恶意攻击者伪造TCP数据包),TCP在传输任何数据前,通信双方需要通过“三次握手”协商一个随机的初始序列号偏移量,而不是简单地使用1, 2, 3...。

三次握手建立连接 👋

以下是建立TCP连接的过程:

  1. 客户端 发送一个SYN(同步)报文,其中包含客户端的初始序列号。
  2. 服务器 回复一个SYN-ACK(同步-确认)报文,其中包含服务器的初始序列号,并对客户端的SYN进行确认。
  3. 客户端 再发送一个ACK报文,对服务器的SYN进行确认。
    至此,连接建立,双方可以开始通信。

四次挥手断开连接 👋

优雅地断开TCP连接需要四个步骤:

  1. 主动关闭方(例如客户端)发送一个FIN(结束)报文。
  2. 被动关闭方(服务器)回复一个ACK报文,确认收到了FIN。
  3. 被动关闭方(服务器)也发送一个FIN报文。
  4. 主动关闭方(客户端)回复一个ACK报文,确认收到了服务器的FIN。
    至此,连接完全关闭。

为什么断开连接如此复杂?当一方发送FIN后,它承诺除了最后的ACK外,不会再发送任何数据。另一方收到FIN后,可以启动一个超时计时器(例如2分钟)。超时后,它就能确信网络上不会再有任何来自对方的老旧数据包,从而可以安全地复用该端口和套接字资源,而不会与新的通信产生混淆。

TCP状态机

如果我们将上述握手和挥手过程中可能发生的状态变化绘制出来,就会得到一个著名的“TCP状态转换图”。这个图描述了TCP套接字在整个生命周期中可能处于的各种状态(如CLOSED, LISTEN, SYN_SENT, ESTABLISHED, FIN_WAIT_1等)以及触发状态转换的事件(收到SYN、发送ACK等)。理解这个状态机对于正确实现TCP协议至关重要。

TCP报文头结构

现在,让我们深入看看TCP报文头的具体结构。理解每个字段的含义是实现协议的基础。

以下是TCP报文头各字段的简要说明:

  • 源端口 (Source Port):16位,发送方的端口号。
  • 目的端口 (Destination Port):16位,接收方的端口号。
  • 序列号 (Sequence Number):32位,本报文段所发送数据的第一个字节的序号。
  • 确认号 (Acknowledgment Number):32位,期望收到对方下一个报文段的第一个数据字节的序号。仅当ACK标志位为1时有效。
  • 数据偏移 (Data Offset):4位,指出TCP报文段的数据起始处距离报文段起始处有多远,即报文头长度。单位是32位字。
  • 保留 (Reserved):3位,必须设为0。
  • 标志位 (Flags):9位,包含多个控制位。
  • 窗口大小 (Window Size):16位,用于流量控制,表示接收方当前可接收的数据量。
  • 校验和 (Checksum):16位,用于差错检验。
  • 紧急指针 (Urgent Pointer):16位,仅当URG标志位为1时有效,指出本报文段中紧急数据的末尾位置。
  • 选项 (Options):可变长度,可选字段。

TCP标志位详解

在标志位字段中,有几个关键的控制位需要我们重点关注:

以下是几个重要的TCP标志位:

  • ACK (Acknowledgment):确认标志。表示确认号字段有效。
  • PSH (Push):推送标志。接收方应尽快将数据交付给应用层,而不是在缓冲区中等待。
  • RST (Reset):复位标志。表示连接出现严重错误,必须释放并重新建立连接。用于非正常断开。
  • SYN (Synchronize):同步标志。在建立连接时用来同步序列号。
  • FIN (Finish):终止标志。用来释放一个连接。

其他如URG(紧急)、CWR(拥塞窗口减少)、ECE(显式拥塞通知)等标志位,在我们的简化实现中暂时不会使用。

开始实现:套接字与状态

理论部分已经介绍完毕,现在让我们开始编码实现。这需要大量的代码,我们将从UDP的实现中借鉴一部分结构。

首先,我们需要修改套接字(Socket)的结构。与UDP简单的“是否在监听”布尔值不同,TCP套接字需要一个状态变量来跟踪其在TCP状态机中所处的位置(例如CLOSED, LISTEN, SYN_SENT, ESTABLISHED等)。

我们还需要为套接字添加存储本地序列号远程序列号(或确认号)的字段。在connect(连接)操作中,客户端套接字将初始化自己的序列号,发送SYN报文,并进入SYN_SENT状态。在listen(监听)操作中,服务器套接字将进入LISTEN状态。disconnect(断开连接)操作将发送FIN报文并进入相应的等待状态。

处理接收到的TCP数据包是核心环节,我们将在一个大的处理函数中,根据当前套接字的状态和收到的TCP标志位(SYN, ACK, FIN, RST等),按照状态转换图的逻辑进行相应的处理并可能转换状态。

总结与下节预告

本节课中我们一起学习了TCP协议的核心原理。我们了解了TCP如何通过序列号、确认和重传来提供可靠传输;详细分析了通过“三次握手”建立连接和“四次挥手”断开连接的过程;浏览了TCP报文头的结构和关键标志位;并开始了我们的代码实现,定义了套接字的状态和基本操作。

在下一节课中,我们将深入实现通过TCP连接发送数据的功能。这包括处理复杂的TCP校验和计算、管理发送缓冲区、实现滑动窗口进行流量控制等。这比听起来要复杂,尤其是校验和部分,但这是构建一个可用TCP栈的关键步骤。

我们下次课再见!

028:传输控制协议 (TCP) - 发送数据

概述

在本节课中,我们将继续实现传输控制协议(TCP)。上一节我们开始了TCP的实现,本节我们将重点实现通过TCP连接发送数据的功能。这个过程涉及构建TCP数据包、计算校验和以及处理连接状态。

计算TCP校验和

上一节我们介绍了TCP的基本结构,本节中我们来看看如何为TCP数据包计算校验和。TCP校验和的计算方式与IPv4校验和类似,但输入的数据不同。我们需要将TCP头部、要发送的数据以及一个“TCP伪头部”一起作为输入。

TCP伪头部包含以下信息(均为大端字节序):

  • 源IP地址(32位)
  • 目标IP地址(32位)
  • 协议号(固定为6,代表TCP,以16位存储)
  • TCP报文段总长度(16位)

公式TCP校验和 = IPv4校验和算法( TCP伪头部 + TCP头部 + 数据 )

实现数据发送

现在,让我们进入发送数据的具体实现。send方法需要处理标志位、序列号并构建完整的数据包。

以下是构建和发送TCP数据包的关键步骤:

  1. 分配缓冲区:分配足够容纳伪头部、TCP头部和用户数据的缓冲区。
  2. 设置伪头部:在缓冲区起始处填充伪头部的各个字段。
  3. 设置TCP头部:在伪头部之后填充TCP头部字段,包括源端口、目标端口、序列号、确认号、数据偏移、标志位、窗口大小等。
  4. 设置选项:如果数据包包含SYN(同步)标志,需要设置特定的选项字段(例如MSS)。
  5. 复制数据:将用户要发送的数据复制到TCP头部之后的缓冲区位置。
  6. 计算校验和:对整个缓冲区(伪头部+头部+数据)调用IPv4校验和算法,并将结果填入头部的校验和字段。
  7. 发送数据:将缓冲区中从TCP头部开始的部分(不包括伪头部)发送出去。

代码示例(概念性):

void send_tcp_packet(...) {
    // 1. 分配缓冲区
    buffer = allocate( PSEUDO_HEADER_SIZE + TCP_HEADER_SIZE + data_length );

    // 2. & 3. 设置伪头部和TCP头部
    write_pseudo_header(buffer, src_ip, dst_ip, TCP_PROTOCOL, total_length);
    write_tcp_header(buffer + PSEUDO_HEADER_SIZE, src_port, dst_port, seq, ack, flags, ...);

    // 4. 处理选项(如SYN)
    if (flags & SYN) {
        set_tcp_options(...);
    }

    // 5. 复制数据
    copy_data(buffer + PSEUDO_HEADER_SIZE + TCP_HEADER_SIZE, user_data, data_length);

    // 6. 计算校验和
    checksum = calculate_ipv4_checksum(buffer, total_length + PSEUDO_HEADER_SIZE);
    write_checksum_to_header(buffer + PSEUDO_HEADER_SIZE, checksum);

    // 7. 发送(从TCP头部开始)
    network_send(buffer + PSEUDO_HEADER_SIZE, total_length);
}

连接测试与问题排查

实现发送功能后,我们尝试发起一个TCP连接(发送SYN包)。测试显示,系统成功发送了SYN包,并且收到了来自目标服务器(如Netcat服务器)的SYN-ACK响应。

然而,连接并未成功建立,因为我们还没有实现处理接收到的SYN-ACK包并回复ACK确认的逻辑。这导致了服务器端超时重传SYN-ACK。这验证了我们的发送功能是有效的,但完整的TCP握手流程尚未完成。

总结

本节课中我们一起学习了如何实现TCP协议的数据发送功能。我们详细讲解了TCP校验和的计算方法,它需要伪头部、TCP头部和数据的共同参与。接着,我们逐步实现了构建和发送TCP数据包的过程。目前,我们已经能够发送数据、发起连接(SYN)以及处理监听和断开等基本操作。下一节,我们将实现TCP的数据接收和处理逻辑,最终完成一个能够与外界通信的、可工作的TCP协议栈。

029:传输控制协议 (TCP) 接收处理 🧩

在本节课中,我们将学习如何为TCP协议实现接收数据和处理连接状态转换的逻辑。这是完成TCP双向通信的关键一步。

在上一节中,我们实现了TCP的发送功能,可以发送数据、发起连接请求和断开请求。然而,我们尚未实现接收方法,因此无法处理从服务器返回的数据。例如,我们发送了同步(SYN)消息并收到了确认(ACK),但未发送对应的确认,导致服务器持续询问。

本节中,我们将深入探讨如何解析接收到的TCP数据包,并根据TCP状态机处理连接建立、数据传输和连接终止。

处理接收到的数据

我们从IPv4层接收到数据。首先,需要确保TCP头部至少有20字节,这是其最小合法长度。处理流程与UDP类似:遍历套接字列表,寻找负责此消息的套接字。

以下是处理接收数据的基本代码结构:

if (tcp_header_size >= 20) {
    // 遍历套接字并匹配
    for (socket in socket_list) {
        if (socket_matches(socket, received_packet)) {
            handle_tcp_packet(socket, received_packet);
        }
    }
}

接下来,我们将根据TCP头部的标志位(SYN, ACK, FIN)进行一个大分支判断。

处理连接建立(三次握手)

以下是处理三次握手过程中不同状态的核心逻辑。

1. 接收SYN(服务器端)

如果找到一个正在监听(LISTENING)的套接字,并且发送方想要同步(SYN标志置位),则说明对方希望连接到我们。

  • 将套接字状态设置为SYN_RECEIVED
  • 将确认号(Acknowledgment Number)设置为接收到的序列号(Sequence Number)加一。注意: 这个“加一”操作至关重要,忘记它会导致许多问题。
  • 设置我们自己的初始序列号(此处为固定值,仅用于演示)。
  • 发送一个SYN-ACK回复。

2. 接收SYN-ACK(客户端)

如果我们之前发送了SYN,现在收到了SYN-ACK。

  • 将连接状态设置为ESTABLISHED(已建立)。
  • 同样,将确认号设置为接收到的序列号加一。
  • 增加我们自己的序列号。
  • 发送最终的ACK确认。

3. 非法组合

SYN和FIN标志不应在同一消息中设置。如果遇到,应视为非法并终止连接。

处理连接终止

连接终止涉及FIN和ACK标志的交换。我们的实现将FIN和FIN-ACK在某种程度上等同处理。

以下是连接终止的状态转换示意图:

1. 接收FIN(主动关闭)

如果连接处于ESTABLISHED状态并收到FIN,表示对方希望断开。

  • 发送ACK进行确认。
  • 随后发送我们自己的FIN。
  • 状态转移到CLOSE_WAIT(实际上,更精确地,发送FIN后会进入FIN_WAIT_1)。

2. 接收FIN-ACK

如果我们处于FIN_WAIT_1状态(已发送FIN),并收到了FIN-ACK。

  • 我们应进入TIME_WAIT状态,但为简化,我们直接发送最后的ACK并将状态设为CLOSED

3. 最终状态

当连接状态变为CLOSED后,我们将从套接字列表中移除该套接字。

处理数据传输与确认

如果接收到的消息不包含SYN、ACK、FIN等控制位,则说明它是承载实际数据的数据包。

以下是处理数据包和确认的步骤:

  1. 将数据负载传递给上层应用处理器(Handler)。
  2. 比较接收到的序列号与我们期望的确认号。如果不匹配,说明数据包乱序到达,当前无法处理。
  3. 应用处理器返回一个布尔值,指示是否应保持连接。
  4. 如果保持连接,我们确认接收到的数据。确认号增加的长度是负载数据的长度,需要根据TCP头部中的偏移量字段计算得出。

公式如下:
新确认号 = 当前确认号 + 负载数据长度

处理重置(RST)标志

在开始上述主要处理之前,应先检查RST标志。

如果收到RST标志:

  • 无论当前何种状态,立即将套接字状态设置为CLOSED
  • 不执行状态切换逻辑。
  • 最终从套接字列表中移除该套接字。

捎带确认

TCP允许“捎带确认”,即在发送数据的同时,确认之前收到的消息。我们的代码需要处理这种ACK标志与数据同时存在的情况。

当前实现的问题与总结

在测试中,连接似乎可以建立,但未能成功接收预期数据。这表明当前的接收逻辑可能存在缺陷,例如状态转换条件不完整、序列号处理错误或数据传递逻辑有误。

本节课中,我们一起学习了TCP接收端的基本框架,包括:

  • 如何根据SYN、ACK、FIN标志处理连接的生命周期(建立、数据传输、终止)。
  • 如何处理数据包和确认。
  • 如何处理连接重置(RST)。
  • 当前代码存在未解决的问题,需要在后续调试中完善。

实现一个完整的TCP栈是复杂的,涉及严格的状态机和序列号管理。本节内容为构建一个可工作的TCP接收器奠定了基础,但距离一个健壮的实现还有距离。在下一节中,我们将诊断并修复当前测试中出现的问题。

030:TCP与简易HTTP服务器 🖥️

在本节课中,我们将继续学习传输控制协议(TCP),并尝试实现一个简易的HTTP服务器。我们将调试之前遇到的连接问题,并最终实现与外部网络(如Web浏览器)的基本通信。


概述

上一节我们介绍了TCP连接的基本握手过程。本节中,我们将深入调试连接问题,修正数据包长度计算错误,并最终实现一个能够响应HTTP请求的简易服务器。


调试TCP连接问题 🔍

在上一节的末尾,我们遇到了TCP连接异常和数据传输问题。首先,我们需要减少网络驱动程序的输出,以便更清晰地观察TCP数据包。

以下是修改网络驱动程序,使其仅打印TCP部分(从第34字节开始)的代码:

// 仅打印从第34字节开始的数据(跳过14字节以太网帧头和20字节IPv4头)
print_bytes_from_offset(packet_data, 34);

修改后,控制台输出变得清晰,我们主要看到TCP消息。最初几条消息是ARP请求,可以忽略。我们重点关注后续的TCP消息。


分析TCP消息

我们发送并接收了多条消息。每条TCP消息包含源端口、目标端口、序列号、确认号等字段。关键信息在于标志位(Flags)。

例如,我们发送的标志位是 0x02,这代表 SYN(同步)标志。然而,我们期望在连接建立后发送 SYN-ACK(同步-确认)标志,即 0x12

检查内核代码发现,我们在 TCP connect 函数中存在逻辑错误,导致重复创建了监听套接字。修正后,我们成功收到了正确的 SYN-ACK 响应。


数据传输与确认

成功建立连接后,我们尝试发送数据 “Hello TCP”。我们为数据包设置了 PSH(推送)和 ACK(确认)标志。PSH标志要求接收方立即处理数据,而不是缓冲。

我们成功将数据发送到了虚拟机外部,并收到了确认响应。这是一个重要的里程碑,表明我们的操作系统能够通过TCP与外部机器通信。


连接关闭与问题排查

在关闭连接时,我们遇到了异常。Netcat(我们使用的测试工具)发送了 FIN-ACK(结束-确认)标志,我们进行了确认,但随后收到了重复的数据包。

问题根源在于之前IPv4视频中提到的数据包长度计算错误。我们错误地将整个以太网帧的长度(包括填充的零字节)添加到了确认号中,导致序列号与远程机器不同步。

修正方法是计算IP消息的实际长度,忽略末尾的填充零字节。以下是修正逻辑:

// 查找消息中最后一个非零字节的位置
int actual_length = find_last_non_zero(packet_data, total_length);
acknowledgement_number += actual_length; // 仅添加有效数据长度

修正后,确认号正确,连接能够正常关闭。


实现TCP重置(RST)功能

在某些异常情况下,我们需要发送 RST(重置)标志来终止连接。我们在TCP处理函数中添加了重置逻辑。

如果收到非法数据或需要强制终止连接,我们构造一个临时的TCP套接字,设置相应的序列号和确认号,并发送RST标志包。

以下是发送RST的示例代码:

if (need_reset) {
    TCP_Socket temp_socket;
    temp_socket.sequence_number = incoming_ack_number;
    temp_socket.acknowledgement_number = incoming_sequence_number;
    send_tcp_packet(&temp_socket, RST_FLAG);
}

测试表明,重置功能正常工作,能够立即终止连接。


实现简易HTTP服务器 🌐

最后,我们实现一个简易的HTTP服务器。当收到HTTP GET请求时,我们返回一个简单的HTML页面。

以下是处理HTTP请求并响应的代码:

if (strstr(request, "GET / HTTP")) {
    char response[] = "HTTP/1.1 200 OK\r\n"
                      "Content-Type: text/html\r\n"
                      "\r\n"
                      "<html><head><title>MyOS</title></head>"
                      "<body><h1>Hello from MyOS!</h1></body></html>";
    send_tcp_data(socket, response, strlen(response));
}

我们在内核中创建一个监听套接字,并在收到请求时发送上述响应。通过浏览器访问虚拟机的IP地址,我们成功收到了HTML页面。


总结

本节课中我们一起学习了:

  1. 调试TCP连接:通过减少日志输出和分析标志位,定位并修正了连接建立问题。
  2. 修正长度计算错误:解决了因IP消息长度计算错误导致的序列号不同步问题。
  3. 实现TCP重置:添加了发送RST标志来异常终止连接的功能。
  4. 构建简易HTTP服务器:实现了一个能够响应HTTP GET请求并返回HTML页面的基本服务器。

我们的操作系统现在能够通过TCP/IP协议栈与外部世界(如Web浏览器)进行通信,这是一个重要的进步。虽然实现中仍有一些边界情况未处理,但核心功能已可运行。

下一节课,我们将开始探索文件系统,具体是FAT32格式。敬请关注!


031:分区表 📚

在本教程中,我们将学习主引导记录(MBR)分区表的结构。这是理解硬盘如何被组织成多个独立区域(分区)的第一步,也是后续构建文件系统的必要基础。

概述

上一节我们介绍了如何从硬盘读取原始扇区。本节中,我们来看看如何解析硬盘的第一个扇区——主引导记录(MBR),以获取分区信息。理解分区表是创建文件系统的前提,因为文件系统通常建立在分区之上。

主引导记录(MBR)结构

主引导记录位于硬盘的第0扇区。当计算机启动时,BIOS会读取这个扇区并执行其中的代码。MBR的结构如下:

  • 440字节: 预引导加载程序(Bootstrap Loader)代码。
  • 4字节: 磁盘签名(Disk Signature),本教程中不涉及。
  • 2字节: 保留未使用。
  • 64字节: 分区表,包含4个条目,每个条目16字节。
  • 2字节: 引导签名(Boot Signature),固定为 0x55AA

以下是MBR结构的C语言定义:

struct mbr {
    unsigned char bootstrap[440];
    unsigned int signature;
    unsigned short unused;
    struct partition_entry partitions[4];
    unsigned short boot_signature;
} __attribute__((packed));

注意__attribute__((packed)) 指令告诉编译器不要为了内存对齐而在这个结构体中插入填充字节,这对于精确读取磁盘数据至关重要。

分区表条目详解

分区表包含4个条目,每个条目描述一个分区。以下是每个16字节条目的结构:

  • 1字节: 引导标志(Boot Flag)。0x80 表示该分区可引导,0x00 表示不可引导。通常只有一个分区被标记为可引导。
  • 3字节: 分区起始的CHS(柱面-磁头-扇区)地址。这是一个旧式寻址方式,现代系统已较少使用。
  • 1字节: 分区类型ID(Partition Type ID)。例如,0x83 通常代表Linux分区。
  • 3字节: 分区结束的CHS地址。
  • 4字节: 分区起始的LBA(逻辑块地址)扇区号。这是我们需要的关键信息
  • 4字节: 分区占用的总扇区数(长度)。

以下是分区表条目的C语言定义:

struct partition_entry {
    unsigned char boot_flag;
    unsigned char start_chs[3];
    unsigned char partition_type;
    unsigned char end_chs[3];
    unsigned int start_lba;
    unsigned int size_lba;
} __attribute__((packed));

实践:读取并解析分区表

为了演示,我们使用Tiny Core Linux创建了一个包含两个FAT32分区的虚拟硬盘。现在,我们编写代码来读取并显示分区表信息。

以下是读取和打印分区表的核心代码逻辑:

// 1. 定义MBR和分区条目结构(如上所示)
// 2. 从硬盘第0扇区读取数据到mbr结构体
read_sectors(0, 1, (unsigned char*)&mbr);
// 3. 遍历4个分区条目
for(i = 0; i < 4; i++) {
    pe = &mbr.partitions[i];
    if(pe->partition_type != 0) { // 类型0表示空条目
        printf("Partition %d: Type 0x%02X, Start LBA: %u, Size: %u sectors\n",
               i+1, pe->partition_type, pe->start_lba, pe->size_lba);
    }
}

运行此程序后,输出结果类似于:

Partition 1: Type 0x83, Start LBA: 63, Size: 67 sectors
Partition 2: Type 0x83, Start LBA: 130, Size: 132 sectors

注意:第一个分区通常从LBA 63开始,这是因为磁盘开头保留了一些扇区(例如,用于MBR本身)。

关键概念:分区起始偏移

从分区表获取的最重要信息是 start_lba。这个值代表了分区相对于整个硬盘开始的扇区偏移量

后续在实现文件系统时,所有对该分区的读写操作都必须加上这个偏移量。例如,要读取分区的第一个扇区,实际需要读取的硬盘扇区号是 start_lba,而不是0。

公式表示为:
实际硬盘扇区号 = 分区起始LBA + 分区内逻辑扇区号

总结

本节课中我们一起学习了主引导记录(MBR)分区表的结构和解析方法。我们了解了MBR的各个组成部分,重点分析了16字节的分区表条目,并学会了如何从中提取关键的分区起始LBA地址。这个地址是连接硬盘抽象层和文件系统层的桥梁。

现在,我们已经能够识别硬盘上有哪些分区以及它们的位置。下一节,我们将利用这里获取的 start_lba 信息,深入一个具体的分区,开始探索FAT32文件系统的内部结构,学习如何读取目录和文件。

032:文件分配表 (FAT32) 🗂️

在本节课中,我们将学习FAT32文件系统的基本结构。我们将了解如何从硬盘分区中读取BIOS参数块,定位根目录,并最终读取目录中的文件内容。通过本教程,你将掌握在自制操作系统中实现基本文件读取功能的关键步骤。

概述

上一节我们介绍了硬盘分区表。本节中,我们将深入探讨FAT32文件系统的具体结构。FAT32是微软曾广泛使用的一种文件系统,理解其布局是操作文件与目录的基础。

FAT32 磁盘布局

一块格式化为FAT32的硬盘分区,其布局大致分为三个部分。

以下是这三个主要部分:

  1. 保留区:包含BIOS参数块。
  2. 文件分配表区:包含一个或多个FAT表副本。
  3. 数据区:存放目录条目和文件的实际数据。

数据区被划分为多个。一个簇包含若干个扇区(例如8个,共4KB)。文件系统以簇为单位分配空间。

BIOS 参数块

BIOS参数块位于保留区的第一个扇区,它包含了描述文件系统结构的关键信息。其结构定义如下:

struct bios_parameter_block {
    uint8_t  jump_code[3];
    char     oem_name[8];
    uint16_t bytes_per_sector;
    uint8_t  sectors_per_cluster;
    uint16_t reserved_sectors;
    uint8_t  fat_copies;
    uint16_t root_dir_entries; // FAT32中通常为0
    uint16_t total_sectors_16; // FAT32中通常为0
    uint8_t  media_type;
    uint16_t sectors_per_fat_16; // FAT32中通常为0
    uint16_t sectors_per_track;
    uint16_t head_count;
    uint32_t hidden_sectors;
    uint32_t total_sectors_32;
    // 以下是FAT32扩展部分
    uint32_t sectors_per_fat;
    uint16_t flags;
    uint16_t version;
    uint32_t root_cluster;
    uint16_t fs_info_sector;
    uint16_t backup_boot_sector;
    uint8_t  reserved[12];
    uint8_t  drive_number;
    uint8_t  reserved1;
    uint8_t  boot_signature;
    uint32_t volume_id;
    char     volume_label[11];
    char     fat_type_label[8];
};

我们需要关注其中几个核心字段:

  • sectors_per_cluster:每簇扇区数。
  • reserved_sectors:保留扇区数,用于定位FAT表起始位置。
  • fat_copies:FAT表副本数量。
  • sectors_per_fat:每个FAT表占用的扇区数。
  • root_cluster:根目录的起始簇号。

通过这些信息,我们可以计算出关键区域的偏移量。

定位关键区域

掌握了BIOS参数块的信息后,我们就可以计算出文件系统中各个关键部分的起始位置。

以下是计算步骤:

  1. FAT表起始扇区fat_start = partition_start + reserved_sectors
  2. 数据区起始扇区data_start = fat_start + (fat_copies * sectors_per_fat)
  3. 根目录起始扇区root_dir_sector = data_start + ( (root_cluster - 2) * sectors_per_cluster )

注意:簇号从2开始计数,因此计算偏移时需要减去2。

目录条目

数据区中的目录(包括根目录)本身也存储在簇中。一个扇区可以容纳16个目录条目。

目录条目的结构定义如下:

struct directory_entry {
    char     name[8];
    char     ext[3];
    uint8_t  attributes;
    uint8_t  reserved;
    uint8_t  creation_time_tenths;
    uint16_t creation_time;
    uint16_t creation_date;
    uint16_t last_access_date;
    uint16_t cluster_high; // 簇号的高16位
    uint16_t write_time;
    uint16_t write_date;
    uint16_t cluster_low;  // 簇号的低16位
    uint32_t file_size;
};

关键字段说明:

  • nameext:文件名和扩展名(传统8.3格式)。
  • attributes:文件属性,例如用于判断是文件还是目录。
  • cluster_highcluster_low:共同组成文件或子目录起始簇的32位簇号。计算公式为:cluster = (cluster_high << 16) | cluster_low
  • file_size:文件大小(字节)。

如果条目是目录,其簇号指向存储该目录内容的簇。如果条目是文件,其簇号指向文件数据的第一个簇。

文件分配表的作用

当一个文件或目录的大小超过一个簇时,就需要使用文件分配表来追踪其后续的簇。FAT表本质上是一个簇号的数组,每个表项指向文件的下一个簇,形成链表。文件结束的标志是一个特定的值(如0x0FFFFFFF)。

读取一个多簇文件的流程是:

  1. 从目录条目获取起始簇号 N
  2. 读取簇 N 的数据。
  3. 查询FAT表第 N 项,获得下一个簇号 M
  4. 如果 M 不是结束标志,则读取簇 M 的数据,并重复步骤3。

实践:读取根目录和文件

现在,让我们将理论付诸实践,编写代码来读取根目录并显示其中的文件内容。

以下是实现步骤:

  1. 读取分区起始扇区,解析BIOS参数块。
  2. 根据参数块信息,计算并定位到根目录所在的扇区。
  3. 读取根目录扇区,遍历其中的目录条目。
  4. 对于每个有效的文件条目(跳过目录和长文件名特殊条目),打印其名称。
  5. 根据文件条目的簇号,计算文件数据所在的扇区,并读取和打印其内容(这里仅读取第一个簇)。

核心代码逻辑如下(伪代码示意):

// 1. 读取并解析BPB
read_sector(partition_start, &bpb);
// 2. 计算根目录位置
root_sector = data_start + ((bpb.root_cluster - 2) * bpb.sectors_per_cluster);
// 3. 读取根目录
read_sector(root_sector, dir_buffer);
// 4. 遍历条目
for (entry in dir_buffer) {
    if (entry.name[0] == 0) break; // 无更多条目
    if (entry.attributes == 0x0F) continue; // 跳过长文件名条目
    if (entry.attributes & 0x10) continue; // 跳过目录条目
    // 打印文件名
    print(entry.name);
    // 5. 读取文件内容
    file_cluster = (entry.cluster_high << 16) | entry.cluster_low;
    file_sector = data_start + ((file_cluster - 2) * bpb.sectors_per_cluster);
    read_sector(file_sector, file_buffer);
    print(file_buffer); // 假设文件内容为文本
}

运行此代码,将能列出根目录下的文件(传统短文件名),并显示每个文件第一个簇的内容。

总结

本节课中我们一起学习了FAT32文件系统的基础知识。我们了解了磁盘的三个主要区域(保留区、FAT表区、数据区),解析了包含关键信息的BIOS参数块,掌握了目录条目的结构,并实践了如何定位根目录、遍历文件以及读取文件数据。虽然FAT32包含许多历史遗留字段,但核心机制清晰。通过实现这些步骤,你的操作系统便获得了访问文件系统的基本能力。在下一节中,我们将进一步探讨如何利用文件分配表来读取跨越多簇的大型文件。

033:文件分配表(FAT32)进阶 🗂️

在本节课中,我们将学习如何读取跨越多个簇(Cluster)的大文件。上一节我们介绍了如何读取FAT32文件系统的根目录和单个文件的第一扇区。本节中我们来看看当文件大小超过一个簇时,如何通过文件分配表(FAT)来定位和读取文件的后续部分。

概述

我们已经掌握了访问硬盘扇区、解析分区表以及定位FAT32分区中根目录和文件起始簇的方法。然而,一个扇区只有512字节,一个文件通常远大于此。FAT32文件系统将文件数据存储在多个连续的扇区组(即簇)中。如果文件很大或硬盘存在碎片,文件的不同部分可能存储在不连续的簇中。文件分配表(FAT)的作用就是记录这些簇之间的链接关系。

从簇到扇区

首先,回顾一下如何从簇号计算对应的起始扇区号。公式如下:

起始扇区号 = 数据区起始扇区号 + (簇号 - 2) * 每簇扇区数

其中,数据区起始扇区号可以通过引导扇区(BPB)中的参数计算得出。簇号 - 2是因为FAT32中数据区的簇编号从2开始。

读取跨簇文件

读取一个文件的完整内容,需要遵循以下逻辑:

  1. 获取起始簇号:从文件的目录项中读取其起始簇号。
  2. 读取当前簇:根据上述公式,计算出该簇对应的所有扇区,并依次读取。
  3. 查找下一个簇:当前簇读取完毕后,如果文件还有剩余数据(即文件大小 > 已读取字节数),则需要查询FAT表。
  4. 定位FAT表项:FAT表本身也占据多个扇区。要找到对应某个簇号的表项,需要计算该表项所在的FAT扇区。
    • FAT表项偏移 = 簇号 * 4 (因为每个FAT32表项占4字节)
    • 所在FAT扇区号 = FAT表起始扇区号 + (FAT表项偏移 / 512)
    • 扇区内偏移 = FAT表项偏移 % 512
  5. 获取下一簇号:从计算出的FAT扇区中读取4字节,并屏蔽高4位(FAT32实际只用28位寻址),得到下一个簇的编号。
    • 下一簇号 = 读取的32位值 & 0x0FFFFFFF
  6. 循环与终止:将“当前簇号”更新为“下一簇号”,重复步骤2-5。当在FAT表中读到的下一个簇号是特定值(如0x0FFFFFFF)时,表示这是文件的最后一个簇。

以下是读取文件数据的核心循环结构伪代码:

当前簇号 = 文件起始簇号;
已读取字节数 = 0;

while (已读取字节数 < 文件总大小) {
    // 1. 计算并读取当前簇的所有扇区
    起始扇区 = 数据区起始扇区号 + (当前簇号 - 2) * 每簇扇区数;
    for (i = 0; i < 每簇扇区数 && 已读取字节数 < 文件总大小; i++) {
        读取扇区(起始扇区 + i, 缓冲区);
        处理缓冲区中的数据(最多512字节或剩余字节数);
        已读取字节数 += 本次读取的字节数;
    }

    // 2. 如果文件还没读完,查找下一个簇
    if (已读取字节数 < 文件总大小) {
        // 计算FAT表项位置并读取
        fat_entry_offset = 当前簇号 * 4;
        fat_sector_to_read = FAT表起始扇区号 + (fat_entry_offset / 512);
        读取扇区(fat_sector_to_read, fat_buffer);

        // 从fat_buffer的合适位置取出下一簇号
        next_cluster_raw = *(uint32_t*)(&fat_buffer[fat_entry_offset % 512]);
        下一簇号 = next_cluster_raw & 0x0FFFFFFF; // 屏蔽高4位

        // 检查是否为文件结束标志
        if (下一簇号 >= 0x0FFFFFF8) { // 通常是0x0FFFFFFF
            break; // 文件结束
        }
        // 准备读取下一个簇
        当前簇号 = 下一簇号;
    }
}

面向对象的设计思路

为了构建一个清晰、可扩展的文件系统层,可以考虑采用面向对象的设计模式。以下是一个抽象接口的设计示例:

  • FileSystem (抽象基类):代表一个文件系统实例。

    • GetRootDirectoryTraverser(): 返回一个指向根目录的遍历器对象。
  • FatFileSystem (派生类):实现FAT32文件系统。

    • 重写GetRootDirectoryTraverser(),返回FatDirectoryTraverser
  • DirectoryTraverser (抽象基类):代表对一个目录的访问。

    • GetFileEnumerator(): 获取该目录下的文件枚举器。
    • ChangeDirectory(name): 进入子目录。
    • GetParentDirectory(): 返回父目录遍历器(利用目录中的..条目)。
    • MakeDirectory(name): 创建目录。
  • FatDirectoryTraverser (派生类):实现FAT32目录遍历。

    • 内部保存当前目录的簇号。
    • 重写上述所有方法。
  • FileEnumerator (抽象基类):用于枚举目录中的文件。

    • GetName(): 获取当前文件名。
    • GetReader(): 获取当前文件的读取器对象。
    • Next(): 移动到目录中的下一个文件条目。
  • FatFileEnumerator (派生类):实现FAT32目录条目枚举。

  • FileReader (抽象基类):用于读取文件内容。

    • Read(buffer, size): 读取指定大小的数据到缓冲区。
    • GetSize(): 获取文件总大小。
    • Seek(position): 移动读指针。
  • FatFileReader (派生类):实现FAT32文件读取。

    • 内部管理当前簇号、已读字节、每簇扇区数等状态,并实现上述跨簇读取逻辑。

通过这种设计,上层的文件管理器或应用程序只需要与FileSystemDirectoryTraverserFileEnumeratorFileReader这些抽象接口交互,完全不需要关心底层是FAT32、NTFS还是其他文件系统。具体的文件系统实现细节被封装在各自的派生类中。

总结

本节课中我们一起学习了FAT32文件系统读取大文件的关键机制。我们理解了簇的概念以及文件分配表(FAT)在链接文件碎片中的作用,并掌握了通过簇号计算扇区地址、查询FAT表获取下一簇号以读取整个文件的完整流程。最后,我们探讨了一个面向对象的文件系统抽象层设计,这为构建支持多种文件系统的操作系统打下了良好的基础。虽然关于FAT32长文件名支持等更深入的话题因故未在本教程中展开,但希望本系列课程为你自行探索操作系统开发的广阔世界提供了坚实的起点。

034:为操作系统编程教程创建一个网站

在本节课中,我们将学习如何为操作系统编程教程系列创建一个专门的网站,以改善技术讨论和问题解答的体验。

上一节我们介绍了操作系统开发中的一些挑战,本节中我们来看看如何建立一个更有效的社区支持平台。

网站创建背景

我最近开始了一份新工作,并且搬了家,因此非常忙碌。这是我没有时间在YouTube评论区详细回答问题的主要原因。

YouTube评论区并非讨论技术问题的理想场所。当我收到问题通知时,需要加载视频页面。由于我目前带宽有限,频繁加载视频并不方便。加载后,我还需要滚动页面直到评论加载完毕,才能开始查找并回复特定的评论。整个过程大约需要5到10分钟。目前我有大约300封未回复的邮件,你可以自己计算全部回复所需的时间。

解决方案:专用网站

鉴于YouTube评论区不适合处理这类技术问题,我决定花时间为本视频系列创建一个专用网站。

网站地址是:wyoos.org(即 Write Your Own Operating System 的缩写)。

以下是该网站提供的主要功能:

  • 视频链接:可以直接访问教程系列的所有视频。
  • 源代码:提供课程中涉及的完整源代码。
  • 论坛:设有专门用于讨论技术问题的论坛板块。
  • IRC频道:提供了一个用于实时聊天的IRC频道。在工作日,我通常会在这个频道中,你可以在这里找到我。

我认为这些平台更适合进行技术问题的提问和深入的讨论。

其他事项说明

正如之前提到的,我开始在C公司工作。我们目前正在招聘。如果你对此感兴趣,我认为在那里工作非常愉快,同事很好,并且能以高度面向对象的方式使用C++进行Linux开发。如果你打算申请,请告知他们是我引荐的。

本节课中我们一起学习了为何以及如何为技术教程建立专门的交流平台。通过创建 wyoos.org 网站,我们旨在提供一个比YouTube评论区更高效、更适合深入讨论操作系统编程问题的环境。希望能在新的平台见到大家。

posted @ 2026-03-29 09:35  布客飞龙II  阅读(56)  评论(0)    收藏  举报