برنامهنویسی bare metal یعنی نوشتن کدی که مستقیم روی سختافزار اجرا میشود و هیچ سیستمعاملی زیرش نیست که پرونده، فرایند یا حافظه فراهم کند. ساخت خودایستا (Freestanding) در زبان برنامهنویسی سلام دقیقاً چنین برنامهای تولید میکند: پروندهای اجرایی که هیچ سیستمعاملی را صدا نمیزند، با libc و پروندههای آغازین C پیوند نمیخورد و هیچ فراخوان سیستمی (System Call) انجام نمیدهد. کامپایلر این برنامه را برای x86-64، i686 و AArch64 از راه پشتیبان LLVM خود تولید میکند و آن را با یک محیط اجرای کوچک پیوند میدهد که همان چند تابعی را فراهم میکند که کد تولیدشده لازم دارد. مخزن سلام یک کرنل Multiboot کامل دارد که به سلام نوشته شده و در QEMU بوت میشود.
این مقاله نشان میدهد هدف خودایستا چطور کار میکند، یک کرنل را میسازد و بوت میکند، توضیح میدهد محیط اجرا چه چیزی فراهم میکند و روشن فهرست میکند که نویسندهی کرنل هنوز چه چیزهایی را باید خودش بنویسد.
خودایستا یعنی چه؟
استاندارد C از دیرباز پیادهسازی میزبانیشده (hosted)، که در آن کل کتابخانهی استاندارد و یک سیستمعامل در دسترس است، را از پیادهسازی خودایستا (freestanding)، که تقریباً هیچ چیز در آن نیست، جدا کرده است. برنامهنویسان C گزینهی -ffreestanding را میدهند، برنامهنویسان Rust #![no_std] را مینویسند و Zig در هدف خود سیستمعامل freestanding را انتخاب میکند. سلام این حالت را با سهتایی هدف (target triple) انتخاب میکند:
| هدف | خروجی | توضیح |
|---|---|---|
x86_64-unknown-none | ELF64 | برای بوتلودر ۶۴ بیتی مثل GRUB یا Limine |
i686-unknown-none | ELF32 | Multiboot، گزینهی -kernel در QEMU، رایانههای ۳۲ بیتی |
aarch64-unknown-none | ELF64 | درگاه سریال PL011 در 0x09000000، و wfi برای توقف |
x86_64-unknown-salamos | ELF64 | هدفی به سبک فضای کاربر سیستمعامل سلام |
x86_64-unknown-salamos-kernel | ELF64 | ثابت SALAM_KERNEL را تعریف میکند |
هر سهتایی *-none یا *-salamos خودایستاست. سهتایی *-uefi هم خودایستاست، اما قالب شیء UEFI را نگه میدارد. کامپایلر برنامه را به LLVM IR تبدیل میکند، آن را برای هدف به یک پروندهی شیء کامپایل میکند و با ld.lld پیوند میدهد، بدون libc، بدون پروندههای crt و بدون -lm. اگر تا حالا با پشتیبان LLVM کار نکردهاید، مقالهی پشتیبانهای C، LLVM و بومی سلام توضیح میدهد این پشتیبان کجای کار است.
فرمان ساخت
ساخت خودایستا، افزون بر --target، از چهار گزینه استفاده میکند:
salam build kernel.salam \
--target=i686-unknown-none \
--entry=_start \
--linker-script=linker.ld \
--link-object=boot.o \
--output=kernel.elf--entryنام نمادی است که پیونددهنده (Linker) اجرا را از آن شروع میکند و پیشفرضش_startاست. تابع ورودی سلام (ریشهدر سلام فارسی وmainدر انگلیسی) با همان نام معمولش و بدون پردازشargc/argvتولید میشود، پس چند خط اسمبلی کافی است تا پشته را آماده کند و آن را صدا بزند.--linker-scriptگزینهی-T scriptرا بهld.lldمیدهد؛ کرنل از همین راه تعیین میکند هر بخش کجای حافظه برود.--link-objectیک پروندهی شیء اضافه میکند، مثل قطعهی بوت، بردارهای وقفه یا بستهی پشتیبانی برد. هر چند بار لازم باشد میتوان تکرارش کرد.--no-freestanding-runtimeمحیط اجرای درونی را کنار میگذارد، برای کرنلی که همهی نمادها را خودش فراهم میکند.
کرنلی به زبان سلام که در QEMU بوت میشود
پوشهی tests/kernel/ یک کرنل Multiboot v1 برای i686 دارد. نقطهی ورود اسمبلی در boot.s یک پشتهی ۳۲ کیلوبایتی میسازد، SSE را روشن میکند و تابع ورودی سلام را صدا میزند. بقیهی کار سلام معمولی است. نسخهی مخزن به انگلیسی است؛ این همان کرنل به فارسی است:
واردسازی کرنل
تابع ریشه:
کرنل.پاک کردن()
سرچاپ "هستهی سلام"
سرچاپ "بدون هیچ سیستمعاملی زیر پا"
سرچاپ ""
ناپایا تیک := ۰
تا تیک < ۵:
چاپ "تیک "
سرچاپ تیک
تیک++
پایان
کرنل.نوشتن جایگاه(۱۰، ۰، "this line went straight to VGA memory"، 0x0A برگردان طبیعی۸)
سرچاپ ""
سرچاپ "حافظهی مصرفی: "، کرنل.حافظه مصرفی()، " بایت"
سرچاپ "اندازهی هیپ: "، کرنل.اندازه حافظه()، " بایت"
سرچاپ "توقف QEMU"
کرنل.خروج شبیه ساز(۰)
پایانتا در سلام حلقه را تا وقتی شرطش برقرار است تکرار میکند، پس این حلقه پنج تیک چاپ میکند. سرچاپ همچنان کار میکند، چون محیط اجرا خروجی را به درگاه سریال، بافر متنی VGA و کنسول اشکالزدایی QEMU میفرستد. اسکریپت ساخت مخزن، tests/kernel/build.sh، کرنل را با این دو فرمان میسازد و بوت میکند، بیآنکه صفحهای نمایش دهد و با درگاه سریال روی خروجی استاندارد:
SALAM=/path/to/salam sh tests/kernel/build.sh
qemu-system-i386 -kernel tests/kernel/kernel.elf -serial stdio -display none \
-device isa-debug-exit,iobase=0xf4,iosize=0x04ما نسخهی فارسی بالا را با همان قطعهی بوت و اسکریپت پیوند ساختیم. نتیجه یک پروندهی اجرایی ELF32 به حجم ۲۵٬۰۰۸ بایت بود و این خروجی را روی درگاه سریال چاپ کرد:
هستهی سلام
بدون هیچ سیستمعاملی زیر پا
تیک 0
تیک 1
تیک 2
تیک 3
تیک 4
حافظهی مصرفی: 80 بایت
اندازهی هیپ: 16777216 بایت
توقف QEMUسپس QEMU از راه دستگاه isa-debug-exit خارج میشود. کد خروج فرایند (code << 1) | 1 است، پس کد ۰ کرنل به شکل وضعیت خروج ۱ دیده میشود. اگر به جای -display none از -display gtk استفاده کنید، خط سبزی را هم میبینید که مستقیم در حافظهی VGA در سطر ۱۰ نوشته شده است. متن آن خط را عمداً انگلیسی گذاشتهایم، به دلیلی که در بخش محیط اجرا میآید.
بارگذار -kernel در QEMU فقط ELF ۳۲ بیتی را میپذیرد و برای همین این نمونه i686 را هدف گرفته است. هدف x86_64-unknown-none یک ELF ۶۴ بیتی معمولی میسازد که GRUB، Limine یا بارگذار خودتان میتواند اجرایش کند.
یک برنامه، هم روی سیستمعامل و هم روی bare metal
یکی از ویژگیهای مفید هدف خودایستا این است که بیشتر کد شما اصلاً به آن اهمیتی نمیدهد. کامپایلر ثابتهای سلام خودایستا (SALAM_FREESTANDING، یعنی سیستمعامل میزبانی در کار نیست)، سلام هسته (SALAM_KERNEL، یعنی هدف کرنل است) و سلام سیستم هیچ یا سلام سیستم سلام (SALAM_OS_NONE و SALAM_OS_SALAMOS) را تعریف میکند و یک اگر در سطح بالای پرونده کد مخصوص میزبان یا مخصوص کرنل را جدا نگه میدارد. درس زمان کامپایل این سازوکار را توضیح میدهد. برنامهی زیر چکسام Fletcher-16 را حساب میکند و بستهی کرنل را فقط وقتی وارد میکند که سیستمعاملی در کار نباشد:
اگر سلام خودایستا:
واردسازی کرنل
پایان
تابع فلچر۱۶(داده: رشته): صحیح:
ناپایا الف := ۰
ناپایا ب := ۰
هر ن در داده.بایت ها():
الف = (الف + (ن برگردان صحیح)) % ۲۵۵
ب = (ب + الف) % ۲۵۵
پایان
برگشت ب * ۲۵۶ + الف
پایان
تابع ریشه:
هر واژه در ["abcde"، "abcdef"، "سلام"]:
سرچاپ واژه، فلچر۱۶(واژه)
پایان
اگر سلام خودایستا:
کرنل.خروج شبیه ساز(۰)
پایان
پایاناین خروجی از یک salam run معمولی روی لینوکس است. همین پرونده با قطعهی بوت و اسکریپت پیوند کرنل برای i686-unknown-none ساخته شد و کرنلی ۲۲٬۸۹۶ بایتی شد، و QEMU همین سه خط را روی درگاه سریال چاپ کرد؛ واژهی فارسی هم درست رسید، چون درگاه سریال بایتهای UTF-8 را دستنخورده میفرستد. دو مقدار نخست همان بردارهای آزمون شناختهشدهی Fletcher-16 هستند (0xC8F0 و 0x2057). اینکه بتوانید یک تجزیهگر، یک تخصیصدهندهی حافظه یا یک چکسام را روی رایانهی خودتان با salam test بیازمایید و سپس عین همان کد را در کرنل پیوند بزنید، یکی از دلیلهای عملیتر برای نوشتن کد کرنل با یک زبان سطحبالاتر است.
همین منبع برای x86_64-unknown-none و aarch64-unknown-none هم به پروندهی شیء کامپایل میشود. در نسخهی انگلیسی همین برنامه، پروندهی شیء x86-64 دقیقاً شش نماد را برای محیط اجرا باقی گذاشت: abort، malloc، memcpy، strlen، write و salam_rt_exit_qemu.
محیط اجرای درونی چه چیزی فراهم میکند
پروندهی std/baremetal/runtime.c برای هدف انتخابشده کامپایل و پیوند میشود. این پرونده به C نوشته شده، چون همان نقشی را بازی میکند که libc در جاهای دیگر دارد، و اینها را فراهم میکند:
malloc،calloc،reallocوfreeروی یک تخصیصدهندهی پیشرونده (bump allocator) ۱۶ مبیبایتی، با سرآیندی کوچک تاreallocبتواند محتوای قبلی را کپی کند؛memcpy،memmove،memset،memcmp،strlen،strcmp،strncmp،strstr،strtol،strtod،snprintf،printfوdprintf؛write،isatty،fflush،atexit،signal،raise،sched_yield،abortوexit؛- خروجی کنسول به درگاه سریال COM1، بافر متنی VGA در
0xB8000و کنسول اشکالزدایی QEMU در درگاه0xE9.
بافر VGA بایتها را به شکل ASCII نشان میدهد، پس متن فارسی یا هر متن UTF-8 دیگری باید به کنسول سریال برود. هر قلاب (hook) یک نماد ضعیف (weak symbol) است، پس کرنل میتواند salam_rt_write، salam_rt_exit، salam_rt_outb و بقیه را با نسخههای خودش جایگزین کند. تخصیصدهندهی پیشرونده هرگز حافظهی آزادشده را دوباره به کار نمیبرد؛ این برای نمونهی نمایشی و کرنلهایی که فقط یک بار هنگام بوت حافظه میگیرند خوب است و نخستین چیزی است که یک کرنل واقعی عوضش میکند: یا malloc خودتان را جایگزین کنید یا محیط اجرا را با SALAM_RT_HEAP_SIZE دیگری بسازید.
بستهی کرنل (baremetal)
std/baremetal چهرهی سلامیِ همان محیط اجراست:
| تابع | کاربرد |
|---|---|
Halt() | توقف پردازنده (cli; hlt روی x86 و wfi روی AArch64) |
Exit(code) | خروج از راه salam_rt_exit؛ معنایش را کرنل تعیین میکند |
Out8(port, value)، In8(port) | ورودی و خروجی درگاه در x86 |
Clear()، WriteAt(row, col, text, color) | صفحهی متنی VGA |
SerialWrite(text) | خروجی سریال بدون بافر |
Panic(message) | چاپ یک خط panic و توقف |
ExitQemu(code) | خروج از QEMU از راه isa-debug-exit |
HeapUsed()، HeapSize() | شمارندههای تخصیصدهندهی پیشرونده بر حسب بایت |
این بسته هم مثل همهی بستههای کتابخانهی استاندارد نامهای فارسی دارد: در منبع فارسی نام بسته کرنل است و برای نمونه کرنل.توقف() پردازنده را متوقف میکند، کرنل.پاک کردن() صفحه را پاک میکند و کرنل.خروج شبیه ساز(۰) از QEMU خارج میشود. کرنل را میتوان سراسر به فارسی نوشت، همانطور که در بخش پیش دیدید. این کوتاهترین نمونه است که با همان قطعهی بوت و اسکریپت پیوند ساخته شد، خوشامدش را روی درگاه سریال چاپ کرد و QEMU را خاموش کرد:
واردسازی کرنل
تابع ریشه:
سرچاپ "هستهی سلام"
کرنل.خروج شبیه ساز(۰)
پایانآنچه هنوز باید خودتان بنویسید
کار کامپایلر در اینجا محدود است: یک پروندهی شیء درست و بدون libc با قرارداد فراخوانی قابل پیشبینی. سیستمعامل به شما نمیدهد. بهویژه:
- وقفه، صفحهبندی حافظه و راهانداز دستگاه وجود ندارد. نه راهاندازی جدول توصیفگر وقفه (IDT) در کار است، نه کد جدول صفحه و نه لایهی دستگاه، نه در کامپایلر و نه در محیط اجرا. اینها را به سلام مینویسید، یا در یک بستهی پشتیبانی کوچک به اسمبلی یا C که با
--link-objectپیوند میخورد. برای ورودی و خروجی درگاه و ثباتهای نگاشته در حافظه، اشارهگرهای سلام و ارتباط با C ابزار کارند. - بیشتر کتابخانهی استاندارد در دسترس نیست. پرونده، سوکت، فرایند، نخ و زمان ساعت دیواری به سیستمعامل نیاز دارند، پس بیشتر
os،fs،net،dbوguiکنار میروند.str،mem،conv،encodingو گونههای مجموعهای کار میکنند وسلام هستهبخشهایی ازstdرا که در کرنل ساخته نمیشوند حذف میکند. - فقط سه معماری محیط اجرا دارند. ماشینهای دیگر به
--no-freestanding-runtimeوmalloc،memcpy،writeو قلابهایsalam_rt_*خودتان نیاز دارند. - حافظه، مثل همهجای سلام، مسئولیت خود شماست. زبالهروبی (Garbage Collector) وجود ندارد که بخواهید خاموشش کنید و این برای کرنل خبر خوبی است، اما وارسیگر قرض (Borrow Checker) هم وجود ندارد. سلام در ساختهای توسعه بررسی مرز آرایه و در زمان کامپایل هشدارهای مالکیت دارد؛ پیش از تکیه بر آنها، مقالهی مدیریت حافظه در سلام را بخوانید.
- کرنل نمونه فقط نمونه است. این کرنل زنجیرهابزار را از سر تا ته اثبات میکند؛ زمانبند، سیستم پرونده یا مدل راهانداز ندارد.
چرا کرنل را با زبان سلام بنویسیم؟
C به دلیلهای خوبی زبان پیشفرض کرنلهاست: دههها ابزار، کامپایلر برای هر معماری و حجم عظیمی از کد مرجع. Rust برای راهاندازها به کرنل لینوکس راه یافته است و Zig برای کرنلهای سرگرمی و فرمور (firmware) محبوب است. سلام از هر سه بسیار جوانتر است و هیچکس نباید امروز آن را برای یک کرنل تجاری انتخاب کند.
جایی که سلام جذاب میشود آموزش و آزمایش است. یک دانشجو میتواند کرنلی را بوت کند که تابع ورودیاش شبیه درس اول دورهی آموزشی است، سرچاپ و وکتورها همچنان در آن کار میکنند، منطقش را روی لپتاپ با salam test بیازماید و کامپایلری را که آن را ساخته بخواند، چون خود کامپایلر هم به سلام نوشته شده است (مقالهی کامپایلر خودمیزبان را ببینید). زبالهروب یا زمانبند زمان اجرایی وجود ندارد که بخواهید حذفش کنید، و به همین دلیل محیط اجرای خودایستا میتواند تنها یک پروندهی C باشد. و چون زبان کلیدواژههای فارسی و بستهی فارسی کرنل دارد، میتوان درس سیستمعامل را با کد کرنل به فارسی تدریس کرد؛ کاری که هیچ زبان سیستمی رایجی امکانش را نمیدهد. برای دانشگاهها و دورههای فارسیزبان، این یعنی دانشجو میتواند پیش از درگیر شدن با جزئیات سختافزار، منطق کرنل را به زبان خودش بخواند.
همین دلیلها ساخت خودایستا را فراتر از کرنل هم مفید میکنند: محمولههای بوتلودر، یونیکرنلها، اجزای هایپروایزر و تصویرهای آزمون برای شبیهسازها. برای هدفهایی که سیستمعامل معمولی دارند، مقالهی کامپایل متقابل لینوکس، مک، ویندوز و ARM را پوشش میدهد.
پرسشهای پرتکرار دربارهی برنامهنویسی bare metal با زبان سلام
آیا زبان سلام بدون سیستمعامل اجرا میشود؟
بله. ساخت برای یک هدف *-none، مثل i686-unknown-none یا x86_64-unknown-none، برنامهای میسازد که با libc پیوند نمیخورد و هیچ فراخوان سیستمی انجام نمیدهد. کرنل نمونهی مخزن در QEMU بوت میشود و از راه درگاه سریال و حافظهی VGA چاپ میکند.
سلام خودایستا از کدام معماریها پشتیبانی میکند؟
x86-64، i686 و AArch64 محیط اجرای درونی دارند. ماشینهای دیگر را هم میتوان از راه LLVM هدف گرفت، به شرط آنکه --no-freestanding-runtime را بدهید و تابعهای حافظه، رشته و خروجی را خودتان فراهم کنید.
آیا سلام خودایستا زبالهروبی دارد که باید خاموشش کرد؟
نه. سلام هرگز زبالهروب نداشته است؛ حافظهی هیپ در همهی ساختها صریحاً آزاد میشود. در ساخت خودایستا، هیپ از تخصیصدهندهی پیشروندهی ۱۶ مبیبایتی محیط اجرا میآید، مگر اینکه malloc را جایگزین کنید.
آیا میتوان با سلام ماژول کرنل لینوکس نوشت؟
هیچ چیزی در مخزن از این کار پشتیبانی نمیکند. ماژولهای کرنل به سیستم ساخت، سرآیندها و قراردادهای همان کرنل مشخص وابستهاند، و هدف خودایستا برای برنامههایی طراحی شده که کل ماشین را در اختیار دارند، نه برای کدی که درون کرنل دیگری بارگذاری میشود.