刨一刨核心 container_of() 的設計精髓

發表於2016-11-27

新年第一帖,總得拿出點乾貨才行,雖然這篇水分還是有點大,大家可以曬幹了溫水沖服。這段時間一直在整理核心學習的基礎知識點,期間又碰到了container_of()這個巨集,當然還包括一個叫做offsetof()的傢伙。在這兩個巨集定義裡都出現將“零”地址強轉成目標結構體型別,然後再訪問其成員屬性的情形。如果有童鞋看過我之前的博文《Segmentation fault到底是何方妖孽》的話,估計此時心裡會犯嘀咕:不是說0地址不可以訪問麼,那container_of()和offsetof()巨集定義裡用0時怎麼沒報錯呢?到底該TM如何理解“零”地址?結構體被編譯時有沒有什麼貓膩呢?程式到底是如何訪問結構體裡的每個成員屬性的?本篇,我們就來聊聊這幾個問題。

先從核心巨集定義container_of()入手:

這個巨集定義我們已經不止一次遇到過,相信大家對其作用和用法已經瞭解了(啥玩意?不瞭解,那就猛擊這裡)。今天我們主要探究的是container_of()的實現原理相關層面的技術細節。要說清container_of()還是得先過了offsetof()這關才行:

關於這行程式碼你要是到網上去搜,百分之99%的答案都是:將零地址強制轉換成目標結構體型別TYPE,然後訪問其成員屬性MEMBER,就得到了該成員在其宿主結構體裡的偏移量(按位元組計算)。當然,人家這個回答也無可厚非,也不能說人家錯,可為什麼0地址能被這樣用呢?編譯器就不報錯?OK,先讓我們看一個簡單的例子:

其中第三行程式碼是取消編譯預設的結構體對齊優化,這樣一來Student結構體所佔記憶體空間大小為37位元組。執行結果如下:

刨一刨核心 container_of() 的設計精髓

我們可以看到,Student結構體物件stu裡的三個成員屬性的地址,按照我們的預期進行排列的(-_-|| 這TM不廢話麼,難道還倒著排不成)。此時我們知道stu物件的地址是個隨機值,每次執行的時候都會變,但是無論怎麼變stu.sex的地址永遠和stu的地址是一致:

刨一刨核心 container_of() 的設計精髓

我們來反彙編一下可執行程式test:

刨一刨核心 container_of() 的設計精髓

如果你對AT&T的組合語言不是很熟悉,建議先看一下我的另外一篇博文《深入理解C語言的函式呼叫過程 》。上面的反彙編程式碼已經和C原始碼關聯起來了,注意看第20行反彙編程式碼“lea 0x1b(%esp),%edx”,用lea指令將esp向高地址偏移27位元組的地址,也就是棧空間上stu的地址裝載到edx暫存器裡,lea指令的全稱是load effective address,所以該指令是將要操作的地址裝載到目標暫存器裡。另外,我們看到,在列印stu.age地址時,第26行也裝載的是 0x1b(%esp)地址;列印stu.age時,注意第32、33行程式碼,因為棧是向高地址增長的,所以age的地址比stu.sex的地址值要大,這裡在編譯階段編譯器就已經完成了地址偏移的計算過程;同樣地,stu.name的地址,觀察第39、40行程式碼,是在0x1b(%esp)的基礎上,增加了stu.sex和stu.age的偏移,即5個位元組後找到了stu.name的地址。

也就是說,編譯器在編譯階段就已經知道結構體裡每個成員屬性的相對偏移量,我們原始碼裡的所有對結構體成員的訪問,最終都會被編譯器轉化成對其相對地址的訪問,程式碼在執行時根本沒有變數名、成員屬性一說,有的也只有地址。OK,那就簡單了,我們再看一下下面的程式:
點選(此處)摺疊或開啟

執行結果:

刨一刨核心 container_of() 的設計精髓

反彙編:

刨一刨核心 container_of() 的設計精髓

第8行“movl $0x0,0x1c(%esp)” 為指標stu賦值,為了列印stu指標所指向的地址值,第18、19行準備將0x1c(%esp)的值壓棧,為呼叫printf()做準備;準備列印stu->sex時,參見第23、25兩行所做的事情,與第18、19行相同;當準備列印stu->age時,參見第29、30行,eax裡已經儲存了stu所指向的地址0,是從棧上0x1c(%esp)裡取來的,然後lea指令將eax所指向地址向“後”偏1位元組的地址值裝載到edx裡,和上面第一個例項程式碼一樣。因為eax的值是0,所以0x1(%eax)的值肯定就是1,即此時在stu=NULL的前提下,找到了stu->age的地址。到這裡,我們的問題也就差不多明朗了:

第一:對於任何一個變數,任何時候我們都可以訪問該變數的地址,但是卻不一定能訪問該地址裡的值,因為在保護模式下對地址裡的值的訪問是受限的;

第二,結構體在編譯期間就已經確定了每個成員的大小,進而明確了每個成員相對於結構體頭部的偏移的地址,原始碼裡所有對結構體成員的訪問,在編譯期間都已經靜態地轉化成了對相對地址的訪問。

換句話說,原始碼裡你可以寫類似於int *ptr = 0x12345;這樣的語句程式碼,對ptr執行加、減,甚至強制型別轉換都沒有任何問題,但是如果你想訪問ptr地址裡的內容,那麼很不幸,你可能會收到一個“Segmentation Fault”的錯誤提示,因為你訪問了非法的記憶體地址。

最後,讓我們回到開篇的那個問題:

相信大家現在對offsetof()定義裡那個奇怪的0應該不再會感到奇怪了吧。其實container_of()裡還有一個名叫typeof的東東,是用於取一個變數的型別,這是GCC編譯器的一個擴充套件功能,也就是說typeof是編譯器相關的。既不是C語言規範的所要求,也不是某個神馬標準的一部分,僅僅是GCC編譯器的一個擴充套件特性而已,Windows下的VC編譯器就不帶這個技能。讓我們繼續刨一刨container_of()的程式碼:

第二句程式碼意思是用typeof()獲取結構體裡member成員屬性的型別,然後定義一個該型別的臨時指標變數__mptr,並將ptr所指向的member的地址賦給__mptr;第三句程式碼意思就更簡單了,__mptr減去它自身在結構體type裡的偏移量就找到了結構體的入口地址,最後將該地址強轉成目標結構體的地址型別就OK了。如果我們將使用了container_of()的程式碼進行巨集展開後,看得會比較清楚一點:

執行結果:

刨一刨核心 container_of() 的設計精髓

巨集展開後的程式碼如下:

GCC在接下來的編譯過程中會將typeof()進行替換處理,我們可以認為此時上述的程式碼和下面的程式碼是等價的:

最後向偉大的程式猿、攻城獅們致敬!!
向“自由、開源”精神致敬!!

相關文章