Nós escrevemos um sistema operacional no Rust. Implementando a memória da página (nova)

Neste artigo, descobriremos como implementar o suporte à memória da página em nosso núcleo. Primeiro, estudaremos vários métodos para que os quadros da tabela de páginas físicas fiquem disponíveis para o kernel e discutiremos suas vantagens e desvantagens. Em seguida, implementamos a função de conversão de endereço e a função de criar um novo mapeamento.

Esta série de artigos publicados no GitHub . Se você tiver alguma dúvida ou problema, abra o ticket correspondente lá. Todas as fontes para o artigo estão neste segmento .

Outro artigo sobre paginação?
Se você seguir esse ciclo, verá o artigo “Memória da página: nível avançado” no final de janeiro. Mas fui criticado por tabelas de páginas recursivas. Portanto, decidi reescrever o artigo, usando uma abordagem diferente para acessar quadros.

Aqui está uma nova opção. O artigo ainda explica como as tabelas de páginas recursivas funcionam, mas usamos uma implementação mais simples e poderosa. Não excluiremos o artigo anterior, mas marcaremos como desatualizado e não o atualizaremos.

Espero que você goste da nova opção!

Conteúdo



1. Introdução


No último artigo, aprendemos sobre os princípios da memória de paginação e como funcionam as tabelas de página de quatro níveis no x86_64 . Também descobrimos que o carregador já configurou a hierarquia da tabela de páginas para nosso kernel, portanto, o kernel é executado em endereços virtuais. Isso aumenta a segurança porque o acesso não autorizado à memória causa uma falha na página em vez de alterar aleatoriamente a memória física.

O artigo acabou não conseguindo acessar as tabelas de páginas do nosso kernel, porque elas são armazenadas na memória física e o kernel já está sendo executado em endereços virtuais. Aqui continuamos o tópico e exploramos diferentes opções para acessar os quadros da tabela de páginas no kernel. Discutiremos as vantagens e desvantagens de cada um deles e, em seguida, escolheremos a opção apropriada para nosso núcleo.

O suporte ao carregador de inicialização é necessário, portanto, vamos configurá-lo primeiro. Em seguida, implementamos uma função que percorre toda a hierarquia de tabelas de páginas, a fim de converter endereços virtuais em físicos. Por fim, aprenderemos como criar novos mapeamentos em tabelas de páginas e como encontrar quadros de memória não utilizados para criar novas tabelas.

Atualizações de dependência


Este artigo requer que você registre o bootloader versão 0.4.0 ou superior e x86_64 versão 0.5.2 ou superior nas dependências. Você pode atualizar as dependências no Cargo.toml :

 [dependencies] bootloader = "0.4.0" x86_64 = "0.5.2" 

Para alterações nessas versões, consulte o log do carregador de inicialização e o log x86_64 .

Acesso a tabelas de páginas


Acessar tabelas de páginas do kernel não é tão fácil quanto parece. Para entender o problema, dê uma olhada na hierarquia de tabela de quatro níveis do artigo anterior:



O importante é que cada entrada da página armazene o endereço físico da próxima tabela. Isso evita a tradução desses endereços, o que reduz o desempenho e leva facilmente a loops sem fim.

O problema é que não podemos acessar diretamente endereços físicos do kernel, pois ele também funciona em endereços virtuais. Por exemplo, quando vamos para o endereço 4 KiB , obtemos acesso ao endereço virtual 4 KiB , e não ao endereço físico onde a tabela de páginas do 4º nível está armazenada. Se queremos acessar o endereço físico de 4 KiB , precisamos usar um endereço virtual, que é traduzido para ele.

Portanto, para acessar os quadros das tabelas de páginas, é necessário mapear algumas páginas virtuais para esses quadros. Existem diferentes maneiras de criar esses mapeamentos.

Mapeamento de identidade


Uma solução simples é a exibição idêntica de todas as tabelas de páginas .



Neste exemplo, vemos a exibição idêntica de quadros. Os endereços físicos das tabelas de páginas são ao mesmo tempo endereços virtuais válidos, para que possamos acessar facilmente as tabelas de páginas de todos os níveis, começando com o registro CR3.

No entanto, essa abordagem confunde o espaço de endereço virtual e dificulta a descoberta de grandes áreas contíguas de memória livre. Digamos que queremos criar uma área de memória virtual de 1000 KiB na figura acima, por exemplo, para exibir um arquivo na memória . Não podemos começar com a região de 28 KiB , porque ela fica em uma página já ocupada em 1004 KiB . Portanto, você precisará procurar mais até encontrarmos um fragmento grande e adequado, por exemplo, com 1008 KiB . Há o mesmo problema de fragmentação da memória segmentada.

Além disso, a criação de novas tabelas de páginas é muito mais complicada, pois precisamos encontrar quadros físicos cujas páginas correspondentes ainda não foram usadas. Por exemplo, para o nosso arquivo, reservamos uma área de 1000 KiB de memória virtual , começando no endereço 1008 KiB . Agora não podemos mais usar nenhum quadro com um endereço físico entre 1000 KiB e 2008 KiB , porque ele não pode ser exibido de forma idêntica.

Mapa de deslocamento fixo


Para evitar sobrecarregar o espaço de endereço virtual, você pode exibir as tabelas de páginas em uma área de memória separada . Portanto, em vez de identificar o mapeamento, mapeamos quadros com um deslocamento fixo no espaço de endereço virtual. Por exemplo, o deslocamento pode ser 10 TiB:



Ao alocar esse intervalo de memória virtual exclusivamente para a exibição de tabelas de páginas, evitamos os problemas de exibição idêntica. A reserva de uma área tão grande do espaço de endereço virtual só é possível se o espaço de endereço virtual for muito maior que o tamanho da memória física. No x86_64 isso não é um problema, porque o espaço de endereço de 48 bits é de 256 TiB.

