概述

本lab是6.828默认的最后一个实验,围绕网络展开。主要就做一件事

从0实现网络驱动。

还提到一些比较重要的概念:

  1. 内存映射I/O
  2. DMA
  3. 用户级线程实现原理

The Network Server

从0开始写协议栈是十分困难的,我们将使用IwIP,这是一种轻量级TCP/IP的实现,更多的IwIP信息可以参考lwIP官网。对于我们来说IwIP就像是实现了BSD socket接口的黑盒,分别有一个包输入和包输出端口。

JOS网络服务由四个进程组成:

image-20210901212107460

  1. 核心网络进程:

    核心网络进程由socket调用分发器和IwIP组成。socket调用分发器和文件服务一样。用户进程发送IPC消息给核心网络进程。

    用户进程不直接使用nsipc_*开头的函数调用,而是使用lib/socket.c中的函数。这样用户进程通过文件描述符来访问socket。

    文件服务和网络服务有很多相似的地方,但是最大的不同点在于,BSD socket调用accept和recv可能会阻塞,如果分发调用IwIP这些阻塞的函数,自己也会阻塞,这样就只能提供一个网络服务了。显然是不能接受的,网络服务将使用用户级的线程来避免这种情况。

  2. 包输出进程:

    IwIP通过IPC发送packets到输出进程,然后输出进程负责通过系统调用将这些packets转发给设备驱动。

  3. 包输入进程:

    对于每个从设备驱动受到的packets,输入进程从内核取出这些packet,然后使用IPC转发给核心网络进程。

  4. 定时器进程:

    定时器进程周期性地发消息给核心网络进程,通知它一段时间已经过了,这些消息被IwIP用来是实现网络超时。

仔细看上图,绿颜色的部分是本lab需要实现的部分。分别是:

  1. E1000网卡驱动,并对外提供两个系统调用,分别用来接受和发送数据。
  2. 输入进程
  3. 输出进程
  4. 用户程序httpd的一部分

Part A: Initialization and transmitting packets

内核目前还没有时间的概念,硬件每隔10ms都会发送一个时钟中断。每次时钟中断,我们可以给某个变量加一,来表明时间过去了10ms,具体实现在kern/time.c中

exercise1

在kern/trap.c中添加对time_tick的调用。实现sys_time_mesc()系统调用。sys_time_msec()配合sys_yield()实现sleep()(见user/testtime.c)。

//kern/trap.c
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
lapic_eoi();
time_tick();
sched_yield();
return;
}

//kern/syscall.c
static int
sys_time_msec(void)
{
// LAB 6: Your code here.
//panic("sys_time_msec not implemented");
return time_msec();
}

case (SYS_time_msec):
return sys_time_msec();

The Network Interface Card

编写驱动我们需要很深的硬件以及硬件接口知识,本lab会提供一些E1000比较底层的知识,我们需要学会看E1000的开发者手册

PCI Interface

PCI是外围设备互联(Peripheral Component Interconnect)的简称,是在目前计算机系统中得到广泛应用的通用总线接口标准:

  • 在一个PCI系统中,最多可以有256根总线,一般主机上只会用到其中很少的几条
  • 在一根PCI总线上可以连接多个物理设备,可以是一个网卡、显卡或者声卡等,最多不超过32个
  • 一个PCI物理设备可以有多个功能,比如同时提供视频解析和声音解析,最多可提供8个功能
  • 每个功能对于一个256字节的PCI配置空间

E1000是PCI设备,意味着E1000将插到主板上的PCI总线上。PCI总线有地址,数据,中断线允许CPU和PCI设备进行交互。PCI设备在被使用前需要被发现和初始化。发现的过程是遍历PCI总线寻找相应的设备。初始化的过程是分配I/O和内存空间,包括协商IRQ线。

我们已经在kern/pic.c提供了PCI代码。为了在启动阶段初始化PCI,PCI代码遍历PCI总线寻找设备,当它找到一个设备,便会读取该设备的厂商ID和设备ID,然后使用这两个值作为键搜索pci_attach_vendor数组,该数组由struct pci_driver结构组成。struct pci_driver结构如下:

struct pci_driver {
uint32_t key1, key2;
int (*attachfn) (struct pci_func *pcif);
};

