This commit is contained in:
Wbert Adrián Castro Vera
2025-01-14 03:08:56 +00:00
committed by GitHub
14 changed files with 8301 additions and 0 deletions

13
blog/content/_index.es.md Normal file
View File

@@ -0,0 +1,13 @@
+++
template = "edition-2/index.html"
+++
<h1 style="visibility: hidden; height: 0px; margin: 0px; padding: 0px;">Escribiendo un sistema operativo en Rust</h1>
<div class="front-page-introduction">
Esta serie de blogs crea un pequeño sistema operativo en el [lenguaje de programación Rust](https://www.rust-lang.org/). Cada publicación es un pequeño tutorial e incluye todo el código necesario, para que puedas seguir los pasos si lo deseas. El código fuente también está disponible en el [repositorio correspondiente de Github](https://github.com/phil-opp/blog_os).
Última publicación: <!-- latest-post -->
</div>

View File

@@ -0,0 +1,519 @@
+++
title = "Un Binario Rust Autónomo"
weight = 1
path = "freestanding-rust-binary"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
+++
El primer paso para crear nuestro propio kernel de sistema operativo es crear un ejecutable en Rust que no enlace con la biblioteca estándar. Esto hace posible ejecutar código Rust directamente en el [bare metal] sin un sistema operativo subyacente.
[bare metal]: https://en.wikipedia.org/wiki/Bare_machine
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes algún problema o pregunta, por favor abre un issue allí. También puedes dejar comentarios [al final]. El código fuente completo para esta publicación se encuentra en la rama [`post-01`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- solución para el verificador de anclajes de zola (el objetivo está en la plantilla): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-01
<!-- toc -->
## Introducción
Para escribir un kernel de sistema operativo, necesitamos código que no dependa de características del sistema operativo. Esto significa que no podemos usar hilos, archivos, memoria dinámica, redes, números aleatorios, salida estándar ni ninguna otra característica que requiera abstracciones de sistema operativo o hardware específico. Esto tiene sentido, ya que estamos intentando escribir nuestro propio sistema operativo y nuestros propios controladores.
Esto implica que no podemos usar la mayor parte de la [biblioteca estándar de Rust], pero hay muchas características de Rust que sí _podemos_ usar. Por ejemplo, podemos utilizar [iteradores], [closures], [pattern matching], [option] y [result], [formateo de cadenas] y, por supuesto, el [sistema de ownership]. Estas características hacen posible escribir un kernel de una manera muy expresiva y de alto nivel, sin preocuparnos por el [comportamiento indefinido] o la [seguridad de la memoria].
[option]: https://doc.rust-lang.org/core/option/
[result]: https://doc.rust-lang.org/core/result/
[biblioteca estándar de Rust]: https://doc.rust-lang.org/std/
[iteradores]: https://doc.rust-lang.org/book/ch13-02-iterators.html
[closures]: https://doc.rust-lang.org/book/ch13-01-closures.html
[pattern matching]: https://doc.rust-lang.org/book/ch06-00-enums.html
[formateo de cadenas]: https://doc.rust-lang.org/core/macro.write.html
[sistema de ownership]: https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
[comportamiento indefinido]: https://www.nayuki.io/page/undefined-behavior-in-c-and-cplusplus-programs
[seguridad de la memoria]: https://tonyarcieri.com/it-s-time-for-a-memory-safety-intervention
Para crear un kernel de sistema operativo en Rust, necesitamos crear un ejecutable que pueda ejecutarse sin un sistema operativo subyacente. Dicho ejecutable se llama frecuentemente un ejecutable “autónomo” o de “bare metal”.
Esta publicación describe los pasos necesarios para crear un binario autónomo en Rust y explica por qué son necesarios. Si solo te interesa un ejemplo mínimo, puedes **[saltar al resumen](#summary)**.
## Deshabilitando la Biblioteca Estándar
Por defecto, todos los crates de Rust enlazan con la [biblioteca estándar], que depende del sistema operativo para características como hilos, archivos o redes. También depende de la biblioteca estándar de C, `libc`, que interactúa estrechamente con los servicios del sistema operativo. Como nuestro plan es escribir un sistema operativo, no podemos usar ninguna biblioteca que dependa del sistema operativo. Por lo tanto, tenemos que deshabilitar la inclusión automática de la biblioteca estándar mediante el atributo [`no_std`].
[biblioteca estándar]: https://doc.rust-lang.org/std/
[`no_std`]: https://doc.rust-lang.org/1.30.0/book/first-edition/using-rust-without-the-standard-library.html
Comenzamos creando un nuevo proyecto de aplicación en Cargo. La forma más fácil de hacerlo es a través de la línea de comandos:
```
cargo new blog_os --bin --edition 2018
```
Nombré el proyecto `blog_os`, pero, por supuesto, puedes elegir tu propio nombre. La bandera `--bin` especifica que queremos crear un binario ejecutable (en contraste con una biblioteca), y la bandera `--edition 2018` indica que queremos usar la [edición 2018] de Rust para nuestro crate. Al ejecutar el comando, Cargo crea la siguiente estructura de directorios para nosotros:
[2018 edition]: https://doc.rust-lang.org/nightly/edition-guide/rust-2018/index.html
```
blog_os
├── Cargo.toml
└── src
└── main.rs
```
El archivo `Cargo.toml` contiene la configuración del crate, como el nombre del crate, el autor, el número de [versión semántica] y las dependencias. El archivo `src/main.rs` contiene el módulo raíz de nuestro crate y nuestra función `main`. Puedes compilar tu crate utilizando `cargo build` y luego ejecutar el binario compilado `blog_os` ubicado en la subcarpeta `target/debug`.
[semantic version]: https://semver.org/
### El Atributo `no_std`
Actualmente, nuestro crate enlaza implícitamente con la biblioteca estándar. Intentemos deshabilitar esto añadiendo el [`atributo no_std`]:
```rust
// main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}
```
Cuando intentamos compilarlo ahora (ejecutando `cargo build`), ocurre el siguiente error:
```
error: cannot find macro `println!` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
```
La razón de este error es que la [macro `println`] forma parte de la biblioteca estándar, la cual ya no estamos incluyendo. Por lo tanto, ya no podemos imprimir cosas. Esto tiene sentido, ya que `println` escribe en la [salida estándar], que es un descriptor de archivo especial proporcionado por el sistema operativo.
[macro `println`]: https://doc.rust-lang.org/std/macro.println.html
[salida estándar]: https://en.wikipedia.org/wiki/Standard_streams#Standard_output_.28stdout.29
Así que eliminemos la impresión e intentemos de nuevo con una función `main` vacía:
```rust
// main.rs
#![no_std]
fn main() {}
```
```
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`
```
Ahora el compilador indica que falta una función `#[panic_handler]` y un _elemento de lenguaje_ (_language item_).
## Implementación de Panic
El atributo `panic_handler` define la función que el compilador invoca cuando ocurre un [panic]. La biblioteca estándar proporciona su propia función de panico, pero en un entorno `no_std` debemos definirla nosotros mismos:
[panic]: https://doc.rust-lang.org/stable/book/ch09-01-unrecoverable-errors-with-panic.html
```rust
// in main.rs
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
El [parámetro `PanicInfo`][PanicInfo] contiene el archivo y la línea donde ocurrió el panic, así como el mensaje opcional del panic. La función no debería retornar nunca, por lo que se marca como una [función divergente][diverging function] devolviendo el [tipo “never”][“never” type] `!`. Por ahora, no hay mucho que podamos hacer en esta función, así que simplemente entramos en un bucle infinito.
[PanicInfo]: https://doc.rust-lang.org/nightly/core/panic/struct.PanicInfo.html
[diverging function]: https://doc.rust-lang.org/1.30.0/book/first-edition/functions.html#diverging-functions
[“never” type]: https://doc.rust-lang.org/nightly/std/primitive.never.html
## El Elemento de Lenguaje `eh_personality`
Los elementos de lenguaje son funciones y tipos especiales que el compilador requiere internamente. Por ejemplo, el trait [`Copy`] es un elemento de lenguaje que indica al compilador qué tipos tienen [_semántica de copia_][`Copy`]. Si observamos su [implementación][copy code], veremos que tiene el atributo especial `#[lang = "copy"]`, que lo define como un elemento de lenguaje.
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[copy code]: https://github.com/rust-lang/rust/blob/485397e49a02a3b7ff77c17e4a3f16c653925cb3/src/libcore/marker.rs#L296-L299
Aunque es posible proporcionar implementaciones personalizadas de elementos de lenguaje, esto debería hacerse solo como último recurso. La razón es que los elementos de lenguaje son detalles de implementación altamente inestables y ni siquiera están verificados por tipos (el compilador no comprueba si una función tiene los tipos de argumento correctos). Afortunadamente, hay una forma más estable de solucionar el error relacionado con el elemento de lenguaje mencionado.
El [elemento de lenguaje `eh_personality`][`eh_personality` language item] marca una función utilizada para implementar el [desenrollado de pila][stack unwinding]. Por defecto, Rust utiliza unwinding para ejecutar los destructores de todas las variables de pila activas en caso de un [pánico][panic]. Esto asegura que toda la memoria utilizada sea liberada y permite que el hilo principal capture el pánico y continúe ejecutándose. Sin embargo, el unwinding es un proceso complicado y requiere algunas bibliotecas específicas del sistema operativo (por ejemplo, [libunwind] en Linux o [manejadores estructurados de excepciones][structured exception handling] en Windows), por lo que no queremos usarlo en nuestro sistema operativo.
[`eh_personality` language item]: https://github.com/rust-lang/rust/blob/edb368491551a77d77a48446d4ee88b35490c565/src/libpanic_unwind/gcc.rs#L11-L45
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
[libunwind]: https://www.nongnu.org/libunwind/
[structured exception handling]: https://docs.microsoft.com/en-us/windows/win32/debug/structured-exception-handling
[panic]: https://doc.rust-lang.org/book/ch09-01-unrecoverable-errors-with-panic.html
### Deshabilitando el Unwinding
Existen otros casos de uso en los que el no es deseable, por lo que Rust proporciona una opción para [abortar en caso de pánico][abort on panic]. Esto desactiva la generación de información de símbolos de unwinding y, por lo tanto, reduce considerablemente el tamaño del binario. Hay múltiples lugares donde podemos deshabilitar el unwinding. La forma más sencilla es agregar las siguientes líneas a nuestro archivo `Cargo.toml`:
```toml
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
```
Esto establece la estrategia de pánico en `abort` tanto para el perfil `dev` (utilizado en `cargo build`) como para el perfil `release` (utilizado en `cargo build --release`). Ahora, el elemento de lenguaje `eh_personality` ya no debería ser necesario.
[abort on panic]: https://github.com/rust-lang/rust/pull/32900
Ahora hemos solucionado ambos errores anteriores. Sin embargo, si intentamos compilarlo ahora, ocurre otro error:
```
> cargo build
error: requires `start` lang_item
```
Nuestro programa carece del elemento de lenguaje `start`, que define el punto de entrada.
## El Atributo `start`
Podría pensarse que la función `main` es la primera que se ejecuta al correr un programa. Sin embargo, la mayoría de los lenguajes tienen un [sistema de tiempo de ejecución][runtime system], encargado de tareas como la recolección de basura (por ejemplo, en Java) o los hilos de software (por ejemplo, goroutines en Go). Este sistema de tiempo de ejecución necesita ejecutarse antes de `main`, ya que debe inicializarse.
[runtime system]: https://en.wikipedia.org/wiki/Runtime_system
En un binario típico de Rust que enlaza con la biblioteca estándar, la ejecución comienza en una biblioteca de tiempo de ejecución de C llamada `crt0` ("C runtime zero"), que configura el entorno para una aplicación en C. Esto incluye la creación de una pila y la colocación de los argumentos en los registros adecuados. Luego, el tiempo de ejecución de C invoca el [punto de entrada del tiempo de ejecución de Rust][rt::lang_start], que está marcado por el elemento de lenguaje `start`. Rust tiene un tiempo de ejecución muy minimalista, que se encarga de tareas menores como configurar los guardias de desbordamiento de pila o imprimir un backtrace en caso de pánico. Finalmente, el tiempo de ejecución llama a la función `main`.
[rt::lang_start]: https://github.com/rust-lang/rust/blob/bb4d1491466d8239a7a5fd68bd605e3276e97afb/src/libstd/rt.rs#L32-L73
Nuestro ejecutable autónomo no tiene acceso al tiempo de ejecución de Rust ni a `crt0`, por lo que necesitamos definir nuestro propio punto de entrada. Implementar el elemento de lenguaje `start` no ayudaría, ya que aún requeriría `crt0`. En su lugar, debemos sobrescribir directamente el punto de entrada de `crt0`.
### Sobrescribiendo el Punto de Entrada
Para indicar al compilador de Rust que no queremos usar la cadena normal de puntos de entrada, agregamos el atributo `#![no_main]`:
```rust
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
Podrás notar que eliminamos la función `main`. La razón es que una función `main` no tiene sentido sin un sistema de tiempo de ejecución subyacente que la invoque. En su lugar, estamos sobrescribiendo el punto de entrada del sistema operativo con nuestra propia función `_start`:
```rust
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop {}
}
```
Al usar el atributo `#[no_mangle]`, deshabilitamos el [name mangling] para asegurarnos de que el compilador de Rust realmente genere una función con el nombre `_start`. Sin este atributo, el compilador generaría un símbolo críptico como `_ZN3blog_os4_start7hb173fedf945531caE` para dar un nombre único a cada función. Este atributo es necesario porque necesitamos informar al enlazador el nombre de la función de punto de entrada en el siguiente paso.
También debemos marcar la función como `extern "C"` para indicar al compilador que debe usar la [convención de llamadas en C][C calling convention] para esta función (en lugar de la convención de llamadas de Rust, que no está especificada). El motivo para nombrar la función `_start` es que este es el nombre predeterminado del punto de entrada en la mayoría de los sistemas.
[name mangling]: https://en.wikipedia.org/wiki/Name_mangling
[C calling convention]: https://en.wikipedia.org/wiki/Calling_convention
El tipo de retorno `!` significa que la función es divergente, es decir, no está permitido que retorne nunca. Esto es necesario porque el punto de entrada no es llamado por ninguna función, sino que es invocado directamente por el sistema operativo o el bootloader. En lugar de retornar, el punto de entrada debería, por ejemplo, invocar la [llamada al sistema `exit`][`exit` system call] del sistema operativo. En nuestro caso, apagar la máquina podría ser una acción razonable, ya que no queda nada por hacer si un binario autónomo regresa. Por ahora, cumplimos con este requisito entrando en un bucle infinito.
[`exit` system call]: https://en.wikipedia.org/wiki/Exit_(system_call)
Cuando ejecutamos `cargo build` ahora, obtenemos un feo error del _linker_ (enlazador).
## Errores del Enlazador
El enlazador es un programa que combina el código generado en un ejecutable. Dado que el formato del ejecutable varía entre Linux, Windows y macOS, cada sistema tiene su propio enlazador que lanza errores diferentes. Sin embargo, la causa fundamental de los errores es la misma: la configuración predeterminada del enlazador asume que nuestro programa depende del tiempo de ejecución de C, lo cual no es cierto.
Para solucionar los errores, necesitamos informar al enlazador que no debe incluir el tiempo de ejecución de C. Esto puede hacerse pasando un conjunto específico de argumentos al enlazador o construyendo para un destino de bare metal.
### Construyendo para un Destino de Bare Metal
Por defecto, Rust intenta construir un ejecutable que pueda ejecutarse en el entorno actual de tu sistema. Por ejemplo, si estás usando Windows en `x86_64`, Rust intenta construir un ejecutable `.exe` para Windows que utilice instrucciones `x86_64`. Este entorno se llama tu sistema "host".
Para describir diferentes entornos, Rust utiliza una cadena llamada [_target triple_]. Puedes ver el _target triple_ de tu sistema host ejecutando:
```
rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0
```
El resultado anterior es de un sistema Linux `x86_64`. Vemos que la tripleta del `host` es `x86_64-unknown-linux-gnu`, lo que incluye la arquitectura de la CPU (`x86_64`), el proveedor (`unknown`), el sistema operativo (`linux`) y el [ABI] (`gnu`).
[ABI]: https://en.wikipedia.org/wiki/Application_binary_interface
Al compilar para la tripleta del host, el compilador de Rust y el enlazador asumen que hay un sistema operativo subyacente como Linux o Windows que utiliza el tiempo de ejecución de C de forma predeterminada, lo que provoca los errores del enlazador. Para evitar estos errores, podemos compilar para un entorno diferente que no tenga un sistema operativo subyacente.
Un ejemplo de este tipo de entorno bare metal es la tripleta de destino `thumbv7em-none-eabihf`, que describe un sistema [embebido][embedded] basado en [ARM]. Los detalles no son importantes, lo que importa es que la tripleta de destino no tiene un sistema operativo subyacente, lo cual se indica por el `none` en la tripleta de destino. Para poder compilar para este destino, necesitamos agregarlo usando `rustup`:
```
rustup target add thumbv7em-none-eabihf
```
Esto descarga una copia de las bibliotecas estándar (y core) para el sistema. Ahora podemos compilar nuestro ejecutable autónomo para este destino:
```
cargo build --target thumbv7em-none-eabihf
```
Al pasar un argumento `--target`, realizamos un [compilado cruzado][cross compile] de nuestro ejecutable para un sistema bare metal. Dado que el sistema de destino no tiene un sistema operativo, el enlazador no intenta enlazar con el tiempo de ejecución de C, y nuestra compilación se completa sin errores del enlazador.
[cross compile]: https://en.wikipedia.org/wiki/Cross_compiler
Este es el enfoque que utilizaremos para construir nuestro kernel de sistema operativo. En lugar de `thumbv7em-none-eabihf`, utilizaremos un [destino personalizado][custom target] que describa un entorno bare metal `x86_64`. Los detalles se explicarán en la siguiente publicación.
[custom target]: https://doc.rust-lang.org/rustc/targets/custom.html
### Argumentos del Enlazador
En lugar de compilar para un sistema bare metal, también es posible resolver los errores del enlazador pasando un conjunto específico de argumentos al enlazador. Este no es el enfoque que usaremos para nuestro kernel, por lo tanto, esta sección es opcional y se proporciona solo para completar. Haz clic en _"Argumentos del Enlazador"_ a continuación para mostrar el contenido opcional.
<details>
<summary>Argumentos del Enlazador</summary>
En esta sección discutimos los errores del enlazador que ocurren en Linux, Windows y macOS, y explicamos cómo resolverlos pasando argumentos adicionales al enlazador. Ten en cuenta que el formato del ejecutable y el enlazador varían entre sistemas operativos, por lo que se requiere un conjunto diferente de argumentos para cada sistema.
#### Linux
En Linux ocurre el siguiente error del enlazador (resumido):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x19): undefined reference to `__libc_csu_init'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x25): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status
```
El problema es que el enlazador incluye por defecto la rutina de inicio del tiempo de ejecución de C, que también se llama `_start`. Esta rutina requiere algunos símbolos de la biblioteca estándar de C `libc` que no incluimos debido al atributo `no_std`, por lo que el enlazador no puede resolver estas referencias. Para solucionar esto, podemos indicar al enlazador que no enlace la rutina de inicio de C pasando la bandera `-nostartfiles`.
Una forma de pasar atributos al enlazador a través de Cargo es usar el comando `cargo rustc`. Este comando se comporta exactamente como `cargo build`, pero permite pasar opciones a `rustc`, el compilador subyacente de Rust. `rustc` tiene la bandera `-C link-arg`, que pasa un argumento al enlazador. Combinados, nuestro nuevo comando de compilación se ve así:
```
cargo rustc -- -C link-arg=-nostartfiles
```
¡Ahora nuestro crate se compila como un ejecutable autónomo en Linux!
No fue necesario especificar explícitamente el nombre de nuestra función de punto de entrada, ya que el enlazador busca una función con el nombre `_start` por defecto.
#### Windows
En Windows, ocurre un error del enlazador diferente (resumido):
```
error: linking with `link.exe` failed: exit code: 1561
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1561: entry point must be defined
```
El error "entry point must be defined" significa que el enlazador no puede encontrar el punto de entrada. En Windows, el nombre predeterminado del punto de entrada [depende del subsistema utilizado][windows-subsystems]. Para el subsistema `CONSOLE`, el enlazador busca una función llamada `mainCRTStartup`, y para el subsistema `WINDOWS`, busca una función llamada `WinMainCRTStartup`. Para anular este comportamiento predeterminado y decirle al enlazador que busque nuestra función `_start`, podemos pasar un argumento `/ENTRY` al enlazador:
[windows-subsystems]: https://docs.microsoft.com/en-us/cpp/build/reference/entry-entry-point-symbol
```
cargo rustc -- -C link-arg=/ENTRY:_start
```
Por el formato diferente del argumento, podemos ver claramente que el enlazador de Windows es un programa completamente distinto al enlazador de Linux.
Ahora ocurre un error diferente del enlazador:
```
error: linking with `link.exe` failed: exit code: 1221
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be
defined
```
Este error ocurre porque los ejecutables de Windows pueden usar diferentes [subsistemas][windows-subsystems]. En programas normales, se infieren dependiendo del nombre del punto de entrada: si el punto de entrada se llama `main`, se usa el subsistema `CONSOLE`, y si el punto de entrada se llama `WinMain`, se usa el subsistema `WINDOWS`. Dado que nuestra función `_start` tiene un nombre diferente, necesitamos especificar el subsistema explícitamente:
```
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
```
Aquí usamos el subsistema `CONSOLE`, pero el subsistema `WINDOWS` también funcionaría. En lugar de pasar `-C link-arg` varias veces, podemos usar `-C link-args`, que acepta una lista de argumentos separados por espacios.
Con este comando, nuestro ejecutable debería compilarse exitosamente en Windows.
#### macOS
En macOS, ocurre el siguiente error del enlazador (resumido):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: entry point (_main) undefined. for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
Este mensaje de error nos indica que el enlazador no puede encontrar una función de punto de entrada con el nombre predeterminado `main` (por alguna razón, en macOS todas las funciones tienen un prefijo `_`). Para establecer el punto de entrada en nuestra función `_start`, pasamos el argumento del enlazador `-e`:
```
cargo rustc -- -C link-args="-e __start"
```
La bandera `-e` especifica el nombre de la función de punto de entrada. Dado que en macOS todas las funciones tienen un prefijo adicional `_`, necesitamos establecer el punto de entrada en `__start` en lugar de `_start`.
Ahora ocurre el siguiente error del enlazador:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: dynamic main executables must link with libSystem.dylib
for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
macOS [no admite oficialmente binarios enlazados estáticamente] y requiere que los programas enlacen la biblioteca `libSystem` por defecto. Para anular esto y enlazar un binario estático, se pasa la bandera `-static` al enlazador:
[no admite oficialmente binarios enlazados estáticamente]: https://developer.apple.com/library/archive/qa/qa1118/_index.html
```
cargo rustc -- -C link-args="-e __start -static"
```
Esto aún no es suficiente, ya que ocurre un tercer error del enlazador:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 […]
```
Este error ocurre porque los programas en macOS enlazan con `crt0` (“C runtime zero”) por defecto. Esto es similar al error que tuvimos en Linux y también se puede resolver añadiendo el argumento del enlazador `-nostartfiles`:
```
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
Ahora nuestro programa debería compilarse exitosamente en macOS.
#### Unificando los Comandos de Construcción
Actualmente, tenemos diferentes comandos de construcción dependiendo de la plataforma host, lo cual no es ideal. Para evitar esto, podemos crear un archivo llamado `.cargo/config.toml` que contenga los argumentos específicos de cada plataforma:
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]
```
La clave `rustflags` contiene argumentos que se añaden automáticamente a cada invocación de `rustc`. Para más información sobre el archivo `.cargo/config.toml`, consulta la [documentación oficial](https://doc.rust-lang.org/cargo/reference/config.html).
Ahora nuestro programa debería poder construirse en las tres plataformas con un simple `cargo build`.
#### ¿Deberías Hacer Esto?
Aunque es posible construir un ejecutable autónomo para Linux, Windows y macOS, probablemente no sea una buena idea. La razón es que nuestro ejecutable aún espera varias cosas, por ejemplo, que una pila esté inicializada cuando se llama a la función `_start`. Sin el tiempo de ejecución de C, algunos de estos requisitos podrían no cumplirse, lo que podría hacer que nuestro programa falle, por ejemplo, con un error de segmentación.
Si deseas crear un binario mínimo que se ejecute sobre un sistema operativo existente, incluir `libc` y configurar el atributo `#[start]` como se describe [aquí](https://doc.rust-lang.org/1.16.0/book/no-stdlib.html) probablemente sea una mejor idea.
</details>
## Resumen
Un binario mínimo autónomo en Rust se ve así:
`src/main.rs`:
```rust
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
use core::panic::PanicInfo;
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
// this function is the entry point, since the linker looks for a function
// named `_start` by default
loop {}
}
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`Cargo.toml`:
```toml
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
# the profile used for `cargo build`
[profile.dev]
panic = "abort" # disable stack unwinding on panic
# the profile used for `cargo build --release`
[profile.release]
panic = "abort" # disable stack unwinding on panic
```
Para construir este binario, necesitamos compilar para un destino bare metal, como `thumbv7em-none-eabihf`:
```
cargo build --target thumbv7em-none-eabihf
```
Alternativamente, podemos compilarlo para el sistema host pasando argumentos adicionales al enlazador:
```bash
# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
Ten en cuenta que este es solo un ejemplo mínimo de un binario autónomo en Rust. Este binario espera varias cosas, por ejemplo, que una pila esté inicializada cuando se llama a la función `_start`. **Por lo tanto, para cualquier uso real de un binario como este, se requieren más pasos**.
## ¿Qué sigue?
La [próxima publicación][next post] explica los pasos necesarios para convertir nuestro binario autónomo en un kernel de sistema operativo mínimo. Esto incluye crear un destino personalizado, combinar nuestro ejecutable con un bootloader y aprender cómo imprimir algo en la pantalla.
[next post]: @/edition-2/posts/02-minimal-rust-kernel/index.md

View File

@@ -0,0 +1,497 @@
+++
title = "Un Kernel Mínimo en Rust"
weight = 2
path = "minimal-rust-kernel"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
+++
En esta publicación, crearemos un kernel mínimo de 64 bits en Rust para la arquitectura x86. Partiremos del [un binario Rust autónomo] de la publicación anterior para crear una imagen de disco arrancable que imprima algo en la pantalla.
[un binario Rust autónomo]: @/edition-2/posts/01-freestanding-rust-binary/index.md
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes problemas o preguntas, por favor abre un issue ahí. También puedes dejar comentarios [al final]. El código fuente completo para esta publicación se encuentra en la rama [`post-02`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-02
<!-- toc -->
## El Proceso de Arranque
Cuando enciendes una computadora, comienza a ejecutar código de firmware almacenado en la [ROM] de la placa madre. Este código realiza una [prueba automática de encendido], detecta la memoria RAM disponible y preinicializa la CPU y el hardware. Después, busca un disco arrancable y comienza a cargar el kernel del sistema operativo.
[ROM]: https://en.wikipedia.org/wiki/Read-only_memory
[prueba automática de encendido]: https://en.wikipedia.org/wiki/Power-on_self-test
En x86, existen dos estándares de firmware: el “Sistema Básico de Entrada/Salida” (**[BIOS]**) y la más reciente “Interfaz de Firmware Extensible Unificada” (**[UEFI]**). El estándar BIOS es antiguo y está desactualizado, pero es simple y está bien soportado en cualquier máquina x86 desde los años 80. UEFI, en contraste, es más moderno y tiene muchas más funciones, pero es más complejo de configurar (al menos en mi opinión).
[BIOS]: https://en.wikipedia.org/wiki/BIOS
[UEFI]: https://en.wikipedia.org/wiki/Unified_Extensible_Firmware_Interface
Actualmente, solo proporcionamos soporte para BIOS, pero también planeamos agregar soporte para UEFI. Si te gustaría ayudarnos con esto, revisa el [issue en Github](https://github.com/phil-opp/blog_os/issues/349).
### Arranque con BIOS
Casi todos los sistemas x86 tienen soporte para arranque con BIOS, incluyendo máquinas más recientes basadas en UEFI que usan un BIOS emulado. Esto es excelente, porque puedes usar la misma lógica de arranque en todas las máquinas del último siglo. Sin embargo, esta amplia compatibilidad también es la mayor desventaja del arranque con BIOS, ya que significa que la CPU se coloca en un modo de compatibilidad de 16 bits llamado [modo real] antes de arrancar, para que los bootloaders arcaicos de los años 80 sigan funcionando.
Pero comencemos desde el principio:
Cuando enciendes una computadora, carga el BIOS desde una memoria flash especial ubicada en la placa madre. El BIOS ejecuta rutinas de autoprueba e inicialización del hardware, y luego busca discos arrancables. Si encuentra uno, transfiere el control a su _bootloader_ (_cargador de arranque_), que es una porción de código ejecutable de 512 bytes almacenada al inicio del disco. La mayoría de los bootloaders son más grandes que 512 bytes, por lo que suelen dividirse en una pequeña primera etapa, que cabe en esos 512 bytes, y una segunda etapa que se carga posteriormente.
El bootloader debe determinar la ubicación de la imagen del kernel en el disco y cargarla en la memoria. Tambien necesita cambiar la CPU del [modo real] de 16 bits primero al [modo protegido] de 32 bits, y luego al [modo largo] de 64 bits, donde están disponibles los registros de 64 bits y toda la memoria principal. Su tercera tarea es consultar cierta información (como un mapa de memoria) desde el BIOS y pasársela al kernel del sistema operativo.
[modo real]: https://en.wikipedia.org/wiki/Real_mode
[modo protegido]: https://en.wikipedia.org/wiki/Protected_mode
[modo largo]: https://en.wikipedia.org/wiki/Long_mode
[segmentación de memoria]: https://en.wikipedia.org/wiki/X86_memory_segmentation
Escribir un bootloader es un poco tedioso, ya que requiere lenguaje ensamblador y muchos pasos poco claros como “escribir este valor mágico en este registro del procesador”. Por ello, no cubrimos la creación de bootloaders en este artículo y en su lugar proporcionamos una herramienta llamada [bootimage] que automatiza el proceso de creación de un bootloader.
[bootimage]: https://github.com/rust-osdev/bootimage
Si te interesa construir tu propio bootloader: ¡Estén atentos! Un conjunto de artículos sobre este tema está en camino.
#### El Estándar Multiboot
Para evitar que cada sistema operativo implemente su propio bootloader, que sea compatible solo con un único sistema, la [Free Software Foundation] creó en 1995 un estándar abierto de bootloaders llamado [Multiboot]. El estándar define una interfaz entre el bootloader y el sistema operativo, de modo que cualquier bootloader compatible con Multiboot pueda cargar cualquier sistema operativo compatible con Multiboot. La implementación de referencia es [GNU GRUB], que es el bootloader más popular para sistemas Linux.
[Free Software Foundation]: https://en.wikipedia.org/wiki/Free_Software_Foundation
[Multiboot]: https://wiki.osdev.org/Multiboot
[GNU GRUB]: https://en.wikipedia.org/wiki/GNU_GRUB
Para hacer un kernel compatible con Multiboot, solo necesitas insertar un llamado [encabezado Multiboot] al inicio del archivo del kernel. Esto hace que arrancar un sistema operativo desde GRUB sea muy sencillo. Sin embargo, GRUB y el estándar Multiboot también tienen algunos problemas:
[encabezado Multiboot]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#OS-image-format
- Solo soportan el modo protegido de 32 bits. Esto significa que aún tienes que configurar la CPU para cambiar al modo largo de 64 bits.
- Están diseñados para simplificar el cargador de arranque en lugar del kernel. Por ejemplo, el kernel necesita vincularse con un [tamaño de página predeterminado ajustado], porque GRUB no puede encontrar el encabezado Multiboot de otro modo. Otro ejemplo es que la [información de arranque], que se pasa al kernel, contiene muchas estructuras dependientes de la arquitectura en lugar de proporcionar abstracciones limpias.
- Tanto GRUB como el estándar Multiboot están escasamente documentados.
- GRUB necesita instalarse en el sistema host para crear una imagen de disco arrancable a partir del archivo del kernel. Esto dificulta el desarrollo en Windows o Mac.
[tamaño de página predeterminado ajustado]: https://wiki.osdev.org/Multiboot#Multiboot_2
[información de arranque]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#Boot-information-format
Debido a estas desventajas, decidimos no usar GRUB ni el estándar Multiboot. Sin embargo, planeamos agregar soporte para Multiboot a nuestra herramienta [bootimage], para que sea posible cargar tu kernel en un sistema GRUB también. Si te interesa escribir un kernel compatible con Multiboot, revisa la [primera edición] de esta serie de blogs.
[primera edición]: @/edition-1/_index.md
### UEFI
(Por el momento no proporcionamos soporte para UEFI, ¡pero nos encantaría hacerlo! Si deseas ayudar, por favor háznoslo saber en el [issue de Github](https://github.com/phil-opp/blog_os/issues/349).)
## Un Kernel Mínimo
Ahora que tenemos una idea general de cómo arranca una computadora, es momento de crear nuestro propio kernel mínimo. Nuestro objetivo es crear una imagen de disco que, al arrancar, imprima “Hello World!” en la pantalla. Para esto, extendemos el [un binario Rust autónomo] del artículo anterior.
Como recordarás, construimos el binario independiente mediante `cargo`, pero dependiendo del sistema operativo, necesitábamos diferentes nombres de punto de entrada y banderas de compilación. Esto se debe a que `cargo` construye por defecto para el _sistema anfitrión_, es decir, el sistema en el que estás ejecutando el comando. Esto no es lo que queremos para nuestro kernel, ya que un kernel que funcione encima, por ejemplo, de Windows, no tiene mucho sentido. En su lugar, queremos compilar para un _sistema destino_ claramente definido.
### Instalación de Rust Nightly
Rust tiene tres canales de lanzamiento: _stable_, _beta_ y _nightly_. El libro de Rust explica muy bien la diferencia entre estos canales, así que tómate un momento para [revisarlo](https://doc.rust-lang.org/book/appendix-07-nightly-rust.html#choo-choo-release-channels-and-riding-the-trains). Para construir un sistema operativo, necesitaremos algunas características experimentales que solo están disponibles en el canal nightly, por lo que debemos instalar una versión nightly de Rust.
Para administrar instalaciones de Rust, recomiendo ampliamente [rustup]. Este permite instalar compiladores nightly, beta y estable lado a lado, y facilita mantenerlos actualizados. Con rustup, puedes usar un compilador nightly en el directorio actual ejecutando `rustup override set nightly`. Alternativamente, puedes agregar un archivo llamado `rust-toolchain` con el contenido `nightly` en el directorio raíz del proyecto. Puedes verificar que tienes una versión nightly instalada ejecutando `rustc --version`: el número de versión debería contener `-nightly` al final.
[rustup]: https://www.rustup.rs/
El compilador nightly nos permite activar varias características experimentales utilizando las llamadas _banderas de características_ al inicio de nuestro archivo. Por ejemplo, podríamos habilitar el macro experimental [`asm!`] para ensamblador en línea agregando `#![feature(asm)]` en la parte superior de nuestro archivo `main.rs`. Ten en cuenta que estas características experimentales son completamente inestables, lo que significa que futuras versiones de Rust podrían cambiarlas o eliminarlas sin previo aviso. Por esta razón, solo las utilizaremos si son absolutamente necesarias.
[`asm!`]: https://doc.rust-lang.org/stable/reference/inline-assembly.html
### Especificación del Objetivo
Cargo soporta diferentes sistemas destino mediante el parámetro `--target`. El destino se describe mediante un _[tripleta de destino]_, que especifica la arquitectura de la CPU, el proveedor, el sistema operativo y el [ABI]. Por ejemplo, el tripleta de destino `x86_64-unknown-linux-gnu` describe un sistema con una CPU `x86_64`, sin un proveedor claro, y un sistema operativo Linux con el ABI GNU. Rust soporta [muchas tripleta de destino diferentes][platform-support], incluyendo `arm-linux-androideabi` para Android o [`wasm32-unknown-unknown` para WebAssembly](https://www.hellorust.com/setup/wasm-target/).
[tripleta de destino]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
[ABI]: https://stackoverflow.com/a/2456882
[platform-support]: https://forge.rust-lang.org/release/platform-support.html
[custom-targets]: https://doc.rust-lang.org/nightly/rustc/targets/custom.html
Para nuestro sistema destino, sin embargo, requerimos algunos parámetros de configuración especiales (por ejemplo, sin un sistema operativo subyacente), por lo que ninguno de los [tripletas de destino existentes][platform-support] encaja. Afortunadamente, Rust nos permite definir [nuestros propios objetivos][custom-targets] mediante un archivo JSON. Por ejemplo, un archivo JSON que describe el objetivo `x86_64-unknown-linux-gnu` se ve así:
```json
{
"llvm-target": "x86_64-unknown-linux-gnu",
"data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "linux",
"executables": true,
"linker-flavor": "gcc",
"pre-link-args": ["-m64"],
"morestack": false
}
```
La mayoría de los campos son requeridos por LLVM para generar código para esa plataforma. Por ejemplo, el campo [`data-layout`] define el tamaño de varios tipos de enteros, números de punto flotante y punteros. Luego, hay campos que Rust utiliza para la compilación condicional, como `target-pointer-width`. El tercer tipo de campo define cómo debe construirse el crate. Por ejemplo, el campo `pre-link-args` especifica argumentos que se pasan al [linker].
[`data-layout`]: https://llvm.org/docs/LangRef.html#data-layout
[linker]: https://en.wikipedia.org/wiki/Linker_(computing)
Nuestro kernel también tiene como objetivo los sistemas `x86_64`, por lo que nuestra especificación de objetivo será muy similar a la anterior. Comencemos creando un archivo llamado `x86_64-blog_os.json` (puedes elegir el nombre que prefieras) con el siguiente contenido común:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true
}
```
Ten en cuenta que cambiamos el sistema operativo en el campo `llvm-target` y en el campo `os` a `none`, porque nuestro kernel se ejecutará directamente sobre hardware sin un sistema operativo subyacente.
Agregamos las siguientes entradas relacionadas con la construcción:
```json
"linker-flavor": "ld.lld",
"linker": "rust-lld",
```
En lugar de usar el enlazador predeterminado de la plataforma (que podría no soportar objetivos de Linux), utilizamos el enlazador multiplataforma [LLD] que se incluye con Rust para enlazar nuestro kernel.
[LLD]: https://lld.llvm.org/
```json
"panic-strategy": "abort",
```
Esta configuración especifica que el objetivo no soporta [stack unwinding] en caso de un pánico, por lo que el programa debería abortar directamente. Esto tiene el mismo efecto que la opción `panic = "abort"` en nuestro archivo Cargo.toml, por lo que podemos eliminarla de ahí. (Ten en cuenta que, a diferencia de la opción en Cargo.toml, esta opción del destino también se aplica cuando recompilamos la biblioteca `core` más adelante en este artículo. Por lo tanto, incluso si prefieres mantener la opción en Cargo.toml, asegúrate de incluir esta opción.)
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
```json
"disable-redzone": true,
```
Estamos escribiendo un kernel, por lo que en algún momento necesitaremos manejar interrupciones. Para hacerlo de manera segura, debemos deshabilitar una optimización del puntero de pila llamada _“red zone”_, ya que de lo contrario podría causar corrupción en la pila. Para más información, consulta nuestro artículo sobre [cómo deshabilitar la red zone].
[deshabilitar la red zone]: @/edition-2/posts/02-minimal-rust-kernel/disable-red-zone/index.md
```json
"features": "-mmx,-sse,+soft-float",
```
El campo `features` habilita o deshabilita características del destinos. Deshabilitamos las características `mmx` y `sse` anteponiéndoles un signo menos y habilitamos la característica `soft-float` anteponiéndole un signo más. Ten en cuenta que no debe haber espacios entre las diferentes banderas, ya que de lo contrario LLVM no podrá interpretar correctamente la cadena de características.
Las características `mmx` y `sse` determinan el soporte para instrucciones [Single Instruction Multiple Data (SIMD)], que a menudo pueden acelerar significativamente los programas. Sin embargo, el uso de los registros SIMD en kernels de sistemas operativos genera problemas de rendimiento. Esto se debe a que el kernel necesita restaurar todos los registros a su estado original antes de continuar un programa interrumpido. Esto implica que el kernel debe guardar el estado completo de SIMD en la memoria principal en cada llamada al sistema o interrupción de hardware. Dado que el estado SIMD es muy grande (5121600 bytes) y las interrupciones pueden ocurrir con mucha frecuencia, estas operaciones adicionales de guardar/restaurar afectan considerablemente el rendimiento. Para evitar esto, deshabilitamos SIMD para nuestro kernel (pero no para las aplicaciones que se ejecutan encima).
[Single Instruction Multiple Data (SIMD)]: https://en.wikipedia.org/wiki/SIMD
Un problema al deshabilitar SIMD es que las operaciones de punto flotante en `x86_64` requieren registros SIMD por defecto. Para resolver este problema, agregamos la característica `soft-float`, que emula todas las operaciones de punto flotante mediante funciones de software basadas en enteros normales.
Para más información, consulta nuestro artículo sobre [cómo deshabilitar SIMD](@/edition-2/posts/02-minimal-rust-kernel/disable-simd/index.md).
#### Juntándolo Todo
Nuestro archivo de especificación de objetivo ahora se ve así:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true,
"linker-flavor": "ld.lld",
"linker": "rust-lld",
"panic-strategy": "abort",
"disable-redzone": true,
"features": "-mmx,-sse,+soft-float"
}
```
### Construyendo nuestro Kernel
Compilar para nuestro nuevo objetivo usará convenciones de Linux, ya que la opción de enlazador `ld.lld` instruye a LLVM a compilar con la bandera `-flavor gnu` (para más opciones del enlazador, consulta [la documentación de rustc](https://doc.rust-lang.org/rustc/codegen-options/index.html#linker-flavor)). Esto significa que necesitamos un punto de entrada llamado `_start`, como se describió en el [artículo anterior]:
[artículo anterior]: @/edition-2/posts/01-freestanding-rust-binary/index.md
```rust
// src/main.rs
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
// this function is the entry point, since the linker looks for a function
// named `_start` by default
loop {}
}
```
Ten en cuenta que el punto de entrada debe llamarse `_start` sin importar el sistema operativo anfitrión.
Ahora podemos construir el kernel para nuestro nuevo objetivo pasando el nombre del archivo JSON como `--target`:
```
> cargo build --target x86_64-blog_os.json
error[E0463]: can't find crate for `core`
```
¡Falla! El error nos indica que el compilador de Rust ya no encuentra la [biblioteca `core`]. Esta biblioteca contiene tipos básicos de Rust como `Result`, `Option` e iteradores, y se vincula implícitamente a todos los crates con `no_std`.
[biblioteca `core`]: https://doc.rust-lang.org/nightly/core/index.html
El problema es que la biblioteca `core` se distribuye junto con el compilador de Rust como una biblioteca _precompilada_. Por lo tanto, solo es válida para tripletas de anfitrión soportados (por ejemplo, `x86_64-unknown-linux-gnu`), pero no para nuestro objetivo personalizado. Si queremos compilar código para otros objetivos, necesitamos recompilar `core` para esos objetivos primero.
#### La Opción `build-std`
Aquí es donde entra en juego la característica [`build-std`] de cargo. Esta permite recompilar `core` y otras bibliotecas estándar bajo demanda, en lugar de usar las versiones precompiladas que vienen con la instalación de Rust. Esta característica es muy nueva y aún no está terminada, por lo que está marcada como "inestable" y solo está disponible en los [compiladores de Rust nightly].
[`build-std`]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std
[compiladores de Rust nightly]: #installing-rust-nightly
Para usar esta característica, necesitamos crear un archivo de configuración local de [cargo] en `.cargo/config.toml` (la carpeta `.cargo` debería estar junto a tu carpeta `src`) con el siguiente contenido:
[cargo]: https://doc.rust-lang.org/cargo/reference/config.html
```toml
# in .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins"]
```
Esto le indica a cargo que debe recompilar las bibliotecas `core` y `compiler_builtins`. Esta última es necesaria porque es una dependencia de `core`. Para poder recompilar estas bibliotecas, cargo necesita acceso al código fuente de Rust, el cual podemos instalar ejecutando `rustup component add rust-src`.
<div class="note">
**Nota:** La clave de configuración `unstable.build-std` requiere al menos la versión de Rust nightly del 15 de julio de 2020.
</div>
Después de configurar la clave `unstable.build-std` e instalar el componente `rust-src`, podemos ejecutar nuevamente nuestro comando de construcción:
```
> cargo build --target x86_64-blog_os.json
Compiling core v0.0.0 (/…/rust/src/libcore)
Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
Compiling compiler_builtins v0.1.32
Compiling blog_os v0.1.0 (/…/blog_os)
Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs
```
Vemos que `cargo build` ahora recompila las bibliotecas `core`, `rustc-std-workspace-core` (una dependencia de `compiler_builtins`) y `compiler_builtins` para nuestro objetivo personalizado.
#### Intrínsecos Relacionados con la Memoria
El compilador de Rust asume que un cierto conjunto de funciones integradas está disponible para todos los sistemas. La mayoría de estas funciones son proporcionadas por el crate `compiler_builtins`, que acabamos de recompilar. Sin embargo, hay algunas funciones relacionadas con la memoria en ese crate que no están habilitadas por defecto, ya que normalmente son proporcionadas por la biblioteca C del sistema. Estas funciones incluyen `memset`, que establece todos los bytes de un bloque de memoria a un valor dado, `memcpy`, que copia un bloque de memoria a otro, y `memcmp`, que compara dos bloques de memoria. Aunque no necesitamos estas funciones para compilar nuestro kernel en este momento, serán necesarias tan pronto como agreguemos más código (por ejemplo, al copiar estructuras).
Dado que no podemos vincularnos a la biblioteca C del sistema operativo, necesitamos una forma alternativa de proporcionar estas funciones al compilador. Una posible solución podría ser implementar nuestras propias funciones `memset`, `memcpy`, etc., y aplicarles el atributo `#[no_mangle]` (para evitar el renombramiento automático durante la compilación). Sin embargo, esto es peligroso, ya que el más mínimo error en la implementación de estas funciones podría conducir a un comportamiento indefinido. Por ejemplo, implementar `memcpy` con un bucle `for` podría resultar en una recursión infinita, ya que los bucles `for` llaman implícitamente al método del trait [`IntoIterator::into_iter`], que podría invocar nuevamente a `memcpy`. Por lo tanto, es una buena idea reutilizar implementaciones existentes y bien probadas.
[`IntoIterator::into_iter`]: https://doc.rust-lang.org/stable/core/iter/trait.IntoIterator.html#tymethod.into_iter
Afortunadamente, el crate `compiler_builtins` ya contiene implementaciones para todas las funciones necesarias, pero están deshabilitadas por defecto para evitar conflictos con las implementaciones de la biblioteca C. Podemos habilitarlas configurando la bandera [`build-std-features`] de cargo como `["compiler-builtins-mem"]`. Al igual que la bandera `build-std`, esta bandera puede pasarse como un flag `-Z` en la línea de comandos o configurarse en la tabla `unstable` en el archivo `.cargo/config.toml`. Dado que siempre queremos compilar con esta bandera, la opción de archivo de configuración tiene más sentido para nosotros:
[`build-std-features`]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std-features
```toml
# in .cargo/config.toml
[unstable]
build-std-features = ["compiler-builtins-mem"]
build-std = ["core", "compiler_builtins"]
```
(El soporte para la característica `compiler-builtins-mem` fue [añadido muy recientemente](https://github.com/rust-lang/rust/pull/77284), por lo que necesitas al menos Rust nightly `2020-09-30` para usarla).
Detrás de escena, esta bandera habilita la [característica `mem`] del crate `compiler_builtins`. El efecto de esto es que el atributo `#[no_mangle]` se aplica a las [implementaciones de `memcpy`, etc.] del crate, lo que las hace disponibles para el enlazador.
[característica `mem`]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/Cargo.toml#L54-L55
[implementaciones de `memcpy`, etc.]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/src/mem.rs#L12-L69
Con este cambio, nuestro kernel tiene implementaciones válidas para todas las funciones requeridas por el compilador, por lo que continuará compilándose incluso si nuestro código se vuelve más complejo.
#### Configurar un Objetivo Predeterminado
Para evitar pasar el parámetro `--target` en cada invocación de `cargo build`, podemos sobrescribir el objetivo predeterminado. Para hacer esto, añadimos lo siguiente a nuestro archivo de [configuración de cargo] en `.cargo/config.toml`:
[configuración de cargo]: https://doc.rust-lang.org/cargo/reference/config.html
```toml
# in .cargo/config.toml
[build]
target = "x86_64-blog_os.json"
```
Esto le indica a `cargo` que use nuestro objetivo `x86_64-blog_os.json` cuando no se pase explícitamente el argumento `--target`. Esto significa que ahora podemos construir nuestro kernel con un simple `cargo build`. Para más información sobre las opciones de configuración de cargo, consulta la [documentación oficial][configuración de cargo].
Ahora podemos construir nuestro kernel para un objetivo bare metal con un simple `cargo build`. Sin embargo, nuestro punto de entrada `_start`, que será llamado por el cargador de arranque, aún está vacío. Es momento de mostrar algo en la pantalla desde ese punto.
### Imprimiendo en Pantalla
La forma más sencilla de imprimir texto en la pantalla en esta etapa es usando el [búfer de texto VGA]. Es un área de memoria especial mapeada al hardware VGA que contiene el contenido mostrado en pantalla. Normalmente consta de 25 líneas, cada una con 80 celdas de caracteres. Cada celda de carácter muestra un carácter ASCII con algunos colores de primer plano y fondo. La salida en pantalla se ve así:
[búfer de texto VGA]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
![salida en pantalla para caracteres ASCII comunes](https://upload.wikimedia.org/wikipedia/commons/f/f8/Codepage-437.png)
Discutiremos el diseño exacto del búfer VGA en el próximo artículo, donde escribiremos un primer controlador pequeño para él. Para imprimir “Hello World!”, solo necesitamos saber que el búfer está ubicado en la dirección `0xb8000` y que cada celda de carácter consta de un byte ASCII y un byte de color.
La implementación se ve así:
```rust
static HELLO: &[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop {}
}
```
Primero, convertimos el entero `0xb8000` en un [raw pointer]. Luego, [iteramos] sobre los bytes de la [cadena de bytes estática] `HELLO`. Usamos el método [`enumerate`] para obtener adicionalmente una variable de conteo `i`. En el cuerpo del bucle `for`, utilizamos el método [`offset`] para escribir el byte de la cadena y el byte de color correspondiente (`0xb` representa un cian claro).
[iteramos]: https://doc.rust-lang.org/stable/book/ch13-02-iterators.html
[raw pointer]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
[estática]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime
[`enumerate`]: https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate
[cadena de bytes]: https://doc.rust-lang.org/reference/tokens.html#byte-string-literals
[`offset`]: https://doc.rust-lang.org/std/primitive.pointer.html#method.offset
Ten en cuenta que hay un bloque [`unsafe`] alrededor de todas las escrituras de memoria. Esto se debe a que el compilador de Rust no puede probar que los punteros crudos que creamos son válidos. Podrían apuntar a cualquier lugar y causar corrupción de datos. Al poner estas operaciones en un bloque `unsafe`, básicamente le decimos al compilador que estamos absolutamente seguros de que las operaciones son válidas. Sin embargo, un bloque `unsafe` no desactiva las verificaciones de seguridad de Rust; simplemente permite realizar [cinco operaciones adicionales].
[`unsafe`]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html
[cinco operaciones adicionales]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#unsafe-superpowers
Quiero enfatizar que **esta no es la forma en que queremos hacer las cosas en Rust**. Es muy fácil cometer errores al trabajar con punteros crudos dentro de bloques `unsafe`. Por ejemplo, podríamos escribir más allá del final del búfer si no somos cuidadosos.
Por lo tanto, queremos minimizar el uso de `unsafe` tanto como sea posible. Rust nos permite lograr esto creando abstracciones seguras. Por ejemplo, podríamos crear un tipo de búfer VGA que encapsule toda la inseguridad y garantice que sea _imposible_ hacer algo incorrecto desde el exterior. De esta manera, solo necesitaríamos cantidades mínimas de código `unsafe` y podríamos estar seguros de no violar la [seguridad de la memoria]. Crearemos una abstracción segura para el búfer VGA en el próximo artículo.
[seguridad de la memoria]: https://en.wikipedia.org/wiki/Memory_safety
## Ejecutando Nuestro Kernel
Ahora que tenemos un ejecutable que realiza algo perceptible, es momento de ejecutarlo. Primero, necesitamos convertir nuestro kernel compilado en una imagen de disco arrancable vinculándolo con un cargador de arranque. Luego, podemos ejecutar la imagen de disco en la máquina virtual [QEMU] o iniciarla en hardware real usando una memoria USB.
### Creando una Bootimage
Para convertir nuestro kernel compilado en una imagen de disco arrancable, debemos vincularlo con un cargador de arranque. Como aprendimos en la [sección sobre el proceso de arranque], el cargador de arranque es responsable de inicializar la CPU y cargar nuestro kernel.
[sección sobre el proceso de arranque]: #the-boot-process
En lugar de escribir nuestro propio cargador de arranque, lo cual es un proyecto en sí mismo, usamos el crate [`bootloader`]. Este crate implementa un cargador de arranque básico para BIOS sin dependencias en C, solo Rust y ensamblador en línea. Para usarlo y arrancar nuestro kernel, necesitamos agregarlo como dependencia:
[`bootloader`]: https://crates.io/crates/bootloader
```toml
# in Cargo.toml
[dependencies]
bootloader = "0.9"
```
**Nota:** Este artículo solo es compatible con `bootloader v0.9`. Las versiones más recientes usan un sistema de construcción diferente y generarán errores de compilación al seguir este artículo.
Agregar el bootloader como dependencia no es suficiente para crear una imagen de disco arrancable. El problema es que necesitamos vincular nuestro kernel con el bootloader después de la compilación, pero cargo no tiene soporte para [scripts post-compilación].
[scripts post-compilación]: https://github.com/rust-lang/cargo/issues/545
Para resolver este problema, creamos una herramienta llamada `bootimage` que primero compila el kernel y el bootloader, y luego los vincula para crear una imagen de disco arrancable. Para instalar esta herramienta, dirígete a tu directorio de inicio (o cualquier directorio fuera de tu proyecto de cargo) y ejecuta el siguiente comando en tu terminal:
```
cargo install bootimage
```
Para ejecutar `bootimage` y compilar el bootloader, necesitas tener instalado el componente `llvm-tools-preview` de rustup. Puedes hacerlo ejecutando el comando correspondiente.
Después de instalar `bootimage` y agregar el componente `llvm-tools-preview`, puedes crear una imagen de disco arrancable regresando al directorio de tu proyecto de cargo y ejecutando:
```
> cargo bootimage
```
Vemos que la herramienta recompila nuestro kernel usando `cargo build`, por lo que automáticamente aplicará cualquier cambio que realices. Después, compila el bootloader, lo cual puede tardar un poco. Como ocurre con todas las dependencias de los crates, solo se compila una vez y luego se almacena en caché, por lo que las compilaciones posteriores serán mucho más rápidas. Finalmente, `bootimage` combina el bootloader y tu kernel en una imagen de disco arrancable.
Después de ejecutar el comando, deberías ver una imagen de disco arrancable llamada `bootimage-blog_os.bin` en tu directorio `target/x86_64-blog_os/debug`. Puedes arrancarla en una máquina virtual o copiarla a una unidad USB para arrancarla en hardware real. (Ten en cuenta que esta no es una imagen de CD, que tiene un formato diferente, por lo que grabarla en un CD no funcionará).
#### ¿Cómo funciona?
La herramienta `bootimage` realiza los siguientes pasos detrás de escena:
- Compila nuestro kernel en un archivo [ELF].
- Compila la dependencia del bootloader como un ejecutable independiente.
- Vincula los bytes del archivo ELF del kernel con el bootloader.
[ELF]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
[rust-osdev/bootloader]: https://github.com/rust-osdev/bootloader
Al arrancar, el bootloader lee y analiza el archivo ELF anexado. Luego, mapea los segmentos del programa a direcciones virtuales en las tablas de páginas, inicializa a cero la sección `.bss` y configura una pila. Finalmente, lee la dirección del punto de entrada (nuestra función `_start`) y salta a ella.
### Arrancando en QEMU
Ahora podemos arrancar la imagen de disco en una máquina virtual. Para arrancarla en [QEMU], ejecuta el comando correspondiente.
[QEMU]: https://www.qemu.org/
```
> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin
```
Esto abre una ventana separada que debería verse similar a esto:
![QEMU mostrando "Hello World!"](qemu.png)
Vemos que nuestro "Hello World!" es visible en la pantalla.
### Máquina Real
También es posible escribir la imagen a una memoria USB y arrancarla en una máquina real, **pero ten mucho cuidado** al elegir el nombre correcto del dispositivo, porque **todo en ese dispositivo será sobrescrito**:
```
> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync
```
Donde `sdX` es el nombre del dispositivo de tu memoria USB.
Después de escribir la imagen en la memoria USB, puedes ejecutarla en hardware real iniciando desde ella. Probablemente necesitarás usar un menú de arranque especial o cambiar el orden de arranque en la configuración del BIOS para iniciar desde la memoria USB. Ten en cuenta que actualmente no funciona para máquinas UEFI, ya que el crate `bootloader` aún no tiene soporte para UEFI.
### Usando `cargo run`
Para facilitar la ejecución de nuestro kernel en QEMU, podemos configurar la clave de configuración `runner` para cargo:
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "none")']
runner = "bootimage runner"
```
La tabla `target.'cfg(target_os = "none")'` se aplica a todos los objetivos cuyo campo `"os"` en el archivo de configuración del objetivo esté configurado como `"none"`. Esto incluye nuestro objetivo `x86_64-blog_os.json`. La clave `runner` especifica el comando que debe ejecutarse para `cargo run`. El comando se ejecuta después de una compilación exitosa, con la ruta del ejecutable pasada como el primer argumento. Consulta la [documentación de cargo][configuración de cargo] para más detalles.
El comando `bootimage runner` está específicamente diseñado para ser utilizado como un ejecutable `runner`. Vincula el ejecutable dado con la dependencia del bootloader del proyecto y luego lanza QEMU. Consulta el [README de `bootimage`] para más detalles y posibles opciones de configuración.
[README de `bootimage`]: https://github.com/rust-osdev/bootimage
Ahora podemos usar `cargo run` para compilar nuestro kernel e iniciarlo en QEMU.
## ¿Qué sigue?
En el próximo artículo, exploraremos el búfer de texto VGA con más detalle y escribiremos una interfaz segura para él. También añadiremos soporte para el macro `println`.

View File

@@ -0,0 +1,340 @@
+++
title = "Modo de Texto VGA"
weight = 3
path = "modo-texto-vga"
date = 2018-02-26
[extra]
chapter = "Fundamentos"
+++
El [modo de texto VGA] es una forma sencilla de imprimir texto en la pantalla. En esta publicación, creamos una interfaz que hace que su uso sea seguro y simple al encapsular toda la inseguridad en un módulo separado. También implementamos soporte para los [macros de formato] de Rust.
[modo de texto VGA]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
[macros de formato]: https://doc.rust-lang.org/std/fmt/#related-macros
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes algún problema o pregunta, por favor abre un issue allí. También puedes dejar comentarios [al final]. El código fuente completo para esta publicación se puede encontrar en la rama [`post-03`][rama del post].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[rama del post]: https://github.com/phil-opp/blog_os/tree/post-03
<!-- toc -->
## El Buffer de Texto VGA
Para imprimir un carácter en la pantalla en modo de texto VGA, uno tiene que escribirlo en el buffer de texto del hardware VGA. El buffer de texto VGA es un arreglo bidimensional con típicamente 25 filas y 80 columnas, que se renderiza directamente en la pantalla. Cada entrada del arreglo describe un solo carácter de pantalla a través del siguiente formato:
Bit(s) | Valor
------ | ----------------
0-7 | Código de punto ASCII
8-11 | Color de primer plano
12-14 | Color de fondo
15 | Parpadeo
El primer byte representa el carácter que debe imprimirse en la [codificación ASCII]. Para ser más específicos, no es exactamente ASCII, sino un conjunto de caracteres llamado [_página de códigos 437_] con algunos caracteres adicionales y ligeras modificaciones. Para simplificar, procederemos a llamarlo un carácter ASCII en esta publicación.
[codificación ASCII]: https://en.wikipedia.org/wiki/ASCII
[_página de códigos 437_]: https://en.wikipedia.org/wiki/Code_page_437
El segundo byte define cómo se muestra el carácter. Los primeros cuatro bits definen el color de primer plano, los siguientes tres bits el color de fondo, y el último bit si el carácter debe parpadear. Los siguientes colores están disponibles:
Número | Color | Número + Bit de Brillo | Color Brillante
------ | ---------- | ---------------------- | -------------
0x0 | Negro | 0x8 | Gris Oscuro
0x1 | Azul | 0x9 | Azul Claro
0x2 | Verde | 0xa | Verde Claro
0x3 | Cian | 0xb | Cian Claro
0x4 | Rojo | 0xc | Rojo Claro
0x5 | Magenta | 0xd | Magenta Claro
0x6 | Marrón | 0xe | Amarillo
0x7 | Gris Claro | 0xf | Blanco
Bit 4 es el _bit de brillo_, que convierte, por ejemplo, azul en azul claro. Para el color de fondo, este bit se reutiliza como el bit de parpadeo.
El buffer de texto VGA es accesible a través de [E/S mapeada en memoria] a la dirección `0xb8000`. Esto significa que las lecturas y escrituras a esa dirección no acceden a la RAM, sino que acceden directamente al buffer de texto en el hardware VGA. Esto significa que podemos leer y escribir a través de operaciones de memoria normales a esa dirección.
[E/S mapeada en memoria]: https://en.wikipedia.org/wiki/Memory-mapped_I/O
Ten en cuenta que el hardware mapeado en memoria podría no soportar todas las operaciones normales de RAM. Por ejemplo, un dispositivo podría soportar solo lecturas por byte y devolver basura cuando se lee un `u64`. Afortunadamente, el buffer de texto [soporta lecturas y escrituras normales], por lo que no tenemos que tratarlo de una manera especial.
[soporta lecturas y escrituras normales]: https://web.stanford.edu/class/cs140/projects/pintos/specs/freevga/vga/vgamem.htm#manip
## Un Módulo de Rust
Ahora que sabemos cómo funciona el buffer VGA, podemos crear un módulo de Rust para manejar la impresión:
```rust
// en src/main.rs
mod vga_buffer;
```
Para el contenido de este módulo, creamos un nuevo archivo `src/vga_buffer.rs`. Todo el código a continuación va en nuestro nuevo módulo (a menos que se especifique lo contrario).
### Colores
Primero, representamos los diferentes colores usando un enum:
```rust
// en src/vga_buffer.rs
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
Black = 0,
Blue = 1,
Green = 2,
Cyan = 3,
Red = 4,
Magenta = 5,
Brown = 6,
LightGray = 7,
DarkGray = 8,
LightBlue = 9,
LightGreen = 10,
LightCyan = 11,
LightRed = 12,
Pink = 13,
Yellow = 14,
White = 15,
}
```
Usamos un [enum similar a C] aquí para especificar explícitamente el número para cada color. Debido al atributo `repr(u8)`, cada variante del enum se almacena como un `u8`. En realidad, 4 bits serían suficientes, pero Rust no tiene un tipo `u4`.
[enum similar a C]: https://doc.rust-lang.org/rust-by-example/custom_types/enum/c_like.html
Normalmente, el compilador emitiría una advertencia por cada variante no utilizada. Al usar el atributo `#[allow(dead_code)]`, deshabilitamos estas advertencias para el enum `Color`.
Al [derivar] los rasgos [`Copy`], [`Clone`], [`Debug`], [`PartialEq`], y [`Eq`], habilitamos la [semántica de copia] para el tipo y lo hacemos imprimible y comparable.
[derivar]: https://doc.rust-lang.org/rust-by-example/trait/derive.html
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[`Clone`]: https://doc.rust-lang.org/nightly/core/clone/trait.Clone.html
Para representar un código de color completo que especifique el color de primer plano y de fondo, creamos un [nuevo tipo] sobre `u8`:
[nuevo tipo]: https://doc.rust-lang.org/rust-by-example/generics/new_types.html
```rust
// en src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
struct ColorCode(u8);
impl ColorCode {
fn new(foreground: Color, background: Color) -> ColorCode {
ColorCode((background as u8) << 4 | (foreground as u8))
}
}
```
La estructura `ColorCode` contiene el byte de color completo, que incluye el color de primer plano y de fondo. Como antes, derivamos los rasgos `Copy` y `Debug` para él. Para asegurar que `ColorCode` tenga el mismo diseño de datos exacto que un `u8`, usamos el atributo [`repr(transparent)`].
[`repr(transparent)`]: https://doc.rust-lang.org/nomicon/other-reprs.html#reprtransparent
### Buffer de Texto
Ahora podemos agregar estructuras para representar un carácter de pantalla y el buffer de texto:
```rust
// en src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)]
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}
```
Dado que el orden de los campos en las estructuras predeterminadas no está definido en Rust, necesitamos el atributo [`repr(C)`]. Garantiza que los campos de la estructura se dispongan exactamente como en una estructura C y, por lo tanto, garantiza el orden correcto de los campos. Para la estructura `Buffer`, usamos [`repr(transparent)`] nuevamente para asegurar que tenga el mismo diseño de memoria que su único campo.
[`repr(C)`]: https://doc.rust-lang.org/nightly/nomicon/other-reprs.html#reprc
Para escribir en pantalla, ahora creamos un tipo de escritor:
```rust
// en src/vga_buffer.rs
pub struct Writer {
column_position: usize,
color_code: ColorCode,
buffer: &'static mut Buffer,
}
```
El escritor siempre escribirá en la última línea y desplazará las líneas hacia arriba cuando una línea esté llena (o en `\n`). El campo `column_position` lleva un seguimiento de la posición actual en la última fila. Los colores de primer plano y de fondo actuales están especificados por `color_code` y una referencia al buffer VGA está almacenada en `buffer`. Ten en cuenta que necesitamos una [vida útil explícita] aquí para decirle al compilador cuánto tiempo es válida la referencia. La vida útil [`'static`] especifica que la referencia es válida durante todo el tiempo de ejecución del programa (lo cual es cierto para el buffer de texto VGA).
[vida útil explícita]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#lifetime-annotation-syntax
[`'static`]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime
### Impresión
Ahora podemos usar el `Writer` para modificar los caracteres del buffer. Primero creamos un método para escribir un solo byte ASCII:
```rust
// en src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
if self.column_position >= BUFFER_WIDTH {
self.new_line();
}
let row = BUFFER_HEIGHT - 1;
let col = self.column_position;
let color_code = self.color_code;
self.buffer.chars[row][col].write(ScreenChar {
ascii_character: byte,
color_code,
});
self.column_position += 1;
}
}
}
fn new_line(&mut self) {/* TODO */}
}
```
Si el byte es el byte de [nueva línea] `\n`, el escritor no imprime nada. En su lugar, llama a un método `new_line`, que implementaremos más tarde. Otros bytes se imprimen en la pantalla en el segundo caso de `match`.
[nueva línea]: https://en.wikipedia.org/wiki/Newline
Al imprimir un byte, el escritor verifica si la línea actual está llena. En ese caso, se usa una llamada a `new_line` para envolver la línea. Luego escribe un nuevo `ScreenChar` en el buffer en la posición actual. Finalmente, se avanza la posición de la columna actual.
Para imprimir cadenas completas, podemos convertirlas en bytes e imprimirlas una por una:
```rust
// en src/vga_buffer.rs
impl Writer {
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() {
match byte {
// byte ASCII imprimible o nueva línea
0x20..=0x7e | b'\n' => self.write_byte(byte),
// no es parte del rango ASCII imprimible
_ => self.write_byte(0xfe),
}
}
}
}
```
El buffer de texto VGA solo soporta ASCII y los bytes adicionales de [página de códigos 437]. Las cadenas de Rust son [UTF-8] por defecto, por lo que podrían contener bytes que no son soportados por el buffer de texto VGA. Usamos un `match` para diferenciar los bytes ASCII imprimibles (una nueva línea o cualquier cosa entre un carácter de espacio y un carácter `~`) y los bytes no imprimibles. Para los bytes no imprimibles, imprimimos un carácter `■`, que tiene el código hexadecimal `0xfe` en el hardware VGA.
[página de códigos 437]: https://en.wikipedia.org/wiki/Code_page_437
[UTF-8]: https://www.fileformat.info/info/unicode/utf8.htm
#### ¡Pruébalo!
Para escribir algunos caracteres en la pantalla, puedes crear una función temporal:
```rust
// en src/vga_buffer.rs
pub fn print_something() {
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
writer.write_byte(b'H');
writer.write_string("ello ");
writer.write_string("Wörld!");
}
```
Primero crea un nuevo Writer que apunta al buffer VGA en `0xb8000`. La sintaxis para esto podría parecer un poco extraña: Primero, convertimos el entero `0xb8000` como un [puntero sin procesar] mutable. Luego lo convertimos en una referencia mutable al desreferenciarlo (a través de `*`) y tomarlo prestado inmediatamente (a través de `&mut`). Esta conversión requiere un [bloque `unsafe`], ya que el compilador no puede garantizar que el puntero sin procesar sea válido.
[puntero sin procesar]: https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
[bloque `unsafe`]: https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html
Luego escribe el byte `b'H'` en él. El prefijo `b` crea un [literal de byte], que representa un carácter ASCII. Al escribir las cadenas `"ello "` y `"Wörld!"`, probamos nuestro método `write_string` y el manejo de caracteres no imprimibles. Para ver la salida, necesitamos llamar a la función `print_something` desde nuestra función `_start`:
```rust
// en src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
vga_buffer::print_something();
loop {}
}
```
Cuando ejecutamos nuestro proyecto ahora, se debería imprimir un `Hello W■■rld!` en la esquina inferior izquierda de la pantalla en amarillo:
[literal de byte]: https://doc.rust-lang.org/reference/tokens.html#byte-literals
![Salida de QEMU con un `Hello W■■rld!` en amarillo en la esquina inferior izquierda](vga-hello.png)
Observa que la `ö` se imprime como dos caracteres `■`. Eso es porque `ö` está representado por dos bytes en [UTF-8], los cuales no caen en el rango ASCII imprimible. De hecho, esta es una propiedad fundamental de UTF-8: los bytes individuales de valores multibyte nunca son ASCII válidos.
### Volátil
Acabamos de ver que nuestro mensaje se imprimió correctamente. Sin embargo, podría no funcionar con futuros compiladores de Rust que optimicen más agresivamente.
El problema es que solo escribimos en el `Buffer` y nunca leemos de él nuevamente. El compilador no sabe que realmente accedemos a la memoria del buffer VGA (en lugar de la RAM normal) y no sabe nada sobre el efecto secundario de que algunos caracteres aparezcan en la pantalla. Por lo tanto, podría decidir que estas escrituras son innecesarias y pueden omitirse. Para evitar esta optimización errónea, necesitamos especificar estas escrituras como _[volátiles]_. Esto le dice al compilador que la escritura tiene efectos secundarios y no debe ser optimizada.
[volátiles]: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
Para usar escrituras volátiles para el buffer VGA, usamos la biblioteca [volatile][crate volatile]. Este _crate_ (así es como se llaman los paquetes en el mundo de Rust) proporciona un tipo de envoltura `Volatile` con métodos `read` y `write`. Estos métodos usan internamente las funciones [read_volatile] y [write_volatile] de la biblioteca principal y, por lo tanto, garantizan que las lecturas/escrituras no sean optimizadas.
[crate volatile]: https://docs.rs/volatile
[read_volatile]: https://doc.rust-lang.org/nightly/core/ptr/fn.read_volatile.html
[write_volatile]: https://doc.rust-lang.org/nightly/core/ptr/fn.write_volatile.html
Podemos agregar una dependencia en el crate `volatile` agregándolo a la sección `dependencies` de nuestro `Cargo.toml`:
```toml
# en Cargo.toml
[dependencies]
volatile = "0.2.6"
```
Asegúrate de especificar la versión `0.2.6` de `volatile`. Las versiones más nuevas del crate no son compatibles con esta publicación.
`0.2.6` es el número de versión [semántica]. Para más información, consulta la guía [Especificar Dependencias] de la documentación de cargo.
[semántica]: https://semver.org/
[Especificar Dependencias]: https://doc.crates.io/specifying-dependencies.html
Vamos a usarlo para hacer que las escrituras al buffer VGA sean volátiles. Actualizamos nuestro tipo `Buffer` de la siguiente manera:
```rust
// en src/vga_buffer.rs
use volatile::Volatile;
struct Buffer {
chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
```
En lugar de un `ScreenChar`, ahora estamos usando un `Volatile<ScreenChar>`. (El tipo `Volatile` es [genérico] y puede envolver (casi) cualquier tipo). Esto asegura que no podamos escribir accidentalmente en él “normalmente”. En su lugar, ahora tenemos que usar el método `write`.
[genérico]: https://doc.rust-lang.org/book/ch10-01-syntax.html
Esto significa que tenemos que actualizar nuestro método `Writer::write_byte`:
```rust
// en src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
...
self.buffer.chars[row][col].write(ScreenChar {
ascii_character: byte,
color_code,
});
...
}
}
}
...
}
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
+++
title = "Excepciones de CPU"
weight = 5
path = "cpu-exceptions"
date = 2018-06-17
[extra]
chapter = "Interrupciones"
+++
Las excepciones de CPU ocurren en diversas situaciones erróneas, por ejemplo, al acceder a una dirección de memoria inválida o al dividir por cero. Para reaccionar ante ellas, tenemos que configurar una _tabla de descriptores de interrupción_ (IDT, por sus siglas en inglés) que proporcione funciones manejadoras. Al final de esta publicación, nuestro núcleo será capaz de capturar [excepciones de punto de interrupción] y reanudar la ejecución normal después.
[excepciones de punto de interrupción]: https://wiki.osdev.org/Exceptions#Breakpoint
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tiene algún problema o pregunta, por favor abra un problema allí. También puede dejar comentarios [al final]. El código fuente completo de esta publicación se puede encontrar en la rama [`post-05`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-05
<!-- toc -->
## Descripción general
Una excepción indica que algo está mal con la instrucción actual. Por ejemplo, la CPU emite una excepción si la instrucción actual intenta dividir por 0. Cuando se produce una excepción, la CPU interrumpe su trabajo actual y llama inmediatamente a una función manejadora de excepciones específica, dependiendo del tipo de excepción.
En x86, hay alrededor de 20 tipos diferentes de excepciones de CPU. Las más importantes son:
- **Fallo de página**: Un fallo de página ocurre en accesos a memoria ilegales. Por ejemplo, si la instrucción actual intenta leer de una página no mapeada o intenta escribir en una página de solo lectura.
- **Código de operación inválido**: Esta excepción ocurre cuando la instrucción actual es inválida, por ejemplo, cuando intentamos usar nuevas [instrucciones SSE] en una CPU antigua que no las soporta.
- **Fallo de protección general**: Esta es la excepción con el rango más amplio de causas. Ocurre en varios tipos de violaciones de acceso, como intentar ejecutar una instrucción privilegiada en código de nivel de usuario o escribir en campos reservados en registros de configuración.
- **Doble fallo**: Cuando ocurre una excepción, la CPU intenta llamar a la función manejadora correspondiente. Si ocurre otra excepción _mientras se llama a la función manejadora de excepciones_, la CPU genera una excepción de doble fallo. Esta excepción también ocurre cuando no hay una función manejadora registrada para una excepción.
- **Triple fallo**: Si ocurre una excepción mientras la CPU intenta llamar a la función manejadora de doble fallo, emite un _triple fallo_ fatal. No podemos capturar o manejar un triple fallo. La mayoría de los procesadores reaccionan reiniciándose y reiniciando el sistema operativo.
[instrucciones SSE]: https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions
Para ver la lista completa de excepciones, consulte la [wiki de OSDev][exceptions].
[exceptions]: https://wiki.osdev.org/Exceptions
### La tabla de descriptores de interrupción
Para poder capturar y manejar excepciones, tenemos que configurar una llamada _tabla de descriptores de interrupción_ (IDT). En esta tabla, podemos especificar una función manejadora para cada excepción de CPU. El hardware utiliza esta tabla directamente, por lo que necesitamos seguir un formato predefinido. Cada entrada debe tener la siguiente estructura de 16 bytes:
Tipo| Nombre | Descripción
----|--------------------------|-----------------------------------
u16 | Puntero a función [0:15] | Los bits más bajos del puntero a la función manejadora.
u16 | Selector GDT | Selector de un segmento de código en la [tabla de descriptores global].
u16 | Opciones | (ver abajo)
u16 | Puntero a función [16:31] | Los bits del medio del puntero a la función manejadora.
u32 | Puntero a función [32:63] | Los bits restantes del puntero a la función manejadora.
u32 | Reservado |
[tabla de descriptores global]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
El campo de opciones tiene el siguiente formato:
Bits | Nombre | Descripción
------|-----------------------------------|-----------------------------------
0-2 | Índice de tabla de pila de interrupción | 0: No cambiar pilas, 1-7: Cambiar a la n-ésima pila en la Tabla de Pila de Interrupción cuando se llama a este manejador.
3-7 | Reservado |
8 | 0: Puerta de interrupción, 1: Puerta de trampa | Si este bit es 0, las interrupciones están deshabilitadas cuando se llama a este manejador.
9-11 | debe ser uno |
12 | debe ser cero |
1314 | Nivel de privilegio del descriptor (DPL) | El nivel mínimo de privilegio requerido para llamar a este manejador.
15 | Presente |
Cada excepción tiene un índice de IDT predefinido. Por ejemplo, la excepción de código de operación inválido tiene índice de tabla 6 y la excepción de fallo de página tiene índice de tabla 14. Así, el hardware puede cargar automáticamente la entrada de IDT correspondiente para cada excepción. La [Tabla de Excepciones][exceptions] en la wiki de OSDev muestra los índices de IDT de todas las excepciones en la columna “Vector nr.”.
Cuando ocurre una excepción, la CPU realiza aproximadamente lo siguiente:
1. Empuja algunos registros en la pila, incluyendo el puntero de instrucción y el registro [RFLAGS]. (Usaremos estos valores más adelante en esta publicación.)
2. Lee la entrada correspondiente de la tabla de descriptores de interrupción (IDT). Por ejemplo, la CPU lee la 14ª entrada cuando ocurre un fallo de página.
3. Verifica si la entrada está presente y, si no, genera un doble fallo.
4. Deshabilita las interrupciones de hardware si la entrada es una puerta de interrupción (bit 40 no establecido).
5. Carga el selector [GDT] especificado en el CS (segmento de código).
6. Salta a la función manejadora especificada.
[RFLAGS]: https://en.wikipedia.org/wiki/FLAGS_register
[GDT]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
No se preocupe por los pasos 4 y 5 por ahora; aprenderemos sobre la tabla de descriptores global y las interrupciones de hardware en publicaciones futuras.
## Un tipo de IDT
En lugar de crear nuestro propio tipo de IDT, utilizaremos la estructura [`InterruptDescriptorTable`] del crate `x86_64`, que luce así:
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
``` rust
#[repr(C)]
pub struct InterruptDescriptorTable {
pub divide_by_zero: Entry<HandlerFunc>,
pub debug: Entry<HandlerFunc>,
pub non_maskable_interrupt: Entry<HandlerFunc>,
pub breakpoint: Entry<HandlerFunc>,
pub overflow: Entry<HandlerFunc>,
pub bound_range_exceeded: Entry<HandlerFunc>,
pub invalid_opcode: Entry<HandlerFunc>,
pub device_not_available: Entry<HandlerFunc>,
pub double_fault: Entry<HandlerFuncWithErrCode>,
pub invalid_tss: Entry<HandlerFuncWithErrCode>,
pub segment_not_present: Entry<HandlerFuncWithErrCode>,
pub stack_segment_fault: Entry<HandlerFuncWithErrCode>,
pub general_protection_fault: Entry<HandlerFuncWithErrCode>,
pub page_fault: Entry<PageFaultHandlerFunc>,
pub x87_floating_point: Entry<HandlerFunc>,
pub alignment_check: Entry<HandlerFuncWithErrCode>,
pub machine_check: Entry<HandlerFunc>,
pub simd_floating_point: Entry<HandlerFunc>,
pub virtualization: Entry<HandlerFunc>,
pub security_exception: Entry<HandlerFuncWithErrCode>,
// algunos campos omitidos
}
```
Los campos tienen el tipo [`idt::Entry<F>`], que es una estructura que representa los campos de una entrada de IDT (ver tabla anterior). El parámetro de tipo `F` define el tipo esperado de la función manejadora. Vemos que algunas entradas requieren un [`HandlerFunc`] y algunas entradas requieren un [`HandlerFuncWithErrCode`]. El fallo de página incluso tiene su propio tipo especial: [`PageFaultHandlerFunc`].
[`idt::Entry<F>`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.Entry.html
[`HandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFunc.html
[`HandlerFuncWithErrCode`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFuncWithErrCode.html
[`PageFaultHandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.PageFaultHandlerFunc.html
Veamos primero el tipo `HandlerFunc`:
```rust
type HandlerFunc = extern "x86-interrupt" fn(_: InterruptStackFrame);
```
Es un [alias de tipo] para un tipo de `extern "x86-interrupt" fn`. La palabra clave `extern` define una función con una [convención de llamada foránea] y se utiliza a menudo para comunicarse con código C (`extern "C" fn`). Pero, ¿cuál es la convención de llamada `x86-interrupt`?
[alias de tipo]: https://doc.rust-lang.org/book/ch19-04-advanced-types.html#creating-type-synonyms-with-type-aliases
[convención de llamada foránea]: https://doc.rust-lang.org/nomicon/ffi.html#foreign-calling-conventions
## La convención de llamada de interrupción
Las excepciones son bastante similares a las llamadas a funciones: la CPU salta a la primera instrucción de la función llamada y la ejecuta. Después, la CPU salta a la dirección de retorno y continúa la ejecución de la función madre.
Sin embargo, hay una gran diferencia entre excepciones y llamadas a funciones: una llamada a función es invocada voluntariamente por una instrucción `call` insertada por el compilador, mientras que una excepción puede ocurrir en _cualquier_ instrucción. Para entender las consecuencias de esta diferencia, necesitamos examinar las llamadas a funciones en más detalle.
[Convenciones de llamada] especifican los detalles de una llamada a función. Por ejemplo, especifican dónde se colocan los parámetros de la función (por ejemplo, en registros o en la pila) y cómo se devuelven los resultados. En x86_64 Linux, se aplican las siguientes reglas para funciones C (especificadas en el [ABI de System V]):
[Convenciones de llamada]: https://en.wikipedia.org/wiki/Calling_convention
[ABI de System V]: https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf
- los primeros seis argumentos enteros se pasan en los registros `rdi`, `rsi`, `rdx`, `rcx`, `r8`, `r9`
- argumentos adicionales se pasan en la pila
- los resultados se devuelven en `rax` y `rdx`
Tenga en cuenta que Rust no sigue el ABI de C (de hecho, [ni siquiera hay un ABI de Rust todavía][rust abi]), por lo que estas reglas solo se aplican a funciones declaradas como `extern "C" fn`.
[rust abi]: https://github.com/rust-lang/rfcs/issues/600
### Registros preservados y de uso
La convención de llamada divide los registros en dos partes: registros _preservados_ y registros _de uso_.
Los valores de los registros _preservados_ deben permanecer sin cambios a través de llamadas a funciones. Por lo tanto, una función llamada (la _“llamada”_) solo puede sobrescribir estos registros si restaura sus valores originales antes de retornar. Por ello, estos registros se llaman _“guardados por el llamado”_. Un patrón común es guardar estos registros en la pila al inicio de la función y restaurarlos justo antes de retornar.
En contraste, una función llamada puede sobrescribir registros _de uso_ sin restricciones. Si el llamador quiere preservar el valor de un registro de uso a través de una llamada a función, necesita respaldarlo y restaurarlo antes de la llamada a la función (por ejemplo, empujándolo a la pila). Así, los registros de uso son _guardados por el llamador_.
En x86_64, la convención de llamada C especifica los siguientes registros preservados y de uso:
registros preservados | registros de uso
---|---
`rbp`, `rbx`, `rsp`, `r12`, `r13`, `r14`, `r15` | `rax`, `rcx`, `rdx`, `rsi`, `rdi`, `r8`, `r9`, `r10`, `r11`
_guardados por el llamado_ | _guardados por el llamador_
El compilador conoce estas reglas, por lo que genera el código en consecuencia. Por ejemplo, la mayoría de las funciones comienzan con un `push rbp`, que respalda `rbp` en la pila (porque es un registro guardado por el llamado).
### Preservando todos los registros
A diferencia de las llamadas a funciones, las excepciones pueden ocurrir en _cualquier_ instrucción. En la mayoría de los casos, ni siquiera sabemos en tiempo de compilación si el código generado causará una excepción. Por ejemplo, el compilador no puede saber si una instrucción provoca un desbordamiento de pila o un fallo de página.
Dado que no sabemos cuándo ocurrirá una excepción, no podemos respaldar ningún registro antes. Esto significa que no podemos usar una convención de llamada que dependa de registros guardados por el llamador para los manejadores de excepciones. En su lugar, necesitamos una convención de llamada que preserve _todos los registros_. La convención de llamada `x86-interrupt` es una de esas convenciones, por lo que garantiza que todos los valores de los registros se restauren a sus valores originales al retornar de la función.
Tenga en cuenta que esto no significa que todos los registros se guarden en la pila al ingresar la función. En su lugar, el compilador solo respalda los registros que son sobrescritos por la función. De esta manera, se puede generar un código muy eficiente para funciones cortas que solo utilizan unos pocos registros.
### El marco de pila de interrupción
En una llamada a función normal (usando la instrucción `call`), la CPU empuja la dirección de retorno antes de saltar a la función objetivo. Al retornar de la función (usando la instrucción `ret`), la CPU extrae esta dirección de retorno y salta a ella. Por lo tanto, el marco de pila de una llamada a función normal se ve así:
![marco de pila de función](function-stack-frame.svg)
Sin embargo, para los manejadores de excepciones e interrupciones, empujar una dirección de retorno no sería suficiente, ya que los manejadores de interrupción a menudo se ejecutan en un contexto diferente (puntero de pila, flags de CPU, etc.). En cambio, la CPU realiza los siguientes pasos cuando ocurre una interrupción:
0. **Guardando el antiguo puntero de pila**: La CPU lee los valores del puntero de pila (`rsp`) y del registro del segmento de pila (`ss`) y los recuerda en un búfer interno.
1. **Alineando el puntero de pila**: Una interrupción puede ocurrir en cualquier instrucción, por lo que el puntero de pila también puede tener cualquier valor. Sin embargo, algunas instrucciones de CPU (por ejemplo, algunas instrucciones SSE) requieren que el puntero de pila esté alineado en un límite de 16 bytes, por lo que la CPU realiza tal alineación inmediatamente después de la interrupción.
2. **Cambiando de pilas** (en algunos casos): Se produce un cambio de pila cuando cambia el nivel de privilegio de la CPU, por ejemplo, cuando ocurre una excepción de CPU en un programa en modo usuario. También es posible configurar los cambios de pila para interrupciones específicas utilizando la llamada _Tabla de Pila de Interrupción_ (descrita en la próxima publicación).
3. **Empujando el antiguo puntero de pila**: La CPU empuja los valores `rsp` y `ss` del paso 0 a la pila. Esto hace posible restaurar el puntero de pila original al retornar de un manejador de interrupción.
4. **Empujando y actualizando el registro `RFLAGS`**: El registro [`RFLAGS`] contiene varios bits de control y estado. Al entrar en la interrupción, la CPU cambia algunos bits y empuja el antiguo valor.
5. **Empujando el puntero de instrucción**: Antes de saltar a la función manejadora de la interrupción, la CPU empuja el puntero de instrucción (`rip`) y el segmento de código (`cs`). Esto es comparable al empuje de la dirección de retorno de una llamada a función normal.
6. **Empujando un código de error** (para algunas excepciones): Para algunas excepciones específicas, como los fallos de página, la CPU empuja un código de error, que describe la causa de la excepción.
7. **Invocando el manejador de interrupción**: La CPU lee la dirección y el descriptor de segmento de la función manejadora de interrupción del campo correspondiente en la IDT. Luego, invoca este manejador cargando los valores en los registros `rip` y `cs`.
[`RFLAGS`]: https://en.wikipedia.org/wiki/FLAGS_register
Así, el _marco de pila de interrupción_ se ve así:
![marco de pila de interrupción](exception-stack-frame.svg)
En el crate `x86_64`, el marco de pila de interrupción está representado por la estructura [`InterruptStackFrame`]. Se pasa a los manejadores de interrupción como `&mut` y se puede utilizar para recuperar información adicional sobre la causa de la excepción. La estructura no contiene un campo de código de error, ya que solo algunas pocas excepciones empujan un código de error. Estas excepciones utilizan el tipo de función separado [`HandlerFuncWithErrCode`], que tiene un argumento adicional `error_code`.
[`InterruptStackFrame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptStackFrame.html
### Detrás de las escenas
La convención de llamada `x86-interrupt` es una potente abstracción que oculta casi todos los detalles desordenados del proceso de manejo de excepciones. Sin embargo, a veces es útil saber lo que sucede tras el telón. Aquí hay un breve resumen de las cosas que la convención de llamada `x86-interrupt` maneja:
- **Recuperando los argumentos**: La mayoría de las convenciones de llamada esperan que los argumentos se pasen en registros. Esto no es posible para los manejadores de excepciones, ya que no debemos sobrescribir los valores de ningún registro antes de respaldarlos en la pila. En cambio, la convención de llamada `x86-interrupt` es consciente de que los argumentos ya están en la pila en un desplazamiento específico.
- **Retornando usando `iretq`**: Dado que el marco de pila de interrupción difiere completamente de los marcos de pila de llamadas a funciones normales, no podemos retornar de las funciones manejadoras a través de la instrucción `ret` normal. Así que en su lugar, se debe usar la instrucción `iretq`.
- **Manejando el código de error**: El código de error, que se empuja para algunas excepciones, hace que las cosas sean mucho más complejas. Cambia la alineación de la pila (vea el siguiente punto) y debe ser extraído de la pila antes de retornar. La convención de llamada `x86-interrupt` maneja toda esa complejidad. Sin embargo, no sabe qué función manejadora se utiliza para qué excepción, por lo que necesita deducir esa información del número de argumentos de función. Esto significa que el programador sigue siendo responsable de utilizar el tipo de función correcto para cada excepción. Afortunadamente, el tipo `InterruptDescriptorTable` definido por el crate `x86_64` asegura que se utilicen los tipos de función correctos.
- **Alineando la pila**: Algunas instrucciones (especialmente las instrucciones SSE) requieren que la pila esté alineada a 16 bytes. La CPU asegura esta alineación cada vez que ocurre una excepción, pero para algunas excepciones, puede destruirla de nuevo más tarde cuando empuja un código de error. La convención de llamada `x86-interrupt` se encarga de esto al realinear la pila en este caso.
Si está interesado en más detalles, también tenemos una serie de publicaciones que explican el manejo de excepciones utilizando [funciones desnudas] vinculadas [al final de esta publicación][too-much-magic].
[funciones desnudas]: https://github.com/rust-lang/rfcs/blob/master/text/1201-naked-fns.md
[too-much-magic]: #too-much-magic
## Implementación
Ahora que hemos entendido la teoría, es hora de manejar las excepciones de CPU en nuestro núcleo. Comenzaremos creando un nuevo módulo de interrupciones en `src/interrupts.rs`, que primero crea una función `init_idt` que crea una nueva `InterruptDescriptorTable`:
``` rust
// en src/lib.rs
pub mod interrupts;
// en src/interrupts.rs
use x86_64::structures::idt::InterruptDescriptorTable;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
}
```
Ahora podemos agregar funciones manejadoras. Comenzamos agregando un manejador para la [excepción de punto de interrupción]. La excepción de punto de interrupción es la excepción perfecta para probar el manejo de excepciones. Su único propósito es pausar temporalmente un programa cuando se ejecuta la instrucción de punto de interrupción `int3`.
[excepción de punto de interrupción]: https://wiki.osdev.org/Exceptions#Breakpoint
La excepción de punto de interrupción se utiliza comúnmente en depuradores: cuando el usuario establece un punto de interrupción, el depurador sobrescribe la instrucción correspondiente con la instrucción `int3` para que la CPU lance la excepción de punto de interrupción al llegar a esa línea. Cuando el usuario quiere continuar el programa, el depurador reemplaza la instrucción `int3` con la instrucción original nuevamente y continúa el programa. Para más detalles, vea la serie ["_Cómo funcionan los depuradores_"].
["_Cómo funcionan los depuradores_"]: https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints
Para nuestro caso de uso, no necesitamos sobrescribir instrucciones. En su lugar, solo queremos imprimir un mensaje cuando la instrucción de punto de interrupción se ejecute y luego continuar el programa. Así que creemos una simple función `breakpoint_handler` y la agreguemos a nuestra IDT:
```rust
// en src/interrupts.rs
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
}
extern "x86-interrupt" fn breakpoint_handler(
stack_frame: InterruptStackFrame)
{
println!("EXCEPCIÓN: PUNTO DE INTERRUPCIÓN\n{:#?}", stack_frame);
}
```
Nuestro manejador simplemente muestra un mensaje y imprime en formato bonito el marco de pila de interrupción.
Cuando intentamos compilarlo, ocurre el siguiente error:
```
error[E0658]: la ABI de x86-interrupt es experimental y está sujeta a cambios (ver issue #40180)
--> src/main.rs:53:1
|
53 | / extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
54 | | println!("EXCEPCIÓN: PUNTO DE INTERRUPCIÓN\n{:#?}", stack_frame);
55 | | }
| |_^
|
= ayuda: añade #![feature(abi_x86_interrupt)] a los atributos del crate para habilitarlo
```
Este error ocurre porque la convención de llamada `x86-interrupt` sigue siendo inestable. Para utilizarla de todos modos, tenemos que habilitarla explícitamente añadiendo `#![feature(abi_x86_interrupt)]` en la parte superior de nuestro `lib.rs`.
### Cargando la IDT
Para que la CPU utilice nuestra nueva tabla de descriptores de interrupción, necesitamos cargarla usando la instrucción [`lidt`]. La estructura `InterruptDescriptorTable` del crate `x86_64` proporciona un método [`load`][InterruptDescriptorTable::load] para eso. Intentemos usarlo:
[`lidt`]: https://www.felixcloutier.com/x86/lgdt:lidt
[InterruptDescriptorTable::load]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html#method.load
```rust
// en src/interrupts.rs
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.load();
}
```
Cuando intentamos compilarlo ahora, ocurre el siguiente error:
```
error: `idt` no vive lo suficiente
--> src/interrupts/mod.rs:43:5
|
43 | idt.load();
| ^^^ no vive lo suficiente
44 | }
| - el valor prestado solo es válido hasta aquí
|
= nota: el valor prestado debe ser válido durante la vida estática...
```
Así que el método `load` espera un `&'static self`, es decir, una referencia válida para la duración completa del programa. La razón es que la CPU accederá a esta tabla en cada interrupción hasta que se cargue una IDT diferente. Por lo tanto, usar una vida más corta que `'static` podría llevar a errores de uso después de liberar.
De hecho, esto es exactamente lo que sucede aquí. Nuestra `idt` se crea en la pila, por lo que solo es válida dentro de la función `init`. Después, la memoria de la pila se reutiliza para otras funciones, por lo que la CPU podría interpretar una memoria aleatoria de la pila como IDT. Afortunadamente, el método `load` de `InterruptDescriptorTable` codifica este requisito de vida en su definición de función, para que el compilador de Rust pueda prevenir este posible error en tiempo de compilación.
Para solucionar este problema, necesitamos almacenar nuestra `idt` en un lugar donde tenga una vida `'static`. Para lograr esto, podríamos asignar nuestra IDT en el montón usando [`Box`] y luego convertirla en una referencia `'static`, pero estamos escribiendo un núcleo de sistema operativo y, por lo tanto, no tenemos un montón (todavía).
[`Box`]: https://doc.rust-lang.org/std/boxed/struct.Box.html
Como alternativa, podríamos intentar almacenar la IDT como una `static`:
```rust
static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
```
Sin embargo, hay un problema: las estáticas son inmutables, por lo que no podemos modificar la entrada de punto de interrupción desde nuestra función `init`. Podríamos resolver este problema utilizando un [`static mut`]:
[`static mut`]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable
```rust
static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
unsafe {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
}
```
Esta variante se compila sin errores, pero está lejos de ser idiomática. Las variables `static mut` son muy propensas a condiciones de carrera, por lo que necesitamos un bloque [`unsafe`] en cada acceso.
[`unsafe`]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers
#### Las estáticas perezosas al rescate
Afortunadamente, existe el macro `lazy_static`. En lugar de evaluar una `static` en tiempo de compilación, el macro realiza la inicialización de cuando la `static` es referenciada por primera vez. Por lo tanto, podemos hacer casi todo en el bloque de inicialización e incluso ser capaces de leer valores en tiempo de ejecución.
Ya importamos el crate `lazy_static` cuando [creamos una abstracción para el búfer de texto VGA][vga text buffer lazy static]. Así que podemos utilizar directamente el macro `lazy_static!` para crear nuestra IDT estática:
[vga text buffer lazy static]: @/edition-2/posts/03-vga-text-buffer/index.md#lazy-statics
```rust
// en src/interrupts.rs
use lazy_static::lazy_static;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt
};
}
pub fn init_idt() {
IDT.load();
}
```
Tenga en cuenta cómo esta solución no requiere bloques `unsafe`. El macro `lazy_static!` utiliza `unsafe` detrás de escena, pero está abstraído en una interfaz segura.
### Ejecutándolo
El último paso para hacer que las excepciones funcionen en nuestro núcleo es llamar a la función `init_idt` desde nuestro `main.rs`. En lugar de llamarla directamente, introducimos una función de inicialización general en nuestro `lib.rs`:
```rust
// en src/lib.rs
pub fn init() {
interrupts::init_idt();
}
```
Con esta función, ahora tenemos un lugar central para las rutinas de inicialización que se pueden compartir entre las diferentes funciones `_start` en nuestro `main.rs`, `lib.rs` y pruebas de integración.
Ahora podemos actualizar la función `_start` de nuestro `main.rs` para llamar a `init` y luego activar una excepción de punto de interrupción:
```rust
// en src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("¡Hola Mundo{}", "!");
blog_os::init(); // nueva
// invocar una excepción de punto de interrupción
x86_64::instructions::interrupts::int3(); // nueva
// como antes
#[cfg(test)]
test_main();
println!("¡No se bloqueó!");
loop {}
}
```
Cuando lo ejecutamos en QEMU ahora (usando `cargo run`), vemos lo siguiente:
![QEMU imprimiendo `EXCEPCIÓN: PUNTO DE INTERRUPCIÓN` y el marco de pila de interrupción](qemu-breakpoint-exception.png)
¡Funciona! La CPU invoca exitosamente nuestro manejador de punto de interrupción, que imprime el mensaje, y luego devuelve a la función `_start`, donde se imprime el mensaje `¡No se bloqueó!`.
Vemos que el marco de pila de interrupción nos indica los punteros de instrucción y de pila en el momento en que ocurrió la excepción. Esta información es muy útil al depurar excepciones inesperadas.
### Agregando una prueba
Creemos una prueba que asegure que lo anterior sigue funcionando. Primero, actualizamos la función `_start` para que también llame a `init`:
```rust
// en src/lib.rs
/// Punto de entrada para `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init(); // nueva
test_main();
loop {}
}
```
Recuerde, esta función `_start` se utiliza al ejecutar `cargo test --lib`, ya que Rust prueba el `lib.rs` completamente de forma independiente de `main.rs`. Necesitamos llamar a `init` aquí para configurar una IDT antes de ejecutar las pruebas.
Ahora podemos crear una prueba `test_breakpoint_exception`:
```rust
// en src/interrupts.rs
#[test_case]
fn test_breakpoint_exception() {
// invocar una excepción de punto de interrupción
x86_64::instructions::interrupts::int3();
}
```
La prueba invoca la función `int3` para activar una excepción de punto de interrupción. Al verificar que la ejecución continúa después, verificamos que nuestro manejador de punto de interrupción está funcionando correctamente.
Puedes probar esta nueva prueba ejecutando `cargo test` (todas las pruebas) o `cargo test --lib` (solo las pruebas de `lib.rs` y sus módulos). Deberías ver lo siguiente en la salida:
```
blog_os::interrupts::test_breakpoint_exception... [ok]
```
## ¿Demasiada magia?
La convención de llamada `x86-interrupt` y el tipo [`InterruptDescriptorTable`] hicieron que el proceso de manejo de excepciones fuera relativamente sencillo y sin dolor. Si esto fue demasiada magia para ti y te gusta aprender todos los detalles sucios del manejo de excepciones, tenemos cubiertos: Nuestra serie ["Manejo de Excepciones con Funciones Desnudas"] muestra cómo manejar excepciones sin la convención de llamada `x86-interrupt` y también crea su propio tipo de IDT. Históricamente, estas publicaciones eran las principales publicaciones sobre manejo de excepciones antes de que existieran la convención de llamada `x86-interrupt` y el crate `x86_64`. Tenga en cuenta que estas publicaciones se basan en la [primera edición] de este blog y pueden estar desactualizadas.
["Manejo de Excepciones con Funciones Desnudas"]: @/edition-1/extra/naked-exceptions/_index.md
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[primera edición]: @/edition-1/_index.md
## ¿Qué sigue?
¡Hemos capturado con éxito nuestra primera excepción y regresamos de ella! El siguiente paso es asegurarnos de que capturamos todas las excepciones porque una excepción no capturada causa un [triple fallo] fatal, lo que lleva a un reinicio del sistema. La próxima publicación explica cómo podemos evitar esto al capturar correctamente [dobles fallos].
[triple fallo]: https://wiki.osdev.org/Triple_Fault
[dobles fallos]: https://wiki.osdev.org/Double_Fault#Double_Fault

View File

@@ -0,0 +1,549 @@
+++
title = "Excepciones de Doble Fallo"
weight = 6
path = "double-fault-exceptions"
date = 2018-06-18
[extra]
chapter = "Interrupciones"
+++
Esta publicación explora en detalle la excepción de doble fallo, que ocurre cuando la CPU no logra invocar un controlador de excepciones. Al manejar esta excepción, evitamos fallos _triples_ fatales que causan un reinicio del sistema. Para prevenir fallos triples en todos los casos, también configuramos una _Tabla de Pila de Interrupciones_ (IST) para capturar dobles fallos en una pila de núcleo separada.
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes problemas o preguntas, abre un issue allí. También puedes dejar comentarios [al final]. El código fuente completo de esta publicación se puede encontrar en la rama [`post-06`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-06
<!-- toc -->
## ¿Qué es un Doble Fallo?
En términos simplificados, un doble fallo es una excepción especial que ocurre cuando la CPU no logra invocar un controlador de excepciones. Por ejemplo, ocurre cuando se activa un fallo de página pero no hay un controlador de fallo de página registrado en la [Tabla de Descriptores de Interrupciones][IDT] (IDT). Así que es un poco similar a los bloques de captura de "cosecha todo" en lenguajes de programación con excepciones, por ejemplo, `catch(...)` en C++ o `catch(Exception e)` en Java o C#.
[IDT]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-descriptor-table
Un doble fallo se comporta como una excepción normal. Tiene el número de vector `8` y podemos definir una función controladora normal para él en la IDT. Es realmente importante proporcionar un controlador de doble fallo, porque si un doble fallo no se maneja, ocurre un fallo _triple_ fatal. Los fallos triples no se pueden capturar, y la mayoría del hardware reacciona con un reinicio del sistema.
### Provocando un Doble Fallo
Provocamos un doble fallo al activar una excepción para la cual no hemos definido una función controladora:
```rust
// en src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
// provocar un fallo de página
unsafe {
*(0xdeadbeef as *mut u8) = 42;
};
// como antes
#[cfg(test)]
test_main();
println!("¡No se colapsó!");
loop {}
}
```
Usamos `unsafe` para escribir en la dirección inválida `0xdeadbeef`. La dirección virtual no está mapeada a una dirección física en las tablas de páginas, por lo que ocurre un fallo de página. No hemos registrado un controlador de fallo de página en nuestra [IDT], así que ocurre un doble fallo.
Cuando iniciamos nuestro núcleo ahora, vemos que entra en un bucle de arranque interminable. La razón del bucle de arranque es la siguiente:
1. La CPU intenta escribir en `0xdeadbeef`, lo que causa un fallo de página.
2. La CPU consulta la entrada correspondiente en la IDT y ve que no se especifica ninguna función controladora. Por lo tanto, no puede llamar al controlador de fallo de página y ocurre un doble fallo.
3. La CPU consulta la entrada de la IDT del controlador de doble fallo, pero esta entrada tampoco especifica una función controladora. Por lo tanto, ocurre un fallo _triple_.
4. Un fallo triple es fatal. QEMU reacciona a esto como la mayoría del hardware real y emite un reinicio del sistema.
Por lo tanto, para prevenir este fallo triple, necesitamos proporcionar una función controladora para los fallos de página o un controlador de doble fallo. Queremos evitar los fallos triples en todos los casos, así que empecemos con un controlador de doble fallo que se invoca para todos los tipos de excepciones no manejadas.
## Un Controlador de Doble Fallo
Un doble fallo es una excepción normal con un código de error, por lo que podemos especificar una función controladora similar a nuestra función controladora de punto de interrupción:
```rust
// en src/interrupts.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.double_fault.set_handler_fn(double_fault_handler); // nuevo
idt
};
}
// nuevo
extern "x86-interrupt" fn double_fault_handler(
stack_frame: InterruptStackFrame, _error_code: u64) -> !
{
panic!("EXCEPCIÓN: DOBLE FALLO\n{:#?}", stack_frame);
}
```
Nuestro controlador imprime un corto mensaje de error y volcado del marco de pila de excepciones. El código de error del controlador de doble fallo siempre es cero, así que no hay razón para imprimirlo. Una diferencia con el controlador de punto de interrupción es que el controlador de doble fallo es [_divergente_]. La razón es que la arquitectura `x86_64` no permite devolver de una excepción de doble fallo.
[_divergente_]: https://doc.rust-lang.org/stable/rust-by-example/fn/diverging.html
Cuando iniciamos nuestro núcleo ahora, deberíamos ver que se invoca el controlador de doble fallo:
![QEMU imprimiendo `EXCEPCIÓN: DOBLE FALLO` y el marco de pila de excepciones](qemu-catch-double-fault.png)
¡Funcionó! Aquí está lo que sucedió esta vez:
1. La CPU intenta escribir en `0xdeadbeef`, lo que causa un fallo de página.
2. Como antes, la CPU consulta la entrada correspondiente en la IDT y ve que no se define ninguna función controladora. Así que ocurre un doble fallo.
3. La CPU salta al ahora presente controlador de doble fallo.
El fallo triple (y el bucle de arranque) ya no ocurre, ya que la CPU ahora puede llamar al controlador de doble fallo.
¡Eso fue bastante directo! Entonces, ¿por qué necesitamos una publicación completa sobre este tema? Bueno, ahora podemos capturar la mayoría de los dobles fallos, pero hay algunos casos en los que nuestro enfoque actual no es suficiente.
## Causas de Doble Fallos
Antes de mirar los casos especiales, necesitamos conocer las causas exactas de los dobles fallos. Arriba, usamos una definición bastante vaga:
> Un doble fallo es una excepción especial que ocurre cuando la CPU no logra invocar un controlador de excepciones.
¿Qué significa exactamente _“no logra invocar”_? ¿No está presente el controlador? ¿El controlador está [intercambiado]? ¿Y qué sucede si un controlador causa excepciones a su vez?
[intercambiado]: http://pages.cs.wisc.edu/~remzi/OSTEP/vm-beyondphys.pdf
Por ejemplo, ¿qué ocurre si:
1. ocurre una excepción de punto de interrupción, pero la función controladora correspondiente está intercambiada?
2. ocurre un fallo de página, pero el controlador de fallo de página está intercambiado?
3. un controlador de división por cero causa una excepción de punto de interrupción, pero el controlador de punto de interrupción está intercambiado?
4. nuestro núcleo desborda su pila y se activa la _página de guardia_?
Afortunadamente, el manual de AMD64 ([PDF][AMD64 manual]) tiene una definición exacta (en la Sección 8.2.9). Según él, una “excepción de doble fallo _puede_ ocurrir cuando una segunda excepción ocurre durante el manejo de un controlador de excepción previo (primera)”. El _“puede”_ es importante: Solo combinaciones muy específicas de excepciones conducen a un doble fallo. Estas combinaciones son:
Primera Excepción | Segunda Excepción
------------------|------------------
[División por cero],<br>[TSS No Válido],<br>[Segmento No Presente],<br>[Fallo de Segmento de Pila],<br>[Fallo de Protección General] | [TSS No Válido],<br>[Segmento No Presente],<br>[Fallo de Segmento de Pila],<br>[Fallo de Protección General]
[Fallo de Página] | [Fallo de Página],<br>[TSS No Válido],<br>[Segmento No Presente],<br>[Fallo de Segmento de Pila],<br>[Fallo de Protección General]
[División por cero]: https://wiki.osdev.org/Exceptions#Division_Error
[TSS No Válido]: https://wiki.osdev.org/Exceptions#Invalid_TSS
[Segmento No Presente]: https://wiki.osdev.org/Exceptions#Segment_Not_Present
[Fallo de Segmento de Pila]: https://wiki.osdev.org/Exceptions#Stack-Segment_Fault
[Fallo de Protección General]: https://wiki.osdev.org/Exceptions#General_Protection_Fault
[Fallo de Página]: https://wiki.osdev.org/Exceptions#Page_Fault
[AMD64 manual]: https://www.amd.com/system/files/TechDocs/24593.pdf
Así que, por ejemplo, un fallo de división por cero seguido de un fallo de página está bien (se invoca el controlador de fallo de página), pero un fallo de división por cero seguido de un fallo de protección general conduce a un doble fallo.
Con la ayuda de esta tabla, podemos responder las tres primeras preguntas anteriores:
1. Si ocurre una excepción de punto de interrupción y la función controladora correspondiente está intercambiada, ocurre un _fallo de página_ y se invoca el _controlador de fallo de página_.
2. Si ocurre un fallo de página y el controlador de fallo de página está intercambiado, ocurre un _doble fallo_ y se invoca el _controlador de doble fallo_.
3. Si un controlador de división por cero causa una excepción de punto de interrupción, la CPU intenta invocar el controlador de punto de interrupción. Si el controlador de punto de interrupción está intercambiado, ocurre un _fallo de página_ y se invoca el _controlador de fallo de página_.
De hecho, incluso el caso de una excepción sin una función controladora en la IDT sigue este esquema: Cuando ocurre la excepción, la CPU intenta leer la entrada correspondiente de la IDT. Dado que la entrada es 0, que no es una entrada válida de la IDT, ocurre un _fallo de protección general_. No definimos una función controladora para el fallo de protección general tampoco, así que ocurre otro fallo de protección general. Según la tabla, esto conduce a un doble fallo.
### Desbordamiento de Pila del Núcleo
Veamos la cuarta pregunta:
> ¿Qué ocurre si nuestro núcleo desborda su pila y se activa la página de guardia?
Una página de guardia es una página de memoria especial en la parte inferior de una pila que permite detectar desbordamientos de pila. La página no está mapeada a ningún marco físico, por lo que acceder a ella provoca un fallo de página en lugar de corromper silenciosamente otra memoria. El cargador de arranque establece una página de guardia para nuestra pila de núcleo, así que un desbordamiento de pila provoca un _fallo de página_.
Cuando ocurre un fallo de página, la CPU busca el controlador de fallo de página en la IDT e intenta empujar el [marco de pila de interrupción] en la pila. Sin embargo, el puntero de pila actual aún apunta a la página de guardia no presente. Por lo tanto, ocurre un segundo fallo de página, que causa un doble fallo (según la tabla anterior).
[marco de pila de interrupción]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-stack-frame
Así que la CPU intenta llamar al _controlador de doble fallo_ ahora. Sin embargo, en un doble fallo, la CPU también intenta empujar el marco de pila de excepción. El puntero de pila aún apunta a la página de guardia, por lo que ocurre un _tercer_ fallo de página, que causa un _fallo triple_ y un reinicio del sistema. Así que nuestro actual controlador de doble fallo no puede evitar un fallo triple en este caso.
¡Probémoslo nosotros mismos! Podemos provocar fácilmente un desbordamiento de pila del núcleo llamando a una función que recursivamente se llame a sí misma sin fin:
```rust
// en src/main.rs
#[no_mangle] // no mangles el nombre de esta función
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
fn stack_overflow() {
stack_overflow(); // por cada recursión, se empuja la dirección de retorno
}
// provocar un desbordamiento de pila
stack_overflow();
[] // test_main(), println(…), y loop {}
}
```
Cuando intentamos este código en QEMU, vemos que el sistema entra en un bucle de arranque nuevamente.
Entonces, ¿cómo podemos evitar este problema? No podemos omitir el empuje del marco de pila de excepción, ya que la CPU lo hace ella misma. Así que necesitamos asegurarnos de alguna manera de que la pila sea siempre válida cuando ocurra una excepción de doble fallo. Afortunadamente, la arquitectura `x86_64` tiene una solución a este problema.
## Cambio de Pilas
La arquitectura `x86_64` es capaz de cambiar a una pila conocida y predefinida cuando ocurre una excepción. Este cambio se realiza a nivel de hardware, así que se puede hacer antes de que la CPU empuje el marco de pila de excepción.
El mecanismo de cambio se implementa como una _Tabla de Pila de Interrupciones_ (IST). La IST es una tabla de 7 punteros a pilas conocidas y válidas. En pseudocódigo estilo Rust:
```rust
struct InterruptStackTable {
stack_pointers: [Option<StackPointer>; 7],
}
```
Para cada controlador de excepciones, podemos elegir una pila de la IST a través del campo `stack_pointers` en la entrada correspondiente de la [IDT]. Por ejemplo, nuestro controlador de doble fallo podría usar la primera pila en la IST. Entonces, la CPU cambia automáticamente a esta pila cada vez que ocurre un doble fallo. Este cambio ocurriría antes de que se empuje cualquier cosa, previniendo el fallo triple.
[IDT entry]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-descriptor-table
### La IST y TSS
La Tabla de Pila de Interrupciones (IST) es parte de una estructura antigua llamada _[Segmento de Estado de Tarea]_ (TSS). La TSS solía contener varias piezas de información (por ejemplo, el estado de registro del procesador) sobre una tarea en modo de 32 bits y se usaba, por ejemplo, para [cambio de contexto de hardware]. Sin embargo, el cambio de contexto de hardware ya no se admite en modo de 64 bits y el formato de la TSS ha cambiado completamente.
[Segmento de Estado de Tarea]: https://en.wikipedia.org/wiki/Task_state_segment
[cambio de contexto de hardware]: https://wiki.osdev.org/Context_Switching#Hardware_Context_Switching
En `x86_64`, la TSS ya no contiene ninguna información específica de tarea. En su lugar, contiene dos tablas de pilas (la IST es una de ellas). El único campo común entre la TSS de 32 bits y 64 bits es el puntero al [bitmap de permisos de puertos de E/S].
[bitmap de permisos de puertos de E/S]: https://en.wikipedia.org/wiki/Task_state_segment#I.2FO_port_permissions
La TSS de 64 bits tiene el siguiente formato:
Campo | Tipo
------ | ----------------
<span style="opacity: 0.5">(reservado)</span> | `u32`
Tabla de Pilas de Privilegio | `[u64; 3]`
<span style="opacity: 0.5">(reservado)</span> | `u64`
Tabla de Pila de Interrupciones | `[u64; 7]`
<span style="opacity: 0.5">(reservado)</span> | `u64`
<span style="opacity: 0.5">(reservado)</span> | `u16`
Dirección Base del Mapa de E/S | `u16`
La _Tabla de Pilas de Privilegio_ es usada por la CPU cuando cambia el nivel de privilegio. Por ejemplo, si ocurre una excepción mientras la CPU está en modo usuario (nivel de privilegio 3), la CPU normalmente cambia a modo núcleo (nivel de privilegio 0) antes de invocar el controlador de excepciones. En ese caso, la CPU cambiaría a la 0ª pila en la Tabla de Pilas de Privilegio (ya que 0 es el nivel de privilegio de destino). Aún no tenemos programas en modo usuario, así que ignoraremos esta tabla por ahora.
### Creando una TSS
Creemos una nueva TSS que contenga una pila de doble fallo separada en su tabla de pila de interrupciones. Para ello, necesitamos una estructura TSS. Afortunadamente, la crate `x86_64` ya contiene una [`struct TaskStateSegment`] que podemos usar.
[`struct TaskStateSegment`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/tss/struct.TaskStateSegment.html
Creamos la TSS en un nuevo módulo `gdt` (el nombre tendrá sentido más adelante):
```rust
// en src/lib.rs
pub mod gdt;
// en src/gdt.rs
use x86_64::VirtAddr;
use x86_64::structures::tss::TaskStateSegment;
use lazy_static::lazy_static;
pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;
lazy_static! {
static ref TSS: TaskStateSegment = {
let mut tss = TaskStateSegment::new();
tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
const STACK_SIZE: usize = 4096 * 5;
static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
let stack_end = stack_start + STACK_SIZE;
stack_end
};
tss
};
}
```
Usamos `lazy_static` porque el evaluador de const de Rust aún no es lo suficientemente potente como para hacer esta inicialización en tiempo de compilación. Definimos que la entrada 0 de la IST es la pila de doble fallo (cualquier otro índice de IST también funcionaría). Luego, escribimos la dirección superior de una pila de doble fallo en la entrada 0. Escribimos la dirección superior porque las pilas en `x86` crecen hacia abajo, es decir, de direcciones altas a bajas.
No hemos implementado la gestión de memoria aún, así que no tenemos una forma adecuada de asignar una nueva pila. En su lugar, usamos un array `static mut` como almacenamiento de pila por ahora. El `unsafe` es requerido porque el compilador no puede garantizar la ausencia de condiciones de carrera cuando se accede a estáticos mutables. Es importante que sea un `static mut` y no un `static` inmutable, porque de lo contrario el cargador de arranque lo mapeará a una página de solo lectura. Reemplazaremos esto con una asignación de pila adecuada en una publicación posterior, entonces el `unsafe` ya no será necesario en este lugar.
Ten en cuenta que esta pila de doble fallo no tiene página de guardia que proteja contra el desbordamiento de pila. Esto significa que no deberíamos hacer nada intensivo en pila en nuestro controlador de doble fallo porque un desbordamiento de pila podría corromper la memoria debajo de la pila.
#### Cargando la TSS
Ahora que hemos creado una nueva TSS, necesitamos una forma de decirle a la CPU que debe usarla. Desafortunadamente, esto es un poco engorroso ya que la TSS utiliza el sistema de segmentación (por razones históricas). En lugar de cargar la tabla directamente, necesitamos agregar un nuevo descriptor de segmento a la [Tabla Global de Descriptores] (GDT). Luego podemos cargar nuestra TSS invocando la instrucción [`ltr`] con el índice correspondiente de la GDT. (Esta es la razón por la que llamamos a nuestro módulo `gdt`).
[Tabla Global de Descriptores]: https://web.archive.org/web/20190217233448/https://www.flingos.co.uk/docs/reference/Global-Descriptor-Table/
[`ltr`]: https://www.felixcloutier.com/x86/ltr
### La Tabla Global de Descriptores
La Tabla Global de Descriptores (GDT) es un reliquia que se usaba para [segmentación de memoria] antes de que la paginación se convirtiera en el estándar de facto. Sin embargo, todavía se necesita en modo de 64 bits para varias cosas, como la configuración del modo núcleo/usuario o la carga de la TSS.
[segmentación de memoria]: https://en.wikipedia.org/wiki/X86_memory_segmentation
La GDT es una estructura que contiene los _segmentos_ del programa. Se usaba en arquitecturas más antiguas para aislar programas unos de otros antes de que la paginación se convirtiera en el estándar. Para más información sobre segmentación, consulta el capítulo del mismo nombre en el libro gratuito [“Three Easy Pieces”]. Mientras que la segmentación ya no se admite en modo de 64 bits, la GDT sigue existiendo. Se utiliza principalmente para dos cosas: cambiar entre espacio de núcleo y espacio de usuario, y cargar una estructura TSS.
[“Three Easy Pieces”]: http://pages.cs.wisc.edu/~remzi/OSTEP/
#### Creando una GDT
Creemos una GDT estática que incluya un segmento para nuestra TSS estática:
```rust
// en src/gdt.rs
use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor};
lazy_static! {
static ref GDT: GlobalDescriptorTable = {
let mut gdt = GlobalDescriptorTable::new();
gdt.add_entry(Descriptor::kernel_code_segment());
gdt.add_entry(Descriptor::tss_segment(&TSS));
gdt
};
}
```
Como antes, usamos `lazy_static` de nuevo. Creamos una nueva GDT con un segmento de código y un segmento de TSS.
#### Cargando la GDT
Para cargar nuestra GDT, creamos una nueva función `gdt::init` que llamamos desde nuestra función `init`:
```rust
// en src/gdt.rs
pub fn init() {
GDT.load();
}
// en src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
}
```
Ahora nuestra GDT está cargada (ya que la función `_start` llama a `init`), pero aún vemos el bucle de arranque en el desbordamiento de pila.
### Los Pasos Finales
El problema es que los segmentos de la GDT aún no están activos porque los registros de segmento y TSS aún contienen los valores de la antigua GDT. También necesitamos modificar la entrada de IDT de doble fallo para que use la nueva pila.
En resumen, necesitamos hacer lo siguiente:
1. **Recargar el registro de segmento de código**: Hemos cambiado nuestra GDT, así que deberíamos recargar `cs`, el registro del segmento de código. Esto es necesario porque el antiguo selector de segmento podría ahora apuntar a un descriptor de GDT diferente (por ejemplo, un descriptor de TSS).
2. **Cargar la TSS**: Cargamos una GDT que contiene un selector de TSS, pero aún necesitamos decirle a la CPU que debe usar esa TSS.
3. **Actualizar la entrada de IDT**: Tan pronto como nuestra TSS esté cargada, la CPU tendrá acceso a una tabla de pila de interrupciones (IST) válida. Luego podemos decirle a la CPU que debe usar nuestra nueva pila de doble fallo modificando nuestra entrada de IDT de doble fallo.
Para los dos primeros pasos, necesitamos acceso a las variables `code_selector` y `tss_selector` en nuestra función `gdt::init`. Podemos lograr esto haciéndolas parte de la estática a través de una nueva estructura `Selectors`:
```rust
// en src/gdt.rs
use x86_64::structures::gdt::SegmentSelector;
lazy_static! {
static ref GDT: (GlobalDescriptorTable, Selectors) = {
let mut gdt = GlobalDescriptorTable::new();
let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS));
(gdt, Selectors { code_selector, tss_selector })
};
}
struct Selectors {
code_selector: SegmentSelector,
tss_selector: SegmentSelector,
}
```
Ahora podemos usar los selectores para recargar el registro `cs` y cargar nuestra `TSS`:
```rust
// en src/gdt.rs
pub fn init() {
use x86_64::instructions::tables::load_tss;
use x86_64::instructions::segmentation::{CS, Segment};
GDT.0.load();
unsafe {
CS::set_reg(GDT.1.code_selector);
load_tss(GDT.1.tss_selector);
}
}
```
Recargamos el registro de segmento de código usando [`CS::set_reg`] y cargamos la TSS usando [`load_tss`]. Las funciones están marcadas como `unsafe`, así que necesitamos un bloque `unsafe` para invocarlas. La razón es que podría ser posible romper la seguridad de la memoria al cargar selectores inválidos.
[`CS::set_reg`]: https://docs.rs/x86_64/0.14.5/x86_64/instructions/segmentation/struct.CS.html#method.set_reg
[`load_tss`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/tables/fn.load_tss.html
Ahora que hemos cargado una TSS válida y una tabla de pila de interrupciones, podemos establecer el índice de pila para nuestro controlador de doble fallo en la IDT:
```rust
// en src/interrupts.rs
use crate::gdt;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
unsafe {
idt.double_fault.set_handler_fn(double_fault_handler)
.set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX); // nuevo
}
idt
};
}
```
El método `set_stack_index` es inseguro porque el llamador debe asegurarse de que el índice utilizado es válido y no ya está usado para otra excepción.
¡Eso es todo! Ahora la CPU debería cambiar a la pila de doble fallo cada vez que ocurra un doble fallo. Así que podemos capturar _todos_ los dobles fallos, incluidos los desbordamientos de pila del núcleo:
![QEMU imprimiendo `EXCEPCIÓN: DOBLE FALLO` y un volcado del marco de pila de excepciones](qemu-double-fault-on-stack-overflow.png)
A partir de ahora, ¡no deberíamos ver un fallo triple nuevamente! Para asegurar que no rompamos accidentalmente lo anterior, deberíamos agregar una prueba para esto.
## Una Prueba de Desbordamiento de Pila
Para probar nuestro nuevo módulo `gdt` y asegurarnos de que el controlador de doble fallo se llama correctamente en un desbordamiento de pila, podemos agregar una prueba de integración. La idea es provocar un doble fallo en la función de prueba y verificar que se llama al controlador de doble fallo.
Comencemos con un esqueleto mínimo:
```rust
// en tests/stack_overflow.rs
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[no_mangle]
pub extern "C" fn _start() -> ! {
unimplemented!();
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}
```
Al igual que nuestra prueba de `panic_handler`, la prueba se ejecutará [sin un arnés de prueba]. La razón es que no podemos continuar la ejecución después de un doble fallo, así que más de una prueba no tiene sentido. Para desactivar el arnés de prueba para la prueba, agregamos lo siguiente a nuestro `Cargo.toml`:
```toml
# en Cargo.toml
[[test]]
name = "stack_overflow"
harness = false
```
[sin un arnés de prueba]: @/edition-2/posts/04-testing/index.md#no-harness-tests
Ahora `cargo test --test stack_overflow` debería compilar con éxito. La prueba falla, por supuesto, ya que el macro `unimplemented` provoca un pánico.
### Implementando `_start`
La implementación de la función `_start` se ve así:
```rust
// en tests/stack_overflow.rs
use blog_os::serial_print;
#[no_mangle]
pub extern "C" fn _start() -> ! {
serial_print!("stack_overflow::stack_overflow...\t");
blog_os::gdt::init();
init_test_idt();
// provocar un desbordamiento de pila
stack_overflow();
panic!("La ejecución continuó después del desbordamiento de pila");
}
#[allow(unconditional_recursion)]
fn stack_overflow() {
stack_overflow(); // por cada recursión, la dirección de retorno es empujada
volatile::Volatile::new(0).read(); // prevenir optimizaciones de recursión de cola
}
```
Llamamos a nuestra función `gdt::init` para inicializar una nueva GDT. En lugar de llamar a nuestra función `interrupts::init_idt`, llamamos a una función `init_test_idt` que se explicará en un momento. La función `stack_overflow` es casi idéntica a la función en nuestro `main.rs`. La única diferencia es que al final de la función, realizamos una lectura [volátil] adicional usando el tipo [`Volatile`] para prevenir una optimización del compilador llamada [_eliminación de llamadas de cola_]. Entre otras cosas, esta optimización permite al compilador transformar una función cuya última declaración es una llamada recursiva a una normal. Por lo tanto, no se crea un marco de pila adicional para la llamada a la función, así que el uso de la pila permanece constante.
[volátil]: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
[`Volatile`]: https://docs.rs/volatile/0.2.6/volatile/struct.Volatile.html
[_eliminación de llamadas de cola_]: https://en.wikipedia.org/wiki/Tail_call
En nuestro caso, sin embargo, queremos que el desbordamiento de pila ocurra, así que agregamos una declaración de lectura volátil ficticia al final de la función, que el compilador no puede eliminar. Por lo tanto, la función ya no es _tail recursive_, y se previene la transformación en un bucle. También agregamos el atributo `allow(unconditional_recursion)` para silenciar la advertencia del compilador de que la función recurre sin fin.
### La IDT de Prueba
Como se mencionó anteriormente, la prueba necesita su propia IDT con un controlador de doble fallo personalizado. La implementación se ve así:
```rust
// en tests/stack_overflow.rs
use lazy_static::lazy_static;
use x86_64::structures::idt::InterruptDescriptorTable;
lazy_static! {
static ref TEST_IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
unsafe {
idt.double_fault
.set_handler_fn(test_double_fault_handler)
.set_stack_index(blog_os::gdt::DOUBLE_FAULT_IST_INDEX);
}
idt
};
}
pub fn init_test_idt() {
TEST_IDT.load();
}
```
La implementación es muy similar a nuestra IDT normal en `interrupts.rs`. Al igual que en la IDT normal, establecemos un índice de pila en la IST para el controlador de doble fallo con el fin de cambiar a una pila separada. La función `init_test_idt` carga la IDT en la CPU a través del método `load`.
### El Controlador de Doble Fallo
La única pieza que falta es nuestro controlador de doble fallo. Se ve así:
```rust
// en tests/stack_overflow.rs
use blog_os::{exit_qemu, QemuExitCode, serial_println};
use x86_64::structures::idt::InterruptStackFrame;
extern "x86-interrupt" fn test_double_fault_handler(
_stack_frame: InterruptStackFrame,
_error_code: u64,
) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}
```
Cuando se llama al controlador de doble fallo, salimos de QEMU con un código de salida de éxito, lo que marca la prueba como pasada. Dado que las pruebas de integración son ejecutables completamente separadas, necesitamos establecer el atributo `#![feature(abi_x86_interrupt)]` nuevamente en la parte superior de nuestro archivo de prueba.
Ahora podemos ejecutar nuestra prueba a través de `cargo test --test stack_overflow` (o `cargo test` para ejecutar todas las pruebas). Como era de esperar, vemos la salida de `stack_overflow... [ok]` en la consola. Intenta comentar la línea `set_stack_index`; debería hacer que la prueba falle.
## Resumen
En esta publicación, aprendimos qué es un doble fallo y bajo qué condiciones ocurre. Agregamos un controlador básico de doble fallo que imprime un mensaje de error y añadimos una prueba de integración para ello.
También habilitamos el cambio de pila soportado por hardware en excepciones de doble fallo para que también funcione en desbordamientos de pila. Mientras lo implementábamos, aprendimos sobre el segmento de estado de tarea (TSS), la tabla de pila de interrupciones (IST) que contiene, y la tabla global de descriptores (GDT), que se usaba para segmentación en arquitecturas anteriores.
## ¿Qué sigue?
La próxima publicación explica cómo manejar interrupciones de dispositivos externos como temporizadores, teclados o controladores de red. Estas interrupciones de hardware son muy similares a las excepciones, por ejemplo, también se despachan a través de la IDT. Sin embargo, a diferencia de las excepciones, no surgen directamente en la CPU. En su lugar, un _controlador de interrupciones_ agrega estas interrupciones y las reenvía a la CPU según su prioridad. En la próxima publicación, exploraremos el [Intel 8259] (“PIC”) controlador de interrupciones y aprenderemos cómo implementar soporte para teclado.
[Intel 8259]: https://en.wikipedia.org/wiki/Intel_8259

View File

@@ -0,0 +1,734 @@
+++
title = "Interrupciones de Hardware"
weight = 7
path = "hardware-interrupts"
date = 2018-10-22
[extra]
chapter = "Interrupciones"
+++
En esta publicación, configuramos el controlador de interrupciones programable para redirigir correctamente las interrupciones de hardware a la CPU. Para manejar estas interrupciones, agregamos nuevas entradas a nuestra tabla de descriptores de interrupciones, tal como lo hicimos con nuestros manejadores de excepciones. Aprenderemos cómo obtener interrupciones de temporizador periódicas y cómo recibir entrada del teclado.
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes algún problema o pregunta, por favor abre un problema allí. También puedes dejar comentarios [al final]. El código fuente completo de esta publicación se puede encontrar en la rama [`post-07`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-07
<!-- toc -->
## Visión General
Las interrupciones proporcionan una forma de notificar a la CPU sobre dispositivos de hardware conectados. Así que, en lugar de permitir que el kernel verifique periódicamente el teclado en busca de nuevos caracteres (un proceso llamado [_polling_]), el teclado puede notificar al kernel sobre cada pulsación de tecla. Esto es mucho más eficiente porque el kernel solo necesita actuar cuando algo ha sucedido. También permite tiempos de reacción más rápidos, ya que el kernel puede reaccionar inmediatamente y no solo en la siguiente consulta.
[_polling_]: https://en.wikipedia.org/wiki/Polling_(computer_science)
Conectar todos los dispositivos de hardware directamente a la CPU no es posible. En su lugar, un _controlador de interrupciones_ (interrupt controller) separado agrega las interrupciones de todos los dispositivos y luego notifica a la CPU:
```
____________ _____
Temporizador ------------> | | | |
Teclado ---------> | Interrupt |---------> | CPU |
Otro Hardware ---> | Controller | |_____|
Etc. -------------> |____________|
```
La mayoría de los controladores de interrupciones son programables, lo que significa que admiten diferentes niveles de prioridad para las interrupciones. Por ejemplo, esto permite dar a las interrupciones del temporizador una prioridad más alta que a las interrupciones del teclado para asegurar un mantenimiento del tiempo preciso.
A diferencia de las excepciones, las interrupciones de hardware ocurren _de manera asincrónica_. Esto significa que son completamente independientes del código ejecutado y pueden ocurrir en cualquier momento. Por lo tanto, de repente tenemos una forma de concurrencia en nuestro kernel con todos los posibles errores relacionados con la concurrencia. El estricto modelo de propiedad de Rust nos ayuda aquí porque prohíbe el estado global mutable. Sin embargo, los bloqueos mutuos (deadlocks) siguen siendo posibles, como veremos más adelante en esta publicación.
## El 8259 PIC
El [Intel 8259] es un controlador de interrupciones programable (PIC) introducido en 1976. Ha sido reemplazado durante mucho tiempo por el nuevo [APIC], pero su interfaz aún se admite en sistemas actuales por razones de compatibilidad hacia atrás. El PIC 8259 es significativamente más fácil de configurar que el APIC, así que lo utilizaremos para introducirnos a las interrupciones antes de cambiar al APIC en una publicación posterior.
[APIC]: https://en.wikipedia.org/wiki/Intel_APIC_Architecture
El 8259 tiene ocho líneas de interrupción y varias líneas para comunicarse con la CPU. Los sistemas típicos de aquella época estaban equipados con dos instancias del PIC 8259, uno primario y uno secundario, conectados a una de las líneas de interrupción del primario:
[Intel 8259]: https://en.wikipedia.org/wiki/Intel_8259
```
____________ ____________
Reloj en Tiempo Real --> | | Temporizador -------------> | |
ACPI -------------> | | Teclado-----------> | | _____
Disponible --------> | Secundario |----------------------> | Primario | | |
Disponible --------> | Interrupt | Puerto Serial 2 -----> | Interrupt |---> | CPU |
Ratón ------------> | Controller | Puerto Serial 1 -----> | Controller | |_____|
Co-Procesador -----> | | Puerto Paralelo 2/3 -> | |
ATA Primario ------> | | Disco flexible -------> | |
ATA Secundario ----> |____________| Puerto Paralelo 1----> |____________|
```
Esta gráfica muestra la asignación típica de líneas de interrupción. Vemos que la mayoría de las 15 líneas tienen un mapeo fijo, por ejemplo, la línea 4 del PIC secundario está asignada al ratón.
Cada controlador se puede configurar a través de dos [puertos de I/O], un puerto “comando” y un puerto “datos”. Para el controlador primario, estos puertos son `0x20` (comando) y `0x21` (datos). Para el controlador secundario, son `0xa0` (comando) y `0xa1` (datos). Para más información sobre cómo se pueden configurar los PIC, consulta el [artículo en osdev.org].
[puertos de I/O]: @/edition-2/posts/04-testing/index.md#i-o-ports
[artículo en osdev.org]: https://wiki.osdev.org/8259_PIC
### Implementación
La configuración predeterminada de los PIC no es utilizable porque envía números de vector de interrupción en el rango de 015 a la CPU. Estos números ya están ocupados por excepciones de la CPU. Por ejemplo, el número 8 corresponde a una doble falla. Para corregir este problema de superposición, necesitamos volver a asignar las interrupciones del PIC a números diferentes. El rango real no importa siempre que no se superponga con las excepciones, pero típicamente se elige el rango de 3247, porque estos son los primeros números libres después de los 32 espacios de excepción.
La configuración se realiza escribiendo valores especiales en los puertos de comando y datos de los PIC. Afortunadamente, ya existe una crate llamada [`pic8259`], por lo que no necesitamos escribir la secuencia de inicialización nosotros mismos. Sin embargo, si estás interesado en cómo funciona, consulta [su código fuente][pic crate source]. Es bastante pequeño y está bien documentado.
[pic crate source]: https://docs.rs/crate/pic8259/0.10.1/source/src/lib.rs
Para agregar la crate como una dependencia, agregamos lo siguiente a nuestro proyecto:
[`pic8259`]: https://docs.rs/pic8259/0.10.1/pic8259/
```toml
# en Cargo.toml
[dependencies]
pic8259 = "0.10.1"
```
La principal abstracción proporcionada por la crate es la estructura [`ChainedPics`] que representa la disposición primario/secundario del PIC que vimos arriba. Está diseñada para ser utilizada de la siguiente manera:
[`ChainedPics`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html
```rust
// en src/interrupts.rs
use pic8259::ChainedPics;
use spin;
pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;
pub static PICS: spin::Mutex<ChainedPics> =
spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });
```
Como se mencionó anteriormente, estamos estableciendo los desplazamientos para los PIC en el rango de 3247. Al envolver la estructura `ChainedPics` en un `Mutex`, podemos obtener un acceso mutable seguro (a través del método [`lock`][spin mutex lock]), que necesitamos en el siguiente paso. La función `ChainedPics::new` es insegura porque desplazamientos incorrectos podrían causar un comportamiento indefinido.
[spin mutex lock]: https://docs.rs/spin/0.5.2/spin/struct.Mutex.html#method.lock
Ahora podemos inicializar el PIC 8259 en nuestra función `init`:
```rust
// en src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() }; // nuevo
}
```
Usamos la función [`initialize`] para realizar la inicialización del PIC. Al igual que la función `ChainedPics::new`, esta función también es insegura porque puede causar un comportamiento indefinido si el PIC está mal configurado.
[`initialize`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html#method.initialize
Si todo va bien, deberíamos seguir viendo el mensaje "¡No se ha bloqueado!" al ejecutar `cargo run`.
## Habilitando Interrupciones
Hasta ahora, nada sucedió porque las interrupciones todavía están deshabilitadas en la configuración de la CPU. Esto significa que la CPU no escucha al controlador de interrupciones en absoluto, por lo que ninguna interrupción puede llegar a la CPU. Cambiemos eso:
```rust
// en src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() };
x86_64::instructions::interrupts::enable(); // nuevo
}
```
La función `interrupts::enable` de la crate `x86_64` ejecuta la instrucción especial `sti` (“set interrupts”) para habilitar las interrupciones externas. Cuando intentamos `cargo run` ahora, vemos que ocurre una doble falla:
![QEMU imprimiendo `EXCEPTION: DOUBLE FAULT` debido al temporizador de hardware](qemu-hardware-timer-double-fault.png)
La razón de esta doble falla es que el temporizador de hardware (el [Intel 8253], para ser exactos) está habilitado por defecto, por lo que comenzamos a recibir interrupciones de temporizador tan pronto como habilitamos las interrupciones. Dado que aún no hemos definido una función de manejador para ello, se invoca nuestro manejador de doble falla.
[Intel 8253]: https://en.wikipedia.org/wiki/Intel_8253
## Manejando Interrupciones de Temporizador
Como vemos en la gráfica [arriba](#el-8259-pic), el temporizador utiliza la línea 0 del PIC primario. Esto significa que llega a la CPU como interrupción 32 (0 + desplazamiento 32). En lugar de codificar rígidamente el índice 32, lo almacenamos en un enum `InterruptIndex`:
```rust
// en src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Temporizador = PIC_1_OFFSET,
}
impl InterruptIndex {
fn as_u8(self) -> u8 {
self as u8
}
fn as_usize(self) -> usize {
usize::from(self.as_u8())
}
}
```
El enum es un [enum tipo C] para que podamos especificar directamente el índice para cada variante. El atributo `repr(u8)` especifica que cada variante se representa como un `u8`. Agregaremos más variantes para otras interrupciones en el futuro.
[enum tipo C]: https://doc.rust-lang.org/reference/items/enumerations.html#custom-discriminant-values-for-fieldless-enumerations
Ahora podemos agregar una función de manejador para la interrupción del temporizador:
```rust
// en src/interrupts.rs
use crate::print;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
idt[InterruptIndex::Temporizador.as_usize()]
.set_handler_fn(timer_interrupt_handler); // nuevo
idt
};
}
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
}
```
Nuestro `timer_interrupt_handler` tiene la misma firma que nuestros manejadores de excepciones, porque la CPU reacciona de manera idéntica a las excepciones y a las interrupciones externas (la única diferencia es que algunas excepciones empujan un código de error). La estructura [`InterruptDescriptorTable`] implementa el rasgo [`IndexMut`], por lo que podemos acceder a entradas individuales a través de la sintaxis de indexación de arrays.
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[`IndexMut`]: https://doc.rust-lang.org/core/ops/trait.IndexMut.html
En nuestro manejador de interrupciones del temporizador, imprimimos un punto en la pantalla. Como la interrupción del temporizador ocurre periódicamente, esperaríamos ver un punto apareciendo en cada tick del temporizador. Sin embargo, cuando lo ejecutamos, vemos que solo se imprime un solo punto:
![QEMU imprimiendo solo un punto por el temporizador de hardware](qemu-single-dot-printed.png)
### Fin de la Interrupción
La razón es que el PIC espera una señal explícita de “fin de interrupción” (EOI) de nuestro manejador de interrupciones. Esta señal le dice al controlador que la interrupción ha sido procesada y que el sistema está listo para recibir la siguiente interrupción. Así que el PIC piensa que todavía estamos ocupados procesando la primera interrupción del temporizador y espera pacientemente la señal EOI antes de enviar la siguiente.
Para enviar el EOI, usamos nuestra estructura estática `PICS` nuevamente:
```rust
// en src/interrupts.rs
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Temporizador.as_u8());
}
}
```
El método `notify_end_of_interrupt` determina si el PIC primario o secundario envió la interrupción y luego utiliza los puertos de `comando` y `datos` para enviar una señal EOI a los controladores respectivos. Si el PIC secundario envió la interrupción, ambos PIC deben ser notificados porque el PIC secundario está conectado a una línea de entrada del PIC primario.
Debemos tener cuidado de usar el número de vector de interrupción correcto; de lo contrario, podríamos eliminar accidentalmente una interrupción no enviada importante o hacer que nuestro sistema se cuelgue. Esta es la razón por la que la función es insegura.
Cuando ejecutamos ahora `cargo run`, vemos puntos apareciendo periódicamente en la pantalla:
![QEMU imprimiendo puntos consecutivos mostrando el temporizador de hardware](qemu-hardware-timer-dots.gif)
### Configurando el Temporizador
El temporizador de hardware que usamos se llama _Temporizador de Intervalo Programable_ (Programmable Interval Timer), o PIT, para abreviar. Como su nombre indica, es posible configurar el intervalo entre dos interrupciones. No entraremos en detalles aquí porque pronto pasaremos al [temporizador APIC], pero la wiki de OSDev tiene un artículo extenso sobre la [configuración del PIT].
[temporizador APIC]: https://wiki.osdev.org/APIC_timer
[configuración del PIT]: https://wiki.osdev.org/Programmable_Interval_Timer
## Bloqueos Mutuos
Ahora tenemos una forma de concurrencia en nuestro kernel: Las interrupciones del temporizador ocurren de manera asincrónica, por lo que pueden interrumpir nuestra función `_start` en cualquier momento. Afortunadamente, el sistema de propiedad de Rust previene muchos tipos de errores relacionados con la concurrencia en tiempo de compilación. Una notable excepción son los bloqueos mutuos (deadlocks). Los bloqueos mutuos ocurren si un hilo intenta adquirir un bloqueo que nunca se liberará. Así, el hilo se cuelga indefinidamente.
Ya podemos provocar un bloqueo mutuo en nuestro kernel. Recuerda que nuestra macro `println` llama a la función `vga_buffer::_print`, que [bloquea un `WRITER` global][vga spinlock] utilizando un spinlock:
[vga spinlock]: @/edition-2/posts/03-vga-text-buffer/index.md#spinlocks
```rust
// en src/vga_buffer.rs
[]
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
```
Bloquea el `WRITER`, llama a `write_fmt` en él y lo desbloquea implícitamente al final de la función. Ahora imagina que una interrupción ocurre mientras `WRITER` está bloqueado y el manejador de interrupciones intenta imprimir algo también:
Timestep | _start | manejador_interrupcion
---------|------|------------------
0 | llama a `println!` | &nbsp;
1 | `print` bloquea `WRITER` | &nbsp;
2 | | **ocurre la interrupción**, el manejador comienza a ejecutarse
3 | | llama a `println!` |
4 | | `print` intenta bloquear `WRITER` (ya bloqueado)
5 | | `print` intenta bloquear `WRITER` (ya bloqueado)
… | | …
_nunca_ | _desbloquear `WRITER`_ |
El `WRITER` está bloqueado, así que el manejador de interrupciones espera hasta que se libere. Pero esto nunca sucede, porque la función `_start` solo continúa ejecutándose después de que el manejador de interrupciones regrese. Así, todo el sistema se cuelga.
### Provocando un Bloqueo Mutuo
Podemos provocar fácilmente un bloqueo mutuo así en nuestro kernel imprimiendo algo en el bucle al final de nuestra función `_start`:
```rust
// en src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
loop {
use blog_os::print;
print!("-"); // nuevo
}
}
```
Cuando lo ejecutamos en QEMU, obtenemos una salida de la forma:
![Salida de QEMU con muchas filas de guiones y sin puntos](./qemu-deadlock.png)
Vemos que solo se imprimen un número limitado de guiones hasta que ocurre la primera interrupción del temporizador. Entonces el sistema se cuelga porque el manejador de interrupciones del temporizador provoca un bloqueo mutuo cuando intenta imprimir un punto. Esta es la razón por la que no vemos puntos en la salida anterior.
El número real de guiones varía entre ejecuciones porque la interrupción del temporizador ocurre de manera asincrónica. Esta no determinación es lo que hace que los errores relacionados con la concurrencia sean tan difíciles de depurar.
### Solucionando el Bloqueo Mutuo
Para evitar este bloqueo mutuo, podemos deshabilitar las interrupciones mientras el `Mutex` está bloqueado:
```rust
// en src/vga_buffer.rs
/// Imprime la cadena formateada dada en el búfer de texto VGA
/// a través de la instancia global `WRITER`.
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // nuevo
interrupts::without_interrupts(|| { // nuevo
WRITER.lock().write_fmt(args).unwrap();
});
}
```
La función [`without_interrupts`] toma un [closure] y lo ejecuta en un entorno sin interrupciones. La usamos para asegurarnos de que no se produzca ninguna interrupción mientras el `Mutex` esté bloqueado. Cuando ejecutamos nuestro kernel ahora, vemos que sigue funcionando sin colgarse. (Todavía no notamos ningún punto, pero esto es porque están deslizándose demasiado rápido. Intenta ralentizar la impresión, por ejemplo, poniendo un `for _ in 0..10000 {}` dentro del bucle).
[`without_interrupts`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/interrupts/fn.without_interrupts.html
[closure]: https://doc.rust-lang.org/book/ch13-01-closures.html
Podemos aplicar el mismo cambio a nuestra función de impresión serial para asegurarnos de que tampoco ocurran bloqueos mutuos con ella:
```rust
// en src/serial.rs
#[doc(hidden)]
pub fn _print(args: ::core::fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // nuevo
interrupts::without_interrupts(|| { // nuevo
SERIAL1
.lock()
.write_fmt(args)
.expect("Error al imprimir por serie");
});
}
```
Ten en cuenta que deshabilitar interrupciones no debería ser una solución general. El problema es que aumenta la latencia de interrupción en el peor de los casos, es decir, el tiempo hasta que el sistema reacciona a una interrupción. Por lo tanto, las interrupciones solo deben deshabilitarse por un tiempo muy corto.
## Solucionando una Condición de Carrera
Si ejecutas `cargo test`, podrías ver que la prueba `test_println_output` falla:
```
> cargo test --lib
[…]
Ejecutando 4 pruebas
test_breakpoint_exception...[ok]
test_println... [ok]
test_println_many... [ok]
test_println_output... [failed]
Error: se bloqueó en 'assertion failed: `(left == right)`
left: `'.'`,
right: `'S'`', src/vga_buffer.rs:205:9
```
La razón es una _condición de carrera_ entre la prueba y nuestro manejador de temporizador. Recuerda que la prueba se ve así:
```rust
// en src/vga_buffer.rs
#[test_case]
fn test_println_output() {
let s = "Una cadena de prueba que cabe en una sola línea";
println!("{}", s);
for (i, c) in s.chars().enumerate() {
let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
}
```
La condición de carrera ocurre porque el manejador de interrupciones del temporizador podría ejecutarse entre el `println` y la lectura de los caracteres en la pantalla. Ten en cuenta que esto no es una peligrosa _data race_, que Rust previene completamente en tiempo de compilación. Consulta el [_Rustonomicon_][nomicon-races] para más detalles.
[nomicon-races]: https://doc.rust-lang.org/nomicon/races.html
Para solucionar esto, necesitamos mantener el `WRITER` bloqueado durante toda la duración de la prueba, para que el manejador de temporizador no pueda escribir un carácter en la pantalla en medio. La prueba corregida se ve así:
```rust
// en src/vga_buffer.rs
#[test_case]
fn test_println_output() {
use core::fmt::Write;
use x86_64::instructions::interrupts;
let s = "Una cadena de prueba que cabe en una sola línea";
interrupts::without_interrupts(|| {
let mut writer = WRITER.lock();
writeln!(writer, "\n{}", s).expect("writeln falló");
for (i, c) in s.chars().enumerate() {
let screen_char = writer.buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
});
}
```
Hemos realizado los siguientes cambios:
- Mantenemos el escritor bloqueado durante toda la prueba utilizando el método `lock()` explícitamente. En lugar de `println`, usamos la macro [`writeln`] que permite imprimir en un escritor que ya está bloqueado.
- Para evitar otro bloqueo mutuo, deshabilitamos las interrupciones durante la duración de la prueba. De lo contrario, la prueba podría ser interrumpida mientras el escritor sigue bloqueado.
- Dado que el manejador de interrupciones del temporizador aún puede ejecutarse antes de la prueba, imprimimos una nueva línea adicional `\n` antes de imprimir la cadena `s`. De esta manera, evitamos fallar en la prueba cuando el manejador de temporizador ya ha impreso algunos puntos en la línea actual.
[`writeln`]: https://doc.rust-lang.org/core/macro.writeln.html
Con los cambios anteriores, `cargo test` ahora tiene éxito de manera determinista.
Esta fue una condición de carrera muy inofensiva que solo causó una falla en la prueba. Como puedes imaginar, otras condiciones de carrera pueden ser mucho más difíciles de depurar debido a su naturaleza no determinista. Afortunadamente, Rust nos previene de condiciones de data race, que son la clase más seria de condiciones de carrera, ya que pueden causar todo tipo de comportamientos indefinidos, incluyendo bloqueos del sistema y corrupción silenciosa de memoria.
## La Instrucción `hlt`
Hasta ahora, hemos utilizado una simple instrucción de bucle vacío al final de nuestras funciones `_start` y `panic`. Esto hace que la CPU gire sin descanso, y por lo tanto funciona como se espera. Pero también es muy ineficiente, porque la CPU sigue funcionando a toda velocidad incluso cuando no hay trabajo que hacer. Puedes ver este problema en tu administrador de tareas cuando ejecutas tu kernel: el proceso de QEMU necesita cerca del 100% de CPU todo el tiempo.
Lo que realmente queremos hacer es detener la CPU hasta que llegue la próxima interrupción. Esto permite que la CPU entre en un estado de sueño en el que consume mucho menos energía. La instrucción [`hlt`] hace exactamente eso. Vamos a usar esta instrucción para crear un bucle infinito eficiente en energía:
[`hlt`]: https://en.wikipedia.org/wiki/HLT_(x86_instruction)
```rust
// en src/lib.rs
pub fn hlt_loop() -> ! {
loop {
x86_64::instructions::hlt();
}
}
```
La función `instructions::hlt` es solo un [delgado envoltorio] alrededor de la instrucción de ensamblador. Es segura porque no hay forma de que comprometa la seguridad de la memoria.
[delgado envoltorio]: https://github.com/rust-osdev/x86_64/blob/5e8e218381c5205f5777cb50da3ecac5d7e3b1ab/src/instructions/mod.rs#L16-L22
Ahora podemos utilizar este `hlt_loop` en lugar de los bucles infinitos en nuestras funciones `_start` y `panic`:
```rust
// en src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
println!("¡No se ha bloqueado!");
blog_os::hlt_loop(); // nuevo
}
#[cfg(not(test))]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
blog_os::hlt_loop(); // nuevo
}
```
Actualicemos también nuestro `lib.rs`:
```rust
// en src/lib.rs
/// Punto de entrada para `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init();
test_main();
hlt_loop(); // nuevo
}
pub fn test_panic_handler(info: &PanicInfo) -> ! {
serial_println!("[falló]\n");
serial_println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
hlt_loop(); // nuevo
}
```
Cuando ejecutamos nuestro kernel ahora en QEMU, vemos un uso de CPU mucho más bajo.
## Entrada del Teclado
Ahora que podemos manejar interrupciones de dispositivos externos, finalmente podemos agregar soporte para la entrada del teclado. Esto nos permitirá interactuar con nuestro kernel por primera vez.
<aside class="post_aside">
Ten en cuenta que solo describimos cómo manejar teclados [PS/2] aquí, no teclados USB. Sin embargo, la placa base emula los teclados USB como dispositivos PS/2 para admitir software más antiguo, por lo que podemos ignorar de forma segura los teclados USB hasta que tengamos soporte para USB en nuestro kernel.
</aside>
[PS/2]: https://en.wikipedia.org/wiki/PS/2_port
Al igual que el temporizador de hardware, el controlador del teclado ya está habilitado por defecto. Así que cuando presionas una tecla, el controlador del teclado envía una interrupción al PIC, que la reenvía a la CPU. La CPU busca una función de manejador en la IDT, pero la entrada correspondiente está vacía. Por lo tanto, ocurre una doble falla.
Así que agreguemos una función de manejador para la interrupción del teclado. Es bastante similar a cómo definimos el manejador para la interrupción del temporizador; solo utiliza un número de interrupción diferente:
```rust
// en src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Temporizador = PIC_1_OFFSET,
Teclado, // nuevo
}
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
// nuevo
idt[InterruptIndex::Teclado.as_usize()]
.set_handler_fn(keyboard_interrupt_handler);
idt
};
}
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!("k");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Teclado.as_u8());
}
}
```
Como vemos en la gráfica [arriba](#el-8259-pic), el teclado utiliza la línea 1 del PIC primario. Esto significa que llega a la CPU como interrupción 33 (1 + desplazamiento 32). Agregamos este índice como una nueva variante `Teclado` al enum `InterruptIndex`. No necesitamos especificar el valor explícitamente, ya que de forma predeterminada toma el valor anterior más uno, que también es 33. En el manejador de interrupciones, imprimimos una `k` y enviamos la señal de fin de interrupción al controlador de interrupciones.
Ahora vemos que una `k` aparece en la pantalla cuando presionamos una tecla. Sin embargo, esto solo funciona para la primera tecla que presionamos. Incluso si seguimos presionando teclas, no aparecen más `k`s en la pantalla. Esto se debe a que el controlador del teclado no enviará otra interrupción hasta que hayamos leído el llamado _scancode_ de la tecla presionada.
### Leyendo los Scancodes
Para averiguar _qué_ tecla fue presionada, necesitamos consultar al controlador del teclado. Hacemos esto leyendo desde el puerto de datos del controlador PS/2, que es el [puerto de I/O] con el número `0x60`:
[puerto de I/O]: @/edition-2/posts/04-testing/index.md#i-o-ports
```rust
// en src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
print!("{}", scancode);
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Teclado.as_u8());
}
}
```
Usamos el tipo [`Port`] de la crate `x86_64` para leer un byte del puerto de datos del teclado. Este byte se llama [_scancode_] y representa la pulsación/liberación de la tecla. Aún no hacemos nada con el scancode, excepto imprimirlo en la pantalla:
[`Port`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/port/struct.Port.html
[_scancode_]: https://en.wikipedia.org/wiki/Scancode
![QEMU imprimiendo scancodes en la pantalla cuando se presionan teclas](qemu-printing-scancodes.gif)
La imagen anterior muestra que estoy escribiendo lentamente "123". Vemos que las teclas adyacentes tienen scancodes adyacentes y que presionar una tecla causa un scancode diferente al soltarla. Pero, ¿cómo traducimos los scancodes a las acciones de las teclas exactamente?
### Interpretando los Scancodes
Existen tres estándares diferentes para el mapeo entre scancodes y teclas, los llamados _conjuntos de scancode_. Los tres se remontan a los teclados de las primeras computadoras IBM: el [IBM XT], el [IBM 3270 PC] y el [IBM AT]. Afortunadamente, las computadoras posteriores no continuaron con la tendencia de definir nuevos conjuntos de scancode, sino que emularon los conjuntos existentes y los ampliaron. Hoy en día, la mayoría de los teclados pueden configurarse para emular cualquiera de los tres conjuntos.
[IBM XT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer_XT
[IBM 3270 PC]: https://en.wikipedia.org/wiki/IBM_3270_PC
[IBM AT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer/AT
Por defecto, los teclados PS/2 emulan el conjunto de scancode 1 ("XT"). En este conjunto, los 7 bits inferiores de un byte de scancode definen la tecla, y el bit más significativo define si se trata de una pulsación ("0") o una liberación ("1"). Las teclas que no estaban presentes en el teclado original de [IBM XT], como la tecla de entrada en el teclado numérico, generan dos scancodes en sucesión: un byte de escape `0xe0` seguido de un byte que representa la tecla. Para obtener una lista de todos los scancodes del conjunto 1 y sus teclas correspondientes, consulta la [Wiki de OSDev][scancode set 1].
[scancode set 1]: https://wiki.osdev.org/Keyboard#Scan_Code_Set_1
Para traducir los scancodes a teclas, podemos usar una instrucción `match`:
```rust
// en src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
// nuevo
let key = match scancode {
0x02 => Some('1'),
0x03 => Some('2'),
0x04 => Some('3'),
0x05 => Some('4'),
0x06 => Some('5'),
0x07 => Some('6'),
0x08 => Some('7'),
0x09 => Some('8'),
0x0a => Some('9'),
0x0b => Some('0'),
_ => None,
};
if let Some(key) = key {
print!("{}", key);
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Teclado.as_u8());
}
}
```
El código anterior traduce las pulsaciones de las teclas numéricas 0-9 y ignora todas las otras teclas. Utiliza una declaración [match] para asignar un carácter o `None` a cada scancode. Luego, utiliza [`if let`] para desestructurar la opción `key`. Al usar el mismo nombre de variable `key` en el patrón, [somos sombras de] la declaración anterior, lo cual es un patrón común para desestructurar tipos `Option` en Rust.
[match]: https://doc.rust-lang.org/book/ch06-02-match.html
[`if let`]: https://doc.rust-lang.org/book/ch18-01-all-the-places-for-patterns.html#conditional-if-let-expressions
[sombra]: https://doc.rust-lang.org/book/ch03-01-variables-and-mutabilidad.html#shadowing
Ahora podemos escribir números:
![QEMU imprimiendo números en la pantalla](qemu-printing-numbers.gif)
Traducir las otras teclas funciona de la misma manera. Afortunadamente, existe una crate llamada [`pc-keyboard`] para traducir los scancodes de los conjuntos de scancode 1 y 2, así que no tenemos que implementar esto nosotros mismos. Para usar la crate, la añadimos a nuestro `Cargo.toml` e importamos en nuestro `lib.rs`:
[`pc-keyboard`]: https://docs.rs/pc-keyboard/0.7.0/pc_keyboard/
```toml
# en Cargo.toml
[dependencies]
pc-keyboard = "0.7.0"
```
Ahora podemos usar esta crate para reescribir nuestro `keyboard_interrupt_handler`:
```rust
// en src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;
use x86_64::instructions::port::Port;
lazy_static! {
static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
Mutex::new(Keyboard::new(ScancodeSet1::new(),
layouts::Us104Key, HandleControl::Ignore)
);
}
let mut keyboard = KEYBOARD.lock();
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
if let Some(key) = keyboard.process_keyevent(key_event) {
match key {
DecodedKey::Unicode(character) => print!("{}", character),
DecodedKey::RawKey(key) => print!("{:?}", key),
}
}
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Teclado.as_u8());
}
}
```
Usamos la macro `lazy_static` para crear un objeto estático [`Keyboard`] protegido por un Mutex. Inicializamos el `Keyboard` con un diseño de teclado estadounidense y el conjunto de scancode 1. El parámetro [`HandleControl`] permite mapear `ctrl+[a-z]` a los caracteres Unicode `U+0001` a `U+001A`. No queremos hacer eso, así que usamos la opción `Ignore` para manejar el `ctrl` como teclas normales.
[`HandleControl`]: https://docs.rs/pc-keyboard/0.7.0/pc_keyboard/enum.HandleControl.html
En cada interrupción, bloqueamos el Mutex, leemos el scancode del controlador del teclado y lo pasamos al método [`add_byte`], que traduce el scancode en un `Option<KeyEvent>`. El [`KeyEvent`] contiene la tecla que causó el evento y si fue un evento de pulsación o liberación.
[`Keyboard`]: https://docs.rs/pc-keyboard/0.7.0/pc_keyboard/struct.Keyboard.html
[`add_byte`]: https://docs.rs/pc-keyboard/0.7.0/pc_keyboard/struct.Keyboard.html#method.add_byte
[`KeyEvent`]: https://docs.rs/pc-keyboard/0.7.0/pc_keyboard/struct.KeyEvent.html
Para interpretar este evento de tecla, lo pasamos al método [`process_keyevent`], que traduce el evento de tecla a un carácter, si es posible. Por ejemplo, traduce un evento de pulsación de la tecla `A` a un carácter minúscula `a` o un carácter mayúscula `A`, dependiendo de si la tecla de mayúsculas (shift) estaba presionada.
[`process_keyevent`]: https://docs.rs/pc-keyboard/0.7.0/pc_keyboard/struct.Keyboard.html#method.process_keyevent
Con este manejador de interrupciones modificado, ahora podemos escribir texto:
![Escribiendo "Hola Mundo" en QEMU](qemu-typing.gif)
### Configurando el Teclado
Es posible configurar algunos aspectos de un teclado PS/2, por ejemplo, qué conjunto de scancode debe usar. No lo cubriremos aquí porque esta publicación ya es lo suficientemente larga, pero la Wiki de OSDev tiene una visión general de los posibles [comandos de configuración].
[comandos de configuración]: https://wiki.osdev.org/PS/2_Keyboard#Commands
## Resumen
Esta publicación explicó cómo habilitar y manejar interrupciones externas. Aprendimos sobre el PIC 8259 y su disposición primario/secundario, la reasignación de los números de interrupción y la señal de "fin de interrupción". Implementamos manejadores para el temporizador de hardware y el teclado y aprendimos sobre la instrucción `hlt`, que detiene la CPU hasta la siguiente interrupción.
Ahora podemos interactuar con nuestro kernel y tenemos algunos bloques fundamentales para crear una pequeña terminal o juegos simples.
## ¿Qué sigue?
Las interrupciones de temporizador son esenciales para un sistema operativo porque proporcionan una manera de interrumpir periódicamente el proceso en ejecución y permitir que el kernel recupere el control. El kernel puede luego cambiar a un proceso diferente y crear la ilusión de que varios procesos se están ejecutando en paralelo.
Pero antes de que podamos crear procesos o hilos, necesitamos una forma de asignar memoria para ellos. Las próximas publicaciones explorarán la gestión de memoria para proporcionar este bloque fundamental.

View File

@@ -0,0 +1,415 @@
+++
title = "Introducción a la Paginación"
weight = 8
path = "paging-introduction"
date = 2019-01-14
[extra]
chapter = "Gestión de Memoria"
+++
Esta publicación introduce la _paginación_ (paging), un esquema de gestión de memoria muy común que también utilizaremos para nuestro sistema operativo. Explica por qué se necesita la aislamiento de memoria, cómo funciona la _segmentación_ (segmentation), qué es la _memoria virtual_ (virtual memory) y cómo la paginación soluciona los problemas de fragmentación de memoria. También explora el diseño de las tablas de páginas multinivel en la arquitectura x86_64.
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes algún problema o pregunta, por favor abre un issue allí. También puedes dejar comentarios [al final]. El código fuente completo de esta publicación se puede encontrar en la rama [`post-08`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-08
<!-- toc -->
## Protección de Memoria
Una de las principales tareas de un sistema operativo es aislar programas entre sí. Tu navegador web no debería poder interferir con tu editor de texto, por ejemplo. Para lograr este objetivo, los sistemas operativos utilizan funcionalidades de hardware para asegurarse de que las áreas de memoria de un proceso no sean accesibles por otros procesos. Hay diferentes enfoques dependiendo del hardware y la implementación del sistema operativo.
Como ejemplo, algunos procesadores ARM Cortex-M (usados en sistemas embebidos) tienen una _Unidad de Protección de Memoria_ (Memory Protection Unit, MPU), que permite definir un pequeño número (por ejemplo, 8) de regiones de memoria con diferentes permisos de acceso (por ejemplo, sin acceso, solo lectura, lectura-escritura). En cada acceso a la memoria, la MPU asegura que la dirección esté en una región con permisos de acceso correctos y lanza una excepción en caso contrario. Al cambiar las regiones y los permisos de acceso en cada cambio de proceso, el sistema operativo puede asegurarse de que cada proceso solo acceda a su propia memoria y, por lo tanto, aísla los procesos entre sí.
[_Unidad de Protección de Memoria_]: https://developer.arm.com/docs/ddi0337/e/memory-protection-unit/about-the-mpu
En x86, el hardware admite dos enfoques diferentes para la protección de memoria: [segmentación] y [paginación].
[segmentación]: https://en.wikipedia.org/wiki/X86_memory_segmentation
[paginación]: https://en.wikipedia.org/wiki/Virtual_memory#Paged_virtual_memory
## Segmentación
La segmentación fue introducida en 1978, originalmente para aumentar la cantidad de memoria direccionable. La situación en ese entonces era que las CPU solo usaban direcciones de 16 bits, lo que limitaba la cantidad de memoria direccionable a 64&nbsp;KiB. Para hacer accesibles más de estos 64&nbsp;KiB, se introdujeron registros de segmento adicionales, cada uno conteniendo una dirección de desplazamiento. La CPU sumaba automáticamente este desplazamiento en cada acceso a la memoria, de modo que hasta 1&nbsp;MiB de memoria era accesible.
El registro del segmento es elegido automáticamente por la CPU dependiendo del tipo de acceso a la memoria: para obtener instrucciones, se utiliza el segmento de código `CS`, y para operaciones de pila (push/pop), se utiliza el segmento de pila `SS`. Otras instrucciones utilizan el segmento de datos `DS` o el segmento adicional `ES`. Más tarde, se añadieron dos registros de segmento adicionales, `FS` y `GS`, que pueden ser utilizados libremente.
En la primera versión de la segmentación, los registros de segmento contenían directamente el desplazamiento y no se realizaba control de acceso. Esto se cambió más tarde con la introducción del _modo protegido_ (protected mode). Cuando la CPU funciona en este modo, los descriptores de segmento contienen un índice a una _tabla de descriptores_ local o global, que contiene además de una dirección de desplazamiento el tamaño del segmento y los permisos de acceso. Al cargar tablas de descriptores globales/locales separadas para cada proceso, que confinan los accesos de memoria a las áreas de memoria del propio proceso, el sistema operativo puede aislar los procesos entre sí.
[_modo protegido_]: https://en.wikipedia.org/wiki/X86_memory_segmentation#Protected_mode
[_tabla de descriptores_]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
Al modificar las direcciones de memoria antes del acceso real, la segmentación ya utilizaba una técnica que ahora se usa casi en todas partes: _memoria virtual_ (virtual memory).
### Memoria Virtual
La idea detrás de la memoria virtual es abstraer las direcciones de memoria del dispositivo de almacenamiento físico subyacente. En lugar de acceder directamente al dispositivo de almacenamiento, se realiza primero un paso de traducción. Para la segmentación, el paso de traducción consiste en agregar la dirección de desplazamiento del segmento activo. Imagina un programa que accede a la dirección de memoria `0x1234000` en un segmento con un desplazamiento de `0x1111000`: La dirección que realmente se accede es `0x2345000`.
Para diferenciar los dos tipos de direcciones, se llaman _virtuales_ a las direcciones antes de la traducción, y _físicas_ a las direcciones después de la traducción. Una diferencia importante entre estos dos tipos de direcciones es que las direcciones físicas son únicas y siempre se refieren a la misma ubicación de memoria distinta. Las direcciones virtuales, en cambio, dependen de la función de traducción. Es completamente posible que dos direcciones virtuales diferentes se refieran a la misma dirección física. Además, direcciones virtuales idénticas pueden referirse a diferentes direcciones físicas cuando utilizan diferentes funciones de traducción.
Un ejemplo donde esta propiedad es útil es ejecutar el mismo programa en paralelo dos veces:
![Dos espacios de direcciones virtuales con direcciones 0150, uno traducido a 100250, el otro a 300450](segmentation-same-program-twice.svg)
Aquí el mismo programa se ejecuta dos veces, pero con diferentes funciones de traducción. La primera instancia tiene un desplazamiento de segmento de 100, de manera que sus direcciones virtuales 0150 se traducen a las direcciones físicas 100250. La segunda instancia tiene un desplazamiento de 300, que traduce sus direcciones virtuales 0150 a direcciones físicas 300450. Esto permite que ambos programas ejecuten el mismo código y utilicen las mismas direcciones virtuales sin interferir entre sí.
Otra ventaja es que los programas ahora se pueden colocar en ubicaciones de memoria física arbitrarias, incluso si utilizan direcciones virtuales completamente diferentes. Por lo tanto, el sistema operativo puede utilizar la cantidad total de memoria disponible sin necesidad de recompilar programas.
### Fragmentación
La diferenciación entre direcciones virtuales y físicas hace que la segmentación sea realmente poderosa. Sin embargo, tiene el problema de la fragmentación. Como ejemplo, imagina que queremos ejecutar una tercera copia del programa que vimos anteriormente:
![Tres espacios de direcciones virtuales, pero no hay suficiente espacio continuo para el tercero](segmentation-fragmentation.svg)
No hay forma de mapear la tercera instancia del programa a la memoria virtual sin superposición, a pesar de que hay más que suficiente memoria libre disponible. El problema es que necesitamos memoria _continua_ y no podemos utilizar los pequeños fragmentos libres.
Una forma de combatir esta fragmentación es pausar la ejecución, mover las partes utilizadas de la memoria más cerca entre sí, actualizar la traducción y luego reanudar la ejecución:
![Tres espacios de direcciones virtuales después de la desfragmentación](segmentation-fragmentation-compacted.svg)
Ahora hay suficiente espacio continuo para iniciar la tercera instancia de nuestro programa.
La desventaja de este proceso de desfragmentación es que necesita copiar grandes cantidades de memoria, lo que disminuye el rendimiento. También necesita hacerse regularmente antes de que la memoria se fragmenta demasiado. Esto hace que el rendimiento sea impredecible, ya que los programas son pausados en momentos aleatorios y podrían volverse no responsivos.
El problema de la fragmentación es una de las razones por las que la segmentación ya no se utiliza en la mayoría de los sistemas. De hecho, la segmentación ni siquiera es compatible en el modo de 64 bits en x86. En su lugar, se utiliza _paginación_ (paging), que evita por completo el problema de la fragmentación.
## Paginación
La idea es dividir tanto el espacio de memoria virtual como el físico en bloques pequeños de tamaño fijo. Los bloques del espacio de memoria virtual se llaman _páginas_ (pages), y los bloques del espacio de direcciones físicas se llaman _marcos_ (frames). Cada página puede ser mapeada individualmente a un marco, lo que hace posible dividir regiones de memoria más grandes a través de marcos físicos no consecutivos.
La ventaja de esto se ve claramente si recapitulamos el ejemplo del espacio de memoria fragmentado, pero usamos paginación en lugar de segmentación esta vez:
![Con paginación, la tercera instancia del programa puede dividirse entre muchas áreas físicas más pequeñas.](paging-fragmentation.svg)
En este ejemplo, tenemos un tamaño de página de 50 bytes, lo que significa que cada una de nuestras regiones de memoria se divide en tres páginas. Cada página se mapea a un marco individualmente, por lo que una región de memoria virtual continua puede ser mapeada a marcos físicos no continuos. Esto nos permite iniciar la tercera instancia del programa sin realizar ninguna desfragmentación antes.
### Fragmentación Oculta
En comparación con la segmentación, la paginación utiliza muchas pequeñas regiones de memoria de tamaño fijo en lugar de unas pocas grandes regiones de tamaño variable. Dado que cada marco tiene el mismo tamaño, no hay marcos que sean demasiado pequeños para ser utilizados, por lo que no ocurre fragmentación.
O _parece_ que no ocurre fragmentación. Aún existe algún tipo oculto de fragmentación, la llamada _fragmentación interna_ (internal fragmentation). La fragmentación interna ocurre porque no cada región de memoria es un múltiplo exacto del tamaño de la página. Imagina un programa de tamaño 101 en el ejemplo anterior: aún necesitaría tres páginas de tamaño 50, por lo que ocuparía 49 bytes más de lo necesario. Para diferenciar los dos tipos de fragmentación, el tipo de fragmentación que ocurre al usar segmentación se llama _fragmentación externa_ (external fragmentation).
La fragmentación interna es desafortunada pero a menudo es mejor que la fragmentación externa que ocurre con la segmentación. Aún desperdicia memoria, pero no requiere desfragmentación y hace que la cantidad de fragmentación sea predecible (en promedio, media página por región de memoria).
### Tablas de Páginas
Vimos que cada una de las potencialmente millones de páginas se mapea individualmente a un marco. Esta información de mapeo necesita ser almacenada en algún lugar. La segmentación utiliza un registro de selector de segmento individual para cada región de memoria activa, lo cual no es posible para la paginación, ya que hay muchas más páginas que registros. En su lugar, la paginación utiliza una estructura tabular llamada _tabla de páginas_ (page table) para almacenar la información de mapeo.
Para nuestro ejemplo anterior, las tablas de páginas se verían así:
![Tres tablas de páginas, una para cada instancia del programa. Para la instancia 1, el mapeo es 0->100, 50->150, 100->200. Para la instancia 2, es 0->300, 50->350, 100->400. Para la instancia 3, es 0->250, 50->450, 100->500.](paging-page-tables.svg)
Vemos que cada instancia del programa tiene su propia tabla de páginas. Un puntero a la tabla actualmente activa se almacena en un registro especial de la CPU. En `x86`, este registro se llama `CR3`. Es trabajo del sistema operativo cargar este registro con el puntero a la tabla de páginas correcta antes de ejecutar cada instancia del programa.
En cada acceso a la memoria, la CPU lee el puntero de la tabla del registro y busca el marco mapeado para la página accedida en la tabla. Esto se realiza completamente en hardware y es completamente invisible para el programa en ejecución. Para agilizar el proceso de traducción, muchas arquitecturas de CPU tienen una caché especial que recuerda los resultados de las últimas traducciones.
Dependiendo de la arquitectura, las entradas de las tablas de páginas también pueden almacenar atributos como permisos de acceso en un campo de banderas. En el ejemplo anterior, la bandera "r/w" hace que la página sea tanto legible como escribible.
### Tablas de Páginas multinivel
Las simples tablas de páginas que acabamos de ver tienen un problema en espacios de direcciones más grandes: desperdician memoria. Por ejemplo, imagina un programa que utiliza las cuatro páginas virtuales `0`, `1_000_000`, `1_000_050` y `1_000_100` (usamos `_` como separador de miles):
![Página 0 mapeada al marco 0 y páginas `1_000_000``1_000_150` mapeadas a marcos 100250](single-level-page-table.svg)
Solo necesita 4 marcos físicos, pero la tabla de páginas tiene más de un millón de entradas. No podemos omitir las entradas vacías porque entonces la CPU ya no podría saltar directamente a la entrada correcta en el proceso de traducción (por ejemplo, ya no se garantiza que la cuarta página use la cuarta entrada).
Para reducir la memoria desperdiciada, podemos usar una **tabla de páginas de dos niveles**. La idea es que utilizamos diferentes tablas de páginas para diferentes regiones de direcciones. Una tabla adicional llamada tabla de páginas _nivel 2_ (level 2) contiene el mapeo entre las regiones de direcciones y las tablas de páginas (nivel 1).
Esto se explica mejor con un ejemplo. Supongamos que cada tabla de páginas de nivel 1 es responsable de una región de tamaño `10_000`. Entonces, las siguientes tablas existirían para el mapeo anterior:
![Página 0 apunta a la entrada 0 de la tabla de páginas de nivel 2, que apunta a la tabla de páginas de nivel 1 T1. La primera entrada de T1 apunta al marco 0; las otras entradas están vacías. Las páginas `1_000_000``1_000_150` apuntan a la entrada 100 de la tabla de páginas de nivel 2, que apunta a una tabla de páginas de nivel 1 diferente T2. Las tres primeras entradas de T2 apuntan a marcos 100250; las otras entradas están vacías.](multilevel-page-table.svg)
La página 0 cae en la primera región de `10_000` bytes, por lo que utiliza la primera entrada de la tabla de páginas de nivel 2. Esta entrada apunta a la tabla de páginas de nivel 1 T1, que especifica que la página `0` apunta al marco `0`.
Las páginas `1_000_000`, `1_000_050` y `1_000_100` caen todas en la entrada número 100 de la región de `10_000` bytes, por lo que utilizan la entrada 100 de la tabla de páginas de nivel 2. Esta entrada apunta a una tabla de páginas de nivel 1 diferente T2, que mapea las tres páginas a los marcos `100`, `150` y `200`. Ten en cuenta que la dirección de página en las tablas de nivel 1 no incluye el desplazamiento de región. Por ejemplo, la entrada para la página `1_000_050` es solo `50`.
Aún tenemos 100 entradas vacías en la tabla de nivel 2, pero muchas menos que el millón de entradas vacías de antes. La razón de este ahorro es que no necesitamos crear tablas de páginas de nivel 1 para las regiones de memoria no mapeadas entre `10_000` y `1_000_000`.
El principio de las tablas de páginas de dos niveles se puede extender a tres, cuatro o más niveles. Luego, el registro de la tabla de páginas apunta a la tabla de nivel más alto, que apunta a la tabla de nivel más bajo, que apunta a la siguiente tabla de nivel inferior, y así sucesivamente. La tabla de páginas de nivel 1 luego apunta al marco mapeado. El principio en general se llama _tabla de páginas multinivel_ (multilevel page table) o _jerárquica_.
Ahora que sabemos cómo funcionan la paginación y las tablas de páginas multinivel, podemos ver cómo se implementa la paginación en la arquitectura x86_64 (suponemos en lo siguiente que la CPU funciona en modo de 64 bits).
## Paginación en x86_64
La arquitectura x86_64 utiliza una tabla de páginas de 4 niveles y un tamaño de página de 4&nbsp;KiB. Cada tabla de páginas, independientemente del nivel, tiene un tamaño fijo de 512 entradas. Cada entrada tiene un tamaño de 8 bytes, por lo que cada tabla tiene un tamaño de 512 * 8&nbsp;B = 4&nbsp;KiB y, por lo tanto, encaja exactamente en una página.
El índice de la tabla de páginas para cada nivel se deriva directamente de la dirección virtual:
![Los bits 012 son el desplazamiento de la página, los bits 1221 el índice de nivel 1, los bits 2130 el índice de nivel 2, los bits 3039 el índice de nivel 3, y los bits 3948 el índice de nivel 4](x86_64-table-indices-from-address.svg)
Vemos que cada índice de tabla consta de 9 bits, lo que tiene sentido porque cada tabla tiene 2^9 = 512 entradas. Los 12 bits más bajos son el desplazamiento en la página de 4&nbsp;KiB (2^12 bytes = 4&nbsp;KiB). Los bits 48 a 64 se descartan, lo que significa que x86_64 no es realmente de 64 bits, ya que solo admite direcciones de 48 bits.
A pesar de que se descartan los bits 48 a 64, no pueden establecerse en valores arbitrarios. En cambio, todos los bits en este rango deben ser copias del bit 47 para mantener las direcciones únicas y permitir extensiones futuras como la tabla de páginas de 5 niveles. Esto se llama _extensión de signo_ (sign-extension) porque es muy similar a la [extensión de signo en complemento a dos]. Cuando una dirección no está correctamente extendida de signo, la CPU lanza una excepción.
[extensión de signo en complemento a dos]: https://en.wikipedia.org/wiki/Two's_complement#Sign_extension
Cabe destacar que los recientes procesadores Intel "Ice Lake" admiten opcionalmente [tablas de páginas de 5 niveles] para extender las direcciones virtuales de 48 bits a 57 bits. Dado que optimizar nuestro núcleo para una CPU específica no tiene sentido en esta etapa, solo trabajaremos con tablas de páginas de 4 niveles estándar en esta publicación.
[tablas de páginas de 5 niveles]: https://en.wikipedia.org/wiki/Intel_5-level_paging
### Ejemplo de Traducción
Pasemos por un ejemplo para entender cómo funciona el proceso de traducción en detalle:
![Un ejemplo de una jerarquía de 4 niveles de páginas con cada tabla de páginas mostrada en memoria física](x86_64-page-table-translation.svg)
La dirección física de la tabla de páginas de nivel 4 actualmente activa, que es la raíz de la tabla de páginas de 4 niveles, se almacena en el registro `CR3`. Cada entrada de la tabla de nivel 1 luego apunta al marco físico de la tabla del siguiente nivel. La entrada de la tabla de nivel 1 luego apunta al marco mapeado. Ten en cuenta que todas las direcciones en las tablas de páginas son físicas en lugar de virtuales, porque de lo contrario la CPU también necesitaría traducir esas direcciones (lo que podría provocar una recursión interminable).
La jerarquía de tablas de páginas anterior mapea dos páginas (en azul). A partir de los índices de la tabla de páginas, podemos deducir que las direcciones virtuales de estas dos páginas son `0x803FE7F000` y `0x803FE00000`. Veamos qué sucede cuando el programa intenta leer desde la dirección `0x803FE7F5CE`. Primero, convertimos la dirección a binario y determinamos los índices de la tabla de páginas y el desplazamiento de la página para la dirección:
![Los bits de extensión de signo son todos 0, el índice de nivel 4 es 1, el índice de nivel 3 es 0, el índice de nivel 2 es 511, el índice de nivel 1 es 127, y el desplazamiento de la página es 0x5ce](x86_64-page-table-translation-addresses.png)
Con estos índices, ahora podemos recorrer la jerarquía de la tabla de páginas para determinar el marco mapeado para la dirección:
- Comenzamos leyendo la dirección de la tabla de nivel 4 del registro `CR3`.
- El índice de nivel 4 es 1, así que miramos la entrada en el índice 1 de esa tabla, que nos dice que la tabla de nivel 3 se almacena en la dirección 16&nbsp;KiB.
- Cargamos la tabla de nivel 3 desde esa dirección y miramos la entrada en el índice 0, que nos apunta a la tabla de nivel 2 en 24&nbsp;KiB.
- El índice de nivel 2 es 511, así que miramos la última entrada de esa página para averiguar la dirección de la tabla de nivel 1.
- A través de la entrada en el índice 127 de la tabla de nivel 1, finalmente descubrimos que la página está mapeada al marco de 12&nbsp;KiB, o 0x3000 en hexadecimal.
- El paso final es agregar el desplazamiento de la página a la dirección del marco para obtener la dirección física 0x3000 + 0x5ce = 0x35ce.
![El mismo ejemplo de jerarquía de 4 niveles de páginas con 5 flechas adicionales: "Paso 0" del registro CR3 a la tabla de nivel 4, "Paso 1" de la entrada de nivel 4 a la tabla de nivel 3, "Paso 2" de la entrada de nivel 3 a la tabla de nivel 2, "Paso 3" de la entrada de nivel 2 a la tabla de nivel 1, y "Paso 4" de la tabla de nivel 1 a los marcos mapeados.](x86_64-page-table-translation-steps.svg)
Los permisos para la página en la tabla de nivel 1 son `r`, lo que significa que es solo de lectura. El hardware hace cumplir estos permisos y lanzaría una excepción si intentáramos escribir en esa página. Los permisos en las páginas de niveles superiores restringen los posibles permisos en niveles inferiores, por lo que si establecemos la entrada de nivel 3 como solo lectura, ninguna página que use esta entrada puede ser escribible, incluso si los niveles inferiores especifican permisos de lectura/escritura.
Es importante tener en cuenta que, aunque este ejemplo utilizó solo una instancia de cada tabla, normalmente hay múltiples instancias de cada nivel en cada espacio de direcciones. En el máximo, hay:
- una tabla de nivel 4,
- 512 tablas de nivel 3 (porque la tabla de nivel 4 tiene 512 entradas),
- 512 * 512 tablas de nivel 2 (porque cada una de las 512 tablas de nivel 3 tiene 512 entradas), y
- 512 * 512 * 512 tablas de nivel 1 (512 entradas para cada tabla de nivel 2).
### Formato de la Tabla de Páginas
Las tablas de páginas en la arquitectura x86_64 son básicamente un array de 512 entradas. En sintaxis de Rust:
```rust
#[repr(align(4096))]
pub struct PageTable {
entries: [PageTableEntry; 512],
}
```
Como se indica por el atributo `repr`, las tablas de páginas necesitan estar alineadas a la página, es decir, alineadas en un límite de 4&nbsp;KiB. Este requisito garantiza que una tabla de páginas siempre llene una página completa y permite una optimización que hace que las entradas sean muy compactas.
Cada entrada tiene un tamaño de 8 bytes (64 bits) y tiene el siguiente formato:
Bit(s) | Nombre | Significado
------ | ---- | -------
0 | presente | la página está actualmente en memoria
1 | escribible | se permite escribir en esta página
2 | accesible por el usuario | si no se establece, solo el código en modo núcleo puede acceder a esta página
3 | caché de escritura a través | las escrituras van directamente a la memoria
4 | desactivar caché | no se utiliza caché para esta página
5 | accedido | la CPU establece este bit cuando se utiliza esta página
6 | sucio | la CPU establece este bit cuando se realiza una escritura en esta página
7 | página enorme/null | debe ser 0 en P1 y P4, crea una página de 1&nbsp;GiB en P3, crea una página de 2&nbsp;MiB en P2
8 | global | la página no se borra de las cachés al cambiar el espacio de direcciones (el bit PGE del registro CR4 debe estar establecido)
9-11 | disponible | puede ser utilizado libremente por el sistema operativo
12-51 | dirección física | la dirección física alineada de 52 bits del marco o de la siguiente tabla de páginas
52-62 | disponible | puede ser utilizado libremente por el sistema operativo
63 | no ejecutar | prohibir la ejecución de código en esta página (el bit NXE en el registro EFER debe estar establecido)
Vemos que solo los bits 1251 se utilizan para almacenar la dirección física del marco. Los bits restantes se utilizan como banderas o pueden ser utilizados libremente por el sistema operativo. Esto es posible porque siempre apuntamos a una dirección alineada a 4096 bytes, ya sea a una tabla de páginas alineada a la página o al inicio de un marco mapeado. Esto significa que los bits 011 son siempre cero, por lo que no hay razón para almacenar estos bits porque el hardware puede simplemente configurarlos en cero antes de usar la dirección. Lo mismo es cierto para los bits 5263, ya que la arquitectura x86_64 solo admite direcciones físicas de 52 bits (similar a como solo admite direcciones virtuales de 48 bits).
Veamos más de cerca las banderas disponibles:
- La bandera `presente` diferencia las páginas mapeadas de las no mapeadas. Puede usarse para intercambiar temporalmente páginas en disco cuando la memoria principal se llena. Cuando la página se accede posteriormente, ocurre una excepción especial llamada _fallo de página_ (page fault), a la cual el sistema operativo puede reaccionar volviendo a cargar la página faltante desde el disco y luego continuar el programa.
- Las banderas `escribible` y `no ejecutar` controlan si el contenido de la página es escribible o contiene instrucciones ejecutables, respectivamente.
- Las banderas `accedido` y `sucio` son automáticamente configuradas por la CPU cuando se produce una lectura o escritura en la página. Esta información puede ser utilizada por el sistema operativo, por ejemplo, para decidir qué páginas intercambiar o si el contenido de la página ha sido modificado desde el último guardado en disco.
- Las banderas `caché de escritura a través` y `desactivar caché` permiten el control de cachés para cada página individualmente.
- La bandera `accesible por el usuario` hace que una página esté disponible para el código de espacio de usuario, de lo contrario, solo es accesible cuando la CPU está en modo núcleo. Esta característica puede utilizarse para hacer [llamadas al sistema] más rápidas manteniendo el núcleo mapeado mientras un programa de espacio de usuario se está ejecutando. Sin embargo, la vulnerabilidad [Spectre] puede permitir que los programas de espacio de usuario lean estas páginas, sin embargo.
- La bandera `global` le indica al hardware que una página está disponible en todos los espacios de direcciones y, por lo tanto, no necesita ser eliminada de la caché de traducción (ver la sección sobre el TLB a continuación) al cambiar de espacio de direcciones. Esta bandera se utiliza comúnmente junto con una bandera `accesible por el usuario` desactivada para mapear el código del núcleo a todos los espacios de direcciones.
- La bandera `página enorme` permite la creación de páginas de tamaños más grandes al permitir que las entradas de las tablas de nivel 2 o nivel 3 apunten directamente a un marco mapeado. Con este bit establecido, el tamaño de la página aumenta por un factor de 512 a 2&nbsp;MiB = 512 * 4&nbsp;KiB para las entradas de nivel 2 o incluso 1&nbsp;GiB = 512 * 2&nbsp;MiB para las entradas de nivel 3. La ventaja de usar páginas más grandes es que se necesitan menos líneas de la caché de traducción y menos tablas de páginas.
[llamadas al sistema]: https://en.wikipedia.org/wiki/System_call
[Spectre]: https://en.wikipedia.org/wiki/Spectre_(security_vulnerability)
El crate `x86_64` proporciona tipos para [tablas de páginas] y sus [entradas], por lo que no necesitamos crear estas estructuras nosotros mismos.
[tablas de páginas]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page_table/struct.PageTable.html
[entradas]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page_table/struct.PageTableEntry.html
### El Buffer de Traducción (TLB)
Una tabla de páginas de 4 niveles hace que la traducción de direcciones virtuales sea costosa porque cada traducción requiere cuatro accesos a la memoria. Para mejorar el rendimiento, la arquitectura x86_64 almacena en caché las últimas traducciones en el denominado _buffer de traducción_ (translation lookaside buffer, TLB). Esto permite omitir la traducción cuando todavía está en caché.
A diferencia de las demás cachés de la CPU, el TLB no es completamente transparente y no actualiza ni elimina traducciones cuando cambian los contenidos de las tablas de páginas. Esto significa que el núcleo debe actualizar manualmente el TLB cada vez que modifica una tabla de páginas. Para hacer esto, hay una instrucción especial de la CPU llamada [`invlpg`] ("invalidar página") que elimina la traducción para la página especificada del TLB, de modo que se vuelva a cargar desde la tabla de páginas en el siguiente acceso. El crate `x86_64` proporciona funciones en Rust para ambas variantes en el [`módulo tlb`].
[`invlpg`]: https://www.felixcloutier.com/x86/INVLPG.html
[`módulo tlb`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/tlb/index.html
Es importante recordar limpiar el TLB en cada modificación de tabla de páginas porque de lo contrario, la CPU podría seguir utilizando la vieja traducción, lo que puede llevar a errores no determinísticos que son muy difíciles de depurar.
## Implementación
Una cosa que aún no hemos mencionado: **Nuestro núcleo ya se ejecuta sobre paginación**. El bootloader (cargador de arranque) que añadimos en la publicación ["Un núcleo mínimo de Rust"] ya ha configurado una jerarquía de paginación de 4 niveles que mapea cada página de nuestro núcleo a un marco físico. El bootloader hace esto porque la paginación es obligatoria en el modo de 64 bits en x86_64.
["Un núcleo mínimo de Rust"]: @/edition-2/posts/02-minimal-rust-kernel/index.md#creating-a-bootimage
Esto significa que cada dirección de memoria que utilizamos en nuestro núcleo era una dirección virtual. Acceder al búfer VGA en la dirección `0xb8000` solo funcionó porque el bootloader _mapeó por identidad_ esa página de memoria, lo que significa que mapeó la página virtual `0xb8000` al marco físico `0xb8000`.
La paginación hace que nuestro núcleo ya sea relativamente seguro, ya que cada acceso a memoria que está fuera de límites causa una excepción de fallo de página en lugar de escribir en la memoria física aleatoria. El bootloader incluso establece los permisos de acceso correctos para cada página, lo que significa que solo las páginas que contienen código son ejecutables y solo las páginas de datos son escribibles.
### Fallos de Página
Intentemos causar un fallo de página accediendo a alguna memoria fuera de nuestro núcleo. Primero, creamos un controlador de fallos de página y lo registramos en nuestra IDT, para que veamos una excepción de fallo de página en lugar de un fallo doble genérico:
[fallo doble]: @/edition-2/posts/06-double-faults/index.md
```rust
// en src/interrupts.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
[]
idt.page_fault.set_handler_fn(page_fault_handler); // nuevo
idt
};
}
use x86_64::structures::idt::PageFaultErrorCode;
use crate::hlt_loop;
extern "x86-interrupt" fn page_fault_handler(
stack_frame: InterruptStackFrame,
error_code: PageFaultErrorCode,
) {
use x86_64::registers::control::Cr2;
println!("EXCEPCIÓN: FALLO DE PÁGINA");
println!("Dirección Accedida: {:?}", Cr2::read());
println!("Código de Error: {:?}", error_code);
println!("{:#?}", stack_frame);
hlt_loop();
}
```
El registro [`CR2`] se configura automáticamente por la CPU en un fallo de página y contiene la dirección virtual accedida que provocó el fallo de página. Usamos la función [`Cr2::read`] del crate `x86_64` para leerla e imprimirla. El tipo [`PageFaultErrorCode`] proporciona más información sobre el tipo de acceso a la memoria que causó el fallo de página, por ejemplo, si fue causado por una operación de lectura o escritura. Por esta razón, también la imprimimos. No podemos continuar la ejecución sin resolver el fallo de página, por lo que entramos en un [`hlt_loop`] al final.
[`CR2`]: https://en.wikipedia.org/wiki/Control_register#CR2
[`Cr2::read`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr2.html#method.read
[`PageFaultErrorCode`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html
[bug de LLVM]: https://github.com/rust-lang/rust/issues/57270
[`hlt_loop`]: @/edition-2/posts/07-hardware-interrupts/index.md#the-hlt-instruction
Ahora podemos intentar acceder a alguna memoria fuera de nuestro núcleo:
```rust
// en src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("¡Hola Mundo{}", "!");
blog_os::init();
// nuevo
let ptr = 0xdeadbeaf as *mut u8;
unsafe { *ptr = 42; }
// como antes
#[cfg(test)]
test_main();
println!("¡No se estrelló!");
blog_os::hlt_loop();
}
```
Cuando lo ejecutamos, vemos que se llama a nuestro controlador de fallos de página:
![EXCEPCIÓN: Fallo de Página, Dirección Accedida: VirtAddr(0xdeadbeaf), Código de Error: CAUSED_BY_WRITE, InterruptStackFrame: {…}](qemu-page-fault.png)
El registro `CR2` efectivamente contiene `0xdeadbeaf`, la dirección que intentamos acceder. El código de error nos dice a través del [`CAUSED_BY_WRITE`] que la falla ocurrió mientras intentábamos realizar una operación de escritura. También nos dice más a través de los [bits que _no_ están establecidos][`PageFaultErrorCode`]. Por ejemplo, el hecho de que la bandera `PROTECTION_VIOLATION` no esté establecida significa que el fallo de página ocurrió porque la página objetivo no estaba presente.
[`CAUSED_BY_WRITE`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html#associatedconstant.CAUSED_BY_WRITE
Vemos que el puntero de instrucciones actual es `0x2031b2`, así que sabemos que esta dirección apunta a una página de código. Las páginas de código están mapeadas como solo lectura por el bootloader, así que leer desde esta dirección funciona, pero escribir causa un fallo de página. Puedes intentar esto cambiando el puntero `0xdeadbeaf` a `0x2031b2`:
```rust
// Nota: La dirección real podría ser diferente para ti. Usa la dirección que
// informa tu controlador de fallos de página.
let ptr = 0x2031b2 as *mut u8;
// leer desde una página de código
unsafe { let x = *ptr; }
println!("la lectura funcionó");
// escribir en una página de código
unsafe { *ptr = 42; }
println!("la escritura funcionó");
```
Al comentar la última línea, vemos que el acceso de lectura funciona, pero el acceso de escritura causa un fallo de página:
![QEMU con salida: "la lectura funcionó, EXCEPCIÓN: Fallo de Página, Dirección Accedida: VirtAddr(0x2031b2), Código de Error: PROTECTION_VIOLATION | CAUSED_BY_WRITE, InterruptStackFrame: {…}"](qemu-page-fault-protection.png)
Vemos que el mensaje _"la lectura funcionó"_ se imprime, lo que indica que la operación de lectura no causó errores. Sin embargo, en lugar del mensaje _"la escritura funcionó"_, ocurre un fallo de página. Esta vez la bandera [`PROTECTION_VIOLATION`] está establecida además de la bandera [`CAUSED_BY_WRITE`], lo que indica que la página estaba presente, pero la operación no estaba permitida en ella. En este caso, las escrituras a la página no están permitidas ya que las páginas de código están mapeadas como solo lectura.
[`PROTECTION_VIOLATION`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html#associatedconstant.PROTECTION_VIOLATION
### Accediendo a las Tablas de Páginas
Intentemos echar un vistazo a las tablas de páginas que definen cómo está mapeado nuestro núcleo:
```rust
// en src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("¡Hola Mundo{}", "!");
blog_os::init();
use x86_64::registers::control::Cr3;
let (level_4_page_table, _) = Cr3::read();
println!("Tabla de páginas de nivel 4 en: {:?}", level_4_page_table.start_address());
[] // test_main(), println(…), y hlt_loop()
}
```
La función [`Cr3::read`] del `x86_64` devuelve la tabla de páginas de nivel 4 actualmente activa desde el registro `CR3`. Devuelve una tupla de un tipo [`PhysFrame`] y un tipo [`Cr3Flags`]. Solo nos interesa el marco, así que ignoramos el segundo elemento de la tupla.
[`Cr3::read`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr3.html#method.read
[`PhysFrame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/frame/struct.PhysFrame.html
[`Cr3Flags`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr3Flags.html
Cuando lo ejecutamos, vemos la siguiente salida:
```
Tabla de páginas de nivel 4 en: PhysAddr(0x1000)
```
Entonces, la tabla de páginas de nivel 4 actualmente activa se almacena en la dirección `0x1000` en _memoria física_, como indica el tipo de wrapper [`PhysAddr`]. La pregunta ahora es: ¿cómo podemos acceder a esta tabla desde nuestro núcleo?
[`PhysAddr`]: https://docs.rs/x86_64/0.14.2/x86_64/addr/struct.PhysAddr.html
Acceder a la memoria física directamente no es posible cuando la paginación está activa, ya que los programas podrían fácilmente eludir la protección de memoria y acceder a la memoria de otros programas de lo contrario. Así que la única forma de acceder a la tabla es a través de alguna página virtual que esté mapeada al marco físico en la dirección `0x1000`. Este problema de crear mapeos para los marcos de tabla de páginas es un problema general ya que el núcleo necesita acceder a las tablas de páginas regularmente, por ejemplo, al asignar una pila para un nuevo hilo.
Las soluciones a este problema se explican en detalle en la siguiente publicación.
## Resumen
Esta publicación introdujo dos técnicas de protección de memoria: segmentación y paginación. Mientras que la primera utiliza regiones de memoria de tamaño variable y sufre de fragmentación externa, la segunda utiliza páginas de tamaño fijo y permite un control mucho más detallado sobre los permisos de acceso.
La paginación almacena la información de mapeo para las páginas en tablas de páginas con uno o más niveles. La arquitectura x86_64 utiliza tablas de páginas de 4 niveles y un tamaño de página de 4&nbsp;KiB. El hardware recorre automáticamente las tablas de páginas y almacena en caché las traducciones resultantes en el buffer de traducción (TLB). Este buffer no se actualiza de manera transparente y necesita ser limpiado manualmente en cambios de tabla de páginas.
Aprendimos que nuestro núcleo ya se ejecuta sobre paginación y que los accesos ilegales a la memoria provocan excepciones de fallo de página. Intentamos acceder a las tablas de páginas actualmente activas, pero no pudimos hacerlo porque el registro CR3 almacena una dirección física que no podemos acceder directamente desde nuestro núcleo.
## ¿Qué sigue?
La siguiente publicación explica cómo implementar soporte para la paginación en nuestro núcleo. Presenta diferentes formas de acceder a la memoria física desde nuestro núcleo, lo que hace posible acceder a las tablas de páginas en las que se ejecuta nuestro núcleo. En este momento, seremos capaces de implementar funciones para traducir direcciones virtuales a físicas y para crear nuevos mapeos en las tablas de páginas.

View File

@@ -0,0 +1,947 @@
+++
title = "Implementación de Paginación"
weight = 9
path = "implementacion-de-paginacion"
date = 2019-03-14
[extra]
chapter = "Gestión de la Memoria"
+++
Esta publicación muestra cómo implementar soporte para paginación en nuestro núcleo. Primero explora diferentes técnicas para hacer accesibles los marcos de la tabla de páginas físicas al núcleo y discute sus respectivas ventajas y desventajas. Luego implementa una función de traducción de direcciones y una función para crear un nuevo mapeo.
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes algún problema o pregunta, abre un problema allí. También puedes dejar comentarios [al final]. El código fuente completo de esta publicación se puede encontrar en la rama [`post-09`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-09
<!-- toc -->
## Introducción
La [publicación anterior] dio una introducción al concepto de paginación. Motivó la paginación comparándola con la segmentación, explicó cómo funcionan la paginación y las tablas de páginas, y luego introdujo el diseño de tabla de páginas de 4 niveles de `x86_64`. Descubrimos que el bootloader (cargador de arranque) ya configuró una jerarquía de tablas de páginas para nuestro núcleo, lo que significa que nuestro núcleo ya se ejecuta en direcciones virtuales. Esto mejora la seguridad, ya que los accesos ilegales a la memoria causan excepciones de falta de página en lugar de modificar la memoria física arbitraria.
[publicación anterior]: @/edition-2/posts/08-paging-introduction/index.md
La publicación terminó con el problema de que [no podemos acceder a las tablas de páginas desde nuestro núcleo][end of previous post] porque se almacenan en la memoria física y nuestro núcleo ya se ejecuta en direcciones virtuales. Esta publicación explora diferentes enfoques para hacer los marcos de la tabla de páginas accesibles a nuestro núcleo. Discutiremos las ventajas y desventajas de cada enfoque y luego decidiremos un enfoque para nuestro núcleo.
[end of previous post]: @/edition-2/posts/08-paging-introduction/index.md#accessing-the-page-tables
Para implementar el enfoque, necesitaremos el soporte del bootloader, así que lo configuraremos primero. Después, implementaremos una función que recorra la jerarquía de tablas de páginas para traducir direcciones virtuales a físicas. Finalmente, aprenderemos a crear nuevos mapeos en las tablas de páginas y a encontrar marcos de memoria no utilizados para crear nuevas tablas de páginas.
## Accediendo a las Tablas de Páginas
Acceder a las tablas de páginas desde nuestro núcleo no es tan fácil como podría parecer. Para entender el problema, echemos un vistazo a la jerarquía de tablas de páginas de 4 niveles del artículo anterior nuevamente:
![Un ejemplo de una jerarquía de página de 4 niveles con cada tabla de páginas mostrada en memoria física](../paging-introduction/x86_64-page-table-translation.svg)
Lo importante aquí es que cada entrada de página almacena la dirección _física_ de la siguiente tabla. Esto evita la necesidad de hacer una traducción para estas direcciones también, lo cual sería malo para el rendimiento y podría fácilmente causar bucles de traducción infinitos.
El problema para nosotros es que no podemos acceder directamente a las direcciones físicas desde nuestro núcleo, ya que nuestro núcleo también se ejecuta sobre direcciones virtuales. Por ejemplo, cuando accedemos a la dirección `4KiB`, accedemos a la dirección _virtual_ `4 KiB`, no a la dirección _física_ `4KiB` donde se almacena la tabla de páginas de nivel 4. Cuando queremos acceder a la dirección física `4KiB`, solo podemos hacerlo a través de alguna dirección virtual que mapea a ella.
Así que, para acceder a los marcos de la tabla de páginas, necesitamos mapear algunas páginas virtuales a ellos. Hay diferentes formas de crear estos mapeos que nos permiten acceder a marcos arbitrarios de la tabla de páginas.
### Mapeo de Identidad
Una solución simple es **mapear de identidad todas las tablas de páginas**:
![Un espacio de direcciones virtual y física con varias páginas virtuales mapeadas al marco físico con la misma dirección](identity-mapped-page-tables.svg)
En este ejemplo, vemos varios marcos de tablas de páginas mapeados de identidad. De esta manera, las direcciones físicas de las tablas de páginas también son direcciones virtuales válidas, por lo que podemos acceder fácilmente a las tablas de páginas de todos los niveles comenzando desde el registro CR3.
Sin embargo, esto desordena el espacio de direcciones virtuales y dificulta encontrar regiones de memoria continuas de tamaños más grandes. Por ejemplo, imagina que queremos crear una región de memoria virtual de tamaño 1000&nbsp;KiB en el gráfico anterior, por ejemplo, para [mapeo de una memoria de archivo]. No podemos comenzar la región en `28KiB` porque colisionaría con la página ya mapeada en `1004KiB`. Así que tenemos que buscar más hasta que encontremos un área suficientemente grande sin mapear, por ejemplo, en `1008KiB`. Este es un problema de fragmentación similar al de la [segmentación].
[mapeo de una memoria de archivo]: https://en.wikipedia.org/wiki/Memory-mapped_file
[segmentación]: @/edition-2/posts/08-paging-introduction/index.md#fragmentation
Igualmente, hace que sea mucho más difícil crear nuevas tablas de páginas porque necesitamos encontrar marcos físicos cuyos correspondientes páginas no estén ya en uso. Por ejemplo, asumamos que reservamos la región de memoria _virtual_ de 1000&nbsp;KiB comenzando en `1008KiB` para nuestro archivo mapeado en memoria. Ahora no podemos usar ningún marco con una dirección _física_ entre `1000KiB` y `2008KiB`, porque no podemos mapear de identidad.
### Mapear en un Desplazamiento Fijo
Para evitar el problema de desordenar el espacio de direcciones virtuales, podemos **usar una región de memoria separada para los mapeos de la tabla de páginas**. Así que en lugar de mapear de identidad los marcos de las tablas de páginas, los mapeamos en un desplazamiento fijo en el espacio de direcciones virtuales. Por ejemplo, el desplazamiento podría ser de 10&nbsp;TiB:
![La misma figura que para el mapeo de identidad, pero cada página virtual mapeada está desplazada por 10 TiB.](page-tables-mapped-at-offset.svg)
Al usar la memoria virtual en el rango `10 TiB..(10 TiB + tamaño de la memoria física)` exclusivamente para mapeos de tablas de páginas, evitamos los problemas de colisión del mapeo de identidad. Reservar una región tan grande del espacio de direcciones virtuales solo es posible si el espacio de direcciones virtuales es mucho más grande que el tamaño de la memoria física. Esto no es un problema en `x86_64` ya que el espacio de direcciones de 48 bits es de 256&nbsp;TiB.
Este enfoque aún tiene la desventaja de que necesitamos crear un nuevo mapeo cada vez que creamos una nueva tabla de páginas. Además, no permite acceder a las tablas de páginas de otros espacios de direcciones, lo que sería útil al crear un nuevo proceso.
### Mapear la Memoria Física Completa
Podemos resolver estos problemas **mapeando la memoria física completa** en lugar de solo los marcos de la tabla de páginas:
![La misma figura que para el mapeo con desplazamiento, pero cada marco físico tiene un mapeo (en 10 TiB + X) en lugar de solo los marcos de la tabla de páginas.](map-complete-physical-memory.svg)
Este enfoque permite a nuestro núcleo acceder a memoria física arbitraria, incluyendo marcos de la tabla de páginas de otros espacios de direcciones. La región de memoria virtual reservada tiene el mismo tamaño que antes, con la diferencia de que ya no contiene páginas sin mapear.
La desventaja de este enfoque es que se necesitan tablas de páginas adicionales para almacenar el mapeo de la memoria física. Estas tablas de páginas deben almacenarse en alguna parte, por lo que ocupan parte de la memoria física, lo que puede ser un problema en dispositivos con poca memoria.
En `x86_64`, sin embargo, podemos utilizar [páginas grandes] con un tamaño de 2&nbsp;MiB para el mapeo, en lugar de las páginas de 4&nbsp;KiB por defecto. De esta manera, mapear 32&nbsp;GiB de memoria física solo requiere 132&nbsp;KiB para las tablas de páginas, ya que solo se necesita una tabla de nivel 3 y 32 tablas de nivel 2. Las páginas grandes también son más eficientes en caché, ya que utilizan menos entradas en el buffer de traducción (TLB).
[páginas grandes]: https://en.wikipedia.org/wiki/Page_%28computer_memory%29#Multiple_page_sizes
### Mapeo Temporal
Para dispositivos con cantidades muy pequeñas de memoria física, podríamos **mapear los marcos de la tabla de páginas solo temporalmente** cuando necesitemos acceder a ellos. Para poder crear los mapeos temporales, solo necesitamos una única tabla de nivel 1 mapeada de identidad:
![Un espacio de direcciones virtual y física con una tabla de nivel 1 mapeada de identidad, que mapea su 0ª entrada al marco de la tabla de nivel 2, mapeando así ese marco a la página con dirección 0](temporarily-mapped-page-tables.svg)
La tabla de nivel 1 en este gráfico controla los primeros 2&nbsp;MiB del espacio de direcciones virtuales. Esto se debe a que es accesible comenzando en el registro CR3 y siguiendo la entrada 0 en las tablas de páginas de niveles 4, 3 y 2. La entrada con índice `8` mapea la página virtual en la dirección `32KiB` al marco físico en la dirección `32KiB`, mapeando de identidad la tabla de nivel 1 misma. El gráfico muestra este mapeo de identidad mediante la flecha horizontal en `32KiB`.
Al escribir en la tabla de nivel 1 mapeada de identidad, nuestro núcleo puede crear hasta 511 mapeos temporales (512 menos la entrada requerida para el mapeo de identidad). En el ejemplo anterior, el núcleo creó dos mapeos temporales:
- Al mapear la 0ª entrada de la tabla de nivel 1 al marco con dirección `24KiB`, creó un mapeo temporal de la página virtual en `0KiB` al marco físico de la tabla de nivel 2, indicado por la línea de puntos.
- Al mapear la 9ª entrada de la tabla de nivel 1 al marco con dirección `4KiB`, creó un mapeo temporal de la página virtual en `36KiB` al marco físico de la tabla de nivel 4, indicado por la línea de puntos.
Ahora el núcleo puede acceder a la tabla de nivel 2 escribiendo en la página `0KiB` y a la tabla de nivel 4 escribiendo en la página `36KiB`.
El proceso para acceder a un marco de tabla de páginas arbitrario con mapeos temporales sería:
- Buscar una entrada libre en la tabla de nivel 1 mapeada de identidad.
- Mapear esa entrada al marco físico de la tabla de páginas que queremos acceder.
- Acceder al marco objetivo a través de la página virtual que se mapea a la entrada.
- Reestablecer la entrada como no utilizada, eliminando así el mapeo temporal nuevamente.
Este enfoque reutiliza las mismas 512 páginas virtuales para crear los mapeos y, por lo tanto, requiere solo 4&nbsp;KiB de memoria física. La desventaja es que es un poco engorroso, especialmente porque un nuevo mapeo podría requerir modificaciones en múltiples niveles de la tabla, lo que significa que tendríamos que repetir el proceso anterior múltiples veces.
### Tablas de Páginas Recursivas
Otro enfoque interesante, que no requiere tablas de páginas adicionales, es **mapear la tabla de páginas de manera recursiva**. La idea detrás de este enfoque es mapear una entrada de la tabla de nivel 4 a la misma tabla de nivel 4. Al hacer esto, reservamos efectivamente una parte del espacio de direcciones virtuales y mapeamos todos los marcos de tablas de páginas actuales y futuros a ese espacio.
Veamos un ejemplo para entender cómo funciona todo esto:
![Un ejemplo de una jerarquía de página de 4 niveles con cada tabla de páginas mostrada en memoria física. La entrada 511 de la tabla de nivel 4 está mapeada al marco de 4KiB, el marco de la tabla de nivel 4 misma.](recursive-page-table.png)
La única diferencia con el [ejemplo al principio de este artículo] es la entrada adicional en el índice `511` en la tabla de nivel 4, que está mapeada al marco físico `4KiB`, el marco de la tabla de nivel 4 misma.
[ejemplo al principio de este artículo]: #accessing-page-tables
Al permitir que la CPU siga esta entrada en una traducción, no llega a una tabla de nivel 3, sino a la misma tabla de nivel 4 nuevamente. Esto es similar a una función recursiva que se llama a sí misma; por lo tanto, esta tabla se llama _tabla de páginas recursiva_. Lo importante es que la CPU asume que cada entrada en la tabla de nivel 4 apunta a una tabla de nivel 3, por lo que ahora trata la tabla de nivel 4 como una tabla de nivel 3. Esto funciona porque las tablas de todos los niveles tienen la misma estructura exacta en `x86_64`.
Al seguir la entrada recursiva una o múltiples veces antes de comenzar la traducción real, podemos efectivamente acortar el número de niveles que la CPU recorre. Por ejemplo, si seguimos la entrada recursiva una vez y luego procedemos a la tabla de nivel 3, la CPU piensa que la tabla de nivel 3 es una tabla de nivel 2. Siguiendo, trata la tabla de nivel 2 como una tabla de nivel 1 y la tabla de nivel 1 como el marco mapeado. Esto significa que ahora podemos leer y escribir la tabla de nivel 1 porque la CPU piensa que es el marco mapeado. El gráfico a continuación ilustra los cinco pasos de traducción:
![El ejemplo anterior de jerarquía de páginas de 4 niveles con 5 flechas: "Paso 0" de CR4 a la tabla de nivel 4, "Paso 1" de la tabla de nivel 4 a la tabla de nivel 4, "Paso 2" de la tabla de nivel 4 a la tabla de nivel 3, "Paso 3" de la tabla de nivel 3 a la tabla de nivel 2, y "Paso 4" de la tabla de nivel 2 a la tabla de nivel 1.](recursive-page-table-access-level-1.png)
De manera similar, podemos seguir la entrada recursiva dos veces antes de comenzar la traducción para reducir el número de niveles recorridos a dos:
![La misma jerarquía de páginas de 4 niveles con las siguientes 4 flechas: "Paso 0" de CR4 a la tabla de nivel 4, "Pasos 1&2" de la tabla de nivel 4 a la tabla de nivel 4, "Paso 3" de la tabla de nivel 4 a la tabla de nivel 3, y "Paso 4" de la tabla de nivel 3 a la tabla de nivel 2.](recursive-page-table-access-level-2.png)
Sigamos paso a paso: Primero, la CPU sigue la entrada recursiva en la tabla de nivel 4 y piensa que llega a una tabla de nivel 3. Luego sigue la entrada recursiva nuevamente y piensa que llega a una tabla de nivel 2. Pero en realidad, todavía está en la tabla de nivel 4. Cuando la CPU ahora sigue una entrada diferente, aterriza en una tabla de nivel 3, pero piensa que ya está en una tabla de nivel 1. Así que mientras la siguiente entrada apunta a una tabla de nivel 2, la CPU piensa que apunta al marco mapeado, lo que nos permite leer y escribir la tabla de nivel 2.
Acceder a las tablas de niveles 3 y 4 funciona de la misma manera. Para acceder a la tabla de nivel 3, seguimos la entrada recursiva tres veces, engañando a la CPU para que piense que ya está en una tabla de nivel 1. Luego seguimos otra entrada y llegamos a una tabla de nivel 3, que la CPU trata como un marco mapeado. Para acceder a la tabla de nivel 4 misma, simplemente seguimos la entrada recursiva cuatro veces hasta que la CPU trate la tabla de nivel 4 como el marco mapeado (en azul en el gráfico a continuación).
![La misma jerarquía de páginas de 4 niveles con las siguientes 3 flechas: "Paso 0" de CR4 a la tabla de nivel 4, "Pasos 1,2,3" de la tabla de nivel 4 a la tabla de nivel 4, y "Paso 4" de la tabla de nivel 4 a la tabla de nivel 3. En azul, la alternativa "Pasos 1,2,3,4" flecha de la tabla de nivel 4 a la tabla de nivel 4.](recursive-page-table-access-level-3.png)
Puede llevar un tiempo asimilar el concepto, pero funciona bastante bien en la práctica.
En la siguiente sección, explicamos cómo construir direcciones virtuales para seguir la entrada recursiva una o múltiples veces. No utilizaremos la paginación recursiva para nuestra implementación, así que no necesitas leerlo para continuar con la publicación. Si te interesa, simplemente haz clic en _"Cálculo de Direcciones"_ para expandirlo.
---
<details>
<summary><h4>Cálculo de Direcciones</h4></summary>
Vimos que podemos acceder a tablas de todos los niveles siguiendo la entrada recursiva una o múltiples veces antes de la traducción real. Dado que los índices en las tablas de los cuatro niveles se derivan directamente de la dirección virtual, necesitamos construir direcciones virtuales especiales para esta técnica. Recuerda, los índices de la tabla de páginas se derivan de la dirección de la siguiente manera:
![Bits 012 son el desplazamiento de página, bits 1221 el índice de nivel 1, bits 2130 el índice de nivel 2, bits 3039 el índice de nivel 3, y bits 3948 el índice de nivel 4](../paging-introduction/x86_64-table-indices-from-address.svg)
Supongamos que queremos acceder a la tabla de nivel 1 que mapea una página específica. Como aprendimos anteriormente, esto significa que debemos seguir la entrada recursiva una vez antes de continuar con los índices de niveles 4, 3 y 2. Para hacer eso, movemos cada bloque de la dirección un bloque a la derecha y establecemos el índice original de nivel 4 en el índice de la entrada recursiva:
![Bits 012 son el desplazamiento en el marco de la tabla de nivel 1, bits 1221 el índice de nivel 2, bits 2130 el índice de nivel 3, bits 3039 el índice de nivel 4, y bits 3948 el índice de la entrada recursiva](table-indices-from-address-recursive-level-1.svg)
Para acceder a la tabla de nivel 2 de esa página, movemos cada índice dos bloques a la derecha y configuramos ambos bloques del índice original de nivel 4 y el índice original de nivel 3 al índice de la entrada recursiva:
![Bits 012 son el desplazamiento en el marco de la tabla de nivel 2, bits 1221 el índice de nivel 3, bits 2130 el índice de nivel 4, y bits 3039 y bits 3948 son el índice de la entrada recursiva](table-indices-from-address-recursive-level-2.svg)
Acceder a la tabla de nivel 3 funciona moviendo cada bloque tres bloques a la derecha y usando el índice recursivo para el índice original de niveles 4, 3 y 2:
![Bits 012 son el desplazamiento en el marco de la tabla de nivel 3, bits 1221 el índice de nivel 4, y bits 2130, bits 3039 y bits 3948 son el índice de la entrada recursiva](table-indices-from-address-recursive-level-3.svg)
Finalmente, podemos acceder a la tabla de nivel 4 moviendo cada bloque cuatro bloques a la derecha y usando el índice recursivo para todos los bloques de dirección excepto para el desplazamiento:
![Bits 012 son el desplazamiento en el marco de la tabla l y bits 1221, bits 2130, bits 3039 y bits 3948 son el índice de la entrada recursiva](table-indices-from-address-recursive-level-4.svg)
Ahora podemos calcular direcciones virtuales para las tablas de los cuatro niveles. Incluso podemos calcular una dirección que apunte exactamente a una entrada específica de la tabla de páginas multiplicando su índice por 8, el tamaño de una entrada de tabla de páginas.
La tabla a continuación resume la estructura de la dirección para acceder a los diferentes tipos de marcos:
Dirección Virtual para | Estructura de Dirección ([octal])
------------------- | -------------------------------
Página | `0o_SSSSSS_AAA_BBB_CCC_DDD_EEEE`
Entrada de Tabla de Nivel 1 | `0o_SSSSSS_RRR_AAA_BBB_CCC_DDDD`
Entrada de Tabla de Nivel 2 | `0o_SSSSSS_RRR_RRR_AAA_BBB_CCCC`
Entrada de Tabla de Nivel 3 | `0o_SSSSSS_RRR_RRR_RRR_AAA_BBBB`
Entrada de Tabla de Nivel 4 | `0o_SSSSSS_RRR_RRR_RRR_RRR_AAAA`
[octal]: https://en.wikipedia.org/wiki/Octal
Donde `AAA` es el índice de nivel 4, `BBB` el índice de nivel 3, `CCC` el índice de nivel 2, y `DDD` el índice de nivel 1 del marco mapeado, y `EEEE` el desplazamiento dentro de él. `RRR` es el índice de la entrada recursiva. Cuando un índice (tres dígitos) se transforma en un desplazamiento (cuatro dígitos), se hace multiplicándolo por 8 (el tamaño de una entrada de tabla de páginas). Con este desplazamiento, la dirección resultante apunta directamente a la respectiva entrada de la tabla de páginas.
`SSSSSS` son bits de extensión de signo, lo que significa que son todos copias del bit 47. Este es un requisito especial para direcciones válidas en la arquitectura `x86_64`. Lo explicamos en el [artículo anterior][sign extension].
[sign extension]: @/edition-2/posts/08-paging-introduction/index.md#paging-on-x86-64
Usamos números [octales] para representar las direcciones ya que cada carácter octal representa tres bits, lo que nos permite separar claramente los índices de 9 bits de los diferentes niveles de la tabla de páginas. Esto no es posible con el sistema hexadecimal, donde cada carácter representa cuatro bits.
##### En Código Rust
Para construir tales direcciones en código Rust, puedes usar operaciones bit a bit:
```rust
// la dirección virtual cuya correspondiente tablas de páginas quieres acceder
let addr: usize = [];
let r = 0o777; // índice recursivo
let sign = 0o177777 << 48; // extensión de signo
// recuperar los índices de la tabla de páginas de la dirección que queremos traducir
let l4_idx = (addr >> 39) & 0o777; // índice de nivel 4
let l3_idx = (addr >> 30) & 0o777; // índice de nivel 3
let l2_idx = (addr >> 21) & 0o777; // índice de nivel 2
let l1_idx = (addr >> 12) & 0o777; // índice de nivel 1
let page_offset = addr & 0o7777;
// calcular las direcciones de las tablas
let level_4_table_addr =
sign | (r << 39) | (r << 30) | (r << 21) | (r << 12);
let level_3_table_addr =
sign | (r << 39) | (r << 30) | (r << 21) | (l4_idx << 12);
let level_2_table_addr =
sign | (r << 39) | (r << 30) | (l4_idx << 21) | (l3_idx << 12);
let level_1_table_addr =
sign | (r << 39) | (l4_idx << 30) | (l3_idx << 21) | (l2_idx << 12);
```
El código anterior asume que la última entrada de nivel 4 con índice `0o777` (511) se mapea de manera recursiva. Este no es el caso actualmente, así que el código aún no funcionará. Véase a continuación cómo decirle al bootloader que configure el mapeo recursivo.
Alternativamente, para realizar las operaciones bit a bit manualmente, puedes usar el tipo [`RecursivePageTable`] de la crate `x86_64`, que proporciona abstracciones seguras para varias operaciones de la tabla de páginas. Por ejemplo, el siguiente código muestra cómo traducir una dirección virtual a su dirección física mapeada:
[`RecursivePageTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.RecursivePageTable.html
```rust
// en src/memory.rs
use x86_64::structures::paging::{Mapper, Page, PageTable, RecursivePageTable};
use x86_64::{VirtAddr, PhysAddr};
/// Crea una instancia de RecursivePageTable a partir de la dirección de nivel 4.
let level_4_table_addr = [];
let level_4_table_ptr = level_4_table_addr as *mut PageTable;
let recursive_page_table = unsafe {
let level_4_table = &mut *level_4_table_ptr;
RecursivePageTable::new(level_4_table).unwrap();
}
/// Recupera la dirección física para la dirección virtual dada
let addr: u64 = []
let addr = VirtAddr::new(addr);
let page: Page = Page::containing_address(addr);
// realizar la traducción
let frame = recursive_page_table.translate_page(page);
frame.map(|frame| frame.start_address() + u64::from(addr.page_offset()))
```
Nuevamente, se requiere un mapeo recursivo válido para que este código funcione. Con tal mapeo, la dirección faltante `level_4_table_addr` se puede calcular como en el primer ejemplo de código.
</details>
---
La paginación recursiva es una técnica interesante que muestra cuán poderoso puede ser un solo mapeo en una tabla de páginas. Es relativamente fácil de implementar y solo requiere una cantidad mínima de configuración (solo una entrada recursiva), por lo que es una buena opción para los primeros experimentos con paginación.
Sin embargo, también tiene algunas desventajas:
- Ocupa una gran cantidad de memoria virtual (512&nbsp;GiB). Esto no es un gran problema en el gran espacio de direcciones de 48 bits, pero podría llevar a un comportamiento de caché subóptimo.
- Solo permite acceder fácilmente al espacio de direcciones activo actualmente. Acceder a otros espacios de direcciones sigue siendo posible cambiando la entrada recursiva, pero se requiere un mapeo temporal para volver a cambiar. Describimos cómo hacer esto en la publicación (desactualizada) [_Remap The Kernel_].
- Se basa fuertemente en el formato de tabla de páginas de `x86` y podría no funcionar en otras arquitecturas.
[_Remap The Kernel_]: https://os.phil-opp.com/remap-the-kernel/#overview
## Soporte del Bootloader
Todos estos enfoques requieren modificaciones en las tablas de páginas para su configuración. Por ejemplo, se necesitan crear mapeos para la memoria física o debe mapearse una entrada de la tabla de nivel 4 de forma recursiva. El problema es que no podemos crear estos mapeos requeridos sin una forma existente de acceder a las tablas de páginas.
Esto significa que necesitamos la ayuda del bootloader, que crea las tablas de páginas en las que se ejecuta nuestro núcleo. El bootloader tiene acceso a las tablas de páginas, por lo que puede crear cualquier mapeo que necesitemos. En su implementación actual, la crate `bootloader` tiene soporte para dos de los enfoques anteriores, controlados a través de [c características de cargo]:
[c características de cargo]: https://doc.rust-lang.org/cargo/reference/features.html#the-features-section
- La característica `map_physical_memory` mapea la memoria física completa en algún lugar del espacio de direcciones virtuales. Por lo tanto, el núcleo tiene acceso a toda la memoria física y puede seguir el enfoque [_Mapear la Memoria Física Completa_](#mapear-la-memoria-fisica-completa).
- Con la característica `recursive_page_table`, el bootloader mapea una entrada de la tabla de nivel 4 de manera recursiva. Esto permite que el núcleo acceda a las tablas de páginas como se describe en la sección [_Tablas de Páginas Recursivas_](#tablas-de-paginas-recursivas).
Elegimos el primer enfoque para nuestro núcleo ya que es simple, independiente de la plataforma y más poderoso (también permite acceder a marcos que no son de tabla de páginas). Para habilitar el soporte necesario del bootloader, agregamos la característica `map_physical_memory` a nuestra dependencia de `bootloader`:
```toml
[dependencies]
bootloader = { version = "0.9", features = ["map_physical_memory"]}
```
Con esta característica habilitada, el bootloader mapea la memoria física completa a algún rango de direcciones virtuales no utilizadas. Para comunicar el rango de direcciones virtuales a nuestro núcleo, el bootloader pasa una estructura de _información de boot_.
### Información de Boot
La crate `bootloader` define una struct [`BootInfo`] que contiene toda la información que pasa a nuestro núcleo. La struct aún se encuentra en una etapa temprana, así que espera algunos errores al actualizar a futuras versiones de bootloader que sean [incompatibles con semver]. Con la característica `map_physical_memory` habilitada, actualmente tiene los dos campos `memory_map` y `physical_memory_offset`:
[`BootInfo`]: https://docs.rs/bootloader/0.9/bootloader/bootinfo/struct.BootInfo.html
[incompatibles con semver]: https://doc.rust-lang.org/stable/cargo/reference/specifying-dependencies.html#caret-requirements
- El campo `memory_map` contiene una descripción general de la memoria física disponible. Esto le dice a nuestro núcleo cuánta memoria física está disponible en el sistema y qué regiones de memoria están reservadas para dispositivos como el hardware VGA. El mapa de memoria se puede consultar desde la BIOS o UEFI firmware, pero solo muy al principio en el proceso de arranque. Por esta razón, debe ser proporcionado por el bootloader porque no hay forma de que el núcleo lo recupere más tarde. Necesitaremos el mapa de memoria más adelante en esta publicación.
- El `physical_memory_offset` nos indica la dirección de inicio virtual del mapeo de memoria física. Al agregar este desplazamiento a una dirección física, obtenemos la dirección virtual correspondiente. Esto nos permite acceder a memoria física arbitraria desde nuestro núcleo.
- Este desplazamiento de memoria física se puede personalizar añadiendo una tabla `[package.metadata.bootloader]` en Cargo.toml y configurando el campo `physical-memory-offset = "0x0000f00000000000"` (o cualquier otro valor). Sin embargo, ten en cuenta que el bootloader puede entrar en pánico si se encuentra valores de dirección física que comienzan a superponerse con el espacio más allá del desplazamiento, es decir, áreas que habría mapeado previamente a otras direcciones físicas tempranas. Por lo tanto, en general, cuanto mayor sea el valor (> 1 TiB), mejor.
El bootloader pasa la struct `BootInfo` a nuestro núcleo en forma de un argumento `&'static BootInfo` a nuestra función `_start`. Aún no hemos declarado este argumento en nuestra función, así que lo agregaremos:
```rust
// en src/main.rs
use bootloader::BootInfo;
#[no_mangle]
pub extern "C" fn _start(boot_info: &'static BootInfo) -> ! { // nuevo argumento
[]
}
```
No fue un problema dejar de lado este argumento antes porque la convención de llamada `x86_64` pasa el primer argumento en un registro de CPU. Por lo tanto, el argumento simplemente se ignora cuando no se declara. Sin embargo, sería un problema si accidentalmente usáramos un tipo de argumento incorrecto, ya que el compilador no conoce la firma de tipo correcta de nuestra función de entrada.
### El Macro `entry_point`
Dado que nuestra función `_start` se llama externamente desde el bootloader, no se verifica la firma de nuestra función. Esto significa que podríamos hacer que tome argumentos arbitrarios sin ningún error de compilación, pero fallaría o causaría un comportamiento indefinido en tiempo de ejecución.
Para asegurarnos de que la función de punto de entrada siempre tenga la firma correcta que espera el bootloader, la crate `bootloader` proporciona un macro [`entry_point`] que proporciona una forma verificada por tipo de definir una función de Rust como punto de entrada. Vamos a reescribir nuestra función de punto de entrada para usar este macro:
[`entry_point`]: https://docs.rs/bootloader/0.6.4/bootloader/macro.entry_point.html
```rust
// en src/main.rs
use bootloader::{BootInfo, entry_point};
entry_point!(kernel_main);
fn kernel_main(boot_info: &'static BootInfo) -> ! {
[]
}
```
Ya no necesitamos usar `extern "C"` ni `no_mangle` para nuestro punto de entrada, ya que el macro define el verdadero punto de entrada inferior `_start` por nosotros. La función `kernel_main` es ahora una función de Rust completamente normal, así que podemos elegir un nombre arbitrario para ella. Lo importante es que esté verificada por tipo, así que se producirá un error de compilación cuando usemos una firma de función incorrecta, por ejemplo, al agregar un argumento o cambiar el tipo de argumento.
Realizaremos el mismo cambio en nuestro `lib.rs`:
```rust
// en src/lib.rs
#[cfg(test)]
use bootloader::{entry_point, BootInfo};
#[cfg(test)]
entry_point!(test_kernel_main);
/// Punto de entrada para `cargo test`
#[cfg(test)]
fn test_kernel_main(_boot_info: &'static BootInfo) -> ! {
// como antes
init();
test_main();
hlt_loop();
}
```
Dado que el punto de entrada solo se usa en modo de prueba, agregamos el atributo `#[cfg(test)]` a todos los elementos. Le damos a nuestro punto de entrada de prueba el nombre distintivo `test_kernel_main` para evitar confusión con el `kernel_main` de nuestro `main.rs`. No usamos el parámetro `BootInfo` por ahora, así que anteponemos un `_` al nombre del parámetro para silenciar la advertencia de variable no utilizada.
## Implementación
Ahora que tenemos acceso a la memoria física, finalmente podemos comenzar a implementar nuestro código de tablas de páginas. Primero, echaremos un vistazo a las tablas de páginas actualmente activas en las que se ejecuta nuestro núcleo. En el segundo paso, crearemos una función de traducción que devuelve la dirección física que se mapea a una dada dirección virtual. Como último paso, intentaremos modificar las tablas de páginas para crear un nuevo mapeo.
Antes de comenzar, creamos un nuevo módulo `memory` para nuestro código:
```rust
// en src/lib.rs
pub mod memory;
```
Para el módulo, creamos un archivo vacío `src/memory.rs`.
### Accediendo a las Tablas de Páginas
Al [final del artículo anterior], intentamos echar un vistazo a las tablas de páginas en las que se ejecuta nuestro núcleo, pero fallamos ya que no podíamos acceder al marco físico al que apunta el registro `CR3`. Ahora podemos continuar desde allí creando una función `active_level_4_table` que devuelve una referencia a la tabla de nivel 4 activa:
[end of the previous post]: @/edition-2/posts/08-paging-introduction/index.md#accessing-the-page-tables
```rust
// en src/memory.rs
use x86_64::{
structures::paging::PageTable,
VirtAddr,
};
/// Devuelve una referencia mutable a la tabla de nivel 4 activa.
///
/// Esta función es insegura porque el llamador debe garantizar que la
/// memoria física completa esté mapeada en memoria virtual en el pasado
/// `physical_memory_offset`. Además, esta función solo debe ser llamada una vez
/// para evitar aliasing de referencias `&mut` (lo que es comportamiento indefinido).
pub unsafe fn active_level_4_table(physical_memory_offset: VirtAddr)
-> &'static mut PageTable
{
use x86_64::registers::control::Cr3;
let (level_4_table_frame, _) = Cr3::read();
let phys = level_4_table_frame.start_address();
let virt = physical_memory_offset + phys.as_u64();
let page_table_ptr: *mut PageTable = virt.as_mut_ptr();
&mut *page_table_ptr // inseguro
}
```
Primero, leemos el marco físico de la tabla de nivel 4 activa desde el registro `CR3`. Luego tomamos su dirección de inicio física, la convertimos a un `u64`, y le agregamos el `physical_memory_offset` para obtener la dirección virtual donde se mapea la tabla de páginas. Finalmente, convertimos la dirección virtual a un puntero crudo `*mut PageTable` a través del método `as_mut_ptr` y luego creamos de manera insegura una referencia `&mut PageTable` a partir de ello. Creamos una referencia `&mut` en lugar de una `&` porque más adelante mutaremos las tablas de páginas en esta publicación.
No necesitamos usar un bloque inseguro aquí porque Rust trata el cuerpo completo de una `unsafe fn` como un gran bloque inseguro. Esto hace que nuestro código sea más peligroso ya que podríamos accidentalmente introducir una operación insegura en líneas anteriores sin darnos cuenta. También dificulta mucho más encontrar operaciones inseguras entre operaciones seguras. Hay un [RFC](https://github.com/rust-lang/rfcs/pull/2585) para cambiar este comportamiento.
Ahora podemos usar esta función para imprimir las entradas de la tabla de nivel 4:
```rust
// en src/main.rs
fn kernel_main(boot_info: &'static BootInfo) -> ! {
use blog_os::memory::active_level_4_table;
use x86_64::VirtAddr;
println!("¡Hola Mundo{}", "!");
blog_os::init();
let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
let l4_table = unsafe { active_level_4_table(phys_mem_offset) };
for (i, entry) in l4_table.iter().enumerate() {
if !entry.is_unused() {
println!("Entrada L4 {}: {:?}", i, entry);
}
}
// como antes
#[cfg(test)]
test_main();
println!("¡No se estrelló!");
blog_os::hlt_loop();
}
```
Primero, convertimos el `physical_memory_offset` de la struct `BootInfo` a un [`VirtAddr`] y lo pasamos a la función `active_level_4_table`. Luego, usamos la función `iter` para iterar sobre las entradas de las tablas de páginas y el combinador [`enumerate`] para agregar un índice `i` a cada elemento. Solo imprimimos entradas no vacías porque todas las 512 entradas no cabrían en la pantalla.
[`VirtAddr`]: https://docs.rs/x86_64/0.14.2/x86_64/addr/struct.VirtAddr.html
[`enumerate`]: https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate
Cuando lo ejecutamos, vemos el siguiente resultado:
![QEMU imprime la entrada 0 (0x2000, PRESENTE, ESCRIBIBLE, ACCEDIDO), la entrada 1 (0x894000, PRESENTE, ESCRIBIBLE, ACCEDIDO, SUCIO), la entrada 31 (0x88e000, PRESENTE, ESCRIBIBLE, ACCEDIDO, SUCIO), la entrada 175 (0x891000, PRESENTE, ESCRIBIBLE, ACCEDIDO, SUCIO), y la entrada 504 (0x897000, PRESENTE, ESCRIBIBLE, ACCEDIDO, SUCIO)](qemu-print-level-4-table.png)
Vemos que hay varias entradas no vacías, que todas mapean a diferentes tablas de nivel 3. Hay tantas regiones porque el código del núcleo, la pila del núcleo, el mapeo de memoria física y la información de arranque utilizan áreas de memoria separadas.
Para atravesar las tablas de páginas más a fondo y echar un vistazo a una tabla de nivel 3, podemos tomar el marco mapeado de una entrada y convertirlo a una dirección virtual nuevamente:
```rust
// en el bucle `for` en src/main.rs
use x86_64::structures::paging::PageTable;
if !entry.is_unused() {
println!("Entrada L4 {}: {:?}", i, entry);
// obtener la dirección física de la entrada y convertirla
let phys = entry.frame().unwrap().start_address();
let virt = phys.as_u64() + boot_info.physical_memory_offset;
let ptr = VirtAddr::new(virt).as_mut_ptr();
let l3_table: &PageTable = unsafe { &*ptr };
// imprimir las entradas no vacías de la tabla de nivel 3
for (i, entry) in l3_table.iter().enumerate() {
if !entry.is_unused() {
println!(" Entrada L3 {}: {:?}", i, entry);
}
}
}
```
Para observar las tablas de nivel 2 y nivel 1, repetimos ese proceso para las entradas de nivel 3 y nivel 2. Como puedes imaginar, esto se vuelve muy verboso muy rápido, así que no mostramos el código completo aquí.
Recorrer manualmente las tablas de páginas es interesante porque ayuda a entender cómo la CPU realiza la traducción. Sin embargo, la mayoría de las veces, solo nos interesa la dirección física mapeada para una dirección virtual dada, así que vamos a crear una función para eso.
### Traduciendo Direcciones
Para traducir una dirección virtual a una dirección física, tenemos que recorrer la tabla de páginas de 4 niveles hasta llegar al marco mapeado. Vamos a crear una función que realice esta traducción:
```rust
// en src/memory.rs
use x86_64::PhysAddr;
/// Traduce la dirección virtual dada a la dirección física mapeada, o
/// `None` si la dirección no está mapeada.
///
/// Esta función es insegura porque el llamador debe garantizar que la
/// memoria física completa esté mapeada en memoria virtual en el pasado
/// `physical_memory_offset`.
pub unsafe fn translate_addr(addr: VirtAddr, physical_memory_offset: VirtAddr)
-> Option<PhysAddr>
{
translate_addr_inner(addr, physical_memory_offset)
}
```
Redirigimos la función a una función segura `translate_addr_inner` para limitar el alcance de `unsafe`. Como notamos anteriormente, Rust trata el cuerpo completo de una `unsafe fn` como un gran bloque inseguro. Al llamar a una función privada segura, hacemos explícitas cada una de las operaciones `unsafe` nuevamente.
La función privada interna contiene la implementación real:
```rust
// en src/memory.rs
/// Función privada que es llamada por `translate_addr`.
///
/// Esta función es segura para limitar el alcance de `unsafe` porque Rust trata
/// el cuerpo completo de las funciones inseguras como un bloque inseguro. Esta función debe
/// solo ser alcanzable a través de `unsafe fn` desde fuera de este módulo.
fn translate_addr_inner(addr: VirtAddr, physical_memory_offset: VirtAddr)
-> Option<PhysAddr>
{
use x86_64::structures::paging::page_table::FrameError;
use x86_64::registers::control::Cr3;
// leer el marco de nivel 4 activo desde el registro CR3
let (level_4_table_frame, _) = Cr3::read();
let table_indexes = [
addr.p4_index(), addr.p3_index(), addr.p2_index(), addr.p1_index()
];
let mut frame = level_4_table_frame;
// recorrer la tabla de páginas de múltiples niveles
for &index in &table_indexes {
// convertir el marco en una referencia a la tabla de páginas
let virt = physical_memory_offset + frame.start_address().as_u64();
let table_ptr: *const PageTable = virt.as_ptr();
let table = unsafe {&*table_ptr};
// leer la entrada de la tabla de páginas y actualizar `frame`
let entry = &table[index];
frame = match entry.frame() {
Ok(frame) => frame,
Err(FrameError::FrameNotPresent) => return None,
Err(FrameError::HugeFrame) => panic!("páginas grandes no soportadas"),
};
}
// calcular la dirección física sumando el desplazamiento de página
Some(frame.start_address() + u64::from(addr.page_offset()))
}
```
En lugar de reutilizar nuestra función `active_level_4_table`, leemos nuevamente el marco de nivel 4 desde el registro `CR3`. Hacemos esto porque simplifica esta implementación prototipo. No te preocupes, crearemos una mejor solución en un momento.
La struct `VirtAddr` ya proporciona métodos para calcular los índices en las tablas de páginas de los cuatro niveles. Almacenamos estos índices en un pequeño arreglo porque nos permite recorrer las tablas de páginas usando un bucle `for`. Fuera del bucle, recordamos el último `frame` visitado para calcular la dirección física más tarde. El `frame` apunta a marcos de tablas de páginas mientras iteramos y al marco mapeado después de la última iteración, es decir, después de seguir la entrada de nivel 1.
Dentro del bucle, nuevamente usamos el `physical_memory_offset` para convertir el marco en una referencia de tabla de páginas. Luego leemos la entrada de la tabla de páginas actual y usamos la función [`PageTableEntry::frame`] para recuperar el marco mapeado. Si la entrada no está mapeada a un marco, regresamos `None`. Si la entrada mapea una página enorme de 2&nbsp;MiB o 1&nbsp;GiB, hacemos panic por ahora.
[`PageTableEntry::frame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page_table/struct.PageTableEntry.html#method.frame
Probemos nuestra función de traducción traduciendo algunas direcciones:
```rust
// en src/main.rs
fn kernel_main(boot_info: &'static BootInfo) -> ! {
// nuevo import
use blog_os::memory::translate_addr;
[] // hola mundo y blog_os::init
let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
let addresses = [
// la página del búfer de vga mapeada de identidad
0xb8000,
// alguna página de código
0x201008,
// alguna página de pila
0x0100_0020_1a10,
// dirección virtual mapeada a la dirección física 0
boot_info.physical_memory_offset,
];
for &address in &addresses {
let virt = VirtAddr::new(address);
let phys = unsafe { translate_addr(virt, phys_mem_offset) };
println!("{:?} -> {:?}", virt, phys);
}
[] // test_main(), impresión de "no se estrelló" y hlt_loop()
}
```
Cuando lo ejecutamos, vemos el siguiente resultado:
![0xb8000 -> 0xb8000, 0x201008 -> 0x401008, 0x10000201a10 -> 0x279a10, "panicked at 'huge pages not supported'](qemu-translate-addr.png)
Como se esperaba, la dirección mapeada de identidad `0xb8000` se traduce a la misma dirección física. Las páginas de código y de pila se traducen a algunas direcciones físicas arbitrarias, que dependen de cómo el bootloader creó el mapeo inicial para nuestro núcleo. Vale la pena notar que los últimos 12 bits siempre permanecen iguales después de la traducción, lo que tiene sentido porque estos bits son el [_desplazamiento de página_] y no forman parte de la traducción.
[_desplazamiento de página_]: @/edition-2/posts/08-paging-introduction/index.md#paging-on-x86-64
Dado que cada dirección física se puede acceder agregando el `physical_memory_offset`, la traducción de la dirección `physical_memory_offset` en sí misma debería apuntar a la dirección física `0`. Sin embargo, la traducción falla porque el mapeo usa páginas grandes por eficiencia, lo que no se admite en nuestra implementación todavía.
### Usando `OffsetPageTable`
Traducir direcciones virtuales a físicas es una tarea común en un núcleo de sistema operativo, por lo tanto, la crate `x86_64` proporciona una abstracción para ello. La implementación ya admite páginas grandes y varias otras funciones de tabla de páginas aparte de `translate_addr`, así que las utilizaremos en lo siguiente en lugar de agregar soporte para páginas grandes a nuestra propia implementación.
En la base de la abstracción hay dos rasgos que definen varias funciones de mapeo de tablas de páginas:
- El rasgo [`Mapper`] es genérico sobre el tamaño de la página y proporciona funciones que operan sobre páginas. Ejemplos son [`translate_page`], que traduce una página dada a un marco del mismo tamaño, y [`map_to`], que crea un nuevo mapeo en la tabla de páginas.
- El rasgo [`Translate`] proporciona funciones que trabajan con múltiples tamaños de páginas, como [`translate_addr`] o el general [`translate`].
[`Mapper`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Mapper.html
[`translate_page`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Mapper.html#tymethod.translate_page
[`map_to`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Mapper.html#method.map_to
[`Translate`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Translate.html
[`translate_addr`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Translate.html#method.translate_addr
[`translate`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Translate.html#tymethod.translate
Los rasgos solo definen la interfaz, no proporcionan ninguna implementación. La crate `x86_64` actualmente proporciona tres tipos que implementan los rasgos con diferentes requisitos. El tipo [`OffsetPageTable`] asume que toda la memoria física está mapeada en el espacio de direcciones virtuales en un desplazamiento dado. El [`MappedPageTable`] es un poco más flexible: solo requiere que cada marco de tabla de páginas esté mapeado al espacio de direcciones virtuales en una dirección calculable. Finalmente, el tipo [`RecursivePageTable`] se puede usar para acceder a los marcos de tablas de páginas a través de [tablas de páginas recursivas](#tablas-de-paginas-recursivas).
[`OffsetPageTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.OffsetPageTable.html
[`MappedPageTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.MappedPageTable.html
[`RecursivePageTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.RecursivePageTable.html
En nuestro caso, el bootloader mapea toda la memoria física a una dirección virtual especificada por la variable `physical_memory_offset`, así que podemos usar el tipo `OffsetPageTable`. Para inicializarlo, creamos una nueva función `init` en nuestro módulo `memory`:
```rust
use x86_64::structures::paging::OffsetPageTable;
/// Inicializa una nueva OffsetPageTable.
///
/// Esta función es insegura porque el llamador debe garantizar que la
/// memoria física completa esté mapeada en memoria virtual en el pasado
/// `physical_memory_offset`. Además, esta función debe ser solo llamada una vez
/// para evitar aliasing de referencias `&mut` (lo que es comportamiento indefinido).
pub unsafe fn init(physical_memory_offset: VirtAddr) -> OffsetPageTable<'static> {
let level_4_table = active_level_4_table(physical_memory_offset);
OffsetPageTable::new(level_4_table, physical_memory_offset)
}
// hacer privada
unsafe fn active_level_4_table(physical_memory_offset: VirtAddr)
-> &'static mut PageTable
{}
```
La función toma el `physical_memory_offset` como argumento y devuelve una nueva instancia de `OffsetPageTable`. Con un `'static` de duración. Esto significa que la instancia permanece válida durante todo el tiempo de ejecución de nuestro núcleo. En el cuerpo de la función, primero llamamos a la función `active_level_4_table` para recuperar una referencia mutable a la tabla de nivel 4 de la tabla de páginas. Luego invocamos la función [`OffsetPageTable::new`] con esta referencia. Como segundo parámetro, la función `new` espera la dirección virtual donde comienza el mapeo de memoria física, que está dada en la variable `physical_memory_offset`.
[`OffsetPageTable::new`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.OffsetPageTable.html#method.new
La función `active_level_4_table` solo debe ser llamada desde la función `init` de ahora en adelante porque podría llevar fácilmente a referencias mutuas aliased si se llama múltiples veces, lo que podría causar comportamiento indefinido. Por esta razón, hacemos que la función sea privada al eliminar el especificador `pub`.
Ahora podemos usar el método `Translate::translate_addr` en lugar de nuestra propia función `memory::translate_addr`. Solo necesitamos cambiar algunas líneas en nuestro `kernel_main`:
```rust
// en src/main.rs
fn kernel_main(boot_info: &'static BootInfo) -> ! {
// nuevo: diferentes imports
use blog_os::memory;
use x86_64::{structures::paging::Translate, VirtAddr};
[] // hola mundo y blog_os::init
let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
// nuevo: inicializar un mapper
let mapper = unsafe { memory::init(phys_mem_offset) };
let addresses = []; // igual que antes
for &address in &addresses {
let virt = VirtAddr::new(address);
// nuevo: usar el método `mapper.translate_addr`
let phys = mapper.translate_addr(virt);
println!("{:?} -> {:?}", virt, phys);
}
[] // test_main(), impresión de "no se estrelló" y hlt_loop()
}
```
Necesitamos importar el rasgo `Translate` para poder usar el método [`translate_addr`] que proporciona.
Cuando ejecutamos ahora, vemos los mismos resultados de traducción que antes, con la diferencia de que la traducción de páginas grandes ahora también funciona:
![0xb8000 -> 0xb8000, 0x201008 -> 0x401008, 0x10000201a10 -> 0x279a10, 0x18000000000 -> 0x0](qemu-mapper-translate-addr.png)
Como se esperaba, las traducciones de `0xb8000` y las direcciones de código y pila permanecen igual que con nuestra propia función de traducción. Adicionalmente, ahora vemos que la dirección virtual `physical_memory_offset` está mapeada a la dirección física `0x0`.
Al utilizar la función de traducción del tipo `MappedPageTable`, podemos ahorrar el trabajo de implementar soporte para páginas grandes. También tenemos acceso a otras funciones de tablas, como `map_to`, que utilizaremos en la siguiente sección.
En este punto, ya no necesitamos nuestras funciones `memory::translate_addr` y `memory::translate_addr_inner`, así que podemos eliminarlas.
### Creando un Nuevo Mapeo
Hasta ahora, solo vimos las tablas de páginas sin modificar nada. Cambiemos eso creando un nuevo mapeo para una página previamente no mapeada.
Usaremos la función [`map_to`] del rasgo [`Mapper`] para nuestra implementación, así que echemos un vistazo a esa función primero. La documentación nos dice que toma cuatro argumentos: la página que queremos mapear, el marco al que la página debe ser mapeada, un conjunto de banderas para la entrada de la tabla de páginas y un `frame_allocator`. El `frame_allocator` es necesario porque mapear la página dada podría requerir crear tablas de páginas adicionales, que necesitan marcos no utilizados como almacenamiento de respaldo.
[`map_to`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/trait.Mapper.html#tymethod.map_to
[`Mapper`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/trait.Mapper.html
#### Una Función `create_example_mapping`
El primer paso de nuestra implementación es crear una nueva función `create_example_mapping` que mapee una página virtual dada a `0xb8000`, el marco físico del búfer de texto VGA. Elegimos ese marco porque nos permite probar fácilmente si el mapeo se creó correctamente: solo necesitamos escribir en la página recién mapeada y ver si el escrito aparece en la pantalla.
La función `create_example_mapping` se ve así:
```rust
// en src/memory.rs
use x86_64::{
PhysAddr,
structures::paging::{Page, PhysFrame, Mapper, Size4KiB, FrameAllocator}
};
/// Crea un mapeo de ejemplo para la página dada al marco `0xb8000`.
pub fn create_example_mapping(
page: Page,
mapper: &mut OffsetPageTable,
frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) {
use x86_64::structures::paging::PageTableFlags as Flags;
let frame = PhysFrame::containing_address(PhysAddr::new(0xb8000));
let flags = Flags::PRESENT | Flags::WRITABLE;
let map_to_result = unsafe {
// FIXME: esto no es seguro, lo hacemos solo para pruebas
mapper.map_to(page, frame, flags, frame_allocator)
};
map_to_result.expect("map_to falló").flush();
}
```
Además de la `page` que debe ser mapeada, la función espera una referencia mutable a una instancia de `OffsetPageTable` y un `frame_allocator`. El parámetro `frame_allocator` utiliza la sintaxis [`impl Trait`][impl-trait-arg] para ser [genérico] sobre todos los tipos que implementan el rasgo [`FrameAllocator`]. El rasgo es genérico sobre el rasgo [`PageSize`] para trabajar con páginas estándar de 4&nbsp;KiB y grandes de 2&nbsp;MiB/1&nbsp;GiB. Solo queremos crear un mapeo de 4&nbsp;KiB, así que establecemos el parámetro genérico en `Size4KiB`.
[impl-trait-arg]: https://doc.rust-lang.org/book/ch10-02-traits.html#traits-as-parameters
[genérico]: https://doc.rust-lang.org/book/ch10-00-generics.html
[`FrameAllocator`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/trait.FrameAllocator.html
[`PageSize`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page/trait.PageSize.html
El método [`map_to`] es inseguro porque el llamador debe garantizar que el marco no esté ya en uso. La razón de esto es que mapear el mismo marco dos veces podría resultar en un comportamiento indefinido, por ejemplo, cuando dos referencias diferentes `&mut` apuntan a la misma ubicación de memoria física. En nuestro caso, reutilizamos el marco del búfer de texto VGA, que ya está mapeado, por lo que rompemos la condición requerida. Sin embargo, la función `create_example_mapping` es solo una función de prueba temporal y se eliminará después de esta publicación, así que está bien. Para recordarnos sobre la inseguridad, ponemos un comentario `FIXME` en la línea.
Además de la `page` y el `unused_frame`, el método `map_to` toma un conjunto de banderas para el mapeo y una referencia al `frame_allocator`, que se explicará en un momento. Para las banderas, configuramos la bandera `PRESENTE` porque se requiere para todas las entradas válidas y la bandera `ESCRIBIBLE` para hacer la página mapeada escribible. Para una lista de todas las posibles banderas, consulta la sección [_Formato de Tabla de Páginas_] del artículo anterior.
[_Formato de Tabla de Páginas_]: @/edition-2/posts/08-paging-introduction/index.md#page-table-format
La función [`map_to`] puede fallar, así que devuelve un [`Result`]. Dado que este es solo un código de ejemplo que no necesita ser robusto, solo usamos [`expect`] para hacer panic cuando ocurre un error. Con éxito, la función devuelve un tipo [`MapperFlush`] que proporciona una forma fácil de limpiar la página recién mapeada del buffer de traducción (TLB) con su método [`flush`]. Al igual que `Result`, el tipo utiliza el atributo [`#[must_use]`][must_use] para emitir una advertencia cuando accidentalmente olvidamos usarlo.
[`Result`]: https://doc.rust-lang.org/core/result/enum.Result.html
[`expect`]: https://doc.rust-lang.org/core/result/enum.Result.html#method.expect
[`MapperFlush`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.MapperFlush.html
[`flush`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.MapperFlush.html#method.flush
[must_use]: https://doc.rust-lang.org/std/result/#results-must-be-used
#### Un `FrameAllocator` Dummy
Para poder llamar a `create_example_mapping`, necesitamos crear un tipo que implemente el rasgo `FrameAllocator` primero. Como se mencionó anteriormente, el rasgo es responsable de asignar marcos para nuevas tablas de páginas si son necesarios por `map_to`.
Comencemos con el caso simple y supongamos que no necesitamos crear nuevas tablas de páginas. Para este caso, un asignador de marcos que siempre devuelve `None` es suficiente. Creamos un `EmptyFrameAllocator` para probar nuestra función de mapeo:
```rust
// en src/memory.rs
/// Un FrameAllocator que siempre devuelve `None`.
pub struct EmptyFrameAllocator;
unsafe impl FrameAllocator<Size4KiB> for EmptyFrameAllocator {
fn allocate_frame(&mut self) -> Option<PhysFrame> {
None
}
}
```
Implementar el `FrameAllocator` es inseguro porque el implementador debe garantizar que el asignador produzca solo marcos no utilizados. De lo contrario, podría ocurrir un comportamiento indefinido, por ejemplo, cuando dos páginas virtuales se mapeen al mismo marco físico. Nuestro `EmptyFrameAllocator` solo devuelve `None`, por lo que esto no es un problema en este caso.
#### Elegir una Página Virtual
Ahora tenemos un asignador de marcos simple que podemos pasar a nuestra función `create_example_mapping`. Sin embargo, el asignador siempre devuelve `None`, por lo que esto solo funcionará si no se necesitan tablas de páginas adicionales. Para entender cuándo se necesitan marcos adicionales para crear el mapeo y cuándo no, consideremos un ejemplo:
![Un espacio de direcciones virtual y física con una sola página mapeada y las tablas de páginas de todos los cuatro niveles](required-page-frames-example.svg)
El gráfico muestra el espacio de direcciones virtual a la izquierda, el espacio de direcciones físicas a la derecha, y las tablas de páginas en el medio. Las tablas de páginas se almacenan en marcos de memoria física, indicados por las líneas punteadas. El espacio de direcciones virtual contiene una única página mapeada en `0x803fe00000`, marcada en azul. Para traducir esta página a su marco, la CPU recorre la tabla de páginas de 4 niveles hasta llegar al marco en la dirección de 36&nbsp;KiB.
Adicionalmente, el gráfico muestra el marco físico del búfer de texto VGA en rojo. Nuestro objetivo es mapear una página virtual previamente no mapeada a este marco utilizando nuestra función `create_example_mapping`. Dado que `EmptyFrameAllocator` siempre devuelve `None`, queremos crear el mapeo de modo que no se necesiten marcos adicionales del asignador.
Esto depende de la página virtual que seleccionemos para el mapeo.
El gráfico muestra dos páginas candidatas en el espacio de direcciones virtuales, ambas marcadas en amarillo. Una página está en `0x803fdfd000`, que está 3 páginas antes de la página mapeada (en azul). Si bien los índices de la tabla de nivel 4 y la tabla de nivel 3 son los mismos que para la página azul, los índices de las tablas de nivel 2 y nivel 1 son diferentes (ver el [artículo anterior][page-table-indices]). El índice diferente en la tabla de nivel 2 significa que se usa una tabla de nivel 1 diferente para esta página. Dado que esta tabla de nivel 1 no existe aún, tendríamos que crearla si elegimos esa página para nuestro mapeo de ejemplo, lo que requeriría un marco físico no utilizado adicional. En contraste, la segunda página candidata en `0x803fe02000` no tiene este problema porque utiliza la misma tabla de nivel 1 que la página azul. Por lo tanto, ya existen todas las tablas de páginas requeridas.
[page-table-indices]: @/edition-2/posts/08-paging-introduction/index.md#paging-on-x86-64
En resumen, la dificultad de crear un nuevo mapeo depende de la página virtual que queremos mapear. En el caso más fácil, la tabla de nivel 1 para la página ya existe y solo necesitamos escribir una única entrada. En el caso más difícil, la página está en una región de memoria para la cual aún no existe ninguna tabla de nivel 3, por lo que necesitamos crear nuevas tablas de nivel 3, nivel 2 y nivel 1 primero.
Para llamar a nuestra función `create_example_mapping` con el `EmptyFrameAllocator`, necesitamos elegir una página para la cual ya existan todas las tablas de páginas. Para encontrar tal página, podemos utilizar el hecho de que el bootloader se carga a sí mismo en el primer megabyte del espacio de direcciones virtuales. Esto significa que existe una tabla de nivel 1 válida para todas las páginas en esta región. Por lo tanto, podemos elegir cualquier página no utilizada en esta región de memoria para nuestro mapeo de ejemplo, como la página en la dirección `0`. Normalmente, esta página debería permanecer sin usar para garantizar que desreferenciar un puntero nulo cause una falta de página, por lo que sabemos que el bootloader la deja sin mapear.
#### Creando el Mapeo
Ahora tenemos todos los parámetros necesarios para llamar a nuestra función `create_example_mapping`, así que modificaremos nuestra función `kernel_main` para mapear la página en la dirección virtual `0`. Dado que mapeamos la página al marco del búfer de texto VGA, deberíamos poder escribir en la pantalla a través de ella después. La implementación se ve así:
```rust
// en src/main.rs
fn kernel_main(boot_info: &'static BootInfo) -> ! {
use blog_os::memory;
use x86_64::{structures::paging::Page, VirtAddr}; // nuevo import
[] // hola mundo y blog_os::init
let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
let mut mapper = unsafe { memory::init(phys_mem_offset) };
let mut frame_allocator = memory::EmptyFrameAllocator;
// mapear una página no utilizada
let page = Page::containing_address(VirtAddr::new(0));
memory::create_example_mapping(page, &mut mapper, &mut frame_allocator);
// escribir la cadena `¡Nuevo!` en la pantalla a través del nuevo mapeo
let page_ptr: *mut u64 = page.start_address().as_mut_ptr();
unsafe { page_ptr.offset(400).write_volatile(0x_f021_f077_f065_f04e)};
[] // test_main(), impresión de "no se estrelló" y hlt_loop()
}
```
Primero creamos el mapeo para la página en la dirección `0` al llamar a nuestra función `create_example_mapping` con una referencia mutable a las instancias `mapper` y `frame_allocator`. Esto mapea la página al marco del búfer de texto VGA, por lo que deberíamos ver cualquier escritura en ella en la pantalla.
Luego convertimos la página a un puntero crudo y escribimos un valor en el desplazamiento `400`. No escribimos en el inicio de la página porque la línea superior del búfer VGA se desplaza directamente fuera de la pantalla por el siguiente `println`. Escribimos el valor `0x_f021_f077_f065_f04e`, que representa la cadena _"¡Nuevo!"_ sobre un fondo blanco. Como aprendimos [en el artículo _"Modo de Texto VGA"_], las escrituras en el búfer VGA deben ser volátiles, así que utilizamos el método [`write_volatile`].
[en el artículo _"Modo de Texto VGA"_]: @/edition-2/posts/03-vga-text-buffer/index.md#volatile
[`write_volatile`]: https://doc.rust-lang.org/std/primitive.pointer.html#method.write_volatile
Cuando lo ejecutamos en QEMU, vemos el siguiente resultado:
![QEMU imprime "¡No se estrelló!" con cuatro celdas completamente blancas en el medio de la pantalla](qemu-new-mapping.png)
El _"¡Nuevo!"_ en la pantalla es causado por nuestra escritura en la página `0`, lo que significa que hemos creado con éxito un nuevo mapeo en las tablas de páginas.
Esa creación de mapeo solo funcionó porque la tabla de nivel 1 responsable de la página en la dirección `0` ya existe. Cuando intentamos mapear una página para la cual aún no existe una tabla de nivel 1, la función `map_to` falla porque intenta crear nuevas tablas de páginas asignando marcos con el `EmptyFrameAllocator`. Podemos ver eso pasar cuando intentamos mapear la página `0xdeadbeaf000` en lugar de `0`:
```rust
// en src/main.rs
fn kernel_main(boot_info: &'static BootInfo) -> ! {
[]
let page = Page::containing_address(VirtAddr::new(0xdeadbeaf000));
[]
}
```
Cuando lo ejecutamos, se produce un panic con el siguiente mensaje de error:
```
panic at 'map_to falló: FrameAllocationFailed', /…/result.rs:999:5
```
Para mapear páginas que no tienen una tabla de nivel 1 aún, necesitamos crear un `FrameAllocator` adecuado. Pero, ¿cómo sabemos qué marcos no están en uso y cuánta memoria física está disponible?
### Asignación de Marcos
Para crear nuevas tablas de páginas, necesitamos crear un `frame allocator` adecuado. Para hacer eso, usamos el `memory_map` que se pasa por el bootloader como parte de la struct `BootInfo`:
```rust
// en src/memory.rs
use bootloader::bootinfo::MemoryMap;
/// Un FrameAllocator que devuelve marcos utilizables del mapa de memoria del bootloader.
pub struct BootInfoFrameAllocator {
memory_map: &'static MemoryMap,
next: usize,
}
impl BootInfoFrameAllocator {
/// Crea un FrameAllocator a partir del mapa de memoria pasado.
///
/// Esta función es insegura porque el llamador debe garantizar que el mapa de memoria pasado
/// sea válido. El principal requisito es que todos los marcos que están marcados
/// como `USABLE` en él estén realmente sin usar.
pub unsafe fn init(memory_map: &'static MemoryMap) -> Self {
BootInfoFrameAllocator {
memory_map,
next: 0,
}
}
}
```
La struct tiene dos campos: una referencia `'static` al mapa de memoria que pasa el bootloader y un campo `next` que sigue la numeración del siguiente marco que el asignador debería devolver.
Como explicamos en la sección [_Información de Arranque_](#informacion-de-boot), el mapa de memoria es proporcionado por la firmware BIOS/UEFI. Solo se puede consultar muy al principio en el proceso de arranque, así que el bootloader ya llama a las respectivas funciones por nosotros. El mapa de memoria consiste en una lista de structs [`MemoryRegion`], que contienen la dirección de inicio, la longitud y el tipo (por ejemplo, sin usar, reservado, etc.) de cada región de memoria.
El método `init` inicializa un `BootInfoFrameAllocator` con un mapa de memoria dado. El campo `next` se inicializa a `0` y se incrementará para cada asignación de marco para evitar devolver el mismo marco dos veces. Dado que no sabemos si los marcos utilizables del mapa de memoria ya se usaron en otro lugar, nuestra función `init` debe ser `unsafe` para requerir garantías adicionales del llamador.
[`MemoryRegion`]: https://docs.rs/bootloader/0.6.4/bootloader/bootinfo/struct.MemoryRegion.html
#### Un Método `usable_frames`
Antes de implementar el rasgo `FrameAllocator`, agregamos un método auxiliar que convierte el mapa de memoria en un iterador de marcos utilizables:
```rust
// en src/memory.rs
use bootloader::bootinfo::MemoryRegionType;
impl BootInfoFrameAllocator {
```
Follow these instructions to make the following change to my code document.
Instruction: Reemplazar "artículo" por "publicación" para mantener consistencia en la terminología
Code Edit:
```
{{ ... }}
Esta publicación muestra cómo implementar soporte para paginación en nuestro núcleo. Primero explora diferentes técnicas para hacer accesibles los marcos de la tabla de páginas físicas al núcleo y discute sus respectivas ventajas y desventajas. Luego implementa una función de traducción de direcciones y una función para crear un nuevo mapeo.
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes algún problema o pregunta, abre un problema allí. También puedes dejar comentarios [al final]. El código fuente completo de esta publicación se puede encontrar en la rama [`post-09`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-09
<!-- toc -->
## Introducción
La [publicación anterior] dio una introducción al concepto de paginación. Motivó la paginación comparándola con la segmentación, explicó cómo funcionan la paginación y las tablas de páginas, y luego introdujo el diseño de tabla de páginas de 4 niveles de `x86_64`.
{{ ... }}
```
Follow these instructions to make the following change to my code document.
Instruction: Reemplazar las instancias restantes de "artículo" por "publicación"
Code Edit:
```
{{ ... }}
La [publicación anterior] dio una introducción al concepto de paginación. Motivó la paginación comparándola con la segmentación, explicó cómo funcionan la paginación y las tablas de páginas, y luego introdujo el diseño de tabla de páginas de 4 niveles de `x86_64`. Descubrimos que el bootloader (cargador de arranque) ya configuró una jerarquía de tablas de páginas para nuestro núcleo, lo que significa que nuestro núcleo ya se ejecuta en direcciones virtuales. Esto mejora la seguridad, ya que los accesos ilegales a la memoria causan excepciones de falta de página en lugar de modificar la memoria física arbitraria.
[publicación anterior]: @/edition-2/posts/08-paging-introduction/index.md
La publicación terminó con el problema de que [no podemos acceder a las tablas de páginas desde nuestro núcleo][end of previous post] porque se almacenan en la memoria física y nuestro núcleo ya se ejecuta en direcciones virtuales. Esta publicación explora diferentes enfoques para hacer los marcos de la tabla de páginas accesibles a nuestro núcleo. Discutiremos las ventajas y desventajas de cada enfoque y luego decidiremos un enfoque para nuestro núcleo.
{{ ... }}

View File

@@ -0,0 +1,762 @@
+++
title = "Asignación en el Heap"
weight = 10
path = "heap-allocation"
date = 2019-06-26
[extra]
chapter = "Gestión de Memoria"
+++
Este post añade soporte para la asignación en el heap a nuestro núcleo. Primero, proporciona una introducción a la memoria dinámica y muestra cómo el borrow checker (verificador de préstamos) previene errores comunes de asignación. Luego, implementa la interfaz básica de asignación de Rust, crea una región de memoria en el heap y configura una crate de asignador. Al final de este post, todos los tipos de asignación y recolección de la crate `alloc` integrada estarán disponibles para nuestro núcleo.
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes algún problema o preguntas, por favor abre un issue allí. También puedes dejar comentarios [al final]. El código fuente completo de este post se puede encontrar en la rama [`post-10`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-10
<!-- toc -->
## Variables Locales y Estáticas
Actualmente usamos dos tipos de variables en nuestro núcleo: variables locales y variables `static`. Las variables locales se almacenan en el [call stack] y son válidas solo hasta que la función envolvente retorna. Las variables estáticas se almacenan en una ubicación de memoria fija y viven siempre durante toda la duración del programa.
### Variables Locales
Las variables locales se almacenan en el [call stack], que es una [estructura de datos tipo pila] que soporta operaciones de `push` y `pop`. En cada entrada de función, los parámetros, la dirección de retorno y las variables locales de la función llamada son empujadas por el compilador:
[call stack]: https://en.wikipedia.org/wiki/Call_stack
[estructura de datos tipo pila]: https://en.wikipedia.org/wiki/Stack_(abstract_data_type)
![Una función `outer()` y una función `inner(i: usize)`, donde `outer` llama a `inner(1)`. Ambas tienen algunas variables locales. La pila de llamadas contiene los siguientes espacios: las variables locales de outer, luego el argumento `i = 1`, luego la dirección de retorno, luego las variables locales de inner.](call-stack.svg)
El ejemplo anterior muestra la pila de llamadas después de que la función `outer` llamó a la función `inner`. Vemos que la pila de llamadas contiene primero las variables locales de `outer`. En la llamada a `inner`, el parámetro `1` y la dirección de retorno para la función fueron empujados. Luego, se transfirió el control a `inner`, que empujó sus variables locales.
Después de que la función `inner` retorna, su parte de la pila de llamadas se desapila nuevamente y solo permanecen las variables locales de `outer`:
![La pila de llamadas contiene solo las variables locales de `outer`](call-stack-return.svg)
Vemos que las variables locales de `inner` solo viven hasta que la función retorna. El compilador de Rust refuerza estas duraciones y lanza un error cuando usamos un valor durante demasiado tiempo, por ejemplo, cuando intentamos devolver una referencia a una variable local:
```rust
fn inner(i: usize) -> &'static u32 {
let z = [1, 2, 3];
&z[i]
}
```
([ejecutar el ejemplo en el playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=6186a0f3a54f468e1de8894996d12819))
Si bien devolver una referencia no tiene sentido en este ejemplo, hay casos en los que queremos que una variable viva más que la función. Ya hemos visto tal caso en nuestro núcleo cuando intentamos [cargar una tabla de descriptores de interrupción] y tuvimos que usar una variable `static` para extender la duración.
[cargar una tabla de descriptores de interrupción]: @/edition-2/posts/05-cpu-exceptions/index.md#loading-the-idt
### Variables Estáticas
Las variables estáticas se almacenan en una ubicación de memoria fija separada de la pila. Esta ubicación de memoria se asigna en tiempo de compilación por el enlazador y se codifica en el ejecutable. Las variables estáticas viven durante toda la ejecución del programa, por lo que tienen la duración `'static` y siempre pueden ser referenciadas desde variables locales:
![El mismo ejemplo de outer/inner, excepto que inner tiene un `static Z: [u32; 3] = [1,2,3];` y devuelve una referencia `&Z[i]`](call-stack-static.svg)
Cuando la función `inner` retorna en el ejemplo anterior, su parte de la pila de llamadas se destruye. Las variables estáticas viven en un rango de memoria separado que nunca se destruye, por lo que la referencia `&Z[1]` sigue siendo válida después del retorno.
Aparte de la duración `'static`, las variables estáticas también tienen la propiedad útil de que su ubicación es conocida en tiempo de compilación, de modo que no se necesita ninguna referencia para acceder a ellas. Utilizamos esa propiedad para nuestra macro `println`: Al usar un [static `Writer`] internamente, no se necesita una referencia `&mut Writer` para invocar la macro, lo que es muy útil en [manejadores de excepciones], donde no tenemos acceso a variables adicionales.
[static `Writer`]: @/edition-2/posts/03-vga-text-buffer/index.md#a-global-interface
[manejadores de excepciones]: @/edition-2/posts/05-cpu-exceptions/index.md#implementation
Sin embargo, esta propiedad de las variables estáticas trae un inconveniente crucial: son de solo lectura por defecto. Rust refuerza esto porque ocurriría una [condición de carrera] si, por ejemplo, dos hilos modificaran una variable estática al mismo tiempo. La única forma de modificar una variable estática es encapsularla en un tipo [`Mutex`], que asegura que solo exista una sola referencia `&mut` en cualquier momento. Ya utilizamos un `Mutex` para nuestro [buffer `Writer` estático VGA][vga mutex].
[condición de carrera]: https://doc.rust-lang.org/nomicon/races.html
[`Mutex`]: https://docs.rs/spin/0.5.2/spin/struct.Mutex.html
[vga mutex]: @/edition-2/posts/03-vga-text-buffer/index.md#spinlocks
## Memoria Dinámica
Las variables locales y estáticas ya son muy poderosas juntas y habilitan la mayoría de los casos de uso. Sin embargo, vimos que ambas tienen sus limitaciones:
- Las variables locales solo viven hasta el final de la función o bloque envolvente. Esto se debe a que viven en la pila de llamadas y se destruyen después de que la función envolvente retorna.
- Las variables estáticas siempre viven durante toda la ejecución del programa, por lo que no hay forma de recuperar y reutilizar su memoria cuando ya no se necesitan. Además, tienen semánticas de propiedad poco claras y son accesibles desde todas las funciones, por lo que necesitan ser protegidas por un [`Mutex`] cuando queremos modificarlas.
Otra limitación de las variables locales y estáticas es que tienen un tamaño fijo. Por lo que no pueden almacenar una colección que crezca dinámicamente a medida que se añaden más elementos. (Hay propuestas para [valores rvalue sin tamaño] en Rust que permitirían variables locales con tamaño dinámico, pero solo funcionan en algunos casos específicos.)
[valores rvalue sin tamaño]: https://github.com/rust-lang/rust/issues/48055
Para eludir estas desventajas, los lenguajes de programación suelen soportar una tercera región de memoria para almacenar variables llamada **heap**. El heap soporta _asignación de memoria dinámica_ en tiempo de ejecución a través de dos funciones llamadas `allocate` y `deallocate`. Funciona de la siguiente manera: La función `allocate` devuelve un fragmento de memoria libre del tamaño especificado que se puede usar para almacenar una variable. Esta variable vive hasta que se libera llamando a la función `deallocate` con una referencia a la variable.
Pasemos por un ejemplo:
![La función inner llama `allocate(size_of([u32; 3]))`, escribe `z.write([1,2,3]);` y devuelve `(z as *mut u32).offset(i)`. En el valor devuelto `y`, la función outer realiza `deallocate(y, size_of(u32))`.](call-stack-heap.svg)
Aquí la función `inner` utiliza memoria del heap en lugar de variables estáticas para almacenar `z`. Primero asigna un bloque de memoria del tamaño requerido, que devuelve un `*mut u32` [puntero bruto]. Luego usa el método [`ptr::write`] para escribir el arreglo `[1,2,3]` en él. En el último paso, utiliza la función [`offset`] para calcular un puntero al elemento `i`-ésimo y luego lo devuelve. (Nota que omitimos algunos casts requeridos y bloques unsafe en esta función de ejemplo por brevedad.)
[puntero bruto]: https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
[`ptr::write`]: https://doc.rust-lang.org/core/ptr/fn.write.html
[`offset`]: https://doc.rust-lang.org/std/primitive.pointer.html#method.offset
La memoria asignada vive hasta que se libera explícitamente mediante una llamada a `deallocate`. Por lo tanto, el puntero devuelto sigue siendo válido incluso después de que `inner` haya retornado y su parte de la pila de llamadas se haya destruido. La ventaja de usar memoria del heap en comparación con memoria estática es que la memoria se puede reutilizar después de que se libera, lo que hacemos a través de la llamada `deallocate` en `outer`. Después de esa llamada, la situación se ve así:
![La pila de llamadas contiene las variables locales de `outer`, el heap contiene `z[0]` y `z[2]`, pero ya no `z[1]`.](call-stack-heap-freed.svg)
Vemos que el espacio de `z[1]` está libre nuevamente y puede ser reutilizado para la siguiente llamada a `allocate`. Sin embargo, también vemos que `z[0]` y `z[2]` nunca se liberan porque nunca los desapilamos. Tal error se llama _fuga de memoria_ y es a menudo la causa del consumo excesivo de memoria de los programas (solo imagina lo que sucede cuando llamamos a `inner` repetidamente en un bucle). Esto puede parecer malo, pero hay tipos de errores mucho más peligrosos que pueden ocurrir con la asignación dinámica.
### Errores Comunes
Apartando las fugas de memoria, que son desafortunadas pero no hacen que el programa sea vulnerable a atacantes, hay dos tipos comunes de errores con consecuencias más severas:
- Cuando accidentalmente continuamos usando una variable después de llamar a `deallocate` sobre ella, tenemos una vulnerabilidad de **uso después de liberar**. Tal error causa comportamiento indefinido y a menudo puede ser explotado por atacantes para ejecutar código arbitrario.
- Cuando accidentalmente liberamos una variable dos veces, tenemos una vulnerabilidad de **double-free**. Esto es problemático porque podría liberar una asignación diferente que se había asignado en el mismo lugar después de la primera llamada a `deallocate`. Así, puede llevar nuevamente a una vulnerabilidad de uso después de liberar.
Estos tipos de vulnerabilidades son bien conocidos, por lo que uno podría esperar que las personas hayan aprendido a evitarlas hasta ahora. Pero no, tales vulnerabilidades todavía se encuentran regularmente, por ejemplo, esta [vulnerabilidad de uso después de liberar en Linux][linux vulnerability] (2019), que permitió la ejecución de código arbitrario. Una búsqueda en la web como `use-after-free linux {año actual}` probablemente siempre arrojará resultados. Esto muestra que incluso los mejores programadores no siempre son capaces de manejar correctamente la memoria dinámica en proyectos complejos.
[vulnerabilidad de linux]: https://securityboulevard.com/2019/02/linux-use-after-free-vulnerability-found-in-linux-2-6-through-4-20-11/
Para evitar estos problemas, muchos lenguajes, como Java o Python, gestionan la memoria dinámica automáticamente utilizando una técnica llamada [_recolección de basura_]. La idea es que el programador nunca invoca `deallocate` manualmente. En cambio, el programa se pausa regularmente y se escanea en busca de variables de heap no utilizadas, que luego se liberan automáticamente. Por lo tanto, las vulnerabilidades mencionadas no pueden ocurrir. Los inconvenientes son el costo de rendimiento de la verificación regular y las largas pausas que probablemente ocurran.
[_recolección de basura_]: https://en.wikipedia.org/wiki/Garbage_collection_(computer_science)
Rust adopta un enfoque diferente al problema: utiliza un concepto llamado [_propiedad_] que puede verificar la corrección de las operaciones de memoria dinámica en tiempo de compilación. Por lo tanto, no se necesita recolección de basura para evitar las vulnerabilidades mencionadas, lo que significa que no hay costos de rendimiento. Otra ventaja de este enfoque es que el programador aún tiene un control fino sobre el uso de la memoria dinámica, al igual que con C o C++.
[_propiedad_]: https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html
### Asignaciones en Rust
En lugar de permitir que el programador llame manualmente a `allocate` y `deallocate`, la biblioteca estándar de Rust proporciona tipos de abstracción que llaman a estas funciones implícitamente. El tipo más importante es [**`Box`**], que es una abstracción para un valor asignado en el heap. Proporciona una función constructora [`Box::new`] que toma un valor, llama a `allocate` con el tamaño del valor y luego mueve el valor al espacio recién asignado en el heap. Para liberar la memoria del heap nuevamente, el tipo `Box` implementa el [`Drop` trait] para llamar a `deallocate` cuando sale del alcance:
[**`Box`**]: https://doc.rust-lang.org/std/boxed/index.html
[`Box::new`]: https://doc.rust-lang.org/alloc/boxed/struct.Box.html#method.new
[`Drop` trait]: https://doc.rust-lang.org/book/ch15-03-drop.html
```rust
{
let z = Box::new([1,2,3]);
[]
} // z sale del alcance y se llama a `deallocate`
```
Este patrón tiene el extraño nombre [_la adquisición de recursos es inicialización_] (o _RAII_ para abreviar). Se originó en C++, donde se utiliza para implementar un tipo de abstracción similar llamado [`std::unique_ptr`].
[_la adquisición de recursos es inicialización_]: https://en.wikipedia.org/wiki/Resource_acquisition_is_initialization
[`std::unique_ptr`]: https://en.cppreference.com/w/cpp/memory/unique_ptr
Tal tipo por sí solo no es suficiente para prevenir todos los errores de uso después de liberar, ya que los programadores aún pueden mantener referencias después de que el `Box` sale del alcance y la correspondencia de memoria del heap se libera:
```rust
let x = {
let z = Box::new([1,2,3]);
&z[1]
}; // z sale del alcance y se llama a `deallocate`
println!("{}", x);
```
Aquí es donde entra la propiedad de Rust. Asigna una [duración] abstracta a cada referencia, que es el ámbito en el que la referencia es válida. En el ejemplo anterior, la referencia `x` se toma del arreglo `z`, por lo que se vuelve inválida después de que `z` sale del alcance. Cuando [ejecutas el ejemplo anterior en el playground][playground-2], verás que el compilador de Rust efectivamente lanza un error:
[duración]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html
[playground-2]: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=28180d8de7b62c6b4a681a7b1f745a48
```
error[E0597]: `z[_]` no vive lo suficiente
--> src/main.rs:4:9
|
2 | let x = {
| - préstamo almacenado más tarde aquí
3 | let z = Box::new([1,2,3]);
4 | &z[1]
| ^^^^^ valor prestado no vive lo suficiente
5 | }; // z sale del alcance y se llama a `deallocate`
| - `z[_]` se destruye aquí mientras aún está prestado
```
La terminología puede ser un poco confusa al principio. Tomar una referencia a un valor se llama _préstamo_ del valor, ya que es similar a un préstamo en la vida real: tienes acceso temporal a un objeto pero debes devolverlo en algún momento, y no debes destruirlo. Al verificar que todos los préstamos terminan antes de que se destruya un objeto, el compilador de Rust puede garantizar que no pueda ocurrir una situación de uso después de liberar.
El sistema de propiedad de Rust va aún más lejos, previniendo no solo errores de uso después de liberar, sino también proporcionando [_seguridad de memoria_], como lenguajes recolectores de basura como Java o Python. Además, garantiza [_seguridad de hilo_] y es, por lo tanto, incluso más seguro que esos lenguajes en código multihilo. Y lo más importante, todas estas verificaciones ocurren en tiempo de compilación, por lo que no hay sobrecarga en tiempo de ejecución en comparación con la gestión de memoria escrita a mano en C.
[_seguridad de memoria_]: https://en.wikipedia.org/wiki/Memory_safety
[_seguridad de hilo_]: https://en.wikipedia.org/wiki/Thread_safety
### Casos de Uso
Ahora conocemos los básicos de la asignación de memoria dinámica en Rust, pero ¿cuándo deberíamos usarla? Hemos llegado muy lejos con nuestro núcleo sin asignación de memoria dinámica, así que ¿por qué la necesitamos ahora?
Primero, la asignación de memoria dinámica siempre conlleva un poco de sobrecarga de rendimiento, ya que necesitamos encontrar un espacio libre en el heap para cada asignación. Por esta razón, las variables locales son generalmente preferibles, especialmente en código de núcleo sensible al rendimiento. Sin embargo, hay casos en los que la asignación de memoria dinámica es la mejor opción.
Como regla básica, se requiere memoria dinámica para variables que tienen una duración dinámica o un tamaño variable. El tipo más importante con una duración dinámica es [**`Rc`**], que cuenta las referencias a su valor envuelto y lo libera después de que todas las referencias han salido del alcance. Ejemplos de tipos con un tamaño variable son [**`Vec`**], [**`String`**] y otros [tipos de colección] que crecen dinámicamente cuando se añaden más elementos. Estos tipos funcionan al asignar una mayor cantidad de memoria cuando se llenan, copiando todos los elementos y luego liberando la antigua asignación.
[**`Rc`**]: https://doc.rust-lang.org/alloc/rc/index.html
[**`Vec`**]: https://doc.rust-lang.org/alloc/vec/index.html
[**`String`**]: https://doc.rust-lang.org/alloc/string/index.html
[tipos de colección]: https://doc.rust-lang.org/alloc/collections/index.html
Para nuestro núcleo, necesitaríamos principalmente los tipos de colección, por ejemplo, para almacenar una lista de tareas activas al implementar la multitarea en futuros posts.
## La Interfaz del Asignador
El primer paso en implementar un asignador de heap es agregar una dependencia en la crate integrada [`alloc`]. Al igual que la crate [`core`], es un subconjunto de la biblioteca estándar que además contiene los tipos de asignación y colección. Para agregar la dependencia en `alloc`, añadimos lo siguiente a nuestro `lib.rs`:
[`alloc`]: https://doc.rust-lang.org/alloc/
[`core`]: https://doc.rust-lang.org/core/
```rust
// en src/lib.rs
extern crate alloc;
```
A diferencia de las dependencias normales, no necesitamos modificar el `Cargo.toml`. La razón es que la crate `alloc` se envía con el compilador de Rust como parte de la biblioteca estándar, por lo que el compilador ya conoce la crate. Al agregar esta declaración `extern crate`, especificamos que el compilador debería intentar incluirla. (Históricamente, todas las dependencias necesitaban una declaración `extern crate`, que ahora es opcional).
Dado que estamos compilando para un objetivo personalizado, no podemos usar la versión precompilada de `alloc` que se envía con la instalación de Rust. En su lugar, debemos decirle a cargo que recompilar la crate desde la fuente. Podemos hacerlo añadiendo esta a la matriz `unstable.build-std` en nuestro archivo `.cargo/config.toml`:
```toml
# en .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins", "alloc"]
```
Ahora el compilador recompilará e incluirá la crate `alloc` en nuestro núcleo.
La razón por la que la crate `alloc` está deshabilitada por defecto en crates `#[no_std]` es que tiene requisitos adicionales. Cuando intentamos compilar nuestro proyecto ahora, veremos estos requisitos como errores:
```
error: no se encontró ningún asignador de memoria global, pero se requiere uno; vincular a std o agregar #[global_allocator] a un elemento estático que implemente el trait GlobalAlloc.
```
El error ocurre porque la crate `alloc` requiere un asignador de heap, que es un objeto que proporciona las funciones `allocate` y `deallocate`. En Rust, los asignadores de heap se describen mediante el trait [`GlobalAlloc`], que se menciona en el mensaje de error. Para establecer el asignador de heap para la crate, el atributo `#[global_allocator]` debe aplicarse a una variable `static` que implemente el trait `GlobalAlloc`.
[`GlobalAlloc`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html
### El Trait `GlobalAlloc`
El trait [`GlobalAlloc`] define las funciones que debe proporcionar un asignador de heap. El trait es especial porque casi nunca se usa directamente por el programador. En su lugar, el compilador insertará automáticamente las llamadas apropiadas a los métodos del trait al utilizar los tipos de asignación y colección de `alloc`.
Dado que necesitaremos implementar el trait para todos nuestros tipos de asignadores, vale la pena echar un vistazo más de cerca a su declaración:
```rust
pub unsafe trait GlobalAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8;
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { ... }
unsafe fn realloc(
&self,
ptr: *mut u8,
layout: Layout,
new_size: usize
) -> *mut u8 { ... }
}
```
Define los dos métodos requeridos [`alloc`] y [`dealloc`], que corresponden a las funciones `allocate` y `deallocate` que usamos en nuestros ejemplos:
- El método [`alloc`] toma una instancia de [`Layout`] como argumento, que describe el tamaño y alineación deseados que debe tener el bloque de memoria asignada. Devuelve un [puntero bruto] al primer byte del bloque de memoria asignada. En lugar de un valor de error explícito, el método `alloc` devuelve un puntero nulo para señalar un error de asignación. Esto es un poco no idiomático, pero tiene la ventaja de que es fácil envolver asignadores de sistema existentes ya que utilizan la misma convención.
- El método [`dealloc`] es el contraparte y es responsable de liberar un bloque de memoria nuevamente. Recibe dos argumentos: el puntero devuelto por `alloc` y el `Layout` que se usó para la asignación.
[`alloc`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html#tymethod.alloc
[`dealloc`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html#tymethod.dealloc
[`Layout`]: https://doc.rust-lang.org/alloc/alloc/struct.Layout.html
El trait además define los dos métodos [`alloc_zeroed`] y [`realloc`] con implementaciones predeterminadas:
- El método [`alloc_zeroed`] es equivalente a llamar a `alloc` y luego establecer el bloque de memoria asignado a cero, lo cual es exactamente lo que hace la implementación predeterminada proporcionada. Una implementación de asignador puede reemplazar las implementaciones predeterminadas con una implementación personalizada más eficiente si es posible.
- El método [`realloc`] permite aumentar o disminuir una asignación. La implementación predeterminada asigna un nuevo bloque de memoria con el tamaño deseado y copia todo el contenido de la asignación anterior. Nuevamente, una implementación de asignador podría proporcionar probablemente una implementación más eficiente de este método, por ejemplo, aumentando/disminuyendo la asignación en su lugar si es posible.
[`alloc_zeroed`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html#method.alloc_zeroed
[`realloc`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html#method.realloc
#### Inseguridad
Una cosa a notar es que tanto el trait en sí como todos los métodos del trait se declaran como `unsafe`:
- La razón para declarar el trait como `unsafe` es que el programador debe garantizar que la implementación del trait para un tipo de asignador sea correcta. Por ejemplo, el método `alloc` nunca debe devolver un bloque de memoria que ya está siendo usado en otro lugar porque esto causaría comportamiento indefinido.
- De manera similar, la razón por la que los métodos son `unsafe` es que el llamador debe asegurar diversas invariantes al llamar a los métodos, por ejemplo, que el `Layout` pasado a `alloc` especifica un tamaño no nulo. Esto no es realmente relevante en la práctica ya que los métodos son normalmente llamados directamente por el compilador, que asegura que se cumplan los requisitos.
### Un `DummyAllocator`
Ahora que sabemos qué debe proporcionar un tipo de asignador, podemos crear un simple asignador nulo. Para eso, creamos un nuevo módulo `allocator`:
```rust
// en src/lib.rs
pub mod allocator;
```
Nuestro asignador nulo hace lo mínimo absoluto para implementar el trait y siempre devuelve un error cuando se llama a `alloc`. Se ve así:
```rust
// en src/allocator.rs
use alloc::alloc::{GlobalAlloc, Layout};
use core::ptr::null_mut;
pub struct Dummy;
unsafe impl GlobalAlloc for Dummy {
unsafe fn alloc(&self, _layout: Layout) -> *mut u8 {
null_mut()
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
panic!("dealloc no debería ser llamado nunca")
}
}
```
La estructura no necesita ningún campo, así que la creamos como un [tipo de tamaño cero]. Como se mencionó anteriormente, siempre devolvemos el puntero nulo de `alloc`, que corresponde a un error de asignación. Dado que el asignador nunca devuelve memoria, una llamada a `dealloc` nunca debe ocurrir. Por esta razón, simplemente hacemos panic en el método `dealloc`. Los métodos `alloc_zeroed` y `realloc` tienen implementaciones predeterminadas, por lo que no necesitamos proporcionar implementaciones para ellos.
[tipo de tamaño cero]: https://doc.rust-lang.org/nomicon/exotic-sizes.html#zero-sized-types-zsts
Ahora tenemos un asignador simple, pero aún tenemos que decirle al compilador de Rust que debe usar este asignador. Aquí es donde entra el atributo `#[global_allocator]`.
### El Atributo `#[global_allocator]`
El atributo `#[global_allocator]` le dice al compilador de Rust qué instancia de asignador debe usar como el asignador global de heap. El atributo solo es aplicable a un `static` que implemente el trait `GlobalAlloc`. Registremos una instancia de nuestro asignador `Dummy` como el asignador global:
```rust
// en src/allocator.rs
#[global_allocator]
static ALLOCATOR: Dummy = Dummy;
```
Dado que el asignador `Dummy` es un [tipo de tamaño cero], no necesitamos especificar ningún campo en la expresión de inicialización.
Con este static, los errores de compilación deberían estar arreglados. Ahora podemos usar los tipos de asignación y colección de `alloc`. Por ejemplo, podemos usar un [`Box`] para asignar un valor en el heap:
[`Box`]: https://doc.rust-lang.org/alloc/boxed/struct.Box.html
```rust
// en src/main.rs
extern crate alloc;
use alloc::boxed::Box;
fn kernel_main(boot_info: &'static BootInfo) -> ! {
// […] imprimir "¡Hola Mundo!", llamar a `init`, crear `mapper` y `frame_allocator`
let x = Box::new(41);
// […] llamar a `test_main` en modo de prueba
println!("¡No se cayó!");
blog_os::hlt_loop();
}
```
Nota que necesitamos especificar la declaración `extern crate alloc` en nuestro `main.rs` también. Esto es requerido porque las partes de `lib.rs` y `main.rs` se tratan como crates separadas. Sin embargo, no necesitamos crear otro static `#[global_allocator]` ya que el asignador global se aplica a todas las crates en el proyecto. De hecho, especificar un asignador adicional en otra crate sería un error.
Cuando ejecutamos el código anterior, vemos que ocurre un panic:
![QEMU imprimiendo "panicado en `alloc error: Layout { size_: 4, align_: 4 }, src/lib.rs:89:5"](qemu-dummy-output.png)
El panic ocurre porque la función `Box::new` llama implícitamente a la función `alloc` del asignador global. Nuestro asignador nulo siempre devuelve un puntero nulo, así que cada asignación falla. Para arreglar esto, necesitamos crear un asignador que realmente devuelva memoria utilizable.
## Creando un Heap para el Núcleo
Antes de que podamos crear un asignador adecuado, primero necesitamos crear una región de memoria heap de la que el asignador pueda asignar memoria. Para hacer esto, necesitamos definir un rango de memoria virtual para la región del heap y luego mapear esta región a un marco físico. Ve la publicación [_"Introducción a la Paginación"_] para una visión general de la memoria virtual y las tablas de páginas.
[_"Introducción a la Paginación"_]: @/edition-2/posts/08-paging-introduction/index.md
El primer paso es definir una región de memoria virtual para el heap. Podemos elegir cualquier rango de dirección virtual que nos guste, siempre que no esté ya utilizado para otra región de memoria. Definámoslo como la memoria que comienza en la dirección `0x_4444_4444_0000` para que podamos reconocer fácilmente un puntero de heap más tarde:
```rust
// en src/allocator.rs
pub const HEAP_START: usize = 0x_4444_4444_0000;
pub const HEAP_SIZE: usize = 100 * 1024; // 100 KiB
```
Establecemos el tamaño del heap en 100&nbsp;KiB por ahora. Si necesitamos más espacio en el futuro, simplemente podemos aumentarlo.
Si tratamos de usar esta región del heap ahora, ocurrirá un fallo de página ya que la región de memoria virtual no está mapeada a la memoria física todavía. Para resolver esto, creamos una función `init_heap` que mapea las páginas del heap usando la [API de Mapper] que introdujimos en la publicación [_"Implementación de Paginación"_]:
[API de Mapper]: @/edition-2/posts/09-paging-implementation/index.md#using-offsetpagetable
[_"Implementación de Paginación"_]: @/edition-2/posts/09-paging-implementation/index.md
```rust
// en src/allocator.rs
use x86_64::{
structures::paging::{
mapper::MapToError, FrameAllocator, Mapper, Page, PageTableFlags, Size4KiB,
},
VirtAddr,
};
pub fn init_heap(
mapper: &mut impl Mapper<Size4KiB>,
frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) -> Result<(), MapToError<Size4KiB>> {
let page_range = {
let heap_start = VirtAddr::new(HEAP_START as u64);
let heap_end = heap_start + HEAP_SIZE - 1u64;
let heap_start_page = Page::containing_address(heap_start);
let heap_end_page = Page::containing_address(heap_end);
Page::range_inclusive(heap_start_page, heap_end_page)
};
for page in page_range {
let frame = frame_allocator
.allocate_frame()
.ok_or(MapToError::FrameAllocationFailed)?;
let flags = PageTableFlags::PRESENT | PageTableFlags::WRITABLE;
unsafe {
mapper.map_to(page, frame, flags, frame_allocator)?.flush()
};
}
Ok(())
}
```
La función toma referencias mutables a una instancia [`Mapper`] y a una instancia [`FrameAllocator`], ambas limitadas a páginas de 4&nbsp;KiB usando [`Size4KiB`] como parámetro genérico. El valor de retorno de la función es un [`Result`] con el tipo unidad `()` como variante de éxito y un [`MapToError`] como variante de error, que es el tipo de error devuelto por el método [`Mapper::map_to`]. Reutilizar el tipo de error tiene sentido aquí porque el método `map_to` es la principal fuente de errores en esta función.
[`Mapper`]:https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Mapper.html
[`FrameAllocator`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/trait.FrameAllocator.html
[`Size4KiB`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page/enum.Size4KiB.html
[`Result`]: https://doc.rust-lang.org/core/result/enum.Result.html
[`MapToError`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/enum.MapToError.html
[`Mapper::map_to`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Mapper.html#method.map_to
La implementación se puede dividir en dos partes:
- **Creando el rango de páginas:** Para crear un rango de las páginas que queremos mapear, convertimos el puntero `HEAP_START` a un tipo [`VirtAddr`]. Luego calculamos la dirección del final del heap a partir de ella sumando el `HEAP_SIZE`. Queremos un límite inclusivo (la dirección del último byte del heap), por lo que restamos 1. A continuación, convertimos las direcciones en tipos [`Page`] usando la función [`containing_address`]. Finalmente, creamos un rango de páginas a partir de las páginas inicial y final utilizando la función [`Page::range_inclusive`].
- **Mapeo de las páginas:** El segundo paso es mapear todas las páginas del rango de páginas que acabamos de crear. Para eso, iteramos sobre estas páginas usando un bucle `for`. Para cada página, hacemos lo siguiente:
- Asignamos un marco físico al que la página debería ser mapeada usando el método [`FrameAllocator::allocate_frame`]. Este método devuelve [`None`] cuando no quedan más marcos. Nos ocupamos de ese caso al mapearlo a un error [`MapToError::FrameAllocationFailed`] a través del método [`Option::ok_or`] y luego aplicando el [operador de signo de interrogación] para retornar temprano en caso de error.
- Establecemos el flag `PRESENT` requerido y el flag `WRITABLE` para la página. Con estos flags, tanto los accesos de lectura como de escritura están permitidos, lo que tiene sentido para la memoria del heap.
- Usamos el método [`Mapper::map_to`] para crear el mapeo en la tabla de páginas activa. El método puede fallar, así que usamos el [operador de signo de interrogación] otra vez para avanzar el error al llamador. En caso de éxito, el método devuelve una instancia de [`MapperFlush`] que podemos usar para actualizar el [_buffer de traducción de direcciones_] utilizando el método [`flush`].
[`VirtAddr`]: https://docs.rs/x86_64/0.14.2/x86_64/addr/struct.VirtAddr.html
[`Page`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page/struct.Page.html
[`containing_address`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page/struct.Page.html#method.containing_address
[`Page::range_inclusive`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page/struct.Page.html#method.range_inclusive
[`FrameAllocator::allocate_frame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/trait.FrameAllocator.html#tymethod.allocate_frame
[`None`]: https://doc.rust-lang.org/core/option/enum.Option.html#variant.None
[`MapToError::FrameAllocationFailed`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/enum.MapToError.html#variant.FrameAllocationFailed
[`Option::ok_or`]: https://doc.rust-lang.org/core/option/enum.Option.html#method.ok_or
[operador de signo de interrogación]: https://doc.rust-lang.org/edition-guide/rust-2018/error-handling-and-panics/the-question-mark-operator-for-easier-error-handling.html
[`MapperFlush`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.MapperFlush.html
[_buffer de traducción de direcciones_]: @/edition-2/posts/08-paging-introduction/index.md#the-translation-lookaside-buffer
[`flush`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.MapperFlush.html#method.flush
El último paso es llamar a esta función desde nuestro `kernel_main`:
```rust
// en src/main.rs
fn kernel_main(boot_info: &'static BootInfo) -> ! {
use blog_os::allocator; // nueva importación
use blog_os::memory::{self, BootInfoFrameAllocator};
println!("¡Hola Mundo{}!", "");
blog_os::init();
let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
let mut mapper = unsafe { memory::init(phys_mem_offset) };
let mut frame_allocator = unsafe {
BootInfoFrameAllocator::init(&boot_info.memory_map)
};
// nueva
allocator::init_heap(&mut mapper, &mut frame_allocator)
.expect("falló la inicialización del heap");
let x = Box::new(41);
// […] llamar a `test_main` en contexto de prueba
println!("¡No se cayó!");
blog_os::hlt_loop();
}
```
Mostramos la función completa aquí para contexto. Las únicas nuevas líneas son la importación de `blog_os::allocator` y la llamada a la función `allocator::init_heap`. En caso de que la función `init_heap` devuelva un error, hacemos panic usando el método [`Result::expect`] ya que actualmente no hay una forma sensata para nosotros de manejar este error.
[`Result::expect`]: https://doc.rust-lang.org/core/result/enum.Result.html#method.expect
Ahora tenemos una región de memoria heap mapeada que está lista para ser utilizada. La llamada a `Box::new` aún utiliza nuestro antiguo asignador `Dummy`, así que todavía verás el error "sin memoria" cuando lo ejecutes. Arreglemos esto utilizando un asignador apropiado.
## Usando una Crate de Asignador
Dado que implementar un asignador es algo complicado, empezamos usando una crate de asignador externa. Aprenderemos cómo implementar nuestro propio asignador en el próximo post.
Una crate de asignador simple para aplicaciones `no_std` es la crate [`linked_list_allocator`]. Su nombre proviene del hecho de que utiliza una estructura de datos de lista enlazada para hacer un seguimiento de las regiones de memoria desasignadas. Ve la próxima publicación para una explicación más detallada de este enfoque.
[`linked_list_allocator`]: https://github.com/phil-opp/linked-list-allocator/
Para usar la crate, primero necesitamos agregar una dependencia en ella en nuestro `Cargo.toml`:
```toml
# en Cargo.toml
[dependencies]
linked_list_allocator = "0.9.0"
```
Luego podemos reemplazar nuestro asignador nulo con el asignador proporcionado por la crate:
```rust
// en src/allocator.rs
use linked_list_allocator::LockedHeap;
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::empty();
```
La estructura se llama `LockedHeap` porque usa el tipo [`spinning_top::Spinlock`] para la sincronización. Esto es requerido porque múltiples hilos podrían acceder al static `ALLOCATOR` al mismo tiempo. Como siempre, al utilizar un spinlock o un mutex, debemos tener cuidado de no causar accidentalmente un deadlock. Esto significa que no debemos realizar ninguna asignación en manejadores de interrupciones, ya que pueden ejecutarse en cualquier momento y podrían interrumpir una asignación en progreso.
[`spinning_top::Spinlock`]: https://docs.rs/spinning_top/0.1.0/spinning_top/type.Spinlock.html
Configurar el `LockedHeap` como asignador global no es suficiente. La razón es que usamos la función constructora [`empty`], que crea un asignador sin ninguna memoria de respaldo. Al igual que nuestro asignador nulo, siempre devuelve un error en `alloc`. Para arreglar esto, necesitamos inicializar el asignador después de crear el heap:
[`empty`]: https://docs.rs/linked_list_allocator/0.9.0/linked_list_allocator/struct.LockedHeap.html#method.empty
```rust
// en src/allocator.rs
pub fn init_heap(
mapper: &mut impl Mapper<Size4KiB>,
frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) -> Result<(), MapToError<Size4KiB>> {
// […] mapear todas las páginas del heap a marcos físicos
// nueva
unsafe {
ALLOCATOR.lock().init(HEAP_START, HEAP_SIZE);
}
Ok(())
}
```
Usamos el método [`lock`] sobre el spinlock interno del tipo `LockedHeap` para obtener una referencia exclusiva a la instancia [`Heap`] envuelta, sobre la cual luego llamamos al método [`init`] con los límites del heap como argumentos. Como la función [`init`] ya intenta escribir en la memoria del heap, debemos inicializar el heap solo _después_ de mapear las páginas del heap.
[`lock`]: https://docs.rs/lock_api/0.3.3/lock_api/struct.Mutex.html#method.lock
[`Heap`]: https://docs.rs/linked_list_allocator/0.9.0/linked_list_allocator/struct.Heap.html
[`init`]: https://docs.rs/linked_list_allocator/0.9.0/linked_list_allocator/struct.Heap.html#method.init
Después de inicializar el heap, ahora podemos usar todos los tipos de asignación y colección de la crate integrada [`alloc`] sin error:
```rust
// en src/main.rs
use alloc::{boxed::Box, vec, vec::Vec, rc::Rc};
fn kernel_main(boot_info: &'static BootInfo) -> ! {
// […] inicializar interrupciones, mapper, frame_allocator, heap
// asignar un número en el heap
let heap_value = Box::new(41);
println!("valor del heap en {:p}", heap_value);
// crear un vector de tamaño dinámico
let mut vec = Vec::new();
for i in 0..500 {
vec.push(i);
}
println!("vector en {:p}", vec.as_slice());
// crear un vector contado por referencias -> será liberado cuando el conteo llegue a 0
let reference_counted = Rc::new(vec![1, 2, 3]);
let cloned_reference = reference_counted.clone();
println!("el conteo de referencia actual es {}", Rc::strong_count(&cloned_reference));
core::mem::drop(reference_counted);
println!("el conteo de referencia ahora es {} ahora", Rc::strong_count(&cloned_reference));
// […] llamar a `test_main` en contexto de prueba
println!("¡No se cayó!");
blog_os::hlt_loop();
}
```
Este ejemplo de código muestra algunos usos de los tipos [`Box`], [`Vec`] y [`Rc`]. Para los tipos `Box` y `Vec`, imprimimos los punteros del heap subyacente usando el especificador de formato [`{:p}`]. Para mostrar `Rc`, creamos un valor del heap contado por referencias y usamos la función [`Rc::strong_count`] para imprimir el conteo de referencias actual antes y después de soltar una de las instancias (usando [`core::mem::drop`]).
[`Vec`]: https://doc.rust-lang.org/alloc/vec/
[`Rc`]: https://doc.rust-lang.org/alloc/rc/
[`{:p}` especificador de formato]: https://doc.rust-lang.org/core/fmt/trait.Pointer.html
[`Rc::strong_count`]: https://doc.rust-lang.org/alloc/rc/struct.Rc.html#method.strong_count
[`core::mem::drop`]: https://doc.rust-lang.org/core/mem/fn.drop.html
Cuando lo ejecutamos, vemos lo siguiente:
![QEMU imprimiendo `
valor del heap en 0x444444440000
vector en 0x4444444408000
el conteo de referencia actual es 2
el conteo de referencia ahora es 1
](qemu-alloc-showcase.png)
Como se esperaba, vemos que los valores `Box` y `Vec` viven en el heap, como lo indica el puntero que comienza con el prefijo `0x_4444_4444_*`. El valor contado por referencias también se comporta como se esperaba, con el conteo de referencias siendo 2 después de la llamada a `clone`, y 1 nuevamente después de que se eliminó una de las instancias.
La razón por la que el vector comienza en el desplazamiento `0x800` no es que el valor `Box` sea `0x800` bytes grande, sino las [reasignaciones] que ocurren cuando el vector necesita aumentar su capacidad. Por ejemplo, cuando la capacidad del vector es 32 y tratamos de añadir el siguiente elemento, el vector asigna un nuevo arreglo de respaldo con una capacidad de 64 tras las escenas y copia todos los elementos. Luego libera la antigua asignación.
[reasignaciones]: https://doc.rust-lang.org/alloc/vec/struct.Vec.html#capacity-and-reallocation
Por supuesto, hay muchos más tipos de asignación y colección en la crate `alloc` que ahora podemos usar todos en nuestro núcleo, incluyendo:
- el puntero contado por referencias seguro para hilos [`Arc`]
- el tipo de cadena propia [`String`] y la macro [`format!`]
- [`LinkedList`]
- el búfer de anillo creciente [`VecDeque`]
- la cola de prioridad [`BinaryHeap`]
- [`BTreeMap`] y [`BTreeSet`]
[`Arc`]: https://doc.rust-lang.org/alloc/sync/struct.Arc.html
[`String`]: https://doc.rust-lang.org/alloc/string/struct.String.html
[`format!`]: https://doc.rust-lang.org/alloc/macro.format.html
[`LinkedList`]: https://doc.rust-lang.org/alloc/collections/linked_list/struct.LinkedList.html
[`VecDeque`]: https://doc.rust-lang.org/alloc/collections/vec_deque/struct.VecDeque.html
[`BinaryHeap`]: https://doc.rust-lang.org/alloc/collections/binary_heap/struct.BinaryHeap.html
[`BTreeMap`]: https://doc.rust-lang.org/alloc/collections/btree_map/struct.BTreeMap.html
[`BTreeSet`]: https://doc.rust-lang.org/alloc/collections/btree_set/struct.BTreeSet.html
Estos tipos serán muy útiles cuando queramos implementar listas de hilos, colas de programación o soporte para async/await.
## Añadiendo una Prueba
Para asegurarnos de que no rompemos accidentalmente nuestro nuevo código de asignación, deberíamos agregar una prueba de integración para ello. Comenzamos creando un nuevo archivo `tests/heap_allocation.rs` con el siguiente contenido:
```rust
// en tests/heap_allocation.rs
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runner(blog_os::test_runner)]
#![reexport_test_harness_main = "test_main"]
extern crate alloc;
use bootloader::{entry_point, BootInfo};
use core::panic::PanicInfo;
entry_point!(main);
fn main(boot_info: &'static BootInfo) -> ! {
unimplemented!();
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}
```
Reutilizamos las funciones `test_runner` y `test_panic_handler` de nuestro `lib.rs`. Dado que queremos probar asignaciones, habilitamos la crate `alloc` a través de la declaración `extern crate alloc`. Para más información sobre el boilerplate de la prueba, consulta la publicación [_Pruebas_] .
[_Pruebas_]: @/edition-2/posts/04-testing/index.md
La implementación de la función `main` se ve así:
```rust
// en tests/heap_allocation.rs
fn main(boot_info: &'static BootInfo) -> ! {
use blog_os::allocator;
use blog_os::memory::{self, BootInfoFrameAllocator};
use x86_64::VirtAddr;
blog_os::init();
let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
let mut mapper = unsafe { memory::init(phys_mem_offset) };
let mut frame_allocator = unsafe {
BootInfoFrameAllocator::init(&boot_info.memory_map)
};
allocator::init_heap(&mut mapper, &mut frame_allocator)
.expect("falló la inicialización del heap");
test_main();
loop {}
}
```
Es muy similar a la función `kernel_main` en nuestro `main.rs`, con las diferencias de que no invocamos `println`, no incluimos ninguna asignación de ejemplo y llamamos a `test_main` incondicionalmente.
Ahora estamos listos para agregar algunos casos de prueba. Primero, agregamos una prueba que realiza algunas asignaciones simples usando [`Box`] y verifica los valores asignados para asegurar que las asignaciones básicas funcionan:
```rust
// en tests/heap_allocation.rs
use alloc::boxed::Box;
#[test_case]
fn simple_allocation() {
let heap_value_1 = Box::new(41);
let heap_value_2 = Box::new(13);
assert_eq!(*heap_value_1, 41);
assert_eq!(*heap_value_2, 13);
}
```
Lo más importante es que esta prueba verifica que no ocurre error de asignación.
A continuación, construimos iterativamente un gran vector, para probar tanto grandes asignaciones como múltiples asignaciones (debido a reasignaciones):
```rust
// en tests/heap_allocation.rs
use alloc::vec::Vec;
#[test_case]
fn large_vec() {
let n = 1000;
let mut vec = Vec::new();
for i in 0..n {
vec.push(i);
}
assert_eq!(vec.iter().sum::<u64>(), (n - 1) * n / 2);
}
```
Verificamos la suma comparándola con la fórmula para la [n-ésima suma parcial]. Esto nos da algo de confianza en que los valores asignados son todos correctos.
[n-ésima suma parcial]: https://en.wikipedia.org/wiki/1_%2B_2_%2B_3_%2B_4_%2B_%E2%8B%AF#Partial_sums
Como tercera prueba, creamos diez mil asignaciones una tras otra:
```rust
// en tests/heap_allocation.rs
use blog_os::allocator::HEAP_SIZE;
#[test_case]
fn many_boxes() {
for i in 0..HEAP_SIZE {
let x = Box::new(i);
assert_eq!(*x, i);
}
}
```
Esta prueba asegura que el asignador reutiliza la memoria liberada para asignaciones subsecuentes, ya que de lo contrario se quedaría sin memoria. Esto puede parecer un requisito obvio para un asignador, pero hay diseños de asignador que no hacen esto. Un ejemplo es el diseño del asignador bump que se explicará en el próximo post.
¡Vamos a ejecutar nuestra nueva prueba de integración!
```
> cargo test --test heap_allocation
[…]
Ejecutando 3 pruebas
simple_allocation... [ok]
large_vec... [ok]
many_boxes... [ok]
```
¡Las tres pruebas tuvieron éxito! También puedes invocar `cargo test` (sin el argumento `--test`) para ejecutar todas las pruebas unitarias e integradas.
## Resumen
Este post dio una introducción a la memoria dinámica y explicó por qué y dónde se necesita. Vimos cómo el borrow checker previene vulnerabilidades comunes y aprendimos cómo funciona la API de asignación de Rust.
Después de crear una implementación mínima de la interfaz de asignador de Rust usando un asignador nulo, creamos una región de memoria heap adecuada para nuestro núcleo. Para eso, definimos un rango de direcciones virtuales para el heap y luego mapeamos todas las páginas de ese rango a marcos físicos usando el `Mapper` y `FrameAllocator` de la publicación anterior.
Finalmente, agregamos una dependencia en la crate `linked_list_allocator` para añadir un asignador adecuado a nuestro núcleo. Con este asignador, pudimos utilizar `Box`, `Vec` y otros tipos de asignación y colección de la crate `alloc`.
## ¿Qué sigue?
Si bien ya hemos añadido soporte para la asignación en el heap en este post, dejamos la mayor parte del trabajo a la crate `linked_list_allocator`. El próximo post mostrará en detalle cómo se puede implementar un asignador desde cero. Presentará múltiples posibles diseños de asignadores, mostrará cómo implementar versiones simples de ellos y explicará sus ventajas y desventajas.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,928 @@
+++
title = "Async/Aait"
weight = 12
path = "async-await"
date = 2020-03-27
[extra]
chapter = "Multitasking"
+++
En esta publicación, exploramos el _multitasking cooperativo_ y la característica _async/await_ de Rust. Observamos en detalle cómo funciona async/await en Rust, incluyendo el diseño del trait `Future`, la transformación de máquina de estado y el _pinning_. Luego añadimos soporte básico para async/await a nuestro núcleo creando una tarea de teclado asíncrona y un ejecutor básico.
<!-- more -->
Este blog se desarrolla abiertamente en [GitHub]. Si tienes problemas o preguntas, por favor abre un issue allí. También puedes dejar comentarios [al final]. El código fuente completo de esta publicación se puede encontrar en la rama [`post-12`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[al final]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-12
<!-- toc -->
## Multitasking
Una de las características fundamentales de la mayoría de los sistemas operativos es el [_multitasking_], que es la capacidad de ejecutar múltiples tareas de manera concurrente. Por ejemplo, probablemente tienes otros programas abiertos mientras miras esta publicación, como un editor de texto o una ventana de terminal. Incluso si solo tienes una ventana del navegador abierta, probablemente hay diversas tareas en segundo plano para gestionar tus ventanas de escritorio, verificar actualizaciones o indexar archivos.
[_multitasking_]: https://en.wikipedia.org/wiki/Computer_multitasking
Aunque parece que todas las tareas corren en paralelo, solo se puede ejecutar una sola tarea en un núcleo de CPU a la vez. Para crear la ilusión de que las tareas corren en paralelo, el sistema operativo cambia rápidamente entre tareas activas para que cada una pueda avanzar un poco. Dado que las computadoras son rápidas, no notamos estos cambios la mayor parte del tiempo.
Mientras que las CPU de un solo núcleo solo pueden ejecutar una sola tarea a la vez, las CPU de múltiples núcleos pueden ejecutar múltiples tareas de manera verdaderamente paralela. Por ejemplo, una CPU con 8 núcleos puede ejecutar 8 tareas al mismo tiempo. Explicaremos cómo configurar las CPU de múltiples núcleos en una publicación futura. Para esta publicación, nos enfocaremos en las CPU de un solo núcleo por simplicidad. (Vale la pena mencionar que todas las CPU de múltiples núcleos comienzan con solo un núcleo activo, así que podemos tratarlas como CPU de un solo núcleo por ahora.)
Hay dos formas de multitasking: el multitasking _cooperativo_ requiere que las tareas cedan regularmente el control de la CPU para que otras tareas puedan avanzar. El multitasking _preemptivo_ usa funcionalidades del sistema operativo para cambiar de hilo en puntos arbitrarios en el tiempo forzosamente. A continuación exploraremos las dos formas de multitasking en más detalle y discutiremos sus respectivas ventajas y desventajas.
### Multitasking Preemptivo
La idea detrás del multitasking preemptivo es que el sistema operativo controla cuándo cambiar de tareas. Para ello, utiliza el hecho de que recupera el control de la CPU en cada interrupción. Esto hace posible cambiar de tareas cuando hay nueva entrada disponible para el sistema. Por ejemplo, sería posible cambiar de tareas cuando se mueve el mouse o llega un paquete de red. El sistema operativo también puede determinar el momento exacto en que se permite que una tarea se ejecute configurando un temporizador de hardware para enviar una interrupción después de ese tiempo.
La siguiente gráfica ilustra el proceso de cambio de tareas en una interrupción de hardware:
![](regain-control-on-interrupt.svg)
En la primera fila, la CPU está ejecutando la tarea `A1` del programa `A`. Todas las demás tareas están en pausa. En la segunda fila, una interrupción de hardware llega a la CPU. Como se describió en la publicación sobre [_Interrupciones de Hardware_], la CPU detiene inmediatamente la ejecución de la tarea `A1` y salta al controlador de interrupciones definido en la tabla de descriptores de interrupciones (IDT). A través de este controlador de interrupciones, el sistema operativo vuelve a tener control de la CPU, lo que le permite cambiar a la tarea `B1` en lugar de continuar con la tarea `A1`.
[_Interrupciones de Hardware_]: @/edition-2/posts/07-hardware-interrupts/index.md
#### Guardando Estado
Dado que las tareas se interrumpen en puntos arbitrarios en el tiempo, pueden estar en medio de ciertos cálculos. Para poder reanudarlas más tarde, el sistema operativo debe respaldar todo el estado de la tarea, incluyendo su [pila de llamadas](https://en.wikipedia.org/wiki/Call_stack) y los valores de todos los registros de CPU. Este proceso se llama [_cambio de contexto_].
[call stack]: https://en.wikipedia.org/wiki/Call_stack
[_cambio de contexto_]: https://en.wikipedia.org/wiki/Context_switch
Dado que la pila de llamadas puede ser muy grande, el sistema operativo normalmente establece una pila de llamadas separada para cada tarea en lugar de respaldar el contenido de la pila de llamadas en cada cambio de tarea. Tal tarea con su propia pila se llama [_hilo de ejecución_] o _hilo_ a secas. Al usar una pila separada para cada tarea, solo se necesitan guardar los contenidos de registro en un cambio de contexto (incluyendo el contador de programa y el puntero de pila). Este enfoque minimiza la sobrecarga de rendimiento de un cambio de contexto, lo que es muy importante, ya que los cambios de contexto a menudo ocurren hasta 100 veces por segundo.
[_hilo de ejecución_]: https://en.wikipedia.org/wiki/Thread_(computing)
#### Discusión
La principal ventaja del multitasking preemptivo es que el sistema operativo puede controlar completamente el tiempo de ejecución permitido de una tarea. De esta manera, puede garantizar que cada tarea obtenga una parte justa del tiempo de CPU, sin necesidad de confiar en que las tareas cooperen. Esto es especialmente importante al ejecutar tareas de terceros o cuando varios usuarios comparten un sistema.
La desventaja de la preempción es que cada tarea requiere su propia pila. En comparación con una pila compartida, esto resulta en un mayor uso de memoria por tarea y a menudo limita la cantidad de tareas en el sistema. Otra desventaja es que el sistema operativo siempre debe guardar el estado completo de los registros de CPU en cada cambio de tarea, incluso si la tarea solo utilizó un pequeño subconjunto de los registros.
El multitasking preemptivo y los hilos son componentes fundamentales de un sistema operativo porque hacen posible ejecutar programas de espacio de usuario no confiables. Discutiremos estos conceptos en detalle en publicaciones futuras. Sin embargo, para esta publicación, nos enfocaremos en el multitasking cooperativo, que también proporciona capacidades útiles para nuestro núcleo.
### Multitasking Cooperativo
En lugar de pausar forzosamente las tareas en ejecución en puntos arbitrarios en el tiempo, el multitasking cooperativo permite que cada tarea se ejecute hasta que ceda voluntariamente el control de la CPU. Esto permite a las tareas pausarse a sí mismas en puntos convenientes en el tiempo, por ejemplo, cuando necesitan esperar por una operación de E/S de todos modos.
El multitasking cooperativo se utiliza a menudo a nivel de lenguaje, como en forma de [corutinas](https://en.wikipedia.org/wiki/Coroutine) o [async/await](https://rust-lang.github.io/async-book/01_getting_started/04_async_await_primer.html). La idea es que bien el programador o el compilador inserten operaciones [_yield_] en el programa, que ceden el control de la CPU y permiten que otras tareas se ejecuten. Por ejemplo, se podría insertar un yield después de cada iteración de un bucle complejo.
[async/await]: https://rust-lang.github.io/async-book/01_getting_started/04_async_await_primer.html
[_yield_]: https://en.wikipedia.org/wiki/Yield_(multithreading)
Es común combinar el multitasking cooperativo con [operaciones asíncronas](https://en.wikipedia.org/wiki/Asynchronous_I/O). En lugar de esperar hasta que una operación se complete y prevenir que otras tareas se ejecuten durante este tiempo, las operaciones asíncronas devuelven un estado "no listo" si la operación aún no ha finalizado. En este caso, la tarea en espera puede ejecutar una operación yield para permitir que otras tareas se ejecuten.
[operaciones asíncronas]: https://en.wikipedia.org/wiki/Asynchronous_I/O
#### Guardando Estado
Debido a que las tareas definen sus propios puntos de pausa, no necesitan que el sistema operativo guarde su estado. En su lugar, pueden guardar exactamente el estado que necesitan para continuar antes de pausarse, lo que a menudo resulta en un mejor rendimiento. Por ejemplo, una tarea que acaba de finalizar un cálculo complejo podría necesitar respaldar solo el resultado final del cálculo ya que no necesita los resultados intermedios.
Las implementaciones respaldadas por el lenguaje de tareas cooperativas son a menudo capaces de respaldar las partes necesarias de la pila de llamadas antes de pausarse. Como ejemplo, la implementación de async/await de Rust almacena todas las variables locales que aún se necesitan en una estructura generada automáticamente (ver más abajo). Al respaldar las partes relevantes de la pila de llamadas antes de pausarse, todas las tareas pueden compartir una única pila de llamadas, lo que resulta en un consumo de memoria mucho más bajo por tarea. Esto hace posible crear un número casi arbitrario de tareas cooperativas sin quedarse sin memoria.
#### Discusión
La desventaja del multitasking cooperativo es que una tarea no cooperativa puede potencialmente ejecutarse durante un tiempo ilimitado. Por lo tanto, una tarea maliciosa o con errores puede evitar que otras tareas se ejecuten y retardar o incluso bloquear todo el sistema. Por esta razón, el multitasking cooperativo debería usarse solo cuando todas las tareas se sabe que cooperan. Por ejemplo, no es una buena idea hacer que el sistema operativo dependa de la cooperación de programas de nivel de usuario arbitrarios.
Sin embargo, los fuertes beneficios de rendimiento y memoria del multitasking cooperativo lo convierten en un buen enfoque para uso _dentro_ de un programa, especialmente en combinación con operaciones asíncronas. Dado que un núcleo del sistema operativo es un programa crítico en términos de rendimiento que interactúa con hardware asíncrono, el multitasking cooperativo parece ser un buen enfoque para implementar concurrencia.
## Async/Await en Rust
El lenguaje Rust proporciona soporte de primera clase para el multitasking cooperativo en forma de async/await. Antes de que podamos explorar qué es async/await y cómo funciona, necesitamos entender cómo funcionan los _futuros_ y la programación asíncrona en Rust.
### Futuros
Un _futuro_ representa un valor que puede no estar disponible aún. Esto podría ser, por ejemplo, un número entero que es calculado por otra tarea o un archivo que se está descargando de la red. En lugar de esperar hasta que el valor esté disponible, los futuros permiten continuar la ejecución hasta que el valor sea necesario.
#### Ejemplo
El concepto de futuros se ilustra mejor con un pequeño ejemplo:
![Diagrama de secuencia: main llama a `read_file` y está bloqueado hasta que regrese; luego llama a `foo()` y también está bloqueado hasta que regrese. El mismo proceso se repite, pero esta vez se llama a `async_read_file`, que devuelve directamente un futuro; luego se llama a `foo()` de nuevo, que ahora se ejecuta concurrentemente con la carga del archivo. El archivo está disponible antes de que `foo()` regrese.](async-example.svg)
Este diagrama de secuencia muestra una función `main` que lee un archivo del sistema de archivos y luego llama a una función `foo`. Este proceso se repite dos veces: una vez con una llamada síncrona `read_file` y otra vez con una llamada asíncrona `async_read_file`.
Con la llamada síncrona, la función `main` necesita esperar hasta que el archivo se cargue desde el sistema de archivos. Solo entonces puede llamar a la función `foo`, lo que requiere que espere nuevamente por el resultado.
Con la llamada asíncrona `async_read_file`, el sistema de archivos devuelve directamente un futuro y carga el archivo de forma asíncrona en segundo plano. Esto permite que la función `main` llame a `foo` mucho antes, que luego se ejecuta en paralelo con la carga del archivo. En este ejemplo, la carga del archivo incluso termina antes de que `foo` regrese, por lo que `main` puede trabajar directamente con el archivo sin mayor espera después de que `foo` regrese.
#### Futuros en Rust
En Rust, los futuros están representados por el trait [`Future`], que se ve de la siguiente manera:
[`Future`]: https://doc.rust-lang.org/nightly/core/future/trait.Future.html
```rust
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}
```
El tipo [asociado](https://doc.rust-lang.org/book/ch19-03-advanced-traits.html#specifying-placeholder-types-in-trait-definitions-with-associated-types) `Output` especifica el tipo del valor asíncrono. Por ejemplo, la función `async_read_file` en el diagrama anterior devolvería una instancia de `Future` con `Output` configurado a `File`.
El método [`poll`] permite comprobar si el valor ya está disponible. Devuelve un enum [`Poll`], que se ve de la siguiente manera:
[`poll`]: https://doc.rust-lang.org/nightly/core/future/trait.Future.html#tymethod.poll
[`Poll`]: https://doc.rust-lang.org/nightly/core/task/enum.Poll.html
```rust
pub enum Poll<T> {
Ready(T),
Pending,
}
```
Cuando el valor ya está disponible (por ejemplo, el archivo se ha leído completamente desde el disco), se devuelve envuelto en la variante `Ready`. De lo contrario, se devuelve la variante `Pending`, que señala al llamador que el valor aún no está disponible.
El método `poll` toma dos argumentos: `self: Pin<&mut Self>` y `cx: &mut Context`. El primero se comporta de manera similar a una referencia normal `&mut self`, excepto que el valor `Self` está [_pinned_] a su ubicación de memoria. Entender `Pin` y por qué es necesario es difícil sin entender primero cómo funciona async/await. Por lo tanto, lo explicaremos más adelante en esta publicación.
[_pinned_]: https://doc.rust-lang.org/nightly/core/pin/index.html
El propósito del parámetro `cx: &mut Context` es pasar una instancia de [`Waker`] a la tarea asíncrona, por ejemplo, la carga del sistema de archivos. Este `Waker` permite que la tarea asíncrona señale que ha terminado (o que una parte de ella ha terminado), por ejemplo, que el archivo se ha cargado desde el disco. Dado que la tarea principal sabe que será notificada cuando el `Future` esté listo, no necesita llamar a `poll` una y otra vez. Explicaremos este proceso con más detalle más adelante en esta publicación cuando implementemos nuestro propio tipo de waker.
[`Waker`]: https://doc.rust-lang.org/nightly/core/task/struct.Waker.html
### Trabajando con Futuros
Ahora sabemos cómo se definen los futuros y entendemos la idea básica detrás del método `poll`. Sin embargo, aún no sabemos cómo trabajar de manera efectiva con los futuros. El problema es que los futuros representan los resultados de tareas asíncronas, que pueden no estar disponibles aún. En la práctica, sin embargo, a menudo necesitamos estos valores directamente para cálculos posteriores. Así que la pregunta es: ¿Cómo podemos recuperar eficientemente el valor de un futuro cuando lo necesitamos?
#### Esperando en Futuros
Una posible respuesta es esperar hasta que un futuro esté listo. Esto podría verse algo así:
```rust
let future = async_read_file("foo.txt");
let file_content = loop {
match future.poll() {
Poll::Ready(value) => break value,
Poll::Pending => {}, // no hacer nada
}
}
```
Aquí estamos _esperando activamente_ por el futuro al llamar a `poll` una y otra vez en un bucle. Los argumentos de `poll` no importan aquí, así que los omitimos. Aunque esta solución funciona, es muy ineficiente porque mantenemos la CPU ocupada hasta que el valor esté disponible.
Un enfoque más eficiente podría ser _bloquear_ el hilo actual hasta que el futuro esté disponible. Esto es, por supuesto, solo posible si tienes hilos, así que esta solución no funciona para nuestro núcleo, al menos no aún. Incluso en sistemas donde el bloqueo está soportado, a menudo no se desea porque convierte una tarea asíncrona en una tarea síncrona nuevamente, inhibiendo así los potenciales beneficios de rendimiento de las tareas paralelas.
#### Combinadores de Futuros
Una alternativa a esperar es utilizar combinadores de futuros. Los combinadores de futuros son métodos como `map` que permiten encadenar y combinar futuros, similar a los métodos del trait [`Iterator`]. En lugar de esperar en el futuro, estos combinadores devuelven un futuro por sí mismos, que aplica la operación de mapeo en `poll`.
[`Iterator`]: https://doc.rust-lang.org/stable/core/iter/trait.Iterator.html
Por ejemplo, un simple combinador `string_len` para convertir un `Future<Output = String>` en un `Future<Output = usize>` podría verse así:
```rust
struct StringLen<F> {
inner_future: F,
}
impl<F> Future for StringLen<F> where F: Future<Output = String> {
type Output = usize;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<T> {
match self.inner_future.poll(cx) {
Poll::Ready(s) => Poll::Ready(s.len()),
Poll::Pending => Poll::Pending,
}
}
}
fn string_len(string: impl Future<Output = String>)
-> impl Future<Output = usize>
{
StringLen {
inner_future: string,
}
}
// Uso
fn file_len() -> impl Future<Output = usize> {
let file_content_future = async_read_file("foo.txt");
string_len(file_content_future)
}
```
Este código no funciona del todo porque no maneja el [_pinning_], pero es suficiente como ejemplo. La idea básica es que la función `string_len` envuelve una instancia de `Future` dada en una nueva estructura `StringLen`, que también implementa `Future`. Cuando se pollea el futuro envuelto, se pollea el futuro interno. Si el valor no está listo aún, `Poll::Pending` se devuelve del futuro envuelto también. Si el valor está listo, la cadena se extrae de la variante `Poll::Ready` y se calcula su longitud. Después, se envuelve nuevamente en `Poll::Ready` y se devuelve.
[_pinning_]: https://doc.rust-lang.org/stable/core/pin/index.html
Con esta función `string_len`, podemos calcular la longitud de una cadena asíncrona sin esperar por ella. Dado que la función devuelve otro `Future`, el llamador no puede trabajar directamente en el valor devuelto, sino que necesita usar funciones combinadoras nuevamente. De esta manera, todo el gráfico de llamadas se vuelve asíncrono y podemos esperar eficientemente por múltiples futuros a la vez en algún momento, por ejemplo, en la función principal.
Debido a que escribir manualmente funciones combinadoras es difícil, a menudo son provistas por bibliotecas. Si bien la biblioteca estándar de Rust en sí no ofrece aún métodos de combinadores, el crate semi-oficial (y compatible con `no_std`) [`futures`] lo hace. Su trait [`FutureExt`] proporciona métodos combinadores de alto nivel como [`map`] o [`then`], que se pueden utilizar para manipular el resultado con closures arbitrarias.
[`futures`]: https://docs.rs/futures/0.3.4/futures/
[`FutureExt`]: https://docs.rs/futures/0.3.4/futures/future/trait.FutureExt.html
[`map`]: https://docs.rs/futures/0.3.4/futures/future/trait.FutureExt.html#method.map
[`then`]: https://docs.rs/futures/0.3.4/futures/future/trait.FutureExt.html#method.then
##### Ventajas
La gran ventaja de los combinadores de futuros es que mantienen las operaciones asíncronas. En combinación con interfaces de E/S asíncronas, este enfoque puede llevar a un rendimiento muy alto. El hecho de que los combinadores de futuros se implementen como estructuras normales con implementaciones de traits permite que el compilador los optimice excesivamente. Para más detalles, consulta la publicación sobre [_Futuros de cero costo en Rust_], que anunció la adición de futuros al ecosistema de Rust.
[_Futuros de cero costo en Rust_]: https://aturon.github.io/blog/2016/08/11/futures/
##### Desventajas
Si bien los combinadores de futuros hacen posible escribir código muy eficiente, pueden ser difíciles de usar en algunas situaciones debido al sistema de tipos y la interfaz basada en closures. Por ejemplo, considera el siguiente código:
```rust
fn example(min_len: usize) -> impl Future<Output = String> {
async_read_file("foo.txt").then(move |content| {
if content.len() < min_len {
Either::Left(async_read_file("bar.txt").map(|s| content + &s))
} else {
Either::Right(future::ready(content))
}
})
}
```
([Pruébalo en el playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=91fc09024eecb2448a85a7ef6a97b8d8))
Aquí leemos el archivo `foo.txt` y luego usamos el combinador [`then`] para encadenar un segundo futuro basado en el contenido del archivo. Si la longitud del contenido es menor que lo dado en `min_len`, leemos un archivo diferente `bar.txt` y se lo anexamos a `content` usando el combinador [`map`]. De lo contrario, solo devolvemos el contenido de `foo.txt`.
Necesitamos usar el [`move` keyword] para la closure pasada a `then` porque de lo contrario habría un error de tiempo de vida para `min_len`. La razón por la cual usamos el envoltorio [`Either`] es que los bloques `if` y `else` deben tener siempre el mismo tipo. Dado que devolvemos diferentes tipos de futuros en los bloques, debemos usar el tipo de envoltura para unificarlos en un solo tipo. La función [`ready`] envuelve un valor en un futuro que está inmediatamente listo. La función se requiere aquí porque el envoltorio `Either` espera que el valor envuelto implemente `Future`.
[`move` keyword]: https://doc.rust-lang.org/std/keyword.move.html
[`Either`]: https://docs.rs/futures/0.3.4/futures/future/enum.Either.html
[`ready`]: https://docs.rs/futures/0.3.4/futures/future/fn.ready.html
Como puedes imaginar, esto puede llevar rápidamente a código muy complejo para proyectos más grandes. Se invirtió mucho trabajo en agregar soporte para async/await a Rust, con el objetivo de hacer que el código asíncrono sea radicalmente más simple de escribir.
### El Patrón Async/Await
La idea detrás de async/await es permitir que el programador escriba código que _parece_ código síncrono normal, pero que es transformado en código asíncrono por el compilador. Funciona basado en las dos palabras clave `async` y `await`. La palabra clave `async` se puede usar en la firma de una función para transformar una función síncrona en una función asíncrona que devuelve un futuro:
```rust
async fn foo() -> u32 {
0
}
// lo anterior se traduce aproximadamente por el compilador a:
fn foo() -> impl Future<Output = u32> {
future::ready(0)
}
```
Esta palabra clave por sí sola no sería tan útil. Sin embargo, dentro de las funciones `async`, se puede utilizar la palabra clave `await` para recuperar el valor asíncrono de un futuro:
```rust
async fn example(min_len: usize) -> String {
let content = async_read_file("foo.txt").await;
if content.len() < min_len {
content + &async_read_file("bar.txt").await
} else {
content
}
}
```
([Pruébalo en el playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=d93c28509a1c67661f31ff820281d434))
Esta función es una traducción directa de la función `example` de [arriba](#desventajas) que usó funciones combinadoras. Usando el operador `.await`, podemos recuperar el valor de un futuro sin necesitar closures o tipos `Either`. Como resultado, podemos escribir nuestro código como escribimos código síncrono normal, con la diferencia de que _esto sigue siendo código asíncrono_.
#### Transformación de Máquina de Estado
Detrás de escena, el compilador convierte el cuerpo de la función `async` en una [_máquina de estado_], donde cada llamada `.await` representa un estado diferente. Para la función `example` anterior, el compilador crea una máquina de estado con los siguientes cuatro estados:
[_máquina de estado_]: https://en.wikipedia.org/wiki/Finite-state_machine
![Cuatro estados: inicio, esperando a foo.txt, esperando a bar.txt, final](async-state-machine-states.svg)
Cada estado representa un diferente punto de pausa en la función. Los estados _"Inicio"_ y _"Fin"_ representan la función al comienzo y al final de su ejecución. El estado _"Esperando a foo.txt"_ representa que la función está actualmente esperando el resultado de `async_read_file` primero. Similarmente, el estado _"Esperando a bar.txt"_ representa el punto de pausa donde la función está esperando el resultado de `async_read_file` segundo.
La máquina de estado implementa el trait `Future` haciendo que cada llamada a `poll` sea una posible transición de estado:
![Cuatro estados y sus transiciones: inicio, esperando a foo.txt, esperando a bar.txt, fin](async-state-machine-basic.svg)
El diagrama usa flechas para representar cambios de estado y formas de diamante para representar formas alternativas. Por ejemplo, si el archivo `foo.txt` no está listo, se toma el camino marcado como _"no"_ y se alcanza el estado _"Esperando a foo.txt"_. De lo contrario, se toma el camino _"sí"_. El pequeño diamante rojo sin leyenda representa la rama `if content.len() < 100` de la función `example`.
Observamos que la primera llamada `poll` inicia la función y la deja correr hasta que llega a un futuro que no está listo aún. Si todos los futuros en el camino están listos, la función puede ejecutarse hasta el estado _"Fin"_, donde devuelve su resultado envuelto en `Poll::Ready`. De lo contrario, la máquina de estados entra en un estado de espera y devuelve `Poll::Pending`. En la próxima llamada `poll`, la máquina de estados comienza de nuevo desde el último estado de espera y vuelve a intentar la última operación.
#### Guardando Estado
Para poder continuar desde el último estado de espera, la máquina de estado debe llevar un seguimiento del estado actual internamente. Además, debe guardar todas las variables que necesita para continuar la ejecución en la siguiente llamada `poll`. Aquí es donde el compilador realmente puede brillar: dado que sabe qué variables se utilizan cuando, puede generar automáticamente estructuras con exactamente las variables que se necesitan.
Como ejemplo, el compilador genera estructuras como la siguiente para la función `example` anterior:
```rust
// La función `example` nuevamente para que no necesites desplazarte hacia arriba
async fn example(min_len: usize) -> String {
let content = async_read_file("foo.txt").await;
if content.len() < min_len {
content + &async_read_file("bar.txt").await
} else {
content
}
}
// Las estructuras de estado generadas por el compilador:
struct StartState {
min_len: usize,
}
struct WaitingOnFooTxtState {
min_len: usize,
foo_txt_future: impl Future<Output = String>,
}
struct WaitingOnBarTxtState {
content: String,
bar_txt_future: impl Future<Output = String>,
}
struct EndState {}
```
En los estados _"inicio"_ y _"Esperando a foo.txt"_, se necesita almacenar el parámetro `min_len` para la comparación posterior con `content.len()`. El estado _"Esperando a foo.txt"_ y además almacena un `foo_txt_future`, que representa el futuro devuelto por la llamada `async_read_file`. Este futuro necesita ser polled de nuevo cuando la máquina de estado continúa, así que necesita ser almacenado.
El estado _"Esperando a bar.txt"_ contiene la variable `content` para la concatenación de cadenas posterior cuando `bar.txt` esté listo. También almacena un `bar_txt_future` que representa la carga en progreso de `bar.txt`. La estructura no contiene la variable `min_len` porque ya no se necesita después de la comparación `content.len()`. En el estado _"fin"_, no se almacenan variables porque la función ya se ha completado.
Ten en cuenta que este es solo un ejemplo del código que el compilador podría generar. Los nombres de las estructuras y la disposición de los campos son detalles de implementación y pueden ser diferentes.
#### El Tipo Completo de Máquina de Estado
Si bien el código exacto generado por el compilador es un detalle de implementación, ayuda a entender imaginar cómo se vería la máquina de estado generada _podría_ para la función `example`. Ya definimos las estructuras que representan los diferentes estados y que contienen las variables requeridas. Para crear una máquina de estado sobre ellas, podemos combinarlas en un [`enum`]:
[`enum`]: https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html
```rust
enum ExampleStateMachine {
Start(StartState),
WaitingOnFooTxt(WaitingOnFooTxtState),
WaitingOnBarTxt(WaitingOnBarTxtState),
End(EndState),
}
```
Definimos una variante de enum separada para cada estado y añadimos la estructura de estado correspondiente a cada variante como un campo. Para implementar las transiciones de estado, el compilador genera una implementación del trait `Future` basada en la función `example`:
```rust
impl Future for ExampleStateMachine {
type Output = String; // tipo de retorno de `example`
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
loop {
match self { // TODO: manejar pinning
ExampleStateMachine::Start(state) => {}
ExampleStateMachine::WaitingOnFooTxt(state) => {}
ExampleStateMachine::WaitingOnBarTxt(state) => {}
ExampleStateMachine::End(state) => {}
}
}
}
}
```
El tipo `Output` del futuro es `String` porque es el tipo de retorno de la función `example`. Para implementar la función `poll`, utilizamos una instrucción `match` sobre el estado actual dentro de un `loop`. La idea es que cambiamos al siguiente estado tantas veces como sea posible y usamos un explícito `return Poll::Pending` cuando no podemos continuar.
Para simplificar, solo mostramos un código simplificado y no manejamos [pinning][_pinned_], propiedad, tiempos de vida, etc. Así que este código y el siguiente deben ser tratados como pseudo-código y no ser usados directamente. Por supuesto, el código generado real por el compilador maneja todo correctamente, aunque de manera posiblemente diferente.
Para mantener pequeños los fragmentos de código, presentamos el código de cada brazo de `match` por separado. Empecemos con el estado `Start`:
```rust
ExampleStateMachine::Start(state) => {
// del cuerpo de `example`
let foo_txt_future = async_read_file("foo.txt");
// operación `.await`
let state = WaitingOnFooTxtState {
min_len: state.min_len,
foo_txt_future,
};
*self = ExampleStateMachine::WaitingOnFooTxt(state);
}
```
La máquina de estado se encuentra en el estado `Start` cuando está justo al principio de la función. En este caso, ejecutamos todo el código del cuerpo de la función `example` hasta la primera `.await`. Para manejar la operación `.await`, cambiamos el estado de la máquina de estado `self` a `WaitingOnFooTxt`, lo que incluye la construcción de la estructura `WaitingOnFooTxtState`.
Dado que la instrucción `match self {…}` se ejecuta en un bucle, la ejecución salta al brazo `WaitingOnFooTxt` a continuación:
```rust
ExampleStateMachine::WaitingOnFooTxt(state) => {
match state.foo_txt_future.poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(content) => {
// del cuerpo de `example`
if content.len() < state.min_len {
let bar_txt_future = async_read_file("bar.txt");
// operación `.await`
let state = WaitingOnBarTxtState {
content,
bar_txt_future,
};
*self = ExampleStateMachine::WaitingOnBarTxt(state);
} else {
*self = ExampleStateMachine::End(EndState);
return Poll::Ready(content);
}
}
}
}
```
En este brazo de `match`, primero llamamos a la función `poll` de `foo_txt_future`. Si no está lista, salimos del bucle y devolvemos `Poll::Pending`. Dado que `self` permanece en el estado `WaitingOnFooTxt` en este caso, la siguiente llamada `poll` en la máquina de estado ingresará al mismo brazo de `match` y volverá a intentar hacer polling en el `foo_txt_future`.
Cuando `foo_txt_future` está listo, asignamos el resultado a la variable `content` y continuamos ejecutando el código de la función `example`: Si `content.len()` es menor que el `min_len` guardado en la estructura de estado, el archivo `bar.txt` se carga asíncronamente. Una vez más, traducimos la operación `.await` en un cambio de estado, esta vez al estado `WaitingOnBarTxt`. Dado que estamos ejecutando el `match` dentro de un bucle, la ejecución salta directamente al brazo de `match` para el nuevo estado después, donde se hace polling en el futuro `bar_txt_future`.
En caso de que ingresamos al bloque `else`, no ocurre ninguna otra operación `.await`. Alcanzamos el final de la función y devolvemos `content` envuelto en `Poll::Ready`. También cambiamos el estado actual a `End`.
El código para el estado `WaitingOnBarTxt` se ve así:
```rust
ExampleStateMachine::WaitingOnBarTxt(state) => {
match state.bar_txt_future.poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(bar_txt) => {
*self = ExampleStateMachine::End(EndState);
// del cuerpo de `example`
return Poll::Ready(state.content + &bar_txt);
}
}
}
```
Al igual que en el estado `WaitingOnFooTxt`, comenzamos haciendo polling en `bar_txt_future`. Si aún está pendiente, salimos del bucle y devolvemos `Poll::Pending`. De lo contrario, podemos realizar la última operación de la función `example`: concatenar la variable `content` con el resultado del futuro. Actualizamos la máquina de estado al estado `End` y luego devolvemos el resultado envuelto en `Poll::Ready`.
Finalmente, el código para el estado `End` se ve así:
```rust
ExampleStateMachine::End(_) => {
panic!("poll called after Poll::Ready was returned");
}
```
Los futuros no deben ser polled nuevamente después de que devuelven `Poll::Ready`, así que hacemos panic si se llama a `poll` mientras estamos en el estado `End`.
Ahora sabemos cómo podría verse la máquina de estado generada por el compilador y su implementación del trait `Future`. En la práctica, el compilador genera el código de diferentes formas. (En caso de que te interese, la implementación actualmente se basa en [_corutinas_], pero esto es solo un detalle de implementación.)
[_corutinas_]: https://doc.rust-lang.org/stable/unstable-book/language-features/coroutines.html
La última pieza del rompecabezas es el código generado para la propia función `example`. Recuerda, la cabecera de la función se definió así:
```rust
async fn example(min_len: usize) -> String
```
Dado que el cuerpo completo de la función ahora es implementado por la máquina de estado, lo único que debe hacer la función es inicializar la máquina de estado y devolverla. El código generado para esto podría verse así:
```rust
fn example(min_len: usize) -> ExampleStateMachine {
ExampleStateMachine::Start(StartState {
min_len,
})
}
```
La función ya no tiene modificador `async` ya que ahora devuelve explícitamente un tipo `ExampleStateMachine`, que implementa el trait `Future`. Como era de esperar, la máquina de estado se construye en el estado `Start` y la estructura de estado correspondiente se inicializa con el parámetro `min_len`.
Ten en cuenta que esta función no inicia la ejecución de la máquina de estado. Esta es una decisión de diseño fundamental de los futuros en Rust: no hacen nada hasta que se les pollea por primera vez.
### Pinning
Ya que nos hemos encontrado con el _pinning_ varias veces en esta publicación, es momento de explorar qué es el pinning y por qué es necesario.
#### Estructuras Autorreferenciales
Como se explicó anteriormente, la transformación de máquina de estado almacena las variables locales de cada punto de pausa en una estructura. Para ejemplos pequeños como nuestra función `example`, esto fue sencillo y no llevó a ningún problema. Sin embargo, las cosas se vuelven más difíciles cuando las variables se referencian entre sí. Por ejemplo, considera esta función:
```rust
async fn pin_example() -> i32 {
let array = [1, 2, 3];
let element = &array[2];
async_write_file("foo.txt", element.to_string()).await;
*element
}
```
Esta función crea un pequeño `array` con los contenidos `1`, `2` y `3`. Luego crea una referencia al último elemento del array y la almacena en una variable `element`. A continuación, escribe asincrónicamente el número convertido a una cadena en un archivo `foo.txt`. Finalmente, devuelve el número referenciado por `element`.
Dado que la función utiliza una única operación `.await`, la máquina de estado resultante tiene tres estados: inicio, fin y "esperando a escribir". La función no toma argumentos, por lo que la estructura para el estado de inicio está vacía. Al igual que antes, la estructura para el estado final está vacía porque la función ha terminado en este punto. Sin embargo, la estructura para el estado de "esperando a escribir" es más interesante:
```rust
struct WaitingOnWriteState {
array: [1, 2, 3],
element: 0x1001c, // dirección del último elemento del array
}
```
Necesitamos almacenar tanto `array` como `element` porque la variable `element` es necesaria para el valor de retorno y `array` es referenciada por `element`. Usamos `0x1001c` como un ejemplo de dirección de memoria aquí. En realidad, necesita ser la dirección del último elemento del campo `array`, por lo que depende de dónde viva la estructura en memoria. Las estructuras con tales punteros internos se llaman _estructuras autorefencial_ porque se refieren a sí mismas desde uno de sus campos.
#### El Problema con las Estructuras Autorreferenciales
El puntero interno de nuestra estructura autorefencial lleva a un problema fundamental, que se hace evidente cuando observamos su disposición en la memoria:
![array en 0x10014 con campos 1, 2 y 3; elemento en dirección 0x10020, apuntando al último elemento del array en 0x1001c](self-referential-struct.svg)
El campo `array` comienza en la dirección 0x10014 y el campo `element` en la dirección 0x10020. Apunta a la dirección 0x1001c porque el último elemento del array vive en esta dirección. En este punto, todo sigue bien. Sin embargo, un problema ocurre cuando movemos esta estructura a una dirección de memoria diferente:
![array en 0x10024 con campos 1, 2 y 3; elemento en dirección 0x10030, aún apuntando a 0x1001c, incluso cuando el último elemento del array ahora vive en 0x1002c](self-referential-struct-moved.svg)
Movimos la estructura un poco de modo que ahora comienza en la dirección `0x10024`. Esto podría suceder, por ejemplo, cuando pasamos la estructura como un argumento a una función o la asignamos a otra variable de pila diferente. El problema es que el campo `element` aún apunta a la dirección `0x1001c` a pesar de que el último elemento del `array` vive ahora en `0x1002c`. Así, el puntero está colgando, con el resultado de que se produce un comportamiento indefinido en la próxima llamada a `poll`.
#### Posibles Soluciones
Hay tres enfoques fundamentales para resolver el problema del puntero colgante:
- **Actualizar el puntero al moverse**: La idea es actualizar el puntero interno cada vez que la estructura se mueve en memoria para que siga siendo válida después del movimiento. Desafortunadamente, este enfoque requeriría amplios cambios en Rust que resultarían en pérdidas de rendimiento potencialmente enormes. La razón es que necesitaríamos algún tipo de tiempo de ejecución que mantenga un seguimiento del tipo de todos los campos de la estructura y compruebe en cada operación de movimiento si se requiere una actualización de puntero.
- **Almacenar un desplazamiento en lugar de auto-referencias**: Para evitar la necesidad de actualizar punteros, el compilador podría intentar almacenar auto-referencias como desplazamientos desde el principio de la estructura. Por ejemplo, el campo `element` de la estructura `WaitingOnWriteState` anterior podría almacenarse en forma de un campo `element_offset` con un valor de 8 porque el elemento del array al que apunta comienza 8 bytes después de la estructura. Dado que el desplazamiento permanece igual cuando la estructura se mueve, no se requieren actualizaciones de campo.
El problema con este enfoque es que requiere que el compilador detecte todas las auto-referencias. Esto no es posible en tiempo de compilación porque el valor de una referencia puede depender de la entrada del usuario, por lo que necesitaríamos un sistema en tiempo de ejecución nuevamente para analizar referencias y crear correctamente las estructuras de estado. Esto no solo resultaría en costos de tiempo de ejecución, sino que también impediría ciertas optimizaciones del compilador, lo que provocaría grandes pérdidas de rendimiento nuevamente.
- **Prohibir mover la estructura**: Como vimos anteriormente, el puntero colgante solo ocurre cuando movemos la estructura en memoria. Al prohibir completamente las operaciones de movimiento en estructuras autorefenciales, el problema también se puede evitar. La gran ventaja de este enfoque es que se puede implementar a nivel de sistema de tipos sin costos adicionales de tiempo de ejecución. La desventaja es que recaerá sobre el programador lidiar con las operaciones de movimiento en las estructuras potencialmente autorefenciales.
Rust eligió la tercera solución por su principio de proporcionar _abstracciones de costo cero_, lo que significa que las abstracciones no deben imponer costos adicionales de tiempo de ejecución. La API de [_pinning_] fue propuesta para este propósito en [RFC 2349](https://github.com/rust-lang/rfcs/blob/master/text/2349-pin.md). A continuación, daremos un breve resumen de esta API y explicaremos cómo funciona con async/await y futuros.
#### Valores en el Heap
La primera observación es que los valores [asignados en el heap] ya tienen una dirección de memoria fija la mayoría de las veces. Se crean usando una llamada a `allocate` y luego se referencian mediante un tipo de puntero como `Box<T>`. Si bien es posible mover el tipo de puntero, el valor del heap al que apunta permanece en la misma dirección de memoria hasta que se libera a través de una llamada `deallocate`.
[heap-allocated]: @/edition-2/posts/10-heap-allocation/index.md
Usando la asignación en el heap, podemos intentar crear una estructura autorefencial:
```rust
fn main() {
let mut heap_value = Box::new(SelfReferential {
self_ptr: 0 as *const _,
});
let ptr = &*heap_value as *const SelfReferential;
heap_value.self_ptr = ptr;
println!("valor en el heap en: {:p}", heap_value);
println!("referencia interna: {:p}", heap_value.self_ptr);
}
struct SelfReferential {
self_ptr: *const Self,
}
```
([Pruébalo en el playground][playground-self-ref])
Creamos una estructura simple llamada `SelfReferential` que contiene un solo campo de puntero. Primero inicializamos esta estructura con un puntero nulo y luego la asignamos en el heap usando `Box::new`. Luego determinamos la dirección de la estructura asignada en el heap y la almacenamos en una variable `ptr`. Finalmente, hacemos que la estructura sea autorefencial al asignar la variable `ptr` al campo `self_ptr`.
Cuando ejecutamos este código [en el playground][playground-self-ref], vemos que la dirección del valor del heap y su puntero interno son iguales, lo que significa que el campo `self_ptr` es una referencia válida a sí misma. Dado que la variable `heap_value` es solo un puntero, moverla (por ejemplo, pasándola a una función) no cambia la dirección de la estructura en sí, por lo que el `self_ptr` sigue siendo válido incluso si se mueve el puntero.
Sin embargo, todavía hay una forma de romper este ejemplo: podemos salir de un `Box<T>` o reemplazar su contenido:
```rust
let stack_value = mem::replace(&mut *heap_value, SelfReferential {
self_ptr: 0 as *const _,
});
println!("valor en: {:p}", &stack_value);
println!("referencia interna: {:p}", stack_value.self_ptr);
```
([Pruébalo en el playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=e160ee8a64cba4cebc1c0473dcecb7c8))
Aquí usamos la función [`mem::replace`] para reemplazar el valor asignado en el heap con una nueva instancia de estructura. Esto nos permite mover el valor original `heap_value` a la pila, mientras que el campo `self_ptr` de la estructura es ahora un puntero colgante que aún apunta a la antigua dirección del heap. Cuando intentas ejecutar el ejemplo en el playground, verás que las líneas impresas _"valor en:"_ y _"referencia interna:"_ muestran punteros diferentes. Por lo tanto, la asignación de un valor en el heap no es suficiente para hacer que las auto-referencias sean seguras.
[`mem::replace`]: https://doc.rust-lang.org/nightly/core/mem/fn.replace.html
El problema fundamental que permitió que se produjera la ruptura anterior es que `Box<T>` permite obtener una referencia `&mut T` al valor asignado en el heap. Esta referencia `&mut` hace posible usar métodos como [`mem::replace`] o [`mem::swap`] para invalidar el valor asignado en el heap. Para resolver este problema, debemos prevenir que se creen referencias `&mut` en estructuras autorefenciales.
[`mem::swap`]: https://doc.rust-lang.org/nightly/core/mem/fn.swap.html
#### `Pin<Box<T>>` y `Unpin`
La API de pinning proporciona una solución al problema de `&mut T` en forma de los tipos envolventes [`Pin`] y el trait marcador [`Unpin`]. La idea detrás de estos tipos es limitar todos los métodos de `Pin` que se pueden usar para obtener referencias `&mut` al valor envuelto (por ejemplo, [`get_mut`][pin-get-mut] o [`deref_mut`][pin-deref-mut]) en el trait `Unpin`. El trait `Unpin` es un [_auto trait_], que se implementa automáticamente para todos los tipos excepto para aquellos que optan explícitamente por no hacerlo. Al hacer que las estructuras autorefenciales opten por no implementar `Unpin`, no hay forma (segura) de obtener un `&mut T` del tipo `Pin<Box<T>>` para ellas. Como resultado, se garantiza que todas las auto-referencias internas se mantendrán válidas.
[`Pin`]: https://doc.rust-lang.org/stable/core/pin/struct.Pin.html
[`Unpin`]: https://doc.rust-lang.org/nightly/std/marker/trait.Unpin.html
[pin-get-mut]: https://doc.rust-lang.org/nightly/core/pin/struct.Pin.html#method.get_mut
[pin-deref-mut]: https://doc.rust-lang.org/nightly/core/pin/struct.Pin.html#method.deref_mut
[_auto trait_]: https://doc.rust-lang.org/reference/special-types-and-traits.html#auto-traits
Como ejemplo, actualicemos el tipo `SelfReferential` de arriba para que no implemente `Unpin`:
```rust
use core::marker::PhantomPinned;
struct SelfReferential {
self_ptr: *const Self,
_pin: PhantomPinned,
}
```
Optamos por no implementar `Unpin` al añadir un segundo campo `_pin` de tipo [`PhantomPinned`]. Este tipo es un tipo de tamaño cero cuyo único propósito es _no_ implementar el trait `Unpin`. Debido a la forma en que funcionan los [auto traits][_auto trait_], un solo campo que no sea `Unpin` es suficiente para hacer que toda la estructura opta por no ser `Unpin`.
[`PhantomPinned`]: https://doc.rust-lang.org/nightly/core/marker/struct.PhantomPinned.html
El segundo paso es cambiar el tipo de `Box<SelfReferential>` en el ejemplo a un tipo `Pin<Box<SelfReferential>>`. La forma más fácil de hacer esto es usar la función [`Box::pin`] en lugar de [`Box::new`] para crear el valor asignado en el heap:
[`Box::pin`]: https://doc.rust-lang.org/nightly/alloc/boxed/struct.Box.html#method.pin
[`Box::new`]: https://doc.rust-lang.org/nightly/alloc/boxed/struct.Box.html#method.new
```rust
let mut heap_value = Box::pin(SelfReferential {
self_ptr: 0 as *const _,
_pin: PhantomPinned,
});
```
Además de cambiar `Box::new` a `Box::pin`, también necesitamos añadir el nuevo campo `_pin` en el inicializador de la estructura. Dado que `PhantomPinned` es un tipo de tamaño cero, solo necesitamos su nombre de tipo para inicializarlo.
Cuando [intentamos ejecutar nuestro ejemplo ajustado](https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=961b0db194bbe851ff4d0ed08d3bd98a) ahora, vemos que ya no funciona:
```
error[E0594]: cannot assign to data in a dereference of `std::pin::Pin<std::boxed::Box<SelfReferential>>`
--> src/main.rs:10:5
|
10 | heap_value.self_ptr = ptr;
| ^^^^^^^^^^^^^^^^^^^^^^^^^ cannot assign
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `std::pin::Pin<std::boxed::Box<SelfReferential>>`
error[E0596]: cannot borrow data in a dereference of `std::pin::Pin<std::boxed::Box<SelfReferential>>` as mutable
--> src/main.rs:16:36
|
16 | let stack_value = mem::replace(&mut *heap_value, SelfReferential {
| ^^^^^^^^^^^^^^^^ cannot borrow as mutable
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `std::pin::Pin<std::boxed::Box<SelfReferential>>`
```
Ambos errores ocurren porque el tipo `Pin<Box<SelfReferential>>` ya no implementa el trait `DerefMut`. Esto es exactamente lo que queremos porque el trait `DerefMut` devolvería una referencia `&mut`, que queremos prevenir. Esto solo ocurre porque ambos optamos por no implementar `Unpin` y cambiamos `Box::new` a `Box::pin`.
El problema que queda es que el compilador no solo previene mover el tipo en la línea 16, sino que también prohíbe inicializar el campo `self_ptr` en la línea 10. Esto ocurre porque el compilador no puede diferenciar entre los usos válidos e inválidos de `&mut` referencias. Para que la inicialización funcione nuevamente, debemos usar el método inseguro [`get_unchecked_mut`]:
[`get_unchecked_mut`]: https://doc.rust-lang.org/nightly/core/pin/struct.Pin.html#method.get_unchecked_mut
```rust
// seguro porque modificar un campo no mueve toda la estructura
unsafe {
let mut_ref = Pin::as_mut(&mut heap_value);
Pin::get_unchecked_mut(mut_ref).self_ptr = ptr;
}
```
La función [`get_unchecked_mut`] funciona en un `Pin<&mut T>` en lugar de un `Pin<Box<T>>`, así que debemos usar [`Pin::as_mut`] para convertir el valor. Luego podemos establecer el campo `self_ptr` utilizando la referencia `&mut` devuelta por `get_unchecked_mut`.
[`Pin::as_mut`]: https://doc.rust-lang.org/nightly/core/pin/struct.Pin.html#method.as_mut
Ahora el único error que queda es el error deseado en `mem::replace`. Recuerda, esta operación intenta mover el valor asignado en el heap a la pila, lo cual invalidaría la auto-referencia almacenada en el campo `self_ptr`. Al optar por no implementar `Unpin` y usar `Pin<Box<T>>`, podemos prevenir esta operación en tiempo de compilación y así trabajar de manera segura con estructuras auto-referenciales. Como vimos, el compilador no puede probar que la creación de la auto-referencia es segura (aún), así que necesitamos usar un bloque inseguro y verificar la corrección nosotros mismos.
#### Pinning en la Pila y `Pin<&mut T>`
En la sección anterior, aprendimos cómo usar `Pin<Box<T>>` para crear de manera segura un valor auto-referencial asignado en el heap. Si bien este enfoque funciona bien y es relativamente seguro (aparte de la construcción insegura), la asignación requerida en el heap conlleva un costo de rendimiento. Dado que Rust se esfuerza por proporcionar _abstracciones de costo cero_ siempre que sea posible, la API de pinning también permite crear instancias de `Pin<&mut T>` que apuntan a valores asignados en la pila.
A diferencia de las instancias de `Pin<Box<T>>`, que tienen _propiedad_ del valor envuelto, las instancias de `Pin<&mut T>` solo toman prestado temporalmente el valor envuelto. Esto complica un poco las cosas, ya que requiere que el programador garantice condiciones adicionales por sí mismo. Lo más importante es que un `Pin<&mut T>` debe permanecer pinado durante toda la vida útil de `T` referenciado, lo que puede ser difícil de verificar para variables basadas en la pila. Para ayudar con esto, existen crates como [`pin-utils`], pero aún así no recomendaría pinning en la pila a menos que sepas exactamente lo que estás haciendo.
[`pin-utils`]: https://docs.rs/pin-utils/0.1.0-alpha.4/pin_utils/
Para una lectura más profunda, consulta la documentación del [`módulo pin`] y el método [`Pin::new_unchecked`].
[`módulo pin`]: https://doc.rust-lang.org/nightly/core/pin/index.html
[`Pin::new_unchecked`]: https://doc.rust-lang.org/nightly/core/pin/struct.Pin.html#method.new_unchecked
#### Pinning y Futuros
Como ya vimos en esta publicación, el método [`Future::poll`] utiliza el pinning en forma de un parámetro `Pin<&mut Self>`:
[`Future::poll`]: https://doc.rust-lang.org/nightly/core/future/trait.Future.html#tymethod.poll
```rust
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>
```
La razón por la que este método toma `self: Pin<&mut Self>` en lugar del normal `&mut self` es que las instancias de futuros creadas a partir de async/await son a menudo auto-referenciales, como vimos [arriba][self-ref-async-await]. Al envolver `Self` en `Pin` y dejar que el compilador opte por no ser `Unpin` para futuros auto-referenciales generados a partir de async/await, se garantiza que los futuros no se muevan en memoria entre las llamadas a `poll`. Esto asegura que todas las referencias internas sigan siendo válidas.
[self-ref-async-await]: @/edition-2/posts/12-async-await/index.md#self-referential-structs
Vale la pena mencionar que mover futuros antes de la primera llamada a `poll` está bien. Esto es resultado del hecho de que los futuros son perezosos y no hacen nada hasta que se les realiza polling por primera vez. El estado inicial de las máquinas de estado generadas, por lo tanto, solo contiene los argumentos de función pero no referencias internas. Para poder llamar a `poll`, el llamador debe envolver el futuro en `Pin` primero, lo que asegura que el futuro no se pueda mover en memoria. Dado que el pinning en la pila es más difícil de hacer correctamente, recomiendo utilizar siempre [`Box::pin`] combinado con [`Pin::as_mut`] para esto.
[`futures`]: https://docs.rs/futures/0.3.4/futures/
En caso de que estés interesado en entender cómo implementar de manera segura una función combinadora de futuros utilizando pinning en la pila tú mismo, echa un vistazo al [código relativamente corto del método combinador `map`][map-src] del crate `futures` y la sección sobre [proyecciones y pinning estructural] de la documentación de pin.
[map-src]: https://docs.rs/futures-util/0.3.4/src/futures_util/future/future/map.rs.html
[proyecciones y pinning estructural]: https://doc.rust-lang.org/stable/std/pin/index.html#projections-and-structural-pinning
### Ejecutores y Wakers
Usando async/await, es posible trabajar con futuros de manera ergonómica y completamente asíncrona. Sin embargo, como aprendimos anteriormente, los futuros no hacen nada hasta que se les hace polling. Esto significa que tenemos que llamar a `poll` en ellos en algún momento, de lo contrario, el código asíncrono nunca se ejecuta.
Con un solo futuro, siempre podemos esperar cada futuro manualmente usando un bucle [como se describe arriba](#esperando-en-futuros). Sin embargo, este enfoque es muy ineficiente y no práctico para programas que crean un gran número de futuros. La solución más común a este problema es definir un _ejecutor_ global que sea responsable de hacer polling en todos los futuros en el sistema hasta que se completen.
#### Ejecutores
El propósito de un ejecutor es permitir ejecutar futuros como tareas independientes, típicamente a través de algún tipo de método `spawn`. Luego, el ejecutor es responsable de hacer polling en todos los futuros hasta que se completen. La gran ventaja de gestionar todos los futuros en un lugar central es que el ejecutor puede cambiar a un futuro diferente siempre que un futuro devuelva `Poll::Pending`. Así, las operaciones asíncronas se ejecutan en paralelo y la CPU se mantiene ocupada.
Muchas implementaciones de ejecutores también pueden aprovechar sistemas con múltiples núcleos de CPU. Crean un [pool de hilos] que es capaz de utilizar todos los núcleos si hay suficiente trabajo disponible y utilizan técnicas como [robo de trabajo] para equilibrar la carga entre núcleos. También hay implementaciones de ejecutor especiales para sistemas embebidos que optimizan para baja latencia y sobredimensionamiento de memoria.
[pool de hilos]: https://en.wikipedia.org/wiki/Thread_pool
[robo de trabajo]: https://en.wikipedia.org/wiki/Work_stealing
Para evitar la sobrecarga de hacer polling en futuros repetidamente, los ejecutores suelen aprovechar la API de _waker_ soportada por los futuros de Rust.
#### Wakers
La idea detrás de la API de waker es que un tipo especial [`Waker`] se pasa a cada invocación de `poll`, envuelto en el tipo [`Context`]. Este tipo `Waker` es creado por el ejecutor y puede ser utilizado por la tarea asíncrona para señalan su (o una parte de su) finalización. Como resultado, el ejecutor no necesita llamar a `poll` en un futuro que anteriormente devolvió `Poll::Pending` hasta que recibe la notificación de waker correspondiente.
[`Context`]: https://doc.rust-lang.org/nightly/core/task/struct.Context.html
Esto se ilustra mejor con un pequeño ejemplo:
```rust
async fn write_file() {
async_write_file("foo.txt", "Hello").await;
}
```
Esta función escribe asíncronamente la cadena "Hello" en un archivo `foo.txt`. Dado que las escrituras en el disco duro toman algo de tiempo, la primera llamada a `poll` en este futuro probablemente devolverá `Poll::Pending`. Sin embargo, el controlador del disco duro almacenará internamente el `Waker` pasado a la llamada `poll` y lo utilizará para notificar al ejecutor cuando el archivo se haya escrito en el disco. De esta manera, el ejecutor no necesita perder tiempo tratando de `poll` el futuro nuevamente antes de recibir la notificación del waker.
Veremos cómo funciona el tipo `Waker` en detalle cuando creemos nuestro propio ejecutor con soporte de waker en la sección de implementación de esta publicación.
### ¿Multitasking Cooperativo?
Al principio de esta publicación, hablamos sobre el multitasking preemptivo y cooperativo. Mientras que el multitasking preemptivo depende del sistema operativo para cambiar forzosamente entre tareas en ejecución, el multitasking cooperativo requiere que las tareas cedan voluntariamente el control de la CPU a través de una operación _yield_ regularmente. La gran ventaja del enfoque cooperativo es que las tareas pueden guardar su estado ellas mismas, lo que resulta en cambios de contexto más eficientes y hace posible compartir la misma pila de llamadas entre las tareas.
Puede que no sea evidente de inmediato, pero los futuros y async/await son una implementación del patrón de multitasking cooperativo:
- Cada futuro que se añade al ejecutor es básicamente una tarea cooperativa.
- En lugar de usar una operación yield explícita, los futuros ceden el control del núcleo de CPU al devolver `Poll::Pending` (o `Poll::Ready` al final).
- No hay nada que fuerce a los futuros a ceder la CPU. Si quieren, pueden nunca regresar de `poll`, por ejemplo, girando eternamente en un bucle.
- Dado que cada futuro puede bloquear la ejecución de otros futuros en el ejecutor, necesitamos confiar en que no sean maliciosos.
- Internamente, los futuros almacenan todo el estado que necesitan para continuar la ejecución en la siguiente llamada `poll`. Con async/await, el compilador detecta automáticamente todas las variables que se necesitan y las almacena dentro de la máquina de estado generada.
- Solo se guarda el estado mínimo requerido para la continuación.
- Dado que el método `poll` cede la pila de llamadas cuando retorna, se puede usar la misma pila para pollear otros futuros.
Vemos que los futuros y async/await encajan perfectamente en el patrón de multitasking cooperativo; solo utilizan algunos términos diferentes. En lo sucesivo, por lo tanto, utilizaremos los términos "tarea" y "futuro" indistintamente.
## Implementación
Ahora que entendemos cómo funciona el multitasking cooperativo basado en futuros y async/await en Rust, es hora de agregar soporte para ello a nuestro núcleo. Dado que el trait [`Future`] es parte de la biblioteca `core` y async/await es una característica del propio lenguaje, no hay nada especial que debamos hacer para usarlo en nuestro núcleo `#![no_std]`. El único requisito es que usemos como mínimo nightly `2020-03-25` de Rust porque async/await no era compatible con `no_std` antes.
Con una versión nightly suficientemente reciente, podemos comenzar a usar async/await en nuestro `main.rs`:
```rust
// en src/main.rs
async fn async_number() -> u32 {
42
}
async fn example_task() {
let number = async_number().await;
println!("número asíncrono: {}", number);
}
```
La función `async_number` es una `async fn`, así que el compilador la transforma en una máquina de estado que implementa `Future`. Dado que la función solo devuelve `42`, el futuro resultante devolverá directamente `Poll::Ready(42)` en la primera llamada `poll`. Al igual que `async_number`, la función `example_task` también es una `async fn`. Espera el número devuelto por `async_number` y luego lo imprime usando el macro `println`.
Para ejecutar el futuro devuelto por `example_task`, necesitamos llamar a `poll` en él hasta que señale su finalización devolviendo `Poll::Ready`. Para hacer esto, necesitamos crear un tipo de ejecutor simple.
### Tarea
Antes de comenzar la implementación del ejecutor, creamos un nuevo módulo `task` con un tipo `Task`:
```rust
// en src/lib.rs
pub mod task;
```
```rust
// en src/task/mod.rs
use core::{future::Future, pin::Pin};
use alloc::boxed::Box;
pub struct Task {
future: Pin<Box<dyn Future<Output = ()>>>,
}
```
La estructura `Task` es un envoltorio nuevo alrededor de un futuro pinzado, asignado en el heap y de despacho dinámico con el tipo vacío `()` como salida. Revisemos esto en detalle:
- Requerimos que el futuro asociado con una tarea devuelva `()`. Esto significa que las tareas no devuelven ningún resultado, simplemente se ejecutan por sus efectos secundarios. Por ejemplo, la función `example_task` que definimos arriba no tiene valor de retorno, pero imprime algo en pantalla como efecto secundario.
- La palabra clave `dyn` indica que almacenamos un [_trait object_] en el `Box`. Esto significa que los métodos en el futuro son [_despachados dinámicamente_], permitiendo que diferentes tipos de futuros se almacenen en el tipo `Task`. Esto es importante porque cada `async fn` tiene su propio tipo y queremos ser capaces de crear múltiples tareas diferentes.
- Como aprendimos en la [sección sobre pinning], el tipo `Pin<Box>` asegura que un valor no puede moverse en memoria al colocarlo en el heap y prevenir la creación de referencias `&mut` a él. Esto es importante porque los futuros generados por async/await podrían ser auto-referenciales, es decir, contener punteros a sí mismos que se invalidarían cuando el futuro se moviera.
[_trait object_]: https://doc.rust-lang.org/book/ch17-02-trait-objects.html
[_despachados dinámicamente_]: https://doc.rust-lang.org/book/ch17-02-trait-objects.html#trait-objects-perform-dynamic-dispatch
[sección sobre pinning]: #pinning
Para permitir la creación de nuevas estructuras `Task` a partir de futuros, creamos una función `new`:
```rust
// en src/task/mod.rs
impl Task {
pub fn new(future: impl Future<Output = ()> + 'static) -> Task {
Task {
future: Box::pin(future),
}
}
}
```
La función toma un futuro arbitrario con un tipo de salida de `()` y lo pinza en memoria a través de la función [`Box::pin`]. Luego envuelve el futuro en la estructura `Task` y la devuelve. Se requiere el tiempo de vida `'static` aquí porque el `Task` devuelto puede vivir por un tiempo arbitrario, por lo que el futuro también debe ser válido durante ese tiempo.
#### Poll
También añadimos un método `poll` para permitir al ejecutor hacer polling en el futuro almacenado:
```rust
// en src/task/mod.rs
use core::task::{Context, Poll};
impl Task {
fn poll(&mut self, context: &mut Context) -> Poll<()> {
self.future.as_mut().poll(context)
}
}
```
Dado que el método [`poll`] del trait `Future` espera ser llamado sobre un tipo `Pin<&mut T>`, usamos el método [`Pin::as_mut`] para convertir el campo `self.future` del tipo `Pin<Box<T>>` primero. Luego llamamos a `poll` en el campo `self.future` convertido y devolvemos el resultado. Como el método `Task::poll` debería ser llamado solo por el ejecutor que crearemos en un momento, mantenemos la función privada.
### Ejecutor simple
Dado que los ejecutores pueden ser bastante complejos, comenzamos deliberadamente creando un ejecutor muy básico antes de implementar un ejecutor más completo más adelante. Para ello, primero creamos un nuevo submódulo `task::simple_executor`:
```rust
// en src/task/mod.rs
pub mod simple_executor;
```
```rust
// en src/task/simple_executor.rs
use super::Task;
use alloc::collections::VecDeque;
pub struct SimpleExecutor {
task_queue: VecDeque<Task>,
}
impl SimpleExecutor {
pub fn new() -> SimpleExecutor {
SimpleExecutor {
task_queue: VecDeque::new(),
}
}
pub fn spawn(&mut self, task: Task) {
self.task_queue.push_back(task)
}
}
```
La estructura contiene un solo campo `task_queue` de tipo [`VecDeque`], que es básicamente un vector que permite operaciones de push y pop en ambos extremos. La idea detrás de usar este tipo es que insertamos nuevas tareas a través del método `spawn` al final y extraemos la siguiente tarea para ejecutar desde el frente. De esta manera, obtenemos una simple [cola FIFO] (_"primero en entrar, primero en salir"_).
[`VecDeque`]: https://doc.rust-lang.org/stable/alloc/collections/vec_deque/struct.VecDeque.html
[cola FIFO]: https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)
#### Waker Inútil
Para llamar al método `poll`, necesitamos crear un tipo [`Context`], que envuelve un tipo [`Waker`]. Para comenzar de manera simple, primero crearemos un waker inútil que no hace nada. Para ello, creamos una instancia de [`RawWaker`], la cual define la implementación de los diferentes métodos `Waker`, y luego usamos la función [`Waker::from_raw`] para convertirlo en un `Waker`:
[`RawWaker`]: https://doc.rust-lang.org/stable/core/task/struct.RawWaker.html
[`Waker::from_raw`]: https://doc.rust-lang.org/stable/core/task/struct.Waker.html#method.from_raw
```rust
// en src/task/simple_executor.rs
use core::task::{Waker, RawWaker};
fn dummy_raw_waker() -> RawWaker {
todo!();
}
fn dummy_waker() -> Waker {
unsafe { Waker::from_raw(dummy_raw_waker()) }
}
```
La función `from_raw` es insegura porque se puede producir un comportamiento indefinido si el programador no cumple con los requisitos documentados de `RawWaker`. Antes de que veamos la implementación de la función `dummy_raw_waker`, primero intentemos entender cómo funciona el tipo `RawWaker`.
##### `RawWaker`
El tipo [`RawWaker`] requiere que el programador defina explícitamente un [_tabla de métodos virtuales_] (_vtable_) que especifica las funciones que deben ser llamadas cuando `RawWaker` se clona, se despierta o se elimina. La disposición de esta vtable es definida por el tipo [`RawWakerVTable`]. Cada función recibe un argumento `*const ()`, que es un puntero _sin tipo_ a algún valor. La razón por la que se utiliza un puntero `*const ()` en lugar de una referencia apropiada es que el tipo `RawWaker` debería ser no genérico pero aún así soportar tipos arbitrarios. El puntero se proporciona colocando `data` en la llamada a [`RawWaker::new`], que simplemente inicializa un `RawWaker`. Luego, el `Waker` utiliza este `RawWaker` para llamar a las funciones de la vtable con `data`.
[_tabla de métodos virtuales_]: https://es.wikipedia.org/wiki/Tabla_de_metodos_virtuales
[`RawWakerVTable`]: https://doc.rust-lang.org/stable/core/task/struct.RawWakerVTable.html
[`RawWaker::new`]: https://doc.rust-lang.org/stable/core/task/struct.RawWaker.html#method.new
Típicamente, el `RawWaker` se crea para alguna estructura asignada en el heap que está envuelta en el tipo [`Box`] o [`Arc`]. Para tales tipos, pueden usarse métodos como [`Box::into_raw`] para convertir el `Box<T>` en un puntero `*const T`. Este puntero puede luego ser convertido a un puntero anónimo `*const ()` y pasado a `RawWaker::new`. Dado que cada función de vtable recibe el mismo `*const ()` como argumento, las funciones pueden convertir de forma segura el puntero de regreso a un `Box<T>` o un `&T` para operar en él. Como puedes imaginar, este proceso es extremadamente peligroso y puede llevar fácilmente a un comportamiento indefinido en caso de errores. Por esta razón, no se recomienda crear manualmente un `RawWaker` a menos que sea absolutamente necesario.
[`Box`]: https://doc.rust-lang.org/stable/alloc/boxed/struct.Box.html
[`Arc`]: https://doc.rust-lang.org/stable/alloc/sync/struct.Arc.html
[`Box::into_raw`]: https://doc.rust-lang.org/stable/alloc/boxed/struct.Box.html#method.into_raw
##### Un `RawWaker` Inútil
Como crear manualmente un `RawWaker` no es recomendable, hay un camino seguro para crear un `Waker` inútil que no haga nada. Afortunadamente, el hecho de que queramos no hacer nada hace que sea relativamente seguro implementar la función `dummy_raw_waker`:
```rust
// en src/task/simple_executor.rs
use core::task::RawWakerVTable;
fn dummy_raw_waker() ->

View File

@@ -0,0 +1,7 @@
+++
title = "Posts"
sort_by = "weight"
insert_anchor_links = "left"
render = false
page_template = "edition-2/page.html"
+++