嵌入式Linux驱动开发(一)——字符设备驱动框架入门

1字数 3456阅读 6724

提到了关于Linux的设备驱动,那么在Linux中I/O设备可以分为两类:块设备和字符设备。这两种设备并没有什么硬件上的区别,主要是基于不同的功能进行了分类,而他们之间的区别也主要是在是否能够随机访问并操作硬件上的数据。

  1. 字符设备:提供连续的数据流,应用程序可以顺序读取,通常不支持随机存取。相反,此类设备支持按字节/字符来读写数据。举例来说,调制解调器是典型的字符设备。
  2. 块设备:应用程序可以随机访问设备数据,程序可自行确定读取数据的位置。硬盘是典型的块设备,应用程序可以寻址磁盘上的任何位置,并由此读取数据。此外,数据的读写只能以块(通常是512Byte)的倍数进行。与字符设备不同,块设备并不支持基于字符的寻址。
    两种设备本身并没用严格的区分,主要是字符设备和块设备驱动程序提供的访问接口(file I/O API)是不一样的。本文主要就数据接口、访问接口和设备注册方法对两种设备进行比较。

那么,首先,认识一下字符设备的驱动框架。

对于上层的应用开发人员来说,没有必要了解具体的硬件是如何组织在一起并工作的。比如,在Linux中,一切设备皆文件,那么应用程序开发者,如果需要在屏幕上打印一串文字,虽然表面看起来只是使用printf函数就实现了,其实,他也是使用了int fprintf(FILE *fp, const char* format[, argument,...])封装后的结果,而实际上,fprintf函数操作的还是一个FILE,这个FILE对应的就是标准输出文件,也就是我们的屏幕了。

那么最简单的字符设备驱动程序的框架是如何呢?

应用程序和底层调用的结构

正如上图所显示的那样,用户空间的应用开发者,只需要通过C库来和内核空间打交道;而内核空间通过系统调用和VFS(virtual file system),来调用各类硬件设备的驱动。如果,有过单片机的经验,那么一定知道,操作硬件简单来说就是操作对应地址的寄存器中的内容。而硬件驱动实际就是和这些寄存器打交道的。通过操作对应硬件的寄存器来直接的控制硬件设备。

那么,对于上面这幅图可以看出,驱动程序实际也是内核的一部分,当然可以把代码直接放到内核中一起编译出来。但是对于很多开发板来说,内核来说早已经编译完成运行在开发板上,那么是不是必须要重新编译并烧写整个内核呢?
换到我们使用pc来说,显然不是这样,如果我们购买了一个键盘,为了键盘还需要重新安装对应的操作系统,那么未免也太不方便,并且我们的使用经验也并非如此。
而在之前谈到的内核编译过程中,可以将一些模块编译为module的方式编译,在运行时加载该模块即可,而不用每次都需要完整的对内核进行编译。
因此,对于驱动程序的开发来说,这一点就显得很重要,也是我们日常工作最常用的一种方式。

那么我们先回顾一下,在应用层我们一般是如何来操作一个设备文件的?我们通常会使用一些类似于readopenwrite等函数。(可以参见我之前写的文章:Linux文件编程)。那么在使用这些函数的时候,会包含一些头文件,例如:sys/types.hsys/stat.h以及fcntl.h等这些头文件,实际他们就是C库的部分,用户程序这时候只需要关心的是C库到底如何使用,而C库背后实际完成的是调用一些系统调用,类似于sys_opensys_read等函数来对内核空间进行调用的。

在这里毕竟不是为了分析框架的具体实现原理,以后有机会慢慢展开,在此主要为了讨论,如何快速使用这些框架来写出字符设备的驱动程序。

其实编写字符驱动的步骤并不复杂,我们首先将框架建立起来,建立框架的大致我认为可以分为以下两部(其中的细节问题后续展开):

  1. 编写驱动的入口和出口函数,此函数会在驱动模块加载和卸载时调用
  2. 编写具体的read、write、open等函数,在用户程序使用对应的函数时,该函数可以被调用。(非必须)

我们先看看一个简单的驱动程序的框架:

#include <linux/init.h>     //定义了module_init
#include <linux/module.h>   //最基本的头文件,其中定义了MODULE_LICENSE这一类宏
#include <linux/fs.h>       // file_operations结构体定义在该头文件中

static const char* dev_name = "first_driver";  //  定义设备名
static unsigned int major = 55;               //定义设备号

//定义了open函数
static int first_drv_open (struct inode *inode, struct file *file)
{
        printk("open\n");
        return 0;
}

//定义了write函数
static ssize_t first_drv_write (struct file *file, const char __user *buf, size_t size, loff_t * ppos)
{
        printk("write\n");
        return 0;
}

//在file_operations中注册open和write函数
static struct file_operations first_drv_fo =
{
        .owner  =  THIS_MODULE,

        //将对应的函数关联在file_operations的结构体中
        .open   =  first_drv_open,      
        .write  =  first_drv_write,
};

//init驱动的入口函数
static int first_drv_init(void)
{      
        //注册设备,实际是将file_operations结构体放到内核的制定数组中,以便管理
        //在register_chrdev中制定major作为主设备号
        register_chrdev(major, dev_name , &first_drv_fo);
        printk("init\n");
        if(dev_id < 0) 
            printk("error\n");
        return 0;
}

//驱动的出口函数
static void first_drv_exit(void)
{
        printk("exit\n");
        unregister_chrdev(major, dev_name);  //卸载设备,实际是将file_operations结构体从内核维护的相关数组中以主设备号作为索引删除
}

//内核将通过这个宏,来直到这个驱动的入口和出口函数
module_init(first_drv_init);  
module_exit(first_drv_exit);

MODULE_AUTHOR("Ethan Lee <4128127@qq.com>");
MODULE_LICENSE("GPL");  //指定协议

以上的代码基本是关于字符型设备驱动的框架结构了。可以看到以上的代码其实就是一个简单的驱动程序框架了,其实如果没有first_drv_openfirst_dev_write两个函数也是可以的,在硬件上可以正确的安装该驱动,在安装驱动的时候会调用注册在module_init中的函数,在卸载程序时会调用module_exit中所注册的函数。但是file_operations结构体依然还是需要定义的,但实际的驱动程序需要操作实际的硬件,一般都会有open、read、write这类函数。但在此仅仅是为了说明驱动的最小框架而已。

那么驱动程序写完了,我们来使用测试程序调用一下,以下是测试程序

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>

int main(int argc, char **argv)
{
    int fd;      //声明设备描述符
    int val = 1;  //随便定义变量传入到
    fd = open("/dev/xxx",  O_RDWR);  //根据设备描述符打开设备
    if(fd < 0)          //打开失败
            printf("can't open\n");  
    write(fd, &val, 4);  //根据文件描述符调用write


    return 0;
}

Makefile

#驱动程序实际属于内核的一部分,那么在编译的时候就需要使用已经编译好的内核,来编译驱动程序了,这点尤为重要。
KERN_DIR=/code/LinuxDev/Lab/KernelOfLinux/linux-2.6.22.6        #内核目录

all:
        make -C $(KERN_DIR) M=`pwd` modules     #M=`pwd`表示,生成的目标放在pwd命令的目录下                                                             # -C代表使用目录中的Makefile来进行编译

clean:
        make -C $(KERN_DIR) M=`pwd` modules clean
        rm -f modules.order

obj-m += first.o #加载到module的编译链中,内核会编译生成出来ko文件,作为一个模块
使用Makefile编译驱动程序
编译测试程序

完成了测试程序和驱动程序的编译,那么接下来就是将写好的驱动程序安装在开发板上
在开发板上使用lsmod命令查看已安装的模块

PS:我的开发板使用的是NFS系统,这个NFS系统是linux服务器所提供的,所以在Linux服务器上编译完成后就直接切换在了开发板上操作,如果你的开发板使用的不是NFS系统,那么,还需要把编译出来的测试程序的可执行文件和.ko模块文件拷贝到开发板的文件系统中,才能执行后续的操作。

lsmod查看系统中已经安装的模块

目前在系统中还没有添加任何的模块。
使用insmod 模块名来加载我们刚才写好的驱动程序,添加的驱动程序模块是.ko文件

在系统装装在first.ko模块

现在可以看到,lsmod以后可以看到已经安装好的驱动程序了,并且在insmod的时候,调用驱动程序里面我们写好的入口程序first_drv_init函数中的printk("init\n")函数的打印结果。

因此我们知道了在装载驱动程序的时候就会调用驱动程序对应的入口函数。

这时候迫不及待的试一下测试程序,看看能不能正确的open和read吧。

使用测试程序

驱动程序既然已经安装好了,为什么打开测试程序的时候却没法正确的打开呢,回看我们之前的代码,也没发现错误。

如果我们查看/proc/devices文件,我们会发现,有一个主设备号为55的字符设备。

image.png

这时候如果查看/dev/目录下我们会发现我们写的设备并没有添加在其中。那么我们为开发板新增加一个设备文件。

添加一个设备文件,然后执行测试程序

添加了设备文件后,在执行测试程序,发现正确的open了,并且调用了write函数,正确打印了。

mknod命令,第一个参数是设备文件的名字,这个名字要和测试程序中的打开的相一致
第二参数c代表的是字符设备
55代表的是主设备号
0代表的是次设备号

驱动程序测试通过了,当我们不需要驱动程序的时候,我们应该将他卸载掉rmmod 驱动名

写在驱动程序

在我们卸载驱动程序的时候,可以看到调用了驱动程序的出口函数,打印出来了exit。此时在查看/proc/devices没有设备了。而在/dev/目录下的设备节点则需要手动来删除。

以上就是一个简单的字符设备驱动程序的框架,驱动程序的在insmod的时候调用了入口函数,在rmmod的时候调用了出口函数,而当我们调用write或者open的时候,会调用到驱动程序中在file_operatios结构体中注册的对应的write和open函数。
如果观察刚才的执行过程,会发现几个问题问题

  1. 装载了驱动程序以后,在/proc/devices中设备,分配设备号,但设备号是在驱动程序中写死的,那么如果设备号被占用,肯定会装载失败;
  2. 装载完成了驱动程序以后,实际上还不能直接用测试程序打开对应的设备文件,因为设备文件并没有自动创建,需要我们手动创建设备节点,这时候才能使用测试程序来通过打开文件的方式操作驱动程序所对应的硬件。

以上的问题,肯定是有办法解决的,不然我们每次设备都需要这样操作实在也不方便。那么我们就来改进一下我们的代码来实现自动分配设备号以及创建设备文件吧。


首先关于第一个问题的解决方案很简单,注册驱动程序的时候,如果传入的major为0,那么系统将会自动为这个驱动程序分配主设备号,同时这个程序也会返回所分配的主设备号。
第二个问题,解决起来也不是很困难,在Linux中提供了一种机制是udev,可以用于自动的创建设备,在嵌入式Linux的文件系统,比如busybox,也有一套简化版的机制,是mdev,在配置文件系统的时候会进行相应的配置,写完了关于文件系统的文章,我会将链接贴上来。

我们如果在调用驱动程序的入口函数的时候,就使用mdev来创建这个设备文件其实就行了。使用mdev的时候,需要用到两个结构体,一个是classclass_device。这两个结构体,都定义在%kernel%/include/linux/device.h的头文件中

为了代码篇幅,这个驱动程序中,就没有定义相关的open、write等函数,那么同样也就不需要在定义相关的测试函数了。

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/device.h>    //class、class_device结构体的定义位置

static const char* dev_name = "third_driver";
static struct class *third_class;    //定义class结构体
static struct class_device *third_class_dev;    //定义class_device结构体

static struct file_operations third_fos= 
{
    .owner = THIS_MODULE,    //file_operations结构题中只指定owner
};

static int dev_id = 0;     //初始化的设备号0
static int __init third_init(void)
{
    dev_id = register_chrdev(dev_id, dev_name, &third_fos); //指定的主设备号为0,同时把用设备号来接收函数的返回值,实际该函数会返回自动创建的主设备号

    third_class = class_create(THIS_MODULE, "third_drv");    //初始化class结构体,指定设备文件名

    third_class_dev = class_device_create(third_class, NULL, MKDEV(dev_id, 0), NULL, "third_drv");// 根据class来初始化class_device,会创建出对应的设备文件
       
    printk("init\n");

    return 0;
}

static void __exit third_exit(void)
{
    unregister_chrdev(dev_id, dev_name);  
    class_device_unregister(third_class_dev); // 后创建的先卸载
    class_destroy(third_class);
    printk("exit\n");
}

module_init(third_init);
module_exit(third_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Ethan Lee <4128127@qq.com>");

执行结果

从以上的运行结果我们看出,自动在/proc/devices中,创建了一个主设备号为252的设备,名字为third_driver,而实际代码中并没有指定对应的主设备号,那么也就是说明该设备号是由系统自动创建的。
同时我们如果查看一下/dev/目录中,我们发现在该目录下,创建了一个主设备号为252的设备文件。那么如果用测试程序来操作,就只需要操作该设备文件就能够操作对应的硬件设备了。

卸载驱动程序

我们卸载了驱动程序后,自然会调用出口函数,我们在出口函数中写了卸载设备文件的代码,我们发现之前的自动创建的设备文件,也被自动卸载了,这样就解决了我们之前提出的两个问题。

现在基本完成了设备的框架搭建,并且可以自动生成设备文件了。但是实际现在还存在两个问题:
1.一直只使用主设备号了,但是次设备号一直没有使用过。其实他可以帮我们来确认具体我们操作的是哪个具体的硬件设备,那次设备号应该如何使用呢?

  1. 驱动程序是需要来操作硬件的,我们现在还并没有和硬件打交道,那么应该如何来操作硬件呢?

关于这两个问题,就请看下一篇文章吧,让我们通过一个实验解决我们的问题。
嵌入式Linux驱动开发(二)——字符设备驱动之点亮LED

推荐阅读更多精彩内容