C语言与操作系统 (C Language and Operating Systems)


章节概述

本章是第四部分”内核”的入口。前三部分(1入门2深化3数据结构)为你打下了坚实的 C 语言基础。现在,我们将进入 C 语言最辉煌的战场——Linux 内核。在写第一行内核代码之前,你需要先理解:为什么是 C?内核是什么?内核态和用户态的区别是什么?

Linux 内核是世界上最大的 C 语言项目(超过 3000 万行代码)。它运行在全球数十亿台设备上——从超级计算机到手机,从路由器到嵌入式传感器。理解为什么内核选择 C,以及 C 语言如何承载如此复杂的系统,是成为系统级工程师的第一步。

本章内容:

  • C 语言的历史地位:为什么内核用 C 而不是 C++、Rust 或汇编
  • 操作系统架构概览:内核在系统中的位置和职责
  • 内核态与用户态:权限级别、上下文切换、系统调用
  • 开发环境搭建:下载内核源码、交叉编译、QEMU 调试
  • 第一个内核模块:从 hello world 开始触摸内核
  • GNU C 扩展:内核中使用的非标准 C 特性

前置要求


第一节:为什么 Linux 内核用 C 语言编写


1.1 历史的选择:从汇编到 C

Linux 内核诞生于 1991 年。当时,Unix 已经在 1973 年被 Dennis Ritchie 用 C 语言重写。Linus Torvalds 在 comp.os.minix 新闻组发布的第一条消息中写道:

“I’m doing a (free) operating system (just a hobby, won’t be big and professional like gnu) for 386(486) AT clones.”

他选择 C 语言是必然的:

  1. Unix 传统:Unix 已经是 C 语言的标志性项目。K&R C(《The C Programming Language》)定义了系统编程的标准范式。
  2. 编译器支持:GCC(GNU C Compiler)已经成熟,且是自由软件。
  3. C 与硬件的天然亲近:C 语言被称为”可移植的汇编语言”(portable assembly),它允许直接操作内存地址、使用指针与硬件寄存器交互,同时由编译器负责寄存器分配和指令选择。
// C 语言的"可移植汇编"特质示例
// 这段代码在不同架构上编译出不同的指令序列,但语义完全相同
 
// x86-64 上: movl $0xDEAD, (%rax)
// ARM64 上:  mov w0, #0xDEAD; str w0, [x1]
// RISC-V 上: li a0, 0xDEAD; sw a0, 0(a1)
void write_register(volatile unsigned int *reg) {
    *reg = 0xDEAD;
}

1.2 C 语言在系统编程中的不可替代性

让我们从几个维度分析为什么 C 语言至今仍是内核编程的首选:

维度一:零运行时开销(Zero-cost Abstraction)

// C 语言没有:
// - 异常处理(try/catch)—— 不允许内核中有不可控的栈展开
// - RTTI(运行时类型识别)—— 内核不需要动态类型信息
// - 构造函数/析构函数自动调用 —— 内核需要精确控制初始化顺序
// - 虚函数表自动生成 —— 虽然内核用结构体函数指针实现了"手工多态"
 
// 每一条 C 语句都对应可预测的汇编指令
int abs_val(int x) {
    return (x < 0) ? -x : x;  // 编译为 cmov 或分支,无任何隐藏开销
}

维度二:直接内存访问

内核需要管理物理内存、配置 MMU、建立页表。这些操作要求语言能够直接操作任意内存地址:

// 内核中设置页表项(简化示例)
// 这需要直接写入物理地址 —— Java/Python/Go 都无法做到
static inline void set_pte(pte_t *ptep, pte_t pte) {
    // WRITE_ONCE 防止编译器优化掉这次写入
    WRITE_ONCE(*ptep, pte);
    // 在 x86 上这会编译为 mov %rsi, (%rdi)
}

维度三:ABI 稳定性

C 语言的 ABI(Application Binary Interface)在每种平台上高度稳定。.c 文件编译为 .o 文件后,只要函数签名不变,不同版本编译出的目标文件可以互相链接。这使得内核模块(LKM)可以在不同内核版本间保持兼容(需要重新编译,但不需要修改代码)。

C 语言不可替代性总结(mermaid 图):

graph TD
    C["C 语言"] --> ZC["零运行时开销<br/>无GC, 无异常展开<br/>无隐式内存分配"]
    C --> DA["直接地址访问<br/>指针 = 虚拟地址<br/>volatile = IO寄存器"]
    C --> ABI["稳定 ABI<br/>链接器兼容<br/>模块化支持"]
    C --> PA["可移植汇编<br/>架构抽象<br/>编译器优化可控"]
    C --> CE["编译器成熟度<br/>GCC 支持 50+ 架构<br/>Clang 支持 30+ 架构"]
    
    ZC --> KERNEL
    DA --> KERNEL
    ABI --> KERNEL
    PA --> KERNEL
    CE --> KERNEL
    
    KERNEL["Linux 内核<br/>3000万+ 行 C 代码<br/>30+ 种架构支持"]

1.3 C++ 和 Rust 为什么没有取代 C

