En este artículo, descubriremos cómo implementar el soporte de memoria de página en nuestro núcleo. Primero, estudiaremos varios métodos para que los marcos de la tabla de páginas físicas estén disponibles para el núcleo, y discutiremos sus ventajas y desventajas. Luego implementamos la función de traducción de direcciones y la función de crear una nueva asignación.
Esta serie de artículos publicados en
GitHub . Si tiene alguna pregunta o problema, abra el ticket correspondiente allí. Todas las fuentes para el artículo están
en este hilo .
¿Otro artículo sobre paginación?
Si sigue este ciclo, vio el artículo "Memoria de página: Nivel avanzado" a fines de enero. Pero me criticaron por las tablas de páginas recursivas. Por lo tanto, decidí volver a escribir el artículo, usando un enfoque diferente para acceder a los marcos.Aquí hay una nueva opción. El artículo todavía explica cómo funcionan las tablas de páginas recursivas, pero utilizamos una implementación más simple y poderosa. No eliminaremos el artículo anterior, pero lo marcaremos como obsoleto y no lo actualizaremos.
¡Espero que disfrutes la nueva opción!Contenido
Introduccion
Desde el
último artículo, aprendimos sobre los principios de la memoria de paginación y cómo funcionan las tablas de páginas de cuatro niveles en
x86_64
. También encontramos que el cargador ya configuró la jerarquía de la tabla de páginas para nuestro núcleo, por lo que el núcleo se ejecuta en direcciones virtuales. Esto aumenta la seguridad porque el acceso no autorizado a la memoria provoca un error de página en lugar de cambiar aleatoriamente la memoria física.
El artículo terminó sin poder acceder a las tablas de páginas de nuestro núcleo, porque están almacenadas en la memoria física y el núcleo ya se está ejecutando en direcciones virtuales. Aquí continuamos con el tema y exploramos diferentes opciones para acceder a los marcos de la tabla de páginas desde el núcleo. Discutiremos las ventajas y desventajas de cada uno de ellos, y luego elegiremos la opción adecuada para nuestro núcleo.
Se requiere soporte para el cargador de arranque, por lo que lo configuraremos primero. Luego implementamos una función que se ejecuta en toda la jerarquía de las tablas de páginas para traducir las direcciones virtuales en físicas. Finalmente, aprenderemos cómo crear nuevas asignaciones en tablas de páginas y cómo encontrar marcos de memoria no utilizados para crear nuevas tablas.
Actualizaciones de dependencia
Este artículo requiere que registre la versión 0.4.0 o superior del
bootloader
y la versión 0.5.2 o superior
x86_64
en las dependencias. Puede actualizar las dependencias en
Cargo.toml
:
[dependencies] bootloader = "0.4.0" x86_64 = "0.5.2"
Para ver los cambios en estas versiones, consulte
el registro del cargador de arranque y el
registro x86_64 .
Acceso a tablas de páginas.
Acceder a las tablas de páginas desde el núcleo no es tan fácil como parece. Para comprender el problema, eche otro vistazo a la jerarquía de tablas de cuatro niveles del artículo anterior:
Lo importante es que cada entrada de página almacena la dirección
física de la siguiente tabla. Esto evita la traducción de estas direcciones, lo que reduce el rendimiento y conduce fácilmente a bucles sin fin.
El problema es que no podemos acceder directamente a las direcciones físicas desde el núcleo, ya que también funciona en direcciones virtuales. Por ejemplo, cuando vamos a la dirección
4 KiB
, tenemos acceso a la dirección
virtual 4 KiB
, y no a la dirección
física donde se almacena la tabla de páginas del 4to nivel. Si queremos acceder a la dirección física de
4 KiB
, entonces necesitamos usar alguna dirección virtual, que se traduce en ella.
Por lo tanto, para acceder a los marcos de las tablas de páginas, debe asignar algunas páginas virtuales a estos marcos. Hay diferentes formas de crear tales asignaciones.
Mapeo de identidad
Una solución simple es la
visualización idéntica de todas las tablas de páginas .
En este ejemplo, vemos la visualización idéntica de cuadros. Las direcciones físicas de las tablas de páginas son al mismo tiempo direcciones virtuales válidas, por lo que podemos acceder fácilmente a las tablas de páginas de todos los niveles, comenzando con el registro CR3.
Sin embargo, este enfoque satura el espacio de direcciones virtuales y hace que sea difícil encontrar grandes áreas contiguas de memoria libre. Digamos que queremos crear un área de memoria virtual de 1000 KiB en la figura anterior, por ejemplo, para
mostrar un archivo en la memoria . No podemos comenzar con la región
28 KiB
, porque descansa en una página ya ocupada en
1004 KiB
. Por lo tanto, tendrá que buscar más hasta encontrar un fragmento grande adecuado, por ejemplo, con
1008 KiB
. Existe el mismo problema de fragmentación que en la memoria segmentada.
Además, la creación de nuevas tablas de páginas es mucho más complicada, ya que necesitamos encontrar marcos físicos cuyas páginas correspondientes aún no se utilizan. Por ejemplo, para nuestro archivo, reservamos un área de 1000 KiB de memoria
virtual , comenzando en la dirección
1008 KiB
. Ahora ya no podemos usar ningún marco con una dirección física entre
1000 KiB
y
2008 KiB
, porque no se puede mostrar de forma idéntica.
Mapa de desplazamiento fijo
Para evitar abarrotar el espacio de direcciones virtuales, puede mostrar las tablas de páginas en un
área de memoria separada . Por lo tanto, en lugar de identificar el mapeo, mapeamos cuadros con un desplazamiento fijo en el espacio de direcciones virtuales. Por ejemplo, el desplazamiento puede ser 10 TiB:

Al asignar este rango de memoria virtual únicamente para mostrar tablas de páginas, evitamos los problemas de visualización idéntica. Reservar un área tan grande de espacio de direcciones virtuales solo es posible si el espacio de direcciones virtuales es mucho mayor que el tamaño de la memoria física. En
x86_64
esto no es un problema porque el espacio de direcciones de 48 bits es de 256 TiB.
Pero este enfoque tiene la desventaja de que al crear cada tabla de páginas, debe crear una nueva asignación. Además, no permite el acceso a tablas en otros espacios de direcciones, lo que sería útil al crear un nuevo proceso.
Mapeo completo de memoria física
Podemos resolver estos problemas
mostrando toda la memoria física , y no solo los cuadros de la tabla de páginas:

Este enfoque permite que el núcleo acceda a la memoria física arbitraria, incluidos los cuadros de la tabla de páginas de otros espacios de direcciones. Se reserva un rango de memoria virtual del mismo tamaño que antes, pero solo no quedan páginas inigualables.
La desventaja de este enfoque es que se necesitan tablas de páginas adicionales para mostrar la memoria física. Estas tablas de páginas deben almacenarse en algún lugar, por lo que utilizan parte de la memoria física, lo que puede ser un problema en dispositivos con una pequeña cantidad de RAM.
Sin embargo, en x86_64 podemos usar
enormes páginas de 2 MiB para mostrar en lugar del tamaño predeterminado de 4 KiB. Por lo tanto, para mostrar 32 GiB de memoria física, solo se requieren 132 KiB por tabla de páginas: solo una tabla de tercer nivel y 32 tablas de segundo nivel. Las páginas enormes también se almacenan en caché de manera más eficiente porque usan menos entradas en el búfer de traducción dinámica (TLB).
Exhibición temporal
Para dispositivos con muy poca memoria física, solo puede
mostrar tablas de páginas temporalmente cuando necesita acceder a ellas. Para comparaciones temporales, se requiere una visualización idéntica de solo la tabla de primer nivel:
En esta figura, una tabla de nivel 1 gestiona los primeros 2 MiB de espacio de direcciones virtuales. Esto es posible porque el acceso desde el registro CR3 es a través de cero entradas en las tablas de los niveles 4, 3 y 2. El registro con el índice
8
traduce la página virtual a
32 KiB
en un marco físico a
32 KiB
, identificando así la tabla del nivel 1. En la figura, esto se muestra con una flecha horizontal.
Al escribir en la tabla de nivel 1 asignada de forma idéntica, nuestro núcleo puede crear hasta 511 comparaciones de tiempo (512 menos el registro necesario para el mapeo de identidad). En el ejemplo anterior, el núcleo crea dos comparaciones de tiempo:
- Asignación de una entrada nula en una tabla de nivel 1 a un marco a
24 KiB
. Esto crea un mapeo temporal de la página virtual en 0 KiB
al marco físico de la tabla de nivel de página 2 indicado por la flecha punteada. - Coincide con el noveno registro de una tabla de nivel 1 con un marco a
4 KiB
. Esto crea un mapeo temporal de la página virtual a 36 KiB
al marco físico de la tabla de nivel 4 de página indicado por la flecha punteada.
Ahora el núcleo puede acceder a una tabla de nivel 2 escribiendo en una página que comienza en
0 KiB
y a una tabla de nivel 4 escribiendo en una página que comienza en
33 KiB
.
Por lo tanto, el acceso a un marco arbitrario de la tabla de páginas con asignaciones temporales consta de las siguientes acciones:
- Encuentre una entrada gratuita en la tabla de nivel 1 que se muestra idénticamente.
- Asigne esta entrada al marco físico de la tabla de páginas a la que queremos acceder.
- Acceda a este marco a través de la página virtual asociada con la entrada.
- Vuelva a establecer el registro como no utilizado, eliminando así la asignación temporal.
Con este enfoque, el espacio de direcciones virtuales permanece limpio, ya que las mismas 512 páginas virtuales se usan constantemente. La desventaja es algo engorroso, especialmente porque una nueva comparación puede requerir cambiar varios niveles de la tabla, es decir, necesitamos repetir el proceso descrito varias veces.
Tablas de página recursivas
Otro enfoque interesante que no requiere tablas de páginas adicionales es la
coincidencia recursiva .
La idea es traducir algunos registros de la tabla de cuarto nivel en ella misma. Por lo tanto, en realidad reservamos una parte del espacio de direcciones virtuales y asignamos todos los marcos de tabla actuales y futuros a este espacio.
Veamos un ejemplo para entender cómo funciona todo esto:
La única diferencia con el ejemplo al principio del artículo es un registro adicional con el índice
511
en la tabla de nivel 4, que se asigna al marco físico
4 KiB
, que se encuentra en esta tabla.
Cuando la CPU pasa a este registro, no se refiere a la tabla de nivel 3, sino que nuevamente se refiere a la tabla de nivel 4. Esto es similar a una función recursiva que se llama a sí misma. Es importante que el procesador asuma 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 en x86_64 tienen la misma estructura.
Al seguir un registro recursivo una o más veces antes de comenzar la conversión real, podemos reducir efectivamente el número de niveles por los que pasa el procesador. Por ejemplo, si seguimos el registro recursivo una vez, y luego vamos a la tabla de nivel 3, el procesador piensa que la tabla de nivel 3 es una tabla de nivel 2. Continuando, considera la tabla de nivel 2 como una tabla de nivel 1, y la tabla de nivel 1 como asignada marco en la memoria física. Esto significa que ahora podemos leer y escribir en la tabla de nivel de página 1 porque el procesador piensa que este es un marco mapeado. La siguiente figura muestra los cinco pasos de dicha traducción:
Del mismo modo, podemos seguir una entrada recursiva dos veces antes de comenzar la conversión para reducir el número de niveles pasados a dos:
Veamos este procedimiento paso a paso. Primero, la CPU sigue una entrada recursiva en la tabla de nivel 4 y piensa que ha alcanzado la tabla de nivel 3. Luego sigue el registro recursivo nuevamente y piensa que ha alcanzado el nivel 2. Pero en realidad todavía está en el nivel 4. Luego la CPU va a la nueva dirección y entra en la tabla de nivel 3, pero cree que ya está en la tabla de nivel 1. Finalmente, en el siguiente punto de entrada en la tabla de nivel 2, el procesador cree que ha accedido al marco de memoria física. Esto nos permite leer y escribir en una tabla de nivel 2.
También se accede a las tablas de los niveles 3 y 4. Para acceder a la tabla del nivel 3, seguimos un registro recursivo tres veces: el procesador cree que ya está en la tabla del nivel 1, y en el siguiente paso llegamos al nivel 3, que la CPU considera como un marco mapeado. Para acceder a la tabla de nivel 4, simplemente seguimos el registro recursivo cuatro veces hasta que el procesador procese la tabla de nivel 4 como un marco mapeado (en azul en la figura a continuación).
El concepto es difícil de entender al principio, pero en la práctica funciona bastante bien.
Cálculo de dirección
Por lo tanto, podemos acceder a las tablas de todos los niveles siguiendo un registro recursivo una o más veces. Dado que los índices en tablas de cuatro niveles se derivan directamente de la dirección virtual, se deben crear direcciones virtuales especiales para este método. Como recordamos, los índices de la tabla de páginas se extraen de la dirección de la siguiente manera:
Supongamos que queremos acceder a una tabla de nivel 1 que muestra una página específica. Como aprendimos anteriormente, debe pasar por un registro recursivo una vez, y luego a través de los índices de los niveles 4º, 3º y 2º. Para hacer esto, movemos todos los bloques de direcciones un bloque a la derecha y establecemos el índice del registro recursivo en el lugar del índice inicial del nivel 4:
Para acceder a la tabla de nivel 2 de esta página, movemos todos los bloques de índice dos bloques a la derecha y establecemos el índice recursivo en el lugar de ambos bloques de origen: nivel 4 y nivel 3:
Para acceder a la tabla de nivel 3, hacemos lo mismo, solo nos desplazamos a la derecha con tres bloques de direcciones.
Finalmente, para acceder a la tabla de nivel 4, mueva todo cuatro bloques a la derecha.
Ahora puede calcular direcciones virtuales para tablas de páginas de los cuatro niveles. Incluso podemos calcular una dirección que apunta exactamente a una entrada específica de la tabla de páginas multiplicando su índice por 8, el tamaño de la entrada de la tabla de páginas.
La siguiente tabla muestra la estructura de direcciones para acceder a varios tipos de marcos:
Dirección virtual para | Estructura de direcciones ( octal ) |
---|
Pagina | 0o_SSSSSS_AAA_BBB_CCC_DDD_EEEE |
Entrada en la mesa de nivel 1 | 0o_SSSSSS_RRR_AAA_BBB_CCC_DDDD |
Entrada en una mesa de nivel 2 | 0o_SSSSSS_RRR_RRR_AAA_BBB_CCCC |
Entrada en una mesa de nivel 3 | 0o_SSSSSS_RRR_RRR_RRR_AAA_BBBB |
Entrada en la mesa del nivel 4 | 0o_SSSSSS_RRR_RRR_RRR_RRR_AAAA |
Aquí
es el índice de nivel 4,
es el nivel 3,
es el nivel 2 y
DDD
es el índice de nivel 1 para el cuadro visualizado,
EEEE
es su desplazamiento.
RRR
es el índice del registro recursivo. Un índice (tres dígitos) se convierte en un desplazamiento (cuatro dígitos) al multiplicar por 8 (el tamaño de la entrada de la tabla de páginas). Con este desplazamiento, la dirección resultante apunta directamente a la entrada de la tabla de páginas correspondiente.
SSSS
son bits de expansión del dígito firmado, es decir, son todas copias del bit 47. Este es un requisito especial para las direcciones válidas en la arquitectura x86_64, que discutimos en el
artículo anterior .
Las direcciones son
octales , ya que cada carácter octal representa tres bits, lo que le permite separar claramente los índices de tablas de 9 bits en diferentes niveles. Esto no es posible en el sistema hexadecimal, donde cada carácter representa cuatro bits.
Código de óxido
Puede construir tales direcciones en código Rust utilizando operaciones bit a bit:
Este código supone que una asignación recursiva del último registro de nivel 4 con el índice
0o777
(511) coincide recursivamente. Actualmente, este no es el caso, por lo que el código aún no funcionará. Vea a continuación cómo decirle al cargador que configure una asignación recursiva.
Como alternativa a la realización manual de operaciones bit a bit, puede usar el tipo
RecursivePageTable
de la caja
x86_64
, que proporciona abstracciones seguras para diversas operaciones de tabla. Por ejemplo, el siguiente código muestra cómo convertir una dirección virtual a su dirección física correspondiente:
Nuevamente, este código requiere un mapeo recursivo correcto. Con esta asignación, el
level_4_table_addr
faltante
level_4_table_addr
calcula como en el primer ejemplo de código.
El mapeo recursivo es un método interesante que muestra cuán poderosa puede ser la correspondencia a través de una sola tabla. Es relativamente fácil de implementar y requiere una configuración mínima (solo una entrada recursiva), por lo que esta es una buena opción para los primeros experimentos.
Pero tiene algunas desventajas:
- Una gran cantidad de memoria virtual (512 GiB). Esto no es un problema en un gran espacio de direcciones de 48 bits, pero puede conducir a un comportamiento de caché subóptimo.
- Facilita el acceso solo al espacio de direcciones actualmente activo. El acceso a otros espacios de direcciones todavía es posible cambiando la entrada recursiva, pero se requiere una coincidencia temporal para el cambio. Describimos cómo hacer esto en un artículo anterior (obsoleto).
- Depende en gran medida del formato de tabla de páginas x86 y puede no funcionar en otras arquitecturas.
Soporte de arranque
Todos los enfoques descritos anteriormente requieren cambios en las tablas de página y la configuración correspondiente. Por ejemplo, para mapear la memoria física de forma idéntica o recursiva mapear registros de una tabla de cuarto nivel. El problema es que no podemos realizar esta configuración sin acceso a las tablas de páginas.
Entonces, necesito ayuda del gestor de arranque. Tiene acceso a las tablas de páginas, por lo que puede crear cualquier pantalla que necesitemos. En su implementación actual, la caja del
bootloader
admite los dos enfoques anteriores utilizando
funciones de carga :
- La función
map_physical_memory
mapea la memoria física completa en algún lugar del espacio de direcciones virtuales. Por lo tanto, el kernel obtiene acceso a toda la memoria física y puede aplicar un enfoque con la visualización de la memoria física completa .
- Usando la función
recursive_page_table
, el cargador muestra recursivamente una entrada de tabla de página de cuarto nivel. Esto permite que el núcleo funcione de acuerdo con el método descrito en la sección "Tablas de páginas recursivas" .
, , ( , ).
map_physical_memory
:
[dependencies] bootloader = { version = "0.4.0", features = ["map_physical_memory"]}
, . ,
.
bootloader
BootInfo , . , ,
semver . :
memory_map
physical_memory_offset
:
memory_map
. , , VGA. BIOS UEFI, . , . .
physical_memory_offset
. , . .
BootInfo
&'static BootInfo
_start
. :
, .
_start
, . , , .
, ,
bootloader
entry_point
. :
extern "C"
no_mangle
,
_start
.
kernel_main
Rust, . , , , , ,
, , . -, , . , , . , .
memory
:
src/memory.rs
.
, , ,
CR3
. :
active_level_4_table
:
4-
CR3
. ,
physical_memory_offset
. ,
*mut PageTable
as_mut_ptr
,
&mut PageTable
.
&mut
&
, .
unsafe, Rust
unsafe fn
. , . .
RFC Rust.
Ahora podemos usar esta función para generar los registros de la tabla de cuarto nivel:
physical_memory_offset
BootInfo
.
iter
enumerate
i
. , 512 .
, :

