泰晓科技 -- 聚焦 Linux - 追本溯源,见微知著!
网站地址:https://tinylab.org

泰晓Linux知识星球:1300+知识点,520+用户
请稍侯

Linux 的 RISC-V 拓展支持与 alternative 运行时代码段修改

Reset816 创作于 2024/06/25

Corrector: TinyCorrect v0.2-rc1 - [spaces codeinline pangu] Author: Tan Yuan tanyuan@tinylab.org Date: 20230730 Revisor: Falcon falcon@tinylab.org Project: RISC-V Linux 内核剖析 Proposal: 通过编译器解决因链接过程 KEEP 操作引起的 Section GC 失败问题 Sponsor: PLCT Lab, ISCAS

1 概述

RISC-V 拥有许多拓展指令集,CPU 可以选择性地支持这些拓展指令集。其中,RISC-V ISA Zbb 是一种基本位操作拓展指令集,可应用于字符串相关操作。近期,Linux 内核开始采用 Zbb 中的新指令来优化字符串操作,从而提升系统性能。

然而,内核必须同时考虑不支持 Zbb 的设备。使用不同的函数实现可能会引起多个问题,包括:

  • 如果 CPU 不支持新指令该如何处理?
  • 如何将旧指令实现的函数替换成新指令实现的函数?
  • 启动的最初阶段内核不知道 CPU 支持哪些拓展。

为了解决这些问题,内核采用了”alternative”功能来实现函数替换。该功能可以在内核运行时将原有代码内容动态替换为新的代码内容,修改代码段以更改函数的执行路径。

“alternative”根据 CPU 的型号来决定是否替代代码。如果 CPU 不支持 Zbb,那么在内核启动时,就不会更改函数的执行路径,避免运行到 Zbb 专属指令的部分。

接下来,我们将研究 Linux Kernel v6.5-rc2 如何将原有的 strcmp 替换为 Zbb 中的新指令,并详细阐述”alternative”的实现原理。

2 准备工作

我们需要准备好测试环境,包括支持 Zbb 的工具链、支持 Zbb 的 CPU、并开启内核关于 Zbb 的选项。

2.1 工具链支持和内核选项

在内核代码中搜索 Zbb,可以找到如下配置选项:

  1. // arch/riscv/Kconfig:507
  2. config TOOLCHAIN_HAS_ZBB
  3. bool
  4. default y
  5. depends on !64BIT || $(cc-option,-mabi=lp64 -march=rv64ima_zbb)
  6. depends on !32BIT || $(cc-option,-mabi=ilp32 -march=rv32ima_zbb)
  7. depends on LLD_VERSION >= 150000 || LD_VERSION >= 23900
  8. depends on AS_HAS_OPTION_ARCH
  9. config RISCV_ISA_ZBB
  10. bool "Zbb extension support for bit manipulation instructions"
  11. depends on TOOLCHAIN_HAS_ZBB
  12. depends on MMU
  13. depends on RISCV_ALTERNATIVE
  14. default y
  15. help
  16. Adds support to dynamically detect the presence of the ZBB
  17. extension (basic bit manipulation) and enable its usage.
  18. The Zbb extension provides instructions to accelerate a number
  19. of bit-specific operations (count bit population, sign extending,
  20. bitrotation, etc).
  21. If you don't know what to do here, say Y.

我们需要开启这两个选项。RISCV_ISA_ZBB 依赖于 TOOLCHAIN_HAS_ZBBTOOLCHAIN_HAS_ZBB 需要 LD_VERSION >= 23900

我们可以使用 Binutils 2.40。如果发行版仓库中 Binutils 低于该版本,可以在 交叉编译工具链镜像 上下载该版本的 Binutils。

在正确的环境变量配置下,我们可以打开 menuconfig 来开启 RISCV_ISA_ZBB 选项。可以使用 / 来搜索选项,并且使用数字 1、2 来快速跳转到目标选项位置。

  1. make ARCH=riscv CROSS_COMPILE=riscv64-linux- menuconfig

2.2 QEMU 支持

我们需要使用支持 Zbb 的 CPU 来运行内核。

QEMU 关于这方面的文档并不完整。通过在其 代码 中搜索,发现 v8.1.0-rc1 版本中的 veyron-v1 CPU 支持 Zbb。

使用 QEMU 运行 Linux Kernel:

  1. qemu-system-riscv64 \
  2. -nographic \
  3. -machine virt \
  4. -cpu veyron-v1 \
  5. -kernel arch/riscv/boot/Image \
  6. -append "console=ttyS0"

如果我们想使用不支持 Zbb 的 CPU 来调试,可以使用 sifive-u54 CPU。

  1. qemu-system-riscv64 \
  2. -nographic \
  3. -machine virt \
  4. -cpu sifive-u54 \
  5. -kernel arch/riscv/boot/Image \
  6. -append "console=ttyS0"

2.3 调试配置

2.3.1 内核开启调试选项

需要开启内核中的 CONFIG_DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT 选项来为内核增加调试信息。

2.3.2 VSCode 调试配置

我们可以使用 VSCode 来让调试更加便利。

只需要在项目根目录下创建两个文件 .vscode/launch.json.vscode/tasks.json,即可使用 VSCode 来调试内核。

  1. // .vscode/launch.json
  2. {
  3. "version": "0.2.0",
  4. "configurations": [
  5. {
  6. "name": "Kernel Debug",
  7. "type": "cppdbg",
  8. "request": "launch",
  9. "miDebuggerServerAddress": "localhost:1234",
  10. "program": "${workspaceFolder:}/vmlinux",
  11. "sourceFileMap": {
  12. "kernel.map": "${workspaceFolder:}/System.map"
  13. },
  14. "MIMode": "gdb",
  15. "externalConsole": false,
  16. "miDebuggerPath": "gdb-multiarch",
  17. "internalConsoleOptions": "openOnSessionStart",
  18. "preLaunchTask": "qemu-debug",
  19. "cwd": "${workspaceFolder:}",
  20. "setupCommands": [
  21. {
  22. "text": "-enable-pretty-printing",
  23. "description": "Enable GDB pretty printing",
  24. "ignoreFailures": true
  25. },
  26. {
  27. "text": "set architecture riscv:rv64",
  28. "description": "Set target architecture",
  29. "ignoreFailures": true
  30. }
  31. ],
  32. "miDebuggerArgs": "-q"
  33. }
  34. ]
  35. }
  1. // .vscode/tasks.json
  2. {
  3. "version": "2.0.0",
  4. "tasks": [
  5. {
  6. "type": "shell",
  7. "label": "qemu-debug",
  8. "command": "echo starting qemu... 1>&2 && qemu-system-riscv64 -nographic -machine virt -cpu veyron-v1 -kernel arch/riscv/boot/Image -append console=ttyS0 -s -S",
  9. "isBackground": true,
  10. "presentation": {
  11. "echo": true,
  12. "reveal": "always",
  13. "focus": true,
  14. "panel": "shared",
  15. "showReuseMessage": false
  16. },
  17. "options": {
  18. "cwd": "${workspaceFolder:}"
  19. },
  20. "problemMatcher": {
  21. "pattern": {
  22. "regexp": "."
  23. },
  24. "background": {
  25. "activeOnStart": true,
  26. "beginsPattern": ".",
  27. "endsPattern": "."
  28. }
  29. }
  30. }
  31. ]
  32. }

通过更改 .vscode/tasks.json 中的 command 项,可以修改 QEMU 的参数。

创建好这两个文件后,即可使用 VSCode 调试功能。

3 alternative 原理

3.1 动态替换代码

首先我们来看一下 arch/riscv/lib/strcmp.S,可以 在线 查看代码。

strcmp.S 的结构为:

  1. strcmp:
  2. ALTERNATIVE("nop", "j strcmp_zbb", 0, RISCV_ISA_EXT_Zbb, CONFIG_RISCV_ISA_ZBB)
  3. < 通用的 strcmp 汇编代码 >
  4. ret
  5. strcmp_zbb:
  6. < 使用 Zbb 拓展指令的 strcmp 汇编代码 >
  7. ret

ALTERNATIVE汇编宏,在汇编转换为机器码的阶段展开。编译后,这部分代码段在运行时实际上会变为:

  1. strcmp:
  2. nop
  3. < 通用的 strcmp 汇编代码 >
  4. ret
  5. strcmp_zbb:
  6. < 使用 Zbb 拓展指令的 strcmp 汇编代码 >
  7. ret

原来的 ALTERNATIVE 展开成 nop 指令。

我们可以推测,在内核启动时,如果 CPU 支持并且内核配置了 ZBB 选项,ALTERNATIVE 将把 nop 指令修改为跳转指令,跳转到 strcmp_zbb 处执行;相反,不支持 ZBB 的 CPU 或者没有配置 ZBB 选项,只会执行通用的 strcmp 汇编代码。

3.2 运行时的代码段修改

先在 arch/riscv/kernel/alternative.criscv_alternative_fix_offsets() 函数打断点。

image-20230729150151420

这里的 alt_ptrstrcmp,有点像我们要找的地方。我们又在调用栈的上一层 riscv_cpufeature_patch_func() 打断点并重新运行,看执行 riscv_alternative_fix_offsets() 函数之前是什么样子。

调用栈的更上一层,即 riscv_alternative_fix_offsets() 的调用者 _apply_alternatives() 函数我们暂时不讨论。

我们暂停在执行 riscv_cpufeature_patch_func() 函数的 patch_text_nosync() 语句前:

image-20230729165650943

oldptr 指向代码段 strcmpaltptr 指向代码段 strcmp+130strcmp+130 的指令内容为 j strcmp+34,即跳转到 Zbb 拓展实现的 strcmp_zbb 处。

为什么突然出现 strcmp+130 这样的地址呢?

刚刚的 strcmp.S 代码中是这样的:

  1. ALTERNATIVE("nop", "j strcmp_zbb", 0, RISCV_ISA_EXT_ZBB, CONFIG_RISCV_ISA_ZBB)

我们对 vmlinux 进行反汇编。strcmp+130 是整个的 strcmp.S 的结束地址,再往后是 strlen

  1. $ riscv64-linux-objdump -D -j .text vmlinux | grep -A "35" "<strcmp_zbb>:"
  2. ffffffff800ab18a <strcmp_zbb>:
  3. ffffffff800ab18a: 00b563b3 or t2,a0,a1
  4. ffffffff800ab18e: 5efd li t4,-1
  5. ffffffff800ab190: 0073f393 and t2,t2,7
  6. ffffffff800ab194: 02039e63 bnez t2,ffffffff800ab1d0 <strcmp_zbb+0x46>
  7. ffffffff800ab198: 00053283 ld t0,0(a0)
  8. ffffffff800ab19c: 0005b303 ld t1,0(a1)
  9. ffffffff800ab1a0: 2872de13 orc.b t3,t0
  10. ffffffff800ab1a4: 03de1163 bne t3,t4,ffffffff800ab1c6 <strcmp_zbb+0x3c>
  11. ffffffff800ab1a8: 0521 add a0,a0,8
  12. ffffffff800ab1aa: 05a1 add a1,a1,8
  13. ffffffff800ab1ac: fe6286e3 beq t0,t1,ffffffff800ab198 <strcmp_zbb+0xe>
  14. ffffffff800ab1b0: 6b82d293 rev8 t0,t0
  15. ffffffff800ab1b4: 6b835313 rev8 t1,t1
  16. ffffffff800ab1b8: 0062b533 sltu a0,t0,t1
  17. ffffffff800ab1bc: 40a00533 neg a0,a0
  18. ffffffff800ab1c0: 00156513 or a0,a0,1
  19. ffffffff800ab1c4: 8082 ret
  20. ffffffff800ab1c6: 00629563 bne t0,t1,ffffffff800ab1d0 <strcmp_zbb+0x46>
  21. ffffffff800ab1ca: 4501 li a0,0
  22. ffffffff800ab1cc: 8082 ret
  23. ffffffff800ab1ce: 0001 nop
  24. ffffffff800ab1d0: 00054283 lbu t0,0(a0)
  25. ffffffff800ab1d4: 0005c303 lbu t1,0(a1)
  26. ffffffff800ab1d8: 0505 add a0,a0,1
  27. ffffffff800ab1da: 0585 add a1,a1,1
  28. ffffffff800ab1dc: 00629463 bne t0,t1,ffffffff800ab1e4 <strcmp_zbb+0x5a>
  29. ffffffff800ab1e0: fe0298e3 bnez t0,ffffffff800ab1d0 <strcmp_zbb+0x46>
  30. ffffffff800ab1e4: 40628533 sub a0,t0,t1
  31. ffffffff800ab1e8: 8082 ret
  32. ffffffff800ab1ea: fa1ff06f j ffffffff800ab18a <strcmp_zbb>
  33. ...
  34. ffffffff800ab1f8 <__pi_strlen>:
  35. ffffffff800ab1f8: 00000013 nop
  36. ffffffff800ab1fc: 832a mv t1,a0

可以发现新指令 j strcmp_zbb 实际上是存放在 strcmp.S 的结束处的。说明 ALTERNATIVE 搞了一些魔法,定位到了该节的末尾,插入了新代码 j strcmp_zbb。我们将在文章的下个小节讨论这个魔法。

继续分析后续代码。在执行了 patch_text_nosync() 后,刚刚查看的 strcmp 内存处的 nop 指令已经变成了 j 0xffffffff800ab108,如下图:

image-20230729171229044

这个跳转地址很奇怪,看起来没有任何作用。我们将在文章的下个小节讨论它。

执行了 riscv_alternative_fix_offsets() 后,原来的 nop 变成了 j strcmp+34

image-20230729173407736

查看 strcmp+34 处的指令,可以发现该处即为使用 Zbb 拓展指令实现的高性能 strcmp_zbb

image-20230729152123586

在此之后,内核代码段的内容已经被修改。原本调用 strcmp() 执行的第一条指令为 nop 指令,现在 nop 指令被修改为无条件跳转指令。调用 strcmp() 会立即跳转到 34 字节外的新指令 strcmp_zbb 上。

原有的:

  1. strcmp:
  2. nop
  3. < 通用的 strcmp 汇编代码 >
  4. ret
  5. strcmp_zbb:
  6. < 使用 Zbb 拓展指令的 strcmp_zbb 汇编代码 >
  7. ret

修改后:

  1. strcmp:
  2. j strcmp_zbb
  3. < 通用的 strcmp 汇编代码 >
  4. ret
  5. strcmp_zbb:
  6. < 使用 Zbb 拓展指令的 strcmp_zbb 汇编代码 >
  7. ret

代码段 strcmp label 处的指令的变化过程:

语句代码段 strcmp label 处的指令
patch_text_nosync() 执行前nop
patch_text_nosync() 执行后j 0xffffffff800ab108 <__memset+156>
riscv_alternative_fix_offsets() 执行后j 0xffffffff800ab18a <strcmp+34>

经过两次代码段修改,nop 指令被修改为跳转到 strcmp_zbb 的指令

3.3 ELF 的 .alternative 节与魔法

  1. $ riscv64-linux-readelf -S vmlinux.o | grep -A 1 alternative
  2. [21332] .alternative PROGBITS 0000000000000000 0017df18
  3. 0000000000000030 0000000000000000 A 0 0 1

ALTERNATIVE 汇编宏展开时,会调用 ALT_NEW_CONTENT 汇编宏。

  1. // arch/riscv/include/asm/alternative-macros.h:61
  2. #define ALT_ENTRY(oldptr, newptr, vendor_id, patch_id, newlen) \
  3. ".4byte ((" oldptr ") - .) \n" \
  4. ".4byte ((" newptr ") - .) \n" \
  5. ".2byte " vendor_id "\n" \
  6. ".2byte " newlen "\n" \
  7. ".4byte " patch_id "\n"
  8. #define ALT_NEW_CONTENT(vendor_id, patch_id, enable, new_c) \
  9. ".if " __stringify(enable) " == 1\n" \
  10. ".pushsection .alternative, \"a\"\n" \
  11. ALT_ENTRY("886b", "888f", __stringify(vendor_id), __stringify(patch_id), "889f - 888f") \
  12. ".popsection\n" \
  13. ".subsection 1\n" \
  14. "888 :\n" \
  15. ".option push\n" \
  16. ".option norvc\n" \
  17. ".option norelax\n" \
  18. new_c "\n" \
  19. ".option pop\n" \
  20. "889 :\n" \
  21. ".org . - (887b - 886b) + (889b - 888b)\n" \
  22. ".org . - (889b - 888b) + (887b - 886b)\n" \
  23. ".previous\n" \
  24. ".endif\n"

其中,".4byte ((" oldptr ") -.) \n"oldptr label 到当前位置的距离存储到了这个 4byte 大小空间中。有了偏移量,在应用 alternative 时(即刚刚打断点的 riscv_cpufeature_patch_func() 函数的调用者 _apply_alternatives()),就能计算出新代码和旧代码的位置: image-20230729191712546

保存 offset 这个行为是由 该 patch 提供的,在这之前是直接保存的绝对地址,占用了更多的空间。

该汇编宏使用 .pushsection 在 ELF 文件中建立了 .alternative 节,将偏移量、vendor_idpatch_id 一起放在该节中。新代码并没有存储在这里,应该是为了缓存命中率的考虑,把新代码放在了所属代码节末尾。

