news 2026/10/17 2:59:32

Linux字符设备驱动开发实战:从hello_chardev理解内核驱动框架

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux字符设备驱动开发实战:从hello_chardev理解内核驱动框架

刚接触嵌入式或者操作系统课程的人,十有八九会问一句:什么是Linux驱动?网上搜一圈,答案大多是“驱动就是控制硬件的软件”,这话没错,但基本等于没说。真正做驱动开发的人都知道,难点从来不在于“控制硬件”这四个字,而在于整套约定俗成的框架:你的代码要运行在内核态,要被系统调用记住,要通过设备节点暴露给应用层,还要处理并发、中断、内存映射这一堆麻烦事。这篇文章我不想站在课本角度重新讲一遍概念,而是从一个最普通的字符设备驱动写起,把一个hello_chardev从无到有跑起来的过程掰开讲,看看驱动在Linux里到底扮演什么角色。适合想搞懂驱动本质、或者正准备写第一个驱动的朋友,也适合想弄明白“为什么应用层一个open()能影响到硬件”的读者。

先说清楚一个底层事实:你自己写一个驱动文件放在那,不代表系统就认识它。驱动必须被“注册”进内核,告诉内核“我负责某个设备号,请把对它的操作交给我”。这个注册、分发、回调的过程,才是理解Linux驱动的钥匙。为了把这把钥匙讲明白,以下内容我会顺着代码走一遍,该给的配置给全,该解释的原理讲透,希望你读完能自己动手复现一个最小的字符设备驱动。

1. 驱动不是“控制硬件的代码”那么玄:它填补的是系统调用和设备之间的断层

1.1 用户态进程碰到硬件,中间隔了多少层

先看一个最简单的场景:你在终端里敲了cat /dev/hello_chardev,这条命令背后到底发生了什么事?进程在用户态发起open()和read(),这两个是系统调用,不是普通函数。系统调用进入内核后,VFS(虚拟文件系统)层会根据文件路径找到对应的inode,再发现这个inode指向一个字符设备节点。字符设备节点里记录着主设备号和次设备号,内核拿着主设备号去字符设备表里找,找到挂在对应设备号上的cdev结构体,而cdev里保存着这个驱动的file_operations结构体。到这一步,调用才真正被交给你的驱动代码。

整个链路可以压缩成一条线:

  • 系统调用(open/read/write/close)→ VFS 按路径查 inode → 字符设备按设备号查 cdev → 驱动 file_operations 被回调 → 驱动访问硬件或自身维护的缓存

如果换成块设备或者网络设备,中间走的路径会变成block layer或socket协议栈,但用户态看到的“文件”外观仍然是保留的。这正是Linux“一切皆文件”思想的关键:内核用统一的文件接口,把千奇百怪的硬件差异藏在驱动层。驱动从来不是一个孤立程序,它是这条链中的一个环节。很多初学者直接打开一个驱动源码,看到一堆结构体和宏定义,却不知道这些函数到底被谁调用,就是因为脑子里没有这条链路。

有个类比可以帮助记忆:用户态进程是一个打电话的人,系统调用是总机,设备号是分机号,驱动是分机旁边负责处理事务的工作人员。你只看到总机把电话接过去,但具体事务由分机工作人员处理,中间还隔着总机的规则和交换机线路。这个类比虽然粗糙,但“回调”和“分发”的概念已经出来了。

1.2 设备节点、设备号、驱动:三者不是一回事

很多人有个误解:只要/dev/hello_chardev这个文件存在,就说明驱动装好了。不是的,设备节点、设备号、驱动是三个独立的东西,缺一个都不行。

设备节点是/dev下的一个文件入口,它本身不包含任何硬件逻辑,只是保存了主设备号和次设备号。主设备号用来标识驱动程序,告诉内核“哪个驱动处理这个设备”;次设备号用来标识同类硬件中的第几个实例,比如两个串口通常是同一个主设备号,用次设备号区分彼此。驱动是一段内核模块代码,加载时向系统声明“我负责这个主设备号”,并挂上一组读写函数。只有节点、没有驱动,应用层open()时会返回-ENODEV或-ENXIO;反过来,驱动加载了但没有节点,应用层连入口都找不到。

