BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

编写Linux内核模块——第二部分:字符设备

| 作者 金灵杰 关注 5 他的粉丝 发布于 2015年11月2日. 估计阅读时间: 93 分钟 | Google、Facebook、Pinterest、阿里、腾讯 等顶尖技术团队的上百个可供参考的架构实例!

【编者的话】字符设备作为Linux设备中的一大类,它提供对按字节访问设备的抽象。用户空间应用程序可以通过标准文件操作来访问设备。本文来自Derek Molloy的博客,介绍了如何字符设备驱动的概念,以及如何编写和测试一个字符设备驱动。

前言

本系列文章中,主要描述如何为嵌入式Linux设备编写可加载内核模块(LKM)。这是该系列的第二篇文章,在阅读本文之前,请先阅读《编写Linux内核模块——第一部分:前言》,它讲解了如何构建、加载和卸载可加载内核模块。这些描述不在本文中不再赘述。

字符设备驱动

字符设备通常和用户应用程序双向传输数据,它们的行为类似管道和串行接口,即时从字符流中读写字节数据。它们为许多典型的驱动提供了框架,比如那些需要和串行设备、视频捕捉设备和音频设备交互的驱动。字符设备的一种替代是块设备。块设备的行为类似普通文件,它可以允许程序查看缓存数据中的缓冲队列,或是通过读、写、查找等函数进行操作。两种设备类型都可以通过关联到文件系统树上的设备文件进行访问。例如,本文中的代码构建后,可以通过以下方式在Linux系统中创建/dev/ebbchar设备文件:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ lsmod
Module                  Size  Used by
ebbchar                 2754  0
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ls -l /dev/ebb*
crw-rw-rwT 1 root root 240, 0 Apr 11 15:34 /dev/ebbchar

本文介绍了一个简单的字符设备,可用于用户空间应用程序和运行在内核空间的内核模块之间相互传递消息。在示例中,用C编写的用户空间应用程序发送字符串到内核模块。内核模块响应这条消息,并发回这条消息包含的字母数。然后,本文还将介绍为什么需解决示例中这种实现方式引发的同步问题,并且提供一个使用互斥锁的版本,解决这个同步问题。

在描述本文驱动的源代码前,需要先讨论一些概念,比如设备驱动的主设备号和次设备号,还有文件操作数据结构。

主设备号和次设备号

设备驱动有关联的主设备号和次设备号。例如,/dev/ram0和/dev/null关联了主设备号为1的驱动,而/dev/tty0和/dev/ttyS0关联了主设备号为4的驱动。主设备号用于内核在设备访问时能够识别正确的设备驱动。次设备号的角色和设备相关,它主要使用在驱动中。如果在/dev目录中执行列出文件操作,可以看见每个设备的主/次设备号。比如:

molloyd@beaglebone:/dev$ ls -l
crw-rw---T 1 root i2c      89,   0 Jan  1  2000 i2c-0
brw-rw---T 1 root disk      1,   0 Mar  1 20:46 ram0
brw-rw---T 1 root floppy  179,   0 Mar  1 20:46 mmcblk0
crw-rw-rw- 1 root root      1,   3 Mar  1 20:46 null
crw------- 1 root root      4,   0 Mar  1 20:46 tty0
crw-rw---T 1 root dialout   4,  64 Mar  1 20:46 ttyS0
…

输出中的第一列为“c”,表示这是一个字符设备,而为“b”表示这是一个块设备。每个设备都有授权访问的用户和组。BeagleBone上的普通用户帐号是这些组中的成员,因此有权限访问i2c-0和ttyS0等设备。

molloyd@beaglebone:/dev$ groups
molloyd dialout cdrom floppy audio video plugdev users i2c spi

本文开发的设备将在/dev目录中以设备文件的形式出现(/dev/ebbchar)。

也可以手工创建一个块设备或者字符设备文件项,然后将它关联到指定设备上(即sudo mknod /dev/test c 92 1),但是这个方式容易出现问题。其中一个问题就是,必须确保使用的设备号(即示例中的92)没有被使用。在BeagleBone可以通过/usr/src/linux-headers-3.8.13-bone70/include/uapi/linux/major.h文件检查所有系统设备的主设备号。然后,使用此方法找到“唯一”的主设备号是不可移植的,因为在其他设备或者其他Linux单板机(发行版)中,主设备号可能冲突。本文的代码自动确认并使用一个合适的主设备号。

文件操作数据结构

file_operations数据结构定义在/linux/fs.h头文件中,它保存驱动中的函数指针,允许开发者定义文件操作行为。例如,列表1是从/linux/fs.h头文件中摘录的数据结构的片段。本文中的驱动提供了文件操作中的读、写、打开、释放这几个系统调用的实现。如果数据结构中的某些字段不需要实现,只需要简单的将它指向NULL,这样这些字段将不可访问。列表1展示的操作函数的数量看上去是比较吓人的。然而,构建ebbchar内核模块,只需要提供其中四个字段的实现即可。因此,列表1提供了在驱动框架中可以扩展使用的额外函数接口。

 // 注意:__userNote指向用户空间地址。
