Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← 系统与高性能 / Systems & Performance

xv6 源码学习 / xv6 Source Learning

1. xv6 源码学习专题 / xv6 Source Learning

2. xv6 学习路线:从 C 指针到操作系统源码 / xv6 Learning Roadmap

3. 读 xv6 必备的 C 语言和指针基础 / C And Pointers For xv6

4. 读 xv6 必备的 RISC-V 基础 / RISC-V Basics For xv6

5. 如何阅读 xv6 源码 / How To Read xv6 Source

6. xv6 启动与内核入口 / xv6 Boot And Kernel Entry

7. xv6 内存布局与页表 / xv6 Memory Layout And Page Tables

8. xv6 trap、中断与系统调用 / xv6 Traps Interrupts And System Calls

9. xv6 进程与 struct proc / xv6 Process And struct proc

10. xv6 上下文切换与调度器 / xv6 Context Switch And Scheduler

11. xv6 sleep/wakeup、wait 与 exit / xv6 Sleep Wakeup Wait And Exit

12. xv6 锁与并发 / xv6 Locks And Concurrency

13. xv6 文件描述符与 inode / xv6 File Descriptor And Inode

14. xv6 文件系统与日志 / xv6 File System And Log

15. xv6 设备驱动、console、UART 与磁盘 / xv6 Device Driver Console UART And Disk

16. xv6 用户程序与 shell / xv6 User Programs And Shell

17. 用 QEMU/GDB 调试 xv6 / Debugging xv6 With QEMU And GDB

18. 从 xv6 过渡到 Linux 0.11 / Bridge From xv6 To Linux 0.11

19. xv6 专题总结与实践项目 / xv6 Summary And Practice Projects

本页目录

读 xv6 必备的 C 语言和指针基础 / C And Pointers For xv6 ​

1. 为什么读 xv6 会被 C 指针卡住 ​

xv6 的代码量不算大。

但它对 C 语言的使用非常内核化。

这里说的“内核化”不是语法更高级。

恰恰相反,xv6 使用的 C 很朴素。

难点在于:每个指针背后都对应一个真实的内核对象、生命周期和同步规则。

你看到的不是普通练习题里的:

c
int *p;
1

而是:

c
struct proc *p;
struct file *f;
struct inode *ip;
pagetable_t pagetable;
uint64 dstva;
char *src;
1
2
3
4
5
6

这些变量每一个都连接着操作系统概念。

struct proc *p 可能指向进程表里的一个进程槽位。

struct file *f 可能指向全局打开文件表中的对象。

struct inode *ip 可能指向 inode cache 中的 inode。

pagetable_t 代表页表。

uint64 dstva 可能只是一个用户虚拟地址数值,不是内核可以直接访问的指针。

如果把这些东西都粗暴理解成“地址”,很快就会迷路。

这篇文章只解决一个目标:

让你读 xv6 时,能看懂 C 代码表达的对象关系。

总路线是:

text
C syntax
  |
  v
object and memory
  |
  v
pointer relation
  |
  v
xv6 structure fields
  |
  v
kernel ownership and lifetime
  |
  v
safe reading of xv6 source
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这里不会完整讲 C 语言。

我们只讲读 xv6 最需要的部分。

1.1 本文基础词小注释 ​

读 C 指针时,先别急着觉得自己必须“完全懂指针”。

先把下面几个词分清。

text
对象
  |
  +-- object
  +-- 内存里一块有意义的数据
  +-- 例如一个 struct proc 对象

地址
  |
  +-- address
  +-- 内存位置的编号
  +-- 指针变量里保存的通常就是地址

指针
  |
  +-- pointer
  +-- 保存地址的变量
  +-- struct proc *p 表示 p 指向一个 struct proc

字段
  |
  +-- struct 里的成员
  +-- p->pid 表示 p 指向的进程对象里的 pid 字段

生命周期
  |
  +-- 一个对象从创建、使用到释放的过程
  +-- 内核指针是否安全,和生命周期强相关
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

最基础的一张图:

text
pointer variable p
  |
  | stores address
  v
real object in memory
1
2
3
4
5

2. 先分清对象、地址和值 ​

C 语言里最容易混的三个词是:

  • 对象。
  • 地址。
  • 值。

对象是内存中一块有类型含义的数据。

地址是这块数据所在位置的编号。

值是变量当前保存的内容。

看一个简单例子:

c
int x = 10;
int *p = &x;
1
2

可以画成:

text
memory

+---------+        +---------+
| x = 10  | <----- | p = &x  |
+---------+        +---------+
 address A          address B
1
2
3
4
5
6

x 是一个 int 对象。

&x 是 x 的地址。

p 是一个指针变量。

p 自己也是对象。

p 的值是 x 的地址。

这句话非常重要:

指针变量本身也占内存。

它保存的是另一个对象的地址。

在 xv6 里,这种关系到处都是。

例如:

c
struct proc *p;
1

p 是一个指针变量。

它的值应该是某个 struct proc 对象的地址。

这个 struct proc 对象通常位于全局数组 proc[NPROC] 的某个槽位中。

text
p
|
v
+-------------------+
| struct proc slot  |
| pid               |
| state             |
| pagetable         |
| trapframe         |
+-------------------+
1
2
3
4
5
6
7
8
9
10

所以读 xv6 时,不要只问“这个指针是什么”。

要问:

text
这个指针变量在哪里?
它的值是什么地址?
这个地址指向什么类型的对象?
这个对象是谁创建的?
这个对象什么时候销毁?
1
2
3
4
5

3. . 和 -> 的区别 ​

读 xv6 时会频繁看到:

c
p->state
p->pid
p->trapframe
cpus[cpuid()].proc
1
2
3
4

先讲 .。

如果你有一个结构体对象:

c
struct point {
  int x;
  int y;
};

struct point a;
a.x = 1;
1
2
3
4
5
6
7

a.x 表示访问对象 a 的字段 x。

再看 ->。

如果你有一个指向结构体的指针:

c
struct point *p = &a;
p->x = 2;
1
2

p->x 等价于:

c
(*p).x
1

先用 *p 找到 p 指向的结构体对象。

再用 .x 访问字段。

图如下:

text
p
|
v
+----------------+
| struct point a |
| x              |
| y              |
+----------------+
1
2
3
4
5
6
7
8

所以:

text
a.x
  |
  +-- a is object

p->x
  |
  +-- p is pointer
  +-- p points to object
1
2
3
4
5
6
7
8

xv6 里常见写法:

c
struct proc *p = myproc();
p->killed = 1;
1
2

这表示:

myproc() 返回当前进程的 struct proc 指针。

然后通过这个指针修改当前进程的 killed 字段。

如果你读成“p 里面有 killed”,就会有点模糊。

更准确地说:

p 指向的那个 struct proc 对象里面有 killed 字段。

4. 结构体:内核对象的形状 ​

结构体是 xv6 最重要的 C 语言机制。

因为操作系统需要管理很多对象。

例如:

  • 进程。
  • CPU。
  • 文件。
  • inode。
  • buffer。
  • lock。
  • pipe。

C 没有类。

xv6 用 struct 描述这些对象。

例如进程:

text
struct proc
  |
  +-- identity
  +-- state
  +-- memory
  +-- scheduling context
  +-- opened files
  +-- parent relation
1
2
3
4
5
6
7
8

文件:

text
struct file
  |
  +-- type
  +-- reference count
  +-- readable/writable
  +-- inode pointer
  +-- pipe pointer
  +-- offset
1
2
3
4
5
6
7
8

buffer:

text
struct buf
  |
  +-- valid flag
  +-- disk flag
  +-- device id
  +-- block number
  +-- sleeplock
  +-- data block
1
2
3
4
5
6
7
8

结构体定义不是创建对象。

这是 C 初学者常犯的错误。

c
struct proc {
  int pid;
};
1
2
3

这只是定义一种类型。

它告诉编译器:struct proc 这种对象里面有一个 pid 字段。

真正创建对象要看变量定义。

例如:

c
struct proc proc[NPROC];
1

这才创建了一个数组。

数组里有 NPROC 个 struct proc 对象。

图如下:

text
proc
|
v
+---------+---------+---------+---------+-----+
| proc[0] | proc[1] | proc[2] | proc[3] | ... |
+---------+---------+---------+---------+-----+
1
2
3
4
5
6

如果有:

c
struct proc *p = &proc[3];
1

那就是:

text
p
|
v
proc[3]
1
2
3
4

读 xv6 时,要不断区分:

text
type definition
  |
  +-- describes shape

object definition
  |
  +-- allocates storage

pointer variable
  |
  +-- stores address of object
1
2
3
4
5
6
7
8
9
10
11

5. 嵌入结构体和指针字段 ​

看一个简化版 struct proc。

c
struct proc {
  struct spinlock lock;
  enum procstate state;
  pagetable_t pagetable;
  struct trapframe *trapframe;
  struct context context;
  struct file *ofile[NOFILE];
};
1
2
3
4
5
6
7
8

这里有几种不同字段。

第一种:嵌入结构体。

c
struct spinlock lock;
struct context context;
1
2

表示 struct proc 对象内部直接包含这些对象。

text
+--------------------------------+
| struct proc                    |
|                                |
| +----------------------------+ |
| | struct spinlock lock       | |
| +----------------------------+ |
|                                |
| +----------------------------+ |
| | struct context context     | |
| +----------------------------+ |
+--------------------------------+
1
2
3
4
5
6
7
8
9
10
11

第二种:指针字段。

c
struct trapframe *trapframe;
1

表示 struct proc 内部只保存一个地址。

真正的 struct trapframe 对象在别处。

text
+-----------------------------+
| struct proc                 |
| trapframe ------------------+----+
+-----------------------------+    |
                                   v
                         +------------------+
                         | struct trapframe |
                         +------------------+
1
2
3
4
5
6
7
8

第三种:数组里的指针。

c
struct file *ofile[NOFILE];
1

这不是“一个指向数组的指针”。

它是一个数组。

数组里的每个元素都是 struct file *。

text
proc.ofile
|
v
+-------+-------+-------+-------+-----+
| fd 0  | fd 1  | fd 2  | fd 3  | ... |
+---+---+---+---+---+---+---+---+-----+
    |       |       |
    v       v       v
  file    file    file
1
2
3
4
5
6
7
8
9

第四种:类型别名。

c
pagetable_t pagetable;
1

pagetable_t 在 xv6 中是一个类型别名。

它通常代表页表地址。

虽然写起来不像 *,但本质上和地址有关。

所以不要只看字段表面。

要追踪类型定义。

text
field syntax
  |
  v
field type
  |
  v
actual meaning in xv6
1
2
3
4
5
6
7

6. 数组:xv6 为什么喜欢固定大小表 ​

xv6 经常使用固定大小数组。

例如:

text
proc[NPROC]
ftable.file[NFILE]
bcache.buf[NBUF]
itable.inode[NINODE]
1
2
3
4

这是一种很教学化的设计。

真实系统会更复杂。

但固定数组让你更容易看清对象生命周期。

以进程表为例:

text
proc[NPROC]

+----------+----------+----------+----------+----------+
| UNUSED   | RUNNABLE | SLEEPING | RUNNING  | ZOMBIE   |
+----------+----------+----------+----------+----------+
 proc[0]    proc[1]    proc[2]    proc[3]    proc[4]
1
2
3
4
5
6

UNUSED 表示这个槽位空闲。

allocproc() 会遍历数组,找一个 UNUSED 槽位。

找到后把它变成可使用状态。

所以新进程不是从天上掉下来。

它来自进程表中的一个槽位。

文件表也类似。

text
global file table

+----------+----------+----------+----------+
| ref = 0  | ref = 2  | ref = 1  | ref = 0  |
+----------+----------+----------+----------+
 file[0]    file[1]    file[2]    file[3]
1
2
3
4
5
6

引用计数 ref 为 0,表示槽位空闲。

filealloc() 会找一个空闲文件对象。

进程的 fd 表再指向它。

text
process A ofile[3] ----+
                       v
global file[1] ref = 2
                       ^
process B ofile[4] ----+
1
2
3
4
5

这里能看出指针的重要性。

两个进程可以通过不同 fd 指向同一个 struct file。

这就是为什么 filedup() 和 fileclose() 要维护引用计数。

7. 头文件:声明、类型和跨文件协作 ​

xv6 的头文件很多。

初学者容易觉得头文件只是“放重复代码”。

这不对。

头文件的核心作用是提供声明。

C 编译器按文件编译。

当 proc.c 调用 kalloc() 时,编译器需要先知道 kalloc() 的函数原型。

这个声明通常来自 defs.h。

text
proc.c
  |
  | includes defs.h
  v
knows kalloc declaration
  |
  v
can call function implemented in kalloc.c
1
2
3
4
5
6
7
8

头文件大致分几类。

第一类:基础类型。

text
types.h
  |
  +-- uint
  +-- ushort
  +-- uchar
  +-- uint64
1
2
3
4
5
6

第二类:参数常量。

text
param.h
  |
  +-- NPROC
  +-- NOFILE
  +-- NFILE
  +-- NBUF
1
2
3
4
5
6

第三类:结构体定义。

text
proc.h
file.h
fs.h
buf.h
spinlock.h
sleeplock.h
1
2
3
4
5
6

第四类:函数声明。

text
defs.h
1

第五类:硬件和地址常量。

text
riscv.h
memlayout.h
virtio.h
1
2
3

读源码时,如果看到一个类型不懂,不要猜。

用搜索找到它定义在哪里。

推荐路径:

text
unknown type or function
  |
  v
rg "name"
  |
  v
find header definition
  |
  v
find implementation
  |
  v
return to original code
1
2
3
4
5
6
7
8
9
10
11
12
13

8. typedef:名字变短,但含义不能丢 ​

typedef 可以给类型起别名。

这让代码更短。

但也可能隐藏复杂性。

xv6 中常见:

c
typedef uint64 pte_t;
typedef uint64 *pagetable_t;
1
2

这意味着:

pte_t 是页表项类型。

pagetable_t 是指向 uint64 的指针。

也就是说:

c
pagetable_t pagetable;
1

表面上没有 *。

但它实际是指针类型。

可以画成:

text
pagetable
  |
  v
+-------+-------+-------+-------+
| PTE 0 | PTE 1 | PTE 2 | ...   |
+-------+-------+-------+-------+
1
2
3
4
5
6

这就是为什么读内核 C 代码不能只看变量名。

一定要追类型定义。

遇到 typedef 时问:

text
alias name
  |
  v
original type
  |
  v
is it a value, pointer, array, or function pointer?
1
2
3
4
5
6
7

pagetable_t 就是典型例子。

它看起来像普通值。

实际代表页表根地址。

这会影响你理解 walk()、mappages()、uvmcopy()。

9. 指针和所有权:谁负责释放对象 ​

普通 C 教程经常讲:

c
malloc()
free()
1
2

xv6 内核里没有完整 libc。

它有自己的对象管理方式。

物理页由 kalloc.c 管。

进程槽位由 proc.c 管。

打开文件对象由 file.c 管。

inode 由 fs.c 和 inode cache 管。

buffer 由 bio.c 管。

所以看到一个指针时,不只问它指向哪里。

还要问谁拥有它。

text
pointer
  |
  +-- points to object
  |
  +-- object has owner or manager
  |
  +-- manager decides allocation and release
1
2
3
4
5
6
7

以 trapframe 为例。

进程结构体里有:

c
struct trapframe *trapframe;
1

这个对象通常由进程分配阶段准备。

进程释放时也要释放。

所以 trapframe 的生命周期和进程强相关。

以 struct file *ofile[NOFILE] 为例。

进程持有文件指针。

但 struct file 对象属于全局文件表。

进程退出时要关闭这些文件。

关闭不一定立刻销毁底层 inode。

因为别的进程可能还持有引用。

text
proc
  |
  +-- ofile[fd] ----> struct file
                         |
                         +-- ref count
                         +-- inode pointer
1
2
3
4
5
6

引用计数可以理解成:

text
number of active references
  |
  v
when ref reaches 0, object can be reused or released
1
2
3
4

读 xv6 时,引用计数是理解 file、inode、buffer 的关键。

10. 用户地址不是普通内核指针 ​

这是读 xv6 必须早早建立的概念。

用户程序传给内核的地址,不能随便当作内核指针用。

例如:

c
read(fd, buf, n);
1

这里的 buf 是用户程序里的地址。

对用户程序来说,它是一个 char *。

但系统调用进入内核后,这个地址只是一个用户虚拟地址。

内核要把数据写到这个用户地址,需要通过进程页表检查映射。

所以 xv6 使用:

text
copyin
copyout
copyinstr
1
2
3

关系图:

text
user virtual address
  |
  | belongs to process pagetable
  v
copyin/copyout
  |
  | walk page table
  | validate mapped pages
  v
kernel buffer
1
2
3
4
5
6
7
8
9
10

不要把下面两种东西混为一谈。

text
char *p
  |
  +-- may be a kernel pointer
  +-- can be dereferenced only if valid in current address space

uint64 user_va
  |
  +-- numerical user virtual address
  +-- must be translated/checked through user pagetable
1
2
3
4
5
6
7
8
9

这也是为什么很多 xv6 函数参数用 uint64 表示用户地址。

它提醒你:这只是一个地址数值。

不是内核可以直接访问的 C 指针。

11. 函数指针和系统调用表 ​

xv6 的系统调用分发会用到函数指针表。

函数指针听起来吓人。

但模型很简单。

普通指针指向数据。

函数指针指向代码入口。

text
data pointer
  |
  v
data object

function pointer
  |
  v
function code
1
2
3
4
5
6
7
8
9

系统调用表大致可以理解为:

text
syscall number
  |
  v
array index
  |
  v
function pointer
  |
  v
sys_* implementation
1
2
3
4
5
6
7
8
9
10

例如:

text
SYS_fork  ----> sys_fork
SYS_exit  ----> sys_exit
SYS_read  ----> sys_read
SYS_write ----> sys_write
1
2
3
4

当用户程序执行系统调用时:

text
a7 register contains syscall number
  |
  v
syscall.c reads a7
  |
  v
looks up syscalls[num]
  |
  v
calls selected function
1
2
3
4
5
6
7
8
9
10

这就是函数指针的用途。

它让内核可以根据编号动态选择函数。

初学时不需要深入 C 声明语法的所有怪异角落。

只要先知道:

text
array of function pointers
  |
  v
dispatch table
1
2
3
4

这就够读系统调用路径。

12. 指针数组和数组指针不要混 ​

xv6 常见的是指针数组。

例如:

c
struct file *ofile[NOFILE];
1

读法是:

ofile 是一个数组。

数组有 NOFILE 个元素。

每个元素是 struct file *。

图:

text
ofile
|
v
+-------------+-------------+-------------+
| file ptr 0  | file ptr 1  | file ptr 2  |
+------+------+------+------+------+------+
       |             |             |
       v             v             v
    file obj      file obj      null
1
2
3
4
5
6
7
8
9

这和下面这种东西不一样:

c
struct file (*p)[NOFILE];
1

这个才是指向数组的指针。

xv6 初学阶段通常不需要纠结这种复杂声明。

但要记住 struct file *ofile[NOFILE] 的本质。

它不是一个文件。

也不是一个指针。

它是“很多文件指针槽位”。

每个进程都有自己的 ofile[]。

这就是 fd 表。

text
process A
  |
  +-- fd 0 -> console file
  +-- fd 1 -> console file
  +-- fd 2 -> console file
  +-- fd 3 -> some inode file

process B
  |
  +-- fd 0 -> console file
  +-- fd 1 -> pipe file
1
2
3
4
5
6
7
8
9
10
11

不同进程的 fd 数字可以相同。

但它们位于不同进程的 ofile[] 中。

所以 fd 是进程局部的。

struct file 是内核全局对象。

13. void *、char * 和内存拷贝 ​

C 里 char * 经常用来表示字节地址。

内核里很多数据复制、清零、移动都绕不开它。

例如:

text
memset
memmove
copyin
copyout
1
2
3
4

char * 的特点是按字节移动。

如果一个函数需要处理任意类型的内存区域,经常会转成字节视角。

text
typed object view
  |
  +-- struct proc
  +-- struct file
  +-- struct inode

byte view
  |
  +-- char *
  +-- copy n bytes
  +-- clear n bytes
1
2
3
4
5
6
7
8
9
10
11

例如清空一块结构体内存:

c
memset(p, 0, sizeof(*p));
1

这句话要仔细读。

p 是指针。

*p 是 p 指向的对象。

sizeof(*p) 是这个对象的大小。

所以这句意思是:

把 p 指向的整个对象清零。

不是把指针变量 p 自己清零。

图:

text
p
|
v
+-------------------+
| object bytes      |
| all set to zero   |
+-------------------+
1
2
3
4
5
6
7

这类写法在内核源码里很常见。

初学者要慢慢习惯。

14. sizeof:看对象大小,不要凭感觉 ​

sizeof 是读 C 内核代码必须熟悉的操作符。

常见写法:

c
sizeof(struct proc)
sizeof(*p)
sizeof(p)
sizeof(buf)
1
2
3
4

这些不是一回事。

假设:

c
struct proc *p;
1

那么:

text
sizeof(p)
  |
  +-- size of pointer variable
  +-- usually 8 bytes on xv6-riscv

sizeof(*p)
  |
  +-- size of struct proc object
  +-- much larger than pointer
