5. Linux字符设备驱动——LED灯实验¶
通过字符设备章节的学习,我们已经了解了字符设备驱动程序的基本框架,主要是掌握如何申请及释放设备号、 添加以及注销设备,初始化、添加与删除cdev结构体,并通过cdev_init函数建立cdev和file_operations之间的关联, cdev结构体和file_operations结构体非常重要,希望大家着重掌握。
本小节我们将带领大家做一个激动人心的小实验——点亮led,学习一下如何在linux环境下驱动LED灯。
首先我们需要明白直接操作寄存器和通过驱动程序点亮LED有什么区别。
5.1. 设备驱动的作用与本质¶
直接操作寄存器点亮LED和通过驱动程序点亮LED最本质的区别就是有无使用操作系统。 有操作系统的存在则大大降低了应用软件与硬件平台的耦合度,它充当了我们硬件与应用软件之间的纽带, 使得应用软件只需要调用驱动程序接口API就可以让硬件去完成要求的开发,而应用软件则不需要关心硬件到底是如何工作的, 这将大大提高我们应用程序的可移植性和开发效率。
5.1.1. 驱动的作用¶
设备驱动与底层硬件直接打交道,按照硬件设备的具体工作方式读写设备寄存器, 完成设备的轮询、中断处理、DMA通信,进行物理内存向虚拟内存的映射,最终使通信设备能够收发数据, 使显示设备能够显示文字和画面,使存储设备能够记录文件和数据。
在系统中没有操作系统的情况下,工程师可以根据硬件设备的特点自行定义接口,如对LED定义LightOn()、LightOff()等。 而在有操作系统的情况下,设备驱动的架构则由相应的操作系统定义,驱动工程师必须按照相应的架构设计设备驱动, 如在本次实验中必须设计file_operations的接口。这样,设备驱动才能良好地整合到操作系统的内核中。
5.1.2. 有无操作系统的区别¶
无操作系统(即裸机)时的设备驱动
也就是直接操作寄存器的方式控制硬件,在这样的系统中,虽然不存在操作系统,但是设备驱动是必须存在的。 一般情况下,对每一种设备驱动都会定义为一个软件模块,包含.h文件和.c文件,前者定义该设备驱动的数据结构并声明外部函数, 后者进行设备驱动的具体实现。其他模块需要使用这个设备的时候,只需要包含设备驱动的头文件然后调用其中的外部接口函数即可。 这在STM32的开发中很常见,也相对比较简单。
有操作系统时的设备驱动
反观有操作系统时,首先,驱动硬件工作的的部分仍然是必不可少的,其次,我们还需要将设备驱动融入内核。 为了实现这种融合,必须在所有的设备驱动中设计面向操作系统内核的接口,这样的接口由操作系统规定,对一类设备而言结构一致,独立于具体的设备。
由此可见,当系统中存在操作系统的时候,设备驱动变成了连接硬件和内核的桥梁。操作系统的存在势必要求设备驱动附加更多的代码和功能, 把单一的驱动变成了操作系统内与硬件交互的模块,它对外呈现为操作系统的API。
操作系统的存在究竟带来了什么好处呢?
首先操作系统完成了多任务并发; 其次操作系统为我们提供了内存管理机制,32位Linux操作系统可以让每个进程都能独立访问4GB的内存空间; 对于应用程序来说,应用程序将可使用统一的系统调用接口来访问各种设备, 通过write()、read()等函数读写文件就可以访问各种字符设备和块设备,而不用管设备的具体类型和工作方式。
5.2. 内存管理单元MMU¶
在linux环境直接访问物理内存是很危险的,如果用户不小心修改了内存中的数据,很有可能造成错误甚至系统崩溃。 为了解决这些问题内核便引入了MMU,
5.2.1. MMU的功能¶
MMU为编程提供了方便统一的内存空间抽象,其实我们的程序中所写的变量地址是虚拟内存当中的地址, 倘若处理器想要访问这个地址的时候,MMU便会将此虚拟地址(Virtual Address)翻译成实际的物理地址(Physical Address), 之后处理器才去操作实际的物理地址。MMU是一个实际的硬件,并不是一个软件程序。他的主要作用是将虚拟地址翻译成真实的物理地址同时管理和保护内存, 不同的进程有各自的虚拟地址空间,某个进程中的程序不能修改另外一个进程所使用的物理地址,以此使得进程之间互不干扰,相互隔离。 而且我们可以使用虚拟地址空间的一段连续的地址去访问物理内存当中零散的大内存缓冲区。很多实时操作系统都可以运行在无MMU的CPU中, 比如uCOS、FreeRTOS、uCLinux,以前想CPU也运行linux系统必须要该CPU具备MMU,但现在Linux也可以在不带MMU的CPU中运行了。 总体而言MMU具有如下功能:
保护内存: MMU给一些指定的内存块设置了读、写以及可执行的权限,这些权限存储在页表当中,MMU会检查CPU当前所处的是特权模式还是用户模式,如果和操作系统所设置的权限匹配则可以访问,如果CPU要访问一段虚拟地址,则将虚拟地址转换成物理地址,否则将产生异常,防止内存被恶意地修改。
提供方便统一的内存空间抽象,实现虚拟地址到物理地址的转换: CPU可以运行在虚拟的内存当中,虚拟内存一般要比实际的物理内存大很多,使得CPU可以运行比较大的应用程序。
到底什么是虚拟地址什么是物理地址?
当没有启用MMU的时候,CPU在读取指令或者访问内存时便会将地址直接输出到芯片的引脚上,此地址直接被内存接收,这段地址称为物理地址, 如下图所示。
简单地说,物理地址就是内存单元的绝对地址,好比你电脑上插着一张8G的内存条,则第一个存储单元便是物理地址0x0000, 内存条的第6个存储单元便是0x0005,无论处理器怎样处理,物理地址都是它最终的访问的目标。
当CPU开启了MMU时,CPU发出的地址将被送入到MMU,被送入到MMU的这段地址称为虚拟地址, 之后MMU会根据去访问页表地址寄存器然后去内存中找到页表(假设只有一级页表)的条目,从而翻译出实际的物理地址, 如下图所示。
对于32位处理器而言,其虚拟地址空间共有4G(2^32),一旦CPU开启了MMU, 任何时候CPU发出的地址都是虚拟地址,为了实现虚拟地址到物理地址之间的映射, MMU内部有一个专门存放页表的页表地址寄存器,该寄存器存放着页表的具体位置, 用ioremap映射一段地址意味着使用户空间的一段地址关联到设备内存上, 这使得只要程序在被分配的虚拟地址范围内进行读写操作,实际上就是对设备(寄存器)的访问。
5.2.2. TLB的作用¶
讲到MMU我又忍不住和大家说下TLB(Translation Lookaside Buffer)的作用。 由上面的地址转换过程可知,当只有一级页表进行地址转换的时候,CPU每次读写数据都需要访问两次内存, 第一次是访问内存中的页表,第二次是根据页表找到真正需要读写数据的内存地址; 如果使用两级了表,那么CPU每次读写数据都需要访问3次内存,这样岂不是显得非常繁琐且耗费CPU的性能,
那有什么更好的解决办法呢?答案是肯定的,为了解决这个问题,TLB便孕育而生。 在CPU传出一个虚拟地址时,MMU最先访问TLB,假设TLB中包含可以直接转换此虚拟地址的地址描述符, 则会直接使用这个地址描述符检查权限和地址转换,如果TLB中没有这个地址描述符, MMU才会去访问页表并找到地址描述符之后进行权限检查和地址转换, 然后再将这个描述符填入到TLB中以便下次使用,实际上TLB并不是很大, 那TLB被填满了怎么办呢?如果TLB被填满,则会使用round-robin算法找到一个条目并覆盖此条目。
由于MMU非常复杂,在此我们不做过于深入的了解,大家只要大概知道它的作用即可, 感兴趣的同学可以到网上查阅相关资料,对于初学者,还是建议先掌握全局,然后再深挖其中重要的细节, 千万不能在汪洋大海中迷失了方向。本小结我们主要用到的是MMU的地址转换功能,在linux环境中, 我们开启了MMU之后想要读写具体的寄存器(物理地址),就必须用到物理地址到虚拟地址的转换函数。
5.3. 地址转换函数¶
上面提到了物理地址到虚拟地址的转换函数。包括ioremap()地址映射和取消地址映射iounmap()函数。
5.3.1. ioremap函数¶
1 2 | void __iomem *ioremap(phys_addr_t paddr, unsigned long size)
#define ioremap ioremap
|
函数参数和返回值如下:
参数:
paddr: 被映射的IO起始地址(物理地址);
size: 需要映射的空间大小,以字节为单位;
返回值: 一个指向__iomem类型的指针,当映射成功后便返回一段虚拟地址空间的起始地址,我们可以通过访问这段虚拟地址来实现实际物理地址的读写操作。
ioremap函数是依靠__ioremap函数来实现的,只是在__ioremap当中其最后一个要映射的I/O空间和权限有关的标志flag为0。 在使用ioremap函数将物理地址转换成虚拟地址之后,理论上我们便可以直接读写I/O内存,但是为了符合驱动的跨平台以及可移植性, 我们应该使用linux中指定的函数(如:iowrite8()、iowrite16()、iowrite32()、ioread8()、ioread16()、ioread32()等)去读写I/O内存, 而非直接通过映射后的指向虚拟地址的指针进行访问。读写I/O内存的函数如下:
1 2 3 4 5 6 7 | unsigned int ioread8(void __iomem *addr)
unsigned int ioread16(void __iomem *addr)
unsigned int ioread32(void __iomem *addr)
void iowrite8(u8 b, void __iomem *addr)
void iowrite16(u16 b, void __iomem *addr)
void iowrite32(u32 b, void __iomem *addr)
|
第1行:读取一个字节(8bit)
第2行:读取一个字(16bit)
第3行:读取一个双字(32bit)
第5行:写入一个字节(8bit)
第6行:写入一个字(16bit)
第7行:写入一个双字(32bit)
对于读I/O而言,他们都只有一个__iomem类型指针的参数,指向被映射后的地址,返回值为读取到的数据据; 对于写I/O而言他们都有两个参数,第一个为要写入的数据,第二个参数为要写入的地址,返回值为空。 与这些函数相似的还有writeb、writew、writel、readb、readw、readl等, 在ARM架构下,writex(readx)函数与iowritex(ioreadx)有一些区别, writex(readx)不进行端序的检查,而iowritex(ioreadx)会进行端序的检查。
说了这么多,大家可能还是不太理解,那么我们来举个例子,比如我们需要操作板卡上的系统LED灯中的数据寄存器, 在51或者STM32当中我们是直接看手册查找对应的寄存器,然后往寄存器相应的位写入数据0或1便可以实现LED的亮灭(假设已配置好了输出模式以及上下拉等)。 当我们在linux环境且开启了MMU之后,我们就要将LED灯引脚对应的数据寄存器(物理地址)映射到程序的虚拟地址空间当中, 然后我们就可以像操作寄存器一样去操作我们的虚拟地址,其具体代码如下所示。
1 2 3 4 5 6 7 | #define GPIO0_BASE (0xFDD60000)
#define GPIO0_DR (GPIO0_BASE+0x0000)
va_dr = ioremap(GPIO0_DR, 4); // 映射物理地址到虚拟地址:GPIO0的数据寄存器
val = ioread32(va_dr);
val |= (0x00400000); // 设置GPIO0_A6引脚低电平
writel(val, va_dr);
|
第1-2行:定义寄存器物理地址
第4行:将物理地址GPIO0_DR,映射给虚拟地址指针,这段地址大小为4个字节
第5-6行:读取该地址的值,保存到临时变量,重新赋值
第7行:把值重新写入到被映射后的虚拟地址当中,实际是往寄存器中写入了数据
5.4. 点亮LED灯实验¶
从前面几章内核模块再到字符设备驱动,从理论到实验,总算是一切准备就绪,让我们开始着手写LED的驱动代码吧。 首先我们需要一个LED字符设备结构体,它应该包含我们要操作的寄存器地址。 其次是模块的加载卸载函数,加载函数需要注册设备,卸载函数则需要释放申请的资源。 然后就是file_operations结构体以及open,write,read相关接口的实现。
5.4.1. 实验说明¶
5.4.1.1. 硬件介绍¶
本节实验使用到lubancat_RK系列板上的系统LED灯或者自行在40pin上外接led。
5.4.1.2. 硬件原理图分析¶
LubanCat系列板卡,引出的led引脚可能不同,可打开相应板卡的原理图来查看硬件连接,根据不同的引脚查询相应的寄存器地址。 以LubanCat-2为例,板卡只有一个电源状态灯和一个系统心跳灯,其中电源状态灯不是用主控引脚控制,而系统心跳灯查看原理图可知:
LED的阴极连接到rk3568芯片上GPIO0_C7,如上图所示。
提示
RK系列引脚用GPIO编号,复用型引脚分为5组(GPIO0~4),每组里面都有32个复用型引脚,而且又分为4个小组(A、B、C、D),每个小组8个引脚(0~7)。 例如:GPIO0_C7 是GPIO0大组,第3个小组,第8个引脚。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | * 为了方便实验整理得各板卡的系统心跳灯
* RK3528:
* LubanCat-Q1、LubanCat-Q1IO:(GPIO4_B5)
* RK3562:
* LubanCat-1HS:(GPIO3_A7)
* RK3566:
* LubanCat-0、LubanCat-1、LubanCat-1n:(GPIO0_C5)
* LubanCat-1H:(GPIO0_C7)
* LubanCat-1IO:(GPIO0_C0)
* RK3568:
* LubanCat-2、LubanCat-2-V1、LubanCat-2-V2、LubanCat-2H、LubanCat-2N、LubanCat-2N-V2、LubanCat-2N-V3:(GPIO0_C7)
* LubanCat-2-V3:(GPIO1_A4)
* LubanCat-2IO:(GPIO4_D2)
* RK3576:
* LubanCat-3、LubanCat-3-V2、LubanCat-3IO:(GPIO3_C5)
* RK3588S:
* LubanCat-4、LubanCat-4-V1:(GPIO4_B5)
* LubanCat-4IO:(GPIO1_C6)
* RK3588:
* LubanCat-5、LubanCat-5-V2:(GPIO0_D3)
* LubanCat-5IO:(GPIO1_C6)
|
其中某些板卡有多个版本,如鲁班猫2板卡,有LubanCat-2、LubanCat-2-V1、LubanCat-2-V2、LubanCat-2-V3版本,可以从板卡背面丝印确认,如LubanCat-2-V3板卡:
注意
LubanCat-2和LubanCat-2-V3的系统心跳灯引脚是不一样的!教程默认是以LubanCat-2为例,使用LubanCat-2-V3仍需自行修改代码。
对于LED灯的控制进行控制,也就是对上述GPIO的寄存器进行读写操作,可大致分为以下几个步骤:
使能GPIO时钟(默认开启,不用设置)
设置引脚复用为GPIO(复用默认为GPIO,不用配置)
设置引脚属性(上下拉、速率、驱动能力,使用默认配置)
控制GPIO引脚为输出,并输出高低电平
因为GPIO的时钟默认开启,引脚默认复用为GPIO,我们只需要配置GPIO的引脚输入输出模式及电平即可。
5.4.1.3. 对LED灯进行寄存器配置¶
关于RK芯片寄存器的内容,只给大家快速梳理一遍会使用到的寄存器,不会深究各寄存器如何去配置。 这部分的内容和传统的STM32单片机配置类似,大家可以学习野火STM32单片机的相关教程。
提示
RK相关的数据手册和参考手册,在资料网盘都有提供 云盘资料下载 :鲁班猫/5-RockChip官方文档/RK[具体芯片]。
对应的文档为:
Rockchip_RKxxx_TRM_Part1_xxx.pdf
Rockchip_RKxxx_Datasheet_xxx.pdf
如rk3568系列对应的文档为:
Rockchip_RK3568_TRM_Part1_xxx.pdf
Rockchip_RK3568_Datasheet_xxx.pdf
5.4.1.3.1. 引脚复用¶
对于rockchip系类芯片,我们需要通过参考手册以及数据手册来确定引脚的复用功能, 引脚复用相关的信息可以通过数据手册查询。
以GPIO0_C7为例,查询 Rockchip_RK3568_Datasheet 手册可知,该引脚可以复用gpio功能,PWM等功能。如下图所示:
查询 Rockchip_RK3568_TRM_Part1 手册可知,GPIO0组复用功能是在PMU_GRF寄存器,和复用相关的总共8个寄存器,如下图所示:
查询 Rockchip_RK3568_TRM_Part1 手册,PMU_GRF_GPIO0C_IOMUX_H寄存器,如下图所示:
PMU_GRF_GPIO0C_IOMUX_H寄存器总共32位,高16位都是使能位,控制低16位的写使能,低16位对应4个引脚,每个引脚占用4bits(实际是用3位),不同的值引脚复用为不同功能, 如GPIO0_C7引脚,控制该引脚复位功能的[14:12]位,都为0时,复用为GPIO功能,而系统复位默认为0,本实验中可以不进行配置。 如果需要复用为PWM功能就要将[14:12]位设置为010,也就是将第13bit位设置为1,因为高16位控制对应低16位写使能,所以需要将第13+16bit位,也即第29bit位设置为1,从而才能对第13bit位进行写操作。
5.4.1.3.2. 引脚电平¶
通过设置GPIO寄存器设置输入输出、高低电平、中断、抖动等 一些引脚的驱动能力,电气属性等,主要通过设置General Register Files (GRF)(以GPIO0组为例,详细自行参考Rockchip_RK35xx_TRM_Part1手册):
GPIO_SWPORT_DR_L:低位引脚数据寄存器,设置高低电平。
GPIO_SWPORT_DR_H:高位引脚数据寄存器,设置高低电平。
GPIO_SWPORT_DR_L和GPIO_SWPORT_DR_H寄存器具体如下
以GPIO_SWPORT_DR_L寄存器说明,该寄存器有高16bit和低16bit,高16bit控制低16bit的写使能,低16bit控制GPIO的高低电平,GPIO_SWPORT_DR_H同理。 如果要控制GPIO0_C7的高低电平那么就要写GPIO_SWPORT_DR_H寄存器,因为C7属于GPIO0中A-D组总计64个引脚中高的32引脚范围,所以需要将GPIO_SWPORT_DR_H寄存器的第7bit位和7+16bit位置1。
5.4.1.3.3. 引脚输入输出模式¶
GPIO_SWPORT_DDR_L:低位引脚数据方向寄存器,控制输入或者输出。
GPIO_SWPORT_DDR_H:高位引脚数据方向寄存器,控制输入或者输出。
PIO_SWPORT_DDR_L和GPIO_SWPORT_DDR_H寄存器具体如下
以GPIO_SWPORT_DDR_L寄存器说明,该寄存器有高16bit和低16bit,高16bit控制低16bit的写使能,低16bit控制GPIO的输出方向,GPIO_SWPORT_DDR_H同理。 如果要将GPIO0_C7设置为输出模式那么就要写GPIO_SWPORT_DDR_H寄存器,因为C7属于GPIO0中A-D组总计64个引脚中的高32引脚范围,所以需要将GPIO_SWPORT_DDR_H寄存器的第7bit位和7+16bit位置1。
5.4.1.3.4. 引脚上下拉¶
PMU_GRF_GPIO0X_P是相应GPIO上拉或者下拉的控制寄存器。以GPIO0_C7进行说明,PMU_GRF_GPIO0C_P寄存器具体如下图所示:
PMU_GRF_GPIO0C_P寄存器有高16bit和低16bit,高16bit控制低16bit的写使能,低16bit控制GPIO的上下拉配置。 如果要将GPIO0_C7设置为上拉模式那么就将PMU_GRF_GPIO0C_P寄存器的[15:14]bit位设置为01,也即是第14bit位和14+16bit位置1。
硬件原理以及寄存器配置说明到此为止,更多硬件信息和寄存器配置可以查看原理图和芯片手册。
5.4.2. 代码讲解¶
本章的示例代码目录为: linux_driver/05_chardev_led
5.4.2.1. 定义GPIO寄存器物理地址¶
以GPIO0_C7为例,需要先确定GPIO0的基地址,其余寄存器均在基地址上进行偏移,查询 Rockchip_RK3568_TRM_Part1 手册可知,GPIO0的基地址为0xFDD60000,如下图所示:
确定基地址后,需要确定其余寄存器对于基地址的偏移量,如下图所示:
从上图可知需要设置的寄存器的地址为base+offset,编写代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 | /* 每组 GPIO 有 2 个寄存器,对应 32 个引脚,每个寄存器负责 16 个引脚;
* 一个寄存器 32 位,其中高 16 位都是使能位,低 16 位对应 16 个引脚,每个引脚占用 1 比特位
* 定义 GPIO 寄存器的基地址 */
#define GPIO_BASE (0xFDD60000)
/* 定义 GPIO 数据寄存器低 16 位的地址 */
#define GPIO_DR_L (GPIO_BASE + 0x0000)
/* 定义 GPIO 数据寄存器高 16 位的地址 */
#define GPIO_DR_H (GPIO_BASE + 0x0004)
/* 定义 GPIO 数据方向寄存器低 16 位的地址 */
#define GPIO_DDR_L (GPIO_BASE + 0x0008)
/* 定义 GPIO 数据方向寄存器高 16 位的地址 */
#define GPIO_DDR_H (GPIO_BASE + 0x000C)
|
代码中使用宏定义,定义出了LED灯使用到的GPIO资源物理地址, 在后面需要将这些寄存器物理地址映射到虚拟地址上,供配置使用,其余复用、上下拉等寄存器保持默认即可。
为了方便实验整理得RK各芯片各GPIO寄存器基地址,各RK芯片的数据寄存器和数据方向寄存器高低位基于基地址偏移是一样的,仅列出GPIO寄存器基地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | /*
* 为了方便实验整理得RK各芯片各GPIO寄存器基地址
* RK3528:
* GPIO0:0xff210000
* GPIO1:0xff220000
* GPIO2:0xff230000
* GPIO3:0xff240000
*
* RK3562:
* GPIO0:0xff260000
* GPIO1:0xff620000
* GPIO2:0xff630000
* GPIO3:0xffac0000
* GPIO4:0xffad0000
*
* RK3566/RK3568:
* GPIO0:0xfdd60000
* GPIO1:0xfe740000
* GPIO2:0xfe750000
* GPIO3:0xfe760000
* GPIO4:0xfe770000
*
* RK3588S/RK3588:
* GPIO0:0xfd8a0000
* GPIO1:0xfec20000
* GPIO2:0xfec30000
* GPIO3:0xfec40000
* GPIO4:0xfec50000
*/
|
5.4.2.2. 编写LED字符设备结构体¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | /* 定义 LED 字符设备结构体 */
struct led_chrdev {
/* 字符设备结构体 */
struct cdev dev;
/* 数据寄存器的虚拟地址,用于设置输出的电压 */
unsigned int __iomem *va_dr;
/* 数据方向寄存器的虚拟地址,用于设置输入或者输出 */
unsigned int __iomem *va_ddr;
/* 引脚偏移量 */
unsigned int led_pin;
};
/* 定义 LED 字符设备数组 */
static struct led_chrdev led_cdev[DEV_CNT] = {
{.led_pin = 7}, /* 偏移,此处为GPIO0_C7,高16位的7号引脚 */
};
|
在上面的代码中我们定义了一个led灯的结构体,并且定义且初始化了一个RGB灯的结构体数组, 在初始化结构体的时候我们以“.”+“变量名字”的形式来访问且初始化结构体变量的, 初始化结构体变量的时候要以“,”隔开,使用这种方式简单明了,方便管理数据结构中的成员。
5.4.2.3. 初始化和卸载函数¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | static int __init chrdev_init(void)
{
/* 定义返回值变量 */
int ret = 0;
/* 定义主设备号变量 */
int major;
/* 定义次设备号变量 */
int minor;
/* 用于存储寄存器的值 */
unsigned int val = 0;
/* 打印模块初始化信息 */
printk("chrdev init\n");
/* 映射 GPIO 寄存器 */
/* 映射 GPIO 数据寄存器高 16 位的物理地址到虚拟地址 */
led_cdev[0].va_dr = ioremap(GPIO_DR_H, 4);
/* 映射 GPIO 数据方向寄存器高 16 位的物理地址到虚拟地址 */
led_cdev[0].va_ddr = ioremap(GPIO_DDR_H, 4);
if (!led_cdev[0].va_dr || !led_cdev[0].va_ddr) {
/* 打印映射失败信息 */
printk("fail to ioremap GPIO registers\n");
ret = -ENOMEM;
/* 跳转到错误处理标签 */
goto ioremap_err;
}
/* GPIO初始化 */
/* 设置输出模式 */
val = ioread32(led_cdev[0].va_ddr);
val |= ((unsigned int)0x1 << (led_cdev[0].led_pin + 16));
val |= ((unsigned int)0x1 << (led_cdev[0].led_pin));
iowrite32(val, led_cdev[0].va_ddr);
/* 输出高电平 */
val = ioread32(led_cdev[0].va_dr);
val |= ((unsigned int)0x1 << (led_cdev[0].led_pin + 16));
val |= ((unsigned int)0x1 << (led_cdev[0].led_pin));
iowrite32(val, led_cdev[0].va_dr);
/* 分配设备号 */
ret = alloc_chrdev_region(&devno, 0, DEV_CNT, DEV_NAME);
if (ret < 0) {
/* 打印设备号分配失败信息 */
printk("fail to alloc devno\n");
/* 跳转到错误处理标签 */
goto ioremap_err;
}
/* 获取主设备号 */
major = MAJOR(devno);
/* 获取次设备号 */
minor = MINOR(devno);
/* 打印主设备号和次设备号 */
printk("major=%d, minor=%d\n", major, minor);
/* 初始化字符设备 */
cdev_init(&led_cdev[0].dev, &chardev_led_fops);
led_cdev[0].dev.owner = THIS_MODULE;
/* 添加字符设备 */
ret = cdev_add(&led_cdev[0].dev, devno, DEV_CNT);
if (ret < 0) {
/* 打印字符设备添加失败信息 */
printk("fail to add cdev\n");
/* 跳转到错误处理标签 */
goto add_err;
}
/* 创建设备类 */
class = class_create(THIS_MODULE, DEV_NAME);
if (IS_ERR(class)) {
/* 打印设备类创建失败信息 */
printk("fail to create class\n");
ret = PTR_ERR(class);
/* 跳转到错误处理标签 */
goto class_err;
}
/* 创建设备节点 */
device = device_create(class, NULL, devno, NULL, DEV_NAME);
if (IS_ERR(device)) {
/* 打印设备节点创建失败信息 */
printk("fail to create device\n");
ret = PTR_ERR(device);
/* 跳转到错误处理标签 */
goto device_err;
}
return 0;
device_err:
/* 销毁设备类 */
class_destroy(class);
class_err:
/* 删除字符设备 */
cdev_del(&led_cdev[0].dev);
add_err:
/* 释放设备号 */
unregister_chrdev_region(devno, DEV_CNT);
ioremap_err:
return ret;
}
static void __exit chrdev_exit(void)
{
/* 打印模块退出信息 */
printk("chrdev exit!\r\n");
/* 销毁设备节点 */
device_destroy(class, devno);
/* 销毁设备类 */
class_destroy(class);
/* 删除字符设备 */
cdev_del(&led_cdev[0].dev);
/* 释放设备号 */
unregister_chrdev_region(devno, DEV_CNT);
/* 释放 GPIO 数据寄存器的映射 */
iounmap(led_cdev[0].va_dr);
/* 释放 GPIO 数据方向寄存器的映射 */
iounmap(led_cdev[0].va_ddr);
}
module_init(chrdev_init);
module_exit(chrdev_exit);
|
需要注意 ,第16行和第17行的映射GPIO寄存器,因为此处使用的引脚是GPIO0_C7, 其中A、B端口的引脚为低16位引脚,C、D端口的引脚为高16位引脚,所以例程映射使用的是GPIO_DR_H、GPIO_DDR_H。 如果是低16位引脚映射需使用GPIO_DR_L、GPIO_DDR_L。
第30-39行:GPIO初始化部分:
ioread32/iowrite32:内核中操作硬件寄存器的安全函数,用于读写32位内存映射的IO地址,避免直接操作物理地址导致内核崩溃;
寄存器位操作逻辑:val |= ((unsigned int)0x1 << (led_cdev->led_pin + 16)) 表示“将寄存器的led_pin+16位设为1”,其中+16是硬件设计的高16位使能区。
5.4.2.4. file_operations结构体成员函数的实现¶
1 2 3 4 5 6 7 8 9 10 11 12 | static int chardev_led_open(struct inode *inode, struct file *filp)
{
/* 通过 inode 中的 i_cdev 指针找到对应的 led_chrdev 结构体 */
struct led_chrdev *led_cdev = container_of(inode->i_cdev, struct led_chrdev, dev);
/* 将 led_chrdev 结构体指针存储到文件的私有数据中 */
filp->private_data = led_cdev;
/* 打印设备打开信息 */
printk("chardev_led open\r\n");
return 0;
}
|
第4行:通过container_of把内核的cdev和自定义的led_chrdev绑定,拿到硬件配置信息;
第6行:把硬件配置信息存到filp->private_data,让后续的write/read操作能直接复用,避免重复查找。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | static ssize_t chardev_led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos)
{
/* 定义临时变量,用于存储寄存器的值 */
unsigned long val = 0;
/* 定义临时变量,用于存储从用户空间读取的数据 */
char ret = 0;
/* 从文件的私有数据中获取 led_chrdev 结构体指针 */
struct led_chrdev *led_cdev = filp->private_data;
/* 打印设备写操作信息 */
printk("chardev_led write \r\n");
/* 从用户空间读取一个字符 */
get_user(ret, buf);
/* 读取数据寄存器的值 */
val = ioread32(led_cdev->va_dr);
if (ret == '0') {
/* 设置高 16 位的使能位 */
val |= ((unsigned int)0x1 << (led_cdev->led_pin + 16));
/* 设置低 16 位的对应引脚输出低电平 */
val &= ~((unsigned int)0x01 << (led_cdev->led_pin));
} else {
/* 设置高 16 位的使能位 */
val |= ((unsigned int)0x1 << (led_cdev->led_pin + 16));
/* 设置低 16 位的对应引脚输出高电平 */
val |= ((unsigned int)0x01 << (led_cdev->led_pin));
}
/* 将修改后的值写回到数据寄存器 */
iowrite32(val, led_cdev->va_dr);
return count;
}
|
get_user函数作用:相比copy_from_user函数,get_user函数专为单字符/单数据读取设计,更轻量,此处仅需读取1个控制字符(’0’/’1’),无需使用批量拷贝函数;
寄存器位操作逻辑:val &= ~((unsigned int)0x01 << led_cdev->led_pin):对低16位目标引脚位“清 0”,实现低电平。
返回值count:函数返回count而非固定值,是内核驱动的规范写法——用户态程序(如write(fd, “0”, 1))会根据返回值判断实际写入的字节数,返回count表示“请求的字节数已处理完成”。
1 2 3 4 5 | static int chardev_led_release(struct inode *inode, struct file *filp)
{
printk("chardev_led release\r\n");
return 0;
}
|
当最后一个打开设备的用户进程执行close()系统调用的时候,内核将调用驱动程序release()函数, release函数的主要任务是清理未结束的输入输出操作,释放资源,用户自定义排他标志的复位等。 前面我们用ioremap()将物理地址空间映射到了虚拟地址空间,当我们使用完该虚拟地址空间时需要使用iounmap()函数将它释放掉。 不过我们在驱动模块退出的时候才进行释放,这里我们不做操作。
5.4.2.5. 修改为其他引脚方法¶
如果需要修改其他引脚只需要修改以下部分即可,以3588系列的鲁班猫4为例,心跳灯为GPIO4_B5,查询 Rockchip_RK3588_TRM_Part1 手册确认GPIO4的基地址以及地址偏移,修改代码部分如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | ......
/* 定义 GPIO 寄存器的基地址 */
#define GPIO_BASE (0xFEC50000) //GPIO4的基地址
......
static struct led_chrdev led_cdev[DEV_CNT] = {
{.led_pin = 13}, // 偏移,GPIO4_B5,低16位引脚,偏移8+5位,即偏移13位
};
......
led_cdev[0].va_dr = ioremap(GPIO_DR_L, 4); // 映射数据寄存器物理地址到虚拟地址,GPIO4_B5位于低16号引脚,需要设置GPIO_DR_L
led_cdev[0].va_ddr = ioremap(GPIO_DDR_L, 4); // 映射数据方向寄存器物理地址到虚拟地址,GPIO4_B5位于低16号引脚,需要设置GPIO_DDR_L
......
|
5.4.3. 实验准备¶
如出现 Permission denied 或类似字样,请注意用户权限,大部分操作硬件外设的功能,几乎都需要root用户权限,简单的解决方案是在执行语句前加入sudo或以root用户运行程序。
5.4.3.1. LED驱动Makefile¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | #指定内核路径,可以是相对路径或绝对路径
KERNEL_DIR=../../kernel/
#KERNEL_DIR=/home/guest/LubanCat_Linux_rk356x_SDK/kernel/
#指定目标架构为arm64
ARCH=arm64
#指定交叉编译工具链的前缀
CROSS_COMPILE=aarch64-linux-gnu-
#导出为环境变量
export ARCH CROSS_COMPILE
#指定要编译的内核模块目标文件
obj-m := chardev_led.o
#all :默认目标,执行时会编译驱动模块
#$(MAKE) :调用make工具
#-C $(KERNEL_DIR) :指定的内核源码目录
#M=$(CURDIR) :模块的源码位于当前目录
#modules :编译模块
all:
$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules
.PHONE:clean
#清理编译生成的文件
clean:
$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean
|
以上Makefile与上一个实验,只有目标文件不同。
5.4.3.2. 编译命令说明¶
在实验目录下输入如下命令来编译驱动模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #进入chardev_led例程源码目录
cd linux_driver/05_chardev_led
#编译驱动模块
make
#信息输出如下
make -C ../../kernel/ M=/home/guest/linux_driver/05_chardev_led modules
make[1]: Entering directory '/home/guest/kernel'
CC [M] /home/guest/linux_driver/05_chardev_led/chardev_led.o
Building modules, stage 2.
MODPOST 1 modules
CC /home/guest/linux_driver/05_chardev_led/chardev_led.mod.o
LD [M] /home/guest/linux_driver/05_chardev_led/chardev_led.ko
make[1]: Leaving directory '/home/guest/kernel'
|
编译成功后,实验目录下会生成名为“chardev_led.ko”的驱动模块文件
5.4.4. 程序运行结果¶
在本节实验中,鲁班猫系列板卡,系统设备树中均默认使能了 LED 的设备功能,需要关闭设备树的leds节点,
可以修改leds节点的 status = "okay"; 为 status = "disabled";,然后编译设备树进行替换,
也可以在板卡中直接使用以下命令关闭系统leds驱动对LED的控制:
1 2 3 4 5 6 7 8 | #心跳灯命名可能为sys_status_led或sys_led,需先确认
ls /sys/class/leds/
#如果为sys_status_led
sudo sh -c 'echo 0 > /sys/class/leds/sys_status_led/brightness'
#如果为sys_led
sudo sh -c 'echo 0 > /sys/class/leds/sys_led/brightness'
|
将led的亮度调为0,与此同时led的触发条件自动变为none,从而取消leds驱动对LED的控制。
将chardev_led.ko文件拷贝到开发板中,然后执行命令加载,如果是在板卡上编译内核模块,直接执行命令加载驱动:
1 2 3 4 5 6 | #加载LED驱动
sudo insmod chardev_led.ko
#信息输出如下
[ 317.717347] chrdev init
[ 317.717538] major=236, minor=0
|
然后我们可以在/dev/目录下找到chardev_led这个设备文件, 可以通过直接给设备写入1/0来控制LED的亮灭,也可以通过我们的测试程序来控制LED。
1 2 3 4 5 6 7 8 9 10 | #绿灯亮
sudo sh -c 'echo 0 >/dev/chardev_led'
#绿灯灭
sudo sh -c 'echo 1 >/dev/chardev_led'
#信息输出如下
[ 442.932014] chardev_led open
[ 442.932165] chardev_led write
[ 442.932198] chardev_led release
|
如果成功可以在板卡上可以看到灯亮灭的情况,如果出现无法控制情况,需要检查GPIO基地址是否正确, led_pin偏移是否正确,映射GPIO寄存器的数据寄存器和数据方向寄存器使用的高位还是低位是否正确。