Day62 设备驱动程序开发基础与LED控制
day62 设备驱动程序开发基础与LED控制
本文档系统整理了设备驱动程序的核心概念、开发流程及实际应用,从理论到实践,涵盖字符设备驱动的注册、操作方法实现、硬件寄存器操作以及用户空间与内核空间的数据交互。
一、 驱动程序核心概念
1. 什么是驱动程序
驱动程序是操作和管理硬件的程序。在操作系统中,应用程序不能直接访问硬件,必须通过内核提供的驱动程序接口来间接操作硬件。
2. 系统架构分层
一个典型的计算机系统由硬件和软件构成,软件部分可进一步分为:
- 操作系统 (OS):核心,负责管理系统资源。
- 文件系统 (File System):建立在操作系统之上,提供文件管理功能。
- 用户应用程序 (User App):运行在文件系统之上,通过系统调用与操作系统交互。
user app [open(led) fopen(led)]
---
rootfs [libc]
---
kernel [syscall_open(led)--led-->led 1\key 2\uart 3---1-->sys_open(1)--->1 2 3---open--->LED_DRV]
1 LED_DRV|2 KEY_DRV|3 UART_DRV
---
硬件
LED|KEY|UART
3. 驱动程序分类
根据数据访问方式和设备特性,驱动程序主要分为三类:
-
字符设备驱动 (Character Device Driver):
- 特点: 数据按字节流 (byte stream) 形式访问,即数据访问有严格的顺序性。
- 示例: LED灯、按键、串口(UART)等。例如,控制LED灯亮灭必须按顺序执行“开-关-开”,无法跳过中间步骤。
- 管理方式: 通过设备号 (Device Number) 管理。
-
块设备驱动 (Block Device Driver):
- 特点: 数据以固定大小的块 (block) 为单位进行访问,通常用于存储设备。
- 示例: 硬盘、SSD、U盘等。访问时可以随机读取任意位置的数据,不受顺序限制。
- 管理方式: 通过设备号 (Device Number) 管理。
-
网络设备驱动 (Network Device Driver):
- 特点: 集成复杂的协议栈 (Protocol Stack),如TCP/IP、UDP等。
- 示例: 网卡。
- 管理方式: 按照名字 (Name) 管理,如
ens33
。
4. 设备号 (Device Number)
设备号是内核用于管理驱动设备的一个32位无符号整数 (u32)。它被划分为两部分:
- 主设备号 (Major Number): 高12位,用于区分设备类型。例如,所有LED灯使用同一个主设备号,所有按键使用另一个主设备号。
- 次设备号 (Minor Number): 低20位,用于区分同一类型设备中的不同个体。例如,4个LED灯分别使用不同的次设备号(0, 1, 2, 3)。
注意: 网络设备没有设备号,而是通过名称管理。
二、 字符设备驱动开发流程
开发一个字符设备驱动程序需要完成以下四个核心步骤:
步骤1: 定义设备号
为驱动程序分配一个唯一的设备号。通常,主设备号从较大的数字开始分配(如255),因为较小的号码已被系统占用。
// 定义主设备号、次设备号和设备名称
#define MAJOR_NUM 248 // 主设备号
#define MINOR_NUM 0 // 次设备号
#define DEV_NAME "led1" // 设备名称
步骤2: 实现操作方法 (file_operations)
定义一个 struct file_operations
结构体,其中包含指向驱动程序具体操作函数的指针,如 open
, read
, write
, release
(close)。
// 声明操作函数原型
static int open(struct inode *node, struct file *file);
static ssize_t read(struct file *file, char __user *buf, size_t len, loff_t *loff);
static ssize_t write(struct file *file, const char __user *buf, size_t len, loff_t *loff);
static int close(struct inode *node, struct file *file);// 定义并初始化 file_operations 结构体
static struct file_operations fops = {.owner = THIS_MODULE, // 指向当前模块,用于防止模块在被引用时被卸载.open = open, // 绑定 open 函数.read = read, // 绑定 read 函数.write = write, // 绑定 write 函数.release = close // 绑定 release (close) 函数
};
步骤3: 绑定设备号与操作方法
创建一个 struct cdev
结构体,并将其与设备号和操作方法绑定。
static dev_t devno; // 设备号变量
static struct cdev cdev; // cdev 结构体实例// 在模块初始化函数中执行绑定
static int __init led1_init(void)
{int ret = 0;// 1. 使用 MKDEV 宏组合主、次设备号devno = MKDEV(MAJOR_NUM, MINOR_NUM);// 2. 初始化 cdev 结构体,关联操作方法cdev_init(&cdev, &fops);// 3. 将 cdev 添加到内核中,指定设备号和数量ret = cdev_add(&cdev, devno, 1);if(ret < 0)goto err_cdev_add; // 错误处理...
}
步骤4: 向内核注册驱动
调用 register_chrdev_region
函数,向内核申请并注册设备号范围。
// 在模块初始化函数中注册设备号
ret = register_chrdev_region(devno, 1, DEV_NAME); // 注册一个设备
if(ret < 0)goto err_register_chrdev; // 错误处理
步骤5: 清理与注销 (模块退出)
当模块被卸载时,必须反向执行上述步骤,释放资源。
static void __exit led1_exit(void)
{// 1. 取消映射的物理地址iounmap(gpio1_data);iounmap(gpio1_dir);iounmap(sw_pad);iounmap(sw_mux);// 2. 注销设备号unregister_chrdev_region(devno, 1);// 3. 删除 cdevcdev_del(&cdev);printk("led1_exit ############################\n");
}
三、 用户空间与内核空间交互
1. 应用程序调用流程
用户程序通过标准库函数(如 open
, read
, write
)发起系统调用,最终由内核调用驱动程序中对应的函数。
// 用户空间应用程序 (led1_app.c)
int main(int argc, const char *argv[])
{int fd = open("/dev/led1", O_RDWR); // 调用 open 系统调用if(fd < 0) { /* 错误处理 */ }write(fd, "ledon", 5); // 调用 write 系统调用write(fd, "ledoff", 6); // 调用 write 系统调用close(fd); // 调用 close 系统调用return 0;
}
2. 驱动程序函数详解
- open() 函数:
- 作用: 初始化设备(如配置GPIO引脚),并建立设备与文件描述符的关联。
- 返回值: 返回0表示成功,非0表示失败。注意:
open
的返回值不是文件描述符,而是给内核判断是否成功的状态码。成功后,内核会分配一个文件描述符(通常是3, 4, 5…)返回给用户程序。
static int open(struct inode *node, struct file *file)
{led_init(); // 初始化LED硬件printk("kernel led open ...\n");return 0; // 成功
}
- read() 函数:
- 作用: 从设备读取数据。对于LED驱动,此功能通常不需要实现。
- 参数:
file
文件结构体,buf
用户空间缓冲区指针,len
请求读取长度,loff
文件偏移量。 - 返回值: 返回实际读取的字节数或错误码。
static ssize_t read(struct file *file, char __user *buf, size_t len, loff_t *loff)
{printk("kernel led read ...\n");return 0; // 未实现读取功能
}
- write() 函数:
- 作用: 向设备写入数据,用于控制硬件行为(如开关LED)。
- 关键点: 用户空间传入的指针 (
buf
) 是虚拟地址,不能直接在内核中使用。必须使用copy_from_user
函数将数据从用户空间安全地拷贝到内核空间。 - 返回值: 返回成功写入的字节数,或错误码(如
-EINVAL
表示无效参数)。
static ssize_t write(struct file *file, const char __user *buf, size_t len, loff_t *loff)
{int ret = 0;unsigned char data[10] = {0}; // 内核空间缓冲区// 计算要拷贝的实际长度,防止越界unsigned int len_cp = len < sizeof(data) ? len : sizeof(data);// 从用户空间拷贝数据到内核空间ret = copy_from_user(data, buf, len_cp);if(ret != 0) {printk("copy_from_user failed\n");return -EFAULT; // 拷贝失败}// 根据接收到的字符串控制LEDif(!strcmp(buf, "ledon"))led_on(); // 开灯else if(!strcmp(buf, "ledoff"))led_off(); // 关灯elseret = -EINVAL; // 无效参数printk("kernel led write ...\n");return ret; // 返回写入的字节数或错误码
}
- close() 函数:
- 作用: 清理设备状态(如关闭LED),释放资源。
- 返回值: 返回0表示成功,非0表示失败。
static int close(struct inode *node, struct file *file)
{led_off(); // 关闭LEDprintk("kernel led close ...\n");return 0; // 成功
}
四、 硬件操作与寄存器访问
为了控制LED,驱动程序需要直接操作CPU的GPIO寄存器。由于这些寄存器位于物理地址空间,而内核代码运行在虚拟地址空间,因此需要使用 ioremap
函数进行地址映射。
1. 寄存器地址定义
根据芯片手册,定义相关寄存器的物理地址。
#define GPIO1_SW_MUX_CTL 0x20e0068 // IOMUXC_GPR_GPR1寄存器地址,用于设置引脚复用功能
#define GPIO1_SW_PAD_CTL 0x20e02f4 // IOMUXC_GPR_GPR1寄存器地址,用于设置引脚电气属性
#define GPIO1_DIR 0x209C004 // GPIO1_DR寄存器地址,用于设置方向(输入/输出)
#define GPIO1_DATA 0x209C000 // GPIO1_GDIR寄存器地址,用于设置数据(高/低电平)
2. 地址映射与硬件初始化
在驱动初始化时,使用 ioremap
将物理地址映射为内核可访问的虚拟地址。
static volatile unsigned long * sw_mux;
static volatile unsigned long * sw_pad;
static volatile unsigned long * gpio1_dir;
static volatile unsigned long * gpio1_data;static void led_init(void)
{// 配置引脚复用功能为GPIO模式*sw_mux &= ~(0xf << 0); // 清除低4位*sw_mux |= (0x5 << 0); // 设置为GPIO模式 (0b0101)// 配置引脚电气属性*sw_pad = 0x10b0; // 设置为默认值// 设置GPIO方向为输出*gpio1_dir |= (1 << 3); // 第3位设为1,表示输出// 初始化LED为关闭状态 (高电平)*gpio1_data |= (1 << 3);
}static int __init led1_init(void)
{int ret = 0;...// 映射物理地址到虚拟地址sw_mux = ioremap(GPIO1_SW_MUX_CTL, 4);sw_pad = ioremap(GPIO1_SW_PAD_CTL, 4);gpio1_dir = ioremap(GPIO1_DIR, 4);gpio1_data = ioremap(GPIO1_DATA, 4);...
}
3. 控制LED亮灭
通过向数据寄存器写入特定值来控制LED。
static void led_on(void)
{*gpio1_data &= ~(1 << 3); // 清除第3位,拉低电平,点亮LED
}static void led_off(void)
{*gpio1_data |= (1 << 3); // 设置第3位,拉高电平,熄灭LED
}
volatile关键字: 用于修饰指针,告诉编译器该变量的值可能会被外部(如硬件)意外修改,禁止编译器对其进行优化,确保每次访问都从内存中读取真实值。
五、 编译、安装与测试
1. 修改内核配置
将驱动程序添加到内核构建系统中。
-
修改 Makefile:
# drivers/char/Makefile obj-$(CONFIG_LED1) += led1.o
-
修改 Kconfig:
# drivers/char/Kconfig config LED1bool "this is my led1"default yhelpthis is my first drivers, no use!
-
配置内核:
make menuconfig # 在菜单中选择 "Device Drivers" -> "Character devices" -> "this is my led1" # 保存并退出
2. 编译与部署
make zImage # 编译内核镜像
cp arch/arm/boot/zImage ~/tftpboot/ # 复制到TFTP服务器
3. 手动创建设备节点
在目标板上手动创建设备文件节点。
# 在目标板上执行
mknod /dev/led1 c 248 0 # 创建字符设备节点,主设备号248,次设备号0
4. 编译并运行测试程序
# 在主机上交叉编译应用程序
arm-linux-gnueabihf-gcc led1_app.c -o led1_app# 将应用程序复制到目标板的NFS根文件系统
scp led1_app root@<target_ip>:/nfs/imx6/rootfs/# 在目标板上运行程序
./led1_app
5. 测试结果
程序运行后,LED灯应按照程序逻辑闪烁(亮1秒,灭1秒)。同时,内核日志会输出相应的调试信息。
kernel led open ...
kernel led write ...
kernel led write ...
kernel led write ...
write to led1: Invalid argument
...
六、 错误处理与最佳实践
1. 错误处理
在驱动开发中,任何可能失败的操作(如 cdev_add
, register_chrdev_region
, ioremap
)都必须进行错误检查,并在失败时清理已申请的资源。
static int __init led1_init(void)
{int ret = 0;...ret = cdev_add(&cdev, devno, 1);if(ret < 0)goto err_cdev_add;ret = register_chrdev_region(devno, 1, DEV_NAME);if(ret < 0)goto err_register_chrdev;sw_mux = ioremap(GPIO1_SW_MUX_CTL, 4);if(!sw_mux) {ret = -ENOMEM;goto err_ioremap;}... // 其他映射return 0;err_ioremap:iounmap(sw_mux);// ... 清理其他映射
err_register_chrdev:unregister_chrdev_region(devno, 1);
err_cdev_add:cdev_del(&cdev);printk("led1_init failed ret = %d\n", ret);return ret;
}
2. 自动创建设备节点 (关键补充)
手动创建设备节点繁琐且易错。推荐使用 class_create
和 device_create
函数让内核自动创建设备节点。
#include <linux/device.h> // 包含 class_create, device_create 等函数声明
// ... 其他必要的头文件// ... 其他定义
#define DEV_NAME "led1" // 设备节点名称 (这个名称将决定 /dev/ 下的文件名)
#define CLASS_NAME "led_class" // 设备类名称 (用于在 /sys/class/ 下创建类目录)static struct class *led_class; // 用于指向设备类
static struct device *led_device; // 用于指向设备节点static int __init led1_init(void)
{...// 创建设备类led_class = class_create(THIS_MODULE, "led_class");if(IS_ERR(led_class)) {ret = PTR_ERR(led_class);goto err_class_create;}// 创建设备节点led_device = device_create(led_class, NULL, devno, NULL, DEV_NAME);if(IS_ERR(led_device)) {ret = PTR_ERR(led_device);goto err_device_create;}...
// 错误处理路径 (按相反顺序清理资源)
err_device_create:class_destroy(led_class); // 销毁类
err_class_create:// iounmap(gpio1_data); // 假设这里有 iounmap 调用// iounmap(gpio1_dir);// iounmap(sw_pad);// iounmap(sw_mux);// (注意: 如果 ioremap 在 device_create 之前,需要在这里添加 iounmap)
err_ioremap_sw_mux: // (假设 ioremap 在 device_create 之前)cdev_del(&cdev);
err_cdev_add:unregister_chrdev_region(devno, 1);
err_register_chrdev:printk("led1_init failed, ret = %d\n", ret);return ret; // 返回错误码
}static void __exit led1_exit(void)
{// 销毁设备和类device_destroy(led_class, devno);class_destroy(led_class);...
}
关键问题解答: 为什么需要手动创建设备节点?
系统自带设备(如/dev/ttyUSB0
)无需手动创建,是因为它们的驱动程序内部已经实现了class_create
和device_create
机制。我们的驱动程序缺少这个机制,所以需要手动执行mknod
来创建节点。自动创建设备节点的关键在于class_create
和device_create
这两个函数。设备节点的名称(如led1
)是由device_create
函数的最后一个参数DEV_NAME
决定的。你可以尝试修改#define DEV_NAME "auto_led"
,然后重新编译加载驱动,观察/dev
目录下生成的节点名称是否变为auto_led
。
七、 总结与延伸
通过本日学习,我们掌握了字符设备驱动程序的基本框架和开发流程,包括设备号管理、操作方法实现、硬件寄存器操作以及用户空间与内核空间的数据交互。最终,我们成功实现了对LED灯的控制,验证了驱动程序的有效性。
后续学习建议:
- 深入研究
ioctl
系统调用,实现更复杂的设备控制。 - 学习中断处理机制,实现按键等外设的异步响应。
- 探索
platform_driver
模型,使驱动程序与设备树(Device Tree)更好地集成。 - 学习如何使用
udev
规则自动创建和管理设备节点。
重要提醒:
- 拒绝图形化界面: 强烈建议使用命令行进行所有操作,这有助于深入理解底层原理,是嵌入式开发人员必备技能。
- 别怕麻烦,多动手调试: 驱动开发过程复杂,遇到问题不要气馁,通过反复练习和调试,才能真正掌握。
- 搜索关键词: “Linux 字符设备驱动自动创建设备节点”、“class_create”、“device_create”。