版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2021 凌云物网智科实验室·郭工

  • Author: GuoWenxue <guowenxue@gmail.com> QQ: 281143292

wechat_pub

3.1 Ubuntu下驱动开发

首先我们在自己的虚拟机Ubuntu上,学习了解Linux字符设备驱动开发的基本流程。

3.1.1 Linux驱动开发入门

在单片机驱动编程中,这些硬件的设备驱动由我们自己编写,然后在代码中由我们自己调用,他们没有一个统一的规范,一百个人有一百种驱动的写法。而在linux系统下编写驱动,他有严格的规范,哪些该驱动做,哪些该应用程序做;驱动程序编写要先做什么,然后再做什么、然后再做什么都有严格的定义。正因为这样的规范,所以每个人只需要注重自己的角色,做自己该做的事,这也将嵌入式Linux开发的岗位分为两个,一个是底层驱动开发的BSP(Board Support Packet)开发,另外就是应用程序(Application)开发。

对于BSP开发岗位,相关的开发人员应该了解各种硬件知识,如电路基础、各种接口技术和硬件调试工具的使用(如万用表、示波器、甚至逻辑分析仪等),此外还需要计算机组成、操作系统原理等理论基础,同时还要了解各种体系架构的CPU、汇编语言等,当然最重要的是C语言编程能力和数据结构的知识以及对Linux内核源码大量阅读和分析。

对于嵌入式应用开发的岗位,我们不需要了解底层驱动的具体实现细节,而只需要知道怎么使用他们即可,因为Linux是一个模块化、严格分成的系统,所以应用程序人员只需要了解Linux的驱动调用的统一API(Application Program Interface,应用程序编程接口)即可,这些API就是Linux的系统调用(System call),他们在《UNIX环境高级编程》这本书里较为详细的描述,对于嵌入式应用程序的开发,我们大部分使用C、C++或python(树莓派)和数据结构,根据应用程序的不同需求,我们需要补充额外的计算机网络数据库等知识。

Linux内核将虚拟内存空间划分成相互隔离的两个部分:内核空间和用户空间。内核空间只能用于运行内核代码、内核扩展以及硬件设备驱动程序。相反,用户空间的内存可以被所有运行的用户态程序使用,并且必要的时候用户空间的内存会被交换到磁盘中。如果应用程序空间的代码需要使用硬件资源,则通过调用操作系统提供的系统调用接口函数来实现,此时CPU将会切换到内核空间去”代为”执行相关的硬件操作,完成之后再返回到应用程序空间继续执行。

kernel_userspace

Linux系统下的程序开发一般分为两种: 一种是应用程序开发,一种是内核级驱动程序开发,这两种开发种类对应Linux的两种状态,分别是用户态和内核态。当我们在应用程序空间编写一个打印“Hello World”字符串的程序时,在调用 printf("Hello World") 之前的所有代码都运行在用户态。而当C语言库函数printf() 要开始往LCD显示器上打印”Hello World” 字符串时,它将会通过调用 write()系统调用来实现。而该系统调用将会让该进程从 用户态 切换到 内核态 来执行,此时Linux内核中的代码将会调用LCD驱动提供的相应接口函数,把该字符串输出到LCD显示屏上。在完成这些显示工作后,write() 系统调用将会返回,此时该进程将会从 内核态 切回到 用户态 继续运行。

  • 进程从 用户态 切换到 内核态 一般是由 系统调用(System Call) 来实现的;

  • 系统调用返回时,进程将会从 内核态 切换到 用户态

在Linux系统下,我们可以使用 time 命令查看一个进程(程序) 分别在 用户态内核态 运行了多长时间。

guowenxue@ubuntu20:~$ time printf "Hello World\n"
Hello World

real    0m0.000s
user    0m0.000s
sys     0m0.000s

Linux设备驱动程序在 Linux 内核里扮演着特殊的角色. 它们是截然不同的”黑盒子”,实现了对硬件的配置和控制,并对其进行进一步的抽象,为应用层软件操作硬件提供了统一的接口函数。不论硬件的具体形式如何,linux驱动都将其映射成一个设备文件(存放在Linux系统的 /dev 路径下,譬如早期的Linux系统下LCD对应的设备文件就是 /dev/fb0),应用程序空间只需要调用open()、read()、write()、ioctl()等这些标准的系统调用API,就可以操作实际的硬件了。

driver_model

3.1.1.1 Linux内核功能介绍

在 Linux 系统中, 几个并发的进程用来处理不同的任务. 每个进程都需要向操作系统请求系统资源, 如CPU、内存、网络连接或者一些其它的资源, 而这些功能都是通过系统调用来完成的. 这样,Linux内核可以看作是一个大块的可执行文件, 负责处理所有这样的请求。对于Linux内核而言,其主要功能职责有这么几个:

  • 进程管理: Linux内核负责创建和销毁进程(应用程序空间调用 fork() 后), 并处理它们与外部世界的联系(输入和输出),此外它还实现了不同进程间的各种通信方式,如管道、信号、信号量等,这些对操作系统来说是最基本的. 此外,进程调度程序则执行相应的进程调度策略,以确保各个进程可以公平地访问CPU,并实现实现了多个进程在一个单核或多核 CPU 上的抽象。

  • 内存管理: 计算机的内存是主要的资源,,处理它所用的策略对系统性能是至关重要的,它允许多个进程安全地共享机器的主内存系统。内存管理器主要使用虚拟内存管理机制,它为每个进程都建立了一个独立的4GB虚拟地址空间,从而保证每个进程都有足够的内存运行并互不影响,而我们的物理主机实际上可能只有1GB甚至更少的内存。内核的不同部分与内存管理子系统通过一套函数调用交互, 从简单的 malloc/free 到更多、更复杂的功能。

  • 文件系统: Unix 系统的成功很大程度上基于文件系统的概念,几乎 Unix 中的任何东西都可看作一个文件. 内核在非结构化的硬件之上建立了一个结构化的文件系统, 结果是文件的抽象非常多地在整个系统中应用. 另外, Linux系统通过 VFS(Virtual File System) 支持多个文件系统类型。例如, 磁盘可被格式化成 Linux 的 ext4 文件系统, 也可以使用Windows的 FAT32 文件系统,或者其他几个文件系统。

  • 设备管理: 除了处理器, 内存和非常少的别的实体之外, 几乎每个系统操作最终都会映射到一个物理设备上,如使用vim编辑文件是将会操作到屏幕、鼠标、磁盘等。而任何设备控制操作都需要由其相应的驱动程序来实现,如显示器需要显卡的驱动才能驱动显示、鼠标需要鼠标的驱动才能工作、磁盘需要磁盘驱动才能工作。如何在Linux系统下编写相应的设备驱动,是我们这里重点讨论的东西。在Linux系统下,除几乎所有的字符设备和块设备都会在 /dev 路径下有一个设备文件与其相对应。

  • 网络功能: Linux系统之所以在嵌入式领域、服务器领域由非常非常广泛的应用,正是由于它出色的网络通信功能。在Linux内核里,它实现了常见的网络协议栈(TCP/IP),以及对底层网络设备的控制,并对上层提供统一的网络socket编程接口。需要注意的是,网卡设备在 /dev 路径下并没有相应的设备文件,它都是通过 socket() 来管理的。事实上我们常用的 ifconfig、route 等命令也是由 socket 系统调用实现的,因为网卡设备并没有具体的设备文件。

3.1.1.2 Linux设备驱动分类

Linux内核设计哲学是把所有的东西都抽象成文件进行访问,这样对设备的访问都是通过文件I/O来进行操作。Linux内核将设备按照访问特性分为三类:字符设备、块设备、网络设备;

设备分类

1. 字符设备驱动: 这类设备在进行数据读取操作时是以字节为单位进行的,对于这种字节流的设备叫做字符设备。典型的如串口、Led、LCD、蜂鸣器、SPI、触摸屏等驱动,都属于字符设备驱动的范畴。需要注意的是,Linux系统中大部分的驱动程序都是属于字符设备驱动。我们可以使用命令 ls -l /dev/ | grep ^c 查看当前系统下的字符设备。

guowenxue@ubuntu20:~$ ls -l /dev/ | grep ^c | grep ttyS | sort | head -5
crw-rw----  1 root dialout   4,  64 Sep 30 09:43 ttyS0
crw-rw----  1 root dialout   4,  65 Sep 30 09:43 ttyS1
crw-rw----  1 root dialout   4,  66 Sep 30 09:43 ttyS2
crw-rw----  1 root dialout   4,  67 Sep 30 09:43 ttyS3
crw-rw----  1 root dialout   4,  68 Sep 30 09:43 ttyS4

2. 块设备驱动: 块设备驱动是相对于字符设备驱动而定义的,因为块设备被软件操作时,是以块或扇区(block/sector)为单位进行操作的(块指的是多个字节组成一个块)。块设备大多指的都是各种存储类类设备,比如硬盘、U盘、SD卡、eMMC、NandFlash等等。这类设备在Linux下使用时,一般都需要进行分区、格式化(文件系统)、挂载(mount)起来使用。我们可以使用命令 ls -l /dev/ | grep ^b 查看当前系统下的块设备。

guowenxue@ubuntu20:~$ ls -l /dev/ | grep ^b | grep sd
brw-rw----  1 root disk      8,   0 Sep 30 09:43 sda
brw-rw----  1 root disk      8,   1 Sep 30 09:43 sda1
brw-rw----  1 root disk      8,   2 Sep 30 09:43 sda2
brw-rw----  1 root disk      8,   3 Sep 30 09:43 sda3
brw-rw----  1 root disk      8,   5 Sep 30 09:43 sda5
brw-rw----  1 root disk      8,   6 Sep 30 09:43 sda6
brw-rw----  1 root disk      8,  16 Sep 30 09:43 sdb
brw-rw----  1 root disk      8,  17 Sep 30 09:43 sdb1
brw-rw----  1 root disk      8,  18 Sep 30 09:43 sdb2

3. 网络设备驱动: 专门针对网络设备而设计的一种驱动,不管是有线还是无线网络,都属于网络设备驱动。对于 Linux下的字符设备和块设备在 /dev 路径下都会有一个设备节点与其相对应,但网络设备并不存在这样的设备节点。如果想要查看网络设备的信息,应该使用 ifconfig 命令来查看,而如果要使用网络设备进行通信,则应该使用 socket 编程API来实现。

guowenxue@ubuntu20:~$ ifconfig -a
enp2s0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.2.2  netmask 255.255.255.0  broadcast 192.168.2.255
        inet6 fe80::c8bb:79d9:c9e9:8a5  prefixlen 64  scopeid 0x20<link>
        ether 0a:e0:af:d3:02:c4  txqueuelen 1000  (Ethernet)
        RX packets 432132951  bytes 167421101647 (167.4 GB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 1012633701  bytes 1410299631784 (1.4 TB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 990981  bytes 5105487012 (5.1 GB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 990981  bytes 5105487012 (5.1 GB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

另外需要注意的是,有些设备是可以属于多种设备驱动类型,比如 USB WIFI 设备,其使用 USB 接口,所以属于字符设备(USB驱动),但是其又能上网,所以也属于网络设备(网卡驱动)。而U盘也是使用的USB接口则属于字符设备,它又能做存储所以又是块设备。事实上,USB只是设备与CPU之间通信的通信协议,这样USB设备则会在Linux内核中根据其功能模拟实现成不同的设备驱动(USB鼠标键盘模拟成HID字符设备,USB网卡模拟成网络设备,U盘模拟成块设备)。

3.1.1.3 内核驱动开发注意事项

大多数程序员致力于应用程序的开发,少数程序员则致力于内核及驱动程序的开发。相对于应用程序的开发,内核及驱动程序的开发有很大的不同。最重要的差异包括以下几点:

  • 内核及驱动程序开发时不能使用C库提供的函数,如printf()等。因为C库是在应用程序空间中编程使用的,它里面很多函数需要调用Linux内核中的系统调用来实现的,如printf() 将会调用内核的 write()系统调用。这样,很显然我们在编写Linux驱动程序时不能调用printf()函数,而应该使用Linux内核里实现的 printk() 函数。

  • Linux应用程序空间中的每个进程都有受保护的4GB的虚拟地址空间,这样我们在应用程序编程出现指针错误时,只会导致该进程退出(通常会抛Segmentation Fault),并不会导致系统或其它进程奔溃。而Linux内核驱动编程时出现指针错误将可能会导致整个Linux系统死机(通常会抛Kernel Panic),所以Linux内核驱动编程要异常小心。

  • 内核里只有一个很小的定长堆栈,这样在驱动编程时不能像应用程序空间一样随意开辟一段大的存储空间,另外在内核里动态分配的内存使用完成之后务必要要记得释放。

  • Linux内核空间不支持浮点运算,这样在驱动程序开发时使用浮点数将会很难,应该使用整型数。譬如我们在写温湿度传感器驱动时,往往不会直接返回一个浮点类型的值。

  • 内核及驱动程序开发时必须使用GNU C,因为Linux操作系统从一开始就使用的是GNU C,虽然也可以使用其他的编译工具,但是需要对以前的代码做大量的修改。

  • 内核支持异步终端、抢占和SMP,因此内核及驱动程序开发时必须时刻注意同步和并发。

  • 内核及驱动程序开发要考虑可移植性,因为对于不同的平台,驱动程序是不兼容的。

3.1.1.4 内核驱动开发基本原则

作为一个程序员, 你能够对你的驱动作出你自己的选择, 并且在所需的编程时间和结果的灵活性之间, 选择一个可接受的平衡. 但在做驱动开发时,我们应该遵循一个基本的原则:***驱动程序的角色应该是提供机制(需要提供什么功能), 而不是策略(这些功能怎么使用)。***机制和策略的区分是 Unix/Linux 系统设计背后最好的哲学,这也是类Unix系统的应用程序接口这么多年来保持统一、稳定、不变的核心原因。

那什么是机制和策略呢?这里以Led灯的驱动为例,对于Led驱动而言,我们应该提供Led灯操作的基本功能,如点亮Led、熄灭Led,那这些就是机制。而在某个项目中有个需求要让Led灯亮10s后再熄灭,这个就是策略。这样,我们在Led驱动实现中,应该只提供Led的点亮和熄灭操作函数(机制),而不应该提供把Led亮10s然后再熄灭的功能函数(策略)。

之所以在写驱动时,需要把机制和策略区分开来,这是为了让我们的驱动能够具备更大的可扩展性和兼容性。试想一下,如果我们在Led驱动中实现了亮10s后再熄灭的“策略”,那如果今后的需求变更需要亮15s后再熄灭,此时我们需要重新修改驱动源码、编译驱动内核并升级Linux系统。而频繁升级Linux内核或系统,这可是用户不能接受的,并且一旦Linux内核升级失败会导致系统不能启动,出现灾难性的后果。

3.1.1.5 Linux源码及版权问题

Linux 是以 GNU 通用公共版权( GPL )的版本 2 作为许可的, 它来自自由软件基金的 GNU 项目. GPL 允许任何人重发布, 甚至是销售, GPL 涵盖的产品, 只要接收方对源码能存取并且能够行使同样的权力. 另外, 任何源自使用 GPL 产品的软件产品, 如果它是完全的重新发布, 必须置于 GPL 之下发行.

这样一个许可的主要目的是允许知识的增长, 通过同意每个人去任意修改程序; 同时, 销售软件给公众的人仍然可以做他们的工作. 尽管这是一个简单的目标, 关于 GPL 和它的使用存在着从未结束的讨论. 如果你想阅读这个许可证, 你能够在你的系统中几个地方发现它, 包括你的内核源码树的目录中的 COPYING 文件

如果你想你的代码进入主流内核, 或者如果你的代码需要对内核的补丁, 你在发行代码时, 必须立刻使用一个 GPL 兼容的许可. 尽管个人使用你的改变不需要强加 GPL, 如果你发布你的代码, 你必须包含你的代码到发布里面 – 要求你的软件包的人必须被允许任意重建二进制的内容.

最后,Linux内核完全是免费、开源的代码,大家可以随意下载、使用、阅读学习Linux内核源码,其官方站点地址为 https://kernel.org/ 。如果想要深入掌握Linux驱动开发,在完成接下来的驱动开发工作以外,我们还需要阅读大量的Linux内核源码中的驱动文件,这样才能对Linux内核各个子系统及驱动框架有更深入的理解和认识。

3.1.2 Hello驱动模块

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

我们在学习编程语言时,通常都是从 “hello world” 例子开始。作为一个编程入门指导最简单的方法,我们也将以 Hello 模块为例,来讲解Linux内核里的驱动模块的开发和使用流程。

3.1.2.1 编写 hello 驱动

首先创建X86主机下驱动学习的项目文件夹。

guowenxue@ubuntu20:~$ mkdir -p drivers/x86/driver && cd drivers/x86/driver

编写 hello.c 驱动模块源码如下。

guowenxue@ubuntu20:~/drivers/x86/driver$ vim hello.c
/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * Hello driver example in linux kernel.
 */

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

static __init int hello_init(void)
{
    printk(KERN_ALERT "Hello, Linux kernel module.\n");
    return 0;
}

static __exit void hello_exit(void)
{
    printk(KERN_ALERT "Goodbye, Linux kernel module.\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");

源码开始的三个头文件,这是所有Linux内核驱动模块源码必须要包含的几个头文件。需要注意的时,我们编写的应用程序所使用的头文件大多在 /usr/include 路径下,它是用来存放各种用户态下运行的库的C/C++头文件。而Linux内核模块编译所需要的头文件在Linux内核的源代码,所以 Linux 内核驱动源码不能像应用程序那样直接 gcc hello.c 来编译,而必须写 Makefile 文件并在里面指定 Linux内核源码所在的路径,这点我们在后面的驱动编译过程中再来详细介绍。

guowenxue@ubuntu20:~/drivers/x86/driver$ gcc hello.c
hello.c:1:10: fatal error: linux/init.h: No such file or directory
    1 | #include <linux/init.h>
      |          ^~~~~~~~~~~~~~
compilation terminated.

在 Linux 内核中没有 printf() 这个函数,printk() 相当于 printf 的孪生兄妹,printf 运行在用户态,而 printk 运行在内核态。另外,printk支持分级打印调试(上面代码中的字符串KERN_ALERT是优先级),这个就是 printk 和 printf 的最大区别。Linux内核中的 printk() 一共定义了 8 个级别,它们定义在内核源码头文件 include/linux/kern_levels.h 里,其中 0 的优先级最高,7 的优先级最低,其定义如下:

#define KERN_SOH "\001"
#define KERN_EMERG KERN_SOH "0" /* 紧急事件,一般是内核崩溃 */
#define KERN_ALERT KERN_SOH "1" /* 必须立即采取行动 */
#define KERN_CRIT KERN_SOH "2" /* 临界条件,比如严重的软件或硬件错误*/
#define KERN_ERR KERN_SOH "3" /* 错误状态,一般设备驱动程序中使用KERN_ERR 报告硬件错误 */
#define KERN_WARNING KERN_SOH "4" /* 警告信息,不会对系统造成严重影响 */
#define KERN_NOTICE KERN_SOH "5" /* 有必要进行提示的一些信息 */
#define KERN_INFO KERN_SOH "6" /* 提示性的信息 */
#define KERN_DEBUG KERN_SOH "7" /* 调试信息 */

WARNNING: 嵌入式Linux内核中 printk() 语句是否打印到串口终端上,与 u-boot 里的 bootargs 参数中的 loglevel=7 选项相关,只有低于这个loglevel级别的信息才会打印到控制终端上,否则不会在控制终端上输出,这时我们只能通过 dmesg 命令查看。

Linux内核驱动可以在系统运行时动态地安装和卸载,这样每一个Linux内核驱动都会用 module_init()hello_exit() 来声明两个函数, 如上面的代码中的 hello_init()hello_exit()。如下所示:

module_init(hello_init);    //注册模块加载函数
module_exit(hello_exit);    //注册模块卸载函数
  • module_init 宏的作用是将传入的初始化函数(fn)注册到内核模块加载系统中,这样在使用 insmod 命令安装驱动时会被调用。这个函数通常用来进行设备初始化、资源申请等操作。

  • module_exit 宏的作用是将传入的初始化函数(fn)注册到内核模块卸载系统中,这样在使用 rmmod 命令卸载驱动时会被调用。这个函数通常会负责清理模块在加载时申请的资源,例如释放内存、关闭设备、注销驱动程序等。

我们在定义 hello_init()hello_exit() 这两个函数时,通常会加上 __init、 __exit 这两个宏,它们定义在内核源码 include/linux/init.h 中:

#define __init      __section(".init.text") __cold  __latent_entropy __noinitretpoline
#define __exit      __section(".exit.text") __exitused __cold notrace

前面我们在学习C语言的时候了解到,C程序在编译生成的可执行程序中会有文本段数据段堆栈等,其实我们在使用 GCC 编译时,还可以自定义其它一些段。这里的 __section 就是 gcc 的链接选项,他表示把该函数链接到Linux内核映像文件的相应段中,这样 hello_init() 将会被链接进 .init.text 自定义段中,而hello_exit() 将会被链接进 .exit.text 段中。被链接进这两段中的函数的代码在调试完之后,内核将会自动释放他们所占用的内存资源。因为这些函数只需要初始化或退出一次,所以 hello_init()hello_exit() 函数最好在前面加上__init__exit

需要注意的是 module_init()hello_exit() 它们并不是函数,而是 Linux 内核的两个宏,它们定义在内核源码文件 include/linux/module.h 中。其定义如下:

#define module_init(x)  __initcall(x);
#define module_exit(x)  __exitcall(x);

这样 module_init(hello_init) 宏最终将会被展开为:

satic int (*initcall_t)(void) __initcall_hello_init6_used_attribute_((_section_("initcall""6"".init")))=hello_init

这段代码也就是定义了一个叫 __initcall_hello_init6 的函数指针,他指向 hello_init() 这个函数,gcc 的链接选项__attribute____section__将该指针变量链接到linux内核映像的.initcall段中。linux系统在启动时,完成CPU和板级初始化之后,就会从该段中读入所有的模块初始化函数执行。每一个Linux内核模块都需要使用module_init()和module_exit()宏来修饰,这样系统启动时才能自动调用并初始化他们。

Linux内核模块并不是用户程序,它在编译、使用方式上有很多不同之处,接下来我们我们继续了解驱动模块的编译与使用。

3.1.2.2 内核版本和头文件

因为 ubuntu 系统可能会经常升级 Linux内核,这样我们的系统中可能会有很多Linux内核相关源码。而我们的Linux内核模块将在当前系统下编译并运行,那我们就得找到当前系统所用的 Linux内核头文件。这时可以使用 uname 命令查看当前的内核版本,如下执行结果显示当前系统正使用 5.15.0-88-generic 这个内核版本。

guowenxue@ubuntu20:~$ uname -a
Linux ubuntu20 5.15.0-88-generic #98~20.04.1-Ubuntu SMP Mon Oct 9 16:43:45 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux

guowenxue@ubuntu20:~$ uname -r
5.15.0-88-generic

/lib/modules 路径下,存放了当前系统安装的所有内核版本,我们也可以使用 dpkg 命令来找出所安装的 Linux内核。

guowenxue@ubuntu20:~$ ls /lib/modules
5.11.0-27-generic  5.11.0-37-generic  5.11.0-38-generic  5.11.0-40-generic  5.15.0-41-generic  5.15.0-43-generic  5.15.0-75-generic  5.15.0-79-generic  5.15.0-86-generic  5.15.0-87-generic  5.15.0-88-generic

guowenxue@ubuntu20:~$ sudo dpkg --list | grep linux-image
rc  linux-image-5.11.0-27-generic              5.11.0-27.29~20.04.1                  amd64        Signed kernel image generic
rc  linux-image-5.11.0-37-generic              5.11.0-37.41~20.04.2                  amd64        Signed kernel image generic
rc  linux-image-5.11.0-38-generic              5.11.0-38.42~20.04.1                  amd64        Signed kernel image generic
rc  linux-image-5.11.0-40-generic              5.11.0-40.44~20.04.2                  amd64        Signed kernel image generic
rc  linux-image-5.15.0-41-generic              5.15.0-41.44~20.04.1                  amd64        Signed kernel image generic
rc  linux-image-5.15.0-43-generic              5.15.0-43.46~20.04.1                  amd64        Signed kernel image generic
rc  linux-image-5.15.0-75-generic              5.15.0-75.82~20.04.1                  amd64        Signed kernel image generic
ii  linux-image-5.15.0-79-generic              5.15.0-79.86~20.04.2                  amd64        Signed kernel image generic
rc  linux-image-5.15.0-86-generic              5.15.0-86.96~20.04.1                  amd64        Signed kernel image generic
rc  linux-image-5.15.0-87-generic              5.15.0-87.97~20.04.1                  amd64        Signed kernel image generic
ii  linux-image-5.15.0-88-generic              5.15.0-88.98~20.04.1                  amd64        Signed kernel image generic
ii  linux-image-generic-hwe-20.04              5.15.0.88.98~20.04.46                 amd64        Generic Linux kernel image

我们也可以使用 apt purge 命令移除无用的 Linux 内核。

guowenxue@ubuntu20:~$ sudo apt purge -y linux-image-5.11.0* linux-image-5.15.0-4*

guowenxue@ubuntu20:~$ ls /lib/modules
5.15.0-79-generic  5.15.0-86-generic  5.15.0-87-generic  5.15.0-88-generic

事实上Linux内核源码在编译时,需要进入到一个已经编译过的Linux内核源码路径下去编译,并在这个路径下找相应的头文件。而这个路径通常为 /lib/modules/内核版本/build,而该文件是指向 /usr/src/linux-headers-5.15.0-88-generic 的符号链接。

guowenxue@ubuntu20:~$ ls /lib/modules/5.15.0-88-generic/
build   kernel         modules.alias.bin  modules.builtin.alias.bin  modules.builtin.modinfo  modules.dep.bin  modules.order    modules.symbols      vdso
initrd  modules.alias  modules.builtin    modules.builtin.bin        modules.dep              modules.devname  modules.softdep  modules.symbols.bin

guowenxue@ubuntu20:~$ ls -l /lib/modules/5.15.0-88-generic/build
lrwxrwxrwx 1 root root 40 Oct  9 23:46 /lib/modules/5.15.0-88-generic/build -> /usr/src/linux-headers-5.15.0-88-generic

接下来我们再看 /usr/src/linux-headers-5.15.0-88-generic 里的文件,它里面的文件大部分也都是符号链接。

 guowenxue@ubuntu20:~$ ls -l /usr/src/linux-headers-5.15.0-88-generic
total 1804
drwxr-xr-x 3 root root    4096 Nov  1 06:50 arch
lrwxrwxrwx 1 root root      41 Oct  9 23:46 block -> ../linux-hwe-5.15-headers-5.15.0-88/block
lrwxrwxrwx 1 root root      41 Oct  9 23:46 certs -> ../linux-hwe-5.15-headers-5.15.0-88/certs
lrwxrwxrwx 1 root root      42 Oct  9 23:46 crypto -> ../linux-hwe-5.15-headers-5.15.0-88/crypto
lrwxrwxrwx 1 root root      49 Oct  9 23:46 Documentation -> ../linux-hwe-5.15-headers-5.15.0-88/Documentation
lrwxrwxrwx 1 root root      43 Oct  9 23:46 drivers -> ../linux-hwe-5.15-headers-5.15.0-88/drivers
lrwxrwxrwx 1 root root      38 Oct  9 23:46 fs -> ../linux-hwe-5.15-headers-5.15.0-88/fs
drwxr-xr-x 4 root root    4096 Nov  1 06:50 include
lrwxrwxrwx 1 root root      40 Oct  9 23:46 init -> ../linux-hwe-5.15-headers-5.15.0-88/init
lrwxrwxrwx 1 root root      44 Oct  9 23:46 io_uring -> ../linux-hwe-5.15-headers-5.15.0-88/io_uring
lrwxrwxrwx 1 root root      39 Oct  9 23:46 ipc -> ../linux-hwe-5.15-headers-5.15.0-88/ipc
lrwxrwxrwx 1 root root      42 Oct  9 23:46 Kbuild -> ../linux-hwe-5.15-headers-5.15.0-88/Kbuild
lrwxrwxrwx 1 root root      43 Oct  9 23:46 Kconfig -> ../linux-hwe-5.15-headers-5.15.0-88/Kconfig
drwxr-xr-x 2 root root    4096 Nov  1 06:50 kernel
lrwxrwxrwx 1 root root      39 Oct  9 23:46 lib -> ../linux-hwe-5.15-headers-5.15.0-88/lib
lrwxrwxrwx 1 root root      44 Oct  9 23:46 Makefile -> ../linux-hwe-5.15-headers-5.15.0-88/Makefile
lrwxrwxrwx 1 root root      38 Oct  9 23:46 mm -> ../linux-hwe-5.15-headers-5.15.0-88/mm
-rw-r--r-- 1 root root 1815928 Oct  9 23:46 Module.symvers
lrwxrwxrwx 1 root root      39 Oct  9 23:46 net -> ../linux-hwe-5.15-headers-5.15.0-88/net
lrwxrwxrwx 1 root root      43 Oct  9 23:46 samples -> ../linux-hwe-5.15-headers-5.15.0-88/samples
drwxr-xr-x 7 root root   12288 Nov  1 06:50 scripts
lrwxrwxrwx 1 root root      44 Oct  9 23:46 security -> ../linux-hwe-5.15-headers-5.15.0-88/security
lrwxrwxrwx 1 root root      41 Oct  9 23:46 sound -> ../linux-hwe-5.15-headers-5.15.0-88/sound
drwxr-xr-x 4 root root    4096 Nov  1 06:50 tools
lrwxrwxrwx 1 root root      42 Oct  9 23:46 ubuntu -> ../linux-hwe-5.15-headers-5.15.0-88/ubuntu
lrwxrwxrwx 1 root root      39 Oct  9 23:46 usr -> ../linux-hwe-5.15-headers-5.15.0-88/usr
lrwxrwxrwx 1 root root      40 Oct  9 23:46 virt -> ../linux-hwe-5.15-headers-5.15.0-88/virt

接下来我们看 /usr/src/ 可知,linux-headers-5.15.0-88-generic 存放编译的内核模块,而 linux-hwe-5.15-headers-5.15.0-88 则存放内核头文件。

guowenxue@ubuntu20:~$ ls /usr/src/
linux-headers-5.15.0-79-generic  linux-headers-5.15.0-88-generic  linux-hwe-5.15-headers-5.15.0-79  linux-hwe-5.15-headers-5.15.0-88

3.1.2.3 内核驱动编译

如前面的介绍,由于Linux内核的编译使用了很多 gcc 独有的特性,所以Linux内核只能使用 gcc 编译器编译,而不能使用其它编译器。另外它的编译并不能像应用程序那样直接 gcc hello.c 来编译,必须要写一个Makefile 来完成整个编译工作。显然这个 Makefile 与应用程序空间又有点不一样,下面这是 Linux内核下的一个通用 Makefile :

guowenxue@ubuntu20:~/drivers/x86/driver$ vim Makefile

KERNAL_DIR ?= /lib/modules/$(shell uname -r)/build
PWD :=$(shell pwd)
obj-m += hello.o

modules:
    $(MAKE) -C $(KERNAL_DIR) M=$(PWD) modules
    @make clear

clear:
    @rm -f *.o *.cmd *.mod *.mod.c
    @rm -rf *~ core .depend .tmp_versions Module.symvers modules.order -f
    @rm -f .*ko.cmd .*.o.cmd .*.o.d
    @rm -f *.unsigned

clean:
    @rm -f *.ko
  • KERNAL_DIR 该变量用来指定当前正在运行的Linux内核的源码路径,前面我们已经讲解了如何在Linux服务器下找到我们所需要的内核源码路径。那为什么在编译驱动的时候一定要指定这个路径呢?这是因为我们编写的Linux内核模块可能存在各种依赖关系,如我们写的Led驱动会依赖 GPIO 驱动,如果当前正在运行的Linux内核并没有使能GPIO驱动,那Led驱动是没法工作的。因此在编译Linux内核驱动时,必须要通过 KERNAL_DIR 指定当前正在运行的Linux内核所对应的 Linux内核源码所在路径。

  • obj-m += hello.o 该行告诉 make 命令要将 hello.c 源码编译生成内核模块 hello.ko;

  • $(MAKE) -C $(KERNAL_DIR) M=$(PWD) modules -C: 把工作目录切换到 -C 指定的参数目录,M是Makefile里面的一个变量,作用是回到当前目录继续读取 Makefile 文件。在使用 make 命令编译hello模块时,它将会进入到 KERNAL_DIR 指定的linux内核源码中去编译,然后当前目录下生成驱动模块文件 hello.ko 以及很多编译驱动的临时文件;

  • clear 改目标将编译linux内核过程生成的一些临时文件全部删掉,该目标并非必须的目标;

接下来我们就可以使用 make 命令来编译这个驱动模块,编译生成的 hello.ko 文件就是我们的驱动文件。

guowenxue@ubuntu20:~/drivers/x86/driver$ make
make -C /lib/modules/5.4.0-124-generic/build M=/home/guowenxue/driver/x86/driver modules
make[1]: Entering directory '/usr/src/linux-headers-5.4.0-124-generic-generic'
warning: the compiler differs from the one used to build the kernel
  The kernel was built by: gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0
  You are using:           gcc (Ubuntu 9.4.0-1ubuntu1~20.04.3) 9.4.0
  CC [M]  /home/guowenxue/driver/x86/driver/hello.o
  MODPOST /home/guowenxue/driver/x86/driver/Module.symvers
  CC [M]  /home/guowenxue/driver/x86/driver/hello.mod.o
  LD [M]  /home/guowenxue/driver/x86/driver/hello.ko
  BTF [M] /home/guowenxue/driver/x86/driver/hello.ko
Skipping BTF generation for /home/guowenxue/driver/x86/driver/hello.ko due to unavailability of vmlinux
make[1]: Leaving directory '/usr/src/linux-headers-5.4.0-124-generic'
make[1]: Entering directory '/home/guowenxue/driver/x86/driver'
make[1]: Leaving directory '/home/guowenxue/driver/x86/driver'

guowenxue@ubuntu20:~/drivers/x86/driver$ ls
hello.c  hello.ko  Makefile

3.1.2.4 内核驱动使用

Linux驱动有两种使用方式,第一种是将驱动编译进Linux内核 Image 文件中,这样当Linux内核启动时就会自动运行驱动程序。第二种就是将驱动编译成 .ko 驱动模块文件,在Linux内核启动后使用 insmodrmmod 命令来根据需要动态的加载核卸载。通常,在调试驱动的时候,我们一般会编译成模块,这样不需要编译并升级整个Linux内核,也不需要重启系统。

接下来我们使用 insmod 命令安装这个 hello 驱动。在该驱动代码中,我们并没有作任何实质性操作,只是打印一下 Hello 字符串。但在安装驱动的时候,我们并没有看到这个字符串打印,这是因为它默认会打印到系统的 Console(控制台) 上,而不会输出到我们登录的 SSH 会话中。通常 PC 上的 Console 默认是连接的显示器,而嵌入式Linux开发板则是登录的串口。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo insmod hello.ko

WARNNING: Linux系统下所有的硬件操作包括安装驱动都必须要以 root 的权限来操作。

Linux下的 dmesg 命令的可以查看 linux 内核所有的打印信息,这里就可以看到我们在内核源码中 使用 module_init() 宏声明的 hello_init() 函数的打印信息了,它会在内核驱动模块被加载时调用。

guowenxue@ubuntu20:~/drivers/x86/driver$ dmesg | tail -3
[6846251.317517] hello: loading out-of-tree module taints kernel.
[6846251.317577] hello: module verification failed: signature and/or required key missing - tainting kernel
[6846251.318073] Hello, Linux kernel module.

linux内核的打印信息很多,如果需要的话,我们可以使用 dmesg -c 命令清除之前的打印信息,该命令需要以 root 权限来执行。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo dmesg -c

在 Linux 系统下,我们可以使用 lsmod 命令查看当前系统已经安装的驱动。

guowenxue@ubuntu20:~/drivers/x86/driver$ lsmod | grep hello
hello                  16384  0

接下来我们使用 rmmod 命令卸载前面加载的驱动。这里也可以看到我们在内核源码中 使用 module_exit() 宏声明的 hello_exit() 函数的打印信息,它会在内核驱动模块被卸载时调用。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo rmmod hello

guowenxue@ubuntu20:~/drivers/x86/driver$ dmesg
[6847440.984190] Goodbye, Linux kernel module.

guowenxue@ubuntu20:~/drivers/x86/driver$ lsmod | grep hello

3.1.2.5 驱动模块依赖

我们在执行 lsmod 命令时可以发现,当前系统默认加载了很多的驱动文件,这些驱动是在系统启动时自动加载的。

guowenxue@ubuntu20:~/drivers/x86/driver$ lsmod | head -8
Module                  Size  Used by
btrfs                1540096  0
blake2b_generic        20480  0
xor                    24576  1 btrfs
zstd_compress         225280  1 btrfs
raid6_pq              122880  1 btrfs
ufs                   106496  0
qnx4                   16384  0

那这些自动加载的驱动放在哪里呢?Linux内核的驱动文件都会存放在 /lib/modules 路径下。

guowenxue@ubuntu20:~/drivers/x86/driver$ ls /lib/modules
5.15.0-100-generic  5.15.0-113-generic  5.15.0-122-generic  5.15.0-88-generic  5.15.0-97-generic
5.15.0-101-generic  5.15.0-116-generic  5.15.0-124-generic  5.15.0-89-generic
5.15.0-102-generic  5.15.0-117-generic  5.15.0-125-generic  5.15.0-91-generic
5.15.0-105-generic  5.15.0-119-generic  5.15.0-86-generic   5.15.0-92-generic
5.15.0-107-generic  5.15.0-121-generic  5.15.0-87-generic   5.15.0-94-generic

这里又有很多,哪一个又是我们当前系统的内核驱动呢?这时候我们就需要使用 uname -r 命令查看当前内核版本。

guowenxue@ubuntu20:~/drivers/x86/driver$ uname -r
5.15.0-122-generic

guowenxue@ubuntu20:~/drivers/x86/driver$ ls /lib/modules/5.15.0-122-generic
build   modules.alias      modules.builtin.alias.bin  modules.dep      modules.order    modules.symbols.bin
initrd  modules.alias.bin  modules.builtin.bin        modules.dep.bin  modules.softdep  vdso
kernel  modules.builtin    modules.builtin.modinfo    modules.devname  modules.symbols

如果我们想在 Linux 服务器上使用 CP210 芯片的 USB 转串口设备,那就需要安装它的驱动,这时候我们可以使用 find 命令查找它所在的路径。

guowenxue@ubuntu20:~/drivers/x86/driver$ find /lib/modules/$(uname -r) -iname "*.ko" | grep cp210
/lib/modules/5.15.0-122-generic/kernel/drivers/usb/serial/cp210x.ko

接下来,我们使用 insmod 命令来安装这个驱动试试,结果发现驱动安装失败。此时可以使用 demsg 命令查看内核的打印信息。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo insmod /lib/modules/5.15.0-122-generic/kernel/drivers/usb/serial/cp210x.ko
insmod: ERROR: could not insert module /lib/modules/5.15.0-122-generic/kernel/drivers/usb/serial/cp210x.ko: Unknown symbol in module

guowenxue@ubuntu20:~/drivers/x86/driver$ dmesg
[6848023.950115] cp210x: Unknown symbol usb_serial_generic_open (err -2)
[6848023.950140] cp210x: Unknown symbol usb_serial_generic_close (err -2)
[6848023.950173] cp210x: Unknown symbol usb_serial_deregister_drivers (err -2)
[6848023.950192] cp210x: Unknown symbol usb_serial_generic_unthrottle (err -2)
[6848023.950219] cp210x: Unknown symbol usb_serial_generic_get_icount (err -2)
[6848023.950235] cp210x: Unknown symbol usb_serial_generic_throttle (err -2)
[6848023.950259] cp210x: Unknown symbol usb_serial_register_drivers (err -2)
[6848094.273745] cp210x: Unknown symbol usb_serial_generic_open (err -2)
[6848094.273768] cp210x: Unknown symbol usb_serial_generic_close (err -2)
[6848094.273799] cp210x: Unknown symbol usb_serial_deregister_drivers (err -2)
[6848094.273817] cp210x: Unknown symbol usb_serial_generic_unthrottle (err -2)
[6848094.273842] cp210x: Unknown symbol usb_serial_generic_get_icount (err -2)
[6848094.273857] cp210x: Unknown symbol usb_serial_generic_throttle (err -2)
[6848094.273879] cp210x: Unknown symbol usb_serial_register_drivers (err -2)

这是因为 cp210x.ko 这个驱动模块还依赖另外一个驱动模块,这样在安装 cp210.ko 之前应该先安装它所依赖的其它模块。modprobe 是一个更高级的工具,它不仅能加载模块,还会自动处理模块的依赖关系。它会根据模块名加载模块,并且会从系统的模块路径(如 /lib/modules/$(uname -r)/kernel/)中搜索模块。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo modprobe cp210x

guowenxue@ubuntu20:~/drivers/x86/driver$ dmesg
[6848105.574710] usbcore: registered new interface driver usbserial_generic
[6848105.574720] usbserial: USB Serial support registered for generic
[6848105.576206] usbcore: registered new interface driver cp210x
[6848105.576215] usbserial: USB Serial support registered for cp210x

guowenxue@ubuntu20:~/drivers/x86/driver$ lsmod | grep cp210
cp210x                 36864  0
usbserial              57344  1 cp210x

此外,我们还可以使用 modprobe --show-depends 命令来查看驱动的依赖关系。

guowenxue@ubuntu20:~/drivers/x86/driver$ modprobe --show-depends cp210x
insmod /lib/modules/5.15.0-122-generic/kernel/drivers/usb/serial/usbserial.ko
insmod /lib/modules/5.15.0-122-generic/kernel/drivers/usb/serial/cp210x.ko

由上可知,如果我们想要安装 cp210x.ko 驱动文件,则首先要安装它所依赖的 usbserial.ko 文件。这也就是为什么Linux内核在编译时,必须要指定当前系统正在运行的内核所对应的源码路径,并且这个源码一定要是编译过的。

下面是 insmodmodprobe 命令在使用时的区别:

  • insmod 用于直接加载指定模块文件,不处理依赖。

  • modprobe 用于根据模块名加载模块,自动处理依赖关系,并且提供更多的管理功能。

3.1.3 字符设备注册

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

在前面我们提到,Linux内核将设备按照访问特性分为三类:字符设备、块设备、网络设备。其中字符设备驱动程序适合于大多数简单的硬件设备,而且比起块设备或网络驱动更加容易理解,因此Linux下驱动开发通常从字符设备驱动开始。在开始学习字符设备驱动之前,我们还是简单的了解一下Linux下的应用程序是如何调用驱动程序的,其调用关系如下图所示:

设备分类

由上图所知,应用程序运行在用户空间,而Linux驱动属于内核一部分,因此驱动运行于内核空间,当用户想要实现对内核操作时,必须使用系统调用来实现从用户空间到内核空间的操作。

3.1.3.1 Linux主次设备号

Linux下所有的设备文件都存放在 /dev 路径下,其中字符设备由 ls -l 命令输出的第一列字符 “c”(character) 标识,而块设备则由 “b”(block) 标识,网络设备则不会出现在该路径下,它可以通过 ifconfig 命令查看。

guowenxue@ubuntu20:~$ ls -l /dev/ttyS* | sort | head -5
crw-rw---- 1 root dialout 4, 64 Sep 30 09:43 /dev/ttyS0
crw-rw---- 1 root dialout 4, 65 Sep 30 09:43 /dev/ttyS1
crw-rw---- 1 root dialout 4, 66 Sep 30 09:43 /dev/ttyS2
crw-rw---- 1 root dialout 4, 67 Sep 30 09:43 /dev/ttyS3
crw-rw---- 1 root dialout 4, 68 Sep 30 09:43 /dev/ttyS4

guowenxue@ubuntu20:~$ ls -l /dev/sd* | head -9
brw-rw---- 1 root disk 8,  0 Sep 30 09:43 /dev/sda
brw-rw---- 1 root disk 8,  1 Sep 30 09:43 /dev/sda1
brw-rw---- 1 root disk 8,  2 Sep 30 09:43 /dev/sda2
brw-rw---- 1 root disk 8,  3 Sep 30 09:43 /dev/sda3
brw-rw---- 1 root disk 8,  5 Sep 30 09:43 /dev/sda5
brw-rw---- 1 root disk 8,  6 Sep 30 09:43 /dev/sda6
brw-rw---- 1 root disk 8, 16 Sep 30 09:43 /dev/sdb
brw-rw---- 1 root disk 8, 17 Sep 30 09:43 /dev/sdb1
brw-rw---- 1 root disk 8, 18 Sep 30 09:43 /dev/sdb2

guowenxue@ubuntu20:~$ ifconfig
enp2s0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.2.2  netmask 255.255.255.0  broadcast 192.168.2.255
        inet6 fe80::c8bb:79d9:c9e9:8a5  prefixlen 64  scopeid 0x20<link>
        ether 0a:e0:af:d3:02:c4  txqueuelen 1000  (Ethernet)
        RX packets 432246385  bytes 167429310782 (167.4 GB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 1013298010  bytes 1411242352600 (1.4 TB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

在上面的字符设备和块设备的输出中,有两个数是由逗号分隔,它们分别是该设备的主次设备号。如上面的串口设备 /dev/ttySx 中的 4, 64 与硬盘盘设备 /dev/sdx 中的 8,  0,前面的 4/8 是该设备的主设备号(major number),而后面 64/0 是该设备的次设备号(minor number)。其中:

  • 主设备号表示这是一类设备,如所有主设备号为 4 的设备都为 TTY 字符设备、所有主设备号为 8 的则为磁盘块设备;

  • 而次设备号则表示这是这一类设备的第几个设备,如 /dev/ttyS0 的次设备号为 64,那它就是这一类设备的第 65个设备(次设备号通常从0开始编号),需要注意的是普通串口的次设备号通常从64开始,那前面的 0~63 则给 tty 设备了。

guowenxue@ubuntu20:~$ ls -l /dev/tty[0-5]
crw--w---- 1 root tty 4, 0 Sep 30 09:43 /dev/tty0
crw--w---- 1 root tty 4, 1 Sep 30 09:44 /dev/tty1
crw--w---- 1 root tty 4, 2 Sep 30 09:43 /dev/tty2
crw--w---- 1 root tty 4, 3 Sep 30 09:43 /dev/tty3
crw--w---- 1 root tty 4, 4 Sep 30 09:43 /dev/tty4
crw--w---- 1 root tty 4, 5 Sep 30 09:43 /dev/tty5

在编写Linux内核驱动时,每个设备都要有一个独一无二的设备号(包括主、次设备号),它通常使用 dev_t 类型(在<linux/types.h>中)来定义。dev_t是一个32位的量,其中12位用作主编号,20位用作次编号。在驱动编程中,我们不应该管哪些位是主设备号,哪些位是次设备号,而应该统一使用 <linux/kdev_t.h>中的一套宏设置/获取一个dev_t 的主、次编号:

#define MINORBITS 20
#define MINORMASK ((1U << MINORBITS) - 1)
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))  获取 dev_t 类型设备号变量 dev 中的主设备号
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))   获取 dev_t 类型设备号变量 dev 中的次设备号
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))         根据输入的主(ma)、次(mi)设备号生成一个 dev_t 类型设备号;

在写一个字符驱动时需要做的第一件事是获取一个或多个设备号来使用,在Linux内核里,一些主设备号是静态分配给最普通的设备的,这些设备列表在内核源码树的 Documentation/devices.txt 中列出。因此,作为一个驱动开发者,我们有两个选择:一是简单地捡一个看来没有用的主设备号,二是让内核以动态方式分配一个主设备号给你。只要你是你的驱动的唯一用户就可以捡一个编号用;一旦你的驱动更广泛的被使用了,一个随机捡来的主编号将导致冲突和麻烦。

另外在 Linux系统运行时 /proc/devices 记录了当前所有已被系统使用的设备号。

guowenxue@ubuntu20:~$ cat /proc/devices
Character devices:
  1 mem
  4 /dev/vc/0
  4 tty
  4 ttyS
  5 /dev/tty
  5 /dev/console
  5 /dev/ptmx
  5 ttyprintk
  ... ...
  254 mdp
259 blkext

对于新驱动,我们强烈建议使用动态分配来获取你的主设备编号,而不是随机选取一个当前空闲的编号。下面是 Linux 内核中申请主、次设备号的示例代码:

//#define DEV_MAJOR  79
#ifndef DEV_MAJOR
#define DEV_MAJOR  0
#endif

int dev_major = DEV_MAJOR;
module_param(dev_major, int, S_IRUGO);

static int __init chrdev_init(void)
{
    dev_t      devno;
    int        rv;
    ... ...
    /* dynamic alloc device node major number if not set */
    if(0 != dev_major)
    {
        devno = MKDEV(dev_major, 0);
        rv = register_chrdev_region(devno, 1, DEV_NAME);
    }
    else
    {
        rv = alloc_chrdev_region(&devno, 0, 1, DEV_NAME);
        dev_major = MAJOR(devno);
    }
    ... ...
}

在该代码中:

  • 如果未定义了 DEV_MAJOR 这个宏的话,那 dev_major = DEV_MAJOR 就会被赋值为0,此时驱动将会调用 alloc_chrdev_region() 函数来动态申请一个主、次设备号,然后再通过 MAJOR(devno) 就可以获取到申请的主设备号了;

  • 如果定义了 DEV_MAJOR 这个宏的话,那 dev_major = DEV_MAJOR 就会被赋值为定义的值 79 ,此时驱动将会使用 MKDEV(dev_major, 0) 来创建一个 dev_t 类型的设备号,再调用 register_chrdev_region() 注册到 Linux内核中;

  • 除此以外,Linux内核在insmod 加载时是可以传参数的,在代码中我们加入了 module_param(dev_major, int, S_IRUGO); 这么一句,该行代码就是运行传入一个整型类型的驱动给 dev_major 这个全局变量,这样我们使用 insmod chrdev dev_major=88 这条命令在安装驱动时动态设置其主设备号。

静态注册设备号的函数API说明如下:

devno = MKDEV(dev_major, 0);
rv = register_chrdev_region(devno, 1, DEV_NAME);

int register_chrdev_region(dev_t first, unsigned int count, char *name);

参数:

  • first:要分配的起始设备号,其为 dev_t 类型,可以由 MKDEV() 宏来生成 。first的次编号部分通常是从0开始,但不是强制的。

  • count:请求分配的设备号的总数。注意,如果count太大,你要求的范围可能溢出到下一次编号;但是只要你要求的编号范围可用,一切都任然会正确工作。

  • name:设备名称,即最终 /dev 路径下的设备名。它也会出现在***/proc/devices*** 或 /sysfs 伪文件系统中。

返回值:

  • register_chrdev_region的返回值:是0。出错的情况下,返回一个负的错误码。

动态注册设备号的函数API说明如下:

rv = alloc_chrdev_region(&devno, 0, 1, DEV_NAME);
dev_major = MAJOR(devno);

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)

参数:

  • dev:这是一个输出参数,用来保存申请到的 dev_t 类型设备号。这样我们可以使用 MAJOR() 宏从它里面提取出相应设备的主设备号。

  • baseminor:传入给内核的次设备号起始值,通常次设备号从0开始编号。

  • count:要申请的设备号数量。

  • name:设备名称,即最终 /dev 路径下的设备名。它也会出现在***/proc/devices*** 或 /sysfs 伪文件系统中。

返回值:

  • register_chrdev_region的返回值:是0。出错的情况下,返回一个负的错误码。

动态分配的缺点是你无法提前创建设备节点,因为分配给你的主设备号会发生变化,对于驱动的正常使用这不是问题,但是一旦编号分配了,只能通过 查看 /proc/devices文件才能知道它的值,然后再创建设备节点。

通常我们在驱动安装时会申请主、次设备号,那很显然我们应该在驱动卸载时应该释放主次设备号。设备号释放函数如下:

void unregister_chrdev_region(dev_t from, unsigned count)

参数:

  • from:要释放的设备号。

  • count:表示从 from 开始,要释放的设备号数量。

3.1.3.2 字符设备注册流程

在 Linux 内核中,字符设备的申请和注册流程通常涉及以下几个步骤: 分配主次设备号字符设备结构 (struct cdev) 分配和初始化字符设备文件操作函数 (fops) 设置字符设备注册 以及 字符设备释放 等步骤。

接下来我们编写一个普通的字符设备驱动,来了解一下Linux内核里字符设备驱动的注册流程。

guowenxue@ubuntu20:~/drivers/x86/driver$ vim chrdev.c
/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * A character skeleton driver example in linux kernel.
 */

#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>   /* printk() */
#include <linux/fs.h>       /* everything... */
#include <linux/errno.h>    /* error codes */
#include <linux/types.h>    /* size_t */
#include <linux/cdev.h>     /* cdev */
#include <linux/version.h>  /* kernel version code */
#include <linux/moduleparam.h>

//#define CONFIG_DYNAMIC_ALLOC

/* device name and major number */
#define DEV_NAME         "chrdev"

#ifdef CONFIG_DYNAMIC_ALLOC
#define DEV_MAJOR  0
#else
#define DEV_MAJOR  79
#endif

int dev_major = DEV_MAJOR;
module_param(dev_major, int, S_IRUGO);

#ifdef CONFIG_DYNAMIC_ALLOC
struct cdev   *cdev;
#else
struct cdev    cdev;
#endif

static int chrdev_open (struct inode *inode, struct file *file)
{
    return 0;
}

static int chrdev_close (struct inode *node, struct file *file)
{
    return 0;
}

static struct file_operations chrdev_fops = {
    .owner   = THIS_MODULE,
    .open    = chrdev_open,          /* open()  implementation */
    .release = chrdev_close,         /* close() implementation */
};

static int __init chrdev_init(void)
{
    dev_t      devno;
    int        rv;

    /* 1. Allocate device number */
    if(0 != dev_major)
    {
        devno = MKDEV(dev_major, 0);
        rv = register_chrdev_region(devno, 1, DEV_NAME);
    }
    else
    {
        rv = alloc_chrdev_region(&devno, 0, 1, DEV_NAME);
        dev_major = MAJOR(devno);
    }

    if(rv < 0)
    {
        printk(KERN_ERR "%s driver can't use major %d\n", DEV_NAME, dev_major);
        return -ENODEV;
    }

    /* 2. Allocate and initialize cdev */
#ifdef CONFIG_DYNAMIC_ALLOC
    cdev = cdev_alloc();
    if( !cdev )
    {
         printk(KERN_ERR "Unable to allocate cdev\n");
         goto failed1;
    }
    cdev_init(cdev, &chrdev_fops);
#else
    cdev_init(&cdev, &chrdev_fops);
    cdev.owner = THIS_MODULE;
#endif

    /* 3. Register cdev to linux kernel */
#ifdef CONFIG_DYNAMIC_ALLOC
    rv = cdev_add(cdev, devno, 1);
#else
    rv = cdev_add(&cdev, devno, 1);
#endif
    if( rv )
    {
        rv = -ENODEV;
        printk(KERN_ERR "%s driver regist failed, rv=%d\n", DEV_NAME, rv);
        goto failed1;
    }

    printk(KERN_INFO "%s driver on major[%d] installed.\n", DEV_NAME, dev_major);
    return 0;

failed1:
    unregister_chrdev_region(devno, 1);

    printk(KERN_ERR "%s driver installed failed.\n", DEV_NAME);
    return rv;
}

static void __exit chrdev_exit(void)
{
#ifdef CONFIG_DYNAMIC_ALLOC
    cdev_del(cdev);
#else
    cdev_del(&cdev);
#endif
    unregister_chrdev_region(MKDEV(dev_major,0), 1);

    printk(KERN_INFO "%s driver removed!\n", DEV_NAME);
    return;
}

module_init(chrdev_init);
module_exit(chrdev_exit);

MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");

在阅读C代码时,应用程序通常会从 main() 函数开始,而Linux内核驱动程序则从 module_init() 声明的函数开始。上面的 chrdev_init() 函数就完成了一个字符设备驱动注册的流程。

  1. 分配主次设备号

字符设备需要一个唯一的设备号,通常由 主设备号次设备号 组成。你可以使用 alloc_chrdev_region() 来分配设备号,也可以静态指定,在上面的代码中我们通过宏 CONFIG_DYNAMIC_ALLOC 来控制是动态申请还是静态指定。

... ...

#ifdef CONFIG_DYNAMIC_ALLOC
#define DEV_MAJOR  0
#else
#define DEV_MAJOR  79
#endif

int dev_major = DEV_MAJOR;
module_param(dev_major, int, S_IRUGO);

... ...

static int __init chrdev_init(void)
{
    ... ...
    /* 1. Allocate device number */
    if(0 != dev_major)
    {
        devno = MKDEV(dev_major, 0);
        rv = register_chrdev_region(devno, 1, DEV_NAME);
    }
    else
    {
        rv = alloc_chrdev_region(&devno, 0, 1, DEV_NAME);
        dev_major = MAJOR(devno);
    }
    ... ...
}
  1. 分配并初始化 cdev 结构体

每个字符设备在内核中都对应一个 struct cdev 结构体,该结构体同样有两种方式来获取,一种是静态定义,另外一种是使用 cdev_alloc() 函数来动态分配,在上面的代码中我们同样通过宏 CONFIG_DYNAMIC_ALLOC 来控制是动态申请还是静态指定。然后,使用 cdev_init() 来初始化它,并设置设备操作函数fops

... ...

#ifdef CONFIG_DYNAMIC_ALLOC
struct cdev   *cdev;
#else
struct cdev    cdev;
#endif

... ...

static int __init chrdev_init(void)
{
    ... ...
    /* 2. Allocate and initialize cdev */
#ifdef CONFIG_DYNAMIC_ALLOC
    cdev = cdev_alloc();
    if( !cdev )
    {
         printk(KERN_ERR "Unable to allocate cdev\n");
         goto failed1;
    }
    cdev_init(cdev, &chrdev_fops);
#else
    cdev_init(&cdev, &chrdev_fops);
    cdev.owner = THIS_MODULE;
#endif
    ... ...
}
  1. 注册字符设备

注册Linux字符设备主要是调用 cdev_add() 函数,将初始化的 struct cdev 注册到内核中,使其成为一个已知的设备。

... ...

static int __init chrdev_init(void)
{
    ... ...
    /* 3. Register cdev to linux kernel */
#ifdef CONFIG_DYNAMIC_ALLOC
    rv = cdev_add(cdev, devno, 1);
#else
    rv = cdev_add(&cdev, devno, 1);
#endif
    ... ...
}
  1. 设备文件操作函数fops

struct file_operations(通常缩写为 fops)定义了字符设备的操作方法。在你实现一个字符设备驱动时,需要将这个结构体与设备的 struct cdev 关联,使得内核在处理用户空间对设备的请求时能够找到相应的操作方法。

前面注册 Linux 字符设备的过程是一套标准的流程,没有太多自己发挥的空间。那编写字符设备驱动的核心就在 fops 的实现。fops(file operations)是 Linux 内核中字符设备驱动的关键结构体之一,用于定义与设备交互的各种操作接口(系统调用)。它包含了一组函数指针,每个指针都指向一个实现特定操作的函数,允许用户空间与设备进行读取、写入、打开、关闭等交互。

关于 fops 的实现,我们现在暂时不作过多的介绍,下面的章节我们将再作详细的讲解。

... ...
static int chrdev_open (struct inode *inode, struct file *file)
{
    return 0;
}

static int chrdev_close (struct inode *node, struct file *file)
{
    return 0;
}

static struct file_operations chrdev_fops = {
    .owner   = THIS_MODULE,
    .open    = chrdev_open,          /* open()  implementation */
    .release = chrdev_close,         /* close() implementation */
};
... ...
  1. 字符设备清理

当设备驱动不再需要时,我们会使用 rmmod 命令将其从Linux内核中移除。此时我们必须在module_exit() 声明的函数中释放设备号并注销设备。该过程为首先使用 cdev_del() 删除字符设备,然后再调用 unregister_chrdev_region() 释放设备号。

... ...
static void __exit chrdev_exit(void)
{
#ifdef CONFIG_DYNAMIC_ALLOC
    cdev_del(cdev);
#else
    cdev_del(&cdev);
#endif
    unregister_chrdev_region(MKDEV(dev_major,0), 1);

    printk(KERN_INFO "%s driver removed!\n", DEV_NAME);
    return;
}
... ...

WARNNING:

  1. 要先调用 cdev_del() 将字符设备从 Linux内核中移除后,再调用 unregister_chrdev_region() 函数注销主、次设备;

  2. 如果 cdev 是通过 cdev_alloc() 函数动态申请的,我们不需要显式地 free 它,因为 cdev_del() 会自动释放;

3.1.3.3 字符设备驱动使用

在写好上面的驱动源码之后,我们只需要在此前的 Makefile 文件中添加一行 obj-m += chrdev.o 即可。

guowenxue@ubuntu20:~/drivers/x86/driver$ ls
chrdev.c  hello.c  Makefile

guowenxue@ubuntu20:~/drivers/x86/driver$ vim Makefile
 KERNAL_DIR ?= /lib/modules/$(shell uname -r)/build
 PWD :=$(shell pwd)
 obj-m += hello.o
 obj-m += chrdev.o

 modules:
     $(MAKE) -C $(KERNAL_DIR) M=$(PWD) modules
     @make clear
 clear:
     @rm -f *.o *.cmd *.mod *.mod.c
     @rm -rf *~ core .depend .tmp_versions Module.symvers modules.order -f
     @rm -f .*ko.cmd .*.o.cmd .*.o.d
     @rm -f *.unsigned

 clean:
     @rm -f *.ko

接下来可以使用 make 命令来编译我们的驱动。

guowenxue@ubuntu20:~/drivers/x86/driver$ make
make -C /lib/modules/5.15.0-122-generic/build M=/home/guowenxue/drivers/x86/driver modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-122-generic'
warning: the compiler differs from the one used to build the kernel
  The kernel was built by: gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0
  You are using:           gcc (Ubuntu 9.4.0-1ubuntu1~20.04.3) 9.4.0
  CC [M]  /home/guowenxue/drivers/x86/driver/chrdev.o
  MODPOST /home/guowenxue/drivers/x86/driver/Module.symvers
  CC [M]  /home/guowenxue/drivers/x86/driver/chrdev.mod.o
  LD [M]  /home/guowenxue/drivers/x86/driver/chrdev.ko
  BTF [M] /home/guowenxue/drivers/x86/driver/chrdev.ko
Skipping BTF generation for /home/guowenxue/drivers/x86/driver/chrdev.ko due to unavailability of vmlinux
make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-122-generic'
make[1]: Entering directory '/home/guowenxue/drivers/x86/driver'
make[1]: Leaving directory '/home/guowenxue/drivers/x86/driver'

guowenxue@ubuntu20:~/drivers/x86/driver$ ls
chrdev.c  chrdev.ko  hello.c  Makefile

接下来我们安装驱动试试,为方便查看驱动打印,我们首先使用 dmesg -c 命令清除内核此前的打印信息。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo dmesg -c

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo insmod chrdev.ko

guowenxue@ubuntu20:~/drivers/x86/driver$ dmesg
[6922302.298988] chrdev driver on major[79] installed.

驱动安装成功之后,我们可以看到使用的是我们静态指定的主设备号 79,此时在 /proc/devices 文件中也可以看到我们的字符设备,其中 chrdev 就是我们前面驱动注册时的设备名。

guowenxue@ubuntu20:~/drivers/x86/driver$ cat /proc/devices | grep chrdev
 79 chrdev

接下来我们将前面的驱动卸载掉,从 /proc/devices 文件中可以看到主设备号 78 并没有被使用。接下来我们在安装驱动时传入一个参数 dev_major=78,此时会发现我们的主设备号变成了我们设置的 78

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo rmmod chrdev
guowenxue@ubuntu20:~/drivers/x86/driver$ dmesg
[6922302.298988] chrdev driver on major[79] installed.
[6922430.502586] chrdev driver removed!

guowenxue@ubuntu20:~/drivers/x86/driver$ cat /proc/devices | grep 78
guowenxue@ubuntu20:~/drivers/x86/driver$ sudo insmod chrdev.ko dev_major=78
guowenxue@ubuntu20:~/drivers/x86/driver$ cat /proc/devices | grep chrdev
 78 chrdev

由此可见我们在安装驱动时是可以传入参数的,那在驱动源码中我们必须使用 module_param() 声明接收一个参数。

int dev_major = DEV_MAJOR;
module_param(dev_major, int, S_IRUGO);

接下来我们把代码中的 #define CONFIG_DYNAMIC_ALLOC 这个宏的注释去掉,再重新编译驱动并安装。此时我们会发现内核会给我们动态分配一个主设备号。

guowenxue@ubuntu20:~/drivers/x86/driver$ make

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo rmmod chrdev
guowenxue@ubuntu20:~/drivers/x86/driver$ sudo insmod chrdev.ko

guowenxue@ubuntu20:~/drivers/x86/driver$ dmesg
[6922475.914285] chrdev driver on major[78] installed.
[6922785.510926] chrdev driver removed!
[6922789.062896] chrdev driver on major[238] installed.

guowenxue@ubuntu20:~/drivers/x86/driver$ cat /proc/devices | grep chrdev
238 chrdev

前面我们提到,/dev 路径下存放着我们的字符设备和块设备文件,现在我们发现 Linux 内核里已经成功注册了该设备,但在 /dev 路径并没有这个设备文件。这是因为 /dev 路径是应用程序空间的文件,它并不是属于 Linux 内核来管理的。如果我们想要使用使用该设备文件,则必须使用 mknod 命令来创建该设备文件。

guowenxue@ubuntu20:~/drivers/x86/driver$ ls /dev/ | grep chrdev

mknod 是一个用于在 Linux 或类 Unix 系统中创建设备文件的命令。设备文件通常位于 /dev 目录下,用于在用户空间与硬件设备进行交互。mknod 命令可以创建 字符设备块设备 文件,这些文件通过设备文件描述符提供与硬件的接口。

其语法格式为:

mknod <路径> <类型> <主设备号> <次设备号>

<路径>: 要创建的设备文件路径,例如 /dev/mydevice

<类型>: 设备的类型,常用的类型包括:

  • c: 字符设备(Character device)

  • b: 块设备(Block device)

<主设备号>: 设备的主设备号(通常是一个数字),它代表设备的类型。

<次设备号>: 设备的次设备号(通常是一个数字),它用于标识设备类型中的具体设备。

由上可知,如果想要创建设备节点,则我们必须要知道该设备的设备名及其主、次设备号。由上可知,不管是静态指定,还是动态分配我们都可以从 /proc/devices 中查询到。

guowenxue@ubuntu20:~/drivers/x86/driver$ cat /proc/devices | grep chrdev
238 chrdev

此时我们可以使用 mknod 命令来创建我们的设备节点了,这里之所以叫 chrdev0 是因为它是这一类设备的第一个设备,所以我们让其从 0 开始编号,当然直接命名为 /dev/chrdev 也是可以的。这是因为 Linux 内核主要是通过主、次设备号来区分设备,而不是通过设备名。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo mknod /dev/chrdev0 c 238 0

guowenxue@ubuntu20:~/drivers/x86/driver$ ls -l /dev/chrdev0
crw-r--r-- 1 root root 238, 0 Dec 19 12:52 /dev/chrdev0

此后,我们就可以在应用程序空间通过 open()、read()、write()、close() 等这些系统调用 API 操作我们的设备了。但在上面的驱动的 fops 中我们只实现了 open()close() 系统调用,接下来我们将继续深入学习它并实现驱动的读写操作函数。

因为Linux下设备也是文件,如果我们想删除某个设备节点的话,可以直接使用 rm 命令将其删除。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo rm -f /dev/chrdev0

3.1.4 字符设备读写实现

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

3.1.4.1 字符设备读写实现

前面我们提到 fops(file operations)是 Linux 内核中字符设备驱动的关键结构体之一,它定义应用程序空间与设备驱动交互的各种操作接口。struct file_operations 结构体的定义在 inlude/linux/fs.h 头文件中,包含了很多可以被设备驱动重载的操作接口。常见的字段有:

struct file_operations {
    struct module *owner;          // 驱动模块的所有者(一般设置为 THIS_MODULE)
    loff_t (*llseek) (struct file *, loff_t, int);  // 文件定位操作
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);  // 读取操作
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); // 写入操作
    int (*open) (struct inode *, struct file *);  // 打开设备操作
    int (*release) (struct inode *, struct file *); // 关闭设备操作
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);  // IO 控制命令
    unsigned int (*poll) (struct file *, struct poll_table_struct *);  // 查询设备状态
    int (*mmap) (struct file *, struct vm_area_struct *);  // 映射设备内存
    int (*flush) (struct file *, fl_owner_t id);  // 刷新操作
    int (*fsync) (struct file *, loff_t start, loff_t end, int datasync);  // 同步操作
    int (*aio_read) (struct kiocb *, const char __user *, size_t);  // 异步读操作
    int (*aio_write) (struct kiocb *, const char __user *, size_t);  // 异步写操作
};

常见的 file_operations 成员函数:

  • owner:

​ 通常设置为 THIS_MODULE,表示驱动程序的模块本身,这有助于内核管理模块的生命周期。

  • open:

    打开设备时调用的函数,通常用于初始化设备,分配资源等操作,即 open() 系统调用的实现。

    int open(struct inode *inode, struct file *file)
    
  • release:

    关闭设备时调用的函数,用于释放设备使用的资源,即 close() 系统调用的实现。

    int release(struct inode *inode, struct file *file)
    
  • read:

    读取设备时调用的函数,负责将设备的数据从内核空间拷贝到用户空间,即 read() 系统调用的实现。

    ssize_t read(struct file *file, char __user *buf, size_t len, loff_t *offset)
    
  • write:

    向设备写数据时调用的函数,负责将用户空间的数据复制内核空间的设备中去,即 write() 系统调用的实现。

    ssize_t write(struct file *file, const char __user *buf, size_t len, loff_t *offset)
    
  • unlocked_ioctl:

    处理设备的 IO 控制命令)。ioctl 函数用于执行设备特定的操作,通常用于设备控制和查询,它也就是 ioctl() 系统调用的实现。

    long unlocked_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
    
  • poll:

    用于处理设备的非阻塞 I/O 操作,通常用于支持 select()poll() 系统调用设备驱动。

    unsigned int poll(struct file *file, struct poll_table_struct *wait)
    
  • mmap:

    用于内存映射设备(例如,设备的共享内存),允许用户进程将设备的内存映射到其虚拟地址空间,即 mmap() 系统调用的实现。

    int mmap(struct file *file, struct vm_area_struct *vma)
    

在了解到上面这些知识后,接下来我们对前面的驱动进行一点改造,让它支持 read()write() 系统调用。

guowenxue@ubuntu20:~/drivers/x86/driver$ vim chrdev.c
/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * A character skeleton driver example in linux kernel.
 */

#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>   /* printk() */
#include <linux/fs.h>       /* everything... */
#include <linux/errno.h>    /* error codes */
#include <linux/types.h>    /* size_t */
#include <linux/cdev.h>     /* cdev */
#include <linux/slab.h>     /* kmalloc() */
#include <linux/version.h>  /* kernel version code */
#include <linux/uaccess.h>  /* copy_from/to_user() */
#include <linux/moduleparam.h>

/* device name and major number */
#define DEV_NAME         "chrdev"
int dev_major = 0;
module_param(dev_major, int, S_IRUGO);

#define BUF_SIZE         1024
typedef struct chrdev_s
{
    struct cdev    cdev;
    char          *data;   /* data buffer */
    uint32_t       size;   /* data buffer size */
    uint32_t       bytes;  /* data bytes in the buffer */
} chrdev_t;

static struct chrdev_s   dev;

static ssize_t chrdev_read (struct file *file, char __user *buf, size_t count, loff_t *f_pos)
{
    struct chrdev_s   *dev = file->private_data;
    ssize_t nbytes;
    ssize_t rv = 0;

    /* no data in buffer  */
    if( !dev->bytes )
        return 0;

    /* copy data to user space */
    nbytes = count>dev->bytes ? dev->bytes : count;
    if( copy_to_user(buf, dev->data, nbytes) )
    {
        rv = -EFAULT;
        goto out;
    }

    /* update return value and data bytes in buffer */
    rv = nbytes;
    dev->bytes -= nbytes;

out:
    return rv;
}

static ssize_t chrdev_write (struct file *file, const char __user *buf, size_t count, loff_t *f_pos)
{
    struct chrdev_s   *dev = file->private_data;
    ssize_t nbytes;
    ssize_t rv = 0;

    /* no space left */
    if( dev->bytes >= dev->size )
        return -ENOSPC;

    /* check copy data bytes */
    if( dev->size - dev->bytes < count)
        nbytes = dev->size - dev->bytes;
    else
        nbytes = count;

    /* copy data from user space  */
    if( copy_from_user(&dev->data[dev->bytes], buf, nbytes) )
    {
        rv = -EFAULT;
        goto out;
    }

    /* update return value and data bytes in buffer */
    rv = nbytes;
    dev->bytes += nbytes;

out:
    return rv;
}

static int chrdev_open (struct inode *inode, struct file *file)
{
    struct chrdev_s    *dev; /* device struct address */

    /* get the device struct address by container_of() */
    dev = container_of(inode->i_cdev, struct chrdev_s, cdev);

    /* save the device struct address for other methods */
    file->private_data = dev;

    return 0;
}

static int chrdev_close (struct inode *node, struct file *file)
{
    return 0;
}

static struct file_operations chrdev_fops = {
    .owner   = THIS_MODULE,
    .open    = chrdev_open,          /* open()  implementation */
    .read    = chrdev_read,          /* read()  implementation */
    .write   = chrdev_write,         /* write() implementation */
    .release = chrdev_close,         /* close() implementation */
};

static int __init chrdev_init(void)
{
    dev_t      devno;
    int        rv;

    /* malloc and initial device read/write buffer */
    dev.data = kmalloc(BUF_SIZE, GFP_KERNEL);
    if( !dev.data )
    {
        printk(KERN_ERR " %s driver kmalloc() failed\n", DEV_NAME);
        return -ENOMEM;
    }
    dev.size = BUF_SIZE;
    dev.bytes = 0;
    memset(dev.data, 0, dev.size);

    /* allocate device number */
    if(0 != dev_major)
    {
        devno = MKDEV(dev_major, 0);
        rv = register_chrdev_region(devno, 1, DEV_NAME);
    }
    else
    {
        rv = alloc_chrdev_region(&devno, 0, 1, DEV_NAME);
        dev_major = MAJOR(devno);
    }

    if(rv < 0)
    {
        printk(KERN_ERR "%s driver can't use major %d\n", DEV_NAME, dev_major);
        return -ENODEV;
    }

    /* initialize cdev and setup fops */
    cdev_init(&dev.cdev, &chrdev_fops);
    dev.cdev.owner = THIS_MODULE;

    /* register cdev to linux kernel */
    rv = cdev_add(&dev.cdev, devno, 1);
    if( rv )
    {
        rv = -ENODEV;
        printk(KERN_ERR "%s driver regist failed, rv=%d\n", DEV_NAME, rv);
        goto failed1;
    }

    printk(KERN_INFO "%s driver on major[%d] installed.\n", DEV_NAME, dev_major);
    return 0;

failed1:
    unregister_chrdev_region(devno, 1);
    kfree(dev.data);

    printk(KERN_ERR "%s driver installed failed.\n", DEV_NAME);
    return rv;
}

static void __exit chrdev_exit(void)
{
    cdev_del(&dev.cdev);
    unregister_chrdev_region(MKDEV(dev_major,0), 1);
    kfree(dev.data);

    printk(KERN_INFO "%s driver removed!\n", DEV_NAME);
    return;
}

module_init(chrdev_init);
module_exit(chrdev_exit);

MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");

在上面的驱动代码中,我们首先使用 kmalloc() 函数在内核中申请了一段内存空间。

static int __init chrdev_init(void)
{
    ... ...
    /* malloc and initial device read/write buffer */
    dev.data = kmalloc(BUF_SIZE, GFP_KERNEL);
    if( !dev.data )
    {
        printk(KERN_ERR " %s driver kmalloc() failed\n", DEV_NAME);
        return -ENOMEM;
    }
    dev.size = BUF_SIZE;
    dev.bytes = 0;
    memset(dev.data, 0, dev.size);
    ... ...
}

kmalloc() 是 Linux 内核中用于动态分配内存的函数,类似于用户空间的 malloc(),但是专门用于内核空间。它在内核中分配一块指定大小的内存,并返回该内存块的指针。与用户空间的 malloc() 不同,kmalloc() 分配的内存块必须使用 kfree() 来释放。它的函数原型为:

void *kmalloc(size_t size, gfp_t flags);

参数说明

  • size: 要分配的内存的字节数。这个值决定了要分配的内存块的大小。

  • flags: 分配内存时的标志,指定了内存分配的行为和策略。常见的标志有:

    • GFP_KERNEL: 普通内存分配,允许睡眠,适用于大多数情况。

    • GFP_ATOMIC: 原子内存分配,不允许睡眠,通常用于中断上下文或临界区中,需要快速分配内存的场景。

    • GFP_NOFS: 防止在文件系统代码中调用内存分配,避免死锁。

    • GFP_DMA: 分配物理内存地址在 DMA 受限区域(通常是较低地址空间)的内存,常用于设备驱动中。

    • GFP_HIGHUSER: 试图分配高端内存区域(用户空间的高地址区域)。

    • GFP_KERNELGFP_ATOMIC 是最常用的标志。

返回值

  • 成功: 返回一个指向已分配内存块的指针,内存块的大小为 size 字节。

  • 失败: 如果内存分配失败,返回 NULL

使用 kmalloc() 时,确保释放内存,否则会导致内存泄漏。内核中释放内存的函数为 kfree() ,其原型为:

kfree(ptr);  // 释放通过 kmalloc 分配的内存

需要注意的是:

  • Slab 分配器kmalloc() 是通过 slab 分配器slab allocator)管理内存,旨在提高内存分配效率并减少碎片。同时 slab 分配器管理分配的小块内存,采用缓存的方式来避免反复申请和释放内存。

  • 内存池:内存分配使用了一个高效的内存池机制,分配的内存不会立即释放,而是存入内核缓存中,等待下一次使用。

  • 内存分配失败:如果内存分配失败,kmalloc() 会返回 NULL。在高负载或低内存情况下,分配可能会失败,因此代码中应对分配失败进行适当的处理(如检查返回值、释放已分配的资源等)。

另外,在内核中还有一个 vmalloc() 函数同样可以申请内存,它与 kmalloc() 的区别是:

  • kmalloc(): 从物理内存中分配内存,分配的内存是连续的。适用于对连续物理内存有要求的情况,如设备驱动、内核数据结构等。

  • vmalloc(): 分配虚拟内存,分配的内存可以是非连续的。通常用于分配大块内存或当需要更大的内存时。vmalloc() 的内存是虚拟连续的,但在物理内存中可能不连续。

在上面的这个驱动中,我们添加了 read()write() 系统调用的实现:

static ssize_t chrdev_read (struct file *file, char __user *buf, size_t count, loff_t *f_pos)
{
    struct chrdev_s   *dev = file->private_data;
    ssize_t nbytes;
    ssize_t rv = 0;

    /* no data in buffer  */
    if( !dev->bytes )
        return 0;

    /* copy data to user space */
    nbytes = count>dev->bytes ? dev->bytes : count;
    if( copy_to_user(buf, dev->data, nbytes) )
    {
        rv = -EFAULT;
        goto out;
    }

    /* update return value and data bytes in buffer */
    rv = nbytes;
    dev->bytes -= nbytes;

out:
    return rv;
}

static ssize_t chrdev_write (struct file *file, const char __user *buf, size_t count, loff_t *f_pos)
{
    struct chrdev_s   *dev = file->private_data;
    ssize_t nbytes;
    ssize_t rv = 0;

    /* no space left */
    if( dev->bytes >= dev->size )
        return -ENOSPC;

    /* check copy data bytes */
    if( dev->size - dev->bytes < count)
        nbytes = dev->size - dev->bytes;
    else
        nbytes = count;

    /* copy data from user space  */
    if( copy_from_user(&dev->data[dev->bytes], buf, nbytes) )
    {
        rv = -EFAULT;
        goto out;
    }

    /* update return value and data bytes in buffer */
    rv = nbytes;
    dev->bytes += nbytes;

out:
    return rv;
}

在这两个fops 的函数实现中,我们对比了解一下用户空间的 read()write() 系统调用。

static ssize_t chrdev_read (struct file *file, char __user *buf, size_t count, loff_t *f_pos);
static ssize_t chrdev_write (struct file *file, const char __user *buf, size_t count, loff_t *f_pos);

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

参数说明

  • file: 这是Linux内核中的一个 struct file * 结构体指针,它对应的是系统调用中的 int fd。Linux内核会帮我们将用户空间的 int fd 转换成内核空间中的 struct file *file

  • buf: 它用来接收应用程序空间系统调用传入进来的 void *buf。站在 Linux 内核的角度,我们应该始终认为用户空间传入进来的数据地址是不可靠的,例如它有可能是 NULL 指针或者一段非法访问的地址空间,如果内核直接使用这个非法地址的话,就会导致Linux内核直接挂死。所以在这里会使用 char __user * 来修饰,此外在使用用户空间传进来的 buf 数据时,我们必须使用 copy_to_user()copy_from_user() 两个函数来完成;

  • count: 它对应应用程序空间系统调用传入进来的 size_t count, 即要写的数据大小,或接收数据的 buf 大小;

  • f_pos: 它是Linux内核维护的文件偏移量,其为 loff_t * 类型。我们在应用程序空间可以使用 lseek() 系统调用来修改文件偏移量;

  • 返回值: 它们的返回值 ssize_t 就是实际读到或写入的字节数,如果读取或写入出错时将会返回一个负数,如代码中的 -EFAULT-ENOSPC 等。这些错误码统一定义在 Linux 内核源码的 include/linux/errno.h 头文件中。

在上面的Linux内核代码中,__user 是一个修饰符,用来标记一个指针指向的是用户空间的地址,而不是内核空间的地址。这有助于提高代码的可读性和安全性。它的作用主要是在编译时进行标注,提示开发者和工具链该指针指向的是用户空间的地址。

由于内核空间和用户空间之间存在保护机制,直接从内核空间访问用户空间的内存是被禁止的。因此,必须使用 copy_from_usercopy_to_user 来确保内核在操作用户空间内存时不会破坏系统的内存隔离。

  • 访问权限copy_from_usercopy_to_user 会在内部检查目标地址是否为有效的用户空间地址。如果地址无效,内核将返回未复制的字节数,并报告错误。

  • 返回值检查:如果返回值非零,表示复制操作没有成功完成,通常意味着访问了无效的内存地址(如非法指针或用户空间地址不可访问)。

  • EFAULT 错误码:当内存复制失败时,通常会返回 -EFAULT 错误码,表示 “坏的地址”。

copy_from_usercopy_to_user 是 Linux 内核中用于在内核空间和用户空间之间复制数据的函数。它们在内核模块中非常重要,尤其是在系统调用、设备驱动程序或其他内核功能中,它们用于将数据从用户空间传输到内核空间,或将数据从内核空间传输到用户空间。接下来我们还需要了解这两个函数:

copy_from_user 用于将数据从用户空间复制到内核空间。由于内核空间和用户空间有不同的内存地址空间(内核空间不能直接访问用户空间),所以在进行数据复制时需要确保安全和有效性。

函数原型

long copy_from_user(void *to, const void __user *from, unsigned long len);

参数

  • to: 指向内核空间的目标地址,数据将复制到这个地址。

  • from: 指向用户空间的源地址,要从该地址读取数据。

  • len: 要复制的字节数。

返回值

  • 成功: 返回未复制的字节数(如果返回值为零,表示完全成功复制)。

  • 失败: 如果发生错误(例如,访问无效的用户空间地址),则返回未复制的字节数。

copy_to_user 用于将数据从内核空间复制到用户空间。它是内核与用户空间通信的另一种常见操作,通常用于将计算结果、状态信息等从内核传递到用户空间。

函数原型

long copy_to_user(void __user *to, const void *from, unsigned long len);

参数

  • to: 指向用户空间的目标地址,数据将被复制到这个地址。

  • from: 指向内核空间的源地址,要从该地址读取数据。

  • len: 要复制的字节数。

返回值

  • 成功: 返回未复制的字节数(如果返回值为零,表示完全成功复制)。

  • 失败: 如果发生错误(例如,访问无效的用户空间地址),则返回未复制的字节数。

3.1.4.2 应用程序编程测试

在了解到上面的驱动系统调用实现原理后,接下来我们在应用程序空间编写一个测试程序,来测试我们的驱动是否能够正常工作。为了区分驱动编程,这里我们新建一个 app 文件夹,用来存放我们应用程序空间的代码。其代码如下:

guowenxue@ubuntu20:~$ mkdir -p drivers/x86/apps/ && cd drivers/x86/apps/
guowenxue@ubuntu20:~/drivers/x86/apps$ vim app_chrdev.c
/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * A character skeleton driver test code in user space.
 */

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main (int argc, char **argv)
{
    char      *devname = "/dev/chrdev0";
    char       buf[1024];
    int        rv = 0;
    int        fd;

    fd = open(devname, O_RDWR);
    if( fd < 0 )
    {
        printf("Open device %s failed: %s\n", devname, strerror(errno));
        return 1;
    }

    rv = write(fd, "Hello", 5);
    if( rv< 0)
    {
        printf("Write data into device failed, rv=%d: %s\n", rv, strerror(errno));
        rv = 2;
        goto cleanup;
    }
    printf("Write %d bytes data okay\n", rv);

    memset(buf, 0, sizeof(buf));
    rv = read(fd, buf, sizeof(buf));
    if( rv< 0)
    {
        printf("Read data from device failed, rv=%d: %s\n", rv, strerror(errno));
        rv = 3;
        goto cleanup;
    }
    printf("Read %d bytes data: %s\n", rv, buf);

cleanup:
    close(fd);
    return rv;
}

首先,我们确认并删除之前安装的设备驱动,及创建的设备节点(因为动态分配的主次设备号可能变了)。

guowenxue@ubuntu20:~/drivers/x86/apps$ sudo lsmod | grep chrdev
chrdev                 16384  0
guowenxue@ubuntu20:~/drivers/x86/apps$ sudo rmmod chrdev
guowenxue@ubuntu20:~/drivers/x86/apps$ rm -f /dev/chrdev0

现在我们再安装当前最新的设备驱动。

guowenxue@ubuntu20:~/drivers/x86/apps$ sudo insmod ~/driver/chrdev.ko

编译运行测试程序看看,这是提示打开设备节点出错。这是因为我们只安装了驱动,却没有创建设备节点。

guowenxue@ubuntu20:~/drivers/x86/apps$ gcc app_chrdev.c -o app_chrdev
guowenxue@ubuntu20:~/drivers/x86/apps$ ./app_chrdev
Open device /dev/chrdev0 failed: No such file or directory

接下来查看当前设备驱动的主、次设备号并创建其设备节点。

guowenxue@ubuntu20:~/drivers/x86/apps$ cat /proc/devices | grep chrdev
238 chrdev
guowenxue@ubuntu20:~/drivers/x86/apps$ sudo mknod /dev/chrdev0 c 238 0
guowenxue@ubuntu20:~/drivers/x86/apps$ ls -l /dev/chrdev0
crw-r--r-- 1 root root 238, 0 Dec 19 15:28 /dev/chrdev0

再次运行测试程序,提示出错 Permission denied。这是因为我们必须使用 sudo 权限来创建设备节点,因此这些设备通常都是属于 root 的,普通用户一般没有权限操作这些设备。

guowenxue@ubuntu20:~/drivers/x86/apps$ ./app_chrdev
Open device /dev/chrdev0 failed: Permission denied

接下来,我们以 sudo 权限运行测试程序。

guowenxue@ubuntu20:~/drivers/x86/apps$ sudo ./app_chrdev
Write 5 bytes data okay
Read 5 bytes data: Hello

现在可以看到,我们的应用程序可以通过调用 write() 系统调用往 Linux 内核驱动申请的一段 buf 中写入了 “hello” 字符串,接下来我们又可以调用 read() 系统调用从内核驱动中读取出来,这样就实现了一个设备驱动的读、写操作。

3.1.4.3 inode 与 file 结构体

有心的同学可能会注意到,open()close() 系统调用实现函数的第一个参数文件句柄是 struct inode *inode 结构体类型指针,而后面的其它系统调用实现函数却是 struct file *file

static int chrdev_open (struct inode *inode, struct file *file);
static int chrdev_close (struct inode *node, struct file *file);

static ssize_t chrdev_read (struct file *file, char __user *buf, size_t count, loff_t *f_pos);
static ssize_t chrdev_write (struct file *file, const char __user *buf, size_t count, loff_t *f_pos);

我们知道Linux中一切皆文件。在 Linux 文件系统中,inode(索引节点)是存储文件元数据的结构体,每个设备节点、文件和目录都有一个唯一的 inode。它记录了文件的关键信息,如文件类型、大小、权限、创建时间、修改时间、文件的物理存储位置等。每个文件或设备节点,我们都可以使用 ls -i 命令查看其 inode 号:

guowenxue@ubuntu20:~/drivers/x86/apps$ ls -i app_chrdev
5013027 app_chrdev
guowenxue@ubuntu20:~/drivers/x86/apps$ ls -i /dev/chrdev0
789 /dev/chrdev0

一个 inode 存储着文件的元数据,而不包含文件的名称和文件内容。具体来说,inode 包含以下信息:

  • 文件类型:如常规文件、目录文件、符号链接等。

  • 文件权限:文件的读、写、执行权限(通常是所有者、用户组和其他用户的权限)。

  • 文件所有者:文件的所有者用户和用户组。

  • 文件大小:文件的字节数。

  • 文件的时间戳

    • ctime(改变时间):文件的元数据(如权限)最后一次被修改的时间。

    • mtime(修改时间):文件内容最后一次被修改的时间。

    • atime(访问时间):文件最后一次被访问的时间。

  • 文件的硬链接计数:文件的硬链接数量,即有多少不同的路径名指向该 inode。

  • 文件数据块的指针:指向文件内容的磁盘块(数据块)。这通常是一个指针数组,直接或间接地指向文件存储的数据。

这些信息我们可以通过 ls -l 命令查看到:

guowenxue@ubuntu20:~/drivers/x86/apps$ ls -l app_chrdev
-rwxrwxr-x 1 guowenxue guowenxue 17072 Dec 19 15:43 app_chrdev
guowenxue@ubuntu20:~/drivers/x86/apps$ ls -l /dev/chrdev0
crw-r--r-- 1 root root 238, 0 Dec 19 15:32 /dev/chrdev0

我们知道,一个文件在磁盘上只会存在一份,Linux系统只会维护这一个 inode 结构体。但同一个文件可以被不同的进程打开,这样每个进程都会有一个文件描述符与之相对应。

对于不同的文件类型,inode被填充的成员内容也会有所不同。以注册字符设备为例,我们知道add_chrdev_region() 其实是把一个驱动对象和一个(一组)设备号联系到一起。而创建设备文件,其实就是把设备文件和设备号联系在一起。至此,这三者就被绑定在一起了。这样,内核就有能力创建一个struct inode 实例了。

struct inode 结构体同样定义在 inlude/linux/fs.h 文件中:

struct inode {
    umode_t         i_mode;
    unsigned short      i_opflags;
    kuid_t          i_uid;
    kgid_t          i_gid;
    unsigned int        i_flags;

    ... ...

    dev_t           i_rdev;
    loff_t          i_size;
    struct timespec64   i_atime;
    struct timespec64   i_mtime;
    struct timespec64   i_ctime;
    spinlock_t      i_lock; /* i_blocks, i_bytes, maybe i_size */
    unsigned short          i_bytes;

    .. ...

    union {
        const struct file_operations    *i_fop; /* former ->i_op->default_file_ops */
        void (*free_inode)(struct inode *);
    };
    struct file_lock_context    *i_flctx;
    struct address_space    i_data;
    struct list_head    i_devices;
    union {
        struct pipe_inode_info  *i_pipe;
        struct block_device *i_bdev;
        struct cdev     *i_cdev;
        char            *i_link;
        unsigned        i_dir_seq;
    };

    ... ...
};

inod结构体包含大量关于文件的信息,作为一个通用规则,这个结构体只有两个成员对于编写驱动代码有用:

  • dev_t  i_rdev: 代表设备文件的节点,也就是Linux字符设备的设备号;

  • struct cdev *i_cdev: 这个结构体代表字符设备,这个成员包含一个指针,指向这个结构体,当节点指的是一个字符设备文件时。

struct file *file 结构体代表一个打开的文件。它由内核在 open() 时创建,并传递给在文件上操作的任何函数(与之相对应的是 fd ),直到最后的关闭。在文件的所有实例都关闭后,内核才会释放这个数据结构。在内核源码中struct file的指针常常称为file或者filp(file pointer)。

下面是 struct file 结构体的主要内容,它一样是定义在 inlude/linux/fs.h 文件中:

struct file {
    union {
        struct llist_node   fu_llist;
        struct rcu_head     fu_rcuhead;
    } f_u;
    struct path     f_path;
    struct inode        *f_inode;   /* cached value */
    const struct file_operations    *f_op;

    /*
     * Protects f_ep_links, f_flags.
     * Must not be taken from IRQ context.
     */
    spinlock_t      f_lock;
    enum rw_hint        f_write_hint;
    atomic_long_t       f_count;
    unsigned int        f_flags;
    fmode_t         f_mode;
    struct mutex        f_pos_lock;
    loff_t          f_pos;
    struct fown_struct  f_owner;
    const struct cred   *f_cred;
    struct file_ra_state    f_ra;

    u64         f_version;
#ifdef CONFIG_SECURITY
    void            *f_security;
#endif
    /* needed for tty driver, and maybe others */
    void            *private_data;

#ifdef CONFIG_EPOLL
    /* Used by fs/eventpoll.c to link all the hooks to this file */
    struct list_head    f_ep_links;
    struct list_head    f_tfile_llink;
#endif /* #ifdef CONFIG_EPOLL */
    struct address_space    *f_mapping;
    errseq_t        f_wb_err;
    errseq_t        f_sb_err; /* for syncfs */
}

在上面的内核结构体中,我们可以看到相关联的结构体通常都是你中有我,我中有你。如:

  • 在 ``struct inode结构体中有struct file_operations *i_fop`;

  • struct file 结构体中会有 struct inode *f_inodestruct file_operations *f_op

要解决各个结构体之间错综复杂的爱恨纠葛,不得不提到 linux 内核里的一个奇技淫巧: container_of()

3.1.4.4 container_of介绍

container_of() 是 Linux 内核中常用的一个宏,它可以用于根据结构体中的某个成员地址获取包含该成员的结构体地址。它是内核中处理数据结构、容器或链表等复杂结构时的一个重要工具。

在许多情况下,我们有一个结构体,它包含其他结构体或数据成员的指针。如果你知道这个数据成员的指针(例如某个结构体成员),你可以使用 container_of() 宏来获取该数据成员所在的父结构体的指针,也就是这个宏可以通过成员指针来反推整个结构体的地址。

下面是 container_of() 宏的定义:

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

参数:

  • ptr: 指向结构体成员的指针。

  • type: 包含该成员的结构体类型。

  • member: 结构体中成员的名称。

返回值: 返回的是一个指向包含该成员的父结构体的指针。

工作原理:

  • container_of() 宏的核心思想是通过已知的成员指针(ptr)和该成员在结构体中的偏移量,来计算出包含该成员的结构体的起始地址。

  • 使用 offsetof(type, member) 可以得到结构体成员相对于结构体起始位置的偏移量,从而可以通过成员指针 ptr 和偏移量来计算整个结构体的地址。

下面是 container_of() 宏在应用程序空间的一个使用实例:

guowenxue@ubuntu20:~/drivers/x86/apps$ cat container_of.c
#include <stdio.h>
#include <stddef.h>

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

struct student_s
{
    char    name[50];
    int     age;
};

void print_student(int *p_age)
{
    struct student_s    *p_student;

    // 使用 container_of 获取指向 student_s 结构体的指针
    p_student = container_of(p_age, struct student_s, age);

    printf("Name: %s, Age: %d\n", p_student->name, p_student->age);

    return ;
}

int main(void)
{
    struct student_s    student = {"Zhang San", 30};

    print_student(&student.age);

    return 0;
}

在上面这个代码中,我们定义了一个结构体 struct student_s ,它里面有一个 int  age 成员。在 main() 函数中,我们将其成员 age 的地址 & student.age 传给了 print_student() 函数。

print_student() 函数中,我们使用 container_of() 宏可以通过成员 age 的地址,推导出 main() 函数里 struct student_s student 结构体的地址:

p_student = container_of(p_age, struct student_s, age);

在上面的这行中:

  • struct student_s *p_student 用来存放返回获取到 main() 函数里的 struct student_s student 结构体的地址;

  • p_age 为指向 struct student_s student 结构体里 age 成员的地址;

  • struct student_smain() 函数里的 student 结构体的类型;

  • age 为指向 struct student_s结构体里 age` 成员的名称;

在前面我们写的字符设备驱动的 open() 系统调用实现函数 chrdev_open() 中就使用了这个宏,接下来我们来解释一下这段代码的意义。

static int chrdev_open (struct inode *inode, struct file *file)
{
    struct chrdev_s    *dev; /* device struct address */

    /* get the device struct address by container_of() */
    dev = container_of(inode->i_cdev, struct chrdev_s, cdev);

    /* save the device struct address for other methods */
    file->private_data = dev;

    return 0;
}

static ssize_t chrdev_read (struct file *file, char __user *buf, size_t count, loff_t *f_pos)
{
    struct chrdev_s   *dev = file->private_data;
    ... ...
}

static ssize_t chrdev_write (struct file *file, const char __user *buf, size_t count, loff_t *f_pos)
{
    struct chrdev_s   *dev = file->private_data;
    ... ...
}

前面我们提到,除了open()close() 系统调用实现函数的第一个参数文件句柄是 struct inode *inode 结构体类型指针以外,后面的其它系统调用实现函数都是 struct file *file。在比较复杂的Linux设备驱动中,注册设备的函数和相应的 fops() 函数有可能不在同一个 C 文件中定义,这样我们就不能直接通过全局变量来访问。

chrdev_open() 的函数原型中我们可以看到,Linux内核给我们传入了两个指针,分别为我们现在已经熟悉的 struct inode *inodestruct file *file,对这两个类型指针的深入理解就有助于我们阅读或编写 Linux 设备驱动。

static int chrdev_open (struct inode *inode, struct file *file)

在前面我们写的驱动代码中,定义了一个 struct chrdev_s 结构体,用来保存我们这个设备所相关的数据信息,在这里面就有一个成员 struct cdev cdev

typedef struct chrdev_s
{
    struct cdev    cdev;
    char          *data;   /* data buffer */
    uint32_t       size;   /* data buffer size */
    uint32_t       bytes;  /* data bytes in the buffer */
} chrdev_t;

在Linux字符设备注册时,我们使用 cdev_add() 函数将其注册给了 Linux 内核。事实上Linux 内核此时会创建一个 struct inode *inode 节点并让它里面的 struct cdev *i_cdev i_cdev 指向我们提交的 &dev.cdev

/* register cdev to linux kernel */
rv = cdev_add(&dev.cdev, devno, 1);

那现在我们就清楚了,在 struct inode *inode 节点中有一个成员指针struct cdev *i_cdev ,它指向我们定义的一个结构体 struct chrdev_s 中的 struct cdev cdev 成员。那现在我们就可以使用 container_of() 宏来获取我们自定义结构体 struct chrdev_s 的地址了,如下面 chrdev_open() 的代码:

static int chrdev_open (struct inode *inode, struct file *file)
{
    struct chrdev_s    *dev; /* device struct address */

    /* get the device struct address by container_of() */
    dev = container_of(inode->i_cdev, struct chrdev_s, cdev);

    /* save the device struct address for other methods */
    file->private_data = dev;

    return 0;
}

chrdev_open() 函数中我们可以通过 struct inode *inode 结构体获取到我们自定义的设备私有数据结构体 struct chrdev_s 。但 chrdev_read()chrdev_write() 等系统调用传入的参数却是 struct file *file 类型,这样它们就获取不到这个结构体了。

前面的学习中,我们知道在 struct file 结构体中有一个 void *private_data 指针,此时我们可以将 chrdev_open() 里将获取到的设备私有数据结构体 struct chrdev_s 地址保存到 struct fileprivate_data 指针中,上面代码中的 file->private_data = dev; 就是完成这个工作,这样后续的 read()write() 等其它系统调用都可以获取到我们的设备私有数据了。

static ssize_t chrdev_read (struct file *file, char __user *buf, size_t count, loff_t *f_pos)
{
    struct chrdev_s   *dev = file->private_data;
    ... ...
}

static ssize_t chrdev_write (struct file *file, const char __user *buf, size_t count, loff_t *f_pos)
{
    struct chrdev_s   *dev = file->private_data;
    ... ...
}

在Linux内核驱动中,许多驱动文件中都是你中有我,我中有你,所以理解 Linux 内核中的各个结构体之间的关系,以及 container_of() 宏等这些技巧非常重要。当然,理解它们之后在我们应用程序编程中,也会有非常大的帮助。

3.1.4.5 驱动与应用程序

在前面写的驱动中,我们发现编写驱动有个固定的模式,只需要往里面套代码就可以了,它们之间的大致流程可以总结如下:

  • 实现入口函数 xxx_init() 和卸载函数 xxx_exit()

  • 申请设备号 register_chrdev_region()alloc_chrdev_region()

  • 初始化字符设备:cdev_init()cdev_add()

  • 硬件初始化,如时钟寄存器配置使能,GPIO设置为输入输出模式等;

  • 构建 file_operation 结构体内容,实现硬件各个相关的操作

  • 创建设备节点文件 /dev/xxx,然后开始应用程序编程测试;

我们可以使用下面的思维导图来解读字符设备驱动的实现过程:

而我们则可以使用下面这张图来描述,应用程序空间的程序与Linux内核驱动之间的调用关系。

linux_syscall

3.1.5 字符设备 ioctl() 实现

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

在 Linux 系统中,ioctl(输入/输出控制,Input/Output Control)是一个系统调用,允许用户空间程序与设备驱动程序进行交互,执行一些比标准文件操作更低级的操作。ioctl() 提供了设备特定的控制功能,比如设置硬件参数、获取设备状态、管理硬件资源等。在我们前面学习串口编程时,就会调用 ioctl() 系统调用来设置串口的波特率、奇偶校验位、数据位、停止位等。

另外,在Linux系统设备驱动中,并不是所有的设备的操作都适合通过标准的 readwriteopenclose 系统调用完成。如 Led 灯的驱动,它就不适合使用 write() 来控制 Led 灯的亮灭,在这种情况下通常使用 ioctl() 会更加合适,所以学习了解 Linux 系统下的 ioctl() 系统调用实现非常有必要。

在了解驱动怎么实现之前,我们得先了解 ioctl() 系统调用 API 怎么使用。

3.1.5.1 ioctl() 系统调用

ioctl() 的原型如下:

int ioctl(int fd, unsigned long request, ...);

参数:

  • fd: 打开的文件描述符(通常是通过 open() 获取的)。它指向设备文件或某些内核资源(如网络设备、串口设备等)。

  • request: 一个命令标识符,表示要执行的操作。它告诉内核应该执行哪种类型的控制操作,这个命令通常是通过宏定义的。

  • ...: 取决于具体命令类型,ioctl 可能需要更多的参数。例如,它可以是指向内存缓冲区的指针,或者是整数值、结构体等。

返回值:

  • 成功时,ioctl 返回 0 或正数。

  • 失败时,返回 -1,并设置 errno 以指示错误原因。

ioctl() 的使用通常是针对特定设备驱动的,它需要使用预定义的命令来指定操作的内容。不同的设备驱动支持不同的命令,这些命令通常通过常量(如 TCSETS, TIOCGWINSZ 等)进行定义。这也就意味着不同的设备它支持不同的 ioctl() 命令,所以这个系统调用具体该如何使用与想要操作的设备驱动高度相关。

3.1.5.2 ioctl() 命令字

ioctl() 的命令字参数 int request 本质上就是一个32位数字,理论上可以是任何一个数,但为了保证命令码的唯一性,linux定义了一套严格的规定,通过计算得到这个命令字。它们通常是通过下面宏定义来创建的,这些宏定义根据命令的类型(如设备控制、读取、写入等)来组织。:

#define _IO(type, nr)            _IOC(_IOC_NONE,  type, nr, 0)
#define _IOR(type, nr, size)     _IOC(_IOC_READ, type, nr, size)
#define _IOW(type, nr, size)     _IOC(_IOC_WRITE, type, nr, size)
#define _IOWR(type, nr, size)    _IOC(_IOC_READ | _IOC_WRITE, type, nr, size)

_IO, _IOR, _IOW, _IOWR 等宏是用于定义不同类型的 ioctl() 命令字的常用宏。它们通过 typenr 生成完整的命令字。

  • _IO(type, nr):生成一个没有数据交换的命令字(即设备不需要传递数据)。

  • _IOR(type, nr, size):生成一个从设备读取数据的命令字。

  • _IOW(type, nr, size):生成一个向设备写入数据的命令字。

  • _IOWR(type, nr, size):生成一个既能读取又能写入数据的命令字。

  • 命令字通过 _IOC() 宏结合魔术字、命令编号和数据大小等信息形成一个独特的命令标识符。

32位命令字通常由以下4个部分组成:

  • direction: 表示ioctl命令的访问模式,分为无数据(_IO)、读数据(_IOR)、写数据(_IOW)、读写数据(_IOWR) 四种模式。

  • type: 即 magic number, 表示设备类型,也可翻译成“幻数”或“魔数”(),可以是任意一个char型字符,如’a’、‘b’、‘c’等,其主要作用是使ioctl命令具有唯一的设备标识,不过有很多魔术字在Linux 内核中已经被使用了。

  • nr: 即number,命令编号/序数,取值范围0~255,在定义了多个ioctl命令的时候,通常从0开始顺次往下编号。

  • size,涉及到ioctl的参数arg,占据13bit或14bit,这个与体系有关,arm使用14bit。用来传递arg的数据类型的长度,比如如果arg是int型,我们就将这个参数填入int,系统会检查数据类型和长度的正确性。

魔术字(magic number)是命令字的一个重要组成部分,它通过 type 参数传递给命令宏,用于区分不同类型的设备或不同类别的操作。魔术字是一个 8 位的字符,每个设备类别或设备通常会有一个独特的魔术字,以避免命令在不同设备间冲突。例如,魔术字 'L' 表示Led这类设备,而'K' 表示按键这一类设备。这样,设备驱动程序就可以通过魔术字区分不同类别的操作了。

以下是一些常见的 ioctl 魔术数字,它们已经在 Linux 内核中被使用,并且通常与特定类型的设备驱动或子系统相关联:

魔术数字

设备类别/子系统

说明

T

终端设备(TTY)

用于控制终端的 ioctl 操作

S

串口设备(Serial)

用于串口设备的 ioctl 操作

B

块设备(Block devices)

用于块设备(如磁盘、分区)的 ioctl

F

文件系统(File systems)

用于文件系统相关的 ioctl 操作

D

网络设备(Network devices)

用于网络设备(如网卡)的 ioctl

M

内存(Memory)

内存设备相关的 ioctl 操作

L

网络接口(Network interfaces)

用于网络接口的 ioctl

W

控制台(Console)

用于控制台的 ioctl 操作

N

名称解析(Name resolution)

用于网络协议栈的 ioctl 操作

P

电源管理(Power management)

用于电源管理设备的 ioctl

C

内核控制(Kernel control)

用于内核控制的 ioctl 操作

I

输入设备(Input devices)

用于输入设备(如键盘、鼠标)的 ioctl

R

设备特性(Device attributes)

用于特定设备的特性查询和控制

H

高级硬件(High-level hardware)

用于高级硬件设备的 ioctl

X

特殊用途设备(Special devices)

用于特殊用途设备的 ioctl

V

视频设备(Video devices)

用于视频设备(如摄像头、视频捕捉卡)

如果你想查看哪些魔术数字已经在内核中使用,可以检查内核源代码中的一些头文件,特别是与设备驱动和 ioctl 相关的文件。常见的文件包括:

  • include/linux/ioctl.h:定义了与 ioctl 相关的宏和数据结构,可以查看如何使用魔术数字生成 ioctl 命令字。

  • Documentation/userspace-api/ioctl/ioctl-number.rst 列出了内核使用的魔术数字和对应的命令。

3.1.5.3 设备驱动ioct()

接下来我们在前面写的 chrdev.c 驱动文件的基础上,添加 ioctl() 系统调用实现的函数 chrdev_ioctl() 如下,并在 fops 中添加它的支持。

#if LINUX_VERSION_CODE < KERNEL_VERSION(5,0,0)
#define access_ok_wrapper(type,arg,cmd) access_ok(type, arg, cmd)
#else
#define access_ok_wrapper(type,arg,cmd) access_ok(arg, cmd)
#endif

/* ioctl definitions, use 'c' as magic number */
#define CHR_MAGIC           'c'
#define CHR_MAXNR           2
#define CMD_READ            _IOR(CHR_MAGIC, 0, int)
#define CMD_WRITE           _IOW(CHR_MAGIC, 1, int)

static long chrdev_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
    static int value = 0xdeadbeef;
    int rv = 0;

    /*
     * extract the type and number bitfields, and don't decode
     * wrong cmds: return ENOTTY (inappropriate ioctl) before access_ok()
     */
    if (_IOC_TYPE(cmd) != CHR_MAGIC) return -ENOTTY;
    if (_IOC_NR(cmd) > CHR_MAXNR) return -ENOTTY;

    /*
     * the direction is a bitmask, and VERIFY_WRITE catches R/W transfers.
     * `Type' is user-oriented, while access_ok is kernel-oriented,
     * so the concept of "read" and "write" is reversed
     */
    if (_IOC_DIR(cmd) & _IOC_READ)
        rv = !access_ok_wrapper(VERIFY_WRITE, (void __user *)arg, _IOC_SIZE(cmd));
    else if (_IOC_DIR(cmd) & _IOC_WRITE)
        rv =  !access_ok_wrapper(VERIFY_READ, (void __user *)arg, _IOC_SIZE(cmd));

    if (rv)
        return -EFAULT;

    switch (cmd) {
        case CMD_READ:
            if (copy_to_user((int __user *)arg, &value, sizeof(value)))
                return -EFAULT;
            break;

        case CMD_WRITE:
            if (copy_from_user(&value, (int __user *)arg, sizeof(value)))
                return -EFAULT;
            break;

        default:
            return -EINVAL;
    }

    return 0;
}

static struct file_operations chrdev_fops = {
    .owner          = THIS_MODULE,
    .open           = chrdev_open,  /* open()  implementation */
    .read           = chrdev_read,  /* read()  implementation */
    .write          = chrdev_write, /* write() implementation */
    .unlocked_ioctl = chrdev_ioctl, /* ioctl() implementation */
    .release        = chrdev_close, /* close() implementation */
};

在上面的驱动中,我们定义了两个 ioctl() 系统调用命令字 CMD_READCMD_WRITE ,这样应用程序空间可以通过该系统调用来获取或修改 value 变量的值。

接下来我们重新编译并安装驱动,如果申请到的设备号有变动,则还需要重新创建设备节点。这里我们发现并没有发生变化,就不需要重新创建设备节点了。

guowenxue@ubuntu20:~/drivers/x86/driver$ make
make -C /lib/modules/5.15.0-122-generic/build M=/home/guowenxue/driver/x86/driver modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-122-generic'
warning: the compiler differs from the one used to build the kernel
  The kernel was built by: gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0
  You are using:           gcc (Ubuntu 9.4.0-1ubuntu1~20.04.3) 9.4.0
  CC [M]  /home/guowenxue/driver/x86/driver/chrdev.o
  MODPOST /home/guowenxue/driver/x86/driver/Module.symvers
  CC [M]  /home/guowenxue/driver/x86/driver/chrdev.mod.o
  LD [M]  /home/guowenxue/driver/x86/driver/chrdev.ko
  BTF [M] /home/guowenxue/driver/x86/driver/chrdev.ko
Skipping BTF generation for /home/guowenxue/driver/x86/chrdev.ko due to unavailability of vmlinux
make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-122-generic'
make[1]: Entering directory '/home/guowenxue/driver/x86/driver'
make[1]: Leaving directory '/home/guowenxue/driver/x86/driver'

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo rmmod chrdev
guowenxue@ubuntu20:~/drivers/x86/driver$ sudo insmod chrdev.ko

guowenxue@ubuntu20:~/drivers/x86/driver$ cat /proc/devices | grep chrdev
238 chrdev

3.1.5.4 应用程序测试

接下来我们在应用程序空间编写测试程序,用来测试我们驱动里的 ioctl() 系统调用是否能够正常工作。在该测试程序中,我们首先调用 ioctl() 系统调用读取当前的默认值,然后再写入想要修改的值并读回验证。

guowenxue@ubuntu20:~/drivers/x86/apps$ vim app_chrdev.c
/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * A character skeleton driver test code in user space.
 */

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <fcntl.h>

#define CHR_MAGIC       'c'
#define CMD_READ        _IOR(CHR_MAGIC, 0, int)
#define CMD_WRITE       _IOW(CHR_MAGIC, 1, int)

int main (int argc, char **argv)
{
    char      *devname = "/dev/chrdev0";
    int        value;
    int        fd;

    fd = open(devname, O_RDWR);
    if( fd < 0 )
    {
        printf("Open device %s failed: %s\n", devname, strerror(errno));
        return 1;
    }

    if( ioctl(fd, CMD_READ, &value) < 0 )
    {
        printf("ioctl() failed: %s\n", strerror(errno));
        goto cleanup;
    }
    printf("Default value in driver: 0x%0x\n", value);

    value = 0x12345678;
    if( ioctl(fd, CMD_WRITE, &value) < 0 )
    {
        printf("ioctl() failed: %s\n", strerror(errno));
        goto cleanup;
    }
    printf("Wriee value into driver: 0x%0x\n", value);

    value = 0;
    if( ioctl(fd, CMD_READ, &value) < 0 )
    {
        printf("ioctl() failed: %s\n", strerror(errno));
        goto cleanup;
    }
    printf("Read value from driver : 0x%0x\n", value);

cleanup:
    close(fd);
    return 0;
}

需要注意的是,驱动中的 ioctl() 命令字是我们自定义的,它并不是Linux系统的标准命令字,所以系统头文件中并没有它们的定义。这样我们就需要在应用程序中定义它们,其中定义命令字的宏在 <sys/ioctl.h> 头文件中。

#include <sys/ioctl.h>

#define CHR_MAGIC       'c'
#define CMD_READ        _IOR(CHR_MAGIC, 0, int)
#define CMD_WRITE       _IOW(CHR_MAGIC, 1, int)

下面是该测试程序执行的结果,由此可以看出驱动中的 ioctl() 系统调用实现功能正常。

guowenxue@ubuntu20:~/drivers/x86/apps$ gcc app_chrdev.c -o app_chrdev
guowenxue@ubuntu20:~/drivers/x86/apps$ sudo ./app_chrdev
Default value in driver: 0xdeadbeef
Wriee value into driver: 0x12345678
Read value from driver : 0x12345678

3.1.6 Linux系统设备管理

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

3.1.6.1 驱动创建设备节点

在前面我们写的驱动中,每次测试都要自己手动来创建设备节点比较麻烦,其实我们对 Linux 内核驱动中添加创建设备节点的相关代码,这样应用程序空间就会自动创建设备节点了。下面是需要作的修改:

... ...
typedef struct chrdev_s
{
    struct cdev    cdev;
    struct class  *class;
    struct device *device;
    char          *data;   /* data buffer */
    uint32_t       size;   /* data buffer size */
    uint32_t       bytes;  /* data bytes in the buffer */
} chrdev_t;
... ...

static int __init chrdev_init(void)
{
    ... ...

    /* register cdev to linux kernel */
    rv = cdev_add(&dev.cdev, devno, 1);
    if( rv )
    {
        rv = -ENODEV;
        printk(KERN_ERR "%s driver regist failed, rv=%d\n", DEV_NAME, rv);
        goto failed1;
    }

    /* create device node in user space */
#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 5, 0)
    dev.class = class_create(DEV_NAME);
#else
    dev.class = class_create(THIS_MODULE, DEV_NAME);
#endif
    if (IS_ERR(dev.class)) {
        rv = PTR_ERR(dev.class);
        goto failed2;
    }

    dev.device = device_create(dev.class, NULL, MKDEV(dev_major, 0), NULL, "%s%d", DEV_NAME, 0);
    if( !dev.device )
    {
        rv = -ENODEV;
        printk(KERN_ERR "%s driver create device failed\n", DEV_NAME);
        goto failed3;
    }

    printk(KERN_INFO "%s driver on major[%d] installed.\n", DEV_NAME, dev_major);
    return 0;

failed3:
    class_destroy(dev.class);

failed2:
    cdev_del(&dev.cdev);

failed1:
    unregister_chrdev_region(devno, 1);
    kfree(dev.data);

    printk(KERN_ERR "%s driver installed failed.\n", DEV_NAME);
    return rv;
}

static void __exit chrdev_exit(void)
{
    device_del(dev.device);
    class_destroy(dev.class);

    cdev_del(&dev.cdev);
    unregister_chrdev_region(MKDEV(dev_major,0), 1);

    kfree(dev.data);

    printk(KERN_INFO "%s driver removed!\n", DEV_NAME);
    return;
}

首先我们在结构体 struct chrdev_s 中添加了两个指针成员 struct class  *classstruct device *device ,然后再在 probe() 函数里,添加了class_createdevice_create 两个函数调用,它们分别用于创建设备类和设备文件,使设备能够与用户空间进行交互。其中:

class_create 用于创建一个新的设备类。设备类是一种抽象,它使得多个设备可以按照一定的规则进行组织,通常与 device_create 配合使用。

函数原型:

struct class *class_create(struct module *owner, const char *name);
  • owner: 模块所有者,通常是当前驱动模块。

  • name: 类的名称,该名称将用于创建设备文件时的路径,通常是 /sys/class/<name>

返回值

  • 如果成功,返回一个指向创建的 struct class 的指针。

  • 如果失败,返回 NULL,此时可以使用 ptr_err()IS_ERR() 来检查错误。

device_create 用于在 /dev 目录下创建设备文件,使得用户空间可以通过文件操作接口访问设备。它会将设备与一个已创建的设备类相关联。

函数原型:

struct device *device_create(struct class *class, struct device *parent,
                             dev_t devt, void *drvdata, const char *fmt, ...);
  • class: 设备类,通常是通过 class_create 创建的类。

  • parent: 设备的父设备,如果没有可以传 NULL

  • devt: 设备号,通常是通过 MKDEV() 宏生成的主次设备号。

  • drvdata: 指向驱动数据的指针,通常是设备特有的私有数据。

  • fmt: 设备文件的名称,通常是 /dev/<fmt>

返回值

  • 如果成功,返回一个指向 struct device 的指针。

  • 如果失败,返回 NULL

另外,我们设备在驱动 probe 时会自动创建设备节点,那在设备驱动移除时显然应该删除该设备节点。这个就需要在 chrdev_exit() 函数中添加下面两个函数来删除设备节点和它的类。

    device_del(dev.device);
    class_destroy(dev.class);

3.1.6.2 驱动编译测试

首先我们编译驱动文件。

guowenxue@ubuntu20:~/drivers/x86/driver$ make
make -C /lib/modules/5.15.0-122-generic/build M=/home/guowenxue/drivers/x86/driver modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-122-generic'
warning: the compiler differs from the one used to build the kernel
  The kernel was built by: gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0
  You are using:           gcc (Ubuntu 9.4.0-1ubuntu1~20.04.3) 9.4.0
  CC [M]  /home/guowenxue/driver/x86/driver/chrdev.o
  MODPOST /home/guowenxue/driver/x86/driver/Module.symvers
  CC [M]  /home/guowenxue/driver/x86/driver/chrdev.mod.o
  LD [M]  /home/guowenxue/driver/x86/driver/chrdev.ko
  BTF [M] /home/guowenxue/driver/x86/driver/chrdev.ko
Skipping BTF generation for /home/guowenxue/driver/x86/driver/chrdev.ko due to unavailability of vmlinux
make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-122-generic'
make[1]: Entering directory '/home/guowenxue/driver/x86/driver'
make[1]: Leaving directory '/home/guowenxue/driver/x86/driver'

接下来删除之前可能创建的设备节点。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo rm -f /dev/chrdev0

重新安装设备驱动后,我们会发现此时系统会自动创建设备节点文件 /dev/chrdev0

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo insmod chrdev
guowenxue@ubuntu20:~/drivers/x86/driver$ sudo insmod chrdev.ko
guowenxue@ubuntu20:~/drivers/x86/driver$ ls /dev/chrdev0 -l
crw------- 1 root root 238, 0 Dec 23 16:19 /dev/chrdev0

在移除该设备驱动后,此设备节点也会被自动移除。

guowenxue@ubuntu20:~/drivers/x86/driver$ sudo rmmod chrdev
guowenxue@ubuntu20:~/drivers/x86/driver$ ls /dev/chrdev0
ls: cannot access '/dev/chrdev0': No such file or directory

我们知道驱动是工作在内核空间的, device_create() 函数调用应该是工作在内核空间。而 /dev 路径是在应用程序空间的根文件系统(root filesystem)中,那有可能驱动在加载时,根文件系统都没有加载起来,很显然此时驱动不可能在 /dev 路径下来创建这些设备节点。那这些设备节点究竟是在什么时候、由谁来创建的呢?

要想搞清楚这个问题,我们得了解Linux系统下设备节点管理的细节。

3.1.6.3 udev创建设备节点

在 Linux 中,device_createudev 之间有一个密切的关系。device_create() 用于创建设备文件(通常是 /dev/ 目录下的文件)添加的事件,而 udev 是 Linux 用户空间的设备管理器,负责处理设备的动态管理,包括设备文件的创建、删除和管理等。当前主流的 Linux 操作系统中,如 Debian、Ubuntu、RedHat、Yocto 等都是默认使用的 udev 管理机制,而在一些小的嵌入式系统中,如使用 busyboxbuildroot 做的文件系统中,它们可能使用的是 mdev这个设备管理器。

udev 是 Linux 用户空间的设备管理器,它会作为一个系统服务在后台运行,负责动态管理设备节点、管理设备属性、监听设备事件等。udev 运行在用户空间中,并且与内核中的设备管理系统(如 devtmpfs)紧密集成。它会:

  • udev 会监听内核发送的设备事件(通过 netlink 套接字),如设备的添加(device_create())、删除(device_del())、状态变化等。

  • 它会根据规则(通常位于 /etc/udev/rules.d/)生成设备节点(例如 /dev/chrdev0)并自动设置相应的权限和属性。

  • udev 会在设备插入或删除时自动管理设备节点,不需要手动干预。

当你在驱动中调用 device_create() 函数时,它会创建一个设备文件(如 /dev/chrdev0)。这个设备文件实际上是由内核和 udev 协同管理的。

  1. 设备添加:当内核通过 device_create() 或其他方式创建设备时,udev 会收到一个设备事件。udev 监听到设备事件后,会检查设备的属性(如 sysfs 中的 devpath)。

  2. 规则匹配udev 会根据 /etc/udev/rules.d/ 中的规则文件来确定如何处理设备。规则文件中可以定义设备名称、权限、所有者、组等属性。

  3. 设备文件创建:如果没有显式的设备文件,udev 会创建设备文件(如 /dev/chrdev0),设备文件通常会使用 mknod() 系统调用来创建。

  4. 设备属性设置udev 会根据规则文件的设置来调整设备的权限、所有者等。例如,它可以设置设备文件的权限为 0666,所有者为 root,组为 plugdev

你可以编写规则文件来定义如何处理设备。例如下面就是我在我们的 JelliesV3 项目中,创建了一个规则文件 /etc/udev/rules.d/99-usb-mount.rules,它就的作用就是 U 盘在插入时会自动挂载 (mount) U盘,同时在拔除 U 盘后会自动卸载(umount) U盘。

guowenxue@ubuntu20:~/jelliesv3-ma35d1/bsp/images/fs$ cat rootfs/etc/udev/rules.d/99-usb-mount.rules
ACTION=="add", SUBSYSTEM=="block", KERNEL=="sd*[0-9]", ENV{ID_FS_TYPE}!="", RUN+="/etc/udev/scripts/usb-mount.sh $env{DEVNAME} add"
ACTION=="remove", SUBSYSTEM=="block", KERNEL=="sd*[0-9]", RUN+="/etc/udev/scripts/usb-mount.sh $env{DEVNAME} remove"

guowenxue@ubuntu20:~/jelliesv3-ma35d1/bsp/images/fs$ cat rootfs/etc/udev/scripts/usb-mount.sh
#!/bin/sh
# Shell script for /etc/udev/rules.d/99-usb-mount.rules

# Get the device name and action passed to the script
DEVICE=$1
ACTION=$2

# mount point
MOUNT_POINT="/media/$(basename $DEVICE)"

# Mount or unmount the partition
if [ "$ACTION" == "add" ]; then
    mkdir -p $MOUNT_POINT
    mount $DEVICE $MOUNT_POINT
elif [ "$ACTION" == "remove" ]; then
    umount $MOUNT_POINT
fi

需要注意的是,Linux内核在通知 udev 在执行我们的这些规则时,会传入很多环境变量,如上面的 ACTIONSUBSYSTEMKERNEL 等。这样我们可以通过这些环境变量来识别它是什么设备,产生了怎样的事件,接下来我们再根据 udev 的相应规则来做相应的处理。

下面这个脚本则是如果设备上发现了 USB 无线网卡设备,并且其 厂商:设备 ID 为 8179:0bda 的话,就调用 /etc/init.d/s40wlan start 脚本开始自动安装其设备驱动,并开启WiFi无线连接。

guowenxue@ubuntu20:~/jelliesv3-ma35d1/bsp/images/fs$ cat rootfs/etc/udev/rules.d/99-usb-wifi.rules
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="8179", RUN+="/etc/init.d/s40wlan start"

udevadm 是一个用于与 udev 交互的命令行工具,我们在系统下写完 udev 规则文件后,通常需要使用它来重新加载规和触发。常见的 udevadm 命令包括:

  • udevadm control --reload:重新加载 udev 规则。

  • udevadm trigger:手动触发设备事件,通常用于更新设备文件或规则。

  • udevadm info --query=all --name=/dev/chrdev0:查询设备的详细信息。

下面是使用 udevadm 命令查询我们驱动的设备节点例子:

guowenxue@ubuntu20:~/drivers/x86/driver$ udevadm info --query=all --name=/dev/chrdev0
P: /devices/virtual/chrdev/chrdev0
N: chrdev0
L: 0
E: DEVPATH=/devices/virtual/chrdev/chrdev0
E: DEVNAME=/dev/chrdev0
E: MAJOR=238
E: MINOR=0
E: SUBSYSTEM=chrdev

至此,我们了解了 Linux 下字符设备驱动开发的基本流程和原理,但这些驱动代码都是脱离硬件来写的。接下来我们将会在 IGKBoard-IMX6ULL 开发板上,继续学习针对具体硬件开发板的Linux设备驱动编程。

3.2 ARM Linux驱动开发

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

3.2.1 Linux BSP编译

通过前面的学习我们了解到,如果要编写Linux内核驱动的话,离不开当前开发板的内核源码,并且这个Linux内核一定是要编译过的。这样要想在 IGKBoard-IMX6ULL 上学习Linux设备驱动开发,那就首先要编译开发板的BSP(Board Support Package),并制作出系统烧录镜像。这样我们开发板上运行的Linux系统内核,才和我们编译驱动的内核保持一致。

3.2.1.1 编译系统介绍

嵌入式Linux系统BSP(Board Support Package)的整个编译过程非常繁琐且漫长,而我们在BSP开发过程中却经常需要编译这些源码,如果每次都是逐条命令输入执行,这个过程非常麻烦且容易出错。为此,凌云实验室郭工为 IMX 系列开发板编写了一套编译脚本,这样方便各个开发板的一键编译。当前该编译系统支持的开发板有:

  • 凌云实验室 IGKBoard-IMX6ULL 开发板

  • 凌云实验室 IGKBoard-IMX8MP 开发板

  • 凌云实验室 GauGuin-imx8mp 开发板

  • 正点原子 IMX6ULL 开发板

凌云实验室 IMX 系列开发板一键编译脚本托管在 凌云实验室的 git 服务器上, 我们可以从该站点上下载最新的编译系统。这里以 IGKBoard-IMX6ULL 开发板为例,使用 git 命令下载该项目源码并重命名为 igkboard-imx6ull(也可以命名为其它开发板的名字)。

guowenxue@ubuntu20:~$ git clone http://master.weike-iot.com:8088/r/build-imxboard.git ~/igkboard-imx6ull

guowenxue@ubuntu20:~$ cd ~/igkboard-imx6ull/

guowenxue@ubuntu20:~$ ls bsp
bootloader  config.json  debian  drivers  images  kernel  tools  yocto

下面是BSP编译系统各文件的简单介绍。

文件/文件夹

描述

config.json

编译系统的JSON格式配置文件

tools

编译系统所依赖系统命令和交叉编译器安装脚本

bootloader

Bootloader的编译脚本及补丁文件

kernel

Linux内核的编译脚本及补丁文件

drivers

驱动学习示例源码及测试程序

images

Linux系统烧录镜像一键制作脚本

debian

Debian根文件系统一键制作的脚本

yocto

Yocto系统源码编译脚本

下面是顶层编译系统的配置文件,默认为 IGKBoard-IMX6ULL 开发板的配置。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp$ cat config.json
{
    "bsp":
    {
        "board":"igkboard-imx6ull",
        "version":"lf-6.1.36-2.1.0",
        "giturl":"https://github.com/nxp-imx/",
        "cortexAtool":"/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf-",
        "cortexMtool":"/opt/gcc-cortexM-10.3-2021.07/bin/arm-none-eabi-"
    },
    "system":
    {
        "distro":"yocto",
        "version":"mickledore",
        "imgsize":"3072",
        "bootsize":"100"
    }
}

下面是该配置文件中各选项的配置说明:

  • bsp.board 该选项指定要编译的目标开发板,具体支持哪些可以查看 kernel/patches/ 文件夹下有哪些开发板文件夹.

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp$ ls kernel/patches/
alientek-imx6ull-v20  alientek-imx6ull-v24  gauguin-imx8mp  gen_patch.sh  igkboard-imx6ull  igkboard-imx8mp

如上面支持的开发板 igkboard-imx6ull、 igkboard-imx8mp、alientek-imx6ull-v20

  • bsp.version 该选项指定要编译的Linux BSP版本,具体支持哪些可以查看 kernel/patches/ 相应开发板下支持哪些补丁.

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp$ ls kernel/patches/igkboard-imx6ull/
linux-imx-lf-6.1.36-2.1.0.patch

如上面支持的 BSP 版本为 lf-6.1.36-2.1.0

  • bsp.giturl 该选项指定Linux BSP源码的下载地址,默认从NXP 官方的 github 仓库下载;

  • bsp.cortexAtool 该选项用来指定 Cortex-A核 + Linux系统的交叉编译器;

  • bsp.cortexMtool 该选项用来指定 Cortex-M核 + FreeRTOS系统的交叉编译器;

  • system.distro 该选项指定要发型的Linux系统类型,当前支持 YoctoDebian 系统;

  • system.version 该选项指定要发型的Linux系统类型相应版本;

  • system.imgsize 该选项指定要生成的Linux系统镜像的大小,通常需要根据根文件系统的大小来调整;

  • system.bootsize 该选项指定要生成的Linux系统镜像Boot分区(FAT文件系统)的大小;

3.2.1.2 编译系统配置

在开始编译之前,我们首先确定当前编译系统支持哪些BSP版本,这点可以通过查看相应开发板的补丁文件来获取。如下所示,当前 IGKBoard-IMX6ULL 只支持 lf-6.1.36-2.1.0 这个版本。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp$ ls kernel/patches/igkboard-imx6ull/
linux-imx-lf-6.1.36-2.1.0.patch

接下来我们需要修改顶层的配置文件 config.json 如下:

{
    "bsp":
    {
        "board":"igkboard-imx6ull",
        "version":"lf-6.1.36-2.1.0",
        "giturl":"http://studio.weike-iot.com:2211",
        "cortexAtool":"/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf-",
        "cortexMtool":"/opt/gcc-cortexM-10.3-2021.07/bin/arm-none-eabi-"
    },
    "system":
    {
        "distro":"yocto",
        "version":"mickledore",
        "imgsize":"3072",
        "bootsize":"100"
    }
}
  • 目标开发板 board 使用默认的 igkboard-imx6ull ;

  • 交叉编译器 cortexAtool 使用默认的交叉编译器路径;

  • 版本 version 使用默认的 lf-6.1.36-2.1.0

  • BSP源码下载地址 giturl 修改为实验室的文件服务器地址 http://studio.weike-iot.com:2211

  • 其它的配置我们都保持默认;

3.2.1.3 编译系统安装

在嵌入式BSP系统开发过程中,依赖很多的Linux系统命令及交叉编译器。在该编译系统的 tools 文件夹下,有一个 setup_tools.sh 脚本可以用来一键安装它们。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp$ ls tools/
imgmnt  setup_tools.sh

接下来以 root 权限执行 tools/setup_tools.sh 脚本,来安装BSP源码编译所依赖的系统工具和交叉编译器,如果之前已经安装过则会自动跳过。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp$ cd tools/

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/tools$ sudo ./setup_tools.sh
 All system tools already installed, skip it
 All development tools already installed, skip it
 start download cross compiler from ARM Developer for Cortex-A core
 ... ...
 start decompress cross compiler for Cortex-A core
 ... ...
  cross compiler for Cortex-A installed to "/opt/gcc-aarch32-10.3-2021.07" successfully

至此,整个编译系统的配置和开发环境搭建已经完成,其中交叉编译器将会安装到 /opt/gcc-aarch32-10.3-2021.07 路径下。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/tools$ /opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf-gcc --version
arm-none-linux-gnueabihf-gcc (GNU Toolchain for the A-profile Architecture 10.3-2021.07 (arm-10.29)) 10.3.1 20210621
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

3.2.1.4 编译bootloader

在做好前面的准备工作后,依赖这套编译脚本整个BSP的源码编译工作将会非常容易。

首先我们需要编译 bootloader程序,此时我们只需要切换到 bootloader 文件夹下,执行 build.sh 脚本即可。它将会自动从配置的源码服务器上下载源码,并打上相应的补丁文件,开始漫长的编译工作。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp$ cd bootloader/

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/bootloader$ ls
build.sh  patches

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/bootloader$ ./build.sh
 start build bootloader for igkboard-imx6ull
 start fetch uboot-imx source code
 ... ...
 do patch for uboot-imx now...
 ... ...
 start build uboot-imx
 ... ...
  MKIMAGE u-boot-dtb.imx
  OFCHK   .config
+ cp u-boot-igkboard-imx6ull.imx /home/guowenxue/igkboard-imx6ull/bsp/bootloader/install
+ set +x

 bootloader installed to '/home/guowenxue/igkboard-imx6ull/bsp/bootloader/install'
u-boot-igkboard-imx6ull.imx

编译完成后,生成的 bootloader 文件将会存放到自动创建的 install 文件夹下。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/bootloader$ ls install/
u-boot-igkboard-imx6ull.imx

3.2.1.5 编译Linux内核

同样地,编译 Linux内核源码的工作也非常简单,我们只需要切换到 kernel 文件夹下,执行 build.sh 脚本即可。它将会自动从配置的源码服务器上下载源码,并打上相应的补丁文件,开始漫长的编译工作。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp$ cd kernel/

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel$ ls
build.sh  patches

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel$ ./build.sh
 start build linux kernel for igkboard-imx6ull
 start fetch linux kernel source code
... ...
 do patch for linux-imx now...
 ... ...
 start build linux-imx
 ... ...
 linux kernel installed to '/home/guowenxue/igkboard-imx6ull/bsp/kernel/install'
igkboard-imx6ull.dtb  lib  overlays  zImage

编译完成后生成的 linux内核文件也将会存放到自动创建的 install 文件夹下。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel$ ls install/
igkboard-imx6ull.dtb  lib  overlays  zImage

3.2.1.6 制作系统镜像

系统镜像的制作将依赖前面编译好的 Bootloader 和 Linux内核镜像文件,此外它还需要从实验室的文件服务器上下载根文件系统系统树来制作系统镜像。因为 Linux 系统镜像需要使用 sudoloop 设备,所以这个需要在自己的虚拟机或实验室的编译服务器具有相应权限的账号下工作。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp$ cd images/

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/images$ ls
build.sh

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/images$ sudo ./build.sh
 Build system image yocto-mickledore-lf-6.1.36-2.1.0.img
 INFO: download rootfs-yocto-mickledore.tar.zst form http://studio.xxx.com:2211
 ... ...
 start generate empty system image
 ... ...
 start partition system image
 losetup system image on loop4
 ... ...
 start format system image
 ... ...
 start install u-boot image
 ... ...
 start install linux kernel images
 update drivers in root filesystem
 start install root filesystem
 Build system image yocto-mickledore-lf-6.1.36-2.1.0.img done
 Start bzip2 compress yocto-mickledore-lf-6.1.36-2.1.0.img
 ... ...
 Shell script exit now, do some clean work

 kpartx -dv /dev/loop4
del devmap : loop4p1
del devmap : loop4p2
 losetup -d /dev/loop4

编译完成后,系统镜像和烧录所需要的文件将会存放到自动创建的 install 文件夹下。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/images$ ls install/
u-boot-igkboard-imx6ull.imx  yocto-mickledore-lf-6.1.36-2.1.0.img.bz2

接下来将上面两个文件下载到自己的 Windows 系统下,参考前面的文档将这里自己编译制作的系统镜像烧录到开发板上并启动。这时候,我们就有了开发板上正在运行的Linux内核源码了,它就在 bsp/kernel/linux-imx/ 路径下。

guowenxue@ubuntu20:~/igkboard-imx6ull$ ls bsp/kernel/linux-imx/
arch     crypto         init      kernel           Makefile  samples   usr
block    Documentation  io_uring  lib              mm        scripts   virt
certs    drivers        ipc       LICENSES         net       security
COPYING  fs             Kbuild    MAINTAINERS      README    sound
CREDITS  include        Kconfig   MAINTAINERS.NXP  rust      tools

接下来我们就可以学习ARM 开发板上的Linux设备驱动开发了。

3.2.2 编写Hello驱动

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

同样地,这里的驱动开发我们也从 “hello world” 例子开始,来讲解ARM Linux开发板上的设备驱动开发的详细流程。

3.2.2.1 编写 hello 驱动

首先创建开发板驱动学习的项目文件夹。

guowenxue@ubuntu20:~$ mkdir -p drivers/imx6ull/driver && cd drivers/imx6ull/driver

编写 hello.c 驱动模块源码如下,它与 X86 架构下的 hello 驱动模块完全一样。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim hello.c
/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * Hello driver example in linux kernel.
 */

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

static __init int hello_init(void)
{
    printk(KERN_ALERT "Hello, Linux kernel module.\n");
    return 0;
}

static __exit void hello_exit(void)
{
    printk(KERN_ALERT "Goodbye, Linux kernel module.\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");

3.2.2.2 内核驱动编译

下面是我们 ARM Linux开发板的驱动模块编译通用 Makefile 文件。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim Makefile

ARCH ?= arm
CROSS_COMPILE ?= /opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf-
KERNAL_DIR ?= ~/igkboard-imx6ull/bsp/kernel/linux-imx/

PWD :=$(shell pwd)

obj-m += hello.o

modules:
    $(MAKE) ARCH=${ARCH} CROSS_COMPILE=${CROSS_COMPILE} -C $(KERNAL_DIR) M=$(PWD) modules
    @make clear

clear:
    @rm -f *.o *.cmd *.mod *.mod.c
    @rm -rf *~ core .depend .tmp_versions Module.symvers modules.order -f
    @rm -f .*ko.cmd .*.o.cmd .*.o.d
    @rm -f *.unsigned

clean:
    @rm -f *.ko

它与我们前面编写的 X86 下的 Makefile 差不多,只是修改了一下几点:

  • KERNAL_DIR 该变量用来指定当前开发板上正在运行的Linux内核源码路径,它为前面BSP里编译的 Linux内核源码路径;

  • ARCH 这里指定我们要编译的驱动版本为 arm 版本,否则默认编译生成 X86 架构的驱动;

  • CROSS_COMPILE 用来指定我们的交叉编译器;

接下来我们就可以使用 make 命令来编译这个驱动模块。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ make
make ARCH=arm CROSS_COMPILE=/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf- -C ~/igkboard-imx6ull/bsp/kernel/linux-imx/ M=/home/guowenxue/drivers/imx6ull/driver modules
make[1]: Entering directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/hello.o
  MODPOST /home/guowenxue/drivers/imx6ull/driver/Module.symvers
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/hello.mod.o
  LD [M]  /home/guowenxue/drivers/imx6ull/driver/hello.ko
make[1]: Leaving directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
make[1]: Entering directory '/home/guowenxue/drivers/imx6ull/driver'
make[1]: Leaving directory '/home/guowenxue/drivers/imx6ull/driver'


guowenxue@ubuntu20:~/drivers/imx6ull/driver$ ls
hello.c  hello.ko  Makefile

3.2.2.3 开发板驱动测试

接下来我们在开发板上使用 scp 命令或其它方式将 hello.ko 从编译服务器上下载到开发板上来。

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/hello.ko .
The authenticity of host '[192.168.0.2]:2200 ([192.168.0.2]:2200)' can't be established.
ED25519 key fingerprint is SHA256:d2Smvi54uezvn5d1vERQcfAP45aCeex/AKefLP2ybas.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '[192.168.0.2]:2200' (ED25519) to the list of known hosts.
guowenxue@192.168.0.2's password:
hello.ko                                                                100% 4516   193.7KB/s   00:00

root@igkboard:~# ls hello.ko
hello.ko

接下来使用 insmod 命令安装驱动文件测试,使用 dmesg 可以看到模块加载时的打印信息。

root@igkboard:~# insmod hello.ko
root@igkboard:~# lsmod  | grep hello
hello                  16384  0

root@igkboard:~# dmesg | tail -1
[407380.821705] Hello, Linux kernel module.

使用 rmmod 命令在删除驱动模块时,用 dmesg 命令也可以看到模块在卸载时的打印信息。

root@igkboard:~# rmmod hello.ko

root@igkboard:~# dmesg | tail -2
[407380.821705] Hello, Linux kernel module.
[407424.221337] Goodbye, Linux kernel module.

至此,我们以 hello 驱动模块为例,了解了 Linux 下设备驱动开发的基本流程。接下来我们将会在 IGKBoard-IMX6ULL 开发板上学习具体的硬件驱动开发。

3.2.3 设备驱动模型

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

3.2.3.1 Linux设备驱动模型

Linux设备驱动模型是Linux内核为了管理硬件上的设备和对应的驱动制定的一套软件体系。它通过将设备、驱动程序和总线(如PCI、USB)抽象为统一的对象,并建立了设备和驱动程序之间的绑定机制,从而实现了对硬件设备的有效管理。在linux设备驱动模型中,总线可以看作是linux设备模型的核心,系统中的其他设备以及驱动都是以总线为核心围绕。

驱动模型中的总线可以是真是存在的物理总线(USB总线,I2C总线,PCI总线),也可以是为了驱动模型架构设计出的虚拟总线(Platform总线)。为此linux设备驱动模型都将围绕”总线–设备–驱动”来展开,因为符合linux设备驱动模型的设备与驱动都是必须挂载在一个总线上的,无论是实际存在的或者虚拟的。

这里我们以几个实际的物理外设来描述一下总线:

  • 在开发板的 USB 接口上插入了一个 U 盘设备,那它就挂在了 Linux 内核的 USB 总线上。USB和PCI 总线差不多,每一个USB 设备都有一个厂商ID(Vendor ID,VID)和产品ID(Production ID, PID),这样总线上的主机控制器就可以通过 VID 和 PID 来识别它们是什么设备、该加载哪个驱动。

    root@igkboard:~# lsusb
    Bus 001 Device 003: ID 0bda:f179 Realtek Semiconductor Corp. RTL8188FTV 802.11b/g/n 1T1R 2.4G WLAN Adapter
    Bus 001 Device 004: ID 2c7c:0125 Quectel Wireless Solutions Co., Ltd. EC25 LTE modem
    Bus 001 Device 002: ID 0424:2514 Microchip Technology, Inc. (formerly SMSC) USB 2.0 Hub
    Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
    
  • 在开发板的 I2C 接口上接入了一个 SHT20 温湿度传感器,那它就挂在了 Linux 内核的 I2C 总线上。Linux 内核通过 I2C 总线提供了设备驱动支持,使得 I2C 设备能够与操作系统和其他硬件组件交互。每个I2C从设备都有一个 7-bit 的设备地址,这样我们也可以使用该设备来区分它们。

    root@igkboard:~# i2cdetect -y 0
         0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
    00:                         -- -- -- -- -- -- -- --
    10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
    20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
    30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
    40: 40 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
    50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
    60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
    70: -- -- -- -- -- -- -- --
    
  • 开发板上的 Led、按键、网卡等这些设备,它们并不直接连接到标准的总线接口,而是通过特定的硬件接口或自定义方式与 CPU 连接通信。对于这类设备的管理 Linux 内核实现了一种虚拟总线类型 Platform总线platform bus),它用于连接和管理这类没有标准硬件总线(如 PCI、I2C、SPI 等)的设备。

这样,设备模型通过几个数据结构来反映当前系统中总线、设备以及驱动的工作状况:

  • 设备(device) :挂载在某个总线的物理设备;

  • 驱动(driver) :与特定设备相关的软件,负责初始化该设备以及提供一些操作该设备的操作方式;

  • 总线(bus) :负责管理挂载对应总线的设备以及驱动;

  • 类(class) :对于具有相同功能的设备,归结到一种类别,进行分类管理;

在 Linux 的根文件系统中有个 /sys 文件目录,里面记录各个设备之间的关系。

root@igkboard:~# ls /sys/
block  bus  class  dev  devices  firmware  fs  kernel  module  power

下面介绍 /sys 下几个较为重要目录的作用:

/sys/bus目录 下的每个子目录都是注册好了的总线类型。这里是设备按照总线类型分层放置的目录结构, 每个子目录(总线类型)下包含两个子目录:devices和drivers文件夹。其中devices下是该总线类型下的所有设备, 而这些设备都是符号链接,它们分别指向真正的设备(***/sys/devices/***下)。而drivers下是所有注册在这个总线上的驱动,每个driver子目录下 是一些可以观察和修改的driver参数。

root@igkboard:~# ls /sys/bus/
ac97         cpu           gpio  mdio_bus  nvmem     rpmsg   serio  ulpi        w1
clockevents  event_source  hid   media     pci       scsi    soc    usb         workqueue
clocksource  gadget        i2c   mmc       pci-epf   sdio    spi    usb-serial
container    genpd         iio   mmc_rpmb  platform  serial  tee    virtio

root@igkboard:~# ls /sys/bus/i2c/
devices  drivers  drivers_autoprobe  drivers_probe  uevent

root@igkboard:~# ls /sys/bus/i2c/devices/
1-006f  i2c-0  i2c-1

root@igkboard:~# ls /sys/bus/i2c/drivers
Goodix-TS     edt_ft5x06      isl29018     mpl3115             rtc-pcf8523          tsc2004
ad7879        egalax_ts       ltc3676      pca953x             rtc-pcf8563          tsc2007
at24          elan-touch      mag3110      pcf857x             sgtl5000             vtl_ts
atmel_mxt_ts  es8328          max11801_ts  pfuze100-regulator  si476x-core          wm8960
cs42xx8       fts_ts          max17135     rn5t618             stmpe-i2c            wm8962
da9052        fxas21002c_i2c  max732x      rpmsg-codec-wm8960  sx8654               wm8994
da9062        fxos8700_i2c    mc13xxx      rtc-ds1307          tfp410
da9063        ili210x_i2c     mma8450      rtc-isl1208         tlv320aic23-codec
dummy         ir-kbd-i2c      mma8452      rtc-m41t80          tlv320aic31xx-codec

/sys/devices目录 下是全局设备结构体系,包含所有被发现的注册在各种总线上的各种物理设备。一般来说, 所有的物理设备都按其在总线上的拓扑结构来显示。/sys/devices是内核对系统中所有设备的分层次表达模型, 也是/sys文件系统管理设备的最重要的目录结构。

root@igkboard:~# ls /sys/devices/
armv7_cortex_a7  breakpoint  mmdc0  platform  soc0  software  system  virtual  w1_bus_master1

root@igkboard:~# ls /sys/devices/platform/
 1p8v                keys   power          reg-dummy            regulatory.0    uevent
 3p3v                leds   psci-cpuidle   regulator-peri-3v3   snd-soc-dummy   w1
'Fixed MDIO bus.0'   mqs    pwm-buzzer     regulator-sd1-vmmc   soc
 imx6q-cpufreq       pmu    pxp_v4l2       regulator@0          sound-mqs

/sys/class目录 下则是包含所有注册在kernel里面的设备类型,这是按照设备功能分类的设备模型, 我们知道每种设备都具有自己特定的功能,比如:鼠标、按键的功能是作为人机交互的输入,按照设备功能分类无论它 挂载在哪条总线上都是归类到/sys/class/input下。

root@igkboard:~# ls /sys/class/
ata_device   devlink   hwmon      misc      power_supply  rpmsg        spidev    video4linux
ata_link     dma       i2c-dev    mmc_host  ppp           rtc          tee       vtconsole
ata_port     drm       ieee80211  mtd       pps           scsi_device  thermal   wakeup
backlight    dvb       input      mux       ptp           scsi_disk    tty       watchdog
bdi          extcon    lcd        net       pwm           scsi_host    ubi
block        firmware  leds       pci_bus   rc            sound        udc
bluetooth    gpio      mdio_bus   pci_epc   regulator     spi_master   usb_role
devcoredump  graphics  mem        phy       remoteproc    spi_slave    vc

root@igkboard:~# ls /sys/class/input/
event0  event1  input0  input1

3.2.3.2 设备和驱动匹配

我们知道,通常设备离不开驱动、驱动离不开设备。如 Linux 内核里如果没有使能 USB Storage 的设备驱动,那 U 盘插入到开发板上后它也不能工作;即使系统安装了 U 盘的驱动,但开发板上没插 U 盘,显然此时 U 盘的驱动也没法工作,所以设备和驱动是两个不可分割的共生体。另外,有时候我们是插着 U 盘上电的,这种情况下是现先有了设备,再有驱动;又有时我们是等系统运行起来后才插入的 U 盘,这种情况下是先有了驱动,再有的设备。那在 Linux 系统下,设备是如何找到相应的驱动,驱动又是如何找到相应的设备的呢?

事实上在 Linux 设备驱动中,每条总线上的驱动与设备通常分别维护各自的链表。以下是这两个链表的基本概念及其作用:

1. 设备链表(Device List)

设备链表通常用于管理该总线上所有的设备实例。每个设备都会在设备驱动中被登记并存入链表中,这些设备可以是具体的硬件设备(U盘)或虚拟设备(Led)。

  • 链表作用:设备链表的作用是存储系统中所有注册的设备,可以用来遍历所有设备、管理设备资源、执行设备操作等。

  • 链表结构:通常,设备结构体会包含一个指向下一个设备的指针。例如,struct device 结构体内有一个链表成员 device_link,可以将设备按顺序连接起来。

2. 驱动链表(Driver List)

驱动链表用于管理该总线上所有已注册的驱动程序,驱动程序通常会在内核启动时或通过模块加载进行注册。

  • 链表作用:驱动链表的作用是维护所有注册的设备驱动,使得设备与其对应的驱动程序能够关联起来,系统可以通过设备的 ID 或其他标识找到对应的驱动进行操作。

  • 链表结构:驱动结构体通常会包含一个链表成员 driver_list,以便将所有注册的驱动程序串联在一起。

下面是我们写的驱动在注册/移除时,相应的设备总线上所发生的事。

当我们向系统某条总线上注册一个驱动时,它会向Linux内核里该总线的驱动链表中插入我们的新驱动;同样,当我们向系统某条总线上接入一个新设备时,也会向该总线的设备链表中插入我们的新设备。在插入的同时该总线的核心(core)层会执行一个 bus_type 结构体中 match() 的方法对新插入的设备/驱动进行匹配,如 USB 总线通过 PID 和 VID 来匹配,而 Platform 总线则通过 compatible 字符串来进行匹配。

这样,在Linux内核中无论是设备先出现还是驱动先出现,总能找到彼此对方。当Linux内核的总线核心层在匹配成功后,将会调用驱动 device_driver 结构体中 probe 函数(通常在probe中获取设备资源,具体的功能可由驱动编写人员自定义), 并且在移除设备或驱动时,会调用 device_driver 结构体中 remove 函数,分别完成驱动的注册和移除工作。

3.2.3.3 Linux内核设备树

Linux操作系统可以支持各种各样的处理器架构和不同厂家的开发板,为了解决它们之间的硬件差异,Linux内核提出分层设计的思想,同时将设备和驱动隔离开来,这样就可以使用一个驱动服务不同的设备。举个例子,甲、乙两个厂家都使用了 i.MX6ULL 处理器设计了开发板,但它们分别使用 GPIO4_15 和 GPIO1_11 来控制自己的 Led 灯,其中甲开发板为高电平点亮,而乙开发板则是低电平点亮。此时如果没有分层设计的概念,我们就需要为每个开发板写一个独立的Led驱动。

事实上,如果我们可以找到一个方法将设备和驱动剥离开来,在设备的描述文件中来描述:

  • Led 灯连接的是哪个 GPIO口?

  • Led 灯是搞电平点亮,还是低电平点亮?

然后再在驱动文件中:

  • 找到相应的设备文件,并从它里面解析上面 Led 灯的硬件信息;

  • 注册设备驱动并提供应用程序空间编程或操作的接口;

这样我们就不用为每个设备的Led灯都写一个驱动了,而只需要写一个通用的Led驱动来解析相应的硬件差异,来提供统一的上层操作接口。在早期的Linux2.6内核中,ARM平台的“硬件平台板级细节”保存在 arch/arm/plat-xxxarch/arm/mach-xxx 目录下的C文件中。 随着处理器和开发板数量的增多,这些C代码也越来越多并导致Linux内核非常臃肿。

2011年3月17日,Linus Torvalds在ARM Linux邮件列表宣称“this whole ARM thing is a fucking pain in the ass”,引发ARM Linux社区的地震,社区必须改变这种局面,随后ARM社区进行了一系列的重大修正,于是PowerPC等其他体系架构下已经使用的Flattened Device Tree(FDT)进入ARM社区的视野。Device Tree是一种描述硬件的数据结构,它起源于OpenFirmware(OF)。采用Device Tree后,许多硬件的细节可以通过Bootloader(U-boot)传递给Linux内核,从而不再需要在kernel中进行大量的冗余编码。

在 Linux 系统中,设备树 (Device Tree) 就是这样一种数据结构,它用来描述硬件设备的信息,特别是硬件设备与操作系统如何交互的方式。设备树的作用有:

  1. 描述硬件布局: 设备树可以描述 CPU、内存、外设、GPIO 等硬件资源的位置和属性。

  2. 硬件与驱动分离: 设备树将硬件信息从内核代码中分离出去,使得内核能够更加通用,不依赖特定硬件配置。

  3. 动态配置: 设备树支持热插拔设备和动态硬件配置,内核可以在启动时解析设备树来获取硬件信息。

设备树通常由 设备树源文件 (.dts 文件) 和 编译后的设备树二进制文件 (.dtb 文件) 组成。

  • .dts 文件(device tree source): 这是一个纯文本文件,使用设备树源语言编写,描述硬件设备及其配置。

  • .dtb 文件(device tree binary): 这是一个二进制文件,它是 dtc 命令(device tree compiler) 编译 .dts 文件生成的二进制文件。

Linux3.x以后的版本才引入了设备树,相比于早期使用C代码来描述硬件而言,它简单、易用、可重用性强。它的主要作用就是用于描述一个硬件平台的板级细节(开发板上的设备信息),比如CPU数量,内存基地址,IIC接口上接了哪些设备、SPI接口接了哪些设备。

设备树

设备树描述硬件资源时有两个特点:

①树的主干就是系统总线,在设备树里面称为“根节点”。IIC控制器、GPIO控制器、SPI控制器等都是接到系统主线上的分支,在设备树里称为“根节点的子节点”。

②设备树可以像头文件(.h文件)那样,一个设备树文件引用另外一个设备树文件, 这样可以实现“代码”的重用。例如多个硬件平台都使用i.MX6ULL作为主控芯片, 那么我们可以将i.MX6ULL芯片的硬件资源写到一个单独的设备树文件里面一般使用 .dtsi 后缀, 其他设备树文件直接使用 # include xxx 引用即可。

简单了解了设备树的作用,我们还不知道“设备树”究竟是什么样。此时可以直接打开内核源码,了解设备树的框架和基本语法:

guowenxue@ubuntu20:~$ cd ~/igkboard-imx6ull/bsp/kernel/linux-imx/

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel/linux-imx$ ls
arch           fs        LICENSES                 net         usr
block          include   MAINTAINERS              README      virt
built-in.a     init      MAINTAINERS.NXP          rust        vmlinux
certs          io_uring  Makefile                 samples     vmlinux.a
COPYING        ipc       mm                       scripts     vmlinux.o
CREDITS        Kbuild    modules.builtin          security
crypto         Kconfig   modules.builtin.modinfo  sound
Documentation  kernel    modules.order            System.map
drivers        lib       Module.symvers           tools

在Linux内核源码中,32 位ARM处理器其设备树文件存放在 arch/arm/boot/dts 路径中,64 位ARM处理器其设备树文件则存放在 arch/arm64/boot/dts/ 路径下。我们的开发板使用的是 NXP i.MX6ULL处理器,它是一颗基于Arm Cortex-A7芯片的 32 位处理器,所以我们开发板的设备树源文件为 :

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel/linux-imx$ ls arch/arm/boot/dts/igkboard-imx6ull.dts
arch/arm/boot/dts/igkboard-imx6ull.dts

打开开发板的设备树文件,给我们最直观的感受是它由一些嵌套的大括号“{}”组成, 每一个“{}”都是一个“节点”。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel/linux-imx$ vim arch/arm/boot/dts/igkboard-imx6ull.dts
/*
 * Device Tree Source for LingYun IGKBoard(IoT Gateway Kit Board)
 * Based on imx6ul-14x14-evk.dts/imx6ul-14x14-evk.dtsi
 *
 * Copyright (C) 2022 LingYun IoT System Studio.
 * Author: Guo Wenxue<guowenxue@gmail.com>
 */

/dts-v1/;

#include "imx6ull.dtsi"     /*头文件*/

/*设备树根节点*/
/ {
        model = "LingYun IoT System Studio IoT Gateway Kits Board Based on i.MX6ULL"; /*model属性,用于指定设备的制造商和型号*/
        compatible = "lingyun,igkboard-imx6ull", "fsl,imx6ull"; /*compatible属性,系统用来决定绑定到设备驱动的关键,用来查找节点的方法之一*/

        /*根节点的子节点*/
        chosen {
                stdout-path = &uart1;
        };

        /*根节点的子节点*/
        memory@80000000 {
                device_type = "memory";
                reg = <0x80000000 0x20000000>;
        };

        /*根节点的子节点*/
        reserved-memory {
                #address-cells = <1>;
                #size-cells = <1>;
                ranges;
                 linux,cma {
                        compatible = "shared-dma-pool";
                        reusable;
                        size = <0xa000000>;
                        linux,cma-default;
                };
        };

        /*根节点的子节点*/
        leds {
                compatible = "gpio-leds";
                pinctrl-names = "default";
                pinctrl-0 = <&pinctrl_gpio_leds>;
                status = "okay";

                sysled {
                        lable = "sysled";
                        gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>;
                        linux,default-trigger = "heartbeat";
                        default-state = "off";
                };
           };
      /*-------------以下内容省略-------------*/

};

/*设备树节点追加内容*/
/*+--------------+
  | Misc Modules |
  +--------------+*/
/*而是向原有节点追加内容*/
&uart1 {
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_uart1>;
        status = "okay";
};

&pwm1 { /* backlight */
        #pwm-cells = <2>;
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_pwm1>;
        status = "okay";
};
... ...

设备树源码分为三部分:

  • 头文件,设备树是可以像C语言那样使用 #include 引用 .h 后缀的头文件,也可以引用设备树 .dtsi 后缀的头文件。imx6ull.dtsi 由NXP官方提供,是一个imx6ull平台“共用”的设备树文件。

  • 设备树节点,/ {…}; 表示“根节点”,每一个设备树只有一个根节点。不同文件的根节点最终会合并为一个。在根节点内部的chosen{...}memory{…}reserved-memory{…} 等字符,都是根节点的子节点。如果我们想要添加一个自定义的节点(如上面的 leds),需要添加到根节点里。

  • 事实上,在官方的设备树头文件imx6ul.dtsi 中,已经定义了绝大部分的设备节点,如 uart、 i2c、spi、pwm 控制器等。很多情况,我们需要在这些节点里添加、删除或修改一些内容,此时我们可以使用 & 来引用前面已经定义好的节点,如上面代码中的 &uart1{…}&pwm1{…} 等,它们就定义在 imx6ul.dtsi 中。

在内核源码 arch/arm/boot/dts/imx6ull.dtsi 里, pwm1 节点的定义如下:

pwm1: pwm@2080000 { /*节点标签:节点名称@单元地址*/
                compatible = "fsl,imx6ul-pwm", "fsl,imx27-pwm"; /*model属性用于指定设备的制造商和型号*/
                reg = <0x02080000 0x4000>;  /*reg属性描述设备资源在其父总线定义的地址空间内的地址*/
                interrupts = <GIC_SPI 83 IRQ_TYPE_LEVEL_HIGH>;  /*描述中断相关的信息*/
                clocks = <&clks IMX6UL_CLK_PWM1>,   /*初始化GPIO外设时钟信息*/
                     <&clks IMX6UL_CLK_PWM1>;
                clock-names = "ipg", "per";
                #pwm-cells = <3>;       /*表示有多少个cells来描述pwm引脚*/
                status = "disabled";    /*状态属性用于指示设备的“操作状态”*/
            };

到目前为止我们知道设备树由一个根节点和众多子节点组成,子节点也可以继续包含其他节点,也就是子节点的子节点。 设备树的组成很简单,下面我们一起来看看节点的基本格式和节点属性。

设备树中的每个节点都按照以下约定命名:

node-name@unit-address{
    属性1 = …
    属性2 = …
    属性3 = …
    子节点…
}
  • node-name 节点名称,用于指定节点的名称,如上面的 pwm。它的长度为1至31个字符,只能由“数字、大小字母、英文逗号句号、下划线和加减号”组成,节点名应当使用大写或小写字母开头并且能够描述设备类别。

  • @unit-address,其中的符号“@”可以理解为是一个分割符,“unit-address”用于指定“单元地址”, 它的值要和节点“reg”属性的第一个地址一致,如上面的 @2080000。如果节点没有“reg”属性值,可以直接省略“@unit-address”。注意同级别的设备树下相同级别的子节点节点名唯一node-name@unit-address 的整体要求同级唯一。

  • 节点标签,节点名的简写,当其它位置需要引用时可以使用节点标签来向该节点中追加内容。在imx6ul.dtsi头文件中,节点名 pwm 前面多了个 pwm1 ,这个“pwm1”就是我们所说的节点标签。

  • 节点路径,通过指定从根节点到所需节点的完整路径,可以唯一地标识设备树中的节点,“不同层次的设备树节点名字可以相同,同层次的设备树节点要唯一”。类似于我们Windows上的文件,一个路径唯一标识一个文件或文件夹,不同目录下的文件文件名可以相同。

  • 节点属性:节点的“{}”中包含的内容是节点属性,通常情况下一个节点包含多个属性信息, 这些属性信息就是要传递到内核的“板级硬件描述信息”,驱动中会通过一些API函数获取这些信息。

设备树最主要的内容是编写节点的节点属性,通常情况下一个节点代表一个设备,下面时一些节点的常用属性:

1.compatible属性

compatible属性值由一个或多个字符串组成,有多个字符串时使用“,”分隔开。设备树中的每一个设备的节点都要有一个compatible属性,它用来指定该设备所使用的驱动。如 leds 节点中的 compatible = "gpio-leds"; 就指定了该设备使用 Linux 内核源码中自带的通用 Led 驱动。在该驱动文件中也会声明其 compatible = "gpio-leds"; ,通过这个 compatbile 标识符,Linux 内核的 platform 总线就可以帮设备找到驱动,驱动找到设备了;

2.model属性

model属性用于指定设备的制造商和型号。

3.status属性

状态属性用于指示设备的“操作状态”,通过status可以去禁止设备或者启用设备,默认情况下不设置status属性设备是使能的,如果我们想禁用该设备则可以设置为 disabled;。

4.#address-cells 和 #size-cells

#size-cells和#address-cells决定了子节点的reg属性中哪些数据是“地址”,哪些数据是“长度”信息。

#address-cells,用于指定子节点reg属性“地址字段”所占的长度(单元格cells的个数)。

#size-cells,用于指定子节点reg属性“大小字段”所占的长度(单元格cells的个数)。

例如#address-cells=2,#address-cells=1,则reg内的数据含义为 reg = <address address size address address size>, 每个cells是一个32位宽的数字。

5.reg属性

ret属性的书写格式为reg = < cells cells cells cells cells cells…>

reg属性描述设备资源在其父总线定义的地址空间内的地址。通常情况下用于表示一块寄存器的起始地址(偏移地址)和长度, 在特定情况下也有不同的含义。

例如#address-cells = <1>,#address-cells = <1>,reg = <0x9000000 x4000>, 其中0x9000000表示的是地址,0x4000表示的是地址长度,这里的reg属性指定了起始地址为0x9000000,长度为0x4000的一块地址空间。

6.ranges

该属性提供了子节点地址空间和父地址空间的映射(转换)方法,常见格式是 <子地址、父地址、地址长度>。如果父地址空间和子地址空间相同则无需转换。

比如对于#address-cells和#size-cells都为1的话,以ranges=<0x0 0x10 0x20>为例,表示将子地址的从0x0~(0x0 + 0x20)的地址空间映射到父地址的0x10~(0x10 + 0x20)。

7.name和device_type

这两个属性很少用(已经被废弃),不推荐使用。name用于指定节点名,在旧的设备树中它用于确定节点名, 现在我们使用的设备树已经弃用。device_type属性也是一个很少用的属性,只用在CPU和内存的节点上。 如上例中所示,device_type用在了CPU节点。

接下来,我们将以 Led 的驱动为例,深入学习理解 Linux 下的设备树及其相应设备驱动编写。

3.2.4 编写 Led 驱动

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

3.2.4.1 硬件原理图分析

在 IGKBoard-IMX6ULL 开发板上,板载了一个2个Led,如下图底板原理图所示:

  • LED1 为用户编程控制灯,它受 RUN_LED 这个引脚控制,且为高电平点亮,低电平灭;

  • LED2 为开发板电源指示灯,系统上电后就由硬件点亮,用户不能控制;

另外,从核心板上的原理图中搜索 RUN_LED 可知,它连到了 CPU 的 NAND_DQS 引脚上,其 GPIO 引脚为 GPIO4_IO16。这样,接下来我们在编写 Led 驱动时,只要控制 GPIO4_IO16 这个引脚输出高电平,那 LED1 就点亮了;如果输出低电平,则 LED1 熄灭了。

在上面的原理图中,我们只能看到它连到了 NAND_DQS 上,那我怎么就知道它是 GPIO4_IO16 引脚呢? 第一种方法就是查看芯片的datasheet,另外一种方法则是查看 Linux 内核设备树文件里的定义,接下来我们就了解一下 Linux 内核设备树。

3.2.4.2 Linux内核设备树

通常在官方 DTS 文件路径下,都会有一个处理器引脚定义相关的 pinfunc.h 文件,该文件下会有其相关的所有引脚及功能定义。如:

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel/linux-imx$ ls arch/arm/boot/dts/*pinfunc*.h
arch/arm/boot/dts/imx1-pinfunc.h   arch/arm/boot/dts/imx6dl-pinfunc.h        arch/arm/boot/dts/imx7ulp-pinfunc.h
arch/arm/boot/dts/imx23-pinfunc.h  arch/arm/boot/dts/imx6q-pinfunc.h         arch/arm/boot/dts/imxrt1050-pinfunc.h
arch/arm/boot/dts/imx25-pinfunc.h  arch/arm/boot/dts/imx6sll-pinfunc.h       arch/arm/boot/dts/imxrt1170-pinfunc.h
arch/arm/boot/dts/imx27-pinfunc.h  arch/arm/boot/dts/imx6sl-pinfunc.h        arch/arm/boot/dts/mt2701-pinfunc.h
arch/arm/boot/dts/imx28-pinfunc.h  arch/arm/boot/dts/imx6sx-pinfunc.h        arch/arm/boot/dts/mxs-pinfunc.h
arch/arm/boot/dts/imx35-pinfunc.h  arch/arm/boot/dts/imx6ull-pinfunc.h       arch/arm/boot/dts/sama5d2-pinfunc.h
arch/arm/boot/dts/imx50-pinfunc.h  arch/arm/boot/dts/imx6ull-pinfunc-snvs.h  arch/arm/boot/dts/sama7g5-pinfunc.h
arch/arm/boot/dts/imx51-pinfunc.h  arch/arm/boot/dts/imx6ul-pinfunc.h        arch/arm/boot/dts/vf610-pinfunc.h
arch/arm/boot/dts/imx53-pinfunc.h  arch/arm/boot/dts/imx7d-pinfunc.h

我们开发板所使用的处理器是 imx6ull,它是 imx6ul 系列的一个变种。这样我们如果查看 imx6ull-pinfunc.h 文件的话,会发现它会 #include "imx6ul-pinfunc.h" 文件。接下来我们打开 imx6ul-pinfunc.h 文件,在里面搜索关键字 NAND_DQS 就可以找到我们的 LED1 引脚使用的是 GPIO4_IO16 这个GPIO口了。

另外,从上面这里可以看出,该引脚有下面这些复用功能:

  • MX6UL_PAD_NAND_DQS__RAWNAND_DQS 作为 Nandflash 接口的 NAND_DQS 功能使用;

  • MX6UL_PAD_NAND_DQS__CSI_FIELD 作为CSI(Camera Sensor Interface) 接口的 CSI_FIELD 功能使用;

  • MX6UL_PAD_NAND_DQS__QSPI_A_SS0_B 作为 QSPI(Quad SPI) 接口的 QSPI_A_SS0_B 功能使用;

  • MX6UL_PAD_NAND_DQS__PWM5_OUT 作为 PWM 接口的 PWM5_OUT 功能使用;

  • MX6UL_PAD_NAND_DQS__EIM_WAIT 作为 EIM_WAIT 功能使用;

  • MX6UL_PAD_NAND_DQS__GPIO4_IO16 作为 GPIO4_IO16 的普通GPIO功能使用;

  • MX6UL_PAD_NAND_DQS__SDMA_EXT_EVENT01 作为 SDMA_EXT_EVENT01 功能使用;

  • MX6UL_PAD_NAND_DQS__SPDIF_EXT_CLK 作为 SPDIF_EXT_CLK 功能使用;

显然,如果我们想使用该引脚来控制 Led 灯亮灭,那我们就应该将其设置为 MX6UL_PAD_NAND_DQS__PWM5_OUT 功能模式。接下来看看我们开发板的设备树源文件,如下:

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel/linux-imx$ vim arch/arm/boot/dts/igkboard-imx6ull.dts
... ...
/dts-v1/;

#include "imx6ull.dtsi"

/ {
    model = "LingYun IoT System Studio IoT Gateway Kits Board Based on i.MX6ULL";
    compatible = "lingyun,igkboard-imx6ull", "fsl,imx6ull";

    chosen {
        stdout-path = &uart1;
    };
... ...

    leds {
        compatible = "gpio-leds";
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_gpio_leds>;
        status = "okay";

        sysled {
            lable = "sysled";
            gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>;
            linux,default-trigger = "heartbeat";
            default-state = "off";
        };
    };
... ...

&iomuxc {
    pinctrl-names = "default";

    pinctrl_gpio_leds: gpio-leds {
        fsl,pins = <
            MX6UL_PAD_NAND_DQS__GPIO4_IO16          0x17059 /* led run */
        >;
    };
... ...

在上面的这个设备树源文件中:

  • leds { 这是我们添加的 Led 灯设备节点,这个名字我们可以随便起,只要不冲突即可。通常自己加的节点,都放在根节点下。

  • compatible = “gpio-leds”; 指定该设备使用 Linux 内核源码中自带的通用 Led 驱动。在该驱动文件中也会声明其 compatible = "gpio-leds"; ,通过这个 compatbile 标识符,Linux 内核的 platform 总线就可以帮设备找到驱动,驱动找到设备了;

  • pinctrl-0 = <&pinctrl_gpio_leds>; 这里的 pinctrl-0 用来指定该驱动所使用的引脚如何初始化,它定义在下面的 pinctrl_gpio_leds 节点中;

  • status = "okay"; 告诉 Linux 内核使能该设备,如果我们想禁用该设备则可以设置为 disabled

  • sysled { 这是每个Led的节点,如果我们有多个 Led 就在下面继续追加,它将由 Linux 内核里的 Led 驱动来解析,它也是我们随便命名,只要不冲突即可。

  • lable = "sysled"; 它由 Linux 内核自带Led 驱动解析,该驱动会导出 /sys/class/leds/sysled 文件来操作控制该 Led;

  • gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>; 它告诉驱动该 Led 连到 GPIO4_16 引脚上,并且是高电平点亮。如果是低电平点亮则应该设置为 GPIO_ACTIVE_LOW

  • linux,default-trigger = "heartbeat"; 它告诉驱动该灯工作在 heartbeat 模式,这样系统上电后这个 Led 灯就像心跳一样快闪两下;

  • pinctrl_gpio_leds: gpio-leds { 里的 MX6UL_PAD_NAND_DQS__GPIO4_IO16          0x17059 , 它告诉 Linux 内核的 pinctrl 子系统,在系统上电时,将 GPIO4_IO16 这个引脚初始化为 MX6UL_PAD_NAND_DQS__GPIO4_IO16 普通GPIO模式使用。后面的0x17059 为该引脚的输入/输出、内部上拉/下拉 等模式配置。

接下来,我们将继续学习了解 Linux 内核设备树里的 pinctrl 配置。

3.2.4.2 pinctrl 寄存器配置

在Linux内核中,pinctrl 子系统用于管理和配置硬件平台的引脚控制。这个子系统使得内核能够灵活地控制每个引脚的功能、方向、输入/输出电平、上拉/下拉电阻等设置。它是处理引脚复用、引脚驱动和电气特性等配置的关键组件,尤其在嵌入式设备和SoC(System-on-Chip)中尤为重要。

接下来以 MX6UL_PAD_NAND_DQS__GPIO4_IO16 引脚为例,讲解一下 DTS 里关于CPU引脚 pinctrl 的配置。首先来看看一下这个宏的定义,它定义在头文件 arch/arm/boot/dts/imx6ul-pinfunc.h 中。

#define MX6UL_PAD_NAND_DQS__RAWNAND_DQS         0x01b8 0x0444 0x0000 0 0
#define MX6UL_PAD_NAND_DQS__CSI_FIELD           0x01b8 0x0444 0x0530 1 1
#define MX6UL_PAD_NAND_DQS__QSPI_A_SS0_B        0x01b8 0x0444 0x0000 2 0
#define MX6UL_PAD_NAND_DQS__PWM5_OUT            0x01b8 0x0444 0x0000 3 0
#define MX6UL_PAD_NAND_DQS__EIM_WAIT            0x01b8 0x0444 0x0000 4 0
#define MX6UL_PAD_NAND_DQS__GPIO4_IO16          0x01b8 0x0444 0x0000 5 0
#define MX6UL_PAD_NAND_DQS__SDMA_EXT_EVENT01    0x01b8 0x0444 0x0614 6 1
#define MX6UL_PAD_NAND_DQS__SPDIF_EXT_CLK       0x01b8 0x0444 0x061c 8 1

在头文件中一共有 8 个以 MX6UL_PAD_NAND_DQS 开头的宏定义,这也就意味着 GPIO4_IO16 这个引脚可以复用八种不同的功能模式。查 阅《i.MX 6ULL Reference Manual.pdf》可以知 MX6UL_PAD_NAND_DQS 的可选复用 IO 如下图所示:

MX6UL_PAD_NAND_DQS__GPIO4_IO16 表示将该引脚复用为 GPIO 模式,它后面跟了个5数字,也就是这个宏定义的具体值: 0x01b8 0x0444 0x0000 5 0。它们的具体含义为:

<mux_reg   conf_reg  input_reg   mux_mode  input_val>

  • 0x01b8: mux_reg 寄存器偏移地址,设备树中的 iomuxc 节点就是 IOMUXC 外设对应的节 点 , 根据其 reg 属性可知 IOMUXC 外设寄存器起始地址为 0x020e0000 。 因此 0x020e0000+0x01b8=0x020e01b8,如上图所示,IOMUXC_SW_MUX_CTL_PAD_NAND_DQS 寄存器地址正好是 20E_01B8h 

  • 0x0444:conf_reg 寄存器偏移地址,和 mux_reg 一样,0x020e0000+0x0444=0x020e0444, 这个就是寄存器 IOMUXC_SW_PAD_CTL_PAD_NAND_DQS  的地址。同样在《i.MX 6ULL Reference Manual.pdf》文档中,可以找到这个寄存器的定义:

  • 0x0000:input_reg 寄存器偏移地址,有些外设有 input_reg 寄存器,有 input_reg 寄存器的外设需要配置 input_reg 寄存器。没有的话就不需要设置,NAND_DQS 这个 PIN 在做 GPIO1_IO19 的时候是没有 input_reg 寄存器,因此这里 intput_reg 是无效的。

  • 5:mux_reg 寄存器值,在这里就相当于设置 IOMUXC_SW_MUX_CTL_PAD_NAND_DQS 寄存器为 0x5(0101 ALT5 — Select mux mode: ALT5 mux port: GPIO4_IO16 of instance: gpio4),也即是设置 NAND_DQS 这个PIN复用为 GPIO4_IO16

  • 0x0:input_reg 寄存器值,在这里无效。

看的比较仔细的同学应该会发现并没有 conf_reg 寄存器的值,config_reg 寄存器是设置一个 PIN 的电气特性的。这么重要的寄存器怎么没有值呢?回到示例代码 中:MX6UL_PAD_NAND_DQS__GPIO4_IO16 0x17059 ,这里的 0x17059 就是 conf_reg 寄存器值,通过此值来设置一个 IO 的上/下拉、驱动能力和速度等。

具体参考上面寄存器(0x0444) IOMUXC_SW_PAD_CTL_PAD_NAND_DQS  的寄存器说明,0x17059 化成二进制为 0001 0111 0000 0101 1001,其解析为:

位域

说明

31:17

0

This field is reserved

16

1

HYS_1_Hysteresis_Enabled — Hysteresis Enabled

15:14

01

PUS_1_47K_Ohm_Pull_Up — 47K Ohm Pull Up

13

1

PUE_1_Pull — Pull

12

1

PKE_1_Pull_Keeper_Enabled — Pull/Keeper Enabled

11

0

ODE_0_Open_Drain_Disabled — Open Drain Disabled

10:8

000

This field is reserved

7:6

01

SPEED_1_medium_100MHz_ — medium(100MHz)

5:3

011

DSE_3_R0_3 — R0/3

2:1

00

This field is reserved

0

1

SRE_1_Fast_Slew_Rate — Fast Slew Rate

关于 pinctrl 系统这里将不再作过多的介绍,但大家都应该点此链接深入学习一下

3.2.4.3 设备树修改与编译

在前面的学习中我们了解到,我们开发板的设备树中板载 LED1 默认使用了 Linux内核自带的 Led 驱动。此时,我们如果想要自己编写驱动控制该 Led 灯的话,则需要先禁用该驱动。这点很容易实现,此时只需将根节点下的 leds 里的 status 设置为 disabled 即可。接下来,再在根节点下添加我们自己的 Led 设备驱动节点myleds 如下所示:

guowenxue@ubuntu20:~$ cd ~/igkboard-imx6ull/bsp/kernel/linux-imx/
guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel/linux-imx$ vim arch/arm/boot/dts/igkboard-imx6ull.dts
.... ...
    leds {
        compatible = "gpio-leds";
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_gpio_leds>;
        status = "disabled";

        sysled {
            lable = "sysled";
            gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>;
            linux,default-trigger = "heartbeat";
            default-state = "off";
        };
    };

    myleds {
        compatible = "lingyun,leds";
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_gpio_leds>;
        status = "okay";

        gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>;
    };
... ...

在上面的 myleds 子节点中:

  • compatible = "lingyun,leds" 这里指定使用我们自己的驱动,在后面我们写的驱动中,也将会使用这个 compatible 标识符;

  • pinctrl-xxx 这是 pinctrl 子系统配置,让系统上电时自动将我们驱动所使用的管脚初始化为 GPIO模式,并设置相应的电平属性;

  • status = "okay" 设置本设备的状态为使能;

  • gpios = <&gpio4 16 GPIO_ACTIVE_HIGH> 开发板只板载了一个 Led 灯,这里我们就设置这个Led灯的GPIO口,后面我们也将会使用 RGB 灯作测试,连接多个 Led 。

在修改好设备树文件后,我们在 Linux 内核源码下执行下面命令,就可以编译生成开发板启动所需要的 dtb 文件。

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel/linux-imx$ make ARCH=arm dtbs
  DTC     arch/arm/boot/dts/igkboard-imx6ull.dtb

guowenxue@ubuntu20:~/igkboard-imx6ull/bsp/kernel/linux-imx$ ls arch/arm/boot/dts/igkboard-imx6ull.dtb -l
-rw-rw-r-- 1 guowenxue guowenxue 42201 Dec 31 15:16 arch/arm/boot/dts/igkboard-imx6ull.dtb

接下来登录到开发板上,可以看到设备树中默认的 sysled 灯是存在的,并且该灯在系统启动后会间隔一段事件闪烁一下。

root@igkboard:~# ls /sys/class/leds/
mmc0::  mmc1::  sysled

接下来我们开始升级刚才编译生成的新设备树文件。该文件需存放在 eMMC 的 boot分区下(FAT32格式),该分区在系统启动时可能默认不会自动挂载,这就需要我们手动挂载一下。

root@igkboard:~# mount /dev/mmcblk1p1 /media/
root@igkboard:~# ls /media/
config.txt  igkboard-imx6ull.dtb  overlays  zImage

接下来使用 scp 或其它方式,将编译生成的 dtb 文件下载到开发板上,并替换掉原来的文件,完成后重启开发板生效。

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/igkboard-imx6ull/bsp/kernel/linux-imx/arch/arm/boot/dts/igkboard-imx6ull.dtb /media/
guowenxue@192.168.0.2's password:
igkboard-imx6ull.dtb

root@igkboard:~# sync && reboot

系统重启后,我们可以看到 sysled 已经不存在了,接下来我们就可以编写自己的 Led 设备驱动来控制它了。

root@igkboard:~# ls /sys/class/leds/
mmc0::  mmc1::

3.2.4.4 编写Led设备驱动

切换工作路径到我们的驱动学习工作路径下。

guowenxue@ubuntu20:~$ cd drivers/imx6ull/driver/

编写我们的 Led 字符设备驱动如下,该驱动主要是提供了 ioctl() 系统调用函数接口来控制Led 。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim ledv1.c
/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * Leds driver example with ioctl() API on IGKBoard-IMX6ULL board.
 */

#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>   /* printk() */
#include <linux/fs.h>       /* everything... */
#include <linux/errno.h>    /* error codes */
#include <linux/types.h>    /* size_t */
#include <linux/cdev.h>     /*  cdev */
#include <linux/slab.h>     /* kmalloc() */
#include <linux/version.h>  /* kernel version code */
#include <linux/ioctl.h>
#include <linux/platform_device.h>
#include <linux/gpio/consumer.h>
#include <linux/of.h>

#define LED_IOC_MAGIC   'l'
#define LED_IOC_SET     _IOW(LED_IOC_MAGIC, 1, int)

struct led_desc
{
    struct cdev         cdev;  /* character device for register */
    struct device      *dev;   /* device for create /dev/ledX */
    struct gpio_desc   *gpio;  /* gpio instance for this led */
};

struct led_priv {
    struct class       *class; /* class instance to register the devices */
    dev_t               devt;  /* device number */
    int                 nleds; /* number of leds */
    struct led_desc    *leds;  /* leds array */
};

struct led_priv *priv;

static long led_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
    int led_id, brightness;

    /* which LED to operate on based on the minor number of the file descriptor */
    led_id = iminor(file->f_inode);

    switch (cmd) {
        case LED_IOC_SET: /* turn on/off led */
            brightness = (int)arg;
            gpiod_set_value_cansleep(priv->leds[led_id].gpio, brightness?1:0);
            break;

        default:
            return -ENOTTY;
    }

    return 0;
}

static const struct file_operations led_fops = {
    .owner = THIS_MODULE,
    .unlocked_ioctl = led_ioctl,
};

static int led_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    int ret, i;

    /* allocate memory for private data structure */
    priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
    if (!priv)
        return -ENOMEM;

    /* parser the number of LEDs from the device tree */
    priv->nleds = gpiod_count(dev, NULL);
    if ( priv->nleds < 1) {
        dev_err(dev, "Failed to read leds gpio from device tree\n");
        return -EINVAL;
    }
    dev_info(dev, "led driver probe for %d leds from device tree\n", priv->nleds);

    /* allocate memory for all the leds */
    priv->leds = devm_kzalloc(dev, priv->nleds*sizeof(*priv->leds), GFP_KERNEL);
    if( !priv->leds )
        return -ENOMEM;

    /* parser and request GPIO pins from the device tree */
    for (i = 0; i < priv->nleds; i++) {
        priv->leds[i].gpio = devm_gpiod_get_index(dev, NULL, i, GPIOD_ASIS);
        if (IS_ERR(priv->leds[i].gpio))
            return PTR_ERR(priv->leds[i].gpio);

        /* set GPIO as output mode and default off */
        gpiod_direction_output(priv->leds[i].gpio, 0);
    }

    /* Allocate device number */
    ret = alloc_chrdev_region(&priv->devt, 0, priv->nleds, "myled");
    if (ret) {
        dev_err(dev, "Failed to allocate char dev region\n");
        return ret;
    }

    /* create the class for the devices */
#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 5, 0)
    priv->class = class_create("myled");
#else
    priv->class = class_create(THIS_MODULE, "myled");
#endif

    if (IS_ERR(priv->class)) {
        ret = PTR_ERR(priv->class);
        goto failed_unregister_chrdev;
    }

    /* add the character devices and create corresponding device nodes */
    for (i = 0; i < priv->nleds; i++) {
        cdev_init(&priv->leds[i].cdev, &led_fops);
        priv->leds[i].cdev.owner = THIS_MODULE;

        /* add the cdev */
        ret = cdev_add(&priv->leds[i].cdev, MKDEV(MAJOR(priv->devt), i), 1);
        if (ret) {
            dev_err(&pdev->dev, "Failed to add cdev for led%d\n", i);
            while (--i >= 0) {
                cdev_del(&priv->leds[i].cdev);
            }
            goto failed_destroy_class;
        }

        /* create the device node for each led */
        priv->leds[i].dev = device_create(priv->class, &pdev->dev, MKDEV(MAJOR(priv->devt), i), NULL, "led%d", i);
        if (IS_ERR(priv->leds[i].dev)) {
            ret = PTR_ERR(priv->leds[i].dev);
            dev_err(&pdev->dev, "Failed to create device node for led%d\n", i);

            while (--i >= 0) {
                device_destroy(priv->class, MKDEV(MAJOR(priv->devt), i));
                cdev_del(&priv->leds[i].cdev);
            }
            goto failed_destroy_class;
        }
    }

    /* Store private data in platform driver context */
    platform_set_drvdata(pdev, priv);

    return 0;

failed_destroy_class:
    class_destroy(priv->class);

failed_unregister_chrdev:
    unregister_chrdev_region(priv->devt, priv->nleds);

    return ret;
}

static int led_remove(struct platform_device *pdev)
{
    struct led_priv *priv = platform_get_drvdata(pdev);
    int i;

    /* remove the device nodes */
    for (i = 0; i < priv->nleds; i++) {
        device_destroy(priv->class, MKDEV(MAJOR(priv->devt), i));
        cdev_del(&priv->leds[i].cdev);
    }

    /* Destroy the class and unregister the character devices */
    class_destroy(priv->class);
    unregister_chrdev_region(priv->devt, priv->nleds);

    dev_info(&pdev->dev, "led driver removed.\n");
    return 0;
}

static const struct of_device_id led_of_match[] = {
    { .compatible = "lingyun,leds", },
    { /* sentinel */ },
};
MODULE_DEVICE_TABLE(of, led_of_match);

static struct platform_driver led_driver = {
    .probe = led_probe,
    .remove = led_remove,
    .driver = {
        .name = "leds",
        .of_match_table = led_of_match,
    },
};

module_platform_driver(led_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");
MODULE_DESCRIPTION("Led driver with ioctl() API");

在上面的驱动中,字符设备驱动的注册流程与前面我们讲的内容一致,只是现在驱动的注册方式不大一样了:

  • 在此驱动源码中,并没有使用 module_init()module_exit() 来声明函数了,取而代之的是 module_platform_driver()。事实上,后者最终还是会调用前者来声明 struct platform_driver led_driver 里的 .probe.remove,只是将这些底层细节掩藏了而已。

  • 前面我们提到对于 Led、网卡这类设备模拟了一个虚拟的总线来管理它们,这个虚拟的总线就是 platform 总线。显然该总线下的驱动就是我们上面代码中的 struct platform_driver,那设备就是 struct platform_device 了。在这个驱动文件中,我们只看到了设备驱动 struct platform_driver led_driver,那struct platform_device设备又在哪里呢?

事实上,这些设备都是在内核设备树源文件(igkboard-imx6ull.dts) 中定义的,最终它会被编译成 dtb 文件(igkboard-imx6ull.dtb) 存放到开发板的启动介质eMMC boot分区下。开发板在上电启动时,bootloader(u-boot)将会将这个 dtb 文件加载到相应的内存空间中,并传递给 Linux内核(booti 命令)。Linux内核在启动时会从该内存地址上解析 dtb 文件,生成相应的 struct platform_device 设备并注册到 platform 虚拟总线上。

root@igkboard:~# mount /dev/mmcblk1p1  /media/
root@igkboard:~# ls /media/
config.txt  igkboard-imx6ull.dtb  overlays  zImage
  • struct platform_driver led_driver 驱动中有个 .of_match_table = led_of_match 成员,而在 led_of_match 中又有 { .compatible = "lingyun,leds", } ,再对比设备树中的 myleds 节点是不是有点似曾相识?对的,你猜的不错!对于 platform 虚拟总线,正是通过这个 .compatilbe 标签来让驱动找到设备、设备找到驱动的。

  • 由此可见,在开发板上电时,Linux内核会从 igkboard-imx6ull.dtb 文件中解析出 myleds 设备节点,并将该 platform_device 添加到 platform虚拟总线的 device 链表上;当我们 insmod led.ko 安装驱动文件时,module_platform_driver(led_driver) 将会把我们的 platform_driver 注册到 platform虚拟总线的 driver 链表上。当此驱动添加进来后,Linux内核会从 platform虚拟总线的 device 链表上 中遍历所有的设备,看是否有设备的 .compatible 也是 “lingyun,leds” 这个字符串。如果没有找到驱动则会静静地在那,等待设备的出现。如果找到了,内核会自动调用驱动里的 .probe = led_probe 函数,开始字符设备的注册流程。

接下来我们再继续看驱动中的 int led_probe(struct platform_device *pdev) 函数实现。从这个函数原型中,我们就可以看到了传入的参数为 struct platform_device *pdev 指针,显然这就是 Linux 内核传给驱动的一个设备 platform_device 指针,它立面就包含有设备树里的相关硬件信息。这样,我们就可以在驱动中使用一系列的设备树解析函数 of_xxx()gpiod_xxx() 来从设备树中获取到我们想要的信息了。

这些函数通常定义在下面头文件中:

#include <linux/of.h>
#include <linux/of_gpio.h>
#include <linux/gpio/consumer.h>

接下来我们继续分析 led_probe() 函数代码:

static int led_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    int ret, i;

    /* allocate memory for private data structure */
    priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
    if (!priv)
        return -ENOMEM;

    /* parser the number of LEDs from the device tree */
    priv->nleds = gpiod_count(dev, NULL);
    if ( priv->nleds < 1) {
        dev_err(dev, "Failed to read leds gpio from device tree\n");
        return -EINVAL;
    }
    dev_info(dev, "led driver probe for %d leds from device tree\n", priv->nleds);

    /* allocate memory for all the leds */
    priv->leds = devm_kmalloc_array(dev, priv->nleds, sizeof(*priv->leds), GFP_KERNEL);
    if( !priv->leds )
        return -ENOMEM;

    /* parser and request GPIO pins from the device tree */
    for (i = 0; i < priv->nleds; i++) {
        priv->leds[i].gpio = devm_gpiod_get_index(dev, NULL, i, GPIOD_ASIS);
        if (IS_ERR(priv->leds[i].gpio))
            return PTR_ERR(priv->leds[i].gpio);

        /* set GPIO as output mode and default off */
        gpiod_direction_output(priv->leds[i].gpio, 0);
    }
    ... ...
}
  • 在代码的最开始,我们使用了 devm_kzalloc() 动态分配了 priv 结构体,今后在分析驱动代码的时候,经常会遇到使用该函数为一个设备分配一片内存的情况。同样可以分配内存的内核函数还有devm_kmalloc, kzalloc, kmalloc。它们之间的区别在于 devm_xx() 分配的内存可以跟设备进行绑定,当设备跟驱动分离时,跟设备绑定的内存会被自动释放,不需要我们手动释放。当然,如果内存不再使用了,我们也可以使用函数 devm_kfree() 手动进行释放。而使用 kzalloc()kzmalloc() 分配的内存需要我们调用 kfree() 手动进行释放,如果使用完毕却没有释放的话,会造成内存泄漏。 另外priv 定义如下:

    struct led_desc
    {
        struct cdev         cdev;  /* character device for register */
        struct device      *dev;   /* device for create /dev/ledX */
        struct gpio_desc   *gpio;  /* gpio instance for this led */
    };
    
    struct led_priv {
      struct class       *class; /* class instance to register the devices */
        dev_t               devt;  /* device number */
        int                 nleds; /* number of leds */
        struct led_desc    *leds;  /* leds array */
    };
    
    struct led_priv *priv;
    
  • 后面的代码 priv->nleds = gpiod_count(dev, NULL) 就是从设备树文件中解析出 gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>; 里的 GPIO 引脚数。通过这个函数我们可以知道在 gpios 节点属性中有多少个GPIO值,也就是有多少个Led灯了,接下来我们会用 RGB 三色灯来作实验深入理解。

  • 此后我们会根据解析出来的 Led 数量,为每个Led分配一个struct led_desc 类型结构体,它里面包含有注册字符设备需要的struct cdev cdev、创建设备节点需要的 struct device *dev 和相应的 gpio 信息 struct gpio_desc *gpio

        /* allocate memory for all the leds */
        priv->leds = devm_kzalloc(dev, priv->nleds*sizeof(*priv->leds), GFP_KERNEL);
        if( !priv->leds )
            return -ENOMEM;
    
  • 此后会在循环遍历中,使用 devm_gpiod_get_index() 函数获取每个 Led 的 GPIO 信息,该函数同时会调用 gpio_request()gpio_direction_output() 等来初始化它们。需要注意的是,这里使用 devm_xx() 相关的函数都是与设备绑定,它们所申请的资源会在设备注销时自动释放。另外关于 Linux内核里的 GPIO 操作,与前面我们学习的应用程序空间 libgpiod 编程函数差不多。

        /* parser and request GPIO pins from the device tree */
        for (i = 0; i < priv->nleds; i++) {
            priv->leds[i].gpio = devm_gpiod_get_index(dev, NULL, i, GPIOD_ASIS);
            if (IS_ERR(priv->leds[i].gpio))
                return PTR_ERR(priv->leds[i].gpio);
    
            /* set GPIO as output mode and default off */
            gpiod_direction_output(priv->leds[i].gpio, 0);
        }
    
  • 在该设备驱动中,我们将会使用 for 循环为每一个 Led 灯创建一个独立的设备节点,如 /dev/led0/dev/led1 …等。

        /* add the character devices and create corresponding device nodes */
        for (i = 0; i < priv->nleds; i++) {
            cdev_init(&priv->leds[i].cdev, &led_fops);
            priv->leds[i].cdev.owner = THIS_MODULE;
    
            /* add the cdev */
            ret = cdev_add(&priv->leds[i].cdev, MKDEV(MAJOR(priv->devt), i), 1);
            if (ret) {
                dev_err(&pdev->dev, "Failed to add cdev for led%d\n", i);
                while (--i >= 0) {
                    cdev_del(&priv->leds[i].cdev);
                }
                goto failed_destroy_class;
            }
    
            /* create the device node for each led */
            priv->leds[i].dev = device_create(priv->class, &pdev->dev, MKDEV(MAJOR(priv->devt), i), NULL, "led%d", i);
            if (IS_ERR(priv->leds[i].dev)) {
                ret = PTR_ERR(priv->leds[i].dev);
                dev_err(&pdev->dev, "Failed to create device node for led%d\n", i);
    
                while (--i >= 0) {
                    device_destroy(priv->class, MKDEV(MAJOR(priv->devt), i));
                    cdev_del(&priv->leds[i].cdev);
                }
                goto failed_destroy_class;
            }
        }
    
  • 此驱动提供了一个标准的 ioctl() 系统调用实现,这样我们在应用程序空间就可以通过 ioctl() 来编程控制 Led 的亮灭了。注意下面代码中的 gpiod_set_value_cansleep()gpiod_set_value() 的第二个参数 1或0 是指 ActiveInactive, 具体设置成高电平还是低电平由 DTS 文件里相应 GPIO 口配置的 GPIO_ACTIVE_HIGH 还是 GPIO_ACTIVE_LOW 决定。

    #define LED_IOC_MAGIC   'l'
    #define LED_IOC_SET     _IOW(LED_IOC_MAGIC, 1, int)
    
    static long led_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
    {
        int led_id, brightness;
    
        /* which LED to operate on based on the minor number of the file descriptor */
        led_id = iminor(file->f_inode);
    
        switch (cmd) {
            case LED_IOC_SET: /* turn on/off led */
                brightness = (int)arg;
                gpiod_set_value_cansleep(priv->leds[led_id].gpio, brightness?1:0);
                break;
    
            default:
                return -ENOTTY;
        }
    
        return 0;
    }
    

3.2.4.5 Led驱动编译测试

接下来我们修改驱动的 Makefile 文件,添加 led 驱动的编译规则:

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim Makefile
 obj-m += hello.o
+obj-m += ledv1.o

然后执行 make 命令编译来编译我们的驱动。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ make
make ARCH=arm CROSS_COMPILE=/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf- -C ~/igkboard-imx6ull/bsp/kernel/linux-imx/ M=/home/guowenxue/drivers/imx6ull/driver modules
make[1]: Entering directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/hello.o
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/ledv1.o
  MODPOST /home/guowenxue/drivers/imx6ull/driver/Module.symvers
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/hello.mod.o
  LD [M]  /home/guowenxue/drivers/imx6ull/driver/hello.ko
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/ledv1.mod.o
  LD [M]  /home/guowenxue/drivers/imx6ull/driver/ledv1.ko
make[1]: Leaving directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
make[1]: Entering directory '/home/guowenxue/drivers/imx6ull/driver'
make[1]: Leaving directory '/home/guowenxue/drivers/imx6ull/driver'

参考前面的章节的内容,在开发板上更新我们最新的设备树文件后,再将Led设备驱动文件拷贝到开发板上。

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/ledv1.ko .
guowenxue@192.168.0.2's password:
ledv1.ko

接下来安装驱动测试,此时我们会发现 /dev 路径下只有一个设备文件,这是我们在设备树中只配置了一个 GPIO口。

root@igkboard:~# insmod ledv1.ko

root@igkboard:~# dmesg | tail -1
[  103.837243] leds myleds: led driver probe for 1 leds from device tree

root@igkboard:~# ls /dev/led0 -l
crw------- 1 root root 243, 0 Dec 31 09:30 /dev/led0

3.2.4.6 设备树DTS编译

通过前面的内容学习,我们知道 Linux 内核的设备树和驱动是分离的。在上面我们写了一个 Led 驱动,它可以使我们板载的这一个 LED1 工作,那它是否支持多个 Led 设备呢?答案是肯定的!前面我们在学习 Linux 接口编程时,在应用程序空间通过 libgpiod 库编程操作控制了一个 RGB 三色Led灯,下面是这个共阴极 RGB 三色Led灯的物理连接示意图。

从上面的连接示意图我们知道这些 Led 灯是高电平点亮,另外可以看出:

  • RGB三色灯 R(红灯) 连接到了 开发板40Pin 33#脚上,查看原理图可知它使用 GPIO1_IO23;

  • RGB三色灯 G(绿灯) 连接到了 开发板40Pin 35#脚上,查看原理图可知它使用 GPIO5_IO01;

  • RGB三色灯 B(蓝灯) 连接到了 开发板40Pin 37#脚上,查看原理图可知它使用 GPIO5_IO08;

  • RGB三色灯 G(Gnd) 连接到了 开发板40Pin 39#脚上;

接下来我们只需要简单地修改一下 DTS 源文件,而不需要对前面写的驱动作任何修改,就可以支持它了。需要了解的是,DTS 文件也可以跟驱动一样独立编译,并不是一定要在 arch/arm/boot/dts 路径下编译的。这里为了了解 DTS 的编译过程,我们就不直接修改 Linux 内核源码里的设备树文件了。

首先创建 DTS 文件的工作目录。

guowenxue@ubuntu20:~$ cd ~/drivers/imx6ull/driver/
guowenxue@ubuntu20:~/drivers/imx6ull/driver$ ls
hello.c  led.c  Makefile
guowenxue@ubuntu20:~/drivers/imx6ull/driver$ mkdir -p dts && cd dts

接下来我们将 Linux 内核源码里的设备树源文件 arch/arm/boot/dts/igkboard-imx6ull.dts 拷贝过来。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ cp ~/igkboard-imx6ull/bsp/kernel/linux-imx/arch/arm/boot/dts/igkboard-imx6ull.dts

将前面添加的 myleds 设备注释掉 或 将 status 设置为 disabled,然后再添加我们的 rgbled 设备节点如下:

#if 0
    myleds {
        compatible = "lingyun,leds";
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_gpio_leds>;
        status = "disabled";

        gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>;
    };
#else
    rgbled {
        compatible = "lingyun,leds";
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_gpio_rgbleds>;
        status = "okay";

        gpios = <&gpio1 23 GPIO_ACTIVE_HIGH  /* 33# */
                 &gpio5 1  GPIO_ACTIVE_HIGH  /* 35# */
                 &gpio5 8  GPIO_ACTIVE_HIGH  /* 37# */
                >;
    };
#endif

在下面的 iomuxc 里,加上 RGB 三色灯的 pinctrl 配置。

&iomuxc {
    pinctrl-names = "default";

    pinctrl_gpio_leds: gpio-leds {
        fsl,pins = <
            MX6UL_PAD_NAND_DQS__GPIO4_IO16          0x17059 /* led run */
        >;
    };

    pinctrl_gpio_rgbleds: rgb-leds {
        fsl,pins = <
            MX6UL_PAD_UART2_RTS_B__GPIO1_IO23       0x17059 /* RGB Led red   */
            MX6UL_PAD_SNVS_TAMPER1__GPIO5_IO01      0x17059 /* RGB Led green */
            MX6UL_PAD_SNVS_TAMPER8__GPIO5_IO08      0x17059 /* RGB Led blue  */
        >;
    };
    ... ...

接下来,我们编写 DTS 的 Makefile 文件如下:

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ vim Makefile

ARCH ?= arm
KERNAL_DIR ?= ${HOME}/igkboard-imx6ull/bsp/kernel/linux-imx

CPP_CFLAGS=-Wp,-MD,.x.pre.tmp -nostdinc -undef -D__DTS__ -x assembler-with-cpp
CPP_CFLAGS+= -I ${KERNAL_DIR}/arch/${ARCH}/boot/dts -I ${KERNAL_DIR}/include/

DTC=${KERNAL_DIR}/scripts/dtc/dtc
DTC_FLAGS=-q -@ -I dts -O dtb

DTS_NAME=igkboard-imx6ull

all:
        @cpp ${CPP_CFLAGS} ${DTS_NAME}.dts -o .${DTS_NAME}.dts.tmp
        ${DTC} ${DTC_FLAGS} .${DTS_NAME}.dts.tmp -o ${DTS_NAME}.dtb
        @rm -f .*.tmp

decompile:
        ${DTC} -q -I dtb -O dts ${DTS_NAME}.dtb -o decompile.dts

clean:
        rm -f *.dtb decompile.dts

这个Makefile 描述了内核里的 DTS 编译过程:

  • 在我们的 DTS 里有 #include C代码的头文件,所以在使用 dtc 命令编译 .dts 文件之前,首先使用 cpp 对头文件进行预处理;

  • 编译Linux内核源码时会编译生成 dtc 编译器,另外在 Ubuntu系统下也可以使用 sudo apt install -y device-tree-compiler 命令来安装。

  • 接下来我们可以使用 dtc -I dts -O dtb xxx.dts -o xxx.dtb 将设备树源文件 .dts 编译生成 .dtb 二进制文件;也可以使用 dtc -I dtb -O dts xxx.dtb -o xxx.dts 将设备树二进制文件 .dtb 反编译生成设备树源文件 .dts

  • 在上面的 DTC_FLAGS-q (quiet mode) 选项表示启用静默模式,在编译过程中不会显示任何不重要的警告信息,只有错误和严重的警告会被显示。而 -@ (add “reserved-memory” node) 选项用于在生成的设备树二进制文件中加入 reserved-memory 节点,这通常是为了在设备树中指定一块保留内存区域,以供特定硬件设备(如 DMA、图形处理单元等)使用。

  • 这里我们添加了一个 decompile 目标,用来将 .dtb 文件反编译生成 decompile.dts 设备树文件。

接下来我们可以使用 make 命令编译 igkboard-imx6ull.dts 生成 igkboard-imx6ull.dtb 文件。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ make
/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx/scripts/dtc/dtc -q -@ -I dts -O dtb .igkboard-imx6ull.dts.tmp -o igkboard-imx6ull.dtb
guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ ls
igkboard-imx6ull.dtb  igkboard-imx6ull.dts  Makefile

另外,如果需要我们也可以使用 make decompile 命令将 igkboard-imx6ull.dtb 文件反编译生成 igkboard-imx6ull.dts 源文件。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ make decompile
/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx/scripts/dtc/dtc -q -I dtb -O dts igkboard-imx6ull.dtb -o decompile.dts
guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ ls
decompile.dts  igkboard-imx6ull.dtb  igkboard-imx6ull.dts  Makefile

我们可以查看了解一下反编译生成的 .dts 文件内容。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ vim decompile.dts
/dts-v1/;

/ {
    #address-cells = <0x01>;
    #size-cells = <0x01>;
    model = "LingYun IoT System Studio IoT Gateway Kits Board Based on i.MX6ULL";
    compatible = "lingyun,igkboard-imx6ull\0fsl,imx6ull";

    chosen {
        stdout-path = "/soc/bus@2000000/spba-bus@2000000/serial@2020000";
    };

    aliases {
        ethernet0 = "/soc/bus@2100000/ethernet@2188000";
        ethernet1 = "/soc/bus@2000000/ethernet@20b4000";
        gpio0 = "/soc/bus@2000000/gpio@209c000";
        gpio1 = "/soc/bus@2000000/gpio@20a0000";
        gpio2 = "/soc/bus@2000000/gpio@20a4000";

... ...

    rgbled {
        compatible = "lingyun,leds";
        pinctrl-names = "default";
        pinctrl-0 = <0x34>;
        status = "okay";
        gpios = <0x25 0x17 0x00 0x12 0x01 0x00 0x12 0x08 0x00>;
    };

... ...
            pinctrl@20e0000 {
                ... ...
                gpio-leds {
                    fsl,pins = <0x1b8 0x444 0x00 0x05 0x00 0x17059>;
                    phandle = <0x32>;
                };

                rgb-leds {
                    fsl,pins = <0xa0 0x32c 0x00 0x05 0x00 0x17059 0x20 0x2ac 0x00 0x05 0x00 0x17059 0x3c 0x2c8 0x00 0x05 0x00 0x17059>;
                    phandle = <0x34>;
                };
... ...

3.2.4.7 Led驱动编程测试

接下来我们可以将新的设备树文件下载到开发板上,并重启生效。

root@igkboard:~# mount /dev/mmcblk1p1 /media/
root@igkboard:~# ls /media/
config.txt  igkboard-imx6ull.dtb  overlays  zImage

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/dts/igkboard-imx6ull.dtb /media/

root@igkboard:~# sync && reboot

使用 insmod 命令再次安装驱动模块文件。

root@igkboard:~# insmod ledv1.ko

使用 dmesg 命令查看驱动加载的信息,此时我们可能会发现 RGB 的引脚 MX6UL_PAD_UART2_RTS_B__GPIO1_IO23 会与 CAN 总线冲突,这是因为我们开发板此时使能了 CAN 接口了。

[   79.472281] imx6ul-pinctrl 20e0000.pinctrl: pin MX6UL_PAD_UART2_RTS_B already requested by 2094000.can; cannot claim for rgbled
[   79.472332] imx6ul-pinctrl 20e0000.pinctrl: pin-40 (rgbled) status -22
[   79.472359] imx6ul-pinctrl 20e0000.pinctrl: could not request pin 40 (MX6UL_PAD_UART2_RTS_B) from group rgb-leds  on device 20e0000.pinctrl
[   79.472388] leds rgbled: Error applying setting, reverse things back

查看 arch/arm/boot/dts/imx6ul-pinfunc.h 文件我们可知,GPIO1_IO23 还可以用作 FLEXCAN2_RX 功能使用。

guowenxue@ubuntu20:~$ vim ~/igkboard-imx6ull/bsp/kernel/linux-imx/arch/arm/boot/dts/imx6ul-pinfunc.h
#define MX6UL_PAD_UART2_RTS_B__UART2_DCE_RTS        0x00a0 0x032c 0x0628 0 1
#define MX6UL_PAD_UART2_RTS_B__UART2_DTE_CTS        0x00a0 0x032c 0x0000 0 0
#define MX6UL_PAD_UART2_RTS_B__ENET1_COL        0x00a0 0x032c 0x0000 1 0
#define MX6UL_PAD_UART2_RTS_B__FLEXCAN2_RX      0x00a0 0x032c 0x0588 2 0
#define MX6UL_PAD_UART2_RTS_B__CSI_DATA09       0x00a0 0x032c 0x04e8 3 0
#define MX6UL_PAD_UART2_RTS_B__GPT1_COMPARE3        0x00a0 0x032c 0x0000 4 0
#define MX6UL_PAD_UART2_RTS_B__GPIO1_IO23       0x00a0 0x032c 0x0000 5 0
#define MX6UL_PAD_UART2_RTS_B__SJC_FAIL         0x00a0 0x032c 0x0000 7 0
#define MX6UL_PAD_UART2_RTS_B__ECSPI3_MISO      0x00a0 0x032c 0x0558 8 0

在我们开发板的 CAN2 接口设备树 dtoverlay 文件 arch/arm/boot/dts/igkboard-imx6ull/can2.dts 中,将 GPIO1_IO23 引脚复用作了 CAN 功能使用。

guowenxue@ubuntu20:~$ vim ~/igkboard-imx6ull/bsp/kernel/linux-imx/arch/arm/boot/dts/igkboard-imx6ull/can2.dts
/*
 * Copyright (C) 2022 LingYun IoT System Studio
 * Author:  Guo Wenxue<guowenxue@gmail.com>
 */

/dts-v1/;
/plugin/;

#include "../imx6ul-pinfunc.h"

/* 40-pin extended GPIO, CAN2 interfaces */

&can2 {
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_flexcan2>;
    xceiver-supply = <&reg_can_3v3>;
    status = "okay";
};

&iomuxc {
    pinctrl_flexcan2: flexcan2grp{
        fsl,pins = <
            MX6UL_PAD_UART2_RTS_B__FLEXCAN2_RX  0x1b020
            MX6UL_PAD_UART2_CTS_B__FLEXCAN2_TX  0x1b020
        >;
    };
};

如果出现这种情况,我们可以修改 eMMC 的 boot 分区下的 config.txt 文件,将 dtoverlay_can 注释并重启即可。

root@igkboard:~# mount /dev/mmcblk1p1 /media/
root@igkboard:~# ls /media/
config.txt  igkboard-imx6ull.dtb  overlays  zImage

root@igkboard:~# vi /media/config.txt
... ...
# Enable CAN overlays
#dtoverlay_can=1 2
... ...

接下来再次重新安装驱动文件,这时就可以看到三个设备文件了。

root@igkboard:~# insmod ledv1.ko
root@igkboard:~# dmesg | tail -1
[   76.963060] leds rgbled: led driver probe for 3 leds from device tree

root@igkboard:~# ls -l /dev/led*
crw------- 1 root root 243, 0 Jan  2 03:02 /dev/led0
crw------- 1 root root 243, 1 Jan  2 03:02 /dev/led1
crw------- 1 root root 243, 2 Jan  2 03:02 /dev/led2

接下来开始编写我们的驱动测试程序,首先创建测试程序工作路径。

guowenxue@ubuntu20:~$ cd drivers/imx6ull/
guowenxue@ubuntu20:~/drivers/imx6ull$ mkdir apps && cd apps

接下来编写测试程序代码如下。因为 Led 设备的个数是可变的,所以在代码中我们使用了一个 for() 循环来探测存在多少个Led设备。

guowenxue@ubuntu20:~/drivers/imx6ull/apps$ vim app_led.c
/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * RGB led diver test code in user space.
 */

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <signal.h>

#define DEVNAME             "/dev/led"
#define MAX_LED             10

#define RGBLED_IOC_MAGIC    'l'
#define RGBLED_IOC_SET      _IOW(RGBLED_IOC_MAGIC, 1, int)

int g_stop = 0;

void sig_handler(int signum)
{
    switch(signum)
    {
        case SIGINT:
        case SIGTERM:
            g_stop = 1;
            break;

        default:
            break;
    }

    return;
}

int main (int argc, char **argv)
{
    char       devname[32];
    int        fd[MAX_LED];
    int        led_num = 0;
    int        i;

    /* open all the led device */
    for(i=0; i<MAX_LED; i++)
    {
        snprintf(devname, sizeof(devname), "%s%d", DEVNAME, i);

        /* check device node exist or not */
        if( access(devname, F_OK)<0 )
            break; /* can not found any more */

        fd[i] = open(devname, O_RDWR);
        if( fd < 0 )
        {
            printf("Open device %s failed: %s\n", devname, strerror(errno));
            return 1;
        }

        led_num ++;
        printf("open %s -> fd[%d]\n", devname, fd[i]);
    }

    /* install signal to turn led off when program exit */
    signal(SIGINT, sig_handler);
    signal(SIGTERM, sig_handler);

    /* blink the RGB leds */
    i = 0;
    while( !g_stop )
    {
        ioctl(fd[i], RGBLED_IOC_SET, 1);
        usleep(300000);

        ioctl(fd[i], RGBLED_IOC_SET, 0);
        usleep(300000);

        i = (i+1)%led_num;
    }

cleanup:
    /* turn all the leds off and close the fd */
    for(i=0; i<led_num; i++)
    {
        printf("close %s -> fd[%d]\n", devname, fd[i]);
        ioctl(fd[i], RGBLED_IOC_SET, 0);
        close(fd[i]);
    }

    return 0;
}

编写一个通用的应用测试程序 makefile 文件如下,该文件会将该目录下的 xxx.cyyy.c… 编译生成 xxxyyy

guowenxue@ubuntu20:~/drivers/imx6ull/apps$ vim makefile

#********************************************************************************
#      Copyright:  (C) 2023 LingYun IoT System Studio
#                  All rights reserved.
#
#       Filename:  Makefile
#    Description:  This file used to compile all the C file to respective binary,
#                  and it will auto detect cross compile or local compile.
#
#        Version:  1.0.0(11/08/23)
#         Author:  Guo Wenxue <guowenxue@gmail.com>
#      ChangeLog:  1, Release initial version on "11/08/23 16:18:43"
#
#*******************************************************************************


BUILD_ARCH=$(shell uname -m)
ifeq ($(findstring "x86_64" "i386", $(BUILD_ARCH)),)
    CROSS_COMPILE?=/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf-
endif

CC=${CROSS_COMPILE}gcc

SRCFILES = $(wildcard *.c)
BINARIES=$(SRCFILES:%.c=%)

all: ${BINARIES}

%: %.c
    $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)

clean:
    rm -f ${BINARIES}

在写好上述代码后,直接执行 make 命令即可交叉编译我们的测试程序了。

guowenxue@ubuntu20:~/drivers/imx6ull/apps$ ls
app_led.c  makefile
guowenxue@ubuntu20:~/drivers/imx6ull/apps$ make
/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf-gcc  -o app_led app_led.c
guowenxue@ubuntu20:~/drivers/imx6ull/apps$ ls
app_led  app_led.c  makefile

接下来将其下载到我们的开发板上并进行测试,观察 RGB 三色灯的状态变化。

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/apps/app_led .

root@igkboard:~# ./app_led
open /dev/led0 -> fd[3]
open /dev/led1 -> fd[4]
open /dev/led2 -> fd[5]
^Cclose /dev/led3 -> fd[3]
close /dev/led3 -> fd[4]
close /dev/led3 -> fd[5]

驱动用完后卸载驱动,此时会发现设备文件也会自动消失。

root@igkboard:~# rmmod ledv1.ko

root@igkboard:~# dmesg | tail -2
[  692.051793] leds rgbled: led driver probe for 3 leds from device tree
[  699.962607] leds rgbled: led driver removed.

root@igkboard:~# ls /dev/led*
ls: cannot access '/dev/led*': No such file or directory

3.2.4.8 /sys接口驱动实现

在上面我们所写的 Led 驱动文件中,如果想控制 Led 亮灭的话非常麻烦,必须要编程才能控制,这样非常不方便。而前面我们学习接口编程时了解到,Linux内核自带的 Led 驱动可以直接通过 /sys/class 伪文件系统中来控制 Led 灯亮灭,这样就非常方便。接下来在我们的驱动中,也可以参考Linux 内核自带的 Led 驱动 linux-imx/drivers/leds/leds-gpio.c 文件实现该接口。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim ledv2.c

#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>   /* printk() */
#include <linux/fs.h>       /* everything... */
#include <linux/errno.h>    /* error codes */
#include <linux/types.h>    /* size_t */
#include <linux/cdev.h>     /* cdev */
#include <linux/slab.h>     /* kmalloc() */
#include <linux/version.h>  /* kernel version code */
#include <linux/uaccess.h>  /* copy_from/to_user() */
#include <linux/device.h>
#include <linux/of.h>
#include <linux/platform_device.h>
#include <linux/gpio/consumer.h>
#include <linux/leds.h>

struct leds_desc
{
    struct led_classdev dev;     /* dev for led_classdev_register() */
    struct gpio_desc   *gpio;    /* gpio instance for this led */
    char                name[8]; /* led name in /sys/class/leds */
};

struct led_priv
{
    int                 nleds; /* number of leds */
    struct leds_desc   *leds;  /* leds array */
};

struct led_priv *priv;

static void led_brightness_set(struct led_classdev *dev, enum led_brightness brightness)
{
    struct leds_desc *led = container_of(dev, struct leds_desc, dev);

    gpiod_set_value_cansleep(led->gpio, brightness?1:0 );
}

static int led_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    struct leds_desc *led;
    int ret, i;

    /* allocate memory for private data structure */
    priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
    if (!priv)
        return -ENOMEM;

    /* parser the number of LEDs from the device tree */
    priv->nleds = gpiod_count(dev, NULL);
    if ( priv->nleds < 1) {
        dev_err(dev, "Failed to read leds gpio from device tree\n");
        return -EINVAL;
    }
    dev_info(dev, "led driver probe for %d leds from device tree\n", priv->nleds);

    /* allocate memory for all the leds */
    priv->leds = devm_kzalloc(dev, priv->nleds*sizeof(*priv->leds), GFP_KERNEL);
    if( !priv->leds )
        return -ENOMEM;

    /* parser and request GPIO pins from the device tree */
    for (i = 0; i < priv->nleds; i++) {
        priv->leds[i].gpio = devm_gpiod_get_index(dev, NULL, i, GPIOD_ASIS);
        if (IS_ERR(priv->leds[i].gpio))
            return PTR_ERR(priv->leds[i].gpio);

        /* set GPIO as output mode and default off */
        gpiod_direction_output(priv->leds[i].gpio, 0);
    }

    /* create sysfs file for each led */
    for (i = 0; i < priv->nleds; i++) {
        led = priv->leds+i;
        snprintf(led->name, sizeof(led->name), "led%d", i);

        led->dev.name = led->name;
        led->dev.brightness_set = led_brightness_set;
        ret = led_classdev_register(dev, &led->dev);
        if (ret) {
            dev_err(dev, "Failed to register LED[%d]\n", i);
            goto failed_destroy;
        }
    }

    platform_set_drvdata(pdev, priv);
    return 0;

failed_destroy:
    for (--i; i >= 0; i--)
        led_classdev_unregister(&priv->leds[i].dev);

    return ret;
}

static int led_remove(struct platform_device *pdev)
{
    struct led_priv *priv = platform_get_drvdata(pdev);
    int i;

    for (i = 0; i < priv->nleds; i++) {
        led_classdev_unregister(&priv->leds[i].dev);
    }

    dev_info(&pdev->dev, "led driver removed.\n");
    return 0;
}

static const struct of_device_id led_of_match[] = {
    { .compatible = "lingyun,leds", },
    { /* sentinel */ },
};
MODULE_DEVICE_TABLE(of, led_of_match);

static struct platform_driver led_driver = {
    .probe = led_probe,
    .remove = led_remove,
    .driver = {
        .name = "leds",
        .of_match_table = led_of_match,
    },
};

module_platform_driver(led_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");
MODULE_DESCRIPTION("Led driver with sysfs interface");

该驱动文件与前面 ioctl() API 接口函数大部分都差不多,只是这里它并没有注册一个字符设备驱动,而是通过调用Linux内核封装好的 led_classdev_register() 函数创建 /sys/class/leds/ 路径下的文件,这样在命令行就可以通过 echo 命令写该路径下相应 Led 文件夹下的 brightnesss 文件即可控制灯的亮灭。

接下来我们修改 Makefile 文件,添加该驱动的编译并编译。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim Makefile
 obj-m += ledv1.o
+obj-m += ledv2.o

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ make
make ARCH=arm CROSS_COMPILE=/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf- -C ~/igkboard-imx6ull/bsp/kernel/linux-imx/ M=/home/guowenxue/drivers/imx6ull/driver modules
make[1]: Entering directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/hello.o
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/ledv1.o
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/ledv2.o
  MODPOST /home/guowenxue/drivers/imx6ull/driver/Module.symvers
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/hello.mod.o
  LD [M]  /home/guowenxue/drivers/imx6ull/driver/hello.ko
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/ledv1.mod.o
  LD [M]  /home/guowenxue/drivers/imx6ull/driver/ledv1.ko
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/ledv2.mod.o
  LD [M]  /home/guowenxue/drivers/imx6ull/driver/ledv2.ko
make[1]: Leaving directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
make[1]: Entering directory '/home/guowenxue/drivers/imx6ull/driver'
make[1]: Leaving directory '/home/guowenxue/drivers/imx6ull/driver'

在开发板上下载新的驱动文件。

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/ledv2.ko .
guowenxue@192.168.0.2's password:
ledv2.ko

如果前面安装的驱动还没卸载,则我们首先卸载该驱动,然后安装新的驱动。

root@igkboard:~# rmmod ledv1.ko
root@igkboard:~# dmesg | tail -
[  848.246744] leds rgbled: led driver probe for 3 leds from device tree

在新的驱动中,我们并没有注册字符设备,所以 /dev/ 下并不会产生新的设备文件,而在 /sys/class/leds 路径下出现了我们的三个 Led 设备文件。

root@igkboard:~# ls /dev/led*
ls: cannot access '/dev/led*': No such file or directory

root@igkboard:~# ls /sys/class/leds/
led0  led1  led2  mmc0::  mmc1::

root@igkboard:~# ls /sys/class/leds/led1/
brightness  device  max_brightness  power  subsystem  trigger  uevent

接下来我们使用 echo 命令就可以控制相应 Led 亮灭了。

root@igkboard:~# echo 1 > /sys/class/leds/led1/brightness
root@igkboard:~# echo 0 > /sys/class/leds/led1/brightness

从上面 Led 驱动程序编写过程中我们了解到,要实现一个设备的驱动供应用程序空间使用,可以有多种不同的实现方式。如果我们想要容易编程控制,则可以使用常规的字符设备驱动通过调用 ioctl() 系统调用实现;而如果想要在命令行或Shell脚本中直接实现,则我们可以使用 /sys/class 伪文件系统来实现。

与此同时,在Linux内核中要实现某一个功能也有不同的方式,如内存的动态申请有 kmalloc()devm_kzalloc()、 DTS 文件里的GPIO解析通用函数 of_xxx() 和 libgpiod 相关函数 gpiod_xxx() 等、GPIO 的传统控制函数 gpio_xxx() 和最新的 gpiod_xxx() 等。每一套函数都有它们的使用特性和规则,我们只有熟悉了解它们之后才能灵活运用,而这些可以通过搜索学习或查看 Linux内核中的其它驱动源码来学习了解。

3.2.5 编写按键驱动

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

3.2.5.1 硬件原理图分析

在 IGKBoard-IMX6ULL 开发板电源接口旁边有一个用户自定义按键,在前面我们学习接口编程《2.3 Input设备编程之按键控制》时,了解到了Linux的 input 子系统及其接口编程。接下来我们将通过编写该设备的驱动,了解 Linux内核里的 input 子系统驱动编程。

userkey_image

下面是该按键的原理图。

从上面的原理图我们可以看出:

  • 该按键连接到了 NAND_nCE1 这个引脚上,通过设备树的头文件我们可以查到它使用 MX6UL_PAD_NAND_CE1_B__GPIO4_IO14 这个引脚;

  • 如果没有按下按键时,左侧的上拉电阻 R25 将该 GPIO 引脚拉成高电平;而一旦按键 SW2 按下,则该引脚与 GND 导通变成低电平;

  • 由此可见,我们应该将 GPIO4_IO14 中断设置成下降沿触发;

3.2.5.2 Input设备驱动

在 Linux 中,按键驱动通常通过 Input Subsystem 来处理,这一子系统为所有类型的输入设备(如键盘、鼠标、触摸屏、游戏控制器等)提供统一的接口和管理。下面是一个关于 Linux 按键 Input 设备驱动 的简要介绍:

  • Input Subsystem 概述

Linux 的 Input Subsystem 提供了统一的框架来管理不同类型的输入设备。它为内核提供了一个通用的接口来处理输入事件,例如按键按下、鼠标移动或触摸事件。对于按键设备,Linux 的 Input Subsystem 会将按键的事件转化为统一的事件格式,并通过 input 接口传递给用户空间程序。用户空间的应用程序(如 X11、Wayland 或命令行工具)可以通过该接口获取按键事件。

  • 按键驱动的结构

按键驱动的实现通常包括以下几个关键部分:

  • Input 设备结构体input_dev):这是一个描述输入设备的结构体,它定义了设备的类型、名称、支持的事件、事件类型、键码等信息。

  • 事件类型:输入设备会生成不同类型的事件,最常见的事件类型是 EV_KEY(键盘按键事件)、EV_REL(鼠标事件)、EV_ABS(触摸屏事件)。

  • 键码:每个按键在输入子系统中都有一个唯一的键码,它用于标识某个具体的按键,按键的键码通常对应于 Linux 的 keycode

  • 输入事件:通过 input_report_key() 等函数,按键驱动可以将按键按下或释放等事件报告给系统。

  • 按键事件的类型

在 Linux 的输入子系统中,按键事件分为以下几类:

  • 按键按下事件(KEY_DOWN):当用户按下一个按键时,驱动会报告一个按键按下的事件。

  • 按键释放事件(KEY_UP):当用户松开按键时,驱动会报告一个按键释放的事件。

  • 长按事件:一些特殊的按键(如软键盘)可以通过定时检测来实现长按事件。

  • 按键重复事件:对于可重复的按键(如空格键),驱动可以实现按键按下后定期重复发送按键按下事件。

  • 按键驱动的开发流程

一个典型的按键设备驱动需要以下几个步骤:

  1. 分配和初始化输入设备

按键驱动首先需要分配一个 input_dev 结构体,并设置它的基本属性,如设备名称、事件类型、支持的按键等。

struct input_dev *input_device;

input_device = input_allocate_device();
if (!input_device) {
    pr_err("Failed to allocate input device\n");
    return -ENOMEM;
}

input_device->name = "my_key_device";
input_device->evbit[0] = BIT_MASK(EV_KEY);  // 设置支持按键事件
  1. 注册按键

设置好设备的属性后,调用 input_register_device() 函数来注册输入设备,使其可以开始接收并处理事件。

ret = input_register_device(input_device);
if (ret) {
    pr_err("Failed to register input device\n");
    return ret;
}
  1. 按键事件报告

按键驱动需要在按键状态发生变化时(按下或松开)通过 input_report_key() 向输入子系统报告事件。

input_report_key(input_device, KEY_ENTER, 1);  // 报告按下事件
input_sync(input_device);  // 同步事件

该函数将按键按下的事件报告给系统,用户空间应用程序可以通过 evdev 等接口读取到这些事件。

  1. 处理中断和按键状态

在驱动中,需要监听 GPIO 引脚(或者其他硬件)上按键的状态变化。通常这通过硬件中断(IRQ)来触发。按键状态的改变将会触发相应的中断处理函数,在中断处理函数中再通过 input_report_key() 来报告事件。

  1. 释放和注销设备

在驱动退出时,需要释放资源,并通过 input_unregister_device() 注销输入设备。

input_unregister_device(input_device);
input_free_device(input_device);
  • 事件同步与事件队列

  • 事件同步:在报告完按键事件后,驱动需要调用 input_sync() 来同步事件,确保事件被正确地传递到输入子系统。

  • 事件队列:输入子系统通过事件队列的方式管理输入事件,驱动程序负责将按键事件传递到队列中,用户空间程序通过 evdev 等接口从队列中读取事件。

  • 设备树支持

按键驱动常常需要与设备树(Device Tree)配合使用。在设备树中定义按键的 GPIO 引脚、按键的名称、按键的 Linux 键码等信息。驱动通过解析设备树来动态加载和配置按键设备。例如,在设备树中可以这样定义一个按键:

key1 {
    compatible = "linux,keys";
    label = "KEY_1";
    gpios = <&gpio1 12 GPIO_ACTIVE_LOW>;
    linux,code = <KEY_A>;
};
  • 应用层交互

按键驱动完成事件报告后,应用程序可以通过如下方式读取按键事件:

  • 使用 evdev 接口(/dev/input/eventX)读取事件;

  • 使用图形界面框架(如 X11 或 Wayland)获取键盘输入。

应用程序通过读取输入事件文件(例如 /dev/input/eventX)来获取按键的状态变化(按下、释放)。

  • 总结

Linux 中的按键输入设备驱动通过 Input Subsystem 提供统一的接口来管理按键事件。驱动通过解析设备树,配置 GPIO 和中断处理,报告按键事件,最终通过输入子系统将按键事件传递给用户空间。开发者需要了解如何初始化输入设备、处理按键事件、同步事件以及在设备移除时清理资源。

3.2.5.3 设备树修改

接下来,我们修改 DTS 文件中按键的配置,因为 BSP 中默认已经使能了该设备和Linux内核自带的按键驱动,这里只需将 compatible 修改成我们自己即将编写的按键驱动 "lingyun,keys" 即可,别的都不需要修改。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ vim igkboard-imx6ull.dts

    keys {
        compatible = "lingyun,keys";
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_gpio_keys>;
        autorepeat;
        status = "okay";

        key_user {
            lable = "key_user";
            gpios = <&gpio4 14 GPIO_ACTIVE_LOW>;
            linux,code = <KEY_ENTER>;
        };
    };
... ...
&iomuxc {
    pinctrl-names = "default";
... ...
    pinctrl_gpio_keys: gpio-keys {
        fsl,pins = <
            MX6UL_PAD_NAND_CE1_B__GPIO4_IO14        0x17059 /* gpio key */
        >;
    };
... ...
};

在上面的 DTS 文件中:

  • 我们的按键使用的是 GPIO4_14 引脚,并且低电平有效 GPIO_ACTIVE_LOW ;

  • 我们设置该按键的键值 linux,code 为回车 KEY_ENTER,按下该按键即相当于按下了回车;

  • 按键的键值定义在Linux内核源码的 linux-imx/include/dt-bindings/input/linux-event-codes.h 路径下;

修改完成之后,重新编译生成 .dtb 文件。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ make
/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx/scripts/dtc/dtc -q -@ -I dts -O dtb .igkboard-imx6ull.dts.tmp -o igkboard-imx6ull.dtb

3.2.5.4 编写按键驱动

接下来编写按键驱动文件如下:

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim keys.c
/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * GPIO keys driver example on IGKBoard-IMX6ULL board.
 */

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/errno.h>
#include <linux/init.h>
#include <linux/gpio.h>
#include <linux/input.h>
#include <linux/interrupt.h>
#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/of_gpio.h>
#include <linux/of_device.h>

struct keys_desc {
    const char         *lable;    /* Key name */
    unsigned int        key_code; /* Key code */
    int                 gpio;     /* GPIO number */
    unsigned int        irq;      /* IRQ number */
};

struct key_priv {
    int                 nkeys; /* number of keys */
    struct keys_desc   *keys;  /* keys array */
};

struct input_dev   *input_device;
struct key_priv *priv;

static irqreturn_t gpio_key_irq(int irq, void *arg) {
    struct keys_desc *key = arg;
    int value = gpio_get_value(key->gpio);

    /* if the key is pressed (GPIO value is low), report key press */
    if (value == 0) {
        input_report_key(input_device, key->key_code, 1);  /* key pressed */
    } else {
        input_report_key(input_device, key->key_code, 0);  /* key released */
    }

    /* sync the input event */
    input_sync(input_device);

    return IRQ_HANDLED;
}

static int key_probe(struct platform_device *pdev) {
    struct device *dev = &pdev->dev;
    struct device_node *np = pdev->dev.of_node;
    struct device_node *key_node;
    int ret, i=0;

    /* allocate memory for private data structure */
    priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
    if (!priv)
        return -ENOMEM;

    /* parser the number of keys from the device tree */
    priv->nkeys = device_get_child_node_count(dev);
    if ( priv->nkeys < 1) {
        dev_err(dev, "Failed to read keys gpio from device tree\n");
        return -EINVAL;
    }
    dev_info(dev, "gpio keys driver probe for %d keys from device tree\n", priv->nkeys);

    /* allocate memory for all the keys */
    priv->keys = devm_kzalloc(dev, priv->nkeys*sizeof(*priv->keys), GFP_KERNEL);
    if (!priv->keys )
        return -ENOMEM;

    /* traval all the keys child node */
    for_each_child_of_node(np, key_node) {
        /* read lable information */
        if (of_property_read_string(key_node, "lable", &priv->keys[i].lable)) {
            dev_err(dev, "Failed to read lable from key node\n");
            continue;
        };

        /* read gpio information */
        priv->keys[i].gpio = of_get_named_gpio(key_node, "gpios", 0);
        if( priv->keys[i].gpio < 0 ) {
            dev_err(dev, "Failed to read lable from key node\n");
            continue;
        }

        /* read key code value */
        if (of_property_read_u32(key_node, "linux,code", &priv->keys[i].key_code)) {
            dev_err(dev, "Failed to read linux,code for key %s\n", priv->keys[i].lable);
            continue;
        }

        /* request gpio for this key */
        ret = devm_gpio_request(dev, priv->keys[i].gpio, priv->keys[i].lable);
        if (ret) {
            dev_err(dev, "Failed to request GPIO for key %s\n", priv->keys[i].lable);
            continue;
        }
        /* request interrupt for this key */
        priv->keys[i].irq = gpio_to_irq(priv->keys[i].gpio);
        ret = devm_request_irq(dev, priv->keys[i].irq, gpio_key_irq, IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING, priv->keys[i].lable, &priv->keys[i]);
        if (ret) {
            dev_err(dev, "Failed to request IRQ for key %s\n", priv->keys[i].lable);
            continue;
        }

        i++;
    }
    priv->nkeys = i; /* update valid keys number */

    /* alloc input device */
    input_device = devm_input_allocate_device(dev);
    if (!input_device) {
        dev_err(dev, "failed to allocate input device\n");
        return -ENOMEM;
    }

    /* set input deivce information */
    input_device->name = "mykeys";
    input_device->evbit[0] = BIT_MASK(EV_KEY); /* key event */
    for ( i=0; i<priv->nkeys; i++) {
        set_bit(priv->keys[i].key_code, input_device->keybit);
    }

    /* register input device */
    ret = input_register_device(input_device);
    if (ret) {
        pr_err("Failed to register input device\n");
        return ret;
    }

    return 0;
}

static int key_remove(struct platform_device *pdev)
{
    input_unregister_device(input_device);
    dev_info(&pdev->dev, "gpio keys driver removed.\n");
    return 0;
}

static const struct of_device_id key_of_match[] = {
    { .compatible = "lingyun,keys", },
    { /* sentinel */ },
};
MODULE_DEVICE_TABLE(of, key_of_match);

static struct platform_driver key_driver = {
    .probe = key_probe,
    .remove = key_remove,
    .driver = {
        .name = "keys",
        .of_match_table = key_of_match,
    },
};

module_platform_driver(key_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");
MODULE_DESCRIPTION("GPIO key driver example");

这个按键驱动的注册流程分为几个关键步骤,具体流程如下:

  1. 设备树解析与按键配置

  • key_probe() 函数中,首先通过 pdev->dev.of_node 获取当前设备节点的信息。然后,使用 device_get_child_node_count() 获取设备树中子节点的数量,表示按键的数量。如果按键数量为 0,则返回错误。

  • 接下来为每个按键分配内存(priv->keys),并通过 for_each_child_of_node() 遍历设备树中的每个子节点。

  • 对于每个按键子节点,使用 of_property_read_string() 获取按键的标签(lable),使用 of_get_named_gpio() 获取 GPIO 引脚编号,使用 of_property_read_u32() 获取按键的 Linux 键码(linux,code)。

  • 然后,调用 devm_gpio_request() 请求 GPIO 引脚,并通过 gpio_to_irq() 函数获取该 GPIO 对应的中断号,接着使用 devm_request_irq() 请求该 GPIO 的中断。

  1. 按键中断处理

  • 按键的中断处理函数是 gpio_key_irq,它会在按键的 GPIO 状态变化时被触发。

  • 在该函数中,通过 gpio_get_value() 获取按键的 GPIO 电平,判断按键是按下(GPIO 为低电平)还是释放(GPIO 为高电平)。

  • 根据 GPIO 状态,使用 input_report_key() 向输入子系统报告按键的按下或释放事件,并调用 input_sync() 同步事件。

  1. 输入设备初始化

  • key_probe 函数中,通过 devm_input_allocate_device() 分配一个输入设备结构体 input_device

  • 设置输入设备的名称(mykeys)和键盘按键事件类型(EV_KEY),并通过 set_bit() 将每个按键的键码添加到输入设备的 keybit 位图中。

  • 然后,调用 input_register_device() 注册输入设备。

  1. 驱动的移除(清理资源)

当设备被移除时,key_remove() 函数会被调用。它会注销输入设备(input_unregister_device())并打印设备移除的日志信息。

  1. 驱动的匹配和注册

  • 驱动通过 of_device_id 表格与设备树中的设备节点匹配,key_of_match 数组定义了驱动支持的设备类型(lingyun,keys)。

  • 驱动通过 platform_driver 注册,platform_driver 结构体中包含了 proberemove 函数指针,用于设备的初始化和移除。

接下来修改驱动 Makefile 文件,添加按键驱动的编译支持并编译。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim Makefile
+obj-m += keys.o

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ make
make ARCH=arm CROSS_COMPILE=/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf- -C ~/igkboard-imx6ull/bsp/kernel/linux-imx/ M=/home/guowenxue/drivers/imx6ull/driver modules
make[1]: Entering directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
  ... ...
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/keys.mod.o
  LD [M]  /home/guowenxue/drivers/imx6ull/driver/keys.ko
  ... ...
make[1]: Leaving directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
make[1]: Entering directory '/home/guowenxue/drivers/imx6ull/driver'
make[1]: Leaving directory '/home/guowenxue/drivers/imx6ull/driver'

3.2.5.5 按键驱动测试

首先,在开发板上更新我们最新的设备树文件,并重启开发板生效。

root@igkboard:~# mount /dev/mmcblk1p1 /media/
root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/dts/igkboard-imx6ull.dtb /media/
root@igkboard:~# sync && reboot

再将我们编写的按键设备驱动文件拷贝到开发板上。

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/keys.ko .
guowenxue@192.168.0.2's password:
keys.ko

接下来我们使用 insmod 命令安装按键驱动,输入设备的设备文件都在 /dev/input 路径下。

root@igkboard:~# insmod keys.ko

root@igkboard:~# dmesg | tail -3
[346937.139733] keys keys: gpio keys driver probe for 1 keys from device tree
[346937.157333] input: mykeys as /devices/platform/keys/input/input1
[346937.158291] evbug: Connected device: input1 (mykeys at unknown)

root@igkboard:~# ls /dev/input/
by-path  event0  event1

接下来使用 evtest 命令测试我们编写的驱动如下:

3.2.5.6 按键消抖实现

按键抖动(Key Bounce 或 Key Debouncing)是指当按键被按下或释放时,由于机械结构或触点的物理特性,可能会出现短暂的、快速的多次开关变化。抖动会导致输入系统误认为按键被多次按下或释放,从而产生错误的事件或行为。

为什么会发生按键抖动

按键通常是由两个金属触点组成,当按键按下或释放时,这些触点会发生接触或断开。由于物理原因,触点在短时间内可能会发生多次闭合和断开,而不是单次稳定地触发。这种现象被称为“抖动”或“抖动效应”。

  • 按下时抖动:按键按下时,触点会快速闭合和断开,导致多个按下事件被触发。

  • 释放时抖动:当按键松开时,触点会迅速打开和关闭,导致多个释放事件被触发。

抖动的影响

  • 多次按键事件:抖动会使得一个按键的按下或释放被多次记录,从而导致错误的按键事件。

  • 影响输入系统的稳定性:如果系统没有进行去抖动处理,会产生误读,影响正常的用户交互。

  • 增加系统负担:如果没有适当的去抖动机制,硬件中断可能会频繁触发,增加系统的处理负担,尤其是在低资源系统中。

去抖动的解决方法

按键去抖动是通过软件或硬件方法消除按键抖动的过程。硬件去抖动方法通常是在按键硬件的设计中加以改进,例如使用 RC 滤波器、专用的去抖动 IC 或使用晶振稳定信号,这些方法能在硬件层面消除抖动,无需依赖软件。

常见的软件去抖动方法有:

  1. 延时法(延时去抖动)

一种常见的简单方法是,当按键状态发生变化时,等待一定时间(例如 10~50 毫秒)以确保按键稳定。此方法的思路是等待按键接触或断开后的一段时间,再检测一次按键状态,以确认状态变化是否稳定。

#define DEBOUNCE_DELAY_MS 20  // 延时 20ms

static irqreturn_t gpio_key_irq(int irq, void *arg)
{
    struct keys_desc *key = arg;
    static unsigned long last_irq_time = 0;
    unsigned long now = jiffies;

    // 检查抖动延迟
    if (time_after(now, last_irq_time + msecs_to_jiffies(DEBOUNCE_DELAY_MS))) {
        int value = gpio_get_value(key->gpio);
        if (value == 0) {
            input_report_key(input_device, key->key_code, 1);  // 按下事件
        } else {
            input_report_key(input_device, key->key_code, 0);  // 释放事件
        }
        input_sync(input_device);
        last_irq_time = now;
    }

    return IRQ_HANDLED;
}
  • 优点:简单实现,不需要额外硬件。

  • 缺点:延时法可能导致响应时间变长,不能实现非常精确的去抖动,延时的时间需要根据具体情况调整。

  1. 持续检测法(轮询去抖动)

这种方法是对按键状态进行多次连续检查,只有在按键状态一致时才认为状态已稳定。通常在硬件中断中进行,读取按键状态并检查是否稳定。

#define DEBOUNCE_COUNT 5  // 检查连续的 5 次状态

static irqreturn_t gpio_key_irq(int irq, void *arg)
{
    struct keys_desc *key = arg;
    static int stable_state = -1;
    static int count = 0;
    int value = gpio_get_value(key->gpio);

    if (stable_state == value) {
        count++;
        if (count > DEBOUNCE_COUNT) {
            // 状态稳定,报告事件
            if (value == 0) {
                input_report_key(input_device, key->key_code, 1);  // 按下事件
            } else {
                input_report_key(input_device, key->key_code, 0);  // 释放事件
            }
            input_sync(input_device);
            count = 0;
        }
    } else {
        stable_state = value;
        count = 0;
    }

    return IRQ_HANDLED;
}
  • 优点:更可靠地过滤抖动,适合处理快速的按键状态变化。

  • 缺点:增加了额外的处理复杂度,需要做更多的状态检测和计数。

下面是我们用一个Linux内核定时器实现按键消抖的源码。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim keys.c

/*
 * Copyright (C) 2024 LingYun IoT System Studio
 * Author: Guo Wenxue <guowenxue@gmail.com>
 *
 * GPIO keys driver example on IGKBoard-IMX6ULL board.
 */

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/errno.h>
#include <linux/init.h>
#include <linux/gpio.h>
#include <linux/input.h>
#include <linux/interrupt.h>
#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/of_gpio.h>
#include <linux/of_device.h>
#include <linux/jiffies.h>
#include <linux/delay.h>

struct keys_desc {
    const char         *lable;     /* Key name */
    unsigned int        key_code;  /* Key code */
    int                 gpio;      /* GPIO number */
    unsigned int        irq;       /* IRQ number */
    struct timer_list   timer;     /* Timer for debounce */
    int                 last_value;/* Last key value */
};

struct key_priv {
    int                 nkeys; /* number of keys */
    struct keys_desc   *keys;  /* keys array */
};

struct input_dev *input_device;
struct key_priv *priv;

/* Timer callback function for debounce */
static void debounce_timer_func(struct timer_list *t)
{
    struct keys_desc *key = from_timer(key, t, timer);
    int value = gpio_get_value(key->gpio);

    if (value != key->last_value) {
        key->last_value = value;

        if (value == 0) {
            input_report_key(input_device, key->key_code, 1);  /* Key press event */
        } else {
            input_report_key(input_device, key->key_code, 0);  /* Key release event */
        }

        input_sync(input_device);
    }
}

/* GPIO IRQ handler */
static irqreturn_t gpio_key_irq(int irq, void *arg)
{
    struct keys_desc *key = arg;

    /* start debounce timer(20ms) to delay event processing */
    mod_timer(&key->timer, jiffies + msecs_to_jiffies(20));

    return IRQ_HANDLED;
}

static int key_probe(struct platform_device *pdev) {
    struct device *dev = &pdev->dev;
    struct device_node *np = pdev->dev.of_node;
    struct device_node *key_node;
    int ret, i=0;

    /* allocate memory for private data structure */
    priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
    if (!priv)
        return -ENOMEM;

    /* parser the number of keys from the device tree */
    priv->nkeys = device_get_child_node_count(dev);
    if ( priv->nkeys < 1) {
        dev_err(dev, "Failed to read keys gpio from device tree\n");
        return -EINVAL;
    }
    dev_info(dev, "gpio keys driver probe for %d keys from device tree\n", priv->nkeys);

    /* allocate memory for all the keys */
    priv->keys = devm_kzalloc(dev, priv->nkeys*sizeof(*priv->keys), GFP_KERNEL);
    if (!priv->keys )
        return -ENOMEM;

    /* traval all the keys child node */
    for_each_child_of_node(np, key_node) {
        /* read lable information */
        if (of_property_read_string(key_node, "lable", &priv->keys[i].lable)) {
            dev_err(dev, "Failed to read lable from key node\n");
            continue;
        };

        /* read gpio information */
        priv->keys[i].gpio = of_get_named_gpio(key_node, "gpios", 0);
        if( priv->keys[i].gpio < 0 ) {
            dev_err(dev, "Failed to read lable from key node\n");
            continue;
        }

        /* read key code value */
        if (of_property_read_u32(key_node, "linux,code", &priv->keys[i].key_code)) {
            dev_err(dev, "Failed to read linux,code for key %s\n", priv->keys[i].lable);
            continue;
        }

        /* request gpio for this key */
        ret = devm_gpio_request(dev, priv->keys[i].gpio, priv->keys[i].lable);
        if (ret) {
            dev_err(dev, "Failed to request GPIO for key %s\n", priv->keys[i].lable);
            continue;
        }

        /* request interrupt for this key */
        priv->keys[i].irq = gpio_to_irq(priv->keys[i].gpio);
        ret = devm_request_irq(dev, priv->keys[i].irq, gpio_key_irq, IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING, priv->keys[i].lable, &priv->keys[i]);
        if (ret) {
            dev_err(dev, "Failed to request IRQ for key %s\n", priv->keys[i].lable);
            continue;
        }

        /* initialize debounce timer */
        timer_setup(&priv->keys[i].timer, debounce_timer_func, 0);
        priv->keys[i].last_value = gpio_get_value(priv->keys[i].gpio);

        /* increase to next key */
        i++;
    }
    priv->nkeys = i; /* update valid keys number */

    /* alloc input device */
    input_device = devm_input_allocate_device(dev);
    if (!input_device) {
        dev_err(dev, "failed to allocate input device\n");
        return -ENOMEM;
    }

    /* set input deivce information */
    input_device->name = "mykeys";
    input_device->evbit[0] = BIT_MASK(EV_KEY); /* key event */
    for ( i=0; i<priv->nkeys; i++) {
        set_bit(priv->keys[i].key_code, input_device->keybit);
    }

    /* register input device */
    ret = input_register_device(input_device);
    if (ret) {
        pr_err("Failed to register input device\n");
        return ret;
    }

    return 0;
}

static int key_remove(struct platform_device *pdev)
{
    input_unregister_device(input_device);
    dev_info(&pdev->dev, "gpio keys driver removed.\n");
    return 0;
}

static const struct of_device_id key_of_match[] = {
    { .compatible = "lingyun,keys", },
    { /* sentinel */ },
};
MODULE_DEVICE_TABLE(of, key_of_match);

static struct platform_driver key_driver = {
    .probe = key_probe,
    .remove = key_remove,
    .driver = {
        .name = "keys",
        .of_match_table = key_of_match,
    },
};

module_platform_driver(key_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");
MODULE_DESCRIPTION("GPIO key driver example with debounce using timer interrupt");

在这个新的驱动中:

  1. 定时器初始化

    • 在每个按键的 keys_desc 结构中,添加了一个 timer 定时器,用于实现按键去抖动。

    • key_probe() 中,我们为每个按键初始化了一个定时器,定时器回调函数 debounce_timer_func() 负责处理按键的消抖。

  2. GPIO 中断处理

    • 当 GPIO 引脚的状态发生变化时(按键按下或释放),gpio_key_irq 函数会被调用,触发定时器的启动。

    • 定时器的延时设置为 50 毫秒,这意味着按键状态需要稳定超过 50 毫秒才会报告按键事件。

  3. 定时器回调函数

    • 定时器回调函数会在定时器到期时检查按键的当前状态。如果按键的状态与上次的状态不同,则更新状态并报告按键事件(按下或释放)。

  4. 移除函数

    • key_remove 中,我们删除了所有按键的定时器,以确保在驱动移除时不会发生定时器回调。

通过定时器和软中断机制实现按键的消抖,我们避免了因为硬件抖动导致的多次事件触发问题。这个方法提供了一个平衡的方案,既避免了过多的中断触发,又能保证按键事件的响应正确和及时。

3.2.5.7 中断上下半部

上面我们按键的驱动使用到了 Linux 系统中断处理,关于Linux中断处理我们需要知道两点:

  • Linux系统中的中断不支持优先级嵌套;

  • 中断服务函数运行时间应当尽量短,做到快进快出。

因此,在 Linux 下编写中断服务程序 (ISR, Interrupt Service Routine) 时,有一些关键点需要特别注意,以确保中断处理的高效性和系统的稳定性:

  1. 中断上下文与进程上下文

中断服务程序是在中断上下文中执行的,这意味着不能直接执行会引起阻塞的操作,例如 sleepwait 等。不能使用阻塞性函数,因为它们会导致死锁或不可预料的行为。中断服务程序应该尽量简短快速,避免执行过多的操作。

  1. 避免使用不安全的函数

中断上下文不能调用非原子操作或非可重入的函数,避免使用例如 mallocfree 这样的内存分配函数,因为它们可能引发调度或锁的问题。另外也应该避免调用可能被阻塞的系统调用,如文件操作、网络 I/O 等。

  1. 中断标志管理

中断服务程序执行时需要确保中断标志被及时清除,以便能够正确响应后续中断。不同的硬件平台有不同的中断控制机制,通常需要读取中断控制寄存器并清除相关标志。

  1. 中断共享

当多个设备共享同一个中断时,要特别小心。此时的 ISR 需要检查是哪一个设备触发了中断,通常通过读取硬件状态寄存器来确定是哪一个设备生成了中断。

  1. 避免中断嵌套

在中断服务程序中,不能调用会引发新的中断的函数。中断嵌套可能导致栈溢出或者系统崩溃。如果硬件支持,可以使用中断使能和禁止来避免中断嵌套。

  1. 中断禁用与重入保护

在某些情况下,需要在进入中断服务程序时禁用本地中断,以避免被其他中断打断,通常使用 local_irq_disable()spin_lock_irqsave() 来禁用中断。如果需要保护共享资源,使用自旋锁 (spin_lock) 或读写锁来保证数据的一致性。

  1. 中断响应速度与优化

中断服务程序的目的是尽可能快地响应中断并清除中断源。避免在 ISR 中执行复杂的算法或者延迟操作。优化 ISR 的响应速度非常重要。

  1. 检查中断源

在处理中断时,首先需要检查中断的来源。因为多个设备或多个中断可能会触发相同的中断线,需要通过硬件寄存器或状态寄存器来确认具体的触发原因。

  1. 释放中断

中断处理完成后,要释放中断线,防止中断持续触发或其他设备产生新的中断。可以通过 free_irq() 来释放申请的中断线。

然而一些中断的产生之后需要较长的时间来处理,如由于网络传输产生的中断, 在产生网络传输中断后需要比较长的时间来处理接收或者发送数据,因为在linux中断并不能被嵌套,如果这时有其他中断产生就不能够及时的响应。为了解决这个问题,linux对中断的处理引入了“中断上半部(Top Half)”和 “中断下半部(Bottom Half)”的概念,在中断的上半部中只对中断做简单的处理,把需要耗时处理的部分放在中断下半部中,使得能够 对其他中断作为及时的响应,提供系统的实时性。这一概念又被称为中断分层:

  • “上半部分”是指在中断服务函数中执行的那部分代码,

  • “下半部分”是指那些原本应当在中断服务函数中执行但通过某种方式把它们放到中断服务函数外执行。

像前面我们写的按键消抖程序中,当按键按下后CPU就进入到了中断服务处理程序 gpio_key_irq()。按照以往我们应用程序的设计逻辑,此时会在该函数里做个20ms 的延时,然后再读按键是按下还是释放,这样来消除抖动。需要注意的是,我们所写的驱动是工作在 Linux 内核里,20ms 对于 CPU 来说是非常漫长的时间,在这段期间它还可以做很多的事。

另外,Linux系统下的中断并不像STM32单片机那样支持中断嵌套,如果是这样高优先级可以抢占低优先级中断。但在 Linux 系统下,所有的中断都处于同一优先级,此时如果我们在中断里做了 20ms 的延时,那在这段时间内所有产生的系统中断,CPU都得不到及时响应 。这就是为什么我们需要在消抖程序里添加一个定时器的原因,通过这个定时器我们就可以把中断分成了上半部和下半部两个部分:

  • 中断服务函数 gpio_key_irq() 就是中断的上半部,它要快进快出,在这里只是简单地修改一下定时器的超时时间,20ms 延时和按键的按下和释放判断,都丢给下半部来处理。

    /* GPIO IRQ handler */
    static irqreturn_t gpio_key_irq(int irq, void *arg)
    {
        struct keys_desc *key = arg;
    
        /* start debounce timer(20ms) to delay event processing */
        mod_timer(&key->timer, jiffies + msecs_to_jiffies(20));
    
        return IRQ_HANDLED;
    }
    
  • 定时器回调函数 debounce_timer_func() 就是中断的下半部,此时CPU并不是工作在中断模式下,即使发生了别的中断 CPU 也可以及时响应。

    /* Timer callback function for debounce */
    static void debounce_timer_func(struct timer_list *t)
    {
        struct keys_desc *key = from_timer(key, t, timer);
        int value = gpio_get_value(key->gpio);
    
        if (value != key->last_value) {
            key->last_value = value;
    
            if (value == 0) {
                input_report_key(input_device, key->key_code, 1);  /* Key press event */
            } else {
                input_report_key(input_device, key->key_code, 0);  /* Key release event */
            }
    
            input_sync(input_device);
        }
    }
    

在前面使用定时器的过程中,我们发现定时器的响应过程跟中断类似,只是它是由软件定时器来触发的,依次Linux系统下定时器也叫做 软件中断。因此在内核中CPU就有可能处于三种上下文,它使用了一个per-pcu的变量preempt_count来区分这些上下文,同时管理中断嵌套、抢占行为。

  • 硬中断上下文:CPU在执行中断上半部时处于硬中断上下文,在较新的内核中断设计中,硬中断上下文会禁用中断,因此不会产生中断嵌套,只有等待当前中断上半部执行结束后才能响应下一个中断。

  • 软中断上下文:CPU在执行中断下半部时处于软中断上下文,在软中断上下文中可以被中断抢占,但是在CPU上软中断不允许嵌套,如果在执行中断下半部时发生中断,在处理完新的中断上半部之后不会进入新的软中断上下文。

  • 进程上下文:CPU在执行进程代码时处于进程上下文

3.2.5.8 下半部实现机制

在 Linux 内核中,中断下半部是指在中断处理程序(ISR)执行完后,将较长时间的处理或不紧急的任务推迟到其他地方执行的机制。内核通过不同的机制来实现中断下半部的任务处理,这些机制包括 定时器 (Timers)软中断 (Softirqs)任务队列(Tasklets)工作队列 (Workqueues)、以及 内核线程 (Kernel Threads) 等。

下面是对这些机制的详细介绍,及其特点、使用场景。

1. 定时器 (Timer)

在 Linux 内核中,定时器(Timer)是一种机制,用于在指定时间后或周期性地触发回调函数。定时器的回调函数通常是一些轻量级的任务,用于处理超时、周期性任务或延迟执行的操作。从软、硬件上通常它分为:

  • 硬定时器:通常由硬件提供,定时器回调函数可以直接在硬件中断上下文中调用。

  • 软定时器:由内核软件实现,通常会在软中断上下文中触发。

Linux 内核定时器是通过基于系统时钟的中断来管理的。当定时器超时时,内核会在下一个合适的时刻调用相应的回调函数。定时器的精度由系统时钟决定,通常是根据 jiffies(内核计时单位)来调度定时器。根据它的使用类型又分为:

  • 普通定时器:普通定时器是通过调用 init_timer()timer_setup() 初始化的,它用于单次超时操作,是在指定时间间隔后触发一次回调函数。

  • 延迟定时器:延迟定时器通常是通过 mod_timer()add_timer() 调整定时器的触发时间,可以动态改变定时器的时间间隔。

  • 周期定时器: 周期定时器是通过 delayed_work()mod_timer() 创建的,可以在固定的时间间隔重复触发回调。

创建和使用内核定时器的典型步骤如下:

  1. 初始化定时器 定时器通常是通过 timer_setup() 来初始化,并指定回调函数。

  2. 设置定时器 通过 mod_timer()add_timer() 启动定时器,并设置定时器的超时值(到期时间)。

  3. 删除定时器 当不再需要定时器时,使用 del_timer()del_timer_sync() 来删除定时器。

Timer的相关函数

  1. timer_setup() 用于初始化定时器并设置定时器的回调函数。

void timer_setup(struct timer_list *timer, void (*callback)(struct timer_list *), unsigned long flags);

参数:

  • timer:定时器结构体对象。

  • callback:定时器触发时调用的回调函数。

  • flags:标志,用于配置定时器的行为。

  1. setup_timer() 用于初始化定时器并指定回调函数,通常用于创建普通定时器。

void setup_timer(struct timer_list *timer, void (*function)(unsigned long), unsigned long data);

参数:

  • timer:定时器结构体对象。

  • function:定时器触发时调用的回调函数。

  • data:传递给回调函数的数据。

  1. add_timer() 将其添加到内核定时器管理队列中,从而启动定时器。

int add_timer(struct timer_list *timer);

参数:

  • timer:要启动的定时器结构体对象。

  1. mod_timer() 用于修改定时器的触发时间,可以改变定时器的到期时间。

int mod_timer(struct timer_list *timer, unsigned long expires);

参数:

  • timer:要修改的定时器结构体对象。

  • expires:定时器到期的时间戳,通常使用 jiffies 来设置。

  1. del_timer() 删除定时器,取消定时器的计时,并停止触发回调函数。

int del_timer(struct timer_list *timer);

参数:

  • timer:要修改的定时器结构体对象。

  1. del_timer_sync() 同步删除定时器,确保定时器回调函数执行完毕后再删除定时器。

int del_timer_sync(struct timer_list *timer);

参数:

  • timer:要修改的定时器结构体对象。

示例代码:

#include <linux/timer.h>
#include <linux/jiffies.h>

static struct timer_list my_timer;

void my_timer_callback(struct timer_list *t)
{
    pr_info("Timer expired\n");
}

static int __init my_init(void)
{
    timer_setup(&my_timer, my_timer_callback, 0);
    mod_timer(&my_timer, jiffies + msecs_to_jiffies(1000));
    return 0;
}

module_init(my_init);
MODULE_LICENSE("GPL");

2. 软中断 (Softirq)

SoftIRQ(Software Interrupts) 是 Linux 内核中的一种机制,用于处理网络、定时器等需要高效且及时响应的操作。它与硬中断(硬件中断)不同,软中断是在内核的上下文中延迟执行的中断处理方式。软中断机制被设计成可以将一些繁重的任务从中断上下文中分离出来,进而提高系统的整体响应性和效率。

软中断的工作原理如下:

  1. 中断上下文

    • 硬中断发生时,硬件会向 CPU 发送一个中断信号,打断当前正在执行的任务,转而执行中断处理程序。

    • 硬中断执行期间,CPU 的上下文切换是比较昂贵的,且不可中断,因此通常会将一些较为复杂的操作延迟到软中断来处理。

  2. 软中断的触发

    • 在处理硬中断时,内核会将一些任务(如网络包的接收处理、定时器事件、block I/O 的后处理等)加入软中断队列。软中断的处理一般由内核中的 softirq 线程完成。

    • SoftIRQ 的任务会在后续的时刻由内核调度执行,通常是在中断上下文之后。

  3. SoftIRQ 的执行

    • SoftIRQ 被分为多个不同的类型,内核会根据不同的优先级或需要,逐个执行这些软中断任务。

    • 软中断的执行通常是在中断被禁用的情况下进行的,因此需要保证执行的高效性和快速性。

SoftIRQ 任务的执行是由内核中的 softirq 调度程序控制的,在某些系统中,ksoftirqd 线程会被用于处理软中断队列中的任务。此线程会在没有中断上下文的情况下运行,处理那些没有立即执行的软中断。内核中定义了多种软中断类型,每种类型对应不同的任务,常见的包括:

  1. 网络相关软中断(NET_TX, NET_RX):处理网络数据包的发送与接收。

  2. 定时器软中断(TIMER):处理内核中的定时器超时事件。

  3. Block I/O(BLOCK):处理块设备的 I/O 完成任务。

  4. Scheduler 软中断(SCHEDULE):与进程调度有关,确保进程能够在合适的时机被唤醒。

  5. Tasklet:一种基于软中断机制的任务执行方式,允许在硬中断上下文中延迟执行某些操作。

由于软中断通常与硬中断相关联,因此其执行的效率至关重要。长时间执行软中断可能导致系统响应迟缓,甚至引起其他中断处理的延迟。因此,在设计涉及软中断的操作时,需要尽量将耗时的任务推迟执行,或者分散到不同的上下文中去,避免阻塞主线程或硬中断的执行。

SoftIRQ 的相关函数:

  1. open_softirq() 用来初始化并注册一个软中断类型,通常在内核模块或初始化过程中调用。它允许你定义一个软中断处理函数,并将其与软中断的编号绑定。

    void open_softirq(int nr, void (*action)(struct softirq_action *));
    

    参数:

    • nr:软中断的编号。

    • action:处理软中断的函数,该函数会在软中断触发时执行

  2. raise_softirq() 用来手动触发某种软中断类型的函数。它会将指定类型的软中断标记为待处理,等到中断上下文完成后,内核会在合适的时机执行对应的软中断任务。

    void raise_softirq(int nr);
    

    参数:

    • nr:软中断类型的编号。每种软中断类型对应一个数字,如网络接收(NET_RX)、网络发送(NET_TX)等。

  3. local_bh_disable()local_bh_enable() 这两个函数用来控制 软中断(软中断即 bh,即 bottom half),这在某些需要禁止软中断执行的情况下非常有用。

    void local_bh_disable(void);
    void local_bh_enable(void);
    

    参数:

    • local_bh_disable():禁用当前 CPU 上的所有软中断,防止软中断的执行。它通常用于临界区内,会将 softirq_pending 标志位设置为关闭,确保在执行某些关键代码时不被软中断打断。

    • local_bh_enable():恢复软中断的处理。如果在 local_bh_disable() 后没有启用软中断,调用此函数会使得软中断得以调度执行。

示例代码:

#include <linux/interrupt.h>

static void my_softirq_handler(struct softirq_action *action)
{
    pr_info("SoftIRQ handler processing\n");
}

static irqreturn_t my_interrupt_handler(int irq, void *dev_id)
{
    pr_info("Interrupt occurred\n");
    raise_softirq(MY_SOFTIRQ);
    return IRQ_HANDLED;
}

static int __init my_init(void)
{
    open_softirq(MY_SOFTIRQ, my_softirq_handler);
    return 0;
}

module_init(my_init);
MODULE_LICENSE("GPL");

3. 任务队列 (Tasklet)

Tasklet 是 Linux 内核中的一种延迟执行机制,属于软中断(SoftIRQ)的一个实现,使得某些操作能够在中断上下文中异步执行。与 SoftIRQ 不同,Tasklet 提供了较低级别的接口,并且可以由内核中的中断上下文调度。与 SoftIRQ 相比,Tasklet 更易于管理,通常用于处理那些不需要立即响应的、但是必须在中断上下文中处理的操作。

Tasklet 的特性:

  1. 不可重入性:Tasklet 不能在同一个 CPU 上并行执行。这意味着 Tasklet 的执行是不可重入的,在任务执行期间,该 Tasklet 不会再次被调度。内核会确保每次只运行一个 Tasklet。

  2. 任务执行的延迟:虽然 Tasklet 是在软中断上下文中执行的,但它本质上是延迟执行的。它的执行会推迟到下一个软中断窗口中。只有在当前软中断队列的任务执行完成后,才会执行 Tasklet。

  3. 分配和执行的效率:Tasklet 比起直接在中断上下文中执行长时间的任务要更加高效,因为它使得延迟执行的任务得以释放中断资源,降低了中断嵌套的复杂性。

  4. Tasklet 是基于软中断的:Tasklet 运行的上下文本质上是软中断上下文。这意味着它们执行时会禁用中断,因此执行时间要尽可能短,避免阻塞其他中断。

下面是 Tasklet 的工作流程:

  1. Tasklet 初始化:Tasklet 必须先调用 tasklet_init() 初始化,通过注册一个函数来处理 Tasklet 需要执行的任务。

  2. Tasklet 调度:调用 tasklet_schedule() 函数将 Tasklet 添加到软中断队列中。

  3. Tasklet 执行:一旦软中断被处理,Tasklet 中注册的函数会被执行。该函数通常用于处理不需要立即执行的任务,但必须尽快完成的操作(例如:网络包的接收后处理、设备驱动的后处理等)。

  4. Tasklet 结束:任务执行完成后,Tasklet 会被标记为已处理,内核会在下一个合适的时机重新调度该 Tasklet。

Tasklet的相关函数:

  1. tasklet_init() 用来初始化一个 Tasklet 结构。通过此函数,我们可以指定 Tasklet 在触发时执行的回调函数和传递给该回调函数的参数。

    void tasklet_init(struct tasklet_struct *t, void (*func)(unsigned long), unsigned long data);
    

    参数:

    • t:要初始化的 Tasklet 结构体。

    • func:Tasklet 执行时调用的回调函数。

    • data:传递给回调函数的参数。

  2. tasklet_schedule() 用来将一个初始化过的 Tasklet 调度到软中断队列中,等待执行。

    void tasklet_schedule(struct tasklet_struct *t);
    

    参数:

    • t:要调度的 Tasklet 结构体。

  3. tasklet_kill() 用来停止并销毁一个已调度的 Tasklet。它会阻止 Tasklet 的进一步调度,并等待其当前执行完成。

    void tasklet_kill(struct tasklet_struct *t);
    

    参数:

    • t:要停止的 Tasklet 结构体。

示例代码:

#include <linux/interrupt.h>

static struct tasklet_struct my_tasklet;

static void my_tasklet_handler(unsigned long data)
{
    pr_info("Tasklet processing\n");
}

static irqreturn_t my_interrupt_handler(int irq, void *dev_id)
{
    pr_info("Interrupt occurred\n");
    tasklet_schedule(&my_tasklet);
    return IRQ_HANDLED;
}

static int __init my_init(void)
{
    tasklet_init(&my_tasklet, my_tasklet_handler, 0);
    return 0;
}

module_init(my_init);
MODULE_LICENSE("GPL");

Tasklet 和 SoftIRQ 都是延迟任务处理机制,但它们有一些不同之处:

特性

SoftIRQ

Tasklet

上下文

中断上下文

中断上下文(软中断上下文)

调度机制

由内核软中断调度机制控制

由 Tasklet 调度接口控制

执行顺序

可以并行执行(在不同 CPU 上)

只能在一个 CPU 上串行执行

使用复杂度

相对复杂,需要显式管理每个软中断

相对简单,接口易用且易于管理

性能

更高效,但代码需要精心设计

适用于简单任务的处理

Tasklet 适用于那些:

  • 必须尽快执行,但不需要立即响应的任务。

  • 在中断处理后立即执行的任务。

  • 不需要像进程那样复杂调度的任务。

常见的使用场景包括:

  • 网络数据包的后处理(例如:协议栈的处理)。

  • 定时器事件的处理。

  • 中断服务程序(ISR)执行后的一些清理工作(如:驱动程序的异步操作)。

总结来说,Tasklet 提供了一种简洁且高效的机制来延迟执行任务,适用于需要中断上下文中执行的任务。它简化了内核中断处理流程,减少了中断处理时的阻塞,允许开发者以轻量级的方式执行一些不立即响应的操作。

4. 工作队列 (Workqueue)

在 Linux 内核中,Workqueue 是一个用于处理延迟任务的机制。它允许任务在稍后的时间以进程上下文执行,而不是在中断上下文或软中断上下文中立即执行。与 TaskletSoftIRQ 相比,Workqueue 提供了更灵活的任务调度方式,能够让任务在正常的内核线程上下文中执行,因此它允许在任务执行时进行较长时间的操作,且不会阻塞中断或软中断的处理。

  • Workqueue 是基于内核线程的:它提供了一种将任务推送到内核线程池中执行的机制。不同于 Tasklet 和 SoftIRQ,它允许任务在内核线程的上下文中执行,而不是中断上下文中,这意味着它可以执行较为复杂和耗时的操作。

  • 内核线程执行任务:当任务被调度到 Workqueue 时,它们会在内核线程上下文中执行,允许更长的执行时间和更复杂的操作,而不会影响中断处理。

  • 延迟执行:Workqueue 提供了一种延迟任务执行的机制,但延迟的任务是在合适的上下文中(进程上下文)执行,而不像 Tasklet 或 SoftIRQ 那样在中断上下文中执行。

Workqueue 的工作流程:

  1. 创建 Workqueue:通过 create_workqueue()alloc_workqueue() 函数创建一个 Workqueue,可以指定 Workqueue 处理任务的内核线程的属性。

  2. 初始化 Work_struct:每个任务都需要一个 work_struct 结构体,它包含了任务执行时需要的信息和回调函数。

  3. 调度任务:通过 queue_work()queue_delayed_work() 函数将任务添加到 Workqueue 中。任务会在合适的时机(由 Workqueue 线程调度)执行。

  4. 执行任务:内核会在 Workqueue 的工作线程中执行这些任务。如果有多个任务,Workqueue 会按顺序执行。

  5. 销毁 Workqueue:当任务完成后,如果不再需要 Workqueue,可以使用 destroy_workqueue() 销毁它。

Workqueue的相关函数:

  1. create_workqueue()alloc_workqueue() 这些函数用于创建一个 Workqueue。create_workqueue() 用于创建一个新的 Workqueue,而 alloc_workqueue() 允许传入一些额外的属性,如线程的优先级等。

    struct workqueue_struct *create_workqueue(const char *name);
    struct workqueue_struct *alloc_workqueue(const char *fmt, unsigned int flags, int max_active);
    

    参数:

    • name:Workqueue 的名字,用于标识该 Workqueue。

    • flags:Workqueue 的属性,可以是 WQ_UNBOUND(任务调度到任何 CPU)或者 WQ_HIGHPRI(优先级高的任务)。

    • max_active:最大并行执行任务数。

  2. INIT_WORK()INIT_DELAYED_WORK() 用于初始化任务结构体 work_structINIT_WORK() 用于普通任务,而 INIT_DELAYED_WORK() 用于延迟任务。

    void INIT_WORK(struct work_struct *work, void (*func)(struct work_struct *));
    void INIT_DELAYED_WORK(struct delayed_work *dwork, void (*func)(struct work_struct *));
    

    参数:

    • work:待初始化的 work_struct 结构体。

    • func:任务执行时调用的回调函数

  3. queue_work()queue_delayed_work() 这些函数用于将任务调度到 Workqueue 中执行。

    int queue_work(struct workqueue_struct *wq, struct work_struct *work);
    int queue_delayed_work(struct workqueue_struct *wq, struct delayed_work *dwork, unsigned long delay);
    

    参数:

    • wq:目标 Workqueue。

    • work:要调度的任务结构体。

    • delay:延迟执行的时间,单位为 jiffies(内核时间单位)。

  4. flush_workqueue() 此函数会等待指定的 Workqueue 上所有任务完成并退出,常用于清理资源时确保所有任务都已完成。

    void flush_workqueue(struct workqueue_struct *wq);
    

    参数:

    • wq:目标 Workqueue。

  5. destroy_workqueue() 销毁一个 Workqueue,并确保所有任务都已完成。

    void destroy_workqueue(struct workqueue_struct *wq);
    

    参数:

    • wq:目标 Workqueue。

示例代码:

#include <linux/workqueue.h>

static struct workqueue_struct *my_wq;
static struct work_struct my_work;

void my_work_handler(struct work_struct *work)
{
    pr_info("Workqueue processing\n");
}

static irqreturn_t my_interrupt_handler(int irq, void *dev_id)
{
    pr_info("Interrupt occurred\n");
    schedule_work(&my_work);
    return IRQ_HANDLED;
}

static int __init my_init(void)
{
    my_wq = create_workqueue("my_workqueue");
    if (!my_wq)
        return -ENOMEM;

    INIT_WORK(&my_work, my_work_handler);
    return 0;
}

module_init(my_init);
MODULE_LICENSE("GPL");

Workqueue 与其他延迟任务机制的比较:

特性

Workqueue

Tasklet

SoftIRQ

上下文

在内核线程上下文中执行

在软中断上下文中执行

在软中断上下文中执行

执行时长

可以执行较长时间的任务,不受中断时间限制

执行时间受限,必须迅速完成

执行时间受限,必须迅速完成

复杂度

适用于复杂、耗时的任务

适用于简单的延迟任务

适用于简单的延迟任务

并发执行

可以并发执行多个任务

任务按顺序执行,不支持并发

任务按顺序执行,不支持并发

任务调度

使用内核线程池来调度任务,灵活性更高

任务通过软中断队列调度,灵活性较低

任务通过软中断队列调度,灵活性较低

适用场景

需要较长时间执行的异步任务

短时间的异步任务

短时间的异步任务

它更适合于:

  • 耗时的任务:Workqueue 适合那些需要较长时间运行、并且不适合在中断上下文中运行的任务。例如,网络驱动程序的接收数据后处理、设备驱动程序的延迟工作等。

  • 异步处理:对于需要在中断处理后进行的任务,Workqueue 可以将任务推迟到内核线程中执行,避免长时间占用中断上下文,保持系统的高响应性。

  • 延迟任务:Workqueue 还可以通过 queue_delayed_work() 延迟执行任务,允许你设置一个延迟时间,让任务在指定的时间后执行。

5. 内核线程 (Kthread)

内核线程(Kernel Threads)是运行在操作系统内核空间中的线程,它们通常不与用户交互,而是执行系统层面的任务,这与用户空间的线程有显著的不同。内核线程是由操作系统内核创建和调度的,通常用于处理那些需要在内核空间中执行的任务,如设备驱动、文件系统管理、网络处理等内核操作。

内核线程的基本概念:

  • 内核线程与进程的区别:内核线程与普通进程的最大区别在于,内核线程不具有用户空间。它们仅在内核空间执行,因此不能直接访问用户空间的资源和数据。内核线程通常只执行系统层面的操作,而不涉及与用户交互的任务。

  • 内核线程的创建:内核线程是由内核内部的机制或内核模块创建的,它通常由系统初始化或设备驱动程序创建,用于处理一些需要在内核空间执行的工作。

  • 内核线程的调度:内核线程由内核调度器管理,内核会定期调度内核线程的执行。内核线程与用户线程共享 CPU 资源,但是由于它们处于内核模式,可以直接访问硬件资源。

  • 内核线程没有用户空间映射:与普通进程相比,内核线程没有自己的用户空间,因此没有用户态的地址空间。这意味着它们的堆栈和内存都位于内核空间。内核线程通常不需要用户交互界面,而是执行内核级任务。

内核线程的调度与执行:

  • 调度:内核线程由 Linux 内核的调度器调度执行,它们与用户进程一样具有时间片,并可以抢占。内核线程通常会被内核调度器在内核空间中调度执行。

  • 内核线程的优先级:内核线程与普通进程一样可以设置优先级。默认情况下,内核线程的优先级较低,但可以根据需要设置为更高的优先级。

  • 内核线程的阻塞与非阻塞:内核线程可以根据需要进行阻塞或非阻塞操作。内核线程可以调用 schedule()msleep() 等函数来挂起自己,等待某个事件或条件的发生。

  • 同步与互斥:内核线程之间的同步与互斥通常依赖于内核提供的各种同步原语,如信号量(semaphore)、自旋锁(spinlock)、互斥锁(mutex)等,以避免竞态条件和不一致性。

内核线程的工作流程:

  • 创建:内核线程通过 kthread_create() 创建,并通过 wake_up_process() 启动。

  • 执行:内核线程会在内核上下文中执行其任务。它们可以执行阻塞操作,如等待 I/O 或睡眠。

  • 停止:内核线程可以通过 kthread_stop() 或在任务完成后自行退出。通常通过 kthread_should_stop() 来检测是否需要停止。

内核线程的相关函数:

  1. kthread_create() 用于创建一个新的内核线程。它返回一个 task_struct 指针,表示这个线程。如果创建失败,返回一个错误指针。

    struct task_struct *kthread_create(int (*threadfn)(void *data), void *data, const char *namefmt, ...);
    

    参数:

    • threadfn:内核线程的回调函数。

    • data:传递给线程回调函数的数据,可以是指向任何数据的指针。

    • namefmt:线程的名字(格式化字符串)。

  2. wake_up_process() 用于启动一个已经创建的内核线程。内核线程在调用 kthread_create() 后不会立即开始执行,需要显式调用此函数来启动。

    void wake_up_process(struct task_struct *p);
    

    参数:

    • p:指向 task_struct 的指针,该指针表示内核线程。

  3. kthread_run() 是一个 Linux 内核中的辅助函数,用于创建并启动内核线程。它实际上是 kthread_create()wake_up_process() 的组合,简化了内核线程的创建和启动过程。当你调用 kthread_run() 时,它不仅创建一个内核线程,还会立即启动该线程。

    struct task_struct *kthread_run(int (*threadfn)(void *data), void *data, const char *namefmt, ...);
    

    参数:

    • threadfn:这是线程的回调函数(线程的执行逻辑)。该函数的参数是一个指向 void 的指针,可以传递任何类型的数据。

  • data:传递给线程函数 threadfn 的数据,可以是任何类型的数据,通常是指向结构体或其他数据的指针。

    • namefmt:线程名称的格式化字符串,内核线程会使用这个名称,方便在调试或查看进程信息时识别。

  1. kthread_should_stop() 用于检查内核线程是否应停止。内核线程在循环中检查这个函数的返回值,以确定是否应该终止线程的执行。

    bool kthread_should_stop(void);
    

    返回值:如果线程应该停止,则返回 true,否则返回 false

  2. kthread_stop() 用于标记内核线程需要停止。当线程调用 kthread_should_stop() 时,它会检测到停止信号,进而终止执行。

    void kthread_stop(struct task_struct *p);
    

    参数:

    • p:指向 task_struct 的指针,该指针表示内核线程。

示例代码:

#include <linux/kthread.h>
#include <linux/delay.h>

static struct task_struct *my_thread;

int my_kernel_thread(void *data)
{
    while (!kthread_should_stop()) {
        pr_info("Kernel thread running\n");
        msleep(1000);
    }
    return 0;
}

static int __init my_init(void)
{
    my_thread = kthread_run(my_kernel_thread, NULL, "my_kernel_thread");
    if (IS_ERR(my_thread)) {
        pr_err("Failed to create kernel thread\n");
        return PTR_ERR(my_thread);
    }
    return 0;
}

static void __exit my_exit(void)
{
    kthread_stop(my_thread);
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");

内核线程与用户线程的区别:

特性

内核线程

用户线程

执行上下文

在内核空间中执行,直接访问内核资源

在用户空间中执行,依赖于系统调用与内核交互

调度

由内核调度器调度,优先级通常较低

由进程调度器调度,通常在用户空间中调度

堆栈

内核线程的堆栈在内核空间

用户线程的堆栈在用户空间

访问权限

可以访问内核空间中的资源

只能访问用户空间的资源,无法直接访问内核

创建方式

通过内核 API(如 kthread_create())创建

通过用户空间的库(如 pthread_create())创建

调试与监控

难以调试,需要内核调试工具

可以通过普通的调试工具(如 gdb)调试

内核线程的常见用途:

  1. 设备驱动:很多设备驱动程序使用内核线程来处理设备的异步操作,例如:硬件中断处理后的一些清理工作、网络数据包的处理等。

  2. 后台任务:内核线程通常用于处理一些后台任务,例如,文件系统的垃圾回收、定时器事件的处理、网络接口的管理等。

  3. 网络协议栈:内核线程经常被用于实现网络协议栈的不同部分,处理来自网络的数据包,并将其分发给不同的网络层次。

  4. 文件系统:内核线程可以用于执行文件系统中的后台任务,如日志回放、延迟回写操作等。

我们在 Linux 系统中,使用 ps 命令看到使用 [] 括起来的线程都是 Linux 内核线程。

root@igkboard:~# ps aux | grep "\[" | head -10
root         2  0.0  0.0      0     0 ?        S    Jan11   0:00 [kthreadd]
root         3  0.0  0.0      0     0 ?        I<   Jan11   0:00 [rcu_gp]
root         4  0.0  0.0      0     0 ?        I<   Jan11   0:00 [rcu_par_gp]
root         5  0.0  0.0      0     0 ?        I<   Jan11   0:00 [slub_flushwq]
root         6  0.0  0.0      0     0 ?        I<   Jan11   0:00 [netns]
root        10  0.0  0.0      0     0 ?        I<   Jan11   0:00 [mm_percpu_wq]
root        11  0.0  0.0      0     0 ?        I    Jan11   0:00 [rcu_tasks_kthread]
root        12  0.0  0.0      0     0 ?        S    Jan11   0:01 [ksoftirqd/0]
root        13  0.0  0.0      0     0 ?        I    Jan11   0:31 [rcu_preempt]
root        14  0.0  0.0      0     0 ?        S    Jan11   0:00 [migration/0]

3.2.6 编写 DS18B20 驱动

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

3.2.6.1 DS18b20传感器简介

DS18B20 (淘宝购买链接) 是由 Dallas 半导体公司推出的一种的一线总线(1-Wire)接口的数字温度传感器,它工作在 3v~5.5V的电压范围,其测量温度范围为-55~+125℃ ,精度为±0.5℃。与传统的热敏电阻等测温元件相比,它是一种新型的体积小、适用电压宽、接口简单的数字化温度传感器,并采用多种封装形式,从而使系统设计灵活、方便。

ds18b20

DS18B20根据不同的应用场合而改变其外观,封装后的DS18B20可用于电缆沟测温,高炉水循环测温,锅炉测温,机房测温,农业大棚测温,洁净室测温,弹药库测温等各种非极限温度场合。耐磨耐碰,体积小,使用方便,封装形式多样,适用于各种狭小空间设备数字测温和控制领域。

ds18b20_package

DS18B20温度传感器主要具有以下功能特性:

  • 它的工作电压范围为3.0v~5.0v(建议3.3v),另外也可以直接由数据线供电而不需要外部电源供电;

  • 采用一线协议(1-Wire),即仅使用一根数据线(以及地线)与微控制器(MCU)进行通信;

  • 它可以提供9-Bit到12-Bit的测量精度和一个用户可编程的非易失性且具有过温和低温触发报警的报警功能;

  • 该传感器的温度检测范围为-55℃至+125℃,并且在温度范围超过-10℃至85℃之外时还具有+-0.5℃的精度;

  • DS18B20温度转换时间在转换精度为12-Bits时达到最大值750ms;

  • DS18B20可以设置为9, 10, 11, 12位分辨率, 其温度分辨率分别对应0.5, 0.25, 0.125, 0.0625摄氏度,缺省分辨率是12位;

每个DS18B20芯片在出厂时,都固化烧录了一个唯一的64位产品序列号在其ROM中,它可以看作是该 DS18B20 的地址序列码。 64 位 ROM 的排列是:前 8 位是产品家族码,接着 48 位是DS18B20 的序列号,最后 8 位是前面 56 位的循环冗余校验码(CRC=X8+X5+X4+1)。 ROM 作用是使每一个 DS18B20 都各不相同,这样就可实现一根总线上挂接多个 DS18B20。

接下来,我们将深入学习并了解DS18B20温度传感器的工作原理,需要注意的是在学习的过程中,一定要对着芯片的datasheet来理解点此链接可以下载或在线查阅。

3.2.6.2 DS18B20一线协议

一线总线(1-Wire)结构具有简洁且经济的特点,可使用户轻松地组建传感器网络,从而为测量系统的构建引入全新概念。现场温度直接以“一线总线”的数字方式传输,大大提高了系统的抗干扰性。它能直接读出被测温度,并且可根据实际要求通过简单的编程实现 9~12 位的数字值读数方式。

所有的单总线器件要求采用严格的信号时序,以保证数据的完整性。 DS18B20 共有 6 种信号类型:复位脉冲、应答脉冲、写 0/ 1、读 0/1。所有这些信号,除了应答脉冲以外,都由主机发出同步信号,并且发送所有的命令和数据都是字节的低位在前(LSB)。

1)复位脉冲和应答脉冲

单总线上的所有通信都是以初始化序列开始。主机(CPU)输出低电平,并保持低电平时间至少 480us,以产生复位脉冲。接着主机释放总线, 4.7K 的上拉电阻将单总线拉高,延时 15~60 us,并进入接收模式(Rx)。DS18B20芯片在收到主机发送过来的这个复位脉冲后,将会拉低总线 60~240 us,以产生低电平应答脉冲,然后释放总线并维持至少480us。CPU在这段期间如果读到低电平,则说明探测到DS18B20芯片,否则DS18B20芯片损坏或芯片并没有连接。

w1_reset

2)写时序

写时序包括写 0 时序和写 1 时序。所有写时序至少需要 60us,且在 2 次独立的写时序之间至少需要 1us 的恢复时间,两种写时序均起始于主机拉低总线:

  • 写 0 时序:主机输出低电平,延时 60us,然后释放总线,延时 2us;

  • 写 1 时序:主机输出低电平,延时 2us,然后释放总线,延时 60us;

w1_write

3)读时序

单总线器件(DS18B20)仅在主机发出读时序时,才向主机传输数据,所以,在主机发出读数据命令后,必须马上产生读时序,以便从机能够传输数据。所有读时序至少需要 60us,且在 2 次独立的读时序之间至少需要 1us 的恢复时间。

当总线控制器把数据线从高电平拉到低电平时,读时序开始,数据线必须至少保持1us,然后总线被释放。DS18B20 通过拉高或拉低总线上来传输”1”或”0”。当传输逻辑”0”结束后,总线将被释放,通过上拉电阻回到上升沿状态,从DS18B20输出的数据在读时序的下降沿出现后15us 内有效。因此,总线控制器在读时序开始后必须停止把I/O口驱动为低电15us,以读取I/O口状态。

w1_read

3.2.6.3 DS18B20工作流程

DS18B20传感器的工作流程为:

  1. 总线初始化;

  2. ROM操作命令;

  3. 存储器操作命令;

  4. 处理数据;

1)总线初始化

单总线上的所有通信都是以初始化序列开始,Master发出初始化信号后等待从设备的应答信号,已确定从设备是否存在并能正常工作。

2)ROM操作命令

总线主机检测到DS18B20的存在后,便可以发出 ROM 操作命令之一,这些命令如下表所示。一般我们不关心ROM中的16位产品序列号,通常会发送0xCC跳过ROM的相关操作。

指令说明

十六进制代码

Read ROM(读 ROM)

[33H]

Match ROM(匹配 ROM)

[55H]

Skip ROM(跳过 ROM]

[CCH]

Search ROM(搜索 ROM)

[F0H]

Alarm search(告警搜索)

[ECH]

3)存储器操作命令

ROM命令操作完成之后,接下来可以发送相应的高速暂存存储器操作命令,这些命令如下表所示。其中0x44命令将通知DS18B20温度传感器开始采样, 而0xBE命令则将开始读出DS18B20的采样值。

指令说明

十六进制代码

Write Scratchpad(写暂存存储器)

[4EH]

Read Scratchpad(读暂存存储器)

[BEH]

Copy Scratchpad(复制暂存存储器)

[48H]

Convert Temperature(温度变换)

[44H]

Recall EPROM(重新调出)

[B8H]

Read Power supply(读电源)

[B4H]

3)数据处理

DS18B20的高速暂存存储器由9个字节组成。当温度转换命令(0x44)发布后,经转换所得的温度值以二字节补码形式存放在高速暂存存储器前两个字节。接着单片机可以发送读暂存存储器命令(0xBH)读出存储器里的值, 存储器里的9个字节的存储结构如下图所示:

memory_map

  • 字节0~1 是温度存储器,用来存储转换好的温度。第0个字节存储温度低8位,第一个字节存储温度高8位;

  • 字节2~3 是用户用来设置最高报警和最低报警值(TH和TL)。

  • 字节4 是配置寄存器,用来配置转换精度,可以设置为9~12 位。

  • 字节5~7 保留位。芯片内部使用

  • 字节8 CRC校验位。是64位ROM中的前56位编码的校验码,由CRC发生器产生。

如果我们只关心采样温度值的话,则只需要读前两个字节即可。其中Byte[0]为温度值的低字节,而Byte[1]为温度值的高字节。这16位数据的格式如下图所示:

reg_format

  • BIT[3:0]为温度值的小数部分;

  • BIT[10:4]为温度值的整数部分;

  • BIT[15:11]则为符号位,如果为0则温度为正值,如果为1则温度为负值。

3.2.6.4 模块硬件连接说明

DS18B20传感器的工作电压范围为3~5.5v,所以其电源连接3.3v和5v都可以(建议连接3.3V)。这样DS18B20在与开发板相连时,主要连接如下三个引脚:

  1. GND,该引脚要连到开发板的GND扩展引脚上;

  2. VDD, 该引脚要连到开发板的 3.3v 或 5v 供电引脚上;

  3. DQ, 是DS18B20的数据通信引脚,该引脚应该连开发板上具有 1-Wire 协议功能的GPIO引脚上;

在IGKBoard开发板上,提供了与树莓派兼容的 40Pin扩展引脚,其定义如下。其中物理引脚 #7 (GPIO1_IO18) 在Linux系统启动时如果启用了 w1 overlay 后,它将会默认作为 DS18B20 传感器的一线协议接口使用。这样,DS18B20的 DQ 引脚应该连接它上。

40pin_ds18b20

如下是 DS18B20 温度传感器连接到 IGKBoard上的实物示意图。

igkboard_ds18b20

3.2.6.5 Linux设备树修改

接下来,我们在 DTS 文件的根节点下添加 ds18b20 设备节点,并在 iomuxc 里添加该 GPIO 的 pinctrl 配置。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ vim igkboard-imx6ull.dts
... ...
/ {
    ... ...
    keys {
        ... ...
    };
... ...
    ds18b20 {
        compatible = "lingyun,ds18b20";
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_ds18b20>;
        status = "okay";

        gpios = <&gpio1 18 GPIO_ACTIVE_HIGH>;
    };

    pxp_v4l2 {
... ...
};
... ...

&iomuxc {
    ... ...
    pinctrl_ds18b20: ds18b20grp {
        fsl,pins = <
            MX6UL_PAD_UART1_CTS_B__GPIO1_IO18   0x110b0
        >;
    };
};

修改完成之后,重新编译生成 .dtb 文件。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ make
/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx/scripts/dtc/dtc -q -@ -I dts -O dtb .igkboard-imx6ull.dts.tmp -o igkboard-imx6ull.dtb

3.2.6.6 DS18B20驱动编写

接下来编写 DS18B20 的驱动代码如下:

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim ds18b20.c

/*********************************************************************************
 *      Copyright:  (C) 2025 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  ds18b20.c
 *    Description:  This file is DS18B20 temperature sensor driver
 *
 *        Version:  1.0.0(01/18/2025)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "01/18/2025 10:18:40 PM"
 *
 ********************************************************************************/

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/gpio.h>
#include <linux/device.h>
#include <linux/sysfs.h>
#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/delay.h>
#include <linux/mutex.h>

struct ds18b20_priv {
    struct class       *class;
    struct device      *dev;
    struct gpio_desc   *gpio;
    struct mutex        lock;
};

struct ds18b20_priv *priv;

/* DS18B20 GPIO operator macro, $p is struct gpio_desc *gpio, $v should be 0/1 */
#define W1DQ_Input(p)           gpiod_direction_input(p)     /* set as input and default high level */
#define W1DQ_Output(p)          gpiod_direction_output(p, 1) /* set as output and active high */
#define W1DQ_Read(p)            gpiod_get_value(p)
#define W1DQ_Write(p, v)        gpiod_set_value(p, v)

enum {
    LOW,
    HIGH,
};

/* master issues reset pulse and DS18B20 respond with presence pulse */
static int ds18b20_reset(struct gpio_desc *pin)
{
    int time = 0;
    int present = 0; /* default not present */

    /* Setup W1 DQ pin as output and high level */
    W1DQ_Output(pin);

    /* Reset pulse by pulling the DQ pin low >=480us */
    W1DQ_Write(pin, LOW);
    udelay(480);

    /* Master releases bus to high. When DS18B20 detects this rising edge, it waits 15µs to 60µs */
    W1DQ_Write(pin, HIGH);
    udelay(60);

    /* ds18b02 transmits a presence pulse by pulling the W1 bus low for 60µs to 240µs */
    W1DQ_Input(pin);
    while( W1DQ_Read(pin) && time<240) {
        time++;
        udelay(1);
    }

    /* ds18b20 present pulse detected in 240us */
    if( time < 240 )
        present = 1;

    /* Master Rx time must >= 480us */
    W1DQ_Output(pin);
    udelay(480-time);

    return present;
}

void ds18b20_write_byte(struct gpio_desc *pin, uint8_t byte)
{
    uint8_t            i = 0;

    W1DQ_Output(pin);

    for(i=0; i<8; i++) {
        /* Write 1: pull low <= 15us, Write 0: pull low 15~60us*/
        W1DQ_Write(pin, 0);
        udelay(10);

        /* DS18B20 bit sent by LSB (lower bit first) */
        if( byte & 0x1 )
            W1DQ_Write(pin, 1);
        else
            W1DQ_Write(pin, 0);

        /* Write 1/0 slot both >= 60us, hold the level for 60us */
        udelay(60);

        /* Release W1 bus to high */
        W1DQ_Write(pin, 1);
        udelay(2);

        /* Prepare for next bit */
        byte >>= 1;
    }
}

uint8_t ds18b20_read_byte(struct gpio_desc *pin)
{
    uint8_t            i = 0;
    uint8_t            byte = 0;

    for(i=0; i<8; i++) {
        /* Read time slot is initiated by master pulling the W1 bus
         * low for minimum of 1µs and then releasing the bus */
        W1DQ_Output(pin);
        W1DQ_Write(pin, 0);
        udelay(2);
        W1DQ_Write(pin, 1);
        udelay(2);

        /* After master initiates read time slot, DS18B20 will begin
         * transmitting a 1 or 0 on bus */
        W1DQ_Input(pin);

        /* DS18B20 bit sent by LSB (lower bit first) */
        if( W1DQ_Read(pin) ) {
            byte |= 1<<i;
        }

        /* Read slot for >= 60us */
        udelay(60);

        /* Release W1 bus to high */
        W1DQ_Output(pin);
        udelay(2);
    }

    return byte;
}

static inline int ds18b20_convert(struct gpio_desc *pin)
{
    /* Master issues reset pulse and DS18B20s respond with presence pulse */
    if( !ds18b20_reset(pin) )
        return -1;

    /* Master issues Skip ROM command */
    ds18b20_write_byte(pin, 0xCC);

    /* Master issues Convert T command. */
    ds18b20_write_byte(pin, 0x44);

    return 0;
}

/* Check 8bit CRC checksum for given data (Maxim/Dallas) */
static int ds18b20_checkcrc(uint8_t *data, uint8_t length, uint8_t checksum)
{
    uint8_t     i, j, byte;
    uint8_t     mix = 0;
    uint8_t     crc = 0;

    for ( i=0; i<length; i++ ) {
        byte = data[i];

        for( j=0; j<8; j++ ) {
            mix = ( crc ^ byte ) & 0x01;
            crc >>= 1;
            if ( mix )
                crc ^= 0x8C; //POLYNOMIAL;
            byte >>= 1;
        }
    }

    return crc==checksum ? 1 : 0;
}

static inline int ds18b20_read(struct gpio_desc *pin, uint8_t *out, int bytes)
{
    uint8_t     buf[9];
    uint8_t     i = 0;

    /* Master issues reset pulse and DS18B20s respond with presence pulse */
    if( !ds18b20_reset(pin) )
        return -1;

    /* Master issues Skip ROM command */
    ds18b20_write_byte(pin, 0xCC);

    /* Master issues Read Scratchpad command. */
    ds18b20_write_byte(pin, 0xBE);

    buf[i++] = ds18b20_read_byte(pin); /* Temperature LSB */
    buf[i++] = ds18b20_read_byte(pin); /* Temperature MSB */
    buf[i++] = ds18b20_read_byte(pin); /* Th Register or User Byte 1 */
    buf[i++] = ds18b20_read_byte(pin); /* Tl Register or User Byte 2 */
    buf[i++] = ds18b20_read_byte(pin); /* Configure Register */
    buf[i++] = ds18b20_read_byte(pin); /* Reserved(0xFF) */
    buf[i++] = ds18b20_read_byte(pin); /* Reserved */
    buf[i++] = ds18b20_read_byte(pin); /* Reserved(0x10) */
    buf[i++] = ds18b20_read_byte(pin); /* CRC */

    /* 9th byte is CRC checksum value */
    if( !ds18b20_checkcrc(buf, 8, buf[8]) ) {
        return -2;
    }

    /* output the 2 bytes temperature */
    out[0]=buf[0];
    out[1]=buf[1];

    return 0;
}

/* We can not do float calculate in linux kernel for temperature */
int ds18b20_sample(struct ds18b20_priv *priv, int *temperature)
{
    uint8_t               byte[2] = {0xFF, 0xFF};
    static uint8_t        firstin = 1;
    int16_t               temp;
    int                   ret;
    int                   sign;

    if( !temperature )
        return -1;

    mutex_lock(&priv->lock);

    /* send command to start convert */
    ret = ds18b20_convert(priv->gpio);
    if( ret < 0) {
        dev_err(priv->dev, "DS18B20 start convert failed, ret=%d\n", ret);
        mutex_unlock(&priv->lock);
        return -2;
    }

    /* First sample need 750ms(max) delay */
    if( firstin ) {
        msleep(750);
        firstin = 0;
    }
    else {
        msleep(100);
    }

    /* read out the sample raw data */
    ret = ds18b20_read(priv->gpio, byte, 2);
    if( ret < 0) {
        dev_err(priv->dev, "DS18B20 read convert data failed, ret=%d\n", ret);
        mutex_unlock(&priv->lock);
        return -3;
    }
    dev_dbg(priv->dev, "Convert raw data: 0x%02x 0x%02x\n", byte[1], byte[0]);

    mutex_unlock(&priv->lock);

    /* Temperature byte[0] is LSB, byte[1] is MSB, total 16 bit:
     * Byte[0]:  bit[3:0]: decimal bits,  bit[7:4]: integer bits
     * bYTE[1]:  bit[2:0]: integer bits,  bit[7:3]: sign bits
     */
    if( byte[1]> 0x7 ) { /* bit[7:3] is 1, negative temperature */
        /* Convert from 2's complement (complement and add 1) */
        temp = ~(byte[1]<<8|byte[0]) + 1;
        sign=0;
    }
    else {
        temp = byte[1]<<8 | byte[0];
        sign=1;
    }

   /* DS18b20 resolution default work in 12 bits, which should be 0.0625.
    * We multiply the decimal part by 625 (10000 / 16) to scale it here
    */
    *temperature = (temp>>4)*10000 + (temp&0xF)*625;
    if( !sign ) {
        *temperature = -*temperature;
    }

    return 0;
}

static ssize_t ds18b20_show(struct device *dev, struct device_attribute *attr, char *buf)
{
    struct ds18b20_priv *priv = dev_get_drvdata(dev);
    int temperature;

    if( ds18b20_sample(priv, &temperature) < 0 ) {
        dev_err(dev, "ds18b20 sensor maybe not present?\n");
        return 0;
    }

    return snprintf(buf, PAGE_SIZE, "%d.%d\n", temperature/10000, temperature%10000);
}

/* define `struct device_attribute dev_attr_temperature` */
static DEVICE_ATTR(temperature, 0444, ds18b20_show, NULL);

static int ds18b20_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    int ret;

    /* allocate memory for private data structure */
    priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
    if (!priv)
        return -ENOMEM;

    /* parser the gpio from the device tree */
    priv->dev = dev;
    priv->gpio = devm_gpiod_get(dev, NULL, GPIOD_OUT_HIGH);
    if (IS_ERR(priv->gpio)) {
        dev_err(dev, "Failed to read ds18b20 gpio from device tree\n");
        return -EINVAL;
    }

    /* create the ds18b20 class under /sys/class */
    priv->class = class_create(THIS_MODULE, "ds18b20");
    if (IS_ERR(priv->class)) {
        dev_err(dev, "Failed to create ds18b20 class\n");
        return PTR_ERR(priv->class);
    }

    /* create the ds18b02 device under /sys/class/ds18b20 class */
    priv->dev = device_create(priv->class, dev, 0, NULL, "ds18b20");
    if (IS_ERR(priv->dev)) {
        dev_err(dev, "Failed to create ds18b20 device\n");
        return PTR_ERR(priv->dev);
    }

    /* create the temperature file under /sys/class/ds18b20/ds18b20 */
    ret = device_create_file(priv->dev, &dev_attr_temperature);
    if (ret) {
        dev_err(dev, "Failed to create sysfs file\n");
        return ret;
    }

    mutex_init(&priv->lock);
    platform_set_drvdata(pdev, priv);
    dev_set_drvdata(priv->dev, priv);

    dev_info(dev, "ds18b20 driver probe successfully\n");
    return 0;
}

static int ds18b20_remove(struct platform_device *pdev)
{
    struct ds18b20_priv *priv = platform_get_drvdata(pdev);

    /* remove the sysfs file */
    device_remove_file(&pdev->dev, &dev_attr_temperature);

    /* destroy the device */
    device_destroy(priv->class, 0);

    /* destroy the class */
    class_destroy(priv->class);

    pr_info("ds18b20 driver removed\n");
    return 0;
}

static const struct of_device_id ds18b20_of_match[] = {
    { .compatible = "lingyun,ds18b20", },
    {},
};
MODULE_DEVICE_TABLE(of, ds18b20_of_match);

static struct platform_driver ds18b20_driver = {
    .probe = ds18b20_probe,
    .remove = ds18b20_remove,
    .driver = {
        .name = "ds18b20",
        .of_match_table = ds18b20_of_match,
    },
};

module_platform_driver(ds18b20_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");
MODULE_DESCRIPTION("DS18B20 Linux driver with sysfs interface");

大家务必要对着 DS18B20 传感器的芯片 datasheet ,来理解其一线通信(1-Wire)协议的时序逻辑。需要注意的是,Linux内核驱动并不支持浮点运算,所以我们在将采样值转换成实际温度时,是先将它们放大 10000 倍处理,再在 ds18b20_show() 函数中转换成小数形式的字符串并写入到 /sys/class/ds18b20/ds18b20/temperature 文件中。

接下来修改驱动 Makefile 文件,添加 ds18b20 驱动的编译支持并编译。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim Makefile
+obj-m += ds18b20.o

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ make
make ARCH=arm CROSS_COMPILE=/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf- -C ~/igkboard-imx6ull/bsp/kernel/linux-imx/ M=/home/guowenxue/drivers/imx6ull/driver modules
make[1]: Entering directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
  ... ...
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/ds18b20.mod.o
  LD [M]  /home/guowenxue/drivers/imx6ull/driver/ds18b20.ko
  ... ...
make[1]: Leaving directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
make[1]: Entering directory '/home/guowenxue/drivers/imx6ull/driver'
make[1]: Leaving directory '/home/guowenxue/drivers/imx6ull/driver'

3.2.6.7 DS18B20驱动测试

前面学习应用程序编程时,我们可能使能了 Linux 内核自带的 DS18B20 驱动。如果这样的话,我们可以修改启动配置文件 config.txt 来禁用它。

root@igkboard:~# mount /dev/mmcblk1p1 /media/
root@igkboard:~# vi /media/config.txt
# Eanble 1-Wire overlay
#dtoverlay_w1=yes

然后更新开发板上的Linux内核设备树文件。

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/dts/igkboard-imx6ull.dtb /media/igkboard-imx6ull.dtb
root@igkboard:~# sync && reboot

再将 DS18B20设备驱动文件拷贝到开发板上,并安装此驱动。

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/ds18b20.ko .

root@igkboard:~# insmod ds18b20.ko

root@igkboard:~# dmesg | tail -1
[78572.544254] ds18b20 ds18b20: ds18b20 driver probe successfully

此时在 /sys/class 路径下将会创建 ds18b20 类,而它下面又有 ds18b20 设备,在该设备下会有个 temperature 的文件。我们使用 cat 命令读取此文件内容时,将会触发驱动里的 ds18b20_show() 函数,从而驱动 DS18B20 开始采样获取当前温度值并将其写入到文件中。

root@igkboard:~# ls /sys/class/ds18b20/ds18b20
device  power  subsystem  temperature  uevent

root@igkboard:~# cat /sys/class/ds18b20/ds18b20/temperature
13.6250

3.2.7 编写 SHT20 驱动

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

3.2.7.1 SHT20传感器简介

SHT20 是瑞士sensirion公司的温湿度传感器中,应用最为广泛的型号,因传感器精度可以达到±3%rh、±0.3℃,同时传感器采用I2C接口数字输出。产品采用DNF封装,方便焊接,其使用寿命长达15年。在民用商用领域收到一致认可,并常年大量出货。

sht20_app

其技术参数如下:

  • 工作电压: 2.1~3.6VDC(请勿使用5V供电!!!)

  • 信号输出:I2C数字输出

  • 湿度测量范围: 0~100%RH 湿度测量精度:±3%

  • 温度测量范围: -40~125℃ 温度测量精度:±0.3℃

  • 能耗: 3.2uW (8位测量, 1次/秒)

瑞士sensirion公司的温湿度传感器共有四代:

  • 第一代: SHT1X 系列(SHT10,SHT11,SHT15,精度区别) 已经停产 (2.4-5.5V)

  • 第二代: SHT2X系列(SHT20,SHT21,SHT25,精度区别)(2.1-3.6V)

  • 第三代: SHT3X-DIS系列 (SHT30,SHT31,SHT35,精度区别) (2.15 V to 5.5 V)

  • 第四代: SHT4X-DIS系列 (SHT40,SHT41,SHT45,精度区别)(1.08-3.6V)

3.2.7.2 SHT20工作原理

这里我们以SHT20为例讲解SHT系列温湿度传感器的工作原理,其芯片 datasheet 可以 点击此链接下载。datasheet中的第1章节关于芯片的介绍及其特性,作为软件工程应该了解一下,而芯片datasheet的第 5~6 章节为软件工程师在编程时需要重点了解的部分,其他章节为硬件工程师在产品选型和硬件设计需要参考的。

sht20

在芯片datasheet的 5.3 章节,它介绍到了 SHT20 温湿度传感器的7-bit I2C从设备地址为二进制 ‘1000’000’,转换成十六进制其地址为 0x40. 另外,在该节中也提到了温湿度传感器的相关操作命令如下:

sht20_commands

在 5.4 章节中我们可以了解到,SHT20在采样时有两种工作模式:hold master模式no hold master 模式,具体采用哪种工作模式由MCU发过来的命令决定。其中 Holder Master 模式(温度命令为 0xE3, 相对湿度命令为0xE5) 将会使用 Clock Stretching 机制来与MCU通信,其工作时序如下:

sht20_hold_master

而使用 no hold master 模式(温度命令为 0xF3, 相对湿度命令为0xF5),其工作时序如下:

sht20_nohold_master

在 Table7 中给出了不同采样精度下,SHT20完成采样的典型时间值和最大时间值。

sht20_timeout

此外在该章节还描述了,无论是 Hold 还是 No Hold Master Mode 模式,如果采样精度为14位的话,SHT20发过来的2字节采样数据高14位(bit28~bit42)相应的温湿度值,而最低两个位(bit43、bit44)用来表示传输状态。其中bit44目前未用,而 bit43 如果为0表示温度值为1则表示相对湿度值

在芯片datasheet第6章,详细描述了如何将这14个位的数据(下图中的Srh 或 St)转换成相应的温、湿度值:

sht20_formula1

sht20_formula2

在芯片datasheet的 5.6 章节讲到如何配置SHT20温度传感器的精度:

sht20_reguser

在芯片datasheet 5.7节中,详细描述了2字节数据后面紧跟的1字节CRC校验和公式:

sht20_crc

在芯片datasheet的 5.5 章节提到,MCU给SHT20传感器发送 0xFE命令 可以完成芯片的软件复位。通常,我们在让SHT20工作前,会让它软件复位以下。

sht20_reset

下面是使用逻辑分析仪抓取的给 SHT20 温湿度传感器发送 no hold master 模式采样温度命令(0xF3)的时序图。

sht20_clock1

下面是从SHT20温度传感器读取温度值的时序图:

sht20_clock2

3.2.7.3 模块硬件连接说明

SHT20传感器的工作电压范围为2.1~3.6V,所以其电源需连接3.3v。这样SHT20在与开发板相连时,主要连接如下三个引脚:

  1. GND,该引脚要连到开发板的GND扩展引脚上;

  2. VDD, 该引脚要连到开发板的 3.3v ;

  3. SCL, 是I2C通信的时钟线,连接开发板上的I2C SCL引脚上;

  4. SDA, 是I2C通信的数据线,连接开发板上的I2C SDA引脚上;

在IGKBoard开发板上的 40Pin 扩展引脚上,提供了 I2C1 这路 I2C 接口,这样我们可以将 SHT20 模块连接到它上面。

40pin_hat

如下是 SHT20 温湿度传感器连接到 IGKBoard上的实物示意图。

igkboard_sht20

3.2.7.4 Linux设备树修改

接下来,我们在 DTS 文件的 &i2c2 前添加 &i2c1 节点配置,并在里面添加 sht20 设备节点,然后在 iomuxc 里添加 I2C1 的 pinctrl 配置。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ vim igkboard-imx6ull.dts
... ...
&i2c1 {
    clock-frequency = <100000>;
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_i2c1>;
    status = "okay";

    sht20@0x40 {
        compatible = "lingyun,sht20";
        reg = <0x40>;
        status = "okay";
    };
};

&i2c2 {
... ...

&iomuxc {
    ... ...
    pinctrl_i2c1: i2c1grp {
        fsl,pins = <
            MX6UL_PAD_GPIO1_IO02__I2C1_SCL      0x4001b8b0
            MX6UL_PAD_GPIO1_IO03__I2C1_SDA      0x4001b8b0
        >;
    };

    pinctrl_i2c2: i2c2grp {
    ... ...
};

修改完成之后,重新编译生成 .dtb 文件。

guowenxue@ubuntu20:~/drivers/imx6ull/driver/dts$ make
/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx/scripts/dtc/dtc -q -@ -I dts -O dtb .igkboard-imx6ull.dts.tmp -o igkboard-imx6ull.dtb

3.2.7.5 Linux I2C驱动框架

我们知道一个I2C总线上可以挂载多个I2C从设备,例如SHT20、I2C接口的OLED显示屏、摄像头(摄像头通过i2c接口发送控制信息)等等, 这些从设备共用同一个I2C总线,这个 I2C 总线的驱动我们称为I2C总线驱动。而对应具体的从设备,例如 SHT20 的驱动就是I2C从设备驱动。 这样我们要使用STH20就需要拥有“两个驱动”,一个是I2C总线驱动和SHT20从设备驱动。

  • I2C总线驱动由芯片厂商提供(驱动复杂,官方提供了经过测试的驱动,我们直接用),上面 DTS 文件中的 &i2c1 设备节点的驱动就是总线驱动。

  • SHT20从设备驱动可以从芯片厂家那里获得,也可以我们自己对着芯片datasheet来编写。

如下图所示,显示了Linux内核下的I2C总线驱动框架:

I2C总线包括I2C设备(i2c_client)和I2C驱动(i2c_driver),当我们向linux中注册设备或驱动的时候,按照 I2C总线匹配规则进行配对,配对成功,则可以通过i2c_driver中.prob函数创建具体的设备驱动。 在现代linux中,I2C设备不再需要手动创建,而是使用设备树机制引入,设备树节点是与paltform总线相配合使用的。 所以需先对 I2C 总线包装一层paltform总线,当设备树节点转换为平台总线设备时,我们在进一步将其转换为I2C设备,注册到I2C总线中。

设备驱动创建成功,我们还需要实现设备的文件操作接口(file_operations),file_operations中会使用到内核中i2c核心函数(i2c系统已经实现的函数,专门开放给驱动工程师使用)。 使用这些函数会涉及到i2c适配器,也就是i2c控制器。由于ic2控制器有不同的配置,所有linux将每一个i2c控制器抽象成i2c适配器对象。 这个对象中存在一个很重要的成员变量——Algorithm,Algorithm中存在一系列函数指针,这些函数指针指向真正硬件操作代码。

下面是 Linux 内核里 I2C 设备驱动的一些核心函数说明:

(1)i2c_register_driver()函数

i2c_add_driver()宏

注册一个i2c驱动 (linux-imx/include/linux/i2c.h)

/* use a define to avoid include chaining to get THIS_MODULE */
#define i2c_add_driver(driver) i2c_register_driver(THIS_MODULE, driver)

这个宏函数的本质是调用了i2c_register_driver()函数,函数如下。

注册一个i2c驱动 (linux-imx/drivers/i2c/i2c-core-base.c)

int i2c_register_driver(struct module *owner, struct i2c_driver *driver)

参数

  • owner: 一般为 THIS_MODULE

  • driver: 要注册的 i2c_driver.

返回值

  • 成功: 0

  • 失败: 负数

(2)i2c_del_driver()函数

将前面注册的 i2c_driver 从 Linux 内核中注销掉

  void i2c_del_driver(struct i2c_driver *driver)
  • driver:要注销的 i2c_driver

  • 返回值:无。

(3)i2c_transfer()函数

i2c_transfer()函数最终就是调用 i2c_imx_xfer() 函数来实现数据传输。

收发i2c消息

int i2c_transfer(struct i2c_adapter *adap, struct i2c_msg *msgs, int num)

参数

  • adap: struct i2c_adapter 结构体,收发消息所使用的i2c适配器,i2c_client 会保存其对应的 i2c_adapter

  • msgs: struct i2c_msg 结构体,i2c要发送的一个或多个消息

  • num: 消息数量,也就是msgs的数量

返回值

  • 成功: 发送的msgs的数量

  • 失败: 负数

I2C 进行数据收发说白了就是消息的传递,Linux 内核使用 i2c_msg 结构体来描述一个消息,使用 i2c_transfer()之前需要先构建好 i2c_msg 结构体。

    struct i2c_msg {

            __u16 addr; /* 从机地址 */
            __u16 flags; /* 标志 */
            #define I2C_M_TEN 0x0010
            #define I2C_M_RD 0x0001
            #define I2C_M_STOP 0x8000
            #define I2C_M_NOSTART 0x4000

            #define I2C_M_REV_DIR_ADDR 0x2000
            #define I2C_M_IGNORE_NAK 0x1000
            #define I2C_M_NO_RD_ACK 0x0800
            #define I2C_M_RECV_LEN 0x0400
            __u16 len; /* 消息(本 msg)长度 */

            __u8 *buf; /* 消息数据 */
    };

(4)用于数据传输的其他API

I2C 数据发送函数为 i2c_master_send,函数原型如下:

  int i2c_master_send(const struct i2c_client *client, const char *buf, int  count)
  • client:I2C 设备对应的 i2c_client。

  • buf:要发送的数据。

  • count:要发送的数据字节数,要小于 64KB,以为 i2c_msg 的 len 成员变量是一个 u16(无 符号 16 位)类型的数据。

  • 返回值:负值,失败,其他非负值,发送的字节数。

I2C 数据接收函数为 i2c_master_recv,函数原型如下:

int i2c_master_recv(const struct i2c_client *client, char *buf, int   count)
  • client:I2C 设备对应的 i2c_client。

  • buf:要接收的数据。

  • count:要接收的数据字节数,要小于 64KB,以为 i2c_msg 的 len 成员变量是一个 u16(无 符号 16 位)类型的数据。

  • 返回值:负值,失败,其他非负值,发送的字节数

Linux内核下的 I2C 驱动编程接口 API 和前面我们学习Linux下的I2C接口应用编程接口差不多,接下来我们就开始编写 SHT20 的驱动代码。

3.2.7.6 SHT20驱动编写

接下来编写 SHT20 的驱动代码如下:

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim sht20.c

/*********************************************************************************
 *      Copyright:  (C) 2025 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  sht20.c
 *    Description:  This file is SHT20 temperature & humidity sensor driver
 *
 *        Version:  1.0.0(01/18/2025)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "01/18/2025 10:18:40 PM"
 *
 ********************************************************************************/

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/gpio.h>
#include <linux/device.h>
#include <linux/sysfs.h>
#include <linux/platform_device.h>
#include <linux/version.h>
#include <linux/of.h>
#include <linux/delay.h>
#include <linux/i2c.h>

struct sht20_priv {
    struct class       *class;
    struct device      *dev;
    struct i2c_client  *client;
    struct mutex        lock;
};

struct sht20_priv *priv;

enum
{
    TRIG_T_MEASUREMENT_HM    = 0xE3, // command trig. temp meas. hold master
    TRIG_RH_MEASUREMENT_HM   = 0xE5, // command trig. humidity meas. hold master
    TRIG_T_MEASUREMENT_POLL  = 0xF3, // command trig. temp meas. no hold master
    TRIG_RH_MEASUREMENT_POLL = 0xF5, // command trig. humidity meas. no hold master
    USER_REG_W               = 0xE6, // command writing user register
    USER_REG_R               = 0xE7, // command reading user register
    SOFT_RESET               = 0xFE  // command soft reset
};

int sht20_send_cmd(struct i2c_client *client, uint8_t cmd)
{
    struct i2c_msg  msg;
    int             ret;

    msg.addr = client->addr; /* SHT20 slave address */
    msg.flags = 0;  /* send flag */
    msg.buf = &cmd; /* send data  */
    msg.len = 1;    /* data length  */

    ret = i2c_transfer(client->adapter, &msg, 1);
    if( ret < 0 ) {
        dev_err(&client->dev, "SHT20 send command 0x%0x failed, ret=%d\r\n", cmd, ret);
    }

    return ret;
}

int sht20_read_dat(struct i2c_client *client, uint8_t *data, int bytes)
{
    struct i2c_msg  msg;
    int             ret;

    msg.addr = client->addr; /* SHT20 slave address */
    msg.flags = I2C_M_RD;    /* read flag */
    msg.buf = data;     /* read data buffer */
    msg.len = bytes;    /* read data length  */

    ret = i2c_transfer(client->adapter, &msg, 1);
    if( ret < 0 ) {
        dev_err(&client->dev, "SHT20 read data failed, ret=%d\r\n", ret);
    }

    return ret;
}

static int sht20_checkcrc(uint8_t *data, uint8_t bytes, uint8_t checksum)
{
    uint8_t         crc = 0;
    uint8_t         i;
    uint8_t         bit;

    /* calculates 8-Bit checksum with given polynomial */
    for (i=0; i<bytes; ++i) {
        crc ^= (data[i]);
        for (bit=8; bit>0; --bit) {
            if (crc & 0x80)
                crc = (crc << 1) ^ 0x0131; //POLYNOMIAL;
            else
                crc = (crc << 1);
        }
    }

    return crc==checksum ? 1 : 0;
}

static int sht20_sample(struct i2c_client *client, int *temperature, int *humidity)
{
    uint8_t         buf[3];    /* I2C transfer buffer */
    int             ret;
    int             rawdata;

    /* send soft reset command */
    ret = sht20_send_cmd(client, SOFT_RESET);
    if( ret < 0)
        return ret;

    msleep(15); /* datasheet: 15ms */

    /* send trigger temperature measurement command, no hold master mode */
    ret = sht20_send_cmd(client, TRIG_T_MEASUREMENT_POLL);
    if( ret < 0)
        return ret;

    msleep(85); /* datasheet: typ=66, max=85 */

    /* read out 3 bytes measurement result value */
    ret = sht20_read_dat(client, buf, 3);
    if( ret < 0)
        return ret;

    /* CRC checksum */
    if( !sht20_checkcrc(buf, 2, buf[2]) ) {
        dev_err(&client->dev, "SHT20 temperature value CRC checksum failed.\n");
        return -2;
    }

    /* temperature measurement resolution default is 14bits */
    rawdata =  buf[0]<<8|(buf[1]&0xFC); //14bits(1111 1100)

    /* We multiply the decimal part by 100 to scale it here for temperature */
    *temperature = ((17572*rawdata)>>16) - 4685;


    /* send trigger relative humidity measurement command, no hold master mode */
    ret = sht20_send_cmd(client, TRIG_RH_MEASUREMENT_POLL);
    if( ret < 0)
        return ret;

    msleep(29); /* datasheet: typ=22, max=29 */

    /* read out 3 bytes measurement result value */
    ret = sht20_read_dat(client, buf, 3);
    if( ret < 0)
        return ret;

    /* CRC checksum */
    if( !sht20_checkcrc(buf, 2, buf[2]) ) {
        dev_err(&client->dev, "SHT20 temperature value CRC checksum failed.\n");
        return -2;
    }

    /* humidity measurement resolution default is 12bits */
    rawdata = buf[0]<<8|(buf[1]&0xF0); //12bits(1111 0000)

    /* no decimal part for relative humidity */
    *humidity = ((125*rawdata)>>16) - 6;

    return 0;
}

static ssize_t sht20_show(struct device *dev, struct device_attribute *attr, char *buf)
{
    struct sht20_priv *priv = dev_get_drvdata(dev);
    int temperature, humidity;

    mutex_lock(&priv->lock);

    if( sht20_sample(priv->client, &temperature, &humidity) < 0 ) {
        dev_err(dev, "sht20 sensor maybe not present?\n");
        mutex_unlock(&priv->lock);
        return 0;
    }

    mutex_unlock(&priv->lock);

    return snprintf(buf, PAGE_SIZE, "temperature: %d.%d\nhumidity: %d%%\n",
            temperature/100, temperature%100, humidity);
}

/* define `struct device_attribute dev_attr_trh` */
static DEVICE_ATTR(trh, 0444, sht20_show, NULL);

#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 5, 0)
static int sht20_probe(struct i2c_client *client)
#else
static int sht20_probe(struct i2c_client *client, const struct i2c_device_id *id)
#endif
{
    struct device *dev = &client->dev;
    int ret;

    /* allocate memory for private data structure */
    priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
    if (!priv)
        return -ENOMEM;

    /* create the sht20 class under /sys/class */
    priv->class = class_create(THIS_MODULE, "sht20");
    if (IS_ERR(priv->class)) {
        dev_err(dev, "Failed to create sht20 class\n");
        return PTR_ERR(priv->class);
    }

    /* create the sht20 device under /sys/class/sht20 class */
    priv->dev = device_create(priv->class, dev, 0, NULL, "sht20");
    if (IS_ERR(priv->dev)) {
        dev_err(dev, "Failed to create sht20 device\n");
        return PTR_ERR(priv->dev);
    }

    /* create the temperature file under /sys/class/sht20/sht20 */
    ret = device_create_file(priv->dev, &dev_attr_trh);
    if (ret) {
        dev_err(dev, "Failed to create sysfs file\n");
        return ret;
    }

    mutex_init(&priv->lock);
    priv->client = client;
    dev_set_drvdata(priv->dev, priv);
    i2c_set_clientdata(client, priv);

    dev_info(dev, "sht20 driver probe successfully\n");
    return 0;
}

static void sht20_remove(struct i2c_client *client)
{
    struct sht20_priv *priv = i2c_get_clientdata(client);

    /* remove the sysfs file */
    device_remove_file(priv->dev, &dev_attr_trh);

    /* destroy the device */
    device_destroy(priv->class, 0);

    /* destroy the class */
    class_destroy(priv->class);

    pr_info("sht20 driver removed\n");
    return ;
}


static const struct i2c_device_id sht20_ids[] = {
    { "sht20", 0 }, /* match device name "sht20" in device tree  */
    { /* sentinel */ },
};
MODULE_DEVICE_TABLE(i2c, sht20_ids);

static const struct of_device_id sht20_of_match[] = {
    { .compatible = "lingyun,sht20", }, /* match compatible "lingyun,sht20" in device tree */
    { /* sentinel */ },
};
MODULE_DEVICE_TABLE(of, sht20_of_match);


/* both .id_table and .of_match_table can match the driver */
static struct i2c_driver sht20_driver = {
    .probe = sht20_probe,
    .remove = sht20_remove,
    .id_table = sht20_ids,
    .driver = {
        .name = "sht20",
        .of_match_table = sht20_of_match,
    },
};

module_i2c_driver(sht20_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("GuoWenxue <guowenxue@gmail.com>");
MODULE_DESCRIPTION("SHT20 Linux driver with sysfs interface");

关于驱动里的SHT20采样逻辑实现,请务必参考 SHT20 传感器的芯片 datasheet 来理解。在上面的驱动代码中,我们的设备是挂在 I2C 总线上,所以驱动在注册是通过 module_i2c_driver() 来声明,而不是此前的 module_platform_driver()来声明。此时,它在匹配设备树中的设备时有两种方法来匹配:

  1. 通过 sht20_of_match 里的 .compatible = "lingyun,sht20" 字符串来匹配设备树里的 compatible = "lingyun,sht20"

  2. 通过 sht20_ids 里的 "sht20" 驱动名与设备树里的设备名 sht20@0x40 来匹配;

接下来修改驱动 Makefile 文件,添加 sht20 驱动的编译支持并编译。

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ vim Makefile
+obj-m += sht20.o

guowenxue@ubuntu20:~/drivers/imx6ull/driver$ make
make ARCH=arm CROSS_COMPILE=/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf- -C ~/igkboard-imx6ull/bsp/kernel/linux-imx/ M=/home/guowenxue/drivers/imx6ull/driver modules
make[1]: Entering directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
  ... ...
  CC [M]  /home/guowenxue/drivers/imx6ull/driver/sht20.mod.o
  LD [M]  /home/guowenxue/drivers/imx6ull/driver/sht20.ko
  ... ...
make[1]: Leaving directory '/home/guowenxue/igkboard-imx6ull/bsp/kernel/linux-imx'
make[1]: Entering directory '/home/guowenxue/drivers/imx6ull/driver'
make[1]: Leaving directory '/home/guowenxue/drivers/imx6ull/driver'

3.2.7.7 SHT20驱动测试

更新开发板上的Linux内核设备树文件。

root@igkboard:~# mount /dev/mmcblk1p1 /media/
root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/dts/igkboard-imx6ull.dtb /media/igkboard-imx6ull.dtb
root@igkboard:~# sync && reboot

再将 sht20 设备驱动文件拷贝到开发板上,并安装此驱动。

root@igkboard:~# scp -P 2200 guowenxue@192.168.0.2:~/drivers/imx6ull/driver/sht20.ko .

root@igkboard:~# insmod sht20.ko

root@igkboard:~# dmesg | tail -1
[52293.302443] sht20 0-0040: sht20 driver probe successfully

此时在 /sys/class 路径下将会创建 sht20 类,而它下面又有 sht20 设备,在该设备下会有个 trh 的文件。我们使用 cat 命令读取此文件内容时,将会触发驱动里的 sht20_show() 函数,从而驱动 SHT20 开始采样获取当前温度值并将其写入到文件中。

root@igkboard:~# ls /sys/class/sht20/sht20
device  power  subsystem  trh  uevent

root@igkboard:~# cat /sys/class/sht20/sht20/trh
temperature: 13.47
humidity: 43%