本文是《go調度器源代碼情景分析》系列 第一章 預備知識的第4小節。
匯編語言是每位后端程序員都應該掌握的一門語言,因為學會了匯編語言,不管是對我們調試程序還是研究與理解計算機底層的一些運行原理都具有非常重要的作用,所以建議有興趣的讀者可以多花點時間把它學好。
與高級編程語言一樣,匯編語言也是一門完整的計算機編程語言,它所涉及的知識內容也很多,好在我們的主要目標是通過對本小節的學習而有能力去讀懂匯編代碼,而不是要用匯編語言去寫代碼,所以本節並不會全面介紹匯編語言,而只會選取匯編語言的一個子集--匯編指令出來做介紹。不過,雖然這里的介紹做了精簡,但讀者大可放心,熟練運用這些知識就足以應付本書將要分析的goroutine調度器中的匯編代碼了。
說到匯編指令,不得不提一下機器指令,二進制格式的機器指令才是CPU能夠理解的語言,因為它是二進制格式的,非常便於CPU的解析和執行,但並不利於人類閱讀和交流,所以才有了跟機器指令一一對應的匯編指令,匯編指令使用符號來表示機器指令,下面的例子非常直觀的說明了這兩種指令之間的差異:
0x40054d: add %rdx,%rax # 匯編指令 (gdb) x/3xb 0x40054d 0x40054d: 0x48 0x01 0xd0 # 機器指令 (gdb)
同樣是把rdx和rax寄存器中的值相加,匯編指令為:add %rdx,%rax,而機器指令卻是三個數字:0x48 0x01 0xd0,顯然,匯編指令對人類來說更加友好,它更加易記易讀和易寫。
匯編指令格式
因為不同的CPU所支持的機器指令不一樣,所以其匯編指令也不同,即使是相同的CPU,不同的匯編工具和平台所使用的匯編指令格式也有些差別,由於本書主要專注於AMD64 Linux平台下的go調度器,因此下面我們只介紹該平台下所使用的AT&T格式的匯編指令,AT&T匯編指令的基本格式為:
操作碼 [操作數]
可以看到每一條匯編指令通常都由兩部分組成:
-
操作碼:操作碼指示CPU執行什么操作,比如是執行加法,減法還是讀寫內存。每條指令都必須要有操作碼。
-
操作數:操作數是操作的對象,比如加法操作需要兩個加數,這兩個加數就是這條指令的操作數。操作數的個數一般是0個,1個或2個。
來看幾個匯編指令的例子
add %rdx,%rax
這條指令的操作碼是add,表示執行加法操作,它有兩個操作數,rdx和rax。如果一條指令有兩個操作數,那么第一個操作數叫做源操作數,第二個操作數叫做目的操作數,顧名思義,目的操作數表示這條指令執行完后結果應該保存的地方。所以上面這條指令表示對rax和rdx寄存器里面的值求和,並把結果保存在rax寄存器中。其實這條指令的第二個操作數rax寄存器既是源操作數也是目的操作數,因為rax既是加法操作的兩個加數之一,又得存放加法操作的結果。這條指令執行完后rax寄存器的值發生了改變,指令執行前的值被覆蓋而丟失了,如果rax寄存器之前的值還有用,那么就得先用指令把它保存到其它寄存器或內存之中。
再來看一個只有一個操作數的例子:
callq 0x400526
這條指令的操作碼是callq,表示調用函數,操作數是0x400526,它是被調用函數的地址。
最后來看一條沒有操作數的指令:
retq
這條指令只有操作碼retq,表示從被調用函數返回到調用函數繼續執行。
為了更好的理解AT&T格式的匯編指令,這里先對其格式做一個簡要的說明:
- AT&T格式的匯編指令中,寄存器名需要加%作為前綴,前面我們已經見過;
- 有2個操作數的指令中,第一個操作數是源操作數,第二個是目的操作數,剛才也討論過,不過那條指令中的源和目的不是那么清晰,來看一個直白的,mov %eax,%esi,這條指令表示把eax寄存器中的值拷貝給esi,這條指令中源和目的就很清楚了;
- 立即操作數需要加上$符號做前綴,如 "mov $0x1 %rdi" 這條指令中第一個操作數不是寄存器,也不是內存地址,而是直接寫在指令中的一個常數,這種操作數叫做立即操作數。這條指令表示把數值0x1放入rdi寄存器中。
- 寄存器間接尋址的格式為 offset(%register),如果offset為0,則可以略去偏移不寫直接寫成(%register)。何為間接尋址呢?其實就是指指令中的寄存器並不是真正的源操作數或目的操作數,寄存器的值是一個內存地址,這個地址對應的內存才是真正的源或目的操作數,比如 mov %rax, (%rsp)這條指令,第二個操作數(%rsp)中的寄存器的名字用括號括起來了,表示間接尋址,rsp的值是一個內存地址,這條指令的真實意圖是把rax寄存器中的值賦值給rsp寄存器的值(內存地址)對應的內存,rsp寄存器本身的值不會被修改,作為比較,我們看一下 mov %rax, %rsp 這條指令 ,這里第二個操作數僅僅少了個括號,變成了直接尋址,意思完全不一樣了,這條指令的意思是把rax的值賦給rsp,這樣rsp寄存器的值被修改為跟rax寄存器一樣的值了。下面的2張圖展示了這兩種尋址方式的不同:
執行mov %rax, %rsp這條指令之前,rsp寄存器的值是x,rax寄存器的值是y,執行指令之后,rax寄存器的值被復制給了rsp寄存器,所以rsp寄存器的值變成了y,可以看出,采用直接尋址方式時,目的操作數rsp寄存器的值在指令執行之前和指令執行之后發生了變化,源操作數沒有變化。再看看間接尋址方式的示意圖:
執行mov %rax, (%rsp)這條指令之前,rax寄存器的值是y,rsp寄存器的值是X,它是一個內存地址,如上圖所示,我們用了一個紅色箭頭從rsp寄存器指向了地址為X的內存;執行指令之后,rsp寄存器的值並沒有發生變化,而rsp所指的內存中的值卻發生了改變,因為這條指令的目的操作數采用了間接尋址方式(%rsp),指令執行的結果是rax寄存器中的值被復制到了rsp寄存器存放的地址所對應的8個內存單元中。另外需要注意的是指令中出現的內存地址僅僅是起始地址,具體要操作以這個地址為起始地址的連續幾個內存單元要根據具體的指令而定,比如上圖中的mov %rax,(%rsp),因為源操作數是一個64位的寄存器,所以這條指令會復制rax存放的8個字節到地址為X, X+1, X+2, X+3, X+4, X+5, X+6, X+7這8個內存單元中去。
間接尋址格式offset(%register)中前面的offset表示偏移,如-0x8(%rbp),-0x8就是偏移量,整個表示rbp寄存器里面保存的地址值先減去8(因為偏移是負8)得到的地址對應的內存。
- 與內存相關的一些指令的操作碼會加上b, w, l和q字母分別表示操作的內存是1,2,4還是8個字節,比如指令 movl $0x0,-0x8(%rbp) ,這條指令操作碼movl的后綴字母l說明我們要把從-0x8(%rbp) 這個地址開始的4個內存單元賦值為0。可能有讀者會問,那如果我要操作3個,或5個內存單元呢?很遺憾的是cpu沒有提供相應的單條指令,我們只能通過多條指令組合起來達到目的。
常用指令詳解
x86-64匯編指令上千條,這里不會去詳細講解每一條,讀者如果有興趣可以參考匯編語言相關教程。我們在這里着重關注幾條非常常見或是能幫助我們理解程序運行機制的指令。
-
mov指令
mov 源操作數 目的操作數
該指令復制源操作數到目的操作數。例:
mov%rsp,%rbp # 直接尋址,把rsp的值拷貝給rbp,相當於 rbp = rsp mov-0x8(%rbp),%edx# 源操作數間接尋址,目的操作數直接尋址。從內存中讀取4個字節到edx寄存器 mov%rsi,-0x8(%rbp) # 源操作數直接尋址,目的操作數間接尋址。把rsi寄存器中的8字節值寫入內存
-
add/sub指令
add 源操作數 目的操作數 sub 源操作數 目的操作數
加減運算指令。例:
sub$0x350,%rsp # 源操作數是立即操作數,目的操作數直接尋址。rsp = rsp - 0x350 add%rdx,%rax # 直接尋址。rax = rax + rdx addl$0x1,-0x8(%rbp) # 源操作數是立即操作數,目的操作數間接尋址。內存中的值加1(addl后綴字母l表示操作內存中的4個字節)
-
call/ret指令
call 目標地址 ret
call指令執行函數調用。CPU執行call指令時首先會把rip寄存器中的值入棧,然后設置rip值為目標地址,又因為rip寄存器決定了下一條需要執行的指令,所以當CPU執行完當前call指令后就會跳轉到目標地址去執行。
ret指令從被調用函數返回調用函數,它的實現原理是把call指令入棧的返回地址彈出給rip寄存器。
下面用例子對這兩條指令的原理加以說明。
# 調用函數片段 0x0000000000400559: callq 0x400526 <sum> 0x000000000040055e: mov %eax,-0x4(%rbp) -------------------------------------------------- # 被調用函數片段 0x0000000000400526: push %rbp ...... 0x000000000040053f: retq
上面代碼片段中,調用函數使用callq 0x400526指令調用0x400526處的函數,0x400526是被調用函數的第一條指令所在的地址。被調用函數在0x40053f處執行retq指令返回調用函數繼續執行0x40055e地址處的指令。注意這兩條指令會涉及入棧和出棧操作,所以會影響rsp寄存器的值。
從上圖可以看到call指令執行之初rip寄存器的值是緊跟call后面那一條指令的地址,即0x40055e,但當call指令完成后但還未開始執行下一條指令之前,rip寄存器的值變成了call指令的操作數,即被調用函數的地址0x400526,這樣CPU就會跳轉到被調用函數去執行了。
同時還需要注意的是這里的call指令執行時把call指令后面那一條指令的地址 0x40055e PUSH到了棧上,所以一條call指令修改了3個地方的值:rip寄存器、rsp和棧。
下面我們再看看從被調用函數返回調用函數時執行的ret指令,其示意圖如下:
可以看到ret指令執行的操作跟call指令執行的操作完全相反,ret指令開始執行時rip寄存器的值是緊跟ret指令后面的那個地址,也就是0x400540,但ret指令執行過程中會把之前call指令PUSH到棧上的返回地址 0x40055e POP給rip寄存器,這樣,當ret執行完成后就會從被調用函數返回到調用函數的call指令的下一條指令繼續執行。這里同樣要注意的是retq指令也會修改rsp寄存器的值。
-
jmp/je/jle/jg/jge等等j開頭的指令
這些都屬於跳轉指令,操作碼后面直接跟要跳轉到的地址或存有地址的寄存器,這些指令與高級編程語言中的 goto 和 if 等語句對應。用法示例:
jmp 0x4005f2 jle 0x4005ee jl 0x4005b8
-
push/pop指令
push 源操作數 pop 目的操作數
專用於函數調用棧的入棧出棧指令,這兩個指令都會自動修改rsp寄存器。
push入棧時rsp寄存器的值先減去8把棧位置留出來,然后把操作數復制到rsp所指位置。push指令相當於:
sub $8,%rsp mov 源操作數,(%rsp)
push指令需要重點注意rsp寄存器的變化。
pop出棧時先把rsp寄存器所指位置的數據復制到目的操作數中,然后rsp寄存器的值加8。pop指令相當於:
mov(%rsp),目的操作數 add$8,%rsp
同樣,pop指令也需要重點注意rsp寄存器的變化。
- leave指令
leave指令沒有操作數,它一般放在函數的尾部ret指令之前,用於調整rsp和rbp,這條指令相當於如下兩條指令:
mov%rbp,%rsp pop%rbp
AMD64匯編我們就介紹這么多,下一節我們將介紹goruntime中使用的go匯編語言,它與這里介紹的AMD64匯編類似,但有一些差別。理解了本節的內容,go匯編也就很容易理解了。