如果找到一个struct pci_driver结构,PCI代码将会执行struct pci_driver结构的attachfn函数指针指向的函数执行初始化。attachfn函数指针指向的函数传入一个struct pci_func结构指针。struct pci_func结构的结构如下:

struct pci_func {
struct pci_bus *bus;

uint32_t dev;
uint32_t func;

uint32_t dev_id;
uint32_t dev_class;

uint32_t reg_base[6];
uint32_t reg_size[6];
uint8_t irq_line;
};

其中reg_base数组保存了内存映射I/O的基地址,reg_size保存了以字节为单位的大小。irq_line包含了IRQ线。当attachfn函数指针指向的函数执行后,该设备就算被找到了,但还没有启用,attachfn函数指针指向的函数应该调用pci_func_enable(),该函数启动设备,协商资源,并且填入传入的struct pci_func结构。

exercise3

实现attach函数来初始化E1000。在kern/pci.c的pci_attach_vendor数组中添加一个元素。82540EM的厂商ID和设备ID可以在手册5.2节找到。实验已经提供了kern/e1000.c和kern/e1000.h,补充这两个文件完成实验。添加一个函数,并将该函数地址添加到pci_attach_vendor这个数组中。

根据手册和内核的启动信息我们能够找到E1000 的 Vender ID = 0x8086, Device ID = 0x100E。

那么我们修改我们的e1000.h文件:

#ifndef JOS_KERN_E1000_H
#define JOS_KERN_E1000_H

#include <kern/pci.h>

#define E1000_VENDER_ID_82540EM 0x8086
#define E1000_DEV_ID_82540EM 0X100E

int e1000_attachfn(struct pci_func *pcif);
#endif // SOL >= 6

然后是e1000.c文件加上初始化函数:

#include <kern/e1000.h>
#include <kern/pmap.h>

// LAB 6: Your driver code here
int
e1000_attachfn(struct pci_func *pcif)
{
pci_func_enable(pcif);
return 0;
}

在kern/pci.c的pci_attach_vendor数组中添加一个元素:

struct pci_driver pci_attach_vendor[] = {
{ E1000_VENDER_ID_82540EM, E1000_DEV_ID_82540EM, &e1000_attachfn},
{ 0, 0, 0 },
};

Memory-mapped I/O

程序通过内存映射I/O(MMIO)和E1000交互。通过MMIO这种方式,允许通过读写”memory”进行控制设备,这里的”memory”并非DRAM,而是直接读写设备。pci_func_enable()协商MMIO范围,并将基地址和大小保存在基地址寄存器0(reg_base[0] and reg_size[0])中,这是一个物理地址范围,我们需要通过虚拟地址来访问,所以需要创建一个新的内核内存映射。

exercise4

我们在table 13.2中能查到状态寄存器的offset。

image-20210902140414667

使用mmio_map_region()建立内存映射。至此我们能通过虚拟地址bar_va来访问E1000的寄存器。

#include <kern/e1000.h>
#include <kern/pmap.h>
volatile void *bar_va;

#define E1000REG(offset) (void *)(bar_va + offset)
// LAB 6: Your driver code here
int
e1000_attachfn(struct pci_func *pcif)
{
pci_func_enable(pcif);
//该函数从线性地址MMIOBASE开始映射物理地址pa开始的size大小的内存,并返回pa对应的线性地址。
bar_va = mmio_map_region(pcif->reg_base[0],pcif->reg_size[0]);
uint32_t *status_reg = (uint32_t *)E1000REG(E1000_STATUS);
assert(*status_reg == 0x80080783);
return 0;
}

lab3和lab4的结果是,我们可以通过直接访问bar_va开始的内存区域来设置E1000的特性和工作方式。

image-20210902141026050

DMA

DMA是什么?简单的说就是允许外部设备直接访问内存,而不需要CPU的参与。

我们可以通过读写E1000的寄存器来发送和接受数据包,但是这种方式非常慢。E1000使用DMA直接读写内存,不需要CPU参与。驱动负责分配内存作为发送和接受队列,设置DMA描述符,配置E1000这些队列的设置,之后的操作都是异步的。

发送一个数据包:驱动将该数据包拷贝到发送队列中的一个DMA描述符中,通知E1000,E1000从发送队列的DMA描述符中拿到数据发送出去。

接受数据包:E1000将数据拷贝到接受队列的一个DMA描述符中,驱动可以从该DMA描述符中读取数据包。

发送和接受队列非常相似,都由DMA描述符组成,DMA描述符的确切结构不是固定的,但是都包含一些标志和包数据的物理地址。发送和接受队列可以由环形数组实现,都有一个头指针和尾指针。

这些数组的指针和描述符中的包缓冲地址都应该是物理地址,因为硬件操作DMA读写物理内存不需要通过MMU。

Transmitting Packets

首先我们需要初始化E1000来支持发送包。第一步是建立发送队列,队列的具体结构在3.4节,描述符的结构在3.3.3节。驱动必须为发送描述符数组和数据缓冲区域分配内存。有多种方式分配数据缓冲区。最简单的是在驱动初始化的时候就为每一个描述符分配一个对应的数据缓冲区。最大的包是1518字节。

发送队列和发送队列描述符如下:

发送队列

发送队列描述符

exercise5

按照14.5节的描述来初始化。步骤如下

  1. 分配一块内存用作发送描述符队列,起始地址要16字节对齐。用基地址(TDBAL/TDBAH)寄存器。
  2. 设置(TDLEN)寄存器,该寄存器保存发送描述符队列长度,必须128字节对齐。
  3. 设置(TDH/TDT)寄存器,这两个寄存器都是发送描述符队列的下标。分别指向头部和尾部。应该初始化为0。
  4. 初始化TCTL寄存器。设置TCTL.EN位为1,设置TCTL.PSP位为1。设置TCTL.CT为10h。设置TCTL.COLD为40H
  5. 设置TIPG寄存器
#include <kern/e1000.h>
#include <kern/pmap.h>

// LAB 6: Your driver code here
volatile void *bar_va;
struct e1000_tdh *tdh;
struct e1000_tdt *tdt;
struct e1000_tdlen *tdlen;
struct e1000_tx_desc tx_desc_array[TXDESCS];
char tx_buffer_array[TXDESCS][TX_PKT_SIZE];


#define E1000REG(offset) (void *)(bar_va + offset)


int
e1000_attachfn(struct pci_func *pcif)
{
pci_func_enable(pcif);
//该函数从线性地址MMIOBASE开始映射物理地址pa开始的size大小的内存,并返回pa对应的线性地址。
cprintf("reg_base:%x, reg_size:%x\n", pcif->reg_base[0], pcif->reg_size[0]);
bar_va = mmio_map_region(pcif->reg_base[0],pcif->reg_size[0]);
uint32_t *status_reg = (uint32_t *)E1000REG(E1000_STATUS);
assert(*status_reg == 0x80080783);
e1000_transmit_init();
return 0;
}

static void
e1000_transmit_init()
{
int i;
for(int i = 0; i < TXDESCS;i++){
tx_desc_array[i].addr = PADDR(tx_buffer_array[i]);
tx_desc_array[i].cmd = 0;
tx_desc_array[i].status |= E1000_TXD_STAT_DD;
}

//设置队列长度寄存器
tdlen = (struct e1000_tdlen *)E1000REG(E1000_TDLEN);
tdlen->len = TXDESCS;

//设置队列基址低32位
uint32_t *tdbal = (uint32_t *)E1000REG(E1000_TDBAL);
*tdbal = PADDR(tx_desc_array);

//设置队列基址高32位
uint32_t *tdbah = (uint32_t *)E1000REG(E1000_TDBAH);
*tdbah = 0;

//设置头指针寄存器
tdh = (struct e1000_tdh *)E1000REG(E1000_TDH);
tdh->tdh = 0;

//设置尾指针寄存器
tdt = (struct e1000_tdt *)E1000REG(E1000_TDT);
tdt->tdt = 0;

//TCTL register
struct e1000_tctl *tctl = (struct e1000_tctl *)E1000REG(E1000_TCTL);
tctl->en = 1;
tctl->psp = 1;
tctl->ct = 0x10;
tctl->cold = 0x40;

//TIPG register
struct e1000_tipg *tipg = (struct e1000_tipg *)E1000REG(E1000_TIPG);
tipg->ipgt = 10;
tipg->ipgr1 = 4;
tipg->ipgr2 = 6;
}

现在初始化已经完成,接着需要编写代码发送数据包,提供系统调用给用户代码使用。要发送一个数据包,需要将数据拷贝到数据下一个数据缓冲区,然后更新TDT寄存器来通知网卡新的数据包已经就绪。

exercise6

编写发送数据包的函数,处理好发送队列已满的情况。如果发送队列满了怎么办?

怎么检测发送队列已满;如果设置了发送描述符的RS位,那么当网卡发送了一个描述符指向的数据包后,会设置该描述符的DD位,通过这个标志位就能知道某个描述符是否能被回收。检测到当发送队列已满后怎么办:可以简单的丢弃准备发送的数据包。也可以告诉用户进程进程当前发送队列已满,请重试,就像sys_ipc_try_send()一样。我们采用重试的方式。

int 
e1000_transmit(void *data,size_t len)
{
uint32_t current = tdt->tdt; //tail index in queue
if(!(tx_desc_array[current].status & E1000_TXD_STAT_DD)){
return -E_TRANSMIT_RETRY;
}
tx_desc_array[current].length = len;
tx_desc_array[current].status &= ~E1000_TXD_STAT_DD;
tx_desc_array[current].cmd |= (E1000_TXD_CMD_EOP | E1000_TXD_CMD_RS);
memcpy(tx_buffer_array[current],data,len);
uint32_t next = (current + 1) % TXDESCS;
tdt->tdt = next;
return 0;
}

驱动工作方式

对于发送队列来说是一个典型的生产者-消费者模型:

  1. 生产者:用户进程。通过系统调用往tail指向的描述符的缓冲区添加数据包,并且移动tail。
  2. 消费者:网卡。通过DMA的方式直接从head指向的描述符对应的缓冲区拿包发送出去,并移动head

接受队列也类似

exercise7

实现发送数据包的系统调用,我们已经添加过很多次了,这里就不贴代码了

Transmitting Packets: Network Server

输出协助进程的任务是,执行一个无限循环,在该循环中接受核心网络进程的IPC请求,解析该请求,然后使用系统调用发送数据。

exercise8

实现net/output.c

#include "ns.h"
#include <inc/lib.h>

extern union Nsipc nsipcbuf;

void
output(envid_t ns_envid)
{
binaryname = "ns_output";

// LAB 6: Your code here:
// - read a packet from the network server
// - send the packet to the device driver
uint32_t whom;
int perm;
int32_t req;
while(1){
req = ipc_recv((envid_t*)&whom,&nsipcbuf,&perm);//接受核心网络进程发来的请求
if(req != NSREQ_OUTPUT){
cprintf("not a nsreq output!\n");
continue;
}
struct jif_pkt *pkt = &(nsipcbuf.pkt);
while(sys_pkt_send(pkt->jp_data,pkt->jp_len) < 0){//通过系统调用发送数据包
sys_yield();
}
}

}

Part B: Receiving packets and the web server

Receiving Packets

像发送过程一样,需要配置E1000去接受数据包,同时提供一个接受描述符队列,接受缓冲区。3.2节描述了数据包接受的工作原理,包括接受队列和接受描述符,初始化过程详见14.4节。

exercise9

阅读文档我们可以写出接受描述符的结构体

struct e1000_rx_desc {
uint64_t addr;
uint16_t length;
uint16_t chksum;
uint8_t status;
uint8_t errors;
uint16_t special;
}__attribute__((packed));

当E1000收到数据包时,它首先检查它是否与卡配置的过滤器匹配(例如,查看数据包中MAC地址与网卡是否匹配),如果数据包与任意一个过滤器不匹配,则忽略该数据包。 否则,E1000尝试从接收队列的头部检索下一个接收描述符。 如果头部(RDH)已经赶上尾部(RDT),则接收队列没有空闲描述符,网卡将会丢弃数据包。 如果存在空闲接收描述符,则将分组数据复制到描述符指向的缓冲区中,设置描述符的DD(描述符完成)和EOP(包结束)状态位,并递增 RDH。

如果 E1000 接收到一个数据包,其大小大于数据包缓冲区大小,它将从接收队列中检索所需数量的描述符以完整存储数据包。 为了表明发生这种情况,它将在所有这些描述符上设置DD状态位,但仅在最后一个描述符上设置EOP状态位。 我们可以选择在驱动程序中处理这种可能性,或者只是将网卡配置为不接受“长数据包”(也称为巨型帧)并确保接收缓冲区足够大以存储最大可能的标准以太网数据包(1518字节)。

