LV06-03-chrdev-09-点亮LED-01-linux下LED驱动原理

字符设备驱动的基础知识学习的差不多了,那怎么通过字符设备控制LED灯?若笔记中有错误或者不合适的地方,欢迎批评指正😃。

点击查看使用工具及版本
PC端开发环境 Windows Windows11
Ubuntu Ubuntu20.04.2的64位版本
VMware® Workstation 17 Pro 17.6.0 build-24238078
终端软件 MobaXterm(Professional Edition v23.0 Build 5042 (license))
Win32DiskImager Win32DiskImager v1.0
Linux开发板环境 Linux开发板 正点原子 i.MX6ULL Linux 阿尔法开发板
uboot NXP官方提供的uboot,使用的uboot版本为U-Boot 2019.04
linux内核 linux-4.19.71(NXP官方提供)
点击查看本文参考资料
分类 网址 说明
官方网站 https://www.arm.com/ ARM官方网站,在这里我们可以找到Cotex-Mx以及ARMVx的一些文档
https://www.nxp.com.cn/ NXP官方网站
https://www.nxpic.org.cn/NXP 官方社区
https://u-boot.readthedocs.io/en/latest/u-boot官网
https://www.kernel.org/linux内核官网
点击查看相关文件下载
分类 网址 说明
NXP https://github.com/nxp-imx NXP imx开发资源GitHub组织,里边会有u-boot和linux内核的仓库
nxp-imx/linux-imx/releases/tag/v4.19.71 NXP linux内核仓库tags中的v4.19.71
nxp-imx/uboot-imx/releases/tag/rel_imx_4.19.35_1.1.0 NXP u-boot仓库tags中的rel_imx_4.19.35_1.1.0
I.MX6ULL i.MX 6ULL Applications Processors for Industrial Products I.MX6ULL 芯片手册(datasheet,可以在线查看)
i.MX 6ULL Applications ProcessorReference Manual I.MX6ULL 参考手册(下载后才能查看,需要登录NXP官网)
Source Code https://elixir.bootlin.com/linux/latest/source linux kernel源码
kernel/git/stable/linux.git - Linux kernel stable tree linux kernel源码(官网,tag 4.19.71)
https://elixir.bootlin.com/u-boot/latest/source uboot源码

一、linux下LED灯驱动原理

1. 设备驱动的作用与本质

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

1.1 驱动的作用

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

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

1.2. 有无操作系统的区别

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

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

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

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

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

2. 内存管理单元MMU

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

2.1 MMU是什么?

MMU就是内存管理单元,(英语:memory management unit,缩写为MMU),有时称作分页内存管理单元(英语:paged memory management unit,缩写为PMMU)。它是一种负责处理中央处理器(CPU)的内存访问请求的计算机硬件。它的功能包括虚拟地址物理地址的转换(即虚拟内存管理)、内存保护、中央处理器高速缓存的控制,在较为简单的计算机体系结构中,负责总线仲裁以及存储体切换(bank switching,尤其是在8位的系统上)。

2.2 MMU的功能

MMU为编程提供了方便统一的内存空间抽象,其实我们的程序中所写的变量地址是虚拟内存当中的地址, 倘若处理器想要访问这个地址的时候,MMU便会将此虚拟地址(Virtual Address)翻译成实际的物理地址(Physical Address), 之后处理器才去操作实际的物理地址。

MMU是一个实际的硬件,并不是一个软件程序。他的主要作用是将虚拟地址翻译成真实的物理地址同时管理和保护内存, 不同的进程有各自的虚拟地址空间,某个进程中的程序不能修改另外一个进程所使用的物理地址,以此使得进程之间互不干扰,相互隔离。

而且我们可以使用虚拟地址空间的一段连续的地址去访问物理内存当中零散的大内存缓冲区。很多实时操作系统都可以运行在无MMU的CPU中, 比如uCOS、FreeRTOS、uCLinux,以前想CPU也运行linux系统必须要该CPU具备MMU,但现在Linux也可以在不带MMU的CPU中运行了。 总体而言MMU具有如下功能:

  • (1)保护内存,设置存储器的访问权限,设置虚拟存储空间的缓冲特性:MMU给一些指定的内存块设置了读、写以及可执行的权限,这些权限存储在页表当中,MMU会检查CPU当前所处的是特权模式还是用户模式,如果和操作系统所设置的权限匹配则可以访问,如果CPU要访问一段虚拟地址,则将虚拟地址转换成物理地址,否则将产生异常,防止内存被恶意地修改。
  • (2)提供方便统一的内存空间抽象,实现虚拟地址到物理地址的转换(也叫做地址映射 ): CPU可以运行在虚拟的内存当中,虚拟内存一般要比实际的物理内存大很多,使得CPU可以运行比较大的应用程序。

2.3 虚拟地址和物理地址

首先来了解两个地址概念:虚拟地址(VA,Virtual Address)、物理地址(PA, Physcical Address)。

当没有启用MMU的时候,CPU在读取指令或者访问内存时便会将地址直接输出到芯片的引脚上,此地址直接被内存接收,这段地址称为物理地址, 如下图所示。

image-20241228111951604

简单地说,物理地址就是内存单元的绝对地址,好比我们电脑上插着一张8G的内存条,则第一个存储单元便是物理地址0x0000, 内存条的第6个存储单元便是0x0005,无论处理器怎样处理,物理地址都是它最终的访问的目标。

