linux0.11环境搭建与阅读

本文最后更新于:2022年8月7日 晚上

Linux0.11环境搭建与阅读

背景

更好的学习 linux 内核思想,做一个有想法的人。

环境搭建

在 windows 下使用虚拟机 vmware ,虚拟机使用 ubuntu 22.04 LTS,模拟器使用 qemu 。

搭建虚拟机vmware过程省略,具体的可查看 archlinux的搭建

搭建ubuntu中qemu

由于使用命令 sudo apt install qemu 安装版本过旧并可能存在问题,所以本文使用编译方式安装。

ubuntu 宿主机环境如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 1. 安装环境必备包
sudo apt-get install git libglib2.0-dev libfdt-dev libpixman-1-dev zlib1g-dev
sudo apt install ninja-build

# 2. 下载qemu源码
git clone https://mirrors.tuna.tsinghua.edu.cn/git/qemu.git

# 3. 配置qemu
cd qemu
./configure
# 如果配置没有报错,则可以直接编译并安装了。
# 大约 6376.73s 编译完成,可使用命令 time make -j$(nproc) 查看时间,以下为我本次编译所需时间
# make -j12 6376.73s user 729.92s system 1160% cpu 10:12.17 total
make -j$(nproc)

# 直接./configure 可能会报错,./configure --with-git-submodules=validate 重新配置后编译安装即可。
sudo make install -j$(nproc)

# 4.验证 通过命令可以看出 qemu 的版本号,代表安装完成。
u@u-virtual-machine /home/u/workspace/tools
⚡ qemu-system-x86_64 --version
QEMU emulator version 7.0.50
Copyright (c) 2003-2022 Fabrice Bellard and the QEMU Project developers

配置好后,打开发现只输出一句话,VNC Server runnning on 127.0.0.1:5900

这是因为没有支持SDL(Simple DirectMedia Layer)

所以需要重新配置后在安装,

1
2
3
4
5
6
7
8
9
10
11
# 安装SDL支持依赖
sudo apt install libsdl1.2-dev libsdl2-dev

# 继续配置,一会搜索一下看下SDL是否为YES,为YES则已经开启了,剩下的直接编译安装就可。
./configure --with-git-submodules=validate

# 编译和安装
make -j$(nproc) && sudo make install -j$(nproc)

# 再次验证,输入下面命令后会直接弹出qemu虚拟机
qemu-system-x86_64

配置gdb环境

1
sudo apt install gdb

习惯使用pwndbg了,添加此插件。

代码分析

首先下载源码,并编译,没有问题那么就开始分析源码了。

下载源码

地址为 https://github.com/yuan-xy/Linux-0.11

不过也可以fork到自己的仓库中,方便后面更改后的上传。

1
2
3
git clone https://github.com/Gonglja/Linux-0.11.git && cd Linux-0.11
make clean -j$(nproc) && make -j$(nproc)
# 直接可以编过,不会缺少什么库。

代码汇编部分采用AT&T语法,需要熟悉AT&T

源码分析

大部分来自闪客大佬的品读 Linux 0.11 核心代码和赵炯博士的Linux内核0.11完全注释_V3.0

本文只是一个在学习 Linux-0.11 过程中的一个笔记,如有侵权,请联系:glj0@outlook.com

寄存器

类别 寄存器 说明
AX
BX
CX
DX
CS 代码段(code segment)寄存器
DS 数据段(data segment)寄存器
SS 堆栈段(stack segment)寄存器
ES 附加段(extra segment)寄存器
SP 堆栈指针(stack pointer)寄存器
BP 基址指针(base pointer)寄存器
SI 源变址指针(source index)寄存器
DI 目的变址指针(destination index)寄存器
IP 指令指针(instruction pointer)寄存器
FLAGS 状态标志()
IDTR 中断描述符表索引()
GDTR 全局描述符表索引()
CR3 页表索引

指令

AT&T

类别 指令 说明 示例
跳转指令 jmp 跳转到相对有效地址 jmp *4(%edi)跳转到EDI寄存器+4偏移处
ljmp 长跳转 ljmp $0xfebc,$0x12345678 0xfebc用于CS寄存器,0x12345678用于EIP寄存器,换句话说,跳转至0xfebc<<16 + 0x12345678
jne 条件跳转,CF=0,则跳转 mov $AX,%ax;jnc label; 如果AX >= 0,则跳转到label处执行。
数据传送指令 movsw 从源地址向目的地址传送数据
在16位模式下,源地址DS:SI ,目的地址ES:DI
在32位模式下,源地址DS:ESI,目的地址ES:EDI
movsb、movsw、movsd 区别,b字节、w字、d双字,也即传递一个字节、一个字、一个双字。
cld;rep;stosl cld设置edi或同esi为递增方向(从前往后)(如果为std,则从后往前 反向),rep做(%ecx)次重复操作,stosl表示edi每次增加4

先看下最外层的Makefile,关注几个点

当链接器链接的时候,其参数携带-Ttext 0,指定代码段的运行地址从0开始

另一个就是all,也就是Image,是由boot/bootsect boot/setup tools/system等编译的,编译结束后又经过tools/build.sh脚本构建镜像

模块 偏移(段/Byte) 大小(段/Byte) 备注
bootsect 0/0 1/512 启动阶段
setup 1/512 4/2048 配置阶段
system 5/2560 (2888-1-4)*512=1476096,最大长度,实际为system的大小 内核等
DEFAULT_MINOR_ROOT DEFAULT_MAJOR_ROOT 0/508*1 2 版本号信息

也由此可知,上电后从boot跳转过来后,直接执行的是bootsect中的内容。

boot

1
2
3
4
5
6
7
8
➜  boot git:(master) ✗ tree    
.
├── bootsect.s
├── head.s
├── Makefile
└── setup.s

0 directories, 4 files

先看下Makefile,通过编译脚本可得 链接器参数为 -Ttext 0,另外三个bootsectsetuphead模块也是分别编译。

  • 补个图

当cpu上电后,bios会将第一个可启动的硬盘的前512字节数据,拷贝到0x7c00处。

什么叫可启动设备:只要第一个扇区的512字节的最后两字节分别为0x55、0xaa,那么其就是一个可启动设备。

前512字节是什么呢?bootsect模块,换句话说,当cpu上电后,biosbootsect拷贝到0x7c00处,其大小为 512 Byte。

之后就进入到0x7c00处,开始执行bootsect模块的代码。

bootsect

.global 声明部分标号对全局可见,

  • _start 程序开始的地方
  • begtext text段开始的位置
  • begdata data段开始的位置
  • begbss bss段开始的位置
  • endtext text段结束的位置
  • enddata data段结束的位置
  • endbss bss段结束的位置

.equ 表达式赋值操作符

标号 含义
SYSSIZE 0x3000 system大小
SETUPLEN 4 setup段中长度
BOOTSEG 0x07c0 boot段源地址
INITSEG 0x9000 boot段即将移动到的位置
SETUPSEG 0x9020 setup段开始的位置
SYSSEG 0x1000 system加载的位置0x10000
ENDSEG SYSSEG+SYSSIZE 停止加载的位置

ljmp $BOOTSEG, $_start长跳转,跳转至$BOOTSEG<<16 + _start$处,也即0x7c00处。

1
2
3
4
5
6
7
8
9
10
_start:
mov $BOOTSEG, %ax
mov %ax, %ds #将ds段寄存器设置为0x7C0
mov $INITSEG, %ax
mov %ax, %es #将es段寄存器设置为0x9000
mov $256, %cx #设置移动计数值256字
sub %si, %si #源地址 ds:si = 0x07C0:0x0000
sub %di, %di #目标地址 es:si = 0x9000:0x0000
rep #重复执行并递减cx的值
movsw #从内存[si]处移动cx个字到[di]处

由于不能直接给ds赋值,借助ax,将ds寄存器值设置为0x7C0;同理,将es段寄存器设置为0x900

接着设置cx寄存器值为256,将si、di寄存器清零。

movsw:数据传送指令,从源地址向目的地址传送数据

在16位模式下,源地址DS:SI ,目的地址ES:DI

在32位模式下,源地址DS:ESI,目的地址ES:EDI

movsb、movsw、movsd 区别,b字节、w字、d双字,也即传递一个字节、一个字、一个双字。

所以,这段代码的作用就是将ds:si(0x7c0:0x00x7c00)处开始,大小为256字,即512字节的数据拷贝到es:di(0x9000:0x00x90000)处。

也就是cpu上电后,bios将第一个可启动设备的前512字节先拷贝到0x7c00处,接着跳转到0x7c00处执行,然后又将该部分拷贝到0x90000

ljmp $INITSEG, $go 长跳转,直接跳转至0x9000:go处。

接着执行go处的代码。由于是长跳转,所以cs的值为$INITSEG,也就是0x9000

所以此处代码也就是将dsesss设置为移动后代码所在的段处,并且将堆栈段 设置为0x9000:0 - 0x9000:0xff00,即0x90000 - 0x9ff00

1
2
3
4
5
6
go:	 mov   %cs, %ax		#将ds,es,ss都设置成移动后代码所在的段处(0x9000)
mov %ax, %ds
mov %ax, %es
# put stack at 0x9ff00.
mov %ax, %ss
mov $0xFF00, %sp # arbitrary value >>512
1
2
3
4
5
6
7
load_setup:
mov $0x0000, %dx # drive 0, head 0
mov $0x0002, %cx # sector 2, track 0
mov $0x0200, %bx # address = 512, in INITSEG
.equ AX, 0x0200+SETUPLEN
mov $AX, %ax # service 2, nr of sectors
int $0x13 # read it

看一下int $13,BIOS int 13h中断也叫直接磁盘服务(Direct Disk Service)其对应。

此处int为中断,int 0x13,发起0x13号中断。

当中断发生后,CPU会根据中断编号去找对应的中断函数入口地址并跳转过去执行,相当于此处执行了一个函数。

  • ax=0x0204 其功能描述为读扇区,扇区数为4
  • bx=0x0200 其功能配置es寄存器组成缓冲区地址,es:bx 缓冲区地址
  • cx=0x0002 分为ch和cl,ch 柱面,cl 扇区。即0柱面2扇区。
  • dx=0x0000 分为dh和dl,dh 磁头,dl 驱动器00H7FH 软盘;80HFFH 硬盘

也就是从 软盘驱动器00柱面2扇区开始,拷贝4扇区es:bx,也就是0x9000:0x02000x90200

拷贝的是什么东西呢?硬盘中1-5共4扇区的代码。

1
2
3
4
5
6
7
load_setup:
...
jnc ok_load_setup # ok - continue
mov $0x0000, %dx
mov $0x0000, %ax # reset the diskette
int $0x13
jmp load_setup

接着 因为AX>=0,跳转到ok_load_setup执行。

1
2
3
4
5
6
7
ok_load_setup:
...
mov $SYSSEG, %ax
mov %ax, %es # segment of 0x010000
call read_it
...
ljmp $SETUPSEG, $0

这部分只看主要代码,其将剩下的从第6个扇区后面的x个扇区,加载到内存0x10000处,简单来说,就是将system代码挪了个地。

接着一个长跳转 SETUPSEG,将CS设置为0x9020,EIP设置为0x0 也就是跳转到0x9020<<16 | 00x90200

硬盘中数据是怎么分区的呢

通过Makefiletools/build.sh配合完成,其中

  • bootsect.s编译成bootsect,放在第1扇区

  • setup.s编译成setup,放在2~5扇区

  • 将剩下的head.s和其他代码编译成system,放在随后的240个扇区?(不一定是240扇区)

    总结一下:cpu上电后bios将第一个可启动分区的前512字节(bootsect)拷贝到0x7c00处,并跳转过去执行,接着bootsect 又把自己搬到了0x90000处。

    然后跳转过去,将2扇区-5扇区(setup)共4扇区拷贝到0x90200处。接着将6扇区以后(system)拷贝到0x10000处。最后跳转到0x90200处执行。

    在分析的过程中,我们借助gdbtarget remote :1234

    b *0x7c00 在0x7c00处加个断点

    b *0x90200 在0x90200处加个断点

    当跳转到 0x90200 处,通过命令 x/512b 0x90200/x/256h 0x90200处值,发现就是我们拷贝过去的第一个扇区(bootsect)

