├── 2021 ├── 一种有效的页表系统抽象设计.md ├── 传统内核与异步内核运行任务.png ├── 内核执行器的恢复操作.png ├── 分页系统实现共享调度器.png ├── 地址空间与进程.md ├── 大页分配图示.png ├── 大页分配算法推导.png ├── 异步内核的设计与实现.md └── 执行器与生成语义.md ├── LICENSE └── README.md /2021/一种有效的页表系统抽象设计.md: -------------------------------------------------------------------------------- 1 | # 一种有效的页表系统抽象设计(笔记) 2 | 3 | 从段进化到页,是操作系统内存抽象的一大进步之处。页式存储管理由软硬件共同实现,软件提供映射关系, 4 | 硬件来加速地址翻译的过程。为了更好地设计硬件,往往要求软件页满足一定的数据要求。 5 | 6 | 这篇笔记尝试梳理软件层次上,页的实现过程。将会包括大页的分配算法,物理地址的对齐,多种分页模式的兼容设计, 7 | 以及如何使用泛型、模块化等现代语言技术实现它们。 8 | 9 | 可能讲得不清楚,请各位看官海涵。 10 | 11 | ## 大页分配算法 12 | 13 | 页式存储系统拥有“大页”的概念。在一些架构下,大页要求地址对齐。 14 | 即大页的物理地址必须对齐到特定的级别,随后的低级页号和索引,将共同看作更长的索引使用。 15 | 这就对分配算法提出了一定的要求。 16 | 17 | 归纳需要解决的问题。我们的问题是:给定页表系统`M`,最大等级为`n`;对虚拟页号`v = (vn-1, ...v1, v0)`和待映射连续页数`n`, 18 | 找到一组页的映射集合`U = { (v_开始, p_开始, 长度) }`,使得页号`v`能映射到物理页号`p`。 19 | 考虑使用大页优化这一过程,使`| U |`越少越好,但需要注意对齐要求,即对等级为`k`的大页, 20 | 起始地址为`(vn-1, ...vk, 0, ...0)`的`k-1`到`0`级均为0,即假如`k`级的对齐要求为`a[k]`,有`v = 0 (mod a[k])`。 21 | 22 | 在`M = Sv39系统`中,最大等级`n = 3`,对齐要求`a[0] = 1`, `a[1] = 512`, `a[2] = 26'2144`[注释1]。即,每一级别包含512个页表项。 23 | 24 | 为了简化问题理解,我们定义一个`M = 简单的页表系统`,最大等级也为`n = 3`,但`a[0] = 1`, `a[1] = 4`, `a[2] = 16`。 25 | 每一级别包含3个页表项。给定虚拟地址`v = (2, 2, 2)`到`v + n = (5, 2, 1)`,要映射到物理地址`p = (2, 2, 2)`。 26 | 27 | 现在我们在最高的3级,将要分配`r(v) = (2, 2, 2)..=(5, 2, 1)`到`p = (2, 2, 2)`。 28 | 如果直接使用大页,将必须保证2、1级的编号都为0。也就是说,只能分配`r(v) = (3, 0, 0)..(5, 0, 0)`到`p = (3, 0, 0)`。 29 | 30 | 为什么不分配`(2, 0, 0)..(3, 0, 0)`到`(2, 0, 0)`呢?因为这将分配超过`(2, 2, 2)..=(5, 2, 1)`的范围, 31 | 和我们要求解的问题不同。同理,也不能分配`(5, 0, 0)..(6, 0, 0)`到`(5, 0, 0)`。这部分的范围,需要借助更低级的大页来完成。 32 | 33 | 第3级的分配完成了,然后我们借助2级页表,分配附近零碎的`(2, 3, 0)..(3, 0, 0)`和`(5, 0, 0)..(5, 2, 0)`。 34 | 分配`(2, 3, 0)..(3, 0, 0)`到`(2, 3, 0)`;`(5, 0, 0)..(5, 2, 0)`到`(5, 0, 0)`。 35 | 36 | 最后,借助1级页表,分配`(2, 2, 2)..(2, 3, 0)`到`(2, 2, 2)`;`(5, 2, 0)..(5, 2, 2)`到`(5, 2, 0)`。 37 | 至此,所有的分配完成了。 38 | 39 | 由于这种方法要求在虚拟的大页上分配物理的大页,两个大页的基地址必须有相同的对齐方式,因此, 40 | 初始传入的虚拟页号和物理页号,差值必须对齐到相应的页,即`v = p (mod a[k])`,才可以使用这种分配方法;否则就应当使用更低等级的分配方法。 41 | (想想看为什么?)否则就会在分配过程中,产生对齐要求的异常。 42 | 43 | 比如`v = (2, 2, 2)`到`p = (2, 2, 2)`,就可以使用3级;而`v = (2, 2, 2)`到`p = (2, 1, 2)`,就只能使用2级。 44 | 45 | ![大页分配图示](大页分配图示.png) 46 | 47 | 推广这个结论,我们可以得到一个规律。首先,使用贪心的方法,将地址的上下限分别向内取整,分配最大的大页给最高的地址范围。 48 | 然后,对两边的零碎范围,使用低1级的页表,继续分配。继续降低等级,直到所有的页都被分配完毕。 49 | 50 | ![大页分配算法推导](大页分配算法推导.png) 51 | 52 | 根据对齐规则和所需求的页数,逐级降低算法的起始等级。对等级`n = 3`的页表系统,以分支形式,我们编写顺序规则的伪代码。 53 | 54 | ```rust 55 | // input: v: VirtPageNum, p: PhysPageNum, n: usize, a: PageMode; 56 | if (v - p) % (a[2].frame_align()) == 0 && n >= a[2].frame_align() { 57 | let l2n = (vs2 - ve2) / a[2].frame_align(); 58 | map(2, ve2, vs2, ve2-v+p); 59 | let l1n = (ve2 - ve1 + vs1 - vs2) / a[1].frame_align(); 60 | map(1, ve1, ve2, ve1-v+p); map(1, vs2, vs1, vs2-v+p); 61 | let l0n = (n + ve1 - vs1) / a[0].frame_align(); 62 | map(0, v, ve1, p); map(0, vs1, v+n, vs1-v+p); 63 | } else if (v - p) % (a[1].frame_align()) == 0 && n >= a[1].frame_align() { 64 | let l1n = (vs1 - ve1) / a[1].frame_align(); 65 | map(1, ve1, vs1, ve1-v+p); 66 | let l0n = (n + ve1 - vs1) / a[0].frame_align(); 67 | map(0, v, ve1, p); map(0, vs1, v+n, vs1-v+p); 68 | } else if (v - p) % (a[0].frame_align()) == 0 && n >= a[0].frame_align() { 69 | let l0n = n / a[0].frame_align(); 70 | map(0, v, v+n, p); 71 | } else { 72 | panic!("Can't map v to p under this page mode") 73 | } 74 | ``` 75 | 76 | 我们发现,等级低算法的中间变量,也在等级高的地方出现了。于是这个算法可以改成循环的形式。 77 | 78 | ```rust 79 | // input: v: VirtPageNum, p: PhysPageNum, n: usize, M: PageMode; 80 | for i in M::visit_levels_until(PageLevel::leaf_level()) { // 遍历顺序:[n, ...1, 0] 81 | let align = M::a(i); // i层的对齐要求 82 | if (v - p) % align != 0 || n < align { // 对齐要求达不到等级,或者数量不够,使用低级算法 83 | continue; 84 | } 85 | let (mut ve_prev, mut vs_prev) = (None, None); 86 | for j in M::visit_levels_from(i) { // 遍历顺序:[j, j-1, ...0] 87 | let a = M::a(j); // j层的对齐要求 88 | let ve_cur = a * roundup(v / a) 89 | let vs_cur = a * rounddown((v + n) / a) 90 | if let (Some(ve_prev), Some(vs_prev)) = (ve_prev, vs_prev) { 91 | map(j, ve_cur..ve_prev); // 执行映射函数 92 | map(j, vs_prev..vs_cur); 93 | } else { 94 | map(j, ve_cur..vs_cur); 95 | } 96 | (ve_prev, vs_prev) = (Some(ve_cur), Some(vs_cur)); 97 | } 98 | break; 99 | } 100 | ``` 101 | 102 | 这个算法就可以用于任何等级的页表系统了,因此题目要求的算法得到解决。 103 | 使用Rust语言的生成器或者迭代器包装算法,即可得到比较好的算法实现。 104 | 105 | 传统分配算法是,将地址段内的所有地址,映射到最小的页帧上。此时,需要管理多少个页帧,就需要分配多少个页。 106 | 大页分配算法通过分配满足对齐要求更少的页,就能完成同样的任务。我们如何比较大页分配算法和传统算法分配的页数呢? 107 | 108 | 我们取用`M = Sv39系统`。其中,最大等级`n = 3`,对齐要求`a[0] = 1`, `a[1] = 512`, `a[2] = 26'2144`。 109 | 110 | 同样分配`505'5550`个页帧,假设页号对齐能满足最大的`26'2144`,采用不同的虚拟页号。 111 | 112 | | 虚拟页号 | 数量 | 所需页表数 | 节省 | 等级0 | 等级1 | 等级2 | 113 | |:--------|:----|:----------|:-----|:-----|:------|:-------| 114 | | 0 | 505'5550 | 227 | 0.00% | 62 | 146 | 19 | 115 | | 10 | 505'5550 | 1249 | 0.02% | 574 | 657 | 18 | 116 | | 20 | 505'5550 | 1249 | 0.02% | 574 | 657 | 18 | 117 | | 512 | 505'5550 | 738 | 0.01% | 62 | 658 | 18 | 118 | | 1024 | 505'5550 | 738 | 0.01% | 62 | 658 | 18 | 119 | | 1025 | 505'5550 | 1249 | 0.02% | 574 | 657 | 18 | 120 | | 26'2144 | 505'5550 | 227 | 0.00% | 62 | 146 | 19 | 121 | | 100'0000 | 505'5550 | 738 | 0.01% | 574 | 145 | 19 | 122 | 123 | | 虚拟页号 | 数量 | 所需页表数 | 节省 | 等级0 | 等级1 | 等级2 | 124 | |:--------|:----|:----------|:-----|:-----|:------|:-------| 125 | | 30'0000 | 1 | 1 | 100.00% | 1 | N/A | N/A | 126 | | 30'0000 | 10 | 10 | 100.00% | 10 | N/A | N/A | 127 | | 30'0000 | 100 | 100 | 100.00% | 100 | N/A | N/A | 128 | | 30'0000 | 1000 | 489 | 48.90% | 488 | 1 | N/A | 129 | | 30'0000 | 1'0000 | 291 | 2.91% | 272 | 19 | N/A | 130 | | 30'0000 | 10'0000 | 355 | 0.36% | 160 | 195 | N/A | 131 | | 30'0000 | 100'0000 | 995 | 0.10% | 64 | 929 | 2 | 132 | | 30'0000 | 1000'0000 | 752 | 0.01% | 128 | 587 | 37 | 133 | 134 | 可以发现,Sv39下对齐要求高、页帧数量大时,大页只需要小于一千个页表,就能管理百万个页帧空间,非常节省页表的数量。 135 | 页帧数量小时,由于对齐要求不高,节省的数量并不明显;对齐要求低时,节省数量也不明显。 136 | 137 | 实际使用时,尽量给出最大的对齐要求,这样可以在分配大量页帧时,节省更多的页帧空间。 138 | 这一结果对芯片外设的布局也有指导作用,如果高级的嵌入式芯片拥有较多外设,尽量将外设的物理地址放置到更高的对齐要求上, 139 | 这样操作系统管理时就可以腾出更多的内存空间,供应用使用。 140 | 141 | [注释1]:表格中的'号表示万位分隔符,[成会明院士:传承祖先的智慧,倡导中文中阿拉伯数字书写方式采用“4位数分隔法” | 中国科学院院刊](https://zhuanlan.zhihu.com/p/358635675) 142 | 143 | ## 抽象软件设计 144 | 145 | 以Rust语言为例,给出页系统常见结构的抽象方法。 146 | 147 | ### 页号 148 | 149 | 首先定义物理和虚拟页号。 150 | 151 | ```rust 152 | #[derive(Copy, Clone, PartialEq, Eq, Debug)] 153 | pub struct PhysPageNum(usize); 154 | 155 | #[derive(Copy, Clone, PartialEq, Eq, Debug)] 156 | pub struct VirtPageNum(usize); 157 | ``` 158 | 159 | “物理页号长度 + 偏移长度”可能大于“架构地址宽度”,这允许我们访问大于架构宽度的地址。 160 | 比如RISC-V RV32下,使用Sv32系统,可以访问34位的物理地址,即使架构只有32位。 161 | 162 | 物理页号和虚拟页号,可以通过对应的地址转换而来。 163 | 164 | ```rust 165 | impl PhysPageNum { 166 | pub fn addr_begin(&self) -> PhysAddr { 167 | PhysAddr(self.0 << M::FRAME_SIZE_BITS) 168 | } 169 | } 170 | ``` 171 | 172 | 这种转换关系要求输入页表的模式。不同架构下,地址的偏移量可能不同。 173 | 174 | ### 页帧分配器 175 | 176 | 然后我们需要一个页帧分配器。模仿Rust语言alloc包的设计,可以给出结构如下。 177 | 178 | ```rust 179 | pub trait FrameAllocator { 180 | fn allocate_frame(&self) -> Result; 181 | fn deallocate_frame(&self, ppn: PhysPageNum); 182 | } 183 | ``` 184 | 185 | 构造页帧分配器时,应当给定一个物理页的范围。 186 | 187 | 而后,每次请求分配,其中的算法将返回分配的结果,或者当没有页存在时,返回一个错误。 188 | 189 | ```rust 190 | impl StackFrameAllocator { 191 | pub fn new(start: PhysPageNum, end: PhysPageNum) -> Self { 192 | StackFrameAllocator { current: start, end, recycled: Vec::new() } 193 | } 194 | } 195 | ``` 196 | 197 | 页帧分配器只分配编号,不会向被分配的内存中存储或读取数据,所以它的设计与alloc库简单。 198 | 199 | 这种设计是为了方便测试页帧分配器的正确性和性能。 200 | 201 | ### 装箱的页帧 202 | 203 | 或者说`FrameBox`,借鉴了Rust中拥有所有权的Box名称,表示拥有所有权的一个页帧。 204 | 205 | ```rust 206 | #[derive(Debug)] 207 | pub struct FrameBox { 208 | ppn: PhysPageNum, // 相当于*mut类型的指针 209 | frame_alloc: A, 210 | } 211 | ``` 212 | 213 | 每次新建时,从页帧分配器`frame_alloc`中得到新的页帧,然后使用所有权语义包装妥当。 214 | 当它的生命周期结束,调用页帧分配器,释放所占有的页帧。 215 | 216 | ```rust 217 | impl FrameBox { 218 | // 分配页帧并创建FrameBox 219 | pub fn try_new_in(frame_alloc: A) -> Result, FrameAllocError> { 220 | let ppn = frame_alloc.allocate_frame()?; 221 | Ok(FrameBox { ppn, frame_alloc }) 222 | } 223 | } 224 | 225 | impl Drop for FrameBox { 226 | fn drop(&mut self) { 227 | // 释放所占有的页帧 228 | self.frame_alloc.deallocate_frame(self.ppn); 229 | } 230 | } 231 | ``` 232 | 233 | 装箱的页帧实际地保管了页帧内存的所有权,可以向内写入数据,从中读取数据。 234 | 235 | ### 页式地址空间 236 | 237 | 一个表示分页系统实现的结构体,它保管着所有包含的页帧箱子,在释放时会释放其中的所有页帧。 238 | 239 | 这个结构体拥有一个分页模式的类型参数,用于计算页帧插入算法。 240 | 241 | ```rust 242 | // 表示一个分页系统实现的地址空间 243 | // 244 | // 如果属于直接映射或者线性偏移映射,不应当使用这个结构体,应当使用其它的结构体。 245 | #[derive(Debug)] 246 | pub struct PagedAddrSpace { 247 | root_frame: FrameBox, 248 | frames: Vec>, 249 | frame_alloc: A, 250 | page_mode: M, 251 | } 252 | ``` 253 | 254 | 当创建页式地址空间时,立即分配一个根页表。 255 | 256 | ```rust 257 | impl PagedAddrSpace { 258 | // 创建一个空的分页地址空间。一定会产生内存的写操作 259 | pub fn try_new_in(page_mode: M, frame_alloc: A) -> Result { 260 | // 新建一个根页表要求的页帧 261 | let mut root_frame = FrameBox::try_new_in(frame_alloc.clone())?; 262 | // 而后,向帧里填入一个空的根页表 263 | unsafe { fill_frame_with_initialized_page_table::(&mut root_frame) }; 264 | Ok(Self { root_frame, frames: Vec::new(), frame_alloc, page_mode }) 265 | } 266 | } 267 | ``` 268 | 269 | 创建结构后,当插入新的映射关系,使用上一节提供的插入算法,得到需要插入的范围,然后读写页帧箱,完成插入操作。 270 | 271 | ```rust 272 | impl PagedAddrSpace { 273 | // 设置页表项。如果寻找的过程中,中间的页表没创建,那么创建它们 274 | unsafe fn alloc_get_table(&mut self, entry_level: PageLevel, vpn_start: VirtPageNum) 275 | -> Result<&mut M::PageTable, FrameAllocError> 276 | { 277 | let mut ppn = self.root_frame.phys_page_num(); 278 | for &level in M::visit_levels_before(entry_level) { 279 | let page_table = unref_ppn_mut::(ppn); 280 | let vidx = M::vpn_index(vpn_start, level); 281 | match M::slot_try_get_entry(&mut page_table[vidx]) { 282 | Ok(entry) => ppn = M::entry_get_ppn(entry), 283 | Err(mut slot) => { // 需要一个内部页表,这里的页表项却没有数据,我们需要填写数据 284 | let frame_box = FrameBox::try_new_in(self.frame_alloc.clone())?; 285 | M::slot_set_child(&mut slot, frame_box.phys_page_num()); 286 | ppn = frame_box.phys_page_num(); 287 | self.frames.push(frame_box); 288 | } 289 | } 290 | } 291 | // println!("[kernel-alloc-map-test] in alloc_get_table PPN: {:x?}", ppn); 292 | let page_table = unref_ppn_mut::(ppn); // 此时ppn是当前所需要修改的页表 293 | // 创建了一个没有约束的生命周期。不过我们可以判断它是合法的,因为它的所有者是Self,在Self的周期内都合法 294 | Ok(&mut *(page_table as *mut _)) 295 | } 296 | pub fn allocate_map(&mut self, vpn: VirtPageNum, ppn: PhysPageNum, n: usize, flags: M::Flags) 297 | -> Result<(), FrameAllocError> 298 | { 299 | for (page_level, vpn_range) in MapPairs::solve(vpn, ppn, n, self.page_mode) { 300 | // println!("[kernel-alloc-map-test] PAGE LEVEL: {:?}, VPN RANGE: {:x?}", page_level, vpn_range); 301 | let table = unsafe { self.alloc_get_table(page_level, vpn_range.start) }?; 302 | let idx_range = M::vpn_index_range(vpn_range.clone(), page_level); 303 | // println!("[kernel-alloc-map-test] IDX RANGE: {:?}", idx_range); 304 | for vidx in idx_range { 305 | let this_ppn = PhysPageNum(ppn.0 - vpn.0 + M::vpn_level_index(vpn_range.start, page_level, vidx).0); 306 | // println!("[kernel-alloc-map-test] Table: {:p} Vidx {} -> Ppn {:x?}", table, vidx, this_ppn); 307 | match M::slot_try_get_entry(&mut table[vidx]) { 308 | Ok(_entry) => panic!("already allocated"), 309 | Err(slot) => M::slot_set_mapping(slot, this_ppn, flags.clone()) 310 | } 311 | } 312 | } 313 | Ok(()) 314 | } 315 | } 316 | ``` 317 | 318 | ### 包装的页帧分配算法 319 | 320 | 定义与实现如下。 321 | 322 | ```rust 323 | #[derive(Debug)] 324 | pub struct MapPairs { 325 | ans_iter: alloc::vec::IntoIter<(PageLevel, Range)>, 326 | mode: M, 327 | } 328 | 329 | impl MapPairs { 330 | pub fn solve(vpn: VirtPageNum, ppn: PhysPageNum, n: usize, mode: M) -> Self { 331 | let mut ans = Vec::new(); 332 | /* 省略求解过程 */ 333 | Self { ans_iter: ans.into_iter(), mode } 334 | } 335 | } 336 | 337 | impl Iterator for MapPairs { 338 | type Item = (PageLevel, Range); 339 | fn next(&mut self) -> Option { 340 | self.ans_iter.next() 341 | } 342 | } 343 | ``` 344 | 345 | 每次迭代它的结果,会返回一个应当分配的页帧。应当根据这个结果,设置映射关系。 346 | 347 | ### 激活函数 348 | 349 | 这个函数的实现与具体架构有关,此处以RISC-V Sv39为例。 350 | 351 | ```rust 352 | // 切换地址空间,同时需要提供1.地址空间的详细设置 2.地址空间编号 353 | pub unsafe fn activate_paged_riscv_sv39(root_ppn: PhysPageNum, asid: AddressSpaceId) { 354 | use riscv::register::satp::{self, Mode}; 355 | satp::set(Mode::Sv39, asid.0 as usize, root_ppn.0); 356 | asm!("sfence.vma {}", in(reg) asid.0 as usize); 357 | } 358 | ``` 359 | 360 | 执行完毕后,就已经进入新的地址空间了。注意当前的pc地址仍未改变,如果进入新空间后, 361 | 指令对应的代码段已经消失了,将产生异常。因此,一般使用各个虚拟空间中共同映射的“跳板页”,完成这一切换过程。 362 | 363 | ### 本设计的优缺点 364 | 365 | 这个设计的优点是,你会发现只需要传入泛型参数M,代表页表模式,就能自动填写算法剩余的部分。 366 | 367 | 比如,RISC-V Sv39模式可以实现为页表模式,传入泛型参数M;它的定义如下。 368 | 369 | ```rust 370 | // Sv39分页系统模式;RISC-V RV64下有效 371 | #[derive(Copy, Clone, PartialEq, Eq, Debug)] 372 | pub struct Sv39; 373 | 374 | impl PageMode for Sv39 { 375 | const FRAME_SIZE_BITS: usize = 12; 376 | const PPN_BITS: usize = 44; 377 | type PageTable = Sv39PageTable; 378 | type Entry = Sv39PageEntry; 379 | type Slot = Sv39PageSlot; 380 | type Flags = Sv39Flags; 381 | /* 省略了大量的工具函数 */ 382 | } 383 | ``` 384 | 385 | 只需要实现模式M中的这些参数,就可以无缝使用这个页表空间系统,包括求解算法。 386 | 387 | 完整的代码实现在[这里](https://github.com/HUST-OS/luojia-os-labs/blob/main/03-virt-addr-kern/src/mm.rs)。 388 | 389 | 这种方法也有缺点,就是需要支持泛型的编程语言才可以使用;比如操作系统内核用Rust写,可以采用这种编程方法。 390 | 391 | ## 目前常见的硬件页表系统 392 | 393 | RISC-V提供了Sv39和Sv48;它们分别是3、4级的页表系统,等级越高,能管理的虚拟空间越大。 394 | 395 | 我们使用上一节的描述方法,描述这些页表系统的基本参数。 396 | 397 | | 页表系统M | 虚拟地址 | 物理地址 | 等级n | 对齐要求a | 398 | |:---------|:------|:----------|:----------|:----------| 399 | | RISC-V Sv39 | 39 | 55 | 3 | 1, 512, 26'2144 | 400 | | RISC-V Sv48 | 48 | 55 | 4 | 1, 512, 26'2144, 1'3421'7728 | 401 | | RISC-V Sv32 | 32 | 34* | 2 | 1, 1024 | 402 | | 龙芯 LA64 | 48 | 60 | 4 | 1, 4096/2048*, ... | 403 | | 龙芯 LA32 | 32 | 36 | 2 | 1, 1024 | 404 | | arm64 | 48 | 39 | 4 | 1, 512, 26'2144, 1'3421'7728 | 405 | | arm32 | 32 | 32 | 2 | 1, 256 | 406 | | x86-64 (旧) | 48 | 47 | 4 | 1, 512, 26'2144, 1'3421'7728 | 407 | | x86-64 (新) | 57 | 52 | 5 | 1, 512, 26'2144, 1'3421'7728, 687'1947'6736 | 408 | | x86-32 | 32 | 32 | 2 | 1, 1024 | 409 | 410 | *Sv32的物理地址的确超过32位 411 | 412 | *龙芯LA64架构中,双页存储结构对齐要求不同 413 | 414 | 系统启动时,内核可以激活Sv39,页表的等级少,开销较低,启动快。随后,根据需求,可以更换到更大空间的页表系统,来容纳更多应用。 415 | 416 | ## 页式内存管理笔记 417 | 418 | 在文章的最后,我们花一些时间尝试整理页式内存管理概念的笔记。 419 | 420 | ### 页式内存管理 421 | 422 | 要管理挂载的外设和内存块,我们定义物理地址,它是真实硬件中资源单元的编号。 423 | 所有的物理地址构成一个地址空间;地址空间是可由地址索引的,具体资源和硬件的集合。 424 | 425 | 我们的应用程序可以直接在物理地址上运行。然而,为了便于程序独占地址空间,便于连续地规划内存, 426 | 我们为它们构造虚拟空间。于是程序可以在虚拟地址上运行,虚拟地址是虚拟空间中内存单元的编号。 427 | 428 | 从前我们使用段的方式管理内存。为了减少内碎片和内存的浪费,我们引入了分页管理系统。 429 | 430 | 分页系统将地址空间分为连续的等大内存块,它们被称作页帧。 431 | 又将一个或多个连续页帧组成一个页,页的大小由硬件实现决定,软件必须按硬件给定页的大小。 432 | 433 | 管理页的数据结构称作页表。在内存中,页表通常占一个页帧的大小,以便硬件上的分页系统管理。 434 | 页表在内存中的存储位置被称作页号。 435 | 436 | 超过一个页帧大小的页,又被称作大页。管理大页的页表中,每个项目代表一个子页。 437 | 这个项目被称作页表的页表项,通常由权限位、控制位和物理页号组成。 438 | 439 | 将地址空间看作最大的页,根页表就是管理最大页的页表。根页表的页号会被保存在专用的位置中,以作为硬件查询的起始地址使用。 440 | 441 | ### 翻译过程与地址对齐 442 | 443 | 现代的页表系统中,无论等级,都称管理页的数据结构为页表。页表的翻译过程大致等同于这个步骤: 444 | 445 | 1. 取出根页表的页号 446 | 2. 读取虚拟地址的特定区域,作为本级页表的索引 447 | 3. 根据索引,取出管理更小页的页表项 448 | 4. 如果页表项指向子页表,读取页号,返回到步骤2 449 | 450 | 在这个简化的步骤中,可能出现非常多的异常。需要注意的是,如果当前页表项指向物理页号,这个物理页号有对齐要求。 451 | 452 | 我们如果使用`(v0, v1, v2):offset`代表一个虚拟地址。 453 | 454 | 如果`rt[v0][v1]`指向子页表,那么将继续查找`rt[v0][v1][v2]`。得到物理页号`(p0, p1, p2)`。 455 | 和off结合,得到物理地址`(p0, p1, p2):offset`。 456 | 457 | 如果`rt[v0][v1]`指向一个物理页,这是一个大页的页表项。页表项中,得到物理页号`(p0, p1, 0)`, 458 | 直接将v2、off拼接,得到大页对应的物理地址为`(p0, p1):v2,offset`;相当于延长了偏移量的位数。 459 | 460 | 采用后一种形式时,硬件通常要求物理页号只有高于大页的等级有效,低于它的无效。 461 | 也就是说,如果页表项`(p0, p1, p2)`的p2不等于零,将会返回页异常。这就是大页页表系统的对齐要求。 462 | -------------------------------------------------------------------------------- /2021/传统内核与异步内核运行任务.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HUST-OS/design-pattern-notes/fe9c7ac1c85e3bd73b0718c1227b1618e40d5a96/2021/传统内核与异步内核运行任务.png -------------------------------------------------------------------------------- /2021/内核执行器的恢复操作.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HUST-OS/design-pattern-notes/fe9c7ac1c85e3bd73b0718c1227b1618e40d5a96/2021/内核执行器的恢复操作.png -------------------------------------------------------------------------------- /2021/分页系统实现共享调度器.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HUST-OS/design-pattern-notes/fe9c7ac1c85e3bd73b0718c1227b1618e40d5a96/2021/分页系统实现共享调度器.png -------------------------------------------------------------------------------- /2021/地址空间与进程.md: -------------------------------------------------------------------------------- 1 | # 地址空间与进程 2 | 3 | 传统操作系统内核里,地址空间和进程是一一对应的关系。很多情况下两者的概念杂凑在一起,不能得到很好的区分。 4 | 在飓风内核的实现中,我们尝试引入新的划分方法,区分“地址空间”和“进程”,体现出它们各自的作用,以供实现内核的核心思想。 5 | 6 | ## 1 地址空间 7 | 8 | 地址空间是一组地址映射关系的集合。 9 | 10 | 这里的地址映射关系是指虚拟地址到物理地址的映射关系。软件,包括 SBI 运行时,操作系统内核,用户程序,所面对的地址都属于虚拟地址,这个虚拟地址通过称为 MMU(内粗管理单元)的硬件实现会以某种映射关系转换成物理地址,物理地址是访问真实存储硬件,包括高速缓存和内存,所使用的地址。而这种虚拟地址到物理地址的映射关系,是由操作系统内核所设置的。 11 | 12 | 具体到指令集层面,在 RISC-V 指令中我们可以认为一个 satp 寄存器和它对应的页表对应一个地址空间。在这个概念的基础上,切换地址空间就可以描述为“切换 satp 寄存器并刷新 TLB”。 13 | 14 | 地址空间提供安全性隔离的作用,一个地址空间内的进程不能访问另一个地址空间的数据,可能在传统操作系统概念里面起到安全性隔离作用的是进程,但我们认为不是。**我们觉得是地址空间起到了安全性隔离的作用,进程不能做到这点**。 15 | 16 | RISC-V 指令集里面针对地址空间会有一些优化点,比如 sfence 指令,可以指定某个地址空间编号,达到只刷新 TLB 中特定地址空间的页表项的效果。这种优化点是否存在取决于具体的硬件实现。 17 | 18 | 地址空间编号是什么?RISC-V 指令集里面的 satp 寄存器,第 22 到第 30 位是 ASID 位,也就是**地址空间编号**,它唯一地标识了一个地址空间。 19 | 基于上面的思考,我们觉得地址空间这一概念是与进程分开的,在飓风内核的设计中,我们通过地址空间隔离来进行安全性隔离。 20 | 21 | ## 2 进程 22 | 23 | 我们认为,传统意义上的进程定义有三个组成部分: 24 | 25 | + 与地址空间的一一对应关系 26 | + 资源占用和释放的单位 27 | + 共同承担错误的单位 28 | 29 | 在飓风内核的设计与实现中,我们把传统意义上的这三个特点分割开来,分别单独去思考它们。 30 | 31 | 首先对于第一个特点,进程与地址空间是一一对应的关系,我们考虑新的设计,分别是一个进程对应多个地址空间和一个地址空间对应多个进程。 32 | 33 | 对于一个进程对应多个地址空间,我们暂时还没找到应用场景,因此在飓风内核的实现中暂时不考虑这部分。 34 | 35 | 一个地址空间对应多个进程,我们想到了一些应用场景,那就是资源相关性强的两个进程放到同一个地址空间中,相互之间的数据访问会比较快,因为这时候不需要切换地址空间,比如块设备驱动和文件系统。 36 | 37 | 对于这种设计,优点是在某些特定场景,同一个进程之间的数据访问会比较快(具体快在哪里需要我们后面去尝试实现这种设计之后才能比较好地归纳),但同时牺牲了一些安全性,因为这时候不同的进程处于同一个地址空间,可以访问彼此的数据。 38 | 39 | 对于第二个特点,资源占用和释放的单位,我们暂时沿用这个设计。 40 | 41 | 举个例子:一个进程打开一个文件,那么另外的进程就不能再次打开这个文件,因为这个文件资源已经被某个进程占用了。当一个进程退出之后,需要释放这个进程占用的所有资源,然后这些资源就可以被其他的进程占用。 42 | 43 | 对于第三个特点,共同承担错误的单位,我们也会沿用这个设计。 44 | 45 | 当一个任务的运行中遇到了不可恢复的错误,该任务所在的进程需要整个退出。也就是说,对于一个不可恢复的错误,该错误发生所在的任务所在的进程需要全部退出,另外的进程不受影响,这个错误由整个进程负责而不是由单个任务负责。 46 | 47 | ## todo:补充 48 | -------------------------------------------------------------------------------- /2021/大页分配图示.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HUST-OS/design-pattern-notes/fe9c7ac1c85e3bd73b0718c1227b1618e40d5a96/2021/大页分配图示.png -------------------------------------------------------------------------------- /2021/大页分配算法推导.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HUST-OS/design-pattern-notes/fe9c7ac1c85e3bd73b0718c1227b1618e40d5a96/2021/大页分配算法推导.png -------------------------------------------------------------------------------- /2021/异步内核的设计与实现.md: -------------------------------------------------------------------------------- 1 | # 异步内核的设计与实现 2 | 3 | 操作系统内核经历了几个主要的发展阶段,从裸机应用、批处理系统到多道任务系统,演变为至今主流的线程操作系统。这种系统基于线程的切换来调度任务;为了提升它的性能,有一些语言和编程架构,在应用层复用线程资源,提出了“协程”的概念,节省任务调度的开销。本次的作品中我们提出一个新的思想:由不同资源共享调度器,在操作系统层面提供协程。我们期望通过这种全新设计的内核,同时满足传统操作系统的易用性,和专有操作系统的高性能特点。 4 | 5 | ## 1 内核中的调度对象 6 | 7 | 异步内核是为处理任务而设计的。传统基于线程的编程方法中,线程会较长期地存在于系统内;较新型的编程语言中,协程通常是较短的任务片段,通常不会常驻在运行环境里。本次的内核设计中,我们将这两类时间资源的组织形式统称为“任务”。较短或较长的时间资源,都作为任务,统一被调度器处理和分配。 8 | 9 | 任务的优先级、状态和表示方式称为任务的元数据,它是由用户级和系统级共享的资源。调度器根据元数据,决定下一个阶段应当运行的任务。由任务的表示方式可以得到内容,但因为不同的上下文具有不同的地址空间,不同地址空间不能相互访问资源,从而任务的内容只有它所属的地址空间可以解释。 10 | 11 | 根据任务所占空间资源的不同,每个任务将粗略的分属于一个地址空间。这个地址空间意味着他们共享同一组资源或映射方式,同一空间内的任务相互切换开销较小;不同空间内的任务相互切换,就有较大的开销。任务所属的地址空间将被标记在它的元数据中,以便调度器解释和运算,尽量运行同一空间的任务。 12 | 13 | ## 2 如何运行异步任务 14 | 15 | 传统内核中,线程的执行占用整个处理核的计算资源。因此,通常的指令集架构都允许介入当前环境而切换上下文,这些介入方式通常与时间有关,我们称之为时钟中断。这种较为强制性方式没有过多的前提条件,因此通常假定所有影响的部分都需要保存,因此这类抢占式调度为主的方式保存上下文时间较长。 16 | 17 | 在异步内核中,协作式调度占主要的地位。我们假定切换地址空间的开销较大,就需要更多的任务在同一个地址空间里完成。调度器将优先处理同地址空间的任务,因为这能减少切换地址空间的次数。在运行多个任务后,我们提供主动让出的方式。主动让出执行后,将陷入内核,分配下一个空间的任务并运行。 18 | 19 | 主动让出和传统内核的中断有哪些关联呢?我们的主动让出,通常在已知晓任务执行完成的情况下。当任务执行完成,它占有的资源也将被释放,所以此时主动让出处理核资源,不需要保存上下文。相似地,恢复到下一个任务时,通常这个任务才开始运行,它没有需要恢复的处理核资源,若不考虑安全性,此时不需要恢复上下文。在传统的中断中,保存、恢复上下文总是必要的过程。 20 | 21 | 现代编程语言的异步任务通常较短,它的长度小于一个时间片,最终将会执行让出操作。如果有意编写较长的异步任务,我们就可将时钟中断作为一种“保底机制”,此时一个强制的中断,阻止它继续占用处理核时间。时钟中断没有过多的前提,此时需要保存任务的上下文,下次也需要恢复此上下文。 22 | 23 | 异步任务拥有不同的执行逻辑,它的调度器也需要特殊设计。调度器常给出相同空间的任务,因此不会频繁地让出,从而保证切换的次数较少,开销较低。 24 | 25 | ![传统内核与异步内核运行任务](传统内核与异步内核运行任务.png) 26 | 27 | ## 3 共享调度器 28 | 29 | 以往的操作系统内核中,调度器只在内核中运行。内核中执行不同的线程,这些线程中的任务完全由用户的任务给定,而不涉及调度运行的逻辑。如果我们开始设计异步内核,在用户层也要完成任务到任务间的切换过程。这种切换是以知晓下一个任务为前提的,所以用户层也需要运行一个调度器。我们可以以两种思路设计这种调度器: 30 | 31 | ——共享参数的分离调度器。类似于许多共享内存的输入-输出接口,它们以一些共享内存中的信息作为媒介,和内核交流。用户层的调度器以此和内核沟通,从而判断下一个任务是哪一类任务。 32 | 33 | ——合并的共享调度器。这种调度器直接将所使用的代码、任务池资源都共享到用户,由用户运行和内核相同的代码,以此与内核以相同的逻辑处理任务池中的任务,从而从共享的任务池中得到下一个任务。 34 | 35 | 共享参数调度器的设计要求无论信息为何,用户都不能干扰内核的运行,因此具有一定的安全性。这种设计需要一个工程学上稳定的数据接口,才能在内核、应用更新之后,仍然能够和旧的应用共存调度,完成内核的功能。合并的共享调度器要求将代码、任务池数据都共享给用户,在传统的架构上具有一定的风险,但用户运行的代码永远和内核是同一个版本,有利于生态的建设和推广。 36 | 37 | 社区中已有rCore-OS团队的aCore作为共享参数调度器的参考。本次设计中,我们采用合并的共享调度器,以探索未来操作系统内核的可能性,希望以此促进指令集架构的研究和探讨,帮助设计更安全的指令集架构。 38 | 39 | 共享调度器的实现可以采用页表的多重映射,在没有页表系统的嵌入式核上,也可以通过在内存权限寄存器中设计交集来实现。 40 | 41 | ![分页系统实现共享调度器](分页系统实现共享调度器.png) 42 | 43 | ## 4 调度器与执行器 44 | 45 | 在上面的分析中,我们已经知道了,异步内核中需要一个合适的调度器,它也能够用户层运行,来完成调度任务的目的。这个调度器返回的内容应当分为以下三类:一、执行一个特定的任务;二、应当切换空间,以执行下一空间的任务;三、任务池为空,应当退出程序。 46 | 47 | 这就是为什么我们需要执行器。调度器因为要设计得便于在地址空间间共享,它执行任务的逻辑就应当独立为一个执行器。这个执行器也由用户设计,它将调用本空间内共享的调度器,根据调度器输出的结果,执行对应的任务。 48 | 49 | 执行器应当根据调度器的输出来处理。如果存在不合规则的用户,不按调度器的输出执行操作,对内核的运行没有较大的影响。我们分为两类情况讨论这个问题。如果应当执行任务,却切换了地址空间,用户层将得不到继续运行所属任务的时间,因此这个操作将导致异步程序无法运行,容易被用户发现和修复。如果应当切换空间,执行器却继续执行特定的任务,“保底机制”时间中断就出现了,超时中断当前的程序,以免占据处理核时间资源。 50 | 51 | 另外,由于任务池中包含任务的元数据,只有当前地址空间的任务,允许在当前地址空间运行。因为任务的元数据中包含它的内容,只有当前地址空间的执行器可以解释,所以夹杂不同空间的调度器,不会造成执行器误认任务的情况。 52 | 53 | 在真实的应用开发中,用户不会选择为每个程序都编写一个执行器。因此,编程语言将提供简单的执行器,与用户的程序共同编译,得到完整的异步程序。 54 | 55 | ## 5 向调度器添加任务 56 | 57 | 我们有了共享的调度器,就应当新建一个任务,添加到调度器中了。由于调度算法将共享到用户层,添加任务的方法也应当共享到用户层。用户层添加新任务时,直接在当前地址空间分配、新建任务的信息,拿到一个当前空间能识别的数据作为任务的内容,从而以此作为识别任务的标志,加入到调度器中。 58 | 59 | 删除任务或任务执行完毕时,这个标志将可在地址空间内被还原为任务,从而生命周期被还原,就可以释放任务占用的内存资源。将要执行此任务时,可以通过标志得到任务的引用,从而拿出任务的内容开始执行。 60 | 61 | 这个添加任务的过程也是异步编程最核心的“生成”原语。异步编程中,常常生成一个新的任务,来分担本任务的工作,或者作为本任务结束后,接受返回数据并处理的后续任务。通过向共享调度器添加任务,我们或用语法,或用标准库函数的形式,最终可以将封装好的“生成”原语提供给用户。 62 | 63 | ## 6 进程与地址空间 64 | 65 | 我们提到了地址空间的概念,它是内存资源映射和分配必需的因素。我们仍然需要考虑内核中空间资源的分配,传统内核中“进程”的概念可以被再次利用。 66 | 67 | 开发包含插件的应用时,以往的内核将插件以链接库的形式加入主程序的进程,以便和主程序共享资源。然而在考虑安全性的前提下,主程序占有的所有资源并非都希望对插件可见,链接库形式的插件在主程序的同一个进程中,就不得不拥有这些资源的所有权,这将带来很多安全性问题。 68 | 69 | 所以我们在同一个地址空间下,可以设计多个进程,每个这些进程因为在同一个地址空间,可以很方便地通信。又因为不同的进程占用不同的资源,主程序和插件程序的权限隔离就终于可以达成了。 70 | 71 | 在实际的应用中,插件系统的主程序将通过专门的系统调用,得到当前的地址空间编号,用这个编号新建一个插件子进程,这样子进程就被映射到同一空间里了。插件和主程序在两个进程中,因此互不共享资源,从而保证了安全性。 72 | 73 | ## 7 内核本身的异步设计 74 | 75 | 通过以上的步骤,我们拥有了一个可以运行异步任务的完整运行时,和一个即将添加更多代码的内核。我们可以用这个运行时,编写异步代码的文件系统、网络层协议以及更多的内容。内核本身运行的速度也受益于本身提供的运行时,这样异步内核的设计也允许内核开发者发挥想象力,将异步编程运用到内核开发的更多个方面上来。内核中常见的文件、设备等概念也可以由此产生了。 76 | -------------------------------------------------------------------------------- /2021/执行器与生成语义.md: -------------------------------------------------------------------------------- 1 | # 执行器与生成语义 2 | 3 | 操作系统为处理多应用而生,应当包含执行应用、中断处理这些模块。 4 | 传统的方法需要较多不会返回的函数,某种程度上,需要开发者加以更多的注意。 5 | 我们基于逐渐成型的“生成器语义”,提出一种新的方法来编写它们。 6 | 7 | 要搭建生成器语义的执行器,我们使用一个“恢复”函数运行用户应用,“恢复”函数将返回产生的中断; 8 | 这样可以以一种编程语言常见的形式,来隐含切换到应用和处理中断的流程。另外,应用切换到内核也可使用相似的形式。 9 | 这种方法能包装了应用执行和中断处理的逻辑,降低了编写难度,增加内核开发者的编程效率。 10 | 11 | ## 1 生成器与“让出”操作 12 | 13 | 以Rust语言为例,我们来看“生成器”是如何的一种概念。生成器存在特殊的“让出”操作,允许在运行“让出”操作时, 14 | 打断当前的执行流程,回到调用生成器的环境。“让出”操作可以带一个返回值,允许环境做一些需要的操作。 15 | 16 | 我们看一段例子代码([来源:知乎@MashPlant](https://zhuanlan.zhihu.com/p/157496421)): 17 | 18 | ```rust 19 | use std::ops::{Generator, GeneratorState}; 20 | use std::pin::Pin; 21 | 22 | fn main() { 23 | let mut g = || { 24 | yield 1; 25 | return "foo" 26 | }; 27 | 28 | assert_eq!(Pin::new(&mut g).resume(()), GeneratorState::Yielded(1)); 29 | assert_eq!(Pin::new(&mut g).resume(()), GeneratorState::Complete("foo")); 30 | } 31 | ``` 32 | 33 | 这里的变量“g”定义比较像普通的闭包,但里面出现了“yield”让出关键字,所以变量“g”变成了一个生成器。 34 | 生成器拥有一个“恢复”即“resume”函数,只要调用了恢复函数,生成器就会继续执行,直到下一个“让出”操作或者“返回”。 35 | 36 | 出现“让出”操作时,生成器内的代码将会暂停运行,让出到它的调用者即“main”函数,它将拿到让出操作的让出值。 37 | 使用让出值执行一些操作,可以打印出来,或者也可以做一些随后的处理,这里判断它和预期的值是否相等。 38 | 这之后,可以继续执行“恢复”函数,直到生成器运行结束。 39 | 40 | 所以简单来说,生成器是一个执行可以暂停,并不断产生值的一种变量。拥有一个生成器时,我们可以不断执行它, 41 | 处理它产生的中间值,直到它的执行过程完成。 42 | 43 | ## 2 内核执行器的新编程方法 44 | 45 | 传统内核中隐约地包含了“执行器”的思想:它运行当前的线程,中断发生时暂停线程,转入调度程序,得到下一个要运行的线程。 46 | 编写传统内核的调度逻辑,开发者必须完整地了解上下文切换、中断处理的流程。 47 | 48 | 思考内核的编写方法。应用的执行可以暂停,它可以不断地产生中断的原因和上下文。咦,这是不是和生成器的思路非常相似? 49 | 50 | 于是,我们可以把内核的运行环境看作是一个生成器。内核不断执行“恢复”函数,继续运行用户代码。 51 | 每当中断发生,将会产生中断的原因和上下文,这就是生成器生成的让出值。 52 | 内核前去处理让出值,在这期间,可以决定下一个要运行的应用,切换继续运行的应用,继续“恢复”它们,直到应用执行完成。 53 | 54 | 在传统内核中,假设运行环境是一个产生中断的生成器,我们可以编写下面的伪代码: 55 | 56 | ```rust 57 | let mut runtime = Runtime::new(); // Runtime是一个生成器,实现了trait Generator 58 | runtime.prepare_first_thread(); 59 | loop { // 不断执行恢复操作 60 | match Pin::new(&mut runtime).resume(()) { // 判断产生的中断是哪种类型 61 | Yielded(ResumeResult::Syscall()) => { 62 | syscall_process(&mut runtime); 63 | // runtime包含用户上下文,系统调用函数会读取其中的参数,将返回值填写回runtime里。 64 | // 随后,下一个循环resume函数会继续执行当前的线程 65 | // 如果这个系统调用将增加一个线程,它会操作runtime的值。 66 | } 67 | Yielded(ResumeResult::TimerInterrupt()) => { 68 | runtime.prepare_next_thread(); 69 | // 准备完毕后,下一个循环的resume函数将会执行下一个线程 70 | } 71 | Yielded(ResumeResult::IllegalInstruction(_insn_addr)) => { 72 | core_dump(); 73 | runtime.destroy_current_thread(); 74 | runtime.prepare_next_thread(); 75 | // 当前的线程会被销毁,然后下一个线程会在下一个resume函数被运行 76 | } 77 | Complete(()) => break // 如果没有线程了,执行器运行结束 78 | } 79 | } 80 | ``` 81 | 82 | 这样的编程方法遵守了高级语言的思路。在Rust语言中,它没有使用全局变量,便于控制生命周期, 83 | 能提高开发者的编程效率和代码的安全性。只要在启动代码最后使用这段代码,就可以不断运行线程了。 84 | 85 | 接下来就是生成器要如何实现了。和普通应用的生成器不同,内核的中断和恢复是比较复杂的。 86 | 我们需要整理传统的上下文切换方法,实现内核运行用户的生成器。 87 | 88 | ## 3 实现生成器 89 | 90 | 对不同的架构来说,传统内核的上下文切换,需要保存和读取通用的寄存器,也需要地址空间的切换操作。 91 | 不同的是,当前执行器的上下文将将被保存,因为后续仍然需要运行执行器。 92 | 93 | 以常用于嵌入式开发的RTOS为例,我们需要存取通用的寄存器。 94 | 在恢复函数执行时,首先保存当前执行器的上下文,只需要保存被调用者保存的寄存器,因为调用者保存的寄存器已经在调用函数之前保存了。 95 | 在RISC-V中,需要保存所有的s0-s11寄存器。因为用户程序不会为我们保存返回地址,所以我们还需要保存ra寄存器。 96 | 97 | 然后,恢复用户的上下文,这里应当保存用户所有的寄存器。 98 | 在RISC-V架构中,需要x1一直到x31寄存器;如果内核也可能发生中断,为了支持嵌套中断,还需要保存sstatus寄存器。 99 | 100 | 用户和内核都可能修改gp、tp寄存器。为了隔离数据、增加安全性,两个过程都需要保存gp、tp寄存器。 101 | 102 | 产生中断时,首先保存用户的上下文,随后跳转到中断处理函数。中断处理函数将保存s0-s11寄存器,返回值保存到a0和a1寄存器。 103 | 最后,当中断处理函数返回,它将跳转到另一段代码,它将恢复内核的ra、s0-s11寄存器,最终通过ra寄存器,返回到内核的生成器中。 104 | 105 | 这个生成器绕了一圈,终于接收到了中断处理函数传来的a0和a1寄存器作为返回值,说明收到了中断。 106 | 生成器的函数终于可以返回了,它将返回到运行它的执行环境中,以等待下一步的操作。 107 | 108 | 我们用伪代码来说明这个过程: 109 | 110 | ```rust 111 | impl Generator for Runtime { 112 | type Yield = IsaSpecificTrap; 113 | type Return = (); 114 | fn resume(mut self: Pin<&mut Self>, _arg: ()) -> GeneratorState { 115 | do_resume(&mut self.context); 116 | // 到这里,用户程序已经开始执行。发生中断时,跳转到处理函数interrupt。 117 | // ← 当interrupt_return函数运行结束,它将返回到这里 118 | return IsaSpecificCauseRegister::read().into() 119 | } 120 | } 121 | fn do_resume(ctx: &mut UserContext) { 122 | asm!("save executor context", "load user context `ctx`", "jump to user application") 123 | } 124 | fn interrupt() -> ! { 125 | asm!("save user context", "jump to `interrupt_return`") 126 | } 127 | fn interrupt_return() -> ! { 128 | asm!("load executor context", "jump to return address register") 129 | // 这段代码已经完成了中断上下文的操作,它会返回到resume函数中 → 130 | } 131 | ``` 132 | 133 | 可以看到,恢复函数跳转到了用户程序中。但是,中断处理函数给恢复函数填写了返回值。 134 | 从用户看来,虽然经过很多过程的包装,但生成器函数竟然能够返回,拿到中断的类型和上下文。 135 | 至此,最重要的恢复操作已经实现,生成器可以投入使用了。 136 | 137 | 这段代码是伪代码,具体能运行的实现代码已经提交到luojia-os-labs中:[生成器](https://github.com/HUST-OS/luojia-os-labs/blob/main/01b-magic-return-kern/kernel/src/executor.rs)、[使用方法](https://github.com/HUST-OS/luojia-os-labs/blob/b3876866f2b6e2b6ad7bd1eba286fbaa9a6cca8a/01b-magic-return-kern/kernel/src/main.rs#L37)。 138 | 139 | ![内核执行器的恢复操作](内核执行器的恢复操作.png) 140 | 141 | 需要做的一些说明是,具体实现中,参数的填写方式和二进制接口有关。如果返回值的长度超过两个usize长度,RISC-V下参数会从a1寄存器开始填写, 142 | a0寄存器将被用作寄存器相对寻址,来保存真正的返回值;参数将不会a0寄存器开始保存。 143 | 144 | 根据中断处理函数的返回值类型,来决定上下文参数要放在a0还是a1中。一般为了简便,中断处理函数的返回值都很小, 145 | 就假设它很小,总是填在a0里面就可以了。如果写错了参数的保存方法,代码会出现很多意料之外的情况。 146 | 147 | ## 4 生成语义与“相对性” 148 | 149 | 经过以上的讨论,我们的内核可以创建一个生成器,不断执行它的恢复操作,运行所有的应用程序。我们有一个很大胆的想法。 150 | 151 | 首先我们来做一个思维实验。有一个人在地球上画一个圈,然后他站在圈里面。 152 | 如果从地球上其他人来看,是这个人被圈圈在里面。如果从这个人的角度看,是地球上的其他人都被圈在圈里面。 153 | 154 | 那么,如果内核能不断用生成器使用用户,用户能不能也把内核看成生成器,不断使用内核呢? 155 | 156 | 好像想得通啊,是不是有些头皮发麻了?不急,我们看看怎么回事。 157 | 158 | 每当发生一个硬件中断,它不是由内核产生的,也不是由用户产生的。 159 | 如果我们有一个不断运行任务的应用,把所有的任务看作是相对于内核的用户,它将运行所有的任务,不断产生“运行结束”的提示。 160 | 或者,如果一个任务超时了,将产生“已经超时”的提示。 161 | 如果是运行结束,用户的执行器可以复用原有的栈,拿出下一个任务,继续运行。 162 | 如果生成了“已经超时”,上一个任务的栈将会保留,执行器将创建一个新的栈,来运行下一个任务。 163 | 164 | 那么如何编写这个生成器呢?如果产生了中断,永远是内核在处理,轮不到用户去处理,于是用户没有机会知道中断发生了。 165 | 除非,用户层也有一个中断委托机制。即使内核能切换到其它的用户,如果内核在切换到这个用户时,提示用户,发生了一次上下文切换, 166 | 这时候用户就能包装这个提示,作为“已经超时”,作为生成器实现的一部分了。 167 | 168 | 如果在有信号量的系统,我们可以使用信号量,完成上下文切换提示的过程。如果硬件提供了用户层中断,我们也可以将它作为一个提示, 169 | 用生成语义实现用户层的执行器。 170 | 171 | 在这种比较初步的想法下,内核和用户都能接收到中断,他们是相对的。在未来的架构中,我们可以尝试使用生成语义, 172 | 在用户层更高效地开发的执行器和运行环境。 173 | 174 | ## 一些记录 175 | 176 | Rust语言有一个还没稳定的执行器语法,我提了一个[issue评论](https://github.com/rust-lang/rust/issues/43122#issuecomment-830573558),不知道社区的各位会怎么看。就记在这里,以免找不到issue。 177 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 木兰宽松许可证, 第2版 2 | 3 | 2020年1月 http://license.coscl.org.cn/MulanPSL2 4 | 5 | 您对“软件”的复制、使用、修改及分发受木兰宽松许可证,第2版(“本许可证”)的如下条款的约束: 6 | 7 | 0. 定义 8 | 9 | “软件” 是指由“贡献”构成的许可在“本许可证”下的程序和相关文档的集合。 10 | 11 | “贡献” 是指由任一“贡献者”许可在“本许可证”下的受版权法保护的作品。 12 | 13 | “贡献者” 是指将受版权法保护的作品许可在“本许可证”下的自然人或“法人实体”。 14 | 15 | “法人实体” 是指提交贡献的机构及其“关联实体”。 16 | 17 | “关联实体” 是指,对“本许可证”下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。 18 | 19 | 1. 授予版权许可 20 | 21 | 每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可以复制、使用、修改、分发其“贡献”,不论修改与否。 22 | 23 | 2. 授予专利许可 24 | 25 | 每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其“贡献”或以其他方式转移其“贡献”。前述专利许可仅限于“贡献者”现在或将来拥有或控制的其“贡献”本身或其“贡献”与许可“贡献”时的“软件”结合而将必然会侵犯的专利权利要求,不包括对“贡献”的修改或包含“贡献”的其他结合。如果您或您的“关联实体”直接或间接地,就“软件”或其中的“贡献”对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或其他专利维权行动,指控其侵犯专利权,则“本许可证”授予您对“软件”的专利许可自您提起诉讼或发起维权行动之日终止。 26 | 27 | 3. 无商标许可 28 | 29 | “本许可证”不提供对“贡献者”的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定的声明义务而必须使用除外。 30 | 31 | 4. 分发限制 32 | 33 | 您可以在任何媒介中将“软件”以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供“本许可证”的副本,并保留“软件”中的版权、商标、专利及免责声明。 34 | 35 | 5. 免责声明与责任限制 36 | 37 | “软件”及其中的“贡献”在提供时不带任何明示或默示的担保。在任何情况下,“贡献者”或版权所有者不对任何人因使用“软件”或其中的“贡献”而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于何种法律理论,即使其曾被建议有此种损失的可能性。 38 | 39 | 6. 语言 40 | 41 | “本许可证”以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文版为准。 42 | 43 | 条款结束 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 项目已封存 2 | 3 | 文章已经归纳到战队官网(恰饭网):[https://qf.rs](https://qf.rs) 4 | 5 | --- 6 | 7 | # 内核设计笔记 8 | 9 | 各类不同内核的设计思路 10 | 11 | ## 2021年 12 | 13 | - [一种有效的页表系统抽象设计](2021/一种有效的页表系统抽象设计.md),2021年5月 14 | - [执行器与生成语义](2021/执行器与生成语义.md),2021年5月 15 | - [地址空间与进程](2021/地址空间与进程.md),2021年5月 16 | - [异步内核的设计与实现](2021/异步内核的设计与实现.md),2021年4月 17 | 18 | ## 协议 19 | 20 | 本仓库的所有文章使用[木兰宽松许可证,第2版](https://license.coscl.org.cn/MulanPSL2/)协议发布。 21 | 22 | ```text 23 | 版权所有©2021 无相之风团队。项目“内核设计笔记”以木兰宽松许可证,第2版发布。 24 | 您可以在遵守木兰宽松许可证,第2版的定义与条款下,使用本项目。 25 | 您可以在这个链接查看许可证的完整内容: 26 | http://license.coscl.org.cn/MulanPSL2 27 | 本软件仅按原样提供,并不包括任何明示或暗示形式的保证,这些保证包括但不限于非侵权、适销性或适合特定目的。 28 | 请阅读木兰宽松许可证,第2版的原文来获取更多信息。 29 | ``` 30 | --------------------------------------------------------------------------------