1
2
3
4
5
6
7
8
9

这点非常重要。

如果你要清空 p 指向的对象,用:

c
memset(p, 0, sizeof(*p));
1

如果误写成:

c
memset(p, 0, sizeof(p));
1

那只会清空指针大小的几个字节。

这在真实 C 程序里是常见 bug。

xv6 代码相对干净。

读它时正好可以建立正确直觉。

15. 链接关系:一个对象怎么指向另一个对象 ​

操作系统不是一堆孤立结构体。

它是一张对象图。

以进程为中心:

text
struct proc
  |
  +-- parent ------------> struct proc
  |
  +-- pagetable ---------> page table pages
  |
  +-- trapframe ---------> struct trapframe
  |
  +-- ofile[fd] ---------> struct file
  |                         |
  |                         +-- inode ----> struct inode
  |                         |
  |                         +-- pipe -----> struct pipe
  |
  +-- cwd ---------------> struct inode
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这张图比单独背字段有用。

因为 xv6 的很多函数就是在维护这张图。

fork() 会复制或增加引用:

text
parent proc
  |
  +-- user memory copied
  +-- trapframe copied
  +-- open files filedup
  +-- cwd idup
  |
  v
child proc
1
2
3
4
5
6
7
8
9

exit() 会释放或关闭引用:

text
exiting proc
  |
  +-- close open files
  +-- release cwd
  +-- reparent children
  +-- become ZOMBIE
  +-- wait for parent to reap
1
2
3
4
5
6
7

exec() 会替换用户地址空间:

text
same proc
  |
  +-- old user pagetable removed
  +-- new ELF loaded
  +-- new user stack built
  +-- pid remains
  +-- open files usually remain
1
2
3
4
5
6
7

所以学进程,绝不能只看 pid。

进程是对象关系的中心。

16. 生命周期:创建、使用、释放 ​

每个内核对象都有生命周期。

读源码时可以用统一模板。

text
object lifecycle
  |
  +-- where allocated?
  +-- how initialized?
  +-- who can reference it?
  +-- what lock protects it?
  +-- when state changes?
  +-- when released?
1
2
3
4
5
6
7
8

进程生命周期:

text
UNUSED
  |
  v
USED
  |
  v
RUNNABLE
  |
  v
RUNNING
  |
  +--> SLEEPING
  |      |
  |      v
  |   RUNNABLE
  |
  v
ZOMBIE
  |
  v
UNUSED
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

文件对象生命周期:

text
ref = 0
  |
  v
filealloc sets ref = 1
  |
  v
filedup increases ref
  |
  v
fileclose decreases ref
  |
  v
ref reaches 0
  |
  v
slot reusable
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

buffer 生命周期:

text
not cached
  |
  v
bread finds or allocates buf
  |
  v
locked buffer
  |
  v
read/write data
  |
  v
brelse releases lock
  |
  v
buffer remains cached
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

如果你不知道对象生命周期,就很难理解锁。

因为锁通常保护生命周期中的关键状态。

17. 宏和常量:不要跳过大写名字 ​

xv6 里有很多大写名字。

例如:

text
NPROC
NOFILE
NFILE
NBUF
PGSIZE
MAXPATH
KERNBASE
PHYSTOP
TRAMPOLINE
TRAPFRAME
1
2
3
4
5
6
7
8
9
10

它们通常来自头文件。

大写名字多半是宏或常量。

不要跳过。

这些常量经常决定数组大小、地址边界和系统规模。

例如:

text
NPROC
  |
  +-- maximum number of processes

NOFILE
  |
  +-- maximum open files per process

NFILE
  |
  +-- maximum open file objects in global file table

PGSIZE
  |
  +-- page size

TRAMPOLINE
  |
  +-- special virtual address for trampoline code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

读源码时遇到宏,用搜索查定义。

尤其是内存管理。

地址常量决定你看到的地址关系。

如果跳过它们,页表代码会变成魔法。

18. 锁字段:结构体里的同步边界 ​

很多 xv6 结构体都有锁。

例如:

c
struct proc {
  struct spinlock lock;
  ...
};
1
2
3
4

锁不是装饰。

锁表示这个对象会被并发访问。

它保护某些字段的正确关系。

例如进程状态:

text
proc.lock
  |
  +-- protects state
  +-- protects chan
  +-- protects killed
  +-- protects xstate
  +-- coordinates sleep/wakeup/scheduler
