MIT-OSlab2:进程与线程

part1:预备知识

1、进程管理的数据结构——进程控制块(Process Control Block, PCB)

在XV6中,与进程有关的数据结构定义如下:

struct proc {
  uint sz;                     // Size of process memory (bytes)
  pde_t* pgdir;                // Page table
  char *kstack;                // Bottom of kernel stack for this process
  enum procstate state;        // Process state
  int pid;                     // Process ID
  struct proc *parent;         // Parent process
  struct trapframe *tf;        // Trap frame for current syscall
  struct context *context;     // swtch() here to run process
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  char name[16];               // Process name (debugging)
};

这些定义可分成两类:

  1. 与操作系统管理进程有关的信息:内核栈kstack,进程的状态state,进程的pid,父进程parent,进程的中断帧tf,进程的上下文context,与sleepkill有关的chankilled变量。
  2. 进程本身运行所需要的全部环境:虚拟内存信息szpgdir,打开的文件ofile和当前目录cwd

某些信息详细介绍:

  1. pgdir:用户程序使用的是虚拟地址,虚拟地址被映射到物理地址,并且这样的映射是以页(4K)为单位的,xv6让每个进程都有独立的页表结构来记录页面在内存中对应的物理块号。 当进程切换时,页表也会随之发生切换,因此对于每个进程来说,似乎是独占整个大小为4G的虚拟地址空间的。
  2. kstack:内核栈的栈底。每个进程都有一个与用户栈分开的内核栈,该栈就是运行时的使用的栈;而对于用户进程,该栈是发生特权级改变进入内核时保存环境用的栈。 之所以内核栈要和用户栈分开也是防止用户程序通过修改栈内容来突破内核安全保护。
  3. tf:中断帧的指针,总是指向内核栈的某个位置。当进程从用户空间跳到内核空间时,中断帧记录了进程在用户态时的状态。当内核需要回到用户空间时,可从中断帧中弹出各寄存器值来恢复进程。
2. 进程表ptable

在操作系统中,所有的进程信息struct proc都存储在ptable中,ptable的定义如下

struct {
  struct spinlock lock;
  struct proc proc[NPROC];
} ptable;

param.h#define NPROC 64限制了 XV6最多只允许同时存在64个进程 。

3、用户态和内核态:
  • 特权级:inter x86架构的cpu对执行权限分成四个级别,0-3级,0级特权级最高,3级特权级最低。当一个进程在执行用户自己的代码时处于用户态,此时特权级最低,为3级,大部分用户直接面对的程序都是运行在用户态;当一个进程因为系统调用陷入内核代码中执行时处于内核态,此时特权级最高,为0级。3级状态不能访问0级的地址空间,包括代码和数据。

  • 状态切换:当一个程序需要操作系统帮助完成一些用户态自己没有特权和能力完成的操作时就会切换到内核态。

    用户态切换到内核态的3种方式:

    (1)系统调用

    这是用户态进程主动要求切换到内核态的一种方式。用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。例如fork()就是执行了一个创建新进程的系统调用。系统调用的机制是使用了操作系统为用户特别开放的一个中断来实现,如Linux的int 80h、xv6的int 64。

    (2)异常

    当cpu在执行运行在用户态下的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常。

    (3)外围设备的中断

    当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令是用户态下的程序,那么转换的过程自然就会是由用户态到内核态的切换。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。

    这三种方式是系统在运行时由用户态切换到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。从触发方式上看,切换方式都不一样,但从最终实际完成由用户态到内核态的切换操作来看,都相当于执行了一个中断响应的过程。后面会详细解释系统调用如何通过中断机制实现。

4、进程的状态

XV6操作系统中的进程状态是六模型:

enum procstate { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

状态之间的转换关系如下:

1571108088779
5、xv6的独特内核切换

一般操作系统中进程的切换是:A的用户态->A的内核态->B的内核态->B的用户态。但是xv6模拟出了一个内核线程——per-cpu调度器线程,当需要切换进程时,在这个线程上会调用scheduler()在切换中起到调度的作用。所以实际上在xv6中,进程切换的中间两步要换成三步:旧内核线程->per-cpu调度器线程->新的内核线程。如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Dp7TXwNo-1572157762066)(C:\Users\zcr\AppData\Roaming\Typora\typora-user-images\1571239670419.png)]

6、进程的虚拟地址布局:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y3Vt2FW8-1572157762067)(C:\Users\zcr\AppData\Roaming\Typora\typora-user-images\1571109484080.png)]

  • 用户内存位于0 ~ 0x7FFFFFFF

  • 内核区域

    • BIOS被映射到 0x80000000 ~ 0x800FFFFF
    • 内核指令和数据映射到 0x80100000 ~ 0xFFFFFFFF。之所以进程的页表要包含内核代码的映射,是因为当进程从用户态进入内核态时,内核代码在同一个进程的内核区域运行,不需要切换页表。
7、内核页表kpgdir

内核页表是per-cpu调度器线程的页表。

如上一条,对于每一个用户进程映射的线性地址包括两部分:用户态与内核态。其中,内核态地址对应的相关页表项,对于所有进程来说都是相同的(因为内核空间对所有进程来说都是共享的),而这部分页表内容其实就来源于kpgdir,即每个进程的“进程页表”中内核态地址相关的页表项都是kpgdir的一个拷贝。进程使用的是自己的页表,当要退出当前进程或者切换到其他进程时,当前页表会被切换为per-cpu调度器线程的页表——kpgdir,所以只有当进程切换时kpgdir会被使用。

8、x86的分页映射

x86使用了两级表。32位地址的高20位的转换被分成两步来进行,每步使用其中的10bit。

第一级表称为页目录(page directory)。它被存放在1页4K大小的页面中,每个表项32bit,指向对应的二级表。线性地址的最高10位(位31~22)用作页目录中的索引值,取表项高20位为对应二级页表基址。

第二级表称为页表(page table),它的大小也是4k。每个表项含有相关页面的20位物理基址。二级页表使用线性地址中间10位(位21~12)作为表项索引值,获取表项的高20位作为最终物理页表的基址,与线性地址中的低12位(页内偏移)组合在一起就得到了最终的最终物理地址。

上述过程如下图所示,其中CR3寄存器指定页目录表的基地址:

img

Part2:第一个进程的创建

1、main函数

在jos中entry结束后会跳到i386_init()进入c code,而xv6是跳到main()由此开始运行c code:

#在entry.S最后
mov $main, %eax
jmp *%eax 

main函数定义在main.c中,调用了很多初始化函数。下面只介绍和进程创建相关的重要的函数:

1)kvmalloc

作用:为kernel建立内核页表(setupkvm),然后再采用该页表(switchkvm)。

void
kvmalloc(void)
{
    kpgdir = setupkvm();
    switchkvm();
}
  1. setupkvm:

首先,它会分配一页内存作为页目录表(PDE)来放置页目录:

if((pgdir = (pde_t*)kalloc()) == 0)

return 0;

然后调用 mappages 来建立内核需要的映射:

for(k = kmap; k < &kmap[NELEM(kmap)]; k++)
	if(mappages(pgdir, k−>virt, k−>phys_end − k−>phys_start,(uint)k−>phys_start, k−>perm) < 0)
        return 0;

具体映射项可以在 kmap数组中找到:

static struct kmap {
void *virt;
uint phys_start;
uint phys_end;
int perm;
} kmap[] = {
	{ (void*)KERNBASE, 0, EXTMEM, PTE_W}, // I/O space
	{ (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0}, // kern text+rodata
	{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
	{ (void*)DEVSPACE, DEVSPACE, 0, PTE_W}, // more devices
};

这里的映射包括内核的指令和数据,PHYSTOP 以下的物理内存,以及 I/O 设备所占的内存。注:setupkvm 不会建立任何用户内存的映射。

mappages做的工作是在页表中建立一段虚拟内存到一段物理内存的映射。它是在页的级别,即一页一页地建立映射的。对于每一个待映射虚拟地址,mappages 调用 walkpgdir 来找到该地址对应的二级页表中的PTE 地址。然后更新该 PTE 以保存对应物理地址、许可级别(PTE_W 和/或 PTE_U)以及用标记该 PTE 是否是有效的 PTE_P 位。 代码如下:

mappages(pde_t *pgdir, void *va, uint size, uint pa, int perm)
{
	char *a, *last;
	pte_t *pte;
    a = (char*)PGROUNDDOWN((uint)va);
    last = (char*)PGROUNDDOWN(((uint)va) + size − 1);
    for(;;){
    if((pte = walkpgdir(pgdir, a, 1)) == 0)
    return −1;
    if(*pte & PTE_P)
    panic("remap");
    *pte = pa | perm | PTE_P;
    if(a == last)
    break;
    a += PGSIZE;
    pa += PGSIZE;
    }
    return 0;
}

mappages中关键的就是walkpgdir函数,该函数模仿前面介绍的 x86 的分页硬件为一个虚拟地址寻找 PTE 的过程。walkpgdir 通过虚拟地址的前 10 位来找到在页目录中的对应条目地址:

pde = &pgdir[PDX(va)];

如果该条目不存在,说明要找的二级页表页尚未分配,如果 alloc 参数被设置了,walkpgdir 会分配页表给这个条目,将这个被分配页用0填充,并把其物理基址放到页目录中:

if(!alloc || (pgtab = (pte_t*)kalloc()) == 0)
	return 0;
// Make sure all those PTE_P bits are zero.
memset(pgtab, 0, PGSIZE);
*pde = V2P(pgtab) | PTE_P | PTE_W | PTE_U;

然后用虚拟地址的接下来 10 位来找到其在这个二级页表中的偏移并返回:

return &pgtab[PTX(va)];

至此,内核页表已经建好,此页表项对于今后所有的用户进程都是通用的。

  1. 调用switchkvm来更新cr3寄存器( CR3是页目录基址寄存器,保存页目录表的物理地址 ),开始采用此页表。
void switchkvm(void){    
    lcr3(V2P(kpgdir)); // switch to the kenel page table}
}

2)xxinit

调用各种init建立内存页管理系统

3)userinit:初始化第一个用户进程:
  1. allocproc 初始化用于进程内核线程执行的 PCB中部分数据结构 :
p = allocproc();

【alloc_proc】

1、遍历进程表proc ,找到一个标记为 UNUSED的槽位。若找到,将其状态设置为 EMBRYO,并设置进程pid;否则返回0:

在全局变量ptable中寻找UNUSED的进程结构,未找到返回0:

for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
    if(p->state == UNUSED)
      goto found;
return 0;

found:
  p->state = EMBRYO;
  p->pid = nextpid++;

2、给进程p分配内核栈,如果分配失败了,把这个位置的结构的状态恢复为 UNUSED ,并返回0以标记失败;若成功,p->kstack则代表内核栈栈底,KSTACKSIZE是分配给内核栈的大小,为4k,sp代表内核栈栈顶:

if((p->kstack = kalloc()) == 0){
    p->state = UNUSED;
    return 0;
}
sp = p->kstack + KSTACKSIZE;

3、设置trapframe(中断发生时保存信息的地方)的栈顶:

 sp -= sizeof *p->tf;
 p->tf = (struct trapframe*)sp;

【trapframe】

trapframe的定义:

struct trapframe {
  // registers as pushed by pusha
  uint edi;
  uint esi;
  uint ebp;
  uint oesp;      // useless & ignored
  uint ebx;
  uint edx;
  uint ecx;
  uint eax;

  // rest of trap frame
  ushort gs;
  ushort padding1;
  ushort fs;
  ushort padding2;
  ushort es;
  ushort padding3;
  ushort ds;
  ushort padding4;
  uint trapno;

  // below here defined by x86 hardware
  uint err;
  uint eip;
  ushort cs;
  ushort padding5;
  uint eflags;

  // below here only when crossing rings, such as from user to kernel
  uint esp;
  ushort ss;
  ushort padding6;
};

这个结构体中,依次储存了——

  • 目标寄存器
  • gs, fs, es, ds 段寄存器
  • trap_no, err :中断信息
  • eip, cs :trap返回后的目的地址
  • esp, ss :如果是从用户态进入内核态时,因为要使用内核栈,所以会保存这两个指针表示用户栈