在早期Linux里,设备节点需要管理员用mknod命令手动创建,非常容易出错。后来内核引入了sysfs和uevent机制,驱动在注册设备时创建一个class和device,用户空间的守护进程收到内核发来的uevent事件后,会自动在/dev下生成对应的设备节点。这套机制在绝大多数现代发行版上都是默认生效的,也是我们下面代码里class_create和device_create存在的原因。

理解了这三个概念的分工,再去看驱动的注册代码,你会觉得每一行都有明确目的——它不是在“创造设备”,而是在“向系统申报设备并挂接操作接口”。

2. 最小骨架:hello_chardev 从模块加载到自动创建设备节点

2.1 模块的出生与死亡:module_init 和 module_exit

写一个最小字符设备驱动,先别想复杂的寄存器操作,把数据通道跑通就行。下面是完整代码,我建议你亲手敲一遍,理解每一行的作用再粘到编辑器里。

#include <linux/init.h> #include <linux/module.h> #include <linux/kdev_t.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> #define DEVICE_NAME "hello_chardev" #define CLASS_NAME "hello_class" static dev_t dev_num; static struct cdev hello_cdev; static struct class *hello_class; static struct device *hello_device; static char device_buf[1024]; static int buf_len; static int hello_open(struct inode *inode, struct file *filp) { pr_info("hello_chardev: open called\n"); return 0; } static int hello_release(struct inode *inode, struct file *filp) { pr_info("hello_chardev: release called\n"); return 0; } static ssize_t hello_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t to_read; if (*offset >= buf_len) return 0; to_read = min(count, (size_t)(buf_len - *offset)); if (copy_to_user(buf, device_buf + *offset, to_read)) return -EFAULT; *offset += to_read; return to_read; } static ssize_t hello_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { if (count >= sizeof(device_buf)) count = sizeof(device_buf) - 1; if (copy_from_user(device_buf, buf, count)) return -EFAULT; device_buf[count] = '\0'; buf_len = count; *offset = 0; return count; } static struct file_operations hello_fops = { .owner = THIS_MODULE, .open = hello_open, .release = hello_release, .read = hello_read, .write = hello_write, }; static int __init hello_init(void) { int ret; ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret < 0) { pr_err("hello_chardev: failed to alloc region\n"); return ret; } cdev_init(&hello_cdev, &hello_fops); hello_cdev.owner = THIS_MODULE; ret = cdev_add(&hello_cdev, dev_num, 1); if (ret) { unregister_chrdev_region(dev_num, 1); return ret; } hello_class = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(hello_class)) { cdev_del(&hello_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(hello_class); } hello_device = device_create(hello_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(hello_device)) { class_destroy(hello_class); cdev_del(&hello_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(hello_device); } pr_info("hello_chardev: registered, major=%d, minor=%d\n", MAJOR(dev_num), MINOR(dev_num)); return 0; } static void __exit hello_exit(void) { device_destroy(hello_class, dev_num); class_destroy(hello_class); cdev_del(&hello_cdev); unregister_chrdev_region(dev_num, 1); pr_info("hello_chardev: unregistered\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL");

module_init和module_exit是Linux内核模块的入口宏,它们的作用不是把函数直接拉出来执行,而是告诉内核“这个模块加载时调用hello_init(),卸载时调用hello_exit()”。hello_init()干的事情可以概括为四步:先申请一段设备号,再把字符设备结构体挂到系统表里,然后创建class和device,最后打印注册日志。每一步都在为“用户态能找到这个设备”做准备。

MODULE_LICENSE("GPL")这行不是摆设。你不写也能编译,但内核会把模块标记为“污染”状态(tainted),出问题时内核开发者通常不愿意帮你分析。开源的驱动模块最好都写明许可证,这既是规则也是惯例。