exercise10

这部分与发送部分代码编写流程是类似的,查文档,为接收描述符、接收buffer 分配静态内存等,然后对接收描述符、E1000 RCTL等寄存器进行初始化。

static void
get_ra_address(uint32_t mac[],uint32_t *ral,uint32_t *rah)
{
uint32_t low = 0,high = 0;
int i;
for(i = 0; i < 4; i++){
low |= mac[i] << (8 * i);
}

for(i = 4; i < 6; i++){
high |= mac[i] << (8 * i);
}

*ral = low;
*rah = high | E1000_RAH_AV;
}
static void
e1000_receive_init()
{
//RDBAL and RDBAH register
uint32_t *rdbal = (uint32_t *)E1000REG(E1000_RDBAL);
uint32_t *rdbah = (uint32_t *)E1000REG(E1000_RDBAH);

*rdbah = 0;
*rdbal = PADDR(rx_desc_array);

int i;
for(i = 0;i < RXDESCS; i++){
rx_desc_array[i].addr = PADDR(rx_buffer_array[i]);
}

//RDLEN register
rdlen = (struct e1000_rdlen *)E1000REG(E1000_RDLEN);
rdlen->len = RXDESCS;

//RDH and RDT register
rdh = (struct e1000_rdh *)E1000REG(E1000_RDH);
rdt = (struct e1000_rdt *)E1000REG(E1000_RDT);

rdh->rdh = 0;
rdt->rdt = RXDESCS - 1;

uint32_t *rctl = (uint32_t *)E1000REG(E1000_RCTL);
*rctl = E1000_RCTL_BAM | E1000_RCTL_EN | E1000_RCTL_SECRC;

uint32_t *ra = (uint32_t *)E1000REG(E1000_RA);
uint32_t ral,rah;
get_ra_address(E1000_MAC,&ral,&rah);
ra[0] = rah;
ra[1] = ral;
}

现在我们已经准备好开始接受数据包。要接收数据包,我们的驱动程序必须跟踪希望被保留下来接受数据包的下一个描述符。与发送类似,文档指出无法从软件中可靠地读取RDH寄存器,因此为了确定数据包是否已传动到此描述符的数据包缓冲区,我们必须读取描述符中的DD状态位。如果DD位已经置位(此由硬件完成),则可以将数据包数据从该描述符的数据包缓冲区中复制出来,然后通过更新队列的尾部索引RDT告诉卡该描述符是空闲的。

如果DD位未置位,则表示此时此刻未收到任何数据包。 在这种情况下我们可以只需返回“try again”错误,并要求调用者重试。

exercise11

receive的实现,最重要的一点就是理解硬件接受数据包的过程:当硬件接收到数据包时,首先会进行一次过滤(比对MAC地址等),若符合接受标准,硬件会将数据包存储到我们分配的buffer中,并同时设置描述符的DD位以及执行RDH加1操作。所以当我们编写receive函数时,可以选择定义一个static变量,用来指向第一个可接收的描述符。系统调用的实现就不再详细给出。receive的实现如下:

int
e1000_receive(void *addr,size_t *len)
{
static int32_t next = 0;
if(!(rx_desc_array[next].status && E1000_RXD_STAT_DD)){ //simply tell client to retry
return -E_RECEIVE_RETRY;
}
if(rx_desc_array[next].errors){
cprintf("receive error\n");
return -E_RECEIVE_RETRY;
}
*len = rx_desc_array[next].length;
memcpy(addr,rx_buffer_array[next],*len);

next = (next + 1) % RXDESCS;
rdt->rdt = (rdt->rdt + 1) % RXDESCS;
return 0;
}

Receiving Packets: Network Server

在网络服务器输入环境中,您将需要使用新的接受系统调用来接受数据包,并使用NSREQ_INPUTIPC信息将它们传送到核心网络服务器环境。这些IPC输入消息应该有一个页面附加一个union Nsipc,其struct jif_pkt pkt字段填入从网络接受的数据包。

exercise12

#include "ns.h"
#include <kern/e1000.h>
extern union Nsipc nsipcbuf;

