目录
二、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手动创建设备文件)
四、内核演进:2.6内核重构,char_device_struct→cdev
[1. 2.6内核核心结构体:struct cdev](#1. 2.6内核核心结构体:struct cdev)
[2. 2.6内核驱动注册流程(拆分式,替代register_chrdev)](#2. 2.6内核驱动注册流程(拆分式,替代register_chrdev))
误区1:register_chrdev会自动生成/dev设备文件
误区2:cdev是char_device_struct的子类/兼容扩展
[(2)struct file中的成员private_data的作用](#(2)struct file中的成员private_data的作用)
(3)register_chrdev拆解成三步法后的示例代码
刚接触Linux驱动开发的新手,大概率会被cdev、设备号、mknod、自动创建设备节点这些概念绕晕,更搞不懂现代驱动和早期古法驱动的区别。其实想要摸透Linux字符设备的底层逻辑,必须回到最原始的内核架构,抛开设备树、platform总线、udev这些后期封装的复杂机制,回归设备管理的本质。
这篇文章就从零讲起,严格遵循Linux内核真实演进历史,拆解早期字符设备核心架构,结合源码讲清char_device_struct 和cdev的关系,帮新手彻底理清古法驱动的工作流程,避开所有概念误区。
一、先明确核心前提:Linux2.4及更早的极简设备模型
Linux 2.4内核及之前,没有总线架构、没有设备树、没有sysfs,更没有复杂的设备模型,内核的设备管理逻辑极其朴素直接:
内核将设备分为三大类,每类设备独立维护一套管理结构,三套体系完全隔离、互不干扰,没有统一的全局总链表:
字符设备(char dev):按字节流串行读写,如串口、键盘、自定义字符驱动,是驱动入门最常用的类型
块设备(block dev):按数据块批量读写,如硬盘、闪存
网络设备(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是驱动的核心,存放open、read、write、close等用户态调用的底层实现函数这个结构承担了后续
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结合,就是早期字符驱动最核心的工作链路,逻辑极其清晰:
驱动编写 :工程师实现
file_operations中的open/read/write等函数内核注册 :调用
register_chrdev,将char_device_struct结构加入chrdevs数组用户态入口 :执行
mknod,在/dev创建设备文件,绑定对应设备号应用调用 :用户进程执行
open("/dev/my_char_drv", O_RDWR)内核解析 :内核读取设备文件(刚刚mknod生成的文件)inode中的设备号,通过主设备号索引
chrdevs数组,找到对应的char_device_struct函数绑定 :获取绑定的
file_operations,执行对应的open函数,为用户进程返回文件描述符fd后续操作 :用户进程通过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,核心变化
命名简化 :冗长的
char_device_struct直接更名为cdev,更简洁管理方式升级 :从全局数组
chrdevs改为全局链表管理,支持更多设备号、动态扩展融入设备模型 :新增
kobject成员,为后续class_create/device_create自动创建设备节点打下基础设备号封装 :用
dev_t类型统一封装主、次设备号,扩展性更强旧结构彻底消失 :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的前世今生
Linux 2.4及更早 :字符设备核心管理结构是
struct char_device_struct,通过chrdevs数组管理,必须手动mknod创建设备文件,是古法驱动的核心;Linux 2.6及以后 :内核重构字符设备模型,彻底删除
char_device_struct,推出struct cdev取而代之,管理方式升级为链表,融入设备模型,支持自动化设备节点创建;本质不变 :无论是
char_device_struct还是cdev,核心作用都是绑定设备号和file_operations,作为内核管理字符设备的载体,用户态始终通过设备号+设备文件访问内核驱动。
对于新手来说,先吃透2.4内核的古法驱动逻辑,看懂char_device_struct到cdev的演进本质,再去学习现代驱动的复杂机制,就会发现所有后期封装都是为了简化手动操作,底层逻辑一脉相承,再也不会被繁杂的概念绕晕。
后续我会基于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;
}