cblc/aulas/10-dataio/README.org

14 KiB

Curso Básico da Linguagem C

Aula 10: Entrada e saída de dados

Vídeo desta aula

Tabela de descritores de arquivos

Um fluxo de dados (ou stream) é a abstração, em software, do transito de dados entre dispositivos de hardware e processos no espaço de usuário.

Por exemplo:

  • A leitura dos dados digitados no teclado do terminal;
  • A impressão (ou exibição) de mensagens no display do terminal;
  • O acesso a um arquivo para escrita e/ou leitura de dados.

No sistema operacional, esses fluxos são estabelecidos e controlados por diversas estruturas (structs), associadas individualmente a cada processo, chamadas de tabelas de descritores de arquivos.

Ao serem iniciados, os processos herdam vários dados de seus respectivos processos pais, entre eles, uma cópia da tabela de descritores de arquivos representando, no mínimo, três fluxos de dados padrão.

Fluxos de dados padrão

Por padrão, todo processo inicia com acesso a, pelo meno, três dispositivos ligados a um terminal:

  • Entrada padrão (/dev/stdin): leitura da digitação no terminal;
  • Saída padrão (/dev/stdout): exibição de mensagens no terminal;
  • Saída padrão de erros (/dev/stderr): exibição de mensagens de erros no terminal.

Cada um desses dispositivos padrão é disponibilizado para cada processo, individualmente, através de uma entrada na sua tabela de descritores de arquivos:

  • stdin: descritor de arquivos 0;
  • stdout: descritor de arquivos 1;
  • stderr: descritor de arquivos 2.

Uma nota sobre dispositivos

Sistemas parecidos com o UNIX geralmente trabalham com 7 tipos de arquivos:

  • Diretórios: arquivos que indexam a localização de outros arquivos;
  • Arquivos comuns: como os arquivos dos nossos textos, programas e imagens;
  • Ligações simbólicas: arquivos que representam outros arquivos;
  • Dispositivos caractere: abstração da comunicação com dispositivos de hardware sem o acúmulo de dados;
  • Dispositivos bloco: abstração da comunicação com dispositivos de hardware com acúmulo de dados;
  • Pipes/FIFOs: arquivos que interfaceiam a troca de dados entre processos;
  • Sockets: arquivos de interfaceiam o acesso a serviços.

Então, quando dizemos algo como: "dispositivo de terminal", ou "dispositivo padrão de entrada" (ou de saída), nós estamos falando de um desses tipos de arquivos.

Representação em /proc

Como vimos na aula 8, o kernel disponibiliza todas as informações sobre todos os processos em execução na forma de um sistema de arquivos virtual (procfs) montado no diretório /proc. Nele, cada processo terá um subdiretório nomeado segundo seu número de identificação (PID) e, neste subdiretório, existe um outro subdiretório com as ligações simbólicas entre os descritores de arquivos do processo e os arquivos e dispositivos a que ele tem acesso: é o diretório /proc/PID/fd.

Se listarmos, por exemplo, o diretório fd do processo do shell, nós veremos algo assim:

$ ls -l /proc/$$/fd
total 0
lrwx------ 1 blau blau 64 abr 23 08:09 0 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 08:09 1 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 08:09 2 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 08:09 255 -> /dev/pts/1

Aqui, $$ é a expansão do parâmetro especial do shell que contém o número do seu processo.

Observe que os três descritores de arquivos padrão (0, 1 e 2) estão presentes como ligações simbólicas (outro tipo de arquivo) para o mesmo dispositivo de terminal (/dev/pts/1).

Um exemplo em C

Com a função system, declarada no cabeçalho stdlib.h, nós podemos executar o utilitário ls para listar os descritores de arquivos atribuídos ao processo iniciado para a execução do nosso programa (fdlist.c):

#include <stdlib.h>

int main(void) {
    system("ls -l /proc/$(pidof a.out)/fd");
    return 0;
}

O utilitário pidof exibe o número do PID de um processo a partir do nome do programa que o iniciou passado como argumento: no caso, o nosso a.out.

Compilando e executando:

:~$ gcc -Wall fdlist.c
:~$ ./a.out
total 0
lrwx------ 1 blau blau 64 abr 23 08:16 0 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 08:16 1 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 08:16 2 -> /dev/pts/1

Repare que, como o processo foi iniciado pela mesma sessão do shell do exemplo anterior, nós temos os três descritores de arquivos padrão ligados ao mesmo terminal de antes (/dev/pts/1). Isso aconteceu porque esses fluxos de dados foram herdados do processo pai (o processo do shell).

Redirecionamentos e pipes

Também na aula 8, nós vimos que as linhas de comandos simples podem conter operadores de redirecionamento de fluxos de dados de/para arquivos, ou ainda, poderiam ser encadeados com outros comandos simples através do operador de pipe.

