字数 12516,阅读大约需 63 分钟
当你按下计算机的电源按钮时,一系列精心设计的程序开始依次执行,从最简单的BIOS代码到引导扇区,再到setup程序,最后启动内核。每个阶段完成自己的任务后,将控制权交给下一个阶段,最终将系统带入正常运行状态。

Linux 0.12的引导过程包含三个主要阶段的代码:bootsect.S(引导扇区)、setup.S(系统设置)和head.s(内核头部)。这三个程序是引导内核启动过程中的三个关键阶梯,引导系统从实模式切换到保护模式,从简单的16位环境进入更加强大的32位环境。理解这个过程不仅能让你掌握Linux的启动机制,更能深刻体会操作系统如何从无到有地建立起运行环境。
阅读这些汇编代码除了需要知道一些 8086 汇编语言知识以外,还需要了解一些采用 Intel 80X86 微处理器的 PC 机体系结构,以及在 32 位保护模式下进行编程的基本原理。
当 PC 的电源打开后,80x86 结构的CPU 将自动进入实模式,并从固定的地址(0xFFFF0)开始自动执行程序代码,这个地址通常是 ROM-BIOS 中的地址。BIOS(Basic Input/Output System)是固化在主板芯片上的程序,它的第一项工作是进行加电自检(POST,Power-On Self Test),检查内存、显卡等硬件是否正常,并在物理地址 0 处开始设置和初始化中断向量。

自检完成后,BIOS根据CMOS设置中的启动顺序,依次尝试从软盘、硬盘、光盘或网络启动。对于硬盘启动,BIOS读取硬盘的第一个扇区(主引导记录,MBR)到内存地址0x7C00,然后检查这个扇区的最后两个字节是否为0x55和0xAA(引导扇区标志)。如果是,BIOS将控制权转交给0x7C00处的代码,引导程序开始执行。
这个过程就像图书馆管理员打开图书馆大门后,将钥匙交给第一位到达的工作人员。BIOS完成了最基础的硬件初始化,引导扇区将继续后续的启动工作。
下图展示了从BIOS加载引导扇区到setup准备跳转到head.s的整个过程中,各个程序模块在内存中的位置变化:

