#Linux

Windows上通过WSL2进行Linux开发,但是有时候需要开发带GUI的引用,这样就需要将图像转发。

配置Windows

下载安装XMing,启动Xlaunch。

  1. 选择MultiWindow
  2. 设置Display number为10(可以自行设置,主要是需要和后面在WSL2中设置的变量保持一致)
  3. 选择Start no client(Windows的XMing是被动等待接收图像数据,所以选择该项)
  4. 一直下一页,其余保持默认,点击完成即可。

配置VSCode

安装RemoteX11插件,直接在插件中心搜索安装即可。

打开设置页面,搜索Remote x11,找到如下配置项,将Display Number配置为10

配置WSL2

安装xclock用于测试

sudo apt-get install xclock

设置环境变量

export DISPLAY=localhost:10.0
# 或者
export DISPLAY=:0

运行xclock查看结果

repo 源配置解析

openEuler 的软件源配置文件位于/etc/yum.repos.d/目录下,以.repo 为后缀名,文件名可以任意取,但是必须以.repo 结尾。

#generic-repos is licensed under the Mulan PSL v2.
#You can use this software according to the terms and conditions of the Mulan PSL v2.
#You may obtain a copy of Mulan PSL v2 at:
#    http://license.coscl.org.cn/MulanPSL2
#THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR
#IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR
#PURPOSE.
#See the Mulan PSL v2 for more details.

[OS]
name=OS
baseurl=http://repo.openeuler.org/openEuler-23.09/OS/$basearch/
metalink=https://mirrors.openeuler.org/metalink?repo=$releasever/OS&arch=$basearch
metadata_expire=1h
enabled=1
gpgcheck=1
gpgkey=http://repo.openeuler.org/openEuler-23.09/OS/$basearch/RPM-GPG-KEY-openEuler

[source]
name=source
baseurl=http://repo.openeuler.org/openEuler-23.09/source/
metalink=https://mirrors.openeuler.org/metalink?repo=$releasever&arch=source
metadata_expire=1h
enabled=1
gpgcheck=1
gpgkey=http://repo.openeuler.org/openEuler-23.09/source/RPM-GPG-KEY-openEuler

其中各个配置项的含义如下:

  • [repoid]中的 repoid 为软件仓库(repository)的 ID 号,所有.repo 配置文件中的各 repoid 不能重复,必须唯一。示例中 repoid 为 OS 和 source。
  • name 为软件仓库描述的字符串,可以任意取,但是建议取一个有意义的名称,方便用户理解。示例中 name 为 OS 和 source。
  • baseurl 为软件仓库的地址,可以是 http、https、ftp 等协议,也可以是本地目录。
  • enabled 为是否启用该软件源仓库,可选值为 1 和 0。默认值为 1,表示启用该软件源仓库。示例中 enabled 为 1。
  • metalink 为动态的镜像地址,用于镜像加速。
  • metadata_expire 为元数据过期时间,单位为秒。默认值为 90 分钟,即 5400 秒。示例中 metadata_expire 为 1h,即 1 小时。
  • gpgcheck 可设置为 1 或 0,1 表示进行 gpg(GNU Private Guard)校验,0 表示不进行 gpg 校验,gpgcheck 可以确定 rpm 包的来源是有效和安全的。
  • gpgkey 为验证签名用的公钥地址,如果 gpgcheck 为 1,则必须设置 gpgkey。

gpgcheck 详解

RPM-GPG-KEY 是一个公共密钥,用于验证由该密钥签名的RPM包的真实性和完整性。在使用 yumdnf 这样的包管理工具时,这些工具会使用 GPG 密钥来验证软件包的签名,以确保软件包来自于可信的源,且未被篡改。

  1. 命名约定: RPM-GPG-KEY 是一个命名约定,通常与其所属的仓库或发行版相关。例如,如果你在使用某个特定发行版的官方仓库,它可能会提供一个 RPM-GPG-KEY 文件来进行软件包签名验证。如openEuler官方仓库提供的 RPM-GPG-KEY 文件名为 RPM-GPG-KEY-openEuler

  2. 密钥生成: 这个密钥是通过 GPG(GNU Privacy Guard)工具生成的。GPG 是一个用于进行加密和签名的开源工具。RPM-GPG-KEY 文件包含了一个公钥,该公钥由仓库所有者使用私钥签署软件包,而用户使用公钥验证软件包。

  3. 验证软件包: 当用户使用 yumdnf 安装软件包时,这些工具会检查软件包的签名,并使用相应的 RPM-GPG-KEY 文件中的公钥来验证签名。如果验证通过,工具会认为软件包是可信的,否则将会发出警告或拒绝安装。

  4. 导入密钥: 为了使用 RPM-GPG-KEY 文件,用户通常需要将密钥导入到本地系统中。这通常可以通过运行类似于以下命令的导入密钥的操作来完成:

    rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-openEuler

    上述命令中的 /etc/pki/rpm-gpg/RPM-GPG-KEY-openEuler 路径可能会因发行版和配置而有所不同。

RPM包签名

对于发布的 RPM 包进行 GPG 签名是一种重要的安全措施,可以确保接收者能够验证软件包的真实性和完整性。以下是一般的步骤:

  1. 生成 GPG 密钥: 如果你还没有 GPG 密钥对,你需要使用 GPG 工具生成一对密钥,包括私钥和公钥。你可以运行以下命令来生成密钥:

    gpg --gen-key 

    当你运行 gpg --gen-key 命令时,它会启动 GPG(GNU Privacy Guard)的密钥生成过程。这个过程将引导你提供一些必要的信息以生成密钥对,包括私钥和公钥。以下是这个命令的详细步骤:

    • 选择密钥类型: 你将被要求选择密钥的类型。通常,默认的 RSA 和 DSA 都是可接受的选择,你可以通过键入数字来选择。

    • 选择密钥大小: 你将被要求选择密钥的大小。通常,默认值(通常是2048位)是足够的,但你也可以选择更大的值。

    • 选择密钥的有效期: 你将被要求选择密钥的有效期。你可以选择密钥永久有效,或者在一段特定的时间内有效。如果你选择了特定的时间,你需要输入一个表示有效期的值,例如1y表示一年,1m表示一个月。

    • 提供用户标识信息: 你将被要求提供与密钥相关联的用户标识信息。这包括你的真实姓名、电子邮件地址和一个可选的注释。

    • 确认提供的信息: GPG 将显示你提供的信息并询问你是否确认。如果确认无误,你可以输入 O 或直接按回车键。

    • 输入保护密语(passphrase): 你将被要求输入保护密语,用于保护你的私钥。请确保选择一个强密码。

    • 等待密钥生成: GPG 将使用提供的信息生成密钥对。这可能需要一些时间,具体取决于你选择的密钥大小。

    • 生成完成: 一旦生成完成,你将看到一条消息表明密钥生成成功。

    在整个过程中,你将看到类似以下的一些提示:

    gpg: key ABCDEFGH marked as ultimately trusted
    gpg: revocation certificate stored as '/home/your_user/.gnupg/openpgp-revocs.d/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.rev'
    public and secret key created and signed.
    
    gpg: checking the trustdb
    gpg: 3 marginal(s) needed, 1 complete(s) needed, PGP trust model
    gpg: depth: 0  valid:   2  signed:   0  trust: 0-, 0q, 0n, 0m, 0f, 2u
    pub   2048R/XXXXXXXX 2024-01-01 [expires: 2024-01-01]
        Key fingerprint = XXXX YYYY ZZZZ AAAA BBBB  CCCC DDDD EEEE FFFF 1111
    uid                  Your Name <your.email@example.com>
    sub   2048R/YYYYYYYY 2024-01-01 [expires: 2024-01-01]

    在这个例子中,XXXXXXXX 是你的密钥 ID,YYYYYYYY 是子密钥的 ID。你可以使用这些 ID 来引用你的密钥。

  2. 导出公钥: 生成密钥后,你需要将公钥导出。运行以下命令:

    gpg --output RPM-GPG-KEY-your-repo --armor --export your@email.com

    这将生成一个 ASCII 格式的公钥文件 RPM-GPG-KEY-your-repo,你可以与软件包一起发布。

  3. 为 RPM 包签名: 在构建 RPM 包时,使用 rpmbuild 命令时,可以通过添加 --sign 选项来指示 rpmbuild 对 RPM 包进行签名。例如:

    rpmbuild -ba your-package.spec --sign

    当你执行 rpmbuild -ba your-package.spec --sign 命令时,rpmbuild 会使用默认的 GPG 密钥进行签名。这通常是你在系统上配置为默认 GPG 密钥的密钥。

    你可以通过检查 rpmbuild 使用的 GPG 密钥来确认它是哪个密钥:

    rpm -q gpg-pubkey --qf '%{name}-%{version}-%{release} --> %{summary}\n'

    这个命令将显示系统上安装的 GPG 公钥,其中默认的密钥可能是 “gpg-pubkey-xxxxxxxx-yyyyyyyy”。你可以根据密钥的 “xxxxxxxx-yyyyyyyy” 部分来确定默认使用的 GPG 密钥。

    如果你想使用不同的 GPG 密钥进行签名,可以在 rpmbuild 命令中使用 --signwith 选项,例如:

    rpmbuild -ba your-package.spec --signwith <key-id>

    其中 <key-id> 是你想要使用的 GPG 密钥的 ID。这会覆盖默认的密钥。确保你在构建和签名 RPM 包时使用的是正确的 GPG 密钥。

  4. 导入密钥: 为了验证你的软件包,用户需要导入你的公钥。他们可以运行以下命令:

    rpm --import RPM-GPG-KEY-your-repo
  5. 发布: 将签名的 RPM 包和公钥一起发布。确保用户知道他们可以使用导入的公钥来验证软件包的签名。

初学 Linux 内核或者第一次编译使用内核模块时经常会遇到类似这样的错误:

insmod: ERROR: could not insert module kvm.ko: Invalid module format 

这个报错通常由于当前插入kvm.koversion magic版本信息与正在运行的 kernel 的version magic版本不一致导致的。

内核校验模块的流程

我们从问题出发,看看内核是如何校验模块的。搜索了内核源码,找到了在函数check_version中抛出了disagrees about version of symbol错误信息,我们根据源码来回溯一下整个过程。

// kernel/module.c
static int check_version(const struct load_info *info,
    const char *symname,
    struct module *mod,
    const s32 *crc)
{
 Elf_Shdr *sechdrs = info->sechdrs;
 unsigned int versindex = info->index.vers;
 unsigned int i, num_versions;
 struct modversion_info *versions;

 /* Exporting module didn't supply crcs?  OK, we're already tainted. */
 if (!crc)
  return 1;

 /* No versions at all?  modprobe --force does this. */
 if (versindex == 0)
  return try_to_force_load(mod, symname) == 0;

 versions = (void *) sechdrs[versindex].sh_addr;
 num_versions = sechdrs[versindex].sh_size
  / sizeof(struct modversion_info);

 for (i = 0; i < num_versions; i++) {
  u32 crcval;

  if (strcmp(versions[i].name, symname) != 0)
   continue;

  if (IS_ENABLED(CONFIG_MODULE_REL_CRCS))
   crcval = resolve_rel_crc(crc);
  else
   crcval = *crc;
  if (versions[i].crc == crcval)
   return 1;
  pr_debug("Found checksum %X vs module %lX\n",
    crcval, versions[i].crc);
  goto bad_version;
 }

 /* Broken toolchain. Warn once, then let it go.. */
 pr_warn_once("%s: no symbol version for %s\n", info->name, symname);
 return 1;

bad_version:
 pr_warn("%s: disagrees about version of symbol %s\n",
        info->name, symname);
 return 0;
}

参数说明:

  • info: 包含正在加载信息的结构体。
  • symname: 需要查找对比的符号的名称。
  • mod: 表示模块的结构体。
  • crc: 当前正在运行的内核的模块符号的 CRC(Cyclic Redundancy Check)值
  1. 如果 CRC 为空,不检查直接 PASS
  2. 如果模块中没有_versions 小节,表示模块没有开启 CRC
    1. 如果开启了 CONFIG_MODULE_FORCE_LOAD,则强制加载,内核标记为 tainted,直接 PASS
    2. 如果没有开启 CONFIG_MODULE_FORCE_LOAD,则报错
  3. 如果模块中有_versions 小节,没有找到 module_layout 符号,报 warning,直接 PASS
  4. 如果模块中有_versions 小节,找到 module_layout 符号
    1. 对比_versions 小节中的 CRC 和参数中的 CRC,如果一致,PASS
    2. 如果不一致,报错

