1.设计目的
Linux 提供的模块机制能动态扩充 Linux 功能而无需重新编译内核,已经广泛应用在
linux 内核的许多功能的实现中。在本实验中将学习模块的基本概念、原理及实现技术,然
后利用内核模块编程访问进程的基本信息,加深对进程概念的理解,掌握基本的模块编程
技术
2.内容要求
(1)设计一个模块,要求列出系统中所有内核线程的程序名、PID、进程状态、进程优先级、父进程的 PID。
(2)设计一个带参数的模块,其参数为某个进程的 PID 号,模块的功能是列出该进程的家族信息,包括父进程、兄弟进程和子进程的程序名、PID 号、进程状态。
(3)请根据自身情况,进一步阅读分析程序中用到的相关内核函数的源码实现。
3、模块基本概念
Linux 内核是单体式结构,相对于微内核结构而言,其运行效率高,但系统的可维护性及可扩展性较差。为此,Linux 提供了内核模块(module)机制,它不仅可以弥补单体式内核相对于微内核的一些不足,而且不影响系统性能。内核模块的全称是动态可加载内核模块(Loadable Kernel Module,KLM),简称为模块。
模块是一个目标文件,能完成某种独立的功能,但其自身不是一个独立的进程,不能单独运行,可以动态载入内核,使其成为内核代码的一部分,与其他内核代码的地位完全相同。当不需要某模块功能时,可以动态卸载。实际上,Linux 中大多数设备驱动程序或文件系统都以模块方式实现,因为它们数目繁多,体积庞大,不适合直接编译在内核中,而是通过模块机制,需要时临时加载。使用模块机制的另一个好处是,修改模块代码后只需重新编译和加载模块,不必重新编译内核和引导系统,降低了系统功能的更新难度。
通过看内核编译能够深刻体会到这一点。
内核编译可看:https://zynorl.blog.csdn.net/article/details/105754952
一个模块通常由一组函数和数据结构组成,用来实现某种功能,如实现一种文件系统、一个驱动程序或其他内核上层的功能。模块自身不是一个独立的进程,当前进程运行过程中调用到模块代码时,可以认为该段代码就代表当前进程在核心态运行。
将 Makefile 和module01.c module02.c 放在一个文件夹内
一、module01.c
#include
#include
#include
#include
#include
// 初始化函数
static int hello_init(void)
{
struct task_struct *p;
printk("-----------------------------------------------------------------------");
printk(KERN_ALERT"名称 进程 状态 优先级 父进程");
for_each_process(p)
{
if(p->mm == NULL){ //内核线程的mm成员为空
printk(KERN_ALERT"%s\t%d\t%ld\t%d\n",p->comm,p->pid,p->state,p->normal_prio,p->parent->pid);
}
}
return 0;
}
// 清理函数
static void hello_exit(void)
{
printk(KERN_ALERT"goodbye!\n");
}
// 函数注册
module_init(hello_init);
module_exit(hello_exit);
// 模块许可申明
MODULE_LICENSE("GPL");
二、module02.c
#include
#include
#include
#include
#include
MODULE_LICENSE("GPL");
static pid_t pid;
module_param(pid,int,0644);
static int hello_init(void)
{
struct task_struct *p;
struct list_head *pp;
struct task_struct *psibling;
printk("--------------------------------------------------------");
//当前进程的 PID
p = pid_task(find_vpid(pid), PIDTYPE_PID);
printk("me:%s %d %ld\n",p->comm, p->pid, p->state);
// 父进程
if(p->parent == NULL) {
printk("No Parent\n");
}
else {
printk("Parent:%s %d %ld\n",p->parent->comm, p->parent->pid, p->parent->state);
}
// 兄弟进程
list_for_each(pp, &p->parent->children)
{
psibling = list_entry(pp, struct task_struct, sibling);
printk("sibling %s %d %ld \n",psibling->comm, psibling->pid, psibling->state);
}
// 子进程
list_for_each(pp, &p->children)
{
psibling = list_entry(pp, struct task_struct, sibling);
printk("children %s %d %ld \n", psibling->comm,psibling->pid, psibling->state);
}
return 0;
}
static void hello_exit(void)
{
printk(KERN_ALERT"goodbye!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
三、Makefile
obj-m:=module02.o
KDIR:= /lib/modules/$(shell uname -r)/build
PWD:= $(shell pwd)
default:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
// $前面这里是TAB键 不是多个空格
以下命令除 make 命令外,其他都应以 root 用户执行:
insmod 模块名.ko
insmod 模块名.ko pid=223 // 带参数的模块编程 本文中对应于module02.c
module01.c 中通过这段代码:
module_init(hello_init);
module_exit(hello_exit);
该模块被载入内核时会向系统日志文件中写入“hello,world”;当被卸载时,也会向系统日志中写入“goodbye”。
头文件声明:
第 1、2 行是模块编程的必需头文件。
模块许可申明:
Linux 内核从 2.4.10 版本内核开始,模块必须通过MODULE_LICENSE 宏声明此模块的许可证,否则在加载此模块时,会收到内核被污染 “kernel tainted” 的警告。
从 linux/module.h 文件中可以看到,被内核接受的有意义的许可证有 “GPL”,“GPL v2”,“GPL and additional rights”,“Dual BSD/GPL”,“Dual MPL/GPL”,“Proprietary”,其中“GPL” 表示这是 GNU General Public License 的任意版本,其他许可证大家可以查阅资料进一步了解。
MODULE_LICENSE 宏声明可以写在模块的任何地方(但必须在函数外面),不过惯例是写在模块最后。
初始化与清理函数的注册:
内核模块程序中没有 main 函数,每个模块必须定义两个函数:一个函数用来初始化(init),主要完成模块注册和申请资源,该函数返回 0,表示初始化成功,其他值表示失败;另一个函数用来退(exit),主要完成注销和释放资源。
Linux 调用宏module_init 和 module_exit 来注册这两个函数,module_init 宏标记的函数在加载模块时调 用,module_exit 宏标记的函数在卸载模块时调用。
需要注意的是,初始化与清理函数必须在宏module_init 和 module_exit 使用前定义,否则会出现编译
错误。
初始化函数通常定义为:
static int __init init_func(void)
{
//初始化代码
}
module_init(init_func);
一般情况下,初始化函数应当申明为 static,以便它们不会在特定文件之外可见。如果
该函数只是在初始化使用一次,可在声明语句中加__init 标识,则模块在加载后会丢弃这个
初始化函数,释放其内存空间。
清理函数通常定义为:
static void __exit exit_func(void)
{
//清理代码
}
module_exit(exit_func); 清理函数没有返回值,因此被声明为 void。声明语句中的__exit 的含义与初始化函数中的__init 类似,不再重述。
一个基本的内核模块只要包含上述三个部分就可以正常工作了。
内核模块组成
模块组成 | 是否可选 |
---|---|
头文件: #include必选 |
|
许可声明 MODULE_LICENSE(“Dual BSD/GPL”) | 必选 |
加载函数 static int __init hello_init(void) | 必选 |
卸载函数 static void __exit hello_exit(void) | 必选 |
模块参数 module_param(name,type,perm) | 必选 |
模块导出符号 EXPORT_SYMBOL(符号名) | 可选 |
模块作者等信息 MODULE_AUTHOR(“作者名”) | 可选 |
p->mm == NULL
由于操作系统中用户进程与内核线程的区别在于是否分配用户内存空间。内核线程是不分配用户空间的。所以内核线程的mm ==NULL; 以此为依据判断是用户进程还是内核线程。
struct list_head 双向循环链表详解
链表对每位写过程序的同学都再熟悉不过了。无非是对链表的创建,初始化,插入,删除,遍历等操作。但您是否想过,如果针对每一种数据结构都实现一套对链表操作的服务原语,是否太浪费时间和精力了。实际上在Linux内核2.4以后,内核开发者对链表的结构实现了一个统一的接口,可以利用这些接口实现链表,而不用去考虑数据结构的差异。你的兴趣是否来了?那就让我们一睹为快:
下图为链表数据结构的定义(include/linux/types.h):
list_head 结构包含两个list_head结构的指针 *next ,*prev ,咋一看这定义,似乎很普通,其实伟大常常孕育在平凡之中。
我们一般会这样构造链表:
struct list_node{
TYPE data; //链表中的数据域
struct list_node *next, *prev;
};
这样我们把数据嵌入到链表节点中之后的示意图为:
而,内核开发者写的结构算法是将链表的前后指针所组成的list_head 结构体嵌入到list_node 这整个数据结构中。
struct list_node{
TyPE data;
struct list_head list; //定义一个list_head的节点
};
可以看出,链表的操作是通过访问为一个list_head 来操作的。
在这种链表中,所有的链表基本操作都是针对list_head 数据结构进行,而不是针对包含list_head的list_node 数据结构。无论无论什么数据,链表操作都得到了统一。
那么现在碰到一个问题,因为所有链表操作涉及到的指针都是指向list_head数据结构的,而不是包含的list_node数据结构。那么怎样从list_head的地址得到包含其list_node数据结构的地址呢?
我们来看linux 内核中(include/linux/list.h)的list_entry(ptr, type, member)这个宏:
把0 地址转化为type类型的指针, 然后获取该结构中member成员的名称(sibling-data)。如果data 现在在0 地址上, 那么由上图代码段
offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
(type *)((char *)__mptr - offsetof(type, member))
可知,就是计算0 地址到list_head 的偏移量 (相对长度),说白了就是数据域data (sibling-data) 在此结构中占多长的空间。这样如果我们有一个绝对的地址ptr (list_head类型) 那么 :
ptr 绝对地址 - data 相对地址 = 包含 list_head 结构的task_struct结构体的绝对地址。
试想,如果我们知道链表的list_head 的地址,(因为list_head为链表的节点,我们当然可以知道他的地址) 就可以找到包含这个节点的数据结构的地址,找到这个数据结构的地址顺理成章的就可以访问这个结构中的每个元素了。
list_for_each详解
Linux系统中的每个进程都有一个父进程(init进程除外);每个进程还有0个或多个子进程。在进程描述符中parent指针指向其父进程,还有一个名为children的子进程链表(父进程task_struct中的children相当于链表的表头)。
task_struct源代码链接:
https://elixir.bootlin.com/linux/v5.6.3/source/include/linux/sched.h#L629
下图, 为task_struct 源代码中的chilren 代码截图。可以看出,父进程task_struct中的children相当于链表(list_head)的表头。
而我们可以使用list_for_each(/include/linux/list.h)来依次遍历访问子进程:
list_for_each 源代码链接:
https://elixir.bootlin.com/linux/v5.6.3/source/include/linux/list.h#L552
源码截图:
struct list_head *pp;
struct task_struct *psibling;
// 兄弟进程
list_for_each(pp, &p->parent->children)
{
psibling = list_entry(pp, struct task_struct, sibling);
printk("sibling %s %d %ld \n",psibling->comm, psibling->pid, psibling->state);
}
list_for_each 其实就是一个for 循环, for() 实现的就是一个children 链表的遍历。
首先需要说明,task_struct 指针指向其某个子进程的进程描述符task_struct中的childre的地址而非指向某个子进程的地址,也就是说子进程链表中存放的仅仅是各个task_struct成员children的地址。
那么问题来了,由children的地址如何取到task_struct的地址呢, 它是由list_entry 宏来实现的,关于list_entry 这个宏前面已经讲到。
算法总结:
看到这里,你是否已经恍然大悟,linux 利用list_for_each 这个宏通过双向循环链表这个数据结构算法这个方式找到相对于父进程的children这个进程,但这只是找到了children进程 (task_struct) 中的children成员的地址,并没有找到children本身的地址。所以就需要 list_entry 这个宏来调节这个“地址差”。
而,list_entry 里面又是通过container_of() 函数进行偏移(
container_of()思路为先求出结构体成员member(即children)在结构体(即task_struct)中的偏移量,然后再根据member的地址(即ptr)来求出结构体(即task_struct)的地址。
这里 ((type *)0)->member,他将地址0强制转换为type类型的指针,然后再指向成员member,此时((type )0)->member的地址即为member成员相对于结构体的位移。),来最终实现的。
如果对你有帮助,麻烦能给个赞吗,b( ̄▽ ̄)d。
参考于:
http://blog.sina.cn/dpool/blog/s/blog_4cd5d2bb0101525j.html
http://blog.sina.cn/dpool/blog/s/blog_861912cd0100xty9.html