Post

系统编程入门训练笔记

系统编程入门训练笔记

阅读指南

1
2
3
4
5
6
7
8
9
10
11
知识依赖链:
  POSIX 文件 I/O(Block 1)
        │
        ▼
  函数指针结构体(Block 2)
        │
        ▼
  Make 与编译流程(Block 3)
        │
        ▼
  实验1:Hello 内核模块  ← 你的最终目标

每个 Block 结构:

  1. 问题动机
  2. 核心概念讲解
  3. 带注释的示例代码
  4. 你的任务(必须完成,有明确验收标准)

Block 1:文件描述符与 POSIX I/O

1.1 问题动机

你已经用过 fopen/fread/fwrite/fclose(C 标准库)。但内核实验里的 test_app.c 用的是另一套:

1
2
3
4
int fd = open("/dev/mydev", O_RDWR);
write(fd, buf, len);
read(fd, buf, len);
close(fd);

这两套 API 有什么本质区别?

1
2
3
4
5
6
7
8
9
fopen/fread(C 标准库,libc)
  用户程序 → libc 缓冲区 → [系统调用] → 内核 VFS → 磁盘
                ↑
          这一层是 libc 加的,在用户空间

open/read(POSIX,系统调用)
  用户程序 → [系统调用] → 内核 VFS → 磁盘
  ↑
  直接跨越到内核,中间没有 libc 缓冲区

为什么驱动实验用 POSIX 而不是 libc?

因为 /dev/mydev 是一个设备文件,不是普通文件。fopen 内部假设它操作的是有固定格式的普通文件(会做缓冲、会做 EOF 检测),对设备文件行为不可预测。open 系统调用是直接与内核交互,才是操作设备文件的正确方式。


1.2 文件描述符(File Descriptor)是什么

文件描述符(fd) 是一个非负整数,是内核中”打开文件表”的索引。

1
2
3
4
5
6
7
8
9
10
11
进程视角:
  fd = 3  →  内核中 struct file(记录offset、权限、指向的驱动)
  fd = 4  →  内核中另一个 struct file
  fd = 5  →  又一个...

每个进程默认已经打开了三个:
  fd = 0  → stdin  (标准输入)
  fd = 1  → stdout (标准输出)
  fd = 2  → stderr (标准错误)

所以你自己 open 的第一个文件,fd 通常是 3。

fd 不是指针,不是地址,就是一个整数。内核拿着这个整数去查自己的表。


1.3 核心 API 详解

open

1
2
3
4
#include <fcntl.h>

int fd = open(const char *path, int flags);
int fd = open(const char *path, int flags, mode_t mode); // 创建文件时用

flags 常用值

Flag含义
O_RDONLY只读
O_WRONLY只写
O_RDWR读写
O_CREAT不存在则创建(需要同时提供 mode)
O_TRUNC打开时清空文件
O_APPEND每次写都追加到文件末尾

这些 flag 可以用 | 组合:O_WRONLY | O_CREAT | O_TRUNC

返回值

  • 成功:返回一个 ≥ 0 的整数(fd)
  • 失败:返回 -1,并设置全局变量 errno

mode 参数(只在 O_CREAT 时有效):

1
2
3
4
5
6
open("file.txt", O_WRONLY | O_CREAT, 0644);
//                                   ↑
//                          八进制权限:rw-r--r--
//                          6 = rw- (owner)
//                          4 = r-- (group)
//                          4 = r-- (others)

read

1
2
3
#include <unistd.h>

ssize_t ret = read(int fd, void *buf, size_t count);
  • 从 fd 当前偏移量位置读取最多 count 字节到 buf
  • 返回实际读取字节数
  • 返回 0:到达文件末尾(EOF)
  • 返回 -1:出错

关键陷阱read 不保证一次读满 count 字节!网络 socket、管道、设备文件都可能只返回部分数据(Short Read)。严格的代码必须循环读取直到满足需求或到达 EOF。

write

