מצביעים, באפרים ופענוח טקסט ב-C
מצביעים, באפרים ופענוח טקסט — מהמחרוזת אל הקובץ
בשיעור הקודם ראינו שמחרוזת ב-C היא מערך char שמסתיים ב-\0. עכשיו נבין מה באמת עומד מאחוריה: מצביעים, חשבון מצביעים, הקצאה דינמית ובאפרים גולמיים. אלה בדיוק הכלים שתצטרכו בשיעור הבא, כשנקרא נתונים מקובץ אל תוך באפר ונפרק אותם לשדות.
- מצביעים: &, *, וחשבון מצביעים (Pointer Arithmetic)
- מערך מול מצביע — למה sizeof מתנהג אחרת בתוך פונקציה
- מערכי מחרוזות: char *argv[] ומערכים דו-ממדיים
- הקצאה דינמית למחרוזות: malloc, realloc, free, strdup
- באפרים גולמיים: memcpy, memmove, memset, memcmp
- פירוק טקסט: strchr, strstr, strtok, sscanf
- קריאת שורות לבאפר — התבנית שתחזור בשיעור File I/O
מצביעים וחשבון מצביעים
מצביע הוא משתנה שמכיל כתובת בזיכרון. חיבור למצביע לא מוסיף בייט אחד — הוא מקדם ביחידות של הטיפוס שאליו הוא מצביע.
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!)מערך מול מצביע — הבדל שמפיל אנשים
מערך אינו מצביע, אבל ברוב ההקשרים הוא מתפורר (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 מקבל את גודל הבאפר.מערכי מחרוזות ו-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;
}הקצאה דינמית למחרוזות
כשגודל הטקסט אינו ידוע מראש — וזה בדיוק המצב כשקוראים מקובץ — מקצים זיכרון ב-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);באפרים גולמיים — 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פירוק טקסט לשדות — 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. תמיד להגביל רוחב שדה!
}קריאת שורות לבאפר — הגשר לשיעור הבא
התבנית הבאה תחזור כמעט מילה במילה בשיעור 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) על קובץ פתוח.
// כל השאר — זהה בדיוק.סיכום
- חשבון מצביעים מקדם ביחידות הטיפוס; חיסור מצביעים מחזיר מספר איברים
- פרמטר מערך הוא מצביע — תמיד להעביר גם את האורך
- הקצאה למחרוזת = 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 בבאפר. בלי הסרתו כל השוואה או פיצול של השורה ייכשלו.