关注了就能看到更多这么棒的文章哦~
Yet another memory allocator for executable code
By Jonathan Corbet
June 8, 2023
DeepL assisted translation
https://lwn.net/Articles/933867/
内核的代码越来越动态了,也就是随时会出现新的 text 段代码。目前,为新内核代码分配内存的责任,属于首先将代码加载到正在运行的内核中的子系统上,也就是 module loader。Mike Rapoport 的这个 patch set 希望将这些内存分配的责任转移到新的 “JIT allocator”上去,从而解决目前流程中的一些问题。
为了能支持在运行时动态加载 module,内核必须要能够分配内存来容纳这些 module。早期,只要调用 vmalloc() 来获取所需数量的 page,并为生成的 page 启用执行权限就好。然而,随着时间的推移,事情变得越来越复杂(似乎无可避免)。
一方面,有越来越多的子系统实现了将代码加载到正在运行的内核中的功能。例如,tracing 子系统就可能需要向内核里添加少量代码。当前内核中更加频繁进行这个操作的子系统是 BPF,它可以频繁有(通常比较小)可执行代码进进出出的。而提案中的 bcachefs 文件系统则是一个更难想到的用例:为每个 B 树节点动态生成一个专门的 unpack 函数,从而提高性能。所有这些新用户都在从各种不同的角度来考验内存管理子系统,也导致出现更多 direct-map 碎片化以及其他的性能问题。
更何况还要考虑到众多处理器体系架构引入的问题,其中有一些体系架构限制了可用于保存内核代码的地址范围。各种体系架构已将针对自己的特有支持都添加到 module allocator 里面了,从而使整体代码更加复杂。架构维护者正在积极地采用更严格的策略,也就是可执行内存绝不可以也同时是可写状态,这使得攻击者更难将代码加载到内核中。对于需要将代码写入内核内存的子系统来说,这也使得这个任务更加困难了。
Rapoport 的 patch set 希望能让那些需要为可执行代码分配内存的内核子系统的开发人员更加轻松一些。它用两个新函数替换了现有的 module_alloc() 接口:
void *jit_text_alloc(size_t len);
void jit_free(void *buf);
调用 jit_text_alloc() 会返回 len 字节的可执行内存,而 jit_free() 会将该内存释放返还给系统。内存被初始化为全 0。在实现可执行内存和可写内存严格分离的系统上,不能将可加载代码直接复制到这个分配区域中去。相反,应使用以下的函数或之一:
void jit_update_copy(void *buf, void *new_buf, size_t len);
void jit_update_set(void *addr, int c, size_t len);
jit_update_copy() 会将可执行代码内容从 buf 复制到 new_buf (来自 jit_text_alloc() 返回的地址),而 jit_update_set() 会将该内存的范围设置为常数值。
在某些体系架构上,跟该代码区域关联的数据必须要在该区域附近分配。例如,内核 module 的数据段可能就必须满足这个要求。为了确保放到正确的位置,可以用下面的函数来分配用来保存此类数据的内存:
void *jit_data_alloc(size_t len);
通过这组函数,内核代码就可以为新的可执行代码段分配和使用内存了。但是,仍然有一些特定体系架构上的限制。这些限制主要跟这些可执行代码在内核的虚拟地址空间中放到哪里有关。Rapoport 没有让每个架构重新实现 jit_text_alloc() 来满足其特殊要求,而是引入了一个新的 structure 来简单地向统一的内存分配器来描述这些要求:
struct jit_address_space {
pgprot_t pgprot;
unsigned long start;
unsigned long end;
unsigned long fallback_start;
unsigned long fallback_end;
};
跟特定体系架构相关的代码需要提供两个结构:一个描述可执行代码分配方面的要求,另一个描述数据分配方面的要求。在每个结构中, pgprot 字段指定了必须在页表中实现什么样的保护,而 start 和 end 则指定了内存分配应来自哪个地址区域。某些体系架构上实现了另一个“fallback”区域,从而在主要分配区域内未能分配成功的时候来使用这个 fallback 区域满足分配要求。fallback 的位置(如果有的话)就由 fallback_start 和 fallback_end 来指定。
然后将这些结构捆绑到一个整体结构中,从而控制如何在任何一个体系结构上处理可执行内存(和相关数据)的分配:
struct jit_alloc_params {
struct jit_address_space text;
struct jit_address_space data;
enum jit_alloc_flags flags;
unsigned int alignment;
};
flags 字段用来指定一些特定体系架构上才有的怪癖(quirk),而 alignment 指定此类分配所需满足的最小对齐方式。需要对代码进行一些研究才能了解到 alignment 需要是 2 的幂,或者,可以将其视为正确对齐的地址中必须为零的最低有效 bit 的数量。
有了这套基础架构之后,内核子系统就可以给可执行代码分配空间了。由于此分配器独立于内核的 module loader,因此在加载其他类型的代码时就不再需要启用 loadable module 功能了。patch 中并没有解决跟可执行内存分配相关的性能问题,作者认为这个优化可以在接口达成一致后再添加。
对这项工作的 review 回复可以分为两大类。Rick Edgecombe 担心此接口可能会把一些尚未达到原定目标的可执行代码给暴露出来。例如,module 代码在进入内存后可以通过多种方式进行调整。他建议,最好先准备代码区域,然后再将其改成可执行的。
Mark Rutland 的另一个担忧是,至少在某些体系架构上,放置可执行代码的要求是因代码类型而异的。例如,arm64 上的可加载 module 就比 kprobe 有更严格的限制。可以想象,将所有内存分配都保持在最严格的限制条件之下就可能会导致目标区域中的地址空间不太够用了。他建议为每种内存类型都创建单独的分配器,所有这些分配器可以仍然使用底层的通用的代码基础设施。Rapoport 回答说,如果事实证明有必要的话,可以让这个统一的基础设施来学会针对不同的分配目标来采用不同的规则。不过,目前还不完全清楚,这个问题是否足够严重,导致必须采用这种解决方案。
总体而言,这个 patch set 看起来像是朝着在内核中分配可执行内存的合适的 API 走出的很合理的一步。然而,过去的几年在这个领域已经有过几次尝试了,但还没有出现让每个人都满意的方案。因此,我们智能等等看,这次会是什么结果。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~