操作系统学习13 保护模式下加载运行C程序
操作系统学习13 保护模式下加载运行C程序

一、本章说明
1. 前置知识
请先阅读:
- lesson11 — x86-C 调用汇编程序与传参:引导程序读盘、加载内核、C 与汇编互调
- lesson12 — 进入保护模式:GDT、GDTR、
CR0.PE、32 位远跳转
本章将两者合并:在实模式下读盘加载内核,再切换到 32 位保护模式,运行自己的 C 程序。
2. 本章目标
完成本章后,我们将能够:
- 在引导扇区内完成 读盘 + 进保护模式 的完整流程
- 使用 平坦模式 GDT(代码/数据段均映射 0~4G),简化 32 位 C 内核寻址
- 用 32 位 GCC(
-m32 -ffreestanding)编译内核,链接地址仍为0xC200 - 在保护模式下 直接写 VGA 显存(
0xB8000),不再调用 BIOSINT 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.bin、kernel.bin、os.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 | 引导扇区内 | 平坦代码段 + 平坦数据段 |
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_screen、init_keyboard、print_char、read_char 全部改为裸机实现。本章键盘采用 端口轮询,未使用 IDT / PIC / 键盘中断(IRQ1)(概念见 第四节)。
二、平坦模式 GDT
lesson12 为显存单独建了一个基址 0xB8000 的数据段描述符,并用 GS 访问。本章采用更常见的 平坦模型(Flat Model)。
什么是平坦模型(之前章节已经介绍,这里再复习下)
保护模式下,CPU 不再用「段值 × 16 + 偏移」算物理地址,而是:
线性地址 = 段基址(来自 GDT 描述符)+ 32 位偏移
每个段寄存器(CS、DS 等)存的是 选择子,指向 GDT 里的一项 段描述符。描述符里规定了该段的基址、界限和属性(可读/可写/可执行、16 位还是 32 位等)。
平坦模型的做法是:让代码段、数据段 共享同一套「从 0 开始、覆盖整个 4G 线性空间」的映射——
| 描述符 | 基址 | 界限 | G 位 | 实际覆盖范围 |
|---|---|---|---|---|
| 代码段 | 0 | 0xFFFFF | 1(4K 粒度) | 0 ~ 4G−1 |
| 数据段 | 0 | 0xFFFFF | 1 | 0 ~ 4G−1 |
界限字段存的是 页帧数 − 1:0xFFFFF 配合 G=1 表示 (0xFFFFF + 1) × 4K = 4G。于是任意 32 位偏移 offset 都在段内合法,线性地址就等于 offset。
与 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,界限 = 0xFFFFF,G = 1(
DA_4K)→ 每段覆盖 4G 线性空间 - 代码段选择子
SelectorCode = 8,数据段SelectorData = 16 - 线性地址 = 段基址 + 偏移,故
0xB8000、0xC200均可直接用 32 位偏移访问 - 进保护模式后:
DS/ES/SS/FS/GS均加载数据段选择子,CS由远跳转加载代码段选择子
三、引导程序流程
1. 实模式阶段(与 lesson11 相同)
- 设置
SS:SP = 0:0x7C00 - 从软盘扇区 2 起循环读入,目标
ES:BX从0x8200:0递增 - 读盘成功后打印
Loading NotOneOS (32-bit PM)... - 调用
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_screen、init_keyboard、read_char、print_char、print_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 配色接近。
本质区别: 调 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 0x0D,print_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,读键盘时应丢弃 |
按键到字符的数据路径:
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 |
❌ 未实现 |
中断方案需 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_scancode 在 0x64 bit0 就绪时读 0x60;若 bit3 表示鼠标数据则丢弃重读。
scancode_map 为 128 字节的 Set 1 查找表,按下码作下标查 ASCII。仅覆盖主键盘可打印字符;不支持 Shift 大写。
修饰键须映射为 0。 左 Shift(0x2A)、右 Shift(0x36)等若仍对应可打印字符,按 Shift 时会误回显——例如出现 \。应把这些条目写成 0,read_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_screen 对 0xB8000 的写入,或在 read_char 处设断(查看 ../target/kernel.map 中符号地址)。
七、本章小结
- lesson11 解决了「加载并运行 C 程序」;lesson12 解决了「进入保护模式」;本章把两条路径串成一条完整启动链
- 保护模式下 BIOS 不可用,显示与输入必须自己实现(显存、8042 端口轮询)
- 平坦 GDT 让 C 代码可以像访问物理地址一样使用
0xC200、0xB8000等线性地址 - 键盘采用 轮询 + Set 1 扫描码表,软件光标保证回显位置正确;内核从 16 位实模式编译 升级为 32 位保护模式编译
下一章可在此基础上增加:IDT + PIC 键盘中断(替代轮询)、Shift 大写、或开启分页(页表)。本章刻意保持简单,中断机制见 第四节「IDT 与 PIC」。
源码地址:
https://gitee.com/xundh/learn-os
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)