לדלג לתוכן

אתחול תהליך ושחזור מצב הביצוע

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

בעת טעינת תוכנית הקרנל בונה מצב התחלתי הכולל את הארגומנטים, הסביבה ומערך auxiliary vector. הטוען הדינמי, אם קיים, משתמש במידע הזה ובכותרות ELF כדי להשלים מיפויים ו־relocations. סביבת הריצה מכינה את הכניסה לפונקציית המשתמש. התהליך אינו מתחיל בקריאה רגילה ל־main מתוך פונקציה שכתבנו.

התוכנית הבאה מציגה את מה שהגיע לתוכנית ומאפשרת לעצור אותה כדי לחקור את /proc. היא מיועדת ללינוקס, ומשתמשת ב־getauxval של libc כדי לקרוא את ערך גודל העמוד.

use std::ffi::c_ulong;
use std::io;
unsafe extern "C" { fn getauxval(kind: c_ulong) -> c_ulong; }
fn main() -> io::Result<()> {
    const AT_PAGESZ: c_ulong = 6;
    println!("pid={}", std::process::id());
    for (index, arg) in std::env::args_os().enumerate() {
        println!("arg[{index}]={arg:?}");
    }
    println!("page size={}", unsafe { getauxval(AT_PAGESZ) });
    println!("PATH present={}", std::env::var_os("PATH").is_some());
    let mut line = String::new();
    io::stdin().read_line(&mut line)?;
    Ok(())
}

הבחירה ב־args_os חשובה משום שארגומנטים בלינוקס אינם חייבים להיות UTF-8. הממשק args נוח כאשר החוזה של התוכנית מגביל את הקלט לטקסט תקין, אבל כלי מערכת צריך להחליט איך הוא מטפל בשמות ובארגומנטים שאינם טקסט כזה. גם סביבת התהליך היא קלט שמגיע מבחוץ, ולכן נוכחות של משתנה אינה הבטחה לתוכן תקין.

הריצו את התוכנית ובמסוף נוסף בדקו את המיפויים שלה. החליפו את המספר בדוגמה ב־PID שהתוכנית הדפיסה.

cat /proc/12345/maps
readelf -l ./startup
readelf -n ./startup

קשרו בין מיפוי בעל הרשאת ביצוע לבין segment מתאים בבינארי או בספרייה. חפשו גם את [vdso], שמאפשר לקרנל לספק חלק מהשירותים בלי מעבר מלא בכל קריאה. הכתובות משתנות בין הרצות עקב ASLR, ולכן כתובת מוחלטת לבדה אינה מזהה יציב של פונקציה. אנחנו צריכים לדעת איזה אובייקט מופה ובאיזה offset.

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

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

#[inline(never)]
fn parse_record(text: &str) -> usize { text.parse().expect("invalid record") }
#[inline(never)]
fn process(text: &str) -> usize { parse_record(text) + 1 }
fn main() { println!("{}", process("broken")); }

הריצו בבנייה עם נתוני דיבוג, ולאחר מכן בבנייה ממוטבת עם אותם נתונים. אפשר להשתמש ב־RUST_BACKTRACE=1 כדי להציג מחסנית פאניקה, וב־-C force-frame-pointers=yes כאשר רוצים לשמור שרשרת frame pointers לצורך ניסוי.

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

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

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