各阶段详细说明:
这种设计巧妙地利用了有限的实模式内存空间(1MB以内),通过程序的分阶段加载和移动,既保证了各个模块有足够空间,又避免了相互覆盖。
引导扇区的代码空间非常有限——只有512字节,必须在这个狭小的空间内完成关键任务。Linux 0.12的bootsect.S就像一个高效的搬运工,它的主要任务是将自己和setup程序、内核模块从磁盘读入内存的合适位置。
在实模式下,内存布局相对简单,但也有很多区域被预留用于特殊用途。理解这个布局就像理解一座城市的分区规划,每个区域都有其特定的功能。
bootsect.S被BIOS加载到0x7C00后,第一件事就是把自己移动到0x90000。这个操作看似多此一举,实际上是为后续步骤腾出空间。先将自己的东西移走,才能有足够的空间放置新的东西进去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 !! SYS_SIZE is the number of clicks (16 bytes) to be loaded.! 0x3000 is 0x30000 bytes = 196kB, more than enough for current! versions of linux! SYS_SIZE是要加载的系统模块长度,单位是节,每节16字节。0x3000共为0x30000字节=196KB。! 若以1024字节为1KB计,则因该就192KB。对应当前内核版本这个空间长度已足够了。当该值为! 0x8000时,表示内核最大为512KB。因为内存0x90000处开始存放移动后的bootsect和setup的代码,! 因此该值最大不得超过0x9000(表示584KB)。!! 头文件linux/config.h中定义了内核用到的一些常数符号和Linus自己使用的默认硬盘参数块。! 例如定义了以下一些常数:! DEF_INITSEG 0x9000 //引导扇区程序将被移动到得段值。! DEF_SYSSEG 0x1000 //引导扇区程序把系统模块加载到内存的段值。! DEF_SETUPSEG 0x9020 //setup程序所处内存段位置。! DEF_SYSSIZE 0x3000 //内核系统模块默认最大节数(16字节=1节)。!#include <linux/config.h>SYSSIZE = DEF_SYSSIZE !定义一个标号或符号。指明编译连接后system模块的大小。!! bootsect.s (C) 1991 Linus Torvalds! modified by Drew Eckhardt!! bootsect.s is loaded at 0x7c00 by the bios-startup routines, and moves! iself out of the way to address 0x90000, and jumps there.!! It then loads 'setup' directly after itself (0x90200), and the system! at 0x10000, using BIOS interrupts.!! NOTE! currently system is at most 8*65536 bytes long. This should be no! problem, even in the future. I want to keep it simple. This 512 kB! kernel size should be enough, especially as this doesn't contain the! buffer cache as in minix!! The loader has been made as simple as possible, and continuos! read errors will result in a unbreakable loop. Reboot by hand. It! loads pretty fast by getting whole sectors at a time whenever possible.!! bootsect.s (C) 1991 Linus Torvalds! Drew Eckhardt修改!! bootsect.S被ROM BIOS启动子程序加载至0x7c00(32KB)处,并将自己移到了地址0x90000! (576KB)处,并跳转至那里。!! 它然后使用BIOS中断将'setup'直接加载到自己的后面(0x90200)(576.5KB),并将system! 加载到地址0x10000处。!! 注意!目前的内核系统最大长度限制为(8*65536)(512KB)B,即使是在将来这也应该没有问题! 的。我想让他保持简单明了。这样512KB的最大内核长度应该足够了,尤其是这里没有像MINIX中! 一样包含缓冲区高速缓冲。.globl begtext, begdata, begbss, endtext, enddata, endbss.textbegtext:.databegdata:.bssbegbss:.textSETUPLEN = 4 ! nr of setup-sectors ! setup程序占用的扇区数BOOTSEG = 0x07c0 ! original address of boot-sector ! bootsect代码所在内存原始段地址INITSEG = DEF_INITSEG ! we move boot here - out of the way ! 将bootsect移到位置0x90000 避开系统模块占用处SETUPSEG = DEF_SETUPSEG ! setup starts here ! setup程序从内存0x90200处开始SYSSEG = DEF_SYSSEG ! system loaded at 0x10000 (65536). ! system模块加载到0x10000(64KB)处ENDSEG = SYSSEG + SYSSIZE ! where to stop loading ! 停止加载的段地址! ROOT_DEV & SWAP_DEV are now written by "build".! 根文件系统设备号ROOT_DEV和交换设备号SWAP_DEV现在由tools目录下的build程序写入ROOT_DEV = 0 !根文件系统设备使用与系统引导是同样的设备SWAP_DEV = 0 !交换设备使用与系统引导是同样的设备entry start ! 告知连接程序,程序从start标号开始执行start: mov ax, #BOOTSEG ! 将DS段寄存器置为0x07c0 mov ds, ax mov ax, #INITSEG ! 将ES段寄存器置为0x9000 mov es, ax mov cx, #256 ! 设置移动计数值=256字(512字节) sub si, si ! 源地址 ds:si = 0x07c0:0x0000 sub di, di ! 目的地址 es:di = 0x9000:0x0000 rep ! 重复执行并递减cx的值,直到cx=0为止 movw ! 即movs指令。从内存[si]处移动cx个字到[di]处 jmpi go, INITSEG ! 段间跳转(Jump Intersegment)。这里INITSEG指出跳转到得段地址, ! 标号go是段内偏移地址
这段代码使用rep movw指令,将512字节的引导扇区从0x7C00复制到0x90000,然后通过段间跳转指令jmpi跳转到新位置继续执行。
上一步调转到0x90000后,bootsect开始执行,bootsect继续从磁盘读取setup程序和system模块,并把setup读入到内存 0x90200 处(启动设备盘后 2KB 字节代码,也就是boot/setup.S)。内核的system 模块则被读入到从内存地址 0x10000(64KB)开始处。这个过程就像快递物流分拣,按照地址将不同的快递包裹送到指定位置。
自移动完成后,bootsect从新位置0x90000继续执行。接下来的任务是从磁盘加载setup程序到内存0x90200处。setup程序共占用4个扇区(2KB),紧跟在引导扇区之后。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 go: mov ax, cs ! 将ds、es、ss都设置为0x9000 mov ds, ax mov es, ax! put stack at 0x9ff00. mov ss, ax mov sp, #0xFF00 ! arbitrary value >>512 ! 栈指针sp指向0x9ff00(从0x9ff00向下增长)! load the setup-sectors directly after the bootblock.! Note that 'es' is already set up.! 加载setup模块到0x90200(在bootsect后面)load_setup: mov dx, #0x0000 ! drive 0, head 0 mov cx, #0x0002 ! sector 2, track 0 mov bx, #0x0200 ! address = 512, in INITSEG mov ax, #0x0200+SETUPLEN ! service 2, nr of sectors int 0x13 ! read it jnc ok_load_setup ! ok - continue mov dx, #0x0000 mov ax, #0x0000 ! reset the diskette int 0x13 j load_setup ! 出错则重试ok_load_setup:
这段代码使用BIOS中断INT 0x13(磁盘服务)加载setup。AH=0x02表示读扇区功能,AL=SETUPLEN(4)表示读取4个扇区,CH=0表示柱面号0,CL=2表示从第2个扇区开始(第1个扇区是bootsect自己),DH=0表示磁头0,DL=0表示驱动器0,ES:BX=0x9000:0x0200指向目标缓冲区0x90200。
如果读取失败(CF标志置位),就复位磁盘(AH=0)后重试。这种简单的错误处理在现代系统中看似原始,但在启动阶段是必要的容错机制。
setup加载完成后,bootsect继续加载system模块。system模块是内核的主体部分,包含head.s和所有C编译的内核代码,体积可达数百KB。由于太大无法一次加载,bootsect采用循环方式逐个扇区读取。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 ! Get disk drive parameters, specifically nr of sectors/track! 获取磁盘驱动器参数,特别是每磁道扇区数 mov dx, #0x0000 mov ax, #0x0800 ! AH=8 is get drive parameters int 0x13 mov ch, #0x00 seg cs ! seg cs表示下一条指令的操作数在cs段中 mov sectors, cx ! 保存每磁道扇区数 mov ax, #INITSEG mov es, ax! Print some inane message! 打印一些信息 mov ah, #0x03 ! read cursor pos xor bh, bh int 0x10 mov cx, #24 ! 显示24个字符 mov bx, #0x0007 ! page 0, attribute 7 (normal) mov bp, #msg1 mov ax, #0x1301 ! write string, move cursor int 0x10! ok, we've written the message, now! we want to load the system (at 0x10000)! 现在开始加载system模块到0x10000处 mov ax, #SYSSEG mov es, ax ! segment of 0x010000 call read_it ! 调用read_it子程序读取system模块 call kill_motor ! 关闭驱动器马达
read_it是一个循环读取子程序,它计算要读取的扇区数(根据SYSSIZE),然后一个扇区一个扇区地读取system模块到0x10000开始的内存区域。每次读取后ES增加0x20(512字节/16=32),使下一次读取的目标地址后移512字节。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 read_it: mov ax, es test ax, #0x0fff ! 测试是否到达64KB边界die: jne die ! es must be at 64kB boundary xor bx, bx ! bx is starting address within segmentrp_read: mov ax, es cmp ax, #ENDSEG ! have we loaded all yet? jb ok1_read retok1_read: seg cs mov ax, sectors ! 每磁道扇区数 sub ax, sread ! 减去已读扇区数 mov cx, ax shl cx, #9 ! cx = cx * 512 字节 add cx, bx jnc ok2_read je ok2_read xor ax, ax sub ax, bx shr ax, #9 ! ax = ax / 512ok2_read: call read_track ! 读取当前磁道的剩余扇区 mov cx, ax add ax, sread seg cs cmp ax, sectors ! 是否读完当前磁道 jne ok3_read mov ax, #1 sub ax, head jne ok4_read ! 如果是0磁头,则去读1磁头 inc track ! 否则读下一磁道ok4_read: mov head, ax xor ax, axok3_read: mov sread, ax ! 保存已读扇区数 shl cx, #9 add bx, cx ! 调整缓冲区指针 jnc rp_read mov ax, es add ax, #0x1000 ! 将es增加0x1000(64KB) mov es, ax xor bx, bx jmp rp_read
这段代码展示了在16位实模式下处理大文件的复杂性。由于段寄存器的限制(每个段最大64KB),必须在读取64KB后调整ES寄存器指向下一个64KB段。这种分段管理在保护模式下会通过分页机制得到简化。
所有模块加载完毕后,bootsect的使命完成,它通过段间跳转指令jmpi将控制权交给setup程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 ! After that we check which root-device to use. If the device is! defined (!= 0), nothing is done and the given device is used.! Otherwise, either /dev/PS0 (2,28) or /dev/at0 (2,8), depending! on the number of sectors that the BIOS reports currently.! 检查要使用哪个根文件系统设备 seg cs mov ax, root_dev ! 取根设备号 cmp ax, #0 jne root_defined seg cs mov bx, sectors mov ax, #0x0208 ! /dev/ps0 - 1.2Mb cmp bx, #15 je root_defined mov ax, #0x021c ! /dev/PS0 - 1.44Mb cmp bx, #18 je root_definedundef_root: jmp undef_root ! 死循环,表示未定义根设备root_defined: seg cs mov root_dev, ax! after that (everyting loaded), we jump to! the setup-routine loaded directly after! the bootblock:! 跳转到setup程序执行(setup位于0x90200) jmpi 0, SETUPSEG
这个跳转标志着bootsect使命的结束。控制权传递给setup,系统引导进入下一阶段。jmpi指令的格式是"jmpi 偏移地址, 段地址",这里跳转到0x9020:0x0000,即物理地址0x90200。

setup程序是引导过程中的关键环节,它就像一个全面的系统检查员,负责收集硬件信息、准备进入保护模式的环境,最后完成模式切换。setup程序的代码相对较长,但逻辑清晰,可以分为几个主要阶段。
setup程序使用BIOS中断获取系统硬件信息,这些信息对内核正常运行至关重要。这就像医生在手术前详细了解病人的身体状况。这些信息被存储在0x90000开始的内存区域(原来bootsect所在位置),供内核初始化时使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 ! ok, the read went well so we get current cursor position and save it for! posterity.! 获取光标位置 mov ax, #INITSEG ! this is done in bootsect already, but... mov ds, ax mov ah, #0x03 ! read cursor pos xor bh, bh int 0x10 ! save it in known place, con_init fetches mov [0], dx ! it from 0x90000.! Get memory size (extended mem, kB)! 获取扩展内存大小(单位:KB) mov ah, #0x88 int 0x15 mov [2], ax ! 存储在 0x90002! Get video-card data:! 获取显卡数据 mov ah, #0x0f int 0x10 mov [4], bx ! bh = display page mov [6], ax ! al = video mode, ah = window width! check for EGA/VGA and some config parameters! 检查EGA/VGA和配置参数 mov ah, #0x12 mov bl, #0x10 int 0x10 mov [8], ax mov [10], bx mov [12], cx! Get hd0 data! 获取第一个硬盘的参数表 mov ax, #0x0000 mov ds, ax lds si, [4*0x41] ! 取中断向量0x41的值,就是硬盘参数表的地址 mov ax, #INITSEG mov es, ax mov di, #0x0080 ! 传送目的地址0x90080 mov cx, #0x10 rep movsb ! 复制16字节! Get hd1 data! 获取第二个硬盘的参数表 mov ax, #0x0000 mov ds, ax lds si, [4*0x46] ! 取中断向量0x46的值 mov ax, #INITSEG mov es, ax mov di, #0x0090 ! 传送目的地址0x90090 mov cx, #0x10 rep movsb! Check that there IS a hd1 :-)! 检查第二个硬盘是否存在 mov ax, #0x01500 mov dl, #0x81 int 0x13 jc no_disk1 cmp ah, #3 je is_disk1no_disk1: mov ax, #INITSEG mov es, ax mov di, #0x0090 mov cx, #0x10 mov ax, #0x00 rep stosb ! 清零第二个硬盘参数表is_disk1:
这段代码收集了以下关键信息:
这些信息的存储位置是精心设计的:0x90000-0x901ff这512字节原本是bootsect所在位置,现在bootsect已经完成使命,这块内存可以被重新利用来存储系统参数。

