【CS50 學習筆記】week4 — Memory
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);
中,我們使用取址運算子 &
取得了 x
和 y
的地址,然後將這些地址傳遞給了 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);
}
但需要注意的是
char s[4];
不可只寫成char *s;
,因為沒有為其分配任何記憶體空間,char s[4];
的 s 代表宣告一個 4 個 bytes 的字元數列。scanf(“%s”, s);
不用寫成scanf(“%s”, &s);
,因為 s 本身就是指摽。
小結:
這週的課程真 D 難,需反覆看幾次教學才能慢慢理解,因為 int 和 char 運作模式很不一樣,但藉此也多了解一些記憶體的運作模式。