DEV Community

Plínio Balduino
Plínio Balduino

Posted on

[pt-BR] Como um programa vira um processo: entendendo processos com o meniOS

Há alguns anos publiquei o artigo Criando um sistema operacional (quase) do zero, contando os primeiros passos do meniOS. Naquela época, fazer o kernel inicializar, configurar algumas estruturas básicas e colocar alguma coisa na tela já representava uma parte considerável da diversão.

O projeto continuou crescendo e, hoje, o meniOS consegue executar programas em user space, possui um scheduler preemptivo, memória virtual por processo, filesystem, shell, sinais, pipes e outras funcionalidades que fazem com que ele se pareça um pouco mais com aquilo que normalmente chamamos de sistema operacional e um pouco menos com um programa muito complicado que tomou conta do computador.

Dizer que um sistema operacional "executa programas", entretanto, esconde uma quantidade considerável de trabalho. Quando clicamos em um ícone ou digitamos um comando no terminal, estamos acostumados a pensar que simplesmente mandamos o computador executar aquele programa, mas entre o arquivo armazenado no disco e suas instruções sendo executadas pela CPU existe uma abstração fundamental fornecida pelo sistema operacional: o processo.

Este é o primeiro passo de uma pequena descida ao submundo do desenvolvimento de sistemas operacionais. Ainda estamos relativamente perto da superfície.

Programa e processo não são a mesma coisa

Imagine um executável chamado hello. Enquanto ele está armazenado no disco, temos basicamente um arquivo contendo código, dados e algumas informações que permitem ao sistema operacional entender como esse conteúdo deve ser carregado. No caso do meniOS, assim como no Linux e em vários outros sistemas Unix-like, os executáveis usam o formato ELF. Por enquanto, ELF pode continuar sendo apenas uma sigla misteriosa; voltaremos a ele quando estivermos alguns degraus abaixo.

O importante neste momento é perceber que o arquivo armazenado no disco é o programa, enquanto cada instância desse programa em execução é um processo. Podemos ter dez cópias de hello no disco sem que nenhuma delas esteja sendo executada e, da mesma forma, podemos ter apenas um /bin/hello e executá-lo várias vezes simultaneamente.

Essa distinção aparentemente simples muda completamente o problema que o sistema operacional precisa resolver. Um programa pode ser tratado essencialmente como informação armazenada, enquanto um processo precisa representar uma execução em andamento ou pronta para continuar quando receber tempo de CPU. Isso significa que o kernel precisa manter informações sobre sua identidade, seu estado, os recursos que pertencem a ele e o ponto em que sua execução deverá continuar.

No meniOS, boa parte dessas informações fica em uma estrutura chamada proc_info_t. A estrutura real possui muitos campos, mas podemos reduzir alguns deles para enxergar melhor a ideia:

typedef struct proc_info_t {
  proc_info_p parent;
  uint32_t pid;

  proc_state_t state;
  uint8_t priority;

  char name[32];

  cpu_state_t *cpu_state;

  phys_addr_t address_space_root;

  file_descriptor_entry_t files[PROC_MAX_FILES];

  char cwd[PROC_CWD_MAX];

  int exit_code;
} proc_info_t;
Enter fullscreen mode Exit fullscreen mode

Já existe bastante coisa escondida nesse pequeno trecho. O pid identifica o processo, enquanto state registra seu estado atual e cpu_state mantém informações necessárias para continuar sua execução. Também aparecem referências ao espaço de memória do processo, aos arquivos que ele mantém abertos, ao seu diretório corrente e até ao processo que o criou.

Alguns desses conceitos merecem artigos inteiros e, por enquanto, não precisamos entender todos eles. O importante é perceber que um processo não é simplesmente "um programa carregado na memória": ele é uma estrutura mantida pelo sistema operacional para representar e controlar uma execução.

Se você já usou Linux, macOS ou outro sistema Unix-like, provavelmente já encontrou um PID ao executar comandos como ps. Poderíamos ter, por exemplo:

PID   COMMAND
1     init
7     mosh
12    hello
13    hello
Enter fullscreen mode Exit fullscreen mode

Os processos 12 e 13 estão executando o mesmo programa, mas continuam sendo processos independentes. Cada um pode estar em um ponto diferente da execução, possuir dados diferentes na memória, manter arquivos diferentes abertos e terminar com resultados diferentes.

O meniOS também atribui um PID a cada novo processo. Em uma das partes da criação de um processo filho encontramos algo tão simples quanto:

child->pid = last_pid++;
Enter fullscreen mode Exit fullscreen mode

Isso fornece uma identidade ao novo processo, mas ainda precisamos entender de onde ele veio e como chegou ao ponto em que pode executar um programa.

Criando um processo com fork()

Sistemas Unix-like tradicionalmente oferecem uma operação chamada fork(), cujo nome descreve razoavelmente bem o que acontece: a execução chega a determinado ponto e se divide em dois caminhos. Um programa pode fazer algo parecido com:

pid_t pid = fork();

if (pid == 0) {
  // processo filho
} else {
  // processo pai
}
Enter fullscreen mode Exit fullscreen mode

O aspecto inicialmente estranho de fork() é que existe apenas uma chamada no código, mas, se ela for bem-sucedida, passam a existir dois processos continuando a execução a partir daquele ponto. O processo original, que chamamos de pai, recebe como resultado o PID do processo recém-criado, enquanto o filho observa fork() retornando 0. Essa diferença permite que o mesmo trecho de código descubra em qual dos dois processos está sendo executado.

Se o pai receber 42 como resultado, por exemplo, significa que o novo processo possui PID 42. Quando o processo 42 continuar sua execução, entretanto, o valor retornado por fork() será zero.

Não existe alguma característica especial da linguagem C capaz de fazer uma função retornar dois valores diferentes. Quem produz esse comportamento é o sistema operacional, criando dois contextos de execução e preparando cada um deles para observar um resultado diferente.

No meniOS, isso pode ser visto diretamente na implementação de proc_fork(). Entre outras tarefas, o kernel cria uma nova estrutura para o filho, atribui um PID, prepara uma nova pilha do kernel, replica informações herdadas do pai e cria um espaço de endereçamento para o novo processo. A memória virtual do processo pai também é clonada para que o filho possa continuar a execução com uma visão inicialmente equivalente dos dados que existiam antes do fork().

Essa última frase esconde uma quantidade razoável de complexidade. Clonar memória de processos envolve memória virtual, páginas, page tables e várias outras coisas que não precisamos trazer para a conversa agora. Para este primeiro degrau basta assumir que o meniOS consegue preparar para o filho uma cópia apropriada do ambiente de execução do pai.

Há, entretanto, um detalhe da implementação que vale observar desde já. O kernel copia para o filho o estado da CPU existente no momento da chamada:

memcpy(child->cpu_state, frame, sizeof(syscall_frame_t));
Enter fullscreen mode Exit fullscreen mode

Depois modifica o registrador que será usado para devolver o resultado da operação ao processo filho:

child->cpu_state->rax = 0;
Enter fullscreen mode Exit fullscreen mode

Quando o filho voltar a executar em user space, continuará praticamente do ponto em que o pai estava, mas enxergará o retorno de fork() como zero. O pai, por sua vez, receberá o PID atribuído ao filho.

Esse pequeno detalhe é uma boa demonstração de algo que aparece repetidamente quando começamos a olhar por baixo das abstrações fornecidas por um sistema operacional. Aquilo que parece um comportamento peculiar de uma API frequentemente corresponde a alguma manipulação bastante concreta do estado da máquina.

Há ainda outra consequência importante: depois de fork(), pai e filho continuam executando o mesmo programa. Se quem chamou fork() era um shell, acabamos de criar outro processo executando uma cópia daquele shell, o que ainda não resolve nosso problema inicial de executar /bin/hello.

É nesse ponto que entra exec().

Trocando o programa com exec()

