☰
从内核Oops到用户态指针安全:Linux驱动数据交换边界实战
2026/10/21 3:51:33 网站建设 项目流程

凌晨两点,我在调一个某字符设备驱动的ioctl接口,屏幕突然一黑,SSH直接断开。重启后翻dmesg,看到一行让我彻底清醒的记录:BUG: unable to handle page fault,后面紧跟着我那个驱动模块的名字。那一瞬间我就明白,用户空间与内核空间之间那道看不见的墙,我没有敬够。这次Oops不是偶然,它是我在用户态与内核态之间做数据交换时,踩中了边界规则的雷。

这篇文章会把那次崩溃的前因后果完整复盘,聊聊内核和用户程序之间到底隔着什么,为什么不能直接解引用用户指针,以及规范的数据交换API应该怎么用。同时我会给出从Oops信息到定位修复的完整排查链路,用命令行、代码示例和错误码对照表把过程讲透。无论你是刚开始写驱动的新手,还是已经在内核模块里趟过几回水的老手,这篇内容应该都能让你少熬几个夜。

1. 那一晚的Oops:一次用户指针引出的内核崩溃

1.1 崩溃前的代码:新手眼中“理所当然”的写法

先还原出事的那段代码。当时我写了一个虚拟字符设备驱动的ioctl,功能很简单:用户态传一个缓冲区过来,内核把它原样保存到驱动私有的一个数组里。我最初的实现长这样:

static long my_drv_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { char *user_buf = (char *)arg; /* 直接把用户地址当内核地址用 */ char kbuf[64]; memcpy(kbuf, user_buf, 64); /* 崩在这里 */ ... }

从纯C语言看,这段代码“没什么不对”:拿一个指针,memcpy64字节。但问题在于,这个指针来自用户态的调用者,它生活在用户空间,而我正站在内核空间。我用内核态的权限去访问一个用户态的地址,就是这次Oops的直接原因。

当时的用户态测试程序更简单,就是打开设备节点,然后调用ioctl传一个正常的栈上缓冲区。无论从哪个角度看,缓冲区本身完全合法,但内核依然崩了。这恰恰是最让人困惑的地方:地址明明有效,为什么一碰就炸?带着这个疑问,我开始逐行读Oops信息。

1.2 读懂Oops现场:错误码、RIP与调用栈的含义

当时dmesg截取的关键内容大致是这样:

BUG: unable to handle page fault for address: 0x00007f8a3c2b1000 #PF: supervisor read access in kernel mode #PF: error_code(0x0000) - not-present page RIP: 0010:my_drv_ioctl+0x3a/0x120 [my_drv] Call Trace: my_drv_ioctl do_vfs_ioctl ksys_ioctl __x64_sys_ioctl do_syscall_64 entry_SYSCALL_64_after_hwframe

很多人看到这一大串会慌,其实信息量非常大。unable to handle page fault for address说明内核访问某个地址时发生了缺页异常,而#PF: supervisor read access in kernel mode表示这是内核态(supervisor)发起的一次读操作。换句话说,CPU在执行内核代码时尝试读取一个用户态线性地址,结果该地址对应的页表条目根本不存在。

再往下看error_code(0x0000),它的二进制是0000,按x86的页错误错误码规则拆开:

  • bit0 = 0:页不存在(not-present),不是权限冲突,纯粹是页表里没有这项。
  • bit1 = 0:读操作触发。
  • bit2 = 0:内核态触发。

组合起来就是:内核在读取一个不存在映射的用户地址。为什么会不存在映射?因为我当时所在的上下文虽然能看到用户进程的页表,但那个地址对应的页恰好不在物理内存里,而缺页处理程序又不能在内核态直接替用户空间补页。

至于RIP和调用栈,my_drv_ioctl+0x3a/0x120给出了我函数内部的具体偏移,调用栈则完整还原了从用户态通过系统调用进入到驱动的路径。这两行是我后续定位源码位置的最重要线索,具体怎么用,我会在最后一节讲。

2. 两个世界的边界:地址空间隔离与特权级约束

2.1 门禁规则:地址空间、页表与CPU特权级

