树莓派学习笔记(六):Linux高阶课程之驱动开发学习笔记

Linux高阶课程之驱动开发学习笔记

一、Ubuntu系统环境与基本开发工具安装

这部分课程中是在windows环境下安装VMware虚拟机(vmware15.5+Ubuntu18.04),由于我已经有一台云主机能提供Linux环境,所以就没有安装。这部分主要的准备工作包括:

  • 虚拟机系统安装,虚拟机网络配置(桥接模式,但在不知为何我的电脑上只有NAT模式才能上网)
  • 基本编译工具:sudo apt install build-essential以及网络工具sudo apt install net-tools
  • vim编辑器
  • 虚拟机与主机之间的共享文件夹设置
内核开发环境准备
  • 首先是下载树莓派linux内核源码:
    • 使用uname -r检查自己树莓派系统的版本
    • 进入https://github.com/raspberrypi/linux/tree/rpi-x.xx.y下载zip文件,x.xx为上面查到的结果
    • zip解压,设置环境变量:添加
  • 然后是下载安装交叉编译工具,同上节,以任意路径arm-linux-gnueabihf-gcc --version输出4.8.3为成功标志

二、预备知识——嵌入式设备(内核)启动过程

  • 最常用的x86架构,intel芯片,运行windows系统的话,设备启动过程为电源->BIOS->windows内核->C、D、E等各盘区->设置了自启动的各软件

  • 以树莓派、香橙派、nano pi、rock pi等为代表的Linux系统嵌入式产品,启动顺序为:电源->Bootloader->Linux内核->文件系统

    与windows不同,linux的文件系统没有分盘的概念,而是根据不同功能和性质放置在不同分区下:

    /boot中的文件和脚本定义了系统启动时的动作,/dev存放设备文件,/lib存放各种库、/proc存放内核的各种数据信息、/home为用户工作区、/sbin/bin用于存放一些可执行文件、系统命令等

    当文件系统启动完成,各种应用软件才能启动。若文件系统启动失败,显示在设备上的将是操作系统提供的界面。

  • 安卓系统基于Linux内核开发,因此启动过程与Linux类似:

    电源->fastBoot/Bootloader->Linux内核->文件系统->启动一个虚拟机->运行HOME应用程序(显示桌面)—>当用户点击APP图标,在虚拟机中运行应用

    Android里的语言VM是Dalvik VM,可以看做是JVM的衍生物(Android是平台,Java是语言)

  • 而像之前最简单的51单片机、stm32系列逻机,没有操作系统,是C语言直接操控底层寄存器实现相关业务,为了保持程序持续运行一般使用while(1)、loop循环实现。逻辑代码是业务流程型。

  • 关于BootLoader:

    • 作用与BIOS相似,都是具有基本的初始化系统,USB下载和硬件测试等功能的系统启动程序,具体的工作/启动过程又分为两阶段:
    • 第一阶段基于汇编实现,让CPU去驱动和控制内存、Flash、串口、IIC、IIS、数据段等设备和内容,完成一些依赖于CPU体系结构的初始化,并调用第二阶段的代码;
    • 第二阶段是纯C实现,主要引导Linux内核启动

BootLoader、Linux内核、文件系统是嵌入式设备启动的核心步骤,也是交叉编译的工作内容和实现目的(目的平台还没建立,通过交叉编译实现这三部分,搭建一个操作系统)

三、Linux内核源码目录树扫盲分析

目录树,就是目录结构,指的是源码文件的结构组织,这小节来对上面下载的树莓派Linux内核源码各部分功能进行一个基本的认知。

由于Linux开源、免费,有官方的开源社区和社区工作者,因此自1991年正式发布以来已经形成了一个庞大的系统生态,其源码也已经达到了1100余w行。如果使用tree命令对目录结构进行分析,其结果是很庞大的,以5.15版本为例,有近八万行记录:

以下内容参考自博客Linux内核源代码目录树结构

