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 |
|
配置好后,打开发现只输出一句话,VNC Server runnning on 127.0.0.1:5900
这是因为没有支持SDL
(Simple DirectMedia Layer)
所以需要重新配置后在安装,
1 |
|
配置gdb环境
1 |
|
习惯使用pwndbg了,添加此插件。
代码分析
首先下载源码,并编译,没有问题那么就开始分析源码了。
下载源码
地址为 https://github.com/yuan-xy/Linux-0.11
不过也可以fork到自己的仓库中,方便后面更改后的上传。
1 |
|
代码汇编部分采用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 |
|
先看下Makefile
,通过编译脚本可得 链接器参数为 -Ttext 0
,另外三个bootsect
、setup
、head
模块也是分别编译。
补个图
当cpu上电后,bios会将第一个可启动的硬盘的前512字节数据,拷贝到0x7c00处。
什么叫可启动设备:只要第一个扇区的512字节的最后两字节分别为0x55、0xaa,那么其就是一个可启动设备。
前512字节是什么呢?bootsect
模块,换句话说,当cpu
上电后,bios
将bootsect
拷贝到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 |
|
由于不能直接给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:0x0
即0x7c00
)处开始,大小为256字
,即512字节
的数据拷贝到es:di
(0x9000:0x0
即0x90000
)处。
也就是cpu上电后,bios将第一个可启动设备的前512字节
先拷贝到0x7c00
处,接着跳转到0x7c00
处执行,然后又将该部分拷贝到0x90000
处
ljmp $INITSEG, $go
长跳转,直接跳转至0x9000:go
处。
接着执行go
处的代码。由于是长跳转,所以cs的值为$INITSEG
,也就是0x9000
。
所以此处代码也就是将ds
、es
、ss
设置为移动后代码所在的段处,并且将堆栈段 设置为0x9000:0
- 0x9000:0xff00
,即0x90000
- 0x9ff00
1 |
|
1 |
|
看一下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 驱动器00H
7FH 软盘;80HFFH 硬盘
也就是从 软盘驱动器0
的0柱面2扇区
开始,拷贝4扇区
到es:bx
,也就是0x9000:0x0200
即0x90200
拷贝的是什么东西呢?硬盘中1-5共4扇区的代码。
1 |
|
接着 因为AX>=0,跳转到ok_load_setup
执行。
1 |
|
这部分只看主要代码,其将剩下的从第6个扇区后面的x个扇区,加载到内存0x10000
处,简单来说,就是将system代码挪了个地。
接着一个长跳转 SETUPSEG
,将CS设置为0x9020
,EIP设置为0x0
也就是跳转到0x9020<<16 | 0
即0x90200
硬盘中数据是怎么分区的呢
通过Makefile
和tools/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
处执行。在分析的过程中,我们借助
gdb
,target remote :1234
,b *0x7c00 在0x7c00处加个断点
b *0x90200 在0x90200处加个断点
当跳转到 0x90200 处,通过命令
x/512b 0x90200
/x/256h 0x90200
处值,发现就是我们拷贝过去的第一个扇区(bootsect)
setup
setup.s负责从BIOS中获取系统数据,并将这些数据放到系统内存的合适地方。这段代码询问bios 有关内存/磁盘/其它参数,并将这些参数存到一个“安全的”地方:0x90000-0x901FF。
1 |
|
跳转到相对于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 |
|
最终会通过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 |
|
看下面的部分,是不很熟悉movsw
,是一个数据传送指令,将一段数据从源地址传送到目的地址,详见指令。
1 |
|
这段代码,也就是将system 模块移动到新的位置,新位置起始为0。
与以下c代码相同。
1 |
|
重新规划后内存布局如下,
实模式与保护模式的第一个区别:物理地址计算方式不同
实模式下:物理地址为段寄存器中的地址<<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 |
|
这个图描述了如何从逻辑地址经过分段、分页之后得到物理地址的。
开启A20线
A20地址线是为了突破20位地址线的限制,变成32位可用,所以即使地址线有32位了,但是你如果不手动开启,还是会限制20位可用。现在的CPU位数都32位、64位,为了兼容以前的20位地址总线,便有了此选项。
1 |
|
接着是对可编程中断控制器 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 |
|
接着就是直接跳转到物理地址 0 处了。
1 |
|
首先将sel_cs0
置为0x0008
,由于是保护模式,所以ds
为段选择子,其值为0x0008
,0b0000_0000_0000_1000
,描述符索引为0b0000_0000_0000_1
,也就是1
,前面我们设定的段描述符,第0个为空,第1个为代码段,第2个为数据段,也就是代码段,其基地址为0,所以此处也就是跳转到0地址处。
head
由最外层的Makefile可得,system由boot/head.o
、init/main.o
及其它组成,并且system在bootsect中被搬运至0处。
所以在setup中跳转到0处,也就是跳转到head中了。
1 |
|
看下代码,刚开始有个标号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 |
|
也就是将高位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 |
|
设置256个中断描述符,每个中断描述符中的中断处理都指向ignore_int
,这是个默认的中断处理程序,后面慢慢会被具体的中断程序覆盖。
setup_gdt也是如此,设置后的gdt如下:
1 |
|
这部分代码有两个精彩的地方,一个是分页机制、另一个就是怎么进入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 |
|
接着往下走,几个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
,也即是0x5ffc
,eax
值为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 |
|
此后,分页就结束了,这个时候只需要开启分页机制的开关就可以了。开关在cr0的最高位,将其置为1,开启了分页机制。一个ret直接返回
设置pg_dir,到cr3,将cr0的最高位置为1。
1 |
|
如何进入main
1 |
|
在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
到此,汇编部分就结束了。主要有如下操作,
整个内存分布如下: