How to write your own Operating System for Raspberry Pi (AArch64): Booting
Πως να γράψετε το δικό σας Λειτουργικό Σύστημα για Raspberry Pi (AArch64): Εκκίνηση
IntroductionΕισαγωγή
In this article, we will show how we can write our own code in ARM64 assembly (AArch64) and run it bare-metal in Raspberry Pi during the boot process. The code will send "Hello world!" messages to our PC via a connection with the serial UART of Raspberry Pi.
Σε αυτό το άρθρο, θα δείξουμε πώς μπορούμε να γράψουμε τον δικό μας κώδικα σε ARM64 assembly (AArch64) και να τον εκτελέσουμε απευθείας σε Raspberry Pi κατά τη διαδικασία εκκίνησης. Ο κώδικας θα στείλει μηνύματα "Hello world!" στον υπολογιστή μας μέσω μιας σύνδεσης με το σειριακό UART του Raspberry Pi.
Boot processΔιαδικασία εκκίνησης
The Raspberry Pi boot process is somewhat complex and unique compared to that of traditional x86 PCs:
Η διαδικασία εκκίνησης του Raspberry Pi είναι κάπως σύνθετη και ιδιαίτερη σε σύγκριση με αυτήν των παραδοσιακών υπολογιστών x86:
Power On → Boot ROM (Internal) → Bootloader (Storage / EEPROM / SPI Flash) → GPU Firmware → Kernel Load → OS Boot
Τροφοδοσία → ROM εκκίνησης (Εσωτερική) → Εκκινητής (Αποθηκευτικό μέσο / EEPROM / SPI Flash) → Λογισμικό υλικού της GPU → Φόρτωση Πυρήνα → Εκκίνηση Λειτουργικού
- When you apply power, the SoC (System on Chip) starts executing code either from a ROM built into the chip (Pi 3 and 4) or from SPI Flash (Pi 5).
 - This code then initializes minimal hardware and loads the main bootloader (bootcode.bin), either from a SD/USB FAT32 partition (Pi 3) or from EEPROM (Pi 4) or from SPI Flash (Pi 5).
 - The main bootloader then initializes SDRAM and other peripherals and loads the GPU firmware (start.elf or start4.elf for Pi 4 and later).
 - The GPU firmware sets up clocks, graphics and other things and loads the configuration (config.txt) and the kernel image (kernel7.img for 32-bit or kernel8.img for 64-bit). Optionally, it can also load Device Tree Blob files (*.dtb) that describe the hardware.
 - Finally, the CPU starts executing the kernel image.
 
- Όταν συνδέσετε την τροφοδοσία, το SoC (System on Chip) αρχίζει να εκτελεί κώδικα είτε από τη ROM που είναι ενσωματωμένη στο τσιπ (Pi 3 και 4) είτε από τη μνήμη SPI Flash (Pi 5).
 - Αυτός ο κώδικας στη συνέχεια αρχικοποιεί το ελάχιστο απαραίτητο υλικό και φορτώνει τον κύριο εκκινητή (bootcode.bin), είτε από SD/USB FAT32 (Pi 3), είτε από EEPROM (Pi 4), είτε από SPI Flash (Pi 5).
 - Ο κύριος εκκινητής έπειτα αρχικοποιεί τη μνήμη SDRAM και άλλες περιφερειακές συσκευές και φορτώνει το λογισμικό υλικού της GPU (start.elf ή start4.elf για Pi 4 και νεότερα).
 - Το λογισμικό υλικού της GPU ρυθμίζει τα ρολόγια, τα γραφικά και άλλα στοιχεία και φορτώνει το αρχείο ρυθμίσεων (config.txt) και το αρχείο του πυρήνα (kernel7.img για 32-bit ή kernel8.img για 64-bit). Προαιρετικά, μπορεί επίσης να φορτώσει αρχεία Device Tree Blob (*.dtb) που περιγράφουν το υλικό.
 - Τέλος, η ΚΜΕ (CPU) αρχίζει να εκτελεί την εικόνα του πυρήνα.
 
