6. 6.s081学习
6.s081
阅读文档: https://th0ar.gitbooks.io/xv6-chinese/content/content/chapter0.html
0. 操作系统接口
一个向其他运行中程序提供服务的特殊程序。每一个运行中程序(称之为进程)都拥有包含指令、数据、栈的内存空间 , 指令实现了程序的运算,数据是用于运算过程的变量,栈管理了程序的过程调用
进程通过系统调用使用内核服务,系统调用会进入内核,让内核执行服务然后返回。所以 进程总是在用户空间和内核空间之间交替运行。
内核使用了 CPU 的硬件保护机制来保证用户进程只能访问自己的内存空间,内核拥有实现保护机制所需的硬件权限(hardware privileges),而用户程序没有这些权限。当一个用户程序进行一次系统调用时,硬件会提升特权级并且开始执行一些内核中预定义的功能。
接口
内核提供的一系列系统调用就是用户程序可见的操作系统接口
系统调用 | 描述 |
---|---|
fork() | 创建进程 |
exit() | 结束当前进程 |
wait() | 等待子进程结束 |
kill(pid) | 结束 pid 所指进程 |
getpid() | 获得当前进程 pid |
sleep(n) | 睡眠 n 秒 |
exec(filename, *argv) | 加载并执行一个文件 |
sbrk(n) | 为进程内存空间增加 n 字节 |
open(filename, flags) | 打开文件,flags 指定读/写模式 |
read(fd, buf, n) | 从文件中读 n 个字节到 buf |
write(fd, buf, n) | 从 buf 中写 n 个字节到文件 |
close(fd) | 关闭打开的 fd |
dup(fd) | 复制 fd |
pipe( p) | 创建管道, 并把读和写的 fd 返回到p |
chdir(dirname) | 改变当前目录 |
mkdir(dirname) | 创建新的目录 |
mknod(name, major, minor) | 创建设备文件 |
fstat(fd) | 返回文件信息 |
link(f1, f2) | 给 f1 创建一个新名字(f2) |
unlink(filename) | 删除文件 |
shell 是一个普通的程序,它接受用户输入的命令并且执行它们,它也是传统 Unix 系统中最基本的用户界面。
进程和内存
xv6 进程的组成部分:
-
用户内存空间: (指令,数据,栈)
-
仅对内核可见的进程状态:
- xv6 是一个操作系统,而不是一个普通的程序。它包含了操作系统的核心组件,如进程管理、内存管理、文件系统、设备驱动等。
问题1:
为什么我可以在我的操作系统上同时运行xv6来进行学习呢?
回答1:
xv6 是作为一个 虚拟机 运行的,而不是直接在你的物理硬件上运行。以下是详细的解释:
因为使用了 QEMU: - QEMU:一个开源的虚拟化工具,可以模拟完整的计算机硬件。 类似于vm所以可以运行
xv6 分时特性 :
分时(Time-sharing) 是 xv6 的一个重要特性,允许多个进程共享 CPU 资源,通过快速切换进程来实现“同时运行”的效果。以下是 xv6 分时特性的具体实现:
- 轮转调度算法 : 时间片轮转调度
- 上下文切换
- 多进程并发
- 系统调用支持
- 中断机制
问题2:
进程是怎么产生的? 我们桌面上的一个软件点击打开的过程发生了什么
回答2:
(1) 进程创建
进程通常通过 系统调用 创建
(2) 进程执行
创建子进程后,通常会用 exec()
系统调用来加载一个新的程序到子进程的内存中,并开始执行。
(3) 进程终止
-
进程可以通过
exit()
系统调用主动终止,或者因为某些错误(如段错误)被操作系统强制终止。 -
父进程可以通过
wait()
系统调用等待子进程终止,并回收子进程的资源。
fork() 进程创建
一次调用会返回两次: 一次是 父进程 一次是 子进程
示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
pid_t pid;
int x = 1;
pid = fork();
if (pid == 0) {
// Child process
printf("child: x = %d\n", ++x);
exit(0);
} else if (pid > 0) {
// Parent process
printf("parent: x = %d\n", x);
exit(0);
} else {
// Error in fork
perror("fork failed");
exit(1);
}
return 0;
}
返回结果:
parent: x=0
child: x=2
在 shell 中 执行 ./hello :
shell看为父进程, 程序hello看为子进程,
父子进程都有各自的空间互不干扰
程序和进程的区别:
程序是代码 : 存储在磁盘上 在执行 是程序以段的形式 存在于在内存的地址空间
进程为正在执行中程序的具体实例
进程状态:
- 运行
- 暂停
- 终止
对比
对比fork 和 goroutine
特性 | fork() | Goroutine |
---|---|---|
创建单位 | 进程 | 轻量级线程 |
资源开销 | 高(复制整个进程) | 低(共享地址空间) |
通信方式 | 进程间通信(IPC)如管道、信号 | 通道(channel) |
调度 | 操作系统调度 | Go 运行时调度 |
使用场景 | 需要完全独立执行的任务 | 高并发任务 |
问题3:
如果有两个fork()函数在放在上下句 那么第一个fork出来的子进程是调用两次fork还是一次fork呢?
回答3:
-
第一个
fork()
:创建子进程 C1。 -
第二个
fork()
:-
原始进程创建子进程 C2。
-
子进程 C1 创建子进程 C3。
-
因此,第一个 fork()
创建的子进程 C1 会调用一次 fork()
,创建子进程 C3。
使用gcc编译程序:
gcc ~.c -o XXX
./XXX
理解这段程序的执行顺序
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
pid_t pid;
pid = fork(); // 创建子进程
if (pid > 0) {
// 父进程
printf("parent: waiting for child to finish\n");
pid_t child_pid = wait(NULL); // 等待子进程完成
printf("parent: child %d finished\n", child_pid);
} else if (pid == 0) {
// 子进程
printf("child: doing some work\n");
sleep(2); // 模拟子进程工作
printf("child: exiting\n");
exit(0); // 子进程退出
} else {
// fork 错误
printf("fork error\n");
}
return 0;
}
pid_t child_pid = wait(NULL); // 等待子进程完成 执行顺序:
- parent: waiting for child to finish
- 打印else里面的pid == 0也就是 子进程: child: doing some work child: exiting
- wait收到了子进程的退出
- parent: child %d finished
父子进程拥有不同的内存空间和寄存器,改变一个进程中的变量不会影响另一个进程。
父进程和子进程唯一的区别:
fork()返回的的进程号不一样 也就是返回两个值 父进程大于0 子进程小于0
exec()
加载并执行一个新的程序,替换当前进程的镜像,将指定的可执行文件(如 ./a.out
)加载到当前进程的地址空间中。
- 加载程序:将指定的可执行文件(如
./a.out
)加载到当前进程的地址空间中。 - 替换当前进程映像:当前进程的代码、数据、堆栈等都会被新的程序替换。
- 执行新程序:新程序开始执行,从其
main()
函数开始。 - 不返回:一旦调用
exec
,它会完全替换当前进程的执行代码,因此 不会返回到原来的代码(如果成功执行exec
)。
示例:
#include <stdio.h>
#include <unistd.h>
int main() {
char *args[] = {"/bin/ls", "-l", NULL}; // 执行的程序及参数
execv("/bin/ls", args); // 使用 execv 执行 ls 命令
return 0; // 如果 execv 执行成功,这行代码永远不会被执行
}
sleep 2000
执行这句指令 的父进程是bash终端
pstree -p
查看进程树
echo $$
是输出当前 shell 进程的 PID,这在一些进程管理和调试过程中很有用
结合 exec
exec sleep 200
这样使sleep进程替换了原来的bash进程 如果此时终止睡眠则终端直接关闭,因为sleep进程替换了bash进程
vim test.sh
shell脚本
#! /bin/bash
sleep 200
添加执行权限:
chmod +x test.sh
./test/sh
执行脚本相当于: 用当前交互的Shell运行了一个shell子进程
I/O 和文件描述符
文件描述符是一个整数,它代表了一个进程可以读写的被内核管理的对象
文件描述符指向的对象称为“文件”
可以理解为key value 一个整数或者几个整数 对应一个文件
文件描述符和进程相伴相生
每个进程都有一个从0开始的文件描述符空间
0 1 2
0: 标准输入: 键盘对应的存储空间
1: 标准输出 : 输出到屏幕所对应的存储空间
./test > test.txt
将 程序 结果输出 到 文本文件中
2: 标准出错 : 出错信息打印到屏幕
shell 保证在任何时候都有3个打开的文件描述符(8007),他们是控制台(console)的默认文件描述符
系统调用 read
和 write
从文件描述符所指的文件中读或者写 n 个字节 read(fd, buf, n)
从 fd
读最多 n 个字节 将它们拷贝到 buf
中,然后返回读出的字节数
write(fd, buf, n)
写 buf
中的 n 个字节到 fd
并且返回实际写出的字节数
cat
的本质实现:
将数据从标准输入复制到标准输出
if(fork() == 0) {
write(1, "hello ", 6);
exit();
} else {
wait();
write(1, "world\n", 6);
}
dup
复制一个已有的文件描述符,返回一个指向同一个输入/输出对象的新描述符
示例:
fd = dup(1);
write(1, "hello", 6);
write(fd, "world\n", 6);
dup()
主要用来复制文件描述符,方便使用多个描述符来操作同一个文件或 I/O 对象。它广泛用于 I/O 重定向、进程间共享文件描述符等场景。
关于做题之前的一些建议
- 了解对指针的运算
p[i] = *(p + i)
(int)p + 1,(int)(p + 1)
- gdb调试
make qemu-gdb
当 内核悬挂(例如,由于僵局)或无法进一步执行
使用GDB来找出其悬挂的位置 :
run 'make qemu-gdb' in one window , run gdb (riscv64-linux-gnu-gdb) in another windows
理解程序:
char*
strcpy(char *s, const char *t)
{
char *os;
os = s;
while((*s++ = *t++) != 0)
;
return os;
}
make grade
查看所有程序是否通过
./grade-lab-util sleep
单独 运行某个程序 or
make GRADEFLAGS=sleep grade
快速查找:
grep "关键字" 文件名
1. 下载qemu慢
cd /mnt/c/Users/30413/Downloads
在wsl 可以 转换到本地目录
cp qemu-7.2.16.tar.xz ~
直接就过来了
在wsl中下载qemu实在是太慢了
解压:
tar xvJf qemu-7.2.16.tar.xz -C /opt/qemu
tar tvJf qemu-7.2.16.tar.xz
成功运行了!
2.make grade
解决xv6无法 make grade
参考仓库:
https://github.com/heeyoung-choi/xv6-lab/blob/main/Makefile
强制删除在git中嵌套的仓库,以避免推送出现问题
git rm -f --cached xv6-public
sleep.c
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int
main(int argc, char *argv[])
{
if (argc < 2 || argc > 2 )
{
write(2,"only 1 arguments place write again\n",36);
exit(1);
}
sleep(atoi(argv[1]));
exit(0);
}
管道
pipe() 创建了一个文件 放在内存中 特殊的文件可供读写的一段存储空间
使用文件描述符去操作管道 int fd[2]:
fd的两个元素 fd[0]读取内容 fd[1]写内容 两个文件描述符操作一个管道
操作系统对于文件描述符的分配: 在所有的整数中取最小的整数 0 1 2 被使用过了 所以是 3和4分别为 fd[0] fd[1]
Pipe(fd)
示例:
#include<stdio.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
int main() {
int fd[2];
pipe (fd);
int pid = fork();
if (pid == 0) {
//char* receive = (char*) malloc (100*sizeof(char));
int *num = (int*) malloc (2*sizeof(int));
read(fd[0], num, 2*sizeof(int));
//printf("form father receive is :%s",receive);
printf("the receive is %d,%d\n",*num,*(num + 1));
}
else {
//char test[] = "hello world";
int num[4] = {8,4,2,1};
write(fd[1],num,4*sizeof(int));
}
return 0;
}
dup()
系统调用用于复制一个文件描述符。 会创建一个新的文件描述符,指向与原始文件描述符相同的文件或资源。
理解命令wc
示例程序:
int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if(fork() == 0) {
close(0);
dup(p[0]); //利用文件描述符性质,这个dup会占用最小整数0的文件描述符
close(p[0]);
close(p[1]);
exec("/bin/wc", argv);
} else {
write(p[1], "hello world\n", 12);
close(p[0]);
close(p[1]);
}
问题:
为什么要close(0)
- 当打开一个新文件或复制一个文件描述符时,系统会分配 最小的可用文件描述符。
例如:
- 如果文件描述符
0
被关闭,那么下一个可用的文件描述符就是0
。 - 如果文件描述符
0
已经被占用,系统会分配下一个可用的文件描述符(如3
、4
等)。
- 如果文件描述符
为什么不直接使用p[0]呢??
解答:
- 确保
dup(p[0])
复制到文件描述符0
-
如果不关闭标准输入,文件描述符
0
仍然指向默认的标准输入(通常是终端)。 -
调用
dup(p[0])
时,系统会分配一个可用的文件描述符(如3
),而不是文件描述符0
。 -
这样,
wc
命令仍然会从终端读取输入,而不是从管道读取数据。
wc
命令默认从 标准输入(文件描述符0
) 读取数据
-
如果直接使用
p[0]
,wc
仍然会从标准输入读取数据,而不是从p[0]
读取。 -
这意味着
wc
会等待用户从终端输入数据,而不是从管道读取数据。
(2)文件描述符的语义
-
文件描述符
0
是标准输入,许多程序(如wc
、cat
等)都依赖于这一约定。 -
如果直接使用
p[0]
,需要修改wc
的源代码,使其从p[0]
读取数据,而不是标准输入。这是不现实的,因为无法修改所有命令行工具的源代码。 -
wc
命令仍然从标准输入读取数据,但它实际上是从管道读取数据。
总结: 关闭标准输入并使用 dup(p[0])
的目的是将管道的读端重定向到标准输入。
Ctrl+D
,如果你在命令行直接按 Ctrl+D
,用于表示 End of File (EOF, 文件结束)
pingpong.c
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int
main(int argc,char* argv[])
{
int fd[2];
pipe(fd);
if (fork() == 0)
{
read(fd[0],"received ping\n",14);
close(fd[1]);
write(fd[1],"pong",4);
exit(0)
}else
{
write(fd[1],"ping",4);
close(fd[0]);
read(fd[0],"recdived pong\n",5);
close(fd[0]);
}
wait(0);
exit(0);
}
第一次写出来的 错误代码!
最终修改:
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
/*对于错误处理第一次整体都没有判断 read write pipe*/
int
main(int argc,char* argv[])
{
char buf[5];
int fd[2];
if (pipe(fd) < 0)
{
fprintf(2, "pipe failed\n");
exit(1);
}
if (fork() == 0)
{
read(fd[0],buf,4);
buf[4] = '\0';
printf("%d received %s\n",getpid(),buf);
close(fd[0]);
write(fd[1],"pong",4);
close(fd[1]);
exit(0);
}else
{
write(fd[1],"ping",4);
close(fd[1]);
read(fd[0],buf,4);
buf[4] = '\0'; // 添加空字符 忘记添加了
printf("%d: received %s\n",getpid(),buf);
close(fd[0]);
}
wait(0);
exit(0);
}
我觉得这段程序可能会导致父子进程之间的相互竞争 事实可能也是这样
重点:
正确使用 pipe
、fork
、read
和 write
,以及关闭文件描述符和等待子进程完成
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int
main(int argc, char *argv[])
{
int p1[2];
int p2[2];
if (-1 == pipe(p1) || -1 == pipe(p2)) {
write(2, "error\n", 6);
}
if (fork() == 0) {
char buf[1];
read(p2[0], buf, 1);
printf("%d: received ping\n", getpid());
write(p1[1], "x", 1);
} else {
write(p2[1], "x", 1);
char buf[1];
read(p1[0], buf, 1);
printf("%d: received pong\n", getpid());
}
exit(0);
}
这是取巧的一段代码
第一次程序出现的问题:
-
父进程没有等待子进程完成:
-
父进程在读取
pong
后直接退出,没有调用wait(0)
等待子进程完成。 -
这可能导致子进程的输出被截断,或者父进程提前退出。 第二次程序:
-
添加wait()等待子进程完成操作,避免造成竞争
文件描述符的疑惑
if(fork() == 0) {
write(1, "hello ", 6);
exit();
} else {
wait();
write(1, "world\n", 6);
}
这段程序子进程使用1这个文件描述符后没有关闭,会不会导致父进程无法使用1这个文件描述符呢
解答:
每个进程都有自己独立的文件描述符表。子进程通过 `fork()` 创建时,会继承父进程的文件描述符表,但子进程和父进程的文件描述符是相互独立的。子进程对文件描述符的操作(如写入、关闭)不会影响父进程的文件描述符。
当子进程调用 `exit()` 退出时,操作系统会关闭子进程打开的所有文件描述符。这些关闭操作仅限于子进程,不会影响父进程的文件描述符。
因为:
每个进程可以打开的文件描述符数量是有限的(由系统配置决定,可以通过 ulimit -n
查看)。如果不关闭文件描述符,可能会导致文件描述符泄漏,最终耗尽系统资源。
在编写pingpong.c时:
因为是对管道的读写,管道的读写操作是阻塞的。如果不关闭文件描述符,可能会导致进程一直等待,无法正常结束。
-
如果父进程不关闭
fd[1]
,子进程的read(fd[0], buf, 4)
可能会一直等待,因为父进程的fd[1]
仍然打开,子进程无法确定父进程是否已经完成写入。 -
如果子进程不关闭
fd[1]
,父进程的read(fd[0], buf, 4)
可能会一直等待,因为子进程的fd[1]
仍然打开,父进程无法确定子进程是否已经完成写入。
管道的设计遵循以下规则:
-
如果写入端(
fd[1]
)关闭,读取端(fd[0]
)的read()
会返回0
,表示没有更多数据可读(即文件结束,EOF)。 -
如果读取端(
fd[0]
)关闭,写入端(fd[1]
)的write()
会触发SIGPIPE
信号,通常导致写入进程终止。
这并不是通过信号实现的,而是通过管道的文件描述符状态实现的。
如果父进程关闭了 fd[1]
,子进程的 read()
会返回 0
,表示管道已经关闭,没有更多数据可读。
信号与管道的区别
-
信号:是一种异步通知机制,用于通知进程发生了某些事件(如
SIGINT
、SIGTERM
等)。 -
管道:是一种同步通信机制,通过文件描述符的状态(如关闭写入端)来通知读取端
文件系统
文件就是一个简单的字节数组,
chdir()
是一个系统调用,用于改变当前进程的工作目录。
primes.c
第一次尝试
void fork(int *father_pipe)
{
int n;
int pid = fork();
int son_pipe[2];
pipe[son_pipe];
if (pid == 0)
{
while (1)
{
read(father_pipe[0], &n, sizeof(n));
for (int i = 2; i < 35; i++)
{
if (n % i != 0)
{
write(son_pipe[1], &n, sizeof(n));
}
}
}
close(father_pipe[0]);
close(son_pipe[1]);
}
else
{
int st;
wait(&st);
}
}
int main(int argc, char *argv[])
{
int fd[2];
int buf[36];
pipe[fd];
for (int i = 2; i <= 35; i++)
{
write(fd[1], &i, sizeof(i));
}
close(fd[1]);
f(fd);
}
我的思想:
父亲传入数据到管道的时候,在函数中应该需要先fork出一个子进程 来与父进程通信读取,并且我对从管道中读取数据也有点模糊,是一口气全部读出来,还是一个个读取然后做处理,我是想对所有的数字依次被2-35除然后筛选一层层向子进程传递,并最终打印出最终结果
以下是我的想法,并能求出的代码:
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "stddef.h"
void create_child(int *input_pipe, int divisor) {
int output_pipe[2];
pipe(output_pipe); // 创建输出管道
int pid = fork(); // 创建子进程
if (pid == 0) {
// 子进程
close(input_pipe[1]); // 关闭父进程的写入端
close(output_pipe[0]); // 关闭子进程的读取端
int num;
while (read(input_pipe[0], &num, sizeof(num)) > 0) {
if (num % divisor != 0 || num == divisor) {
// 如果不能被当前除数整除,或者数字是当前除数本身,传递给下一个子进程
write(output_pipe[1], &num, sizeof(num));
}
}
close(input_pipe[0]); // 关闭父进程的读取端
close(output_pipe[1]); // 关闭子进程的写入端
exit(0);
} else {
// 父进程
close(input_pipe[0]); // 关闭父进程的读取端
close(output_pipe[1]); // 关闭子进程的写入端
if (divisor >= 35) {
int num;
while (read(output_pipe[0], &num, sizeof(num)) > 0) {
printf("prime %d\n", num); // 打印质数
}
close(output_pipe[0]); // 关闭读取端
} else {
// 将子进程的输出管道作为下一个子进程的输入管道
create_child(output_pipe, divisor + 1);
}
}
}
int main() {
int fd[2];
pipe(fd); // 创建初始管道
// 将数字 2 到 35 写入管道
for (int i = 2; i <= 35; i++) {
write(fd[1], &i, sizeof(i));
}
close(fd[1]); // 关闭写入端
// 创建第一个子进程,从 2 开始筛选
create_child(fd, 2);
// 父进程等待所有子进程结束
while (wait(NULL) > 0);
return 0;
}这段程序并不能正常打印出6.s081课程的素数题目的最终结果,我还应该怎么修改?
这段程序的错误:
-
筛选条件
-
递归逻辑问题:
正确的做法应该是每个子进程从输入管道中读取第一个数作为新的divisor
(质数),然后用它筛选后续的数。而原代码强制divisor
递增,导致后续进程可能使用非质数作为除数
- 打印时机:
只有当divisor >=35
时,父进程才会读取output_pipe
并打印结果。这意味着只有最后一个进程才会打印,而实际上每个进程应负责打印自己筛选出的质数(即第一个接收到的数)
问题 1:
read(input_pipe[0], &num, sizeof(num)) > 0 我并不理解有时候 在read和write中括号后面时常会出现 >0 =0 <0 都是什么意思? 用while循环的目的又是什么
如何处理对于本身 例如: 2 % 2 == 0 !
解答 1:
- read:
-
> 0
:成功读取了数据,返回值是实际读取的字节数。 -
= 0
:读取到文件结束(EOF),表示写入端已经关闭,没有更多数据可读。 -
< 0
:读取失败,通常是由于错误(如文件描述符无效、管道被意外关闭等)。
- write:
-
> 0
:成功写入了数据,返回值是实际写入的字节数。 -
= 0
:没有写入任何数据(通常不会发生,除非count
为 0)。 -
< 0
:写入失败,通常是由于错误(如文件描述符无效、管道被意外关闭等)
while (read(input_pipe[0], &num, sizeof(num)) > 0)
:
-
持续从
input_pipe[0]
中读取数据。 -
如果读取成功(
read()
返回> 0
),则处理数据。
回答2:
直接传递给下一个进程
问题 2:
不理解这里的数据是一次性全部传入管道一次性读取还是 父进程传入一个数据经过处理后然后再读取
解答 2:
- 理解管道特性
管道是一种 先进先出(FIFO) 的通信机制。数据写入管道后,会按照写入的顺序依次被读取。管道的读写是 阻塞的:
-
如果管道为空,读取端会阻塞,直到有数据写入。
-
如果管道已满,写入端会阻塞,直到有数据被读取。
- 代码中数据是一次性全部写入管道的, 当父进程关闭写入端,表示数据写入完成。
-
- 子进程从管道中 逐个读取数据,而不是一次性读取所有数据。
- 每读取一个数据,就根据
divisor
进行筛选,并将筛选后的数据写入下一个管道。
子进程:
-
逐个处理:
-
每个子进程从管道中 逐个读取数据,处理后再写入下一个管道。
-
数据是 流式处理 的,而不是一次性全部读取。
-
-
批量处理:
- 如果管道中有多个数据,子进程会逐个读取并处理,直到管道为空。
核心代码:
while (read(input_pipe[0], &num, sizeof(num)) > 0) {
if (num == divisor) {
// 如果是当前除数本身,直接传递给下一个子进程
write(output_pipe[1], &num, sizeof(num));
} else if (num % divisor != 0) {
// 如果不能被当前除数整除,传递给下一个子进程
write(output_pipe[1], &num, sizeof(num));
}
}
为什么可以持续的读,可以自动检测读完吗?
while (read(output_pipe[0], &num, sizeof(num)) > 0) {
printf("primes: %d\n", num); // 打印质数
}
close(output_pipe[0]); // 关闭读取端
} else {
// 将子进程的输出管道作为下一个子进程的输入管道
create_child(output_pipe, divisor + 1);
重点解释:
管道的写入端关闭
当父进程写入数据并关闭写入端时,管道的写入端会被标记为关闭。
关闭写入端后,读取端的 `read()` 行为会发生变化:
如果管道中还有数据,`read()` 会继续读取数据。
如果管道中没有数据,`read()` 会返回 `0`,表示写入端已经关闭,没有更多数据可读。
- 管道的内部实现会跟踪写入端的状态。
- 当写入端关闭时,操作系统会通知读取端,表示没有更多数据会写入管道。
- 如果读取端尝试读取数据,但管道中没有数据且写入端已关闭,`read()` 会返回 `0`。
出乎意料
这段程序并没有成功打印出结果!
重新修改 primes.c
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
void create_child(int parent_fd[]) {
int p;
// 读取第一个数字,它一定是质数
if (read(parent_fd[0], &p, sizeof(p)) == 0) {
close(parent_fd[0]);
exit(0);
}
printf("prime %d\n", p); // 立即打印当前质数
int child_fd[2];
pipe(child_fd);
if (fork() == 0) {
// 子进程:关闭不必要的文件描述符,递归处理
close(parent_fd[0]);
close(child_fd[1]);
create_child(child_fd);
exit(0);
} else {
// 父进程:过滤并传递剩余数字
close(child_fd[0]);
int num;
while (read(parent_fd[0], &num, sizeof(num)) > 0) {
if (num % p != 0) {
write(child_fd[1], &num, sizeof(num));
}
}
close(parent_fd[0]);
close(child_fd[1]);
wait(0); // 等待子进程结束
}
}
int main() {
int initial_fd[2];
pipe(initial_fd);
for (int i = 2; i <= 35; i++) {
write(initial_fd[1], &i, sizeof(i));
}
close(initial_fd[1]);
create_child(initial_fd);
// 确保所有子进程结束
while (wait(0) > 0);
exit(0);
}
疑问:
while (read(parent_fd[0], &num, sizeof(num)) > 0) { if (num % p != 0) { write(child_fd[1], &num, sizeof(num)); } }
这段程序不是也会把2等过滤掉吗
管道是队列结构
管道(Pipe)本质上是一个先进先出(FIFO)的字节流。每次调用 read
读取数据时,读取过的数据会从管道中移除,后续的 read
操作只会读取未被读取的数据。
输出重定向
redirect.c
你不必成为专家, 你花费大量时间开发维护和调试,会了解很多操作系统的知识,
ls > out 输出重定向
echo hello > out
cat < out
子进程调用exit(1) -> 父进程会接受到子进程的退出
exec会丢弃所有复制的内存,并将其替换为
思考fork的副本复制 复制了所有的内存 所花费的时间
虚拟内存映射
为子进程分配权重
观看完第一集视频,看完第一章内容,还剩两道题没有做
find.c
理解 ls.c 程序:
理解 ls.c 程序:
......写起来有点费劲,不太理解
寻求博客帮助:
在 Linux 中,只使用 ls 并不会显示 .
和 ..
。
- -a:显示所有文件及目录 (ls 内定将文件名或目录名称开头为 "." 的视为隐藏档,不会列出)
- -A:同 -a ,但不列出 "." (目前目录) 及 ".." (父目录)
ls.c程序
-
fmtname()
函数:格式化文件名。 -
ls()
函数:遍历目录并打印文件信息。 -
main()
函数:解析命令行参数并调用ls()
。
fmtname()
函数
char* fmtname(char *path) {
static char buf[DIRSIZ+1];
char *p;
// 找到最后一个斜杠后的字符
for(p = path + strlen(path); p >= path && *p != '/'; p--)
;
p++;
// 返回格式化后的文件名
if(strlen(p) >= DIRSIZ)
return p;
memmove(buf, p, strlen(p));
memset(buf + strlen(p), ' ', DIRSIZ - strlen(p));
return buf;
}
-
从路径末尾向前查找最后一个斜杠(
/
),找到文件名起始位置。 -
如果文件名长度超过
DIRSIZ
,直接返回文件名。 -
否则,将文件名复制到
buf
中,并用空格填充剩余部分。
思路:
首先确定递归的边界条件之一:第一个参数dir_name是一个文件名。使用fmtname(需要修改一下)处理文件名之后直接比对即可,然后返回函数。find遍历目录的方式和ls基本相同。遍历目录时,遇到.和..两个文件要跳过,遇到文件时就和file_name比对,如果相同就打印这个文件的相对路径。如果遇到了目录,就递归调用search函数。
- 中途思考:printf 和 write(1)的区别
特性 | printf | write(1, ...) |
---|---|---|
功能 | 格式化输出 | 原始数据输出 |
缓冲机制 | 有缓冲(行缓冲或全缓冲) | 无缓冲 |
易用性 | 高(支持格式化字符串) | 低(需要手动计算长度) |
性能 | 较慢(由于缓冲和格式化开销) | 较快(直接系统调用) |
适用场景 | 通用输出,适合大多数情况 | 底层操作,适合精确控制输出的场景 |
指针用法:
char buf[256] = "hello";
char *p = buf + strlen(buf); // p 指向 buf 中字符串的末尾
strcpy(p, " world"); // 在 buf 中追加 " world"
printf("%s\n", buf); // 输出 "hello world"
struct dirent 用法
#include <stdio.h>
#include <dirent.h>
int main() {
DIR *dir = opendir("."); // 打开当前目录
if (dir == NULL) {
perror("opendir failed");
return 1;
}
struct dirent de;
while ((de = readdir(dir)) != NULL) { // 读取目录项
printf("File: %s\n", de->d_name); // 打印文件名
}
closedir(dir); // 关闭目录
return 0;
}
de.inum == 0
- 目录项仍然存在,但
inum
为0
,表示该目录项是空闲的或无效的。
memmove(buf, p, strlen(p));
- 将文件名从指针
p
指向的位置复制到缓冲区buf
中
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "kernel/fs.h"
char*
fmtname(char *path)
{
static char buf[DIRSIZ+1];
char *p;
// Find first character after last slash.
for(p=path+strlen(path); p >= path && *p != '/'; p--)
;
p++;
// Return blank-padded name.
if(strlen(p) >= DIRSIZ)
return p;
memmove(buf, p, strlen(p));
memset(buf+strlen(p), ' ', DIRSIZ-strlen(p));
return buf;
}
void
find(char* dir_name, char* file_name)
{
char buf[256];
int fd;
struct stat st;
struct dirent de;
if(fd = open(dir_name,0))
{
fprintf(2, "find cannot open dir %s\n",dir_name);
return;
}
if(fstat(fd,&st) < 0)
{
fprintf(2,"find: cannot stat dir %s\n",dir_name);
}
//如果是文件则直接输出
if(st.type == T_FILE)
{
if(!strcmp(fmtname(dir_name),file_name))
{
printf("%s\n",dir_name);
}
return;
}
//如果是目录应该遍历目录下的文件
if(st.type = T_DIR)
{
if(strlen(dir_name) + 1 + DIRSIZ + 1 > sizeof(buf))
{
printf("find: path too long\n");
return;
}
}
strcpy(buf, dir_name);
p = buf + strlen(buf);//定位指针
*p++ = '/';
struct stat st_temp;
while(read(fd,.&de,sizeof(de)) == sizeof(de))
{
if(de.inum == 0)
{
continue;
}
memmove(p,de.name,DIRSIZE);
p[DIRSIZ] = 0;
if(stat(buf,&st_temp) < 0)
{
continue;
}
if(st_tmp.type==T_FILE)//如果是普通文件
{
if(!strcmp(de.name,file_name))//找到文件
{
printf("%s\n",buf);//打印文件的相对路径
}
}
if(st_tmp.type==T_DIR)//如果是目录
{
//递归搜索,使用BFS遍历directory tree
//禁止遍历. .. 这两个目录
if((!strcmp(de.name,this_dir))||(!strcmp(de.name,parent_dir)))
continue;
find(buf,file_name);//递归搜索
}
}
return;
}
int
main(int argc, char* argv[]){
if(argc < 3 ) {
fprintf(2,"too few arguments\n");
exit(1);
}
find(argv[1],argv[2]);
exit(0);
}
第一次完成代码: 有很多问题,代码格式还需要修改!
需要修改很多地方
关键点:
while(read(...))
循环遍历目录项,每次read
取出一个de
结构体,赋值给de.name
。!strcmp(de.name, file_name)
持续比较de.name
和file_name
,匹配成功时打印路径。- 递归调用
find(buf, file_name)
,在子目录里重复上述过程。
正确代码已推送至仓库:
总结find.c系统调用函数:
find.c
中使用的操作系统调用函数及其作用如下:
函数 | 作用 |
---|---|
open | 打开目录或文件。 |
close | 关闭文件描述符。 |
fstat | 获取文件或目录的状态信息。 |
read | 读取目录项。 |
stat | 获取文件或目录的状态信息。 |
strcmp | 比较文件名。 |
strcpy | 复制路径字符串。 |
memmove | 复制文件名到缓冲区。 |
memset | 填充缓冲区。 |
printf | 输出文件路径或信息。 |
fprintf | 输出错误信息到标准错误流。 |
xargs.c
如何将指令的结果 作为 参数传给xargs.c
指针含义
char *args[MAXARG]; // 每个元素是一个 char*
args[argc - 1] = p; //将 p 指向的字符串存入args数组的第 argc - 1个位置
xargs
的作用是将标准输入的内容作为参数,拼接到指定命令的后面。
过程:
-
初始时,
p
指向buf
的开头,内容是hello too\n
。 -
跳过空白字符(如果有),这里没有前导空白。
-
将
hello
作为一个参数,存入args
:args = ["echo", "bye", "hello"]
。
-
继续解析,将
too
作为一个参数,存入args
:args = ["echo", "bye", "hello", "too"]
。
-
遇到换行符
\n
,解析结束。
-
在这个例子中:
-
echo hello too
输出hello too
。 -
xargs
读取hello too
,将其解析为参数hello
和too
。 -
将
hello
和too
拼接到echo bye
后面,形成echo bye hello too
。 -
最终输出
bye hello too
。
-
’\0‘ 作为字符串的结束
关键代码:
while ((n = read(0, buf, sizeof(buf))) {
if (n < 0) {
fprintf(2, "xargs: read error\n");
exit(1);
}
// 将输入数据解析为参数
char *p = buf;
while (*p != '\0') {
// 跳过空白字符
while (*p == ' ' || *p == '\n') {
*p++ = '\0';
}
// 如果遇到非空白字符,将其作为参数
if (*p != '\0') {
args[argc - 1] = p;
argc++;
while (*p != '\0' && *p != ' ' && *p != '\n') {
p++;
}
}
}
今天执行程序测试的时候出现了问题:
make: *** No rule to make target 'user/_xargs\', needed by 'fs.img'. Stop.
就是xargs配置出现了问题 问题应该是在Makfile中
emmm 缩进使用的空格造成了问题! 直接使用tab就好了
((n = read(0, buf, sizeof(buf))) > 0)
运算优先级
1. 第一个进程:
![[页表映射.png]]
xv6是如何开始运行的:
进程,它让一个程序可以假设它独占一台机器。进程向程序提供“看上去”私有的,其他进程无法读写的内存系统(或地址空间),以及一颗“看上去”仅执行该程序的CPU
xv6 使用页表 为每个进程提供其独有的地址空间。页表将_虚拟地址_(x86 指令所使用的地址) 翻译 为_物理地址_(处理器芯片向主存发送的地址)
数据结构: 分页表
用于计算机 操作系统中的 虚拟内存 系统,其存储了虚拟地址到物理地址间的映射。虚拟地址在访问进程中是唯一的,而物理地址在硬件(比如内存)中是唯一的
操作系统中使用虚拟内存, 进程会认为 自己使用了一块大的连续内存,但是事实 每个进程的内存散布在 物理内存 的不同区域
进程和页表都存储在内存中,查询进程数据时,需要访问两次内存, 放入寄存器中加快查询速度
操作系统负责把程序生成的虚拟地址,映射到实际存储的物理内存上 存储虚拟地址到物理地址的映射
xv6 为每个进程维护了不同的页表
-
线程: 每个进程都有一个运行线程 来执行进程的指令 线程可以被暂时挂起,稍后再恢复运行 系统在进程之间切换 实际上就是挂起当前运行的线程,恢复另一个进程的线程
-
进程的组成: 每个进程都有 用户栈 和 内核栈 : 进程运行用户指令时,只有其用户栈被使用,其内核栈则是空的,当进程(通过系统调用或中断)进入内核时,内核代码就在进程的内核栈中执行,进程处于内核中时,其用户栈仍然保存着数据,只是暂时处于不活跃状态
-
线程交替地使用着用户栈和内核栈 内核栈是用户代码无法使用的,这样即使一个进程破坏了自己的用户栈,内核也能保持运行
-
内核栈是用户代码无法使用的,这样即使一个进程破坏了自己的用户栈,内核也能保持运行
进程使用系统调用时,处理器转入内核栈中,提升硬件的特权级,然后运行系统调用对应的内核代码, 当系统调用完成时,又从内核空间回到用户空间:降低硬件特权级,转入用户栈 线程可以在内核中“阻塞”,等待 I/O, 在 I/O 结束后再恢复运行
p->state
指示了进程的状态:新建、准备运行、运行、等待 I/O 或退出状态中
PC 开机时: 从磁盘中载入 boot loader 到内存并运行 boot loader 把 xv6 内核从磁盘中载入并从 entry
(1040)开始运行
分页硬件在此时还没有开始工作;所以这时的虚拟地址是直接映射到物理地址上的
boot loader 把 xv6 内核装载到物理地址 0x100000 处。之所以没有装载到内核指令和内核数据应该出现的 0x80100000,是因为小型机器上很可能没有这么大的物理内存。而之所以在 0x100000 而不是 0x0 则是因为地址 0xa0000 到 0x100000 是属于 I/O 设备的。
为了让内核的剩余部分能够运行,entry
的代码设置了页表 将 0x80000000开始的虚拟地址映射到物理地址 0x0 处 页表经常会这样把两段不同的虚拟内存映射到相同的一段物理内存
lab3 :
多路复用 隔离
将一个cpu抽象为一个进程 4核心就是同时或并行四个进程每个核心上都有一个进程 不同的进程之间进行时间复用
exec: 抽象了内存 内存映像
files: 抽象了磁盘块
proc.c 有关多路复用
Strong isolation between apps + os
User/kernel modev
cpu vitual memory:
page table 将虚拟地址 映射到 物理地址
查看 kernel/kernel.asm
*gdb调试:
使用 gdb-multiarch
指令开启gdb 并且使用 target remote :25000
连接到qemu
视频中老师使用的linux版本并不是ubuntu 所以指令在ubuntu中并不适用,需要更改!!
首先要手动读取内核:
file kernel/kernel
之后就可以看到详细的输出了 但是没有视频中的纤细输出
b ~~ 给某个位置打断点
b _entry
b main
c
(Continue) - 继续执行直到下一个断点
si
(Step Instruction) - 单步执行一条汇编指令
n
(Next) - 单步执行一行源代码
layout asm
:
纯汇编视图
- 只显示汇编指令,适用于低级调试(如 OS 内核、Bootloader)。
- 适合用
si
(单步执行指令)进行逐条汇编指令调试。
**layout split
:
源代码 + 汇编混合视图
- 上半部分:显示源码(C 代码)。
- 下半部分:显示对应的汇编指令。
- 适用于调试 C 语言时,同时观察 C 代码和编译后的汇编代码。
ctrl + x a 退出
lab 2: System-calls
测评脚本修改为:
sudo python3 grade-lab-syscall trace
System call tracing
添加一个系统调用跟踪功能,该功能可能会在调试后续实验时为您提供帮助
*第二章:页表
页表使得 xv6 能够让不同进程各自的地址空间映射到相同的物理内存上,还能够为不同进程的内存提供保护
插入学习:
锁
xv6 运行在多处理器上,即计算机上有多个单独执行代码的 CPU。这些 CPU 操作同一片地址空间并分享其中的数据结构;xv6 必须建立一种合作机制防止它们互相干扰。即使是在单个处理器上,xv6 也必须使用某些机制来防止中断处理程序与非中断代码之间互相干扰
锁提供了互斥功能,保证某个时间点只有一个 CPU 能持有锁
关键:
你一定要问自己另一个处理器的存在是否会让这行代码无法达到期望的运行结果(因为另一个处理器也可能正在运行该行代码,或者另一行修改这个共享变量的代码),还要考虑如果这里执行一个中断处理程序,又会发生什么情况。
一行 C 代码可能由多条机器指令组成,而另一个处理器或者中断可能在这些指令中间影响之。你不能假设这些代码是顺序执行的,也不能假设一个 C 指令是以原子操作执行的。并发使得考虑代码的正确性变得困难。
竞争条件:
为什么我们需要锁??
一段链表代码:
struct list{
int data;
struct list *next;
};
struct list *list = 0;
void
insert(int data)
{
struct list *l;
l = malloc(sizeof *l);
l->data = data;
l->next = list;
list = l;
}
即使可以证明其正确性,实际上这种实现也是错误的,至少不能在多处理器上运行
全局变量 list
的潜在问题:
这意味着所有对链表的操作都会共享同一个 list
。在多线程环境中,这可能会导致竞争条件(race condition),因为多个线程可能同时修改 list
。
我的想法:
如何模拟多线程 使这段原本正常运行的代码出现问题? 打开两个编译器同时执行这段代码可以吗?
解答:
打开两个编译器同时执行这段代码并不能模拟多线程环境,因为每个编译器运行的是独立的进程,它们的内存空间是隔离的,不会共享全局变量 list
要模拟多线程环境,你需要在同一个进程内创建多个线程,并让这些线程同时操作共享的全局变量 list
竞争问题在于它们的结果由 CPU 执行时间以及其内存操作的先后决定的
使用c语言模拟多线程编程导致的资源竞争:
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
// 链表结构体
struct list {
int data;
struct list* next;
};
struct list* list = NULL;
//CRITICAL_SECTION lock; // 互斥锁
void insert(int data) {
struct list* l = malloc(sizeof * l);
if (l == NULL) {
fprintf(stderr, "Memory allocation failed\n");
exit(EXIT_FAILURE);
}
l->data = data;
// 线程安全的修改
//EnterCriticalSection(&lock);
l->next = list;
list = l;
//LeaveCriticalSection(&lock);
}
void print_list() {
struct list* current = list;
while (current != NULL) {
printf("%d -> ", current->data);
current = current->next;
}
printf("NULL\n");
}
DWORD WINAPI thread_func(LPVOID arg) {
for (int i = 0; i < 10; i++) {
insert(i);
}
return 0;
}
int main() {
insert(10);
insert(20);
insert(30);
insert(40);
// InitializeCriticalSection(&lock); // 初始化互斥锁
HANDLE thread1, thread2;
// 创建两个线程
thread1 = CreateThread(NULL, 0, thread_func, NULL, 0, NULL);
thread2 = CreateThread(NULL, 0, thread_func, NULL, 0, NULL);
// 等待线程结束
// WaitForSingleObject(thread1, INFINITE);
//WaitForSingleObject(thread2, INFINITE);
print_list();
//DeleteCriticalSection(&lock); // 释放互斥锁
return 0;
}
进程和线程的关系
-
进程:进程是操作系统分配资源的基本单位。每个进程都有独立的内存空间、文件描述符、环境变量等资源。进程之间是相互隔离的,一个进程崩溃通常不会影响其他进程。
-
线程:线程是进程内的执行单元,是操作系统调度的基本单位。一个进程可以包含多个线程,这些线程共享进程的内存空间和资源(如堆、全局变量、文件描述符等),但每个线程有自己的栈和寄存器状态。
问题2:
一个进程会没有线程吗?
回答2:
一个进程至少有一个线程,称为主线程(main thread)。当你运行一个普通的 C 程序时,main
函数就是在主线程中执行的。这种进程是单线程的。
通过调用线程库(如 pthread
),可以在一个进程中创建多个线程,这些线程并发执行。
没有线程的进程是不存在的。
void
acquire(struct spinlock *lk)
{
for(;;) {
if(!lk->locked) {
lk->locked = 1;
break;
}
}
}
这段代码在现代处理器上并不能保证互斥 代码会导致 两个不同的 CPU 持有锁,违反了互斥
若要保证代码的正确,就必须让操作是原子操作的 意味着在执行过程中不会被其他线程或 CPU 核心打断。
特殊指令 xchg
xchg(&lk->locked, 1);
交换了内存中的一个字和一个寄存器的值
交换了什么?
-
内存中的值:
lk->locked
的当前值(可能是0
或1
)。 -
寄存器中的值:
1
(表示尝试获取锁)。
问题3:
如果两个线程同时运行到xarg这个语句不也还是会造成冲突吗
回答3:
关键在于 xchg
指令的原子性。即使两个线程同时运行到 xchg
语句,也不会造成冲突,因为 xchg
是硬件级别的原子操作。
- CPU 会确保
xchg
指令的执行是原子的。即使多个线程同时执行xchg
,硬件也会通过锁总线(bus locking)或缓存一致性协议(cache coherence)来保证只有一个线程能够成功执行xchg
syscall.c
void
syscall(void)
{
int num;
struct proc *p = myproc();
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// Use num to lookup the system call function for num, call it,
// and store its return value in p->trapframe->a0
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
解读
-
myproc()
是 xv6 中的一个函数,用于获取当前正在运行的进程的proc
结构体指针。 -
系统调用号是通过寄存器
a7
传递的。
寄存器 RS触发器 :
是cpu用来暂存指令,数据,地址的电脑存储器
作用:
-
从用户进程的陷阱帧中获取系统调用号。
-
根据系统调用号找到对应的内核函数并执行。
-
将系统调用的结果返回给用户进程。
-
如果系统调用号无效,则返回错误信息。
***DMA *插入知识
遇见错误:
kernel/syscall.c:129:15: error: ‘sys_close’ undeclared here (not in a function); did you mean ‘sys_closei’?
129 | [SYS_close] sys_close,
| ^~~~~~~~~
| sys_closei
make: *** [<builtin>: kernel/syscall.o] Error 1
手贱 修改了 extern uint64 sys_close(void); 为 extern uint64 sys_closei(void);
系统调用表
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_sleep] sys_sleep,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
};
问题:
为什么在 char* syscall_name[]数组中如果顺序错误,会导致 在调用
trace 32 grep hello README
产生的命令也会不一样(4: syscall read -> 1023) (3: syscall pipe -> 1023)
关键:
syscall.c
proc.c
sysproc.c
在做实验的时候 因为镜像不是教程使用的镜像 所以导致很多环境缺失,我现在必须切换另一个仓库
for branch in $(git branch -r | grep -v HEAD); do
git branch --track ${branch#origin/} $branch
done
拉取另一个仓库的所有分支
终于配好了环境!!!
先接着做,回头再补上之前的代码
sys_sysinfo.c
uint64 acquire_freemem(){
struct run *r;
uint64 cnt = 0;
acquire(&kmem.lock);
r = kmem.freelist;
while(r) {
r = r->next;
cnt++;
}
if(r)
kmem.freelist = r->next;
release(&kmem.lock);
return cnt * PGSIZE;
}
if (argaddr(0,&addr)<0) {
return -1
}
if (copyout(p->pagetable,addr,(char*)&info,sizeof(info))<0)
return -1;
}
从系统调用的参数中获取用户空间的地址。
将内核中的 info
结构体数据复制到用户空间的地址 addr
。
p->pagetable
:当前进程的页表,用于将内核虚拟地址映射到用户虚拟地址。
(char *)&info
:内核中 info
结构体的起始地址,强制转换为 char *
以便逐字节复制
问题4:
为什么会考虑使用链表来管理空闲内存
回答4:
动态内存管理的需求
链表在分配和释放操作上的时间复杂度为 O(1),非常高效。
高效的内存分配和释放
:每个空闲页只需要一个指针
适应碎片化内存
:轻松管理不连续的空闲页
kalloc.c
的核心功能
使用一个链表来管理空闲的物理内存页。每个空闲页的开头存储一个指向下一个空闲页的指针。
一个页表在物理内存中像一棵两层的树。树的根是一个 4096 字节的_页目录_,其中包含了 1024 个类似 PTE 的条目,每个条目是指向一个_页表页_的引用
PTE & 页 & 页表
PTE 结构通常包含以下关键字段:
-
物理页帧号(PFN,Page Frame Number):映射的物理页地址。
-
存在位(Present Bit):指示该页是否在内存中(1 代表在内存,0 代表不在,需要从磁盘调入)。
-
读/写权限(R/W Bit):控制该页是否可写(1 可写,0 只读)。
-
用户/内核权限(U/S Bit):决定该页是用户态(1)还是内核态(0)。
-
访问位(Accessed Bit):指示该页最近是否被访问过(用于页面置换策略)。
-
脏位(Dirty Bit):如果该页被修改过,操作系统可能需要回写到磁盘。
每个字节的物理内存都有一个地址 虚拟地址则是程序所使用的
每个进程都有自己的页表 xv6 会在进程切换时通知分页硬件切换页表
分页机制采用 两级页表 结构,即 页表目录(Page Directory)+ 页表(Page Table),用于管理 虚拟地址到物理地址的映射
-
页表目录是一个“目录”,指向多个页表。
-
页表是具体的“映射表”,存储 PTE(页表项),负责映射虚拟地址到物理地址。
-
PTE 是最终的映射单元,包含物理地址和权限控制信息。
问题5:
每个进程有独立的页表是什么意思? 如果有两个进程 并且两个进程的页表分别都映射到了相同的物理地址怎么办?
回答5:
// 进程A的页表(部分)
虚拟页0x1000 → 物理页帧0x2000 (存变量X)
虚拟页0x2000 → 物理页帧0x3000 (存代码段)
// 进程B的页表(部分)
虚拟页0x1000 → 物理页帧0x4000 (存变量Y)
虚拟页0x2000 → 物理页帧0x3000 (共享库代码)
-
虽然两个进程都有虚拟地址
0x1000
,但实际指向不同物理内存 -
两者都访问
0x2000
时,却指向同一物理页帧(共享库)
共享代码库(故意共享)
-
原理:多个PTE指向同一物理页,节省内存
-
关键:页表项标记为只读,防止互相干扰
解析:
1. 层级包含关系图示
复制
页表 (Page Table) │ ├── 页表项 (PTE 1) → 映射到物理页帧 X ├── 页表项 (PTE 2) → 映射到物理页帧 Y ├── ... └── 页表项 (PTE N) → 标记为无效(缺页)
- 每个PTE对应一个虚拟页,记录该页的物理位置和属性。
问题6:
教授提出问题:为什么ppn存在于page directory中?目的是什么
回答6:
页目录的本质与作用
页目录是多级页表的第一级(如x86的PML4或ARM的L1页表),其核心功能是:
-
定位下级页表:存储下一级页表(Page Table)的物理页帧号(PPN)。
-
控制访问权限:通过标志位管理整个下级页表的全局权限(如是否可写、用户态可否访问)。
(1)多级页表的物理地址连续性要求
-
关键约束:CPU的MMU硬件在查表时,必须直接访问物理内存(因为此时尚未完成虚拟→物理地址转换)。
-
解决方案:页目录项中存储下级页表的物理页帧号(PPN),使MMU能直接定位下级页表的物理位置。
satp作用:
satp
(Supervisor Address Translation and Protection,监管者地址转换和保护)是 RISC-V 架构中控制 虚拟内存系统 的核心寄存器,主要功能包括:
-
启用/禁用分页机制:决定是否开启虚拟地址到物理地址的转换。
-
设置页表基地址:告诉 CPU 当前进程的页表在物理内存中的起始位置。
-
选择地址转换模式:例如 Sv32(32位)、Sv39(39位虚拟地址)等。
若 satp.PPN = 0x1000
,表示顶级页表(L2)位于物理地址 0x1000
处。
CPU 根据 satp
寄存器找到顶级页表(L2)的物理地址。
-
satp
是页表系统的“大脑”:-
告诉 CPU 页表在哪里(
PPN
)。 -
控制是否开启分页(
MODE
)。 -
协助隔离进程(
ASID
)。
-
问题7:
为什么要设置多级页表,如果这样做好的话,那么更多级别的不是越来越好吗?如果不好为什么要设计多级
回答7:
多级页表的设计是为了 节省内存,并 提高地址翻译的效率。但页表层级 不是越多越好,因为层级过多会 增加访问开销。
1. 为什么要使用多级页表?
如果使用 单级页表,每个进程需要维护完整的页表,会导致 巨大的内存占用。
单级页表的问题
假设:
-
虚拟地址 是 64-bit(常见架构)
-
页大小 是
4KB
(即2^12
=4096
字节) -
每个页表项(PTE) 占
8B
计算单级页表大小:
-
需要管理
2^64 / 2^12 = 2^52
个页 -
每个 PTE
8B
-
单级页表大小 =
2^52 × 8B = 36PB
(无法接受!)
如果使用多级页表,只分配“必要的页表”,而不是整个大表,从而减少内存开销!
2. 为什么不是越多级越好?
多级页表减少内存占用,但增加了访问开销!
地址翻译的成本
假设使用 3 级页表(Sv39):
[ L2 (9-bit) ] [ L1 (9-bit) ] [ PTE (9-bit) ] [ Offset (12-bit) ]
访问过程:
-
访问
L2 页表
(取出L1 页表
的 PPN) -
访问
L1 页表
(取出物理页
的 PPN) -
访问
物理页
计算最终物理地址 -
总共 3 次内存访问 才能完成一次翻译!
如果使用 5 级页表,会有更多次内存访问,导致性能下降!
3. 现有架构是如何选择级数的?
-
Sv32(2 级页表) → 适用于 32-bit 系统
-
Sv39(3 级页表) → 适用于 大多数 64-bit 服务器/PC
-
Sv48(4 级页表) → 适用于 超大内存服务器
-
Sv57(5 级页表) → 适用于 未来超大内存
4. 关键总结
✅ 多级页表减少内存占用,因为它 按需分配页表
❌ 页表级别过多会降低访问速度,因为每次地址转换都要查多个级别
最优解 取决于 物理内存大小 和 性能需求,不是越多级越好!
问题8:
所以每一个进程都会有一个虚拟地址是吗? 这样做的目的是什么呢?为了隔离应用程序吗?所以如果要通过寻找虚拟地址的物理地址,经过多次的内存读写是不是也是很大的问题?
回答8:
连续虚拟空间:程序无需关心物理内存碎片。例如:
- 进程的堆、栈、代码段在虚拟地址中是连续的,但物理内存可能分散。
每次访问虚拟地址需查页表(多级页表可能需4-5次内存访问):
解决:
-
TLB(Translation Lookaside Buffer):CPU缓存近期使用的
虚拟页→物理页
映射。-
命中时:1个周期完成转换。
-
未命中时:触发“页表遍历”(由MMU硬件加速)。
-
-
TLB效果:典型程序的TLB命中率 >99%,几乎无额外开销。
我的错误想法,把cpu等同于处理器了!
cpu并不等同于处理器
-
CPU = 大脑(负责思考)。
-
处理器芯片 = 整个头(大脑+眼睛+耳朵+嘴巴)。
问题9:
那mmu是什么? 和cpu的关系是什么
回答9:
MMU(Memory Management Unit,内存管理单元)是CPU的“地址翻译官”,负责把程序用的虚拟地址(如0x8048000
)转换成物理内存的真实地址(如0x12340000
)。
- 现代CPU:MMU直接集成在CPU内部(如Intel的MMU叫“Memory Management Unit”,ARM的叫“MMU”或“SMMU”)。
问题10:
cache和TLB之间的区别是什么?都是缓存 当计算机处理数据的时候什么时候用到TLB什么时候用到cache
回答10:
Cache 和 TLB 的区别及使用场景
一句话总结:
-
Cache(缓存):存储 数据 和 指令,加速CPU访问内存。
-
TLB(快表):存储 虚拟地址→物理地址的映射关系,加速MMU查页表。
问题11:
page table是谁可以拥有的? 一个进程吗 也就是一个应用程序吗? 或者说一个应用程序分为内核态和用户态 这个表是谁在持有谁在控制呢
回答11:
-
谁拥有页表?
-
每个用户进程 有独立的页表(用户空间部分)。
-
内核 有全局共享的页表(内核空间部分)。
-
-
谁控制页表?
-
内核 全权管理页表的创建、修改和销毁。
-
CPU的MMU 负责运行时地址转换。
-
问题12:
为什么分页机制能让虚拟内存远大于物理内存?
回答12:
-
虚拟内存 ≠ 物理内存,程序看到的是虚拟地址,由操作系统和硬件(MMU)动态映射到物理内存或磁盘。
-
按需加载(Demand Paging):程序运行时,只有当前需要的部分数据会加载到物理内存,其余部分暂存磁盘。
-
分页与交换:当物理内存不足时,操作系统将不活跃的内存页(Page)换出(Swap Out)到磁盘,腾出空间给新数据。
我的想法
以xv6操作系统举例 分页机制就是分成了三个L2 L1 L0 然后分别对应了三个高级 中级 低级表,为什么只是用一个单表不行呢? 如果只是用一个单表,并且设置为按需加载不是可以达到一样的效果吗? 我的理解哪里出现了差错?
回答:
主要错误:
可能错误地认为单级页表可以按需加载页表项,但实际上单级页表的结构导致必须预先分配所有条目,而多级页表通过层次结构允许动态分配。
单页表
-
内存占用爆炸:
若虚拟地址空间为 239239(如RISC-V Sv39标准),页大小为4KB(212212),则单级页表需要 239/212=227239/212=227 个页表项(PTE)。
每个PTE占8字节(RISC-V标准),总内存占用为 227×8B=1GB227×8B=1GB。
这意味着每个进程的页表自身就需要占用1GB物理内存,显然不可行。 -
按需加载的局限性:
单级页表的所有PTE必须预先分配(即使虚拟地址未使用),否则无法通过单级结构定位到缺失的PTE。
按需加载只能管理页面(Page),无法管理页表本身。若页表条目未预先分配,硬件在地址转换时无法找到下一级PTE,导致无法触发缺页异常。
-
单级页表:固定占用4MB(无论实际用了多少内存)。
-
二级页表:
-
页目录:始终占用1个页(4KB,含1024个条目)。
-
页表:只需为已使用的虚拟页分配二级页表。
-
若3个物理页分散在3个不同的二级页表中,最多需要3个二级页表(每个4KB),总占用 4KB+3×4KB=16KB4KB+3×4KB=16KB。
-
若3个物理页集中在1个二级页表内,则仅需 4KB+4KB=8KB4KB+4KB=8KB。
-
-
page fault
当程序首次访问某个虚拟地址时:
-
硬件查页目录,发现对应的二级页表“不存在”(标记为无效)。
-
触发 缺页异常,操作系统动态分配一个二级页表,并将其地址填入页目录。
-
继续执行地址转换。
-
多级页表通过层级查询,允许中间层PTE标记为“无效”,从而跳过下级页表的分配。
-
多级页表:像图书馆的层级目录:
-
先按大类(页目录)查找 → 再按小类(页表)查找 → 最后找到具体的书(物理页)。
-
如果某大类无人借阅(未使用的地址空间),整个小类目录无需打印。
-
关键代码:
如何计算
第三章:终端和驱动程序
运行进程时,cpu 一直处于一个大循环中 取指,更新 PC,执行,取指……
用户程序的非法操作
(例如引用一个找不到页表项的虚拟地址)
三大挑战
- 内核必须使处理器能够从用户态转换到内核态(并且再转换回用户态)
- 内核和设备必须协调好他们并行的活动
- 内核必须知道硬件接口的细节
系统调用,异常和中断
当硬盘读完一个数据块时,它会产生一个中断来提醒操作系统这个块已经准备好被获取了 所有的中断都由内核管理,而不是进程
强迫进程服从处理器的调度
!系统必须保存寄存器以备将来的状态恢复
!系统必须保持用户进程和系统进程的隔离
处理器需要在用户模式和内核模式之间切换
操作系统必须知道硬件是如何处理系统调用、异常和中断的
一定要记住陷入是由在 cpu 上运行的当前进程导致的,而中断是由设备导致的,可能与当前进程毫无关系
示例:
- 陷入(Trap)场景:
-
当Word执行
printf()
系统调用请求打印服务时,会主动触发一个陷入(由CPU当前运行的Word进程导致) -
这就像你主动打电话给打印机客服(主动触发系统调用)
-
陷入与当前进程(Word)直接相关,是它的代码逻辑的一部分
- 中断(Interrupt)场景:
当打印机完成打印后:
-
打印机的硬件控制器会发送一个中断信号给CPU
-
此时CPU可能正在运行任何进程(比如你在中断到来时正好切换到Excel做表格)
-
这个中断与Excel进程完全无关,是外部设备(打印机)触发的
-
就像打印机客服突然回拨电话,不管你现在正在做什么事情都要接听
x86的保护机制
x86 有四个特权级,从 0(特权最高)编号到 3(特权最低)
在操作系统中 int
指令(如 int 0x80
或 int 3
)和你平时编程中定义的 int n
(整数变量)完全不同,它是 x86 架构下的一个机器指令,用于触发软中断(Software Interrupt),从而实现系统调用、调试断点、异常处理等关键功能。它的复杂性主要体现在以下几个方面:
-
int n
(变量声明)- 例如
int n = 10;
,这只是声明一个整数变量,属于高级语言(C/C++)的语法,与 CPU 无关。
- 例如
-
int
指令(x86 机器指令)- 例如
int 0x80
,是 CPU 直接支持的指令,用于主动触发中断,让 CPU 从用户态切换到内核态,执行操作系统提供的服务(如读写文件、创建进程等)。
- 例如
插入 extern用法
仅当您正在构建的程序由多个链接在一起的源文件组成时,使用 extern
才有意义,其中定义的某些变量(例如,在源文件 file1.c
中定义的变量)需要在其他源文件(如 file2.c
)中引用。
中断和陷阱
中断是打断 CPU 的“控制权”,不是打断程序逻辑本身。
对比总结:
项目 | 中断 Interrupt | 陷阱 Trap |
---|---|---|
来源 | 外部设备 | 当前程序 |
类型 | 异步 | 同步 |
用途 | 响应外设事件 | 系统调用、错误处理 |
控制权转移 | 用户态 -> 内核态 | 用户态 -> 内核态 |
+-------------------+ +-------------------+ +-------------------+
| 用户程序执行 | | 中断处理流程 | | 操作系统内核 |
| | | | | |
| 执行int 0x80指令 | ----> | 1. 保存CPU上下文 | ----> | 查找系统调用表 |
| (系统调用) | | 2. 切换内核模式 | | 执行对应服务例程 |
| | | 3. 识别中断号 | | |
| 发生除零错误 | ----> | 1. 保存CPU上下文 | ----> | 执行异常处理程序 |
| (异常) | | 2. 切换内核模式 | | 可能终止进程 |
| | | 3. 识别异常类型 | | |
+-------------------+ +-------------------+ +-------------------+
中断可能打断一个正在持有锁的上下文,然后又尝试去拿这个锁,造成自己等自己释放锁 → 死锁。
自旋锁(Spinlock)的作用
自旋锁是一种 低级的同步机制,用于在多核 CPU 或并发线程中保护共享资源(如全局变量、数据结构)。它的核心行为是:
-
获取锁(
acquire
):如果锁已被其他 CPU/线程占用,当前 CPU/线程会 忙等待(自旋),直到锁被释放。 -
释放锁(
release
):当前持有者解锁,允许其他竞争者获取。
struct spinlock lock;
int shared_data = 0;
void thread_A() {
acquire(&lock); // 获取锁
shared_data += 1; // 安全修改共享数据
release(&lock); // 释放锁
}
void thread_B() {
acquire(&lock); // 如果锁被 thread_A 持有,则自旋等待
shared_data -= 1;
release(&lock);
}
为什么需要知道“当前持有锁的 CPU”?
自旋锁的实现中,通常会记录 当前持有锁的 CPU 核心编号(通过 cpuid()
获取),目的是:
-
死锁检测:防止同一 CPU 重复获取锁(导致永久等待)。
-
调试信息:在发生死锁时,打印持有锁的 CPU 编号,帮助定位问题。
自旋锁的优缺点
优点 | 缺点 |
---|---|
无上下文切换,响应快 | 忙等待浪费 CPU 周期 |
适合极短临界区(如修改一个变量) | 不适合长时间持有锁 |
实现简单 | 可能引发活锁(高竞争场景) |
原子操作
Lab Page table
Speed up system calls
通过在用户空间和内核之间的只读区域中共享数据来加速某些系统调用
技术实现关键点
-
内存映射:通过页表机制,将内核中的一块物理页面同时映射到内核和用户地址空间,并标记为用户只读(避免用户篡改)。
-
同步问题:由于数据是只读的,内核在更新数据时(如进程切换后更新PID),需保证用户态看到的是最新值(可通过原子写或页表权限动态调整实现)。
-
安全性:必须确保用户程序不能修改共享数据(通过硬件页表权限保护)。
🧩 总目标
实现在用户态,不用执行系统调用,就能获取当前进程的
pid
。
理解kvmmap
void
kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
if(mappages(kpgtbl, va, sz, pa, perm) != 0)
panic("kvmmap");
}
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 a, last;
pte_t *pte;
if(size == 0)
panic("mappages: size");
a = PGROUNDDOWN(va);
last = PGROUNDDOWN(va + size - 1);
for(;;){
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
if(*pte & PTE_V)
panic("mappages: remap");
*pte = PA2PTE(pa) | perm | PTE_V;
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
2. RISC-V Sv39 地址划分
虚拟地址 va
被划分为 5 部分(从高到低):
| 63..39 | 38..30 (L2) | 29..21 (L1) | 20..12 (L0) | 11..0 (offset) |
必须为0 二级页表索引 一级页表索引 零级页表索引 页内偏移
-
每级页表索引占 9 位(
2^9=512
个 PTE)。 -
页表项(PTE)存储下一级页表的 物理地址(PA) 或最终页的物理地址。
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc)
参数 含义
pagetable 根页表(即 satp 指向的页表)
va 虚拟地址
alloc 如果中间页表不存在,是否允许自动 kalloc
-
RISC-V 使用三级页表:level 2 → level 1 → level 0
-
每级页表有 512 项,对应 VA 中的不同段(用
PX(level, va)
取出)
PGROUNDDOWN(a) = a & ~(PGSIZE - 1)
PGSIZE - 1 就是 0xFFF(即 4095,低 12 位全是 1)
~(PGSIZE - 1) 就是 0xFFFFF000,高位全是 1,低 12 位全是 0
a & ~(PGSIZE - 1) 会把 a 的低 12 位清零
页表项(PTE)如何存储物理地址
一个 PTE 是一个 64 位的数。其结构如下:
| 63 ... 10 | 9 ... 0 | | PPN (物理页号) | 标志位 |
PTE 到物理地址的过程是这样的
uint64 ppn = *pte >> 10; // 去掉低 10 位的标志
uint64 pa = ppn << 12; // 得到物理页起始地址
页号 * 页大小
示例:
第一个页 页号为1 页大小固定为4K 0x1000 ~ 0x1FFF
(4096 字节大小)
pa = ppn × 4096 = ppn × 2¹² = ppn << 12
12288 = 0x3000 怎么快速计算出
4096 = 0x1000
(因为16^3 = 4096
)。
-
分解 12288:
-
12288 ÷ 4096 = 3
→ 表示有 3 个 0x1000。 -
因此:
3 × 0x1000 = 0x3000
。
-
下次遇到类似计算,直接问自己:
“这个数包含几个 4096?” → 答案就是 0xN000
中的 N
理解 initcode.S
目前还看不懂啊
c中指针与数组的关系
帮我理解一下 数组和指针的关系以及操作我对这里还是有点模糊就是 pte_t pte = pagetable[i]; typedef uint64 *pagetable_t; // 512 PTEs
typedef uint64* pagetable_t; // 页表是 uint64 的指针,指向 512 个 PTE(页表项)
pagetable_t pagetable; // 等价于:uint64* pagetable;
pagetable[i] 等价于 *(pagetable + i)
-
pagetable
是一个指针,指向第一个 PTE; -
pagetable + i
就是跳过 i 个 PTE 的位置; -
*
就是取出这个位置的值; -
所以
pagetable[i]
就是第 i 个页表项(类型是 uint64,也就是 pte_t);
内存布局图! GDB
新的理解
2025-06-03
目前看这张图有了更深的理解
字节(Byte) 是内存中最小的可寻址单位,1 字节 = 8 位(bit)。
char *p = malloc(4096);
向操作系统要了 4096 字节 的内存,地址存在p
中。PTE : PPN(物理页号)+ flag(权限)
最终物理地址 PA 是:
PA = PPN × 页大小 + offset
PTE 中的
PPN + flag
其实就是:这页该映射到哪里,能不能读写等页号决定你在哪页,offset 决定你在页内哪个字节
假设一个虚拟地址 VA =
0x00001234
这个地址属于第几页?
0x00001234 / 4096 = 1(整除)
说明在第 1 页内。
它在这一页内偏移多少个字节?
offset = 0x00001234 % 4096 = 0x234(十进制是564)
页里装的是什么?
页里装的是你程序运行时真实用的数据,比如:
malloc
分配的内存假设你运行一个 C 程序,它调用了
int *a = malloc(8192);
,即申请了 8KB 的堆内存:操作系统会为你分配 两页物理页(2 × 4KB = 8192 字节)
虚拟地址可能是:
0x40000000 ~ 0x40001FFF
(2 页)页表中会记录:这两个虚拟页 → 映射到两个物理页
如果没有“页”的概念,会怎样?
那操作系统要按字节或者按更细粒度来管理内存,比如每次内存映射都搞成这样:
0x40000000 → 0xA0000000 0x40000001 → 0xA0000001 0x40000002 → 0xA0000002
结果就是:
页表会非常巨大(一个进程 4GB 虚拟地址 ➜ 就要 2³² 个条目)
CPU 查表慢爆了,性能炸裂
存储这些映射表会占用大量内存
不好做权限管理、缓存对齐、置换管理
“页”让地址映射从字节级 ➜ 页级,大大降低了复杂度、提高了效率。
没有页的概念,页表就会爆炸,系统根本跑不起来。页是虚拟内存机制的支柱。
理解为什么要用页管理
0x00000000
0x00000001
...
0xFFFFFFFF
如果你按 字节映射
就表示你要为每个虚拟地址 单独建立一个映射关系:
所以你要存储 2³² 条映射表项(PTE),一共就是 4,294,967,296 条!
每个页表项(PTE)如果是 8 字节(常见情况),
那么你总共需要的页表大小是:
8 字节 × 2³² = 32GB 的页表!
如果一页是 4KB(即 2¹² 字节),那么:
总共 2³² / 2¹² = 2²⁰ 页
你只需要 2²⁰ 条页表项 = 1048576 条 PTE
乘上 8 字节 ➜ 总页表大小只需 8MB(可以接受)
一条 PTE(页表项) 管理一整页(4KB)内的所有虚拟地址,
当程序访问某个地址时,用:
物理地址 = PPN × 页大小 + 页内偏移
问题
举个实际例子:
你程序调用:
char *p = malloc(1); // 只要 1 字节
但操作系统会为它分配一整页(4KB),这时:
实际只用到 1 字节
但分配了 4096 字节
剩下的 4095 字节都闲着,这就是内部碎片
问题
虚拟地址会存放数据吗? 如果是的话那么虚拟地址中存放的数据不也会放在内存中的某些地方吗
答:
✅ 你访问的是虚拟地址,但数据永远存放在物理内存的某个位置。虚拟地址只是给你看的假象,实际操作会被MMU 转换成物理地址再操作内存。
理解freewalk
什么是 叶子页表 和 下一层页表
什么是“叶子页表项”(Leaf PTE)
页表项(PTE)指向的是一个真正的物理页,比如某个用户的数据页、代码页。
它的权限位会设置
PTE_R / PTE_W / PTE_X
中的至少一个。表示这就是最终的映射,没有下一层了。
什么是“非叶子页表项”(Middle PTE)
页表项指向的是下一层页表的地址,比如从 L2 → L1,或者 L1 → L0。
它没有设置 RWX 权限,只有
PTE_V
。它代表的是页表结构,还要继续往下走一层