Mas essa abordagem tem a desvantagem de que, ao criar cada tabela de páginas, você precisa criar um novo mapeamento. Além disso, ele não permite o acesso a tabelas em outros espaços de endereço, o que seria útil ao criar um novo processo.

Mapeamento completo da memória física


Podemos resolver esses problemas exibindo toda a memória física , e não apenas os quadros da tabela de página:



Essa abordagem permite que o kernel acesse memória física arbitrária, incluindo quadros de tabela de página de outros espaços de endereço. Um intervalo de memória virtual está reservado com o mesmo tamanho de antes, mas somente não há páginas sem correspondência nele.

A desvantagem dessa abordagem é que são necessárias tabelas de páginas adicionais para exibir a memória física. Essas tabelas de páginas devem ser armazenadas em algum lugar, para que elas usem parte da memória física, o que pode ser um problema em dispositivos com uma pequena quantidade de RAM.

No entanto, no x86_64, podemos usar 2 páginas MiB enormes para exibir, em vez do tamanho padrão de 4 KiB. Portanto, para exibir 32 GiB de memória física, apenas 132 KiB por tabela de página são necessárias: apenas uma tabela de terceiro nível e 32 tabelas de segundo nível. Páginas enormes também são armazenadas em cache de maneira mais eficiente, porque usam menos entradas no buffer de tradução dinâmico (TLB).

Exibição temporária


Para dispositivos com muito pouca memória física, você só pode exibir tabelas de páginas temporariamente quando precisar acessá-las. Para comparações temporárias, é necessária uma exibição idêntica apenas da tabela de primeiro nível:



Nesta figura, uma tabela de nível 1 gerencia os 2 primeiros MiB de espaço de endereço virtual. Isso é possível porque o acesso a partir do registro CR3 é feito através de zero entradas nas tabelas dos níveis 4, 3 e 2. O registro com o índice 8 converte a página virtual em 32 KiB em um quadro físico em 32 KiB , identificando a própria tabela de nível 1. Na figura, isso é mostrado por uma seta horizontal.

Ao escrever na tabela de nível 1 mapeada de forma idêntica, nosso kernel pode criar até 511 comparações de tempo (512 menos o registro necessário para o mapeamento de identidade). No exemplo acima, o kernel cria duas comparações de tempo:

  • Mapeando uma entrada nula em uma tabela de nível 1 para um quadro em 24 KiB . Isso cria um mapeamento temporário da página virtual em 0 KiB para o quadro físico da tabela de nível 2 da página indicado pela seta pontilhada.
  • Combine o nono registro de uma tabela de nível 1 com um quadro em 4 KiB . Isso cria um mapeamento temporário da página virtual em 36 KiB para o quadro físico da tabela de nível 4 da página indicada pela seta pontilhada.

Agora o kernel pode acessar uma tabela de nível 2 gravando em uma página que começa em 0 KiB e uma tabela de nível 4 gravando em uma página que começa em 33 KiB .

Portanto, o acesso a um quadro arbitrário da tabela de páginas com mapeamentos temporários consiste nas seguintes ações:

  • Encontre uma entrada gratuita na tabela de nível 1 exibida de forma idêntica.
  • Mapeie essa entrada para o quadro físico da tabela de páginas que queremos acessar.
  • Acesse esse quadro através da página virtual associada à entrada.
  • Defina o registro novamente como não utilizado, removendo o mapeamento temporário.

Com essa abordagem, o espaço de endereço virtual permanece limpo, pois as mesmas 512 páginas virtuais são usadas constantemente. A desvantagem é uma certa dificuldade, especialmente porque uma nova comparação pode exigir a alteração de vários níveis da tabela, ou seja, precisamos repetir o processo descrito várias vezes.

Tabelas de páginas recursivas


Outra abordagem interessante que não requer tabelas de páginas adicionais é a correspondência recursiva .

A idéia é traduzir alguns registros da tabela de quarto nível para ela mesma. Assim, reservamos uma parte do espaço de endereço virtual e mapeamos todos os quadros de tabela atuais e futuros para esse espaço.

Vejamos um exemplo para entender como tudo isso funciona:



A única diferença do exemplo no início do artigo é um registro adicional com o índice 511 na tabela de nível 4, que é mapeado para o quadro físico 4 KiB , localizado nesta tabela em si.

Quando a CPU entra nesse registro, ela não se refere à tabela de nível 3, mas novamente à tabela de nível 4. Isso é semelhante a uma função recursiva que se chama. É importante que o processador assuma que cada entrada na tabela de nível 4 aponta para uma tabela de nível 3. Portanto, agora ela trata a tabela de nível 4 como uma tabela de nível 3. Isso funciona porque as tabelas de todos os níveis em x86_64 têm a mesma estrutura.

Seguindo um registro recursivo uma ou mais vezes antes de iniciar a conversão real, podemos reduzir efetivamente o número de níveis pelos quais o processador passa. Por exemplo, se seguirmos o registro recursivo uma vez e depois formos para a tabela de nível 3, o processador achará que a tabela de nível 3 é uma tabela de nível 2. Seguindo em frente, ele considera a tabela de nível 2 como uma tabela de nível 1 e a tabela de nível 1 como mapeada quadro na memória física. Isso significa que agora podemos ler e gravar na tabela de nível 1 da página, porque o processador pensa que esse é um quadro mapeado. A figura abaixo mostra as cinco etapas dessa tradução:



Da mesma forma, podemos seguir uma entrada recursiva duas vezes antes de iniciar a conversão para reduzir o número de níveis passados ​​para dois:



Vamos seguir este procedimento passo a passo. Primeiro, a CPU segue uma entrada recursiva na tabela de nível 4 e pensa que alcançou a tabela de nível 3. Em seguida, segue o registro recursivo novamente e pensa que alcançou o nível 2. Mas, na realidade, ainda está no nível 4. Em seguida, a CPU vai para o novo endereço e entra na tabela de nível 3, mas acha que já está na tabela de nível 1. Por fim, no próximo ponto de entrada da tabela de nível 2, o processador acha que acessou o quadro de memória física. Isso nos permite ler e gravar em uma tabela de nível 2.

Também são acessadas as tabelas dos níveis 3 e 4. Para acessar a tabela do nível 3, seguimos um registro recursivo três vezes: o processador pensa que já está na tabela do nível 1 e, na próxima etapa, atingimos o nível 3, que a CPU considera como um quadro mapeado. Para acessar a tabela de nível 4, basta seguir o registro recursivo quatro vezes até que o processador processe a tabela de nível 4 como um quadro mapeado (em azul na figura abaixo).



O conceito é difícil de entender a princípio, mas, na prática, funciona muito bem.

Cálculo de endereço


Assim, podemos acessar tabelas de todos os níveis seguindo um registro recursivo uma ou mais vezes. Como os índices em tabelas de quatro níveis são derivados diretamente do endereço virtual, endereços virtuais especiais devem ser criados para esse método. Como lembramos, os índices da tabela de páginas são extraídos do endereço da seguinte maneira:



Suponha que desejamos acessar uma tabela de nível 1 que exibe uma página específica. Como aprendemos acima, você precisa passar por um registro recursivo uma vez e depois pelos índices do 4º, 3º e 2º níveis. Para fazer isso, movemos todos os blocos de endereço um bloco para a direita e configuramos o índice do registro recursivo para o local do índice inicial do nível 4:



Para acessar a tabela de nível 2 desta página, movemos todos os blocos de índice dois blocos para a direita e configuramos o índice recursivo para o local dos dois blocos de origem: nível 4 e nível 3:



Para acessar a tabela de nível 3, fazemos o mesmo, apenas mudamos para a direita, já com três blocos de endereços.



Finalmente, para acessar a tabela de nível 4, mova todos os quatro blocos para a direita.



Agora você pode calcular endereços virtuais para tabelas de páginas dos quatro níveis. Podemos até calcular um endereço que aponte exatamente para uma entrada específica da tabela de páginas multiplicando seu índice por 8, o tamanho da entrada da tabela de páginas.

A tabela abaixo mostra a estrutura de endereços para acessar vários tipos de quadros:

Endereço virtual paraEstrutura de endereço ( octal )
Page0o_SSSSSS_AAA_BBB_CCC_DDD_EEEE
Entrada na tabela de nível 10o_SSSSSS_RRR_AAA_BBB_CCC_DDDD
Entrada em uma tabela de nível 20o_SSSSSS_RRR_RRR_AAA_BBB_CCCC
Entrada em uma tabela de nível 30o_SSSSSS_RRR_RRR_RRR_AAA_BBBB
Entrada na tabela de nível 40o_SSSSSS_RRR_RRR_RRR_RRR_AAAA

Aqui, é o índice de nível 4, é o nível 3, é o nível 2 e DDD é o índice de nível 1 para o quadro exibido, EEEE é o seu deslocamento. RRR é o índice do registro recursivo. Um índice (três dígitos) é convertido em um deslocamento (quatro dígitos) multiplicando por 8 (o tamanho da entrada da tabela da página). Com esse deslocamento, o endereço resultante aponta diretamente para a entrada da tabela de páginas correspondente.

SSSS são bits de expansão do dígito assinado, ou seja, são cópias do bit 47. Esse é um requisito especial para endereços válidos na arquitetura x86_64, que discutimos em um artigo anterior .

Os endereços são octais , pois cada caractere octal representa três bits, o que permite separar claramente os índices de 9 bits das tabelas em diferentes níveis. Isso não é possível no sistema hexadecimal, onde cada caractere representa quatro bits.

Código de ferrugem


Você pode construir esses endereços no código Rust usando operações bit a bit:

 // the virtual address whose corresponding page tables you want to access let addr: usize = […]; let r = 0o777; // recursive index let sign = 0o177777 << 48; // sign extension // retrieve the page table indices of the address that we want to translate let l4_idx = (addr >> 39) & 0o777; // level 4 index let l3_idx = (addr >> 30) & 0o777; // level 3 index let l2_idx = (addr >> 21) & 0o777; // level 2 index let l1_idx = (addr >> 12) & 0o777; // level 1 index let page_offset = addr & 0o7777; // calculate the table addresses 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); 

Esse código pressupõe que um mapeamento recursivo do último registro de nível 4 com o índice 0o777 (511) seja correspondido recursivamente. Atualmente não é o caso, portanto o código ainda não funcionará. Veja abaixo como instruir o carregador a configurar um mapeamento recursivo.

Como alternativa à execução manual de operações bit a bit, você pode usar o tipo RecursivePageTable da caixa x86_64 , que fornece abstrações seguras para várias operações da tabela. Por exemplo, o código abaixo mostra como converter um endereço virtual em seu endereço físico correspondente:

 // in src/memory.rs use x86_64::structures::paging::{Mapper, Page, PageTable, RecursivePageTable}; use x86_64::{VirtAddr, PhysAddr}; /// Creates a RecursivePageTable instance from the level 4 address. 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(); } /// Retrieve the physical address for the given virtual address let addr: u64 = […] let addr = VirtAddr::new(addr); let page: Page = Page::containing_address(addr); // perform the translation let frame = recursive_page_table.translate_page(page); frame.map(|frame| frame.start_address() + u64::from(addr.page_offset())) 

Novamente, esse código requer um mapeamento recursivo correto. Com esse mapeamento, o level_4_table_addr ausente level_4_table_addr calculado como no primeiro exemplo de código.



O mapeamento recursivo é um método interessante que mostra o quão poderosa pode ser a correspondência através de uma única tabela. É relativamente fácil de implementar e requer apenas configuração mínima (apenas uma entrada recursiva), portanto, essa é uma boa opção para as primeiras experiências.