1
ssize_t ret = write(int fd, const void *buf, size_t count);
  • buf 中的 count 字节写入 fd
  • 返回实际写入字节数
  • 返回 -1:出错

关键陷阱:同样存在 Short Write。对设备文件和网络 socket,必须循环写入。

close

1
int ret = close(int fd);
  • 释放 fd,内核清理对应的 struct file
  • 返回 0 成功,-1 失败

lseek(移动文件偏移量)

1
2
3
#include <unistd.h>

off_t new_pos = lseek(int fd, off_t offset, int whence);
whence含义
SEEK_SET从文件开头偏移 offset 字节
SEEK_CUR从当前位置偏移 offset 字节
SEEK_END从文件末尾偏移 offset 字节

常用:lseek(fd, 0, SEEK_SET) = 回到文件开头(等价于”rewind”)


1.4 错误处理:errnoperror

系统调用失败时,会设置全局变量 errno(定义在 <errno.h>)为一个错误码。

1
2
3
4
5
6
7
8
9
10
11
12
#include <errno.h>
#include <string.h>  // strerror

int fd = open("nonexistent.txt", O_RDONLY);
if (fd == -1) {
    // 方式1:perror 自动读取 errno 并打印
    perror("open failed");
    // 输出:open failed: No such file or directory

    // 方式2:手动读取
    printf("错误码: %d, 描述: %s\n", errno, strerror(errno));
}

常见 errno 值

宏名数值含义
ENOENT2No such file or directory
EACCES13Permission denied
EBADF9Bad file descriptor(fd 无效)
EFAULT14Bad address(指针非法)
EINVAL22Invalid argument

重要errno 只有在系统调用返回 -1 时才有意义。成功的调用不会清零 errno,所以不要在成功后读 errno。


1.5 完整示例代码

仔细读这段代码,每一行都有注释说明它在做什么、为什么这样做:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// posix_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>      // open, O_RDWR, O_CREAT...
#include <unistd.h>     // read, write, close, lseek
#include <string.h>     // strlen, memset
#include <errno.h>      // errno

#define FILE_PATH "test_output.txt"

// 辅助函数:循环写入,处理 Short Write
// 返回 0 成功,-1 失败
int write_all(int fd, const char *buf, size_t len) {
    size_t written = 0;
    while (written < len) {
        ssize_t ret = write(fd, buf + written, len - written);
        if (ret < 0) {
            perror("write");
            return -1;
        }
        written += ret;
    }
    return 0;
}

// 辅助函数:循环读取,读满 len 字节或到 EOF
// 返回实际读到的字节数,-1 失败
ssize_t read_all(int fd, char *buf, size_t len) {
    size_t total = 0;
    while (total < len) {
        ssize_t ret = read(fd, buf + total, len - total);
        if (ret < 0) {
            perror("read");
            return -1;
        }
        if (ret == 0) break;  // EOF
        total += ret;
    }
    return (ssize_t)total;
}

