前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >ucore-lab7

ucore-lab7

作者头像
Heeler-Deer
发布于 2023-02-22 06:20:17
发布于 2023-02-22 06:20:17
95700
代码可运行
举报
文章被收录于专栏:HD-学习笔记HD-学习笔记
运行总次数:0
代码可运行

练习解答

  • 理解操作系统的同步互斥的设计实现;
  • 理解底层支撑技术:禁用中断、定时器、等待队列;
  • 在ucore中理解信号量(semaphore)机制的具体实现;
  • 理解管程机制,在ucore内核中增加基于管程(monitor)的条件变量(condition variable)的支持;
  • 了解经典进程同步问题,并能使用同步机制解决进程同步问题。

练习0

填写实验,自行填写,懒得找了,可以参考kiprey

练习一

理解内核级信号量的实现和基于内核级信号量的哲学家就餐问题(不需要编码) 完成练习0后,建议大家比较一下(可用meld等文件diff比较软件)个人完成的lab6和练习0完成后的刚修改的lab7之间的区别,分析了解lab7采用信号量的执行过程。执行make grade,大部分测试用例应该通过。 请在实验报告中给出内核级信号量的设计描述,并说明其大致执行流程。 请在实验报告中给出给用户态进程/线程提供信号量机制的设计方案,并比较说明给内核级提供信号量机制的异同。

实际上就是解释ucore的哲学家就餐怎么实现的,内核级别的信号量怎么实现的,之后给出自己关于用户级别的信号量的设计方案,比较两者异同。

关于哲学家就餐问题,不知道为什么,代码里面有注释,中文的。。。

结合注释是不难理解的。

在kern/sync/check_sync.c可以找到:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
//---------- philosophers problem using semaphore ----------------------
int state_sema[N]; /* 记录每个人状态的数组 */
/* 信号量是一个特殊的整型变量 */
semaphore_t mutex; /* 临界区互斥 */
semaphore_t s[N]; /* 每个哲学家一个信号量 */

struct proc_struct *philosopher_proc_sema[N];

void phi_test_sema(i) /* i:哲学家号码从0到N-1 */
{ 
    if(state_sema[i]==HUNGRY&&state_sema[LEFT]!=EATING
            &&state_sema[RIGHT]!=EATING)
    {
        state_sema[i]=EATING;
        up(&s[i]);
    }
}

void phi_take_forks_sema(int i) /* i:哲学家号码从0到N-1 */
{ 
        down(&mutex); /* 进入临界区 */
        state_sema[i]=HUNGRY; /* 记录下哲学家i饥饿的事实 */
        phi_test_sema(i); /* 试图得到两只叉子 */
        up(&mutex); /* 离开临界区 */
        down(&s[i]); /* 如果得不到叉子就阻塞 */
}

void phi_put_forks_sema(int i) /* i:哲学家号码从0到N-1 */
{ 
        down(&mutex); /* 进入临界区 */
        state_sema[i]=THINKING; /* 哲学家进餐结束 */
        phi_test_sema(LEFT); /* 看一下左邻居现在是否能进餐 */
        phi_test_sema(RIGHT); /* 看一下右邻居现在是否能进餐 */
        up(&mutex); /* 离开临界区 */
}

int philosopher_using_semaphore(void * arg) /* i:哲学家号码,从0到N-1 */
{
    int i, iter=0;
    i=(int)arg;
    cprintf("I am No.%d philosopher_sema\n",i);
    while(iter++<TIMES)
    { /* 无限循环 */
        cprintf("Iter %d, No.%d philosopher_sema is thinking\n",iter,i); /* 哲学家正在思考 */
        do_sleep(SLEEP_TIME);
        phi_take_forks_sema(i); 
        /* 需要两只叉子,或者阻塞 */
        cprintf("Iter %d, No.%d philosopher_sema is eating\n",iter,i); /* 进餐 */
        do_sleep(SLEEP_TIME);
        phi_put_forks_sema(i); 
        /* 把两把叉子同时放回桌子 */
    }
    cprintf("No.%d philosopher_sema quit\n",i);
    return 0;    
}

  • 请给出内核级信号量的设计描述,并说明其大致执行流程

