在复盘MIT6.828的时候,偶然看到了一个将MIT6.S081的视频翻译成文字的gitbook:https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081/
然后我就从头到尾看了一遍,总结了一些比较重要的内容,对理解MIT6.828也很有帮助。

forkandexec

这是一个常用的写法,先调用fork,再在子进程中调用exec。这里实际上有些浪费,fork首先拷贝了整个父进程的,但是之后exec整个将这个拷贝丢弃了,并用你要运行的文件替换了内存的内容。某种程度上来说这里的拷贝操作浪费了,因为所有拷贝的内存都被丢弃并被exec替换。在大型程序中这里的影响会比较明显。如果你运行了一个几G的程序,并且调用fork,那么实际就会拷贝所有的内存,可能会要消耗将近1秒钟来完成拷贝,这可能会是个问题。
实现一些优化,比如说copy-on-write fork,这种方式会消除fork的几乎所有的明显的低效,而只拷贝执行exec所需要的内存,这里需要很多涉及到虚拟内存系统的技巧。

多核CPU

单CPU中进程只能是并发,多CPU计算机中进程可以并行。
单CPU单核中线程只能并发,单CPU多核中线程可以并行。
同一个进程下的所有线程都只能在CPU同一个核下运行,同一进程下的多个线程在同一个核下轮流使用处理器,因为处理速度快,看起来是并行,实际上同一进程下的多线程是并发。
多核可以同时运行多个进程。

用户程序怎么执行特殊权限指令?

如果在用户空间(user space)尝试执行一条特殊权限指令,用户程序会通过系统调用来切换到kernel mode。当用户程序执行系统调用,会通过ECALL触发一个软中断(software interrupt),软中断会查询操作系统预先设定的中断向量表,并执行中断向量表中包含的中断处理程序。中断处理程序在内核中,这样就完成了user mode到kernel mode的切换,并执行用户程序想要执行的特殊权限指令。

system call

在RISC-V中,有一个专门的指令用来实现这个功能,叫做ECALL。ECALL接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行ECALL指令,并传入一个数字。这里的数字参数代表了应用程序想要调用的System Call。

操作系统在什么时候检查是否允许执行fork或者write?

原则上来说,在内核侧实现fork的位置可以实现任何的检查,例如检查系统调用的参数,并决定应用程序是否被允许执行fork系统调用。在Unix中,任何应用程序都能调用fork,我们以write为例吧,write的实现需要检查传递给write的地址(需要写入数据的指针)属于用户应用程序,这样内核才不会被欺骗从别的不属于应用程序的位置写入数据。

当应用程序表现的恶意或者就是在一个死循环中,内核是如何夺回控制权限的?

内核会通过硬件设置一个定时器,定时器到期之后会将控制权限从用户空间转移到内核空间,之后内核就有了控制能力并可以重新调度CPU到另一个进程中。

宏内核 vs 微内核 (Monolithic Kernel vs Micro Kernel)

所有的操作系统服务都在kernel mode中,这种形式被称为Monolithic Kernel Design(宏内核)

  • 首先,如果考虑Bug的话,这种方式不太好。在一个宏内核中,任何一个操作系统的Bug都有可能成为漏洞。因为我们现在在内核中运行了一个巨大的操作系统,出现Bug的可能性更大了。你们可以去查一些统计信息,平均每3000行代码都会有几个Bug,所以如果有许多行代码运行在内核中,那么出现严重Bug的可能性也变得更大。所以从安全的角度来说,在内核中有大量的代码是宏内核的缺点。
  • 另一方面,如果你去看一个操作系统,它包含了各种各样的组成部分,比如说文件系统,虚拟内存,进程管理,这些都是操作系统内实现了特定功能的子模块。宏内核的优势在于,因为这些子模块现在都位于同一个程序中,它们可以紧密的集成在一起,这样的集成提供很好的性能。例如Linux,它就有很不错的性能。
    另一种设计主要关注点是减少内核中的代码,它被称为Micro Kernel Design(微内核)
    在这种模式下,希望在kernel mode中运行尽可能少的代码。所以这种设计下还是有内核,但是内核只有非常少的几个模块,例如,内核通常会有一些IPC的实现或者是Message passing;非常少的虚拟内存的支持,可能只支持了page table;以及分时复用CPU的一些支持。
    某种程度上来说,这是一种好的设计。因为在内核中的代码的数量较小,更少的代码意味着更少的Bug。
    但是这种设计也有相应的问题。假设我们需要让Shell能与文件系统交互,比如Shell调用了exec,必须有种方式可以接入到文件系统中。通常来说,这里工作的方式是,Shell会通过内核中的IPC系统发送一条消息,内核会查看这条消息并发现这是给文件系统的消息,之后内核会把消息发送给文件系统。