Mas tem algumas desvantagens:

  • Uma grande quantidade de memória virtual (512 GiB). Isso não é um problema em um espaço de endereço grande de 48 bits, mas pode levar a um comportamento de cache abaixo do ideal.
  • Facilmente, dá acesso apenas ao espaço de endereço atualmente ativo. O acesso a outros espaços de endereço ainda é possível alterando a entrada recursiva, mas a correspondência temporária é necessária para a alternância. Descrevemos como fazer isso em um artigo anterior (obsoleto).
  • Depende muito do formato da tabela de páginas x86 e pode não funcionar em outras arquiteturas.

Suporte ao carregador de inicialização


Todas as abordagens descritas acima requerem alterações nas tabelas de páginas e nas configurações correspondentes. Por exemplo, para mapear a memória física de forma idêntica ou recursiva, mapeie registros de uma tabela de quarto nível. O problema é que não podemos fazer essas configurações sem acessar as tabelas de páginas.

Então, preciso de ajuda do gerenciador de inicialização. Ele tem acesso a tabelas de páginas, para que ele possa criar qualquer exibição necessária. Na sua implementação atual, o caixote do bootloader suporta as duas abordagens acima usando funções de carga :

  • A função map_physical_memory mapeia a memória física completa em algum lugar do espaço de endereço virtual. Assim, o kernel obtém acesso a toda a memória física e pode aplicar uma abordagem com a exibição da memória física completa .
  • Usando a função recursive_page_table , o carregador exibe recursivamente uma entrada da tabela de página de quarto nível. Isso permite que o kernel funcione de acordo com o método descrito na seção "Tabelas de páginas recursivas" .

Para o nosso kernel, escolhemos a primeira opção, porque é uma abordagem simples, independente de plataforma e mais poderosa (também dá acesso a outros quadros, não apenas a tabelas de páginas). Para suporte do gerenciador de inicialização, inclua a função em suas dependências map_physical_memory:

 [dependencies] bootloader = { version = "0.4.0", features = ["map_physical_memory"]} 

Se esse recurso estiver ativado, o carregador de inicialização mapeia a memória física completa para algum intervalo não utilizado de endereços virtuais. Para passar um intervalo de endereços virtuais para o kernel, o carregador de inicialização passa a estrutura das informações de inicialização .

Informações de inicialização


O engradado bootloaderdefine a estrutura do BootInfo com todas as informações passadas ao kernel. A estrutura ainda está sendo finalizada, portanto, pode haver algumas falhas ao atualizar para versões futuras incompatíveis com o semver . Atualmente, a estrutura possui dois campos: memory_mape physical_memory_offset:

  • O campo memory_mapfornece uma visão geral da memória física disponível. Ele informa ao kernel quanta memória física está disponível no sistema e quais áreas de memória estão reservadas para dispositivos como o VGA. Um cartão de memória pode ser solicitado no firmware do BIOS ou UEFI, mas apenas no início do processo de inicialização. Por esse motivo, o carregador deve fornecê-lo, porque o kernel não poderá mais receber essas informações. Um cartão de memória será útil mais adiante neste artigo.
  • physical_memory_offsetrelata o endereço inicial virtual do mapeamento de memória física. Adicionando esse deslocamento ao endereço físico, obtemos o endereço virtual correspondente. Isso dá acesso do kernel à memória física arbitrária.

O carregador passa a estrutura BootInfopara o kernel como um argumento &'static BootInfopara a função _start. Adicione:

 // in src/main.rs use bootloader::BootInfo; #[cfg(not(test))] #[no_mangle] pub extern "C" fn _start(boot_info: &'static BootInfo) -> ! { // new argument […] } 

É importante especificar o tipo de argumento correto, pois o compilador não conhece o tipo de assinatura correto da nossa função de ponto de entrada.

Macro do ponto de entrada


Como a função _starté chamada externamente a partir do carregador, a assinatura da função não é verificada. Isso significa que podemos aceitar argumentos arbitrários sem erros de compilação, mas isso trava ou causa um comportamento indefinido do tempo de execução.

Para garantir que a função do ponto de entrada sempre tenha a assinatura correta, a caixa bootloaderfornece uma macro entry_point. Reescrevemos nossa função usando esta macro:

 // in src/main.rs use bootloader::{BootInfo, entry_point}; entry_point!(kernel_main); #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] } 

Você não precisa mais usar o ponto de entrada extern "C"ou no_mangle, já que a macro define para nós o ponto de entrada real do nível inferior _start. A função kernel_mainagora se tornou uma função Rust completamente normal, para que possamos escolher um nome arbitrário para ela. O importante é que ele seja verificado por tipo; portanto, se você usar a assinatura errada, por exemplo, adicionando um argumento ou alterando seu tipo, ocorrerá um erro de compilação

Implementação


Agora temos acesso à memória física e podemos finalmente começar a implementação do sistema. Primeiro, considere as tabelas de páginas ativas atuais nas quais o kernel é executado. Na segunda etapa, crie uma função de conversão que retorne o endereço físico para o qual esse endereço virtual está mapeado. Na última etapa, tentaremos modificar as tabelas de páginas para criar um novo mapeamento.

Primeiro, crie um novo módulo no código memory:

 // in src/lib.rs pub mod memory; 

Para o módulo, crie um arquivo vazio src/memory.rs.

Acesso a tabelas de páginas


No final do artigo anterior, tentamos examinar a tabela de páginas em que o kernel funciona, mas não conseguimos acessar o quadro físico apontado pelo registro CR3. Agora podemos continuar trabalhando a partir deste local: a função active_level_4_tableretornará um link para a tabela ativa de páginas do quarto nível:

 // in src/memory.rs use x86_64::structures::paging::PageTable; /// Returns a mutable reference to the active level 4 table. /// /// This function is unsafe because the caller must guarantee that the /// complete physical memory is mapped to virtual memory at the passed /// `physical_memory_offset`. Also, this function must be only called once /// to avoid aliasing `&mut` references (which is undefined behavior). pub unsafe fn active_level_4_table(physical_memory_offset: u64) -> &'static mut PageTable { use x86_64::{registers::control::Cr3, VirtAddr}; let (level_4_table_frame, _) = Cr3::read(); let phys = level_4_table_frame.start_address(); let virt = VirtAddr::new(phys.as_u64() + physical_memory_offset); let page_table_ptr: *mut PageTable = virt.as_mut_ptr(); &mut *page_table_ptr // unsafe } 

Primeiro, lemos o quadro físico da tabela ativa do 4º nível no registro CR3. Em seguida, pegamos seu endereço físico inicial e o convertemos em um endereço virtual adicionando physical_memory_offset. Por fim, converta o endereço em um ponteiro bruto *mut PageTablepelo método as_mut_ptre, em seguida, crie um link com segurança &mut PageTable. Em &mutvez disso &, criamos o link , porque mais adiante neste artigo modificaremos essas tabelas de páginas.

Não há necessidade de inserir um bloco não seguro aqui, porque Rust considera todo o corpo unsafe fncomo um bloco grande e não seguro. Isso aumenta os riscos, porque é possível introduzir acidentalmente uma operação insegura nas linhas anteriores. Também dificulta a detecção de operações não seguras. Um RFC já foi criado para modificar esse comportamento do Rust.

Agora podemos usar esta função para gerar os registros da tabela do quarto nível:

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] // initialize GDT, IDT, PICS use blog_os::memory::active_level_4_table; let l4_table = unsafe { active_level_4_table(boot_info.physical_memory_offset) }; for (i, entry) in l4_table.iter().enumerate() { if !entry.is_unused() { println!("L4 Entry {}: {:?}", i, entry); } } println!("It did not crash!"); blog_os::hlt_loop(); } 

Passamos physical_memory_offsetno campo correspondente da estrutura BootInfo. Em seguida, usamos uma função iterpara percorrer as entradas da tabela de páginas e um combinador enumeratepara adicionar um índice ia cada elemento. Somente entradas não vazias são exibidas, porque todas as 512 entradas não cabem na tela.

Quando executamos o código, vemos o resultado:



Vemos vários registros não vazios que são mapeados para várias tabelas de terceiro nível. Muitas áreas de memória são usadas porque são necessárias áreas separadas para código do kernel, pilha do kernel, tradução da memória física e informações de inicialização.

Para percorrer as tabelas de páginas e olhar para a tabela de terceiro nível, podemos converter novamente o quadro exibido em um endereço virtual:

 // in the for loop in src/main.rs use x86_64::{structures::paging::PageTable, VirtAddr}; if !entry.is_unused() { println!("L4 Entry {}: {:?}", i, entry); // get the physical address from the entry and convert it 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 }; // print non-empty entries of the level 3 table for (i, entry) in l3_table.iter().enumerate() { if !entry.is_unused() { println!(" L3 Entry {}: {:?}", i, entry); } } } 

Para visualizar as tabelas do segundo e primeiro níveis, repita esse processo, respectivamente, para registros do terceiro e do segundo níveis. Como você pode imaginar, a quantidade de código está crescendo muito rapidamente, portanto não publicaremos a lista completa.

A travessia manual de tabelas é interessante porque ajuda a entender como o processador converte endereços. Mas geralmente só estamos interessados ​​em exibir um endereço físico para um endereço virtual específico, então vamos criar uma função para isso.

Tradução de endereços


Para converter um endereço virtual em um endereço físico, precisamos passar por uma tabela de página de quatro níveis até atingir o quadro mapeado. Vamos criar uma função que execute esta tradução de endereço:

 // in src/memory.rs use x86_64::{PhysAddr, VirtAddr}; /// Translates the given virtual address to the mapped physical address, or /// `None` if the address is not mapped. /// /// This function is unsafe because the caller must guarantee that the /// complete physical memory is mapped to virtual memory at the passed /// `physical_memory_offset`. pub unsafe fn translate_addr(addr: VirtAddr, physical_memory_offset: u64) -> Option<PhysAddr> { translate_addr_inner(addr, physical_memory_offset) } 

Nós nos referimos a uma função segura translate_addr_innerpara limitar a quantidade de código não seguro. Como observado acima, Rust considera todo o corpo unsafe fncomo um grande bloco inseguro. Invocando uma função segura, tornamos explícitas novamente cada operação unsafe.

Uma função interna especial possui funcionalidade real:

 // in src/memory.rs /// Private function that is called by `translate_addr`. /// /// This function is safe to limit the scope of `unsafe` because Rust treats /// the whole body of unsafe functions as an unsafe block. This function must /// only be reachable through `unsafe fn` from outside of this module. fn translate_addr_inner(addr: VirtAddr, physical_memory_offset: u64) -> Option<PhysAddr> { use x86_64::structures::paging::page_table::FrameError; use x86_64::registers::control::Cr3; // read the active level 4 frame from the CR3 register 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; // traverse the multi-level page table for &index in &table_indexes { // convert the frame into a page table reference let virt = frame.start_address().as_u64() + physical_memory_offset; let table_ptr: *const PageTable = VirtAddr::new(virt).as_ptr(); let table = unsafe {&*table_ptr}; // read the page table entry and update `frame` let entry = &table[index]; frame = match entry.frame() { Ok(frame) => frame, Err(FrameError::FrameNotPresent) => return None, Err(FrameError::HugeFrame) => panic!("huge pages not supported"), }; } // calculate the physical address by adding the page offset Some(frame.start_address() + u64::from(addr.page_offset())) } 

Em vez de reutilizar a função, relemos o active_level_4_tablequadro de quarto nível do registro CR3, porque isso simplifica a implementação do protótipo. Não se preocupe, melhoraremos a solução em breve.

A estrutura VirtAddrjá fornece métodos para calcular índices em tabelas de páginas de quatro níveis. Armazenamos esses índices em uma pequena matriz, porque permite percorrer todas as tabelas for. Fora do loop, lembramos do último quadro visitado para calcular o endereço físico posteriormente. frameaponta para os quadros da tabela de páginas durante a iteração e para o quadro associado após a última iteração, ou seja, após passar o registro de nível 1.

