字符设备驱动简介 - 从零开始理解内核驱动
前言:我们为什么要折腾这个
说实话,第一次接触驱动开发的时候,我真的很懵。看着那些稀奇古怪的函数名、数据结构,还有满屏的宏定义,完全不知道从哪里下手。这种感觉就像是你第一次走进一家全是自助餐的餐厅——菜品太多,反而不知道该从哪开始吃。
但后来我发现,驱动开发其实没那么可怕。它的核心思想非常简单:把硬件变成文件。只要理解了这一点,后面的事情就顺理成章了。
现在我们坐下来,好好聊聊字符设备驱动到底是什么,以及为什么它是你进入内核开发世界的第一道门槛。
一切皆文件:Linux 的设计哲学
在 Linux 的世界里,有一个非常霸道的规则:一切都是文件。不管你是硬盘、显卡、LED 灯,还是键盘鼠标,统统都得在 /dev 目录下给我变出一个文件节点来。
为什么要这么做?因为这样可以统一接口。应用程序开发者不需要知道底层硬件是怎么工作的,只需要像操作普通文件一样,用 open()、read()、write() 这些熟悉的函数就能搞定一切。
这种设计真的很优雅。你想想,如果没有这套机制,每个硬件厂商都要提供自己的 API,那应用程序开发者得疯掉——今天学这个厂商的 SDK,明天学那个厂商的 API,永远在重复造轮子。
字符设备:按顺序读写的家伙
Linux 里的设备驱动分好几种,字符设备是最基本的一类。什么叫"字符设备"?说白了,就是那种按照字节流来读写、还得讲究个先来后到的设备。
这就像你在读一本书,必须从第一页第一个字开始,一行一行地来。你不能说"我要读取第 500 个字",然后直接跳过去——那是块设备干的事,比如硬盘。
我们在嵌入式开发里最常打交道的几位——点灯、按键、IIC、SPI、LCD ——统统都是字符设备。它们的数据传输就像是流水线上的零件,一个接一个,顺序不能乱。
举个具体的例子:假设你要从 IIC 总线上读一个传感器的数据。你不能说"我要第 5 个字节",因为 IIC 的数据是按时间顺序一个一个吐出来的,你必须把前面 4 个字节都读完,才能拿到第 5 个。这就是字符设备的特点。
那道墙:用户空间与内核空间
在深入代码细节之前,我们需要先建立一个大局观。这个大局观很重要,因为它能帮你理解后面要讲的一切。
当你在应用程序里写下 open("/dev/led", ...) 这行代码的时候,实际上发生了一场跨越边界的旅行。这场旅行涉及四个层级,我们一步一步来看。
应用程序在最上层,它调用 open()、read()、write() 这些函数。但这些函数其实不是直接和硬件打交道的,它们只是 C 库(比如 glibc)提供的封装。
接下来是关键的一步:C 库会执行一段特殊的汇编代码,引发一个叫做"系统调用"的操作。这个操作会让 CPU 从用户态切换到内核态。你可以把这想象成按了一个按钮,请求内核代劳。
内核接到请求后,会根据你打开的文件路径(比如 /dev/led),找到对应的驱动程序。然后驱动程序里真正干活的函数被执行,硬件开始动作。
这里有个有趣的设计:名字是一样的。你在应用层调用 open,驱动层也得有一个叫 open 的函数在那等着。这种"同名呼应"不是巧合,而是 Linux 内核为了降低认知负担特意设计的。你在这一头喊话,那一头有个同样名字的人在听。
Linux 的铁律是:应用程序运行在用户空间,驱动运行在内核空间。用户空间是无权直接触碰内核空间的——这就像你不能随便走进核电站的控制室。如果你想进去,必须通过那个特殊的"安检通道",也就是系统调用。
file_operations:驱动的"队员名单"
现在我们走到内核里。既然应用层调用 open、read、write,那驱动层怎么知道把这些调用映射到哪段代码上呢?
答案是靠一个核心的数据结构:file_operations。
你可以把这个结构体理解成一支足球队的队员名单,或者一张函数表。它是一个函数指针集合,里面罗列了驱动能响应的所有动作。当应用程序调用某个函数时,内核会查这张表,找到对应的函数指针,然后跳转过去执行。
别看 file_operations 结构体有一大堆成员,真正在开发字符设备时必须实现的,其实就那么几个。就像球队里虽然报了 20 个人,但上场的始终是那几个主力。
owner 这个字段很重要,它的作用是防止驱动还在运行时就被意外卸载。标准写法是 THIS_MODULE,你照着写就行,不用纠结太多原理。
read 和 write 这两个函数是核心中的核心。read 负责从设备读取数据传给用户空间,write 则相反,把用户空间的数据写到设备去。这两个函数的实现方式直接决定了你的驱动性能如何。
open 函数在应用程序打开设备文件时被调用,通常用来做一些初始化检查。但说实话,很多简单的驱动根本不需要做什么特别的初始化,这个函数里可能就打个日志就返回了。
release 函数在应用程序关闭设备时调用,用来释放资源。这里有个容易踩的坑:release 不一定只被调用一次。如果一个进程用 fork() 创建了子进程,每个进程关闭文件时都会调用 release。所以你得用引用计数来管理资源,不能在第一次 release 就把所有东西都清理掉。
设备号:驱动的"身份证"
每个字符设备都有一个唯一的身份标识,叫做设备号。这个概念一开始可能会让人困惑,但其实很好理解。
设备号是一个 32 位的整数,分为两部分。高 12 位是主设备号,低 20 位是次设备号。你可以把主设备号理解成"公司代号",次设备号理解成"员工编号"。
主设备号标识驱动程序本身。所有使用同一个驱动的设备,它们的主设备号都是一样的。次设备号则标识使用该驱动的具体设备。比如你有三个同样的 LED 灯,它们的主设备号相同,但次设备号不同。
你可以在 /proc/devices 文件里看到当前系统中所有注册的设备号。这个文件很有用,当你怀疑设备号冲突的时候,第一件事就是看看这个文件。
设备号的管理方式在老内核和新内核里不太一样。老内核(比如 4.1.15)用的是一套比较简单的 API,register_chrdev() 这个函数一把梭。但这种方式有问题,要么你自己指定主设备号(容易冲突),要么让内核动态分配(但会占用整个主设备号下的 256 个次设备号)。
新内核(比如 6.12.49)推荐使用更精细化的管理方式。你可以只申请需要的设备号数量,避免浪费整个主设备号。这听起来有点抠门,但在资源紧张的嵌入式系统里,这种抠门是必要的。
关于设备号的详细管理方式和 API 对比,我们会在后面的章节里详细讲。这里你只需要记住:设备号是驱动的身份证,必须唯一,而且要合理管理。
我们要做什么
看完了这些概念,你可能会觉得压力山大——这么多函数、这么多数据结构,从哪里开始?
其实不用焦虑。我们的目标是:实现一个最小化的字符设备驱动。我们不需要把上面每一个函数都填满,但我们会把整个框架搭起来。
你要记住的是:驱动开发的核心,就是填写 file_operations 这个结构体,然后把它注册到内核里去。只要这一步通了,剩下的就是具体的业务逻辑——怎么操作寄存器,怎么处理中断,那些只是填充函数体里的代码罢了。
接下来的章节,我们会从最基础的内核模块开始,一步步把框架搭起来。你会发现,当你真正动手写代码的时候,那些抽象的概念会变得具体而实在。
说句实话,驱动开发这东西,看书是学不会的。你必须亲自写代码、编译、加载、调试,在这个过程中踩坑、填坑,才能真正理解。所以我们接下来的风格会是:少讲理论,多写代码,遇到问题就解决问题。
准备好了吗?我们开始吧。
下一步: 继续阅读 02_kernel_space_basics.md 了解内核空间的基础知识,或者直接跳到 06_legacy_chardev.md 看实战代码。