Lab2 实验报告

关键词:物理内存的管理方法(链表法),虚拟内存的管理方法(两级页表法),TLB清除与重填

思考题

2.1 请根据上述说明,回答问题:在编写的 C 程序中,指针变量中存储的地址是虚拟地址,还是物理地址?MIPS 汇编程序中 lw 和 sw 使用的是虚拟地址,还是物理地址?

均为虚拟地址

2.2 请思考下述两个问题:① 从可重用性的角度,阐述用宏来实现链表的好处。②查看实验环境中的 /usr/include/sys/queue.h,了解其中单向链表与循环链表的实现,比较它们与本实验中使用的双向链表,分析三者在插入与删除操作上的性能差异。

① 实现了C语言不支持的泛型。宏函数参数type的使用实现自定义数据类型的链表,通过各种宏定义实现链表的基本操作。

C++ 中可以使用 std::stack 定义一个类型为 T 的栈,Java 中可以使用 HashMap<K,V>

定义一个键类型为 K 且值类型为 V 的哈希表。这种模式称为泛型,C 语言并没有泛型的语法,因

此需要通过宏另辟蹊径来实现泛型。

插入操作 删除操作
单向链表 头部插入O(1), 尾部插入O(n),指定节点前O(n),指定节点后O(1) 头部删除O(1),指定节点O(n)
循环链表 头部插入O(1), 尾部插入O(1),指定节点前O(1),指定节点后O(1) 头部删除O(1),指定节点O(1)
双向链表 头部插入O(1), 尾部插入O(n),指定节点前O(1),指定节点后O(1) 头部删除O(1),指定节点O(1)

空间性能:单向链表<双向链表<循环链表

时间性能:循环链表<双向链表<单向链表

2.3 Page_list 的展开结构
struct Page_list{
struct {
struct {
struct Page *le_next;
struct Page **le_prev;
} pp_link; // 对应的链表项
u_short pp_ref; // 这一页物理内存被引用的次数,等于有多少虚拟页映射到该物理页
}* lh_first;
}
2.4 请思考下面两个问题:① 请阅读上面有关 R3000-TLB 的描述,从虚拟内存的实现角度,阐述 ASID 的必要性。② 请阅读《IDT R30xx Family Software Reference Manual》的 Chapter 6,结合 ASID段的位数,说明 R3000 中可容纳不同的地址空间的最大数量。

① ASID 用于区分不同的地址空间,因为同一虚拟地址在不同的地址空间中通常映射到不同的物理地址。ASID能在多进程操作系统中访存更加安全。

② ASID 长度为6位,可以容纳不同地址空间的最大数量即为64。每个进程对应唯一的 ASID,则处理器可支持64个并发进程。

请回答下述三个问题:① tlb_invalidate 和 tlb_out 的调用关系?② 请用一句话概括 tlb_invalidate 的作用。③ 逐行解释 tlb_out 中的汇编代码。

tlb_invalidate 调用 tlb_out,实现删除特定虚拟地址在 TLB 中的旧表项。

LEAF(tlb_out)
.set noreorder
mfc0 t0, CP0_ENTRYHI // t0 = CP0_ENTRYHI
mtc0 a0, CP0_ENTRYHI // CP0_ENTRYHI = a0
nop // 解决数据冒险(tlbq)
/* Step 1: Use 'tlbp' to probe TLB entry */
/* Exercise 2.8: Your code here. (1/2) */
tlbp // 根据 ENTRYHI 中的 key 查找对应的旧表项,将表项的索引存入 Index
nop // 解决数据冒险(tlbq)
/* Step 2: Fetch the probe result from CP0.Index */
mfc0 t1, CP0_INDEX // t1 = CP0_INDEX
.set reorder
bltz t1, NO_SUCH_ENTRY // 若索引值>=0(即TLB中存在 key 对应的表项),则向 ENTRYHI 和 ENTRYLO中写入0
.set noreorder
mtc0 zero, CP0_ENTRYHI
mtc0 zero, CP0_ENTRYLO0
nop
/* Step 3: Use 'tlbwi' to write CP0.EntryHi/Lo into TLB at CP0.Index */
/* Exercise 2.8: Your code here. (2/2) */
tlbwi // 将 ENTRYHI, EHTRYLO 中的值写入索引指定的表项
.set reorder

