RISC-V Linux 的并发指令修改与执行(CMODX)¶
CMODX 是一种编程技术,程序在其中执行由程序自身修改过的指令。在 RISC-V 硬件上,指令存储器和指令缓存(icache)不保证是同步的。因此,程序必须使用非特权级的 fence.i 指令强制进行自身的同步。
内核空间中的 CMODX¶
动态 ftrace¶
本质上,动态 ftrace 通过在每个可修补的函数入口插入一个函数调用来引导控制流,并在运行时动态修补它以启用或禁用重定向。对于 RISC-V,需要 2 条指令(AUIPC + JALR)来组成一个函数调用。然而,不可能在修补 2 条指令的同时期望并发的读侧(read-side)在没有竞争条件的情况下执行它们。本系列补丁使得 RISC-V ftrace 中的原子代码修补成为可能。内核抢占使情况变得更糟,因为它允许旧状态在整个修补过程中伴随 stop_machine() 持续存在。
为了摆脱 stop_machine() 并在完全内核抢占下运行动态 ftrace,我们在启动时部分初始化每个可修补的函数入口,将第一条指令设为 AUIPC,第二条设为 NOP。现在,原子修补成为可能,因为内核只需要更新一条指令。根据 Ziccif 的说法,只要指令是自然对齐的,ISA 就可以保证原子更新。
通过固定第一条指令 AUIPC,由于 RISC-V 中缺少立即数编码空间,ftrace 中继(trampoline)的范围被限制在距离预定目标 ftrace_caller 的 +-2K 范围内。为了解决这个问题,我们引入了 CALL_OPS,在每个可修补函数的前面添加了一个 8 字节自然对齐的元数据。该元数据在第一个中继处解析,然后可以将执行引导到另一个自定义的中继。
用户空间中的 CMODX¶
尽管 fence.i 是一个非特权指令,但默认的 Linux ABI 禁止在用户空间应用程序中使用 fence.i。调度程序随时可能将任务迁移到新的 hart。如果在用户空间使用 fence.i 同步了 icache 和指令存储器之后发生迁移,新 hart 上的 icache 将不再是清洁的(clean)。这是因为 fence.i 的行为只影响调用它的那个 hart。因此,任务迁移到的 hart 可能没有同步指令存储器和 icache。
解决这个问题有两种方法:使用 riscv_flush_icache() 系统调用,或者使用 PR_RISCV_SET_ICACHE_FLUSH_CTX prctl() 并在用户空间发出 fence.i。系统调用执行一次性的 icache 刷新操作。prctl 则修改 Linux ABI 以允许用户空间发出 icache 刷新操作。
顺便提一下,内核有时会触发“延迟(deferred)”icache 刷新。在撰写本文时,这仅在 riscv_flush_icache() 系统调用期间以及内核使用 copy_to_user_page() 时发生。这些延迟刷新仅在 hart 正在使用的内存映射发生更改时发生。如果 prctl() 上下文引起了 icache 刷新,则由于冗余,此延迟的 icache 刷新将被跳过。因此,在 prctl() 上下文内部使用 riscv_flush_icache() 系统调用时,不会产生额外的刷新。
prctl() 接口¶
调用 prctl(),并将 PR_RISCV_SET_ICACHE_FLUSH_CTX 作为第一个参数。其余参数将委托给下面详细介绍的 riscv_set_icache_flush_ctx 函数。
-
int riscv_set_icache_flush_ctx(unsigned long ctx, unsigned long scope)¶
在用户空间中启用/禁用 icache 刷新指令。
参数
unsigned long ctx设置在用户空间中允许/禁止的 icache 刷新指令类型。支持的值如下所述。
unsigned long scope设置允许发出 icache 刷新指令的作用域。支持的值如下所述。
描述
ctx 的支持值
PR_RISCV_CTX_SW_FENCEI_ON:允许在用户空间中使用 fence.i。PR_RISCV_CTX_SW_FENCEI_OFF:不允许在用户空间中使用 fence.i。当scope == PR_RISCV_SCOPE_PER_PROCESS时,进程中的所有线程都会受到影响。因此,必须谨慎操作;只有在你能够保证进程中的任何线程从此时起都不会发出 fence.i 时,才使用此标志。
scope 的支持值
PR_RISCV_SCOPE_PER_PROCESS:确保此进程中任何线程的 icache在迁移时与指令存储器保持一致。
PR_RISCV_SCOPE_PER_THREAD:确保当前线程的 icache在迁移时与指令存储器保持一致。
当 scope == PR_RISCV_SCOPE_PER_PROCESS 时,允许进程中的所有线程发出 icache 刷新指令。每当进程中的任何线程被迁移时,都保证相应 hart 的 icache 与指令存储器保持一致。这并不在迁移之外强制任何保证。如果一个线程修改了另一个线程可能试图执行的指令,则另一个线程在试图执行潜在修改后的指令之前,仍必须发出 icache 刷新指令。这必须由用户空间程序来执行。
在线程级上下文中(例如 scope == PR_RISCV_SCOPE_PER_THREAD),仅允许调用此函数的线程发出 icache 刷新指令。当该线程被迁移时,将保证相应 hart 的 icache 与指令存储器保持一致。
在未配置 SMP 的内核上,此函数是一个空操作(nop),因为不会发生跨 hart 的迁移。
用法示例
以下文件旨在互相编译和链接。modify_instruction() 函数将加 0 的操作替换为加 1 的操作,从而导致 get_value() 中的指令序列从返回零变为返回一。
cmodx.c
#include <stdio.h>
#include <sys/prctl.h>
extern int get_value();
extern void modify_instruction();
int main()
{
int value = get_value();
printf("Value before cmodx: %d\n", value);
// Call prctl before first fence.i is called inside modify_instruction
prctl(PR_RISCV_SET_ICACHE_FLUSH_CTX, PR_RISCV_CTX_SW_FENCEI_ON, PR_RISCV_SCOPE_PER_PROCESS);
modify_instruction();
// Call prctl after final fence.i is called in process
prctl(PR_RISCV_SET_ICACHE_FLUSH_CTX, PR_RISCV_CTX_SW_FENCEI_OFF, PR_RISCV_SCOPE_PER_PROCESS);
value = get_value();
printf("Value after cmodx: %d\n", value);
return 0;
}
cmodx.S
.option norvc
.text
.global modify_instruction
modify_instruction:
lw a0, new_insn
lui a5,%hi(old_insn)
sw a0,%lo(old_insn)(a5)
fence.i
ret
.section modifiable, "awx"
.global get_value
get_value:
li a0, 0
old_insn:
addi a0, a0, 0
ret
.data
new_insn:
addi a0, a0, 1