操作系统学习笔记——第一章

第一章 操作系统的启动

计算机启动的大致概念(bootsec模块剖析)

核心关注

计算模型中,需重点关注指针IP/PC及其指向的内容

计算机开机时IP的初始值

计算机刚开机时IP的值,由硬件设计者决定(以下以x86 PC为例)

x86 PC开机启动(实模式)流程

注:实模式寻址方式为 CS左移4位 + IP = CS * 16 + IP(与保护模式寻址逻辑不同)

  1. 开机初始状态:CPU处于实模式
  2. 初始寄存器值:CS=0xFFFFIP=0x0000
  3. 寻址结果:通过实模式寻址,定位到内存地址 0xFFFF0(ROM BIOS映射区)
  4. BIOS执行自检:检查RAM、键盘、显示器、软硬磁盘等硬件
  5. 加载引导扇区:将磁盘0磁道0扇区的内容,读入内存0x7c00
  6. 更新CS:IP:设置CS=0x07c0IP=0x0000,后续执行引导扇区代码

操作系统课程笔记:引导扇区代码(bootsect.s)

▶ 1. 伪操作符与段声明

  • .globl:声明全局符号(如begtextbegdata等),供链接器识别
  • .text/.data/.bss:是伪操作符,分别标识文本段(代码)、数据段、未初始化数据段的开始
    • 注意:此处这3个段是重叠、不分段
  • entry start:关键字entry告诉链接器“程序入口是start”,而start对应的代码就是之前引导扇区被读入的0x7c00处的语句

▶ 2. 常量定义
代码中用到的段地址常量:

  • BOOTSEG = 0x07c0:引导扇区初始加载的段地址
  • INITSEG = 0x9000:引导扇区要迁移到的目标段地址
  • SETUPSEG = 0x9020:后续setup程序的段地址

▶ 3. start代码功能(引导扇区核心操作)

start:
  mov ax, #BOOTSEG   mov ds, ax   ; 把数据段寄存器ds设为引导扇区当前段地址(0x7c0)
  mov ax, #INITSEG   mov es, ax   ; 把附加段寄存器es设为目标段地址(0x9000)
  mov cx, #256                    ; 设置拷贝字数:256字(对应512字节,即引导扇区大小)
  sub si, si         sub di, di   ; 源/目的变址寄存器清零(源地址:ds:si=0x7c0:0000;目的地址:es:di=0x9000:0000)
  rep movw                        ; 重复执行字拷贝:把引导扇区从0x7c00处迁移到0x90000处
  jmpi go, INITSEG                ; 跳转到INITSEG段下的go标签(即跳转到迁移后的代码位置继续执行)

▶ 代码功能解析
➤ 一、sub si, si / sub di, di 的本质含义

sub si, si
sub di, di

这两条指令的作用是:

  • si ← si - si = 0
  • di ← di - di = 0

即:将 SI 和 DI 寄存器清零

等价写法为:

mov si, 0
mov di, 0

注意:
这里清零的是寄存器本身,而不是任何内存地址中的内容。


➤ 二、为什么不用 mov si, 0

在 8086 及早期 x86 汇编中,清零寄存器有多种常见写法:

mov si, 0
xor si, si
sub si, si

其中:

  • sub si, sixor si, si

    • 不需要立即数
    • 指令长度较短
    • 执行效率较高

因此在早期操作系统源码(如 Linux 0.11 的引导代码)中,这是一种非常典型、规范的寄存器清零写法


➤ 三、结合上下文理解这两条指令

引导扇区中的相关代码如下:

mov ax, #BOOTSEG
mov ds, ax        ; DS = 0x07c0

mov ax, #INITSEG
mov es, ax        ; ES = 0x9000

mov cx, #256      ; 拷贝 256 个字(512 字节)

sub si, si        ; SI = 0
sub di, di        ; DI = 0

rep movw

此时:

  • DS 保存源段地址
  • SI 保存源段内偏移
  • ES 保存目标段地址
  • DI 保存目标段内偏移

因而:

  • 源地址寄存器对为 DS:SI
  • 目的地址寄存器对为 ES:DI

➤ 四、rep movw 的工作机制

指令 movw 的含义是:

将一个字(16 位)从 DS:SI 指向的内存地址
复制到 ES:DI 指向的内存地址

执行一次 movw 后:

  • SI += 2
  • DI += 2

指令 rep movw 表示:

CX ≠ 0(CX就是上面设置的拷贝字数) 时,重复执行 movw

其逻辑等价于:

while (CX != 0) {
    *(ES:DI) = *(DS:SI);
    SI += 2;
    DI += 2;
    CX--;
}

因此,真正发生内存读写操作的是 movw
而不是前面的 sub si, sisub di, di


➤ 五、为什么必须把 SI 和 DI 清零?

