Linux 內核內存管理:分頁技術的核心原理

你有沒有想過,當你在電腦上同時打開多個程序,一邊聽音樂、一邊寫文檔,還掛着下載任務時,系統是怎麼有條不紊地運作,不讓這些程序 “打架” 爭內存的呢?答案就藏在這神奇的分頁機制裏。它就像是一位擁有超能力的空間魔法師,面對有限的物理內存“空間”,大手一揮,將虛擬的邏輯地址空間和物理內存空間,都精準地劃分成了一塊塊同等大小、排列規整的“魔法方塊”——也就是頁。然後,憑藉着一套如同精密齒輪組般的映射規則,讓每個程序都以爲自己擁有了廣袤無垠的“專屬領地”,實則是在內核的巧妙調配下,高效共享着物理內存資源。

這不僅極大地提升了內存利用率,避免了內存碎片的 “亂象”,更是爲虛擬內存的華麗登場鋪就了堅實大道,讓我們的電腦彷彿擁有了無限擴容的 “超能力”。現在,就請緊跟我的腳步,一起深入探究這分頁機制背後的奇妙故事,解鎖 Linux 內核高效運行的密碼吧!

一、分頁 Paging 簡介

1.1 什麼是分頁?

在 Linux 系統的廣袤天地裏,內存管理猶如一座精密複雜的大廈,而分頁機制則是這座大廈的基石。它就像是一位幕後英雄,默默地支撐着整個系統的穩定運行,讓衆多進程能夠井然有序地共享物理內存資源。

想象一下,在計算機的世界中,衆多進程如同一個個活躍的 “居民”,它們都渴望擁有自己的內存空間來存儲數據、運行程序。然而,物理內存的容量是有限的,就好比一塊有限大小的土地,如何合理地分配給這些 “居民”,讓它們都能安居樂業呢?這便是分頁機制大展身手的時刻。

Linux 內核通過分頁機制,將物理內存劃分爲一個個固定大小的 “小房間”,這些 “小房間” 被稱爲頁。一般來說,常見的頁大小爲 4KB 或 8KB,當然,在不同的硬件架構和系統配置下,頁大小可能會有所不同。與此同時,虛擬內存空間也被按照相同的頁大小進行劃分。這樣一來,虛擬內存中的每一頁都能找到與之對應的物理內存頁,就像給每個 “居民” 都分配了一個專屬的小房間,它們通過 “門牌號”(地址映射)就能準確無誤地找到自己的家。

這種分頁的方式帶來了諸多好處。一方面,它極大地簡化了內存管理的複雜度。內核只需關注這些固定大小的頁,而無需對每一個字節的內存進行精細管理,大大減輕了內核的負擔,就如同小區管理員只需管理一個個房間,而不用操心房間裏每一塊磚的擺放。另一方面,分頁使得內存的分配與回收變得更加高效。當一個進程需要內存時,內核可以輕鬆地分配若干個連續或不連續的頁給它;當進程結束後,回收這些頁也變得輕而易舉,不會留下混亂的內存碎片,保證了內存空間的整潔有序,就像整理房間一樣,把不用的東西清理出去,爲新的需求騰出空間。

再者,分頁機制爲內存的保護與共享提供了堅實的保障。每個進程都擁有自己獨立的頁表,這就好比每個 “居民” 都有一把獨一無二的鑰匙,只能打開自己房間的門,從而有效地隔離了不同進程的內存空間,防止一個進程誤闖另一個進程的 “領地”,保障了系統的安全性。而且,通過巧妙的頁表映射,多個進程還能共享同一段物理內存,就像幾個朋友可以一起在客廳裏玩耍,共享公共空間,實現了資源的高效利用,提升了系統的整體性能。

1.2 爲什麼會有分頁機制?

如果沒有分頁機制,能否實現 “虛擬內存”?答案是肯定的。

當同時運行的任務很多時,內存可能就不夠用,如上圖所示,每個段描述符都有 AVL 位(簡稱 A 位),用於表示一個段最近是否被訪問過(準確地說是表明從上次操作系統清零該位後一個段是否被訪問過)。

當創建描述符的時候,應該把 A 位清零。之後,每當該段被訪問時,準確地說是處理器把這個段的段選擇符加載進段寄存器時,CPU 就將該位置 “1”;對該位的清零是由操作系統負責的,通過定期監視該位的狀態,就可以統計出該段的使用頻率(比如,每 1 秒鐘查看一次,一旦置位就清零,統計 10 秒鐘內被置位了多少次,次數越多說明使用越頻繁)。當內存空間緊張時,可以把不經常使用的段退避到硬盤上,從而實現虛擬內存管理。

當某個段被換出到磁盤時,操作系統應該將這個段的描述符的 P 位清零。過上一段時間,當再次訪問這個段時,因爲它的描述符的 P 位是 0,處理器就會引發段不存在異常(中斷號 11)。這類中斷通常是由操作系統處理的,它會用同樣的方法騰出空間,然後把這個段從磁盤調入內存。當這類中斷返回時,處理器會再次執行引發異常的那條指令,這時候段已經在內存中(P=1),於是程序又可以繼續執行了。

由此可見,即使沒有分頁機制,利用 “分段” 也可以實現“虛擬內存”。但是,因爲段的長度不固定,在段的換入換出時會產生外部碎片,這樣就浪費了很多內存。爲了解決這個問題,從 80386 處理器開始,引入了分頁機制。分頁機制簡單來說,是用長度固定的頁來代替長度不定的段,以解決因段的長度不同帶來的內存空間管理變得複雜的問題。儘管操作系統也可以利用純軟件來實施固定長度的內存分配,但是過於複雜。由處理器固件來做這件事情,可以省去很多麻煩,速度也可以提高。總結一下,引入分頁機制並不是爲了實現虛擬內存,而是爲了解決內存碎片的問題。

二、分頁的核心構成:頁表的神奇架構

2.1 頁表 —— 虛擬與物理的橋樑

在深入瞭解分頁機制時,頁表無疑是其中最爲關鍵的核心部件,它宛如一座神奇的橋樑,穩穩地架設在虛擬內存和物理內存之間,實現了二者之間的精準映射。

當進程運行時,它所使用的是虛擬地址,這些虛擬地址就像是一系列抽象的 “房間編號”,進程憑藉這些編號去訪問內存。然而,實際的數據存儲在物理內存中,物理內存有着自己真實的 “房間佈局”。此時,頁表就發揮作用了,它記錄着每一個虛擬頁號與對應的物理頁框號之間的映射關係,就如同一張詳細的 “房間對照表”。

舉個例子,假設一個進程發出了對虛擬地址 0x1234 的訪問請求,系統首先會將這個虛擬地址按照既定的頁大小規則,拆分成頁號和頁內偏移量。比如,頁大小爲 4KB(2^12 字節),0x1234 對應的二進制爲 0001 0010 0011 0100,前幾位表示頁號,後 12 位表示頁內偏移。接着,通過查詢頁表,找到與該頁號對應的物理頁框號,假設是 0x56,再結合頁內偏移量,就能準確地定位到物理內存中的實際存儲位置,完成數據的讀取或寫入操作,整個過程就像根據房間編號在對照表中找到真實房間位置一樣精準高效。

而且,頁表不僅僅是簡單的地址映射,它還包含了豐富的控制信息。每個頁表項通常會設置一些標誌位,如讀寫權限位,用來控制進程對該頁的訪問模式,防止進程誤操作或惡意篡改數據;還有存在位,用於標識該頁當前是否已經加載到物理內存中,若不存在,則可能觸發缺頁異常,促使內核進行相應的頁面調度操作,確保進程的順利運行。可以說,頁表以其精妙的設計,保障了內存訪問的準確性、安全性以及系統的穩定性,是分頁機制得以順暢運行的關鍵樞紐。

每級頁表由多個表項組成,頁表可以看做一個數組,而表項則是數組中的元素。各級頁表的表項格式比較類似,但並不完全相同。在 4 級分頁下,各級表項的名稱如下:

其中,頁目錄指針表項(3 級頁表項)和 頁目錄項(2 級頁表項)可以直接映射到頁,也可以引用下級頁表。根據不同的映射情況,它們各自又有 2 種不同的格式。但頁目錄指針表項(3 級頁表項)直接映射到頁時,頁的大小爲 1GB,這種大頁很少使用,而且不是所有處理器都支持,所以我們不做介紹。

