你管这破玩意儿叫操作系统源码(九)

本文为学习操作系统源码 (低并发编程)所作笔记,仅供学习参考,不做任何商业用途,若有侵权,请联系删除。

第三十五回 | 扒开execve的皮

第35回 | 扒开 execve 的皮 (qq.com)

我们先打开 execve,开一下它的调用链。

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
static char * argv_rc[] = { "/bin/sh", NULL };
static char * envp_rc[] = { "HOME=/", NULL };

// 调用方
execve("/bin/sh",argv_rc,envp_rc);

// 宏定义
_syscall3(int,execve,const char *,file,char **,argv,char **,envp)

// 通过系统调用进入到这里
EIP = 0x1C
_sys_execve:
lea EIP(%esp),%eax
pushl %eax
call _do_execve
addl $4,%esp
ret

// 最终执行的函数
int do_execve(
unsigned long * eip,
long tmp,
char * filename,
char ** argv,
char ** envp) {
...
}

我们在 第25回 | 通过 fork 看一次系统调用 已经详细分析了整个调用链中的栈以及参数传递的过程。

eip 调用方触发系统调用时由 CPU 压入栈空间中的 eip 的指针 。

tmp 是一个无用的占位参数。

filename 是 "/bin/sh"

argv 是 { "/bin/sh", NULL }

envp 是 { "HOME=/", NULL }

好了,接下来我们看看整个 do_execve 函数,它非常非常长!我先把整个结构列出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int do_execve(...) {
// 检查文件类型和权限等
...
// 读取文件的第一块数据到缓冲区
...
// 如果是脚本文件,走这里
if (脚本文件判断逻辑) {
...
}
// 如果是可执行文件,走这里
// 一堆校验可执行文件是否能执行的判断
...
// 进程管理结构的调整
...
// 释放进程占有的页面
...
// 调整线性地址空间、参数列表、堆栈地址等
...
// 设置 eip 和 esp,这里是 execve 变身大法的关键!
eip[0] = ex.a_entry;
eip[3] = p;
return 0;
...
}

整理起来的步骤就是。

1 检查文件类型和权限等

2 读取文件的第一块数据到缓冲区

3 脚本文件与可执行文件的判断

4 校验可执行文件是否能执行

5 进程管理结构的调整

6 释放进程占有的页面

7 调整线性地址空间、参数列表、堆栈地址等

8 设置 eip 和 esp,完成摇身一变

如果去掉一些逻辑校验和判断,那核心逻辑就是加载文件调整内存开始执行三个步骤,由于这些部分的内容已经非常复杂了,所以我们就去掉那些逻辑校验的部分,直接挑主干逻辑进行讲解,以便带大家认清 execve 的本质。

读取文件开头 1KB 的数据

先是根据文件名,找到并读取文件里的内容

1
2
3
4
5
6
7
8
9
// exec.c
int do_execve(...) {
...
// 根据文件名 /bin/sh 获取 inode
struct m_inode * inode = namei(filename);
// 根据 inode 读取文件第一块数据(1024KB)
struct buffer_head * bh = bread(inode->i_dev,inode->i_zone[0]);
...
}

很简单,就是读取了文件(/bin/sh)第一个块,也就是 1KB 的数据,在 第32回 | 加载根文件系统 里说过文件系统的结构,所以代码里 inode -> i_zone[0] 就刚好是文件开头的 1KB 数据。

解析这 1KB 的数据为 exec 结构

接下来的工作就是解析它,本质上就是按照指定的数据结构来解读罢了。

1
2
3
4
5
6
// exec.c
int do_execve(...) {
...
struct exec ex = *((struct exec *) bh->b_data);
...
}

先从刚刚读取文件返回的缓冲头指针中取出数据部分 bh -> data,也就是文件前 1024 个字节,此时还是一段读不懂的二进制数据。

然后按照 exec 这个结构体对其进行解析,它便有了生命。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct exec {
// 魔数
unsigned long a_magic;
// 代码区长度
unsigned a_text;
// 数据区长度
unsigned a_data;
// 未初始化数据区长度
unsigned a_bss;
// 符号表长度
unsigned a_syms;
// 执行开始地址
unsigned a_entry;
// 代码重定位信息长度
unsigned a_trsize;
// 数据重定位信息长度
unsigned a_drsize;
};

