Linux驱动开发实战指南-上

第 1 章 驱动章节实验环境搭建

本章重点是搭建实验环境,后续章节讲解设备驱动原理。

程序可在板卡或PC上交叉编译。

需下载内核源码或头文件,编译源码、驱动模块和设备树,再拷贝到开发板运行。

驱动模块独立编译,但需链接到内核在内核空间运行,必须在目标内核版本上编译。

本章不烧录内核,编译内核是为了辅助编译驱动,也可编译内核头文件deb包来编译驱动模块。若需更新内核,可参考镜像构建与部署章节。

1.1 搭建编译环境

在 PC 上,可通过VirtualBox或 VMware 搭建 Ubuntu 虚拟机(推荐18.04或20.04),

也可直接在板卡上开发,但后者编译时间长。

搭建编译环境时,需执行以下命令安装相关库和工具:

bash 复制代码
sudo apt update
sudo apt install gcc \      # GNU C编译器,用于编译C语言代码
                make \      # 用于自动化构建和管理项目
                git \       # 分布式版本控制系统,用于代码版本管理
                bc \        # 精确计算工具,用于高精度数学运算
                libssl-dev \ # OpenSSL库的开发版本,用于加密和安全通信
                liblz4-tool \ # LZ4压缩工具,用于快速压缩和解压缩文件
                device-tree-compiler \ # 设备树编译器,用于编译设备树源文件
                bison \     # YACC(Yet Another Compiler Compiler)的替代品,用于解析语法
                flex \      # 词法分析器生成器,用于生成词法分析器代码
                u-boot-tools \ # U-Boot工具,用于处理U-Boot相关的文件和操作
                gcc-aarch64-linux-gnu # 用于交叉编译ARM64架构的GNU C编译器

1.2 获取内核源码

板卡使用的内核版本,可以使用命令 uname -a 查看。

获取内核源码,建议直接 git 克隆野火官方提供的内核源码,或者下载 Lubancat-SDK 源码,SDK 源码中包含内核源码。

1.2.1 直接克隆野火官方提供的内核源码

RK3588 系列板卡用户执行以下命令获取内核源码:

bash 复制代码
#RK3588 系列板卡目前有两个版本的内核,具体版本可以使用 uname -a 命令查看,查看后获取对应
的源码
#-b 参数指定 stable-5.10-rk3588 分支(5.10.160 版本) 稳定版
git clone -b stable-5.10-rk3588 https://github.com/LubanCat/kernel.git 

#-b 参数指定 lbc-develop-5.10 分支(5.10.209 版本)   开发版
git clone -b lbc-develop-5.10 https://github.com/LubanCat/kernel.git

1.2.2 通过 SDK 获取内核源码

访问百度网盘资源介绍页面,下载鲁班猫板卡对应型号的最近日期SDK源码压缩包。

源码压缩包体积大,仅在稳定版本时更新,发布日期可能与镜像发布日期不一致。

本地解压后,借助Github做少量更新即可同步到最新版本。

1.3 编译内核

1.3.1 在 PC 上交叉编译内核 (建议)

搭建编译环境并下载源码之后,进入内核源码根目录,根据具体的板卡设置配置文件。

RK3588 系列板卡用户执行以下命令编译内核源码:

bash 复制代码
# 清除之前生成的所有文件和配置
make mrproper
# 加载 lubancat_linux_rk3588_defconfig 配置文件,rk3588 系列均是该配置文件
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- lubancat_linux_rk3588_defconfig
# 编译内核,指定交叉编译工具,使用 8 线程进行编译,线程可根据电脑性能自行确定
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j8

获取交叉编译工具

如果没有交叉编译工具,或者编译工具版本不匹配,

也可以使用 Lubancat-SDK 源码的 gcc-linaro-6.3.1-2017.05-x86_64_aarch64-linux-gnu 版本的编译工具链。

执行以下命令获取并配置编译工具的环境变量:

bash 复制代码
# 获取编译工具链
git clone https://github.com/LubanCat/gcc-arm-10.3-2021.07-x86_64-aarch64-none-linux-gnu.git

# 导出环境变量,需要根据实际指定编译工具链的绝对路径
export PATH=/root/gcc-arm-10.3-2021.07-x86_64-aarch64-none-linux-gnu/bin:$PATH

# 查看编译工具链,如果 COLLECT_LTO_WRAPPER 变量为指定的路径,即配置成功
aarch64-none-linux-gnu-gcc -v

# 注意名字不是 aarch64-linux-gnu-gcc 而是 aarch64-none-linux-gnu-gcc,
# 所以执行其他命令的时候要注意改为 aarch64-none-linux-gnu-
# none代表无FPU支持

以上配置为临时导出环境变量,打开其他终端或者重启都需要重新导出环境变量。

如需永久保存需要将导出环境变量的命令写入 ~/.bashrc 文件末尾,并执行 source ~/.bashrc 重新加载配置。

SDK 中也包含了交叉编译工具链,位置在 SDK 源码/prebuilts/gcc/linux-x86/aarch64/目录下,也可使用以上方法导出环境变量。

1.3.2 在板卡上本地编译内核

1.4 如何编译和加载内核驱动模块

1.4.1 编译内核驱动模块

内核模块可单独编译后手动加载,也可直接编译进内核自动加载。

测试时一般单独编译,方便调试且节省时间。

获取野火驱动教程源码:

bash 复制代码
# 从GitHub获取
git clone https://github.com/LubanCat/lubancat_rk_code_storage
# 或从Gitee获取
git clone https://gitee.com/LubanCat/lubancat_rk_code_storage

目录下的 linux_driver 文件夹存放驱动教程例程文件,需将其放置到内核代码同级目录,因驱动程序编译需依赖编核好的Linux内核,其Makefile指定了内核路径,放至同一目录结构下更方便使用例程。

以编译 hellomodule 内核模块为例,进入 linux_driver/module/hellomodule/ 目录,执行 make 命令即可编译程序。

Makefile内容及说明如下:

bash 复制代码
KERNEL_DIR=../../../kernel/  # 内核目录路径
ARCH=arm64                   # 目标架构
CROSS_COMPILE=aarch64-linux-gnu-  # 交叉编译工具链前缀
export ARCH CROSS_COMPILE    # 导出环境变量

obj-m := hellomodule.o       # 指定模块对象文件

all:                         # 默认目标.命令行运行make时会尝试构建该目标
    # 调用内核目录的makefile,将当前目录作为源码,构建模块
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules  

.PHONY: clean                # 声明clean为伪目标
                             # 伪目标的主要用途是避免与同名文件冲突
                             # 不然如果存在名为clean的文件, make clean会尝试更新该文件而不是执行目标规则

clean:                       # 清理目标
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean  # 在内核目录中清理模块

编译成功后,module/hellomodule/目录下新增hellomodule.ko文件,即为内核驱动模块。

1.4.2 加载内核驱动模块

bash 复制代码
# 编译好内核驱动模块 hellomodule.ko 后,拷贝到开发板
# 使用 scp 命令
scp hellomodule.ko cat@192.168.103.129:/home/cat/

# 在开发板上操作
cd /home/cat/
sudo insmod hellomodule.ko  # 加载内核模块
lsmod                       # 查看当前加载的内核模块
sudo rmmod hellomodule.ko   # 卸载内核模块

# 查看模块相关信息
cd /sys/module/hellomodule

使用 lsmod 查看已加载的内核模块,更多信息可在 /sys/module 目录下查看。

例如,加载 hellomodule.ko 后,可在 /sys/module/hellomodule 目录查看详细信息。

scp(Secure Copy Protocol)命令是一种在Linux和Unix系统中用于安全地复制文件和目录的命令。它基于SSH(Secure Shell)协议进行数据传输,因此提供了数据传输过程中的加密和身份验证功能,确保数据的安全性。

bash 复制代码
scp [选项] 源文件 目标文件

#-r:递归复制整个目录。
#-P:指定远程主机的SSH端口(默认是22)。
#-v:显示详细信息,用于调试和了解传输过程。

1.5 如何编译和加载设备树

1.5.1 编译设备树

Linux 3.x后引入设备树,用于描述硬件平台的静态数据结构,包含硬件设备、总线、中断控制器等信息。

驱动开发依赖设备树,后续将介绍其编译和加载方法,具体原理见Linux设备树章节。

1.5.1.1 使用内核工具编译设备树

在编译Linux内核时,会生成dtc(Device Tree Compiler)工具,用于将设备树源文件(.dts或.dtsi)编译为二进制的 .dtb文件。

bash 复制代码
# 使用内核目录中的dtc工具
内核目录/scripts/dtc/dtc -I dts -O dtb -o xxx.dtb xxx.dts

# 使用系统安装的dtc工具
dtc -I dts -O dtb -o xxx.dtb xxx.dts

实际编译设备树时,由于存在许多依赖关系,通常需要更复杂的步骤。

1.5.1.2 使用内核的构建脚本编译设备树

为了单独编译设备树而不编译整个内核,可以使用内核构建脚本。

设备树文件位于内核源码的 arch/arm64/boot/dts/rockchip 目录中。

加载配置文件

bash 复制代码
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- lubancat_linux_rk3588_defconfig

单独编译设备树

bash 复制代码
make ARCH=arm64 -j4 CROSS_COMPILE=aarch64-linux-gnu- dtbs

如果没有修改设备树文件,单独编译设备树不会有任何输出。

为了测试编译,可以手动修改设备树文件,例如在arch/arm64/boot/dts/rockchip目录下的rk3568-lubancat-2.dts文件中加一个空格。

修改后,再次执行编译命令,编译成功后生成的.dtb文件将位于arch/arm64/boot/dts/rockchip目录中。

1.5.2 加载设备树

加载设备树,将编译好的新设备树文件,替换对应板卡的设备树,

替换 /boot/dtb/ 目录下的设备树文件即可。

1.5.2.1 确定板卡使用的设备树文件

不同型号的 rk-lubancat 板卡使用的设备树文件可能不同。

要确认当前板卡使用的设备树文件,可以在板卡中执行以下命令:

bash 复制代码
ls -l /boot/

例如,鲁班猫1N板卡的 rk-kernel.dtb 软链接 指向 /boot/dtb/rk3566-lubancat-1n.dtb。

系统启动时会读取 rk-kernel.dtb 作为设备树。

如果需要修改设备树,需更改 rk-kernel.dtb 的软链接目标。

1.5.2.2 替换设备树

以鲁班猫1N板卡为例,其设备树文件为 rk3566-lubancat-1n.dtb。

通过 SCP 或 NFS 将内核源码目录 arch/arm64/boot/dts/rockchip/ 下编译好的设备树文件拷贝到开发板的 /boot/dtb/ 目录,替换原有的 rk3566-lubancat-1n.dtb。

要验证新设备树是否生效,可以查看 /proc/device-tree 目录。

例如,设备树根目录下的 leds 节点在文件系统中对应 /proc/device-tree/leds 目录。

进入该目录,查看子节点 sys-status-led 的属性文件,如 compatible、name、status 等,使用 cat 命令查看这些文件内容。若能看到相关属性,说明设备树替换并加载成功。

1.6 如何编译和加载设备树插件

1.6.1 编译设备树插件

Linux 4.4以后引入了动态设备树(Dynamic DeviceTree)。

设备树插件可以动态加载到系统中,供内核识别。

重要:设备树插件与设备树不是替代关系,而是互补关系。

设备树插件可在主设备树定型后,动态拓展未描述的功能。

例如,A板的设备树未开启串口1功能,但B板需要,可沿用A板的设备树,通过设备树插件拓展串口1功能,满足B板需求。

1.6.1.1 使用内核工具编译设备树插件

编译设备树插件(.dtbo)与编译设备树(.dtb)类似,使用内核中的dtc工具。

示例命令如下:

bash 复制代码
内核构建目录/scripts/dtc/dtc -I dts -O dtb -o xxx.dtbo xxx.dts

例如,将arch/arm64/boot/dts/rockchip/overlay/rk356x-lubancat-uart3-m0-overlay.dts

编译为rk356x-lubancat-uart3-m0-overlay.dtbo:

bash 复制代码
scripts/dtc/dtc -I dts \            #文件输入格式
                -O dtb \            #文件输出格式
     -o arch/arm64/boot/dts/rockchip/overlay/rk356x-lubancat-uart3-m0-overlay.dtbo #输出名称
        arch/arm64/boot/dts/rockchip/overlay/rk356x-lubancat-uart3-m0-overlay.dts

编译后,可在arch/arm64/boot/dts/rockchip/overlay/目录下找到生成的.dtbo文件。

与编译设备树类似,设备树插件的编译也涉及依赖关系,通常在内核目录下执行make命令并指定dtbs选项,即可自动编译添加的设备树插件。

1.6.1.2 使用内核的构建脚本编译设备树插件

设备树和设备树插件都用DTC工具编译,但设备树编译成.dtb,插件编译成.dtbo。

手动用DTC编译.dtbo较繁琐且易出错。

鲁班猫系列开发板的许多外设硬件描述以.dtbo插件形式提供,使用设备树插件配置外设非常灵活。
设备树插件文件列表

设备树插件文件列表用于指定编译内核时要编译的插件。

根据不同的CPU配置(如CONFIG_CPU_RK3568、CONFIG_CPU_RK3588、CONFIG_CPU_RK3528),相应的设备树插件会被编译。

若要添加新的设备树插件,需将插件文件放入内核源码arch/arm64/boot/dts/rockchip/overlays目录,并修改该目录下的 Makefile 以添加编译选项。

例如,添加 lubancat-test-overlay.dtbo。

设备树插件的.dts源码示例(rk356x-lubancat-uart3-m0-overlay.dts)如下,用于启用UART3接口:

cpp 复制代码
/dts-v1/;          // 设备树版本声明
/plugin/;          // 声明这是一个设备树插件
/ {                // 根节点开始
    compatible = "rockchip,rk3568";  // 指定兼容的芯片型号
    fragment@0 {   // 插件的第一个片段
        target = <&uart3>;            // 指定目标设备(UART3)
        __overlay__ {                 // 开始覆盖操作
            status = "okay";          // 启用UART3设备
        };
    };
};                // 根节点结束

添加插件源文件到 overlays 目录下后,执行设备树编译命令,插件也会同步编译。

对于rk3588系列板卡,执行以下命令:

bash 复制代码
# 加载配置文件
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- lubancat_linux_rk3588_defconfig
# 单独编译设备树
make ARCH=arm64 -j4 CROSS_COMPILE=aarch64-linux-gnu- dtbs

执行命令后,若修改或添加了新的设备树插件,可在终端看到相应的编译输出,新插件的.dts文件将被编译为.dtbo文件。

1.6.2 加载设备树插件

野火Lubancat_RK系列板卡支持通过U-Boot加载设备树插件。

使用SCP或NFS将.dtbo文件拷贝到开发板的 /boot/dtb/overlays/ 目录,

后续操作在开发板上完成。

设备树插件的配置文件通过软链接管理,便于不同板卡加载对应文件。

可执行命令确认系统实际加载的配置文件。

bash 复制代码
ls -l /boot/uEnv/

以Lubancat2为例

当前系统加载的配置文件是 /boot/uEnv/uEnvLubanCat2.txt。

将设备树插件写入此文件,系统启动时会自动读取并加载指定的插件。

需编辑 /boot/uEnv/uEnvLubanCat2.txt文件。


dtoverlay 是设备树(Device Tree)中用于动态加载设备树片段(overlay)的指令

第 2 章 Linux 内核模块

2.1 内核模块的概念

2.1.1 内核

内核是操作系统的核心,提供基本功能,决定系统性能和稳定性。内核分为三种架构:

  1. 微内核 :只包含核心功能,如进程管理、内存管理等,其他功能(如文件系统、设备驱动)在内核外。优点是动态扩展性强,修改不影响核心功能。Windows 和鸿蒙采用微内核。

  2. 宏内核 :将所有功能编译成一个整体,执行效率高,但修改功能需重新编译内核。Linux 采用宏内核。

  3. 混合内核:结合微内核和宏内核的特点,兼具两者的优点。

Linux 为解决宏内核的缺点,引入了内核模块机制

2.1.2 内核模块机制引入

2.1.2.1 内核模块引入原因

Linux 是跨平台操作系统,支持多种设备,其内核源码中超过 50% 与设备驱动相关。

Linux 采用宏内核架构,开启所有功能会使内核臃肿。

内核模块 是实现特定功能的内核代码,可在内核运行时动态加载,增加功能。

开发设备驱动时,以模块形式编写,只需编译驱动代码,无需编译整个内核。

2.1.2.2 内核模块引入好处

内核模块提高了系统灵活性,方便开发人员。

开发设备驱动时,可随时添加或移除测试驱动,修改代码无需重启内核。

在开发板上,无需存放驱动的ELF文件,可通过挂载NFS服务器从其他设备加载模块,节省存储空间。特定场合下,按需加载/卸载模块,更好地服务当前环境。
ELF文件

2.1.3 内核模块的定义和特点

内核模块(Loadable Kernel Module, LKM)是在内核运行时加载的目标代码,用于实现特定功能。它独立编译,运行时链接到内核,运行在内核空间,与用户空间进程不同。

模块由函数和数据结构组成,可实现文件系统、驱动程序等功能。其特点包括:不编译入内核映像,控制内核大小;加载后与内核其他部分无异。

2.2 内核模块的工作机制

我们编写的内核模块,经过编译,最终形成.ko 为后缀的 ELF 文件

可以使用 file 命令来查看它。
lubancat@lubancat-vm:~/rk356x/linux_driver/module/hellomodules file hellomodule.ko hellomodule.ko: ELF 64位 LSB 可重定位文件,ARM 架构64位,版本1 (SYSV),带有调试信息,未剥离 lubancat@lubancat-vm:\~/rk356x/linux_driver/module/hellomodules

内核模块(.ko文件)是ELF格式,包含代码和数据,供内核加载。

加载过程包括:模块加载、初始化、执行、卸载。

2.2.1 内核模块详细加载/卸载过程

2.2.1.1 ko 文件的文件格式

.ko 文件是 ELF 格式的可重定位目标文件,包含代码和数据,

可用于生成可执行文件或共享库。
ELF文件格式

ELF 文件结构

  1. 文件头

    • 包含文件基本信息(类型、机器、版本等)

    • 定义了程序头表段头表的位置及大小

  2. 程序头表

    • 描述各个段(segment)的信息

    • 每个段包含类型、偏移、地址、大小等属性

  3. 段头表

    • 描述各个节(section)的信息

    • 每个节包含名称、类型、大小、地址等属性

  4. 段(Section)

    • 根据存储信息类型分为代码段数据段

    • 包含符号表、字符串表等

  5. 符号表

    • 保存段名的符号段序号

关键字段

  • 文件头字段e_ident, e_type, e_machine, e_version, e_entry, e_phoff, e_shoff, e_flags, e_ehsize, e_phentsize, e_phnum, e_shentsize, e_shnum, e_shstrndx

  • 段头字段sh_name, sh_type, sh_flags, sh_addr, sh_offset, sh_size, sh_link, sh_info, sh_addralign, sh_entsize

用途

  • 文件头:提供文件整体信息

  • 程序头表:用于加载程序到内存

  • 段头表:描述文件中各个节的详细信息

  • :存储具体的代码和数据

ELF头部位于文件开头,描述文件结构,与处理器和文件内容无关。

可用readelf工具查看其详细信息。


readelf -S查看节区头部表

节区头部表包含多种子表,其中重定位表(如.rel.text)用于记录需要重定位的代码或数据段的绝对地址引用。

重定位表类型为 SHT_REL,是 ELF 文件的一个段。链接器利用这些信息对目标文件进行重定位。使用 readelf工具可以读取重定位表信息。
readelf -r查看重定位表

字符串表是ELF文件中存储字符串(如段名、变量名)的集中区域。字符串通过在表中的偏移量引用。字符串表通常以段形式存在,常见段名为".strtab"或".shstrtab"。使用 readelf 工具可以读取这些字符串表。
elf -p查看字符串表

了解ELF文件格式有助于理解内核模块加载、卸载和符号导出,只需掌握基本概念。

2.2.1.2 内核模块加载过程

insmod 工具将 .ko文件读入用户空间内存,调用sys_init_module()系统调用,

由内核在vmalloc区暂存并解析模块,分配内存、调整地址、执行初始化操作,

最终完成加载并释放 init 段,仅留 core 段运行

2.2.1.3 内核模块卸载过程

2.2.2 内核是如何导出符号的

1. 符号是什么?

符号是指内核模块中使用 EXPORT_SYMBOL 声明的函数和变量。

这些符号在模块被加载到内核后,会被记录在公共内核符号表中。

2. 为什么需要导出符号?

导出符号的目的是为了让其他模块能够使用当前模块提供的函数和变量。

这在模块层叠技术中非常重要,例如,msdos 文件系统依赖于 fat 模块导出的符号,USB 输入设备模块依赖于 usbcore 和 input 模块导出的符号。

3. 如何导出符号?

使用以下宏导出符号:

  • EXPORT_SYMBOL(name):导出符号,任何模块都可以使用。

  • EXPORT_SYMBOL_GPL(name):导出符号,只有 GPL 许可的模块可以使用。

这些宏在模块文件的全局部分使用,不能在函数中使用。

编译模块时,这些宏会被扩展为特殊变量的声明,存放在 ELF 文件的符号表中。

4. 符号表的结构

ELF 文件的符号表包含以下字段:

  • st_name:符号名称在符号名称字符串表中的索引值。

  • st_value:符号所在的内存地址。

  • st_size:符号大小。

  • st_info:符号类型和绑定信息。

  • st_shndx:符号所在的 section。

5. 符号表的加载

当 ELF 文件的符号表被加载到内核后,simplify_symbols 函数会遍历整个符号表,根据 st_shndx 找到符号所在的 section 和 st_value 中符号在 section 中的偏移,得到真正的内存地址,并将符号内存地址和符号名称指针存储到内核符号表中。

6. 符号查找

其他模块通过 resolve_symbol_wait 函数查找符号,该函数调用 resolve_symbol,进而调用 find_symbol

find_symbol 会在内核的导出符号表和已加载的模块中查找目标符号。找到符号后,将符号的实际地址赋值给符号表中的 st_value

第 3 章 Linux 内核模块实验

3.1 hellomodule 实验

3.1.2 实验代码讲解

cpp 复制代码
#include <linux/module.h>    // 包含模块相关的宏和函数
#include <linux/init.h>      // 包含模块初始化和退出的宏
#include <linux/kernel.h>    // 包含 printk 等内核打印函数

static int __init hello_init(void)  // 初始化函数
{
    printk(KERN_EMERG "[ KERN_EMERG ] Hello Module Init\n");  // 打印紧急消息
    printk("[ default ] Hello Module Init\n");  // 默认级别打印消息
    return 0;  // 返回 0 表示初始化成功
}

static void __exit hello_exit(void)  // 退出函数
{
    printk("[ default ] Hello Module Exit\n");  // 默认级别打印消息
}

module_init(hello_init);  // 指定初始化函数
module_exit(hello_exit);  // 指定退出函数

MODULE_LICENSE("GPL2");  // 声明模块许可证
MODULE_AUTHOR("embedfire");  // 声明模块作者
MODULE_DESCRIPTION("hello module");  // 描述模块功能
MODULE_ALIAS("test_module");  // 提供模块别名
3.1.2.1 代码框架分析
3.1.2.2 头文件

