DEV Community

Igor Melo
Igor Melo

Posted on

Implementando um cat em C

Introdução

O cat é uma ferramenta muito utilizada para ler e escrever em arquivos.

Ela faz parte do Coreutils, que é um conjunto de utilitários para manipular arquivos, shell e texto, como ls, chmod, mkdir, cd, entre outros.

Exemplos de utilização do cat

Ler um arquivo:

$ cat arquivo.txt
Esse
é
o 
conteúdo!
Enter fullscreen mode Exit fullscreen mode

Ler mais de um arquivo:

$ cat arquivo1.txt arquivo2.txt
Conteúdo do arquivo 1
Conteúdo do arquivo 2
Enter fullscreen mode Exit fullscreen mode

Entendendo stdin, stdout e stderr

Esses três são streams de dados, ou seja, são um “caminho” por onde dado é recebido ou enviado.

stdin representa a entrada padrão (stan*dard **in*put) do programa, que no terminal geralmente é o teclado.

stdout representa a saída padrão (*standard **out*put) do programa, e stderr representa a **saída padrão de erro (*standard **err***or), sendo que ambas são geralmente a tela do terminal.

O “geralmente” é usado por 2 motivos:

  1. Essas 3 streams podem ser redirecionadas para arquivos e outros lugares
  2. Em aparelhos como um Arduino a entrada pode vir de sensores e a saída pode ser para um display

Detalhe: no C, stdin, stdout e stderr são arquivos, então lidamos com eles da mesma forma que lidaríamos com qualquer outro arquivo.

Redirecionando streams no terminal

Ainda no exemplo do cat, se rodarmos somente o comando sem nenhum argumento, o que ele faz é ler do stdin e escrever no stdout.

$ cat
oi            # eu escrevi
oi            # cat printou
tudo bem?     # eu escrevi
tudo bem?     # cat printou
Enter fullscreen mode Exit fullscreen mode

A princípio pode parecer meio sem utilidade o cat ”imitar” o que você escreve, mas isso se torna útil quando você redireciona a saída para um arquivo usando o >.

$ cat > novo.txt
Escrevendo o
conteúdo
desse arquivo
# aperte ctrl + D para finalizar
Enter fullscreen mode Exit fullscreen mode
$ cat < original.txt > copia.txt
Enter fullscreen mode Exit fullscreen mode

Iniciando a implementação em C usando stdio.h

Agora que os conceitos de entrada e saída padrão já estão mais claros, podemos começar a implementar o código do nosso cat.

Para isso vamos partir dessa estrutura básica:

#include <stdio.h>

