一、驱动程序基础概念
1. 驱动程序的本质
驱动程序本质上就是操作硬件的程序,和裸机开发中写的 BSP 代码干的是同一件事------直接控制寄存器、管理外设。
区别在于:
- 裸机开发:驱动和应用代码混在一起写,没有明确的分层,想怎么访问硬件就怎么访问。
- 有操作系统:驱动被独立成内核模块,和应用层严格分开。应用层不能直接碰硬件寄存器,必须通过驱动提供的接口(open/read/write)来操作。
这样做的好处有两个:
- 给上层应用提供统一、安全的访问接口,屏蔽硬件细节,降低开发门槛。
- 通过内核隔离机制防止应用层误操作硬件(早期没有这个机制,病毒可以直接烧毁主板)。
2. Linux"一切皆文件"与设备管理
Linux 中所有设备都可以通过文件操作接口(open/read/write)访问。应用层用设备文件名(如 /dev/led)调用,但内核内部是通过设备号(一个数字)来唯一标识和管理设备的,因为数字比字符串查找更高效。
设备号的构成:
| 字段 | 位数 | 作用 |
|---|---|---|
| 主设备号(major) | 12位 | 区分设备类型,如 UART、LED |
| 次设备号(minor) | 20位 | 区分同类设备的不同实例,如 UART0、UART1 |
内核维护一个全局设备链表,每个设备对应一个包含设备号和操作函数指针的结构体,系统通过设备号在链表中查找并调用对应驱动函数。
3. Linux 设备驱动三大分类
- 字符设备:按字节流顺序访问(LED、UART),支持 read/write,是本文的主角。
- 块设备:按数据块随机访问(磁盘),通常以 512 字节为单位,支持缓存与重排序。
- 网络设备:不依赖设备号,通过名称(eth0)管理,使用 socket 接口通信。
4. 应用层调用驱动的完整流程
应用层调用 open("/dev/led")
→ 内核通过设备名查表获取设备号(如 255:0)
→ 根据设备号定位驱动结构体
→ 调用其 open 函数
后续 read/write 直接通过文件描述符(fd)操作,无需再次解析文件名
二、驱动程序模板与硬件接入
1. 驱动程序模板("0.5个Hello World")
第一个驱动模板只实现了 open、read、write 接口,内核端打印日志确认通信成功。
老师称之为"0.5 个 Hello World"------虽然应用层和驱动之间通信链路通了,但还没有真正操作硬件,只是打印了几行 log。
2. 驱动接入硬件:LED 控制
在 write 函数中根据用户写入的字符串控制 LED:
c
static ssize_t led_write(struct file *file, const char __user *buf,
size_t len, loff_t *offset)
{
unsigned char data[10] = {0};
// 取用户传入长度和缓冲区大小的最小值,防止越界
size_t len_cp = len < sizeof(data) ? len : sizeof(data);
int ret = copy_from_user(data, buf, len_cp); // 安全拷贝,不能直接访问用户指针
if (ret < 0) return ret;
if (!strcmp(data, "ledon"))
led_on();
else if (!strcmp(data, "ledoff"))
led_off();
else
return -EINVAL; // 使用标准错误码,不要统一返回 -1
return len_cp;
}
这里有两个重要细节:
① 必须用 copy_from_user,不能直接访问用户空间指针
用户空间和内核空间是隔离的,直接访问用户指针存在安全风险,必须通过 copy_from_user() 安全复制数据过来。
② 错误码要用标准定义,不要返回 -1
c
// ❌ 不好的写法
return -1;
// ✅ 正确写法,用户空间 perror() 才能准确解析
return -EINVAL; // 无效参数,= -22
return -ENOMEM; // 内存不足
return -EBUSY; // 设备忙
3. 硬件寄存器映射:ioremap
MMU 启用后不能直接访问物理地址,必须用 ioremap() 映射为内核虚拟地址:
c
sw_mux = ioremap(IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03, 4);
sw_pad = ioremap(IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO03, 4);
gpio1_dr = ioremap(GPIO1_DR, 4);
gpio1_gdir = ioremap(GPIO1_GDIR, 4);
// 退出时必须释放
iounmap(sw_mux);
三、编写字符设备驱动的四步核心流程
1. 实现 file_operations 结构体
驱动需要实现标准操作函数并填入 file_operations 结构体:
c
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = led_open,
.read = led_read,
.write = led_write,
.release = led_close,
};
不同设备实现不同的函数:LED 只需 write,按键只需 read,UART 需要 read/write,波特率等配置通过 ioctl 实现。
2. 分配/注册设备号 & 向内核注册驱动
这里用到的函数有:
① 手动分配设备号的函数(旧方式,了解即可)
c
// 手动指定设备号
dev = MKDEV(255, 0);
register_chrdev_region(dev, 1, "led");
② 自动分配设备号的函数(推荐)
c
// 让内核自动挑一个空闲的主设备号
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
| 参数 | 说明 |
|---|---|
dev |
输出参数,内核将分配好的设备号写进来 |
baseminor |
起始次设备号,通常填 0 |
count |
要申请的设备数量 |
name |
设备名,出现在 /proc/devices 中 |
③ 将操作函数和设备号关联到字符设备结构体
c
cdev_init(&cdev, &fops); // 把 fops 绑定到 cdev
④ 自动创建字符设备驱动(注册到内核)
c
cdev_add(&cdev, dev, 1); // 将 cdev 注册到内核,从此内核能响应对该设备的访问
⑤ 转换虚拟地址
c
sw_mux = ioremap(物理地址, 大小); // 物理地址 → 内核虚拟地址
⑥ 各个销毁函数
c
device_destroy(led_class, dev); // 销毁设备节点
class_destroy(led_class); // 销毁 class
cdev_del(&cdev); // 注销字符设备
unregister_chrdev_region(dev, 1); // 释放设备号
iounmap(sw_mux); // 释放寄存器映射
⑦ __init 和 __exit 解释
__init 宏会把初始化函数放入内核的 .initcall6.init 段,内核启动时按顺序遍历执行。__exit 标记退出函数,rmmod 时调用。
⑧ 加载到内核初始化(启动内核会自动执行)
c
module_init(led1_init); // 告诉内核:insmod 时调用 led1_init
module_exit(led1_exit); // 告诉内核:rmmod 时调用 led1_exit
整个过程可以解释为:
insmod led.ko
→ led1_init() 被调用
→ 分配设备号
→ 初始化 cdev,绑定 fops
→ 注册到内核(cdev_add)
→ 创建 class 和 device(udev 自动建 /dev/led 节点)
→ 映射寄存器
应用层 open("/dev/led")
→ 内核查设备号 → 找到 cdev → 调用 led_open()
rmmod led.ko
→ led1_exit() 被调用,逆序清理所有资源
3. 今天的作业侧重点:动态设备号 + 自动节点
问题1:手动指定设备号的缺陷
c
#define MAJOR_NUM 255 // 写死了,万一别人也用了 255?
dev = MKDEV(MAJOR_NUM, 0);
register_chrdev_region(dev, 1, "led"); // 返回 -EBUSY,加载失败
解决:改用 alloc_chrdev_region
c
static dev_t dev; // 不再自己填,让内核分配
ret = alloc_chrdev_region(&dev, 0, 1, "led");
if (ret < 0) {
printk(KERN_ERR "led: 分配设备号失败 ret=%d\n", ret);
goto err_alloc;
}
// 之后用 MAJOR(dev) 读出实际分配到的主设备号
printk(KERN_INFO "led: major=%d\n", MAJOR(dev));
⚠️ 注意初始化顺序,原代码顺序是错的:
c
// ❌ 错误顺序(原代码)
cdev_add(&cdev, dev, 1); // 先 add(门开了但门牌没挂)
register_chrdev_region(dev, 1, "led"); // 后 register
// ✅ 正确顺序
alloc_chrdev_region(&dev, 0, 1, "led"); // ① 先拿到设备号
cdev_init(&cdev, &fops); // ② 初始化 cdev
cdev_add(&cdev, dev, 1); // ③ 再对外注册
问题2:手动 mknod 太麻烦
每次 insmod 后都要手动执行 mknod /dev/led c <major> 0,设备号还是动态的,根本不知道填几。
解决:用 class_create + device_create 自动创建
c
#include <linux/device.h> // 必须加这个头文件
// 第一步:在 /sys/class/ 下建 led 目录,udev 监视这里
static struct class *led_class;
led_class = class_create(THIS_MODULE, "led");
if (IS_ERR(led_class)) { // 注意:失败不是返回 NULL,要用 IS_ERR 判断
ret = PTR_ERR(led_class); // 用 PTR_ERR 提取错误码
goto err_class;
}
// 第二步:写入设备信息,udev 自动执行 mknod,/dev/led 节点自动生成
static struct device *led_device;
led_device = device_create(led_class, NULL, dev, NULL, "led");
if (IS_ERR(led_device)) {
ret = PTR_ERR(led_device);
goto err_device;
}
4. 创建设备节点(绑定名字和设备号)
用自动创建则不需要手动 mknod,系统挂载文件系统后 udev 会自动在 /dev/ 下生成节点。
四、健壮性设计:goto 错误链
驱动初始化任何一步失败,必须回滚之前已分配的资源,否则造成内核资源泄漏。Linux 内核推荐用 goto 实现逆序回滚:
c
static int __init led1_init(void)
{
int ret;
ret = alloc_chrdev_region(&dev, 0, 1, "led");
if (ret < 0) goto err_alloc;
cdev_init(&cdev, &fops);
ret = cdev_add(&cdev, dev, 1);
if (ret < 0) goto err_cdev;
led_class = class_create(THIS_MODULE, "led");
if (IS_ERR(led_class)) { ret = PTR_ERR(led_class); goto err_class; }
led_device = device_create(led_class, NULL, dev, NULL, "led");
if (IS_ERR(led_device)) { ret = PTR_ERR(led_device); goto err_device; }
sw_mux = ioremap(IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03, 4);
sw_pad = ioremap(IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO03, 4);
gpio1_dr = ioremap(GPIO1_DR, 4);
gpio1_gdir = ioremap(GPIO1_GDIR, 4);
printk(KERN_INFO "led_init success\n");
return 0;
// 逆序回滚:哪步失败就从这里开始,把前面成功的依次清理掉
err_device:
class_destroy(led_class);
err_class:
cdev_del(&cdev);
err_cdev:
unregister_chrdev_region(dev, 1);
err_alloc:
return ret;
}
退出函数同样严格逆序:
c
static void __exit led1_exit(void)
{
iounmap(gpio1_gdir);
iounmap(gpio1_dr);
iounmap(sw_pad);
iounmap(sw_mux);
device_destroy(led_class, dev); // ① 先销毁节点
class_destroy(led_class); // ② 再销毁 class
cdev_del(&cdev); // ③ 注销 cdev
unregister_chrdev_region(dev, 1); // ④ 最后释放设备号
}
五、总结与补充
1. vim 的 ctags 使用方法
在阅读内核源码时,经常需要跳转到函数定义处,ctags 是最顺手的工具:
bash
# 在内核源码根目录生成索引
ctags -R
# vim 中使用
Ctrl+] # 跳转到光标下符号的定义
Ctrl+o # 返回跳转前的位置
# 有多个定义时,输入数字选择跳转目标
2. 我们的侧重点(今天作业总结)
今天的两个改动,让驱动从"能跑"升级到"规范":
| 改动 | 旧写法 | 新写法 |
|---|---|---|
| 设备号分配 | MKDEV(255, 0) 手动写死 |
alloc_chrdev_region 动态申请 |
| 初始化顺序 | cdev_add 在 alloc 之前(错误) | 先 alloc → 再 init → 再 add |
| 创建设备节点 | 手动执行 mknod |
class_create + device_create 自动生成 |
| 错误处理 | 缺失或不完整 | goto 错误链,逆序释放 |
完整驱动代码:
c
#include <linux/init.h>
#include <linux/printk.h>
#include <linux/kdev_t.h>
#include <linux/types.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h> // 新增:class_create / device_create
#include <asm/uaccess.h>
#include <asm/string.h>
#include <asm/io.h>
#define MINOR_START 0
#define DEV_COUNT 1
#define DEV_NAME "led"
#define IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03 0x20e0068U
#define IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO03 0x20E02F4U
#define GPIO1_DR 0x209C000U
#define GPIO1_GDIR 0x209C004U
static volatile unsigned int *sw_mux;
static volatile unsigned int *sw_pad;
static volatile unsigned int *gpio1_dr;
static volatile unsigned int *gpio1_gdir;
static void led_init(void)
{
*sw_mux = 0x05;
*sw_pad = 0x10b0;
*gpio1_gdir |= (1 << 3);
*gpio1_dr |= (1 << 3);
}
static void led_on(void) { *gpio1_dr &= ~(1 << 3); }
static void led_off(void) { *gpio1_dr |= (1 << 3); }
static int led_open(struct inode *node, struct file *file)
{
led_init();
printk("led open...\n");
return 0;
}
static ssize_t led_read(struct file *file, char __user *buf,
size_t len, loff_t *offset)
{
printk("led read...\n");
return 0;
}
static ssize_t led_write(struct file *file, const char __user *buf,
size_t len, loff_t *offset)
{
unsigned char data[10] = {0};
size_t len_cp = len < sizeof(data) ? len : sizeof(data);
int ret = copy_from_user(data, buf, len_cp);
if (ret < 0) return ret;
if (!strcmp(data, "ledon")) led_on();
else if (!strcmp(data, "ledoff")) led_off();
else return -EINVAL;
printk("led write...\n");
return len_cp;
}
static int led_close(struct inode *node, struct file *file)
{
led_off();
printk("led close...\n");
return 0;
}
static dev_t dev;
static struct cdev cdev;
static struct class *led_class;
static struct device *led_device;
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = led_open,
.read = led_read,
.write = led_write,
.release = led_close,
};
static int __init led1_init(void)
{
int ret;
// ① 动态分配主设备号
ret = alloc_chrdev_region(&dev, MINOR_START, DEV_COUNT, DEV_NAME);
if (ret < 0) { printk(KERN_ERR "alloc_chrdev_region failed\n"); goto err_alloc; }
printk(KERN_INFO "led: major=%d\n", MAJOR(dev));
// ② 注册字符设备
cdev_init(&cdev, &fops);
ret = cdev_add(&cdev, dev, DEV_COUNT);
if (ret < 0) { printk(KERN_ERR "cdev_add failed\n"); goto err_cdev; }
// ③ 创建 class(/sys/class/led)
led_class = class_create(THIS_MODULE, DEV_NAME);
if (IS_ERR(led_class)) { ret = PTR_ERR(led_class); goto err_class; }
// ④ 创建 device,udev 自动生成 /dev/led
led_device = device_create(led_class, NULL, dev, NULL, DEV_NAME);
if (IS_ERR(led_device)) { ret = PTR_ERR(led_device); goto err_device; }
// ⑤ 映射寄存器
sw_mux = ioremap(IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03, 4);
sw_pad = ioremap(IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO03, 4);
gpio1_dr = ioremap(GPIO1_DR, 4);
gpio1_gdir = ioremap(GPIO1_GDIR, 4);
printk(KERN_INFO "led_init success\n");
return 0;
err_device: class_destroy(led_class);
err_class: cdev_del(&cdev);
err_cdev: unregister_chrdev_region(dev, DEV_COUNT);
err_alloc: return ret;
}
static void __exit led1_exit(void)
{
iounmap(gpio1_gdir);
iounmap(gpio1_dr);
iounmap(sw_pad);
iounmap(sw_mux);
device_destroy(led_class, dev);
class_destroy(led_class);
cdev_del(&cdev);
unregister_chrdev_region(dev, DEV_COUNT);
printk(KERN_INFO "led_exit\n");
}
module_init(led1_init);
module_exit(led1_exit);