在这里插入图片描述

一、本章说明

1. 前置知识

请先阅读:

本章将两者合并:在实模式下读盘加载内核,再切换到 32 位保护模式,运行自己的 C 程序

2. 本章目标

完成本章后,我们将能够:

  • 在引导扇区内完成 读盘 + 进保护模式 的完整流程
  • 使用 平坦模式 GDT(代码/数据段均映射 0~4G),简化 32 位 C 内核寻址
  • 32 位 GCC-m32 -ffreestanding)编译内核,链接地址仍为 0xC200
  • 在保护模式下 直接写 VGA 显存0xB8000),不再调用 BIOS INT 10h
  • 通过 PS/2 键盘端口轮询init_keyboard + read_char)实现简易控制台回显
  • 软件光标_)替代 VGA 硬件光标,避免光标位置错乱

3. 目录结构

lesson13/
├── Makefile
├── boot/
│   ├── loader.s        # BPB + 读盘 + 进保护模式 + 跳转内核
│   └── pm.inc          # 段描述符宏(含 DA_4K 平坦段)
├── kernel/
│   ├── kernel.c        # 32 位 C 入口:欢迎语 + 提示符 + 键盘回显
│   ├── utilities.s     # 32 位显存输出、软件光标与键盘轮询
│   └── utilities.h
└── README.md

编译产物输出到 ../target/loader.binkernel.binos.img)。

4. 准备工具

工具 用途
nasm 汇编引导程序与 utilities
i686-elf-gcc 编译 32 位 C 内核(-m32 -ffreestanding
i686-elf-ld 链接 flat binary
dd 写入 os.img
make 一键编译、安装、运行
Bochs 运行与调试

工具已放在开源仓库tools/env/ 目录。

5. 内存布局

位置 物理地址 说明
引导扇区 0x7C00 BIOS 加载入口,loader.bin
读盘缓冲区 0x8200 INT 13h 读入起点(与 lesson11 相同)
内核入口 0xC200 kernel.bin 链接地址,保护模式下 JMP 目标
内核栈 ESP = 0x90000 进保护模式后由引导程序设置
VGA 显存 0xB8000 文本模式,每字符 2 字节 [ASCII][属性]
GDT 引导扇区内 平坦代码段 + 平坦数据段

INT 13h 读盘

loader.s 实模式

内核映像在 0xC200

lgdt / CR0.PE / 远跳转

32位: 设 DS/SS/ESP

JMP 0xC200

kernel.c: clear_screen → init_keyboard

print_str 欢迎语 + > 提示符

read_char → print_char 循环

6. 与 lesson11 / lesson12 的差异

项目 lesson11 lesson12 lesson13
运行模式 16 位实模式 32 位保护模式 先实模式读盘,再进保护模式
内核编译 -m16 无 C 内核 -m32 -ffreestanding
显示 BIOS INT 10h 直接写显存 直接写显存
键盘 BIOS INT 16h init_keyboard + 端口 0x60/0x64 轮询
GDT 代码段 + 显存段 平坦代码段 + 平坦数据段
跳转内核 JMP 0xC200(实模式) JMP 0xC200(保护模式)

关键变化: 进入保护模式后,不能再调用 BIOS 中断(BIOS 只在实模式有效)。C 内核依赖的 clear_screeninit_keyboardprint_charread_char 全部改为裸机实现。本章键盘采用 端口轮询,未使用 IDT / PIC / 键盘中断(IRQ1)(概念见 第四节)。


二、平坦模式 GDT

lesson12 为显存单独建了一个基址 0xB8000 的数据段描述符,并用 GS 访问。本章采用更常见的 平坦模型(Flat Model)

什么是平坦模型(之前章节已经介绍,这里再复习下)

保护模式下,CPU 不再用「段值 × 16 + 偏移」算物理地址,而是:

线性地址 = 段基址(来自 GDT 描述符)+ 32 位偏移

每个段寄存器(CSDS 等)存的是 选择子,指向 GDT 里的一项 段描述符。描述符里规定了该段的基址、界限和属性(可读/可写/可执行、16 位还是 32 位等)。

平坦模型的做法是:让代码段、数据段 共享同一套「从 0 开始、覆盖整个 4G 线性空间」的映射——

描述符 基址 界限 G 位 实际覆盖范围
代码段 0 0xFFFFF 1(4K 粒度) 0 ~ 4G−1
数据段 0 0xFFFFF 1 0 ~ 4G−1

界限字段存的是 页帧数 − 10xFFFFF 配合 G=1 表示 (0xFFFFF + 1) × 4K = 4G。于是任意 32 位偏移 offset 都在段内合法,线性地址就等于 offset

lesson12 分段方式

GS 基址 0xB8000

GS:0 → 0xB8000

CS 基址 引导段

CS:偏移 → 代码

平坦模型

32 位偏移 0xB8000

线性地址 0xB8000

32 位偏移 0xC200

线性地址 0xC200

与 lesson12 的区别: lesson12 代码段基址随引导扇区动态填写,显存段基址固定 0xB8000,访问 VGA 要 MOV AX, [GS:EDI] 这类 段:偏移 写法。平坦模型DS 基址也是 0,可直接 MOV EAX, 0xB8000 / MOV [EAX], BL,C 编译出的指针也是裸 32 位地址,无需为显存单独设段。

与实模式的区别: 实模式段最大 64K,段与段可能重叠;平坦模型段上限 4G,代码/数据段重叠同一地址空间——这正是现代 32 位 OS 启动阶段的常见布局(分页开启前仍用平坦段,开启页表后再用虚拟地址)。

GDT 定义

LABEL_GDT:
   Descriptor    0,       0,                  0
LABEL_DESC_CODE:
   Descriptor    0,       0xFFFFF,            DA_C + DA_32 + DA_4K
LABEL_DESC_DATA:
   Descriptor    0,       0xFFFFF,            DA_DRW + DA_32 + DA_4K
  • 基址 = 0界限 = 0xFFFFFG = 1DA_4K)→ 每段覆盖 4G 线性空间
  • 代码段选择子 SelectorCode = 8,数据段 SelectorData = 16
  • 线性地址 = 段基址 + 偏移,故 0xB80000xC200 均可直接用 32 位偏移访问
  • 进保护模式后:DS/ES/SS/FS/GS 均加载数据段选择子,CS 由远跳转加载代码段选择子

