3. 記憶體數據 前面我們知道了,記憶體是按位元組編址,每個地址的存儲單元可以存放8bit的數據。我們也知道CPU通過記憶體地址獲取一條指令和數據,而他們存在存儲單元中。現在就有一個問題。我們的數據和指令不可能剛好是8bit,如果小於8位,沒什麼問題,頂多是浪費幾位(或許按位元組編址是為了節省記憶體空間考慮)。 ...
3. 記憶體數據
前面我們知道了,記憶體是按位元組編址,每個地址的存儲單元可以存放8bit的數據。我們也知道CPU通過記憶體地址獲取一條指令和數據,而他們存在存儲單元中。現在就有一個問題。我們的數據和指令不可能剛好是8bit,如果小於8位,沒什麼問題,頂多是浪費幾位(或許按位元組編址是為了節省記憶體空間考慮)。但是當數據或指令的長度大於8bit呢?因為這種情況是很容易出現的,比如一個16bit的Int數據在記憶體是如何存儲的呢?
3.1 記憶體數據存放
其實一個簡單的辦法就是使用多個存儲單元來存放數據或指令。比如Int16使用2個記憶體單元,而Int32使用4個記憶體單元。當讀取數據時,一次讀取多個記憶體單元。於是這裡又出現2個問題:
- 多個存儲單元存儲的順序?
- 如何確定要讀幾個記憶體單元?
3.1.1 大端和小端存儲
- Little-Endian 就是低位位元組排放在記憶體的低地址端,高位位元組排放在記憶體的高地址端。
- Big-Endian 就是高位位元組排放在記憶體的低地址端,低位位元組排放在記憶體的高地址端。
需要說明的是,電腦採用大端還是小端存儲是CPU來決定的, 我們常用的X86體系的CPU採用小端,一下ARM體系的CPU也是用小端,但有一些CPU卻採用大端比如PowerPC、Sun。判斷CPU採用哪種方式很簡單:
[cpp] view plain copy print?- bool IsBigEndian()
- {
- int vlaue = 0x1234;
- char lowAdd = *(char *)&value;
- if( lowAdd == 0x12)
- {
- return true;
- }
- return false;
- }
既然不同電腦存儲的方式不同,那麼在不同電腦之間交互就可能需要進行大小端的轉換。這一點我們在Socket編程中可以看到。這裡就不介紹了,對以我們單一CPU來說我們可以不需要管這個轉換的問題,另外我們目前個人PC都是採用小端方式,所以我們後面預設都是這種方式。
3.1.2 CPU指令
前面我們多次提到了指令的概念,也知道指令是0和1組成的,而彙編代碼提高了機器碼的可讀性。為什麼突然在這裡介紹CPU指令呢? 主要是解釋上面的第二個問題,當我讀取一個數據或指令時,我怎麼知道需要讀取多少個記憶體單元。
3.1.2.1 CPU指令格式
首先我們來看看CPU指令的格式,我們知道CPU質量主要就是告訴CPU做什麼事情,所以一條CPU指令一般包含操作碼(OP)和操作
操作碼欄位 | 地址碼欄位 |
根據一條指令中有幾個操作數地址,可將該指令稱為幾操作數指令或幾地址指令。
操作碼 | A1 | A2 | A3 |
三地址指令: (A1) OP (A2) --> A3
操作碼 | A1 | A2 |
二地址指令: (A1) OP (A2) --> A1
操作碼 | A1 |
一地址指令: (AC) OP (A) --> AC
操作碼 |
零地址指令
A1為被操作數地址,也稱源操作數地址; A2為操作數地址,也稱終點操作數地址; A3為存放結果的地址。 同樣,A1,A2,A3以是記憶體中的單元地址,也可以是運算器中通用寄存器的地址。所以就有一個定址的問題。關於指令定址後面會介紹。
CPU指令設計是十分複雜的,因為在電腦中都是0和1保存,那電腦如何區分一條指令中的操作數和操作碼呢?如何保證指令不會重覆呢?這個不是我們討論的重點,有興趣的可以看看電腦體繫結構的書,裡面都會有介紹。從上圖來看我們知道CPU的指令長度是變長的。所以CPU並不能確定一條指令需要占用幾個記憶體單元,那麼CPU又是如何確定一條指令是否讀取完了呢?
3.1.2.2 指令的獲取
現在的CPU多數採用可變長指令系統。關鍵是指令的第一位元組。 當CPU讀指令時,並不是一下把整個指令讀近來,而是先讀入指令的第一個位元組。指令解碼器分析這個位元組,就知道這是幾位元組指令。接著順序讀入後面的位元組。每讀一個位元組,程式計數器PC加一。整個指令讀入後,PC就指向下一指令(等於為讀下一指令做好了準備)。
Sample1:
[plain] view plain copy print?- MOV AL,00 機器碼是1011 0000 0000 0000
機器碼是16位在記憶體中占用2個位元組:
【00000000】 <- 0x0002
【10110000】 <- 0x0001
比如上面這條MOV彙編指令,把立即數00存入AL寄存器。而CPU獲取指令過程如下:
- 從程式計數器獲取當前指令的地址0x0001。
- 存儲控制器從0x0001中讀出整個位元組,發送給CPU。PC+1 = 0X0002.
- CPU識別出【10110000】表示:操作是MOV AL,並且A2是一個立即數長度為一個位元組,所以整個指令的字長為2位元組。
- CPU從地址0x0002取出指令的最後一個位元組
- CPU將立即數00存入AL寄存器。
這裡的疑問應該是在第3步,CPU是怎麼知道是MOV AL 立即數的操作呢?我們在看下麵一個列子。
Sample2:
[plain] view plain copy print?- MOV AL,[0000] 機器碼是1010 0000 0000 0000 0000 0000
這裡同樣是一條MOV的彙編指令,整個指令需要占用3個位元組。
【00000000】 <-0x0003
【00000000】 <- 0x0002
【10100000】 <- 0x0001
我們可以比較一下2條指令第一個位元組的區別,發現這裡的MOV AL是1010 0000,而不是Sample1中的1011 000。CPU讀取了第一個位元組後識別出,操作是MOV AL [D16],表示是一個寄存器間接定址,A3操作是存放的是一個16位就是地址偏移量(為什麼是16位,後面文章會介紹),CPU就判定這條指令長度3個位元組。於是從記憶體0x0002~0x0003讀出指令的後2個位元組,進行定址找到真正的數據記憶體地址,再次通過CPU讀入,並完成操作。
從上面我們可以看出一個指令會根據不同的定址格式,有不同的機器碼與之對應。而每個機器碼對應的指令的長度都是在CPU設計時就規定好了。8086採用變長指令,指令長度是1-6個位元組,後面可以添加8位或16位的偏移量或立即數。 下麵的指令格式相比上面2個就更加複雜。
- 第一個位元組的高6位是操作碼,W表示傳說的數據是字(W=1)還是位元組(W=0),D表示數據傳輸方向D=0數據從寄存器傳出,D=1數據傳入寄存器。
- 第二個位元組中REG表示寄存器號,3位可以表示8種寄存器,根據第一位元組的W,可以表示是8位還是16位寄存器。表3-1中列出了8086寄存器編碼表
- 第二個位元組中的MOD和R/M指定了操作數的定址方式,表3-2列出了8086的編碼
這裡沒必要也無法更詳細介紹CPU指令的,只需要知道,CPU指令中已經定義了指令的長度,不會出現混亂讀取記憶體單元的現象。有興趣的可以查看引用中的連接。
3.1.3 記憶體數據
3.1.3.1 記憶體數據的操作
從上面我們可以知道,操作數可以是立即數,可以存放在寄存器,也可以存放在記憶體。對於第一個例子,指令已經說明,操作時是一個位元組,於是CPU可以從下一個記憶體地址讀取操作時,而對於第二個列子,操作數只是地址偏移,所以當CPU獲得這個數據後,需要轉換成實際的記憶體地址,在進行一次記憶體訪問,把數據讀入到寄存器中。這裡就出現我們前面提到的問題,這個數據我們要讀幾個存儲單元呢?
[cpp] view plain copy print?- MyClass cla;
- 008C3EC9 lea ecx,[cla]
- 008C3ECC call MyClass::MyClass (08C1050h)
- 008C3ED1 mov dword ptr [ebp-4],0
- cla.num5 = 500;
- 008C3ED8 mov dword ptr [ebp-6Ch],1F4h
- int b1 = MyClass::num1;
- 008C3EDF mov dword ptr [b1],64h
- int b2 = MyClass::num2;
- 008C3EE6 mov dword ptr [b2],0C8h
- int b3 = MyClass::num3;
- 008C3EF0 mov eax,dword ptr ds:[008C9008h]
- 008C3EF5 mov dword ptr [b3],eax
- int b4 = cla.num4;
- 008C3EFB mov eax,dword ptr [cla]
- 008C3EFE mov dword ptr [b4],eax
- int b5 = cla.num5;
- 008C3F04 mov eax,dword ptr [ebp-6Ch]
- 008C3F07 mov dword ptr [b5],eax
讓我們看一段C++代碼和對應的彙編代碼,操作很簡單,創建一個Myclass對象後,對成員變數賦值。而賦值都是試用Mov操作符。對於這些變數我們有賦值操作和取值操作,那麼是如何確定要讀取或寫入數據的大小呢?
- cla.num5 = 500;
- 08C3ED8 mov dword ptr [ebp-6Ch],1F4h
我看先看看賦值操作,往dword ptr [ebp-6Ch]記憶體存入一個立即數, [ebp-6Ch]是num5的記憶體地址,而前面的dword ptr 表示這是進行一個雙子操作。還記得上面指令格式中第一個位元組的W欄位嗎? 在8086中只能進行位元組或字操作,而現在CPU都可以進行雙字操作。
- int b5 = cla.num5;
- 08C3F04 mov eax,dword ptr [ebp-6Ch]
同樣,當我們要從一個記憶體讀取數據的時候,也要指定讀取數據的操作類型,這裡也是雙字操作。這樣以來,就能從記憶體中正確的讀出需要的長度了。就這麼一個簡單的賦值操作,獲取你從來沒想過在記憶體中怎麼存放,又是怎麼讀取的。這一切都是編譯器和CPU在背後為我們完成了。
3.1.3.2 記憶體對齊
前面我們清楚了CPU是如何正確讀取數大小不同的數據的,最後一部分來看看有關記憶體對齊的問題。對於大部分程式員來說,記憶體對齊應該是透明的。記憶體對齊是編譯器的管轄範圍。編譯器為程式中的每個數據單元安排在適當的位置上。
3.1.3.2.1 對齊原因
從前面我們知道,目前電腦記憶體按照位元組編址,每個地址的記憶體大小為1個位元組。而讀取數據的大小和數據線有關。比如數據線為8位那麼一次讀取一個位元組,而如果數據線為32位,那麼一次需要讀取32個位元組,這樣是為了一次更多的獲取數據提高效率。否則讀取一個int變數就需要進行4次記憶體操作。對於記憶體訪問一般有以下兩個條件:
- CPU進行一次記憶體訪問讀取的數據和字長相同。
- 有些CPU只能對字長倍數的記憶體地址進行訪問。
對於第一個條件一般來說,目前存儲器一個cell是8bit,進行位擴展使他和字長還有數據線位數是相同,那麼一次就能傳送CPU可以處理最多的數據。而前面我們說過目前是按位元組編址可能是因為一個cell是8bit,所以一次記憶體操作讀取的數據就是和字長相同。
也正是因為和存儲器擴展有關(參考1.2.1的圖),每個DRAM位擴展晶元使用相同RAS。如果需要跨行訪問,那麼需要傳遞2次RAS。所以以32位CPU為例,CPU只能對0,4,8,16這樣的地址進行定址。而很多32位CPU禁掉了地址線中的低2位A0,A1,這樣他們的地址必須是4的倍數,否則會發送錯誤。
如上圖,當電腦數據線為32位時,一次讀入4個地址範圍的數據。當一個int變數存放在0-3的地址中時,CPU一次就記憶體操作就可以取得int變數的值。但是如果int變數存放在1-4的地址中呢? 根據上麵條件2的解釋,這個時候CPU需要進行2次記憶體訪問,第一次讀取0-4的數據,並且只保存1-3的內容,第二次訪問讀取4-7的數據並且只保存4的數據,然後將1-4組合起來。如下圖:
所以記憶體對齊不但可以解決不同CPU的相容性問題,還能減少記憶體訪問次數,提高效率。當然目前關於這個原因爭論很多,可以看看CSDN上的討論:http://bbs.csdn.net/topics/30388330
3.1.3.2.2 如何對齊記憶體
記憶體對齊有一個對齊繫數,一般是2,4,8,16位元組這樣。而不同平臺上的對齊方式不同,這個主要是編譯器來決定的。
具體的規則可以參考之前轉的一篇文章,這裡就不詳細寫了: http://blog.csdn.net/cc_net/article/details/2908600
總結
通過這一篇對記憶體工作的介紹,我們從記憶體的硬體結構,存儲方式過渡到了記憶體的編址方式,然後又探討了按位元組編址帶來的問題和解決的辦法。這裡就涉及到了CPU的指令格式,編譯器的支持。最後我們也是從硬體和軟體方面討論了記憶體對齊的問題。
我自己感覺,記憶體的訪問管理是電腦中最重要的部分,也是電腦硬體和軟體之間交互的過渡的一個地方。所以理解了記憶體的工作原理,對於後面理解不同的記憶體模型很有幫助。
參考 http://blog.csdn.net/cc_net/article/details/11097267