最近工作中,經歷了很多項目問題的調試,把這些問題歸總起來,其中和存儲有關的,獨占一半。而存儲對計算機系統造成的影響又可分為兩塊:一是系統功能的穩健性;二是程序的執行效率。
存儲器結構
1.1 存儲器層次結構
由于訪問速度、成本、功耗等指標的制約,計算機系統中的存儲往往不是作為一個單一的大塊存在,而是被設置成一個多級的層次結構。作為一個程序員,需要理解存儲器層次結構,因為它對應用程序的性能有著巨大的影響。
圖1中展示了一個典型 計算機系統的存儲層級結構。和金字塔模型很像,越靠近金字塔頂端的存儲器,離CPU越近,訪問更便利且訪問速度快,但同時它們的容量也越來越小;反之越靠近底層,存儲容量越大,訪問速度卻越來越慢。
最高層的L0,是CPU寄存器,CPU可以在一個時鐘周期內訪問他們;接下來是一個或多個小型到中型的基于SRAM的高速緩存存儲器,可以在幾個CPU時鐘周期內訪問它們;然后是一個大的基于DRAM的主存,可以在幾十到幾百個時鐘周期內訪問它們;接下來是慢速但容量很大的本地磁盤;最后,甚至有些系統會通過網絡訪問其遠程服務器上的磁盤。
圖1 存儲器層次結構
高速緩存可以存在一級或多級,甚至不存在于一些低性能的單片機中。TI C64X架構中設計有2級的高速緩存,其數據和代碼的存取機制在本訂閱號之前的文章“TI C6000 數據存儲處理與性能優化”中有過描述。
1.2 存儲器的訪問
一個編寫良好的計算機程序常常具有良好的局部性(locality)。
局部性通常有兩種不同的形式:時間局部性和空間局部性。
時間局部性:在一個具有良好時間局部性的程序中,被訪問過一次的內存位置很可能在不遠的將來再被多次訪問 。
空間局部性:在一個具有良好空間局部性的程序中,如果一個內存位置被訪問 了一次,那么程序很可能在不遠的將來訪問 附近的一個內存位置。
例如對一個二維數組求和:
for (i=0; i《M; i++)
for(j=0; j《N; j++)
sum += data_tab[i][j];
其中,sum在循環迭代中被多次訪問,它具有良好的時間局部性,但因其為標量,因此沒有空間局部性。對二維數組data_tab而言,其元素在存儲中順序存儲,因此有好的空間局部性,但由于其每個元素只訪問一次,因此時間局部性很差。
另外,假設把上面第三行替換成:
sum += data_tab[j][i];
由于二維數組是按行依次存儲,則每次循環對內存訪問的步長由1變為N,空間局部性變差。像這樣看上去對程序很小的改動對它的局部性有很大的影響。
存儲類型
2.1 隨機訪問存儲器(RAM)
上電使用,掉電存儲內容丟失。
靜態RAM(SRAM):存儲單元具有雙穩態特性,上電就能保持它的值,對光和電噪聲等干擾不敏感。它使用晶體管多,因而密集度低,更貴,功耗更大。
動態RAM(DRAM):對每個位存儲為對一個電容的充電,因漏電的原因需要不斷刷新來保持存儲值。
SDRAM(Synchronous DRAM):用與驅動內存控制器相同的外部時鐘信號的上升沿,來作為控制信號。
DDR SDRAM(Double Data-Rate Synchronous DRAM):通過使用兩個時鐘沿作為控制信號,從而使DRAM的速度翻倍。它是用提高有效帶寬的很小的預緩沖區的大小來劃分的,如DDR(2位)、DDR2(4位)、DDR3(8位)。
表1 SRAM與DRAM性能比較
2.2 非易失性存儲器
非易失性存儲器,即使在關電后仍能保存著它們的信息。由于歷史的原因,它們也被整體成稱為ROM(Read Only Memory),但很多ROM類型是即可以讀也可以寫的。
PROM(Programble ROM):只能被編程一次,PROM的每個存儲器單元有一種熔絲,只能用高電流熔斷一次。
EPROM(Erasable Programble ROM):可擦寫可編程,對它的編程是通過一種把1寫入EPROM的特殊設備來完成的。
EEPROM(Electrically Erasable Programble ROM):電子可擦除,不需要一個物理上獨立的編程設備,因此可以直接在芯片上編程,能被編程的次數可以達到10e5次。
閃存(flash):基于EEPROM的一類非易失性存儲器。對它寫數據必須先將芯片中對應的內容清空,然后再寫入,也就是通常說的“先擦后寫 ”。
NAND flash:對它的操作以“塊 ”為基本單位,因此要修改一個字節,必須重寫整個數據塊。它的容量大,適合做大量數據的存儲。
NOR flash:對它的操作以“字 ”為基本單位。它的容量相對較小,成本大,一般用來存儲程序。
2.3 磁盤
磁盤由盤片構成,它是廣為應用的保存大量數據的存儲設備。不過從磁盤上讀信息的時間為毫秒級,比從DRAM讀慢了10萬倍,比從SRAM讀慢了100萬倍。
棧和堆
3.1 棧(stack)
棧是一個“后進先出”的存儲空間,程序運行中,代碼“過程 ”涉及到的返回地址、過程參數、需要保存的寄存器信息都被壓入棧中,過程中聲明的臨時變量也是在棧中開辟存儲。
過程的形式多樣:函數、方法、子例程、中斷處理函數等。當過程需要的存儲空間超出寄存器能夠存放的大小時,就會在棧上分配空間,這部分空間稱為過程的棧幀。
通常,棧底位于高地址,隨著數據的壓入,棧向低地址方向增長,而棧指針指向棧頂。將棧指針減少一個適當的量,可以為沒有指定初始值的數據,在棧上分配空間;類似的,可以通過增加棧指針來釋放空間。因為棧的位置取決于.stack段分配在什么地方,所以棧的實際地址是在連接時決定的。
假設過程P調用過程Q時,相關的控制和數據信息添加到棧尾,當Q返回時,這些信息會釋放掉。如果存在多級調用(包括遞歸調用),較早的棧幀也會累積下來,直到它對應的過程結束。
圖2 通用的棧幀結構
棧空間的大小可以在鏈接命令文件中,使用-stack選項進行設定。在對TI C64X架構芯片進行編程時,如果用戶沒有自己設定,系統默認棧的尺寸為1KB。注意:編譯器在編譯期間和運行期間都不進行對棧溢出的檢查,因此對棧空間的管理上需格外小心。
3.2 堆(heap)
堆是用于動態內存分配的一個存儲區域,動態分配的對象不是可直接尋址的,它們總是用指針來訪問。動態內存分配器將堆視為一組不同大小的塊的集合來維護。每個塊就是一個連續的虛擬內存片,要么是已分配的,要么是空閑的。一個已分配的塊保持已分配狀態,直到他被釋放,這種釋放要么是應用程序顯式執行的,要么是內存分配器自身隱式執行的。
C標準庫提供一個稱為malloc程序包的顯式分配器,程序通過調用malloc函數來從堆中分配塊。malloc函數返回一個指針,指向大小至少為size字節的內存塊,這個塊會為可能包含在這個塊內的任何數據對象類型做對齊。與malloc相對應的是free程序包,用于釋放已分配的內存塊。
下圖直接截取深入理解計算機系統這本書中,用一個16字的小堆來展示的malloc和free是如何管理堆空間的:
程序使用動態內存分配的最重要原因是,經常直到程序實際運行時才知道某些數據結構的大小。
堆位于段.sysmem中,可分配動態存儲池的大小,僅僅受限于系統中實際的存儲容量。 堆空間的大小可以在鏈接命令文件中,使用-heap選項進行設定。在對TI C64X架構芯片進行編程時,如果用戶沒有自己設定,系統默認堆的尺寸為1KB。
C編程中常見的與內存有關的錯誤
在我們項目的調試中,死機現象被認為是最讓人頭疼的問題,因為這類問題往往很難復現,而即便是找到了復現的規律,也很難通過跟蹤調試來斷定問題所屬的區域。由軟件造成死機的因素,可大致分為兩類:內存操作錯誤和線程阻塞。
線程阻塞也有很多表現方式,如高優先級線程堆積、程序陷入異常處理流程、死循環等等。所幸的是,線程阻塞要么很好復現,要么被追蹤到一次就能立馬找出癥結所在,可面對內存錯誤造成的死機就不那么容易解決了。《深入理解計算機系統》這本書中也講到:與內存有關的錯誤,屬于那些最令人驚恐的錯誤,因為他們在時間和空間上,經常在舉措無緣一段距離之后才表現出來,將錯誤的數據選擇錯誤的位置,你的程序可能在最終失敗之前運行了好幾個小時,其實程序終止的位置距離處的位置已經很遠了。
內存操作錯誤為什么會造成死機?舉個簡單的類比:一套性能完整的代碼就好比是一幢設計建造完好的房子,而內存操作錯誤就像是突然拆除或篡改了支撐房屋結構或運轉的關鍵部位,這一突發錯誤完全超出設計意料之外,引發一連串的惡果也就在所難免。下面是一些常見的內存錯誤操作的例子(真實的情況遠不止這些,更多情況可參考《深入理解計算機系統》這本書):
4.1 數組越界
int data_tab[100];
int i;
for(i=0; i《=100; i++)
{
data_tab[i] = i;
}
這個例子中data_tab占100個int型長度,但循環卻進行了101次。C對于數組應用不進行任何邊界檢查,而且局部變量和狀態信息(例如保存的寄存器值和返回地址)存放在棧中,這兩種情況結合到一起就能導致嚴重的程序錯誤,對越界數組元素的寫操作,會破壞存儲在棧中的狀態信息。
另一種情況是data_tab[]是全局數組,這樣的越界將引起對數組尾部其它存儲內容的修改,倘若被篡改的存儲區域剛好涉及到代碼的關鍵流程判斷,意味著異常流程的出現。
4.2 間接引用壞指針
scanf(“%d”, &val);
錯誤寫成:
scanf(“%d”, val);
在這種情況下,scanf將val的內容解釋為一個地址,并試圖將一個字節寫到這個位置。在最好的情況下,程序立即異常終止,在最糟糕的情況下,val的內容對應于內存的某個合法的讀寫區域,于是我們就覆蓋了這塊內存,就通常會在相當長的一段時間以后,造成災難性的令人困惑的后果。
4.3 誤解指針運算
int *search(int *p, int val)
{
while(*p && *p != val)
p += sizeof(int) // should be p++
return p;
}
這種錯誤是忘記了指針的算術操作是以它指向的對象的大小為單位來進行的。假設這里int占四個字節,原本掃描每個int變成了每四個int掃描一次。
4.4 引用不存在的變量
int *stackref()
{
int val;
int *p;
p = &val;
return p;
}
這個函數返回一個指針,指向棧里的一個局部變量。盡管p仍指向一個合法的內存地址,是它已經不再指向一個合法的變量了。要知道,局部變量val在函數返回時,將會被釋掉。
4.5 引用空閑堆塊中的數據
int *heapref()
{
int *x, *y;
x = (int *)malloc(sizeof(int));
。。。
free(x);
y = x;
。。。
return y;
}
這里y錯誤的引用已經被釋放的堆塊的數據和指針x。申請的堆塊一旦被釋放,雖然之前指向該堆塊的指針x并沒有變化,但它指向的區域數據已經不再有效了。
4.6 引起內存泄漏
void leak(int n)
{
x = (int *)malloc(n*sizeof(int));
return;
}
這個函數中分配了一個堆塊,但沒釋放就返回。如果經常調用leak,那么漸漸的堆里就會充滿了垃圾,最糟糕的情況下會占用整個內存地址空間。內存泄漏是緩慢、隱性的殺手。
評論
查看更多