深入学习操作系统!详细剖析 MIT 6.S081 课程 Lab 3 : page tables - 1 Speed up system calls

本文细致的剖析了 2021 FALL MIT 6.S081 课程的一项实验, Lab 链接 Lab: page tables (mit.edu)

新人博主,大家的每一次阅读都会激励我继续创作,大家的点赞将会是我继续更新的巨大动力,对文中内容或实验过程中有任何疑问欢迎留言!

Lab 3 将探索页表并对其进行修改,以简化将数据从用户空间复制到内核空间的函数。本实验涉及到一些虚拟地址和物理地址结构,以及两者之间的转换,需要对虚拟内存的知识有基本认识。

xv6 内核中与内存管理相关的一些文件包括:

  • kernel/memlayout.h ,它捕获了内存的布局。
  • kernel/vm.c ,其中包含大多数虚拟内存(VM)代码。
  • kernel/kalloc.c ,它包含分配和释放物理内存的代码。

Speed up system calls (easy)

题目要求

有些操作系统(例如 Linux)通过在用户空间和内核之间的只读区域共享数据,加速特定的系统调用。这消除了在执行这些系统调用时进行内核切换的需要。为了帮助您学习如何建立页表映射,您的第一个任务是在 xv6 中为 getpid() 系统调用实现这种优化。

在创建每个进程时,在虚拟地址USYSCALL 处映射一个只读页面 usyscall(USYSCALL是在 memlayout.h 中定义的虚拟地址)。在此页面的开始处,存储一个struct usyscall(同样在 memlayout.h 中定义),并初始化它以存储当前进程的 PID 。在这个实验中,用户空间提供了ugetpid(),它将自动使用USYSCALL映射。如果在运行pgtbltestugetpid测试用例通过,您将获得此实验的全部分数。

提示

  • 您可以在 kernel/proc.c 中的proc_pagetable()函数中执行映射。
  • 选择允许用户空间仅读取页面的权限位。
  • 您可能会发现mappages()是一个有用的实用工具。
  • 别忘了在allocproc()中分配和初始化页面。
  • 确保在freeproc()中释放页面。

实验过程

首先,按照实验要求,我们先看一下测试代码是怎么实现的, user/pgtbltest.c 调用了 ugetpid() 函数:

C 复制代码
// user/pgtbltest.c
// ...
void ugetpid_test();
void pgaccess_test();

int
main(int argc, char *argv[])
{
  ugetpid_test();
  pgaccess_test();
  printf("pgtbltest: all tests succeeded\n");
  exit(0);
}

// ...

void
ugetpid_test()
{
  int i;

  printf("ugetpid_test starting\n");
  testname = "ugetpid_test";

  for (i = 0; i < 64; i++) {
    int ret = fork();
    if (ret != 0) {
      wait(&ret);
      if (ret != 0)
        exit(1);
      continue;
    }
    if (getpid() != ugetpid())
      err("missmatched PID");
    exit(0);
  }
  printf("ugetpid_test: OK\n");
}
// ...

ugetpid() 函数定义在 user/ulib.c 中:

C 复制代码
//user/ulib.c
//...
int
ugetpid(void)
{
  struct usyscall *u = (struct usyscall *)USYSCALL;
  return u->pid;
}

该函数将一个 struct usyscall 结构体的起始地址设为 USYSCALL ,然后返回这个结构体中保存的 pid

要进一步理解这个函数,首先我们来看 struct usyscall 结构体的定义:

C 复制代码
// kernel/memlayout.h
// ...
// User memory layout.
// Address zero first:
//   text
//   original data and bss
//   fixed-size stack
//   expandable heap
//   ...
//   USYSCALL (shared with kernel)
//   TRAPFRAME (p->trapframe, used by the trampoline)
//   TRAMPOLINE (the same page as in the kernel)
#define TRAPFRAME (TRAMPOLINE - PGSIZE)
#ifdef LAB_PGTBL
#define USYSCALL (TRAPFRAME - PGSIZE)

struct usyscall {
  int pid;  // Process ID
};
#endif