三、引导程序流程

1. 实模式阶段(与 lesson11 相同)

  1. 设置 SS:SP = 0:0x7C00
  2. 从软盘扇区 2 起循环读入,目标 ES:BX0x8200:0 递增
  3. 读盘成功后打印 Loading NotOneOS (32-bit PM)...
  4. 调用 enter_pm 而非直接 JMP 0xC200

2. 切换保护模式(与 lesson12 相同)

enter_pm:
   MOV      AX, CS
   MOV      DS, AX
   ; 计算 GDT 物理地址填入 GdtPtr
   LGDT     [GdtPtr]
   CLI
   IN       AL, 92h          ; 开 A20
   OR       AL, 00000010b
   OUT      92h, AL
   MOV      EAX, CR0
   OR       EAX, 1           ; CR0.PE = 1
   MOV      CR0, EAX
   JMP      DWORD SelectorCode:pm_start

3. 32 位入口

[BITS 32]
pm_start:
   MOV      AX, SelectorData
   MOV      DS, AX
   MOV      ES, AX
   MOV      SS, AX
   MOV      FS, AX
   MOV      GS, AX
   MOV      ESP, 0x90000
   JMP      0xC200           ; 跳转到 C 内核

注意: 平坦模式下 CS 基址为 0,JMP 0xC200 即跳到线性地址 0xC200 的内核入口。


四、32 位内核与 utilities

1. kernel.c

与 lesson11 类似,但运行于保护模式,且需先初始化键盘:

void main(void)
{
    clear_screen();
    init_keyboard();
    char *str = "NotOneOS 32-bit Protected Mode.\r\nWelcome..\r\n";
    int len = strlen(str);
    print_str(str, len);
    print_str("> ", 2);

    char a;
    fin:
        a = read_char();
        if (a != 0)
            print_char(a);
        goto fin;
}

utilities.h 导出:clear_screeninit_keyboardread_charprint_charprint_str

2. 显存输出:直接写与调 BIOS 的区别

