阅读前注意事项:

1、我的博客从lab2之后,如果没有特殊说明,所有标注的代码行数位置,以labcodes_answer(答案包)里的文件为准!!!因为你以后会发现做实验用meld软件比较费时费力,对于咱们学校的验收不如直接对着答案来;

2、感谢网上的各路前辈大佬们,本人在这学期初次完成实验的过程中,各位前辈们的博客给了我很多有用的指导;本人的博客内容在现有的内容上,做了不少细节的增补内容,有些地方属个人理解,如果有错在所难免,还请各位大佬们批评指正;

3、所有实验的思考题,我把它规整到了文章最后;

4、所有实验均默认不做challenge,对实验评分无影响;

5、湖南大学的实验顺序为1 4 5 6 7 2 3 8,在实验4-7过程中涉及到实验二三的页表虚存问题,当做黑盒处理,没有过多探索。

一、实验内容

当一个程序加载到内存中运行时, 首先通过ucore OS的内存管理子系统分配合适的空间, 然后就需要考虑如何分时使用CPU来“并发”执行多个程序, 让每个运行的程序(这里用线程或进程表示) “感到”它们各自拥有“自己”的CPU。

本次实验将首先接触的是内核线程的管理。 内核线程是一种特殊的进程, 内核线程与用户进程的区别有两个:内核线程只运行在内核态,用户进程会在在用户态和内核态交替运行,所有内核线程共用ucore内核内存空间, 不需为每个内核线程维护单独的内存空间,而用户进程需要维护各自的用户内存空间。

二、目的

了解内核线程创建/执行的管理过程

了解内核线程的切换和基本调度过程

三、实验设计思想和流程

练习1:分配并初始化一个进程控制块(需要编码)

alloc_proc函数(位于kern/process/proc.c86行中,参考proc.h文件)负责分配并返回一个新的struct proc_struct结构,用于存储新建立的内核线程的管理信息。ucore需要对这个结构进行最基本的初始化,你需要完成这个初始化过程。

【提示】在alloc_proc函数的实现中,需要初始化的proc_struct结构中的成员变量至少包括:state/pid/runs/kstack/need_resched/parent/mm/context/tf/cr3/flags/name。

首先找到kern/process/proc.c,需要填写的alloc_proc函数中有这么一段注释,它主要定义了一个结构体proc_struct,它也说,我们需要初始化这样一个结构体的一个对象并返回它。

这个结构体的详细定义在同一文件夹下的proc.h中的42——57行也能找到:

struct proc_struct {enum proc_state state;           // Process stateint pid;                        // Process IDint runs;                      // the running times of Procesuintptr_t kstack;             // Process kernel stackvolatile bool need_resched;      // bool value: need to be rescheduled to release CPU?struct proc_struct *parent;        // the parent processstruct mm_struct *mm;          // Process's memory management fieldstruct context context;            // Switch here to run processstruct trapframe *tf;          // Trap frame for current interruptuintptr_t cr3;               // CR3 register: the base addr of Page Directroy Table(PDT)uint32_t flags;                  // Process flagchar name[PROC_NAME_LEN + 1];       // Process namelist_entry_t list_link;          // Process link list list_entry_t hash_link;            // Process hash list
};

alloc_proc,这个函数的返回语句是“return proc”,其中proc就是这个proc_struct的一个对象。

其实这里我们需要初始化的一个东西就是proc_struct的一个对象,分配的是一个内核线程的PCB,它通常只是内核中的一小段代码或者函数,没有用户空间。而由于在操作系统启动后,已经对整个核心内存空间进行了管理,所以内核中的所有线程都不需要再建立各自的页表,只需共享这个核心虚拟空间就可以访问整个物理内存了。(这里需要指出的一个重点是,虽然名字叫做内核线程,但是内核线程是一种特殊的进程,来自指导书P167)

PCB包括进程状态,进程编号,程序计数器、寄存器等各种参数,和上面这个结构体一比较,可以确定这就是一个标准的PCB,我们需要做的东西就是初始化它,也可以看做给一个新开辟的内核线程(进程)做初始化。

反过来看这个结构体里面定义的PCB参数,经过分析我们可以得到以下含义:

state:进程所处的状态,这个在proc.h的第11行——15行有定义,具体如下:

PROC_UNINIT      //未初始状态
PROC_SLEEPING       //睡眠(阻塞)状态
PROC_RUNNABLE       //运行与就绪态
PROC_ZOMBIE     //僵尸状态