在Linux系统中,编写内核模块所需的头文件位于内核源码的 include/linux 目录下,而不是通常的应用程序头文件目录/usr/include。以下是两个常用的头文件及其内容:
内核/ linux/ init.h
模块的初始化和退出函数
内核 / linux / module.h
注册模块的初始化和退出函数

3.1.2.3 模块加载/卸载函数

内核模块初始化与卸载函数及相关宏

模块初始化函数(module_init)

模块加载时执行,完成初始化工作。

返回 0 初始化成功,在 /sys/module下创建模块目录。非 0 初始化失败。

cpp 复制代码
static int __init func_init(void) {
    // 初始化代码
    return 0; // 成功返回0
}

/*模块初始化函数*/
module_init(func_init);

这里的 static关键字用于限制函数或变量的可见性,避免与内核其他部分冲突。

__init 宏: 将函数放入.init.text节区,初始化后释放

__initdata宏: 将变量放入.init.data节区,初始化后释放

模块卸载函数(module_exit)

模块卸载时执行,完成清理工作。

cpp 复制代码
static void __exit func_exit(void) {
    // 清理代码
}
module_exit(func_exit);

__exit宏: 将函数放入.exit.text节区,卸载后释放

__exitdata宏: 将变量放入.exit.data节区,卸载后释放

printk函数

内核模块的打印函数,类似于printf,但需指定打印等级。

如果消息的优先级低于当前内核日志的显示等级,该消息不会直接显示在控制台上,但会被记录在内核日志中。可用dmesg查看内核日志。dmesg查看的是内核环形缓冲区(kernel ring buffer)中的内容,而不是某个具体的文件。

printf 是 glibc实现的打印函数。工作在用户空间。

而内核无法使用 glibc的库函数,于是自己实现了 printk。
printf属于glibc库
printk是内核自己实现的,不属于这个库,不要搞混

日志会被打印到内核日志缓冲区。

系统日志守护进程(如 syslogd 或 rsyslogd)会定期从内核日志缓冲区读取消息,并将它们写入日志文件。常见的日志文件包括: /var/log/messages 或 /var/log/syslog:存储系统日志消息。 /var/log/kernel:专门存储内核消息(某些系统配置中)。

bash 复制代码
#查看当前打印等级
cat /proc/sys/kernel/printk

#查看内核日志
dmesg
3.1.2.4 许可证


比如QT是LGPL开源的,
开发闭源桌面应用时必须动态链接QT库

  1. MODULE_LICENSE 宏

在内核模块中,使用 MODULE_LICENSE 宏声明模块的许可证类型:

bash 复制代码
#define MODULE_LICENSE(_license) MODULE_INFO(license, _license)
3.1.2.5 相关信息声明

内核模块信息声明是可选的,用于提供模块的额外信息,如许可证、作者、描述和别名。

bash 复制代码
#声明模块的许可证
MODULE_LICENSE("GPL v2");

#描述模块的作者信息
MODULE_AUTHOR("Your Name <your.email@example.com>");

#对模块进行简单的介绍
MODULE_DESCRIPTION("A simple example Linux module.");

#给模块设置一个别名
MODULE_ALIAS("example_module");

/$(uname -r) 用于动态地引用当前运行的内核版本对应的模块目录

3.1.3 实验准备

将驱动代码解压到内核源码同级目录

3.1.3.1 makefile 说明

内核模块是内核的一部分,但独立于内核源码。

编译时需在内核源码目录下进行,

使用 Kbuild 系统。需设置环境变量 ARCH 和 CROSS_COMPILE。

Kbuild 是 Linux 内核构建系统的核心,用于管理和自动化内核及其模块的编译过程。
Kbuild 系统支持内核模块的独立构建
menuconfig 用于生成 Kbuild系统的 .config文件

bash 复制代码
#模块 Makefile (位 于 linux_driver/module/hellomodule/Makefile)

# 内核源码目录
KERNEL_DIR=../../../kernel/

# 目标架构和交叉编译工具链
ARCH=arm64
CROSS_COMPILE=aarch64-linux-gnu-
export ARCH CROSS_COMPILE #导出环境变量

# 模块目标文件   源文件将被编译成目标文件,最后链接成.ko文件
obj-m := hellomodule.o

# 默认目标:编译模块
all:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules   
#告知内核目录的顶层makefile对当前模块源码执行 modules伪目标

#调用 MAKE 工具 
#-C 跳转到 内核目录
#M是内核编译系统的特殊变量,指定当前模块源码所在目录(CURDIR为当前工作目录)
#内核顶层Makefile的伪目标,用于生成modules 

# 清理目标:清理编译生成的文件
clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean
3.1.3.2 编译命令说明

在模块的目录 linux_driver/module/hellomodule 下

cpp 复制代码
make

编译成功后,目录下会生成名为"hellomodule.ko"的驱动模块文件。

3.1.4 程序运行结果

3.1.4.1 如何加载内核模块

lsmod 查看已加载的模块。

insmod 加载模块。modprobe 加载模块并检查依赖关系。deomod 生成.dep文件创建依赖关系。

rmmod 移除模块。

modinfo 显示在模块中定义的宏。

有些内核模块存在依赖关系,不能直接用insmod加载。

例如,在后续实验中,calculation.ko依赖于parametermodule.ko中的参数和函数,因此必须先手动加载 parametermodule.ko,再加载calculation.ko。

即存在依赖关系的模块需要按照依赖关系依次加载。

卸载时逆着依赖关系从外到内卸载。

modprobe

modprobe 和 insmod 都能加载内核模块,但 modprobe 还能检查并按顺序加载模块依赖。

使用前需运行 depmod -a 建立依赖关系,且模块需放在系统驱动模块文件夹中。

depmod

modprobe 通过 /lib/modules/$(uname -r)/modules.dep 文件了解模块依赖关系。

.dep 文件由 depmod 生成,记录模块间的依赖

depmod -a 会扫描 /lib/modules/$(uname -r) 目录下的所有内核模块(通常是 .ko 文件),并根据模块中的依赖信息生成 modules.dep 文件。

/lib/modules 目录按内核版本存放模块和配置文件。


执行 depmod -a 时,模块的依赖会被写入 modules.dep文件中

rmmod

rmmod 通过路径卸载内核模块,会触发模块的 _exit() 函数进行清理,但输出可能因 printk 等级未显示,可通过 dmesg 查看。

rmmod 不卸载依赖模块,需手动依次卸载,而 modprobe -r 可一键卸载模块及其依赖。

3.1.4.2 系统自动加载模块

先将.ko文件放入 /lib/modules/$(uname -r) 目录下,(该目录的子文件夹下也可)
然后执行 depmod -a 建立模块依赖关系


mm子系统是内存管理子系统,与内核高度耦合
在 etc/modules 文件中添加模块名,让模块开机自动加载
(需确保/lib/modules/(uname -a)下有.dep依赖)

重启开发板,lsmod 就能查看到我们的模块开机就被加载到内核里面了。

3.2 内核模块传参与符号共享实验

3.2.2 实验代码讲解

3.2.2.1 内核模块传参代码讲解

module_param(变量名,参数,文件权限) 函数 (内核源码 /include/linux/moduleparam.h)

cpp 复制代码
#define module_param(name, type, perm) \\
module_param_named(name, //变量名
                   name, //变量名的别名
                   type, //参数的类型
                   perm) //该文件的权限
#define module_param_array(name, type, nump, perm) \\
module_param_array_named(name, name, type, nump, perm)

#注意到上面 定义的宏是三个变量  而函数是4个变量
#其实是让 变量的名称 和 变量的别名 相同了

内核模块参数的文件权限不能设置可执行权限。

若强行设置 S_IXUGO,加载模块时会报错。
加载权限时提示权限无效

cpp 复制代码
#示例程序

// 定义一个整数参数 itype,默认值为 0,无读写权限
static int itype = 0;
module_param(itype, int, 0);

// 定义一个布尔参数 btype,默认值为 0,有读写权限
static bool btype = 0;
module_param(btype, bool, 0644);  //特殊 用户 组 其他

// 定义一个字节参数 ctype,默认值为 0,无读写权限
static char ctype = 0;
module_param(ctype, byte, 0);

// 定义一个字符指针参数 stype,默认值为 NULL,有读写权限
static char *stype = 0;
module_param(stype, charp, 0644);

// 模块初始化函数,打印参数值
static int __init param_init(void)
{
  printk(KERN_ALERT "param init!\n");
  printk(KERN_ALERT "itype=%d\n", itype);
  printk(KERN_ALERT "btype=%d\n", btype);
  printk(KERN_ALERT "ctype=%d\n", ctype);
  printk(KERN_ALERT "stype=%s\n", stype);
  return 0;
}
3.2.2.2 符号共享代码讲解

static 定义变量

module_param 声明和初始化模块参数

export 符号(变量或函数)到共享符号表

在 .h里面 通过 extern引用符号变量,声明函数

在 .c里面引用 .h 导入的符号变量和函数

cpp 复制代码
//parametermodule.c

static int itype = 0;
module_param(itype, int, 0);

EXPORT_SYMBOL(itype);  //这里导出了 itype参数

int my_add(int a, int b)
{
    return a + b;
}
EXPORT_SYMBOL(my_add);    //这里导出了 my_add函数

int my_sub(int a, int b)
{
    return a - b;
}
EXPORT_SYMBOL(my_sub);  //这里导出了 my_sub函数
cpp 复制代码
//calculation.h

#ifndef __CALCULATION_H__
#define __CALCULATION_H__

extern int itype;  //引用 itype参数

int my_add(int a, int b);
int my_sub(int a, int b);

#endif
cpp 复制代码
//calculation.c

#include "calculation.h"

static int __init calculation_init(void)
{
    printk(KERN_ALERT "calculation init!\n");

    # 这里使用了头文件引入的 参数和函数
    printk(KERN_ALERT "itype+1 = %d, itype-1 = %d\n", my_add(itype, 1), my_sub(itype, 1));
    return 0;
}

3.2.3 实验准备

3.2.3.1 makefile 说明
bash 复制代码
#模块 makefile

... 省略代码...
obj-m := parametermodule.o calculation.o  #只有obj-m不同
... 省略代码...
3.2.3.2 编译命令说明
bash 复制代码
#执行makefile来编译文件
make

编译成功后,目录下会生成名为"parametermodule.ko"和"calculation.ko"的驱动模块文件。

3.2.4 程序运行结果


/proc/kallsyms 就是内核符号表
可以找到我们通过 export导出的符号 /proc/kallsyms 就是内核符号表

第 4 章 字符设备驱动

本章主要介绍字符设备及其驱动的相关知识,内容包括:

Linux设备分类。

字符设备的抽象及设计思路。

字符设备相关概念和数据结构,如设备号、file_operations、file、inode等。

字符设备驱动程序框架,包括设备号管理、file_operations接口调用、open函数等知识。

设备驱动程序实验。

4.1 Linux 设备分类

字符设备

块设备

网络设备

4.2 字符设备抽象

在 Linux 内核中,字符设备通过 struct cdev 数据结构抽象为对象,记录设备信息和操作接口(如打开、读写、关闭)。

添加字符设备时,将 cdev 对象 注册到内核并创建设备节点文件。

通过虚拟文件系统访问该文件时,内核找到对应的 cdev 对象及其操作接口,从而控制设备。

虽然 C 语言没有面向对象的继承语法,但通过结构体嵌套可实现类似功能。这种抽象提取了设备共性,为上层提供了统一接口,简化了设备管理和操作。

在硬件层,通过查看原理图和芯片手册确定寄存器配置,将这些操作实现为 file_operations 结构体中的文件操作接口。

在驱动层,将该接口注册到内核,内核通过散列表记录主次设备号。

在文件系统层,创建文件绑定接口,应用程序通过操作文件来设置寄存器。

在 Linux 上开发驱动程序,主要是填充内核提供的框架,内核会根据这些框架内容正常工作。因此,在开始之前,需要先学习字符设备驱动的相关概念和数据结构。

4.3 相关概念及数据结构

在 Linux 中,设备用设备编号表示,主设备号区分设备类别,次设备号标识具体设备。

内核通过 cdev 结构体记录设备号。使用设备时,打开设备节点,通过其 inode 结构体和 file 结构体找到 file_operations 结构体,从而获取操作设备的具体方法。

inode结构体指向实际数据和文件信息相关数据,包括指向 file 结构体,

file 结构体再 指向 file_operations 结构体,得到与文件绑定的各种操作函数。

4.3.1 设备号

在 Linux 中,字符设备块设备 通过 /dev 目录下的特殊文件(设备文件)进行访问。

使用 ls -l 可以列出这些设备文件。

每个设备文件的第一列字符表示设备类型:

c 表示字符设备,b 表示块设备。

例如,

autofs 是字符设备(c),主设备号为 10,次设备号为 235;

loop0 是块设备(b),主设备号为 7,次设备号为 0。

loop0-loop3 共用主设备号 7,次设备号从 0 递增。

主设备号指向设备驱动程序,次设备号指向具体设备。

例如,I2C-0 和 I2C-1 共用同一驱动程序,但通过不同的次设备号区分。

4.3.1.1 内核设备编号的含义

在 Linux 内核中,dev_t 是一个 32 位的数,用于表示设备编号。

其中,高 12 位是主设备号,低 20 位是次设备号。

理论上,主设备号范围是 0-4095,次设备号范围是 0-1048575。

但实际中,主设备号被限制在 0-512(由宏 CHRDEV_MAJOR_MAX 定义)。

内核提供了宏 MAJOR 和 MINOR 来从设备号中提取主设备号和次设备号,

以及宏 MKDEV 来将主设备号和次设备号合成设备号。

主设备号可通过查阅内核源码的 Documentation/devices.txt 文件获取,

次设备号通常从 0 开始编号。

4.3.1.2 cdev 结构体

cdev 结构体是 cdev_map的链表节点。

包含有 内核对象、模块对象、文件操作结构体ops、链表节点结构体list、设备号dev_t

内核通过哈希表(由数组和链表组成)记录设备编号,利用数组查找快、链表增删高效且易拓展的优点。

哈希表以主设备号作为 cdev_map 编号,通过哈希函数 f(major)=major%255 计算数组下标,使链表节点均匀分布以提升查询效率。主设备号冲突时,以次设备号排序链表节点。

内核用 struct cdev 描述字符设备,

通过 struct kobj_map 类型的 cdev_map 散列表管理所有字符设备。
cdev_map存放带有 cdev属性的节点,来管理字符设备
hashmap管理cdev节点,cdev节点带有
设备内核对象、模块对象、文件操作函数指针、设备号等属性

cpp 复制代码
//cdev结构体示例

struct cdev {
    struct kobject kobj;       // 设备的内核对象
    struct module *owner;      // 字符设备所属驱动模块的指针
    const struct file_operations *ops; // 文件操作函数指针
    struct list_head list;     // 链表节点
    dev_t dev;                 // 设备号
    unsigned int count;        // 属于同一主设备号的次设备号的个数
} __randomize_layout;

4.3.2 设备节点

在 Linux 中,设备节点是通过 mknod 命令创建的文件,存放在 /dev 目录下。

这些设备文件是内核与用户层的连接枢纽,记录硬件设备的位置和信息。所有设备都以文件形式存在,通过标准化的文件操作调用进行访问,这些调用由驱动程序映射到硬件的特有操作。

4.3.3 数据结构

在驱动开发中,需了解三个重要内核数据结构:

文件操作结构体(file_operations)、

文件描述结构体(struct file)和

inode结构体。

当一个文件被打开时,会创建一个 File 结构体

private_data fop inode

File 结构体的 FileOperations 结构体 指针成员,指向文件的操作函数。

File 结构体的 Inode 结构体 成员,指向文件的 inode 结构体。

设备号
应用层open

VFS根据目录项找到 Inode节点,

cdev_map 中根据 inode节点的设备号找到字符设备的 cdev

创建 file对象 ,绑定 cdev的 ops到 file对象的 ops上,

调用 ops的open方法(chrdev_open)执行设备初始化

4.3.3.1 file_operations 结构体

file_operations 是连接驱动程序和系统调用的关键数据结构,其成员对应系统调用,通过读取函数指针并调用相应函数,完成驱动程序的工作。

cpp 复制代码
struct file_operations {
    struct module *owner; // 所属模块
    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 *); // 写入
    ssize_t (*read_iter)(struct kiocb *, struct iov_iter *); // 迭代读取
    ssize_t (*write_iter)(struct kiocb *, struct iov_iter *); // 迭代写入
    int (*iterate)(struct file *, struct dir_context *); // 目录迭代
    int (*iterate_shared)(struct file *, struct dir_context *); // 共享目录迭代
    __poll_t (*poll)(struct file *, struct poll_table_struct *); // 轮询
    long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long); // 非锁定IO控制
    long (*compat_ioctl)(struct file *, unsigned int, unsigned long); // 兼容IO控制
    int (*mmap)(struct file *, struct vm_area_struct *); // 内存映射
    unsigned long mmap_supported_flags; // 支持的内存映射标志
    int (*open)(struct inode *, struct file *); // 打开文件
    int (*flush)(struct file *, fl_owner_t id); // 刷新
    int (*release)(struct inode *, struct file *); // 释放文件
    int (*fsync)(struct file *, loff_t, loff_t, int datasync); // 文件同步
    int (*fasync)(int, struct file *, int); // 异步通知
    int (*lock)(struct file *, int, struct file_lock *); // 文件锁定
    ssize_t (*sendpage)(struct file *, struct page *, int, size_t, loff_t *, int); // 发送页面
    unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, int); // 获取未映射区域
    int (*check_flags)(int); // 检查标志
    int (*flock)(struct file *, int, struct file_lock *); // 文件锁
    ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int); // 拼接写入
    ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int); // 拼接读取
    int (*setlease)(struct file *, long, struct file_lock **, void **); // 设置租约
    long (*fallocate)(struct file *, int mode, loff_t offset, loff_t len); // 分配文件空间
    void (*show_finfo)(struct seq_file *m, struct file *f); // 显示文件信息
#ifdef CONFIG_MMU
    unsigned (*mmap_capabilities)(struct file *); // 内存映射能力
#endif
};

在Linux系统中,I/O设备访问通过设备驱动程序提供的入口点进行,

这些入口点由 file_operations结构体定义,并在 include/linux/fs.h 中声明。每个成员指向实现特定操作的函数,不支持的操作可设为NULL,具体行为依函数而异。

通常称 file_operations结构或指针为 fops。

cpp 复制代码
//file_operations 结构体 (内核源码/include/linux/fs.h)

struct file_operations {
    struct module *owner;       // 驱动模块的指针
    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 *); // 写入数据
    long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long); // 非锁定的IO控制
    int (*open)(struct inode *, struct file *); // 打开文件
    int (*release)(struct inode *, struct file *); // 释放文件
};

read函数来源于 copy_to_user
write来源于 copy_from_user

cpp 复制代码
//copy_to_user 和 copy_from_user 函数
//(内核源码/include/asm-generic/uaccess.h)

//从用户空间拷贝
static inline long copy_from_user(void *to, //目标地址
                   const void __user *from, //源地址
                          unsigned long n); //字节数

//拷贝到用户空间
static inline long copy_to_user(void __user *to, const void *from, unsigned long n);
4.3.3.2 file 结构体

内核通过 file结构体表示每个打开的文件。

每次文件被打开,内核创建一个file结构体实例,并将文件操作函数赋给其f_op成员。

所有文件实例关闭后,该结构体被释放。

cpp 复制代码
//文件结构体
    //文件操作结构体
    //私有数据

struct file {
    /* ... */
    const struct file_operations *f_op; /* 指向文件操作的指针,用于驱动程序 */
    void *private_data;                 /* 私有数据指针 */
    stuct inode;                        /* inode结构体 */
    /* ... */
};
4.3.3.3 inode 结构体

VFS inode 是 Linux 文件系统的基本单位,包含文件的元数据如权限、时间戳等。

每个文件由一个 inode 表示,与表示打开文件的 file 结构不同。

多个 file 结构可指向同一 inode。

inode 结构体中,对驱动开发重要的字段有: dev_t i_rdev:设备号,标识设备文件。

struct block_device *i_bdev:指向块设备的指针,当 inode指向块设备文件时使用。

struct cdev *i_cdev:指向字符设备的指针,当 inode 指向字符设备文件时使用。

cpp 复制代码
struct inode {
    dev_t i_rdev;   //设备号
    /* ... */
    union {
        struct pipe_inode_info *i_pipe; /* 内核管道 */
        struct block_device *i_bdev; /* 块设备 */
        struct cdev *i_cdev; /* 字符设备 */
        char *i_link;
        unsigned i_dir_seq;
    };
    /* ... */
};

4.4 字符设备驱动程序框架

字符设备的驱动程序框架
分配设备号 cdev_alloc()、
cdev初始化 cdev_init()、
cdev设备注册到cdev_map cdev_add()

创建字符设备时,需先获取设备号,可通过静态或动态分配。

获取设备号后,实现 file_operations并保存至 cdev结构体,初始化 cdev。

接着,通过 cdev_add()注册 cdev。

最后,通过 mknod命令在 /dev创建设备节点以供后续调用 file_operations接口。

注销设备时,需要执行以下步骤:

释放内核中的 cdev结构体,归还设备号,并删除设备节点。

在设备操作实现中,open函数的作用值得关注。

4.4.1 驱动初始化和注销

4.4.1.1 设备号的申请和归还
定义字符设备

Linux内核提供两种方式定义字符设备:

静态分配:

直接定义 struct cdev变量。

动态分配:

使用 cdev_alloc() 函数获取 struct cdev指针。

cpp 复制代码
//第一种方式  直接定义struct cdev结构体变量
static struct cdev chrdev;

//第二种方式 通过 cdev_alloc()获取cdev结构体变量
struct cdev *cdev_alloc(void);
删除字符设备
cpp 复制代码
//从内核移除设备
void cdev_del(struct cdev *p)
申请设备号

设备号申请:

静态:register_chrdev_region(),指定起始设备号、数量和设备名。

动态:

alloc_chrdev_region(),自动分配设备号。

cpp 复制代码
//静态申请设备号
int register_chrdev_region(dev_t from,  //设备号.指定字符设备的起始设备号.如果已被注册会失败
                       unsigned count, //要申请的设备号个数
                       const char *name) //设备名称.可在/proc/devices中看到该设备

//动态申请设备号
int alloc_chrdev_region(dev_t *dev, //设备号
                unsigned baseminor, //次设备号的起始值.通常为0
                    unsigned count, //个数
                  const char *name) //设备名称.可在/proc/devices中看到该设备
cpp 复制代码
//返回主设备号.该方式不建议使用
static inline int register_chrdev(
    unsigned int major,    //要申请的主设备号
    const char *name,      //字符设备的名称
    const struct file_operations *fops) //操作函数接口
{
        return __register_chrdev(major, 0, 256, name, fops);
}

//使用 register_chrdev 函数向内核申请设备号,
//同一类字符设备 (即主设备号相同),会在内核中申请了 256 个,
//通常情况下,我们不需要用到这么多个设备,这就造成了极大的资源浪费。
注销设备号

删除字符设备的时候,需要把申请的设备号还给内核。

