Embedded Linux Development——Note (2)——字符设备驱动的开发流程

Copyright Mentioned Ahead

这篇文章是我学习Linux设备驱动开发的笔记,是在阅读正点原子的教程中的摘要,所以版权将会被标记为转载。我们首先梳理一下开发一款字符设备驱动程序的流程,然后在此基础上分析一下最简单的设备驱动程序LED的开发流程。最后作为补充,再加入一些关于新版驱动程序编写的流程。

吐槽一下,正点原子的教程,逻辑太乱了,看的时候绕来绕去。

1.字符设备驱动的重要步骤——自顶向下看

1.1 驱动模块的加载与卸载

假设我们我们已经有了一个字符设备驱动模块,驱动编译完成之后的模块后缀是.ko,有两种方法可以加载驱动模块:insmod和modprobe,它们的基本用法如下:

# insmod加载模块
insmod drv.ko
# rmmod卸载模块
rmmod drv.ko

# -------------------------------

# modprobe加载模块
modprobe drv.ko
# modprobe 卸载模块
modprobe -r drv.ko

insmod和rmmod是配对的,它们在加载模块时不能自动分析模块依赖关系,而modprobe可以,所以在加载驱动模块时推荐modprobe。而使用modprobe -r卸载模块时必须首先卸载依赖它的其他模块,所以在卸载模块时推荐rmmod命令

1.2 字符设备的注册与注销

我们在编写驱动代码时,要分别使用module_init、module_exit来向Linux内核注册模块入口和出口函数。当使用上述的insmod或modprobe命令加载或者卸载驱动模块时,使用module_init和module_exit注册的入口与出口函数就会被自动执行,以完成设备的初始化或者注销,所以下面仔细研究一下入口和出口函数的写法。

人口和出口函数中要做的事就是对字符设备进行初始化和注册,或者注销。在老版驱动程序中,完成注册和注销对应功能的函数分别是register_chrdev和unregister_chrdev

// @param major:要注册的设备的主设备号
// @param name:要注册的设备名,帮助debug
// @param fops:设备驱动包含的动作结构体
static inline int register_chrdev(
									unsigned int major, 
									const char *name,
									const struct file_operations *fops);

//---------分割线-----------
// @param major: 要注销的设备的主设备号
// @param name: 要注销的设备的名字
static inline void unregister_chrdev(
									unsigned int major, 
									const char *name);

1.3 编写设备的具体驱动函数

我们在上面注册设备时用到了register_chrdev函数,这个函数要接收一个描述设备支持的操作的结构体fops来描述这个设备驱动程序中需要支持的操作。所以这里就需要我们自己编写代码,来实现去驱动程序中例如打开文件、读写文件等等一系列的操作,这是开发驱动设备时最困难和千变万化的部分,我们需要自己阅读手册并正确地配置寄存器完成这些函数

在完成具体函数的编写和实现之后,使用如下类似的语法将函数注册到一个file_operations类型的结构体中,随后在调用register_chrdev函数时将此结构体传入即可,下面是虚拟的字符设备驱动的函数注册代码,作为参考。

static struct file_operations test_fops = {
    
    
	.owner = THIS_MODULE, 
	.open = chrtest_open,
	.read = chrtest_read,
	.write = chrtest_write,
	.release = chrtest_release,
};

1.4 添加LICENSE和作者信息

在开发完上述流程之后,其实我们大部分工作已经完成了,但是最后还要添加一下此驱动遵循的LICENSE信息和作者的相关信息,这些信息使用如下宏来定义:

// 给驱动程序附加LICENSE和作者相关信息,其中LICENSE是必须的
MODULE_LICENSE();
MODULE_AUTHOR();

// 例子:添加模块LICENSE信息和作者信息
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Tom");

2.设备号及其分配

在Linux系统中,Linux 中每个设备都有一个设备号,设备号由主设备号和从设备号两部分组成,主设备号表示某一个具体的驱动程序,从设备号表示使用这个驱动的各个设备。设备号的本质就是一个无符号整型数据(unsigned int32),其中高12位为主设备号,低20位为从设备号。所以,主设备号的取值范围是0~4095,在选择主设备号时一定不能超过这个范围

在分配主设备号时(如上面我们注册设备时,register_chrdev函数中要传入的第一个参数)可以有如下两种方法:

  • 静态分配:使用cat /proc/devices命令可以列出当前所有已经被占用的设备号,然后手动选一个没有被占用的主设备号
  • 动态分配(推荐!):我们可以使用alloc_chrdev_region和unregister_chrdev_region这两个函数来让Linux自动帮我们申请和回收设备号。这两个函数的原型如下,注意它们都可以用来分配和回收一段范围内的设备号
// @param dev: 申请到的主设备号,带出参数
// @param baseminor: 从设备号开始值,一般从0开始
// @param count: 要申请的设备号数量
// @param name: 设备名称 
int alloc_chrdev_region( 	dev_t *dev, 
							unsigned baseminor, 
							unsigned count, 
							const char *name);
// -------------------------------------------------
// @param from: 要释放的初始主设备号
// @param count: 要释放的设备号数量
void unregister_chrdev_region(dev_t from, unsigned count);

3.实例与更多细节——以led驱动的开发为例

3.1 ioremap和iounmap函数

上面我们简单陈述了字符设备驱动的开发流程,现在以一个例子将上述过程串起来,这个例子就是最简单的字符设备:LED灯。我们在板子上点亮LED灯,本质是通过GPIO的一个引脚输出高电平而做到的,那么现在要做的事情其实非常简单:就是向MMIO指定的地址写入值(电平)即可

但是因为有MMU的存在,就势必绕不开虚拟地址到物理地址的映射这个关键环节,这是由MMU自动完成的,现在的问题是我们知道端口的物理地址(就像裸机开发的时候那样),但是不知道它对应的虚拟地址,也就无法在程序中通过指针去访问这个地址。也就是反映射的过程我们无法实现, 为了帮助我们解决这个问题,Linux系统中提供了两个函数帮助我们将IO的物理地址映射到虚拟地址空间:ioremap和iounmap

前者用于获取指定物理地址空间对应的虚拟地址空间,后者用于释放掉ioremap所做的映射

// ioremap完成物理地址到虚拟地址的反映射
#define ioremap(cookie,size) __arm_ioremap((cookie), (size), MT_DEVICE)

// ioremap函数本质上是以下__arm_ioremap函数的简单封装
// @param phys_addr : 要反映射的物理地址起始位置
// @param size : 要反映射的地址空间大小
// @param mtype : ioremap的类型,默认传入MT_DEVICE
void __iomem * __arm_ioremap(phys_addr_t phys_addr, size_t size, unsigned int mtype)
{
    
    
	return arch_ioremap_caller(phys_addr, size, mtype, __builtin_return_address(0));
}

以下是iounmap的函数声明:

// 解除ioremap构建的映射关系,只需要将ioremap返回的指针传入即可
// @param addr : 要释放的IO设备在虚拟地址空间中的首地址
void iounmap (volatile void __iomem *addr)

可能你注意到了__iomem这个奇怪的修饰符,这个符号背后的文章不少。大体来说,它说明当前指向的地址空间属于IO地址空间。同时,含有__iomem修饰符的指针不允许解引用。那么,既然不能解引用,我们如何去访问这块地址呢?Linux要求我们必须通过以下这些函数来访问含有__iomem修饰的指针,否则编译器可能会报错或警告:

// 以不同大小来读取__iomem指针
u8 readb(const volatile void __iomem *addr)
u16 readw(const volatile void __iomem *addr)
u32 readl(const volatile void __iomem *addr)

// 以不同大小来写入__iomem指针
void writeb(u8 value, volatile void __iomem *addr)
void writew(u16 value, volatile void __iomem *addr)
void writel(u32 value, volatile void __iomem *addr)

3.2 实例-浅析设备的初始化代码

至于驱动程序的编写,其实非常简单,这里比较有趣的是初始化函数,这里贴出源码。可以看到首先代码中使用ioremap将物理地址映射到虚拟地址上,然后就和裸机开发一样,进行GPIO寄存器的配置,注意对寄存器的读写都通过上述特制的读写函数来完成,这些都是很常规的操作,不再展开说明。

最后使用register_chrdev函数将设备驱动进行注册,这样就完成了整个初始化过程。led_init函数将会作为入口函数使用module_init进行注册,这样我们在加载驱动模块时这个函数就会被连带执行。

static int __init led_init(void)
{
    
    
	int retvalue = 0;
	u32 val = 0;

	/* 初始化LED */
	/* 1、寄存器地址映射 */
  	IMX6U_CCM_CCGR1 = ioremap(CCM_CCGR1_BASE, 4);
	SW_MUX_GPIO1_IO03 = ioremap(SW_MUX_GPIO1_IO03_BASE, 4);
  	SW_PAD_GPIO1_IO03 = ioremap(SW_PAD_GPIO1_IO03_BASE, 4);
	GPIO1_DR = ioremap(GPIO1_DR_BASE, 4);
	GPIO1_GDIR = ioremap(GPIO1_GDIR_BASE, 4);

	/* 2、使能GPIO1时钟 */
	val = readl(IMX6U_CCM_CCGR1);
	val &= ~(3 << 26);	/* 清楚以前的设置 */
	val |= (3 << 26);	/* 设置新值 */
	writel(val, IMX6U_CCM_CCGR1);

	/* 3、设置GPIO1_IO03的复用功能,将其复用为
	 *    GPIO1_IO03,最后设置IO属性。
	 */
	writel(5, SW_MUX_GPIO1_IO03);
	
	/*寄存器SW_PAD_GPIO1_IO03设置IO属性
	 *bit 16:0 HYS关闭
	 *bit [15:14]: 00 默认下拉
     *bit [13]: 0 kepper功能
     *bit [12]: 1 pull/keeper使能
     *bit [11]: 0 关闭开路输出
     *bit [7:6]: 10 速度100Mhz
     *bit [5:3]: 110 R0/6驱动能力
     *bit [0]: 0 低转换率
	 */
	writel(0x10B0, SW_PAD_GPIO1_IO03);

	/* 4、设置GPIO1_IO03为输出功能 */
	val = readl(GPIO1_GDIR);
	val &= ~(1 << 3);	/* 清除以前的设置 */
	val |= (1 << 3);	/* 设置为输出 */
	writel(val, GPIO1_GDIR);

	/* 5、默认关闭LED */
	val = readl(GPIO1_DR);
	val |= (1 << 3);	
	writel(val, GPIO1_DR);

	/* 6、注册字符设备驱动 */
	retvalue = register_chrdev(LED_MAJOR, LED_NAME, &led_fops);
	if(retvalue < 0){
    
    
		printk("register chrdev failed!\r\n");
		return -EIO;
	}
	return 0;
}

与上述过程对应,我们也得有个出口函数,代码如下所示,首先使用iounmap取消映射,最后注销驱动模块

static void __exit led_exit(void)
{
    
    
	/* 取消映射 */
	iounmap(IMX6U_CCM_CCGR1);
	iounmap(SW_MUX_GPIO1_IO03);
	iounmap(SW_PAD_GPIO1_IO03);
	iounmap(GPIO1_DR);
	iounmap(GPIO1_GDIR);

	/* 注销字符设备驱动 */
	unregister_chrdev(LED_MAJOR, LED_NAME);
}

3.3 上板验证

我们将上述编写的代码(其实也就是正点原子写好的代码…汗)进行编译得到以.ko(kernel object)为后缀的驱动模块。得到这个模块之后通过nfs直接映射到开发板上,并放到可加载的内核模块文件夹中,这个文件夹的名称因内核版本而异,我的路径是/lib/modules/4.1.15-g3dc0a4b,注意要保证之前编译内核驱动模块时使用的Linux内核源码版本也必须是/4.1.15-g3dc0a4b,否则就会报错,详情可见这篇微信文章,我在上板验证的过程中就踩了坑。

验证的步骤如下:

# 1.此命令用于检测模块之间的相互依赖性,应该在调用modprobe之前执行
depmod

# 2.然后调用modprobe进行驱动模块加载
modprobe led.ko

# 这里可能会报错
# root@ATK-IMX6U:/lib/modules/4.1.15-g3dc0a4b# modprobe led.ko
# modprobe: FATAL: Module led.ko not found in directory /lib/modules/4.1.15-g3dc0a4b
# 这种情况只需要去掉.ko的后缀即可

# 3.为设备创建设备节点
# c表示当前设备是字符设备,200和0分别是主设备号和从设备号
mknod /dev/led c 200 0

其实在执行完modprobe指令之后,驱动模块就已经加载好了,我们可以通过cat /proc/devices命令来确认,如下图所示。而创建设备节点只是为了让我们更方便地去使用这个设备,就好像读写一个文件一样
在这里插入图片描述

4.新版驱动程序的编写

这里正点原子引入了新的字符设备驱动开发流程,这里只简单写出和传统流程不同的地方,我总结有以下几点:

4.1 优化1——设备号的分配过程

在传统的开发流程中,我们使用register_chrdev函数来手工指定设备号,在此之前我们必须通过cat /proc/devices来确定某一个设备号有没有被使用,相对来说不方便也不灵活。

retvalue = register_chrdev(LED_MAJOR, LED_NAME, &led_fops);

在新版设备驱动程序中,我们引入了alloc_chrdev_region和register_chrdev_region这样两个函数来向Linux系统申请设备号。这两个函数的区别在于alloc_chrdev_region函数是让Linux系统自动分配一个空闲设备号出来,而register_chrdev_region则是由用户向Linux系统申请指定的设备号。在新版驱动代码中是这样做的:

if (newchrled.major) {
    
    												/*  如果指定了设备号 */
		newchrled.devid = MKDEV(newchrled.major, 0);
		register_chrdev_region(newchrled.devid, NEWCHRLED_CNT, NEWCHRLED_NAME);
	} else {
    
    														/* 没有指定设备号 */
		alloc_chrdev_region(&newchrled.devid, 0, NEWCHRLED_CNT, NEWCHRLED_NAME);	
		// ...
	}

也就是说,如果用户提前指定好了设备号,那么就直接用register_chrdev_region函数申请,否则使用alloc_chrdev_region函数让Linux系统自动帮助我们分配一个设备好,并通过newchrled.devid这个量直接带出

当然,向系统申请了设备号就必须要释放设备号,所以出口函数代码如下,我们可以看到有释放设备号的过程,这个操作是使用unregister_chrdev_region函数完成的,此函数专门用来释放通过alloc_chrdev_region和register_chrdev_region函数分配的设备号

static void __exit led_exit(void)
{
    
    
	/* 取消映射 */
	iounmap(IMX6U_CCM_CCGR1);
	iounmap(SW_MUX_GPIO1_IO03);
	iounmap(SW_PAD_GPIO1_IO03);
	iounmap(GPIO1_DR);
	iounmap(GPIO1_GDIR);

	/* 注销字符设备驱动 */
	cdev_del(&newchrled.cdev);/*  删除cdev */
	unregister_chrdev_region(newchrled.devid, NEWCHRLED_CNT); /* 注销设备号 */

	device_destroy(newchrled.class, newchrled.devid);
	class_destroy(newchrled.class);
}

4.2 优化2——新的字符设备注册方法

在之前的版本中,我们使用register_chrdev函数向内核注册一个字符设备,在新版驱动程序的编写中这个过程变为了使用cdev这个特殊结构串连起来的一个过程。也就是说在Linux内核中有一个专门的数据结构来表示字符设备,即cdev:

struct cdev {
    
    
	struct kobject kobj;
	struct module *owner;				// 一般是THIS_MODULE
	const struct file_operations *ops;	// 字符设备的各个动作都记录在此
	struct list_head list;		
	dev_t dev;							// 设备号
	unsigned int count;
};

我们向内核注册一个设备的动作变成了如下几步:

// 1.使用cdev_init函数初始化一个cdev设备
// @param cdev : 指向设备的指针
// @param fops : 设备支持的驱动操作
void cdev_init(struct cdev *cdev, const struct file_operations *fops)

// 2.使用cdev_add函数将上面初始化好的字符设备添加到内核中
// @param p : 指向字符设备的指针
// @param dev : 设备号
// @param count : 要添加的设备数量
int cdev_add(struct cdev *p, dev_t dev, unsigned count)

// 3.在出口函数中,删除掉这个字符设备,将设备指针传入即可
// @param p : 指向字符设备的指针
void cdev_del(struct cdev *p)

4.3 优化3——自动创建设备节点

在老版本的驱动代码中,我们在得到.ko模块之后还得自己使用mknod命令来创建设备节点,而在新版代码中完全可以使用mdev机制来自动创建节点。这里不深入了解mdev机制,而是直接给出开发的流程,我们要在cdev_add操作之后使用class_create函数和device_create函数创建这个设备节点

// 1.在cdev_add操作之后创建一个类
// @param owner :此模块属于什么机构,一般写为THIS_MODULE
// @param name : 类的名称
struct class *class_create (struct module *owner, const char *name)

// 2.使用device_create函数创建一个设备
// @param class : 设备所属的类,就用上面创建出来的类
// @param parent : 该设备的父设备,如果是单一模块开发,则设为NULL
// @param devt : 设备号
// @param drvdata : 设备的一些附加数据,一般设为NULL
// @param fmt : 设备名字,传入“xxx”时,会生成名为/dev/xxx的设备
struct device *device_create(	struct class *class, 
 								struct device *parent,
 								dev_t devt, 
 								void *drvdata, 
 								const char *fmt, ...)

// 2.与上述函数对应的,在出口函数中应该将创建的设备和类都销毁
void class_destroy(struct class *cls);
void device_destroy(struct class *class, dev_t devt);

4.4 对数据的保护

经过上面引入新的函数与结构体,现在一个函数相关联的信息有很多,首先一个字符设备被抽象成了cdev这样一个结构体,它还属于一个类class,同时它本身作为一个设备device,也有自己的相关信息。所以为了保证这些信息逻辑上的相关性,我们一般将其声明为一个结构体,并使用,如下所示:

struct newchrled_dev{
    
    
	dev_t devid;			/* 设备号 	 */
	struct cdev cdev;		/* cdev 	*/
	struct class *class;	/* 类 		*/
	struct device *device;	/* 设备 	 */
	int major;				/* 主设备号	  */
	int minor;				/* 次设备号   */
};

// 使用上面的结构体声明一个设备,这就是我们要操作的设备实例
// 注意,最好将它注册进flip->private_data
struct newchrled_dev newchrled;	/* led设备 */

这样一个结构体其实就抽象了我们定义的整个设备,在设备的open函数中,一般要将其地址注册到flip->private_data字段中,比如在新版驱动代码中有如下实现:

// 将led设备对应的结构体注册到private_data字段
static int led_open(struct inode *inode, struct file *filp)
{
    
    
	filp->private_data = &newchrled; /* 设置私有数据 */
	return 0;
}

4.5 总结

这里给出出口函数和入口函数的代码实现,注意和老版本的代码实现进行对比:

4.5.1 新版入口函数的实现

/* newchrled设备结构体 */
struct newchrled_dev{
    
    
	dev_t devid;			/* 设备号 	 */
	struct cdev cdev;		/* cdev 	*/
	struct class *class;	/* 类 		*/
	struct device *device;	/* 设备 	 */
	int major;				/* 主设备号	  */
	int minor;				/* 次设备号   */
};

struct newchrled_dev newchrled;	/* led设备 */

static int __init led_init(void)
{
    
    
	u32 val = 0;

	/* 初始化LED */
	/* 1、寄存器地址映射 */
  	IMX6U_CCM_CCGR1 = ioremap(CCM_CCGR1_BASE, 4);
	SW_MUX_GPIO1_IO03 = ioremap(SW_MUX_GPIO1_IO03_BASE, 4);
  	SW_PAD_GPIO1_IO03 = ioremap(SW_PAD_GPIO1_IO03_BASE, 4);
	GPIO1_DR = ioremap(GPIO1_DR_BASE, 4);
	GPIO1_GDIR = ioremap(GPIO1_GDIR_BASE, 4);

	/* 2、使能GPIO1时钟 */
	val = readl(IMX6U_CCM_CCGR1);
	val &= ~(3 << 26);	/* 清楚以前的设置 */
	val |= (3 << 26);	/* 设置新值 */
	writel(val, IMX6U_CCM_CCGR1);

	/* 3、设置GPIO1_IO03的复用功能,将其复用为
	 *    GPIO1_IO03,最后设置IO属性。
	 */
	writel(5, SW_MUX_GPIO1_IO03);
	
	/*寄存器SW_PAD_GPIO1_IO03设置IO属性
	 *bit 16:0 HYS关闭
	 *bit [15:14]: 00 默认下拉
     *bit [13]: 0 kepper功能
     *bit [12]: 1 pull/keeper使能
     *bit [11]: 0 关闭开路输出
     *bit [7:6]: 10 速度100Mhz
     *bit [5:3]: 110 R0/6驱动能力
     *bit [0]: 0 低转换率
	 */
	writel(0x10B0, SW_PAD_GPIO1_IO03);

	/* 4、设置GPIO1_IO03为输出功能 */
	val = readl(GPIO1_GDIR);
	val &= ~(1 << 3);	/* 清除以前的设置 */
	val |= (1 << 3);	/* 设置为输出 */
	writel(val, GPIO1_GDIR);

	/* 5、默认关闭LED */
	val = readl(GPIO1_DR);
	val |= (1 << 3);	
	writel(val, GPIO1_DR);
	
	// ----------------------------以下代码和老版本实现不一致----------------------
	// ---------------------------4.1 优化1——设备号的分配过程----------------------------
	/* 注册字符设备驱动 */
	/* 1、创建设备号 */
	if (newchrled.major) {
    
    			/*  定义了设备号 */
		newchrled.devid = MKDEV(newchrled.major, 0);
		register_chrdev_region(newchrled.devid, NEWCHRLED_CNT, NEWCHRLED_NAME);
	} else {
    
    						/* 没有定义设备号 */
		alloc_chrdev_region(&newchrled.devid, 0, NEWCHRLED_CNT, NEWCHRLED_NAME);	/* 申请设备号 */
		newchrled.major = MAJOR(newchrled.devid);	/* 获取分配号的主设备号 */
		newchrled.minor = MINOR(newchrled.devid);	/* 获取分配号的次设备号 */
	}
	printk("newcheled major=%d,minor=%d\r\n",newchrled.major, newchrled.minor);	
	
	// ---------------------------4.2 优化2——新的字符设备注册方法------------------------
	/* 2、初始化cdev */
	newchrled.cdev.owner = THIS_MODULE;
	cdev_init(&newchrled.cdev, &newchrled_fops);
	
	/* 3、添加一个cdev */
	cdev_add(&newchrled.cdev, newchrled.devid, NEWCHRLED_CNT);
	
	// ---------------------------4.3 优化3——自动创建设备节点------------------------
	/* 4、创建类 */
	newchrled.class = class_create(THIS_MODULE, NEWCHRLED_NAME);
	if (IS_ERR(newchrled.class)) {
    
    
		return PTR_ERR(newchrled.class);
	}

	/* 5、创建设备 */
	newchrled.device = device_create(newchrled.class, NULL, newchrled.devid, NULL, NEWCHRLED_NAME);
	if (IS_ERR(newchrled.device)) {
    
    
		return PTR_ERR(newchrled.device);
	}
	
	return 0;
}

4.5.2 新版出口函数的实现

static void __exit led_exit(void)
{
    
    
	/* 取消映射 */
	iounmap(IMX6U_CCM_CCGR1);
	iounmap(SW_MUX_GPIO1_IO03);
	iounmap(SW_PAD_GPIO1_IO03);
	iounmap(GPIO1_DR);
	iounmap(GPIO1_GDIR);
	
	// ---------------新版出口函数要做的事情-------------
	// 1.注销字符设备
	// 2.注销设备号
	// 3.注销设备节点
	/* 注销字符设备驱动 */
	cdev_del(&newchrled.cdev);/*  删除cdev */
	unregister_chrdev_region(newchrled.devid, NEWCHRLED_CNT); /* 注销设备号 */

	device_destroy(newchrled.class, newchrled.devid);
	class_destroy(newchrled.class);
}

猜你喜欢

转载自blog.csdn.net/zzy980511/article/details/131506030