לדלג לתוכן

3.1 - פריסת זיכרון ותקפות ערכים - הרצאה

בייטים קיימים אינם בהכרח ערך חוקי

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

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

נתחיל בפריסה שאפשר לבדוק בלי גישה לא בטוחה:

use std::mem::{align_of, offset_of, size_of};

#[repr(C)]
struct Header {
    kind: u8,
    length: u32,
}

fn main() {
    println!("size={}", size_of::<Header>());
    println!("align={}", align_of::<Header>());
    println!("kind={}", offset_of!(Header, kind));
    println!("length={}", offset_of!(Header, length));
}

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

ריפוד אינו נתון שהיישום אתחל

כאשר אנחנו מאתחלים את שדות Header, איננו מבטיחים שכל בייט ריפוד ביניהם קיבל ערך בעל משמעות. לכן העתקת כל הייצוג של המבנה כפרוסת בייטים אינה דרך בטוחה אוטומטית לכתוב פורמט קובץ. מעבר לשאלת endian, יש גם שאלת padding ותקפות הקריאה.

נכתוב קידוד מפורש שאינו תלוי בריפוד:

fn encode(kind: u8, length: u32) -> [u8; 5] {
    let mut output = [0u8; 5];
    output[0] = kind;
    output[1..].copy_from_slice(&length.to_le_bytes());
    output
}

fn main() {
    assert_eq!(encode(1, 0x12345678), [1, 0x78, 0x56, 0x34, 0x12]);
}

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

מספר ייצוגים אינם מתקבלים בטיפוס

לבייט יש 256 אפשרויות, אבל bool מייצג רק שני ערכים חוקיים. גם char אינו כל מספר u32: הוא מייצג Unicode scalar value ומחריג אזורים מסוימים. הפניה רגילה דורשת כתובת שאינה null, יישור מתאים ותקפות עבור השימוש המותר.

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

fn main() {
    for number in [0x41, 0xD800, 0x10FFFF, 0x110000] {
        println!("{number:x} -> {:?}", char::from_u32(number));
    }
}

הפונקציה מחזירה None כאשר הייצוג אינו חוקי. זו אינה בדיקת נגישות זיכרון אלא בדיקת משמעות הטיפוס. באותה רוח, enum עם מצבים מוגדרים אינו מקבל בהכרח כל discriminant שהתקבל מקובץ או מספריית C.

מעטפת שקופה והבטחה מצומצמת

טיפוס מעטפת יכול להשתמש ב־repr(transparent) כאשר הוא עומד בדרישות הייצוג. כך אפשר לבטא טיפוס בעל משמעות חדשה שנשען על ייצוג של שדה עיקרי. זה שימושי בגבולות בינאריים, אבל אינו הופך כל ערך של הטיפוס הפנימי לערך לוגי תקין של המעטפת.

#[repr(transparent)]
struct NonEmptyCount(u32);

impl NonEmptyCount {
    fn new(value: u32) -> Option<Self> {
        if value == 0 { None } else { Some(Self(value)) }
    }
    fn get(&self) -> u32 { self.0 }
}

fn main() {
    assert!(NonEmptyCount::new(0).is_none());
    assert_eq!(NonEmptyCount::new(3).unwrap().get(), 3);
}

המעטפת שלנו מגדירה אינווריאנט לוגי של ערך חיובי. הייצוג השקוף אינו מבצע את בדיקת ה־constructor במקום הלקוח. אם נבנה FFI שמאפשר לצד זר ליצור את הייצוג ישירות, נצטרך לאמת אותו לפני חשיפה כערך שהמתודות סומכות על חוקיותו.

יישור הוא תנאי לגישה מסוימת

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

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

עוברים מתצפית לחוזה

הדפסת גודל, יישור ו־offset היא ניסוי חשוב, אך היא עונה מה קרה בבנייה המסוימת. כדי להסתמך על הפרט בקוד צריך לבדוק מה מובטח על ידי ייצוג הטיפוס ומה נשאר בחירת מימוש. מפרט הפריסה מפריד בין ההבטחות האלה.

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