Uma confusão bastante comum é imaginar que execve() cria outro processo, quando sua função é diferente: ela substitui o programa que está sendo executado pelo processo atual. O PID continua sendo o mesmo, mas o espaço de memória destinado ao programa é reconstruído para receber o novo executável e a execução passa a continuar a partir do ponto de entrada desse programa.

Podemos visualizar a combinação de fork() e execve() desta maneira:

                    fork()
                      |
               +------+------+
               |             |
             shell         shell
             (pai)         (filho)
                              |
                           execve()
                              |
                            hello
Enter fullscreen mode Exit fullscreen mode

No meniOS, a substituição da imagem passa por proc_exec_image(). O kernel prepara um novo espaço de endereçamento, cria uma nova pilha de user space, carrega o executável e determina em qual endereço a execução deverá começar. Depois que a nova imagem está pronta, os antigos mapeamentos pertencentes ao programa anterior podem ser descartados e o contexto do processo passa a apontar para o ponto de entrada do novo executável.

O código real contém operações como:

frame->rip = entry;
frame->rsp = stack_top;
frame->rbp = stack_top;
Enter fullscreen mode Exit fullscreen mode

rip é o registrador que indica à CPU qual instrução deverá ser executada. Ao configurá-lo com o entry point obtido do executável, o kernel está preparando o processo para que, quando voltar a user space, sua execução continue no novo programa.

Também estamos escondendo bastante coisa aqui. Ainda não explicamos como o meniOS interpreta um arquivo ELF, como cria o novo espaço de memória, o que exatamente significa voltar para user space ou por que existem registradores chamados rip e rsp. Esses detalhes não são irrelevantes; estamos apenas adiando a explicação até que tenhamos construído o vocabulário necessário.

Por enquanto, uma regra resume bem as duas operações: fork() cria outro processo a partir de um processo existente, enquanto exec() substitui o programa executado pelo processo atual.

Pode parecer estranho realizar duas operações quando o objetivo final era simplesmente executar outro programa. Por que não pedir diretamente ao kernel que crie um novo processo executando /bin/hello?

Existem sistemas e APIs que trabalham com modelos mais próximos dessa ideia, mas a separação entre fork() e exec() oferece uma propriedade bastante conveniente: há um intervalo entre criar o filho e substituir seu programa, e nesse intervalo o filho pode preparar o ambiente no qual o novo executável será iniciado.

Um shell pode aproveitar esse momento para modificar a entrada ou a saída do processo. É assim que uma construção como:

hello > output.txt
Enter fullscreen mode Exit fullscreen mode

pode ser implementada. O shell cria um processo filho, altera sua saída padrão para apontar para output.txt e somente então executa o novo programa. hello não precisa saber que existe um arquivo envolvido; para ele, escrever na saída padrão continua sendo apenas escrever na saída padrão.

A mesma ideia pode ser estendida para pipes:

cat arquivo.txt | head
Enter fullscreen mode Exit fullscreen mode

O shell cria os processos necessários, conecta a saída de um deles à entrada do outro e depois substitui cada processo filho pelo executável correspondente. O mosh, shell do meniOS, utiliza exatamente esse tipo de mecanismo, mas acompanhar o caminho completo de um comando dentro do shell será assunto para o próximo artigo.

Do fork() ao waitpid()

Agora conseguimos criar um processo e substituir o programa que ele executa, mas ainda falta uma parte importante do seu ciclo de vida. Em algum momento, o programa terminará, seja porque chegou normalmente ao final de sua execução, porque chamou exit() explicitamente ou porque alguma outra condição provocou seu encerramento. Quando isso acontece, o kernel ainda precisa preservar informações suficientes para que o processo pai descubra o que aconteceu com seu filho.

É aí que aparece waitpid():

waitpid(pid, &status, 0);
Enter fullscreen mode Exit fullscreen mode

Essa chamada permite que um processo espere pela mudança de estado de um filho e obtenha informações sobre seu término. Para um shell, esse comportamento é particularmente importante porque, quando executamos um programa em primeiro plano, normalmente esperamos que o prompt somente volte depois que aquele programa terminar.