C++ 的困境

  • Linus Torvalds 在 2007 年的一封著名邮件中解释了内核不使用 C++ 的原因:C++ 的异常、模板膨胀、隐式内存分配与内核编程的哲学相悖
  • C++ 的 STL 使用异常和动态内存分配,不适合内核环境
  • 但 C++ 的某些模式(面向对象、RAII)被”手工翻译”成 C 语言模式在内核中使用

Rust 的崛起与定位

  • Linux 6.1(2022 年 12 月)正式合入 Rust 支持——这是内核史上最具里程碑意义的合并之一
  • Rust 解决的是 C 语言最大的痛点:内存安全。根据 Google 和 Microsoft 的统计,约 70% 的严重安全漏洞源于内存问题(UAF、缓冲区溢出、未初始化读取)
  • 但 Rust 不取代 C,而是与 C 共存:详见 Linux 内核 Rust 基础设施相关文档
  • Rust 编译器对不安全操作的显式标注(unsafe {})使得代码审查更聚焦
  • C 语言仍然是内核的主要语言,Rust 从新驱动开始逐步渗透

核心观点:C 和 Rust 不是敌人。C 是系统编程的”通用语”(lingua franca),Rust 是在其肩膀上构建的安全增强层。理解 C 语言的内核编程模式,才能理解 Rust 在内核中的设计为什么是这样。

graph LR
    subgraph "过去 (1991-2020)"
        P1["纯 C"] --> P1K["Linux 内核"]
        P1A["汇编"] --> P1K
    end
    subgraph "现在 (2022+)"
        C2["C (主要)"]
        R2["Rust (增长中)"]
        A2["汇编 (少量)"]
        C2 --> K2["Linux 内核"]
        R2 --> K2
        A2 --> K2
    end
    P1K -.->|"30年演进"| K2

第二节:操作系统架构与内核


2.1 内核在系统中的位置

操作系统是硬件和应用程序之间的中间层。内核(Kernel)是操作系统的核心,运行在最高特权级别(Ring 0)。

graph TB
    subgraph "用户态 (Ring 3)"
        APP1["应用程序<br/>(bash, vim, firefox)"]
        APP2["应用程序<br/>(nginx, mysql, gcc)"]
        LIBC["C 标准库<br/>(glibc / musl)"]
    end
    
    subgraph "内核态 (Ring 0)"
        SYSCALL["系统调用接口"]
        VFS["虚拟文件系统<br/>(VFS)"]
        MM["内存管理<br/>(MM/Memory Management)"]
        SCHED["进程调度<br/>(Scheduler)"]
        NET["网络栈<br/>(TCP/IP)"]
        DRV["设备驱动<br/>(Drivers)"]
        ARCH["架构相关代码<br/>(arch/x86, arch/arm64...)"]
    end
    
    HW["硬件 (CPU, 内存, 磁盘, 网卡, USB...)"]
    
    APP1 --> LIBC
    APP2 --> LIBC
    LIBC --> SYSCALL
    SYSCALL --> VFS
    SYSCALL --> MM
    SYSCALL --> SCHED
    SYSCALL --> NET
    VFS --> DRV
    NET --> DRV
    MM --> ARCH
    DRV --> ARCH
    ARCH --> HW

内核的五大核心子系统

子系统内核目录职责
进程管理kernel/进程创建/销毁、调度、信号
内存管理mm/虚拟内存、页表、分配器
文件系统fs/VFS、具体文件系统驱动
网络栈net/协议栈、socket
设备驱动drivers/硬件抽象、字符/块/网络设备

2.2 x86 特权级别:Ring 0 ~ Ring 3

x86 架构定义了 4 个特权级别(Rings),但 Linux 只使用两个:

graph TD
    subgraph "x86 特权级别定义"
        R0["Ring 0<br/>最高权限<br/>可执行所有指令<br/>可访问所有内存"]
        R1["Ring 1<br/>(Linux 未使用)"]
        R2["Ring 2<br/>(Linux 未使用)"]
        R3["Ring 3<br/>最低权限<br/>受限指令<br/>虚拟内存隔离"]
    end
    
    R0 -->|"Linux 内核态"| K
    R3 -->|"Linux 用户态"| U
    
    K["内核态<br/>- 可执行特权指令 (lgdt, lidt, mov cr3...)<br/>- 可直接访问物理内存<br/>- 可修改页表<br/>- 可响应中断"]
    
    U["用户态<br/>- 无法执行特权指令<br/>- 只能访问映射的虚拟地址<br/>- 必须有系统调用才能访问硬件<br/>- 内存访问受限(段错误保护)"]

特权指令示例

// 以下操作只能在 Ring 0(内核态)执行
// 用户态程序尝试执行会导致 General Protection Fault (#GP)
 
// 1. 设置 CR3 寄存器(切换页表)
//    mov %rax, %cr3   ← 特权指令
 
// 2. 启用/禁用中断
//    cli / sti        ← 特权指令
 
// 3. 读写 MSR(Model Specific Register)
//    rdmsr / wrmsr    ← 特权指令
 
// 4. I/O 端口访问(可配置为 Ring 3 也能执行)
//    in / out         ← IOPL 控制

2.3 系统调用:用户态与内核态的桥梁

