GammaCyber

מצביעים, באפרים ופענוח טקסט ב-C

Low-Level: C, Assembly ו-RE · 4–5 שעות

// סקירה

מצביעים, באפרים ופענוח טקסט — מהמחרוזת אל הקובץ

בשיעור הקודם ראינו שמחרוזת ב-C היא מערך char שמסתיים ב-\0. עכשיו נבין מה באמת עומד מאחוריה: מצביעים, חשבון מצביעים, הקצאה דינמית ובאפרים גולמיים. אלה בדיוק הכלים שתצטרכו בשיעור הבא, כשנקרא נתונים מקובץ אל תוך באפר ונפרק אותם לשדות.

✓נושאי השיעור
  • מצביעים: &, *, וחשבון מצביעים (Pointer Arithmetic)
  • מערך מול מצביע — למה sizeof מתנהג אחרת בתוך פונקציה
  • מערכי מחרוזות: char *argv[] ומערכים דו-ממדיים
  • הקצאה דינמית למחרוזות: malloc, realloc, free, strdup
  • באפרים גולמיים: memcpy, memmove, memset, memcmp
  • פירוק טקסט: strchr, strstr, strtok, sscanf
  • קריאת שורות לבאפר — התבנית שתחזור בשיעור File I/O
🎯 איך זה מופיע במיוני גאמא
שאלון C — חשבון מצביעים, sizeof על פרמטר מסוג מערך, ניהול זיכרון נכון. Black Box — פירוק קלט משתמש לשדות ובדיקת גבולות באפר. RE — זיהוי memcpy/memset וחישובי כתובות ב-Assembly.
// פרק 1

מצביעים וחשבון מצביעים

מצביע הוא משתנה שמכיל כתובת בזיכרון. חיבור למצביע לא מוסיף בייט אחד — הוא מקדם ביחידות של הטיפוס שאליו הוא מצביע.

int x = 42; int *p = &x; // p מחזיק את הכתובת של x printf("%d", *p); // 42 — Dereference int arr[5] = {10,20,30,40,50}; int *q = arr; // שם מערך "מתפורר" לכתובת האיבר הראשון printf("%d", *(q+2)); // 30 ← q+2 מקדם ב-2*sizeof(int) = 8 בתים printf("%d", q[2]); // 30 ← בדיוק אותו דבר char *s = "abc"; printf("%c", *(s+1)); // 'b' ← כאן כל צעד = בית אחד // הפרש בין מצביעים = מספר האיברים ביניהם int *end = arr + 5; printf("%ld", end - arr); // 5 (ולא 20!)
✏תרגול עצמי2 שאלות
שאלה 1.1
בהינתן int arr[5]; int *q = arr; — לכמה בתים קדימה מצביע q+2 ביחס ל-q?
✓ תשובה: ב) — חשבון מצביעים מקדם ביחידות של הטיפוס. עבור int בגודל 4 בתים, q+2 = q + 8 בתים. זו בדיוק הסיבה ש-q[2] ו-*(q+2) שקולים.
שאלה 1.2
מה יחזיר end - arr כאשר int *end = arr + 5;?
✓ תשובה: ג) — חיסור מצביעים באותו מערך מחזיר את מספר האיברים ביניהם (טיפוס ptrdiff_t). המהדר מחלק אוטומטית בגודל הטיפוס.
// פרק 2

מערך מול מצביע — הבדל שמפיל אנשים

מערך אינו מצביע, אבל ברוב ההקשרים הוא מתפורר (decay) לכתובת האיבר הראשון. ברגע שהעברתם מערך לפונקציה — איבדתם את המידע על גודלו.

void f(char buf[]) { printf("%zu", sizeof(buf)); // 8 — גודל של מצביע! לא של המערך } int main(void) { char buf[64]; printf("%zu", sizeof(buf)); // 64 — כאן זה עדיין מערך f(buf); return 0; } // ← לכן כל API בטוח מקבל גם את הגודל: // void f(char *buf, size_t len); // זו בדיוק הסיבה ש-fgets מקבל את גודל הבאפר.
✏תרגול עצמי1 שאלה
שאלה 2.1
מה מחזיר sizeof(buf) בתוך void f(char buf[]) במערכת 64 ביט?
✓ תשובה: א) — פרמטר מערך הוא למעשה מצביע, ולכן sizeof מחזיר את גודל המצביע (8 בתים ב-64 ביט). מכאן הכלל: תמיד להעביר את אורך הבאפר בנפרד.
// פרק 3

מערכי מחרוזות ו-argv

מערך של מחרוזות הוא מערך של מצביעים. כך בדיוק מגיעים הארגומנטים לתוכנית — וכך תחזיקו בהמשך שורות שנקראו מקובץ.

char *names[3] = {"alice", "bob", "carol"}; printf("%s", names[1]); // bob printf("%c", names[1][0]); // 'b' // מערך דו-ממדי — כל שורה בגודל קבוע, ניתן לשינוי char table[3][16] = {"alice", "bob", "carol"}; strcpy(table[1], "dan"); // חוקי // names[1][0] = 'B'; // ⚠️ Read-Only — קריסה int main(int argc, char *argv[]) { for (int i = 0; i < argc; i++) printf("%s\n", argv[i]); // argv[0] = שם התוכנית return 0; }
✏תרגול עצמי1 שאלה
שאלה 3.1
מה ההבדל המהותי בין char *names[3] לבין char table[3][16]?
✓ תשובה: ד) — names הוא מערך מצביעים ל-String Literals בזיכרון לקריאה בלבד; table מקצה 3×16 בתים ממשיים שניתן לשנות עם strcpy.
// פרק 4

הקצאה דינמית למחרוזות

כשגודל הטקסט אינו ידוע מראש — וזה בדיוק המצב כשקוראים מקובץ — מקצים זיכרון ב-Runtime. הכלל הקריטי: תמיד +1 עבור ה-\0.

char *copy = malloc(strlen(src) + 1); // +1 ל-\0! if (!copy) return NULL; // תמיד לבדוק strcpy(copy, src); ... free(copy); copy = NULL; // מניעת Use-After-Free // הגדלה תוך כדי ריצה char *buf = malloc(64); char *tmp = realloc(buf, 128); if (!tmp) { free(buf); return NULL; } // realloc עלול להיכשל buf = tmp; // strdup = malloc + strcpy (גם הוא דורש free) char *d = strdup(src);
✏תרגול עצמי2 שאלות
שאלה 4.1
מדוע malloc(strlen(s)) ואחריו strcpy היא באג?
✓ תשובה: ב) — strlen אינו סופר את ה-\0, אבל strcpy כותב אותו. חסר בית אחד ⇒ Heap Overflow. הכתיב הנכון: malloc(strlen(s)+1).
שאלה 4.2
מדוע כותבים tmp = realloc(buf, n) ולא buf = realloc(buf, n)?
✓ תשובה: ג) — בכישלון realloc מחזיר NULL והבלוק המקורי נשאר תקף. השמה ישירה דורסת את הכתובת היחידה שלו — Memory Leak קלאסי.
// פרק 5

באפרים גולמיים — memcpy, memmove, memset

פונקציות ה-str עוצרות ב-\0. נתונים מקובץ בינארי אינם מכילים \0 בסוף — ולכן עובדים עם פונקציות mem שמקבלות אורך מפורש.

char buf[64]; memset(buf, 0, sizeof(buf)); // איפוס הבאפר memcpy(buf, src, n); // העתקה של n בתים בדיוק memmove(buf, buf + 4, n - 4); // בטוח גם בחפיפה if (memcmp(a, b, 16) == 0) { ... } // השוואה בינארית // ⚠️ memcpy אינו מוסיף \0: memcpy(buf, "abc", 3); buf[3] = '\0'; // חובה ידנית לפני printf("%s") // ⚠️ חפיפה ב-memcpy = Undefined Behavior → memmove
✏תרגול עצמי2 שאלות
שאלה 5.1
מתי חובה להשתמש ב-memmove במקום memcpy?
✓ תשובה: א) — בחפיפה בין המקור ליעד memcpy הוא Undefined Behavior. memmove מטפל בחפיפה נכון (כאילו דרך באפר ביניים).
שאלה 5.2
אחרי memcpy(buf, "abc", 3), מדוע printf("%s", buf) מסוכן?
✓ תשובה: ד) — פונקציות mem מעתיקות בתים בלבד. בלי buf[3] = '\0' ההדפסה קוראת מעבר לנתונים — דליפת מידע או קריסה.
// פרק 6

פירוק טקסט לשדות — strchr, strtok, sscanf

שורה שנקראה מקובץ היא רק מחרוזת אחת ארוכה. כדי להפוך אותה לנתונים צריך לפרק אותה — וזה בדיוק מה שנעשה בשיעור הבא על כל שורה בקובץ.

char line[] = "alice,30,haifa"; char *comma = strchr(line, ','); // מצביע לפסיק הראשון (או NULL) char *found = strstr(line, "hai"); // חיפוש תת-מחרוזת // strtok — משנה את המחרוזת המקורית! (מחליף מפריד ב-\0) char *tok = strtok(line, ","); while (tok) { printf("[%s]\n", tok); tok = strtok(NULL, ","); // NULL = המשך מאותה מחרוזת } // sscanf — פירוק לפי תבנית char name[32]; int age; if (sscanf("alice 30", "%31s %d", name, &age) == 2) { // 31 ולא 32 — מקום ל-\0. תמיד להגביל רוחב שדה! }
✏תרגול עצמי2 שאלות
שאלה 6.1
מהי תופעת הלוואי המרכזית של strtok?
✓ תשובה: ב) — strtok כותב \0 במקום המפרידים בתוך המחרוזת המקורית. לכן אסור להריץ אותו על String Literal, ואם צריך לשמור את המקור — יש לעבוד על עותק (strdup).
שאלה 6.2
מדוע כותבים sscanf(s, "%31s", name) עבור char name[32]?
✓ תשובה: ג) — בלי הגבלת רוחב, %s ב-sscanf/scanf הוא Buffer Overflow מובנה. הרוחב הוא גודל המערך פחות אחד, כדי להשאיר מקום ל-\0.
// פרק 7

קריאת שורות לבאפר — הגשר לשיעור הבא

התבנית הבאה תחזור כמעט מילה במילה בשיעור File I/O — רק שבמקום stdin יופיע מצביע לקובץ.

char line[256]; while (fgets(line, sizeof(line), stdin)) { line[strcspn(line, "\n")] = '\0'; // הסרת תו שורה חדשה if (line[0] == '\0') continue; // שורה ריקה char *sep = strchr(line, '='); if (!sep) continue; // שורה לא תקינה — לדלג *sep = '\0'; // פיצול לשני חלקים char *key = line, *val = sep + 1; printf("key=%s val=%s\n", key, val); } // בשיעור הבא: fgets(line, sizeof(line), fp) על קובץ פתוח. // כל השאר — זהה בדיוק.
✏תרגול עצמי1 שאלה
שאלה 7.1
מדוע מריצים line[strcspn(line, "\n")] = '\0' אחרי fgets?
✓ תשובה: א) — בשונה מ-gets, הפונקציה fgets משאירה את ה-\n בבאפר. בלי הסרתו כל השוואה או פיצול של השורה ייכשלו.
// סיכום

סיכום

✓מה חייבים לזכור
  • חשבון מצביעים מקדם ביחידות הטיפוס; חיסור מצביעים מחזיר מספר איברים
  • פרמטר מערך הוא מצביע — תמיד להעביר גם את האורך
  • הקצאה למחרוזת = strlen + 1, ותמיד free אחרי
  • realloc לתוך משתנה זמני, אחרת דליפת זיכרון בכישלון
  • פונקציות mem עובדות לפי אורך ולא לפי \0 — והן אינן מוסיפות אותו
  • strtok הורס את המחרוזת המקורית; ב-sscanf תמיד להגביל רוחב שדה
  • התבנית fgets + הסרת \n + פיצול = בסיס העבודה עם קבצים בשיעור הבא
שאלות תרגול ותשובות (11)

שאלה 1.1 · בהינתן int arr[5]; int *q = arr; — לכמה בתים קדימה מצביע q+2 ביחס ל-q?

  • א. שני בתים בלבד, מפני שחשבון מצביעים תמיד מקדם בית אחד לכל יחידה שמוסיפים למצביע בשפת C
  • ב. שמונה בתים, מפני שהקידום נעשה ביחידות של sizeof(int) שהוא ארבעה בתים בסביבה נפוצה
  • ג. עשרים בתים, מפני שחשבון מצביעים מקדם תמיד את גודל המערך כולו ולא את גודל האיבר הבודד
  • ד. אפס בתים, מפני שחיבור למצביע אינו חוקי בשפת C ויש להשתמש אך ורק בסוגריים מרובעים
תשובה: ב

✓ תשובה: ב) — חשבון מצביעים מקדם ביחידות של הטיפוס. עבור int בגודל 4 בתים, q+2 = q + 8 בתים. זו בדיוק הסיבה ש-q[2] ו-*(q+2) שקולים.

שאלה 1.2 · מה יחזיר end - arr כאשר int *end = arr + 5;?

  • א. הערך עשרים, מפני שהפרש בין מצביעים מחושב תמיד במספר הבתים המפרידים בין שתי הכתובות
  • ב. הערך ארבע, מפני שהפרש בין מצביעים מחסיר תמיד איבר אחד כדי לא לחרוג מגבול המערך
  • ג. הערך חמש, מפני שהפרש בין מצביעים נמדד במספר האיברים שביניהם ולא במספר הבתים
  • ד. ערך לא מוגדר, מפני שאסור לחסר מצביעים זה מזה בשום מצב בשפת C גם באותו מערך
תשובה: ג

✓ תשובה: ג) — חיסור מצביעים באותו מערך מחזיר את מספר האיברים ביניהם (טיפוס ptrdiff_t). המהדר מחלק אוטומטית בגודל הטיפוס.

שאלה 2.1 · מה מחזיר sizeof(buf) בתוך void f(char buf[]) במערכת 64 ביט?

  • א. שמונה, מפני שפרמטר מסוג מערך מתפורר למצביע ולכן נמדד גודלו של המצביע ולא של המערך המקורי
  • ב. שישים וארבעה, מפני שהמהדר שומר את גודל המערך המקורי ומעביר אותו יחד עם הפרמטר לפונקציה
  • ג. אחד, מפני שגודלו של תו בודד מסוג char הוא בית אחד וזה מה שנמדד עבור הפרמטר הזה
  • ד. שגיאת הידור, מפני שאסור להפעיל את האופרטור sizeof על פרמטר של פונקציה בשפת C
תשובה: א

✓ תשובה: א) — פרמטר מערך הוא למעשה מצביע, ולכן sizeof מחזיר את גודל המצביע (8 בתים ב-64 ביט). מכאן הכלל: תמיד להעביר את אורך הבאפר בנפרד.

