RISC-V Linux 的并发修改和指令执行 (CMODX)

CMODX 是一种编程技术,其中程序执行由程序本身修改的指令。RISC-V 硬件不保证指令存储和指令缓存 (icache) 同步。因此,程序必须使用非特权 fence.i 指令强制执行其自己的同步。

内核空间中的 CMODX

动态 ftrace

本质上,动态 ftrace 通过在每个可修补的函数入口处插入函数调用来指导控制流,并在运行时动态地修补它以启用或禁用重定向。对于 RISC-V,需要 2 条指令 AUIPC + JALR 来组成一个函数调用。但是,不可能修补 2 条指令并期望并发读取方在没有竞争条件的情况下执行它们。该系列使 RISC-V ftrace 中可以进行原子代码修补。内核抢占使情况变得更糟,因为它允许旧状态在带有 stop_machine() 的修补过程中持续存在。

为了摆脱 stop_machine() 并运行具有完全内核抢占的动态 ftrace,我们在启动时部分初始化每个可修补的函数入口,将第一条指令设置为 AUIPC,第二条指令设置为 NOP。现在,原子修补是可能的,因为内核只需要更新一条指令。根据 Ziccif,只要指令是自然对齐的,ISA 就可以保证原子更新。

通过固定第一条指令 AUIPC,由于 RISC-V 中缺少立即编码空间,ftrace trampoline 的范围被限制在距预定目标 ftrace_caller 的 +-2K 范围内。为了解决这个问题,我们引入了 CALL_OPS,其中在每个可修补函数的前面添加了一个 8B 自然对齐的元数据。该元数据在第一个 trampoline 中解析,然后执行可以重定向到另一个自定义 trampoline。

用户空间中的 CMODX

虽然 fence.i 是一条非特权指令,但默认的 Linux ABI 禁止在用户空间应用程序中使用 fence.i。在任何时候,调度程序都可能将任务迁移到新的 hart 上。如果在用户空间使用 fence.i 同步了 icache 和指令存储后发生迁移,则新 hart 上的 icache 将不再是干净的。这是由于 fence.i 的行为只会影响调用它的 hart。因此,任务已迁移到的 hart 可能没有同步的指令存储和 icache。

有两种方法可以解决这个问题:使用 riscv_flush_icache() 系统调用,或者使用 PR_RISCV_SET_ICACHE_FLUSH_CTX prctl() 并在用户空间发出 fence.i。系统调用执行一次性的 icache 刷新操作。prctl 更改 Linux ABI 以允许用户空间发出 icache 刷新操作。

顺便说一句,有时可以在内核中触发“延迟” icache 刷新。在编写本文时,这仅在 riscv_flush_icache() 系统调用和内核使用 copy_to_user_page() 时发生。这些延迟刷新仅在 hart 使用的内存映射发生更改时才会发生。如果 prctl() 上下文导致了 icache 刷新,则将跳过此延迟的 icache 刷新,因为它会多余。因此,在使用 prctl() 上下文中的 riscv_flush_icache() 系统调用时,不会有额外的刷新。

prctl() 接口

使用 PR_RISCV_SET_ICACHE_FLUSH_CTX 作为第一个参数调用 prctl()。其余参数将委托给下面详细介绍的 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 的内核上,此函数是一个空操作,因为不会发生跨 hart 的迁移。

用法示例

以下文件旨在相互编译和链接。modify_instruction() 函数用加一的指令替换加零的指令,导致 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