cpp 复制代码
void unregister_chrdev_region(dev_t from,  //设备号
                          unsigned count)  //与申请的count相等
4.4.1.2 初始化 cdev

要将 file_operations结构体字符设备关联,使用内核提供的 cdev_init函数。

cpp 复制代码
void cdev_init(struct cdev *cdev,             //cdev结构体
        const struct file_operations *fops)   //文件操作结构体

4.4.2 设备注册和注销

cdev_add 函数用于将字符设备 添加到内核的 cdev_map 散列表中。

cpp 复制代码
//将字符设备添加到cdev_map中
int cdev_add(struct cdev *p,  //cdev字符设备指针
                  dev_t dev,  //设备号
             unsigned count)  //指定注册多少个设备
cpp 复制代码
//从cdev_map中移除字符设备
void cdev_del(struct cdev *p) //要删除的字符设备cdev

使用 cdev_del 删除设备后,设备不能再被打开,但已打开的设备文件描述符仍可继续使用其操作函数。

4.4.3 设备节点的创建和销毁

创建一个设备并将其注册到文件系统

cpp 复制代码
//创建设备并将其注册到文件系统
//成功时返回 struct device 结构体指针, 错误时返回 ERR_PTR().
struct device *device_create(struct class *class,    // 设备类指针
                            struct device *parent,  // 父设备指针
                            dev_t devt,            // 设备号
                            void *drvdata,         // 驱动数据
                            const char *fmt,       // 格式化字符串
                            ...);                  // 可变参数列表
cpp 复制代码
//删除使用device_create创建的设备
void device_destroy(struct class *class, //设备类指针
                             dev_t devt) //设备号

mknod 命令可创建设备节点,

用法为:

bash 复制代码
mknod 设备名 类型 主设备号 次设备号

#mkmod /dev/test c 2 0
#创建一个字符设备 /dev/test c代表字符设备 2主设备号 0次设备号

类型为 "p" 时主次设备号可省略,其余类型需指定。

主次设备号以 "0x" 或 "0X" 开头为十六进制,以 "0" 开头为八进制,其余为十进制。

可用类型有:

b 为有缓冲的区块特殊文件,

c、u 为无缓冲的字符特殊文件,(c 普通字符设备文件,u无缓冲的字符设备文件)

p 为先进先出(FIFO)特殊文件。

4.5 open 函数到底做了什么


目录项(dentry)是文件系统中路径名的表示,
每个目录项都包含一个 d_inode 指针,
指向对应的 inode 节点。
Linux 内核维护了一个目录项缓存(dcache),用于快速查找路径名对应的目录项。

应用层open

VFS根据目录项找到 Inode节点,

cdev_map 中根据 inode节点的设备号找到字符设备的 cdev

创建 file对象 ,绑定 cdev的 ops到 file对象的 ops上,

调用 ops的open方法(chrdev_open)执行设备初始化


应用层的open执行步骤

4.6 字符设备驱动程序实验

4.6.2 实验代码讲解

4.6.2.1 内核模块框架

编写字符设备驱动程序时,需要先构建基本的内核模块框架。

alloc_chrdev_region 给 dev_t 分配设备号

cdev_init 关联字符设备结构体cdev文件操作结构体 ops。

cdev_add 将 cdev添加进 cdev_map散列表。

cpp 复制代码
#define DEV_NAME "EmbedCharDev"
#define DEV_CNT (1)
#define BUFF_SIZE 128

// 定义字符设备的设备号
static dev_t devno;

// 定义字符设备结构体 chr_dev
static struct cdev chr_dev;

static int __init chrdev_init(void)
{
    int ret = 0;
    printk("chrdev init\n");

    // 第一步:采用动态分配的方式,获取设备编号,次设备号为 0,
    // 设备名称为 EmbedCharDev,可通过命令 cat /proc/devices 查看
    // DEV_CNT 为 1,当前只申请一个设备编号
    ret = alloc_chrdev_region(&devno, 0, DEV_CNT, DEV_NAME);
    if (ret < 0) {
        printk("fail to alloc devno\n");
        goto alloc_err;
    }

    // 第二步:关联字符设备结构体 cdev 与文件操作结构体 file_operations
    cdev_init(&chr_dev, &chr_dev_fops);      //关联cdev与fop

    // 第三步:添加设备至 cdev_map 散列表中
    ret = cdev_add(&chr_dev, devno, DEV_CNT); //添加cdev到cdev_map
    if (ret < 0) {
        printk("fail to add cdev\n");
        goto add_err;
    }

    return 0;

add_err:
    // 添加设备失败时,需要注销设备号
    unregister_chrdev_region(devno, DEV_CNT);
alloc_err:
    return ret;
}

module_init(chrdev_init); //注册模块初始化函数

static void __exit chrdev_exit(void)
{
    printk("chrdev exit\n");
    unregister_chrdev_region(devno, DEV_CNT);  //注销设备号
    cdev_del(&chr_dev);                        //从cdev_map注销设备
}
module_exit(chrdev_exit); //注册模块卸载函数
4.6.2.2 文件操作方式的实现

文件操作结构体(file_operations)

cpp 复制代码
#define BUFF_SIZE 128                            //128字节的缓冲区
static char vbuf[BUFF_SIZE];                    //自定义的内核缓冲区

/*字符设备文件的操作函数*/
static struct file_operations chr_dev_fops = {
    .owner = THIS_MODULE,
    .open = chr_dev_open,
    .release = chr_dev_release,
    .write = chr_dev_write,
    .read = chr_dev_read,
};

自定义的文件操作函数

cpp 复制代码
// 自定义的文件打开和释放函数chr_dev_open 和 chr_dev_release 函数
static int chr_dev_open(struct inode *inode, struct file *filp)
{
    printk("\nopen\n");
    return 0;
}

static int chr_dev_release(struct inode *inode, struct file *filp)
{
    printk("\nrelease\n");
    return 0;
}

//自定义的写函数
static ssize_t chr_dev_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos)
{
    unsigned long p = *ppos;
    int tmp = count;

    if (p > BUFF_SIZE)  //检查写位置是否超出缓存区大小
        return 0;

    if (tmp > BUFF_SIZE - p)
        tmp = BUFF_SIZE - p;

    //使用 copy_from_user 将数据从用户空间拷贝到内核缓冲区 vbuf,并更新文件位置 ppos。
    if (copy_from_user(vbuf + p, buf, tmp))
        return -EFAULT;

    *ppos += tmp;
    return tmp;
}

//自定义的读函数
static ssize_t chr_dev_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos)
{
    unsigned long p = *ppos;
    int tmp = count;

//检查读取位置 p 是否超出缓冲区大小,若超出则返回 0
    if (p >= BUFF_SIZE)
        return 0;

    if (tmp > BUFF_SIZE - p)
        tmp = BUFF_SIZE - p;

//使用 copy_to_user 将数据从内核缓冲区 vbuf 拷贝到用户空间,并更新文件位置 ppos
    if (copy_to_user(buf, vbuf + p, tmp))
        return -EFAULT;

    *ppos += tmp;
    return tmp;
}
4.6.2.3 简单测试程序

下面,我们开始编写应用程序,来读写我们的字符设备

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>

char *wbuf = "Hello World\n";
char rbuf[128];