qemu执行的第一条指令(入口)

地址0x80000000是一个被QEMU认可的地址。也就是说如果你想使用QEMU,那么第一个指令地址必须是它。所以,我们会让内核加载器从那个位置开始加载内核。
XV6从entry.s开始启动,这个时候没有内存分页,没有隔离性,并且运行在M-mode(machine mode)。XV6会尽可能快的跳转到kernel mode或者说是supervisor mode。
我们总是需要有一个用户进程在运行,这样才能实现与操作系统的交互,所以需要一个小程序来初始化第一个用户进程。

虚拟内存

使用页表(Page Table)。页表是在硬件中通过处理器和内存管理单元(Memory Management Unit)实现。
通常来说,内存地址对应关系的表单也保存在内存中。所以CPU中需要有一些寄存器用来存放表单在物理内存中的地址(CR3寄存器)——这样,CPU就可以告诉MMU,可以从哪找到将虚拟内存地址翻译成物理内存地址的表单。
因此,MMU并不会保存page table,只会从内存中读取page table,然后完成翻译
页表项分为两部分:index和offset
index用来确定页号,offset用来确定在页中的偏移量。
一个page为4k(4096bytes==2^12),因此需要12位来当作offset

如果每个进程都有自己的page table,那么每个page table表会有多大呢?

这个page table最多会有2^27个条目(虚拟内存地址中的index长度为27),这是个非常大的数字。如果每个进程都使用这么大的page table,进程需要为page table消耗大量的内存,并且很快物理内存就会耗尽。
所以实际上,硬件并不是按照这里的方式来存储page table。从概念上来说,你可以认为page table是从0到2^27,但是实际上并不是这样。实际中,page table是一个多级的结构:

index,实际上是由3个9bit的数字组成(L2,L1,L0)

为什么是PPN存在这些page directory中?为什么不是一个虚拟内存地址?

因为我们需要在物理内存中查找下一个page directory的地址。我们不能让我们的地址翻译依赖于另一个翻译,否则我们可能会陷入递归的无限循环中。所以page directory必须存物理地址。

页表缓存

实际中,几乎所有的处理器都会对于最近使用过的虚拟地址的翻译结果有缓存。这个缓存被称为:Translation Lookside Buffer(通常翻译成页表缓存)。你会经常看到它的缩写TLB。基本上来说,这就是Page Table Entry的缓存,也就是PTE的缓存。

硬件会完成3级 page table的查找,那为什么我们要在XV6中有一个walk函数来完成同样的工作?

首先XV6中的walk函数设置了最初的page table,它需要对3级page table进行编程所以它首先需要能模拟3级page table。另一个原因或许你们已经在syscall实验中遇到了,在XV6中,内核有它自己的page table,用户进程也有自己的page table,用户进程指向sys_info结构体的指针存在于用户空间的page table,但是内核需要将这个指针翻译成一个自己可以读写的物理地址。如果你查看copy_in,copy_out,你可以发现内核会通过用户进程的page table,将用户的虚拟地址翻译得到物理地址,这样内核可以读写相应的物理内存地址。这就是为什么在XV6中需要有walk函数的一些原因。