可以看到 struct usyscall 结构体中仅保存了一个 pid ,而 USYSCALL 是一个与用户内存空间布局相关的常量,我们来看这个常量具体是怎么定义的:

C 复制代码
// kernel/memlayout.h
// ...
// map the trampoline page to the highest address,
// in both user and kernel space.
#define TRAMPOLINE (MAXVA - PGSIZE)
// ...
// User memory layout.
// Address zero first:
//   text
//   original data and bss
//   fixed-size stack
//   expandable heap
//   ...
//   USYSCALL (shared with kernel)
//   TRAPFRAME (p->trapframe, used by the trampoline)
//   TRAMPOLINE (the same page as in the kernel)
#define TRAPFRAME (TRAMPOLINE - PGSIZE)
#define USYSCALL (TRAPFRAME - PGSIZE)
C 复制代码
// kernel/riscv.h
// ...
#define PGSIZE 4096 // bytes per page
#define PGSHIFT 12  // bits of offset within a page
// ...
// one beyond the highest possible virtual address.
// MAXVA is actually one bit less than the max allowed by
// Sv39, to avoid having to sign-extend virtual addresses
// that have the high bit set.
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))
// ...

#define MAXVA (1L << (9 + 9 + 9 + 12 - 1)) 用于表示最大虚拟地址( MAXVA ),在这里,1L 是一个长整数常量,表示数值 1 的长整数类型。那么后面的 (9 + 9 + 9 + 12 - 1) 是怎么来的呢?

RISC-V 的寄存器是 64 位的,因此其虚拟内存地址都是 64 位,但是实际上,在我们使用(模拟)的 RISC-V 处理器上,并不是所有的 64 位都被使用了,高 25 位并没有被使用。在剩下的 39 位中,有 27 位被用来当做 index (标记), 12 位被用来当做 offset (页内偏移)。 offset 必须是 12 位,因为一个 page (页面)有 4096 个字节,对于按字节编址的机器,应该用 12 个比特位来表示。

RISC-V 采用三级页表的结构,虚拟地址前 27 位的 index 被拆分为 3 段,每一段的 9 个比特位应相应级别 page directory (页目录表)的索引,page directory 的一个条目称为 PTE(page table entry),一个 page directory 的大小为 512 * 8 = 4096 B ,与一个 page 的大小相同,如下图所示:

这个宏定义最终生成一个二进制数,其中最高位为 1,其余为 0,即 0b100...0,共有 39 位,即为虚拟地址的最大值。按照 kernel/memlayout.h 中的注释,用户地址空间从高位到低位依次是 TRAMPOLINE 、 TRAPFRAME 、 USYSCALL 等等,而这些区域均占用一个页面大小。

每个进程有着自己独立的虚拟地址空间,因此在创建每个进程时,在虚拟地址USYSCALL 处映射的页面的地址应该是属于进程的属性,参考 kernel/proc.c 中的 allocproc 函数:

C 复制代码
// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc*
allocproc(void)
{
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state == UNUSED) {
      goto found;
    } else {
      release(&p->lock);
    }
  }
  return 0;

found:
  p->pid = allocpid();
  p->state = USED;

  // Allocate a trapframe page.
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  // An empty user page table.
  p->pagetable = proc_pagetable(p);
  if(p->pagetable == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  // Set up new context to start executing at forkret,
  // which returns to user space.
  memset(&p->context, 0, sizeof(p->context));
  p->context.ra = (uint64)forkret;
  p->context.sp = p->kstack + PGSIZE;

  return p;
}

allocproc 函数用于分配一个空闲的进程表( struct proc )的实例,以表示一个新的进程。allocproc 函数还会为进程分配必要的物理内存、创建用户页表、设置上下文用于执行 forkret 。可以看到在分配 trapframe 的部分, trapframe 是一个属于进程的属性,照猫画虎,我们为进程也添加一个 usyscall 属性用于保存 usyscall 页面的地址,属于进程的属性应该定义在 kernel/proc.h 中:

c 复制代码
// kernel/proc.h
// ...
// Per-process state
struct proc {
  struct spinlock lock;

  // p->lock must be held when using these:
  enum procstate state;        // Process state
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  int xstate;                  // Exit status to be returned to parent's wait
  int pid;                     // Process ID

  // wait_lock must be held when using this:
  struct proc *parent;         // Parent process

  // these are private to the process, so p->lock need not be held.
  uint64 kstack;               // Virtual address of kernel stack
  uint64 sz;                   // Size of process memory (bytes)
  pagetable_t pagetable;       // User page table
  struct trapframe *trapframe; // data page for trampoline.S
  struct context context;      // swtch() here to run process
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  char name[16];               // Process name (debugging)
  struct usyscall *usyscall;   // data page for sharing data between userspace and the kernel
};

依照提示,我们应该在进程创建时为其分配和初始化 usyscall 页面,因此我们需要修改 kernel/proc.c 中的 allocproc() 函数,按照用户内存空间结构,应该先分配 trapframe ,再分配 usyscall ,因此我们将分配 usyscall 页面的部分添加到分配 trapframe 的后面。分配成功后,将当前进程的 pid 存入 usyscall 页面的开始处:

C 复制代码
// kernel/proc.c
// ...
static struct proc*
allocproc(void)
{
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state == UNUSED) {
      goto found;
    } else {
      release(&p->lock);
    }
  }
  return 0;

found:
  p->pid = allocpid();
  p->state = USED;

  // Allocate a trapframe page.
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  // Allocate a usyscall page.
  if((p->usyscall = (struct usyscall *)kalloc()) == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }
  p->usyscall->pid =  p->pid ; 

  // An empty user page table.
  p->pagetable = proc_pagetable(p);
  if(p->pagetable == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  // Set up new context to start executing at forkret,
  // which returns to user space.
  memset(&p->context, 0, sizeof(p->context));
  p->context.ra = (uint64)forkret;
  p->context.sp = p->kstack + PGSIZE;

  return p;
}
// ...

注意, kalloc 函数分配的是物理内存,我们还需要完成从虚拟地址到物理地址的映射,这一过程需要在 kernel/proc.cproc_pagetable() 函数中,利用 mappages() 函数实现。

proc_pagetable() 函数用于为进程创建一个用户页表。用户页表用于映射用户进程的虚拟地址到物理地址,但在此过程中,还会映射一些特殊页,如 trampolinetrapframe ,我们需要为其增加一个到 usyscall 的映射:

C 复制代码
// kernel/proc.c
// ...
// Create a user page table for a given process,
// with no user memory, but with trampoline pages.
pagetable_t
proc_pagetable(struct proc *p)
{
  pagetable_t pagetable;

  // An empty page table.
  pagetable = uvmcreate();
  if(pagetable == 0)
    return 0;

  // map the trampoline code (for system call return)
  // at the highest user virtual address.
  // only the supervisor uses it, on the way
  // to/from user space, so not PTE_U.
  // int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
  if(mappages(pagetable, TRAMPOLINE, PGSIZE,
              (uint64)trampoline, PTE_R | PTE_X) < 0){
    uvmfree(pagetable, 0);
    return 0;
  }

  // map the trapframe just below TRAMPOLINE, for trampoline.S.
  if(mappages(pagetable, TRAPFRAME, PGSIZE,
              (uint64)(p->trapframe), PTE_R | PTE_W) < 0){
    uvmunmap(pagetable, TRAMPOLINE, 1, 0);
    uvmfree(pagetable, 0);
    return 0;
  }
    
  // map the usyscall page.
  if(mappages(pagetable, USYSCALL, PGSIZE,
              (uint64)(p->usyscall), PTE_U | PTE_R) < 0){
    uvmfree(pagetable, 0);
    return 0;
  }

  return pagetable;
}
// ...

这个函数中的代码逻辑较为复杂,但功能非常重要,有必要深入分析一下。首先我们来看函数定义:

C 复制代码
pagetable_t proc_pagetable(struct proc *p)