4、保存trapret:

sp -= 4;
*(uint*)sp = (uint)trapret;

【为什么trapret】

.globl trapret
trapret:
  popal
  popl %gs
  popl %fs
  popl %es
  popl %ds
  addl $0x8, %esp  # trapno and errcode
  iret

popal根据注释是从栈中恢复普通寄存器,然后恢复段寄存器,最后iret指令恢复eip、csEFLAGS(可能还有esp、ss)。所以trapret是把trapframe里的寄存器依次弹出。

从用户态到内核态有三种方式:外部硬件中断、异常、系统调用。但在xv6中,从用户态到内核态只能通过中断机制实现。每当进程运行中要将控制权交到内核时,硬件就会在进程的内核栈的trapframe上保存用户态下的寄存器。当中断返回时,trapframe会自动弹栈恢复寄存器。现在是出于内核态,但是并不是因为中断进入的,所以这里手动放入trapret以弹栈,模拟成是通过中断进入内核的一样。

5、设置context( 进程切换需要保存的上下文 )的顶部指针, 设置 context->eipforkret:

sp -= sizeof *p->context;
p->context = (struct context*)sp;
memset(p->context, 0, sizeof *p->context);
p->context->eip = (uint)forkret;

proc.hcontext的定义:

struct context {
  uint edi;
  uint esi;
  uint ebx;
  uint ebp;
  uint eip;
};

储存了部分用户寄存器的值。 为什么要保存这些寄存器?

首先要明确,要用到trapframe的只有两种情况:中断;进程切换。

  • 中断(后面的alltraps部分进行了介绍)会形成trapframe,然后会call trap处理中断,在call trap的时候会自动把这些寄存器压栈形成context(因为这些几个寄存器是被调用者保护寄存器:被调用者会改变这几个寄存器的值,所以先把调用者的这几个寄存器的值保存起来,在返回时恢复)。
  • 进程切换:步骤是A用户线程->A内核线程->per-cpu调度器线程->B内核线程->B用户线程。在用户线程陷入内核时会生成trapframe,在内核线程切换到调度器线程时会保存context,为了下次调度器调用swtch的时候能够恢复该线程上下文。也就是说一个被阻塞的内核线程(除了调度器线程)都得有trapframecontext

当前进程之后要被scheduler调度,scheduler会调用swtch切换context。由于swtch函数(后面mpmainswtch进行了介绍)结束后将返回到context.eip位置。alloc_procp->context->eip = (uint)forkret,所以会进入forkret函数(定义在proc.c中)。forkret 返回后进入trapret()继续执行 。(在后面关于wait()的介绍中解释了为什么是forkret)

根据上面的分析,可以画出此时内核栈结构:

                  /   +---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
                  |   | ss            |                           
                  |   +---------------+                           
                  |   | esp           |                           
                  |   +---------------+                           
                  |   | eflags        |                           
                  |   +---------------+                           
                  |   | cs            |                           
                  |   +---------------+                           
                  |   | eip           | 
                  |   +---------------+    
                  |   | err           |  
                  |   +---------------+  
                  |   | trapno        |  
                  |   +---------------+                       
                  |   | ds            |                           
                  |   +---------------+                           
                  |   | es            |                           
                  |   +---------------+                           
                  |   | fs            |                           
 struct trapframe |   +---------------+                           
                  |   | gs            |                           
                  |   +---------------+   
                  |   | eax           |   
                  |   +---------------+   
                  |   | ecx           |   
                  |   +---------------+   
                  |   | edx           |   
                  |   +---------------+   
                  |   | ebx           |   
                  |   +---------------+                        
                  |   | oesp          |   
                  |   +---------------+   
                  |   | ebp           |   
                  |   +---------------+   
                  |   | esi           |   
                  |   +---------------+   
                  |   | edi           |   
                  \   +---------------+ <-- p->tf                 
                      | trapret       |                           
                  /   +---------------+ <-- forkret will return to
                  |   | eip(=forkret) | <-- return addr           
                  |   +---------------+                           
                  |   | ebp           |                           
                  |   +---------------+                           
   struct context |   | ebx           |                           
                  |   +---------------+                           
                  |   | esi           |                           
                  |   +---------------+                           
                  |   | edi           |                           
                  \   +-------+-------+ <-- p->context            
                      |       |       |                           
                      |       v       |                           
                      |     empty     |                           
                      +---------------+ <-- p->kstack           

  1. 然后初始化这个进程p自己的页表(struct proc中的pgdir)。首先,userinit()会使用setupkvm()生成与前面建立的内核页表一模一样的内核页表,然后使用inituvm()生成第一个用户内存页并映射到虚拟地址0x0:
if((p->pgdir = setupkvm()) == 0)
    panic("userinit: out of memory?");
  inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size);

​ 关于inituvm

1)调用`kalloc`分配一个物理页*mem*:
mem = kalloc();

2)调用`mappages`将虚拟地址*0*-PGSIZE映射到该物理页:
mappages(pgdir, 0, PGSIZE, V2P(mem), PTE_W|PTE_U);

3)将`initcode`(用户进程初始化代码,后面会用到)拷贝至该物理页 :
memmove(mem, init, sz);

  1. 设置PCB:

    其中p->tf->eip = 0;表示从虚拟0地址(即initcode.S)开始执行;p->tf->esp = PGSIZE; 表示用户堆栈的空间最大只有4K ; p->tf->cs = (SEG_UCODE << 3) | DPL_USER;会使段选择器指向段SEG_UCODE并处于特权级DPL_USER(即在用户模式而非内核模式);对于数据段的处理p->tf->ds = (SEG_UDATA << 3) | DPL_USER同理;将状态设为RUNNABLE:p->state = RUNNABLE;

4)mpmain

关键:调用scheduler()开始调度准备切换

scheduler();     // start running processes

关于scheduler()

scheduler有一个外循环是不断进行的。外循环开始的时候会开起中断 sti(), 防止所有进程都在等待IO时,由于关闭中断而产生的死锁。

外循环中有一个内循环:

1.每一次内循环前都要获取ptable.lock,然后遍历进程表寻找runnable状态的进程