在各級表項中,除了包含下級頁表的物理地址之外,還有各種標誌位,我們稱之爲頁標誌或者頁屬性。直接映射到頁的表項和引用了其它頁表的表項,其標誌位是不同的。

當表項直接映射到頁時,它啓用了 Dirty 標誌位(髒位,位 6)、Global 標誌位(全局標誌位,位 8)以及 PAT 標誌位( Page Attribute Table,位 7 或 位 12);否則,這 3 個標誌位被保留不使用。另外,當表項直接映射到頁時,PAT 標誌位在表項的位置也不相同。當表項映射到 4KB 的頁時(1 級頁表項),PAT 標誌位在第 7 位,該表項沒有 PS 位;當表項映射到 2MB (2 級頁表項)或 1 GB (3 級頁表項)的頁時,PAT 標誌位在第 12 位。

各標誌位說明如下:

2.2 多級頁表的進階之路

隨着計算機系統的不斷髮展,內存容量日益增大,程序對內存的需求也愈發複雜,傳統的單級頁表逐漸暴露出一些侷限性。想象一下,在一個 32 位的系統中,虛擬地址空間高達 4GB,若以 4KB 爲一頁大小進行計算,那麼需要的頁表項數量將多達 100 多萬個(2^20)。每個頁表項佔用一定的內存空間,如此龐大數量的頁表項,所佔用的內存開銷將是巨大的,這無疑是一種資源的浪費,就好比爲了管理一個大型倉庫,準備了一份極其冗長且大部分區域爲空置的物品清單,耗費了大量紙張卻沒有充分發揮作用。

爲了解決這一問題,多級頁表應運而生,它猶如一套精心設計的多層索引系統,爲大規模內存管理帶來了新的曙光。以常見的二級頁表爲例,虛擬地址被劃分爲三個部分:頁目錄索引、頁表索引和頁內偏移。最頂層的是頁目錄,它就像是一本總目錄,將整個虛擬地址空間劃分爲若干個較大的區域,每個區域對應一個頁目錄項,這些頁目錄項指向第二層的頁表。而第二層的頁表,才真正詳細記錄着虛擬頁與物理頁框的映射關係,如同在總目錄下細分的各個子目錄,精準指向具體的物品存放位置。

當進程訪問一個虛擬地址時,首先根據頁目錄索引,在頁目錄中找到對應的頁目錄項,獲取到指向頁表的指針;接着,依據頁表索引,在相應的頁表中查找具體的頁表項,從而得到物理頁框號;最後,結合頁內偏移,就能準確無誤地定位到物理內存中的目標數據。這種分層的結構,使得頁表的存儲變得更加靈活高效。對於那些尚未被使用的虛擬地址區域,對應的二級頁表可以暫不創建,只有當進程實際訪問到相關區域時,才按需創建頁表,大大減少了內存的不必要佔用,就像只有當需要查看某個子目錄下的物品時,纔去詳細構建該子目錄,避免了一開始就準備所有可能用到的詳細清單,節省了大量的紙張(內存)。

在 64 位系統中,甚至會採用更多層級的頁表,如四級頁表,進一步細化內存管理粒度,以適應更爲龐大的虛擬地址空間需求。多級頁表的出現,充分展現了計算機系統設計的智慧,在滿足內存高效管理需求的同時,最大限度地優化了資源利用,爲現代操作系統的穩定高效運行提供了堅實保障。

三、虛擬內存佈局

x86_64 架構下,虛擬內存中屬於內核空間的各內存區域,其起始地址、空間大小、用途都是預先設計好的。4 級分頁下,內存佈局如下所示:

// file: Documentation/x86/x86_64/mm.txt
 Virtual memory map with 4 level page tables:
 
 0000000000000000 - 00007fffffffffff (=47 bits) user space, different per mm
 hole caused by [48:63] sign extension
 ffff800000000000 - ffff80ffffffffff (=40 bits) guard hole
 ffff880000000000 - ffffc7ffffffffff (=64 TB) direct mapping of all phys. memory
 ffffc80000000000 - ffffc8ffffffffff (=40 bits) hole
 ffffc90000000000 - ffffe8ffffffffff (=45 bits) vmalloc/ioremap space
 ffffe90000000000 - ffffe9ffffffffff (=40 bits) hole
 ffffea0000000000 - ffffeaffffffffff (=40 bits) virtual memory map (1TB)
 ... unused hole ...
 ffffffff80000000 - ffffffffa0000000 (=512 MB)  kernel text mapping, from phys 0
 ffffffffa0000000 - ffffffffff5fffff (=1525 MB) module mapping space
 ffffffffff600000 - ffffffffffdfffff (=8 MB) vsyscalls
 ffffffffffe00000 - ffffffffffffffff (=2 MB) unused hole

其中,地址 0x0000 7FFF FFFF FFFF - 0x0000 7FFF FFFF FFFF 共 128T(47 位),屬於用戶空間;地址 0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 共 128T(47 位), 屬於內核空間。示意圖如下:

這裏我們重點關注下物理內存直接映射區和內核代碼映射區。物理內存直接映射區,虛擬地址區間爲 0xFFFF 8800 0000 0000 - 0xFFFF E900 0000 0000, 共 64T 大小。Linux 內核會把所有的物理內存映射到該虛擬地址區間。內核定義了宏 __PAGE_OFFSET 以及 PAGE_OFFSET,用來表示該區間的起始地址:

// file: arch/x86/include/asm/page_64_types.h
 #define __PAGE_OFFSET           _AC(0xffff880000000000, UL)
 // file: arch/x86/include/asm/page_types.h
 #define PAGE_OFFSET     ((unsigned long)__PAGE_OFFSET)

該區間內的地址減去 __PAGE_OFFSET,就可以得到對應的物理地址。內核代碼映射區,虛擬地址區間爲 0xFFFF FFFF 8000 0000 - 0xFFFF FFFF A000 0000,共 512M 大小。該區域用於映射內核代碼段、數據段、bss 段等內容。內核定義了宏 __START_KERNEL_map 來表示該區間的起始地址:

// file: arch/x86/include/asm/page_64_types.h
 #define __START_KERNEL_map  _AC(0xffffffff80000000, UL)

同理,該區域內的地址減去 __START_KERNEL_map 後,就能得到對應的物理地址。

四、分頁實戰:內存分配與回收藝術

4.1 內存分配的精細策略

在 Linux 內核的實際運行過程中,內存分配就像是一場精心策劃的資源調配行動,每一個步驟都蘊含着智慧與巧思。當進程向內核發出內存請求時,內核便依據既定的規則,有條不紊地從物理內存這片 “資源寶庫” 中選取合適的頁進行分配。

內核分配內存時,通常以頁爲基本單位進行操作。對於一些小型的數據結構或臨時變量,它們所需的內存空間往往遠小於一頁的大小,此時內核並不會直接分配一整頁,而是採用一種更爲精妙的策略 ——slab 分配器。slab 分配器就像是一位精打細算的管家,它預先將頁劃分爲多個大小固定的對象池,每個對象池存放着相同類型、相同大小的對象。當需要分配內存時,它能快速地從對應的對象池中取出一個空閒對象,就像從裝滿相同規格零件的盒子裏拿出一個零件一樣便捷高效,大大減少了內存碎片化的風險,同時提高了內存分配的速度。

而對於那些較大的內存需求,比如進程加載一個大型的動態鏈接庫或者運行一個內存密集型的應用程序,內核則會直接分配連續的多個頁。這種分配方式就像是爲大型項目預留一整片連續的場地,確保數據的存儲和訪問能夠高效、順暢地進行。在內核的內存分配代碼中,有着嚴謹的邏輯判斷,它會根據請求內存的大小、當前內存的使用狀況以及系統的性能需求等多方面因素,綜合考量選擇最優的分配方案,確保每一頁內存都能物盡其用,爲系統的穩定運行提供堅實保障。

4.2 內存回收的權衡之道

內存回收是內存管理中的另一項關鍵任務,它就像是一場及時雨,在內存資源緊張時爲系統帶來生機。當系統運行一段時間後,隨着進程的不斷創建與銷燬,內存中的頁面使用情況變得愈發複雜,一些頁面可能長時間未被使用,佔用着寶貴的內存空間,此時就需要啓動內存回收機制。

內存回收的觸發條件多種多樣。一方面,當內核檢測到空閒內存的數量低於某個預設的閾值時,就如同水庫水位降至警戒線以下,系統會立即啓動內存回收程序,確保有足夠的內存可供後續的進程使用。這個閾值的設定並非一成不變,它會根據系統的配置、運行負載等因素動態調整,就像根據不同季節、不同用水量靈活調整水庫的警戒水位一樣,以達到最佳的資源利用效果。

另一方面,在一些特定的場景下,如系統進入休眠狀態或者進行大規模的內存密集型任務切換時,爲了保證系統的平穩過渡,也會提前觸發內存回收,釋放不必要的內存佔用,爲關鍵任務騰出空間。

在內存回收的過程中,頁面置換算法起着核心作用。常見的頁面置換算法有先進先出(FIFO)算法、最近最少使用(LRU)算法以及時鐘(Clock)算法等,它們就像是不同風格的 “管家”,各有其獨特的管理策略。

先進先出算法遵循着最樸素的原則,認爲最先進入內存的頁面最有可能是最早不再被使用的,就像排隊時先到的人先離開一樣,它將內存中的頁面按照進入的先後順序排成一個隊列,當需要置換頁面時,總是淘汰隊首的頁面。然而,這種算法在實際應用中存在一定的侷限性,因爲有些頁面雖然最先進入內存,但可能在後續的運行過程中仍然頻繁被訪問,此時若按照先進先出的原則將其置換出去,容易導致頻繁的頁面調入調出,降低系統性能,這種現象被稱爲 Belady 異常。

最近最少使用算法則顯得更加 “智能”,它基於一種局部性原理,認爲如果一個頁面在過去一段時間內長時間未被訪問,那麼在未來的短期內它也不太可能被訪問。所以,LRU 算法會爲每個頁面記錄一個訪問時間戳或者使用次數,當需要置換頁面時,選擇那個訪問時間最久遠或者使用次數最少的頁面淘汰出去,就像清理倉庫時先清理那些長時間未動過的物品一樣。這種算法在大多數情況下能夠較好地反映頁面的實際使用情況,減少不必要的頁面置換,提高系統性能,但它需要額外的硬件或軟件開銷來記錄頁面的訪問信息。

時鐘算法是一種結合了先進先出算法和近似 LRU 算法思想的折衷方案。它將內存中的頁面看作是時鐘錶盤上的一個個刻度,每個頁面都有一個與之對應的引用位,就像時鐘指針走過的刻度會被標記一樣。當頁面被訪問時,其引用位被置爲 1。在進行頁面置換時,時鐘指針從當前位置開始順序掃描頁面,遇到引用位爲 0 的頁面就將其置換出去,若遇到引用位爲 1 的頁面,則先將其引用位清零,然後繼續掃描,直到找到一個可置換的頁面爲止。這種算法在一定程度上模擬了 LRU 算法的行爲,同時避免了 LRU 算法中記錄精確訪問時間戳所帶來的高開銷,以相對較低的成本實現了較爲合理的頁面置換策略。

不同的頁面置換算法在不同的應用場景下各有優劣,Linux 內核會根據系統的實時運行狀態、硬件配置以及性能需求等因素,靈活選擇或組合使用這些算法,力求在內存回收的過程中達到最佳的平衡,確保系統能夠在有限的內存資源下穩定、高效地運行。

五、分頁優勢盡顯:系統性能飆升的密碼

5.1 內存利用率的飛躍

分頁機制猶如一位神奇的魔法師,將原本可能雜亂無章、碎片化嚴重的內存空間,變得井然有序,極大地提升了內存的利用率。在未引入分頁之前,內存分配常常面臨着內碎片和外碎片的困擾。內碎片就像是一個個藏在角落裏難以利用的 “小角落”,當程序所需內存大小不是分配單元(如分區)的整數倍時,剩餘的那部分空間就白白浪費了,如同買了一大盒月餅,最後剩下幾個零散的小格子裝不滿東西。外碎片則更像是散落在各處的 “拼圖碎片”,隨着程序的頻繁加載與卸載,內存中會出現許多不連續的小空閒塊,當有新程序需要一塊較大的連續內存時,儘管空閒內存的總量足夠,卻因這些碎片分散各處而無法滿足需求,就像拼圖時發現有很多小塊,但就是湊不出一塊完整的大區域。

分頁機制的出現徹底改變了這一局面。它將內存劃分成固定大小的頁,無論是分配還是回收內存,都以頁爲基本單位進行操作。這就好比把一個大倉庫分割成規格統一的小儲物格,每個儲物格都能被精準管理和高效利用。當程序需要內存時,內核按照頁的粒度進行分配,即使程序大小不是頁大小的整數倍,浪費的空間也僅僅是最後一頁的一小部分,相較於之前的分區分配方式,內碎片的問題得到了有效緩解。而且,由於頁的大小固定,內存中空閒頁的分佈變得清晰明瞭,內核可以通過一些巧妙的算法(如夥伴系統)將相鄰的空閒頁合併成更大的空閒塊,或者快速找到滿足需求的空閒頁組合,使得外碎片問題也迎刃而解,讓內存的每一寸 “土地” 都能得到充分開墾。

據相關數據統計,在一些複雜的服務器應用場景中,引入分頁機制後,內存利用率相較於傳統的連續內存分配方式提升了 30% - 50%,這意味着系統能夠承載更多的服務任務,爲用戶提供更流暢的體驗,如同將原本狹窄擁擠的道路拓寬,讓車輛(進程)能夠更加順暢地通行。

5.2 進程隔離的安全護盾

分頁機制爲進程之間構建了一道堅不可摧的安全護盾,確保每個進程都能在自己的獨立 “領地” 內安穩運行,互不干擾,爲系統的穩定與安全奠定了堅實基礎。

在多進程併發執行的操作系統環境中,如果沒有有效的內存隔離機制,就如同多個家庭住在一個沒有牆壁分隔的大房子裏,彼此的生活空間完全暴露,極易引發混亂。一個進程可能會不小心誤讀到另一個進程的數據,甚至惡意篡改其他進程的內存內容,導致系統崩潰、數據丟失等嚴重後果。

分頁機制通過爲每個進程創建獨立的頁表,巧妙地解決了這一難題。每個進程都擁有自己的虛擬地址空間,這些虛擬地址通過各自的頁表映射到物理內存的不同頁框上,就像每個家庭都有獨立的房間佈局圖(頁表),依據這張圖,他們只能進入自己家的房間(物理內存頁),而無法闖入鄰居家。即使兩個進程在虛擬地址空間中使用了相同的地址,經過頁表的轉換,它們所對應的物理內存位置也是截然不同的,如同兩家的房間號可能一樣,但實際所處的物理位置(樓層、朝向等)卻大相徑庭,從根本上杜絕了進程間相互干擾的可能性。

以常見的網絡服務器爲例,它需要同時處理來自衆多客戶端的請求,每個請求都會觸發一個獨立的進程進行處理。分頁機制確保了這些進程在內存層面的獨立性,即使某個進程因遭受惡意攻擊而出現內存異常,也不會波及其他正常運行的進程,服務器整體仍能穩定地爲其他客戶端提供服務,保障了系統的高可用性與安全性,就像一座堅固的城堡,各個房間(進程)相互獨立,即使一間屋子着火(某個進程出錯),也不會蔓延到整個城堡(系統崩潰),爲城堡裏的居民(其他進程)提供了可靠的庇護。

六、Linux 內核分頁:持續進化的傳奇

回首分頁機制的發展歷程,從早期簡單的應對內存管理困境,到如今多級頁表、複雜頁面置換算法等精妙設計的呈現,它始終緊扣時代的脈搏,與計算機硬件的飛速發展、軟件應用的日益繁雜同頻共振。每一次的升級與優化,都像是爲 Linux 系統注入了一股新的活力,讓它能夠從容應對內存管理中的各種挑戰。

在未來,隨着人工智能、大數據、雲計算等前沿技術的持續突破,Linux 內核的分頁機制必將踏上新的征程。或許,在量子計算時代的浪潮下,我們將見證全新的量子分頁算法,以超乎想象的速度處理海量數據的內存映射;又或是在物聯網的廣袤天地中,分頁機制將進一步適配微型設備的超低功耗、小內存需求,以極小的資源開銷保障設備的穩定運行。

分頁機制作爲 Linux 內核內存管理的中流砥柱,已然鑄就了無數輝煌,而它的傳奇故事仍在繼續書寫,不斷推動着 Linux 系統向着更高性能、更強大功能的巔峯奮勇攀登,持續爲全球無數用戶與開發者賦能,開啓更爲絢爛的科技新篇章。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-WyaO-z0DxeMqAo-GRf1pg