int main(void) {
    int fd;
    const char *message = "Hello, POSIX I/O! 这是写入文件的数据。\n";
    char read_buf[256];
    ssize_t bytes_read;

    // ── 第一步:创建并写入文件 ──────────────────────────

    // O_WRONLY | O_CREAT | O_TRUNC: 只写方式打开
    // 若不存在则创建,若已存在则清空
    // 0644: 文件权限 rw-r--r--
    fd = open(FILE_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open for write");
        return EXIT_FAILURE;  // EXIT_FAILURE = 1
    }
    printf("[1] 文件打开成功,fd = %d\n", fd);

    // 写入数据(使用我们的 write_all,处理 Short Write)
    if (write_all(fd, message, strlen(message)) < 0) {
        close(fd);
        return EXIT_FAILURE;
    }
    printf("[2] 写入 %zu 字节成功\n", strlen(message));

    // 关闭写入的 fd
    close(fd);
    printf("[3] 写入的文件已关闭\n");

    // ── 第二步:重新打开并读取 ──────────────────────────

    // O_RDONLY: 只读方式打开
    fd = open(FILE_PATH, O_RDONLY);
    if (fd == -1) {
        perror("open for read");
        return EXIT_FAILURE;
    }
    printf("[4] 文件重新打开,fd = %d\n", fd);

    // 清空读缓冲区,避免残留数据干扰
    memset(read_buf, 0, sizeof(read_buf));

    bytes_read = read_all(fd, read_buf, sizeof(read_buf) - 1);
    if (bytes_read < 0) {
        close(fd);
        return EXIT_FAILURE;
    }
    printf("[5] 读取了 %zd 字节\n", bytes_read);
    printf("[6] 内容: %s", read_buf);

    // ── 第三步:用 lseek 演示偏移量控制 ────────────────

    // 回到文件开头
    off_t pos = lseek(fd, 0, SEEK_SET);
    printf("[7] lseek 回到开头,当前位置: %ld\n", (long)pos);

    // 只读前 5 个字节
    memset(read_buf, 0, sizeof(read_buf));
    bytes_read = read(fd, read_buf, 5);
    printf("[8] 只读前5字节: \"%.*s\"\n", (int)bytes_read, read_buf);

    // 查询当前位置
    pos = lseek(fd, 0, SEEK_CUR);
    printf("[9] 当前文件偏移量: %ld\n", (long)pos);

    close(fd);
    printf("[10] 完成,fd 已关闭\n");

    return EXIT_SUCCESS;  // EXIT_SUCCESS = 0
}

编译和运行

1
2
3
gcc posix_demo.c -o posix_demo
./posix_demo
cat test_output.txt   # 验证文件内容

✅ Block 1 任务:你必须完成这个

任务描述:写一个 C 程序 my_copy.c,实现一个简化版的 cp 命令:

1
./my_copy source.txt dest.txt

功能要求

  1. 从命令行参数读取源文件路径和目标文件路径
  2. open 打开源文件(只读)
  3. open 创建目标文件(只写,若存在则覆盖)
  4. 循环 read + write,将源文件内容复制到目标文件(每次操作 512 字节)
  5. 正确处理所有错误(每个系统调用失败都要 perror + 退出)
  6. 最后关闭两个 fd

验收标准

1
2
3
echo "Hello, this is a test file with some content." > source.txt
./my_copy source.txt dest.txt
diff source.txt dest.txt   # 无输出 = 文件完全一致 = 成功

思考题(做完任务后回答):

  • 如果源文件是 1 MB,你的程序会调用 read 多少次?
  • 如果 read 某次返回 300(而不是 512),你的代码能正确处理吗?

Block 2:函数指针与函数指针结构体

2.1 问题动机

实验里这段代码,你一定看到过:

1
2
3
4
5
6
7
static struct file_operations fops = {
    .owner   = THIS_MODULE,
    .open    = device_open,
    .release = device_release,
    .read    = device_read,
    .write   = device_write,
};

这是内核驱动的核心接口机制。整个 VFS(虚拟文件系统)就是靠这样的函数指针结构体来实现”面向对象”的——不同的设备注册不同的 fops,但用户程序调用的是统一的 read/write 系统调用。

要读懂这个,你需要彻底理解 C 语言的函数指针。


2.2 函数指针基础

函数在内存中是什么?

函数编译后就是一段机器码,存放在内存的代码段(.text)。函数名本身就是这段代码的起始地址

1
2
3
4
5
6
void hello(void) {
    printf("Hello!\n");
}

// hello 这个名字就是函数的地址
// 可以把它赋给一个指针变量

函数指针的声明语法(这是 C 里最令人头疼的语法之一):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 普通变量声明:
int x;

// 普通指针声明:
int *p;

// 函数声明:
int add(int a, int b);

// 函数指针声明(指向"接收两个int、返回int"类型函数的指针):
int (*fp)(int, int);
//  ↑   ↑   ↑
//  *   fp  参数类型列表
// 说明它是指针,不是函数名