acquire(&ptable.lock);
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
      if(p->state != RUNNABLE)
        continue;

scheduler函数是个死循环,不停地通过内循环遍历所有进程状态,由于遍历的过程中可能会改变进程状态(xv6支持多cpu,共享ptable),所以,一次遍历的内循环外需要加锁保护。并且内循环执行了一遍后需要释放ptable.lock,这是为了防止当前没有进程待执行的时候,由于当前CPU一直占有锁,其他CPU上的进程可能会执行wakeup改变进程状态却没办法获取锁导致的死锁。

2.调用switchuvm,该函数负责切换相关进程独有的数据结构,其中包括TSS相关的操作,然后将该进程的页表载入cr3寄存器,完成设置进程相关的虚拟地址空间。

【关于TSS】:

  • 不管是用户程序还是内核调用了系统调用,CPU 都会离开原来的指令,跳转到系统调用的代码执行。当系统调用完成后,会回到系统调用处继续原来的指令。所以需要保存原来程序的上下文,当系统调用结束后,能恢复原来的运行环境,继续执行。

  • x86对于每一个 task, 都会有一个 TSS 来保存该 task 运行环境,当 task 发生切换时,TSS 负责保存/恢复上下文,通过TR寄存器指向的TSS改变而切换进程。

  • xv6可以运行多cpu的计算机上,使用struct cpu结构体来记录每个CPU状态 ,并将所有cpu保存在struct cpu cpus[NCPU]中,通过mycpu()获取当前cpu。 proc.h:struct cpu中有两个字段,一个是 ts,一个是 gdt。

struct cpu {
  ...
  struct taskstate ts;         // Used by x86 to find stack for interrupt
  struct segdesc gdt[NSEGS];   // x86 global descriptor table
  ...
 }

这个ts字段就是TSS,但是xv6 并没有采用每个进程一个TSS,更换TSS来切换进程。xv6是一个cpu一个TSS,并且不完全依靠TSS保存每个进程切换时的寄存器副本,而是将这些寄存器副本保存在各个进程自己的内核栈中,因此TSS中的绝大部分内容就失去了原来的意义。那么,当进行任务切换时,怎样自动更换堆栈?只更换TSS中的ss0和esp0(指向内核栈),而不更换TSS本身。这是因为,改变TSS中ss0和esp0的开销比通过装入TR以更换一个TSS要小得多。所以每个进程运行之前会通过switchuvm更新ts字段(实际上只是更新ts的ss0和esp0)。对应的具体代码:

1)安装 ts描述符到 gdt中:

mycpu()->gdt[SEG_TSS] = SEG16(STS_T32A, &mycpu()->ts, sizeof(mycpu()->ts)-1, 0);

2)设置 ts 的内核栈:

mycpu()->ts.esp0 = (uint)p->kstack + KSTACKSIZE;

【/tss】

当TSS设置完之后,通知硬件开始使用目标进程的页表:

lcr3(V2P(p->pgdir));  

3.将目标进程状态改为running:

p->state = RUNNING;

4.调用swtch,作用是切换上下文到目标进程的内核线程中。如前所述,xv6中线程的切换步骤是是旧内核线程->一个特殊的per-cpu调度器线程->新的内核线程。此时调用scheduler的就是这个per-cpu调度器线程。所以此处调用swtch是实现从per-cpu调度器线程上下文切换到新进程的内核线程的上下文。

swtch(struct context **old, struct context *new)

旧进程调用swich时首先保存自己的context,然后将栈指针指向新的context,弹出恢复寄存器,当switch返回时返回的则是新的进程,也就是说,旧进程总是被阻塞在swtch处,只有当进程再次被调度时才能恢复到返回swich的“状态”。

swtch具体:

1)把struct context **old赋给%eax,把struct context *new赋给%edx

movl 4(%esp), %eax

movl 8(%esp), %edx

2)保存old process的寄存器

#Save old callee-saved registers

 pushl %ebp
 pushl %ebx
 pushl %esi
 pushl %edi

3)栈的切换:

#Switch stacks

 movl %esp, (%eax)
 movl %edx, %esp

第一句时把当前%esp的值赋给(%eax),即(**old),即*old也就是old process对应的proc结构体的context字段,指向的是内核栈中context的起始地址,布局如下所示:

                     /   +---------------+ 
                     |   | eip(return addr)|            
                     |   +---------------+                           
                     |   | ebp           |                           
       old process's |   +---------------+                                                  struct context|   | ebx           |                           
                     |   +---------------+                           
                     |   | esi           |                           
                     |   +---------------+                           
                     |   | edi           |                           
                     \   +-------+-------+ <-- p->context            

第二句是把%edx赋给%esp,又因为现在%edxstruct context *new,所以现在%esp就是new process对应的proc结构体的context字段。

4)切换到新线程的栈后,就可以恢复新线程的寄存器。

#Load new callee-saved registers

 popl %edi
 popl %esi
 popl %ebx
 popl %ebp
 ret

最后的ret指令从栈中弹出目标进程的%eip,结束context切换工作。现在处理器就运行在新进程的内核栈上了。

因为在前面alloc_procp->context->eip = (uint)forkret,所以ret使得执行proc.c:forkret的代码。forkret 启动了一个log就返回了,当forkret返回弹栈时,根据allocproc的栈的分配可知接下来是trapret()的地址,于是返回到trapret()继续执行。trapret用弹出指令从trapframe中恢复寄存器,就像swtchcontext的操作一样:

.globl trapret
trapret:
  popal
  popl %gs
  popl %fs
  popl %es
  popl %ds
  addl $0x8, %esp  # trapno and errcode
  iret

popal根据注释是从栈中恢复普通寄存器,然后恢复段寄存器,最后iret指令恢复eipcsEFLAGS(可能还有espss)。其中%eip即进入用户态之后执行的程序的入口。而在userinit()p->tf->eip = 0;并且inituvm()initcode.S放到了虚拟地址0处,所以接下来回进入initcode.S

【如何理解forkret和trapret的设置?】

cpu最初是内核态,要创建第一个用户进程,需要进入用户态。由于XV6系统只允许中断返回这一种从内核态进入用户态的方式,因此需要通过allocproc()模拟中断调用的栈结构,仿佛是从一次真正的中断里返回进程一样。

【什么时候会需要保存esp和ss?】

当从用户态进入内核态时,cpu会做下面一些事:如果cpu在用户模式下运行,它会从tss(mycpu()->ts)中加载 %esp(ts->ts_esp0)%ss(ts->ts_ss0)并压入栈中;如果cpu在内核模式下运行,上面的事件就不会发生。cpu接下来会把 %eflags,%cs,%eip 压栈~~(此处可查看栈)~~。这样做的目的是为了中断返回时可以恢复进程状态。

2、SYSCALL

接下来会通过中断从用户态进入内核态,然后执行exec系统调用,通过这个系统调用来加载进一个真正的用户进程。

1)initcode.S
# exec(init, argv)

.globl start
start:
  pushl $argv
  pushl $init
  pushl $0  // where caller pc would be
  movl $SYS_exec, %eax
  int $T_SYSCALL

# char init[] = "/init\0";

init:
  .string "/init\0"

# char *argv[] = { init, 0 };

.p2align 2
argv:
  .long init
  .long 0

SYS_execsyscall.h中被定义为7,syscall.c中有一个syscalls函数指针数组:static int (*syscalls[])(void),每一项指向一个系统调用函数,这些函数在syscall.h中被define成不同的序号。当系统调用发生时,需要有个参数来通知内核要调用的是哪一个函数,这里通过movl $SYS_exec, %eaxsys_exec这个系统调用函数的序号存到了%eax中。

系统调用是内核模式,所以要从用户态“陷入”内核态“,在xv6中是通过触发中断。在 x86 中,中断处理程序的入口在中断描述符表(IDT)中被定义,这个表有256个表项,每一个都提供了相应的 %cs 和 %eip指向中断处理程序,并且通过int n指令来选用哪个中断处理程序,n 就是 IDT 的索引。n是由操作系统的开发者指定的,比如Linux用的是int 0x80,而XV6选用的是int 64:在traps.hT_SYSCALL被定义成64。

所以上面的代码其实可以翻译成:

char init[] = "/init\0";
char *argv[] = { init, 0 };
start:
Push argv #exec的参数
Push init
Push 0  #应该是argv的结束符
%eax = 7 #exec的系统调用号
int 64 #触发硬件中断,进入内核执行exec系统调用执行init程序

前面说过,当从用户模式向内核模式转换后,要使用区别于用户栈的内核栈。xv6 会使得在“内陷”(用户态->内核态)发生的时候进行一个栈切换:让硬件从mycpu()->ts中读出内核的 %esp%ss 的值。这两个值是在进程开始前switchuvm存入ts中的:

mycpu()->ts.ss0 = SEG_KDATA << 3;
mycpu()->ts.esp0 = (uint)p->kstack + KSTACKSIZE;

然后把进程的用户栈的 %ss%esp 压入内核栈中,继续把 %eflags,%cs,%eip 压栈(此处可查看栈)。

【判断是否需要保存 %esp%ss 的方式:特权级保护】

预备知识说到,x86共有4个特权级。但是一般只会用到2个特权级,内核特权级为0,用户进程特权级为3。

中断描述符有3个相关的特权级

  1. 当前代码的特权级,就是当前 csCPL
  2. 中断处理过程所在的目标代码段的特权级,中断门中存放目标代码段描述符的选择子,通过选择子,可以从 GDT/LDT 中找到目标代码段的 DPL
  3. 中断门描述符的 DPL

判断规则如下:

  1. 当前的 CPL < 目标代码段描述符的特权级:不允许将控制转移到中断处理程序中

  2. 目标代码段描述符的特权级 < 当前的 CPL:栈切换并保存%ss%esp

【特权级保护/】

2) vector64

int 64使得cpu保存了上述寄存器后,会进入vector64

vectors.pl 脚本生成 vectors.S,定义了每一个 interrupt handler,并在最后定义了数组 uint vectors[]存放interrupt handler,共256项。之所以叫interrupt handler是因为tvinit:SETGATE(idt[i], 0, SEG_KCODE<<3, vectors[i], 0);将这个vectors[]与中断描述符表struct gatedesc idt[256]一 一对应,而中断描述符表存储着中断程序入口。

每个 interrupt handler 主要做两件事情:

 vector64:
    pushl $0
    pushl $64
    jmp alltraps

  • push 0(errnum)和中断号(trapnum)

  • 跳转到 alltraps 执行

所以int64将cpu导向vector64,压入参数后,跳到alltraps

3)alltraps

定义在trapasm.s

alltraps继续压入寄存器保存现场,得到trapframe结构体(如alloc_proctrapframe图所示):

.globl alltraps
alltraps:

  # Build trap frame.

  pushl %ds
  pushl %es
  pushl %fs
  pushl %gs
  pushal

重设段寄存器从而进入内核态:

movw $(SEG_KDATA<<3), %ax
movw %ax, %ds
movw %ax, %es

压入%esp作为trap的参数,然后调用C函数trap处理中断:

pushl %esp
call trap
addl $4, %esp

注:此时的trapframe的下一条是%esp作为参数,与alloc_proctrapret目的不同,后者是因为不是真正的要从中断返回,所以需要手动加入trapret弹栈。

trap结束后trapret自动弹出寄存器恢复中断前现场:

.globl trapret
trapret:
  popal
  popl %gs
  popl %fs
  popl %es
  popl %ds
  addl $0x8, %esp  # trapno and errcode
  iret

4)trap

由于trap是c函数,所以调用它时会自动在栈上压入返回地址eip和部分寄存值构成context结构(这些寄存器是calleesave register),在trap执行ret指令返回之前会自动弹出context恢复栈的调用前状态。

注:这里的contextalloc_proccontext结构一样,但是弹出方法不一样。前者的形成是因为调用C函数,当C函数返回时会自动弹出,后者的形成是为了完成scheduler的进程调度,弹出则是靠scheduler调用swtch,在swtch中手动弹出context结构来模拟函数返回的情形:mpmain->scheduler->swtch.S。这样的区别和上一条关于trapframe如何弹栈的注释类似。

