嵌入式Linux驱动开发的基本知识(驱动程序的本质、常见的设备类型、设备号的本质理解、设备文件、类属性文件、驱动程序的设备实例的注册过程、如何加载驱动程序模块、设备实例的调用流程)

发布于:2025-02-11 ⋅ 阅读:(59) ⋅ 点赞:(0)

01-1-基本概念之什么是驱动程序()?

驱动程序本质上是代码逻辑的集合,通常用于管理、驱动多个设备实例。要想使用某个设备的驱动程序,需要实例化相应的驱动程序,具体实例化的过程请看本文第05点。
这里重点要理解驱动程序只是代码逻辑的集合,它本身并没有主设备号、次设备号,也没有设备文件,主设备号、次设备号、设备文件都是在实例化驱动程序过程中产生的。

01-2-驱动程序运行于Linux的内核空间,而不是用户空间中

驱动程序运行于Linux的内核空间,而不是用户空间中,用户程序是不能直接访问或修改内核中的数据,需要用专门的方式或函数进行数据交换。
关于这个标题的详解见 https://blog.csdn.net/wenhao_ir/article/details/144950319

02-Linux系统中常见的设备类型有哪些?

在 Linux 系统中,设备可以分为不同的类型,主要根据它们的工作方式、接口和功能进行分类。以下是 Linux 系统中常见的设备类型:


1. 字符设备 (Character Devices)

字符设备是按字符流方式进行数据传输的设备,每次读写都涉及一个字符或字节的数据。例如:

  • 终端设备 (tty): 用于与用户交互的设备,如串口终端、虚拟终端。
  • 串口设备 (ttyS): 串口通信设备,如 /dev/ttyS0
  • 键盘 (kbd): 连接的键盘设备。
  • 鼠标 (mouse): 连接的鼠标设备。
  • 伪设备:例如 /dev/null/dev/random/dev/zero 等,这些设备没有硬件实现,提供特殊的功能。

特点

  • 按字符流进行读写,每次读写一个字符(或字节)。
  • 通过字符设备文件(如 /dev/ttyS0)与应用程序交互。

2. 块设备 (Block Devices)

块设备是按块(通常是 512 字节或更大的单位)进行数据传输的设备。它们支持随机访问和缓存功能。例如:

  • 硬盘 (/dev/sda, /dev/sdb 等): 计算机的主要存储设备。
  • 固态硬盘 (SSD) (/dev/sda1, /dev/nvme0n1): 新型存储设备,提供更高的读写速度。
  • 光盘驱动器 (/dev/cdrom): 用于读取光盘数据的设备。
  • USB 存储设备 (/dev/sdb1, /dev/sdc): 连接到系统的 USB 存储设备。
  • 虚拟磁盘 (/dev/loop0): 用于挂载虚拟磁盘映像的设备。

特点

  • 支持随机读写访问,通常用于文件系统的挂载。
  • 设备文件通常位于 /dev/ 目录下。

3. 网络设备 (Network Devices)

网络设备是用于网络通信的设备。它们支持网络接口的连接和数据传输。例如:

  • 以太网卡 (eth0, eth1): 网络接口,用于连接有线网络。
  • 无线网卡 (wlan0, wlan1): 用于连接无线网络。
  • 虚拟网络设备 (lo): 回环接口,用于与本机进行网络通信。

特点

  • 用于实现计算机与外部网络的通信。
  • 可以是有线或无线设备,支持 TCP/IP 等协议。

4. 输入设备 (Input Devices)

输入设备是用于将用户输入传递给计算机的设备。它们通常使用字符设备接口。例如:

  • 鼠标 (/dev/input/mice): 用于提供用户的指针控制输入。
  • 键盘 (/dev/input/event0): 用于接收用户的键盘输入。
  • 触摸屏 (/dev/input/eventX): 用于接收触摸操作。

特点

  • 输入设备通常通过 /dev/input/ 目录进行管理,使用字符设备接口。
  • 输入事件通过设备文件传递给应用程序。