插播一句,CRC是什么?在 Linux 内核中,模块符号 CRC(Cyclic Redundancy Check)是一种校验值,用于确保模块中的符号(函数、变量等)在加载时与内核中的符号一致。当模块被构建时,针对每个符号都会计算一个 CRC 值,然后将这些 CRC 值保存在模块的符号版本表中。简单理解就是如果要保持CRC不变,需要满足两个条件:

  1. 语法保持不变
    遵守这个条件,说明如果模块在新内核下重新编译,那应该没有任何语法问题。 即导出符号的类型名没有变化,如果是函数,则要求参数和返回值类型没有任何变化;如果这些类型是结构体的话,结构体的成员名也没有有任何变化。
  2. 语义保持不变
    这要求符号的类型不能有变化,如果类型本身是结构体(struct),则它成员的类型不能有变化,成员在结构体内的位置不能有变化,以及成员本身不能增删。
    如果想要深入了解如何计算CRC可以参考这篇博客:Linux内核模块符号CRC检查机制-CSDN博客

分析代码我们可以知道,内核会通过遍历正在加载的模块的版本信息的数组versions,从中查找与给定符号名称匹配的版本信息。如果找到匹配的版本信息,则计算 CRC 值,与参数中的 CRC 值进行比较。(通过后续分析我们知道参数的 CRC 就是正在运行的内核的 module_layout 符号的 CRC)如果匹配,表示版本一致,返回 1。如果不匹配,打印调试信息,并跳转到 bad_version,输出警告信息。

这里有两个疑问,

  1. versions 内容是怎么链接到模块的 elf 文件中的?

我们找到模块的mod.c文件,打开可以发现以下内容:

MODULE_INFO(vermagic, VERMAGIC_STRING);
MODULE_INFO(name, KBUILD_MODNAME);

__visible struct module __this_module
__section(.gnu.linkonce.this_module) = {
 .name = KBUILD_MODNAME,
 .init = init_module,
#ifdef CONFIG_MODULE_UNLOAD
 .exit = cleanup_module,
#endif
 .arch = MODULE_ARCH_INIT,
};

#ifdef CONFIG_RETPOLINE
MODULE_INFO(retpoline, "Y");
#endif

static const struct modversion_info ____versions[]
__used __section(__versions) = {
 { 0x3e549f1d, "module_layout" },
 { 0x5138e6ba, "param_ops_int" },
 { 0x183b57f9, "phy_ethtool_nway_reset" },
 { 0x6e5363eb, "eth_validate_addr" },
 { 0x4df55e5b, "usb_deregister" },
 { 0x8e48f69b, "usb_register_driver" },
}

这个文件是在编译过程中调用了scripts/modpost脚本生成的,它的功能是在里面增加了 2 个__section.gnu.linkonce.this_module__versions。__versions 小节的内容就是一些字符串和值组成的数组,check_version就是解析这个小节去做验证。

  1. CRC 值哪来的?

我们继续向上跟踪,找到函数check_modstruct_version,其中find_symbol会在内核符号表中查找给定符号名称的符号信息,接着调用check_version函数,传入符号名称、模块结构体和 CRC 值,进行版本匹配。

// kernel/module.c
static inline int check_modstruct_version(const struct load_info *info,
       struct module *mod)
{
 const s32 *crc;

 /*
  * Since this should be found in kernel (which can't be removed), no
  * locking is necessary -- use preempt_disable() to placate lockdep.
  */
 preempt_disable();
 if (!find_symbol("module_layout", NULL, &crc, true, false)) {
  preempt_enable();
  BUG();
 }
 preempt_enable();
 return check_version(info, "module_layout", mod, crc);
}
// 获取当前运行内核 module_layout 函数的 crc 值
find_symbol("module_layout", NULL, &crc, true, false)
    each_symbol_section(find_exported_symbol_in_section, &fsa)
        // 遍历内核三个导出符号表段__start___ksymtab,__start___ksymtab_gpl 和__start___ksymtab_gpl_future,为每段调用 find_symbol_in_section
        each_symbol_in_section(arr, ARRAY_SIZE(arr), NULL, fn, data)
        // 遍历内核每个已加载模块的三个导出符号表段 mod->syms,mod->gpl_syms,mod->gpl_future_syms,为每段调用 find_symbol_in_section
        each_symbol_in_section(arr, ARRAY_SIZE(arr), mod, fn, data)
            // 对导出符号表进行二分查找,按照字符串排序,符号表的地址按照地址排序
            find_exported_symbol_in_section(const struct symsearch *syms,struct module *owner,void *data)

Linux 对可装载模块采取了两层验证:除了上述的模块 CRC 值校验外还有 vermagic 的检查。模块 vermagic(即 Version Magic String)保存了模块编译时的内核版本以及 SMP 等配置信息,当模块 vermagic 与主机信息不相符时也无法加载模块。

在内核中load_module函数调用check_modstruct_version函数完成 CRC 校验后,就会继续调用layout_and_allocate --> check_modinfo 完成 vermagic 校验。

// kernel/module.c
static int check_modinfo(struct module *mod, struct load_info *info, int flags)
{
 const char *modmagic = get_modinfo(info, "vermagic");
 int err;

 if (flags & MODULE_INIT_IGNORE_VERMAGIC)
  modmagic = NULL;

 /* This is allowed: modprobe --force will invalidate it. */
 if (!modmagic) {
  err = try_to_force_load(mod, "bad vermagic");
  if (err)
   return err;
 } else if (!same_magic(modmagic, vermagic, info->index.vers)) {
  pr_err("%s: version magic '%s' should be '%s'\n",
         info->name, modmagic, vermagic);
  return -ENOEXEC;
 }
...

 return 0;
}

get_modinfo 会获取内核中的 vermagic 信息,模块 vermagic 信息则被保存在了 ELF 的 .modinfo 小节中。这里我们说的 vermagic 就是下文提到的拓展版本信息,它就是系统配置信息组成的一个字符串。

如何解决模块校验错误

Linux 对可装载模块采取了两层验证,我们需要分别从 CRC 和 vermagic 两个方面来解决模块校验错误。首先从简单的 vermagic 校验开始。我们需要保证运行的内核版本与模块编译时的内核版本一致,这样才能保证 vermagic 校验通过。首先了解如何查看内核版本以及模块版本信息,然后修改内核模块版本信息。

解决 vermagic 校验错误

如何查看内核版本以及模块版本信息

uname参数功能:

  • -s, 输出 kernel 名称;
  • -n, 输出主机名;
  • -r, 输出 kernel 发行版本号;
  • -v, 输出操作系统版本;
  • -m, 输出主机的硬件架构名称;
  • -p, 输出处理器类型;
  • -i, 输出硬件平台;
  • -o, 输出操作系统名称
  • -a, 输出所有信息
# 输出kernel发行版本号
uname -r
6.4.0-10.1.0.20.oe2309.riscv64
# 输出所有信息
uname -a
Linux openeuler 6.4.0-10.1.0.20.oe2309.riscv64 #1 SMP Sat Oct  7 06:19:28 UTC 2023 riscv64 riscv64 riscv64 GNU/Linux

modinfo 可以查看模块信息,包括模块vermagic信息。

modinfo kvm.ko
filename:       /root/build-kernel/kernel/./arch/riscv/kvm/kvm.ko
license:        GPL
author:         Qumranet
srcversion:     5DA13DC0E55100B5FE1D56A
depends:
intree:         Y
name:           kvm
vermagic:       6.4.0 SMP mod_unload modversions riscv
parm:           halt_poll_ns:uint
parm:           halt_poll_ns_grow:uint
parm:           halt_poll_ns_grow_start:uint
parm:           halt_poll_ns_shrink:uint

其中vermagic就是version magic版本信息,可以看到当前kvm.koversion magic版本信息为6.4.0。与前文的uname -r输出的 kernel 发行版本号6.4.0-10.1.0.20.oe2309.riscv64不一致。所以会报错。

修改内核模块版本信息

  1. 修改基础版本信息

打开内核源代码根目录下的 Makefile 文件。你会找到一个包含内核版本信息的地方,类似于:

# SPDX-License-Identifier: GPL-2.0
VERSION = 6
PATCHLEVEL = 4
SUBLEVEL = 0
EXTRAVERSION =

表示内核版基础本号为6.4.0

  1. 修改拓展版本信息

kernel 引入了一些配置来增强版本信息,在内核源码的"include/linux/vermagic.h"下我们可以看到模块的健全版本信息,如下默认配置的有:

/* Simply sanity version stamp for modules. */
#ifdef CONFIG_SMP
#define MODULE_VERMAGIC_SMP "SMP "
#else
#define MODULE_VERMAGIC_SMP ""
#endif
#ifdef CONFIG_PREEMPT_BUILD
#define MODULE_VERMAGIC_PREEMPT "preempt "
#elif defined(CONFIG_PREEMPT_RT)
#define MODULE_VERMAGIC_PREEMPT "preempt_rt "
#else
#define MODULE_VERMAGIC_PREEMPT ""
#endif
#ifdef CONFIG_MODULE_UNLOAD
#define MODULE_VERMAGIC_MODULE_UNLOAD "mod_unload "
#else
#define MODULE_VERMAGIC_MODULE_UNLOAD ""
#endif
#ifdef CONFIG_MODVERSIONS
#define MODULE_VERMAGIC_MODVERSIONS "modversions "
#else
#define MODULE_VERMAGIC_MODVERSIONS ""
#endif
#ifdef RANDSTRUCT
#include <generated/randstruct_hash.h>
#define MODULE_RANDSTRUCT "RANDSTRUCT_" RANDSTRUCT_HASHED_SEED
#else
#define MODULE_RANDSTRUCT
#endif

#define VERMAGIC_STRING                                                 \
        UTS_RELEASE " "                                                 \
        MODULE_VERMAGIC_SMP MODULE_VERMAGIC_PREEMPT                     \
        MODULE_VERMAGIC_MODULE_UNLOAD MODULE_VERMAGIC_MODVERSIONS       \
        MODULE_ARCH_VERMAGIC                                            \
        MODULE_RANDSTRUCT

