Fundamentos de Sistemas Operacionais com XV6: Introdução e Chamadas de Sistema

Este material introduz os conceitos centrais de um curso prático sobre sistemas operacionais, utilizando o XV6 como plataforma de experimentação. O objetivo principal é compreender como um kernel funciona internamente e, por meio de modificações no código-fonte, desenvolver habilidades para estender ou aprimorar um sistema real.

Propósito do Curso

  • Entender o projeto e a implementação de um sistema operacional.
  • Praticar a modificação de um kernel real através de laboratórios baseados no XV6.

Funções Esperadas de um Sistema Operacional

  1. Abstração: ocultar a complexidade do hardware por meio de interfaces uniformes.
  2. Multiplexação: permitir que múltiplos programas compartilhem os mesmos recuross físicos.
  3. Isolamento: garantir que falhas em um processo não afetem outros processos.
  4. Compartilhamento: viabilizar troca de dados e cooperação entre aplicações.
  5. Controle de Acesso: possibilitar compartilhamento seletivo, com permissões adequadas.
  6. Desempenho: não limitar a eficiência dos programas; quando possível, auxiliar na aceleração.
  7. Versatilidade: suportar uma grande variedade de aplicações com requisitos distintos.

Arquitetura e Chamadas de Sistema

A interface entre programas usuários e o kernel é formada por chamadas de sistema. Abaixo estão as principais utilizadas em ambientes UNIX-like.

Leitura e Escrita com read e write

Essas primitivas tratam dados como uma sequência contínua de bytes, sem interpretar formatos. O primeiro argumento é o descritor de arquivo.

Por convenção, o shell asocia:

  • descritor 0 à entrada padrão (stdin);
  • descritor 1 à saída padrão (stdout);
  • descritor 2 aos erros padrão (stderr).

Abertura de Arquivos com open

A função open recebe um caminho e flags que indicam a operação desejada.

int descritor = open("saida.txt", O_WRONLY | O_CREATE);

Nesse exemplo, o arquivo é criado caso não exista e aberto apenas para escrita. Internamente, o kernel mantém uma tabela de descritores por processo. Portanto, dois processos diferentes podem obter o mesmo número numérico de descritor, mas cada um se referirá a um arquivo distinto.

Criação de Processos com fork

A chamada fork duplica o processo atual. O processo original é chamado de pai; a cópia, de filho. Ambos começam com o mesmo código, dados, pilha e descritores, porém possuem espaços de endereçamento independentes.

O valor de retorno permite diferenciar os dois:

  • No processo pai: retorna o identificador (PID) do filho.
  • No processo filho: retorna 0.
  • Em caso de erro: retorna -1.
pid_t resultado = fork();

if (resultado == 0) {
    // código executado pelo processo filho
} else if (resultado > 0) {
    // código executado pelo processo pai
} else {
    // falha na criação do processo
}

Aguardando Término com wait

wait suspende o processo chamador até que um de seus filhos termine.

  • Se não houver filhos, retorna -1 imediatamente.
  • Se o estado de saída não for necessário, pode-se passar NULL.
  • Caso contrário, o valor passado para exit pelo filho é armazenado na variável indicada.
pid_t filho = wait(NULL);

// ou, capturando o status:
int estado;
pid_t terminado = wait(&estado);

Encerramento com exit

A chamada exit finaliza o processo, libera recursos e informa o código de término ao processo pai. O valor 0 geralmente indica sucesso; valores diferentes de zero indicam erro.

Substituição de Imagem com exec

exec substitui o programa em execução por outro, reutilizando o mesmo processo. Os descritores de arquivo abertos normalmente são preservados. Se a chamada for bem-sucedida, não há retorno; o retorno só ocorre em caso de falha.

char *argumentos[3];
argumentos[0] = "echo";
argumentos[1] = "bom dia";
argumentos[2] = NULL;

if (execv("/bin/echo", argumentos) < 0) {
    fprintf(stderr, "erro ao substituir programa\n");
}

Na sequência típica, o processo pai cria um filho com fork e o filho invoca exec. Esse padrão, embora aparentemente ineficiente, é otimizado pelo kernel por meio de técnicas de memória virtual.

Comunicação entre Processos com pipe

Um pipe é um canal de comunicação unidirecional fornecido como um par de descritores: um para leitura e outro para escrita. Internamente, é gerenciado pelo kernel como um buffer circular com capacidade limitada.

Restrições dos Pipes

  • Funcionam apenas entre processos relacionados (pai e filho).
  • Não permitem que o mesmo processo leia e escreva no mesmo pipe de forma cooperativa.
  • São half-duplex: a informação flui em apenas uma direção.
  • Os dados lidos são consumidos e não podem ser relidos.