lesson11 在 16 位实模式下通过 INT 10h 输出字符,例如 AH=0x0E(电传/Teletype)逐字显示、AH=0x00 AL=0x03 清屏。BIOS 视频服务替你维护光标、处理 \r/\n、必要时滚屏,并更新 VGA 硬件光标寄存器。

进入 32 位保护模式后,这条路径不可用:BIOS 中断服务例程按 实模式编写,依赖实模式段寄存器与中断环境;在保护模式下执行 INT 10h 时,CPU 会按 IDT(中断描述符表)取门描述符,而本章尚未建立可用的 IDT,轻则无效、重则 triple fault(IDT 说明见 第四节)。

因此本章改为 直接写 VGA 文本模式显存 0xB8000

对比项 lesson11:INT 10h lesson13:直接写 0xB8000
运行模式 16 位实模式 32 位保护模式
输出方式 INT 10h,BIOS 代劳 MOV [0xB8000+偏移], 字符/属性
显存布局 由 BIOS 抽象,调用者不必关心 80×25,每格 2 字节:[ASCII][属性]
光标 BIOS 内部维护 自行维护 cursor_x / cursor_y
回车换行 AH=0x0E 可自动处理(lesson11 还在 \r 后再发 \n print_char 自行解析 0x0D / 0x0A
滚屏 BIOS 自动 本章简化为到达末行后停在最后一行
硬件光标 BIOS 同步更新 关闭硬件光标,用 _ 软件光标

偏移计算: 字符 (x, y) 在显存中的字节偏移为 (y × 80 + x) × 2。属性字节常用 0x07(灰字黑底),与 lesson11 中 BX=15 的 INT 10h 配色接近。

lesson13 保护模式

平坦段寻址

print_char

0xB8000 + 偏移

cursor_x/y + 软件光标

lesson11 实模式

INT 10h AH=0x0E

print_char

BIOS 视频服务

写 0xB8000 + 更新光标

本质区别: 调 BIOS 是「请求固件服务」;直写显存是「按硬件手册操作帧缓冲」。后者代码更多,但不依赖实模式,是保护模式 / 内核自绘控制台的基础。

3. utilities.s — 显存输出与软件光标

不再调用 INT 10h,直接写 0xB8000。用 cursor_x / cursor_y 维护逻辑光标,关闭 VGA 硬件光标disable_hw_cursor),在当前位置绘制 _ 作为软件光标(cursor_draw / cursor_restore)。

VIDEO_MEM equ 0xB8000

clear_screen:
    ; 整屏填 0x0720,重置 cursor_x/y
    CALL disable_hw_cursor
    CALL cursor_draw

print_char:
    ; 偏移 = (y * 80 + x) * 2
    ; 字符先存 BL,再 MOV [EAX], BL(避免用 EAX 算址后覆盖 AL)
    ; 0x0D:列归零(行不变);0x0A:列归零并换行

注意: 回车键扫描码 0x1C 映射为 ASCII 0x0Dprint_char 收到后只回到当前行行首,不会自动换行;字符串里的 \r\n 仍由 print_str 逐字处理。

4. 8042 端口轮询原理

PC 的 8042 芯片(或兼容逻辑)连接 PS/2 键盘。CPU 不直接读键盘,而是经 8042 的两个 I/O 端口交换数据:

端口 方向 作用
0x64 状态寄存器(是否有数据可读、输入缓冲是否满等)
0x64 命令寄存器(如 0xAE 启用键盘端口)
0x60 读入 扫描码(或控制器应答)
0x60 向键盘发送 命令(如 0xF4 开启扫描)

状态寄存器 0x64 常用位:

名称 含义
0 OBF Output Buffer Full:为 1 表示 0x60 有数据可读
1 IBF Input Buffer Full:为 1 表示勿向 0x64/0x60
3 AUX 为 1 表示数据来自鼠标/aux,读键盘时应丢弃

按键到字符的数据路径:

CPU 轮询 8042 控制器 PS/2 键盘 CPU 轮询 8042 控制器 PS/2 键盘 loop [read_kbd_scancode] 按下/释放,发送扫描码 置 OBF=1 IN 0x64 读状态 OBF=1 时 IN 0x60 取扫描码 scancode_map 查 ASCII