CodeΚώδικας
A note of cautionΣυμβουλές
When programming in ARM64 (AArch64) assembly, it is important to exercise caution regarding both control flow and data alignment.
Κατά τον προγραμματισμό σε assembly ARM64 (AArch64), είναι σημαντικό να δείχνουμε προσοχή τόσο στη ροή ελέγχου όσο και στην ευθυγράμμιση των δεδομένων.
Unlike x86, where the return address is automatically pushed onto the stack by the CALL instruction, ARM uses the link register (x30 a.k.a. lr) to store the return address. This means that each BL (Branch with Link) overwrites the previous value of x30. If a function makes another BL call without first saving the content of the link register (for example, by pushing it onto the stack), the original return address will be lost forever, causing return to a wrong place.
Σε αντίθεση με την αρχιτεκτονική x86, όπου η διεύθυνση επιστροφής αποθηκεύεται αυτόματα στη στοίβα από την εντολή CALL, στην ARM χρησιμοποιείται ο καταχωρητής σύνδεσης (x30 ή αλλιώς lr) για την αποθήκευση της διεύθυνσης επιστροφής. Αυτό σημαίνει ότι κάθε εντολή BL (Branch with Link) αντικαθιστά την προηγούμενη τιμή του x30. Αν μια συνάρτηση εκτελέσει μια κλήση BL χωρίς πρώτα να αποθηκεύσει το περιέχομενο του καταχωρητή σύνδεσης (για παράδειγμα, τοποθετώντας τον στη στοίβα), η αρχική διεύθυνση επιστροφής θα χαθεί για πάντα, οδηγώντας σε επιστροφή σε λάθος μέρος.
Be also mindful of data alignment requirements when accessing memory. Many load and store instructions require the addresses to be aligned to specific boundaries (e.g. LDHR, load halfword, needs a 2-byte alignment). Misaligned accesses can cause alignment faults or degrade performance. Ensuring proper alignment of data structures and using alignment directives (.align) where necessary helps maintain both correctness and efficiency.
Επίσης, πρέπει να δίνεται προσοχή στις απαιτήσεις ευθυγράμμισης δεδομένων κατά την πρόσβαση στη μνήμη. Πολλές εντολές φόρτωσης και αποθήκευσης απαιτούν οι διευθύνσεις να είναι ευθυγραμμισμένες σε συγκεκριμένα όρια (λ.χ. η εντολή LDHR, load halfword, χρειάζεται ευθυγράμμιση στα 2 bytes). Οι μη ευθυγραμμισμένες προσπελάσεις μπορεί να προκαλέσουν σφάλματα ή να μειώσουν την απόδοση. Η εξασφάλιση σωστής ευθυγράμμισης των δομών δεδομένων και η χρήση οδηγιών ευθυγράμμισης (.align) όπου είναι απαραίτητο, βοηθά στη διατήρηση τόσο της ορθότητας όσο και της αποδοτικότητας.
Makefile
For this project, we are going to use the GNU Assembler (GAS). Our initial Makefile is like that:
Για αυτό το έργο, θα χρησιμοποιήσουμε τον GNU Assembler (NASM). Το αρχικό μας Makefile είναι ως εξής:
.PHONY: clean, .force-rebuild
all: kernel8.img
kernel8.img: boot.S uart.S .force-rebuild
	aarch64-linux-gnu-as -o boot.o boot.S
	aarch64-linux-gnu-as -o uart.o uart.S
	aarch64-linux-gnu-ld -T linker.ld -o kernel.elf boot.o uart.o
	aarch64-linux-gnu-objcopy -O binary kernel.elf kernel8.img
clean:
	rm *.o
	rm *.elf
	rm *.img
linker.ld
The contents of linker.ld are:
Τα περιεχόμενα του linker.ld είναι:
/* Linker Script: Defines the memory layout */
ENTRY(_start)
SECTIONS {
  . = 0x80000; /* The kernel code must start at the address 0x80000 for Raspberry Pi. */
  .text : { *(.text) } :read_execute_segment  /* Code goes here. */
  .rodata : { *(.rodata) } :read_only_segment /* Initialized read-only data go here. */
  .data : { *(.data) } :read_write_segment    /* Initialized data go here. */
  /* BSS Section: Uninitialized data (like the stack) go here. */
  /* This section is ONLY reserved in memory; nothing is written to the output file. */
  .bss : {
     . = ALIGN(16);
     . = . + 0x10000;  /* Reserve 64KB for the stack */
     __stack_top = .;  /* Set the label __stack_top to the end of the reserved space */
   } :read_write_segment
  /* /DISCARD/ section: Get rid of any unused/unwanted sections */
  /DISCARD/ : {
    *(.note.gnu.build-id)
    *(.ARM.exidx)
  }
}
/* Define the loadable segments and set their permissions */
PHDRS { 
  read_execute_segment PT_LOAD FLAGS(5); /* R + X */
  read_only_segment    PT_LOAD FLAGS(4); /* R */
  read_write_segment   PT_LOAD FLAGS(6); /* R + W */   
}boot.S
Let's see the booting code:
Ας δούμε τον κώδικα εκκίνησης:
/*---Initialized-data---------------------------------------------------------*/
.section ".rodata"
.align 2   // ARM instructions needs proper data alignment otherwise they fault.
hello_msg: .short 14                  // The length of the string (16 bits). 
           .ascii "Hello, world!\n"   // We don't use null-terminated strings.