内核空间

kernel stack之下有一个未被映射的guard page。这样,如果kernel stack耗尽了,它会溢出到Guard page,但是因为Guard page的PTE中Valid标志位未设置,会导致立即触发page fault,这样的结果好过内存越界之后造成的数据混乱。

对于不同的进程会有不同的kernel stack吗?

答案是的。每一个用户进程都有一个对应的kernel stack

内核虚拟空间与用户虚拟空间大小一样吗?

进程的虚拟地址空间理论上与内核的虚拟地址空间一样大,但实际中,其使用的大小不会一样:
当kernel创建了一个进程,针对这个进程的page table也会从Free memory中分配出来。内核会为用户进程的page table分配几个page,并填入PTE。在某个时间点,当内核运行了这个进程,内核会将进程的根page table的地址加载到CR3寄存器中。从那个时间点开始,处理器会使用内核为那个进程构建的虚拟地址空间,所以内核为进程放弃了一些自己的内存。

汇编与C语言

这里面.secion,.global,.text分别是什么意思?

.global表示你可以在其他文件中调用这个函数。.secion表明这里的是代码段,段名为.text

.asm文件和.s文件有什么区别?

这两类文件都是汇编代码,.asm文件中包含大量额外的标注,而.s文件中没有。所以通常来说当你编译你的C代码,你得到的是.s文件。如果你好奇我们是如何得到.asm文件,makefile里面包含了具体的步骤。

Trap机制

程序运行是完成用户空间和内核空间的切换。每当

  • 程序执行系统调用
  • 程序出现了类似page fault、运算时除以0的错误
  • 一个设备触发了中断使得当前程序运行需要响应内核设备驱动
    都会发生这样的切换 。
    这里用户空间和内核空间的切换通常被称为trap

从用户态到内核态,trap处理过程:

  1. 保存32个用户寄存器。因为很显然我们需要恢复用户应用程序的执行,尤其是当用户程序随机的被设备中断所打断时。我们希望内核能够响应中断,之后在用户程序完全无感知的情况下再恢复用户代码的执行。所以这意味着32个用户寄存器不能被内核弄乱。但是这些寄存器又要被内核代码所使用,所以在trap之前,你必须先在某处保存这32个用户寄存器。
  2. 程序计数器也需要在某个地方保存,它几乎跟一个用户寄存器的地位是一样的,我们需要能够在用户程序运行中断的位置继续执行用户程序。
  3. 我们需要将mode改成supervisor mode,因为我们想要使用内核中的各种各样的特权指令。
  4. 修改CR3寄存器,现在正指向user page table,而user page table只包含了用户程序所需要的内存映射和一两个其他的映射,它并没有包含整个内核数据的内存映射。所以在运行内核代码之前,我们需要将CR3指向kernel page table。
  5. 将堆栈寄存器指向内核的堆栈段,因为我们需要一个堆栈来调用内核的C函数。

内存映射文件机制

在这个机制里面通过page table,可以将用户空间的虚拟地址空间,对应到文件内容(物理地址),这样你就可以通过内存地址直接读写文件。
而read/write系统调用需要进行用户态与内核态的转变。

为什么寄存器保存在trapframe,而不是用户代码中的栈?

如果我们想要运行任意编程语言实现的用户程序,内核就不能假设用户内存的哪部分可以访问,哪部分有效,哪部分存在。所以内核需要自己管理这些寄存器的保存,这就是为什么内核将这些内容保存在属于内核内存的trapframe中,而不是用户内存。

Page fault Basics

