跳转至

博客

ucore-mips 代码分析

1. Makefile

  1. boot/loader.bin基本就是一个读取 elf 的函数,我们上板使用 pmon 加载 ucore,因此用不到。
  2. obj/ucore-kernel-initrd是一个 elf 文件,特殊之处在于,它的 data 段包含user/_archive目录下的文件(包含一个 test.txt)和$(USER_APP_BINS)(如 sh, ls 等用户程序)的内容。将内核和文件系统打包到了一起

2. kernel_entry

kern/init/entry.S

  1. 设置了$gp

    定义在链接脚本里,可以通过readelf obj/ucore-kernel-initrd -a|grep _gp来查看其值。参考值(修改了代码编译出的值可能不一样)0x800c7f40

  2. 设置了$sp,启动时用的栈,栈大小为KSTACKSIZE,8KB

    参考值:bootstacktop(0x80068730)bootstack(0x80066730)

  3. 设置了 Ebase 为__exception_vector。参考值:0x8002b000。__exception_vector 包含了各异常向量地址的入口。

3. kern_init

kern/int/init.c

杂项
tlb_invalidate_all

定义于 kern/include/thumips_tlb.h 和 kern/mm/thumips_tlb.c

tlb_invalidata_all 循环调用 write_one_tlb 清空 tlb 表项 默认 tlb 有 128 项,虽然不会导致错误,但是可以优化

pic_init

定义于 kern/driver/picirq.c pic_init -> write_c0_status 将 Statu 中的 IM 域全置 0,关闭所有中断。 之后可以通过 pic_enable(int irq) 或 pic_disable(int irq) 开关中断

cons_init & clock_init

一个初始化串口,一个初始化时钟中断。

相关宏定义于/kern/include/thumips.h

#define COM1_IRQ        3
#define TIMER0_IRQ       7

clock 需要用到cp0_countcp0_compare 设置 clock 中断后,每clock.c::TIMER0_INTERVAL=1000000 后会产生一个时钟中断

check_initrd

检查内核是否有 initrd,通过判断是否_initrd_begin == _initrd_end)

kprintf

内核第一次输出信息

const char *message = "(THU.CST) os is loading ...\n\n";
kprintf(message);

kprintf 函数最后调用串口的 cons_putc 输出字符。串口输出字符采用忙等待方式。

kern/lib/stdio.c::kprintf -> kern/lib/stdio.c::vkprintf -> kern/lib/printfmt.c::vprintfmt(cputch, ...)

kern/lib/stdio.c::cputch -> kern/driver/console.c::cons_putc
#!kern/driver/console.c

static void
serial_putc_sub(int c) {
    while (!(inb(COM1 + COM_LSR) & COM_LSR_TXRDY)) {
    }
    outb(COM1 + COM_TX, c);
}

作为对比,用户程序 cprintf

user/lib/stdio.c::cprintf -> user/lib/stdio.c::vcprintf -> user/lib/stdio.c::vprintfmt(cputch, ...)
user/lib/stdio.c::cputch -> user/lib/syscall.c::sys_putc -> syscall(SYS_putc, )
系统调用进入内核
异常处理程序调用kern/syscall/syscall.c::sys_putc -> kern/lib/stdio.c::kputchar -> cons_putc

用户程序输出每一个字符都会触发一次系统调用

pmm_init
page 结构体
struct Page {
    atomic_t ref;                   // page frame's reference counter
    uint32_t flags;                 // array of flags that describe the status of the page frame
    unsigned int property;          // used in buddy system, stores the order (the X in 2^X) of the continuous memory block
    int zone_num;                   // used in buddy system, the No. of zone which the page belongs to
    list_entry_t page_link;         // free list link
//    swap_entry_t index;             // stores a swapped-out page identifier
    list_entry_t swap_link;         // swap hash link
};

ucore 将物理内存划分成一个一个的页,并使用 Page 结构体数组来管理。一个 Page 项对应一个物理页。

参考 pmm.c::page_init 代码

kprintf("memory map:\n");
kprintf("    [");
printhex(KERNBASE);
kprintf(", ");
printhex(KERNTOP);
kprintf("]\n\n");

maxpa = KERNTOP;
npage = KMEMSIZE >> PGSHIFT;

// end address of kernel
extern char end[];
// put page structure table at the end of kernel
pages = (struct Page *)ROUNDUP_2N((void *)end, PGSHIFT); 

KMEMSIZE代表内核管理的物理内存的大小,npage 计算出来为物理页的个数。

end 在链接脚本中定义,为 ucore-kernel-initrd 的结尾。即 ucore 在内核的结尾分配了(直接写)Page 数组的空间,pages 为数组的起始地址。

此时 page 项可以和物理页一一对应起来了

static inline ppn_t
page2ppn(struct Page *page) {
    return page - pages;
}

static inline uintptr_t
page2pa(struct Page *page) {
    return KERNBASE + (page2ppn(page) << PGSHIFT);
}

static inline struct Page *
pa2page(uintptr_t pa) {
    if (PPN(pa) >= npage) {
        panic("pa2page called with invalid pa");
    }
    return &pages[PPN(pa)];
}

static inline void *
page2kva(struct Page *page) {
    return KADDR(page2pa(page));
}

这里强调一下page2kvaKADDR的作用,事实上如果看其定义的话,KADDR(pa) 的值和 pa 是一样的,如下

/* *
 * PADDR - takes a kernel virtual address (an address that points above KERNBASE),
 * where the machine's maximum 256MB of physical memory is mapped and returns the
 * corresponding physical address.  It panics if you pass it a non-kernel virtual address.
 * */
#define PADDR(kva) ({                                                   \
            uintptr_t __m_kva = (uintptr_t)(kva);                       \
            if (__m_kva < KERNBASE) {                                   \
                panic("PADDR called with invalid kva %08lx", __m_kva);  \
            }                                                           \
            __m_kva ;                                         \
        })

/* *
 * KADDR - takes a physical address and returns the corresponding kernel virtual
 * address. It panics if you pass an invalid physical address.
 * */
#define KADDR(pa) ({                                                    \
            uintptr_t __m_pa = (pa);                                    \
            size_t __m_ppn = PPN(__m_pa);                               \
            if (__m_ppn >= npage) {                                     \
                panic("KADDR called with invalid pa %08lx", __m_pa);    \
            }                                                           \
            (void *) (__m_pa);                               \
        })

而之所以需要进行一次转换,主要是保持逻辑上的一致性。因为 CPU 直接发出的地址都是虚拟地址,会经过 MMU 进行虚拟地址转换(一般基于 TLB,而 TLB 基于操作系统管理的页表)。整个转换过程是硬件自动完成的,因此软件基本不用和物理地址打交道(操作系统中涉及到和硬件软硬件协同的部分会涉及到物理地址,如页表中存储的地址是物理地址,当 MIPS 中 tlb refill 异常时,便会读取页表项加载到 TLB 表项中)

因而我们在代码中访问数据时都要使用虚拟地址。

因为这里的整个内核虚拟空间([KERNBASE, KERNBASE+KMEMSIZE]=[0x8000_0000, 0x8200_0000))都位于 MIPS 中的 kseg0 段,而该段采用固定地址映射的方式,而不使用 TLB,因此内核访问整个内核虚拟地址空间丝毫不用担心。

这里应该有一个错误

按照定义来说,page2pa 就应该是 (page2ppn(page) << PGSHIFT),加了 KERNBASE 后反而是虚拟地址了。

不过因为 KADDR 也错了,因此当我们分配了一个空闲物理页 page 后,要获得其虚拟地址

KADDR(page2pa(page))反而没错了

空闲页

空闲地址开始于 Page 数组之后。init_memmap 会调用 pmm_manager 的 init_memmap 函数,用于记录哪些页是空闲的。

uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * npage);
PRINT_HEX("freemem start at: ", freemem);

uint32_t mbegin = ROUNDUP_2N(freemem, PGSHIFT);
uint32_t mend = ROUNDDOWN_2N(KERNTOP, PGSHIFT);
assert( mbegin < mend );
init_memmap(pa2page(mbegin), (mend - mbegin) >> PGSHIFT );
PRINT_HEX("free pages: ", (mend-mbegin)>>PGSHIFT);
PRINT_HEX("## ", sizeof(struct Page));
pmm_manager

物理内存管理其实就是要实现物理空闲页的分配和回收,ucore 定义了 pmm_manager 的结构体,可以认为 C 中实现的类。

关键的两个函数:

struct Page *(*alloc_pages)(size_t n);
void (*free_pages)(struct Page *base, size_t n);

alloc_pages 从空闲页中分配了一段连续的空闲页

而 free_pages 则将指定的一段连续的页重新回收为空闲页

ucore 代码实现了伙伴分配系统 (buddy system),可以参考其具体实现

页表相关函数

ucore 实现了二级页表

关键函数

pte_t *get_pte(pde_t *pgdir, uintptr_t la, bool create);
struct Page *get_page(pde_t *pgdir, uintptr_t la, pte_t **ptep_store); //根据 pte 的值获得对应的 page
void page_remove(pde_t *pgdir, uintptr_t la);
int page_insert(pde_t *pgdir, struct Page *page, uintptr_t la, uint32_t perm);
struct Page * pgdir_alloc_page(pde_t *pgdir, uintptr_t la, uint32_t perm);
get_pte
pte_t *get_pte(pde_t * pgdir, uintptr_t la, bool create)

函数作用:给定页目录表的虚拟地址,查找虚拟地址 la 对应的页表项 pte。(只要 la 在一个页内,返回的 pte 是相同的) 注:获得的是 pte 项的虚拟地址 过程:

  1. 先计算页目录表中对应 pde 表项的虚拟地址

    pdep = pgdir + PDX(la)
    
  2. 如果 pde 项有效,即((*pdep)&PTE_P) == 0。直接访问 pde 获得 la 对应二级页表的物理地址。

    PDE_ADDR(*pdep)
    
  3. 计算二级页表中对应页表项 pte 的物理地址

    (pte_t*)(    PDE_ADDR(*pdep)   ) + PTX(la)
    
  4. 转换成虚拟地址

    (pte_t*)KADDR(   (uintptr_t)(  (pte_t*)(PDE_ADDR(*pdep))+PTX(la)  )  )
    
  5. 如果 02 中无效,则需要根据 create 决定是否创建二级页表。(下面代码只考虑了创建情形,并为了突出重点,删减了许多有用的代码)

    struct Page* new_pte = alloc_page();
    
    uintptr_t pa = (uintptr_t)page2kva(new_pte);
    *pdep = PADDR(pa);
    

    最后一行,页表中存储的是物理地址。然而根据之前的分析,我们知道上面代码中的 PADDR(pa) 实际上是虚拟地址。然而我们发现在这样设置之后,下次调用 get_pte 时,获得的 pte 的地址确实是虚拟地址(KADDR 不产生作用)。

page_insert
int page_insert(pde_t *pgdir, struct Page *page, uintptr_t la, uint32_t perm)

作用:将虚拟地址 la 映射到 page 对应的物理页上

步骤:

  1. 根据虚拟地址 la 查找到二级页表项 ptep

  2. 如果原本 ptep 已经映射到另一个物理页上了,则删除该映射(有可能导致回收物理页)

  3. 设置 ptep 映射到新物理页

    *ptep = page2pa(page) | PTE_P | perm;
    

    由以上代码,可以看到二级页表项中存储的地址其实是虚拟地址。

page_remove
void page_remove(pde_t *pgdir, uintptr_t la) {

作用:解除 pgdir 中虚拟地址 la 的映射

步骤:

  1. 获得 pte

    pte_t *ptep = get_pte(pgdir, la, 0);
    
  2. 如果ptep != NULL*ptep & PTE_P

    struct Page *page = pte2page(*ptep);
    page_ref_dec(page);
    // and free it when reach 0
    if(page_ref(page) == 0){
        free_page(page);
    }
    
  3. 清除映射

    *ptep = 0;
    
pgdir_alloc_page
struct Page * pgdir_alloc_page(pde_t *pgdir, uintptr_t la, uint32_t perm)

作用:该函数分配一个物理页,并将 la 对应的虚拟页,映射到该物理页

check
check_pgdir
  1. 将虚拟地址 0x0 映射到一个物理页 (随便分配一个 page, p1) 上。
  2. 将虚拟地址 0x1000(PAGESIZE) 映射到一个物理页 (随便分配一个 page, p2)。
  3. 将 0x1000 重新映射到 p1. ...... 主要是检查了 page_insert, page_remove 等函数的正确性。
check_boot_pgdir(tlb refill 处理)
  1. 分配一个物理页,并写入初始值

    struct Page *p;
    p = alloc_page();
    *(int*)(page2kva(p) + 0x100) = 0x1234;
    
  2. 将两个虚拟页映射到该物理页上

    assert(page_insert(boot_pgdir, p, 0x100, PTE_W) == 0);
    assert(page_ref(p) == 1);
    assert(page_insert(boot_pgdir, p, 0x100 + PGSIZE, PTE_W) == 0);
    assert(page_ref(p) == 2);
    
  3. 直接使用虚拟地址访问该物理页,引发 tlb refill 异常

    assert(*(int*)0x100 == 0x1234);
    
  4. 进入对应异常处理程序

    static void handle_tlbmiss(struct trapframe* tf, int write)
    {
      int in_kernel = trap_in_kernel(tf);
      assert(current_pgdir != NULL);
      uint32_t badaddr = tf->tf_vaddr;
      int ret = 0;
      pte_t *pte = get_pte(current_pgdir, tf->tf_vaddr, 0);
      if(pte==NULL || ptep_invalid(pte)){   //PTE miss, pgfault
          kprintf("## pagefault in kernel, badaddr: 0x%x\n", badaddr);
        //tlb will not be refill in do_pgfault,
        //so a vmm pgfault will trigger 2 exception
        //permission check in tlb miss
        ret = pgfault_handler(tf, badaddr, get_error_code(write, pte));
      }else{ //tlb miss only, reload it
        /* refill two slot */
        /* check permission */
        if(in_kernel){
          kprintf("## tlb refill in kernel, badaddr: 0x%x\n", badaddr);
          tlb_refill(badaddr, pte); 
          return;
        }else{
          if(!ptep_u_read(pte)){
            ret = -1;
            goto exit;
          }
          if(write && !ptep_u_write(pte)){
            ret = -2;
            goto exit;
          }
        //kprintf("## refill U %d %08x\n", write, badaddr);
          tlb_refill(badaddr, pte);
          return ;
        }
      }
    
    exit:
      if(ret){
        print_trapframe(tf);
        if(in_kernel){
          panic("unhandled pgfault");
        }else{
          do_exit(-E_KILLED);
        }
      }
      return ;
    }
    
    1. 首先判断是否在内核中发生了异常,这里通过 Status.KSU 位来判断,2'b00 表示 kernel mode,2'b10 表示 user mode。

      bool
      trap_in_kernel(struct trapframe *tf) {
        return !(tf->tf_status & KSU_USER);
      }
      
    2. 然后获得 pte

      pte_t *pte = get_pte(current_pgdir, tf->tf_vaddr, 0);
      
    3. 之后进入 tlb_refill 函数,tlb_refill 执行 tlbwr,从 pte 读取映射的物理页号写入到 tlb 项中。(一次映射两页)

      tlb_replace_random(0, badaddr & THUMIPS_TLB_ENTRYH_VPN2_MASK, 
            pte2tlblow(*pte), pte2tlblow(*(pte+1)));
      

终端输出

## tlb refill in kernel, badaddr: 0x100
check_boot_pgdir() succeeded!
kmalloc

pmm_init 中还调用了 kmalloc_init。

kmalloc 中主要实现了以下关键函数

void *kmalloc(size_t n);
void kfree(void *objp);

可以看到 kmalloc 函数的参数为字节数,返回值为分配的物理内存的虚拟起始地址。

kmalloc 利用到了 slab 机制

slab 是 Linux 操作系统的一种内存分配机制。其工作是针对一些经常分配并释放的对象,如进程描述符等,这些对象的大小一般比较小,如果直接采用伙伴系统来进行分配和释放,不仅会造成大量的内存碎片,而且处理速度也太慢。而 slab 分配器是基于对象进行管理的,相同类型的对象归为一类 (如进程描述符就是一类),每当要申请这样一个对象,slab 分配器就从一个 slab 列表中分配一个这样大小的单元出去,而当要释放时,将其重新保存在该列表中,而不是直接返回给伙伴系统,从而避免这些内碎片。

vmm_init

通过内存地址虚拟化,可以使得软件在没有访问某虚拟内存地址时不分配具体的物理理内存,而只有在实际访问某虚拟内存地址时,操作系统再动态地分配物理内存,建立虚拟内存到物理理内存的页映射关系,这种技术称为按需分页(demand paging)。把不不经常访问的数据所占的内存空间临时写到硬盘上,这样可以腾出更更多的空闲内存空间 给经常访问的数据;当 CPU 访问到不不经常访问的数据时,再把这些数据从硬盘读入到内存中。这种技术称为页换入换出 (page in/out)

vma_struct

vma 用于描述进程对虚拟内存的需求

struct vma_struct {
    struct mm_struct *vm_mm; // the set of vma using the same PDT 
    uintptr_t vm_start;      // start addr of vma 
    uintptr_t vm_end;        // end addr of vma
    uint32_t vm_flags;       // flags of vma
    list_entry_t list_link;  // linear list link which sorted by start addr of vma
};

vma 指示了一段连续的虚拟内存空间,不同 vma 通过 list_link 相连,共同表示一个进程的虚拟内存空间。

mm_struct
struct mm_struct {
    list_entry_t mmap_list;        // linear list link which sorted by start addr of vma
    struct vma_struct *mmap_cache; // current accessed vma, used for speed purpose
    pde_t *pgdir;                  // the PDT of these vma
    int map_count;                 // the count of these vma
 void *sm_priv;       // the private data for swap manager
 atomic_t mm_count;
 semaphore_t mm_sem;
 int locked_by;
};

mm_struct 用于管理一系列使用同一个页目录表的 vma 的集合。

进程控制块中包含一个 mm_struct 的指针,用于管理该进程的虚拟内存空间

关键函数
struct vma_struct *find_vma(struct mm_struct *mm, uintptr_t addr);
struct vma_struct *vma_create(uintptr_t vm_start, uintptr_t vm_end, uint32_t vm_flags);
void insert_vma_struct(struct mm_struct *mm, struct vma_struct *vma);
check_pgfault(pagefault 的处理)
  1. 创建 mm_struct

    check_mm_struct = mm_create();
    

    这里的check_mm_struct为全局变量

  2. 分配[0, PTSIZE]的虚拟内存

    struct vma_struct *vma = vma_create(0, PTSIZE, VM_WRITE); //如果该成 VM_READ,之后在 do_pgfault 便会通不过权限检查。
    insert_vma_struct(mm, vma);
    
  3. 访问 0x100

      uintptr_t addr = 0x100;
      assert(find_vma(mm, addr) == vma);
    
      int i, sum = 0;
      for (i = 0; i < 100; i ++) {
        *(char *)(addr + i) = i;    //page fault
        sum += i;
      }
      for (i = 0; i < 100; i ++) {
        sum -= *(char *)(addr + i);
      }
      assert(sum == 0);
    
  4. 触发 tlb refill 异常。(实际为 pagefault,此时页表中没有该映射,因此 tlb 中也没有该项)

    参考之前 tlb refill 异常处理的代码,进入 pgfault_handler

    1. 首先通过判断 check_mm_struct 非空,知道是在执行 check_pgfault 函数,将 mm 设置为 check_mm_struct 2. 在 do_pgfault 中首先调用 find_vma 函数,确定该虚拟地址是有效的 3. 然后检查该地址访问权限是否正确(如果将 4. 都通过了后,调用 pgdir_alloc_page,直接分配一个物理页,并在 mm->pgdir 中插入映射关系。 5. 异常处理完成并返回

  5. 再次触发 tlb refill 异常。(现在页表中存在了该映射,因此进入 tlb_refill 函数)

终端输出

## pagefault in kernel, badaddr: 0x100
## tlb refill in kernel, badaddr: 0x100
check_pgfault() succeeded!

代码

static inline int get_error_code(int write, pte_t *pte)
{
  int r = 0;
  if(pte!=NULL && ptep_present(pte)) //因为 pagefault 的条件 (pte==NULL || ptep_invalid(pte)),这里根本不可能为真
    r |= 0x01;
  if(write)
    r |= 0x02;
  return r;
}
static int
pgfault_handler(struct trapframe *tf, uint32_t addr, uint32_t error_code) {
  extern struct mm_struct *check_mm_struct;
  struct mm_struct *mm;
  if (check_mm_struct != NULL) {
    assert(current == NULL);
    mm = check_mm_struct;
  }
  else {
    if (current == NULL) {
      print_trapframe(tf);
      //print_pgfault(tf);
      panic("unhandled page fault.\n");
    }
    mm = current->mm;
  }
  return do_pgfault(mm, error_code, addr);
}
#! vmm.c

//page fault number
volatile unsigned int pgfault_num=0;
// do_pgfault - interrupt handler to process the page fault execption
int
do_pgfault(struct mm_struct *mm, uint32_t error_code, uintptr_t addr) {
  int ret = -E_INVAL;
  struct vma_struct *vma = find_vma(mm, addr);
  //kprintf("## %08x %08x\n", error_code, addr);

  pgfault_num++;
  if (vma == NULL || vma->vm_start > addr) {
    kprintf("not valid addr %x, and  can not find it in vma\n", addr);
    goto failed;
  }

  switch (error_code & 3) {
    default:
      /* default is 3: write, present */
    case 2: /* write, not present */
      if (!(vma->vm_flags & VM_WRITE)) {
        kprintf("write, not present in do_pgfault failed\n");
        goto failed;
      }
      break;
    case 1: /* read, present */
      kprintf("read, present in do_pgfault failed\n");
      goto failed;
    case 0: /* read, not present */
      if (!(vma->vm_flags & (VM_READ | VM_EXEC))) {
        kprintf("read, not present in do_pgfault failed\n");
        goto failed;
      }
  }

  uint32_t perm = PTE_U;
  if (vma->vm_flags & VM_WRITE) {
    perm |= PTE_W;
  }
  addr = ROUNDDOWN_2N(addr, PGSHIFT);

  ret = -E_NO_MEM;

  // try to find a pte, if pte's PT(Page Table) isn't existed, then create a PT.
  pte_t *ptep=NULL;
  if ((ptep = get_pte(mm->pgdir, addr, 1)) == NULL) {
    goto failed;
  }

  if (*ptep == 0) { // if the phy addr isn't exist, then alloc a page & map the phy addr with logical addr
    if (pgdir_alloc_page(mm->pgdir, addr, perm) == NULL) {
      goto failed;
    }
  }
  else { // if this pte is a swap entry, then load data from disk to a page with phy addr, 
    // map the phy addr with logical addr, trig swap manager to record the access situation of this page
    if(swap_init_ok) {
      panic("No swap!! never reach!!"); 
    }
    else {
      kprintf("no swap_init_ok but ptep is %x, failed\n",*ptep);
      goto failed;
    }
  }
  /* refill TLB for mips, no second exception */
  //tlb_refill(addr, ptep);
  ret = 0;
failed:
  return ret;
}
函数调用
  1. 使用 jal 调用函数,使用 jr ra 返回。使用 a0-a3 传递参数,使用 v0-v1 传递返回值。对于更多的参数,使用栈传递。

  2. 为了防止寄存器的值被覆盖。需要在存储寄存器的值,可以由 caller 或者 callee 来完成。但是只有一方完成时,因为 caller 不知道 callee 会用哪些寄存器,或者 callee 不知道 caller 需要用哪些寄存器。因此就会导致存储不必要的寄存器。MIPS 采用了一种折衷的策略,将寄存器分为 caller-save(如 t0-t7, a0-a3, v0-v1)和 callee-save(如 s0-s7, ra)

  3. fp 寄存器(也是 s8)用于指向栈帧的第一个元素。因为 x86 中提供了 push 和 pop 指令,sp 的位置是变化的,要引用不同变量的值是用 fp 更方便。而 MIPS 中则是在函数的开头手动将 sp 设置,sp 之后的值不会变化,因此 fp 貌似作用不大。

  4. gp 寄存器,用于程序访问全局变量(比如函数的地址)。

    ps. 使用 mipsel-linux-gnu-gcc -S 编译一个简单的函数调用测试代码。不知为什么和上面讲的 caller-save,callee-save 对不上。比如函数开头addiu $sp,$sp,-40,但结果却根本没用上这么大的空间。然后,为什么还会出现sw $a0,40($fp),即将参数a0写入父函数栈帧的情况。(这个然后 fp 和 sp 始终相等,不知道为什么要去存储 fp

sched_init & proc_init
proc_struct(进程控制块)
struct proc_struct {
    enum proc_state state;                      // Process state
    int pid;                                    // Process ID
    int runs;                                   // the running times of Proces
    uintptr_t kstack;                           // Process kernel stack
    volatile bool need_resched;                 // bool value: need to be rescheduled to release CPU?
    struct proc_struct *parent;                 // the parent process
    struct mm_struct *mm;                       // Process's memory management field
    struct context context;                     // Switch here to run process
    struct trapframe *tf;                       // Trap frame for current interrupt
    uintptr_t cr3;                              // CR3 register: the base addr of Page Directroy Table(PDT)
    uint32_t flags;                             // Process flag
    char name[PROC_NAME_LEN + 1];               // Process name
    list_entry_t list_link;                     // Process link list 
    list_entry_t hash_link;                     // Process hash list
    int exit_code;                              // exit code (be sent to parent proc)
    uint32_t wait_state;                        // waiting state
    struct proc_struct *cptr, *yptr, *optr;     // relations between processes
    struct run_queue *rq;                       // running queue contains Process
    list_entry_t run_link;                      // the entry linked in run queue
    int time_slice;                             // time slice for occupying the CPU
    struct fs_struct *fs_struct;                // the file related info(pwd, files_count, files_array, fs_semaphore) of process
};
tf
struct trapframe {
 uint32_t tf_vaddr; /* coprocessor 0 vaddr register */
 uint32_t tf_status; /* coprocessor 0 status register */
 uint32_t tf_cause; /* coprocessor 0 cause register */
 uint32_t tf_lo;
 uint32_t tf_hi;
 uint32_t tf_ra; /* Saved register 31 */
  struct pushregs tf_regs;
 uint32_t tf_epc; /* coprocessor 0 epc register */
};

tf 记录了

  1. 进程的上下文:32 个通用寄存器的值
  2. epc, status, cause 等 CP0 寄存器

进入异常后,要将 sp 切换到内核栈。对于内核进程来说,就是当前的 sp,而对于用户进程来说,需要从进程控制块中读取 kstack 的地址。

uCore 内核允许嵌套中断。因此为了了保证嵌套中断发⽣生时 tf 总是能够指向当前的 trapframe,uCore 在内核栈上维护了了 tf 的链。

发生中断(异常)时,trap.c::mips_trap 会改变当前进程 current->tf 的值

current->tf = *tf*;

要实现嵌套中断,需要在改变 current->tf 前使用一个 trapframe *otf(局部变量,存储在内核栈中)存下来。之后再将 current->tf 恢复为 otf。

这里提一下,如果一个用户进程发生了异常,在异常处理程序中又发生了一次异常,那么第二次异常时,已经处于了内核模式(在压入 tf 后,调用 mips_trap 之前)。在 ramExcHandle_general 中就不会计算新的 sp

mfc0 k0, CP0_STATUS /* Get status register */
andi k0, k0, KSU_USER/* Check the we-were-in-user-mode bit */
beq k0, $0, 1f  /* If clear, from kernel, already have stack */
kstack

内核栈用于存储中断帧 tf 以及作为进程在内核中执行使用的 sp

对于内核线程,该栈就是运行时的程序使⽤用的栈。而对于普通进程,uCore 在创建进程时分配了了 2 个连续的物理理⻚页作为内核栈的空间。

内核栈位于内核地址空间,并且是不不共享的(每个线程都拥有⾃自⼰己的内核栈),因此不不受到 mm 的管理理,当进程退出的时候,内核能够根据 kstack 的值快速定位栈的位置并进⾏行行回收。

mm

内存管理理的信息,包括内存映射列列表、⻚页表指针等。mm 成员变量量在 lab3 中⽤用于虚存管理理。但在实际 OS 中,内核线程常驻内存,不不需要考虑 swap page 问题,在 lab3 中涉及到了了⽤用户进程,才考虑进程⽤用户内存空间的 swap page 问题,mm 才会发挥作⽤用。

还没看到用户进程的 swap page 代码

创建内核线程
idle

proc_init 函数启动了创建内核线程的步骤。首先当前的执行上下文(从 kern_init 启动至今)就可以看成是 uCore 内核中的一个内线程的上下文。为此,uCore 调用 alloc_proc 函数给当前执行的上下文分配一个进程控制块并在 proc_init 中对它进行相应初始化,将其打造成第 0 个内核线程 --idleproc。

   //alloc_proc
   proc->mm = NULL;    //不需要,所有内核进程公用一个页表
   proc->cr3 = boot_cr3;  //在 pmm_init 中分配为 PADDR(boot_pgdir)

   //proc_init
   idleproc->pid = 0;
   idleproc->state = PROC_RUNNABLE;
   idleproc->kstack = (uintptr_t)bootstack; //在 entry.S 中设置的
   idleproc->need_resched = 1; //表明在 kern_init 后会切换
init(kernel_thread & do_fork)

idle 内核线程主要工作是完成内核中各个子系统的初始化,idle 会调用 kernel_thread 函数创建内核线程 init。

kernel_thread 简单来说为新进程分配了 PCB 空间,和 kstack 空间。

  //proc_init
 int pid = kernel_thread(init_main, NULL, 0);
    if (pid <= 0) {
        panic("create init_main failed.\n");
    }
    initproc = find_proc(pid);
    set_proc_name(initproc, "init");

过程:

  1. kernel_thread 创建内核线程的中断帧,重点在于将 epc 设置为了 kernel_thread_entry,以及设置 a0 和 a1 为 arg 和 fn。还有一点值得注意,status 的 KSU 为内核模式

    当从 eret 返回后(forkrets -> exception_return),CPU 便会进入 kernel_thread_entry,并以 tf 中指定的寄存器值执行。

    kernel_thread 函数采用了局部变量 tf 来放置中断帧,并把中断帧的指针传递给 do_fork 函数。

    kernel_thread 调用 do_fork 的 stack 参数为 0。在 do_fork->copy_thread 以此来判断是创建内核线程

  2. do_fork 作用是以当前进程为父进程,创建一个子进程,可以根据 clone_flags 决定是否共享 mm。具体主要做了以下一些事情:

    1. 调用 alloc_proc 为子进程分配 PCB 空间

    2. 调用 setup_kstack(proc)分配了内核栈空间(注意这里的 kstack 为空闲页的起始地址,而非栈顶)

    3. 调用 copy_mm,根据 clone_flags 来确定是复制还是共享 mm。(只对用户进程有效,内核线程 mm 都为 NULL)

    4. 调用 copy_thread:将 tf 拷贝到 kstack 的栈顶,设置 tf 中的 sp 为 proc->tf - 32(这里的减 32 应该和之前提到的 MIPS 函数调用规则有关,不管怎样,sp 是位于 kstack 中的)

      将 context 中的 ra 设置为 forkret

    5. 将子进程插入 hash_list 和 proc_list

    6. 调用 wakeup_proc 将进程加入调度队列

int
kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) {
    struct trapframe tf;
    memset(&tf, 0, sizeof(struct trapframe));
    tf.tf_regs.reg_r[MIPS_REG_A0] = (uint32_t)arg;
    tf.tf_regs.reg_r[MIPS_REG_A1] = (uint32_t)fn;
    tf.tf_regs.reg_r[MIPS_REG_V0] = 0;
    //TODO
    tf.tf_status = read_c0_status();
    tf.tf_status &= ~ST0_KSU;
    tf.tf_status |= ST0_IE;
    tf.tf_status |= ST0_EXL;
    tf.tf_regs.reg_r[MIPS_REG_GP] = __read_reg($28);
    tf.tf_epc = (uint32_t)kernel_thread_entry;
    return do_fork(clone_flags | CLONE_VM, 0, &tf);
}

//kern/proc/entry.S
kernel_thread_entry:        # void kernel_thread(void)
  addiu $sp, $sp, -16
  jal $a1
  nop
  move $a0, $v0
  la  $t0, do_exit
  jal $t0 
  nop
  /* never here */

//called by do_fork
static void
copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) {
    proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1;
    *(proc->tf) = *tf;
    proc->tf->tf_regs.reg_r[MIPS_REG_V0] = 0;
    if(esp == 0) //a kernel thread
      esp = (uintptr_t)proc->tf - 32;
    proc->tf->tf_regs.reg_r[MIPS_REG_SP] = esp;
    proc->context.sf_ra = (uintptr_t)forkret;
    proc->context.sf_sp = (uintptr_t)(proc->tf) - 32;
}

// do_fork - parent process for a new child process
//    1. call alloc_proc to allocate a proc_struct
//    2. call setup_kstack to allocate a kernel stack for child process
//    3. call copy_mm to dup OR share mm according clone_flag
//    4. call copy_thread to setup tf & context in proc_struct
//    5. insert proc_struct into hash_list && proc_list
//    6. call wakup_proc to make the new child process RUNNABLE 
//    7. set the 
int
do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
 int ret = -E_NO_FREE_PROC;
 struct proc_struct *proc;
 if (nr_process >= MAX_PROCESS) {
     goto fork_out;
 }
 ret = -E_NO_MEM;
 //LAB4:EXERCISE2 2009010989
 if ((proc = alloc_proc()) == NULL) {
     goto fork_out;
 }

 proc->parent = current;

 if(setup_kstack(proc)){
     goto bad_fork_cleanup_proc;
 }
 //LAB8:EXERCISE2 2009010989 HINT:how to copy the fs in parent's proc_struct?
 if (copy_fs(clone_flags, proc) != 0) {
     goto bad_fork_cleanup_kstack;
 }
 if (copy_mm(clone_flags, proc)){
     goto bad_fork_cleanup_fs;
 }

 copy_thread(proc, (uint32_t)stack, tf);

 proc->pid = get_pid();
 hash_proc(proc);


 //list_add(&proc_list, &(proc->list_link));
 set_links(proc);

 wakeup_proc(proc);

 ret = proc->pid;

fork_out:
 return ret;

bad_fork_cleanup_fs:
 put_fs(proc);
bad_fork_cleanup_kstack:
 put_kstack(proc);
bad_fork_cleanup_proc:
 kfree(proc);
 goto fork_out;
}
user_main