执行清零后:

  • DS = 0x07c0SI = 0x0000
  • ES = 0x9000DI = 0x0000
    在 8086 实模式 下:

物理地址 = 段寄存器 × 16 + 偏移寄存器

因而常用记号表示为:

  • 源地址:DS:SI = 0x07c0(段寄存器):0000(偏移寄存器)
  • 目的地址:ES:DI = 0x9000(段寄存器):0000(偏移寄存器)

该写法的含义是:
描述当前段寄存器与偏移寄存器组合后所形成的地址
而不是对内存内容进行赋值或清零。

转换为物理地址(实模式):

  • 源物理地址:
    使用 DS:SI 时:
    物理地址 = DS × 16 + SI

    0x07c0 × 16 + 0x0000 = 0x7c00
    
  • 目的物理地址:

使用 ES:DI 时:
物理地址 = ES × 16 + DI

0x9000 × 16 + 0x0000 = 0x90000

因此,rep movw 的整体作用是:

将整个 512 字节的引导扇区,从 BIOS 加载位置 0x7c00
复制到新的内存位置 0x90000

➤ 六、总结

sub si, sisub di, di 是一种经典的寄存器清零写法,用于将 SIDI 置为 0,从而使地址表示 DS:SIES:DI 分别指向源段和目标段的段首地址,使 rep movw 能够从段首开始完成整个引导扇区的内存复制。


bootsect.s 代码解析(引导扇区核心操作)

bootsect.s:从 jmpi 到 setup 执行前的完整逻辑


➤ 一、jmpi 指令:段间跳转的作用与时机

▪ 1. 指令形式

jmpi go, INITSEG

▪ 2. 指令性质

  • jmpi段间跳转(far jump / intersegment jump)

  • 一次性修改:

    • CS ← INITSEG
    • IP ← go

▪ 3. 执行结果

假设:

INITSEG = 0x9000

执行后:

  • CS = 0x9000
  • IP = go

CPU 从 0x9000:go 开始继续执行代码。

▪ 4. 为什么必须用 jmpi

bootsect 最初由 BIOS 加载在 0x7C00 附近:

  • 段寄存器不统一
  • 栈环境混乱
  • 不适合继续复杂逻辑

因此第一件事就是:

把引导代码“搬家”并通过 jmpi 正式切换到新的段环境


➤ 二、go 标签:统一段寄存器 + 初始化栈

▪ 1. 代码

go:
    mov ax, cs        ; 此时 cs = INITSEG = 0x9000
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, #0xff00

▪ 2. 每条指令的意义

指令 作用
mov ax, cs 取当前代码段
mov ds, ax 数据段 = 代码段
mov es, ax 附加段 = 代码段
mov ss, ax 栈段 = 代码段
mov sp, #0xff00 初始化栈指针

▪ 3. 整体效果

  • 所有段寄存器统一为 0x9000
  • 栈位于 0x9000:FF00 向下增长
  • 后续可以安全使用 call / ret / int

➤ 三、load_setup:加载 setup 模块(磁盘 → 内存)

▪ 1. 常量定义(上下文)

SETUPLEN = 4        ; setup 占 4 个扇区
INITSEG  = 0x9000
SETUPSEG = 0x9020   ; setup 目标段地址

setup 被加载到物理地址:
\(0x9020 \times 16 = 0x90200\)


▪ 2. load_setup 完整代码(整理版)

load_setup:
    ; 1. 设置磁盘参数
    mov dx, #0x0000      ; dh=0 磁头号, dl=0 驱动器号(A:)
    mov cx, #0x0002      ; ch=0 柱面号, cl=2 起始扇区

    ; 2. 设置内存目标地址
    mov bx, #0x0200      ; ES:BX = 0x9000:0200 → 0x90200

    ; 3. 设置功能号 + 扇区数
    mov ax, #0x0200 + SETUPLEN
                          ; ah=0x02 读扇区
                          ; al=4     读 4 个扇区

    ; 4. 调用 BIOS 磁盘中断
    int 0x13

    ; 5. 检查是否成功
    jnc ok_load_setup

    ; 6. 失败:复位磁盘并重试
    mov ax, #0x0000      ; ah=0x00 磁盘复位
    int 0x13
    j load_setup

ok_load_setup:
    ; setup 已成功加载到内存

▪ 3. int 0x13(磁盘中断)的核心意义

▫ 为什么必须用 BIOS 中断

  • 实模式下 CPU 不能直接操作磁盘硬件
  • 唯一可行方式:调用 BIOS 提供的中断服务

▫ 本次调用完成的任务

  • 从启动盘:

    • 第 2~5 扇区
  • 读取:

    • setup 模块(4 个扇区)
  • 存放到:

    • 内存 0x90200