Set 1 扫描码约定: 按下码 bit7=0(make code),释放码 bit7=1(break code,通常 = 按下码 | 0x80)。本章 read_char 先等按下、再等释放,避免一次按键被重复处理。

与 lesson11 INT 16h 的对比:

对比项 INT 16h(BIOS) 8042 端口轮询(本章)
可用模式 实模式 保护模式可用
读键方式 BIOS 阻塞等待 循环读 0x64/0x60
返回值 已转换的 ASCII(含 Shift 等) 原始扫描码 → 自行查 scancode_map
CPU 占用 等待时 BIOS 内部轮询/中断 当前线程空转在 .wait 循环
复杂度 调用简单 init_keyboard、查表、处理修饰键

初始化两步: OUT 0x64, 0xAE 打开键盘端口;OUT 0x60, 0xF4 让键盘开始扫描。写命令前须 wait_8042(等 IBF=0),写后 drain_kbd 清空残留字节,避免误读旧数据。

IDT 与 PIC 是什么

本章用 轮询 读键盘,没有建 IDT、也没有配置 PIC。二者是保护模式下处理 硬件中断 的基础设施,与 lesson12 已学过的 GDT 地位类似——GDT 管「段从哪访问」,IDT 管「中断/异常来了跳去哪」。

IDT(Interrupt Descriptor Table,中断描述符表)

  • 保护模式下,CPU 遇到 异常(除零、页故障等)或 中断(硬件 IRQ、INT n 指令)时,用 中断向量号(0~255)作为下标,到 IDT 里取一项 门描述符
  • 门描述符里写着处理程序的 代码段选择子 + 偏移;CPU 自动切换去执行该 中断处理程序(ISR)
  • LIDT 指令加载 IDTR(类似 LGDT 加载 GDT)。没有合法 IDT 时,任何中断/异常都无法正确分发,通常导致 triple fault。
  • 向量 0~31 保留给 CPU 异常;32 起常留给外部设备(具体编号由 PIC 或 APIC 决定)。

PIC(Programmable Interrupt Controller,可编程中断控制器)

  • PC 上外设(键盘、时钟、磁盘等)不能直接「打断」CPU,而是向 8259A PIC(或后来的 APIC)发 IRQ 请求线
  • PIC 把多条 IRQ 汇总 成一条信号送给 CPU;CPU 响应后,PIC 再告知是 哪一号 IRQ
  • 经典布局:键盘占 IRQ1。BIOS 在实模式下已把 IRQ 映射到某个中断向量;OS 进入保护模式后通常要做 PIC 重映射(例如把 IRQ0~15 改到向量 32~47),避免与 CPU 异常号冲突。
  • 还需 屏蔽/开放 某条 IRQ(OUT 到 PIC 的 0x21/0xA1 等端口),并用 STI 允许 CPU 响应中断。

键盘中断 vs 本章轮询

方式 数据路径 本章
轮询 CPU 空转循环读 0x64,有数据再读 0x60 ✅ 采用
IRQ1 中断 按键 → 8042 → PIC → CPU 跳 ISR → ISR 读 0x60 ❌ 未实现

中断方式 下一章

按键

8042

PIC IRQ1

CPU 查 IDT

键盘中断处理程序

本章:轮询

IN 0x64

read_char 循环

OBF=1?

IN 0x60 取扫描码

中断方案需 LIDT、PIC 重映射、编写 ISR、开 STI,概念与代码量都更大。轮询虽浪费 CPU,但逻辑直观,足够完成回显演示;下一章可改为 IRQ 驱动,并顺带支持 Shift 大写等。

5. utilities.s — 键盘轮询实现

保护模式下无法使用 INT 16h。本章不建 IDT,也不开 STI,改为轮询 8042 控制器端口 0x64 / 0x60

初始化init_keyboard):

init_keyboard:
    ; wait_8042 → OUT 0x64, 0xAE   ; 启用键盘端口
    ; drain_kbd 清空缓冲
    ; OUT 0x60, 0xF4(经 kbd_cmd) ; 开启扫描
    ; drain_kbd

读键read_char):