在 init_main 中,创建了 user_main 线程。

// init_main - the second kernel thread used to create user_main kernel threads
static int
init_main(void *arg) {
 ...
    int pid = kernel_thread(user_main, NULL, 0);
    if (pid <= 0) {
        panic("create user_main failed.\n");
    }

    while (do_wait(0, NULL) == 0) {
        schedule();
    }

    fs_cleanup();
    kprintf("all user-mode processes have quit.\n");
    ...
}

kernel_thread 之前已经分析过了,我们来看 user_main 做了什么

// user_main - kernel thread used to exec a user program
static int
user_main(void *arg) {
    kprintf("in user main, pid: %d\n", current->pid);
    KERNEL_EXECVE(sh);
    panic("user_main execve failed.\n");
}

user_main 调用了KERNEL_EXECVE来加载执行一个用户程序。KERNEL_EXECVE被展开为对 kernel_execve 的调用。

// kernel_execve - do SYS_exec syscall to exec a user program called by user_main kernel_thread
static int
kernel_execve(const char *name, const char **argv) {
    int argc = 0, ret;
    while (argv[argc] != NULL) {
        argc ++;
    }
    //panic("unimpl");
    asm volatile(
      "la $v0, %1;\n" /* syscall no. */
      "move $a0, %2;\n"
      "move $a1, %3;\n"
      "move $a2, %4;\n"
      "move $a3, %5;\n"
      "syscall;\n"
      "nop;\n"
      "move %0, $v0;\n"
      : "=r"(ret)
      : "i"(SYSCALL_BASE+SYS_exec), "r"(name), "r"(argc), "r"(argv), "r"(argc) 
      : "a0", "a1", "a2", "a3", "v0"
    );
    return ret;
}

