故事是這樣子的,雖然在上上篇的 Context Switch 我們曾經預告過因為 static 的問題, 可能要停更一段時間,但後來沒停更,static 問題很快就用 spin 提供的 no_std Mutex 解決了。
不過,no_std Mutex 雖然解決 static 不用手爆 constructor 的問題,同時卻產生了另一個問題:stack overflow。

因為呼叫 static variable constructor 會用到 os process 的 stack,static variable size 一大,os process 的 stack 就會被吃乾抹淨。
所以問題來了,我們必須要儘可能的縮小 static variable 的尺寸,像上篇把 process 的 context 全塞在裡面是不可能的 (為了讓 code 動起來我把 process 數量降到剩 2 個),因此下一步就很清楚了,必須挑戰作業系統裡一大難題:記憶體管理

這個問題說難不難,我最早看到用 rust 寫 OS 的 blog_os , 作者自己就有公佈一款 linked list allocator , 理論上我可以直接在 Cargo 上引用這個 repository,然後插到我的 project 即可。
當然,除了直接用人家的 project ,我們還是要看一下 Rust 的記憶體管理是怎麼實作的,這篇我們就實作一個只能 alloc 不能 dealloc 的記憶體管理 (其實這樣說起來,我在 Mutex 都自己實作才對,不該直接用 spin 提供的)。

Global Allocator

rust 在設計上,記憶體管理是透過 Global Allocator 來處理,一般有 std 下會使用 std::alloc 的 System allocator,像我們這樣沒有 Global Allocator 的程式,是無法使用 heap 的,現下的 OS 在呼叫 Box 的時候:

extern crate alloc;
use alloc::boxed::Box;

let b = Box::new(64);

會出現如下錯誤:

error: no global memory allocator found but one is required;  
link to std or add `#[global_allocator]` to a static item that implements the GlobalAlloc trait.

錯誤訊息說得很明白了,要提供一個實作 GlobalAlloc trait 的 struct,為它加上 #[global_allocator] 這個 attribute。

extern crate alloc;
use alloc::alloc::{GlobalAlloc, Layout};

pub struct HeapAllocator;

GlobalAlloc trait 裡面定義了四個函式,必要的函式為前兩個 alloc 與 dealloc,rust 會自動幫我們推出 alloc_zeroed 跟 realloc:

pub unsafe trait GlobalAlloc {
  unsafe fn alloc(&self, layout: Layout) -> *mut u8;
  unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);

  unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { ... }
  unsafe fn realloc(
        &self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { ... }
}

為我們的空殼實作 GlobalAlloc,trait 本身就是 unsafe 還真鮮; Layout 是 rust 定義用來表示一塊記憶體的資料結構,儲存了這塊記憶體的 size, align 等資訊,有趣的是,rust dealloc 的時候也會帶 layout 進來, 這意味著跟 C 的 free 不一樣, GlobalAlloc 在背後還是有某種管理機制在 realloc 的時候可以知道記憶體的尺寸資訊:

use core::ptr::null_mut;

unsafe impl {
  unsafe fn alloc(&self, _layout: Layout) -> *mut u8 {
    null_mut()
  }   

  unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
    panic!("dealloc should be never called");
  }   
}

除了這個還要加上 memory alloc error 的實作,或者在 main.rs (lib.rs) 裡面加上 #![feature(default_alloc_error_handler)] 讓編譯器自行弄成 panic!

#[alloc_error_handler]
fn alloc_error_handler(layout: Layout) -> ! {
  panic!("allocation error {:?}", layout);
}

最後是在 main.rs 將我們的空殼 HeapAllocator 設定為 #[global_allocator]

#[global_allocator]
static ALLOCATOR: HeapAllocator = HeapAllocator;

如此一來上面使用 Box 的程式就能通過編譯了;在執行時則會 panic,因為 Box 會對 alloc 傳回的 null pointer 存取。

實作 Linked List

一般最簡便的 heap 管理就是用 linked_list,這次也好好跟 linked_list_allocator 的作者學會怎麼打造 rust 的 linked_list。
第一步先打造我們的 Hole,用 Option 來打造 list 應該是基本中的基本,Option 內是另一個 Hole 的 static reference。

pub struct Hole {
  size: usize,
  next: Option<&'static mut Hole>,
}

HoleList 的 first 是 dummy Hole,size 為 0;在生成的時候會如下,在 hole_addr 上建一個 Hole pointer,對 pointer 寫入 Hole struct 即可。
ptr 寫入這個動作是 unsafe 的,不過我們整個 new 函式都是 unsafe 所以沒問題。

pub struct HoleList {
  first: Hole,
}

impl HoleList {
  pub unsafe fn new(hole_addr: usize, hole_size: usize) -> Self {
    let ptr = hole_addr as *mut Hole;
    ptr.write( Hole {
      size: hole_size,
      next: None,
    });
    Self {
      first: Hole {
        size: 0,
        next: Some(&mut *ptr),
      }
    }
  }
}

因為 Hole 本身有 reference 不適合複製,另外實作一個 HoleInfo 來代表一塊記憶體:

struct HoleInfo {
  addr: usize,
  size: usize,
}

impl Hole {
  pub fn info(&self) -> HoleInfo {
    HoleInfo {
      addr: self as *const _ as usize,
      size: self.size,
    }
  }
}