NO_SUCH_ENTRY:
mtc0 t0, CP0_ENTRYHI // CPO_ENTRYHI = t0 恢复为原始值,以便后续指令能正确访问虚拟地址
j ra
END(tlb_out)
2.5 在现代的 64 位系统中,提供了 64 位的字长,但实际上不是 64 位页式存储系统。假设在 64 位系统中采用三级页表机制,页面大小 4KB。由于 64 位系统中字长为8B,且页目录也占用一页,因此页目录中有 512 个页目录项,因此每级页表都需要 9 位。因此在 64 位系统下,总共需要 3 × 9 + 12 = 39 位就可以实现三级页表机制,并不需要 64位。现考虑上述 39 位的三级页式存储系统,虚拟地址空间为 512 GB,若三级页表的基地址为 PTbase,请计算:三级页表页目录的基地址。映射到页目录自身的页目录项(自映射)。

三级页表的基地址为PTbase(即虚拟内存第一个页表项的地址),其所在页为 PTbase >> 12。第二级页表的基地址 PTbase + PTbase >> 12 << 3 = PTbase + PTbase >> 9。那么一级页表的基地址为 PTbase + PTbase >> 9 + PTbase >> 18。

映射到页目录自身的页目录项即为 PDEbase = PTbase + PTbase >> 9 + PTbase >> 18 + PTbase >> 27。

2.6 ① 简单了解并叙述 X86 体系结构中的内存管理机制,比较 X86 和 MIPS 在内存管理上的区别。② 简单了解并叙述 RISC-V 中的内存管理机制,比较 RISC-V 与 MIPS 在内存管理上的区别。

① x86主要为为段页式内存管理机制,更有利于内存保护和共享。而MIPS作为一种精简的指令集体系结构,主要采用分页式内存管理机制和单一地址空间模型,即将内存地址空间分为用户空间和内核空间。

函数定义分析

  • mips_detect_memory() : kern/pmap.c,探测硬件可用内存,并对一些和内存管理相关的变量进行初始化
  • mips_vm_init():kern/pmap.c,在探测完可用内存后,将开始建立内存管理机制。
    • alloc:kern/pmap.c,分配内存空间(在建立页式内存管理机制之前使用)。分配 n 字节的空间并返回初始的虚拟地址,同时将地址按 align 字节对齐(保证 align 可以整除初始虚拟地址),若 clear 为真,则将对应内存空间的值清零,否则不清零。
  • page_init()
  • ROUND(a,n) :一个定义在 include/types.h 的宏,作用是返回 ⌈a\n⌉ n(将 a 按 n 向上对齐),要求 n 必须是 2 的非负整数次幂。
  • ROUNDDOWN(a, n) : 下取整
  • PPN(va) :得到某个虚拟地址的页号。
  • PADDR(x):include/mmu.h,将某个内核虚拟地址x转化为物理地址。(该宏要求x必须是kseg0中的虚拟地址)
  • KADDR:include/mmu.h,返回物理地址 x 所位于 kseg0 的虚拟地址。
  • page2kva(pp) :得到 Page pp 的内核虚拟地址
  • page2pa(pp) :得到 Page pp 的物理地址
  • pa2page(pa) :得到物理地址 pa 所对应的 Page 结构体(读取pte后可进行转换)
  • PPN(va) :得到虚拟地址 va 的页号
  • page2ppn(pp):得到 Page pp 的页号
  • memset(void *dst, int c, size_t n) :eg. memset((void *)alloced_mem, 0, n)。

链表宏

struct {
struct type *le_next; // 指向下一个元素
struct type **le_prev; // 指向前一个元素链表项 le_next (当删除一个元素时,更改前一个元素链表项的le_next)
}

对于往链表中插入元素操作解释如图(其中黄线表示需要进行的操作,操作顺序不一定如图。)

image-20230321112349534

 * Hint:
* Step 1: assign 'elm.next' from 'listelm.next'.
* Step 2: if 'listelm.next' is not NULL, then assign 'listelm.next.pre' from a proper value.
* Step 3: assign 'listelm.next' from a proper value.
* Step 4: assign 'elm.pre' from a proper value.
*/
#define LIST_INSERT_AFTER(listelm, elm, field) \
/* Exercise 2.2: Your code here. */ \
do {\
LIST_NEXT((elm), field) = LIST_NEXT((listelm), field);\
if (LIST_NEXT((listelm), field) != NULL) \
LIST_NEXT((listelm), field)->field.le_prev = &(LIST_NEXT((elm), field));\
LIST_NEXT((listelm), field) = (elm);\
(elm)->field.le_prev = &(LIST_NEXT((listelm), field));\
} while (0)