而 kernel_execve 则是进行了一次系统调用。

kernel_execve -> SYSCALL -> sys_exec -> do_execve

  1. 因为 kernel_execve 就是在内核线程,所以之后进行系统调用,仍然是使用一样的内核栈。

  2. do_execve 做的事情

    int do_execve(const char *name, int argc, const char **argv) 
    
    1. 创建局部变量 local_name, kargv(位于上下文的内核栈中),将参数 name, argv 复制过来。

      kernel_execve 调用时传递了"sh"字符串常量的地址。而该"sh"的地址在 gdb 调试时显示为 0x8002_7ff0,而 local_name 的地址则为 0x81fe_bd20(此时 sp 为 0x81fe_bd00,可以知道 local_name 位于当前函数的栈帧中,而"sh"位于别处)

      copy_string(mm, local_name, name, sizeof(local_name))
      
      copy_kargv(mm, argc, kargv, argv)
      

      copy_string 函数,作用是先检查访问 src 是否合法(通过 mm),然后将 src 复制到 dst 中。这里的 dst 位于内核空间

      bool copy_string(struct mm_struct *mm, char *dst, const char *src, size_t maxn) 
      

      copy_string 具体代码,if 判断有点复杂,且没有注释,没太看懂。

    2. 读取文件系统,获得文件号,之后传给 load_icode 使用。

      fd = sysfile_open(path, O_RDONLY)
      
    3. 清空原本的 mm。vma,分配的 Page,页表等等都会被清除。(user 此时是内核进程,因此没有 mm)

       if (mm != NULL) {
              lcr3(boot_cr3);
              if (mm_count_dec(mm) == 0) {
                  exit_mmap(mm);
                  put_pgdir(mm);
                  mm_destroy(mm);
              }
              current->mm = NULL;
          }
      
    4. 调用 load_icode

      load_icode(fd, argc, kargv)
      

      调用完 load_icode 后,elf 文件已经被全部加载进内存,并且根据 elf 文件 program header 的内容设置好了 vma,页表等内容。还分配了 USTACK 空间。

  3. load_icode 做的事情

    // load_icode -  called by sys_exec-->do_execve
    // 1. create a new mm for current process
    // 2. create a new PDT, and mm->pgdir= kernel virtual addr of PDT
    // 3. copy TEXT/DATA/BSS parts in binary to memory space of process
    // 4. call mm_map to setup user stack, and put parameters into user stack
    // 5. setup trapframe for user environment 
    static int
    load_icode(int fd, int argc, char **kargv)
    
    1. 创建新的 mm

       struct mm_struct *mm;
          if ((mm = mm_create()) == NULL) {
              goto bad_mm;
          }
          if (setup_pgdir(mm) != 0) {
              goto bad_pgdir_cleanup_mm;
          }
      
    2. 接下来涉及到对 elf 文件的解析。首先调用 load_icode_read 读取 elf 头 (struct elfhdr32)。然后根据 elfhdr,获得程序头 (struct proghdr) 的文件内偏移 elf->e_phoff、程序头的数量 elf->e_phnum。再通过 for 循环,读取每个程序头 ph。程序头显示了每个段的信息。

      for (phnum = 0; phnum < elf->e_phnum; phnum ++) {
          off_t phoff = elf->e_phoff + sizeof(struct proghdr) * phnum;
          if ((ret = load_icode_read(fd, ph, sizeof(struct proghdr), phoff)) != 0) {
              goto bad_cleanup_mmap;
       }
          ...
      

      readelf -l obj/user/sh的信息

      Elf 文件类型为 EXEC (可执行文件)
      Entry point 0x100033d0
      There are 2 program headers, starting at offset 52
      
      程序头:
        Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
        ABIFLAGS       0x0141d0 0x100041d0 0x100041d0 0x00018 0x00018 R   0x8
        LOAD           0x010000 0x10000000 0x10000000 0x05010 0x09010 RWE 0x10000
      
       Section to Segment mapping:
        段节...
         00     .MIPS.abiflags 
         01     .text .MIPS.abiflags .data .bss
      
    3. 根据每个 ph 的信息,添加 vma。这里用的是 ph->p_memsz。

       vm_flags = 0;
            //ptep_set_u_read(&perm);
          perm |= PTE_U;
          if (ph->p_flags & ELF_PF_X) vm_flags |= VM_EXEC;
          if (ph->p_flags & ELF_PF_W) vm_flags |= VM_WRITE;
          if (ph->p_flags & ELF_PF_R) vm_flags |= VM_READ;
          if (vm_flags & VM_WRITE) perm |= PTE_W; 
      
          if ((ret = mm_map(mm, ph->p_va, ph->p_memsz, vm_flags, NULL)) != 0) {
              goto bad_cleanup_mmap;
          }
      
    4. 调用 pgdir_alloc_page 为每个虚拟地址分配物理页,并添加页表映射。调用 load_icode_read 将 elf 文件对应段 (segment) 读取到对应的物理页中。

          off_t offset = ph->p_offset;
          size_t off, size;
          uintptr_t start = ph->p_va, end, la = ROUNDDOWN_2N(start, PGSHIFT);
      
          end = ph->p_va + ph->p_filesz;
          while (start < end) {
              if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) {
                  ret = -E_NO_MEM;
                  goto bad_cleanup_mmap;
              }
              off = start - la, size = PGSIZE - off, la += PGSIZE;
              if (end < la) {
                  size -= la - end;
              }
              if ((ret = load_icode_read(fd, page2kva(page) + off, size, offset)) != 0) {
                  goto bad_cleanup_mmap;
              }
              start += size, offset += size;
          }
      
    5. 设置 bss 段(猜测)

       end = ph->p_va + ph->p_memsz;
      
          if (start < la) {
              if (start >= end) {
                  continue ;
              }
              off = start + PGSIZE - la, size = PGSIZE - off;
              if (end < la) {
                  size -= la - end;
              }
              memset(page2kva(page) + off, 0, size);
              start += size;
              assert((end < la && start == end) || (end >= la && start == la));
          }
      
          while (start < end) {
              if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) {
                  ret = -E_NO_MEM;
                  goto bad_cleanup_mmap;
              }
              off = start - la, size = PGSIZE - off, la += PGSIZE;
              if (end < la) {
                  size -= la - end;
              }
              memset(page2kva(page) + off, 0, size);
              start += size;
          }
      
    6. 映射用户栈空间(添加 vma,当发生 pagefault 时,异常处理程序会自动分配物理页)

       vm_flags = VM_READ | VM_WRITE | VM_STACK;
          if ((ret = mm_map(mm, USTACKTOP - USTACKSIZE, USTACKSIZE, vm_flags, NULL)) != 0) {
              goto bad_cleanup_mmap;
          }
      
    7. 将函数调用参数压入用户栈中

         uintptr_t stacktop = USTACKTOP - argc * PGSIZE;
          char **uargv = (char **)(stacktop - argc * sizeof(char *));
          int i;
          for (i = 0; i < argc; i ++) {
              uargv[i] = strcpy((char *)(stacktop + i * PGSIZE), kargv[i]);
          }
      

      上一个步骤已经将 USTACKTOP 添加到了 vma 中。因此在第一次访问用户栈时会触发 pagefault(页表中没有映射关系),操作系统会动态分配 Page。第二次访问会再触发一次 tlb refill,tlb 添加映射关系,之后便可以正常访问了。

    8. 设置 tf。

      1. 将 tf->tf_epc 设置为了 elf->e_entry。因此异常返回时便开始执行 elf 程序。

      3. 在 tf->tf_status 中设置了 KSU_USER,因此从 SYSCALL 返回后,CPU 会转变为用户态。

       struct trapframe *tf = current->tf;
          memset(tf, 0, sizeof(struct trapframe));
      
          tf->tf_epc = elf->e_entry;
          tf->tf_regs.reg_r[MIPS_REG_SP] = USTACKTOP;
          uint32_t status = read_c0_status();
          status &= ~ST0_KSU;
          status |= KSU_USER;     //变成用户进程
          status |= ST0_EXL;
          tf->tf_status = status;
          tf->tf_regs.reg_r[MIPS_REG_A0] = argc;
          tf->tf_regs.reg_r[MIPS_REG_A1] = (uint32_t)uargv;
      
