【CS50 學習筆記】week4 — Memory

Chengcheng Hsu
15 min readMar 7, 2023

Addresses

【CS50 學習筆記】week2 — 了解編譯、除錯與字串處理 中的記憶體章節中有粗略提到當聲明一個變數時,會在記憶體中分配一段空間來存儲該變數的值,這一個空間指的是一個位元組 byte(等於 8 bits),這些空間都有其唯一的記憶體地址(Addresses)。一般情況下,這些地址會以十六進制的形式表示,並且為了表示這些地址而非變數的值,前面會添加前綴「0x」。例如:十進制的數字 10 在十六進制中表示為「0xA」,這個「0x」前綴告訴計算機這個數字是一個十六進制數字而不是十進制。

以下為宣告一 n 變數並賦值 50,在電腦的記憶體中就會分配一個地址給 n 存 50,並且有個地址,下圖中其地址是 0x123。

#include <stdio.h>

int main(void)
{
int n = 50;
printf("%i\n", n);
}

再來要先了解一個運算子:

  • (ampersand):被稱作「取址」運算子(address-of operator),主要是提供變數的記憶體位址(Provides the address of something stored in memory.)

像是以下就透過 把 n 變數的地址印出來

#include <stdio.h>

int main(void)
{
int n = 50;
printf("%p\n", &n); // 會印出變數 n 的記憶體位址,%p 這個格式化代碼代表要印出 16 進位的地址
}

補充:int 會佔據 4 個 bytes,印出的記憶體位址會是第一個 byte 的地址。

指標(pointer)

指標(pointer)是個「資料型態」,就像 int、char 一樣,只不過他是存的是「記憶體地址」,無論是某個變數的記憶體地址,或是某個記憶體地址的記憶體地址,通常需要搭配 * 來存入,如下方 *p 是用來宣告一個指標變數。

#include <stdio.h>

int main(void)
{
int n = 50;
int *p = &n; // 也有人會寫成 int* p = &n; ,星星的位置不同
printf("%i\n", *p); // 50
}

最後印出的那一行的 * 會被稱作「取值」或「間接」(indirection)運算子,主要用來取出指標所指向的變數的值,也就是取出指標所指向記憶體地址上的值。例如,若 p 是一個 int 型態的指標,則 *p 表示取出指標 p 所指向的記憶體地址上的值,也就是 50,所以和宣告指標的 * 是不同的用處。

Strings

我們知道字串是一個以 null character(’\0')結尾的字元陣列,而在此週之前會透過 cs50.h header 所提供的 string 來宣告字串,但到了這週可以透過 char 型態的指標來宣告字串,所以將 string s = “HI!”; 寫成 char *s = “HI!”; ,這樣替換就不用 string這個「輔助輪」了,而 s 就是指標,去指向 H 這個字元的記憶體地址。

進一步解釋,當編譯器看到 “” 這樣的字元串時,會在記憶體中配置足夠的空間以容納這些字元,並將它們以連續的方式存儲下來,最後在結尾處添加一個空字符 ‘\0’ 作為終止符號,在以下s[0]會是指 H 這個字元,而 &s[0]就是該記憶體地址,最後 &s[3] 會是 ‘\0’ 這個終止符號的記憶體地址。

所以在 printf 函數中使用 %s 格式化字串時,指標 s 就會從起始位置開始遍歷字元陣列並印出,直到遇到結束字元(null character,即 ‘\0’),即只會印出 “HI!”。

#include <cs50.h>
#include <stdio.h>

int main(void)
{
char *s = "HI!" // 原來是 string s = "HI!";
printf("%p\n", s); // 0x5621c6dd7004
printf("%p\n", &s[0]); // 0x5621c6dd7004
printf("%p\n", &s[1]); // 0x5621c6dd7005
printf("%p\n", &s[2]); // 0x5621c6dd7006
printf("%p\n", &s[3]); // 0x5621c6dd7007
printf("%s\n", s); // HI!
}

將宣告指向 int 的指標以及指向 char 放在一起來看:

int n = 50; int *p = &n; 是宣告一個 int 變數 n,並賦值為 50,接著宣告一個指向 int 的指標 p,並將 p 設定為 n 的記憶體位址。這樣 p 就指向了 n 這個整數變數。

char *s = “HI!”;宣告一個指向字元的指標 s,並把它指向字元陣列 “HI!” 的第一個字元 ‘H’的記憶體位址,也就是 &”HI!”[0]

Pointer Arithmetic 指標算術運算

若將程式改寫成以下,透過指標的地址來印出其對應的字元,因為 s 就是存H 的記憶體地址,因此透過 *s取出其字元後用 %c 來輸出字元,*(s + 1) 即 H 的記憶體地址加一,以此類推,也可將 *s 寫成 s[0] ,會印出同樣的東西。

#include <stdio.h>

int main(void)
{
char *s = "HI!";
printf("%c\n", *s); // H
printf("%c\n", *(s + 1)); // I
printf("%c\n", *(s + 2)); // !
}

Comparing Strings

此週之前我們在比較字串時會透過 strcmp 來比較兩者字串是否是相同的,而在以下的例子中用 == 來比較時就會比較到兩者指標所存的記憶體地址,所以就算輸入的是同樣的東西,因為其地址不同所以會印出 Different。

#include <cs50.h>
#include <stdio.h>

int main(void)
{
// Get two strings
char *s = get_string("s: ");
char *t = get_string("t: ");

// Compare strings' addresses
if (s == t)
{
printf("Same\n");
}
else
{
printf("Different\n");
}
}

Copying

若要複製一個字串的話,可能一開始會像以下這樣寫

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
// Get a string
string s = get_string("s: ");

// Copy string's address
string t = s;

// Capitalize first letter in string
t[0] = toupper(t[0]);

// Print string twice
printf("s: %s\n", s);
printf("t: %s\n", t);
}

