Linux0.12内核源码解读(6)-main.c
作者:小牛呼噜噜 | https://xiaoniuhululu.github.io
计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜」
大家好,我是呼噜噜,好久没有更新old linux了,上次我们讲到了Linux0.12内核的 head.s,内核正式完成从汇编到c程序的切换,本文我们一起来看看main.s究竟发生了什么?
main.c
在内核源代码的init/
目录下,我们知到main函数通常是C语言的入口,所以我们可以知晓这里的main是操作系统很重要的入口文件,main.c
文件不是很大,但却包括了内核初始化的所有工作,主要是完成操作系统各种硬件数据结构的初始化
内嵌汇编 为进程0做准备
1 | static inline _syscall0(int,fork) |
syscall()
是 unistd.h 中定义的内嵌宏代码,名称最后的0表示无参数,1表示1个参数
。以嵌入汇编的形式触发中断0x80,该中断又是所有系统调用的入口
上面这段代码, 其实就是 以内嵌宏代码的形式实现了fork,pause,setup和sync函数的调用
, 那为什么这里不直接使用C语言的函数调用?
我们知道C语言调用方法,一般是通过堆栈实现来实现的,每个进程(用户进程,特权层级3)对应一个调用栈结构(call stack)
但在linux中有3个非常特殊的进程:
- 进程0(也被称为
idle进程
,PID = 0),此时CPU在执行指令时所位于的特权层级=0;进程0它的前身是操作系统创建的第一个进程,也是唯一一个没有通过fork或者kernel_thread产生的进程 - 进程1(也被称为init进程,PID = 1),操作系统中其他所有的用户进程都是由进程1直接或间接创建的
- 进程2(也被称为kthreadd进程,PID = 2),是我们内核的守护进程,可以管理和调度其他内核线程
它们之间的关系是,进程0负责创建进程1,进程1创建进程2,由于从内核空间fork的进程,并没有写时复制机制( Copy on write ),在创建的时候就直接分配栈空间。
所以进程1创建的时候,是直接复制了进程0的用户栈空间,也就是说进程1和进程0共用一个用户栈空间
这样为了避免进程0
弄乱栈空间,所以进程0
不能操作栈空间,这也意味着进程0
不能直接调用函数,只能触发中断0x80来实现函数调用
但是需要注意的是,main.c程序从开头执行到现在,并不是进程0,所以其实并不用担心进程0
会弄乱栈的这个问题,可以随意调用函数。直到CPU走到main.c程序的164行,执行完move_to_user_mode()
,利用中断返回指令才启动了进程0
,开始执行任务,之后用嵌入汇编的形式实现的fork,pause,setup和sync函数调用
才真正地发挥作用
我们这里先做简单介绍,等后面源码中遇到,再详细讲
硬件参数的复制与保存
我们先跳过main.c
的头文件,等后面用到的时候再讲,直接从main函数开始走起:
1 |
|
我们知道C语言中,main函数的三个参数为int argc,char*argv[],char*envp[]
,但是此处并没有使用这些参数,所以此处的main只保留传统main形式,main函数就是去完成内核初始化的所有工作
CON_ROWS、CON_COLS、ORIG_ROOT_DEV、ORIG_SWAP_DEV、DRIVE_INFO
这些宏都是setup.s程序读取并保存的参数
大家还记得setup.s获取了哪些参数?并把它们放到了哪里?
我们简单回顾一下,当Setup.S依次获取各个硬件参数后,从内存地址 0x90000
处开始存放这些信息
最终保留的参数在内存上的分布图如下:
比如内存中地址0x901FC
处就是存放的是根文件系统设备号
,由于内核代码是从物理内存零地址处开始存放的,这些线性地址正好也是对应的物理地址
而*(unsigned short *)0x901FC
就是将指定的线性地址强行转换为给定数据类型的指针,并获取指针所指内容
逻辑地址、线性地址、物理地址 分页推算
这里笔者还是有一连串的小疑惑,head.s已经开启分页了,0x901FC
不应该是逻辑地址嘛?不进行页部件转换的吗?
但呼噜噜debug反汇编后,发现确实能取出内存0x901FC
处的数据,这不得较真一下,看看其中究竟发生了什么?
1 | 0x66d1 <main+9> movzx eax,WORD PTR ds:0x901fc │ |
我们根据下面的原理图,来手动算算ds:0x901fc
,开启分页后,实际物理地址是多少?
- 逻辑地址转化成线性地址
通过debug打印信息,我们知晓ds=0x10
,在保护模式下,ds存放的不再是寻址段的基地址,而是一个一个”描述符表表索引”,称为段选择子(也叫段选择符),ds=0x10=0b 0000 0000 0001 0000
,
我们根据段选择子后3位,知晓0特权级, 描述符是全局描述符;接着取段选择子前13位,来算出对应的全局描述符表项的索引=0b 0000 0000 0001 0 = 0b10 =2(十进制)
我们还需要知道GDTR
来找到对应的全局描述符GDT,由于gdb无法显示GDTR,我们无法直接知道GDTR的地址,但还记得Setup.S和head.s依次设置了GDT,其实我们可以知晓保护模式下,GDT中所以段基地址都为0x0
现在我们就能得到线性地址=段基地址:偏移地址=0x0 + 0x901fc= 0x901fc
,所以上面说0x901fc
是线性地址没毛病
- 开启分页后, 线性地址到物理地址的转换
先将线性地址0x901fc
转换成32位2进制:0b 0000 0000 00 00 1001 0000 0001 1111 1100
,这步很重要,等待被使用
前10位0b 0000 0000 00 = 0x0 =0(十进制)
,由于CR3=0,所以页目录表的第0项地址=0x0 + 0x0*4 = 0x0
为什么要乘以4,是一个页的大小为4KB,有1024项,那么一项的大小为4B
然后我们查看一下内存0x0地址处的值:0x0000 1027
1 | (gdb) x /20xh 0x0 # 20表示内存单元的数量,x是16进制,h是双字显示 |
按照页目录和页表的结构,算出对应的页表所在的地址 =0x0000 1027 & 0xfffff000 = 0x1000
又因为中10位00 1001 0000 = 0x90
,那么页表项的地址=0x1000 + 0x90 * 4=0x1240
我们再通过gdb来查看内存0x1240处
的值为0x 0009 0027
1 | (gdb) x /20xh 0x1240 |
同理我们可以算出,页地址=0x0009 0027 & 0xfffff000 = 0x 9000
别忘了,后12位0001 1111 1100 = 0x1FC
手动算了大半天,最终我们得出实际物理地址= 0x 9000 + 0x1FC = 0x901fc
,也就是线性地址0x901fc
依此类推,我们可以发现 开启分页以后,低端内存1M以内,线性地址其实就是实际物理地址,二者一一对应
我们继续回到linux源码处:
1 | sprintf(term, "TERM=con%dx%d", CON_COLS, CON_ROWS);//CON_COLS, CON_ROWS 即选定的控制台屏幕行、列数 |
这里的sprintf
函数是用于产生格式化信息,并输出到指定缓冲区str
计算内存边界值,划分内存
1 | /* |
其中需要注意的是,为什么我们一直说linux0.12
只管16M
的内存,这里的源码中,就明确写了,如果内存超过16M,则按16M计算
上面这段源码就是计算内存边界值,比如主内存区的开始地址main_memory_start
、系统所拥有的内存容量memory_end
和作为高速缓冲区内存的末端地址buffer_memory_end
Linux0.12按功能划分的内存区域(本文都假设内存为最大值16M):
如果还定义了虚拟盘(RAMDISK),会在主内存中初始化虚拟盘,上图的高速缓冲区是用于磁盘等块设备临时存放数据的地方,当一个进程需要读取块设备中的数据时,系统会首先把数据读到高速缓冲区中;当有数据需要写到块设备上时,系统也是先将数据放到高速缓冲区中,然后由块设备驱动程序写到相应的设备上;另外缓冲区中还有部分需留给显示卡显存及其BIOS占用
内核初始化程序流程
我们接着阅读源码,发现下面是一系列的操作系统初始化方法:
1 | mem_init(main_memory_start,memory_end);// 主内存区初始化 |
这些方法大致是依此进行:主存初始化,陷阱门初始化,块设备初始化,字符设备初始化,tty初始化,时间初始化,调度初始化(加载进程0 的 tr,ldtr),缓冲管理初始化,硬盘初始化,软盘初始化,待所有初始化工作完成后,就开启中断,并切换到进程0
中运行,永远不会退出!
最后当内核基本完成所有设置工作,会去通过进程0
,新建进程1
,运行 shell程序并显示命令行提示,至此Linux系统正常运行…
这些方法大概的作用,我们先简单了解一下,后面文章我们会挨个去详细解读
men_init()主内存区的初始化
本文先介绍一个men_init()
,主要功能是初始化主内存区,和上面计算内存边界值有关,所以我们这边连起来讲
这个函数其实就是初始化mem_map
这个数组,参数start_mem是可用作页面分配的主内存区起始地址,end_mem是实际物理内存最大地址
1 | unsigned long HIGH_MEMORY = 0; //全局变量,存放实际物理内存最高端地址。 |
需要注意一下,mem_map
数组的范围是1M~16M
是根据PAGING_PAGES
得出来的,而PAGING_PAGES
它是定义在mm.h
中的:
1 | //除内核占用的那1M内存,其他的内存都会被分页,总共15M |
我们可以算出 PAGING_PAGES= (16M -1M) /4K = 3840
项
通过这部分源码的解析,我们可以发现mem_map
,被叫做页面映射数组,记录主内存(1M~16M),每个字节描述一个内存页的占用状态(即占用次数),比如哪些内存页被占用了,哪些内存页空闲,从而对内存分页进行管理
我们是不是有点疑惑,比如引入mem_map
是如何内存分页进行管理的?或者说为啥要引入mem_map
?
其实这一切,得追溯到,早期操作系统是不区分内核空间和用户空间的,导致应用程序能访问任意内存空间,导致整个操作系统的数据都可以被随意地删改,缺乏安全性
所以Intel CPU 进行了分级,Linux只用了0内核态
和3用户态
2个级别。实现了当进程运行在内核态,可以访问任意内存;如果进程运行在用户态,只能访问用户空间,更不能访问内存内核区
这样内核为了更方便地管理所有物理内存页的分配,将用户空间的内存区域(这里是缓冲区+虚拟盘+主内存)映射到内核空间的mem_map
,映射成功后,用户对这段内存区域的修改就可以直接反映到内核空间,也就是说mem_map
的占用状态会及时地发生改变。
这样确实进一步提升用户空间和内核空间的数据传输效率。这也是mem_map
没什么不对1M以内的内存进行管理的原因
mem_map
的初始化过程,可以分为3步:
- 计算非内核空间内存所需要的页面数
PAGING_PAGES=3840
- 然后将主内存(1M~16M),包括高速缓冲区域以及虚拟盘区域(如果有),全部置成
USED=100
- 将主内存区域的内存页清零
最终初始化完成后的mem_map
,如下图所示:
men_init()
讲解到这里就结束了,后续我们将继续顺着main.c
的内核初始化程序流程,将相关方法依此解读,最后再回到main.c
,可以发现main.c
文件是我们linux0.12
内核源码探索的转折点,后续内容会有难度,但更精彩,我们下期再见~~
参考资料:
https://elixir.bootlin.com/linux/0.12/source/boot/head.s
英特尔® 64 位和 IA-32 架构开发人员手册:卷 3A-英特尔®
《Linux内核完全注释5.0》
全文完,感谢您的阅读,如果我的文章对你有所帮助的话,还请点个免费的赞,你的支持会激励我输出更高质量的文章,感谢!
计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜」,我们下期再见!