在收集完硬件信息后,setup要将system模块从0x10000移动到0x00000。这一步非常关键,因为内核代码必须从物理地址0开始,这样才能正确设置分页机制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ! now we want to move to protected mode ...! 现在要移动到保护模式了 cli ! no interrupts allowed ! ! 关中断,以防止移动过程中发生中断! first we move the system to it's rightful place! 首先将system模块移动到正确的位置 mov ax, #0x0000 cld ! 'direction'=0, movs moves forwarddo_move: mov es, ax ! destination segment add ax, #0x1000 cmp ax, #0x9000 ! 已经移动了所有的段了吗? jz end_move mov ds, ax ! source segment sub di, di sub si, si mov cx, #0x8000 ! 移动64KB,0x8000个字(32KB个字=64KB) rep movsw jmp do_moveend_move:
这段代码将system模块从0x10000开始的位置移动到0x00000开始的位置。移动过程采用循环方式,每次移动64KB(一个段),直到0x90000为止(因为0x90000开始是bootsect和setup所在位置,不能覆盖)。移动后,system模块的内容从物理地址0x00000开始,这是内核分页机制的起始地址。
移动操作为什么采用这种复杂的方式?因为在实模式下,每次移动只能处理64KB的段。移动的源地址从0x10000开始,每次增加0x1000(即下一个64KB段);目的地址从0x00000开始,也是每次增加0x1000。移动直到0x90000为止,这样最多可以移动512KB的内核代码。
进入保护模式之前,必须准备好中断描述符表(IDT)和全局描述符表(GDT)。setup设置了临时的IDT和GDT,这些在head.s中会被重新设置。
1 2 3 4 5 6 ! then we load the segment descriptors! 加载段描述符 mov ax, cs ! right, forgot this at first. didn't work :-) mov ds, ax lidt idt_48 ! load idt with 0,0 lgdt gdt_48 ! load gdt with whatever appropriate
lidt和lgdt指令加载IDT和GDT寄存器。idt_48和gdt_48是两个6字节的数据结构,前2字节是表的限长(单位字节),后4字节是表的基地址。
1 2 3 4 5 6 7 idt_48: .word 0 ! idt limit=0 .word 0, 0 ! idt base=0Lgdt_48: .word 0x800 ! gdt limit=2048, 256 GDT entries .word 512+gdt, 0x9 ! gdt base = 0X9xxxx
IDT被设置为空(限长0,基地址0),因为真正的IDT会在内核初始化时由trap_init()函数设置。GDT包含3个描述符:空描述符、代码段描述符和数据段描述符。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 gdt: .word 0, 0, 0, 0 ! dummy ! 空描述符,x86要求GDT第一项为空 .word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb) .word 0x0000 ! base address=0 .word 0x9A00 ! code read/exec .word 0x00C0 ! granularity=4096, 386 ! 代码段,基址0,限长8MB,可读可执行 .word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb) .word 0x0000 ! base address=0 .word 0x9200 ! data read/write .word 0x00C0 ! granularity=4096, 386 ! 数据段,基址0,限长8MB,可读可写
这个简单的GDT只是临时使用,为了能够进入保护模式并执行一些基本操作。真正完整的GDT会在head.s中重新设置,包含每个进程的TSS和LDT描述符。