setup

setup.s负责从BIOS中获取系统数据,并将这些数据放到系统内存的合适地方。这段代码询问bios 有关内存/磁盘/其它参数,并将这些参数存到一个“安全的”地方:0x90000-0x901FF。

1
2
3
4
5
6
7
.equ SETUPSEG, 0x9020	# this is the current segment
...
ljmp $SETUPSEG, $_start
_start:
mov %cs,%ax
mov %ax,%ds
mov %ax,%es

跳转到相对于0x90200处偏移_start的位置,也就是当前_start代码的位置,更新当前ds、es寄存器值为0x9020

接着往下看代码,都是形似 mov %ax,\$xxa;mov %bx,\$xxb;mov %cx,\$xxc;mov %dx,\$xxd;int xxe;都是通过bios中断获取信息,然后将其存在内存中。

存在哪呢?实际上是保存在ds寄存器值为cs,偏移为0处(cs:0 == cs<<16+0)。

1
2
3
4
5
6
7
	ljmp $SETUPSEG, $_start	
_start:
mov %cs,%ax
mov %ax,%ds
...
int $0x10 # save it in known place, con_init fetches
mov %dx, %ds:0 # it from 0x90000.

最终会通过bios获取到这些数据,将之存到0x90000处。

内存地址 长度(字节) 名称
0x90000 2 光标位置
0x90002 2 扩展内存数
0x90004 2 显示页面
0x90006 1 显示模式
0x90007 1 字符列数
0x90008 2 未知
0x9000A 1 显示内存
0x9000B 1 显示状态
0x9000C 2 显卡特性参数
0x9000E 1 屏幕行数
0x9000F 1 屏幕列数
0x90080 16 硬盘1参数表
0x90090 16 硬盘2参数表
0x901FC 2 根设备号

将以上信息存储到0x90000处后,将关闭中断。因为后面我们要自己实现中断,并且将bios的中断向量表破坏掉,所以这个时候是不允许中断进来的。

1
2
3
...
cli
...

看下面的部分,是不很熟悉movsw,是一个数据传送指令,将一段数据从源地址传送到目的地址,详见指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
	mov	$0x0000, %ax
cld # 'direction'=0, movs moves forward
do_move:
mov %ax, %es # destination segment
add $0x1000, %ax
cmp $0x9000, %ax
jz end_move
mov %ax, %ds # source segment
sub %di, %di
sub %si, %si
mov $0x8000, %cx
rep
movsw
jmp do_move

这段代码,也就是将system 模块移动到新的位置,新位置起始为0。

与以下c代码相同。

1
2
3
4
5
char add[0x90000];
int j=0;
for(int i=0x10000; i<0x90000;i++){
add[j++] = add[i];
}

重新规划后内存布局如下,

切换到保护模式

实模式与保护模式的第一个区别:物理地址计算方式不同

实模式下:物理地址为段寄存器中的地址<<16 + 偏移地址。

保护模式下:段寄存器中 存的是段选择子,段选择子去全局描述符中寻找段选择符,从中取出段基地址 再加上偏移地址才是物理地址。

“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”。

“Any problem in computer science can be solved by anther layer of indirection.”

段选择子结构

由段描述符索引 以及TI、RPL组成

段描述符结构

全局描述符表寄存器

也就是说:进入保护模式之后,物理地址计算方式发生改变,在仅有段机制的情况下,段寄存器(cs)中存的不再是段基址,而是段选择子,段选择子结构如上,

段选择子中存的是段描述符索引,通过段描述符索引就可以从全局描述符表(gdt)中取出对应的段描述符结构,其中包含段基地址,段基地址+偏移地址就是物理地址。

全局描述符表(gdt)在哪存呢?内存中,在哪个位置呢?这时候就有一个专门的寄存器gdtr去存储gdt的位置,其结构如上。

