提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 前言
- 一、驱动框架演进:三层逐步实现解耦与复用
- [1.1 第一层:原生字符设备驱动(cdev)](#1.1 第一层:原生字符设备驱动(cdev))
- [1.2 第二层:misc子系统------字符设备的轻量化封装](#1.2 第二层:misc子系统——字符设备的轻量化封装)
- [1.3 第三层:platform总线------驱动与硬件的彻底解耦](#1.3 第三层:platform总线——驱动与硬件的彻底解耦)
- [1.4 三层框架对比总结](#1.4 三层框架对比总结)
- 二、设备树(DTS)深度实战与常见问题
- [2.1 设备树的文件形态](#2.1 设备树的文件形态)
- [2.2 设备树节点的标准写法](#2.2 设备树节点的标准写法)
- [2.3 引脚复用配置(pinctrl)](#2.3 引脚复用配置(pinctrl))
- [2.4 编译与部署全流程](#2.4 编译与部署全流程)
- [2.5 常见编译错误与排坑](#2.5 常见编译错误与排坑)
- [2.6 vim编辑设备树的踩坑记录](#2.6 vim编辑设备树的踩坑记录)
- 三、中断与阻塞I/O:按键驱动深度实践
- [3.1 中断的分层处理:顶半部与底半部](#3.1 中断的分层处理:顶半部与底半部)
- [3.2 工作队列实现底半部](#3.2 工作队列实现底半部)
- [3.3 阻塞I/O与等待队列](#3.3 阻塞I/O与等待队列)
- [3.4 GPIO子系统标准接口](#3.4 GPIO子系统标准接口)
- 四、单总线外设驱动:DHT11驱动开发
- [4.1 单总线通信原理](#4.1 单总线通信原理)
- [4.2 GPIO模拟时序实现](#4.2 GPIO模拟时序实现)
- [4.3 延时函数的选择](#4.3 延时函数的选择)
- [4.4 稳定性优化:重试机制](#4.4 稳定性优化:重试机制)
- [4.5 框架复用](#4.5 框架复用)
- 五、工程化实践:编译、部署与调试
- [5.1 内核模块Makefile编写规范](#5.1 内核模块Makefile编写规范)
- [5.2 应用层交叉编译](#5.2 应用层交叉编译)
- [5.3 两种部署方式对比](#5.3 两种部署方式对比)
- [5.4 驱动加载与卸载调试](#5.4 驱动加载与卸载调试)
- [5.5 常见加载失败问题排查](#5.5 常见加载失败问题排查)
- 六、学习总结与方法论
- [6.1 核心收获](#6.1 核心收获)
- [6.2 调试方法论](#6.2 调试方法论)
- [6.3 后续方向](#6.3 后续方向)
- 附录:本阶段踩坑清单速查
前言
这一阶段的学习围绕Linux字符设备驱动的工程化分层思想逐步深入,从最基础的字符设备驱动封装,到misc子系统简化模板,再到platform总线+设备树实现驱动与硬件彻底解耦,同时引入中断分层处理、阻塞I/O与等待队列、GPIO子系统、单总线时序模拟等核心知识点。全程以LED、按键、DHT11温湿度传感器三个外设为实战载体,覆盖从设备树编写、驱动代码实现、交叉编译、部署到调试排错的完整链路,记录了开发过程中大量真实踩坑点与解决方案。
一、驱动框架演进:三层逐步实现解耦与复用
Linux驱动开发的核心设计思想是分层、封装、解耦,内核将通用逻辑逐层下沉,驱动开发者只需要关注硬件差异和业务逻辑,避免重复实现相同的模板代码,同时提升驱动的可移植性。
1.1 第一层:原生字符设备驱动(cdev)
这是字符设备驱动的最原始形态,直接使用内核提供的cdev接口完成设备注册。
- 核心流程:手动分配设备号 → 创建
cdev结构体 → 绑定file_operations操作集 → 向内核添加cdev → 创建设备节点 - 特点:所有逻辑全部自己实现,硬件引脚、寄存器直接写死在驱动代码中
- 缺点:代码冗余度高,硬件改动必须修改驱动源码,可移植性差
- 对应学习阶段:第1-2天的基础LED字符设备驱动
1.2 第二层:misc子系统------字符设备的轻量化封装
misc(杂项设备)是内核对字符设备驱动的一层标准封装,自动处理了设备号分配、cdev注册、设备节点创建等固定流程,是字符设备驱动最常用的简化框架。
-
核心结构体:
struct miscdevicestatic struct miscdevice led_misc = { .minor = MISC_DYNAMIC_MINOR, // 自动分配次设备号 .name = "led_misc", // 设备名,对应/dev下的节点名 .fops = &fops, // 绑定文件操作集 }; -
核心接口:
misc_register/misc_deregister,一行代码完成注册与注销 -
优势:
- 省去手动分配设备号、
cdev_init、cdev_add、创建类等模板化代码 - 统一由内核管理杂项设备,资源更规范
- 代码量比原生cdev减少近一半,结构更清晰
- 省去手动分配设备号、
-
适用场景:简单字符设备、小型外设驱动,LED、按键、传感器等都非常适合
1.3 第三层:platform总线------驱动与硬件的彻底解耦
这是本阶段学习的核心,也是现代Linux驱动的标准开发模式。platform是一条虚拟总线,将硬件描述信息 和驱动逻辑代码 完全拆分开,通过compatible属性进行匹配。
两端分工
| 角色 | 存放位置 | 内容 |
|---|---|---|
| 设备端 | 设备树(DTS) | 硬件引脚、中断号、引脚复用、时钟等板级硬件信息 |
| 驱动端 | 内核驱动代码 | 通用驱动逻辑、probe初始化、remove资源释放、文件操作接口 |
核心机制
- 内核启动时解析设备树,注册所有platform设备
- 驱动加载时,向platform总线注册驱动
- 总线自动对比设备的
compatible字符串和驱动的匹配表,匹配成功则调用驱动的probe函数 probe函数中完成硬件初始化、资源申请、设备注册等工作
优势
- 驱动通用化:一套驱动可以适配多块不同引脚的开发板,只需修改设备树,无需改动驱动源码
- 硬件信息统一管理:所有板级硬件描述集中在设备树中,符合Linux内核"板级信息下移"的发展方向
- 资源自动管理 :配合
devm_系列接口,驱动卸载时自动释放申请的GPIO、中断、内存等资源,减少内存泄漏
1.4 三层框架对比总结
| 框架 | 代码量 | 可移植性 | 硬件耦合度 | 适用场景 |
|---|---|---|---|---|
| 原生cdev | 大 | 差 | 高 | 学习原理、特殊定制设备 |
| misc封装 | 中 | 一般 | 中 | 简单字符设备 |
| platform+misc+设备树 | 小 | 好 | 低 | 标准外设驱动、产品级开发 |
本阶段所有外设驱动均采用 platform总线 + misc设备 + 设备树 的标准架构,这也是工业界最常用的字符设备驱动开发范式。
二、设备树(DTS)深度实战与常见问题
设备树是本阶段学习的重点之一,也是很多新手的第一个卡点,从文件位置、编辑方法、语法规则到编译部署,每一步都有容易踩的坑。
2.1 设备树的文件形态
| 文件类型 | 全称 | 位置 | 作用 |
|---|---|---|---|
.dts |
设备树源码 | 内核源码arch/arm/boot/dts/ |
人类可编辑的文本文件,描述板级硬件信息 |
.dtsi |
设备树头文件 | 内核源码arch/arm/boot/dts/ |
描述SOC级通用硬件信息,板级dts通过#include引用 |
.dtb |
设备树二进制 | 编译后生成在同目录 | 内核可直接解析的二进制文件,开发板启动加载的就是它 |
.dtc |
设备树编译器 | 内核源码脚本目录 | 编译工具,将dts编译为dtb |
2.2 设备树节点的标准写法
以三个外设为例,标准节点包含以下要素:
/* 1. LED设备节点 */
myled0 {
compatible = "pute,myled"; // 驱动匹配字符串
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_myled0>; // 引用引脚复用配置
gpio-myled0 = <&gpio1 3 0>; // GPIO引脚:组号+引脚号+默认电平
status = "okay"; // 启用节点
};
/* 2. 按键设备节点(带中断) */
putekey {
compatible = "pute,putekey";
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_putekey>;
gpio-key = <&gpio1 18 0>;
interrupt-parent = <&gpio1>; // 中断控制器
interrupts = <18 IRQ_TYPE_EDGE_FALLING>; // 中断号+触发方式
status = "okay";
};
/* 3. DHT11单总线设备节点 */
putedht11 {
compatible = "pute,putedht11";
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_putedht11>;
gpio-dht11 = <&gpio1 4 0>;
status = "okay";
};
2.3 引脚复用配置(pinctrl)
仅在设备节点里指定GPIO是不够的,还需要在&iomuxc节点中配置引脚的复用功能,将引脚设置为通用GPIO模式并配置电气属性。
&iomuxc {
imx6ul-evk {
pinctrl_myled0: myled0grp {
fsl,pins = <
MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x10B0
>;
};
};
};
- 命名规则:
pinctrl_xxx标签名要和设备节点里的引用名一致 - 语法格式:
PAD名__复用功能+ 电气配置值 - 注意:标签名是全局唯一的,重复定义会导致编译失败
2.4 编译与部署全流程
-
编辑源码 :在内核源码的
arch/arm/boot/dts/目录下修改板级dts文件 -
编译设备树 :必须在内核源码根目录 执行命令
make dtbs输出
DTC arch/arm/boot/dts/xxx.dtb即为编译成功 -
部署到启动目录 :将编译好的dtb拷贝到TFTP共享目录
cp arch/arm/boot/dts/imx6ull-alientek-emmc.dtb ~/tftpboot/ -
重启生效:开发板断电重启,u-boot会从TFTP加载新的dtb文件
-
验证节点 :开发板进入系统后验证
ls /proc/device-tree/节点名能看到
compatible、gpio等属性文件,说明节点已被内核成功解析
2.5 常见编译错误与排坑
问题1:Duplicate label 编译错误
- 报错信息:
ERROR (duplicate_label): Duplicate label 'pinctrl_xxx' - 原因:设备树中带冒号的标签是全局唯一标识符,两个节点使用了相同的标签名
- 解决:删除重复的节点,只保留一个正确配置;或者修改其中一个标签名,同步修改设备节点中的引用
问题2:修改设备树后不生效
- 原因:只修改了dts源码,没有重新编译dtb;或者编译了没有拷贝到启动目录;或者拷贝了但没有重启开发板
- 解决:严格按照「修改→编译→拷贝→重启」四步执行,缺一不可
问题3:驱动probe函数不执行
- 优先排查:设备树的
compatible字符串和驱动的匹配表是否完全一致,包括大小写、逗号分隔 - 其次排查:设备节点
status是否为okay,pinctrl配置是否正确
2.6 vim编辑设备树的踩坑记录
设备树通常用vim编辑,新手很容易在模式切换上卡住,这是本阶段高频问题。
问题1:输入的文字删不掉
- 原因:还停留在插入模式,按键盘只会继续输入字符,不会执行删除命令
- 解决:先按
Esc回到正常命令模式 ,再执行删除操作- 删单个字符:光标移到目标字符,按
x - 删整行:光标移到目标行,连续按两次
d(dd命令) - 删多行:
ndd删除n行
- 删单个字符:光标移到目标字符,按
问题2:打开文件出现swap file提示
- 现象:打开dts文件时提示
Found a swap file by the name ... - 原因:之前编辑文件时异常退出(直接关闭终端、断电等),vim留下了临时交换文件
- 解决:按
D键删除交换文件,即可正常编辑
问题3:查找定位内容
- 正常模式下输入
/关键词,回车即可跳转到第一个匹配位置 - 按
n跳转到下一个匹配项,按N跳转到上一个 - 编辑设备树时非常适合快速定位节点、引脚配置
三、中断与阻塞I/O:按键驱动深度实践
按键驱动是学习中断、阻塞I/O的最佳载体,核心是理解Linux中断的分层处理思想,以及用户态与内核态的同步机制。
3.1 中断的分层处理:顶半部与底半部
Linux中断设计的核心原则是:顶半部快进快出,耗时逻辑下移到底半部。
-
顶半部 :中断触发后立即执行,只做最紧急、最耗时短的操作
<ul> -
例如:清除中断标志、标记事件状态、唤醒等待队列
-
执行上下文:中断上下文,不可睡眠
- 底半部 :处理耗时的业务逻辑,在系统空闲时执行
- 例如:数据处理、上报用户态、日志打印
- 执行上下文:进程上下文(工作队列)或中断上下文(tasklet)
3.2 工作队列实现底半部
工作队列是最常用的底半部机制,运行在进程上下文,可以睡眠,使用灵活。
-
定义工作队列与工作项
static struct workqueue_struct *key_wq; static struct work_struct key_work; -
顶半部调度工作
static irqreturn_t key_isr(int irq, void *dev) { schedule_work(&key_work); // 调度底半部 return IRQ_HANDLED; } -
底半部处理函数
static void key_work_func(struct work_struct *work) { // 按键消抖、状态更新、唤醒等待队列等耗时逻辑 }
3.3 阻塞I/O与等待队列
实现用户态read函数的阻塞效果:没有数据时进程休眠,有数据时自动唤醒。
-
定义等待队列头
static DECLARE_WAIT_QUEUE_HEAD(key_wq); -
read函数中实现阻塞
static ssize_t key_read(struct file *fp, char __user *puser, size_t n, loff_t *off) { wait_event_interruptible(key_wq, 事件标志); // 不满足条件则休眠 copy_to_user(...); // 唤醒后拷贝数据到用户空间 } -
中断底半部唤醒队列
wake_up_interruptible(&key_wq);
3.4 GPIO子系统标准接口
本阶段所有GPIO操作均使用内核标准GPIO子系统API,不直接操作寄存器。
| API | 作用 |
|---|---|
of_get_named_gpio |
从设备树节点中解析获取GPIO编号 |
devm_gpio_request |
申请GPIO引脚,devm前缀自动管理资源 |
gpio_direction_input |
设置引脚为输入模式 |
gpio_direction_output |
设置引脚为输出模式并指定默认电平 |
gpio_get_value |
读取引脚电平 |
gpio_set_value |
设置引脚电平 |
gpio_to_irq |
GPIO引脚映射为中断号 |
四、单总线外设驱动:DHT11驱动开发
DHT11温湿度传感器采用单总线协议,通过一根GPIO引脚实现双向通信,是练习GPIO方向切换、时序模拟的绝佳案例。
4.1 单总线通信原理
单总线只有一根数据线SDA,主机和从机分时复用,通信全程由主机发起,从机应答。
- 起始信号:主机拉低总线至少18ms,再拉高20~40us
- 响应信号:从机拉低80us,再拉高80us
- 数据传输:40bit数据,高位先出,每一位以50us低电平开始,高电平时长区分0和1
- 数据0:高电平持续23~27us
- 数据1:高电平持续68~74us
- 校验:前4字节(湿度高、湿度低、温度高、温度低)之和等于第5字节校验和
4.2 GPIO模拟时序实现
通过不断切换GPIO的输入输出方向,配合微秒级延时,软件模拟单总线时序。
- 主机发送起始信号:配置为输出模式,拉低18ms,拉高30us
- 切换输入模式:等待从机响应信号
- 逐位读取数据 :每一位先等待低电平过去,延时40us后读取电平
- 此时还是高电平 → 数据1
- 此时已经变低 → 数据0
- 组装数据:移位组装成5字节数据
- 校验验证:比对校验和,数据合法则提取温湿度
4.3 延时函数的选择
mdelay:毫秒级延时,用于起始信号的18ms延时udelay:微秒级延时,用于单总线时序判断,是单总线驱动的核心- 注意:内核态延时并非绝对精准,受系统调度影响,微秒级延时会有偏差
4.4 稳定性优化:重试机制
单总线靠软件模拟时序,内核态可能被调度、中断打断,导致时序偏移,出现校验失败,属于正常现象。
- 优化方法:读取函数外层增加3次重试,失败后延时10ms重新读取
- 效果:成功率可以从60%左右提升到95%以上,满足日常使用
4.5 框架复用
DHT11驱动完全沿用了platform + misc + 设备树的标准架构,和LED、按键驱动的框架高度一致,只需要修改probe中的硬件初始化和读写函数逻辑,充分体现了分层设计的复用优势。
五、工程化实践:编译、部署与调试
驱动开发不止是写代码,工程化的编译、部署、调试能力同样重要,本阶段积累了大量实操经验。
5.1 内核模块Makefile编写规范
标准模板
# 内核源码路径
kerdir := /home/linux/zyt/驱动/kernel/linux-imx-rel_imx_4.1.15_2.1.0_ga_alientek
# 当前目录
curdir := $(shell pwd)
# 模块名
MODULE_NAME := dht11_platform_drv
all:
make -C $(kerdir) M=$(curdir) modules
clean:
make -C $(kerdir) M=$(curdir) clean
常见问题
-
clean报错 :如果是自定义clean规则,
rm命令一定要加-f参数,文件不存在时不会报错<pre>clean: rm -f $(MODULE_NAME).ko -
路径错误:内核路径必须指向已经编译过的完整内核源码树,不能是压缩包或空目录
5.2 应用层交叉编译
-
必须使用ARM交叉编译器:
arm-linux-gnueabihf-gcc -
不能使用Ubuntu主机自带的gcc,否则编译出的x86程序在ARM开发板上无法运行
-
编译命令:
arm-linux-gnueabihf-gcc app.c -o app
5.3 两种部署方式对比
开发板有两种常用的文件部署方式,各有适用场景。
| 方式 | 原理 | 特点 | 适用场景 |
|---|---|---|---|
| TFTP下载 | 开发板通过网络从Ubuntu的TFTP目录下载文件到本地内存 | 文件临时存在,重启消失 | 快速调试、单次验证 |
| NFS根文件系统 | 开发板根目录直接挂载Ubuntu上的rootfs文件夹,文件实时同步 | 文件永久存在,重启不消失 | 正式使用、长期测试 |
TFTP方式操作
-
Ubuntu端:把文件拷贝到
~/tftpboot/目录 -
开发板端:
tftp -g -r 文件名 服务器IP
NFS方式操作
-
Ubuntu端:把文件直接拷贝到rootfs目录
cp xxx.ko ~/zyt/rootfs/ -
开发板端:直接在根目录就能看到文件,无需下载
5.4 驱动加载与卸载调试
加载驱动
insmod xxx.ko
- 无任何输出即为加载成功
- 常见报错
No such file or directory:当前目录下没有该ko文件,切换到文件所在目录或写绝对路径
验证加载成功
- 查看设备节点:
ls /dev/设备名 - 查看内核日志:
dmesg | tail,看到probe成功打印 - 查看sysfs设备:
ls /sys/class/misc/设备名
卸载驱动
rmmod xxx
- 不需要加
.ko后缀 - 卸载后设备节点自动消失,资源自动释放
5.5 常见加载失败问题排查
- insmod提示文件不存在:优先检查当前工作目录,或者写文件的绝对路径
- 加载后没有设备节点:检查设备树节点是否存在、compatible是否匹配、probe函数是否执行成功
- probe执行失败:逐行排查GPIO申请、中断申请、misc注册等步骤,通过打印定位失败点
- 应用执行格式错误:确认是用ARM交叉编译器编译的,不是主机gcc
六、学习总结与方法论
6.1 核心收获
- 分层设计思想:从原生cdev到misc封装,再到platform总线+设备树,本质是通用逻辑的不断下沉。学习驱动不能只关注功能实现,更要理解内核的设计思想。
- 设备树是标配:现代Linux驱动开发中,设备树已经是硬件描述的标准方式,驱动与硬件分离是必须掌握的开发范式。
- 资源管理意识 :优先使用
devm_系列接口,利用内核的自动资源管理,减少内存泄漏、资源未释放的问题。 - 中断处理原则:顶半部快进快出,耗时逻辑全部下沉到底半部,避免影响系统响应性能。
- 时序模拟能力:通过GPIO模拟单总线时序,是嵌入式驱动的基本功,对延时精度、时序逻辑有很高要求。
6.2 调试方法论
驱动开发问题排查要分层定位、逐步缩小范围:
- 先确认设备树节点是否正常加载
- 再确认驱动是否加载成功、probe是否执行
- 然后验证硬件操作是否正常(GPIO读写、中断触发)
- 最后调试业务逻辑与数据交互
不要一开始就陷入代码细节,先从宏观层面排除环境、配置、路径问题。
6.3 后续方向
接下来可以继续深入学习的方向:
- 更复杂的外设总线:I2C、SPI子系统驱动
- 字符设备高级功能:ioctl、mmap、异步通知
- 输入子系统:input设备驱动框架
- 设备树高级语法:节点引用、属性解析、中断控制器
附录:本阶段踩坑清单速查
- vim编辑删不掉内容 → 先按Esc回到正常模式
- 设备树编译重复标签 → 删除重复节点,标签全局唯一
- dtb拷贝找不到tftpboot → 用
~/tftpboot,不是/tftpboot - insmod找不到文件 → 检查当前目录或写绝对路径
- 应用运行报错 → 确认用ARM交叉编译器编译
- 驱动probe不执行 → 核对compatible字符串完全一致
- Makefile clean报错 → rm命令加-f参数
- 设备树修改不生效 → 重新编译dtb+拷贝+重启开发板
- DHT11读取失败率高 → 增加3次重试机制
- 单总线时序不准 → 调整udelay延时参数,结合示波器校准