trap函数主要根据trapframe中的trapno来确定到底是哪种原因导致中断的发生,如果是系统调用,则通过调用syscall函数负责具体的系统调用处理:

 if(tf->trapno == T_SYSCALL){
    if(proc->killed)
      exit();
    proc->tf = tf;
    syscall();
    if(proc->killed)
      exit();
    return;
  }

注意到这里更新了进程的trapframe的地址,这样做的原因是trapframe的大小由于可能硬件未压入ssesp导致不一致,而任务栈ts需要指向内核栈所在页的最高地址处:

cpu->ts.esp0 = (uint)proc->kstack + KSTACKSIZE;

但是,proc->tfalloc_proc中的赋值是按照大小包括ssesp来的:

p->tf = (struct trapframe*)sp;

所以在这里更新了tf的地址。

【系统调用实现机制】

syscall通过trapframe中的eax来确定系统调用号:

num = proc->tf->eax;

eax的值之所以存储着系统调用号是因为在initcode.Smovl $SYS_exec, %eax并且eax在alltraps中被压入trapframe

然后通过系统调用号调用具体的系统调用处理函数,并把返回给trapframe中的eax,这样trapret时便能根据eax得到系统调用的返回值:

if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    proc->tf->eax = syscalls[num]();

syscalls[]这个函数指针数组中,7对应的函数是sys_execsys_exec根据trapframe中的esp来找到陷入内核前的用户栈,从而获得当时压入的参数(这个例子中是“init”和char *argv),然后调用exec

5)exec

exec的实现在exec.c中。exec会从磁盘里加载一个ELF文件(init程序,第一个用户进程)取代调用进程的内容。ELF文件中包含了所有代码段和数据段的信息,并且描述了这些段应该被加载到的虚拟地址。

然后,exec会分配两个虚拟内存页,第一个页设置为不可访问,第二个页用作用户栈。最后会重设进程的页表,中断帧的eipesp,意味着trapframe弹栈后不会恢复到中断前,而是开始使用新的代码段,数据段和堆栈。

 oldpgdir = curproc->pgdir;
 curproc->pgdir = pgdir;
 curproc->sz = sz;
 curproc->tf->eip = elf.entry;  // main
 curproc->tf->esp = sp;
 switchuvm(curproc);

所以scheduler通过swtch进入initcode,然后initcode陷入内核,然后系统调用exec,本来scheduler后面还有调用switchkvm()但是不会再返回到此处了。

经过exec, 第一个进程init即将正式运行。为什么要从内核态返回用户态执行initcode.S,然后在initcode.S中又陷入内核通过系统调用创建第一个用户进程?

当内核初始化结束后,只有init唯一 一个进程,让它执行initcode.S其实只是起到过渡的作用,主要是真正的init程序的代码和数据太庞大,无法塞到内核中。所以就用initcode.S这个过渡代码发起了exec系统调用,在磁盘里加载真正的init程序。

Part3:几个函数的理解

1、fork()

功能: 除了第一个进程,其他进程都是由父进程调用fork()创建的。

  1. 调用myproc函数获取当前的进程:

    struct proc *curproc = myproc();
    
    

    myproc()通过mycpu()获取当前cpu,进而获得当前cpu->proc:

    struct proc*
    myproc(void) {
      struct cpu *c;
      struct proc *p;
      pushcli();
      c = mycpu();
      p = c->proc;
      popcli();
      return p;
    }
    
    
  2. 调用allocproc()函数获得并初始化一个进程控制块struct proc

    if((np = allocproc()) == 0){
        return -1;
      }
    
    

    根据前面的分析可知,allocproc()会对np的内核栈进行初始化,在内核栈里设置trapframe和context,并用0填充。

  3. 调用copyuvm()函数复制父进程的虚拟内存结构:

    np->pgdir = copyuvm(curproc->pgdir, curproc->sz)
    
    

    【copyuvm】:

    先调用setupkvm建立内核代码段与数据段在虚拟内存高地址处的映射:

    d = setupkvm();
    
    

    开始对父进程的用户内存一页一页的进行拷贝:

    for(i = 0; i < sz; i += PGSIZE){
        if((pte = walkpgdir(pgdir, (void *) i, 0)) == 0)  #pte是二级页表的表项
          panic("copyuvm: pte should exist");
        if(!(*pte & PTE_P))
          panic("copyuvm: page not present");
        pa = PTE_ADDR(*pte);  #pa是以pte为基础寻址得到的物理地址
        flags = PTE_FLAGS(*pte);
        if((mem = kalloc()) == 0)  #在物理地址空间中分配一页
          goto bad;
        memmove(mem, (char*)P2V(pa), PGSIZE); #把pa为基址的一页拷贝到mem中
        #建立拷贝好的内存mem与子进程页表的映射
        if(mappages(d, (void*)i, PGSIZE, V2P(mem), flags) < 0) {  
          kfree(mem);
          goto bad;
        }
      }
    
    

    由此可见,xv6在创建子进程时就实打实地分配了新的物理地址,而**不是采取“ 写时复制“**的方式。

  4. 子进程继承父进程的大小,设置父进程为当前进程,继承trapframe使得子进程返回时处在和父进程一模一样的状态:

    np->sz = curproc->sz;
    np->parent = curproc;
    *np->tf = *curproc->tf;
    
    
  5. 因为子进程完全拷贝父进程的trapframe,所以被调度到时会返回父进程调用fork()处,为了区分子进程和父进程,设置trapframeeax为0,这样子进程创建完通过中断返回的时候,会返回0,而父进程会返回子进程的pid:

    np->tf->eax = 0;
    
    
  6. 把父进程打开的文件描述符、父进程所处于的目录和父进程的名字全部拷贝到子进程:

    for(i = 0; i < NOFILE; i++)
        if(curproc->ofile[i])
          np->ofile[i] = filedup(curproc->ofile[i]);
    np->cwd = idup(curproc->cwd);
    safestrcpy(np->name, curproc->name, sizeof(curproc->name));
    
    
  7. 设置子进程当前状态为就绪(RUNABLE):

    np->state = RUNNABLE;
    
    
  8. 最后返回子进程当前的pid给父进程:

    pid = np->pid;
    return pid;
    
    
