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:
- Atualiza o campo
SPPno CSRsstatuspara refletir o nível de privilégio anterior. - Salva o endereço da instrução que causou o trap no CSR
sepc. - Registra a causa e informações adicionais nos CSRs
scauseestval. - Salta para o endereço do manipulador definido em
stvece 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 emsscratch) com o ponteiro de pilha do kernel. - Registradores Especiais: Registradores como
x0(sempre zero) ex4(tp, thread pointer) não precisam ser salvos. Osp(x2) é tratado separadamente para reconstruir a pilha. - Retorno ao Modo U: A instrução
sretno final de__restoreutiliza os valores restaurados desstatusesepcpara 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.