HoleList 之外我們再包一層 Heap 用來記錄整個記憶體使用的狀況:

struct Heap {
  bottom: usize,
  size: usize,
  used: usize,
  holes: HoleList,
}

impl Heap {
  pub const fn empty() -> Self {
    Self {
      bottom: 0,
      size: 0,
      used: 0,
      holes: HoleList::empty(),
    }
  }
}

原本空無一物的 HeapAllocator 也要改造一番,為了避免 malloc 的競態條件,heap 外要加上 Mutex。

pub struct HeapAllocator {
  heap: Mutex<Heap>,
}

impl HeapAllocator {
  pub const fn empty() -> Self {
    Self {
      heap: Mutex::new(Heap::empty()),
    }
  }
}

整體的架構依序為:HeapAllocator -> Heap -> HoleList -> [Hole]

實作 alloc

global allocate 函式,實作很單純,因為我們已經知道不會有記憶體被 free,要做的就只有拿出下一個元素(單一區塊管理所有記憶體), 檢查 size 夠不夠,如果夠的話就在 Hole 的尾端把空出記憶體;不夠的話就是回傳 Err
實際的 linked_list 則是在 Hole 沒有足夠記憶體的時候,會去拿出下一個 Hole 試著 alloc 看看,直到 list 尾端就回傳 ErrAllocation 用來表示一塊分配的記憶體,未來處理 align 的時候會在裡面加上更多東西。

pub fn allocate(previous: &mut Hole, layout: Layout) -> Result<Allocation, ()> {
  let mut current = previous.next.as_mut().unwrap().info();
  let required_size = layout.size();
  if current.size < required_size {
    Err(())
  } else {
    let allocated_info = HoleInfo {
      addr: current.addr + current.size - required_size,
      size: required_size,
    };
    current.size -= required_size;
    Ok(Allocation {
      info: allocated_info,
    })
  }
}

如此一來,HoleList 就是把自己的 List 拿出來,呼叫 global allocate 函式得到 Allocation,轉型為 NonNull pointer , 這裡可以用 NonNull 因為 Null 的狀況已經由 Result Err 來表示了:

// HoleList
pub fn allocate(&mut self, layout: Layout) -> Result<NonNull<u8>, ()> {
  allocate(&mut self.first, layout).map(|allocation| {
    NonNull::new(allocation.info.addr as *mut u8).unwrap()
  })
}

在實作 GlobalAlloc 的 alloc 函式裡面呼叫 allocate,如果是 Err 的狀況會由 map_or 轉型為 Null pointer。

unsafe impl GlobalAlloc for HeapAllocator {
  unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
    self.heap
      .lock()
      .allocate(layout)
      .map_or(null_mut(), |allocation| allocation.as_ptr())
  }
}

初始化 Heap

我們 heap 的尺寸和 xv6 一樣設定為 128MB(扣掉 kernel code 的空間,因此實際空間會略小於 128MB),heap 的起始位置則由 linker script 決定,在 linker script .data, .bss 等區間之後,加上:

PROVIDE(_END = .);

初始化記憶體就能透過 extern "C" 取得 _END 的值,再把這個 heap 設定寫入 ALLOCATOR 的 heap 中。

pub fn init_heap() {
  extern "C" {
    static _END: usize;
  }

  let heap_start: usize = unsafe {
    &_END as *const usize as usize
  };
  let heap_end = memorylayout::PHYSTOP as usize;
  let heap_size = heap_end - heap_start;
  unsafe {
    ALLOCATOR
      .heap
      .lock()
      .init(heap_start, heap_size)
  }
}

結語

我們實作了一款只會 allocate 不會 free 的 allocator,但至少,現在我們可以在 code 裡面使用各種使用 heap 的資料結構,例如上述的 Box。
簡而言之我們的 HeapAllocator 大概可以用下面這段概述:

我之前是幫人弄記憶體管理的,而我 malloc 的原則是:「○林涼 malloc 爆」
沒錯,就是○林涼 malloc 爆,老子才不管甚麼要不要 free 的,每次 malloc 就是一大塊。1K malloc 1M,1M malloc 1G。
後來作業系統跳 panic 跟我說記憶體不足,你有頭緒嗎?
我TMD怎麼會知道。

第二,如果你跟 xv6 的實作比較的話,可以明顯看到 rust 優於 C 的一點,rust 的 locking 是強制性的,只要我們把東西包到 Mutex 裡面, 你一定要用 lock 再去存取內部的資料;相對的 xv6 實作則是:

struct {
  struct spinlock lock;
  struct run *freelist;
} kmem;

存取 freelist 之前記得先跟 lock 去 acquire lock。
啊沒有 lock 會怎麼樣?系統爆掉啊。
誰的錯?當然是程式設計師的。

Rust 在這方面 比較 貼心一點,雖然 Mutex 的實作相對麻煩一些,但只要做完,實作的限制就會如通往建功嶼的海中道路般浮現; 你的實作會被限制到只剩安全的方式,不這麼做 compiler 就會跳出來把你攔住,叫你回去修練 21 小時之後再來, 或是非得讓你狂下 unsafe 或 unwrap 弄出有危險的程式它才放行。

套句 jserv 教授的話:

C 語言假定了程式設計師都是聰明人,很清楚知道自己在做什麼

而愚者用了 C 就必須要為自己的愚蠢付出代價。