by 張悠慧教授(清華大學(xué)),課程鏈接 https://www.bilibili.com/video/av27895807/?p=1 ,大概有十幾個(gè)小時(shí)的視頻??赐暾n程之后我又回看了阮一峰老師的《匯編語言入門》博客 http://www.ruanyifeng.com/blog/2018/01/assembly-language-primer.html 。因此本筆記就依據(jù)這兩份資料來總結(jié)編寫。
另外,我覺得學(xué)習(xí)匯編語言之前最好先了解 計(jì)算機(jī)組成 的相關(guān)知識(shí),否則遇到一些 CPU 寄存器 內(nèi)存尋址
等相關(guān)概念時(shí),可能會(huì)聽著有點(diǎn)懵。
學(xué)完 計(jì)算機(jī)組成原理 之后接下來再學(xué)什么?通過本課程一開始的圖,就知道要緊接著學(xué)習(xí)匯編語言(再往下是編譯原理、操作系統(tǒng))。
本課程內(nèi)容太多我沒有看完,大概看了 2/3 吧,但這并不影響我來做這個(gè)總結(jié)記錄,因?yàn)槲也皇菍I(yè)搞匯編的,就來了解一下。
PS:雖然本課程十幾個(gè)小時(shí),看著不長,但是老師語速非??欤?x 速度聽都感覺講話挺快的,因此不敢 1.5x 看。
這個(gè)問題其實(shí)可以拆解成兩個(gè)問題:第一,匯編語言是什么?第二,高級(jí)語言程序猿學(xué)習(xí)匯編有何用?分開解答。
簡答來說,匯編語言就是機(jī)器語言(二進(jìn)制代碼)的助記符,每條匯編語言都能直接翻譯成機(jī)器語言,如下圖。計(jì)算機(jī)就是一臺(tái)各種電子設(shè)備組成的機(jī)器,它只能識(shí)別機(jī)器代碼,即一堆二進(jìn)制數(shù)字。但是二進(jìn)制不易于人類閱讀,而且在計(jì)算機(jī)發(fā)展初期還沒有高級(jí)語言和編譯器,因此出現(xiàn)了匯編語言。僅僅這樣一個(gè)微創(chuàng)新,就大大提升了開發(fā)效率。
匯編語言常見的語法是 指令 參數(shù)1, 參數(shù)2
,指令不同參數(shù)也不同。指令即 add
mov
jmp
等常見的算數(shù)、邏輯運(yùn)算和跳轉(zhuǎn)等功能,參數(shù)可以是立即數(shù)、內(nèi)存地址、寄存器。因此,匯編語言編程能深入到計(jì)算機(jī)編程的最底層,通常說匯編語言是一種“面向機(jī)器的語言 / 編程”。正是因?yàn)檫@個(gè)特點(diǎn),使得匯編語言能提供所有編程語言中最大的時(shí)間和空間的效率,因此至今依然活躍在某些計(jì)算機(jī)領(lǐng)域。
匯編語言都是針對(duì)特定的計(jì)算機(jī)體系結(jié)構(gòu)的,例如 x86 匯編(本課重點(diǎn)內(nèi)容)、MIPS 匯編、ARM 匯編,因此沒有讓所有計(jì)算機(jī)都通用的匯編語言。
一句話總結(jié)就是:了解程序是在計(jì)算機(jī)中是如何被執(zhí)行的,即透過現(xiàn)象(高級(jí)語言)看本質(zhì) —— 這是所有領(lǐng)域的技術(shù)人員都應(yīng)該追求的東西。那些能隨意在 php java js C++ 等語言中隨意切換的程序猿大牛,我想他肯定熟知這個(gè)本質(zhì)。
無論你日常編寫的語言多么高級(jí),肯定最終經(jīng)過轉(zhuǎn)換(編譯原理的內(nèi)容)然后生成匯編語言這種最底層的語言,再被計(jì)算機(jī)執(zhí)行。而“執(zhí)行”的本質(zhì),就可以通過匯編語言的一行一行代碼看出:使用了哪個(gè)指令、獲取了哪個(gè)內(nèi)存地址、操作了哪個(gè)內(nèi)存片段或者寄存器……
另外一個(gè)重要的部分就是程序執(zhí)行的時(shí)候的內(nèi)存模型。一段程序拿過來,哪些變量將被放在棧 stack ,哪些變量將被放在堆 heap ?以及這些內(nèi)存空間如何被釋放?甚至是你日常遇到的爆棧、內(nèi)存泄露等問題,了解了內(nèi)存模型,這些都會(huì)變的非常具象,不再懵。
所謂“指令集”,我理解就是一套操作 CPU 的指令體系集合,以及體系規(guī)范。指令集是一種上層定義,匯編就是其具體的體現(xiàn)和實(shí)現(xiàn)。指令集分兩類:
最初的計(jì)算機(jī)編程很麻煩,例如用紙帶打孔輸入,因此計(jì)算機(jī)的設(shè)計(jì)者就考慮將 CPU 做的復(fù)雜一點(diǎn),以簡化這種本來就很麻煩的編程。因此有了 CISC 復(fù)雜指令集。x86 就是其中的典型代表,x86 的特點(diǎn)是:
歷史原因,RISC 是 80 年代初發(fā)明的,那時(shí)整個(gè)計(jì)算機(jī)生態(tài)系統(tǒng)已經(jīng)形成,編譯器能力增強(qiáng),就不需要 CPU 對(duì)外暴露過度復(fù)雜的指令集,因此有了 RISC 精簡指令集。MIPS ARM 是 RISC 的代表,RISC 指令集特點(diǎn)是:
MIPS 特點(diǎn):
load
和 store
指令可以訪問內(nèi)存,其他指令只能操作寄存器和立即數(shù)(以寄存器為中心嘛)ARM 指令集特點(diǎn):
現(xiàn)代計(jì)算機(jī)中,像 x86 結(jié)構(gòu)雖然也是 CISC ,但那時(shí)對(duì)外的,內(nèi)部實(shí)現(xiàn)還是類似 RISC 實(shí)現(xiàn)的。因此,隨著歷史發(fā)展 CISC 和 RISC 的界限也越來越模糊。如果非要區(qū)分兩者,可以看是不是只允許 load
和 store
操作主存。
數(shù)字用二進(jìn)制表示終歸是一個(gè)數(shù)學(xué)問題,而常用的文本(中文、英文等)如何用二進(jìn)制表示,這就是“編碼”領(lǐng)域的問題。
例如十進(jìn)制 3 的二進(jìn)制表示是 11 這沒問題,但是在計(jì)算機(jī)中表示也是 11 嗎?—— 不對(duì),得分情況。例如在 C 語言中:
int
類型占 4 bytes ,即 4 * 8 = 32 bits 。那么 3 在計(jì)算機(jī)中表示就是 00000000 00000000 00000000 00000011
,即前面要補(bǔ)充上若干個(gè) 0 。short
long
等長度不一樣,表示方式也不一樣。因此各類語言中才會(huì)有類型轉(zhuǎn)換。補(bǔ)碼
二進(jìn)制負(fù)數(shù)是通過補(bǔ)碼來表示的,補(bǔ)碼算法是:按位取反、末尾加 1 。為何要用補(bǔ)碼呢?建議讀者看下阮一峰老師的《關(guān)于2的補(bǔ)碼》 http://www.ruanyifeng.com/blog/2009/08/twos_complement.html ,里面講的比我這里詳細(xì)。下面簡單通過一個(gè)例子來說明:
00110000 00111001
(這里暫且假設(shè)一個(gè)整數(shù)占 2 bytes ,這樣簡單)11001111 11000111
00110000 00111001
—— 這里體會(huì)到了補(bǔ)碼運(yùn)算的奧秘了,可以來回“搗騰”,完全符合數(shù)學(xué)中對(duì)正數(shù)負(fù)數(shù)的運(yùn)算邏輯12345 + (-12345)
的話,只需要將這兩個(gè)二進(jìn)制相加,得到 1 00000000 00000000
,但是這里一個(gè)整數(shù)只有 2 bytes ,因此第一位的 1
會(huì)被移除,得到的正好是 00000000 00000000
,和數(shù)學(xué)運(yùn)算一樣 —— 又一次感受到補(bǔ)碼運(yùn)算的奧秘?。?!a - b
,就會(huì)轉(zhuǎn)換為 a + (-b)
,其中采用補(bǔ)碼計(jì)算 -b
,然后直接做加法運(yùn)算。這樣也從硬件上節(jié)省了資源有符號(hào)數(shù)和無符號(hào)數(shù)
計(jì)算機(jī)肯定是看不懂正數(shù)、負(fù)數(shù)的,它只能識(shí)別二進(jìn)制數(shù)字。那么計(jì)算機(jī)如何知道一個(gè)數(shù)是正數(shù)還是負(fù)數(shù)呢?要看兩點(diǎn):
因此 C 語言中的數(shù)字類型就有很多種,適用于不同長度。而每種數(shù)字類型,又分有符號(hào)性和無符號(hào)型。即便是是0
也可以有符號(hào)或者無符號(hào)兩種表示,因?yàn)閮烧邔?duì)二進(jìn)制代碼的解析方法不一樣。
PS:日常開發(fā)中,盡量別用無符號(hào)數(shù),會(huì)帶來運(yùn)算問題。C 語言中,有符號(hào)數(shù)和無符號(hào)數(shù)一起進(jìn)行算數(shù)運(yùn)算是,會(huì)將有符號(hào)數(shù)轉(zhuǎn)換為無符號(hào)數(shù)(負(fù)數(shù)第一 bit 的 1
就不再代表負(fù)數(shù)了)再進(jìn)行運(yùn)算,很危險(xiǎn)!??!除非特殊場景,例如摸運(yùn)算或者按位運(yùn)算。
其他
除法計(jì)算比較復(fù)雜,如果遇到以 2 為底數(shù)的除法,盡量使用位運(yùn)算。例如 js 中的 >>
。64 >> 2 === 16
,即將 64 轉(zhuǎn)換為 2 進(jìn)制,然后整體右移 2 位。這種運(yùn)算效率會(huì)非常快 —— 但是估計(jì)現(xiàn)代編譯器會(huì)捕捉到這一特點(diǎn),將除法自動(dòng)編譯為位運(yùn)算。
浮點(diǎn)數(shù)的二進(jìn)制表示比較復(fù)雜,細(xì)節(jié)部分可以忽略
十進(jìn)制小數(shù)如何轉(zhuǎn)換為二進(jìn)制小數(shù)
規(guī)則是:整體規(guī)則是“乘 2 取整,順序排列”,例如:
因此,二進(jìn)制能精確表示的小數(shù),只能是若干次 *2
能得到整數(shù)的值。其他情況如 0.2 就無法精確表示,只能精確到某個(gè)度,因此 C 語言才有單精度 float 和雙精度 double 浮點(diǎn)數(shù)。
浮點(diǎn)數(shù)的二進(jìn)制存儲(chǔ)
IEEE (美國電器與電子工程師協(xié)會(huì))的浮點(diǎn)數(shù)標(biāo)準(zhǔn)參考一下 http://www.ruanyifeng.com/blog/2010/06/ieee_floating-point_representation.html,即將一個(gè)存儲(chǔ)空間分成三段:
通過以上幾個(gè)區(qū)域能計(jì)算出它存儲(chǔ)的浮點(diǎn)數(shù)的數(shù)值,按公式 V = (-1)^S * M * 2^E
。不同精度的浮點(diǎn)數(shù),這幾個(gè)區(qū)間的大小不一致:
整數(shù)和浮點(diǎn)數(shù)的轉(zhuǎn)換
我感覺這部分算是對(duì)計(jì)算機(jī)組成原理的一個(gè)簡單介紹,但我更加推薦大家去專門的計(jì)算機(jī)組成原理的課程去詳細(xì)學(xué)習(xí)。
主要結(jié)構(gòu)分為:
8086 是 intel 在 1978 年發(fā)布的 16 位處理器,80386 是 1985 年 intel 發(fā)布的 32 位處理器(寄存器 32 位)。80386 有三種工作模式:
有了保護(hù)模式,編程人員才可以在一個(gè)私有的空間內(nèi)為所欲為。
就好像程序猿占有了一個(gè)(虛擬的) CPU 和一段內(nèi)存地址
這部分內(nèi)容中,寄存器的知識(shí)對(duì)于匯編語言是很重要的,阮一峰老師的博客中也介紹了寄存器,大家可以去參考。
匯編語言是面向機(jī)器的最基礎(chǔ)編程,既然是編程就涉及到內(nèi)存的使用和分配,于是就有了內(nèi)存模型。這部分的知識(shí),我感覺阮一峰老師的博客中已經(jīng)寫的很詳細(xì)了,我也會(huì)參考他的文章來進(jìn)行下文的總結(jié)。
某個(gè)程序開始運(yùn)行之前,操作系統(tǒng)會(huì)給它分配一段內(nèi)存空間,用于存儲(chǔ)改程序時(shí)使用的、產(chǎn)出的數(shù)據(jù)。具體這塊內(nèi)存區(qū)域的大小和起止指針先不用關(guān)心。
棧這個(gè)數(shù)據(jù)結(jié)構(gòu)的特點(diǎn)是“先進(jìn)后出”。像 C 語言這種“過程調(diào)用過程”后者“函數(shù)調(diào)用函數(shù)”的執(zhí)行方式,最先調(diào)用的過程或者函數(shù),會(huì)是最后一個(gè)結(jié)束。這一特點(diǎn)和棧的特點(diǎn)基本一致。
需要強(qiáng)調(diào)一點(diǎn),在整個(gè)這段內(nèi)存空間中,棧是自上(高地址)而下(低地址)進(jìn)行累積的,即棧頂?shù)膬?nèi)存地址比棧底的內(nèi)存地址要小。這一點(diǎn)和堆正好相反,如下圖:
壓棧 push
當(dāng)一個(gè)過程或者函數(shù)被執(zhí)行時(shí),會(huì)有一些數(shù)據(jù)(參數(shù)、局部變量、返回地址)需要臨時(shí)存儲(chǔ)起來。而且在“函數(shù)調(diào)用函數(shù)”的整個(gè)過程中,會(huì)有很多這樣的操作。那么就在每個(gè)函數(shù)執(zhí)行時(shí),將這些數(shù)據(jù)壓棧。如下圖,注意調(diào)用鏈和壓棧的關(guān)系(其中兩個(gè) amI
是發(fā)生了遞歸調(diào)用)。
當(dāng)前正在執(zhí)行的函數(shù)對(duì)應(yīng)的棧,叫做“棧幀”,%ebp
和 %esp
兩個(gè)寄存器分別存儲(chǔ)了該棧幀兩端的地址。
PS:遞歸和循環(huán)雖然都可以滿足某些計(jì)算場景,但是在構(gòu)建內(nèi)存模型上是完全不一樣的,遞歸復(fù)雜度更高。
出棧 pop
棧中的數(shù)據(jù)是有聲明周期的,每個(gè)函數(shù)執(zhí)行完 return 之后,其對(duì)應(yīng)的數(shù)據(jù)就要被 pop ,并釋放這段內(nèi)存空間。因此棧的內(nèi)存空間是由系統(tǒng)分配、系統(tǒng)自動(dòng)釋放,不需要人為干預(yù)。人只管好好寫自己的程序就 OK 了。
可以拿上圖中的調(diào)用鏈和棧寫一個(gè)詳細(xì)的調(diào)用過程:
yoo
函數(shù)被調(diào)用,yoo
的數(shù)據(jù)被壓棧yoo
函數(shù)中又調(diào)用了 who
函數(shù),who
的數(shù)據(jù)被壓棧who
函數(shù)中又調(diào)用了 amI
函數(shù),amI
的數(shù)據(jù)被壓棧amI
函數(shù)中又遞歸調(diào)用了 amI
函數(shù),amI
的數(shù)據(jù)被壓棧amI
函數(shù) return ,出棧amI
函數(shù) return ,出棧who
函數(shù) return ,出棧yoo
函數(shù) return ,出棧%ebp
和 %esp
寄存器一直隨著棧幀的變化而變化其他
有一個(gè)程序猿知名網(wǎng)站叫 stackoverflow ,意思就是“棧溢出”。按照上述模型的理解,就是程序執(zhí)行時(shí)棧內(nèi)存累計(jì)過多,導(dǎo)致溢出了整個(gè)分配的內(nèi)存空間了。常見的導(dǎo)致這種問題的方式是大量的遞歸調(diào)用,可以用“尾遞歸”來解決這一問題,感興趣的可以去具體查一查。
在整個(gè)程序被分配的內(nèi)存空間里,棧是系統(tǒng)自己使用和分配,自上而下的累積。其中還有一部分內(nèi)存空間是給程序猿使用的,即你可以通過程序動(dòng)態(tài)占有一部分內(nèi)存(如 C 語言的 malloc
,C++ 的 new
,其他高級(jí)語言的引用類型),這部分內(nèi)存叫“堆”。它和棧不一樣:
常說的內(nèi)存泄露就是在堆中占有的內(nèi)存沒有被及時(shí)的清理或者 GC ,導(dǎo)致長時(shí)間積累之后內(nèi)存崩潰。對(duì)于 JS 開發(fā)者,應(yīng)該知道 Chrome devtools 中有一個(gè) heap Snapshot ,用來記錄當(dāng)前時(shí)刻 JS 堆內(nèi)存,如下圖:
以 C 語言中的數(shù)組和結(jié)構(gòu)體為例。
C 語言中,數(shù)組需要一個(gè)連續(xù)的存儲(chǔ)空間,每個(gè)數(shù)組需要一個(gè) L * sizeOf(T)
字節(jié)的空間。例如 10 個(gè) int 元素的數(shù)組,其空間就需要 10 * 4 = 40 bytes 的空間。通過這個(gè)存儲(chǔ)格式,就可以很容易的遍歷、訪問到數(shù)據(jù)的每個(gè)元素。用 %edx
寄存器存儲(chǔ)起始地址,用 %eax
表示 index ,那么 (%edx, %eax, 4)
就是這個(gè)當(dāng)前元素的內(nèi)存地址(4 即取出 4 bytes 長度的內(nèi)容,int 類型占 4 bytes)。二維數(shù)組也是同樣的道理。
PS:數(shù)組和鏈表有時(shí)候看著用途一樣,但是數(shù)據(jù)結(jié)構(gòu)上是有明顯區(qū)別的,鏈表不需要一個(gè)連續(xù)的存儲(chǔ)空間。
C 語言結(jié)構(gòu)體也需要一個(gè)連續(xù)的存儲(chǔ)空間,結(jié)構(gòu)提內(nèi)部通過名字訪問,每個(gè)元素都可以有不同的類型。
- struct rec {
- int i;
- init a[3];
- int *p; // *p 表示一個(gè)內(nèi)存地址,&p 可以獲取該地址的值
- }
以上代碼將會(huì)被分配這樣一個(gè)連續(xù)的內(nèi)存地址:0 - 4
存放 i(4 bytes),接著 5 - 16
存放數(shù)組(3 個(gè) int),接著 16 - 20
存放指針(32 位指針)。
雖然本課是主講匯編語言,課程中也花了大量的時(shí)間講解了常用的指令、示例以及 C 語言和匯編語言的如何對(duì)應(yīng)。不過對(duì)于我這種以了解匯編、學(xué)習(xí)基礎(chǔ)知識(shí)為目的的高級(jí)語言的開發(fā)者,并沒有去認(rèn)真聽每個(gè)指令的具體意義。不知道這是不是常說的“不求甚解”。
課程中幾個(gè)比較簡單的匯編指令如下:(阮一峰老師的博客中也講了一些常用指令,講的更加詳細(xì),可以去學(xué)習(xí))
addl 參數(shù)1, 參數(shù)2
加法movl Source, Dest
賦值leal Source, Dest
計(jì)算出地址賦值給 Destcmpl Src2, Src1
比較,類似于計(jì)算 Src2 - Src1
上述兩個(gè)指令,add
和 mov
等表示指令類型,后面的 l
是一個(gè)后綴,表示一次性操作 2 bytes 。這樣的后綴還有很多,例如 b
w
,都有不同的含義,不過不用去管它。
參數(shù)中,%edx
表示某個(gè)寄存器,(%edx)
表示將這個(gè)寄存器的值作為內(nèi)存地址,$
開頭的是一個(gè)立即數(shù)。8(%edx)
找到某個(gè)內(nèi)存地址并連續(xù)讀取 8 bits 內(nèi)容(如 int 類型就占 8 bits)。
上文中【和匯編程序相關(guān)的結(jié)構(gòu)】圖中可以看到,CPU 中有“條件碼”。例如,x86 中常用的四個(gè)條件碼(其實(shí)我也不知道怎么用……)
(每個(gè)條件碼只占 1 bit 空間,可見它是一個(gè) boolean 型的存在)
在指令運(yùn)行過程中,硬件會(huì)根據(jù)指令運(yùn)行的狀態(tài)實(shí)時(shí)的修改這些條件碼的值,然后用 set
指令,從條件碼中讀出來,放入通用寄存器中,然后就可以用于分支跳轉(zhuǎn)了。細(xì)節(jié)沒具體看。
以 j
開頭的一系列指令,滿足不同的條件即可跳轉(zhuǎn)到某個(gè)程序塊。例如 jmp
是無條件跳轉(zhuǎn),je
是 ZF 條件碼為 0 時(shí)才跳轉(zhuǎn),jne
是 ZF 條件碼不是 0 時(shí)才跳轉(zhuǎn)。跳轉(zhuǎn)的語法類似于 C 語言的 goto
語句,但在 C 語言中不推薦使用 goto
語句。
高級(jí)編程語言中有三種基本的執(zhí)行邏輯:第一,順序執(zhí)行,這個(gè)對(duì)應(yīng)匯編語言沒啥問題;第二,分支執(zhí)行(即 if else);第三,循環(huán)執(zhí)行。后兩種,通過判斷條件碼和跳轉(zhuǎn)也都可以實(shí)現(xiàn)。
關(guān)于遞歸,課程中也講了很多內(nèi)容,不過我沒看懂(沒有那么那么認(rèn)真的看,看不懂就算了……)。
如果想簡單看一下匯編語言是什么樣子的,可以通過 gcc 編譯一段簡單的 C 語言來看下。首先,新建一個(gè) hello.c
的文件然后寫上如下內(nèi)容并保存。
- #include <stdlib.h>
- #include <stdio.h>
- int main() {
- printf("hello word
- ");
- exit(0);
- return 0;
- }
在該文件目錄中運(yùn)行 gcc -S -O2 -m32 hello.c
,然后即可看到生成了一個(gè) hello.s
的文件,內(nèi)容如下:
- .section __TEXT,__text,regular,pure_instructions
- .macosx_version_min 10, 12
- .globl _main
- .p2align 4, 0x90
- _main: ## @main
- ## BB#0:
- pushl %ebp
- movl %esp, %ebp
- subl $8, %esp
- calll L0$pb
- L0$pb:
- popl %eax
- leal L_str-L0$pb(%eax), %eax
- movl %eax, (%esp)
- calll _puts
- movl $0, (%esp)
- calll _exit
- subl $4, %esp
- .section __TEXT,__cstring,cstring_literals
- L_str: ## @str
- .asciz "hello word"
- .subsections_via_symbols
這就是 C 語言編譯出來的匯編語言。具體的示例,可以去看阮一峰老師那篇博客最后的內(nèi)容,他在博客中對(duì)一段匯編語言做了詳細(xì)的解釋。我這里就省略了。
僅僅是一個(gè)學(xué)習(xí)筆記,發(fā)現(xiàn)錯(cuò)誤歡迎指正。
聯(lián)系客服