pbn/curso/aula-08.org
2025-06-02 11:48:54 -03:00

50 KiB
Raw Permalink Blame History

8 Fluxos de dados

Objetivos

  • Compreender o conceito de fluxos de dados.
  • Entender a relação entre descritores de arquivos e streams.
  • Demonstrar como os mecanismos de pipe e redirecionamento são implementados em baixo nível.
  • Introduzir a criação de programas em baixo nível que recebem dados interativamente.
  • Aprender a diferenciar a origem de um fluxo de entrada de dados.

Tabela de descritores de arquivos

Um fluxo de dados é a abstração, em software, de todas as possíveis combinações de trocas de dados entre arquivos e processos no espaço de usuário, como 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 e até a mediação da troca de dados entre dois processos.

No nível do 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, onde cada entrada é um inteiro sem sinal chamado de descritor de arquivo (ou FD, de file descriptor).

O diagrama abaixo representa, conceitualmente, como o kernel do Linux organiza os descritores de arquivos e suas estruturas internas para cada processo:

TABELA DE DESCRIOTES
    DE ARQUIVOS        ┌─────────────┐       ┌──────────────────┐
  ┌──────────────┐     │             ├───────┤ FILE OPETRATIONS │
  │   PROCESSO   │  ┌──┤ STRUCT FILE ├────┐  └──────────────────┘
  ├──────────────┤  │  │             ├──┐ │  ┌──────────────────┐
  │ FD0 (STDIN)  ├──┘  └─────────────┘  │ └──┤      INODE       │
  ├──────────────┤                      │    └──────────────────┘
  │ FD1 (STDOUT) ├── ···                │    ┌──────────────────┐
  ├──────────────┤                      └────┤     DIR ENTRY    │
  │ FD2 (STDERR) ├── ···                     └──────────────────┘
  ├──────────────┤                                   ···
  │     ···      ├── ···
  └──────────────┘

Nele, nós podemos ver que cada entrada da tabela (um descritor de arquivo) está associada a uma estrutura de dados (struct file), gerenciada pelo kernel, onde são encontradas diversas informações sobre um arquivo e as operações que serão realizadas com ele.

Uma nota sobre tipos de arquivos

Sistemas parecidos com o UNIX, como o GNU/Linux, 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 dissermos algo como: "dispositivo de terminal", ou "ligações simbólicas", nós estaremos nos referindo a diferentes tipos de arquivos.

Descritores de arquivos padrão

No sistema de arquivos, já no espaço de usuário, os descritores de arquivos são disponibilizados aos processos como ligações simbólicas, e todo processo é iniciado com acesso a três deles os descritores de arquivos padrão:

  • Descritor de arquivo 0: para leitura de fluxos de dados;
  • Descritor de arquivo 1: para escrita de dados;
  • Descritor de arquivo 2: para escrita de dados relacionados a erros.

Esses descritores, assim como todos os outros criados ao longo da execução do processo, podem ser encontrados no diretório virtual /proc/<pid>/fd e, em princípio, eles estão ligados a um dispositivo de terminal. Os descritores de arquivos padrão também são expostos no sistema de arquivos pelas ligações simbólicas stdin, stout e stderr, no diretório /dev (de device):

:~$ ls -l /dev/std*
lrwxrwxrwx 1 root root 15 mai 28 11:30 /dev/stderr -> /proc/self/fd/2
lrwxrwxrwx 1 root root 15 mai 28 11:30 /dev/stdin -> /proc/self/fd/0
lrwxrwxrwx 1 root root 15 mai 28 11:30 /dev/stdout -> /proc/self/fd/1

Isso mostra claramente a relação entre os nomes simbólicos e os descritores de arquivos que representam:

  • stdin → Descritor de arquivo 0
  • stdout → Descritor de arquivo 1
  • stderr → Descritor de arquivo 2

Ainda na listagem anterior, note que as ligações apontam para os descritores de arquivos padrão de um processo chamado self ("eu mesmo", em inglês), que é um link mágico que expande o PID do processo que acessa o diretório fd através das ligações /dev/std*.

Por exemplo:

:~$ grep -E '^(Name:|Pid:)' /proc/self/status
Name:	grep
Pid:	262881

Isso fez com que o arquivo status, no diretório do processo do próprio grep, fosse filtrado para imprimir apenas as linhas iniciadas com Nome: e Pid:. Nós também podemos usar self para expandir a lista de arquivos no diretório fd do processo do shell em execução em um terminal:

:~$ printf '%s\n' /proc/self/fd/*
/proc/self/fd/0
/proc/self/fd/1
/proc/self/fd/2
/proc/self/fd/255
/proc/self/fd/3

No Bash, printf é um comando interno.

Além dos descritores padrão (0, 1 e 2), nós temos os descritores 255, criado pelo shell para uso interno, e 3, que é criado pelo kernel para resolver o caminho expandido por self, como podemos demonstrar listando os descritores do processo do ls utilizado para listar seu próprio diretório fd:

:~$ ls -l /proc/self/fd
total 0
lr-x------ 1 blau blau 64 mai 30 07:16 3 -> /proc/263784/fd
lrwx------ 1 blau blau 64 mai 30 07:16 0 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 07:16 1 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 07:16 2 -> /dev/pts/0

Sabendo o número do PID, nós podemos listar o conteúdo do diretório fd associado ao processo sem usar fd. Por exemplo, nós podemos descobrir o PID do processo do shell em execução no terminal com:

:~$ echo $$
252271

Em seguida, nós utilizamos o PID encontrado:

:~$ ls -l /proc/252271/fd
total 0
lrwx------ 1 blau blau 64 mai 30 05:56 0 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 05:56 1 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 05:56 2 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 05:56 255 -> /dev/pts/0

A expansão do PID do shell também poderia ser feita diretamente na linha de comando com: ls -l /proc/$$/fd.

Como podemos ver, as ligações simbólicas 0, 1 e 2 estão presentes e apontam para o dispositivo de terminal /dev/pts/0. Além deles, nós também podemos ver que o shell criou um quarto descritor de arquivo (255) para seu uso interno, mas o descritor 3 não existe, porque nós não usamos self.

Abstração em alto nível (stream)

Acima da interface de baixo nível, a biblioteca padrão da linguagem C (glibc) introduz uma camada de abstração bastante conveniente por meio de streams (de fluxos, em inglês), representadas por ponteiros do tipo FILE * para uma estrutura de controle que, entre outras coisas, encapsula os descritores de arquivos. Para entender a diferença entre descritores de arquivos e streams, imagine um arquivo como um livro numa biblioteca:

  • Descritor de arquivo: é como ter apenas o número de catálogo do livro no sistema da biblioteca você sabe onde o livro está, mas precisa ir até ele e manuseá-lo por conta própria.
  • Stream: é como ter um assistente que encontra o livro, traz até você, lembra em que página você parou, faz anotações enquanto você lê, e ainda avisa se houver algum problema com o conteúdo.

A glibc, portanto, fornece diversas funções de alto nível utilizando streams para abertura e fechamento de ligações com arquivos e tarefas de leitura e escrita, além de recursos como bufferização automática, verificação de erros e manipulação de arquivos. Os fluxos padrão (stdin, stdout e stderr) também são inicializados automaticamente como streams assim que o programa é executado, apontando para os descritores 0, 1 e 2, respectivamente.

Uso dos fluxos de dados padrão no shell

O shell utiliza o fluxo padrão de entrada (stdin) para ler os comandos digitados no terminal e os fluxos padrão de saída para imprimir, também no terminal, as saídas de comandos (stdout) e mensagens de erro (stderr). Os programas executados na linha de comandos, por sua vez, herdarão os descritores de arquivos do shell e também utilizarão os fluxos padrão para ler e escrever no mesmo terminal:

:~$ ls -l /proc/self/fd
total 0
lr-x------ 1 blau blau 64 mai 30 07:49 3 -> /proc/266285/fd
lrwx------ 1 blau blau 64 mai 30 07:49 0 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 07:49 1 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 07:49 2 -> /dev/pts/0
:~$ ls -l /proc/$$/fd
total 0
lrwx------ 1 blau blau 64 mai 30 05:56 0 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 05:56 1 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 05:56 2 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 05:56 255 -> /dev/pts/0

Veja que o processo do programa ls (primeira listagem) herdou o acesso ao pseudo terminal /dev/pts/0 de seu processo pai o processo do shell, na segunda listagem. Contudo, existem dois mecanismos, implementados como operadores do shell, que possibilitam alterar os arquivos associado aos descritores de arquivo padrão: redirecionamentos e pipes.

Mecanismo de redirecionamento

Com os redirecionamentos, nós podemos indicar os arquivos que substituirão o terminal como origem e destino de fluxos de dados. Por exemplo, em vez de imprimir a saída do ls no terminal, nós podemos desviar o fluxo em stdout para o arquivo exemplo.txt com os operadores > ou >>. Ambos criam o arquivo de destino se ele não existir, mas lidam de formas diferentes com arquivos existentes:

  • Operador de escrita em stdout (>): trunca (apaga) o conteúdo do arquivo antes de iniciar a escrita.
  • Operador de append em stdout (>>): inclui as novas linhas após o conteúdo existente no arquivo.

Como o nosso arquivo ainda não existe, tanto faz o operador, portanto…

:~$ ls -l /proc/self/fd > exemplo.txt
:~$

Nada foi impresso no terminal, mas o arquivo exemplo.txt foi criado e a saída do ls foi escrita nele:

:~$ cat exemplo.txt
total 0
lr-x------ 1 blau blau 64 mai 30 08:04 3 -> /proc/267394/fd
lrwx------ 1 blau blau 64 mai 30 08:04 0 -> /dev/pts/0
l-wx------ 1 blau blau 64 mai 30 08:04 1 -> /home/blau/exemplo.txt
lrwx------ 1 blau blau 64 mai 30 08:04 2 -> /dev/pts/0

Observe que o descritor de arquivo 1 (stdout) estava ligado ao arquivo /home/blau/exemplo.txt durante a execução do ls. O mesmo poderia ser feito com a saída padrão de erros, mas nós teríamos que informar o número do descritor de arquivos (2) junto ao operador. Por exemplo, a tentativa de listar um arquivo inexistente causaria um erro e imprimiria uma mensagem no terminal via stderr:

:~$ ls banana.txt
ls: não foi possível acessar 'banana.txt': Arquivo ou diretório inexistente

Esta saída poderia ser redirecionada para o arquivo exemplo.txt:

:~$ ls -l /proc/self/fd banana.txt 2> exemplo.txt
/proc/self/fd:
total 0
lr-x------ 1 blau blau 64 mai 30 08:14 3 -> /proc/268233/fd
lrwx------ 1 blau blau 64 mai 30 08:14 0 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 08:14 1 -> /dev/pts/0
l-wx------ 1 blau blau 64 mai 30 08:14 2 -> /home/blau/exemplo.txt

Aqui, a listagem em stdout foi impressa no terminal e a mensagem de erro foi escrita no arquivo:

:~$ cat exemplo.txt
ls: não foi possível acessar 'banana.txt': Arquivo ou diretório inexistente

Na listagem impressa, aliás, nós também podemos ver que o descritor de arquivo 2 foi redirecionado para o arquivo. A entrada padrão também pode ser redirecionada para a leitura de um arquivo, mesmo que o programa não o utilize. Isso é feito com o operador de redirecionamento de leitura (<):

$ ls -l /proc/self/fd < exemplo.txt
total 0
lr-x------ 1 blau blau 64 mai 30 08:20 3 -> /proc/268655/fd
lr-x------ 1 blau blau 64 mai 30 08:20 0 -> /home/blau/exemplo.txt
lrwx------ 1 blau blau 64 mai 30 08:20 1 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 08:20 2 -> /dev/pts/0

Veja que o descritor de arquivo 0 estava ligado a exemplo.txt durante a execução de ls.

Mecanismo de pipe

Enquanto o mecanismo de redirecionamento conecta processos a arquivos, o mecanismo de pipe conecta o fluxo padrão de saída de um processo ao fluxo padrão de entrada de outro processo através de um arquivo do tipo pipe.

┌────────────┐ STDOUT   ┌──────┐   STDIN ┌────────────┐ 
│ PROCESSO 1 ├─────────▶│ PIPE ├────────▶│ PROCESSO 2 │
└────────────┘          └──────┘         └────────────┘

No shell, o mecanismo é implementado com o operador de pipe (|) e expressa perfeitamente, na forma de interface com o usuário, todos os princípios da Filosofia Unix. Programas como ls e cat, por exemplo, são especializados em fazer muito bem apenas uma coisa (listar diretórios e concatenar conteúdos de arquivos), e ambos são capazes de trabalhar juntos através de um fluxo de texto canalizado pelo mecanismo do pipe:

:~$ ls -l /proc/self/fd /proc/$(pidof cat)/fd | cat
/proc/270862/fd:
total 0
lr-x------ 1 blau blau 64 mai 30 08:48 0 -> pipe:[1689012]
lrwx------ 1 blau blau 64 mai 30 08:48 1 -> /dev/pts/0
lrwx------ 1 blau blau 64 mai 30 08:48 2 -> /dev/pts/0

/proc/self/fd:
total 0
lr-x------ 1 blau blau 64 mai 30 08:48 3 -> /proc/270861/fd
lrwx------ 1 blau blau 64 mai 30 08:48 0 -> /dev/pts/0
l-wx------ 1 blau blau 64 mai 30 08:48 1 -> pipe:[1689012]
lrwx------ 1 blau blau 64 mai 30 08:48 2 -> /dev/pts/0

Neste exemplo, o ls foi executado para listar o conteúdo de seu diretório fd e o diretório do processo do cat que, por estar encadeado por pipe na linha do comando, foi executado simultaneamente e seu PID foi obtido com o utilitário pidof. Como resultado, nós temos os dois diretórios, onde podemos ver que a entrada padrão do processo do cat (FD0) estava ligada ao mesmo arquivo pipe conectado à saída padrão do ls (FD1).

Captura da entrada padrão

Uma terceira forma de manipulação de fluxos de dados,é a possibilidade de pausar a execução do shell para ler a entrada padrão e armazenar os dados lidos em uma ou mais variáveis. Isso é feito por meio do comando read, implementado como um comando interno do shell:

read [-r] VAR [VARS ...]   # Sintaxe POSIX
read [OPÇÕES] [VAR ...]    # Sintaxe no Bash

Nas versões POSIX, o read lê apenas a entrada padrão, mas o Bash inclui uma opção para a leitura de qualquer descritor de arquivo (opção -u).

Com o read, é possível receber dados interativamente, ler uma linha de um arquivo por redirecionamento de leitura ou ler uma linha da saída de outro comando encadeado por pipe, mas em circunstâncias bem distintas:

Critério Leitura interativa Redirecionamento Pipe
Origem da entrada Terminal Arquivo Arquivo pipe
Fim da leitura Enter \n ou EOF \n ou EOF
Interrupção Ctrl+D Não Não
Processo Shell corrente Shell corrente Subshell
Dados capturados Disponíveis Disponíveis Inexistentes

Origem da entrada

Enquanto pipes e redirecionamentos alteram a ligação com o descritor de arquivo 0 (stdin), a leitura interativa utiliza a ligação original com o terminal associado ao processo corrente do shell. Em scripts, isso pode ser detectado através de vários testes, como:

test -t 0        # Comando 'test' (POSIX e Bash)
[ -t 0 ]         # Comando '['    (POSIX e Bash)
[[ -t 0 ]]       # Comando composto '[[ ... ]]', do Bash

Nos três casos, os comandos terminam com sucesso (estado 0) se o descritor de arquivo 0 estiver ligado a um terminal.

Fim da leitura

O comando interno read lê apenas uma linha de dados, o que geralmente é delimitado pelo caractere de nova linha (\n na notação ANSI-C ou 0x0a na tabela ASCII) ou quando não houver mais caracteres para ler (fim do arquivo). Contudo, em leituras não interativas, o Bash possibilita a alteração do caractere delimitador de linha -d.

Numa leitura interativa, onde a linha é digitada pelo usuário no terminal, o fim da leitura acontece quando o terminal recebe o comando para enviar os caracteres lidos, o que é feito com a tecla Enter.

Notas:

  • Por padrão, o delimitador de fim de linha é descartado.
  • O Bash também implementa a possibilidade de finalizar o comando após a leitura de um dado número de caracteres (opções -n e -N).

Interrupção da leitura

No modo interativo, a leitura da entrada padrão pode ser cancelada com a combinação de teclas Ctrl+D, que envia para o terminal o caractere ASCII EOT (fim de transmissão). Já em pipes e redirecionamentos, a leitura não pode ser cancelada, mas o término dos processos envolvidos pode ser forçado, por exemplo, com a combinação de teclas Ctrl+C, que envia para eles o sinal SIGINT.

Processos envolvidos na leitura

Como o read é um comando interno do shell, sua execução não dá início a um novo processo. Isso vale para seu uso em comandos simples, mesmo que a leitura seja feita por um redirecionamento de leitura. Entretanto, se o read estiver encadeado por pipe com outros comandos, haverá o início de um novo processo do shell para executá-lo (um subshell), o que traz consequências importantes quanto à disponibilidade dos dados capturados.

Disponibilidade dos dados capturados

A linha lida pelo read é separada em campos conforme os caracteres definidos na variável interna IFS. Por padrão, esses caracteres são o espaço e a tabulação o caractere de nova de linha também está em IFS, mas é utilizado como delimitador de linha. O que determina a quantidade de campos é a quantidade de argumentos passados para o read como nomes de variáveis, por exemplo:

read          # Toda a linha será atribuída à variável interna 'REPLY'
read var      # Toda a linha será atribuída à variável 'var'
read a b c    # 'a' recebe o primeiro campo, 'b' o segundo e 'c' o restante da linha

De qualquer forma, todos os dados capturados serão atribuídos a variáveis de escopo local: ou seja, disponíveis apenas para o processo do shell em que o read for executado. Sendo assim, quando o read é executado em um pipe, todas as variáveis definidas serão perdidas quando o processo do subshell em que ele for executado terminar.

Uso dos fluxos de dados padrão em programas

Em baixo nível, os descritores de arquivos padrão são utilizados diretamente como argumentos de chamadas de sistema para leitura ou escrita no terminal associado à execução do processo do programa, como as chamadas read e write, quando passamos os descritores 0 e 1, respectivamente, como argumentos.

Também existem chamadas de sistema que possibilitam a escrita de programas que redirecionam os fluxos padrão para outros arquivos ou para pipes, como é o caso da chamada dup quando utilizada em conjunto com a chamada open (ou a chamada pipe) para trocar o descritor associado ao arquivo aberto por um dos descritores de arquivos padrão.

Demonstrando um redirecionamento interno

Por exemplo, se eu quiser que a saída do meu programa (salve-redir.asm) seja escrita em um arquivo, e não no terminal, eu posso redirecionar o descritor de arquivo 1 com a chamada de sistema dup2 após criar o arquivo de destino.

Obviamente, essa é a demonstração de um redirecionamento para escrita em stdout tal como é feita pelo shell: nós não precisamos redirecionar nada nos nossos programas para escrever em arquivos.

Arquivo: salve-redir.asm

; Chamadas de sistema...
%define SYS_WRITE  1
%define SYS_OPEN   2
%define SYS_CLOSE  3
%define SYS_DUP2   33
%define SYS_EXIT   60

; Flags da chamada SYS_OPEN...
%define O_WRONLY   0x1     ; apenas para escrita
%define O_CREAT    0x40    ; cria se não existir
%define O_TRUNC    0x200   ; trunca conteúdo existente

; Permissões do arquivo criado...
%define F_MODE     0o644

; Descritores de arquivos...
%define STDOUT     1
	
section .rodata
	file db "mensagem.txt", 0         ; arquivo de destino
	msg  db "Salve, simpatia!", 10    ; Mensagem
	len  equ $ - msg                  ; Tamanho da mensagem

section .text
	global _start

_start:
	; open (ARQUIVO, FLAGS, MODO)
	mov rax, SYS_OPEN
	mov rdi, file
	mov rsi, O_WRONLY | O_CREAT | O_TRUNC
	mov rdx, F_MODE
	syscall

	mov r12, rax ; salvar descritor de arquivo retornado

	; dup2(ANTIGO_FD, NOVO_FD) <-- redirecionamento
	mov rax, SYS_DUP2
	mov rdi, r12
	mov rsi, STDOUT
	syscall

	; write(FD, STRING, SIZE)
	mov rax, SYS_WRITE
	mov rdi, STDOUT
	mov rsi, msg
	mov rdx, len
	syscall

	; close(FD)
	mov rax, SYS_CLOSE
	mov rdi, r12
	syscall

	; exit(STATUS)
	mov rax, SYS_EXIT
	xor rdi, rdi
	syscall

Montagem e execução:

:~$ nasm -f elf64 salve-redir.asm
:~$ ld -o salve-redir salve-redir.o
:~$ ./salve-redir
:~$ cat mensagem.txt
Salve, simpatia!

Demonstrando um pipe interno

O programa abaixo (salve-pipe.asm) demonstra o encadeamento por pipe entre ele mesmo e o utilitário cat, também utilizando os descritores de arquivos padrão, como o shell faria. A ideia é emular o comportamento do comando:

echo 'Salve, simpatia!' | cat

Arquivo: salve-pipe.asm

; ----------------------------------------------------------
; Arquivo : salve-pipe.asm
; Reproduz: echo 'Salve, simpatia!' | cat
; Montagem: nasm -f elf64 salve-pipe.asm
; Ligação : ld -o salve-pipe salve-pipe.o 
; ----------------------------------------------------------
; Chamadas de sistema...
; ----------------------------------------------------------
%define SYS_WRITE    1	
%define SYS_CLOSE    3	
%define SYS_PIPE     22	
%define SYS_DUP2     33	
%define SYS_FORK     57	
%define SYS_EXECVE   59	
%define SYS_EXIT     60
%define SYS_WAIT     61
; ----------------------------------------------------------
; Parâmetros de SYS_WAIT...
; ----------------------------------------------------------
%define W_CHILD      -1		; filho de qualquer PID
%define W_STATUS     0		; ignorar o estado de término (NULL)
%define W_OPTIONS    0		; sem opções
; ----------------------------------------------------------
; Descritores de arquivos padrão...
; ----------------------------------------------------------
%define STDIN_FD     0
%define STDOUT_FD    1
; ----------------------------------------------------------
; Estados de término...
; ----------------------------------------------------------
%define EXIT_SUCCESS 0
%define EXIT_ERROR   1
; ----------------------------------------------------------
section .rodata
; ----------------------------------------------------------
	; Mensagem...
	msg db "Salve, simpatia!", 10
	len equ $ - msg

	; Argumentos de execve...
	comm db "/bin/cat", 0
	argv dq comm, 0
	envp dq 0
; ----------------------------------------------------------
section .bss
; ----------------------------------------------------------
	read_end resd 1 	; Descritor da ponta de leitura do pipe
	write_end resd 1 	; Descritor da ponta de escrita do pipe
; ----------------------------------------------------------
section .text
; ----------------------------------------------------------
global _start

_start:
	; pipe(int pipefd[])
	; retorna: pipefd[0] => read_end; pipefd[1] => write_end
	mov rax, SYS_PIPE
	mov rdi, read_end	; Retorno vai avançar em write_end
	syscall

.parada1:			; examinar FDs retornados!

	; fork()
	mov rax, SYS_FORK
	syscall
	
	test rax, rax		; rax = 0 -> processo filho
	jz .filho               ; pula a execução do código do pai
; ----------------------------------------------------------
; Código executado apenas no processo pai...
; ----------------------------------------------------------
.pai:
; ----------------------------------------------------------
	; dup2(write_end, STDOUT)
	mov rax, SYS_DUP2
	mov edi, [write_end]	; FD original: write_end
	mov rsi, STDOUT_FD	; novo descritor de escrita
	syscall

	; Fechar write_end e read_end...
	call _close_all

.parada2:			;examinar /proc/<pid>/fd!

	; write(STDOUT, msg, len)
	mov rax, SYS_WRITE
	mov rdi, STDOUT_FD
	mov rsi, msg
	mov rdx, len
	syscall

	; wait4(-1, NULL, 0)
	mov rax, SYS_WAIT
	mov rdi, W_CHILD
	mov rsi, W_STATUS
	mov rdx, W_OPTIONS
	
	; exit(0)
	mov rax, SYS_EXIT
	mov rdi, EXIT_SUCCESS	; sai com sucesso se chegar aqui
	syscall
; ----------------------------------------------------------
; Código executado apenas no processo filho...
; ----------------------------------------------------------
.filho:
; ----------------------------------------------------------
	; dup2(read_end, STDIN)
	mov rax, SYS_DUP2
	mov edi, [read_end]	; descritor de leitura no pipe
	mov rsi, STDIN_FD	; novo descritor de leitura
	syscall

	; Fechar write_end e read_end...
	call _close_all
	
	; execve("/bin/cat", ["/bin/cat", NULL], [NULL])
	mov rax, SYS_EXECVE
	mov rdi, comm
	mov rsi, argv
	mov rdx, envp
	syscall

	; exit(1)
	mov rax, SYS_EXIT
	mov rdi, EXIT_ERROR	; sai com erro se execve falhar
	syscall
; ----------------------------------------------------------
; Sub de fechamento dos descritores originais do pipe...
; ----------------------------------------------------------
_close_all:
; ----------------------------------------------------------
	; close(read_end)
	mov rax, SYS_CLOSE
	mov rdi, [read_end]
	syscall
	; close(write_end)
	mov rax, SYS_CLOSE
	mov rdi, [write_end]
	syscall
	ret

Montagem e execução:

:~$ nasm -g -felf64 salve-pipe.asm
:~$ ld -o salve-pipe salve-pipe.o
:~$ ./salve-pipe
Salve, simpatia!

Não há como observar o que o programa faz apenas pelo seu resultado, então vamos usar o gdb para analisar seus efeitos em dois pontos-chave (repare que montamos o programa com a opção -g):

  • Após a criação do pipe (.parada1:);
  • Antes da impressão da mensagem (.parada2:).

Nossos objetivos serão:

  • Observar os descritores de arquivos retornados para as pontas de leitura e escrita do pipe;
  • Observar o diretório /proc/<pid>/fd do processo pai após a criação do pipe;
  • Observar o diretório /proc/<pid>/fd do processo pai antes da impressão da mensagem;
  • Observar o diretório /proc/<pid>/fd do processo do cat antes da impressão da mensagem.

Abertura do programa no GDB:

:~$ gdb ./salve-pipe
Reading symbols from ./salve-pipe...
(gdb)

Definição dos pontos de parada e execução:

(gdb) b _start.parada1
Breakpoint 1 at 0x401011: file salve-pipe.asm, line 64.
(gdb) b _start.parada2
Breakpoint 2 at 0x401035: file salve-pipe.asm, line 86.
(gdb) r
Starting program: /home/blau/git/pbn/curso/exemplos/08/salve-pipe

Breakpoint 1, _start.parada1 () at salve-pipe.asm:64
64		mov rax, SYS_FORK
(gdb)

Neste ponto, a criação do pipe acabou de ser executada:

mov rax, SYS_PIPE
mov rdi, read_end       ; Retorno vai avançar em write_end
syscall

Quando a chamada pipe é executada, um arquivo do tipo pipe é criado e dois descritores de arquivos são associados a ele no processo:

┌──────────┐
│          ├─ 0
│          ├─ 1
│ PROCESSO ├─ 2              ┌──────┐
│          │◀─── READ_END ───┤      │
│          │                 │ PIPE │
│          ├─── WRITE_END ──▶│      │
└──────────┘                 └──────┘

Os números dos descritores de arquivos são dois inteiros (4 bytes cada) que, no nosso programa, são escritos a partir do endereço representado pelo rótulo read_end. Sendo assim, vamos examinar seus valores:

(gdb) x /1gx &read_end
0x403034 <read_end>:	0x0000000400000003
(gdb) x /1wx &read_end
0x403034 <read_end>:	0x00000003
(gdb) x /1wx &write_end
0x403038 <write_end>:	0x00000004

Isso mostra que os descritores de arquivos do pipe são 3 (ponta de leitura) e 4 (ponta de escrita). Sabendo o PID do programa, nós podemos listar o conteúdo de seu diretório fd em outro terminal:

:~$ pidof salve-pipe
547187
:~$ ls -l /proc/547187/fd
total 0
lrwx------ 1 blau blau 64 jun  1 10:03 0 -> /dev/pts/1
lrwx------ 1 blau blau 64 jun  1 10:03 1 -> /dev/pts/1
lrwx------ 1 blau blau 64 jun  1 10:03 2 -> /dev/pts/1
lr-x------ 1 blau blau 64 jun  1 10:03 3 -> pipe:[3331719]
l-wx------ 1 blau blau 64 jun  1 10:03 4 -> pipe:[3331719]

Observe as permissões r e w nos dois novos descritores de arquivos.

A partir daqui, o programa deve clonar o processo pai (chamada fork) em um novo processo filho, em seguida…

  • No processo pai:

    • Mover o descritor de escrita do pipe para a saída padrão;
    • Escrever a mensagem no pipe;
    • Aguardar o teŕmino do processo filho.
  • No processo filho:

    • Mover o descritor de leitura do pipe para a entrada padrão;
    • Executar o utilitário cat para ler o pipe.

O efeito sobre os descritores de arquivos pode ser visto no próximo ponto de parada, definido para antes da impressão da mensagem:

(gdb) c
Continuing.
[Detaching after fork from child process 549470]

Breakpoint 2, _start.parada2 () at salve-pipe.asm:86
86		mov rax, SYS_WRITE

No outro terminal, nós vamos repetir a listagem de fd:

:~$ ls -l /proc/547187/fd
total 0
lrwx------ 1 blau blau 64 jun  1 10:03 0 -> /dev/pts/1
l-wx------ 1 blau blau 64 jun  1 10:03 1 -> pipe:[3331719]
lrwx------ 1 blau blau 64 jun  1 10:03 2 -> /dev/pts/1

Como podemos ver no processo pai, os descritores 3 e 4 não existem mais e o pipe está conectado apenas ao descritor de arquivos 1 (stdout). Para ver como estão os descritores do processo do cat, vamos utilizar pidof na própria linha do comando:

:~$ ls -l /proc/$(pidof cat)/fd
total 0
lr-x------ 1 blau blau 64 jun  1 10:36 0 -> 'pipe:[3331719]'
lrwx------ 1 blau blau 64 jun  1 10:36 1 -> /dev/pts/1
lrwx------ 1 blau blau 64 jun  1 10:36 2 -> /dev/pts/1

O que demonstra que o pipe está ligado ao descritor de arquivo 0 (stdin) e o nosso programa conseguiu reproduzir perfeitamente o funcionamento do mecanismo de pipe no shell.

Demonstrando a leitura da entrada padrão

O kernel fornece uma chamada de sistema para leitura da entrada padrão ou de descritores de arquivos: a chamada read. Assim como o comando de mesmo nome no shell, ao atuar sobre stdin, essa chamada pode receber dados de forma interativa, ler arquivos por redirecionamento ou a saída de outro comando encadeado por pipe.

Contudo, diferente do comando, a chamada de sistema não se limita à leitura de uma linha. Em vez disso, ela tenta ler uma quantidade fixa de bytes e escreve os dados lidos no endereço de memória especificado. Interativamente, a leitura só é iniciada a partir do momento que o terminal envia os dados digitados para o processo geralmente, quando o usuário tecla Enter.

Uso da chamada de sistema 'read'

Na arquitetura x86_64, a chamada de sistema read é utilizada da seguinte forma para ler a entrada padrão:

mov rax, 0        ; número da chamada de sistema read
mov rdi, FD       ; Descritor de arquivo (0 = entrada padrão)
mov rsi, BUF      ; O endereço do buffer em que os bytes serão escritos
mov rdx, COUNT    ; A quantidade de bytes a ler
syscall           ; executa a chamada de sistema

O retorno, passado por rax, é a quantidade de bytes lidos ou -1 em caso de erro.

Exemplo de uso em Assembly

O exemplo abaixo (salve-read.asm) demonstra como podemos receber dados pela entrada padrão utilizando a chamada de sistema read.

Arquivo: salve-read.asm

; ----------------------------------------------------------
; Arquivo : salve-read.asm
; Montagem: nasm -f elf64 salve-read.asm
; Ligação : ld -o salve-read salve-read.o 
; ----------------------------------------------------------
; Chamadas de sistema
; ----------------------------------------------------------
%define SYS_READ     0
%define SYS_WRITE    1
%define SYS_EXIT     60
; ----------------------------------------------------------
; Descritores de arquivos padrão
; ----------------------------------------------------------
%define STDIN_FD     0
%define STDOUT_FD    1
; ----------------------------------------------------------
; Estados de término
; ----------------------------------------------------------
%define EXIT_SUCCESS 0
; ----------------------------------------------------------
; Constantes simbólicas
; ----------------------------------------------------------
%define BUF_SIZE     256
; ----------------------------------------------------------
section .rodata
; ----------------------------------------------------------
	msg db `Salve, simpatia!\nQual é a sua graça?\n`
	msg_len equ $ - msg
	
	resp db "Falaê, "
	resp_len equ $ - resp

	tail db `!\n`
; ----------------------------------------------------------
section .bss
; ----------------------------------------------------------
	buf resb BUF_SIZE	; Buffer de leitura
	count resd 1	; retorno de read (int)
; ----------------------------------------------------------
section .text
; ----------------------------------------------------------
global _start
_start:
	; Imprime a mensagem inicial
	mov rsi, msg
	mov rdx, msg_len
	call _print

	; Aguarda os dados da entrada padrão
	mov rax, SYS_READ
	mov rdi, STDIN_FD
	mov rsi, buf
	mov rdx, BUF_SIZE
	syscall

	; Salva retorno da chamada (bytes lidos)
	mov [count], eax

	; Imprime prefixo da resposta
	mov rsi, resp
	mov rdx, resp_len
	call _print

	; Imprime resposta
	mov rsi, buf
	mov rdx, [count]	; total de bytes lidos
	dec rdx		; desconta \n
	call _print

	; Imprime final da resposta
	mov rsi, tail
	mov rdx, 2	; !\n = 2 bytes
	call _print
	
	; Termina o programa
	mov rax, SYS_EXIT
	mov rdi, EXIT_SUCCESS
	syscall
; ----------------------------------------------------------
; Sub-rotinas...	
; ----------------------------------------------------------
_print:
; ----------------------------------------------------------
	mov rax, SYS_WRITE
	mov rdi, STDOUT_FD
	syscall
	ret

Montagem e execução:

:~$ nasm -f elf64 salve-read.asm
:~$ ld -o salve-read salve-read.o
:~$ ./salve-read
Salve, simpatia!
Qual é a sua graça?
[Espera a digitação]

Digitando, por exemplo, meu nome:

Blau
Falaê, Blau!

Testes com pipe e redirecionamento

O nosso exemplo também pode ler a entrada padrão se ela estiver ligada a um arquivo, por redirecionamento, ou à ponta de leitura de um pipe, como veremos nos próximos testes.

Leitura por pipe:

:~$ echo Fulano de Tal | ./salve-read
Salve, simpatia!
Qual é a sua graça?
Falaê, Fulano de Tal!

Leitura de um arquivo:

:~$ ./salve-read < /proc/sys/kernel/ostype
Salve, simpatia!
Qual é a sua graça?
Falaê, Linux!

Leitura em uma here string (Bash):

:~$ ./salve-read <<< 'Teste 123'
Salve, simpatia!
Qual é a sua graça?
Falaê, Teste 123!

Leitura de várias linhas (here doc):

:~$ ./salve-read << FIM
> Linha 1
> Linha 2
> Linha 3
> FIM
Salve, simpatia!
Qual é a sua graça?
Falaê, Linha 1
Linha 2
Linha 3!

Identificando a origem do fluxo de leitura

Para determinar se a entrada padrão está ligada ou não a um terminal, nós podemos utilizar a chamada de sistema ioctl (identificador 16), que manipula diversos parâmetros internos de dispositivos especiais, como os terminais.

Argumentos da chamada de sistema ioctl:

mov rax, 16    ; número da chamada de sistema ioctl
mov rdi, FD    ; descritor de arquivo (0 = stdin)
mov rsi, OP    ; código da operação (TCGETS = 0x5401)
mov rdx, BUF   ; NULL ou buffer para receber os parâmetros solicitados
syscall

O código de operação que nos interessa é TCGETS (0x5401), que é a solicitação das configurações do terminal associado ao descritor de arquivo em rdi. Se não houver a ligação com um terminal, a chamada termina com erro ENOTTY (não é um TTY) e retorna o inteiro -1 em rax. Como só queremos testar o retorno da chamada ioctl, nós não precisamos de um buffer e podemos passar o valor 0 (NULL) para rdx.

Exemplo genérico de teste:

    mov rax, 16      ; chamada ioctl
    mov rdi, 0       ; stdin
    mov rsi, 0x5401  ; TCGETS
    xor rdx, rdx     ; 0 = NULL
    syscall

    cmp rax, 0
    jl .noy_a_tty    ; rax < 0 -> não é um terminal

.is_a_tty:
    ; rotina se stdin for um terminal...
    ; ...
    jmp .end:
	
.not_a_tty:
    ; rotina se stdin não for um terminal...
    ; ...
.end:
    ; Rotinha de término...

Criação e manipulação de fluxos de dados

Criar um novo fluxo de dados tem o significado de estabelecer uma nova conexão entre um processo e um arquivo. Em alto nível, com C/C++ por exemplo, isso é feito através das abstrações da glibc, o que pode resultar na ligação com arquivos via streams (por exemplo, com fopen) ou via descritores de arquivos (por exemplo, com pipe).

Exemplos:

Função Descrição
fopen Abre um arquivo comum
fclose Fecha um stream
freopen Redireciona um FILE * existente
fdopen Associa um descritor de arquivo a um FILE *
popen Executa um comando e abre um pipe unidirecional
fmemopen Cria um FILE * associado a uma região da memória
open_memstream Cria um FILE * de escrita que escreve em memória
open_wmemstream Versão wide-char de open_memstream

Já em baixo nível, como vimos nos nossos exemplos, a criação de novos fluxos de dados é feita pelas chamadas de sistema do kernel e sempre resulta na ligação com arquivos via descritores de arquivos.

Exemplos (Linux x86_64):

Syscall Descrição
2 open Abre um arquivo e retorna um file descriptor
3 close Fecha um descritor de arquivo
85 creat Cria/abre arquivo para escrita
41 dup Duplica um descritor de arquivo
33 dup2 Duplica descritor para um valor específico
292 dup3 Como dup2, mas com flags
22 pipe Cria um pipe anônimo (int pipefd[2])
293 pipe2 Versão de pipe com flags adicionais
41 socket Cria endpoint de comunicação (AF_INET, AF_UNIX…)
53 socketpair Cria par de sockets conectados

Armazenamento temporário (bufferização)

Bufferização (buffering) é a técnica de usar áreas intermediárias de memória chamadas buffers para armazenar temporariamente dados durante operações de entrada e saída, principalmente com o propósito de:

  • Reduzir o número de chamadas de sistema.
  • Adaptar diferenças de velocidade entre a produção e o consumo de dados.
  • Agrupar dados em blocos maiores, aumentando a eficiência da comunicação com dispositivos ou arquivos.

Bufferização em alto nível

No GNU/Linux, a glibc implementa três modos de bufferização:

Tipo Descrição
Sem bufferização Dados são enviados diretamente, sem armazenar em buffer.
Bufferização por linha Dados são enviados quando uma nova linha é detectada (\n).
Bufferização total Dados são armazenados até o buffer ficar cheio ou ser liberado

Os três fluxos padrão estão associados a buffers internos mantidos pela glibc:

  • stdin: bufferizado totalmente.
  • stdout: bufferizado por linha se conectado a um terminal; caso contrário, totalmente.
  • stderr: sem bufferização.

Esses buffers fazem parte da estrutura FILE, da biblioteca C padrão, que aloca uma região de memória para guardar dados temporariamente.

Bufferização no nível do kernel

Além da glibc, o kernel Linux também implementa buffers internos em diversos contextos:

Tipo de buffer Onde ocorre Finalidade principal
Cachê de página Sistema de arquivos Acelerar leitura/escrita em disco
Buffer de pipe IPC (pipe) Comunicação entre processos
Buffer de socket Rede (TCP/UDP) Armazenar pacotes antes/depois da transmissão
Disciplina de linha Dispositivos de terminal Controlar entrada interativa
Buffer de blocos Dispositivos de bloco Otimização de leitura/escrita de disco

Cachê de página

O subsistema de arquivos do kernel utiliza cachês de página (page cache) para manter dados recentemente lidos ou escritos na memória RAM. Isso evita acessos frequentes ao disco, que são muito mais lentos. Quando um processo executa read, o kernel verifica se os dados já estão no cache; quando um processo executa write, os dados vão para o cache e são escritos no disco posteriormente (write-back).

Esse mecanismo é transparente para o usuário e só afeta o desempenho.

Buffers de pipes (IPC)

O kernel mantém buffers internos para comunicação entre processos (IPC) via pipes, que possuem um buffer circular (ring buffer) para armazenar dados de forma contínua em um espaço fixo de memória (tipicamente, 64kB em sistemas modernos). Ao escrever num pipe, os dados vão para esse buffer e são consumidos quando acontece a leitura. Se o buffer estiver cheio, a escrita é bloqueada; se estiver vazio, a leitura é bloqueada.

Esta condição padrão é chamada de modo bloqueante, mas também é possível abrir arquivos em modo não bloqueante se estivermos lidando com eventos simultâneos, escrevendo programas que não podem parar esperando operações de entrada e saída ou se precisarmos implementar um controle fino sobre o fluxo de dados.

Buffers de sockets

Comunicações via sockets também contam com buffers internos no kernel. Cada socket possui um buffer de envio (send buffer) e um recepção (receive buffer) para armazenar dados enquanto o pacote é processado, retransmitido ou recebido pela outra extremidade.

O tamanho dos buffers pode ser consultado com a chamada de sistema getsockopt ou definido com a chamada setsockopt.

Buffers de terminais (TTY)

Os dispositivos de terminal possuem buffers no kernel que são manipulados pelo driver de TTY. No modo canônico (também chamado de modo linha), os dados são acumulados até que o usuário tecle Enter. Além disso, o terminal é responsável pela edição a linha antes que ela seja entregue ao processo.

Esse buffer faz parte da disciplina de linha do kernel, um componente que fica entre o driver do terminal e o processo do usuário para interpretar e gerenciar entradas e saídas de texto.

As principais características do modo canônico, que é o modo padrão de terminais interativos, são:

  • Entrada bufferizada por linha.
  • Possibilidade de edição das linhas antes do envio.
  • Interpretação de caracteres de controle.

No modo não canônico (modo bruto ou "caractere a caractere"), a entrada não é bufferizada e, portanto, cada caractere digitado é disponibilizado imediatamente para o programa. Isso é muito utilizado por programas com interfaces em tela cheia (como o editor Vim, por exemplo), multiplexadores de terminal (como o Tmux e o GNU Screen) e pseudo terminais remotos (como no SSH).

Buffers de blocos (bloco de disco)

Dispositivos de bloco (como discos) usam buffers no kernel para:

  • Agrupar operações de escrita.
  • Reordenar acessos.
  • Sincronizar acesso com caches do sistema de arquivos.

Bufferização em Assembly (x86_64)

Em Assembly, os buffers são criados manualmente com a reserva de espaço na seção .bss (resb, resq, etc), utilizando a pilha ou alocando espaços dinamicamente, por exemplo, com as chamadas de sistema brk e mmap.

No último exemplo (salve-read.asm), foi o que fizemos quando reservamos 256 bytes (valor de BUF_SIZE) em .bss para armazenar os caracteres lidos pela chamada read:

; ----------------------------------------------------------
section .bss
; ----------------------------------------------------------
	buf resb BUF_SIZE	; Buffer de leitura
	count resd 1	; retorno de read (int)

Essa mesma técnica é aplicável a programas em C, geralmente utilizando vetores de caracteres ou alocando espaço no heap com funções como malloc, da biblioteca padrão.

Exercícios propostos

  1. Altere o tamanho do buffer do programa salve-read.asm para 10 (bytes), observe seu novo funcionamento e responda:

    • O que acontece quando digitamos mais de 10 caracteres?
    • Como você explica o que aconteceu?
  2. Ainda usando salve-read.asm como base, faça com que o programa só funcione no modo interativo, imprimindo uma mensagem por stderr e terminando com estado 1 caso esteja num pipe ou com a entrada padrão redirecionada para um arquivo.
  3. Pesquise e escreva um programa em C que reproduza o funcionamento do exemplo salve-pipe.asm.
  4. Pesquise e escreva programas em C que reproduzam o funcionamento do exemplo salve-read.asm e de todas as alterações feitas nos exercícios 1 e 2.

Tabela das chamadas de sistema utilizadas

Chamada #ID Propósito rax rdi rsi rdx
read 0 Ler um descritor de arquivo 0 fd (ex: STDIN=0) buffer quantidade
write 1 Escrever em um descritor de arquivo 1 fd (ex: STDOUT=1) buffer tamanho
open 2 Abre um arquivo 2 nome do arquivo flags modo
close 3 Fechar descritor de arquivo 3 fd - -
ioctl 16 Obter parâmetros de dispositivos 16 fd operação buffer
pipe 22 Criar pipe (par de descritores) 22 ponteiro pipefd[2] - -
dup2 33 Duplicar descritor de arquivo 33 oldfd newfd -
fork 57 Criar novo processo 57 - - -
execve 59 Executar novo programa 59 caminho ("/bin/cat") argv envp
exit 60 Encerrar o processo 60 código de saída - -
wait4 61 Esperar término de processo filho 61 pid (-1 = qualquer) statloc = NULL options = 0

Retorno em rax.

Referências

  • man 2 read
  • man 2 write
  • man 2 open
  • man 2 close
  • man 2 ioctl
  • man 2 pipe
  • man 2 dup2
  • man 2 fork
  • man 2 execve
  • man 2 wait4
  • man 2 waitpid
  • man 3 stdin
  • man 5 proc_pid_fd
  • Wikipedia: Pipeline (Unix)