read_char:
.mk_wait:
    CALL read_kbd_scancode      ; 等按下(bit7=0)
    TEST AL, 0x80
    JNZ  .mk_wait
    MOV  BL, AL
.br_wait:
    CALL read_kbd_scancode      ; 等释放(bit7=1)
    TEST AL, 0x80
    JZ   .br_wait
    ; 0x1C → 0x0D;其余查 scancode_map(128 字节 Set 1 表)

read_kbd_scancode0x64 bit0 就绪时读 0x60;若 bit3 表示鼠标数据则丢弃重读。

scancode_map 为 128 字节的 Set 1 查找表,按下码作下标查 ASCII。仅覆盖主键盘可打印字符;不支持 Shift 大写

修饰键须映射为 0。 左 Shift(0x2A)、右 Shift(0x36)等若仍对应可打印字符,按 Shift 时会误回显——例如出现 \。应把这些条目写成 0read_char 返回 0 时 kernel.c 不回显。

表项必须逐字节对齐。 反斜杠应写 DB 0x5C,不能写 DB '\\':NASM 会把 '\\' 汇编成 2 字节,从该行起整表偏移 1,典型现象是按 b 却显示 v。写完后可对照 Set 1 扫描码表抽查几键。

6. 编译选项变化

# lesson11(实模式)
CCFLAGS = -march=i386 -m16 -ffreestanding

# lesson13(保护模式 32 位)
CCFLAGS = -m32 -ffreestanding -fno-pic -fno-builtin -nostdlib -O2

链接地址与 lesson11 相同:

kernel.bin: kernel.o utilities.o
	$(LD) -m elf_i386 -Ttext 0xC200 --oformat binary \
	      -o $(TARGET)$@ $(TARGET)kernel.o $(TARGET)utilities.o

五、Makefile 与写盘

loader.o: boot/loader.s boot/pm.inc
	$(NASM) -I./ -f elf32 -o $(TARGET)loader.o boot/loader.s

loader.bin: loader.o
	$(LD) -m elf_i386 -Ttext 0x7c00 --oformat binary \
	      -o $(TARGET)loader.bin $(TARGET)loader.o

install:
	$(DD) if=$(TARGET)loader.bin of=$(TARGET)os.img conv=notrunc
	$(DD) if=$(TARGET)kernel.bin of=$(TARGET)os.img conv=notrunc bs=512 seek=33

seek=33 与 lesson11 一致:内核写入数据区起始扇区(0-based 扇区 33)。


六、编译与运行

lesson13/ 目录下:

make          # 生成 loader.bin、kernel.bin
make install  # 写入 ../target/os.img
make run      # 启动 Bochs
make debug    # Bochs 调试模式

验证清单:

  • 屏幕短暂显示 Loading NotOneOS (32-bit PM)...
  • 清屏后输出 NotOneOS 32-bit Protected Mode.Welcome..> 提示符
  • 光标显示为 _,随输入移动
  • 按键可回显字母、数字;回车回到当前行行首
  • 无 triple fault(Bochs 不重启)

运行注意: Bochs 启动后需 点击显示窗口 使其获得键盘焦点,否则按键无反应。

调试可在内核入口设断点(地址以链接结果为准):

make debug
b 0xC200
c

单步跟踪 clear_screen0xB8000 的写入,或在 read_char 处设断(查看 ../target/kernel.map 中符号地址)。


七、本章小结

  • lesson11 解决了「加载并运行 C 程序」;lesson12 解决了「进入保护模式」;本章把两条路径串成一条完整启动链
  • 保护模式下 BIOS 不可用,显示与输入必须自己实现(显存、8042 端口轮询)
  • 平坦 GDT 让 C 代码可以像访问物理地址一样使用 0xC2000xB8000 等线性地址
  • 键盘采用 轮询 + Set 1 扫描码表,软件光标保证回显位置正确;内核从 16 位实模式编译 升级为 32 位保护模式编译

下一章可在此基础上增加:IDT + PIC 键盘中断(替代轮询)、Shift 大写、或开启分页(页表)。本章刻意保持简单,中断机制见 第四节「IDT 与 PIC」

源码地址:
https://gitee.com/xundh/learn-os

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