▫ 容错机制

  • 通过 CF(进位标志) 判断成败

  • 失败则:

    1. 磁盘复位
    2. 重新读取

➤ 四、启动盘结构(逻辑视图)

┌───────────────┐
│ boot 扇区     │  第 1 扇区
│ bootsect.s    │
└───────────────┘
┌───────────────┐
│ setup 模块    │  第 2~5 扇区
│ 共 4 个扇区   │
└───────────────┘
┌───────────────┐
│ system 模块   │  第 6 扇区起
│ OS 核心代码   │
└───────────────┘

➤ 五、bootsect 后半段逻辑(setup 执行前)

▪ 1. 获取磁盘参数(int 0x13 / AH=08)

mov dl, #0x00
mov ax, #0x0800
int 0x13
mov sectors, cx

用途:

  • 获取 每磁道扇区数
  • 为后续读取 system 模块做准备

▪ 2. 显示启动提示(int 0x10)

▫ 读光标位置

mov ah, #0x03
xor bh, bh
int 0x10

▫ 显示字符串

mov cx, #24
mov bx, #0x0007
mov bp, #msg1
mov ax, #0x1301
int 0x10

显示效果:

Loading system...

▪ 3. 读取 system 模块

mov ax, #SYSSEG
mov es, ax
call read_it

▪ 4. 跳转到 setup 模块执行

jmpi 0, SETUPSEG

执行结果:

  • CS = SETUPSEG
  • IP = 0
  • 开始执行 setup 模块

➤ 六、bootsect.s 数据区

sectors: .word 0

msg1:
    .byte 13,10
    .ascii "Loading system..."

➤ 七、boot 扇区整体职责总结

bootsect.s 只做引导,不做初始化内核

  1. 建立干净的段 / 栈环境
  2. 读取 setup 模块
  3. 获取磁盘参数
  4. 显示启动提示
  5. 加载 system 到内存
  6. 跳转到 setup

boot → setup → system
这是 Linux 启动链条的最小闭环。


setup 模块剖析(启动阶段)

一、setup 模块的整体定位(主线先行)

setup 模块是引导过程中的过渡阶段代码,位于:

  • bootsect 之后
  • system(内核)之前

其核心任务不是“运行操作系统”,而是:

为内核正式启动准备运行环境


二、setup 模块的三项核心职责(一定要记)

setup 模块主要完成以下工作:

  1. 获取并保存 BIOS 提供的硬件参数
  2. 关闭中断并完成必要的 CPU 状态初始化
  3. 将 system 模块整体迁移到内存 0 地址

这三点就是 setup 的课程主线,后面的所有代码都围绕它们展开。


三、start 段:获取硬件参数并完成初始化

▶ 1. 初始化数据段寄存器

mov ax,#INITSEG
mov ds,ax

含义:

  • 设置 DS = 0x9000
  • 后续所有 [偏移] 内存访问,统一存放到 0x90000 起始的内存区域

这块区域相当于:

内核启动前的“硬件参数缓冲区”


▶ 2. 获取光标位置(BIOS 中断)

mov ah,#0x03
xor bh,bh
int 0x10
mov [0],dx

说明:

  • int 0x10 是 BIOS 显示服务
  • 功能号 0x03:获取当前光标位置
  • 返回结果保存在 DX

存储位置:

  • DS:0 → 物理地址 0x90000

目的:

让内核在启动后知道屏幕当前状态


▶ 3. 获取扩展内存大小(BIOS 中断)

mov ah,#0x88
int 0x15
mov [2],ax

说明:

  • int 0x15 是 BIOS 系统服务
  • 功能号 0x88:查询扩展内存大小
  • 返回值保存在 AX

存储位置:

  • DS:2 → 物理地址 0x90002

目的:

为内核后续内存管理提供基础信息


▶ 4. 关闭中断并初始化 CPU 状态

cli
mov ax,#0x0000
cld

作用说明:

  • cli:禁止中断

    • 启动阶段不允许被外部中断打断
  • cld:清除方向标志

    • 确保串操作指令(如 rep movsw)按 地址递增方向 执行

这一步属于:

为后续内存搬移操作做准备


四、do_move 段:迁移 system 模块到内存 0 地址

▶ 1. 为什么要迁移 system 模块?

system(内核)最初被加载在较高内存地址
而内核设计上:

要求从内存 0 地址开始运行

因此 setup 的关键任务之一是:

把 system 模块整体搬到低地址内存


▶ 2. 迁移逻辑的总体思路(比代码重要)

system 模块的迁移采用:

  • 按段(64KB)逐段搬移
  • 每次使用串操作指令完成批量拷贝

▶ 3. 关键代码与含义(理解级即可)

