לדלג לתוכן

ממשק בינארי בין ראסט לשפת סי

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

נתחיל בממשק שאינו מקצה זיכרון ואינו שומר מצביעים. שמרו את הקוד הבא בקובץ sum.c.

#include <stddef.h>
#include <stdint.h>

int sum_bytes(const uint8_t *data, size_t len, uint64_t *out) {
    if (!out || (!data && len != 0)) return -1;
    uint64_t value = 0;
    for (size_t i = 0; i < len; ++i) value += data[i];
    *out = value;
    return 0;
}

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

הקוד הבא הוא תוכנית ראסט מלאה בקובץ main.rs.

use std::ffi::c_int;

unsafe extern "C" {
    fn sum_bytes(data: *const u8, len: usize, out: *mut u64) -> c_int;
}

fn sum(data: &[u8]) -> Result<u64, c_int> {
    let mut out = 0;
    // SAFETY: both pointers remain valid throughout this synchronous call.
    let status = unsafe { sum_bytes(data.as_ptr(), data.len(), &mut out) };
    if status == 0 { Ok(out) } else { Err(status) }
}

fn main() {
    assert_eq!(sum(&[10, 20, 30]), Ok(60));
    assert_eq!(sum(&[]), Ok(0));
}

הריצו בסביבת לינוקס עם קומפיילר סי והכלים הרגילים של הלינקר.

cc -std=c11 -Wall -Wextra -c sum.c -o sum.o
ar rcs libsum.a sum.o
rustc --edition=2024 main.rs -L . -l static=sum -o ffi-sum
./ffi-sum

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

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

#[repr(C)]
struct Summary {
    length: u32,
    total: u64,
}

המהדר עשוי להכניס ריפוד בין השדות. כללי הABI קובעים גם אם המבנה מוחזר ברגיסטרים או דרך מצביע נסתר, ולכן הכרזה על פונקציה זרה ככזאת שמחזירה u128 במקום Summary אינה תיקון חוקי אפילו אם גודל שני הייצוגים במקרה שווה. בדקו את הכותרת האמיתית, את sizeof ואת offsetof בצד סי ואת size_of ו־offset_of בצד ראסט.

הבדל נוסף מופיע כאשר מעבירים long. בסביבת לינוקס רגילה של 64 ביט נפוץ מודל LP64, שבו גם long וגם מצביע הם בני 64 ביט. בסביבת Windows של 64 ביט נפוץ מודל LLP64, שבו המצביע בן 64 ביט אבל long נשאר בן 32 ביט. השתמשו ב־c_long וב־c_int עבור טיפוסי סי, ובטיפוסים בעלי רוחב קבוע כאשר הכותרת עצמה משתמשת ב־uint64_t. אין להסיק רוחב טיפוס רק מרוחב המעבד.

גבול המחרוזת דורש החלטה נפרדת. הטיפוס &str הוא זוג של מצביע ואורך עם דרישת UTF-8, ואינו מחרוזת סי. הטיפוס CString מבטיח סיום באפס והיעדר אפס פנימי, אבל המצביע שמוחזר מ־as_ptr תקף רק כל עוד ה־CString חי. אל תכתבו CString::new(text)?.as_ptr() ותשמרו את המצביע להמשך, משום שהערך הזמני יושמד.

כלל הבעלות חשוב במיוחד בממשק שמחזיר הקצאה. אם ספרייה זרה מספקת create ו־destroy, השתמשו בזוג שלה. אין להעביר אוטומטית את המצביע ל־Box::from_raw, מפני שהמקצה, הפריסה והבעלות אינם בהכרח אלה של Box. העטיפה יכולה להחזיק NonNull<Opaque> ולקרוא ל־destroy ב־Drop, אבל עליה לטפל גם בכשל יצירה ובשאלה אם הידית מותרת להעברה לתהליכון אחר.

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