页控制块

npage 个 Page 和 npage 个物理页面一一顺序对应,具体来说,npage 个 Page 的起始地址为 pages,则 pages[i] 对应从 0 开始计数的第 i 个物理页面。两者的转换可以使用 include/pmap.h 中的 page2pa 和 pa2page 这两个函数。

page_free_list : 空闲链表。(当一个进程需要分配内存时,将空闲链表头部的页控制块对应的那一页物理内存分配出去,同时将该页控制块从空闲链表的头部删去。)

freemem:小于 freemem 对应物理地址的物理内存都已经被分配完了。(freemem是虚拟地址)

pmap.c page_alloc 函数注意点

memset(void *dst, int c, size_t n);:第一个参数应为Page pp指向页面的虚拟地址。

*new = pp; // new地址的内容为pp
new = &pp; // new指针指向pp所在地址

为什么内核初始化的时候要把内核用到的物理页面pp_ref置1?

物理页面的pp_ref通常用于跟踪物理页面的使用情况。当一个物理页面被映射到虚拟页表时,pp_ref会增加;删除映射关系后,pp_ref减1。如果此时pp_ref等于0就将这个物理页再添加到空闲物理页面链表里(即页面被释放)。在内核初始化期间,将内核需要使用的物理页面的pp_ref计数值设置为1,就是为了确保这些页面不会被释放,从而保证内核代码和数据的完整性和可靠性。

虚拟内存管理

PDX(va) : 获取虚拟地址 va 的 31-22位(一级页表偏移)

PTX(va) : 获取虚拟地址 va 的 21-12 位(二级页表偏移)

PTE_ADDR(pte): 获取页表项中的物理地址

在 Exercise 2.6时需要注意MIPS R3000 发出的地址均为虚拟地址,因此如果程序想访问某个物理地址,需要通过映射到该物理地址的虚拟地址来访问。对页表进行操作时处于内核态,因此使用宏 KADDR 获得其位于 kseg0 中的虚拟地址即可完成转换。

image-20230327201609483

课上

exam

题目

对于给定的页目录 pgdir,统计其包含的所有二级页表中满足以下条件的页表项:

  1. 页表项有效;
  2. 页表项映射的物理地址为给定的 Page *pp 对应的物理地址;
  3. 页表项的权限包含给定的权限 perm_mask

思路

pgdir 应该理解为给定的页目录基地址,我们需要遍历所有页目录及其对应的所有二级页表项,统计满足条件的二级页表项的个数。另外注意需要判断页目录的有效性。

u_int page_perm_stat(Pde *pgdir, struct Page *pp, u_int perm_mask) {
// count:统计满足条件的页表项的数量
u_int count = 0;
// 遍历页目录项
for (u_long i = 0; i < 1024; i++) {
// 当前页目录项地址 pgdir_entryp = 页目录基地址 + 页内偏移
Pde *pgdir_entryp = pgdir + i;
// 判断当前页目录是否有效
if (*pgdir_entryp & PTE_V) {
// 当前二级页表页基地址 pgtab_entryb = 当前页目录项指向的物理页号对应的虚拟地址
Pte *pgtab_entryb = (Pte *)KADDR(PTE_ADDR(*pgdir_entryp)); //
for (u_long j = 0; j < 1024; j++) {
// 当前页表项地址 pte = 当前页表页基地址 + 页内偏移
Pte *pte = pgtab_entryb + j;
// 判断条件 1 和条件 2
if ((*pte & PTE_V) && (page2pa(pp) == PTE_ADDR(*pte))) {
// 判断条件 3
if (((*pte) & perm_mask) == perm_mask) count ++;
}
}
}
}
return count;
}

extra

extra 挂了,发现只是因为忘了 memcpy复制的时候,需要把 page *p 转换为虚拟地址 page2kva(p)。

题目背景

在理论课程中,我们学习了交换技术。它实现进程在内存与外存之间的交换,因而获得更多的虚拟内存空间。

简单来说,交换空间(swap)是外存上的一块区域,当系统物理内存不足时,内核会将内存中不常访问的数据保存到 swap 上,这样系统就有更多的物理内存为各个进程服务,而当系统需要访问 swap 上存储的内容时,再将 swap 上的数据加载到内存中。这样相当于我们获得了更多的虚拟存储(通过使用一部分外存)。

