Phát Triển Hệ Điều Hành Từ Con Số 0
Chương 0 - Lời Nói Đầu
Series về việc tự tay xây dựng một hệ điều hành hoạt động được - từ byte đầu tiên của boot sector cho tới một kernel biết chạy nhiều tiến trình cùng lúc.
Chào mừng bạn
Đây là một series các bài viết và hướng dẫn về máy tính và hệ điều hành. Mục tiêu không phải là ghép đại một OS đồ chơi rồi coi như xong - mà là hiểu tại sao mọi thứ vận hành như vậy. Từ khoảnh khắc bạn bấm nút nguồn và BIOS trao quyền điều khiển, cho tới khi kernel của chính bạn in dòng chữ đầu tiên lên màn hình.
Trên đường đi, chúng ta sẽ chạm vào gần như mọi tầng của một chiếc máy: kiến trúc CPU, quá trình boot, quản lý bộ nhớ, ngắt, trình điều khiển thiết bị, hệ thống tập tin và đa nhiệm. Đây là loại kiến thức bạn hiếm khi cần khi viết ứng dụng thông thường - nhưng một khi có nó, bạn sẽ không bao giờ nhìn máy tính theo cách cũ nữa.
Mình viết series này với một nguyên tắc: giải thích mọi thứ, không giấu phép màu. Mỗi khi có một dòng kiểu “cứ gõ vậy đi rồi nó chạy”, mình sẽ cố hết sức cho bạn biết dòng đó thực sự làm gì.
Cuối series bạn sẽ xây được gì?
Để bạn hình dung con đường phía trước, đây là lộ trình tổng quát:
- Boot sector 512 byte - đoạn code assembly đầu tiên mà CPU thực thi, chạy ở real mode 16-bit.
- Bootloader nhiều tầng - nạp phần kernel lớn hơn từ đĩa vào bộ nhớ.
- Chuyển sang Protected Mode (32-bit) - thiết lập GDT và bật các cơ chế bảo vệ của CPU.
- Kernel viết bằng C - in ký tự ra màn hình qua VGA text buffer.
- Xử lý ngắt - IDT, ngoại lệ CPU, ngắt phần cứng (IRQ), bàn phím và timer.
- Quản lý bộ nhớ - physical memory manager, paging, bộ nhớ ảo, và cuối cùng là
malloc/freecủa riêng ta. - Trình điều khiển thiết bị - bàn phím, ổ đĩa, timer.
- Hệ thống tập tin - đọc file từ một định dạng đĩa đơn giản như FAT.
- Đa nhiệm - chuyển ngữ cảnh, bộ lập lịch, và chạy nhiều tác vụ song song.
- (Nâng cao, tùy chọn) - Long Mode 64-bit, không gian người dùng, và system call.
Chưa cần hiểu hết ngay đâu - đây chỉ là bản đồ. Chúng ta sẽ đi từng bước một.
Bạn cần biết gì trước?
Series này dùng C và hợp ngữ x86 (x86 assembly). Bạn nên nắm vững cả hai trước khi bắt đầu. Chương này có phần ôn nhanh những điểm quan trọng nhất.
Nếu bạn chưa từng lập trình
Chào mừng đến với lập trình! Nhưng nói thật: phát triển hệ điều hành là chỗ cực tệ để học code lần đầu. Ở đây không có lưới an toàn - không có thông báo lỗi thân thiện, không có print để debug dễ dàng, và một lỗi nhỏ có thể làm cả máy đơ cứng mà không nói một lời.
Lời khuyên: hãy bắt đầu với thứ nhẹ nhàng hơn như Python để xây dựng trực giác lập trình, rồi chuyển sang C. Khi con trỏ, quản lý bộ nhớ thủ công, và cách máy tính thực sự lưu trữ dữ liệu đã trở nên tự nhiên với bạn, hãy quay lại đây. Nó sẽ đáng giá hơn nhiều.
Môi trường phát triển: Ubuntu Linux
Toàn bộ series này được phát triển và kiểm thử trên Ubuntu Linux. Linux là môi trường cực kỳ tự nhiên cho việc dev OS: công cụ mã nguồn mở đầy đủ, cross-compiler có sẵn, và bạn kiểm soát được mọi thứ.
Cài đặt bộ công cụ:
1sudo apt update
2sudo apt install build-essential nasm qemu-system-x86 gdb
- build-essential - GCC,
make, và các công cụ biên dịch cốt lõi. - nasm - trình hợp dịch cho code x86 assembly của chúng ta.
- qemu-system-x86 - máy ảo để chạy và thử nghiệm OS, thay vì reboot máy thật mỗi lần. Nhanh, và không thể làm hỏng máy.
- gdb - trình gỡ lỗi, sẽ được cắm thẳng vào QEMU để chạy từng bước qua kernel.
Chúng ta sẽ tự viết bootloader bằng assembly, nên không cần GRUB hay bất kỳ công cụ đóng gói ảnh đĩa nào. Sản phẩm cuối là một ảnh đĩa thô (flat binary) chạy thẳng trong QEMU, hoặc ghi ra USB để boot trên máy thật (xem phần cuối).
Tại sao phải dùng cross-compiler
Đây là điều mà nguồn cảm hứng gốc của series bỏ qua, và nó khiến gần như ai cũng vấp. Nó xứng đáng có phần giải thích riêng.
GCC mặc định trên Ubuntu được cấu hình để tạo ra chương trình chạy trên Linux. Nó âm thầm cài vào những giả định về hệ điều hành chủ - thư viện chuẩn, cách chương trình khởi động, định dạng thực thi, v.v. Nhưng kernel của bạn chính là hệ điều hành. Nó không chạy bên trên Linux, nên tất cả những giả định đó biến thành các lỗi khó chịu và khó hiểu.
Giải pháp là xây dựng một cross-compiler riêng, ví dụ i686-elf-gcc (cho 32-bit) hoặc x86_64-elf-gcc (cho 64-bit). Đây là bản GCC được cấu hình để xuất ra code “freestanding” - không giả định có hệ điều hành nào bên dưới. Chúng ta sẽ hướng dẫn cách build nó ở một chương riêng - hãy xem đây là bước bắt buộc, không phải tùy chọn.
Tổng quan về C trong “cõi kernel”
Phần này giả định bạn đã biết C. Nó chỉ chỉ ra những chỗ mà C hoạt động khác so với khi viết ứng dụng.
C 16-bit và 32-bit
Ngay khi bật nguồn, CPU chạy ở real mode 16-bit - chế độ mà các trình biên dịch 32-bit hiện đại không hỗ trợ. Đây là điều quan trọng đầu tiên: nếu muốn OS real-mode 16-bit, bạn cần trình biên dịch C 16-bit; nếu muốn OS 32-bit, bạn cần trình biên dịch 32-bit. Code C 16-bit và 32-bit không tương thích với nhau.
Trong series này, chúng ta xây dựng một hệ điều hành 32-bit (rồi sau đó mở rộng lên 64-bit), nên sẽ dùng cross-compiler tương ứng.
C và các định dạng tập tin thực thi
Một điểm vướng với C là nó không xuất ra được flat binary - loại chương trình mà điểm bắt đầu nằm ngay ở byte đầu tiên, không có cấu trúc nội bộ nào cả, chỉ là một chuỗi các số 0 và 1.
Tại sao ta lại cần nó? Vì khi máy boot, BIOS ROM nắm quyền, và khi cần khởi động một OS thì nó hoàn toàn không biết mọi thứ đang ở định dạng gì. Nó chỉ đơn giản nạp boot sector vào bộ nhớ rồi nhảy tới byte đầu tiên. Nó coi bootloader như một flat binary thuần túy - không hơn.
(Có một chi tiết cụ thể khá hay ở đây: BIOS chỉ chấp nhận một boot sector nếu hai byte cuối của nó là chữ ký 0x55 0xAA. Thiếu nó là máy từ chối boot ngay. Đây là một trong những “nghi thức” nhỏ đầu tiên bạn sẽ học.)
Vì vậy, phần đầu của bootloader (Tầng 1) bắt buộc phải là assembly. Mọi trình biên dịch C đều xuất ra tập tin có cấu trúc nội bộ - object file, thư viện, hoặc file thực thi như ELF. Chỉ có một ngôn ngữ hỗ trợ trực tiếp flat binary thô: hợp ngữ.
Trên Linux, định dạng nội bộ mà GCC tạo ra là ELF (Executable and Linkable Format), và đó là thứ ta dùng cho kernel. Ta sẽ dùng một linker script để kiểm soát chính xác cách từng phần của chương trình được bố trí trong bộ nhớ, và objcopy để trích ra flat binary khi cần.
Gọi kernel viết bằng C
Khi bootloader đã sẵn sàng, nó nạp và chạy kernel C bằng cách gọi vào điểm vào (entry point) của kernel. Vì chương trình C tuân theo một định dạng nội bộ nhất định (ELF), bootloader phải biết cách phân tích tập tin đó và tìm điểm vào để gọi. Ta sẽ nói chính xác cách làm ở phần sau. Đây là điều cho phép ta viết kernel và các thư viện bằng C, thay vì phải hợp dịch mọi thứ bằng tay.
Chuỗi khởi động: mỗi tầng nạp tầng tiếp theo rồi trao quyền điều khiển, cho tới khi kernel của bạn tiếp quản. Chú ý rằng Tầng 1 phải vừa trong một sector 512 byte và kết thúc bằng chữ ký ma thuật 0x55AA.
Con trỏ và Không gian địa chỉ vật lý (PAS)
Vì sao con trỏ quan trọng đến vậy ở đây
Trong phần mềm hệ thống, con trỏ có mặt ở khắp nơi. Con trỏ đơn giản là một biến chứa địa chỉ của một thứ gì đó:
1char* pointer;
Con trỏ này chưa được gán gì cả - đây là một wild pointer (con trỏ hoang). Nó có thể trỏ tới bất cứ đâu: một biến khác, địa chỉ 0, chính đoạn code của bạn, hay một thanh ghi phần cứng. C không khởi tạo giá trị cho bạn.
Không gian địa chỉ vật lý (PAS)
Không gian địa chỉ vật lý định nghĩa tất cả các địa chỉ bạn có thể dùng. Những địa chỉ này có thể trỏ tới bất cứ thứ gì trong PAS: bộ nhớ vật lý (RAM), thiết bị phần cứng, hoặc thậm chí là hư không. Đây là điểm khác biệt lớn so với lập trình ứng dụng trên một OS ở protected mode, nơi mọi “địa chỉ” đều là bộ nhớ.
Đây là ví dụ kinh điển. Trong một ứng dụng, đoạn này sập với lỗi segmentation fault:
1char* pointer = 0;
2*pointer = 0;
Chạy đúng đoạn code đó trong kernel tương lai của chúng ta… và chẳng có gì sập cả. Thay vào đó, nó ghi đè lên byte đầu tiên của Interrupt Vector Table.
Từ đó rút ra vài điều quan trọng:
- Hệ thống sẽ không sập khi ghi qua con trỏ null.
- Con trỏ có thể trỏ tới bất cứ thứ gì trong PAS - có thể là bộ nhớ, có thể không.
Đọc từ một địa chỉ không tồn tại cho bạn giá trị rác (bất cứ thứ gì đang có trên bus dữ liệu). Ghi vào đó thì chẳng làm gì cả. Các thiết bị ROM như BIOS cũng được ánh xạ vào cùng PAS - bạn đọc được chúng, nhưng ghi vào ROM cũng vô ích như ghi vào hư không.
Bản đồ sơ lược của không gian địa chỉ vật lý ở real mode. Cùng một phép tính con trỏ có thể đưa bạn tới RAM, tới một thiết bị phần cứng, hoặc tới ROM chỉ-đọc - riêng địa chỉ không nói cho bạn biết đó là loại nào.
Nói cách khác, đừng nghĩ con trỏ là “một biến trỏ tới một ô nhớ”, mà hãy nghĩ nó là “một biến trỏ tới một địa chỉ trong PAS” - nơi đó có thể là RAM, và cũng có thể là thứ hoàn toàn khác.
Cấp phát bộ nhớ
Ở cõi ứng dụng, bạn gọi malloc()/free() để xin bộ nhớ từ heap. Ở cõi hệ thống, mọi thứ khởi đầu rất khác. Ban đầu, để “cấp phát” bộ nhớ, bạn chỉ làm thế này:
1char* pointer = (char*)0x5000;
Vậy thôi. Vì ta kiểm soát mọi thứ, ta chỉ cần trỏ tới một địa chỉ nào đó trong PAS (tốt nhất là RAM) và tuyên bố “đây là buffer mới của mình”.
Điểm mấu chốt: ban đầu không hề có cấp phát bộ nhớ động. malloc/free là các dịch vụ hệ thống - chúng cần một OS đang chạy để hỗ trợ. Nhưng khoan - chẳng phải chúng ta đang xây OS sao? Đúng vậy. Đó chính là vấn đề. Chúng ta sẽ phải tự viết các dịch vụ quản lý bộ nhớ trước khi có được malloc/free thật sự. Cho tới lúc đó, cách duy nhất để “cấp phát” là chiếm lấy một vùng địa chỉ chưa ai dùng.
Assembly nội tuyến (Inline assembly)
Có những việc C không tự làm được - giao tiếp với phần cứng, gọi các dịch vụ cấp thấp của CPU. Với những việc đó, ta phải dùng assembly.
Với GCC trên Ubuntu, từ khóa là __asm__, và cú pháp theo phong cách AT&T:
1__asm__ volatile ("cli"); // tắt ngắt
Bạn cũng có thể viết cả khối lệnh:
1__asm__ volatile (
2 "cli\n\t"
3 "hlt"
4);
Cú pháp extended inline assembly của GCC còn cho phép truyền biến C vào và ra. Ta sẽ dựa vào nó liên tục khi viết các hàm đọc/ghi cổng I/O để nói chuyện với phần cứng - ví dụ, đọc một byte từ cổng I/O:
1static inline uint8_t inb(uint16_t port) {
2 uint8_t value;
3 __asm__ volatile ("inb %1, %0" : "=a"(value) : "Nd"(port));
4 return value;
5}
Chi tiết cú pháp đó sẽ có chương riêng; giờ chỉ cần biết đây là cách C và phần cứng học cách nói chuyện với nhau.
Thư viện chuẩn và Runtime (RTL)
Bạn có thể dùng thư viện ngoài - nhưng chỉ với những routine không phụ thuộc vào dịch vụ hệ thống. Những thứ như printf(), scanf(), hay các hàm cấp phát bộ nhớ rốt cuộc đều cần một OS đang chạy. Trên thực tế, khoảng 90% thư viện chuẩn phải được viết lại cho OS của bạn, nên tốt nhất là tự viết ngay từ đầu.
Runtime Library là tập hợp các dịch vụ mà chương trình dựa vào lúc chạy; bản chất của chúng là giả định có một OS đang hiện diện. Trên một hệ thống bình thường, đoạn code khởi động của RTL là thứ chạy trước main() - thiết lập stack, xóa (zero) vùng .bss, v.v. Chúng ta viết kernel hoàn toàn bằng C, nên ta sẽ tự cung cấp phần nhỏ mà mình thực sự cần, trong đoạn stub assembly và code khởi động của mình.
Xuyên suốt series, chúng ta sẽ xây cả hai: một runtime tối giản cho kernel C của ta những gì nó cần để chạy, và một thư viện chuẩn nhỏ lớn dần cùng chúng ta.
Sửa lỗi với QEMU + GDB
Đây là câu hỏi khó nhất trong phát triển OS: khi không có printf() và không có debugger thông thường, bạn làm gì khi code không chạy?
Câu trả lời của chúng ta là QEMU kết hợp với GDB - và đây là một trong những lợi thế lớn nhất của việc dev trên Linux. QEMU tích hợp sẵn một “GDB stub” cho phép GDB cắm thẳng vào máy ảo và debug kernel của bạn theo từng lệnh, y như một chương trình bình thường.
Quy trình cơ bản: khởi động QEMU ở chế độ chờ debugger.
1qemu-system-i386 -s -S -kernel kernel.bin
-s- mở một GDB server tạilocalhost:1234(viết tắt của-gdb tcp::1234).-S- đóng băng CPU khi khởi động và chờ lệnh của bạn trước khi chạy.
Sau đó, ở một terminal thứ hai, mở GDB và kết nối:
(gdb) target remote localhost:1234
(gdb) symbol-file kernel.elf
(gdb) break kmain
(gdb) continue
Từ đây bạn có thể đặt breakpoint, chạy từng lệnh (stepi), xem thanh ghi (info registers), soi bộ nhớ (x/), và đọc biến. Đây là cách cực mạnh để thấy chính xác CPU đang làm gì tại mỗi thời điểm.
QEMU mở một GDB stub qua TCP; GDB kết nối vào đó và điều khiển kernel đang bị đóng băng của bạn như thể nó là một chương trình bình thường.
Hàng thật: QEMU đóng băng ở Booting from Hard Disk... trong khi GDB đứng tại PC: 0x7c7f - chính lệnh jmp $ ở cuối boot sector, đang quay vòng tại chỗ. Mấy dòng add %al,(%eax) bên dưới chỉ là phần đệm toàn số 0 của sector bị disassemble ra.
Mẹo: đặt các lệnh kết nối đó vào một file
.gdbinitđể khỏi phải gõ lại mỗi phiên.
Bạn cũng nên sớm viết vài routine đơn giản để tự in thông tin ra - ra VGA text buffer, hoặc ra cổng serial. Ít nhất chúng cũng cho bạn biết code chạy được tới đâu trước khi “chết”.
Mẹo: QEMU có thể chuyển hướng cổng serial của guest thẳng ra terminal của bạn bằng
-serial stdio. “In log qua serial” là một trong những mẹo debug tiện nhất khi làm OS - bạn sẽ dùng nó liên tục.
Chạy trên máy thật: ghi ra USB bằng dd
Vì ta tự viết bootloader và xuất ra một ảnh đĩa flat-binary thô, việc đưa OS lên máy thật đơn giản đến bất ngờ: ghi nguyên ảnh đĩa đó ra USB bằng dd, rồi boot máy từ nó.
Trước tiên, xác định đúng thiết bị USB. Cắm USB vào rồi chạy lsblk - bạn cần cả thiết bị (/dev/sdb, /dev/sdc, …), chứ không phải một phân vùng như /dev/sdb1:
1lsblk
Sau đó ghi ảnh đĩa:
1sudo dd if=os-image.bin of=/dev/sdX bs=512 conv=notrunc status=progress
⚠️ Cảnh báo:
ddđược mệnh danh là “disk destroyer” - nó ghi đè mù quáng, không hỏi lại. Nếu bạn trỏof=nhầm vào ổ hệ thống, toàn bộ dữ liệu trên đó sẽ mất sạch. Hãy kiểm tra/dev/sdXbằnglsblkmỗi lần, không sót lần nào.
Quy trình hợp lý là: luôn thử trong QEMU trước - nhanh, an toàn, và có GDB - chỉ khi mọi thứ chạy ổn mới dd ra USB để kiểm tra trên phần cứng thật. Trên máy thật, nhớ bật chế độ boot Legacy/CSM trong cài đặt BIOS/UEFI và chọn USB làm thiết bị khởi động.
Tài nguyên tham khảo
Xuyên suốt series, những nguồn sau sẽ vô cùng hữu ích:
- OSDev Wiki (
wiki.osdev.org) - kho kiến thức phát triển OS lớn nhất; gần như câu hỏi nào của bạn cũng có lời giải ở đó. - Intel® 64 and IA-32 Architectures Software Developer’s Manuals - tài liệu gốc, và là nguồn tham khảo chính xác nhất về CPU x86.
- Tài liệu của NASM, GNU binutils, QEMU và GDB - nắm chắc công cụ giúp bạn tiết kiệm rất nhiều thời gian.
Hẹn gặp lại
Vậy là xong chương mở đầu. Ở chương tiếp theo, chúng ta chính thức bước vào cuộc phiêu lưu: hệ điều hành thực sự là gì, kiến trúc của máy tính khớp với nhau ra sao, và thiết lập đầy đủ môi trường phát triển trên Ubuntu để viết những dòng code đầu tiên.
Hẹn gặp lại ở Chương 1.