Criando um Pipe

int tubo[2];

if (pipe(tubo) == -1) {
    // erro na criação do canal
}

Aqui, tubo[0] é a extremidade de leitura e tubo[1] a de escrita.

Exemplo de Uso

int canal[2];
char *parametros[3];
parametros[0] = "wc";
parametros[1] = "-l";
parametros[2] = NULL;

pipe(canal);
pid_t pid = fork();

if (pid == 0) {
    close(STDIN_FILENO);
    dup(canal[0]);
    close(canal[0]);
    close(canal[1]);
    execvp(parametros[0], parametros);
} else {
    close(canal[0]);
    const char *mensagem = "linha um\nlinha dois\nlinha tres\n";
    write(canal[1], mensagem, strlen(mensagem));
    close(canal[1]);
    wait(NULL);
}

O processo filho redireciona sua entrada padrão para a extremidade de leitura do pipe e executa wc -l. O processo pai escreve dados na extremidade de escrita e depois fecha o canal.

Comportamento de Leitura e Escrita

Leitura:

  • Se houver dados, read retorna a quantidade de bytes efetivamente lidos.
  • Se o pipe estiver vazio e a extremidade de escrita estiver fechada, read retorna 0.
  • Se o pipe estiver vazio e a extremidade de escrita ainda estiver aberta, read bloqueia.

Escrita:

  • Se a extremidade de leitura estiver fechada, o processo escritor recebe o sinal SIGPIPE e é encerrado.
  • Se o buffer estiver cheio, write bloqueia.
  • Caso contrário, escreve os bytes e retorna a quantidade transferida.

Sistema de Arquivos

Estrutura

O sistema de arquivos do XV6 possui dois tipos principais:

  • Arquivos de dados: armazenam sequências de bytes.
  • Diretórios: contêm referências nomeadas a outros arquivos e diretórios.

Caminhos iniciados por / são absolutos; os demais são relativos ao diretório de trabalho atual.

Alterando o Diretório Atual com chdir

chdir("/projeto");
chdir("fontes");

int d1 = open("principal.c", O_RDONLY);
int d2 = open("/projeto/fontes/principal.c", O_RDONLY);

As duas chamadas a open acima referem-se ao mesmo arquivo.

Criação de Arquivos, Diretórios e Dispositivos

  • mkdir cria um novo diretório.
  • open com a flag O_CREATE cria um arquivo de dados.
  • mknod cria um arquivo especial de dispositivo.
mkdir("/dados");

int fd = open("/dados/relatorio.txt", O_CREATE | O_WRONLY);
close(fd);

mknod("/console", 1, 1);

Os números passados para mknod identificam o dispositivo no kernel. Quando um processo abre esse tipo de arquivo, as operações de leitura e escrita são direcionadas para o driver do dispositivo, em vez do sistema de arquivos.

Inodes, Links e Metadados

O inode é a estrutura interna que armazena os metadados de um arquivo: tipo, tamanho, localização dos blocos no disco e número de referências. Cada arquivo pode ter vários nomes, todos apontando para o mesmo inode.

A chamada fstat recupera informações do inode a partir de um descritor.

#define T_DIR     1
#define T_FILE    2
#define T_DEVICE  3

struct stat {
  int   dispositivo;
  uint  numero_inode;
  short tipo;
  short quantidade_links;
  uint64 tamanho;
};

Criando e Removendo Links

link adiciona um novo nome a um inode existente.

open("original.txt", O_CREATE | O_WRONLY);
link("original.txt", "alternativo.txt");

unlink remove um nome do sistema de arquivos. O inode e os blocos de dados só são liberados quando não existem mais links nem descritores abertos para ele.

unlink("original.txt");

// padrão comum para arquivos temporários:
int fd = open("/tmp/temporario", O_CREATE | O_WRONLY);
unlink("/tmp/temporario");

No exemplo acima, o arquivo continua existindo enquanto fd estiver aberto, mas não possui nome no sistema de arquivos.

Utilitários do Shell

Conversão de Entrada em Argumentos com xargs

O utilitário xargs transforma linhas lidas da entrada padrão em argumentos de linha de comando. Isso é necessário porque alguns programas, como mkdir e rm, não aceitam diretamente dados vindos de um pipe.

echo "um dois tres" | xargs mkdir

O comando acima é equivalente a:

mkdir um dois tres

Outro exemplo:

echo "ola mundo" | xargs echo

A saída será:

ola mundo

Sintaxe geral:

xargs [opcoes] comando

O comando indicado após xargs é executado recebendo os tokens provenientes da entrada padrão.

Tags: xv6 MIT 6.081 system calls fork pipe

Publicado em 6-25 01:36