
Entonces, directo al grano. Escribiremos bajo Linux, en NASM y usando QEMU. Esto es fácil de instalar, así que omita este paso.
Se entiende que el lector está familiarizado con la sintaxis de NASM al menos en el nivel básico (sin embargo, no habrá nada particularmente complicado aquí) y entiende qué son los registros.
Teoría básica
Lo primero que inicia el procesador cuando la computadora está encendida es el código del BIOS (o UEFI, pero aquí solo hablaré sobre el BIOS), que está "conectado" en la memoria de la placa base (específicamente, en 0xFFFFFFF0).
Inmediatamente después de encender el BIOS, se inicia la autoprueba de encendido (POST), autocomprobación después de encender. El BIOS verifica el estado de la memoria, detecta e inicializa los dispositivos conectados, verifica los registros, determina el tamaño de la memoria, y así sucesivamente.
El siguiente paso es identificar el disco de inicio desde el que puede iniciar el sistema operativo. Un disco de arranque es un disco (o cualquier otra unidad) que tiene los últimos 2 bytes del primer sector (el primer sector significa los primeros 512 bytes de la unidad, porque 1 sector = 512 bytes) es 55 y AA (en formato hexadecimal). Tan pronto como se encuentre un disco de arranque, el BIOS cargará sus primeros 512 bytes en la RAM en la dirección 0x7c00 y transferirá el control al procesador en esta dirección.
Por supuesto, en estos 512 bytes no funcionará para adaptarse a un sistema operativo completo. Por lo tanto, generalmente en este sector coloque el cargador primario, que carga el código del sistema operativo principal en la RAM y le transfiere el control.
Desde el principio, el procesador ha estado funcionando en modo real (= modo de 16 bits). Esto significa que solo puede funcionar con datos de 16 bits y utiliza direccionamiento de memoria segmentado, y también solo puede direccionar 1 MB de memoria. Pero no usaremos el segundo aquí. La siguiente imagen muestra el estado de la RAM al transferir el control a nuestro código (la imagen se toma desde aquí ).

Lo último que hay que decir antes de la parte práctica son las interrupciones. Una interrupción es una señal especial (por ejemplo, desde un dispositivo de entrada, como un teclado o mouse) a un procesador que dice que es necesario interrumpir inmediatamente la ejecución del código actual y ejecutar el código del controlador de interrupción. Todas las direcciones de los manejadores de interrupciones se encuentran en la Tabla de descriptores de interrupción (IDT) en la memoria principal. Cada interrupción tiene su propio manejador de interrupciones. Por ejemplo, cuando se presiona una tecla del teclado, se llama a una interrupción, el procesador se detiene, recuerda la dirección de la instrucción interrumpida, guarda todos los valores de sus registros (en la pila) y procede a ejecutar el controlador de interrupción. Tan pronto como finaliza su ejecución, el procesador restaura los valores de los registros y vuelve a la instrucción interrumpida y continúa la ejecución.
Por ejemplo, para mostrar algo en la pantalla, el BIOS usa la interrupción 0x10 (formato hexadecimal), y la interrupción 0x16 se usa para esperar que se presione una tecla. De hecho, estas son todas las interrupciones que necesitaremos aquí.
Además, cada interrupción tiene su propia subfunción que determina la peculiaridad de su comportamiento. Para mostrar algo en el formato de texto (!), Debe ingresar el valor 0x0e en el registro AH. Además, las interrupciones tienen sus propios parámetros. 0x10 toma valores de ah (define una subfunción específica) y al (el carácter a imprimir). De esta manera
mov ah, 0x0e mov al, 'x' int 0x10
muestra el caracter 'x'. 0x16 toma el valor de ah (subfunción específica) y carga el valor de la clave ingresada en el registro al. Utilizaremos la función 0x0.
Parte práctica
Comencemos con el código auxiliar. Necesitaremos la función de comparar dos líneas y la función de mostrar una línea en la pantalla. Traté de describir el funcionamiento de estas funciones en los comentarios lo más claramente posible.
str_compare.asm:
compare_strs_si_bx: push si ; push bx push ax comp: mov ah, [bx] ; , cmp [si], ah ; ah jne not_equal ; , cmp byte [si], 0 ; , je first_zero ; inc si ; bx si inc bx jmp comp ; first_zero: cmp byte [bx], 0 ; bx != 0, , jne not_equal ; , not_equal mov cx, 1 ; , cx = 1 pop si ; pop bx pop ax ret ; not_equal: mov cx, 0 ; , cx = 0 pop si ; pop bx pop ax ret ;
La función acepta los registros SI y BX como parámetros. Si las líneas son iguales, entonces CX se establece en 1, de lo contrario 0.
También vale la pena señalar que los registros AX, BX, CX y DX se dividen en dos partes de un solo byte: AH, BH, CH y DH para el byte alto, y AL, BL, CL y DL para el byte bajo.
Inicialmente, se supone que en bx y si hay punteros (!) (Es decir, almacena la dirección en la memoria) a alguna dirección en la memoria en la que se encuentra el comienzo de la línea. La operación [bx] tomará un puntero de bx, irá a esta dirección y tomará algún valor desde allí. inc bx significa que ahora el puntero se referirá a la dirección inmediatamente después de la dirección original.
print_string.asm:
print_string_si: push ax ; ax mov ah, 0x0e ; ah 0x0e, call print_next_char ; pop ax ; ax ret ; print_next_char: mov al, [si] ; cmp al, 0 ; si jz if_zero ; int 0x10 ; al inc si ; jmp print_next_char ; ... if_zero: ret
Como parámetro, la función toma el registro SI y byte a byte imprime una cadena.
Ahora pasemos al código principal. Primero, definamos todas las variables (este código estará al final del archivo):
; 0x0d - , 0xa - wrong_command: db "Wrong command!", 0x0d, 0xa, 0 greetings: db "The OS is on. Type 'help' for commands", 0x0d, 0xa, 0xa, 0 help_desc: db "Here's nothing to show yet. But soon...", 0x0d, 0xa, 0 goodbye: db 0x0d, 0xa, "Goodbye!", 0x0d, 0xa, 0 prompt: db ">", 0 new_line: db 0x0d, 0xa, 0 help_command: db "help", 0 input: times 64 db 0 ; - 64 times 510 - ($-$$) db 0 dw 0xaa55
El carácter de retorno de carro mueve el carro al borde izquierdo de la pantalla, es decir, al comienzo de la línea.
input: times 64 db 0
significa que asignamos 64 bytes bajo el búfer para entrada y los llenamos con ceros.
El resto de las variables son necesarias para mostrar cierta información, más adelante en el código comprenderá por qué son necesarias.
times 510 - ($-$$) db 0 dw 0xaa55
significa que establecemos explícitamente el tamaño del archivo de salida (con la extensión .bin) en 512 bytes, llenamos los primeros 510 bytes con ceros (por supuesto, se llenan antes de que se ejecute el código completo) y los dos últimos bytes con los mismos bytes 55 y AA "mágicos" . $ significa la dirección de la instrucción actual, y $$ es la dirección de la primera instrucción de nuestro código.
Pasemos al código real:
org 0x7c00 ; (1) bits 16 ; (2) jmp start ; start %include "print_string.asm" ; %include "str_compare.asm" ; ==================================================== start: mov ah, 0x00 ; (3) mov al, 0x03 int 0x10 mov sp, 0x7c00 ; (4) mov si, greetings ; call print_string_si ; mainloop
(1) Este comando deja en claro a NASM que estamos ejecutando código a partir de 0x7c00. Esto le permite sesgar automáticamente todas las direcciones relativas a esa dirección para que no lo hagamos explícitamente.
(2) Este comando le indica a NASM que estamos operando en modo de 16 bits.
(3) Cuando se inicia, QEMU imprime mucha información que no necesitamos. Para hacer esto, configure ah 0x00, al 0x03 y llame a 0x10 para borrar la pantalla de todo.
(4) Para guardar registros en la pila, debe especificar en qué dirección se ubicará su vértice utilizando el puntero de la pila SP. SP indicará el área en memoria en la que se escribirá el siguiente valor. Agregue el valor a la pila: SP baja la memoria en 2 bytes (ya que estamos en modo real, donde todos los operandos de registro son valores de 16 bits, es decir, de doble byte). Especificamos 0x7c00, por lo que los valores en la pila se almacenarán justo al lado de nuestro código en la memoria. Una vez más, la pila crece (!). Esto significa que cuantos más valores haya en la pila, menos memoria indicará el puntero de la pila SP.
mainloop: mov si, prompt ; call print_string_si call get_input ; jmp mainloop ; mainloop...
Bucle principal Aquí, con cada iteración, imprimimos el símbolo ">", después de lo cual llamamos a la función get_input, que implementa el trabajo con interrupción del teclado.
get_input: mov bx, 0 ; bx input_processing: mov ah, 0x0 ; 0x16 int 0x16 ; ASCII cmp al, 0x0d ; enter je check_the_input ; , , ; cmp al, 0x8 ; backspace je backspace_pressed cmp al, 0x3 ; ctrl+c je stop_cpu mov ah, 0x0e ; - ; int 0x10 mov [input+bx], al ; inc bx ; cmp bx, 64 ; input je check_the_input ; , enter jmp input_processing ;
(1) [input + bx] significa que tomamos la dirección del comienzo de la entrada del buffer de entrada y le agregamos bx, es decir, llegamos a bx + el primer elemento del buffer.
stop_cpu: mov si, goodbye ; call print_string_si jmp $ ; ; $
Aquí todo es simple: si presionó Ctrl + C, la computadora realiza sin cesar la función jmp $.
backspace_pressed: cmp bx, 0 ; backspace , input , je input_processing ; mov ah, 0x0e ; backspace. , int 0x10 ; , mov al, ' ' ; , int 0x10 ; mov al, 0x8 ; int 0x10 ; backspace dec bx mov byte [input+bx], 0 ; input jmp input_processing ;
Para no borrar el carácter '>' al presionar la tecla de retroceso, verificamos si la entrada está vacía. Si no, entonces no hagas nada.
check_the_input: inc bx mov byte [input+bx], 0 ; , ; ( '\0' ) mov si, new_line ; call print_string_si mov si, help_command ; si help mov bx, input ; bx - call compare_strs_si_bx ; si bx ( help) cmp cx, 1 ; compare_strs_si_bx cx 1, ; je equal_help ; => ; help jmp equal_to_nothing ; , "Wrong command!"
Aquí, creo que todo está claro por los comentarios.
equal_help: mov si, help_desc call print_string_si jmp done equal_to_nothing: mov si, wrong_command call print_string_si jmp done
Dependiendo de lo que se ingresó, mostramos el texto de la variable help_desc o el texto de la variable wrong_command.
; done input done: cmp bx, 0 ; input je exit ; , mainloop dec bx ; , mov byte [input+bx], 0 jmp done ; exit: ret
En realidad, todo el código es:
prompt.asm:
org 0x7c00 bits 16 jmp start ; start %include "print_string.asm" %include "str_compare.asm" ; ==================================================== start: cli ; , ; mov ah, 0x00 ; mov al, 0x03 int 0x10 mov sp, 0x7c00 ; mov si, greetings ; call print_string_si ; mainloop mainloop: mov si, prompt ; call print_string_si call get_input ; jmp mainloop ; mainloop... get_input: mov bx, 0 ; bx input_processing: mov ah, 0x0 ; 0x16 int 0x16 ; ASCII cmp al, 0x0d ; enter je check_the_input ; , , ; cmp al, 0x8 ; backspace je backspace_pressed cmp al, 0x3 ; ctrl+c je stop_cpu mov ah, 0x0e ; - ; int 0x10 mov [input+bx], al ; inc bx ; cmp bx, 64 ; input je check_the_input ; , enter jmp input_processing ; stop_cpu: mov si, goodbye ; call print_string_si jmp $ ; ; $ backspace_pressed: cmp bx, 0 ; backspace , input , je input_processing ; mov ah, 0x0e ; backspace. , int 0x10 ; , mov al, ' ' ; , int 0x10 ; mov al, 0x8 ; int 0x10 ; backspace dec bx mov byte [input+bx], 0 ; input jmp input_processing ; check_the_input: inc bx mov byte [input+bx], 0 ; , ; ( '\0' ) mov si, new_line ; call print_string_si mov si, help_command ; si help mov bx, input ; bx - call compare_strs_si_bx ; si bx ( help) cmp cx, 1 ; compare_strs_si_bx cx 1, ; je equal_help ; => ; help jmp equal_to_nothing ; , "Wrong command!" equal_help: mov si, help_desc call print_string_si jmp done equal_to_nothing: mov si, wrong_command call print_string_si jmp done ; done input done: cmp bx, 0 ; input je exit ; , mainloop dec bx ; , mov byte [input+bx], 0 jmp done ; exit: ret ; 0x0d - , 0xa - wrong_command: db "Wrong command!", 0x0d, 0xa, 0 greetings: db "The OS is on. Type 'help' for commands", 0x0d, 0xa, 0xa, 0 help_desc: db "Here's nothing to show yet. But soon...", 0x0d, 0xa, 0 goodbye: db 0x0d, 0xa, "Goodbye!", 0x0d, 0xa, 0 prompt: db ">", 0 new_line: db 0x0d, 0xa, 0 help_command: db "help", 0 input: times 64 db 0 ; - 64 times 510 - ($-$$) db 0 dw 0xaa55
Para compilar todo esto, ingrese el comando:
nasm -f bin prompt.asm -o bootloader.bin
Y obtenemos el binario con nuestro código en la salida. Ahora ejecute el emulador QEMU con este archivo (-monitor stdio le permite mostrar el valor de registro en cualquier momento usando el comando print $ reg):
qemu-system-i386 bootloader.bin -monitor stdio
Y obtenemos la salida:
