Linux字符设备驱动详解:设备号、cdev与节点创建

发布时间:2026/10/22 8:14:30
Linux字符设备驱动详解:设备号、cdev与节点创建 做内核驱动的前两年我被字符设备这块结结实实阴过一回。模块编译好了insmod也成功dmesg里打印了一堆初始化信息可应用层拿着设备路径来open就是报ENOENT。一开始我以为是路径拼错了反复检查权限最后才发现问题出在设备号上——我压根没把设备号和字符设备正确挂钩/dev下面自然没有对应的节点。后来老老实实把设备号注册进内核cdev_add挂上去节点一建open立刻就通了。回头看这其实是Linux字符设备驱动最基础的一环但越基础的东西越容易埋坑。这篇东西我会把设备号的底层逻辑、注册方式、cdev生命周期、节点创建、调试手段完整捋一遍适合刚接触内核模块的新手也适合写了一段时间驱动但对主次设备号仍然有点含糊的朋友。1. 先搞清楚设备号是什么一个被当成数字的“内核对接口”1.1 dev_t这个32位整数存的不是你以为的那个数Linux里用dev_t类型保存设备号。它本质是一个32位无符号整数但在约定俗成下被拆成两段高位是主设备号低位是次设备号。大多数架构上主设备号占12位次设备号占20位所以取值范围分别是0~4095和0~1048575。内核提供了一组宏来操作它#include linux/kdev_t.h dev_t dev MKDEV(240, 1); // 构造设备号主240次1 unsigned int major MAJOR(dev); // 取主设备号240 unsigned int minor MINOR(dev); // 取次设备号1主设备号标识设备对应的驱动类型。系统里同一类设备共享同一个主设备号比如磁盘、串口、随机数设备各自有独立的主设备号。次设备号标识同一驱动下的具体设备实例。你插两块同型号串口卡驱动大概率只需要一个主设备号但每块卡上的每个串口都需要不同的次设备号来区分。理解这一点现实中很多困惑就能解开。比如有人问“/dev/ttyS0和/dev/ttyS1为什么不是一个设备”它们的主设备号相同次设备号不同。又有人问“为什么mknod的时候必须指定两个数字”因为内核只能靠这两个数字定位到具体驱动里的具体设备实例。1.2 用户态open到内核驱动的查找路径设备号不是给用户看的它是给内核的寻址凭证。当应用层执行open(/dev/my_demo, O_RDWR)时流程大致是这样系统调用进入VFSVFS从dentry和inode里拿到该节点对应的dev_t接着调用chrdev_open内核拿着这个设备号去cdev_map全局映射表里查找找到之后把cdev对应的file_operations挂到struct file上最后才调用驱动里的open函数。这个过程很像打电话。主设备号是区号次设备号是分机号内核拿着完整号码查通讯录找到对应的人驱动函数然后接通。用户态根本不关心电话号码本身怎么分配它只关心拨出去能不能通。所以应用层open失败时第一反应不该是查代码逻辑而是确认内核侧到底有没有这个“电话号码”。1.3 一个经典误区设备号不是设备的属性是节点文件的属性我见过不少人把设备号和设备本身强绑定认为“这个设备天生就是这个号”。实际上设备号存放在设备节点文件的inode里你完全可以把同一个设备号做成两个路径完全不同的设备节点打开它们都会进入同一个驱动实例。反过来同一块硬件你也可以用两个不同的设备号注册两次让用户态看到两个完全不同的“设备”。这也解释了为什么rmmod卸载驱动后/dev下的节点文件还在但一open就报错。节点文件是文件系统层面的人造物设备号对应的内核实体被销毁后剩下的节点文件只是一个没有接线端的空壳子。很多新手在这块绕不过弯总以为是文件被删了其实文件还在只是内核侧找不到对应的cdev了。2. 注册设备号的两条路线静态指定和动态分配怎么选设备号不能直接拿来就用必须先向内核注册让内核知道这个区间你占了。注册接口有两个思路完全不同。2.1 register_chrdev_region手工指定适合特殊场景#include linux/fs.h int register_chrdev_region(dev_t first, unsigned int count, const char *name);first是起始设备号count是连续分配的次设备号个数name是这个设备区间在内核里的名字。这个接口的逻辑是“我已经想好门牌号了你帮我确认一下能不能用能用就钉上去”。典型场景是你做的设备有行业规范或内核文档里预留了固定主设备号或者你要对接旧版用户态程序用户态程序写死了/dev下节点的主次设备号。用这个接口最怕撞号。你指定一个已经被其他驱动占用的主设备号注册会失败返回负的错误码常见的是-EBUSY。内核在fs/char_dev.c里维护着一份已分配区间的链表register_chrdev_region会检查新区间和已有区间是否冲突重叠一部分都会拒绝。2.2 alloc_chrdev_region让内核帮我挑号推荐做法int alloc_chrdev_region(dev_t *dev, unsigned int baseminor, unsigned int count, const char *name);这个接口更有意思你把一个dev_t指针传进去但不告诉它主设备号是多少只告诉它次设备号的起点baseminor和数量count。内核会从未占用的主设备号里挑一个连同你指定的次设备号起点一起填到*dev里返回。动态分配的含义就是让内核做裁判不要自己拍脑袋。内核选出来的主设备号一定没有被占用你说baseminor0, count1那返回的dev就是某个空闲主设备号下的次设备0。模块加载后用MAJOR(dev)把实际主设备号打出来看一眼就行。这样加载几次都不会跟系统里现存的其他驱动打架。2.3 两个接口的对比与“为什么我推荐动态”对比项register_chrdev_regionalloc_chrdev_region是否指定主设备号指定不指定由内核分配冲突风险高需要自己维护号段表低内核保证不冲突适用场景硬件规范固定、兼容历史设备绝大多数普通驱动返回值错误码错误码设备号通过参数返回日常写驱动能用动态就别用静态。有些教程为了打印出来的设备号好看喜欢自己硬编码一个主设备号比如240觉得这样好记。问题是240可能已经被你机器上某个摄像头驱动占用了你写死它两台机器表现就不一样。动态分配看起来每次加载主设备号可能不同但它永远不会背叛你。你只需要在加载后读一下dmesg或者/proc/devices就能知道当前用的是哪个号。这里还要提一嘴很老的register_chrdev接口。它和register_chrdev_region虽然名字像但完全是两代东西。register_chrdev需要你直接传主设备号内部会把该主设备号下0~255全部次设备号一次性占用再内部创建一个cdev绑定fops。这种粗暴做法导致一个主设备号一次只能被一个驱动使用次设备号浪费严重现代驱动里基本不推荐。你读老代码时看到register_chrdev(0, name, fops)这种用法知道它是旧时代的遗迹就行别在新项目里模仿。3. cdev是把设备号接到file_operations的接线员设备号分配好了相当于你有了一个门牌号但门牌号本身不会干活。要让open、read、write这些系统调用真正进入你的驱动代码你还需要一个cdev结构体它是字符设备在内核里的实体负责把设备号和file_operations关联起来。3.1 cdev生命周期四步走#include linux/cdev.h 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_release, }; static int __init my_init(void) { dev_t dev_num; int ret; // 第一步分配设备号 ret alloc_chrdev_region(dev_num, 0, 1, my_demo); if (ret 0) { pr_err(alloc_chrdev_region failed: %d\n, ret); return ret; } pr_info(my_demo: major%d minor%d\n, MAJOR(dev_num), MINOR(dev_num)); // 第二步初始化cdev并绑定fops cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; // 第三步把cdev加到内核 ret cdev_add(my_cdev, dev_num, 1); if (ret 0) { unregister_chrdev_region(dev_num, 1); pr_err(cdev_add failed: %d\n, ret); return ret; } return 0; } static void __exit my_exit(void) { // 第四步卸载时先删cdev再释放设备号 cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); pr_info(my_demo: removed\n); }这段代码是标准流程顺序不能乱先有设备号再有cdev最后把两者绑定后加入内核。cdev_add的第三个参数是count表示这个cdev覆盖几个次设备号。如果你分配设备号时申请了4个次设备号这里count就要传4否则剩下3个次设备号虽然在内核里做了登记却没有对应的cdev实体打开会失败。3.2 顺序为什么不能乱cdev_add到底是干什么的cdev_add做的事情是把你的cdev挂进内核的cdev_map映射表。映射表以设备号为key指向cdev。只有完成这一步chrdev_open才可能通过设备号找到你的file_operations。所以反复强调的依赖顺序是alloc_chrdev_region/register_chrdev_region → cdev_init → cdev_add → 创建设备节点有些驱动在cdev_add之前就调用device_create去做节点结果就是节点文件存在但打开就报ENODEV。还有些驱动忘掉cdev_add模块加载成功但所有接口都像打在棉花上。调试这种问题最好的办法就是加载模块后立刻cat /proc/devices看你的设备名在不在列表里。不在说明设备号没注册成功或cdev_add没执行到。3.3 cdev_add失败常见原因cdev_add返回负值多半是设备号区间冲突。比如你已经用register_chrdev_region占用了主设备号240的次设备0~3再用另一个cdev_add去注册主设备号240次设备2就会失败。这说明内核不只检查设备号区间是否有人用还检查cdev层是否重叠。另一个容易犯的错是count参数比分配的区间大。你只分配了1个次设备号cdev_add却传了个16内核校验时可能直接拒绝或者出现部分次设备号映射异常。实践里我给新手的建议是把设备和cdev相关的注册代码拆成独立函数init里按顺序调用每个函数return前都检查错误并做好回滚。一旦cdev_add失败别忘了释放前面已分配的设备号。C语言没有RAII这里全靠自觉漏掉一个unregister设备号就永久泄漏了。4. 从insmod到/dev下有节点三种建节点方式与背后的uevent机制设备号注册好了cdev也挂上去了可你的程序还是找不到设备因为/dev下什么都没有。用户态根本不知道设备号是多少它只知道路径。因此必须把设备号和路径关联起来也就是创建设备节点。4.1 手工mknod最原始也最直观的办法# 假设 dmesg 里看到 major240 minor0 mknod /dev/my_demo c 240 0 chmod 666 /dev/my_demomknod的第二个参数c表示字符设备后面两个数字分别是主设备号和次设备号。这种方式的优点是透明你能亲眼看到设备号是怎么跟路径绑定的。缺点是依赖人工每次驱动加载后如果主设备号变了你得删掉旧节点重新建。动态分配主设备号的驱动如果靠手敲mknod体验会很糟。有一点必须注意即使驱动卸载了mknod创建的节点文件也不会自动消失。下次驱动重新加载如果用了相同的主次设备号旧节点可以继续用如果设备号变了旧节点打开仍然失败必须新建。所以手工方式只适合固定主设备号或纯学习场景。4.2 device_create自动建节点内核帮你把uevent广播出去更省心的做法是在驱动里调用device_create。前提是你要先创建自己的struct class这个class会出现在/sys/class下设备节点则通过uevent机制或devtmpfs出现在/dev下。#include linux/device.h static struct class *my_class; static int __init my_init(void) { ... // 在cdev_add成功之后做 my_class class_create(my_demo); if (IS_ERR(my_class)) { ret PTR_ERR(my_class); goto err_class; } device_create(my_class, NULL, dev_num, NULL, my_demo%d, 0); return 0; err_class: cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return ret; }class_create的签名在不同内核版本上有点变化有的版本只传name有的版本要传owner和name以你当前内核头文件为准。device_create的核心作用是把设备号告诉内核设备模型内核随即在devtmpfs中生成/dev/my_demo0节点或者向用户空间的udev发送uevent由udev根据规则创建。现代发行版基本都开着devtmpfs所以驱动里调过device_create之后/dev下立刻能看到节点不需要你再动一根手指。4.3 只做cdev_add不做device_create会怎样这里要揭露一个很多教程没讲透的点cdev_add本身不会触发设备节点创建它只影响内核内部的查找表。设备节点归设备模型管而cdev_add并没有把cdev加入设备模型。你可以不调用device_create只写cdev_add然后手动mknod完全能跑起来。反过来只创建class和device却不调cdev_add节点也能出现但open必然失败。所以驱动里最稳妥的组合拳是alloc_chrdev_region cdev_add class_create device_create。前面两个保证内核能找到驱动函数后面两个保证用户态能看到路径。四个都做齐了一个标准字符设备才算完整。5. 查看设备号和排错三板斧别让节点欺骗你5.1 cat /proc/devices 看的是“内核登记册”加载模块后第一件事就是执行cat /proc/devices。这个文件是内核动态生成的列出了所有已注册的字符设备Character devices和块设备Block devices每行是主设备号加设备名。如果你看到自己的设备名出现在列表里说明设备号注册成功cdev_add十有八九也成功了。如果列表里根本没有你的名字直接去查dmesg看是不是alloc/register那一步就失败了。有一个细节/proc/devices里显示的设备名是你注册设备号时传入的name不是你device_create里的名称。我在项目里见过有人打印出来“my_demo_class”而不是“my_demo”一时对不上号其实只要确认主设备号一致就行。5.2 对照节点主次号ls -l和cat /proc/devices配合使用ls -l /dev/my_demo0输出类似c 240 0 1 root root其中第一个数字240是主设备号第二个0是次设备号。拿这个主设备号去比对/proc/devices里的列表如果对不上说明mknod或device_create用了旧设备号。在动态分配场景下尤其常见驱动代码改了重新编译后主设备号变了但udev缓存了旧节点结果就是应用层拿着旧路径打开一个已经不存在的映射。如果你看到设备节点的文件类型是c但主设备号是一个从没见过的数字比如10别慌先确认你是不是用了miscdevice。杂项设备misc_register统一使用主设备号10次设备号动态分配这是内核特意留给小型杂项设备的便捷通道不属于你那套标准字符设备注册流程。5.3 错误码语义ENOENT、ENODEV、EBUSY不要混应用层open失败时报错码能透露内核侧线索ENOENT文件不存在/dev下没有这个节点。ENODEV节点在但内核cdev_map里找不到对应的cdev大概率cdev_add没成功或驱动已卸载。EBUSY注册设备号或cdev_add时区间被占用去dmesg看具体哪个函数报的。我调试过最长的一个问题就是每次insmod都成功dmesg也正常但open就报ENODEV。后来发现是cdev_add被包在条件编译里某次配置把那段代码裁掉了设备号分配了节点也建了唯独cdev没挂进去。所以说到底排错要沿着“设备号—cdev—节点”这条链逐个环节确认哪里断了补哪里。6. 设备号相关的几个经典坑主设备号溢出、次设备号越界、泄漏6.1 打印dev_t不能图省事dev_t在头文件里被定义为unsigned long或u32直接printf(%d, dev)打出来的数值完全是乱的它等于主设备号左移了20位再或上次设备号一个大整数看不出任何门道。正确姿势是拆开用MAJOR(dev)和MINOR(dev)分别打印。有些老代码会写printk(dev %d:%d\n, MAJOR(dev), MINOR(dev))这没问题但如果你直接把dev当int打迟早被它坑一次。还有个小陷阱MAJOR宏返回的值在极个别平台上可能出现高位符号扩展。保险起见打印时统一用%u或%x不要用%d。别小看这种打印问题设备号看起来不对排查方向就全歪了。6.2 count申请过大与register_chrdev的历史包袱申请设备号时count不是越大越好。count表示连续次设备号个数如果你申请了65536个内核会认为你把整个次设备号空间的一大半都占了。虽然20位次设备号上限足够大但滥用会让其他驱动无法分配。尤其不要混用老接口register_chrdev它一占就是256个次设备号只看不需要那么多却白白堵死了别人。更隐蔽的问题是在同一主设备号下重复注册区间。比如你先alloc_chrdev_region拿到主设备号240申请了次设备0~3然后又跑一次alloc_chrdev_region这次baseminor传了2请求再次落在同一个主设备号下内核会检测到区间重叠并返回-EBUSY。所以动态分配后要记住这组设备号不要反复去申请。6.3 设备号泄漏卸载驱动不还号写过一阵子驱动后你会发现内存泄漏大家都会警惕设备号泄漏反而没人当回事。unregister_chrdev_region忘记调用最直接的后果是/proc/devices里还留着你的设备名哪怕你已经rmmod了。最坑的是当你再次insmod时如果又用了动态分配内核会给你分配一个新的主设备号而旧的号一直占着不放日积月累主设备号被消耗殆尽。我在一个长期不重启的嵌入式设备上碰到过类似问题板子上加载过几十次不同版本的测试驱动每次都不释放设备号最后动态分配拿不到号了。查完原因就是有一版驱动在cdev_add失败的错误分支里漏掉了unregister_chrdev_region。从那以后我给自己定了个规矩凡是看到alloc_chrdev_region立刻在下方写好对应的unregister_chrdev_region无论中途走哪个分支卸载路径都得能执行到它。6.4 设备节点出现但设备还没就绪热插拔的时差问题驱动加载和设备节点创建在部分场景下有时差。如果你在device_create之后又做了一些耗时初始化比如请求固件、等待硬件复位这期间应用层去open节点可能因为驱动还没准备好而失败。处理方式是在open里检查设备状态未就绪就返回-EAGAIN让应用层稍后重试而不是在init里拖时间。这种问题在USB相关驱动上特别常见但标准字符设备驱动同样可能遇到。我自己写了一个小工具脚本加载驱动后自动轮询/proc/devices等设备名出现后再去执行用户态测试程序。这样能把“内核侧没准备好”和“应用侧写错逻辑”两类问题分开排查省下来的时间相当可观。写在最后的一点经验设备号这东西看着简单实际上它贯穿了整个字符设备驱动模型的脉络。我后来把设备号、cdev、设备节点三者的关系简化成一句话设备号是门牌cdev是房子节点是朝向街面的招牌。招牌可以随便挂门牌必须向内核登记房子里得有人住三样齐全用户敲门才有人应。写驱动的时候我会先在纸上把这四步写清楚分配设备号、初始化cdev、cdev_add、创建节点。每步检查返回值出错就回滚。整套流程熟练之后字符设备驱动里那些奇奇怪怪的打开失败问题基本一眼就能定位到是门牌登记错了还是房里没人。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询