所以,以下前两句代码,也就是将idt/gdt的地址存储到idtr/gdtr中;中间的代码(gdt_48标签处)则描述了全局描述符表寄存器的配置值,低16位:gdt界限,也就是全局描述符表的长度,高32位,描述了全局描述符表的内存地址;后面的代码(gdt标签处)描述了3个段描述符,第一个为空,第二个是代码段描述符(type=code),第三个是数据段描述符(type=data),第二个和第三个基地址均为0,那么也就是说在后面不管通过段选择子选择的是代码段还是数据段,其基地址为0,实际物理地址(仅段模式机制下,否则称为线性地址)等于偏移地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
lidt fword ptr idt_48 ;// 加载中断描述符表(idt)寄存器,idt_48 是6 字节操作数的位置
lgdt fword ptr gdt_48 ;// 加载全局描述符表(gdt)寄存器,gdt_48 是6 字节操作数的位置
...
gdt_48:
dw 800h ;// 全局表长度为2k 字节,因为每8 字节组成一个段描述符项,所以表中共可有256 项
dw 512+gdt,9h ;// 4 个字节构成的内存线性地址:(0009<<16 + 0200)+gdt,也即90200 + gdt(即在本程序段中的偏移地址)。
...
gdt:
.word 0,0,0,0 # dummy

.word 0x07FF # 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 # base address=0
.word 0x9A00 # code read/exec
.word 0x00C0 # granularity=4096, 386

.word 0x07FF # 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 # base address=0
.word 0x9200 # data read/write
.word 0x00C0 # granularity=4096, 386

这个图描述了如何从逻辑地址经过分段、分页之后得到物理地址的。

开启A20线

A20地址线是为了突破20位地址线的限制,变成32位可用,所以即使地址线有32位了,但是你如果不手动开启,还是会限制20位可用。现在的CPU位数都32位、64位,为了兼容以前的20位地址总线,便有了此选项。

1
2
3
4
5
...
inb $0x92, %al # open A20 line(Fast Gate A20).
orb $0x02, %al
outb %al, $0x92
...

接着是对可编程中断控制器 8259 芯片的编程,一堆代码不用看,其作用就是重新配置中断号IRQ0-IRQ15的功能。

PIC 请求号 中断号 用途
IRQ0 0x20 时钟中断
IRQ1 0x21 键盘中断
IRQ2 0x22 接连从芯片
IRQ3 0x23 串口2
IRQ4 0x24 串口1
IRQ5 0x25 并口2
IRQ6 0x26 软盘驱动器
IRQ7 0x27 并口1
IRQ8 0x28 实时钟中断
IRQ9 0x29 保留
IRQ10 0x2a 保留
IRQ11 0x2b 保留
IRQ12 0x2c 鼠标中断
IRQ13 0x2d 数学协处理器
IRQ14 0x2e 硬盘中断
IRQ15 0x2f 保留

打开cr0寄存器的第0位,也即开启了保护模式。模式的切换非常简单,重要的是前期的准备工作。

起初是先给ax赋值为0x0001,后面通过lmsw命令将ax的值给到cr0寄存器。

但是后来可以直接通过mov 指令读写cr0寄存器,这样就可以读出cr0的值,然后将0位置1,接着在写回cr0,这样也能实现。

1
2
3
4
5
#mov	$0x0001, %ax	# protected mode (PE) bit
#lmsw %ax # This is it!
mov %cr0, %eax # get machine status(cr0|MSW)
bts $0, %eax # turn on the PE-bit
mov %eax, %cr0 # protection enabled

接着就是直接跳转到物理地址 0 处了。

1
2
.equ	sel_cs0, 0x0008 # select for code segment 0 (  001:0 :00) 
ljmp $sel_cs0, $0 # jmp offset 0 of code segment 0 in gdt

首先将sel_cs0置为0x0008,由于是保护模式,所以ds为段选择子,其值为0x00080b0000_0000_0000_1000,描述符索引为0b0000_0000_0000_1,也就是1,前面我们设定的段描述符,第0个为空,第1个为代码段,第2个为数据段,也就是代码段,其基地址为0,所以此处也就是跳转到0地址处。

由最外层的Makefile可得,system由boot/head.oinit/main.o及其它组成,并且system在bootsect中被搬运至0处。

所以在setup中跳转到0处,也就是跳转到head中了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pg_dir:
.globl startup_32
startup_32:
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss stack_start,%esp
call setup_idt
call setup_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

