Linux 模块编程

实验环境

  • 本地开发操作系统:Windows 10

  • 远程开发操作系统:CentOS Linux release 7.6.1810 (Core)

  • VSCode:1.56.1

实验环境配置方法见使用 VSCode 配置远程开发

模块编程简介

内核模块

模块是可以根据需要来加载和卸载到内核中的代码片段,可以完成某种独立的功能;模块自身不是一个独立的进程,不能单独运行,可以动态的载入模块,使其成为内核代码的一部分,与内核其他代码的地位完全相同。
模块编程不同于添加系统调用,不需要重新编译内核。

模块编程示例

HelloWord

  1. 编写模块代码

helloword.c

   #include <linux/init.h>
   #include <linux/module.h>
   #include <linux/kernel.h>
   
   static int hello_word_init(void) 
   {
       // 打印hello word
       printk(KERN_ALERT"hello word!");
       return 0;
   
   }
   
   static void hello_word_exit(void)
   {
       // 清理时打印hello word exit
       printk(KERN_ALERT "hello world exit\n");
   }
   
   // 初始化
   module_init(hello_word_init);
   // 清理函数
   module_exit(hello_word_exit);
   // 许可声明
   MODULE_LICENSE("GPL");
  • 头文件: module.h 包含了加载模块需要的函数以及符号;init.h 包含了初始化以及清理时的定义;kernel.h 包含了 printk 等函数。module.hinit.h 是模块变成必须包含的,如果编写带参数的模块,还必须包含 moduleparam.h
  • 许可声明:MODULE_LICENSE 声明此模块的许可证,一般写在最后。如果不进行声明,则会收到内核被污染(kernel tainted)的警告。常见的有意义的声明:GPL,GPL v2 ,proprietary 等,其中 GPL 代表 GNU 通用公共许可证
  • 初始化与清理:初始化主要完成资源申请以及模块注册,在 module_init 中定义并完成,返回 0 表示初始化成功,可以进行下一步。清理函数没有返回值,在 module_exit 中定义并完成

  1. 编写模块编译规则文件

Makefile

   obj-m :=hello.o
   hello-objs:=helloword.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
  • obj-m :=hello.o:定义模块名,格式必须是 obj-m :=模块名
  • hello-objs:=helloword.o:定义目标文件,格式必须是:模块名-objs:=目标文件
  • KDIR:=/lib/modules/$(shell uname -r)/build:指定内核编译器路径
  • PWD:=$(shell pwd):模块源码所在目录
  • default: make -C $(KDIR) M=$(PWD) moduleclean: make -C $(KDIR) M=$(PWD) clean 分别表示编译执行指令和清理执行指令

  1. 编译模块

执行 make 指令进行模块编译

   $ make

编译成功后会在源码所在目录生成许多编译文件,其中 hello.ko 便是编译成功后生成的模块

若报错 Makefile:6: *** missing separator. Stop. 是因为 Makefile 文件中的编译执行指令和清理执行指令前的 Tab 不对,Windows 上开发的 Tab 与 Linux 不同,建议在终端中使用 vim 修改 Makefile 文件。

若需要重新编译,需要先执行

   $ make clean

再重新编译

  1. 加载模块

执行指令

   $ insmod hello.ko

若加载带参数的模块,执行指令

   $ insmod 模块名 参数=值

加载成功不会有任何提示

查看打印消息

   $ dmesg

若报错module verification failed: signature and/or required key missing - tainting kernel且不输出hello word是由于module签名导致的,有两种方式解决:

  • 在当前 linux 系统的 kernel 源码下修改 config 文件,CONFIG_MODULE_SIG=n

  • 在驱动的 Makefile 文件里面添加这行 CONFIG_MODULE_SIG=n

    (这里选择第二种)

重新编译后,加载模块,便可看到输出

  1. 卸载模块

执行命令

   $ rmmod hello.ko

查看打印消息

   $ dmesg

简单的操作系统模块设计

