Linux驱动开发(2):第一个内核模块

1. hellomodule实验

1.1. 实验说明

本节实验使用到 EBF6ULL-PRO 开发板。

1.2. 实验代码讲解

本章的示例代码目录为:linux_driver/module/hellomodule

从前面我们已经知道了内核模块的工作原理,这一小节就开始写代码了,跟hello world一样,下面就展示一个最简单hello module框架。

cpp 复制代码
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>

static int __init hello_init(void)
{
    printk(KERN_EMERG "[ KERN_EMERG ]  Hello  Module Init\n");
    printk( "[ default ]  Hello  Module Init\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk("[ default ]   Hello  Module Exit\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL2");
MODULE_AUTHOR("embedfire ");
MODULE_DESCRIPTION("hello module");
MODULE_ALIAS("test_module");

类比hello world,接来下理解每一行代码的含义,并最终在Linux上运行这个模组,验证我们前面的理论,也为下一章驱动打下基础。

1.2.1. 代码框架分析

Linux内核模块的代码框架通常由下面几个部分组成:

  • 模块加载函数(必须): 当通过insmod或modprobe命令加载内核模块时,模块的加载函数就会自动被内核执行,完成本模块相关的初始化工作。

  • 模块卸载函数(必须): 当执行rmmod命令卸载模块时,模块卸载函数就会自动被内核自动执行,完成相关清理工作。

  • 模块许可证声明(必须): 许可证声明描述内核模块的许可权限,如果模块不声明,模块被加载时,将会有内核被污染的警告。

  • 模块参数: 模块参数是模块被加载时,可以传值给模块中的参数。

  • 模块导出符号: 模块可以导出准备好的变量或函数作为符号,以便其他内核模块调用。

  • 模块的其他相关信息: 可以声明模块作者等信息。

上面示例的hello module程序只包含上面三个必要部分以及模块的其他信息声明(模块参数和导出符号将在下一节实验出现)。

头文件包含了<linux/init.h>和<linux/module.h>,这两个头文件是写内核模块必须要包含的 。 模块初始化函数hello_init调用了printk函数,在内核模块运行的过程中,他不能依赖于C库函数, 因此用不了printf函数,需要使用单独的打印输出函数printk。该函数的用法与printf函数类似。 完成模块初始化函数之后,还需要调用宏module_init 来告诉内核,使用hello_init函数 来进行初始化。 模块卸载函数也用printk函数打印字符串,并用宏module_exit在内核注册该模块的卸载函数。 最后,必须声明该模块使用遵循的许可证,这里我们设置为GPL2协议。

1.2.2. 头文件

前面我们已经接触过了Linux的应用编程,了解到Linux的头文件都存放在/usr/include中。 编写内核模块所需要的头文件,并不在上述说到的目录,而是在Linux内核源码中的include文件夹。

  • #include <linux/module.h>: 包含了内核加载module_init()/卸载module_exit()函数和内核模块信息相关函数的声明

  • #include <linux/init.h>: 包含一些内核模块相关节区的宏定义

  • #include <linux/kernel.h>: 包含内核提供的各种函数,如printk

编写内核模块中经常要使用到的头文件有以下两个:<linux/init.h>和<linux/module.h>。 我们可以看到在头文件前面也带有一个文件夹的名字linux,对应了include下的linux文件夹,我们到该文件夹下,查看这两个头文件都有什么内容。

init.h头文件(位于内核源码 /include/linux/init.h):

cpp 复制代码
/* These are for everybody (although not all archs will actually discard it in modules) */
#define __init      __section(.init.text) __cold  __latent_entropy __noinitretpoline
#define __initdata  __section(.init.data)
#define __initconst __section(.init.rodata)
#define __exitdata  __section(.exit.data)
#define __exit_call __used __section(.exitcall.exit)

#define __exit          __section(.exit.text) __exitused __cold notrace

init.h头文件主要包含了内核模块用到的一些宏定义,因此,只要我们涉及内核模块的编程,就需要加上该头文件。

module.h头文件(位于内核源码 /include/linux/module.h):

cpp 复制代码
#define module_init(x)  __initcall(x);
#define module_exit(x)  __exitcall(x);

/* Generic info of form tag = "info" */
#define MODULE_INFO(tag, info) __MODULE_INFO(tag, tag, info)
#define MODULE_ALIAS(_alias) MODULE_INFO(alias, _alias)
#define MODULE_LICENSE(_license) MODULE_INFO(license, _license)
#define MODULE_AUTHOR(_author) MODULE_INFO(author, _author)

以上代码中,包含了内核模块的加载、卸载函数的声明,还列举了module.h文件中的部分宏定义,这部分宏定义, 有的是可有可无的,但是MODULE_LICENSE这个是指定该内核模块的许可证,是必须要有的。

注意: 在本教程使用的4.19.35版本内核中, module_initmodule_exit 函数声明在 /include/linux/module.h 文件中,旧版本的内核这两个函数声明在 /include/linux/init.h 文件中。

1.2.3. 模块加载/卸载函数

module_init

回忆我们使用单片机时,假设我们要使用串口等外设时,是不是都需要调用一个初始化函数, 在这个函数里面,我们初始化了串口的GPIO,配置了串口的相关参数,如波特率,数据位,停止位等等参数。 func_init函数在内核模块中也是做与初始化相关的工作。

内核模块加载函数(位于内核源码/include/linux/module.h)

cpp 复制代码
static int __init func_init(void)
{
}
module_init(func_init);

内核模块的代码,实际上是内核代码的一部分, 假如内核模块定义的函数和内核源代码中的某个函数重复了, 编译器就会报错,导致编译失败,因此我们给内核模块的代码加上static修饰符的话, 那么就可以避免这种错误。

返回值:

  • 0: 表示模块初始化成功,并会在**/sys/module**下新建一个以模块名为名的目录,如下图中的红框处

  • 非0: 表示模块初始化失败

__init、__initdata宏定义(位于内核源码/include/linux/init.h):

cpp 复制代码
#define __init      __section(.init.text) __cold  __latent_entropy __noinitretpoline
#define __initdata  __section(.init.data)

以上代码 __init、__initdata宏定义(位于内核源码/linux/init.h)中的__init用于修饰函数,__initdata用于修饰变量。 带有__init的修饰符,表示将该函数放到可执行文件的__init节区中,该节区的内容只能用于模块的初始化阶段, 初始化阶段执行完毕之后,这部分的内容就会被释放掉,真可谓是"针尖也要削点铁"。

module_init宏定义:

cpp 复制代码
#define module_init(x) __initcall(x);

宏定义module_init用于通知内核初始化模块的时候, 要使用哪个函数进行初始化 。它会将函数地址加入到相应的节区section中, 这样的话,开机的时候就可以自动加载模块了。

内核模块卸载函数(位于内核源码/include/linux/module.h):

cpp 复制代码
static void __exit func_exit(void)
{
}
module_exit(func_exit);

理解了模块加载的内容之后,来学习模块卸载函数应该会比较简单。 与内核加载函数相反,内核模块卸载函数func_exit主要是用于释放初始化阶段分配的内存, 分配的设备号等,是初始化过程的逆过程。

与函数func_init区别在于,该函数的返回值是void类型,且修饰符也不一样, 这里使用__exit,表示将该函数放在可执行文件的**__exit节区**, 当执行完模块卸载阶段之后,就会自动释放该区域的空间。

__exit、__exitdata宏定义(位于内核源码/include/linux/init.h):

cpp 复制代码
#define __exit          __section(.exit.text) __exitused __cold notrace
#define __exitdata  __section(.exit.data)

类比于模块加载函数,__exit用于修饰函数,__exitdata用于修饰变量。 宏定义module_exit用于告诉内核,当卸载模块时,需要调用哪个函数。

printk函数

  • printf:glibc实现的打印函数,工作于用户空间

  • printk:内核模块无法使用glibc库函数,内核自身实现的一个类printf函数,但是需要指定打印等级

    • #define KERN_EMERG "<0>" 通常是系统崩溃前的信息

    • #define KERN_ALERT "<1>" 需要立即处理的消息

    • #define KERN_CRIT "<2>" 严重情况

    • #define KERN_ERR "<3>" 错误情况

    • #define KERN_WARNING "<4>" 有问题的情况

    • #define KERN_NOTICE "<5>" 注意信息

    • #define KERN_INFO "<6>" 普通消息

    • #define KERN_DEBUG "<7>" 调试信息

查看当前系统printk打印等级:cat /proc/sys/kernel/printk, 从左到右依次对应当前控制台日志级别、默认消息日志级别、 最小的控制台级别、默认控制台日志级别。

打印内核所有打印信息:dmesg,注意内核log缓冲区大小有限制,缓冲区数据可能被覆盖掉。

1.2.4. 许可证

Linux是一款免费的操作系统,采用了GPL协议,允许用户可以任意修改其源代码。 GPL协议的主要内容是软件产品中即使使用了某个GPL协议产品提供的库, 衍生出一个新产品,该软件产品都必须采用GPL协议,即必须是开源和免费使用的, 可见GPL协议具有传染性。因此,我们可以在Linux使用各种各样的免费软件。 在以后学习Linux的过程中,可能会发现我们安装任何一款软件,从来没有30天试用期或者是要求输入激活码的。

cpp 复制代码
#define MODULE_LICENSE(_license) MODULE_INFO(license, _license)

内核模块许可证有 "GPL","GPL v2","GPL and additional rights","Dual SD/GPL","Dual MPL/GPL","Proprietary"。

1.2.5. 相关信息声明

下面,我们介绍一下关于内核模块程序结构的最后一部分内容。 这部分内容只是为了给使用该模块的读者一本"说明书",属于可有可无的部分, 有则锦上添花,若没有也无伤大雅。

表 内核模块信息声明函数

函数 作用
MODULE_LICENSE() 表示模块代码接受的软件许可协议,Linux内核遵循GPL V2开源协议,内核模块与linux内核保持一致即可。
MODULE_AUTHOR() 描述模块的作者信息
MODULE_DESCRIPTION() 对模块的简单介绍
MODULE_ALIAS() 给模块设置一个别名
cpp 复制代码
#define MODULE_AUTHOR(_author) MODULE_INFO(author, _author)

我们前面使用modinfo中打印出的模块信息中"author"信息便是来自于宏定义MODULE_AUTHOR。 该宏定义用于声明该模块的作者。

cpp 复制代码
#define MODULE_DESCRIPTION(_description) MODULE_INFO(description, _description)

模块信息中"description"信息则来自宏MODULE_DESCRIPTION,该宏用于描述该模块的功能作用

cpp 复制代码
#define MODULE_ALIAS(_alias) MODULE_INFO(alias, _alias)

模块信息中"alias"信息来自于宏定义MODULE_ALIAS。该宏定义用于给内核模块起别名。 注意,在使用该模块的别名时,需要将该模块复制到/lib/modules/内核源码/下, 使用命令depmod更新模块的依赖关系,否则的话,Linux内核怎么知道这个模块还有另一个名字。

1.3. 实验准备

获取内核模块源码,将配套代码 linux_driver/module/hellomodule 解压到内核代码同级目录。

1.3.1. makefile说明

对于内核模块而言,它是属于内核的一段代码,只不过它并不在内核源码中。 为此,我们在编译时需要到内核源码目录下进行编译。 编译内核模块使用的Makefile文件,和我们前面编译C代码使用的Makefile大致相同, 这得益于编译Linux内核所采用的Kbuild系统,因此在编译内核模块时,我们也需要指定环境变量ARCH和CROSS_COMPILE的值。

Makefile (位于 linux_driver/module/hellomodule/Makefile):

cpp 复制代码
KERNEL_DIR=../../ebf_linux_kernel/build_image/build

ARCH=arm
CROSS_COMPILE=arm-linux-gnueabihf-
export  ARCH  CROSS_COMPILE

obj-m := hellomodule.o
all:
    $(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules

.PHONE:clean copy

clean:
    $(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean

copy:
    sudo  cp  *.ko  /home/embedfire/workdir

以上代码中提供了一个关于编译内核模块的Makefile。

  • 第1行:该Makefile定义了变量KERNEL_DIR,来保存内核源码的目录。

  • 第3-5行: 指定了工具链并导出环境变量

  • 第6行:变量obj-m保存着需要编译成模块的目标文件名。

  • 第8行:'(MAKE)modules'实际上是执行Linux顶层Makefile的伪目标modules。通过选项'-C',可以让make工具跳转到源码目录下读取顶层Makefile。'M=(CURDIR)'表明返回到当前目录,读取并执行当前目录的Makefile,开始编译内核模块。CURDIR是make的内嵌变量,自动设置为当前目录。

1.3.2. 编译命令说明

在实验目录下输入如下命令来编译驱动模块:

bash 复制代码
make

编译成功后,实验目录下会生成名为"hellomodule.ko"的驱动模块文件。

1.4. 程序运行结果

1.4.1. 如何使用内核模块

我们如愿编译了自己的内核模块,接下来就该了解如何使用这个内核模块了。 将hellomodule.ko通过scp或NFS拷贝到开发板中,我们来逐一讲解这些工具。

lsmod

lsmod列出当前内核中的所有模块,格式化显示在终端,其原理就是将**/proc/modules**中的信息调整一下格式输出。 lsmod输出列表有一列 Used by, 它表明此模块正在被其他模块使用,显示了模块之间的依赖关系。

如果要将一个模块加载到内核中,insmod是最简单的办法, insmod+模块完整路径就能达到目的,前提是你的模块不依赖其他模块,还要注意需要sudo权限。 如果你不确定是否使用到其他模块的符号,你也可以尝试modprobe,后面会有它的详细用法。

通过insmod命令加载hellomodule.ko内存模块加载该内存模块的时候, 该内存模块会自动执行module_init()函数,进行初始化操作,该函数打印了 'hello module init'。 再次查看已载入系统的内核模块,我们就会在列表中发现hellomodule.ko的身影。

在我们 内核模块传参与符号共享实验 这一小节,calculation.ko和parametermodule.ko。 其中calculation.ko依赖parametermodule.ko中的参数和函数, 所以先手动加载parametermodule.ko,然后再加载calculation.ko。

同样卸载的时,parametermodule.ko中的参数和函数被calculation.ko调用,必须先卸载calculation.ko 再卸载parametermodule.ko,否则会报错"ERROR: Module parametermodule is in use by: calculation"

modprobe和insmod具备同样的功能,同样可以将模块加载到内核中,除此以外modprobe还能检查模块之间的依赖关系, 并且按照顺序加载这些依赖,可以理解为按照顺序多次执行insmod。

在内核模块传参与符号共享实验中,calculation.ko和parametermodule.ko需要按照先后次序依次加载,而使用modprobe工具, 可以直接加载parametermodule.ko,当然modprobe之前需要先用depmod -a建立模块之间的依赖关系。注意: 使用 depmod -a 命令建立依赖关系之前需要把calculation.ko和parametermodule.ko复制到 /lib/modules/内核版本 目录下;此外在使用 modprobe 加载驱动时,驱动名不带 .ko , 否则会出现如下错误 modprobe: FATAL: Module calculation.ko not found in directory /lib/modules/4.19.35-imx6, 卸载驱动时也一样)

depmod

modprobe是怎么知道一个给定模块所依赖的其他的模块呢?在这个过程中,depend起到了决定性作用,当执行modprobe时, 它会在模块的安装目录下搜索module.dep文件,这是depmod创建的模块依赖关系的文件。

rmmod

rmmod工具仅仅是将内核中运行的模块删除,只需要传给它路径就能实现。

rmmod命令卸载某个内存模块时,内存模块会自动执行*_exit()函数,进行清理操作, 我们的hellomodule中的*_exit()函数打印了一行内容,但是控制台并没有显示,可以使用dmesg查看, 之所以没有显示是与printk的打印等级有关,前面有关于printk函数有详细讲解。 rmmod不会卸载一个模块所依赖的模块,需要依次卸载,当然是用modprobe -r 可以一键卸载

modinfo

modinfo用来显示我们在内核模块中定义的几个宏。 我们可以通过modinfo来查看hellomodule,我们从打印的输出信息中,可以了解到,该模块遵循的是GPL协议, 该模块的作者是embedfire,该模块的vermagic等等。而这些信息在模块代码中由相关内核模块信息声明函数声明

1.4.2. 系统自动加载模块

我们自己编写了一个模块,或者说怎样让它在板子开机自动加载呢? 这里就需要用到上述的depmod和modprobe工具了。

首先需要将我们想要自动加载的模块统一放到"/lib/modules/内核版本 "目录下,内核版本使用'**uname -r'**查询; 其次使用depmod建立模块之间的依赖关系,命令' depmod -a'; 这个时候我们就可以在modules.dep中看到模块依赖关系,可以使用如下命令查看;

复制代码
cat /lib/modules/内核版本/modules.dep | grep calculation

最后在/etc/modules加上我们自己的模块,注意在该配置文件中,模块不写成.ko形式代表该模块与内核紧耦合,有些是系统必须要跟内核紧耦合,比如mm子系统, 一般写成.ko形式比较好,如果出现错误不会导致内核出现panic错误,如果集成到内核,出错了就会出现panic。

然后重启开发板,lsmod就能查看到我们的模块开机就被加载到内核里面了。

2. 内核模块传参与符号共享实验

2.1. 实验说明

本节实验使用到 EBF6ULL-PRO 开发板。

2.2. 实验代码讲解

本章的示例代码目录为:linux_driver/module/parametermodule

本节实验验证内核模块传参和符号共享。

2.2.1. 内核模块传参代码讲解

内核模块作为一个可拓展的动态模块,为Linux内核提供了灵活性,但是有时我们需要根据不同的应用场景给内核传递不同的参数, 例如在程序中开启调试模式、设置详细输出模式以及制定与具体模块相关的选项,都可以通过参数的形式来改变模块的行为。

Linux内核提供一个宏来实现模块的参数传递

module_param函数 (内核源码/include/linux/moduleparam.h):

cpp 复制代码
 #define module_param(name, type, perm)              \
     module_param_named(name, name, type, perm)

 #define module_param_array(name, type, nump, perm)      \
     module_param_array_named(name, name, type, nump, perm)

以上代码中的module_param函数需要传入三个参数:

  • name: 我们定义的变量名;

  • type: 参数的类型,目前内核支持的参数类型有byte,short,ushort,int,uint,long,ulong,charp,bool,invbool。其中charp表示的是字符指针,bool是布尔类型,其值只能为0或者是1;invbool是反布尔类型,其值也是只能取0或者是1,但是true值表示0,false表示1。变量是char类型时,传参只能是byte,char * 时只能是charp。

  • perm: 表示的是该文件的权限,具体参数值见下表。

用户组 标志位 解释
当前用户 S_IRUSR 用户拥有读权限
S_IWUSR 用户拥有写权限
当前用户组 S_IRGRP 当前用户组的其他用户拥有读权限
S_IWUSR 当前用户组的其他用户拥有写权限
其他用户 S_IROTH 其他用户拥有读权限
S_IWOTH 其他用户拥有写权限
[文件权限]

上述文件权限唯独没有关于可执行权限的设置,请注意, 该文件不允许它具有可执行权限。如果强行给该参数赋予表示可执行权限的参数值S_IXUGO, 那么最终生成的内核模块在加载时会提示错误,见下图。

下面是我们一个实验代码:

示例程序(linux_driver/module/parametermodule/parametermodule.c)

cpp 复制代码
static int itype=0;
module_param(itype,int,0);

static bool btype=0;
module_param(btype,bool,0644);

static char ctype=0;
module_param(ctype,byte,0);

static char  *stype=0;
module_param(stype,charp,0644);

static int __init param_init(void)
{
   printk(KERN_ALERT "param init!\n");
   printk(KERN_ALERT "itype=%d\n",itype);
   printk(KERN_ALERT "btype=%d\n",btype);
   printk(KERN_ALERT "ctype=%d\n",ctype);
   printk(KERN_ALERT "stype=%s\n",stype);
   return 0;
}
  • 第1-11行:定义了四个常见变量然后使用module_param宏来声明这四个参数

  • 第13-21行:并在param_init中输出上面声明的四个参数。

我们定义的四个模块参数,会在 '/sys/module/模块名/parameters' 下会存在以模块参数为名的文件。 由于itype和ctype的权限是0,所以我们没有权限查看该参数。

2.2.2. 符号共享代码讲解

在前面我们已经详细的分析了关于导出符号的内核源码,符号指的就是在内核模块中导出函数变量 , 在加载模块时被记录在公共内核符号表 中,以供其他模块调用。 这个机制,允许我们使用分层的思想解决一些复杂的模块设计。我们在编写一个驱动的时候, 可以把驱动按照功能分成几个内核模块,借助符号共享去实现模块与模块之间的接口调用,变量共享。

cpp 复制代码
#define EXPORT_SYMBOL(sym) \\
__EXPORT_SYMBOL(sym, "")

EXPORT_SYMBOL宏用于向内核导出符号,这样的话,其他模块也可以使用我们导出的符号了。 下面通过一段代码,介绍如何使用某个模块导出符号。

cpp 复制代码
...省略代码...
static int itype=0;
module_param(itype,int,0);

EXPORT_SYMBOL(itype);

int my_add(int a, int b)
{
   return a+b;
}

EXPORT_SYMBOL(my_add);

int my_sub(int a, int b)
{
   return a-b;
}

EXPORT_SYMBOL(my_sub);
...省略代码...
  • 第2-3行:定义了参数itype,并通过EXPORT_SYMBOL宏导出

  • 第7-12行:和my_add,并通过EXPORT_SYMBOL宏导出

  • 第14-21行:my_sub函数,并通过EXPORT_SYMBOL宏导出

以上代码中,省略了内核模块程序的其他内容,如头文件,加载/卸载函数等。

calculation.h:

cpp 复制代码
#ifndef __CALCULATION_H__
#define __CALCULATION_H__

extern int itype;

int my_add(int a, int b);
int my_sub(int a, int b);

#endif

calculation.c:

cpp 复制代码
...省略代码...
#include "calculation.h"

...省略代码...
static int __init calculation_init(void)
{
   printk(KERN_ALERT "calculation  init!\n");
   printk(KERN_ALERT "itype+1 = %d, itype-1 = %d\n", my_add(itype,1), my_sub(itype,1));
   return 0;
}
...省略代码...

2.3. 实验准备

获取内核模块源码,将配套代码 linux_driver/module/parametermoudule 解压到内核代码同级目录。

2.3.1. makefile说明

Makefile (位于 linux_driver/module/hellomodule/Makefile):

cpp 复制代码
...省略代码...
obj-m := parametermodule.o calculation.o
...省略代码...

以上Makefile与上一个实验,只有目标文件不同。

2.3.2. 编译命令说明

在实验目录下输入如下命令来编译驱动模块:

bash 复制代码
make

编译成功后,实验目录下会生成名为"parametermodule.o" 和"calculation.o "的驱动模块文件。

2.4. 程序运行结果

通过NFS将编译好的module_param.ko拷贝到开发板中,加载module_param.ko并传参, 这时我们声明的四个变量的值,就是变成了我们赋的值。

bash 复制代码
sudo insmod parametermodule.ko itype=123 btype=1 ctype=200 stype=abc

查看向内核导出的符号表 'cat /proc/kallsyms'

相关推荐
hero_th11 分钟前
[Ubuntu] 文件/目录权限更改
linux·ubuntu
wclass-zhengge12 分钟前
SpringBoot篇(运维实用篇 - 临时属性)
运维·spring boot·后端
速盾cdn14 分钟前
速盾:什么是高防CDN?高防CDN的用处有哪些?
运维·服务器·网络·web安全
花花少年20 分钟前
pip在ubuntu下换源
linux·ubuntu·pip
y0ungsheep39 分钟前
[GXYCTF 2019]Ping Ping Ping 题解(多种解题方式)
linux·web安全·网络安全·php
海绵波波10743 分钟前
Webserver(1.6)Linux系统IO函数
linux·运维·服务器
江水三千里1 小时前
NFS服务器
运维·服务器
forestqq1 小时前
构建后端为etcd的CoreDNS的容器集群(七)、编写适合阅读的域名管理脚本
运维·数据库·etcd
czme1 小时前
线程和进程
linux·数据结构·计算机网络
华东设计之美1 小时前
etcd多实例配置
linux·服务器·etcd