函数的参数为一个进程,返回值为 pagetable_t ,在 pagetable_t 定义为指向 uint64 类型的指针,这个指针指向一个页表的起始地址。

C 复制代码
// An empty page table.
pagetable = uvmcreate();
if(pagetable == 0)
    return 0;

这段代码创建了一个空白页表,我们跳转查看 uvmcreate() 函数的定义:

c 复制代码
// kernel/vm.c
// create an empty user page table.
// returns 0 if out of memory.
pagetable_t
uvmcreate()
{
  pagetable_t pagetable;
  pagetable = (pagetable_t) kalloc();
  if(pagetable == 0)
    return 0;
  memset(pagetable, 0, PGSIZE);
  return pagetable;
}

这段函数其实就是用 kalloc() 分配了一个页面大小的内存,然后将这块内存的起始地址赋值给 pagetable ,再用 memset() 函数将这块内存区域初始化。

继续分析 proc_pagetable() 的代码:

C 复制代码
// map the trampoline code (for system call return)
// at the highest user virtual address.
// only the supervisor uses it, on the way
// to/from user space, so not PTE_U.
// int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
if(mappages(pagetable, TRAMPOLINE, PGSIZE,
            (uint64)trampoline, PTE_R | PTE_X) < 0){
    uvmfree(pagetable, 0);
    return 0;
}

这段代码将 trampoline code 映射到用户虚拟内存区的高地址处(与前文用户内存空间结构对应)。这里出现了一个 mappages() 函数,以提示所述,此函数极为重要,我们来看这个函数的定义:

C 复制代码
// kernel/vm.c
// ...
// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page-aligned. Returns 0 on success, -1 if walk() couldn't
// allocate a needed page-table page.
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
  uint64 a, last;
  pte_t *pte;

  if(size == 0)
    panic("mappages: size");
  
  a = PGROUNDDOWN(va);
  last = PGROUNDDOWN(va + size - 1);
  for(;;){
    if((pte = walk(pagetable, a, 1)) == 0)
      return -1;
    if(*pte & PTE_V)
      panic("mappages: remap");
    *pte = PA2PTE(pa) | perm | PTE_V;
    if(a == last)
      break;
    a += PGSIZE;
    pa += PGSIZE;
  }
  return 0;
}
// ...

mappages() 函数可以将虚拟地址范围 [va, va + size) 映射到物理地址范围 [pa, pa + size),并设置相应的访问权限。

mappages() 函数有五个参数,分别为

  • pagetable_t pagetable:表示页表的指针,用于指定将要修改的页表。
  • uint64 va:虚拟地址的起始地址。
  • uint64 size:需要映射的虚拟地址范围的大小。
  • uint64 pa:物理地址的起始地址。
  • int perm:权限标志,用于设置 PTE 的权限。

函数首先对起始虚拟地址 va 向下取整,得到页面对齐的地址 a。然后计算虚拟地址范围的结束地址 last,同样进行向下取整。

进入循环,对每一页进行映射,直到 a 等于 last。下面具体解释循环中的代码:

C 复制代码
if((pte = walk(pagetable, a, 1)) == 0)
	return -1;

PTE 代表 Page Table Entry,即页表项。该 if 语句使用 walk 函数获取虚拟地址 a 对应的页表项指针 pte,并在需要时创建页表页。

c 复制代码
if(*pte & PTE_V)
	panic("mappages: remap");

如果页表项 *pte 已经被映射,说明发生了重复映射,产生了错误。注意, PTE_V 是定义在 kernel/riscv.h 中的宏,是一个权限参数,用于表示该页表项合法,与之类似的权限参数还有:

C 复制代码
// kernel/riscv.h
// ...
#define PTE_V (1L << 0) // valid
#define PTE_R (1L << 1)	// readable
#define PTE_W (1L << 2)	// writable
#define PTE_X (1L << 3)	// Executable
#define PTE_U (1L << 4) // 1 -> user can access
// ...
C 复制代码
*pte = PA2PTE(pa) | perm | PTE_V;

