通过上一小节的学习,我们已经了解了字符设备驱动程序的基本框架,主要是掌握如何申请及释放设备号、添加以及注销设备,初始化、添加与删除cdev结构体,并通过cdev_init函数建立cdev和file_operations之间的关联,cdev结构体和file_operations结构体非常重要,希望大家着重掌握,本小节我们将带领大家做一个激动人心的小实验,点亮led,相信大家和我一样,在人生第一次点亮led的时候内心都满怀激动甚至狂欢,成就感简直爆棚。
本节我们将带领大家进入点亮开发板RGB LED灯的世界,学习一下如何在linux环境下驱动RGB LED灯。
前面我们已经学习了通过汇编或者C的方式直接操作寄存器,从而达到控制LED的目的,那本小结与之前的有什么区别么?当然有很大的区别,在linux环境直接访问物理内存是很危险的,如果用户不小心修改了内存中的数据,很有可能造成错误甚至系统崩溃。为了解决这些问题内核便引入了MMU,MMU为编程提供了方便统一的内存空间抽象,其实我们的程序中所写的变量地址是虚拟内存当中的地址,倘若处理器想要访问这个地址的时候,MMU便会将此虚拟地址(Virtual Address)翻译成实际的物理地址(Physical Address),之后处理器才去操作实际的物理地址。MMU是一个实际的硬件,并不是一个软件程序。他的主要作用是将虚拟地址翻译成真实的物理地址同时管理和保护内存,不同的进程有各自的虚拟地址空间,某个进程中的程序不能修改另外一个进程所使用的物理地址,以此使得进程之间互不干扰,相互隔离。而且我们可以使用虚拟地址空间的一段连续的地址去访问物理内存当中零散的大内存缓冲区。很多实时操作系统都可以运行在无MMU的CPU中,比如uCOS、FreeRTOS、uCLinux,以前想CPU也运行linux系统必须要该CPU具备MMU,但现在Linux也可以在不带MMU的CPU中运行了。总体而言MMU具有如下功能:
讲到MMU我又忍不住和大家说下TLB(Translation Lookaside Buffer)的作用,希望大家能够多学点知识,技多不压身。由上面的地址转换过程可知,当只有一级页表进行地址转换的时候,CPU每次读写数据都需要访问两次内存,第一次是访问内存中的页表,第二次是根据页表找到真正需要读写数据的内存地址;如果使用两级了表,那么CPU每次读写数据都需要访问3此内存,这样岂不是显得非常繁琐且耗费CPU的性能,那有什么更好的解决办法呢?答案是肯定的,为了解决这个问题,TLB便孕育而生。在CPU传出一个虚拟地址时,MMU最先访问TLB,假设TLB中包含可以直接转换此虚拟地址的地址描述符,则会直接使用这个地址描述符检查权限和地址转换,如果TLB中没有这个地址描述符,MMU才会去访问页表并找到地址描述符之后进行权限检查和地址转换,然后再将这个描述符填入到TLB中以便下次使用,实际上TLB并不是很大,那TLB被填满了怎么办呢?如果TLB被填满,则会使用round-robin算法找到一个条目并覆盖此条目。
ioremap函数在arch/arm/include/asm/io.h(linux4.19)中定义如下:
void __iomem *ioremap(resource_size_t res_cookie, size_t size);
#define ioremap ioremap
ioremap函数有两个参数:res_cookie、size 和 一个__iomem类型指针的返回值。
unsigned int ioread8(void __iomem *addr) //读取一个字节(8bit)
unsigned int ioread16(void __iomem *addr) //读取一个字(16bit)
unsigned int ioread32(void __iomem *addr) //读取一个双字(32bit)
void iowrite8(u8 b, void __iomem *addr) //写入一个字节(8bit)
void iowrite16(u16 b, void __iomem *addr) //写入一个字(16bit)
void iowrite32(u32 b, void __iomem *addr) //写入一个双字(32bit)
对于读I/O而言,他们都只有一个__iomem类型指针的参数,指向被映射后的地址,返回值为读取到的数据据;对于写I/O而言他们都有两个参数,第一个为要写入的数据,第二个参数为要写入的地址,返回值为空。与这些函数相似的还有writeb、writew、writel、readb、readw、readl等,在ARM架构下,writex(readx)函数与iowritex(ioreadx)有一些区别,writex(readx)不进行端序的检查,而iowritex(ioreadx)会进行端序的检查。
说了社么多,大家可能还是不太理解,那么我们来举个栗子,比如我们需要操作RGB灯中的蓝色led中的数据寄存器,在51或者STM32当中我们是直接看手册查找对应的寄存器,然后往寄存器相应的位写入数据0或1便可以实现LED的亮灭(假设已配置好了输出模式以及上下拉等)。前面我们在不带linux的环境下也是用的类似的方法,但是当我们在linux环境且开启了MMU之后,我们就要将LED灯引脚对应的数据寄存器(物理地址)映射到程序的虚拟地址空间当中,然后我们就可以像操作寄存器一样去操作我们的虚拟地址啦!其具体代码如下所示。
unsigned long pa_dr = 0x20A8000 + 0x00; //Address: Base address + 0h offset
unsigned int __iomem *va_dr; //定义一个__iomem类型的指针
unsigned int val;
va_dr = ioremap(pa_dr, 4); //将va_dr指针指向映射后的虚拟地址起始处,这段地址大小为4个字节
val = ioread32(va_dr); //读取被映射后虚拟地址的的数据,此地址的数据是实际数据寄存器(物理地址)的数据
val &= ~(0x01 << 19); //将蓝色LED灯引脚对应的位清零
iowrite32(val, va_dr); //把修改后的值重新写入到被映射后的虚拟地址当中,实际是往寄存器中写入了数据
iounmap函数定义如下:
void iounmap(volatile void __iomem *iomem_cookie);
#define iounmap iounmap
iounmap函数只有一个参数iomem_cookie,用于取消ioremap所映射的地址映射。
iounmap(va_dr); //释放掉ioremap映射之后的起始地址(虚拟地址)
led字符设备结构体
struct led_chrdev {
struct cdev dev; //描述一个字符设备的结构体
unsigned int __iomem *va_dr; //数据寄存器虚拟地址指针
unsigned int __iomem *va_gdir; //输入输出方向寄存器虚拟地址指针
unsigned int __iomem *va_iomuxc_mux; //端口复用寄存器虚拟地址指针
unsigned int __iomem *va_ccm_ccgrx; //时钟寄存器虚拟地址指针
unsigned int __iomem *va_iomux_pad; //电气属性寄存器虚拟地址指针
unsigned long pa_dr; //装载数据寄存器(物理地址)的变量
unsigned long pa_gdir; //装载输出方向寄存器(物理地址)的变量
unsigned long pa_iomuxc_mux; //装载端口复用寄存器(物理地址)的变量
unsigned long pa_ccm_ccgrx; //装载时钟寄存器(物理地址)的变量
unsigned long pa_iomux_pad; //装载电气属性寄存器(物理地址)的变量
unsigned int led_pin; //LED的引脚
unsigned int clock_offset; //时钟偏移地址(相对于CCM_CCGRx)
};
static struct led_chrdev led_cdev[DEV_CNT] = {
{
.pa_dr = 0x0209C000,.pa_gdir = 0x0209C004,.pa_iomuxc_mux = 0x20E006C,
.pa_ccm_ccgrx = 0x20C406C,
.pa_iomux_pad = 0x20E02F8,
.led_pin = 4,
.clock_offset = 26
}, //初始化红灯结构体成员变量
{
.pa_dr = 0x20A8000,
.pa_gdir = 0x20A8004,
.pa_iomuxc_mux = 0x20E01E0,
.pa_ccm_ccgrx = 0x20C4074,
.pa_iomux_pad = 0x20E046C,
.led_pin = 20,
.clock_offset = 12
}, //初始化绿灯结构体成员变量
{
.pa_dr = 0x20A8000,
.pa_gdir = 0x20A8004,
.pa_iomuxc_mux = 0x20E01DC,
.pa_ccm_ccgrx = 0x20C4074,
.pa_iomux_pad = 0x20E0468,
.led_pin = 19,
.clock_offset = 12
}, //初始化蓝灯结构体成员变量
};
在上面的代码中我们定义了一个RGB灯的结构体,并且定义且初始化了一个RGB灯的结构体数组,因为我们开发板上面共有3个RGB灯,所以代码中DEV_CNT为3。在初始化结构体的时候我们以“.”+“变量名字”的形式来访问且初始化结构体变量的,初始化结构体变量的时候要以“,”隔开,使用这种方式简单明了,方便管理数据结构中的成员。
内核RGB模块的加载和卸载函数
static __init int led_chrdev_init(void) {
int i = 0; dev_t cur_dev; printk("led chrdev init\n"); alloc_chrdev_region(&devno, 0, DEV_CNT, DEV_NAME); //向动态申请一个设备号
led_chrdev_class = class_create(THIS_MODULE, "led_chrdev"); //创建设备类
for (; i < DEV_CNT; i++) {
cdev_init(&led_cdev[i].dev, &led_chrdev_fops); //绑定led_cdev与led_chrdev_fops
led_cdev[i].dev.owner = THIS_MODULE;
cur_dev = MKDEV(MAJOR(devno), MINOR(devno) + i); //注册设备
cdev_add(&led_cdev[i].dev, cur_dev, 1);
device_create(led_chrdev_class, NULL, cur_dev, NULL, DEV_NAME "%d", i); //创建设备
}
return 0;
}
module_init(led_chrdev_init); //模块加载
static __exit void led_chrdev_exit(void) {
int i;
dev_t cur_dev;
printk("led chrdev exit\n");
for (i = 0; i < DEV_CNT; i++) {
cur_dev = MKDEV(MAJOR(devno), MINOR(devno) + i); //计算出设备号
device_destroy(led_chrdev_class, cur_dev); //删除设备
cdev_del(&led_cdev[i].dev); //注销设备
}
unregister_chrdev_region(devno, DEV_CNT); //释放被占用的设备号
class_destroy(led_chrdev_class); //删除设备类
}
module_exit(led_chrdev_exit); //模块卸载
第一部分为内核RGB模块的加载函数,其主要完成了以下任务:
file_operations中open函数的实现
static int led_chrdev_open(struct inode *inode, struct file *filp) { unsigned int val = 0; /* 通过led_chrdev结构变量中dev成员的地址找到这个结构体变量的首地址 */
struct led_chrdev *led_cdev = (struct led_chrdev *)container_of(inode->i_cdev, struct led_chrdev, dev);
filp->private_data = led_cdev; //把文件的私有数据private_data指向设备结构体led_cdev
printk("open\n"); /* 实现地址映射 */
led_cdev->va_dr = ioremap(led_cdev->pa_dr, 4); //,数据寄存器映射,将led_cdev->va_dr指针指向映射后的虚拟地址起始处,这段地址大小为4个字节
led_cdev->va_gdir = ioremap(led_cdev->pa_gdir, 4); //方向寄存器映射
led_cdev->va_iomuxc_mux = ioremap(led_cdev->pa_iomuxc_mux, 4); //端口复用功能寄存器映射
led_cdev->va_ccm_ccgrx = ioremap(led_cdev->pa_ccm_ccgrx, 4); //时钟控制寄存器映射
led_cdev->va_iomux_pad = ioremap(led_cdev->pa_iomux_pad, 4); //电气属性配置寄存器映射 /* 配置寄存器 */
val = ioread32(led_cdev->va_ccm_ccgrx); //间接读取寄存器中的数据
val &= ~(3 << led_cdev->clock_offset);
val |= (3 << led_cdev->clock_offset); //置位对应的时钟位
iowrite32(val, led_cdev->va_ccm_ccgrx); //重新将数据写入寄存器
iowrite32(5, led_cdev->va_iomuxc_mux); //复用位普通I/O口
iowrite32(0x1F838, led_cdev->va_iomux_pad);
val = ioread32(led_cdev->va_gdir);
val &= ~(1 << led_cdev->led_pin);
val |= (1 << led_cdev->led_pin);
iowrite32(val, led_cdev->va_gdir); //配置位输出模式
val = ioread32(led_cdev->va_dr);
val |= (0x01 << led_cdev->led_pin);
iowrite32(val, led_cdev->va_dr); //输出高电平
return 0;
}
file_operations中open函数的实现函数很重要,下面我们来详细分析一下该函数具体做了哪些工作。
1、container_of()函数:
在Linux驱动编程当中我们会经常和container_of()这个函数打交道,所以特意拿出来和大家分享一下,其实这个函数功能不多,但是如果单靠自己去阅读内核源代码分析,那可能非常难以理解,编写内核源代码的大牛随便两行代码都会让我们看的云深不知处,分析内核源代码需要我们有很好的知识积累以及技术沉淀。下面我简单跟大家讲解一下container_of()函数的大致工作内容,其宏定义实现如下所示:
//caption: container_of()函数
/** * container_of - cast a member of a structure out to the containing structure * * @ptr: the pointer to the member. * @type: the type of the container struct this is embedded in. * @member: the name of the member within the struct. * */ #define container_of(ptr, type, member) ({ \
const typeof( ((type *)0)->member ) *__mptr = (ptr); \
(type *)( (char *)__mptr - offsetof(type,member) );
})
该函数共有三个输入参数,分别是ptr(结构体变量中某个成员的地址)、type(结构体类型)和member(该结构体变量的具体名字),原理其实很简单,就是通过已知类型type的成员member的地址ptr,计算出结构体type的首地址。type的首地址 = ptr - size ,需要注意的是它们的大小都是以字节为单位计算的,container_of()函数的如下:
下面我们接着分析一下file_operations中write函数的实现:
//caption: file_operations中write函数的实现
static ssize_t led_chrdev_write(struct file *filp, const char __user * buf, size_t count, loff_t * ppos) {
unsigned long val = 0;
unsigned long ret = 0;
int tmp = count;
kstrtoul_from_user(buf, tmp, 10, &ret); //将用户空间缓存区复制到内核空间
struct led_chrdev *led_cdev = (struct led_chrdev *)filp->private_data; //将文件的私有数据地址赋给led_cdev结构体指针
val = ioread32(led_cdev->va_dr); //间接读取数据寄存器中的数据
if (ret == 0)
val &= ~(0x01 << led_cdev->led_pin); //点亮LED
else
val |= (0x01 << led_cdev->led_pin); //熄灭LED
iowrite32(val, led_cdev->va_dr); //将数据重新写入寄存器中
*ppos += tmp;
return tmp;
}
1、kstrtoul_from_user()函数:
再分析该函数之前,我们先分析一下内核中提供的kstrtoul()函数,理解kstrtoul()函数之后再分析kstrtoul_from_user()就信手拈来了。kstrtoul()在linux4.19的include/linux/kernel.h中有如下定义。
//caption: kstrtoul()函数解析
/** * kstrtoul - convert a string to an unsigned long
* @s: The start of the string. The string must be null-terminated, and may also
* * include a single newline before its terminating null. The first character
* * may also be a plus sign, but not a minus sign.
* * @base: The number base to use. The maximum supported base is 16. If base is
* * given as 0, then the base of the string is automatically detected with the
* * conventional semantics - If it begins with 0x the number will be parsed as a
* * hexadecimal (case insensitive), if it otherwise begins with 0, it will be
* * parsed as an octal number. Otherwise it will be parsed as a decimal. * @res: Where to write the result of the conversion on success.
* *
* * Returns 0 on success, -ERANGE on overflow and -EINVAL on parsing error. * Used as a replacement for the obsolete simple_strtoull. Return code must * be checked. */
static inline int __must_check kstrtoul(const char *s, unsigned int base, unsigned long *res) {
/* * We want to shortcut function call, but * __builtin_types_compatible_p(unsigned long, unsigned long long) = 0. */
if (sizeof(unsigned long) == sizeof(unsigned long long) && __alignof__(unsigned long) == __alignof__(unsigned long long)) return kstrtoull(s, base, (unsigned long long *)res);
else
return _kstrtoul(s, base, res);
}
该函数的功能是将一个字符串转换成一个无符号长整型的数据,它一共有三个参数,各个参数详细描述如下:
// kstrtoul_from_user()函数
int __must_check kstrtoul_from_user(const char __user *s, size_t count, unsigned int base, unsigned long *res);
该函数相比kstrtoul()多了一个参数count,count为要转换数据的大小,因为用户空间是不可以直接访问内核空间的,所以内核提供了kstrtoul_from_user()函数以实现用户缓冲区到内核缓冲区的拷贝,与之相似的还有copy_to_user(),copy_to_user()完成的是内核空间缓冲区到用户空io间的拷贝。如果你使用的内存类型没那么复杂,便可以选择使用put_user()或者get_user()函数。
最后我们再回到file_operations中write函数的实现中的第九行代码,该代码我们在前面已经说过了,就是将在open函数实现中存储在文件的私有数据重新拿出来用而已,后面10~15行代码便是根据文件的私有数据来进行I/O读写访问的。
最后分析一下file_operations中release函数的实现:
// file_operations中release函数的实现
static int led_chrdev_release(struct inode *inode, struct file *filp) { struct led_chrdev *led_cdev = (struct led_chrdev *)container_of(inode->i_cdev, struct led_chrdev, dev); //将文件的私有数据地址赋给led_cdev结构体指针 /* 释放ioremap后的虚拟地址空间 */
iounmap(led_cdev->va_dr); //释放数据寄存器虚拟地址
iounmap(led_cdev->va_gdir); //释放输入输出方向寄存器虚拟地址
iounmap(led_cdev->va_iomuxc_mux); //释放I/O复用寄存器虚拟地址
iounmap(led_cdev->va_ccm_ccgrx); //释放时钟控制寄存器虚拟地址
iounmap(led_cdev->va_iomux_pad); //释放端口电气属性寄存器虚拟地址
return 0;
}
最后一个打开设备的用户进程执行close()系统调用的时候,内核将调用驱动程序release()函数,release函数的主要任务是清理未结束的输入输出操作,释放资源,用户自定义排他标志的复位等。前面我们用ioremap()将物理地址空间映射到了虚拟地址空间,当我们使用完该虚拟地址空间时应该记得使用iounmap()函数将它释放掉。
驱动Makefile
obj-m := led_cdev.o
NATIVE ?= true
ifeq ($(NATIVE), false)
KERNEL_DIR = /home/book/embedfire/imx6ull/linuxcore/ebf-buster-linux-master
else
KERNEL_DIR = /lib/modules/$(shell uname -r)/build
endif
all:modules
modules clean:
$(MAKE) -C $(KERNEL_DIR) M=$(shell pwd) $@
驱动程序和应用程序编译命令如下所示:
驱动编译命令:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
应用程序编译命令:
arm-linux-gnueabihf-gcc <源文件名> –o <输出文件名>
进入要加载的.ko文件目录并查看/dev目录下已存在的模块,确认是否重复,如下所示。
执行下面的命令加载驱动:
命令:
insmod led_cdev.ko
在驱动程序中,我们在.probe函数中注册字符设备并创建了设备文件,设备和驱动匹配成功后.probe函数已经执行,所以正常情况下在“/dev/”目录下已经生成了“led_chrdev0”、“led_chrdev1”、“led_chrdev2”三个设备节点,如下所示。
驱动加载成功后直接运行应用程序如下所示。
命令:
./test_ledcdevApp <设备路径> <命令>
执行结果如下:
运行完命令后我们便会看到绿色LED灯被成功点亮了,如下图所示。