看下代码,刚开始有个标号pg_dir,这个是页目录,之后设置分页机制得时候,页目录会放这,覆盖这里得代码。

往下走,就是给eax赋值为0x10,给 ds、es、fs、gs赋值0x10(0b0001_0000),也就是0b0001_0 即2,索引为2的段为数据段。

然后lss stack_start,%esp,将stack_start高位给ss,低16位给esp。(之前是0x9ff00,现在要换到_stack_start)

stack_start这个标号在sched.c中,(关于为什么是start_start 而不是_stack_start这个是因为cdecl调用规约中第4条:编译后的函数名前缀以一个下划线字符开始)

1
2
3
4
5
6
long user_stack [ 4096>>2 ] ;

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

也就是将高位0x10给ss,低位user_stack [4096>>2] 的元素的下一个地址值给esp,0x10也就是0x0001_0000表示指向全局描述符的第0x0001_0个段,也就是第2个段为data段,其基地址为0。

call setup_idt设置中断描述符表
call setup_gdt设置全局描述符表

然后又重新设置一遍,为什么要重新设置?在上面设置中断/全局描述符表的时候,修改了gdt,所以要重新设置才会生效。

接下来重点看 setup_idt/setup_gdt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
setup_idt:
lea ignore_int,%edx
movl $0x00080000,%eax
movw %dx,%ax /* selector = 0x0008 = cs */
movw $0x8E00,%dx /* interrupt gate - dpl=0, present */

lea idt,%edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
jne rp_sidt
lidt idt_descr
ret
...
idt_descr:
.word 256*8-1 # idt contains 256 entries
.long idt
...
idt: .fill 256,8,0 # idt is uninitialized #256项,每项8字节,填0

设置256个中断描述符,每个中断描述符中的中断处理都指向ignore_int,这是个默认的中断处理程序,后面慢慢会被具体的中断程序覆盖。

setup_gdt也是如此,设置后的gdt如下:

1
2
3
4
5
gdt:   .quad 0x0000000000000000	/* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */
.quad 0x00c0920000000fff /* 16Mb */
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */

这部分代码有两个精彩的地方,一个是分页机制、另一个就是怎么进入main函数的。

配置完idt和gdt后,接着继续往下走,跳转到after_page_tables后,先是几个push,其中包含了c语言的世界的地址,然后一个跳转到setup_paging,给ecx分配大小为5*1024(5pages),然后将eax清零,edi清零。接着将al中的数据(0)填充到edi起始的位置(0)处,方向为正向,大小为5*1024*4。(也就是说 从零地址开始前20k内存清零

cld;rep;stosl
cld设置edi或同esi为递增方向,rep做(%ecx)次重复操作,stosl表示edi每次增加4,这条语句达到按4字节清空前5*1024*4字节地址空间的目的。

然后就把内存分成了这个样子,如下图,页表0-3正好可以将 16MB 空间完全覆盖($(0x5000-0x1000)/4 * 4 / 1024=16MB$)

开启分页机制

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
	...
jmp after_page_tables
...
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_paging
L6:
jmp L6
...

.align 2
setup_paging:
movl $1024*5,%ecx /* 5 pages - pg_dir+4 page tables */
xorl %eax,%eax
xorl %edi,%edi /* pg_dir is at 0x000 */
cld;rep;stosl
movl $pg0+7,pg_dir /* 7-->111-> set User/supervisor Read/Write Present */
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) */
std
1: stosl /* fill pages backwards - more efficient :-) */
subl $0x1000,%eax
jge 1b
cld
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 */
...
.org 0x1000
pg0:

.org 0x2000
pg1:

.org 0x3000
pg2:

.org 0x4000
pg3:

.org 0x5000

接着往下走,几个mov指令将页目录表的前几个空填上,$pg0 ~ $pg4地址如:0x1000、0x2000、0x3000、0x4000 对应页表地址为0x1、2、3、4(0x1000>>12)

那么+7又是什么意思?这个时候就必须了解一下 页目录项/页表项 的结构了。如下图所示,7 二进制位 0b0111 ,也就是对应的是低3位为1,第1位表示可读写、第2位表示用户/管理员 第3位表示页面级的写穿透,7也就是表示 当前页可读可写,管理员模式,写穿透等等。

