לדלג לתוכן

ניתוח פלט המהדר ואבחון התנהגות זיכרון

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

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

#[unsafe(no_mangle)]
pub fn checksum(bytes: &[u8]) -> u64 {
    bytes.iter().fold(0u64, |sum, &value| sum.wrapping_add(u64::from(value)))
}

הפקודות הבאות יוצרות ייצוג ביניים ואסמבלי בלי ליצור תוכנית הרצה. ההגדרה crate-type=lib מתאימה משום שאין כאן main.

rustc --edition=2024 --crate-type=lib -C opt-level=0 \
  --emit=mir,llvm-ir,asm checksum.rs --out-dir out-debug
rustc --edition=2024 --crate-type=lib -C opt-level=3 \
  --emit=mir,llvm-ir,asm checksum.rs --out-dir out-release

הייצוג MIR קרוב יותר למושגים של ראסט ויכול להראות מסלולי השמדה וזרימת שליטה. הייצוג LLVM IR כבר מבטא הנחות ואופטימיזציות בשפה של שכבת הקודגנרציה. האסמבלי הוא תוצאה המותאמת לארכיטקטורה ולדגלי הבנייה. אין לצפות לשם משתנה מהמקור בכל שכבה, ואין לפרש היעדר קריאה ל־Iterator::next כהוכחה שהאיטרטור לא השתתף בתוכנית.

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

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

כאן נכנס הכלי Miri. הוא מריץ תוכניות לפי מודל ביצוע שמסוגל לזהות סוגים של התנהגות לא מוגדרת, כמו שימוש בזיכרון לא מאותחל או חריגה מגבולות הקצאה. הכלי תלוי ב־nightly ומתפתח לצד מודלי aliasing, ולכן אנחנו מפרידים אותו מכלי הבנייה היציבים של הקורס. שמרו בפרויקט הניסוי את גרסת ה־nightly המדויקת שעבדה אצלכם.

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

#[test]
fn invalid_extra_element() {
    let bytes = Box::new([10u8, 20, 30]);
    let invalid = unsafe { std::slice::from_raw_parts(bytes.as_ptr(), 4) };
    assert_eq!(invalid[0], 10);
}

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

rustup +nightly component add miri
cargo +nightly miri test

הכלי Miri אינו מריץ כל קריאת מערכת או ספרייה זרה שרירותית. במקרה של עטיפת FFI נרצה לעיתים לבדוק את לוגיקת הבעלות עם מימוש מדומה, ובנפרד לבדוק את קוד הסי באמצעות sanitizers. הכלי AddressSanitizer יכול לעזור למצוא גלישות ושימוש אחרי שחרור בהרצות native, אבל גם הוא רואה רק מסלולים שהרצנו ובנייה שהותאמה אליו. תמיכה ב־sanitizers בראסט וביעדים מסוימים תלויה בכלי nightly ובהגדרות היעד, ולכן נבדוק את התיעוד לפני שינוי דגלים.

שילוב של fuzzing ושל בדיקות תכונה מרחיב את אוסף הקלטים. למשל, עבור parser אפשר להשוות בין מימוש בטוח ופשוט לבין מימוש ממוטב ולחפש קלט שבו התוצאות שונות. הבדיקה אינה מוכיחה soundness, אבל היא יכולה לייצר דוגמת נגד שניתן לצמצם ולחקור. גם היעדר ממצא ב־Miri אינו הוכחה לכל קוד עתידי ולכל סדר אירועים.

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

ההוראות וההגבלות מופיעות במאגר הרשמי של Miri ובתיעוד sanitizers של המהדר. בכל דוח רשמו איזו השערה נבדקה, איזה כלי יכול להפריך אותה ומה נשאר מחוץ לבדיקה.