20 張圖揭開「記憶體管理」的迷霧,瞬間豁然開朗

小林coding發表於2020-06-30
每日英語,每天進步一點點

前言

之前有不少讀者跟我反饋,能不能寫圖解作業系統?

既然那麼多讀者想看,我最近就在瘋狂的複習作業系統的知識。

作業系統確實是比較難啃的一門課,至少我認為比計算機網路難太多了,但它的重要性就不用我多說了。

學作業系統的時候,主要痛苦的地方,有太多的抽象難以理解的詞語或概念,非常容易被勸退。

即使懷著滿腔熱血的心情開始學作業系統,不過 3 分鐘睡意就突然襲來。。。

該啃的還是得啃的,該圖解的還是得圖解的,萬眾期待的「圖解作業系統」的系列來了。

本篇跟大家說說記憶體管理,記憶體管理還是比較重要的一個環節,理解了它,至少對整個作業系統的工作會有一個初步的輪廓,這也難怪面試的時候常問記憶體管理。

幹就完事,本文的提綱:

本文提綱本文提綱

正文

虛擬記憶體

如果你是電子相關專業的,肯定在大學裡搗鼓過微控制器。

微控制器是沒有作業系統的,所以每次寫完程式碼,都需要藉助工具把程式燒錄進去,這樣程式才能跑起來。

另外,微控制器的 CPU 是直接操作記憶體的「實體地址」

在這種情況下,要想在記憶體中同時執行兩個程式是不可能的。如果第一個程式在 2000 的位置寫入一個新的值,將會擦掉第二個程式存放在相同位置上的所有內容,所以同時執行兩個程式是根本行不通的,這兩個程式會立刻崩潰。

作業系統是如何解決這個問題呢?

這裡關鍵的問題是這兩個程式都引用了絕對實體地址,而這正是我們最需要避免的。

我們可以把程式所使用的地址「隔離」開來,即讓作業系統為每個程式分配獨立的一套「虛擬地址」,人人都有,大家自己玩自己的地址就行,互不干涉。但是有個前提每個程式都不能訪問實體地址,至於虛擬地址最終怎麼落到實體記憶體裡,對程式來說是透明的,作業系統已經把這些都安排的明明白白了。

程式的中間層程式的中間層

作業系統會提供一種機制,將不同程式的虛擬地址和不同記憶體的實體地址對映起來。

如果程式要訪問虛擬地址的時候,由作業系統轉換成不同的實體地址,這樣不同的程式執行的時候,寫入的是不同的實體地址,這樣就不會衝突了。

於是,這裡就引出了兩種地址的概念:

  • 我們程式所使用的記憶體地址叫做虛擬記憶體地址Virtual Memory Address
  • 實際存在硬體裡面的空間地址叫實體記憶體地址Physical Memory Address)。

作業系統引入了虛擬記憶體,程式持有的虛擬地址會通過 CPU 晶片中的記憶體管理單元(MMU)的對映關係,來轉換變成實體地址,然後再通過實體地址訪問記憶體,如下圖所示:

虛擬地址定址虛擬地址定址

作業系統是如何管理虛擬地址與實體地址之間的關係?

主要有兩種方式,分別是記憶體分段和記憶體分頁,分段是比較早提出的,我們先來看看記憶體分段。


記憶體分段

程式是由若干個邏輯分段組成的,如可由程式碼分段、資料分段、棧段、堆段組成。不同的段是有不同的屬性的,所以就用分段(Segmentation)的形式把這些段分離出來。

分段機制下,虛擬地址和實體地址是如何對映的?

分段機制下的虛擬地址由兩部分組成,段選擇子段內偏移量

記憶體分段-定址的方式記憶體分段-定址的方式
  • 段選擇子就儲存在段暫存器裡面。段選擇子裡面最重要的是段號,用作段表的索引。段表裡面儲存的是這個段的基地址、段的界限和特權等級等。

  • 虛擬地址中的段內偏移量應該位於 0 和段界限之間,如果段內偏移量是合法的,就將段基地址加上段內偏移量得到實體記憶體地址。

在上面,知道了虛擬地址是通過段表與實體地址進行對映的,分段機制會把程式的虛擬地址分成 4 個段,每個段在段表中有一個項,在這一項找到段的基地址,再加上偏移量,於是就能找到實體記憶體中的地址,如下圖:

記憶體分段-虛擬地址與實體地址記憶體分段-虛擬地址與實體地址

如果要訪問段 3 中偏移量 500 的虛擬地址,我們可以計算出實體地址為,段 3 基地址 7000 + 偏移量 500 = 7500。

分段的辦法很好,解決了程式本身不需要關心具體的實體記憶體地址的問題,但它也有一些不足之處:

  • 第一個就是記憶體碎片的問題。
  • 第二個就是記憶體交換的效率低的問題。

接下來,說說為什麼會有這兩個問題。

我們先來看看,分段為什麼會產生記憶體碎片的問題?

我們來看看這樣一個例子。假設有 1G 的實體記憶體,使用者執行了多個程式,其中:

  • 遊戲佔用了 512MB 記憶體
  • 瀏覽器佔用了 128MB 記憶體
  • 音樂佔用了 256 MB 記憶體。

這個時候,如果我們關閉了瀏覽器,則空閒記憶體還有 1024 - 512 - 256 = 256MB。

如果這個 256MB 不是連續的,被分成了兩段 128 MB 記憶體,這就會導致沒有空間再開啟一個 200MB 的程式。

記憶體碎片的問題記憶體碎片的問題

這裡的記憶體碎片的問題共有兩處地方:

  • 外部記憶體碎片,也就是產生了多個不連續的小實體記憶體,導致新的程式無法被裝載;
  • 內部記憶體碎片,程式所有的記憶體都被裝載到了實體記憶體,但是這個程式有部分的記憶體可能並不是很常使用,這也會導致記憶體的浪費;

針對上面兩種記憶體碎片的問題,解決的方式會有所不同。

解決外部記憶體碎片的問題就是記憶體交換

可以把音樂程式佔用的那 256MB 記憶體寫到硬碟上,然後再從硬碟上讀回來到記憶體裡。不過再讀回的時候,我們不能裝載回原來的位置,而是緊緊跟著那已經被佔用了的 512MB 記憶體後面。這樣就能空缺出連續的 256MB 空間,於是新的 200MB 程式就可以裝載進來。

這個記憶體交換空間,在 Linux 系統裡,也就是我們常看到的 Swap 空間,這塊空間是從硬碟劃分出來的,用於記憶體與硬碟的空間交換。

再來看看,分段為什麼會導致記憶體交換效率低的問題?

對於多程式的系統來說,用分段的方式,記憶體碎片是很容易產生的,產生了記憶體碎片,那不得不重新 Swap 記憶體區域,這個過程會產生效能瓶頸。

因為硬碟的訪問速度要比記憶體慢太多了,每一次記憶體交換,我們都需要把一大段連續的記憶體資料寫到硬碟上。

所以,如果記憶體交換的時候,交換的是一個佔記憶體空間很大的程式,這樣整個機器都會顯得卡頓。

為了解決記憶體分段的記憶體碎片和記憶體交換效率低的問題,就出現了記憶體分頁。


記憶體分頁

分段的好處就是能產生連續的記憶體空間,但是會出現記憶體碎片和記憶體交換的空間太大的問題。

要解決這些問題,那麼就要想出能少出現一些記憶體碎片的辦法。另外,當需要進行記憶體交換的時候,讓需要交換寫入或者從磁碟裝載的資料更少一點,這樣就可以解決問題了。這個辦法,也就是記憶體分頁Paging)。

分頁是把整個虛擬和實體記憶體空間切成一段段固定尺寸的大小。這樣一個連續並且尺寸固定的記憶體空間,我們叫Page)。在 Linux 下,每一頁的大小為 4KB

虛擬地址與實體地址之間通過頁表來對映,如下圖:

記憶體對映記憶體對映

頁表實際上儲存在 CPU 的記憶體管理單元MMU) 中,於是 CPU 就可以直接通過 MMU,找出要實際要訪問的實體記憶體地址。

而當程式訪問的虛擬地址在頁表中查不到時,系統會產生一個缺頁異常,進入系統核心空間分配實體記憶體、更新程式頁表,最後再返回使用者空間,恢復程式的執行。

分頁是怎麼解決分段的記憶體碎片、記憶體交換效率低的問題?

