跳到內容

控制記憶體的必備指令

控制記憶體只有三件事:知道資料放在哪裡(stack、heap、全域)、用指標存取它、配置了就要釋放。C 用 malloc / free 自己管理;C++ 建議交給 vector、string、智慧指標自動管理,只有在需要時才自己動手。

指令語言作用一定要記得
sizeof(x)C / C++x(型態或變數)佔幾個 bytes陣列傳進函式後,sizeof 只量得到指標的大小
&xC / C++取得 x 的位址
*pC / C++存取指標 p 指向的東西p 是 NULL 或已經釋放時不能用
p->mC / C++存取指標指向的 struct 成員等於 (*p).m
malloc(n)C從 heap 配置 n bytes,內容是垃圾值檢查是不是 NULL;一定要配一個 free
calloc(個數, 大小)C配置並把內容清成 0
realloc(p, n)C把配置的大小改成 n bytes,可能會搬到新位置用暫存指標接結果,失敗時原本的還在
free(p)C釋放只能釋放一次;之後把 p 設成 NULL
memset(p, v, n)C / C++把 n 個 bytes 都設成 v只適合設成 0 或 −1
memcpy(dst, src, n)C / C++複製 n 個 bytes來源和目的地不能重疊
memmove(dst, src, n)C / C++複製,允許重疊
new T / delete pC++配置 / 釋放一個物件,會執行建構子 / 解構子一個 new 配一個 delete
new T[n] / delete[] pC++配置 / 釋放陣列new[] 一定要用 delete[]
std::make_unique<T>(...)C++建立 unique_ptr,離開範圍時自動釋放首選;不用寫 delete
std::make_shared<T>(...)C++建立 shared_ptr,最後一個擁有者消失時才釋放比 unique_ptr 多一些負擔
std::vector<T>C++自動管理的動態陣列reserve、shrink_to_fit 可以控制容量
stackheap全域 / 靜態
放什麼區域變數、參數malloc / new 配置的全域變數、static 變數
誰負責釋放自動(離開函式時)你自己(free / delete)程式結束時
大小小(Windows 預設 1 MB)大編譯時決定
速度最快配置和釋放比較慢快

詳細的原理和實際的位址見程式是怎麼跑起來的。

指標是一個存放位址的變數:

pointers.c
/* 指標存的是「位址」。& 取位址,* 透過位址存取那個變數。 */
#include <stdio.h>
int main(void) {
int x = 10;
int *p = &x; /* p 指向 x */
*p = 20; /* 透過 p 修改 x */
printf("x = %d, *p = %d\n", x, *p);
int arr[4] = {10, 20, 30, 40};
int *q = arr; /* 陣列名稱會自動變成指向第一個元素的指標 */
printf("*q = %d, *(q + 2) = %d, q[3] = %d\n", *q, *(q + 2), q[3]);
/* 指標 +1 前進的是「一個元素」,不是 1 個 byte */
printf("q + 1 is %td bytes after q\n", (char *)(q + 1) - (char *)q);
printf("sizeof(int *) = %zu bytes\n", sizeof(int *));
return 0;
}

在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):

執行結果
x = 20, *p = 20
*q = 10, *(q + 2) = 30, q[3] = 40
q + 1 is 4 bytes after q
sizeof(int *) = 8 bytes
在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
  • int *p 讀作「p 是指向 int 的指標」。
  • p + 1 前進的是一個元素的大小,int * 前進 4 bytes,double * 前進 8 bytes。
  • 陣列名稱在大部分情況下會自動變成「指向第一個元素的指標」,所以 a[i] 和 *(a + i) 完全一樣。
  • 沒有指向任何東西的指標要設成 NULL(C)或 nullptr(C++),使用前先檢查。
  • const int *p 代表不能透過 p 修改內容;int *const p 代表 p 本身不能改指向別的地方。

函式要修改呼叫者的變數,C 的做法是傳指標:

void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
swap(&x, &y);
void *malloc(size_t size);
void *calloc(size_t count, size_t size);
void *realloc(void *ptr, size_t new_size);
void free(void *ptr);
dynamic_array.c
/* malloc / realloc / free 的標準用法:一個會自己長大的陣列。 */
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t size = 0, capacity = 2;
int *data = malloc(capacity * sizeof *data);
if (data == NULL) return 1; /* 1. 一定要檢查配置是否成功 */
for (int i = 1; i <= 10; i++) {
if (size == capacity) {
capacity *= 2;
int *tmp = realloc(data, capacity * sizeof *data); /* 2. 用暫存指標接 realloc 的結果 */
if (tmp == NULL) { /* 失敗時原本的 data 還在,要自己釋放 */
free(data);
return 1;
}
data = tmp;
printf("grow to capacity %zu\n", capacity);
}
data[size++] = i * i;
}
printf("values:");
for (size_t i = 0; i < size; i++) printf(" %d", data[i]);
printf("\n");
free(data); /* 3. 用完一定要釋放 */
data = NULL; /* 4. 釋放後設成 NULL,避免不小心再使用 */
return 0;
}

在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):