5. 虚拟设备 (Virtual Devices)

虚拟设备并不对应于物理硬件设备,而是通过软件实现的设备。例如:

  • 虚拟终端 (/dev/tty0, /dev/tty1): 提供与系统交互的虚拟控制台。
  • 内存设备 (/dev/mem): 用于访问物理内存的虚拟设备。
  • 随机数设备 (/dev/random, /dev/urandom): 用于生成随机数的设备。

特点

  • 不依赖于硬件,而是由内核或驱动程序模拟。
  • 提供系统管理或特殊功能。

6. 特殊设备 (Special Devices)

这些设备通常与硬件密切相关,但它们的作用和用法比较特殊。例如:

  • 时钟设备 (/dev/rtc): 实时钟设备,用于管理系统时间。
  • 伪设备 (/dev/null, /dev/zero): 不涉及实际硬件,而是提供特殊功能,通常用于丢弃或生成数据。

7. USB 设备 (USB Devices)

USB 设备通过 USB 总线连接到计算机,涵盖了各种设备类型。例如:

  • USB 存储设备 (/dev/sda1, /dev/sdb1): 连接的 USB 存储。
  • USB 摄像头 (/dev/video0): 通过 USB 接口连接的摄像头。
  • USB 键盘和鼠标 (/dev/input/eventX): 连接的 USB 键盘和鼠标。

特点

  • 支持即插即用(plug-and-play),可以动态连接和断开。

8. 串口设备 (Serial Devices)

串口设备通过串行接口进行通信,通常用于较为传统的设备。例如:

  • 串口终端 (/dev/ttyS0, /dev/ttyS1): 传统的串口设备。
  • 调制解调器 (/dev/ttyUSB0): 通过串口连接的调制解调器。

特点

  • 通过串行接口传输数据,通常用于低速数据通信。

9. SCSI 设备 (SCSI Devices)

SCSI 是一种广泛用于硬盘、光驱等设备的标准接口。例如:

  • SCSI 硬盘 (/dev/sda, /dev/sdb): SCSI 接口的硬盘设备。
  • SCSI 光驱 (/dev/sr0): SCSI 接口的光驱设备。

特点

  • 高性能和扩展性,常用于服务器或工作站中。
  • 支持多个设备共享同一总线。

10. PCI 设备 (PCI Devices)

PCI 总线上的设备用于高性能数据交换。例如:

  • 显卡 (/dev/video0): 通过 PCI 接口连接的显卡设备。
  • 网卡 (/dev/eth0): 通过 PCI 接口连接的网卡设备。
  • 音频设备 (/dev/snd/pcmC0D0p): 通过 PCI 接口连接的音频设备。

特点

  • 高速数据传输,支持多种设备接口。
  • 通过 PCI 总线连接多个设备。

11. I2C 和 SPI 设备 (I2C/SPI Devices)

I2C 和 SPI 是常用于嵌入式设备的通信协议,用于连接传感器、显示器等设备。例如:

  • I2C 设备:通过 I2C 总线连接的设备,通常位于 /dev/i2c-X
  • SPI 设备:通过 SPI 总线连接的设备,通常位于 /dev/spidevX.Y

特点

  • 用于短距离、高速的设备间通信。
  • 主要用于嵌入式系统中的外围设备。

小结

在 Linux 系统中,设备类型非常丰富,主要包括字符设备、块设备、网络设备、输入设备、虚拟设备、USB 设备、串口设备等。每种设备都有不同的特点和用途,Linux 系统通过设备文件(通常位于 /dev/ 目录下)来对这些设备进行管理和访问。

03-什么叫主设备号、次设备号?

