尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Linux内核驱动:copy_to_user原理与实战指南

发布时间:2026/9/12 17:06:56

资讯中心
01
ARTICLE

Linux内核驱动:copy_to_user原理与实战指南

Linux内核驱动:copy_to_user原理与实战指南
写驱动的人十个里有八个在copy_to_user上栽过跟头我也不例外。早年内核版本还比较宽松的时候有人图省事直接在驱动里用memcpy把数据塞给用户态指针偶尔能跑通换个内核版本或者开个CONFIG_SMAP就瞬间oops甚至被利用提权。copy_to_user本质上是内核态与用户态之间的一层安检门它不只是把数据搬过去还负责处理页表切换、缺页异常、权限校验和指令修复。把这层机制弄明白写字符设备、网络模块、文件系统、ioctl接口时都能少踩很多坑。这篇文章我打算从为什么不能直接拷这个根本问题讲起沿着调用链走到异常表处理再给一个可以直接编译运行的驱动例子最后把常见的翻车现场和排查思路一起列出来适合正在学内核驱动、或者被EFAULT、page fault搞到头大的开发者参考。1. 为什么内核不能直接碰用户态指针copy_to_user的存在理由1.1 地址空间隔离一个指针两张页表现代操作系统把虚拟地址空间分成内核态和用户态两部分以x86-64为例0x0000000000000000到0x00007fffffffffff是用户空间0xffff800000000000以上属于内核空间。切换进程时CPU会加载不同的CR3寄存器也就是切换页表。内核虽然可以访问整个地址空间但用户态指针指向的页面在当前进程的页表里可能根本没有映射也可能已经换出到磁盘上。在内核态直接解引用用户态指针CPU去查页表发现这个虚拟地址没有对应的物理页就会触发缺页异常。如果这个异常发生在内核上下文情况就很麻烦了要么直接产生一个Oops系统日志里出现Unable to handle kernel paging request at virtual address要么因为异常不能被正确处理导致内核恐慌。更危险的是如果你拿用户态指针去做memcpy的源地址攻击者可以利用这个路径去读任意内核内存。打个比方copy_to_user就像你站在公司前台把文件递给外面的访客。前台不是你工位访客也不是你同事你不能直接把文件从工位扔出去得走传递窗口确认访客在、文件地址合理、然后安全交接。copy_to_user就是这个传递窗口。1.2 用户态指针的三重不确定性第一重不确定性是映射状态用户态malloc出来的内存只是建立了虚拟地址映射不一定分配了物理页内核去访问时随时可能触发major fault或者minor fault内核代码不能像用户态那样从容地等待磁盘I/O加载页面。第二重不确定性是生命周期用户态线程可能在系统调用途中退出、被信号打断或者另一个线程对这块内存调用了munmap指针瞬间变成悬空指针。copy_to_user通过access_ok和异常表机制把这种用户态地址突然失效视为可捕获的错误返回-EFAULT而不是让内核崩溃。第三重是权限问题内核里有些数据是敏感的不能无差别暴露给用户态。copy_to_user本身不做内容过滤但它提供了一个统一的出口你可以在拷贝之前做好权限校验而不是在拷贝过程中指望硬件保护。1.3 那memcpy到底错在哪直接看一段错误代码static ssize_t my_read(struct file *file, char __user *user_buf, size_t count, loff_t *offset) { char kbuf[64] hello from kernel; memcpy(user_buf, kbuf, strlen(kbuf) 1); // 错误的做法 return strlen(kbuf) 1; }这段代码在用户态只读映射、或者user_buf越界、或者目标地址刚被munmap的情况下会直接在内核态产生缺页异常。缺页异常处理程序会检查异常地址对应的fixup表但memcpy不在表里于是系统只能走panic或oops流程。即使当前运气好没崩溃调用者也无法判断这次拷贝到底成功没有。而copy_to_user的实现里每一个可能触发缺页的指令旁边都放了一个异常表条目一旦用户态地址有问题CPU会跳到异常表指定的修复地址给返回值打上EFAULT标记然后正常回到内核代码而不是崩溃。这就是它和memcpy最根本的差异它把用户态内存不可访问从致命错误降级成了可处理的内核错误码。2. 一次copy_to_user的完整旅程从系统调用到异常表的协作2.1 调用链路的起点系统调用与驱动的read回调用户态发起的read(fd, buf, size)会触发sys_read系统调用VFS层会根据文件描述符找到对应的struct file实例再通过file-f_op-read调用到驱动实现的回调函数。所以在你写的read回调里拿到的char __user *user_buf其实是系统调用层从用户态的buf参数直接透传下来的用户空间虚拟地址。__user是一个编译期注释宏告诉开发者以及sparse静态检查工具这个指针不能在内核态直接解引用。它不会生成任何机器码纯粹是代码约定。如果你用了__kernel或者不带这个标记去解引用sparse会报警告但gcc不会拦你。驱动read回调的典型签名ssize_t my_read(struct file *filp, char __user *buff, size_t count, loff_t *offp);阅读这个方法时你要有一个意识buff指向的地址不是内核内存地址而是当前进程的用户态虚拟地址。系统调用执行期间当前进程的mm仍然是用户的进程地址空间这也是copy_to_user能工作的基础——它访问的页表正是当前进程的页表。2.2 中间层copy_to_user、_copy_to_user与__copy_to_user的三层分工源码里有一套三层封装容易被初学者混淆函数是否做access_ok是否might_fault适用于copy_to_user是是进程上下文可睡眠_copy_to_user是否内部封装层__copy_to_user否否内核已经确认地址有效时__copy_to_user_inatomic否否原子上下文自旋锁/中断标准调用链是copy_to_user(...)→might_fault()→_copy_to_user(...)→access_ok(...)→__copy_to_user(...)。might_fault会检查当前是否处于原子上下文持自旋锁、关抢占、中断上下文等如果是它会在CONFIG_DEBUG_ATOMIC_SLEEP下输出告警因为后续缺页处理可能需要睡眠。而在原子上下文里你不能睡眠所以要用__copy_to_user_inatomic这类不触发睡眠路径的版本。access_ok检查用户地址的范围在x86-64上主要验证地址在USER_DS内且不越界即addr size不高于TASK_SIZE_MAX。它只查范围不查实际映射是否存在。换句话说它管的是这地址像不像用户态地址真正的用户内存是否真的可访问要靠后面的异常表机制兜底。2.3 异常表内核对可控事故的保险机制这是理解copy_to_user最核心的一块。内核对每个可能触发缺页的用户态拷贝指令都会注册一个异常表条目。内核模块加载时异常表信息会被组织成struct exception_table_entry数组包含三个字段insn指令地址、fixup修复地址和可选的data寄存器状态等。当__copy_to_user执行到那条访问用户态内存的指令时如果CPU产生了缺页异常异常处理程序会调用search_exception_tables()在异常表中查找当前指令地址。如果找到了对应条目就修正CPU的指令指针到fixup指定的位置同时设置返回值表示有几个字节没拷成功然后从异常中恢复。查不到条目那就只能oops。为了避免指令预取和寻址带来的偏差架构相关的代码通常在编译时通过宏来生成异常表条目例如x86的_ASM_EXTABLE_UA它标记某条指令的修复入口。汇编层面看起来大概是这样asm volatile(1: rep movsb\n 2:\n _ASM_EXTABLE_UA(1b, 2b) : ... : ...);执行到1:处的拷贝指令不妥CPU跳转去异常处理fixup把指令流拉到2:的标签位置续接返回流程。因此用户态内存有问题copy_to_user返回的是负数错误或者未拷贝字节数而不是内核崩溃。这套机制从2.0时代就一直活跃到现在6.x内核里依然能看到。2.4 拷贝的硬件加速路径现代内核在x86-64上对用户态拷贝做了分派根据CPU特性选择不同的实现老式CPU使用rep movsb支持ERMSEnhanced REP MOVSB/STOSB的CPUrep movsb会启用微码优化支持SMAP时内核会在拷贝前临stac开用户态访问标志拷贝后clac关闭所以你在perf里看到某些拷贝函数名带enhanced_fast_string、user_copy_advanced之类的后缀就是运行时根据boot_cpu_has做的函数指针分派。这块不用看太深只需知道copy_to_user的耗时不止在数据搬运还包括权限检查与可能触发的缺页处理。3. 源码视角copy_to_user三兄弟的分工与调用关系3.1 从copy_to_user源码看检查顺序以较新内核为例核心调用关系可以浓缩成下面这段逻辑简化版static __always_inline unsigned long __must_check copy_to_user(void __user *to, const void *from, unsigned long n) { might_fault(); return _copy_to_user(to, from, n); } static inline unsigned long _copy_to_user(void __user *to, const void *from, unsigned long n) { if (should_fail_usercopy()) // fault-injection测试用 return n; if (access_ok(to, n)) { instrument_copy_to_user(to, from, n); n raw_copy_to_user(to, from, n); } return n; }注意__must_check标志它在编译期强制你必须检查返回值。返回值是未拷贝成功的字节数不是错误码。如果用户空间缓冲区长度不够或者地址无效它返回剩余字节数。所以常见写法if (copy_to_user(user_buf, kernel_buf, count)) return -EFAULT;意思是一旦有字节没拷成功就返回-EFAULT给应用层。但也有人故意不检查并直接返回成功比如某些对性能敏感的路径但这属于风险操作不推荐新手效仿。3.2 为什么有raw_copy_to_user这个接口raw_copy_to_user通常通过架构相关代码实现平台上有对应的汇编拷贝循环。它做的事很纯粹真正去搬数据并把未完成拷贝的字节数返回。它不检查地址范围、不做might_fault、也不做KASAN等插桩。因此它要求调用方保证access_ok已经通过。instrument_copy_to_user是KASAN和KMSAN用来追踪内核数据拷贝的hook点如果你打开了CONFIG_KASAN这里会插入检查逻辑防止内核地址泄漏到用户态。这也是为什么内核社区反复强调不要自己写裸拷贝绕过这个check否则直接暴露内核指针给用户态等于帮攻击者减小了内核基址随机化的作用。3.3 什么时候用copy_to_user什么时候用put_usercopy_to_user处理多字节缓冲区put_user处理单个原子值一个int、一个long等。put_user的语义更简单不需要传入长度自动根据类型决定拷贝2/4/8字节并且保证在支持的架构上单值拷贝是原子的。多好的场景用哪个拷贝结构体、字符串、数组、大数据块 →copy_to_user返回一个ioctl状态码、锁状态、单个寄存器值、错误代号 →put_user(x, user_ptr)这两个接口都有对应的__get_user/__put_user无检查版本用在已经确认地址合法、且不想多付出access_ok开销的高速路径上。注意get_user/put_user也有__must_check但返回的不是未拷贝字节数而是一个错误码0表示成功-EFAULT表示失败。3.4 为什么返回值和错误码容易被搞混copy_to_user返回的是剩余字节数get_user返回的是错误码两个完全不同的约定写代码时很容易串。如果你的read回调直接返回copy_to_user的返回值给VFS层VFS会把正值当作已读字节数0当EOF负值当作错误。假如copy_to_user因地址非法返回了整个count比如count是4096read返回4096应用层就以为成功读到了4096字节但实际上什么都没拷进去缓冲区内容是不可信的。所以正确的写法是先判断剩余字节数是否为0非0再映射为错误码unsigned long ret copy_to_user(buf, kbuf, len); if (ret) { pr_err(copy_to_user failed, %lu bytes not copied\n, ret); return -EFAULT; }4. 实战手写一个只读设备驱动把内核数据安全交到用户态4.1 设备设计思路为了演示copy_to_user的完整调用流程我写一个极简的内核信息读取设备内部维护一个固定长度的状态字符串用户态通过read系统调用读取它。这个驱动没有任何真实硬件依赖跑在任何Linux开发机上都能编译测试适合做第一课的实验对象。为什么选这个场景因为read回调是copy_to_user出现频率最高的地方也是很多入门驱动唯一需要实现的方法。把这个路径吃透后面遇到ioctl、splice、scatter-gather等复杂路径思路是一样的。4.2 驱动代码// kbuf_dev.c #include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/slab.h #define DEVICE_NAME kbuf #define CLASS_NAME kbuf_class #define BUF_SIZE 128 static int major_num; static struct class *kbuf_class; static struct cdev kbuf_cdev; static dev_t dev_num; static char kernel_data[BUF_SIZE] hello from kernel\n; static int kernel_data_len 18; static ssize_t kbuf_read(struct file *filp, char __user *user_buf, size_t count, loff_t *offset) { size_t available kernel_data_len - *offset; size_t to_copy; unsigned long ret; pr_info(kbuf_read: offset%lld, count%zu\n, *offset, count); if (*offset kernel_data_len) return 0; // EOF if (count available) to_copy available; else to_copy count; if (copy_to_user(user_buf, kernel_data *offset, to_copy)) { pr_err(kbuf_read: copy_to_user failed\n); return -EFAULT; } *offset to_copy; return to_copy; } static struct file_operations kbuf_fops { .owner THIS_MODULE, .read kbuf_read, }; static int __init kbuf_init(void) { int ret; ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(kbuf: alloc_chrdev_region failed\n); return ret; } cdev_init(kbuf_cdev, kbuf_fops); ret cdev_add(kbuf_cdev, dev_num, 1); if (ret 0) { pr_err(kbuf: cdev_add failed\n); goto err_unregister; } kbuf_class class_create(CLASS_NAME); if (IS_ERR(kbuf_class)) { ret PTR_ERR(kbuf_class); goto err_cdev_del; } device_create(kbuf_class, NULL, dev_num, NULL, DEVICE_NAME); major_num MAJOR(dev_num); pr_info(kbuf: loaded, major%d\n, major_num); return 0; err_cdev_del: cdev_del(kbuf_cdev); err_unregister: unregister_chrdev_region(dev_num, 1); return ret; } static void __exit kbuf_exit(void) { device_destroy(kbuf_class, dev_num); class_destroy(kbuf_class); cdev_del(kbuf_cdev); unregister_chrdev_region(dev_num, 1); pr_info(kbuf: unloaded\n); } module_init(kbuf_init); module_exit(kbuf_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(your name); MODULE_DESCRIPTION(copy_to_user demo driver);代码里几个关键点*offset代表当前文件读位置驱动需要自己维护copy_to_user不负责管offsetVFS层只把初始值传进来后续更新完全靠驱动自己。count available时只拷贝剩余长度这是保证不越界的标准做法。EOF返回0应用层才知道读完了。真正的拷贝工作就那一行copy_to_user但返回值的处理不能省。4.3 用户态测试程序// test_kbuf.c #include stdio.h #include fcntl.h #include unistd.h #include string.h #include errno.h int main(void) { int fd open(/dev/kbuf, O_RDONLY); if (fd 0) { perror(open); return 1; } char buf[64]; ssize_t n read(fd, buf, sizeof(buf) - 1); if (n 0) { perror(read); close(fd); return 1; } buf[n] \0; printf(read %zd bytes: %s\n, n, buf); close(fd); return 0; }Makefileobj-m kbuf_dev.o all: make -C /lib/modules/$(shell uname -r)/build M$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M$(PWD) clean4.4 编译运行与验证make sudo insmod kbuf_dev.ko dmesg | tail # 看major号 sudo mknod /dev/kbuf c major 0 gcc -o test_kbuf test_kbuf.c ./test_kbuf一次正常运行的输出read 18 bytes: hello from kerneldmesg里能看到kbuf: loaded, major240 kbuf_read: offset0, count63如果用户态缓冲区太小只给一个字节read回调会只拷贝一个字节并更新offset下一次read从offset1继续读。这就是普通文件的顺序读语义在驱动里的实现方式全部依赖你对offset的管理。4.5 如果故意把user_buf传一个非法地址想验证copy_to_user的异常保护能力可以在用户态用mmap映射一段只读内存再试图写或者干脆传入一个不可能的用户态地址。实际上为了安全测试时可以这样char *bad (char *)0xdead0000; ssize_t n read(fd, bad, 10);此时access_ok大概率直接拒绝地址接近TASK_SIZE边界就通不过copy_to_user返回10你的驱动把它映射成-EFAULT返回。应用层perror显示Bad address但内核不会oops。这就是这套机制的价值可控的错误路径而不是内核崩溃。5. 踩坑实录驱动开发里copy_to_user常见的六种翻车现场5.1 翻车一在自旋锁保护下调用copy_to_user我见过不少新手在驱动里为保护共享数据用了spin_lock然后在这个临界区里调用copy_to_user。spin_lock会禁用抢占而copy_to_user可能因为用户页不在内存而睡眠睡眠时不能持自旋锁否则其他CPU上的等待者永远转不出去死锁无法避免。排查这个问题的典型线索系统日志出现BUG: sleeping function called from invalid context at mm/filemap.c并且括弧里能看到spin_lock字样。解决办法把共享数据先拷贝到临时内核缓冲区释放锁再在锁外执行copy_to_user。数据一致性由临时缓冲区保护不必要在临界区做用户态拷贝。这在本质上不是copy_to_user的问题而是临界区设计问题但它确实是最容易踩的组合。5.2 翻车二返回值处理不当应用层读到脏数据常见代码return copy_to_user(ubuf, kbuf, len);如果len正好是用户给的count且用户缓冲区小于lencopy_to_user返回剩余字节数不为0read返回一个正数。应用层以为读到了这么多数据但缓冲区实际是被用户态旧数据污染的。这就需要按照前面说的非零返回值映射成-EFAULT。反过来如果你在copy_to_user失败时直接return ret;这里的ret是个正数剩余字节数应用层不会认为出错进而可能导致后续逻辑错误。很多人调试半天最后发现是自己把返回值约定搞混了。5.3 翻车三地址没经过user指针检查就解引用有些代码喜欢把ioctl传来的用户态地址存到内核驱动的全局变量里下次read时直接拿来做源地址或者目标地址。问题在于用户态地址在当前进程上下文有效不代表在另一个进程上下文也有效内核线程没有用户地址空间甚至根本无法解引用。正确姿势每次系统调用传入的用户指针只在本次调用生命周期内有效不要存储后跨调用使用。如果必须缓存需要用get_user_pages或pin_user_pages固定物理页并处理好生命周期。copy_to_user的目标地址也一样它操作的是当前进程的地址空间不能传入其他进程的mm中的地址。5.4 翻车四在中断上下文或原子上下文拷贝大块数据中断处理程序里绝对不能调用copy_to_user因为中断上下文没有用户进程的mm概念也没有might_fault的睡眠条件。此时要用__copy_to_user_inatomic或copy_to_user_inatomic并且前提是目标内存区域已经被get_user_pages固定且映射好否则即使inatomic版本也只是尽量访问缺页时依然无法睡眠处理只能返回未拷贝的数量。这就是为什么网络驱动收发路径中很少直接在软中断里调用copy_to_user的原因通常都是把数据排到队列里唤醒内核线程或等待用户进程下次read时再拷贝。5.5 翻车五偏移量和count没夹紧导致越界读如果read回调里没有检查*offset count是否超过内核缓冲区长度直接把kernel_data *offset传给copy_to_user就可能把缓冲区后面的内核数据泄露给用户态。这种漏洞属于信息泄露类内核社区对这种问题零容忍。所以在拷贝前一定要计算可用长度确保*offset和to_copy都不越界。很多CVE都是类似场景驱动开发者以为用户态传入的size是安全的但没考虑偏移量累加后越界。规则很简单任何内核缓冲区的拷贝都必须同时验证偏移量和拷贝长度。5.6 翻车六access_ok被__copy_to_user绕过的场景如果你图性能用了__copy_to_user但又没提前调用access_ok那等于把安检门拆了。正常情况下不推荐使用无检查版本除非你明确知道地址是可信的。内核测试模块有时候会直接用__copy_to_user那是因为测试代码已经确认了地址合法范围。我见过有人为了快几纳秒在ioctl里直接__copy_to_user结果应用层传入一个内核地址直接触发SMAP异常或者Oops。内核编程里性能优化和安全检查要分开看拷贝大块数据这类低频操作省掉access_ok的收益微乎其微但安全风险是实打实的。5.7 排查思路从Oops日志定位copy_to_user问题真出了问题第一步是看dmesg里的Oops信息。关键字段包括BUG:开头的说明是unable to handle kernel paging request还是sleeping function called from invalid contextRIP:指向的指令地址和函数名比如my_read0x12/0x50 [my_driver]Call Trace:显示调用栈能看出从哪个系统调用进来的Code:后面的机器码在my_read0x12这样的信息里0x12表示函数内偏移用addr2line可以换算成源码行号addr2line -e /usr/lib/debug/lib/modules/$(uname -r)/kernel/drivers/misc/my_driver.ko -f -i 0x12如果ko没有-g编译或者没有debug symbol就先加ccflags-y -g重新编译。大部分时候看了RIP的偏移就知道是copy_to_user行还是memcpy行出问题了。6. 选型与进阶copy_to_user之外还有哪些数据通道6.1 和copy_from_user在方向上的对称性copy_from_user逻辑完全对称但要注意方向语义copy_from_user(to_kernel, from_user, n)第一个参数是内核目标地址第二个参数是用户源地址。它内部同样做access_ok检查返回剩余未拷贝字节数。读用户传入的结构体时必须先拷到内核缓冲区再读取字段防止TOCTOUtime-of-check to time-of-use问题即先检查用户内存、后再访问时用户内存内容已变。6.2 大数据量场景的替代方案mmap和splice如果每次read都需要拷贝上百MB数据copy_to_user的反复拷贝会成为瓶颈。此时可以考虑让驱动实现mmap直接把内核分配的物理页映射给用户态省掉数据拷贝。还有一种做法是用splice配合管道完成零拷贝传递但驱动侧要做splice_read的适配复杂度远高于copy_to_user。我的建议是性能调优时先测量不要过早放弃copy_to_user。对大多数设备来说copy_to_user是一条经过高度优化的路径数据量在几十KB级别时开销完全可以接受。只有真实测量显示拷贝占了大头再考虑mmap或splice不迟。6.3 与put_user的配合使用ioctl返回值设计设计ioctl接口时经常需要同时返回一个错误码和一个状态值。常见写法if (put_user(status, (int __user *)arg)) return -EFAULT; return 0;这种做法把status和错误码分离应用层先判断返回值不为0表示系统调用错误为0时再去读arg指向的status语义清晰。相比之下如果混用copy_to_user和put_user人容易在返回码上犯糊涂。6.4 我个人的编程习惯写了这么多年代码个人总结了几条关于copy_to_user的使用规矩分享给后来者一是所有用户态指针参数进函数第一时间就标注为__user并提醒自己这不是裸指针。二是copy_to_user返回非零一律交给调用者处理返回-EFAULT不要吞掉错误。三是绝不在自旋锁、中断、preempt_disable区域调用标准版copy_to_user。四是拷贝前反复确认内核缓冲区的边界和偏移。五是真的需要在高频路径用__copy_to_user也要配合access_ok并加上完整注释说明为什么这里安全。copy_to_user看起来只是一层封装背后是内核在可扩展性、安全性、性能之间的精密平衡。把这个机制看透了你写驱动时遇到的其他用户态交互接口比如get_user_pages、iov_iter、process_vm_readv理解起来都会顺畅很多。它应当是每个内核模块开发者工具箱里最先吃透的工具之一。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。