Como o seu código realmente funciona? Entendendo as Syscalls
Você já sentiu curiosidade em entender como um printf ou um System.out.println funciona por baixo dos panos? Fica aqui comigo nesse artigo que eu vou te explicar melhor como esses métodos realmente funcionam no seu computador.
Entendendo o seu código
Para começar, precisamos entender o que é o seu código Java, C++ etc. Esses códigos nada mais são do que linhas de comando que serão passadas para o núcleo executar o que tiver nele. Entretanto, o seu código está no que chamamos de “Nível usuário” e o núcleo está no que chamamos de “nível núcleo”, a regra de um sistema operacional é: O que está no nível usuário não acessa o nível núcleo senão pelo kernel (núcleo do sistema). Sendo assim, para que seu programa, no nível usuário, possa executar coisas no hardware, no nível núcleo, precisamos do que chamamos de “Chamada de sistema” ou System Call (syscall).
O que é Syscall?
A chamada de sistema é um mecanismo programático que consiste em um programa de computador solicitar um serviço ao núcleo do sistema operacional sobre o qual está sendo executado. Desse modo, através da Syscall o programa consegue dar comandos para o núcleo para que ele realize operações como leitura de arquivos, execução de arquivos ou escrever um arquivo no HD.
Imagine que o sistema operacional é como um restaurante, os clientes são programas, os garçons são as chamadas de sistema, os cozinheiros são núcleos e a cozinha é o hardware do restaurante. Quando você entra em algum restaurante, você não vai até a cozinha, monta seu prato e retorna a mesa, pelo contrário, você escolhe o que será pedido, chama um garçom e então o garçom leva a requisição até os cozinheiros e então os cozinheiros realizam a requisição do pedido do cliente. Desse modo, os clientes ficam no nível usuário e os cozinheiros no nível núcleo, separados porém acessíveis através de Syscall ou garçons.
Vale ressaltar que sem Syscall é impossível do programa executar qualquer tarefa que afete o hardware. Ou seja, sem chamada de sistema seria impossível de um programa ler diretórios (pastas), fazer downloads de arquivos entre outras operações comuns do dia a dia.
Entendo o fluxo de uma Syscall
A chamada de sistema começa com a aplicação definindo parâmetros que o núcleo requer para que a operação solicitada possa ser executada. Esses parâmetros podemos chamar de registradores, mais a frente iremos explicar mais sobre eles. Esses registradores a serem preenchidos são de extrema importância para que o núcleo saiba o que programa quer que ele execute, preencher registradores de forma errada ou não preenchê-los pode resultar em exceções e o não cumprimento da requisição.
Após o preenchimento adequado dos registradores, é lançada uma chamada de sistema, essa Syscall é a ponte do nível usuário para o nível núcleo. A chamada de sistema consiste numa interrupção nos processos do núcleo para que o núcleo execute o que está chegando.
Depois da interrupção ser feita, o núcleo irá interpretar o que é esse processo novo que chegou. Primeiro, ele checa os registradores e interpreta o que eles dizem com base na System Call Table (Tabela de Chamada de Sistema). Caso algum deles apresentem dados inválidos ou estejam vazios, o núcleo pode lançar uma exceção, não executar o programa e retornar o erro para a aplicação. Caso todos os registradores tenham sido preenchidos corretamente, o kernel irá executar o que foi solicitado pela aplicação.
Após executar o que foi solicitado, os registradores serão preenchidos novamente e retornados para a aplicação com os dados atuais daquilo que foi solicitado. Em um simples “Hello World!” por exemplo, há alocação desse texto em um espaço de memória, o núcleo preenche os registradores com o que foi feito e retorna para a aplicação o “Hello World!” com base no endereço que foi alocado e depois encerra a aplicação.
Entendendo profundamente as etapas do fluxograma
Caso tenha ficado dúvida, irei agora explicar mais profundamente e detalhado esse fluxograma usando como exemplo um programa em Assembly que escreve “Hello World!” através do método “write” em um sistema Linux x64.
O código para essa operação corresponde a isso:
section .data
msg db 'Hello World!', 0xA
len equ 13
section .text
global _start
_start:
mov rax, 1
mov rdi, 1
mov rsi, msg
mov rdx, len
syscall
mov rax, 60
mov rdi, 0
syscall
Primeiramente, neste código, criamos as variáveis “msg” e “len”. A variável “msg”, abreviação para “message”, contém o texto que queremos escrever, no caso “Hello World!”, e uma quebra de linha representado por “0xA”. A variável “len”, abreviação para “length”, possui o valor 13 que corresponde ao tamanho da variável “msg”.
Feito a declaração das variáveis, nosso programa irá começar. Primeiramente precisamos preencher os registradores que serão interpretados pelo kernel com base na System Call Table. O site https://x64.syscall.sh/ mostra como é uma tabela de chamada de sistema de processadores (núcleos) de diferentes arquiteturas como: x64, ARM, x86 entre outras.
Para o preenchimento dos registradores utilizamos a sigla “mov”, abreviação para “move”, em seguida o registrador a ser preenchido, como RAX, RDI, RSI etc. E juntamente ao registrador, o valor que quer atribuir a ele. Vale ressaltar que cada operação exige um número de registradores a serem preenchidos. Como iremos fazer a operação “write”, é obrigatório preenchermos o RAX, RDI, RSI e RDX. Porém, há também operações que precisamos preencher apenas RAX e RDI como o método “exit”.
Para facilitar o entendimento irei explicar cada um dos registradores:
- RAX: O registrador rax (Register Accumulator) serve para armazenar. No começo da operação ele armazena o ID da operação a ser executada. Como iremos fazer a operação “write” no Linux x64 e o ID dela é “1”, movemos o valor “1” para esse registrador. Porém ao final, o kernel irá preencher esse registrador com o valor final da operação e retornar para a aplicação.
- RDI: O registrador rdi (Register Destination Index) armazena o primeiro input, podendo variar de acordo com cada operação, entretanto, para a operação “write” ele serve para informar onde será a saída. O valor “1” corresponde à “saída” padrão. Ou seja, estamos informando que queremos o resultado da operação exibido na saída padrão.
- RSI: O registrador rsi (Register Source Index) armazena o índice de origem. Como iremos escrever “Hello World!” o índice de origem de onde o processador deve pegar para exibir é a variável que declaramos no começo, a variável “msg”.
- RDX: O registrador (Register Data) armazena os dados. Geralmente usado para armazenar o tamanho do dado. “Hello World!” possui tamanho de número 12, somando a quebra de linha “0xA” obtemos 13. Por isso, movemos 13 para esse registrador.
Após preenchermos os registradores, fazemos a chamada do sistema. A partir daí o núcleo consulta o código de operação (Opcode) passado no RAX na tabela de chamada de sistema e então confere os restantes dos registradores e executa o que foi pedido. Feito isso, o kernel armazena o valor do retorno a ser exibido no RAX novamente, retorna o valor a aplicação e é exibido o “Hello World!” na saída padrão estabelecida pelo RDI.
Por fim, é feito mais uma chamada de sistema porém com o RAX contendo o Opcode “60” que indica a operação “exit” e o RDI contendo “0” que representa o código de erro, sendo que “0” significa “execução sem erros”. E simples assim, você tem um programa em assembly que escreve um “Hello World!”.