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
- Abstração: ocultar a complexidade do hardware por meio de interfaces uniformes.
- Multiplexação: permitir que múltiplos programas compartilhem os mesmos recuross físicos.
- Isolamento: garantir que falhas em um processo não afetem outros processos.
- Compartilhamento: viabilizar troca de dados e cooperação entre aplicações.
- Controle de Acesso: possibilitar compartilhamento seletivo, com permissões adequadas.
- Desempenho: não limitar a eficiência dos programas; quando possível, auxiliar na aceleração.
- 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
2aos 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
-1imediatamente. - Se o estado de saída não for necessário, pode-se passar
NULL. - Caso contrário, o valor passado para
exitpelo 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,
readretorna a quantidade de bytes efetivamente lidos. - Se o pipe estiver vazio e a extremidade de escrita estiver fechada,
readretorna0. - Se o pipe estiver vazio e a extremidade de escrita ainda estiver aberta,
readbloqueia.
Escrita:
- Se a extremidade de leitura estiver fechada, o processo escritor recebe o sinal
SIGPIPEe é encerrado. - Se o buffer estiver cheio,
writebloqueia. - 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
mkdircria um novo diretório.opencom a flagO_CREATEcria um arquivo de dados.mknodcria 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.