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
2
3
4
static inline _syscall0(int,fork)
static inline _syscall0(int,pause)
static inline _syscall1(int,setup,void *,BIOS)
static inline _syscall0(int,sync)

syscall()是 unistd.h 中定义的内嵌宏代码,名称最后的0表示无参数,1表示1个参数。以嵌入汇编的形式触发中断0x80,该中断又是所有系统调用的入口

上面这段代码, 其实就是 以内嵌宏代码的形式实现了fork,pause,setup和sync函数的调用, 那为什么这里不直接使用C语言的函数调用?

我们知道C语言调用方法,一般是通过堆栈实现来实现的,每个进程(用户进程,特权层级3)对应一个调用栈结构(call stack)

但在linux中有3个非常特殊的进程:

  1. 进程0(也被称为idle进程,PID = 0),此时CPU在执行指令时所位于的特权层级=0进程0它的前身是操作系统创建的第一个进程,也是唯一一个没有通过fork或者kernel_thread产生的进程
  2. 进程1(也被称为init进程,PID = 1),操作系统中其他所有的用户进程都是由进程1直接或间接创建的
  3. 进程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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define CON_ROWS ((*(unsigned short *)0x9000e) & 0xff) // 选定的控制台屏幕行、列数
#define CON_COLS (((*(unsigned short *)0x9000e) & 0xff00) >> 8)

#define DRIVE_INFO (*(struct drive_info *)0x90080) // 硬盘参数表 32 字节内容

#define ORIG_ROOT_DEV (*(unsigned short *)0x901FC)//根文件系统设备号
#define ORIG_SWAP_DEV (*(unsigned short *)0x901FA)//交换文件设备号


---

