《深刻理解計算機系統》(CSAPP)讀書筆記 —— 第三章 程序的機器級表示

本章主要介紹了計算機中的機器代碼——彙編語言。當咱們使用高級語言(C、Java等)編程時,代碼會屏蔽機器級的細節,咱們沒法瞭解到機器級的代碼實現。既然有了高級語言,咱們爲何還須要學習彙編語言呢?學習程序的機器級實現,能夠幫助咱們理解編譯器的優化能力,可讓咱們瞭解程序是如何運行的,哪些部分是能夠優化的;當程序受到攻擊(漏洞)時,都會涉及到程序運行時控制信息的細節,不少程序都會利用系統程序中的漏洞信息重寫程序,從而得到系統的控制權(蠕蟲病毒就是利用了gets函數的漏洞)。特別是做爲一名嵌入式軟件開發的從業人員,會常常接觸到底層的代碼實現,好比Bootloader中的時鐘初始化,重定位等都是用匯編語言實現的。雖然不要求咱們使用匯編語言寫複雜的程序,可是要求咱們要可以閱讀和理解編譯器產生的彙編代碼。面試

程序編碼

計算機的抽象模型

  在以前的《深刻理解計算機系統》(CSAPP)讀書筆記 —— 第一章 計算機系統漫遊文章中提到過計算機的抽象模型,計算機利用更簡單的抽象模型來隱藏實現的細節。對於機器級編程來講,其中兩種抽象尤其重要。第一種是由指令集體系結構或指令集架構( Instruction Set Architecture,ISA)來定義機器級程序的格式和行爲,它定義了處理器狀態指令的格式,以及每條指令對狀態的影響。大多數ISA,包括x86-64,將程序的行爲描述成好像每條指令都是按順序執行的,一條指令結束後,下一條再開始。處理器的硬件遠比描述的精細複雜,它們併發地執行許多指令,可是能夠採起措施保證總體行爲與ISA指定的順序執行的行爲徹底一致。第二種抽象是,機器級程序使用的內存地址是虛擬地址,提供的內存模型看上去是一個很是大的字節數組。存儲器系統的實際實現是將多個硬件存儲器和操做系統軟件組合起來。編程

彙編代碼中的寄存器

  程序計數器(一般稱爲「PC」,在x86-64中用號%rip表示)給出將要執行的下一條指令在內存中的地址。數組

  整數寄存器文件包含16個命名的位置,分別存儲64位的值。這些寄存器能夠存儲地址(對應於C語言的指針)或整數數據。有的寄存器被用來記錄某些重要的程序狀態,而其餘的寄存器用來保存臨時數據,例如過程的參數和局部變量,以及函數的返回值。sass

  條件碼寄存器保存着最近執行的算術或邏輯指令的狀態信息。它們用來實現控制或數據流中的條件變化,好比說用來實現if和 while語句安全

  一組向量寄存器能夠存放個或多個整數或浮點數值bash

  關於彙編中經常使用的寄存器建議看我整理的嵌入式軟件開發面試知識點中的ARM部分,裏面詳細介紹了Arm中經常使用的寄存器和指令集。數據結構

機器代碼示例

  假如咱們有一個main.c文件,使用 gcc -0g -S main.c能夠產生一個彙編文件。接着使用gcc -0g -c main.c就能夠產生目標代碼文件main.o。一般,這個.o文件是二進制格式的,沒法直接查看,咱們打開編輯器能夠調整爲十六進制的格式,示例以下所示。架構

53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3

  這就是彙編指令對應的目標代碼。從中獲得一個重要信息,即機器執行的程序只是一個字節序列,它是對一系列指令的編碼。機器對產生這些指令的源代碼幾乎一無所知。併發

反彙編簡介

  要查看機器代碼文件的內容,有一類稱爲反彙編器( disassembler)的程序很是有用。這些程序根據機器代碼產生一種相似於彙編代碼的格式。在 Linux系統中,使用命令 objdump -d main.o能夠產生反彙編文件。示例以下圖。less

image-20201030224154512

  在左邊,咱們看到按照前面給出的字節順序排列的14個十六進制字節值,它們分紅了若干組,每組有1~5個字節。每組都是一條指令,右邊是等價的彙編語言

  其中一些關於機器代碼和它的反彙編表示的特性值得注意

  • x86-64的指令長度從1到15個字節不等。經常使用的指令以及操做數較少的指令所需的字節數少,而那些不太經常使用或操做數較多的指令所需字節數較多

  • 設計指令格式的方式是,從某個給定位置開始,能夠將字節惟一地解碼成機器指令。例如,只有指令 push%rbx是以字節值53開頭的

  • 反彙編器只是基於機器代碼文件中的字節序列來肯定彙編代碼。它不須要訪問該程序的源代碼或彙編代碼

  • 反彙編器使用的指令命名規則與GCC生成的彙編代碼使用的有些細微的差異。在咱們的示例中,它省略了不少指令結尾的‘q’。這些後綴是大小指示符,在大多數狀況中能夠省略。相反,反彙編器給ca11和ret指令添加了‘q’後綴,一樣,省略這些後綴也沒有問題。

數據格式

   Intel用術語「字(word)」表示16位數據類型。所以,稱32位數爲「雙字( double words)」,稱64位數爲「四字( quad words)。下表給出了C語言基本數據類型對應的x86-64表示。

C聲明 Intel數據類型 彙編代碼後綴 大小(字節)
char 字節 b 1
short w 2
int 雙字 l 4
long 四字 q 8
char* 四字 q 8
float 單精度 s 4
double 雙精度 1 8

訪問信息

操做數指示符

整數寄存器

  不一樣位的寄存器名字不一樣,使用的時候要注意。

image-20201031150130488

三種類型的操做數

  1.當即數,用來表示常數值,好比,$0x1f 。不一樣的指令容許的當即數值範圍不一樣,彙編器會自動選擇最緊湊的方式進行數值編碼。

  2.寄存器,它表示某個寄存器的內容,16個寄存器的低位1字節、2字節、4字節或8字節中的一個做爲操做數,這些字節數分別對應於8位、16位、32位或64位。在圖3-3中,咱們用符號 r a {r_a} ra來表示任意寄存器a,用引用 R [ r a ] R[{r_a}] R[ra]來表示它的值,這是將寄存器集合當作一個數組R,用寄存器標識符做爲索引

  3.內存引用,它會根據計算出來的地址(一般稱爲有效地址)訪問某個內存位置。由於將內存當作一個很大的字節數組,咱們用符號 M b [ A d d r ] {M_b}[Addr] Mb[Addr]表示對存儲在內存中從地址Addr開始的b個字節值的引用。爲了簡便,咱們一般省去下標b。

操做數的格式

  看彙編指令的時候,對照下圖能夠讀懂大部分的彙編代碼。

image-20201031145813867

數據傳送指令

image-20201101214234883

  不一樣後綴的指令主要區別在於它們操做的數據大小不一樣。

  源操做數:寄存器,內存

  目的操做數:寄存器,內存。

注意:傳送指令的兩個操做數不能都指向內存位置。將一個值從一個內存位置複製到另外一個內存位置須要兩條指令—第一條指令將源值加載到寄存器中,第二條將該寄存器值寫入目的位置。

movl $0x4050,%eax         Immediate--Register,4 bytes p,1sp  move 
movw %bp,%sp              Register--Register, 2 bytes
movb (%rdi. %rcx),%al     Memory--Register  1 bytes
movb $-17,(%rsp)          Immediate--Memory 1 bytes
movq %rax,-12(%rpb)       Register--Memory, 8 bytes

  將較小的源值複製到較大的目的時使用以下指令。

image-20201101215745466

image-20201101215812134

舉例

image-20201101220323188

  過程參數xp和y分別存儲在寄存器%rdi和%rsi中(參數經過寄存器傳遞給函數)。

  第二行:指令movq從內存中讀出xp,把它存放到寄存器**%rax**中(像x這樣的局部變量一般是保存在寄存器中,而不是在內存中)。

  第三行:指令movq將y寫入到寄存器**%rdi**中的xp指向的內存位置。

  第四行:指令ret用寄存器 %rax從這個函數返回一個值。

  總結:

  間接引用指針就是將該指針放在一個寄存器中,而後在內存引用中使用這個寄存器。

  像x這樣的局部變量一般是保存在寄存器中,而不是內存中。訪問寄存器比訪問內存要快得多。

壓入和彈出棧數據

image-20201101220629292

  pushq指令的功能是把數據壓入到棧上,而popq指令是彈出數據。這些指令都只有一個操做數——壓入的數據源和彈出的數據目的。

pushq %rbp等價於如下兩條指令:

subq $8,%rsp             Decrement stack pointer
movq %rbp,(%rsp)       Store %rbp on stack

popq %rax等價於下面兩條指令:

mova (%rsp), %rax        Read %rax from stack 
addq $8,%rsp             Increment stack pointer

算數和邏輯操做

加載有效地址

  IA32指令集中有這樣一條加載有效地址指令leal,用法爲leal S, D,效果是將S的地址存入D,是mov指令的變形。但是這條指令每每用在計算乘法上,GCC編譯器特別喜歡使用這個指令,好比下面的例子

leal (%eax, %eax, 2), %eax

  實現的功能至關於%eax = %eax * 3。括號中是一種比例變址尋址,將第一個數加上第二個數和第三個數的乘積做爲地址尋址,leal的效果使源操做數正好是尋址獲得的地址,而後將其賦值給%eax寄存器。爲何用這種方式算乘法,而不是用乘法指令imul呢?

  這是由於Intel處理器有一個專門的地址運算單元,使得leal的執行沒必要通過ALU,並且只須要單個時鐘週期。相比於imul來講要快得多。所以,對於大部分乘數爲小常數的狀況,編譯器都會使用leal完成乘法操做。

一元和二元操做
地址
0x100 0xFF
0x108 0xAB
0x110 0x13
0x118 0x11
寄存器
%rax 0x100
%rcx 0x1
%rdx 0x3

  看個例子應該就明白這些指令的含義了,不知道指令意思的,能夠看操做數的格式這一節中總結的常見彙編指令的格式。

指令 目的 解釋
addq %rcx,(%rax) 0x100 0x100 將rcx寄存器的值(0x1)加到%rax地址處(0xFF)
subq %rdx,8(%rax) 0x108 0xA8 從8(%rax)地址處取值(0XAB)並減去%rdx的值(0x3)
imulq $16,(%rax,%rdx,8) 0x118 0x110 (0x100+0x3 * 8) = 118.從118的地址取值並乘以10(16)結果爲0x110
incq 16(%rax) 0x110 0x14 %rax + 16 = 0x100+10 = 0x110。從0x110取值得0x13,結果+1爲0x14。
decq %rcx %rcx 0x0 0x1-1
移位操做

  左移指令:SAL,SHL

  算術右移指令:SAR(填上符號位)

  邏輯右移指令:SHR(填上0)

  移位操做的目的操做數是一個寄存器或是一個內存位置。169

image-20201101223636287

  C語言對應的彙編代碼

image-20201101223537078

image-20201101223407147

控制

條件碼

條件碼的定義

  描述了最近的算術或邏輯操做的屬性。能夠檢測這些寄存器來執行條件分支指令

經常使用的條件碼

  CF:進位標誌。最近的操做使最高位產生了進位。可用來檢查無符號操做的溢出。
  ZF:零標誌。最近的操做得出的結果爲0。
  SF:符號標誌。最近的操做獲得的結果爲負數。
  OF:溢出標誌。最近的操做致使一個補碼溢出—正溢出或負溢出。


改變條件碼的指令

image-20201104155658145

  cmp指令根據兩個操做數之差來設置條件碼,經常使用來比較兩個數,可是不會改變操做數。

  test指令用來測試這個數是正數仍是負數,是零仍是非零。兩個操做數相同

test %rax,%rax //檢查%rax是負數、零、仍是正數(%rax && %rax)

cmp %rax,%rdi //與sub指令相似,%rdi - %rax 。

image-20201104160246288

  上表中除了leap指令,其餘指令都會改變條件碼。

ⅩOR,進位標誌和溢出標誌會設置成0.對於移位操做,進位標誌將設置爲最後一個被移出的位,而溢出標誌設置爲0。INC和DEC指令會設置溢出和零標誌。

訪問條件碼

訪問條件碼的三種方式

  1.能夠根據條件碼的某種組合,將一個字節設置爲0或者1。

  2.能夠條件跳轉到程序的某個其餘的部分。

  3.能夠有條件地傳送數據。

  對於第一種狀況,常使用set指令來設置,set指令以下圖所示。

image-20201104164128434

/* 計算a<b的彙編代碼 int comp(data_t a,data_t b) a in %rdi,b in %rsi */
comp:
cmpq %rsi,%rdi
setl %al
movzbl %al,%eax
ret

setl %al 當a<b,設置%eax的低位爲0或者1。

跳轉指令

image-20201104164950004

  上表中的有些指令是帶有後綴的,表示條件跳轉,下面解釋下這些後綴,有助於記憶。

  e == equal,ne == not equal,s == signed,ns == not signed,g == greater,ge == greater or equal,l == less,le == less or eauql,a == ahead,ae == ahead or equal,b == below,be == below or equal

  直接跳轉

jmp .L1 //直接給出標號,跳轉到標號處

  間接跳轉

jmp *%rax  //用寄存器%rax中的值做爲跳轉目標
jmp *(%rax) //以%rax中的值做爲讀地址,從內存中讀出跳轉目標
跳轉指令的編碼

  經過看跳轉指令的編碼格式理解下程序計數器PC是如何實現跳轉的。

  彙編

movq %rdi, %rax 
jmp .L2
.L3:
sarq %rax 
.L2:
testq %rax, %rax 
jg .L3
rep;ret

  反彙編

0:48 89 f8      mov %rdi,%raxrdi, 
3:eb 03         jmp 8 <loop+0x8>
5:48 d1 f8      sar %rax
8:48 85 c0      test %rax %rax
b:71 f8         jg 5<loop+0x5>
d: f3 C3        repz rete

  右邊反彙編器產生的註釋中,第2行中跳轉指令的跳轉目標指明爲0x8,第5行中跳轉指令的跳轉目標是0x5(反彙編器以十六進制格式給出全部的數字)。不過,觀察指令的宇節編碼,會看到第一條跳轉指令的目標編碼(在第二個字節中)爲0x03.把它加上0×5,也就是下一條指令的地址,就獲得跳轉目標地址0x8,也就是第4行指令的地址。

  相似,第二個跳轉指令的目標用單字節、補碼錶示編碼爲0xf8(十進制-8)。將這個數加上0xa(十進制13),即第6行指令的地址,咱們獲得0x5,即第3行指令的地址。

  這些例子說明,當執行PC相對尋址時,程序計數器的值是跳轉指令後面的那條指令的地址,而不是跳轉指令自己的地址

條件控制實現條件分支

image-20201104174115100

  上圖分別給出了C語言,goto表示,彙編語言的三種形式。這裏使用goto語句,是爲了構造描述彙編代碼程序控制流的C程序。

  彙編代碼的實現(圖3-16c)首先比較了兩個操做數(第2行),設置條件碼。若是比較的結果代表x大於或者等於y,那麼它就會跳轉到第8行,增長全局變量 ge_cnt,計算x-y做爲返回值並返回。由此咱們能夠看到 absdiff_se對應彙編代碼的控制流很是相似於gotodiff_ se的goto代碼。

  C語言中的if-else通用模版以下:

image-20201104175413267

  對應的彙編代碼以下:

image-20201104175428373

條件傳送實現條件分支

image-20201104174629197

  GCC爲該函數產生的彙編代碼如圖3-17c所示,它與圖3-17b中所示的C函數cmovdiff有類似的形式。研究這個C版本,咱們能夠看到它既計算了y-x,也計算了x-y,分別命名爲rval和eval。而後它再測試x是否大於等於y,若是是,就在函數返回rval前,將eval複製到rval中。圖3-17c中的彙編代碼有相同的邏輯。關鍵就在於彙編代碼的那條 cmovge指令(第7行)實現了 cmovdiff的條件賦值(第8行)。只有當第6行的cmpq指令代表一個值大於等於另外一個值(正如後綴ge代表的那樣)時,纔會把數據源寄存器傳送到目的

  條件控制的彙編模版以下:

image-20201104175602353

  實際上,基於條件數據傳送的代碼會比基於條件控制轉移的代碼性能要好。主要緣由是處理器經過使用流水線來得到高性能,處理器採用很是精密的分支預測邏輯來猜想每條跳轉指令是否會執行。只要它的猜想還比較可靠(現代微處理器設計試圖達到90%以上的成功率),指令流水線中就會充滿着指令。另外一方面,錯誤預測一個跳轉,要求處理器丟掉它爲該跳轉指令後全部指令已作的工做,而後再開始用從正確位置處起始的指令去填充流水線。這樣一個錯誤預測會招致很嚴重的懲罰,浪費大約15~30個時鐘週期,致使程序性能嚴重降低

  使用條件傳送也不老是會提升代碼的效率。例如,若是 then expr或者 else expr的求值須要大量的計算,那麼當相對應的條件不知足時,這些工做就白費了。編譯器必須考慮浪費的計算和因爲分支預測錯誤所形成的性能處罰之間的相對性能。說實話,編譯器井不具備足夠的信息來作出可靠的決定;例如,它們不知道分支會多好地遵循可預測的模式。咱們對GCC的實驗代表,只有當兩個表達式都很容易計算時,例如表達式分別都只是條加法指令,它纔會使用條件傳送。根據咱們的經驗,即便許多分支預測錯誤的開銷會超過更復雜的計算,GCC仍是會使用條件控制轉移。

  因此,總的來講,條件數據傳送提供了一種用條件控制轉移來實現條件操做的替代策略。它們只能用於很是受限制的狀況,可是這些狀況仍是至關常見的,並且與現代處理器的運行方式更契合。

循環

  將循環翻譯成彙編主要有兩種方法,第一種咱們稱爲跳轉到中間,它執行一個無條件跳轉跳到循環結尾處的測試,以此來執行初始的測試。第二種方法叫guarded-do,首先用條件分支,若是初始條件不成立就跳過循環,把代碼變換爲do-whie循環。當使用較髙優化等級編譯時,例如使用命令行選項-O1,GCC會採用這種策略。

跳轉到中間

  以下圖所示爲while循環寫的計算階乘的代碼。能夠看到編譯器使用了跳轉到中間的翻譯方法,在第3行用jmp跳轉到以標號L5開始的測試,若是n知足要求就執行循環,不然就退出。

image-20201106155420381

guarded-do

  下圖爲使用第二種方法編譯的彙編代碼,編譯時是用的是-O1,GCC就會採用這種方式編譯循環。

image-20201106160031027

  上面介紹的是while循環和do-while循環的兩種編譯模式,根據GCC不一樣的優化結果會獲得不一樣的彙編代碼。實際上,for循環產生的彙編代碼也是以上兩種彙編代碼中的一種。for循環的通用形式以下所示。

image-20201106162441921

  選擇跳轉到中間策略會獲得以下goto代碼:

image-20201106162556429

  guarded-do策略會獲得以下goto代碼:

image-20201106162625631

suitch語句

  switch語句能夠根據一個整數索引值進行多重分支。它們不只提升了C代碼的可讀性並且經過使用跳轉表這種數據結構使得實現更加高效。跳轉表是一個數組,表項i是一個代碼段的地址,這個代碼段實現當開關索引值等於i時程序應該採起的動做。

  程序代碼用開關索引值來執行一個跳轉表內的數組引用,肯定跳轉指令的目標。和使用組很長的if-else語句相比,使用跳轉表的優勢是執行開關語句的時間與開關狀況的數量無關。GCC根據開關狀況的數量和開關狀況值的稀疏程度來翻譯開關語句。當開關狀況數量比較多(例如4個以上),而且值的範圍跨度比較小時,就會使用跳轉表。

image-20201106171009414

  原始的C代碼有針對值100、102104和106的狀況,可是開關變量n能夠是任意整數。編譯器首先將n減去100,把取值範圍移到0和6之間,建立一個新的程序變量,在咱們的C版本中稱爲 index。補碼錶示的負數會映射成無符號表示的大正數,利用這一事實,將 index看做無符號值,從而進一步簡化了分支的可能性。所以能夠經過測試 index是否大於6來斷定index是否在0~6的範圍以外。在C和彙編代碼中,根據 index的值,有五個不一樣的跳轉位置:loc_A(.L3),loc_B(.L5),loc_C(.L6),loc_D(.L7)和 loc_def(.L8),最後一個是默認的目的地址。每一個標號都標識一個實現某個狀況分支的代碼塊。在C和彙編代碼中,程序都是將 index和6作比較,若是大於6就跳轉到默認的代碼處

image-20201106172403510

  執行 switch語句的關鍵步驟是經過跳轉表來訪問代碼位置。在C代碼中是第16行一條goto語句引用了跳轉表jt。GCC支持計算goto,是對C語言的擴展。在咱們的彙編代碼版本中,相似的操做是在第5行,jmp指令的操做數有前綴‘ * ’,代表這是一個間接跳轉,操做數指定一個內存位置,索引由寄存器%rsi給出,這個寄存器保存着 index的值。

  C代碼將跳轉表聲明爲一個有7個元素的數組,每一個元素都是一個指向代碼位置的指針。這些元素跨越 index的值0 ~ 6,對應於n的值100~106。能夠觀察到,跳轉表對重複狀況的處理就是簡單地對錶項4和6用一樣的代碼標號(loc_D),而對於缺失的狀況的處理就是對錶項1和5使用默認狀況的標號(loc_def)

  在彙編代碼中,跳轉表聲明爲以下形式

image-20201106172457352

  (.rodata段的詳細解釋在我總結的嵌入式軟件開發筆試面試知識點中有詳細介紹)

已知switch彙編代碼,如何利用匯編語言和跳轉表的結構推斷出switch的C語言結構?

  關於C語言的switch語句,須要重點肯定的有跳轉表的大小,跳轉範圍,那些case是缺失的,那些是重複的。下面咱們一 一肯定。

  這些表聲明中,從圖3-23的彙編第1行能夠知道,n的起始計數爲100。由第二行能夠知道,變量和6進行比較,說明跳轉表索引偏移範圍爲0 ~ 6,對應爲100 ~106。從.quad .L3開始,由上到下,依次編號爲0,1,2,3,4,5,6。其中由圖3-23的ja .L8可知,大於6時就跳轉到.L8,那麼跳轉表中編號爲1和5的都是跳轉的默認位置。所以,編號爲1和5的爲缺失的狀況,即沒有101和105的選項。而編號爲4和6的都跳轉到了.L7,說明二者是對應於100+4=104,100+6=106。剩下的狀況0,2,3依次編號爲100,102,103。至此咱們就得出了switch的編號狀況,一共有6項,100,102,103,104,106,default。剩下的關於每種case的C語言內容就能夠根據彙編代碼寫出來了。

過程

運行時棧

  C語言過程調用機制的一個關鍵特性(大多數其餘語言也是如此)在於使用了棧數據結構提供的後進先出的內存管理原則。假如在過程P調用過程Q時,能夠看到當Q在執行時,P以及全部在向上追溯到P的調用鏈中的過程,都是暫時被掛起的。當Q運行時,它只須要爲局部變量分配新的存儲空間,或者設置到另外一個過程的調用。另外一方面,當Q返回時,任何它所分配的局部存儲空間均可以被釋放。所以,程序能夠用棧來管理它的過程所須要的存儲空間,棧和程序寄存器存放着傳遞控制和數據、分配內存所須要的信息。當P調用Q時,控制和數據信息添加到棧尾。當P返回時,這些信息會釋放掉。

image-20201107144949376

  x86-64的棧向低地址方向增加,而棧指針號%rsp指向棧頂元素。能夠用 pushq和popq指令將數據存人棧中或是從棧中取出。將棧指針減少一個適當的量能夠爲沒有指定初始值的數據在棧上分配空間。相似地,能夠經過增長棧指針來釋放空間。

  過程P能夠傳遞最多6個整數值(也就是指針和整數),可是若是Q須要更多的參數,P能夠在調用Q以前在本身的**棧幀(也就是內存)**裏存儲好這些參數。

轉移控制

  將控制從函數轉移到函數Q只須要簡單地把程序計數器(PC)設置爲Q的代碼的起始位置。不過,當稍後從Q返回的時候,處理器必須記錄好它須要繼續P的執行的代碼位置。在x86-64機器中,這個信息是用指令call Q調用過程Q來記錄的。該指令會把地址A壓入棧中,並將PC設置爲Q的起始地址。壓入的地址A被稱爲返回地址,是緊跟在call指令後面的那條指令的地址。對應的指令ret會從棧中彈出地址A,並把PC設置爲A。

image-20201107170128713

  下面看個例子

image-20201107170248280

image-20201107170636553

  main調用top(100),而後top調用leaf(95)。函數leaf向top返回97,而後top向main返回194.前面三列描述了被執行的指令,包括指令標號、地址和指令類型。後面四列給出了在該指令執行前程序的狀態,包括寄存器%rdi、%rax和%rsp的內容,以及位於棧頂的值。

  leaf的指令L1將%rax設置爲97,也就是要返回的值。而後指令L2返回,它從棧中彈出0×400054e。經過將PC設置爲這個彈出的值,控制轉移回top的T3指令。程序成功完成對leaf的調用,返回到top。

  指令T3將%rax設置爲194,也就是要從top返回的值。而後指令T4返回,它從棧中彈出0×4000560,所以將PC設置爲main的M2指令。程序成功完成對top的調用,返回到main。能夠看到,此時棧指針也恢復成了0x7fffffffe820,即調用top以前的值。

 這種把返回地址壓入棧的簡單的機制可以讓函數在稍後返回到程序中正確的點。C語言標準的調用/返回機制恰好與棧提供的後進先出的內存管理方法吻合。

數據傳送

  X86-64中,能夠經過寄存器來傳遞最多6個參數。寄存器的使用是有特殊順序的,以下表所示,會根據參數的順序爲其分配寄存器。

image-20201107150424194

  當傳遞參數超過6個時,會把大於6個的部分放在棧上。

  以下圖所示的部分,紅框內的參數就是存儲在棧上的。

image-20201107152154583

棧上的局部存儲

  一般來講,不須要超出寄存器大小的本地存儲區域。不過有些時候,局部數據必須存放在內存中,常見的狀況包括:1.寄存器不足夠存放全部的本地數據。
2.對一個局部變量使用地址運算符‘&‘,所以必須可以爲它產生一個地址。3.某些局部變量是數組或結構,所以必須可以經過數組或結構引用被訪問到。

  下面看一個例子。

image-20201107153947303

image-20201107154242368

  第二行的subq指令將棧指針減去32,實際上就是分配了32個字節的內存空間。在棧指針的基礎上,分別+24,+20,+18,+17,用來存放1,2,3,4的值。在第7行中,使用leaq生成到17(%rsp)的指針並賦值給%rax。接着在棧指針基礎上+8和+16的位置存放參數7和參數8。而參數1-參數6分別放在6個寄存器中。棧幀的結構以下圖所示。

image-20201107155835033

  上述彙編中第2-15行都是在爲調用proc作準備(爲局部變量和函數創建棧幀,將函數加載到寄存器)。當準備工做完成後,就會開始執行proc的代碼。當程序返回call_proc時,代碼會取出4個局部變量(第17~20行),並執行最終的計算。在程序結束前,把棧指針加32,釋放這個棧幀。

寄存器中的局部存儲

  寄存器組是惟一被全部過程共享的資源。所以,在某些調用過程當中,咱們要不一樣過程調用的寄存器不能相互影響。

  根據慣例,寄存器%rbx、%rbp和%r12~%r15被劃分爲被調用者保存寄存器。當過程P調用過程Q時,Q必須保存這些寄存器的值,保證它們的值在Q返回到P時與Q被調用時是同樣的。過程Q保存一個寄存器的值不變,要麼就是根本不去改變它,要麼就是把原始值壓入棧中。有了這條慣例,P的代碼就能安全地把值存在被調用者保存寄存器中(固然,要先把以前的值保存到棧上),調用Q,而後繼續使用寄存器中的值。

  下面看個例子。

image-20201107160726777

  能夠看到GCC生成的代碼使用了兩個被調用者保存寄存器:%rbp保存x和%rbx保存計算出來的Q(y)的值。在函數的開頭,把這兩個寄存器的值保存到棧中(第2~3行)。在第一次調用Q以前,把參數ⅹ複製到%rbp(第5行)。在第二次調用Q以前,把此次調用的結果複製到%rbx (第8行)。在函數的結尾,(第13~14行),把它們從棧中彈出,恢復這兩個被調用者保存寄器的值。注意它們的彈壓入順序,說明了棧的後進先出規則。

遞歸過程

  根據以前的內容能夠知道,多個過程調用在棧中都有本身的私有空間,多個未完成調用的局部變量不會相互影響,遞歸本質上也是多個過程的相互調用。以下所示爲一個計算階乘的遞歸調用。

image-20201107163433595

  上圖給出了遞歸的階乘函數的C代碼和生成的彙編代碼。能夠看到彙編代碼使用寄存器%rbx來保存參數n,先把已有的值保存在棧上(第2行),隨後在返回前恢復該值(第11行)。根據棧的使用特性和寄存器保存規則,能夠保證當遞歸調用 refact(n-1)返回時(第9行),(1)該次調用的結果會保存在寄存器號%rax中,(2)參數n的值仍然在寄存器各%rbx中。把這兩個值相乘就能獲得指望的結果。

數組分配和訪問

基本原則

  在機器代碼級是沒有數組這一更高級的概念的,只是你將其視爲字節的集合,這些字節的集合是在連續位置上存儲的,結構也是如此,它就是做爲字節集合來分配的,而後,C 編譯器的工做就是生成適當的代碼來分配該內存,從而當你去引用結構或數組的某個元素時,去獲取正確的值。

  數據類型T和整型常數N,聲明一個數組T A[N]。起始位置表示爲 X A {X_A} XA.這個聲明有兩個效果。首先,它在內存中分配一個 L ∙ N L \bullet N LN字節的連續區域,這裏L是數據類型T的大小(單位爲字節)。其次,它引入了標識符A,能夠用來做A爲指向數組開頭的指針,這個指針的值就是 X A {X_A} XA。能夠用0~N-1的整數索引來訪問該數組元素。數組元素i會被存放在地址爲 X A + L ∙ i {X_A} + L \bullet i XA+Li的地方。

char A[12];

char *B[8];

char C[6];

char *D[5];

數組 元素大小 總的大小 起始地址 元素i
A 1 12 X A {X_A} XA X A + i {X_A}+i XA+i
B 8 64 X B {X_B} XB X B + 8 i {X_B}+8i XB+8i
C 4 24 X C {X_C} XC X C + 4 i {X_C}+4i XC+4i
D 8 40 X D {X_D} XD X D + 8 i {X_D}+8i XD+8i
  指針運算

  假設整型數組E的起始地址和整數索引i分別存放在寄存器是%rdx和%rcx中。下面是一些與E有關的表達式。咱們還給出了每一個表達式的彙編代碼實現,結果存放在寄存器號%eax(若是是數據)或寄存器號%rax(若是是指針)中。

image-20201108173123826

二維數組

  對於一個聲明爲T D[R] [C]的二維數組來講,數組D[i] [j]的內存地址爲 X D + L ( C ∙ i + j ) {X_D} + L(C \bullet i + j) XD+L(Ci+j)

  這裏,L是數據類型T以字節爲單位的大小。假設 X A {X_A} XA、i和j分別在寄存器%rdi、%rsi和%rdx中。而後,能夠用下面的代碼將數組元素A[i] [j]複製到寄存器%eax中:

/*A in %rdi, i in %rsi, and j in %rdx*/ 
leaq (%rsi,%rsi,2), %rax //Compute 3i
leaq (%rdi,%rax,4)%rax //Compute XA+ 12i 
movl (7rax, rdx, 4)%eax //Read from M[XA+ 12i+4j]

異質的數據結構

結構體

  C語言的 struct聲明建立一個數據類型,將可能不一樣類型的對象聚合到一個對象中。結構的全部組成部分都存放在內存中一段連續的區域內,而指向結構的指針就是結構第個字節的地址。編譯器維護關於每一個結構類型的信息,指示每一個字段( field)的字節偏移。它以這些偏移做爲內存引用指令中的位移,從而產生對結構元素的引用。

  結構體在內存中是以偏移的方式存儲的,具體能夠看這個文章。Linux內核中container_of宏的詳細解釋

struct rec {
	int i;
	int j;
	int a[2];
	int *p;
};

  這個結構包括4個字段:兩個4字節int、一個由兩個類型爲int的元素組成的數組和一個8字節整型指針,總共是24個字節。

image-20201109153549034

  看彙編代碼也能夠看出,結構體成員的訪問是基地址加上偏移地址的方式。例如,假設 struct rec*類型的變量r放在寄存器%rdi中。那麼下面的代碼將元素r->i複製到元素r->j:

/*Registers:r in %rdi,i %rsi */
movl (%rdi), %eax //Get r->i 
movl %eax, 4(%rdi) //Store in r-27
leaq  8(%rdi,%rsi,4),//%rax 獲得一個指針,8+4*%rsi,&(r->a[i])
數據對齊

  關於字節對齊的相關內容見我整理的《嵌入式軟件筆試面試知識點總結》裏面詳細介紹了字節對齊的相關內容。

在機器級程序中將控制和程序結合起來

理解指針

  關於指針的幾點說明:

  1.每一個指針都對應一個類型

int *ip;//ip爲一個指向int類型對象的指針
char **cpp;//cpp爲指向指針的指針,即cpp指向的自己就是一個指向char類型對象的指針
void *p;//p爲通用指針,malloc的返回值爲通用指針,經過強制類型轉換能夠轉換成咱們須要的指針類型

  2.每一個指針都有一個值。這個值能夠是某個指定類型的對象的地址,也能夠是一個特殊的NULL(0)。

  3.指針用&運算符建立。在彙編代碼中,用leaq指令計算內存引用的地址。

int i = 0;
int *p = &i;//取i的地址賦值給p指針

  4.* 操做符用於間接引用指針。引用的結果是一個具體的數值,它的類型與該指針的類型一致。

  5.數組與指針緊密聯繫,可是又有所區別。

int a[10] ={ 0};

一個數組的名字能夠像一個指針變量同樣引用(可是不能修改)。數組引用(例如a[5]與指針運算和間接引用(例如*(a+5))有同樣的效果。

數組引用和指針運算都須要用對象大小對偏移量進行伸縮。當咱們寫表達式a+i,這裏指針p的值爲a,獲得的地址計算爲a+L * i,這裏L是與a相關聯的數據類型的大小。

數組名對應的是一塊內存地址,不能修改。指針指向的是任意一塊內存,其值能夠隨意修改。

  6.將指針從一種類型強制轉換成另外一種類型,只改變它的類型,而不改變它的值。強制類型轉換的一個效果是改變指針運算的伸縮。例如,若是a是一個char * 類型的指針,它的值爲a,a+7結果爲a+7 * 1,而表達式(int* )p+7結果爲p+4 * 7。

內存越界引用

  C對於數組引用不進行任何邊界檢查,並且局部變量和狀態信息(例如保存的寄存器值和返回地址)都存放在棧中。這兩種狀況結合到一塊兒就能致使嚴重的程序錯誤,對越界的數組元素的寫操做會破壞存儲在棧中的狀態信息。當程序使用這個被破壞的狀態,就會出現很嚴重的錯誤,一種特別常見的狀態破壞稱爲緩衝區溢出( buffer overflow)。

image-20201109201730652

image-20201109201936732

  上述C代碼,buf只分配了8個字節的大小,任何超過7字節的都會使的數組越界。

  輸入不一樣數量的字符串會發生不一樣的錯誤,具體能夠參考下圖。

image-20201109202120957

  echo函數的棧分佈以下圖所示。

image-20201109202614633

  字符串到23個字符以前都沒有嚴重的後果,可是超過之後,返回指針的值以及更多可能的保存狀態會被破壞。若是存儲的返回地址的值被破壞了,那麼ret指令(第8行)會致使程序跳轉到一個徹底意想不到的位置。若是隻看C代碼,根本就不可能看出會有上面這些行爲。只有經過研究機器代碼級別旳程序才能理解像gets這樣的函數進行的內存越界寫的影響。

浮點代碼

  計算機中的浮點數能夠說是"另類"的存在,每次提到數據相關的內容時,浮點數老是會被單獨拿出來講。一樣,在彙編中浮點數也是和其餘類型的數據有所差異的,咱們須要考慮如下幾個方面:1.如何存儲和訪問浮點數值。一般是經過某種寄存器方式來完成2.對浮點數據操做的指令3.向函數傳遞浮點數參數和從函數返回浮點數結果的規則。4.函數調用過程當中保存寄存器的規則—例如,一些寄存器被指定爲調用者保存,而其餘的被指定爲被調用者保存。

  X86-64浮點數是基於SSE或AVX的,包括傳遞過程參數和返回值的規則。在這裏,咱們講解的是基於AVX2。在利用GCC進行編譯時,加上-mavx2,GCC會生成AVX2代碼。

  以下圖所示,AVX浮點體系結構容許數據存儲在16個YMM寄存器中,它們的名字爲%ymm0~%ymm15。每一個YMM寄存器都是256位(32字節)。當對標量數據操做時,這些寄存器只保存浮點數,並且只使用低32位(對於float)或64位(對於 double)。彙編代碼用寄存器的 SSE XMM寄存器名字%xmm0~%xmm15來引用它們,每一個XMM寄存器都是對應的YMM寄存器的低128位(16字節)。

image-20201110155725299

   其實浮點數的彙編指令和整數的指令都是差很少的,不須要都記住,用到的時候再查詢就能夠了。

數據傳送指令

image-20201110155810267

雙操做數浮點轉換指令

image-20201110160221164

三操做數浮點轉換指令

image-20201110160314177

標量浮點算術運算

image-20201110160352682

浮點數的位級操做

image-20201110160422252

比較浮點數值的指令

image-20201110160511101
  在本章中,咱們瞭解了C語言提供的抽象層下面的東西。經過讓編譯器產生機器級程序的彙編代碼表示,咱們瞭解了編譯器和它的優化能力,以及機器、數據類型和指令集。本章要求咱們要能閱讀和理解編譯器產生的機器級代碼,機器指令並不須要都記住,在須要的時候查就能夠了。Arm的指令集和X86指令集大同小異,作嵌入式軟件開發掌握經常使用的Arm指令集就能夠。嵌入式軟件開發知識點詳細介紹了經常使用的Arm指令集及其含義,有須要的能夠關注個人公衆號領取。

  養成習慣,先贊後看!若是以爲寫的不錯,歡迎關注,點贊,轉發,謝謝!

相關文章
相關標籤/搜索