核心规则:把普通函数声明中的”函数名”换成”(*指针名)”,就得到对应的函数指针类型。

赋值和调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int add(int a, int b) {
    return a + b;
}

int main(void) {
    // 声明函数指针并赋值
    // 方式1:直接用函数名(函数名自动转换为地址)
    int (*fp)(int, int) = add;

    // 方式2:显式取地址(等价,推荐写法更清晰)
    int (*fp2)(int, int) = &add;

    // 调用方式1:直接调用(推荐)
    int result = fp(3, 4);

    // 调用方式2:显式解引用(等价)
    int result2 = (*fp)(3, 4);

    printf("%d\n", result);   // 输出 7
    return 0;
}

2.3 用 typedef 简化函数指针

裸函数指针语法丑陋,实际代码中大量使用 typedef

1
2
3
4
5
6
7
8
9
10
11
// 定义类型别名:ReadFunc 是"接收 char*, int,返回 int"的函数指针类型
typedef int (*ReadFunc)(char *buf, int len);

// 现在声明变量就清晰多了:
ReadFunc my_reader;

// 赋值:
my_reader = some_read_function;

// 调用:
int n = my_reader(buf, 100);

内核源码大量使用这个模式。


2.4 函数指针结构体:C 语言的”接口”

现在把多个函数指针放进结构体,就得到了一个”接口”:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 定义接口(类似面向对象的抽象类)
struct animal_ops {
    void (*speak)(void);
    void (*move)(void);
    int  (*eat)(const char *food);
};

// 实现1:狗
void dog_speak(void) { printf("Woof!\n"); }
void dog_move(void)  { printf("Dog runs.\n"); }
int  dog_eat(const char *food) {
    printf("Dog eats %s\n", food);
    return 0;
}

struct animal_ops dog_ops = {
    .speak = dog_speak,
    .move  = dog_move,
    .eat   = dog_eat,
};

// 实现2:猫
void cat_speak(void) { printf("Meow!\n"); }
void cat_move(void)  { printf("Cat slinks.\n"); }
int  cat_eat(const char *food) {
    printf("Cat ignores %s\n", food);
    return -1;
}

struct animal_ops cat_ops = {
    .speak = cat_speak,
    .move  = cat_move,
    .eat   = cat_eat,
};

// 统一的操作函数:不关心是什么动物
void make_animal_do_stuff(struct animal_ops *ops) {
    ops->speak();
    ops->move();
    ops->eat("kibble");
}

int main(void) {
    make_animal_do_stuff(&dog_ops);
    printf("---\n");
    make_animal_do_stuff(&cat_ops);
    return 0;
}

这就是 file_operations 的设计本质

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 内核里的 file_operations(简化版)
struct file_operations {
    int     (*open)(struct inode *, struct file *);
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int     (*release)(struct inode *, struct file *);
};

// 你的驱动注册一个实现:
struct file_operations mydev_fops = {
    .open    = mydev_open,    // 你写的函数
    .read    = mydev_read,
    .write   = mydev_write,
    .release = mydev_release,
};

// 内核的 VFS 调用驱动(不关心是哪个驱动):
void vfs_read(struct file *f, ...) {
    f->f_op->read(f, ...);   // 多态!调用的是注册进来的那个函数
    //       ↑
    //  f_op 是指向 file_operations 的指针
}

关键洞察:这就是面向对象的”多态”,用 C 实现。Go 的 interface、C++ 的虚函数表、Java 的接口,底层都是同一个思想:函数指针表。


2.5 NULL 函数指针:不实现某些操作

如果你的驱动不支持某个操作(比如不支持 lseek),只需要在 fops 里不设置那个字段(C99 指定初始化器未提及的字段默认为 0,即 NULL):

1
2
3
4
5
struct file_operations fops = {
    .read  = device_read,
    .write = device_write,
    // .llseek 没有设置 → 默认为 NULL
};

内核 VFS 在调用前会检查:

1
2
3
4
if (f->f_op->llseek)
    return f->f_op->llseek(f, offset, whence);
else
    return default_llseek(f, offset, whence);  // 用默认实现

2.6 完整示例代码

下面这段代码模拟一个微型 VFS,有两个”设备”:内存设备和磁盘设备,通过函数指针表统一操作:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
// mini_vfs.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

// ── 接口定义(类比 file_operations)─────────────────────

struct device_ops {
    int     (*open)(const char *name);
    ssize_t (*read)(char *buf, int len);
    ssize_t (*write)(const char *buf, int len);
    void    (*close)(void);
};

// ── 实现1:内存设备 ─────────────────────────────────────

#define MEM_BUF_SIZE 256
static char mem_buffer[MEM_BUF_SIZE];
static int  mem_pos = 0;
static int  mem_size = 0;

static int mem_open(const char *name) {
    printf("[MemDev] 打开设备: %s\n", name);
    mem_pos = 0;
    return 0;
}

static ssize_t mem_read(char *buf, int len) {
    int available = mem_size - mem_pos;
    if (available <= 0) return 0;  // EOF
    int to_read = (len < available) ? len : available;
    memcpy(buf, mem_buffer + mem_pos, to_read);
    mem_pos += to_read;
    printf("[MemDev] 读取 %d 字节\n", to_read);
    return to_read;
}

static ssize_t mem_write(const char *buf, int len) {
    if (len > MEM_BUF_SIZE - 1) len = MEM_BUF_SIZE - 1;
    memcpy(mem_buffer, buf, len);
    mem_buffer[len] = '\0';
    mem_size = len;
    mem_pos = 0;
    printf("[MemDev] 写入 %d 字节: %s\n", len, mem_buffer);
    return len;
}

static void mem_close(void) {
    printf("[MemDev] 关闭设备\n");
}

// 注册内存设备的函数指针表
struct device_ops mem_ops = {
    .open  = mem_open,
    .read  = mem_read,
    .write = mem_write,
    .close = mem_close,
};

// ── 实现2:日志设备(只写,不支持读)────────────────────

static int log_open(const char *name) {
    printf("[LogDev] 日志设备打开: %s\n", name);
    return 0;
}

static ssize_t log_write(const char *buf, int len) {
    printf("[LogDev] LOG: %.*s\n", len, buf);
    return len;
}

static void log_close(void) {
    printf("[LogDev] 日志设备关闭\n");
}

struct device_ops log_ops = {
    .open  = log_open,
    .read  = NULL,          // 不支持读!
    .write = log_write,
    .close = log_close,
};

// ── 统一操作函数(类比内核 VFS)─────────────────────────

// 这个函数不知道、也不在乎"ops"是什么设备
// 只通过函数指针表操作
void use_device(struct device_ops *ops, const char *name) {
    char buf[256];
    ssize_t n;

    ops->open(name);

    // 写入
    const char *data = "Hello from VFS layer!";
    ops->write(data, strlen(data));

    // 读取(如果支持)
    if (ops->read != NULL) {
        memset(buf, 0, sizeof(buf));
        n = ops->read(buf, sizeof(buf) - 1);
        if (n > 0) {
            printf("[VFS] 读回数据: %s\n", buf);
        }
    } else {
        printf("[VFS] 该设备不支持读操作\n");
    }

    ops->close();
}

int main(void) {
    printf("=== 使用内存设备 ===\n");
    use_device(&mem_ops, "/dev/mem");

    printf("\n=== 使用日志设备 ===\n");
    use_device(&log_ops, "/dev/log");

    return 0;
}

编译和运行

1
2
gcc mini_vfs.c -o mini_vfs
./mini_vfs

预期输出

1
2
3
4
5
6
7
8
9
10
11
12
=== 使用内存设备 ===
[MemDev] 打开设备: /dev/mem
[MemDev] 写入 21 字节: Hello from VFS layer!
[MemDev] 读取 21 字节
[VFS] 读回数据: Hello from VFS layer!
[MemDev] 关闭设备