调度

执行完 proc_init 后,已经创建好了两个内核线程 idle 和 init,并且当前执行的线程为 proc_init。当 ucore 的所有初始化工作完成后,ucore 执行 kern_init 的最后一个函数 cpu_idle 函数。cpu_idle 会调用 schedule 让出 CPU。

schedule 具体的实现我们可以不关心。我们只需要知道 sched_class_enqueue(proc) 将一个进程放入运行队列 run queue。next = sched_class_pick_next() 从 rq 中找到下一个执行的进程。

当选定需要执行的进程后,proc_run 函数将该进程载入 CPU 执行。

void
cpu_idle(void) {
    while (1) {
        if (current->need_resched) {
            schedule();
        }
    }
}

void
schedule(void) {
    bool intr_flag;
    struct proc_struct *next;
    local_intr_save(intr_flag);
    {
        current->need_resched = 0;
        if (current->state == PROC_RUNNABLE) {
            sched_class_enqueue(current);
        }
        if ((next = sched_class_pick_next()) != NULL) {
            sched_class_dequeue(next);
        }
        if (next == NULL) {
            next = idleproc;
        }
        next->runs ++;
        if (next != current) {
            //kprintf("########################\n");
            //kprintf("c %d TO %d\n", current->pid, next->pid);
            //print_trapframe(next->tf);
            //kprintf("@@@@@@@@@@@@@@@@@@@@@@@@\n");
            proc_run(next);
        }
    }
    local_intr_restore(intr_flag);
}

void
proc_run(struct proc_struct *proc) {
    if (proc != current) {
        bool intr_flag;
        struct proc_struct *prev = current, *next = proc;
        local_intr_save(intr_flag);
        {
          //panic("unimpl");
            current = proc;
            //load_sp(next->kstack + KSTACKSIZE);
            lcr3(next->cr3);
            tlb_invalidate_all();       //标注一下这里的 tlb 清空,待优化
            switch_to(&(prev->context), &(next->context));
        }
        local_intr_restore(intr_flag);
    }
}

proc_run 会调用 switch_to,switch_to 会:

  1. 将当前的 context 存储在 prev->context 中。(注意,context 只包含 s0-s8, sp, gp, ra)
  2. 恢复 next->context
  3. 执行j ra
init 进程执行过程

通过 kernel_thread(init_main, NULL, 0) 创建 init 进程后

  1. cpu_idle 中(idle 进程中),执行 schedule(),此时只有 idle 和 init 两个进程,因此调度到 init 进程执行 proc_run。

  2. 执行 switch_to,kernel_thread 设置了 context 中的 sp, ra。执行 jr ra 后,跳转到 forkret

    // forkret -- the first kernel entry point of a new thread/process
    // NOTE: the addr of forkret is setted in copy_thread function
    //       after switch_to, the current proc will execute here.
    static void
    forkret(void) {
        forkrets(current->tf);
    }
    
    forkrets:
      addiu sp, a0, -16
      b exception_return
      nop
    
  3. forkrets->exception_return 从中断返回,弹出中断帧 tf。CPU 便会进入 kernel_thread_entry,并以 tf 中指定的寄存器值执行

  4. kernel_thread_entry 跳转执行$a1 即 init_main

    //kern/proc/entry.S
    kernel_thread_entry:        # void kernel_thread(void)
      addiu $sp, $sp, -16
      jal $a1
      nop
      move $a0, $v0
      la  $t0, do_exit
      jal $t0 
      nop
      /* never here */
    
user_main 的执行过程
  1. 在 init_main 中,创建了 user_main 线程。然后 init_main 执行 do_wait

    // init_main - the second kernel thread used to create user_main kernel threads
    static int
    init_main(void *arg) {
     ...
        int pid = kernel_thread(user_main, NULL, 0);
        if (pid <= 0) {
            panic("create user_main failed.\n");
        }
    
        while (do_wait(0, NULL) == 0) {
            schedule();
        }
    
        fs_cleanup();
        kprintf("all user-mode processes have quit.\n");
        ...
    }
    
  2. do_wait 的作用是等待指定的或者任意一个(传入的 pid 为 0)子线程进入 ZOMBIE 状态,释放子进程的内核栈空间和 PCB 空间。否则,自己进入睡眠状态 SLEEPING,然后调用 schedule()。

    // do_wait - wait one OR any children with PROC_ZOMBIE state, and free memory space of kernel stack
    //         - proc struct of this child.
    // NOTE: only after do_wait function, all resources of the child proces are free.
    int
    do_wait(int pid, int *code_store)
    
  3. schedule 调用到 user_main 线程。和 init 一样,执行到 user_main 函数。

  4. user_main 函数调用了 kernel_execv 函数,实质为一次 SYS_exec 系统调用。

  5. 最后重新分配内核和用户空间,加载了 sh 用户程序。user_main 线程从内核线程变为用户线程。

  6. 用户进程链接脚本 user.ld 指定了 ENTRY(_start)。_start 在 user/libs/initcode.S 中定义。

    _start:
        nop
        la $gp, _gp
        addiu $sp, $sp, -16
        jal umain
        nop
    

    umain 在 user/libs/umain.c 中定义

    void
    umain(int argc, char *argv[]) {
        int fd;
        if ((fd = initfd(0, "stdin:", O_RDONLY)) < 0) {
            warn("open <stdin> failed: %e.\n", fd);
        }
        if ((fd = initfd(1, "stdout:", O_WRONLY)) < 0) {
            warn("open <stdout> failed: %e.\n", fd);
        }
        int ret = main(argc, argv);
        exit(ret);
    }
    
ide_init & fs_init

更新固态之旅

我姐的电脑是 15-16 年买的,型号为 Acer Aspire V15 Nitro Black Edition(暗影骑士 2,精确型号为 Aspire VN7-592G-58NG),ZOL上的商品信息

不得不说这台电脑确实是非常厉害了,有以下这些亮眼的地方。

  • i5-6300HQ。HQ 表示标压 - 四核。
  • NIVIDIA 的 GTX960M 独立显卡。(当时的中端主流显卡)

下面这些即使是现在也是完全够用的,特别是那个支持 2x2 MU-MIMO 的无线网卡。

而不仅限于此,这台电脑的扩展性是真的非常不错。

  • 2 条内存条插槽。支持最大 32G(原装了一条海力士的 4GB DDR4 2133MHz 内存条)
  • 一个 m.2 插槽,支持 NVMe 协议(PCIe Gen3x4,最高 4GB/s)。

下面是发现这台电脑支持 NVMe 协议的固态的曲折过程:

在阅读之前,需要以下知识:

  • m.2 接口(也叫 NGFF(Next Generation Form Factor))
  • NVMe

linux 内核设计与实现阅读

