开发平台:正点原子 IMX6ULL(Cortex-A7)|内核版本:Linux 4.1.15(NXP BSP)交叉工具链:arm-linux-gnueabihf-gcc|启动方式:tftp 下载内核 + NFS 挂载根文件系统适合读者:正在学嵌入式 Linux 驱动、准备驱动开发岗面试的同学
本文把「系统移植 → 三种驱动框架 → 设备树 → 完整工程代码」串成一条线,最后用DHT11 温湿度传感器做一个 platform + 设备树驱动的完整实例(含 Makefile、驱动层、应用层全部代码)。
目录
- 一、系统移植:内核是怎么起来的
- 1.1 Bootloader(U-Boot)启动流程
- 1.2 Linux 内核镜像与启动过程
- 1.3 根文件系统 rootfs
- 1.4 init 进程
- 二、Linux 驱动开发框架
- 2.1 驱动的三种分类
- 2.2 字符设备驱动框架
- 2.3 混杂设备 misc 驱动框架
- 2.4 platform + 设备树驱动框架
- 2.5 三种框架对比
- 三、驱动开发完整流程(DHT11 实战)
- 四、完整工程代码
- 五、面试要点速答
- 六、易错点与踩坑记录
- 七、总结
一、系统移植:内核是怎么起来的
系统移植是驱动开发的前置背景。只有清楚内核从何处启动、在什么阶段接管硬件、驱动在哪个环节被加载,才能准确定位驱动问题。
1.1 Bootloader(U-Boot)启动流程
以 IMX6ULL 为例,U-Boot 的启动分为七个阶段:
① 芯片上电执行 BootROM
BootROM 是 SoC(System on Chip)内部固化的程序,厂商烧写、用户不可修改。它把uboot.imx搬移到 OCRAM(片上 RAM),并完成 DDR(内存,掉电数据丢失)初始化,随后加载完整的 U-Boot。
② 初始化核心硬件
初始化时钟、内存、CPU 工作模式。CPU 上电复位后默认进入 SVC 模式(Supervisor,管理员模式,权限最高,也是操作系统内核运行的模式);同时关闭看门狗、关闭中断、关闭 D-Cache(数据高速缓存)、关闭 MMU(内存管理单元,负责物理地址与虚拟地址的转换)。
③ 初始化板级硬件
完成串口、网口的初始化,为后续打印与网络下载做准备。
④ 初始化软件环境
完成网络协议栈初始化,使能 tftp 下载能力。
⑤ 搬移内核与设备树
将内核镜像和设备树下载到内存的约定地址:
tftp 0x80800000 zImage tftp 0x83000000 imx6ull-alientek-emmc.dtb⑥ 向内核传参
通过bootargs环境变量把根文件系统位置、网络参数、控制台设备等传递给内核:
setenv bootargs root=/dev/nfs nfsroot=/home/linux/nfs/rootfs \ serverip=192.168.1.100 ip=192.168.1.50 \ console=ttymxc0 init=/linuxrc| 字段 | 含义 | 说明 |
|---|---|---|
root=/dev/nfs | 根文件系统挂载方式 | NFS(Network File System)网络文件系统 |
nfsroot= | 根文件系统所在路径 | 服务端导出的根目录绝对路径 |
serverip= | 服务端 IP | 提供 NFS 与 tftp 服务的主机地址 |
ip= | 开发板 IP | 与 serverip 处于同一网段 |
console= | 控制台设备 | ttymxc0对应 IMX6ULL 的 UART1 |
init= | 用户空间第一个进程 | 通常为/linuxrc或/sbin/init |
⑦ 引导内核启动
跳转到内核入口地址,把 CPU 控制权交给 Linux 内核。
1.2 Linux 内核镜像与启动过程
Linux 内核(本平台 4.1.15 版本)负责文件系统管理、多任务调度(时间片轮询)、网络管理与内存管理。内核源码经make编译后生成arch/arm/boot/zImage:
Linux 内核源码 → make → arch/arm/boot/zImage| 镜像类型 | 构成 | 特点 | 适用场景 |
|---|---|---|---|
Image | 原生内核镜像 | 体积大,不便于移植与网络传输 | 调试、本地烧写 |
zImage | Image + 自解压代码 | 体积小,便于移植,运行时自行解压 | 嵌入式主流选择 |
uImage | zImage + 64 字节头部 | 头部含 CPU 架构、加载地址、镜像大小等信息 | U-Boot 专用格式 |
内核启动流程:
- 跳转到内核入口点;
- 执行第一个 C 语言函数
start_kernel()(位于init/main.c)。
start_kernel()按固定顺序完成内核初始化:
① 平台与架构初始化
setup_arch()匹配设备树(imx6ul.dtsi)、初始化时钟树与内存描述,并通过of_platform_populate把设备树节点展开为 platform 设备;trap_init()建立 ARM 异常向量表。
② 内存管理初始化
mm_init()完成 memblock → 页表 → buddy 分配器 → slab 分配器的建立,此后kmalloc才可用。
③ 调度器初始化
sched_init()初始化 CFS 调度器,之后系统才具备进程切换能力。
④ 中断与控制台初始化
开启中断,console_init()初始化串口控制台,内核打印开始可见。
⑤ 创建 init 进程
rest_init()创建 1 号内核线程kernel_init()与 2 号线程kthreadd(),当前进程退化为 idle(0 号进程),调度正式开始。
1.3 根文件系统 rootfs
根文件系统是内核启动后挂载的第一个文件系统,提供/dev、/proc、/sys等目录以及用户空间程序。
常用制作工具为busybox或buildroot:前者轻量、手工配置度高,后者可一键生成完整系统。
1.4 init 进程
init是用户空间的第一个进程(PID 为 1),由内核在挂载根文件系统后启动,负责按配置文件拉起各类系统服务,并作为所有用户态进程的父进程。
二、Linux 驱动开发框架
按「字符设备 → 混杂设备 → platform + 设备树」的递进顺序梳理,重点给出每一步的核心结构体、注册函数与执行时机。
2.1 驱动的三种分类
- 字符设备驱动:按字节流顺序访问,如传感器、按键;
- 块设备驱动:按数据块随机访问,如 eMMC、SD 卡;
- 网络设备驱动:面向报文收发,如以太网卡。
嵌入式传感器开发以字符设备驱动与混杂设备驱动为主。
2.2 字符设备驱动框架
字符设备驱动不会自动生成设备节点,加载后需手工创建设备文件:
insmod sensor.ko # 加载驱动模块 mknod /dev/sensor c 250 0 # 创建设备节点:c 表示字符设备,250 为主设备号,0 为次设备号实现步骤如下:
① 构建 cdev 结构体
描述一个具体的字符设备:设备号(主设备号 12 bit + 次设备号 20 bit)、设备名称、file_operations结构体。
② 构建 file_operations 结构体
实现.open、.read、.write、.release等函数指针,即驱动层操作函数集合。
③ 实现驱动入口函数(insmod时被调用)
申请设备号 → 初始化并注册 cdev → 创建类与设备节点 → 申请硬件资源。
④ 实现驱动注销函数(rmmod时被调用)
按入口的逆序释放资源。
⑤ 绑定入口与出口
通过module_init()与module_exit()宏绑定入口函数与注销函数。
⑥ 实现具体文件操作
完成open、read、write、close等函数,供应用层通过系统调用访问。
| 步骤 | 入口函数(init) | 注销函数(exit) |
|---|---|---|
| 申请/释放设备号 | alloc_chrdev_region()(动态)或register_chrdev_region()(静态) | unregister_chrdev_region() |
| 注册/删除 cdev | cdev_init()+cdev_add() | cdev_del() |
| 创建/销毁类与节点 | class_create()+device_create() | class_destroy()+device_destroy() |
| 申请/释放硬件资源 | gpio_request()、request_irq()、spin_lock_init() | gpio_free()、free_irq()、spin_lock_destroy() |
2.3 混杂设备 misc 驱动框架
混杂设备本质是主设备号固定为 10的字符设备,注册时由内核自动创建/dev下的设备节点,省去手工mknod的步骤,适合单一功能的传感器驱动。实现步骤如下:
① 构建 miscdevice 结构体
填写次设备号(20 bit)、设备名称与file_operations结构体指针。
② 构建 file_operations 结构体
实现.open、.read、.write、.release。
③ 实现入口函数
调用misc_register()完成注册,并申请 GPIO、中断、自旋锁等硬件资源。
④ 实现注销函数
调用misc_deregister()注销,并释放硬件资源。
⑤ 绑定入口与出口
使用module_init()与module_exit()。
⑥ 实现文件操作函数
完成open、read、write、close。
2.4 platform + 设备树驱动框架
该框架是实际产品开发的主流方案,其核心是把设备信息与驱动代码解耦,交由 platform 总线在运行时完成匹配。
2.4.1 设备驱动分离思想
platform 总线把"设备信息"与"驱动信息"分开管理:设备信息由设备树(.dts)描述,驱动信息由驱动程序实现,二者通过compatible属性匹配。设备树变更无需重新编译驱动,同一驱动也可复用于多块板卡。
2.4.2 设备树需添加的内容
| 需添加内容 | 写法示例 | 作用 | 驱动端获取方式 | 必要性 |
|---|---|---|---|---|
| 设备节点 | dht11 { … };放根节点;I2C 设备挂i2c1节点下 | 生成 platform 设备或 i2c 客户端 | — | 必需 |
compatible | compatible = "myboard,dht11"; | 与of_match_table匹配,触发probe() | 驱动中定义一致字符串 | 必需 |
pinctrl | pinctrl-0 = <&pinctrl_dht11>; | 引脚复用与电气属性,进入 probe 前自动生效 | pinctrl 子系统自动应用 | GPIO 设备必需 |
gpios | dht11-gpios = <&gpio5 1 GPIO_ACTIVE_HIGH>; | 指定使用的 GPIO 引脚 | of_get_named_gpio() | GPIO 设备必需 |
reg | reg = <0x23>; | I2C 从机地址或 SPI 片选 | client->addr或platform_get_resource() | 总线设备必需 |
interrupts | interrupts = <&gpio5 2 IRQ_TYPE_EDGE_FALLING>; | 声明中断资源 | platform_get_irq() | 可选 |
clocks | clocks = <&clks IMX6UL_CLK_GPT1>; | 外设时钟使能 | devm_clk_get() | 可选 |
status | status = "okay"; | 节点使能开关 | — | 必需 |
关于 gpio 属性的三个 cell:
dht11-gpios = <&gpio1 3 0>中,&gpio1是所属 GPIO 控制器 phandle,3是控制器内引脚号(即 GPIO1_IO03),0是有效电平标志(0 = 高电平有效)。驱动端用of_get_named_gpio()把这个"设备树描述"换算成全局引脚编号。与
pinctrl的分工:pinctrl 管引脚复用与电气属性,gpio-xxx属性管逻辑编号。
2.4.3 驱动端实现要点
① 构建 platform_driver 结构体
填写.probe、.remove,以及.driver中的匹配规则(.name、.of_match_table)。
② 构建 of_device_id 结构体
.compatible必须与设备树完全一致,例如"dallas,ds18b20"(不含空格、全小写)。
③ 注册与注销驱动
使用module_platform_driver()宏,其内部展开为platform_driver_register()与platform_driver_unregister()。
④ 实现 probe 函数(设备与驱动匹配成功后自动执行)
① 注册混杂设备 misc_register() ② 通过 of_get_named_gpio() 从设备树取得 GPIO 编号 ③ 用 devm_gpio_request() 申请 GPIO(该接口在驱动卸载时自动释放) ④ 完成其余初始化⑤ 实现 remove 函数(设备或驱动从总线注销时执行)
misc_deregister()注销混杂设备,释放相关资源。
2.5 三种框架对比
三种框架的差异集中在设备号管理、节点创建方式与硬件信息来源上。实际开发中优先选择 platform + 设备树框架,学习阶段可先掌握字符设备与 misc 框架。
| 对比项 | 字符设备框架 | misc 框架 | platform + 设备树框架 |
|---|---|---|---|
| 主设备号 | 动态或静态申请 | 固定为 10 | 由内部注册的设备类型决定 |
| 设备节点 | 需mknod手工创建 | 自动创建 | 自动创建(内部多基于 misc) |
| 硬件信息 | 硬编码在驱动中 | 硬编码在驱动中 | 由设备树描述,驱动只读 |
| 匹配机制 | 无 | 无 | compatible匹配后调probe() |
| 适用场景 | 学习框架、简单设备 | 单一功能传感器 | 实际产品开发(推荐) |
三、驱动开发完整流程(DHT11 实战)
以 DHT11 温湿度传感器为例,一次完整的驱动开发包含四个环节:读手册与原理图、写设备树、编译部署、加载验证。四个环节的顺序不可颠倒,因为设备树是驱动probe被调用的前提。
3.1 阅读手册与原理图
先阅读传感器芯片手册,确认功能、接口类型(I2C / SPI / GPIO / ADC)、关键参数、数据格式与时序要求;再阅读 IMX6ULL 底板原理图,确认传感器实际连接的引脚与总线,为设备树编写提供依据。
DHT11 关键参数与时序:
| 项目 | 参数 |
|---|---|
| 供电 | 3.3V ~ 5.5V |
| 测量范围 | 温度 0~50 ℃,湿度 20~90 %RH |
| 分辨率 | 温度 1 ℃,湿度 1 %RH |
| 通信方式 | 单总线(1-wire),需外接 4.7 kΩ 上拉 |
| 采样周期 | ≥ 1 s |
| 数据格式 | 40 bit = 湿度整数 + 湿度小数 + 温度整数 + 温度小数 + 校验和 |
通信时序:
主机:拉低 ≥18ms → 释放总线(拉高 20~40us) DHT11:响应 80us 低 → 80us 高 数据:每位以 50us 低电平开始,随后高电平 高电平 26~28us = '0',约 70us = '1'驱动里用udelay(10)计数判断高电平持续长度,time > 4(>40us)即判为 1。
3.2 添加设备树节点
进入内核源码的设备树目录,编辑对应板级 dts 文件:
cd ~/imx6ull/linux-imx-rel_imx_4.1.15_2.1.0_ga_alientek/arch/arm/boot/dts vi imx6ull-alientek-emmc.dts在iomuxc节点中添加引脚复用,并在根节点下新增设备节点:
/* 1. 引脚复用:把 GPIO1_IO03 这个 PAD 配置为 GPIO 功能 */ &iomuxc { pinctrl_dht11: dht11grp { fsl,pins = <MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x10B0>; }; }; /* 2. 设备节点:compatible 必须与驱动 of_match_table 完全一致 / dht11 { compatible = "pute,dht11"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_dht11>; gpio-dht11 = <&gpio1 3 0>; / GPIO1_IO03,0 = 高电平有效 */ status = "okay"; };
#address-cells/#size-cells用来规定子节点reg属性中"地址"与"长度"各占几个 32 位字(一字 4 字节、半字 2 字节、双字 8 字节),DHT11 这类 GPIO 设备不需要reg,可省略。
3.3 编译与部署
# 回到内核根目录,仅重新编译设备树 make dtbs # 拷贝到 tftp 服务目录 cp arch/arm/boot/dts/imx6ull-alientek-emmc.dtb ~/tftpboot/重启开发板,U-Boot 通过 tftp 重新下载 dtb,新节点即生效。
验证节点是否生效:板上执行
ls /sys/firmware/devicetree/base/,能看到dht11目录即可。
3.4 加载与验证
insmod dht11_drv.ko # 加载驱动 dmesg | tail # 查看 probe 是否执行、初始化是否成功 ls /dev/misc_dht11 # 混杂设备节点应自动出现 ./dht11_app # 运行测试程序,读取温湿度若probe()未被调用,按以下顺序排查:
① compatible 字符串是否与驱动完全一致
注意大小写、逗号后不能有空格。
② 节点位置是否正确
GPIO 类设备放根节点,I2C 设备必须挂在i2c1之类总线节点下。
③ status 是否为 okay
disabled状态下节点不参与设备创建。
④ 引脚是否已被其他节点占用
同一 PAD 被两个节点复用会冲突。
四、完整工程代码
4.1 目录结构
dht11/ ├── Makefile # 顶层 Makefile,统一调度 app 与 drv ├── dht11_app/ │ ├── Makefile # 应用层交叉编译 │ └── dht11_app.c # 测试程序 └── dht11_drv/ ├── Makefile # 内核模块编译 └── dht11_drv.c # 平台驱动 + 设备树4.2 Makefile 三件套
(1)顶层 Makefile
# ============================================================ # 顶层 Makefile:统一编译 DHT11 应用程序 + 内核驱动模块 # 用法: make -> 依次编译 app 和 drv 两个子目录 # make clean -> 清理两个子目录的普通编译产物 # make distclean -> 深度清理(含 .o/.ko/.cmd/.order 等中间文件) # ============================================================ 模块名前缀,用于拼接出两个子目录的名字 MODULENAME := dht11 应用程序目录:dht11_app MODULE_APP := $(MODULENAME)_app 驱动模块目录:dht11_drv MODULE_DRV := $(MODULENAME)_drv 导出变量,让子目录里的 Makefile 也能读到(驱动编译时常用) export MODULE_APP MODULE_DRV 默认目标 all:make -C <目录> 等价于先 cd 进该目录再执行 make 任一子目录编译报错会立即中止,不会继续往下编 all: make -C $(MODULE_APP) make -C $(MODULE_DRV) 声明伪目标:防止目录下恰好存在同名文件 clean/distclean 时目标失效 .PHONY: all clean distclean 清理普通编译产物(应用可执行文件、驱动 .o 等) clean: make -C $(MODULE_APP) clean make -C $(MODULE_DRV) clean 深度清理:连 .o.bak、Module.symvers、.tmp_versions 等内核编译中间文件一并删掉 distclean: make -C $(MODULE_APP) distclean make -C $(MODULE_DRV) distclean(2)应用层 Makefile(dht11_app/Makefile)
# ============================================================ # dht11_app 子目录 Makefile:交叉编译 DHT11 测试应用程序 # 依赖顶层的 export 变量:$(MODULE_APP) = dht11_app # ============================================================ 交叉编译工具链:编译 ARM 平台(IMX6ULL)可执行文件 CC := arm-linux-gnueabihf-gcc 目标规则:目标 dht11_app 依赖于同名源文件 dht11_app.c $^ 代表全部依赖(dht11_app.c),$@ 代表目标名(dht11_app) $(MODULE_APP): $(MODULE_APP).c $(CC) $^ -o $@ # 编译完成后自动把可执行文件拷到 NFS 根文件系统 # 开发板通过 NFS 挂载该目录,即可直接运行 ./dht11_app 测试 cp $(MODULE_APP) ~/nfs/rootfs 声明伪目标,防止同名文件干扰 .PHONY: clean distclean 清理本地编译产物 clean: rm -f $(MODULE_APP) 深度清理:同时删除 NFS 根文件系统里已部署的程序 distclean: rm -f $(MODULE_APP) rm -f ~/nfs/rootfs/$(MODULE_APP)(3)驱动 Makefile(dht11_drv/Makefile)
# ============================================================ # dht11_drv 子目录 Makefile:把驱动编译成内核模块 .ko # 依赖顶层的 export 变量:$(MODULE_DRV) = dht11_drv # ============================================================ 告诉内核 Kbuild:把 dht11_drv.o 编译成外部模块,产物为 dht11_drv.ko obj-m += $(MODULE_DRV).o 内核源码顶层目录:模块编译必须"借"内核的构建系统 注意:路径要与开发板实际运行的内核版本完全一致,否则 insmod 报版本不匹配 KERDIR := /home/linux/imx6ull/linux-imx-rel_imx_4.1.15_2.1.0_ga_alientek 当前目录(驱动源码所在目录),用 shell pwd 在 make 执行时展开 MODULEDIR := $(shell pwd) 1. -C $(KERDIR):先进入内核源码目录,借用其顶层 Makefile 2. M=$(MODULEDIR):告诉 Kbuild 回到本目录编译外部模块 3. 编译成功后把 .ko 拷到 NFS 根文件系统 all: make -C $(KERDIR) M=$(MODULEDIR) modules cp $(MODULE_DRV).ko ~/nfs/rootfs 声明伪目标 .PHONY: clean distclean 清理:调用内核构建系统清除本目录的 .o/.ko/.cmd 等中间文件 clean: make -C $(KERDIR) M=$(MODULEDIR) clean rm -f ~/nfs/rootfs/$(MODULE_DRV).ko为什么编驱动要进内核目录:模块不能独立编译,它链接的符号(
printk、gpio_to_irq等)来自内核,必须用与板上运行同版本的内核源码 + 同一交叉编译器,vermagic一致才能insmod。
4.3 驱动代码 dht11_drv.c
/* ================= 头文件 ================= */ #include <linux/module.h> // 模块框架:THIS_MODULE、MODULE_LICENSE #include <linux/platform_device.h> // platform 总线:platform_driver、probe/remove #include <linux/of_gpio.h> // 设备树 GPIO 接口:of_get_named_gpio() #include <linux/miscdevice.h> // 混杂设备:miscdevice、misc_register() #include <linux/fs.h> // file_operations #include <linux/cdev.h> // 字符设备相关(本驱动用 misc,未直接用到) #include <linux/device.h> // 设备模型:struct device #include <linux/gpio.h> // GPIO API:gpio_direction_*/gpio_get/set_value #include <linux/delay.h> // 延时:msleep、udelay #include <linux/kernel.h> // pr_info 等内核打印 #include <linux/init.h> // __init/__exit 标记 #include <asm/uaccess.h> // copy_to_user(4.1.15 内核路径) #include <asm/delay.h> // udelay 底层实现 /* dht11 数据引脚编号,probe 时从设备树读出 */ int dht11_no = 0; /* ============ 复位:发起一次通信 ============ DHT11 起始时序:主机拉低 >=18ms -> 释放总线(拉高 20~40us) -> 等待 DHT11 回 80us 低 + 80us 高的响应信号 */ static int dht11_reset(void) { int time = 0; /* 1. 引脚设为输出,默认高(总线空闲态) */ gpio_direction_output(dht11_no, 1); /* 2. 拉低 18ms:告诉 DHT11 "开始通信" */ gpio_set_value(dht11_no, 0); msleep(18); /* 3. 释放总线:拉高后改输入,交出总线控制权 */ gpio_set_value(dht11_no, 1); gpio_direction_input(dht11_no); /* 4. 等 DHT11 的响应:总线被拉低(超时 ~200us 判无响应) / while (gpio_get_value(dht11_no) && time < 20) { udelay(10); time++; } if (time >= 20) { / 超时:传感器未应答 */ pr_info("[driver]: dht11 no response!\n"); return -1; } /* 5. 响应信号结构:80us 低 -> 80us 高 -> 开始发数据 / while (!gpio_get_value(dht11_no)); / 等低电平结束 / while (gpio_get_value(dht11_no)); / 等高电平结束 */ return 0; } /* ============ 读 40bit 数据 ============ DHT11 位编码:每位以 50us 低电平开始,随后高电平 高电平 ~26-28us = '0',~70us = '1'(用 40us 阈值区分) 数据格式:湿度整数+湿度小数+温度整数+温度小数+校验和,共 5 字节 */ static int read_dht11_data(unsigned char *pdata, int len) { int i = 0, n = 0, time = 0; unsigned char data[5] = {0}; /* 1. 引脚设为输入(总线由 DHT11 主导) */ gpio_direction_input(dht11_no); /* 2. 逐位读取 5 字节 × 8 位,高位在前(n 从 7 到 0) / for (i = 0; i < 5; i++) { for (n = 7; n >= 0; n--) { time = 0; while (!gpio_get_value(dht11_no)); / 等 50us 低电平结束 / while (gpio_get_value(dht11_no)) { / 测量高电平持续时间 / udelay(10); time++; } if (time > 4) / >40us 即 '1'('0' 只有 ~27us) */ data[i] |= (1 << n); } } /* 3. 读完拉高引脚并设回输出,恢复空闲态 */ gpio_direction_output(dht11_no, 1); /* 4. 校验和:前 4 字节之和的低 8 位应等于第 5 字节 */ if (data[0] + data[1] + data[2] + data[3] != data[4]) { pr_info("[driver]:dht11_check failed!\n"); return -1; } /* 5. 把湿度+温度(前 4 字节)拷给调用者 */ memcpy(pdata, data, len); return 0; } /* ============ 驱动层 read ============ 应用 read("/dev/misc_dht11") 时执行: 复位 -> 读 4 字节 -> copy_to_user 送回应用层 */ static ssize_t dht11_read(struct file *fp, char __user *puser, size_t n, loff_t *off) { int ret = 0; unsigned long nsize = 0; unsigned char data[4] = {0}; ret = dht11_reset(); /* 1. 起始时序 */ if (ret) { pr_info("[driver]:dht11_reset failed!\n"); return ret; } ret = read_dht11_data(data, 4); /* 2. 读温湿度 */ if (ret) { pr_info("[driver]:dht11_read failed!\n"); return ret; } nsize = copy_to_user(puser, data, 4); /* 3. 内核数据 -> 用户空间 / if (nsize) { / 返回值 = 未拷贝成功的字节数 */ pr_info("[driver]:copy_to_user failed!\n"); return nsize; } return sizeof(data); /* 成功返回 4 */ } /* 文件操作集合:只实现 read */ static struct file_operations dht11_fops = { .read = dht11_read, }; /* 混杂设备:主设备号固定 10,自动创建 /dev/misc_dht11 节点 / static struct miscdevice misc_dht11 = { .minor = MISC_DYNAMIC_MINOR, / 动态分配次设备号 / .name = "misc_dht11", / 设备名 -> /dev/misc_dht11 */ .fops = &dht11_fops, }; /* probe:设备树 compatible 与 of_match_table 匹配成功后自动执行 */ int dht11_probe(struct platform_device *pdev) { int ret = 0; /* 1. 注册混杂设备,创建 /dev/misc_dht11 */ misc_register(&misc_dht11); /* 2. 从设备树 dht11 节点的 "gpio-dht11" 属性取引脚编号 */ dht11_no = of_get_named_gpio(pdev->dev.of_node, "gpio-dht11", 0); if (dht11_no < 0) { pr_info("[driver]:of_get_named_gpio failed!\n"); return -1; } /* 3. 申请 GPIO(devm_ 前缀:随设备生命周期自动释放) */ ret = devm_gpio_request(misc_dht11.this_device, dht11_no, "dht11_gpio"); if (ret) { pr_info("[driver]:devm_gpio_request failed!\n"); return ret; } /* 4. 设为输出高:总线空闲态 */ gpio_direction_output(dht11_no, 1); pr_info("dht11_probe is ok!\n"); return 0; } /* remove:设备或驱动注销时执行,与 probe 逆序释放资源 */ int dht11_remove(struct platform_device pdev) { misc_deregister(&misc_dht11); / 注销混杂设备 */ pr_info("dht11_remove is ok!\n"); return 0; } /* 无设备树时的匹配方式:总线按 .name 比对 / static struct platform_device_id dht11_id_table[] = { { .name = "putedht11" }, { }, / 结尾必须留空项 */ }; /* 设备树匹配:与 dts 节点 compatible = "pute,dht11" 一致才进入 probe */ static struct of_device_id dht11_match_table[] = { { .compatible = "pute,dht11" }, { }, }; /* platform_driver:probe/remove + 两种匹配规则 / static struct platform_driver dht11_drv = { .probe = dht11_probe, .remove = dht11_remove, .driver = { .name = "pute_dht11", / 无设备树时的匹配名 */ .owner = THIS_MODULE, .of_match_table = dht11_match_table, }, .id_table = dht11_id_table, }; /* 一键注册宏:insmod -> platform_driver_register() rmmod -> platform_driver_unregister() */ module_platform_driver(dht11_drv); MODULE_LICENSE("GPL"); MODULE_AUTHOR("PUTE");驱动的编写顺序(自底向上,先能跑通再完善时序):
- 写头文件:platform、misc、gpio、of_gpio、delay、uaccess 六大类;
- 搭匹配框架:
of_device_id→platform_driver→module_platform_driver(),先保证insmod能触发 probe; - 写 probe:注册 misc → 取 GPIO → 申请 GPIO → 初始化引脚(remove 按逆序释放);
- 写 fops:
file_operations→miscdevice,挂到 probe 里注册; - 最后实现时序:
dht11_read→read_dht11_data→dht11_reset,单总线时序对延时敏感,放最底层。
4.4 应用层代码 dht11_app.c
#include <fcntl.h> // open 及 O_RDWR 标志 #include <stdio.h> // printf、perror #include <sys/types.h> // ssize_t 等基础类型 #include <unistd.h> // read、sleep、close int main(void) { int fd = 0; /* 设备文件描述符 / ssize_t nsize = 0; / read 返回值:实际读到的字节数 / unsigned char data[4] = {0}; / 湿度整数、湿度小数、温度整数、温度小数 / double temp = 0; / 温度(℃) / double humity = 0; / 湿度(%RH),拼写应为 humidity */ /* 1. 打开设备节点:必须是驱动 misc_register 创建的那个名字 * 路径 /dev/misc_dht11 与驱动 .name = "misc_dht11" 严格一致 */ fd = open("/dev/misc_dht11", O_RDWR); if (fd < 0) { perror("[APP]:fail to open misc_dht11!"); /* 打印 errno 对应原因 */ return -1; } /* 2. 循环采集:每次 read 会触发一次完整的单总线时序 * 两次读取间隔必须 >=1s(DHT11 采样周期),这里 2s 更稳妥 */ while (1) { nsize = read(fd, data, 4); if (nsize != 4) { /* 读失败(校验和错/无响应)时不退出,继续下一次尝试 */ perror("[APP]:fail to read dht11!"); continue; } /* 3. 数据解析: * data[0] = 湿度整数,data[1] = 湿度小数 * data[2] = 温度整数,data[3] = 温度小数 * DHT11 分辨率为 1,小数位恒为 0;若是 DHT22 应改为 /10.0 */ humity = data[0] + (data[1] / 100.0); temp = data[2] + (data[3] / 100.0); printf("temp = %.2lf, humity = %.2lf\n", temp, humity); sleep(2); /* 满足 DHT11 采样间隔要求 */ } return 0; }运行效果:
[root@imx6ull /]# ./dht11_app temp = 26.00, humity = 58.00 temp = 26.00, humity = 58.00 temp = 27.00, humity = 57.00五、面试要点速答
Q U-Boot 的作用是什么?
A 初始化 DDR 与板级硬件、提供下载与调试能力、把内核与设备树搬移到内存、通过 bootargs 传参并引导内核。
Q zImage 与 uImage 的区别?
A zImage 是带自解压代码的压缩内核;uImage 在 zImage 前加了 64 字节 U-Boot 专用头部。
Q start_kernel() 之后内核做了哪些初始化?
A 平台与架构、内存管理、调度器、中断与控制台,最后创建 init 进程开始调度。
Q 字符设备的主次设备号各占多少位?
A 主设备号 12 bit,次设备号 20 bit。
Q misc 设备与普通字符设备的关键差别?
A 主设备号固定为 10,注册时自动创建设备节点。
Q platform 驱动何时进入 probe()?
A 设备与驱动的 compatible 匹配成功时由总线调用。
Q 为什么要用设备树?
A 实现设备信息与驱动代码分离,改硬件不必改驱动,一份驱动可适配多块板卡。
Q devm_ 前缀接口的优势?
A 资源与设备生命周期绑定,驱动卸载时自动释放,减少错误路径的资源泄漏。
六、易错点与踩坑记录
整理初稿时发现若干拼写与术语错误,这些细节在面试手写代码环节极易失分:
| 易错写法 | 正确写法 | 说明 |
|---|---|---|
root=dev/nfs | root=/dev/nfs | 挂载点必须是绝对路径 |
/dev/xxx.c | mknod /dev/xxx c 250 0 | 设备节点无.c后缀,需指明类型与主次设备号 |
platfrom | platform | 拼写错误 |
moudle_platform_driver | module_platform_driver | 拼写错误 |
of_get_name_gpio | of_get_named_gpio | 函数名含 named |
| 开启中端 | 开启中断 | 术语错误 |
"dallas, ds18B20" | "dallas,ds18b20" | compatible 不含空格,按惯例全小写 |
altientek/romove | alientek/remove | 拼写错误 |
实际调试中踩到的坑:
insmod报 "Invalid module format":内核源码版本 / 交叉编译器与板上内核不一致,vermagic对不上;/dev/misc_dht11不出现:misc_register()前就return了,或probe根本没执行(查 compatible);- 读到数据全是 0xFF 或校验失败:单总线时序对延时极敏感,
udelay(10)的计数阈值要按实测微调;同时确认上拉电阻已焊; copy_to_user返回值非 0:应用层传的缓冲区不在用户空间(如传了内核地址);Makefile里rm ~/nfs/rootfs:误把整个根文件系统目录当文件删,正确写法是rm -f ~/nfs/rootfs/$(MODULE_APP)。
七、总结
一条完整的链路:
BootROM → U-Boot(初始化 DDR/串口/网络)→ tftp 下载 zImage+dtb → start_kernel()(架构/内存/调度/中断 → init 进程) → 挂载 rootfs → init → 内核解析设备树(of_platform_populate 展开 platform 设备) → 驱动模块 compatible 匹配成功 → probe() → misc_register 创建 /dev 节点 → 应用 open/read 访问硬件驱动开发记住三句话:
- 框架先跑通,再写时序:probe 能打印
is ok比读对数据更重要; - 硬件信息写设备树,代码只读不写死:换板子只改 dts;
- 资源申请与释放严格逆序,优先用
devm_系列减少泄漏。
如果这篇笔记对你有帮助,欢迎点赞收藏,评论区交流~