Linux 内核 Memory Protection Keys(PKU)权威指南:x86_64 与 arm64 用户态内存保护密钥机制深度解析
2026/9/15 17:07:52 网站建设 项目流程

Linux 内核 Memory Protection Keys(PKU)权威指南:x86_64 与 arm64 用户态内存保护密钥机制深度解析

【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux

导读

Memory Protection Keys(内存保护密钥,简称 Pkeys / PKU)是 Linux 内核提供的、用于在不修改页表的前提下为不同内存区域动态切换访问权限的机制:应用将页表项中保留的若干位标记为"保护密钥",随后仅需读写一个 per-CPU 的寄存器,即可瞬时启用或禁用对整片密钥区域的读/写/执行权限。本指南以内核文档 Documentation/core-api/protection-keys.rst 为骨架,结合内核源码(mm/mprotect.carch/x86/mm/pkeys.carch/arm64相关实现)与自测程序(tools/testing/selftests/mm/),系统讲解 x86_64 与 arm64 两种架构的密钥编码、PKRU/POR_EL0 寄存器语义、pkey_alloc()/pkey_free()/pkey_mprotect()三个系统调用、以及故障信号行为。读完本文,你将能够在自己的应用中安全地分配密钥、设置密钥保护并正确处理SEGV_PKERR信号。


一、什么是 Memory Protection Keys:动机与核心思想

传统的内存保护依赖mprotect()修改页表项中的权限位,而页表是共享的、修改代价高昂(需要 TLB shootdown 等操作)。当应用需要在"可写/只读"等保护域之间频繁切换时,每次都改页表既不高效也不够灵活。

Memory Protection Keys 提供了另一条路径:

在不修改页表的前提下,为每个页表项打上一个"密钥编号",权限的开关由 CPU 寄存器中的位决定。

核心思路分两步:

  1. 打标(静态、低频):通过pkey_mprotect()将页表项中原本保留的若干位编码为"保护密钥编号",此操作与普通mprotect()一样需要经过内核、修改页表项;
  2. 切换(动态、高频):应用直接读写 per-CPU 的用户可访问寄存器(x86_64 为PKRU,arm64 为POR_EL0),瞬间改变该密钥对应内存的访问权限,全程不经过内核、不触碰页表

由于权限寄存器的状态天然是 per-CPU、per-thread 的,不同线程可以持有同一块内存的不同访问视图——这正是"保护域"隔离的天然载体。这一特性常用于 JIT 运行时(代码页写后改只读)、沙箱、内存安全库等场景。


二、架构差异:x86_64 与 arm64 的密钥编码与权限寄存器

同一套概念在两个架构上有不同的硬件实现,内核文档分别做了说明,下面结合架构代码逐一展开。

2.1 x86_64:4 位密钥 + 32 位 PKRU 寄存器

  • 页表项编码:每个 PTE 中原本保留的 4 位被用于编码"protection key",因此共有16 个可用密钥(编号 0~15)。
  • 权限寄存器 PKRU:每个 CPU 有一个用户可访问的32 位 PKRU 寄存器,每 2 位对应一个密钥,分别表示Access Disable(禁止访问,即禁止读)Write Disable(禁止写),16 个密钥 × 2 位 = 32 位。
  • 读写指令:内核提供RDPKRU/WRPKRU两条指令读取和写入该寄存器。
  • 可用模式:该特性仅存在于 64 位模式(尽管理论上 PAE 页表也有空间);且权限仅作用于数据访问,不影响指令取指

内核自测程序 tools/testing/selftests/mm/pkey-x86.h 给出了这两条指令的直接内联汇编实现:

static inline u64 __read_pkey_reg(void) { unsigned int eax, edx; unsigned int ecx = 0; asm volatile(".byte 0x0f,0x01,0xee\n\t" /* RDPKRU */ : "=a" (eax), "=d" (edx) : "c" (ecx)); return eax; } static inline void __write_pkey_reg(u64 pkey_reg) { unsigned int eax = pkey_reg; unsigned int ecx = 0; unsigned int edx = 0; asm volatile(".byte 0x0f,0x01,0xef\n\t" /* WRPKRU */ : : "a" (eax), "c" (ecx), "d" (edx)); }

同一头文件中还定义了 x86 的密钥常量与容量信息:

#define PKEY_DISABLE_ACCESS 0x1 #define PKEY_DISABLE_WRITE 0x2 #define NR_PKEYS 16 #define NR_RESERVED_PKEYS 2 /* pkey-0 and exec-only-pkey */ #define PKEY_BITS_PER_PKEY 2
  • 支持的 CPU:Intel 服务器 CPU(Skylake 及以后)、Intel 客户端 CPU(Tiger Lake,第 11 代酷睿及以后)、以及未来的 AMD CPU。
  • CPU 特性检测:x86 上需要同时具备PKU(CPUID leaf 0x7, ECX bit 3)和OSPKE(ECX bit 4)两个特性位才算可用,pkey-x86.h中的cpu_has_pkeys()正是这样检测的;内核在启动时也依赖这两个特性位来使能 PKU 支持。

2.2 arm64:3 位密钥索引 + 64 位 POR_EL0 寄存器(Permission Overlay Extension)

  • 页表项编码:arm64 使用页表项中3 位编码"protection key index",因此共有8 个可用密钥(编号 0~7)。
  • 权限寄存器 POR_EL0:每个 CPU 有一个用户可写的64 位系统寄存器POR_EL0,每个密钥索引用 4 位编码 read/write/execute 三种"overlay"(叠加)权限,8 个密钥 × 4 位 = 32 位使用量。
  • 线程本地性:与 x86 一样,POR_EL0是 CPU 寄存器,天然线程本地,不同线程可持有不同保护视图。
  • 关键差异:与 x86_64 不同,arm64 的保护密钥权限同样作用于指令取指(instruction fetch)

arm64 的硬件实现基于Permission Overlay Extension(FEAT_S1POE)。内核自测程序 tools/testing/selftests/mm/pkey-arm64.h 给出了POR_EL0的读写与权限编码细节:

/* POR_EL0 通过系统寄存器操作指令访问(S3_3_c10_c2_4) */ static inline u64 __read_pkey_reg(void) { u64 pkey_reg = 0; asm volatile("mrs %0, S3_3_c10_c2_4" : "=r" (pkey_reg)); return pkey_reg; } /* 每个密钥占 4 位,权限取值如下 */ #define POE_NONE 0x0 /* 无权限 */ #define POE_X 0x2 /* 仅执行 */ #define POE_RX 0x3 /* 读 + 执行 */ #define POE_RWX 0x7 /* 读 + 写 + 执行 */ #define PKEY_BITS_PER_PKEY 4

set_pkey_bits()展示了如何把 Linux 的PKEY_DISABLE_ACCESS/PKEY_DISABLE_WRITE标志翻译为 arm64 的 overlay 权限:PKEY_DISABLE_ACCESS对应POE_X(仅可执行、禁读),PKEY_DISABLE_WRITE对应POE_RX(可读执行、禁写)。这也印证了文档所述"arm64 的权限同时作用于指令取指"——因为POE_X这类"仅执行"状态本身就是指令级权限。

小结:两个架构对照表

维度x86_64arm64
页表项编码位4 位3 位
密钥数量168
权限寄存器PKRU(32 位)POR_EL0(64 位)
每密钥位数2 位(AD/WD)4 位(R/W/X overlay)
是否作用于指令取指
硬件扩展PKU/OSPKEPermission Overlay Extension(FEAT_S1POE)

三、三个系统调用:pkey_alloc / pkey_free / pkey_mprotect

文档定义了与 pkeys 直接交互的 3 个系统调用:

int pkey_alloc(unsigned long flags, unsigned long init_access_rights); int pkey_free(int pkey); int pkey_mprotect(unsigned long start, size_t len, unsigned long prot, int pkey);

3.1 pkey_alloc():分配密钥

pkey_alloc()用于在使用前先分配一个密钥。其内核实现位于 mm/mprotect.c(SYSCALL_DEFINE2(pkey_alloc, ...)),关键行为如下:

  • flags参数目前必须为 0,否则返回-EINVAL(内核注释明确 "No flags supported yet");
  • init_access_rights是初始访问权限,必须是PKEY_ACCESS_MASK的子集,即只能是PKEY_DISABLE_ACCESSPKEY_DISABLE_WRITE或二者的组合(见 include/uapi/asm-generic/mman-common.h):
#define PKEY_DISABLE_ACCESS 0x1 #define PKEY_DISABLE_WRITE 0x2 #define PKEY_ACCESS_MASK (PKEY_DISABLE_ACCESS | PKEY_DISABLE_WRITE)
  • 内核在mmap_write_lock保护下通过mm_pkey_alloc()分配一个空闲密钥;若耗尽(返回 -1)则返回-ENOSPC
  • 随后调用架构钩子arch_set_user_pkey_access(pkey, init_val)将初始权限写入寄存器(x86 实现见 arch/x86/kernel/fpu/xstate.c,x86 的 PKRU 状态同时作为 XSAVE 扩展状态的一部分被保存/恢复)。

3.2 pkey_mprotect():给内存打上密钥

