Wait for signal (do_wait)
Linux 内核中 do_wait()
函数的实现,该函数是父进程等待子进程结束的系统调用的一部分。它通过在内核模式下等待信号,处理进程终止以及与父子进程相关的机制。让我们详细解读您提供的信息,涉及 do_wait()
的执行过程、内核模块的加载和等待队列的机制。
1. do_wait()
函数的作用
-
定义位置:
do_wait()
函数的实现位于/kernel/exit.c
文件中。这个函数是系统调用wait()
和waitpid()
的核心处理部分,它让父进程等待一个或多个子进程的终止并收集子进程的退出状态。 -
执行流程:当父进程调用
wait()
或waitpid()
等系统调用时,内核会进入do_wait()
函数,进行子进程状态的收集和处理。具体来说,do_wait()
会阻塞父进程,直到子进程终止或收到相应信号。
2. 创建 wait_opts
结构体并加入等待队列
-
wait_opts
结构体:wait_opts
是一个结构体,包含了等待操作所需的选项和参数。这个结构体用于保存wait()
操作的选项,例如应该等待哪个子进程、是否是非阻塞的等待等。 -
加入等待队列:通过调用
add_wait_queue()
,将当前任务(父进程)加入到等待队列中。等待队列是内核的一种数据结构,用来管理等待某个事件(如子进程结束)的进程。当子进程终止时,等待队列中的进程将被唤醒。
do_wait()
函数
long do_wait (struct wait_opts *wo);
-
作用:
do_wait()
是内核中的一个函数,它处理与wait()
和waitpid()
系统调用相关的逻辑。父进程通过wait()
或waitpid()
调用,内核通过do_wait()
实现等待子进程的结束,并收集子进程的退出状态。 -
参数
wo
:该参数是一个struct wait_opts
结构体的指针,包含了等待子进程终止的各种选项和信息。 -
struct wait_opts { enum pid_type wo_type; // 用于标识 PID 的类型 int wo_flags; // 等待选项 (WNOHANG, WEXITED 等) struct pid *wo_pid; // 内核中进程标识符 struct siginfo *wo_info; // 信号信息 int *wo_stat; // 子进程的终止状态 struct rusage *wo_rusage; // 子进程资源使用信息 wait_queue_entry_t child_wait; // 等待队列 int notask_error; // 错误码 };
结构体成员解释:
-
enum pid_type wo_type
- 定义位置:
pid_type
在/include/linux/pid.h
中定义,表示内核中 PID 的类型。 - 作用:
wo_type
表示要等待的进程类型(可以是PIDTYPE_PID
,PIDTYPE_PGID
,PIDTYPE_SID
等),它决定了等待的进程是单个进程、进程组还是会话。
- 定义位置:
-
int wo_flags
- 作用:
wo_flags
是等待选项的标志。常见的标志有:WNOHANG
:如果没有子进程结束,wait()
不会阻塞,而是立即返回。WEXITED
:等待已经终止的子进程。- 还有其他等待标志如
WUNTRACED
(等待暂停的子进程)、WCONTINUED
(等待恢复运行的子进程)。
- 作用:
-
struct pid *wo_pid
- 作用:
wo_pid
是内核中表示进程 ID 的数据结构。它用于标识要等待的子进程。可以通过find_get_pid()
函数获取对应的pid
结构体。 find_get_pid()
:这是内核中一个函数,用于从 PID 表中查找并获取与给定进程 ID 对应的pid
结构体。
- 作用:
-
struct siginfo *wo_info
- 作用:
wo_info
是一个指向struct siginfo
的指针,用于保存进程接收到的信号信息。当子进程终止时,这个结构体包含关于终止信号的详细信息,例如终止原因、信号编号等。
- 作用:
-
int *wo_stat
- 作用:
wo_stat
是一个指向整数的指针,表示子进程的终止状态。父进程通过wo_stat
获取子进程的退出码。可以使用宏WIFEXITED
、WEXITSTATUS
、WIFSIGNALED
等来解析子进程的终止状态。
- 作用:
-
struct rusage *wo_rusage
- 作用:
wo_rusage
用于保存子进程的资源使用情况。它是一个指向struct rusage
的指针,记录了子进程的 CPU 时间、内存使用等资源信息。父进程可以通过它来了解子进程的资源消耗。
- 作用:
-
wait_queue_entry_t child_wait
- 作用:
child_wait
是一个等待队列条目,它用于让当前任务(即父进程)在等待子进程结束时进入睡眠状态。当子进程结束时,父进程会被唤醒,继续执行。
- 作用:
-
int notask_error
- 作用:
notask_error
用于保存出错信息。如果没有可用的子进程或者发生其他错误,notask_error
会保存相应的错误码,以便父进程可以进行错误处理。
- 作用:
do_wait()
的工作流程
-
初始化
wait_opts
:父进程调用wait()
或waitpid()
,内核初始化一个wait_opts
结构体,将父进程的等待选项、目标子进程、信号信息等填入其中。 -
将父进程加入等待队列:
do_wait()
会调用add_wait_queue()
将当前父进程加入到等待队列中,更新进程状态为TASK_INTERRUPTIBLE
,等待子进程终止。 -
子进程终止后唤醒父进程:当子进程结束时,子进程会调用
wake_up_parent()
来唤醒父进程。父进程从睡眠中被唤醒,并开始处理子进程的终止状态。 -
处理子进程的终止状态:父进程通过
wo_stat
获取子进程的退出状态,通过wo_rusage
获取子进程的资源使用信息。然后根据子进程的终止原因,父进程可以执行后续的逻辑。 -
返回结果:
do_wait()
返回子进程的退出状态或错误信息。
相关函数
-
find_get_pid()
:用于查找并获取pid
结构体。它会从 PID 表中根据指定的 PID 找到对应的内核进程结构。 -
set_current_state()
:在父进程进入等待状态时,set_current_state()
会将其状态设置为TASK_INTERRUPTIBLE
,即可中断的睡眠状态。 -
wake_up_parent()
:当子进程终止时,内核会调用该函数唤醒等待的父进程。
这个函数怎么用?
do_wait()
本身是内核代码的一部分,应用程序不会直接使用它。用户态程序会通过 wait()
或 waitpid()
系统调用来等待子进程结束,内核在处理这些系统调用时,会间接调用 do_wait()
。所以,作为开发者,你只需要在用户态程序里用 wait()
或 waitpid()
来等待子进程。
wait()
和 waitpid()
用法示例
假设你有一个程序,它创建了一个子进程,然后父进程需要等待子进程执行完毕,收集子进程的退出状态。
代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork(); // 创建子进程
if (pid == 0) {
// 子进程
printf("Child process running...\n");
sleep(2); // 模拟子进程执行的工作
exit(42); // 子进程退出,返回码为42
} else if (pid > 0) {
// 父进程
int status;
pid_t child_pid = wait(&status); // 父进程等待子进程结束
if (WIFEXITED(status)) { // 检查子进程是否正常退出
printf("Child process %d terminated with exit status %d\n", child_pid, WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) { // 检查子进程是否被信号终止
printf("Child process %d was killed by signal %d\n", child_pid, WTERMSIG(status));
}
} else {
// 如果 fork 失败
perror("Fork failed");
exit(1);
}
return 0;
}
执行流程
-
父进程创建子进程:
fork()
创建一个新的子进程,子进程执行它的任务,例如sleep(2)
模拟工作,并在结束时返回一个退出状态42
。 -
父进程等待子进程:
wait()
调用会让父进程阻塞,直到它的子进程终止。此时,内核就会调用do_wait()
来处理这个等待请求。 -
内核的工作(通过
do_wait()
):- 内核将父进程放入等待队列中,让父进程进入“睡眠”状态,直到子进程结束。
- 当子进程结束时,内核通过
do_wait()
收集子进程的终止状态,并通过wake_up_parent()
将父进程从等待队列中唤醒。
-
父进程获得子进程状态:父进程被唤醒后,它通过
wait()
得到子进程的 PID 和退出状态,并根据WIFEXITED()
和WEXITSTATUS()
检查子进程是否正常结束,以及结束的状态码。
wait()
和 waitpid()
的区别:
wait()
:等待任意子进程结束。父进程会一直阻塞,直到一个子进程结束。waitpid()
:可以指定等待特定的子进程,也可以通过选项(如WNOHANG
)实现非阻塞等待。
比喻
可以把 do_wait()
想象成一个工厂中的“工头”(内核),当父进程(工厂经理)创建了子进程(工人)去干活时,工头会一直盯着这些工人,直到他们的任务完成。如果工人完成了任务(子进程终止),工头就会立即告诉经理(唤醒父进程),并报告工人的工作结果(子进程的退出状态)。do_wait()
就是这个“工头”,负责监视和报告工人(子进程)状态。在比喻中,wait()
函数可以被比喻成“工厂经理”的耳朵。它负责监听“工头”(也就是 do_wait()
,内核中的实际处理机制)是否有反馈,也就是子进程(工人)的工作是否完成。
1. 进程描述符 (task_struct
):
每个进程在内核中都有一个对应的 task_struct
,这个结构体包含了与该进程相关的所有信息,包括信号处理的信息。task_struct
是进程的核心数据结构,内核通过它跟踪每个进程的状态。
在 task_struct
中,有一些与信号处理相关的字段和数据结构,比如:
pending
:挂起的信号列表。用于记录进程当前有哪些信号正在等待处理。blocked
:被屏蔽的信号集。表示进程当前屏蔽了哪些信号,这些信号暂时不会被处理。sighand
:信号处理程序的指针。指向struct sighand_struct
,保存了进程当前的信号处理程序。
2. struct sigpending
:
sigpending
结构体用于保存挂起信号的集合。进程或线程在某些情况下接收到信号,但还不能处理(例如被屏蔽),这些信号会被存储在 sigpending
中。挂起信号一旦条件允许,会被取出并处理。
struct sigpending {
struct list_head list; // 挂起的信号链表
sigset_t signal; // 信号集,表示哪些信号挂起
};
list
:这是一个链表,保存了所有挂起的信号。signal
:这是一个位图,用来表示有哪些信号处于挂起状态。
3. sigset_t
:
sigset_t
是一个信号集数据类型,用来表示一组信号。它通常用于屏蔽信号,或者表示挂起的信号。
blocked
:task_struct
中的blocked
是一个sigset_t
类型的字段,用来表示进程当前屏蔽的信号集。被屏蔽的信号不会被立即处理,直到它们从屏蔽集中移除。sigset_t blocked; // 屏蔽的信号
- 操作函数:通过
sigprocmask()
系统调用,用户进程可以设置或修改进程的信号屏蔽集。 - 忽略信号
- 捕获信号并调用自定义处理函数
- 使用默认处理方式(如终止进程)
-
4.
struct sighand_struct
:struct sighand_struct
保存了进程的信号处理程序的信息。它包含每个信号对应的处理函数(sigaction
),每个信号都有一个处理方式,比如:struct sighand_struct { atomic_t count; // 引用计数,表示有多少线程共享这个处理程序 struct k_sigaction action[_NSIG]; // 信号处理数组,保存每个信号的处理方式 };
action[_NSIG]
:这是一个数组,存储每个信号的处理程序。每个信号(从SIG_0
到SIG_64
)都有一个对应的k_sigaction
结构,用于描述该信号的处理方式。
5. struct k_sigaction
:
k_sigaction
结构体用于保存信号的具体处理方式。每个信号都有一个对应的 k_sigaction
结构,其中包含该信号的处理程序以及其他相关的信息。
struct k_sigaction {
struct sigaction sa; // 信号处理动作,用户层的处理信息
unsigned long sa_flags; // 信号处理的标志
};
sa
:这是一个sigaction
结构体,保存用户空间定义的信号处理程序。sa_handler
:指向信号处理函数的指针,或者表示对该信号的默认处理或忽略。sa_mask
:处理该信号时要屏蔽的其他信号。sa_flags
:信号处理的标志,控制信号的具体处理行为,例如SA_RESTART
(自动重启被中断的系统调用)。
sa_flags
:指定信号处理的行为,比如是否在处理信号时屏蔽其他信号,是否自动重启被信号中断的系统调用。
6. 信号处理流程概述:
当一个进程接收到信号时,内核会根据以下流程处理信号:
- 接收信号:进程接收到信号后,如果信号未被屏蔽,它会被添加到
task_struct
中的pending
列表中。 - 检查屏蔽集:内核会检查
blocked
信号集,判断该信号是否被屏蔽。如果信号被屏蔽,则会保持挂起状态,不会立即处理。 - 查找处理方式:如果信号未被屏蔽,内核会查找该信号的处理方式(
k_sigaction
),然后根据定义的处理程序执行相应的操作:- 如果
sa_handler
设置为自定义处理函数,调用该函数。 - 如果
sa_handler
设置为SIG_IGN
,忽略该信号。 - 如果
sa_handler
设置为SIG_DFL
,使用信号的默认处理方式。
- 如果
- 唤醒等待的进程:如果有进程在等待该信号(例如
wait()
或select()
),信号处理完成后,等待中的进程会被唤醒。
7. 信号相关的系统调用:
kill()
:向指定的进程发送信号。sigaction()
:设置或获取信号的处理程序。sigprocmask()
:设置或获取当前进程的信号屏蔽集。
我们有一个办公室场景:
1. 办公室的员工 = 进程
每个员工(进程)都在忙于自己的工作(运行程序)。有时,外部的事情需要打断他们的工作,像电话铃声、电子邮件,或者老板的指令,这些都是“信号”。
2. 信号 = 电话、电子邮件、老板的指示
信号就是这些打断员工的事件,比如:
- 电话响了(信号
SIGINT
),可能要求员工立即停止工作。 - 收到了一封重要邮件(信号
SIGTERM
),要求员工保存工作并退出。 - 老板发来了一条消息(信号
SIGHUP
),可能要求员工重新启动项目。
3. 信号处理的配置 = 员工对待打断的态度
每个员工都可以有不同的态度来处理这些“打断事件”(信号)。这就是信号的“处理方式”:
- 忽略:员工可以选择不接电话(忽略信号)。
- 响应:员工也可以设置一个特定的响应动作,比如接听电话并按老板的要求去做(自定义信号处理函数)。
- 默认反应:如果员工没有特别设定,他们可能会根据习惯来应对(默认信号处理)。比如,有些员工听到电话响了就会直接挂断(默认终止进程)。
4. 信号屏蔽集 = 员工的专注模式
有时员工进入了“专注模式”,不想被任何事情打扰。这相当于信号屏蔽集(blocked
)。当员工处于这种状态时,他们会暂时不接电话、也不处理任何电子邮件(屏蔽信号)。这些未处理的信号被“挂起”了。
5. 挂起信号 = 等待处理的电话/邮件
如果一个信号到来了,但员工正在专注工作而无法处理(屏蔽信号),信号会进入“挂起状态”。相当于电话在“未接电话列表”中,或者电子邮件在“收件箱”里没有被处理。
6. 信号处理程序 = 员工的应对策略
每个员工可以提前为某些“信号”设定好应对策略。就像在办公室里,如果电话响了:
- 默认处理方式:员工可以让电话铃响几下后自动挂断(
SIG_DFL
)。 - 忽略处理方式:员工可能设置手机为静音,永远不接电话(
SIG_IGN
)。 - 自定义处理方式:员工可以设定某个电话铃声响起时,他们会暂停手上的工作,然后处理电话。
7. k_sigaction = 员工的备忘录
每个员工都有一本“备忘录”(k_sigaction
),记录了如果电话来了该怎么做。如果是老板来电,他们可能会停止当前的工作立即执行老板的任务。如果是垃圾邮件,他们可能会忽略。这本备忘录告诉员工该如何处理不同的打断事件。
8. 信号挂起时处理的过程:
当电话(信号)响起,而员工正在专注模式中无法处理时,电话被挂起。等员工解除专注模式后,检查未接电话(挂起的信号),并根据备忘录(k_sigaction
)逐一处理这些打断。
总结:
- 信号:像电话、电子邮件、老板的指示,它们打断进程的正常执行。
- 信号处理程序:员工可以选择忽略、响应,或按默认规则处理这些打断。
- 屏蔽信号:员工在专注模式时暂时不接电话,但这些电话(信号)会保留在待处理列表中。
- 挂起信号:员工暂时没有处理的“未接电话”或“未读邮件”,等专注模式结束后会处理。
内核线程(kthread
)
在 Linux 内核中,内核线程(kthread
)是内核中独立执行的任务,类似于用户空间的线程,但运行在内核态。kthread
的创建和执行涉及到几个关键函数,包括 kthread_create()
用于创建线程,以及线程的执行函数 thread_function()
。
1. kthread_create()
- 创建内核线程
struct task_struct *kthread_create(
int (*threadfn)(void *data),
void *data,
const char *namefmt, ...);
-
说明:
threadfn
:这是线程的主函数,当线程开始运行时,会执行这个函数。函数的原型是int thread_function(void *data)
。data
:传递给线程函数threadfn
的参数,它是一个void *
指针,允许传递任何类型的数据给线程函数。namefmt
:线程的名字,可以通过格式化字符串(类似printf()
)为线程命名。
-
返回值:
- 返回的是一个指向
struct task_struct
的指针,表示内核中创建的线程。task_struct
是 Linux 内核中表示一个进程或线程的主要数据结构。
- 返回的是一个指向
-
线程不立即运行:
- 调用
kthread_create()
创建线程后,线程不会立即运行。线程只有在调用wake_up_process()
函数时才会真正开始执行。struct task_struct *my_kthread; my_kthread = kthread_create(thread_function, NULL, "my_kthread"); if (my_kthread) { wake_up_process(my_kthread); // 唤醒并启动线程 }
在上面的代码中,
kthread_create()
创建了一个名为"my_kthread"
的内核线程,但它不会立即执行。随后通过wake_up_process()
来启动这个线程。 -
thread_function()
- 线程执行函数 -
这是内核线程的实际执行代码,它是由
kthread_create()
传递的函数。函数接受一个void *data
参数,通常是用于传递给线程的一些上下文或数据。int thread_function(void *data) { // 线程的实际工作代码 while (!kthread_should_stop()) { // 线程的主要工作循环 // 执行一些工作 schedule(); // 手动调用调度器,让出 CPU } // 如果 `kthread_should_stop()` 返回 true,表示要停止线程 return 0; // 返回时线程结束 }
-
kthread_should_stop()
:这是一个检查函数,判断内核线程是否应该停止。当kthread_stop()
被调用时,kthread_should_stop()
会返回true
,这时线程应该退出执行循环并结束。 -
直接调用
do_exit()
:- 对于某些“独立”的线程,可能没有调用
kthread_stop()
的需求,这时线程可以直接调用do_exit()
函数来终止自己。
- 对于某些“独立”的线程,可能没有调用
-
返回机制:
- 内核线程可以通过返回值来结束,当
kthread_should_stop()
返回true
时,线程可以通过简单返回结束自己。
如果线程是独立的、不需要停止控制(例如,系统中没有其他地方会调用
kthread_stop()
来停止它),可以直接调用do_exit()
来退出线程。 - 内核线程可以通过返回值来结束,当
-
3.
kthread_stop()
- 停止内核线程如果您想停止一个内核线程,可以调用
kthread_stop()
,这会通知线程应该停止,并等待线程的退出。int kthread_stop(struct task_struct *k);
- 调用
kthread_stop()
的作用:当您调用kthread_stop()
时,会设置线程的内部状态,让kthread_should_stop()
函数返回true
。线程一旦检测到kthread_should_stop()
为true
,应该尽快退出。kthread_stop()
会阻塞,直到线程退出。
4. 线程的生命周期总结
- 创建线程:调用
kthread_create()
创建一个线程,但线程不会立即开始运行。 - 启动线程:通过
wake_up_process()
启动线程,让它开始执行thread_function()
。 - 线程执行循环:在
thread_function()
中,线程通常会在循环中执行任务,并定期检查kthread_should_stop()
来判断是否要退出。 - 停止线程:如果需要停止线程,可以调用
kthread_stop()
,这会通知线程停止,并等待线程安全退出。
5. 示例 - 完整的内核线程创建与停止
#include <linux/kthread.h>
#include <linux/delay.h>
struct task_struct *my_thread;
// 线程函数
int thread_function(void *data) {
while (!kthread_should_stop()) {
printk(KERN_INFO "Thread running...\n");
ssleep(5); // 模拟工作,睡眠5秒
}
printk(KERN_INFO "Thread stopping...\n");
return 0;
}
// 模块加载时创建线程
static int __init my_module_init(void) {
my_thread = kthread_create(thread_function, NULL, "my_thread");
if (my_thread) {
wake_up_process(my_thread); // 启动线程
printk(KERN_INFO "Thread created and started.\n");
} else {
printk(KERN_ERR "Failed to create thread.\n");
}
return 0;
}
// 模块卸载时停止线程
static void __exit my_module_exit(void) {
if (my_thread) {
kthread_stop(my_thread); // 停止线程
printk(KERN_INFO "Thread stopped.\n");
}
}
module_init(my_module_init);
module_exit(my_module_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Example Kernel Thread");
MODULE_AUTHOR("Your Name");
6. 关键步骤总结:
- 创建线程:通过
kthread_create()
创建一个线程,返回task_struct
指针。 - 启动线程:调用
wake_up_process()
启动线程。 - 线程循环:线程通过检查
kthread_should_stop()
确定是否需要停止。 - 停止线程:通过
kthread_stop()
来停止线程,并等待线程安全退出。
通过这些步骤,您可以在 Linux 内核中创建、运行和停止内核线程,使其执行特定的任务并安全退出。
这张图片进一步解释了内核线程创建、启动和运行的函数以及其返回值。
1. 返回值 (task_struct
)
-
成功时:如果内核线程创建成功,
kthread_create()
会返回一个指向内核线程的task_struct
结构体的指针。task_struct
是 Linux 内核中描述线程或进程的主要数据结构,它包含了线程的状态、PID 以及其他与线程相关的信息。 -
失败时:如果创建失败,
kthread_create()
会返回ERR_PTR
,这是一个错误指针,用来表示具体的错误。
2. 启动线程 (wake_up_process()
)
-
wake_up_process(struct task_struct *p)
:创建线程后,使用wake_up_process()
来启动线程。- 该函数会将线程从创建状态变为可运行状态,这意味着它已经准备好被调度执行了。
- 传递的参数是指向内核线程的
task_struct
指针。
-
ERR_PTR
:如果wake_up_process()
失败,它可能会返回错误指针(ERR_PTR
),指示出错的原因。
3. kthread_run()
- 创建并启动线程的快捷函数
2. 运行环境的差异
-
kthread_run()
是一个方便的内核 API,它相当于kthread_create()
和wake_up_process()
的组合。 -
参数:
threadfn
:要在线程中执行的函数。data
:传递给threadfn
的数据。namefmt
:线程的名称,类似printf()
格式化字符串。
-
功能:
kthread_run()
直接创建并启动一个线程,相当于调用kthread_create()
后立刻调用wake_up_process()
。- 这样,您不需要单独调用
wake_up_process()
,使得代码更加简洁。struct task_struct *my_kthread; // 线程函数 int my_thread_function(void *data) { while (!kthread_should_stop()) { // 执行线程的主要任务 printk(KERN_INFO "Thread is running...\n"); ssleep(1); // 模拟工作,睡眠1秒 } return 0; } // 创建并启动线程 my_kthread = kthread_run(my_thread_function, NULL, "my_thread"); if (IS_ERR(my_kthread)) { printk(KERN_ERR "Failed to create thread.\n"); } else { printk(KERN_INFO "Thread started successfully.\n"); }
在这个示例中,
kthread_run()
创建了一个内核线程,并立刻启动它执行my_thread_function()
函数。线程的名字是"my_thread"
,线程会一直运行,直到kthread_stop()
被调用,kthread_should_stop()
返回true
。总结:
kthread_create()
用于创建线程,返回一个task_struct
指针,表示线程的结构。wake_up_process()
用于启动线程。kthread_run()
是一个简化的 API,它可以同时创建并启动线程,使代码更加简洁。
-
kthread_create()
和用户空间的fork()
调用之间的区别主要在于内核线程和用户线程的不同。它们的使用场景、运行环境、以及实现方式都不同。下面我会详细对比这两者的区别。1. 内核线程 (
kthread_create()
) 和用户进程 (fork()
) 的本质区别 - 内核线程:
- 定义:内核线程是运行在 内核空间 的线程,专门用于执行内核的任务。
- 创建方式:内核线程通过
kthread_create()
创建,运行在内核态,不受用户空间的直接影响。 - 执行环境:内核线程不属于任何用户空间进程,它只存在于内核中,用于处理内核任务(例如调度器、内核服务等)。
- 内存空间:内核线程使用的是内核的地址空间,它没有自己独立的虚拟地址空间,和内核共享同一地址空间。
- 功能:内核线程通常用于处理不与用户空间直接交互的后台任务,如设备驱动、内核模块的调度任务等。
- 用户进程:
- 定义:用户进程是在 用户空间 运行的进程,它们是用户程序(如文本编辑器、浏览器等)的实例。
- 创建方式:用户进程通过
fork()
来创建,fork()
会复制调用进程的地址空间,生成一个新的用户进程(子进程)。 - 执行环境:用户进程在用户态运行,且可以通过系统调用请求内核服务。它有自己独立的地址空间和权限。
- 内存空间:
fork()
创建的子进程有独立的虚拟地址空间,和父进程的地址空间是分离的(虽然开始时它们是相同的,但写时复制技术确保了之后它们独立修改内存)。 - 功能:用户进程用于运行用户应用程序,并通过系统调用与内核进行交互。
- 内核线程:
- 运行在内核态,它们可以直接访问和操作内核的数据结构和硬件资源。
- 没有用户空间的限制(如访问控制、权限限制等),可以执行特权操作。
- 内核线程的调度由内核直接控制,通常用于执行系统底层操作。
- 用户进程:
- 运行在用户态,与内核隔离。用户进程需要通过系统调用来与内核交互。
- 受到内核的限制,不能直接访问内核的内存或硬件资源。
- 通过用户态的调度机制进行调度,用户态进程可以被内核挂起、切换或终止。
-
总结
特性 内核线程 ( kthread_create()
)用户进程 ( fork()
)执行空间 内核空间 用户空间 内存空间 与内核共享内存地址空间 拥有独立的虚拟内存地址空间 创建方式 通过 kthread_create()
创建,不自动启动通过 fork()
创建,自动开始执行启动方式 需要手动调用 wake_up_process()
创建后立即执行 使用场景 内核模块、驱动程序、后台管理任务 用户应用程序,如服务器、并行任务 调度 由内核调度器调度,始终运行在内核态 由内核调度器调度,运行在用户态
#include <linux/init.h> // 包含模块初始化和退出的头文件
#include <linux/module.h> // 包含模块的基本定义
#include <linux/kthread.h> // 包含内核线程相关的函数和定义
MODULE_LICENSE("GPL"); // 模块的许可证声明
static struct task_struct *task; // 定义一个内核线程的任务结构体指针,用来存储线程信息
// 实现测试函数,供内核线程执行
int func(void *data) {
int time_count = 0; // 计数器,记录线程执行次数
do {
printk(KERN_INFO "thread_function: %d times", ++time_count); // 每次循环打印线程运行次数
} while (!kthread_should_stop() && time_count < 30); // 如果没有收到停止信号,并且执行次数小于30,则继续循环
return time_count; // 返回执行次数
}
// 模块初始化函数,在模块加载时调用
static int __init KT_init(void) {
printk("KT module create kthread start\n"); // 打印模块加载信息
// 创建内核线程,线程的执行函数是 func,线程名为 "MyThread"
task = kthread_create(&func, NULL, "MyThread");
// 如果线程创建成功,启动线程
if (!IS_ERR(task)) {
printk("kthread starts\n"); // 打印线程启动信息
wake_up_process(task); // 唤醒并启动线程,线程开始执行
}
return 0; // 初始化函数返回 0 表示成功
}
// 模块退出函数,在模块卸载时调用
static void __exit KT_exit(void) {
printk("KT module exits! \n"); // 打印模块退出信息
// 漏掉的步骤:停止线程
if (task) {
kthread_stop(task); // 调用 kthread_stop() 停止内核线程
}
}
// 指定模块初始化和退出时调用的函数
module_init(KT_init); // 指定 KT_init 为模块加载时调用的函数
module_exit(KT_exit); // 指定 KT_exit 为模块卸载时调用的函数
代码解释
这段代码是一个用于创建和启动内核线程的 Linux 内核模块。它通过 kthread_create()
创建一个线程,并通过 wake_up_process()
启动该线程执行 func()
函数。内核模块的生命周期由 KT_init()
和 KT_exit()
控制。
代码详细解读:
-
task_struct *task
:- 定义了一个指向内核线程的
task_struct
指针,用于存储创建的内核线程信息。
- 定义了一个指向内核线程的
-
func(void *data)
:- 这是内核线程执行的函数。它是一个循环,每次循环输出线程执行的次数
time_count
,并且在kthread_should_stop()
返回true
或计数达到 30 时退出循环。最后返回time_count
。 - 逻辑:线程在最多循环 30 次时退出,并且每次循环时打印一次日志。
- 这是内核线程执行的函数。它是一个循环,每次循环输出线程执行的次数
-
KT_init()
:- 内核模块加载时的初始化函数。这里的主要工作是创建一个内核线程,并在创建成功后启动该线程。
kthread_create()
:用于创建内核线程,传入的参数包括func
(线程执行的函数)、NULL
(传递给func
的数据,未使用)、"MyThread"
(线程的名字)。wake_up_process()
:如果线程创建成功(未返回IS_ERR()
),则调用wake_up_process()
启动线程。
-
KT_exit()
:- 内核模块卸载时调用的清理函数。在这里,它只打印一条消息并退出。通常,
kthread_stop()
会在这里调用以停止线程,但代码中没有调用它。
- 内核模块卸载时调用的清理函数。在这里,它只打印一条消息并退出。通常,
-
module_init()
和module_exit()
:- 它们分别指示模块加载时应调用的初始化函数
KT_init()
和卸载时应调用的清理函数KT_exit()
。
- 它们分别指示模块加载时应调用的初始化函数
终端输出解释:
insmod KT.ko
:这条命令加载内核模块KT.ko
。加载后,模块开始执行KT_init()
,创建并启动内核线程。lsmod | grep KT
:显示内核模块列表,确认KT.ko
已被加载。rmmod KT.ko
:卸载内核模块,执行KT_exit()
并打印相应的退出消息。
比喻
你可以把这个内核线程比作一个办公室的自动报告机器人:
- 办公室(内核环境):这是系统内部的环境,只有授权的人(内核线程)可以在这里运行。
- 机器人(内核线程):当内核模块加载时(办公室打开),一个机器人被制造出来,它会每隔一段时间记录自己的运行次数,最多记录 30 次。
- 机器人启动的按钮(
wake_up_process()
):虽然机器人已经被制造出来,但它还不会立即开始工作,只有当有人按下启动按钮(调用wake_up_process()
)时,它才会开始执行任务。 - 机器人检查信号(
kthread_should_stop()
):机器人每次工作时都会检查它的控制面板,看看是否有人要求它停止工作。如果有人按下停止按钮,它会立即停止任务。
在机器人完成了任务(循环 30 次)或者收到停止信号(kthread_should_stop()
返回 true
)时,它会自动返回,并停止继续工作。
什么是模块?
在 Linux 内核中,模块(Module)是指一种可以动态加载和卸载的可执行代码。它允许你在不重启系统或重新编译内核的情况下,扩展或修改内核的功能。模块的最常见用途之一就是实现设备驱动或其他特定功能,比如网络协议、文件系统等。
模块的特点:
-
动态性:
- 模块可以根据需要动态加载(
insmod
)到内核中,或者动态卸载(rmmod
)。不需要重新编译或启动内核即可更改系统功能。
- 模块可以根据需要动态加载(
-
可插拔:
- 你可以将模块想象成一种“插件”,它可以在需要时插入内核,为内核提供某种特定的功能,而当功能不再需要时,你可以安全地将其移除。
-
节省资源:
- 由于模块是按需加载的,因此它们可以减少内核的内存使用。在不需要特定功能时,模块可以保持未加载的状态,从而节省系统资源。
模块的组成:
-
代码:模块本身是一段可以执行的代码,通常是以
.ko
(kernel object) 文件的形式存在。这些代码与内核交互,提供特定的功能,比如设备驱动、文件系统支持等。 -
初始化函数 (
module_init
):模块加载到内核时,会执行初始化函数,这个函数通常用来分配资源或启动某些服务。 -
退出函数 (
module_exit
):当模块被卸载时,会执行退出函数,这个函数通常用来清理资源和终止与模块相关的服务。
模块的工作原理:
-
模块加载 (
insmod
):- 当你需要加载某个模块时,使用
insmod
命令加载模块。 - 例如,
insmod my_module.ko
会将模块my_module
加载到内核中,并调用模块的初始化函数 (module_init
)。
- 当你需要加载某个模块时,使用
-
模块运行:
- 一旦加载,模块便会像内核的其他部分一样运行。模块可以调用内核提供的 API,访问内核数据结构,甚至创建线程(例如你之前代码中的
kthread_create
)。
- 一旦加载,模块便会像内核的其他部分一样运行。模块可以调用内核提供的 API,访问内核数据结构,甚至创建线程(例如你之前代码中的
-
模块卸载 (
rmmod
):- 当模块的功能不再需要时,你可以使用
rmmod
命令将其卸载。 - 例如,
rmmod my_module
会卸载模块,并调用模块的退出函数 (module_exit
) 来清理资源。
- 当模块的功能不再需要时,你可以使用
1. 什么是 LKM(可加载内核模块)?
LKM 是一种独立编译的内核扩展,可以在操作系统启动后动态加载和卸载,允许内核根据需要增加或减少功能。它们通常用于:
- 添加对新硬件的支持,比如设备驱动程序。
- 增加对新文件系统的支持,比如挂载某些文件系统格式。
- 扩展内核功能,比如增加新的系统调用。
LKM 提供了一种模块化的机制来扩展内核功能,避免在添加新功能时必须重新编译整个内核。
2. LKM 的主要用途
- 硬件支持:内核模块通常用于设备驱动程序,这意味着在引入新硬件(例如 USB 设备、网卡或硬盘控制器)时,不需要重新编译内核,而是通过加载模块来实现支持。
- 文件系统支持:一些文件系统(如
ext4
、btrfs
)的支持通常作为模块实现,当需要挂载这些文件系统时,可以加载相应的模块来处理。 - 系统调用:LKM 还可以用于扩展内核的 API,例如在开发调试环境中添加自定义的系统调用。
3. LKM 的跨平台支持
几乎所有现代的类 Unix 操作系统都支持 LKM,虽然它们可能在不同操作系统上使用不同的名称。例如:
- Linux:LKM 在 Linux 中通常以
.ko
文件的形式存在,可以通过insmod
或modprobe
命令加载。 - MacOS:在 MacOS 上,LKM 被称为内核扩展(Kernel Extension, kext)。它们通常用于类似的目的,如支持新硬件、文件系统和扩展系统功能。
- FreeBSD 和 Solaris 也支持类似的模块化内核设计,尽管具体实现有所不同。
4. LKM 的优点
-
动态加载/卸载:LKM 可以在系统运行时加载和卸载,这使得内核能够根据需求动态增加或减少功能。无需重启系统,节省了大量时间。
-
模块化开发:开发人员可以编写、测试和调试单独的内核模块,而不需要重新编译整个内核。这为开发和维护内核代码提供了更高的灵活性。
-
节省内存:只有在需要特定功能时才加载相应的模块,节省了系统资源。
5. LKM 的工作原理
LKM 在运行时被加载到内核空间,与内核共享相同的地址空间。这使得 LKM 可以直接访问内核的内部数据结构和函数,但也需要小心处理,因为 LKM 一旦出现问题(如崩溃),可能会导致整个系统不稳定。
-
加载模块:通过命令
insmod
或modprobe
来加载一个.ko
文件(内核模块)。例如,在 Linux 中,加载模块的命令:
1. Makefile
Makefile
是一种自动化工具,用于编译程序。在 Linux 内核模块编译中,Makefile 扮演着非常重要的角色。它帮助我们定义了如何构建内核模块以及如何清理生成的文件。
Makefile 的内容解释:
obj-m := KM.o # 指定要编译的目标模块对象,KM.o 是内核模块的名称
KVERSION := $(shell uname -r) # 获取当前运行的内核版本
PWD := $(shell pwd) # 获取当前工作目录的路径
all:
$(MAKE) -C /lib/modules/$(KVERSION)/build M=$(PWD) modules
# 使用内核的 build 系统,编译模块。-C 选项指定内核源代码路径,M=$(PWD) 表示模块的路径
clean:
$(MAKE) -C /lib/modules/$(KVERSION)/build M=$(PWD) clean
# 清理生成的中间文件
-
obj-m := KM.o
:这行指定了要生成的模块名称为KM.o
。这是模块的目标文件,KM.o
最终会被生成为KM.ko
文件,这就是可加载的内核模块文件。 -
KVERSION := $(shell uname -r)
:使用uname -r
命令获取当前内核的版本号,存储在KVERSION
变量中。这对于确保我们编译的模块与当前正在运行的内核兼容非常重要。 -
PWD := $(shell pwd)
:pwd
命令获取当前的工作目录,$(PWD)
用来表示当前路径。 -
all
目标:这定义了编译内核模块的步骤。通过调用$(MAKE)
,并使用-C /lib/modules/$(KVERSION)/build
,告诉make
工具去内核源代码目录下找到适当的编译规则,并在M=$(PWD)
目录下执行模块编译。 -
clean
目标:make clean
用于清理编译生成的临时文件或中间文件。调用的指令类似于all
,但是带有clean
选项,告诉系统清理文件。
2. 编译内核模块
-
输入命令
make
:- 当你在终端输入
make
命令时,Makefile 会根据all
目标来自动编译模块。它会生成.o
文件以及最终的.ko
文件,即可加载的内核模块。 - 在文件夹中你会看到类似于
KM.o
,KM.ko
,KM.mod.o
, 和KM.mod.c
这些文件。
- 当你在终端输入
-
输入命令
make clean
:- 当你执行
make clean
命令时,Makefile 中的clean
目标会被执行。它会删除编译生成的中间文件和目标文件,只保留源代码和 Makefile。这样可以保持工作目录的整洁。
- 当你执行
3. Makefile 的工作流程概述
-
make
:当你运行make
命令时,它会根据 Makefile 中的规则,调用内核的构建系统,编译你指定的内核模块文件(KM.o
),最终生成可加载的内核模块文件(KM.ko
)。 -
make clean
:用于清理生成的目标文件,移除.o
和.ko
文件,以便你可以重新编译模块。
4. 目录结构
- 图片中展示了在
LoadModule
目录下,生成了几个重要的文件:KM.c
:模块的源代码文件,包含内核模块的实现。KM.o
和KM.ko
:目标文件和可加载模块文件。Makefile
:控制模块编译的文件。
当执行 make
命令后,KM.ko
就是最终生成的模块文件,你可以使用 insmod
命令将其加载到内核中,或用 rmmod
卸载。
关于如何管理 Linux 内核模块的具体步骤说明。让我们逐步解释一下每个命令的作用,以及它们如何操作内核模块。
1. 切换到 root 账户
$ sudo su
作用:Linux 内核模块的加载和卸载需要超级用户权限,因此你必须以 root
用户身份进行操作。sudo su
命令允许你从普通用户切换到 root
账户,从而获得系统的最高权限,能够进行内核模块的操作。
2. 插入内核模块
$ insmod MODULE_NAME.ko
-
作用:
insmod
命令用于将内核模块插入(加载)到正在运行的 Linux 内核中。你需要指定你要加载的模块文件名称,通常是一个以.ko
结尾的文件。 -
示例:
insmod my_module.ko