=== 使用日志设备 ===
[LogDev] 日志设备打开: /dev/log
[LogDev] LOG: Hello from VFS layer!
[VFS] 该设备不支持读操作
[LogDev] 日志设备关闭

✅ Block 2 任务:你必须完成这个

任务描述:在 mini_vfs.c 的基础上,添加第三个设备:计数器设备

计数器设备行为

  • open:打印”计数器设备打开,计数器归零”,将内部计数器重置为 0
  • write:每次调用,将计数器加上写入的字节数,打印当前计数值(不实际存储数据)
  • read:将当前计数值格式化为字符串(例如 "counter=42\n")写入 buf,返回字节数
  • close:打印”计数器设备关闭,最终计数: X”

然后:修改 main 函数,用 use_device 函数测试计数器设备。

验收标准:运行后输出应包含:

1
2
3
4
[CounterDev] 计数器设备打开,计数器归零
[CounterDev] 写入 N 字节,当前计数: N
[VFS] 读回数据: counter=N
[CounterDev] 计数器设备关闭,最终计数: N

思考题(做完任务后回答):

  • 如果将来要支持 50 种不同的设备,这套机制需要修改 use_device 函数吗?
  • 对比 Block 1 的 my_copy.cuse_device 中的 ops->write(data, len)write(fd, data, len) 有什么本质联系?

Block 3:Make 与编译流程

3.1 问题动机

内核实验的 Makefile 看起来像这样:

1
2
3
4
5
6
obj-m += hello_module.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD  := $(shell pwd)

all:
    make -C $(KDIR) M=$(PWD) modules

如果你不理解 Makefile 的基本运作,这段代码会显得完全不可理解。本节从基础讲起,最后你会明白这段代码每个字的意义。


3.2 为什么需要 Make

假设你有三个文件:

1
2
main.c  ─依赖─►  utils.h
utils.c ─依赖─►  utils.h

每次改了 utils.h,你需要重新编译 main.cutils.c。手动管理很麻烦,而且容易忘记。Make 做的事情是:

  1. 描述文件之间的依赖关系
  2. 只重新编译”比依赖更旧”的文件
  3. 按正确顺序执行编译命令

3.3 Makefile 核心语法

Makefile 的基本单元是规则(Rule)

1
2
3
目标: 依赖1 依赖2 ...
[TAB]命令1
[TAB]命令2

严格要求:命令行必须以 TAB 开头,不能是空格。这是 Make 的历史遗留设计,非常反人类,但你必须遵守。

一个完整的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Makefile

# 最终目标:生成可执行文件 myapp
myapp: main.o utils.o
	gcc -o myapp main.o utils.o

# 中间目标:编译 main.c 为 main.o
main.o: main.c utils.h
	gcc -c main.c -o main.o

# 中间目标:编译 utils.c 为 utils.o
utils.o: utils.c utils.h
	gcc -c utils.c -o utils.o

# 伪目标:清理编译产物(.PHONY 声明它不是真实文件)
.PHONY: clean
clean:
	rm -f myapp main.o utils.o

Make 的执行逻辑

1
2
3
4
5
6
7
8
9
make(不带参数)→ 执行第一个目标(myapp)

检查 myapp 是否比 main.o 和 utils.o 更新?
  → 不是 → 需要重新生成
    → 检查 main.o 是否比 main.c 和 utils.h 更新?
      → 不是 → gcc -c main.c -o main.o
    → 检查 utils.o 是否比 utils.c 和 utils.h 更新?
      → 不是 → gcc -c utils.c -o utils.o
  → 然后:gcc -o myapp main.o utils.o

3.4 Makefile 变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 定义变量
CC      = gcc
CFLAGS  = -Wall -Wextra -g
TARGET  = myapp
SRCS    = main.c utils.c
OBJS    = main.o utils.o

# 使用变量:$(变量名)
$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) -o $(TARGET) $(OBJS)