这个hello_chardev并没有操作任何硬件寄存器,它读写的是一个内核内存缓冲区。但这不影响它的价值——它的流程和真正的外设驱动完全一致:申请资源、注册回调、暴露设备节点。很多外设驱动第一版就是这个样子,先把数据传输通道打通,再往read/write里加真正的硬件访问。

2.2 设备号申请和自动创建节点

代码里用的是alloc_chrdev_region()而不是register_chrdev_region(),这个选择是刻意的。前者让内核动态分配一个可用的主设备号,后者要求你自己指定主设备号。自己指定看着很酷,但很容易撞车:你选的主设备号可能已经被某个已有驱动占用,加载时直接报Device or resource busy。动态分配省心得多,代价是你需要从/proc/devices查一下实际拿到的主设备号。这个文件是内核导出的设备号列表,字符设备那一项里就能看到你注册的设备名和主设备号。

cdev_init()和cdev_add()是两件事,很多新手混在一起。cdev_init()只是把hello_cdev这个结构体和hello_fops关联起来,像一个接线前的准备动作;cdev_add()才是真正把字符设备挂到系统字符设备表里,挂了之后,内核按主设备号找 cdev 时才能找到它。顺序不能颠倒,少了任意一步设备都不可用。

class_create()和device_create()的作用是配合用户空间的守护进程自动生成设备节点。驱动创建了一个设备类,再在该类下创建一个设备实例,内核会通过sysfs发出uevent事件,守护进程收到之后就在/dev下创建对应的节点。如果你的嵌入式环境里没有这套用户空间机制,就得自己用mknod /dev/hello_chardev c 主设备号 0手动补一个节点,这也是为什么老教程里总有mknod的身影。环境不同,自动化的程度不同,但内核这边的注册逻辑是不变的。

3. file_operations 这条链:用户态 read() 在内核态到底跑了多远

3.1 open/write/read对应到哪里:file_operations的挂接

驱动里的file_operations结构体是一堆函数指针,它定义的是“这个设备支持哪些文件操作”。应用层每个open()、read()、write()、close()最终都会走到对应函数指针指向的驱动函数。你在代码里看到的struct file_operations hello_fops = { .owner, .open, .release, .read, .write },本质上是一张回调表,内核在合适的时机回调你注册的函数。没用到的字段保持NULL即可,比如没有实现mmap时,用户态调用mmap()会得到一个合理的错误,而不会崩内核。

这里有个必须留意的点:驱动里的read/write返回值语义和普通用户态编程不一样。read返回0表示EOF,返回正数表示本次实际读取的字节数,返回负数表示错误码;write返回正数代表写了多少字节,返回负数代表错误。用户态库函数拿到这些返回值直接当成系统调用的结果。如果驱动开发者不遵守这套约定,应用层的行为就会变得很奇怪,比如read()永远返回0,程序就会觉得文件是空的。

我在很多学习项目中见到的第一个驱动bug,就是read函数忘记判断文件偏移offset,导致应用层cat一个设备节点时陷入死循环——因为内核认为每次read都有新数据。学会管理offset和返回值,比记住结构体里所有字段重要得多。

3.2 copy_to_user为什么不能换成memcpy

这是新手最容易栽的坑。有人看了上面的代码会问:为什么不能直接在驱动里写memcpy(buf, device_buf, count)?因为buf是一个用户空间指针,驱动运行在内核态,用户空间的内存页可能根本没有映射到内核地址空间。直接解引用一个用户指针,轻则触发缺页异常,重则导致整个系统崩溃或产生严重的安全漏洞。

内核之所以要区分“用户指针”和“内核指针”,是因为现代CPU有特权级别隔离,操作系统利用这种隔离把用户进程和内核分开。用户进程不能随意访问内核内存,内核也不能理所当然地替用户进程读写任意地址。copy_to_user()和copy_from_user()就是专门负责跨越这个边界的接口:它们会先检查用户缓冲区是否合法可访问,再逐页处理可能的缺页,然后才执行拷贝。使用它们时还要注意返回值:成功时返回0,失败时返回未拷贝成功的字节数。所以常见写法是if (copy_to_user(...)) return -EFAULT;,这里的-EFAULT是“地址错误”的标准错误码。