在本题中,我们会实现一个较为简单的交换机制,使得在没有空闲的物理页面时,可以暂时将正在使用的一页内存换出,同时释放出一页物理页面用于使用。

题目描述

我们建立的交换机制可以分为两部分,“换入”部分,以及“换出”部分。

当我们没有空闲的物理页面时,我们进行“换出”,即申请物理页面时,如果没有可用的页面,我们换出一页正在使用的物理页,供申请者使用。

当我们需要访问某个 kuseg 段的虚拟地址时,我们会检查这个虚拟地址对应的虚拟页是否已被换出到外存,如果是,则我们将其“换入”。

虚拟页被换入的物理页可能与其被换出时不同,但需要保证换入后物理页中的数据以及页表项中的权限位与换出时相同。为此,我们需要在换出时利用外存来保存数据。

题目要求

在本题中,你需要使用物理地址属于 [0x3900000, 0x3910000) 的这 16 个物理页以及外存来实现“交换”。

  • 在本题中我们把这 16 个物理页叫做可交换的物理页。
  • 为了区分这些可交换的物理页,我们建立了一个新的空闲可交换页面链表 page_free_swapable_list

同时,我们将提供部分代码(请参看实验提供代码部分),你需要将其粘贴至 kern/pmap.c 之后,并补全或者实现如下几个函数:

换出部分(struct Page *swap_alloc(Pde *pgdir, u_int asid)

本函数的功能为:

  • 当存在空闲可交换的物理页(page_free_swapable_list 链表非空),只需从 page_free_swapable_list 中取出头部并返回。
  • 若不存在空闲且可交换的物理页(page_free_swapable_list链表为空),需要从 [0x3900000, 0x3910000) 中选取一个物理页,将其换出到外存,并将其返回。
    • 本题不限制页面置换的策略,也就是说,你可以使用任意策略来选取一个物理页,将其换出到外存。

注意

  • 实验提供代码中的 swap_init 函数将 [0x3900000, 0x3910000) 对应的 Page 结构体从 page_free_list 中移除并插入到 page_free_swapable_list 中。因此,swap_alloc 所返回的 Page 对应的物理页,其物理地址必须是处于 [0x3900000, 0x3910000) 中的。
  • 我们保证:在每次测试中,传入的 pgdirasid唯一的。
换入部分(void swap_lookup(Pde *pgdir, u_int asid, u_long va)

本函数的功能为:

  • 当地址空间 asid 中的虚拟地址 va 在页目录 pgdir 中存在映射,但对应物理页面被换出时,调用 swap 函数将其换入
  • 调用 page_lookup 函数,返回 va 对应的页表项

注意

  • 我们保证:在每次测试中,传入的 pgdirasid唯一
  • 传入的 va 不一定是页对齐的。

本函数的实现已经给出,你需要实现该函数中调用的 swap 函数和 is_swapped 函数。

  • int is_swapped(Pde *pgdir, u_long va)
    • 本函数的功能为:当虚拟地址 va 在页目录 pgdir 中存在映射且对应物理页面被换出时,返回非 0 值,否则返回 0
  • void swap(Pde *pgdir, u_int asid, u_long va)
    • 本函数的调用者需保证虚拟地址 va 映射到的物理页已被换出到外存。
    • 本函数的具体功能为:将页目录 pgdir 中虚拟地址 va 映射的物理页从外存中换入内存,并且更新其对应的页表项。换入时需要使用 swap_alloc 来申请一个物理页。其中 asid 参数用于传递给 swap_alloc 函数、更新页表时无效化对应的 TLB 表项。

外存模拟部分

由于还没有学习如何访问外存,我们使用一个数组 swap_disk 来模拟外存(大小为 64 个物理页大小)。

我们使用如下两个接口函数来申请、释放外存空间:

  • u_char *disk_alloc()
    

    - 申请一页大小的外存空间(页对齐),返回值为这片空间的起始地址。外存空间的一页大小为 4096 字节,与内存中的页大小一致。
    - 返回的地址为 kseg0 段的,指向 `swap_disk` 数组内空间的地址。

    - ```
    void disk_free(u_char* da)
    - 释放 `da` 起始的一页外存空间。

设计提示

我们给出一种可行的设计,当然,你也可以略过本节自己进行设计。

当没有空闲的物理页时,我们需要进行换出操作。在本设计中,我们在页表项中增加了一个新的标志位 PTE_SWP(在下发的头文件 swap.h 中已有定义)。

  • PTE_SWP1PTE_V0 时:
    • 对应的虚拟地址映射到的物理内存有效但被换出,实际的内容存在外存上,该页表项的高 20 位为内容在外存上的外存页号。
  • 软件应保证不会出现 PTE_SWP1PTE_V1 的页表项。
  • PTE_SWP0 时,页表项的含义与 Lab2 课下定义的相同。
  • 我们可以通过 da / BY2PG 计算 da 对应的外存页号

当我们希望将某个虚拟地址对应的物理页从外存中换入内存时:

  1. 使用 swap_alloc 申请一个物理页 p
  2. 将外存中以 da 起始的一页内容拷贝到该物理页 p 上(da 为换出时内容在外存上的地址)
  3. 对指定页表中,所有“ PTE_SWP1PTE_V0 且高 20 位为 da 对应的外存页号”的页表项,做如下操作:
    1. PTE_V1
    2. PTE_SWP0
    3. 在高 20 位中填入 p 对应的物理页号
    4. 维持其它权限位不变
    5. 无效化旧 TLB 表项
  4. 使用 disk_free 释放 da 起始的一页外存空间

当我们需要换出一个内存中的物理页至外存时:

  1. [0x3900000, 0x3910000) 的内存空间中,选择一个物理页 p

  2. 使用 disk_alloc 申请一页大小的外存空间,记该外存空间的起始地址为 da

  3. 对指定页表中,所有 PTE_V1 且高 20 位为 p 的物理页号的页表项,做如下操作:

    ​ i. 将 PTE_V0

    ​ ii. 将 PTE_SWP1

    ​ iii. 在高 20 位中填入 da 对应的外存页号

    ​ iv. 维持其它权限位不变

    ​ v. 无效化旧 TLB 表项

  4. 将物理页 p 上的内容拷贝到外存中 da 起始的一页空间上

  5. 释放物理页 p,也就是将其插回 page_free_swapable_list 链表中

任务总结

在提交前,你需要完成以下任务:

  • 换入部分:
    • 完成 is_swapped 函数。
    • 完成 swap 函数,维护 page_free_swapable_list 链表,适时无效化 TLB 中的旧表项。
  • 换出部分:
    • 完成 swap_alloc 函数,维护 page_free_swapable_list 链表,适时无效化 TLB 中的旧表项。

本题不涉及课下代码的修改。

实验提供代码

请将本部分提供代码附加在你的 kern/pmap.c 的尾部,然后开始做题。

#include <swap.h>

struct Page_list page_free_swapable_list;
static u_char *disk_alloc();
static void disk_free(u_char *pdisk);

void swap_init() {
LIST_INIT(&page_free_swapable_list);
for (int i = SWAP_PAGE_BASE; i < SWAP_PAGE_END; i += BY2PG) {
struct Page *pp = pa2page(i);
LIST_REMOVE(pp, pp_link);
LIST_INSERT_HEAD(&page_free_swapable_list, pp, pp_link);
}
}

// Interface for 'Passive Swap Out'
struct Page *swap_alloc(Pde *pgdir, u_int asid) {
// Step 1: Ensure free page
if (LIST_EMPTY(&page_free_swapable_list)) {
/* Your Code Here (1/3) */
}

// Step 2: Get a free page and clear it
struct Page *pp = LIST_FIRST(&page_free_swapable_list);
LIST_REMOVE(pp, pp_link);
memset((void *)page2kva(pp), 0, BY2PG);

return pp;
}

// Interfaces for 'Active Swap In'
static int is_swapped(Pde *pgdir, u_long va) {
/* Your Code Here (2/3) */
}

static void swap(Pde *pgdir, u_int asid, u_long va) {
/* Your Code Here (3/3) */
}

Pte swap_lookup(Pde *pgdir, u_int asid, u_long va) {
// Step 1: If corresponding page is swapped out, swap it in
if (is_swapped(pgdir, va)) {
swap(pgdir, asid, va);
}

// Step 2: Look up page table element.
Pte *ppte;
page_lookup(pgdir, va, &ppte);

// Step 3: Return
return ppte == NULL ? 0 : *ppte;
}

// Disk Simulation (Do not modify)
u_char swap_disk[SWAP_DISK_NPAGE * BY2PG] __attribute__((aligned(BY2PG)));
u_char swap_disk_used[SWAP_DISK_NPAGE];

static u_char *disk_alloc() {
int alloc = 0;
for (;alloc < SWAP_DISK_NPAGE && swap_disk_used[alloc]; alloc++) {
;
}
assert(alloc < SWAP_DISK_NPAGE);
swap_disk_used[alloc] = 1;
return &swap_disk[alloc * BY2PG];
}

static void disk_free(u_char *pdisk) {
int offset = pdisk - swap_disk;
assert(offset % BY2PG == 0);
swap_disk_used[offset / BY2PG] = 0;
}

实现

struct Page *swap_alloc(Pde *pgdir, u_int asid) {
// Step 1: Ensure free page
if (LIST_EMPTY(&page_free_swapable_list)) {
/* Your Code Here (1/3) */
struct Page *p = pa2page(0x3900000);
u_long da = (u_long)disk_alloc();
for (u_long i = 0; i < 1024; i++) {
Pde *pgdir_entryp = pgdir + i;
if (*pgdir_entryp & PTE_V) {
Pte *tmp = (Pte *)KADDR(PTE_ADDR(*pgdir_entryp));
for (u_long j = 0; j < 1024; j++) {
Pte *pte = tmp + j;
if ((*pte & PTE_V) && PTE_ADDR(*pte) == 0x3900000) {
// 维持其他权限位保持不变
*pte = PTE_ADDR(da) | (*pte & 0xfff);
*pte = (*pte & ~PTE_V) | PTE_SWP; // 注意优先级
// tlb_invalidate(asid, va);
tlb_invalidate(asid, (i << 22) + (j << 12));
}
}
}
}
memcpy((void *)da, (void *)page2kva(p), BY2PG);
// 没有清空 Page P 是因为在下一次使用 p 的时候会初始化
LIST_INSERT_HEAD(&page_free_swapable_list, p, pp_link);
}

// Step 2: Get a free page and clear it
struct Page *pp = LIST_FIRST(&page_free_swapable_list);
LIST_REMOVE(pp, pp_link);
memset((void *)page2kva(pp), 0, BY2PG);

return pp;
}

// Interfaces for 'Active Swap In'
static int is_swapped(Pde *pgdir, u_long va) {
/* Your Code Here (2/3) */
Pde *pgdir_entryp = pgdir + PDX(va);
if (!(*pgdir_entryp & PTE_V)) return 0;
Pte *pte = (Pte *)KADDR(PTE_ADDR(*pgdir_entryp)) + PTX(va);
if ((*pte & PTE_SWP) && ~(*pte & PTE_V)) return 1;
return 0;
}

static void swap(Pde *pgdir, u_int asid, u_long va) {
/* Your Code Here (3/3) */
struct Page *p = swap_alloc(pgdir, asid);

u_long da = PTE_ADDR(*((Pte *)KADDR(PTE_ADDR(*(pgdir + PDX(va)))) + PTX(va)));
u_long kva = page2kva(p);
memcpy((void *)kva, (void *)da, BY2PG);
for (u_long i = 0; i < 1024; i++) {
Pde *pgdir_entryp = pgdir + i;
if (*pgdir_entryp & PTE_V) {
Pte *tmp = (Pte *)KADDR(PTE_ADDR(*pgdir_entryp));
for (u_long j = 0; j < 1024; j++) {
Pte *pte = tmp + j;
if (PTE_ADDR(*pte) == da && (*pte & PTE_SWP) && ~(*pte & PTE_V)) {
*pte = PTE_ADDR(page2pa(p)) | (*pte & 0xfff);
*pte = (*pte & ~PTE_SWP) | PTE_V ;
tlb_invalidate(asid, (i << 22) + (j << 12));
}

}
}
}
disk_free((u_char *)da);
}

实验体会

感觉自己目前主要是学习方法上的问题,比较依赖课上视频的讲解(视频内容比较详细,个人没有更多思考)。在 hint帮助下能完成实验,但是对实验的整体架构和一些原理还是比较模糊的概念,导致课上摸索着上机的感觉。给自己的建议是提早写课下(也就是在视频讲解出之前自己先写一遍课下,遇到问题动动脑子,多联系上下文)