int main(int argc, char *argv[]) {
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

E agora vamos implementar só a parte de ler do stdin e escrever no stdout:

#include <stdio.h>

int main(int argc, char *argv[]) {

  // argc: número de argumentos
  // argc == 1 signfica que o programa foi chamado sem nenhum
  // argumento extra, exemplo: ./cat

  if (argc == 1) {
    while (1) {
      // lê um caracter do stdin
      int ch = getc(stdin);

      // EOF significa fim do arquivo (end of file)
      if (ch == EOF)
        break;

      // escreve o caracter no stdout
      putc(ch, stdout);
    }
  }

  return 0;
}
Enter fullscreen mode Exit fullscreen mode

O que esse programa faz é ficar lendo cara caractere do stdin até o último do arquivo.

Se a entrava for o teclado, ele lê até forçamos um EOF apertando ctrl + D.

Validando usando o cat original

Criei esse shell script para testar se o programa tem o mesmo comportamento do cat:

# para cada arquivo na pasta home
for f in ~/*; do

    # executa o cat real, o meu cat e tira o md5 da saída
    # < $f redireciona o conteúdo do arquivo atual para o stdin
    a=$(cat < $f | md5sum)
    b=$(./cat < $f | md5sum)

    # se o md5 for diferente, é sinal que algo está errado
    if [[ "$a" != "$b" ]]
    then
        echo "$f ($a vs $b)"
    fi
done
Enter fullscreen mode Exit fullscreen mode

Isso não garante que nosso cat está totalmente correto, mas nos dá uma confiança maior de que está funcionando igual para muitos cenários.

Implementando o cat com argumentos

Caso sejam passados argumentos, o cat vai tratá-los como nomes de arquivos, vai tentar ler um por um e escrever seu conteúdo no stdout.

  • Obs.: o cat também recebe opcionalmente alguns outros argumentos, mas não vamos implementá-los aqui.
#include <stdio.h>

int main(int argc, char *argv[]) {
  if (argc == 1) {
    ...
  } else {
    for (int i = 1; i < argc; i++) {

      // abrimos o arquivo no modo leitura
      FILE *file = fopen(argv[i], "r");

      while (1) {
        // lê um caracter do arquivo, printa no stdout
        int ch = getc(file);
        if (ch == EOF)
          break;
        putc(ch, stdout);
      }

      // fechamos o arquivo para evitar problemas
      fclose(file);
    }
  }

  return 0;
}
Enter fullscreen mode Exit fullscreen mode

Tratando arquivos que não podem ser lidos

Se você passar um arquivo que não existe ou que você não tem permissão para ler, vai acontecer um segmentation fault:

$ ./cat naoexiste     
[1]    99032 segmentation fault (core dumped)  ./cat naoexiste
Enter fullscreen mode Exit fullscreen mode

Isso acontece porque quando o arquivo não pode ser aberto, o fopen retorna NULL e altera a variável global errno para o código do erro.
Precisamos tratar esses erros antes de tentar fazer qualquer operação com o arquivo, senão teremos comportamentos indefinidos no nosso programa.

#include <stdio.h>
#include <errno.h>

// usamos a função strerror para printar uma
// mensagem de erro com base no errno
#include <string.h>

int main(...) {
  // se tudo correr bem, esse valor não vai mudar,
  // e vamos retornar 0, que signifca ok
  int exit_status = 0;

  ...

  else {
      FILE *file = fopen(argv[i], "r");

      // arquivo não existe / não pode ser lido
      if (file == NULL) {

        // printar na saída de erro no mesmo formato que o cat, exemplo:
        // cat: naoexiste: No such file or directory
        fprintf(stderr, "%s: %s: %s\n", argv[0], argv[i], strerror(errno));

        // se o arquivo não existe, o cat não encerra imediatamente,
        // mas continua lendo os próximos arquivos e só depois 
        // finaliza com código de erro
        exit_status = errno;
        continue;
      }

      ...
    }
  }

  // retornando o status
  return exit_status;
}
Enter fullscreen mode Exit fullscreen mode

Também seria necessário tratar erros de leitura e escrita em arquivos, mas vamos deixar para a parte 2.

Código completo feito com stdio.h

#include <errno.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) {
  int exit_status = 0;

  if (argc == 1) {
    while (1) {
      int ch = getc(stdin);
      if (ch == EOF)
        break;
      putc(ch, stdout);
    }
  } else {
    for (int i = 1; i < argc; i++) {
      FILE *file = fopen(argv[i], "r");

      if (file == NULL) {
        fprintf(stderr, "%s: %s: %s\n", argv[0], argv[i], strerror(errno));
        exit_status = errno;
        continue;
      }

      while (1) {
        int ch = getc(file);
        if (ch == EOF)
          break;
        putc(ch, stdout);
      }
    }
  }

  return exit_status;
}
Enter fullscreen mode Exit fullscreen mode

Dúvidas comuns

Ler/escrever byte por byte não prejudica a performance?

Não, porque os arquivos no C por padrão são “buferizados” (buffered), ou seja, quando você chama putc, esse byte é escrito num array (buffer) em memória até uma certa quantidade pré-definida, e depois esse array inteiro é escrito no arquivo de uma vez.

Dessa forma a escrita se torna mais rápida, pois escrever na memória é ordens de vezes mais rápido que no disco.
Essa escrita no arquivo de fato é chamada de “flush”, e você pode forçar um flush a qualquer momento no C usando fflush.

Da mesma forma a leitura de um arquivo com getc lê vários bytes e guarda num buffer em memória. Próximas chamadas para getc vão ler desse buffer e não do disco, até que seja necessário consultar o disco de novo.

Usei getc e putc como exemplos, mas o buffer é aplicado independente da função que você está usando para leitura ou escrita.

Conclusão

Bom, já falei bastante, apesar de não ter dado para abordar alguns assuntos.
Espero que esse texto tenha sido útil e tenha te trazido insights sobre C, sobre o programa cat e sobre como lidar com stdin, stdout e stderr.

Na parte 2 eu pretendo reimplementar esse código de forma mais baixo nível, falando diretamente com o kernel, implementando manualmente os buffers de leitura e escrita e usando outra estratégia para tratar erros.

Como sempre qualquer correção, sugestão ou informação que agregue é muito bem vinda.

Até a próxima!

Top comments (2)

Collapse
 
eduardoklosowski profile image
Eduardo Klosowski

stdin, stdout e stderr na verdade são file descriptors, que é a interface utilizada para manipular arquivos do C e do Linux também.

Lembrei desse texto que escrevi:

Collapse
 
igormelo profile image
Igor Melo

Corrigi a indentação do código que estava misturando tab e espaços