ساختن کوچکترین لینوکس شخصی من (از کرنل تا Lua)
دنیا پر از توزیعهای لینوکسی آماده است. از اوبونتو و فدورا گرفته تا آرچ و نیکس و هزاران توزیع دیگر که فقط با چند کلیک بالا میآیند، دسکتاپ دارند و بدون دردسر کار میکنند. اما یک جایی در مسیر یادگیری سیستمعامل، انسان دلش میخواهد یک سؤال خیلی ساده بپرسد:
«حداقلِ کرنلی که لازم دارم تا یک لینوکس ۶۴ بیتی بالا بیاید چقدر است؟
اگر خودم همهچیز را از صفر بسازم یا تظیم کنم، این سیستمعامل چه شکلی میشود؟»
این مقاله روایت همین سؤال است؛ روایتی در سه مرحله:
- ساختن یک کرنل مینیمال و بوتکردنش در QEMU
- نوشتن init با Rust + اسمبلی، بدون
libcو بدونstd - اضافهکردن Lua، ساخت initramfs واقعی و در نهایت ساختن یک ISO قابل بوت
این متن مکمل ویدیو است، نه فقط ترنسکریپت. اگر دوست داری عمیقتر وارد ماجرا شوی، اینجا جاییست که تمام جزئیات را با حوصله باز میکنم.
چرا اصلاً چنین کاری کردم؟
ما معمولاً لینوکس را به شکل «یک توزیع» میبینیم:
اوبونتو، آرچ، دبیان، نیکس…
در حالیکه در عمق، لینوکس بیشتر شبیه یک «پازل» است:
- کرنل (kernel)
- بوتلودر (bootloader)
- فضای کاربر (user space)
- ابزارها، شل، کتابخانهها، زبانها…
وقتی دستبهکار میشوی و خودت یک سیستم کوچک میسازی، چند چیز تغییر میکند:
- دیگر سیستمعامل را «جادو» نمیبینی؛
میفهمی از کجا شروع میشود و چطور لایهلایه ساخته میشود. - بهتر میفهمی ابزارهایی مثل Docker، QEMU، initramfs، systemd و… روی چه زمینی ایستادهاند.
- مهمتر از همه: حس مالکیتت نسبت به سیستمات عوض میشود.
این پروژه قرار نیست یک دیسترو «کاربردی» برای استفاده روزمره باشد؛
بیشتر یک میکروسکوپ آموزشی است برای دیدن سادهترین فرم یک سیستمعامل.
بخش اول – ساختن کوچکترین کرنل ممکن و بوت در QEMU
در بخش اول، هدف این بود:
- سورس کرنل را بگیرم
- یک تنظیمات ultra-minimal بسازم (
tinyconfig) - چند گزینهی مهم را دستی تنظیم کنم
- و در نهایت کرنل را بوت کنم تا به آن پیام معروف برسم:
no working init found
گرفتن سورس کرنل
من زیر ~/_dev/smallest_linux یک پوشه برای کرنل در نظر گرفتم:
cd ~/_dev/smallest_linux
git clone https://github.com/torvalds/linux.git
cd linux
برای اطمینان:
pwd
ls
ساختار پوشهها (arch, fs, kernel, drivers و…) همان کرنلی است که همهجا میبینیم و ازش میترسیم =)
استفاده از tinyconfig – کوچکترین کانفیگ ممکن
کرنل خودش یک هدف آماده برای ساختن کانفیگهای «خیلی کوچک» دارد:
make tinyconfig
این دستور یک .config فوق مینیمال میسازد. اما:
- من ۶۴ بیتی میخوام
- initramfs لازم دارم
- لاگ و کنسول متنی لازم داریم
پس باید با menuconfig کمی تنظیمات را دستکاری کنم:
make menuconfig
چند تنظیم حیاتی که انجام دادم:
۱. فعال کردن کرنل ۶۴بیتی
در منوهای مربوط (بسته به نسخهی کرنل):
Processor type and featuresیا بخش مشابه- گزینهی
64-bit kernelرا فعال کردم.
۲. فعالکردن initramfs
از General setup:
Initial RAM filesystem and RAM disk (initramfs/initrd) support
این برای ما حیاتی است، چون بعداً کل init و Lua را از طریق initramfs به کرنل میدهیم.
۳. فعالکردن printk
باز در General setup، زیر گزینههای «expert»:
Enable support for printk
اگر این گزینه را خاموش بذاریم، کرنل هیچچیزی روی صفحه چاپ نمیکند و عملاً به کار این پروژه نمیآید.
۴. فعالکردن TTY / کنسول
در Device Drivers → Character devices،
گزینههای مربوط به Virtual terminal / TTY / Console on virtual terminal را فعال کردم. بدون اینها، خروجی متنی نداریم.
۵. فعالبودن ELF binary support
در Executable file formats / Emulations،
گزینهی Kernel support for ELF binaries رو روشن کردم.
چون init من یک ELF واقعی است؛ اگر این گزینه روشن نباشد، کرنل اصلاً توانایی اجرای اجرا آن را ندارد.
بعد از این تغییرها، از menuconfig خارج شو و ذخیره کن تا .config نهایی شود.
ساخت کرنل
حالا:
make -j 5
یا:
make -j $(nproc)
بعد از بیلد:
ls -lh arch/x86/boot/bzImage
همین bzImage همان هستهی اصلی دنیای کوچک ماست.
بوت اولیه با QEMU (بدون initramfs)
برای این که ببینیم کرنل بالا میآید:
qemu-system-x86_64 \
-kernel arch/x86/boot/bzImage \
-m 64M
روی صفحه کلی پیام میبینی؛ مهمترین بخش در انتهاست:
Run /sbin/init as init process
...
No working init found. Try passing init= option to kernel.
Kernel panic - not syncing: Attempted to kill init!
در این لحظه:
- کرنل بالا آمده
- مموری، CPU، و بقیهی موارد را تنظیم کرده
- فقط init ندارد، و کاملاً منطقی panic میکند
این همان جایی است که پارت اول تمام میشود و سؤال بعدی شکل میگیرد:
اگر init را خودم بنویسم، چه؟
بخش دوم – نوشتن یک init مینیمال با Rust + اسمبلی
در پارت دوم، هدف این بود:
- یک باینری فوقالعاده کوچک به نام
initبسازم - بدون
std، بدونlibc، بدونmain - مستقیم با system callها حرف بزنم
- و این
initرا بهعنوان PID 1 روی کرنل خودم بالا بیارم
در اینجا، Rust بیشتر شبیه زبان اسمبلی سطح بالا استفاده میشود تا یک زبان راحت سطح بالا.
طراحی کلی init
طراحی منطقی این init:
-
نقطهی ورود:
_start -
چاپ چند پیام خوشآمدگویی
-
وارد یک حلقهی شبیه شل شود:
-
"$ "چاپ کند -
از stdin یک خط بخواند
-
newline را حذف کند
-
اگر خالی بود، continue
-
اگر نه،:
forkکند- در child →
execve(path, argv, envp) - در parent →
wait4برای فرزند
-
به خاطر این که هیچ PATH و محیط استانداردی ندارم، حتماً باید مسیر کامل را مینوشتم، مثلاً /bin/lua.
کد Rust بدون std و بدون main
ایده این است:
#![no_std]→ یعنی هیچ کتابخانهی استاندارد#![no_main]→ یعنی نقطهی ورود خودمان را تعریف میکنیم- سیستمکالها را با
extern "C"اعلان میکنیم و در اسمبلی پیادهسازی
کد اصلی (خلاصهشده) چیزی شبیه این است:
#![no_std]
#![no_main]
use core::panic::PanicInfo;
extern "C" {
fn sys_write(fd: isize, buf: *const u8, len: usize) -> isize;
fn sys_read(fd: isize, buf: *mut u8, len: usize) -> isize;
fn sys_fork() -> isize;
fn sys_execve(path: *const u8,
argv: *const *const u8,
envp: *const *const u8) -> isize;
fn sys_wait4(pid: i32,
status: *mut i32,
options: i32,
rusage: *mut u8) -> isize;
fn sys_exit(code: i32) -> !;
}
#[no_mangle]
pub extern "C" fn _start() -> ! {
write_str(b"\n\n[init] Rust minimal init started.\n");
write_str(b"[init] Type a full path to exec (e.g. /bin/lua).\n\n");
write_str(b"_______________________________\n");
shell_loop();
}
fn shell_loop() -> ! {
let mut buf = [0u8; 256];
loop {
write_str(b"-$ ");
let n = unsafe { sys_read(0, buf.as_mut_ptr(), buf.len()) };
if n <= 0 {
unsafe { sys_exit(0) }
}
let mut n = n as usize;
n = strip_newline(&mut buf[..n]);
if n == 0 {
continue;
}
let path_ptr = buf.as_ptr();
let argv: [*const u8; 2] = [path_ptr, core::ptr::null()];
let pid = unsafe { sys_fork() };
if pid == 0 {
unsafe {
sys_execve(path_ptr, argv.as_ptr(), core::ptr::null());
write_str(b"[child] execve failed, exiting.\n");
sys_exit(1);
}
} else if pid > 0 {
let mut status: i32 = 0;
unsafe {
sys_wait4(-1, &mut status as *mut i32, 0, core::ptr::null_mut());
}
} else {
write_str(b"[parent] fork failed.\n");
}
}
}
fn strip_newline(buf: &mut [u8]) -> usize {
let mut len = buf.len();
while len > 0 && (buf[len - 1] == b'\n' || buf[len - 1] == b'\r') {
len -= 1;
}
if len < buf.len() {
buf[len] = 0;
}
len
}
fn write_str(s: &[u8]) {
unsafe {
sys_write(1, s.as_ptr(), s.len());
}
}
#[panic_handler]
fn panic(_: &PanicInfo) -> ! {
unsafe { sys_exit(1) }
}
اینجا هیچ خبری از println!، heap، Vec، String و… نیست.
همهچیز روی یک بافر ثابت و چند system call ساده سوار شده.
لایهی اسمبلی: پیادهسازی system callها
برای این که sys_write و بقیه واقعاً کار کنند، باید آنها را در یک فایل ASM پیاده کنیم:
.section .text
.intel_syntax noprefix
.globl sys_write
.globl sys_read
.globl sys_fork
.globl sys_execve
.globl sys_wait4
.globl sys_exit
# isize sys_write(isize fd, const u8 *buf, usize len)
sys_write:
mov rax, 1 # __NR_write
syscall
ret
# isize sys_read(isize fd, u8 *buf, usize len)
sys_read:
mov rax, 0 # __NR_read
syscall
ret
# isize sys_fork(void)
sys_fork:
mov rax, 57 # __NR_fork
syscall
ret
# isize sys_execve(const u8 *path, const u8 **argv, const u8 **envp)
sys_execve:
mov rax, 59 # __NR_execve
syscall
ret
# isize sys_wait4(i32 pid, i32 *status, i32 options, u8 *rusage)
sys_wait4:
mov rax, 61 # __NR_wait4
syscall
ret
# void sys_exit(i32 code) -> !
sys_exit:
mov rax, 60 # __NR_exit
syscall
.Lhang:
jmp .Lhang
- شمارهها (
0,1,57,59,60,61) همان شمارههای سیستمکال روی x86_64 هستند. - آرگومانها طبق ABI لینوکس در
rdi,rsi,rdx,rcx,r8,r9میآیند. - من فقط
raxرا ست میکنم وsyscallمیزنم.
کامپایل و لینک به یک باینری استاتیک non-PIE
ابتدا object اسمبلی را بساز:
as --64 syscalls.s -o syscalls.o
بعد با rustc مستقیماً لینک کن:
rustc \
-C opt-level=z \
-C lto \
-C panic=abort \
-C relocation-model=static \
-C link-arg=-nostartfiles \
-C link-arg=-static \
-C link-arg=-no-pie \
-C link-arg=syscalls.o \
shell.rs -o init
و در نهایت:
strip init
file init
ldd init || echo "ldd failed (probably static / no interpreter)"
الان یک init دارم که:
- استاتیک است
- non-PIE است
- هیچ وابستگی داینامیکی ندارد
ساخت initramfs مینیمال فقط با init
یک rootfs ساده:
mkdir -p rootfs
cp init rootfs/init
cd rootfs
find . | cpio -H newc -o > ../init.cpio
و بعد:
cp init.cpio ~/_dev/linux/
بوت کرنل با init خودم
دوباره به کرنل برگرد:
cd ~/_dev/linux
qemu-system-x86_64 \
-kernel arch/x86/boot/bzImage \
-initrd ./init.cpio \
-append "console=ttyS0 loglevel=3 quiet" \
-m 64M
اگر همهچیز درست باشد، اینبار خبری از no working init found نیست.
باید چیزی شبیه این نمایش داده بشه:
[init] Rust minimal init started.
[init] Type a full path to exec (e.g. /bin/lua).
-$
در این لحظه، کرنل tiny خودم در حال اجراست و init نوشتهشده با Rust خودم، PID 1 سیستم است.
بخش سوم – اضافه کردن Lua و ساخت یک ISO کامل
تا اینجا:
- کرنل دارم
- init دارم
- initramfs دارم
ولی هیچ user space واقعی ندارم. در پارت سوم هدف این بود که:
- یک برنامه واقعی (Lua) را استاتیک بسازم
- آن را کنار init در rootfs قرار بدم
- یک initramfs درست و حسابی بسازم
- و در نهایت، با ابزار داخلی کرنل، یک ISO قابل بوت بسازیم.
کامپایل استاتیک Lua
در ~/_dev:
cd ~/_dev/smallest_linux/
wget https://www.lua.org/ftp/lua-5.4.7.tar.gz
tar xvf lua-5.4.7.tar.gz
cd lua-5.4.7
make clean
make linux MYLDFLAGS="-static"
و بعد:
file src/lua
ldd src/lua
strip src/lua
ls -lh src/lua
این باینری lua، یک ELF استاتیک است؛ دقیقاً همان چیزی که میخواهیم.
ساخت rootfs نهایی (init + Lua)
در پروژهی Rust:
cd ~/_dev/smallest_linux/shell_min
mkdir -p rootfs/bin
cp init rootfs/init
cp ~/ _dev/smallest_linux/lua-5.4.7/src/lua rootfs/bin/lua
cd rootfs
find . -print
ساختار:
.
./init
./bin
./bin/lua
حالا یک فضای کاربر کوچک دارم: init و Lua.
ساخت initramfs واقعی
در rootfs:
find . | cpio -H newc -o > ../init.cpio
و دوباره کپی:
cd ..
cp init.cpio ~/_dev/smallest_linux/linux/
ساخت ISO با make isoimage
حالا در سورس لینوکس:
cd ~/_dev/smallest_linux/linux
make isoimage \
FDARGS="loglevel=3 quiet initrd=init.cpio" \
FDINITRD=/home/snape/_dev/smallest_linux/linux/init.cpio
نتیجه:
ls -lh arch/x86/boot/image.iso
این همان ISO است که:
- کرنل tiny تو
- initramfs خودت
- و init + Lua را در خودش دارد.
بوت ISO در QEMU
qemu-system-x86_64 \
-cdrom arch/x86/boot/image.iso \
-m 64M
اگر همهچیز درست باشد، همان پیامهای init ظاهر میشود و prompt میرسد.
حالا وقت اجرای Lua است:
-$ /bin/lua
Lua 5.4.7 Copyright ...
>
و مثلاً:
> for i=1,10 do print("hello", i) end
این لحظه، نقطهی اوج پروژه بود:
- کرنلی که خودم ساختم
- initی که خودم با Rust و ASM نوشتم
- initramfsی که خودم پک کردم
- و زبان Lua که روی این «جهان کوچک» اجرا میشود
همهی اینها در چند 3.4 مگابایت جا شدهاند.
جمعبندی – وقتی سیستمعامل از «ابزار آماده» به «اثر شخصی» تبدیل میشود
این پروژه قرار نیست جای هیچ توزیعی را بگیرد، قرار نیست یک سیستمعامل «مصرفی» برای استفادهی روزمره بسازد.
چیزی که برای من مهم بود، این بود که:
- ببینم کوچکترین لینوکسی که واقعاً میفهمم چطور کار میکند، چه شکلی است
- از لایهی کرنل، تا init، تا اجرای یک برنامهی واقعی مثل Lua
- و این مسیر را تا انتها خودم برم، نه فقط با خواندن مستندات
بعد از این سفر:
- کرنل دیگر فقط یک «هستهٔ ترسناک» نیست؛ یک فایل ۱–۲ مگی است که خودم ساختم
- init دیگر فقط یک پروسهی ناشناس با PID 1 نیست؛ یک برنامهی Rust است که خودم نوشتم
- و «سیستمعامل» دیگر فقط لوگوی یک شرکت یا اسم یک دیسترو نیست؛ چیزی است که میتونم خودم هم بسازمش، هرچند در ابعاد کوچک.
دنیا پر از دیستروهای آماده است؛ اما یک بار ساختن کوچکترین دنیای لینوکسی خودت، چیزی است که دیدت را نسبت به همهی آنها عوض میکند.
دیدگاهها
اولین نفری باشید که دیدگاه میگذارد.