OverView of Content
本文深入探討了電腦記憶體管理的核心概念,從程式定義到作業系統的作用。首先,我們將介紹程式的定義及其與記憶體的關係,以及馮·諾伊曼和哈佛結構之間的區別。接下來,我們將討論作業系統在管理記憶體中的作用,以及記憶體與不同程式語言之間的關係。
在深入了解記憶體之後,我們將探討記憶體的邏輯抽象模型、記憶體單位、數據總線的位寬以及記憶體對齊和數據類型之間的關係。隨後,我們將專注於使用 C 語言操作記憶體的技術,包括封裝記憶體地址、指針訪問、陣列記憶體和結構體的使用。
最後,我們將研究記憶體管理的重要性,涵蓋堆疊、堆、靜態儲存區塊等關鍵概念。通過本文,讀者將獲得對電腦記憶體管理的全面理解,從而更好地理解程式的執行和優化記憶體使用。
早期電腦是沒有記憶體的(記憶體也稱之為內存),但現在的電腦要十分注重記憶體,接下來將以記憶體為重點往外拓展
了解電腦運行、程式
程式定義
A. 程式是啥?程式最基礎的就是由函數組成,它為了達到某個目的而做事,而達到這個目的則需要透過 數據 + 算法
int main() {
// 數據
int a = 10;
int b = 20;
printf("sum = %d\n", sum(a, b));
}
int sum(int a, int b) {
// 算法
return a + b;
}
B. 程式的目的:關注程式 1 運行的結果、2 運行的過程
int main() {
int a = 10;
int b = 20;
printf("sum = %d\n", sum(a, b));
printInfo(a);
}
// 關注結果
int sum(int a, int b) {
return a + b;
}
// 關注過程
void printInfo(int a) {
printf("value = %d\n", a);
}
// 關注結果 & 過程
int sumWithInfo(int a, int b) {
printInfo(a);
printInfo(b);
return a + b;
}
記憶體 RAM
● 記憶體就是儲存程式、數據
● 記憶體粗略有分為 DRAM(Dynamic RAM) & SRAM(Static RAM) 兩種
Type | 特性 | 使用地方 |
---|---|---|
暫存器 | 高性能、小、貴 | CPU 暫存器 |
SRAM | 比 暫存器 慢、中、中 | CPU 的一、二、三 緩存 |
DRAM | 比 SRAM 慢、大、便宜 | 外插 RAM,DDR3、4 |
● 這邊我們大略說明一下 DDR,DDR (Doubk Data Rate) 是一種改進型的 RAM,它的 特性是可以在一個 CPU 時鐘內 讀取兩次數據
程式 & 記憶體的關係
● 記憶體就是用來儲存 程式中 可變數據 (在 C 語言中就有全局、局部變量),當你聲明一個變量時,就會在記憶體中開闢該變量的位子
// 全局部量
int globalValue = 10; // 存在記憶體中
int main() {
// 局部變量
int localValue = 1;
}
● 常量
GCC 中常量就存在記憶體中
但大部分的單晶片機,會將常量存在 Flash 中,也就是
.data
中 (這個我們之後會說到)
● 數據結構 & 演算法簡單來講就是在研究如何將數據組織、排列放入記憶體中,好讓數據的存取有更高的效率
A. 數據結構:如何組織數據放入記憶體
B. 演算法:如何加工存入數據,讓其符合你需要的業務邏輯
馮‧諾依曼 & 哈佛結構
● 硬體是軟體的基礎,所有軟體功能最終都由 硬體來決定,計算機結構,是軟體跟硬體的抽象結構
● 馮‧諾依曼結構
A. 採用二進制結構:簡化電子邏輯
B. 程序儲存 (stored-program):程序以及數據全部除存在內部儲存器中,這會導致程序& 數據共享在同一個地方,一定程度限制了機器 (因為數據、指令共享一條數據線,影響了傳輸速度)
C. 指令、數據寬度要相同(這點要看使用的指令集,如果是複雜指令集 CISC
就會不同)
ARM7、MIPS 的 CPU 都是使用 馮‧諾依曼 結構
● 哈佛結構
馮‧諾依曼 結構的進化版本,同樣是使用了二進制&stored-program,差異是在 指令 & 數據分開儲存 (增強了存取效率)
A. CPU 可以先到指令儲存器讀取指令,解碼後得到記憶體地址,再到對應的數據儲存器中讀取數據
B. 在執行步驟一時仍可儲存數據到儲存器中(就是一個非同步操作)
C. 指令、數據的寬度也可以不同
● 馮‧諾依曼 & 哈佛 - 差異
● 哈佛可以在執行操作時同時讀取下一個指令,提高的吞吐量(IO 速度),而 馮‧諾依曼 結構則無法
● 哈佛結構缺點在 1 架構複雜 & 2 需要兩個儲存器
管理記憶體:OS 的作用
● 記憶體就是一種資源,如何高效率的管理記憶體是重要課題 (怎麼申請、申請大小、何時釋放... 等等)
A. 有操作系統:操作系統會館裡所有的記憶體,並將一大塊記憶體 分頁管理 (每頁大小一般是 4 KB),一頁就是一個單位
B. 無操作系統 (eg. MCU):必須手動管理記憶體,如果沒有管理好記憶體可能會覆蓋到不能覆蓋的資料
記憶體 & 程式語言
● 不同程式語言也有不同的記憶體管理方式
語言 | 特色 | 說明 | 其他 |
---|---|---|---|
匯編 | 沒有管理、效率高 | 直接操作記憶體 | 較為麻煩 |
C | 編譯器會幫我們管理記憶體地址 | 透過變量名訪問記憶體 (eg. int a = 10; a 實際記憶體地址由編譯器指定) | 可透過 API 動態申請記憶體地址 |
C++ | 可透過關鍵字快速申請記憶體 | new 分配地址、delete 釋放記憶體 | 可透過 new/delete 動態申請地址 |
Java/C# | 不直接操控地址,而是透過虛擬機管理 | 申請、釋放都是虛擬機操作 | 虛擬機也是占用空間、效率的 |
深入了解記憶體
以邏輯角度記憶體可以隨機訪問,記憶體對於程式來說是可以存放變量 (讀取 & 寫入)
● C 語言並不會直接操作到真正的記憶體地址 (虛擬記憶體的原因),但申明的變量就是對記憶體的操作
記憶體邏輯抽象模型
● 記憶體實際上是無限多個記憶體單元格組成,每個單元格有固定的地址,該記憶體地址與記憶體單元格式永久綁定的
● 記憶體在邏輯上可以無限大 (無限多個地址),但其實 記憶體大小是 受到硬體所限制,以下提到三個有關硬體的設計,三總線:
1.地址總線:可訪問的記憶體地址範圍
傳輸記憶體 (記憶體) 地址
● 記憶體大小 & 真正可使用
記憶體大小不是越大就可以用得越多,這取決於地址總線的數量 (2 的冪次方)
e.g: 2 條線就有 22 個,也就是 00、01、10、11 這 4 個記憶體位置
e.g: 32 條就是 232個,接近於 4G
就算用了超過 地址總線數量 的記憶體還是只能使用到 地址總線的大小
2.數據總線:一次可傳輸的數據量
傳輸要寫入的數據
● 幾位元 CPU,32、64 ?
通常在說幾位元 CPU 就是指數據總線的數量
3.控制總線:控制數據的 讀取 或是 寫入
傳輸 讀 or寫 指令
● 總線概念圖
記憶體單位
● 基礎使用單位不管是哪一台電腦,或是任何位元都是使用固定單位 (除了特規...),其基本規定如下
單位 | 記憶體單元 | 其他 |
---|---|---|
Bit | 1 | 最小單元 |
Byte | 8 | 也就是 8 個 Bit |
單位 | 記憶體單元 |
---|---|
1GB | 1024MB |
1MB | 1024KB |
1KB | 1024Byte |
1B | 8Bit |
● 計算 & 電腦中的 1000 是不同的
計算的 1000 就是 1000,電腦的 1000 則是 1024
● 有關於
Word
?Word 就是指 int,一般來講 32Bit,但是 這必須要以作業系統的位元為準 (64 位元系統就是 64 Bit),所以這就不用詳細區分
● 雖然在程式中最小單位為 Bit,但電腦中,硬體的記憶體地址是以 Byte 為基礎單位來切割,一次能傳送的數據又跟數據總線有關 (下面會提及)
A. 能夠儲存數據的是記憶體
B. 記憶體又被規劃為單位大小為 1 Byte
C. 每個 Byte 又對應到 一個地址
記憶體位寬:硬體的數據總線
● 前有提到數據總線,而 記憶體位寬就是指,數據線的總量,就是一次能傳遞數據的大小,下圖就是 記憶體位寬 & 數據總線的關係
A. 左圖,依照硬體特性,一次必須傳送 8Bit 數據
B. 右圖,依照硬體特性,一次必須傳送 32Bit 數據
一次能傳送的數據是指,一個 CPU 時鐘週期內能傳送的數據
● 硬體:記憶體是可以連結的,就算是 8 位元也可以傳送 16、32 位元數據
● 軟體:軟體是可以隨意規定要取用的位元 (0 ~ 100都可以),但仍需 依賴硬體限制,就算規定是 11 位元,但記憶體寬是 32 位,那實際就是傳送 32 位數據
記憶體對齊 & 數據類型
● 在不同數據內行做存入時 (char、short、int、long、float、double) 仍是依照 硬體規定的記憶體位寬做存入的 (數據總線),不一定用了 char 類型就一定效率更高
尚未使用的部分就可以當作是浪費的
● 從這裡也可以了解到每次訪問就是以 Word (就是 int) 為單位來訪問,Word 大小又依賴於數據總線
● 對齊訪問不是邏輯的問題,是 硬體的問題,因為一次訪問一個 Word 效率是最高的
● 彙編可以使用不對其訪問,而高級語言對於記憶體的分配都是依照自動對齊做訪問
C 語言操作記憶體
C 封裝記憶體地址
// 1. 編譯器申請了一個 int 類型的記憶體 (該地址由編譯自動幫我們分配)
int a;
// 2. 透過數據總線,將 5 賦予給 a,存入 a 的地址內
a = 6;
// 3. 該操作非原子操作
// 3-1. 透過地址分析、讀取的數值
// 3-2. 將該值 +4
// 3-3. 將最終結果存入 a 的地址內
a += 4;
數據類型的含意
● C 語言中,數據類型的涵義代表了,1 記憶體單元的長度(Byte 為單位),2 解析該數據的方法
● 一個地址代表了一個記憶體單元 (Byte)
A. 地址總線透過透過地址編碼器份配,之後我們取得一個地址
B. 該地址能儲存的大小為 1 個 Byte (因為記憶體最小是以一個 Byte 為單位)
● 所以如果是 32 位元系統,透過 C 語言要申請一個 int 數據類型,那就必須要使用到 4 個 連續 地址的數據 (也就是 4 個 byte),並且 將首地址跟變量 a 綁定
這邊數據的存入使用小端 (little-endian 從低未元開始存入)
指針訪問
● 指針對於 C 來說也是一種 數據類型,也就是說指針仍是一種變量,該變量儲存在記憶體中,並且它的也有它獨特的解析方式 (用指針的方式解析)
void main() {
int a;
a = 6;
int *p = 0; // 宣告一個 p 指針 (又稱為一級指針)
p = &a; // 透過 `&` 將 a 的地址存進 p 中
printf("%d", *p); // 透過 `*` 解析 p 中存取的地址中的數值 (也就是 a 的數值)
}
上圖 int a 是使用 int 的方式解析 0x00-00-00-00,而 int *p 則是使用 int * 的方式來解析 0x03-00-00-00
● 有關於函數指針:
函數名的實質就是一個記憶體地址,透過訪問該記憶體地址來訪問函數
// test_function 就是指針 // void* 類型指針 void test_function() { // 透過編譯器會將 test_function 賦予指針 printf("TEST"); }
Array 記憶體
● Array 數組也是一種變量類型。這裡要再次強調一下,C語言中普通變量、數組、指針的本質都是一樣的,只是 解析方式不同
● Array 就是定義一塊 連續 的記憶體空間,而 數組名就是該記憶體空間的首地址 (跟前面我們所說的 int 相同,差異在解析方式),數組名就相當於一個指針
int a; // 編譯器分配 4 Byte 記憶體給 a,並把 a 與 記憶體的首地址綁定
int a[10]; // 編譯器分配 40 Byte 記憶體給 a,並把 a 與 記憶體的首地址綁定 (但解析方式與 int a 不同)
● 定義一個數組,並輸出該數組的地址,1 查看 Array 個元素地址是否是連續、2 宣告的符號是否可以當作指針操作、3 測試 Array 指針類型
從測試結果可以看到
A. 每個 int 元素都是相連的,都相差 4 個地址 (一個地址佔一個 Byte)
B. 宣告的符號 a 是數組的首地址,並且 a 可以當作 int(因為這邊宣告是 int[] 數組) 指針操作
C. 透過 &
就可以取得該數組的地址,並且該 地址解析的方式也是數組
#include <stdio.h>
int main() {
int a[5] = {0, 1, 2, 3, 4};
int len = sizeof(a) / sizeof(int); // 計算數組長度
printf("array length: %d\n", len);
for(int i = 0; i < len; i++) {
// 1. 空間相連
printf("a[%d] addr = %p\n", i, &a[i]);
}
// 2. a 作為指針
printf("a + 1, addr: %p, value: %d\n", a + 1, *(a + 1));
// 3. &a 是取 int[5] 這種結構的地址,所以 + 1 就會往下再給予 int[5] 空間的地址
printf("&a + 1, addr: %p\n", &a + 1);
return 0;
}
struct 結構
● struct 可以將各種不同類的變量存在一起(聚合數據類型),並用一個名稱描述它
// 定義一個 訊息 結構,方便之後不斷使用
struct information {
char *name;
long id;
int weight;
int height;
};
void main() {
// 可以輕鬆定義 一整塊相關資料,不用分開變量
struct information A;
struct information B;
}
● 作為 function 入參時,它也是傳遞結構中的數值,所以它會將要傳入函數內的 struct 中所有的數值都壓入 stack 內
● struct 作為參數 ?
不建議太長使 struct 作為 function 參數,建議使用 struct pointer 來傳送,原因是因為參數是一個堆疊,它會消耗堆疊的大小(之後介紹)
#include <stdio.h>
struct information {
char *name;
long id;
int weight;
int height;
};
static void set(struct information* info) {
info->name = "Alien";
}
void main() {
struct information A;
set(&A);
printf("name: %s\n", A.name);
}
C 語言的物件導向
● C 語言是注重順序的語言(面向過程),它與其他物件導向 Java、C#、C、Python 不同,但 C 仍可寫出物件導向的特性 (eg. 像是 Linux 系統就是 C 語言建構的系統)
struct {
int count; // 普通變數
// 包含函數指針的結構體 就類似於一個 class
int (*pFunc)(void); // 函數指針
}
C 語言的記憶體管理
C 語言簡單來說就是在玩記憶體的操控,而 C 語言也有自己管理記憶體的特性(如下),所以我們一定要了解其特性
A. 堆 Heap
B. 棧 Stack
● 堆棧是相同的?
首先我們要知道 堆就是堆、棧就是棧,兩者是完全不同的數據結構,常說的堆棧,是指棧
堆 Stack
● 堆主要是 C 語言用來保存局部變量、函數調用(控制記憶體的一種方式),Stack 的特性是 先進後出,對於棧的理解就可以想成是指針的移動
A. 局部變量
● 在 Function 中定義一個局部變量時 (int a),邊一起會在 Stack 中分配 4Byte 大小的空間給局部變量 a (並將 4 Byte 的地址與 a 產生關聯),對應的操作就是 入棧,將數據存入 a 中
void myFunction() {
int a; // 將 a 入棧,並讓 a 與 stack 指針產生關聯
a = 10; // 將數據 10 存入區域變量中
}
Stack 中的指針、記憶體分配是自動完成
● 當 Function 執行完畢後,區域變數會從 Stack 出棧,我們也盡量不要去控制 Stack 中地址,因為該地址的內容可能是舊 or 新
B. 函數調用
Stack 內有保有函數調用所需的所有維護信息,這樣才知道該函數調用完畢後要回到的位置、還有調用該函數之前的數據
void myFunction() {
int a = 1; // 分配 4Byte 的 Stack 空間
a = funcA(); // 在呼叫 funcA 之前,儲存 a 的地址 & 數據 & 下一個要執行的函數(funcB) 並 ++入棧 ++
// funcA 結束後原先入棧的數據出棧
a = funcB(a); // 再次除存數據 & 入棧
}
int funcA() {
return 10;
}
int funcB(int value) {
printf("Hello World: %d", value);
return value * 10;
}
● 棧的重點如下
A. 透過指針移動來操控
B. 棧中的數據不會清理(髒),這也就是為何我們在局部變量時 IDE 老是提醒我們要初始話的原因
● 棧超出?
這是棧的一個缺點,棧是固定大小的,不可以隨意調整,所以 區域變數不建議分配過大的容量,像是
A. int a[10000] 之類的就不適合
B. 遞歸函數(遞歸函數時也是棧的堆疊 !)
堆 Heap
● Heap 是用來動態配置記憶體時會使用到的管理記憶體方式,其特點是自由,但是要 手動申請釋放,而 C、C 語言也有提供我們 API 操控
A. 申請 malloc(C 語言)、new (C)
B. 釋放 free (C、C 語言)
● Heap 的特點
A. 容量不限、動態分配
B. 手動申請、釋放
● 記憶體洩漏?
就是屬於該進程的記憶體、但該進程並不使用(一直放置...忘記!),這會導致記憶體空間一直被佔據,最後可能會導致 OOM 程式 Crush
● C 語言提供的動態分配 Heap 函數 (提出幾個常用的)
在使用 C Heap 函數時,請務必 檢查返回值,若分配記憶體失敗則會返回 0
函數 | 說明 | 原型 | 其他 |
---|---|---|---|
malloc | 申請記憶體空間 | void *malloc(size_t size) | - |
calloc | 申請記憶體空間 + 初始化清理空間為 0 | void *calloc(size_t nmemb, size_t size) | - |
realloc | 修改已經分配的記憶體空間 | void *realloc(void *ptr, size_t size) | 其實是建立一個新空間,並將舊有資料複製進新空間 |
free | 釋放 | void free(void *ptr) | - |
靜態儲存區塊
● 如其名,它是負責儲存靜態變量的區塊(不管是局部、還是全局),編譯器會在編譯時就確定好靜態存區的大小,在 靜態儲存區的記憶體生命週期就是如同整個程式
static int gTest = 123;
void myFunction() {
// 局部靜態
static int test = 444;
}
更多的 C 語言相關文章
關於 C 語言的應用、研究其實涉及的層面也很廣闊,但主要是有關於到系統層面的應用(所以 C 語言又稱之為系統語言),為了避免文章過長導致混淆重點,所以將文章係分成如下章節來幫助讀者更好地從不同的層面去學習 C 語言
C 語言基礎
● C 語言基礎:有關於到 C 語言的「語言基礎、細節」
編譯器、系統開念
● 編譯器、系統開念:是學習完 C 語言的基礎(或是有一定的程度)之後,從編譯器以及系統的角度重新檢視 C 語言的一些細節
C 語言與系統開發
● C 語言與系統開發:在這裡會說明 C 語言的實際應用,以及系統為 C 語言所提供的一些函數、庫... 等等工具,看它們是如何實現、應用