其中,"UTS_RELEASE"的配置在内核源码的"include/generated/utsrelease.h"下可以看到,utsrelease.h的内容是由Makefile和``.config的内容来生成的,当成功编译kernel以后,utsrelease.h`得到更新,

#defineUTS_RELEASE "6.4.0"

那么前文”6.4.0-d46299ae”中的”-d46299ae”是如何得来的呢?其实这个是开发者使用了 Git 来管理代码。当你修改或者提交代码以后,每次编译内核后,在”UTS_RELEASE”后面就会看到一串哈希值,例如”6.4.0-d46299ae”,其中”-d46299ae”这个就是 Git 版本控制而产生的哈希值。发行版本号只是各个厂商用于区别自己发布的不同时期的 kernel 版本或者产品版本而产生的编号,完全由各厂商自己定义。

解决 CRC 校验错误

我们可以通过一些手段修改新模块的 module_layout 的 CRC 与内核CRC相同,再插入。/boot目录下通常有个在symvers-<kernel_version>.gz 文件通常包含了内核模块的符号版本信息。这个文件是由 Linux 内核构建时生成的,用于记录在该内核版本下构建的模块的符号信息,包括函数和变量的名称、版本号等。里面就保存了内核的 module_layout 的 CRC 值。我们可以使用gzip -d命令解压这个文件,找到 module_layout 的 CRC 值,记录下来。

  • 方法一:使用 16 进制编辑器修改模块文件,将 module_layout 的值修改为相同的值,再插入。
    在 Linux 中,可以使用 hexdumpxxd 等工具查看二进制文件的内容,并尝试编辑。下面是一种使用 xxd 查看和修改二进制文件的方法:

    1. 使用 xxd 将二进制文件转换为十六进制文本:

      xxd /usr/src/linux-6.4.0-10.1.0.20.oe2309.riscv64/arch/riscv/kvm/kvm.ko > kvm_hex.txt

      这将创建一个名为 kvm_hex.txt 的文本文件,其中包含 kvm.ko 的十六进制表示。

    2. 使用文本编辑器(如 nanovim)打开 kvm_hex.txt,找到并编辑 module_layout 的值。请确保你了解所做更改的含义,并且只修改你确信的内容。

    3. 保存并关闭文本编辑器。

    4. 使用 xxd 将修改后的十六进制文本转换回二进制文件:

      xxd -r kvm_hex.txt > /usr/src/linux-6.4.0-10.1.0.20.oe2309.riscv64/arch/riscv/kvm/kvm.ko

      这将覆盖原始的 kvm.ko 文件。

  • 方法二:修改 Module.symvers

    # 清理编译结果。不要使用 make distclean,这会删除.config 文件以及 Module.symvers 文件
    make clean
    # 修改 Module.symvers 文件
    sed -i  '/module_layout/ s/0x[0-9a-f][0-9a-f]*/0xdf88831e/' Module.symvers
    # 重新编译模块
    make M=./arch/riscv/kvm/ -j33
    # 检查模块的 module_layout
    modprobe --dump-modversions /usr/src/linux-6.4.0-10.1.0.20.oe2309.riscv64/arch/riscv/kvm/kvm.ko | grep module_layout
    0xdf88831e

如何生成 .mod.c 文件

如果是自己写的模块,可以根据下面的命令编译模块,就可以得到.mod.c文件,它是源文件到.mod.o文件的一个中间文件。

make -C /root/build-kernel/kernel M=/driver_study/ modules -j22

如果是用内核自己的模块,执行make modules命令,也可以得到.mod.c文件。但是一般执行make -j22命令就不会生成这些文件,我们可以找一个启用的内核模块,例如kvm.ko,执行下面的命令,就可以得到.mod.c文件。不能随便找个模块,必须在 config 中开启的模块,否则会报错undefined!

make  M=./arch/riscv/kvm modules -j22

本次实验是清华大学操作系统课程的课程实验,实验内容是基于 RISC-V 架构的 uCore 操作系统。本次实验的目的是搭建实验环境,为后续实验做准备。指导书参考uCore-Tutorial-Guide-2023S 文档。本系列文章内容主要是指导书的补充以及我在实验过程的一些理解。

本章没有什么需要特别说明的,指导手册十分详细,按照指导手册的步骤一步步来就可以了。因为平时也在用 WSL2 开发,所以配置十分顺利,没有遇到什么问题。这篇文章就当占坑了,如果后续有什么需要补充的再来更新。

了解系统调用

操作系统的系统调用(syscall)是操作系统提供给应用程序使用的一种接口。它允许应用程序通过向操作系统发送请求,来执行一些必须由操作系统来完成的任务,例如读取文件、创建进程、分配内存等。

通俗地说,可以把操作系统看作一个巨大的服务员,而应用程序就像是顾客。应用程序不能直接访问硬件或执行特权操作,因为这样可能会导致系统不稳定或不安全。所以,应用程序需要通过系统调用来与操作系统进行交互,请求操作系统代表它完成某些任务。

当应用程序需要操作系统执行特定的功能时,它会调用适当的系统调用函数,并传递参数给它。然后操作系统会接收到这个请求,并根据请求的类型和参数来执行相应的操作。完成后,操作系统会将执行结果返回给应用程序。

在 RISC-V 架构中,系统调用是通过使用特定的指令来实现的。具体来说,RISC-V 架构提供了一个称为 ecall(environment call)的指令来触发系统调用。

要使用 syscall,在 RISC-V 汇编代码中可以通过以下步骤来完成:

  1. 将系统调用编号(syscall number)放入寄存器 a7 中,该编号对应于所需的系统调用功能。
  2. 将系统调用所需的参数放入其他相应的寄存器中。例如,参数传递给文件读取系统调用可能需要将文件描述符放入 a0 寄存器,缓冲区地址放入 a1 寄存器,以及读取的字节数放入 a2 寄存器。
  3. 执行 ecall 指令。这会触发操作系统处理当前运行的程序的系统调用请求。
  4. 操作系统接收到系统调用请求后,根据寄存器 a7 中的系统调用编号和其他寄存器中的参数来执行相应的操作。
  5. 当操作系统完成系统调用请求时,它将结果放入适当的寄存器中,通常是 a0 寄存器。
  6. 程序继续执行,可以检查结果并进行后续的处理。

需要注意的是,具体的系统调用编号以及参数的传递方式会根据操作系统的实现而有所不同。所以在编写 RISC-V 汇编代码时,需要参考操作系统的相关文档来了解具体的系统调用接口和参数传递方式。

makr run 之后发生了什么?

当执行make run命令后,以下是运行流程的概述:

  1. 内核代码编译:执行make run会触发 Makefile 中的相应规则,从而编译生成内核(kernel)二进制文件。

  2. 加载 kernel 并启动 QEMU:根据 QEMUOPTS 变量指定的参数,QEMU 加载生成的 kernel 二进制文件,并启动模拟器。

  3. 引导代码执行:在模拟器启动后,CPU 的通用寄存器被清零,程序计数器(PC)指向 0x1000 的位置,这里有硬件固化的一小段引导代码。该引导代码会迅速跳转到 0x80000000 处的 RustSBI(Rust Supervisor Binary Interface)。

  4. RustSBI 完成硬件初始化:RustSBI 是一个用于与操作系统进行交互的接口层。在跳转到 RustSBI 之后,它会完成必要的硬件初始化工作。

  5. 执行操作系统第一条指令:RustSBI 在完成硬件初始化后,会跳转到 kernel 二进制文件所在内存位置 0x80200000 处,并开始执行我们操作系统的第一条指令。

综上所述,执行make run命令会完成内核的编译和加载,启动 QEMU 虚拟机,并经过引导代码和 RustSBI 的处理,最终开始执行操作系统的第一条指令。

了解链接脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# kernel.ld
BASE_ADDRESS = 0x80200000;
SECTIONS
{
. = BASE_ADDRESS;
skernel = .;

stext = .;
.text : {
*(.text.entry) # 第一行代码
*(.text .text.*)
}

...
}

kernel.ld 中的 BASE_ADDRESS = 0x80200000 指定了内核的加载地址,这个地址哪来的?

以下内容摘自参考rCore-Tutorial-Book-v3 3.6.0-alpha.1 文档

在 Qemu 模拟的 virt 硬件平台上,物理内存的起始物理地址为 0x80000000,物理内存的默认大小为 128MiB,它可以通过 -m 选项进行配置。如果使用默认配置的 128MiB 物理内存则对应的物理地址区间为 [0x80000000,0x88000000) 。如果使用上面给出的命令启动 Qemu,那么在 Qemu 开始执行任何指令之前,首先把两个文件加载到 Qemu 的物理内存中:即作把作为 bootloader 的 rustsbi-qemu.bin 加载到物理内存以物理地址 0x80000000 开头的区域上,同时把内核镜像 os.bin 加载到以物理地址 0x80200000 开头的区域上。

为什么加载到这两个位置呢?这与 Qemu 模拟计算机加电启动后的运行流程有关。一般来说,计算机加电之后的启动流程可以分成若干个阶段,每个阶段均由一层软件或 固件 负责,每一层软件或固件的功能是进行它应当承担的初始化工作,并在此之后跳转到下一层软件或固件的入口地址,也就是将计算机的控制权移交给了下一层软件或固件。Qemu 模拟的启动流程则可以分为三个阶段:第一个阶段由固化在 Qemu 内的一小段汇编程序负责;第二个阶段由 bootloader 负责;第三个阶段则由内核镜像负责。

第一阶段:将必要的文件载入到 Qemu 物理内存之后,Qemu CPU 的程序计数器(PC, Program Counter)会被初始化为 0x1000,因此 Qemu 实际执行的第一条指令位于物理地址 0x1000,接下来它将执行寥寥数条指令并跳转到物理地址 0x80000000 对应的指令处并进入第二阶段。从后面的调试过程可以看出,该地址 0x80000000 被固化在 Qemu 中,作为 Qemu 的使用者,我们在不触及 Qemu 源代码的情况下无法进行更改。

第二阶段:由于 Qemu 的第一阶段固定跳转到 0x80000000,我们需要将负责第二阶段的 bootloader rustsbi-qemu.bin 放在以物理地址 0x80000000 开头的物理内存中,这样就能保证 0x80000000 处正好保存 bootloader 的第一条指令。在这一阶段,bootloader 负责对计算机进行一些初始化工作,并跳转到下一阶段软件的入口,在 Qemu 上即可实现将计算机控制权移交给我们的内核镜像 os.bin。这里需要注意的是,对于不同的 bootloader 而言,下一阶段软件的入口不一定相同,而且获取这一信息的方式和时间点也不同:入口地址可能是一个预先约定好的固定的值,也有可能是在 bootloader 运行期间才动态获取到的值。我们选用的 RustSBI 则是将下一阶段的入口地址预先约定为固定的 0x80200000,在 RustSBI 的初始化工作完成之后,它会跳转到该地址并将计算机控制权移交给下一阶段的软件——也即我们的内核镜像。

第三阶段:为了正确地和上一阶段的 RustSBI 对接,我们需要保证内核的第一条指令位于物理地址 0x80200000 处。为此,我们需要将内核镜像预先加载到 Qemu 物理内存以地址 0x80200000 开头的区域上。一旦 CPU 开始执行内核的第一条指令,证明计算机的控制权已经被移交给我们的内核,也就达到了本节的目标。

以上过程是 QEMU 中的启动流程,真实计算机的加电启动流程大致如下:
第一阶段:加电后 CPU 的 PC 寄存器被设置为计算机内部只读存储器(ROM,Read-only Memory)的物理地址,随后 CPU 开始运行 ROM 内的软件。我们一般将该软件称为固件(Firmware),它的功能是对 CPU 进行一些初始化操作,将后续阶段的 bootloader 的代码、数据从硬盘载入到物理内存,最后跳转到适当的地址将计算机控制权转移给 bootloader。它大致对应于 Qemu 启动的第一阶段,即在物理地址 0x1000 处放置的若干条指令。可以看到 Qemu 上的固件非常简单,因为它并不需要负责将 bootloader 从硬盘加载到物理内存中,这个任务此前已经由 Qemu 自身完成了。
第二阶段:bootloader 同样完成一些 CPU 的初始化工作,将操作系统镜像从硬盘加载到物理内存中,最后跳转到适当地址将控制权转移给操作系统。可以看到一般情况下 bootloader 需要完成一些数据加载工作,这也就是它名字中 loader 的来源。它对应于 Qemu 启动的第二阶段。在 Qemu 中,我们使用的 RustSBI 功能较弱,它并没有能力完成加载的工作,内核镜像实际上是和 bootloader 一起在 Qemu 启动之前加载到物理内存中的。
第三阶段:控制权被转移给操作系统。由于篇幅所限后面我们就不再赘述了。
值得一提的是,为了让计算机的启动更加灵活,bootloader 目前可能非常复杂:它可能也分为多个阶段,并且能管理一些硬件资源,从复杂性上它已接近一个传统意义上的操作系统。

终端是如何控制颜色的?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum LOG_COLOR {
RED = 31,
GREEN = 32,
BLUE = 34,
GRAY = 90,
YELLOW = 93,
};

#if defined(USE_LOG_ERROR)
#define errorf(fmt, ...) \
do { \
int tid = threadid(); \
printf("\x1b[%dm[%s %d]" fmt "\x1b[0m\n", RED, "ERROR", tid, \
##__VA_ARGS__); \
} while (0)
#else

ANSI 转义码是一种用于控制终端输出的特殊字符序列。它们由\x1b(或\033)开头,后面跟着一系列数字和分号组成。

ANSI 转义码中的数字部分用于指定不同的控制操作,如设置文本颜色、背景颜色、光标位置等等。其中,用于设置颜色的转义码包括三个主要的部分:\x1b[颜色代码m

具体来说,\x1b[表示开始使用控制序列,接下来的数字代表不同的颜色代码,最后的m表示结束控制序列。例如,\x1b[31m表示将文本颜色设置为红色,而\x1b[0m用于重置所有属性为默认值。

当终端遇到这样的转义序列时,它会解析并执行相应的控制操作,从而实现对文本颜色、背景颜色和其他属性的控制。

需要注意的是,不同的终端可能支持不同的 ANSI 转义码,并且不同操作系统也可能有不同的实现。因此,在编写使用 ANSI 转义码的代码时,建议先测试并确保其在目标终端上正常工作。

更多详细解释可以参考文章:终端颜色控制 - 简书

应用程序输出字符会调用 SBI 服务,SBI 中发生了什么?

因为对 Rust 语言不熟悉,所以这里的分析是基于 C 语言的 OpenSBI 来分析的,他们的逻辑是一样的。如果有熟悉 Rust 的可以查看 RustSBI 源码

根据指导书中的解释以及阅读代码,我们知道调用了 printf 最终实际上是调用了 sbi_call。那么 sbi_call 是如何实现的呢?因为我是做驱动开发以及固件开发的,也经常需要使用 OpenSBI,所想多问一句,OpenSBI 是如何实现的呢?OpenSBI 是如何提供服务的呢?它是如何打印出字符的呢?

内核中的 SBI 调用

我们先看一下内核中的 sbi_call 都做了写啥。

1
2
3
4
5
6
7
// uCore-Tutorial-Code-2023S/os/sbi.c
const uint64 SBI_CONSOLE_PUTCHAR = 1;

void console_putchar(int c)
{
sbi_call(SBI_CONSOLE_PUTCHAR, c, 0, 0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// uCore-Tutorial-Code-2023S/os/sbi.c
int inline sbi_call(uint64 which, uint64 arg0, uint64 arg1, uint64 arg2)
{
// 使用寄存器变量来保存参数值和系统调用编号
register uint64 a0 asm("a0") = arg0; // 将 'arg0' 的值保存在寄存器 'a0' 中
register uint64 a1 asm("a1") = arg1; // 将 'arg1' 的值保存在寄存器 'a1' 中
register uint64 a2 asm("a2") = arg2; // 将 'arg2' 的值保存在寄存器 'a2' 中
register uint64 a7 asm("a7") = which; // 将 'which' 的值保存在寄存器 'a7' 中
// 内联汇编代码使用 ecall 指令进行系统调用
asm volatile(
"ecall" // 使用 ecall 指令进行系统调用
// 在这段代码中,指令 "ecall" 的输入参数是寄存器 a0 a1 a2 和 a7,输出参数是寄存器 a0
: "=r"(a0) // 输出操作数:将返回值存储在变量 'a0' 中
: "r"(a0), "r"(a1), "r"(a2), "r"(a7) // 输入操作数:传递参数和系统调用编号
: "memory" // "memory" 标志告诉编译器,这条指令可能会修改内存中的数据,需要进行内存屏障操作来保证数据的正确性。
);
return a0; // 返回存储在变量 'a0' 中的值
}

那么 OpenSBI 如何提供服务?在include/sbi/sbi_ecall.h这种定义了每个ecall服务全局变量。

1
2
3
4
5
6
7
8
9
//include/sbi/sbi_ecall.h
extern struct sbi_ecall_extension ecall_base;
extern struct sbi_ecall_extension ecall_legacy;
extern struct sbi_ecall_extension ecall_time;
extern struct sbi_ecall_extension ecall_rfence;
extern struct sbi_ecall_extension ecall_ipi;
extern struct sbi_ecall_extension ecall_vendor;
extern struct sbi_ecall_extension ecall_hsm;
extern struct sbi_ecall_extension ecall_srst;

lib/sbi/sbi_ecall.c中注册了所有的ecall服务,并将其加到链表ecall_exts_list中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
int sbi_ecall_init(void)
{
int ret;
struct sbi_ecall_extension *ext;
unsigned long i;

for (i = 0; i < sbi_ecall_exts_size; i++) {
ext = sbi_ecall_exts[i];
ret = sbi_ecall_register_extension(ext);
if (ret)
return ret;
}

return 0;
}
int sbi_ecall_register_extension(struct sbi_ecall_extension *ext)
{
struct sbi_ecall_extension *t;

if (!ext || (ext->extid_end < ext->extid_start) || !ext->handle)
return SBI_EINVAL;

sbi_list_for_each_entry(t, &ecall_exts_list, head) {
unsigned long start = t->extid_start;
unsigned long end = t->extid_end;
if (end < ext->extid_start || ext->extid_end < start)
/* no overlap */;
else
return SBI_EINVAL;
}

SBI_INIT_LIST_HEAD(&ext->head);
sbi_list_add_tail(&ext->head, &ecall_exts_list);

return 0;
}

/**
* Iterate over list of given type
* @param pos the type * to use as a loop cursor.
* @param head the head for your list.
* @param member the name of the list_struct within the struct.
*/
#define sbi_list_for_each_entry(pos, head, member) \
for (pos = sbi_list_entry((head)->next, typeof(*pos), member); \
&pos->member != (head); \
pos = sbi_list_entry(pos->member.next, typeof(*pos), member))

那么服务 id 如何和相对应的服务绑定的呢?以ecall_time为例,查看其结构体原型struct sbi_ecall_extension

1
2
3
4
5
6
7
8
9
10
11
// include/sbi/sbi_ecall.h: 23
struct sbi_ecall_extension {
struct sbi_dlist head;
unsigned long extid_start;
unsigned long extid_end;
int (* probe)(unsigned long extid, unsigned long *out_val);
int (* handle)(unsigned long extid, unsigned long funcid,
const struct sbi_trap_regs *regs,
unsigned long *out_val,
struct sbi_trap_info *out_trap);
};

可以看到有 extid_startextid_endhandle

目前 OpenSBI 逐步将每个服务的实现都放在了lib/sbi单独文件中,以ecall_time为例,其实现在lib/sbi/sbi_ecall_time.c中。单独为其绑定回调处理函数sbi_ecall_time_handler。但是还有很多服务的实现还是放在了lib/sbi/sbi_ecall_legacy.c中,后续应该会逐步迁移。我们上文使用的SBI_CONSOLE_PUTCHAR服务就是在这里实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// lib/sbi/sbi_ecall_legacy.c
struct sbi_ecall_extension ecall_legacy = {
.extid_start = SBI_EXT_0_1_SET_TIMER,
.extid_end = SBI_EXT_0_1_SHUTDOWN,
.handle = sbi_ecall_legacy_handler,
};

static int sbi_ecall_legacy_handler(unsigned long extid, unsigned long funcid,
const struct sbi_trap_regs *regs,
unsigned long *out_val,
struct sbi_trap_info *out_trap)
{
int ret = 0;
struct sbi_tlb_info tlb_info;
u32 source_hart = current_hartid();
ulong hmask = 0;

switch (extid) {
case SBI_EXT_0_1_SET_TIMER:
sbi_timer_event_start((u64)regs->a0);
break;
case SBI_EXT_0_1_CONSOLE_PUTCHAR:
sbi_putc(regs->a0);
break;
case SBI_EXT_0_1_CONSOLE_GETCHAR:
ret = sbi_getc();
break;
// ...
};

return ret;
}

这就把 id 与相应的服务函数绑定。一个extid对应一个handler

我们可以在找到SBI_EXT_0_1_CONSOLE_PUTCHAR的值,是与 Linux 内核里定义的值是一致的。

1
2
3
// include/sbi/sbi_ecall_interface.h
/* SBI Extension IDs */
#define SBI_EXT_0_1_CONSOLE_PUTCHAR 0x1

ecall 服务调用流程

  1. firmware/fw_base.S 中注册了 Machine Modetrap handler,即 sbi_trap_handler

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    _start_warm:
    /* Setup trap handler */
    la a4, _trap_handler
    csrw CSR_MTVEC, a4 /* CSR_MTVEC = _trap_handler */

    _trap_handler:
    TRAP_SAVE_AND_SETUP_SP_T0

    TRAP_SAVE_MEPC_MSTATUS 0

    TRAP_SAVE_GENERAL_REGS_EXCEPT_SP_T0

    TRAP_CALL_C_ROUTINE

    TRAP_RESTORE_GENERAL_REGS_EXCEPT_SP_T0

    TRAP_RESTORE_MEPC_MSTATUS 0

    TRAP_RESTORE_SP_T0

    mret

    .macro TRAP_CALL_C_ROUTINE
    /* Call C routine */
    add a0, sp, zero
    call sbi_trap_handler
    .endm
  2. lib/sbi/sbi_trap.c 中定义了 sbi_trap_handler,处理各种 mcause,比如 Illegal InstructionsMisaligned Load & Store, Supervisor & Machine Ecall 等。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    // lib/sbi/sbi_trap.c
    void sbi_trap_handler(struct sbi_trap_regs *regs)
    {
    ...

    switch (mcause) {
    case CAUSE_ILLEGAL_INSTRUCTION:
    rc = sbi_illegal_insn_handler(mtval, regs);
    break;
    case CAUSE_MISALIGNED_LOAD:
    rc = sbi_misaligned_load_handler(mtval, mtval2, mtinst, regs);
    break;
    case CAUSE_MISALIGNED_STORE:
    rc = sbi_misaligned_store_handler(mtval, mtval2, mtinst, regs);
    break;
    case CAUSE_SUPERVISOR_ECALL:
    case CAUSE_MACHINE_ECALL:
    rc = sbi_ecall_handler(regs);
    break;
    default:
    /* If the trap came from S or U mode, redirect it there */
    trap.epc = regs->mepc;
    trap.cause = mcause;
    trap.tval = mtval;
    trap.tval2 = mtval2;
    trap.tinst = mtinst;
    rc = sbi_trap_redirect(regs, &trap);
    break;
    };

    ...
  3. lib/sbi/sbi_ecall.c 中定义了处理 ecall mcausesbi_ecall_handler,它遍历上面 ecall_exts_list 中注册的各种 ecall 服务。

  4. sbi_ecall_handler 根据 Linux 内核传递的 ext (extension id) 找到链表中对应的 ecall 服务,执行其中的 handle 函数,该函数根据 fid 执行具体的服务内容。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // lib/sbi/sbi_ecall.c
    int sbi_ecall_handler(struct sbi_trap_regs *regs)
    {
    // ...
    unsigned long extension_id = regs->a7;
    unsigned long func_id = regs->a6;
    struct sbi_trap_info trap = {0};
    unsigned long out_val = 0;

    // 遍历所有 ecall 服务
    ext = sbi_ecall_find_extension(extension_id);
    if (ext && ext->handle) {
    // 如果找到了就执行
    ret = ext->handle(extension_id, func_id,
    regs, &out_val, &trap);
    if (extension_id >= SBI_EXT_0_1_SET_TIMER &&
    extension_id <= SBI_EXT_0_1_SHUTDOWN)
    is_0_1_spec = 1;
    } else {
    ret = SBI_ENOTSUPP;
    }

    ...
    }

    我们可以发现 extension_id 就是 a7 寄存器,他和我们在 uCore OS 中定义的 SBI_EXT_0_1_CONSOLE_PUTCHAR 是一致的。

程序的内存布局与编译流程

程序的内存布局

uCore 的编译系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
.PHONY: clean build user
# 设置伪目标clean、build和user,可以通过命令make来执行这些目标

all: build_kernel
# 默认目标为build_kernel,即执行build_kernel目标下的指令

LOG ?= error
# 定义一个变量LOG,默认值是error

K = os
TOOLPREFIX = riscv64-unknown-elf-

CC = $(TOOLPREFIX)gcc
AS = $(TOOLPREFIX)gcc
LD = $(TOOLPREFIX)ld
OBJCOPY = $(TOOLPREFIX)objcopy
OBJDUMP = $(TOOLPREFIX)objdump
PY = python3
GDB = $(TOOLPREFIX)gdb
CP = cp

MKDIR_P = mkdir -p

BUILDDIR = build

C_SRCS = $(wildcard $K/*.c)
# 定义一个变量C_SRCS,使用wildcard函数匹配所有以.c为后缀的文件,并存储在$K目录下

AS_SRCS = $(wildcard $K/*.S)
# 定义一个变量AS_SRCS,使用wildcard函数匹配所有以.S为后缀的文件,并存储在$K目录下

C_OBJS = $(addprefix $(BUILDDIR)/, $(addsuffix .o, $(basename $(C_SRCS))))
# 定义一个变量C_OBJS,通过addprefix和addsuffix函数将$(C_SRCS)中的路径替换为$(BUILDDIR),并将后缀修改为.o

AS_OBJS = $(addprefix $(BUILDDIR)/, $(addsuffix .o, $(basename $(AS_SRCS))))
# 定义一个变量AS_OBJS,通过addprefix和addsuffix函数将$(AS_SRCS)中的路径替换为$(BUILDDIR),并将后缀修改为.o

OBJS = $(C_OBJS) $(AS_OBJS)
# 定义一个变量OBJS,其值为$(C_OBJS)和$(AS_OBJS)的组合

HEADER_DEP = $(addsuffix .d, $(basename $(C_OBJS)))
# 定义一个变量HEADER_DEP,通过addsuffix函数将$(C_OBJS)中的后缀修改为.d

-include $(HEADER_DEP)
# 包含$(HEADER_DEP)中的.d文件

CFLAGS = -Wall -Werror -O -fno-omit-frame-pointer -ggdb
# 定义一个变量CFLAGS,并赋值为-Wall -Werror -O -fno-omit-frame-pointer -ggdb

CFLAGS += -MD
# 将-MD选项追加到CFLAGS变量中,用于自动生成依赖关系文件

CFLAGS += -mcmodel=medany
# 将-mcmodel=medany选项追加到CFLAGS变量中,用于指定内存模型

CFLAGS += -ffreestanding -fno-common -nostdlib -mno-relax
# 将-ffreestanding -fno-common -nostdlib -mno-relax选项追加到CFLAGS变量中,用于编译无操作系统环境下的程序

CFLAGS += -I$K
# 将-I$K选项追加到CFLAGS变量中,用于指定头文件搜索路径为$K目录下

CFLAGS += $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)
# 将$(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector命令执行结果追加到CFLAGS变量中,用于禁用栈保护机制

ifeq ($(LOG), error)
CFLAGS += -D LOG_LEVEL_ERROR
else ifeq ($(LOG), warn)
CFLAGS += -D LOG_LEVEL_WARN
else ifeq ($(LOG), info)
CFLAGS += -D LOG_LEVEL_INFO
else ifeq ($(LOG), debug)
CFLAGS += -D LOG_LEVEL_DEBUG
else ifeq ($(LOG), trace)
CFLAGS += -D LOG_LEVEL_TRACE
endif
# 根据$(LOG)变量的值,向CFLAGS变量追加相应的预处理器选项,相当于添加了一个宏定义,log.h中的LOG_LEVEL_ERROR等宏定义会根据这个宏定义来决定是否生效

# Disable PIE when possible (for Ubuntu 16.10 toolchain)
ifneq ($(shell $(CC) -dumpspecs 2>/dev/null | grep -e '[^f]no-pie'),)
CFLAGS += -fno-pie -no-pie
endif
ifneq ($(shell $(CC) -dumpspecs 2>/dev/null | grep -e '[^f]nopie'),)
CFLAGS += -fno-pie -nopie
endif
# 根据系统环境判断是否支持PIE(位置无关执行)选项,并根据情况向CFLAGS变量追加相应的选项

LDFLAGS = -z max-page-size=4096
# 定义一个变量LDFLAGS,并赋值为-z max-page-size=4096

$(AS_OBJS): $(BUILDDIR)/$K/%.o : $K/%.S
@mkdir -p $(@D)
$(CC) $(CFLAGS) -c $< -o $@
# 规则:生成$(AS_OBJS)目标所需的依赖文件$(BUILDDIR)/$K/%.o,依赖于$K/%.S,并通过$(CC)命令编译生成目标文件

$(C_OBJS): $(BUILDDIR)/$K/%.o : $K/%.c $(BUILDDIR)/$K/%.d
@mkdir -p $(@D)
$(CC) $(CFLAGS) -c $< -o $@
# 规则:生成$(C_OBJS)目标所需的依赖文件$(BUILDDIR)/$K/%.o,依赖于$K/%.c和$(BUILDDIR)/$K/%.d,并通过$(CC)命令编译生成目标文件

$(HEADER_DEP): $(BUILDDIR)/$K/%.d : $K/%.c
@mkdir -p $(@D)
@set -e; rm -f $@; $(CC) -MM $< $(INCLUDEFLAGS) > $@.$$$$; \
sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.$$$$ > $@; \
rm -f $@.$$$$
# 规则:生成$(HEADER_DEP)目标所需的依赖文件$(BUILDDIR)/$K/%.d,依赖于$K/%.c,并通过$(CC)命令生成依赖关系文件

build: build/kernel
# 定义一个目标build,其依赖于build/kernel

build/kernel: $(OBJS)
$(LD) $(LDFLAGS) -T os/kernel.ld -o $(BUILDDIR)/kernel $(OBJS)
$(OBJDUMP) -S $(BUILDDIR)/kernel > $(BUILDDIR)/kernel.asm
$(OBJDUMP) -t $(BUILDDIR)/kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(BUILDDIR)/kernel.sym
@echo 'Build kernel done'
# 规则:生成build/kernel目标,依赖于$(OBJS),通过$(LD)命令连接生成kernel,并通过$(OBJDUMP)命令生成汇编文件和符号表

clean:
rm -rf $(BUILDDIR)

# BOARD
BOARD ?= qemu
SBI ?= rustsbi
BOOTLOADER := ./bootloader/rustsbi-qemu.bin

QEMU = qemu-system-riscv64
QEMUOPTS = \
-nographic \
-machine virt \
-bios $(BOOTLOADER) \
-kernel build/kernel \

run: build/kernel
$(QEMU) $(QEMUOPTS)

# QEMU's gdb stub command line changed in 0.11
QEMUGDB = $(shell if $(QEMU) -help | grep -q '^-gdb'; \
then echo "-gdb tcp::15234"; \
else echo "-s -p 15234"; fi)

# 启动QEMU并通过GDB调试,此时QEMu会进入后台运行,并暂停执行,等待GDB连接
# 连接的GDB端口为15234
debug: build/kernel .gdbinit
$(QEMU) $(QEMUOPTS) -S $(QEMUGDB) &
sleep 1
$(GDB)

编译、运行 uCore 的一些常用命令有如下一些,涉及了后续章节中引入的测试用例中的命令:

1
2
3
4
5
6
7
8
9
make run
make debug
make clean
# 编译测试用例的前四章
make user CHAPTER=4 LOG=trace
# 编译测试用例的第四章
make user CHAPTER=4_only LOG=trace
# 只运行测试用例的第四章
make test CHAPTER=4_only

附录

makefile 和 qemu

AS = $(TOOLPREFIX)gas > AS = $(TOOLPREFIX)as

参考资料

首先,.section .data 表示定义了一个数据段,在这个段中定义了一系列的全局变量。其中,_app_num 是一个标签,表示一个 64 位的整数,初始值为 23。接下来是一系列的标签,分别代表了应用程序的起始地址,每个标签都是 64 位的整数。

接着,.section .data 后面又出现了一个标签 _app_names,它是一个字符串数组,包含了一组字符串,分别命名为 “ch2b_exit”、”ch2b_hello_world”、”ch2b_power” 等等。这些字符串名字对应了前面定义的应用程序的起始地址。

再往下,出现了一个标签 INIT_PROC,它是一个字符串,表示初始化进程的名称,值为 “usershell”。

之后,每个应用程序都有自己的标签和段名,比如 app_0_startapp_1_start 等等。每个标签都包含一个指令 .incbin,它用于将一个二进制文件(以字符串形式指定文件路径)插入到当前段中。

进程初始化分析

1
2
3
scheduler()
fetch_task() // 获取下一个要执行的进程
swtch(&curenv->context, nextenv->context) // 切换到下一个进程上下文
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Per-process state
struct proc {
enum procstate state; // 进程状态
int pid; // 进程 ID
uint64 ustack; // 进程用户栈虚拟地址 (用户页表)
uint64 kstack; // 进程内核栈虚拟地址 (内核页表)
struct trapframe *trapframe; // 进程中断帧
struct context context; // 用于保存进程内核态的寄存器信息,进程切换时使用
pagetable_t pagetable; // User page table
uint64 max_page;
uint64 program_brk;
uint64 heap_bottom;
struct proc * parent; // Parent process
uint64 exit_code;
struct file * files[FD_BUFFER_SIZE];
uint32 syscall_times[MAX_SYSCALL_NUM]; // 系统调用次数统计
uint64 start_time; // 进程开始运行时间
struct vma vmas[NVMA]; // 虚拟内存区域
};

wait 系统调用的功能

wait 系统调用是用于处理子进程终止状态的系统调用。其主要功能是等待子进程的终止,并获取子进程的退出状态信息。在操作系统中,当一个父进程创建了一个子进程后,通常会使用 wait 来等待子进程的终止,以便进行后续的处理,如回收子进程的资源或获取其运行结果。

以下是 wait 系统调用的主要功能:

  1. 等待子进程终止:父进程调用 wait 系统调用后,会进入阻塞状态,等待子进程终止。如果子进程已经终止,那么 wait 立即返回,否则父进程会一直等待直到子进程终止。

  2. 获取子进程的退出状态wait 系统调用会获取子进程的退出状态信息,包括子进程的退出码(通常是一个整数)。这个退出码可以告诉父进程子进程的终止情况,例如是否成功执行等。

  3. 回收子进程资源:一旦子进程终止,其占用的系统资源(如内存、文件描述符等)通常需要由父进程来回收,以避免资源泄漏。wait 系统调用在等待子进程终止后,会自动回收这些资源。

  4. 处理僵尸进程:在某些情况下,子进程可能会在终止后成为僵尸进程,即已经终止但其进程描述符仍然存在。父进程可以使用 wait 来回收这些僵尸进程,释放相关资源。

execforkspawn 是操作系统中常见的进程管理系统调用,各自具有不同的功能和用途:

  1. exec 系统调用

    • 功能exec 系统调用用于在当前进程的上下文中加载并执行一个新的程序。
    • 用途:通常在一个进程需要替换自身的执行映像时使用。它会加载一个新的可执行文件,覆盖当前进程的地址空间和代码段,然后开始执行新的程序。这个新程序可以是完全不同的程序,从而允许进程动态切换到不同的应用程序,而不需要创建新的进程。
  2. fork 系统调用

    • 功能fork 系统调用用于创建一个与当前进程几乎完全相同的新进程,包括代码、数据和上下文等。
    • 用途:通常用于创建新的进程,新进程称为子进程,它从父进程继承了大部分状态,然后可以在独立的地址空间中执行不同的操作。fork 创建的子进程是父进程的副本,可以并行执行不同的任务。
  3. spawn 系统调用

    • 功能spawn 系统调用通常用于创建新的进程并执行指定的程序。
    • 用途:类似于 fork,它也创建了一个新的进程,但不像 fork 那样完全复制父进程。相反,spawn 允许你指定一个新程序的路径和参数,而不是完全复制当前进程的状态。这使得它更适合用于启动新程序,而不是简单地创建一个进程副本。

总结:

  • exec 用于替换当前进程的执行映像,允许加载和执行新程序。
  • fork 用于创建一个几乎与父进程相同的新进程,新进程成为父进程的副本。
  • spawn 通常用于创建一个新进程并执行指定的程序,允许指定不同的程序路径和参数。

    附录

本章任务:
在次 -> 在此

为何指定 TRAMPOLINE 和 TRAPFRAME 在 va 的最高位?
TRAMPOLINE 和 TRAPFRAME 被定义在最高的虚拟内存地址上,是因为它们在操作系统的内存布局中起着重要作用。
TRAMPOLINE 被用作从用户模式切换到内核模式的跳转目标。当发生异常或中断时,处理器将从用户模式切换到内核模式,并将控制权转移到内核中预定义的位置,也就是陷阱处理程序。TRAMPOLINE 页面被映射到最高虚拟地址,以便处理器能够在这个转换过程中方便地引用它。通过将其放置在最高地址,确保了无论系统的具体内存布局如何,它始终是可访问的。
另一方面,TRAPFRAME 用于在发生异常或中断时存储机器状态。它包含寄存器、标志和其他操作系统处理异常所需的信息。TRAPFRAME 也被放置在最高的虚拟地址上,以确保它易于访问,并且陷阱处理程序可以高效地访问它。
通过将 TRAMPOLINE 和 TRAPFRAME 定义在最高的虚拟内存地址上,内核可以方便而可靠地处理异常和中断,而无需关心它们在内存中的特定位置。

如何确定分页方案 - satp

在 MMU 没有使能的情况下,虚拟地址和物理地址是相同的。在 MMU 使能的情况下,虚拟地址会被转换成物理地址。这个转换过程是由操作系统来管理的,操作系统需要维护一个数据结构来记录虚拟地址和物理地址的映射关系。这个数据结构就是页表。

转换的过程需要分页机制,分页机制有多种。RISC-V 的分页方案以 SvX 的模式命名,其中 X 是以位为单位的虚拟地址的长度。在 RV64 架构下,RISC-V 支持多种分页方案,包括 Sv39,Sv48,Sv57 以及 Sv64。Sv39 最大支持 39 位的虚拟地址,这意味着它可以支持 512 GB 的虚拟地址空间。Sv48 最大支持 48 位的虚拟地址,这意味着它可以支持 256 TB 的虚拟地址空间。我们将在本章中实现 Sv39 分页方案。

如何开启分页机制呢?RISC-V 的分页机制是通过 satp(Supervisor address translation and protection)寄存器来开启的。satp 寄存器字段分布如下:

  • Mode 字段可以决定是否开启分页以及分页级数。Mode=0 时,不开启分页;Mode=8 时,开启 Sv39 分页机制。
  • ASID(Address Space Identifier,地址空间标识符)域是可选的,它可以用来降低上下文切换的开销。目前我们暂不考虑这个字段的作用。
  • PPN(Physical Page Number,物理页号),保存了根页表的物理地址。

SV39 多级页表机制

页表项描述

Sv39 页表项(page-table entry,PTE)的布局,从左到右分别包含如下所述的域:

  • V 位决定了该页表项的其余部分是否有效 (V=1 时有效)。若 V=0,则任何遍历到此页表项的虚址转换操作都会导致页错误。
  • R、W 和 X 位分别表示此页是否可以读取、写入和执行。如果这三个位都是 0,那么这个页表项是指向下一级页表的指针,否则它是页表树的一个叶节点。
  • U 位表示该页是否是用户页面。若 U=0,则 U 模式不能访问此页面,但 S 模式可以。若 U=1,则 U 模式下能访问这个页面,而 S 模式不能。
  • G 位表示这个映射是否对所有虚址空间有效,硬件可以用这个信息来提高地址转换的性能。这一位通常只用于属于操作系统的页面。
  • A 位表示自从上次 A 位被清除以来,该页面是否被访问过。
  • D 位表示自从上次清除 D 位以来页面是否被弄脏(例如被写入)。
  • RSW 域留给操作系统使用,它会被硬件忽略。
  • PPN 域包含物理页号,这是物理地址的一部分。若这个页表项是一个叶节点,那么 PPN 是转换后物理地址的一部分。否则 PPN 给出下一节页表的地址。

虚拟地址转换物理地址过程

当 satp 寄存器中开启分页时,S 模式和 U 模式中访存的地址都会被视为虚拟地址,需要将其转换为物理地址。虚拟地址转换物理地址的过程如下:

  • 从 satp 寄存器中读取 PPN,得到根页表的物理地址,为了表述方便,我们将其记做三级页表基地址 satp.PPN;
  • 从虚拟地址中取出三级虚拟页号 L2
  • 处理器会读取地址位于 satp.PPN * 4096 + L2 * 4 的页表项,得到下一级页表的基地址 L1.PPN;
  • 从虚拟地址中取出二级虚拟页号 L1
  • 处理器会读取地址位于 L1.PPN * 4096 + L1 * 4 的页表项,得到下一级页表的基地址 L0.PPN;
  • 从虚拟地址中取出一级虚拟页号 L0
  • 处理器会读取地址位于 L0.PPN * 4096 + L0 * 4 的页表项,得到物理页号 PPN;
  • 将 PPN 和虚拟地址的低 12 位也就是 Offset 拼接起来,得到物理地址。

我们看代码中是如何实现的:

1
2
3
4
5
6
7
#define PTE2PA(pte) (((pte) >> 10) << 12)
// 从虚拟地址中提取三个 9 位的页表索引
#define PXMASK 0x1FF // 9
// PGSHIFT = 12,这段宏定义用于定位 VPNx 的位置
#define PXSHIFT(level) (PGSHIFT + (9 * (level)))
// 从虚拟地址 VA 中提取出第 level 级页表的索引
#define PX(level, va) ((((uint64)(va)) >> PXSHIFT(level)) & PXMASK)

上面这三个工具宏可以用来提取虚拟页号 VPN。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

// 返回页表 pagetable 中与虚拟地址 va 对应的 PTE 的地址。
// 如果 alloc != 0,则创建所需的页表页。
//
// RISC-V Sv39 方案有三级页表页。一个页表页包含 512 个 64 位的 PTEs。
// 一个 64 位的虚拟地址被分为五个字段:
// 39..63 -- 必须为零。
// 30..38 -- 2 级索引的 9 位。
// 21..29 -- 1 级索引的 9 位。
// 12..20 -- 0 级索引的 9 位。
// 0..11 -- 页面内的 12 位字节偏移量。
// pagetable 页表
// va 虚拟地址
// alloc 页表项不存在时是否分配
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc)
{
if (va >= MAXVA)
panic("walk");

for (int level = 2; level > 0; level--) {
pte_t *pte = &pagetable[PX(level, va)];
// 通过 PTE 的标志位判断每一级的 pte 是否是有效的(V 位)
if (*pte & PTE_V) {
pagetable = (pagetable_t)PTE2PA(*pte);
} else {
// 如果该项无效且 alloc 标志被设置,则分配一个新的页表
// 如果 alloc 参数=0 或者已经没有空闲的内存了,那么遇到中途 V=0 的 pte 整个 walk 过程就会直接退出
if (!alloc || (pagetable = (pde_t *)kalloc()) == 0) {
return 0;
}
// 清空分配的页表
memset(pagetable, 0, PGSIZE);
// 更新页表项,将其指向新分配的页表,并设置有效位 PTE_V
*pte = PA2PTE(pagetable) | PTE_V;
}
}
// 返回最低级和虚拟地址的页表项,不是返回物理地址
return &pagetable[PX(0, va)];
}

每次从虚拟地址 va 中提取出一个虚拟页号,然后根据这个虚拟页号从页表中取出下一级页表的基地址。如果这个页表项无效,那么根据 alloc 参数决定是否分配一个新的页表。如果 alloc 参数为 0 或者已经没有空闲的内存了,那么遇到中途 V=0 的 pte 整个 walk 过程就会直接退出。如果 alloc 参数为 1,那么就会分配一个新的页表,然后将这个页表项指向新分配的页表,并设置有效位 PTE_V。

我们可以发现 walk 返回的结果不是物理地址,而是页表项的地址。这是因为 walk 函数的作用是将虚拟地址转换为物理地址,而页表项中的 PPN 只是物理地址的一部分,还需要加上虚拟地址的低 12 位偏移量才能得到物理地址

如何建立页表

前面的过程实际上是以用户的角度来考虑的,也就是给你一个虚拟地址按照分页的规则将其转化成物理地址就能访问了。但是作为一个操作系统,我们还需要多考虑一下,页表是哪来的?我们知道从虚拟地址中去获取页表地址,但是页表的内容是哪来的呢?页表是如何建立起来的呢?这些是需要操作系统来完成的。

建立页表也就是建立虚拟地址到物理地址的映射关系。也就是给你一个虚拟地址,你需要告诉我如何查到物理地址,实际上这个过程就是建立页表的过程。这个过程也是通过 walk 函数来完成的,从上文我们知道如果页表都建好的情况下 walk 就是不断查页表的过程,那么在没有页表的情况下,walk 还可以建立一个个页表。稍有不同的是,walk 返回的是最后一级页表项的地址,我们需要将物理地址写入这个页表项中。

在 uCore 中使用 mappages 函数封装了 walk 函数,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)

/**
* 为从虚拟地址 va 开始的页面创建指向物理地址 pa 开始的页表项(PTE)
* 注意:va 和 size 可能不是页面对齐的
* 如果无法分配所需的页表,则返回 0,否则返回 -1
*
* @param pagetable 根页表地址
* @param va 虚拟地址
* @param size 映射的字节数
* @param pa 物理地址
* @param perm 权限位
* @return 成功返回 0,否则返回 -1
*/
int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 virtualAddress, lastVirtualAddress;
pte_t *pte;

// 地址必须是页面对齐的
virtualAddress = PGROUNDDOWN(va);
lastVirtualAddress = PGROUNDDOWN(va + size - 1);
for (;;) {
// 返回最低级的虚拟地址的页表项,如果不存在会创建一个新的页表项
// 页表项可能会因为内存不足创建失败,如果创建失败,则返回 -1
if ((pte = walk(pagetable, virtualAddress, 1)) == 0) {
return -1;
}
// 如果 PTE 已经有效,则输出错误信息并返回 -1
if (*pte & PTE_V) {
errorf("remap");
return -1;
}
// 将物理地址 pa 转换为页表项,并设置权限位 perm 和 有效位 PTE_V
*pte = PA2PTE(pa) | perm | PTE_V;
// 如果当前是最后一个地址,则结束循环
if (virtualAddress == lastVirtualAddress) {
break;
}
virtualAddress += PGSIZE;
pa += PGSIZE;
}
return 0;
}

问答作业

请列举 SV39 页表页表项的组成,结合课堂内容,描述其中的标志位有何作用/潜在作用?

Sv39 页表页表项的组成如下:

  1. **有效位 (V)**:这是页表项的最高位,用于指示页表项是否有效。如果有效位设置为 1,表示页表项有效,可以使用;如果设置为 0,表示页表项无效,禁止使用。这是虚拟内存中页表项的基本有效性标志。
  2. **写入位 (W)**:这个标志位用于指示是否可以对此页进行写入操作。如果设置为 1,表示允许写入;如果设置为 0,表示禁止写入。它是页表项的访问权限控制标志之一。
  3. **用户位 (U)**:用户位用于指示是否允许用户态程序访问此页。如果设置为 1,表示允许用户态访问;如果设置为 0,表示只允许内核态访问。它是页表项的访问权限控制标志之一。
  4. **执行位 (X)**:执行位用于指示是否允许执行此页上的指令。如果设置为 1,表示允许执行;如果设置为 0,表示禁止执行。它也是页表项的访问权限控制标志之一。
  5. **全局位 (G)**:全局位用于指示此页是否是全局的,即无需 TLB 缓存,通常用于内核页。如果设置为 1,表示是全局的;如果设置为 0,表示不是全局的。
  6. **已访问位 (A)**:已访问位表示是否已经访问过此页,通常由硬件设置。操作系统可以用它来实现页面置换算法,如 LRU。
  7. **已修改位 (D)**:已修改位表示是否已经对此页进行了写入操作。与已访问位类似,操作系统可以用它来实现页面置换算法。
  8. **物理页框地址 (PPN)**:这是页表项中存储的物理页框的地址。它指示了虚拟页到物理页的映射关系。

Sv39 页表的页表项标志位允许操作系统和硬件实现对虚拟内存的细粒度控制和保护。不同的标志位组合可以实现不同级别的内存保护和权限控制,从而提高系统的安全性和可用性。例如,有效位、写入位、用户位和执行位的不同组合可以实现不同级别的内存保护,使操作系统可以将不同的内存区域分配给用户态和内核态,并设置不同的权限。已访问位和已修改位则用于实现页面置换算法,帮助操作系统决定哪些页面应该被置换出去,以优化内存利用率。全局位可以用于标识全局共享的页,从而节省 TLB 缓存空间。物理页框地址是页表项的核心,它建立了虚拟地址到物理地址的映射关系,使虚拟内存管理成为可能。

缺页相关问题

请问哪些异常可能是缺页导致的?

缺页异常是由于进程访问的页面不在页表中或者在页表中无效而引发的异常。以下这些异常可能是因为缺页导致的:

  • Load Page Fault(Load 异常):当进程试图读取一个不在页表中或者无效的页面时,会引发 Load Page Fault 异常。在 RISC-V 中,这个异常对应的异常代码是 5。

  • Store Page Fault(Store 异常):当进程试图写入一个不在页表中或者无效的页面时,会引发 Store Page Fault 异常。在 RISC-V 中,这个异常对应的异常代码是 7。

  • Instruction Page Fault(指令页异常):当进程试图执行一个不在页表中或者无效的页面上的指令时,会引发 Instruction Page Fault 异常。在 RISC-V 中,这个异常对应的异常代码是 12。

发生缺页时,描述相关的重要寄存器的值(lab2 中描述过的可以简单点)。

  • sepc(Exception Program Counter):trap 发生时会将当前指令的下一条指令地址写入其中,用于 trap 处理完成后返回。
  • stval(Machine Trap Value):mtval 寄存器包含导致异常的原因,即导致异常的指令的具体信息。例如,如果是缺页异常,那么 mtval 寄存器包含导致缺页异常的虚拟地址。
  • scause: 中断/异常发生时, CSR 寄存器 scause 中会记录其信息, Interrupt 位记录是中断还是异常, Exception Code 记录中断/异常的种类。
  • sstatus: 记录处理器当前状态,其中 SPP 段记录当前特权等级。
  • stvec: 记录处理 trap 的入口地址,现有两种模式 Direct 和 Vectored 。
  • sscratch: 其中的值是指向hart相关的S态上下文的指针,比如内核栈的指针。

以下行为的好处?

缺页有两个常见的原因,其一是 Lazy 策略,也就是直到内存页面被访问才实际进行页表操作。比如,一个程序被执行时,进程的代码段理论上需要从磁盘加载到内存。但是 os 并不会马上这样做,而是会保存 .text 段在磁盘的位置信息,在这些代码第一次被执行时才完成从磁盘的加载操作。

Lazy Loading 策略有以下好处:

  1. 减少初始化开销:Lazy Loading 允许操作系统在程序启动时只加载必需的页面,而不是一次性加载整个程序。这可以减少启动时间和初始化开销,因为不需要将整个程序加载到内存中。
  2. 节省内存:Lazy Loading 策略避免了不必要的内存占用。如果程序的某些部分从不被访问,那么它们就不会被加载到内存中,从而节省了内存资源。
  3. 提高响应速度:通过仅在需要时加载页面,Lazy Loading 可以提高系统的响应速度。只有当程序访问某个页面时,操作系统才会执行磁盘加载操作,而不会在程序启动时浪费时间加载可能永远不会被访问的内容。
  4. 更好的磁盘利用率:Lazy Loading 允许操作系统将程序的不同部分分散在磁盘上,根据需要加载。这可以提高磁盘利用率,因为不需要在磁盘上为整个程序分配连续的空间。

请问处理 10G 连续的内存页面,需要操作的页表实际大致占用多少内存 (给出数量级即可)?

此外 COW(Copy On Write) 也是常见的容易导致缺页的 Lazy 策略,这个之后再说。其实,我们的 mmap 也可以采取 Lazy 策略,比如:一个用户进程先后申请了 10G 的内存空间,然后用了其中 1M 就直接退出了。按照现在的做法,我们显然亏大了,进行了很多没有意义的页表操作。

处理 10GB 连续的内存页面所需的页表实际上占用的内存量取决于操作系统的页表结构和管理策略。在 RISC-V 的页表结构中,一个页表项(Page Table Entry,PTE)通常占据 8 字节(64 位系统),其中包括物理页框号和一些标志位。让我们假设一个 PTE 占用 8 字节。

为了估算 10GB 连续内存页面所需的页表实际占用内存量,我们可以按照以下步骤进行计算:

  1. 首先,将 10GB 转换为字节数。1GB 等于 1,073,741,824 字节,所以 10GB 等于 10 * 1,073,741,824 = 10,737,418,240 字节。

  2. 然后,计算每个页面表项覆盖的内存范围。假设每个页面表项管理 4KB(4 * 1024 字节)的内存页面。

  3. 计算需要多少个页面表项来管理 10GB 的内存。这可以通过将 10GB 除以每个页面表项管理的内存范围来实现。

  4. 最后,将所需的页面表项数量乘以每个 PTE 的大小来估算所需的总内存量。

让我们进行具体计算:

  • 内存大小:10,737,418,240 字节
  • 每个页面表项管理的内存范围:4KB = 4 * 1024 字节
  • 需要的页面表项数量:10,737,418,240 字节 / 4KB = 2,621,440 个页表项

假设每个页表项占用 8 字节,则需要的总内存量为:

2,621,440 个页表项 * 8 字节/页表项 = 20,971,520 字节

所以,处理 10GB 连续的内存页面所需的页表实际占用内存量约为 20,971,520 字节,或者大约 20MB。这只是一个估算,实际内存占用可能会因操作系统的管理策略和对齐等因素而有所不同。

请简单思考如何才能在现有框架基础上实现 Lazy 策略,缺页时又如何处理?描述合理即可,不需要考虑实现。

要在现有框架基础上实现 Lazy 策略,可以采取以下简单思路:

  1. 延迟加载(Lazy Loading):在用户进程请求内存映射时,不立即将整个内存区域加载到物理内存中。而是仅创建虚拟内存映射和页表项,记录对应的磁盘位置等信息。

  2. 缺页处理(Page Fault Handling):当用户进程访问虚拟内存中的某个尚未加载的内存页面时,会触发缺页异常。在缺页异常处理程序中,操作系统会根据页表中的磁盘位置信息,将相应的磁盘数据加载到物理内存中,并更新页表项,使其指向新加载的物理页面。

  3. 惰性加载(Demand Paging):为了提高性能,可以采用惰性加载策略,即只加载实际被访问的内存页面,而不是一次性加载整个区域。这可以通过在缺页处理程序中进行懒加载操作来实现。

  4. 内存回收(Memory Reclamation):当系统内存不足时,操作系统可以选择回收一些不常访问的内存页面,将其写回磁盘,并更新页表项为无效。这需要根据页面访问模式和策略来确定哪些页面可以被回收。

  5. 性能优化:为了提高性能,可以采用预读取(Prefetching)策略,即在缺页处理时,不仅加载当前访问的页面,还预先加载相邻的页面,以减少未来可能的缺页次数。

此时页面失效如何表现在页表项 (PTE) 上?

缺页的另一个常见原因是 swap 策略,也就是内存页面可能被换到磁盘上了,导致对应页面失效。

Dirty bit (D 位):当页面被修改并且尚未写回到主存时,该位会被设置为 1。如果页面已经被换出到磁盘上,D 位将保持为 1,以指示页面数据已过期。

Valid bit (V 位):当页面在主存中有效时,V 位被设置为 1。如果页面被换出到磁盘上,V 位将被清除为 0,表示该页无效。

通过检查页表项的 D 位和 V 位,操作系统可以确定页面是否需要从磁盘重新加载到内存中。如果 D 位为 1,说明页面需要写回到主存,在将其置为有效之前,必须将页数据从磁盘读取到内存中。如果 V 位为 0,说明页面当前无效,需要将其从磁盘加载到内存中,并将 V 位设置为 1,表示页面有效。

双页表与单页表

为了防范侧信道攻击,我们的 os 使用了双页表。但是传统的设计一直是单页表的,也就是说,用户线程和对应的内核线程共用同一张页表,只不过内核对应的地址只允许在内核态访问。请结合课堂知识回答如下问题:(备注:这里的单/双的说法仅为自创的通俗说法,并无这个名词概念,详情见 KPTI )

单页表情况下,如何更换页表?

在单页表情况下,页表的更换通常是由操作系统的上下文切换来触发的。当从用户态切换到内核态或从一个进程切换到另一个进程时,操作系统会根据相应的上下文信息加载不同的页表,实现页表的更换。

单页表情况下,如何控制用户态无法访问内核页面?(tips:看看第一题最后一问)

  • 设置页面权限:内核页面通常会被设置为只能在内核态下访问(例如,设置 PTE_U 位为 0),这样用户态无法访问内核页面。
  • 操作系统权限:操作系统内核态拥有较高的权限,可以通过特权级别或访问控制机制来确保用户态无法直接访问内核页面。用户程序只能通过系统调用进入内核态,并在内核态下由操作系统执行,从而实现对内核页面的访问控制。

单页表有何优势?(回答合理即可)

单页表的主要优势在于简化了地址转换过程,减少了内存访问的开销。由于用户线程和内核线程共享同一张页表,不需要在上下文切换时频繁切换页表,这可以提高地址转换的效率。此外,单页表还可以节省内存,因为不需要为每个用户线程分配独立的页表。

双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择何时更换页表(回答合理即可)?

在双页表实现下,页表的更换通常在发生上下文切换时需要。当从用户态切换到内核态或从一个进程切换到另一个进程时,需要加载相应的页表,以确保正确的地址转换。如果操作系统采用了每个进程独立的页表,那么在进程切换时需要更换页表。

如果我写一个单页表操作系统,我会选择在发生进程切换时更换页表,因为这是最频繁的上下文切换情况之一。在其他情况下,如从用户态切换到内核态,可能不需要更换整张页表,而只需修改页表项的权限位来实现访问控制。这样可以减少页表更换的开销,提高性能。

附录

修改user项目中的makefile,删除ch4_

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 启动时初始化进程表
void proc_init(void)
{
struct proc *p;
for (p = pool; p < &pool[NPROC]; p++) {
p->state = UNUSED;
// p - pool 是 p 指向的 proc 在 pool 中的下标,因此 p - pool 变化情况是 0, 1, 2, ..., NPROC - 1
p->kstack = (uint64)kstack[p - pool];
p->ustack = (uint64)ustack[p - pool];
p->trapframe = (struct trapframe *)trapframe[p - pool];
/*
* LAB1: you may need to initialize your new fields of proc here
*/
}
idle.kstack = (uint64)boot_stack_top;
idle.pid = 0;
current_proc = &idle;
}

p - pool 表示什么?
假设我们有一个名为 pool 的数组,其中包含了多个类型为 struct proc 的元素,并且有一个指针 p 指向其中的某个元素。
当 p 指向 pool 数组的第一个元素时,p - pool 的结果将是 0,因为指针相对于数组首地址的偏移量为 0。
当 p 指向 pool 数组的第二个元素时,p - pool 的结果将是 1,因为指针相对于数组首地址的偏移量为 1。
以此类推,当 p 指向 pool 数组的第 N 个元素时,p - pool 的结果将是 N-1,因为指针相对于数组首地址的偏移量为 N-1。
总结来说,如果 p 是指向 pool 数组中第 N 个元素的指针,那么 p - pool 的结果将是 N-1。

原调度函数每次都会从 pool 数组的第一个元素开始遍历,这样会导致每次都是从第一个进程开始运行,而不是从上次运行的进程开始运行。需要修改为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 调度程序永不返回。它循环执行以下操作:
// - 选择要运行的进程。
//  - 切换以启动运行该进程。
//  - 最终,该进程通过切换将控制权
//    传递回调度程序。
void scheduler(void)
{
struct proc *p;
struct proc *last_checked_proc = pool; // 初始化指针为 pool

for (;;) {
for (p = last_checked_proc; p < &pool[NPROC];
p++) { // 将 p 初始化为 last_checked_proc
if (p->state == RUNNABLE) {
/*
* LAB1:你可能需要在这里初始化进程的起始时间
*/
p->state = RUNNING;
current_proc = p;
swtch(&idle.context, &p->context);
}
}
last_checked_proc = pool + 1; // 更新 last_checked_proc 的值为下一个位置
}
}

LAB1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
---
os/loader.c | 5 +-
os/proc.c | 15 +-
os/proc.h | 23 +-
os/syscall.c | 55 ++++-
os/syscall_ids.h | 5 +-
os/timer.h | 2 +
9 files changed, 374 insertions(+), 291 deletions(-)

diff --git a/os/loader.c b/os/loader.c
index b45e85d..b21b0a4 100644
--- a/os/loader.c
+++ b/os/loader.c
@@ -1,6 +1,7 @@
#include "loader.h"
#include "defs.h"
#include "trap.h"
+#include <string.h>

static uint64 app_num;
static uint64 *app_info_ptr;
@@ -49,8 +50,10 @@ int run_all_app()
trapframe->sp = (uint64)p->ustack + USER_STACK_SIZE;
p->state = RUNNABLE;
/*
- * LAB1: you may need to initialize your new fields of proc here
+ * LAB1: 初始化系统调用数以及进程开始时间
*/
+ memset(p->syscall_times, 0, MAX_SYSCALL_NUM * sizeof(uint32));
+ p->start_time = 0;
}
return 0;
}
\ No newline at end of file
diff --git a/os/proc.c b/os/proc.c
index fee3886..0c69ae5 100644
--- a/os/proc.c
+++ b/os/proc.c
@@ -2,6 +2,7 @@
#include "defs.h"
#include "loader.h"
#include "trap.h"
+#include "timer.h"

struct proc pool[NPROC];
char kstack[NPROC][PAGE_SIZE];
@@ -33,9 +34,8 @@ void proc_init(void)
p->kstack = (uint64)kstack[p - pool];
p->ustack = (uint64)ustack[p - pool];
p->trapframe = (struct trapframe *)trapframe[p - pool];
- /*
- * LAB1: you may need to initialize your new fields of proc here
- */
+ memset(p->syscall_times, 0, MAX_SYSCALL_NUM * sizeof(uint32));
+ p->start_time = 0;
}
idle.kstack = (uint64)boot_stack_top;
idle.pid = 0;
@@ -47,6 +47,7 @@ int allocpid()
static int PID = 1;
return PID++;
}
+
// 在进程表中寻找一个未使用的进程。
// 如果找到,则初始化在内核中运行所需的状态。
// 如果没有空闲的进程,或者内存分配失败,则返回 0。
@@ -80,14 +81,18 @@ void scheduler(void)
{
struct proc *p;
struct proc *last_checked_proc = pool; // 初始化指针为 pool
-
for (;;) {
for (p = last_checked_proc; p < &pool[NPROC];
p++) { // 将 p 初始化为 last_checked_proc
if (p->state == RUNNABLE) {
/*
- * LAB1:你可能需要在这里初始化进程的起始时间
+ * LAB1:在这里初始化进程的开始时间
*/
+ if (p->start_time == 0) {
+ uint64 cycle = get_cycle();
+ p->start_time =
+ (cycle % CPU_FREQ) * MILLISECONDS_PER_SECOND / CPU_FREQ;
+ }
p->state = RUNNING;
current_proc = p;
swtch(&idle.context, &p->context);
diff --git a/os/proc.h b/os/proc.h
index d208c5d..53576bf 100644
--- a/os/proc.h
+++ b/os/proc.h
@@ -3,7 +3,8 @@

#include "types.h"

-#define NPROC (16)
+#define NPROC (16) // 最大进程数
+#define MAX_SYSCALL_NUM (500) // 最大系统调用数

// Saved registers for kernel context switches.
struct context {
@@ -42,14 +43,28 @@ struct proc {
uint64 kstack; // 进程内核栈虚拟地址 (内核页表)
struct trapframe *trapframe; // 进程中断帧
struct context context; // 用于保存进程内核态的寄存器信息,进程切换时使用
- /*
- * LAB1: you may need to add some new fields here
+ /*
+ * LAB1: 添加一些新的成员用于新的 sys_task_info 系统调用
*/
+ uint32 syscall_times[MAX_SYSCALL_NUM]; // 系统调用次数统计 TODO: 后续改为指针
+ uint64 start_time; // 进程开始运行时间
};

/*
-* LAB1: you may need to define struct for TaskInfo here
+* LAB1: 定义 TaskInfo 结构体
*/
+typedef enum {
+ UnInit,
+ Ready,
+ Running,
+ Exited,
+} TaskStatus;
+
+typedef struct {
+ TaskStatus status;
+ uint32 syscall_times[MAX_SYSCALL_NUM];
+ int time; // 进程运行时间统计
+} TaskInfo;

struct proc *curr_proc();
void exit(int);
diff --git a/os/syscall.c b/os/syscall.c
index 1cc5aeb..f54ed86 100644
--- a/os/syscall.c
+++ b/os/syscall.c
@@ -4,6 +4,7 @@
#include "syscall_ids.h"
#include "timer.h"
#include "trap.h"
+#include "proc.h"

uint64 sys_write(int fd, char *str, uint len)
{
@@ -31,14 +32,46 @@ uint64 sys_sched_yield()
uint64 sys_gettimeofday(TimeVal *val, int _tz)
{
uint64 cycle = get_cycle();
- val->sec = cycle / CPU_FREQ;
- val->usec = (cycle % CPU_FREQ) * MICROSECONDS_PER_SECOND / CPU_FREQ;
+ tracef("sys_gettimeofday cycle = %d", cycle);
+ val->sec = cycle / CPU_FREQ;
+ val->msec = (cycle % CPU_FREQ) * MILLISECONDS_PER_SECOND / CPU_FREQ;
+ val->usec = (cycle % CPU_FREQ) * MICROSECONDS_PER_SECOND / CPU_FREQ;
return 0;
}

-/*
-* LAB1: you may need to define sys_task_info here
-*/
+/**
+ * LAB1:此处定义 sys_task_info 函数
+ * 查询当前正在执行的任务信息,任务信息包括任务控制块相关信息(任务状态)、任务使用的系统调用次数、任务总运行时长。
+ */
+int sys_task_info(TaskInfo *ti)
+{
+ struct proc *proc = curr_proc();
+ // TODO: proc 检查为空
+ for (int i = 0; i < MAX_SYSCALL_NUM; i++) {
+ ti->syscall_times[i] = proc->syscall_times[i];
+ }
+ uint64 cycle = get_cycle();
+ uint64 current_time =
+ (cycle % CPU_FREQ) * MILLISECONDS_PER_SECOND / CPU_FREQ;
+ infof("sys_task_info current_time = %d", current_time);
+ infof("proc->start_time = %d", proc->start_time);
+ infof("ti->time = %d", current_time - proc->start_time);
+
+ if (proc->state == RUNNING) {
+ ti->status = Running;
+ } else if (proc->state == RUNNABLE) {
+ ti->status = Ready;
+ } else if (proc->state == SLEEPING) {
+ ti->status = Ready;
+ } else if (proc->state == ZOMBIE) {
+ ti->status = Exited;
+ } else if (proc->state == UNUSED) {
+ ti->status = UnInit;
+ }
+
+ ti->time = current_time - proc->start_time;
+ return 0;
+}

extern char trap_page[];

@@ -51,8 +84,9 @@ void syscall()
tracef("syscall %d args = [%x, %x, %x, %x, %x, %x]", id, args[0], args[1],
args[2], args[3], args[4], args[5]);
/*
- * LAB1: you may need to update syscall counter for task info here
+ * LAB1: 更新系统调用次数
*/
+ curr_proc()->syscall_times[id]++;
switch (id) {
case SYS_write:
ret = sys_write(args[0], (char *)args[1], args[2]);
@@ -67,8 +101,15 @@ void syscall()
ret = sys_gettimeofday((TimeVal *)args[0], args[1]);
break;
/*
- * LAB1: you may need to add SYS_taskinfo case here
+ * LAB1: 在此处添加 SYS_task_info 的系统调用处理情况
*/
+ case SYS_task_info:
+ ret = sys_task_info((TaskInfo *)args[0]);
+ break;
+ case SYS_getpid:
+ infof("SYS_getpid %d", SYS_getpid);
+ ret = curr_proc()->pid;
+ break;
default:
ret = -1;
errorf("unknown syscall %d", id);
diff --git a/os/syscall_ids.h b/os/syscall_ids.h
index 05a6cb9..3c1a5a9 100644
--- a/os/syscall_ids.h
+++ b/os/syscall_ids.h
@@ -277,9 +277,8 @@
#define SYS_io_pgetevents 292
#define SYS_rseq 293
#define SYS_kexec_file_load 294
-/*
-* LAB1: you may need to define SYS_task_info here
-*/
+// LAB1:添加 SYS_task_info 的系统调用号
+#define SYS_task_info 410
#define SYS_pidfd_send_signal 424
#define SYS_io_uring_setup 425
#define SYS_io_uring_enter 426
diff --git a/os/timer.h b/os/timer.h
index c6ebd14..63ab45c 100644
--- a/os/timer.h
+++ b/os/timer.h
@@ -6,6 +6,7 @@
#define TICKS_PER_SEC (100)
// QEMU
#define CPU_FREQ (12500000)
+#define MILLISECONDS_PER_SECOND (1000)
#define MICROSECONDS_PER_SECOND (1000000)

uint64 get_cycle();
@@ -14,6 +15,7 @@ void set_next_timer();

typedef struct {
uint64 sec; // 自 Unix 纪元起的秒数
+ uint64 msec; // 毫秒数
uint64 usec; // 微秒数
} TimeVal;

--
2.34.1


问答作业

问题一

正确进入 U 态后,程序的特征还应有:使用 S 态特权指令,访问 S 态寄存器后会报错。请同学们可以自行测试这些内容(参考 前三个测例,描述程序出错行为,同时注意注明你使用的 sbi 及其版本。

测试前三个测试用例指的是uCore-Tutorial-Code-2023S/user/src/ 目录下的三个bad测试用例,查看user项目的 Makefile 可以发现在编译时修改CHAPTER参数值为2_bad即可编译运行这些测试用例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[rustsbi] RustSBI version 0.3.0-alpha.2, adapting to RISC-V SBI v1.0.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Implementation : RustSBI-QEMU Version 0.2.0-alpha.2
[rustsbi] Platform Name : riscv-virtio,qemu
[rustsbi] Platform SMP : 1
[rustsbi] Platform Memory : 0x80000000..0x88000000
[rustsbi] Boot HART : 0
[rustsbi] Device Tree Region : 0x87000000..0x87000ef2
[rustsbi] Firmware Address : 0x80000000
[rustsbi] Supervisor Address : 0x80200000
[rustsbi] pmp01: 0x00000000..0x80000000 (-wr)
[rustsbi] pmp02: 0x80000000..0x80200000 (---)
[rustsbi] pmp03: 0x80200000..0x88000000 (xwr)
[rustsbi] pmp04: 0x88000000..0x00000000 (-wr)
[TRACE 0]load app 0 at 0x0000000080400000
[TRACE 0]load app 1 at 0x0000000080420000
[TRACE 0]load app 2 at 0x0000000080440000
[INFO 0]start scheduler!
[ERROR 1]unknown trap: 0x0000000000000007, stval = 0x0000000000000000

[INFO 1]进程 1 以代码 -1 退出
IllegalInstruction in application, core dumped.
[INFO 2]进程 2 以代码 -3 退出
IllegalInstruction in application, core dumped.
[INFO 3]进程 3 以代码 -3 退出
[PANIC 3] os/loader.c:15: all apps over

第一个进程测试用例如下:

1
2
3
4
5
6
int main()
{
int *p = (int *)0;
*p = 0;
return 0;
}

在您提供的代码中,将空指针分配给指针变量*p 后,试图对其进行解引用并将值 0 赋给该指针。由于用户模式下禁止直接访问物理内存,操作系统会检测到这个非法操作并触发异常。因此,该程序 IllegalInstruction in application, core dumped.

在 RISC-V 架构中,U 模式是最低的用户模式,用户程序无法直接访问物理内存或其他特权级别资源。这种限制是为了确保操作系统的安全性和稳定性。

第二个进程测试用例如下:

1
2
3
4
5
int main()
{
asm volatile("sret");
return 0;
}

试图使用汇编语言执行 sret 指令,该指令用于从中断或异常处理程序返回。由于用户模式下禁止直接访问特权级别寄存器,操作系统会检测到这个非法操作并触发异常。因此,该程序 IllegalInstruction in application, core dumped。

第三个进程测试用例如下:

1
2
3
4
5
6
int main()
{
uint64 x;
asm volatile("csrr %0, sstatus" : "=r"(x));
return 0;
}

原因同上,试图使用汇编语言执行 csrr 指令,该指令用于从特权级别寄存器中读取值。由于用户模式下禁止直接访问特权级别寄存器,操作系统会检测到这个非法操作并触发异常。因此,该程序 IllegalInstruction in application, core dumped。

在操作系统代码中,触发异常后会进入void usertrap() 函数,该函数会根据 scause 寄存器的值判断异常类型,用例中的结果进入了case IllegalInstruction,其中 IllegalInstruction = 2。我们查阅手册 riscv-privileged.pdf ,可以查到 IllegalInstruction 的值为 2,与预期相符。

问题二

请结合用例理解 trampoline.S 中两个函数 userretuservec 的作用,并回答如下几个问题:

L79: 刚进入 userret 时,a0a1 分别代表了什么值。

在进入userret函数时,a0a1分别代表以下值:

  • a0: TRAPFRAME 的地址,指向当前进程的陷阱帧(trapframe)结构体。
  • a1: 用户页表的地址,即进程的页表(pagetable)。这个地址会被写入到satp寄存器中,用于切换到用户模式的页表。

L87-L88: sfence 指令有何作用?为什么要执行该指令,当前章节中,删掉该指令会导致错误吗?

1
2
csrw satp, a1
sfence.vma zero, zero

sfence指令(Store Fence)的作用是确保之前的存储操作完成,并且对其他处理器上的核心可见。

执行sfence指令的主要目的是为了保证内存访问的顺序性和一致性。在多核处理器系统中,不同的核心可能会有自己的缓存,当一个核心修改了共享内存中的数据时,为了保证其他核心能够看到这个修改,需要使用sfence指令来刷新缓存并将修改写回共享内存。

在代码中,sfence指令被用于确保对用户页表的修改对其他处理器上的核心可见。因为目前我只使用了单核处理器,所以不会出现多核处理器的情况,因此sfence指令的作用是确保对用户页表的修改对当前核心可见。

因此,当前章节中,删掉该指令不会导致错误

L96-L125: 为何注释中说要除去 a0?哪一个地址代表 a0?现在 a0 的值存在何处?

1
2
3
4
5
# restore all but a0 from TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
ld t5, 272(a0)
ld t6, 280(a0)

a0保存在 sscratch 寄存器中的,首先,该代码通过 ld 指令从 TRAPFRAME 中加载各个寄存器的值。然后,这些值被存储在相应的寄存器中,以便在恢复用户上下文时使用。

接下来,代码使用 csrrw 指令将 sscratch 寄存器的值与 a0(即 TRAPFRAME)进行交换。这样做是为了将用户的 a0TRAPFRAME)保存在 sscratch 寄存器中,以便后续步骤可以正确地恢复用户上下文。

最后,通过 sret 指令返回到用户模式,并将控制权交给用户代码。在执行 sret 指令后,处理器将根据用户上下文中的 sepc 寄存器的值跳转到用户代码的指令地址。返回的同时,处理器还会自动恢复 sstatus 寄存器的值,以确保正确的特权级别和中断状态。

userret:中发生状态切换在哪一条指令?为何执行之后会进入用户态?

userret函数中,发生状态切换的指令是sret指令。

sret指令用于从内核模式切换到用户模式,并将控制权交给用户代码。执行sret指令后,处理器会根据用户上下文中的sepc寄存器的值跳转到用户代码的指令地址。

执行sret指令之后进入用户态的原因是,该指令会自动恢复sstatus寄存器的值,以确保正确的特权级别和中断状态。当sret指令执行后,处理器将从内核态切换回用户态,程序将继续执行用户代码。这意味着userret函数成功完成了从内核切换到用户模式的过程。

L29:执行之后,a0 和 sscratch 中各是什么值,为什么?

1
csrrw a0, sscratch, a0     

在执行指令后,a0sscratch中的值发生了互换。

假设原始a0寄存器中的值为 X,而sscratch寄存器中的值为 Y。执行csrrw a0, sscratch, a0指令后,a0寄存器中的值变为 Y,而sscratch寄存器中的值变为 X。

这是因为csrrw指令是一个特权指令,用于将某个 CSR(Control and Status Register)的值读取到目标寄存器,然后将目标寄存器的值写回到该 CSR 中。在这里,csrrw a0, sscratch, a0指令将sscratch寄存器的值读取到a0寄存器中,同时将a0寄存器中的值写回到sscratch寄存器中,从而实现了两者之间的数据交换。

L32-L61: 从 trapframe 第几项开始保存?为什么?是否从该项开始保存了所有的值,如果不是,为什么?

1
2
3
4
5
6

sd ra, 40(a0)
sd sp, 48(a0)
...
sd t5, 272(a0)
sd t6, 280(a0)

进入 S 态是哪一条指令发生的?

L75-L76: ld t0, 16(a0) 执行之后,t0中的值是什么,解释该值的由来?

1
2
ld t0, 16(a0)
jr t0

ld t0, 16(a0)就是从 trapframe 中恢复 t0寄存器值,t0保存了kernel_trap的入口地址。使用 jr t0,就跳转到了我们早先设定在 trapframe->kernel_trap 中的地址,也就是 trap.c 之中的 usertrap 函数。这个函数在 main 的初始化之中已经调用了。

背景

配置 yaml 文件时会遇到需要将配置的内容按照键值排序的情况,比如下面这样riscv_fork_list.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
packages:
- name: accumulo
- name: abseil-cpp
- name: acpica-tools
- name: acpid
- name: activemq
- name: afflib
- name: adcli
- name: adwaita-icon-theme
- name: aide
- name: alsa-lib
- name: amtk
- name: anaconda
- name: apache-sshd
- name: annobin
- name: antlr3
- name: apache-commons-csv
- name: aom
- name: apache-commons-beanutils
- name: apache-commons-daemon
- name: apache-commons-el
- name: apache-commons-exec
- name: apache-commons-jexl
- name: apache-poi
- name: apache-rat

我想按照 name 的字母顺序排序,可以使用 yq 工具来实现。

安装 yq

1
2
wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq &&\
chmod +x /usr/bin/yq

使用 yq

1
yq -i '.packages |= sort_by(.name)' riscv_fork_list.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
packages:
- name: abseil-cpp
- name: accumulo
- name: acpica-tools
- name: acpid
- name: activemq
- name: adcli
- name: adwaita-icon-theme
- name: afflib
- name: aide
- name: alsa-lib
- name: amtk
- name: anaconda
- name: annobin
- name: antlr3
- name: aom
- name: apache-commons-beanutils
- name: apache-commons-csv
- name: apache-commons-daemon
- name: apache-commons-el
- name: apache-commons-exec
- name: apache-commons-jexl
- name: apache-poi
- name: apache-rat
- name: apache-sshd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
flowchart TB
subgraph entry.S
_entry[_entry]
end
subgraph link_app.S
_app_num[_app_num]
end
subgraph main.c
main[main]
end
subgraph loader.c
loader_init[loader_init]
run_next_app[run_next_app]
load_app[load_app]
end

_entry --> main
main --> loader_init
main --> run_next_app
run_next_app --> load_app
loader_init --> _app_num
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×