天天看點

《作業系統真象還原》——0.8 代碼中為什麼分為代碼段、資料段?這和記憶體通路機制中的段是一回事嗎

本節書摘來自異步社群《作業系統真象還原》一書中的第0章,第0.8節,作者:鄭鋼著,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視

首先,程式不是一定要分段才能運作的,分段隻是為了使程式更加優美。就像用飯盒裝飯菜一樣,完全可以将很多菜和米飯混合在一起,或者攪拌成一體,哈哈,但這樣可能就沒什麼胃口啦。如果飯盒中有好多小格子,友善将不同的菜和飯區分存放,這樣會讓我們胃口大開增加食欲。

x86平台的處理器是必須要用分段機制通路記憶體的,正因為如此,處理器才提供了段寄存器,用來指定待通路的記憶體段起始位址。我們這裡讨論的程式代碼中的段(用section或segment來定義的段,不同彙編編譯器提供的關鍵字有所差別,功能是一樣的)和記憶體通路機制中的段本質上是一回事。在硬體的記憶體通路機制中,處理器要用硬體——段寄存器,指向軟體——程式代碼中用section或segment以軟體形式所定義的記憶體段。

分段是必然的,隻是在平坦模型下,硬體段寄存器中指向的記憶體段為最大的4gb,而在多段模式下程式設計,硬體段寄存器中指向的記憶體段大小不一。

對于在代碼中的分段,有的是作業系統做的,有的是程式員自己劃分的。如果是在多段模型下程式設計,我們必然會在源碼中定義多個段,然後需要不斷地切換段寄存器所指向的段,這樣才能通路到不同段中的資料,是以說,在多段模型下的程式分段是程式員人為劃分的。如果是在平坦模型下程式設計,作業系統将整個4gb記憶體都放在同一個段中,我們就不需要來回切換段寄存器所指向的段。對于代碼中是否要分段,這取決于作業系統是否在平坦模型下。

一般的進階語言不允許程式員自己将代碼分成各種各樣的段,這是因為其所用的編譯器是針對某個作業系統編寫的,該作業系統采用的是平坦模型,是以該編譯器要編譯出适合此作業系統加載運作的程式。由于處理器支援了具有分頁機制的虛拟記憶體,作業系統也采用了分頁模型,是以編譯器會将程式按内容劃分成代碼段和資料段,如編譯器gcc會把c語言寫出的程式劃分成代碼段、資料段、棧段、.bss段、堆等部分。這會由作業系統将編譯器編譯出來的使用者程式中的各個段配置設定到不同的實體記憶體上。對于目前咱們用進階語言編碼來說,我們之是以不用關心如何将程式分段,正是由于編譯器按平坦模型編譯,而程式所依賴的作業系統又采用了虛拟記憶體管理,即處理器的分頁機制。像彙編這種低級語言允許程式員為自己的程式分段,能夠靈活地編排布局,這就屬于人為将程式分成段了,也就是采用多段模型程式設計。

這麼說似乎不是很清楚,一會再用例子和大夥兒解釋就明白了。在這之前,先和大家明确一件事。

cpu是個自動化程度極高的晶片,就像心髒一樣,給它一個初始的收縮,它将永遠地跳下去。突然想到intel的廣告詞:給你一顆奔騰的心。

隻要給出cpu第一個指令的起始位址,cpu在它執行本指令的同時,它會自動擷取下一條的位址,然後重複上述過程,繼續執行,繼續取址。假如執行的每條指令都正确,沒有異常發生的話,我想它可以運作到世界的盡頭,能讓它停下來的唯一條件就是斷電。

