第五节 字符设备驱动——点亮LED 灯

通过字符设备章节的学习,我们已经了解了字符设备驱动程序的基本框架,主要是掌握如何申请及释放设备号、添加以及注销设备,初始化、添加与删除cdev 结构体,并通过cdev_init 函数建立cdev 和file_operations 之间的关联,cdev 结构体和file_operations 结构体非常重要,希望大家着重掌握。

本小节我们将带领大家做一个激动人心的小实验–点亮led。前面我们已经通过操作寄存器的方式点亮了LED,本节我们将带领大家进入点亮开发板RGB LED 灯的世界,学习一下如何在linux环境下驱动RGB LED 灯。

首先我们需要明白直接操作寄存器和通过驱动程序点亮LED 有什么区别。

设备驱动的作用与本质

直接操作寄存器点亮LED 和通过驱动程序点亮LED 最本质的区别就是有无使用操作系统。有操作系统的存在则大大降低了应用软件与硬件平台的耦合度,它充当了我们硬件与应用软件之间的纽带,使得应用软件只需要调用驱动程序接口API 就可以让硬件去完成要求的开发,而应用软件则不需要关心硬件到底是如何工作的。这将大大提高我们应用程序的可移植性和开发效率。

驱动的作用

设备驱动与底层硬件直接打交道,按照硬件设备的具体工作方式读写设备寄存器,完成设备的轮询、中断处理、DMA 通信,进行物理内存向虚拟内存的映射,最终使通信设备能够收发数据,使显示设备能够显示文字和画面,使存储设备能够记录文件和数据。

在系统中没有操作系统的情况下,工程师可以根据硬件设备的特点自行定义接口,如对LED 定义LightOn()、LightOff() 等。而在有操作系统的情况下,设备驱动的架构则由相应的操作系统定义,驱动工程师必须按照相应的架构设计设备驱动,如在本次实验中必须设计file_operations 的接口。这样,设备驱动才能良好地整合到操作系统的内核中。

有无操作系统的区别

  1. 无操作系统(即裸机)时的设备驱动也就是直接操作寄存器的方式控制硬件,在这样的系统中,虽然不存在操作系统,但是设备驱动是必须存在的。一般情况下,对每一种设备驱动都会定义为一个软件模块,包含.h 文件和.c 文件,前者定义该设备驱动的数据结构并声明外部函数,后者进行设备驱动的具体实现。其他模块需要使用这个设备的时候,只需要包含设备驱动的头文件然后调用其中的外部接口函数即可。这在STM32 的开发中很常见,也相对比较简单。

  2. 有操作系统时的设备驱动反观有操作系统时,首先,驱动硬件工作的的部分仍然是必不可少的,其次,我们还需要将设备驱动融入内核。为了实现这种融合,必须在所有的设备驱动中设计面向操作系统内核的接口,这样的接口由操作系统规定,对一类设备而言结构一致,独立于具体的设备。

由此可见,当系统中存在操作系统的时候,设备驱动变成了连接硬件和内核的桥梁。操作系统的存在势必要求设备驱动附加更多的代码和功能,把单一的驱动变成了操作系统内与硬件交互的模块,它对外呈现为操作系统的API。

操作系统的存在究竟带来了什么好处呢?

首先操作系统完成了多任务并发;其次操作系统为我们提供了内存管理机制,32 位Linux 操作系统可以让每个进程都能独立访问4GB 的内存空间;对于应用程序来说,应用程序将可使用统一的系统调用接口来访问各种设备,通过write()、read() 等函数读写文件就可以访问各种字符设备和块设备,而不用管设备的具体类型和工作方式。

内存管理单元MMU

在linux 环境直接访问物理内存是很危险的,如果用户不小心修改了内存中的数据,很有可能造成错误甚至系统崩溃。为了解决这些问题内核便引入了MMU

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 会根据去访问页表地址寄存器然后去内存中找到页表(假设只有一级页表)的条目,从而翻译出实际的物理地址,如下图所示。

在这里插入图片描述

对于I.MX 6ULL 这种32 位处理器而言,其虚拟地址空间共有4G(2^32), 一旦CPU 开启了MMU,任何时候CPU 发出的地址都是虚拟地址,为了实现虚拟地址到物理地址之间的映射,MMU 内部有一个专门存放页表的页表地址寄存器,该寄存器存放着页表的具体位置,用ioremap 映射一段地址意味着使用户空间的一段地址关联到设备内存上,这使得只要程序在被分配的虚拟地址范围内进行读写操作,实际上就是对设备(寄存器)的访问。

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 之后想要读写具体的寄存器(物理地址),就必须用到物理地址到虚拟地址的转换函数。

地址转换函数

上面提到了物理地址到虚拟地址的转换函数。包括ioremap() 地址映射和取消地址映射iounmap()函数。

ioremap 函数

列表1: 地址映射函数(内核源码/arch/arc/mm/ioremap.c)

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 内存的函数如下:

列表2: 读写I/O 函数

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)会进行端序的检查。

