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!
Ler mais de um arquivo:
$ cat arquivo1.txt arquivo2.txt
Conteúdo do arquivo 1
Conteúdo do arquivo 2
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 (standard input) do programa, que no terminal geralmente é o teclado.
stdout
representa a saída padrão (standard output) do programa, e stderr
representa a saída padrão de erro (standard error), sendo que ambas são geralmente a tela do terminal.
O “geralmente” é usado por 2 motivos:
- Essas 3 streams podem ser redirecionadas para arquivos e outros lugares
- 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
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
$ cat < original.txt > copia.txt
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;
}
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;
}
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
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;
}
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
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;
}
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);
}
fclose(file);
}
}
return exit_status;
}
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!