2、wait()

功能:等待一个子进程exit并返回其pid,如果没有子进程返回-1

  1. wait()函数首先必须要获得ptable的锁,因为它有可能会对ptable做出修改。

    acquire(&ptable.lock);
    
    
  2. 遍历ptable,从中寻找自己的子进程。如果发现僵尸子进程,就把僵尸子进程回收,具体地说要回收它的内核栈、虚拟内存,并设置状态为UNUSED

    for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
          if(p->parent != curproc)
            continue;
          havekids = 1;
          if(p->state == ZOMBIE){  // 找到一个僵尸子进程
            pid = p->pid;
            kfree(p->kstack);        // 回收内核栈
            p->kstack = 0;
            freevm(p->pgdir);        // 回收虚拟内存
            p->pid = 0;
            p->parent = 0;
            p->name[0] = 0;
            p->killed = 0;
            p->state = UNUSED;        // 设置状态为UNUSED
            release(&ptable.lock);
            return pid;
          }
      }
    
    

    注:wait()函数没有回收这个子进程打开的文件描述符,因为只有exit()之后的进程才可能是ZOMBIE状态,而在exit()函数内这个进程打开的文件描述符已经全部被关闭了。

  3. 对于没有子进程的情况,wait()会直接返回,否则他会调用sleep()阻塞父进程,并传入ptable的锁作为参数:

    //没有子进程
    if(!havekids || curproc->killed){
          release(&ptable.lock);
          return -1;
        }
    
    // 暂时阻塞父进程,等待僵尸子进程
    sleep(curproc, &ptable.lock); 
    
    

    为了进一步理解wait,继续看sleepwakeup的代码。

    sleep:

    if(lk != &ptable.lock){  //判断sleep的调用者获取的锁是否为ptable.lock
        acquire(&ptable.lock);  //如果不是,就要loop直至获取ptable.lock
        release(lk); //可以释放sleep调用者获取的锁
      }
      
      p->chan = chan; //将该进程sleep on chan
      p->state = SLEEPING;//设置进程状态
    
      sched(); //切换上下文到scheduler线程
      
      //被唤醒后
      p->chan = 0;
      // 重新获取原本的锁,并释放ptable.lock
      if(lk != &ptable.lock){  
        release(&ptable.lock);
        acquire(lk);
      }
    
    

    wakeup:在获取ptable.lock的前提下唤醒所有sleep on chan的进程

    static void
    wakeup1(void *chan)
    {
      struct proc *p;
    
      for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
        if(p->state == SLEEPING && p->chan == chan)
          p->state = RUNNABLE;
    }
    
    

    1)为什么sleep要获取ptable.lock

    因为在真正把进程设置为SLEEP状态之前,子进程可能就已经成为僵死进程并在exit()函数中调用了wakeup1()来唤醒父进程,但此时父进程还未SLEEP,这会使得父进程接收不到wakeup信号从而进入死锁状态。这种现象的原因是睡眠操作与检测睡眠条件不是一个原子操作,xv6让进程在SLEEP之前获取了ptable.lock,而exit()调用wakeup又必须要获取ptable.lock,所以不会发生lose wakeup

    2)sleep一直占着ptable.lock,哪里有释放才能使其他进程获取锁?

    在xv6中,ptable.lock总是由旧进程获得,并将锁的控制权转移给切换代码,由新进程释放。锁的这种使用方式很少见,通常来说,持有锁的线程应该负责释放该锁,这样更容易让我们理解其正确性。但对于上下文切换来说,我们必须使用这种方式,因为 ptable.lock 会保证进程的 statecontext 在运行 swtch 时保持不变。如果在 swtch 中没有持有 ptable.lock,可能引发这样的问题:在 yield 将某个进程状态设置为 RUNNABLE 之后,但又是在 swtch 让它停止在其内核栈上运行之前,有另一个 CPU 的schduler检测到该进程位RUNNABLE,然后调用swtch,结果将是两个 CPU 都运行在同一个栈上,这显然是不该发生的。这里也可以解释为什么在第一个进程中要把contexteip设为forkret,就是为了按照惯例释放 ptable.lock,否则这个新进程是可以直接从从 trapret 就开始执行的。 所以在进程被置为SLEEPING之后要give up CPU,并不由当前进程释放锁,而是调用sched()sched()会通过下面这句切换到scheduler

    swtch(&p->context, mycpu()->scheduler);
    
    

    而前面说过所有内核线程被切换掉时都会被卡在swtchscheduler也不例外,所以scheduler会从swtch开始继续往下执行。而下面正有 release(&ptable.lock);

    3)为什么有lk锁,为什么可以释放?

    因为调用sleep的不一定是wait,比如发送者和接收者也需要调用sleep和wakeup(详细的例子参考xv6调度[睡眠与唤醒])。sleep的调用者必须持有锁,这个锁不是ptable.lock,是保证二者比如发送者和接收者操作原子性的锁,作用是防止在调用者在test和sleep之间,对手进程在另一个cpu上运行,更新了共享量,调用了wakeup却发现没有需要唤醒的进程,导致对手进程被阻塞在test上,而调用者进程也依然sleep,这就造成死锁。接着 sleep 要求持有 ptable.lock。于是该进程就会同时持有锁 ptable.lock 和 lk 了。而如今 sleep 已经持有了 ptable.lock,因为wakeup一定要持有ptable.lock,所以即便对手进程调用了wakeup,wakeup 也不可能在没有持有 ptable.lock 的情况下运行直至sleep 让进程睡眠后,所以此时sleep完全可以释放lk,让对手进程能够修改共享量防止另一种死锁,同时这样一来,wakeup也 不会错过 sleep 。

    在这里是由wait调用sleep,lk 就是 ptable.lock 的时候,这样sleep 就会直接跳过lk和ptable.lock这两个步骤。

    4)事实上xv6实现了wakeup和wakeup1,前者获取ptable.lock之后调用后者。为什么要这样做?

    因为有时调度器既可能在持有 ptable.lock 的情况下唤醒进程,也可能在不持有的情况下唤醒。

