Implementação de Rastreamento de Pilha e Validação de Memória em Sistemas Operacionais RISC-V com Rust

Rastreamento de Pilha em Ambiente Bare-Metal

Em sistemas operacionais desenvolvidos para ambientes bare-metal, a capacidade de inspecionar a pilha de chamadas é crucial para depuração. No RISC-V, a pilha é alocada logo após o segmento .bss. É fundamental distinguir entre o registrador apontador de pilha (sp) e o registrador apontador de frame (fp ou s0).

Para garantir que o compilador Rust mantenha os apontadores de frame, é necessário configurar o arquivo .cargo/config.toml com a flag adequada:

[build]
target = "riscv64gc-unknown-none-elf"

[target.riscv64gc-unknown-none-elf]
rustflags = [
    "-Clink-arg=-Tsrc/linker-qemu.ld", 
    "-Cforce-frame-pointers=yes"
]

Com os frame pointers preservados, podemos implementar uma função para percorrer e exibir a pilha:

use core::arch::asm;

pub unsafe fn exibir_rastreamento_pilha() {
    let mut frame_ptr: *const usize;
    // No RISC-V, s0 é utilizado como frame pointer (fp)
    asm!("mv {}, s0", out(reg) frame_ptr);

    println!("== Iniciando Rastreamento da Pilha ==");
    while !frame_ptr.is_null() {
        let return_addr = *frame_ptr.offset(-1);
        let prev_frame = *frame_ptr.offset(-2);

        println!("Retorno: 0x{:016x}, Frame: 0x{:016x}", return_addr, prev_frame);

        frame_ptr = prev_frame as *const usize;
    }
    println!("== Fim do Rastreamento ==");
}

Esta função pode ser integrada ao manipulador de panic do kernel e acionada quando todas as aplicações em lote terminam sua execução, substituindo o desligamento padrão do sistema.

Chamadas de Sistema vs. Chamadas de Função e Delegação de Exceções

Uma chamada de função convencional utiliza instruções de fluxo de controle padrão (como jal ou jalr) e não altera o nível de privilégio do processador. Em contraste, uma chamada de sistema (syscall) emprega instruções específicas (como ecall no RISC-V) que forçam uma transição para o modo de privilégio do kernel (Modo S ou M).

Para otimizar o tratamento de exceções, o firmware no Modo M (como o RustSBI) pode delegar interrupções e exceções síncronas diretamente para o Modo S. Isso é controlado pelos registradores de estado e controle (CSRs) mideleg (delegação de interrupções) e medeleg (delegação de exceções). Ao configurar os bits apropriados nesses registradores, o hardware roteia eventos como falhas de página ou interrupções de temporizador diretamente para o sistema operacional, contornando o overhead do Modo M.

Segurança em Arquiteturas Unikernel e Proteções de Hardware

Em arquiteturas onde o sistema operacional é vinculado como uma biblioteca (LibOS ou Unikernel), o kernel e a aplicação compartilham o mesmo espaço de endereço e nível de privilégio. Isso introduz vulnerabilidades severas:

  • Estouro de Buffer e Inteiros: Aplicações podem corromper estruturas críticas do kernel.
  • Interceptação de Syscalls: O fluxo de chamadas pode ser desviado para código malicioso.
  • Exaustão de Recursos: Não há isolamento para prevenir ataques de negação de serviço.

Para mitigar esses riscos em sistemas operacionais convencionais, o hardware e o software colaboram através de:

  • Níveis de Privilégio: O CPU bloqueia a execução de instruções privilegiadas no Modo U.
  • Ambientes de Execução Confiáveis (TEE): Isolamento hardware para dados sensíveis.
  • ASLR (Randomização de Layout de Espaço de Endereço): Dificulta a previsibilidade de endereços de memória para exploradores.

Transições de Privilégio e o Contexto de Trap

Quando uma aplicação no Modo U executa uma instrução privilegiada ou ocorre uma exceção, o hardware do RISC-V realiza automaticamente as seguintes ações:

  1. Atualiza o campo SPP no CSR sstatus para refletir o nível de privilégio anterior.
  2. Salva o endereço da instrução que causou o trap no CSR sepc.
  3. Registra a causa e informações adicionais nos CSRs scause e stval.
  4. Salta para o endereço do manipulador definido em stvec e eleva o privilégio para o Modo S.

O sistema operacional deve então salvar o "Contexto de Trap" (registradores de propósito geral e CSRs específicos) na pilha do kernel. Sem esse salvamento e a subsequente restauração via instrução sret, o processador não conseguiria retomar a execução da aplicação no Modo U com seu estado original intacto.

Validação de Limites de Memória em Chamadas de Sistema

Ao implementar syscalls como sys_write em um kernel sem memória virtual, é imperativo validar os ponteiros fornecidos pelo usuário. Um aplicativo malicioso pode tantar ler ou escrever em regiões de memória pertencentes a outros aplicativos ou ao próprio kernel.

Primeiro, definimos métodos para obter os limites de memória válidos:

impl GerenciadorAplicacoes {
    pub fn obter_intervalo_app_atual(&self) -> (usize, usize) {
        let tamanho_app = self.app_start[self.current_app] - self.app_start[self.current_app - 1];
        (APP_BASE_ADDRESS, APP_BASE_ADDRESS + tamanho_app)
    }
}

pub fn obter_intervalo_pilha_usuario() -> (usize, usize) {
    let topo_pilha = PILHA_USUARIO.get_sp();
    (topo_pilha - TAMANHO_PILHA_USUARIO, topo_pilha)
}

Em seguida, a função sys_write deve verificar tanto o endereço inicial quanto o endereço final do buffer fornecido, garantindo que toda a região esteja estritamente contida no segmento de dados da aplicação ou na pilha do usuário:

pub fn sys_write(fd: usize, buffer: *const u8, comprimento: usize) -> isize {
    let limite_app = obter_intervalo_app_atual();
    let limite_pilha = obter_intervalo_pilha_usuario();
    
    let inicio_buffer = buffer as usize;
    let fim_buffer = unsafe { buffer.add(comprimento) } as usize;

    let no_intervalo_app = inicio_buffer >= limite_app.0 && fim_buffer <= limite_app.1;
    let no_intervalo_pilha = inicio_buffer >= limite_pilha.0 && fim_buffer <= limite_pilha.1;

    if !no_intervalo_app && !no_intervalo_pilha {
        return -1; // Acesso fora dos limites permitido
    }

    match fd {
        FD_STDOUT => {
            let slice = unsafe { core::slice::from_raw_parts(buffer, comprimento) };
            let texto = core::str::from_utf8(slice).unwrap_or("Erro UTF-8");
            print!("{}", texto);
            comprimento as isize
        }
        _ => -1, // File descriptor não suportado
    }
}

Análise do Assembly de Tratamento de Trap

No arquivo trap.S, as rotinas __alltraps e __restore gerenciam a transição entre os modos de usuário e supervisor.

  • Troca de Pilhas: A instrução csrrw sp, sscratch, sp é usada para trocar o ponteiro de pilha do usuário (armazenado em sscratch) com o ponteiro de pilha do kernel.
  • Registradores Especiais: Registradores como x0 (sempre zero) e x4 (tp, thread pointer) não precisam ser salvos. O sp (x2) é tratado separadamente para reconstruir a pilha.
  • Retorno ao Modo U: A instrução sret no final de __restore utiliza os valores restaurados de sstatus e sepc para reverter o nível de privilégio e retomar a execução no endereço correto.

Otimização de Salvamento: Nem todas as interrupções exigem o salvamento completo dos 32 registradores de propósito geral. Analisando o CSR scause logo no início do trap, o kernel pode determinar se a exceção resultará no enceramento da aplicação (como uma falha de página ou instrução ilegal). Nesses casos, o contexto completo não precisa ser restaurado, permitindo que o kernel pule o salvamento de registradores temporários e otimize o tempo de tratamento da exceção.

Tags: rCore Rust RISC-V Sistema Operacional Bare-Metal

Publicado em 7-3 04:52