int main(void)
{
    printf("EmbedCharDev test\n");

    // 打开文件
    int fd = open("/dev/chrdev", O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    // 写入数据
    if (write(fd, wbuf, strlen(wbuf)) < 0) {
        perror("write");
        close(fd);
        return -1;
    }

    // 写入完毕,关闭文件
    close(fd);

    // 再次打开文件
    fd = open("/dev/chrdev", O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    // 读取文件内容
    if (read(fd, rbuf, sizeof(rbuf)) < 0) {
        perror("read");
        close(fd);
        return -1;
    }

    // 打印读取的内容
    printf("The content: %s", rbuf);

    // 读取完毕,关闭文件
    close(fd);

    return 0;
}

4.6.3 实验准备

获取内核模块源码,将驱动代码解压到内核代码同级目录。

4.6.3.1 makefile 修改说明

makefile(位于../linux_driver/EmbedCharDev/CharDev/Makefile)

和驱动程序同级的驱动Makefile

bash 复制代码
# 定义内核目录
KERNEL_DIR = ../../kernel/

# 定义目标架构和交叉编译工具链
ARCH = arm64
CROSS_COMPILE = aarch64-linux-gnu-
export ARCH CROSS_COMPILE

# 定义模块目标和测试程序输出文件
obj-m := chrdev.o
out = chrdev_test

# 默认目标
all: 
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules    #生成脚本  .ko
	$(CROSS_COMPILE)gcc -o $(out) main.c            #编译main.c生成测试文件

    # -C 指定目标目录路径 -M指定源代码的makefile路径 modules代表构建目标是modules
    # 先会跳转到 -C路径执行顶层makefile 再会跳转到当前路径执行 驱动makefile
    # 这些参数被提供给 内核构建系统

# 清理目标
.PHONY: clean
clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean
	rm -f $(out)
4.6.3.2 编译命令说明
bash 复制代码
make

编译成功后,实验目录下会生成两个名为"chrdev.ko"驱动模块文件和"chrdev_test"测试程序。

4.6.4 程序运行结果

拷贝到开发板,加载驱动,查看 /proc/devices下的设备文件

bash 复制代码
sudo insmod chrdev.ko
cat /proc/devices

可以看到注册的字符设备的主设备号为244

bash 复制代码
#使用mknod命令来创建一个新的设备chrdev
mknod /dev/chrdev c 244 0


运行测试程序
也可以通过echo来执行write

bash 复制代码
rmmod chrdev.ko   #卸载模块
rm /dev/chrdev    #删除设备文件

为什么通过 insmod加载设备驱动后,
还需要通过 mknod创建设备节点,
什么是udev规则,udev规则常用于做什么
这里udev的规则 : KERNEL表示需要内核识别的设备名称,NAME表示需要创建的设备节点名称

这里udev的规则 :

KERNEL表示需要内核识别的设备名称,NAME表示需要创建的设备节点名称

4.7 一个驱动支持多个设备

在 Linux 内核中,主设备号标识设备驱动程序,次设备号区分同类设备。

通过次设备号和 file 结构体的 private_data 成员,一个驱动程序可控制多种功能不同的设备。

open 函数首次执行时,可利用 private_data 控制底层硬件。

4.7.2 实验代码讲解

4.7.2.1 实现方式一管理各种的数据缓冲区

在 open的时候,通过 MINOR(inode->devno)读取不同的子设备号,FILE 的私有数据获取不同子设备号的缓冲区,

在 write/read 的时候 通过 copy_from_user/ copy_to_user 将 用户缓冲区 和 不同的 内核设备缓冲区(其实就是我们自己驱动里定义的buf) 进行数据读取就可以。

本章示例代码通过修改驱动程序,使一个驱动程序能够管理两个设备,每个设备各自管理自己的数据缓冲区。以下是代码修改的核心内容:

cpp 复制代码
#define DEV_NAME "EmbedCharDev"
#define DEV_CNT (2) // 设备数量
#define BUFF_SIZE 128 // 数据缓冲区大小

// 定义字符设备的设备号
static dev_t devno;

// 定义字符设备结构体 chr_dev
static struct cdev chr_dev;

// 数据缓冲区
static char vbuf1[BUFF_SIZE]; // 第一个设备的数据缓冲区
static char vbuf2[BUFF_SIZE]; // 第二个设备的数据缓冲区
cpp 复制代码
static int chr_dev_open(struct inode *inode, struct file *filp)
{
    printk("\nopen\n");

    //获取设备的次设备号  inode 结构体中,对于设备文件的设备号会被保存到其成员 i_rdev 中
    switch (MINOR(inode->i_rdev)) {
        case 0:
            filp->private_data = vbuf1;  //通过file的private_data读取自定义缓冲区数据
            break;
        case 1:
            filp->private_data = vbuf2;
            break;
    }
    return 0;
}
cpp 复制代码
static ssize_t chr_dev_write(struct file *filp,  //FILE
                        const char __user *buf,  //用户空间缓冲区指针
                                  size_t count,  //用户要写入的数据字节数
                                  loff_t *ppos)  //文件偏移量指针,表示当前读写位置
{
    unsigned long p = *ppos;
    int ret;
    char *vbuf = filp->private_data; //根据打开文件的private_data的不同,
                                     //写入时写入的地址也是不同的
    int tmp = count;

    if (p > BUFF_SIZE)
        return 0;

    if (tmp > BUFF_SIZE - p)
        tmp = BUFF_SIZE - p;

    ret = copy_from_user(vbuf + p,  //目标地址
                              buf,  //用户空间地址
                             tmp);  //字节数
    if (ret == 0) {
        *ppos += tmp;
        return tmp;
    } else {
        return -EFAULT;
    }
}
cpp 复制代码
static ssize_t chr_dev_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos)
{
    unsigned long p = *ppos;
    int ret;
    int tmp = count;
    char *vbuf = filp->private_data; //open时已经让private_data根据设备号指向了不同的缓冲区

    if (p >= BUFF_SIZE)
        return 0;

    if (tmp > BUFF_SIZE - p)
        tmp = BUFF_SIZE - p;

    ret = copy_to_user(buf, vbuf + p, tmp);
    if (ret == 0) {
        *ppos += tmp;
        return tmp;
    } else {
        return -EFAULT;
    }
}
4.7.2.2 实现方式二 i_cdev 变量

把 cdev和 buf封装成一个结构体,

每个设备用一个 封装结构体来定义。

在 open的时候通过 container_of获取我们自定义的结构体成员,

得到私有 buf数据,再通过 copy_from_user去写或者 copy_to_user去读操作

cpp 复制代码
/* 虚拟字符设备 */
struct chr_dev {
    struct cdev dev;
    char vbuf[BUFF_SIZE];
};

// 字符设备 1
static struct chr_dev vcdev1;

// 字符设备 2
static struct chr_dev vcdev2;

以上代码中定义了一个新的结构体 struct chr_dev,它有两个结构体成员:

字符设备结构体 dev 以及设备对应的数据缓冲区。

使用新的结构体类型 struct chr_dev 定义两个虚拟设备 vcdev1 以及 vcdev2。

cpp 复制代码
static int __init chrdev_init(void)
{
    int ret;
    printk("4 chrdev init\n");

    ret = alloc_chrdev_region(&devno, 0, DEV_CNT, DEV_NAME);
    if (ret < 0)
        goto alloc_err;

    // 关联第一个设备:vdev1
    cdev_init(&vcdev1.dev, &chr_dev_fops);
    ret = cdev_add(&vcdev1.dev, devno + 0, 1);
    if (ret < 0) {
        printk("fail to add vcdev1 ");
        goto add_err1;
    }

    // 关联第二个设备:vdev2
    cdev_init(&vcdev2.dev, &chr_dev_fops);
    ret = cdev_add(&vcdev2.dev, devno + 1, 1);
    if (ret < 0) {
        printk("fail to add vcdev2 ");
        goto add_err2;
    }

    return 0;

add_err2:
    cdev_del(&vcdev1.dev);    //添加第二个设备失败时,移除已经成功添加的第一个设备
add_err1:
    unregister_chrdev_region(devno, DEV_CNT); //添加第一个设备失败时,注销设备号
alloc_err:
    return ret;
}
cpp 复制代码
static void __exit chrdev_exit(void)
{
    printk("chrdev exit\n"); 
    unregister_chrdev_region(devno, DEV_CNT); //注销申请到的设备号
    cdev_del(&vcdev1.dev);                    //从map移除cdev
    cdev_del(&vcdev2.dev);
}
cpp 复制代码
static int chr_dev_open(struct inode *inode, struct file *filp)
{
    printk("open\n");

    /*这一步使得 file->private_data指向了我们自定义的chr_dev结构体
        由于初始化时不同设备绑定的 chr_dev的cdev不同,因此得到的是各自的
    */
                         //根据结构体成员的地址,得到结构体的地址
    filp->private_data = container_of(inode->i_cdev, //结构体成员的地址
                                     struct chr_dev, //结构体成员的类型
                                               dev); //结构体中的成员名称
    return 0;
}

static int chr_dev_release(struct inode *inode, struct file *filp)
{
    printk("release\n");
    return 0;
}

container_of 宏是根据一个结构体成员的地址,找到包含该成员的整个结构体的地址。

原理是通过已知类型 type 的成员 member 的地址 ptr,计算出结构体 type 的首地址。

type 的首地址 = ptr - size ,需要注意的是它们的大小都是以字节为单位计算的。

cpp 复制代码
static ssize_t chr_dev_write(struct file *filp, 
                        const char __user *buf,
                                  size_t count,
                                  loff_t *ppos)
{
    unsigned long p = *ppos;
    int ret;
    // 获取文件的私有数据
    struct chr_dev *dev = filp->private_data;
    char *vbuf = dev->vbuf;
    int tmp = count;

    if (p > BUFF_SIZE)
        return 0;

    if (tmp > BUFF_SIZE - p)
        tmp = BUFF_SIZE - p;

    ret = copy_from_user(vbuf, //to
                          buf, //from
                         tmp);
    *ppos += tmp;

    return tmp;
}

读函数也基本一致,主要就是在 open的时候通过 container_of获取我们自定义的结构体成员,得到私有 buf数据,再通过 copy_from_user去写或者 copy_to_user去读操作。

4.7.3 实验准备

4.7.3.1 makefile说明

与上一小节相同,故省略。

4.7.3.2 编译命令说明
cpp 复制代码
make

4.7.4 程序运行结果

加载、
创建字符设备

注意这里,

加载了一个驱动,

手动创建了两个字符设备。

当读写字符设备的时候,其实是根据 inode从 cdev_map找到 cdev,再找到 fop操作。
测试写入函数
测试读函数

第 5 章 字符设备驱动------点亮 LED 灯实验

通过学习字符设备章节,我们掌握了字符设备驱动程序的基本框架,

包括

申请和释放设备号、

添加和注销设备、

初始化和管理 cdev 结构体,

以及通过 cdev_init 函数建立 cdev 和 file_operations 的关联。

本节我们将学习如何在 Linux 环境下通过驱动程序来控制开发板上的 LED 灯。我们将探讨直接操作寄存器与通过驱动程序控制 LED 的区别。

5.1 设备驱动的作用与本质

5.1.1 驱动的作用

设备驱动直接与硬件交互,负责读写寄存器、处理中断、DMA 通信以及内存映射等,以实现设备功能。

无操作系统时,工程师可自定义接口(如 LightOn()、LightOff())。

有操作系统时,驱动架构由操作系统定义,工程师需按此设计驱动(如 file_operations 接口),以确保驱动能整合到内核中。

5.1.2 有无操作系统的区别

5.2 内存管理单元 MMU

在 Linux 环境下,直接访问物理内存非常危险,可能导致错误或系统崩溃。

为解决此问题,内核引入了 MMU(内存管理单元)。

5.2.1 MMU 的功能

MMU(内存管理单元)提供虚拟内存空间的抽象,使程序中的变量地址成为虚拟地址。

处理器访问这些地址时,MMU 将虚拟地址转为实际的物理地址,然后处理器操作物理地址。

MMU 是硬件,负责管理内存并保护内存,防止进程间相互干扰。

它允许使用连续的虚拟地址访问物理内存中分散的区域。

MMU 的主要功能:

保护内存:为内存块设置读、写和执行权限,存储在页表中。MMU 检查 CPU 的模式和权限,若不匹配则产生异常,防止恶意修改。

提供内存空间抽象:实现虚拟地址到物理地址的转换,使 CPU 可以运行在比物理内存大的虚拟内存中,支持大型应用程序。 在没有 MMU 的情况下,CPU 直接将地址输出到芯片引脚,这些地址称为物理地址。
没有MMU时对内存条的读写,是直接通过物理地址操作

物理地址是内存单元的实际地址,如8G内存条的第一个单元地址为0x0000,第六个单元地址为0x0005。

当CPU启用MMU时,发出的地址称为虚拟地址,MMU会根据页表地址寄存器查找页表条目,将虚拟地址翻译为实际的物理地址。
MMU通过页表将虚拟地址翻译为物理地址

对于32位处理器,虚拟地址空间为4G(2^32)。启用MMU后,CPU发出的地址均为虚拟地址。MMU通过页表地址寄存器找到页表,实现虚拟地址到物理地址的映射。

ioremap 用于将用户空间地址映射到设备内存,使程序在指定虚拟地址范围内的读写操作直接访问设备寄存器。

cpp 复制代码
//ioremap 是 Linux 内核中用于将设备的物理地址映射到内核虚拟地址空间的函数。
void __iomem *ioremap(phys_addr_t offset, //设备的物理起始地址
                            size_t size); //区域大小

//void __iomem 用于表示通过 ioremap 映射后的内存地址,是一个带有特殊属性的指针类型。

5.2.2 TLB 的作用

TLB(Translation Lookaside Buffer) 是MMU中用于加速地址转换的缓存

在地址转换时,MMU先查询TLB。若TLB中有对应的虚拟地址描述符,则直接进行地址转换和权限检查;若没有,则访问页表获取描述符后填入TLB。

TLB满了会用round-robin算法替换条目。MMU很复杂,初学者只需了解其地址转换功能。在Linux中,启用MMU后,读写物理地址需通过物理地址到虚拟地址的转换函数。ioremap

5.3 地址转换函数

地址转换函数物理地址到虚拟地址的转换函数。

包括 ioremap() 地址映射和取消地址映射 iounmap() 函数。

5.3.1 ioremap 函数

cpp 复制代码
//地址映射函数
void __iomem *ioremap(phys_addr_t paddr, //被映射的io物理地址
                     unsigned long size) //字节大小
#define ioremap ioremap

5.3.2 iounmap 函数

cpp 复制代码
void iounmap(void *addr) //需要取消映射的虚拟地址起始地址
#define iounmap iounmap

5.4 点亮 LED 灯实验

现在开始写 LED驱动代码。需先定义含寄存器地址的 LED字符设备结构体。

接着实现模块加载和卸载函数:加载时注册设备,卸载时释放资源。

最后实现 file_operations结构体及open、write、read等接口。

5.4.1 实验说明

本节实验使用到 lubancat_RK 系列板上的系统 LED 灯。

5.4.1.2 硬件原理图分析

LubanCat 系列板卡,引出的 led 引脚可能不同,可打开相应板卡的原理图来查看硬件连接,根据不同的引脚查询相应的寄存器地址。
这里以Lubancat2为例

RK3568 的复用型引脚用 GPIO 编号,分为 5 大组(GPIO0~4),

每大组分 4 个小组(A、B、C、D),

每个小组 8 个引脚(0~7)。

例如,GPIO0_C7 是 GPIO0 大组,第 3 小组,第 8 个引脚。

5.4.1.3 对 LED 灯进行寄存器配置
5.4.1.3.1 引脚复用


查GPIO可以复用为哪些功能

查询 Rockchip_RK3568_TRM_Part1 手册可知,
GPIO0 组复用功能是在 PMU_GRF 寄存器,
和复用相关的总共 8 个寄存器

接着查询 Rockchip_RK3568_TRM_Part1 手册,

PMU_GRF_GPIO0C_IOMUX_H 寄存器,
PMU_GRF_GPIO0C_IOMUX_H 寄存器的详细描述

PMU_GRF_GPIO0C_IOMUX_H 寄存器的高 16 位为使能位,控制低 16 位的写操作。低 16 位中,每 4 位对应一个引脚的复用功能,实际使用 3 位。

3'h0 表示一个 3 位宽的十六进制数值,其值为 0。该写法常见于寄存器文档。

例如,GPIO0_C7 的复用功能由 14:12 位控制,默认为 0(GPIO 功能)。

若需设置为 PWM 功能,需将 14:12 位设为 010,并将第 29 位(第 13 位的使能位)设为 1,以允许写操作。

5.4.1.3.2 引脚电平

通过配置 GPIO的 General Register Files (GRF)寄存器,可以控制引脚的输入输出模式、电平状态、中断和抖动等特性。

这些设置影响引脚的驱动能力和电气属性。

具体配置需参考Rockchip RK35xx技术参考手册(TRM)。

• GPIO_SWPORT_DR_L:低位引脚数据寄存器,设置高低电平。

• GPIO_SWPORT_DR_H:高位引脚数据寄存器,设置高低电平。
GPIO的数据寄存器,高16位使能低16位,低16位控制高低电平

GPIO_SWPORT_DR_L 寄存器的高16位控制低16位的写使能,低16位控制GPIO电平。

GPIO_SWPORT_DR_H 寄存器同理。

如果要控制 GPIO0_C7 的高 低电平那么就要写 GPIO_SWPORT_DR_H 寄存器,

因为 C7 属于 GPIO0 中 A-D 组总计 32 个引脚中高的 16 引脚范围,

所以需要将 GPIO_SWPORT_DR_H 寄存器的第 7bit 位和 7+16bit 位置 1。(使能和置位)

需将 GPIO_SWPORT_DR_H 的第7位和第23位(7+16)置1。

5.4.1.3.3 输入输出模式

• GPIO_SWPORT_DDR_L:低位引脚数据方向寄存器,控制输入或者输出。

• GPIO_SWPORT_DDR_H:高位引脚数据方向寄存器,控制输入或者输出。
依然是两个32位寄存器,
高16位使能,低16位控制输入/输出

5.4.1.3.4 引脚上下拉


PMU_GRF_GPIO0X_P 是相应 GPIO 上拉或者下拉的控制寄存器

以GPIO0-C7进行说明,

高16位使能,第15:14控制上下拉。

要将 GPIO0-C7设置为上拉,则将31设置为1来使能,并将 15:14位控制设置为 01。

5.4.2 代码讲解

5.4.2.1 定义 GPIO 寄存器物理地址

以GPIO0_C7为例,
需要先确定 GPIO0的基地址,其余寄存器均在基地址上进行偏移
SWPORT_DR(高低电平)和SWPORT_DDR(方向)的偏移量

cpp 复制代码
#define GPIO0_BASE (0xFDD60000)

// 一个寄存器 32 位,其中高 16 位都是写使能位,控制低 16 位的写使能;低 16 位对应 16 个引脚,控制引脚的输出电平
#define GPIO0_DR_L (GPIO0_BASE + 0x0000) // GPIO0 的低十六位引脚的数据寄存器地址
#define GPIO0_DR_H (GPIO0_BASE + 0x0004) // GPIO0 的高十六位引脚的数据寄存器地址

// 一个寄存器 32 位,其中高 16 位都是写使能位,控制低 16 位的写使能;低 16 位对应 16 个引脚,控制引脚的输入输出模式
#define GPIO0_DDR_L (GPIO0_BASE + 0x0008) // GPIO0 的低十六位引脚的数据方向寄存器地址
#define GPIO0_DDR_H (GPIO0_BASE + 0x000C) // GPIO0 的高十六位引脚的数据方向寄存器地址

代码中使用宏定义,定义出了 LED 灯使用到的 GPIO 资源物理地址,

在后面需要将这些寄存器物 理地址映射到虚拟地址上,供配置使用,

其余复用、上下拉等寄存器保持默认即可。

5.4.2.2 编写 LED 字符设备结构体且初始化
cpp 复制代码
struct led_chrdev {
    struct cdev dev;                     // 字符设备结构体
    unsigned int __iomem *va_dr;         // 数据寄存器虚拟地址保存变量
    unsigned int __iomem *va_ddr;        // 数据方向寄存器虚拟地址保存变量

    unsigned int led_pin;                // 引脚
};

static struct led_chrdev led_cdev[DEV_CNT] = {
    {
        .led_pin = 7,                    // 初始化第一个设备的引脚为7
    },
};

定义了一个 led 灯的结构体,并且定义且初始化了一个 RGB 灯的结构体数组。

5.4.2.3 内核 RGB 模块的加载和卸载函数
cpp 复制代码
/*驱动模块的驱动初始化函数*/
static __init int led_chrdev_init(void)
{
    int i = 0;
    dev_t cur_dev;
    unsigned int val = 0;

    printk("led_chrdev init (lubancat2 GPIO0_C7)\n");

    // 映射数据寄存器物理地址到虚拟地址,GPIO0_C7 需要设置 GPIO0_DR_H
    led_cdev[0].va_dr = ioremap(GPIO0_DR_H, 4);

    // 映射数据方向寄存器物理地址到虚拟地址,GPIO0_C7 需要设置 GPIO0_DDR_H
    led_cdev[0].va_ddr = ioremap(GPIO0_DDR_H, 4);

    // 分配字符设备区域
    alloc_chrdev_region(&devno, 0, DEV_CNT, DEV_NAME);

    // 创建字符设备类    THIS_MODULE宏表示当前模块
    led_chrdev_class = class_create(THIS_MODULE, //当前模块的所有者,如果代码在内核而不是模块中运行可用null
                                   "led_chrdev"); //字符设备类的名称.用于在/sys/class/目录下创建一个对应的目录

    // 初始化字符设备并添加到系统
    for (; i < DEV_CNT; i++) {
        //绑定cdev和fops
        cdev_init(&led_cdev[i].dev, &led_chrdev_fops);
        led_cdev[i].dev.owner = THIS_MODULE;

        //合并主设备号和次设备号,得到完整的设备号
        cur_dev = MKDEV(MAJOR(devno), MINOR(devno) + i);

        //将字符设备添加到字符设备散列表
        cdev_add(&led_cdev[i].dev, cur_dev, 1);

        //创建设备节点
        device_create(led_chrdev_class,  //设备所属的类
                                  NULL,  //父设备
                               cur_dev,  //设备号
                                  NULL,  //私有数据
                         DEV_NAME "%d",  //宏 格式化参数 设备名称前缀 格式化参数
                                    i);  //格式化参数填充
    }

    return 0;
}

module_init(led_chrdev_init);

static __exit void led_chrdev_exit(void)
{
    int i;
    dev_t cur_dev;

    printk("led chrdev exit (lubancat2 GPIO0_C7)\n");

    // 释放数据寄存器虚拟地址
    for (i = 0; i < DEV_CNT; i++) {
        iounmap(led_cdev[i].va_dr);
        iounmap(led_cdev[i].va_ddr);
    }

    // 删除字符设备并注销字符设备区域
    for (i = 0; i < DEV_CNT; i++) {
        //将主、次设备号合成设备号
        cur_dev = MKDEV(MAJOR(devno), MINOR(devno) + i);

        //删除设备节点
        device_destroy(led_chrdev_class, cur_dev);

        cdev_del(&led_cdev[i].dev);
    }

    unregister_chrdev_region(devno, DEV_CNT);
    class_destroy(led_chrdev_class);
}
module_exit(led_chrdev_exit);
5.4.2.4 file_operations 结构体成员函数的实现
cpp 复制代码
// fops的open在系统调用open的情况下被调用

static int led_chrdev_open(struct inode *inode, struct file *filp)
{
    unsigned int val = 0;
    struct led_chrdev *led_cdev;

    // 获取指向 led_chrdev 结构体的指针
    led_cdev = container_of(inode->i_cdev, struct led_chrdev, dev);
    filp->private_data = led_cdev; //把自定义的结构体给到 file的私有数据

    printk(KERN_INFO "LED device opened\n");

    // 设置输出模式
    val = ioread32(led_cdev->va_ddr); // 读取数据方向寄存器,地址是映射过的虚拟地址
    val |= ((unsigned int)0x1 << (led_cdev->led_pin + 16)); // 设置写使能位
    val |= ((unsigned int)0x1 << (led_cdev->led_pin));      // 设置为输出模式
    iowrite32(val, led_cdev->va_ddr);                       // 写回数据方向寄存器

    // 输出高电平
    val = ioread32(led_cdev->va_dr); // 读取数据寄存器,地址是映射过的虚拟地址
    val |= ((unsigned int)0x1 << (led_cdev->led_pin + 16)); // 设置写使能位
    val |= ((unsigned int)0x1 << (led_cdev->led_pin));      // 设置为高电平
    iowrite32(val, led_cdev->va_dr);                        // 写回数据寄存器

    return 0;
}

在 Linux 内核中,ioread32 和 iowrite32 函数用于访问内存映射的 I/O 寄存器。
当最后一个用户进程调用 close() 时,内核会调用驱动程序的 release() 函数。

之前通过 ioremap() 映射的物理地址到虚拟地址空间,在使用完毕后需用 iounmap() 释放,但此操作通常在驱动模块退出时完成,因此在 release() 函数中无需处理。

cpp 复制代码
static int led_chrdev_release(struct inode *inode, struct file *filp)
{
    return 0;
}

5.4.3 实验准备

若出现"Permission denied"等字样,需检查用户权限。

操作硬件外设通常需 root 权限,解决方法是使用 sudo 或以 root 用户运行程序。

5.4.3.1 LED 驱动 Makefile
bash 复制代码
KERNEL_DIR=../../kernel/
ARCH=arm64

CROSS_COMPILE=aarch64-linux-gnu-
export ARCH CROSS_COMPILE

obj-m := led_cdev.o

all:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules

.PHONY: clean copy

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

Makefile 与前面的相差不大,只需将 obj-m 对象修改为 led_cdev.o

5.4.3.2 编译命令说明
bash 复制代码
make

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

5.4.4 程序运行结果

使用insmode加载驱动,由于驱动中使用了device_create创建设备节点,因此不需要mknode来手动创建节点。

如果不关闭设备树的Leds状态,系统自带的驱动可能与我们加载的驱动产生冲突,

第 6 章 Linux 的设备模型

linux驱动开发的固定模式
在内核源码的 drivers 中存放了大量的设备驱动代码

按步骤编写驱动代码虽简单,但将硬件信息写入驱动中会导致硬件改动时需重新修改代码,不合理。

Linux 引入设备驱动模型分层,将驱动分为设备和驱动两部分:

设备提供硬件资源,

驱动使用这些资源,

通过总线连接,构成合理架构。


bus 总线上挂载设备和设备驱动,
class 目录对全部设备进行分类
总线各自分别用一个 链表管理 设备和 驱动

总线管理两个链表,分别用于管理设备和驱动。

注册驱动时,驱动被插入驱动链表

注册设备时,设备被插入设备链表

插入时,

总线调用 bus_type 结构体中的 match 方法匹配设备和驱动(最简单的方式是对比名字)。

匹配成功时,

调用驱动的 probe 方法(通常用于获取设备资源,具体功能可自定义),

移除时,

调用 remove 方法。

这些方法需要开发者实现。

设备驱动模型是 Linux 内核的核心机制,与平台设备驱动、块设备驱动等密切相关。

sysfs 文件系统将内核设备驱动导出到用户空间,用户可通过访问 /sys 目录及其文件查看和控制驱动设备。

6.1 总线

总线是处理器和设备之间的通信桥梁,规定了同类设备的工作时序。

大多数设备通过总线通信。

例如,野火开发板的触摸芯片通过 I2C 总线连接,而鼠标、键盘等 HID 设备通过 USB 总线连接。这些设备的功能是将文字、字符、控制命令或采集的数据输入到计算机。

总线驱动管理两个链表:设备链表和驱动链表。

添加或移除设备(驱动)时,会在相应链表中增删节点,并进行驱动与设备的匹配,忽略已匹配的设备。

在内核中使用结构体 bus_type 来表示总线

cpp 复制代码
//内核中使用 结构体 bus_type表示总线
struct bus_type {
    const char *name;                  // 总线名称
    const struct attribute_group **bus_groups; // 总线属性组
    const struct attribute_group **dev_groups; // 设备属性组
    const struct attribute_group **drv_groups; // 驱动属性组

    int (*match)(struct device *dev, struct device_driver *drv); // 匹配设备和驱动
    int (*uevent)(struct device *dev, struct kobj_uevent_env *env); // 生成 uevent
    int (*probe)(struct device *dev); // 设备探测
    int (*remove)(struct device *dev); // 设备移除

    int (*suspend)(struct device *dev, pm_message_t state); // 设备挂起
    int (*resume)(struct device *dev); // 设备恢复

    const struct dev_pm_ops *pm; // 电源管理操作
    struct subsys_private *p;    // 私有数据
};

bus_register 和 bus_unregister 函数用于注册和注销总线。


/sys/bus目录

6.2 设备

概念

总线设备是通过设备树添加的,

I2C 设备通常在设备树(Device Tree)或板级支持包(BSP)中定义。

内核的 I2C 核心代码会解析设备树或板级支持包中的定义,自动调用 device_register 将 I2C 设备注册到内核的设备模型中。

cpp 复制代码
//假设你的 I2C 设备是一个温湿度传感器(如 DHT11),其 I2C 地址为 0x40,你可以这样配置:
&i2c1 {
    status = "okay";
    clock-frequency = <100000>;

    dht11@40 {
        compatible = "dht11";
        reg = <0x40>;
    };
};

驱动开发可以参考6.3

device结构体

在 Linux 驱动开发中,我们主要关注设备和驱动。

编写驱动是为了让设备正常工作。

系统中所有设备文件都以文件形式存在于 /sys/devices 目录中。

此外,/sys/dev 目录记录了所有设备节点,这些节点实际上是链接文件,最终指向 /sys/devices 下的设备文件。

在内核使用 device 结构体来描述我们的物理设备,

cpp 复制代码
struct device {
    const char *init_name;             // 设备名称,用于总线匹配
    struct device *parent;            // 父设备,设备呈树状结构
    struct bus_type *bus;             // 设备依赖的总线
    struct device_driver *driver;     // 当前绑定的驱动
    void *platform_data;              // 平台相关数据
    void *driver_data;                // 驱动相关数据
    struct device_node *of_node;      // 设备树中匹配的节点
    dev_t devt;                       // 设备号,标识设备
    struct class *class;              // 设备所属类别
    void (*release)(struct device *dev); // 设备注销时的回调函数
    const struct attribute_group **groups; // 设备属性组
    struct device_private *p;         // 私有数据结构指针
    // 其他成员...
};

内核也提供相关的 API 来注册和注销设备,

cpp 复制代码
//通知内核注册设备
int device_register(struct device *dev);   //失败返回负数
// device_register 函数将设备加入到设备链表中
// 在 /sys/devices/ 目录下创建设备目录
// 内核尝试将 xdev 与已注册的驱动进行匹配,如果匹配成功会调用probe
// 内核发送一个 kobject_uevent_env 事件,触发 udev 规则,创建 /dev/xdev 设备节点

//内核注销设备
void device_unregister(struct device *dev);

6.3 驱动

在内核中,使用 device_driver 结构体来描述我们的驱动。

cpp 复制代码
struct device_driver {
    const char *name;                     // 驱动名称
    struct bus_type *bus;                 // 驱动所属的总线类型

    struct module *owner;                 // 驱动所属的内核模块
    const char *mod_name;                 // 内置模块时使用的模块名称

    bool suppress_bind_attrs;             // 禁用通过 sysfs 进行 bind/unbind 操作

    const struct of_device_id *of_match_table; // 设备树匹配表
    const struct acpi_device_id *acpi_match_table; // ACPI 匹配表

    int (*probe)(struct device *dev);    // 设备探测函数
    int (*remove)(struct device *dev);   // 设备移除函数

    const struct attribute_group **groups; // 驱动属性组
    struct driver_private *p;             // 私有数据结构指针
};

内核提供了 driver_register 函数以及 driver_unregister 函数来注册/注销驱动,成功注册的驱动会记录在/sys/bus/<bus>/drivers 目录。

cpp 复制代码
//注册设备驱动
int driver_register(struct device_driver *drv);

//注销设备驱动
void driver_unregister(struct device_driver *drv);

用于将驱动添加到总线的驱动链表中,触发设备与驱动的匹配。
总线关联设备、驱动后的注册流程

系统启动之后会调用 buses_init 函数创建/sys/bus 文件目录,这部分系统在开机时已经帮我们准备好了,接下去就是通过总线注册函数 bus_register函数 进行总线注册,注册完总线后生成 /sys/bus/devices 文件夹和 /sys/bus/drivers 文件夹,最后分别通过 device_register 以及 driver_register 函数注册相对应的设备和驱动。

字符设备、通用设备、平台设备区别

字符设备\通用设备\平台设备的区别

cdev用于字符设备,device结构体用于通用设备。platform_device用于平台设备。

前面给出的字符设备的驱动例程,是通过内核的字符设备管理机制注册的,而不是通过某个硬件总线的驱动注册的。因此,前面的字符设备没有注册到任何具体的硬件总线。
如果需要将设备注册到某个硬件总线,例如 I2C 总线,你需要使用该总线的特定 API。
结构简单的设备(如LED、RTC时钟)无物理总线,内核不会为其创建驱动总线。

因此,Linux引入平台总线(platform bus)这一虚拟总线,管理无物理总线的设备(平台设备)及其驱动(平台驱动)。

cpp 复制代码
//IIC总线设备驱动代码示例

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

static int my_i2c_probe(struct i2c_client *client, const struct i2c_device_id *id)
{
    printk(KERN_INFO "I2C device probed\n");
    return 0;
}

static int my_i2c_remove(struct i2c_client *client)
{
    printk(KERN_INFO "I2C device removed\n");
    return 0;
}

static const struct i2c_device_id my_i2c_id[] = {
    { "my_i2c_device", 0 },
    { }
};
MODULE_DEVICE_TABLE(i2c, my_i2c_id);

static struct i2c_driver my_i2c_driver = {
    .driver = {
        .name = "my_i2c_device",
        .owner = THIS_MODULE,
    },
    .probe = my_i2c_probe,
    .remove = my_i2c_remove,
    .id_table = my_i2c_id,
};

static int __init my_i2c_init(void)
{
    return i2c_add_driver(&my_i2c_driver);
}

static void __exit my_i2c_exit(void)
{
    i2c_del_driver(&my_i2c_driver);
}

module_init(my_i2c_init);
module_exit(my_i2c_exit);

6.4 attribute 属性文件

在Linux内核中,/sys目录用于将内核对象(如设备、驱动等)的信息导出到用户空间。

当注册新的总线、设备或驱动时,内核会在/sys目录下创建对应的目录,

目录名通常为结构体(bus_type、device、device_driver)的 name成员。

/sys目录下的文件由 struct attribute 结构体描述,包含文件名(name)和权限(mode)。

cpp 复制代码
//内核源码/include/linux/sysfs.h

struct attribute {
    const char *name; //文件名
    umode_t mode;     //文件权限
};

为了简化管理,bus_type、device、device_driver 等结构体中包含 struct attribute_group,这是一个attribute文件的集合。

通过 attribute_group 可以批量注册 attribute,避免逐个注册。

cpp 复制代码
struct attribute_group {
    const char *name;               // 组名,用于创建子目录
    umode_t (*is_visible)(struct kobject *, 
                          struct attribute *, 
                                      int); // 回调函数,决定属性是否可见
    struct attribute **attrs;       // 普通属性数组
    struct bin_attribute **bin_attrs; // 二进制属性数组
};

6.4.1 设备属性文件

在单片机开发中,读取寄存器值通常需要修改代码并重新编译。

而在Linux内核开发中,频繁编译源码耗时费力。

为此,Linux提供了接口来注册和注销设备属性文件,允许用户直接在用户空间查询和修改属性,无需重新编译内核。

cpp 复制代码
struct device_attribute {
    struct attribute attr;          // 基础属性结构
    ssize_t (*show)(struct device *dev, struct device_attribute *attr, char *buf); // 读取属性的回调函数
    ssize_t (*store)(struct device *dev, struct device_attribute *attr, const char *buf, size_t count); // 写入属性的回调函数  
};

//定义设备属性的宏   dev_attr是固定前缀,##连接 _name变量
#define DEVICE_ATTR(_name, _mode, _show, _store) \
    struct device_attribute dev_attr_##_name = __ATTR(_name,  //属性文件名
                                                      _mode,  //文件权限,如S_IRUSR(读权限)、S_IWUSR(写权限)等
                                                      _show,  //读取属性时的回调函数,对应用户空间的cat命令。
                                                     _store)  //写入属性时的回调函数,对应用户空间的echo命令。
//##是预处理连接符

// 创建设备属性文件
extern int device_create_file(struct device *device, //指向struct device的指针,指定在哪个设备目录下创建文件
                const struct device_attribute *entry);//struct device_attribute的指针,定义了要创建的属性文件
// 移除设备属性文件
extern void device_remove_file(struct device *dev, 
                const struct device_attribute *attr); 

创建DEVICE_ATTR类型的变量

设备目录下创建属性文件

6.4.2 驱动属性文件

驱动属性文件与设备属性文件功能类似,都是在 /sys/总线|设备|驱动/ 目录下,创建设备模型的属性文件。

区别在于回调函数的参数。

cpp 复制代码
struct driver_attribute {
    struct attribute attr;          // 基础属性结构
    ssize_t (*show)(struct device_driver *driver, char *buf); // 读取属性的回调函数
    ssize_t (*store)(struct device_driver *driver, const char *buf, size_t count); // 写入属性的回调函数
};

#define DRIVER_ATTR_RW(_name) \
    struct driver_attribute driver_attr_##_name = __ATTR_RW(_name) // 定义可读写的驱动属性

#define DRIVER_ATTR_RO(_name) \
    struct driver_attribute driver_attr_##_name = __ATTR_RO(_name) // 定义只读的驱动属性

#define DRIVER_ATTR_WO(_name) \
    struct driver_attribute driver_attr_##_name = __ATTR_WO(_name) // 定义只写的驱动属性

extern int __must_check driver_create_file(struct device_driver *driver, const struct driver_attribute *attr); // 创建驱动属性文件
extern void driver_remove_file(struct device_driver *driver, const struct driver_attribute *attr); // 移除驱动属性文件

6.4.3 总线属性文件

Linux 也为总线通过了相应的函数接口,来注册和注销总线属性文件。

cpp 复制代码
struct bus_attribute {
    struct attribute attr;          // 基础属性结构
    ssize_t (*show)(struct bus_type *bus, char *buf); // 读取属性的回调函数
    ssize_t (*store)(struct bus_type *bus, const char *buf, size_t count); // 写入属性的回调函数
};

#define BUS_ATTR(_name, _mode, _show, _store) \
    struct bus_attribute bus_attr_##_name = __ATTR(_name, _mode, _show, _store) // 定义总线属性的宏

extern int __must_check bus_create_file(struct bus_type *, struct bus_attribute *); // 创建总线属性文件
extern void bus_remove_file(struct bus_type *, struct bus_attribute *); // 移除总线属性文件

使用 bus_create_file 函数,会在/sys/bus/<bus-name> 下创建对应的文件。