将物理地址 pa 转换成页表项并设置权限,更新页表项。

要将这行代码理解透彻我们又需要参考这张图片了,

如上图所示,要将物理地址转化为页表项,需要将 pa 的高 44 位,即图中的 PPN ,放在页表项的 10 到 53 位中,并将低位的标记位按需求置位。

PA2PTE(pa)这个宏定义在 kernel/riscv.h 中,用于将物理地址 pa 转换成页表项(Page Table Entry,PTE)。:

C 复制代码
// kernel/riscv.h
// ...
// shift a physical address to the right place for a PTE.
#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)
// ...

(uint64)pa 将物理地址 pa 强制类型转换为 64 位无符号整数。 >> 12 将物理地址右移 12 位。在 RISC-V 中,物理页框的大小通常是 4KB 。右移 12 位相当于除以 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 12 2^{12} </math>212 ,从而得到页的索引部分,即图中的 PPN 。 << 10 将上一步的结果又左移 10 位,因为低 10 位保存了一些其他的标志和信息。 perm 是传递给函数的权限参数,表示希望为这个虚拟地址设置的权限,通常使用位掩码来表示不同的权限,在上文中已作介绍,比如,PTE_U 表示用户权限,PTE_W 表示可写权限,PTE_X 表示可执行权限。PTE_V 即将页表项中的有效位置位,表示这个虚拟地址是合法的。

c 复制代码
if(a == last)
    break;
a += PGSIZE;
pa += PGSIZE;

如果 a 已经是当前进程虚拟地址空间的最后一页,则退出循环,否则根据页大小增加虚拟地址 a 和物理地址 pa

当前已完成了分配和映射工作,按照提示,我们还需要在必要的时候释放页面,比如终止进程时,我们需要在 kernel/proc.cfreeproc() 函数中,依葫芦画瓢,将我们分配的 usyscall 和 trapframe 页面做相同处理,添加相关代码:

C 复制代码
// kernel/proc.c
// ...
// free a proc structure and the data hanging from it,
// including user pages.
// p->lock must be held.
static void
freeproc(struct proc *p)
{
  if(p->trapframe)
    kfree((void*)p->trapframe);
  p->trapframe = 0;
  if(p->usyscall)
    kfree((void*)p->usyscall);
  p->usyscall = 0;
  if(p->pagetable)
    proc_freepagetable(p->pagetable, p->sz);
  p->pagetable = 0;
  p->sz = 0;
  p->pid = 0;
  p->parent = 0;
  p->name[0] = 0;
  p->chan = 0;
  p->killed = 0;
  p->xstate = 0;
  p->state = UNUSED;
}

到这里我们完成了实验要求和提示中给出的全部内容,但当我们运行 make qemu 时,发现报错,报错结果如下:

shell 复制代码
xv6 kernel is booting

hart 2 starting
hart 1 starting
panic: freewalk: leaf

我们来查看是什么触发了这个 panic ,通过调试(如何进行调试请移步文章(待更新)),发现 panic 是由 kernel/vm.cfreewalk() 函数发出的,这以函数的代码如下所示:

C 复制代码
// ***kernel/vm.c*** 
// ...
// Recursively free page-table pages.
// All leaf mappings must already have been removed.
void
freewalk(pagetable_t pagetable)
{
  // there are 2^9 = 512 PTEs in a page table.
  for(int i = 0; i < 512; i++){
    pte_t pte = pagetable[i];
    if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
      // this PTE points to a lower-level page table.
      uint64 child = PTE2PA(pte);
      freewalk((pagetable_t)child);
      pagetable[i] = 0;
    } else if(pte & PTE_V){
      panic("freewalk: leaf");
    }
  }
  kfree((void*)pagetable);
}
// ...

freewalk() 函数的作用是递归地释放页表(而不是页面)及其所有子表,其参数是页表的物理地址,而不是虚拟地址。在这个过程中,有效的页表项会被继续追踪,而对于叶子页表项的出现则会导致 panic。我们来看看具体是怎么实现的,该函数诸葛遍历页表项,将页表项复制到 pte 变量中,先来看第一个 if 判断:

C 复制代码
if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0)

这个条件判断包含两个部分:

  • (pte & PTE_V):检查页表项是否有效(被映射到物理内存)。
  • (pte & (PTE_R|PTE_W|PTE_X)) == 0:检查页表项是否没有设置任何权限标志(Read、Write、Execute)。

如果这两个条件都满足,表示当前页表项有效,且没有设置权限标志,即不包含具体的映射关系,这说明指向了一个更低级别的页表(内存的递归结构)。这种情况下, uint64 child = PTE2PA(pte); 负责将页表项转换为物理地址,以获取更低级别页表的物理地址。然后递归调用 freewalk();,释放更低级别的页表及其所有子表,并将当前页表项设置为 0,表示释放当前页表项。

再来看第二个 if 判断:

C 复制代码
if(pte & PTE_V)

这个 if 语句仅检查页表项是否有效。在第一个 if 条件不满足的情况下,如果这个条件满足,说明当前页表项是一个有效的叶子页表项(Leaf Entry),它实际映射到物理内存中的一个页。这在 freewalk 函数中是不应该出现的,因为 freewalk 专门用于释放页表结构,而不是释放实际的页,因此触发 panic

分析函数调用结构(这里的分析过程较为复杂,后续会补上),我们可以发现,还需要在 kernel/proc.cproc_freepagetable() 函数中释放我们之前建立的虚拟地址到物理地址的映射,将这段代码修改为:

c 复制代码
// kernel/proc.c
// ...
// Free a process's page table, and free the
// physical memory it refers to.
void
proc_freepagetable(pagetable_t pagetable, uint64 sz)
{
  uvmunmap(pagetable, TRAMPOLINE, 1, 0);
  uvmunmap(pagetable, TRAPFRAME, 1, 0);
  uvmunmap(pagetable, USYSCALL, 1, 0);
  uvmfree(pagetable, sz);
}
// ...

至此,本 Lab 完成,可以按照实验要求运行测试:

shell 复制代码
$ make qemu

xv6 kernel is booting

hart 1 starting
hart 2 starting
init: starting sh
$ pgtbltest
ugetpid_test starting
ugetpid_test: OK
pgaccess_test starting
pgtbltest: pgaccess_test failed: incorrect access bits set, pid=3

评注

本实验码量很小,大多可以依葫芦画瓢完成,提示也给出了应该添加代码的位置,但是要想完全理解代码的内容,仍需要做大量的工作。同时,提示中给出的需要添加的内容并不完整,面对 panic: freewalk: leaf 这一报错信息,一开始我完全摸不到头脑,这就需要通过调试代码,逐层研读函数之间的调用关系,同时还需要对虚拟内存结构有较深的理解。

相关推荐
罗伯特祥1 分钟前
C调用gnuplot绘图的方法
c语言·plot
嵌入式科普1 小时前
嵌入式科普(24)从SPI和CAN通信重新理解“全双工”
c语言·stm32·can·spi·全双工·ra6m5
lqqjuly3 小时前
特殊的“Undefined Reference xxx“编译错误
c语言·c++
2401_858286113 小时前
115.【C语言】数据结构之排序(希尔排序)
c语言·开发语言·数据结构·算法·排序算法
2401_858286115 小时前
109.【C语言】数据结构之求二叉树的高度
c语言·开发语言·数据结构·算法
KevinRay_5 小时前
Linux系统编程深度解析:C语言实战指南
linux·c语言·mfc·gdb
灵槐梦6 小时前
【速成51单片机】2.点亮LED
c语言·开发语言·经验分享·笔记·单片机·51单片机
LittleStone83976 小时前
C语言实现旋转一个HWC的图像
c语言
stm 学习ing8 小时前
HDLBits训练5
c语言·fpga开发·fpga·eda·hdlbits·pld·hdl语言
就爱学编程9 小时前
重生之我在异世界学编程之C语言小项目:通讯录
c语言·开发语言·数据结构·算法