do_move:
  mov es,ax         ; 设置目的段
  add ax,#0x1000    ; 指向下一个 64KB 段
  cmp ax,#0x9000
  jz end_move
  mov ds,ax         ; 设置源段
  sub di,di
  sub si,si
  mov cx,#0x8000
  rep movsw
  jmp do_move

核心含义总结:

  • ES:DI:指向目标内存区域(低地址)
  • DS:SI:指向 system 模块当前所在段
  • rep movsw:完成大块内存复制

循环执行直到 system 模块全部搬移完成


▶ 4. 为什么每次拷贝 0x8000 个字?

  • 0x8000 字 = 32KB
  • 配合段切换,逐步完成整个 system 模块迁移

这是实现细节,理解“分块搬移”即可


五、硬件参数存储表(内核与 setup 的接口)

setup 模块将 BIOS 获取的硬件参数统一存放在:

物理地址 0x90000 开始的一段内存区域

内核启动后将直接从这里读取信息。

物理地址 长度 含义
0x90000 2 光标位置
0x90002 2 扩展内存大小
0x9000C 2 显卡参数
0x901FC 2 根设备号

进入保护模式(Protected Mode)

一、为什么要进入保护模式

实模式存在的主要限制:

  • 地址空间仅 20 位(1MB)
  • 无内存保护机制
  • 无特权级划分
  • 无法支持现代操作系统的内存管理与多任务

因此,操作系统在启动早期必须从 实模式切换到保护模式


二、进入保护模式前必须完成的三项准备工作

进入保护模式不是一条指令就能完成的,需要依次完成以下步骤:

  1. 打开 A20 地址线
  2. 初始化中断控制器(8259A)
  3. 设置 CR0 并执行远跳转

三、步骤一:打开 A20 地址线

▶ 1. 为什么要打开 A20

在早期 8086 体系中,为了兼容性,地址线 A20 默认被关闭:

  • 地址超过 1MB 会回绕到低地址(地址回卷)

保护模式需要线性地址空间,因此必须 显式打开 A20


▶ 2. 控制 A20 的硬件方式

  • 通过 8042 键盘控制器
  • 其输出端口 P2 控制 A20 地址线

▶ 3. A20 控制代码逻辑(说明性)

call empty_8042      ; 等待 8042 空闲
mov al,#0xD1         ; 命令:写 P2 输出端口
out #0x64,al         ; 发送命令到 8042

call empty_8042
mov al,#0xDF         ; 设置 P2,使 A20 = 1
out #0x60,al
call empty_8042

说明:

  • 端口 0x64:8042 命令端口
  • 端口 0x60:8042 数据端口
  • 0xDF:使 A20 地址线被选通

四、步骤二:初始化 8259A 中断控制器

初始化 8259A 的目的是:

  • 重新设置中断向量号
  • 避免硬件中断与 CPU 异常向量冲突

该部分代码通常较为固定、流程机械,因此课件未展开。


五、步骤三:切换到保护模式

▶ 1. 设置 CR0 寄存器

CR0 是控制处理器工作模式的重要控制寄存器:

  • 第 0 位(PE,Protection Enable)

    • 0:实模式
    • 1:保护模式
mov ax,#0x0001
mov cr0,ax

此时:

  • CPU 已进入保护模式
  • 但 CS 缓存仍保留实模式状态

▶ 2. 远跳转刷新 CS 和指令流水线

jmpi 0,8    ; IP = 0,CS = 8

该指令的作用:

  • 刷新 CS 段寄存器
  • 清空指令队列
  • 使 CPU 真正开始按保护模式解释指令

其中:

  • CS = 8 是一个 段选择子
  • 指向 GDT 中的代码段描述符

六、实模式与保护模式地址计算对比

实模式:

物理地址 = CS × 16 + IP
  • 20 位地址
  • 最大 1MB

保护模式:

  • 使用段选择子 + 段描述符
  • 支持 32 位线性地址
  • 理论地址空间可达 4GB

七、empty_8042 子程序的作用

empty_8042:
  .word 0x00eb,0x00eb
  in al,#0x64
  test al,#2
  jnz empty_8042
  ret

功能说明:

  • 读取 8042 状态寄存器
  • 检查输入缓冲区是否为空
  • 确保向 8042 写命令前硬件已准备好

其中:

  • test al,#2:检测输入缓冲区满标志(位 1)

保护模式切换

总览
setup 做的事情只有一件——
为 system 进入保护模式铺好“地址规则”和“中断规则”,然后一跳进去。


➤ 一、为什么进入保护模式前一定要有 GDT / IDT

▪ 1. 实模式 vs 保护模式,根本区别在哪?

▫ (1)实模式:段寄存器本身就是地址

CS:IP → CS << 4 + IP → 物理地址
  • CS 里放的就是“段基址的高 16 位”
  • CPU 不检查权限、不检查越界
  • 能跑,但不安全、不现代