執行結果
grow to capacity 4
grow to capacity 8
grow to capacity 16
values: 1 4 9 16 25 36 49 64 81 100
在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同

養成四個習慣:

  1. 檢查回傳值:配置失敗時會回傳 NULL。
  2. 用 sizeof *p 計算大小:寫成 malloc(n * sizeof *p)。之後改了 p 的型態,這行也不用改。
  3. realloc 用暫存指標接:寫成 p = realloc(p, ...) 的話,失敗時回傳 NULL,原本那塊記憶體就再也找不到,變成洩漏。
  4. 釋放後設成 NULL:避免之後不小心再使用(use-after-free),或重複釋放。對 NULL 呼叫 free 是安全的,什麼事都不會發生。
memset_trap.c
/* memset 是把「每個 byte」都設成同一個值,所以 int 陣列只適合設成 0 或 -1。 */
#include <stdio.h>
#include <string.h>
int main(void) {
int a[3];
memset(a, 0, sizeof a);
printf("memset 0 : %d %d %d\n", a[0], a[1], a[2]);
memset(a, -1, sizeof a); /* 每個 byte 都是 0xFF,int 剛好是 -1 */
printf("memset -1: %d %d %d\n", a[0], a[1], a[2]);
memset(a, 1, sizeof a); /* 每個 byte 都是 0x01,int 變成 0x01010101,不是 1! */
printf("memset 1 : %d %d %d\n", a[0], a[1], a[2]);
/* memcpy 複製一段記憶體;來源和目的地重疊時要改用 memmove */
char s[] = "abcdef";
memmove(s + 2, s, 4); /* 把 "abcd" 搬到往後 2 格的位置,兩段有重疊 */
printf("memmove : %s\n", s);
return 0;
}

在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):

執行結果
memset 0 : 0 0 0
memset -1: -1 -1 -1
memset 1 : 16843009 16843009 16843009
memmove : ababcd
在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
  • memset(a, 0, sizeof a) 是把陣列清成 0 最快的方法。
  • 想把 int 陣列每一格都設成 1,要用迴圈;C++ 可以用 std::fill。
  • memcmp(a, b, n) 比較兩段記憶體,相同時回傳 0。
int *p = new int(5); // 配置一個 int,初始值 5
delete p;
int *arr = new int[100]; // 配置陣列
delete[] arr; // 陣列一定要用 delete[]

自己寫 new / delete 很容易忘記釋放,尤其是函式中途 return 或丟出例外的時候。所以現代 C++ 很少直接寫 new / delete,而是交給會自動釋放的工具。

smart_pointers.cpp
// 智慧指標:離開範圍時自動 delete,不會忘記釋放。
#include <iostream>
#include <memory>
#include <utility>
struct Node {
int value;
explicit Node(int v) : value(v) { std::cout << " new Node " << value << '\n'; }
~Node() { std::cout << " delete Node " << value << '\n'; }
};
int main() {
{
std::unique_ptr<Node> a = std::make_unique<Node>(1); // 只有一個擁有者
std::unique_ptr<Node> b = std::move(a); // 擁有權轉移給 b,a 變成空的
std::cout << "a is " << (a ? "not empty" : "empty") << ", b->value = " << b->value << '\n';
} // b 在這裡自動釋放 Node 1
std::shared_ptr<Node> s1 = std::make_shared<Node>(2); // 可以有好幾個擁有者
{
std::shared_ptr<Node> s2 = s1;
std::cout << "use_count = " << s1.use_count() << '\n';
}
std::cout << "use_count = " << s1.use_count() << '\n';
std::cout << "end of main\n";
return 0; // 最後一個擁有者 s1 消失時,才釋放 Node 2
}

在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):

執行結果
new Node 1
a is empty, b->value = 1
delete Node 1
new Node 2
use_count = 2
use_count = 1
end of main
delete Node 2
在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
std::unique_ptr<T>std::shared_ptr<T>
擁有者只有一個(可以用 std::move 轉移)可以有好幾個
什麼時候釋放擁有者離開範圍時最後一個擁有者消失時
額外成本幾乎沒有,和普通指標一樣大要多記一個計數器
什麼時候用預設用這個真的需要共同擁有時
vector_growth.cpp
// vector 容量不夠時,會配置一塊更大的記憶體,把資料搬過去,再釋放舊的。
// reserve 可以事先要好空間,避免一直搬家;shrink_to_fit 可以退回多餘的空間。
#include <iostream>
#include <vector>
int main() {
std::vector<int> v;
std::size_t last = v.capacity();
for (int i = 0; i < 100; i++) {
v.push_back(i);
if (v.capacity() != last) {
std::cout << "size " << v.size() << " -> capacity " << v.capacity() << '\n';
last = v.capacity();
}
}
std::vector<int> w;
w.reserve(100); // 一次要好 100 個元素的空間
std::cout << "after reserve(100): capacity " << w.capacity() << '\n';
v.resize(10);
std::cout << "after resize(10): size " << v.size() << ", capacity " << v.capacity() << '\n';
v.shrink_to_fit();
std::cout << "after shrink_to_fit: capacity " << v.capacity() << '\n';
return 0;
}