linux 内核设计与实现(原书第 3 版), Robert Love

  • 一个内核由:负责响应中断的中断服务程序,负责多个程序分享处理器时间的进程调度程序,负责管理进程地址空间的内存管理程序和网络、进程间通信等系统服务程序组成。
  • 应用程序通过系统调用访问内核,内核运行于进程上下文中。(不同进程运行同样的内核代码,上下文不同,因此有不同效果)

    • 对于中断来说,内核运行于中断上下文
  • 单内核和微内核。linux 汲取了微内核的优点:linux 是模块化的,多线程的以及内核本身可调度的。
  • inline 函数必须用 static 修饰,涉及到编译,因为 inline 的含义有点像宏。而如果不用 static 修饰,那么其他文件的函数也可以调用它,那么就会导致不得不建立一个符号表项。因此声明为 static 实现起来更简单。
  • 内核使用的 C 语言用到了 ISO99 和 GUN C 扩展特性,其中几个用的比较广的 GUN C 扩展特性
    • 内联函数
    • 内联汇编

WSL 记录

概述

发现使用 wsl + docker + vs code(remote wsl + remote container 插件) 可以让开发、部署、运行整个流程变得更加方便。因此想写一篇文章记录接触 wsl 后了解到的一些知识以及遇到的一些问题以及解决方案(长期更新)。

字体分类

概述

目前还没有一种统一的字体分类方法。因此本文从易于理解的角度,使用 4 个维度对字体进行分类。

简单来说,确定了大小、粗细、样式和字体系列后便确定了一种字体。

font family

字体系列(font family) 表示符合同一种设计风格的字体的集合。比如大名鼎鼎的 Helvetica 字体就因醒目,清晰而广泛应用于广告,宣传语等场合。Time New Roman 也是一种使用非常广泛的字体。

一种字体系列包含多种变体字体,如 Times 包含 TimeIt(倾斜), TimeBd(加粗) 等。我们口语中说的某一种字体,其实通常说的是一种字体系列。

而对于现有的五花八门的字体系列,可以根据一些特点分成 5 类,也叫通用字体系列(generic font families)。如 Helvetica 就属于无衬线 (sans serif) 字体。

以下是字体系列的 5 种类别

在虚拟机上搭建 overleaf

概述

因为学校的 overleaf 无法通过 latex 代码定位到 pdf 位置。因此想在本地搭建一个 latex 环境,而如果在 windows 里安装 texlive+ 配置 VS code 比较麻烦,且以后重装系统又无法保留,因此考虑在 ubuntu 虚拟机里安装。

这里考虑同样搭建一个 overleaf,毕竟在主机里打开浏览器直接就能用就很方便,且界面也更美观。

overleaf 采用了 docker 进行部署,因此需要先安装 docker。


更新

在 virtualbox 虚拟机搭建后发现存在性能问题。以编译计组指导书为例,每次修改编译需要 1 分钟,并且经常出现编译 2-3 分钟然后失败的情况。

在查阅 wsl 性能比较后,发现 wsl2 的性能已经比较好了,有些场合接近原生 linux 的性能。参考Windows 10 May 2020 Performance For WSL vs. WSL2

于是补充了在 wsl2 上搭建的内容。

ucore lab3 总结