6.5 驱动设备模型代码编写和讲解

在设备模型框架下,开发设备驱动很简单:

分配并注册 struct device 变量到对应总线。

创建并注册 struct device_driver 变量。

驱动和设备匹配时,调用驱动的 probe、release 等回调函数。

实际编程中,通常在 device 和 device_driver 上加封装,如 platform_device 等。

6.5.1 编程思路

这里创建一个虚拟的总线 xbus,分别挂载了驱动 xdrv 以及设 备 xdev。

6.5.2 Makefile

makefile、xdev.c、xbus.c、xdrv.c 在同一目录下。

bash 复制代码
# 设置内核目录
KERNEL_DIR = ../../kernel/

# 设置目标架构和交叉编译器
ARCH = arm64
CROSS_COMPILE = aarch64-linux-gnu-
export ARCH CROSS_COMPILE

# 定义模块目标
obj-m := xdev.o xbus.o xdrv.o

# 构建目标
all:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules

# 清理目标
modules clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean

6.5.3 总线

6.5.3.1 定义新的总线
cpp 复制代码
// xbus.c

int xbus_match(struct device *dev, struct device_driver *drv)
{
    printk("%s-%s\n", __FILE__, __func__);  //__FILE__ 当前源文件名称
                                            //__func__ 当前函数名称
    if (!strncmp(dev_name(dev), drv->name, strlen(drv->name))) {
        printk("dev & drv match\n");
        return 1;
    }
    return 0;
}

static struct bus_type xbus = {
    .name = "xbus",
    .match = xbus_match,
};

EXPORT_SYMBOL(xbus);
6.5.3.2 导出总线属性文件

通过 BUS_ATTR 宏,将自定义的属性名称和权限、读、写回调绑定。

BUS_ATTR 宏 会生成 bus_attr_##_name属性变量,

cpp 复制代码
// xbus.c

static char *bus_name = "xbus";

//读属性的回调函数
ssize_t xbus_test_show(struct bus_type *bus, char *buf)
{
    return sprintf(buf, "%s\n", bus_name);//将bus_name的值格式化到buf中,并返回写入的字节数。
}

BUS_ATTR(xbus_test, //属性名称
           S_IRUSR, //属性权限 S_IRUSR对用户可读
    xbus_test_show, //属性读回调
             NULL); //属性写回调

这里,其实就是给 变量 xbus_name 封装了一套 读写函数,

然后通过 BUS_ATTR 封装了用户对该变量的操作接口。

后面注册的时候通过 bus_creator_file 生成属性文件,来便于用户操作。

6.5.3.3 注册总线

在模块初始化的函数中注册总线,在模块注销的函数中注销总线。

在总线的初始化函数中,

通过 bus_register 注册总线结构体 bus_type,

通过 bus_create_file 创建总线对应目录下的属性文件。

cpp 复制代码
//xbus.c

//初始化函数
static __init int xbus_init(void)
{
    printk("xbus init\n");
    
    //注册总线
    bus_register(&xbus); 
    //创建 /sys/bus/<bus_name> 目录
    //注册匹配函数

    //创建 /sys/bus/<bus_name> 目录下的属性文件
    bus_create_file(&xbus, &bus_attr_xbus_test);
    return 0;
}

//模块注册初始化函数
module_init(xbus_init);

static __exit void xbus_exit(void)
{
    printk("xbus exit\n");
    bus_remove_file(&xbus, &bus_attr_xbus_test);
    bus_unregister(&xbus);
}

module_exit(xbus_exit);

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

安装总线模块

cpp 复制代码
sudo insmod xbus.ko

总线模块的初始化函数,通过 bus_register 和 bus_creator 创建了总线和总线属性文件,
通过 tree 查看 /sys/bus/<bus-name>下的目录结构

进入 devices 和 drivers 目录,可以看到都是空的,并没有什么设备和驱动挂载在该总线下。

6.5.4 设备

在Linux设备模型中,注册一个新设备(如xdev)时,需指定设备名称(用于匹配驱动)和挂载的总线(确保正确关联)。

此外,定义并导出一个变量(如id)到用户空间,使用户可通过 sysfs 修改其值。

6.5.4.1 定义新的设备
cpp 复制代码
//xdev.c

//引用外部的总线变量
extern struct bus_type xbus;

//卸载函数
void xdev_release(struct device *dev)
{
    printk("%s-%s\n", __FILE__, __func__);
}

//定义一个通用设备 
static struct device xdev = {
    .init_name = "xdev", //设备的初始化名称,用于在/sys/devices/目录下创建设备目录
    .bus = &xbus,        //设备所属的总线
    .release = xdev_release,//卸载函数
};
6.5.4.2 导出设备属性文件
cpp 复制代码
//xdev.c

unsigned long id = 0;

ssize_t xdev_id_show(struct device *dev, struct device_attribute *attr, char *buf)
{
    return sprintf(buf, "%lu\n", id);
}

ssize_t xdev_id_store(struct device *dev, struct device_attribute *attr, const char *buf, size_t count)
{
    //将字符串转换成无符号长整型数据
    kstrtoul(buf,  //字符串起始地址
              10,  //转换基数,10表示十进制 16表示十六进制
             &id); //结果地址
    return count;
}

DEVICE_ATTR(xdev_id,    //属性文件名,会被宏展开成dev_attr_xdev_id
    S_IRUSR | S_IWUSR,  //权限,用户可读可写
        xdev_id_show,   //读回调
       xdev_id_store);  //写回调
6.5.4.3 注册设备
cpp 复制代码
//xdev.c

//模块初始化函数
static __init int xdev_init(void)
{
    printk("xdev init\n");

    device_register(&xdev); 
    // 通知内核注册设备,
    // device进链表,创建sys/device/xdev,
    // 内核尝试匹配device和driver
    // 通知udev规则,创建/dev/节点
    
    device_create_file(&xdev, 
                    &dev_attr_xdev_id);
    //在 sys/device/下创建属性文件

    return 0;
}
module_init(xdev_init);

static __exit void xdev_exit(void)
{
    printk("xdev exit\n");

    device_remove_file(&xdev, &dev_attr_xdev_id); //移除属性文件
    device_unregister(&xdev);                     //内核注销设备
}
module_exit(xdev_exit);
MODULE_AUTHOR("embedfire");
MODULE_LICENSE("GPL");

安装设备模块

cpp 复制代码
sudo insmod xdrv.ko

看到在/sys/bus/xbus/devices/中多了个设备 xdev,

它是个链接文件,最终指向了 /sys/devices 中的设备。
insmod 模块后,执行初始化函数,device_register 注册设备,
由于device类型指定了设备的总线,/sys/bus/xbus/devices 下挂载了设备,
由于 device_create_file 创建了设备属性文件,设备下也会出现属性

6.5.5 驱动

关于驱动的部分,由于本章实验没有具体的物理设备,因此,没有涉及到设备初始化、设备的函数接口等内容。

6.5.5.1 定义新的驱动
cpp 复制代码
extern struct bus_type xbus;

//设备探测函数
int xdrv_probe(struct device *dev)
{
    printk("%s-%s\n", __FILE__, __func__);
    return 0;
}

//设备移除函数
int xdrv_remove(struct device *dev)
{
    printk("%s-%s\n", __FILE__, __func__);
    return 0;
}

static struct device_driver xdrv = {
    .name = "xdev",    //要匹配的设备名称  和device的.name相同
    .bus = &xbus,      //总线
    .probe = xdrv_probe, //探测函数
    .remove = xdrv_remove, //移除函数
};
6.5.5.2 导出驱动属性文件
cpp 复制代码
//xdrv.c

char *name = "xdrv";

ssize_t drvname_show(struct device_driver *drv, char *buf)
{
    return sprintf(buf, "%s\n", name);
}

DRIVER_ATTR_RO(drvname); //宏定义用于创建一个只读的驱动属性文件

DRIVER_ATTR_RO 定义驱动属性文件时,没有参数可以设置 show 和 store 回调函数,

我们只要保证 store 和 show 函数的前缀与驱动属性文件一致即可。

6.5.5.3 注册驱动

调用 driver_register 函数以及 driver_create_file 函数注册驱动和驱动属性文件。

cpp 复制代码
//模块的初始化函数
static __init int xdrv_init(void)
{
    printk("xdrv init\n");

    //驱动注册
    driver_register(&xdrv);
    //属性文件创建
    driver_create_file(&xdrv,            //驱动
                &driver_attr_drvname);   //宏生成的属性

    return 0;
}
module_init(xdrv_init);

static __exit void xdrv_exit(void)
{
    printk("xdrv exit\n");
    
    driver_remove_file(&xdrv, &driver_attr_drvname);
    driver_unregister(&xdrv);
}
module_exit(xdrv_exit);

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

安装模块

cpp 复制代码
sudo insmod xdrv.ko

insmod 模块后,执行初始化函数,driver_register 注册驱动,
由于device_driver类型指定了设备的总线,/sys/bus/xbus/driver 下挂载了驱动

使用 dmesg | tail 查看模块加载的打印信息。

cpp 复制代码
dmesg | tail
// dmesg 查看内核环形缓冲区内容
// 通常由内核或驱动程序通过 printk 函数生成
// tail用于输出最后几行

加载设备和驱动后,总线通过 match函数 匹配设备和驱动。若名字一致,则关联设备和驱动,并执行驱动的probe函数。

第 7 章 平台设备驱动

在传统字符设备驱动开发中,设备信息与驱动代码紧密耦合,硬件变更需修改驱动源码,与单片机驱动开发无本质区别。

为解决此问题,Linux引入设备驱动模型,通过总线概念分离设备信息与驱动代码。

物理总线是芯片与外设间的通信干线,驱动总线负责设备与驱动的匹配。

Linux内核为 I2C、SPI、USB 等物理总线自动创建驱动总线,

但结构简单的设备(如LED、RTC时钟)无物理总线,内核不会为其创建驱动总线。

因此,Linux引入平台总线(platform bus)这一虚拟总线,管理无物理总线的设备(平台设备)及其驱动(平台驱动)。

平台设备用 platform_device 结构体表示,

平台驱动用 platform_driver 结构体表示,

二者均继承自设备驱动模型中的 device 和 device_driver 结构体。

重点在于理解总线匹配机制、设备和驱动信息的填充,及平台设备驱动与字符设备的关系。

7.1 平台设备

7.1.1 platform_device 结构体

cpp 复制代码
struct platform_device {
    const char *name;                  // 设备名称
    int id;                            // 设备ID,如果只有一个设备通常为-1或0
    struct device dev;                 // 嵌入的通用设备结构体
    u32 num_resources;                 // 资源数量
    struct resource *resource;         // 资源数组指针
    const struct platform_device_id *id_entry; // 设备ID表项指针
    /* 省略部分成员 */
};

7.1.2 何为设备信息

平台设备为驱动程序提供设备信息,包括硬件信息和软件信息:

硬件信息:驱动程序所需的寄存器、中断号、内存资源、I/O口等。

软件信息:如以太网卡的MAC地址、I2C设备的设备地址、SPI设备的片选信号线等。

cpp 复制代码
struct resource {
    resource_size_t start; // 资源起始地址
    resource_size_t end;   // 资源结束地址
    const char *name;      // 资源名称,可为Null
    unsigned long flags;   // 资源类型标志
    /* 省略部分成员 */
};

在 Linux 中,资源包括 I/O、Memory、Register、IRQ、DMA、
Bus 等多种类型

资源类型

设备驱动程序主要操作设备寄存器。

不同架构提供不同操作接口,主要有两种方式:

IO端口映射和IO内存映射。

IO端口映射需用专门函数(如 inb、outb)访问;

IO内存映射可像访问内存一样读写寄存器。

嵌入式系统通常无IO地址空间,多用IO内存映射(IORESOURCE_MEM)。嵌入式系统往往不提供独立的I/O地址空间,而是将I/O设备的寄存器映射到内存地址空间中。

资源的 start 和 end

对于 IORESOURCE_IO 或 IORESOURCE_MEM,表示内存或I/O端口的起始和结束地址。

若是中断引脚或通道,start 和 end 值必须相等。

软件信息保存在platform_device的device成员的私有数据成员

软件信息(如GPIO引脚号、MAC地址等)以私有数据形式保存。

platform_device 中的 dev 成员(类型为struct device)的 platform_data字段可用于保存私有数据。platform_data是 void *类型,可保存任何数据的地址。

cpp 复制代码
unsigned int pin = 10;

// 将 unsigned int 的地址赋给 void * 类型的指针
void *platform_data = &pin;

// 在驱动程序中,将 void * 转换回 unsigned int * 并使用
unsigned int *pin_ptr = (unsigned int *)platform_data;
unsigned int pin_value = *pin_ptr;

printf("Pin value: %u\n", pin_value);

比如在驱动模块的 probe探测函数中获取 platform_device的 device成员的私有数据。

cpp 复制代码
//这是驱动模块的代码

static int my_probe(struct platform_device *pdev)
{
    unsigned int *pin_ptr;
    unsigned int pin_value;

    // 获取私有数据
    pin_ptr = (unsigned int *)pdev->dev.platform_data;
    if (!pin_ptr) {
        dev_err(&pdev->dev, "No platform data available\n");
        return -ENODEV;
    }

    // 使用私有数据
    pin_value = *pin_ptr;
    dev_info(&pdev->dev, "GPIO pin: %u\n", pin_value);

    return 0;
}

7.1.3 注册/注销平台设备

平台设备的注册与注销主要通过 platform_device_register() 和 platform_device_unregister() 函数完成。

cpp 复制代码
//通知内核注册平台设备,总线尝试匹配设备和驱动,在/sys/bus/platform/devices下生成设备文件
int platform_device_register(struct platform_device *pdev);

//通知内核注销平台设备
void platform_device_unregister(struct platform_device *pdev);

7.2 平台设备驱动

7.2.1 platform_driver 结构体

cpp 复制代码
//平台驱动结构体
struct platform_driver {
    int (*probe)(struct platform_device *); // 设备探测函数,当设备匹配时调用
    int (*remove)(struct platform_device *); // 设备移除函数,当设备卸载时调用
    struct device_driver driver; // 嵌入的通用设备驱动结构
    const struct platform_device_id *id_table; // 设备ID表,用于匹配设备
    // 其他成员...
};
cpp 复制代码
//设备id表结构体
struct platform_device_id {
    char name[PLATFORM_NAME_SIZE];       // 设备名称,总线匹配时会与platform_device中的name比较
    kernel_ulong_t driver_data;          // 驱动私有数据,保存设备配置,使一个驱动可匹配多个设备
};

接下来以 imx 芯片的串口为例,具体看下这个结构体的作用:

cpp 复制代码
//内核源码/drivers/tty/serial/imx.c

//定义了不同型号 UART 设备的数据,包括寄存器地址和设备类型
static struct imx_uart_data imx_uart_devdata[] = {
    [IMX1_UART] = {
        .uts_reg = IMX1_UTS,
        .devtype = IMX1_UART,
    },
    [IMX21_UART] = {
        .uts_reg = IMX21_UTS,
        .devtype = IMX21_UART,
    },
    [IMX6Q_UART] = {
        .uts_reg = IMX21_UTS,
        .devtype = IMX6Q_UART,
    },
};

//定设备ID表,用于匹配设备名称和对应的设备数据
static struct platform_device_id imx_uart_devtype[] = {
    {
        .name = "imx1-uart",   //设备名称
        .driver_data = (kernel_ulong_t) &imx_uart_devdata[IMX1_UART],//设备数据
    },
    {
        .name = "imx21-uart",
        .driver_data = (kernel_ulong_t) &imx_uart_devdata[IMX21_UART],
    },
    {
        .name = "imx6q-uart",
        .driver_data = (kernel_ulong_t) &imx_uart_devdata[IMX6Q_UART],
    },
    { /* sentinel */ }
};

代码支持三种设备(imx1、imx21、imx6q)的串口,

它们的区别在于串口的 test 寄存器地址不同。

总线匹配成功后,id_table 条目会赋值给平台设备的 id_entry 成员。

平台驱动的 probe 函数通过平台设备参数,可以获取当前设备串口的 test 寄存器地址。

7.2.2 注册/注销平台驱动

cpp 复制代码
/*注册平台设备驱动*/       //在sys/platbus/driver 下生成文件夹
int platform_driver_register(struct platform_driver *drv);
cpp 复制代码
/*注销平台设备驱动*/
void platform_driver_unregister(struct platform_driver *drv);

最基本的平台驱动框架,只需要实现 probe 函数、remove 函数,

初始化 plat-form_driver 结构体,并调用 platform_driver_register 进行注册即可。

7.2.3 平台驱动获取设备信息

平台设备使用结构体 resource 来抽象表示硬件信息

而软件信息则可以利用设备结构体 device 中的成员 platform_data 来保存。

platform_get_resource() 函数通常会在驱动的 probe 函数中执行,用于获取平台设备提供的资源结构体,最终会返回一个 struct resource 类型的指针。

cpp 复制代码
struct resource *platform_get_resource(struct platform_device *dev, //平台设备指针
                                       unsigned int type,           //资源类型
                                       unsigned int num);           //资源编号
cpp 复制代码
//示例代码

struct resource *res;
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
if (res) {
    // 成功获取资源,可以使用 res->start 和 res->end 访问资源地址范围
} else {
    // 获取资源失败
}

假若资源类型为 IORESOURCE_IRQ,平台设备驱动还提供以下函数,来获取中断引脚,

cpp 复制代码
int platform_get_irq(struct platform_device *pdev, //平台设备指针
                                 unsigned int num) //资源编号

对于存放在 device 结构体中成员 platform_data 的软件信息,

我们可以使用 dev_get_platdata 函数 来获取,函数原型如下所示:

cpp 复制代码
static inline void *dev_get_platdata(const struct device *dev)
{
    return dev->platform_data;
}

7.3 平台总线

7.3.1 平台总线注册和匹配方式

总线负责匹配设备和驱动,维护注册的设备和驱动链表。

新设备或驱动加入时,调用 platform_match 函数进行配对。

cpp 复制代码
struct bus_type platform_bus_type = {
    .name       = "platform",          // 总线名称
    .dev_groups = platform_dev_groups, // 设备属性组
    .match      = platform_match,      // 匹配函数
    .uevent     = platform_uevent,     // uevent 通知函数
    .pm         = &platform_dev_pm_ops,// 电源管理操作
};

EXPORT_SYMBOL_GPL(platform_bus_type);  // 导出为全局符号

内核用 platform_bus_type 来描述平台总线,该总线在 linux 内核启动的时候自动进行注册。

cpp 复制代码
// __init 是一个宏,用于标记初始化函数。在内核启动时,这些函数会被调用。
int __init platform_bus_init(void)  
{
    int error;
    // ...
    error = bus_register(&platform_bus_type); //注册平台总线
    // ...
    return error;
}

platform 总线的 match 函数指针负责实现总线与设备的匹配过程,

每个驱动总线都必须实例化该函数指针。

cpp 复制代码
static int platform_match(struct device *dev, struct device_driver *drv)
{
    struct platform_device *pdev = to_platform_device(dev); // 转换为平台设备
    struct platform_driver *pdrv = to_platform_driver(drv); // 转换为平台驱动

    /* 当 driver_override 设置时,仅与匹配的驱动绑定 */
    if (pdev->driver_override)
        return !strcmp(pdev->driver_override, drv->name);

    /* 尝试 OF(设备树)风格的匹配 */
    if (of_driver_match_device(dev, drv))
        return 1;

    /* 尝试 ACPI 风格的匹配 */
    if (acpi_driver_match_device(dev, drv))
        return 1;

    /* 尝试根据 id 表匹配 */
    if (pdrv->id_table)
        return platform_match_id(pdrv->id_table, pdev) != NULL;

    /* 回退到驱动名称匹配 */
    return (strcmp(pdev->name, drv->name) == 0);
}

平台总线有多种设备与驱动的匹配方法

这里解释一下设备树匹配的问题:

内核在启动时会解析设备树,并为每个平台设备节点 创建一个 platform_device 结构

将 platform_device 注册到 platform 总线上

将 IIC设备注册到 IIC总线上。诸如此类。

这里调用了 to_platform_device() 和 to_platform_driver() 宏。

cpp 复制代码
//to_platform_device 宏通过 container_of 宏,将通用设备指针 x 转换为平台设备指针
#define to_platform_device(x) (container_of((x), 
                         struct platform_device,//目标结构体类型,表示平台设备
                                          dev)) //struct platform_device 中的成员,表示通用设备结构体
cpp 复制代码
//to_platform_driver宏通过 container_of 宏,将通用驱动指针 x 转换为平台驱动指针
#define to_platform_driver(drv) (container_of((drv), 
                             struct platform_driver,  //平台驱动结构体
                                            driver))  //平台驱动结构体的driver成员

倘若我们的驱动没有提供前三种方式的其中一种,那么总线进行匹配时,

只能比较 platform_device 中的 name 字段以及嵌在 platform_driver 中的 device_driver 的 name 字段。

7.3.2 id_table 匹配方式

在平台总线的 id_table 匹配方式中,定义 platform_driver 时需提供一个 id_table 数组,该数组列出了驱动支持的设备。

加载驱动时,若 id_table 非空,

总线的 match 函数

会比较平台驱动 platform_driver的 id_table 的 name 成员

和平台设备 platform_device 的 name 成员,

若相同则返回匹配条目。

cpp 复制代码
//平台总线用于匹配驱动和设备
static const struct platform_device_id *platform_match_id(
    const struct platform_device_id *id, //指向 platform_device_id 数组的指针,
                                         //平台驱动的id_table,该数组包含驱动支持的设备名称。
    struct platform_device *pdev) //指向当前平台设备的指针
{
    while (id->name[0]) {
        if (strcmp(pdev->name,  //平台设备名称
                    id->name)   //平台驱动的 platform_device_id的id表项的设备名字
           == 0) {
            pdev->id_entry = id; //平台设备的id表项赋值为平台驱动的id表项
            return id;  //返回id表项
        }
        id++;
    }
    return NULL;
}

当待匹配的平台设备的 name 字段的值等于驱动提供的 id_table 中的值时,

会将当前匹配的项赋值给 platform_device 中的 id_entry,返回 id_table。

若没有成功匹配,则返回空指针。

倘若我们的驱动没有提供前三种方式的其中一种,那么总线进行匹配时,只能比较 platform_device 中的 name 字段以及嵌在 platform_driver 中的 device_driver 的 name 字段。

7.4 平台设备实验代码讲解

本实验在 Lubancat_RK 板卡上,基于 linux_driver/platform_driver 目录,将平台设备驱动应用于 LED 字符设备驱动,实现软硬件代码分离,巩固平台设备驱动知识,详情见"字符设备驱动--点亮 LED 灯"章节。

7.4.1 编程思路

7.4.2 代码分析

7.4.2.1 定义平台设备

将字符设备的硬件信息(如寄存器地址等)提取出来,独立成平台设备代码并注册到内核。

点亮LED灯需控制相关寄存器,包括GPIO时钟寄存器、IO配置寄存器、IO数据寄存器等,这些资源可用IORESOURCE_MEM处理,寄存器偏移量可通过平台设备的私有数据管理。