# 自动变量(Make 内置):
# $@  = 当前规则的目标名
# $<  = 第一个依赖文件名
# $^  = 所有依赖文件
main.o: main.c utils.h
	$(CC) $(CFLAGS) -c $< -o $@
#                       ↑    ↑
#                    main.c  main.o

Shell 命令替换

1
2
3
4
5
6
# $(shell 命令) 执行 shell 命令,结果作为字符串嵌入
KERNEL_VERSION := $(shell uname -r)
PWD            := $(shell pwd)

# 用法示例:
KDIR := /lib/modules/$(KERNEL_VERSION)/build

3.5 理解内核 Makefile

现在你有了基础,来解析内核模块的 Makefile:

1
2
3
4
5
6
7
8
9
10
obj-m += hello_module.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
    make -C $(KDIR) M=$(PWD) modules

clean:
    make -C $(KDIR) M=$(PWD) clean

逐行解析

1
obj-m += hello_module.o

→ 这是 Linux Kbuild 系统的变量。obj-m 表示”编译为外部内核模块”的目标列表。Kbuild 看到这个变量,就知道要把 hello_module.c 编译成 hello_module.ko

1
KDIR := /lib/modules/$(shell uname -r)/build

→ 当前运行内核的构建目录。例如 uname -r 是 5.15.0-76-generic,则 KDIR 是 /lib/modules/5.15.0-76-generic/build,这个目录里有内核头文件和 Kbuild 配置。

1
make -C $(KDIR) M=$(PWD) modules

-C $(KDIR):先切换到内核构建目录(激活 Kbuild 系统) → M=$(PWD):告诉 Kbuild”外部模块源码在这个目录” → modules:Kbuild 的目标,表示”构建外部模块”

执行顺序

1
2
3
4
5
6
7
8
你运行 make
  → 执行 make -C /lib/modules/.../build M=/your/dir modules
    → Kbuild 系统被激活(KERNELRELEASE 变量被设置)
      → Kbuild 切换回你的目录
        → 读取你的 Makefile(此时 KERNELRELEASE 已设置)
          → 看到 obj-m += hello_module.o
            → 用内核的 gcc 参数编译 hello_module.c
              → 生成 hello_module.ko

为什么需要这么做? 因为 .ko 文件必须与当前内核完全匹配,包括:

  • 内核版本号(写入 .ko 的元数据中)
  • CONFIG_* 编译选项(影响结构体布局)
  • GCC 版本和参数

3.6 完整示例:多文件 Makefile

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
28
29
# Makefile for a multi-file project

CC      := gcc
CFLAGS  := -Wall -Wextra -g -std=c99
TARGET  := myapp

# 自动找当前目录所有 .c 文件
SRCS    := $(wildcard *.c)
# 把 .c 替换为 .o
OBJS    := $(SRCS:.c=.o)

# 默认目标
all: $(TARGET)

# 链接
$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) -o $@ $^
	@echo "链接完成: $@"

# 编译每个 .c 文件(模式规则)
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@
	@echo "编译: $<$@"

# 清理
.PHONY: all clean
clean:
	rm -f $(OBJS) $(TARGET)
	@echo "清理完成"

模式规则 %.o: %.c 表示:对于任何 .o 文件,其依赖是同名的 .c 文件,命令是 gcc -c% 是通配符,类似 shell 的 *


✅ Block 3 任务:你必须完成这个

任务描述:为你在 Block 1 和 Block 2 写的程序创建一个统一的 Makefile。

项目结构:

1
2
3
4
project/
  ├── my_copy.c        (Block 1 任务)
  ├── mini_vfs.c       (Block 2 任务,含计数器设备)
  └── Makefile         (你要写的)

