50 KiB
8 – Fluxos de dados
- Objetivos
- Tabela de descritores de arquivos
- Uso dos fluxos de dados padrão no shell
- Uso dos fluxos de dados padrão em programas
- Criação e manipulação de fluxos de dados
- Armazenamento temporário (bufferização)
- Exercícios propostos
- Tabela das chamadas de sistema utilizadas
- Referências
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 arquivo0
stdout
→ Descritor de arquivo1
stderr
→ Descritor de arquivo2
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 docat
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
ew
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):
Nº | 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 chamadasetsockopt
.
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
-
Altere o tamanho do buffer do programa
salve-read.asm
para10
(bytes), observe seu novo funcionamento e responda:- O que acontece quando digitamos mais de 10 caracteres?
- Como você explica o que aconteceu?
- Ainda usando
salve-read.asm
como base, faça com que o programa só funcione no modo interativo, imprimindo uma mensagem porstderr
e terminando com estado1
caso esteja num pipe ou com a entrada padrão redirecionada para um arquivo. - Pesquise e escreva um programa em C que reproduza o funcionamento do exemplo
salve-pipe.asm
. - 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)