Dentro do loop, aplicamos novamentephysical_memory_offsetpara converter um quadro em um link da tabela da página. Em seguida, lemos o registro da tabela de páginas atual e usamos a função PageTableEntry::framepara recuperar o quadro correspondente. Se o registro não estiver mapeado para um quadro, retorne None. Se o registro exibir uma página enorme de 2 MiB ou 1 GiB, até agora teremos pânico.

Então, vamos verificar a função de tradução em alguns endereços:

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] // initialize GDT, IDT, PICS use blog_os::memory::translate_addr; use x86_64::VirtAddr; let addresses = [ // the identity-mapped vga buffer page 0xb8000, // some code page 0x20010a, // some stack page 0x57ac_001f_fe48, // virtual address mapped to physical address 0 boot_info.physical_memory_offset, ]; for &address in &addresses { let virt = VirtAddr::new(address); let phys = unsafe { translate_addr(virt, boot_info.physical_memory_offset) }; println!("{:?} -> {:?}", virt, phys); } println!("It did not crash!"); blog_os::hlt_loop(); } 

Quando executamos o código, obtemos o seguinte resultado:



Como esperado, com um mapeamento idêntico, o endereço é 0xb8000convertido no mesmo endereço físico. A página de código e a pilha são convertidas em endereços físicos arbitrários, que dependem de como o carregador criou o mapeamento inicial para o nosso kernel. O mapeamento physical_memory_offsetdeve apontar para o endereço físico 0, mas falha, porque a tradução usa páginas enormes para eficiência. Uma versão futura do gerenciador de inicialização pode aplicar a mesma otimização para as páginas do kernel e da pilha.

Usando MappedPageTable


A tradução de endereços virtuais em endereços físicos é uma tarefa típica do kernel do SO, portanto, o engradado x86_64fornece uma abstração para ele. Ele já suporta páginas enormes e várias outras funções, exceto translate_addr, portanto, a usamos em vez de adicionar suporte a páginas grandes em nossa própria implementação.

A base da abstração são duas características que definem várias funções de tradução da tabela de páginas:

  • A característica Mapperfornece funções que funcionam nas páginas. Por exemplo, translate_pagepara converter esta página em um quadro do mesmo tamanho, bem como map_topara criar um novo mapeamento na tabela.
  • A característica MapperAllSizesimplica aplicação Mapperpara todos os tamanhos de página. Além disso, fornece funções que funcionam com páginas de tamanhos diferentes, incluindo translate_addrgeral ou translate.

Os traços definem apenas a interface, mas não fornecem nenhuma implementação. Agora, a caixa x86_64fornece dois tipos que implementam características: MappedPageTablee RecursivePageTable. A primeira exige que cada quadro da tabela da página seja exibido em algum lugar (por exemplo, com um deslocamento). O segundo tipo pode ser usado se a tabela do quarto nível for exibida recursivamente.

Temos toda a memória física mapeada para physical_memory_offsetque você possa usar o tipo MappedPageTable. Para inicializá-lo, crie uma nova função initno módulo memory:

 use x86_64::structures::paging::{PhysFrame, MapperAllSizes, MappedPageTable}; use x86_64::PhysAddr; /// Initialize a new MappedPageTable. /// /// This function is unsafe because the caller must guarantee that the /// complete physical memory is mapped to virtual memory at the passed /// `physical_memory_offset`. Also, this function must be only called once /// to avoid aliasing `&mut` references (which is undefined behavior). pub unsafe fn init(physical_memory_offset: u64) -> impl MapperAllSizes { let level_4_table = active_level_4_table(physical_memory_offset); let phys_to_virt = move |frame: PhysFrame| -> *mut PageTable { let phys = frame.start_address().as_u64(); let virt = VirtAddr::new(phys + physical_memory_offset); virt.as_mut_ptr() }; MappedPageTable::new(level_4_table, phys_to_virt) } // make private unsafe fn active_level_4_table(physical_memory_offset: u64) -> &'static mut PageTable {…} 

Não podemos retornar diretamente MappedPageTablede uma função porque é comum a um tipo de fechamento. Vamos contornar esse problema com uma construção de sintaxe impl Trait. Uma vantagem adicional é que você pode mudar o kernel para RecursivePageTablesem alterar a assinatura da função.

A função MappedPageTable::newespera dois parâmetros: um link mutável para a tabela de páginas do nível 4 e um fechamento phys_to_virtque converte o quadro físico em um ponteiro da tabela de páginas *mut PageTable. Para o primeiro parâmetro, podemos reutilizar a função active_level_4_table. Para o segundo, criamos um fechamento que é usado physical_memory_offsetpara realizar a conversão.

Também a tornamos uma active_level_4_tablefunção privada, porque a partir de agora será chamada apenas de init.

Para usar o métodoMapperAllSizes::translate_addrem vez de nossa própria função memory::translate_addr, precisamos alterar apenas algumas linhas em kernel_main:

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] // initialize GDT, IDT, PICS // new: different imports use blog_os::memory; use x86_64::{structures::paging::MapperAllSizes, VirtAddr}; // new: initialize a mapper let mapper = unsafe { memory::init(boot_info.physical_memory_offset) }; let addresses = […]; // same as before for &address in &addresses { let virt = VirtAddr::new(address); // new: use the `mapper.translate_addr` method let phys = mapper.translate_addr(virt); println!("{:?} -> {:?}", virt, phys); } println!("It did not crash!"); blog_os::hlt_loop(); } 

Após o início, vemos os mesmos resultados de tradução de antes, mas agora apenas páginas grandes também funcionam:



Como esperado, o endereço virtual é physical_memory_offsetconvertido em um endereço físico 0x0. Usando a função de tradução para o tipo MappedPageTable, eliminamos a necessidade de implementar o suporte para páginas enormes. Também temos acesso a outras funções da página, como as map_toque serão usadas na próxima seção. Nesta fase, não precisamos mais da função memory::translate_addr, você pode excluí-la, se desejar.

Crie um novo mapeamento


Até agora, analisamos apenas as tabelas de páginas, mas não mudamos nada. Vamos criar um novo mapeamento para uma página não exibida anteriormente.

Usaremos a função map_toda característica Mapper, portanto, primeiro consideraremos essa função. A documentação diz que requer quatro argumentos: a página que queremos exibir; O quadro para o qual a página deve ser mapeada. conjunto de sinalizadores para escrever o distribuidor de quadro e tabela de páginas frame_allocator. Um alocador de quadros é necessário porque o mapeamento desta página pode exigir a criação de tabelas adicionais que precisam de quadros não utilizados como armazenamento de backup.

Função create_example_mapping


O primeiro passo em nossa implementação é criar uma nova função create_example_mappingque mapeie esta página para o 0xb8000quadro físico do buffer de texto VGA. Selecionamos esse quadro porque facilita verificar se a exibição foi criada corretamente: basta escrever na página exibida recentemente e ver se ela aparece na tela.

A função create_example_mappingfica assim:

 // in src/memory.rs use x86_64::structures::paging::{Page, Size4KiB, Mapper, FrameAllocator}; /// Creates an example mapping for the given page to frame `0xb8000`. pub fn create_example_mapping( page: Page, mapper: &mut impl Mapper<Size4KiB>, 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 { mapper.map_to(page, frame, flags, frame_allocator) }; map_to_result.expect("map_to failed").flush(); } 

Além da página que pagevocê deseja mapear, a função espera uma instância de mappere frame_allocator. O tipo mapperimplementa a característica Mapper<Size4KiB>que o método fornece map_to. É Size4KiBnecessário um parâmetro geral , pois a característica Mapperé comum para a característica PageSize, trabalhando com páginas padrão de 4 KiB e páginas enormes de 2 MiB e 1 GiB. Queremos criar apenas 4 páginas KiB, para que possamos usá-lo em Mapper<Size4KiB>vez do requisito MapperAllSizes.

Para comparação, defina o sinalizador PRESENT, pois é necessário para todas as entradas válidas e o sinalizador WRITABLEpara tornar a página exibida gravável. Desafiomap_toinseguro: você pode violar a segurança da memória com argumentos inválidos; portanto, você deve usar um bloco unsafe. Para obter uma lista de todos os sinalizadores possíveis, consulte a seção “Formato da tabela de páginas” do artigo anterior .

A função map_topode falhar e, portanto, retorna Result. Como este é apenas um exemplo de código que não deve ser confiável, simplesmente o usamos expectpara entrar em pânico no caso de um erro. Se for bem sucedido, a função retorna do tipo MapperFlushque proporciona um método simples de limpeza recentemente foi apresentado no buffer de página da tradução dinâmica (TLB) pelo método flush. Assim Result, esse tipo aplica o atributo [ #[must_use]] aemitir um aviso se esquecermos acidentalmente de usá-lo .

Fictício FrameAllocator


Para ligar create_example_mapping, você deve primeiro criar FrameAllocator. Como observado acima, a complexidade de criar uma nova exibição depende da página virtual que queremos exibir. No caso mais simples, já existe uma tabela de nível 1 para a página e precisamos apenas fazer um registro. No caso mais difícil, a página está em uma área de memória para a qual o nível 3 ainda não foi criado, portanto, primeiro você terá que criar tabelas de níveis 3, 2 e 1.

Vamos começar com um caso simples e supor que você não precisa criar novas tabelas de páginas. Um distribuidor de quadros que sempre retorna é suficiente para isso None. Criamos uma EmptyFrameAllocatorfunção de exibição para teste:

 // in src/memory.rs /// A FrameAllocator that always returns `None`. pub struct EmptyFrameAllocator; impl FrameAllocator<Size4KiB> for EmptyFrameAllocator { fn allocate_frame(&mut self) -> Option<PhysFrame> { None } } 

Agora você precisa encontrar uma página que possa ser exibida sem criar novas tabelas de páginas. O carregador é carregado no primeiro megabyte do espaço de endereço virtual, portanto, sabemos que para esta região há uma tabela válida de nível 1. No nosso exemplo, podemos selecionar qualquer página não utilizada nesta área de memória, por exemplo, a página no endereço 0x1000.

Para testar a função, primeiro exibimos a página 0x1000e, em seguida, exibimos o conteúdo da memória:

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] // initialize GDT, IDT, PICS use blog_os::memory; use x86_64::{structures::paging::Page, VirtAddr}; let mut mapper = unsafe { memory::init(boot_info.physical_memory_offset) }; let mut frame_allocator = memory::EmptyFrameAllocator; // map a previously unmapped page let page = Page::containing_address(VirtAddr::new(0x1000)); memory::create_example_mapping(page, &mut mapper, &mut frame_allocator); // write the string `New!` to the screen through the new mapping let page_ptr: *mut u64 = page.start_address().as_mut_ptr(); unsafe { page_ptr.offset(400).write_volatile(0x_f021_f077_f065_f04e)}; println!("It did not crash!"); blog_os::hlt_loop(); } 

Primeiro, criamos um mapeamento para a página 0x1000, chamando uma função create_example_mappingcom um link mutável para instâncias mappere frame_allocator. Isso mapeia a página 0x1000para o quadro de buffer de texto VGA, portanto, devemos ver o que está escrito na tela.

Em seguida, converta a página em um ponteiro bruto e escreva o valor no deslocamento 400. Não escrevemos no topo da página porque a linha superior do buffer VGA é diretamente deslocada da tela da seguinte maneira println. Escreva o valor 0x_f021_f077_f065_f04eque corresponde à string "New!" sobre um fundo branco Como aprendemos no artigo "Modo de texto VGA" , a gravação no buffer VGA deve ser volátil, por isso usamos o método write_volatile.

Quando executamos o código no QEMU, vemos o seguinte resultado:



Depois de escrever na página 0x1000, a inscrição "Novo!" .Portanto, criamos com sucesso um novo mapeamento nas tabelas de páginas.