当CPU开启了MMU时,CPU发出的地址将被送入到MMU,被送入到MMU的这段地址称为虚拟地址, 之后MMU会根据去访问页表地址寄存器然后去内存中找到页表(假设只有一级页表)的条目,从而翻译出实际的物理地址, 如下图所示。

image-20241228112426501

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

我们的开发板上有 512MB 的 DDR3,这 512MB 的内存就是物理内存,经过 MMU 可以将其映射到整个 4GB 的虚拟空间,如图所示:

image-20241228110544019

物理内存只有 512MB,虚拟内存有 4GB,那么肯定存在多个虚拟地址映射到同一个物理地址上去,虚拟地址范围比物理地址范围大的问题处理器自会处理,这里我们就不去深究了。

2.4 TLB的作用

TLB(Translation Lookaside Buffer),转译后备缓冲器(英语:Translation Lookaside Buffer,首字母缩略字TLB),在中国大陆被翻译为页表缓存转址旁路缓存,为CPU的一种缓存,由内存管理单元用于改进虚拟地址到物理地址的转译速度。

由上面的地址转换过程可知,当只有一级页表进行地址转换的时候,CPU每次读写数据都需要访问两次内存, 第一次是访问内存中的页表,第二次是根据页表找到真正需要读写数据的内存地址; 如果使用两级了表,那么CPU每次读写数据都需要访问3次内存,这样岂不是显得非常繁琐且耗费CPU的性能?

那有什么更好的解决办法呢?答案是肯定的,为了解决这个问题,TLB便孕育而生。 在CPU传出一个虚拟地址时,MMU最先访问TLB,假设TLB中包含可以直接转换此虚拟地址的地址描述符, 则会直接使用这个地址描述符检查权限和地址转换,如果TLB中没有这个地址描述符, MMU才会去访问页表并找到地址描述符之后进行权限检查和地址转换, 然后再将这个描述符填入到TLB中以便下次使用,实际上TLB并不是很大, 那TLB被填满了怎么办呢?如果TLB被填满,则会使用round-robin算法找到一个条目并覆盖此条目。

由于MMU非常复杂,在此我们不做过于深入的了解,只要大概知道它的作用即可,在linux环境中, 我们开启了MMU之后想要读写具体的寄存器(物理地址),就必须用到物理地址到虚拟地址的转换函数。

二、相关的API

1. ioremap()

1.1 函数说明

ioremap()定义在ioremap.c - arch/arc/mm/ioremap.c - ioremap

1
2
3
// #include <asm/io.h>
void __iomem *ioremap(phys_addr_t paddr, unsigned long size);
#define ioremap ioremap

函数参数和返回值如下:

参数:

  • paddr: 被映射的IO起始地址(物理地址);
  • size: 需要映射的空间大小,以字节为单位;

返回值: 一个指向__iomem类型的指针,当映射成功后便返回一段虚拟地址空间的起始地址,我们可以通过访问这段虚拟地址来实现实际物理地址的读写操作。

1.2 函数作用

把物理地址 phys_addr 开始的一段空间(大小为 size),映射为虚拟地址;返回值是该段虚拟地址的首地址:

1
virt_addr = ioremap(phys_addr, size);

实际上,它是按页(4096 字节)进行映射的,是整页整页地映射的。 假设 phys_addr = 0x10002, size=4, ioremap 的内部实现是:

(1)phys_addr 按页取整,得到地址 0x10000;

(2)size 按页取整,得到 4096;

(3)把起始地址 0x10000,大小为 4096 的这一块物理地址空间,映射到虚拟地址空间,假设得到的虚拟空间起始地址为 0xf0010000;

(4)那么 phys_addr = 0x10002 对应的 virt_addr = 0xf0010002。

2. 读写IO内存

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); // 读取一个字节(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灯引脚对应的数据寄存器(物理地址)映射到程序的虚拟地址空间当中,然后我们就可以像操作寄存器一样去操作我们的虚拟地址啦!其具体代码如下所示。

1
2
3
4
5
6
7
8
unsigned long pa_dr = 0X0209C000 + 0x00;
unsigned int __iomem *va_dr;
unsigned int val;

va_dr = ioremap(pa_dr, 4);
val = ioread32(va_dr);
val &= ~(0x01 << 3);
iowrite32(val, va_dr);

3. iounmap()

3.1 函数说明

iounmap()函数定义在ioremap.c - arch/arc/mm/ioremap.c - iounmap

1
2
void iounmap(void *addr);
#define iounmap iounmap

函数参数和返回值如下:

参数:

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

返回值:

3.2 使用实例

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

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

4. volatile

编译器在进行编译的时候会帮我们做些优化 ,比如:

1
2
3
int a;
a = 0; // 这句话可以优化掉,不影响 a 的结果
a = 1;

在另一个场景下,这个优化就会带来意想不到的意外,比如:

1
2
3
int *p = ioremap(xxxx, 4); // GPIO 寄存器的地址
*p = 0; // 点灯,但是这句话被优化掉了
*p = 1; // 灭灯

对于上面的情况,为了避免编译器自动优化,需要加上 volatile,告诉它“这是容易出错的,不要随便优化”:

1
2
3
volatile int *p = ioremap(xxxx, 4); // GPIO 寄存器的地址
*p = 0; // 点灯,这句话不会被优化掉
*p = 1; // 灭灯