目录 作用/存放内容 备注/举例
arch 包含和硬件体系结构相关的代码,每种平台占一个相应的目录。
和32位PC相关的代码存放在i386目录下,其中比较重要的包括kernel(内核核心部分)、mm(内存管理)、lib(硬件相关工具函数)、boot(引导程序)、power(CPU相关状态)。
虽然内核源码总体庞大,但最终编译出来的结果也就几MB,就是因为编译时要配置目标平台,平台下还可能有多种方案,只有目标平台的特定部分才会用到。
block 部分块设备驱动程序。 与存储介质相关
crypto 常用加密和散列算法(如AES、SHA等),还有一些压缩和CRC校验算法。 封装好的,即用的函数,作为开发者(非密码学专业人士)使用即可,无需研究内部原理
Documentation 关于内核各部分的通用解释和注释
drivers 设备驱动程序,每个不同的驱动占用一个子目录。 嵌入式工程师内核底层开发基本功,吃透就很厉害
fs 各种支持的文件系统,如ext、fat、ntfs等。
include 头文件。其中,和系统相关的头文件被放置在linux子目录下
init 内核初始化代码(注意不是系统引导代码)。
ipc 进程间通信的代码 进阶内容。
前面在Linux系统编程接触到的进程通信、管道、消息队列、共享内存等,都是触发系统调用,进入内核态去执行,而不是自己编写的代码就实现了功能
kernel 内核的最核心部分,包括进程调度、定时器等,和**平台相关的一部分代码放在arch/*/kernel**目录下。 进阶
lib 库文件代码。
mm 内存管理代码,和平台相关的一部分代码放在arch/*/mm目录下。 进阶
net 网络相关代码,实现了各种常见的网络协议。 进阶
scripts 用于配置内核文件的脚本文件。
security 主要是一个SELinux的模块。
sound 常用音频设备的驱动程序等。
usr 实现了一个cpio。 cpio:用来建立、还原备份档的工具程序,它可以加入、解开cpio或tar备份档内的文件。

四、配置内核源码以适合树莓派操作

前面提到了,Linux源码经过一定的配置,才能进行编译,生成目标平台的Linux内核,而只有当内核完善了,我们才能编译该平台上的驱动程序。