上面的代码就是 exec 结构体,这是 a.out 格式文件的头部结构,现在的 Linux 已经弃用了这种古老的格式,改用 ELF 格式了,但大体的思想是一致的。

判断是脚本文件还是可执行文件

我们写一个 Linux 脚本文件的时候,通常可以看到前面有这么一坨东西。

1
2
#!/bin/sh
#!/usr/bin/python

你有没有想过为什么我们通常可以直接执行这样的文件?其实逻辑就在下面这个代码里。

1
2
3
4
5
6
7
8
9
// exec.c
int do_execve(...) {
...
if ((bh->b_data[0] == '#') && (bh->b_data[1] == '!') {
...
}
brelse(bh);
...
}

可以看到,很简单粗暴地判断前面两个字符是不是 #!,如果是的话,就走脚本文件的执行逻辑。

当然,我们现在的 /bin/sh 是个可执行的二进制文件,不符合这样的条件,所以这个 if 语句里面的内容我们也可以不看了,直接看外面,执行可执行二进制文件的逻辑。

第一步就是 brelse 释放这个缓冲块,因为已经把这个缓冲块内容解析成 exec 结构保存到我们程序的栈空间里了,那么这个缓冲块就可以释放,用于其他读取磁盘时的缓冲区。

准备参数空间

我们执行 /bin/sh 时,还给它传了 argc 和 envp 参数,就是通过下面这一系列代码来实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define PAGE_SIZE 4096
#define MAX_ARG_PAGES 32

// exec.c
int do_execve(...) {
...
// p = 0x1FFFC = 128K - 4
unsigned long p = PAGE_SIZE * MAX_ARG_PAGES - 4;
...
// p = 0x1FFF5 = 128K - 4 - 7
p = copy_strings(envc,envp,page,p,0);
// p = 0x1FFED = 128K - 4 - 7 - 8
p = copy_strings(argc,argv,page,p,0);
...
// p = 0x3FFFFED = 64M - 4 - 7 - 8
p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;
// p = 0x3FFFFD0
p = (unsigned long) create_tables((char *)p,argc,envc);
...
// 设置栈指针
eip[3] = p;
}

准备参数空间的过程,同时也伴随着一个表示地址的 unsigned long p 的计算轨迹。

开头一行计算出的 p 值为

p = 4096 * 32 - 4 = 0x20000 - 4 = 128K - 4

为什么是这个数呢?整个这块讲完你就会知道,这表示参数表,每个进程的参数表大小为 128K,在每个进程地址空间的最末端

我们说过,每个进程通过不同的局部描述符在线性地址空间中瓜分出不同的空间,一个进程占 64M,我们单独把这部分表达出来。

参数表为 128K,就表示每个进程的线性地址空间的末端 128K,是为参数表保留的,目前这个 p 就指向了参数表的开始处(偏移 4 字节)。

参数表为 128K,就表示每个进程的线性地址空间的末端 128K,是为参数表保留的,目前这个 p 就指向了参数表的开始处(偏移 4 字节)。

接下来两个 copy_strings 就是往这个参数表里面存放信息,不过具体存放的只是字符串常量值的信息,随后他们将被引用,有点像 Java 里 class 文件的字符串常量池思想。

接下来两个 copy_strings 就是往这个参数表里面存放信息,不过具体存放的只是字符串常量值的信息,随后他们将被引用,有点像 Java 里 class 文件的字符串常量池思想。

1
2
3
4
5
6
7
8
9
// exec.c
int do_execve(...) {
...
// p = 0x1FFF5 = 128K - 4 - 7
p = copy_strings(envc,envp,page,p,0);
// p = 0x1FFED = 128K - 4 - 7 - 8
p = copy_strings(argc,argv,page,p,0);
...
}

具体说来,envp 表示字符串参数 "HOME=/"argv 表示字符串参数 "/bin/sh",两个 copy 就表示把这个字符串参数往参数表里存,相应地指针 p 也往下移动(共移动了 7 + 8 = 15 个字节),和压栈的效果是一样的。

当然,这个只是示意图,实际上这些字符串都是紧挨着的,我们通过 debug 查看参数表位置处的内存便可以看到真正存放的方式。

可以看到,两个字符串乖乖地被安排在了参数表内存处,且参数与参数之间用 00 也就是 NULL 来分隔。

接下来是更新局部描述符

1
2
3
4
5
6
7
8
9
10
#define PAGE_SIZE 4096
#define MAX_ARG_PAGES 32

// exec.c
int do_execve(...) {
...
// p = 0x3FFFFED = 64M - 4 - 7 - 8
p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;
...
}

很简单,就是根据 ex.a_text 修改局部描述符中的代码段限长 code_limit,其他没动。

由于这个函数返回值是数据段限长,也就是 64M,所以最终的 p 值被调整为了以每个进程的线性地址空间视角下的地址偏移,大家可以仔细想想怎么算的。

接下来就是真正构造参数表的环节了。

1
2
3
4
5
6
7
8
9
10
#define PAGE_SIZE 4096
#define MAX_ARG_PAGES 32

// exec.c
int do_execve(...) {
...
// p = 0x3FFFFD0
p = (unsigned long) create_tables((char *)p,argc,envc);
...
}

刚刚仅仅是往参数表里面丢入了需要的字符串常量值信息,现在就需要真正把参数表构建起来。

我们展开 create_tables

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
/*
* create_tables() parses the env- and arg-strings in new user
* memory and creates the pointer tables from them, and puts their
* addresses on the "stack", returning the new stack pointer value.
*/
static unsigned long * create_tables(char * p,int argc,int envc) {
unsigned long *argv,*envp;
unsigned long * sp;

sp = (unsigned long *) (0xfffffffc & (unsigned long) p);
sp -= envc+1;
envp = sp;
sp -= argc+1;
argv = sp;
put_fs_long((unsigned long)envp,--sp);
put_fs_long((unsigned long)argv,--sp);
put_fs_long((unsigned long)argc,--sp);
while (argc-->0) {
put_fs_long((unsigned long) p,argv++);
while (get_fs_byte(p++)) /* nothing */ ;
}
put_fs_long(0,argv);
while (envc-->0) {
put_fs_long((unsigned long) p,envp++);
while (get_fs_byte(p++)) /* nothing */ ;
}
put_fs_long(0,envp);
return sp;
}

可能稍稍有点烧脑,不过如果你一行一行仔细分析,不难分析出就是把参数表空间变成了如下样子。

最后,将 sp 返回给 p,这个 p 将作为一个新的栈顶指针,给即将要完成替换的 /bin/sh 程序,也就是下面的代码。

1
2
3
4
5
6
// exec.c
int do_execve(...) {
...
// 设置栈指针
eip[3] = p;
}

为什么这样操作就可以达到更换栈顶指针的作用呢?那我们结合着更换代码指针 PC 来进行讲解。

设置eip和esp,完成摇身一变

下面这两行就是 execve 完成摇身一变的关键,解释了它为什么能做到变成一个新程序开始执行的关键密码。

1
2
3
4
5
6
7
// exec.c
int do_execve(unsigned long * eip, ...) {
...
eip[0] = ex.a_entry;
eip[3] = p;
...
}

什么叫一个新程序开始执行呢?

其实本质上就是,代码指针 eip 和栈指针 esp 指向了一个新的地方

代码指针 eip 决定了 CPU 将执行哪一段指令,栈指针 esp 决定了 CPU 压栈操作的位置,以及读取栈空间数据的位置,在高级语言视角下就是局部变量以及函数调用链的栈帧

所以这两行代码,第一行重新设置了代码指针 eip 的值,指向 /bin/sh 这个 a.out 格式文件的头结构 exec 中的 a_entry 字段,表示该程序的入口地址

第二行重新设置了栈指针 esp 的值,指向了我们经过一路计算得到的 p,也就是图中 sp 的值。将这个值作为新的栈顶十分合理。

eip 和 esp 都设置好了,那么程序摇身一变的工作,自然就结束了,非常简单。

至于为什么往 eip 的 0 和 3 索引位置处写入数据,就可以达到替换 eip 和 esp 的目的,那我们就得看看这个 eip 变量是怎么来的了。

计算机的世界没有魔法

还记得 execve 的调用链么?

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
static char * argv_rc[] = { "/bin/sh", NULL };
static char * envp_rc[] = { "HOME=/", NULL };

// 调用方
execve("/bin/sh",argv_rc,envp_rc);

// 宏定义
_syscall3(int,execve,const char *,file,char **,argv,char **,envp)

// 通过系统调用进入到这里
EIP = 0x1C
_sys_execve:
lea EIP(%esp),%eax
pushl %eax
call _do_execve
addl $4,%esp
ret

// exec.c
int do_execve(unsigned long * eip, ...) {
...
eip[0] = ex.a_entry;
eip[3] = p;
...
}

千万别忘了,我们这个 do_execve 函数,是通过一开始的 execve 函数触发了系统调用来到的这里。

系统调用是一种中断,前面说过,中断时 CPU 会给栈空间里压入一定的信息,这部分信息是死的,查手册可以查得到。

然后,进入中断以后,通过系统调用查表进入到 **_sys_execve** 这里。

1
2
3
4
5
6
7
EIP = 0x1C
_sys_execve:
lea EIP(%esp),%eax
pushl %eax
call _do_execve
addl $4,%esp
ret

看到没?在真正调用 do_execve 函数时,**_sys_execve** 这段代码偷偷地插入了一个小步骤,就是把当前栈顶指针 esp 偏移到 EIP 处的地址值给当做第一个参数 unsigned long * eip 传入进来了。

而偏移 EIP 处的位置,恰好就是中断时压入的 EIP 的值的位置,表示中断发生前的指令寄存器的值。

所以 eip[0] 就表示栈空间里的 EIP 位置,eip[3] 就表示栈空间里的 ESP 位置。

由于我们现在处于中断,所以中断返回后,也就是 do_execve 这个函数 return 之后,就会寻找中断返回前的这几个值(包括 eip 和 esp)进行恢复。

这里有疑惑的同学,看下我之前写的 认认真真的聊聊中断认认真真的聊聊"软"中断 这两篇文章,我认为把中断的原理彻底讲清楚了,不过其实就是读 CPU 手册罢了。

调式Linux最早期的代码

调试 Linux 最早期的代码 (qq.com)

第三十六回 | 缺页中断

第36回 | 缺页中断 (qq.com)

书接上回,上回书咱们说到,进程 2 通过 execve 函数,将自己摇身一变成为 /bin/sh 程序,也就是 shell 程序开始执行。

1
2
3
4
5
6
7
8
9
10
11
// main.c
void init(void) {
...
if (!(pid=fork())) {
close(0);
open("/etc/rc",O_RDONLY,0);
execve("/bin/sh",argv_rc,envp_rc);
_exit(2);
}
...
}

那么此时进程 2 就是 shell 程序了。

再进一步讲,相当于之前的进程 1 通过 fork + execve 这两个函数的组合,创建了一个新的进程去加载并执行了 shell 程序。

我们在 Linux 里执行一个程序,比如在命令行中 ./xxx,其内部实现逻辑都是 fork + execve 这个原理。

但有个问题是,我们仅仅将 /bin/sh 文件的头部加载到了内存,其他部分并没有进行加载,那我们是怎么执行到的 /bin/sh 的程序指令呢?

我们就带着这个问题,开始今天的探索。

跳转到一个不存在的地址会发生什么

/bin/sh 这个文件并不是 Linux 0.11 源码里的内容,Linux 0.11 只管按照 a.out 这种格式去解读它,跳转到 a.out 格式头部数据结构 exec.a_entry 所指向的内存地址去执行指令。

所以这个 a_entry 的值是多少,就完全取决于硬盘中 /bin/sh 这个文件是怎么构造的了,我们简单点,就假设它为 0,这表示随后的 CPU 将跳转到 0 地址处进行执行。

当然,这个 0 仅仅表示逻辑地址,既没有进行分段,也没有进行分页。

之前说过无数次了,Linux 0.11 的每个进程是通过不同的局部描述符在线性地址空间中瓜分出不同的空间,一个进程占 64M。

由于我们现在所处的代码是属于进程 2,所以逻辑地址 0 通过分段机制映射到线性地址空间,就是 0x8000000,表示 128M 位置处。

好,128M 这个线性地址,随后将会通过分页机制的映射转化为物理地址,这才定位到最终的真实物理内存。

可是,128M 这个线性地址并没有页表映射它,也就是因为上面我们说的,我们除了 /bin/sh 文件的头部加载到了内存外,其他部分并没有进行加载操作。

再准确点说,是 0x8000000 这个线性地址的访问,遇到了页表项的存在位 P 等于 0 的情况。

一旦遇到了这种情况,CPU 会触发一个中断:页错误(Page-Fault),这在 Intel 手册 Volume-3 Chapter 4.7 章节里给出了这个信息。

当然,Page-Fault 在很多情况都会触发,具体是因为什么情况触发的,CPU 会帮我们保存在中断的出错码 Error Code 里,这在随后的 Figure 4-12 中给出了详细的出错码说明。

当触发这个 Page-Fault 中断后,就会进入 Linux 0.11 源码中的 page_fault 方法,由于 Linux 0.11 的 page_fault 是汇编写的,很不直观,这里我选 Linux 1.0 的代码给大家看,逻辑是一样的。

1
2
3
4
5
6
7
8
void do_page_fault(..., unsigned long error_code) {
...
if (error_code & 1)
do_wp_page(error_code, address, current, user_esp);
else
do_no_page(error_code, address, current, user_esp);
...
}

根据 error_code 的不同,有不同的逻辑。

刚刚说了,这个中断是由于 0x8000000 这个线性地址的访问,遇到了页表项的存在位 P 等于 0 的情况,所以 error_code 的第 0 位就是 0,会走 do_no_page 逻辑。

之前在讲 第30回 | 番外篇 - 写时复制就这么几行代码 的时候,讲了 do_wp_page,这是在 P=1 时的逻辑,文章的结尾我说过,后面会把页表项的存在位 P 为 0 时触发的 do_no_page 逻辑讲给大家,这不就来了么。

do_wp_page 叫页写保护中断,do_no_page 叫缺页中断

好了,我们用了很大篇幅,说明白了跳转到一个 P=0 的地址会发生什么,接下来就是具体看 do_no_page 函数的逻辑咯。

缺页中断 do_no_page

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
// memory.c
// address 缺页产生的线性地址 0x8000000
void do_no_page(unsigned long error_code,unsigned long address) {
int nr[4];
unsigned long tmp;
unsigned long page;
int block,i;

address &= 0xfffff000;
tmp = address - current->start_code;
if (!current->executable || tmp >= current->end_data) {
get_empty_page(address);
return;
}
if (share_page(tmp))
return;
if (!(page = get_free_page()))
oom();
/* remember that 1 block is used for header */
block = 1 + tmp/BLOCK_SIZE;
for (i=0 ; i<4 ; block++,i++)
nr[i] = bmap(current->executable,block);
bread_page(page,current->executable->i_dev,nr);
i = tmp + 4096 - current->end_data;
tmp = page + 4096;
while (i-- > 0) {
tmp--;
*(char *)tmp = 0;
}
if (put_page(page,address))
return;
free_page(page);
oom();
}

我们仍然是去掉一些不重要的分支,假设跳转不会超过数据末端 end_data,也没有共享内存页面,申请空闲内存时也不会内存不足产生 oom 等,将程序简化如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// memory.c
// address 缺页产生的线性地址 0x8000000
void do_no_page(unsigned long address) {
// 线性地址的页面地址 0x8000000
address &= 0xfffff000;
// 计算相对于进程基址的偏移 0
unsigned long tmp = address - current->start_code;
// 寻找空闲的一页内存
unsigned long page = get_free_page();
// 计算这个地址在文件中的哪个数据块 1
int block = 1 + tmp/BLOCK_SIZE;
// 一个数据块 1024 字节,所以一页内存需要读 4 个数据块
int nr[4];
for (int i=0 ; i<4 ; block++,i++)
nr[i] = bmap(current->executable,block);
bread_page(page,current->executable->i_dev,nr);
...
// 完成页表的映射
put_page(page,address);
}

这就简单多了,我们还是一点点看。

好,那我们再往上看,我们之前是在进程 2 里执行了 execve 函数将程序替换成 /bin/sh,也就是 shell 程序。

1
2
3
4
5
6
7
8
9
10
11
// main.c
void init(void) {
...
if (!(pid=fork())) {
close(0);
open("/etc/rc",O_RDONLY,0);
execve("/bin/sh",argv_rc,envp_rc);
_exit(2);
}
...
}

execve 函数返回后,CPU 就跳转到 /bin/sh 程序的第一行开始执行,但由于跳转到的线性地址不存在,所以引发了今天我们讲的缺页中断,把硬盘里 /bin/sh 所需要的内容加载到了内存,此时缺页中断返回。

返回后,CPU 会再次尝试跳转到 0x8000000 这个线性地址,此时由于缺页中断的处理结果,使得该线性地址已有对应的页表进行映射,所以顺利地映射到了物理地址,也就是 /bin/sh 的代码部分(从硬盘加载过来的),那接下来就终于可以执行 /bin/sh 程序,也就是 shell 程序了。

那这个 shell 程序到底是啥呢?他的代码并不在 Linux 0.11 的源码里,所以我们的重点将不是分析它的源码,仅仅了解它的原理即可。

第三十七回 | shell程序跑起来了

第37回 | shell 程序跑起来了 (qq.com)

xv6 是一个非常非常经典且简单的操作系统,是由麻省理工学院为操作系统工程的课程开发的一个教学目的的操作系统,所以非常适合操作系统的学习。

而在它的源代码中,又恰好实现了一个简单的 shell 程序,所以阅读它的代码,对我们这个系列课程来说,简直再合适不过了。

但我仍然十分贪婪,即便是这么短的代码,我也帮你把一些多余的校验逻辑去掉,再去掉关于 cd 命令的特殊处理分支,来一个最干净的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
// xv6-public sh.c
int main(void) {
static char buf[100];
// 读取命令
while(getcmd(buf, sizeof(buf)) >= 0){
// 创建新进程
if(fork() == 0)
// 执行命令
runcmd(parsecmd(buf));
// 等待进程退出
wait();
}
}

看,shell 程序变得异常简单了!

总得来说,shell 程序就是个死循环,它永远不会自己退出,除非我们手动终止了这个 shell 进程。

在死循环里面,shell 就是不断读取(getcmd)我们用户输入的命令,创建一个新的进程(fork),在新进程里执行(runcmd)刚刚读取到的命令,最后等待(wait)进程退出,再次进入读取下一条命令的循环中。

我们之前说过 shell 就是不断 fork + execve 完成执行一个新程序的功能的,那 execve 在哪呢?

那我们就要看执行命令的 runcmd 代码了。

1
2
3
4
5
6
7
void runcmd(struct cmd *cmd) {
...
struct execcmd ecmd = (struct execcmd*)cmd;
...
exec(ecmd->argv[0], ecmd->argv);
...
}

这里我又省略了很多代码,比如遇到管道命令 PIPE,遇到命令集合 LIST 时的处理逻辑,我们仅仅看单纯执行一条命令的逻辑。

可以看到,就是简简单单调用了个 exec 函数,这个 exec 是 xv6 代码里的名字,在 Linux 0.11 里就是我们在 第35回 | execve 加载并执行 shell 程序 里讲的 execve 函数。

shell 执行一个我们所指定的程序,就和我们在 Linux 0.11 里通过 fork + execve 函数执行了 /bin/sh 程序是一个道理。

第三十八回 | 操作系统启动完毕

第38回 | 操作系统启动完毕! (qq.com)

第四部分完结

第四部分完结!操作系统启动完毕! (qq.com)


你管这破玩意儿叫操作系统源码(九)
https://www.spacezxy.top/2023/04/08/OperatingSystem/Operating-system-source-code-9/
作者
Xavier ZXY
发布于
2023年4月8日
许可协议