cpp 复制代码
//led_pdev.c
//寄存器宏定义
#define GPIO0_BASE    (0xfdd60000)  // 定义 GPIO0 基地址
#define GPIO0_DR      (GPIO0_BASE + 0x0004)  // 定义 GPIO0 数据寄存器地址
#define GPIO0_DDR     (GPIO0_BASE + 0x000C)  // 定义 GPIO0 方向寄存器地址
cpp 复制代码
//led_pdev.c
//定义一个 resource 结构体,用于存放上述的寄存器地址,提供给驱动使用
static struct resource rled_resource[] = {
    [0] = DEFINE_RES_MEM(GPIO0_DR, 4),
    [1] = DEFINE_RES_MEM(GPIO0_DDR, 4),
};

在 include/linux/ioport.h 中,

DEFINE_RES_MEM 宏用于定义 IORESOURCE_MEM 类型的资源,

需传入地址和大小两个参数。因寄存器为32位,故大小为 4字节。

cpp 复制代码
//IORESOURCE_MEM使用示例
// 获取编号为 1 的资源(GPIO0_DDR)
res = platform_get_resource(pdev, IORESOURCE_MEM, 1);//返回的是资源地址
cpp 复制代码
//led_pdev.c
unsigned int led_hwinfo[1] = { 7 };
//这里用了一个变量来记录寄存器的偏移量

//填充平台私有数据时,只需要把数组的首地址赋给 platform_data 即可

上面定义了硬件的设备信息,

接下来只需要定义一个 platform_device 类型的变量, 填充相关信息。

cpp 复制代码
//led_pdev.c

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

/* Red LED device */
static struct platform_device rled_pdev = {
    .name = "led_pdev",                //设备名称和驱动名称保持一致,否则名称匹配会失败
    .id = 0,
    .num_resources = ARRAY_SIZE(led_resource),
    .resource = led_resource,          //将资源赋给resource成员
    .dev = {
        .release = led_release,        //设备注销时的回调函数
        .platform_data = led_hwinfo,   //设备的平台相关数据
    },
};

声明了 led_cdev_release 函数,目的为了防止卸载模块时内核提示报错。

cpp 复制代码
//led_pdev.c

static __init int led_pdev_init(void)
{
    printk("pdev init\n");
    platform_device_register(&rled_pdev);//注册平台设备
    return 0;
}
module_init(led_pdev_init);

static __exit void led_pdev_exit(void)
{
    printk("pdev exit\n");
    platform_device_unregister(&rled_pdev);
}
module_exit(led_pdev_exit);

MODULE_AUTHOR("Embedfire");
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("the example for platform driver");
7.4.2.2 定义平台驱动

我们已注册一个新平台设备,驱动只需提取设备资源并提供操作方式。

采用字符设备控制 LED 灯,重点在平台驱动上。

驱动通过 id_table 匹配设备,定义 platform_device_id 类型的 led_pdev_ids 变量,支持名为 led_pdev 的设备,需与平台设备名称一致。

cpp 复制代码
//led_pdrv.c
static struct platform_device_id led_pdev_ids[] = {
    { .name = "led_pdev" },
    { }
};

/*将设备表注册到内核中,使得内核能够在加载模块时自动匹配设备和驱动程序*/
MODULE_DEVICE_TABLE(platform,  //这里platform指代设备的类型,平台设备
                led_pdev_ids); //id_table表

我们前面定义 platform_device 通过 .name写了设备名称,

在写驱动 platform_driver 通过 MODULE_DEVICE_TABLE 注册的 id_table表也有设备名称,

这是总线对设备和驱动进行名称匹配的前提。

cpp 复制代码
//led_pdrv.c

/*这里声明了一个结构体*/
struct led_data {
    unsigned int led_pin;
    unsigned int __iomem *va_MODER;
    unsigned int __iomem *va_OTYPER;

    struct cdev led_cdev;
};

/* 省略部分代码 */

static int led_pdrv_probe(struct platform_device *pdev)
{
    struct led_data *cur_led; //LED灯结构体
    unsigned int *led_hwinfo; //LED灯的偏移量

    struct resource *mem_DR;
    struct resource *mem_DDR;

    dev_t cur_dev;//用来存放设备号

    int ret = 0;

    printk("led platform driver probe\n");

    // 第一步:提取平台设备提供的资源
    // devm_kzalloc 函数申请 cur_led 和 led_hwinfo 结构体内存大小
    cur_led = devm_kzalloc(&pdev->dev, sizeof(struct led_data), GFP_KERNEL);
    if (!cur_led)
        return -ENOMEM;//内存不足

    led_hwinfo = devm_kzalloc(&pdev->dev, sizeof(unsigned int), GFP_KERNEL);
    if (!led_hwinfo)
        return -ENOMEM;

    // dev_get_platdata 函数获取私有数据,
    // 得到 LED 灯的寄存器偏移量,并赋值给 cur_led->led_pin
    led_hwinfo = dev_get_platdata(&pdev->dev);

    cur_led->led_pin = led_hwinfo[0];

    // 利用函数 platform_get_resource 可以获取到各个寄存器的地址
    mem_DR = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    mem_DDR = platform_get_resource(pdev, IORESOURCE_MEM, 1);

    // 使用 devm_ioremap 将获取到的寄存器地址转化为虚拟地址
    cur_led->va_MODER = devm_ioremap(&pdev->dev, mem_DR->start, resource_size(mem_DR));
    cur_led->va_OTYPER = devm_ioremap(&pdev->dev, mem_DDR->start, resource_size(mem_DDR));

    // 第二步:拼出起始设备号
    cur_dev = MKDEV(DEV_MAJOR, pdev->id);//将主次设备号合成完整的设备号,返回完整设备号
                                         //DEV_MAJOR是一个自定义宏或变量,当前代码没给出
    
    //注册设备号
    ret = register_chrdev_region(cur_dev,     //设备号 
                                        1,    //要注册的count数
                                "led_cdev");  //设备名称
    if (ret < 0) {
        printk("fail to register chrdev region\n");
        return ret;
    }
    
    //将cdev与fops绑定
    cdev_init(&cur_led->led_cdev, &led_cdev_fops);

    //注册cdev到内核的散列表中
    ret = cdev_add(&cur_led->led_cdev, cur_dev, 1);
    if (ret < 0) {
        printk("fail to add cdev\n");
        unregister_chrdev_region(cur_dev, 1);
        return ret;
    }

    //创建设备节点并将其注册到文件系统,字符设备的生命周期由内核设备模型进行管理,不会挂载到总线
    //内核会自动管理设备的引用计数。当设备被创建时,引用计数增加;当设备被删除时,引用计数减少
    device_create(led_test_class, NULL, cur_dev, NULL, "led%d", pdev->id);

    // platform_set_drvdata 函数,
    // 将 LED 数据信息存入在平台驱动结构体中 pdev->dev->driver_data 中
    platform_set_drvdata(pdev, cur_led);
    // 将私有数据与平台设备关联起来.
    // 这样,你可以在设备的其他操作中通过 platform_get_drvdata 函数获取这些数据

    return 0;
}

devm_kzalloc 是 Linux 内核中用于动态分配内存的函数,它是 kzalloc 的设备管理(Device Managed)版本。

主要作用是为设备分配内存,并将分配的内存与设备关联起来,以便在设备卸载时自动释放这些内存。

cpp 复制代码
//动态分配内存,设备卸载时自动释放
//返回分配的内存指针,失败则null
void *devm_kzalloc(struct device *dev,  //设备指针,用于将内存与设备关联起来
                          size_t size,  //内存大小
                         gfp_t flags);  //GFP_KERNEL  在内核空间分配内存

当驱动的内核模块被卸载时,我们需要将注册的驱动注销,相应的字符设备也同样要注销,

cpp 复制代码
//led_pdrv.c
static int led_pdrv_remove(struct platform_device *pdev)
{
    dev_t cur_dev; // 用于存储设备号

    // platform_get_drvdata,获取当前 LED 灯对应的结构体
    struct led_data *cur_data = platform_get_drvdata(pdev);

    printk("led platform driver remove\n");

    // 生成设备号
    cur_dev = MKDEV(DEV_MAJOR, pdev->id);

    // 从 cdev_map 删除对应的字符设备
    cdev_del(&cur_data->led_cdev);

    // 删除使用device_creator创建的字符设备 删除 /dev 目录下的设备节点
    device_destroy(led_test_class, cur_dev);

    // 注销掉当前的字符设备编号
    unregister_chrdev_region(cur_dev, 1);

    return 0;
}

操作LED灯字符设备的方式主要通过字符设备驱动程序实现,具体步骤包括设备注册、用户空间操作和设备移除。

cpp 复制代码
//led_pdrv.c
static int led_cdev_open(struct inode *inode, struct file *filp)
{
    unsigned int val = 0;
    struct led_data *cur_led = container_of(inode->i_cdev, struct led_data, led_cdev);

    printk("led_cdev_open()\n");

    // 设置引脚为输出模式
    val = readl(cur_led->va_DDR);
    val |= ((unsigned int)0x1 << (cur_led->led_pin + 16)); // 设置输出模式
    val |= ((unsigned int)0x1 << (cur_led->led_pin));      // 设置输出模式
    writel(val, cur_led->va_DDR);

    // 设置默认输出高电平
    val = readl(cur_led->va_DR);
    val |= ((unsigned int)0x1 << (cur_led->led_pin + 16)); // 设置输出高电平
    val |= ((unsigned int)0x1 << (cur_led->led_pin));      // 设置输出高电平
    writel(val, cur_led->va_DR);

    // 将私有数据存储在 file 结构体中
    filp->private_data = cur_led;

    return 0;
}

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

static ssize_t led_cdev_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos)
{
    unsigned long val = 0;
    unsigned long ret = 0;

    int tmp = count;

    struct led_data *cur_led = (struct led_data *)filp->private_data;

    // 从用户空间读取值
    val = kstrtoul_from_user(buf,  //字符串起始地址,必须以空格结尾
                             tmp,  //要转换数据的大小
                              10,  //转换基数,十进制
                             &ret);//转换成功后的结果地址
/*
该函数相比 kstrtoul() 多了一个参数 count,
因为用户空间是不可以直接访问内核空间的,
所以内核提供了 kstrtoul_from_user() 函数以实现用户缓冲区到内核缓冲区的拷贝,
与之相似的还有copy_to_user(),copy_to_user() 完成的是内核空间缓冲区到用户空 io 间的拷贝。
*/

    // 读取当前寄存器值
    val = readl(cur_led->va_DR);

    if (ret == 0)
    {
        // 设置低电平
        val |= ((unsigned int)0x1 << ((cur_led->led_pin) + 16));
        val &= ~((unsigned int)0x1 << (cur_led->led_pin));
    }
    else
    {
        // 设置高电平
        val |= ((unsigned int)0x1 << (cur_led->led_pin + 16));
        val |= ((unsigned int)0x1 << (cur_led->led_pin));
    }

    // 写回寄存器
    writel(val, cur_led->va_DR);

    // 更新文件偏移
    *ppos += tmp;

    return tmp;
}

static struct file_operations led_cdev_fops = {
    .open = led_cdev_open,
    .release = led_cdev_release,
    .write = led_cdev_write,
};

注意这里 fops的 write用 kstrtoul_from_user实现了从用户空间到内核空间的转换。

与之相似的还有 copy_to_user(),copy_to_user() 完成的是内核空间缓冲区到用户空 io 间的拷贝。如果你使用的内 存类型没那么复杂,便可以选择使用 put_user() 或者 get_user() 函数。

kstrtoul_from_user:从用户空间读取字符串并转换为 unsigned long。

copy_to_user:将数据从内核空间拷贝到用户空间。

put_user:将单个值从内核空间写入用户空间。

get_user:从用户空间读取单个值到内核空间。

cpp 复制代码
static struct platform_driver led_pdrv = {
    .probe = led_pdrv_probe,
    .remove = led_pdrv_remove,
    .driver = {
        .name = "led_pdev",        //这里其实定义了name和id_table两种匹配模式,id_table匹配优先
        .id_table = led_pdev_ids, 
        //在平台总线匹配过程中,只会根据 id_table
        //中的 name 值进行匹配,若和平台设备的 name 值相等
    },
};

static __init int led_pdrv_init(void)
{
    printk("led platform driver init\n");

    // 创建设备类
    led_test_class = class_create(THIS_MODULE, "test_leds");
    //static struct class *led_test_class;是模块的全局变量,此处省略了

    if (IS_ERR(led_test_class)) {
        printk("Failed to create class\n");
        return PTR_ERR(led_test_class);
    }

    // 注册平台驱动
    platform_driver_register(&led_pdrv);

    return 0;
}
module_init(led_pdrv_init);

static __exit void led_pdrv_exit(void)
{
    printk("led platform driver exit\n");

    // 注销平台驱动
    platform_driver_unregister(&led_pdrv); //调用remove方法

    // 销毁设备类
    class_destroy(led_test_class);
}
module_exit(led_pdrv_exit);

MODULE_AUTHOR("Embedfire");
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("The example for platform driver");

字符设备/驱动和平台设备/驱动

字符设备在/dev,可能是字符设备c,块设备b,软链接l
平台设备在/sys/bus/<bus-name>下的软连接
都会在sys/class下有对应的目录

platform_device结构体有 .name、.id、.resource、.dev属性,
.dev 有 resource和 private_data属性

platform_driver有 .probe、.remove、.driver、.id_table成员
用 platform_driver 注册平台驱动

编写 led 字符设备的操作之后,我们只需要将我们实现好的内容,

填充到 platform_driver 类型的 结构体,并使用 platform_driver_register 函数注册。

7.5 实验准备

如出现 Permission denied 或类似字样,请注意用户权限,

大部分操作硬件外设的功能,几 乎都需要 root 用户权限,

简单的解决方案是在执行语句前加入 sudo 或以 root 用户运行程序。

7.5.1 编译驱动程序

7.5.1.1 makefile 修改说明
bash 复制代码
# 定义内核目录路径
KERNEL_DIR = ../../kernel/

# 定义目标架构和交叉编译工具链
ARCH = arm64
CROSS_COMPILE = aarch64-linux-gnu-
export ARCH CROSS_COMPILE

# 定义需要构建的目标模块
obj-m := led_pdev.o led_pdrv.o

# 默认目标:构建模块
all:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules

# 清理目标:清理构建生成的文件
modules clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean
7.5.1.2 编译命令说明

鲁班猫系列板卡,系统设备树中均默认使能了 LED 的设备功能,需要关闭设备树的 leds 节点,可以修改 leds 节点的 status = "okay"; 为 status = "disabled";,然后编译设备树进行替换,也可以在板卡中直接使用以下命令关闭系统 leds 驱动对 LED 的控制:

bash 复制代码
sudo sh -c 'echo 0 > /sys/class/leds/sys_status_led/brightness'

将 led 的亮度调为 0,与此同时 led 的触发条件自动变为 none,从而取消 leds 驱动对 LED 的控制。

在实验目录下运行 make 命令编译驱动模块,成功后会生成 led_pdev.ko 和 led_pdrv.ko 两个驱动模块文件。

bash 复制代码
make

7.5.2 编译应用程序

本节实验使用 linux 系统自带的"echo"应用程序进行测试,无需额外编译应用程序

本节实验

写了设备模块代码,

通过 platform_device定义了平台设备。

填充了平台设备的 .name、.resource、.dev等成员,

通过 platform_device_register注册了平台设备。

写了驱动模块代码,

通过 MODULE_DEVICE_TABLE(platform,struct platform_device_id)将设备表id_table注册到内核,使内核加载模块时能自动匹配设备,

然后,在probe函数中,

通过 devm_kzalloc将 cdev与 led灯结构体和偏移量的内存空间绑定,

通过 platform_get_resource 获取resource资源,比如各个寄存器的地址,比如寄存器的偏移量,存储到开辟的内存中,

得到的物理地址要通过 devm_ioremap转化为虚拟地址,这里主要是DR和DDR的地址

通过 MKDEV拼出起始设备号,

然后 通过 register_chrdev_region注册设备号,

通过 cdev_add 注册 cedv 到内核的散列表,

通过 device_create 创建设备节点

7.5.3 拷贝驱动程序到板卡

bash 复制代码
# 或者通过 scp 命令直接传输到板卡,
scp *.ko cat@192.168.103.2:/home/cat/

7.6 程序运行结果

7.6.1 开发板加载设备模块

教程中只列举了一个LED灯。

运行命令 sudo insmod led_pdev.ko后,可在/sys/bus/platform/devices下看到注册的LED灯设备led_pdev.0,其中数字0对应平台设备结构体的id编号。

7.6.2 开发板加载驱动模块

执行 sudo insmod led_pdrv.ko 加载LED平台驱动,运行 dmesk | tail 查看内核信息,显示led platform driver probe,表明驱动匹配成功。

7.6.3 开发板运行程序

通过驱动代码,最后会在/dev 下创建个 LED 灯设备,为 led0,

可以使用 echo 命令来测试我们的 LED 驱动是否正常。

我们使用以下命令控制灯的亮灭:

bash 复制代码
# 控制灯亮
sudo sh -c "echo 0 > /dev/led0"
# 控制灯灭
sudo sh -c "echo 1 > /dev/led0"

第 8 章 Linux 设备树

Linux 3.x 之前,硬件平台细节(以 ARM 为例)直接硬编码在 /arch/arm/plat-xxx 和 /arch/arm/mach-xxx 中,导致内核随平台增多而臃肿。

自 Linux 3.x 起引入设备树,将硬件描述从内核源码剥离,实现简单、可重用,成为主流驱动开发方式。

8.1 设备树简介

设备树用于描述硬件平台中无法动态探测的硬件资源,

由 bootloader(如 U-Boot)传给内核,供内核获取硬件信息。
DDR本质是采用 DDR 技术的内存条 / 内存芯片 ------DDR 是内存的 "技术标准",
而非 "内存" 的统称,但因 DDR 已成为主流,常被直接用来代指 "内存"。
在嵌入式领域(如 ARM 架构的开发板、路由器、机顶盒),DDR 同样是核心存储部件,通常以 "DDR 芯片" 形式焊接在主板上(而非 PC 的可插拔内存条),需通过设备树(Device Tree)等配置文件,告知 Linux 内核其容量、速率、时序等硬件参数,确保内核正常识别和使用。

设备树有两个特点:

树状结构 :以根节点为主干,挂载总线和设备作为子节点,形成层级关系,除根节点外每个节点只有一个父节点。

可重用性 :可像头文件一样用 #include 引用 .dtsi 文件,实现硬件描述的复用,减少重复编写。

一句话总结:DTS 源码 → DTC 编译 → DTB 二进制文件

.dtsi是设备树头文件

8.2 设备树框架

设备树 (Device Tree) 由一系列被命名的结点 (node) 和属性 (property) 组成,

以 lubancat2为例,内核源码/arch/arm64/boot/dts/rockchip/rk3568-lubancat2.dts 先睹为快。

板级设备树:rk3568-lubancat2.dts

cpp 复制代码
/dts-v1/;  // 设备树版本声明

// 1. 头文件引用(.h宏定义文件 + .dtsi设备树头文件)
#include <dt-bindings/gpio/gpio.h>    // GPIO相关宏定义
#include <dt-bindings/pwm/pwm.h>      // PWM相关宏定义
#include "rk3568.dtsi"                // 引用RK3568芯片通用设备树

// 2. 节点定义(根节点 + 子节点)
/ {  // 根节点:整个设备树唯一的顶层节点
    model = "EmbedFire LubanCat2 HDMI";  // 板卡型号
    compatible = "embedfire,lubancat2", "rockchip,rk3568";  // 兼容性标识

    // 子节点1:chosen(传递启动参数)
    chosen {
        bootargs = "earlycon=uart8250,mmio32,0xfe660000 console=ttyFIQ0 root=PARTUUID=614e0000-0000 rw rootwait";
    };

    // 子节点2:fiq-debugger(调试相关设备)
    fiq-debugger {
        compatible = "rockchip,fiq-debugger";  // 匹配驱动的标识
        rockchip,serial-id = <2>;              // 串口ID
        status = "okay";                       // 设备使能状态
    };

    /* 其他子节点省略... */
};

// 3. 节点追加(向已定义的节点添加属性,用&标识目标节点)
&saradc {  // 追加"saradc"节点(该节点定义在rk3568.dtsi中)
    vref-supply = <&vcca_1v8>;  // 新增电源属性
    status = "okay";            // 使能该设备
};

&tsadc {  // 追加"tsadc"节点(定义在rk3568.dtsi中)
    status = "okay";            // 使能该设备
};

芯片级设备树头文件:rk3568.dtsi

cpp 复制代码
// 引用芯片相关宏定义头文件
#include <dt-bindings/clock/rk3568-cru.h>  // RK3568时钟宏定义
#include <dt-bindings/interrupt-controller/arm-gic.h>  // 中断控制器宏定义

/ {  // 根节点(与板级dts的根节点最终合并为一个)
    compatible = "rockchip,rk3568";  // 芯片兼容性标识
    #address-cells = <2>;  // 地址字段长度(用于描述设备地址)
    #size-cells = <2>;     // 大小字段长度(用于描述设备地址范围)

    // 子节点:aliases(设备别名,简化节点引用)
    aliases {
        csi2dphy0 = &csi2_dphy0;  // 给"csi2_dphy0"节点起别名"csi2dphy0"
        dsi0 = &dsi0;              // 给"dsi0"节点起别名"dsi0"
        ethernet0 = &gmac0;        // 给"gmac0"节点起别名"ethernet0"
    };

    /* 其他芯片级节点(如cpu0、dmc、i2c0等)省略... */
};

8.2.1 节点基本格式

节点的结构参考:

cpp 复制代码
/dts-v1/; //必要的DTS 文件版本说明
#include "example.dtsi"; //包含头文件 可以是.dtsi .dts .h文件等
/ { //根节点
    node1-name@unit-address{ //节点1,名称是"node1-name",单元地址和reg属性的第一个地址一致
        compatible = "xxx, xxxx";
        a-string-property = "A string"; //节点属性和属性值,是字符串
        a-string-list-property = "first string", "second string";
        a-byte-data-property = [0x00 0x13 0x24 0x36];

        label: child-node1 { //节点1的子节点1 "label"是标签,
            first-child-property;
            second-child-property = <1>;
            a-string-property = "Hello, world";
        };
        child-node2 { //子节点2
        };
    };
    node2-name { //节点2
        an-empty-property;
        a-cell-property = <1 2 3 4>; /* each number (cell) is a uint32 */
        child-node1 { /*节点2的子节点*/
            my-cousin = <&cousin>;
        };
    };
    ...
};

节点名称(node-name)

长度 1 - 31 字符,由数字、大小写字母、英文逗号 / 句号、下划线、加减号组成,需以大小写字母开头,用于描述设备类别;根节点无节点名,用 "/" 指代。

可带@unit-address(@为分隔符),unit-address需与节点reg属性首个地址一致;无reg属性可省略,但此时要注意同级子节点名称需唯一;如果有reg属性则同级节点名可相同,但node-name@unit-address整体需唯一。

8.2.2 节点标签

在 rk3568.dtsi 中,"cpu0" 是节点 "cpu@0" 的标签,它是节点名的简写,作用是方便其他位置通过该标签引用节点,向节点追加内容。

8.2.3 节点路径