struct file_operations {
   struct module *owner;                             // 指向拥有该结构的内核模块
   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 (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);  // 异步读
   ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t); // 异步写
   ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);            // 可能异步读
   ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);           // 可能异步写
   int (*iterate) (struct file *, struct dir_context *);                // 当虚拟文件系统(VFS)需要读取文件夹内容的时候调用
   unsigned int (*poll) (struct file *, struct poll_table_struct *);    // 读或写会阻塞?
   long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); // 由ioctl系统调用使用
   long (*compat_ioctl) (struct file *, unsigned int, unsigned long);   // 由ioctl系统调用使用
   int (*mmap) (struct file *, struct vm_area_struct *);                // 由mmap系统调用使用
   int (*mremap)(struct file *, struct vm_area_struct *);               // 由remap系统调用使用
   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);  // 通知设备修改FASYNC标志
   int (*aio_fsync) (struct kiocb *, int datasync);         // 同步通知设备修改FASYNC标志
   int (*fasync) (int, struct file *, int);                 // 异步通知设备修改FASYNC标志
   int (*lock) (struct file *, int, struct file_lock *);    // 用于文件锁的实现
   …
};

列表1:/linux/fs.h中的文件操作数据结构(片段)

要了解更多信息,Kernel.org虚拟文件系统为文件操作数据结构提供了优秀的文档。

本次讨论的源码

本次讨论的所有代码都在为《Exploring BeagleBone》准备的GitHub仓库上。代码可以在ExploringBB GitHub仓库内核工程目录中公开查看,或者也可以将代码复制到BeagleBone(或者其他Linux设备):

molloyd@beaglebone:~$ sudo apt-get install git
molloyd@beaglebone:~$ git clone https://github.com/derekmolloy/exploringBB.git

代码中/extras/kernel/ebbchar目录是本文最重要的资源。为这些示例代码自动生成的Doxygen文档有HTML格式PDF格式

设备驱动源码

ebbchar设备驱动源码展示在列表2中。和本系列第一篇文章类似,里面有一个init()函数和exit()函数。除此之外,字符设备还需要一些额外的文件操作函数:

  • dev_open():用空空间每次打开设备的时候调用。
  • dev_read():从设备向用户空间发送数据的时候调动。
  • dev_write():从用户空间向设备发送数据的时候调用。
  • dev_release():用户空间关闭设备的时候调用。

设备驱动有一个类名和设备名。在列表2中,ebb(探索BeagleBone,Exploring BeagleBone)作为类名,ebbchar作为设备名。这使得设备最终显示在文件系统的/sys/class/ebb/ebbchar中。

/**
 * @file   ebbchar.c
 * @author Derek Molloy
 * @date   2015年4月7日
 * @version 0.1
 * @brief   一个介绍性的字符设备驱动,作为Linux可加载内核驱动系列文章第二篇的示例。
 * 该模块映射到/dev/ebbchar文件中,并且提供一个运行于Linux用户空间的C程序,
 * 来和此内核模块进行交互。
 * @see http://www.derekmolloy.ie/ 查看完整描述和补充描述。
 */

#include <linux/init.h>           // 用于标记函数的宏,如_init、__exit
#include <linux/module.h>         // 将内核模块加载到内核中的核心头文件
#include <linux/device.h>         // 支持内核驱动模型的头文件
#include <linux/kernel.h>         // 包含内核中的类型、宏和函数
#include <linux/fs.h>             // 支持Linux文件系统的头文件
#include <asm/uaccess.h>          // 复制到用户用户空间函数需要的头文件
#define  DEVICE_NAME "ebbchar"    ///< 使用此值,设备将会展示在/dev/ebbchar
#define  CLASS_NAME  "ebb"        ///< 设备类名,这是一个字符设备驱动

MODULE_LICENSE("GPL");            ///< 许可类型,这回影响到可用功能
MODULE_AUTHOR("Derek Molloy");    ///< 作者,当使用modinfo命令时可见
MODULE_DESCRIPTION("A simple Linux char driver for the BBB");  ///< 描述,参见modinfo命令
MODULE_VERSION("0.1");            ///< 告知用户的版本号

static int    majorNumber;                  ///< 保存主设备号,这里自动确定
static char   message[256] = {0};           ///< 用于保存从用户空间传输过来字符串的内存
static short  size_of_message;              ///< 用于记录保存的字符串长度
static int    numberOpens = 0;              ///< 用于保存设备打开次数的计数器
static struct class*  ebbcharClass  = NULL; ///< 设备驱动类结构体指针
static struct device* ebbcharDevice = NULL; ///< 设备驱动设备结构体指针

// 字符设备操作的函数原型,必须在结构体定义前定义
static int     dev_open(struct inode *, struct file *);
static int     dev_release(struct inode *, struct file *);
static ssize_t dev_read(struct file *, char *, size_t, loff_t *);
static ssize_t dev_write(struct file *, const char *, size_t, loff_t *);

/** @brief 设备在内核中被表示为文件结构。 /linux/fs.h中定义的file_operations结构体,
 * 它使用C99语法的结构体,列举了文件操作关联的回调函数。
 * 字符设备通常需要实现open、read、write和release函数。
 */
static struct file_operations fops =
{
   .open = dev_open,
   .read = dev_read,
   .write = dev_write,
   .release = dev_release,
};

/** @brief 可加载内核模块初始化函数
 *  static关键字限制该函数的可见性在该C文件之内。 The __init
 *  __init宏对于内置驱动(非可加载内核模块)来说,只在初始化时调用,在此之后,该函数将被废弃,内存将被回收。
 *  @return 如果成功返回0
 */
static int __init ebbchar_init(void){
   printk(KERN_INFO "EBBChar: Initializing the EBBChar LKM\n");

   // 尝试为这个设备动态生成一个主设备号,虽然麻烦一点,但这是值得的
   majorNumber = register_chrdev(0, DEVICE_NAME, &fops);
   if (majorNumber<0){
      printk(KERN_ALERT "EBBChar failed to register a major number\n");
      return majorNumber;
   }
   printk(KERN_INFO "EBBChar: registered correctly with major number %d\n", majorNumber);

   // 注册设备类
   ebbcharClass = class_create(THIS_MODULE, CLASS_NAME);
   if (IS_ERR(ebbcharClass)){                // 如果有错误,清理环境
      unregister_chrdev(majorNumber, DEVICE_NAME);
      printk(KERN_ALERT "Failed to register device class\n");
      return PTR_ERR(ebbcharClass);          // 对于指针类型返回错误消息正确的方式
   }
   printk(KERN_INFO "EBBChar: device class registered correctly\n");

   // 注册设备驱动
   ebbcharDevice = device_create(ebbcharClass, NULL, MKDEV(majorNumber, 0), NULL, DEVICE_NAME);
   if (IS_ERR(ebbcharDevice)){               // 如果有错误,清理环境
      class_destroy(ebbcharClass);           // 重复的代码,可选方式是使用goto语句
      unregister_chrdev(majorNumber, DEVICE_NAME);
      printk(KERN_ALERT "Failed to create the device\n");
      return PTR_ERR(ebbcharDevice);
   }
   printk(KERN_INFO "EBBChar: device class created correctly\n"); // 搞定,设备已经初始化
   return 0;
}

/** @brief 可加载内核模块清理函数
 *  和初始化函数类似,该函数是静态的。__exit宏标识如果这个代码是使用在内置驱动(非可加载内核模块)中,该函数不需要。
 */
static void __exit ebbchar_exit(void){
   device_destroy(ebbcharClass, MKDEV(majorNumber, 0));     // 移除设备
   class_unregister(ebbcharClass);                          // 注销设备类
   class_destroy(ebbcharClass);                             // 移除设备类
   unregister_chrdev(majorNumber, DEVICE_NAME);             // 注销主设备号
   printk(KERN_INFO "EBBChar: Goodbye from the LKM!\n");
}

/** @brief 每次设备被代开的时候调用的设备打开函数
 *  在本例中,该函数只是简单的累加numberOpens计数器。
 *  @param inodep 指向inode对象的指针(定义在linux/fs.h头文件中)
 *  @param filep 指向文件对象指针(定义在linux/fs.h头文件中)
 */
static int dev_open(struct inode *inodep, struct file *filep){
   numberOpens++;
   printk(KERN_INFO "EBBChar: Device has been opened %d time(s)\n", numberOpens);
   return 0;
}

/** @brief 该函数在设备从用户空间读取的时候被调用,即数据从设备向用户空间传输。
 *  在本例中,通过copy_to_user()函数将缓冲区中的字符串发送给用户,并且捕获任何异常。
 *  @param filep 指向文件对象的指针(定义在linux/fs.h头文件中)
 *  @param buffer 指向本函数写入数据的缓冲区指针
 *  @param len 缓冲区长度
 *  @param offset 此次读取在内核缓冲区中的偏移量
 */
static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset){
   int error_count = 0;
   // copy_to_user函数参数格式为( * to, *from, size),如果成功返回0
   error_count = copy_to_user(buffer, message, size_of_message);

   if (error_count==0){            // 如果为true,表示调用成功
      printk(KERN_INFO "EBBChar: Sent %d characters to the user\n", size_of_message);
      return (size_of_message=0);  // 清楚当前位置标记,并且返回0
   }
   else {
      printk(KERN_INFO "EBBChar: Failed to send %d characters to the user\n", error_count);
      return -EFAULT;              // 失败,返回无效地址消息(即-14)
   }
}

/** @brief 该函数在设备想用户空间写入的时候调用,即从数据从用户发往设备。
 *  在此内核模块中,数据通过sprintf()函数复制到message[]数组中,同时字符串长度被保存到size_of_message变量中。
 *  @param filep 指向文件对象的指针
 *  @param buffer 包含待写入设备字符串的缓冲区
 *  @param len 传递到const char类型缓冲区的数据长度
 *  @param offset 文件设备当前偏移量
 */
static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset){
   sprintf(message, "%s(%d letters)", buffer, len);   // 通过长度追加当前接受到的字符串
   size_of_message = strlen(message);                 // 保存当前保存的信息的长度
   printk(KERN_INFO "EBBChar: Received %d characters from the user\n", len);
   return len;
}

/** @brief 当设备被用户空间程序关闭/释放时调用的函数。
 *  @param inodep 指向inode对象的指针(定义在linux/fs.h头文件中)
 *  @param filep 指向文件对象的指针(定义在linux/fs.h头文件中)
 */
static int dev_release(struct inode *inodep, struct file *filep){
   printk(KERN_INFO "EBBChar: Device successfully closed\n");
   return 0;
}

/** @brief 内核模块必须使用linux/init.h头文件中提供的module_init()、module_exit()宏,
 *  在插入和清理的时候标识对应的函数(如上所列)
 */
module_init(ebbchar_init);
module_exit(ebbchar_exit);

列表2:ebbchar内核模块(/extras/kernel/ebbchar/ebbchar.c

除了列表2中的注释,这里还有一些补充:

  • 代码中固定了消息长度为256个字符,在下文中会改造成动态申请内存。
  • 代码非多进程安全,下文中也会提到。
  • ebbchar_init()函数比上一篇文章中的要长很多。因为它现在自动申请设备的主设备号,注册设备类并且注册设备驱动。重要的是,如果任何地方出错,代码会小心的终止已经成功的操作。为了达到这点,中间有重复的代码(这是我非常不喜欢的),但是替代方案是使用goto语句,这更是不可接受的(虽然稍微整洁一些)。
  • PTR_ERR()是一个定义在linux/err.h头文件中的函数,用于从指针中抽取出错误码。
  • sprintf()和strlen()函数可以通过包含linux/kernel.h头文件或者直接通过linux/string.h使用。string.h中的函数是和平台相关的。

下一步是将这些代码构建成内核模块。

构建和测试可加载内核模块

构建可加载内核模块需要Makefile文件,在列表3中提供。这个Makefile文件和本系列的第一篇文章中的Makefile文件类似,例外是它同时构建了一个可以和可加载内核模块进行交互的用户空间C程序。

obj-m+=ebbchar.o

all:
 make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) modules
 $(CC) testebbchar.c -o test
clean:
 make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) clean

列表3:构建可加载内核模块和用户空间程序的Makefile文件(/extras/kernel/ebbchar/Makefile

列表4是一个简单的程序,它向用户请求一个字符串,写入到/dev/ebbchar设备。在随后的按键(回车)之后,它从设备读取响应,并且显示在终端窗口中。

/**
 * @file   testebbchar.c
 * @author Derek Molloy
 * @date   2015年4月7日
 * @version 0.1
 * @brief  Linux用户空间程序,用于和ebbchar.c内核模块进行交互。
 * 它传递一个字符串到内核模块,并且从内核模块读取响应。在此示例中,必须使用/dev/ebbchar设备。
 * @see http://www.derekmolloy.ie/ 查看详细描述和后续描述。
*/
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
#include<fcntl.h>
#include<string.h>

#define BUFFER_LENGTH 256               ///< 缓冲区长度(简陋但是可以工作)
static char receive[BUFFER_LENGTH];     ///< 可加载内核模块接收缓存

int main(){
   int ret, fd;
   char stringToSend[BUFFER_LENGTH];
   printf("Starting device test code example...\n");
   fd = open("/dev/ebbchar", O_RDWR);             // 使用读写权限打开设备
   if (fd < 0){
      perror("Failed to open the device...");
      return errno;
   }
   printf("Type in a short string to send to the kernel module:\n");
   scanf("%[^\n]%*c", stringToSend);                // 读取字符串(包含空格)
   printf("Writing message to the device [%s].\n", stringToSend);
   ret = write(fd, stringToSend, strlen(stringToSend)); // 将字符串发送给内核模块
   if (ret < 0){
      perror("Failed to write the message to the device.");
      return errno;
   }

   printf("Press ENTER to read back from the device...\n");
   getchar();

   printf("Reading from the device...\n");
   ret = read(fd, receive, BUFFER_LENGTH);        // 从内核模块读取响应
   if (ret < 0){
      perror("Failed to read the message from the device.");
      return errno;
   }
   printf("The received message is: [%s]\n", receive);
   printf("End of the program\n");
   return 0;
}

列表4:测试可加载内核模块使用的用户空间程序(/extras/kernel/ebbchar/testebbchar.c

列表4中,除了注释中提到的,还有一些补充的点:

  • %[^\n]%*c使用了scanf函数的说明符,其中%[]中的^字符表示读取到第一个\n字符时停止。此外,%*c忽略尾随字符,确保后续的getchar()函数可以正常工作。从本质上说,这里的scanf()代码是要读取一个句子。如果scanf()使用普通的%s说明符,那么字符串将会在第一个空格字符处停止。
  • getchar()函数允许程序在当时中断,直到回车键按下。这对于检查当前代码结构是否有问题是有必要的。
  • 程序然后读取可加载内核模块的响应,并显示在终端窗口中。

如果所有的进展都很顺利,构建内核模块的流程应该比较简单,但前提是已经按照第一篇文章安装了Linux头文件。构建步骤如下:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ make
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ls -l *.ko
-rw-r--r-- 1 molloyd molloyd 7075 Apr  8 19:04 ebbchar.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ls -l test 
-rwxr-xr-x 1 molloyd molloyd 6342 Apr  8 19:23 test
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo insmod ebbchar.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ lsmod
Module                  Size  Used by
ebbchar                 2521  0 

现在,这个设备会展示在/dev目录中,包含以下属性:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ cd /dev
molloyd@beaglebone:/dev$ ls -l ebb*
crw------- 1 root root 240, 0 Apr  8 19:28 ebbchar

从输出可以看到,这个设备文件的主设备号是240,它通过列表2中自动分配的代码生成的。

然后,就可以使用testebbchar程序(列表4)来测试可加载内核模块是否正常工作。目前,这个测试程序必须使用root权限执行,这个问题会马上解决。

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo insmod ebbchar.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is a test of the ebbchar LKM
Writing message to the device [This is a test of the ebbchar LKM].
Press ENTER to read back from the device...
Reading from the device...
The received message is: [This is a test of the ebbchar LKM(33 letters)]
End of the program
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo rmmod ebbchar

printk()函数的输出可以用如下步骤通过内核日志查看:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo tail -f /var/log/kern.log
Apr 11 22:24:50 beaglebone kernel: [358664.365942] EBBChar: Initializing the EBBChar LKM
Apr 11 22:24:50 beaglebone kernel: [358664.365980] EBBChar: registered correctly with major number 240
Apr 11 22:24:50 beaglebone kernel: [358664.366061] EBBChar: device class registered correctly
Apr 11 22:24:50 beaglebone kernel: [358664.368383] EBBChar: device class created correctly
Apr 11 22:25:15 beaglebone kernel: [358689.812483] EBBChar: Device has been opened 1 time(s)
Apr 11 22:25:31 beaglebone kernel: [358705.451551] EBBChar: Received 33 characters from the user
Apr 11 22:25:32 beaglebone kernel: [358706.403818] EBBChar: Sent 45 characters to the user
Apr 11 22:25:32 beaglebone kernel: [358706.404207] EBBChar: Device successfully closed
Apr 11 22:25:44 beaglebone kernel: [358718.497000] EBBChar: Goodbye from the LKM!

这里会发现,向可加载内核模块发送了33个字符,但是返回了45个字符。这是因为多了12个字符“(33 letters)”来标记原始发送的字符串长度。这个用于测试的附加的字符,可以看出代码发送和接收的独特数据。

当前可加载内核模块中有两个重大的问题。第一个是可加载内核模块设备文件只能通过超级用户权限访问,另一个是当前的模块是非多进程安全的。

通过Udev规则修改设备的用户访问权限

在前面的示例中,应用程序和可加载内核模块设备交互的时候是通过sudo执行的。设置内核模块设备能够让特定的用户或者组访问,且仍然能够保护文件系统,是非常有用的。为了解决这个问题,可以通过使用Linux的高级特性:udev规则,它能够让用户定制udevd服务的行为。该服务为Linux系统上的设备提供了用户空间的控制方式。

例如,要给ebbchar设备用户访问权限,第一步是找到该设备在sysfs上的文件项。可以通过简单的find命令实现:

root@beaglebone:/sys# find . -name "ebbchar"
./devices/virtual/ebb/ebbchar
./class/ebb/ebbchar
./module/ebbchar

然后需要找到编写规则需要的KERNEL和SUBSYSTEM的值。这可以通过udevadm命令实现:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ udevadm info -a -p /sys/class/ebb/ebbchar
Udevadm info starts with the device specified by the devpath and then walks up the chain of parent
devices. It prints for every device found, all possible attributes in the udev rules key format. A
rule to match, can be composed by the attributes of the device and the attributes from one single
parent device.
          looking at device '/devices/virtual/ebb/ebbchar':
             KERNEL=="ebbchar"
             SUBSYSTEM=="ebb"
             DRIVER==""

规则保存在/etc/udev/rules.d目录中。新的规则可以通过使用这些值创建一个文件,这个文件名字以优先级编号开头。使用类似99-ebbchar.rules的名字创建一个有最低优先级的规则,以防止它影响其他设备规则。这个规则内容如列表5所示,存放在/etc/udev/rules.d目录中:

molloyd@beaglebone:/etc/udev/rules.d$ ls
50-hidraw.rules  50-spi.rules  60-omap-tty.rules  70-persistent-net.rules 99-ebbchar.rules
molloyd@beaglebone:/etc/udev/rules.d$ more 99-ebbchar.rules
#Rules file for the ebbchar device driver
KERNEL=="ebbchar", SUBSYSTEM=="ebb", MODE="0666"
#Rules file for the ebbchar device driver
KERNEL=="ebbchar", SUBSYSTEM=="ebb", MODE="0666"

列表5:ebbchar设备驱动的Udev规则(/extras/kernel/ebbchar/99-ebbchar.rules

一旦该规则文件添加到系统中以后,可以通过以下方式测试:

 molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo insmod ebbchar.ko
 molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ls -l /dev/ebbchar
 crw-rw-rwT 1 root root 240, 0 Apr  9 00:44 /dev/ebbchar

从以上输出可以看到,用户和组现在已经有权限读写设备了。有趣的是,文件权限也设置了粘滞位(sticky bit)。粘滞位通常表示有写权限,但是没有删除文件的权限。因此,/tmp文件夹任何用户都可以创建文件,但是用户不可以删除其他用户的文件。粘滞位通过在权限列中最后一个字符是大写的T标识。通常来说,如果其他用户的执行权限(x)位设置了,这里将显示一个小写的t;而如果x位没有设置,这里显示的是一个大写的T。然而,还没有完全搞清楚为什么udev设置了粘滞位,这似乎是Debian发行版中不寻常的udev规则。此时,测试应用程序可以在不需要超级用户权限的情况下执行了。

strace命令

strace命令是一个非常有用的调试工具,它可以执行一个程序,拦截和记录该程序执行的系统调用。系统调用名字、传递的参数和返回的结果都是可见的,因此它是解决运行时问题非常有价值的工具。重要的是,strace在查看这些输出的时候,不需要提供可执行程序的源代码。例如,可以在应用空间应用程序中使用strace工具来查看用户空间程序和内核模块之间的交互。以下是test应用程序strace的结果:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo apt-get install strace
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ strace -v
usage: strace [-dffhiqrtttTvVxx] [-a column] [-e expr] … [-o file]
              [-p pid] … [-s strsize] [-u username] [-E var=val] …
              [command [arg …]]
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo strace ./test
execve("./test", ["./test"], [/* 15 vars */]) = 0
…
write(1, "Starting device test code exampl"..., 37Starting de…) = 37
open("/dev/ebbchar", O_RDWR) = 3
write(1, "Writing message to the device [T"..., 60Writing message …) = 60
write(3, "Testing the EBBChar device", 26) = 26
write(1, "Reading from the device...\n", 27Reading from the device…) = 27
read(3, "", 100) = 0
write(1, "The received message is: [Testin"..., 66The received …) = 66
write(1, "End of the program\n", 19End of the program) = 19
exit_group(0) = ?

系统调用的输出给了我们直观的视角来观察用户空间程序test/dev/ebbchar设备驱动交互的过程。

可加载内核模块的同步问题

列表2中描述的可加载内核模块有严重的问题。在本系列第一篇文章中,已经指出可加载内核模块不是顺序执行的,它会被打断。这些现象对本文所写的代码都有重要的影响,以下步骤来示范:

步骤1:在第一个终端窗口中,执行test应用程序,但是不要让应用程序结束(在提示符出现后不要按下回车键):

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is the message from the first terminal window
Writing message to the device [This is the message from the first terminal window].
Press ENTER to read back from the device...

步骤2:打开第二个终端窗口,执行同样的test应用程序,模拟Linux设备上的第二个进程。例如:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is the message from the second terminal window
Writing message to the device [This is the message from the second terminal window].
Press ENTER to read back from the device...

步骤3:现在返回第一个终端窗口,并且按下回车键,让应用程序运行完,此时有如下输出:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is the message from the first terminal window
Writing message to the device [This is the message from the first terminal window].
Press ENTER to read back from the device...
Reading from the device...
The received message is: [This is the message from the second terminal window(51 letters)]
End of the program
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ 

从上面输出可以看到,接受到的消息,实际上是步骤2中的test应用程序发出的,它运行在第二个终端窗口中(不是期望的第一个)。这是因为步骤2发出的消息覆盖了可加载内核模块中保存的步骤1的字符串消息。

步骤4:回到第二个终端,按下回车让应用程序运行结束,结果输出为:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is the message from the second terminal window
Writing message to the device [This is the message from the second terminal window].
Press ENTER to read back from the device...
Reading from the device...
The received message is: []
End of the program
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$

此时没有接收到字符串。这是因为此时可加载内核模块没有保存任何消息。它已经将保存的消息传递给第一个终端窗口的test应用程序,并且将缓冲区索引重置为0.

增加互斥锁

Linux内核模块提供了信号量的完整实现:一个包含用于控制多进程访问共享资源的数据类型(semaphore结构体)。在内核中使用信号量最简单的方式是使用互斥量,因为它提供了全套的帮助函数和宏。

防止上述问题最简单的方式是阻止两个进程同时访问/dev/ebbchar设备。互斥量是一个可以在进程使用共享资源之前设置的锁。这个锁在进程使用完共享资源之后释放。当锁被设置后,其他进程无法访问被锁住的代码区域。一旦互斥锁被加锁的进程释放,共享区域的代码又可以重新被其他进程访问,这样又会对资源加锁。

为了实现互斥锁,对于代码只需要做很小的改动。这些改动的概要内容在列表6中展示:

#include <linux/mutex.h>             /// 互斥锁功能需要的头文件
…
static DEFINE_MUTEX(ebbchar_mutex);  /// 定义互斥变量的宏,可见范围为本文件
                                     /// 结果是创建一个值为1(未上锁)的信号量变量ebbchar_mutex
                                     /// DEFINE_MUTEX_LOCKED()宏的结果是创建一个值为0(上锁)的变量
…
static int __init ebbchar_init(void){
   …
   mutex_init(&ebbchar_mutex);       /// 在运行时动态初始化互斥锁
}

static int dev_open(struct inode *inodep, struct file *filep){
   if(!mutex_trylock(&ebbchar_mutex)){    /// 尝试获取互斥量(即加锁)
                                          /// 如果成功返回1,如果有竞争返回0
      printk(KERN_ALERT "EBBChar: Device in use by another process");
      return -EBUSY;
   }
   …
}

static int dev_release(struct inode *inodep, struct file *filep){
   mutex_unlock(&ebbchar_mutex);          /// 释放互斥变量(即解锁)
   …
}

static void __exit ebbchar_exit(void){
   mutex_destroy(&ebbchar_mutex);        /// 销毁动态分配的互斥量
   …
}

列表6:ebbchar.c程序代码引入互斥锁之后的概要改动

完整示例代码在/extras/kernel/ebbcharmutex/目录中,最终的可加载内核模块源码文件是ebbcharmutex.c。如果在包含互斥锁代码版本中按照前面的步骤做同样的测试,将会观察到不同的行为。例如:

步骤1:使用第一个终端窗口,可以加载模块和执行test应用程序,这会产出如下的输出:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ sudo insmod ebbcharmutex.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ lsmod
Module                  Size  Used by
ebbcharmutex            2754  0
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ ls -l /dev/ebb*
crw-rw-rwT 1 root root 240, 0 Apr 12 15:26 /dev/ebbchar
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
Testing the ebbchar LKM that has mutex code
Writing message to the device [Testing the ebbchar LKM that has mutex code].
Press ENTER to read back from the device...

步骤2:使用第二个终端窗口,尝试执行test应用程序,创建第二个进程:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ ./test 
Starting device test code example...
Failed to open the device...: Device or resource busy
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ 

正如预期和所需要的那样,第二个进程访问设备失败。

步骤3:返回到第一个终端窗口,程序可以通过按回车键继续运行直到结束:

molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
Testing the ebbchar LKM that has mutex code
Writing message to the device [Testing the ebbchar LKM that has mutex code].
Press ENTER to read back from the device...
Reading from the device...
The received message is: [Testing the ebbchar LKM that has mutex code(43 letters)]
End of the program 

在这个时候,第二个终端窗口可以执行test程序,于是这个程序可以获取互斥锁,并正确运行。

总结

本文中提到了几个不同的问题。本文最主要的成果有:

  • 创建了我们自己的设备(/dev/ebbchar),可以向设备发送和读取信息。这是非常重要的,它提供了Linux用户空间和内核空间的桥梁。使得我们能够开发高级驱动,例如通信驱动,这些驱动能够让用户空间C程序能够控制设备。
  • 明白了为什么在内核模块编程中,同步问题需要需要特别重视的。
  • 能够使用udev规则修改设备加载时修改设备属性。

本系列中的下一篇文章会介绍如何和通用输入输出接口(GPIO)设备(如物理按钮和LED电路)交互,让开发者能够为嵌入式Linux应用程序开发定制的驱动,来和定制的硬件进行交互。敬请阅读《编写Linux内核模块——第三部分:和通用输入输出接口设备交互》。最后,列表7和列表8提供了一份参照表,它们提供了本文使用的标准错误状态和它们关联的错误码和描述。

#define EPERM        1  /* 操作不被允许 */
#define ENOENT       2  /* 文件或者目录不存在 */
#define ESRCH        3  /* 没有该进程 */
#define EINTR        4  /* 中断的系统调用 */
#define EIO          5  /* 输入输出错误 */
#define ENXIO        6  /* 没有该设备或地址 */
#define E2BIG        7  /* 参数列表太长 */
#define ENOEXEC      8  /* 执行文件格式错误 */
#define EBADF        9  /* 文件描述符错误 */
#define ECHILD      10  /* 子进程不存在 */
#define EAGAIN      11  /* 资源暂时不可用 */
#define ENOMEM      12  /* 内存不足 */
#define EACCES      13  /* 没有权限 */
#define EFAULT      14  /* 地址错误 */
#define ENOTBLK     15  /* 不是块设备 */
#define EBUSY       16  /* 设备或资源忙 */
#define EEXIST      17  /* 文件已存在 */
#define EXDEV       18  /* 夸设备链接 */
#define ENODEV      19  /* 设备不存在 */
#define ENOTDIR     20  /* 不是目录文件 */
#define EISDIR      21  /* 是目录文件 */
#define EINVAL      22  /* 参数无效 */
#define ENFILE      23  /* 文件表溢出 */
#define EMFILE      24  /* 打开文件太多 */
#define ENOTTY      25  /* 没有tty 终端 */
#define ETXTBSY     26  /* 文本文件忙 */
#define EFBIG       27  /* 文件过大 */
#define ENOSPC      28  /* 设备没有剩余空间 */
#define ESPIPE      29  /* 非法文件指针重定位 */
#define EROFS       30  /* 只读文件系统 */
#define EMLINK      31  /* 链接过多 */
#define EPIPE       32  /* 断开的管道 */
#define EDOM        33  /* 数学函数参数超出范围 */
#define ERANGE      34  /* 函数结果超出范围 */

列表7:可加载内核模块开发使用的错误状态(/usr/include/asm-generic/errno-base.h)

#ifndef _ASM_GENERIC_ERRNO_H
#define _ASM_GENERIC_ERRNO_H

#include <asm-generic/errno-base.h>

#define EDEADLK         35  /* 资源可能死锁 */
#define ENAMETOOLONG    36  /* 文件名太长 */
#define ENOLCK          37  /* 没有锁可用 */
#define ENOSYS          38  /* 函数未实现 */
#define ENOTEMPTY       39  /* 目录非空 */
#define ELOOP           40  /* 遇到太多符号链接 */
#define EWOULDBLOCK     EAGAIN  /* 操作可能被阻塞 */
#define ENOMSG          42  /* 没有符合需求类型的消息 */
#define EIDRM           43  /* 标识符已删除 */
#define ECHRNG          44  /* 通道编号超出范围 */
#define EL2NSYNC        45  /* 2级不同步 */
#define EL3HLT          46  /* 3级停止 */
#define EL3RST          47  /* 3级重置 */
#define ELNRNG          48  /* 链接编号超出范围 */
#define EUNATCH         49  /* 协议驱动程序没有连接 */
#define ENOCSI          50  /* 没有可用的CSI结构 */
#define EL2HLT          51  /* 2级停止 */
#define EBADE           52  /* 无效交换 */
#define EBADR           53  /* 无效请求描述 */
#define EXFULL          54  /* 交换完全 */
#define ENOANO          55  /* 无anode */
#define EBADRQC         56  /* 无效请求码 */
#define EBADSLT         57  /* 无效插槽 */

#define EDEADLOCK       EDEADLK

#define EBFONT          59  /* 错误的字体文件格式 */
#define ENOSTR          60  /* 设备不是流 */
#define ENODATA         61  /* 无数据 */
#define ETIME           62  /* 计时器到期 */
#define ENOSR           63  /* 流资源不足 */
#define ENONET          64  /* 机器不在网络上 */
#define ENOPKG          65  /* 包未安装 */
#define EREMOTE         66  /* 对象是远程的 */
#define ENOLINK         67  /* 链接正在服务中 */
#define EADV            68  /* 广告错误 */
#define ESRMNT          69  /* 服务器远程挂载错误 */
#define ECOMM           70  /* 发送过程中通讯错误 */
#define EPROTO          71  /* 协议错误 */
#define EMULTIHOP       72  /* 试图多次反射 */
#define EDOTDOT         73  /* 远程文件共享(RFS)特定错误 */
#define EBADMSG         74  /* 不是数据消息 */
#define EOVERFLOW       75  /* 定义数据类型值太大 */
#define ENOTUNIQ        76  /* 网络名称不唯一 */
#define EBADFD          77  /* 文件描述符状态不良 */
#define EREMCHG         78  /* 改变远程地址 */
#define ELIBACC         79  /* 无法访问所需共享库 */
#define ELIBBAD         80  /* 访问损坏共享库 */
#define ELIBSCN         81  /* .out中的.lib部分损坏  */
#define ELIBMAX         82  /* 试图链接共享库太多 */
#define ELIBEXEC        83  /* 无法直接执行共享库 */
#define EILSEQ          84  /* 非法字节序列 */
#define ERESTART        85  /* 中断系统调用会重启 */
#define ESTRPIPE        86  /* 流管道错误 */
#define EUSERS          87  /* 用户太多 */
#define ENOTSOCK        88  /* 在非套接字上执行套接字操作 */
#define EDESTADDRREQ    89  /* 需要目的地址 */
#define EMSGSIZE        90  /* 消息太长 */
#define EPROTOTYPE      91  /* 套接字协议类型错误 */
#define ENOPROTOOPT     92  /* 协议不可用 */
#define EPROTONOSUPPORT 93  /* 协议不支持 */
#define ESOCKTNOSUPPORT 94  /* 套接字类型不支持 */
#define EOPNOTSUPP      95  /* 不支持操作传输端点 */
#define EPFNOSUPPORT    96  /* 协议系列不支持 */
#define EAFNOSUPPORT    97  /* 协议不支持地址系列 */
#define EADDRINUSE      98  /* 地址已在使用 */
#define EADDRNOTAVAIL   99  /* 无法分配请求地址 */
#define ENETDOWN        100 /* 网络已关闭 */
#define ENETUNREACH     101 /* 网络无法企及 */
#define ENETRESET       102 /* 网络因为重置而断开连接 */
#define ECONNABORTED    103 /* 软件导致连接中止 */
#define ECONNRESET      104 /* 连接被对方重置 */
#define ENOBUFS         105 /* 没有可用缓冲空间 */
#define EISCONN         106 /* 已连接传输端点 */
#define ENOTCONN        107 /* 未连接传输端点 */
#define ESHUTDOWN       108 /* 传输端点关闭后无法发送 */
#define ETOOMANYREFS    109 /* 引用太多:无法粘接 */
#define ETIMEDOUT       110 /* 连接超时 */
#define ECONNREFUSED    111 /* 连接被拒绝 */
#define EHOSTDOWN       112 /* 主机已关闭 */
#define EHOSTUNREACH    113 /* 没有到主机路由 */
#define EALREADY        114 /* 操作已在进行中 */
#define EINPROGRESS     115 /* 操作已在进行中 */
#define ESTALE          116 /* 过时网络文件系统(NFS)文件句柄 */
#define EUCLEAN         117 /* 结构需要清理 */
#define ENOTNAM         118 /* 非XENIX平台命名类型文件 */
#define ENAVAIL         119 /* 没有可用XENIX平台信号量 */
#define EISNAM          120 /* 是命名类型文件 */
#define EREMOTEIO       121 /* 远程输入输出错误 */
#define EDQUOT          122 /* 超过配额 */

#define ENOMEDIUM       123 /* 未找到媒体 */
#define EMEDIUMTYPE     124 /* 错误的媒体类型 */
#define ECANCELED       125 /* 操作被取消 */
#define ENOKEY          126 /* 需要的key不可用 */
#define EKEYEXPIRED     127 /* key已过期 */
#define EKEYREVOKED     128 /* key已被撤销 */
#define EKEYREJECTED    129 /* key被服务拒绝 */

/* 为稳定的互斥变量 */
#define EOWNERDEAD      130 /* 拥有者已经退出 */
#define ENOTRECOVERABLE 131 /* 状态不可恢复 */
#define ERFKILL         132 /* 因为无线关闭,操作不可实现 */
#define EHWPOISON       133 /* 内存页遇到硬件错误 */

#endif

列表8:可加载内核模块开发使用的错误状态/usr/include/asm-generic/errno.h

查看英文原文:Writing a Linux Kernel Module — Part 2: A Character Device

编后语

《他山之石》是InfoQ中文站新推出的一个专栏,精选来自国内外技术社区和个人博客上的技术文章,让更多的读者朋友受益,本栏目转载的内容都经过原作者授权。文章推荐可以发送邮件到editors@cn.infoq.com。


感谢魏星对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入InfoQ读者交流群InfoQ好读者)。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT