Week 4 メモリ
ようこそ!
これまでの週では、画像がピクセルと呼ばれる小さな構成要素でできていることについて話しました。
今日は、これらの画像を構成する 0 と 1 についてさらに詳しく見ていきます。特に、画像を含むファイルを構成する基本的な構成要素について、より深く掘り下げていきます。
さらに、コンピュータのメモリに保存されている基礎データにアクセスする方法についても説明します。
今日を始めるにあたって、この講義で扱う概念が完全に「腑に落ちる」までには少し時間がかかるかもしれないことを知っておいてください。
ピクセルアート
- ピクセルは正方形で、色の個々の点で、上下左右のグリッド上に配置されています。
画像はビットのマップとして想像できます。ここで 0 は黒を表し、1 は白を表します。
16進数
RGB(赤、緑、青)は、これらの各色の量を表す数値です。Adobe Photoshop では、これらの設定を次のように確認できます。
赤、青、緑の量によって選択される色がどのように変化するかに注目してください。
- 上の画像から、色は単に 3 つの値だけで表されているのではないことがわかります。ウィンドウの下部には、数字と文字で構成された特別な値があります。
255はFFと表現されています。これはなぜでしょうか?
16進数(Hexadecimal)は、16 個の数え値を持つ計数システムです。それらは以下の通りです。
0 1 2 3 4 5 6 7 8 9 A B C D E F
F が 15 を表していることに注目してください。
16進数は base-16(16進法)としても知られています。
16進数で数えるとき、各桁は 16 の累乗になります。
数値の
0は00と表されます。数値の
1は01と表されます。数値の
9は09と表されます。数値の
10は0Aと表されます。数値の
15は0Fと表されます。数値の
16は10と表されます。数値の
255はFFと表されます。なぜなら、16 x 15(またはF)は 240 であり、そこにさらに 15 を足すと 255 になるからです。これは 2 桁の 16 進法で数えることができる最大の数値です。16進数は、より少ない桁数で表現できるため便利です。16進数を使用すると、情報をより簡潔に表現できます。
メモリ
これまでの週で、並んだメモリブロックをアーティストが描いた図を覚えているかもしれません。これらの各メモリブロックに 16 進数の番号を割り当てると、次のように視覚化できます。
上記の 10 のブロックがメモリ内の場所を表しているのか、それとも値の 10 を表しているのかについて、混乱が生じる可能性があることが想像できるでしょう。そのため、慣習として、すべての 16 進数は次のように 0x という接頭辞を付けて表されることがよくあります。
ターミナルウィンドウで code addresses.c と入力し、次のようにコードを記述します。
// Prints an integer
#include
int main(void)
{
int n = 50;
printf("%i\n", n);
}
n が値 50 としてメモリに保存されていることに注目してください。
このプログラムがこの値をどのように保存するかを次のように視覚化できます。
ポインタ
C 言語には、メモリに関連する 2 つの強力な演算子があります。
& メモリに保存されているものの「アドレス」を提供します。
* メモリ内の場所に「行く」ようにコンパイラに指示します。
この知識を活用して、次のようにコードを修正できます。
// Prints an integer's address
#include
int main(void)
{
int n = 50;
printf("%p\n", &n);
}
メモリ内の場所のアドレスを表示できる %p に注目してください。&n は文字通り「n のアドレス」と訳せます。このコードを実行すると、0x で始まるメモリのアドレスが返されます。
- ポインタ(Pointer)とは、何かの「アドレス」を保存する変数です。最も簡潔に言えば、ポインタはコンピュータのメモリ内のアドレスです。
次のコードを考えてみましょう。
int n = 50;
int *p = &n;
p は整数 n のアドレスを保持するポインタであることに注目してください。
コードを次のように修正します。
// Stores and prints an integer's address
#include
int main(void)
{
int n = 50;
int *p = &n;
printf("%p\n", p);
}
このコードは、前のコードと同じ効果があることに注目してください。単純に & 演算子と * 演算子に関する新しい知識を活用しただけです。
* 演算子の使用例を示すために、次を考えてみましょう。
// Stores and prints an integer via its address
#include
int main(void)
{
int n = 50;
int *p = &n;
printf("%i\n", *p);
}
printf の行が p の場所にある整数を出力していることに注目してください。int *p は、整数のメモリドレスを保存することを目的とするポインタを作成します。
コードを次のように視覚化できます。
ポインタがかなり大きく見えることに注目してください。実際、ポインタは通常 8 バイトの値として保存されます。p は 50 のアドレスを保存しています。
ポインタは、別のアドレスを指し示す 1 つのアドレスとして、より正確に視覚化できます。
文字列
- ポインタのメンタルモデルができたところで、このコースの初期に提供された簡略化の段階を一つ剥がしてみましょう。
コードを次のように修正します。
// Prints a string
#include
#include
int main(void)
{
string s = "HI!";
printf("%s\n", s);
}
文字列 s が出力されることに注目してください。
文字列は単なる文字の配列であることを思い出してください。例えば、string s = "HI!" は次のように表現できます。
しかし、s の正体は何でしょうか? s はメモリのどこに保存されているのでしょうか? 想像通り、s はどこかに保存される必要があります。s と文字列の関係を次のように視覚化できます。
s と呼ばれるポインタが、文字列の最初のバイトがメモリ内のどこに存在するかをコンパイラに伝えていることに注目してください。
コードを次のように修正します。
// Prints a string's address as well the addresses of its chars
#include
#include
int main(void)
{
string s = "HI!";
printf("%p\n", s);
printf("%p\n", &s[0]);
printf("%p\n", &s[1]);
printf("%p\n", &s[2]);
printf("%p\n", &s[3]);
}
上記は、文字列 s 内の各文字のメモリ位置を出力していることに注目してください。& 記号は、文字列の各要素のアドレスを表示するために使用されます。このコードを実行すると、要素 0、1、2、3 がメモリ内で隣り合っていることがわかります。
同様に、コードを次のように修正できます。
// Declares a string with CS50 Library
#include
#include
int main(void)
{
string s = "HI!";
printf("%s\n", s);
}
このコードは、s の場所から始まる文字列を表示することに注目してください。このコードは、cs50.h によって提供された string データ型という「補助輪」を事実上取り外しています。これは CS50 ライブラリの足場がない、生の C コードです。
補助輪を外して、もう一度コードを修正してみましょう。
// Declares a string without CS50 Library
#include
int main(void)
{
char *s = "HI!";
printf("%s\n", s);
}
cs50.h が削除されていることに注目してください。文字列は char * として実装されています。
データ型としての文字列がどのように作成されるか想像できるでしょう。
先週、構造体(struct)として独自のデータ型を作成する方法を学びました。
CS50 ライブラリには、次のような構造体が含まれています:
typedef char *stringこの構造体は、CS50 ライブラリを使用するときに、
stringというカスタムデータ型を使用できるようにします。
ポインタ演算
- ポインタ演算とは、メモリの位置に対して算術演算を行う機能です。
次のように文字列内の各メモリ位置を出力するようにコードを修正できます。
// Prints a string's chars
#include
int main(void)
{
char *s = "HI!";
printf("%c\n", s[0]);
printf("%c\n", s[1]);
printf("%c\n", s[2]);
}
s の場所にある各文字を出力していることに注目してください。
さらに、コードを次のように修正できます。
// Prints a string's chars via pointer arithmetic
#include
int main(void)
{
char *s = "HI!";
printf("%c\n", *s);
printf("%c\n", *(s + 1));
printf("%c\n", *(s + 2));
}
s の場所にある最初の文字が出力されます。次に、場所 s + 1 にある文字が出力され、以下同様に続きます。
同様に、次を考えてみましょう。
// Prints substrings via pointer arithmetic
#include
int main(void)
{
char *s = "HI!";
printf("%s\n", s);
printf("%s\n", s + 1);
printf("%s\n", s + 2);
}
このコードは、s で始まるさまざまなメモリ位置に保存されている値を出力することに注目してください。
文字列の比較
- 文字列は、単に最初のバイトの位置によって識別される文字の配列です。
コースの初期に、整数の比較について考えました。これをターミナルウィンドウに code compare.c と入力して、次のようにコードで表現できます。
// Compares two integers
#include
#include
int main(void)
{
// Get two integers
int i = get_int("i: ");
int j = get_int("j: ");
// Compare integers
if (i == j)
{
printf("Same\n");
}
else
{
printf("Different\n");
}
}
このコードは、ユーザーから 2 つの整数を受け取り、それらを比較することに注目してください。
しかし、文字列の場合、
==演算子を使用して 2 つの文字列を比較することはできません。文字列を比較しようとして
==演算子を使用すると、文字列内の文字ではなく、文字列のメモリ位置を比較しようとします。したがって、strcmpの使用をお勧めしました。
これを説明するために、コードを次のように修正します。
// Compares two strings' addresses
#include
#include
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");
}
}
両方の文字列に HI! と入力しても、出力は依然として Different になることに注目してください。
なぜこれらの文字列は一見異なっているのでしょうか? 次の図を使用して、その理由を視覚化できます。
- したがって、上記の
compare.cのコードは、実際には文字列自体ではなく、メモリドレスが異なっているかどうかを確認しようとしています。
strcmp を使用して、コードを修正できます。
// Compares two strings using strcmp
#include
#include
#include
int main(void)
{
// Get two strings
char *s = get_string("s: ");
char *t = get_string("t: ");
// Compare strings
if (strcmp(s, t) == 0)
{
printf("Same\n");
}
else
{
printf("Different\n");
}
}
文字列が同じ場合、strcmp は 0 を返すことに注目してください。
これら 2 つの文字列が 2 つの場所に存在することをさらに説明するために、コードを次のように修正します。
// Prints two strings
#include
#include
int main(void)
{
// Get two strings
char *s = get_string("s: ");
char *t = get_string("t: ");
// Print strings
printf("%s\n", s);
printf("%s\n", t);
}
現在、2 つの別々の文字列がおそらく 2 つの別々の場所に保存されていることに注目してください。
少しの修正で、これら 2 つの保存された文字列の場所を確認できます。
// Prints two strings' addresses
#include
#include
int main(void)
{
// Get two strings
char *s = get_string("s: ");
char *t = get_string("t: ");
// Print strings' addresses
printf("%p\n", s);
printf("%p\n", t);
}
print 文の中で %s が %p に変更されていることに注目してください。
コピーと malloc
- プログラミングでよくあるニーズは、ある文字列を別の文字列にコピーすることです。
ターミナルウィンドウで code copy.c と入力し、次のようにコードを記述します。
// Capitalizes a string
#include
#include
#include
#include
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 は s のアドレスを t にコピーしていることに注目してください。これは私たちが望んでいることを達成していません。文字列はコピーされず、アドレスのみがコピーされます。さらに、ctype.h が含まれていることにも注目してください。
上記のコードを次のように視覚化できます。
s と t が依然として同じメモリブロックを指していることに注目してください。これは文字列の本物のコピーではありません。代わりに、これらは同じ文字列を指している 2 つのポインタです。
この課題に取り組む前に、string s を string t にコピーしようとして、string t が存在しない場合にコードが セグメンテーション・フォールト(segmentation fault)を起こさないようにすることが重要です。これを助けるために、次のように strlen 関数を採用できます。
// Capitalizes a string, checking length first
#include
#include
#include
#include
int main(void)
{
// Get a string
string s = get_string("s: ");
// Copy string's address
string t = s;
// Capitalize first letter in string
if (strlen(t) > 0)
{
t[0] = toupper(t[0]);
}
// Print string twice
printf("s: %s\n", s);
printf("t: %s\n", t);
}
string t が存在することを確認するために strlen が使用されていることに注目してください。存在しない場合、何もコピーされません。
文字列の本物のコピーを作成できるようにするには、2 つの新しいビルディングブロックを導入する必要があります。まず、malloc はプログラマであるあなたが特定のサイズのメモリブロックを割り当てることを可能にします。第二に、free は以前に割り当てたメモリブロックを「解放」するようにコンパイラに伝えることができます。
文字列の本物のコピーを作成するために、コードを次のように修正できます。
// Capitalizes a copy of a string
#include
#include
#include
#include
#include
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; i strlen(s); i++)
{
t[i] = s[i];
}
// Capitalize copy
t[0] = toupper(t[0]);
// Print strings
printf("s: %s\n", s);
printf("t: %s\n", t);
}
malloc(strlen(s) + 1) が、文字列 s の長さに 1 を加えたメモリブロックを作成することに注目してください。これにより、コピーされた最終的な文字列に ヌル(null)\0 文字を含めることができます。次に、for ループが文字列 s を巡回し、各値を文字列 t の同じ場所に割り当てます。
実は、私たちのコードは非効率的です。次のように修正してください。
// Capitalizes a copy of a string, defining n in loop too
#include
#include
#include
#include
#include
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);
}
n = strlen(s) が for ループ の左側で定義されていることに注目してください。for ループの中間条件で不要な関数を呼び出さないのが最善です。中間条件は何度も実行されるからです。n = strlen(s) を左側に移動すると、strlen 関数は一度だけ実行されます。
C 言語には、文字列をコピーするための strcpy という組み込み関数があります。これは次のように実装できます。
// Capitalizes a copy of a string using strcpy
#include
#include
#include
#include
#include
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
strcpy(t, s);
// Capitalize copy
t[0] = toupper(t[0]);
// Print strings
printf("s: %s\n", s);
printf("t: %s\n", t);
}
strcpy が、以前に for ループが行っていたのと同じ作業を行っていることに注目してください。
get_string と malloc の両方は、何らかの問題が発生した場合にメモリ内の特別な値である NULL を返します。次のように、この NULL 状態をチェックするコードを記述できます。
// Capitalizes a copy of a string without memory errors
#include
#include
#include
#include
#include
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;
}
取得した文字列の長さが 0 であるか、malloc が失敗した場合、NULL が返されることに注目してください。さらに、free によって、malloc で作成したこのメモリブロックを使い終わったことをコンピュータに知らせていることにも注目してください。
Valgrind
Valgrind は、malloc を利用したプログラムにメモリ関連の問題がないかどうかを確認できるツールです。具体的には、割り当てたすべてのメモリを free したかどうかをチェックします。
memory.c の次のコードを考えてみましょう。
// Demonstrates memory errors via valgrind
#include
#include
int main(void)
{
int *x = malloc(3 * sizeof(int));
x[1] = 72;
x[2] = 73;
x[3] = 33;
}
このプログラムを実行してもエラーは発生しないことに注目してください。malloc を使用して配列に十分なメモリを割り当てていますが、コードは割り当てられたメモリを free するのに失敗しています。
make memoryに続いてvalgrind ./memoryと入力すると、Valgrind からプログラムの結果としてメモリが失われた場所を報告するレポートが届きます。Valgrind が明らかにするエラーの 1 つは、サイズ3の配列しか割り当てていないのに、配列の 4 番目の位置に値33を割り当てようとしたことです。もう 1 つのエラーは、xを一度も解放しなかったことです。
次のように、x のメモリを解放するようにコードを修正できます。
// Demonstrates memory errors via valgrind
#include
#include
int main(void)
{
int *x = malloc(3 * sizeof(int));
x[1] = 72;
x[2] = 73;
x[3] = 33;
free(x);
}
再度 valgrind を実行すると、メモリリークが発生しなくなることに注目してください。
ゴミ(ガーベジ)値
- コンパイラにメモリブロックを要求しても、そのメモリが空である保証はありません。
割り当てたメモリが以前にコンピュータによって利用されていた可能性は十分にあります。したがって、「ジャンク」または「ゴミ(ガーベジ)値」が見えることがあります。これは、メモリブロックを取得したものの、初期化しなかった結果です。例えば、garbage.c の次のコードを考えてみましょう。
#include
#include
int main(void)
{
int scores[1024];
for (int i = 0; i 1024; i++)
{
printf("%i\n", scores[i]);
}
}
このコードを実行すると、配列用にメモリ内に 1024 個の場所が割り当てられますが、for ループは、そこにあるすべての値が 0 ではないことを示す可能性が高いことに注目してください。メモリブロックをゼロなどの他の値に初期化しない場合、ガーベジ値が含まれる可能性があることを意識しておくことが常にベストプラクティスです。
Binky のポインタの楽しみ
- ポインタを視覚化して理解するのに役立つ スタンフォード大学のビデオ を視聴しました。
スワップ(入れ替え)
現実世界では、プログラミングでよくあるニーズは 2 つの値を入れ替えることです。当然ながら、一時的な保持スペースなしに 2 つの変数を入れ替えるのは困難です。実際には、code swap.c と入力して次のようにコードを記述し、これが動作する様子を確認できます。
// Fails to swap two integers
#include
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;
}
このコードは実行されますが、動作しないことに注目してください。値は swap 関数に送られた後でも、入れ替わりません。なぜでしょうか?
- 関数に値を渡すとき、コピーのみを提供しているからです。現在書かれているコードでは、
xとyの スコープ(scope)は main 関数に限定されています。つまり、main関数の波括弧{}内で作成されたxとyの値は、main関数のスコープしか持ちません。上記のコードでは、xとyは「値渡し」(pass by value)されています。
次の画像を考えてみましょう。
このコースでは使用していませんが、グローバル変数はメモリ内の一箇所に存在することに注目してください。さまざまな関数は、メモリの別の領域である スタック(stack)に保存されます。
次に、この画像を考えてみましょう。
main と swap には 2 つの別々の フレーム またはメモリ領域があることに注目してください。したがって、ある関数の値を別の関数に渡して変更させることは単にできません。
コードを次のように修正します。
// Swaps two integers using pointers
#include
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;
}
変数が「値渡し」ではなく「参照渡し」(pass by reference)されていることに注目してください。つまり、a と b のアドレスが関数に提供されます。したがって、swap 関数は main 関数の実際の a と b をどこで変更すればよいかを知ることができます。
これを次のように視覚化できます。
オーバーフロー
ヒープ・オーバーフロー(heap overflow)とは、ヒープが溢れ、本来触れてはいけないメモリ領域に触れてしまうことです。
スタック・オーバーフロー(stack overflow)とは、あまりにも多くの関数が呼び出され、利用可能なメモリ量を超えてしまうことです。
これらは両方とも、バッファ・オーバーフロー(buffer overflows)と見なされます。
scanf
- CS50 では、ユーザーから入力を取得する動作を簡素化するために、
get_intのような関数を作成してきました。
scanf は、ユーザー入力を取得できる組み込み関数です。
次のように scanf を使用して、get_int をかなり簡単に再実装できます。
// Gets an int from user using scanf
#include
int main(void)
{
int n;
printf("n: ");
scanf("%i", &n);
printf("n: %i\n", n);
}
n の値が scanf("%i", &n) という行で n の場所に保存されていることに注目してください。
ただし、get_string を再実装しようとするのは簡単ではありません。次を考えてみましょう。
// Dangerously gets a string from user using scanf with array
#include
int main(void)
{
char s[4];
printf("s: ");
scanf("%s", s);
printf("s: %s\n", s);
}
文字列は特別なので、& は不要であることに注目してください。それでも、このプログラムは実行されるたびに正しく機能するとは限りません。このプログラムのどこにも、文字列に必要なメモリ量を割り当てていません。実際、ユーザーによってどれくらいの長さの文字列が入力されるか分かりません。さらに、そのメモリ位置にどのようなガーベジ値が存在するかも分かりません。
さらに、コードを次のように変更することもできます。ただし、文字列に対してあらかじめ一定量のメモリを割り当てる必要があります。
// Using malloc
#include
#include
int main(void)
{
char *s = malloc(4);
if (s == NULL)
{
return 1;
}
printf("s: ");
scanf("%s", s);
printf("s: %s\n", s);
free(s);
return 0;
}
4 バイトの文字列が提供された場合、エラーが発生する「可能性がある」ことに注目してください。
コードを次のように簡略化すると、このあらかじめ割り当てることの根本的な問題をさらに理解できます。
#include
int main(void)
{
char s[4];
printf("s: ");
scanf("%s", s);
printf("s: %s\n", s);
}
サイズ 4 の配列をあらかじめ割り当てておけば、cat と入力すればプログラムは機能することに注目してください。しかし、これより長い文字列はエラーを引き起こす可能性があります。
- コンパイラやそれを実行しているシステムが、私たちが指定したよりも多くのメモリを割り当てることがあります。しかし、根本的には、上記のコードは安全ではありません。ユーザーが、あらかじめ割り当てられたメモリに収まる文字列を入力することを信頼することはできません。
ファイル入出力 (I/O)
ファイルから読み取ったり、ファイルを操作したりできます。このトピックについては今後の週でさらに詳しく説明しますが、phonebook.c の次のコードを考えてみましょう。
// Saves names and numbers to a CSV file
#include
#include
#include
int main(void)
{
// Open CSV file
FILE *file = fopen("phonebook.csv", "a");
// Get name and number
char *name = get_string("Name: ");
char *number = get_string("Number: ");
// Print to file
fprintf(file, "%s,%s\n", name, number);
// Close file
fclose(file);
}
このコードがファイルにアクセスするためにポインタを使用していることに注目してください。
- 上記のコードを実行する前に
phonebook.csvというファイルを作成しておくか、phonebook.csv をダウンロードしてください。上記のプログラムを実行し、名前と電話番号を入力すると、そのデータが CSV ファイルに永続的に保存されることがわかります。
プログラムを実行する前に phonebook.csv が存在することを確認したい場合は、次のようにコードを修正できます。
// Saves names and numbers to a CSV file
#include
#include
#include
int main(void)
{
// Open CSV file
FILE *file = fopen("phonebook.csv", "a");
if (!file)
{
return 1;
}
// Get name and number
char *name = get_string("Name: ");
char *number = get_string("Number: ");
// Print to file
fprintf(file, "%s,%s\n", name, number);
// Close file
fclose(file);
}
このプログラムが、return 1 を呼び出すことで NULL ポインタから保護していることに注目してください。
code cp.c と入力し、次のようにコードを記述することで、独自のコピープログラムを実装できます。
// Copies a file
#include
#include
typedef uint8_t BYTE;
int main(int argc, char *argv[])
{
FILE *src = fopen(argv[1], "rb");
FILE *dst = fopen(argv[2], "wb");
BYTE b;
while (fread(&b, sizeof(b), 1, src) != 0)
{
fwrite(&b, sizeof(b), 1, dst);
}
fclose(dst);
fclose(src);
}
このファイルが、uint8_t のサイズである BYTE という独自のデータ型を作成していることに注目してください。次に、ファイルは BYTE を読み取り、それをファイルに書き込みます。
- BMP も、調べて操作できるデータの集まりです。今週は、問題セットでまさにそれを行うことになります!
まとめ
このレッスンでは、特定のメモリ位置にあるデータにアクセスして操作する機能を提供するポインタについて学びました。具体的には、以下の内容を掘り下げました。
ピクセルアート
16進数
メモリ
ポインタ
文字列
ポインタ演算
文字列の比較
コピー
malloc と Valgrind
ガーベジ値
スワップ
オーバーフロー
scanfファイル入出力 (I/O)
また次回お会いしましょう!