说了这么多,大家可能还是不太理解,那么我们来举个栗子,比如我们需要操作RGB 灯中的蓝色led 中的数据寄存器,在51 或者STM32 当中我们是直接看手册查找对应的寄存器,然后往寄存器相应的位写入数据0 或1 便可以实现LED 的亮灭(假设已配置好了输出模式以及上下拉等)。前面我们在不带linux 的环境下也是用的类似的方法,但是当我们在linux 环境且开启了MMU 之后,我们就要将LED 灯引脚对应的数据寄存器(物理地址)映射到程序的虚拟地址空间当中,然后我们就可以像操作寄存器一样去操作我们的虚拟地址啦!其具体代码如下所示。

列表3: 地址映射

#define AHB4_PERIPH_BASE (0x50000000)
#define RCC_BASE (AHB4_PERIPH_BASE + 0x0000)
#define RCC_MP_GPIOENA (RCC_BASE + 0XA28)

va_clkaddr = ioremap(RCC_MP_GPIOENA, 4);// 映射物理地址到虚拟地址:gpio 时钟rcc寄存器
val |= (0x43); // 开启a、b、g 的时钟
writel(val, va_clkaddr);
  • 第1-3 行:定义寄存器物理地址
  • 第5 行:将物理地址RCC_MP_GPIOENA,映射给虚拟地址指针,这段地址大小为4 个字节
  • 第6 行:将要写入的值,写入一个临时变量
  • 第7 行:把值重新写入到被映射后的虚拟地址当中,实际是往寄存器中写入了数据

iounmap 函数

iounmap 函数定义如下:

列表4: 取消地址映射函数(内核源码/arch/arc/mm/ioremap.c)

void iounmap(void *addr)
#define iounmap iounmap

函数参数和返回值如下:

参数:

  • addr:需要取消ioremap 映射之后的起始地址(虚拟地址)。

返回值:无

例如我们要取消一段被ioremap 映射后的地址可以用下面的写法。

列表5: 取消ioremap 映射地址:linenos:

iounmap(va_dr); //释放掉ioremap 映射之后的起始地址(虚拟地址)

点亮LED 灯实验

从第一章内核模块再到第二章字符设备驱动,从理论到实验,总算是一切准备就绪,让我们开始着手写LED 的驱动代码吧。首先我们需要一个LED 字符设备结构体,它应该包含我们要操作的寄存器地址。其次是模块的加载卸载函数,加载函数需要注册设备,卸载函数则需要释放申请的资源。然后就是file_operations 结构体以及open,write,read 相关接口的实现。

实验说明

硬件介绍

本节实验使用到STM32MP1 开发板上的RGB 彩灯。

硬件原理图分析

了解RGB 灯的实物后,可打开相应的原理图文档来查看硬件连接,具体见下图。
在这里插入图片描述
在这里插入图片描述
LED_R 的阴极连接到STM32MP1 芯片上GPIO_A13 引脚,LED_G 连接到GPIO_G2,LED_B 连接到GPIO_B5,如下表所示。

LED 灯原理图的标号GPIO 端口及引脚编号
LED 红灯LED_RGPIO_A13
LED 绿灯LED_GGPIO_G2
LED 蓝灯LED_BGPIO_B5

对于RGB 灯的控制进行控制,也就是对上述GPIO 的寄存器进行读写操作。可大致分为以下几个步骤:

  • 使能GPIO 时钟
  • 设置引脚复用为GPIO(本节不用)
  • 设置引脚属性(上下拉、速率、驱动能力)
  • 控制GPIO 引脚输出高低电平

对RGB 的R 灯进行寄存器配置

GPIO 时钟

跟GPIO 相关的时钟主要有Enable For MCU\MPU Set\Clear Register 寄存器。

查看数据手册P872(参考位置) 可以知道GPIO 控制引脚时钟通过四个寄存器来控制GPIO 的时钟。

由于STM32MP157 为异构处理器,所以GPIO 的时钟控制分为MCU 及MPU 两类寄存器,这两类寄存器中每类对时钟的控制,又分为使能时钟控制寄存器和失能时钟控制寄存器。

在这里插入图片描述
对使能时钟控制寄存器和失能时钟控制寄存器对应的位置1 时,设置对应外设的功能:

在这里插入图片描述

引脚复用GPIO

对于STM32MP1 系类芯片,我们需要通过参考手册以及数据手册来确定引脚的复用功能,引脚复用相关的信息可以通过数据手册查询:

在这里插入图片描述
复用寄存器GPIOx_AFRL(GPIO alternate function low register) 的内容,我们可以在参考手册中查看到关于该寄存器的配置,见下图:

在这里插入图片描述

GPIO 外设寄存器地址为0x50002000,加上对应的偏移,即可访问到复用配置寄存器。通过配置对应端口的寄存器即可设置对应引脚复用功能。

引脚属性

寄存器总览为GPIO registers

在这里插入图片描述

  • GPIOx_MODER:模式寄存器,用以设置GPIO 引脚的模式,可为:输入模式、输出模式、复用模式、模拟模式。
  • GPIOx_OTYPER:输出类型寄存器,用以设置GPIO 引脚的输出模式,可为:推挽输出、开漏输出。
  • GPIOx_OSPEEDR:速度寄存器,用以设置GPIO 引脚的输出速度等级,可为:低、中、高、非常高。
  • GPIOx_PUPDR:上下拉配置寄存器,用以设置GPIO 引脚的上下拉状态,可为:不上下拉、上拉、下拉。
  • GPIOx_IDR:输入寄存器,用以读取GPIO 引脚的输入状态,可读取为:0、1。
  • GPIOx_ODR:输出寄存器,当IO 用作输出的时候,此寄存器用来设置IO 输出的电平高低。
  • GPIOx_BSRR:置位寄存器,当IO 用作输出的时候,此寄存器也可用来设置IO 输出的电平高低。

硬件原理以及寄存器配置到此为止,更多硬件上的信息可以查看原理图和芯片手册。

代码讲解

本章的示例代码目录为:linux_driver/led_cdev/

定义GPIO 寄存器物理地址

列表6: LED 灯用到的GPIO 资源

#define AHB4_PERIPH_BASE (0x50000000)

#define RCC_BASE (AHB4_PERIPH_BASE + 0x0000)
#define RCC_MP_GPIOENA (RCC_BASE + 0XA28)

#define GPIOA_BASE (AHB4_PERIPH_BASE + 0x2000)
#define GPIOA_MODER (GPIOA_BASE + 0x0000)
#define GPIOA_OTYPER (GPIOA_BASE + 0x0004)
#define GPIOA_OSPEEDR (GPIOA_BASE + 0x0008)
#define GPIOA_PUPDR (GPIOA_BASE + 0x000C)
#define GPIOA_BSRR (GPIOA_BASE + 0x0018)

#define GPIOG_BASE (AHB4_PERIPH_BASE + 0x8000)
#define GPIOG_MODER (GPIOG_BASE + 0x0000)
#define GPIOG_OTYPER (GPIOG_BASE + 0x0004)
#define GPIOG_OSPEEDR (GPIOG_BASE + 0x0008)
#define GPIOG_PUPDR (GPIOG_BASE + 0x000C)
#define GPIOG_BSRR (GPIOG_BASE + 0x0018)

#define GPIOB_BASE (AHB4_PERIPH_BASE + 0x3000)
#define GPIOB_MODER (GPIOB_BASE + 0x0000)
#define GPIOB_OTYPER (GPIOB_BASE + 0x0004)
#define GPIOB_OSPEEDR (GPIOB_BASE + 0x0008)
#define GPIOB_PUPDR (GPIOB_BASE + 0x000C)
#define GPIOB_BSRR (GPIOB_BASE + 0x0018)

代码中使用宏定义,定义出了LED 灯使用到的GPIO 资源物理地址,在后面需要将这些寄存器物理地址映射到虚拟地址上,供配置使用。

编写LED 字符设备结构体且初始化

列表7: led 字符设备结构体

struct led_chrdev {
struct cdev dev;
	unsigned int __iomem *va_moder; // 模式寄存器虚拟地址保存变量
	unsigned int __iomem *va_otyper; // 输出类型寄存器虚拟地址保存变量
	unsigned int __iomem *va_ospeedr; // 速度配置寄存器虚拟地址保存变量
	unsigned int __iomem *va_pupdr; // 上下拉寄存器虚拟地址保存变量
	unsigned int __iomem *va_bsrr; // 置位寄存器虚拟地址保存变量

	unsigned int led_pin; // 引脚偏移
};

unsigned int __iomem *va_clkaddr;

static struct led_chrdev led_cdev[DEV_CNT] = {
	{.led_pin = 13}, // 定义GPIO 引脚号
	{.led_pin = 2},
	{.led_pin = 5},
};

在上面的代码中我们定义了一个RGB 灯的结构体,并且定义且初始化了一个RGB 灯的结构体数组,因为我们开发板上面共有3 个RGB 灯,所以代码中DEV_CNT 为3。在初始化结构体的时候我们以“.”+“变量名字”的形式来访问且初始化结构体变量的,初始化结构体变量的时候要以“,”隔开,使用这种方式简单明了,方便管理数据结构中的成员。

  • 第2 行:定义了保存模式寄存器虚拟地址的变量
  • 第3 行:定义了保存输出类型寄存器虚拟地址的变量
  • 第4 行:定义了保存速度配置寄存器虚拟地址的变量
  • 第5 行:定义了保存上下拉寄存器虚拟地址的变量
  • 第6 行:定义了保存置位寄存器虚拟地址的变量
  • 第9 行:LED 的引脚号
  • 第12 行:定义了保存GPIO 时钟寄存器虚拟地址的变量
  • 第15-17 行:初始化三个LED 灯结构体成员变量

内核RGB 模块的加载和卸载函数

第一部分为内核RGB 模块的加载函数,其主要完成了以下任务:

  • 将LED 结构体里的虚拟地址给映射好,让虚拟地址与GPIO 的物理寄存器地址对应上
  • 调用alloc_chrdev_region() 函数向系统动态申请一个未被占用的设备号, 使用alloc_chrdev_region() 相比较于register_chrdev_region() 的好处在于不必自己费时间去查看那些是未被占用的设备号,避免了设备号重复问题;
  • 调用class_create() 函数创建一个RGB 灯的设备类;
  • 分别给三个LED 建立其对应的字符设备结构体cdev 和led_chrdev_fops 的关联,并且初始化字符设备结构体,最后注册并创建设备。

第二部分为内核RGB 模块的卸载函数,其主要完成了以下任务:

  • 释放LED 结构体里的映射的虚拟地址
  • 调用device_destroy() 函数用于从linux 内核系统设备驱动程序模型中移除一个设备,并删除/sys/devices/virtual 目录下对应的设备目录及/dev/目录下对应的设备文件;
  • 调用cdev_del() 函数来释放散列表中的对象以及cdev 结构本身;
  • 释放被占用的设备号以及删除设备类。

从下面代码中我们可以看出这三个LED 都使用的同一个主设备号,只是他们的次设备号有所区别而已。

列表8: 内核RGB 模块的加载和卸载函数

static __init int led_chrdev_init(void)
{
	int i = 0;
	dev_t cur_dev;
	unsigned int val = 0;

	printk("led chrdev init \n");

	led_cdev[0].va_moder = ioremap(GPIOA_MODER, 4); // 映射模式寄存器物理地址到虚拟地址
	led_cdev[0].va_otyper = ioremap(GPIOA_OTYPER, 4); // 映射输出类型寄存器物理地址到虚拟地址
	led_cdev[0].va_ospeedr = ioremap(GPIOA_OSPEEDR, 4); // 映射速度配置寄存器物理地址到虚拟地址
	led_cdev[0].va_pupdr = ioremap(GPIOA_PUPDR, 4); // 映射上下拉寄存器物理地址到虚拟地址
	led_cdev[0].va_bsrr = ioremap(GPIOA_BSRR, 4); // 映射置位寄存器物理地址到虚拟地址

	led_cdev[1].va_moder = ioremap(GPIOG_MODER, 4);
	led_cdev[1].va_otyper = ioremap(GPIOG_OTYPER, 4);
	led_cdev[1].va_ospeedr = ioremap(GPIOG_OSPEEDR, 4);
	led_cdev[1].va_pupdr = ioremap(GPIOG_PUPDR, 4);
	led_cdev[1].va_bsrr = ioremap(GPIOG_BSRR, 4);

	led_cdev[2].va_moder = ioremap(GPIOB_MODER, 4);
	led_cdev[2].va_otyper = ioremap(GPIOB_OTYPER, 4);
	led_cdev[2].va_ospeedr = ioremap(GPIOB_OSPEEDR, 4);
	led_cdev[2].va_pupdr = ioremap(GPIOB_PUPDR, 4);
	led_cdev[2].va_bsrr = ioremap(GPIOB_BSRR, 4);

	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[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++) {
		iounmap(led_cdev[i].va_moder); // 释放模式寄存器虚拟地址
		iounmap(led_cdev[i].va_otyper); // 释放输出类型寄存器虚拟地址
		iounmap(led_cdev[i].va_ospeedr); // 释放速度配置寄存器虚拟地址
		iounmap(led_cdev[i].va_pupdr); // 释放上下拉寄存器虚拟地址
		iounmap(led_cdev[i].va_bsrr); // 释放置位寄存器虚拟地址
	}

	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);
  • 第9-25 行:初始化LED 灯结构体成员,将物理寄存器的地址映射到虚拟地址空间
  • 第27 行:向动态申请一个设备号
  • 第29 行:创建设备类
  • 第32 行:绑定led_cdev 与led_chrdev_fops
  • 第37 行:注册设备
  • 第39 行:创建设备
  • 第45 行:模块加载
  • 第53-59 行:释放在init 函数中申请的虚拟地址空间
  • 第62 行:计算出设备号
  • 第66 行:删除设备
  • 第69 行:注销设备
  • 第70 行:释放被占用的设备号
  • 第72 行:模块卸载

file_operations 结构体成员函数的实现

列表9: file_operations 中open 函数的实现

static int led_chrdev_open(struct inode *inode, struct file *filp)
{
	unsigned int val = 0;
	struct led_chrdev *led_cdev =
	(struct led_chrdev *)container_of(inode->i_cdev, struct led_chrdev,
	dev);
	filp->private_data =
	container_of(inode->i_cdev, struct led_chrdev, dev);

	printk("open\n");

	val |= (0x43); // 开启a、b、g 的时钟
	writel(val, va_clkaddr);

	// 设置模式寄存器:输出模式
	val = readl(led_cdev->va_moder);
	val &= ~((unsigned int)0X3 << (2 * led_cdev->led_pin));
	val |= ((unsigned int)0X1 << (2 * led_cdev->led_pin));
	writel(val,led_cdev->va_moder);
	// 设置输出类型寄存器:推挽模式
	val = readl(led_cdev->va_otyper);
	val &= ~((unsigned int)0X1 << led_cdev->led_pin);
	writel(val, led_cdev->va_otyper);
	// 设置输出速度寄存器:高速
	val = readl(led_cdev->va_ospeedr);
	val &= ~((unsigned int)0X3 << (2 * led_cdev->led_pin));
	val |= ((unsigned int)0x2 << (2 * led_cdev->led_pin));
	writel(val, led_cdev->va_ospeedr);
	// 设置上下拉寄存器:上拉
	val = readl(led_cdev->va_pupdr);
	val &= ~((unsigned int)0X3 << (2*led_cdev->led_pin));
	val |= ((unsigned int)0x1 << (2*led_cdev->led_pin));
	writel(val,led_cdev->va_pupdr);
	// 设置置位寄存器:默认输出低电平
	val = readl(led_cdev->va_bsrr);
	val |= ((unsigned int)0x1 << (led_cdev->led_pin + 16));
	writel(val, led_cdev->va_bsrr);

	return 0;
}
  • 第4 行:通过led_chrdev 结构变量中dev 成员的地址找到这个结构体变量的首地址
  • 第5 行:把文件的私有数据private_data 指向设备结构体led_cdev
  • 第12-14 行:实现GPIO 时钟寄存器的地址映射
  • 第16-38 行:配置寄存器

file_operations 中open 函数的实现函数很重要,下面我们来详细分析一下该函数具体做了哪些工作。

  1. container_of() 函数:

在这里插入图片描述

在Linux 驱动编程当中我们会经常和container_of() 这个函数打交道,所以特意拿出来和大家分享一下,其实这个函数功能不多,但是如果单靠自己去阅读内核源代码分析,那可能非常难以理解,编写内核源代码的大牛随便两行代码都会让我们看的云深不知处,分析内核源代码需要我们有很好的知识积累以及技术沉淀。下面我简单跟大家讲解一下container_of() 函数的大致工作内容,其宏定义实现如下所示:

列表10: container_of() 函数(位于…/ebf_linux_kernel/driver/gpu/drm/mkregtable.c)

#define container_of(ptr, type, member) ({ \
		const typeof( ((type *)0)->member ) *__mptr = (ptr); \
		(type *)( (char *)__mptr - offsetof(type,member) );})

函数参数和返回值如下:

参数

  • ptr:结构体变量中某个成员的地址
  • type:结构体类型
  • member:该结构体变量的具体名字

返回值:结构体type 的首地址

原理其实很简单,就是通过已知类型type 的成员member 的地址ptr,计算出结构体type 的首地址。type 的首地址= ptr - size ,需要注意的是它们的大小都是以字节为单位计算的,container_of()函数的如下:

  • 判断ptr 与member 是否为同一类型
  • 计算size 大小,结构体的起始地址= (type *)((char *)ptr - size) (注:强转为该结构体指针)

通过此函数我们便可以轻松地获取led_chrdev 结构体的首地址了。

  1. 文件私有数据:

一般很多的linux 驱动都会将文件的私有数据private_data 指向设备结构体,其保存了用户自定义设备结构体的地址。自定义结构体的地址被保存在private_data 后,可以通过读、写等操作通过该私有数据去访问设备结构体中的成员,这样做体现了linux 中面向对象的程序设计思想。

  1. 通过ioremap() 函数实现地址的映射:

其实ioremap() 函数我们之前分析过了,在led_chrdev_open() 函数的作用都是一样的,只是对LED灯所用到的时钟控制寄存器做了地址映射,这样我们便可以通过操作程序中的虚拟地址来间接的控制物理寄存器,我们在驱动程序描述寄存器不利于驱动模块的灵活使用,后几个章节我们会带领大家通过设备树(设备树插件)的方式去描述寄存器及其相关属性,在此先埋下伏笔,循序渐进,顺腾摸瓜,使大家能够真正理解并掌握linux 驱动的精髓。

  1. 通过ioread32() 和iowrite32() 等函数操作寄存器:

和STM32 一样,都要开启I/O 引脚对应的时钟、设置其端口的复用(在此复用为普通的GPIO 口)、电气属性、输入输出方向以及输出的高低电平等等,一般我们访问某个地址时都是先将该地址的数据读取到一个变量中然后修改该变量,最后再将该变量写入到原来的地址当中。注意我们在操作这段被映射后的地址空间时应该使用linux 提供的I/O 访问函数(如:iowrite8()、iowrite16()、iowrite32()、ioread8()、ioread16()、ioread32() 等),这里再强调一遍,即使理论上可以直接操作这段虚拟地址了但是Linux 并不建议这么做。

下面我们接着分析一下file_operations 中write 函数的实现:

列表11: 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;

	struct led_chrdev *led_cdev = (struct led_chrdev *)filp->private_data;

	kstrtoul_from_user(buf, tmp, 10, &ret);

	val = ioread32(led_cdev->va_bsrr);
	if (ret == 0){
		val |= (0x01 << (led_cdev->led_pin+16)); // 设置GPIO 引脚输出低电平
	}
	else{
		val |= (0x01 << led_cdev->led_pin); // 设置GPIO 引脚输出高电平
	}

	iowrite32(val, led_cdev->va_bsrr);
	*ppos += tmp;
	return tmp;
}
  • 第9 行:文件的私有数据地址赋给led_cdev 结构体指针
  • 第11 行:将用户空间缓存区复制到内核空间
  • 第13 行:间接读取数据寄存器中的数据
  • 第21 行:将数据重新写入寄存器中, 控制LED 亮灭
  1. kstrtoul_from_user() 函数:

再分析该函数之前,我们先分析一下内核中提供的kstrtoul() 函数,理解kstrtoul() 函数之后再分析kstrtoul_from_user() 就信手拈来了。

列表12: kstrtoul() 函数解析(内核源码/include/linux/kernel.h)

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

该函数的功能是将一个字符串转换成一个无符号长整型的数据。

函数参数和返回值如下:

参数
• s:字符串的起始地址,该字符串必须以空字符结尾;

• base:转换基数,如果base=0,则函数会自动判断字符串的类型,且按十进制输出,比如“0xa”就会被当做十进制处理(大小写都一样),输出为10。如果是以0 开头则会被解析为八进制数,否则将会被解析成小数;

• res:一个指向被转换成功后的结果的地址。

返回值:该函数转换成功后返回0,溢出将返回-ERANGE,解析出错返回-EINVAL。理解完kstrtoul()函数后想必大家已经知道kstrtoul_from_user() 函数的大致用法了,

kstrtoul_from_user() 函数定义如下:

列表13: kstrtoul_from_user() 函数(内核源码/include/linux/kernel.h)

int __must_check kstrtoul_from_user(const char __user *s, size_t count, unsigned int base, unsigned long *res);

函数参数和返回值如下:

参数

  • s:字符串的起始地址,该字符串必须以空字符结尾;
  • count: count 为要转换数据的大小;
  • base:转换基数,如果base=0,则函数会自动判断字符串的类型,且按十进制输出,比如“0xa”就会被当做十进制处理(大小写都一样),输出为10。如果是以0 开头则会被解析为八进制数,否则将会被解析成小数;
  • res:一个指向被转换成功后的结果的地址。

返回值

该函数相比kstrtoul() 多了一个参数count,因为用户空间是不可以直接访问内核空间的,所以内核提供了kstrtoul_from_user() 函数以实现用户缓冲区到内核缓冲区的拷贝,与之相似的还有copy_to_user(),copy_to_user() 完成的是内核空间缓冲区到用户空io 间的拷贝。如果你使用的内存类型没那么复杂,便可以选择使用put_user() 或者get_user() 函数。

最后分析一下file_operations 中release 函数的实现:

当最后一个打开设备的用户进程执行close() 系统调用的时候,内核将调用驱动程序release() 函数,release 函数的主要任务是清理未结束的输入输出操作,释放资源,用户自定义排他标志的复位等。前面我们用ioremap() 将物理地址空间映射到了虚拟地址空间,当我们使用完该虚拟地址空间时应该记得使用iounmap() 函数将它释放掉。不过我们在驱动模块退出的时候才进行释放,这里我们不做操作。

列表14: file_operations 中release 函数的实现

static int led_chrdev_release(struct inode *inode, struct file *filp)
{

	return 0;
}

LED 驱动完整代码

到这里我们的代码已经分析完成了,下面时本驱动的完整代码(由于前面已经带领大家详细的分析了一遍,所以我把完整代码的注释给去掉了,希望你能够会想起每个函数的具体作用)。

led_cdev.c

列表15: 完整代码(位于…linux_driver/led_cdev/led_cdev.c)

#include <linux/init.h>
#include <linux/module.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/io.h>

#define DEV_NAME "led_chrdev"
#define DEV_CNT (3)

#define AHB4_PERIPH_BASE (0x50000000)

#define RCC_BASE (AHB4_PERIPH_BASE + 0x0000)
#define RCC_MP_GPIOENA (RCC_BASE + 0XA28)
#define GPIOA_BASE (AHB4_PERIPH_BASE + 0x2000)
#define GPIOA_MODER (GPIOA_BASE + 0x0000)
#define GPIOA_OTYPER (GPIOA_BASE + 0x0004)
#define GPIOA_OSPEEDR (GPIOA_BASE + 0x0008)
#define GPIOA_PUPDR (GPIOA_BASE + 0x000C)
#define GPIOA_BSRR (GPIOA_BASE + 0x0018)

#define GPIOG_BASE (AHB4_PERIPH_BASE + 0x8000)
#define GPIOG_MODER (GPIOG_BASE + 0x0000)
#define GPIOG_OTYPER (GPIOG_BASE + 0x0004)
#define GPIOG_OSPEEDR (GPIOG_BASE + 0x0008)
#define GPIOG_PUPDR (GPIOG_BASE + 0x000C)
#define GPIOG_BSRR (GPIOG_BASE + 0x0018)

