系统编程入门训练笔记
阅读指南
1
2
3
4
5
6
7
8
9
10
11
知识依赖链:
POSIX 文件 I/O(Block 1)
│
▼
函数指针结构体(Block 2)
│
▼
Make 与编译流程(Block 3)
│
▼
实验1:Hello 内核模块 ← 你的最终目标
每个 Block 结构:
- 问题动机
- 核心概念讲解
- 带注释的示例代码
- 你的任务(必须完成,有明确验收标准)
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 错误处理:errno 与 perror
系统调用失败时,会设置全局变量 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 值:
| 宏名 | 数值 | 含义 |
|---|---|---|
ENOENT | 2 | No such file or directory |
EACCES | 13 | Permission denied |
EBADF | 9 | Bad file descriptor(fd 无效) |
EFAULT | 14 | Bad address(指针非法) |
EINVAL | 22 | Invalid 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
功能要求:
- 从命令行参数读取源文件路径和目标文件路径
- 用
open打开源文件(只读) - 用
open创建目标文件(只写,若存在则覆盖) - 循环
read+write,将源文件内容复制到目标文件(每次操作 512 字节) - 正确处理所有错误(每个系统调用失败都要
perror+ 退出) - 最后关闭两个 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:打印”计数器设备打开,计数器归零”,将内部计数器重置为 0write:每次调用,将计数器加上写入的字节数,打印当前计数值(不实际存储数据)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.c:use_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.c 和 utils.c。手动管理很麻烦,而且容易忘记。Make 做的事情是:
- 描述文件之间的依赖关系
- 只重新编译”比依赖更旧”的文件
- 按正确顺序执行编译命令
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 要求:
make或make all:同时编译出my_copy和mini_vfs两个可执行文件make my_copy:只编译my_copymake mini_vfs:只编译mini_vfsmake clean:删除所有编译产物(可执行文件和.o文件)- 必须使用变量(
CC、CFLAGS) - 编译时启用
-Wall(所有警告)
验收标准:
1
2
3
make # 两个程序都编译成功,无警告
make clean # 产物被删除
make my_copy # 只编译 my_copy,mini_vfs 不存在
思考题(做完任务后回答):
- 如果你只改了
my_copy.c,执行make all,mini_vfs会被重新编译吗?为什么? - 内核 Makefile 中的
make -C $(KDIR) M=$(PWD) modules和你的 Makefile 中的gcc -o myapp ...有什么本质的相同点?
综合检验:串联三个 Block
完成以上三个任务后,做这个综合练习来确认你已经准备好进入内核实验。
综合任务:阅读实验文档里的 test_app.c(用户态测试程序),不看实验笔记,自己回答以下问题:
fd = open(DEVICE_PATH, O_RDWR)失败时会返回什么?应该怎么判断?write(fd, write_buf, strlen(write_buf))调用成功后,strlen(write_buf)和实际写入字节数一定相等吗?- 为什么测试程序要
close(fd)然后再open一次,而不是直接read?(提示:想想 offset) device_write里的fops.write = device_write这行赋值,和你在 Block 2 里写的counter_ops.write = counter_write是完全一样的模式吗?- 内核 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,遇到问题随时回来。