由於記憶體空間都是預先劃分好的,也就不會像分段會產生間隙非常小的記憶體,這正是分段會產生記憶體碎片的原因。而採用了分頁,那麼釋放的記憶體都是以頁為單位釋放的,也就不會產生無法給程式使用的小記憶體。

如果記憶體空間不夠,作業系統會把其他正在執行的程式中的「最近沒被使用」的記憶體頁面給釋放掉,也就是暫時寫在硬碟上,稱為換出Swap Out)。一旦需要的時候,再載入進來,稱為換入Swap In)。所以,一次性寫入磁碟的也只有少數的一個頁或者幾個頁,不會花太多時間,記憶體交換的效率就相對比較高。

換入換出換入換出

更進一步地,分頁的方式使得我們在載入程式的時候,不再需要一次性都把程式載入到實體記憶體中。我們完全可以在進行虛擬記憶體和實體記憶體的頁之間的對映之後,並不真的把頁載入到實體記憶體裡,而是只有在程式執行中,需要用到對應虛擬記憶體頁裡面的指令和資料時,再載入到實體記憶體裡面去。

分頁機制下,虛擬地址和實體地址是如何對映的?

在分頁機制下,虛擬地址分為兩部分,頁號頁內偏移。頁號作為頁表的索引,頁表包含物理頁每頁所在實體記憶體的基地址,這個基地址與頁內偏移的組合就形成了實體記憶體地址,見下圖。

記憶體分頁定址記憶體分頁定址

總結一下,對於一個記憶體地址轉換,其實就是這樣三個步驟:

  • 把虛擬記憶體地址,切分成頁號和偏移量;
  • 根據頁號,從頁表裡面,查詢對應的物理頁號;
  • 直接拿物理頁號,加上前面的偏移量,就得到了實體記憶體地址。

下面舉個例子,虛擬記憶體中的頁通過頁表對映為了實體記憶體中的頁,如下圖:

虛擬頁與物理頁的對映虛擬頁與物理頁的對映

這看起來似乎沒什麼毛病,但是放到實際中作業系統,這種簡單的分頁是肯定是會有問題的。

簡單的分頁有什麼缺陷嗎?

有空間上的缺陷。

因為作業系統是可以同時執行非常多的程式的,那這不就意味著頁表會非常的龐大。

在 32 位的環境下,虛擬地址空間共有 4GB,假設一個頁的大小是 4KB(2^12),那麼就需要大約 100 萬 (2^20) 個頁,每個「頁表項」需要 4 個位元組大小來儲存,那麼整個 4GB 空間的對映就需要有 4MB 的記憶體來儲存頁表。

這 4MB 大小的頁表,看起來也不是很大。但是要知道每個程式都是有自己的虛擬地址空間的,也就說都有自己的頁表。

那麼,100 個程式的話,就需要 400MB 的記憶體來儲存頁表,這是非常大的記憶體了,更別說 64 位的環境了。

多級頁表

要解決上面的問題,就需要採用的是一種叫作多級頁表Multi-Level Page Table)的解決方案。

在前面我們知道了,對於單頁表的實現方式,在 32 位和頁大小 4KB 的環境下,一個程式的頁表需要裝下 100 多萬個「頁表項」,並且每個頁表項是佔用 4 位元組大小的,於是相當於每個頁表需佔用 4MB 大小的空間。

我們把這個 100 多萬個「頁表項」的單級頁表再分頁,將頁表(一級頁表)分為 1024 個頁表(二級頁表),每個表(二級頁表)中包含 1024 個「頁表項」,形成二級分頁。如下圖所示:

二級分頁二級分頁

你可能會問,分了二級表,對映 4GB 地址空間就需要 4KB(一級頁表)+ 4MB(二級頁表)的記憶體,這樣佔用空間不是更大了嗎?

當然如果 4GB 的虛擬地址全部都對映到了實體記憶體上的話,二級分頁佔用空間確實是更大了,但是,我們往往不會為一個程式分配那麼多記憶體。

其實我們應該換個角度來看問題,還記得計算機組成原理裡面無處不在的區域性性原理麼?

每個程式都有 4GB 的虛擬地址空間,而顯然對於大多數程式來說,其使用到的空間遠未達到 4GB,因為會存在部分對應的頁表項都是空的,根本沒有分配,對於已分配的頁表項,如果存在最近一定時間未訪問的頁表,在實體記憶體緊張的情況下,作業系統會將頁面換出到硬碟,也就是說不會佔用實體記憶體。

如果使用了二級分頁,一級頁表就可以覆蓋整個 4GB 虛擬地址空間,但如果某個一級頁表的頁表項沒有被用到,也就不需要建立這個頁表項對應的二級頁表了,即可以在需要時才建立二級頁表。做個簡單的計算,假設只有 20% 的一級頁表項被用到了,那麼頁表佔用的記憶體空間就只有 4KB(一級頁表) + 20% * 4MB(二級頁表)= 0.804MB,這對比單級頁表的 4MB 是不是一個巨大的節約?

那麼為什麼不分級的頁表就做不到這樣節約記憶體呢?我們從頁表的性質來看,儲存在記憶體中的頁表承擔的職責是將虛擬地址翻譯成實體地址。假如虛擬地址在頁表中找不到對應的頁表項,計算機系統就不能工作了。所以頁表一定要覆蓋全部虛擬地址空間,不分級的頁表就需要有 100 多萬個頁表項來對映,而二級分頁則只需要 1024 個頁表項(此時一級頁表覆蓋到了全部虛擬地址空間,二級頁表在需要時建立)。

我們把二級分頁再推廣到多級頁表,就會發現頁表佔用的記憶體空間更少了,這一切都要歸功於對區域性性原理的充分應用。

對於 64 位的系統,兩級分頁肯定不夠了,就變成了四級目錄,分別是:

  • 全域性頁目錄項 PGD(Page Global Directory);
  • 上層頁目錄項 PUD(Page Upper Directory);
  • 中間頁目錄項 PMD(Page Middle Directory);
  • 頁表項 PTE(Page Table Entry);
四級目錄四級目錄

TLB

多級頁表雖然解決了空間上的問題,但是虛擬地址到實體地址的轉換就多了幾道轉換的工序,這顯然就降低了這倆地址轉換的速度,也就是帶來了時間上的開銷。

程式是有區域性性的,即在一段時間內,整個程式的執行僅限於程式中的某一部分。相應地,執行所訪問的儲存空間也侷限於某個記憶體區域。

程式的區域性性程式的區域性性

我們就可以利用這一特性,把最常訪問的幾個頁表項儲存到訪問速度更快的硬體,於是電腦科學家們,就在 CPU 晶片中,加入了一個專門存放程式最常訪問的頁表項的 Cache,這個 Cache 就是 TLB(Translation Lookaside Buffer) ,通常稱為頁表快取、轉址旁路快取、快表等。

地址轉換地址轉換

在 CPU 晶片裡面,封裝了記憶體管理單元(Memory Management Unit)晶片,它用來完成地址轉換和 TLB 的訪問與互動。

有了 TLB 後,那麼 CPU 在定址時,會先查 TLB,如果沒找到,才會繼續查常規的頁表。

TLB 的命中率其實是很高的,因為程式最常訪問的頁就那麼幾個。


段頁式記憶體管理

記憶體分段和記憶體分頁並不是對立的,它們是可以組合起來在同一個系統中使用的,那麼組合起來後,通常稱為段頁式記憶體管理

段頁式地址空間段頁式地址空間

段頁式記憶體管理實現的方式:

  • 先將程式劃分為多個有邏輯意義的段,也就是前面提到的分段機制;
  • 接著再把每個段劃分為多個頁,也就是對分段劃分出來的連續空間,再劃分固定大小的頁;

這樣,地址結構就由段號、段內頁號和頁內位移三部分組成。

用於段頁式地址變換的資料結構是每一個程式一張段表,每個段又建立一張頁表,段表中的地址是頁表的起始地址,而頁表中的地址則為某頁的物理頁號,如圖所示:

段頁式管理中的段表、頁表與記憶體的關係段頁式管理中的段表、頁表與記憶體的關係

段頁式地址變換中要得到實體地址須經過三次記憶體訪問:

  • 第一次訪問段表,得到頁表起始地址;
  • 第二次訪問頁表,得到物理頁號;
  • 第三次將物理頁號與頁內位移組合,得到實體地址。

可用軟、硬體相結合的方法實現段頁式地址變換,這樣雖然增加了硬體成本和系統開銷,但提高了記憶體的利用率。


Linux 記憶體管理

那麼,Linux 作業系統採用了哪種方式來管理記憶體呢?

在回答這個問題前,我們得先看看 Intel 處理器的發展歷史。

早期 Intel 的處理器從 80286 開始使用的是段式記憶體管理。但是很快發現,光有段式記憶體管理而沒有頁式記憶體管理是不夠的,這會使它的 X86 系列會失去市場的競爭力。因此,在不久以後的 80386 中就實現了對頁式記憶體管理。也就是說,80386 除了完成並完善從 80286 開始的段式記憶體管理的同時還實現了頁式記憶體管理。

但是這個 80386 的頁式記憶體管理設計時,沒有繞開段式記憶體管理,而是建立在段式記憶體管理的基礎上,這就意味著,頁式記憶體管理的作用是在由段式記憶體管理所對映而成的地址上再加上一層地址對映。

由於此時由段式記憶體管理對映而成的地址不再是“實體地址”了,Intel 就稱之為“線性地址”(也稱虛擬地址)。於是,段式記憶體管理先將邏輯地址對映成線性地址,然後再由頁式記憶體管理將線性地址對映成實體地址。

Intel X86 邏輯地址解析過程Intel X86 邏輯地址解析過程

這裡說明下邏輯地址和線性地址:

  • 程式所使用的地址,通常是沒被段式記憶體管理對映的地址,稱為邏輯地址;
  • 通過段式記憶體管理對映的地址,稱為線性地址,也叫虛擬地址;

邏輯地址是「段式記憶體管理」轉換前的地址,線性地址則是「頁式記憶體管理」轉換前的地址。

瞭解完 Intel 處理器的發展歷史後,我們再來說說 Linux 採用了什麼方式管理記憶體?

Linux 記憶體主要採用的是頁式記憶體管理,但同時也不可避免地涉及了段機制

這主要是上面 Intel 處理器發展歷史導致的,因為 Intel X86 CPU 一律對程式中使用的地址先進行段式對映,然後才能進行頁式對映。既然 CPU 的硬體結構是這樣,Linux 核心也只好服從 Intel 的選擇。

但是事實上,Linux 核心所採取的辦法是使段式對映的過程實際上不起什麼作用。也就是說,“上有政策,下有對策”,若惹不起就躲著走。

Linux 系統中的每個段都是從 0 地址開始的整個 4GB 虛擬空間(32 位環境下),也就是所有的段的起始地址都是一樣的。這意味著,Linux 系統中的程式碼,包括作業系統本身的程式碼和應用程式程式碼,所面對的地址空間都是線性地址空間(虛擬地址),這種做法相當於遮蔽了處理器中的邏輯地址概念,段只被用於訪問控制和記憶體保護。

我們再來瞧一瞧,Linux 的虛擬地址空間是如何分佈的?

在 Linux 作業系統中,虛擬地址空間的內部又被分為核心空間和使用者空間兩部分,不同位數的系統,地址空間的範圍也不同。比如最常見的 32 位和 64 位系統,如下所示:

使用者空間與記憶體空間使用者空間與記憶體空間

通過這裡可以看出:

  • 32 位系統的核心空間佔用 1G,位於最高處,剩下的 3G 是使用者空間;
  • 64 位系統的核心空間和使用者空間都是 128T,分別佔據整個記憶體空間的最高和最低處,剩下的中間部分是未定義的。

再來說說,核心空間與使用者空間的區別:

  • 程式在使用者態時,只能訪問使用者空間記憶體;
  • 只有進入核心態後,才可以訪問核心空間的記憶體;

雖然每個程式都各自有獨立的虛擬記憶體,但是每個虛擬記憶體中的核心地址,其實關聯的都是相同的實體記憶體。這樣,程式切換到核心態後,就可以很方便地訪問核心空間記憶體。

每個程式的核心空間都是一致的每個程式的核心空間都是一致的

接下來,進一步瞭解虛擬空間的劃分情況,使用者空間和核心空間劃分的方式是不同的,核心空間的分佈情況就不多說了。

我們看看使用者空間分佈的情況,以 32 位系統為例,我畫了一張圖來表示它們的關係:

虛擬記憶體空間劃分虛擬記憶體空間劃分

通過這張圖你可以看到,使用者空間記憶體,從低到高分別是 7 種不同的記憶體段:

  • 程式檔案段,包括二進位制可執行程式碼;
  • 已初始化資料段,包括靜態常量;
  • 未初始化資料段,包括未初始化的靜態變數;
  • 堆段,包括動態分配的記憶體,從低地址開始向上增長;
  • 檔案對映段,包括動態庫、共享記憶體等,從低地址開始向上增長(跟硬體和核心版本有關);
  • 棧段,包括區域性變數和函式呼叫的上下文等。棧的大小是固定的,一般是 8 MB。當然系統也提供了引數,以便我們自定義大小;

在這 7 個記憶體段中,堆和檔案對映段的記憶體是動態分配的。比如說,使用 C 標準庫的 malloc() 或者 mmap() ,就可以分別在堆和檔案對映段動態分配記憶體。


總總結結

為了在多程式環境下,使得程式之間的記憶體地址不受影響,相互隔離,於是作業系統就為每個程式獨立分配一套虛擬地址空間,每個程式只關心自己的虛擬地址就可以,實際上大家的虛擬地址都是一樣的,但分佈到實體地址記憶體是不一樣的。作為程式,也不用關心實體地址的事情。

每個程式都有自己的虛擬空間,而實體記憶體只有一個,所以當啟用了大量的程式,實體記憶體必然會很緊張,於是作業系統會通過記憶體交換技術,把不常使用的記憶體暫時存放到硬碟(換出),在需要的時候再裝載回實體記憶體(換入)。

那既然有了虛擬地址空間,那必然要把虛擬地址「對映」到實體地址,這個事情通常由作業系統來維護。

那麼對於虛擬地址與實體地址的對映關係,可以有分段分頁的方式,同時兩者結合都是可以的。

記憶體分段是根據程式的邏輯角度,分成了棧段、堆段、資料段、程式碼段等,這樣可以分離出不同屬性的段,同時是一塊連續的空間。但是每個段的大小都不是統一的,這就會導致記憶體碎片和記憶體交換效率低的問題。

於是,就出現了記憶體分頁,把虛擬空間和物理空間分成大小固定的頁,如在 Linux 系統中,每一頁的大小為 4KB。由於分了頁後,就不會產生細小的記憶體碎片。同時在記憶體交換的時候,寫入硬碟也就一個頁或幾個頁,這就大大提高了記憶體交換的效率。

再來,為了解決簡單分頁產生的頁表過大的問題,就有了多級頁表,它解決了空間上的問題,但這就會導致 CPU 在定址的過程中,需要有很多層表參與,加大了時間上的開銷。於是根據程式的區域性性原理,在 CPU 晶片中加入了 TLB,負責快取最近常被訪問的頁表項,大大提高了地址的轉換速度。

Linux 系統主要採用了分頁管理,但是由於 Intel 處理器的發展史,Linux 系統無法避免分段管理。於是 Linux 就把所有段的基地址設為 0,也就意味著所有程式的地址空間都是線性地址空間(虛擬地址),相當於遮蔽了 CPU 邏輯地址的概念,所以段只被用於訪問控制和記憶體保護。

另外,Linxu 系統中虛擬空間分佈可分為使用者態核心態兩部分,其中使用者態的分佈:程式碼段、全域性變數、BSS、函式棧、堆記憶體、對映區。


嘮嘮叨叨

釋出的 300 頁的「圖解網路」 PDF 也有一段時間了,近期得到了很多讀者的勘誤反饋,大部分都是錯別字和漏字等問題,非常感謝這些細讀 PDF 的讀者。

其中,就有個非常硬核的讀者,把近 9W 字的 PDF 的錯別字都糾正完了。

而且非常的細節,細節到什麼程度呢?細節到多了空格,標點符合不對,“在”和“再”的區別等等。。

給大家看看小林到底寫了多少的 BUG 。。。

那一串串紅色的,就是這位讀者糾正的記錄,再次感謝這位硬核且細心的讀者。說實話,有這樣的讀者,小林還是蠻驕傲的哈哈。

小林也重新整理了 PDF,大家可以重新下載更正後的「圖解網路 V2.0」,在公眾號回覆「網路」就獲取下載地址連結了。

如果大家在閱讀過程中,發現了不理解或有錯誤的地方,歡迎跟小林反饋和交流。

公眾號回覆「網路」獲取下載地址公眾號回覆「網路」獲取下載地址

小林是專為大家圖解的工具人,Goodbye,我們下次見!

相關文章