pkey_mprotect()与普通mprotect()的唯一区别是多了pkey参数,用于把[start, start+len)区域绑定到指定密钥。内核实现(mm/mprotect.c)非常直白:

SYSCALL_DEFINE4(pkey_mprotect, unsigned long, start, size_t, len, unsigned long, prot, int, pkey) { return do_mprotect_pkey(start, len, prot, pkey); }

而普通mprotect()仅仅是do_mprotect_pkey(start, len, prot, -1)——传-1表示"不使用 pkey"。这说明两者共享同一套 VMA 权限变更核心逻辑,pkey 只是作为额外的vm_flags位(VM_PKEY_SHIFT)写入 VMA 并在建立页表时写入 PTE。此外,x86 还通过arch_override_mprotect_pkey()(见 arch/x86/mm/pkeys.c 与 arch/x86/include/asm/pkeys.h)实现execute-only(仅执行)映射:当应用对某个 pkey 请求PROT_EXEC时,内核会分配一个专门的密钥并设置PKEY_DISABLE_ACCESS,从而得到"可执行但不可读"的页面——这也是 x86 上"exec-only pkey"保留密钥的来历(对应pkey-x86.hNR_RESERVED_PKEYS为 2:pkey-0 与 exec-only pkey)。

3.3 pkey_free():释放密钥

pkey_free()释放密钥供后续复用,实现见 mm/mprotect.c。内核注释提醒:释放时不会检查是否仍有 VMA 引用该密钥("We could provide warnings or errors if any VMA still has the pkey set here"),因此应用必须先munmap()相关区域再释放,否则释放后密钥可能被重新分配给其他用途,造成语义混乱。

