为何指定 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

问题描述

在 VSCode 中编辑 Markdown 文本,复制到思源笔记后,思源笔记无法转义为 Markdown 格式。会变成一个代码块,但是代码块内的内容并不是复制的内容。

比如上面这段话复制到思源笔记成了下图这样:

但是我需要的是能够转义为 Markdown 的阅读模式。

解决方法

问题的原因在于 VSCode 复制的文本是带格式的,而思源笔记默认的粘贴模式是纯文本模式,所以会出现上面的问题。

解决方法就是从 VSCode 复制的内容为纯文本,一种可以把文本复制到 txt 文件中,再复制,但是比较麻烦。

第二种方法是使用 VSCode 的插件 Copy Plain Text,搜索下载后,默认快捷键为 Ctrl+Alt+C,可以复制为纯文本。

再次粘贴到思源笔记中,就可以转义为 Markdown 格式了。

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

背景简介

在使用 VSCode 的过程中,我们经常会安装一些插件来提高开发效率。但是,由于某些原因,我们可能无法直接访问 VSCode 的插件市场,这时候我们就需要离线安装插件了。

这里存在两种情况,一种是为本地的 VSCode 安装插件,另一种是为远程的 VSCode 安装插件。本文将分别介绍这两种情况下的离线安装方法。

远程 VSCode 也就是 VSCode 的Remote Development功能,可以通过 SSH、Docker、WSL 等方式远程连接到远程主机上的 VSCode。

方法一:使用已安装的插件目录

  • 从已经安装插件的电脑上拷贝所有插件,路径一般为 C:\用户\用户名\.vscode\extensions
  • 拷贝到离线安装的电脑上的 .vscode/extensions 文件夹下即可,重启 VScode 即可安装成功。

对于远程 VSCode 我们需要知道,插件不区分操作系统,所以我们可以在本地的 Windows 上的 VSCode 上安装插件,然后将插件目录压缩后整个拷贝到远程主机上即可。

远程主机上的插件目录一般在 ~/.vscode-server/extensions 下。将压缩的文件解药到这个目录下,重启 VSCode 即可。

方法二:下载离线安装包 vslx 安装

  • VScode 插件中心 搜索需要使用的插件名称

  • 下载对应的拓展程序文件,下载的文件的后缀是.vslx

  • VSCode 中安装

命令行

在 Windows 中,可以使用 netsh 命令来添加、查看和删除端口转发规则。

添加一个端口转发规则,可以使用以下命令:

1
netsh interface portproxy add v4tov4 listenaddress=<local_address> listenport=<local_port> connectaddress=<remote_address> connectport=<remote_port>

其中:

  • <local_address>是本地监听的地址(可以是 IP 地址或 0.0.0.0 表示所有地址)。
  • <local_port>是本地监听的端口。
  • <remote_address>是转发连接到的远程地址。
  • <remote_port>是转发连接到的远程端口。

例如,要将本地的 8080 端口转发到远程服务器上的 80 端口,可以使用以下命令:

1
netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=8080 connectaddress=192.168.0.100 connectport=80

查看当前的端口转发规则,可以使用以下命令:

1
netsh interface portproxy show v4tov4

删除特定的端口转发规则,可以使用以下命令:

1
netsh interface portproxy delete v4tov4 listenaddress=<local_address> listenport=<local_port>

其中的<local_address><local_port>应该与你想删除的规则匹配。

请注意,执行这些操作通常需要管理员权限。

GUI

使用开源工具PortProxyGUI可以在 UI 界面快速增删改查端口映射。

背景简介

WSL2 是 Windows 的子系统,可以在 Windows 上运行 Linux,但是 WSL2 是运行在虚拟机中的,所以无法直接访问 WSL2 中的服务,比如 SSH 服务。本文介绍如何使用内网穿透工具花生壳来实现远程访问 WSL2 中的服务。

实现这一需求需要完成两个功能。

  1. WSL2 中的服务是运行在虚拟机中的,如何将公网的访问转发到 WSL2 中。
  2. Windows 没有公网 IP,如何通过公网来访问。

WSL2 端口转发

获取 WSL2 的 IP 地址:

1
2
hostname -I | awk '{print $1}'
172.26.13.98

Windows 自带的netsh interface portproxy可以实现端口转发。管理员身份打开 cmd,执行以下命令:

1
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=2222 connectaddress=172.26.13.98 connectport=22
  • listenport:公网访问的端口(改一个不冲突的就行)
  • connectaddress:WSL2 的 IP 地址
  • connectport:WSL2 中 SSH 服务的端口 (默认为 22,不需要更改)

开启 Windows 防火墙入站规则,管理员身份打开 cmd,执行以下命令:

1
netsh advfirewall firewall add rule name=WSL2 dir=in action=allow protocol=TCP localport=2222

这个命令是用于在 Windows 高级防火墙中添加一条规则。下面是对每个参数的解释:

  • name=WSL2:将规则命名为 “WSL2”。
  • dir=in:指定规则适用于传入的网络流量。
  • action=allow:允许通过该规则的流量通过防火墙。
  • protocol=TCP:指定规则适用于 TCP 协议的流量。
  • localport=2222:指定本地端口号为 2222。

