[譯] C程式設計師該知道的記憶體知識 (1)

felix021發表於2020-05-02

上篇 《踩坑記:go服務記憶體暴漲》好像還挺受歡迎的。儘管文中的核心內容很少,但是為了讓大多數人能讀懂,中間花了很大的篇幅來解釋。

儘管如此,我仍然覺得講得不夠透,思來想去覺得還是文中提到的《What a C programmer should know about memory》[1]講得好,想借著假期翻譯一下,也藉機再學習一遍(順便練習英文)。

內容有點長,我會分成幾篇推送,感興趣的話請關注這個號。

以下是正文。

C程式設計師應該知道的記憶體知識

2007年,Ulrich Drepper 大佬寫了一篇“每個程式設計師都應該知道的記憶體知識”[2],特別長,但乾貨滿滿。

但過去了這麼多年(譯註:原文寫於2015年2月),“虛擬記憶體”這個概念對很多人依然很迷,就像某種魔法。呃,實在是忍不住引用一下(譯註:應該是指皇后樂隊的 A Kind of Magic)。

即使是該文的正確性,這麼多年以後仍然被質疑[3](譯註:有人在stackoverflow上提問文中內容有多少還有效)。出了什麼事?

北橋?這是什麼鬼?這可不是街頭械鬥。

(譯註:北橋是早年電腦主機板上的重要晶片,用來處理來自CPU、記憶體等裝置的高速訊號)

我會試著展現學習這些知識的實用性(即你可以做什麼),包括“學習鎖的基本原理”,和更多有趣的東西。你可以把這當成那篇文章和你日常使用的東西之間的膠水。

文中例子會使用 Linux 下的 C99 來寫(譯註:1999年版的c語言標準),但很多主題都是通用的。注:我對 Windows 不太熟悉,但我會很高興附上能解釋它的文章連結(如果有的話)。我會盡量標註哪些方法是特定平臺相關的。但我只是個凡人,如果你發現有出入,請告訴我。

理解虛擬記憶體 - 錯綜複雜

除非你在處理某些嵌入式系統或核心空間程式碼,否則你會在保護模式下工作。(譯註:指的是x86 CPU提出的保護模式,通過硬體提供的一系列機制,作業系統可以用低許可權執行使用者程式碼)。這太棒了,你的程式可以有獨立的 [虛擬] 地址空間。“虛擬”這個詞在這裡很重要。這表示,包括其他一些情況,你不會被可用記憶體限制住,但也沒有資格使用任何可用記憶體。想用這個空間,你得找OS要一些真東西來做“裡子”,這叫對映(mapping)。這個裡子(backing)可以是實體記憶體(並不一定需要是RAM),或者持久儲存(譯註:一般指硬碟 )。前者被稱為“匿名對映”。別急,馬上講重點。

虛擬記憶體分配器(VMA,virtual memory allocator)可能會給你一段並不由他持有的記憶體,並且徒勞地希望你不去用它。就像如今的銀行一樣(譯註:應該是指銀行存款)。這被稱為 overcomiting[4](譯註:指允許申請超過可用空間的記憶體),有一些正當的應用有這種需求(例如稀疏陣列),這也意味著記憶體分配不會簡單被拒絕。

char *block = malloc(1024 * sizeof(char));
if (block == NULL) {
    return -ENOMEM; /* sad :( */
}

檢查 NULL 返回值是個好習慣,但已經沒有過去那麼強大了。由於 overcommmit 機制的存在,OS可能會給你的記憶體分配器一個有效的指標,但是當你要訪問它的時候 —— 鐺*。這裡的“”是平臺相關的,但是通常表現為你的程式被 OOM Killer [5]幹掉。(譯註:OOM 即 Out Of Memory,當記憶體不足時,Linux會根據一定規則挑出一個程式殺掉,並在 dmesg 裡留下記錄)

—— 這裡有點過度簡化了;在後面的章節裡有進一步的解釋,但我傾向於在鑽研細節之前先過一遍這些更基礎的東西。

程式的記憶體佈局

程式的記憶體佈局在 Gustavo Duarte 的《Anatomy of a Program in Memory》[6] 裡解釋得很好了,所以我只引用原文,希望這算是合理使用。我只有一些小意見,因為該文只介紹了 x86-32 的記憶體佈局,不過還好 x86-64 變化不大,除了程式可以用大得多的空間 —— 在 Linux 下高達 48 位。

linuxFlexibleAddressSpaceLayout.png
(來源:Linux地址空間佈局 - by Gustavo Duarte)

譯註:針對上圖加一些解釋備查

  1. 圖中顯示的地址空間是由高到低,0x00000000在底部,最高0xFFFFFFFF,一共4GB(2^32)。
  2. 高位的1GB是核心空間,使用者程式碼 不能 讀寫,否則會觸發段錯誤。圖右側標註的 0xC0000000 即 3GB;TASK_SIZE 是Linux核心編譯配置的名稱,表示核心空間的起始地址。
  3. Random stack offset:加上隨機偏移量以後可以大幅降低被棧溢位攻擊的風險。
  4. Stack(grows down): 程式的棧空間,向下增長,棧底在高位地址,PUSH指令會減小CPU的SP暫存器(stack pointer)。圖右側的 RLIMIT_STACK 是核心對棧空間大小的限制,一般是8MB,可以用 setrlimit 系統呼叫修改。
  5. Memory Mapping Segment:記憶體對映區,通過mmap系統呼叫,將檔案對映到程式的地址空間(包括 libc.so 這樣的動態庫),或者匿名對映(不需要對映檔案,讓OS分配更多有裡子的地址空間)。
  6. Heap:我們常說的堆空間,從下往上增長,通過brk/sbrk系統呼叫擴充套件其上限
  7. BSS段:包含未初始化的靜態變數
  8. Data段:程式碼裡靜態初始化的變數
  9. Text段(ELF):程式的可執行檔案(機器碼)
  10. 這裡說的段(segment)的概念,源於x86 cpu的段頁式記憶體管理

圖中也展示了 記憶體對映段(memory mapping segment, MMS)是向下增長的,但並不總是這樣。MMS通常(詳見Linux 核心程式碼 x86/mm/mmap.c:113 和 arch/mm/mmap.c:1953)開始於棧的最低地址(譯註:即棧底)以下的某個隨機地址。注意是“通常”,因為它也可能在棧的上方 ,如果棧空間限制很大(或無限;譯註:可用setrlimit修改),或者啟用了相容佈局。這一點有多重要?——不重要,但可以讓你瞭解到自由地址範圍(free address ranges)。

在上圖中,你可以看到3個不同的變數存放區:程式的資料段(靜態儲存,或堆記憶體分配),記憶體對映段,和棧。我們從這裡開始。

 理解棧上的記憶體分配

裝備箱:

  • alloca() - 在呼叫方的棧幀上分配記憶體
  • getrlimit() - 獲取/設定 resource limits
  • sigaltstack() - 設定或獲取訊號棧上下文

棧相對比較容易理解,畢竟每個人都知道如何在棧上放一個變數,對吧 ?比如:

int stairway = 2;
int heaven[] = { 6, 5, 4 };

變數的有效性受到作用域的限制。在 C 裡,作用域指的就是一對大括號 {}。因此每次遇到一個右大括號,對應的變數作用域就結束了。

然後是 alloca(),在當前 棧幀上動態分配記憶體。棧幀和記憶體幀(也叫做物理頁)不太一樣,它只是一組被壓到棧上的資料(函式,引數,變數等)。由於我們在棧頂(譯註:SP暫存器總是指向棧頂),我們可以使用剩下的棧空間,只要不超過棧大小限制。

這就是變長陣列(variable-length,VLA)和 alloca 的原理,區別在於 ,VLA受限於作用域,alloca分配的記憶體的有效性可以持續到當前函式返回。這裡沒有語言律師業務(譯註:沒人管你,愛咋咋地),但如果你在迴圈裡用alloca可能會踩坑,因為你沒辦法釋放它分配的空間:

void laugh(void) {
    for (unsigned i = 0; i < megatron; ++i) {
        char *res = alloca(2);
        memcpy(res, "ha", 2);
        char vla[2] = {'h','a'}
    } /* vla dies, res lives */
} /* all allocas die */

如果要申請大量記憶體,VLA和alloca都不太好使,因為你幾乎無法控制可用的棧空間,如果分配記憶體超過棧限制,就會遇到令人喜聞樂見的stack overflow。有兩種辦法可以繞過它,但都不太實用:

第一種是用  sigaltstack() 來捕獲並處理 SIGSEGV 訊號,但這隻能讓你捕獲棧溢位(譯註:程式仍然無法獲得所需的記憶體)。

另一種是編譯時指定“split-stacks”,這會將一個大的stack分割成用連結串列組織的“棧碎片”(stacklet)。就我所知,GCC 和 clang 編譯器可以用 -fsplit-stasck 選項來啟用這個特性。理論上這會改善記憶體消耗,並降低建立執行緒的開銷,因為剛開始的時候棧可以很小,並按需擴充套件。但實際上可能會遇到相容問題,因為這需要一個支援 split-stack 的連結器(例如 gold;譯註:這是GNU的ELF連結器,不同於我們常用的連結器 ld,針對ELF連結效能更好)、而這是對庫透明的,還可能有效能問題,例如 Go 的 hot-split 問題,在 Agis Anastasopoulos 的這篇文章[7] 中有詳細解釋。(譯註:Go 1.3 之前用 split stack,即前述用連結串列串起來的棧,在某些情況可能因反覆的棧擴充套件和收縮帶來效能問題;1.3 開始改成使用連續的棧空間,空間不夠時重新分配、拷貝內容、修改指向棧空間的指標,因此也要求編譯器能準確分析指標逃逸的情況)



休息一下,第一篇就到這裡。

下一篇接著翻譯下一節 Understanding  heap allocation,感興趣的記得關注,等不及的推薦閱讀原文。

順便再貼下之前推送的幾篇文章,祝過個充實的五一假期~

歡迎關注
weixin1.png


參考連結:

[1] What a C programmer should know about memory
https://marek.vavrusa.com/mem...

[2] What every programmer should know about memory
http://www.akkadia.org/dreppe...

[3] stackoverflow.com - What Every Programmer Should Know About Memory?
https://stackoverflow.com/que...

[4] Kernel - overcommit accounting
https://www.kernel.org/doc/Do...

[5] Linux - Overcommit and OOM
https://www.win.tue.nl/~aeb/l...

[6] anatomy of a program in memory
http://duartes.org/gustavo/bl...

[7] Contiguous stacks in Go
http://agis.io/2014/03/25/con...

相關文章