当出现page fault时,出错的虚拟地址将会被保存在CR2寄存器中。
当出现了page fault,有3个对我们来说极其有价值的信息,分别是:

  • 引起page fault的内存地址
  • 引起page fault的原因类型
  • 引起page fault时的程序计数器值,这表明了page fault在用户空间发生的位置
    我们之所以关心触发page fault时的程序计数器值,是因为在page fault handler中我们或许想要修复page table,并重新执行对应的指令。理想情况下,修复完page table之后,指令就可以无错误的运行了。所以,能够恢复因为page fault中断的指令运行是很重要的。

lazy allocation

核心思想非常简单,sbrk系统调基本上不做任何事情,唯一需要做的事情就是提升p->sz,将p->sz增加n,其中n是需要新分配的内存page数量。但是内核在这个时间点并不会分配任何物理内存。之后在某个时间点,应用程序使用到了新申请的那部分内存,这时会触发page fault,因为我们还没有将新的内存映射到page table。所以,如果我们解析一个大于旧的p->sz,但是又小于新的p->sz(注,也就是旧的p->sz + n)的虚拟地址,我们希望内核能够分配一个内存page,并且重新执行指令。
所以,当我们看到了一个page fault,相应的虚拟地址小于当前p->sz,同时大于stack,那么我们就知道这是一个来自于heap的地址,但是内核还没有分配任何物理内存。所以对于这个page fault的响应也理所当然的直接明了:在page fault handler中,通过kalloc函数分配一个内存page;初始化这个page内容为0;将这个内存page映射到user page table中;最后重新执行指令。
简单来说,就是,程序提前给操作系统说我想要多少内存,操作系统会做记录,但没有真正分配内存。当程序的确需要更多内存时,由于操作系统还没真的分配内存,所以会触发page fault,page handler知道是因为操作系统还没分配之前申请的内存,因此,在handler中进行分配。
再简单点说,就是,提前告知,用到时再分配
此方法好处就是,用到时才会分配内存,从而避免了内存浪费。缺点是,如果程序需要频繁写入新内存,则会不断触发page fault从而陷入内核,降低了运行效率。

COW fork

当发生page fault时,我们其实是在向一个只读的地址执行写操作。内核需要一个cow标志位来分辨现在是一个copy-on-write fork的场景,而不是应用程序在向一个正常的只读地址写数据。

interrput(中断)

中断与系统调用主要有3个小的差别:

  1. asynchronous。当硬件生成中断时,Interrupt handler与当前运行的进程在CPU上没有任何关联。但如果是系统调用的话,系统调用发生在运行进程的context下。
  2. concurrency。我们这节课会稍微介绍并发,在下一节课,我们会介绍更多并发相关的内容。对于中断来说,CPU和生成中断的设备是并行的在运行。网卡自己独立的处理来自网络的packet,然后在某个时间点产生中断,但是同时,CPU也在运行。所以我们在CPU和设备之间是真正的并行的,我们必须管理这里的并行。
  3. program device。我们这节课主要关注外部设备,例如网卡,UART,而这些设备需要被编程。每个设备都有一个编程手册,就像RISC-V有一个包含了指令和寄存器的手册一样。设备的编程手册包含了它有什么样的寄存器,它能执行什么样的操作,在读写控制寄存器的时候,设备会如何响应。不过通常来说,设备的手册不如RISC-V的手册清晰,这会使得对于设备的编程会更加复杂。

自旋锁(spin lock)

实现锁的主要难点在于锁的acquire接口,在acquire里面有一个死循环,循环中判断锁对象的locked字段是否为0,如果为0那表明当前锁没有持有者,当前对于acquire的调用可以获取锁。之后我们通过设置锁对象的locked字段为1来获取锁。最后返回。
如果锁的locked字段不为0,那么当前对于acquire的调用就不能获取锁,程序会一直spin。也就是说,程序在循环中不停的重复执行,直到锁的持有者调用了release并将锁对象的locked设置为0。

实现里面会有什么样的问题?

两个进程可能同时读到锁的locked字段为0。
为了解决这里的问题并得到一个正确的锁的实现方式,其实有多种方法,但是最常见的方法是依赖于一个特殊的硬件指令。这个特殊的硬件指令会保证一次test-and-set操作的原子性