/*---Code---------------------------------------------------------------------*/
.section ".text"
.global _start
_start:
                     /* Read this CPU core's ID */
mrs x1, MPIDR_EL1    // Read Multiprocessor Affinity Register.
and x1, x1, #3       // Mask to get just the CPU core ID (0-3).
cbz x1, core0        // If ID is 0, branch to core0.
b halt               // If not Core 0, put it to sleep.
core0:
    ldr x1, =__stack_top
    mov sp, x1             // We set our stack pointer to __stack_top address
    mov x2, 5              // Counter = 5 (i.e. send the message five times)
main:
    ldr x20, =hello_msg    // Load the address of the message into x20
    bl uart_puts           // Send the message to UART
    subs x2, x2, #1        // Decrease counter
    bne main               // Loop until length counter = 0
halt:        /* Infinite loop. */
    wfe      // Wait For Event: This is a low-power sleep/halt instruction.
    b halt   // It prevents us from going off in memory and executing junk.
/*---Uninitialized-data-------------------------------------------------------*/
.section ".bss"    // We allocate stack space in this section (in linker.ld).uart.S
Here is our code for sending messages via the serial UART. For the time being, you have to set the correct address for the PERIPH_BASE based on your Pi model manually (in the future we will be able to detect it automatically). Also note that we do not use null-terminated strings. Instead, we opted to store the string length in the first 16 bits (2 bytes) of the string structure.
Εδώ έχουμε τον κώδικά μας για την αποστολή μηνυμάτων μέσω του σειριακού UART. Για την ώρα, πρέπει να θέσετε χειροκίνητα τη σωστή διεύθυνση για την PERIPH_BASE ανάλογα το μοντέλο Pi που έχετε (στο μέλλον θα μπορούμε να την ανιχνεύουμε αυτόματα). Σημειώστε επίσης, ότι δεν χρησιμοποιούμε το '\0' για να δηλώσουμε το τέλος των αλφαριθμητικών σειρών χαρακτήρων. Αντίθετα, επιλέξαμε να αποθηκεύουμε τον αριθμό των χαρακτήρων στα πρώτα 16 bits (2 bytes) της δομής της σειράς χαρακτήρων.
/*---Constants----------------------------------------------------------------*/
// Raspberry Pi 3B+
.equ PERIPH_BASE, 0x3F000000      /*  <--- Uncomment this for Pi 3  */
// Raspberry Pi 4B
//.equ PERIPH_BASE, 0xFE000000    /*  <--- Uncomment this for Pi 4  */
.equ UART0_BASE, (PERIPH_BASE + 0x201000)   // UART base address
.equ UART0_DR, (UART0_BASE + 0x00)          // UART Data Register 
.equ UART0_FR, (UART0_BASE + 0x18)          // UART Flag Register
.equ TXFF_BIT, (1 << 5)                     // Transmit FIFO Full bit
/*---Code---------------------------------------------------------------------*/
uart_puts:
/******************************************************************************/
/* Sends a string to UART (PL011).                                            */
/******************************************************************************/
/* x20: The address of the string                                             */
/******************************************************************************/
    stp lr, xzr, [sp, #-16]!   // Store Link Register (has the return address)
    stp x21, x22, [sp, #-16]!  // Store registers that will be used
    ldrh w21, [x20], #2        // Load length into w21 (needs 2-byte alignment!)
 1: ldrb w22, [x20], #1        // Load a byte into w22 (then increase x20 by 1)
    bl uart_putc               // Call uart_putc (Link Register will be written)
    subs w21, w21, #1          // Decrease string length counter
    bne 1b                     // Loop until string length counter = 0
    ldp x21, x22, [sp], #16    // Restore used registers
    ldp lr, xzr, [sp], #16     // Restore Link Register (has the return address)
    ret
uart_putc:
/******************************************************************************/
/* Waits for UART (PL011) to be ready, then writes one character.             */
/******************************************************************************/
/* w22: The character (byte) to print.                                        */
/******************************************************************************/
    stp x23, x24, [sp, #-16]!   // Store registers that will be used
    ldr x23, =UART0_FR    // Load UART Flag Register address into register x23.
 1: ldr w24, [x23]        // Read Flag Register value into register w24.
    tst w24, #TXFF_BIT    // Test if "Transmit FIFO Full" bit is set.
    bne 1b                // If bit is set (UART is not ready), loop.
    ldr x23, =UART0_DR    // Load UART Data Register address into register x23.
    strb w22, [x23]       // Send our character byte to the Data Register
    ldp x23, x24, [sp], #16     // Restore used registers
    retRunning our codeΕκτέλεση του κώδικά μας
Emulation
We can test our code very easily using an emulator like QEMU:
Μπορούμε να δοκιμάσουμε τον κώδικά μας πολύ εύκολα, χρησιμοποιώντας έναν εξομοιωτή όπως ο QEMU:
$ make
$ qemu-system-aarch64 -M raspi3b -kernel kernel8.img -serial mon:stdio
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Real Hardware
If we want to run our code in a real Raspberry Pi, we have to prepare a boot medium (SD/USB) for Pi and connect a USB-to-TTL converter to Pi's UART to get serial output via the UART.
Αν θέλουμε να εκτελέσουμε τον κώδικά μας σε ένα πραγματικό Raspberry Pi, θα πρέπει να προετοιμάσουμε ένα μέσο εκκίνησης (SD/USB) για το Pi και να συνδέσουμε ένα μετατροπέα USB-to-TTL στο UART του Pi για να πάρουμε σειριακή έξοδο μέσω του UART.
SD/USB structure for Raspberry Pi bootΔομή SD/USB για εκκίνηση του Raspberry Pi
Here is the file structure of the boot medium (SD card or USB medium). Take note that the partition should be FAT32 and that the files have to reside inside the /boot directory
Εδώ είναι η αρχειακή δομή του μέσου εκκίνησης (SD κάρτα ή USB μέσο). Προσέξτε ότι η διαμόρφωση πρέπει να ειναι FAT32 και ότι τα αρχεία πρέπει να βρίσκονται μέσα στον φάκελο /boot:
/boot
├─ config.txt          <-- Boot configuration (required)
├─ kernel?.img         <-- Your kernel (required)
├─ start?.elf          <-- GPU firmware (required)
├─ bootcode.bin        <-- Bootloader (required only for Pi 1 / Zero)
└─ *.dtb               <-- Device Tree Blob (optional)
config.txt
For our purpose, the contents of config.txt should be as follows:
Για τον σκοπό μας, τα περιεχόμενα του config.txt πρέπει να είναι ως εξής:
arm_64bit=1                 # Boot to 64-bit mode
dtoverlay=pi3-disable-bt    # Disable bluetooth (to free UART)
enable_uart=1               # Enable UART
os_check=0                  # Don't check OS compatibityUSB-to-TTL (UART) converter
In order to get some kind of early output from our bare-metal code, we need to connect to the Raspberry Pi's UART using a USB to TTL converter. Most of them supports 3.3V and 5V for the digital logic (usually selectable through a jumper or switch). Be careful to set the correct digital logic voltage (that is 3.3V for Raspberry Pi), before connecting it! Otherwise it will not work and you may cause damage to your Pi.
Για να λάβουμε κάποιου είδους πρώιμη έξοδο από τον κώδικά μας, θα πρέπει να συνδεθούμε στο UART του Raspberry Pi χρησιμοποιώντας έναν μετατροπέα USB σε TTL. Οι περισσότεροι από αυτούς υποστηρίζουν 3.3V και 5V για τη ψηφιακή λογική (συνήθως επιλέγεται μέσω jumper ή διακόπτη). Προσέξτε να ρυθμίσετε τη σωστή τάση ψηφιακής λογικής (δηλαδή 3.3V για το Raspberry Pi), πριν τo συνδέσετε! Αλλιώς δε θα δουλέψει και μπορεί να προκαλέσετε ζημιά στο Pi σας.
In most cases, we need to connect only three pins (note that we connect TX to RX and RX to TX):
Στις περισσότερες περιπτώσεις, χρειάζεται να συνδέσουμε μόνο τρεις ακροδέκτες (σημειώστε ότι συνδέουμε το TX με το RX και το RX με το TX):
- TTL_GND <---> GND (Pin 6: Common ground)
 - TTL_RXD <---> UART_TXD (Pin 8: GPIO14)
 - TTL_TXD <---> UART_RXD (Pin 10: GPIO15)
 
Then, in order to communicate, we need to run a tool on the PC (e.g. picocom):
Έπειτα, για να επικοινωνήσουμε, πρέπει να τρέξουμε στον υπολογιστή ένα εργαλείο (όπως το λ.χ. το picocom):
$ picocom -b 115200 /dev/ttyUSB0
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Full codeΠλήρης κώδικας
You can download the full code from here: code
Μπορείτε να κατεβάσετε τον πλήρη κώδικα απο εδώ: κώδικας