类似地,如果驱动需要把内核缓冲区里的数据发给用户,也要用copy_to_user。内核态和用户态之间的“搬运”必须经过这些专用接口,这是驱动开发的基本规矩,不是可选项。

3.3 先打通数据通道,再谈控制寄存器

很多刚学驱动的同学会有一种执念:驱动里一定要看到寄存器地址、ioremap、中断处理,否则就不算驱动。实际上,外设驱动开发的常规路径恰恰是从“先跑通数据通道”开始的。最开始要确保应用层能往内核态写入一段数据,内核态也能把数据回传给应用层。数据通道通了,什么命令字节都可以通过这个通道下发;接下来才是在write里根据命令去操作寄存器,或者在read里把硬件状态打包返回。中断处理、等待队列、内核线程,全都是后面根据自己的设备特性逐步加进去的。

这种“先通数据、再控硬件”的思路,在工业项目里非常常见。寄存器操作需要查阅芯片手册,中断逻辑需要梳理硬件时序,如果一开始就把这些全塞进驱动,出了问题根本不知道是数据通道的问题还是硬件操作的问题。把 hello_chardev 这样的骨架先跑通,相当于先搭好一条干净的路,再往路上加各种车辆。

4. 编译、加载、验证:insmod 到 dmesg 的完整闭环

4.1 Makefile与Kbuild:为什么不能拿gcc直接编

内核模块不是普通的可执行文件,它必须使用内核提供的头文件、Kbuild脚本和编译参数进行编译。最常用的Makefile是这样写的:

obj-m := hello_chardev.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

这里藏着很多初学者看不懂的两阶段套路。第一次执行make时,KERNELRELEASE这个变量还没有被定义,所以Makefile走到else分支,用-C切到内核源码目录,再通过M=$(PWD)指定当前目录,让内核的Kbuild系统重新读取这个Makefile。第二次读取时,KERNELRELEASE已经有值了,此时obj-m := hello_chardev.o才生效,Kbuild会正式编译模块。如果你用gcc直接编译.c文件,大概率会得到一堆 “找不到linux/module.h” 的头文件错误,这就是因为你绕过了Kbuild。

两个容易忽略的前提:一是必须安装内核开发包,否则/lib/modules/$(uname -r)/build可能只是一个空链接或完全不存在;二是本机编译的模块不要指望能直接丢到另一个内核版本的机器上insmod,模块有版本校验机制,编译时会写入内核版本信息,加载时版本不匹配会报错。这种“换机器就挂”的情况,本质上不是代码问题,而是编译环境和运行环境不一致。

4.2 加载卸载与日志观察:insmod后系统的变化

编译成功会生成hello_chardev.ko,接下来就是完整的加载验证流程。我建议按下面这批命令逐步执行,每条命令都去观察输出:

sudo insmod hello_chardev.ko dmesg | tail cat /proc/devices | grep hello ls -l /dev/hello_chardev

insmod会触发module_init,dmesg | tail里能看到“registered, major=xxx, minor=0”的日志,major就是内核动态分配的主设备号。cat /proc/devices | grep hello能看到hello_chardev出现在字符设备列表中,说明注册成功。ls -l /dev/hello_chardev能看到设备节点已经自动生成,这一条能自动出现,就验证了class_create和device_create那几步确实起了作用。

卸载时执行sudo rmmod hello_chardev,再看dmesg,应该能看到“unregistered”的日志。这里顺便说一下insmod和modprobe的区别:modprobe会去/lib/modules下搜索模块并自动解决依赖,适合系统管理;insmod直接加载指定路径的.ko文件,不做依赖分析,适合开发调试。我们写在当前目录的模块用insmod更直观。

4.3 echo和cat测试驱动时的行为差异

数据通道是否真的通了,要跑到应用层去看。测试命令非常简单:

sudo sh -c 'printf "hello" > /dev/hello_chardev' cat /dev/hello_chardev