进程线程切换

当用户程序在运行时,实际上是用户进程中的一个用户线程在运行。如果程序执行了一个系统调用或者因为响应中断走到了内核中,那么相应的用户空间状态会被保存在程序的trapframe中,同时属于这个用户程序的内核线程被激活。所以首先,用户的程序计数器,寄存器等等被保存到了trapframe中,之后CPU被切换到内核栈上运行。
用户寄存器存在trapframe中,内核线程的寄存器存在context中。
或许出于简化代码或者让代码更清晰的目的,trapframe还是只包含进入和离开内核时的数据。而context结构体中包含的是在内核线程和调度器线程之间切换时,需要保存和恢复的数据。

进程线程切换时需要获取锁!

一个进程出于某种原因想要进入休眠状态,比如说出让CPU或者等待数据,它会先获取自己的锁;之后进程将自己的状态从RUNNING设置为RUNNABLE;
获取进程的锁的原因是,这样可以阻止其他CPU核的调度器线程在当前进程完成切换前,发现进程是RUNNABLE的状态并尝试运行它。
等待锁之前必须关闭中断:你或许会想,为什么不能先“自旋”等待锁释放(获得锁),再关闭中断?因为,如果可以进行中断,则在获取锁之后可能会有一个短暂的时间段锁被持有了但是中断没有关闭,在这个时间段内的设备的中断处理程序可能会引起死锁。
同时,线程切换过程中,不允许持有其他锁:如果持有其他锁,则切换到另一个线程A后,该锁不能被释放,如果刚好切换的线程A需要获取该锁,则会一直循环等待,并且不会被中断。原因看上文黑字!

子进程调用exit

直到子进程exit的最后,它都没有释放所有的资源,因为它还在运行的过程中,所以不能释放这些资源。相应的其他的进程,也就是父进程,使用wait来释放运行子进程代码所需要的资源。
在子进程调用exit之后,父进程调用wait释放之前,子进程称作僵尸进程!

kill系统调用

kill系统调用实际上基本不用做任何事情,它先扫描进程表单,找到目标进程。然后只是将进程的proc结构体中killed标志位设置为1。如果进程正在SLEEPING状态,将其设置为RUNNABLE。这里只是将killed标志位设置为1,并没有停止进程的运行。
当目标进程运行到内核代码中能安全停止运行的位置时,会检查自己的killed标志位,如果设置为1,目标进程会自愿的执行exit系统调用。
所以kill系统调用并不是真正的立即停止进程的运行,它更像是这样:如果进程在用户空间,那么下一次它执行系统调用它就会退出,又或者目标进程正在执行用户代码,当下一次定时器中断或者其他中断触发了,进程才会退出。所以从一个进程调用kill,到另一个进程真正退出,中间可能有很明显的延时。

file system

典型文件系统API:openwritelinkunlinkreadclose

文件系统结构

inode:代表文件的对象
文件系统中核心的数据结构就是inode和file descriptor
后者主要与用户进程进行交互。
文件系统还挺复杂的,所以最好按照分层的方式进行理解。可以这样看:

  • 在最底层是磁盘,也就是一些实际保存数据的存储设备,正是这些设备提供了持久化存储。
  • 在这之上是buffer cache或者说block cache,这些cache可以避免频繁的读写磁盘。这里我们将磁盘中的数据保存在了内存中。
  • 为了保证持久性,再往上通常会有一个logging层。许多文件系统都有某种形式的logging
  • 在logging层之上,有inode cache,这主要是为了同步(synchronization)。inode通常小于一个disk block,所以多个inode通常会打包存储在一个disk block中。为了向单个inode提供同步操作,XV6维护了inode cache。
  • 再往上就是inode本身了。它实现了read/write。
  • 再往上,就是文件名,和文件描述符操作。