存在于代码最前端的 ALTERNATIVE 展开后,能够把新代码放到代码段末尾,是通过 .subsection.previous 实现的。

因此,后续 _apply_alternatives() 可以遍历该节每个 entry 来替换旧代码。

3.4 替换了两次旧代码?

在刚刚的分析中,我们 strcmp 处的代码被替换了两次,第一次替换的新代码跳转到 __memset+156 处,在 riscv_alternative_fix_offsets() 执行后,新代码才跳转到 strcmp_zbb 处。

首先我们来看看 patch_text_nosync() 的定义:

  1. // arch/riscv/kernel/patch.c:99
  2. int patch_text_nosync(void *addr, const void *insns, size_t len)
  3. {
  4. u32 *tp = addr;
  5. int ret;
  6. ret = patch_insn_write(tp, insns, len);
  7. if (!ret)
  8. flush_icache_range((uintptr_t) tp, (uintptr_t) tp + len);
  9. return ret;
  10. }

这个函数在 addr 上写入长度为 leninsns。更改代码段需要刷新 icache 来确保缓存一致性。

  1. // arch/riscv/kernel/alternative.c:103
  2. void riscv_alternative_fix_offsets(void *alt_ptr, unsigned int len,
  3. int patch_offset)
  4. {
  5. int num_insn = len / sizeof(u32);
  6. int i;
  7. for (i = 0; i < num_insn; i++) {
  8. u32 insn = riscv_instruction_at(alt_ptr + i * sizeof(u32));
  9. /*
  10. * May be the start of an auipc + jalr pair
  11. * Needs to check that at least one more instruction
  12. * is in the list.
  13. */
  14. if (riscv_insn_is_auipc(insn) && i < num_insn - 1) {
  15. u32 insn2 = riscv_instruction_at(alt_ptr + (i + 1) * sizeof(u32));
  16. if (!riscv_insn_is_jalr(insn2))
  17. continue;
  18. /* if instruction pair is a call, it will use the ra register */
  19. if (RV_EXTRACT_RD_REG(insn) != 1)
  20. continue;
  21. riscv_alternative_fix_auipc_jalr(alt_ptr + i * sizeof(u32),
  22. insn, insn2, patch_offset);
  23. i++;
  24. }
  25. if (riscv_insn_is_jal(insn)) {
  26. s32 imm = riscv_insn_extract_jtype_imm(insn);
  27. /* Don't modify jumps inside the alternative block */
  28. if ((alt_ptr + i * sizeof(u32) + imm) >= alt_ptr &&
  29. (alt_ptr + i * sizeof(u32) + imm) < (alt_ptr + len))
  30. continue;
  31. riscv_alternative_fix_jal(alt_ptr + i * sizeof(u32),
  32. insn, patch_offset);
  33. }
  34. }
  35. }

riscv_alternative_fix_offset() 函数根据偏移量计算出真正需要跳转的距离,按情况指派给 riscv_alternative_fix_auipc_jalr()riscv_alternative_fix_jal()riscv_alternative_fix_offsets() 函数,这些函数生成对应的指令后,再次使用 patch_text_nosync() 来修改目标地址代码。

经过 git blame 和搜索邮件列表,我们发现有一 patch 做了以下改动:

  1. patch_text_nosync(alt->old_ptr, alt->alt_ptr, alt->alt_len);
  2. + riscv_alternative_fix_offsets(alt->old_ptr, alt->alt_len,
  3. + alt->old_ptr - alt->alt_ptr);

邮件中写到:

  1. Alternatives live in a different section, so addresses used by call
  2. functions will point to wrong locations after the patch got applied.
  3. Similar to arm64, adjust the location to consider that offset.

原来是因为单独的一行 patch_text_nosync() 会有 bug,offset 计算不正确,所以才使用一个单独的函数来修补错误。

4 总结

内核的启动过程中,会检测 CPU 的型号,遍历 .alternative 节中的 entry,符合条件的 patch 会被 apply。apply 会修改目标地址的旧代码为新代码,同时确保 icache 一致性。

针对 RISC-V 架构设备而言,其模块化的架构设计意味着不同的扩展可以拥有各自的优化指令。CPU 的指令集只能在运行时才能得知,在此之前只能使用兼容性最高的指令。“alternative” 在这种情况下是一种非常好的解决方案,既保证了兼容性,也提高了性能。

5 参考资料



Read Album:

Read Related:

Read Latest: