Dari bootloader ke kernelJika Anda membaca
artikel sebelumnya, Anda tahu tentang hobi baru saya untuk pemrograman tingkat rendah. Saya menulis beberapa artikel tentang pemrograman assembler untuk
x86_64
Linux dan pada saat yang sama mulai menyelami kode sumber dari kernel Linux.
Saya sangat tertarik untuk memahami bagaimana hal-hal tingkat rendah bekerja: bagaimana program berjalan di komputer saya, bagaimana mereka berada di memori, bagaimana kernel mengelola proses dan memori, bagaimana tumpukan jaringan bekerja pada tingkat rendah, dan banyak lagi. Jadi, saya memutuskan untuk menulis seri artikel lain tentang kernel Linux untuk
arsitektur x86_64 .
Harap dicatat bahwa saya bukan pengembang kernel profesional dan tidak menulis kode kernel di tempat kerja. Ini hanya hobi. Saya hanya suka hal-hal tingkat rendah dan menarik untuk menyelidiki mereka. Karena itu, jika Anda melihat ada kebingungan atau pertanyaan / komentar muncul, hubungi saya
di Twitter , melalui
surat atau hanya membuat
tiket . Saya akan berterima kasih.
Semua artikel diterbitkan dalam
repositori GitHub , dan jika ada yang salah dengan bahasa Inggris saya atau konten artikel, jangan ragu untuk mengirim permintaan tarik.
Harap dicatat bahwa ini bukan dokumentasi resmi, tetapi hanya pelatihan dan berbagi pengetahuan.Pengetahuan yang dibutuhkan- Memahami Kode C
- Memahami Kode Assembler (AT&T Syntax)
Bagaimanapun, jika Anda baru mulai mempelajari alat-alat tersebut, saya akan mencoba menjelaskan sesuatu dalam artikel ini dan selanjutnya. Oke, dengan perkenalan selesai, sekarang saatnya untuk menyelami kernel Linux dan hal-hal tingkat rendah.
Saya mulai menulis buku ini pada zaman kernel Linux 3.18, dan banyak yang telah berubah sejak saat itu. Jika ada perubahan, saya akan memperbarui artikel yang sesuai.
Tombol daya ajaib, apa selanjutnya?
Meskipun ini adalah artikel tentang kernel Linux, kami belum mencapainya - setidaknya di bagian ini. Segera setelah Anda menekan tombol daya ajaib di laptop atau komputer desktop, itu mulai berfungsi. Motherboard mengirimkan sinyal ke
catu daya . Setelah menerima sinyal, ini memberi komputer jumlah listrik yang diperlukan. Segera setelah motherboard menerima
sinyal "Power OK" , ia mencoba memulai CPU. Dia membuang semua data yang tersisa di register dan menetapkan nilai yang telah ditentukan untuk masing-masing data.
Prosesor
80386 dan versi yang lebih baru harus memiliki nilai berikut di register CPU setelah reboot:
IP 0xfff0
Pemilih CS 0xf000
Basis CS 0xffff0000
Prosesor mulai bekerja dalam
mode nyata . Mari kita kembali sedikit dan mencoba memahami
segmentasi memori dalam mode ini. Mode nyata didukung pada semua prosesor yang kompatibel dengan x86: dari
8086 ke prosesor Intel 64-bit modern. Prosesor 8086 menggunakan bus alamat 20-bit, yaitu, ia dapat bekerja dengan ruang alamat
0-0xFFFFF
atau
1
. Tetapi hanya memiliki register 16-bit dengan alamat maksimum
2^16-1
atau
0xffff
(64 kilobyte).
Segmentasi memori diperlukan untuk menggunakan seluruh ruang alamat yang tersedia. Semua memori dibagi menjadi beberapa segmen kecil dengan ukuran tetap
65536
byte (64 KB). Karena dengan register 16-bit kita tidak dapat mengakses memori di atas 64 KB, metode alternatif dikembangkan.
Alamat terdiri dari dua bagian: 1) pemilih segmen dengan alamat basis; 2) offset dari alamat dasar. Dalam mode nyata, alamat dasar
* 16
segmen
* 16
. Dengan demikian, untuk mendapatkan alamat fisik dalam memori, Anda perlu mengalikan bagian pemilih segmen dengan 16 dan menambahkan offset ke dalamnya:
= * 16 +
Misalnya, jika register
CS:IP
memiliki nilai
0x2000:0x0010
, maka alamat fisik yang sesuai akan seperti ini:
>>> hex((0x2000 << 4) + 0x0010) '0x20010'
Tetapi jika Anda mengambil pemilih dari segmen terbesar dan
0xffff:0xffff
offset
0xffff:0xffff
, Anda mendapatkan alamat:
>>> hex((0xffff << 4) + 0xffff) '0x10ffef'
yaitu,
65520
byte setelah megabyte pertama. Karena hanya satu megabyte yang tersedia dalam mode nyata,
0x10ffef
menjadi
0x00ffef
dengan
garis A20 dinonaktifkan.
Nah, sekarang kita tahu sedikit tentang mode nyata dan pengalamatan memori dalam mode ini. Mari kita kembali ke pembahasan nilai register setelah reset.
Register
CS
terdiri dari dua bagian: pemilih segmen yang terlihat dan alamat basis tersembunyi. Meskipun alamat dasar biasanya dibentuk dengan mengalikan nilai pemilih segmen dengan 16, selama reset perangkat keras, pemilih segmen dalam register CS adalah
0xf000
, dan alamat dasar adalah
0xffff0000
. Prosesor menggunakan alamat basis khusus ini sampai CS berubah.
Alamat awal dibentuk dengan menambahkan alamat basis ke nilai dalam register EIP:
>>> 0xffff0000 + 0xfff0 '0xfffffff0'
Kami mendapatkan
0xfffffff0
, yaitu 16 byte di bawah 4 GB. Titik ini disebut
vektor reset . Ini adalah lokasi di memori di mana CPU menunggu instruksi pertama untuk dieksekusi setelah reset: operasi melompat (
jmp ), yang biasanya menunjukkan titik masuk BIOS. Misalnya, jika Anda melihat kode sumber
coreboot (
src/cpu/x86/16bit/reset16.inc
), kita akan melihat:
.section ".reset", "ax", %progbits .code16 .globl _start _start: .byte 0xe9 .int _start16bit - ( . + 2 ) ...
Di sini kita melihat kode operasi (
opcode )
jmp
, yaitu
0xe9
, dan alamat tujuan
_start16bit - ( . + 2)
.
Kita juga melihat bahwa bagian
reset
adalah 16 byte, dan mengkompilasi untuk dijalankan dari alamat
0xfffff0
(
src/cpu/x86/16bit/reset16.ld
):
SECTIONS { _bogus = ASSERT(_start16bit >= 0xffff0000, "_start16bit too low. Please report."); _ROMTOP = 0xfffffff0; . = _ROMTOP; .reset . : { *(.reset); . = 15; BYTE(0x00); } }
BIOS sekarang mulai; Setelah menginisialisasi dan memeriksa perangkat keras BIOS, Anda perlu menemukan perangkat boot. Urutan boot disimpan dalam konfigurasi BIOS. Ketika mencoba untuk boot dari hard drive, BIOS mencoba untuk menemukan sektor boot. Pada disk yang
dipartisi MBR , sektor boot disimpan di 446 byte pertama dari sektor pertama, di mana setiap sektor adalah 512 byte. Dua byte terakhir dari sektor pertama adalah
0x55
dan
0xaa
. Mereka menunjukkan kepada BIOS bahwa itu adalah perangkat boot.
Sebagai contoh:
; ; : Intel x86 ; [BITS 16] boot: mov al, '!' mov ah, 0x0e mov bh, 0x00 mov bl, 0x07 int 0x10 jmp $ times 510-($-$$) db 0 db 0x55 db 0xaa
Kami mengumpulkan dan menjalankan:
nasm -f bin boot.nasm && qemu-system-x86_64 boot
QEMU menerima perintah untuk menggunakan
boot
binary yang baru saja kita buat sebagai disk image. Karena file biner yang dihasilkan di atas memenuhi persyaratan sektor boot (mulai dari
0x7c00
dan diakhiri dengan urutan ajaib), QEMU akan mempertimbangkan biner sebagai master boot record (MBR) dari image disk.
Anda akan melihat:

Dalam contoh ini, kita melihat bahwa kode berjalan dalam mode nyata 16-bit dan dimulai pada alamat
0x7c00
dalam memori. Setelah memulai, ini menyebabkan interupsi
0x10 , yang hanya mencetak karakter
!
; mengisi sisa 510 byte dengan nol dan diakhiri dengan dua byte ajaib
0xaa
dan
0x55
.
Anda dapat melihat dump biner dengan utilitas
objdump
:
nasm -f bin boot.nasm
objdump -D -b binary -mi386 -Maddr16,data16,intel boot
Tentu saja, di sektor boot nyata, ada kode untuk melanjutkan proses boot dan tabel partisi bukannya sekelompok nol dan tanda seru :). Mulai saat ini, BIOS mentransfer kontrol ke bootloader.
Catatan : seperti dijelaskan di atas, CPU dalam mode nyata; di mana perhitungan alamat fisik dalam memori adalah sebagai berikut:
= * 16 +
Kami hanya memiliki register tujuan umum 16-bit, dan nilai maksimum register 16-bit adalah
0xffff
, sehingga pada nilai terbesar hasilnya adalah:
>>> hex((0xffff * 16) + 0xffff) '0x10ffef'
di mana
0x10ffef
adalah
1 + 64 - 16
.
Prosesor 8086 (
prosesor pertama dengan mode real) memiliki garis alamat 20-bit. Sejak
2^20 = 1048576
, memori yang tersedia sebenarnya adalah 1 MB.
Secara umum, pengalamatan memori mode-nyata adalah sebagai berikut:
0x00000000 - 0x000003FF - tabel vektor interupsi dari mode real
0x00000400 - 0x000004FF - Area data BIOS
0x00000500 - 0x00007BFF - tidak digunakan
0x00007C00 - 0x00007DFF - bootloader kami
0x00007E00 - 0x0009FFFF - tidak digunakan
0x000A0000 - 0x000BFFFF - RAM Video (VRAM)
0x000B0000 - 0x000B7777 - memori video monokrom
0x000B8000 - 0x000BFFFF - memori video mode warna
0x000C0000 - 0x000C7FFF - Video ROM BIOS
0x000C8000 - 0x000EFFFF - area bayangan (BIOS Shadow)
0x000F0000 - 0x000FFFFF - BIOS sistem
Di awal artikel tertulis bahwa instruksi pertama untuk prosesor terletak di
0xFFFFFFF0
, yang jauh lebih dari
0xFFFFF
(1 MB). Bagaimana cara CPU mengakses alamat ini dalam mode nyata? Jawab dalam dokumentasi
coreboot :
0xFFFE_0000 - 0xFFFF_FFFF: 128 ROM
Pada awal eksekusi, BIOS tidak dalam RAM, tetapi dalam ROM.
Bootloader
Kernel Linux dapat dimuat dengan bootloader yang berbeda, seperti
GRUB 2 dan
syslinux . Kernel memiliki protokol boot yang mendefinisikan persyaratan bootloader untuk mengimplementasikan dukungan Linux. Dalam contoh ini, kami bekerja dengan GRUB 2.
Melanjutkan proses booting, BIOS memilih perangkat booting dan memindahkan kontrol ke sektor boot, eksekusi dimulai dengan
boot.img . Karena ukurannya yang terbatas, ini adalah kode yang sangat sederhana. Ini berisi pointer untuk menuju ke gambar GRUB 2. Dimulai dengan
diskboot.img dan biasanya disimpan segera setelah sektor pertama di ruang yang tidak digunakan sebelum partisi pertama. Kode di atas memuat ke memori sisa gambar yang berisi kernel GRUB 2 dan driver untuk memproses sistem file. Setelah itu, fungsi
grub_main dijalankan .
Fungsi
grub_main
menginisialisasi konsol, mengembalikan alamat dasar untuk modul, mengatur perangkat root, memuat / mem-parsing file konfigurasi grub, memuat modul, dll. Pada akhir eksekusi, itu menempatkan grub ke mode normal. Fungsi
grub_normal_execute
(dari file sumber
grub-core/normal/main.c
) menyelesaikan persiapan terakhir dan menampilkan menu untuk memilih sistem operasi. Ketika kita memilih salah satu item menu grub, fungsi
grub_menu_execute_entry
, yang mengeksekusi perintah
boot
grub dan memuat OS yang dipilih.
Sebagaimana ditunjukkan dalam protokol boot kernel, bootloader harus membaca dan mengisi beberapa bidang header instalasi kernel, yang dimulai dengan offset
0x01f1
dari kode instalasi kernel. Offset ini ditunjukkan dalam
skrip tautan.
Lengkungan header kernel
/ x86 / boot / header. Dimulai dengan:
.globl hdr hdr: setup_sects: .byte 0 root_flags: .word ROOT_RDONLY syssize: .long 0 ram_size: .word 0 vid_mode: .word SVGA_MODE root_dev: .word 0 boot_flag: .word 0xAA55
Bootloader harus mengisi ini dan header lainnya (yang ditandai hanya sebagai tipe
write
di protokol boot Linux, seperti dalam contoh ini) dengan nilai-nilai yang diterima dari baris perintah atau dihitung pada saat boot. Sekarang kita tidak akan membahas deskripsi dan penjelasan untuk semua bidang header. Kami akan membahas nanti bagaimana kernel menggunakannya. Untuk deskripsi semua bidang, lihat
protokol unduhan .
Seperti yang Anda lihat dalam protokol boot kernel, memori akan ditampilkan sebagai berikut:
| Mode Kernel yang Dilindungi |
100000 + ------------------------ +
| Pemetaan I / O |
0A0000 + ------------------------ +
| Cadangan untuk BIOS | Biarkan sebanyak mungkin gratis
~ ~
| Baris perintah | (mungkin juga di bawah X + 10.000)
X + 10000 + ------------------------ +
| Tumpukan / tumpukan | Untuk menggunakan kode mode kernel nyata
X + 08000 + ------------------------ +
| Instalasi Kernel | Kode mode nyata kernel
| Sektor boot kernel | Sektor boot kernel lawas
X + ------------------------ +
| Loader | <- Titik masuk sektor boot 0x7C00
001000 + ------------------------ +
| Cadangan untuk MBR / BIOS |
000800 + ------------------------ +
| Biasanya digunakan MBR |
000600 + ------------------------ +
| Digunakan Hanya BIOS |
000000 + ------------------------ +
Jadi, ketika loader mentransfer kontrol ke kernel, itu dimulai dengan alamat:
X + sizeof (KernelBootSector) + 1
di mana
X
adalah alamat sektor boot kernel. Dalam kasus kami,
X
adalah
0x10000
, seperti yang terlihat di memori dump:

Bootloader memindahkan kernel Linux ke memori, mengisi bidang header, dan kemudian pindah ke alamat memori yang sesuai. Sekarang kita dapat langsung menuju ke kode instalasi kernel.
Awal dari fase instalasi kernel
Akhirnya kita berada di intinya! Meskipun secara teknis belum berjalan. Pertama, bagian instalasi kernel perlu mengkonfigurasi sesuatu, termasuk dekompresor dan beberapa hal dengan manajemen memori. Setelah semua ini, dia akan membongkar inti yang sebenarnya dan pergi ke sana. Instalasi dimulai pada
arch / x86 / boot / header. Dengan karakter
_start .
Pada pandangan pertama, ini mungkin tampak sedikit aneh, karena ada beberapa instruksi di depannya. Tapi dulu sekali, kernel Linux memiliki bootloader sendiri. Sekarang jika Anda menjalankan, misalnya,
qemu-system-x86_64 vmlinuz-3.18-generic
Anda akan melihat:

Sebenarnya, file
header.S
dimulai dengan angka ajaib
MZ (lihat screenshot dump di atas), teks pesan kesalahan dan header
PE :
#ifdef CONFIG_EFI_STUB # "MZ", MS-DOS header .byte 0x4d .byte 0x5a #endif ... ... ... pe_header: .ascii "PE" .word 0
Diperlukan untuk memuat sistem operasi dengan dukungan
UEFI . Kami akan mempertimbangkan perangkatnya dalam bab-bab berikut.
Titik masuk aktual untuk menginstal kernel:
Bootloader (grub2 dan lainnya) tahu tentang hal ini (mengimbangi
0x200
dari
MZ
) dan langsung menuju ke sana, meskipun
header.S
dimulai dari bagian
.bstext
, di mana teks pesan kesalahan berada:
// // arch/x86/boot/setup.ld // . = 0; // current position .bstext : { *(.bstext) } // put .bstext section to position 0 .bsdata : { *(.bsdata) }
Titik masuk instalasi kernel:
.globl _start _start: .byte 0xeb .byte start_of_setup-1f 1:
Di sini kita melihat kode operasi
jmp
(
0xeb
), yang menuju ke titik
start_of_setup-1f
. Dalam notasi
Nf
, misalnya,
2f
mengacu pada label lokal
2:
Dalam kasus kami, ini adalah label
1
, yang hadir segera setelah transisi, dan berisi sisa header pengaturan. Segera setelah header instalasi, kita melihat bagian
.entrytext
, yang dimulai dengan label
start_of_setup
.
Ini adalah kode pertama yang sebenarnya dieksekusi (selain instruksi lompatan sebelumnya, tentu saja). Setelah bagian dari instalasi kernel menerima kontrol dari loader, instruksi
jmp
pertama terletak pada offset
0x200
dari awal mode kernel sebenarnya, yaitu, setelah 512 byte pertama. Ini dapat dilihat di protokol boot kernel Linux dan dalam kode sumber grub2:
segment = grub_linux_real_target >> 4; state.gs = state.fs = state.es = state.ds = state.ss = segment; state.cs = segment + 0x20;
Dalam kasus kami, kernel melakukan boot pada alamat
0x10000
. Ini berarti bahwa setelah memulai instalasi kernel, register segmen akan memiliki nilai-nilai berikut:
gs = fs = es = ds = ss = 0x10000
cs = 0x10200
Setelah memulai
start_of_setup
kernel harus melakukan hal berikut:
- Pastikan semua nilai register segmen sama
- Jika perlu, konfigurasikan tumpukan yang benar
- Konfigurasikan bss
- Masuk ke kode C di arch / x86 / boot / main.c
Mari kita lihat bagaimana ini diterapkan.
Penjajaran Kasus Segmen
Pertama-tama, kernel memeriksa apakah register segmen
ds
dan
es
menunjuk ke alamat yang sama. Itu kemudian membersihkan bendera arah menggunakan
cld
:
movw %ds, %ax movw %ax, %es cld
Seperti yang saya tulis sebelumnya, grub2 secara default memuat kode instalasi kernel pada
0x10000
, dan
cs
pada
0x10200
, karena eksekusi tidak dimulai dari awal file, tetapi dari transisi di sini:
_start: .byte 0xeb .byte start_of_setup-1f
Ini adalah offset
512
byte dari
4d 5a . Juga perlu untuk menyelaraskan
cs
dari
0x10200
ke
0x10000
, seperti semua register segmen lainnya. Setelah itu instal tumpukan:
pushw %ds pushw $6f lretw
Instruksi ini mendorong nilai
ds
ke stack, diikuti oleh alamat label
6 dan instruksi
lretw
, yang memuat alamat label
6
ke dalam register
penghitung perintah dan memuat
cs
dengan nilai
ds
. Setelah itu,
ds
dan
cs
akan memiliki nilai yang sama.
Pengaturan tumpukan
Hampir semua kode ini adalah bagian dari proses mempersiapkan lingkungan C dalam mode nyata. Langkah selanjutnya adalah memeriksa nilai register
ss
dan membuat tumpukan yang benar jika nilai
ss
salah:
movw %ss, %dx cmpw %ax, %dx movw %sp, %dx je 2f
Ini dapat memicu tiga skenario berbeda:
ss
nilai valid 0x1000
(seperti dengan semua register lain kecuali cs
)ss
nilai yang tidak valid dan bendera CAN_USE_HEAP
diatur (lihat di bawah)ss
nilai yang tidak valid dan bendera CAN_USE_HEAP
tidak disetel (lihat di bawah)
Pertimbangkan semua skenario secara berurutan:
ss
nilai yang valid ( 0x1000
). Dalam hal ini, kita pergi ke label 2:
2: andw $~3, %dx jnz 3f movw $0xfffc, %dx 3: movw %ax, %ss movzwl %dx, %esp sti
Di sini kita mengatur perataan register
dx
(yang berisi nilai
sp
ditunjukkan oleh bootloader) menjadi
4
byte dan memeriksa nol. Jika nol, maka kita masukkan nilai
0xfffc
dx
(alamat selaras
4
byte sebelum ukuran segmen maksimum 64 KB). Jika tidak sama dengan nol, maka kami terus menggunakan nilai
sp
ditentukan oleh bootloader (
0xf7f4
dalam kasus kami). Kemudian kami menempatkan nilai
ax
dalam
ss
, yang menyimpan alamat segmen yang benar
0x1000
dan menetapkan
sp
benar. Sekarang kita memiliki tumpukan yang benar:

- Dalam skenario kedua,
ss != ds
. Pertama-tama kita meletakkan nilai _end (alamat akhir kode instalasi) dalam dx
dan memeriksa loadflags
field header, menggunakan instruksi testb
untuk memeriksa apakah heap dapat digunakan. loadflags adalah tajuk bitmask yang didefinisikan sebagai berikut:
#define LOADED_HIGH (1<<0) #define QUIET_FLAG (1<<5) #define KEEP_SEGMENTS (1<<6) #define CAN_USE_HEAP (1<<7)
dan sebagaimana ditunjukkan dalam protokol boot:
: loadflags
.
7 (): CAN_USE_HEAP
1, ,
heap_end_ptr . ,
.
Jika bit
CAN_USE_HEAP
, maka dalam
dx
kami menetapkan nilai
heap_end_ptr
(yang menunjuk ke
_end
) dan menambahkan
STACK_SIZE
ke sana (ukuran tumpukan minimum adalah
1024
byte). Setelah itu, buka label
2
(seperti pada kasus sebelumnya) dan buat tumpukan yang benar.

- Jika
CAN_USE_HEAP
tidak disetel, cukup gunakan tumpukan minimum dari _end
ke _end + STACK_SIZE
:

Penyiapan BSS
Dua langkah lagi diperlukan sebelum beralih ke kode C utama: ini mengatur
area BSS dan memverifikasi tanda tangan "ajaib". Verifikasi tanda tangan terlebih dahulu:
cmpl $0x5a5aaa55, setup_sig jne setup_bad
Instruksi hanya membandingkan
setup_sig dengan angka ajaib 0x5a5aaa55. Jika mereka tidak sama, kesalahan fatal dilaporkan.
Jika angka ajaib cocok dan kami memiliki satu set register segmen dan tumpukan yang benar, maka yang tersisa hanyalah mengonfigurasi bagian BSS sebelum melanjutkan ke kode C.
Bagian BSS digunakan untuk menyimpan data yang tidak diinisialisasi yang dialokasikan secara statis. Linux dengan hati-hati memeriksa apakah area memori ini diatur ulang:
movw $__bss_start, %di movw $_end+3, %cx xorl %eax, %eax subw %di, %cx shrw $2, %cx rep; stosl
Pertama, alamat awal
__bss_start dipindahkan ke
di
. Kemudian alamat
_end + 3
(+3 untuk perataan dengan 4 byte) dipindahkan ke
cx
. Register
eax
dihapus (menggunakan instruksi
xor
), ukuran partisi bss (
cx-di
) dihitung, dan ditempatkan di
cx
. Kemudian
cx
dibagi menjadi empat (ukuran "kata") dan instruksi
stosl
digunakan
stosl
, menyimpan nilai
(nol) di alamat yang menunjuk ke
di
, secara otomatis meningkatkan
di
oleh empat dan mengulanginya sampai
mencapai nol). Efek bersih dari kode ini adalah nol dituliskan ke semua kata dalam memori dari
__bss_start
ke
_end
:

Pergi ke utama
Itu saja: kami memiliki tumpukan dan BSS, sehingga Anda dapat pergi ke fungsi C
main()
:
calll main
Fungsi
main()
terletak di
arch / x86 / boot / main.c. Kami akan membicarakannya di bagian selanjutnya.
Kesimpulan
Ini adalah akhir dari bagian pertama tentang perangkat kernel Linux.
Jika Anda memiliki pertanyaan atau saran, hubungi saya di Twitter , melalui surat atau cukup buat tiket . Pada bagian berikutnya kita akan melihat kode pertama di C, yang dilakukan selama instalasi Linux kernel, pelaksanaan memori sub-program, seperti memset
, memcpy
, earlyprintk
, awal implementasi dan inisialisasi dari konsol, dan banyak lagi.Referensi