主设备号、次设备号是Linux系统管理设备的一种结构,通常如果有多个设备,其驱动程序如果一样,那我们为其分配相同的主设备号,内核通过主设备号找到负责处理该设备的驱动程序,也就是说驱动程序和主设备号是绑定的【也可简单粗暴地理解为主设备号是针对驱动程序而言的,看到主设备号,应该想到的是驱动程序,而不是具体的哪个设备,次设备号才对应于具体的设备】,但它们的次设备号不同,通过不同的次设备号来区别它们。
当然如果设备类型相同,驱动程序也相同,你也可以为它们分配不同的主设备号,不过不建议这样做,这不符号Linux的基本设计原则。
不过也有特例,比如不同类型的设备有时会使用相同的驱动程序,但此时我们也会为它们分配不同的主设备号,以便在系统中区分。例如:块设备和字符设备通常使用不同的主设备号,即使它们底层可能由同一个驱动程序管理。

04-设备文件、类属性文件有什么用?

请再次理解“在Linux系统中一切皆文件”这句话,驱动程序写好后,编译成模块文件(ko文件),然后加载进内核,加载完成后就在系统的相应目录中生成了设备文件和类属性文件。

设备文件中存储了设备的主设备号和次设备号,通过主设备号能找到这个设备文件的驱动程序,而驱动程序通过次设备号能区分具体是要操作哪个设备。

类属性文件中用于对某一类设备进行统一管理和操作,它里面存储的信息比较丰富,主要是某一类设备的统一读函数(show函数)和写函数(store函数),关于类属性文件的解释和具体例子,请参考博文 https://blog.csdn.net/wenhao_ir/article/details/144901797

05-以字符设备为例,有了设备的底层操作函数后,怎么样把这些底层操作函数整合进驱动程序中?设备是如何注册进系统的?以下这个流程就完成了这两个任务。

这里以字符设备为例。
这里以字符设备为例。
这里以字符设备为例。

注意:不同的设备类型有不同的对应下面各步骤的函数,但流程基本上是一样的。
注意:不同的设备类型有不同的对应下面各步骤的函数,但流程基本上是一样的。
注意:不同的设备类型有不同的对应下面各步骤的函数,但流程基本上是一样的。

第一步是调用函数alloc_chrdev_region()获得可用的主设备号、次设备号。

示例代码如下:

dev_t dev;
ret = alloc_chrdev_region(&dev, 0, 3, "my_device");

函数alloc_chrdev_region()的原型如下:

int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, const char *name);

参数解释

  1. dev
    指向一个 dev_t 类型的变量,用于保存分配的设备号(包含主设备号和次设备号)。成功分配后,可以通过 MAJOR(dev)MINOR(dev) 宏提取主设备号和次设备的起始号

  2. firstminor
    表示从哪个次设备号开始分配,一般设置为 0

  3. count
    表示需要分配的连续次设备号的数量。如果你需要多个次设备号,可以指定这个值(通常为 1 表示只分配一个设备号)。

  4. name
    为设备指定一个名字,这个名字主要用于调试时显示(比如 /proc/devices 中会看到)。

alloc_chrdev_region 运行后,系统为我们将要注册的设备分配了:

  1. 一个主设备号
    主设备号是系统在字符设备表中分配的唯一标识,用于将设备与驱动程序关联。

  2. 一个连续的次设备号范围
    根据调用时指定的 count 参数,系统分配了一段连续的次设备号范围。

这意味着,分配完成后:

  • 我们的设备对应的设备号范围是:
    (主设备号, 起始次设备号)(主设备号, 起始次设备号 + count - 1)
  • 每个次设备号可以用来表示不同的设备实例,但它们共享相同的主设备号。

这里要特别注意:变量dev中保存的是系统为我们分享的主设备号和次设备的超始号,而不是所有的次设备号哈。

第二步是调用函数cdev_init()将描述字符设备的核心结构体cdev的实例和文件操作结构体file_operations的实例绑定起来

示例代码如下:

static struct cdev my_cdev; // 定义字符设备结构体
static struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .read = my_read,
    .write = my_write,
    .release = my_close,
};

