از ابزار خط فرمان تا هسته‌ی سیستم‌عامل: برنامه‌نویسی خودایستا و Bare Metal در زبان سلام

همان کامپایلری که سرورهای وب سلام را می‌سازد، می‌تواند برنامه‌ای بسازد که کل ماشین در اختیار خودش است: بدون libc، بدون فراخوان سیستمی، با یک محیط اجرای کوچک و کرنلی که در QEMU بوت می‌شود. این مقاله سازوکارش را و آنچه هنوز باید خودتان بنویسید توضیح می‌دهد.

در این مقاله
  1. خودایستا یعنی چه؟
  2. فرمان ساخت
  3. کرنلی به زبان سلام که در QEMU بوت می‌شود
  4. یک برنامه، هم روی سیستم‌عامل و هم روی bare metal
  5. محیط اجرای درونی چه چیزی فراهم می‌کند
  6. بسته‌ی کرنل (baremetal)
  7. آنچه هنوز باید خودتان بنویسید
  8. چرا کرنل را با زبان سلام بنویسیم؟
  9. پرسش‌های پرتکرار درباره‌ی برنامه‌نویسی bare metal با زبان سلام

برنامه‌نویسی 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-noneELF64برای بوت‌لودر ۶۴ بیتی مثل GRUB یا Limine
i686-unknown-noneELF32Multiboot، گزینه‌ی -kernel در QEMU، رایانه‌های ۳۲ بیتی
aarch64-unknown-noneELF64درگاه سریال PL011 در 0x09000000، و wfi برای توقف
x86_64-unknown-salamosELF64هدفی به سبک فضای کاربر سیستم‌عامل سلام
x86_64-unknown-salamos-kernelELF64ثابت 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"، "سلام"]:
        سرچاپ واژه، فلچر۱۶(واژه)
    پایان
    اگر سلام خودایستا:
        کرنل.خروج شبیه ساز(۰)
    پایان
پایان
خروجیabcde 51440 abcdef 8279 سلام 62922

این خروجی از یک 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 را جایگزین کنید.

آیا می‌توان با سلام ماژول کرنل لینوکس نوشت؟

هیچ چیزی در مخزن از این کار پشتیبانی نمی‌کند. ماژول‌های کرنل به سیستم ساخت، سرآیندها و قراردادهای همان کرنل مشخص وابسته‌اند، و هدف خودایستا برای برنامه‌هایی طراحی شده که کل ماشین را در اختیار دارند، نه برای کدی که درون کرنل دیگری بارگذاری می‌شود.

ساختن با سلام

محاسبات علمی و یادگیری ماشین با زبان سلام: تانسور، ماتریس، مشتق‌گیری خودکار و شبکه‌ی عصبی

تانسور، مشتق‌گیری خودکار معکوس، لایه‌های شبکه‌ی عصبی، بهینه‌سازها، مدل‌هایی به سبک scikit-learn و خواننده‌های ONNX و GGUF: پشته‌ی یادگیری ماشین سلام در کتابخانه‌ی استاندارد است و به کد بومی کامپایل می‌شود. گشتی همراه با برنامه‌های اجراشدنی و فهرستی روشن از کارهایی که انجام نمی‌دهد.

خواندن مقاله
ساختن با سلام

ابزارهای توسعه‌ی زبان سلام: سرور زبان، افزونه‌های ویرایشگر، خطایاب، قالب‌بند، REPL و اجراکننده‌ی آزمون

لذت کار با یک زبان به اندازه‌ی ابزارهای آن است. سلام کامپایلر، سرور زبان، قالب‌بند، اجراکننده‌ی آزمون، REPL، راه‌انداز خطایاب و بررسی‌گر حافظه را در یک پرونده‌ی اجرایی گذاشته و برای یازده ویرایشگر افزونه دارد. کار هر ابزار با خروجی واقعی، و آنچه هنوز کم است.

خواندن مقاله
ساختن با سلام

توسعه‌ی وب با زبان برنامه‌نویسی سلام: زبانی داخلی که HTML، CSS و جاوااسکریپت می‌سازد

سلام درون کامپایلرش یک زبان صفحه دارد. صفحه را به شکل درختی از عنصرها می‌نویسید، کامپایلر هر عنصر و ویژگی و مقدار CSS را بررسی می‌کند و HTML، CSS و جاوااسکریپت تحویل می‌دهد. این مقاله از یک صفحه‌ی ساده تا بخش‌ها، رویدادها و صفحه‌هایی که سرور سلام می‌سازد پیش می‌رود.

خواندن مقاله