验证端口转发是否成功:

1
ssh -p 2222 user@localhost
  • user 修改成 WSL2 的用户名

如果配置成功,则会成功登录 WSL2。

安装配置花生壳

进入官网下载花生壳客户端,安装后打开,注册账号,登录。需要实名认证

免费账户可以绑定2 个映射,对我来说暂时够用了,免费流量 1G/月。实测阅读代码不编译的话大概每天 50M左右。

打开客户端,添加映射,配置如下:

保存即可。

验证是否配置成功,找一台不在同一个局域网的电脑,使用 SSH 连接 WSL2:

如果复制出来的访问地址为abcdjsj.goho.co:33445,那么 SSH 命令修改为如下:

1
ssh -p 33445  user@abcdjsj.goho.co
  • user 修改成 WSL2 的用户名

如果配置成功,则会成功登录 WSL2。

题外话

  1. WSL2 的 IP 会经常变化,如果连不上了,可以重新获取一下 IP,然后修改一下各个配置。或者想办法将 WSL2 的 IP 固定下来。
  2. 带宽有限,登录时比较慢,耐心等待。后续准备使用 frp 自建一个穿透服务。
  3. PC 耗电伤不起啊,一百多瓦赶上三四台 NAS 了。这玩意只能应急,长时间挂机电费都够买个云服务器了。

常见问题

“System is booting up. Unprivileged users are not permitted to log in yet”

登录服务端,也就是 WSL2,执行以下命令

1
sudo rm /run/nologin

Docker-compose 启动

下载官方的 docker-compose.yml 文件,然后修改一下端口和挂载路径,然后启动即可。

1
wget https://dl.photoprism.app/docker/docker-compose.yml

如果无法下载下载地址可以前往 Docker Compose - PhotoPrism 查看最新。

根据自己需要修改以下参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
version: '3.5'

services:
photoprism:
## Use photoprism/photoprism:preview for testing preview builds:
image: dockerproxy.com/photoprism/photoprism:latest # 配置了镜像加速
ports:
- "2342:2342" # HTTP port (host:container)
environment:
PHOTOPRISM_ADMIN_USER: "admin" # 管理员用户名
PHOTOPRISM_ADMIN_PASSWORD: "12345678" # 管理员密码
PHOTOPRISM_DETECT_NSFW: "true" # 自动检测 NSFW 图片并标记隐私图片
PHOTOPRISM_UPLOAD_NSFW: "true" # 运行上传 NSFW 图片
## Share hardware devices with FFmpeg and TensorFlow (optional):
devices:
- "/dev/dri:/dev/dri" # 如果有核显或者独显可以配置硬件加速
volumes:
- "/root/sharedfolder/syncthing/Photo_Album:/photoprism/originals/Photo_Album" # 照片存放路径
- "/root/sharedfolder/syncthing/daily:/photoprism/originals/daily" # 照片存放路径
- "/root/sharedfolder/syncthing/baby:/photoprism/originals/baby" # 照片存放路径
- "./storage:/photoprism/storage" # 不要删除 (DO NOT REMOVE)

然后启动即可:

1
docker-compose up -d

初始化需要时间,等待一分钟左右,然后访问 http://{hostip}:2342 即可看到登录界面。

配置

配置中文界面

索引照片

这个过程会调用 TensorFlow 进行照片的 AI 识别,然后自动进行分类,照片如果很多会很慢。如果只想索引某一个目录就点击图片中的区域选择指定目录,选择目录的过程会加载比较慢,需要等待。

使用相册

索引完成就可以点击搜索进行查看所有照片了:

索引过程会根据照片的 Exif 信息自动分类,包括时间与地点。后悔从相机导出照片时把地点抹去了。

照片还是得及时整理呀,这成千上万张照片挨个标记还是很麻烦的,就这样吧,做个图片墙也不错。

常见问题

在docker-compose.yml中删除已经索引的volume,为何图片库中还存在缓存

缓存保存在storage中,如果图片内容不多,可以将该目录删除,重启容器。也可以通过以下命令将缓存删除:

1
2
3
4
5
6
# 进入容器
docker exec -it photo-prism bash
# 删除索引
photoprism purge
# 删除文件
photoprism cleanup

全选图片,选择多个图片

可以选择一张图片后按住Shift到最后一张,批量选择图片

定时索引照片

可以使用 crontab 定时执行 photoprism index 命令,例如每天凌晨 3 点执行一次:

1
2
3
4
# 编辑定时任务
crontab -e
# 添加以下内容
0 3 * * * docker exec -i photo-prism photoprism index

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
version: "3.7"

services:
radarr:
container_name: radarr
image: dockerproxy.com/linuxserver/radarr:latest
ports:
- "7878:7878"
environment:
- PUID=1000
- PGID=1000
- UMASK=002
- TZ=Asia/Shanghai
volumes:
- /root/sharedfolder/appdata/radarr:/config
- /root/sharedfolder/media:/movies
- /root/sharedfolder/downloads/qbittorrent:/downloads

配置中文界面:

导入视频:

Your browser is out-of-date!

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

×