1、disk

  • sector通常是磁盘驱动可以读写的最小单元,它过去通常是512字节。
  • block通常是操作系统或者文件系统视角的数据。它由文件系统定义,在XV6中它是1024字节。所以XV6中一个block对应两个sector。通常来说一个block对应了一个或者多个sector。
    通常来说:
  • block0要么没有用,要么被用作boot sector来启动操作系统。
  • block1通常被称为super block,它描述了文件系统。它包含磁盘上有多少个block共同构成了文件系统这样的信息。我们之后会看到XV6在里面会存更多的信息,你可以通过block1构造出大部分的文件系统信息。
  • 在XV6中,log从block2开始,到block32结束。实际上log的大小可能不同,这里在super block中会定义log就是30个block。
  • 接下来在block32到block45之间,XV6存储了inode。我之前说过多个inode会打包存在一个block中,一个inode是64字节。
  • 之后是bitmap block,这是我们构建文件系统的默认方法,它只占据一个block。它记录了数据block是否空闲。
  • 之后就全是数据block了,数据block存储了文件的内容和目录的内容。

2、block cache(buffer cache)

block cache就是磁盘中block在内存中的拷贝

3、logging

文件系统crash之后的问题的解决方案,其实就是logging;
当需要更新文件系统时,我们并不是更新文件系统本身。假设我们在内存中缓存了bitmap block,也就是block 45。当需要更新bitmap时,我们并不是直接写block 45,而是将数据写入到log中,并记录这个更新应该写入到block 45。

4、inode

inode:64字节

  • 通常来说它有一个type字段,表明inode是文件还是目录。
  • nlink字段,也就是link计数器,用来跟踪究竟有多少文件名指向了当前的inode。
  • size字段,表明了文件数据有多少个字节。
  • 不同文件系统中的表达方式可能不一样,不过在XV6中接下来是一些block的编号,例如编号0,编号1,等等。XV6的inode中总共有12个block编号。这些被称为direct block number。这12个block编号指向了构成文件的前12个block。举个例子,如果文件只有2个字节,那么只会有一个block编号0,它包含的数字是磁盘上文件前2个字节的block的位置。
  • 之后还有一个indirect block number,它对应了磁盘上一个block,这个block包含了256个block number,这256个block number包含了文件的数据。所以inode中block number 0到block number 11都是direct block number,而block number 12保存的indirect block number指向了另一个block。

    由此可算出一个文件的最大长度等于=(256+12)*1024bytes——一个block1024字节(不同文件系统不一样)

宏内核与微内核

宏内核优点:

  • 高度抽象的接口,通常可移植。
  • 可以向应用程序隐藏复杂性。
  • 强大的抽象还可以帮助管理共享资源。
  • 因为所有这些功能都在一个程序里面,所有的内核子系统,例如文件系统,内存分配,调度器,虚拟内存系统都是集成在一个巨大的程序中的一个部分,这意味着它们可以访问彼此的数据结构,进而使得依赖多个子系统的工具更容易实现。
    缺点:
  • 大且复杂。
  • 不可避免的会遇到Bug和安全漏洞。
  • Linux中包含了大量的内容使得它很通用,这很好,但是另一方面,通用就意味着慢。对于各种不同的场景都能支持,或许就不能对某些特定场景进行优化。
    微内核:微内核的核心就是实现了IPC(Inter-Process Communication)以及线程和任务的tiny kernel