目录

  1. x86 中断/异常处理 (tss 和 tf 作用)
  2. 重要函数说明 1. 创建进程 (线程) 2. 调度,运行 3. do_wait & do_exit 4. do_execve 5. 其它系统调用
  3. FAQ 1. 一个进程被创建后如何开始执行? 2. 一个用户进程如果因为时间片用完而被调度掉后,再次被调度回来时会从哪里继续执行? 3. 一个进程的完整生命周期? 4. fork 调用,为什么父进程返回值为 pid,子进程返回值为 0(涉及到用户进程的创建

总结

实验 3 涉及到的知识点:

  1. x86 中断中 tss 的作用(类似的内核栈,中断帧的概念)
  2. 进程如何创建、执行、切换(do_fork, do_wait, do_exit 等系统调用的设计与实现)
  3. 用户进程的创建、执行过程
  4. 用户 ulib 库 fork, wait, exit 的实现

ucore lab2 总结

概述

回顾实验 1,我觉得需要学习的知识有:

  1. gdb工具的使用
  2. ELF文件的格式,以及 readelf, objdump, objcopy 工具的使用(查看各个 section,查看符号表)
  3. x86 汇编基础(通用寄存器,段寄存器,EIP,EFLAG 等),GCC内联汇编格式(AT&T 格式)
  4. x86 内存分段机制(GDT 表,段描述符)
  5. x86中断处理机制(IDT 表,中断向量 -> 中断处理程序)
  6. 操作系统启动过程(BIOS->bootloader(第一扇区)->Kernel 各阶段做的事情)

而做完实验 2 后,我觉得需要学习的知识有:

  1. BIOS 探测物理内存,Kernel 打开分页时自带一个页表
  2. 二级页表进行虚拟地址转换
  3. 物理内存管理 之 空闲块(以页为单位) 管理(Page 数据结构和物理页的对应,空闲块链表 free_list,FirstHit 分配连续的物理页)
  4. 虚拟内存管理 之 有效虚拟页(表示一个程序可能使用的虚拟地址段,vma, mm 数据结构)
  5. 导致PageFault的原因(1. pte=0 即从来没有被加载过,2. 被 swap out, 3. 访问权限错误)
  6. FIFO 页替换算法实现(page struct 增加 pra_page_link, pra_vaddr,mm->sm_priv(pra_list_header) 记录最近使用的页序列;swap_manager)

以下介绍一些难点

关于 2020 到目前的一个总结

2020 年对全人类来说是不平凡的一年,而对我来说也是不同寻常的一年。这一年发生了许多大大小小的事,有些事情身处其中时觉得非常不得了,而经历过后,又觉得也许很快就会忘记,因此想记录一些这一年我都干了些什么事。

Virtual Box 配置双网卡

由于目前在电脑上运行一个虚拟机已经比较流畅,而虚拟机又有着真机无法比拟的优势,如:

  • 可以快速地进行备份与还原,且备份的虚拟机镜像可以复制到其它电脑上
  • 可以保存虚拟机当前的运行状态(快速休眠),而下次启动时恢复运行(且速度很快)

因此我配置了一台 Ubuntu18.0 的虚拟机,并做了以下配置:

  • 配置了 build-essential, python3, git, vim 等常用软件
  • 配置了 zsh,以及一些 zsh 的插件如自动提示,命令高亮,auto-jump
  • 配置了 VS Code, Chromium 软件
  • 配置了 nodejs, cnpm, hexo 等,用于编写 hexo 博客
  • 启动 sshd,并配置网络

其中,关于最后一点。刚开始我采用了桥接网络 + 静态 ip 的方案。但是发现当主机网络环境改变时,ssh 便连接不上了,于是便有了这篇博客。

编译原理总结

目前我眼中的计算机层次

  • 计算机体系结构(RTL 级设计,流水线,缓存,多发射)
  • ISA(访存模式,中断/异常)
  • 汇编语言(寄存器 + 内存空间)
  • 高级语言(编译器)
  • 操作系统(内核:进程调度,同步与死锁,内存管理:页表,文件系统 应用:桌面系统,浏览器)

编译器各阶段

  1. 词法分析:解析源代码文件,输出记号流。 记号即指一个有独立意义的“单词”。空白字符便一般不包含意义,故可以省略。 这些单词一般可分类为:标识符,常数(整数,浮点数),字符常量,字符串, 操作符,界符,关键字。

  2. 语法分析:分析记号流,输出程序的逻辑结构,如语法分析树(或 AST 树) 一种语言的语法规则可以被文法产生式完全定义。 文法一般包括表达式的定义,各种语句的定义(if, while, for),变量声明, 函数定义等。

CloudView 平台运行 bad apple

重写了 bad-apple 的代码,利用了多进程的方式进行视频转换。

以下是各个配置转换 bad apple.mp4 的时间

线性执行 4 线程 1 进程 2 进程 4 进程 8 进程 16 进程
时间 (秒) 34.97 34.70 35.05 24.15 18.11 18.09 18

其中线程为采用 threading 库,进程则采用 multiprocessing 库

我的电脑配置为:

CPU intel(R) Core(TM) i5-7200U CPU @ 2.50GHz 2.70GHz
RAM 8.00 GB
Windows Windows10 家庭版 1909 18363.720

官方文档说明了 threading 库底层实现时仍只有一个线程,因而只适用于大量 I/O 并发的情况。而我们的图片转换成字符画的过程主要是计算密集型,因此基本没有改善性能。

而采用多进程时,刚好对应我电脑的 4 线程(2 核,采用超线程技术可以有 4 个线程,其实这里进程线程有点晕)时提升最大。

于是便想知道 32 个核时,能提升多少,便想在服务器上跑跑看。以下是配置运行过程。

出人意料的结果:

1 进程 4 线程 8 进程 16 进程 32 进程
时间 (秒) 51 24 23.22 21.57 21.14

可能进程多了后,写文件的速度反而成了瓶颈,查看 top 发现各个核的 cpu 利用率都只有 10% 左右,在代码中输出 cvt_frame.qsize() 也发现几乎都是满的。(在自己电脑上大多数都是 0,表明 cvt_frame 供不应求)

Use dlib in C++

use dlib in c++

从方法 1.1 到 1.3 都不必预编译 dlib 库,而是在使用 dlib 的项目中编译。

方法 1.4 介绍了将 dlib 预编译成静态库然后使用时会遇到的一些问题。

with CMake(officially recommend)

dlib/example/CMakeLists.txt

cmake_minimum_required(VERSION 2.8.12)

project(examples)

# Tell cmake we will need dlib.  This command will pull in dlib and compile it
# into your project.  Note that you don't need to compile or install dlib.  All
# cmake needs is the dlib source code folder and it will take care of everything.
add_subdirectory(./dlib dlib_build)

macro(add_example name)
   add_executable(${name} ${name}.cpp)
   target_link_libraries(${name} dlib::dlib )
endmacro()

# if an example requires GUI, call this macro to check DLIB_NO_GUI_SUPPORT to include or exclude
macro(add_gui_example name)
   if (DLIB_NO_GUI_SUPPORT)
      message("No GUI support, so we won't build the ${name} example.")
   else()
      add_example(${name})
   endif()
endmacro()

# 
add_gui_example(3d_point_cloud_ex)

命令行执行:

mkdir build
cd build
cmake .. -G "Visual Studio 14 2015 Win64" -T host=x64 #-T host=x64告诉CMake生成64bit的可执行文件。其实安装了最新的VisualStudio后,-G -T都不用指定,默认使用最新的VisualStudio,默认64位。
cmake --build . --config Release

使用 CMake GUI 可以设置一些选项,configure, generate 之后可以使用命令行执行最后一步 (不用打开 VisualStudio)

with GCC in terminal

g++ -std=c++11 -O3 -I.. ../dlib/all/source.cpp -lpthread -lX11 example_program_name.cpp

windows 下还需gdi32, comctl32, user32, winmm, ws2_32, or imm32

with VisualStudio

  • 把 dlib 的父目录添加到 include search path

    You should NOT add the dlib folder itself to your compiler's include path. Doing so will cause the build to fail because of name collisions (such as dlib/string.h and string.h from the standard library). Instead you should add the folder that contains the dlib folder to your include search path and then use include statements of the form #include or

    include "dlib/queue.h". This will ensure that everything builds correctly.

  • 把 dlib/all/source.cpp 添加到源文件

    If you are using Visual Studio you add .cpp files to your application using the solution explorer window. Specifically, right click on Source Files, then select Add -> Existing Item and select the .cpp files you want to add.

  • 如果需要 libjpeg 等,把 dlib/external 文件夹下的源文件添加到 project,并 define the DLIB_PNG_SUPPORT and DLIB_JPEG_SUPPORT preprocessor directives.

Installing dlib as a precompiled library

dlib 的 CMake 脚本包含 INSTALL target,因此可以像其它 C++ 库一样将 dlib 作为一个静态或动态库安装到系统上。

  • 调用 dlib 时出现 USER_ERROR inconsistent_build_configuration see_dlib_faq_2 错误。 需要将 build/dlib/config.h 文件拷贝到源码目录 dlib-19.17/dlib 进行覆盖。config.h 文件内有其说明。

配置 3d_face_reconstruction 总结

1. OpenCV

  1. windows 下载预编译版 (只含有 msvc 编译的版本)

  2. CMake 直接使用 find_packages 即可。(需添加环境变量,以便找到 OpenCV 的 config 文件)

2. dlib

  1. 官方推荐将代码直接包含到项目中编译(好处是没有 ABI 一致性问题)

    CMake 中 add_subdirectory(/path/to/dlib/top/dir) 即可。(甚至可以自动从网上下载)

3. boost

  1. 分为只含有头文件的库和需要独立编译的库。
  2. 官方编译方法为:
    1. 先 build 出 Boost.Build(可以看作 Boost 的一个 build 工具)
    2. 然后调用 b2 编译指定模块。(可以添加参数指定编译的库的路径)
    3. 我的编译目录是 stage/(库位于 stage/lib/下)
  3. CMake find_packages 暂时是失败的。

4. eos

  1. 只需要包含头文件即可 (header only),include 和 3rdparty
  2. 顶层目录下的 CMakeLists.txt 默认勾选了编译 example 下的示例(需要 boost 的 system, filesystem, program_options 和 openCV 的 core, higui, imgproc
  3. CMake 直接 add_subdirectory(/path/to/eos) 比较方便。

5. Qt

  1. 官方介绍了如何使用 CMake find_packages,自动 tic, moc 等。

TODO

  1. 学习 Qt,能自己写界面。
  2. 明白 eos 那个示例程序输入输出是什么
  3. 最后,自己来改写 eos 的程序,自己写 Qt 程序,最后展示出来。

安装使用 OpenCV(windows)

Install

安装预编译版

opencv.org上提供了源代码以及各个操作系统的预编译版。windows 预编译版为一个 installer 程序,运行后会将各种文件 extract 到安装目录。安装完成后,/path/to/opencv/包含两个目录——build, source。source 为源代码目录 (和直接下载源代码解压一模一样),build 下包含了许多其它文件。其中:

  • include/为调用 OpenCV 时需要包含的头文件 (源代码目录下也有)
  • x64/vcxx/下包含 bin/和 lib/,为预编译的库。vcxx 代表使用 MSVC xx 编译的,运行时需要对应的 runtime 库。

    IDE 编译器
    VS2017 VC15
    VS2015 VC14
    VS2013 VC12
    VS2012 VC11
    VS2010 VC10
    VS2008 VC9
    VS2005 VC8
  • etc/文件夹中是 OpenCV 某些算法所依赖的数据集。
  • OpenCVConfig.cmake文件在使用 CMake 编译项目时可使用 find_package 自动搜索头文件、库文件路径
  • 其它文件暂时不管

从源文件编译 OpenCV

Usage

通过 VisualStudio 编译 OpenCV 程序

  1. 菜单栏 project->properities。(或在右侧的 Solution Explorer 下找到一个 project(solution 和 project 的关系)。鼠标右键->Properities)

  2. C/C++/AdditionalIncludeDirectories。编译器从该文件夹下查找头文件。

    (p.s. #include "opencv2/opencv.hpp"#include "opencv.hpp"对应不同的设置)

  3. Linker/Input/AdditionalDependencies 添加opencv_world420.lib

  4. Linker/General/AdditionalLibraryDirectories 添加opencv_world420.lib所在目录。(或直接在 3 中输入绝对路径)

  5. 设置环境变量,把opencv_world420.dll所在目录添加到 Path 变量。(否则运行时报错:无法找到opencv_world420.dll)。不添加环境变量的方法是直接把opencv_world420.dll拷贝到 exe 所在目录。

    p.s. 貌似opencv_world420.lib表面上是一个静态链接库,但实际上是一个动态链接库,因而运行时需要opencv_world420.dll。而第 3 步如若改成添加opencv_world420.dll,则编译失败。

    LNK1107 could also occur if you attempt to pass a module (.dll or .netmodule extension created with /clr:noAssembly or /NOASSEMBLY) to the linker; pass the .obj file instead.

通过 CMake 编译 OpenCV 程序

参考:Using OpenCV with gcc and CMake

通过 CMake 编译 OpenCV 程序有几点好处:

  • 在 Windows 和 Linux 之间移植程序不用更改设置。
  • 自动搜索 include,lib 路径。(通过OpenCVConfig.cmake文件,需添加到 Path 环境变量)
  • 写代码的方式比 VisualStudio 点击按钮然后输入方便,配置的文件可以保留下来用于其它项目。

缺点:

步骤:

  1. 添加opencv/build/到 Path(OpenCVConfig.cmake所在目录)。

  2. 编写CMakeLists.txt。示例:

    cmake_minimum_required(VERSION 2.8)
    project( DisplayImage )
    find_package( OpenCV REQUIRED ) //
    add_executable( DisplayImage DisplayImage.cpp )
    target_link_libraries( DisplayImage ${OpenCV_LIBS} ) //
    
  3. 使用 CMake GUI,点击 configure(可以先 File->Delete Cache,删除掉之前的配置)

    选择 VS 的编译器。

  4. 在中间面板修改一些变量的值。(重新 configure)

  5. Generate,在 build 目录下生成 VS 的 sln 文件,双击打开。

  6. 在 ALL_BUILD 上右键 build。

运行 HPL

编译 BLAS/CBLAS

BLAS 为 fortran 接口的库,CBLAS 为 C 语言接口的库

 tar zxf blas-3.8.0.tgz 
 tar zxf cblas.tgz
 cd BLAS-3.8.0
 make #生成blas_LINUX.a
 cp blas_LINUX.a ../CBLAS
 cd ../CBLAS
 vi Makefile.in
 #修改
 # BLLIB = ../blas_LINUX.a
 make #在lib/下生成cblas_LINUX.a

编译 HPL

tar zxf hpl-2.3.tar.gz 
cd hpl-2.3
cp setup/Make.Linux_PII_CBLAS ./Make.<arch> #在top-level 文件夹下
vi Make.<arch> #修改
make <arch> #在bin/<arch>/下生成HPL.dat xhpl

Make.示例

OpenMPI+CBLAS
ARCH         = <arch>

TOPdir       = /home/mpiuser/hpl-2.3

MPdir        = /path/to/OpenMPI
MPinc        = -I$(MPdir)/include
MPlib        = $(MPdir)/lib/libmpi.so

LAdir        = 
LAinc        = 
LAlib        = path/to/cblas_LINUX.a path/to/blas_LINUX.a #顺序不能改变,cblas依赖blas

HPL_OPTS     = -DHPL_CALL_CBLAS 

CC           = mpicc
LINKER       = mpif77

HPL 优化

配置参数

计算次数:

\[ \#Ns \times \#NBs \times \#(Ps, Qs) \times \#PFACTs \times \#NBMINs \times \#BCASTs \times \#DEPTHs \]
HPLinpack benchmark input file
Innovative Computing Laboratory, University of Tennessee
HPL.out      output file name (if any)
6            device out (6=stdout,7=stderr,file)
1            # of problems sizes (N)
8192         Ns
1            # of NBs
128          NBs
0            PMAP process mapping (0=Row-,1=Column-major)
3            # of process grids (P x Q)
2 1 4        Ps
2 4 1        Qs
16.0         threshold
3            # of panel fact
0 1 2        PFACTs (0=left, 1=Crout, 2=Right)
2            # of recursive stopping criterium
2 4          NBMINs (>= 1)
2            # of panels in recursion
2            NDIVs
3            # of recursive panel fact.
0 1 2        RFACTs (0=left, 1=Crout, 2=Right)
1            # of broadcast
0            BCASTs (0=1rg,1=1rM,2=2rg,3=2rM,4=Lng,5=LnM)
1            # of lookahead depth
0            DEPTHs (>=0)
2            SWAP (0=bin-exch,1=long,2=mix)
64           swapping threshold
0            L1 in (0=transposed,1=no-transposed) form
0            U  in (0=transposed,1=no-transposed) form
1            Equilibration (0=no,1=yes)
8            memory alignment in double (> 0)

记录一次失眠

今天查了一些关于保研的事, 晚上有点失眠。 我回顾了以往的事, 有初中还有高中。

我回忆起那时自己是怎样的模样, 我感慨有些事不记录也许就真的忘了。 我明白如果不经常眺望一下远方, 而只是低头看着现在, 时间也许眨眼就过去了。

要对短期的未来有所计划, 计划不是一个严格的日程表, 让自己失去自由, 而是明白你最近最想做什么, 你现在想做什么。

我想要: 1 我想要练习口语,为夏令营的英语自我介绍做准备 2 我想要联系一些同学,和他们聊聊未来。必定有所收获。 3 我想有一个爱好,如学会基础的日语。 4 我想在编程能力(数据结构和算法)上有所提高。

我发现列出愿望能让自己更加明确方向。

井字棋与最大最小值算法

经过昨晚,和今天早上 8 点醒来趴在床上努力的思考。我终于想起了如何使用数学归纳法证明策梅洛定理。然后经过一上午的努力,我成功使用最大最小值算法实现了井字棋 (Tic-Tak-Toe) 的 AI,然后添加到 wwh 的井字棋代码中。

RISC-V 报告

author: 计科卓越班 袁福焱 20174260

0. 导言

RISC-V 是 21 世纪诞生的一种完全开放的计算机精简指令集,可应用于小到嵌入式系统大到高性能处理器等广泛领域。RISC-V 充分汲取了许多指令集的实践经验,具有非常多的优点。并且它完全开放,采用 BSD 开源协议——全世界任何公司、大学、个人都可以自由免费地使用 RISC-V 指令集构建自己的系统。

本报告第一部分讲述 RISC-V 的几个主要特点与优势,第二部分介绍 RISC-V 的目前已构建建的生态系统。最后一部分,谈论 RISC-V 在推动开源硬件中起到的作用。

1. RISC-V 的特点

1.1 指令集模块化

以往的指令集为了实现兼容,往往采用增量扩展的方法。即新的处理器不仅要实现新的扩展,还必需要实现过去所有的扩展。如典型的 x86 架构,其中许多指令早已失效,但为了兼容性却必须实现,这导致硬件实现变得越来越复杂。RISC-V 按照功能将指令集分为很多个模块。其中 I 模块为基础整型指令集,这是 RISC-V 要求必须实现的指令集,并且永远不会发生改变。只要实现了该指令集便可以运行完整的基于 RISC-V 的软件。这样,RISC-V 在扩展时便不再会像 x86 那样背上沉重的历史包裹。编译器也可以在保证兼容的基础上,根据已实现的扩展,选择性地生成更高效的代码。

1.2 回避以往的设计缺陷

目前主流的指令集有 x86, ARM, MIPS 等。其中 x86 为复杂指令集 (RISC),于 1978 年诞生。而 ARM, MIPS 为精简指令集 (RISC),都是 1986 年诞生的。从计算机体系结构的发展上来看,RISC 由于简洁性,能够更加充分地利用现代微体系结构中的流水线、分支预测、cache 等技术,因此比 CISC 更加高效。因此,诞生于 21 世纪 (2004) 的 RISC-V,必然也是精简指令集。并且,由于有了前面指令的经验,RISC-V 吸收了以往指令集的优点,并回避了以往指令集的一些缺陷。优点如,RISC-V 有 32 个通用寄存器,有专门的 0 寄存器。使用专门的移位指令来处理移位操作。使用 load/store 指令访问内存。跳转指令不使用条件码。缺陷则如,RISC-V 废弃了 MIPS 中的分支延迟槽 (有利于架构和实现的分离),立即数只进行有符号扩展,算数运算不抛出异常 (通过软件判断),更规整的指令格式(源及目的字段位置固定),整数乘除法可选 (简化了基础实现)。

1.3 完全开放

RISC-V 采用 BSD 开源协议,任何人都可以自由免费地使用 RISC-V。其他指令集如 x86 是完全闭源的,只有 intel 和 AMD 等少数公司能够基于 x86 架构生产产品。而 ARM 和 MIPS 都采用授权的方式,通过卖授权的方式盈利,其中 ARM 的授权费则十分昂贵。RISC-V 如今由 RISC-V 基金会维护,任何公司只要愿意每年支付一笔会员费即可成为会员,会员可以参与到 RISC-V 之后标准的制定中。RISC-V 也因此真正成为一个开放的指令集,不会因为任何一个公司的起伏而受到影响。

2. RISC-V 的生态

以下内容引用自开放指令集与开源芯片进展报告-v1p1

2011 年,加州大学伯克利分校发布了开放指令集 RISC-V,并很快建立起一个开源软硬件生态系统。截止 2019 年 1 月,已有包括 Google、NVidia 等在内的 200 多个公司和高校在资助和参与 RISC-V 项目。其中,部分企业已经开始将 RISC-V 集成到产品中。例如全球第一大硬盘厂商西部数据(Western Digital)最近宣布将把每年各类存储产品中嵌入的 10 亿个处理器核换成 RISC-V;Google 利用 RISC-V 来实现主板控制模块;NVidia 也将在 GPU 上引入 RISC-V 等。此外,国内阿里巴巴、华为、联想等公司都在逐步研究各自的 RISC-V 实现;上海市将 RISC-V 列为重点扶持项目;印度政府也正在大力资助基于 RISC-V 的处理器项目,使 RISC-V 成为了印度的事实国家指令集。

由此可见,RISC-V 国内外的兴起使得目前 RISC-V 的生态已经比较完善了。

3. 之于构建开放硬件生态的意义

2019 年度国际计算机体系结构旗舰会议 ISCA 于 6 月在美国亚利桑那州凤凰城召开,会议的主题即是“面向下一代计算的敏捷开放硬件(Agile and Open Hardware for Next-Generation Computing)”。由此可见开放硬件,敏捷开发已成为未来的趋势。而 RISC-V 则刚好成为这趋势中不可或缺的一环。

目前开源硬件开发中面临着 4 个关键问题 (Yungang Bao, Chinese Academy of Sciences, The Four Steps to An Open-Source Chip Design Ecosystem):

  1. 开放的指令集 ISA/开源 IPs/开源 Socs
  2. 硬件描述语言 Lanuages/开源的 EDA 工具
  3. 验证和仿真 Vertification/Simulation
  4. OS/Complier

RISC-V 便处于第一环中,

虽然目前每个环节都还未完全解决,但我们可以相信,在未来,开发硬件可以像开发一个软件一样。充分利用已开源的资源,用户只需要定制 10% 以内的代码,便可以以月为单位开发出客制化的硬件系统。

4. 参考资料

大道至简——RISC-V 架构之魂(上)

大道至简——RISC-V 架构之魂(中)

大道至简——RISC-V 架构之魂(下)

开放指令集与开源芯片进展报告-v1p1(2019-02-22 更新)

远景研讨会纪要–面向下一代计算的开源芯片与敏捷开发

嵌入式技术基础与实践 第五版

心得

  1. 为什么单片机可以控制灯的亮灭,通用 PC 却没办法控制灯呢?(因为没有 CPU 到 LED 的接口也没有驱动程序)

  2. 中断向量机制和 MIPS 采用的统一入口地址不太一样。中断向量机制硬件产生中断请求时,中断号是确定了的(通过中断控制器发送中断号),软件可以配置对应中断服务程序。而 MIPS 没有中断号的概念,通过软件检查 Stastus 寄存器,来确定是哪一个中断,跳转到对应中断服务程序。

    中断向量机制的 CPU 通常需要一个中断控制器,连接在总线上。中断控制器会给 CPU 提供中断号。

  3. MCU 本质就是一个微型计算机,包含 CPU,存储器,I/O 接口(控制器模块)。嵌入式开发就是要基于 MCU 搭建一个硬件系统(通过 MCU 的 I/O 接口连接各种需要被控制的外设),并逐层向上编写构件(驱动),最后基于构件编写嵌入式应用,从而完成需要的功能。

  4. MCU 由于需要通用性,使得用户可以基于 MCU 去搭建自己的系统,驱动自己的外设,因此每个引脚都复用多个功能。通过配置 MCU 内对应控制器的寄存器来配置引脚的功能。

    比如 MSP432 100 个引脚中有 84 个可以配置成 GPIO 引脚。只需要将 MSP432 分配的 GPIO 寄存器中的 PxSEL0, PxSEL1 寄存器(x 表示端口号,有 11 个端口 P1~P10 和 PJ)都置为 0 时,该端口的引脚即为 GPIO 功能。同样可以通过配置 GPIO 寄存器控制引脚方向作为输入还是输出,用于点亮 LED 灯或者接受开关输入。

    再比如 MSP432 有 4 个增强型通用串行通信接口模块 (eUSCI),也分配了若干寄存器。通过配置寄存器可以将对应引脚配置为 UART 的引脚或 SPI 的引脚。

    但是我们在 FPGA 上搭建 SoC 时,开发板上的灯、拨码开关和 FGPA 芯片的引脚是连好了的(由开发板的提供商基于 FPGA 芯片搭建),也就是说每个引脚作为输入还是输出是已经确定了的。我们能改变的只是——是否使用该外设。当然,不排除保留了一些引脚被接到扩展插口上(那一排排的黑色插孔)。因此在 FPGA 上搭建 SoC 后,实现外设驱动需要对外设的控制器进行配置。而嵌入式开发还多出了配置 MCU 内用于复用的模块寄存器。

非对齐指令总结

1 大端与小端

  • 大端:数据的高位位于内存的低地址,数据的地位位于内存的高地址。
  • 小端:与大端相反。

事实上大端更符合人的思维,因为在写一个数字时人们更习惯从高位写到低位,且高位是写在左边的。而这可以直接和内存的“图像”对应,而小端则需要把每个字的字节地址颠倒一下才可以。 例如,一个 32 位的整数 0xffeeddcc,在内存里的存储情况如下。(非对齐,起始地址为 0x01)

大端: 0 1 2 3 0x00 00ffeedd 0x01 cc556677 小端: 3 2 1 0 0x00 eeddcc00 0x01 776655ff ps.假设除去该整数存储的空间,字节地址与存储的十六进制数对应,即地址为 0x01 存 11,0x05 存 55,这点用来突出字节地址是多少。

2 非对齐指令

instruction lwl/swl, lwr/swr usage lwl t0, offset(s0)

其中的 left,与 shift left 的“左”都表示数据的高位。right 则表示数据的低位。

lwl 表示将内存对应地址所在的字(align_address,即抹掉地址低两位对应地址)的数据低位复制到寄存器数据的高位。寄存器其它部分不变。

swl 则表示将寄存器的数据高位复制到内存对应数据低位。其它部分不变。 即 lwl/swl 寄存器数据高位↹内存数据低位 lwr/swr 寄存器数据低位↹内存数据高位

1 从#1 中读取该整数实例

大端 lwl s0, 0(1) lwr s0, 3(1) 小端 lwr s0, 0(1) lwl s0, 3(1)

将整数以图中所示存储到内存中只需将 lw*改为 sw*。

可以以此定义非对齐加载、存储伪指令, 如 uld(rd, rb) 表示非对齐 load,加载内存[rb+3:rb]的数据, uld(rd, rb)=> lwl rd, 0(rb) lwr rd, 3(rb) (可能 uld 是加载 8 字节,待确认)

2 关于 load/store 多少字节

n 为地址低 2 位,n=addr[1:0] 大端 lwl/swl 4-n lwr/swr n+1 小端 lwr/swr 4-n lwl/swl n+1