▫ (2)保护模式:段寄存器只是“索引”

CS:IP → 用 CS 查 GDT → 得到段基址 → 段基址 + IP → 物理地址

关键变化:

  • CS 不再是地址
  • CS 是一个 “选择子(selector)”
  • 真正的地址规则写在 GDT 表项里

没有 GDT,保护模式根本无法工作


➤ 二、IDT:为什么“中断表可以先是空的”

你这里有个非常容易被卡住的点,我直接替你拆掉。

▪ 1. 实模式中断

int n → 中断向量表(固定在 0x0000)→ 处理函数

▪ 2. 保护模式中断

int n → IDT → 处理函数

▪ 3. 那为什么 setup 里 IDT 是“空表”?

idt_48:
    .word 0
    .word 0,0

原因只有一个:

刚进保护模式,先不允许中断发生

  • system 还没准备好
  • 中断一来,CPU 就会查 IDT
  • 查不到 → 直接死机

所以:

  • 先关中断
  • IDT 先给一个“占位”
  • 等 system 初始化完,再真正建立 IDT

这一步是防炸机操作,不是“没写完”。


➤ 三、把 setup 移到 0 地址的真正目的(非常关键)

PPT 里“移到 0 地址”,不是为了“方便”

真正目的:

让 GDT 里的代码段 / 数据段基址 = 0


▪ 1. 为什么段基址要是 0?

因为 system 是一个 32 位内核,它希望:

线性地址 = 逻辑地址

也就是:

段基址 = 0

这样:

  • C 代码里用的指针
  • 汇编里用的地址
  • 都不用再管段偏移

这是现代操作系统的基本假设。


▪ 2. setup 在做什么?

lgdt gdt_48
lidt idt_48

它不是“进入保护模式”,而是:

  • 提前把保护模式要用的表准备好
  • system 一跳进去就能直接用

➤ GDT 表的最小可用结构

▫ 1. GDT 里现在只有三类东西

表项 用途
空表项 保护用,CS=0 会异常
代码段 system 的代码
数据段 system 的数据 / 栈

▫ 2. 代码段 / 数据段“最重要的三个信息”

字段 含义
段基址 0x00000000
段限长 覆盖 system 所需空间
属性 能不能执行 / 读写

你现在完全可以忽略描述符的位级结构,只记:

GDT = 段规则表,不是地址表


➤ jmpi 0,8:真正进入保护模式的跳转

这是整个 setup 的高潮。

▪ 1. 指令本身

jmpi 0,8

含义:

  • IP = 0
  • CS = 8

▪ 2. CS=8 为什么是代码段?

8 = 0000 1000b
  • 低 3 位:特权 / 表类型
  • 高位:GDT 第 1 个有效表项

也就是定义的:

; 代码段描述符
.word 0x07FF, 0x0000, 0x9A00, 0x00C0

▪ 3. 跳转后的地址计算流程

jmpi 0,8
↓
CPU 用 CS=8 查 GDT
↓
得到:段基址 = 0
↓
物理地址 = 0 + IP(0)
↓
开始执行 system 的第一条指令

这不是“跳转”,而是“切换世界规则后第一次执行”


保护模式切换小结

  1. 知道 GDT 决定地址怎么算

  2. 知道 IDT 决定中断怎么进

  3. 知道 jmpi 0,8 是保护模式的入口跳

  4. 能用一句话解释:

    setup 做了什么?
    “准备表 + 跳进 system”


跳转到 system 模块并开始内核运行

(承接:进入保护模式 & GDT / IDT 初始化)

承接「进入保护模式」,说明 CPU 如何从 setup 跳转到 system 模块,并确定 system 的执行入口


4.1 system 模块的入口问题

➤ 问题 1:system 模块的第一段执行代码是什么?

system 模块开始执行的第一条指令来自 head.s

该结论不是约定,而是由 Makefile 链接顺序 + CPU 跳转方式共同决定的。


➤ 问题 2:system 由多个文件组成,为什么偏偏从 head.s 开始?

原因如下:

在链接生成 tools/system 时,boot/head.o 被放在第一个目标文件位置

而 CPU 跳转到 system 后,默认从 system 的起始地址(offset = 0)开始执行


4.2 system 的生成过程(Makefile 视角)

➤ 4.2.1 启动镜像 Image 的组成

Image: boot/bootsect boot/setup tools/system tools/build
	tools/build boot/bootsect boot/setup tools/system > Image

由此可知:

Image =
[ bootsect ]  → 引导扇区
[ setup    ]  → 实模式 → 保护模式准备
[ system   ]  → Linux 内核本体

➤ 4.2.2 system 模块的链接规则

tools/system: boot/head.o init/main.o $(DRIVERS) ...
	$(LD) boot/head.o init/main.o $(DRIVERS) ... -o tools/system