Redirecionamentos

Quando um fluxo de dados é redirecionado, por exemplo, para ler um arquivo, o que é feito com o operador de redirecionamento de leitura (<), a ligação do descritor de arquivos 0 (stdin) passa a ser feita com o arquivo.

Por exemplo, se eu tiver um arquivo chamado lista.txt e redirecionar seu conteúdo para leitura pelo programa do exemplo anterior, o resultado seria esse:

:~$ ./a.out < lista.txt
total 0
lr-x------ 1 blau blau 64 abr 23 08:47 0 -> /home/blau/lista.txt
lrwx------ 1 blau blau 64 abr 23 08:47 1 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 08:47 2 -> /dev/pts/1

Já se eu quisesse escrever a saída do programa em um arquivo, bastaria fazer um redirecionamento de escrita (operadores > ou >>):

:~$ ./a.out > lista.txt
:~$ cat lista.txt
total 0
lrwx------ 1 blau blau 64 abr 23 08:49 0 -> /dev/pts/1
l-wx------ 1 blau blau 64 abr 23 08:49 1 -> /home/blau/lista.txt
lrwx------ 1 blau blau 64 abr 23 08:49 2 -> /dev/pts/1

Como eu redirecionei a saída padrão do programa para um arquivo, nada foi mostrado no terminal e, por isso, eu exibi o conteúdo do arquivo com o utilitário cat.

Pipes

Com os pipes é semelhante, mas nós teríamos uma ligação com outro processo interfaceada por um arquivo do tipo pipe criado pelo sistema.

Por exemplo, eu posso encadear o nosso programa de exemplo em um pipe com o utilitário cat:

:~$ ./a.out | cat
total 0
lrwx------ 1 blau blau 64 abr 23 08:56 0 -> /dev/pts/1
l-wx------ 1 blau blau 64 abr 23 08:56 1 -> pipe:[2551457]
lrwx------ 1 blau blau 64 abr 23 08:56 2 -> /dev/pts/1

Aqui, a saída do nosso programa (descritor de arquivos 1) está ligada ao arquivo pipe:[2551457] para escrita. Ao mesmo tempo, o processo do cat teria a sua entrada padrão (descritor de arquivos 0) ligada ao mesmo arquivo para leitura.

Eu posso até modificar meu programa para exibir os diretórios fd dos dois processos:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    puts("Processo do programa:");
    fflush(stdout);
    system("ls -l /proc/$(pidof a.out)/fd");

    puts("\nProcesso do cat:");
    fflush(stdout);
    system("ls -l /proc/$(pidof cat)/fd");
    return 0;
}

Compilando e executando com o pipe novamente:

:~$ gcc -Wall fdlist.c
:~$ ./a.out | cat
Processo do programa:
total 0
lrwx------ 1 blau blau 64 abr 23 09:06 0 -> /dev/pts/1
l-wx------ 1 blau blau 64 abr 23 09:06 1 -> pipe:[2550676]
lrwx------ 1 blau blau 64 abr 23 09:06 2 -> /dev/pts/1

Processo do cat:
total 0
lr-x------ 1 blau blau 64 abr 23 09:06 0 -> pipe:[2550676]
lrwx------ 1 blau blau 64 abr 23 09:06 1 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 09:06 2 -> /dev/pts/1

Acumuladores (buffers)

Na última versão do exemplo, eu precisei chamar a função fflush(stdout), e veja o que aconteceria sem ela:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    puts("Processo do programa:");
    system("ls -l /proc/$(pidof a.out)/fd");

    puts("\nProcesso do cat:");
    system("ls -l /proc/$(pidof cat)/fd");
    return 0;
}

Compilando e executando:

:~$ gcc -Wall fdlist.c
:~$ ./a.out | cat
total 0
lrwx------ 1 blau blau 64 abr 23 09:12 0 -> /dev/pts/1
l-wx------ 1 blau blau 64 abr 23 09:12 1 -> pipe:[2569607]
lrwx------ 1 blau blau 64 abr 23 09:12 2 -> /dev/pts/1
total 0
lr-x------ 1 blau blau 64 abr 23 09:12 0 -> pipe:[2569607]
lrwx------ 1 blau blau 64 abr 23 09:12 1 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 09:12 2 -> /dev/pts/1
Processo do programa:

Processo do cat:

Notou que as linhas impressas pela função puts só foram exibidas depois das listagens feitas pela função system?

Isso aconteceu porque, em um pipe, a saída do programa é totalmente bufferizada, ou seja, os dados impressos por funções como puts e printf são acumuladas no buffer de saída do processo, mas não podem ser despejadas imediatamente na saída padrão até que outros subprocessos o façam. Como nós temos as chamadas da função system, que iniciam subprocessos para executar comandos, as listagens dos diretórios são despejadas e só depois as linhas das chamadas de puts são despejadas.

Isso não aconteceria se não houvesse um pipe:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    puts("Processo do programa:");
    system("ls -l /proc/$(pidof a.out)/fd");

    puts("\nProcesso do cat:");
    system("ls -l /proc/$(pidof cat)/fd");
    return 0;
}

Compilando e executando:

:~$ gcc -Wall fdlist.c
:~$ ./a.out
Processo do programa:
total 0
lrwx------ 1 blau blau 64 abr 23 09:21 0 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 09:21 1 -> /dev/pts/1
lrwx------ 1 blau blau 64 abr 23 09:21 2 -> /dev/pts/1

Processo do cat:
ls: não foi possível acessar '/proc//fd': Arquivo ou diretório inexistente

Ignorando o erro (o cat não estava em execução), veja que as linhas foram impressas na ordem em que apareciam no programa, sem a função fflush. Isso porque, sem pipes ou redirecionamentos ocupando a saída padrão, o buffer de saída do programa é liberado linha a linha (na ocorrência do caractere de quebra de linha \n). Mas, com um redirecionamento, por exemplo…

:~$ ./a.out > lista.txt
ls: não foi possível acessar '/proc//fd': Arquivo ou diretório inexistente
:~$ cat lista.txt
total 0
lrwx------ 1 blau blau 64 abr 23 09:38 0 -> /dev/pts/1
l-wx------ 1 blau blau 64 abr 23 09:38 1 -> /home/blau/lista.txt
lrwx------ 1 blau blau 64 abr 23 09:38 2 -> /dev/pts/1
Processo do programa:

Processo do cat:

Nota: veja que a mensagem de erro não foi escrita no arquivo, mas foi exibida no terminal, porque ela foi enviada através da saída padrão de erros (stderr).

O que a função fflush faz, com o fluxo de dados stdout como argumento, é forçar a descarga do que houver no buffer de saída do programa, independente de haver pipes ou redirecionamentos.

Buffers de entrada e saída do programa

Voltando um pouco o assunto, algumas funções da libc/glibc alocarão algum espaço no heap do processo do programa para acumular dados recebidos pela entrada padrão ou destinados à saída padrão.

Por exemplo:

  • Produzem um buffer de saída: funções puts, printf, etc;
  • Produzem um buffer de entrada: funções scanf, fgets, etc.

Buffer de saída do programa

No caso do buffer de saída, como vimos, o tipo de acumulação é determinado, entre outras coisas, pela eventualidade de algum pipe ou redirecionamento:

  • Bufferização linha a linha: programa executado sem pipes ou redirecionamentos.
  • Bufferização total: programa executado em um pipe ou com redirecionamentos.
  • Sem bufferização: com chamadas de sistema (ex: write) ou quando configurado explicitamente.

Buffer de entrada do programa

De certo modo, a acumulação de dados lidos de arquivos ou do terminal são mais simples, ou melhor, não estão sujeitos a tantas condições como o buffer de saída, mas também têm as suas particularidades.

Com a funções como scanf e fgets, a entrada é bufferizada e todos os bytes recebidos são acumulados. No entanto, caberá às características de cada função determinar como esses dados serão consumidos no programa.

Por exemplo, se utilizarmos as funções scanf ou fgets para ler algo digitado no terminal (entrada interativa), pode ser que, de tudo que for digitado, apenas uma parte seja consumida, fazendo com que reste alguma coisa no buffer de entrada. Os dados restantes poderão vir a ser consumidos nas chamadas subsequentes dessas funções, com resultados bastante inconvenientes!

Como a glibc não possui uma função para consumir os dados restantes no buffer de entrada após uma leitura, é comum utilizarmos algo assim depois da chamada de uma função com entrada bufferizada (se a leitura for da entrada padrão):

int ch;
while ((ch = getchar()) != '\n' && ch != EOF);

Nas próximas aulas, nós falaremos das particularidades da leitura da entrada padrão com as funções scanf e fgets.

Entrada padrão não bufferizada

Quando a entrada padrão não é bufferizada, nós ficamos sujeitos às regras de bufferização do lado do sistema: em especial, o buffer do dispositivo de terminal.

Por exemplo, numa leitura interativa com a chamada de sistema read, onde nós limitamos a quantidade de bytes que serão consumidos, pode restar algo do que foi digitado no buffer do terminal e, se o nosso programa não consumir esses bytes excedentes, eles serão descarregados na saída padrão pelo próprio terminal, quando o processo do nosso programa terminar, e serão lidos pelo shell como se fosse um comando.

Nas próximas três aulas, nós vamos explorar a leitura interativa de dados digitados no terminal e todas essas particularidades serão demonstradas.