pid:进程id号。这个非常熟悉了。

runs:进程运行的时间,既然任务是初始化,那么runs必须是零。

kstack:记录了分配给该进程/线程的内核桟的位置。这个地方,在没有基于前三个实验代码补全lab4的源代码之前,似乎运行会出问题,如果补全了,运行就不会报错。因为这里记录的是分配给该进程在内存中的栈位置,推测相关操作应该和lab2和lab3有关。

need_resched:是否需要调度,目前实验未到这一步,暂时不管。

parent:用户进程的父进程,这是一个指针变量,记录它的父进程是谁。在所有进程中,只有一个进程没有父进程,就是内核创建的第一个内核线程idleproc。

mm:注释说它负责管理进程的虚拟memory,其实就是内存管理的信息,包括内存映射列表、页表指针等。mm成员变量在lab3中用于虚存管理。但在实际OS中,内核线程常驻内存,不需要考虑swap page问题,mm应该和23有关,暂时不管。

context:Switch here to run process,推测应该是进程的上下文,用于进程切换。使用Switch.S汇编文件中的定义。

tf:中断帧的指针,指导书上说,它是中断帧的指针,总是指向内核栈的某个位置:当进程从用户空间跳到内核空间时,中断帧记录了进程在被中断前的状态。当内核需要跳回用户空间时,需要调整中断帧以恢复让进程继续执行的各寄存器值。除此之外,uCore内核允许嵌套中断。我的理解是,它总是指向内核栈的某个位置,中断帧记录了进程在被中断前的状态,应该也和23实验有关,不深究。

cr3:翻译注释的意思是:记录了当前使用的页表的地址,也是和23有关,不管。

name[PROC_NAME_LEN + 1],这是内核线程(进程)的名称,比如有一次作业里面要画进程数,输入了一个-lm指令,就能看到进程的名字pid等一些信息。

主要的初始化部分如下:

proc->state = PROC_UNINIT;           //给进程设置为未初始化状态
proc->pid = -1;             //未初始化的进程,其pid为-1
proc->runs = 0;             //刚刚初始化的进程,运行时间一定为零
proc->kstack = 0;           //为该进程分配的地址为0,因为还没有执行,也没有被重定位,因为默认地址都是从0开始的。
(上个学期计组课里面学过,刚刚编译出来的代码地址都是从0开始,只有在重定位之后,地址才会分配到内存里的0x8048......这样子的地方)
proc->need_resched = 0;         //刚刚分配出来的进程,都还没有进入CPU,还需要提什么分配么?
proc->parent = NULL;            //同样的道理,父进程,虚拟memory,这些东西刚刚分出来,都是不存在的
proc->mm = NULL;
memset(&(proc->context), 0, sizeof(struct context));     //初始化上下文用,这个是我在博客上查的,因为课程进度,不太明白内部操作
proc->tf = NULL;                //中断帧指针置为空
proc->cr3 = boot_cr3;                   //页目录设为内核页目录表的基址
proc->flags = 0;
memset(proc->name, 0, PROC_NAME_LEN);        //初始化进程名字为空

练习2:为新创建的内核线程分配资源(需要编码)

这个练习核心部分是完成在kern/process/proc.c中的do_fork函数中的处理过程。ucore一般通过do_fork实际创建新的内核线程。do_fork的作用是,创建当前内核线程的一个副本,它们的执行上下文、代码、数据都一样,但是存储位置不同。在这个过程中,需要给新内核线程分配资源,并且复制原进程的状态。

这一点和linux里面的fork()函数的处理完全相同。

(来自于指导书)它的大致执行步骤包括:

1、调用alloc_proc,首先获得一块用户信息块。

2、为进程分配一个内核栈。

3、复制原进程的内存管理信息到新进程(但内核线程不必做此事)

4、复制原进程上下文到新进程

5、将新进程添加到进程列表

6、唤醒新进程

7、返回新进程号

在练习一中的alloc_proc实质只是找到了一小块内存用以记录进程的必要信息并且初始化PCB,并没有分配这些资源,而练习2完成的do_fork才是真正完成了资源分配的工作,但是这个do_fork的功能也是有限的,它fork出来的两个内核线程之间的的执行上下文、代码、数据都一样,但是存储位置不同。

以下是实现过程:

do_fork()位于kern/process/proc.c文件中279行

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;//上面的部分已经给出,不需要自己实现

第一步:调用alloc_proc()函数申请内存块,如果失败,直接返回处理,相关的解释是,alloc_proc()函数在练习一中实现过,如果分配进程PCB失败,也就是说,进程一开始就是NULL,那么就会被if(proc!=NULL)判定为否,那么就不会分配初始化资源,连初始化资源都没有了,那么就会返回NULL,因此第一步这么处理,代码实现如下:

    if ((proc = alloc_proc()) == NULL) {goto fork_out;}

第二步:将子进程的父节点设置为当前进程,这个没什么好解释的,直接用就可以,只需要注意一点,就是代表当前进程的变量current已经在全局定义(第76行),因此代码实现如下:

proc->parent = current;

第三步:调用setup_stack()函数(proc.c235行)为进程分配一个内核栈,根据注释,我们需要使用setup_kstack这个函数,它的解释:alloc pages with size KSTACKPAGE as process kernel stack,正是一个为函数分配一个内核栈的调用,因此,我们找到代码中对应的函数有如下分析:

static int setup_kstack(struct proc_struct *proc) {  //246行struct Page *page = alloc_pages(KSTACKPAGE);if (page != NULL) {proc->kstack = (uintptr_t)page2kva(page);return 0;}return -E_NO_MEM;}

我们看到,如果页不为空的时候,会return 0,也就是说分配内核栈成功了(这样推测的根据在于,最后一个return -E_NO_MEM,大概推测就是一个初始化的或者错误的状态,因为在这个函数最开始不需要实现的部分,这个值就赋值给了ret),那么就会返回0,否则返回一个奇怪的东西。

因此,我们调用该函数分配一个内核栈空间,并判断是否分配成功,代码实现如下:

    if (setup_kstack(proc) != 0) {goto bad_fork_cleanup_proc;}

第四步:调用copy_mm()函数(proc.c253行)复制父进程的内存信息到子进程,那么首先来看copy函数如下:

copy_mm(uint32_t clone_flags, struct proc_struct *proc) {  //253行assert(current->mm == NULL);/* do nothing in this project */return 0;}

这个函数的注释解释是:进程proc复制还是共享当前进程current,是根据clone_flags来决定的,如果是clone_flags & CLONE_VM(为真),那么就可以拷贝。这个函数里面似乎没有做任何事情,仅仅是确定了一下current当前进程的虚拟内存是否为空,那么具体的操作,只需要传入它所需要的clone_flag就可以,其余事情不需要我们去做,代码实现如下:

    if (copy_mm(clone_flags, proc) != 0) {goto bad_fork_cleanup_kstack;}

第五步:调用copy_thread()函数复制父进程的中断帧和上下文信息,那么观察相应的函数:

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_eax = 0;proc->tf->tf_esp = esp;proc->tf->tf_eflags |= FL_IF;proc->context.eip = (uintptr_t)forkret;proc->context.esp = (uintptr_t)(proc->tf);}

需要传入的三个参数,第一个是比较熟悉,练习一中已经实现的PCB模块proc结构体的对象,第二个参数,是一个栈,判断的依据是它的数据类型,在练习一中的PCB模块中,为栈定义的数据类型就是uintptr_t,第三个参数也很熟悉,它是练习一PCB中的中断帧的指针,因为这些内容都和实验23相关,故这个函数只要调用,不再深究内部原理。

第六步:将新进程添加到进程的(hash)列表中,我们看到题目中注释给出了提示:

hash_proc:    add proc into proc hash_list,意思是调用这个函数可以将当前的新进程添加到进程的哈希列表中,分析hash函数的特点,直接调用hash(proc)即可:

hash_proc(struct proc_struct *proc) {list_add(hash_list + pid_hashfn(proc->pid), &(proc->hash_link));}

函数的实现如下:(local intr函数的作用,在后面解释)

bool intr_flag;local_intr_save(intr_flag);{proc->pid = get_pid();hash_proc(proc); //建立映射nr_process ++;  //进程数加1list_add(&proc_list, &(proc->list_link));//将进程加入到进程的链表中}local_intr_restore(intr_flag);

步骤七:唤醒子进程

wakeup_proc(proc);

步骤八:返回子进程的pid

ret = proc->pid;
//下面的部分已经给出,不需要自己实现fork_out:return ret;bad_fork_cleanup_kstack:put_kstack(proc);bad_fork_cleanup_proc:kfree(proc);goto fork_out;}

完整的do_fork函数如下:

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;
//上面的部分已经给出,不需要自己实现
//第一步:调用alloc_proc()函数申请内存块,if ((proc = alloc_proc()) == NULL) {goto fork_out;
}
//第二步:将子进程的父节点设置为当前进程,
proc->parent = current;
//第三步:调用setup_stack()函数为进程分配一个内核栈if (setup_kstack(proc) != 0) {goto bad_fork_cleanup_proc;
}
//第四步:调用copy_mm()函数复制父进程的内存信息到子进程if (copy_mm(clone_flags, proc) != 0) {goto bad_fork_cleanup_kstack;
}
//第五步:调用copy_thread()函数复制父进程的中断帧和上下文信息
copy_thread(proc, stack, tf);
//第六步:将新进程添加到进程的(hash)列表中bool intr_flag;
local_intr_save(intr_flag);{proc->pid = get_pid();hash_proc(proc); //建立映射nr_process ++;  //进程数加1list_add(&proc_list, &(proc->list_link));//将进程加入到进程的链表中}
local_intr_restore(intr_flag);
//步骤七:唤醒子进程
wakeup_proc(proc);
//步骤八:返回子进程的pid
ret = proc->pid;
//下面的部分已经给出,不需要自己实现
fork_out:return ret;
bad_fork_cleanup_kstack:put_kstack(proc);
bad_fork_cleanup_proc:kfree(proc);goto fork_out;
}

练习3:阅读代码,理解proc_run函数和它调用的函数如何完成进程切换的。

阅读了相关代码,proc_run是如何被调用的呢?

首先,在初始化内核的kern_init(kern/init/init.c)函数中,调用了cpu_idle(process/proc.c);

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

具体流程在proc.c的第369——400行:

首先,idleproc = alloc_proc()),为第一个进程进行分配PCB初始化,那么就有:

proc->state = PROC_UNINIT;
proc->pid = -1;
proc->cr3 = boot_cr3;
//等等

这些之前写过的初始化语句,分配给了第一个进程,但是这样不行,第一个进程需要运行,那么此时proc_init(proc.c倒数第二个函数)就会对它进行初始化:

    idleproc->pid = 0;idleproc->state = PROC_RUNNABLE;idleproc->kstack = (uintptr_t)bootstack;idleproc->need_resched = 1;set_proc_name(idleproc, "idle");nr_process ++;

首先设置第一个进程id等于0,当前正在运行,分配了栈空间,需要调度,名字叫做idle。

第0个内核线程主要工作是完成内核中各个子系统的初始化,所以uCore接下来还需创建其他进程来完成各种工作,但idleproc内核子线程自己不想做,于是就通过调用kernel_thread函数创建了一个内核线程init_main。

然后,在proc_init()函数完成了 idleproc 和 initproc 内核线程的初始化。

所以在kern_init() 最后,它通过 cpu_idle()唤醒了0号idle 进程,cpu_idle(proc.c最后)查找到一个需要调度的线程,开始调度它,调用schedule,接着分析调度函数 schedule() 。

接下来,继续分析schedule调度函数:kern/schedule/schedule.c

void schedule(void) {bool intr_flag;list_entry_t *le, *last;struct proc_struct *next = NULL;local_intr_save(intr_flag);{current->need_resched = 0;last = (current == idleproc) ? &proc_list : &(current->list_link);le = last;do {if ((le = list_next(le)) != &proc_list) {next = le2proc(le, list_link);if (next->state == PROC_RUNNABLE) {break;}}} while (le != last);if (next == NULL || next->state != PROC_RUNNABLE) {next = idleproc;}next->runs ++;if (next != current) {proc_run(next);}}local_intr_restore(intr_flag);
}

这个函数所做的工作主要就是调配调度,具体逻辑大致如下:

1、设置当前内核线程 current->need_resched 为 0(即练习一中的PCB “是否需要调度”);

2、遍历进程hash队列,在proc_list 队列中查找下一个处于就绪态的线程或进程next;(比如,这里有一句:next state=runnable)

3、找到这样的进程后,就调用 proc_run函数,保存当前进程current的执行现场(进程上下文),恢复新进程的执行现场,完成进程切换。

最后,通过proc_run函数,就可以跑当前被调度选出的进程,从runable状态正式开始运行。