void
sleep(int msec)
{
unsigned now = sys_time_msec();
unsigned end = now + msec;

if ((int)now < 0 && (int)now > -MAXERROR)
panic("sys_time_msec: %e", (int)now);

while (sys_time_msec() < end)
sys_yield();
}


void
input(envid_t ns_envid)
{
binaryname = "ns_input";

// LAB 6: Your code here:
// - read a packet from the device driver
// - send it to the network server
// Hint: When you IPC a page to the network server, it will be
// reading from it for a while, so don't immediately receive
// another packet in to the same physical page.
size_t len;
char buf[RX_PKT_SIZE];
while(1){
if(sys_pkt_recv(buf,&len) < 0){
continue;
}
memcpy(nsipcbuf.pkt.jp_data,buf,len);
nsipcbuf.pkt.jp_len = len;
ipc_send(ns_envid,NSREQ_INPUT,&nsipcbuf,PTE_P | PTE_U | PTE_W);
sleep(50);
}
}

为了更彻底地测试我们的网络代码,源码已经提供了一个名为echosrv的守护进程,它设置一个在 port 7上运行的echo服务器,它将回显通过TCP连接发送的任何内容。我们在一个终端执行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-echosrv 开启 echo 服务器, 在另一个终端执行 make nc-7连接 echo 服务器。可以看到 nc 端的消息回显。

Question

  1. 你是如何构建接收实现的? 特别是,如果接收队列为空并且用户环境请求下一个传入数据包,你会怎么做?

    用户请求接收时,若接受不成功(队列为空),则sched暂时让出控制权。接收成功,发送IPC,通知网络核心环境已经获得数据包,并简单sleep 50 ms,因为 网络核心环境需要时间对当前 shared 页的数据包进行处理。 当然,这样效率十分低下,我们可以考虑在Input环境中申请一定数量的页,轮流使用这个页向网络核心环境传递网络数据包。

The Web Server

在usr/httpd.c中的框架代码处理到达的连接并解析消息头部

首先编写send_file,根据提示,在原有的代码前添加以下代码。

static int
send_file(struct http_request *req)
{
int r;
off_t file_size = -1;
int fd;

// open the requested url for reading
// if the file does not exist, send a 404 error using send_error
// if the file is a directory, send a 404 error using send_error
// set file_size to the size of the file

// LAB 6: Your code here.
//panic("send_file not implemented");
if((fd = open(req->url,O_RDONLY)) < 0){
send_error(req,404);
goto end;
}
struct Stat stat;
fstat(fd, &stat);
if (stat.st_isdir) { //是一个目录
send_error(req, 404);
goto end;
}

if ((r = send_header(req, 200)) < 0)
goto end;

if ((r = send_size(req, file_size)) < 0)
goto end;

if ((r = send_content_type(req)) < 0)
goto end;

if ((r = send_header_fin(req)) < 0)
goto end;

r = send_data(req, fd);

end:
close(fd);
return r;
}

然后是send_data

static int
send_data(struct http_request *req, int fd)
{
// LAB 6: Your code here.
//panic("send_data not implemented");
//从fd中读取size大小数据,并发送
struct Stat stat;
fstat(fd,&stat);
void *buf = malloc(stat.st_size);
if(readn(fd,buf,stat.st_size) != stat.st_size){
panic("failed to read requested file\n");
}

//write to socket
if(write(req->sock,buf,stat.st_size) != stat.st_size){
panic("failed to send bytes to client");
}
free(buf);
buf = NULL;
return 0;
}

最后 run make run-httpd-nox,然后在虚拟机的浏览器中输入http://localhost:26002,浏览器会显示404, 然后输入http://localhost:26002/index.html,Web将会返回内容cheesy web page

总结

  1. 实现网卡驱动。

    1. 通过MMIO方式访问网卡,直接通过内存就能设置网卡的工作方式和特性。
    2. 通过DMA方式,使得网卡在不需要CPU干预的情况下直接和内存交互。具体工作方式如下:驱动工作方式 以发送数据为例,维护一个发送队列,生产者将要发送的数据放到发送队列中tail指向的描述符对应的缓冲区,同时更新tail指针。网卡作为消费者,从head指向的描述符对应的缓冲区拿到数据并发送出去,然后更新head指针。
  2. 用户级线程实现。主要关注三个函数就能明白原理:

    1. thread_init()
    2. thread_create()
    3. thread_yield()

image-20210910181407379