void main(void)
{
ROOT_DEV = ORIG_ROOT_DEV;//复制并保存根文件系统设备号
SWAP_DEV = ORIG_SWAP_DEV;//复制并保存交换文件设备号
sprintf(term, "TERM=con%dx%d", CON_COLS, CON_ROWS);
envp[1] = term; //设置初始 init 进程中执行 etc/rc 文件和 shell 程序使用的环境变量
envp_rc[1] = term;
drive_info = DRIVE_INFO; // 复制并内存 0x90080 处的硬盘参数表。

...


我们知道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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
0x66d1 <main+9>         movzx  eax,WORD PTR ds:0x901fc
0x66d8 <main+16> mov ds:0x1fc6c,eax

---

(gdb) P ROOT_DEV #打印
$1 = 769

---
(gdb) i register # 当前查看各个寄存器的值
eax 0x301 769
...
cs 0x8 8
ss 0x10 16
ds 0x10 16 #ds寄存器的值
es 0x10 16
fs 0x10 16
gs 0x10 16

我们根据下面的原理图,来手动算算ds:0x901fc,开启分页后,实际物理地址是多少?

  1. 逻辑地址转化成线性地址

通过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是线性地址没毛病

  1. 开启分页后, 线性地址到物理地址的转换

先将线性地址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
2
3
4
5
(gdb) x /20xh 0x0  # 20表示内存单元的数量,x是16进制,h是双字显示
0x0 <startup_32>: 0x1027 0x0000 0x2007 0x0000 0x3007 0x0000 0x4007 0x0000
0x10 <startup_32+16>: 0x0000 0x0000 0x0000 0x0000 0x0000 0x0000 0x0000 0x0000
0x20 <startup_32+32>: 0x0000 0x0000 0x0000 0x0000

按照页目录和页表的结构,算出对应的页表所在的地址 =0x0000 1027 & 0xfffff000 = 0x1000

又因为中10位00 1001 0000 = 0x90,那么页表项的地址=0x1000 + 0x90 * 4=0x1240

我们再通过gdb来查看内存0x1240处的值为0x 0009 0027

1
2
3
4
(gdb) x /20xh 0x1240
0x1240 <pg0+576>: 0x0027 0x0009 0x1007 0x0009 0x2007 0x0009 0x3007 0x0009
0x1250 <pg0+592>: 0x4007 0x0009 0x5007 0x0009 0x6007 0x0009 0x7007 0x0009
0x1260 <pg0+608>: 0x8007 0x0009 0x9007 0x0009

同理我们可以算出,页地址=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
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
/*
* This is set up by the setup-routine at boot-time
*/
#define EXT_MEM_K (*(unsigned short *)0x90002) //扩展内存数

---

//静态变量
static long memory_end = 0; // 机器具有的物理内存容量(字节数)。
static long buffer_memory_end = 0; // 高速缓冲区末端地址。
static long main_memory_start = 0; // 主内存(将用于分页)开始的位置。


// 内存大小=1Mb + 扩展内存(k)*1024 字节。
memory_end = (1<<20) + (EXT_MEM_K<<10);//1左移20位 = 1MB;
//EXT_MEM_K存储的是系统从1MB开始的扩展内存,由于单位是KB,所以和以字节为单位的1MB相加时需要左移10位

memory_end &= 0xfffff000; // 忽略不到 4Kb(1 页)的内存数。
if (memory_end > 16*1024*1024) // 如果内存量超过 16Mb,则按 16Mb 计。因为linux0.12只管内存16M
memory_end = 16*1024*1024;
if (memory_end > 12*1024*1024) //如果内存>12Mb,则设置缓冲区末端=4Mb
buffer_memory_end = 4*1024*1024;
else if (memory_end > 6*1024*1024) // 如果内存>6Mb,则设置缓冲区末端=2Mb
buffer_memory_end = 2*1024*1024;
else
buffer_memory_end = 1*1024*1024; //否则则设置缓冲区末端=1Mb
main_memory_start = buffer_memory_end; // 主内存起始位置 = 缓冲区末端。
#ifdef RAMDISK //如果配置了RAMDISK,就则初始化虚拟盘,主内存适当减少
main_memory_start += rd_init(main_memory_start, RAMDISK*1024);
#endif

其中需要注意的是,为什么我们一直说linux0.12只管16M的内存,这里的源码中,就明确写了,如果内存超过16M,则按16M计算

上面这段源码就是计算内存边界值,比如主内存区的开始地址main_memory_start系统所拥有的内存容量memory_end和作为高速缓冲区内存的末端地址buffer_memory_end

Linux0.12按功能划分的内存区域(本文都假设内存为最大值16M):

如果还定义了虚拟盘(RAMDISK),会在主内存中初始化虚拟盘,上图的高速缓冲区是用于磁盘等块设备临时存放数据的地方,当一个进程需要读取块设备中的数据时,系统会首先把数据读到高速缓冲区中;当有数据需要写到块设备上时,系统也是先将数据放到高速缓冲区中,然后由块设备驱动程序写到相应的设备上;另外缓冲区中还有部分需留给显示卡显存及其BIOS占用

内核初始化程序流程

我们接着阅读源码,发现下面是一系列的操作系统初始化方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mem_init(main_memory_start,memory_end);// 主内存区初始化
trap_init(); // 陷阱门(硬件中断向量)初始化
blk_dev_init(); // 块设备初始化
chr_dev_init(); //字符设备初始化
tty_init(); // tty 初始化
time_init(); //设置开机启动时间
sched_init(); //调度程序初始化(加载进程0 的 tr,ldtr)
buffer_init(buffer_memory_end); // 缓冲管理初始化,建内存链表等
hd_init(); // 硬盘初始化
floppy_init(); //软驱初始化
sti(); //开启中断
move_to_user_mode(); //移到用户模式下执行。
if (!fork()) { //永远不会退出,如果退出就死机了。
init(); //在新建子进程(进程 1)中执行,init() 会启动一个 shell
}
for(;;)//死循环
__asm__("int $0x80"::"a" (__NR_pause):"ax");//执行系统调用 pause()

这些方法大致是依此进行:主存初始化,陷阱门初始化,块设备初始化,字符设备初始化,tty初始化,时间初始化,调度初始化(加载进程0 的 tr,ldtr),缓冲管理初始化,硬盘初始化,软盘初始化,待所有初始化工作完成后,就开启中断,并切换到进程0中运行,永远不会退出!

最后当内核基本完成所有设置工作,会去通过进程0,新建进程1,运行 shell程序并显示命令行提示,至此Linux系统正常运行…

这些方法大概的作用,我们先简单了解一下,后面文章我们会挨个去详细解读

men_init()主内存区的初始化

本文先介绍一个men_init(),主要功能是初始化主内存区,和上面计算内存边界值有关,所以我们这边连起来讲

这个函数其实就是初始化mem_map这个数组,参数start_mem是可用作页面分配的主内存区起始地址,end_mem是实际物理内存最大地址

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
unsigned long HIGH_MEMORY = 0; //全局变量,存放实际物理内存最高端地址。

// 数组,内存映射字节
unsigned char mem_map [ PAGING_PAGES ] = {0,};

//内存初始化
// 参数start_mem是可用作页面分配的主内存区起始地址,end_mem是实际物理内存最大地址。
void mem_init(long start_mem, long end_mem)
{
int i;

HIGH_MEMORY = end_mem;//设置内存的高端
for (i=0 ; i<PAGING_PAGES ; i++)
mem_map[i] = USED;//将页面映射数组全部(1M~16M),置成USED=100,表示占有状态


//接着 计算可使用起始内存的页面号,map_NR()是将指定内存地址映射为页号 i
//#define MAP_NR(addr) (((addr)-LOW_MEM)>>12) 注意这里减去了低端内存边界值1M!
i = MAP_NR(start_mem);

end_mem -= start_mem; //算出主内存区的大小
end_mem >>= 12; //计算出 分页处理的物理内存页数
while (end_mem-->0)
mem_map[i++]=0; //将这些可用页面对应的页面映射数组项 设置为0,表示没有被占用
}

需要注意一下,mem_map数组的范围是1M~16M是根据PAGING_PAGES 得出来的,而PAGING_PAGES 它是定义在mm.h中的:

1
2
3
4
//除内核占用的那1M内存,其他的内存都会被分页,总共15M
#define PAGING_MEMORY (15*1024*1024)
//偏移12位后,恰好就是分页后的物理内存页数
#define PAGING_PAGES (PAGING_MEMORY>>12)

我们可以算出 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步:

  1. 计算非内核空间内存所需要的页面数PAGING_PAGES=3840
  2. 然后将主内存(1M~16M),包括高速缓冲区域以及虚拟盘区域(如果有),全部置成USED=100
  3. 将主内存区域的内存页清零

最终初始化完成后的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》


全文完,感谢您的阅读,如果我的文章对你有所帮助的话,还请点个免费的,你的支持会激励我输出更高质量的文章,感谢!

计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜」,我们下期再见!