设备树节点可通过从根节点开始的完整路径唯一标识,不同层次节点名可相同,同层次节点名需唯一,类似 Windows 文件路径,不同目录下文件名可重复,比如节点路径 "/node1-name/child-node1" 唯一对应该节点。

8.2.4 节点属性

节点的 "{}" 内是节点属性,承载传递给内核的板级硬件描述,驱动通过 API 获取;编写设备树的核心是写节点属性,一个节点通常对应一个设备。

节点属性分两类:

**标准属性:**属性名固定(如根节点的compatible = "rockchip,rk3568",可识别 SOC 型号),部分为所有节点共有;

**自定义属性:**可按需求自行定义属性名。

后续会重点讲设备属性的具体内容、编写方式及驱动引用,本节仅介绍共有标准属性。

compatible 属性

属性值类型:字符串(支持多个字符串,用 "," 分隔)

核心作用:作为设备与驱动匹配的关键依据,也是查找设备节点的方式之一(另两种为节点名、节点路径),代表设备节点必须包含该属性。

匹配逻辑:系统初始化时(如初始化 platform 总线设备),会对比设备节点的compatible属性值与驱动中of_match_table的值,匹配则加载对应驱动。

cpp 复制代码
// model:板卡型号描述
model = "EmbedFire LubanCat2 HDMI";

// compatible:设备兼容性列表(先板级再芯片级)
compatible = "embedfire,lubancat2", "rockchip,rk3568";//板级匹配项 芯片级匹配项

// aliases:节点别名集合,用短名引用长节点
aliases {
    csi2dphy0 = &csi2_dphy0;  // 摄像头CSI接口PHY 0
    csi2dphy1 = &csi2_dphy1;  // 摄像头CSI接口PHY 1
    csi2dphy2 = &csi2_dphy2;  // 摄像头CSI接口PHY 2
    dsi0 = &dsi0;              // MIPI DSI显示接口0
    dsi1 = &dsi1;              // MIPI DSI显示接口1
    ethernet0 = &gmac0;        // 以太网控制器0
    ethernet1 = &gmac1;        // 以太网控制器1
    gpio0 = &gpio0;            // GPIO控制器0
    .....                      // 其他节点别名
};
设备初始化、驱动初始化、平台总线初始化,在匹配设备和驱动的时机和方式上的区别

1. 平台总线初始化

时机: 内核启动早期

方式: 注册 platform_bus_type 及匹配机制

关键: 准备好匹配规则,不直接加载驱动
2. 设备初始化

时机: 内核解析设备树后

方式:

将设备树节点转换为 platform_device

设置 dev.of_node 指向设备树节点

将设备添加到平台总线上

关键: 设备进入总线的待匹配队列
3. 驱动初始化

时机: 驱动模块加载时

方式:

注册 platform_driver 及 of_match_table

总线遍历已有设备,尝试匹配

匹配方法:

比较设备 compatible 属性与驱动 of_match_table

匹配成功则自动绑定驱动,然后调用 probe 函数,
匹配时机差异

设备先于驱动:设备等待,驱动注册时立即匹配

驱动先于设备:驱动等待,设备注册时立即匹配

probe函数获取硬件资源,如从设备树或平台数据中读取寄存器地址、中断号、GPIO等硬件信息,映射物理寄存器地址到内核虚拟地址(ioremap),完成硬件初始化,

注册字符设备(cdev_add)、网络设备(register_netdev)等内核对象,建立/dev节点便于用户空间访问,将硬件操作函数与内核框架挂钩(如file_operations 结构体),保存设备私有数据(dev_set_drvdata),供后续调用使用。

model 属性

用于指定设备的制造商和型号

cpp 复制代码
//model 属性用于指定设备的制造商和型号,推荐使用"制造商, 型号"的格式,当然也可以自定义。
model = "EmbedFire LubanCat2 HDMI+MIPI";
status 属性
cpp 复制代码
/* 外部声卡设备节点 */
sound: sound {
    status = "disabled";  // 初始状态为禁用,需启用时可改为"okay"
};
#address-cells 和 #size-cells

在设备树 ocrams 有子节点的设备节点中,#address-cells 和 #size-cells 共同定义子节点 reg 属性的书写格式:

  1. reg 属性由地址字段、大小字段的数字串构成;
  2. #address-cells 指定每个地址字段占的 32 位单元格数(如 = 2 代表 64 位地址需 2 个单元格);
  3. #size-cells 指定每个大小字段占的 32 位单元格数;
  4. 按 "地址字段(对应 #address-cells 数)+ 大小字段(对应 #size-cells 数)" 的组合,循环构成 reg 的完整数值串,以此区分 reg 中地址与长度信息。
cpp 复制代码
soc {
    #address-cells = <1>;    // 地址字段长度:描述设备地址时用1个32位整数
    #size-cells = <1>;       // 大小字段长度:描述地址范围时用1个32位整数
    compatible = "simple-bus";// 标识为简单总线,内核可按总线规则解析子设备
    interrupt-parent = <&gpc>;// 子节点默认中断父控制器为"gpc"(中断控制器节点)
    ranges;                  // 启用地址映射,子节点地址直接对应父总线物理地址
    
    ocrams: sram@900000 {    // OCRAM(片上RAM)节点,标签"ocrams",地址0x900000
        compatible = "fsl,lpm-sram";// 匹配飞思卡尔低功耗SRAM驱动
        reg = <0x900000 0x4000>;   // 物理地址0x900000,大小0x4000(16KB)
    };
};
reg 属性

reg 属性描述设备在父总线地址空间中的位置,通常表示寄存器的起始地址和长度。

当 #address-cells=1 且 #size-cells=1 时,reg = <地址 长度>,例如 <0x9000000 0x4000> 表示从 0x9000000 开始、大小为 0x4000 的地址空间。

ranges

ranges 属性用于定义子节点与父节点地址空间的映射关系,

格式为 <子地址 父地址 长度>。

当子地址空间与父地址空间一致时,可以写 ranges; 或直接省略该属性。

例如,在 #address-cells=1 且 #size-cells=1 时,ranges=<0x0 0x10 0x20> 表示将子地址 0x0~0x20 映射到父地址 0x10~0x30。

cpp 复制代码
soc {
    #address-cells = <1>;
    #size-cells = <1>;
    compatible = "simple-bus";
    interrupt-parent = <&gpc>;
    ranges;

    busfreq {
        /*-------------以下内容省略--------------*/
    };
};
name 和 device_type

name 和 device_type 是已废弃 的设备树属性,不推荐使用。

name 曾用于指定节点名,现在已不再使用。节点名可直接指定

device_type 仅在 CPU 和内存节点上偶有出现,如上例 CPU 节点。已被comptiable替代

cpp 复制代码
example {
    name = "name";
};

cpus {
    #address-cells = <2>;
    #size-cells = <0>;

    cpu0: cpu@0 {
        device_type = "cpu";
        compatible = "arm,cortex-a55";
        reg = <0x0 0x0>;
        enable-method = "psci";
        clocks = <&scmi_clk 0>;
        operating-points-v2 = <&cpu0_opp_table>;
        cpu-idle-states = <&CPU_SLEEP>;
        #cooling-cells = <2>;
        dynamic-power-coefficient = <187>;
    };
    ...
};

8.2.5 追加/修改节点内容

"&cpu0" 是对已有节点的引用,用于向节点追加或修改属性,不创建新节点。该节点可能在当前文件或其包含的文件(如 rk3588.dtsi)中定义。

bash 复制代码
&cpu0 {
    cpu-supply = <&vdd_cpu>;
};

8.2.6 特殊节点

aliases 子节点

aliases 子节点的作用就是为其他节点起一个别名,与节点标签类似。

bash 复制代码
标签名: 节点名@地址 {
    属性...
};

uart0: serial@fe001000 {
    compatible = "arm,pl011";
    reg = <0xfe001000 0x1000>;
    interrupts = <GIC_SPI 1 0>;
    clocks = <&clk 25>;
};

serial0 = &uart0; 为 uart0 节点创建别名 serial0。

别名可快速定位节点,无需写完整路径,常用于驱动查找设备。

bash 复制代码
aliases {
    csi2dphy0 = &csi2_dphy0;        #左边是右边节点的别名
    csi2dphy1 = &csi2_dphy1;
    csi2dphy2 = &csi2_dphy2;
    /*----------- 省略------------*/
    mmc0 = &sdhci;
    mmc1 = &sdmmc0;
    mmc2 = &sdmmc1;
    mmc3 = &sdmmc2;
    serial0 = &uart0;
    serial1 = &uart1;
    serial2 = &uart2;
    /*----------- 以下省略------------*/
};
chosen 子节点

chosen 子节点位于根节点下,如下所示

bash 复制代码
chosen {
    bootargs = "earlycon=uart8250,mmio32,0xfe660000 console=ttyFIQ0 root=PARTUUID=614e0000-0000 rw rootwait";
};

chosen 节点不对应硬件,仅用于传递启动参数(如 bootargs),是 U-Boot 与内核之间的参数通道,通常由 U-Boot 和内核自动处理。

8.3 如何获取设备树节点信息

设备树中 "led" 节点对应实际 LED 硬件,可提供驱动所需的寄存器地址等信息。

内核提供以 of_开头OF 操作函数,用于从设备节点获取这些定义的属性(资源),本节将学习如何用这类函数获取所需数据。

8.3.1 查找节点的 of 函数

8.3.1.1 节点路径寻找节点
cpp 复制代码
/*根据节点路径寻找节点*/
struct device_node *of_find_node_by_path(const char *path)

//参数:节点在设备树中的路径
//返回值:device_node:结构体指针,保存着设备节点的信息.如果查找失败则返回 NULL
cpp 复制代码
/*device_node结构体*/
struct device_node {
    const char *name;              /* 节点名,对应设备树中节点的名称 */
    const char *type;              /* 节点类型,通常由 "device_type" 属性指定 */
    phandle phandle;               /* 节点的 phandle 标识符 */
    const char *full_name;         /* 节点的完整路径名 */
    struct fwnode_handle fwnode;   /* 通用固件节点句柄 */

    struct property *properties;   /* 指向该节点的属性链表 */
    struct property *deadprops;    /* 已移除的属性链表 */

    struct device_node *parent;    /* 指向父节点 */
    struct device_node *child;     /* 指向第一个子节点 */
    struct device_node *sibling;   /* 指向兄弟节点 */

#if defined(CONFIG_OF_KOBJ)
    struct kobject kobj;           /* 用于内核对象系统的 kobject */
#endif

    unsigned long _flags;          /* 节点标志位 */
    void *data;                    /* 驱动私有数据指针 */

#if defined(CONFIG_SPARC)
    const char *path_component_name; /* SPARC 架构专用的路径组件名 */
    unsigned int unique_id;         /* SPARC 架构专用的唯一 ID */
    struct of_irq_controller *irq_trans; /* SPARC 架构专用的中断控制器 */
#endif
};

得到 device_node 结构体之后我们就可以使用其他 of 函数获取节点的详细信息。

8.3.1.2 节点名字寻找节点
cpp 复制代码
/*根据节点名字寻找节点*/
struct device_node *of_find_node_by_name(struct device_node *from, //开始查找的节点,NULL表示从根节点开始
                                                const char *name); //要查找的节点名
8.3.1.3 节点类型寻找节点

由于device_type在现在设备树规范中已被废弃,compatiable可以写多个兼容值,该函数也几乎不用。

cpp 复制代码
/*节点类型寻找节点*/
struct device_node *of_find_node_by_type(struct device_node *from, //开始查找的节点,NULL表示根节点开始
                                                const char *type); //要查找的节点类型,对应设备树中的 device_type 属性值
8.3.1.4 节点类型和 compatible 属性寻找节点
cpp 复制代码
struct device_node *of_find_compatible_node(struct device_node *from, //开始查找的节点,NULL表示根节点
                                            const char *type,        //NULL表示忽略device_type,也就是device_node-> type
                                            const char *compatible); //兼容性属性
8.3.1.5 匹配表寻找节点
cpp 复制代码
static inline struct device_node *
of_find_matching_node_and_match(struct device_node *from,            //起始查找节点(NULL 表示从根节点开始)
                                 const struct of_device_id *matches, //匹配表(of_device_id 数组),最后一个元素必须是全 0
                                 const struct of_device_id **match)  //用于返回匹配上的具体 of_device_id 条目(可以为 NULL)

of_device_id 匹配表定义了 "驱动能支持哪些设备树节点",让内核能通过表中规则,自动找到 "设备" 与 "驱动" 的对应关系。

cpp 复制代码
/*驱动的匹配表*/
struct of_device_id {
    char    name[32];          /* 设备名(可选,对应节点的name属性) */
    char    type[32];          /* 设备类型(可选,对应节点的device_type属性,已废弃) */
    char    compatible[128];   /* 核心匹配字段!对应节点的compatible属性 */
    const void *data;          /* 私有数据(可选,传给驱动的自定义数据) */
};
8.3.1.6 寻找父节点
cpp 复制代码
struct device_node *of_get_parent(const struct device_node *node)
8.3.1.7 寻找子节点函数
cpp 复制代码
struct device_node *of_get_next_child(const struct device_node *node, //父节点指针(要遍历其子节点的节点)
                                      struct device_node *prev);//前一个子节点,寻找的是 prev 节点之后的节点。这是一个迭代寻找过程,例如寻找第二个子节点,这里就要填第一个子节点。参数为 NULL 表示寻找第一个子节点。

8.3.2 提取属性值的 of 函数

上一小节的 7 个查找节点函数,均返回 device_node * 指针,

可理解为将设备树中的设备节点 "获取" 到驱动;

获取成功后,再通过另一组 of 函数,从该 device_node 中提取所需的设备节点属性信息。

8.3.2.1 查找节点属性
cpp 复制代码
struct property *of_find_property(const struct device_node *np, // 设备节点
                                  const char *name,            // 属性名
                                  int *lenp);                  // 返回属性值长度

property 结构体,我们把它称为节点属性结构体,如下所示。

失败返回 NULL。从这个结构体中我们就可以得到想要的属性值了。

cpp 复制代码
/*节点属性结构体*/
struct property {
    char *name;                /* 属性名 */
    int length;                /* 属性值的长度 */
    void *value;               /* 属性值的指针 */
    struct property *next;     /* 下一个属性的链表指针 */

#if defined(CONFIG_OF_DYNAMIC) || defined(CONFIG_SPARC)
    unsigned long _flags;      /* 属性标志位 */
#endif

#if defined(CONFIG_OF_PROMTREE)
    unsigned int unique_id;    /* 属性唯一ID */
#endif

#if defined(CONFIG_OF_KOBJ)
    struct bin_attribute attr; /* sysfs 二进制属性 */
#endif
};
8.3.2.2 读取整型属性
cpp 复制代码
//从设备树节点 np 读取 propname 属性的 u8 数组,存入 out_values,返回 0 表示成功,负值表示失败
int of_property_read_u8_array(const struct device_node *np,    // 设备树节点指针
                              const char *propname,           // 要读取的属性名
                              u8 *out_values,                 // 输出缓冲区(存放读取结果)
                              size_t sz);                     // 要读取的元素个数

// 16 位整数读取函数
int of_property_read_u16_array(const struct device_node *np,     // 设备树节点指针
                               const char *propname,            // 要读取的属性名
                               u16 *out_values,                 // 输出缓冲区
                               size_t sz);                      // 要读取的元素个数
// 功能:读取指定属性的 u16 数组,成功返回 0,失败返回负值

// 32 位整数读取函数
int of_property_read_u32_array(const struct device_node *np,     // 设备树节点指针
                               const char *propname,            // 要读取的属性名
                               u32 *out_values,                 // 输出缓冲区
                               size_t sz);                      // 要读取的元素个数
// 功能:读取指定属性的 u32 数组,成功返回 0,失败返回负值

// 64 位整数读取函数
int of_property_read_u64_array(const struct device_node *np,     // 设备树节点指针
                               const char *propname,            // 要读取的属性名
                               u64 *out_values,                 // 输出缓冲区
                               size_t sz);                      // 要读取的元素个数
// 功能:读取指定属性的 u64 数组,成功返回 0,失败返回负值

返回值,成功返回 0,

错误返回错误状态码 (非零值),-EINVAL(属性不存在),-ENODATA(没有要读取的数据),-EOVERFLOW(属性值列表太小)。

比如

cpp 复制代码
my_device {
    compatible = "vendor,mydevice";
    led_masks = /bits/ 8 <0x01 0x02 0x04 0x08>;
};

/*   /bits/ 8 是数据宽度声明:强制指定后续数值的存储宽度为 8 位(1 字节)。
避免设备树默认按 32 位(4 字节)存储数值*/
8.3.2.3 简化的读取整型属性

这里的函数是对读取整型属性函数的简单封装,将读取长度设置为 1。用法与读取属性函数完全一致,不再赘述。

cpp 复制代码
// 8 位整数读取函数(单值)
int of_property_read_u8(const struct device_node *np,    // 设备树节点指针
                        const char *propname,           // 要读取的属性名
                        u8 *out_values);                // 用于返回读取到的 u8 值
// 功能:从 np 节点读取 propname 属性的单个 u8 值,成功返回 0,失败返回负值

// 16 位整数读取函数(单值)
int of_property_read_u16(const struct device_node *np,   // 设备树节点指针
                         const char *propname,          // 要读取的属性名
                         u16 *out_values);               // 用于返回读取到的 u16 值
// 功能:从 np 节点读取 propname 属性的单个 u16 值,成功返回 0,失败返回负值

// 32 位整数读取函数(单值)
int of_property_read_u32(const struct device_node *np,   // 设备树节点指针
                         const char *propname,          // 要读取的属性名
                         u32 *out_values);               // 用于返回读取到的 u32 值
// 功能:从 np 节点读取 propname 属性的单个 u32 值,成功返回 0,失败返回负值

// 64 位整数读取函数(单值)
int of_property_read_u64(const struct device_node *np,   // 设备树节点指针
                         const char *propname,          // 要读取的属性名
                         u64 *out_values);               // 用于返回读取到的 u64 值
// 功能:从 np 节点读取 propname 属性的单个 u64 值,成功返回 0,失败返回负值
8.3.2.4 读取字符串属性函数
cpp 复制代码
// 从设备树节点读取字符串属性
int of_property_read_string(const struct device_node *np,  // 设备节点
                            const char *propname,         // 属性名
                            const char **out_string);     // 输出字符串指针

第一个函数只能得到属性值所在地址,也就是第一个字符串的地址,其他字符串需要我们手动修改移动地址,非常麻烦,推荐使用第二个函数。

cpp 复制代码
// 从设备树节点读取字符串数组中指定索引的字符串
int of_property_read_string_index(const struct device_node *np,  // 设备节点
                                   const char *propname,         // 属性名
                                   int index,                    // 字符串索引
                                                //用于指定读取属性值中第几个字符串,从零开始计数
                                   const char **out_string);     // 输出字符串指针
8.3.2.5 读取布尔型属性函数
cpp 复制代码
// 判断设备树节点是否存在某个属性
static inline bool of_property_read_bool(const struct device_node *np,  // 设备节点
                                          const char *propname);         // 属性名

仅仅是读取这个属性存在或者不存在。

如果想要或取值,可以使用之前讲解的查找节点属性函数 of_find_property。

8.3.3 内存映射相关 of 函数

物理地址映射为虚拟地址

设备树节点常用 reg 属性描述寄存器物理地址,通常是读取后用 ioremap 映射为虚拟地址。现在内核提供 of 系列函数,可直接完成物理地址读取与虚拟地址映射。

cpp 复制代码
// 从设备树节点的 reg 属性中读取物理地址并映射为虚拟地址
void __iomem *of_iomap(struct device_node *np,  // 设备节点
                       int index);              // reg 属性的索引
cpp 复制代码
/*reg属性包含多段地址的最精简示例*/
reg = <0x12340000 0x1000   // 第 0 段:起始地址 0x12340000,长度 0x1000
       0x56780000 0x2000>; // 第 1 段:起始地址 0x56780000,长度 0x2000
物理地址映射为resource结构体

内核也提供了常规获取地址的 of 函数,得到的值就是我们在设备树中设置的地址值。

cpp 复制代码
// 将设备树节点 reg 属性的指定段地址转换为 resource 结构.成功返回0失败返回状态码
int of_address_to_resource(struct device_node *dev,  // 设备节点
                           int index,                // reg 属性段索引
                           struct resource *r);      // 输出 resource 结构
cpp 复制代码
/*resource结构体*/
struct resource {
    resource_size_t start;      // 资源起始值(地址/端口等)
    resource_size_t end;        // 资源结束值
    const char *name;           // 资源名称
    unsigned long flags;                // 资源类型和属性标志
    unsigned long desc;                 // 资源描述信息
    struct resource *parent;            // 父资源
    struct resource *sibling;           // 兄弟资源
    struct resource *child;             // 子资源
};

在设备树中,我们并不直接定义 resource 结构,而是通过 reg 等属性描述硬件资源,内核会自动将其转换为 struct resource。

cpp 复制代码
my_device: my_device@12340000 {
    compatible = "vendor,mydevice";
    reg = <0x12340000 0x1000>; // 起始地址 + 长度
};

驱动中使用

cpp 复制代码
struct resource res;
of_address_to_resource(np, 0, &res);
// 现在 res.start/res.end 包含了物理地址范围

8.4 向设备树添加设备节点实验

8.4.1 实验说明

1、无需从零写设备树(如官方 rk3568.dtsi 已达数千行),引用官方主干并按需修改即可;

2、本节实验用野火 Lubancat2 板卡(Ubuntu20.04 系统),设备树文件为 rk3568-lubancat2.dts;

3、若遇 Permission denied,需 root 权限,可在命令前加 sudo 或用 root 用户运行。

引用官方设备树按需修改
  1. 找到官方设备树文件

内核源码中,官方设备树通常按芯片 / 平台分类存放,路径一般为:

bash 复制代码
linux/arch/[架构]/boot/dts/[厂商]/