Esse agrupamento funcionou porque já havia uma tabela de nível 1 para agrupamento 0x1000. Quando tentamos mapear uma página para a qual ainda não existe uma tabela de nível 1, a função map_tofalha porque tenta alocar quadros EmptyFrameAllocatorpara criar novas tabelas. Vemos que isso acontece quando tentamos exibir a página em 0xdeadbeaf000vez de 0x1000:

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] let page = Page::containing_address(VirtAddr::new(0xdeadbeaf000)); […] } 

Se isso for iniciado, ocorrerá um pânico com a seguinte mensagem de erro:

 panicked at 'map_to failed: FrameAllocationFailed', /…/result.rs:999:5 

Para exibir páginas que ainda não possuem uma tabela de nível 1, é necessário criar a correta FrameAllocator. Mas como você sabe quais quadros são gratuitos e quanta memória física está disponível?

Seleção de quadro


Para novas tabelas de páginas, você precisa criar o distribuidor de quadros correto. Vamos começar com o esqueleto geral:

 // in src/memory.rs pub struct BootInfoFrameAllocator<I> where I: Iterator<Item = PhysFrame> { frames: I, } impl<I> FrameAllocator<Size4KiB> for BootInfoFrameAllocator<I> where I: Iterator<Item = PhysFrame> { fn allocate_frame(&mut self) -> Option<PhysFrame> { self.frames.next() } } 

O campo framespode ser inicializado com um iterador de quadro arbitrário. Isso permite que você simplesmente delegue chamadas ao allocmétodo Iterator::next.

Para a inicialização, BootInfoFrameAllocatorusamos o cartão de memória memory_mapque o gerenciador de inicialização transfere como parte da estrutura BootInfo. Conforme explicado na seção Informações de inicialização , o cartão de memória é fornecido pelo firmware BIOS / UEFI. Ele pode ser solicitado apenas no início do processo de inicialização, portanto, o carregador de inicialização já chamou as funções necessárias.

Um cartão de memória consiste em uma lista de estruturas MemoryRegionque contêm o endereço inicial, comprimento e tipo (por exemplo, não utilizados, reservados, etc.) de cada área de memória. Criando um iterador que produz quadros de áreas não utilizadas, podemos criar um válido BootInfoFrameAllocator.

A inicialização BootInfoFrameAllocatorocorre em uma nova função init_frame_allocator:

 // in src/memory.rs use bootloader::bootinfo::{MemoryMap, MemoryRegionType}; /// Create a FrameAllocator from the passed memory map pub fn init_frame_allocator( memory_map: &'static MemoryMap, ) -> BootInfoFrameAllocator<impl Iterator<Item = PhysFrame>> { // get usable regions from memory map let regions = memory_map .iter() .filter(|r| r.region_type == MemoryRegionType::Usable); // map each region to its address range let addr_ranges = regions.map(|r| r.range.start_addr()..r.range.end_addr()); // transform to an iterator of frame start addresses let frame_addresses = addr_ranges.flat_map(|r| r.step_by(4096)); // create `PhysFrame` types from the start addresses let frames = frame_addresses.map(|addr| { PhysFrame::containing_address(PhysAddr::new(addr)) }); BootInfoFrameAllocator { frames } } 

Esta função usa um combinador para converter o mapa inicial MemoryMapem um iterador dos quadros físicos usados:

  • Primeiro, chamamos o método iterpara converter o cartão de memória em um iterador MemoryRegion. Em seguida, usamos o método filterpara pular regiões reservadas ou inacessíveis. O carregador atualiza o cartão de memória para todos os mapeamentos criados, para que os quadros usados ​​pelo kernel (código, dados ou pilha) ou para armazenar informações sobre a inicialização já estejam marcados como InUseou de forma semelhante. Assim, podemos ter certeza de que os quadros Usablenão são usados ​​em outros lugares .
  • 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 .

Agora você pode alterar a nossa função kernel_mainpara transmitir uma cópia BootInfoFrameAllocatorao invés EmptyFrameAllocator:

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] let mut frame_allocator = memory::init_frame_allocator(&boot_info.memory_map); […] } 

Dessa vez, o mapeamento de endereços foi bem-sucedido e novamente vemos o preto e branco "Novo!" .Nos bastidores, o método map_tocria tabelas de páginas ausentes da seguinte maneira:

  • Selecione um quadro não utilizado do quadro transmitido frame_allocator.
  • Zero quadro para criar uma nova tabela de página vazia.
  • Mapeie uma entrada da tabela de nível superior para esse quadro.
  • Vá para o próximo nível da tabela.

Embora nossa função create_example_mappingseja apenas um exemplo de código, agora podemos criar novos mapeamentos para páginas arbitrárias. Isso será necessário para alocar memória e implementar multithreading em artigos futuros.

Sumário


Neste artigo, aprendemos sobre vários métodos de acesso aos quadros físicos das tabelas de páginas, incluindo mapeamento de identidade, mapeamento de memória física completa, mapeamento temporário e tabelas de páginas recursivas. Optamos por exibir a memória física completa como um método simples e poderoso.

Não podemos mapear a memória física do kernel sem acesso à tabela de páginas, portanto, o suporte ao carregador de inicialização é necessário. O rack bootloadercria os mapeamentos necessários por meio de funções adicionais de carga. Ele passa as informações necessárias para o kernel como um argumento &BootInfopara a função de ponto de entrada.

Para nossa implementação, primeiro examinamos manualmente as tabelas de páginas, fizemos uma função de tradução e depois usamos o tipo de MappedPageTablecaixax86_64. Também aprendemos como criar novos mapeamentos na tabela de páginas e como fazê-los FrameAllocatorem um cartão de memória transmitido pelo gerenciador de inicialização.

O que vem a seguir?


No próximo artigo, criaremos uma área de memória heap para o nosso kernel, que permitirá alocar memória e usar diferentes tipos de coleções .

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


All Articles