ARM-驱动-03 Linux 字符设备驱动开发

一、驱动程序基础概念

1. 驱动程序的本质

驱动程序本质上就是操作硬件的程序,和裸机开发中写的 BSP 代码干的是同一件事------直接控制寄存器、管理外设。

区别在于:

  • 裸机开发:驱动和应用代码混在一起写,没有明确的分层,想怎么访问硬件就怎么访问。
  • 有操作系统:驱动被独立成内核模块,和应用层严格分开。应用层不能直接碰硬件寄存器,必须通过驱动提供的接口(open/read/write)来操作。

这样做的好处有两个:

  1. 给上层应用提供统一、安全的访问接口,屏蔽硬件细节,降低开发门槛。
  2. 通过内核隔离机制防止应用层误操作硬件(早期没有这个机制,病毒可以直接烧毁主板)。

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);

相关推荐
somi72 小时前
ARM-驱动-01Linux系统移植
arm开发
似水এ᭄往昔2 小时前
【Linux】--程序地址空间
linux·运维·服务器
DeeplyMind2 小时前
Linux 内核补丁提交(Upstream)完整指南
linux·upstream
三道渊2 小时前
Linux进程通信与信号处理全解析
linux·服务器·网络
Java后端的Ai之路2 小时前
sudo 命令详解:Linux 权限管理的“万能钥匙“
linux·运维·服务器·sudo
努力努力再努力wz2 小时前
【C++高阶系列】告别内查找局限:基于磁盘 I/O 视角的 B 树深度剖析与 C++ 泛型实现!(附B树实现源码)
java·linux·开发语言·数据结构·c++·b树·算法
somi72 小时前
ARM-04-驱动-Misc ,Platform ,DTS
arm开发·单片机·嵌入式硬件·自用
艾莉丝努力练剑2 小时前
【QT】Qt常用控件与布局管理深度解析:从原理到实践的架构思考
linux·运维·服务器·开发语言·网络·qt·架构
石小千3 小时前
使用Inotifywait监控事件并Rsync同步变更
linux·运维