Makefile 要求

  1. makemake all:同时编译出 my_copymini_vfs 两个可执行文件
  2. make my_copy:只编译 my_copy
  3. make mini_vfs:只编译 mini_vfs
  4. make clean:删除所有编译产物(可执行文件和 .o 文件)
  5. 必须使用变量(CCCFLAGS
  6. 编译时启用 -Wall(所有警告)

验收标准

1
2
3
make         # 两个程序都编译成功,无警告
make clean   # 产物被删除
make my_copy # 只编译 my_copy,mini_vfs 不存在

思考题(做完任务后回答):

  • 如果你只改了 my_copy.c,执行 make allmini_vfs 会被重新编译吗?为什么?
  • 内核 Makefile 中的 make -C $(KDIR) M=$(PWD) modules 和你的 Makefile 中的 gcc -o myapp ... 有什么本质的相同点?

综合检验:串联三个 Block

完成以上三个任务后,做这个综合练习来确认你已经准备好进入内核实验。

综合任务:阅读实验文档里的 test_app.c(用户态测试程序),不看实验笔记,自己回答以下问题:

  1. fd = open(DEVICE_PATH, O_RDWR) 失败时会返回什么?应该怎么判断?
  2. write(fd, write_buf, strlen(write_buf)) 调用成功后,strlen(write_buf) 和实际写入字节数一定相等吗?
  3. 为什么测试程序要 close(fd) 然后再 open 一次,而不是直接 read?(提示:想想 offset)
  4. device_write 里的 fops.write = device_write 这行赋值,和你在 Block 2 里写的 counter_ops.write = counter_write 是完全一样的模式吗?
  5. 内核 Makefile 里的 obj-m += my_char_dev.o,和你写的 OBJS := main.o utils.o 起什么类似的作用?

如果这 5 个问题你能自信地回答,你已经准备好做内核实验了。


附录:环境搭建速查

Linux 环境(推荐 Ubuntu 22.04 / openEuler)

1
2
3
4
5
6
7
8
9
10
11
12
13
# 安装编译工具
sudo apt install gcc make      # Ubuntu
sudo dnf install gcc make      # openEuler/Fedora

# 验证安装
gcc --version
make --version

# 编译单个 C 文件
gcc -Wall -g source.c -o output

# 常用调试技巧:打印到 stderr(不被重定向影响)
fprintf(stderr, "debug: fd=%d\n", fd);

常用 Shell 命令速查

1
2
3
4
5
ls -la          # 查看文件(含权限、大小)
stat file.txt   # 查看文件详细信息(含 inode 号)
hexdump -C file # 以十六进制查看文件内容
strace ./prog   # 追踪程序的系统调用(极其有用!)
# strace 会打印每一个系统调用,帮你看 open/read/write 的实际参数和返回值

strace:你最好的学习工具

strace 能拦截并打印一个程序调用的所有系统调用,是理解用户态和内核交互的绝佳工具:

1
strace ./my_copy source.txt dest.txt

输出示例(节选):

1
2
3
4
5
6
7
openat(AT_FDCWD, "source.txt", O_RDONLY) = 3
openat(AT_FDCWD, "dest.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644) = 4
read(3, "Hello, this is a test file with "..., 512) = 47
write(4, "Hello, this is a test file with "..., 47) = 47
read(3, "", 512) = 0          ← EOF
close(3)                      = 0
close(4)                      = 0

你能直观看到每次 read/write 的 fd、数据和返回值。强烈建议在做完 Block 1 任务后,用 strace 观察 my_copy 的每一步。


进入内核实验的清单

完成以上所有任务后,对照这份清单确认准备情况:

  • 能用 open/read/write/close 操作普通文件,正确处理错误
  • 理解 fd 是什么,知道 lseek 如何影响 offset
  • 能声明和使用函数指针,知道 (*fp)(args)fp(args) 等价
  • 能定义带函数指针字段的结构体,并用指定初始化器赋值
  • 能写一个基本的 Makefile,理解目标、依赖、命令的关系
  • 理解内核 Makefile 中 -C $(KDIR) M=$(PWD) 的含义
  • 能回答”综合检验”的 5 个问题

全部打勾 → 去做实验1,遇到问题随时回来。

This post is licensed under CC BY 4.0 by the author.