/* 核心结构体cdev和结构体file_operations的绑定 */
cdev_init(&my_cdev, &my_fops);

}

这里面涉及到两个重要的结构体,第一个是struct cdev 【可称其为字符设备结构体cdev】、另一个是 struct file_operations【可称其为文件操作结构体,为什么可以这样称呼?因为设备最终在Linux系统中被抽象为设备文件,而file_operations正是记录了这个设备文件能有的操作对应的是哪些函数】,下面进行介绍:

struct cdev 是 Linux 内核中的一个核心结构,用于表示字符设备的相关信息。

定义(简化版)

struct cdev {
    struct kobject kobj;                // 内核对象,用于 sysfs 集成
    const struct file_operations *ops; // 设备支持的操作集
    struct list_head list;             // 用于链接到内核的 cdev 列表
    dev_t dev;                         // 设备号
    unsigned int count;                // 管理的次设备号范围
};

从其简化版的定义中可以看出,字符设备结构体struct cdev的一个实例就把设备需要的最重要的信息描述完了:
cdev结构体的第1个成员kobj这里暂时不展开叙述。

cdev结构体的第2个成员ops实际上就是上面说到的两个重要结构体中的另一个 struct file_operations,它里面实际上就是设备能进行的具体操作行为的函数的集合,比如下面是一个 struct file_operations简化版的定义:
PS:完整定义请参考我的另一篇博文 https://blog.csdn.net/wenhao_ir/article/details/144905840

struct file_operations {
    struct module *owner;              // 指向模块的指针,防止模块卸载时被调用
    loff_t (*llseek)(struct file *, loff_t, int);
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
    ...
};

从中我们可以看出文件操作结构体file_operations里面就集合了read、write、open、release这些真正涉及对设备底层操作的成员函数(并且这些成员函数的参数和返回值类型都已经被定义好了),这些函数其实才是某个设备驱动程序的核心。文件操作结构体file_operations的第1个成员struct module *owner的详细介绍见 https://blog.csdn.net/wenhao_ir/article/details/144906774
关于文件操作结构体file_operations的主要操作函数open、read、write 和 release的各参数的详细介绍,请参考下面这篇博文:
https://blog.csdn.net/wenhao_ir/article/details/144934865

cdev结构体的第3个成员list_head list是用于链接到内核的 cdev 列表,这里不展开叙述。

cdev结构体的第4个成员dev中存储了它的一个实例管理的设备号,包括主设备号和次设备号的起始号。

cdev结构体的第5个成员count存储了次设备号的个数,通过dev中记录的次设备号的起始号和个数值,便知道了次设备号的范围。

从上面的描述可以看出,cdev结构体的一个实例确实是把设备需要的最重要的信息描述完了。而调用函数cdev_init()就是将其实例与结构体 struct file_operations的实例绑定起来,结构体 struct file_operations的实例中存储了设备的底层操作函数,比如了read、write、open这些函数。

第三步是调用函数cdev_add()将描述字符设备的核心结构体cdev的实例和设备号绑定起来

第一步中已经获取到了可用的主设备号和次设备号,这里就需要将描述字符设备的核心结构体cdev的实例与第一步中获取到的主设备号和次设备号信息填充上。
示例代码如下:

static dev_t dev;

static struct cdev my_cdev; // 定义字符设备结构体

static struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .read = my_read,
    .write = my_write,
    .release = my_close,
};

// 向系统申请可用的主设备号和次设备号
ret = alloc_chrdev_region(&dev, 0, 3, "my_device");

/* 核心结构体cdev和结构体file_operations的绑定 */
cdev_init(&my_cdev, &my_fops);

/* 核心结构体cdev中写入设备号以及次设备号的个数 */
cdev_add(&my_cdev, dev, 3);

....

在第二步中已经给出了核心结构体cdev的简化版:

struct cdev {
    struct kobject kobj;                // 内核对象,用于 sysfs 集成
    const struct file_operations *ops; // 设备支持的操作集
    struct list_head list;             // 用于链接到内核的 cdev 列表
    dev_t dev;                         // 设备号
    unsigned int count;                // 管理的次设备号范围
};

可见,这里面的第4个成员dev和第5个成员count便是设备号信息。所以这里需要调用函数cdev_add()向这个设备核心结构体写入设备号信息。

强势插入-前三步可用函数register_chrdev()打包完成

关于函数函数register_chrdev()的详细介绍,见博文 https://blog.csdn.net/wenhao_ir/article/details/144973219

第四步调用函数class_create()创建设备类

设备类是内核设备模型中的一个抽象,用于将功能相似的设备分组,以便统一管理这些设备的属性和行为。
关于设备类的详细介绍请参见下面这篇博文:
以一个实际例子来学习Linux驱动程序开发之“设备类”的相关知识【利用设备类实现对同一设备类下的多个LED灯实现点亮或关闭】

把上面这篇博文从头到尾读一遍,就会对设备类的概念和使用有详细的了解了,这里就不开展了,这里相关的代码为:

//创建设备类
my_class = class_create(THIS_MODULE, "my_class");

第五步是调用函数device_create()创建设备文件

一个设备实例对就一个具体的设备文件,所以如果同一个驱动程序实例对应多个设备,便需要调用多次函数device_create()。

示例代码如下:


dev_t dev;
int ret = alloc_chrdev_region(&dev, 0, 3, "my_device"); // 分配3个次设备号
if (ret < 0) {
    printk(KERN_ERR "Failed to allocate device number\n");
    return ret;
}

// 创建 cdev 并注册
cdev_init(&my_cdev, &my_fops);
ret = cdev_add(&my_cdev, dev, 3);
if (ret < 0) {
    printk(KERN_ERR "Failed to add cdev\n");
    unregister_chrdev_region(dev, 3);
    return ret;
}

//创建设备类
my_class = class_create(THIS_MODULE, "my_class");

// 创建设备文件(多个次设备号,调用多次 device_create)
for (int i = 0; i < 3; i++) {
    // 为每个次设备号创建设备文件
    device_create(my_class, NULL, MKDEV(MAJOR(dev), MINOR(dev) + i), NULL, "my_device%d", i);
}

函数device_create()的介绍如下:

函数原型

struct device *device_create(struct class *cls, struct device *parent, dev_t devt,
                             const struct attribute_group **groups, const char *devname);

参数解析:

  1. my_class (struct class *):

  2. NULL (struct device *):

    • 这个参数指定父设备。如果设备没有父设备,可以传递 NULL。大多数设备驱动程序的设备对象不需要父设备,所以这里通常会传递 NULL
    • 父设备用于设备层次结构的管理,例如树形结构的设备关联。如果不需要设备层次结构,可以将其设为 NULL
  3. dev (dev_t):

    • 这是设备的主设备号和次设备号组成的 dev_t 类型值,表示具体的设备。
    • dev_t 是一个 32 位的值,其中低 20 位是次设备号,高 12 位是主设备号。例如,如果你用 alloc_chrdev_region() 分配了一个设备号,dev 就是该设备号。
  4. **NULL (const struct attribute_group groups):

    • 这个参数是一个指向 attribute_group 结构体指针的指针,代表了设备的 sysfs 属性。如果不需要额外的 sysfs 属性,可以传递 NULL
    • 在许多情况下,你可能不需要为设备创建额外的 sysfs 属性,尤其是简单的字符设备。因此,可以把它设为 NULL
  5. "my_device" (const char *):

    • 这是设备文件的名称,即最终会出现在 /dev/ 目录下的设备文件名。在这个例子中,设备文件的名称是 "my_device%d",因此设备文件名为 /dev/my_device0/dev/my_device1/dev/my_device2

返回值:

  • device_create() 返回一个指向创建的 device 结构体的指针,该结构体表示内核中创建设备对象的元数据。
  • 如果函数调用失败,它返回 NULL

当代码运行到这里后,相关的系统目录和文件存在情况如下:
在系统的/sys/class/目录下存在目录my_class,这个目录是运行下面这行后产生的:

//创建设备类
my_class = class_create(THIS_MODULE, "my_class");

在系统的/dev/ 目录下有下面这些文件:

/dev/my_device0
/dev/my_device1
/dev/my_device2

上面这三个文件是由下面这段代码运行时生成的:

// 创建设备文件(多个次设备号,调用多次 device_create)
for (int i = 0; i < 3; i++) {
    // 为每个次设备号创建设备文件
    device_create(my_class, NULL, MKDEV(MAJOR(dev), MINOR(dev) + i), NULL, "my_device%d", i);
}

06-驱动程序编译好后通常是以模块的形式存在的,那么怎么加载这个模块?这个模块加载时从哪里执行?

请参看博文:
以一个实际例子来学习Linux驱动程序开发之“设备类”的相关知识【利用设备类实现对同一设备类下的多个LED灯实现点亮或关闭】 【搜索关键字“驱动模块加载代码”】

07-为什么驱动程序模块的C文件末尾要加上MODULE_LICENSE("GPL");

关于这个问题详细的介绍见我的另一篇博文
https://blog.csdn.net/wenhao_ir/article/details/144902881

08-驱动程序模块加载完成,完成设备实例化注册并创建了设备文件后,怎么调用设备或对设备进行操作?

设备文件的本质

  • 设备文件是文件系统中的一个节点,它本身并不包含任何代码或函数。
  • 它里面的核心信息是:
    • 主设备号:标识设备对应的驱动程序。
    • 次设备号:区分同一驱动程序下的不同设备实例。

这些信息存储在设备文件的元数据中,例如 /dev/my_device0 中存储的信息是存储主设备号 200 和次设备号 0【这里具体的设备号只是举个例子,实际上可能别的主设备号和次设备号】。


工作原理回顾

  1. 注册设备
    驱动程序通过 alloc_chrdev_region, cdev_init, cdev_add 等函数将设备号(主设备号+次设备号)和 file_operations 关联起来。例如:

    cdev_init(&my_cdev, &my_fops);
    ret = cdev_add(&my_cdev, dev, 3);
    

    这里的 my_fops 包含了底层操作函数(如 open, write 等)的指针。

  2. 设备文件与驱动的关联
    当用户访问设备文件 /dev/my_device0,会从设备文件中读到设备的主设备号和次设备号,然后内核会通过主设备号找到对应的驱动程序(struct cdev)。

    • 主设备号是查找驱动的关键,它通过内核的设备号表关联到注册的 struct cdev
    • 次设备号用于区分同一驱动中的具体设备实例。
  3. 调用操作函数
    一旦找到 struct cdev,内核通过其中的 file_operations 指针,调用用户请求的操作函数(如 write 调用 led_write)。


设备文件中不包含底层函数

设备文件只是一个路径标识+设备号,它本身并不存储代码或函数。底层操作函数的信息存储在内核中,与设备号相关联。


之前实验中的相关代码

IMX6ULL开发板基础实验:Framebuffer驱动程序的简单应用实例代码详细分析
其中有一句打开设备的代码为:

fd_fb = open("/dev/fb0", O_RDWR);

这里的open函数为系统的核心函数,它在运行时,首先从设备文件/dev/fb0中读到了设备的主设备号和次设备号,然后根据主设备号找到其对应的驱动程序描述结构体struct cdev,然后通过这个结构体的成员——file_operations 指针来调用设备的底层打开函数。并返回一个文件描述符(文件句柄),文件描述符是进程内的句柄,用于操作已经打开的文件或设备。进程中的其它函数可使用文件描述符来直接引用这个已经打开的打开设备文件,比如下面这个代码:

ioctl(fd_fb, FBIOGET_VSCREENINFO, &var);