第一条命令执行时,shell 会依次打开设备节点、写入6个字节、关闭节点,open、write、release三个回调依次触发,字符串通过copy_from_user进入内核缓冲区。第二条命令执行时,open、read回调被触发,cat会反复调用read直到返回0,于是看到输出“hello”。如果你用echo hello > /dev/hello_chardev测试,写入的实际上是“hello\n”,多一个换行符,输出效果看起来也一样,但字节长度不同。

这个测试里有一个值得注意的行为:我们的hello_write每次都是从缓冲区头部开始覆盖写入。所以第二次写一个更短的内容,之前的长内容会被部分覆盖。如果你希望驱动支持追加写、支持lseek等行为,都需要驱动开发者自己去实现。这也是理解驱动的一个关键点:设备节点看起来像文件,但它不是普通文件,文件系统提供的那些语义全靠驱动自己实现。同样的echo和cat,在不同驱动上的表现可以完全不同,因为底层代码是“自定义”的。

5. 驱动从“能跑”到“能扛”:初学者最该避开的几个坑

5.1 模块删不掉、设备号被占:加载与卸载的常见摩擦

调试驱动的过程中,遇到rmmod报“Module XXX is in use”是家常便饭。这个错误的意思是模块的引用计数不为0,通常是有进程还open着设备节点,或者内核中还有其他对象引用着该模块。先执行fuser -v /dev/hello_chardev或者lsof看看到底谁占用了设备,把占用进程关闭后再卸载。不要在进程占用期间强行卸载,就算某些情况下能卸载,也会留下一个悬空引用,后续访问容易崩溃。

反过来,insmod报Device or resource busy,大概率是设备号冲突。如果你在代码里用了固定主设备号,而它恰好已经被其他驱动占用了,就会是这个错。去/proc/devices里查一下哪些号被用了,或者直接改用动态分配。还有一种情况是模块加载时报Operation not permitted,那往往和内核签名验证或权限有关,需要检查系统是否开启了强制模块签名校验,这种情况和代码本身没关系,别一头扎进代码里改半天。

5.2 Oops和段错误:内核崩溃信息怎么看

驱动崩了不像用户态程序崩了那么“温柔”。用户态空指针会得到一个Segmentation fault,进程挂掉,系统没事;内核态空指针解引用,常见结果是系统日志里出现一大段Oops信息,运气好系统还半死不活地能执行命令,运气差直接卡死或重启。内核态没有轻巧的恢复机制,这是驱动开发最刺激的一点,也是最需要谨慎的一点。

万一你的驱动出了问题,先学会读Oops信息。找几行关键内容:BUG: unable to handle kernel NULL pointer dereference at ...告诉你出错时的地址;RIP那一行能看出具体是哪个函数出错;Call Trace是内核态的函数调用栈,能帮你定位调用路径。配合驱动里多打的pr_info日志,把范围缩小到某个函数,再用addr2line或objdump反汇编模块文件,往往就能找到是哪一行出了问题。

调试驱动的基本功就是打日志。pr_info、pr_err、pr_debug这些接口都值得用起来。加载和卸载时打日志,open和close时打日志,读写时记录关键参数,出问题时把这些日志拼接起来,很多问题当场就露馅了。

5.3 API随内核版本变化:老驱动不能“直接拿来编译”

Linux内核迭代速度非常快,驱动API处于持续变化之中。最典型的一个例子就是class_create:老内核需要传入两个参数class_create(THIS_MODULE, name),而新内核版本把第一个参数去掉了,只保留name。只改这一处还相对简单,更头疼的是像“ioctl从有锁版本变成unlocked_ioctl”“proc_create的参数换了又换”“设备树接口在不同大版本里行为不同”这类变动。网上搜到几年前的驱动代码,直接放在新内核里编译报一屏错误,是很多人经历过的事。

应对这个问题,最重要的一条经验是:只看当前内核版本的源码。你跑的是哪个版本的内核,就去看对应版本的include/linux/fs.h、include/linux/cdev.h、include/linux/device.h里的定义,再看看内核源码树里现成的驱动样例是怎么写的。虽然老代码起参考作用,但把它当成“直接可编译”的答案,会浪费大量时间。写驱动本质上是在和内核的规则打交道,只要规则变了,代码就得跟着变。

5.4 多进程同时访问同一个设备:并发会撕碎你的缓冲区

我们最初的 hello_chardev 虽然能跑,但最多算一个“教学骨架”,因为它完全没有考虑并发。如果两个进程同时往设备写入数据,两个write可能交错执行,buf_len和缓冲区的状态会变得一团糟。数据被撕裂还只是逻辑问题,更严重的是共享资源没有任何保护机制,对整个系统来说这是一个不稳定的隐患。

要让驱动真正“能扛”,下一步就是给共享资源加锁。对字符设备驱动来说,最基本的做法是加一个mutex或者自旋锁,让write和read在访问缓冲区时互相排斥。做完这一步,再往后面走,你还会遇到中断处理期间的资源共享、进程休眠唤醒、原子操作这些更复杂的话题。它们是驱动开发的必经之路,但已经不是“什么是驱动”这个入门问题能覆盖的范围。我自己第一次做驱动时,就是先把这个 hello_chardev 跑通,然后依次加了mutex、wait_event和ioremap,从字符设备一路走到平台驱动。回头再看“什么是Linux驱动”这个最初的问题,答案其实很朴素:它就是在系统调用和硬件之间,按照内核规则提供一组合法操作的内核代码。你把这套规则摸熟了,剩下的就是读芯片手册和写状态机的经验活了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 2:59:30

AI执法办案辅助审核实战:规则引擎与大模型如何协同

审核员一天的工作量&#xff0c;很多时候不是被复杂案件压垮的&#xff0c;而是被几千份雷同文书里的数百个数字和日期磨掉的。我们接到某地执法单位的试点需求时&#xff0c;第一反应并不是“用AI取代审核”&#xff0c;而是先问了一个不那么性感的问题&#xff1a;这个流程里…

作者头像 李华
网站建设 2026/10/17 2:58:24

大模型安全测试实战:从红队模拟到供应链防护

最近 AI 社区传开一个颇为戏剧性的安全测试话题&#xff1a;A社疑似故意让 Opus 模型进入“黑化”状态&#xff0c;让它模拟攻击者去“入侵”Hugging Face 平台&#xff0c;最后抱抱脸只留下一句“&#xff1f;”。消息本身没有完整的官方披露&#xff0c;但真正值得讨论的并不…

作者头像 李华
网站建设 2026/10/17 2:57:35

用原生HTML/CSS/JavaScript打造一个浪漫的表白网页

简介&#xff1a;一份以‘表白’为主题的个性化网页源码包&#xff0c;面向想用代码向女朋友表达心意、或希望学习前端与情感设计融合的初中级开发者。页面整体采用HTML搭建信息结构&#xff0c;CSS铺陈粉色红色调、爱心等浪漫视觉&#xff0c;JavaScript则实现点击动效与日期、…

作者头像 李华
网站建设 2026/10/17 2:57:18

select模型原理与教学价值:从Flash游戏服务器看I/O多路复用本质

简介&#xff1a;本资源是一套基于TCP协议与select I/O多路复用模型实现的Flash网络游戏通信教学案例&#xff0c;面向网络编程初学者及C服务端开发入门者&#xff0c;聚焦客户端&#xff08;SWF游戏&#xff09;与控制台服务器间的实时交互机制。压缩包共15个文件&#xff0c;…

作者头像 李华
网站建设 2026/10/17 2:55:13

SVM三分类实战:一对一策略、核函数与参数调优

简介&#xff1a;这份资源面向机器学习初学者与需要完成模式识别作业的开发者&#xff0c;聚焦如何用支持向量机&#xff08;SVM&#xff09;实现数据的三分类任务&#xff0c;并给出可运行的MATLAB实现方案。压缩包共6个文件&#xff0c;以5个m脚本和1个data数据文件为主&…

作者头像 李华
网站建设 2026/10/17 2:55:08

SelectMany本质:一对多关系的扁平化展开而非Select加强版

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华