我的第一个字符驱动:基于Linux2.4之前版本的古法编程

目录

一、先明确核心前提:Linux2.4及更早的极简设备模型

二、Linux2.4内核:cdev的前身------char_device_struct

[1. 2.4内核字符设备核心数据结构](#1. 2.4内核字符设备核心数据结构)

关键解读

[2. 2.4内核唯一注册函数:register_chrdev](#2. 2.4内核唯一注册函数:register_chrdev)

核心作用

[3. 2.4内核必备操作:mknod手动创建设备文件](#3. 2.4内核必备操作:mknod手动创建设备文件)

mknod的本质

三、2.4内核古法驱动完整运行流程

四、内核演进:2.6内核重构,char_device_struct→cdev

重点强调:二者的关系

[1. 2.6内核核心结构体:struct cdev](#1. 2.6内核核心结构体:struct cdev)

对比2.4的char_device_struct,核心变化

[2. 2.6内核驱动注册流程(拆分式,替代register_chrdev)](#2. 2.6内核驱动注册流程(拆分式,替代register_chrdev))

五、新手必避的3大核心误区

误区1:register_chrdev会自动生成/dev设备文件

误区2:cdev是char_device_struct的子类/兼容扩展

误区3:Linux早期三类设备共用一个全局链表

六、总结:一句话理清cdev的前世今生

七、古法字符驱动示例

(1)代码

[(2)struct file中的成员private_data的作用](#(2)struct file中的成员private_data的作用)

(3)register_chrdev拆解成三步法后的示例代码


刚接触Linux驱动开发的新手,大概率会被cdev、设备号、mknod、自动创建设备节点这些概念绕晕,更搞不懂现代驱动和早期古法驱动的区别。其实想要摸透Linux字符设备的底层逻辑,必须回到最原始的内核架构,抛开设备树、platform总线、udev这些后期封装的复杂机制,回归设备管理的本质。

这篇文章就从零讲起,严格遵循Linux内核真实演进历史,拆解早期字符设备核心架构,结合源码讲清char_device_structcdev的关系,帮新手彻底理清古法驱动的工作流程,避开所有概念误区。

一、先明确核心前提:Linux2.4及更早的极简设备模型

Linux 2.4内核及之前,没有总线架构、没有设备树、没有sysfs,更没有复杂的设备模型,内核的设备管理逻辑极其朴素直接:

内核将设备分为三大类,每类设备独立维护一套管理结构,三套体系完全隔离、互不干扰,没有统一的全局总链表:

  1. 字符设备(char dev):按字节流串行读写,如串口、键盘、自定义字符驱动,是驱动入门最常用的类型

  2. 块设备(block dev):按数据块批量读写,如硬盘、闪存

  3. 网络设备(net dev):专注网络数据包收发,独立于前两类设备

这个时期,设备号(主设备号+次设备号) 已经是核心标识:主设备号对应内核中的具体驱动,次设备号区分同一驱动下的不同设备,是用户态和内核态通信的唯一桥梁。

工程师的工作也很纯粹:定义设备操作函数,绑定设备号,将驱动信息注册到内核对应设备的管理结构中,再通过mknod手动创建用户态入口,整个流程没有任何多余封装。

二、Linux2.4内核:cdev的前身------char_device_struct

这里必须纠正一个极易混淆的点:Linux 2.4及更早内核中,根本没有 struct cdev 这个结构体!

我们现在熟知的cdev,在2.4内核里有功能完全一致的前身,只是名字不同 ,它就是struct char_device_struct------二者是彻底的替代关系 ,不是兼容、继承、包含关系,2.6内核重构时直接废弃了旧结构,重新命名为cdev,旧结构随之彻底消失。

1. 2.4内核字符设备核心数据结构

2.4内核用全局指针数组管理字符设备,数组下标就是主设备号,这是字符设备的核心花名册,源码定义如下:

cpp 复制代码
// Linux 2.4.x 内核源码:include/linux/fs.h
// 字符设备核心管理结构 → cdev的前身,2.4独有
struct char_device_struct {
    struct char_device_struct *next;  // 链表指针,处理同一主设备号多次设备号场景
    unsigned int major;               // 主设备号,核心标识
    unsigned int baseminor;            // 次设备号起始值
    int minorct;                      // 次设备号数量
    const char *name;                 // 设备名称
    struct file_operations *fops;     // 驱动灵魂:设备操作函数集合
};

// 字符设备全局管理数组,最大支持255个主设备号(0-254)
#define MAX_CHRDEV 255
static struct char_device_struct *chrdevs[MAX_CHRDEV];

关键解读

  • chrdevs[]字符设备专属数组,和块设备、网络设备的管理结构完全隔离,各司其职

  • 数组下标=主设备号,内核通过主设备号直接索引,查找效率极高

  • struct file_operations是驱动的核心,存放openreadwriteclose等用户态调用的底层实现函数

  • 这个结构承担了后续cdev的所有核心功能,只是命名不同,2.6内核重构后直接更名为cdev

2. 2.4内核唯一注册函数:register_chrdev

早期古法驱动,只需要调用这一个函数,就能完成字符设备的内核注册(它做了现代的3个函数的事情---设备号请求、与fops的绑定、注册链入chrdevs链表),源码简化版如下:

cpp 复制代码
// Linux 2.4.x 内核源码:fs/char_dev.c
int register_chrdev(unsigned int major, const char *name, struct file_operations *fops)
{
    struct char_device_struct *cd;

    // 1. 分配字符设备管理结构内存
    cd = kmalloc(sizeof(*cd), GFP_KERNEL);
    if (!cd) 
        return -ENOMEM;

    // 2. 填充核心信息:绑定主设备号、设备名、操作函数集
    cd->major = major;
    cd->name = name;
    cd->fops = fops;
    cd->next = NULL;

    // 3. 将结构挂入chrdevs数组对应主设备号下标位置
    if (chrdevs[major] == NULL) {
        chrdevs[major] = cd;
    } else {
        // 同一主设备号多个设备,用链表挂载扩展
        cd->next = chrdevs[major];
        chrdevs[major] = cd;
    }

    return 0;
}

核心作用

这个函数只做内核态的注册工作 :将自定义的file_operations和设备号绑定,存入内核字符设备数组,绝对不会创建/dev目录下的设备文件,这是新手最容易踩的误区。

3. 2.4内核必备操作:mknod手动创建设备文件

内核注册完驱动,只是在内核里"登记了信息",用户态进程完全无法访问,必须通过mknod命令,手动在根文件系统的/dev目录下创建设备文件,这是用户态访问内核驱动的唯一入口。

命令格式:

cpp 复制代码
# mknod /dev/设备文件名 设备类型(c=字符设备/b=块设备) 主设备号 次设备号
mknod /dev/my_char_drv c 120 0

mknod的本质

  • /dev下创建一个特殊设备文件(inode),不占用实际存储空间

  • 主设备号+次设备号写入该inode的属性中,建立用户态和内核态的关联

  • 应用程序通过这个设备文件,找到内核中对应的驱动

三、2.4内核古法驱动完整运行流程

把注册驱动和mknod结合,就是早期字符驱动最核心的工作链路,逻辑极其清晰:

  1. 驱动编写 :工程师实现file_operations中的open/read/write等函数

  2. 内核注册 :调用register_chrdev,将char_device_struct结构加入chrdevs数组

  3. 用户态入口 :执行mknod,在/dev创建设备文件,绑定对应设备号

  4. 应用调用 :用户进程执行open("/dev/my_char_drv", O_RDWR)

  5. 内核解析 :内核读取设备文件(刚刚mknod生成的文件)inode中的设备号,通过主设备号索引chrdevs数组,找到对应的char_device_struct

  6. 函数绑定 :获取绑定的file_operations,执行对应的open函数,为用户进程返回文件描述符fd

  7. 后续操作 :用户进程通过fd调用read/write,内核直接匹配执行file_operations中的对应函数

一句话总结:register_chrdev是在内核挂电话号码,mknod是在用户态立路牌,open是用户按路牌拨号,内核负责精准接通

四、内核演进:2.6内核重构,char_device_struct→cdev

Linux2.6内核对字符设备模型进行了彻底重构 ,核心目的是支持动态设备号、多设备扩展、统一设备模型,适配更复杂的硬件场景,这次重构直接废弃了 struct char_device_struct ,推出了全新的struct cdev结构体。

重点强调:二者的关系

不是兼容、不是继承、不是子类包含,而是彻底的替代!

2.4的char_device_struct被直接删除,2.6用cdev完全取代它的角色,核心功能不变,只是结构优化、命名简化,同时管理方式从数组变为链表。

1. 2.6内核核心结构体:struct cdev

cpp 复制代码
// Linux 2.6.x 内核源码:include/linux/cdev.h
// 2.6内核全新推出,取代char_device_struct
struct cdev {
    struct kobject kobj;               // 融入内核设备模型,支持后期自动化机制
    struct module *owner;              // 指向驱动模块,填THIS_MODULE
    const struct file_operations *ops; // 核心不变:设备操作函数集
    struct list_head list;             // 链表节点,全局cdev链表管理
    dev_t dev;                         // 设备号(主+次合并封装)
    unsigned int count;                 // 次设备号数量
};

对比2.4的char_device_struct,核心变化

  1. 命名简化 :冗长的char_device_struct直接更名为cdev,更简洁

  2. 管理方式升级 :从全局数组chrdevs改为全局链表管理,支持更多设备号、动态扩展

  3. 融入设备模型 :新增kobject成员,为后续class_create/device_create自动创建设备节点打下基础

  4. 设备号封装 :用dev_t类型统一封装主、次设备号,扩展性更强

  5. 旧结构彻底消失 :2.6内核源码中不再有char_device_struct,完全被cdev取代

2. 2.6内核驱动注册流程(拆分式,替代register_chrdev)

2.6内核不再推荐单一的register_chrdev,拆分为多个函数,灵活性大幅提升:

cpp 复制代码
// 1. 申请设备号(静态指定/动态分配均可)
dev_t devno;
alloc_chrdev_region(&devno, 0, 1, "my_cdev_drv");

// 2. 分配并初始化cdev结构,绑定file_operations
struct cdev *cdev = cdev_alloc();
cdev_init(cdev, &fops);
cdev->owner = THIS_MODULE;

// 3. 将cdev加入内核全局cdev链表,完成注册
cdev_add(cdev, devno, 1);

虽然注册流程变了,但底层逻辑和2.4内核完全一致 :依旧是绑定设备号和操作函数,只是管理结构从char_device_struct换成了cdev

五、新手必避的3大核心误区

误区1:register_chrdev会自动生成/dev设备文件

正解 :无论2.4还是2.6内核,register_chrdev只负责内核态注册,绝不会创建用户态设备文件 。2.4必须手动mknod,2.6后期是udev/mdev工具自动执行mknod,并非驱动本身创建。

误区2:cdev是char_device_struct的子类/兼容扩展

正解 :二者是替代关系 ,2.6内核直接废弃了char_device_struct,重新设计并命名为cdev,旧结构彻底从源码中消失,没有继承、包含、兼容关系。

误区3:Linux早期三类设备共用一个全局链表

正解 :2.4及更早内核中,字符、块、网络设备三套管理体系完全独立,字符用chrdevs数组、块用blkdevs数组、网络用独立链表,没有统一的全局总链表。

六、总结:一句话理清cdev的前世今生

  1. Linux 2.4及更早 :字符设备核心管理结构是struct char_device_struct,通过chrdevs数组管理,必须手动mknod创建设备文件,是古法驱动的核心;

  2. Linux 2.6及以后 :内核重构字符设备模型,彻底删除 char_device_struct ,推出struct cdev取而代之,管理方式升级为链表,融入设备模型,支持自动化设备节点创建;

  3. 本质不变 :无论是char_device_struct还是cdev,核心作用都是绑定设备号和file_operations,作为内核管理字符设备的载体,用户态始终通过设备号+设备文件访问内核驱动。

对于新手来说,先吃透2.4内核的古法驱动逻辑,看懂char_device_structcdev的演进本质,再去学习现代驱动的复杂机制,就会发现所有后期封装都是为了简化手动操作,底层逻辑一脉相承,再也不会被繁杂的概念绕晕。

后续我会基于2.4内核,写一个极简的可运行古法字符驱动,手把手实现注册、mknod、应用层调用,让新手彻底落地这套核心逻辑。

七、古法字符驱动示例

(1)代码

字符驱动代码:

cpp 复制代码
#include<linux/init.h>
#include<linux/module.h>
#include<linux/kernel.h>


#include <linux/fs.h>


//我的第一个字符驱动---Linux内核2.4版本及之前的驱动开发方法


static ssize_t my_chardev_read(struct file *, char __user *, size_t, loff_t *)
{
    printk("read驱动函数调用成功\n");
    return 0;
}

static ssize_t my_chardev_write(struct file *, const char __user *, size_t, loff_t *)
{
    printk("write驱动函数调用成功\n");
    return 0;
}

static int my_chardev_open(struct inode *, struct file *)
{
    printk("open驱动函数调用成功\n");
    return 0;
}

static int my_chardev_release(struct inode *, struct file *)
{
    printk("release驱动函数调用成功\n");
    return 0;
}


// //文件操作集合
struct file_operations my_char_fops=
{
    .owner	 = THIS_MODULE,						
	.open	 = my_chardev_open,					
	.release = my_chardev_release,					
	.read	 = my_chardev_read,					
	.write	 = my_chardev_write	
};


//模块加载函数
static int __init my_chardev_init(void)
{
    printk("my_chardev模块加载中\n");


    register_chrdev(200,"my_chardev",&my_char_fops);


    return 0;
}

//模块卸载函数
static void __exit my_chardev_exit(void)
{
    printk("my_chardev模块卸载中\n");

    unregister_chrdev(200,"my_chardev");

}



module_init(my_chardev_init);
module_exit(my_chardev_exit);
MODULE_LICENSE("GPL");

用户态代码:

cpp 复制代码
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main(void)
{
    int fd;
    char buf[100] = {0};
    const char *test_str = "Hello, my_chardev!";

    // 1. 打开设备节点
    fd = open("/dev/my_chardev", O_RDWR);
    if (fd < 0) {
        perror("open failed");
        return -1;
    }
    printf("open /dev/my_chardev success\n");

    // 2. 写数据(触发驱动 write)
    ssize_t w_ret = write(fd, test_str, strlen(test_str));
    if (w_ret < 0) {
        perror("write failed");
        close(fd);
        return -1;
    }
    printf("write %zd bytes\n", w_ret);

    // 3. 读数据(触发驱动 read)
    ssize_t r_ret = read(fd, buf, sizeof(buf)-1);
    if (r_ret < 0) {
        perror("read failed");
        close(fd);
        return -1;
    }
    printf("read %zd bytes: %s\n", r_ret, buf);

    // 4. 关闭设备(触发驱动 release)
    close(fd);
    printf("close /dev/my_chardev success\n");

    return 0;
}

Makefile:

cpp 复制代码
# 内核源码路径
KERNELDIR := /home/hmy/linux-mini/linux-6.8

# 当前目录
CURRENT_PATH := $(shell pwd)

# 驱动模块文件名
obj-m := main.o

# 交叉编译器
CROSS_COMPILE := arm-linux-gnueabihf-
CC := $(CROSS_COMPILE)gcc

# 默认编译:驱动 + 用户测试程序
all: module test

# 编译内核驱动模块
module:
	make -C $(KERNELDIR) M=$(CURRENT_PATH) modules ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE)

# 编译用户态测试程序【静态编译,解决 not found 问题】
test:
	$(CC) test.c -o test -static

# 清理
clean:
	make -C $(KERNELDIR) M=$(CURRENT_PATH) clean ARCH=arm
	rm -f test

(2)struct file中的成员private_data的作用

一个驱动可能对应多个设备,比如USB驱动是一套代码逻辑,但是由于本主机上有5个USB接口,那么就会生成五个struct my_USB_dev,每一个struct my_USB_dev都可能会实现一个缓冲区。但是你上次调用read的时候并没有指定到底是访问哪一个struct my_USB_dev的缓冲区,仅仅只是指定了访问的文件描述符fd。

于是我们必须将struct file与具体设备缓冲区进行关联,所以在file结构体中加入了一个void*private_data指针,标准工业写法要求你在open的时候必须设置private_data关联的设备结构体。从而以后read时直接可以从private_data中访问具体设备了。

private_data的设计在内核很多地方都有体现,大家有兴趣的可以自行查询资料。

(3)register_chrdev拆解成三步法后的示例代码

cpp 复制代码
#include<linux/kernel.h>
#include<linux/init.h>
#include<linux/module.h>

#include<linux/fs.h>
#include<linux/cdev.h>
#include<linux/device.h>


#define my_char_dev_name "my_char_dev"

//我自己用来管理的,方便看一点,不是一定必要的
struct my_char_dev
{
    dev_t device_id;
    int minor;
    int major;

    struct cdev my_char_cdev;       //在Linux2.6之后,用这个替代了以前的,与file_operations进行绑定
};

struct my_char_dev my_dev=
{
    .device_id=0,
    .major=0,
    .minor=0,
};


static ssize_t my_chardev_read(struct file *filep, char __user *, size_t, loff_t *)
{
    //提取出private_data,此后你就可以在read中分辨这个file对应的设备结构体了(每个设备都有一个设备结构体)
    struct my_char_dev* mychardev=(struct my_char_dev*)filep->private_data;

    printk("my_chardev_read\n");
    return 0;
}
static ssize_t my_chardev_write(struct file *filep, const char __user *, size_t, loff_t *)
{
    struct my_char_dev* mychardev=(struct my_char_dev*)filep->private_data;

    printk("my_chardev_write\n");
    return 0;
}
static int my_chardev_open(struct inode *, struct file *filep)
{
    //实际上应该用inode去定位,但是我没学,所以我直接用struct file去绑定了
    filep->private_data=(void*)&my_dev;

    printk("my_chardev_open\n");
    return 0;
}
static int my_chardev_release(struct inode *, struct file *filep)
{
    filep->private_data=NULL;

    printk("my_chardev_release\n");
    return 0;
}



//文件操作集合
struct file_operations my_char_dev_fops=
{
    .owner=THIS_MODULE,
    .open=my_chardev_open,
    .release=my_chardev_release,
    .write=my_chardev_write,
    .read=my_chardev_read,

};


static int __init my_char_dev_init(void)
{
    printk("我的字符驱动正在加载\n");
    //register_chrdev的替代(现代写法,更加不浪费资源)
    //1.申请设备号
    printk("[1]申请设备号中....请稍等\n");
    int ret=0;
    if (my_dev.major) 
    {
        // 静态申请:主设备号已指定,用 MKDEV 生成 dev_t
        ret = register_chrdev_region(MKDEV(my_dev.major, 0), 1, my_char_dev_name);
    } 
    else 
    {
        // 动态申请:由内核分配设备号
        ret = alloc_chrdev_region(&my_dev.device_id, 0, 1, my_char_dev_name);
        my_dev.major=MAJOR(my_dev.device_id);
        my_dev.minor=MINOR(my_dev.device_id);
    }   
    //返回值为0才是正常的,非零的报错处理
    if(ret!=0)
    {
        printk("申请设备号出错!\n");
        return ret;
    }
    else
    {
        printk("申请到主设备号为:%d\n",my_dev.major);
        printk("申请到次设备号为:%d\n",my_dev.minor);
    }

    //2.初始化字符设备、并注册到内核的哈希表中
    printk("[2]cdev初始化中.....正在与fops进行绑定....\n");
    cdev_init(&my_dev.my_char_cdev,&my_char_dev_fops);
    my_dev.my_char_cdev.owner=THIS_MODULE;

    printk("[2]cdev注册.....正在链入内核字符设备哈希表中....\n");
    ret = cdev_add(&my_dev.my_char_cdev,my_dev.device_id,1);
    if (ret < 0) 
    {
        printk("cdev_add 失败!\n");
        unregister_chrdev_region(my_dev.device_id, 1);
        return ret;
    }

    return 0;
    }


static void __exit my_char_dev_exit(void)
{
    printk("我的字符驱动正在卸载\n");
    printk("[1]cdev注销中....正在从内核字符设备哈希表中删除....\n");
    cdev_del(&my_dev.my_char_cdev);
    printk("[2]设备号回收中....内核正在回收方便后续其他设备使用....\n");
    unregister_chrdev_region(my_dev.device_id,1);
}




module_init(my_char_dev_init);
module_exit(my_char_dev_exit);
MODULE_LICENSE("GPL");
cpp 复制代码
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

// 命令行输入:./test /dev/my_char_dev read
// 命令行输入:./test /dev/my_char_dev write

int main(int argc, char *argv[]) 
{
    if (argc != 3) 
    {
        printf("命令行格式错误,用法:%s <设备节点> <read/write>\n", argv[0]);
        return -1;
    }

    char buf[100] = {0};
    const char *test_str = "Hello, my_chardev!";

    // 打开设备
    int fd = open(argv[1], O_RDWR);
    if (fd < 0) 
    {
        perror("open failed");
        return -1;
    }
    printf("打开设备成功\n");

    // 写操作
    if (strcmp(argv[2], "write") == 0) 
    {
        ssize_t w_ret = write(fd, test_str, strlen(test_str));
        if (w_ret < 0) {
            perror("write failed");
            close(fd);
            return -1;
        }
        printf("写入成功:%zd 字节\n", w_ret);
    }

    // 读操作
    if (strcmp(argv[2], "read") == 0) 
    { 
        ssize_t r_ret = read(fd, buf, sizeof(buf)-1);
        if (r_ret < 0) {
            perror("read failed");
            close(fd);
            return -1;
        }
        printf("读取成功:%zd 字节:%s\n", r_ret, buf);
    }

    //关闭设备文件
    close(fd);
    printf("关闭设备成功\n");
    return 0;
}
相关推荐
拾贰_C2 小时前
【Ubuntu | Nvidia | installition0】Ubuntu安装Nvidia驱动
linux·运维·ubuntu
零K沁雪2 小时前
内核定时器
linux·内核
上天_去_做颗惺星 EVE_BLUE2 小时前
Linux Core Dump 测试操作手册
linux·c++·测试工具
拾贰_C2 小时前
【Ubuntu | 自动联网 | 网络问题】Ubuntu无法自动联网问题
linux·网络·ubuntu
0110编程之路2 小时前
Wine & Ubuntu 调用 Windows 应用
linux·windows·ubuntu
晨非辰2 小时前
Git版本控制速成:提交三板斧/日志透视/远程同步15分钟精通,掌握历史回溯与多人协作安全模型
linux·运维·服务器·c++·人工智能·git·后端
gdizcm2 小时前
linux判断文件类型的多种方法
linux·c++
云栖梦泽2 小时前
Linux内核与驱动:3.驱动模块传参,内核模块符号导出
linux·服务器·c++
程序猿编码2 小时前
网络数据包环形缓存捕获技术:原理、设计与实现(C/C++代码实现)
linux·c语言·网络·tcp/ip·缓存