پشتوانهی کامپایلر (Compiler Backend) بخشی از کامپایلر است که برنامهی بررسیشده را به چیزی تبدیل میکند که ماشین بتواند اجرا کند. زبان برنامهنویسی سلام برای ساختن فایلهای اجرایی بومی سه پشتوانه دارد: پشتوانهی C که کد C مینویسد و به یک کامپایلر C میسپارد، پشتوانهی LLVM که LLVM IR مینویسد و بهینهسازی را به LLVM واگذار میکند، و پشتوانهی بومی که خودش کد ماشین x86-64، ARM و RISC-V مینویسد و آن را با پیونددهندهای که درون کامپایلر ساخته شده پیوند میدهد. هر سه پشت یک پیشانهی (frontend) مشترک قرار دارند، پس معنای برنامه هر کدام را که انتخاب کنید یکی است. این مقاله نشان میدهد هر کدام چه تولید میکند، چرا وجود دارد و کی باید از کدام استفاده کرد.
پشتوانهی کامپایلر چه کاری میکند
پیشانهی کامپایلر (تحلیلگر واژگانی، تجزیهگر، تحلیل معنایی) به این پرسش پاسخ میدهد که «آیا این برنامه معتبر است و چه معنایی دارد؟». پشتوانه پاسخ میدهد که «پردازنده باید چه دستورهایی را اجرا کند؟». پرسش دوم سه بخش دشوار دارد: پایین آوردن ساختارهای سطح بالا (یک همخوان روی جداشمار، الحاق رشتهها، فراخوانی جنریک) به عملیات ساده، بهینهسازی آن عملیات، و نوشتن آنها در قالبی که سیستمعامل بتواند بار کند، که به معنای پیروی از قراردادهای فراخوانی و قالب فایل شیء هر سکو هم هست.
هر پروژهی زبانی باید تصمیم بگیرد چه مقدار از این کار را خودش بسازد. برخی با تولید C یک کامپایلر موجود را بهطور کامل دوباره به کار میگیرند. بسیاری LLVM را به کار میگیرند. معدودی همه چیز را خودشان مینویسند. سلام هر سه کار را میکند، و مقالهی مراحل کامپایلر نشان میدهد این سه کجای زنجیره وصل میشوند. این برنامهای است که از هر سه عبور میدهیم:
تابع جمع مربعها(ن: صحیح): صحیح:
ناپایا مجموع := ۰
تکرار ۱ تا ن در ای:
مجموع += ای * ای
پایان
برگشت مجموع
پایان
تابع ریشه:
سرچاپ جمع مربعها(۱۰)
پایانکدام پشتوانه بهطور پیشفرض اجرا میشود
انتخاب با --backend=c، --backend=llvm یا --backend=native است. بدون این گزینه، راننده (use_llvm_backend در compiler/driver/driver_proc.salam) خودش انتخاب میکند:
| وضعیت | پشتوانهی پیشفرض |
|---|---|
فایل اجرایی salam درون خود LLVM دارد (نسخههای اصلی منتشرشده برای لینوکس، مک و ویندوز) | LLVM |
فایل اجرایی salam بدون LLVM است (کامپایلری که بدون آن از منبع ساخته شده، یا نسخهی ایستای musl) | C |
| ویندوز روی Arm، که نه LLVM همراهش منتشر میشود و نه tcc | بومی |
گزینهی --cc=PATH داده شده | C، چون نام بردن از یک کامپایلر C معنای دیگری نمیتواند داشته باشد |
اگر پشتوانهی LLVM به ساختاری برسد که نمیتواند پایینش بیاورد، ساخت شکست نمیخورد: راننده پیام salam: falling back to the C backend for this file (build with --backend=llvm to make this an error) را چاپ میکند و ساخت را از راه C ادامه میدهد. دادن صریح --backend=llvm این تور ایمنی را به یک خطای قطعی تبدیل میکند، که در CI دقیقاً همان چیزی است که میخواهید.
پشتوانهی C: زبان C بهعنوان مقصد کامپایل
استفاده از C بهعنوان مقصد کامپایل یعنی کامپایلر کد منبع C مینویسد و تولید کد ماشین را به یک کامپایلر C موجود میسپارد. این یکی از قدیمیترین ترفندهای پیادهسازی زبان است و هنوز هم ترفند خوبی است: کامپایلر C تقریباً برای هر سکویی وجود دارد، خوب بهینه میکند و کد تولیدشده را انسان هم میتواند بخواند.
پشتوانهی C سلام در compiler/codegen/ است. هر فایل منبع و هر بخش از کتابخانهی استاندارد که به کار رفته، به یک جفت فایل .c و .h در پوشهی .salam-build/ تبدیل میشود. با --emit-c میتوانید آنها را نگه دارید و بخوانید:
static int32_t _Salam_main__d9_85_d8_b1_d8_a8_d8_b9_i32(int32_t _d9_86) {
(void)_d9_86;
return ((int32_t)((uint32_t)(_d9_86) * (uint32_t)(_d9_86)));
}این تابع یکخطی مربع(ن) پس از تولید کد است. نام آن با بسته و گونهی پارامترها دگرگونسازی شده و نام فارسی بایت به بایت (UTF-8) در نام C نوشته شده است. ضرب با حساب بیعلامت انجام میشود تا سرریز روی هر کامپایلر C به یک شکل بچرخد، به جای آنکه مثل سرریز علامتدار در C رفتار تعریفنشده باشد.
سپس راننده یک کامپایلر C پیدا میکند: اگر بستهی دانلودی کامپایلری کنار salam داشته باشد همان (نسخهی ویندوز tcc را همراه دارد)، وگرنه clang، gcc یا tcc از PATH. بخشها را بهصورت موازی، با یک کارگر برای هر هستهی پردازنده، کامپایل میکند، فایلهای شیء را نهانسازی میکند تا ساخت بعدی فقط آنچه تغییر کرده را دوباره کامپایل کند، و پیوند میدهد. پیش از هر انتشار، CI چهار گروه از برنامههای آزمون را با tcc، gcc و clang در حالت سختگیرانه میسازد و اگر کد C تولیدشده زیر هر کدام از آنها تمیز نباشد شکست میخورد.
پشتوانهی C به چه کاری میآید:
- قابلیت حمل و شفافیت. اگر سکویی کامپایلر C داشته باشد، کد احتمالاً آنجا اجرا میشود و میتوانید دقیقاً ببینید چه چیزی تولید شده است.
- ابزارهای C خودتان.
--cc=یک کامپایلر C مشخص را انتخاب میکند و--asan(AddressSanitizer) وقتی آن کامپایلر gcc یا clang باشد کار میکند. - قابلیتهایی که فقط همینجا هستند.
--protect، که لیترالهای رشتهای را در فایل اجرایی رمز میکند، در پشتوانهی C پیادهسازی شده است.
هزینهاش: به یک کامپایلر C روی دستگاه نیاز دارید و کیفیت خروجی به آن بستگی دارد. tcc بسیار سریع کامپایل میکند اما بهینهساز ندارد؛ gcc و clang بهینه میکنند اما زمان بیشتری میگیرند.
پشتوانهی LLVM: بهینهسازی و کامپایل متقابل
پشتوانهی LLVM برنامه را به بازنمایی میانی (IR) پروژهی LLVM ترجمه میکند و میگذارد LLVM آن را بهینه کند و برای پردازندههای گوناگون کد ماشین بسازد. Rust، Swift، Zig و Julia همه به همین شکل از LLVM استفاده میکنند. پشتوانهی LLVM سلام در compiler/llvmgen/ است و خروجیاش را با salam llvm میخوانید:
salam llvm sum.salam -o sum.llاین آغاز تابع جمع مربعها پیش از بهینهسازی است (salam llvm بهطور پیشفرض -O0 است، چون این فرمان برای خواندن ساخته شده):
define internal i32 @salam__d8_ac_d9_85_d8_b9_20_d9_85_d8_b1_d8_a8_d8_b9_20_d9_87_d8_a7_i32(i32 %arg0) nounwind {
entry:
%p._d9_86 = alloca i32
%v._d9_85_d8_ac_d9_85_d9_88_d8_b9.0 = alloca i32
...
L1_rbody:
%t13 = trunc i64 %t9 to i32
store i32 %t13, ptr %v._d8_a7_db_8c.8
%t14 = load i32, ptr %v._d9_85_d8_ac_d9_85_d9_88_d8_b9.0
%t15 = load i32, ptr %v._d8_a7_db_8c.8
%t16 = load i32, ptr %v._d8_a7_db_8c.8
%t17 = mul i32 %t15, %t16
%t18 = add i32 %t14, %t17
store i32 %t18, ptr %v._d9_85_d8_ac_d9_85_d9_88_d8_b9.0نام چندواژهای جمع مربعها به بایتهای UTF-8 خود تبدیل شده و فاصلهها به _20؛ نیمفاصلهی میان «مربع» و «ها» هم _20 شده، چون در نامهای سلام فاصله و نیمفاصله یکیاند. نسخهی انگلیسی همین تابع (sum squares) نام salam_sum_20squares_i32 میگیرد. هر متغیر محلی یک خانهی پشته است که بهینهساز LLVM بعدها آن را به ثبات (Register) ارتقا میدهد. یک salam build واقعی با این پشتوانه با -O2 بهینه میکند، یا با --release با -O3.
برخلاف مسیر C، کل برنامه در یک ماژول LLVM قرار میگیرد، پس بهینهساز از مرز بستهها عبور میکند و هر چه برنامه هرگز به کار نمیبرد را کنار میگذارد. در نسخههای اصلی منتشرشده، LLVM و پیونددهندهاش lld همراه با سیسروتهای (Sysroot) مقصدهای متقابل درون خود فایل اجرایی salam پیوند خوردهاند، پس پشتوانهی LLVM به هیچ ابزار بیرونی نیاز ندارد. نسخهای از salam که LLVM را درون خود ندارد همچنان میتواند با فراخوانی clang از PATH از این پشتوانه استفاده کند.
LLVM همان چیزی است که دامنهی زبان را گسترده میکند. --target برای سیستمعاملها و پردازندههای دیگر کامپایل متقابل انجام میدهد (مقالهی کامپایل متقابل سلام سهتاییهای مقصد را مرور میکند)، WebAssembly از همین راه میگذرد، و ساختهای مستقل از سیستمعامل (Freestanding) به آن نیاز دارند: اگر یکی را با --backend=c بخواهید، کامپایلر میگوید freestanding targets need the LLVM backend.
هزینهاش حجم و زمان ساخت است. salamی که LLVM، lld و سیسروتها را در خود دارد فایل اجرایی بزرگی است، و بهینهساز LLVM روی برنامههای بزرگ زمان محسوسی میگیرد.
پشتوانهی بومی: کد ماشین بدون LLVM و بدون کامپایلر C
پشتوانهی بومی (--backend=native، کد منبع در compiler/nativegen/) تولیدکنندهی کد خود سلام است که به زبان سلام نوشته شده. این بزرگترین بخش مستقل کامپایلر است، حدود ۵۲٬۶۰۰ خط، و مقصدهایش اینهاست: x86-64 روی لینوکس، ویندوز و مک؛ x86 سیودوبیتی روی لینوکس و ویندوز؛ AArch64 روی لینوکس، مک و ویندوز؛ ARM سیودوبیتی روی لینوکس؛ و RISC-V 64 روی لینوکس. هر --target دیگری با پیامی رد میشود که به --backend=llvm اشاره میکند.
این پشتوانه یک انتخاب هوشمندانه دارد. به جای آنکه درخت نحو را برای بار سوم پایین بیاورد، متن LLVM IR را که llvmgen از قبل تولید کرده میخواند، آن را به شکل فشردهی SSA خودش تجزیه میکند و از آنجا با گذرهای خودش ادامه میدهد. پس هر قابلیت زبان که پشتوانهی LLVM پشتیبانی میکند اینجا هم کار میکند، و این دو پشتوانه نمیتوانند در معنا از هم فاصله بگیرند. فایل docs/NATIVE_BACKEND.md زنجیره را فهرست کرده است: SROA و mem2reg، درونخطیسازی (از جمله درونخطیسازی جزئی مسیرهای سرد)، حذف بازگشت دنبالهای، تا کردن ثابتها و رشتهکشی پرش (Jump Threading)، باز کردن حلقه، بیرون بردن کد ناوردای حلقه، کاهش قدرت، شمارهگذاری سراسری مقدارها، برداریسازی حلقه و SLP روی x86-64 و AArch64، انتخاب دستور، تخصیص ثبات با پویش خطی و شکستن بازهی زنده بودن، و در پایان رمزگذارهای هر مجموعهدستور و نویسندههای فایل شیء ELF، COFF و Mach-O.
نتیجه را با --emit-asm میبینید که کد ماشین را به شکل اسمبلی کنار فایل اجرایی مینویسد. برای برنامهی ما، سطر جالب در main است:
mov ecx, 385پشتوانهی بومی جمع مربعها را درونخطی کرده، کل حلقهی دهمرحلهای را در زمان کامپایل حساب کرده و فقط ثابت 385 و کدی را نگه داشته که آن را برای چاپ به رقمهای دهدهی تبدیل میکند. هیچ فراخوانیای به جمع مربعها و هیچ اثری از حلقهاش باقی نمانده است.
گزینهی --cpu سطح مجموعهدستور را روی x86-64 انتخاب میکند: پایهی پیشفرض، x86-64-v2 (کد برداری SSE4.1) یا x86-64-v3 و x86-64-v4 که اجازه میدهند حلقهها با ثباتهای ۲۵۶ بیتی AVX2 برداری شوند اما پردازندهای میخواهند که آنها را داشته باشد. انتخاب در زمان اجرا انجام نمیشود، پس یک ساخت یا همه جا اجرا میشود یا همه جا AVX2 لازم دارد.
منصفانه بگوییم، اندازهگیریهای خود پروژه در docs/NATIVE_BACKEND.md پشتوانهی بومی را روی برنامههای محک نزدیک به LLVM نشان میدهد، در برخی جلوتر و در برخی عقبتر، و روی کدهایی که بار اصلیشان روی کتابخانه است (مثلاً رمزگشاهای تصویر) عقبتر. هنوز کد AVX-512 یا FMA ندارد و ساختهای armhf و RISC-V برداری نمیشوند.
پیونددهندهی داخلی
تولید کد ماشین کافی نیست؛ چیزی باید فایلهای شیء را با زمان اجرای C ترکیب کند و فایل اجرایی بسازد. پشتوانهی بومی معمولاً این کار را هم انجام میدهد، با پیونددهندهای (Linker) که به سلام نوشته شده (compiler/nativelink/، طراحی در docs/NATIVE_LINKER.md). با --linker=auto که پیشفرض است، هر وقت همهی ورودیها پیدا شوند از آن استفاده میشود و این خروجیها را میسازد:
- فایلهای اجرایی کاملاً ایستا با musl روی لینوکس؛
- فایلهای اجرایی مستقل از مکان (PIE) با glibc، برای برنامههای معمولی لینوکس که پویا پیوند میخورند؛
- فایلهای اجرایی PE و DLL برای ویندوز، با نماد، مانیفست و منابع نسخه؛
- فایلهای اجرایی Mach-O برای مک روی x86-64 و arm64، از جمله فایلهای دوگانهی Universal.
--linker=system این کار را به cc یا gcc محیط MinGW میسپارد. --icf تا کردن کدهای یکسان را روشن میکند که تابعهایی را که کد ماشینشان یکسان از آب درآمده ادغام میکند؛ روی خود کامپایلر، جفتهایی مانند Vector<i64>.push و Vector<str>.push را یکی میکند.
اثرش روی اندازهی فایل، برای یک برنامهی «سلام دنیا» که روی لینوکس x86-64 با کامپایلری از شاخهی اصلی در نوامبر ۲۰۲۶ ساخته شده:
| ساخت | پیوند | اندازه |
|---|---|---|
--backend=native | پیونددهندهی داخلی، glibc PIE | ۱۱٬۴۸۸ بایت |
--backend=native --target=x86_64-linux-musl | پیونددهندهی داخلی، کاملاً ایستا | ۲۴٬۹۴۴ بایت |
--backend=llvm | lld، glibc | ۱۷٬۵۳۶ بایت |
--backend=c با clang | پیونددهندهی سیستم، glibc | ۳۶۳٬۳۸۴ بایت |
ساخت C بزرگتر است چون بخشهای پیشکامپایلشدهی کتابخانهی استاندارد را، همراه با دادههایشان، بهصورت فایلهای شیء جداگانه و کامل پیوند میدهد، در حالی که مسیرهای LLVM و بومی یک برنامه را کامپایل میکنند و هر چه هرگز ارجاع داده نشده را کنار میگذارند. گزارش ساخت بومی خود پیوند را هم گزارش میکند: built-in link: 5 objects, 15 live sections, 28 symbols.
مقایسهی پشتوانههای C، LLVM و بومی در زبان برنامهنویسی سلام
| پشتوانهی C | پشتوانهی LLVM | پشتوانهی بومی | |
|---|---|---|---|
| ابزار بیرونی لازم | یک کامپایلر C | در نسخههای منتشرشده هیچ | هیچ، جز فایلهای زمان اجرای C |
| بهینهساز | بهینهساز کامپایلر C (tcc: ندارد) | LLVM با -O2/-O3 | بهینهساز خود سلام |
| مقصدها | هر جا کامپایلر C اجرا شود | سهتاییهای --target که سلام پشتیبانی میکند، بهعلاوهی WebAssembly و Freestanding | x86-64، i686، AArch64، armhf و RISC-V 64 روی لینوکس؛ x86-64، i686 و AArch64 روی ویندوز؛ x86-64 و arm64 روی مک |
| خواندن خروجی | --emit-c | salam llvm | --emit-asm |
| تواناییهای ویژه | --asan، memcheck، --protect، انتخاب کامپایلر C | کامپایل متقابل، wasm، بدون سیستمعامل | سطحهای --cpu، پیونددهندهی داخلی، --icf |
| هزینهی اصلی | وابستگی به مجموعهابزار بیرونی | فایل اجرایی بزرگ کامپایلر | تازهترین پشتوانه، در کد کتابخانهای هنوز عقبتر از LLVM |
در صفحهی سرعت پروژه، سلام با پشتوانهی LLVM و -O3 سنجیده شد: میانگین ۱۰٫۶ میلیثانیه روی ۳۹ برنامه، در برابر ۱۱٫۷ میلیثانیه برای C و ۱۱٫۳ میلیثانیه برای Rust؛ یعنی یک رده، نه یک رتبهبندی. همان صفحه یادآوری میکند که یک ساخت ساده با پشتوانهی C، tcc و بدون بهینهساز روی همان برنامهها میانگین ۱۱٫۰ میلیثانیه داشت، چون بیشتر آنها در چند میلیثانیه تمام میشوند و راهاندازی فرایند سهم بزرگی از هر زمان است. انتخاب پشتوانه بیشترین اثر را روی کدهای طولانی و پرمحاسبه دارد و روی ابزارهای کوتاه خط فرمان اثر بسیار کمتری.
از کدام پشتوانهی زبان برنامهنویسی سلام استفاده کنیم؟
- با پیشفرض شروع کنید. در نسخهای که از صفحهی نصب میگیرید این پیشفرض LLVM است که کد بهینه و کامپایل متقابل را بدون نصب هیچ چیز دیگری میدهد.
- از
--backend=cاستفاده کنید وقتی میخواهید C تولیدشده را بخوانید یا اشکالزدایی کنید، به AddressSanitizer (--asan،salam memcheck) نیاز دارید، میخواهید کامپایلر C خاصی را به کار ببرید، یا روی سکویی هستید که فقط کامپایلر C دارد. - از
--backend=nativeاستفاده کنید وقتی فایلهای اجرایی کوچک، فایلهای اجرایی ایستای لینوکس، فایلهای اجرایی ویندوز یا مک که با پیونددهندهی خود سلام پیوند خوردهاند، کد AVX2 از راه--cpuیا ساخت بدون LLVM میخواهید. روی ویندوزِ Arm هم تنها گزینه همین است. - در CI صریحاً
--backend=llvmبدهید اگر میخواهید ساختارهای پشتیبانینشده ساخت را شکست دهند، نه آنکه بیصدا به C برگردند.
هر کدام را انتخاب کنید، بررسیها یکساناند، چون همه پیش از تولید کد اجرا میشوند. مقالهی گونهبندی ایستا نشان میدهد این بررسیها چه چیزهایی را میگیرند، و مقالهی کامپایل بومی توضیح میدهد اجرا بدون ماشین مجازی برای برنامهی شما چه معنایی دارد.
پرسشهای پرتکرار
آیا زبان سلام به C ترجمه میشود؟
میتواند. پشتوانهی C کد C مینویسد و یک کامپایلر C را فراخوانی میکند، و وقتی فایل اجرایی salam بدون LLVM ساخته شده باشد پیشفرض همین است. نسخههای منتشرشده بهطور پیشفرض از پشتوانهی LLVM استفاده میکنند و پشتوانهی بومی مستقیم کد ماشین مینویسد.
چرا به جای یک پشتوانه سه پشتوانه؟
هر کدام مسئلهی متفاوتی را حل میکند. C تقریباً هر جا که کامپایلر C باشد اجرا میشود و وارسیاش آسان است، LLVM یک بهینهساز پخته و مقصدهای فراوان میآورد، و پشتوانهی بومی مجموعهابزارهای بیرونی را حذف میکند و کنترل کامل تولید کد را به پروژه میدهد. این سه یکدیگر را هم میآزمایند: همان آزمونها روی هر سه اجرا میشوند.
آیا پشتوانهی بومی سلام برای محیط عملیاتی آماده است؟
تازهترین پشتوانهی این سه است و در اکتبر ۲۰۲۶ ادغام شد. روی مقصدهای پشتیبانیشدهاش، جز چند استثنای مستند، از آزمونهای سراسری پروژه میگذرد و خود کامپایلر را میسازد، اما از LLVM جوانتر است و روی برخی کدهای کتابخانهای هنوز عقبتر. برای ساخت عملیاتی امروز، LLVM انتخاب محتاطانه است.
چطور کد اسمبلیای را که سلام تولید میکند ببینم؟
salam build app.salam --backend=native --emit-asm فایل app.s را با نحو اسمبلر GNU مینویسد، و salam llvm app.salam --emit-asm اسمبلی LLVM را برای همان برنامه میدهد. این دو فرمان روی برنامهی فارسی هم به همین شکل کار میکنند.