Linux의 copy_{to, from}_user()에 대한 심층적인 이해(코드 포함)
copy_{to, from}_user() 인터페이스 사용 이보다 더 친숙할 수는 없습니다. 기본 리눅스 책에서는 그 기능을 소개합니다. 결국 이는 커널 공간과 사용자 공간 사이의 다리입니다. 모든 데이터 상호작용은 이와 같은 인터페이스를 사용해야 합니다. 그러므로 인터페이스의 역할을 모를 이유가 없습니다. 그러나 나는 또한 다음과 같은 질문을 받았습니다.
왜 copy_{to,from}_user()가 필요한가요?
copy_{to,from}_user()와 memcpy()의 차이점은 무엇인가요? memcpy()를 직접 사용할 수 있나요?
memcpy()가 copy_{to,from}_user()를 대체하면 문제가 있나요?
갑자기 혼란스러웠던 모습을 되찾았습니다. 내가 물어본 모든 질문은 이전에 생각해 본 적이 있는 것이었습니다. 나는 그것에 대해 여러 번 생각했고 매번 다른 생각이 들었습니다. 물론 처음부터 완전히 이해하지는 못했기 때문이다. 이제 나는 다시 이 무거운 주제로 돌아가서 이 과거의 문제에 대해 계속해서 생각합니다.
위의 문제에 대해서는 물론 Baidu가 첫 번째가 되어야 합니다. Baidu에는 또한 이 문제에 대한 많은 블로그가 있는데, 이는 이 문제가 수많은 Linux 매니아들을 혼란스럽게 하고 있음을 보여주기에 충분합니다. 내 검토 결과와 관련하여 의견은 주로 다음 두 가지 유형으로 나뉩니다.
1. copy_{to,from}_user()는 memcpy()보다 수신 주소 적법성 검사가 더 많습니다.
예를 들어 사용자 공간 주소 범위에 속하는지 여부입니다. 이론적으로 커널 공간은 사용자 공간에서 전달된 포인터를 직접 사용할 수 있습니다. 데이터를 복사하려는 경우에도 실제로 MMU가 없는 아키텍처에서는 copy_{to,from}_user()를 사용할 수도 있습니다. 최종 구현에서는 mencpy()를 사용합니다.
그러나 MMU가 있는 대부분의 플랫폼에서는 상황이 바뀌었습니다. 사용자 공간에서 전달된 포인터는 가상 주소 공간에 있고, 그것이 가리키는 가상 주소 공간은 실제로 실제 물리적 페이지에 매핑되지 않을 수 있습니다. 하지만 그래서 어쩌죠? 페이지 오류로 인해 발생한 예외는 커널에 의해 투명하게 복구되며(오류가 발생한 페이지의 주소 공간에 대한 새 물리적 페이지 제출) 오류가 발생한 페이지에 액세스하는 명령은 아무 일도 일어나지 않은 것처럼 계속 실행됩니다. 그러나 이는 사용자 공간에서 발생하는 페이지 누락 예외의 동작일 뿐입니다. 커널 공간에서 이 페이지 누락 예외는 커널에서 제공하는 페이지 누락 예외 처리 기능의 설계 패턴에 따라 결정됩니다.
그 뒤에 있는 아이디어는 커널 모드에서 프로그램이 아직 물리적 페이지를 커밋하지 않은 사용자 공간 주소에 액세스하려고 시도하는 경우 커널이 이에 대해 경고해야 하며 사용자 공간처럼 이를 인식하지 못할 수 없다는 것입니다.
2. 사용자 모드에서 전달된 포인터의 정확성을 보장하면 copy_{to,from}_user() 대신 memcpy() 함수를 완전히 사용할 수 있습니다. 몇 가지 실험과 테스트 후에 memcpy()를 사용하여 실행되는 프로그램에는 문제가 없다는 것을 발견했습니다. 따라서 사용자 모드 포인터의 안전성을 보장하면서 두 가지를 교체할 수 있습니다.
여러 블로그에서 주로 첫 번째 점에 대한 의견이 집중되어 있습니다. 첫 번째 요점은 모두가 널리 인정하는 것 같습니다. 그러나 실천에 관심을 기울이는 사람들은 결국 실천이 참된 지식을 가져온다는 두 번째 관점에 이르렀습니다. 진실은 소수의 손에 달려있나요? 아니면 대중의 눈이 예리한가? 물론 위의 견해를 부정하는 것은 아닙니다. 어떤 견해가 옳다고 보장할 수는 없습니다. 한때 완벽했던 이론이라도 시간이 흐르거나 특정 상황이 바뀌면 더 이상 정확하지 않을 수 있다고 믿기 때문입니다. 예를 들어, 뉴턴의 고전 역학 이론(조금 멀게 보입니다). 제가 인간의 말로 표현하자면, 시간이 지남에 따라 Linux 코드는 끊임없이 변화하고 있다는 것입니다. 아마도 위의 관점이 한 번은 맞았을 것입니다. 물론 지금도 사실일 수 있다. 다음 분석은 나의 관점이다. 마찬가지로 모든 사람은 회의적인 태도를 유지해야 합니다. 이제 몇 가지 아이디어를 드리겠습니다.
먼저 memcpy()와 copy_{to,from}_user()의 함수 정의를 살펴보겠습니다. 매개변수에는 거의 차이가 없으며 모두 대상 주소, 소스 주소 및 복사할 바이트 크기를 포함합니다.
static __always_inline unsigned long __must_check copy_to_user(void __user *to, const void *from, unsigned long n); static __always_inline unsigned long __must_check copy_from_user(void *to, const void __user *from, unsigned long n); void *memcpy(void *dest, const void *src, size_t len);
그런데, 우리가 확실히 아는 게 하나 있어요. 즉, memcpy()는 수신 주소의 유효성을 확인하지 않습니다. 그리고 copy_{to,from}_user()는 수신 주소에 대해 다음과 유사한 적법성 검사를 수행합니다(간단히 말하면 자세한 확인 내용은 코드를 참조하세요).
데이터가 사용자 공간에서 커널 공간으로 복사되는 경우 사용자 공간 주소와 복사된 바이트 길이 n이 사용자 공간 주소 공간에 있어야 합니다.
커널 공간에서 사용자 공간으로 데이터를 복사한다면 당연히 주소의 적법성도 확인해야 합니다. 예를 들어 범위를 벗어난 액세스인지, 코드 세그먼트의 데이터인지 등입니다. 즉, 모든 불법행위는 즉각 중단되어야 합니다.
经过简单的对比之后,我们再看看其他的差异以及一起探讨下上面提出的2个观点。我们先从第2个观点说起。涉及实践,我还是有点相信实践出真知。从我测试的结果来说,实现结果分成两种情况。
第一种情况的结果是:使用memcpy()测试,没有出现问题,代码正常运行。测试代码如下(仅仅展示proc文件系统下file_operations对应的read接口函数):
static ssize_t test_read(struct file *file, char __user *buf, size_t len, loff_t *offset) { memcpy(buf, "test\n", 5); /* copy_to_user(buf, "test\n", 5) */ return 5; }
我们使用cat命令读取文件内容,cat会通过系统调用read调用test_read,并且传递的buf大小是4k。
测试很顺利,结果很喜人。成功地读到了“test”字符串。看起来,第2点观点是没毛病的。但是,我们还需要继续验证和探究下去。因为第1个观点提到,“在内核空间这种缺页异常必须被显式地修复”。
因此我们还需要验证的情况是:如果buf在用户空间已经分配虚拟地址空间,但是并没有建立和物理内存的具体映射关系,这种情况下会出现内核态page fault。我们首先需要创建这种条件,找到符合的buf,然后测试。这里我当然没测啦。因为有测试结论(主要是因为我懒,构造这个条件我觉得比较麻烦)。
这个测试是我的一个朋友,人称宋老师的“阿助教”阿克曼大牛。他曾经做个这个实验,并且得到的结论是:即使是没有建立和物理内存的具体映射关系的buf,代码也可以正常运行。在内核态发生page fault,并被其修复(分配具体物理内存,填充页表,建立映射关系)。同时,我从代码的角度分析,结论也是如此。
经过上面的分析,看起来好像是memcpy()也可以正常使用,鉴于安全地考虑建议使用copy_{to,from}_user()等接口。
第二种情况的结果是:以上的测试代码并没有正常运行,并且会触发kernel oops。当然本次测试和上次测试的kernel配置选项是不一样的。这个配置项是 <span class="pln">CONFIG_ARM64_SW_TTBR0_PAN</span>
或者 <span class="pln">CONFIG_ARM64_PAN</span>
(针对ARM64平台)。两个配置选项的功能都是阻止内核态直接访问用户地址空间。只不过CONFIG_ARM64_SW_TTBR0_PAN是软件仿真实现这种功能,而CONFIG_ARM64_PAN是硬件实现功能(ARMv8.1扩展功能)。我们以CONFIG_ARM64_SW_TTBR0_PAN作为分析对象(软件仿真才有代码提供分析)。BTW,如果硬件不支持,即使配置CONFIG_ARM64_PAN也没用,只能使用软件仿真的方法。如果需要访问用户空间地址需要通过类似copy_{to,from}_user()的接口,否则会导致kernel oops。
在打开CONFIG_ARM64_SW_TTBR0_PAN的选项后,测试以上代码就会导致kernel oops。原因就是内核态直接访问了用户空间地址。因此,在这种情况我们就不可以使用memcpy()。我们别无选择,只能使用copy_{to,from}_user()。
为什么我们需要PAN(Privileged Access Never)功能呢?原因可能是用户空间和内核空间数据交互上容易引入安全问题,所以我们就不让内核空间轻易访问用户空间,如果非要这么做,就必须通过特定的接口关闭PAN。另一方面,PAN功能可以更加规范化内核态和用户态数据交互的接口使用。在使能PAN功能的情况下,可以迫使内核或者驱动开发者使用copy_{to,from}_user()等安全接口,提升系统的安全性。类似memcpy()非规范操作,kernel就oops给你看。
由于编程的不规范而引入安全漏洞。例如:Linux内核漏洞CVE-2017-5123可以提升权限。该漏洞的引入原因就是是缺少access_ok()检查用户传递地址的合法性。因此,为了避免自己编写的代码引入安全问题,针对内核空间和用户空间数据交互上,我们要格外当心。
既然提到了CONFIG_ARM64_SW_TTBR0_PAN的配置选项。当然我也希望了解其背后设计的原理。由于ARM64的硬件特殊设计,我们使用两个页表基地址寄存器ttbr0_el1和ttbr1_el1。处理器根据64 bit地址的高16 bit判断访问的地址属于用户空间还是内核空间。如果是用户空间地址则使用ttbr0_el1,反之使用ttbr1_el1。因此,ARM64进程切换的时候,只需要改变ttbr0_el1的值即可。ttbr1_el1可以选择不需要改变,因为所有的进程共享相同的内核空间地址。
当进程切换到内核态(中断,异常,系统调用等)后,如何才能避免内核态访问用户态地址空间呢?其实不难想出,改变ttbr0_el1的值即可,指向一段非法的映射即可。因此,我们为此准备了一份特殊的页表,该页表大小4k内存,其值全是0。当进程切换到内核态后,修改ttbr0_el1的值为该页表的地址即可保证访问用户空间地址是非法访问。因为页表的值是非法的。这个特殊的页表内存通过链接脚本分配。
#define RESERVED_TTBR0_SIZE (PAGE_SIZE) SECTIONS { reserved_ttbr0 = .; . += RESERVED_TTBR0_SIZE; swapper_pg_dir = .; . += SWAPPER_DIR_SIZE; swapper_pg_end = .; }
这个特殊的页表和内核页表在一起。和swapper_pg_dir仅仅差4k大小。reserved_ttbr0地址开始的4k内存空间的内容会被清零。
当我们进入内核态后会通过__uaccess_ttbr0_disable切换ttbr0_el1以关闭用户空间地址访问,在需要访问的时候通过_uaccess_ttbr0_enable打开用户空间地址访问。这两个宏定义也不复杂,就以_uaccess_ttbr0_disable为例说明原理。其定义如下:
.macro __uaccess_ttbr0_disable, tmp1 mrs \tmp1, ttbr1_el1 // swapper_pg_dir (1) bic \tmp1, \tmp1, #TTBR_ASID_MASK sub \tmp1, \tmp1, #RESERVED_TTBR0_SIZE // reserved_ttbr0 just before // swapper_pg_dir (2) msr ttbr0_el1, \tmp1 // set reserved TTBR0_EL1 (3) isb add \tmp1, \tmp1, #RESERVED_TTBR0_SIZE msr ttbr1_el1, \tmp1 // set reserved ASID isb .endm
ttbr1_el1存储的是内核页表基地址,因此其值就是swapper_pg_dir。
swapper_pg_dir减去RESERVED_TTBR0_SIZE就是上面描述的特殊页表。
将ttbr0_el1修改指向这个特殊的页表基地址,当然可以保证后续访问用户地址都是非法的。
__uaccess_ttbr0_disable对应的C语言实现可以参考这里。
如何允许内核态访问用户空间地址呢?也很简单,就是__uaccess_ttbr0_disable的反操作,给ttbr0_el1赋予合法的页表基地址。这里就不必重复了。
我们现在需要知道的事实就是,在配置CONFIG_ARM64_SW_TTBR0_PAN的情况下,copy_{to,from}_user()接口会在copy之前允许内核态访问用户空间,并在copy结束之后关闭内核态访问用户空间的能力。因此,使用copy_{to,from}_user()才是正统做法。主要体现在安全性检查及安全访问处理。这里是其比memcpy()多的第一个特性,后面还会介绍另一个重要特性。
现在我们可以解答上一节中遗留的问题。怎样才能继续使用memcpy()?现在就很简单了,在memcpy()调用之前通过uaccess_enable_not_uao()允许内核态访问用户空间地址,调用memcpy(),最后通过uaccess_disable_not_uao()关闭内核态访问用户空间的能力。
以上的测试用例都是建立在用户空间传递合法地址的基础上测试的,何为合法的用户空间地址?
用户空间通过系统调用申请的虚拟地址空间包含的地址范围,即是合法的地址(不论是否分配物理页面建立映射关系)。既然要写一个接口程序,当然也要考虑程序的健壮性,我们不能假设所有的用户传递的参数都是合法的。我们应该预判非法传参情况的发生,并提前做好准备,这就是未雨绸缪。
我们首先使用memcpy()的测试用例,随机传递一个非法的地址。经过测试发现:会触发kernel oops。继续使用copy_{to,from}_user()替代memcpy()测试。
测试发现:read()仅仅是返回错误,但不会触发kernel oops。这才是我们想要的结果。毕竟,一个应用程序不应该触发kernel oops。这种机制的实现原理是什么呢?
我们以copy_to_user()为例分析。函数调用流程是:
copy_to_user()->_copy_to_user()->raw_copy_to_user()->__arch_copy_to_user()
_arch_copy_to_user()在ARM64平台是汇编代码实现,这部分代码很关键。
end .req x5 ENTRY(__arch_copy_to_user) uaccess_enable_not_uao x3, x4, x5 add end, x0, x2 #include "copy_template.S" uaccess_disable_not_uao x3, x4 mov x0, #0 ret ENDPROC(__arch_copy_to_user) .section .fixup,"ax" .align 2 9998: sub x0, end, dst // bytes not copied ret .previous
uaccess_enable_not_uao和uaccess_disable_not_uao是上面说到的内核态访问用户空间的开关。
copy_template.S文件是汇编实现的memcpy()的功能,稍后看看memcpy()的实现代码就清楚了。
<span class="pun">.section.fixup,“ax”</span>
定义一个section,名为“.fixup”,权限是ax(‘a’可重定位的段,‘x’可执行段)。<span class="lit">9998</span>
标号处的指令就是“未雨绸缪”的善后处理工作。还记得copy_{to,from}_user()返回值的意义吗?返回0代表copy成功,否则返回剩余没有copy的字节数。这行代码就是计算剩余没有copy的字节数。当我们访问非法的用户空间地址的时候,就一定会触发page fault。这种情况下,内核态发生的page fault并返回的时候并没有修复异常,所以肯定不能返回发生异常的地址继续运行。所以,系统可以有2个选择:第1个选择是kernel oops,并给当前进程发送SIGSEGV信号;第2个选择是不返回出现异常的地址运行,而是选择一个已经修复的地址返回。如果使用的是memcpy()就只有第1个选择。但是copy_{to,from}_user()可以有第2个选择。<span class="pun">.fixup</span>
段就是为了实现这个修复功能。当copy过程中出现访问非法用户空间地址的时候,do_page_fault()返回的地址变成<span class="lit">9998</span>
标号处,此时可以计算剩余未copy的字节长度,程序还可以继续执行。
对比前面分析的结果,其实_arch_copy_to_user()可以近似等效如下关系。
uaccess_enable_not_uao(); memcpy(ubuf, kbuf, size); == __arch_copy_to_user(ubuf, kbuf, size); uaccess_disable_not_uao();
先插播一条消息,解释copy_template.S为何是memcpy()。memcpy()在ARM64平台是由汇编代码实现。其定义在arch/arm64/lib/memcpy.S文件。
.weak memcpy ENTRY(__memcpy) ENTRY(memcpy) #include "copy_template.S" ret ENDPIPROC(memcpy) ENDPROC(__memcpy)
所以很明显,memcpy()和__memcpy()函数定义是一样的。并且memcpy()函数声明是weak,因此可以重写memcpy()函数(扯得有点远)。再扯一点,为何使用汇编呢?为何不使用lib/string.c文件的memcpy()函数呢?当然是为了优化memcpy() 的执行速度。lib/string.c文件的memcpy()函数是按照字节为单位进行copy(再好的硬件也会被粗糙的代码毁掉)。
但是现在的处理器基本都是32或者64位,完全可以4 bytes或者8 bytes甚至16 bytes copy(考虑地址对齐的情况下)。可以明显提升执行速度。所以,ARM64平台使用汇编实现。这部分知识可以参考这篇博客《ARM64 的 memcpy 优化与实现》。
下面继续进入正题,再重复一遍:内核态访问用户空间地址,如果触发page fault,只要用户空间地址合法,内核态也会像什么也没有发生一样修复异常(分配物理内存,建立页表映射关系)。但是如果访问非法用户空间地址,就选择第2条路,尝试救赎自己。这条路就是利用 <span class="pun">.fixup</span>
和 <span class="pln">__ex_table</span>
段。
如果无力回天只能给当前进程发送SIGSEGV信号。并且,轻则kernel oops,重则panic(取决于kernel配置选项CONFIG_PANIC_ON_OOPS)。在内核态访问非法用户空间地址的情况下,do_page_fault()最终会跳转 <span class="pln">no_context</span>
标号处的do_kernel_fault()。
static void __do_kernel_fault(unsigned long addr, unsigned int esr, struct pt_regs *regs) { /* * Are we prepared to handle this kernel fault? * We are almost certainly not prepared to handle instruction faults. */ if (!is_el1_instruction_abort(esr) && fixup_exception(regs)) return; /* ... */ }
fixup_exception()继续调用search_exception_tables(),其通过查找_extable段。__extable段存储exception table,每个entry存储着异常地址及其对应修复的地址。
例如上述的 <span class="lit">9998:subx0,end,dst</span>
指令的地址就会被找到并修改do_page_fault()函数的返回地址,以达到跳转修复的功能。其实查找过程是根据出问题的地址addr,查找_extable段(exception table)是否有对应的exception table entry,如果有就代表可以被修复。由于32位处理器和64位处理器实现方式有差别,因此我们先从32位处理器异常表的实现原理说起。
_extable段的首尾地址分别是 <span class="pln">__start___ex_table</span>
和 <span class="pln">__stop___ex_table</span>
(定义在include/asm-generic/vmlinux.lds.h。这段内存可以看作是一个数组,数组的每个元素都是 <span class="kwd">struct exception_table_entry</span>
类型,其记录着异常发生地址及其对应的修复地址。
exception tables __start___ex_table --> +---------------+ | entry | +---------------+ | entry | +---------------+ | ... | +---------------+ | entry | +---------------+ | entry | __stop___ex_table --> +---------------+
在32位处理器上,struct exception_table_entry定义如下:
struct exception_table_entry { unsigned long insn, fixup; };
有一点需要明确,在32位处理器上,unsigned long是4 bytes。insn和fixup分别存储异常发生地址及其对应的修复地址。根据异常地址ex_addr查找对应的修复地址(未找到返回0),其示意代码如下:
unsigned long search_fixup_addr32(unsigned long ex_addr) { const struct exception_table_entry *e; for (e = __start___ex_table; e < __stop___ex_table; e++) if (ex_addr == e->insn) return e->fixup; return 0; }
在32位处理器上,创建exception table entry相对简单。针对copy{to,from}user()汇编代码中每一处用户空间地址访问的指令都会创建一个entry,并且insn存储当前指令对应的地址,fixup存储修复指令对应的地址。
当64位处理器开始发展起来,如果我们继续使用这种方式,势必需要2倍于32位处理器的内存存储exception table(因为存储一个地址需要8 bytes)。所以,kernel换用另一种方式实现。在64处理器上,struct exception_table_entry定义如下:
struct exception_table_entry { int insn, fixup; };
每个exception table entry占用的内存和32位处理器情况一样,因此内存占用不变。但是insn和fixup的意义发生变化。insn和fixup分别存储着异常发生地址及修复地址相对于当前结构体成员地址的偏移(有点拗口)。例如,根据异常地址ex_addr查找对应的修复地址(未找到返回0),其示意代码如下:
unsigned long search_fixup_addr64(unsigned long ex_addr) { const struct exception_table_entry *e; for (e = __start___ex_table; e < __stop___ex_table; e++) if (ex_addr == (unsigned long)&e->insn + e->insn) return (unsigned long)&e->fixup + e->fixup; return 0; }
因此,我们的关注点就是如何去构建exception_table_entry。我们针对每个用户空间地址的内存访问都需要创建一个exception table entry,并插入_extable段。例如下面的汇编指令(汇编指令对应的地址是随意写的,不用纠结对错。理解原理才是王道)。
0xffff000000000000: ldr x1, [x0] 0xffff000000000004: add x1, x1, #0x10 0xffff000000000008: ldr x2, [x0, #0x10] /* ... */ 0xffff000040000000: mov x0, #0xfffffffffffffff2 // -14 0xffff000040000004: ret
假设x0寄存器保存着用户空间地址,因此我们需要对0xffff000000000000地址的汇编指令创建一个exception table entry,并且我们期望当x0是非法用户空间地址时,跳转返回的修复地址是0xffff000040000000。为了计算简单,假设这是创建第一个entry, <span class="pln">__start___ex_table</span>
值是0xffff000080000000。那么第一个exception table entry的insn和fixup成员的值分别是:0x80000000和0xbffffffc(这两个值都是负数)。因此,针对copy{to,from}user()汇编代码中每一处用户空间地址访问的指令都会创建一个entry。所以0xffff000000000008地址处的汇编指令也需要创建一个exception table entry。
所以,如果内核态访问非法用户空间地址究竟发生了什么?上面的分析流程可以总结如下:
访问非法用户空间地址:
0xffff000000000000:ldr x1,[x0]
MMU触发异常
CPU调用do_page_fault()
do_page_fault()调用search_exception_table()(regs->pc == 0xffff000000000000)
查看_extable段,寻找0xffff000000000000 并且返回修复地址0xffff000040000000
do_page_fault()修改函数返回地址(regs->pc = 0xffff000040000000)并返回
程序继续执行,处理出错情况
修改函数返回值x0 = -EFAULT (-14) 并返回(ARM64通过x0传递函数返回值)
到了回顾总结的时候,copy_{to,from}_user()的思考也到此结束。我们来个总结结束此文。
无论是内核态还是用户态访问合法的用户空间地址,当虚拟地址并未建立物理地址的映射关系的时候,page fault的流程几乎一样,都会帮助我们申请物理内存并创建映射关系。所以这种情况下memcpy()和copy_{to,from}_user()是类似的。
当内核态访问非法用户空间地址的时候,根据异常地址查找修复地址。这种修复异常的方法并不是建立地址映射关系,而是修改do_page_fault()返回地址。而memcpy()无法做到这点。
在使能 <span class="pln">CONFIG_ARM64_SW_TTBR0_PAN</span>
或者 <span class="pln">CONFIG_ARM64_PAN</span>
(硬件支持的情况下才有效)的时候,我们只能使用copy_{to,from}_user()这种接口,直接使用memcpy()是不行的。
最后,我想说,即使在某些情况下memcpy()可以正常工作。但是,这也是不推荐的,不是良好的编程习惯。在用户空间和内核空间数据交互上,我们必须使用类似copy_{to,from}_user()的接口。为什么类似呢?因为还有其他的接口用于内核空间和用户空间数据交互,只是没有copy_{to,from}_user()出名。例如:{get,put}_user()。
感谢您的耐心阅读。
本文转载自蜗窝科技:http://www.wowotech.net/memory_management/454.html
推荐教程:《Linux运维》
위 내용은 Linux의 copy_{to, from}_user()에 대한 심층적인 이해(코드 포함)의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!