例:RK3568 芯片的官方基础文件 rk3568.dtsi(芯片级共性配置,含核心外设),板级文件 如 rk3568-lubancat2.dts(野火板卡的个性化配置)。

  1. 引用官方基础文件(关键:#include 或 #include "xxx.dtsi")

板级设备树(如 rk3568-lubancat2.dts)通过 #include 直接引用官方芯片级 / 平台级 .dtsi 文件,继承其所有配置,无需重复写主干内容:

cpp 复制代码
// 板级设备树:rk3568-lubancat2.dts
#include "rk3568.dtsi"  // 引用官方芯片级基础文件,继承核心配置

/ {
    model = "野火 Lubancat2 RK3568";  // 板卡专属信息
    compatible = "firefly,lubancat2-rk3568", "rockchip,rk3568";
};

// 按需修改官方已有节点(例:修改UART2引脚)
&uart2 {
    pinctrl-0 = <&uart2m0_xfer>;  // 用板级引脚配置覆盖官方默认
    status = "okay";  // 使能该外设(若官方默认禁用)
};

// 按需新增板卡专属节点(例:新增自定义LED)
&{/} {
    my_led {
        compatible = "gpio-leds";
        led1 = <&gpio1 10 GPIO_ACTIVE_HIGH>;
    };
};
  1. 编译验证

修改后,通过内核编译脚本指定板级设备树,生成可烧录的 .dtb 文件:

bash 复制代码
# 进入内核源码根目录,指定架构和交叉编译工具(按需调整)
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- lubancat2_defconfig
# 编译板级设备树(生成 rk3568-lubancat2.dtb)
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs

手动烧录设备树
动态加载设备树 关于DTBO插件的支持情况

驱动是否支持设备树覆盖
驱动示例
cpp 复制代码
/*假设我们有一个简单的GPIO驱动*/
#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/gpio/consumer.h>

static int led_probe(struct platform_device *pdev)
{
    struct gpio_desc *gpiod;

    /* 从设备树读取 gpios 属性 */
    /*驱动动态读取设备属性,因此设备overlay修改后驱动能正常运行*/
    gpiod = devm_gpiod_get(&pdev->dev, NULL, GPIOD_OUT_LOW);
    if (IS_ERR(gpiod)) {
        dev_err(&pdev->dev, "Failed to get GPIO\n");
        return PTR_ERR(gpiod);
    }

    dev_info(&pdev->dev, "LED probe success\n");
    return 0;
}

static int led_remove(struct platform_device *pdev)
{
    dev_info(&pdev->dev, "LED remove\n");
    return 0;
}

/*驱动注册了device_id匹配表*/
static const struct of_device_id led_of_match[] = {
    { .compatible = "my,led" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, led_of_match);
/********/

static struct platform_driver led_driver = {
    .probe  = led_probe,
    .remove = led_remove,
    .driver = {
        .name = "my-led",
        .of_match_table = led_of_match,
    },
};
module_platform_driver(led_driver);

MODULE_LICENSE("GPL");
对应的设备树覆盖文件
bash 复制代码
/dts-v1/;
/plugin/;

/ {
    fragment@0 {
        target-path = "/";
        __overlay__ {
            my_led {
                compatible = "my,led";
                gpios = <&gpio1 10 GPIO_ACTIVE_HIGH>;
            };
        };
    };
};
编译成 .dtbo
cpp 复制代码
dtc -O dtb -o my_led.dtbo -b 0 -@ my_led.dts
动态加载 overlay

假设内核已经开启overlay支持

bash 复制代码
mkdir -p /sys/kernel/config/device-tree/overlays/my_led
cat my_led.dtbo > /sys/kernel/config/device-tree/overlays/my_led/dtbo

#cat xx > xxx 是覆盖写入

加载后,内核会检测到新的 my_led 节点,并根据 compatible = "my,led" 找到我们的驱动,调用 led_probe()。
驱动要支持设备树 overlay,需要:
1、实现 of_device_id 匹配表;
2、所有硬件参数从设备树动态读取;
3、支持内核的动态绑定机制(如 platform_driver)

驱动是否支持内核动态绑定
of_device_id扫描时期

'
动态加载设备树也就是
cat my_led.dtbo > /sys/kernel/config/device-tree/overlays/my_led/dtbo

8.4.2 代码讲解

实际应用中常见设备树操作:新增节点 、现有节点追加数据编写设备树插件

修改设备树源文件.dts

Lubancat2 默认设备树为 kernel/arch/arm64/boot/dts/rockchip/rk3568-lubancat2.dts,下文将基于该文件尝试新增设备节点。

cpp 复制代码
/dts-v1/;

#include <dt-bindings/gpio/gpio.h>
#省略
#include "rk3568.dtsi"

/ {
    model = "EmbedFire LubanCat2 HDMI";
    compatible = "embedfire,lubancat2", "rockchip,rk3568";

    /* ....................... */

    /* 添加 led_test 节点 ,标签名:节点名,由于该节点不属于设备节点故无物理地址映射*/
    get_dts_info_test: get_dts_info_test {
        compatible = "get_dts_info_test";
        #address-cells = <1>;
        #size-cells = <1>;

        led@0xfdd60000 {      // GPIO0 基地址 0xfdd60000
            compatible = "fire,led_test";
            reg = <0xfdd60000 0x00000100>;  //reg是物理地址
            status = "okay"; 
        };
    };

    /* ....................... */
};

在 rk3568-lubancat2.dts 中新增了 led_test 节点,该节点包含一个子节点。

内核编译设备树:

编译内核时会自动编译设备树,但是编译内核很耗时,所以我们推荐使用如下命令只编译设备树:

bash 复制代码
# 使用 rk356x 系列配置文件生成默认配置
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- lubancat2_defconfig

# 编译设备树文件(-j4 表示使用 4 个线程并行编译)
make ARCH=arm64 -j4 CROSS_COMPILE=aarch64-linux-gnu- dtbs


#ARCH=arm64:指定目标架构为 ARM64
#CROSS_COMPILE=aarch64-linux-gnu-:指定交叉编译工具链前缀
#lubancat2_defconfig:使用板级默认配置
#dtbs:只编译设备树文件(生成 .dtb 文件)

编译成功后生成的设备树文件 (.dtb) 位于源码目录下的 arch/arm64/boot/dts/rockchip/,文件名为 "rk3568-lubancat2.dtb"。(和dts文件在一个目录)

8.4.3 程序结果

8.4.3.1 加载设备树

同 SCP 或 NFS 将编译的设备树拷贝到开发板上,替换/boot/dtb/rk3568-lubancat2.dtb。

uboot 在启动的时候负责该目录的设备文件加载到内存,供内核解析使用,重启开发板。

也即用改过的 dtb替换/boot/dtb目录下的设备树文件。

8.4.3.2 实验结果

设备树中的设备树节点在文件系统中有与之对应的文件,

位于"/proc/device-tree"目录。

进入"/proc/device-tree"目录如下所示。
/proc/device_tree目录下的设备节点

接着进入 led 文件夹,可以发现 led 节点中定义的属性以及它的子节点,
/proc/device_tree下的节点文件的属性及其子节点

在节点属性中多了一个 name,我们在 led 节点中并没有定义 name 属性,这是自从生成的,保存节点名。

进入 /sys/firmware/devicetree/base/.../get_dts_info_test/led@0xfdd60000 目录,

里面有 compatible、name、reg、status 四个属性文件,可用 cat 查看其内容。
设备节点的属性

至此,我们已经成功的在设备树中添加了一个名为"get_dts_info_test"的节点。

8.5 在驱动中获取节点属性实验

8.5.1 代码讲解

这里只列出了 get_dts_info_probe 函数中的内容,

cpp 复制代码
/* get_dts_info_probe 函数 */
static int get_dts_info_probe(struct platform_device *pdev)
{
    int error_status = -1;
    pr_info("%s\n", __func__);

    // 通过路径查找设备节点
    led_test_device_node = of_find_node_by_path("/get_dts_info_test");
    if (led_test_device_node == NULL) {
        printk(KERN_ALERT "\n get led_device_node failed ! \n");
        return -1;
    }

    /* 根据 led_test_device_node 设备节点结构体输出节点的基本信息 */
    printk(KERN_ALERT "name: %s", led_test_device_node->name); // 输出节点名
    printk(KERN_ALERT "child name: %s", led_test_device_node->child->name); // 输出子节点名

    /* 获取 led_device_node 的子节点 */
    led_device_node = of_get_next_child(led_test_device_node, NULL);
    if (led_device_node == NULL) {
        printk(KERN_ALERT "\n get led_device_node failed ! \n");
        return -1;
    }
    printk(KERN_ALERT "name: %s", led_device_node->name); // 输出节点名
    printk(KERN_ALERT "parent name: %s", led_device_node->parent->name); // 输出父节点名

    /* 获取 led_device_node 节点的 "compatible" 属性 */
    led_property = of_find_property(led_device_node, "compatible", &size);
    if (led_property == NULL) {
        printk(KERN_ALERT "\n get led_property failed ! \n");
        return -1;
    }
    printk(KERN_ALERT "size = : %d", size); // 实际读取得到的长度
    printk(KERN_ALERT "name: %s", led_property->name); // 输出属性名
    printk(KERN_ALERT "length: %d", led_property->length); // 输出属性长度
    printk(KERN_ALERT "value : %s", (char*)led_property->value); // 属性值

    /* 获取 reg 地址属性 */
    error_status = of_property_read_u32_array(led_device_node, "reg", out_values, 2);
    if (error_status != 0) {
        printk(KERN_ALERT "\n get out_values failed ! \n");
        return -1;
    }
    printk(KERN_ALERT "0x%08X ", out_values[0]);
    printk(KERN_ALERT "0x%08X ", out_values[1]);

    return 0;
}

进入到驱动模块文件夹中,编译驱动模块:

bash 复制代码
make

该文件夹会执行 makefile 产生 get_dts_info.ko 驱动模块。

8.5.2 程序结果

使用 sudo insmode 安装驱动后可以看到,
打印出了节点的属性值

第 9 章 Linux 设备树---LED 灯实验

9.2 实验代码讲解

9.2.1 编程思路

程序编写核心围绕 LED 控制,分五步:

  1. 向设备树添加 LED 节点

  2. 平台设备驱动框架(含入口、注销函数及设备结构体);

  3. 实现probe 函数完成 LED 注册与初始化

  4. 实现字符设备 write 操作函数

  5. 编测试应用,通过输入不同值控制 LED 亮灭。

9.2.2 代码分析

9.2.2.1 添加 RGB 设备节点

RGB 灯实际使用的是一个 IO 口,控制只需要控制寄存器。

添加设备节点

cpp 复制代码
/* 添加 led_test 节点 */
led_test {
    #address-cells = <1>;
    #size-cells = <1>;
    compatible = "fire,led_test";

    // 例程是控制 lubancat2 的系统灯 GPIO0_C7
    led@0xfdd60004 {      // 数据寄存器 (高 16 位) 地址
        // 数据寄存器和数据方向寄存器 (高 16 位) 的地址和范围
        reg = <0xfdd60004 0x00000004 
               0xfdd6000C 0x00000004>;
        status = "okay";
    };
};
9.2.2.2 编写驱动程序

设备树驱动平台总线驱动核心相似,核心差异是设备树替代 "平台设备",平台驱动只需与对应设备树节点匹配。

驱动程序核心包含四部分内容:

1、搭建平台设备驱动框架。

2、编写.prob 函数。

3、实现字符设备操作函数集。

4、完成驱动注销功能。

驱动入口函数

驱动入口函数仅仅注册一个平台驱动,如下所示

cpp 复制代码
/*
 * 驱动初始化函数
 */
static int __init led_platform_driver_init(void)
{
    int DriverState;                    
    DriverState = platform_driver_register(&led_platform_driver);//参数是平台设备结构体
    printk(KERN_EMERG "\tDriverState is %d\n", DriverState);
    return 0;
}

在整个入口函数中仅仅调用了"platform_driver_register"函数注册了一个平台驱动。参数是传入一个平台设备结构体。

定义平台设备结构体

注册平台驱动时会用到平台设备结构体,指定平台驱动的.probe 函数、指定与平台驱动匹配的平台设备 ,使用了设备树后就是指定与平台驱动匹配的设备树节点

cpp 复制代码
static const struct of_device_id led_ids[] = {
    {.compatible = "fire,led_test"},
    {/* sentinel */}
};

/* 定义平台设备结构体 */
struct platform_driver led_platform_driver = {
    .probe = led_probe,
    .driver = {
        .name = "leds-platform",
        .owner = THIS_MODULE,
        .of_match_table = led_ids,
    }
};
实现.probe 函数

当驱动和设备树节点匹配成功后会自动执行.probe 函数,所以我们在.probe 函数中实现一些初始化工作。本实验将 RGB 初始化以及字符设备的初始化全部放到.probe 函数中实现。

cpp 复制代码
/* 定义 led 资源结构体,保存获取得到的节点信息以及转换后的虚拟寄存器地址 */
struct led_resource {
    struct device_node *device_node;  // led 的设备树节点
    void __iomem *va_DR;
    void __iomem *va_DDR;
};

static int led_probe(struct platform_device *pdv) {
    int ret = -1;                     // 保存错误状态码
    unsigned int register_data = 0;

    printk(KERN_EMERG "\t match successed \n");

    /* 获取 led_test 的设备树节点 */
    led_test_device_node = of_find_node_by_path("/led_test");
    if (led_test_device_node == NULL) {
        printk(KERN_ERR "\t get led_test failed! \n");
        return -1;
    }

    /* 获取 led 节点 */
    led_res.device_node = of_find_node_by_name(led_test_device_node, "led");
    if (led_res.device_node == NULL) {
        printk(KERN_ERR "\n get led_device_node failed ! \n");
        return -1;
    }

    /* 获取 reg 属性并转化为虚拟地址 */
    led_res.va_DR = of_iomap(led_res.device_node, 0);
    if (led_res.va_DR == NULL) {
        printk("of_iomap is error \n");
        return -1;
    }

    led_res.va_DDR = of_iomap(led_res.device_node, 1);
    if (led_res.va_DDR == NULL) {
        printk("of_iomap is error \n");
        return -1;
    }

    // 设置模式寄存器:输出模式
    register_data = readl(led_res.va_DDR);               // GPIO0_C7
    register_data |= ((unsigned int)0X1 << (7));         // 低 16 位控制 GPIO 的输出模式
    register_data |= ((unsigned int)0X1 << (23));        // 7+16,高 16 位控制低 16 位的写使能
    writel(register_data, led_res.va_DDR);

    // 设置置位寄存器:默认输出高电平
    register_data = readl(led_res.va_DR);
    register_data |= ((unsigned int)0x1 << (7));         // 低 16 位控制 GPIO 的电平
    register_data |= ((unsigned int)0x1 << (23));        // 7+16,高 16 位控制低 16 位的写使能
    writel(register_data, led_res.va_DR);

    /*---------------------注册 字符设备部分-----------------*/

    // 第一步:采用动态分配的方式,获取设备编号,次设备号为 0,
    // DEV_CNT 为 1,当前只申请一个设备编号
    ret = alloc_chrdev_region(&led_devno, 0, DEV_CNT, DEV_NAME);
    if (ret < 0) {
        printk("fail to alloc led_devno\n");
        goto alloc_err;
    }

    // 第二步:关联字符设备结构体 cdev 与文件操作结构体 file_operations
    led_chr_dev.owner = THIS_MODULE;
    cdev_init(&led_chr_dev, &led_chr_dev_fops);

    // 第三步:添加设备至 cdev_map 散列表中
    ret = cdev_add(&led_chr_dev, led_devno, DEV_CNT);
    if (ret < 0) {
        printk("fail to add cdev\n");
        goto add_err;
    }

    // 第四步
    /* 创建类 */
    class_led = class_create(THIS_MODULE, DEV_NAME);

    /* 创建设备 */
    device = device_create(class_led, NULL, led_devno, NULL, DEV_NAME);

    return 0;

add_err:
    // 添加设备失败时,需要注销设备号
    unregister_chrdev_region(led_devno, DEV_CNT);
    printk("\n error! \n");
alloc_err:

    return -1;
}
实现字符设备操作函数集
cpp 复制代码
/* 字符设备操作函数集,open 函数 */
static int led_chr_dev_open(struct inode *inode, struct file *filp)
{
    printk("\n led_chr_dev_open \n");
    return 0;
}

/* 字符设备操作函数集,write 函数 */
static ssize_t led_chr_dev_write(struct file *filp, const char __user *buf,
                                 size_t cnt, loff_t *offt)
{
    unsigned int register_data = 0;  // 暂存读取得到的寄存器数据
    unsigned char write_data;        // 用于保存接收到的数据

    int error = copy_from_user(&write_data, buf, cnt);
    if (error < 0) {
        return -1;
    }

    /* 设置 led 引脚 输出电平 */
    if (write_data) {
        register_data |= ((unsigned int)0x1 << (7));  // 低 16 位置 1
        register_data |= ((unsigned int)0x1 << (23)); // 7+16,开启低 16 位的写使能
        writel(register_data, led_res.va_DR);         // lubancat2 的 GPIO0_C7 引脚输出高电平,红灯灭
    } else {
        register_data &= ~((unsigned int)0x1 << (7)); // 低 16 位置 0
        register_data |= ((unsigned int)0x1 << (23)); // 7+16,开启低 16 位的写使能
        writel(register_data, led_res.va_DR);         // lubancat2 的 GPIO0_C7 引脚输出低电平,红灯亮
    }

    return 0;
}

/* 字符设备操作函数集 */
static struct file_operations led_chr_dev_fops = {
    .owner = THIS_MODULE,
    .open  = led_chr_dev_open,
    .write = led_chr_dev_write,
};
9.2.2.3 编写测试应用程序

在驱动程序中我采用自动创建设备节点的方式创建了字符设备的设备节点文件,文件名可自定义,写测试应用程序时记得文件名即可。

在测试应用中直接通过 ops 文件操作去控制就好。

9.3 编译驱动程序

9.3.1 编译设备树

将 led_test 节点添加到设备树中,并在内核源码根目录执行如下命令,使用内核源码的顶层makefile。

bash 复制代码
# 1. 配置内核:使用lubancat2开发板的默认配置,指定ARM64架构和交叉编译器
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- lubancat2_defconfig

# 2. 编译设备树:4线程并行编译ARM64架构的设备树文件
make ARCH=arm64 -j4 CROSS_COMPILE=aarch64-linux-gnu- dtbs

9.3.2 编译驱动和应用程序

执行 make 命令,Makefile 和前面章节大致相同。

最终会生成 led_test.ko 和 test_app 应用程序。

9.4 程序运行结果

9.4.1 实验操作

鲁班猫板卡默认使能 LED 设备功能,可通过两种方式关闭系统 LED 驱动控制:

修改设备树:

将 leds 节点的 status 从 "okay" 改为 "disabled",重新编译添加了灯节点的设备树并替换。

bash 复制代码
#(亮度设为 0,触发条件自动变为 none)
sudo sh -c 'echo 0 > /sys/class/leds/sys_status_led/brightness'

替换原来的设备树/boot/dtb/rk3568-lubancat.dtb,并重启开发板

重启后在目录/proc/device-tree/下,可以找到 led_test,图中控制的是 GPIO0_C7 引脚

装载驱动后测试应用程序,通过应用程序的参数,可以看到指示灯亮灭。

第 10 章 设备树插件 (Device TreeOverlays)

Linux4.4 及以后的动态设备树(译为 "设备树插件"),是主设备树的动态加载 "补丁"。

新增硬件驱动(如 RGB 驱动)时,编写对应插件编译加载即可,无需重编整个设备树。它兼容原有设备树语法,可直接复用原有设备树节点,具体使用方法如下。

10.1 设备树插件格式

设备树插件格式相对固定,可视为给设备节点加 "壳",编译后内核能动态加载,

具体节点省略。

cpp 复制代码
/dts-v1/;  //版本
/plugin/;  //设备树插件

/ {
    fragment@0 {
        target-path = "/";              //指定设备树插件的加载位置
        __overlay__ {                    //要叠加的内容
            /* 在此添加要插入的节点 */
            .......
        };
    };
    
    fragment@1 {
        target = <&XXXXX>;
        __overlay__ {   
            /* 在此添加要插入的节点 */
            .......
        };
    };
    
    .......
};

直接引用而非 name@address

cpp 复制代码
/dts-v1/;
/plugin/;

&{/} {
    /* 此处在根节点"/" 下, 添加要插入的节点或者属性 */
};

&XXXXX {
    /* 此处在节点"XXXXX" 下, 添加要插入的节点或者属性 */
};

10.2 设备树插件加载

以 lubancat2 为例 (ubuntu20.04 镜像),该设备树插件的加载是通过 uboot,流程如下:
设备树插件通过 uboot 加载

编写设备树插件源文件,通过 DTC 工具编译生成.dtbo 文件,存储在 boot 分区;

加载 boot 分区的设备树插件到内存;

在 uboot 中,合并设备树插件 dtbo 和设备树 dtb 文件为一个设备树,并得到内存指定地址;

启动内核,传递设备树在内存中的地址。

10.3 设备树插件实验一

10.3.2 设备树插件编写和加载

为避免冲突,需要删除上一章节在主设备树 .dst 文件上上添加的 led_test 节点,改为设备树插件的形式。

在内核源码 /arch/arm64/boot/dts/rockchip/overlays 目录下添加名为 lubancat-led-overlay.dts 的文件。

修改内核目录/arch/arm64/boot/dts/rockchip/overlays 下的 Makefile 文件,添加我们编辑好的设备树插件。并把设备树插件文件放在和 Makefile 文件同级目录下。以进行设备树插件的编译。

cpp 复制代码
/dts-v1/;
/plugin/;

/ {
    fragment@0 {
        target-path = "/";

        __overlay__ {
            /* 添加 led_test 节点 */
            led_test {
                #address-cells = <2>;
                #size-cells = <1>;
                compatible = "fire,led_test";
                ranges;

                led@0xfec50000 {
                    reg = <0xfec50000 0x00000004 
                           0xfec50008 0x00000004>;
                    status = "okay";
                };
            };
        };
    };
};

把 -overlay.dtbo 放到 boot/dts/厂商/overlays 目录下

bash 复制代码
# 加载配置文件
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- lubancat_linux_rk3588_defconfig

# 使用 dtbs 参数单独编译设备树
make ARCH=arm64 -j4 CROSS_COMPILE=aarch64-linux-gnu- dtbs

为什么编译前要先生成配置文件

将内核源码/arch/arm64/boot/dts/rockchip/overlay/目录下

编译生成的 lubancat-led-overlay.dtbo文件,

传输到板卡/boot/dtb/overlay/目录;

在板卡 /boot/uEnv/uEnv.txt 中按格式添加该设备树插件,(uboot启动内核前,会自动加载 uEnv.txt 中指定的插件,到overlay目录去找,然后与主设备树合并)

重启开发板后,系统即可加载该插件。

关于 /boot/uEnv/uEnv.txt的作用

/boot/uEnv/uEnv.txt 是嵌入式 Linux 系统中用于传递启动参数和配置信息的关键文件,

主要作用是在系统启动阶段(内核加载前)向引导程序(如 U-Boot)和内核传递自定义配置,实现对系统启动过程的灵活控制。
/boot/uEnv/uEnv.txt的作用 uEnv.txt中添加设备树插件

10.3.3 驱动代码

驱动代码和上章完全一样,只不过上章的设备通过设备树注册,本章通过设备树插件注册。

10.3.4 测试 LED

先关闭鲁班猫系列板卡中默认的设备树插件对LED的控制。

bash 复制代码
#将 led 的亮度调为 0,与此同时 led 的触发条件自动变为 none,从而取消 leds 驱动对 LED 的控制。
sudo sh -c 'echo 0 > /sys/class/leds/sys_status_led/brightness'

将设备树、驱动程序和应用程序拷贝到开发板中。

重启后在目录/proc/device-tree/下,可以找到 led_test,图中控制的是 GPIO0_C7 引脚

bash 复制代码
#加载驱动
sudo insmod led_test.ko

由于代码在平台设备驱动的probe函数中注册了字符设备

bash 复制代码
#执行测试函数
./test_app < 参数 >

可以看到灯根据参数亮灭。

相关推荐
HLC++11 小时前
Linux的进程间通信
android·linux·服务器
华清远见IT开放实验室12 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设
groundhappy15 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
通信小小昕16 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫17 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生17 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.1118 小时前
linux基础IO
linux·运维·服务器
BelongPanda20 小时前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯20 小时前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_7779983421 小时前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器