它為什麼能夠取得下一條指令位址?也就是說為什麼知道下一條指令在哪裡。這是因為程式中的指令都是挨着的,彼此之間無空隙。有同學可能會問,程式中不是有對齊這回事嗎?為了對齊,編譯器在程式中塞了好多0。是的,對齊确實是讓程式中出現了好多空隙,但這些空隙是資料間的空隙,指令間不存在空隙,下一條指令的位址是按照前面指令的尺寸大小排下來的,這就是intel處理器的程式計數器cs:eip能夠自動獲得下一條指令的原理,即将目前eip中的位址加上目前指令機器碼的大小便是記憶體中下一條指令的起始位址。即使指令間有空隙或其他非指令的資料,這也僅僅是在實體上将其斷開了,依然可以用jmp指令将非指令部分跳過以保持指令在邏輯上連續,我們在後面會通過執行個體驗證這一原理。

為了讓程式内指令接連不斷地執行,要把指令全部排在一起,形成一片連續的指令區域,這就是代碼段。這樣cpu肯定能接連不斷地執行下去。指令是由操作碼和操作數組成的,這對于資料也一樣,程式運作不僅要有操作碼,也得有操作數,操作數就是指程式中的資料。把資料連續地并排在一起存儲形成的段落,就稱為資料段。

指令大小是由實際指令的操作碼決定的,也就是說cpu在譯碼階段拿到了操作碼後,就知道實際指令所占的大小。其實說來說去,本質上就是在解釋位址是怎麼來的。這部分在第3章中的“什麼是位址”節中有詳解。

給大家示範個小例子,代碼沒有實際意義,是我随便寫的,隻是為友善大家了解指令的位址,代碼如下。

code_seg.s

本示例一共就5行,簡單純粹為示範。将其編譯為二進制檔案,程式内容是:

<code>8e d8 a1 07 00 eb fe 99 00</code>

就這9個位元組的内容,有沒有覺得一陣暈炫。如果沒有,目測讀者兄弟的技術水準遠在我之上,請略過本書。

其實這9個位元組的内容就是機器碼。為了讓大家了解得更清晰,給大家列個機器碼和源碼對照表,見表0-1。

《作業系統真象還原》——0.8 代碼中為什麼分為代碼段、資料段?這和記憶體通路機制中的段是一回事嗎

表0-1第1行,位址0處的指令是“mov ds,ax”,其機器碼是8ed8,這是十六進制表示,可見其大小是2位元組。前面說過,下一條指令的位址是按照前面指令的尺寸排下來的,那第2行指令的起始位址是0+2=2。在第2行的位址列中,位址确實是2。這不是我故意寫上去的,編譯器真的就是這樣編排的。第2列的指令是“mov ax,[0x7]”(0x7是變量var經過編譯後的位址),其機器碼是a10700,這是3位元組大小。是以第3條指令的位址是2+3=5。後面的指令位址也是這樣推算的。程式雖然很短,但麻雀雖小,五髒俱全,完美展示了程式中代碼緊湊無隙的布局。

現在大夥兒明白為什麼cpu能源源不斷擷取到指令了吧,如前所述,原因首先是指令是連續緊湊的,其次是通過指令機器碼能夠判斷目前指令長度,目前指令位址+目前指令長度=下一條指令位址。

上面給出的例子,其指令在實體上是連續的,其實在cpu眼裡,隻要指令邏輯上是連續的就可以,沒必要一定得是實體上連續。是以,明确一點,即使資料和代碼在實體上混在一起,程式也是可以運作的,這并不意味着指令被資料“斷開”了。隻要程式中有指令能夠跨過這些資料就行啦,最典型的就是用jmp跳過資料區。

比如這樣的彙編代碼:

這幾行代碼沒有實際意義,隻是為了解釋清楚問題,咱們隻要關注在第2行的定義變量var之前為什麼要jmp start。如果将上面的彙編代碼按純二進制編譯,如果不加第1行的jmp,cpu也許會發出異常,顯示無效指令,也許不知道執行到哪裡去了。因為cpu隻會執行cs:ip中的指令,這兩個寄存器記錄的是下一條待執行指令的位址,下一個位址var處的值為1,顯然我們從定義中看出這隻是資料,但指令和資料都是二進制數字,cpu可分不出這是指令,還是資料。保不準某些“資料”誤打誤撞恰恰是某種指令也說不定。既然var是我們定義的資料,那麼必須加上jmp start跳過這個var所占的空間才可以。

加個jmp指令,這樣做一點都不影響運作,隻不過這樣寫出來的程式,其中引用的位址大部分是不連續的,也就是程式在取位址時會顯得跳來跳去。就美觀層面上看,這樣的結構顯得很淩亂,不利于程式員閱讀與維護。如果把第2行的var換到第1行,資料和代碼就分開了,沒有混在一起,标号都不用了,代碼簡潔多了,如下。

做過開發的同學都清楚,盡量把同一屬性的資料放在一起,這樣易于維護。這一點類似于mvc,在程式邏輯中把模型、視圖、控制這三部分分開,這樣更新各部分時,不會影響到其他子產品。

将資料和代碼分開的好處有三點。

第一,可以為它們賦予不同的屬性。

例如資料本身是需要修改的,是以資料就需要有可寫的屬性,不讓資料段可寫,那程式根本就無法執行啦。程式中的代碼是不能被更改的,這樣就要求代碼段具備隻讀的屬性。真要是在運作過程中程式的下一條指令被修改了,誰知道會産生什麼樣的災難。

第二,為了提高cpu内部緩存的命中率。

大夥兒知道,緩存起作用的原因是程式的局部性原理。在cpu内部也有緩存機制,将程式中的指令和資料分離,這有利于增強程式的局部性。cpu内部有針對資料和針對指令的兩種緩存機制,是以,将資料和代碼分開存儲将使程式運作得更快。

第三,節省記憶體。

程式中存在一些隻讀的部分,比如代碼,當一個程式的多個副本同時運作時(比如同時執行多個ls指令時),沒必要在記憶體中同時存在多個相同的代碼段,這将浪費有限的實體記憶體資源,隻要把這一個代碼段共享就可以了。

後兩點較容易了解,咱們深入讨論下第一點,不知您有沒有想過,資料段或代碼段的屬性是誰給添加上的呢,是誰又去根據屬性保護程式的呢,是程式員嗎?是編譯器嗎?是作業系統嗎?還是cpu一級的硬體支援?

首先肯定不是程式員,人家作業系統設計人員為了讓程式員編寫程式更加容易,肯定不會讓他們分心去處理這些與業務邏輯無關的事。看看編譯器為我們做了什麼,它将程式中那些隻讀的代碼編譯出來後,放在一片連續的區域,這個區域叫代碼段。将那些已經初始化的資料也放在一片連續的區域,這個區域叫資料段,那些具有全局屬性的但又未初始化的資料放在bss段。總之,程式中段的類型可多了,用“readelf –e elf”指令便可以看到很多段的類型,感興趣的讀者請自行查閱。好了,編譯器的工作到此就完事了,顯然,資料段和代碼段的屬性到現在還沒有展現出來。

先看cpu為我們提供了哪些原生的支援。在保護模式下,有這樣一個資料結構,它叫全局描述符表(global descriptor table,gdt),這個表中的每一項稱為段描述符。先遞歸學習一下,什麼是描述符?描述符就是描述某種資料的資料結構,是元資訊,屬于資料的資料。就像人們的身份證,上面有寫性别、出生日期、位址等描述個人情況的資訊。在段描述符中有段的屬性位,在以後的章節中可以看到,其實是有2個,一個是s字段,占1bit大小,另外一個是占4bit大小的type字段,這兩個字段配合在一起使用就能組合出各種屬性,如隻讀、向下擴充、隻執行等。提供歸提供,可得有人去填寫這張表啊,誰來做這事呢,有請作業系統登場。

接着看作業系統為我們做了什麼。

作業系統在讓cpu進入保護模式之前,首先要準備好gdt,也就是要設定好gdt的相關項,填寫好段描述符。段描述符填寫成什麼樣,段具備什麼樣的屬性,這完全取決于作業系統了,在這裡大家隻要知道,段描述符中的s字段和type字段負責該段的屬性,也就是該屬性與安全相關。

說到這裡,答案似乎浮出水面了。

(1)編譯器負責挑選出資料具備的屬性,進而根據屬性将程式片段分類,比如,劃分出了隻讀屬性的代碼段和可寫屬性的資料段。再補充一下,編譯器并沒有讓段具備某種屬性,對于代碼段,編譯器所做的隻是将代碼歸類到一起而已,也就是将程式中的有關代碼的多個section合并成一個大的segment(這就是我們所說的代碼段),它并沒有為代碼段添加額外的資訊。

(2)作業系統通過設定gdt全局描述符表來建構段描述符,在段描述符中指定段的位置、大小及屬性(包括s字段和type字段)。也就是說,作業系統認為代碼應該是隻讀的,是以給用來指向代碼段的那個段描述符設定了隻讀的屬性,這才是真正給段添加屬性的地方。

(3)cpu中的段寄存器提前被作業系統賦予相應的選擇子(後面章節會講什麼是選擇子,暫時将其了解為相當于段基址),進而确定了指向的段。在執行指令時,會根據該段的屬性來判斷指令的行為,若有傳回則發出異常。

總之,編譯器、作業系統、cpu三個配合在一起才能對程式保護,檢測出指令中的違規行為。如果gdt中的代碼段描述符具備可寫的屬性,那編譯器再怎麼劃分代碼段都沒有用,有判斷權利的隻有cpu。

好,現在大家對gdt有個感性認識,随着以後章節中講gdt的時候,大家就會有深刻的了解了。

以上說明了程式按内容分段的原因,那麼編譯器編譯出來的段和記憶體通路中的段是一回事嗎?

其實算一回事,也不算一回事。怎麼說呢,我覺得當初intel公司在設計cpu時,其采用分段機制通路記憶體的原因,肯定不是為了上層軟體的優美,畢竟那隻是邏輯上的東西。那為什麼也算一回事呢?

分析一下,編譯出來的代碼段是指一片連續的記憶體區域。這個段有自己的起始位址,也有自己的大小範圍。使用者程序中的段,隻是為了便于管理,而編譯器或程式員在“美學方面”做出的規劃,本質上它并不是cpu用于記憶體通路的段,但它們都是描述了一段記憶體,而且程式中的段,其起始位址和大小可以了解為cpu通路記憶體分段政策中的“段基址:段内偏移位址”,這麼說來,至少它們很接近了,讓我們更近一步:程式是可以被人為劃分成段的,并且可以将劃分出來的段位址加載到段寄存器中,見下面的代碼0-1。

代碼0-1 程式分段

代碼0-1是實模式下運作的程式,其中自定義了三個段,為了和标準的段名(.code、.data等)有所差別,這裡代碼段取名為my_code,資料段取名為my_data,棧段取名為my_stack。這段代碼是由mbr加載到實體記憶體位址0x900後,mbr通過“jmp 0x900”跳過來的,我們的想法是讓各段寄存器左移4位後的段基址與程式中各分段實際記憶體位置相同,是以對于代碼段,希望其基址是0x900,故代碼段cs的值為0x90(在實模式下,由cpu的段部件将其左移4位後變成0x900,是以要初始化成左移4位前的值)。但沒有辦法直接為cs寄存器指派,是以在代碼0-1開頭,用“jmp 0x90:0”初始化了程式計數器cs和ip。這樣段寄存器cs就是程式中咱們自己劃分的代碼段了。

在此提醒一下,各section中的定義都有align=16和vstart=0,這是用來指定各section按16位對齊的,各section的起始位址是16的整數倍,即用十六進制表示的話,最後一位是0。是以右移操作如第9行的shr ax,4,結果才是正确的,隻是把0移出去了。否則不加align=16的話,section的位址不能保證是16的整數倍,右移4位可能會丢資料。vstart=0是指定各section内資料或指令的位址以0為起始編号,這樣做為段内偏移位址時更友善。具體vstart内容請參閱本書相應章節。

第6~10行是初始化資料段寄存器ds,是用程式中自已劃分的段my_data的位址來初始化的。由于代碼0-1本身是脫離作業系統的程式,是mbr将其加載到0x900後通過跳轉指令“jmp 0x900”跳入執行的,是以要将my_data在檔案内的位址section.my_data.start加上0x900才是最終在記憶體中的真實位址。右移4位的原因同代碼段相同,都是cpu的段部件會自動将段基址左移4位,故提前右移4位。此位址作為段基址指派給ds,這樣段寄存器ds中的值是程式中咱們自己劃分的資料段了。

第12~17行是初始化棧段寄存器,原理和資料段差不多,唯一差別是棧段初始化多了個針指針sp,為它初始化的值stack_top是最後一行,因為棧指針在使用過程中指向的位址越來越低,是以初始化時一定得是棧段的最高位址。

經過代碼段、資料段、棧段的初始化,cpu中的段寄存器cs、ds、ss都是指向程式中咱們自己劃分的段位址,之後cpu的記憶體分段機制“段基址:段内偏移位址”,段基址就是程式中咱們自己劃分的段,段内偏移位址都是各自定義段内的指令和資料位址,由于在section中有vstart=0限制,位址都是從0開始編号的。是以,程式中的分段和cpu記憶體通路的分段又是一回事。

讓我們對此感到疑惑的原因,可能是我們一般都是用進階語言開發程式,在進階語言中,程式分段這種工作不由我們控制,是由編譯器在編譯階段完成的。而且現代作業系統都是在平坦模型(整個4gb空間為1個段)下工作,編譯器也是按照平坦模型為程式布局,程式中的代碼和資料都在同一個段中整齊排列。大家可以用readelf –e /bin/ls檢視一下ls指令,結果太長,就不截圖啦。咱們主要關注三段内容。

section headers:列出了程式中所有的section,這些section是gcc編譯器幫忙劃分的。

program headers:列出了程式中的段,即segment,這是程式中section合并後的結果。

section to segment mapping:列出了一個segment中包含了哪些section。

有關section和segment的内容請參見本書相關章節。

在section headers和program headers中您會發現,這些分段都是按照位址由低到高在4gb空間中連續整潔地分布的,在平坦模型下和諧融洽。

顯然,不用程式員手工分段,并且采用平坦模型,這種操作上的“隔離”固然讓我們更加友善,但也讓我們更加感到程序空間布局的神秘。如果程式分段像代碼0-1那樣地直白、親民,大家肯定不會感到迷惑了。其實我想說的是無論是否為平坦模型,程式中的分段和cpu中的記憶體分段機制,它們屬于物品與容器的關系。

舉個例子,程式中劃分的段相當于各種水果,比如代碼段相當于香蕉,資料段相當于葡萄,棧段相當于西瓜。cpu記憶體分段政策中的段寄存器相當于盛水果的盤子。可以用一個大盤子将各種水果都放進來,但依然是分門别類地擺放,不能失去美感混成一鍋粥,這就是段大小為4gb的平坦模型。也可以把每種水果分别放在一個小盤子裡一塊兒端上來,這就是普通的分段模型,如圖0-4所示。

《作業系統真象還原》——0.8 代碼中為什麼分為代碼段、資料段?這和記憶體通路機制中的段是一回事嗎

總結一下,程式中的段隻是邏輯上的劃分,用于不同資料的歸類,但是可以用cpu中的段寄存器直接指向它們,然後用記憶體分段機制去通路程式中的段,在這一點上看,它們很像相片和相框的關系:程式中的段是記憶體中的内容,相當于相片,屬于被展示的内容,而記憶體分段機制則是通路記憶體的手段,相當于相框,有了相框,照片才能有地擺放。

我想大家應該已經搞清楚了記憶體分段和程式分段的關系,其實就是一回事,記憶體分段指的是處理器為通路記憶體而采用的機制,稱之為記憶體分段機制,程式分段是軟體中人為邏輯劃分的記憶體區域,它本身也是記憶體,是以處理器在通路該區域時,也會采用記憶體分段機制,用段寄存器指向該區域的起始位址。

繼續閱讀