number headings: auto, first-level 2, max 6, 1.1
参考书籍:
Linux版本编号规则为:
VERSION.PATCHLEVEL.SUBLEVEL[-EXTRAVERSION]
其中:
Linux将设备分为了如下三个大类:
字符设备是指必须以字节流顺序访问的设备,如字符终端( /dev/console
)、串口( /dev/tty*
)、触摸屏、磁带驱动器、鼠标等。
open
、 close
、 read
、 write
系统调用。/dev
下)。open()
、 read()
、 write()
等方式进行访问。块设备允许按照任意顺序进行访问,以块为单位进行操作,如硬盘、eMMC等。
dd
的命令操作设备的块FAT
、 EXT4
等文件系统后,使用文件系统的方式进行访问。/dev
下)。open()
、 read()
、 write()
等方式进行访问。网络设备面向数据包的发送和接收设计,内核与网络设备之间的通信主要使用Socket。
loopback
)。eth0
。read()
、 write()
等。当前的通用处理器主要采用了冯诺依曼架构和哈佛架构两种架构形式,其对应的常见的CPU系列有:
arch | char | short | int | long | ptr | long long | u8 | u16 | u32 | u64 |
---|---|---|---|---|---|---|---|---|---|---|
i386 | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
alpha | 1 | 2 | 4 | 8 | 8 | 8 | 1 | 2 | 4 | 8 |
armv4l | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
ia64 | 1 | 2 | 4 | 8 | 8 | 8 | 1 | 2 | 4 | 8 |
m68k | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
mips | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
ppc | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
sparc | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
sparc64 | 1 | 2 | 4 | 8 | 8 | 8 | 1 | 2 | 4 | 8 |
x86_64 | 1 | 2 | 4 | 8 | 8 | 8 | 1 | 2 | 4 | 8 |
常用存储器分类可见常用存储器 > 常用存储器。
Linux的组件可以选择编译进Linux内核或者编译为Linux内核模块,编译为Linux内核模块的好处有:
Linux内核模块的加载与卸载:
insmod
命令rmmod
命令由于Linux内核模块是直接以内核模式运行的,因此可以出于安全考虑,在编译Linux内核时,禁用加载模块功能。
相比于普通的应用程序开发,Linux内核的开发需要额外注意以下几点:
一个Linux内核模块应当具有且实现以下几个组成部分:
static int __init init_func(void)
{
// 具体实现...
// 初始化成功时应当返回0
// 执行失败时应当选择 <linux/errno.h> 中的错误代码
}
// 指定初始化函数
module_init(init_func);
注意:
goto
语句实现,也可以编写统一的 cleanup
函数检查资源是否被成功分配并释放资源(推荐后者)。一般 cleanup
函数不会被注册为卸载函数,因此不能被标记为 __exit
。<linux/errno.h>
中的错误码,这样方便用户使用 perror
之类的函数将错误转换为有意义的字符串。static void __exit exit_function(void)
{
// ...模块卸载,释放资源
}
// 指定模块卸载函数
module_exit(exit_function);
如果未定义模块卸载函数,则内核不允许卸载该模块。
模块在加载时可以传入一些命令行参数,其主要通过如下方式进行定义与加载:
// 相当于缺省参数
static int port = 8080;
// module_param(name, type, perm)
module_param(port, int, S_IRUGO);
在 module_param
函数中:
perm
为访问许可配置:
S_IRUGO
表示任何人都可以读取该参数S_IRUGO|S_IWUSR
表示root用户可以修改该参数。但是当参数发生修改时,内核不会通知内核模块参数被修改,因此通常不使用。在加载时可以通过如下命令指定模块参数:
insmod var=value #例如:insmod port=80
内核模块支持的加载参数列表如下:
bool
invbool
:关联int型,反转bool值,输入为 true
则传入为 false
。charp
:关联char*,内核会为用户提供的字符串分配内存并设置指针。int
long
short
uint
:关联unsigned int,u表示无符号。ulong
ushort
module_param_array
进行参数接收。int add_int(int a, int b)
{
return a + b;
}
// 导出符号
EXPORT_SYMBOL(add_int);
int add_int_gpl(int a, int b)
{
return a + b;
}
// 导出包含GPL许可证的符号
EXPORT_SYMBOL_GPL(add_int);
注意:使用 EXPORT_SYMBOL_GPL
导出的符号不可以被非GPL模块引用。
模块许可证声明:
MODULE_LICENSE("GPL v2");
模块作者信息:
MODULE_AUTHOR("xxtech");
模块描述:
MODULE_DESCRIPTION("...");
模块版本:
MODULE_VERSION("V1.0");
模块别名:
MODULE_ALIAS("...");
static int var_name __initdata = 0;
上述 __initdata
和模块加载函数的 __init
都标识在内核被加载完毕后,其所定义的变量或函数会被从内存中扔掉。
2. 与上一条相似的是,内核中也有 __exitdata
与 __exit
。上述四个特性均会被放置到特殊ELF端。
在为内核编写模块时,一定要选择与目标系统相匹配的内核源码,否则生成的模块无法使用。
Ubuntu可以使用 apt
和 git
两种方式获取源码。
使用apt获取源码:
直接搜索linux内核,并找出当前使用的内核包名
sudo apt search linux-image
返回结果为:
Sorting... Done
Full Text Search... Done
linux-image-generic-hwe-24.04/noble,now 6.8.0-31.31 amd64 [installed,automatic]
Generic Linux kernel image
则直接使用apt下载上述内核包的源码即可。
apt-get install linux-source-6.8.0
该内核源码会被下载到 /usr/src
目录,复制并解压即可。
使用git获取源码:
在获取源码之前,需要获取当前系统信息:
uname -a
Linux h13-VMware-Virtual-Platform 6.8.0-31-generic #31-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 20 00:40:06 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
上述结果指明目前使用的内核版本为 6.8.0-31-generic
。
Ubuntu所使用的内核源码仓库页面为:https://kernel.ubuntu.com/git/
从上述页面中:
git clone https://git.launchpad.net/~canonical-kernel/ubuntu/+source/linux-intel/+git/noble
git checkout 7fdb45c9bbbc95a3300b4d8de3f751f4c05c98e2
# 先创建目录
mkdir noble
# 初始化git仓库
git init
# 连接到git仓库
git remote add origin https://git.launchpad.net/~canonical-kernel/ubuntu/+source/linux-intel/+git/noble
# 只clone目标版本
git fetch --depth 1 origin 7fdb45c9bbbc95a3300b4d8de3f751f4c05c98e2
# 将工作目录切换到目标commit的内容
git checkout FETCH_HEAD
只做内核驱动/模块开发建议使用后者,完整clone速度过慢。
以上述命令为例,截止2024年05月30日:
本实例为一个基础普通模块的示例。
Linux已经在 lib/test_module.c
放置了一个基础的Hello World模块,其主要代码如下:
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
#include <linux/init.h>
#include <linux/module.h>
#include <linux/printk.h>
static int __init test_module_init(void)
{
pr_warn("Hello, world\n");
return 0;
}
module_init(test_module_init);
static void __exit test_module_exit(void)
{
pr_warn("Goodbye\n");
}
module_exit(test_module_exit);
MODULE_AUTHOR("Kees Cook <[email protected]>");
MODULE_LICENSE("GPL");
并在 lib/Makefile
中已经添加了如下代码:
obj-$(CONFIG_TEST_LKM) += test_module.o
经过搜索发现, CONFIG_TEST_LKM
在如下几个文件中被定义为 m
:
tools/testing/selftests/kmod/config
tools/testing/selftests/splice/config
obj-m += test_module.o
因此当需要编译自己的内核模块时,可以直接在Makefile中添加如下代码:
obj-m += xxx.o
因此可以直接在 lib
目录下直接建立一个新文件 hello_module.c
,并编写如下代码:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/printk.h>
static int __init hello_module_init(void)
{
printk("Hello module inited.\n");
return 0;
}
static void __exit hello_module_exit(void)
{
printk("Hello module exited.\n");
}
module_init(hello_module_init);
module_exit(hello_module_exit);
MODULE_LICENSE("GPL v2");
MODULE_AUTHOR("h13");
MODULE_DESCRIPTION("hello word module.");
MODULE_VERSION("V1.0");
注意 printk
需要以 \n
结尾,否则不会及时刷新。
在 lib/Makefile
的末尾增加如下代码:
# Hello world module
obj-m += hello_module.o
回到linux代码根目录,执行:
make modules -j8 #8线程
即可在 lib
路径下得到 hello_module.ko
,使用
使用 modinfo hello_module.ko
查看模块信息:
# modinfo hello_module.ko
filename: .../lib/hello_module.ko
version: V1.0
description: hello word module.
author: h13
license: GPL v2
srcversion: C6A36BCF3E2045DD1DE8E30
depends:
retpoline: Y
intree: Y
name: hello_module
vermagic: 6.8.1+ SMP preempt mod_unload modversions
注:
pr_fmt(fmt)
:pr_fmt应用及原理解析在上述结果中可以明显地发现即使在下载时内核源码版本匹配,但是编译出来的模块的vermagic仍不匹配。
因此应当直接修改所下载的内核源码中 include/linux/vermagic.h
文件的 vermagic
定义。首先使用 lsmod
列出当前内核已加载的模块,并随便选取一个已加载的模块使用 modinfo
查看其模块信息:
vermagic: 6.8.0-31-generic SMP preempt mod_unload modversions
然后查看 include/linux/vermagic.h
中的相关定义。
/* Simply sanity version stamp for modules. */
#ifdef CONFIG_SMP
#define MODULE_VERMAGIC_SMP "SMP "
#else
#define MODULE_VERMAGIC_SMP ""
#endif
#ifdef CONFIG_PREEMPT_BUILD
#define MODULE_VERMAGIC_PREEMPT "preempt "
#elif defined(CONFIG_PREEMPT_RT)
#define MODULE_VERMAGIC_PREEMPT "preempt_rt "
#else
#define MODULE_VERMAGIC_PREEMPT ""
#endif
#ifdef CONFIG_MODULE_UNLOAD
#define MODULE_VERMAGIC_MODULE_UNLOAD "mod_unload "
#else
#define MODULE_VERMAGIC_MODULE_UNLOAD ""
#endif
#ifdef CONFIG_MODVERSIONS
#define MODULE_VERMAGIC_MODVERSIONS "modversions "
#else
#define MODULE_VERMAGIC_MODVERSIONS ""
#endif
#ifdef RANDSTRUCT
#include <generated/randstruct_hash.h>
#define MODULE_RANDSTRUCT "RANDSTRUCT_" RANDSTRUCT_HASHED_SEED
#else
#define MODULE_RANDSTRUCT
#endif
#define VERMAGIC_STRING \
UTS_RELEASE " " \
MODULE_VERMAGIC_SMP MODULE_VERMAGIC_PREEMPT \
MODULE_VERMAGIC_MODULE_UNLOAD MODULE_VERMAGIC_MODVERSIONS \
MODULE_ARCH_VERMAGIC \
MODULE_RANDSTRUCT
发现是 UTS_RELEASE
字段不匹配,其定义与 generated/utsrelease.h
中,是自动生成的头文件。
回到linux内核源代码根目录的 Makefile
文件,定位到两段关键代码:
VERSION = 6
PATCHLEVEL = 8
SUBLEVEL = 1
EXTRAVERSION =
NAME = Hurr durr I'ma ninja sloth
# Read KERNELRELEASE from include/config/kernel.release (if it exists)
KERNELRELEASE = $(call read-file, include/config/kernel.release)
KERNELVERSION = $(VERSION)$(if $(PATCHLEVEL),.$(PATCHLEVEL)$(if $(SUBLEVEL),.$(SUBLEVEL)))$(EXTRAVERSION)
可发现将修改第一段的内核版本号即可,修改后如下。
VERSION = 6
PATCHLEVEL = 8
SUBLEVEL = 0
EXTRAVERSION = -31-generic
NAME = Hurr durr I'ma ninja sloth
最后的 +
号出现条件为:
LOCALVERSION
LOCALVERSION
为空即可。make LOCALVERSION= include/config/kernel.release
综上,当官方提供的内核源代码版本与实际内核不符时,应当:
Makefile
中的版本号。LOCALVERSION
为空,或删除 scripts/setlocalversion
中的加号(建议)。vermagic: 6.8.0-31-generic SMP preempt mod_unload modversions
与系统中已加载的模块信息一致,但是此时加载仍然报错则证明内核源代码不匹,应当考虑编译安装当前有源代码的内核。
内核中集成了很多可选的调试技术支持。这些调试技术或多或少的都会对内核的运行速度有所影响,因此通常发行版的Linux一般不会开启这些支持,这也是推荐使用自行编译的内核而非发行版内核的一个原因。
make modules
make modules-install
make install
随后重启,通常不建议卸载原内核,当内核配置出错无法启动后,可以在引导处切换内核。
开启一个新的终端监视 printk
信息:
cat /proc/kmsg
在原终端加载模块:
insmod hello_module.ko
无报错,且监视终端会输出日志:
[ 85.532987] Hello module inited.
使用 lsmod
也可以正常获取模块信息
lsmod | grep hello
hello_module 12288 0
卸载mod也可以正常输出日志
rmmod hello_module
[ 411.289281] Hello module exited.
再次使用 lsmod | grep hellod
则无结果。
若抓取日志,会发现一个警告,是由于没有签名造成的:
dmesg | grep hello
[ 85.531743] hello_module: module verification failed: signature and/or required key missing - tainting kernel
可以选择在 Makefile
文件中添加如下配置,关闭强制签名。
CONFIG_MODULE_SIG=n
当内核模块 test_module.ko
由多个源文件 file1.c
、 file2.c
、 ...
构成时,Makefile中可以如下编写:
obj-m += test_module.o
module-objs += file1.o file2.o ...
用户态驱动的优点:
libusb
等。同时可以先在用户态完成大部分驱动开发与调试,随后移植到内核态。用户态驱动的缺点:
vm86
。mmap
映射 /dev/mem
才可以直接访问物理内存,但是需要对应权限(不一定需要root,可以配置访问权限)。ioperm
或 ioctl
才可以访问IO端口,但是并非所有平台都有此支持,性能较差且需要对应权限。mlock
或许可以缓解,但是用户态驱动通常会链接多个库,需要占用多个page。本章节的字符设备驱动程序将以一个简易的进程间管道通信为例。
基本设定:
在 /dev
目录下执行 ls -l
,可以得到如下结果:
...
drwxrwxrwt 2 root root 40 Jun 4 01:31 shm/
crw------- 1 root root 10, 231 Jun 4 01:31 snapshot
drwxr-xr-x 3 root root 200 Jun 4 01:31 snd/
brw-rw----+ 1 root cdrom 11, 0 Jun 4 01:31 sr0
lrwxrwxrwx 1 root root 15 Jun 4 01:31 stderr -> /proc/self/fd/2
lrwxrwxrwx 1 root root 15 Jun 4 01:31 stdin -> /proc/self/fd/0
lrwxrwxrwx 1 root root 15 Jun 4 01:31 stdout -> /proc/self/fd/1
crw-rw-rw- 1 root tty 5, 0 Jun 4 01:35 tty
crw--w---- 1 root tty 4, 0 Jun 4 01:31 tty0
crw--w---- 1 root tty 4, 1 Jun 4 01:31 tty1
crw--w---- 1 root tty 4, 10 Jun 4 01:31 tty10
...
在上述结果的各列对应关系如下:
| 权限 | 文件数 | 所有者 | 用户组 | 文件大小或设备数 | 修改日期 | 文件名 |
drwxrwxrwt 2 root root 40 Jun 4 01:31 shm/
crw------- 1 root root 10, 231 Jun 4 01:31 snapshot
在上述栏目中,列 权限
第一个字母及其对应关系如下:
在上述栏目中,列 文件大小或设备数
在文件类型不同时其意义也不同:
主设备号, 次设备号
在Linux 2.6.0到最新的Linux 6.10中, dev_t
被定义为一个 u32
类型,其:
dev_t dev = MKDEV(major, minor);
int major = MAJOR(dev);
int minor = MINOR(dev);
在Linux 2.5系列的修改中,设备编号从原先的8+8=16位变成了如今的32位。
分配设备号主要有两种方式,分别为静态分配和动态分配。
静态分配需要指定主设备号,主设备号的选择可以查看 Documentation/admin-guide/devices.txt
中的示例。
静态分配设备号的前提是要知道哪些设备号可用,再去指定想要申请的设备号。
查询空闲设备号需要读取 /proc/devices
文件未使用的设备号。
其主要的API有如下两个
// 该函数会从from下的从设备号作为起始从设备号,向from所指的主设备下连续申请count个设备
int register_chrdev_region(dev_t from, unsigned count, const char *name)
// 该函数会在指定的主设备号下注册一个从设备,此时要求主设备号大于0。
// 设备信息为该函数的第二、三个参数。
// 在此种用法下,函数的成功返回值为0,失败为负值。
// 该函数也可以用于动态分配设备号,见下一章节。
static inline int register_chrdev(unsigned int major, const char *name,
const struct file_operations *fops)
上述函数的 struct file_operations
用于建立文件操作和驱动程序操作的连接。具体可见绑定文件操作章节。
动态分配不需要指定主设备号,根据 Documentation/admin-guide/devices.txt
中的说明,对于字符设备而言,动态分配的主设备号会从254开始向下分配直到234,分配完毕后再从511开始向下分配直到384。
为了考虑驱动在多台设备上的兼容性,避免冲突,应当优先考虑动态分配设备号。
// 该函数会从baseminor开始,向dev所指主设备下连续注册count个设备
// baseminor通常为0
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count,
const char *name)
// 该函数会自动,此时要求主设备号等于0。
// 设备信息为该函数的第二、三个参数。
// 在此种用法下,函数的成功返回值为0,失败为负值。
// 该函数也可以用于动态分配设备号,见下一章节。
static inline int register_chrdev(unsigned int major, const char *name,
const struct file_operations *fops)
对于上述的所有分配设备编号的方法,均可以使用如下的函数进行释放:
void unregister_chrdev_region(dev_t from, unsigned count)
如上述章节所述, struct file_operations
用于建立文件操作和驱动程序操作的连接,该数据结构的定义如下(Linux 6.10版本):
struct file_operations {
struct module *owner;
fop_flags_t fop_flags;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iopoll)(struct kiocb *kiocb, struct io_comp_batch *,
unsigned int flags);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
void (*splice_eof)(struct file *file);
int (*setlease)(struct file *, int, struct file_lease **, void **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
unsigned (*mmap_capabilities)(struct file *);
#endif
ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
loff_t, size_t, unsigned int);
loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
struct file *file_out, loff_t pos_out,
loff_t len, unsigned int remap_flags);
int (*fadvise)(struct file *, loff_t, loff_t, int);
int (*uring_cmd)(struct io_uring_cmd *ioucmd, unsigned int issue_flags);
int (*uring_cmd_iopoll)(struct io_uring_cmd *, struct io_comp_batch *,
unsigned int poll_flags);
} __randomize_layout;
其主要成员及用法为:
owner |
不可 | 常用 THIS_MODULE |
用于存储提供该文件操作的模块的指针,避免模块在使用时被卸载。 |
fop_flags |
|||
llseek |
用于修改当前文件的读写位置,并返回修改后的新位置。 | ||
read |
可为空1 | 用于从设备中读取数据,返回值为读取的字节数。 在该函数的实现中,不需要驱动程序使用 file->fmode 鉴别是否可读写,此工作内核已完成。 |
|
aio_read |
可为空1 | 用于从设备中异步读取数据,数据读取完毕后调用参数指定的回调函数。 | |
write |
可为空1 | 用于写入数据,返回值为写入的字节数。 | |
aio_write |
可为空1 | 用于从设备中异步写入数据,数据写入完毕后调用参数指定的回调函数。 | |
readdir |
可为空 | 读取目录操作。对于文件类型,该参数应当为NULL。 | |
iopoll |
可为空 | NIO多路复用模型的后端实现。用于查询请求某个事件/操作是否会被阻塞。 为NULL时则意味告诉内核请求该设备任何事件/操作都不会发生阻塞。 |
|
ioctl |
可为空1 | 为内核/用户提供该设备的特殊命令操作。 | |
mmap |
可为空1 | 将设备内存映射到进程地址空间。 | |
open |
可为空 | 如果该函数为NULL,则打开该设备的请求无论如何都会成功,且驱动程序不会知道本设备被打开。 | |
flush |
可为空 | 含义同用户态的 flush 。如果该函数为NULL,则内核会忽略用户的 flush 请求。flush会被在用户态的每次close时都会被调用一次。 (但是用户态的每次close不一定会触发release,见下例) |
|
release |
可为空 | 注意是没有close函数的,也就是该函数并不对应open函数。 有时file文件在一次打开后会被共享(如使用了 folk 、 dup 等),此时内核中维护的计数器+1,并且当计数器归0时才会真正调用release函数,但是每次关闭都会调用一次上方的flush函数。 该函数也是可以被设置为NULL的,效果与open相似。 |
|
fsync |
可为空1 | fsync 的后端调用。 |
|
aio_fsync |
可为空1 | fsync 的异步版本。 |
|
lock |
常为空 | 用于实现文件锁定。 | |
注:
NULL
时,则会告诉内核该设备不支持对应操作,当用户调用时会抛出错误。注意本章节所述的 file
类型与C标准库的 FILE
类型无任何关联。file
是内核提供的一个数据结构,不会暴露给用户态程序。而 FILE
是C语言标准库中所定义的,不会出现在内核态的代码中。
在内核中,struct file
指针通常被称为 file
或 filep
。struct file
数据结构的定义如下(Linux 6.10版本):
struct file {
union {
/* fput() uses task work when closing and freeing file (default). */
struct callback_head f_task_work;
/* fput() must use workqueue (most kernel threads). */
struct llist_node f_llist;
unsigned int f_iocb_flags;
};
/*
* Protects f_ep, f_flags.
* Must not be taken from IRQ context.
*/
spinlock_t f_lock;
fmode_t f_mode;
atomic_long_t f_count;
struct mutex f_pos_lock;
loff_t f_pos;
unsigned int f_flags;
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
u64 f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* needed for tty driver, and maybe others */
void *private_data;
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct hlist_head *f_ep;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping;
errseq_t f_wb_err;
errseq_t f_sb_err; /* for syncfs */
} __randomize_layout
其主要成员及其用法为:
f_lock |
|||
f_mode |
标志位,表示该文件是否是否可读或可写,由内核配置。 | ||
f_pos |
当前的读写位置。除上一章节 llseek 函数以外不应当直接修改该成员。 |
||
f_flags |
标志位,表示用户请求的属性(如只读、阻塞等)。 会被用户请求改变该标志位的值,内核不需要提前配置该标志位的值,内核只负责读取该值。 |
||
f_op |
不可 | 存储上一章节中所定义的 struct file_operations 。该成员允许随时修改,从而在需要的时候重载各种需要的操作(例如 /dev/null 文件)。 |
|
private_data |
主要用于跨系统调用时保存或传递相关信息,或存储驱动自身所需要保存的信息。 该指针所分配的资源需要在release方法中手动释放。 |
||
f_dentry |
文件对应的目录项结构,大多数设备文件无需关心。 |
inode
即Linux的混合索引分配的节点,不过设备文件通常不需要关心混合索引分配的文件系统管理相关的内容(电科爱考),因此需要关注的成员主要有如下两个:
i_rdev |
包含了上述的 dev_t 可以用于存储设备号等。 |
||
i_cdev |
字符设备的 struct cdev 类型。 |
由于Linux在2.5系列中修改了 dev_t
的位数,因此出于可移植性考虑,建议使用如下的接口获取设备号(而不是先获取 dev_t
再获取设备号):
unsigned int iminor(struct inode *inode);
unsigned int imajor(struct inode *inode);
通常来说(但不绝对),注册一个字符设备需要完成申请结构体空间、配置结构体、注册设备、反注册设备、回收空间几个任务。注意错误处理和资源回收即可。
需要注意的是字符设备使用的 cdev_t
有两种内存分配方式:
cdev_t
结构体:
cdev_t
结构体cdev_init
初始化该结构体,同时指定 file_operations
owner
cdev_add
添加字符设备cdev_t*
指针cdev_alloc
分配内存空间,此时无需再使用 cdev_init
初始化cdev
的 file_operations
owner
cdev_add
添加字符设备cdev_del
即可解除注册并可能回收内存。但是需要额外注意以下几点:cdev_init
初始化静态分配的结构体时,结构体的 kobj_type
会被注册为静态分配类型,并指定release函数为 cdev_default_release
。cdev_alloc
为指针分配内存时, kobj_type
会被注册为动态分配类型,并指定release函数为 cdev_dynamic_release
。cdev_del
解除注册,且无需担心动态内存回收问题。cdev_del
解除注册后,设备不一定会被立即移除,内核会等待用户停止使用该设备后才会移除。但是使用 cdev_del
解除注册后,内核模块不应当再调用该模块。static int __init mpipe_init(void)
{
int ret = 0;
// allocate char dev region.
ret = alloc_chrdev_region(&dev, 0, 1, "mpipe");
if(ret)
goto failed;
// allocate cdev
if((cdev = cdev_alloc()) == NULL)
goto cdev_alloc_failed;
cdev->ops = &fops;
cdev->owner = THIS_MODULE;
// register a character device.
ret = cdev_add(cdev, dev, 1);
if(ret)
goto cdev_add_failed;
cdev_add_failed:
cdev_del(cdev);
cdev_alloc_failed:
unregister_chrdev_region(dev, 1);
failed:
return ret;
}
static void __exit mpipe_exit(void)
{
cdev_del(cdev);
unregister_chrdev_region(dev, 1);
}
open应当完成如下任务:
file_operations
中的指针filep
中填写应当存放在 private_data
中的数据对应地,release应当完成如下任务:
filep
中寄存的数据read和write方法的函数指针如下所示:
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
需要注意的是:
char __user *
是用户空间的指针,在内核代码中永远不要直接操作它们,原因如下:
loff_t *
是长偏移类型的偏移量,是在文件中的偏移量,因此与内存中常用的 ssize_t
不同。*offp
所表示的文件位置。内核和用户之间的copy方法如下:
unsigned long copy_to_user(void __user *to, const void *from, unsigned long count);
unsigned long copy_from_user(void *to, const void __user *from, unsigned long count);
上述两个函数在Linux 6.10中均由汇编实现。
read方法的返回值应符合如下几种情况:
count
。
count
时表示读取成功。count
时,在部分情况下程序可能会重复执行read直到凑够 count
,例如使用标准库的 fread
读取设备文件就会自动执行此流程。<linux/errno.h>
write和read方法的返回值及其注意事项相似。
在Linux中已对用户态提供了readv和writev两个批量读写的api。内核驱动可以自行选择是否实现批量版本的读写方法。若内核驱动不实现该方法,则readv和writev会自动在用户态多次调用对应的read和write方法进行实现。随之带来的问题就是执行一次readv或writev会多次切换CPU状态。
而对于用户而言,使用readv、writev而不是read、write的主要原因是前者可以批量复制位于不连续内存空间上的数据。
在Linux 2.6.18的 struct file_operations
中还保留有 readv
和 writev
的定义:
ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
上述支持在Linux 2.6.19中已经删除。
内核中集成了很多可选的调试技术支持。这些调试技术或多或少的都会对内核的运行速度有所影响,因此通常发行版的Linux一般不会开启这些支持,这也是推荐使用自行编译的内核而非发行版内核的一个原因。
内核中的调试选项都在 menuconfig
下的 Kernel hacking
中,可选的调试选项如下:
TODO,用到再说
Linux内核驱动的多个函数在实际运行中均非独占运行,一个 open
或 read
函数均可能会被多个线程的多个不同的上下文同时调用,因此要做好并发环境下的临界区管理。
在内核程序中通常也会依靠信号量或互斥锁进行临界区访问管理,但是需要注意的是,内核态程序也会随时被切换为休眠状态,即使此时还处于内核的临界区内。在执行一些操作时被切换为休眠状态是一个非常危险的事情。而Linux内核也给出了允许进入休眠态的互斥锁和不允许进入休眠态的互斥锁。
内核程序是这样的,用户态的程序临界区管理只需要上互斥锁就可以,而内核程序需要考虑的就很多了,内核程序在占用互斥锁后退出临界区之前会不会随时被切换为休眠状态、在使用禁止休眠的锁机制的临界区内被调用的其他内核组件会不会被切换为休眠状态等,都是需要考虑的事情。
Linux内核所使用的信号量和互斥锁应当使用头文件 <asm/semaphore.h>
,其相关用法见如下几个子章节所述。
普通信号量的动态初始化可以用如下的函数进行:
void sema_init(struct semaphore *sem, int val);
该函数中的 val
为该信号量的初始值。
互斥锁可以按照如下的方式使用静态定义:
#include <asm/semaphore.h>
// 定义初始值为1的互斥量,即mutex
DECLARE_MUTEX(name);
// 定义初始值为0的互斥量,在获取该互斥量之前必须先显式的解锁
DECLARE_MUTEX_LOCK(name);
由于该宏的实际用途为定义,但命名却使用了 DECLARE
,因此在Linux 2.6.36之后就删除了该接口。
同样的,Linux也提供了互斥锁动态初始化的方法:
void init_MUTEX(struct semaphore *sem);
void init_MUTEX_LOCKED(struct semaphore *sem);
不过这两个函数也在之后的版本被移除。
互斥锁本质就是信号量的增加与减少。互斥锁的减少可以使用如下的函数:
void down(struct semaphore *sem);
int down_interruptible(struct semaphore *sem);
int down_trylock(struct semaphore *sem);
函数 down
会阻塞等待直至成功获取到对应的互斥量,且该操作不允许被用户空间中断。
函数 down_interruptible
也会互斥等待,但是允许等待该信号量的操作被用户空间程序中断。在其因用户空间的程序中断的时候该函数的返回值为非零值,且此时内核程序并未获得该互斥量。
函数 down_trylock
则永远不会休眠,成功获得互斥资源时返回值为0,其他情况为非0。
互斥资源操作时,在非必要情况下应当使用允许中断的版本。
注意上述的中断指的是可否被用户中断,而不是可否被阻塞休眠。不可休眠的是自旋锁。
互斥锁的增加直接使用如下函数即可:
void up(struct semaphore *sem);
读写锁与普通的信号量有些不同,因此其需要使用头文件 <linux/rwsem.h>
,其初始化方法与操作方法如下:
void init_rwsem(struct rw_semaphore *sem);
// 读者API
void down_read(struct rw_semaphore *sem);
int down_read_trylock(struct rw_semaphore *sem);
void up_read(struct rw_semaphore *sem);
// 写者API
void down_write(struct rw_semaphore *sem);
int down_write_trylock(struct rw_semaphore *sem);
void up_write(struct rw_semaphore *sem);
void downgrade_write(struct rw_semaphore *sem);
其中, downgrade_write
是将写者降级为读者。
在内核编程时常用的一个设计方法是在别的线程初始化某个活动,原线程会等待该活动结束后才会继续执行后续任务。尽管使用信号量就可以解决这个需求,但是内核也提供了更好的解决方案,即completion接口,头文件为 <linux/completion.h>
,用法如下:
// 静态初始化
DECLARE_COMPLETION(my_completion);
// 动态初始化
struct completion my_completion;
init_completion(&my_completion);
随后可以如下使用:
void wait_for_completion(struct completion *c);
void complete(struct completion *c);
void complete_all(struct completion *c);
这里将自旋锁与普通的互斥锁分开讨论的主要原因是由于自旋锁和互斥锁的特性(不可休眠)、实现、应用场景及约定均有所不同,因此在后续的讨论中会将轮询实现的自旋锁和信号量分开讨论。
在用户态,自旋锁主要用于避免快速上锁/释放锁的场景下线程进入阻塞状态后,因调度算法导致进程再次获得处理机所需要的较长时间。而无论内核态还是用户态,自旋锁是都用于避免被阻塞休眠的。在内核中,有些工作不能进入阻塞状态,例如中断处理程序等。
自旋锁的头文件位于 <linux/spinlock.h>
,用法如下:
// 静态初始化
spinlock_t lock = SPIN_LOCK_UNLOCKED;
// 动态初始化
void spin_lock_init(spinlock_t *lock);
自旋锁只是在申请和等待锁时不会被休眠,但是自旋锁并不负责保护临界区代码不会被休眠。并且在使用自旋锁时应当避免代码被休眠,否则极有可能引发死锁并引起内核崩溃,例如在中断服务函数中使用了一个自旋锁,但是该锁被其他进程持有,且该进程被中断服务抢占CPU时间,导致中断永远无法结束,进程也永远无法获得CPU时间从而导致死锁。
但是避免休眠很难做到,例如:
copy_form_user
kmalloc
因此自旋锁的操作除了如下的两个基础API:
void spin_lock(spinlock_t *lock);
void spin_unlock(spinlock_t *lock);
以外,还有相较高级一些的API:
// 获得自旋锁的函数
void spin_lock_irqsave(spinlock_t *lock, unsigned long long flags);
void spin_lock_irq(spinlock_t *lock);
void spin_lock_bh(spinlock_t *lock);
// 释放自旋锁的函数
void spin_unlock_irqsave(spinlock_t *lock, unsigned long long flags);
void spin_unlock_irq(spinlock_t *lock);
void spin_unlock_bh(spinlock_t *lock);
其中:
spin_lock_irqsave
会在获得自旋锁之前禁止中断,先前的状态会被保存于 flags
中。该函数是宏函数,因此 flags
会被修改。其本质调用为 flags = _raw_spin_lock_irqsave(lock);
spin_lock_irq
和上述函数的实际调用一致,只是忽略了需要保存的 flags
。spin_lock_bh
会关闭所有软件中断,但不会关闭硬中断。spin_unlock_irqsave
可以将由 spin_lock_irqsave
保存的 flags
恢复。还有非阻塞式的函数:
int spin_trylock(spinlock_t *lock);
int spin_trylock_bh(spinlock_t *lock);
上述函数在成功获得自旋锁时会返回0,其他情况为非0。
自旋读写锁的头文件依旧位于 <linux/spinlock.h>
,其常用方法如下:
// 静态初始化
rwlock_t lock = RE_LOCK_UNLOCKED;
// 动态初始化
rwlock_t lock;
rwlock_init(&lock);
void read_lock(rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock, unsigned long flags);
void read_lock_irq(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
void read_unlock(rwlock_t *lock);
void read_unlock_irqsave(rwlock_t *lock, unsigned long flags);
void read_unlock_irq(rwlock_t *lock);
void read_unlock_bh(rwlock_t *lock);
void write_lock(rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock, unsigned long flags);
void write_lock_irq(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);
int write_trylock(rwlock_t *lock);
void write_unlock(rwlock_t *lock);
void write_unlock_irqsave(rwlock_t *lock, unsigned long flags);
void write_unlock_irq(rwlock_t *lock);
void write_unlock_bh(rwlock_t *lock);
并发管理向来不是一个轻松的任务,并发及互斥锁的常见问题有如下几个章节:
在编写代码时,很容易会写出一个已经持有互斥锁的函数去调用另外一个需要持有互斥锁才能运行的函数,这种通常称为递归调用。而在上述给出的并发管理工具中并未给出类似于POSIX中的递归锁或可重入锁,因此在内核编程中应更加着重注意此类问题。通常的做法是在所有暴露给内核的API中做互斥管理,然后在这些API中所调用的内部实现均假设该互斥资源已被占有。
一个API需要同时获取多个互斥资源是一个很常见的事情,且非常容易触发操作系统中的类似于哲学家吃饭问题的死锁问题,通常的处理方式是一个内核程序统一按照一个有序的方式申请资源(即资源的有序分配法)。
并且,需要注意的是,在同时应当获得一个信号量和一个自旋锁时,必须优先获取信号量,随后再去申请自旋锁。因为持有自旋锁的进程被信号量阻塞是一件严重错误且非常危险的事情。
当然,最好的处理此类需要获取多个互斥资源的方法就是尽量避免这种现象。
关于互斥资源管理的粒度问题,可以直接以Linux对SMP的支持进行举例。
在第一个支持多处理器的Linux2.0,整个内核有且仅有一个巨大的临界区(BKL,Big Kernel Lock)。当需要操作内核临界区时都需要申请该互斥锁。而在后来的Linux2.2中,互斥锁管理的粒度逐渐细化,例如I/O子系统共用一个互斥锁,网络系统共用一个互斥锁等。到了现在的版本,Linux内核中已经拥有了上千个互斥锁。
互斥资源管理粒度的细化无疑会提升CPU资源的利用率,但是在实际的工程中,不应当过早的细化互斥资源的管理,因为项目最终的性能瓶颈往往不会出现在互斥资源的管理上。而混乱的互斥资源管理反而会导致更多严重问题的出现。
例如若要实现一个如下图所示的循环缓冲区,可以使用读写锁,仅使用原子变量表示读写index,并使用该原子index做到Berstein条件即可。
而原子操作对应的头文件为 <asm/atomic.h>
,常见的操作方法如下:
// 设置初始值
void atomic_set(atomic_t *v, int i);
// 可用于但不仅用于静态初始化
atomic_t v = ATOMIC_INIT(0);
// 读取
int atomic_read(atomic_t *v);
// 加减运算
void atomic_add(int i, atomic_t *v);
void atomic_sub(int i, atomic_t *v);
// 自增自减
void atomic_inc(atomic_t *v);
void atomic_dec(atomic_t *v);
// 带判定是否为0的减法及自增自减方法,返回值仅有0或1,与 atomic_*_and_return 方法不同
// (注意不存在加法函数,即不存在 `atomic_add_and_test` 方法)
int atomic_inc_and_test(atomic_t *v);
int atomic_dec_and_test(atomic_t *v);
int atomic_sub_and_test(int i, atomic_t *v);
// 执行加法并判定是否为负,返回值仅有0或1
int atomic_add_negative(int i, atomic_t *v);
// 执行自增自减或加减并返回结果,与 atomic_*_and_test 不同
int atomic_add_return(int i, atomic_t *v);
int atomic_sub_return(int i, atomic_t *v);
int atomic_inc_return(atomic_t *v);
int atomic_dec_return(atomic_t *v);
此外,内核还提供了一组原子位操作,可以用于操作非原子变量。这些原子位操作速度非常快,在架构支持的情况下仅需要一个机器指令就可以完成,则这些平台进行此类操作的时候就可以不关闭中断。但是其实现也高度依赖于具体平台,用于表述目标位的 nr
参数通常为 int
,但是有时会被定义为 unsigned long
;要修改的元素通常用 unsigned long *
来做指针,但在一些平台中会被定义为 void*
。其主要的API如下:
void set_bit(nr, void* addr);
void clear_bit(nr, void* addr);
void change_bit(nr, void* addr);
test_bit(nr, void* addr);
int test_and_set_bit(nr, void* addr);
int test_and_clear_bit(nr, void* addr);
int test_and_change_bit(nr, void* addr);
通常来说并不建议使用原子位操作,而建议改用自旋锁进行操作。
seqlock与信号量不同,其不需要任何阻塞操作。其通常用于处理读者写者问题,但是只能解决仅有一个写者的情况。
seqlock的使用与原理如下:
int seq = 0;
void reader()
{
int value = 0;
// 等待进入临界区,进入临界区条件为seq为偶数
while(seq % 2);
// 进入临界区
do {
if(seq_old != seq)
seq_old = seq;
value = Read();
} while(seq_old != seq);
}
void writer()
{
seq ++;
write();
seq ++;
}
在上述原理中,写者:
注意:不要用seqlock保护一个指针,因为指针在writer里面很可能会被修改为NULL等一旦访问就会触发错误的值。
seqlock的头文件位于 <linux/seqlock.h>
,使用方法如下:
初始化:
// 静态初始化
seqlock_t lock = SEQLOCK_UNLOCKED;
// 动态初始化
seqlock_t lock;
seqlock_init(&lock);
执行读取任务:
int reader()
{
unsigned int seq = 0;
do {
// 下一句可能会进行自旋操作,直到写者退出
seq = read_seqbegin(&lock);
// Do sth...
read();
} while read_seqretry(&lock, seq);
}
上述代码中的 read_seqbegin
、 read_seqretry
分别可以换成如下的任意版本:
unsigned int read_seqbegin_irqsave(seqlock_t *lock, unsigned long flags);
int read_seqretry_irqrestore(seqlock_t *lock, unsigned long flags);
执行写入任务直接使用如下API即可
void write_seqlock(seqlock_t *lock);
void write_sequnlock(seqlock_t *lock);
void write_seqlock_irqsave(seqlock_t *lock, unsigned long flags);
void write_seqlock_irq(seqlock_t *lock);
void write_seqlock_bh(seqlock_t *lock);
void write_sequnlock_irqrestore(seqlock_t *lock, unsigned long flags);
void write_sequnlock_irq(seqlock_t *lock);
void write_sequnlock_bh(seqlock_t *lock);
RCU方法是一个高级的互斥机制,尽管它很少的被用于驱动程序设计中。不过在路由表和Starmode的射频IP驱动仍使用了该方法。
RCU是一个支持一个写者和多个读者同时进行操作的一个互斥机制(但不意味着至多只有一个写者)。RCU适用于需要频繁的读取数据,而修改数据并不多的情景。例如在文件系统中,经常需要查找定位目录,而对目录的修改相对来说并不多,这就是RCU发挥作用的最佳场景。
RCU的基本流程:
读者:
写者:
资源回收:
注意,Linux的RCU并不依赖引用计数器,因为引用计数器本身的互斥保护也是一个麻烦的事情。在Linux中,RCU的基本规则是所有读者对该数据的访问均以原子操作进行,从而保证在进行读者的原子操作时不会被抢占。随后,当所有处理器都完成一次任务调度后(此时保证了所有读者完成对资源的读取)即可进行资源的清除工作。RCU的性能表现比自旋锁要好。
资源的清除工作通过预先设置的回调函数实现,通常该函数的工作内容就是调用 kfree
。
void call_rcu(struct rcu_head* head, void(*func)(void *arg), void *arg);
在第5章讲解了如何编写或实现用户对设备的读取或写入请求,但是在真实的设备中往往还有例如弹出设备、修改波特率等非普通读写操作。而这些方法往往使用ioctl作为操作接口。
在用户空间中, ioctl
的函数原型如下:
int ioctl(int fd, unsigned long cmd, ...);
尽管在用户空间,该API的参数被定义为可变参数,但是在内核中并非如此。
查阅资料并分析可知,用户态的 ioctl
参数要求为:
ioctl
至多支持3个参数,因此可变参数的引用并没有引入更多的参数支持ioctl
支持2个参数的调用,这也是使用可变参数的原因在Linux 2.6.35及以前,内核的 ioctl
接口同时被定义为如下的接口:
int (*ioctl)(struct inode *inode, struct file *filep, unsigned int cmd, unsigned long arg);
long (*unlocked_ioctl) (struct file *, unsigned int cmd, unsigned long arg);
long (*compat_ioctl) (struct file *, unsigned int cmd, unsigned long arg);
在Linux 2.6.36及之后,内核的ioctl仅保留了如下的接口:
long (*unlocked_ioctl) (struct file *, unsigned int cmd, unsigned long arg);
long (*compat_ioctl) (struct file *, unsigned int cmd, unsigned long arg);
而在内核态,需要注意的是:
ioctl
至多支持3个参数,这样也和内核态的接口定义做到了匹配虽然老的 ioctl
接口已不再使用,但是还是要先从老的版本开始讲起。
如上文所述, ioctl
的接口定义如下:
int (*ioctl)(struct inode *inode, struct file *filep, unsigned int cmd, unsigned long arg);
上述接口中的参数:
struct inode *
和 struct file *
的定义与用法和Linux驱动开发笔记 > 5 8 open和release方法中的一致,内核负责将用户态的 ioctl
中指定的 fd
转化为这两个数据结构。cmd
则和用户态中传入的对应参数完全保持一致,而在用户态和内核态对该参数含义的约定也应当保持一致,通常用对应的头文件来实现。而内核态对 cmd
参数的处理通常可以使用 switch
方法。至于 cmd
参数的命令编号原则可见章节Linux驱动开发笔记 > 8 1 3 cmd命令编号原则。而返回值也应当从 <linux/errno.h>
中选值返回,常用的值如下:
-ENOTTY
:POSIX标准规定的指定的命令非法的返回值注意:老的ioctl会在获得大内核锁(BKL)的条件下运行,所以应当尽快处理并返回。
unsigned long
数据大小不同的情况:arch | char | short | int | long | ptr | long long | u8 | u16 | u32 | u64 |
---|---|---|---|---|---|---|---|---|---|---|
i386 | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
alpha | 1 | 2 | 4 | 8 | 8 | 8 | 1 | 2 | 4 | 8 |
armv4l | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
ia64 | 1 | 2 | 4 | 8 | 8 | 8 | 1 | 2 | 4 | 8 |
m68k | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
mips | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
ppc | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
sparc | 1 | 2 | 4 | 4 | 4 | 8 | 1 | 2 | 4 | 8 |
sparc64 | 1 | 2 | 4 | 8 | 8 | 8 | 1 | 2 | 4 | 8 |
x86_64 | 1 | 2 | 4 | 8 | 8 | 8 | 1 | 2 | 4 | 8 |
unsigned long
就是4Byte的整数类型,此时再传递给内核态驱动程序就可能出现错误。此外,更大的问题在于老的ioctl方法是由内核的大内核锁(BKL)实现并发控制的,驱动在ioctl中较长的占用处理器则会导致无关的进程会被该ioctl产生较长时间的系统调用延迟()。为了解决此问题(The new way of ioctl()),Linux在2.6.11开始引入了 unlocked_ioctl
和 compat_ioctl
,并在Linux 2.6.36彻底删除了 ioctl
接口。
正如上文所述,在64位系统上运行32位的用户态程序会导致传输传递时字节大小不匹配的问题。因此无论在32位还是64位的系统上都需要分别实现这两个函数。其调用情况为:
用户和内核位数关系 | ioct实际调用接口 | 理应的内核实现 |
---|---|---|
32位用户态调用32位内核驱动 | int32_t (*unlocked_ioctl) (struct file *, unsigned int cmd, uint32_t arg); |
|
64位用户态调用32位内核驱动 | 不存在此种情况 | |
64位用户态调用64位内核驱动 | unlocked_ioctl |
int64_t (*unlocked_ioctl) (struct file *, unsigned int cmd, uint64_t arg); |
32位用户态调用64位内核驱动 | compat_ioctl |
int32_t (*compat_ioctl) (struct file *, unsigned int cmd, uint32_t arg); |
几个注意点:
unlocked_ioctl
只是位宽不一样。compat_ioctl
意味"兼容性的ioctl"unlocked_ioctl
意味"解锁的ioctl",此函数不会为内核施加大内核锁,且此时需要驱动程序管理自己的互斥锁。按照规则,ioctl的cmd并不能随意编号,一个基本原则是命令号应当在整个系统范围内唯一。该原则的设定主要有如下考虑:
-EINVAL
错误,从而导致错误未被有效暴露。Linux的cmd命令编号原则应参考 Documentation/userspace-api/ioctl/ioctl-number.rst
(翻译可见ioctl-number.rst)。在定义cmd编号时,应当在头文件中参考上述规则使用 _IO(type,nr)
、 _IOR(type,nr,size)
、 _IOWR(type,nr,size)
等宏进行定义。
上述参数中:
type
应当填写该设备所拥有的类型序号,可能多个设备共用一个 type
,并按照 nr
的范围划分,可见ioctl-number.rstnr
应当在该设备所拥有的 type
的 nr
子区间内顺序递增size
不应当填写具体的size值,直接填写类型即可使用示例如下(cifs_ioctl.h
):
#define CIFS_IOCTL_MAGIC 0xCF
#define CIFS_IOC_COPYCHUNK_FILE _IOW(CIFS_IOCTL_MAGIC, 3, int)
#define CIFS_IOC_SET_INTEGRITY _IO(CIFS_IOCTL_MAGIC, 4)
#define CIFS_IOC_GET_MNT_INFO _IOR(CIFS_IOCTL_MAGIC, 5, struct smb_mnt_fs_info)
#define CIFS_ENUMERATE_SNAPSHOTS _IOR(CIFS_IOCTL_MAGIC, 6, struct smb_snapshot_array)
#define CIFS_QUERY_INFO _IOWR(CIFS_IOCTL_MAGIC, 7, struct smb_query_info)
#define CIFS_DUMP_KEY _IOWR(CIFS_IOCTL_MAGIC, 8, struct smb3_key_debug_info)
#define CIFS_IOC_NOTIFY _IOW(CIFS_IOCTL_MAGIC, 9, struct smb3_notify)
#define CIFS_DUMP_FULL_KEY _IOWR(CIFS_IOCTL_MAGIC, 10, struct smb3_full_key_debug_info)
#define CIFS_IOC_NOTIFY_INFO _IOWR(CIFS_IOCTL_MAGIC, 11, struct smb3_notify_info)
#define CIFS_IOC_GET_TCON_INFO _IOR(CIFS_IOCTL_MAGIC, 12, struct smb_mnt_tcon_info)
#define CIFS_IOC_SHUTDOWN _IOR('X', 125, __u32)
而对于上述的"编码宏",Linux也提供了对应的"解码宏":
_IOC_DIR(nr)
_IOC_TYPE(nr)
_IOC_NR(nr)
_IOC_SIZE(nr)
除了自己在ioctl的接受函数里预定义的命令以外,Linux还内置了一些通用的预定义命令。这些命令会被Linux系统截断,并不会传递到内核驱动中。且应当注意自己的cmd命令编号不应当与系统的预定义命令冲突或重复。
预定义命令主要可以分为如下三组:
type
都是 T
。这些命令主要有:FIOCLEX
:设备执行时关闭标志,File ioctl close on exec。设置了这个标志后,当进程退出(即执行 exec()
系统调用)时,自动关闭该文件。FIONCLEX
:清除设备执行时关闭标志,主要用于撤销上述命令。FIOASYNC
:使能或关闭socket的异步模式。FIOQSIZE
:该命令在操作普通文件时会返回文件或目录的大小,但是用于操作设备时会返回 -ENOTTY
错误。FIONBIO
:允许或禁止一个文件(通常是Socket)的非阻塞模式。不过修改该标志位的方法通常是用 fnctl
使用 F_SETFL
命令完成。在ioctl传参时,应当注意传递的是实际参数还是指针。传递普通参数时正常实现即可,当传递指针时应当注意该用户空间的指针是否合法,其方法主要分为如下几类:
copy_from_user
或 copy_to_user
即可。int access_ok(int type, const void *addr, unsigned long size);
其中, type
可以选择 VERIFY_READ
或 VERIFY_WRITE
,参数 addr
指用户空间地址,参数 size
指要传输的字节数。注意, VERIFY_WRITE
是 VERIFY_READ
的超集。 access_ok
将返回0或1。
随后即可使用 <asm/uaccess.h>
中的无需检查的宏函数进行传输(预处理时展开):
__put_user(x, ptr)
: x
为内核态数据、 ptr
为用户指针__get_user(x, ptr)
: x
为内核态数据、 ptr
为用户指针access_ok
只负责检验该内存地址是否位于进程对应有权限访问的区域内access_ok
后可以使用Linux为常用的1、2、4、8字节类型提供的,尽管大部分代码并不需要使用 access_ok
进行访问。access_ok
)的宏函数(预处理时展开):
put_user(x, ptr)
: x
为内核态数据、 ptr
为用户指针get_user(x, ptr)
: x
为内核态数据、 ptr
为用户指针注意:2和3仅可用于sizeof为1、2、4、8字节大小的参数传递,其他类型无法使用。
宏函数内置的sizeof是对用户空间的指针所指对象大小进行计算(即 sizeof(*(ptr))
)
在驱动程序中,对设备的操作也应当分为不同的权限等级,例如读取硬盘、写入硬盘以及格式化硬盘本就不应当是同一组权限。而在Linux中也内置了一些权限,例如在 <linux/capability.h>
中预先定义了如下一些可能常用的权限:
CAP_DAC_OVERRIDE
:CAP_NET_ADMIN
:执行网络管理任务的权限,例如配置网络接口CAP_SYS_MODULE
:载入或者卸载内核模块的权限CAP_SYS_RAWIO
:执行裸IO的权限,例如直接使用I2C或者USB通信CAP_SYS_ADMIN
:CAP_SYS_TTY_CONFIG
:执行tty配置任务的权限<sys/sched.h>
中的如下API检查是否有对应权限:int capable(int capability);
对于不满足权限的请求可以返回 -EPREM
。
并非所有的设备都有必要使用ioctl这种控制方式,例如想要实现一个PWM控制的舵机驱动程序,大可不必使用ioctl实现这类控制,直接使用字符控制即可。例如若实现:
echo "90" > /dev/servo0
则比使用 ioctl
外加特定的头文件定义 ioctl
的 cmd
要方便且可移植。类似的例子还有使用 AT
指令配置调制解调器等设备。
当设备无法立即完成某请求时,常用的做法之一是实现一个阻塞型IO。
阻塞和休眠通常都是由等待一个事件或资源引起的,因此其等待的时间通常具有不可预测的特性。在进入休眠时,务必注意下列注意事项:
在Linux中的头文件 <linux/wait.h>
中提供了阻塞与休眠相关的API,其较为重要的使用方法如下:
// 静态定义
DECLARE_WAIT_QUEUE_HEAD(name);
// 动态定义
wait_queue_head_t q_head;
init_waitqueue_head(&q_head);
// 休眠API(注意都是宏函数)
/**
* @brief: 休眠直到condition为true
* @note: 每次wq_head被唤醒都会被执行并判定condition
*/
wait_event(wq_head, condition)
/**
* @brief: 休眠直到condition为true,支持系统被挂起期间冻结
*/
wait_event_freezable(wq_head, condition)
/**
* @brief: 休眠直到condition为true,或者超时(以jiffy表示)时中断休眠。
* @return:
* - 当超时后condition为true返回0
* - 当超时后condition为false返回1
* - 超时前condition为true则返回剩余jiffy数(大于等于1)
*/
wait_event_timeout(wq_head, condition, timeout)
/**
* @brief: 休眠直到condition为true。在执行等待前和后,cmd1和cmd2会分别被执行
* @return:
* - 当其被信号打断时返回值为非零
*/
wait_event_cmd(wq_head, condition, cmd1, cmd2)
/**
* @brief: 休眠直到condition为true,或者被信号中断休眠。当其被信号打断时返回值为非零。
* @return:
* - 当其被信号打断时返回值为非零
*/
wait_event_interruptible(wq_head, condition)
注:
condition
是一个不带副作用的 bool
表达式,该表达式可能会被多次求值。// wake_up会唤醒所有等在queue上的线程
wake_up(x)
// wake_up_interruptible只会唤醒可中断休眠的线程
wake_up_interruptible(x)
除了阻塞IO之外,Linux支持用户程序以非阻塞IO打开/操作设备。
选择是否为非阻塞IO需要且仅能在 f_open
阶段进行设置。
// 只读、非阻塞
fd = open("path",O_RDONLY | O_NONBLOCK)
随后在内核模块中的