内核级别信号量结构体定义位于kern/sync/sem.h中:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
typedef struct {
    int value;
    wait_queue_t wait_queue;
} semaphore_t;

观察哲学家就餐代码,不难发现信号量有关函数:down,up,继续查看封装的函数在kern/sync/sem.c里面,

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void
up(semaphore_t *sem) {
    __up(sem, WT_KSEM);
}

void
down(semaphore_t *sem) {
    uint32_t flags = __down(sem, WT_KSEM);
    assert(flags == 0);
}
////////////////////////////////////////////////////////////////
static __noinline void __up(semaphore_t *sem, uint32_t wait_state) {
    bool intr_flag;
    //根据intr_flag决定是否要enable irq interrupt
    local_intr_save(intr_flag);
    {
        wait_t *wait;
        //如果队列中没有wait的线程,value++
        if ((wait = wait_queue_first(&(sem->wait_queue))) == NULL) {
            sem->value ++;
        }
        //否则唤醒线程且执行代码
        else {
            assert(wait->proc->wait_state == wait_state);
            wakeup_wait(&(sem->wait_queue), wait, wait_state, 1);
        }
    }
    local_intr_restore(intr_flag);//也是封装的函数
}

static __noinline uint32_t __down(semaphore_t *sem, uint32_t wait_state) {
    bool intr_flag;
    local_intr_save(intr_flag);
    if (sem->value > 0) {
        //如果信号量>0,递减
        sem->value --;
        local_intr_restore(intr_flag);
        return 0;
    }
    //如果等于0,则准备执行进程,添加至等待队列
    wait_t __wait, *wait = &__wait;
    wait_current_set(&(sem->wait_queue), wait, wait_state);
    local_intr_restore(intr_flag);
	//调度算法
    schedule();
	//执行完就删除
    local_intr_save(intr_flag);
    wait_current_del(&(sem->wait_queue), wait);
    local_intr_restore(intr_flag);

    if (wait->wakeup_flags != wait_state) {
        return wait->wakeup_flags;
    }
    return 0;
}
//local_intr_restore,如果传入的flag为true,则enable irq interrupt,继续跟踪intr_enable会发现一段汇编
static inline void
__intr_restore(bool flag) {
    if (flag) {
        intr_enable();
    }
}
  • 请给出给用户态进程/线程提供信号量机制的设计方案,并比较说明给内核级提供信号量机制的异同

首先,用户级别的信号量应该存储在用户空间中,但用户本身能不能操控信号量?对于一个进程的多个线程来讲,似乎可以交由进程进行信号量的管理,但对于多个进程公用的信号量来讲,我认为应该调用内核,由内核进行管理。信号量由使用信号量的代码的更高一级的代码进行管理,应该是比较好的,至少应该抽象出更高的一个层级去管理。但考虑到信号量涉及到的同步问题,完全有内核进行原子性的操作会更好一点。

那么,怎么云实现呢?可以在proc的结构体里面增加信号量的相关代码,用于获取信号量的值,发出增加或减少信号量的请求,再由操作系统实现。详细可以参考kiprey,他参考了linux的实现。

练习二

练习二2: 完成内核级条件变量和基于内核级条件变量的哲学家就餐问题(需要编码) 首先掌握管程机制,然后基于信号量实现完成条件变量实现,然后用管程机制实现哲学家就餐问题的解决方案(基于条件变量)。 执行:make grade 。如果所显示的应用程序检测都输出ok,则基本正确。如果只是某程序过不去,比如matrix.c,则可执行 1make run-matrix 命令来单独调试它。大致执行结果可看附录。 请在实验报告中给出内核级条件变量的设计描述,并说明其大致执行流程。 请在实验报告中给出给用户态进程/线程提供条件变量机制的设计方案,并比较说明给内核级提供条件变量机制的异同。 请在实验报告中回答:能否不用基于信号量机制来完成条件变量?如果不能,请给出理由,如果能,请给出设计说明和具体实现。

在kern/sync/monitor.h中,有相关结构体,

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
typedef struct monitor monitor_t;

typedef struct condvar{
    semaphore_t sem;        // the sem semaphore  is used to down the waiting proc, and the signaling proc should up the waiting proc
    int count;              // the number of waiters on condvar
    monitor_t * owner;      // the owner(monitor) of this condvar
} condvar_t;

typedef struct monitor{
    semaphore_t mutex;      // the mutex lock for going into the routines in monitor, should be initialized to 1
    semaphore_t next;       // the next semaphore is used to down the signaling proc itself, and the other OR wakeuped waiting proc should wake up the sleeped signaling proc.
    int next_count;         // the number of of sleeped signaling proc
    condvar_t *cv;          // the condvars in monitor
} monitor_t;

此处不再赘述,

在kern/sync/monitor.c,有monitor_init的实现:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void     
monitor_init (monitor_t * mtp, size_t num_cv) {
    int i;
    assert(num_cv>0);
    mtp->next_count = 0;
    mtp->cv = NULL;
    
    //初始化锁为0,next为1
    sem_init(&(mtp->mutex), 1); //unlocked
    sem_init(&(mtp->next), 0);
    //分配当前管程的条件变量
    mtp->cv =(condvar_t *) kmalloc(sizeof(condvar_t)*num_cv);
    assert(mtp->cv!=NULL);
    //初始化管程
    for(i=0; i<num_cv; i++){
        mtp->cv[i].count=0;
        sem_init(&(mtp->cv[i].sem),0);
        mtp->cv[i].owner=mtp;
    }
}