也就是说 $pg0+7,$pg0 表示的是页表的地址,7表示的是页面的属性。

那么知道了怎么存的,给你一个地址,是怎么转换的呢?

在没有分页机制之前,我们从逻辑地址经过分段机制之后,就变成了物理地址;现在呢,多了分页机制后,还需要在经过一次转换才能是真正的物理地址。

graph TD
	classDef default fill:#fca104;
	A[逻辑地址]--分段机制-->B["线性地址"]
	B-->C{"是否开启分页"}
	C-->|是<br>分页机制转换| D1["物理地址"]
	C-->|否<br>直接就是物理地址| D2["物理地址"]
	
	

那么是怎么转换的呢?是通过一个叫MMU的硬件来实现的,也叫内存管理单元,由它将线性地址转为物理地址。

首先线性地址为32位,会被拆分成 高10位:中间10位:低12位这样的格式。这种页表方案叫二级页表,第一级为页目录表PDE,第二级叫页表PTE

高12位负责在页目录表中找到一个页目录项,

中间10位在对应的页表中找到一个页表项,

低12位表示偏移地址。

比如:15M的一个线性地址,二进制表示为 0000000011_0100000000_000000000000,

也就是页目录表中的第4个页目录项3页表3中的第256项,再加上偏移地址0,刚好为以15M起始的位置的大小为4K的空间

edi值设置为$pg3+4092,也即是0x5ffceax值为0xfff007

这6行负责填满4个页表中所有项的内容,一共有 4(页表)*1024(项/页表) = 4096(项)

每项表示一个4kb空间,则一共可以表示 4096*4kb = 16Mb。

一个页表中最后一项地址为 1023 * 4 = 4092,所以 $pg3+4092 表示页表3中的最后一项的地址

每项的内容是:当前项所映射的物理内存地址 + 该页的标志(此处均为7)

$pg3+4092 中表示最后一项,也即最后一个4kb空间,最后一个4kb空间起始地址为16Mb-4096,在加上页属性7,也就是$0xfff007

1
2
3
4
5
6
	movl $pg3+4092,%edi
movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */
std
1: stosl /* fill pages backwards - more efficient :-) */
subl $0x1000,%eax
jge 1b

此后,分页就结束了,这个时候只需要开启分页机制的开关就可以了。开关在cr0的最高位,将其置为1,开启了分页机制。一个ret直接返回

设置pg_dir,到cr3,将cr0的最高位置为1。

1
2
3
4
5
6
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 */

如何进入main

1
2
3
4
5
6
7
8
9
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_paging
L6:
jmp L6

在after_page_tables 中连着5个push,将数据依次压入栈,最后的结构如下

注意setup_paging的最后一条命令是ret,ret被叫做返回指令,返回指令的话肯定得有返回的地址,计算机会机械的把栈顶的元素当作返回地址。在具体的说,就是将esp寄存器的值给到eip中,而cs:eip就是CPU要执行的下一条指令的地址。而栈顶此时存放的为main(start)函数的地址,所以ret后就会跳转到main(start)中了。其中L6会作为main的返回值,但main(start)是不会返回的,其它三个值本意是作为main(start)函数的参数,但没有用到。

关于 ret 指令,其实 Intel CPU 是配合 call 设计的,有关 call 和 ret 指令,即调用和返回指令,可以参考 Intel 手册:

Intel 1 Chapter 6.4 CALLING PROCEDURES USING CALL AND RET

到此,汇编部分就结束了。主要有如下操作,

整个内存分布如下:

参考

  1. 调试 Linux 最早期的代码
  2. Ubuntu20.04编译安装qemu
  3. qemu运行虚拟机无反应,只输出一行提示信息:VNC server running on 127.0.0.1:5900
  4. 连接的时候指定-Ttext 和指定-Tmap.lds的区别
  5. Linux dd 命令
  6. BIOS int 13H中断介绍
  7. 汇编指令——用GDB调试汇编

linux0.11环境搭建与阅读
https://www.glj0.top/posts/ca3a0e2a/
作者
gong lj
发布于
2022年5月11日
更新于
2022年8月7日
许可协议