最后,来到了proc_run函数:proc.c文件

void proc_run(struct proc_struct *proc) { //174行if (proc != current) {bool intr_flag;struct proc_struct *prev = current, *next = proc;local_intr_save(intr_flag);{current = proc;load_esp0(next->kstack + KSTACKSIZE);lcr3(next->cr3);switch_to(&(prev->context), &(next->context));}local_intr_restore(intr_flag);}
}

proc_run函数的基本思路是:

1、让 current指向 next内核线程initproc;

2、设置任务状态ts中特权态0下的栈顶指针esp0 为 next 内核线程 initproc 的内核栈的栈顶,即 next->kstack + KSTACKSIZE ;

3、设置 CR3 寄存器的值为 next 内核线程 initproc 的页目录表起始地址 next->cr3,这实际上是完成进程间的页表切换;

4、由 switch_to函数完成具体的两个线程的执行现场切换,即切换各个寄存器,当 switch_to 函数执行完“ret”指令后,就切换到initproc执行了。

发现函数中调用了switch函数,switch函数写在汇编文件switch.S中:

.text
.globl switch_to
switch_to:                      # switch_to(from, to)# save from's registersmovl 4(%esp), %eax          # eax points to frompopl 0(%eax)                # save eip !poplmovl %esp, 4(%eax)movl %ebx, 8(%eax)movl %ecx, 12(%eax)movl %edx, 16(%eax)movl %esi, 20(%eax)movl %edi, 24(%eax)movl %ebp, 28(%eax)# restore to's registersmovl 4(%esp), %eax          # not 8(%esp): popped return address already# eax now points to tomovl 28(%eax), %ebpmovl 24(%eax), %edimovl 20(%eax), %esimovl 16(%eax), %edxmovl 12(%eax), %ecxmovl 8(%eax), %ebxmovl 4(%eax), %esppushl 0(%eax)               # push eipret

很容易发现保存前一个进程的其他 7 个寄存器到 context 中的相应域中,而后7条指令就是前7条的逆操作,前一部分用于保留上下文切换的现场,便于进程下一次执行;后一部分用于读取之前的进程现场,继续之前之前被调度下去的进程。

四、思考题

1、请说明proc_struct中 struct context context 和 struct trapframe *tf 成员变量含义和在本实验中的作用是啥?(提示通过看代码和编程调试可以判断出来)

答:tf是中断帧的指针,指导书上说,它是中断帧的指针,总是指向内核栈的某个位置:当进程从用户空间跳到内核空间时,中断帧记录了进程在被中断前的状态。当内核需要跳回用户空间时,需要调整中断帧以恢复让进程继续执行的各寄存器值。除此之外,uCore内核允许嵌套中断。我的理解是,它总是指向内核栈的某个位置,中断帧记录了进程在被中断前的状态,应该也和23实验有关,不深究。

2、请说明ucore是否做到给每个新fork的线程一个唯一的id?请说明你的分析和理由。

答:在使用 fork 或 clone 系统调用时产生的进程均会由内核分配一个新的唯一的PID值。因为它的实现放在了互斥锁中。

3、在本实验的执行过程中,创建且运行了几个内核线程?

答:两个,如上面的分析:idleproc:ucore第一个内核进程,完成内核中各个子系统的初始化,之后立即调度,执行其他进程。initproc:用于完成实验的功能而调度的内核进程。

4、解释语句 local_intr_save(intr_flag);....local_intr_restore(intr_flag); 在这里有何作用?并说明理由。

答:是保护进程切换不会被中断,以免进程切换时其他进程再进行调度,相当于互斥锁。之前在第六步添加进程到列表的时候也需要有这个操作,是因为进程进入列表的时候,可能会发生一系列的调度事件,比如我们所熟知的抢断等,加上这么一个保护机制可以确保进程执行不被打乱。

五、运行结果

输出以下进程信息:

操作系统实验ucore lab4相关推荐

  1. 操作系统实验Ucore lab8+反馈队列

    综合实验(lab8+反馈队列) 前言 这是中山大学数据科学与计算机学院2019年操作系统实验中关于Ucore的项目以及实验报告,实验要求与Ucore手则有少量出入. 所有源代码已经上传至github. ...

  2. 操作系统实验ucore lab7

    阅读前注意事项: 1.我的博客从lab2之后,如果没有特殊说明,所有标注的代码行数位置,以labcodes_answer(答案包)里的文件为准!!!因为你以后会发现做实验用meld软件比较费时费力,对 ...

  3. 操作系统实验—ucore Lab1

    一.内容 通过 Lab1 中的 bootloader 可以从实模式切换的保护模式,然后再读取磁盘并加载 ELF 文件以加载 OS 操作系统,操作系统能够读入字符并显示到屏幕上,具体内容如下: 练习 1 ...

  4. 操作系统实验Ucore:Kernel_init(四)

    本文首发于我的博客 上一节进行到了kernel_init的printf_kernelinfo,继续往下分析 1.pmm_init 这个函数,顾名思义是用来初始化物理内存的函数,这个函数只会调用gdt_ ...

  5. 操作系统实验ucore lab6

    阅读前注意事项: 1.我的博客从lab2之后,如果没有特殊说明,所有标注的代码行数位置,以labcodes_answer(答案包)里的文件为准!!!因为你以后会发现做实验用meld软件比较费时费力,对 ...

  6. ucore操作系统实验笔记 - Lab1

    最近一直都在跟清华大学的操作系统课程,这个课程最大的特点是有一系列可以实战的操作系统实验.这些实验总共有8个,我在这里记录实验中的一些心得和总结. Task1 这个Task主要是为了熟悉Makfile ...

  7. 操作系统实验报告1:ucore Lab 1

    操作系统实验报告1 实验内容 阅读 uCore 实验项目开始文档 (uCore Lab 0),准备实验平台,熟悉实验工具. uCore Lab 1:系统软件启动过程 (1) 编译运行 uCore La ...

  8. 操作系统实验五:用户进程管理(详细分析)

    操作系统实验五:用户进程管理 一. 实验目的 二. 实验任务 三. 实验准备 1.alloc_proc() 函数 2.do_fork() 函数 3.idt_init() 函数 4.trap_dispa ...

  9. 操作系统实验4——内核线程管理

    操作系统实验4--内核线程管理 一. 实验目的 二. 实验内容 三. 实验准备 1. proc_struct结构: 2. 进程与线程 四. 实验步骤 (一) 练习0:填写已有实验 (二) 练习1:分配 ...

最新文章

  1. 一起谈.NET技术,OnLoad与Page_Load的差异分析
  2. [ARM-assembly]-ARMv8-A64指令集总结和学习
  3. 深度学习代码注解(一)—— mnistdeepauto
  4. Microsoft Dynamics CRM 2015 新增功能 介绍 高级查找功能
  5. zynq网络时钟控制寄存器_ZYNQ笔记(6):普通自定义IP封装实现PL精准定时中断...
  6. Python 装饰器理解
  7. 《SQL与关系数据库理论——如何编写健壮的SQL代码》一第2章
  8. python爬虫框架Scrapy采集数据,并制作词云图分析!
  9. Maven将jar包install到本地仓库deploy到远程仓库命令
  10. 贺利坚老师汇编课程70笔记:端口的读写
  11. Acwing 1243 糖果 - IDA*估值函数
  12. MFC开发工具入门:Ribbon设计器详解
  13. 国土档案管理信息系统【档案著录】-地籍类档案著录
  14. 卖“生活方式”,还会是新式茶饮们的破局点吗?
  15. Python爬虫+requests+伪装浏览器 爬取小说入门总结
  16. 游戏服务器和普通服务器的区别
  17. 2021中国开源码力榜启动,寻找开源世界的超级玛丽
  18. 苹果电脑安装软件显示:映像数据已损坏的解决办法
  19. VS2015安装完成后Visual C++的一些模板找不到,安装C++新模板
  20. 【观察】谁是中国容器软件市场TOP厂商?权威研究机构IDC发布最新报告

热门文章

  1. 亚马逊德国站卖家:提醒您税务及包装法合规事项!
  2. PFC2D 5.0 基础练习1
  3. 同济大学计算机网络综合实验报告,同济计算机复试总结
  4. PID调节C语言解析
  5. 【reverse】逆向5 标志寄存器
  6. iCloud开发实践
  7. ubuntu 16.04L解压zip文件出现乱码
  8. M103: Basic Cluster Administration chapter 0 Introduction学习记录
  9. “你们程序员不就是修电脑的吗,你牛什么牛,移动应用开发专业就业前景
  10. 【Redis代理】- Twemproxy,不来了解一哈?