رابط تابع خارجی (Foreign Function Interface یا FFI) سازوکاری است که به کد یک زبان اجازه میدهد تابعهایی را صدا بزند که به زبان دیگری نوشته شدهاند. FFI زبان برنامهنویسی سلام زبان C را هدف گرفته است، زبانی که تقریباً هر سیستمعامل و کتابخانهای میانجی خودش را با آن عرضه میکند: برای صدا زدن یک تابع C از سلام، امضای آن را در یک بلوک درونداد: (extern:) اعلام میکنید و آن را مثل هر تابع سلام صدا میزنید، بدون مولد پوششدهنده، بدون کد چسب و بدون لایهی تبدیل داده در زمان اجرا. ساختارها میتوانند با مقدار منتقل شوند، تابعهای سلام میتوانند بهعنوان کالبک (callback) به C داده شوند و یک برنامهی سلام را میتوان به یک کتابخانهی اشتراکی با سرآیند C تبدیل کرد.
این اهمیت دارد، چون هیچ زبان تازهای نمیتواند دنیا را از نو بنویسد. SQLite، zlib، OpenSSL، raylib، GTK، API ویندوز (Win32) و هزاران کتابخانهی دیگر به C نوشته شدهاند یا میانجی C دارند. زبانی که ارزان به آنها برسد از روز اول از دهها سال کار موجود بهره میبرد. این مقاله نشان میدهد سلام چطور این کار را میکند، با برنامههایی که اجرا میشوند، و قاعدههایی که کد FFI را از خراب کردن حافظه دور نگه میدارند.
اعلان و صدا زدن تابعهای C با درونداد
بلوک درونداد: تابعهای C را با گونهی پارامترها و نتیجهشان و بدون بدنه فهرست میکند. کد در کتابخانهی C است؛ سلام فقط باید بداند چطور صدایش بزند. نامهای C همانطور که در C هستند میمانند، حتی در پروندهی فارسی. تابعهای کتابخانهی استاندارد C چیز دیگری لازم ندارند.
درونداد:
تابع printf(قالب: رشته، ...): صحیح
تابع sqrt(ع: اعشار۶۴): اعشار۶۴
تابع strlen(ر: رشته): اندازه مثبت
تابع atoi(ر: رشته): صحیح
پایان
تابع ریشه:
سرچاپ "sqrt(2) ="، sqrt(۲.۰)
سرچاپ "strlen:"، strlen("salam")، strlen("سلام")
سرچاپ "atoi:"، atoi("1403") + ۱
printf("%-6s|%5d|%8.3f\n"، "row"، ۴۲، ۳.۱۴۱۵۹)
پایان... تابعی با شمار آرگومان متغیر مثل printf را نشان میدهد و قاعدههای معمول قالببندی C برقرارند. خروجی printf و سرچاپ به همان ترتیبی میآیند که برنامه نوشته است، چون سلام پیش از صدا زدن تابعی از C که خروجی مینویسد بافر خروجی خودش را خالی میکند. به strlen("سلام") هم نگاه کنید: پاسخ ۸ است، چون C بایتهای UTF-8 را میشمارد و هر حرف فارسی دو بایت است؛ همان عددی که .طول() خود سلام میدهد.
با پشتوانهی C (C Backend)، فراخوانی درونداد در کد تولیدشده یک فراخوانی معمولی C میشود، و پشتوانهی LLVM (پیشفرض ساختهای انتشار) و پشتوانهی بومی هم مستقیماً از قرارداد فراخوانی C در آن سکو پیروی میکنند. هیچ پل جداگانهای برای هر فراخوانی، از آن نوع که ctypes در پایتون یا JNI در جاوا میان دو طرف میگذارند، در کار نیست.
مهمترین قاعده اول میآید: کامپایلر به این اعلانها کاملاً اعتماد میکند. سرآیندهای C را نمیخواند. اگر صحیح اعلام کنید در جایی که C یک double میخواهد، چیزی هشدار نمیدهد و برنامه دادهی بیمعنی میخواند. هر امضا را از سرآیند کتابخانه یا صفحهی راهنمای آن رونویسی کنید.
نگاشت گونههای C به گونههای سلام
| C | سلام |
|---|---|
int، unsigned int | صحیح (صحیح۳۲)، طبیعی (طبیعی۳۲) |
long long، int64_t، uint8_t | صحیح۶۴، صحیح۶۴، طبیعی۸ |
size_t | اندازه مثبت |
ssize_t، intptr_t | اندازه |
double، float | اعشار۶۴، اعشار۳۲ |
const char* که متن نگه میدارد | رشته |
char* یا unsigned char* که بایت نگه میدارد | طبیعی۸* یا تهی* |
void*، T* | تهی*، T* |
| اشارهگر تابع | تهی*، یا درونداد تابع (...) R |
یک رشتهی سلام از قبل با یک بایت صفر تمام میشود، پس میتوان آن را مستقیم به تابعی از C داد که رشته میخواهد. جهت برعکس یک دام دارد: رشته در نخستین بایت صفرش تمام میشود، پس دادهی دودویی از C (یک تصویر، یک بلوک فشرده، یک بستهی شبکه) باید به شکل یک اشارهگر و یک طول بماند. متنی که C برمیگرداند مال کتابخانهی C است؛ آن را آزاد نکنید مگر مستندات کتابخانه این را بگوید. تابعی از C که ممکن است رشتهای برنگرداند، مثل getenv، یک اشارهگر تهی برمیگرداند که آن را با (مقدار برگردان تهی*) == پوچ بررسی میکنید.
بافرها و حافظهای که C در آن مینویسد
بسیاری از تابعهای C در حافظهای مینویسند که به آنها میدهید. آن را با بستهی mem (در فارسی حافظه) تخصیص دهید، اشارهگر را بفرستید و وقتی کارتان تمام شد آزادش کنید؛ دیرکن درست پس از تخصیص نمیگذارد این کار فراموش شود.
واردسازی حافظه
درونداد:
تابع snprintf(بافر: تهی*، ظرفیت: اندازه مثبت، قالب: رشته، ...): صحیح
تابع getenv(نام: رشته): رشته
پایان
تابع ریشه:
بافر := حافظه.تخصیص صفرشده(۳۲ برگردان طبیعی۶۴) برگردان طبیعی۸*
دیرکن حافظه.آزادسازی(بافر برگردان تهی*)
ن := snprintf(بافر برگردان تهی*، ۳۲ برگردان اندازه مثبت، "%s-%05d"، "INV"، ۴۲)
سرچاپ ن، بافر برگردان رشته
مقدار := getenv("SALAM_SURELY_NOT_SET")
اگر (مقدار برگردان تهی*) == پوچ:
سرچاپ "این متغیر محیطی تنظیم نشده است"
پایان
پایاناشارهگرها مثل آرایه اندیس میخورند: بافر[ای] یک عنصر را میخواند یا مینویسد. افزودن عدد به اشارهگر گونهدار خطای کامپایل است، پس به جای افزایش اشارهگر، اندیس میزنید؛ کد سطح پایینی که واقعاً به جابهجایی نشانی نیاز دارد از راه یک عدد صحیح تبدیل میکند، و این کار نیت را آشکار میکند. درس کار با C نشان میدهد چه بر سر بافری میآید که وسطش یک بایت صفر است، اشتباهی که بیش از همه پیش میآید.
ارسال ساختار با مقدار
APIهای C اغلب ساختارهای کوچک را با مقدار میگیرند یا برمیگردانند: یک رنگ، یک مستطیل، یک div_t. درست کردن این کار سختترین بخش بیشتر FFIهاست، چون قرارداد فراخوانی تعیین میکند ساختار در ثباتها منتقل شود یا در حافظه. سلام از ABI زبان C در سکوی مقصد پیروی میکند (System V و ویندوز روی x86-64، و AArch64)، پس ساختار سلامی با همان ترتیب و گونهی فیلدها با ساختار C جور درمیآید. div و lldiv در کتابخانهی استاندارد C یک ساختار برمیگردانند و نمونهی خودبسندهای میسازند:
درونداد:
تابع div(صورت: صحیح، مخرج: صحیح): حاصل
تابع lldiv(صورت: صحیح۶۴، مخرج: صحیح۶۴): حاصل۶۴
پایان
ساختار حاصل:
همگانی quot: صحیح = ۰
همگانی rem: صحیح = ۰
پایان
ساختار حاصل۶۴:
همگانی quot: صحیح۶۴ = ۰
همگانی rem: صحیح۶۴ = ۰
پایان
تابع ریشه:
ت := div(۱۷، ۵)
سرچاپ "17 / 5 ="، ت.quot، "باقیمانده"، ت.rem
منفی := div(۰ - ۱۷، ۵)
سرچاپ "-17 / 5 ="، منفی.quot، "باقیمانده"، منفی.rem
بزرگ := lldiv(۱۰۰۰۰۰۰۰۰۰۰۰۷ برگردان صحیح۶۴، ۱۰۰۰ برگردان صحیح۶۴)
سرچاپ "lldiv:"، بزرگ.quot، بزرگ.rem
پایاننام فیلدها همان نامهای div_t در C ماندهاند تا مقایسه با سرآیند آسان باشد؛ آنچه باید جور باشد ترتیب و گونهی فیلدهاست. بلوک درونداد: پیش از تعریف ساختارها آمده، چون سلام تعریفهای سطح بالا را به ترتیب ثابتی میخواهد؛ salam format آنها را برایتان جابهجا میکند. همین قاعده برای اشارهگرهای تابع با گونهی درونداد تابع (...)، تابعهای برونداد و کالبکها، روی همهی پشتوانهها برقرار است. پیوند gui.raylib در کتابخانهی استاندارد به همین تکیه دارد: ClearBackground(color: Color) یک ساختار Color مربوط به raylib را با مقدار میفرستد، دقیقاً همانطور که C میفرستد.
کالبک: سپردن تابعهای سلام به C
بعضی تابعهای C شما را صدا میزنند. qsort آرایهای از هر گونهای را مرتب میکند و برای مقایسهی دو عنصر از تابعی از شما کمک میگیرد. تابع سلامی که C باید صدایش بزند در یک بلوک برونداد: (export:) میآید: به یک تابع واقعی C با همان نام سادهاش و پیوند بیرونی تبدیل میشود، و &نام نشانی آن است.
واردسازی حافظه
درونداد:
تابع qsort(پایه: تهی*، شمار: اندازه مثبت، پهنا: اندازه مثبت، مقایسه: تهی*)
پایان
برونداد:
تابع by_value(الف: تهی*، ب: تهی*): صحیح:
ایکس := (الف برگردان صحیح۳۲*)[۰]
ایگرگ := (ب برگردان صحیح۳۲*)[۰]
اگر ایکس < ایگرگ:
برگشت -۱
وگرنه ایکس > ایگرگ:
برگشت ۱
پایان
برگشت ۰
پایان
پایان
تابع ریشه:
نمرهها := [۴۲، ۷، ۱۹، ۸۸، ۳]
ن := طول(نمرهها)
پ := حافظه.تخصیص((ن * ۴) برگردان طبیعی۶۴) برگردان صحیح۳۲*
دیرکن حافظه.آزادسازی(پ برگردان تهی*)
تکرار ن در ای:
پ[ای] = نمرهها[ای]
پایان
qsort(پ برگردان تهی*، ن برگردان اندازه مثبت، ۴ برگردان اندازه مثبت، &by_value)
ناپایا سطر := ""
تکرار ن در ای:
سطر += " " + پ[ای]
پایان
سرچاپ "مرتبشده با C:" + سطر
پایانتابعهای برونداد چند قاعده دارند، چون نمادهای همگانی معمولی C هستند: هر کدام بدنه لازم دارد، نه گونهی عام (generic) دارند و نه پارامتر ...، همگانی، درخط و نادرخط روی آنها مجاز نیست، و نام باید یک واژه باشد و بهتر است فقط با حرفهای لاتین نوشته شود؛ برای همین تابع بالا by_value نام دارد. جهت دیگر هم کار میکند: (&sqrt) برگردان درونداد تابع (اعشار۶۴) اعشار۶۴ نشانی یک تابع C را به مقداری گونهدار تبدیل میکند که میشود نگهش داشت و صدایش زد، و درونداد تابع (...) R میتواند گونهی یک پارامتر باشد. در ویندوز ۳۲ بیتی، تابعهای Win32 که از راه اشارهگر صدا زده میشوند درونداد فراخوان استاندارد تابع (...) R (در انگلیسی extern stdcall func) لازم دارند؛ در هر جای دیگر این واژه چیزی را عوض نمیکند.
پیوند کتابخانهها: ایستا، پویا و چارچوب
برای هر کتابخانهای غیر از کتابخانهی استاندارد C، با پیوند (link) که پیش از بلوک درونداد نوشته میشود به پیونددهنده بگویید کدام را به کار ببرد. نوع همیشه صریح است:
پیوند پویا "sqlite3"کتابخانهی اشتراکی را پیوند میدهد، مثل-lsqlite3؛پیوند ایستا "z"کتابخانهی ایستایlibz.aرا میخواهد که درون برنامه کپی میشود؛پیوند چارچوب "Cocoa"یک چارچوب (framework) مک را پیوند میدهد.
پیوند "m" بدون نوع خطایی است که این سه نوع را نام میبرد. سیستمهای مختلف کتابخانههای مختلفی لازم دارند، پس بلوکهای پیوند و درونداد میتوانند درون یک شرط زمان کامپایل مثل اگر سلام سیستم ویندوز: یا اگر سلام سیستم مک: بنشینند که روی سکوهای دیگر حذف میشود. این برنامه نسخهی کتابخانهی نصبشدهی SQLite را از خودش میپرسد؛ به کتابخانهی توسعهی SQLite روی دستگاه نیاز دارد، برای همین بدون خروجی آمده است:
پیوند پویا "sqlite3"
درونداد:
تابع sqlite3_libversion(): رشته
تابع sqlite3_libversion_number(): صحیح
پایان
تابع ریشه:
سرچاپ "SQLite"، sqlite3_libversion()، "(" + sqlite3_libversion_number() + ")"
پایانکتابخانهی استاندارد دقیقاً از همین سازوکار برای db.sqlite، gui.raylib، ai.opencv و موتور GTK و WebKit در gui.webview روی لینوکس استفاده میکند. بستهی شخص ثالثی که یک کتابخانهی C را میپیچد آن را در salam.toml زیر [native.<lib>] اعلام میکند، با نام بستههای سیستمی برای apt، dnf، pacman، brew و winget، یک بایگانی پیشساختهی اختیاری برای هر سکو و منبعهای C برای کامپایل بهعنوان آخرین راه؛ صفحهی بستهها این روند را توضیح میدهد.
بار کردن کتابخانه در زمان اجرا و ساختن کتابخانه برای C
وقتی کتابخانه هنگام اجرای برنامه انتخاب میشود، برای افزونهها یا قابلیتهای اختیاری، os.dl (در فارسی سیستم عامل.بارگذاری کتابخانه) توابع dlopen و dlsym را میپیچد: باز کردن (dl.Open) کتابخانهای را بار میکند، نماد (dl.Sym) تابعی را پیدا میکند و فراخوان (dl.Call) آن را با حداکثر هشت آرگومان صحیح یا اشارهگر صدا میزند. آرگومانهای اعشاری فراخوانی ترکیبی (CallMixed) لازم دارند، و تابعهایی که ساختار را با مقدار یا فهرست آرگومان متغیر میگیرند اینطور صدا زده نمیشوند. این بسته روی لینوکس، مک و BSDها کار میکند؛ روی ویندوز پشتیبانی می شود() (dl.Supported()) نادرست است.
جهت برعکس هم همانقدر مستقیم است. salam build --lib یک کتابخانهی اشتراکی (.so، .dylib یا یک .dll همراه کتابخانهی واردات) یا با --lib=static یک .a ایستا میسازد، به همراه سرآیند C که تابعهای بلوک برونداد: را فهرست میکند. برای پروندهی فارسی، پیام کامپایلر هم فارسی است:
$ salam build mathx.salam --lib
libmathx.so و mathx.h ساخته شدندبرای بلوک برونداد: که تابع mathx_gcd(a: صحیح۶۴، b: صحیح۶۴): صحیح۶۴ را نگه میدارد، سرآیند ساختهشده int64_t mathx_gcd(int64_t a, int64_t b); را درون یک محافظ extern "C" برای C++ اعلام میکند، و یک برنامهی معمولی C آن را صدا میزند:
#include <stdio.h>
#include "mathx.h"
int main(void) {
printf("gcd(84, 36) = %lld\n", (long long)mathx_gcd(84, 36));
return 0;
}یک نکتهی ویژهی کد فارسی: نام پارامترهای تابع برونداد هم در سرآیند مینشیند. اگر آنها را فارسی بنویسید، سرآیند همچنان معتبر است ولی نامها به شکل رمزگذاریشدهای مثل _d8_a8 درمیآیند؛ برای همین پارامترهای mathx_gcd هم لاتیناند. در پروندهی سلام ریشه لازم نیست؛ محیط اجرای سلام وقتی کتابخانه بار میشود راه میافتد. این یعنی سلام برای نوشتن یک افزونهی سریع برای برنامهای به C، C++، پایتون یا PHP به کار میآید، از راه هر FFI زبان C که زبان میزبان دارد.
FFI سلام در مقایسه با Rust، Zig، Go و پایتون
| زبان | چطور C را صدا میزند | چه چیزی مینویسید |
|---|---|---|
| سلام | اعلانهای درونداد:، فراخوانی مستقیم | امضا را با دست |
| Zig | @cImport سرآیندهای C را میخواند | برای بیشتر سرآیندها هیچ |
| Rust | بلوکهای extern "C" درون unsafe | امضاها، اغلب ساختهشده با bindgen |
| Go | cgo، با یک کامپایلر C در فرایند ساخت | توضیحی که کد C دارد؛ فراخوانیها گرانتر از فراخوانیهای Goاند |
| پایتون | ctypes یا cffi در زمان اجرا | امضاها در زمان اجرا، با تبدیل داده در هر فراخوانی |
سلام میان Rust و Zig مینشیند. مثل Rust، امضاها را خودتان اعلام میکنید؛ برخلاف Zig، کامپایلر نمیتواند سرآیند C را برایتان بخواند. برخلاف Rust، واژهی unsafe فراخوانیها را علامت نمیزند، پس کد FFI مثل هر کد دیگری به نظر میرسد و مسئولیت تماماً با شماست. در مقایسه با Go و پایتون، فراخوانیها ارزاناند: نه گذار میان دو محیط اجرا به سبک cgo هست و نه لایهی تبدیل در زمان اجرا، چون محیط اجرای جداگانهای وجود ندارد که بخواهید از آن بیرون بروید. با پشتوانهی پیشفرض، div(۱۷، ۵) در نمونهی ساختار بالا در C تولیدشده به شکل div(17, 5) ظاهر میشود.
قاعدههایی که کد FFI را امن نگه میدارند
- هر امضا را دقیق رونویسی کنید. کامپایلر نمیتواند بررسیاش کند و امضای اشتباه بیصدا شکست میخورد.
- متن
رشتهاست؛ بایتها یک اشارهگر و یک طولاند.رشتهدر نخستین بایت صفرش تمام میشود. - آنچه با
حافظهتخصیص میدهید آزاد کنید، و هیچ چیزی را که مال C است آزاد نکنید، مگر مستندات کتابخانه بگوید. - اشارهگرهایی را که C ممکن است تهی برگرداند پیش از استفاده بررسی کنید.
- حواستان به نخها باشد. کتابخانهی C که ایمنِ نخ نیست، وقتی سلام صدایش میزند هم همانطور میماند؛ اگر چند نخ از آن استفاده میکنند با یک قفل از sync از آن محافظت کنید. مقالهی همزمانی در سلام ابزارهایش را پوشش میدهد.
- از پشتوانههای کامپایلی استفاده کنید.
salam exec، یعنی مفسر، از همهی قابلیتهای FFI پشتیبانی نمیکند؛ برای کدی که C را صدا میزندsalam runیاsalam buildرا به کار ببرید. - وقتی کتابخانهی استاندارد کار را پوشش میدهد، آن را ترجیح دهید. فراخوانیهای C را برایتان انجام میدهد و روی همهی سیستمها یکسان کار میکند. برای نمونه TLS، PostgreSQL و MySQL در سلام اصلاً به کتابخانهی C نیاز ندارند.
پرسشهای پرتکرار
آیا زبان سلام میتواند پروندههای سرآیند C را مستقیم بخواند؟
نه. سلام سرآیندهای C را تجزیه نمیکند؛ تابعهایی را که به کار میبرید در یک بلوک درونداد: اعلام میکنید. برای یک API بزرگ این کار واقعی است، و برای همین کتابخانهی استاندارد پیوندهای آماده برای کتابخانههای رایجی مثل SQLite و raylib دارد.
آیا صدا زدن C از سلام کند است؟
کندتر از صدا زدن C از خود C نیست. با پشتوانهی C، فراخوانی درونداد در کد تولیدشده یک فراخوانی سادهی C است، و پشتوانههای LLVM و بومی مستقیم از قرارداد فراخوانی C در آن سکو استفاده میکنند. هیچ لایهی تبدیل دادهای در میان نیست.
آیا C یا پایتون میتوانند کد سلام را صدا بزنند؟
بله. تابعها را در یک بلوک برونداد: بگذارید و با salam build --lib بسازید. یک کتابخانهی اشتراکی یا ایستا و یک سرآیند C به دست میآورید که هر زبانی با FFI زبان C میتواند از آن استفاده کند.
آیا سلام میتواند کتابخانههای C++ را صدا بزند؟
فقط از راه یک میانجی C. مثل بیشتر زبانها، سلام نمیتواند کلاسها و قالبهای C++ را مستقیم صدا بزند؛ کتابخانههایی که یک API از نوع extern "C" عرضه میکنند، یا یک پوشش نازک C که خودتان مینویسید، بهخوبی کار میکنند. پیوند OpenCV خود سلام راه دوم را با یک کتابخانهی واسط کوچک رفته است.