3、exit()
  1. 首先关闭这个进程打开的所有文件描述符,然后除去自己对所处的文件目录的引用:

    for(fd = 0; fd < NOFILE; fd++){
        if(curproc->ofile[fd]){
          fileclose(curproc->ofile[fd]);
          curproc->ofile[fd] = 0;
        }
      }
    begin_op();
    iput(curproc->cwd);
    end_op();
    curproc->cwd = 0;
    
    
  2. 如果这个进程的父进程正在等待子进程结束,那么这个进程必须唤醒父进程,只有这样父进程才能够在某个时刻回收僵尸子进程:

    acquire(&ptable.lock); 
    wakeup1(curproc->parent);
    
    
  3. 如果这个进程有子进程的话,就把这些子进程的父进程都设为init进程,如果是僵尸子进程,则唤醒init进程回收僵尸子进程(总之就是一定要有父进程来帮助回收僵尸子进程):

    for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
        if(p->parent == curproc){
          p->parent = initproc;
          if(p->state == ZOMBIE)
            wakeup1(initproc);
        }
      }
    
    
  4. 把该进程状态设置为ZOMBIE,调度器调度其他进程运行,并且因为不会再被唤醒所以不会再返回继续执行 :

    curproc->state = ZOMBIE;
    sched();
    
    

    为什么子进程不能直接exit(),而是应该变成僵尸子进程,然后交给父进程来回收?

    这样可以保证为退出的进程做好子进程的清理工作。 因为当子进程运行 exit 时,它正在利用 p->kstack 分配到的栈以及 p->pgdir 对应的页表,所以这两者只能在子进程结束运行后由父进程情理。这也是为什么要给scheduler单独分配一个内核栈和一个内核页表(kpgdir),而不能让其运行在调用 sched 的线程的栈上,否则像这种exit()的情况,调用sched(),父进程处理子进程栈和虚拟内存时会把scheduler也清理掉。

Part4:增加syscall获取父进程ID

系统调用SYSCALL实现机制在Part2中已介绍过。

1、实现

1、前面讨论的系统调用函数都是内核态下的,xv6为用户提供的这些函数的API定义在**user.h**中:

// system calls
int fork(void);
int exit(void) __attribute__((noreturn));
int wait(void);
……

在最后添加:

int getfpid(void);

2、这些函数是通过调用汇编指令实现的,在**usys.S**这个文件:

#define SYSCALL(name) 
  .globl name; 
  name: 
    movl $SYS_ ## name, %eax; 
    int $T_SYSCALL; 
    ret

在后面是所有系统调用函数的宏定义,以SYSCALL(wait)为例,展开后就是:

.globl wait;
wait:
movl $SYS_wait, %eax;
int $T_SYSCALL;
ret

在最后添加:

SYSCALL(getpid)

在这里我产生了一个问题:对于有参数的系统调用怎么办?以前面的initcode.S系统调用exec为例,在用户态下调用exec(xx,xx)时参数都已经被压栈了,既然在栈中了在内核态中”顺藤摸瓜“获取参数不是难事。

3、syscall.h,这个文件就定义了所有的$SYS_ ## name的系统调用号。

在最后添加:

#define SYS_getfpid 22

4、traps.h,这个文件定义了所有的中断处理程序的编号。比如#defineT_SYSCALL 64 // system call。前面说过,所有的系统调用都是通过int 64陷入内核,所以这里不需要改动。

5、按照前面分析,接下来硬件会把系统调用号以及其他寄存器压栈->vector64->alltraps建立trapframe->trap调用syscall()->syscall()通过trapframe中的eax来确定系统调用号并调用具体的系统调用处理函数。

这就涉及到syscalls[]这个函数指针数组,定义在**syscall.c**中。这个文件的作用是将系统调用标识SYS_xx和系统调用函数sys_xx通过系统调用关联起来,并注册系统调用函数。

注册:

extern int sys_getfpid(void);

并在static int (*syscalls[])(void) 中添加:

[SYS_getfpid]   sys_getfpid,

6、然后就要实现sys_xx,这些函数都定义在**sysproc.c**中,并且在sys_xx中调用真正的处理函数。

//return parent process's pid
int 
sys_getfpid(void)
{
  return getfpid();
}

7、真正的处理函数在**defs.h中注册,在proc.c**中定义。

注册:

int  getfpid(void);

实现:

int getfpid(void)
{
  struct proc *curproc = myproc();
  for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
    if(curproc->parent ==p)
      return p->pid;
  }
  return -1;
}

2、检验

在xv6系统中,可以自己写一段C语言程序,并通过修改Makefile的方式,使得自己的应用程序成为系统的一部分,并在xv6上通过shell执行。

1、新建一个源代码,命名getfpid.c:

#include "types.h"
#include "stat.h"
#include "user.h"

int main(int argc, char *argv[])
{
	if(argc!=1)
	    printf(1, "Usage: ps\n"); //注意printf的用法
	else
	    printf(2,"parent process's pid : %d\n", getfpid());
	exit();
}

2、修改Makefile,一共有两处:

1)168行左右的UPROGS=\需要在最后加入这个源代码名字:

UPROGS=\
	_cat\
	_echo\
	_forktest\
	_grep\
	_init\
	_kill\
	_ln\
	_ls\
	_mkdir\
	_rm\
	_sh\
	_stressfs\
	_usertests\
	_wc\
	_zombie\
	_getfpid\ 

2)251行的EXTRA=\处,添加入自己的.c源文件:

EXTRA=\
	mkfs.c ulib.c  cat.c echo.c forktest.c grep.c kill.c\
	ln.c ls.c mkdir.c rm.c stressfs.c usertests.c wc.c zombie.c getfpid.c\ 
	……

3)开启xv6:

cd xv6-public;
make clean;
make;
make qemu-nox;

4)在shell中输入getfpid,得到当前shell进程的父进程pid:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2awI18Dl-1572157762070)(C:\Users\zcr\AppData\Roaming\Typora\typora-user-images\1571649901721.png)]


references:

x86中的分页映射

trapframe和context的原理与区别

xv6陷入,中断和驱动程序

xv6:syscall

xv6调度

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值