Lec 2 用C语言编程Xv6
总览
- C语言:一门”高级汇编“
- 数据表示
- 内存安全问题
- 指针: 带类型的整数
- 声明、定义 与
static - 字符串、常用库函数、结构体和位操作
- GDB: 调试内核的手法
- 内存抽象的层次:从总线到堆栈
- 自测清单
本讲定位:用C语言编程Xv6、用 GDB + QEMU 调试。本讲打牢两个工具基础——把 C 当作"高级汇编"来理解内存与指针,并掌握调试内核所需的 GDB 手法;最后从硬件总线一路抽象到栈与堆,建立"内存到底是什么"的层次图景。
0. 本讲脉络
C 作为"高级汇编" ──► 数据表示(端序/范围) ──► 内存三区(栈/堆/静态) ──► 指针(带类型的整数)
│
▼
GDB 调试内核(断点/单步/查寄存器查内存/TUI)
│
▼
内存抽象的层次:硬件总线 → 地址空间 → 栈与堆1. C 语言:一门"高级汇编"
- 更接近机器:C 代码几乎直接映射到机器指令;Python 隐藏了大量底层逻辑。
- 编译型而非解释型:C 直接在 CPU 上执行,无需运行时环境。
- 静态类型:类型与变量绑定(Python 中类型与值绑定),用于解释内存里那串原始字节;类型错误在编译期暴露,省去运行时检查 → 更快。
- 手动内存管理:
malloc/free,无垃圾回收。 int/float范围"具体但不确定"——依平台而定。
2. 数据表示
2.1 端序 (endianness)
0x12345678(= 305419896)在内存里怎么放?)按地址从低到高:
大端序 (big-endian):0x12, 0x34, 0x56, 0x78(高位字节在低地址)。
小端序 (little-endian):0x78, 0x56, 0x34, 0x12(低位字节在低地址)。
RISC-V 是小端序。
2.2 三类内存区域
| 区域 | 分配方式 | 默认初始化 |
|---|---|---|
| 栈 (*stack*) | 函数内局部变量;函数退出即销毁、内存可复用 | ❌ 不初始化(残留旧内容) |
| 堆 (*heap*) | 显式 malloc / free;释放后可被复用 | ❌ 不初始化 |
| 静态 (*static*) | 函数外变量 + static 变量;固定地址、单一副本 | ✅ 默认清零 |
3. 内存安全问题
C 把内存管理交给程序员,于是有一类典型 bug:
- 释放后使用 (use-after-free):释放后仍访问该内存。
- 重复释放 (double-free):对同一块释放两次。
- 使用未初始化内存:读到的是残留垃圾值。
- 缓冲区溢出 (buffer overflow):写越过数组末尾(Lec 22 砸栈攻击的根源)。
- 内存泄漏 (memory leak):分配后从不释放。
- 类型混淆 (type confusion):用错误的类型解释同一段字节。
4. 指针:带类型的整数
声明语法(从右向左读):
int *a; // 指向 int 的指针
float *b; // 指向 float 的指针
int **c; // 指向"int 指针"的指针
char (*d)(int); // 指向函数 (int -> char) 的指针
char (**e)(int); // 指向"函数指针"的指针
void *f; // 指向无类型内存的指针
int ******value; // 指针可任意嵌套4.1 数组与部分初始化
int my_array[6] = {1};
// my_array[0] = 1,其余元素全部被零初始化
// [0] = 1, [3] = 0, [5] = 0 规则:只要给了初始化列表,未显式赋值的元素一律补 0。(订正:原笔记此处写
[3] = 100 有误,正确为 0。)4.2 指针运算会按类型大小缩放
array[n]等价于*(array + n);&array[n]等价于array + n。- 关键:把整数加到指针上时,整数会隐式乘以所指类型的大小。
int my_array[4]; // 设 &my_array[0] = 0x2FB0
// &my_array[3] == 0x2FBC,比起点大 12 = 3 × sizeof(int)
(long)(my_array + 3) == (long)my_array + 3 * sizeof(int); 对比纯整数:设 int *p = (int*)100;(int)p + 1 = 101(先转整数再加 1);(int)(p + 1) = 104(指针先加 1,缩放 ×4,再转整数)。int values[5] = {10, 20, 30, 40, 50};
printf("%d\n", 4[values]); // 打印 50 因为 x[y] == *(x+y) == *(y+x) == y[x],所以 4[values] == values[4] == 50。4.3 指针 ↔ 整数的强制转换
int x[4] = {1, 2, 3, 4};
int *x_ptr = &x[0];
long x_addr = (long)x_ptr; // 指针 → 整数
int *x2_ptr = (int *)(x_addr + 4); // 整数(+4 字节) → 指针,指向 &x[1]
int x2_value = x2_ptr[1]; // x2_ptr[1] = x[2] = 3
// x2_value == 3 要点:x_addr + 4 是按字节加 4(普通整数运算),落到 &x[1];而 x2_ptr[1] 是按 int 缩放再取,落到 x[2]。(订正:原笔记最后一行写成
x_ptr[1] 但答案给 3,前后不一致;自洽写法应为 x2_ptr[1]。)4.4 void 与指针大小
void表示"没有数据类型",主要用于函数返回值/参数;不能定义void变量。- 可以定义
void *,但不能解引用、不能做指针算术(不知道步长)。 - 指针大小依平台而定;本课的 RISC-V 是 64 位指针,与
long同宽。
int x[5]; // 设 x 位于 0x...7f00
printf("%p\n", x); // 0x...7f00 —— 数组退化为首元素指针
printf("%p\n", x+1); // 0x...7f04 —— +1 个 int = +4 字节
printf("%p\n", &x); // 0x...7f00 —— 地址值相同,但类型是 int(*)[5]
printf("%p\n", &x+1); // 0x...7f14 —— +1 个"整个数组" = +20 字节(0x14) 关键区别:x+1 跨一个 int,&x+1 跨整个数组(5×4=20 字节)。5. 声明、定义与 static
- 声明:告诉编译器某名字的类型/签名。可多次声明,只要类型一致。使用前必须先声明。
- 定义:实际分配存储/给出函数体。整个代码库中每个名字只能定义一次(定义也算一次声明)。
- 常把声明集中放进头文件 (*header*),让各部分知道彼此的类型。
static 的两种用途:
- 限定作用域:把函数/变量声明为
static,使其仅在本文件可见,避免跨文件重名冲突。 - 静态生命周期的局部变量:函数内的
static变量分配在静态内存,程序启动时清零一次,跨调用保持值,不会被重新初始化。
int add_cumulative_numbers(int increase) {
static int total_sum = 0; // 启动时初始化为 0,之后跨调用累加
total_sum += increase;
return total_sum;
}6. 字符串、常用库函数、结构体/联合体/位操作
- 字符串就是字符数组,字符是 1 字节整数,以
\0(null 终止符)结尾。
malloc(n)/free(ptr):堆分配/释放;分配失败返回NULL,free(NULL)为空操作。memset(ptr,v,n):把ptr[0..n-1]全设为v。memmove(dst,src,n):安全拷贝(允许重叠)。memcpy(dst,src,n):更快但重叠时行为未定义 → 优先用memmove。strlen(s):靠\0计算长度;缺终止符会越界。strcmp(a,b):返回 <0 / 0 / >0。strcpy(dst,src)≈memcpy(dst,src,strlen(src)+1)(含终止符;不限长 → 砸栈隐患,见 Lec 22)。
结构体 vs 联合体:结构体每个字段有独立内存、可同时安全使用;联合体 (union) 所有字段共享同一块内存——往 x(float)写、再从 y(int)读,会得到对同一段字节的整数解释(类型双关)。
位操作(unsigned short a=0x1313, b=0x3232):a&b==0x1212、a|b==0x3333、a^b==0x2121、~a==0xECEC。常用惯用法:
my_int |= 1 << N; // 置第 N 位
my_int &= ~(1 << N); // 清第 N 位
if (my_int & MASK) // MASK 中有任一位被置
if ((my_int & MASK) == MASK) // MASK 中所有位都被置
if (my_int && !(my_int & (my_int - 1))) // 判断是否 2 的幂7. GDB:调试内核的手法
xv6 在 QEMU 里跑,调试靠 GDB 远程连接。后续 Lec 3 / Lec 6 大量用到,这里先备一份速查。
| 命令 | 作用 |
|---|---|
b *0x80000000 / b main | 在地址或符号处下断点 |
c / stepi / n | 继续运行 / 单步一条指令 / 单步一行 C |
delete 1 | 删除 1 号断点 |
info reg | 查看所有寄存器(如 a0–a7 传参、a7 系统调用号) |
p $pc / p/x $satp | 打印寄存器;/x 以十六进制、/a 以地址形式 |
x/2c $a1 | 检查内存:/2 两项,c 字符(i 指令 / x 十六进制 / a 地址) |
p/3i 0xe14 | 反汇编从 0xe14 起的 3 条指令 |
tui enable 分屏同时显示源码/汇编/寄存器/命令行。布局:layout src / asm / split / regs;快捷键 Ctrl-x 1(仅代码窗)、Ctrl-L(刷新)、Ctrl-x s(切布局)。另一个常用技巧:QEMU 监视器里
Ctrl-a c 进入,info mem 查看当前页表映射(调试 VM/trap 时极有用,Lec 5/6 会用)。layout src(显示源代码窗口)layout asm(显示反汇编窗口)layout split(分屏显示源代码和汇编)layout regs(显示寄存器窗口)
TUI 视图常见快捷键:
Ctrl-x 1(仅显示代码窗口)Ctrl + L:刷新屏幕。Ctrl + P / Ctrl + N:在历史命令中导航。Ctrl + x s:切换布局(源码视图、汇编视图等)。
Bootloader(先埋个伏笔):上电后内核被加载到 RAM 起始地址 0x80000000,第一条指令跳到 entry.S 的 _entry,设置栈后进入 start() → main()。完整启动流程在 Lec 3 展开。
8. 内存抽象的层次:从总线到栈堆
这是本讲承上启下的关键——同一个"内存",在不同层次有完全不同的样貌。

