diff --git a/source/chapter4/0intro.rst b/source/chapter4/0intro.rst index 96db1df..131ba89 100644 --- a/source/chapter4/0intro.rst +++ b/source/chapter4/0intro.rst @@ -61,8 +61,6 @@ .. code-block:: console - $ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git - $ cd rCore-Tutorial-v3 $ git checkout ch4 在 qemu 模拟器上运行本章代码: @@ -211,37 +209,4 @@ 本章代码导读 ----------------------------------------------------- -本章涉及的代码量相对多了起来,也许同学们不知如何从哪里看起或从哪里开始尝试实验。这里简要介绍一下“头甲龙”操作系统的大致开发过程。 - -我们先从简单的地方入手,那当然就是先改进应用程序了。具体而言,主要就是把 ``linker.ld`` 中应用程序的起始地址都改为 ``0x0`` ,这是假定我们操作系统能够通过分页机制把不同应用的相同虚地址映射到不同的物理地址中。这样我们写应用就不用考虑物理地址布局的问题,能够以一种更加统一的方式编写应用程序,可以忽略掉一些不必要的细节。 - -为了能够在内核中动态分配内存,我们的第二步需要在内核增加连续内存分配的功能,具体实现主要集中在 ``os/src/mm/heap_allocator.rs`` 中。完成这一步后,我们就可以在内核中用到Rust的堆数据结构了,如 ``Vec`` 、 ``Box`` 等,这样内核编程就更加灵活了。 - -操作系统如果要建立页表,首先要能管理整个系统的物理内存,这就需要知道物理内存哪些区域放置内核的代码、数据,哪些区域则是空闲的等信息。所以需要了解整个系统的物理内存空间的范围,并以物理页帧为单位分配和回收物理内存,具体实现主要集中在 ``os/src/mm/frame_allocator.rs`` 中。 - -页表中的页表项的索引其实是虚拟地址中的虚拟页号,页表项的重要内容是物理地址的物理页帧号。为了能够灵活地在虚拟地址、物理地址、虚拟页号、物理页号之间进行各种转换,在 ``os/src/mm/address.rs`` 中实现了各种转换函数。 - -完成上述工作后,基本上就做好了建立页表的前期准备。我们就可以开始建立页表,这主要涉及到页表项的数据结构表示,以及多级页表的起始物理页帧位置和整个所占用的物理页帧的记录。具体实现主要集中在 ``os/src/mm/page_table.rs`` 中。 - -一旦使能分页机制,那么内核中也将基于虚地址进行虚存访问,所以在给应用添加虚拟地址空间前,内核自己也会建立一个页表,把整个物理地址空间通过简单的恒等映射对应到一个虚拟地址空间中。后续的应用在执行前,也需要建立一个虚拟地址空间,这意味着第三章的 ``task`` 将进化到第五章的拥有独立页表的进程 。虚拟地址空间需要有一个数据结构管理起来,这就是 ``MemorySet`` ,即地址空间这个抽象概念所对应的具象体现。在一个虚拟地址空间中,有代码段,数据段等不同属性且不一定连续的子空间,它们通过一个重要的数据结构 ``MapArea`` 来表示和管理。围绕 ``MemorySet`` 等一系列的数据结构和相关操作的实现,主要集中在 ``os/src/mm/memory_set.rs`` 中。比如内核的页表和虚拟空间的建立在如下代码中: - -.. code-block:: rust - :linenos: - - // os/src/mm/memory_set.rs - - lazy_static! { - pub static ref KERNEL_SPACE: Arc> = Arc::new(Mutex::new( - MemorySet::new_kernel() - )); - } - -完成到这里,我们就可以使能分页机制了。且我们应该有更加方便的机制来给支持应用运行。在本章之前,都是把应用程序的所有元数据丢弃从而转换成二进制格式来执行,这其实把编译器生成的 ELF 执行文件中大量有用的信息给去掉了,比如代码段、数据段的各种属性,程序的入口地址等。既然有了给应用运行提供虚拟地址空间的能力,我们就可以利用 ELF 执行文件中的各种信息来灵活构建应用运行所需要的虚拟地址空间。在 ``os/src/loader.rs`` 中可以看到如何获取一个应用的 ELF 执行文件数据,而在 ``os/src/mm/memory_set`` 中的 ``MemorySet::from_elf`` 可以看到如何通过解析 ELF 来创建一个应用地址空间。 - -对于有了虚拟地址空间的 *任务* ,我们可以把它叫做 *进程* 了。操作系统为此需要扩展任务控制块 ``TaskControlBlock`` 的管理范围,使得操作系统能管理拥有独立页表和虚拟地址空间的应用程序的运行。相关主要的改动集中在 ``os/src/task/task.rs`` 中。 - -由于代表应用程序运行的进程和管理应用的操作系统各自有独立的页表和虚拟地址空间,所以这就出现了两个比较挑战的事情。一个是由于系统调用、中断或异常导致的应用程序和操作系统之间的 Trap 上下文切换不像以前那么简单了,因为需要切换页表,这需要看看 ``os/src/trap/trap.S`` ;还有就是需要对来自用户态和内核态的 Trap 分别进行处理,这需要看看 ``os/src/trap/mod.rs`` 和 :ref:`跳板的实现 ` 中的讲解。 - -另外一个挑战是,在内核地址空间中执行的内核代码常常需要读写应用地址空间的数据,这无法简单的通过一次访存交给 MMU 来解决,而是需要手动查应用地址空间的页表。在访问应用地址空间中的一块跨多个页数据的时候还需要注意处理边界条件。可以参考 ``os/src/syscall/fs.rs``、 ``os/src/mm/page_table.rs`` 中的 ``translated_byte_buffer`` 函数的实现。 - -实现到这,应该就可以给应用程序运行提供一个方便且安全的虚拟地址空间了。 \ No newline at end of file +本章涉及的代码量相对多了起来。新增的代码主要是集中在页表的处理上的。由于课程整改,春季学期的同学们可能还没有上过计组,对页表的内容还不太熟悉。因此本章的内容可能需要同学们多多回顾OS课上对页表的讲解。同时本章也会介绍我们OS的Riscv-64指令集是如何设计页表,以及页表读取和修改的方式。 \ No newline at end of file diff --git a/source/chapter4/1rust-dynamic-allocation.rst b/source/chapter4/1rust-dynamic-allocation.rst index 039bdc9..186a758 100644 --- a/source/chapter4/1rust-dynamic-allocation.rst +++ b/source/chapter4/1rust-dynamic-allocation.rst @@ -1,4 +1,4 @@ -Rust 中的动态内存分配 +C 中的动态内存分配 ======================================================== @@ -16,49 +16,7 @@ Rust 中的动态内存分配 - 提供空闲空间管理的连续内存分配算法。能够有效地管理空闲快,这样就能够动态地维护一系列空闲和已分配的内存块。 - (可选)提供建立在堆上的数据结构和操作。有了上述基本的内存分配与释放函数接口,就可以实现类似动态数组,动态字典等空间灵活可变的堆数据结构,提高编程的灵活性。 -考虑到我们是用Rust来编程的,为了在接下来的一些操作系统的实现功能中进一步释放 Rust 语言的强表达能力来减轻我们的编码负担,本节我们尝试在内核中支持动态内存分配以可以使用各种需要动态内存支持的Rust功能,如Vec、HashMap等。 - -静态与动态内存分配 ----------------------------------------------- - - -静态分配 -^^^^^^^^^^^^^^^^^^^^^^^^^ - - -若在某一时间点观察一个应用的地址空间,可以看到若干块连续内存,每一块都对应于一个生命周期尚未结束的变量。这个变量可能 -是一个局部变量,它来自于当前正在执行的函数或者当前函数调用栈上某个正在等待调用返回的函数的栈帧,也即它是被分配在 -栈上;这个变量也可能是一个全局变量,它被分配在数据段中。它们有一个共同点:在编译的时候编译器已经知道它们类型的字节大小, -于是给它们分配一块等大的内存将它们存储其中,这块内存在变量所属函数的栈帧/数据段中的位置也已经被固定了下来。 - -.. _term-static-allocation: - -这些变量是被 **静态分配** (Static Allocation) 的,这一过程来源于我们在程序中对变量的声明,在编译期由编译器完成。 -如果应用仅使用静态分配,它也许可以应付绝大部分的需求,但是某些情况则不够灵活。比如,需要将一个文件读到内存进行处理, -而且必须将文件一次性完整读进来处理才能正确。此时,可以选择声明一个栈上的局部变量或者数据段中的全局变量作为缓冲区来暂存 -文件的内容。但在编程的时候我们并不知道待处理的文件的大小,只能根据经验将缓冲区的大小设置为某一固定常数。在代码真正运行 -的时候,如果待处理的文件很小,那么缓冲区多出的部分是被浪费掉的,也拉高了应用的内存占用;如果待处理的文件很大,应用则 -无法正常运行。就像缓冲区的大小设置一样,还有很多其他的问题来源于某些数据结构需求的内存大小取决于应用的实际运行情况。 - - -动态分配 -^^^^^^^^^^^^^^^^^^^^^^^^^ - - -.. _term-dynamic-allocation: - -此时,使用 **动态分配** (Dynamic Allocation) 则可以解决这个问题。动态分配就是指应用不仅在自己的地址空间放置那些 -自编译期开始就大小固定、用于静态内存分配的逻辑段(如全局数据段、栈段),还另外放置一个大小可以随着应用的运行动态增减 -的逻辑段,它的名字叫做堆。同时,应用还要能够将这个段真正管理起来,即支持在运行的时候从里面分配一块空间来存放变量,而 -在变量的生命周期结束之后,这块空间需要被回收以待后面的使用。如果堆的大小固定,那么这其实就是一个连续内存分配问题, -我们课上所介绍到的那些算法都可以随意使用。取决于应用的实际运行状况,每次分配的空间大小可能会有不同,因此也会产生外碎片。 -如果在某次分配的时候发现堆空间不足,我们并不会像上一小节介绍的那样移动变量的存放位置让它们紧凑起来从而释放间隙用来分配 -(事实上它很难做到这一点), -一般情况下应用会直接通过系统调用(如类 Unix 内核提供的 ``sbrk`` 系统调用)来向内核请求增加它地址空间内堆的大小,之后 -就可以正常分配了。当然,这一类系统调用也能缩减堆的大小。 - -鉴于动态分配是一项非常基础的功能,很多高级语言的标准库中都实现了它。以 C 语言为例,C 标准库中提供了如下两个动态分配 -的接口函数: +在使用C++语言的过程中,大家其实对new/delete的使用方法已经烂熟于心了。在C中,对动态内存的申请是采用如下的函数实现的: .. code-block:: c @@ -71,239 +29,77 @@ Rust 中的动态内存分配 一个指针类型的大小就可以等于计算机可寻址空间的位宽。这样的它们却可以作为背后一块大小在编译期无法确定的空间的代表,这是一件非常有趣的 事情。 -除了可以灵活利用内存之外,动态分配还允许我们以尽可能小的代价灵活调整变量的生命周期。一个局部变量被静态分配在它所在函数 -的栈帧中,一旦函数返回,这个局部变量的生命周期也就结束了;而静态分配在数据段中的全局变量则是在应用的整个运行期间均存在。 -动态分配允许我们构造另一种并不一直存在也不绑定于函数调用的变量生命周期:以 C 语言为例,可以说自 ``malloc`` 拿到指向 -一个变量的指针到 ``free`` 将它回收之前的这段时间,这个变量在堆上存在。由于需要跨越函数调用,我们需要作为堆上数据代表 -的变量在函数间以参数或返回值的形式进行传递,而这些变量一般都很小(如一个指针),其拷贝开销可以忽略。 +对于同一个页的地址而言它对应的物理内存时连续的。但是,连续的虚拟地址空间不一定对应着连续的物理地址空间,因此我们需要一个数据结构来存储哪些物理内存是可用的。对于这种给不连续的情况,我们采用了链表的数据结构,将空闲的每个PAGE大小的物理内存空间作为listnode来进行内存的管理。这些新增的代码在kalloc.c之中。 -而动态内存分配的缺点在于:它背后运行着连续内存分配算法,相比静态分配会带来一些额外的开销。如果动态分配非常频繁,可能会产生很多无法使用的空闲空间碎片,甚至可能会成为应用的性能瓶颈。 +kalloc之中的动态内存分配 +---------------------------------------------- -.. _rust-heap-data-structures: +我们采用链表结构记录空闲的物理地址。因此当应用程序申请一段动态内存的时候,只需要把链表头所指向地址拿出即可。 -Rust 中的堆数据结构 ------------------------------------------------- - -Rust 的标准库中提供了很多开箱即用的堆数据结构,利用它们能够大大提升我们的开发效率。 - -.. _term-smart-pointer: - -首先是一类 **智能指针** (Smart Pointer) 。智能指针和 Rust 中的其他两类指针也即裸指针 ``*const T/*mut T`` -以及引用 ``&T/&mut T`` 一样,都指向地址空间中的另一个区域并包含它的位置信息。但不同在于,它们携带的信息数量不等, -需要经过编译器不同等级的安全检查,可靠性和灵活程度也不同。 - -.. _term-borrow-check: - -- 裸指针 ``*const T/*mut T`` 基本等价于 C/C++ 里面的普通指针 ``T*`` ,它自身的内容仅仅是一个地址。它最为灵活, - 但是也最不安全。编译器只能对它进行最基本的可变性检查, :ref:`第一章 ` 曾经提到,对于裸指针 - 解引用访问它指向的那块数据是 unsafe 行为,需要被包裹在 unsafe 块中。 -- 引用 ``&T/&mut T`` 自身的内容也仅仅是一个地址,但是 Rust 编译器会在编译的时候进行比较严格的 **借用检查** - (Borrow Check) ,要求引用的生命周期必须在被借用的变量的生命周期之内,同时可变借用和不可变借用不能共存,一个 - 变量可以同时存在多个不可变借用,而可变借用同时最多只能存在一个。这能在编译期就解决掉很多内存不安全问题。 -- 智能指针不仅包含它指向的区域的地址,还含有一些额外的信息,因此这个类型的字节大小大于平台的位宽,属于一种胖指针。 - 从用途上看,它不仅可以作为一个媒介来访问它指向的数据,还能在这个过程中起到一些管理和控制的功能。 - -在 Rust 中,与动态内存分配相关的智能指针有如下这些: - -- ``Box`` 在创建时会在堆上分配一个类型为 ``T`` 的变量,它自身也只保存在堆上的那个变量的位置。而和裸指针或引用 - 不同的是,当 ``Box`` 被回收的时候,它指向的——也就是在堆上被动态分配的那个变量也会被回收。 -- ``Rc`` 是一个单线程上使用的引用计数类型, ``Arc`` 与其功能相同,只是它可以在多线程上使用。它提供了 - 多所有权,也即地址空间中同时可以存在指向同一个堆上变量的 ``Rc`` ,它们都可以拿到指向变量的不可变引用来 - 访问这同一个变量。而它同时也是一个引用计数,事实上在堆上的另一个位置维护了堆上这个变量目前被引用了多少次, - 也就是存在多少个 ``Rc`` 。这个计数会随着 ``Rc`` 的创建或复制而增加,并当 ``Rc`` 生命周期结束 - 被回收时减少。当这个计数变为零之后,这个计数变量本身以及被引用的变量都会从堆上被回收。 -- ``Mutex`` 是一个互斥锁,在多线程中使用,它可以保护里层被动态分配到堆上的变量同一时间只有一个线程能对它 - 进行操作,从而避免数据竞争,这是并发安全的问题,会在后面详细说明。同时,它能够提供 - :ref:`内部可变性 ` 。``Mutex`` 时常和 ``Arc`` 配套使用,因为它是用来 - 保护多个线程可能同时访问的数据,其前提就是多个线程都拿到指向同一块堆上数据的 ``Mutex`` 。于是,要么就是 - 这个 ``Mutex`` 作为全局变量被分配到数据段上,要么就是我们需要将 ``Mutex`` 包裹上一层多所有权变成 - ``Arc>`` ,让它可以在线程间进行传递。请记住 ``Arc>`` 这个经典组合,我们后面会经常用到。 - - 之前我们通过 ``RefCell`` 来获得内部可变性。可以将 ``Mutex`` 看成 ``RefCell`` 的多线程版本, - 因为 ``RefCell`` 是只能在单线程上使用的。而且 ``RefCell`` 并不会在堆上分配内存,它仅用到静态内存 - 分配。 - -这和 C++ 很像, ``Box`` 可以对标 C++ 的 ``std::unique_ptr`` ;而 ``Arc`` 则类似于 C++ 的 -``std::shared_ptr`` 。 - -.. _term-collection: -.. _term-container: - -随后,是一些 **集合** (Collection) 或称 **容器** (Container) 类型,它们负责管理一组数目可变的元素,这些元素 -的类型相同或是有着一些同样的特征。在 C++/Python/Java 等高级语言中我们已经对它们的使用方法非常熟悉了,对于 -Rust 而言,我们则可以直接使用以下容器: - -- 向量 ``Vec`` 类似于 C++ 中的 ``std::vector`` ; -- 键值对容器 ``BTreeMap`` 类似于 C++ 中的 ``std::map`` ; -- 有序集合 ``BTreeSet`` 类似于 C++ 中的 ``std::set`` ; -- 链表 ``LinkedList`` 类似于 C++ 中的 ``std::list`` ; -- 双端队列 ``VecDeque`` 类似于 C++ 中的 ``std::deque`` 。 -- 变长字符串 ``String`` 类似于 C++ 中的 ``std::string`` 。 - -下面是一张 Rust 智能指针/容器及其他类型的内存布局的经典图示,来自 -`这里 `_ 。 - -.. image:: rust-containers.png - -可以发现,在动态内存分配方面 Rust 和 C++ 很像,事实上 Rust 有意从 C++ 借鉴了这部分优秀特性。让我们先来看其他一些语言 -使用动态内存的方式: - -.. _term-reference-counting: -.. _term-garbage-collection: - -- C 语言仅支持 ``malloc/free`` 这一对操作,它们必须恰好成对使用,否则就会出现错误。比如分配了之后没有回收,则会导致 - 内存溢出;回收之后再次 free 相同的指针,则会造成 Double-Free 问题;又如回收之后再尝试通过指针访问它指向的区域,这 - 属于 Use-After-Free 问题。总之,这样的内存安全问题层出不穷,毕竟人总是会犯错的。 -- Python/Java 通过 **引用计数** (Reference Counting) 对所有的对象进行运行时的动态管理,一套 **垃圾回收** - (GC, Garbage Collection) 机制会被自动定期触发,每次都会检查所有的对象,如果其引用计数为零则可以将该对象占用的内存 - 从堆上回收以待后续其他的对象使用。这样做完全杜绝了内存安全问题,但是性能开销则很大,而且 GC 触发的时机和每次 GC 的 - 耗时都是无法预测的,还使得性能不够稳定。 - -.. _term-raii: - -C++ 的 **资源获取即初始化** (RAII, Resource Acquisition Is Initialization) 风格则致力于解决上述问题。 -RAII 的含义是说,将一个使用前必须获取的资源的生命周期绑定到一个变量上。以 ``Box`` 为例,在它被 -创建的时候,会在堆上分配一块空间保存它指向的数据;而在 ``Box`` 生命周期结束被回收的时候,堆上的那块空间也会 -立即被一并回收。这也就是说,我们无需手动回收资源,它会和绑定到的变量同步由编译器自动回收,我们既不用担心忘记回收更不 -可能回收多次;同时,由于我们很清楚一个变量的生命周期,则该资源何时被回收也是完全可预测的,我们也明确知道这次回收 -操作的开销。在 Rust 中,不限于堆内存,将某种资源的生命周期与一个变量绑定的这种 RAII 的思想无处不见,甚至这种资源 -可能只是另外一种类型的变量。 - - -在内核中支持动态内存分配 --------------------------------------------------------- - -如果要在操作系统内核中支持动态内存分配,则需要实现在本节开始介绍的一系列功能:初始化堆、分配/释放内存块的函数接口、连续内存分配算法。相对于C语言而言,如果用Rust语言实现,它在 ``alloc`` crate中设定了一套简洁规范的接口,只要实现了这套接口,内核就可以很方便地支持动态内存分配了。 - -上边介绍的那些与堆相关的智能指针或容器都可以在 Rust 自带的 ``alloc`` crate 中找到。当我们使用 Rust 标准库 -``std`` 的时候可以不用关心这个 crate ,因为标准库内已经已经实现了一套堆管理算法,并将 ``alloc`` 的内容包含在 -``std`` 名字空间之下让开发者可以直接使用。然而我们的内核是在禁用了标准库(即 ``no_std`` )的裸机平台,核心库 -``core`` 也并没有动态内存分配的功能,这个时候就要考虑利用 ``alloc`` 库了。 - -``alloc`` 库需要我们提供给它一个 ``全局的动态内存分配器`` ,它会利用该分配器来管理堆空间,从而使得它提供的堆数据结构可以正常 -工作。具体而言,我们的动态内存分配器需要实现它提供的 ``GlobalAlloc`` Trait,这个 Trait 有两个必须实现的抽象接口: - -.. code-block:: rust - - // alloc::alloc::GlobalAlloc - - pub unsafe fn alloc(&self, layout: Layout) -> *mut u8; - pub unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout); - -可以看到,它们类似 C 语言中的 ``malloc/free`` ,分别代表堆空间的分配和回收,也同样使用一个裸指针(也就是地址) -作为分配的返回值和回收的参数。两个接口中都有一个 ``alloc::alloc::Layout`` 类型的参数, -它指出了分配的需求,分为两部分,分别是所需空间的大小 ``size`` ,以及返回地址的对齐要求 ``align`` 。这个对齐要求 -必须是一个 2 的幂次,单位为字节数,限制返回的地址必须是 ``align`` 的倍数。 - -.. note:: - - **为何 C 语言 malloc 的时候不需要提供对齐需求?** - - 在 C 语言中,所有对齐要求的最大值是一个平台有关的很小的常数(比如8 bytes),消耗少量内存即可使得每一次分配都符合这个最大 - 的对齐要求。因此也就不需要区分不同分配的对齐要求了。而在 Rust 中,某些分配的对齐要求可能很大,就只能采用更 - 加复杂的方法。 - -之后,只需将我们的动态内存分配器类型实例化为一个全局变量,并使用 ``#[global_allocator]`` 语义项标记即可。由于该 -分配器的实现比较复杂,我们这里直接使用一个已有的伙伴分配器实现。首先添加 crate 依赖: - -.. code-block:: toml - - # os/Cargo.toml - - buddy_system_allocator = "0.6" - -接着,需要引入 ``alloc`` 库的依赖,由于它算是 Rust 内置的 crate ,我们并不是在 ``Cargo.toml`` 中进行引入,而是在 -``main.rs`` 中声明即可: - -.. code-block:: rust - - // os/src/main.rs - - extern crate alloc; - -然后,根据 ``alloc`` 留好的接口提供全局动态内存分配器: - -.. code-block:: rust +.. code-block:: c :linenos: - // os/src/mm/heap_allocator.rs + // os/kalloc.c + struct linklist { + struct linklist *next; + }; - use buddy_system_allocator::LockedHeap; - use crate::config::KERNEL_HEAP_SIZE; + struct { + struct linklist *freelist; + } kmem; - #[global_allocator] - static HEAP_ALLOCATOR: LockedHeap = LockedHeap::empty(); +注意,我们的管理仅仅在页这个粒度进行,所以所有的地址必须是 PAGE_SIZE 对齐的。 - static mut HEAP_SPACE: [u8; KERNEL_HEAP_SIZE] = [0; KERNEL_HEAP_SIZE]; +.. code-block:: c + :linenos: - pub fn init_heap() { - unsafe { - HEAP_ALLOCATOR - .lock() - .init(HEAP_SPACE.as_ptr() as usize, KERNEL_HEAP_SIZE); - } - } - -- 第 7 行,我们直接将 ``buddy_system_allocator`` 中提供的 ``LockedHeap`` 实例化成一个全局变量,并使用 - ``alloc`` 要求的 ``#[global_allocator]`` 语义项进行标记。注意 ``LockedHeap`` 已经实现了 ``GlobalAlloc`` - 要求的抽象接口了。 -- 第 11 行,在使用任何 ``alloc`` 中提供的堆数据结构之前,我们需要先调用 ``init_heap`` 函数来给我们的全局分配器 - 一块内存用于分配。在第 9 行可以看到,这块内存是一个 ``static mut`` 且被零初始化的字节数组,位于内核的 - ``.bss`` 段中。 ``LockedHeap`` 也是一个被互斥锁保护的类型,在对它任何进行任何操作之前都要先获取锁以避免其他 - 线程同时对它进行操作导致数据竞争。然后,调用 ``init`` 方法告知它能够用来分配的空间的起始地址和大小即可。 - -我们还需要处理动态内存分配失败的情形,在这种情况下我们直接 panic : - -.. code-block:: rust - - // os/src/main.rs - - #![feature(alloc_error_handler)] - - // os/src/mm/heap_allocator.rs - - #[alloc_error_handler] - pub fn handle_alloc_error(layout: core::alloc::Layout) -> ! { - panic!("Heap allocation error, layout = {:?}", layout); + // os/kalloc.c: 页面分配 + void * + kalloc(void) + { + struct linklist *l; + l = kmem.freelist; + kmem.freelist = l->next; + return (void*)l; } -最后,让我们尝试一下动态内存分配吧! + // os/kalloc.c: 页面释放 + void * + kfree(void *pa) + { + struct linklist *l; + l = (struct linklist*)pa; + l->next = kmem.freelist; + kmem.freelist = l; + } -.. chyyuu 如何尝试??? +那么我们的内核有那些空闲内存需要管理呢?事实上,qemu 已经规定了内核需要管理的内存范围,可以参考这里,具体来说,需要软件管理的内存为 [0x80000000, 0x88000000),其中,rustsbi 使用了 [0x80000000, 0x80200000) 的范围,其余都是内核使用。来看看 kmem 的初始化 -.. code-block:: rust +.. code-block:: c :linenos: - // os/src/mm/heap_allocator.rs + // kernel/kalloc.c - #[allow(unused)] - pub fn heap_test() { - use alloc::boxed::Box; - use alloc::vec::Vec; - extern "C" { - fn sbss(); - fn ebss(); - } - let bss_range = sbss as usize..ebss as usize; - let a = Box::new(5); - assert_eq!(*a, 5); - assert!(bss_range.contains(&(a.as_ref() as *const _ as usize))); - drop(a); - let mut v: Vec = Vec::new(); - for i in 0..500 { - v.push(i); - } - for i in 0..500 { - assert_eq!(v[i], i); - } - assert!(bss_range.contains(&(v.as_ptr() as usize))); - drop(v); - println!("heap_test passed!"); - } + // ekernel 为链接脚本定义的内核代码结束地址,PHYSTOP = 0x88000000 + void + kinit() + { + freerange(ekernel, (void*)PHYSTOP); + } + + // kfree [pa_start, pa_end) + void + freerange(void *pa_start, void *pa_end) + { + char *p; + p = (char*)PGROUNDUP((uint64)pa_start); + for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) + kfree(p); + } + +我们在main函数中会执行kinit,它会初始化从ekernel到PHYSTOP的所有物理地址作为空闲的物理地址。freerange中调用的kfree函数以页为单位向对应内存中填入垃圾数据(全1),并把初始化好的一个页作为新的空闲listnode插入到链表首部。 + +注意,C语言之中要求进行内存回收,也就是malloc以及free要成对出现。但是我们的OS中不强制要求这一点,也就是如果测例本身未在申请动态内存后显式地调用free来释放内存,OS无需帮助它释放内存。 -其中分别使用智能指针 ``Box`` 和向量 ``Vec`` 在堆上分配数据并管理它们,通过 ``as_ref`` 和 ``as_ptr`` -方法可以分别看到它们指向的数据的位置,能够确认它们的确在 ``.bss`` 段的堆上。 -.. note:: - 本节部分内容参考自 `BlogOS 的相关章节 `_ 。 \ No newline at end of file diff --git a/source/chapter4/2address-space.rst b/source/chapter4/2address-space.rst index 01f7fd1..0a32be0 100644 --- a/source/chapter4/2address-space.rst +++ b/source/chapter4/2address-space.rst @@ -207,6 +207,37 @@ system,其具体表现取决于实际的应用需求,各有优劣。 由于分页内存管理既简单又灵活,它逐渐成为了主流,RISC-V 架构也使用了这种策略。后面我们会基于这种机制,自己来动手从物理内存抽象出应用的地址空间来。 +C的内存布局 +---------------------------------------------- + +在memory_layout.h之中我们展示了内存的布局。 + +.. code-block:: c + :linenos: + + // the kernel expects there to be RAM + // for use by the kernel and user pages + // from physical address 0x80000000 to PHYSTOP. + #define KERNBASE 0x80200000L + #define PHYSTOP (0x80000000 + 128*1024*1024) // 128M + + // map the trampoline page to the highest address, + // in both user and kernel space. + + // one beyond the highest possible virtual address. + // MAXVA is actually one bit less than the max allowed by + // Sv39, to avoid having to sign-extend virtual addresses + // that have the high bit set. + #define MAXVA (1L << (9 + 9 + 9 + 12 - 1)) + + #define USER_TOP (MAXVA) + #define TRAMPOLINE (USER_TOP - PGSIZE) + #define TRAPFRAME (TRAMPOLINE - PGSIZE) + + #define USTACK_BOTTOM (0x0) + +其中前两项在上一节已经介绍过了。下面的MAXVA其实就是SV39中最大的虚拟地址(39位全为1)。va不可能大于MAXVA。大家可以看到,我们指定TRAMPOLINE和TRAPFRAME在va的最高位,这是为什么呢?大家可以自行思考一下,我们将在下面解释。 + .. note:: 本节部分内容参考自 `Operating Systems: Three Easy Pieces `_ diff --git a/source/chapter4/3sv39-implementation-1.rst b/source/chapter4/3sv39-implementation-1.rst index 9062168..bab129b 100644 --- a/source/chapter4/3sv39-implementation-1.rst +++ b/source/chapter4/3sv39-implementation-1.rst @@ -1,12 +1,11 @@ -实现 SV39 多级页表机制(上) +SV39多级页表机制:内容介绍 ======================================================== 本节导读 -------------------------- - -在上一小节中我们已经简单介绍了分页的内存管理策略,现在我们尝试在 RV64 架构提供的 SV39 分页机制的基础上完成内核中的软件对应实现。由于内容过多,我们将分成两个小节进行讲解。本节主要讲解在RV64架构下的虚拟地址与物理地址的访问属性(可读,可写,可执行等),组成结构(页号,帧号,偏移量等),访问的空间范围等;以及如何用Rust语言来设计有类型的页表项。 +在上一小节中我们已经简单介绍了分页的内存管理策略,现在我们尝试在 RV64 架构提供的 SV39 分页机制的基础上完成内核中的软件对应实现。由于内容过多,我们将分成两个小节进行讲解。本节主要讲解在RV64架构下的虚拟地址与物理地址的访问属性(可读,可写,可执行等),组成结构(页号,帧号,偏移量等),访问的空间范围等;以及我们在OS中如何进行页表的处理。 虚拟地址和物理地址 @@ -72,233 +71,8 @@ 地址的位宽毋庸置疑就是 64 位,我们要清楚可用的只有最高和最低这两部分,尽管它们已经巨大的超乎想象了;而本节中 我们专注于介绍 MMU 的机制,强调 MMU 看到的真正用来地址转换的虚拟地址只有 39 位。 - - -地址相关的数据结构抽象与类型定义 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -正如本章第一小节所说,在分页内存管理中,地址转换的核心任务在于如何维护虚拟页号到物理页号的映射——也就是页表。不过在具体 -实现它之前,我们先将地址和页号的概念抽象为 Rust 中的类型,借助 Rust 的类型安全特性来确保它们被正确实现。 - -首先是这些类型的定义: - -.. code-block:: rust - - // os/src/mm/address.rs - - #[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] - pub struct PhysAddr(pub usize); - - #[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] - pub struct VirtAddr(pub usize); - - #[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] - pub struct PhysPageNum(pub usize); - - #[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] - pub struct VirtPageNum(pub usize); - -.. _term-type-convertion: - -上面分别给出了物理地址、虚拟地址、物理页号、虚拟页号的 Rust 类型声明,它们都是 Rust 的元组式结构体,可以看成 -usize 的一种简单包装。我们刻意将它们各自抽象出来而不是都使用 usize 保存,就是为了在 Rust 编译器的帮助下进行 -多种方便且安全的 **类型转换** (Type Convertion) 。 - -首先,这些类型本身可以和 usize 之间互相转换,以物理地址 ``PhysAddr`` 为例,我们需要: - -.. code-block:: rust - - // os/src/mm/address.rs - - impl From for PhysAddr { - fn from(v: usize) -> Self { Self(v) } - } - - impl From for usize { - fn from(v: PhysAddr) -> Self { v.0 } - } - -前者允许我们从一个 ``usize`` 来生成 ``PhysAddr`` ,即 ``PhysAddr::from(_: usize)`` 将得到一个 ``PhysAddr`` -;反之亦然。其实由于我们在声明结构体的时候将字段公开了出来,从物理地址变量 ``pa`` 得到它的 usize 表示的更简便方法 -是直接 ``pa.0`` 。 - -.. note:: - - **Rust 语法卡片:类型转换之 From 和 Into** - - 一般而言,当我们为类型 ``U`` 实现了 ``From`` Trait 之后,可以使用 ``U::from(_: T)`` 来从一个 ``T`` - 类型的实例来构造一个 ``U`` 类型的实例;而当我们为类型 ``U`` 实现了 ``Into`` Trait 之后,对于一个 ``U`` - 类型的实例 ``u`` ,可以使用 ``u.into()`` 来将其转化为一个类型为 ``T`` 的实例。 - - 当我们为 ``U`` 实现了 ``From`` 之后,Rust 会自动为 ``T`` 实现 ``Into`` Trait, - 因为它们两个本来就是在做相同的事情。因此我们只需相互实现 ``From`` 就可以相互 ``From/Into`` 了。 - - 需要注意的是,当我们使用 ``From`` Trait 的 ``from`` 方法来构造一个转换后类型的实例的时候,``from`` 的参数 - 已经指明了转换前的类型,因而 Rust 编译器知道该使用哪个实现;而使用 ``Into`` Trait 的 ``into`` 方法来将当前 - 类型转化为另一种类型的时候,它并没有参数,因而函数签名中并没有指出要转化为哪一个类型,则我们必须在其他地方 *显式* - 指出目标类型。比如,当我们要将 ``u.into()`` 绑定到一个新变量 ``t`` 的时候,必须通过 ``let t: T`` 显式声明 - ``t`` 的类型;又或是将 ``u.into()`` 的结果作为参数传给某一个函数,那么这个函数的函数签名中一定指出了传入位置 - 的参数的类型,Rust 编译器也就明确知道转换的类型。 - - 请注意,解引用 ``Deref`` Trait 是 Rust 编译器唯一允许的一种隐式类型转换,而对于其他的类型转换,我们必须手动 - 调用类型转化方法或者是显式给出转换前后的类型。这体现了 Rust 的类型安全特性,在 C/C++ 中并不是如此,比如两个 - 不同的整数/浮点数类型进行二元运算的时候,编译器经常要先进行隐式类型转换使两个操作数类型相同,而后再进行运算,导致 - 了很多数值溢出或精度损失问题。Rust 不会进行这种隐式类型转换,它会在编译期直接报错,提示两个操作数类型不匹配。 - -其次,地址和页号之间可以相互转换。我们这里仍以物理地址和物理页号之间的转换为例: - -.. code-block:: rust - :linenos: - - // os/src/mm/address.rs - - impl PhysAddr { - pub fn page_offset(&self) -> usize { self.0 & (PAGE_SIZE - 1) } - } - - impl From for PhysPageNum { - fn from(v: PhysAddr) -> Self { - assert_eq!(v.page_offset(), 0); - v.floor() - } - } - - impl From for PhysAddr { - fn from(v: PhysPageNum) -> Self { Self(v.0 << PAGE_SIZE_BITS) } - } - -其中 ``PAGE_SIZE`` 为 :math:`4096` , ``PAGE_SIZE_BITS`` 为 :math:`12` ,它们均定义在 ``config`` 子模块 -中,分别表示每个页面的大小和页内偏移的位宽。从物理页号到物理地址的转换只需左移 :math:`12` 位即可,但是物理地址需要 -保证它与页面大小对齐才能通过右移转换为物理页号。 - -对于不对齐的情况,物理地址不能通过 ``From/Into`` 转换为物理页号,而是需要通过它自己的 ``floor`` 或 ``ceil`` 方法来 -进行下取整或上取整的转换。 - -.. code-block:: rust - - // os/src/mm/address.rs - - impl PhysAddr { - pub fn floor(&self) -> PhysPageNum { PhysPageNum(self.0 / PAGE_SIZE) } - pub fn ceil(&self) -> PhysPageNum { PhysPageNum((self.0 + PAGE_SIZE - 1) / PAGE_SIZE) } - } - -我们暂时先介绍这两种最简单的类型转换。 - -页表项的数据结构抽象与类型定义 ------------------------------------------ - -第一小节中我们提到,在页表中以虚拟页号作为索引不仅能够查到物理页号,还能查到一组保护位,它控制了应用对地址空间每个 -虚拟页面的访问权限。但实际上还有更多的标志位,物理页号和全部的标志位以某种固定的格式保存在一个结构体中,它被称为 -**页表项** (PTE, Page Table Entry) ,是利用虚拟页号在页表中查到的结果。 - -.. image:: sv39-pte.png - -上图为 SV39 分页模式下的页表项,其中 :math:`[53:10]` 这 :math:`44` 位是物理页号,最低的 :math:`8` 位 -:math:`[7:0]` 则是标志位,它们的含义如下(请注意,为方便说明,下文我们用 *页表项的对应虚拟页面* 来表示索引到 -一个页表项的虚拟页号对应的虚拟页面): - -- 仅当 V(Valid) 位为 1 时,页表项才是合法的; -- R/W/X 分别控制索引到这个页表项的对应虚拟页面是否允许读/写/取指; -- U 控制索引到这个页表项的对应虚拟页面是否在 CPU 处于 U 特权级的情况下是否被允许访问; -- G 我们暂且不理会; -- A(Accessed) 记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被访问过; -- D(Dirty) 则记录自从页表项上的这一位被清零之后,页表项的对应虚拟页表是否被修改过。 - -让我们先来实现页表项中的标志位 ``PTEFlags`` : - -.. code-block:: rust - - // os/src/main.rs - - #[macro_use] - extern crate bitflags; - - // os/src/mm/page_table.rs - - use bitflags::*; - - bitflags! { - pub struct PTEFlags: u8 { - const V = 1 << 0; - const R = 1 << 1; - const W = 1 << 2; - const X = 1 << 3; - const U = 1 << 4; - const G = 1 << 5; - const A = 1 << 6; - const D = 1 << 7; - } - } - -`bitflags `_ 是一个 Rust 中常用来比特标志位的 crate 。它提供了 -一个 ``bitflags!`` 宏,如上面的代码段所展示的那样,可以将一个 ``u8`` 封装成一个标志位的集合类型,支持一些常见的集合 -运算。它的一些使用细节这里不展开,请读者自行参考它的官方文档。注意,在使用之前我们需要引入该 crate 的依赖: - -.. code-block:: toml - - # os/Cargo.toml - - [dependencies] - bitflags = "1.2.1" - -接下来我们实现页表项 ``PageTableEntry`` : - -.. code-block:: rust - :linenos: - - // os/src/mm/page_table.rs - - #[derive(Copy, Clone)] - #[repr(C)] - pub struct PageTableEntry { - pub bits: usize, - } - - impl PageTableEntry { - pub fn new(ppn: PhysPageNum, flags: PTEFlags) -> Self { - PageTableEntry { - bits: ppn.0 << 10 | flags.bits as usize, - } - } - pub fn empty() -> Self { - PageTableEntry { - bits: 0, - } - } - pub fn ppn(&self) -> PhysPageNum { - (self.bits >> 10 & ((1usize << 44) - 1)).into() - } - pub fn flags(&self) -> PTEFlags { - PTEFlags::from_bits(self.bits as u8).unwrap() - } - } - -- 第 3 行我们让编译器自动为 ``PageTableEntry`` 实现 ``Copy/Clone`` Trait,来让这个类型以值语义赋值/传参的时候 - 不会发生所有权转移,而是拷贝一份新的副本。从这一点来说 ``PageTableEntry`` 就和 usize 一样,因为它也只是后者的 - 一层简单包装,解释了 usize 各个比特段的含义。 -- 第 10 行使得我们可以从一个物理页号 ``PhysPageNum`` 和一个页表项标志位 ``PTEFlags`` 生成一个页表项 - ``PageTableEntry`` 实例;而第 20 行和第 23 行则分别可以从一个页表项将它们两个取出。 -- 第 15 行中,我们也可以通过 ``empty`` 方法生成一个全零的页表项,注意这隐含着该页表项的 V 标志位为 0 , - 因此它是不合法的。 - -后面我们还为 ``PageTableEntry`` 实现了一些辅助函数(Helper Function),可以快速判断一个页表项的 V/R/W/X 标志位是否为 1,以 V -标志位的判断为例: - -.. code-block:: rust - - // os/src/mm/page_table.rs - - impl PageTableEntry { - pub fn is_valid(&self) -> bool { - (self.flags() & PTEFlags::V) != PTEFlags::empty() - } - } - -这里相当于判断两个集合的交集是否为空集,部分说明了 ``bitflags`` crate 的使用方法。 - 多级页表原理 -------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^ 页表的一种最简单的实现是线性表,也就是按照地址从低到高、输入的虚拟页号从 :math:`0` 开始递增的顺序依次在内存中 (我们之前提到过页表的容量过大无法保存在 CPU 中)放置每个虚拟页号对应的页表项。由于每个页表项的大小是 :math:`8` @@ -453,3 +227,4 @@ usize 的一种简单包装。我们刻意将它们各自抽象出来而不是 .. image:: sv39-full.png :height: 600 :align: center + \ No newline at end of file diff --git a/source/chapter4/4sv39-implementation-2.rst b/source/chapter4/4sv39-implementation-2.rst index b0f44c9..c50fa5a 100644 --- a/source/chapter4/4sv39-implementation-2.rst +++ b/source/chapter4/4sv39-implementation-2.rst @@ -1,4 +1,4 @@ -实现 SV39 多级页表机制(下) +SV39多级页表机制:OS实现 ======================================================== @@ -6,613 +6,228 @@ -------------------------- -本节我们继续来实现 SV39 多级页表机制。这还需进一步了解和管理当前已经使用是或空闲的物理页帧,这样操作系统才能给应用程序动态分配或回收物理地址空间。有了有效的物理内存空间的管理,操作系统就能够在物理内存空间中建立多级页表(页表占用物理内存),为应用程序和操作系统自身建立虚实地址映射关系,从而实现虚拟内存空间,即给应用“看到”的地址空间。 +本节我们将讲述OS是如何实现页表的支持的。在深入本章的内容之前,大家一定要牢记,完成虚拟地址查询页表或TLB转换成物理地址的过程是由硬件,也就是CPU来完成的。我们在框架之中实现的地址转换函数是为了我们在某些函数中自己计算虚拟地址到物理地址转换使用的。OS负责对页表进行建立、更改等处理,真正在程序运行时,CPU对指令、数据虚拟地址会十分机械地按照下面讲述的方法使用os创建好的页表进行地址转换。 -物理页帧管理 +地址相关的数据结构抽象 ----------------------------------- -从前面的介绍可以看出物理页帧的重要性:它既可以用来实际存放应用的数据,也能够用来存储某个应用多级页表中的一个节点。 -目前的物理内存上已经有一部分用于放置内核的代码和数据,我们需要将剩下可用的部分以单个物理页帧为单位管理起来, -当需要存放应用数据或是应用的多级页表需要一个新节点的时候分配一个物理页帧,并在应用出错或退出的时候回收它占有 -的所有物理页帧。 +正如本章第一小节所说,在分页内存管理中,地址转换的核心任务在于如何维护虚拟页号到物理页号的映射——也就是页表。我们对页表的操作集中在vm.c文件之中。 -可用物理页的分配与回收 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +首先是为了实现页表,我们新增的类型的定义: -首先,我们需要知道物理内存的哪一部分是可用的。在 ``os/src/linker.ld`` 中,我们用符号 ``ekernel`` 指明了 -内核数据的终止物理地址,在它之后的物理内存都是可用的。而在 ``config`` 子模块中: - -.. code-block:: rust - - // os/src/config.rs - - pub const MEMORY_END: usize = 0x80800000; - -我们硬编码整块物理内存的终止物理地址为 ``0x80800000`` 。 而 :ref:`之前 ` 提到过物理内存的 -起始物理地址为 ``0x80000000`` ,这意味着我们将可用内存大小设置为 :math:`8\text{MiB}` 。 -实际上在 Qemu 模拟器上可以通过设置使用更大的物理内存,但这里我们希望 -它和真实硬件 K210 的配置保持一致,因此设置为仅使用 :math:`8\text{MiB}` 。我们用一个左闭右开的物理页号区间来表示 -可用的物理内存,则: - -- 区间的左端点应该是 ``ekernel`` 的物理地址以上取整方式转化成的物理页号; -- 区间的右端点应该是 ``MEMORY_END`` 以下取整方式转化成的物理页号。 - -这个区间将被传给我们后面实现的物理页帧管理器用于初始化。 - -我们声明一个 ``FrameAllocator`` Trait 来描述一个物理页帧管理器需要提供哪些功能: - -.. code-block:: rust - - // os/src/mm/frame_allocator.rs - - trait FrameAllocator { - fn new() -> Self; - fn alloc(&mut self) -> Option; - fn dealloc(&mut self, ppn: PhysPageNum); - } - -即创建一个实例,还有以物理页号为单位进行物理页帧的分配和回收。 - -我们实现一种最简单的栈式物理页帧管理策略 ``StackFrameAllocator`` : - -.. code-block:: rust - - // os/src/mm/frame_allocator.rs - - pub struct StackFrameAllocator { - current: usize, - end: usize, - recycled: Vec, - } - -其中各字段的含义是:物理页号区间 :math:`[\text{current},\text{end})` 此前均 *从未* 被分配出去过,而向量 -``recycled`` 以后入先出的方式保存了被回收的物理页号(注意我们已经自然的将内核堆用起来了)。 - -初始化非常简单。在通过 ``FrameAllocator`` 的 ``new`` 方法创建实例的时候,只需将区间两端均设为 :math:`0` , -然后创建一个新的向量;而在它真正被使用起来之前,需要调用 ``init`` 方法将自身的 :math:`[\text{current},\text{end})` -初始化为可用物理页号区间: - -.. code-block:: rust - - // os/src/mm/frame_allocator.rs - - impl FrameAllocator for StackFrameAllocator { - fn new() -> Self { - Self { - current: 0, - end: 0, - recycled: Vec::new(), - } - } - } - - impl StackFrameAllocator { - pub fn init(&mut self, l: PhysPageNum, r: PhysPageNum) { - self.current = l.0; - self.end = r.0; - } - } - -接下来我们来看核心的物理页帧分配和回收如何实现: - -.. code-block:: rust - - // os/src/mm/frame_allocator.rs - - impl FrameAllocator for StackFrameAllocator { - fn alloc(&mut self) -> Option { - if let Some(ppn) = self.recycled.pop() { - Some(ppn.into()) - } else { - if self.current == self.end { - None - } else { - self.current += 1; - Some((self.current - 1).into()) - } - } - } - fn dealloc(&mut self, ppn: PhysPageNum) { - let ppn = ppn.0; - // validity check - if ppn >= self.current || self.recycled - .iter() - .find(|&v| {*v == ppn}) - .is_some() { - panic!("Frame ppn={:#x} has not been allocated!", ppn); - } - // recycle - self.recycled.push(ppn); - } - } - -- 在分配 ``alloc`` 的时候,首先会检查栈 ``recycled`` 内有没有之前回收的物理页号,如果有的话直接弹出栈顶并返回; - 否则的话我们只能从之前从未分配过的物理页号区间 :math:`[\text{current},\text{end})` 上进行分配,我们分配它的 - 左端点 ``current`` ,同时将管理器内部维护的 ``current`` 加一代表 ``current`` 此前已经被分配过了。在即将返回 - 的时候,我们使用 ``into`` 方法将 usize 转换成了物理页号 ``PhysPageNum`` 。 - - 注意极端情况下可能出现内存耗尽分配失败的情况:即 ``recycled`` 为空且 :math:`\text{current}==\text{end}` 。 - 为了涵盖这种情况, ``alloc`` 的返回值被 ``Option`` 包裹,我们返回 ``None`` 即可。 -- 在回收 ``dealloc`` 的时候,我们需要检查回收页面的合法性,然后将其压入 ``recycled`` 栈中。回收页面合法有两个 - 条件: - - - 该页面之前一定被分配出去过,因此它的物理页号一定 :math:`<\text{current}` ; - - 该页面没有正处在回收状态,即它的物理页号不能在栈 ``recycled`` 中找到。 - - 我们通过 ``recycled.iter()`` 获取栈上内容的迭代器,然后通过迭代器的 ``find`` 方法试图 - 寻找一个与输入物理页号相同的元素。其返回值是一个 ``Option`` ,如果找到了就会是一个 ``Option::Some`` , - 这种情况说明我们内核其他部分实现有误,直接报错退出。 - -下面我们来创建 ``StackFrameAllocator`` 的全局实例 ``FRAME_ALLOCATOR`` : - -.. code-block:: rust - - // os/src/mm/frame_allocator.rs - - use spin::Mutex; - - type FrameAllocatorImpl = StackFrameAllocator; - - lazy_static! { - pub static ref FRAME_ALLOCATOR: Mutex = - Mutex::new(FrameAllocatorImpl::new()); - } - -这里我们使用互斥锁 ``Mutex`` 来包裹栈式物理页帧分配器。每次对该分配器进行操作之前,我们都需要先通过 -``FRAME_ALLOCATOR.lock()`` 拿到分配器的可变借用。注意 ``alloc`` 中并没有提供 ``Mutex`` ,它 -来自于一个我们在 ``no_std`` 的裸机环境下经常使用的名为 ``spin`` 的 crate ,它仅依赖 Rust 核心库 -``core`` 提供一些可跨平台使用的同步原语,如互斥锁 ``Mutex`` 和读写锁 ``RwLock`` 等。 - -.. note:: - - **Rust 语法卡片:在单核环境下使用 Mutex 的原因** - - 在编写一个多线程的应用时,加锁的目的是为了避免数据竞争,使得里层的共享数据结构同一时间只有一个线程 - 在对它进行访问。然而,目前我们的内核运行在单 CPU 上,且 Trap 进入内核之后并没有手动打开中断,这也就 - 使得同一时间最多只有一条 Trap 执行流并发访问内核的各数据结构,此时应该是并没有任何数据竞争风险的。那么 - 加锁的原因其实有两点: - - 1. 在不触及 ``unsafe`` 的情况下实现 ``static mut`` 语义。如果读者还有印象, - :ref:`前面章节 ` 我们使用 ``RefCell`` 提供了内部可变性去掉了 - 声明中的 ``mut`` ,然而麻烦的在于 ``static`` ,在 Rust 中一个类型想被实例化为一个全局变量,则 - 该类型必须先告知编译器自己某种意义上是线程安全的,这个过程本身是 ``unsafe`` 的。 - - 因此我们直接使用 ``Mutex`` ,它既通过 ``lock`` 方法提供了内部可变性,又已经在模块内部告知了 - 编译器它的线程安全性。这样 ``unsafe`` 就被隐藏在了 ``spin`` crate 之内,我们无需关心。这种风格 - 是 Rust 所推荐的。 - 2. 方便后续拓展到真正存在数据竞争风险的多核环境下运行。 - - 这里引入了一些新概念,比如什么是线程,又如何定义线程安全?读者可以先不必深究,暂时有一个初步的概念即可。 - -我们需要添加该 crate 的依赖: - -.. code-block:: toml - - # os/Cargo.toml - - [dependencies] - spin = "0.7.0" - -在正式分配物理页帧之前,我们需要将物理页帧全局管理器 ``FRAME_ALLOCATOR`` 初始化: - -.. code-block:: rust - - // os/src/mm/frame_allocator.rs - - pub fn init_frame_allocator() { - extern "C" { - fn ekernel(); - } - FRAME_ALLOCATOR - .lock() - .init(PhysAddr::from(ekernel as usize).ceil(), PhysAddr::from(MEMORY_END).floor()); - } - -这里我们调用物理地址 ``PhysAddr`` 的 ``floor/ceil`` 方法分别下/上取整获得可用的物理页号区间。 - - -分配/回收物理页帧的接口 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -然后是真正公开给其他子模块调用的分配/回收物理页帧的接口: - -.. code-block:: rust - - // os/src/mm/frame_allocator.rs - - pub fn frame_alloc() -> Option { - FRAME_ALLOCATOR - .lock() - .alloc() - .map(|ppn| FrameTracker::new(ppn)) - } - - fn frame_dealloc(ppn: PhysPageNum) { - FRAME_ALLOCATOR - .lock() - .dealloc(ppn); - } - -可以发现, ``frame_alloc`` 的返回值类型并不是 ``FrameAllocator`` 要求的物理页号 ``PhysPageNum`` ,而是将其 -进一步包装为一个 ``FrameTracker`` 。这里借用了 RAII 的思想,将一个物理页帧的生命周期绑定到一个 ``FrameTracker`` -变量上,当一个 ``FrameTracker`` 被创建的时候,我们需要从 ``FRAME_ALLOCATOR`` 中分配一个物理页帧: - -.. code-block:: rust - - // os/src/mm/frame_allocator.rs - - pub struct FrameTracker { - pub ppn: PhysPageNum, - } - - impl FrameTracker { - pub fn new(ppn: PhysPageNum) -> Self { - // page cleaning - let bytes_array = ppn.get_bytes_array(); - for i in bytes_array { - *i = 0; - } - Self { ppn } - } - } - -我们将分配来的物理页帧的物理页号作为参数传给 ``FrameTracker`` 的 ``new`` 方法来创建一个 ``FrameTracker`` -实例。由于这个物理页帧之前可能被分配过并用做其他用途,我们在这里直接将这个物理页帧上的所有字节清零。这一过程并不 -那么显然,我们后面再详细介绍。 - -当一个 ``FrameTracker`` 生命周期结束被编译器回收的时候,我们需要将它控制的物理页帧回收掉 ``FRAME_ALLOCATOR`` 中: - -.. code-block:: rust - - // os/src/mm/frame_allocator.rs - - impl Drop for FrameTracker { - fn drop(&mut self) { - frame_dealloc(self.ppn); - } - } - -这里我们只需为 ``FrameTracker`` 实现 ``Drop`` Trait 即可。当一个 ``FrameTracker`` 实例被回收的时候,它的 -``drop`` 方法会自动被编译器调用,通过之前实现的 ``frame_dealloc`` 我们就将它控制的物理页帧回收以供后续使用了。 - -.. note:: - - **Rust 语法卡片:Drop Trait** - - Rust 中的 ``Drop`` Trait 是它的 RAII 内存管理风格可以被有效实践的关键。之前介绍的多种在堆上分配的 Rust - 数据结构便都是通过实现 ``Drop`` Trait 来进行被绑定资源的自动回收的。例如: - - - ``Box`` 的 ``drop`` 方法会回收它控制的分配在堆上的那个变量; - - ``Rc`` 的 ``drop`` 方法会减少分配在堆上的那个引用计数,一旦变为零则分配在堆上的那个被计数的变量自身 - 也会被回收; - - ``Mutex`` 的 ``lock`` 方法会获取互斥锁并返回一个 ``MutexGuard<'a, T>`` ,它可以被当做一个 ``&mut T`` - 来使用;而 ``MutexGuard<'a, T>`` 的 ``drop`` 方法会将锁释放,从而允许其他线程获取锁并开始访问里层的 - 数据结构。锁的实现原理我们先不介绍。 - - ``FrameTracker`` 的设计也是基于同样的思想,有了它之后我们就不必手动回收物理页帧了,这在编译期就解决了很多 - 潜在的问题。 - -最后做一个小结:从其他模块的视角看来,物理页帧分配的接口是调用 ``frame_alloc`` 函数得到一个 ``FrameTracker`` -(如果物理内存还有剩余),它就代表了一个物理页帧,当它的生命周期结束之后它所控制的物理页帧将被自动回收。下面是 -一段演示该接口使用方法的测试程序: - -.. code-block:: rust - :linenos: - :emphasize-lines: 9 - - // os/src/mm/frame_allocator.rs - - #[allow(unused)] - pub fn frame_allocator_test() { - let mut v: Vec = Vec::new(); - for i in 0..5 { - let frame = frame_alloc().unwrap(); - println!("{:?}", frame); - v.push(frame); - } - v.clear(); - for i in 0..5 { - let frame = frame_alloc().unwrap(); - println!("{:?}", frame); - v.push(frame); - } - drop(v); - println!("frame_allocator_test passed!"); - } - -如果我们将第 9 行删去,则第一轮分配的 5 个物理页帧都是分配之后在循环末尾就被立即回收,因为循环作用域的临时变量 -``frame`` 的生命周期在那时结束了。然而,如果我们将它们 move 到一个向量中,它们的生命周期便被延长了——直到第 11 行 -向量被清空的时候,这些 ``FrameTracker`` 的生命周期才结束,它们控制的 5 个物理页帧才被回收。这种思想我们立即 -就会用到。 - -多级页表实现 ------------------------------------ - - -页表基本数据结构与访问接口 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -我们知道,SV39 多级页表是以节点为单位进行管理的。每个节点恰好存储在一个物理页帧中,它的位置可以用一个物理页号来 -表示。 - -.. code-block:: rust - :linenos: - - // os/src/mm/page_table.rs - - pub struct PageTable { - root_ppn: PhysPageNum, - frames: Vec, - } - - impl PageTable { - pub fn new() -> Self { - let frame = frame_alloc().unwrap(); - PageTable { - root_ppn: frame.ppn, - frames: vec![frame], - } - } - } - -每个应用的地址空间都对应一个不同的多级页表,这也就意味这不同页表的起始地址(即页表根节点的地址)是不一样的。因此 ``PageTable`` 要保存它根节点的物理页号 ``root_ppn`` 作为页表唯一的区分标志。此外, -向量 ``frames`` 以 ``FrameTracker`` 的形式保存了页表所有的节点(包括根节点)所在的物理页帧。这与物理页帧管理模块 -的测试程序是一个思路,即将这些 ``FrameTracker`` 的生命周期进一步绑定到 ``PageTable`` 下面。当 ``PageTable`` -生命周期结束后,向量 ``frames`` 里面的那些 ``FrameTracker`` 也会被回收,也就意味着存放多级页表节点的那些物理页帧 -被回收了。 - -当我们通过 ``new`` 方法新建一个 ``PageTable`` 的时候,它只需有一个根节点。为此我们需要分配一个物理页帧 -``FrameTracker`` 并挂在向量 ``frames`` 下,然后更新根节点的物理页号 ``root_ppn`` 。 - -多级页表并不是被创建出来之后就不再变化的,为了 MMU 能够通过地址转换正确找到应用地址空间中的数据实际被内核放在内存中 -位置,操作系统需要动态维护一个虚拟页号到页表项的映射,支持插入/删除键值对,其方法签名如下: - -.. code-block:: rust - - // os/src/mm/page_table.rs - - impl PageTable { - pub fn map(&mut self, vpn: VirtPageNum, ppn: PhysPageNum, flags: PTEFlags); - pub fn unmap(&mut self, vpn: VirtPageNum); - } - -- 我们通过 ``map`` 方法来在多级页表中插入一个键值对,注意这里我们将物理页号 ``ppn`` 和页表项标志位 ``flags`` 作为 - 不同的参数传入而不是整合为一个页表项; -- 相对的,我们通过 ``unmap`` 方法来删除一个键值对,在调用时仅需给出作为索引的虚拟页号即可。 - -.. _modify-page-table: - -在这些操作的过程中我们自然需要访问或修改多级页表节点的内容。每个节点都被保存在一个物理页帧中,在多级页表的架构中我们是以 -一个节点被存放在的物理页帧的物理页号作为指针指向该节点,这意味着,对于每个节点来说,一旦我们知道了指向它的物理页号,我们 -就需要能够修改这个节点的内容。前面我们在使用 ``frame_alloc`` 分配一个物理页帧之后便立即将它上面的数据清零其实也是一样 -的需求。总结一下也就是说,至少在操作某个多级页表或是管理物理页帧的时候,我们要能够自由的读写与一个给定的物理页号对应的 -物理页帧上的数据。 - -在尚未启用分页模式之前,内核和应用的代码都可以通过物理地址直接访问内存。而在打开分页模式之后,分别运行在 S 特权级 -和 U 特权级的内核和应用的访存行为都会受到影响,它们的访存地址会被视为一个当前地址空间( ``satp`` CSR 给出当前 -多级页表根节点的物理页号)中的一个虚拟地址,需要 MMU -查相应的多级页表完成地址转换变为物理地址,也就是地址空间中虚拟地址指向的数据真正被内核放在的物理内存中的位置,然后 -才能访问相应的数据。此时,如果想要访问一个特定的物理地址 ``pa`` 所指向的内存上的数据,就需要对应 **构造** 一个虚拟地址 -``va`` ,使得当前地址空间的页表存在映射 :math:`\text{va}\rightarrow\text{pa}` ,且页表项中的保护位允许这种 -访问方式。于是,在代码中我们只需访问地址 ``va`` ,它便会被 MMU 通过地址转换变成 ``pa`` ,这样我们就做到了在启用 -分页模式的情况下也能从某种意义上直接访问内存。 - -.. _term-identical-mapping: - -这就需要我们提前扩充多级页表维护的映射,使得对于每一个对应于某一特定物理页帧的物理页号 ``ppn`` ,均存在一个虚拟页号 -``vpn`` 能够映射到它,而且要能够较为简单的针对一个 ``ppn`` 找到某一个能映射到它的 ``vpn`` 。这里我们采用一种最 -简单的 **恒等映射** (Identical Mapping) ,也就是说对于物理内存上的每个物理页帧,我们都在多级页表中用一个与其 -物理页号相等的虚拟页号映射到它。当我们想针对物理页号构造一个能映射到它的虚拟页号的时候,也只需使用一个和该物理页号 -相等的虚拟页号即可。 - -.. _term-recursive-mapping: - -.. note:: - - **其他的映射方式** - - 为了达到这一目的还存在其他不同的映射方式,例如比较著名的 **页表自映射** (Recursive Mapping) 等。有兴趣的同学 - 可以进一步参考 `BlogOS 中的相关介绍 `_ 。 - -这里需要说明的是,在下一节中我们可以看到,应用和内核的地址空间是隔离的。而直接访问物理页帧的操作只会在内核中进行, -应用无法看到物理页帧管理器和多级页表等内核数据结构。因此,上述的恒等映射只需被附加到内核地址空间即可。 - - -内核中访问物理页帧的方法 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. _access-frame-in-kernel-as: - - -于是,我们来看看在内核中应如何访问一个特定的物理页帧: - -.. code-block:: rust - - // os/src/mm/address.rs - - impl PhysPageNum { - pub fn get_pte_array(&self) -> &'static mut [PageTableEntry] { - let pa: PhysAddr = self.clone().into(); - unsafe { - core::slice::from_raw_parts_mut(pa.0 as *mut PageTableEntry, 512) - } - } - pub fn get_bytes_array(&self) -> &'static mut [u8] { - let pa: PhysAddr = self.clone().into(); - unsafe { - core::slice::from_raw_parts_mut(pa.0 as *mut u8, 4096) - } - } - pub fn get_mut(&self) -> &'static mut T { - let pa: PhysAddr = self.clone().into(); - unsafe { - (pa.0 as *mut T).as_mut().unwrap() - } - } - } - -我们构造可变引用来直接访问一个物理页号 ``PhysPageNum`` 对应的物理页帧,不同的引用类型对应于物理页帧上的一种不同的 -内存布局,如 ``get_pte_array`` 返回的是一个页表项定长数组的可变引用,可以用来修改多级页表中的一个节点;而 -``get_bytes_array`` 返回的是一个字节数组的可变引用,可以以字节为粒度对物理页帧上的数据进行访问,前面进行数据清零 -就用到了这个方法; ``get_mut`` 是个泛型函数,可以获取一个恰好放在一个物理页帧开头的类型为 ``T`` 的数据的可变引用。 - -在实现方面,都是先把物理页号转为物理地址 ``PhysAddr`` ,然后再转成 usize 形式的物理地址。接着,我们直接将它 -转为裸指针用来访问物理地址指向的物理内存。在分页机制开启前,这样做自然成立;而开启之后,虽然裸指针被视为一个虚拟地址, -但是上面已经提到这种情况下虚拟地址会映射到一个相同的物理地址,因此在这种情况下也成立。注意,我们在返回值类型上附加了 -静态生命周期泛型 ``'static`` ,这是为了绕过 Rust 编译器的借用检查,实质上可以将返回的类型也看成一个裸指针,因为 -它也只是标识数据存放的位置以及类型。但与裸指针不同的是,无需通过 ``unsafe`` 的解引用访问它指向的数据,而是可以像一个 -正常的可变引用一样直接访问。 - -.. note:: +.. code-block:: c - **unsafe 真的就是“不安全”吗?** + // os/types.h - 下面是笔者关于 ``unsafe`` 一点可能不太正确的理解,不感兴趣的读者可以跳过。 + typedef uint64 pte_t; + typedef uint64 pde_t; + typedef uint64 *pagetable_t;// 512 PTEs - 当我们在 Rust 中使用 unsafe 的时候,并不仅仅是为了绕过编译器检查,更是为了告知编译器和其他看到这段代码的程序员: - “ **我保证这样做是安全的** ” 。尽管,严格的 Rust 编译器暂时还不能确信这一点。从规范 Rust 代码编写的角度, - 我们需要尽可能绕过 unsafe ,因为如果 Rust 编译器或者一些已有的接口就可以提供安全性,我们当然倾向于利用它们让我们 - 实现的功能仍然是安全的,可以避免一些无谓的心智负担;反之,就只能使用 unsafe ,同时最好说明如何保证这项功能是安全的。 +第一小节中我们提到,在页表中以虚拟页号作为索引不仅能够查到物理页号,还能查到一组保护位,它控制了应用对地址空间每个 +虚拟页面的访问权限。但实际上还有更多的标志位,物理页号和全部的标志位以某种固定的格式保存在一个结构体中,它被称为 +**页表项** (PTE, Page Table Entry) ,是利用虚拟页号在页表中查到的结果。 - 这里简要从内存安全的角度来分析一下 ``PhysPageNum`` 的 ``get_*`` 系列方法的实现中 ``unsafe`` 的使用。为了方便 - 解释,我们可以将 ``PhysPageNum`` 也看成一种 RAII 的风格,即它控制着一个物理页帧资源的访问。首先,这不会导致 - use-after-free 的问题,因为在内核运行全期整块物理内存都是可以访问的,它不存在被释放后无法访问的可能性;其次, - 也不会导致并发冲突。注意这不是在 ``PhysPageNum`` 这一层解决的,而是 ``PhysPageNum`` 的使用层要保证任意两个线程 - 不会同时对一个 ``PhysPageNum`` 进行操作。读者也应该可以感觉出这并不能算是一种好的设计,因为这种约束从代码层面是很 - 难直接保证的,而是需要系统内部的某种一致性。虽然如此,它对于我们这个极简的内核而言算是很合适了。 +.. image:: sv39-pte.png -.. chyyuu 上面一段提到了线程??? +上图为 SV39 分页模式下的页表项,其中 :math:`[53:10]` 这 :math:`44` 位是物理页号,最低的 :math:`8` 位 +:math:`[7:0]` 则是标志位,它们的含义如下(请注意,为方便说明,下文我们用 *页表项的对应虚拟页面* 来表示索引到 +一个页表项的虚拟页号对应的虚拟页面): -建立和拆除虚实地址映射关系 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +- 仅当 V(Valid) 位为 1 时,页表项才是合法的; +- R/W/X 分别控制索引到这个页表项的对应虚拟页面是否允许读/写/取指; +- U 控制索引到这个页表项的对应虚拟页面是否在 CPU 处于 U 特权级的情况下是否被允许访问; +- G 我们暂且不理会; +- A(Accessed) 记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被访问过; +- D(Dirty) 则记录自从页表项上的这一位被清零之后,页表项的对应虚拟页表是否被修改过。 -接下来介绍建立和拆除虚实地址映射关系的 ``map`` 和 ``unmap`` 方法是如何实现的。它们都依赖于一个很重要的过程,也即在多级页表中找到一个虚拟地址对应的页表项。 -找到之后,只要修改页表项的内容即可完成键值对的插入和删除。在寻找页表项的时候,可能出现页表的中间级节点还未被创建的情况, -这个时候我们需要手动分配一个物理页帧来存放这个节点,并将这个节点接入到当前的多级页表的某级中。 +由于pte只有54位,每一个页表项我们用一个8字节的无符号整型来记录就已经足够。 +页表实现va-->pa的转换过程 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. code-block:: rust +下面我们通过来解读我们OS的walk函数了解SV39的页表读取机制。 +.. code-block:: c :linenos: - // os/src/mm/address.rs + // os/vm.c - impl VirtPageNum { - pub fn indexes(&self) -> [usize; 3] { - let mut vpn = self.0; - let mut idx = [0usize; 3]; - for i in (0..3).rev() { - idx[i] = vpn & 511; - vpn >>= 9; + pte_t * + walk(pagetable_t pagetable, uint64 va, int alloc) { + if (va >= MAXVA) + panic("walk"); + + for (int level = 2; level > 0; level--) { + pte_t *pte = &pagetable[PX(level, va)]; + if (*pte & PTE_V) { + pagetable = (pagetable_t) PTE2PA(*pte); + } else { + if (!alloc || (pagetable = (pde_t *) kalloc()) == 0) + return 0; + memset(pagetable, 0, PGSIZE); + *pte = PA2PTE(pagetable) | PTE_V; } - idx } + return &pagetable[PX(0, va)]; } - // os/src/mm/page_table.rs +walk函数模拟了CPU进行MMU的过程。它的参数分别是页表,待转换的虚拟地址va,以及如果没有对应的物理地址时是否分配物理地址。 +SV39的转换是由3级页表结构完成。在riscv.h之中定义的宏函数PX完成了每一级从va转换到pte的过程: - impl PageTable { - fn find_pte_create(&mut self, vpn: VirtPageNum) -> Option<&mut PageTableEntry> { - let idxs = vpn.indexes(); - let mut ppn = self.root_ppn; - let mut result: Option<&mut PageTableEntry> = None; - for i in 0..3 { - let pte = &mut ppn.get_pte_array()[idxs[i]]; - if i == 2 { - result = Some(pte); - break; - } - if !pte.is_valid() { - let frame = frame_alloc().unwrap(); - *pte = PageTableEntry::new(frame.ppn, PTEFlags::V); - self.frames.push(frame); - } - ppn = pte.ppn(); - } - result - } - } - -- ``VirtPageNum`` 的 ``indexes`` 可以取出虚拟页号的三级页索引,并按照从高到低的顺序返回。注意它里面包裹的 - usize 可能有 :math:`27` 位,也有可能有 :math:`64-12=52` 位,但这里我们是用来在多级页表上进行遍历,因此 - 只取出低 :math:`27` 位。 -- ``PageTable::find_pte_create`` 在多级页表找到一个虚拟页号对应的页表项的可变引用方便后续的读写。如果在 - 遍历的过程中发现有节点尚未创建则会新建一个节点。 - - 变量 ``ppn`` 表示当前节点的物理页号,最开始指向多级页表的根节点。随后每次循环通过 ``get_pte_array`` 将 - 取出当前节点的页表项数组,并根据当前级页索引找到对应的页表项。如果当前节点是一个叶节点,那么直接返回这个页表项 - 的可变引用;否则尝试向下走。走不下去的话就新建一个节点,更新作为下级节点指针的页表项,并将新分配的物理页帧移动到 - 向量 ``frames`` 中方便后续的自动回收。注意在更新页表项的时候,不仅要更新物理页号,还要将标志位 V 置 1, - 不然硬件在查多级页表的时候,会认为这个页表项不合法,从而触发 Page Fault 而不能向下走。 - -于是, ``map/unmap`` 就非常容易实现了: - -.. code-block:: rust - - // os/src/mm/page_table.rs - - impl PageTable { - pub fn map(&mut self, vpn: VirtPageNum, ppn: PhysPageNum, flags: PTEFlags) { - let pte = self.find_pte_create(vpn).unwrap(); - assert!(!pte.is_valid(), "vpn {:?} is mapped before mapping", vpn); - *pte = PageTableEntry::new(ppn, flags | PTEFlags::V); - } - pub fn unmap(&mut self, vpn: VirtPageNum) { - let pte = self.find_pte_create(vpn).unwrap(); - assert!(pte.is_valid(), "vpn {:?} is invalid before unmapping", vpn); - *pte = PageTableEntry::empty(); - } - } - -只需根据虚拟页号找到页表项,然后修改或者直接清空其内容即可。 - -.. warning:: - - 目前的实现方式并不打算对物理页帧耗尽的情形做任何处理而是直接 ``panic`` 退出。因此在前面的代码中能够看到 - 很多 ``unwrap`` ,这种使用方式并不为 Rust 所推荐,只是由于简单起见暂且这样做。 - -为了方便后面的实现,我们还需要 ``PageTable`` 提供一种不经过 MMU 而是手动查页表的方法: - -.. code-block:: rust +.. code-block:: c :linenos: - // os/src/mm/page_table.rs + #define PXMASK 0x1FF// 9 bits + #define PGSHIFT 12// bits of offset within a page + #define PXSHIFT(level) (PGSHIFT + (9 * (level))) + #define PX(level, va) ((((uint64)(va)) >> PXSHIFT(level)) & PXMASK) - impl PageTable { - /// Temporarily used to get arguments from user space. - pub fn from_token(satp: usize) -> Self { - Self { - root_ppn: PhysPageNum::from(satp & ((1usize << 44) - 1)), - frames: Vec::new(), - } - } - fn find_pte(&self, vpn: VirtPageNum) -> Option<&PageTableEntry> { - let idxs = vpn.indexes(); - let mut ppn = self.root_ppn; - let mut result: Option<&PageTableEntry> = None; - for i in 0..3 { - let pte = &ppn.get_pte_array()[idxs[i]]; - if i == 2 { - result = Some(pte); - break; - } - if !pte.is_valid() { - return None; - } - ppn = pte.ppn(); - } - result - } - pub fn translate(&self, vpn: VirtPageNum) -> Option { - self.find_pte(vpn) - .map(|pte| {pte.clone()}) - } +可以看到,每一次我们只需要截取队va高27位中对应级别的9位即可。一开始截取最高9位,接着是中间的9位和低9位。这9位我们如何使用呢?SV39中要求我们把页表的44位和虚拟地址对应的9位*8直接拼接在一起做为pte的地址。页表的高44位(也就是页号)拼接上12位的0实际上就是pagetable指向的物理地址。我们可以计算得到一个4096大小的页表之中有4096/8=512个页表项。因此我们得到的这9位实际上就是pte在这一页之中的偏移,也就是其下标了。 + +得到了页表项之后,我们使用PTE2PA函数将该页表项的高44位(也就是下一个页表的页号)取出和12个0拼接(通过左移和右移可以轻松实现),就得到了下一级页表的起始物理地址了。接着重复这样的操作,直到最后一个pte解析出来,就可以返回最后一个pte了(循环并没有处理最后一级)。最后一个pte中记录了物理地址的物理页号PPN,将它直接和虚拟地址的12位offset拼接就得到了对应的物理地址pa。 + +整个过程中要注意随时通过PTE的标志位判断每一级的pte是否是有效的(V位)。如果无效则需要kalloc分配一个新的页表,并初始化该pte在其中的位置。如果alloc参数=0或者已经没有空闲的内存了(这个情况在lab8之前不会遇到),那么遇到中途V=0的pte整个walk过程就会直接退出。当然这是OS的写法,如果CPU在MMU的时候遇到这种情况就会直接报异常了。 + +walk函数是我们比较底层的一个函数,但也是所有遍历页表进行地址转换函数的基础。我们还实现了两个转换函数: + +.. code-block:: c + :linenos: + + // Look up a virtual address, return the physical page, + // or 0 if not mapped. + // Can only be used to look up user pages. + // Use `walk` + uint64 walkaddr(pagetable_t pagetable, uint64 va); + + // Look up a virtual address, return the physical address. + // Use `walkaddr` + uint64 useraddr(pagetable_t pagetable, uint64 va); + +大家可以自行阅读。注意walkaddr函数没有考虑偏移量,因此在使用的时候请首先考虑useraddr函数。 + +页表的建立过程 +----------------------------------- + +无论是CPU进行MMU,还是我们自己walk实现va到pa的转换需要的页表都是需要OS来生成的。相关函数也是本章练习涉及到的主要函数。 + +.. code-block:: c + :linenos: + + // Create PTEs for virtual addresses starting at va that refer to + // physical addresses starting at pa. va and size might not + // be page-aligned. Returns 0 on success, -1 if walk() couldn't + // allocate a needed page-table page. + int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm); + + // Remove npages of mappings starting from va. va must be + // page-aligned. The mappings must exist. + // Optionally free the physical memory. + void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free); + +以上是建立新映射和取消映射的函数,mappages 在 pagetable 中建立 [va, va + size) 到 [pa, pa + size) 的映射,页表属性为perm,uvmunmap 则取消一段映射,do_free 控制是否 kfree 对应的物理内存(比如这是一个共享内存,那么第一次 unmap 就不 free,最后一个 unmap 肯定要 free)。 + +mappages的perm是用于控制页表项的flags的。请注意它具体指向哪几位,这将极大地影响页表的可用性。因为CPU进行MMU的时候一旦权限出错,比如CPU在U态访问了flag之中U=0的页表项是会直接报异常的。 + +启用页表后的跨页表操作 +----------------------------------- + +一旦启用了页表之后,U态的测例程序就开始全部使用虚拟地址了。这就意味着它传给OS的指针参数也是虚拟地址,我们无法直接去读虚拟地址,而是要将它使用对应进程的页表转换成物理地址才能读取。 + +为了方便大家,我们预先准备了几个跨页表进行字符串数据交换的函数。 +.. code-block:: c + :linenos: + + // Copy from kernel to user. + // Copy len bytes from src to virtual address dstva in a given page table. + // Return 0 on success, -1 on error. + int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len); + + // Copy from user to kernel. + // Copy len bytes to dst from virtual address srcva in a given page table. + // Return 0 on success, -1 on error. + int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len); + + // Copy a null-terminated string from user to kernel. + // Copy bytes to dst from virtual address srcva in a given page table, + // until a '\0', or max. + // Return 0 on success, -1 on error. + int copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max); + +用于与指定页表进行数据交换,copyout 可以向页表中写东西,后续用于 sys_read,也就是给用户传数据,copyin 用户接受用户的 buffer,也就是从用户哪里读数据。 +注意,用户在启用了虚拟内存之后,用户 syscall 给出的指针是不能直接用的,因为与内核的映射不一样,会读取错误的物理地址,使用指针必须通过 useraddr 转化,当然,更加推荐的是 copyin/out 接口,否则很可能损坏内存数据,同时,copyin/out 接口处理了虚存跨页的情况,useraddr 则需要手动判断并处理。跨页会在测例文件bin比较大的时候出现。如果你的程序出现了完全De不出来的BUG,可能就是跨页+使用了错误的接口导致的。 + +内核页表 +----------------------------------- + +开启页表之后,内核也需要进行映射处理。但是我们这里可以直接进行一一映射,也就是va经过MMU转换得到的pa就是va本身(但是转换过程还是会执行!)。内核需要能访问到所有的物理内存以处理频繁的操作不同进程内存的需求。内核页表建立过程在main函数之中调用。 + +.. code-block:: c + :linenos: + + #define PTE_V (1L << 0) // valid + #define PTE_R (1L << 1) + #define PTE_W (1L << 2) + #define PTE_X (1L << 3) + #define PTE_U (1L << 4) // 1 -> user can access + + #define KERNBASE (0x80200000) + extern char e_text[]; // kernel.ld sets this to end of kernel code. + extern char trampoline[]; + + pagetable_t kvmmake(void) { + pagetable_t kpgtbl; + kpgtbl = (pagetable_t) kalloc(); + memset(kpgtbl, 0, PGSIZE); + mappages(kpgtbl, KERNBASE, KERNBASE, (uint64) e_text - KERNBASE, PTE_R | PTE_X); + mappages(kpgtbl, (uint64) e_text, (uint64) e_text, PHYSTOP - (uint64) e_text, PTE_R | PTE_W); + mappages(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X); + return kpgtbl; } -- 第 5 行的 ``from_token`` 可以临时创建一个专用来手动查页表的 ``PageTable`` ,它仅有一个从传入的 ``satp`` token - 中得到的多级页表根节点的物理页号,它的 ``frames`` 字段为空,也即不实际控制任何资源; -- 第 11 行的 ``find_pte`` 和之前的 ``find_pte_create`` 不同之处在于它不会试图分配物理页帧。一旦在多级页表上遍历 - 遇到空指针它就会直接返回 ``None`` 表示无法正确找到传入的虚拟页号对应的页表项; -- 第 28 行的 ``translate`` 调用 ``find_pte`` 来实现,如果能够找到页表项,那么它会将页表项拷贝一份并返回,否则就 - 返回一个 ``None`` 。 +用户页表的加载 +----------------------------------- -.. chyyuu 没有提到from_token的作用??? \ No newline at end of file +用户的加载逻辑在 loader.c 中(也就是原来的 batch.c,改名了),其中唯一逻辑变化较大的就是 bin_loader 函数: + +.. code-block:: c + :linenos: + + // kernel/vm.c + + // kernel/loader.c + pagetable_t bin_loader(uint64 start, uint64 end, struct proc *p) { + pagetable_t pg = (pagetable_t) kalloc(); + memset(pg, 0, PGSIZE); + // trampoline 就是 uservec userret 两个函数的位置 + mappages(pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline, PTE_R | PTE_X) < 0); + // trapframe 之前是预分配的,现在我们用 kalloc 得到。 + p->trapframe = (struct trapframe*)kalloc(); + memset(p->trapframe, 0, PGSIZE); + // map trapframe,位置稍后解释 + mappages(pg, TRAPFRAME, PGSIZE, (uint64)p->trapframe, PTE_R | PTE_W); + // 这部分就是 bin 程序的实际 map, 我们把 [BASE_ADDRESS, APP_SIZE) map 到 [app_start, app_end) + // 替代了之前的拷贝 + uint64 s = PGROUNDDOWN(start), e = PGROUNDUP(end); + if (mappages(pg, BASE_ADDRESS, e - s, s, PTE_U | PTE_R | PTE_W | PTE_X) != 0) { + panic("wrong loader 1\n"); + } + p->pagetable = pg; + p->trapframe->epc = BASE_ADDRESS; + // map user stack + mappages(pg, USTACK_BOTTOM, USTACK_SIZE, (uint64) kalloc(), PTE_U | PTE_R | PTE_W | PTE_X); + p->ustack = USTACK_BOTTOM; + p->trapframe->sp = p->ustack + USTACK_SIZE; + return pg; + } + +这里大家也要注意,每一个测例进程都有一套自己的页表。因此在进程切换或者异常中断处理返回U态的时候需要设置satp的值为其对应的值才能使用正确的页表。具体的实现其实之前几章已经先做好了。 + +我们需要重点关注一下trapframe 和 trampoline代码的位置。在前面两节我们看到了memory_layout文件。这两块内存用户特权级切换,必须用户态和内核态都能访问。所以它们在内核和用户页表中都有 map,注意所有 kalloc() 分配的内存内核都能访问,这是因为我们已经预先设置好页表了。 + +.. code-block:: c + :linenos: + + #define USER_TOP (MAXVA) + #define TRAMPOLINE (USER_TOP - PGSIZE) + #define TRAPFRAME (TRAMPOLINE - PGSIZE) + +这与为何要这么设定,留给读者思考。提示:这是去年期中试题之一。 \ No newline at end of file