MIT6.S081简单总结相关推荐

  1. MIT6.S081 2021

    MIT6.S081 2021 环境配置 Xv6 and Unix utilities vscode格式化头文件排序问题 以地址空间的视角看待变量 其他 代码参考 system calls trace ...

  2. 操作系统MIT6.S081:P7->Interrupts

    本系列文章为MIT6.S081的学习笔记,包含了参考手册.课程.实验三部分的内容,前面的系列文章链接如下 操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口 操作系统MIT6. ...

  3. 操作系统MIT6.S081:[xv6参考手册第4章]->Trap与系统调用

    本系列文章为MIT6.S081的学习笔记,包含了参考手册.课程.实验三部分的内容,前面的系列文章链接如下 操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口 操作系统MIT6. ...

  4. MIT6.S081 Multithreading

    MIT6.S081 Multithreading xv6 book记录 Uthread Using threads Barrier xv6 book记录 阅读xv6 book之前,简要看一下<深 ...

  5. Mit6.S081学习记录

    Mit6.S081学习记录 前言 一.课程简述 二.课程资源 1,课程主页 2,参考书 3,实验环境 三.学习过程 Mit6.S081-实验环境搭建 Mit6.S081-GDB使用 Mit6.S081 ...

  6. 操作系统MIT6.S081:Lab4->Trap

    本系列文章为MIT6.S081的学习笔记,包含了参考手册.课程.实验三部分的内容,前面的系列文章链接如下 操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口 操作系统MIT6. ...

  7. MIT6.S081 2021 Copy-on-Write Fork for xv6

    MIT6.S081 2021 Copy-on-Write Fork for xv6 简要介绍 debug 代码参考 简要介绍 There is a saying in computer systems ...

  8. MIT6.S081操作系统实验——操作系统是如何在qemu虚拟机中启动的?

    前言 为了更好的理解基于RISC-V体系的Xv6操作系统是如何在qemu中启动的,我将详细地梳理从执行make qemu命令开始到Xv6的shell启动为止的具体流程. 执行make qemu后发生了 ...

  9. 【MIT6.S081/6.828】手把手教你搭建开发环境

    文章目录 1. 简介 2. 安装ubuntu20.04 3. 更换源 3.1 更换/etc/apt/sources.list文件里的源 3.2 备份源列表 3.3 打开sources.list文件修改 ...

最新文章

  1. php中关于mysqli和mysql区别
  2. Swing实现全屏(覆盖任务栏和不覆盖任务栏)
  3. python快速入门第三版-Python3快速入门
  4. 基于几何距离的椭圆拟合
  5. ThinkPHP6项目基操(12.实战部分 验证码)
  6. 案例 以继承的方式实现解析频道节目单 c# 1614262275
  7. WebService之初体验
  8. day69 Django--Form组件
  9. WordPress文章阅读量统计和显示(非插件, 刷新页面不累加)
  10. 数据:以太坊2.0合约新增8032 ETH
  11. 今晚博文视点大咖直播伴你读No.2:人工智能学习路线
  12. 异步FIFO中格雷码和二进制数据的转换
  13. python 广义线性模型_scikit-learn 1.1 广义线性模型(Generalized Linear Models)
  14. 统计相关系数(3)——Kendall Rank(肯德尔等级)相关系数及MATLAB实现
  15. linux vi面板如何复制一行
  16. 前端面试题及解答(尽量口语化,模拟面试现场时的回答)
  17. mac卸载了xcode后,运行软件提示:xcode-select: error: invalid developer directory
  18. android的短信发送全过程源代码分析
  19. 个人ip如何运营?如何打造自己的个人ip?具体好处有哪些?
  20. 算法学习-素数与合数小结

热门文章

  1. Educoder Spring入门 第一关:Hello Spring
  2. dockerfile制作Java镜像
  3. 三星i919u android 6,三星i919u刷机方法盘点【图解】
  4. 清除 vue 表单验证残留
  5. Vsphere 向存储器中上传文件
  6. Unity 屏幕特效 之 简单地调整颜色的亮度、饱和度、对比度
  7. CPU 飙高问题排查和解决方法
  8. Ghost Win10 TH2正式版10586简体中文64位专业版
  9. 小米电视系统统计服务器,史上最全!小米电视4 各系列超详细对比
  10. java游戏课程设计报告_java课程设计报告游戏_相关文章专题_写写帮文库