Boot kernel Linux. Bagian 1

Dari bootloader ke kernel

Jika 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 { /* Trigger an error if I have an unuseable start address */ _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:

 // header.S line 292 .globl _start _start: 

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: // // rest of the header // 

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


Source: https://habr.com/ru/post/id428664/


All Articles