linux驱动程序——入门
编写linux驱动程序
环境:
$ uname -r
5.18.17-amd64-desktop-hwe
$ uname -a
Linux dev-PC 5.18.17-amd64-desktop-hwe #20.01.00.10 SMP PREEMPT_DYNAMIC Thu Jun 15 16:17:50 CST 2023 x86_64 GNU/Linux
背景
linux 根据设备的共同特征将设备划分为三大类型:
- 字符设备
以字节为单击进行I/O传输 - 块设备
以块为单位进行I/O传输 - 网络设备
网络设备是一类比较特殊的设备,涉及网络协议层

基本步骤
一、建立Linux驱动框架(装载、卸载Linux驱动)
Linux内核在使用驱动时首先要装载驱动,在装载过程中进行一些初始化动作(建立设备文件、分配内存等),在驱动程序中需提供相应函数来处理驱动初始化工作,该函数须使用module_init宏指定;Linux系统在退出是需卸载Linux驱动,卸载过程中进行一些退出工作(删除设备文件、释放内存等),在驱动程序中需提供相应函数来处理退出工作,该函数须使用module_exit宏指定。Linux驱动程序一般都要这两个宏指定这两个函数,所以包含这两个宏以及其所指定的两个函数的C程序可看作是Linux驱动的框架。
二、注册和注销设备文件
任何Linux驱动都需要有一个设备文件来与应用程序进行交互。建立设备文件的工作一般在上一步module_init宏指定的函数中完成的,可以使用misc_register函数创建设备文件;删除设备文件的工作一般在上一步module_exit宏指定的函数中完成的,可以使用misc_deregister函数删除设备文件。
三、指定驱动相关信息
驱动程序是自描述的,驱动程序的信息需要在驱动源代码中指定。通过MODULE_AUTHOR(作者姓名)、MODULE_LICENSE(使用的开源协议)、MODULE_ALIAS(别名)、MODULE_DESCRIPTION(驱动描述)等宏来指定与驱动相关的信息,这些宏一般写在驱动源码文件的结尾。可通过modinfo命令获取这些信息。
四、指定回调函数
Linux驱动包含了很多动作,也称为事件,如“读”“写”事件,触发相应事件时Linux系统会自动调用对于驱动程序的相应回调函数。一个驱动程序不一定要指定所以的回调函数。回调函数通过相关机制进行注册。如与设备文件相关的回调函数使用misc_register函数注册。
五、编写业务逻辑
没什么可说的,总不能注册一些空的回调函数,什么也不做吧。
六、编写Makefile文件
Linux内核源码的编译规则是通过Makefile文件定义的,每个Linux驱动程序必须要有一个Makefile文件。
七、编译Linux驱动程序
Linux驱动程序可直接编译进内核(使用obj-y编译),也可以作为模块单独编译(使用obj-m编译)。
八、安装和卸载Linux驱动
如果将驱动编译进内核,只要Linux使用该内核,驱动程序就会自动装载。如果Linux驱动程序以模块单独存在,需要使用insmod或modprobe命令装载Linux驱动模块,使用rmmod命令卸载该模块。
hello world
// hello_world.c
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
// 指定license版本
MODULE_LICENSE("GPL");
// 作者信息
MODULE_AUTHOR("Marvin");
// 模块描述
MODULE_DESCRIPTION("hello world");
// 提供别名
MODULE_ALIAS("hello");
//设置初始化入口函数
static int __init hello_world_init(void)
{
printk(KERN_DEBUG "hello world!!!\n");
return 0;
}
//设置出口函数
static void __exit hello_world_exit(void)
{
printk(KERN_DEBUG "goodbye world!!!\n");
}
//将上述定义的init()和exit()函数定义为模块入口/出口函数
module_init(hello_world_init);
module_exit(hello_world_exit);
上述代码就是一个设备驱动程序代码框架,这套框架主要的任务就是将内核模块中的init函数动态地注册到系统中并运行,由module_init()和module_exit()来实现,分别对应驱动的加载和卸载。
只是它并不做什么事,仅仅是打印两条语句而已,如果要实现某些驱动,我们就可以在init函数中进行相应的编程。
编译
需要准备Linux头文件,一般通过
sudo apt-get install linux-headers-$(uname -r)或者sudo yum install kernel-headers来安装
ifneq ($(KERNELRELEASE),)
MODULE_NAME = helloworld
# 该模块需要的目标文件
# <模块名>-objs := <目标文件>.o
$(MODULE_NAME)-objs := hello_world.o
# 要生成的模块,注意模块名字不能与目标文件相同
# obj-m := <模块名>.o
obj-m := $(MODULE_NAME).o
else
KERNEL_DIR = /lib/modules/`uname -r`/build
MODULEDIR := $(shell pwd)
.PHONY: modules
default: modules
modules:
make -C $(KERNEL_DIR) M=$(MODULEDIR) modules
clean distclean:
rm -f *.o *.mod.c .*.*.cmd *.ko
rm -rf .tmp_versions
endif
编译结果会在当前目录生成helloworld.ko文件,这个文件就是我们需要的内核模块文件了。
可以通过modinfo命令来查看模块信息:
$ sudo modinfo helloworld.ko
filename: /home/dev/workspace/learn_linux_driver/hello_world/helloworld.ko
alias: hello
description: hello world
author: Marvin
license: GPL
srcversion: E09CBAA95387AC8A79E4989
depends:
retpoline: Y
name: helloworld
vermagic: 5.18.17-amd64-desktop-hwe SMP preempt mod_unload modversions
加载
编译生成了内核文件,接下来就要将其加载到内核中,linux支持动态地添加和删除模块,所以我们可以直接在系统中进行加载:
sudo insmod helloworld.ko
我们可以通过lsmod命令来检查模块是否被成功加载:
lsmod | grep helloworld
helloworld 16384 0
lsmod显示当前被加载的模块。
或者通过查看驱动程序打印的log:
dmesg | grep "hello world"
[ 30.993166] hello world!!!
同时,我们也可以卸载这个模块:
sudo rmmod hello_world.ko
同样我们也可以通过lsmod指令来查看模块是否卸载成功。
dmesg | grep "goodbye world"
[ 131.487449] goodbye world!!!
加载模块后,系统会在/sys/module目录下面新建一个目录:
$ tree /sys/module/helloworld -a
/sys/module/helloworld
├── coresize
├── holders
├── initsize
├── initstate
├── notes
│ ├── .note.gnu.build-id
│ └── .note.Linux
├── refcnt
├── sections
│ ├── .exit.text
│ ├── .gnu.linkonce.this_module
│ ├── .init.text
│ ├── __mcount_loc
│ ├── .note.gnu.build-id
│ ├── .note.Linux
│ ├── .return_sites
│ ├── .rodata.str1.1
│ ├── .strtab
│ └── .symtab
├── srcversion
├── taint
└── uevent
3 directories, 19 files
hello_world_plus 版本
在上面实现了一个linux内核驱动程序(虽然什么也没干),接下来我们再来添加一些小功能来丰富这个驱动程序:
- 添加模块信息
- 模块加载时传递参数。
// hello_world_plus.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_AUTHOR("marvin");
MODULE_DESCRIPTION("Linux kernel driver - hello_world plus!");
MODULE_VERSION("0.1");
MODULE_LICENSE("GPL");
static char *name = "world";
module_param(name, charp, S_IRUGO|S_IWUSR);
MODULE_PARM_DESC(name, "name,type: char *, permission: S_IRUGO|S_IWUSR");
static int __init hello_world_init(void)
{
printk(KERN_DEBUG "hello %s!!!\n", name);
return 0;
}
static void __exit hello_world_exit(void)
{
printk(KERN_DEBUG "goodbye %s!!!\n", name);
}
module_init(hello_world_init);
module_exit(hello_world_exit);
编译
编译之前需要修改Makefile,将hello_world.o修改为hello_world_plus.o。
ifneq ($(KERNELRELEASE),)
MODULE_NAME = helloworldplus
$(MODULE_NAME)-objs := hello_world_plus.o
obj-m := $(MODULE_NAME).o
else
KERNEL_DIR = /lib/modules/`uname -r`/build
MODULEDIR := $(shell pwd)
.PHONY: modules
default: modules
modules:
make -C $(KERNEL_DIR) M=$(MODULEDIR) modules
clean distclean:
make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) clean
rm -f *.o *.mod.c .*.*.cmd *.ko
rm -rf .tmp_versions
endif
加载
在上述程序中我们添加了module_param这一选项,module_param支持三个参数:变量名,类型,以及访问权限,我们可以先试一试传入参数:
sudo insmod helloworldplus.ko name="marvin"
查看日志输出,显示:
hello marvin!!!!!
看到模块中name变量被赋值为marvin,表明参数传入成功。
然后卸载:
sudo rmmod helloworldplus
日志输出:
goodbye marvin!!!!!
添加的模块信息
在hello_world_PLUS中,我们添加了一些模块信息,可以使用modinfo来查看:
modinfo helloworldplus.ko
输出:
# modinfo helloworldplus.ko
$ sudo modinfo helloworldplus.ko
filename: /home/dev/workspace/learn_linux_driver/hello_world_plus/helloworldplus.ko
license: GPL
version: 0.1
description: Linux kernel driver - hello_world plus!
author: marvin
srcversion: 28162B4946BD7DDC9A9A9CA
depends:
retpoline: Y
name: helloworldplus
vermagic: 5.18.17-amd64-desktop-hwe SMP preempt mod_unload modversions
parm: name:name,type: char *, permission: S_IRUGO|S_IWUSR (charp)
另外,当设置参数后,加载模块后系统在/sys/module下面的模块内产生参数信息文件:
ls -lh /sys/module/helloworldplus/parameters/
总用量 0
-rw-r--r-- 1 root root 4.0K 6月 17 23:43 name
通过修改这个文件可以对模块对应的参数进行修改,但是要注意对这个参数设置的权限。
总结一下:
- 模块加载函数:加载模块时,该函数会被自动执行,通常做一些初始化工作
- 模块卸载函数:卸载模块时,该函数也会被自动执行,做一些清理工作
- 模块许可声明:内核模块必须声明许可证,否则内核会发出被污染的警告
- 模块参数:根据需要来添加,可选
- 模块作者和描述声明:一般需要完善这些信息
- 模块导出符号:根据需要来添加
sysfs
sysfs是一个文件系统,但是它并不存在于非易失性存储器上(也就是我们常说的硬盘、flash等掉电不丢失数据的存储器),而是由linux系统构建在内存中,简单来说这个文件系统将内核驱动信息展现给用户。
当我们装载模块时,会在/sys/module/目录下生成一个与模块同名的目录,目录里囊括了驱动程序的大部分信息,这部分信息在上面有说明。
这一部分的知识仅仅是在这里引出提一下,建立个映象,在这里就不再赘述,如果想进一步了解可以参考linux设备驱动程序--sysfs。
内核版本是如何生成的:
Linux 内核在进行模块装载时先完成模块的 CRC 值校验,再核对 vermagic 中的字符信息,linux版本:在include/generated/utsrelease.h中定义,文件中的内容如下:#define UTS_RELEASE "5.18.17-amd64-desktop-hwe",utsrelease.h是kernel编译后自动生成的,用户更改里面的内容不会有效果。
在init/version-timestamp.c中,定义了kernel启动时的第一条打印信息:
/* FIXED STRINGS! Don't touch! */
const char linux_banner[] =
"Linux version " UTS_RELEASE " (" LINUX_COMPILE_BY "@"
LINUX_COMPILE_HOST ") (" LINUX_COMPILER ") " UTS_VERSION "\n";
这里UTS_RELEASE在kernel编译时自动生成
在init/main.c的start_kernel函数中,有kernel启动的第一条打印信息,这条信息是dmesg命令打印出来:
pr_notice("%s", linux_banner);
驱动模块的version magic信息是怎么生成的:
在linux/vermagic.h中定义有VERMAGIC_STRING:
#define VERMAGIC_STRING \
UTS_RELEASE " " \
MODULE_VERMAGIC_SMP MODULE_VERMAGIC_PREEMPT \
MODULE_VERMAGIC_MODULE_UNLOAD MODULE_VERMAGIC_MODVERSIONS \
MODULE_ARCH_VERMAGIC \
MODULE_RANDSTRUCT
VERMAGIC_STRING不仅包含内核版本号,还包含有内核使用的gcc版本,SMP与PREEMPT等配置信息。模块在编译时,我们可以看到屏幕上会显示"MODPOST"。在此阶段,VERMAGIC_STRING会添加到模块的modinfo段。在内核源码目录下scripts\mod\modpost.c文件中可以看到模块后续处理部分的代码。
/**
* Header for the generated file
**/
static void add_header(struct buffer *b, struct module *mod)
{
buf_printf(b, "#include <linux/module.h>\n");
/*
* Include build-salt.h after module.h in order to
* inherit the definitions.
*/
buf_printf(b, "#define INCLUDE_VERMAGIC\n");
buf_printf(b, "#include <linux/build-salt.h>\n");
buf_printf(b, "#include <linux/elfnote-lto.h>\n");
buf_printf(b, "#include <linux/export-internal.h>\n");
buf_printf(b, "#include <linux/vermagic.h>\n");
buf_printf(b, "#include <linux/compiler.h>\n");
buf_printf(b, "\n");
buf_printf(b, "BUILD_SALT;\n");
buf_printf(b, "BUILD_LTO_INFO;\n");
buf_printf(b, "\n");
buf_printf(b, "MODULE_INFO(vermagic, VERMAGIC_STRING);\n");
buf_printf(b, "MODULE_INFO(name, KBUILD_MODNAME);\n");
buf_printf(b, "\n");
buf_printf(b, "__visible struct module __this_module\n");
buf_printf(b, "__section(\".gnu.linkonce.this_module\") = {\n");
buf_printf(b, "\t.name = KBUILD_MODNAME,\n");
if (mod->has_init)
buf_printf(b, "\t.init = init_module,\n");
if (mod->has_cleanup)
buf_printf(b, "#ifdef CONFIG_MODULE_UNLOAD\n"
"\t.exit = cleanup_module,\n"
"#endif\n");
buf_printf(b, "\t.arch = MODULE_ARCH_INIT,\n");
buf_printf(b, "};\n");
if (!external_module)
buf_printf(b, "\nMODULE_INFO(intree, \"Y\");\n");
buf_printf(b,
"\n"
"#ifdef CONFIG_RETPOLINE\n"
"MODULE_INFO(retpoline, \"Y\");\n"
"#endif\n");
if (strstarts(mod->name, "drivers/staging"))
buf_printf(b, "\nMODULE_INFO(staging, \"Y\");\n");
if (strstarts(mod->name, "tools/testing"))
buf_printf(b, "\nMODULE_INFO(test, \"Y\");\n");
}
模块编译生成后,通过modinfo mymodule.ko命令可以查看此模块的vermagic等信息。
内核的模块装载器里保存有内核的版本信息,在装载模块时,装载器会比较所保存的内核vermagic与此模块的modinfo段里保存的vermagic信息是否一致,两者一致时,模块才能被装载。为了使两个版本一致:可以把依赖源码中的include/linux/vermagic.h中的UTS_RELEASE修改成与目标机器的版本一致,这样,再次编译模块就可以了。
符号共享
Linux通过EXPORT_SYMBOL()和EXPORT_SYMBOL_GPL()这两个宏来提供符号共享的功能。
EXPORT_SYMBOL()把函数或者符号对全部内核代码公开,即将一个函数以符号的方式导出给内核中的其他模块使用。
EXPORT_SYMBOL_GPL()只能包含GPL许可的模块,内核核心绝大部分模块导出的符号时使用GPL这种形式。如果要使用EXPORT_SYMBOL_GPL()导出的函数,那么需要显式地通过模块声明为GPL,如MODULE_LICENSE("GPL");。
在上面的hello_world_plus.c中添加下面代码后再次编译:
void test_hello_world_plus(void)
{
printk(KERN_DEBUG "test!!!\n");
}
EXPORT_SYMBOL_GPL(test_hello_world_plus);
之后在输出目录下会产生Module.symvers文件,该文件显式了导出的符号:
$ cat Module.symvers
0x26b3d60e test_hello_world_plus /home/dev/workspace/learn_linux_driver/hello_world_plus/helloworldplus EXPORT_SYMBOL_GPL
内核导出的符号表可以通过/proc/kallsyms来查看:
$ cat /proc/kallsyms | grep test_hello_world_plus
0000000000000000 r __kstrtab_test_hello_world_plus [helloworldplus]
0000000000000000 r __kstrtabns_test_hello_world_plus [helloworldplus]
0000000000000000 r __ksymtab_test_hello_world_plus [helloworldplus]
0000000000000000 t test_hello_world_plus [helloworldplus]
其中,第一列显式的是该符号在内核地址空间的地址;第二列是符号属性,t表示在text段中;第三列表示符号的字符串,也就是EXPORT导出来的符号;第四列表示哪些内核模块在使用该符号。

浙公网安备 33010602011771号