string t = s; 其實是在複製記憶體地址,因此若大寫化 t 第一個字元的話,s 也會隨之改變,因為他們指向同個記憶體位置,但這樣並不是複製字串而是複製地址,因此改寫成以下,先用malloc 是在記憶體中分配一段指定大小的空間,這裡是指定 s 的長度加一,這個加一是為了留給 ‘\0’ 這個結束字元,再透過迴圈來將各個字元賦值到 t 的各個記憶體位置,注意到 n = strlen(s) 是寫在第一個參數裡而非第二個,這樣能避免每次回圈都去執行 strlen ,在這裡只需要也只會執行一次。

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
// Get a string
char *s = get_string("s: ");

// Allocate memory for another string
char *t = malloc(strlen(s) + 1);

// Copy string into memory, including '\0'
for (int i = 0, n = strlen(s); i <= n; i++)
{
t[i] = s[i];
}

// Capitalize copy
t[0] = toupper(t[0]);

// Print strings
printf("s: %s\n", s);
printf("t: %s\n", t);
}

然而跑迴圈賦值那段可以改寫成 c 語言內建的函式 strcpy

for (int i = 0, n = strlen(s); i <= n; i++)
{
t[i] = s[i];
}

// 上面等於下面

// Copy string into memory
strcpy(t, s);

為了避免發生錯誤,因此可以檢查指標 s 和指標 t 是否為空值,改寫如下,另外,若有使用到 malloc 就會需要 free 來釋放其分配的記憶體(如下 free 裡面傳入該指標 t ),否則會發生記憶體泄漏或是影響運行效能。

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
// Get a string
char *s = get_string("s: ");
if (s == NULL)
{
return 1;
}

// Allocate memory for another string
char *t = malloc(strlen(s) + 1);
if (t == NULL)
{
return 1;
}

// Copy string into memory
strcpy(t, s);

// Capitalize copy
if (strlen(t) > 0)
{
t[0] = toupper(t[0]);
}

// Print strings
printf("s: %s\n", s);
printf("t: %s\n", t);

// Free memory
free(t);
return 0;
}

Valgrind

透過 Valgrind 可以偵測是否有記憶體洩漏的情況,若將以上的程式的 free(t);拿掉並重新 compile 後執行 valgrind ./address (valgrind ./檔名) 即可發現左下圖會有個 LEAK SUMMARY 指出有多少 bytes 洩漏了,若將 free(t); 加回並重新 compile 後執行 valgrind ./address 就會向右下圖的結果,顯示 no leaks are possible。

Garbage Values

在 C 語言中,定義一個陣列時,編譯器不會自動給陣列元素賦初值,而是會分配一塊記憶體空間給陣列,空間中的內容則是未知的,這就是所謂的「未初始化」狀態。

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
int scores[1024];
for (int i = 0; i < 1024; i++)
{
printf("%i\n", scores[i]);
}
}