1
2
3
4
5
6
7

理解锁时,不要只问:

“这里为什么加锁?”

要问:

text
What invariant is protected?
Who else may access this field?
Could another CPU change it now?
Could an interrupt handler observe it?
Could sleep/wakeup lose a transition?
1
2
3
4
5

比如 sleep(chan, lock) 这类函数,难点不是 C 语法。

难点是并发时序。

text
process decides to sleep
  |
  v
must not miss wakeup
  |
  v
lock coordinates condition check and state change
1
2
3
4
5
6
7

所以看到锁字段时,要把它当成对象关系的一部分。

19. 常见 C 阅读误区 ​

误区一:看到 * 就只想“取值”。

在声明里,* 表示这是指针类型。

在表达式里,* 表示解引用。

c
struct proc *p;  // declaration
(*p).pid;        // dereference expression
1
2

误区二:看到数组名就认为它总是指针。

数组名在很多表达式中会退化为指针。

但数组对象本身和指针变量不是同一个东西。

ofile 是数组。

ofile[0] 是数组元素。

ofile[0] 的类型是 struct file *。

误区三:认为结构体定义会分配内存。

结构体定义只描述类型。

变量定义才分配对象。

误区四:把用户虚拟地址当内核指针。

这是内核学习中的危险误区。

用户地址要通过页表检查和复制。

误区五:只看字段名,不查类型。

xv6 字段名通常简短。

只有查类型,才知道它是对象、指针、数组还是地址值。

误区六:认为 typedef 后就不是指针。

pagetable_t 表面没有星号。

但它的底层定义可能就是指针。

误区七:忽略引用计数。

引用计数决定对象能不能释放。

如果只看指针,不看 ref,文件系统对象关系会很混乱。

20. 读 xv6 指针的固定流程 ​

以后读每个结构体字段,都按这个流程。

text
field
  |
  v
read its type
  |
  v
is it embedded object?
  |
  +-- yes: object lives inside parent
  |
  +-- no
       |
       v
is it pointer?
  |
  +-- yes: find pointed object type and owner
  |
  +-- no
       |
       v
is it scalar state/value?
  |
  +-- yes: find who writes it and what invariant it affects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

对函数参数也一样。

text
function parameter
  |
  v
is it input value?
  |
  v
is it output pointer?
  |
  v
is it kernel pointer?
  |
  v
is it user virtual address?
1
2
3
4
5
6
7
8
9
10
11
12
13

例如:

c
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len);
1

逐个看:

pagetable 是页表。

dstva 是目标用户虚拟地址。

src 是内核源缓冲区指针。

len 是复制字节数。

这比只看函数名有效得多。

21. 用 struct proc 做一次完整练习 ​

现在用 struct proc 练习。

目标不是背字段。

目标是建立对象图。

简化模型:

text
struct proc
  |
  +-- lock
  +-- state
  +-- pid
  +-- parent
  +-- pagetable
  +-- trapframe
  +-- context
  +-- ofile[]
  +-- cwd
1
2
3
4
5
6
7
8
9
10
11

字段分类:

text
embedded object
  |
  +-- lock
  +-- context

scalar value
  |
  +-- state
  +-- pid

pointer to same kind of object
  |
  +-- parent

memory address / pointer-like
  |
  +-- pagetable
  +-- trapframe

array of pointers
  |
  +-- ofile[]

pointer to filesystem object
  |
  +-- cwd
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

对象图:

text
proc p
  |
  +-- parent -----------> proc parent
  |
  +-- pagetable --------> page table
  |
  +-- trapframe --------> trapframe page
  |
  +-- ofile[0] ---------> file object
  |
  +-- ofile[1] ---------> file object
  |
  +-- cwd --------------> inode object
1
2
3
4
5
6
7
8
9
10
11
12
13

生命周期图:

text
allocproc
  |
  v
initialize fields
  |
  v
make RUNNABLE
  |
  v
run / sleep / wakeup
  |
  v
exit
  |
  v
wait frees resources
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

如果你能把这三张图画出来,proc.h 就不再吓人。

后面读 proc.c 会顺很多。

22. 用 struct file 做第二次练习 ​

文件对象是第二个适合练习的结构。

简化模型:

text
struct file
  |
  +-- type
  +-- ref
  +-- readable
  +-- writable
  +-- pipe
  +-- ip
  +-- off
1
2
3
4
5
6
7
8
9

分类:

text
state/value
  |
  +-- type
  +-- ref
  +-- readable
  +-- writable
  +-- off

pointer
  |
  +-- pipe
  +-- ip
1
2
3
4
5
6
7
8
9
10
11
12

对象关系:

text
proc.ofile[fd]
  |
  v
struct file
  |
  +-- if type == FD_PIPE
  |      |
  |      v
  |    struct pipe
  |
  +-- if type == FD_INODE
         |
         v
       struct inode
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这里要注意:

同一个结构体可以根据 type 字段表示不同种类的文件对象。

如果 type 是 FD_PIPE,就看 pipe 字段。

如果 type 是 FD_INODE,就看 ip 字段。

这是一种 C 里常见的手动多态。

没有 C++ 的虚函数。

但通过 type 字段选择不同逻辑。

text
type tag
  |
  v
choose valid fields
  |
  v
execute matching operation
1
2
3
4
5
6
7

23. 用页表参数做第三次练习 ​

现在看虚拟内存函数的参数。

比如:

c
int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len);
1

逐个解释。

pagetable 是用户进程页表。

dst 是内核目的缓冲区。

srcva 是用户虚拟地址。

len 是字节数。

图:

text
user address space
  |
  | srcva
  v
+------------------+
| user memory      |
+------------------+
        |
        | copyin walks pagetable
        v
+------------------+
| kernel dst       |
+------------------+
1
2
3
4
5
6
7
8
9
10
11
12
13

对应 copyout():

c
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len);
1

图:

text
kernel src
  |
  | copyout walks pagetable
  v
user dstva
1
2
3
4
5

不要被 char *dst 和 uint64 srcva 搞混。

一个是内核指针。

一个是用户虚拟地址数值。

读函数参数时必须看命名。

va 通常表示 virtual address。

pa 通常表示 physical address。

dst 和 src 要结合函数语义判断是内核还是用户。

24. 小实验:观察指针地址和对象复用 ​

可以做一个小实验。

在 kernel/proc.c 的进程分配路径中打印。

目标是观察:

  • struct proc *p 的地址。
  • pid。
  • state。
  • 槽位是否被复用。

示意输出可以像这样:

text
allocproc: p=0x... pid=3 state=USED
fork: parent=2 child=3
exit: pid=3 state=ZOMBIE
freeproc: p=0x... pid=3 state=UNUSED
1
2
3
4

观察重点不是具体地址数值。

重点是:

同一个 struct proc 槽位可能被不同生命周期的进程复用。

这能帮助你理解:

text
process identity
  |
  +-- pid changes across allocation
  |
  +-- struct proc slot may be reused
1
2
3
4
5

再做一个文件实验。

在 filealloc() 和 fileclose() 打印 struct file *f 和 ref。

观察:

text
open increases active file object
dup increases ref
close decreases ref
ref reaches 0 then file slot reusable
1
2
3
4

这些实验比背概念更能建立指针直觉。

25. 本篇总结 ​

读 xv6 需要的 C 语言重点不是花哨语法。

而是对象关系。

你要牢牢记住:

text
struct defines object shape
  |
  v
global arrays store many kernel objects
  |
  v
pointers connect objects
  |
  v
locks protect shared object state
  |
  v
lifetimes decide when pointers remain valid
1
2
3
4
5
6
7
8
9
10
11
12
13

对 xv6 来说,最重要的 C 阅读能力是:

  • 区分对象和值。
  • 区分对象和指针。
  • 区分嵌入字段和指针字段。
  • 区分数组和指针数组。
  • 追踪 typedef 背后的真实类型。
  • 明白用户虚拟地址不是普通内核指针。
  • 用所有权和生命周期理解指针。
  • 用对象图理解结构体字段。

下一篇进入:

text
03-riscv-basics-for-xv6.md
1

因为 C 对象关系解决的是“源码怎么表达”。

RISC-V 基础解决的是“CPU 怎么进入内核、怎么切换地址空间、怎么保存寄存器”。

两者合起来,才真正能读懂 xv6 的 trap、proc 和 scheduler。

最后更新于:

Pager
上一篇2. xv6 学习路线:从 C 指针到操作系统源码 / xv6 Learning Roadmap
下一篇4. 读 xv6 必备的 RISC-V 基础 / RISC-V Basics For xv6

持续记录,持续成长

Copyright © Tidenflow