那关于源码的配置文件,一般有这几种方式(参考自树莓派-内核开发-说明 下载代码 编译 替换内核):

  1. 厂商发布芯片、开发板时会同时发布Linux源码,直接使用厂家提供的配置文件(常命名为芯片型号.defconfig

    如上图所示,以树莓派为例,早期树莓派版本使用bcm_rpi.config,2、3代芯片为bcm_2709,4代使用bcm_2711和bcm_2835

    比如我的开发板型号为pi 3b,则使用命令ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNEL=kernel7 make bcm2709_defconfig

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    HOSTCC  scripts/basic/fixdep
    HOSTCC scripts/kconfig/conf.o
    HOSTCC scripts/kconfig/confdata.o
    HOSTCC scripts/kconfig/expr.o
    LEX scripts/kconfig/lexer.lex.c
    YACC scripts/kconfig/parser.tab.[ch]
    HOSTCC scripts/kconfig/lexer.lex.o
    HOSTCC scripts/kconfig/menu.o
    HOSTCC scripts/kconfig/parser.tab.o
    HOSTCC scripts/kconfig/preprocess.o
    HOSTCC scripts/kconfig/symbol.o
    HOSTCC scripts/kconfig/util.o
    HOSTLD scripts/kconfig/conf
    #
    # configuration written to config
    #

    这样,我们就根据厂商的标准配置生成自动了配置文件

    在上述命令执行时,我遇到了flexbison未安装导致中断的问题,使用APT安装后重新执行即可。

  2. 基于厂家的config文件,使用make menuconfig一项一项进行配置

    命令格式:ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNEL=kernel7 make menuconfig

    不过由于此步骤是图形化选择,因此需要提前安装ncurses相关依赖:

    1
    2
    3
    4
    sudo apt-get install -y bc 
    sudo apt-get install -y libncurses5-dev libncursesw5-dev
    sudo apt-get install -y zlib1g:i386
    sudo apt-get install -y libc6-i386 lib32stdc++6 lib32gcc1 lib32ncurses5

    从上面这张图可以看到,驱动程序的加载一般有两种方式:

    • 一种是built-in,直接编译嵌入到内核中(最后的文件是zImage)
    • 另一种是模块化,通常是一个.ko文件,通过命令inmosd xx.ko进行加载

    对于这两种方式,没有特别的要求的话就不用改动,至于源码层面的创新,则更需要技术和经验。

    下图是一些设备驱动相关的设置:

  3. 完全自己实现,难度较大,适合经验丰富的高级工程师。

五、树莓派Linux内核的编译与安装

1. 编译

在上一小节配置步骤完成后,应当会生成一个文件.config,将此文件移动到Linux内核源码的根目录下备用;

执行如下命令:

1
2
3
4
5
ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNEL=kernel7 make -j4 zImage modules dtbs 2>&1 | tee build.log
# 核心命令是make -j4(j4指定编译时用的资源),
# zImage和modules指定要生成的目标文件和驱动模块,
# dtbs指定生成配置文件
# 2>&1 和tee命令规定了编译过程中错误的处理方式

如果前面的交叉编译环境配置正常,约20分钟(因机器性能而异)后将会Linux内核编译成功,标志为:

  1. 编译过程正常退出,结尾没有报错
  2. 在源码根目录下生成vmlinux文件
  3. arch/arm/boot/目录下生成zImage文件,此文件即为编译好的目标镜像
2. 内核的安装
  • 使用./scripts/mkknlimg arch/arm/boot/zImage ./kernel_new.img命令,将zImage转化为可用的image文件

  • 将树莓派的tf卡通过读卡器连接至电脑

  • 在本地的Linux虚拟机(因为要将树莓派的tf卡挂载到宿主机上进行内核移植,因此如果没有本地的Linux电脑,建议还是使用虚拟机)上执行命令:

    1
    2
    3
    4
    5
    sudo mkdir boot extf4
    sudo mount /dev/sdb/sdb1 boot
    sudo mount /dev/sdb/sdb2 extf4
    # boot目录是一个fat分区,kernel的img文件就放在这个分区里;
    # ext4分区,也就是系统的根目录分区。

    如果此不不成功,应当是插入的tf卡被windows占用,解决办法:以VMware为例,右键上方选项“虚拟机”->”可移动设备”->选择“Super Top Mass Storage Device”,点击连接。成功状态下使用dmesg命令应该能看到usb设备的识别事件

  • 使用如下命令安装驱动模块:

    1
    sudo ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNEL=kernel7 make INSTALL_MOD_PATH=path-to-extf4 modules_install

    注意path-to-extf4为上面extf4目录的绝对路径

  • 使用如下方法备份并更新内核镜像:

    1
    2
    3
    4
    cd path-to-boot
    cp kernel7.img kernel7.img.old
    cd root-of-linux-source
    cp kernel_new.img path-to-boot/kernel7.img
  • 拷贝配置文件:cp root-of-linux/arch/arm/boot/dts/.*dtb* path-to-boot

  • 使用sudo umount boot extf4,解除文件系统的挂载并通过vmware断开与磁盘的链接

3. 启动新内核并验证

将tf卡插回树莓派,重新启动开机。

如果前面在串口开发阶段设置了cmdline.txt,解除了串口打印信息。建议此处还原以便能够通过串口观察到启动过程和报错信息

系统启动后使用uname -r命令查看内核发行版本,发现从原来的4.14.98-v7+变为了4.14.114-v7,且编译时间也更新了:

六、文件系统引入

1. 什么是文件系统

通常的认知里,“文件系统”就是用于存放、管理、读取及操作文件的系统,在Linux中,似乎根目录下的所有文件和目录就是一个文件系统。

更学术地说,对于计算机系统而言,文件系统的概念是:操作系统用于明确存储设备组织文件的方法(文件管理系统程序)

常见的文件系统类型有NTFS(windows使用)、FAT(Linux使用)、EXT1/2/3/4、HFS(Hadoop项目开发)、Tempfs、vfat等

2. 分区的概念

分区,指将文件系统上存放的文件,按照功能或内存空间统一存放。

  • 在windows上,分区的概念比较明显:在根目录下有C盘(常为系统盘)、D、E、F盘……每一个盘符通过驱动器进行管理。当对一整块磁盘进行逻辑上的划分时,分配到的不同盘符内部一般是连续的内存空间,在这段连续的内存空间上创建一个目录
  • 而在Linux系统上,文件系统是先创建不同的目录,再将物理内存映射到不同的目录下。因此,Linux系统中同一分区的内存空间不一定连续

对于嵌入式操作系统的文件系统,从逻辑上来说一般有这么几个分区:

  • bootloader,一般通过``/boot`目录进行访问和控制,存放操作系统启动的引导代码
  • para,启动代码向内核传递参数的位置
  • kernel,内核分区
  • 根分区,体现为文件系统目录结构

嵌入式系统没有swap分区,只有实际的物理地址;

嵌入式系统的驱动程序、上层软件都放在根分区.在嵌入式系统启动后,系统无法查看到bootloader、para、kernel这三个分区。

通过df -T命令,我们可以便捷地看到文件系统概览和不同分区的挂载情况:

3. 文件系统目录结构

以下内容参考自Linux文件系统目录结构

在Linux系统中,目录被组织成一个:单根倒置树结构,文件系统从根目录开始,用/来表示。

文件名称区分大小写( 大小写敏感还需要看具体的文件系统格式 ),以.开头的为隐藏文件,路径用/来进行分割(windows中使用\来分割),文件有两个种类:元数据与数据本身.在操作linux系统时,通常会遵循以下的分层结构规则:LSB (Linux Standard Base) / FHS(Filesystem Hierarchy Standard)

根据上图和上面一小节的df命令查看,说明文件系统并非就是根目录,只能说文件系统的目录结构从根目录开始

根目录下的各分区,在物理空间上并不一定连续。

目录 说明
/boot 存放系统启动时相关的文件,比如kernel内核、grub引导菜单等
/bin 存放的都是命令,普通用户能执行
/sbin 超级管理员能执行的命令
/home 存放普通用户的家目录
/root 超级管理员的家目录,普通用户是无法进入
/etc 存放配置文件的目录,如:
/etc/hostname存放主机域名
/etc/hosts存放本地已知的域名的IP地址
/dev 存放设备及其驱动文件的目录,例如:
/dev/null为黑洞目录,所有内容只进不出
/dev/zero,源源不断产生空数据流(可用于清空/覆盖文件内容)
/dev/ptsx为每个登录用户所占用的终端
/dev/serial0/dev/serial1为两个串口
/usr 类似于windows的C盘下面的windows目录,重要的有:
/usr/lib存放共享库文件,后缀为.so
/usr/local早期用于存放软件(应用程序),类似于Windows的C:\ProgramFiles
/var 存放一些可变化的内容,比如/var/log日志,可以人为让其发生变化,也或者是随着时间推移产生变化
/tmp 存放临时文件,无论哪个用户都可以放
/proc 反馈当前运行的进程的状态信息
/run 存放程序运行后所产生的pid文件
/mnt 提供挂载的一个目录
/opt 早期第三方厂商的软件存放的目录
虚拟文件系统

前面小节提到了,文件系统有多种类型。就算是根分区下同一路径,其文件系统的类型也不尽相同,对应地文件存储方式也不相同。

但是我们在前面学习的过程中,无论是通过命令行、文本编辑工具直接查看/编辑文件内容,还是通过标准的C库IO函数(如read、open、write等),我们在打开文件的时候并没有指定文件系统的类型,或者根据文件系统类型去定制文件IO操作。这是为什么呢?

答案就是:上面提到的文件IO都是在用户态进行,而实际的数据操作由内核态去执行,这两者之间隔了一层VFS(Virtual File System,虚拟文件系统)。虚拟文件系统的存在为各文件系统的文件操作提供了一个通用接口,使得开发上层应用的程序员不用去关心底层文件系统的软硬件特性差异而专注于应用逻辑,只需调用标准的文件IO方法即可完成数据操作。

虚拟文件系统的基本思想,就是把表示很多不同种类文件系统的共同信息放入内核;其中有一个字段或函数来支持Linux所支持的所有实际文件系统所提供的任何操作。对所调用的每个读、写或其他函数,内核都能把它们替换成支持本地Linux文件系统、NTFS文件系统,或者文件所在的任何其他文件系统的实际函数。

七、Linux内核与驱动

Linux内核结构介绍

上面所示的Linux系统内核结构图(图片来源:文件系统认知,Linux内核框图),体现了Linux内核的不同层级。

整体看,内核结构分为用户级、内核级和硬件级三个层次,

上层编写的应用程序通过标准的C库函数(open/read/write/close等)去操作文件,而C库函数内部的实现是通过与系统调用接口(类似上节提到的VFS,提供的方法包括sys_open/sys_read/sys_write/sys_close等)接触,内核级的文件系统创建相应的进程去进行具体的IO操作。

由于不同文件所在分区的文件系统类型不同,因此在系统调用接口(sys_open等)的内部实现中,就会对操作的目标文件进行类型判断,从而产生不同的指令信息,进一步调用设备驱动程序去实现硬件层面的

上面的内核结构框图,用另一种形式表达就是下图所示:

在这张图中,shell和库函数处于同一层级(库函数有时也会调用shell),是一个命令解释器。一个shell对应一个终端,在以前终端对应一台硬件设备,用于用户输入并显示输出,在现在一个终端可以像应用程序一样,多开图形化窗口。

我们用>表示重定向,用|表示管道 ,也是通过shell解释&或者|的含义。Shell接着通过系统调,用指挥内核,实现具体的重定向或者管道。在没有图形界面之前,shell充当了用户的界面,当用户要运行某些应用时,通过shell输入命令,来运行程序。shell是可编程的,它可以执行符合shell语法的文本。这样的文本叫做shell脚本(script)。可以在架构图中看到,shell下通系统调用,上通各种应用,同时还有许多自身的小工具可以使用。Shell脚本可以在寥寥数行中,实现复杂的功能。(参考自《图解Linux系统的系统架构》

驱动认知

前面花了大篇幅内容介绍了Linux内核结构,以及文件系统底层运作的原理。在内核发出指令时,是由各种对应的驱动软件去控制这些硬件(键盘、鼠标、字符终端、显示器、音频设备等各种涉及交互的外设),进行数据读写、信息的输入与输出。

这一小节继续将此模型的概念进行深化,以便我们对驱动软件的作用和开发目的有更深入的了解

  • 在用户态的内容,涉及到IO的主要是两部分:上层开发的各种应用(sockte聊天服务器、ftp服务器、进程通信……)和标准的C库(封装了open、read、write、close等通用的支配内核工作的接口)

  • 在内核态,涉及到进程、线程、内存、网络的内容,对于开发上层应用的程序员而言都不需要关心,只需调用相应的接口即可。但是当我们要直接控制非标准的外设,就涉及到设备驱动。

    前面我们使用了wiringPi进行香橙派的外设开发,这是比较幸运的情况。当厂家提供/适配了开发工具,我们可以直接用,当厂家没有提供现成的设备驱动程序,开发者就需要自己去实现驱动,开发自己的“wiringPi
    ,这也就是这部分课程的目的和意义所在。

在linux下,一切皆文件,除了常规意义上的文件(Document)外,所有的设备都是以文件的形式存放在路径/dev下。

鼠标、键盘、显示终端、led等外设……这些设备功能和特性各异,操作系统是如何识别并统一管理它们的?我们进行IO时是系统是如何选中某个具体的设备的?我们将一步步在内核空间开发这些驱动文件,实现上述功能

调用标准C库函数去打开设备驱动,以进行IO时,就像这样:open("device",privileges),和打开文件一样。在这种场景下,标识一个设备(文件)有两种方式:

  1. 通过设备名字(文件名),这种方式最直接也最基础,是初级驱动框架必须提供的方式

  2. 通过设备号。下面是在/dev目录下查看系统设备情况的输出:

在这张图中,时间之前的紧邻两列为主次设备号,其中主设备号用于区分不同设备类别,次设备号用于区分同类型的多个设备,只有字符设备(权限码首位为c)有主设备号。详情可参见主设备号和次设备号

在内核空间存在的驱动程序,一般通过驱动的形式链表进行存储,其涉及到的管理操作一般有两种:

  • 驱动编写完成后插入到驱动链表,加载进内核

    具体的内容:设备名、设备号、设备驱动函数(一般是操作某些寄存器)

  • 由上层应用调用所引导的驱动加载,open相应设备

最后再总结一遍整体流程:用户空间调用open产生一个sys_call(系统调用,软中断,中断号0x80,汇编实现),进而调用VFS中的sys_open,后者根据设备名找到设备号,在系统的驱动链表中找到相关的驱动函数,调用驱动函数的open、read、write等,即去控制寄存器,实现IO

关于sys_call的具体处理过程,可以参见《Linux操作系统分析》之分析系统调用system_call的处理过程

基于编写好的驱动框架编写应用代码

首先看一份最简单的驱动示例,这个示例框架中,open、read、write等具体实现先以printk占位,具体实现后面小节深入:

点击展开
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
//pin4driver.c
#include <linux/fs.h> //file_operations声明
#include <linux/module.h> //module_init module_exit声明
#include <linux/init.h> //_init _exit宏定义声明
#include <linux/device.h> //class device声明
#include <linux/uaccess.h> //copy_from_user的头文件
#include <linux/types.h> //设备号dev_t类型声明
#include <asm/io.h> //ioremap iounmap的头文件

static struct class *pin4_class;
static struct device *pin4_class_dev;

static dev_t devno; //设备号
static int major = 231; //主设备号
static int minor = 0; //次设备号
static char *module_name= "pin4";//模块名
static int pin4_read(struct file *file, char __user *buf, size_t count, loff_t *ppos){
printk( "pin4_read\n");//内核的打印函数,和pinttf类似
return 0;
}

static int pin4_open(struct inode *inode,struct file *file){
printk("pin4_open\n"); //内核的打印函数,和pinttf类似
return 0;
}

static ssize_t pin4_write(struct file *file, const char __user *buf,size_t count, loff_t *ppos){
printk( "pin4_write\n");
return 0;
}
static struct file_operations pin4_fops = {//注册到驱动链表中。缺省赋值,只指定一部分属性的值
.owner = THIS_MODULE,
.open = pin4_open,
.write = pin4_write,
.read = pin4_read,
};

int __init pin4_drv_init(void){ //真实驱动入口
int ret;
devno = MKDEV(major,minor); //创建设备号
ret = register_chrdev( major,module_name,&pin4_fops); //注册驱动告诉内核,把这个驱动加入到内核的链表中
pin4_class = class_create( THIS_MODULE, "myfirstdemo" ); //让代码在dev自动生成设备,手动生成设备的方式:mknod modulename type device_number
pin4_class_dev = device_create( pin4_class, NULL,devno,NULL,module_name ); //创建设备文件
return 0;
}
void __exit pin4_drv_exit(void){
device_destroy(pin4_class, devno);
class_destroy(pin4_class);
unregister_chrdev( major, module_name); //卸装驱动
}

module_init(pin4_drv_init); //入口.内核加载此驱动的时候,这个宏会被调用
module_exit(pin4_drv_exit);
MODULE_LICENSE("GPL v2");

这份示例精简了一个设备驱动的必需要素:

  • open、write和read(看读写的需求)的入口,因为调用标准open函数时最终是调用到驱动提供的的“open函数”
  • 主、次设备号、设备名
  • init与exit,驱动加载初始化和卸载的定义,其中驱动加载需要做的包括:
    • 获取一个设备号
    • 在内核中注册驱动,把驱动加到驱动链表上
    • 根据设备号,在/dev下自动生成设备并创建设备文件

有上面的驱动代码,那么我们的应用代码可以这样写:

1
2
3
4
5
6
7
8
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main(){
int fd;
fd = open("/dev/pin4",O_RDWR);
fd =write(fd,'1',1);
}
驱动编译与测试

将上面的驱动代码进行交叉编译,并在树莓派上进行测试

  • 首先进入内核源码目录下的drivers/char路径(代表字符设备),修改Makefile以配置期望生成的目标文件:在其中加入一条obj-m += pin4test.o,指定新生成一个模块pin4test

  • 然后使用命令ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNEL=kernel7 make modules进行编译并只生成模块(以及对应的测试程序gcc pin4test.c -o pin4test):

  • 使用scp将生成的模块文件和测试文件拷贝至开发板

  • 在开发板使用sudo insmod pin4driver.ko安装此驱动(卸载:sudo rmmod pin4driver

    安装好后可通过lsmod进行查看:

  • 进行测试,直接执行pin4test,然而命令行没有任何提示,dmesg也看不到内核打印的信息

    • 由于驱动的加载和open调用发生在内核态,因此用户空间是看不到的,优化pin4test的逻辑,若返回值非0则报错,由此测试得知失败的原因是驱动文件没有读写权限,因此打开失败,将/dev/pin4文件修改为666权限后即可
  • 最后执行的效果:

八、驱动硬件部分开发

打通软件层面的通道后,要想真正实现数据的IO,还需要配合硬件层面的操作,即:打开寄存器,进行数据(以字符为例)的写入。在编程实现之前,需要先了解两部分预备知识:“地址”与树莓派芯片特性

准备知识——各种地址概念的引入
  1. 微机总线地址

    地址总线(Address Bus,又称位址总线)属于一种电脑总线,由CPU或有DMA(Direct Memory Access)能力的单元,用来沟通这些单元想要存取电脑内存元件的实体地址

    关于CPU能访问内存的范围:

    32位系统的电脑,最大读取内存≤2^32-1=4294967296bit≈4G

    树莓派可使用cat /proc/meminfo查看内存信息(949M),见下图

  2. 物理地址

    也叫硬件实际地址或绝对地址

  3. 虚拟地址

    逻辑地址,基于算法的软件层面的“假”地址,正因为虚拟地址的存在,需求内存大小超过实际最大内存的程序才得以运行。

    事实上,整个Linux系统内,应用层涉及到的内存都是使用虚拟地址

  4. 更多关于虚拟地址的介绍,可以参见《Unix设计与实现》

树莓派BCM2835芯片手册导读

树莓派3b的CPU型号为博通公司BCM2835,属于ARM-cortexA53架构(2440、2410cpu则属于ARM9架构)

下图所示为摘自芯片手册的地址映射示意图,上面的总线地址、物理地址、虚拟地址与上面介绍相对应。

第六章——General-Purpose IO(GPIO,通用I/O口)简介

BCM2835有两组GPIO线,共计54条(也就是说,外围硬件支持的话最多有54个GPIO引脚,在树莓派上实际是40个)。所有GPIO引脚在BCM内至少有两个可选功能。可选功能通常是与外设的IO(即read与write),并且每个组中可能出现单个外围设备,以允许在选择IO电压时具有灵活性。

需要注意的是,该开发手册中的所有pin脚编号,对应的都是实际引脚编号,即gpio readall中最外侧的“BCM编号”

寄存器概览摘要:

BCM2835共有41个寄存器,每个寄存器都是32位。其中比较重要的几个寄存器模式如下:

  1. 5个功能选择寄存器,设置54个pin脚的读或写

    下图展示的是寄存器0,管辖范围为pin脚0-9,设置方法以pin9为例

  2. 2个输出赋值寄存器,写入0无效果,写入1表示输出1(一个寄存器0,一个寄存器1,管辖范围不同)

  3. 2个输出重置寄存器,写入无效果,写入1表示清零(低电平)

总结

寄存器名 描述 (总线)地址
GPFSEL0 GPIO功能选择 输入/输出 32位
以设置pin4为输出口为例:
14-12 001=GPIO Pin4 is an output
14-12 000=GPIO Pin4 is an input
0x 7E20 0000
GPSET0 GPIO pin脚置0
0=no effect
1=set GPIO pin n
0x 7E20 001C
GPSET1 GPIO pin脚置1 0x 7E20 0020
GPCLR0/1 GPIO pin脚清0
0=no effect
1=clear GPIO pin n
0x 7E20 0028
GPLEV0/1 GPIO pin脚级别(仅读) ……
GPREN0/1 GPIO上升沿 ……
GPFEN0/1 GPIO下降沿 ……
IO操控代码编程实现与调测
初步实现

首先还是基于前面展示的demo进行扩展,总体思路就是先定义出上面三个寄存器变量,给寄存器赋地址,然后在上层应用调用open、read、write时,根据调用的方法和参数,初始化寄存器、从相关IO口输出/读取电平

关于三个寄存器,上面的地址映射图中其实展示的是总线地址,实际在写的时候,物理地址的起始值为基址+偏移量=0x3f200000(具体随芯片幸好不同而不同,参考树莓派LED驱动编写

1
2
3
4
//pin4driver.c
volatile unsigned int* GPFSEL0 = volatile(unsigned int*)0x3f200000;
volatile unsigned int* GPSET0 = volatile(unsigned int*)0x3f20001c;
volatile unsigned int* GPCLR0 = volatile(unsigned int*)0x3f200028;

这里使用了强制类型转换将整数类型的地址转化为指针,需要注意;
volatile关键字,有两点作用:

  1. 确保指令不会因硬件优化而被省略
  2. 确保每次调用的时候都直接读值(而不是从内存中获取)

因为应用层接触到的都是虚拟地址,因此不能直接赋值为物理地址。在定义变量时,可以先赋为NULL,随后在驱动初始化函数中使用一个库函数ioremap(原型:void __iomem* ioremap(resource_size_t res_cookie,size_t size)将物理地址映射为虚拟地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
volatile unsigned int* GPFSEL0 = NULL;
volatile unsigned int* GPSET0 = NULL;
volatile unsigned int* GPCLR0 = NULL;

int __init pin4_drv_init(void){ //真实驱动入口
int ret;
printk("insmod pin4 begin\n");
devno = MKDEV(major,minor); //创建设备号
ret = register_chrdev( major,module_name,&pin4_fops); //注册驱动告诉内核,把这个驱动加入到内核的链表中
pin4_class = class_create( THIS_MODULE, "myfirstdemo" ); //让代码在dev自动生成设备,手动生成设备的方式:mknod modulename type device_number
pin4_class_dev = device_create( pin4_class, NULL,devno,NULL,module_name ); //创建设备文件
GPFSEL0 = (unsigned int*)ioremap(0x3f200000,4);//ioremap(基地址,映射大小)
GPSET0 = (unsigned int*)ioremap(0x3f20001c,4);//io口寄存器映射为普通内存单元进行访问
GPCLR0 = (unsigned int*)ioremap(0x3f200028,4);
return 0;
}
void __exit pin4_drv_exit(void){
iounmap(GPFSEL0);
iounmap(GPSET0);
iounmap(GPCLRO);
device_destroy(pin4_class, devno);
class_destroy(pin4_class);
unregister_chrdev( major, module_name); //卸装驱动
}

随后扩展open、write函数的具体实现,操控io口的电平高低:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static ssize_t pin4_write(struct file *file, const char __user *buf,size_t count, loff_t *ppos){
printk( "pin4 write\n");
int userCmd;
copy_from_user(&cmd,buf,count);
if(userCmd==1){
printk("set pin 1\n");
GPSET0 |= (0x1 << 4);//pin脚高电压,1左移几位就设置哪位
} else if(userCmd==0){
printk("set pin 0\n");
GPCLR0 |= (0x1 << 4);//重置pin脚电压(清零),1左移几位就重置哪位
} else {
printk("command mismatch, quit\n");
}
return 0;
}
static int pin4_open(struct inode *inode,struct file *file){
printk("pin4 open\n");
GPFSEL0 &= ~(0x06 << 12);//配置pin4为输出引脚,即需要配置12-14位为001,先与0后或1
GPFSEL0 |= (0x1 << 12);
return 0;
}

这里主要用了移位和按位相与、按位相或来实现具体寄存器和pin脚的定位。

测试
  • 根据上述驱动,编写控制(输出)pin4口电平高低的测试代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //pin4test.c
    #include<sys/types.h>
    #include<sys/stat.h>
    #include<fcntl.h>
    #include<stdio.h>
    int main(){
    int fd;
    int cmd;
    if((fd = open("/dev/pin4",O_RDWR))<0){
    printf("open failed, Reason: \n");
    perror(" ");
    return(-1);
    }
    printf("1: set pin4 high, 0: set pin4 low\ninput: ");
    scanf("%d",&cmd);
    printf("choice: %d\n",cmd);
    fd =write(fd,&cmd,1);
    }
  • 随后在宿主机编译驱动:ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNEL=kernel7 make -j8 modules并将ko文件拷贝到目标机

  • 在目标机使用insmod命令加载模块,修改相应设备驱动的权限为666

  • 执行交叉编译后的测试文件

测试效果:

对应的dmesg:

至此,我们就成功实现了一个pin脚的IO控制,即不用wiringPi实现了pinModedigitalRead方法,打通了上层应用到底层驱动的全链路。


树莓派学习笔记(六):Linux高阶课程之驱动开发学习笔记
https://dockingyuan.top/2023/01/30/raspberry/6-Linux高阶课程—驱动开发/
作者
Yuan Yuan
发布于
2023年1月30日
许可协议