而在上面的程式中定義 int scores[1024]; 陣列時,編譯器只是給這個陣列分配了一塊大小為 1024 * sizeof(int) 的記憶體空間,但並不會把這塊空間做任何的初始化,賦予任何值。所以在執行 printf(“%i\n”, scores[i]); 這個迴圈時,印出的結果都是隨機值(未初始化的值),結果不全是 0,若要全部是 0,可改成宣告 int scores[1024] = {0};

Swap

有時候可能會需要互換變數裡的數字,一開始可能會像以下這樣寫:

#include <stdio.h>

void swap(int a, int b);

int main(void)
{
int x = 1;
int y = 2;

printf("x is %i, y is %i\n", x, y); // x is 1, y is 2
swap(x, y);
printf("x is %i, y is %i\n", x, y); // x is 1, y is 2
}

void swap(int a, int b)
{
int tmp = a;
a = b;
b = tmp;
}

但會發現沒有作用,印出的第二行和第一行是一樣的,這是因為傳入 swap 的是值,而非傳址,若要更深入了解就需要瞭解一些有關記憶體的名詞:

  • Machine code :有些文章會寫成 text,也就是會儲存編譯階段生成的二進位編碼。
  • Static/Global:或是寫成 data ,主要是儲存全域變數,用於程式中跨越多個函數皆需要的變數。
  • Stack:用來儲存函式的呼叫和區域變數的資訊,在函式結束會被釋放。若在做 recursion 時沒有中斷點就可能會超出系統所能負荷的範圍,這時會被稱為 stack overflow。(buffer overflows 的其中一種)
  • Heap:是可以用來動態分配的記憶體,如使用 malloc、calloc 等等,在使用完後釋放回 Heap。若透過 malloc 調用了 5 個 bytes,而嘗試去寫入第 6 個 byte,就會發生 heap overflow。(buffer overflows 的其中一種)

CS50 是以左下圖的方式呈現,有些文章如右下圖會以相反的順序呈現,所以在執行上方的程式時在編譯和儲存全域變數之外,stack 會切一塊記憶體區域給 main 函式來執行,接著再切一塊區域給 swap 函式執行。

若改成以下的方式,swap(&x, &y); 就可以將 x 和 y 的記憶體地址傳入 swap 函式,如此一來就能在 swap 函式間接操作 main 函式的區域變數來進行交換,但這裡比較難以理解的部分在於 swap(&x, &y); 中,我們使用取址運算子 & 取得了 xy 的地址,然後將這些地址傳遞給了 swap 函式。在函式中,透過解取值運算子 * 取得了這些地址所對應的值,並將其進行交換。

#include <stdio.h>

void swap(int *a, int *b);

int main(void)
{
int x = 1;
int y = 2;

printf("x is %i, y is %i\n", x, y);
swap(&x, &y);
printf("x is %i, y is %i\n", x, y);
}

void swap(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}

scanf

在 cs50.h header 中有提供的 get_int 的函式來獲取輸入的整數,但現在可以透過 scanf 這個函式來達成:

#include <stdio.h>

int main(void)
{
int x;
printf("x: ");
scanf("%i", &x);
printf("x: %i\n", x);
}

printf(“x: “); 印出 x: 之後 scanf(“%i”, &x);scanf是「標準輸入」(是有關於 stdin, stdout, stderr 的專有名詞),相較於 printf 是所謂的「標準輸出」來說它會讀取鍵盤上所輸入的文字,主要會傳入兩個參數,第一個是要讀的資料格式,第二個是變數的地址,在這裡就會是 &x ,最後印出輸入的結果。

而若想輸入 string 但不能用 cs50 提供的 get_string 怎麼做?可以改寫成以下:

#include <stdio.h>

int main(void)
{
char s[4];
printf("s: ");
scanf("%s", s);
printf("s: %s\n", s);
}

但需要注意的是

  1. char s[4]; 不可只寫成 char *s; ,因為沒有為其分配任何記憶體空間,char s[4]; 的 s 代表宣告一個 4 個 bytes 的字元數列。
  2. scanf(“%s”, s); 不用寫成 scanf(“%s”, &s);,因為 s 本身就是指摽。

小結:

這週的課程真 D 難,需反覆看幾次教學才能慢慢理解,因為 int 和 char 運作模式很不一樣,但藉此也多了解一些記憶體的運作模式。

參考資料:

  1. C語言的指標(pointer)

--

--