8.1 硬件层:总线、缓存、I/O


- 总线 (bus) 在 CPU、内存、I/O 设备之间传输数据。
- 缓存 (cache) 记住最近从总线取得的数据,通过减少总线访问来加速 CPU。
8.2 CPU / OS 层:地址空间

LOAD/STORE 在这个数组上读写。四个递进的想法:
- 地址空间可以有空洞:能访问的地址称"已映射 (mapped)",访问不到的"未映射 (unmapped)";地址空间通常远大于物理 RAM。
- 地址带权限:读(R,可 load)/ 写(W,可 store)/ 执行(X,可作为代码)。无权限访问会触发异常——这是隔离与安全的基石。
- 统一 RAM 与设备:把 I/O 设备寄存器也放进地址空间(内存映射 I/O),用 load/store 即可操作设备。"代码和数据同为内存"即冯·诺依曼架构 (von Neumann)。(注:x86 早期把 I/O 放在独立地址空间。)映射粒度通常是一个页 (page, 4KB) 而非一字节。
- 虚拟内存 + 缓存一致性:让每个进程拥有独立地址空间;让多 CPU 共享同一地址空间(Lec 5 展开)。


8.3 编译器 / 库层:栈与堆
地址空间仍太底层——"数据放数组哪个位置"这个内存分配 (memory allocation) 问题,有两种基本答案:
- 栈:随函数调用/返回自动分配/释放,高效、简单。
- 堆:分配与释放独立于函数调用,由堆分配器 (*heap allocator*) 跟踪哪些区域已用/空闲——这至今仍是活跃研究领域,存在大量权衡,最优方案取决于分配模式。
对应的内存管理陷阱(务必牢记):用已释放内存、重复释放、忘记初始化、写越界、忘记释放(泄漏)、错误类型转换、忘记检查分配是否失败、返回指向栈上局部变量的指针。

9. 自测清单
- [ ] C 相对 Python 的 5 点差异?"静态类型"中类型绑定到变量还是值?
- [ ]
0x12345678在小端/大端下的字节序列?RISC-V 是哪种? - [ ] 栈/堆/静态三区的分配方式与默认初始化各是什么?
- [ ] 6 类内存安全 bug 分别是什么?
- [ ] 为什么
(int)p+1与(int)(p+1)结果不同?x+1与&x+1差多少字节? - [ ]
static局部变量的生命周期与初始化行为? - [ ]
memcpy与memmove的区别?为何优先memmove? - [ ] 地址空间的 4 个递进想法(空洞/权限/统一 I/O/虚拟内存)。
- [ ] 何时该用堆而非栈?列出至少 5 个内存管理陷阱。