在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):

執行結果
size 1 -> capacity 1
size 2 -> capacity 2
size 3 -> capacity 4
size 5 -> capacity 8
size 9 -> capacity 16
size 17 -> capacity 32
size 33 -> capacity 64
size 65 -> capacity 128
after reserve(100): capacity 100
after resize(10): size 10, capacity 128
after shrink_to_fit: capacity 10
在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
  • size() 是目前有幾個元素,capacity() 是已經配置了多少空間。
  • 容量不夠時,vector 會配置兩倍大的新空間、把資料搬過去、再釋放舊的。搬家的那一瞬間,新舊兩塊記憶體同時存在,所以記憶體的最高用量可能是資料本身的 3 倍。
  • 事先知道大概會有多少元素時,用 reserve(n) 一次要好,可以避免一直搬家。
  • resize 不會減少容量;要退回多餘的空間用 shrink_to_fit()。

第 4 階段的題目就是在練這些技巧:

  1. 不存不需要的資料:一邊讀一邊算,只留下需要的結果。例如第 20 題,200000 個數字不必全部存起來。
  2. 選剛好夠大的型態:0 ~ 255 的數字用 uint8_t(1 byte)而不是 int(4 bytes),可以省下 3/4。
  3. 調整 struct 成員的順序:編譯器會在成員之間補空白,讓每個成員對齊到自己大小的倍數。把大的成員放前面,可以減少浪費:
padding.c
/* struct 的大小不一定等於成員大小的總和:編譯器會插入「對齊用的空白 (padding)」。
改改看:調整成員的順序,看 sizeof 怎麼變。 */
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
struct Bad { /* char, int, char:中間和最後都要補空白 */
char a;
int b;
char c;
};
struct Good { /* 大的放前面,小的放後面 */
int b;
char a;
char c;
};
struct Tiny { /* 用剛好夠大的型態 */
uint8_t age;
uint16_t year;
};
int main(void) {
printf("sizeof(struct Bad) = %zu (members: 1 + 4 + 1 = 6)\n", sizeof(struct Bad));
printf(" offsets: a=%zu b=%zu c=%zu\n", offsetof(struct Bad, a), offsetof(struct Bad, b), offsetof(struct Bad, c));
printf("sizeof(struct Good) = %zu\n", sizeof(struct Good));
printf("sizeof(struct Tiny) = %zu\n", sizeof(struct Tiny));
printf("1000 x Bad = %zu bytes, 1000 x Good = %zu bytes\n", 1000 * sizeof(struct Bad), 1000 * sizeof(struct Good));
return 0;
}

在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):

執行結果
sizeof(struct Bad) = 12 (members: 1 + 4 + 1 = 6)
offsets: a=0 b=4 c=8
sizeof(struct Good) = 8
sizeof(struct Tiny) = 4
1000 x Bad = 12000 bytes, 1000 x Good = 8000 bytes
在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
  1. 用位元代替布林陣列:bool 佔 1 byte,但只需要 1 個位元,用位元運算可以省下 7/8。見第 22 題。
  2. 注意容器的額外成本:std::set、std::map、std::list 每個元素都要另外存好幾個指標。第 22 題實測,用 std::set 存最多 20 萬個數字要 5.8 MB,用位元集合只要 128 KB。
  3. 用完就釋放,不要等到程式結束。vector 可以用 reserve 避免倍增時的用量高峰。
  4. 大陣列不要放在 stack:放全域、static,或用 heap。

常見的記憶體錯誤,以及沙盒怎麼抓

Section titled “常見的記憶體錯誤,以及沙盒怎麼抓”
錯誤例子結果沙盒怎麼抓
越界a[n](應該到 a[n - 1])讀到垃圾值,或把別的資料寫壞Sanitizer:heap-buffer-overflow、stack-buffer-overflow、global-buffer-overflow
使用已經釋放的記憶體free(p); *p = 1;未定義行為Sanitizer:heap-use-after-free
重複釋放free(p); free(p);程式當掉,或 heap 被破壞Sanitizer:double-free
記憶體洩漏malloc 之後沒有 free長時間執行時越用越多run 檔案 --mem;第 3、4 階段的檢查
NULL 指標int *p = NULL; *p = 1;程式當掉Sanitizer:SEGV;UBSan:null pointer
使用沒有初始化的變數int x; printf("%d", x);每次結果都可能不同編譯器警告(-Wall);Windows 上沒有工具能在執行時全部抓到
new[] 配 deleteint *a = new int[5]; delete a;未定義行為明顯的情況編譯器會警告(-Wmismatched-new-delete);執行時 Windows 版的 Sanitizer 抓不到(實測),要靠自己注意
回傳區域變數的位址return &local;讀到已經被回收的 stack編譯器警告
stack overflow遞迴太深、區域陣列太大程式當掉沙盒會顯示 stack overflow

前面四種是最常見的。平常用 run(Sanitizer 開啟)執行,大部分問題都會直接指出是哪一行。

(還沒寫)