Linux驱动开发之字符设备驱动入门
2020-02-10
关键字:
在 Linux 中设备驱动可以分为如下三种类型:
1、字符设备驱动
2、块设备驱动
3、网络设备驱动
字符设备驱动就是指以字符流为数据通信基础的设备。例如:LCD、键盘、I2C等。它的特点就是通信速度快,同时数据量也相对较小。块设备则主要是一些存储设备。例如:磁盘、U盘、Flash、SD卡等。它的数据通信是以块为基础的。它的通信速率相较于字符设备要慢一些。网络设备的概念就比较简单粗暴一些了。例如:以太网、WIFI等。它们的通信是基于协议的,一般是统一基于 Socket 协议的。
对于用户来说字符设备所开放的通信接口就是文件IO。用户可以通过 open()、read()、write()、close() 来与字符设备进行通信。
每一个设备驱动都必须要有一个设备号,用于在众多设备驱动中作身份标识。用户就通过这个设备号来进行文件IO操作进而实现操作驱动的目的。每一个字符设备驱动中都要实现对应的 xx_open()、xx_read()、xx_write()、xx_close() 函数以对应于用户在用户空间所调用的 open()、read()、write()、close() 接口。
开发字符设备驱动
每一个驱动程序,不仅局限于字符设备驱动,都要有一个设备号。这个设备号需要向系统申请,申请设备号的函数为:
int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops);
参数1 表示要申请的主设备号。在Linux设备驱动中,设备号为分两部分组成:1、主设备号;2、次设备号。整个的设备号是一个 32 位的整型数值。其中,主设备号占高 12 位,次设备号占低 20 位。主设备号表示某一类设备,例如“摄像头类”。次设备号则表示某一类中的具体个体,例如“前置摄像头”或“后置摄像头”。这个参数可以直接填写值0,值0表示由系统分配一个主设备号,由系统分配的主设备号将通过返回值告知。也可以自行指定一个正整数,自行指定主设备号时应注意不要与系统中已存在的主设备号冲突了。
参数2 表示描述一个设备信息。在 /proc/devices 下会列举出所有已经注册的设备信息。当然这个参数的值我们也可以自定义设置。
参数3 表示文件操作对象。就是驱动源码中用于响应用户空间的文件IO的 open()、 read()、 write()、 close() 的函数名的注册信息结构体。
返回值0表示申请成功,返回值为负数则表示失败。
与申请设备号相反,还有一个释放设备号的函数:
void unregister_chrdev(unsigned int major, const char *name);
以下是字符设备驱动程序开发中申请设备号的示例代码:
#include <linux/init.h> #include <linux/module.h>
#include <linux/Fs.h> static unsigned int dev_major = 250; const struct file_operations myfops = { }; static int __init chr_dev_init(void) { //申请设备号。 int ret = register_chrdev(dev_major, "chr_dev_test", &myfops); return ret; } static void __exit chr_dev_exit(void) { //释放申请的设备号。 unregister_chrdev(dev_major, "chr_dev_test"); } module_init(chr_dev_init); module_exit(chr_dev_exit); MODULE_LICENSE("GPL");
以上是设备号的创建过程,下面是设备节点的创建过程。
创建设备节点可以通过两种方式来创建:
1、手动创建
2、自动创建
通过 udev/mdev 机制。
手动创建设备节点:
通过控制台的命令实现: mknod /dev/设备名 类型 主设备号 次设备号。例如:mknod /dev/chr0 c 250 1
手动创建设备节点的缺点是创建结果仅本次有效,断电后创建信息会丢失。所以通常都不会使用手动创建设备节点的方式。
自动创建设备节点:
通过在驱动源码中调用接口函数来创建,相关的接口函数有两个,它们的接口签名如下:
struct class *class_create(owner, const char *name);
参数1 通常直接填写 THIS_MODULE 即可。
参数2 表示设备节点名称,可以自定义填写。
struct device *device_create(struct class *class, struct device *parent, dev_t devt, void *drvdata, const char *fmg, ...);
参数1 表示 class 结构体,就是通过上面的 class_create() 创建出来的。
参数2 一般直接填 NULL 即可。
参数3 表示设备号的结构体类型。其实就是主设备号和次设备号组合而成的一个 32 位的整型数值。可以通过系统宏定义 MKDEV(major, minor) 来创建。
参数4 表示私有数据,一般也是直接填 NULL。
参数5 和 参数 6 表示可变参数,字符串。
释放创建的设备节点的函数接口签名如下,要注意调用顺序:
void device_destroy(struct class *class, dev_t devt);
void class_destroy(struct class *class);
以下是一个基于前面创建设备号的示例的自动创建设备节点的的示例代码:
#include <linux/init.h> #include <linux/module.h> #include <linux/Fs.h>
#include <linux/device.h> static unsigned int dev_major = 250; static struct class *devcls; static struct device *dev; const struct file_operations myfops = { }; static int __init chr_dev_init(void) { //申请设备号。 int ret = register_chrdev(dev_major, "chr_dev_test", &myfops); //创建设备节点。 devcls = class_create(THIS_MODULE, "chr_cls"); dev = device_create(devcls, NULL, MKDEV(dev_major, 0), NULL, "chr2"); return ret; } static void __exit chr_dev_exit(void) { //释放设备节点。 device_destroy(devcls, MKDEV(dev_major, 0)); class_destroy(devcls); //释放申请的设备号。 unregister_chrdev(dev_major, "chr_dev_test"); } module_init(chr_dev_init); module_exit(chr_dev_exit); MODULE_LICENSE("GPL");
这段代码成功运行以后就可以在 /dev 目录下发现 chr2 节点了。 /dev/chr2
接下来是实现用户空间的文件IO的响应接口。
响应接口无非就是在驱动源码中通过实现函数以应对用户空间的 open()、 read()、 write() 与 close() 而已。所以我们只需要实现四个函数,并将它们映射到 file_operation 结构体中初始化即可。具体的请参阅下方示例源码:
#include <linux/init.h> #include <linux/module.h> #include <linux/Fs.h> #include <linux/device.h> static unsigned int dev_major = 250; static struct class *devcls; static struct device *dev; ssize_t chr_drv_read(struct file *fp, char __user *data, size_t count, loff_t *fpos) {
printk("chr_drv_read()"); return 0; } ssize_t chr_drv_write(struct file *fp, const char __user *buf, size_t count, loff_t *fpos) {
printk("chr_drv_write()"); return 0; } int chr_drv_open(struct inode *inode, struct file *fp) {
printk("chr_drv_open()"); return 0; } int chr_drv_close(struct inode *inode, struct file *fp) {
printk("chr_drv_close()"); return 0; } const struct file_operations myfops = { .open = chr_drv_open, .read = chr_drv_read, .write = chr_drv_write, .release = chr_drv_close, }; static int __init chr_dev_init(void) { //申请设备号。 int ret = register_chrdev(dev_major, "chr_dev_test", &myfops); //创建设备节点。 devcls = class_create(THIS_MODULE, "chr_cls"); dev = device_create(devcls, NULL, MKDEV(dev_major, 0), NULL, "chr2"); return ret; } static void __exit chr_dev_exit(void) { //释放设备节点。 device_destroy(devcls, MKDEV(dev_major, 0)); class_destroy(devcls); //释放申请的设备号。 unregister_chrdev(dev_major, "chr_dev_test"); } module_init(chr_dev_init); module_exit(chr_dev_exit); MODULE_LICENSE("GPL");
那用户或者说普通应用程序要如何来调用 chr_drv_open()、chr_drv_read()、 chr_drv_write()、 chr_drv_close() 函数呢?
以下就是一个运行在用户空间的应用程序调用上面这个字符设备驱动的示例代码:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int main() { //调用驱动 int fd = open("/dev/chr2", O_RDWR); int value = 0; read(fd, &value, 4); //int占4个字节。 write(fd, &value, 4); close(fd); return 0 }
这段应用程序代码非常简单,仅仅是为了演示上面的字符设备驱动的几个接口是否能够被调用到的而已。这里需要解释一下上面应用程序中的 read() 与 write() 调用太简单的问题,因为在我们上面的驱动程序中读和写并没有任何实现,仅仅只有一行打印而已,因此在示例应用程序中也干脆就没有做任何事了。
再接下来,我们就来实现一下驱动中的四个文件IO响应函数。
在驱动程序中,要想与普通应用程序进行数据交换,那么就不得不考虑到将数据从用户空间转换到内核空间的需求。这个转换可以通过以下两个函数来实现:
int copy_to_user(void __user *to, const void *from, unsinged long n); int copy_from_user(void *to, const void __user *from, unsigned long n);
这两个函数的返回值为0时表示操作完全成功。返回值大于0时表示还剩余多少个字节的数据没有操作成功。
以下是在驱动中应用这两个用户空间到内核空间数据转换的示例代码:
#include <linux/init.h> #include <linux/module.h> #include <linux/Fs.h> #include <linux/device.h> #include <asm/uaccess.h> static unsigned int dev_major = 250; static struct class *devcls; static struct device *dev; ssize_t chr_drv_read(struct file *fp, char __user *data, size_t count, loff_t *fpos) { printk("chr_drv_read()\n"); int kernel_val = 555; int ret = copy_to_user(data, &kernel_val, count); return 0; } ssize_t chr_drv_write(struct file *fp, const char __user *buf, size_t count, loff_t *fpos) { printk("chr_drv_write()\n"); int value; int ret = copy_from_user(&value, buf, count); printk("receive data:%d\n", value); return 0; } int chr_drv_open(struct inode *inode, struct file *fp) { printk("chr_drv_open()\n"); return 0; } int chr_drv_close(struct inode *inode, struct file *fp) { printk("chr_drv_close()\n"); return 0; } const struct file_operations myfops = { .open = chr_drv_open, .read = chr_drv_read, .write = chr_drv_write, .release = chr_drv_close, }; static int __init chr_dev_init(void) { //申请设备号。 int ret = register_chrdev(dev_major, "chr_dev_test", &myfops); //创建设备节点。 devcls = class_create(THIS_MODULE, "chr_cls"); dev = device_create(devcls, NULL, MKDEV(dev_major, 0), NULL, "chr2"); return ret; } static void __exit chr_dev_exit(void) { //释放设备节点。 device_destroy(devcls, MKDEV(dev_major, 0)); class_destroy(devcls); //释放申请的设备号。 unregister_chrdev(dev_major, "chr_dev_test"); } module_init(chr_dev_init); module_exit(chr_dev_exit); MODULE_LICENSE("GPL");
那在驱动程序中如何来控制硬件设备呢?
在驱动程序中通常使用虚拟地址来控制硬件设备。虚拟地址可以通过 ioremap 函数来将硬件物理地址进行映射得到。它的函数签名如下:
void *ioremap(cookie, size); void iounmap(void __iomem *addr);
参数 cookie 表示硬件的物理地址。
参数 size 表示长度,单位为字节。即你要映射的那个寄存器的长度,假如你要映射的寄存器是32位的,则size就是4,是8位的size就是1。
ioremap() 函数的返回值就是映射出来的虚拟地址。
iounmap() 函数的参数就是 ioremap() 函数的返回值。
硬件的物理地址要通过原理图,找到与之相连的 CPU 引脚号,再根据 CPU 的芯片手册确定出对应 GPIO 引脚的物理地址。
以下是一个通过地址映射来实现的点开发板上的 LED 灯的示例代码:
#include <linux/init.h> #include <linux/module.h> #include <linux/Fs.h> #include <linux/device.h> #include <asm/uaccess.h> #include <asm/io.h> //物理地址 #define GPX2_CON 0x11000c40 #define GPX2_SIZE 8 //用于保存虚拟地址,一个指针变量所占空间就与一个long一样。 volatile unsigned long *gpx2conf; volatile unsigned long *gpx2dat; static unsigned int dev_major = 250; static struct class *devcls; static struct device *dev; ssize_t chr_drv_read(struct file *fp, char __user *data, size_t count, loff_t *fpos) { printk("chr_drv_read()\n"); int kernel_val = 555; int ret = copy_to_user(data, &kernel_val, count); return 0; } ssize_t chr_drv_write(struct file *fp, const char __user *buf, size_t count, loff_t *fpos) { printk("chr_drv_write()\n"); int value; int ret = copy_from_user(&value, buf, count); printk("receive data:%d\n", value); //定义应用程序传非0则点灯,传0则灭灯。 if(value) { *gpx2dat |= (1 << 7);//点灯 } else { *gpx2dat &= &(1 << 7);//灭灯 } return 0; } int chr_drv_open(struct inode *inode, struct file *fp) { printk("chr_drv_open()\n"); return 0; } int chr_drv_close(struct inode *inode, struct file *fp) { printk("chr_drv_close()\n"); return 0; } const struct file_operations myfops = { .open = chr_drv_open, .read = chr_drv_read, .write = chr_drv_write, .release = chr_drv_close, }; static int __init chr_dev_init(void) { //申请设备号。 int ret = register_chrdev(dev_major, "chr_dev_test", &myfops); //创建设备节点。 devcls = class_create(THIS_MODULE, "chr_cls"); dev = device_create(devcls, NULL, MKDEV(dev_major, 0), NULL, "chr2"); gpx2conf = ioremap(GPX2_CON, GPX2_SIZE);//将物理地址映射成虚拟地址。 gpx2dat = gpx2conf + 1;//当然也可以用ioremap()来映射,直接加1是一种取巧的做法。 //配置相应GPIO引脚为输出功能。 *gpx2conf &= ~(0xf << 28); //移多少位要看你想操作哪一个引脚,要结合实际情况来决定。 *gpx2conf |= (0x1 << 28); return ret; } static void __exit chr_dev_exit(void) { iounmap(gpx2conf); //释放设备节点。 device_destroy(devcls, MKDEV(dev_major, 0)); class_destroy(devcls); //释放申请的设备号。 unregister_chrdev(dev_major, "chr_dev_test"); } module_init(chr_dev_init); module_exit(chr_dev_exit); MODULE_LICENSE("GPL");
总结一下,字符设备驱动的开发步骤如下:
1、搭建模块的加载和卸载函数框架
module_init(chr_dev_init);
module_exit(chr_dev_exit);
2、实现模块加载函数的细节
1、申请设备号
register_chrdev(dev_major, "chr_dev_test", &myfops);
2、创建设备节点
struct class *class_create(THIS_MODULE, "chr_cls");
struct device *device_create(devcls, NULL, MKDEV(dev_major, 0), NULL, "chr2");
3、硬件初始化
1、地址映射
gpx2conf = ioremap(GPX2_CONF, GPX2_SIZE);
2、中断申请
3、相应寄存器初始化
控制相应GPIO引脚方向与功能等。
4、实现 file_operations 结构体
const struct file_operations myfops = {
.open = chr_drv_open,
.read = chr_drv_read,
.write = chr_drv_write,
.release = chr_drv_close,
};
在驱动程序中操作寄存器地址的方式有如下几种:
1、将物理地址映射成虚拟地址以后直接赋值
2、通过 readl() 与 writel() 函数
这种方式所操作的地址也是经 ioremap 以后得到的虚拟地址。
u32 readl(const volatile void __iomem *addr); //从虚拟地址中读取地址空间的值。
void writel(unsigned long value, const volatile void __iomem *addr); //将 value 的值写到虚拟地址 addr 中去。
例:
u32 value = readl(led_dev->reg_virt_base);
value &= ~(0xf << 28);
value |= (0xf << 28);
writel(value, led_drv->reg_virt_base);
上面贴出的使用函数 register_chrdev() 注册新字符设备的代码是 linux 2.6 版本以前的内核上的接口。高于这个版本的内核有一套新的接口,下面直接贴出使用新接口注册字符设备驱动的示例代码:
#include <linux/module.h> #include <linux/init.h> #include <linux/cdev.h> #include <linux/types.h> #include <linux/fs.h> static dev_t mydevt; static struct cdev *mycdev; ssize_t mycdev_read (struct file *filp, char __user *buf, size_t count, loff_t *off) { printk("%s()\n", __FUNCTION__); return 0; } ssize_t mycdev_write (struct file *filp, const char __user *buf, size_t count, loff_t *off) { printk("%s()\n", __FUNCTION__); return 0; } int mycdev_close(struct inode *finode, struct file *filp) { printk("%s()\n", __FUNCTION__); return 0; } int mycdev_open(struct inode *fin, struct file *filp) { printk("%s()\n", __FUNCTION__); return 0; } static struct file_operations mycdev_ops = { .owner = THIS_MODULE, .open = mycdev_open, .release = mycdev_close, .read = mycdev_read, .write = mycdev_write, }; static int __init mycdev_init(void) { printk("cdev_init()\n"); //1、申请设备号。 memset(&mydevt, 0, sizeof(dev_t)); int ret = alloc_chrdev_region(&mydevt, 0, 1, "mycdev"); printk("alloc chrdev ret:%d,mydevt:%ld\n", ret, mydevt); //2、申请字符设备的内存空间。 mycdev = cdev_alloc(); printk("mycdev address:0x%p\n", mycdev); if(mycdev == NULL) return -1; //3、初始化这段内存并设置fops。 cdev_init(mycdev, &mycdev_ops); //4、设置这个字符设备的基础信息。 ret = cdev_add(mycdev, mydevt, 1); printk("mycdev add ret:%d\n", ret); //以上代码成功执行以后可以在 /proc/devices 下看到 "mycdev" 的字符设备的主设备号记录。 //然后就可以通过 mknode 命令来创建关联到这个主设备号的字符设备节点了。 return 0; } static void __exit mycdev_exit(void) { printk("cdev_exit()\n"); cdev_del(mycdev); unregister_chrdev_region(mydevt, 1); //注册的时候是几个释放时就是几个。 } module_init(mycdev_init); module_exit(mycdev_exit); MODULE_LICENSE("GPL");
代码较简单,就不再解释了。
以上就是嵌入式 Linux 字符设备驱动的开发基础知识。