用户程序如何请求内核服务?通过系统调用(system call)。这是跨越 Ring 3 到 Ring 0 的唯一方式。

sequenceDiagram
    participant U as 用户态程序 (Ring 3)
    participant LIBC as C 标准库 (glibc)
    participant K as Linux 内核 (Ring 0)
    participant HW as 硬件
    
    U->>LIBC: printf("hello\n")
    LIBC->>LIBC: 格式化字符串
    LIBC->>K: write(1, "hello\n", 6)<br/>syscall 指令 (或 int 0x80)
    Note over K: Ring 3 → Ring 0 切换<br/>保存用户态寄存器<br/>加载内核栈<br/>查找 sys_call_table
    K->>K: sys_write() 调用
    K->>HW: 将数据写入终端设备
    HW-->>K: 写入完成
    K->>LIBC: 返回写入字节数
    LIBC-->>U: 返回 6

系统调用全过程(汇编视角)

// 用户态 C 代码
#include <unistd.h>
int main(void) {
    write(1, "Hello", 5);
    return 0;
}
 
// 编译后,write() 在 glibc 中的实现大致等价于:
//
// mov    $1, %rax        # 系统调用号: 1 = __NR_write
// mov    $1, %rdi        # 参数1: fd = stdout
// mov    $0x401000, %rsi # 参数2: buf = "Hello"的地址
// mov    $5, %rdx        # 参数3: count = 5
// syscall                # 进入内核
//
// syscall 指令执行后:
//   1. CPU 从 IA32_LSTAR MSR 加载内核入口地址
//   2. 切换 CS 选择子 → Ring 0
//   3. 将用户 RFLAGS 保存到 R11
//   4. 将用户 RIP 保存到 RCX
//   5. 跳转到 entry_SYSCALL_64 (arch/x86/entry/entry_64.S)

关于系统调用的汇编层细节,详见 ASM: 系统调用。本章第三部分将有更深入的追踪。

// 练习 1: 使用 strace 跟踪系统调用
// 在终端执行:
//   strace ls
//   strace -c ls      (统计各系统调用次数)
//   strace -e write ls (只看 write 系统调用)
//
// 你会发现一个简单的 ls 命令也会触发数十个系统调用:
// execve, brk, mmap, access, openat, fstat, read, write, close...

第三节:搭建 Linux 内核开发环境


3.1 获取内核源码

# 方法 1: 从 kernel.org 下载(推荐新手)
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.tar.xz
tar xf linux-6.6.tar.xz
cd linux-6.6
 
# 方法 2: 从 GitHub 镜像克隆(方便浏览历史)
git clone --depth=1 https://github.com/torvalds/linux.git
# 或用国内镜像:
git clone --depth=1 https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
 
# 方法 3: 发行版的内核源码
# Ubuntu/Debian:
apt-get source linux-image-$(uname -r)

3.2 内核源码目录初览

linux/
├── arch/           # 架构相关代码(x86, arm64, riscv, mips...)
│   └── x86/        #   ├── boot/    (启动代码)
│                   #   ├── kernel/  (架构特定内核代码)
│                   #   ├── mm/      (页表/TLB 管理)
│                   #   └── entry/   (系统调用入口)
├── block/          # 块设备层
├── crypto/         # 加密 API
├── Documentation/  # 内核文档(极其重要!)
├── drivers/        # 设备驱动(内核最大的目录,占约 60% 代码量)
├── fs/             # 文件系统(VFS, ext4, btrfs, nfs...)
├── include/        # 内核头文件
│   └── linux/      #   通用内核头文件
├── init/           # 初始化代码(start_kernel() 在这里)
├── ipc/            # 进程间通信(信号量、消息队列、共享内存)
├── kernel/         # 核心内核(调度器、信号、cgroup...)
├── lib/            # 内核库函数(字符串、位图、红黑树...)
├── mm/             # 内存管理(伙伴系统、slab、页面回收...)
├── net/            # 网络栈
├── scripts/        # 构建脚本
├── security/       # 安全模块(SELinux, AppArmor...)
├── sound/          # 音频子系统(ALSA)
├── tools/          # 用户态工具(perf, objtool...)
└── usr/            # initramfs 生成

3.3 配置与编译最小化内核

# 1. 生成默认配置(推荐使用当前运行系统的配置作为起点)
# 如果你在虚拟机中:make defconfig 生成最小配置
make defconfig
 
# 或者复制当前系统的配置(如果可用)
# zcat /proc/config.gz > .config
# make olddefconfig
 
# 2. 进一步裁剪(可选,减小编译时间)
make menuconfig    # 基于 ncurses 的图形配置界面
# 在 menuconfig 中:
#   - 禁用不需要的驱动(Device Drivers → 只保留关键驱动)
#   - 启用内核模块支持 (Enable loadable module support)
#   - 启用调试信息 (Kernel hacking → Compile-time checks and compiler options → Compile the kernel with debug info)
 
# 3. 编译
make -j$(nproc)    # -j$(nproc) 使用所有 CPU 核心并行编译
 
# 编译产物:
#   vmlinux        - 未压缩的内核 ELF 可执行文件(可用于 gdb 调试)
#   arch/x86/boot/bzImage - 压缩后的可引导内核镜像
#   System.map     - 内核符号表(所有导出符号的地址映射)
#   *.ko           - 内核模块
 