, . , , , .
, :
, , . , , .
, , . , .
, . , :
translate_addr_inner
, . , Rust
unsafe fn
. ,
unsafe
.
:
active_level_4_table
CR3
, . , .
VirtAddr
. ,
for
. , .
frame
, . . 1.
physical_memory_offset
.
PageTableEntry::frame
. ,
None
. 2 1 , .
, :
, :

,
0xb8000
. , , .
physical_memory_offset
0
, , . .
MappedPageTable
— ,
x86_64
. ,
translate_addr
, .
— , :
, .
x86_64
, :
MappedPageTable
RecursivePageTable
. , - (, ). , .
physical_memory_offset
, MappedPageTable. ,
init
memory
:
use x86_64::structures::paging::{PhysFrame, MapperAllSizes, MappedPageTable}; use x86_64::PhysAddr;
MappedPageTable
, .
impl Trait
. ,
RecursivePageTable
.
MappedPageTable::new
: 4
phys_to_virt
,
*mut PageTable
.
active_level_4_table
. ,
physical_memory_offset
.
active_level_4_table
,
init
.
MapperAllSizes::translate_addr
memory::translate_addr
,
kernel_main
:
, , :

,
physical_memory_offset
0x0
.
MappedPageTable
, . ,
map_to
, .
memory::translate_addr
, , .
, . .
map_to
Mapper
, . , : , ; , ;
frame_allocator
. , , .
create_example_mapping
—
create_example_mapping
,
0xb8000
, VGA. , , : , .
create_example_mapping
:
page
, ,
mapper
frame_allocator
.
mapper
Mapper<Size4KiB>
,
map_to
.
Size4KiB
,
Mapper
PageSize
, 4 , 2 1 . 4 ,
Mapper<Size4KiB>
MapperAllSizes
.
PRESENT
, ,
WRITABLE
, .
map_to
: ,
unsafe
. . « »
.
map_to
,
Result
. , ,
expect
.
MapperFlush
, (TLB)
flush
.
Result
, [
#[must_use]
]
, .
FrameAllocator
create_example_mapping
,
FrameAllocator
. , , . 1 , . , 3 , 3, 2 1.
, . ,
None
.
EmptyFrameAllocator
:
, . , , 1. , ,
0x1000
.
,
0x1000
, :
0x1000
,
create_example_mapping
mapper
frame_allocator
.
0x1000
VGA, , .
400
. , VGA
println
.
0x_f021_f077_f065_f04e
,
“New!” .
« VGA» , VGA ,
write_volatile
.
QEMU, :

