MIT 6.1810 · 第 2 课

C 语言与 xv6

操作系统内核几乎都用 C 编写。本课聚焦于 xv6 内核中实际使用的 C 语言特性:内存布局、指针、数组、字符串、链表、位运算,以及最常见的 C 语言陷阱。

授课:nz (Nickolai Zeldovich) 日期:2025-01-23 难度:中等

为什么用 C 写操作系统?

C 的优势

C 的风险

学习识别这些陷阱,是成为系统程序员的基本功。

C 程序在 xv6 中的内存布局

参考 xv6 教材图 3.4,一个用户程序的地址空间由以下几段组成:

段内容属性
text机器码指令、只读数据R+X, 无 W
data已初始化的全局/静态变量R+W
stack函数局部变量、返回地址R+W, 向下增长
heapmalloc/sbrk 动态分配R+W, 向上增长

编译过程示例:cat.c

$ riscv64-linux-gnu-objdump -htr user/cat.o  # 目标文件 + 重定位记录
$ riscv64-linux-gnu-objdump -fp user/_cat     # 可执行文件头 + 段
$ riscv64-linux-gnu-objdump -S user/_cat      # 反汇编
$ riscv64-linux-gnu-nm -n user/_cat           # 符号表 + 地址

可执行文件使用 a.out 格式,包含 text、data、符号表、调试信息等段。ulib.o 是 xv6 的最小 C 库,提供 main() 入口和系统调用封装。

入口点 start

// ulib.c 中的 _start
void _start() {
    exit(main());  // 调用 main,返回后 exit
}

_start 是 ELF 入口点(user.ld 定义),由它调用用户的 main()。

C 指针

指针是内存地址。每个变量都有地址,可以通过指针间接访问:

int i = 10;
int *p = &i;    // p 保存 i 的地址
*p = 20;        // 通过指针修改 i
int a = *p;     // 通过指针读取 i

指针算术

char *c;
int *i;
// c+1 的地址 = c 的地址 + 1
// i+1 的地址 = i 的地址 + 4(RISC-V 上 int 为 4 字节)

指针加 1 的偏移量取决于指向类型的大小——这是 C 数组访问的基础。

结构体指针访问

struct point { int x; int y; };
struct point p = {1, 2};
struct point *pp = &p;
pp->x = 10;   // 等价于 (*pp).x = 10

C 数组

char buf[64];
buf[0] = 'a';
*(buf + 1) = 'b';   // 等价于 buf[1] = 'b'

C 数组是连续的内存,不检查边界,不能自动增长。数组访问 buf[i] 和指针访问 *(buf+i) 完全等价。

C 字符串

C 字符串是以 \0(字节 0)结尾的字符数组:

char *s = "hello";   // h e l l o \0
char buf[] = "world";  // 栈上数组

ulib.c 提供常用字符串函数:

// strlen 用数组索引
int strlen(char *s) {
    int n = 0;
    while (s[n]) n++;
    return n;
}

// strcmp 用指针
int strcmp(char *p, char *q) {
    while (*p && *p == *q)
        p++, q++;
    return (uchar)*p - (uchar)*q;
}

C 链表

xv6 内核大量使用链表。

单链表——kalloc.c 空闲页链表

// kernel/kalloc.c
struct run {
    struct run *next;  // 单链表指针
};

struct {
    struct run *head;  // 链表头
} kmem;

void kfree(void *pa) {
    struct run *r = (struct run*)pa;
    r->next = kmem.head;
    kmem.head = r;     // 头插法
}

void *kalloc(void) {
    struct run *r = kmem.head;
    if (r) kmem.head = r->next;
    return (void*)r;
}

双向链表——bio.c LRU buffer cache

// kernel/buf.h
struct buf {
    struct buf *prev;
    struct buf *next;
    // ...
};

void brelse(struct buf *b) {
    // 把 b 移到链表头部(LRU 策略)
    b->next->prev = b->prev;
    b->prev->next = b->next;
    b->next = bcache.head.next;
    b->prev = &bcache.head;
    bcache.head.next->prev = b;
    bcache.head.next = b;
}

位运算

C 提供四种基本位运算,直接映射到 RISC-V 指令:

10001 &  10000 = 10000    // 按位与:提取位
10001 |  10000 = 10001    // 按位或:设置位
10001 ^  10000 = 00001    // 按位异或:翻转位
~1000 = 0111              // 按位取反

在系统编程中,位运算常用于操作硬件寄存器:

// kernel/fcntl.h
#define O_RDONLY  0x000
#define O_WRONLY  0x001
#define O_RDWR    0x002
#define O_CREATE  0x200
#define O_TRUNC   0x400

// 组合标志位:fd = open("f", O_WRONLY | O_CREATE | O_TRUNC);

常见 C 语言陷阱

陷阱描述
use-after-free访问已释放的内存
double-free同一块内存被 free 两次
未初始化内存栈变量和 malloc 返回的内存不自动清零
buffer overflow写入超出数组边界
memory leak分配后不释放,最终耗尽内存
type confusion错误的类型转换导致解释错误

未初始化内存示例

void f() {
    int x;            // x 的值未定义!
    char buf[64];     // buf 内容未定义
    if (x == 0) ...   // 行为不确定
}

内核中尤其危险——未初始化的内存可能泄露敏感数据(如密钥),这就是著名的"内核栈信息泄露"漏洞。

关键字 static 与 void


知识检查

Q1:为什么操作系统内核通常用 C 而不是 Java/Go/Python?

Q2:设有 int arr[10]; int *p = arr;,p+3 的地址与 p 的地址相差多少字节?

Q3:以下哪项不是 xv6 中 C 链表的典型用途?

Q4:表达式 0x0F & 0x3C 的结果是?

Q5:以下哪项是典型的 C 安全漏洞?


本课要点

C 贴近硬件

C 是系统编程的"通用语"——直接映射指令、无运行时、完全控制内存。

指针是灵魂

指针、数组、字符串、链表本质上都是内存地址的操作。

位运算是工具

与、或、异或、取反——操作硬件寄存器、标志位的基础。

警惕 UB

use-after-free、buffer overflow、未初始化内存——这些是内核漏洞的温床。

延伸阅读