# 4. 安装(在本地系统上,慎用!建议在 VM 或容器中)
# make modules_install
# make install
 
# 5. 使用 QEMU 测试(推荐方式)
qemu-system-x86_64 \
    -kernel arch/x86/boot/bzImage \
    -initrd /boot/initrd.img-$(uname -r) \
    -append "console=ttyS0 nokaslr" \
    -nographic

3.4 使用 GDB 调试内核

# 1. 用 QEMU 启动,开启 GDB stub
qemu-system-x86_64 \
    -kernel arch/x86/boot/bzImage \
    -s -S \                    # -s: 在 :1234 端口开启 gdb server
                               # -S: 启动时暂停 CPU
    -nographic
 
# 2. 另一个终端中连接 GDB
gdb vmlinux                    # vmlinux 包含所有调试符号
(gdb) target remote :1234
(gdb) break start_kernel
(gdb) continue
(gdb) info registers
(gdb) list

第四节:第一个内核模块(Hello World LKM)


4.1 什么是内核模块

LKM(Loadable Kernel Module)是可以在运行时动态加载到内核中的代码。它运行在内核态(Ring 0),可以访问内核的所有符号和函数。设备驱动通常以模块的形式存在。

graph LR
    subgraph "编译时"
        KM["kernel/module.c<br/>(模块源码)"]
        KB["Kbuild (Makefile)"]
        KM --> KB
        KB --> KO["module.ko<br/>(内核目标文件)"]
    end
    subgraph "运行时"
        KO -->|"insmod"| K["运行中的内核"]
        K -->|"rmmod"| REMOVED["模块卸载"]
        K -->|"modprobe"| MODLOADED["模块加载<br/>+ 依赖解析"]
    end

4.2 编写第一个模块

// ============================================
// 文件: hello_kernel.c
// 一个最简内核模块示例
// ============================================
 
#include <linux/module.h>    // MODULE_LICENSE, MODULE_AUTHOR 等宏
#include <linux/kernel.h>    // KERN_INFO, printk
#include <linux/init.h>      // __init, __exit 宏
 
// 模块许可证(GPL 才能访问内核 GPL-only 符号)
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World kernel module");
MODULE_VERSION("0.1");
 
/*
 * 模块初始化函数
 * 当执行 insmod hello_kernel.ko 时被调用
 *
 * __init 宏: 告诉编译器此函数只在初始化时使用
 *   函数代码被放在 .init.text 段
 *   初始化完成后内核可释放此段内存(free_initmem())
 */
static int __init hello_init(void)
{
    /*
     * printk 是内核的"printf"
     *
     * 与 printf 的区别:
     *   1. 没有 C 标准库 —— printk 由内核自身实现(kernel/printk/printk.c)
     *   2. 日志级别前缀:KERN_EMERG, KERN_ALERT, KERN_CRIT, KERN_ERR,
     *      KERN_WARNING, KERN_NOTICE, KERN_INFO, KERN_DEBUG
     *   3. 输出到内核环形缓冲区(dmesg 查看),也可配置输出到控制台
     *   4. 支持浮点吗?不支持!内核态一般不使用浮点运算
     *      (浮点需要保存 FPU 状态,在中断上下文中代价高昂)
     */
    printk(KERN_INFO "hello_kernel: Module loaded successfully\n");
    printk(KERN_INFO "hello_kernel: Current process: %s (pid: %d)\n",
           current->comm, current->pid);
    // current 是一个宏,指向当前进程的 task_struct
    // 这是内核中最重要的全局"变量"之一
 
    return 0;  // 返回 0 表示成功,负数表示错误码(-ENOMEM 等)
}
 
/*
 * 模块清理函数
 * 当执行 rmmod hello_kernel 时被调用
 *
 * __exit 宏: 如果是静态编译的内核(非模块),此段可以被丢弃
 */
static void __exit hello_exit(void)
{
    printk(KERN_INFO "hello_kernel: Module unloaded. Goodbye!\n");
}
 
// 注册初始化和清理函数
module_init(hello_init);
module_exit(hello_exit);

4.3 编写 Makefile

# ============================================
# 文件: Makefile
# 内核模块的 Makefile 与用户态 Makefile 完全不同
# 它使用内核构建系统 (Kbuild)
# ============================================
 
# 目标模块名(不含 .ko 后缀)
obj-m := hello_kernel.o
 
# 内核源码目录(根据你的系统调整)
# 常见路径:
#   /lib/modules/$(shell uname -r)/build   (当前运行内核)
#   /home/user/linux-6.6/                   (自己编译的内核)
KERNEL_DIR := /lib/modules/$(shell uname -r)/build
 
# 当前目录
PWD := $(shell pwd)
 
# 默认目标
all:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules
	# -C: 切换到内核源码目录
	# M=: 告诉 Kbuild 模块源码在哪里
	# modules: 只编译模块(不编译整个内核)
 
# 清理编译产物
clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
 
# 可选:显示模块信息
info:
	modinfo hello_kernel.ko
 
# 编译并加载(需要 root 权限)
load: all
	sudo insmod hello_kernel.ko
	dmesg | tail -5
 
# 卸载模块
unload:
	sudo rmmod hello_kernel
	dmesg | tail -5
 
# 显示内核消息
log:
	dmesg | tail -20

4.4 加载和测试

# 1. 编译模块
make
 
# 2. 查看模块信息
modinfo hello_kernel.ko
# 输出:
#   filename:       /path/to/hello_kernel.ko
#   version:        0.1
#   license:        GPL
#   description:    A simple Hello World kernel module
#   author:         Your Name
#   vermagic:       6.5.0-14-generic SMP preempt mod_unload
 
# 3. 加载模块(需要 root)
sudo insmod hello_kernel.ko
# 或使用 modprobe(自动处理依赖):
# sudo modprobe hello_kernel
 
# 4. 查看内核日志
dmesg | tail
# 输出:
#   [ 1234.567890] hello_kernel: Module loaded successfully
#   [ 1234.567891] hello_kernel: Current process: insmod (pid: 5678)
 
# 5. 查看已加载的模块
lsmod | grep hello_kernel
# 或查看 /proc/modules
cat /proc/modules | grep hello
 
# 6. 卸载模块
sudo rmmod hello_kernel
dmesg | tail
# 输出:
#   [ 1290.123456] hello_kernel: Module unloaded. Goodbye!

4.5 模块的 ELF 结构

# 查看模块的段信息
objdump -h hello_kernel.ko
 
# 典型输出:
#   .text         代码段(你的函数)
#   .init.text    初始化代码(hello_init)
#   .exit.text    退出代码(hello_exit)
#   .rodata.str1.1 只读字符串
#   .modinfo      模块元信息(许可证、作者等)
#   __versions    内核版本校验信息(防止 ABI 不兼容)
 
# 查看模块符号
nm hello_kernel.ko
# U printk            ← 未定义符号,由内核在加载时解析
# T init_module       ← module_init 生成的别名
# T cleanup_module    ← module_exit 生成的别名

关于 ELF 格式的深入讲解,参见 编译链接与ELF

// 练习 2: 增强模块信息
// 修改 hello_kernel.c,添加:
// 1. 输出当前内核版本号(使用 UTS_RELEASE 宏 或 utsname())
// 2. 输出当前 jiffies 值(内核的"心跳"计数器)
// 3. 统计模块被加载的次数(使用静态变量 —— 思考:卸载后再加载会累加吗?)
 
// 提示: #include <linux/utsname.h>
//       #include <linux/jiffies.h>
//       使用 utsname()->release 获取版本字符串

第五节:GNU C 扩展 —— 内核中常用的非标准 C


内核代码不是标准 C(C89/C99/C11),而是 GNU C(GCC 扩展)。以下扩展在内核中随处可见。理解它们对于阅读内核源码至关重要。

5.1 __attribute__ 机制

__attribute__ 是 GCC 的扩展语法,用于给函数、变量、类型附加元信息。内核定义了一系列便捷宏来封装 __attribute__

// ============================================
// 内核中常见的 __attribute__ 用法
// ============================================
 
// 1. __init / __exit —— 将函数放到特定代码段
//    定义在 include/linux/init.h
//    #define __init  __section(".init.text")
//    #define __exit  __section(".exit.text")
static int __init my_init(void) { return 0; }  // 放到 .init.text 段
 
// 2. __user —— 标记用户空间指针(Sparse 静态检查用)
//    #define __user  __attribute__((noderef, address_space(1)))
//    编译器不会检查,但 Sparse 工具会验证
int copy_from_user(void *to, const void __user *from, unsigned long n);
 
// 3. __maybe_unused —— 可能用不到的变量/函数,禁止编译器警告
//    #define __maybe_unused  __attribute__((unused))
static int __maybe_unused debug_level = 3;
 
// 4. __printf —— 告诉编译器此函数的参数是 printf 风格
//    GCC 可以据此检查格式字符串和参数类型是否匹配
//    __attribute__((format(printf, fmt_argno, first_check_argno)))
//    #define __printf(a, b)  __attribute__((format(printf, a, b)))
extern int printk(const char *fmt, ...)
    __printf(1, 2);  // 第1个参数(fmt)是格式串,从第2个参数开始检查
 
// 5. __aligned —— 对齐要求
//    #define __aligned(x)  __attribute__((aligned(x)))
struct my_struct {
    char c;
    int x __attribute__((aligned(64)));  // 64字节对齐(缓存行对齐)
};
 
// 6. __packed —— 紧凑模式,取消对齐填充
struct __attribute__((__packed__)) network_packet {
    uint8_t  version;      // 1 byte
    uint16_t length;       // 2 bytes (不填充到4)
    uint32_t sequence;     // 4 bytes
}; // sizeof = 7, 而非通常的 8
 
// 7. likely / unlikely —— 分支预测提示
//    #define likely(x)   __builtin_expect(!!(x), 1)
//    #define unlikely(x) __builtin_expect(!!(x), 0)
//    告诉编译器哪个分支更可能发生,优化代码布局
if (unlikely(ptr == NULL)) {
    return -ENOMEM;  // 不会发生的错误路径放在函数尾部
}
if (likely(count > 0)) {
    // 正常路径,CPU 会预测这个分支
    process_data(data, count);
}

5.2 __builtin_* 内建函数

GCC 提供了一系列内建函数,编译器直接将其替换为高效指令序列:

// ============================================
// 内核中常用的 __builtin_* 函数
// ============================================
 
// 1. __builtin_constant_p(expr)
//    如果 expr 是编译时常量,返回 1;否则返回 0
//    用于编译时优化——对常量选择特定实现路径
#define min(x, y) ({                            \
    typeof(x) _min1 = (x);                      \
    typeof(y) _min2 = (y);                      \
    (void)(&_min1 == &_min2);  /* 类型安全检查 */ \
    _min1 < _min2 ? _min1 : _min2; })
// 如果 x 和 y 是编译时常量,编译器可以用更高效的指令
 
// 2. __builtin_clz / __builtin_ctz / __builtin_popcount
//    计算前导零 / 后缀零 / 二进制中 1 的个数
//    编译为: bsr (x86) 或 clz (ARM) 单条指令
static inline int ilog2(unsigned long n) {
    return 8 * sizeof(n) - __builtin_clzl(n) - 1;
}
 
// 3. __builtin_offsetof —— 结构体成员偏移
//    #define offsetof(TYPE, MEMBER) __builtin_offsetof(TYPE, MEMBER)
//    offsetof(struct task_struct, pid)  → 该成员的字节偏移
//    与 container_of 配合使用,是内核链表的基石
//    详见: [[../../c语言教程/3数据结构/02_链表|内核链表]] 和 [[07_Linux内核源码导读|内核源码导读]]
 
// 4. __builtin_types_compatible_p —— 检查两个类型是否兼容
//    typeof 的同类型安全检查(_Generic 的底层基础)
 
// 5. __builtin_choose_expr —— 编译时条件选择(类似三目运算符但更严格)

5.3 内联汇编(Inline Assembly)

虽然我们尽量用 C 语言,但某些操作必须用汇编指令。GCC 的内联汇编语法如下:

/*
 * 内联汇编基本语法:
 *
 * asm [volatile] (
 *     汇编模板
 *     : 输出操作数列表 (可空)
 *     : 输入操作数列表 (可空)
 *     : 被破坏的寄存器列表 (clobber list)
 * );
 */
 
// 示例 1: 简单的 NOP 指令
static inline void cpu_relax(void) {
    asm volatile("rep; nop" ::: "memory");
    // rep; nop = pause 指令,用于自旋等待(降低功耗)
    // "memory" clobber 告诉编译器内存可能被修改
}
 
// 示例 2: 读取 MSR(Model Specific Register)
static inline unsigned long long native_read_msr(unsigned int msr) {
    unsigned long long val;
    asm volatile("rdmsr" : "=A"(val) : "c"(msr));
    // rdmsr: 从 MSR 读取
    // ecx = MSR 编号
    // edx:eax = 返回值
    // "=A": 表示 eax 和 edx 联合到 val (仅在32位下有效)
    return val;
}
 
// 示例 3: WRITE_ONCE —— 防止编译器优化掉内存写入
//    定义在 include/linux/compiler.h
static __always_inline void __write_once_size(volatile void *p,
                                               void *res, int size) {
    switch (size) {
    case 1: *(volatile __u8 *)p = *(__u8 *)res; break;
    case 2: *(volatile __u16 *)p = *(__u16 *)res; break;
    case 4: *(volatile __u32 *)p = *(__u32 *)res; break;
    case 8: *(volatile __u64 *)p = *(__u64 *)res; break;
    default:
        barrier();  // 编译器屏障
        __builtin_memcpy((void *)p, (const void *)res, size);
        barrier();
    }
}
#define WRITE_ONCE(x, val) \
({                        \
    union { typeof(x) __val; char __c[1]; } __u =  \
        { .__val = (val) };                       \
    __write_once_size(&(x), __u.__c, sizeof(x));  \
    __u.__val;                                    \
})
 
// 示例 4: 内存屏障
// mb() - 全内存屏障 (mfence / dmb)
// rmb() - 读内存屏障
// wmb() - 写内存屏障
#define mb()  asm volatile("mfence":::"memory")
#define rmb() asm volatile("lfence":::"memory")
#define wmb() asm volatile("sfence":::"memory")
// 在 ARM64 上编译为 dmb ish / dmb ishld / dmb ishst

5.4 语句表达式(Statement Expressions)

// ({ ... }) 是 GCC 扩展,允许在宏定义中使用复合语句并返回值
// 这使得宏可以像函数一样使用,同时保持类型安全
 
// 经典示例: min/max 宏的安全实现
#define max(a, b) ({                    \
    typeof(a) _a = (a);                 \
    typeof(b) _b = (b);                 \
    _a > _b ? _a : _b;                  \
})
 
// 使用示例: max(a++, b) 不会导致 a 被求值两次
// 因为 _a = (a) 只求值一次(相比 #define max(a,b) ((a)>(b)?(a):(b)))
 
// 另一个经典示例:container_of 宏(详见第7章)
#define container_of(ptr, type, member) ({          \
    const typeof(((type *)0)->member) *__mptr = (ptr); \
    (type *)((char *)__mptr - offsetof(type, member)); })

5.5 asm goto —— 内联汇编中的跳转

// asm goto 是 GCC 4.5+ 引入的扩展
// 允许内联汇编代码跳转到 C 语言标签
// 主要用于实现 likely/unlikely 和编译时指令调度
 
// 示例:简化的 arch_static_branch(内核静态分支优化)
// 定义在 include/linux/jump_label.h
//
// 静态分支是一种内核优化技术:
//   编译器生成一条 NOP 指令
//   运行时如果需要启用某个特性,将 NOP 替换为 JMP
//   几乎零开销的条件检查
// 练习 3: GNU C 扩展实验
// 编写一个用户态 C 程序,测试以下 GCC 扩展:
// 1. 使用 __attribute__((constructor)) 在 main 前执行函数
// 2. 使用 typeof 实现类型安全的 swap 宏
// 3. 使用 __builtin_clz 计算一个数的二进制最高位位置
// 4. 使用 ({ }) 语句表达式实现一个可以打印变量名的 DEBUG_PRINT 宏
 
// 示例框架:
#include <stdio.h>
 
// 在 main() 之前自动执行
__attribute__((constructor))
static void before_main(void) {
    printf("Before main()! Kernel module uses this pattern.\n");
}
 
// typeof + 语句表达式 实现安全 swap
#define swap(a, b) ({                           \
    typeof(a) _tmp = (a);                       \
    (a) = (b);                                  \
    (b) = _tmp;                                 \
})
 
int main(void) {
    int x = 10, y = 20;
    swap(x, y);
    printf("x=%d, y=%d\n", x, y);  // x=20, y=10
    printf("msb of 256 = %d\n", 32 - __builtin_clz(256));
    return 0;
}

第六节:内核编程的基本约束


6.1 没有标准 C 库

内核中不能使用 <stdio.h>, <stdlib.h>, <string.h> 等标准库头文件。内核有自己的实现:

用户态内核态说明
printf()printk()带有日志级别的打印
malloc()kmalloc() / vmalloc()内核内存分配,指定标志(GFP_KERNEL 等)
free()kfree() / vfree()分别对应 kmalloc/vmalloc
memcpy()memcpy()内核有自己的实现(lib/string.c)
memset()memset()同上
strlen()strlen()同上
sprintf()sprintf()同上(lib/vsprintf.c)
atoi()simple_strtol()内核版本
exit()do_exit()终止当前进程(不是程序退出)

6.2 不能使用浮点

// 错误!内核态一般不能使用浮点运算
// float f = 3.14;  // 编译可能通过但行为未定义
 
// 原因:
// 1. 浮点寄存器(x87/XMM)的使用需要上下文切换时保存/恢复
// 2. 如果在内核代码中使用了浮点但没有保存 FPU 状态,
//    用户进程的浮点计算结果可能被破坏
// 3. 内核设计哲学:驱动程序不需要浮点运算
 
// 正确做法:使用定点数或整数运算替代
// 例如: 3.14 可以表示为 314/100 用整数运算

6.3 有限的栈空间

// 内核栈通常只有 8KB 或 16KB(取决于配置)
// (x86-64: 16KB, 加上中断栈)
 
// 错误做法:在栈上分配大数组
// static void process_data(void) {
//     unsigned char buffer[64 * 1024];  // 64KB! 栈溢出!
//     ...
// }
 
// 正确做法:使用堆内存
static void process_data(void) {
    unsigned char *buffer = kmalloc(64 * 1024, GFP_KERNEL);
    if (!buffer)
        return;
    // ... 处理数据 ...
    kfree(buffer);
}

6.4 并发无处不在

// 用户态程序通常是单线程的(除非显式创建线程)
// 内核代码几乎总是在并发环境中运行
// 你的模块可能同时在多个 CPU 核心上被执行
// 中断处理程序可能与你的代码并发运行
 
// 因此内核编程需要格外的并发安全意识
// 详见 [[06_并发与同步|并发与同步]]

章节测试


判断题 1

Linux 内核完全使用标准 C(C89/C99)编写,不使用任何 GCC 扩展。 ( )

  • 正确

  • 错误

判断题 2

用户态程序可以直接访问物理内存地址。 ( )

  • 正确

  • 错误

判断题 3

syscall 指令执行后,CPU 自动从 Ring 3 切换到 Ring 0。 ( )

  • 正确

  • 错误

判断题 4

内核模块(LKM)运行在用户态,通过系统调用请求内核服务。 ( )

  • 正确

  • 错误

判断题 5

内核态代码可以安全地使用 printf() 函数输出调试信息。 ( )

  • 正确

  • 错误

判断题 6

内核中使用浮点运算是安全的,编译器会自动保存和恢复 FPU 上下文。 ( )

  • 正确
  • 错误

判断题 7

内核模块加载后可以通过 lsmod 命令查看。 ( )

  • 正确
  • 错误

判断题 8

__attribute__((aligned(64))) 是标准 C 的一部分,在 C11 标准中定义。 ( )

  • 正确
  • 错误

判断题 9

Rust 自 Linux 6.1 起正式支持内核模块开发,用于替代 C 语言。 ( )

  • 正确
  • 错误

判断题 10

内核模块的 module_init() 函数在模块加载时被调用,module_exit() 在卸载时被调用。 ( )

  • 正确
  • 错误

选择题 1

以下哪项不是 Linux 选择 C 语言作为内核主要语言的原因?

  • A. C 语言编译后无运行时开销(无 GC、无异常展开、无隐式内存分配)

  • B. C 语言有 ABI 稳定性,不同版本编译的目标文件可以链接

  • C. C 语言的 RAII 和智能指针机制适合资源管理

  • D. C 语言允许直接操作内存地址和硬件寄存器

选择题 2

x86-64 Linux 中,系统调用使用哪条指令?

  • A. int 0x80

  • B. sysenter

  • C. syscall

  • D. call gate

选择题 3

以下哪个宏用于告诉 GCC 某个分支更可能发生?

  • A. __builtin_expect(x, 0)

  • B. likely(x)

  • C. __attribute__((hot))

  • D. __section(".fast")

选择题 4

container_of(ptr, type, member) 宏的作用是什么?

  • A. 检查一个指针是否在指定的内存区间内

  • B. 通过结构体成员的指针反推出包含该成员的结构体的指针

  • C. 将一块内存转换为指定类型

  • D. 计算结构体成员相对于结构体起始地址的偏移量

选择题 5

内核模块的 Makefile 中 obj-m := hello_kernel.o 的含义是?

  • A. 将 hello_kernel.o 作为目标文件(object)编译

  • B. 将 hello_kernel.o 作为内核模块(module)目标(不是直接链接进内核,而是生成 .ko)

  • C. 将 hello_kernel.o 标记为多线程编译

  • D. 将 hello_kernel.o 的所有依赖标记为必须模块

选择题 6

x86-64 Linux 使用多少个特权级别?

  • A. 1 个

  • B. 2 个(Ring 0 和 Ring 3)

  • C. 3 个(Ring 0, Ring 1, Ring 3)

  • D. 4 个(全部使用)

选择题 7

以下哪种情况会导致内核模块编译失败?

  • A. 在模块中使用 printk

  • B. 调用 printf 而不是 printk

  • C. 使用 malloc 而不是 kmalloc

  • D. B 和 C 都会导致编译失败

选择题 8

以下关于 Rust 在 Linux 内核中角色的描述,哪个是正确的?

  • A. Rust 在 Linux 5.0 中就已合入,是内核的主要语言

  • B. Rust 被设计为替代所有 C 代码,内核将从 C 完全迁移到 Rust

  • C. Rust 自 Linux 6.1 合入,主要用于编写新的设备驱动,作为 C 的补充

  • D. Rust 在内核中只用于文档编写,不用于实际代码

选择题 9

printk(KERN_ERR "error: %s\n", msg) 中的 KERN_ERR 的作用是?

  • A. 指定此消息只输出到特定内核模块

  • B. 设置消息的日志级别(此例为错误级别)

  • C. 指示 printk 只输出到控制台而不写日志

  • D. 指定错误码转换格式

选择题 10

内核模块加载后,其 .init.text 段中的函数会怎样?

  • A. 永远保留,随时可以调用

  • B. 只有 module_init 调用的函数才会保留

  • C. 初始化完成后,如果配置了 CONFIG_STRICT_MEMORY,内核可能释放 .init 段以节省内存

  • D. 每次此函数被调用时,内核会重新从磁盘加载到内存


️ 编程练习题

练习题 1:增强版内核模块()

难度:

编写一个内核模块 sysinfo_mod.c,在加载时输出以下系统信息:

  1. 当前内核版本(使用 UTS_RELEASEinit_uts_ns.name.release
  2. 当前进程名和 PID(使用 current->commcurrent->pid
  3. 系统自启动以来的 jiffies 数
  4. 当前在线 CPU 数量(使用 num_online_cpus()
  5. 统计此模块被加载的次数(思考:模块卸载后静态变量会丢失吗?为什么?)

要求: 使用 __init__exit 标记函数。编写 Makefile 和测试脚本。

练习题 2:用 strace 追踪系统调用()

难度:

写出以下操作涉及的系统调用链:

  1. echo "hello" > /tmp/test.txt
  2. cat /tmp/test.txt
  3. ls -l /tmp

使用 strace -f -e trace=openat,write,read,close 验证你的答案。写出每个命令对应的系统调用序列(调用号、参数含义)。

练习题 3:GNU C 扩展练习()

难度:

编写用户态 C 程序,实现以下功能(使用 GNU C 扩展):

  1. 使用 ({...}) 语句表达式实现类型安全的 max3(a, b, c) 宏,返回三个数的最大值
  2. 使用 __attribute__((constructor))__attribute__((destructor)) 在 main 前后执行函数
  3. 使用 __builtin_choose_expr 配合 __builtin_types_compatible_p 实现编译时类型派发
  4. 编译时使用 gcc -S 查看生成的汇编代码,理解每个扩展如何转化为 CPU 指令

提示: __builtin_choose_expr(cond, expr1, expr2) 类似编译时的三目运算符,但要求 cond 必须是编译时常量。


知识网络