很多人写驱动时,潜意识里觉得“内核权限高,内核当然什么都能访问”。这个直觉只对了一半。内核确实运行在CPU的最高特权级,但“能执行特权指令”和“能访问任意线性地址”是两回事。

现代CPU上,每个进程都有独立的虚拟地址空间。以x86-64为例,一个进程的虚拟地址空间被划分为用户态线性区和内核态线性区。用户态程序跑在低特权级,访问高地址的内核区会直接触发保护异常;内核跑在高特权级,但访问用户区地址时,同样必须经过当前进程页表的转换。页表里没有映射,CPU照样报缺页。

这个设计可以类比酒店管理:住客(用户进程)只能进自己的房间(用户地址空间),后厨(内核空间)有门禁。而经理(内核代码)虽然权限高,能进后厨,但如果想进住客房间,也必须用房卡(当前进程的页表)去开门。房卡没登记这间房,经理照样被锁在门外。

更严格的约束还在后面。现代CPU普遍加入了类似SMAP(Supervisor Mode Access Prevention)的机制,即使在页表有映射的情况下,也不允许内核态随便访问用户空间的数据页。这种机制逼着内核开发者必须走专门的接口去触碰用户内存,而不是拿着指针就直接memcpy。

2.2 内核态≠万能钥匙:页表归属与上下文窗口

还有一个容易被忽略的点:页表是跟着进程走的。内核线程没有独立的用户地址空间,它们运行的时候,current->mm是空的。这时候如果你写一段代码,里面拿着一个用户地址去访问,会发现连“当前进程叫什么”都问不出来,更别提它的页表了。

所以“内核态能不能安全访问用户地址”要同时满足三个条件:

  1. 当前上下文属于某个用户进程,即处于系统调用路径上。
  2. 访问的地址能通过当前进程页表完成映射。
  3. 该地址对应的物理页此刻在内存里,或者缺页时内核有机会去补页。

第三个条件特别关键。用户地址对应的物理页可能被换出到交换分区,可能被madvise标记为丢弃,也可能因为写时复制还没建立真正的映射。普通的内核代码遇到这种缺页不能按用户空间的规则慢慢处理,因为内核没有“等磁盘换入再恢复执行”的容错机制,多数情况下只能直接崩给用户看。

这也就解释了为什么内核专门提供了一套copy_to_user、copy_from_user之类的接口:它们内部会隐式处理缺页、异常恢复和权限校验,而裸的memcpy做不到这些。

3. 三种最常见的“越界”翻车现场

3.1 直接解引用用户指针:内核替用户“闯红灯”

我在文章开头遇到的,就是最经典的第一种坑:拿到一个用户态指针,不做任何处理,直接用内核代码去解引用。这里的风险不只是“可能崩”,而是“大概率会崩”。哪怕那个用户地址在用户态完全合法,内核态访问时也可能因为缺页、权限保护或者SMAP机制而触发异常。

打个比方,这就像你在服务台看到一个顾客递来的信用卡号,你直接在经理办公室的POS机上刷,但经理办公室的POS机根本没接顾客的账户系统。你刷的其实是另一套账本,刷不出来是正常的。

正确的做法是,用户指针永远只当作“待验证的地址”,需要先经过access_ok等检查,再通过专门的复制接口来搬数据。用户指针的类型应该标注成__user,提醒自己和阅读代码的人:这不是内核指针,不能直接解引用。

3.2 在不可睡眠的上下文访问用户内存:睡不着的锁

第二种坑更隐蔽:看起来用了标准的copy_from_user,但调用点选错了。copy_from_user这类接口在触及缺页时是可能睡眠的,因为它要等待页面调入内存。而内核里某些上下文不允许睡眠,典型的有:

  • 中断上下文和底半部处理函数。
  • 持有自旋锁的区域。
  • 被抢占关闭的临界区。
  • RCU读锁保护的区域。

在这些地方调用可睡眠的函数,轻则触发BUG: scheduling while atomic,重则直接死锁,让整个系统卡死。我记得有一次在驱动的release函数里持锁做了copy_to_user,结果系统当场挂起,那种“重启才能解决”的无力感至今难忘。