A20地址线的开启是进入保护模式的必要步骤。这是一个历史遗留问题:在8086时代,地址总线只有20条,最大可寻址1MB内存。8086的分段寻址方式(段地址*16+偏移地址)可能产生超过1MB的地址,例如0xFFFF:0xFFFF=0x10FFEF。在实际硬件上,第21位地址线(A20)会被忽略,导致地址回绕到低端。0x100000会回绕到0x000000。
到了80286时代,地址总线扩展到24条,可寻块16MB内存。但为了保持向后兼容性(有些老软件依赖地址回绕特性),IBM在PC/AT设计中将A20地址线的控制连接到键盘控制器的空闲端口,默认关闭。只有主动开启A20,CPU才能访问1MB以上的内存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ! that was painless, now we enable A20! 现在开启A20地址线 call empty_8042 ! 等待输入缓冲器空 mov al, #0xD1 ! command write out #0x64, al call empty_8042 ! 等待输入缓冲器空 mov al, #0xDF ! A20 on out #0x60, al call empty_8042 ! 等待输入缓冲器空! This routine checks that the keyboard command queue is empty! No timeout is used - if this hangs there is something wrong with! the machine, and we probably couldn't proceed anyway.empty_8042: .word 0x00eb,0x00eb ! jmp $+2, jmp $+2 in al, #0x64 ! 8042 status port test al, #2 ! is input buffer full? jnz empty_8042 ! yes - loop ret
A20地址线的开启通过键盘控制器8042完成:
empty_8042子程序负责等待键盘控制器的输入缓冲器空。它首先执行两次"jmp $+2"(原地跳转)指令,这是一个延时技巧,给键盘控制器留出反应时间。然后循环读取状态端口,直到输入缓冲器空为止。
A20地址线的开启是一个典型的历史包袱:为了向后兼容,现代系统不得不继续支持这30年前的奇技淋巧。这也提醒我们,计算机系统的演进总是带着历史的抈锁。
现在一切准备就绪,setup即将完成其最重要的使命:将CPU从实模式切换到保护模式。这一切换是历史性的一刻,从此系统进入32位的现代操作系统时代。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ! well, that went ok, I hope. Now we have to reprogram the interrupts :-(! we put them right after the intel-reserved hardware interrupts, at! int 0x20-0x2F. There they won't mess up anything. Sadly IBM really! messed this up with the original PC, and they haven't been able to! rectify it afterwards. Thus the bios puts interrupts at 0x08-0x0f,! which is used for the internal hardware interrupts as well. We just! have to reprogram the 8259's, and it isn't fun.! 现在必须重新编程8259A中断控制器 mov al, #0x11 ! initialization sequence out #0x20, al ! send it to 8259A-1 .word 0x00eb,0x00eb ! jmp $+2, jmp $+2 out #0xA0, al ! and to 8259A-2 .word 0x00eb,0x00eb mov al, #0x20 ! start of hardware int's (0x20) out #0x21, al .word 0x00eb,0x00eb mov al, #0x28 ! start of hardware int's 2 (0x28) out #0xA1, al .word 0x00eb,0x00eb mov al, #0x04 ! 8259-1 is master out #0x21, al .word 0x00eb,0x00eb mov al, #0x02 ! 8259-2 is slave out #0xA1, al .word 0x00eb,0x00eb mov al, #0x01 ! 8086 mode for both out #0x21, al .word 0x00eb,0x00eb out #0xA1, al .word 0x00eb,0x00eb mov al, #0xFF ! mask off all interrupts for now out #0x21, al .word 0x00eb,0x00eb out #0xA1, al
这段代码重新编程8259A中断控制器。为什么需要这样做?BIOS将8259A设置为发送中断号0x08-0x0f,但这些中断号在保护模式下被Intel保留用于处理器异常(如除零错误、调试陷阱、非法指令等)。为了避免冲突,Linux将硬件中断重新映射到0x20-0x2f。
重新编程的步骤:
最后,设置CR0寄存器的PE位(保护模式使能位),并跳转到保护模式下的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 ! well, that certainly wasn't fun :-(. Hopefully it works, and we don't! need no steenking BIOS anyway (except for the initial loading :-)! The BIOS-routine wants lots of unnecessary data, and it's less! "interesting" anyway. This is how REAL programmers do it.!! Well, now's the time to actually move into protected mode. To make! things as simple as possible, we do no register set-up or anything,! we let the gnu-compiled 32-bit programs do that. We just jump to! absolute address 0x00000, in 32-bit protected mode.! 现在是实际进入保护模式的时候了 mov ax, #0x0001 ! protected mode (PE) bit lmsw ax ! This is it! jmpi 0, 8 ! jmp offset 0 of segment 8 (cs)
lmsw指令(Load Machine Status Word)加载机器状态字到CR0寄存器的低16位。这里将最低位(PE位)置1,启用保护模式。然后jmpi指令跳转到0x08:0x00000,即代码段选择符为8(GDT中的第2项,即代码段描述符),偏移地址0x00000。

这个跳转是历史性的:它标志着CPU从16位实模式切换到32位保护模式,从此可以访问全部4GB地址空间,使用分段和分页保护机制,运行现代操作系统。跳转的目标地址0x00000是system模块的起始位置,也就是head.s的开始。

head.s是内核真正的入口,此时处理器已经进入保护模式,但还需要做很多初始化工作才能跳转到C语言编写的main函数。head.s是system模块的第一部分,它使用32位汇编语言编写,这与之前的16位实模式代码有本质不同。
setup中设置的GDT只是临时使用的,head.s需要重新建立一个完整的GDT。这个GDT包含了更多的描述符,为后续的进程管理做准备。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 .textstartup_32: movl $0x10,%eax # 0x10是GDT中数据段的选择符 mov %ax,%ds mov %ax,%es mov %ax,%fs mov %ax,%gs lss stack_start,%esp # 设置系统堆栈 call setup_idt # 设置IDT call setup_gdt # 重新设置GDT movl $0x10,%eax # reload all the segment registers mov %ax,%ds # after changing gdt. CS was already mov %ax,%es # reloaded in 'setup_gdt' mov %ax,%fs mov %ax,%gs lss stack_start,%esp
head.s的开始标签是startup_32,这是setup跳转过来的位置。首先初始化所有段寄存器为数据段选择符(0x10),然后设置堆栈指针。接着调用setup_idt和setup_gdt函数设置IDT和GDT。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 setup_idt: lea ignore_int,%edx # 将ignore_int的地址送到edx movl $0x00080000,%eax # 将选择符0x0008置入eax的高半部 movw %dx,%ax # selector = 0x0008 = cs movw $0x8E00,%dx # interrupt gate - dpl=0, present lea idt,%edi # idt是IDT表在内存中的地址 mov $256,%ecx # 设置256个中断门rp_sidt: movl %eax,(%edi) # 将哑元中断门描述符的低4字节存入表中 movl %edx,4(%edi) # 再将高4字节存入 addl $8,%edi # edi指向下一项 dec %ecx jne rp_sidt lidt idt_descr # 加载IDT寄存器 ret
setup_idt函数将IDT的所有256个项都设置为指向ignore_int的中断门。ignore_int是一个哑元中断处理程序,它只是返回而不做任何事情。真正的中断处理程序会在内核初始化时由trap_init()函数设置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 setup_gdt: lgdt gdt_descr # 加载GDT寄存器 retgdt_descr: .word 256*8-1 # so does gdt (not that that's any .long gdt # magic number, but it works for me :^) .align 8idt: .fill 256,8,0 # idt is uninitializedgdt: .quad 0x0000000000000000 /* NULL descriptor */ .quad 0x00c09a0000000fff /* 16Mb */ # 0x08, 内核代码段,可读/执行 .quad 0x00c0920000000fff /* 16Mb */ # 0x10, 内核数据段,可读/写 .quad 0x0000000000000000 /* TEMPORARY - don't use */ .fill 252,8,0 # 剩余252项留给TSS和LDT使用
setup_gdt函数重新加载GDT寄存器。这里GDT的大小被设置为256项,每项8字节。当前只填充了前3项:空描述符、代码段描述符和数据段描述符。剩余252项留给后续的进程TSS和LDT描述符使用。
head.s的首要任务是建立分页机制。虽然已经在保护模式下,但分页还未启用。分页机制如同为整个系统建立了新的地址映射体系。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 movl $pg0+7,pg_dir /* set present bit/user r/w */ movl $pg1+7,pg_dir+4 /* --------- " " --------- */ movl $pg2+7,pg_dir+8 /* --------- " " --------- */ movl $pg3+7,pg_dir+12 /* --------- " " --------- */ movl $pg3+4092,%edi movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */ stdsetup_paging: stosl /* fill pages backwards - more efficient :-) */ subl $0x1000,%eax jge setup_paging xorl %eax,%eax /* pg_dir is at 0x0000 */ movl %eax,%cr3 /* cr3 - page directory start */ movl %cr0,%eax orl $0x80000000,%eax movl %eax,%cr0 /* set paging (PG) bit */ ret /* this also flushes prefetch-queue */
这段代码建立了一个简单的页目录和页表,将16MB的线性地址空间直接映射到16MB的物理地址。具体步骤:
分页机制启用后,所有的线性地址都需要通过页表转换为物理地址。由于这里建立的是恒等映射(线性地址=物理地址),代码可以继续执行而不需要修改。但这为后续的进程管理和虚拟内存制定了基础。
建立内核栈是为后续的C函数调用做准备。没有栈,函数调用、局部变量、参数传递都无法进行。
1 2 3 4 5 6 7 stack_start: .long user_stack+PAGE_SIZE # 堆栈指针指向user_stack数组的顶端 .word 0x10 # 堆栈段选择符=0x10(内核数据段).org 0x5000 # user_stack数组的起始位置user_stack: .fill PAGE_SIZE/4, 4, 0 # 分配一个页面(4KB)作为内核栈
stack_start定义了堆栈的位置,它包含了堆栈指针和堆栈段选择符。lss stack_start,%esp指令同时加载ess和esp寄存器,设置好内核栈。
内核栈的大小只有4KB(一个页面),这在现代系统中看似很小,但对于Linux 0.12的内核来说已经足够。后续每个进程都会有自己的内核态栈,存储在进程的task_struct所在页面中。
完成所有初始化后,head.s跳转到main函数,控制权正式交给C代码,内核初始化进入新阶段。
1 2 3 4 5 6 7 8 9 10 after_page_tables: pushl $0 # These are the parameters to main :-) pushl $0 pushl $0 pushl $L6 # return address for main, if it decides to. pushl $main jmp setup_pagingL6: jmp L6 # main should never return here, but # just in case, we know what happens.
这段代码将几个参数压栈,然后调用setup_paging建立分页。setup_paging执行完毕后会返回,而返回地址是after_page_tables之后的指令。但由于栈上压入了$main,实际上会跳转到main函数执行。
L6标签是一个死循环,用于捕获main函数返回的情况(理论上main不应该返回)。如果main意外返回,系统会在这里挂死。
跳转到main函数标志着引导阶段的结束和内核初始化阶段的开始。从此,系统进入了C语言的世界,开始初始化内存管理、进程调度、设备驱动等子系统。
理解实模式和保护模式的本质区别,是掌握系统引导过程的关键。这两种模式代表了x86处理器的两个时代。
地址计算方式:
1 物理地址 = 段地址 << 4 + 偏移地址
例如,段:偏移 = 0x9000:0x0100
1 2 3 4 // 计算物理地址物理地址 = 0x9000 * 16 + 0x0100 = 0x90000 + 0x0100 = 0x90100
实模式的限制:
实模式寻址示例:
1 2 3 4 5 6 7 ; 实模式下访问显存mov ax, 0xB800 ; 彩色文本模式显存段地址mov es, axmov byte ptr es:[0], 'A' ; 写入字符Amov byte ptr es:[1], 0x07 ; 白色前景,黑色背景; 物理地址 = 0xB800 * 16 + 0 = 0xB8000
地址计算方式:
1 2 3 4 5 1. 段选择符 → 查GDT/LDT → 得到段描述符2. 检查段界限和访问权限3. 线性地址 = 段基址 + 偏移地址4. 如果分页开启:线性地址 → 页表转换 → 物理地址 否则:线性地址 = 物理地址
段描述符结构(8字节):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 struct segment_descriptor { unsigned limit_low:16; // 段界限低16位 unsigned base_low:16; // 基地址低16位 unsigned base_mid:8; // 基地址中8位 unsigned type:4; // 段类型(代码/数据/系统) unsigned s:1; // 描述符类型(0=系统,1=代码/数据) unsigned dpl:2; // 特权级(0-3) unsigned present:1; // 段存在位 unsigned limit_high:4; // 段界限高4位 unsigned avl:1; // 系统软件可用位 unsigned reserved:1; // 保留位 unsigned db:1; // 默认操作数大小(0=16位,1=32位) unsigned granularity:1; // 粒度(0=字节,1=4KB) unsigned base_high:8; // 基地址高8位};
段选择符结构(16位):
1 2 3 4 5 6 7 8 15 3 2 1 0+---------------+--+--+--+| index |TI|RPL |+---------------+--+--+--+index: 索引号(13位),指定描述符在GDT/LDT中的位置TI: 表指示符(1位),0=GDT,1=LDTRPL: 请求特权级(2位),0-3
段选择符结构(32位):

保护模式的优势:
保护模式寻址示例:
1 2 3 4 5 6 7 8 ; 保护模式下访问显存(假设GDT已设置)mov ax, 0x10 ; 数据段选择符(GDT[2])mov es, ax; CPU自动查GDT,获取段基址0x00000000; 检查段界限、访问权限mov byte ptr es:[0xB8000], 'A' ; 写入字符A; 线性地址 = 0x00000000 + 0xB8000 = 0xB8000; 如果分页开启,还需要页表转换
从实模式到保护模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 ; 1. 准备GDTmov ax, csmov ds, axlgdt gdt_48 ; 加载GDTR寄存器; 2. 开启A20地址线call empty_8042 ; 等待键盘控制器空闲mov al, #0xD1 ; 命令:写输出端口out #0x64, alcall empty_8042mov al, #0xDF ; A20 onout #0x60, al; 3. 设置CR0的PE位mov ax, #0x0001 ; PE位lmsw ax ; 加载到CR0; 4. 跳转刷新指令队列jmpi 0, 8 ; 跳转到保护模式代码(段选择符8); 5. 重新加载段寄存器mov ax, 0x10 ; 数据段选择符mov ds, axmov es, axmov fs, axmov gs, axmov ss, ax
为什么需要跳转刷新指令队列?
CPU的指令预取队列中可能还有实模式的指令。切换到保护模式后,这些指令的解释方式完全不同:
如果不刷新指令队列,可能会错误执行这些指令。jmpi指令(段间跳转)会自动刷新指令队列。
Linux 0.12的内核代码段描述符:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 原始值:0x00c09a0000000fff解析: 63-56: base_high = 0x00 → 基地址高8位 55: G(粒度) = 1 → 4KB粒度 54: D/B = 1 → 32位段 53: 保留 = 0 52: AVL = 0 51-48: limit_high = 0x0 → 段界限高4位 47: P(存在) = 1 → 段存在 46-45: DPL = 0 → 特权级0(内核) 44: S = 1 → 代码/数据段 43-40: Type = 0xA → 可执行、可读、已访问 39-32: base_mid = 0x00 → 基地址中8位 31-16: base_low = 0x0000 → 基地址低16位 15-0: limit_low = 0x0FFF → 段界限低16位计算段界限: 界限 = (limit_high << 16 | limit_low) * 粒度 = (0x0 << 16 | 0xFFF) * 4096 = 0xFFF * 4096 = 16MB - 4KB段基址:0x00000000段界限:16MB访问权限:可读、可执行、特权级0
为什么段界限是16MB而不是4GB?
Linux 0.12限制内核代码段为16MB是出于以下考虑:
后续版本的Linux将段界限扩展到4GB,使用平坦内存模型(flat memory model)。
引导过程中大量使用了BIOS提供的磁盘服务。理解这些中断的工作原理,有助于深入掌握底层硬件访问机制。
INT 0x13是BIOS提供的磁盘I/O服务中断,支持多种功能。
主要功能号(AH寄存器):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // 功能0x00: 复位磁盘系统AH = 0x00DL = 驱动器号(0x00=软盘A,0x80=硬盘C)返回:CF=0成功,CF=1失败// 功能0x02: 读扇区到内存AH = 0x02 // 功能号AL = 扇区数 // 要读取的扇区数(1-128)CH = 柱面号低8位CL = 扇区号(0-5位) + 柱面号高2位(6-7位)DH = 磁头号DL = 驱动器号ES:BX = 缓冲区地址 // 数据存放位置返回: CF = 0: 成功,AL = 实际读取的扇区数 CF = 1: 失败,AH = 错误码// 功能0x08: 获取驱动器参数AH = 0x08DL = 驱动器号返回: CF = 0: 成功 CH = 最大柱面号低8位 CL = 每磁道扇区数(0-5位) + 最大柱面号高2位(6-7位) DH = 最大磁头号 DL = 驱动器数量 CF = 1: 失败
bootsect中读取setup的完整过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 load_setup: ; 设置读取参数 mov dx, #0x0000 ; DH=磁头0, DL=驱动器0 mov cx, #0x0002 ; CH=柱面0, CL=扇区2(从第2扇区开始) mov bx, #0x0200 ; 偏移地址=512(ES已经是0x9000) mov ax, #0x0200+SETUPLEN ; AH=0x02(读扇区), AL=4(读4个扇区) ; 调用BIOS中断 int 0x13 ; 读取磁盘 ; 检查是否成功 jnc ok_load_setup ; CF=0则跳转(成功) ; 读取失败,复位磁盘 mov dx, #0x0000 mov ax, #0x0000 ; AH=0x00(复位功能) int 0x13 j load_setup ; 重试ok_load_setup: ; 继续执行后续代码
CHS寻址模式:
CHS(Cylinder-Head-Sector)是传统的磁盘寻址方式:
1 2 3 4 5 6 7 8 9 10 11 12 物理扇区位置 = (柱面号 × 磁头数 + 磁头号) × 扇区数 + 扇区号 - 1例如:读取第100个扇区(假设18扇区/磁道,2磁头)100 = (柱面 × 2 + 磁头) × 18 + 扇区 - 1计算: 柱面 = 100 / (2 × 18) = 2 余数 = 100 % (2 × 18) = 28 磁头 = 28 / 18 = 1 扇区 = 28 % 18 + 1 = 11结果:柱面2,磁头1,扇区11
为什么扇区号从1开始?
这是软盘时代的历史遗留。软盘的第0扇区用于同步和标识,实际数据从扇区1开始。硬盘沿用了这个编号方式以保持兼容性。
read_it是bootsect中最复杂的部分,负责将system模块完整加载到内存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 read_it: ; 检查ES是否在64KB边界 mov ax, es test ax, #0x0fff ; 测试低12位是否为0die: jne die ; 不在64KB边界则死循环 xor bx, bx ; BX=0,段内偏移从0开始rp_read: ; 检查是否已加载所有扇区 mov ax, es cmp ax, #ENDSEG ; 比较当前段与结束段 jb ok1_read ; 小于则继续 ret ; 完成,返回ok1_read: ; 计算当前磁道还剩多少扇区 seg cs mov ax, sectors ; 每磁道扇区数 sub ax, sread ; 减去已读扇区数 mov cx, ax ; CX = 剩余扇区数 shl cx, #9 ; CX = 剩余字节数(乘以512) add cx, bx ; CX += 当前段内偏移 ; 检查是否会超过64KB段界限 jnc ok2_read ; 不超过则跳转 je ok2_read ; 正好等于也跳转 ; 会超过64KB,计算这次能读多少扇区 xor ax, ax ; AX = 0 sub ax, bx ; AX = 64KB - 当前偏移 shr ax, #9 ; AX = 可读扇区数(除以512)ok2_read: ; 调用read_track读取当前磁道剩余扇区 call read_track ; 更新已读扇区数和偏移 mov cx, ax ; CX = 实际读取的扇区数 add ax, sread ; 更新已读扇区数 seg cs cmp ax, sectors ; 是否读完整个磁道? jne ok3_read ; 未读完则跳转 ; 读完一个磁道,切换磁头或磁道 mov ax, #1 sub ax, head ; AX = 1 - 当前磁头 jne ok4_read ; 如果当前是磁头0,切换到磁头1 inc track ; 否则切换到下一磁道ok4_read: mov head, ax xor ax, ax ; 新磁道从扇区0开始ok3_read: mov sread, ax ; 保存已读扇区数 shl cx, #9 ; CX = 读取的字节数 add bx, cx ; 更新段内偏移 jnc rp_read ; 未超过64KB则继续 ; 超过64KB,切换到下一个段 mov ax, es add ax, #0x1000 ; ES += 0x1000(64KB) mov es, ax xor bx, bx ; 偏移归零 jmp rp_read ; 继续读取
关键变量说明:
1 2 3 4 5 sread: 当前磁道已读扇区数head: 当前磁头号(0或1)track: 当前柱面号sectors: 每磁道扇区数(通过INT 0x13功能0x08获取)ES:BX: 当前读取的目标地址
为什么需要这么复杂的逻辑?
read_track子程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 read_track: ; 保存要读取的扇区数 push ax ; AX = 扇区数 push bx ; BX = 段内偏移 push cx ; CX = 扇区数(参数) push dx ; DX = 磁头和驱动器号 ; 设置读取参数 mov dx, track ; DH = 磁头号 mov cx, sread ; CL = 起始扇区号 inc cx ; 扇区号从1开始 mov ch, track ; CH = 柱面号 mov dx, head ; DH = 磁头号 mov dl, #0 ; DL = 驱动器0 ; 读取扇区 and dx, #0x0100 ; 只保留磁头号 mov ah, #2 ; 功能0x02:读扇区 int 0x13 ; 调用BIOS ; 检查是否成功 jc bad_rt ; CF=1则读取失败 ; 成功,恢复寄存器并返回 pop dx pop cx pop bx pop ax retbad_rt: ; 读取失败,复位磁盘后重试 mov ax, #0 mov dx, #0 int 0x13 ; 复位磁盘 pop dx pop cx pop bx pop ax jmp read_track ; 重试
本章详细分析了Linux 0.12的引导过程,从BIOS加载到进入内核。引导过程如同接力赛跑,BIOS、bootsect、setup、head依次完成各自任务。bootsect负责将程序加载到内存,setup负责硬件信息收集和模式切换,head负责建立分页机制和跳转到main函数。这个过程体现了操作系统启动的复杂性和精巧设计。
1. 引导流程四个阶段
2. 内存布局变化
3. 实模式与保护模式
4. 段描述符机制
5. A20地址线历史
6. BIOS中断服务
7. 分页机制基础
1. 为什么bootsect要自移动?
如果bootsect不移动,留在0x7C00:
2. 为什么setup要移动system?
保护模式的分页机制要求:
3. 为什么需要重新设置GDT/IDT?
setup中的GDT只是临时的:
4. 为什么要重编8259A?
BIOS设置的中断向量:
1. 模块化设计
2. 容错设计
3. 空间优化
4. 向前兼容
Linux 0.12 vs 现代Linux
不变的核心原理:
使用Bochs调试引导过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 1. 在.bochsrc中设置断点# b 0x7c00 # bootsect入口# b 0x90000 # bootsect移动后# b 0x90200 # setup入口# b 0x00000 # head.s入口# 2. 查看关键寄存器bochs> info cpu # 查看CPU状态bochs> info gdt # 查看GDTbochs> info idt # 查看IDTbochs> creg # 查看CR0/CR3等# 3. 查看内存bochs> x /10 0x7c00 # 查看bootsect代码bochs> x /10 0x90000 # 查看移动后的bootsectbochs> x /10 0x90200 # 查看setup代码# 4. 单步执行bochs> s # Step执行一条指令bochs> c # Continue继续执行
关键检查点:
"千里之行,始于足下。"——老子《道德经》