שאלה 3.1 · מה ההבדל המהותי בין char *names[3] לבין char table[3][16]?

  • א. אין ביניהם הבדל מהותי, שכן שתי ההגדרות מקצות בדיוק אותה כמות זיכרון ומאפשרות אותן פעולות כתיבה
  • ב. ההגדרה הראשונה מקצה שטח קבוע לכל שורה, ואילו השנייה שומרת רק כתובות של מחרוזות קיימות בזיכרון
  • ג. שתי ההגדרות שומרות מצביעים בלבד, וההבדל היחיד ביניהן הוא בתחביר הגישה לאיבר הבודד בתוכן
  • ד. הראשונה שומרת מצביעים למחרוזות לקריאה בלבד, והשנייה מקצה שטח קבוע לכל שורה שניתן לכתוב אליו
תשובה: ד

✓ תשובה: ד) — names הוא מערך מצביעים ל-String Literals בזיכרון לקריאה בלבד; table מקצה 3×16 בתים ממשיים שניתן לשנות עם strcpy.

שאלה 4.1 · מדוע malloc(strlen(s)) ואחריו strcpy היא באג?

  • א. מפני ש-malloc אינו מסוגל להקצות זיכרון עבור מחרוזות ויש להשתמש בפונקציה ייעודית אחרת לשם כך
  • ב. מפני שחסר בית אחד עבור תו הסיום, ולכן strcpy כותב מעבר לשטח שהוקצה וגורם לשחיתות זיכרון
  • ג. מפני שהפונקציה strlen מחזירה תמיד ערך גדול באחד מן האורך האמיתי ולכן מוקצה שטח מיותר
  • ד. מפני שאין כל באג בקוד הזה, שכן הפונקציה strcpy מקצה בעצמה מקום נוסף לתו הסיום בעת ההעתקה
תשובה: ב

✓ תשובה: ב) — strlen אינו סופר את ה-\0, אבל strcpy כותב אותו. חסר בית אחד ⇒ Heap Overflow. הכתיב הנכון: malloc(strlen(s)+1).

שאלה 4.2 · מדוע כותבים tmp = realloc(buf, n) ולא buf = realloc(buf, n)?

  • א. מפני שהפונקציה realloc אינה מחזירה ערך כלל ולכן חובה לשמור את התוצאה במשתנה עזר נפרד
  • ב. מפני שכתיבה ישירה למשתנה המקורי אינה חוקית מבחינת התחביר של השפה ותגרום לשגיאת הידור
  • ג. מפני שאם ההקצאה נכשלת מוחזר NULL, וכתיבה ישירה תאבד את המצביע המקורי ותגרום לדליפת זיכרון
  • ד. מפני שהפונקציה realloc משחררת תמיד את הבלוק הישן ולכן המצביע המקורי חייב להישאר ללא שינוי
תשובה: ג

✓ תשובה: ג) — בכישלון realloc מחזיר NULL והבלוק המקורי נשאר תקף. השמה ישירה דורסת את הכתובת היחידה שלו — Memory Leak קלאסי.

שאלה 5.1 · מתי חובה להשתמש ב-memmove במקום memcpy?

  • א. כאשר אזור המקור ואזור היעד חופפים בזיכרון, שכן במצב כזה התנהגות memcpy אינה מוגדרת כלל
  • ב. כאשר מעתיקים מחרוזת המסתיימת בתו סיום, שכן memcpy אינו יודע לזהות את סופה של מחרוזת
  • ג. כאשר מספר הבתים המועתקים גדול משישים וארבעה, שכן memcpy מוגבל בגודל ההעתקה המרבי שלו
  • ד. כאשר היעד הוא זיכרון שהוקצה דינמית, שכן memcpy פועל אך ורק על מערכים סטטיים שהוגדרו מראש
תשובה: א

✓ תשובה: א) — בחפיפה בין המקור ליעד memcpy הוא Undefined Behavior. memmove מטפל בחפיפה נכון (כאילו דרך באפר ביניים).