我们要实现的是接下来两个函数:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// Unlock one of threads waiting on the condition variable. 
//看注释知道要做什么
void 
cond_signal (condvar_t *cvp) {
   //LAB7 EXERCISE1: YOUR CODE
   cprintf("cond_signal begin: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);  
    //打印提示信息
    //如果管程内等待的线程大于0个
     if(cvp->count>0) {
        //正在睡眠的进程++
        cvp->owner->next_count ++;
         //尝试唤醒线程,更新信号量
        up(&(cvp->sem));
         //正在执行的线程被挂起
        down(&(cvp->owner->next));
         //睡眠线程就--
        cvp->owner->next_count --;
      }
    //打印提示信息
   cprintf("cond_signal end: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
}

// Suspend calling thread on a condition variable waiting for condition Atomically unlocks 
// mutex and suspends calling thread on conditional variable after waking up locks mutex. Notice: mp is mutex semaphore for monitor's procedures
void
cond_wait (condvar_t *cvp) {
    //LAB7 EXERCISE1: YOUR CODE
    cprintf("cond_wait begin:  cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
    //打印信息
    //需要等待的线程++
      cvp->count++;
    //如果有挂起的线程就线执行该线程
      if(cvp->owner->next_count > 0)
         up(&(cvp->owner->next));
      else
          //否则释放锁
         up(&(cvp->owner->mutex));
    // 尝试获取条件变量 
      down(&(cvp->sem));
    //需要等待的线程--
      cvp->count --;
    cprintf("cond_wait end:  cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
}

哲学家就餐问题基本和信号量的实现相同,此处不再赘述。

关于 能否不用基于信号量机制来完成条件变量?如果不能,请给出理由,如果能,请给出设计说明和具体实现。 ,个人认为,条件变量实质上可以看作信号量的简陋版本。

如果分数低,可以按照网上的办法偷懒改grade.sh,也可以参考我之前写的办法。

结果:

challenge1

扩展练习 Challenge : 在ucore中实现简化的死锁和重入探测机制 在ucore下实现一种探测机制,能够在多进程/线程运行同步互斥问题时,动态判断当前系统是否出现了死锁产生的必要条件,是否产生了多个进程进入临界区的情况。 如果发现,让系统进入monitor状态,打印出你的探测信息。

暑假得抽个空补完这些没有参考的challenge

challengen2

扩展练习 Challenge : 参考Linux的RCU机制,在ucore中实现简化的RCU机制 在ucore 下实现下Linux的RCU同步互斥机制。可阅读相关Linux内核书籍或查询网上资料,可了解RCU的设计实现细节,然后简化实现在ucore中。 要求有实验报告说明你的设计思路,并提供测试用例。下面是一些参考资料:

  • http://www.ibm.com/developerworks/cn/linux/l-rcu/
  • http://www.diybl.com/course/6_system/linux/Linuxjs/20081117/151814.html

仍是源自github

所谓RCU,实际上适用于多个读者,少的写者的情况。他甚至可以不对读者加锁,而只对写者加锁。这样满足了读者可以随时进行读取操作,减少开销,而写者则是正常的加锁策略。由此,需要解决的问题是,我在写共享资源的时候,有一个读者过来读,我怎么保证他读的对?写的进程会copy资源,并且移动资源到新的位置,在写的过程中,读者进程读取原先的位置,此时会报错。rcu通过“销毁不删除”来实现。即写进程写时默认删除原值,读者在写进程执行时读取则可以正常读到原先的值(此时不销毁),写进程结束后销毁原值,更改共享资源地址即可。

具体思路可以参考上述的github.

最终效果如下,由于没有实现相应的哲学家就餐问题,make grade只有183,不过这不重要:

由于只是简化实现,因此并没有对写者加锁的代码。代码解释:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制

typedef struct foo {
	int a;
	char b;
} foo_t;

foo_t* gbl_foo = NULL;

foo_t* old_foo_ref = NULL;
foo_t* new_foo_ref = NULL;
int grace_period_count = 0;

semaphore_t foo_sem;
//初始化

//信号量
static void rcu_read_lock(foo_t* ref) {
    bool intr_flag;
    local_intr_save(intr_flag);
    {
		if (ref == old_foo_ref) {
			grace_period_count += 1;
		}
    }
}
//信号量
static void rcu_read_unlock(foo_t* ref) {
    bool intr_flag;
    local_intr_save(intr_flag);
    {
		if (ref == old_foo_ref) {
			grace_period_count -= 1;
		}
    }
}
//重置
static int resync_rcu_trail() {
	return (grace_period_count != 0);
}
//读,老进程就增加"宽限区"
static void foo_read(int id) {
	cprintf("Foo_read %d starts.\n", id);
	rcu_read_lock(gbl_foo);
	// 读取旧值的指针
	foo_t* fp = gbl_foo;
	// If fp == NULL, it means gbl_foo has been deleted (don't care whether it is destroyed)
	if (fp != NULL) {
		// Sleep for some time.
        //这里我不是很清楚为什么要sleep
		do_sleep(2);
		cprintf("[SAFE] foo_read: gbl_foo.a = %d, gbl_foo.b = %c\n", fp->a, fp->b);
	} else {
		panic("[DANGER] foo_read: attempt to read foo when foo is null.");
	}
	rcu_read_unlock(fp);
	cprintf("Foo_read %d ends.\n", id);
}
//更新共享资源的位置,写
// Update the gbl_foo to new_fp and free the old_fp.
// However, the free process could happen when Line36 is running.
// Thus, we need to do the update but delay the destroy of old_foo.
// Until all foo_reads exits the critical area.
static void foo_update(int id) {
	cprintf("Foo_update %d starts.\n", id);
	// foo_sem is a mutex for gbl_foo
	down(&(foo_sem));
	foo_t* old_fp = gbl_foo;
	gbl_foo = new_foo_ref;
	up(&(foo_sem));
	cprintf("Foo_update waiting for %d graceful period to finish.\n", grace_period_count);
	// spin when process left in grace period
	while (resync_rcu_trail()) schedule();
	kfree(old_fp);
	cprintf("Foo_update %d ends.\n", id);
}
//测试rcu
void check_rcu() {
	sem_init(&(foo_sem), 1);
	old_foo_ref = (foo_t*) kmalloc(sizeof(foo_t));
	old_foo_ref->a = 5;
	old_foo_ref->b = 'O';
	new_foo_ref = (foo_t*) kmalloc(sizeof(foo_t));
	new_foo_ref->a = 6;
	new_foo_ref->b = 'N';

	gbl_foo = old_foo_ref;

	int r1k = kernel_thread(foo_read, (void *)1, 0);
	int r2k = kernel_thread(foo_read, (void *)2, 0);
	int w1k = kernel_thread(foo_update, (void *)1, 0);
	int r3k = kernel_thread(foo_read, (void *)3, 0);
	int r4k = kernel_thread(foo_read, (void *)4, 0);

	do_wait(r1k, NULL);
	do_wait(r2k, NULL);
	do_wait(w1k, NULL);
	do_wait(r3k, NULL);
	do_wait(r4k, NULL);

	cprintf("check_rcu() passed\n");
}
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022年5月27日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
ucoreOS_lab7 实验报告
lab7 会依赖 lab1~lab6 ,我们需要把做的 lab1~lab6 的代码填到 lab7 中缺失的位置上面。练习 0 就是一个工具的利用。这里我使用的是 Linux 下的系统已预装好的 Meld Diff Viewer 工具。和 lab6 操作流程一样,我们只需要将已经完成的 lab1~lab6 与待完成的 lab7 (由于 lab7 是基于 lab1~lab6 基础上完成的,所以这里只需要导入 lab6 )分别导入进来,然后点击 compare 就行了。
Angel_Kitty
2019/08/05
1.6K0
ucoreOS_lab7 实验报告
吐血整理 | 肝翻 Linux 同步管理所有知识点
因为现代操作系统是多处理器计算的架构,必然更容易遇到多个进程,多个线程访问共享数据的情况,如下图所示:
刘盼
2022/01/27
8930
吐血整理 | 肝翻 Linux 同步管理所有知识点
操作系统:第二章 进程的描述与控制(下)
进程同步:在多道程序环境下,进程是并发执行的,不同进程之间存在着不同的相互制约关系。
Here_SDUT
2022/08/08
7130
操作系统:第二章 进程的描述与控制(下)
一文搞懂 | Linux 同步管理(上)
因为现代操作系统是多处理器计算的架构,必然更容易遇到多个进程,多个线程访问共享数据的情况,如下图所示:
刘盼
2021/10/21
6080
一文搞懂 | Linux 同步管理(上)
操作系统:第二章 进程的描述与控制
定义:前趋图是一个有向无环图(DAG),用于描述进程之间执行的前后关系,其实就是一个拓扑排序。 – 结点:表示一个程序段或进程,或一条语句 – 有向边:结点之间的偏序或前序关系“→”
Here_SDUT
2022/08/08
7290
操作系统:第二章 进程的描述与控制
Linux系统中的信号量机制
1、信号量的定义: struct semaphore { spinlock_t lock; unsigned int count; struct list_head wait_list; }; 在linux中,信号量用上述结构体表示,我们可以通过该结构体定义一个信号量。 2、信号量的初始化: 可用void sema_init(struct semaphore *sem, int val);直接创建,其中val为信号量初值。也可以用两个宏来定义和初始化信号量的值为1或0: DECLAR
宅蓝三木
2018/02/07
2.7K0
【源码分析】——信号量
除了原子操作,中断屏蔽,自旋锁以及自旋锁的衍生锁之外,在Linux内核中还存在着一些其他同步互斥的手段。
董哥聊技术
2023/09/28
7180
【源码分析】——信号量
信号量与管程以及原子性,pv原语操作,临界资源和临界区,同步和互斥,信号量,管程与临界区不同,信号量和互斥锁的区别,互斥量(Mutex)
程序的原子性指:整个程序中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。
zhangjiqun
2024/12/16
2220
信号量与管程以及原子性,pv原语操作,临界资源和临界区,同步和互斥,信号量,管程与临界区不同,信号量和互斥锁的区别,互斥量(Mutex)
Operating System 01 - 进程同步
信号量(Semaphore) 是一个整形变量, 可以对其执行down() 和up() 操作, 也就是P和V操作.
Reck Zhang
2021/08/11
4520
ucore-lab5
实验4完成了内核线程,但到目前为止,所有的运行都在内核态执行。实验5将创建用户进程,让用户进程在用户态执行,且在需要ucore支持时,可通过系统调用来让ucore提供服务。为此需要构造出第一个用户进程,并通过系统调用sys_fork/sys_exec/sys_exit/sys_wait来支持运行不同的应用程序,完成对用户进程的执行过程的基本管理。
Heeler-Deer
2023/03/10
7450
ucore-lab5
Rust常用并发示例代码
如果method1()被多次调用,就会创建多个线程,如果希望不管调用多少次,只能有1个线程,在不使用线程池的前提下,有1个简单的办法:
菩提树下的杨过
2022/09/28
1K0
Rust常用并发示例代码
Linux 同步管理
对于基础类型操作,使用原子变量就可以做到线程安全,那原子操作是如何保证线程安全的呢?linux中的原子变量如下:
一只小虾米
2023/03/20
1.7K0
Linux 同步管理
进程同步经典示例 多线程上篇(五)
比如信号量机制中的wait(S) 和 signal(S) ,就相当于是两个方法调用。
noteless
2019/03/04
1.2K0
信号量(semaphore)
信号量也是一种锁,相对于自旋锁,当资源不可用的时候,它会使进程挂起,进入睡眠。而自旋锁则是让等待者忙等。这意味着在使用自旋锁获得某一信号量的进程会出现对处理器拥有权的丧失,也即时进程切换出处理器。信号量一般用于进程上下文,自旋锁一般用于中断上下文。
DragonKingZhu
2020/03/24
9010
Linux内核33-信号量
对于信号量我们并不陌生。信号量在计算机科学中是一个很容易理解的概念。本质上,信号量就是一个简单的整数,对其进行的操作称为PV操作。进入某段临界代码段就会调用相关信号量的P操作;如果信号量的值大于0,该值会减1,进程继续执行。相反,如果信号量的值等于0,该进程就会等待,直到有其它程序释放该信号量。释放信号量的过程就称为V操作,通过增加信号量的值,唤醒正在等待的进程。
Tupelo
2022/08/15
1.7K0
线程同步与互斥
不是什么时候都要靠上锁的。从根源出发,我们为什么需要上锁?因为线程在使用资源的过程中可能会出现冲突,对于这种会出现冲突的资源,还是锁住轮着用比较好。
看、未来
2021/10/09
8870
线程同步与互斥
Linux设备驱动程序(五)——并发和竞态
并发相关的缺陷是最容易制造的,也是最难找到的,为了响应现代硬件和应用程序的需求,Linux 内核已经发展到同时处理更多事情的时代。这种变革使得内核性能及伸缩性得到了相当大的提高,然而也极大提高了内核编程的复杂性。
Gnep@97
2023/08/10
6040
Linux设备驱动程序(五)——并发和竞态
操作系统第二章进程的描述与控制_进程同步和互斥的区别
知识点回顾:进程具有异步性的特征。异步性是指,各并发执行的进程以各自独立的、不可预知的速度向前推进。
全栈程序员站长
2022/09/30
6780
操作系统第二章进程的描述与控制_进程同步和互斥的区别
操作系统实验报告
最后发现当前脚本中exec的功能是执行完spark的启动脚本后,就退出shell,所以导致脚本后面的的两个命令都没有执行,结尾用echo输出也没有任何内容打印。
十二惊惶
2024/02/28
2420
操作系统实验报告
面试专场之「操作系统」知识
本文经 CyC2018 大佬授权发表,更多技术内容请前往 https://github.com/CyC2018/CS-Notes 查看。
五分钟学算法
2019/03/15
5650
面试专场之「操作系统」知识
相关推荐
ucoreOS_lab7 实验报告
更多 >
LV.0
这个人很懒,什么都没有留下~
加入讨论
的问答专区 >
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
    本文部分代码块支持一键运行,欢迎体验
    本文部分代码块支持一键运行,欢迎体验