关键点:

  • boot/head.o 位于链接命令的第一个

  • 链接器规则:

    第一个目标文件的起始地址 = 可执行文件的起始地址

因此:

system 的 offset = 0
→ head.s 的第一条指令

4.3 setup → system 的控制转移

➤ 4.3.1 setup 末尾的关键跳转指令

jmpi 0,8

含义为:

CS = 8   (代码段选择子,对应 GDT 中的代码段)
IP = 0   (段内偏移为 0)

即:

从 system 模块的 0 偏移处开始执行


4.3.2 执行流闭环

bootsect
   ↓
setup
   ↓  jmpi 0,8
system(offset = 0)
   ↓
head.s

至此,CPU 正式进入 system 模块执行。


4.4 head.s 的作用

可以将 head.s 理解为:

从“硬件 + 保护模式”过渡到“C 语言内核”的最后一段汇编代码

其主要职责包括:

  1. 运行于保护模式下(setup 已完成模式切换)

  2. 建立内核运行所需的最小环境

    • 段寄存器
    • 页表(早期 Linux)
  3. 跳转到 C 语言入口函数

    • start_kernel(位于 init/main.c

执行层次为:

head.s   (汇编:环境准备)
   ↓
main.c   (C:内核逻辑开始)

4.5 本节小结

system 模块的入口是 head.s,原因在于链接顺序决定 system 的 0 地址对应 head.o,而 setup 通过 jmpi 0,8 跳转到 system 的起始地址执行


4.6 与上一节的逻辑关系

进入保护模式
   ↓
GDT / IDT 初始化
   ↓
jmpi 0,8
   ↓
system 模块
   ↓
head.s
   ↓
C 语言内核

system 模块的第一段代码:head.s

(承接:setup 跳入 system)

5.1 head.s 的定位与分工

➤ 1. 本节标题含义校准

head.s:一段在保护模式下运行的 32 位汇编代码

注意三个关键词:

  • 保护模式下
  • 32 位
  • 汇编(C 之前)

这三点决定了它“必须存在,且只能用汇编”。


➤ 2. setup 与 head 的职责边界(常考)

模块 职责
setup 进入保护模式(A20、CR0、GDT、跳转)
head 保护模式之后的初始化(段、栈、IDT、分页准备)

一句话记忆:

setup 负责“进门”,head 负责“站稳”


5.2 head.s 的执行入口:startup_32

➤ 1. 为什么入口叫 startup_32

  • system 是 32 位内核
  • CPU 此时已在保护模式
  • 默认使用 32 位指令集

所以第一条执行代码就是:

startup_32:

5.3 段寄存器初始化(保护模式必做)

movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs

➤ 这一步在干什么?

  • 0x10GDT 中的数据段选择子
  • 所有数据段寄存器统一指向 内核数据段

在保护模式下:

  • 段寄存器 不是地址
  • 必须显式初始化,否则访问内存直接异常

5.4 栈初始化(进入 C 世界的前提)

lss _stack_start,%esp

➤ 对应的 C 结构定义

struct {
    long *a;
    short b;
} stack_start = {
    &user_stack[PAGE_SIZE>>2],
    0x10
};

➤ 核心理解

  • 设置 系统栈

  • 0x10:栈段选择子(数据段)

  • 没有栈:

    • call
    • push
    • C 函数
      全部不能用

这是进入 C 代码前的“生命线”


5.5 setup_idt / setup_gdt —— 重点

➤ 1. 调用顺序

call setup_idt
call setup_gdt

➤ 2. 为什么 system 里还要再初始化 IDT / GDT?

▪ 之前 setup 里做了什么?

  • setup 里:

    • 给 CPU 一个 “最小可用的 GDT / IDT”
    • 目的是:不死机,能跳进 system

▪ head.s 里要做什么?

  • system 是正式内核

  • 需要:

    • 完整 GDT
    • 完整 IDT
    • 为后续中断 / 异常 / 系统调用做准备

setup 的表是“临时的”,
head 的表才是“内核级的”。


5.6 IDT 初始化:全部指向 ignore_int

➤ 1. IDT 空间定义

_idt: .fill 256,8,0

含义:

  • 256 个中断向量
  • 每个 8 字节(中断门)
  • 初始全 0

➤ 2. setup_idt 核心逻辑

lea ignore_int,%edx
movl $0x00080000,%eax
movw %dx,%ax
lea _idt,%edi
movl %eax,(%edi)

▪ 这在做什么?

  • 所有中断入口 设置为 ignore_int
  • 即:

中断来了也什么都不做


➤ 3. 为什么“忽略中断”是正确做法?

此时 system 还没完成:

  • 页表
  • 内存管理
  • 中断子系统

如果中断进来:

CPU → 查 IDT → 跳处理函数 → 系统未准备 → 崩

所以策略是:

先全部接住,但不处理

这是典型的 防炸机设计


5.7 A20 检测逻辑(安全兜底)

movl %eax,0x0000000
cmp %eax,0x100000
je 1b

➤ 含义

  • 检查:

    0 地址 和 1MB 地址 是否指向同一物理内存
    
  • 如果相同:

    • A20 没打开
    • 地址回卷
    • 直接死循环

这是内核级的“硬件自检”


5.8 跳转到分页初始化阶段

jmp after_page_tables

说明:

  • head.s 的下一阶段任务是:

    • 建立页表
    • 打开分页
  • 这一步之后,才真正接近现代 OS 的内存模型


5.9 本节总结

➤ 一句话

head.s 是 system 的第一段 32 位汇编代码,用于在保护模式下完成内核运行前的关键初始化(段、栈、IDT、硬件校验),为 C 语言内核铺路


➤ 执行流回顾

bootsect
   ↓
setup(进入保护模式)
   ↓ jmpi 0,8
head.s(startup_32)
   ↓
IDT / GDT / 栈 / A20 检测
   ↓
分页初始化
   ↓
main.c

after_page_tables:设置好页表之后的执行逻辑

➤ 再次明确分工

模块 作用
setup 进入保护模式
head 保护模式后的初始化
after_page_tables 分页完成后,安全跳入 C 内核 main()

➤ 代码整体结构(先看轮廓)

after_page_tables:
    pushl $0
    pushl $0
    pushl $0
    pushl $L6
    pushl $_main
    jmp setup_paging

L6:
    jmp L6

setup_paging:
    ; 设置页表
    ret

这一段不是顺序执行代码


➤ 关键思想:用「栈 + ret」跳进 main

不是 call main,而是用 ret“骗”CPU 跳进 main


➤ 压栈顺序(一定要从“最后一次 push”往回看)

▪ 汇编里的压栈

pushl $0
pushl $0
pushl $0
pushl $L6
pushl $_main

▪ 栈的真实布局(栈顶在上)

ESP →
┌───────────────┐
│ _main         │ ← ret 后跳转到这里
├───────────────┤
│ L6            │ ← main 返回地址
├───────────────┤
│ 0             │ ← 参数3
├───────────────┤
│ 0             │ ← 参数2
├───────────────┤
│ 0             │ ← 参数1
└───────────────┘

➤ 6.6 控制流一步一步走(容易乱)

▪ 第一步:跳入 setup_paging

jmp setup_paging
  • 不使用 call
  • setup_paging 不会自动生成返回地址

▪ 第二步:setup_paging 执行完毕

ret

ret 的行为是:

弹出栈顶地址 → 跳转

栈顶是谁?

_main

所以:

ret 直接跳进 main()


▪ 第三步:main() 看到的世界

此时栈是:

ESP →
┌───────────────┤
│ L6            │ ← main 的返回地址
├───────────────┤
│ 0             │ ← 参数1
├───────────────┤
│ 0             │ ← 参数2
├───────────────┤
│ 0             │ ← 参数3
└───────────────┘

因此:

  • main(0, 0, 0) 被正确调用
  • 完全符合 C 语言调用约定

▪ 第四步:main() 返回之后会发生什么?

  • main 执行 ret
  • 返回地址是 L6
L6:
    jmp L6

结果:

内核 main 永远不应该返回,一旦返回就原地死循环

这是一个明确的设计约束


➤ 为什么不直接 call main

原因:

分页开启前后,栈与返回路径必须完全可控

更具体地说:

  • setup_paging 会改变内存映射

  • 普通 call / ret 路径容易失控

  • 用“提前布好栈 + ret 跳转”:

    • 控制流可预测
    • 不依赖当前 EIP

这是内核早期代码的典型写法


本节总结

after_page_tables 通过“手动构造栈 + ret 跳转”的方式,在分页开启后安全进入 C 语言内核 main(),并确保 main 永不返回


➤ 执行流

bootsect
   ↓
setup(进入保护模式)
   ↓
head.s(startup_32)
   ↓
after_page_tables
   ↓
setup_paging(建页表)
   ↓ ret
main(0,0,0)
   ↓
(若返回)→ 死循环

main.c

➤ 职责变了:

阶段 本质
head.s 汇编,解决 CPU / 内存 / 页表 / 执行权交接
main() C 语言,解决 内核子系统初始化

head.s 的最后一件事就是:

“把执行权安全地交给 main()”

讲的已经是 交接完成之后发生的事


进入 main 函数(C 语言内核入口)

从这里开始,不再是汇编,而是 C 语言内核代码


main 函数的位置与身份

  • 所在文件:init/main.c
  • 函数原型:void main(void)

这是 Linux 内核中,第一个被执行的 C 语言函数


main 是如何被调用的?

  • head.s 中:

    • 构造栈
    • ret 跳转
  • 并不是通过 call main

  • 而是通过 “伪函数调用 + ret” 进入

所以:

main 看起来像“普通函数”,
实际上是 内核执行权的正式起点


main 函数的核心工作(初始化总调度)

void main(void)
{ 
  mem_init();        // 内存初始化
  trap_init();       // 中断 / 异常初始化
  blk_dev_init();    // 块设备
  chr_dev_init();    // 字符设备
  tty_init();        // 终端
  time_init();       // 时钟
  sched_init();      // 调度器
  buffer_init();    // 缓冲区
  hd_init();         // 硬盘
  floppy_init();    // 软盘
  sti();             // 打开中断
  move_to_user_mode();   // ★ 切换到用户态(重点)
  if (!fork()) { init(); }
}

main 的作用是“把内核世界搭起来”


关于 void main(void) 的说明

  • 这不是用户程序的 main
  • 没有参数需求
  • 它只是“C 语言的入口形式”

不用返回值、不用参数 argc/argv
操作系统永远不停


main 中两个“真正的关键点”

  • move_to_user_mode()

    • 第一次从内核态 → 用户态
  • fork() + init()

    • 第一个用户进程的诞生

从这里开始,Linux 不再是“一段代码”,而是“一个系统”


与栈结构的衔接说明

  • 进入 main() 时:

    • 栈中已有 0, 0, 0, L6
  • 这三个 0

    • 对应 main 的“伪参数”
  • main 返回后:

    • 会跳回 L6
    • 进入死循环(系统不会真正返回)
    • 因为我们操作系统是不停
      这是内核设计

内存初始化:mem_init

到这里为止,Linux 已经完成了
从上电 → 内核启动 → 基本资源可用 的全过程。


mem_init 的位置与调用关系

  • 所在文件:linux/mm/memory.c
  • 调用链路
bootsect → setup → head.s → main() → mem_init()

含义:

mem_init 是内核启动后,对“物理内存”的第一次正式接管


mem_init 的函数原型

void mem_init(long start_mem, long end_mem)

参数含义:

  • start_mem:可用内存的起始物理地址
  • end_mem:可用内存的结束物理地址

说明:

这两个参数由前面的启动阶段(setup / head)计算好,
mem_init 只负责“按规则登记”。


内存管理的基本单位

  • 内存以 页(Page) 为单位管理
  • 每一页大小:4KB
  • 换算关系:
1 页 = 4KB
右移 12 位 = 除以 4096

这是后面所有内存管理内容的基础假设。


mem_map:内存映射数组(核心概念)

  • mem_map[]:内核维护的物理页使用情况表
  • 每一个元素对应一页物理内存
  • 状态含义(老师板书):
含义
USED(如 100) 已被占用
0 空闲,可分配

一句话理解:

mem_map 是内核“记账用”的内存台账


mem_init 的整体执行逻辑

➤ 第一步:先“假设所有内存都不能用”

for(i=0; i<PAGING_PAGES; i++)
    mem_map[i] = USED;

意义:

内核启动时采取“保守策略”
先全部标记为已占用,防止误用未知内存区域。


➤ 第二步:计算“真正可用内存”的页范围

i = MAP_NR(start_mem);
end_mem -= start_mem;
end_mem >>= 12;

含义拆解:

  • MAP_NR(start_mem)
    → 把起始物理地址换算成 页号
  • end_mem -= start_mem
    → 得到可用内存总大小
  • >> 12
    → 把字节数换算成 页数

➤ 第三步:把“可用内存页”标记为空闲

while(end_mem-- > 0)
    mem_map[i++] = 0;

最终结果:

  • 不可用区域:仍是 USED
  • 可用内存区域:被标记为 0

一句话总结这一整段代码:

mem_init 的本质工作:
在 mem_map 中标出“哪些物理页可以被内核使用”


从系统角度看 mem_init 在干什么?

你可以站在更高一层这样理解(非常重要):

  • 在 mem_init 之前:

    • 内核“知道有内存”
    • 还不能安全地分配
  • 在 mem_init 之后:

    • 内核拥有了:

      • 可分配的物理页
      • 后续 kmalloc、进程内存、页表管理的基础

这一步完成后,内核才算“真正活过来”


第一章启动流程总结

现在已经完整走完了一条主线:

上电
 ↓
bootsect(引导)
 ↓
setup(进入保护模式)
 ↓
head.s(32 位环境 / 页表 / 栈)
 ↓
main(内核初始化)
 ↓
mem_init(内存正式可用)

到这里:

“操作系统如何启动”这一章,逻辑是闭合的


posted @ 2026-01-16 14:53  LFmin  阅读(4)  评论(0)    收藏  举报