#define GPIOB_BASE (AHB4_PERIPH_BASE + 0x3000)
#define GPIOB_MODER (GPIOB_BASE + 0x0000)
#define GPIOB_OTYPER (GPIOB_BASE + 0x0004)
#define GPIOB_OSPEEDR (GPIOB_BASE + 0x0008)
#define GPIOB_PUPDR (GPIOB_BASE + 0x000C)
#define GPIOB_BSRR (GPIOB_BASE + 0x0018)

static dev_t devno;
struct class *led_chrdev_class;

struct led_chrdev {
	struct cdev dev;
	unsigned int __iomem *va_moder; // 模式寄存器虚拟地址保存变量
	unsigned int __iomem *va_otyper; // 输出类型寄存器虚拟地址保存变量
	unsigned int __iomem *va_ospeedr; // 速度配置寄存器虚拟地址保存变量
	unsigned int __iomem *va_pupdr; // 上下拉寄存器虚拟地址保存变量
	unsigned int __iomem *va_bsrr; // 置位寄存器虚拟地址保存变量

	unsigned int led_pin; // 引脚偏移
};

unsigned int __iomem *va_clkaddr;

static int led_chrdev_open(struct inode *inode, struct file *filp)
{
	unsigned int val = 0;
	struct led_chrdev *led_cdev = (struct led_chrdev *)container_of(inode->i_cdev, struct led_chrdev,dev);
	filp->private_data = container_of(inode->i_cdev, struct led_chrdev, dev);

	printk("open\n");

	va_clkaddr = ioremap(RCC_MP_GPIOENA, 4);// 映射物理地址到虚拟地址:gpio 时钟rcc 寄存器
	val |= (0x43); // 开启a、b、g 的时钟
	iowrite32(val, va_clkaddr);

	// 设置模式寄存器:输出模式
	val = ioread32(led_cdev->va_moder);
	val &= ~((unsigned int)0X3 << (2 * led_cdev->led_pin));
	val |= ((unsigned int)0X1 << (2 * led_cdev->led_pin));
	iowrite32(val,led_cdev->va_moder);
	// 设置输出类型寄存器:推挽模式
	val = ioread32(led_cdev->va_otyper);
	val &= ~((unsigned int)0X1 << led_cdev->led_pin);
	iowrite32(val, led_cdev->va_otyper);
	// 设置输出速度寄存器:高速
	val = ioread32(led_cdev->va_ospeedr);
	val &= ~((unsigned int)0X3 << (2 * led_cdev->led_pin));
	val |= ((unsigned int)0x2 << (2 * led_cdev->led_pin));
	iowrite32(val, led_cdev->va_ospeedr);
	// 设置上下拉寄存器:上拉
	val = ioread32(led_cdev->va_pupdr);
	val &= ~((unsigned int)0X3 << (2*led_cdev->led_pin));
	val |= ((unsigned int)0x1 << (2*led_cdev->led_pin));
	iowrite32(val,led_cdev->va_pupdr);
	// 设置置位寄存器:默认输出低电平
	val = ioread32(led_cdev->va_bsrr);
	val |= ((unsigned int)0x1 << (led_cdev->led_pin + 16));
	iowrite32(val, led_cdev->va_bsrr);

	return 0;
}

static int led_chrdev_release(struct inode *inode, struct file *filp)
{
	iounmap(va_clkaddr); // 释放GPIO 时钟寄存器虚拟地址

	return 0;
}

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;

	struct led_chrdev *led_cdev = (struct led_chrdev *)filp->private_data;

	kstrtoul_from_user(buf, tmp, 10, &ret);

	val = ioread32(led_cdev->va_bsrr);
	if (ret == 0){
		val |= (0x01 << (led_cdev->led_pin+16)); // 设置GPIO 引脚输出低电平
	}
	else{
		val |= (0x01 << led_cdev->led_pin); // 设置GPIO 引脚输出高电平
	}

	iowrite32(val, led_cdev->va_bsrr);
	*ppos += tmp;
	return tmp;
}

static struct file_operations led_chrdev_fops = {
	.owner = THIS_MODULE,
	.open = led_chrdev_open,
	.release = led_chrdev_release,
	.write = led_chrdev_write,
};

static struct led_chrdev led_cdev[DEV_CNT] = {
	{.led_pin = 13}, // 定义GPIO 引脚号
 	{.led_pin = 2},
	{.led_pin = 5},
};

static __init int led_chrdev_init(void)
{
	int i = 0;
	dev_t cur_dev;

	printk("led chrdev init \n");

	va_clkaddr = ioremap(RCC_MP_GPIOENA, 4);// 映射物理地址到虚拟地址:gpio 时钟rcc 寄存器

	led_cdev[0].va_moder = ioremap(GPIOA_MODER, 4); // 映射模式寄存器 物理地址到虚拟地址
	led_cdev[0].va_otyper = ioremap(GPIOA_OTYPER, 4); // 映射输出类型寄存器 物理地址到虚拟地址
	led_cdev[0].va_ospeedr = ioremap(GPIOA_OSPEEDR, 4); // 映射速度配置寄存器 物理地址到虚拟地址
	led_cdev[0].va_pupdr = ioremap(GPIOA_PUPDR, 4); // 映射上下拉寄存器 物理地址到虚拟地址
	led_cdev[0].va_bsrr = ioremap(GPIOA_BSRR, 4); // 映射置位寄存器 物理地址到虚拟地址

	led_cdev[1].va_moder = ioremap(GPIOG_MODER, 4);
	led_cdev[1].va_otyper = ioremap(GPIOG_OTYPER, 4);
	led_cdev[1].va_ospeedr = ioremap(GPIOG_OSPEEDR, 4);
	led_cdev[1].va_pupdr = ioremap(GPIOG_PUPDR, 4);
	led_cdev[1].va_bsrr = ioremap(GPIOG_BSRR, 4);

	led_cdev[2].va_moder = ioremap(GPIOB_MODER, 4);
	led_cdev[2].va_otyper = ioremap(GPIOB_OTYPER, 4);
	led_cdev[2].va_ospeedr = ioremap(GPIOB_OSPEEDR, 4);
	led_cdev[2].va_pupdr = ioremap(GPIOB_PUPDR, 4);
	led_cdev[2].va_bsrr = ioremap(GPIOB_BSRR, 4);

	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[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");

	iounmap(va_clkaddr); // 释放GPIO 时钟寄存器虚拟地址

	for (i = 0; i < DEV_CNT; i++) {
		iounmap(led_cdev[i].va_moder); // 释放模式寄存器虚拟地址
		iounmap(led_cdev[i].va_otyper); // 释放输出类型寄存器虚拟地址
		iounmap(led_cdev[i].va_ospeedr); // 释放速度配置寄存器虚拟地址
		iounmap(led_cdev[i].va_pupdr); // 释放上下拉寄存器虚拟地址
		iounmap(led_cdev[i].va_bsrr); // 释放置位寄存器虚拟地址
	}

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

MODULE_AUTHOR("embedfire");
MODULE_LICENSE("GPL");

实验准备

在板卡上的部分GPIO 可能会被系统占用,在使用前请根据需要改/boot/uEnv.txt 文件,可注释掉某些设备树插件的加载,重启系统,释放相应的GPIO 引脚。

如本节实验中,可能在鲁班猫系统中默认使能了LED 的设备功能,用在了LED 子系统。引脚被占用后,设备树可能无法再加载或驱动中无法再申请对应的资源。

方法参考如下:
在这里插入图片描述

取消LED 设备树插件,以释放系统对应LED 资源,操作如下:

在这里插入图片描述

如若运行代码时出现“Device or resource busy”或者运行代码卡死等等现象,请按上述情况检查并按上述步骤操作。

如出现Permission denied 或类似字样,请注意用户权限,大部分操作硬件外设的功能,几乎都需要root 用户权限,简单的解决方案是在执行语句前加入sudo 或以root 用户运行程序。

LED 驱动Makefile

列表16: LED 驱动Makefile

KERNEL_DIR=../ebf_linux_kernel/build_image/build
ARCH=arm
CROSS_COMPILE=arm-linux-gnueabihf-
export ARCH CROSS_COMPILE

obj-m := led_cdev.o
out = led_cdev_test

all:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules
	$(CROSS_COMPILE)gcc -o $(out) led_test.c

.PHONE:clean copy

clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean
	rm $(out)

Makefile 与前面的相差不大,定义了led_cdev 这个内核模组和led_cdev_test 应用程序。

编译命令说明

在实验目录下输入如下命令来编译驱动模块:

make

编译成功后,实验目录下会生成”led_cdev.ko”的驱动模块文件和”led_cdev_test”的应用程序。

在这里插入图片描述

程序运行结果

通过scp 或者nfs 将上面的两个文件拷贝到开发板中,执行下面的命令加载驱动:

安装LED 驱动

sudo insmod led_cdev.ko

然后我们可以在/dev/目录下找到led_chrdev0、led_chrdev1、led_chrdev2 这三个设备,我们可以通过直接给设备写入1/0 来控制LED 的亮灭,也可以通过我们的测试程序来控制LED。

# 红灯亮
sudo sh -c 'echo 0 >/dev/led_chrdev0'
# 红灯灭
sudo sh -c 'echo 1 >/dev/led_chrdev0'

运行LED 测试程序sudo ./led_cdev_test LED 依次呈现红、绿、蓝三种颜色的灯光。

在这里插入图片描述
在这里插入图片描述

这个时候我们再回味一下设备驱动的作用。当我们开发一款嵌入式产品时,产品的设备硬件发生变动的时候,我们就只需要更改驱动程序以提供相同的API,而不用去变动应用程序,就能达到同样的效果,这将减少多少开发成本呢。


参考资料:嵌入式Linux 驱动开发实战指南-基于STM32MP1 系列

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值