Podemos finalmente montar uma versão simplificada do ciclo completo:

shell
  |
  +-- fork()
  |      |
  |      +-- filho
  |            |
  |          execve()
  |            |
  |          hello
  |            |
  |           exit()
  |
  +-- waitpid()
          |
          +-- filho terminou
          |
       novo prompt
Enter fullscreen mode Exit fullscreen mode

A realidade é um pouco mais sofisticada porque o pai nem sempre precisa ficar bloqueado esperando, processos podem ser interrompidos ou continuar em background, sinais podem alterar seu comportamento e o kernel precisa lidar com estados intermediários. O próprio meniOS possui estados como NEW, READY, RUNNING, WAITING, SLEEPING, ZOMBIE, STOPPED e TERMINATED.

Um desses nomes costuma chamar atenção: ZOMBIE. Apesar do nome dramático, um processo zumbi não está andando pelo sistema operacional em busca de outros processos para devorar. Ele simplesmente terminou sua execução, mas algumas informações sobre seu término ainda precisam ser mantidas porque o processo pai não as coletou. Quando o pai executa wait() ou waitpid(), pode obter o status do filho e permitir que o kernel finalmente conclua a limpeza das estruturas restantes.

Com isso, aquilo que inicialmente parecia ser apenas "executar um programa" já pode ser descrito com um pouco mais de precisão. Existe um processo executando o shell, esse processo cria um filho com fork(), o filho herda parte do contexto do pai e pode preparar seu ambiente antes de usar exec() para substituir sua imagem pelo programa desejado. O novo programa executa mantendo a identidade daquele processo e, quando termina, o kernel registra seu estado para que o pai possa observá-lo através de wait() ou waitpid().

O que ainda estamos escondendo

Chegamos até aqui simplificando deliberadamente várias etapas. Quando dissemos que um processo possui memória própria, não explicamos como dois processos podem usar os mesmos endereços virtuais sem necessariamente acessar a mesma memória física. Quando copiamos cpu_state, também não vimos quais registradores fazem parte desse estado nem como o kernel consegue interromper um processo e continuar sua execução posteriormente.

Também passamos rapidamente por uma questão ainda mais fundamental: quando um programa chama fork(), execve() ou waitpid(), como uma aplicação executando fora do kernel consegue pedir que o kernel faça alguma coisa? A resposta envolve as chamadas de sistema, ou syscalls, e a fronteira entre user space e kernel space, outro ponto em que a escada começa a descer de maneira mais perceptível.

O executável também permaneceu quase inteiramente como uma caixa-preta. Sabemos que o meniOS trabalha com ELF e que existe um entry point, mas ainda não discutimos como o kernel encontra código e dados dentro do arquivo e os coloca nos locais adequados da memória. Da mesma maneira, dissemos algumas vezes que um processo "recebe tempo de CPU" sem explicar quem toma essa decisão ou como vários processos conseguem compartilhar um único processador sem cooperar voluntariamente entre si.

Cada uma dessas simplificações esconde outra parte do sistema operacional e, consequentemente, outro possível degrau da nossa descida. Neste primeiro, entretanto, já temos um modelo suficientemente bom para responder à pergunta que abriu o artigo: um programa é código e dados armazenados, enquanto um processo é a representação, mantida pelo sistema operacional, de uma execução desse programa.

No modelo adotado pelo meniOS, fork() cria um novo processo a partir de um existente, exec() substitui o programa executado por esse processo e waitpid() permite que o pai acompanhe o que aconteceu com seu filho. Essas três operações, combinadas, estão por trás de algo tão cotidiano quanto digitar o nome de um programa no terminal e pressionar Enter.

No próximo degrau podemos fazer justamente isso: acompanhar um comando desde o momento em que é digitado no mosh até a criação e execução do processo correspondente. A partir daí, começaremos a encontrar algumas das abstrações que convenientemente deixamos escondidas neste artigo.

Top comments (0)