xv6部分源码阅读-1
xv6部分源码阅读
前言
在lab2中,我们会为了完成attack这个实验,而花费大量的时间去阅读相关的系统调用源码,以此来分析出我们最终secret所在的页表的位置,而我写lab2中,重点并没有关注其中的逻辑关系,有很多想仔细分析的函数都没有深入地去看,故写此文,以便理解记忆。
proc
要想搞清楚proc所分配的页表,首先,还得了解我们的proc是什么样子的:
// 每个进程的状态
struct proc {struct spinlock lock; // 保护进程状态的锁// 在使用以下字段时必须持有 p->lock 锁:enum procstate state; // 进程的当前状态void *chan; // 如果非零,表示进程正在 chan 上睡眠int killed; // 如果非零,表示该进程已经被杀死int xstate; // 进程退出时返回给父进程的退出状态int pid; // 进程 IDint tracing; // 跟踪系统调用的掩码// 必须持有 wait_lock 锁才能使用以下字段:struct proc *parent; // 父进程// 这些字段是进程私有的,因此不需要持有 p->lock 锁:uint64 kstack; // 内核栈的虚拟地址uint64 sz; // 进程内存大小(字节数)pagetable_t pagetable; // 用户页表struct trapframe *trapframe; // trampoline.S 中使用的数据页面struct context context; // 切换到该进程时的上下文struct file *ofile[NOFILE]; // 打开的文件struct inode *cwd; // 当前工作目录char name[16]; // 进程名称(用于调试)
};
我们的重点便是下半部分,kstack就是我们的内核栈的虚拟地址的指针,当我们进程需要执行系统调用的时候,我们首先会通过trampoline保存我们的上下文数据(寄存器中的数据),保存到哪?每一个proc都有可能执行系统调用,所以不能将他们存放在同一个地方,所以,我们的proc结构体还维护了一个trapframe字段,用来我保存我们在执行系统调用之前的上下文,以便于返回时恢复状态,我们可以在trampoline.S里面轻易地看见,保存上下文的代码:
....sd ra, 40(a0) sd sp, 48(a0) sd gp, 56(a0)sd tp, 64(a0) sd t0, 72(a0) .....
其中的a0,表示我们的trapframe的地址,ra/sp等表示我们执行系统调用之前的寄存器,这就表示将这些寄存器保存到我们的trapframe中,他作为一个单独的页表分配,而在返回的时候恢复上下文的代码,我们也可以在trampoline.S中找到:
...ld ra, 40(a0)ld sp, 48(a0)ld gp, 56(a0) ld tp, 64(a0) ld t0, 72(a0) ...
这就表示从trapframe中获取信息,并赋予寄存器。
在这里我们可以重新理一下之前做过的lab2,刚开始,可能很多人都对trapframe和trampoline不是很理解,实际上,trapframe就是我们需要开辟的新空间,为我们进程保存上下文,恢复上下文服务的,而trampoline是我们所有的进程通过用户页表映射到同一个地方,公共的部分,所有的用户页表都会映射到这个位置,而我们的三级页表实际上也需要分配一个页表来将我们的trampoline和trapframe映射到真正的物理地址上面,最终,我们的proc是如下的映射结构:
VPN2[511]└── VPN1[511]└── VPN0├── [511] → trampoline└── [510] → trapframe
VPN2是我们根页表,也就是最开始创建的页表,随后调用mappages的时候,就会自动创建二级页表,自动创建三级页表(最开始只有VPN2),然后才能建立我们的映射关系。
所以这里的proc_pagetable一共创建了三个页表,也就可以解释了。
exec
这也是一个相当有趣且复杂的系统调用,我在做lab的时候,没有看见需要去阅读源码的准备工作,所以最开始就给没看,写起lab来也是很吃力的,在exec是exec之前,首先,他是一个系统调用,我们可以在syscall.c里面找到所有syscall的入口:
void
syscall(void)
{int num;struct proc *p = myproc(); // 获取当前进程结构体指针num = p->trapframe->a7; // 获取系统调用号(存储在 trapframe 的 a7 寄存器中)// 检查系统调用号是否在有效范围内,且是否有对应的系统调用函数if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {// 根据系统调用号 `num` 查找对应的系统调用函数,并调用它//这里的syscall[num]就是直接调用了系统调用的函数。// 将返回值存储在进程的 trapframe 中的 a0 寄存器中p->trapframe->a0 = syscalls[num](); // 如果进程启用了系统调用追踪功能,打印系统调用的详细信息if ((p->tracing >> num) & 1) {printf("%d: syscall %s -> %ld\n", p->pid, syscall_str[num], p->trapframe->a0);}} else {// 如果系统调用号无效或没有对应的系统调用函数,输出错误信息// 并将返回值设置为 -1printf("%d %s: unknown sys call %d\n", p->pid, p->name, num);p->trapframe->a0 = -1;}
}
我们知道,这里的syscalls数组实际上就是各个系统调用的函数的指针,所以我们通过他,就可以进入到我们的sys_exec了,接下来,就是关于他的代码,而想要去理解这个exec,就得明白,他是干什么的,exec事实上,在lab1里面也见识了很多次
首先是我们执行exec系统调用的入口:
uint64
sys_exec(void)
{char path[MAXPATH], *argv[MAXARG];int i;uint64 uargv, uarg;// 获取传入的参数:参数 1 是用户空间的 argv 地址argaddr(1, &uargv);// 获取程序路径if (argstr(0, path, MAXPATH) < 0) {return -1; // 如果路径获取失败,返回 -1}memset(argv, 0, sizeof(argv)); // 初始化 argv 数组// 从用户空间获取 argv 数组for (i = 0;; i++) {if (i >= NELEM(argv)) {goto bad;}// 获取用户空间中每个参数的地址if (fetchaddr(uargv + sizeof(uint64) * i, (uint64*)&uarg) < 0) {goto bad; }// 如果参数为 0,表示 argv 数组结束if (uarg == 0) {argv[i] = 0;break;}// 为 argv[i] 分配内存argv[i] = kalloc();if (argv[i] == 0)goto bad;// 获取用户空间中字符串并复制到内核空间if (fetchstr(uarg, argv[i], PGSIZE) < 0)goto bad;}// 此处是真正的执行exec函数,加载指定的程序//下面我们会详细介绍int ret = exec(path, argv);// 释放 argv 数组中分配的内存for (i = 0; i < NELEM(argv) && argv[i] != 0; i++)kfree(argv[i]);return ret; // 返回 exec 的返回值,成功则为 0,失败则为 -1
bad:// 如果有任何错误发生,释放所有已分配的内存for (i = 0; i < NELEM(argv) && argv[i] != 0; i++)kfree(argv[i]);return -1; // 返回 -1 表示执行失败
}
我们可以看见,我们系统调用的入口做了一系列的准备工作,然后执行了exec之后,从内核态返回,这里返回就不是执行原来的程序了,而是执行我们在exec中加载的程序!当然,前提是exec执行成功了。
随后,我们可以来看看我们的exec到底干了什么,首先,我们的exec在最开始初始化了一大堆值,我们目前先不关心他,我们要知道,在最开始,我们的exec调用了
begin_op();
这个函数,有啥用?事实上,每一个文件操作之前,前面都会跟上一个begin_up,下面是他的具体实现:
void begin_op(void)
{// 获取日志锁,确保对日志操作的独占访问acquire(&log.lock);// 循环直到可以开始新的文件系统操作while(1) {// 如果当前日志正在提交,等待提交完成if (log.committing) {// 当前日志正在提交,休眠并等待提交完成sleep(&log, &log.lock);} // 如果当前操作可能会超出日志的空间,等待日志提交else if (log.lh.n + (log.outstanding + 1) * MAXOPBLOCKS > LOGSIZE) {// 当前操作可能耗尽日志空间,休眠并等待提交sleep(&log, &log.lock);} else {// 当前操作可以进行,增加待处理操作的计数log.outstanding += 1;// 释放日志锁,允许其他线程访问日志release(&log.lock);// 成功开始文件系统操作,跳出循环break;}}
}
我们可以看到,事实上,这就是一个加上一个锁的操作,而是通过日志加锁,来保证每个文件操对日志操作的原子性,顺序性,但是这里竟然是全局锁,哈人。通过加这样一个锁,我们对日志的操作就实现了原子性,随后,我们会从文件系统中寻找我们需要执行的系统调用:
if((ip = namei(path)) == 0){end_op();return -1;}ilock(ip);
ilock实际上就是锁定该文件,防止其他进程修改
随后,我们需要读取这个文件,然后对这个文件进行一些验证,随后分配空间,在这里需要注意,trapframe依旧是旧的,之后会进行更改:
// 从 inode 所代表的文件中读取 ELF 头部(ELF Header)到 elf 结构体中// 参数含义:// ip:表示打开的文件 inode// 0:表示这是从用户空间发起的(传 0)// (uint64)&elf:读取到内存 elf 的地址// 0:从文件开头开始读// sizeof(elf):读取字节数等于 ELF 头的大小if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))goto bad; // 如果读取失败或读取长度不足,则跳转到错误处理// 检查读取到的 ELF 文件头的魔数是否匹配// ELF_MAGIC 是一个特定的常数(0x464C457F),用来判断文件是否为 ELF 格式if(elf.magic != ELF_MAGIC)goto bad; // 如果不是 ELF 文件,即魔数不对,跳转到错误处理// 为当前进程 p 创建一个新的用户页表// 旧的页表包含的是之前进程的地址空间,现在要执行新程序,因此需要一个全新的页表// 这里会映射trampoline和trapframe,但是trapframe在之后会进行更新。if((pagetable = proc_pagetable(p)) == 0)goto bad; // 分配失败时跳转到错误处理
之后,还会进行一系列验证,最终,会将我们的elf文件的段数据写入内存,总体验证过程和流程如下:
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){// 从 ELF 文件中读取每个程序头(Program Header)到 ph 结构体if(readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))goto bad;// 如果程序头的类型不是 ELF_PROG_LOAD(表示可加载的程序段),跳过当前程序头if(ph.type != ELF_PROG_LOAD)continue;// 程序段的内存大小必须大于或等于文件大小,否则不合法,跳转到错误处理if(ph.memsz < ph.filesz)goto bad;// 如果程序段的结束地址小于起始地址,说明程序头出错,跳转到错误处理if(ph.vaddr + ph.memsz < ph.vaddr)goto bad;// 程序段的起始地址必须是页面大小对齐的,否则跳转到错误处理if(ph.vaddr % PGSIZE != 0)goto bad;// 为程序段分配内存,确保分配的内存区域是连续的,并且不会与其他程序段冲突uint64 sz1;if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz, flags2perm(ph.flags))) == 0)goto bad;sz = sz1;// 将 ELF 程序段的数据加载到内存中if(loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0)goto bad;
}
一旦发现错误,就会立刻跳转到错误处理的地方,而最后,我们会将loadseg加载到页表中:
static int
loadseg(pagetable_t pagetable, uint64 va, struct inode *ip, uint offset, uint sz)
{uint i, n;uint64 pa;// 遍历程序段的每一页(PGSIZE大致为4KB)for(i = 0; i < sz; i += PGSIZE){// 获取当前虚拟地址(va + i)对应的物理地址pa = walkaddr(pagetable, va + i);// 如果无法找到对应的物理地址,发生错误if(pa == 0)panic("loadseg: address should exist");// 判断当前页是否是最后一页,如果是,则计算实际读取的大小if(sz - i < PGSIZE)n = sz - i;elsen = PGSIZE;// 从文件(inode)中读取数据到物理地址 pa 上if(readi(ip, 0, (uint64)pa, offset+i, n) != n)return -1; // 如果读取失败,返回错误}return 0; // 成功加载所有数据
}
实际上,loadseg对于我们并不是过于重要,我们只需要知道,他会将我们给的段写入到单独的内存页,这样就行了。
那么,将段单独写入内存页的逻辑在哪?其实我们已经见识过了:
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph))
这里的for循环,就是将我们的程序段循环遍历的地方,通过这样,我们可以将符合要求的段加入我们的页表中,即便我们不知道有多少个段,我们也可以加一个printf语句来帮助我们判断,从而弄清楚到底分配了几个页。
至此,我们就搞清楚了我们的页表是如何分配的了
但是,到目前为止,我们仅仅是将我们的页表给分配了,并且将我们的段都写入进去了,我们还需要分配我们的栈空间。
// 将当前地址 sz 向上对齐到页边界,为用户栈分配内存页(至少 USERSTACK + 1 页)// 第一页作为 "栈保护页",不可访问;剩下的 USERSTACK 页是实际的用户栈sz = PGROUNDUP(sz); // 保证下一次页分配是对齐的uint64 sz1;if((sz1 = uvmalloc(pagetable, sz, sz + (USERSTACK+1)*PGSIZE, PTE_W)) == 0)goto bad; // 分配失败,跳转错误处理sz = sz1;// 清除栈底的 guard 页,即使映射存在也让它不可访问(trap)uvmclear(pagetable, sz-(USERSTACK+1)*PGSIZE);// 设置 sp(栈指针)和 stackbase(栈底,栈增长不会低于这里)sp = sz; // 初始 sp 指向用户栈顶部stackbase = sp - USERSTACK*PGSIZE; // 栈的最低地址(不包括 guard 页)
当然,我们可以看见除了我们的栈空间,还分配了栈保护页,这是为了防止栈溢出是将其他区域的数据覆盖掉所设置的,当发生栈溢出,访问到保护页的时候,系统就会触发异常。
随后需要初始化我们的栈
// 将命令行参数字符串逐个压入用户栈,同时构建一个 ustack[] 数组,
// 存放每个参数字符串在用户栈中的地址(即 argv[i] 的指针)。
for(argc = 0; argv[argc]; argc++) {// 超出限度,就转到错误处理if(argc >= MAXARG)goto bad;// 给这个参数字符串分配空间,长度需要加上结尾的 '\0'sp -= strlen(argv[argc]) + 1;// 内存对齐sp -= sp % 16;// 如果当前栈指针已经低于栈底,说明栈空间不够,返回错误if(sp < stackbase)goto bad;// 把参数字符串从内核拷贝到用户页表的栈空间(虚拟地址为 sp)if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0)goto bad;// 将我们每个参数的指针放在数组里面。ustack[argc] = sp;
}
// 添加 NULL 结尾,相当于 argv[argc] = 0,表示参数结束
ustack[argc] = 0;
可以看见,我们主要将我们的参数通过sp拷贝到用户的栈空间中,并且初始化了一个ustack数组来存储这些sp指针,他在之后会显现他的作用:
// 这里是为我们需要拷贝的数据腾出栈空间
// 所以需要改变指针栈指针sp -= (argc+1) * sizeof(uint64);
// 内存对齐sp -= sp % 16;if(sp < stackbase)goto bad;
// 将我们的ustack数组的指针数组拷贝到栈空间中,我们之后便能通过ustack中存储的sp指针
// 去找到对应的参数if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0)goto bad;
综上,我们将ustack拷贝到栈空间,随后我们就可以轻易地通过这个数组存储的指针,访问到相对应的参数了,为啥要通过ustack访问?这是为了保证我们栈访问的安全性,统一性。
最后,便是我们的收尾工作了,我们的exec真的要结束了:
// 将栈指针 (sp) 保存到 trapframe 的 a1 寄存器。
// 这个栈指针将在用户程序中使用,'sp' 指向用户栈。
p->trapframe->a1 = sp;// 保存程序名以便调试。
// 从完整路径中提取程序名(即最后一个 '/' 后的部分)。
for(last = s = path; *s; s++)if (*s == '/') // 查找路径中的最后一个 '/'last = s + 1; // 程序名从 '/' 后开始
safestrcpy(p->name, last, sizeof(p->name)); // 将程序名复制到进程名称中,方便调试// 提交新的用户空间镜像,通过切换页表和更新进程状态来完成。
// 保存当前的(旧的)页表
oldpagetable = p->pagetable;
// 设置新的页表
p->pagetable = pagetable;
// 更新进程的大小
p->sz = sz;
// 设置进程的初始程序计数器(epc)为 ELF 文件的入口点
p->trapframe->epc = elf.entry; // 初始程序计数器 = main 函数的地址
// 设置栈指针(sp)为用户空间的栈指针
p->trapframe->sp = sp; // 初始栈指针
// 释放旧的页表资源
proc_freepagetable(oldpagetable, oldsz);
我们这里主要是将栈指针的起始位置存入我们的trapframe,a0保存的是我们的系统调用号,而a1则是我们的参数列表的栈指针。这一步已经完成了,最后的,那就是我们的首位工作,将各个参数更新,除此之外,还需要将我们的旧的页表给释放掉,值得注意的是,我们这里替换的页表实际上是用户态的页表,而当前的代码是在内核态中执行的,所以我们的exec不会受到用户态的替换页表的影响。
系统调用到底干了啥?
另外讲点有意思的,我们之前提过无数次用户态和内核态,我们的用户态通过执行系统调用,就会跳转到内核态,那么到底是怎么跳转的?我们现在就来解决这个问题:
在我们执行系统调用的时候,实际上,在用户态代码中,我们的这些调用都会在user.h中声明,在编译之后,usys.S会通过usys.pl这个脚本去生成出来,这样,就实现了我们在用户态执行系统调用的跳转的汇编代码,举个例子,比如说exec:
exec:li a7, SYS_exececallret
.global open
我们将SYS_exec存入a7这个寄存器中,SYS_exec是啥?事实上,在我们的lab2中应该已经能够知道,SYS_exec就是在kernel/syscall.h中定义的一个常量,而这里也确实引入了这样一个头文件,所以,这里是直接存入了我们的系统调用编号,方便调用的!
然后调用ecall,会跳转到我们的内核指定的位置,也就是stvec寄存器指向的位置,在哪里指定的?当然是在初始化我们的xv6的时候指定的,我们可以在trap.c中找到通过调用函数来设置stvec的函数w_stvec,其内部是这样的:
w_stvec(uint64 x)
{asm volatile("csrw stvec, %0" : : "r" (x));
}
通过这个函数,我们可以动态的修改我们的stvec,因为我们的ecall不仅有可能在用户态执行,还可能在内核态执行,所以通过这种方式,当我们从usertrap中返回的时候,我们就会把这个cpu的stvec寄存器设置成用户产生中断的处理位置,这样,就可以让我们的ecall跳转到正确的位置。
此时我们是在用户态中调用的ecall,所以应该是跳转到trampoline.S中的uservec代码段中,如下:
.globl uservec
uservec: # 保存用户空间传入的 a0 寄存器值到 sscratch 寄存器,# 因为 a0 会用于获取 TRAPFRAME 地址。csrw sscratch, a0# 设置 a0 为 TRAPFRAME 的地址,# 每个进程都有一个单独的 trapframe 内存区域,# 但是在每个进程的用户页表中,该区域被映射到相同的虚拟地址。li a0, TRAPFRAME...#此处省略很多行保存寄存器逻辑# 将之前保存的用户空间的 a0 值(即 TRAPFRAME 的地址)重新保存到 p->trapframe->a0。csrr t0, sscratchsd t0, 112(a0)# 从 p->trapframe->kernel_sp 加载内核栈指针,并设置 sp。ld sp, 8(a0)# 从 p->trapframe->kernel_hartid 加载当前的 hartid,设置 tp。ld tp, 32(a0)# 从 p->trapframe->kernel_trap 加载 usertrap 函数的地址,并跳转到 usertrap。ld t0, 16(a0)# 从 p->trapframe->kernel_satp 获取内核页表地址,设置 satp 寄存器。ld t1, 0(a0)# 等待先前的内存操作完成,以便后续的操作使用用户页表。sfence.vma zero, zero# 安装内核页表(切换到内核页表)。csrw satp, t1# 刷新 TLB 缓存,移除过时的用户页表条目。sfence.vma zero, zero# 跳转到 usertrap 函数,该函数不会返回。jr t0
可以看见,通过一系列相关操作,我们就会跳转到usertrap(C语言函数)中,而usertrap就是进入到syscall之前需要做的准备工作,但是此时已经真正的进入内核态了!
下面来看看我们的usertrap究竟是什么个事
// 处理来自用户态的中断、异常或系统调用。
// 被 trampoline.S 中的 uservec 调用。
void
usertrap(void)
{int which_dev = 0;// 检查是否真的是从用户态陷入内核的。// 如果 SPP(Supervisor Previous Privilege)不为 0,说明不是从用户态过来的,出错。if((r_sstatus() & SSTATUS_SPP) != 0)panic("usertrap: not from user mode");// 设置 stvec 为内核态的中断向量 kernelvec,表示从现在开始内核态中断交由 kernelvec 处理// 这是为了防止在内核执行期间再次陷入时,还用的是用户态的 uservec。w_stvec((uint64)kernelvec);// 获取当前运行的进程struct proc *p = myproc();// 保存当前触发 trap 时的 PC(sepc 存储的是触发 trap 的用户态地址)p->trapframe->epc = r_sepc();// 判断是不是系统调用(scause=8 表示 ecall-from-U-mode)if(r_scause() == 8){// 如果进程已经被标记为 killed,则直接退出if(killed(p))exit(-1);// ecall 指令执行后 sepc 仍然指向 ecall,所以这里 +4 是为了从下一条指令继续执行p->trapframe->epc += 4;// 开中断(因为 syscall 处理期间我们已经不需要再屏蔽中断了)intr_on();// 执行系统调用syscall();} else if((which_dev = devintr()) != 0){// 如果是设备中断(如时钟中断、UART 等),交给 devintr() 处理// 返回值标识具体设备,这里先保存下来以便后续判断是否需要 yield CPU// 如果是设备中断,正常返回;否则下面打印错误信息} else {// 既不是 syscall 也不是设备中断,属于非法中断或异常printf("usertrap(): unexpected scause 0x%lx pid=%d\n", r_scause(), p->pid);printf(" sepc=0x%lx stval=0x%lx\n", r_sepc(), r_stval());// 标记进程为 killed,稍后会终止它setkilled(p);}// 如果此时进程被标记为 killed,就退出if(killed(p))exit(-1);// 如果是时钟中断,就让出 CPU(进行调度)if(which_dev == 2)yield();// 从内核返回用户态(设置好寄存器、trapframe、切换 stvec)usertrapret();
}
每一步相关的说明都已经注释好了,在usertrap中,我们真的会调用一个syscall函数,并且在调用完成之后,真的会调用usertrapret返回到我们的用户态,当然,如果我们执行的是exec系统调用,返回的用户态已经是被替换过的全新的程序,至此,我们已经实现了一个系统调用的闭环。
目前来看,我们已经理解了很多之前不知道的东西,现在,我们来阅读阅读页表课前置要求的一些源码吧!
sbrk/kalloc/mappages
其实最好的阅读方式并不是通过硬啃一个文件,一段源代码能够解决的,关键是流程,我们应该跟着流程来分析,首先是我们在lab2中用到的sbrk,我们可以在syscall.c中找到数组中的sbrk,我们可以通过它跳转到sys_sbrk的函数:
uint64
sys_sbrk(void)
{uint64 addr;int n;argint(0, &n);addr = myproc()->sz;if(growproc(n) < 0)return -1;return addr;
}
这里的argint事实上也是获取参数,然后将我们传入的参数向下传入给growproc函数,而这个函数是干啥的?我们可以仔细来看看这个函数:
int
growproc(int n)
{uint64 sz;struct proc *p = myproc();// 获取当前进程的虚拟内存大小(sz表示进程的堆顶)sz = p->sz;if(n > 0){// 如果 n > 0,表示需要增加内存// 调用 uvmalloc 分配从 sz 到 sz + n 的内存if((sz = uvmalloc(p->pagetable, sz, sz + n, PTE_W)) == 0) {return -1; // 分配失败,返回 -1}} else if(n < 0){// 如果 n < 0,表示需要减少内存sz = uvmdealloc(p->pagetable, sz, sz + n); // 释放 sz 到 sz + n 之间的内存}// 更新进程的虚拟内存大小(sz)p->sz = sz;return 0; // 返回 0 表示成功
}
在这里,我们可以看见至关重要的一个函数,uvmalloc,之前做lab2分析的时候,无论在哪里都可以看见他的身影,虽然我通过注释标出了他的功能,但是我们应该继续往下看,他到底是干了个什么事!
uint64
uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz, int xperm)
{char *mem;uint64 a;int sz;// 如果新的内存大小小于当前大小,则返回当前大小if(newsz < oldsz)return oldsz;// 将 oldsz 对齐到页边界(上舍入到最接近的页边界)oldsz = PGROUNDUP(oldsz);// 从 oldsz 到 newsz 分配内存,每次分配一个页for(a = oldsz; a < newsz; a += sz){sz = PGSIZE; // 每次分配一个页面mem = kalloc(); // 分配一个物理页if(mem == 0){// 如果分配失败,释放已经分配的内存并返回 0uvmdealloc(pagetable, a, oldsz);return 0;}
#ifndef LAB_SYSCALL// 如果没有启用 LAB_SYSCALL,清零分配的内存memset(mem, 0, sz);
#endif// 将我们当前分配的内存页到进程的虚拟地址空间if(mappages(pagetable, a, sz, (uint64)mem, PTE_R|PTE_U|xperm) != 0){// 如果映射失败,释放内存并退回kfree(mem);uvmdealloc(pagetable, a, oldsz);return 0;}}// 返回新的内存大小return newsz;
}
很明显,这里uvmalloc主要干了两件事:
- 分配内存
- 映射虚拟地址
我们现在有的是时间,便可以好好的看一下kalloc是干了一个什么事,mappages又是在干啥:
// kalloc: 从内核的空闲内存链表中分配一个页面(PGSIZE)。
// 它通过加锁来确保线程安全,并从空闲链表中取出一个空闲的内存块(大小为页面)。
// 如果成功,它将返回该内存块的地址。如果没有空闲内存块,则返回 NULL。
void *kalloc(void)
{struct run *r;// 获取锁,保证访问内存分配器时的线程安全。acquire(&kmem.lock);// 从空闲内存链表中获取一个空闲页面块r = kmem.freelist;// 如果有空闲内存块,更新空闲链表指针if(r) {kmem.freelist = r->next;}// 释放锁release(&kmem.lock);// 如果没有定义 LAB_SYSCALL,则将分配的内存填充为 5(用垃圾数据填充)
#ifndef LAB_SYSCALLif(r)memset((char*)r, 5, PGSIZE); // fill with junk
#endif// 返回分配的内存块地址,类型转换为 void*。return (void*)r;
}
在这里,大多数信息已经通过注释标记好了,在lab2中我们提过,我们xv6的内存是一种栈的形式保存的,事实上,只是一个比喻,而真正的数据结构是链表,我们的kalloc便是同空闲内存链表中获取一块页,这样,而这个去除的规则恰好是符合栈的形式的,值得注意的是,我们的kalloc分配的是物理地址,因此我们需要通过mappages来映射这个物理地址!
那么我们已经知道,我们的页表是如何分配的了,下面就可以看看我们的mappages的逻辑了:
// mappages 函数将从虚拟地址 va 开始的内存区域映射到物理地址 pa 开始的物理内存区域。
// 每个页表项(PTE)对应一个虚拟地址页,perm 参数指定了映射的权限。
// va 和 size 必须是页对齐的,表示映射的虚拟地址和大小都应该是页面大小的整数倍。
// 返回 0 表示成功,返回 -1 表示无法为虚拟地址分配页表页面。int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{uint64 a, last; // a 是当前处理的虚拟地址,last 是最后一个虚拟地址pte_t *pte; // 页表项指针// 确保虚拟地址 va 和大小 size 都是页对齐的if((va % PGSIZE) != 0)panic("mappages: va not aligned"); // 如果虚拟地址没有页对齐,触发 panicif((size % PGSIZE) != 0)panic("mappages: size not aligned"); // 如果大小没有页对齐,触发 panic// 如果大小为 0,表示没有映射的内容,触发 panicif(size == 0)panic("mappages: size");a = va; // 当前虚拟地址从 va 开始last = va + size - PGSIZE; // 计算最后一个需要映射的虚拟地址,确保是页对齐的for(;;) {// 使用 walk 函数获取虚拟地址 a 所对应的页表项(pte),如果页表项没有找到,则分配新的页表项if((pte = walk(pagetable, a, 1)) == 0)return -1; // 如果无法为当前虚拟地址分配页表项,则返回 -1// 检查该页表项是否已经被映射(即 PTE_V 标志位是否已设置)if(*pte & PTE_V)panic("mappages: remap"); // 如果该页表项已存在映射,触发 panic// 将该页表项的值设置为物理地址 pa 的页表项,并设置权限(perm)和有效位(PTE_V)*pte = PA2PTE(pa) | perm | PTE_V;// 如果当前虚拟地址是最后一个虚拟地址,则退出循环if(a == last)break;a += PGSIZE; // 移动到下一个虚拟地址(下一个页面)pa += PGSIZE; // 移动到下一个物理地址(下一个页面)}return 0; // 返回 0 表示映射成功!
}
基本没有什么特别重要的,就是检查各种条件,其中的重点便是walk这个函数,通过walk,去获取我们的虚拟地址,并且在漫步过程中,他会自己创建不存在的页表项(三级页表结构),这个walk,便是我们映射页表的核心逻辑了,下面是对walk的详细注释:
// 返回页表 pagetable 中对应虚拟地址 va 的 PTE(页表项)的地址。
// 如果 alloc != 0,则在需要时创建缺失的页表页。
//
// RISC-V 的 Sv39 分页方案使用了三级页表。
// 每个页表页包含 512 个 64 位的页表项(PTE)。
// 一个 64 位的虚拟地址被划分为五个字段:
// 位 39..63 —— 必须为零(当前 Sv39 只使用 0~38 位,共 39 位虚拟地址)。
// 位 30..38 —— 9 位,用作第 2 级页表的索引。
// 位 21..29 —— 9 位,用作第 1 级页表的索引。
// 位 12..20 —— 9 位,用作第 0 级页表的索引。
// 位 0..11 —— 12 位,用作页内偏移(即页面内的字节偏移量)。
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc)
{// 如果虚拟地址 va 超出了最大允许的虚拟地址范围,则触发 panicif(va >= MAXVA)panic("walk");// 遍历页表层级,通常有 3 层页表(根页表、二级页表和三级页表)for(int level = 2; level > 0; level--) {// 根据虚拟地址 va 和当前页表层级 level 计算出当前页表项的索引pte_t *pte = &pagetable[PX(level, va)];// 如果该页表项有效(即 PTE_V 标志位为 1),则继续向下遍历if(*pte & PTE_V) {// 获取下一级页表的物理地址,并更新 pagetable 为该页表pagetable = (pagetable_t)PTE2PA(*pte);//这部分可以不用管
//#ifdef LAB_PGTBL
// // 如果当前页表项是叶子节点(即最后一层页表),则返回该页表项
// if(PTE_LEAF(*pte)) {
// return pte;
// }
#endif} else {// 如果页表项无效,且 alloc 为真,则分配一个新的页表// 如果分配失败,则返回 NULLif(!alloc || (pagetable = (pde_t*)kalloc()) == 0)return 0;// 初始化新分配的页表为零memset(pagetable, 0, PGSIZE);// 将当前页表项的值设置为新分配页表的物理地址,并设置有效位 PTE_V*pte = PA2PTE(pagetable) | PTE_V;}}// 返回当前页表项的指针,该页表项对应的是虚拟地址 va 的最后一级页表项return &pagetable[PX(0, va)];
}
啊啊啊xv6你是一个香香软软的小蛋糕,我们可以看见,我们之前提过很多次的三级页表,终于在这里露出了鸡脚,这里的三个for循环,分别代表每一级的页表,PX(2, va)
:提取虚拟地址 va
中的 VPN2
部分的索引,PX(1, va)
,提取虚拟地址 va
中的 VPN1
部分的索引,PX(0, va)
,提取虚拟地址 va
中的 VPN0
部分的索引。
这里,每一级页表,都会通过kalloc分配一个物理页,但是实际上,最终只有叶子页表上的页表才会映射到我们的物理地址中,其实到这里,还需要再引用一下xv6手册的知识。
kvmmap
(*kernel/vm.c*:127)调用mappages
(*kernel/vm.c*:138),mappages
将范围虚拟地址到同等范围物理地址的映射装载到一个页表中。它以页面大小为间隔,为范围内的每个虚拟地址单独执行此操作。对于要映射的每个虚拟地址,mappages
调用walk
来查找该地址的PTE地址。然后,它初始化PTE以保存相关的物理页号、所需权限(PTE_W
、PTE_X
和/或PTE_R
)以及用于标记PTE有效的PTE_V
(*kernel/vm.c*:153)。在查找PTE中的虚拟地址(参见图3.2)时,
walk
(*kernel/vm.c*:72)模仿RISC-V分页硬件。walk
一次从3级页表中获取9个比特位。它使用上一级的9位虚拟地址来查找下一级页表或最终页面的PTE (*kernel/vm.c*:78)。如果PTE无效,则所需的页面还没有分配;如果设置了alloc
参数,walk
就会分配一个新的页表页面,并将其物理地址放在PTE中。它返回树中最低一级的PTE地址(*kernel/vm.c*:88)。上面的代码依赖于直接映射到内核虚拟地址空间中的物理内存。例如,当
walk
降低页表的级别时,它从PTE (*kernel/vm.c*:80)中提取下一级页表的(物理)地址,然后使用该地址作为虚拟地址来获取下一级的PTE (*kernel/vm.c*:78)。
main
调用kvminithart
(*kernel/vm.c*:53)来安装内核页表。它将根页表页的物理地址写入寄存器satp
。之后,CPU将使用内核页表转换地址。由于内核使用标识映射,下一条指令的当前虚拟地址将映射到正确的物理内存地址。
以上为xv6手册中对我们的Walk及其原理相关的介绍,简单来说,walk应该如何去寻找这些页表的索引,其实并不是由我们规定的,而是由硬件规定的这种查询办法,由于我们的cpu在使用虚拟地址的时候,就是采取这种办法去找到对应的物理地址,所以我们的walk也需要采取相同的办法,为什么硬件已经实现了查找功能,但是我们操作系统还需要去实现walk?因为我们最初在操作系统上分配页表,映射页表,是需要操作系统去将他映射到对应的位置的,我觉得这部分是比较难理解的,所以说最好还是去看原文的手册,当然,2020年的课程也有对应的学生提这个问题,可以看看教授是怎么说的。
我们可以看看PX到底是什么
#define PGSHIFT 12
#define PXMASK 0x1FF // 9 bits
#define PXSHIFT(level) (PGSHIFT+(9*(level)))
#define PX(level, va) ((((uint64) (va)) >> PXSHIFT(level)) & PXMASK)
这里的主要作用,其实就是从虚拟地址va中提取索引值,首先是将其右移层级 * 9 + 12
然后与mask相与计算得到9位数字,对应我们之前说过的PTE为9位,返回的就是我们的索引值,然后从这个索引值中找到对应的表项。
并且,在当前找到的表项有效的时候,还会将这个表项的地址左移十位(原因见手册,这里是为了去除标志位),然后右移12位(同在手册中可见,12位刚好是我们的页表的尺寸,这样就能够腾出offset,表示真正的页表):
if(*pte & PTE_V) {pagetable = (pagetable_t)PTE2PA(*pte);
最终,循环结束,我们的最终会得到一个第三级的页表,然后我们在mappages中调用了
*pte = PA2PTE(pa) | perm | PTE_V;
这是至关重要的一步,我们可以注意到,我们之前的pte基本都是赋值的形式,这是因为,之前都是为了找到我们的地址,而现在真的找到了我们需要的虚拟地址,然后我们就可以解引用,将我们需要映射物理地址给他映射到这个三级页表中,也就是加入这个三级页表的表项!!!至此,我们的映射就完成了。
这里总结一下感受到的好处,像二级页表,三级页表实际上是通过kalloc分配的物理内存,所以会占用我们的内存,但是比起页偏移量| offset
的形式,还是能够通过懒加载节省不少空间,并且寻址页变得更灵活,感觉分页还是很神奇的。
cpu咋根据虚拟地址找到你的物理地址?
对于CPU,我们的汇编中的所有代码涉及到的地址都仅仅是虚拟地址,而不是物理地址,然而,我们如何通过这样一个虚拟地址去找到我们的物理地址呢?在cpu中,有一个寄存器叫做satp,用来保存一个地址(实际上就是最高层级的地址,也就是进程的页表地址),作为我们找到最终物理地址的参考,通过这个寄存器存储的地址,我们的cpu可以告诉MMU,从哪里可以找到将虚拟内存地址翻译成物理地址的"表单",事实上,MMU就是充当了一个翻译器,它计算地址的功能实际上和walk函数是一样的,这样就能够实现准确的查找,而mmu属于硬件部分,不是我们分析的对象。
到了这里,我们的vm.c已经分析了一大半了。
在进行下一步之前,在这里记录一些概念:
TLB页表缓存
我们在之前涉及的函数中可以看到,我们的cpu是从最高级的页表进行查找对应的物理页,其中会涉及到三次跳转,索引都是存在物理页中的,所以需要读取三次内存,代价很高,所以实际上,我们的cpu都会对最近使用过的虚拟地址的翻译结果有缓存,这个缓存就好做页表缓存TLB,而TLB对于我们操作系统是不可见的,不是我们讨论的范畴,这里仅作了解,当然,如果cpu切换了我们的页表,我们过去缓存的的TLB当然也就不可用了,需要被清空,在riscv中,清空TLB的指令为:sfence_vma
,我们可以在xv6中为数不多的几个汇编源文件中找到这个指令,比如trampoline.S
。
插曲就到这里,下面我们涉足的地方将是关于内核的页表!
kvminit/kvminithart
事实上,内核的页表在启动的时候就会分配,我们可以在通过entry,一步一步找到我们的这两个函数的调用:
entry->start.c->main.c
首先,由于在启动xv6的时候,我们并没有映射物理地址,所以说会通过
w_satp(0);
来禁用分页,在之后,我们就会通过物理地址直接返回到我们的main函数了。
我们可以在main.c中找到这样的代码:
kvminit(); // create kernel page tablekvminithart(); // turn on paging
首先,就来看看我们的kvminit()
void
kvminit(void)
{kernel_pagetable = kvmmake();
}
可以看到,这里主要是调用了kvmmake函数来分配内核页表,进一步下去:
// 构建一个 kernel 的页表(direct-map 页表,虚拟地址 = 物理地址)。
// 内核代码、数据、外设、trampoline、内核栈都在此页表中映射。
pagetable_t
kvmmake(void)
{pagetable_t kpgtbl;// 通过kalloc分配页来作为内核的根页表kpgtbl = (pagetable_t) kalloc();memset(kpgtbl, 0, PGSIZE); // 清零页表// 映射 UART 外设的寄存器地址(物理地址 = 虚拟地址)// 用于串口输出,例如 printf 打印信息kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);// 映射 virtio 磁盘接口(内核需要访问硬盘控制器)kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
//以下不是我们目前需要关心的
//#ifdef LAB_NET
//
// kvmmap(kpgtbl, 0x30000000L, 0x30000000L, 0x10000000, PTE_R | PTE_W);
//
// kvmmap(kpgtbl, 0x40000000L, 0x40000000L, 0x20000, PTE_R | PTE_W);
#endif // 映射 PLIC(中断控制器),允许内核收发中断kvmmap(kpgtbl, PLIC, PLIC, 0x4000000, PTE_R | PTE_W);// 映射内核代码段:可读、可执行(不可写,防止内核被修改)kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext - KERNBASE, PTE_R | PTE_X);// 映射内核数据段:从 etext 到 PHYSTOP 的物理内存,可读写// 包括 bss 段、内核堆栈、内核堆等kvmmap(kpgtbl, (uint64)etext, (uint64)etext, PHYSTOP - (uint64)etext, PTE_R | PTE_W);// 映射 trampoline 页(trap 跳板)到最高虚拟地址处// trap handler 通过它进入内核;这是统一入口/出口kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);// 为每个进程分配一个内核栈,并在页表中映射(stack for kernel mode)proc_mapstacks(kpgtbl);// 返回构造好的页表return kpgtbl;
}
可以看见,我们的kvmmake实际上就是新分配一个物理页表,随后进行一系列映射,将我们的页表都映射到内核的数据段,代码段所在的物理地址,使得我们可以进行访问。
我们可以进一步看看kvmmap是怎么做的:
void
kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{if(mappages(kpgtbl, va, sz, pa, perm) != 0)panic("kvmmap");
}
很明显,就是调用了我们之前说过的mappages,然后还会通过walk去找到三级页表,并且将物理页表的地址加到对应的表项中。
我们还看见了一个函数叫做proc_mapstacks,实际上就是为我们每个进程分配内核栈
void proc_mapstacks(pagetable_t kpgtbl)
{struct proc *p;// 遍历所有进程(proc 是进程表的数组,NPROC 是进程的最大数量)for(p = proc; p < &proc[NPROC]; p++) {// 分配一页物理内存给每个进程的内核栈char *pa = kalloc(); // kalloc 是一个内存分配函数,分配一页物理内存if(pa == 0)panic("kalloc"); // 如果分配失败,则触发 panic,表示内存不足// 计算该进程的内核栈的虚拟地址,KSTACK 是一个宏,通常用于计算栈的位置uint64 va = KSTACK((int) (p - proc)); // KSTACK 将进程的索引转化为虚拟地址// 将分配到的物理内存页映射到该进程的内核栈的虚拟地址kvmmap(kpgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);// kvmmap 是将物理地址映射到虚拟地址的函数,PTE_R | PTE_W 表示该页具有读写权限}
}
这里在一开始,就为每个可能会被创建的进程分配了进程的内核栈,但是我看2020年的没有这样做。
随后,我们便可以回归到我们的kvminithart了!
void
kvminithart()
{// 清除所有旧的 TLB 项。// 以确保任何对页表的修改对 CPU 可见。// 这里的sfence_vma函数,实际上就是调用我们的riscv的sfence_vma命令!sfence_vma();// 将satp的值设置为我们刚刚分配的kernel页表w_satp(MAKE_SATP(kernel_pagetable));// 再次清除 TLB 中的条目,以防设置 satp 后仍存在旧的映射。sfence_vma();
}
到这里,我们实际上就是启用了我们的分页模式了。
简单总结一下,我们的xv6启动过程中,在分页上面干了什么事?
- 链接的时候,start,main都是被识别为符号,此时还没有开启分页,编译器将他们识别为物理地址,而我们可以直接通过这些符号进行跳转,跳转到我们的main中。
- 在我们的main中,会调用kvminit和kvminithart来初始化我们的内核页表。
- kvminit先分配了一个主页表,随后将内核中的各种信息存放的物理地址映射到这个新建的页表中,并且提前为所有可能分配的进程创建内核栈。
- kvminithart将启用我们的分页设置。
事实上,在我们修改satp的之后,我们的世界就改变了,但是在内核中,依旧可以正常工作,因为kernel page的映射关系中,虚拟地址到物理地址是完全相等的,这里比较特殊,我们的MMU依旧能够将其映射到相同的地址上,这里并不是特殊处理了,依旧只是通过 MMU 去找到的对应的地址,实际上,只是因为我们的内核页表映射的位置设计得很巧妙,如果你还是不理解怎么回事,你可以回去看看kvminit的映射地址时传入的参数。
相关文章:
xv6部分源码阅读-1
xv6部分源码阅读 前言 在lab2中,我们会为了完成attack这个实验,而花费大量的时间去阅读相关的系统调用源码,以此来分析出我们最终secret所在的页表的位置,而我写lab2中,重点并没有关注其中的逻辑关系,有很…...
CentOS中离线安装DockerCompos并用其部署Rabbitmq(使用离线导入导出docker镜像方式)
场景 DockerDockerCompose实现部署jenkins,并实现jenkinsfile打包SpringBootVue流水线项目过程详解、踩坑记录(附镜像资源、离线包资源下载): DockerDockerCompose实现部署jenkins,并实现jenkinsfile打包SpringBootVue流水线项目过程详解、踩坑记录(附镜像资源、离…...
基于 OpenHarmony 5.0 的星闪轻量型设备应用开发——Ch2 OpenHarmony LiteOS-M 内核应用开发
写在前面: 此篇是系列文章《基于 OpenHarmony5.0 的星闪轻量型设备应用开发》的第 2 章。本篇介绍了如何在 OpenHarmony 5.0 框架下,针对 WS63 进行 LiteOS-M 内核应用工程的开发。 为了方便读者学习,需要OpenHarmony 5.0 WS63 SDK 的小伙伴可…...
2025年4月9日-华为暑期实习-第二题-200分
📌 点击直达笔试专栏 👉《大厂笔试突围》 💻 春秋招笔试突围在线OJ 👉 笔试突围OJ 02. 智能导航系统 问题描述 K小姐生活在一个被称为"未来之城"的智能城市,这个城市拥有高效的无人驾驶运输网络。城市内的智能车辆可以在不同的交通枢纽之间穿行,每个枢…...
抖音视频下载工具
抖音视频下载工具 功能介绍 这是一个基于Python开发的抖音视频下载工具,可以方便地下载抖音平台上的视频内容。 主要特点 支持无水印视频下载自动提取视频标题作为文件名显示下载进度条支持自动重试机制支持调试模式 使用要求 Python 3.10Chrome浏览器必要的P…...
基于大模型预测儿童急性淋巴细胞白血病诱导达完全缓解患者综合治疗方案研究报告
目录 一、引言 1.1 研究背景与意义 1.2 研究目的 1.3 国内外研究现状 二、儿童急性淋巴细胞白血病及大模型相关理论基础 2.1 儿童急性淋巴细胞白血病概述 2.2 大模型技术原理及特点 三、大模型在术前评估中的应用 3.1 患者基本信息与病情数据收集 3.2 大模型对病情严…...
项目合同从专家到小白
文章目录 按项目范围划分项目总承包合同项目单项承包合同项目分包合同 按项目付款方式划分总价合同固定总价合同总价加激励费用合同(FPIF)总价加经济价格调整合同订购单 \ 单边合同 成本补偿合同工料合同(混合型) 基础概念目标成本…...
【windows10】基于SSH反向隧道公网ip端口实现远程桌面
【windows10】基于SSH反向隧道公网ip端口实现远程桌面 1.背景2.SSH反向隧道3.远程连接电脑 1.背景 Windows 10远程桌面协议的简称是RDP(Remote Desktop Protocol)。 RDP是一种网络协议,允许用户远程访问和操作另一台计算机。 远程桌面功…...
学习海康VisionMaster之四边形查找
一:进一步学习了 今天学习下VisionMaster中的四边形查找,这个还是拟合直线的衍生应用,可以同时测量四条直线并且输出交点或者判定是否有交点 二:开始学习 1:什么是四边形查找? 按照传统的算法,…...
菊风RTC 2.0 开发者文档正式发布,解锁音视频新体验!
重磅发布! 开发者们,菊风实时音视频2.0文档已正式发布上线,为您提供更清晰、更高效的开发支持!让菊风实时音视频2.0为您的音视频应用加速~ 菊风实时音视频2.0聚焦性能升级、体验升级、录制服务升级,助力视频通话、语…...
用Python和OpenCV开启图像处理魔法之旅
你是否曾好奇计算机是如何“看懂”这个世界的?从人脸识别到自动驾驶,计算机视觉技术正日益渗透到我们的生活中。而 OpenCV (Open Source Computer Vision Library),作为一个强大的开源计算机视觉库,正是我们探索这个奇妙世界的强大…...
初识MySQL · 复合查询(内外连接)
目录 前言: 基本查询回顾 笛卡尔积和子查询 笛卡尔积 内外连接 子查询 单行子查询 多行子查询 多列子查询 from中使用子查询 合并查询 前言: 在前文我们学习了MySQL的基本查询,就是简单的套用了select语句,最多不过是…...
Devops系列之对接Gerrit的设计与实现(三)-- Java编程实现
一、背景 上文讲述了如何使用shell命令实现创建gerrit项目,本文介绍如何使用java语言编程实现。 二、java语言实现 1、引入jar包 <dependency><groupId>com.urswolfer.gerrit.client.rest</groupId><artifactId>gerrit-rest-java-client…...
深入理解全排列算法:DFS与回溯的完美结合
全排列问题是算法中的经典问题,其目标是将一组数字的所有可能排列组合列举出来。本文将详细解析如何通过深度优先搜索(DFS)和回溯法高效生成全排列,并通过模拟递归过程帮助读者彻底掌握其核心思想。 问题描述 给定一个正整数 n&a…...
服务器(一种管理计算资源的计算机)
服务器是在网络环境中提供计算能力并运行软件应用程序的特定IT设备,它在网络中为其他客户机(如个人计算机、智能手机、ATM机等终端设备)提供计算或者应用服务, 一般来说服务器都具备承担响应服务请求、承担服务、保障服务的能力。服务器相比普…...
时态--02--一般过去时
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档 文章目录 一般过去时1.肯定句am/is — wasare — were 2.否定句3.⼀般疑问句4.特殊疑问句5.there be 过去式 practice过去分词 一般过去时 1.肯定句 am/is — was are — wer…...
WSA(Windows Subsystem for Android)安装LSPosed和应用教程
windows安卓子系统WSA的Lsposed和shamiko的安装教程 WSA(Windows Subsystem for Android)安装LSPosed和应用教程 一、环境准备 在开始之前,请确保: 已经安装好WSA(Windows Subsystem for Android)已经安装好ADB工具下载好LSPosed和Shamiko框架安装包 二、连接WSA 首先需要…...
Opencv计算机视觉编程攻略-第十三节 跟踪视频中的物品
这是opencv系列的最后一节,主要学习视频序列,上一节介绍了读取、处理和存储视频的工具,本文将介绍几种跟踪图像序列中运动物体的算法。可见运动或表观运动,是物体以不同的速度在不同的方向上移动,或者是因为相机在移动…...
10 个最新 CSS 功能已在所有主流浏览器中得到支持
前言 CSS 不断发展,新功能使我们的工作更快、更简洁、更强大。得益于最新的浏览器改进(Baseline 2024),许多新功能现在可在所有主要引擎上使用。以下是您可以立即开始使用的10 CSS新功能。 1. Scrollbar-Gutter 和 Scrollbar-Co…...
[特殊字符] 企业级Docker私有仓库实战:3步搭建Harbor安全仓库,镜像管理从此高效无忧
本文提供 一站式Docker私有仓库部署指南,聚焦企业级镜像管理需求,深入解析Harbor私有仓库的搭建、运维与安全加固全流程。内容涵盖 轻量级Registry快速部署与 Harbor企业级方案对比,手把手演示SSL证书配置、多租户权限控制、镜像漏洞扫描等核…...
一个基于Django的进销存管理系统Demo实现
第一步:创建 Django 项目 bash 复制 django-admin startproject inventory_system cd inventory_system python manage.py startapp erp 第二步:定义数据模型(models.py) python 复制 from django.db import models from d…...
wsl2+ubuntu22.04安装blender教程(详细教程)
本章教程介绍,如何在Windows操作系统上通过wsl2+ubuntu安装blender并运行教程。Blender 是一款免费、开源的 3D 创作套件,广泛应用于建模、动画、渲染、视频编辑、特效制作等领域。它由全球开发者社区共同维护,支持跨平台(Windows、macOS、Linux),功能强大且完全…...
netty中的ChannelPipeline详解
Netty中的ChannelPipeline是事件处理链的核心组件,负责将多个ChannelHandler组织成有序的责任链,实现网络事件(如数据读写、连接状态变化)的动态编排和传播。以下从核心机制、执行逻辑到应用场景进行详细解析: 1. 核心结构与组成 双向链表结构 组成单元:ChannelPipeline…...
使用多进程和 Socket 接收解析数据并推送到 Kafka 的高性能架构
使用多进程和 Socket 接收解析数据并推送到 Kafka 的高性能架构 在现代应用程序中,实时数据处理和高并发性能是至关重要的。本文将介绍如何使用 Python 的多进程和 Socket 技术来接收和解析数据,并将处理后的数据推送到 Kafka,从而实现高效的…...
WinForm真入门(14)——ListView控件详解
一、ListView 控件核心概念与功能 ListView 是 WinForm 中用于展示结构化数据的多功能列表控件,支持多列、多视图模式及复杂交互,常用于文件资源管理器、数据报表等场景。 核心特点: 支持 5种视图模式:Details&…...
FastAPI用户认证系统开发指南:从零构建安全API
前言 在现代Web应用开发中,用户认证系统是必不可少的功能。本文将带你使用FastAPI框架构建一个完整的用户认证系统,包含注册、登录、信息更新和删除等功能。我们将采用JWT(JSON Web Token)进行身份验证,并使用SQLite作…...
【BUG】阿里云服务器数据库远程连接报错
当你遇到 ERROR 2003 (HY000): Cant connect to MySQL server on 47.100.xxx.xx (10061) 错误,这个错误代码 10061 通常意味着客户端无法连接到指定的 MySQL 服务器,原因可能有多种,下面为你分析可能的原因及对应的解决办法。 1. 网络连接问…...
【前端】【React】性能优化三件套useCallback,useMemo,React.memo
一、总览:性能优化三件套 useCallback(fn, deps):缓存函数,避免每次渲染都新建函数。useMemo(fn, deps):缓存值(计算结果),避免重复执行计算。React.memo(Component):缓存组件的渲染…...
Vue3性能优化终极指南:编译策略、运行时调优与全链路监控
一、Vue3性能优化体系框架 1.1 性能优化全景图谱 1.2 关键性能指标定义表 指标测量方式优化目标核心影响因子FCPLighthouse<1.5s资源加载速度LCPPerformance API<2.5s关键资源大小TTIWebPageTest<3.5s主线程阻塞时间Memory UsageChrome DevTools<50MB对象引用策略…...
FISCO BCOS技术架构解析:从多群组设计到性能优化实践
目录 FISCO BCOS整体架构设计 多群组架构与数据隔离机制 交易流程与执行机制 安全架构与隐私保护 性能优化与压测实践 应用案例与生态工具 FISCO BCOS作为中国领先的金融级开源联盟链平台,自2017年由金链盟开源工作组推出以来,已在政务、金融、医疗、版权等众多领域实现…...
Ceph异地数据同步之- S3对象异地同步复制
#作者:闫乾苓 文章目录 关键组件说明数据流说明部署步骤配置主区域配置次要区域S3对象文件同步测试 关键组件说明 在Ceph RGW的多站点复制架构中,Realm、Zonegroup 和 Zone 是关键的组织结构,用于管理多站点的配置和数据同步 Realm(领域)&a…...
iOS按键精灵辅助工具在游戏开发中的创新应用
一、iOS自动化测试辅助工具 在移动游戏开发领域,iOS按键精灵类辅助工具不同于传统的安卓自动化方案,iOS环境下的自动化测试面临更严峻的技术挑战,但通过创新方法仍可实现精准控制。 # 基于图像识别的智能定位算法示例 def find_button(butt…...
3D案例丨多个3D工业相机拼接检测 开启360°新视界
在高速生产线上,经常需要在极短的时间内对工件进行全方位的外观检测,如:线缆直径和直线度检测、锂电池外观缺陷检测、铁轨截面尺寸检测等。 这需要传感器完整还原被测物的截面面轮廓形状,并获取精准的截面轮廓数据。但单一相机的…...
打分函数分类
在分子对接中,打分函数用于评估配体与受体结合的亲和力。不同类型的打分函数有各自的优势和应用场景。常见的打分函数主要分为以下几类: 1. 基于物理(力场)的打分函数 (Force/physics-field-based scoring functions) 这种打分…...
实践 DevOps 项目:使用 Terraform、Helm、SonarQube 和 GitLab CI/CD 在 AWS EKS 上实践全栈部署
在当今快节奏的软件开发领域,自动化至关重要。在本文中,我将向您展示如何构建一个全面的 DevOps 流水线,该流水线能够: 使用 Terraform 预置完整的 AWS 基础设施。部署一个包含私有子网和公共子网、RDS PostgreSQL 以及完整配置的…...
EFT干扰和共模干扰
EFT干扰本质上属于共模干扰的一种具体表现形式,但严格来说不能简单等同于共模干扰。以下从原理、特征及区别角度展开分析: 1. EFT干扰的原理 定义:EFT(Electrical Fast Transient,电快速瞬变脉冲群)干扰是…...
android 下提示 SQLITECIPHER driver not loaded
问题描述: 在android下出现 SQLITECIPHER driver not loaded 错误 解决办法: 在QT的Android目录下面放入 libplugins_sqldrivers_sqlitecipher_arm64-v8a.so...
[D1,2]回溯刷题
文章目录 组合 组合 回溯的基础结构 #组合总和 注意startIndex的更新是用i来更新的,不然会产生重复的组合...
使用 VBA 宏创建一个选择全部word图片快捷指令,进行图片格式编辑
使用 VBA 宏批量选择图片 ✅ 第一步:创建 .dotm 加载项文件 1、使用环境 office word 365,文件格式为.docx 图片格式为.PNG 2、创建 .dotm 加载项文件 打开 Word,新建一个空白文档。 按下 Alt F11 打开 VBA 编辑器。 点击菜单栏ÿ…...
SQL 关键字
SQL 包含许多关键字,这些关键字用于执行各种数据库操作。以下是主要的 SQL 关键字分类: 数据查询语言 (DQL) SELECT - 从数据库中选择数据 FROM - 指定要查询的表 WHERE - 指定查询条件 GROUP BY - 对结果集进行分组 HAVING - 对分组结果进行过滤 …...
从PPT到PNG:Python实现的高效PPT转图工具
从PPT到PNG:Python实现的高效PPT转图工具 在日常工作中,PPT(PowerPoint)文件是我们常用的演示工具。然而,有时候我们需要将PPT的内容提取为图片格式(如PNG)以便于展示或保存。手动将每一页PPT保…...
TCP和UDP协议
前言 TCP(传输控制协议)和UDP(用户数据报协议)是两种主要的传输层协议;它们在连接方式、可靠性、效率等方面有显著区别。 关键对比 差异总结 可靠性: TCP通过确认应答、重传等机制确保数据可靠传输&#…...
高并发内存池(三):PageCache(页缓存)的实现
前言: 在前两期内容中,我们深入探讨了内存管理机制中在 ThreadCache 和 CentralCache两个层级进行内存申请的具体实现。这两层缓存作为高效的内存分配策略,能够快速响应线程的内存需求,减少锁竞争,提升程序性能。 本期…...
使用pybind11开发可供python使用的c++扩展模块
在做紫微斗数程序的时候用到了padas库,不过也只用了它下面几个功能: 1、读入csv文件,构造DataFrame; 2、通过行列标题查找数据; 3、通过行标题读取一行数据。 用这几个功能却导入了pandas、numpy、dateutil、pytz等一堆库,多少有点划不来,于是想用c++开发一个实现这几…...
系统与网络安全------网络通信原理(5)
资料整理于网络资料、书本资料、AI,仅供个人学习参考。 传输层解析 传输层 传输层的作用 IP层提供点到点的连接传输层提供端到端的连接 端口到端口的连接(不同端口号,代表不同的应用程序) TCP协议概述 TCP(Transm…...
JavaScript防抖与节流
目录 防抖(Debounce) 一、防抖的定义 二、防抖的实现原理 三、防抖的代码实现 四、代码解析 五、使用示例 1. 输入框实时搜索(延迟执行模式) 2. 按钮防重复点击(立即执行模式) 六、总结 节流&…...
Java网络编程实战(多人聊天室-CS模式)
一、C/S模式核心原理 1.1 基本架构 C/S(Client/Server)模式采用客户端-服务器架构: 服务器端:持续运行,负责消息路由和广播客户端:用户交互界面,连接服务器进行通信通信协议:TCP&…...
Vue3.5 + Vite6.x 项目的完整 Stylelint 配置方案,支持 .vue/.html 内联样式、Less/SCSS/CSS 等多种文件类
Vue3.5 Vite6.x 项目的完整 Stylelint 配置方案,支持 .vue/.html 内联样式、Less/SCSS/CSS 等多种文件类型 一、完整依赖安装 npm install --save-dev stylelint stylelint-config-standard postcss-html # 解析 Vue/HTML 文件中的样式postcss-scss …...
23种设计模式Java版(带脑图,带示例源码)
设计模式 1、创建型 1.1、单例模式(Singleton pattern) 确保一个类只有一个实例,并提供该实例的全局访问点。 1.2、工厂方法(Factory Method) 它定义了一个创建对象的接口,但由子类决定要实例化哪个类。工厂方法把实例化操作推迟到子类。 1.3、抽象…...
mapbox高阶,使用graphology、graphology-shortest-path前端插件和本地geojson数据纯前端实现路径规划
👨⚕️ 主页: gis分享者 👨⚕️ 感谢各位大佬 点赞👍 收藏⭐ 留言📝 加关注✅! 👨⚕️ 收录于专栏:mapbox 从入门到精通 文章目录 一、🍀前言1.1 ☘️mapboxgl.Map 地图对象1.2 ☘️mapboxgl.Map style属性1.3 ☘️graphology 插件1.3.1 ☘️概念1.3.2 ☘…...