判断办法很简单:访问用户缓冲区之前,确认自己不在in_interrupt()状态,也不要持着自旋锁或RCU锁。如果确实需要在异步上下文用用户数据,正确思路是在进入异步流程之前就把需要的数据拷贝到内核缓冲区,后续异步逻辑只操作内核内存。这也是很多驱动在内核侧维护“影子数据”的原因。

3.3 只听长度、不看边界:缓冲区裸奔的教训

第三种坑来自“信任”:用户传了一个长度字段,内核就信了,然后拿着这个长度去读写内核缓冲区。经典的漏洞类型是缓冲区溢出和信息泄露。

举个例子,某次代码里根据用户传入的len参数,从内核缓冲区拷出数据给用户:

static long my_drv_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct my_ioctl_data __user *uarg = (void __user *)arg; unsigned int len; char outbuf[128]; copy_from_user(&len, &uarg->len, sizeof(len)); memcpy(outbuf, kernel_private_data, len); /* 没有核对len上限 */ copy_to_user(uarg->data, outbuf, len); ... }

用户传入len = 2048,内核就把内部的128字节缓冲区往外拷2048字节,多出来的全是内核内存残留,直接变成信息泄露。把方向反过来,就是内核态堆栈或堆溢出,可能被用来提权。

正确做法永远是:用户传入的长度只能作为“期望值”,内核必须结合自己的缓冲区尺寸再次校验,取小值,或者干脆校验后拒绝。

4. 正规过界通道:内核数据交换API的正确打开方式

4.1 copy_to_user / copy_from_user:标准搬运工与返回值陷阱

内核给开发者准备的标准接口,最常用的就是这两个:

unsigned long copy_to_user(void __user *to, const void *from, unsigned long n); unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);

名字看起来像是memcpy的变体,但行为完全不同。它们的特点可以总结成三点:

  • 内部会处理用户地址的合法性检查,确保访问范围没有越过用户态边界。
  • 如果目标页不在内存,它们能够安全地在缺页处理路径中等待,正常完成搬运。
  • 返回值是尚未拷贝的字节数,不是-EFAULT。返回0表示全部成功,返回非0表示中途失败或部分失败。

返回值这个坑,很多初写驱动的人都会踩。不少人写出if (copy_from_user(...) == -EFAULT),那永远不成立,因为函数返回的“剩余字节数”和errno是两码事。正确写法是:

if (copy_from_user(kbuf, (void __user *)arg, sizeof(kbuf))) { return -EFAULT; }

只要返回值非零,就说明拷贝没有完整完成,统一按失败处理最省心。

另外值得一提是__copy_from_user、__copy_to_user这种带双下划线版本,它们默认不检查地址,调用者自己负责。除非明确知道自己在做什么,否则不要用。

4.2 get_user / put_user 与 access_ok:轻量通道和前置检查

如果交换的数据只有几个字节,用copy_to_user就有点重了。内核提供了更轻量的接口:

int get_user(x, ptr); /* 从用户空间读一个简单类型 */ int put_user(x, ptr); /* 向用户空间写一个简单类型 */

它们会编译成单条访问指令,并在异常表中登记修复点,缺页时也能正确返回错误而不是直接崩溃。适合传整数、指针、标志位等小数据。

在一些场景里,你希望在对用户缓冲区动手之前先做一次范围校验,可以用access_ok:

if (!access_ok((void __user *)arg, size)) return -EFAULT;

这里有一个理解上的关键点:access_ok只是“粗筛”,它验证的是地址落在用户区范围并且没有溢出,并不保证对应的物理页一定存在。真正搬运数据时,页还是可能不在。所以不能把access_ok当成“我已经拿到内存了”的通行证,它只代表“这单业务可以受理”。

4.3 生命周期边界:不要把内核钥匙交到用户手里

还有一些边界问题与“拷贝”无关,而是与“指针对齐”有关。一个常见错误是:驱动在open时把用户传入的缓冲区地址保存在私有数据里,等某个内核事件发生时再回头访问。这种设计非常危险,因为用户进程可能已经退出,那个地址在用户空间里可能已经被释放、被复用,甚至页表已经切换到了另一个进程。

原则可以概括成一句话:内核栈和内核堆的地址不能泄露给用户态,用户态的地址也不能在内核里长期保存。凡是需要跨上下文使用的用户数据,要么在入口处立即拷贝到内核缓冲区,要么通过DMA场景的get_user_pages等机制把用户物理页钉住,生成内核侧的映射再使用。

