在Linux中,一切皆文件,包括设备。对设备使用接口的实现,其实就是对文件操作接口的实现。本博客将详述一个简单的读写设备驱动的编写,理解设备即文件的理念。
总所周知,Linux是整体式OS,采用模块的开发方式。当想添加什么新功能时,编写新的模块即可,设备也不例外。设备驱动程序的注册需要依赖模块,因此写驱动之前,要先明白如何编写模块。
编写Linux内核模块
这里编写一个简单的Linux内核,用来向内核的日志中打印不同的字符串。一个基础模块的编写不难,无非就是模块的加载函数和卸载函数,在Linux库中分别用module_init()和module_exit来实现。前者为安装模块时执行的动作,后者为卸载模块时执行的动作。因此,只需要在这两个函数中进行输出即可,完整代码如下:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
//模块许可证声明
MODULE_LICENSE("Dual BSD/GPLi");
//模块加载函数
static int hello_init(void)
{
printk(KERN_ALERT "Hello World enter\n");
return 0;
}
//模块卸载函数
static void hello_exit(void)
{
printk(KERN_ALERT "Hello World exit\n");
return;
}
//模块的注册
module_init(hello_init);
module_exit(hello_exit);
//声明模块的作者
MODULE_AUTHOR("YeSiyuan");
//声明模块的描述
MODULE_DESCRIPTION("This is a simple example!\n");
//声明模块的别名
MODULE_ALIAS("A simplest example");
其中,printk为内核态的输出函数,用于将内容输出进内核日志,可以用dmesg查看日志。MODULE_LICENSE用于指定模块的许可证声明,这个是必须的,包括后面的设备驱动编写。MODULE_AUTHOR、MODULE_DESCRIPTIO、MODULE_ALIAS用来声明作者、描述、别名,都是非必须的。
编写完成后,即可开始编译。编译需要用Linux内核本身的编译工具,还需要附加一下链接,因此为了编译方便,我们编写一个Makefile文件,内容如下:
obj-m += hello.o # 要和编译的cpp文件一致
#generate the path
CURRENT_PATH:=$(shell pwd)
#the current kernel version number
LINUX_KERNEL:=$(shell uname -r)
#the absolute path--根据获取的内核版本拼装绝对路
LINUX_KERNEL_PATH:=/lib/modules/$(LINUX_KERNEL)/build/
#complie object
all:
make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH)
#clean
make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) clean
在工作目录下执行命令make,它会自动找到该上述Makefile进行执行。编译完成后即可安装/卸载该模块。使用insmod hello.ko来安装,使用rmmod hello.ko来卸载,结果如下,注意要sudo。

编写Linux设备驱动
这里将简单的编写一个Linux驱动程序,功能:write 几个整数进去,read 出其和。先要明白,在Linux中设备即文件,对设备驱动程序的实现实际上就是对文件操作各个接口的的实现。文件操作的结构定义在结构体file_operations中,其是一个函数指针的集合,其中比较重要的的为open(),close(),write(), read()。这里就重点实现这四者。
首先实现open,这个很简单,只需要printk一下就行:
int hello_open(struct inode * ip, struct file * fp)
{
printk("%s : %d\n", __func__, __LINE__);
/* 一般用来做初始化设备的操作 */
return 0;
}
然后实现close,也只需要printk一下就行:
int hello_close(struct inode * ip, struct file * fp)
{
printk("%s : %d\n", __func__, __LINE__);
/* 一般用来做和open相反的操作,open申请资源,close释放资源 */
return 0;
}
接着实现最重要的wirte函数和read函数。这里要求write函数能够写进去2个整数,然后read可以读回两数之和。需要注意的是,用户程序是工作在用户态的,而驱动程序是工作在内核态的,两者是不能直接进行数据通信的,必须借助一些通信函数才能实现,即copy_from_user和copy_to_user,前者将数据从用户空间拷贝到内核空间,后者将数据从内核空间拷贝到用户空间。因此,编写如下write函数:
ssize_t hello_write(struct file * fp, const char __user * buf, size_t count, loff_t * loff)
{
int ret;
/* 将用户需要的数据从用户空间copy到内核空间(buf) */
printk("%s : %d\n", __func__, __LINE__);
if ((ret = copy_from_user(data, buf, count)))
{
printk("copy_from_user err\n");
return -1;
}
printk("data1 = %c\ndata2 = %c\n",data[0],data[1]);
return count;
}
接下来开始编写read函数。需要注意的是,文件操作接口中write和read的缓冲区参数的是char*类型的,而求和是数字求和,因此要减去两个z字符‘0’。完成后,计算出两数之和,然后返回该数即可,代码如下:
ssize_t hello_read(struct file * fp, char __user * buf, size_t count, loff_t * loff)
{
int ret;
/* 将用户需要的数据从内核空间copy到用户空间(buf) */
printk("%s : %d\n", __func__, __LINE__);
if ((ret = copy_to_user(buf, data, count)))
{
printk("copy_to_user err\n");
return -1;
}
int sum = data[0] + data[1] - '0' - '0';
printk("sum is %d\n",sum);
return sum;
}
编写完四个接口实现后,需要在file_operations结构体中对各接口进行注册,如下:
/* 分配file_operations结构体 */
struct file_operations hello_fops = {
.owner = THIS_MODULE,
.open = hello_open,
.release = hello_close,
.read = hello_read,
.write = hello_write8.
};
最后,由于设备驱动程序也是借助了Linux的模块机制,添加设备也需要添加模块,因此还要实现init和exit即进行module_init和module_exit。和上一个任务的init不同,该任务由于要新增设备,因此在init的实现中要使用MKDV生成设备号,并使用register_chrdev_region来注册设备字符设备。注册完之后,还要通过cdev_add、cdev_init来分配、设置、注册cdev结构体,最终的init和exit代码为:
struct cdev cdev;
static int hello_init(void)
{
int ret;
printk("%s : %d\n", __func__, __LINE__);
/* 1. 生成并注册设备号 */
devno = MKDEV(major, 0);
ret = register_chrdev_region(devno, 1, DEVNAME);
if (ret != 0)
{
printk("%s : %d fail to register_chrdev_region\n", __func__, __LINE__);
return -1;
}
/* 3. 分配、设置、注册cdev结构体 */
cdev.owner = THIS_MODULE;
ret = cdev_add(&cdev, devno, 1);
cdev_init(&cdev, &hello_fops);
if (ret < 0)
{
printk("%s : %d fail to cdev_add\n", __func__, __LINE__);
return -1;
}
printk("Init success!\n");
return 0;
}
static void hello_exit(void)
{
printk("%s : %d\n", __func__, __LINE__);
/* 释放资源 */
cdev_del(&cdev);
unregister_chrdev_region(devno, 1);
}
MODULE_LICENSE("GPL");
module_init(hello_init);
module_exit(hello_exit);
编写完设备驱动的代码后,同上一个任务,编写Makefile,模块名为ysy_dev,同cpp文件名:
KERNEL_PATH := /lib/modules/`uname -r`/build
PWD := $(shell pwd)
MODULE_NAME := ysy_dev
obj-m := $(MODULE_NAME).o
all:
$(MAKE) -C $(KERNEL_PATH) M=$(PWD)
clean:
rm -rf .*.cmd *.o *.mod.c *.order *.symvers *.tmp *.ko
注意,模块是模块,设备是设备,设备只是借助模块来注册和卸载,所以设备名不等同于模块名,根据驱动代码可知,设备名为ysy_device。
使用make进行编译,然后使用insmod来安装该模块,之后使用cat /proc/devices来查看所有设备编号,发现设备安装成功,如下:

可以看到,设备安装成功,设备编号为255。但是,到该步为止只是将驱动安装到了内核中,如果想要访问驱动,必须先创建设备节点,通过设备节点来访问,输入命令:
sudo mknod /dev/ysy_device c 255 0
其中,c指字符设备,255是主编好,0是次编号,要和编写的设备对用。该命令会创建设备节点和设备挂钩,实际就是一个文件,位于/dev目录下。

至此,设备驱动与设备节点全部创建成功,可以开始测试。
设备测试
编写了的测试程序代码如下,会接收第1个参数作为设备路径,然后使用设备驱动将第2、3个参数写入内核日志,并返回两数之和。不解释了,直接上代码:
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main(int argc, char * argv[])
{
int fd;
int ret;
char buf[2]; // two num
buf[0] = argv[2][0];
buf[1] = argv[3][0];
/* 将要打开的文件的路径通过main函数的参数传入 */
if (argc < 2){
printf("Usage: %s <filename>\n", argv[0]);
return -1;
}
fd = open(argv[1], O_RDWR);
if (fd < 0){
perror("fail to open file");
return -1;
}
/* write data */
ret = write(fd, buf, sizeof(buf));
if (ret < 0){
printf("read err!");
return -1;
}
/* read data */
ret = read(fd, buf, sizeof(buf));
if (ret < 0){
printf("read err!");
return -1;
}
printf("sum = %d\n",ret);
close(fd);
return 0;
}
使命令sudo ./devTest.o /dev/ysy_deivce 3 4 来执行测试程序,打开设备驱动并计算3和4的和,并使用dmesg查看内核缓冲区,结果如下:

可以看到,不仅测试程序返回了正确的执行结果,驱动程序还在内核中输出了相关信息。包括,两个输入数据分别为3和4,计算的求和结果为7都成功打印在了内核缓冲区。