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);
}