要求:设计一个带参数的模块,其参数为指定进程标识符 PID,模块功能是输出指定进程的内存管理信息,如进程可执行代码的起始及结束地址、已初始化数据的起始及结束地址、用户态堆栈起始地址、堆起始地址等。提示:可能参考的内核函数:get_task_mm()

  1. 分析内核数据结构及函数

    • task_struct进程描述符,Linux 内核通过一个被称为进程描述符的 task_struct 结构体来管理进程,这个结构体包含了一个进程所需的所有信息。这里只例举部分,各个变量详细的解析可见:Linux进程描述符task_struct结构体详解

      // 系统进程状态
      volatile long state;
      // 进程内存管理信息
      struct mm_struct *mm;
      // 进程号
      pid_t pid;
      // 进程组号
      pid_t tpid;
      // 真正的父进程指针
      struct task_struct *real_parent;
      // 父进程指针
      struct task_struct *parent;
      // 孩子进程指针
      struct list_head children;
      // 兄弟进程指针
      struct list_head sibling;
      // 进程名
      char comm.[TASK_COMM_LEN];
           
      ......
      
    • mm_struct内存描述符,mm_struct 结构描述了一个进程的整个虚拟地址空间,每个进程只有一个 mm_struct 结构,在每个进程的 task_struct 结构中,有一个指向该进程的结构。详细解释详见:进程—内存描述符(mm_struct)

      // 指向虚拟区间(VMA)的链表
      struct vm_area_struct * mmap;
      // 指向线性区对象红黑树的根
      struct rb_root mm_rb;
      // 指向最近找到的虚拟区间
      struct vm_area_struct * mmap_cache;
      // 用来在进程地址空间中搜索有效的进程地址空间的函数
      unsigned long(*get_unmapped_area) (struct file *filp,
                                      unsigned long addr, 
                                      unsigned long len,
                                      unsigned long pgoff, 
                                      unsigned long flags);
      unsigned long(*get_unmapped_exec_area) (struct file *filp,
                                           unsigned long addr, 
                                           unsigned long len,
                                           unsigned long pgoff, 
                                           unsigned long flags);
      // 释放线性地址区间时调用的方法
      void(*unmap_area) (struct mm_struct *mm, unsigned long addr);
      // 标识第一个分配文件内存映射的线性地址
      unsigned long mmap_base;
      unsigned long task_size;
      unsigned long cached_hole_size;
      // 内核从这个地址开始搜索进程地址空间中线性地址的空闲区域
      unsigned long free_area_cache;
      // 指向页全局目录
      pgd_t * pgd;
      // 次使用计数器,使用这块空间的个数  
      atomic_t mm_users;
      // 主使用计数器
      atomic_t mm_count;
      // 线性的个数
      int map_count;
      // 线性区的读/写信号量
      struct rw_semaphore mmap_sem;
      // 线性区的自旋锁和页表的自旋锁
      spinlock_t page_table_lock;
      // 指向内存描述符链表中的相邻元素
      struct list_head mmlist;
           
      ......
               
      // 进程拥有的最大页表数目
      unsigned long hiwater_rss;
      // 进程线性区的最大页表数目
      unsigned long hiwater_vm;
      // 进程地址空间的大小,锁住无法换页的个数,共享文件内存映射的页数,可执行内存映射中的页数
      unsigned long total_vm, locked_vm, shared_vm, exec_vm;
      // 用户态堆栈的页数
      unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
      // 维护代码段和数据段
      unsigned long start_code, end_code, start_data, end_data;
      // 维护堆和栈
      unsigned long start_brk, brk, start_stack;
      // 维护命令行参数,命令行参数的起始地址和最后地址,以及环境变量的起始地址和最后地址
      unsigned long arg_start, arg_end, env_start, env_end;
           
      ......
      
    • get_task_mm

      struct mm_struct *get_task_mm(struct task_struct *task)
      

    此函数根据提供的任务描述符信息,获取其对应的内存信息,此内存信息保存在 mm_struct 结构体类型的变量中

  2. 编写模块代码

task.c

   #include <linux/init.h>
   #include <linux/module.h>
   #include <linux/moduleparam.h>
   #include <linux/kernel.h>
   #include <linux/sched.h>
   
   static int pid;
   // 定义模块参数 pid:参数名字,int:参数类型,0644:参数的读写权限
   module_param(pid, int ,0644);
   
   static int task_init(void) 
   {
       struct pid *p;
       struct task_struct *ts;
       struct mm_struct *ms;
   
       // 根据 PID 获取进程的描述符信息
       p=find_get_pid(pid);
       // 获取进程的任务描述符信息
       ts=pid_task(p,PIDTYPE_PID); 
       // 获取任务的内存描述符
       ms=get_task_mm(ts);
       // 打印进程 PID 及 进程名称
       printk("PID: %d\n", ts->pid);
       printk("进程名: %20s\n", ts->comm);
       // 打印进程可执行代码的起始及结束地址
       printk("可执行代码起始地址: %ld\n", ms->start_code);
       printk("可执行代码结束地址: %ld\n", ms->end_code);
       // 打印已初始化数据的起始及结束地址
       printk("数据起始地址: %ld\n", ms->start_data);
       printk("数据结束地址: %ld\n", ms->end_data);
       // 用户态堆栈起始地址、堆起始地址
       printk("栈起始地址: %ld\n", ms->start_brk);
       printk("堆起始地址: %ld\n", ms->start_stack);
       // 打印其他内存管理信息
       printk("进程数量(多线程): %d\n", ms->mm_users);
       printk("引用计数 :%d\n", ms->mm_count);
       return 0;
   }
   
   static void task_exit(void)
   {
       printk(KERN_ALERT "task_init() has exited\n");
   }
   
   module_init(task_init);
   module_exit(task_exit);
   MODULE_LICENSE("GPL");
  1. 编写模块编译规则文件

Makefile

   obj-m :=sec.o
   sec-objs:=task.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 
  1. 编译模块

与前一个实验相同,有 Warning 可以忽略,不影响程序的使用

  1. 加载模块

在终端执行指令获取进程列表,选择一个进程进行测试

   $ ps -A

这里选择 bash进程,PID 为4472

执行指令

   $ insmod sec.ko pid=4472

查看打印消息

   $ dmesg

  1. 卸载模块

执行命令

   $ rmmod sec.ko

查看打印消息

   $ dmesg

两个模块编程的示例均已完成