数据交换如果涉及大块连续内存,还有一个更优雅的通道是mmap。用户态直接映射设备内存,访问起来像访问普通内存一样,内核侧在mmap回调里做一致性映射。这个方案没有中间拷贝,但也要通过页表权限机制控制访问边界。实际项目中,小数据走ioctl + copy系列接口,大数据走mmap,这是最常见的搭配。

5. 从Oops到修复:一条可复制的排查链路

5.1 保存完整现场:从 dmesg 到崩溃转储

出问题之后,千万不要急着重启,更不要急着看两眼dmesg就下结论。完整的现场信息是破案的唯一依据。有几个步骤我每次都会做:

  1. 把dmesg输出完整保存到文件,不仅仅是最后几行。前文的打印、模块加载顺序、请求参数日志都可能有用。
  2. 记录内核版本、模块版本、触发时的用户态程序调用方式。
  3. 如果有条件,提前配好内核崩溃转储(kdump + crash工具)。崩溃转储会把整个内存镜像留下来,用crash工具可以分析任意数据结构的当前值,定位能力比单纯一屏Oops信息强一个量级。

我那次崩溃没有配转储,只能靠dmesg和复现实验硬推,多花了不少时间。如果你长期写字符设备驱动或内核模块,崩溃转储这套基础设施建议提前搭好,别等出事了再后悔。

5.2 让 RIP 说话:模块基址、偏移与源码行号

dmesg里的RIP: 0010:my_drv_ioctl+0x3a/0x120 [my_drv],后面的0x3a是函数内偏移,0x120是函数总长度。配合模块在内存中的加载基址,可以把崩溃点精确定位到源码行。

操作步骤:

cat /sys/module/my_drv/sections/.text

拿到模块的.text节基址,比如0xffffffffc0000000。然后看RIP,如果是my_drv_ioctl+0x3a,就用函数在模块内的实际地址算。理论上我们想知道的是模块内偏移,直接用objdump反汇编模块看这个偏移附近的指令即可,更稳妥:

objdump -d my_drv.ko

反汇编出来之后,在函数my_drv_ioctl里偏移0x3a处能看到具体是哪条指令。比如看到mov %rax,(%rsi)或者rep movsb,那就知道崩溃发生在某个直接解引用用户指针的位置。有了这个坐标,再去源码里对比,通常很快就能锁定。

如果模块带有调试符号,用addr2line也可以直接换出源码行号,但那需要模块内符号表齐全,加载地址和偏移计算要仔细。我实际操作下来的经验是:先看RIP附近几条指令,再回源码,比纯靠行号推算更直观。

5.3 修复、复现与回归:改完代码只是第一步

修复那段崩溃代码,我做的事情并不复杂:把裸的memcpy替换成copy_from_user,然后检查返回值。但真正让我安心的是后面的验证过程。

我先写了一个最小复现器,循环一百万次调用那个ioctl,传不同长度、不同对齐方式的缓冲区,确保没有边界条件漏网。然后重点做了几组异常测试:传NULL指针、传已释放的地址、传只读页、传过短缓冲区,看每次调用是否都返回-EFAULT而不是让内核崩溃。最后,我又在持有自旋锁的路径里故意触发了一次访问用户内存的操作,确认内核能正确报告“scheduling while atomic”,这才算对修复方案的边界有了底气。

改完代码不是终点,关键是确认修复没有掩盖其他问题。比如我后来发现,即使是copy_from_user,如果调用点处于临界区,同样会出问题。这类上下文问题需要靠代码审查和压力测试配合,而不是只盯着一处崩溃点。内核开发里,崩溃是最好的老师,但它只教一次,不记笔记的人大概率还会在同一个坑里再摔一回。

那晚之后,我把驱动的所有用户指针都加了__user标注,所有ioctl入口都补上了长度与边界双重校验,并且给每个异步路径都提前拷贝了数据。以后再遇到用户空间与内核空间的数据交换,我脑子里会先过一遍三个问题:这个地址属于谁?我能不能在这里睡眠?缓冲区边界到底是谁说了算?这三个问题想清楚,大部分边界问题都走不到Oops那一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询