1. 學習匯編的心路歷程
進行8086匯編的介紹之前,想先分享一下我學習匯編的心路歷程 。
rocketmq的學習
其實我並沒有想到這么快的就需要進一步學習匯編語言,因為匯編對於我的當前的工作內容來說太過底層。
但在幾個月前,當時我正嘗試着閱讀rocketmq的源碼。和許多流行的java中間件、框架一樣,rocketmq底層的網絡通信也是通過netty實現的。但由於我對netty並不熟悉,在工作中使用spring-cloud-gateway的時候甚至寫出了一些導致netty內存泄漏的代碼,卻不太明白個中原理 。出於我個人的習慣,在學習源碼時,拋開整體的程序架構不論,希望至少能對其中涉及到的底層內容有一個大致的掌握,能讓我像黑盒子一樣去看待它們。
趁熱打鐵,我決定先學習netty,這樣既能在工作時更好的定位、解決netty相關的問題,又能在研究依賴netty的開源項目時更加得心應手。
netty的學習
隨着對netty學習的深入,除了感嘆netty統一規整的api接口設計,內部交互靈活可配置、同時又提供了足夠豐富的開箱即用組件外;更進一步的,netty或者說java nio涉及到了許多更底層的東西,例如:io多路復用,零拷貝,事件驅動等等。而這些底層技術在redis,nginx,node-js等以高效率io著稱的應用中被廣泛使用。
捫心自問,自己在多大程度上理解這些技術?為什么io多路復用在io密集型的應用中,效率能夠比之傳統的同步阻塞io顯著提高?一次網絡或磁盤的io傳輸內部到底發生了什么,零拷貝到底快在了哪里?
如果沒有很好的弄明白這些問題,那么我的netty學習將是不完整的。
我有限的知識告訴我,答案就在操作系統中。操作系統作為軟硬件的大管家,對上提供應用程序接口(程序員們通常使用高級語言提供的api間接調用);對下控制硬件(cpu、內存、磁盤網卡等外設);依賴硬件提供控制並發的系統原語;其牽涉的許多模塊內容都已經獨立發展了(多系統進程間通信->計算機網絡、文件系統->數據庫)。要想理解現代計算機系統的工作原理,操作系統是絕對繞不開的。
操作系統的學習
雖然也上過操作系統的課,讀過幾本操作系統的書。但一方面由於缺乏實際場景的應用,另一方面也因為當時水平有限,學習操作系統的方式是通過完成一個個孤立簡單的實驗,而不是連貫的實現一個完整的demo操作系統。使得對許多關鍵的知識點的理解依然是模糊不清的,所以也無法很好地回答上述netty學習中碰到的問題。
在重新學習操作系統的過程中,除了撿起當初沒有看完的《現代操作系統》外,我驚喜的發現了清華大學的操作系統公開課(自己動手實現ucore操作系統),以及《OrangeOS 一個操作系統的實現》。
但操作系統的學習從一開始我就遇到了大問題,從零開始實現的操作系統,雖然內核主體是C語言實現的,但在CPU加電開機時的引導程序以及在特定平台上操作特定硬件的功能卻都需要通過匯編來實現(ucore和OrangeOS都是基於Intel-80386的(32位)),看的我是一頭霧水,非常郁悶。
匯編語言的學習
由於在學校里學習的匯編語言是囫圇吞棗的,無法支持繼續操作系統的學習實踐。我找到了王爽老師編寫的《匯編語言》進行學習,雖然《匯編語言》使用的是更早的基於Intel-8086(16位)機器的匯編語言進行講解,但由於Intel的CPU迭代是向前兼容的(x86體系),因此其知識也能夠適用於更先進的Intel-80386。
對於像我這樣的匯編語言初學者,學習簡單經典的8086匯編能夠為理解更復雜的匯編語言打下基礎。通過《匯編語言》這本書的學習,加深了我對諸如內存尋址,中斷,指令跳轉等硬件工作原理的理解,能夠讓我從更底層的角度去看待上層的一些技術。
這一段時間,我進行了類似遞歸的,由上層至底層的學習。在初步完成了8086匯編語言的學習后,我准備返回上層繼續操作系統的學習。
通過寫博客的方式來鞏固這段時間匯編語言學習總結的成果,對知識點查漏補缺的同時也能作為匯編語言知識體系的索引讓以后在有需要時能更好的進行回顧。如果能幫助到同樣感興趣的人就更好了( ^_^ )。
2.匯編語言基本介紹
匯編語言作為編程語言的一種,雖然貼近機器底層,但和我們熟悉的高級編程語言依然有諸多共通之處。站在更高的角度去看待匯編語言,能更好的去理解匯編。
編程語言通常由兩部分組成:編程語言的基礎語法以及操縱編程語言所處環境的api。
舉個例子:
對於java,其基礎語法部分包括變量/方法/類定義、循環/賦值、繼承多態等;同時java作為一門面向通用計算機的編程語言,通常直接運行在操作系統之上,其多線程、系統io、網絡傳輸、圖形編程等api使得java開發人員能夠更簡單的使用操作系統。
對於javaScript,基礎語法部分由EcmaScript規范構成;而javaScript作為運行在web瀏覽器環境中的語言,提供了操作BOM、DOM對象的api,使得js的開發人員能控制瀏覽器的行為,實現所需要的功能。
對於匯編語言,情況又是什么呢?
一方面,匯編語言的語法部分大致包括指令的格式,注釋,定義數據、代碼段等的偽指令等。
另一方面,匯編語言是面向CPU硬件編程的,其指令與最終的機器碼一一對應,但比起以二進制表示的機器碼可讀性要高很多。
舉個例子,機器指令:1000100111011000 其對應的匯編語言表示為:mov ax bx,表示將寄存器bx的值送入寄存器ax。對比一下,表示同樣的內容,匯編語言的可讀性比機器語言要高很多。而機器最終執行的是機器碼,需要由匯編器將匯編源程序轉換成最終的機器碼。
匯編語言提供了直接操作CPU寄存器的指令(各種寄存器的取值、賦值)、控制CPU內存尋址的指令(內存單元的取值、賦值)、控制CPU通過端口操作外設的指令以及控制CPU進行程序跳轉的指令等等。
8086匯編的語法和硬件指令的內容會在后續的博客中,進行更加詳細的說明。
3.8086硬件介紹
匯編語言是用來操作CPU硬件的,匯編語言與其對應的硬件緊密相關。因此,在學習8086匯編語言之前,我們需要先大致了解一下8086硬件的工作原理(以黑盒子的角度來看待,而不是去深入的研究硬件內部復雜的結構)。
3.1 CPU寄存器
CPU通常由運算器,控制器和寄存器組成;運算器和控制器的工作一般無法直接控制,但寄存器卻能夠通過匯編語言直接與之交互。
8086CPU中有14個寄存器,各自都有着特殊的功能,我們可以通過匯編語言將其協調起來,滿足我們的需求
寄存器可以分為三大類,分別是:
通用寄存器 | 段寄存器 | 特殊功能寄存器 |
ax accumulate-register 累加寄存器 | cs code-segment 代碼段寄存器 | si source-index 源變址寄存器 |
bx based-register 基地址寄存器 | ds data-egment 數據段寄存器 | di destination-index 目的變址寄存器 |
cx count-register 計數寄存器 | ss stack-segment 棧段寄存器 | sp stack-point 堆棧指針寄存器 |
dx data-register 數據寄存器 | es extra-segment 附加段寄存器 | bp base-point 基礎指針寄存器 |
ip instructor-point 指令指針寄存器 | ||
psw program-state-word 程序狀態字寄存器 |
千萬別一下子被繁多的寄存器弄糊塗了,后續會在有需要時進行上述寄存器的詳細介紹和用法的。
3.2 CPU和存儲器的交互
在計算機中,CPU作為處理器通常不能獨自進行工作,還需要與外部存儲器(內存 RAM、ROM)進行交互來讀寫所需要執行的代碼指令或數據。
8086 CPU通過邏輯上分為三類的地址總線、數據總線和控制總線共同完成與存儲器交互的任務,。
總線由一系列的導線組成,通常高電平表示1,低電平表示0,數量為N的總線集合可以表示一個N位的二進制數。
其中地址總線用於確定存儲器的地址,數據總線用於在對應存儲器地址和寄存器之間傳輸數據,而控制總線則可以標識當前所進行的控制操作(讀或是寫或是其它指令)。
地址總線:
存儲器在設計時,被划分為多個存儲單元,每個存儲單元都有獨一無二的地址標識。CPU可以通過這些地址標識來定位對應的存儲單元,這叫做內存尋址。
CPU的內存尋址范圍由地址總線的根數(位數)決定,20位的地址總線能尋址的最大范圍為(2 ^ 20)b = 1M;而32位的地址總線能尋址的最大范圍為(4 * (2 ^ 30))b = 4G,這也是在32位CPU時代,PC的內存普遍是4G的主要原因。
數據總線:
CPU與內存或其它器件的數據傳輸是通過數據總線來完成的。數據總線的位數決定了一次數據傳輸的數據大小,數據總線的位數越多,數據傳輸的效率就越高。
8086作為一個16位的CPU,內部寄存器是16位的,其數據總線也是16位的,其一次可以傳輸一個16位的二進制數據。
控制總線:
CPU通過控制總線來對外部設備實施控制。和前兩種總線不同的是,控制總線是不同的控制線的總集合,其中的每一根導線通常是單獨提供控制的。CPU通過控制總線發送控制信號和時許信號來對外圍設備進行控制(讀、寫信號等)或者從控制總線接收外圍設備發出的通知(中斷申請信號 等)。
以8086 CPU從指定內存地址中讀取數據為例簡單說明CPU總線的工作原理:
首先,CPU通過地址總線發送內存地址選取信號。
然后,CPU通過控制總線發送"讀"信號通知內存芯片將要讀取數據,而具體被選中的內存芯片由地址總線信號指定。
最后,CPU通過數據總線將對應內存單元中的數據送入CPU中。
這里工作原理的解釋很模糊,但大致說明了CPU通過總線與外部存儲器交互的方式。
(圖片源自 《匯編語言》 王爽著)
3.3 內存單元物理地址
前面提到每個存儲器單元都有唯一的標識,這個唯一標識被稱為物理地址。
8086的地址總線是20位的,擁有1MB的內存尋址能力。但8086的寄存器卻只有16位,單次處理的數據最多也是16位,如果簡單的將尋址地址送出,那么最多只能尋址2的16次方,也就是64KB的地址空間。
為此,8086內部通過將兩個16位的尋址地址疊加為一個20位地址的方式實現物理地址尋址。
其中一個尋址地址稱為段地址,另一個尋址地址被稱為偏移地址;16位的段地址左移4位(擴大16倍)后將其和偏移地址相加得到最終的物理尋址地址。
舉個例子:
段地址 = 1234 (16進制 0x1024)
偏移地址 = 1000 (16進制 0x1000)
最終物理地址 = (段地址 * 16) + (偏移地址) = 13340 (16進制 0x13340)
這里引入了內存段的概念,"段"這一概念在8086匯編中非常重要,從寄存器中專門存在一類段寄存器可見一斑,這里就不繼續展開了。
(圖片源自 《匯編語言》 王爽著)
3.4 CPU執行程序的基本過程
相信很多人都多少對CPU執行程序的原理感到好奇。對於平常再熟悉不過的程序中的if、else邏輯判斷,for、while循環以及函數的調用(call)、返回(return)機制在以圖靈機為模型的機器中是如何實現的呢?在存儲器中數據都是以010101的二進制形式存在的,可是CPU是如何區分在程序中通常是涇渭分明的代碼和數據的呢?換句話說,CPU是如何知道應該把0101這樣的的二進制"數據"當做代碼執行還是視作數據處理呢?
想知道答案,需要先了解一下前面提到的8086寄存器中的CS(代碼段寄存器)和IP(指令指針寄存器)這兩個或許是最重要的寄存器了,CS/IP兩個寄存器
1. CPU在每次執行指令時,都會去讀取CS:IP所指向內存單元的"數據",將其當做指令來執行。(CS : IP 其中前面的CS代表段地址,后面的IP代表偏移地址)。
2. 在指令執行完畢后,IP值會增加,增加的值取決於之前加載指令的長度(8086的指令一般需要1-3個字節),這樣CS:IP就能正確的指向下一條需要執行的指令了。
3. CPU會不斷的重復執行(1)、(2)這兩個步。CPU能以非常快的速度執行這一運算過程,這一般取決於CPU的主頻。
程序通常都不是線性的、自始至終從上至下執行的,而是存在各種分支判斷來決定最終執行的程序片段。為此,CPU提供了許多指令讓我們能夠修改CS和IP寄存器中的值(例如jmp、call、ret指令等),這類指令被統稱為跳轉指令。有了跳轉指令,就可以在實現邏輯分支的跳轉、循環以及函數子程序的調用,返回等功能。
上述解釋依然是很簡陋、不完全的,諸如如何實現函數返回時參數的傳遞、返回后之前變量的恢復等等更細節的問題都還沒有給出答案。限於篇幅不會在這里回答這些問題。我們可以帶着這些問題進行接下來的學習,隨着學習的深入,相信這些問題的答案會慢慢浮出水面。就我個人而言,如果帶着問題去學習,會更加興致高昂,通過努力將感興趣卻還不理解的地方弄懂是一件很有成就感的事情。
總結
作為8086匯編語言學習的第一篇博客,這里僅僅把學習8086匯編所需要的部分基礎知識蜻蜓點水的簡單介紹了一下,很多知識點只起了個頭就沒后續了,會在后續的博客里繼續分享8086學習的內容。
作為匯編語言的初學者,博客中存在理解有問題的地方還請多多指教。希望對匯編語言或是計算機底層原理感興趣的小伙伴有所幫助。