0x1000
“New!” . , .
, 1
0x1000
. , 1,
map_to
,
EmptyFrameAllocator
. , ,
0xdeadbeaf000
0x1000
:
, :
panicked at 'map_to failed: FrameAllocationFailed', /…/result.rs:999:5
, 1,
FrameAllocator
. , ?
. :
frames
.
alloc
Iterator::next
.
BootInfoFrameAllocator
memory_map
,
BootInfo
.
« » , BIOS/UEFI. , .
MemoryRegion
, , (, , . .) . , ,
BootInfoFrameAllocator
.
BootInfoFrameAllocator
init_frame_allocator
:
MemoryMap
:
- -,
iter
MemoryRegion
. filter
. , , , (, ) , InUse
. , , Usable
- .
map
range Rust .
- :
into_iter
, 4096- step_by
. 4096 (= 4 ) — , . , . flat_map
map
, Iterator<Item = u64>
Iterator<Item = Iterator<Item = u64>>
.
PhysFrame
, Iterator<Item = PhysFrame>
. BootInfoFrameAllocator
.
kernel_main
,
BootInfoFrameAllocator
EmptyFrameAllocator
:
-
“New!” .
map_to
:
create_example_mapping
— , . .
Resumen
, , , . .
, .
bootloader
cargo.
&BootInfo
.
, ,
MappedPageTable
x86_64
. ,
FrameAllocator
, .
?
,
.