注意:这三个系统调用仅在CONFIG_ARCH_HAS_PKEYS配置开启时编译(mm/mprotect.c 的#ifdef条件),x86_64 与 arm64 均满足。


四、完整使用流程:从分配密钥到释放

文档给出了一个完整的最小示例,下面结合源码注释与寄存器操作展开讲解。

4.1 分配密钥并绑定内存

int real_prot = PROT_READ | PROT_WRITE; pkey = pkey_alloc(0, PKEY_DISABLE_WRITE); /* 初始即禁止写 */ ptr = mmap(NULL, PAGE_SIZE, PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); /* 先以 PROT_NONE 映射 */ ret = pkey_mprotect(ptr, PAGE_SIZE, real_prot, pkey); /* 绑定密钥并赋予 RW */ ... /* 应用正常运行 */

关键点:

  1. pkey_alloc(0, PKEY_DISABLE_WRITE)flags传 0,初始权限为"禁写"——即使后续pkey_mprotect()赋予PROT_WRITE,只要寄存器中该密钥仍带PKEY_DISABLE_WRITE,写操作就会被拒绝;
  2. mmap(..., PROT_NONE, ...)后再用pkey_mprotect()赋予真实权限,这是把"页表权限"与"密钥绑定"解耦的标准写法;
  3. 一旦绑定,改变该区域读写权限不再需要调用内核——直接改寄存器即可(见下节)。

4.2 通过寄存器切换权限(无需系统调用)

当应用需要更新ptr指向的数据时,它先清除写禁止位、完成写入、再恢复写禁止位:

pkey_set(pkey, 0); /* 清除 PKEY_DISABLE_WRITE:允许写 */ *ptr = foo; /* 赋值 */ pkey_set(pkey, PKEY_DISABLE_WRITE); /* 重新设置 PKEY_DISABLE_WRITE */

这里pkey_set()是对"写入 CPU 寄存器"的 C 封装(x86 上即WRPKRU,arm64 上即msr POR_EL0)。文档明确指出,可参考的实现位于tools/testing/selftests/mm/pkey-{arm64,powerpc,x86}.h——本文 2.1、2.2 节引用的__read_pkey_reg()/__write_pkey_reg()正是pkey_set()的底层构件(powerpc版本见 tools/testing/selftests/mm/pkey-powerpc.h,用于 64 位 PowerPC 的 AMR 寄存器实现)。

由于写寄存器是普通用户态指令,这段"开关写权限"的开销远低于一次mprotect()系统调用,这正是 PKU 在高频保护域切换场景下的性能价值所在。

4.3 释放内存与密钥

munmap(ptr, PAGE_SIZE); pkey_free(pkey);

先解除映射、再释放密钥,避免释放后仍有 VMA 引用该密钥的悬空状态(内核对此并不做强制检查,见 3.3 节)。

4.4 自测程序中的完整实践

内核自带的自测套件 tools/testing/selftests/mm/protection_keys.c 对上述全流程进行了系统验证:它覆盖了 pkey 分配/释放、pkey_mprotect()绑定、寄存器读写、SIGSEGV信号处理(expected_pkey_fault())以及 exec-only 密钥的读故障验证(见pkey-x86.h中的expect_fault_on_read_execonly_key())等场景,是学习与验证 PKU 行为的最佳参考用例(与配套的 tools/testing/selftests/mm/pkey_sighandler_tests.c 一起,重点测试了信号处理路径中的密钥寄存器状态保存/恢复)。


五、行为语义:与 mprotect() 的一致性及故障信号

5.1 与普通 mprotect() 行为一致

内核努力让保护密钥的语义与普通mprotect()保持一致。例如下面这段普通写法:

mprotect(ptr, size, PROT_NONE); something(ptr); /* 访问 ptr 会触发 SIGSEGV */

应当与下面的 pkey 写法有完全相同的效果:

pkey = pkey_alloc(0, PKEY_DISABLE_WRITE | PKEY_DISABLE_READ); pkey_mprotect(ptr, size, PROT_READ | PROT_WRITE, pkey); something(ptr); /* 访问 ptr 同样触发 SIGSEGV */

无论something()是应用直接访问(如*ptr = foo;),还是内核代表应用进行访问(如read(fd, ptr, 1);),两种情况下内核都会发送SIGSEGV

5.2 通过 si_code 区分两种故障

两种故障虽然都产生SIGSEGV,但si_code不同

  • 违反保护密钥权限si_codeSEGV_PKERR(x86 上即 PKU 故障,arm64 的 Permission Overlay 故障同样映射为此值);
  • 违反普通 mprotect() 页表权限si_codeSEGV_ACCERR

这为信号处理器提供了精确的故障分类依据:应用可以在 handler 中检查siginfo->si_code,区分"密钥寄存器权限不足"与"页表权限不足",从而决定是临时提升寄存器权限(如在 JIT 中补充写权限)还是真正报错终止。x86 上SEGV_PKERR故障时还会在siginfo中携带出错的 pkey 编号(si_pkey,自测头文件pkey-x86.h中定义的si_pkey_offset即用于在信号帧中解析该字段)。

5.3 kthread 场景的注意事项

文档特别提醒:来自 kthread(如 io_uring 的工作线程)的内核访问会使用保护密钥寄存器的默认值,因此不会与用户空间的寄存器值或mprotect()保持一致。换言之,当内核线程替应用执行 I/O 时,它看到的权限是基于页表(含 pkey 绑定位但寄存器为默认值)的视图,而非应用当前线程寄存器中的动态视图。在设计依赖 PKU 的并发 I/O 路径时,这一点需要纳入考量。


六、可用性与配置前提

  • x86_64:需要 Intel Skylake 及以后的服务器 CPU、或 Tiger Lake(第 11 代酷睿)及以后的客户端 CPU(未来的 AMD CPU 也在支持之列);且 CPU 需同时具备PKUOSPKE特性位。
  • arm64:需要实现Permission Overlay Extension(FEAT_S1POE)的 CPU。
  • 内核配置:pkey 相关系统调用在CONFIG_ARCH_HAS_PKEYS开启时可用(x86_64 与 arm64 均支持);用户态需要内核头文件中的PKEY_DISABLE_ACCESS/PKEY_DISABLE_WRITE等 UAPI 常量(定义于 include/uapi/asm-generic/mman-common.h)。
  • pkey-0 的保留语义:密钥 0 是默认密钥,所有未显式绑定 pkey 的内存都归于密钥 0;x86 上除密钥 0 外还保留"exec-only pkey"(见pkey-x86.hNR_RESERVED_PKEYS 2),arm64 则仅保留密钥 0(NR_RESERVED_PKEYS 1,见pkey-arm64.h)。应用可用的密钥数为总密钥数减去保留数。

七、总结

Memory Protection Keys 为 Linux 应用提供了一套"页表打标 + 寄存器开关"的两层内存保护模型:pkey_alloc()/pkey_mprotect()/pkey_free()负责低频的密钥生命周期与内存绑定,RDPKRU/WRPKRU(x86_64)或POR_EL0(arm64)负责高频的权限切换。它在语义上与mprotect()保持一致,同时通过SEGV_PKERRSEGV_ACCERR的区分让应用能够精确识别权限故障来源。无论是 x86_64 的 16 密钥、2 位/密钥编码,还是 arm64 的 8 密钥、4 位 R/W/X overlay 编码,内核都以统一的系统调用接口向用户空间呈现,具体架构差异被封装在 arch/x86/mm/pkeys.c、arch/arm64实现及tools/testing/selftests/mm/自测代码之中——这也是理解与移植 PKU 应用时最值得深入阅读的第一手资料。

【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询