本篇會先從彙編聊起, 帶你們熟悉最接近機器碼的運轉方式, 以後會再來看在基於虛擬機的編程語言中如何模擬這種相似的環境。git
0.計算機的組成
咱們如今的計算機大都基於馮諾依曼體系結構, 最先由馮諾依曼(John von Neumann, 1903年12月28日-1957年2月8日)提出, 他也被叫作「現代計算機之父」 (我心目中的計算機之父是圖靈)。
該結構下, 計算機被描述爲五個部分組成:github
- 輸入設備: 顧名思義, 就是信息的輸入, 常見的設備如鼠標, 鍵盤等
- 輸出設備: 將計算結果輸出的設備, 常見的如打印機輸出, 顯示器輸出, 又如咱們在玩嵌入式時, 燈也是輸出設備, 燈的亮和滅也是一種輸出
- 存儲器: 存儲器是程序和數據的存放處, 供運算器和控制器使用, 常見設備如內存和緩存器
- 運算器: 也叫算數邏輯單元(ALU), 是實際執行計算的元件
- 控制器: 也叫控制單元(CU), 主要任務是控制程序和數據的運行, 以及處理運算結果。 ALU 與 CU 一塊兒組成了咱們如今常說的中央處理器(CPU), 也叫芯片
這五種設備還能進一步簡化, 輸入設備和輸出設備共同組成 I/0設備, 運算器和控制器組成 CPU, 以及最後的主存做爲存儲器。編程
而後有兩個元件須要注意的:
緩存做爲存儲器是集成在 CPU 裏的, 用於加速數據讀取。
硬盤並非存儲器的存在, 做爲一個持久化的存儲設備, 它應該被歸爲 I/O 設備, 程序要執行時會先從硬盤把程序和數據讀入主存, 計算完成後輸出到硬盤的文件。數組
1.x86
x86 有兩種意思, 一種是指 x86 的處理器(CPU), 該型號最先由英特爾在 1976 年開發, 因其型號以86結尾, 如80186,80286, 80386, 因此叫x86。還有一種就是指 x86 的操做指令集, 就是配套 x86 處理器使用的。因爲技術的發展, 如今又出現了 x86-64 或者 amd64, 這個名字很熟悉, 咱們在安裝軟件的時候常常會讓咱們選擇是 i386 仍是 amd64, 前者是通常 32 位的 x86 版本, 後者是 x86 的 64 位擴展, 由於最先由 AMD 公司開發發佈, 因此以 amd64 命名。緩存
今天咱們會了解一下基本的 x86 彙編的知識。服務器
如今的彙編大體分爲兩種流派:閉包
x86 爲表明的使用 CISC(Complex Instruction Set Computer) 複雜指令集並經過棧對數據進行操做, x86-64 中因爲增長了寄存器, 在函數參數傳遞時也會使用寄存器來傳遞。複雜指令集會各類功能強大的處理指令供用戶使用, 如 call
指令調用函數時, 實際上會組合使用多條基礎指令。x86 大量應用於 PC 及服務器。編程語言
下次還要介紹的 ARM 爲表明的 RISC(Reduced Instruction Set Computer) 精簡指令集並經過寄存器來操做。相對於複雜指令集, 精簡指令集提供的指令類型較少。好處就是須要學習的指令相對較少也不復雜。ARM 大量用於手機, 還有嵌入式開發, 其中咱們比較熟悉的開發板樹梅派也搭載了 ARM CPU。函數
2.一個彙編程序
上次咱們簡單的看過 CPython 裏的字節碼, 字節碼和操做指令是一一對應的。一樣的彙編指令也是和機器碼對應的, 一個被編譯到平臺的程序(像由 C 語言這種不經過虛擬機運行的程序), 最後就只剩下二進制機器碼, 對咱們來講基本不可讀。可是咱們仍是能夠經過工具翻譯成彙編來了解程序的邏輯。這一塊是一個單獨的領域,能夠叫二進制分析或者逆向工程, 比較多用於計算機病毒的分析, 軟件破解等, 是一個很是 有趣 且 枯燥 且 有挑戰性 的領域 :)工具
咱們來結合實例來看看一個彙編程序它到底長什麼樣, 是否是真的像傳說中那麼晦澀難懂。 我準備了一個 C 語言程序 test.c
:
#include<stdio.h> int step = 2; int main() { int n = 1; int count = 0; while (count<10) { n += step; count++; } printf("result: %d\n", n); }
這是一個簡單的累加程序, 函數內部定義了兩個局部變量 n 和 count 用於計算, 函數外部定義了全局變量 step 來決定累加的跨度。
咱們運行 gcc test.c -S test.asm
, 來看看輸出的 x86-64 彙編, 如下我只截取了關鍵部分:
.file "test.c" .text .globl step # int step .data .align 4 .type step, @object .size step, 4 step: .long 2 # step = 2 .section .rodata .LC0: .string "result: %d\n" .text .globl main .type main, @function main: .LFB0: .cfi_startproc endbr64 pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $16, %rsp movl $1, -8(%rbp) # n = 1 movl $0, -4(%rbp) # count = 0 jmp .L2 # goto .L2 .L3: movl step(%rip), %eax # eax = 2 addl %eax, -8(%rbp) # n += eax addl $1, -4(%rbp) # count += 1 .L2: cmpl $9, -4(%rbp) # if 9 >= count jle .L3 # goto .L3 movl -8(%rbp), %eax # eax = n movl %eax, %esi # esi = eax leaq .LC0(%rip), %rdi # rdi = "result: %d\n" movl $0, %eax # eax = 0 call printf@PLT # printf(rdi, esi); movl $0, %eax # eax = 0 leave .cfi_def_cfa 7, 8 ret # return eax .cfi_endproc
若是對彙編不太瞭解的同窗, 看起來可能會一頭霧水, 我在代碼中加入了註釋, 咱們來對照 C 語言的代碼逐行分析一下。
首先爲了能更好的理解這些彙編的含義, 咱們須要補充一些內存的知識。
一個程序在加載到內存之後, 進程會被分紅多個內存段。咱們來看幾個主要的段:
------------------------------------------ |text|static|heap | stack|.env| ------------------------------------------ /\ /\ /\ /\ 0x00000000 rsp rbp 0xffffffff
- text: 主要存儲程序執行所需的指令, 這一節是隻讀的, 嘗試寫入會形成段錯誤(Segment Error)。
- static: 主要存儲全局變量與靜態變量, 這一段還能夠往下劃分爲
data
和bss
,data
存放全局初始化的變量,bss
主要存儲沒有初始化的全局變量。另外文字常量也存儲在該段。 - heap: 這是堆區域,用戶根據須要在這裏開闢空間使用,對應 C 語言中的
malloc
, 可是使用完畢後須要自行釋放,對應 C 語言的free
。GC 垃圾回收主要處理的就是這塊區域。 - stack: 主要存儲的是函數的調用,還有局部變量。
- env: 存放環境信息以及運轉信息。
一個32位的程序啓動後會建立一個4G的虛擬內存, 這是 32 位系統能表示的最大內存, 地址可表示的範圍爲0x00000000-0xffffffff, 單位是字節(Byte), 操做系統會幫咱們把虛擬內存映射到物理地址上, 這些在程序裏幾乎無感。
有一種狀況, 當物理內存接近滿載的時候, 虛擬內存就不會再映射到主存上了, 而是經過 SWAP 直接與硬盤交互數據。由於衆所周知的緣由, 直接和硬盤交互效率和內存天差地別, 這樣頻繁的活動就形成了 「磁盤抖動」, 也就是咱們肉眼可見的屏幕卡頓, 卡死的狀況。
好了, 咱們再倒回去看彙編。 main:
以前那段能看出些什麼邏輯呢?
咱們能夠看到 test.c
代碼中的 step
全局變量和一個字符串常量 "result: %d\n" 被單獨拿出來聲明,分別用 .global step
.size step, 4
.long 2
來定義了 C 代碼中的 int step = 2;
, 以及 .string "result: %d\n"
聲明瞭這個字符串常量。按照咱們上面的描述,這兩個數據應該是存儲在 heap 前面的 static 部分。
下面咱們再來看從 main:
開始的 main 函數。main 函數開始, 全部的數據都在 stack 上動態存儲了, 也是程序的重點。
首先咱們看 n 和 count 的賦值:
-------------------------------------------- | | | n | count | -------------------------------------------- /\ /\ /\ /\ 0xfffffff0(rsp) 0xfffffff8 0xfffffffc 0xffffffff(rbp)
如上圖所示,首先這裏用到兩個寄存器, rbp 指向棧(stack)底端, rsp 指向棧頂。初始化時 movq %rsp, %rbp
將 rsp 和 rbp 指向同一處,也就是清空棧, movq
指令在 64 位系統中賦值, 當只須要操做 32 位的數據的時候, 就只須要使用 movl 指令。
而後咱們須要爲 n 和 count 變量開闢內存空間, 由於內存棧(stack) 的結構有些特殊是從高位往低位走的, 因此開闢空間的時候是棧頂減掉須要的空間長度,就是這裏的 subq $16, %rsp
, $16
是常數 16。
而後是給 n 和 count 賦值, 在 C 語言裏咱們學過 int 類型須要 4 個字節的空間, 因此這兩個指令 movl $1, -8(%rbp)
和 movl $1, -4(%rbp)
使用相對尋址方式找到變量的內存空間進行賦值。-4(%rbp)
是根據rbp指向的內存地址進行偏移量計算,換個寫法能夠寫成: %rbp - 4
。
這裏有個小疑點,爲何 rsp 要偏移 16, 多分配 8 個字節?
這裏涉及到一個內存對齊的問題,CPU 讀取內存的時候是按 2, 4, 8 的倍數讀取的, 有讀取顆粒, 具體的對齊方式和系統有關, 個人 64 位系統是 16 字節的對齊方式, 換句話說 esp 寄存器的最後一位只有多是 0 或者 f。由於對齊, 就算我只定義一個 int 也會分配 16 字節, 若是我定義了五個 int 變量, 則會分配 32 字節。
下面是 while
的表達, 上面初始化數據之後, 使用 jmp
命令能夠跳到指定的代碼段, 對應 C 語言中的 goto
語句。而後是 cmp
命令判斷條件是否達成, 達成則 goto 到 L3 代碼段。這裏須要使用 RIP 尋址從 static 內存段中讀取全局變量 step 的值。後邊的 addl
就是對指定變量作加法了。
最後再來看一下 printf 的函數調用, 64位系統中增長了不少個通用寄存器, 這裏 printf 的參數就直接加載到 rdi
和 esi
寄存器裏調用, 返回值會放在 eax
寄存器內。還有一種調用方式在 32 位系統中比較常見, 32位系統一共就 8 個通用寄存器, 因而就把參數壓入棧中調用, 取參數時仍是按照偏移量取, 像這樣:
|----| |----| <- printf 的 rsp 棧頂 |----| <- printf 的 rbp 棧底 |main| <- main 函數的棧底地址,用於函數執行完畢後返回 main 函數 |str-| <- printf 的第一個參數 指向字符串常量 ""result: %d\n"", 這裏是引用類型和 C 語言調用徹底對的上 | n | <- printf 的第二個參數 C 語言也是值傳遞 |----| <- main 函數的 rsp 棧頂 |....| |....| |----| <- main 函數的 rbp 棧底
函數的參數仍是經過 rbp 的相對尋址來得到。當printf函數執行完畢後, 會將 rbp 重置到原來 main 函數的棧底。
最後就是把 0 賦值給 eax 寄存器, ret
會把 eax 寄存器裏的值當返回值返回。
小結
經過這個彙編的例子, 相信你們多少對彙編和程序底層的運行邏輯有些理解了吧。彙編作的事很簡單就是對內存數據計算, 咱們的程序就是這些簡單操做的複雜組合。
這讓我想起有一個極小化語言叫 BrainFxxk, 只有八個操做指令,就具有了圖靈完備, 是一門很是有趣的語言,有時間我會再說。
我本身也實現了一個簡單的 bf 語言的解釋器: https://github.com/00Kai0/myBrainFk 。 最近比較懶, 還有不少問題有待我慢慢改善。
這裏也出現了後面要講的具體的虛擬機的時候要說的 棧式虛擬機 和 寄存器虛擬機 的原型。好比 movl $1, -8(%rbp)
就是一個棧式操做, 又好比這段 x86-64 中的函數調用就是把操做數加載到寄存器進行的寄存器式操做。
後面咱們從內存角度瞭解了, 局部變量是怎麼在函數調用完後失效的。
其實就是調用完後棧頂 RSP 從新返回到調用前的位置, 當再次回到以前的局部變量的位置時, 是會覆蓋老舊數據的。這裏還要提個醒, 在 C 語言中聲明一個新的局部變量的時候記得初始化, 否則裏面多是以前的數據殘留。
全局變量有一點不一樣, 它被分配到靜態空間, 這個空間在程序編譯時就是肯定的, 直到程序終止纔會釋放。
拋出一個小問題: 咱們在高級語言中使用的閉包要去怎麼實現呢?
閉包在這個結構裏應該是不存在的了, 否則爲何 C 語言沒有閉包。若是之後講到虛擬機的時候能夠再研究一下吧。
後面咱們也看到了 CPU 是怎麼處理函數的參數傳遞的, 引用類型時就傳遞引用內存地址, 值類型時就直接複製到參數列表就好了。不過我也看到過一些語言的處理有點不同, 好比 Golang 裏參數傳遞都是值傳遞的。參數包含數組的時候是將數組複製過去的, 相對的傳遞切片類型的時候效率要高一點, 由於切片的結構體裏只是包含一個數組的引用(一個指針)。
這些都很是有意思。
內存裏還有一個堆(heap), 篇幅有限, 以及沒有在例子中用到, 就沒說了, 下次必定吧 :)
最後我分享一個蒐集了一些古老病毒的庫: https://github.com/rdebath/viruses.
這些程序都是使用匯編編寫的, 感興趣的同窗能夠在本身學習的時候去研究下, 可是不建議在本地調試哦。
但願本篇文章能給到大家啓發, 探索的過程是最快樂的 :)