שאלה 5.2 · אחרי memcpy(buf, "abc", 3), מדוע printf("%s", buf) מסוכן?

  • א. מפני שהפונקציה printf אינה יודעת להדפיס באפרים שהועתקו בעזרת memcpy אלא רק מחרוזות שהוגדרו ישירות
  • ב. מפני שהעתקה של שלושה בתים בלבד אינה חוקית ויש להעתיק תמיד את גודלו המלא של באפר היעד
  • ג. מפני שהפונקציה memcpy הופכת את סדר הבתים בזיכרון ולכן הטקסט יודפס במהופך על גבי המסך
  • ד. מפני ש-memcpy אינו מוסיף תו סיום, ולכן printf ימשיך לקרוא בתים אקראיים עד שייתקל באפס מקרי
תשובה: ד

✓ תשובה: ד) — פונקציות mem מעתיקות בתים בלבד. בלי buf[3] = '\0' ההדפסה קוראת מעבר לנתונים — דליפת מידע או קריסה.

שאלה 6.1 · מהי תופעת הלוואי המרכזית של strtok?

  • א. הפונקציה מקצה זיכרון חדש עבור כל אסימון שהיא מחזירה ולכן יש לשחרר כל אחד מהם בנפרד בסיום
  • ב. הפונקציה משנה את המחרוזת המקורית בכך שהיא מחליפה כל תו מפריד בתו סיום ולכן היא נהרסת
  • ג. הפונקציה מעתיקה את המחרוזת לבאפר פנימי קבוע ולכן היא בטוחה לשימוש גם על מחרוזות קבועות
  • ד. הפונקציה אינה משנה דבר במחרוזת ומחזירה עותק חדש ובלתי תלוי של כל אחד מן האסימונים שנמצאו
תשובה: ב

✓ תשובה: ב) — strtok כותב \0 במקום המפרידים בתוך המחרוזת המקורית. לכן אסור להריץ אותו על String Literal, ואם צריך לשמור את המקור — יש לעבוד על עותק (strdup).

שאלה 6.2 · מדוע כותבים sscanf(s, "%31s", name) עבור char name[32]?

  • א. מפני שהמספר בתבנית מציין את מספר השדות שייקראו ולכן שלושים ואחד שדות ייקראו אל תוך המערך
  • ב. מפני שהמספר בתבנית הוא עיטור בלבד ואין לו כל השפעה על אופן הקריאה שמבצעת הפונקציה בפועל
  • ג. מפני שהמספר מגביל את מספר התווים הנקראים ומשאיר בית פנוי לתו הסיום ובכך מונע גלישת באפר
  • ד. מפני שהמספר חייב להיות זהה לגודל המערך ולכן היה נכון לכתוב שלושים ושתיים במקום שלושים ואחת
תשובה: ג

✓ תשובה: ג) — בלי הגבלת רוחב, %s ב-sscanf/scanf הוא Buffer Overflow מובנה. הרוחב הוא גודל המערך פחות אחד, כדי להשאיר מקום ל-\0.

שאלה 7.1 · מדוע מריצים line[strcspn(line, "\n")] = '\0' אחרי fgets?

  • א. מפני ש-fgets שומר גם את תו השורה החדשה בתוך הבאפר, ויש להסירו כדי לקבל את תוכן השורה הנקי
  • ב. מפני ש-fgets אינו מוסיף תו סיום למחרוזת שנקראה ולכן חובה להוסיפו ידנית בסוף הבאפר בכל קריאה
  • ג. מפני שהפונקציה fgets קוראת תמיד את הקובץ כולו ויש לחתוך אותו ידנית לשורות נפרדות לאחר מכן
  • ד. מפני שללא הפעולה הזו הבאפר יישאר ריק לחלוטין ולא ניתן יהיה להשוות את תוכנו למחרוזות אחרות
תשובה: א

✓ תשובה: א) — בשונה מ-gets, הפונקציה fgets משאירה את ה-\n בבאפר. בלי הסרתו כל השוואה או פיצול של השורה ייכשלו.