pbn/curso/aula-04.org
2025-05-21 14:00:12 -03:00

833 lines
32 KiB
Org Mode

#+title: 4 -- Mapeamento de memória
#+author: Blau Araujo
#+email: cursos@blauaraujo.com
#+options: toc:3
* Objetivos
- Compreender como programas são executados no GNU/Linux.
- Descobrir como a execução de programas é gerenciada.
- Conhecer a organização do espaço de endereçamento de processos.
- Visualizar o layout de memória de programas reais.
* Como programas são executados
Como vimos, na criação de arquivos objeto no formato ELF, os dados e instruções
são organizados em várias seções, o que inclui:
- O código do programa (seção =.text=)
- Dados globais constantes (seção =.rodata=)
- Dados globais variáveis inicializados (seção =.data=)
- Dados globais variáveis não inicializados (seção =.bss=)
Também vimos que arquivos objeto podem ser ligados dinâmica ou estaticamente
entre si para formar um programa completo:
- *Ligação estática:* os objetos são vinculados para formar um único binário
executável.
- *Ligação dinâmica:* um arquivo executável e os objetos compartilhados de que
depende são carregados em um mesmo espaço de memória no momento da execução
do programa.
Assim, quando um programa é executado, as seções de seu binário -- bem como
as seções de objetos compartilhados, se forem requeridos -- devem ser mapeadas
e organizadas em uma região da memória principal designada e gerenciada pelo
sistema operacional. Cada programa em execução, portanto, terá seu próprio
espaço isolado na memória onde seus dados e instruções serão organizados.
Contudo, a região da memória atribuída à execução do programa não contém apenas
código e dados carregados do executável -- e, eventualmente, de outros objetos
compartilhados. Nela, também haverá estruturas adicionais, como a /pilha/, a
/heap/ e outros dados de controle para o gerenciamento da execução. O conjunto
individual de estruturas na memória associado à execução de um programa é o
que nós chamamos de /processo/.
** O que são processos
Um processo pode ser entendido como uma entidade ativa no sistema operacional
que representa a execução de um programa, incluindo sua identificação, os
recursos a que tem acesso, seu contexto de execução, permissões e informações
de gerenciamento. É por meio dos processos que o sistema operacional organiza
e administra a execução simultânea de programas, garantindo que cada um deles
funcione corretamente e de forma isolada dos demais.
Portanto, ao executar um programa, o sistema operacional:
- Cria um novo espaço de memória de uso exclusivo;
- Carrega as seções do executável e de objetos compartilhados (se necessário);
- Inicializa estruturas auxiliares (como /pilha/, /heap/ e dados de ambiente);
- Inicia a execução no ponto de entrada do programa;
- Monitora e controla suas atividades como um novo processo.
** Layout da memória virtual de um processo
Quando um programa é executado, ele não acessa diretamente a memória física
(a memória RAM). Em vez disso, é designada para ele uma faixa contínua de
endereços simbólicos, conhecida como /espaço de endereços virtuais/ ou,
simplesmente, /memória virtual/. Assim, quando o programa acessa um endereço
em seu espeço de memória virtual, o sistema, com auxílio do processador,
traduz este acesso para o endereço correspondente na memória física.
O espaço de endereços virtuais é dividido em regiões específicas, organizadas
de forma previsível, e o diagrama abaixo mostra uma representação de seu
layout típico em sistemas GNU/Linux x86_64, com as seções dispostas do
endereço mais alto para o mais baixo.
#+begin_example
Endereços mais altos (ex: 0x7fffffffffff)
┌─────────────────────────────┐
│ ESPAÇO DO KERNEL │ ← informações de controle interno do kernel.
├─────────────────────────────┤
│ VETOR DE AMBIENTE │ ← Variáveis exportadas.
├─────────────────────────────┤
│ VETOR DE ARGUMENTOS │ ← Argumentos de linha de comando.
├─────────────────────────────┤
│ │
│ PILHA (STACK) │
│ │
├───────────── ▼ ─────────────┤
│ │
│ │ ← Espaço livre.
│ │
├─────────────────────────────┤
│ VDSO │ ← Assistente de chamadas de sistema.
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ VVAR │ ← Acesso a tempo real do kernel.
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ │
│ MMAP │ ← Objetos compartilhados, arquivos mapeados,
│ │ alocação com 'mmap', etc.
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
│ │
│ HEAP │ ← Memória alocada com 'malloc'.
│ │
├─────────────────────────────┤ ─┐
│ .BSS │ │
├─────────────────────────────┤ │
│ .DATA │ │
├─────────────────────────────┤ │
│ .RODATA │ ├─ Conteúdo mapeado do arquivo executável.
├─────────────────────────────┤ │
│ .TEXT │ │
├─────────────────────────────┤ │
│ TABELAS ELF │ │
└─────────────────────────────┘ ─┘
Endereços mais baixos (ex: 0x400000)
#+end_example
Onde, de cima para baixo, nós temos...
- Espaço do kernel ::
Informações internas do kernel para configuração da pilha, do ambiente de
execução (argumentos e ambiente) e, em alguns casos, "trampolins de
sinais". Esta região não pode ser acessada diretamente pelo programa.
- Vetor de ambiente ::
Vetor de strings no formato de atribuições de variáveis (=NOME=VALOR=)
correspondendo às definições das variáveis exportadas para o processo.
- Vetor de argumentos ::
Também chamado de /vetor de parâmetros/, é um vetor de strings que contém,
em ordem, as palavras da linha de comando usada para executar o programa.
O primeiro elemento do vetor sempre existe e corresponde à primeira palavra
da linha do comando -- geralmente, o nome ou caminho do programa.
- Pilha (/stack/) ::
É uma estrutura dinâmica que /empilha/ blocos de dados conforme as funções
do programa são chamadas, armazenando temporariamente informações como
endereços de retorno e dados de variáveis locais. A cada nova chamada de
função, um novo bloco (chamado de /quadro de pilha/) é empilhado. Quando a
função termina, esse quadro é removido e a pilha volta ao seu estado
anterior. Ao ser inicializada, a pilha recebe, do endereço mais alto para
o mais baixo: os endereços dos elementos no vetor de ambiente, os endereços
dos elementos do vetor de argumentos e, no topo da pilha, a quantidade de
argumentos...
#+begin_example
Endereço mais alto na pilha (sua base)
┌─────────────┐
│ NULL │ ← Fim do vetor de ambiente
├──── ... ────┤
│ envp[1] │
├─────────────┤
│ envp[0] │ ← Definição da primeira variável exportada
├─────────────┤
│ NULL │ ← Fim do vetor de argumentos
├──── ... ────┤
│ argv[1] │
├─────────────┤
│ argv[0] │ ← Nome/caminho do programa
├──── ... ────┤
│ argc │ ← Quantidade de argumentos.
└─────────────┘
Endereço mais baixo da pilha (na direção de seu topo)
#+end_example
- Espaço livre entre pilha e endereços mapeados ::
Em versões mais modernas do GNU/Linux, a folga de segurança para o
crescimento da pilha (/guard gap/) não é compartilhada com a região de dados
alocados dinamicamente pelo programa (/heap/, de "amontoado"), como
costumava ser. Atualmente, o sistema insere várias regiões mapeadas
automaticamente com bibliotecas compartilhadas, inclusive do próprio
sistema. Deste modo, a tradicional região do /heap/ deixa de ficar
imediatamente abaixo da pilha no espaço de endereços virtuais.
- Região de mapeamento automático de bibliotecas ::
Atualmente, logo abaixo da folga de segurança, nós temos várias áreas
mapeadas com objetos compartilhados e do sistema, como a biblioteca
especial =linux-vdso.so.1= (que possibilita realizar chamadas de sistema
sem utilizar interrupções), a biblioteca C padrão (=glibc=) e o carregador
dinâmico (=ld-linux=). Essas bibliotecas são mapeadas automaticamente pelo
sistema e podem variar de acordo com as dependências do programa e as
configurações do ambiente de execução.
- Regiões mapeadas com 'mmap' ::
Áreas criadas com a chamada de sistema =mmap=, que possibilita mapear
arquivos, carregar bibliotecas explicitamente e alocar de grandes blocos
de memória.
- Região de alocação dinâmica de memória (/heap/) ::
É um grande espaço de endereços reservado para que o programa possa
carregar e manipular dados dinamicamente -- ou seja, dados que não foram
associados previamente a variáveis ou vetores, o que geralmente é feito
com funções da família =malloc=. Em termos gerais, diz-se que o /heap/ cresce
no sentido dos endereços mais altos, a partir de um ponto inicial acima
das regiões estáticas do programa. Contudo, novas alocações não seguem
necessariamente um padrão linear, podendo ocupar regiões já liberadas ou
até mesmo fora do espaço tradicional do /heap/, via =mmap=. De todo modo, o
limite de crescimento do /heap/ está nos espaços superiores já ocupados
com mapeamentos automáticos feitos pelo sistema.
- Conteúdo estático do arquivo executável ::
No início da faixa de endereços da memória virtual, ficam os dados
carregados diretamente do arquivo executável, o que inclui as seções
do binário e as tabelas de cabeçalhos e segmentos do formato ELF.
* Explorando os espaços de endereços de processos
Conhecendo o aspecto geral do layout de organização da memória virtual de
processos em execução em sistemas GNU/Linux, é importante enfatizar que o
conteúdo nesse espaço virtual de endereços pode variar em função de diversos
fatores como, entre outros:
- O tipo do programa e seu grau de abstração;
- A necessidade, ou não, de carregar objetos compartilhados;
- A forma como a memória é alocada em tempo de execução;
- A versão do sistema operacional.
Sendo assim, a melhor forma de consolidar a compreensão sobre os espaços de
memória é observando diretamente como eles são organizados durante a execução
de programas reais, escritos em diferentes linguagens de programação e com
diferentes níveis de abstração.
* Exemplo 1: mapeamento de memória de um programa em C
Arquivo: [[exemplos/04/mappings.c][mappings.c]]
#+begin_src c :tangle exemplos/04/mappings.c
#include <stdio.h>
#include <stdlib.h>
int global_var = 42; // .data
static int static_var = 7; // .data
int uninit_var; // .bss
const int const_var = 99; // .rodata
int main() {
int local_var = 10; // stack
static int static_main = 5; // .data
int *heap_var = malloc(sizeof(int)); // heap
*heap_var = 123;
printf("local_var : %p : %d\n", &local_var, local_var);
printf("*heap_var : %p : %d\n", heap_var, *heap_var);
printf("uninit_var : %p : %d\n", &uninit_var, uninit_var);
printf("static_main: %p : %d\n", &static_main, static_main);
printf("static_var : %p : %d\n", &static_var, static_var);
printf("global_var : %p : %d\n", &global_var, global_var);
printf("const_var : %p : %d\n", &const_var, const_var);
// Código da função main (.text)...
printf("main() : %p\n", &main);
// Pausa para análise na linha de comandos...
puts("Tecle algo para terminar...");
getchar();
free(heap_var);
return 0;
}
#+end_src
Compilando e executando, nós termos algo como...
#+begin_example
:~$ gcc -Wall mappings.c
:~$ ./a.out
local_var : 0x7fff8803eb94 : 10
*heap_var : 0x563f84c752a0 : 123
uninit_var : 0x563f63a47048 : 0
static_main: 0x563f63a47040 : 5
static_var : 0x563f63a4703c : 7
global_var : 0x563f63a47038 : 42
const_var : 0x563f63a45004 : 99
main() : 0x563f63a44179
Tecle algo para terminar...
#+end_example
Como podemos ver, a impressão foi planejada de modo a apresentar os endereços
na ordem em que seriam mapeados na memória virtual, do mais alto para o mais
baixo. Isso facilitará a continuação do experimento, que é a localização
desses endereços nas faixas de mapeamento obtidas com o utilitário =pmap= e
no arquivo =/proc/<pid>/maps=.
** Análise com 'pmap'
O utilitário =pmap= exibe o mapa de memória de um ou mais processos:
#+begin_example
pmap [opções] PID [PID ...]
#+end_example
Por padrão, o utilitário exibe o mapeamento de forma simplificada (ideal
para os nossos propósitos), mas outros detalhes podem ser exibidos com as
opções listadas em:
#+begin_example
pmap --help
#+end_example
Para utilizá-lo nos nossos experimentos, com o programa de exemplo ainda
em execução, nós teremos que abrir outro terminal e executar:
#+begin_example
$ pmap $(pidof a.out)
#+end_example
O resultado impresso deve ser algo assim:
#+begin_example
2782565: ./a.out
0000563f63a43000 4K r---- a.out
0000563f63a44000 4K r-x-- a.out
0000563f63a45000 4K r---- a.out
0000563f63a46000 4K r---- a.out
0000563f63a47000 4K rw--- a.out
0000563f84c75000 132K rw--- [ anôn ]
00007f6207f5f000 12K rw--- [ anôn ]
00007f6207f62000 160K r---- libc.so.6
00007f6207f8a000 1428K r-x-- libc.so.6
00007f62080ef000 344K r---- libc.so.6
00007f6208145000 16K r---- libc.so.6
00007f6208149000 8K rw--- libc.so.6
00007f620814b000 52K rw--- [ anôn ]
00007f620817b000 8K rw--- [ anôn ]
00007f620817d000 16K r---- [ anôn ]
00007f6208181000 8K r-x-- [ anôn ]
00007f6208183000 4K r---- ld-linux-x86-64.so.2
00007f6208184000 160K r-x-- ld-linux-x86-64.so.2
00007f62081ac000 44K r---- ld-linux-x86-64.so.2
00007f62081b7000 8K r---- ld-linux-x86-64.so.2
00007f62081b9000 4K rw--- ld-linux-x86-64.so.2
00007f62081ba000 4K rw--- [ anôn ]
00007fff88020000 132K rw--- [ pilha ]
total 2560K
#+end_example
Na primeira linha...
#+begin_example
2782565: ./a.out
#+end_example
Nós temos o número do processo do nosso programa e o comando que invocou
sua execução.
Nas linhas seguintes, as informações seguem este padrão:
#+begin_example
ENDEREÇO_INICIAL TAMANHO(BYTES) PERMISSÕES MAPEAMENTO
#+end_example
Finalmente, na última linha, nós temos o tamanho total da memória mapeada
para o processo:
#+begin_example
total 2560K
#+end_example
Nosso principal objetivo é comparar os endereços na saída do nosso programa
com os endereços iniciais listados por =pmap= para localizar onde os dados
e instruções foram posicionados na memória:
#+begin_example
2782565: ./a.out
0000563f63a43000 4K r---- a.out
0000563f63a44000 4K r-x-- a.out <-------- main()
0000563f63a45000 4K r---- a.out <-------- const_var
0000563f63a46000 4K r---- a.out
0000563f63a47000 4K rw--- a.out <-------- global_var até uninit_var
0000563f84c75000 132K rw--- [ anôn ] <--- *heap_var
00007f6207f5f000 12K rw--- [ anôn ]
00007f6207f62000 160K r---- libc.so.6
00007f6207f8a000 1428K r-x-- libc.so.6
00007f62080ef000 344K r---- libc.so.6
00007f6208145000 16K r---- libc.so.6
00007f6208149000 8K rw--- libc.so.6
00007f620814b000 52K rw--- [ anôn ]
00007f620817b000 8K rw--- [ anôn ]
00007f620817d000 16K r---- [ anôn ]
00007f6208181000 8K r-x-- [ anôn ]
00007f6208183000 4K r---- ld-linux-x86-64.so.2
00007f6208184000 160K r-x-- ld-linux-x86-64.so.2
00007f62081ac000 44K r---- ld-linux-x86-64.so.2
00007f62081b7000 8K r---- ld-linux-x86-64.so.2
00007f62081b9000 4K rw--- ld-linux-x86-64.so.2
00007f62081ba000 4K rw--- [ anôn ]
00007fff88020000 132K rw--- [ pilha ] <-- local_var
total 2560K
#+end_example
Note que a região iniciada em =0x563f63a47000= recebeu os dados de todas as
variáveis globais e estáticas inicializadas e não inicializadas:
#+begin_example
uninit_var : 0x563f63a47048 : 0
static_main: 0x563f63a47040 : 5
static_var : 0x563f63a4703c : 7
global_var : 0x563f63a47038 : 42
#+end_example
Isso mostra que, pelo menos neste caso, a seção =.bss= iniciou imediatamente
após o fim da seção =.data= na mesma faixa de endereços -- aqui, ocupando um
total de 4kbytes:
#+begin_example
0000563f63a47000 4K rw--- a.out <-------- global_var até uninit_var
#+end_example
Nosso segundo objetivo é comparar o mapeamento com o layout típico da
memória virtual de um processo. Portanto, nós temos...
*** Tabelas de cabeçalhos e seções ELF
A primeira parte do arquivo mapeada na memória é o conteúdo das tabelas
de cabeçalhos e seções:
#+begin_example
0000563f63a43000 4K r---- a.out
#+end_example
*** Seção =.text=
Em seguida, é mapeado o conteúdo da seção =.text= do arquivo:
#+begin_example
0000563f63a44000 4K r-x-- a.out <-- main()
#+end_example
Observe que esta região foi mapeada com permissões apenas para leitura e
execução (=r-x--=) e é nela que estão as instruções da função =main= do nosso
programa.
*** Seção =.rodata=
A seção seguinte a ser mapeada é =.rodata=, onde temos os dados que não
poderão ser alterados:
#+begin_example
0000563f63a45000 4K r---- a.out <-- const_var
#+end_example
A região mapeada na memória, portanto, só tem permissão para leitura (=r=).
*** Seções =.data= e =.bss=
#+begin_example
0000563f63a47000 4K rw--- a.out <-- global_var até uninit_var
#+end_example
Com permissões de leitura e escrita, esta região de endereços recebe todos
os dados globais e estáticos que poderão ser alterados ao longo da execução
do programa.
*** Fim do mapeamento do arquivo executável
Até aqui, todos os dados mapeados correspondem ao conteúdo do arquivo
executável do programa (=a.out=). Agora, porém, nós entraremos nas regiões
dos endereços mapeados pelo sistema para viabilizar a sua execução como
um processo.
*** Região de alocação dinâmica (heap)
#+begin_example
0000563f84c75000 132K rw--- [ anôn ] <--- *heap_var
#+end_example
*** Região de mapeamento automático e com 'mmap'
#+begin_example
00007f6207f5f000 12K rw--- [ anôn ]
00007f6207f62000 160K r---- libc.so.6
00007f6207f8a000 1428K r-x-- libc.so.6
00007f62080ef000 344K r---- libc.so.6
00007f6208145000 16K r---- libc.so.6
00007f6208149000 8K rw--- libc.so.6
00007f620814b000 52K rw--- [ anôn ]
00007f620817b000 8K rw--- [ anôn ]
00007f620817d000 16K r---- [ anôn ]
00007f6208181000 8K r-x-- [ anôn ]
00007f6208183000 4K r---- ld-linux-x86-64.so.2
00007f6208184000 160K r-x-- ld-linux-x86-64.so.2
00007f62081ac000 44K r---- ld-linux-x86-64.so.2
00007f62081b7000 8K r---- ld-linux-x86-64.so.2
00007f62081b9000 4K rw--- ld-linux-x86-64.so.2
00007f62081ba000 4K rw--- [ anôn ]
#+end_example
Observe que é nesta região que o conteúdo da =glibc= (=libc.so.6=) e do
carregador do sistema (=ld-linux-x86-64.so.2=) são mapeados.
*** Pilha (stack)
A partir dos endereços mais altos da memória virtual, nós temos a pilha
do processo, onde são carregados os dados locais das funções à medida em
que elas são chamadas, como é o caso da variável =local_var=.
#+begin_example
00007fff88020000 132K rw--- [ pilha ] <-- local_var
#+end_example
** Análise com o arquivo /proc/<pid>/maps
Nós também podemos analisar o mapeamento de memória do processo associado
à execução do nosso programa pela mesma fonte de dados utilizada pelo =pmap=:
o arquivo =/proc/<pid>/maps=. Para isso, com o nosso programa rodando, nós
podemos executar:
#+begin_example
:~$ cat /proc/$(pidof a.out)/maps
563f63a43000-563f63a44000 r--p 00000000 08:12 4856488 /home/blau/git/pbn/curso/exemplos/04/a.out
563f63a44000-563f63a45000 r-xp 00001000 08:12 4856488 /home/blau/git/pbn/curso/exemplos/04/a.out
563f63a45000-563f63a46000 r--p 00002000 08:12 4856488 /home/blau/git/pbn/curso/exemplos/04/a.out
563f63a46000-563f63a47000 r--p 00002000 08:12 4856488 /home/blau/git/pbn/curso/exemplos/04/a.out
563f63a47000-563f63a48000 rw-p 00003000 08:12 4856488 /home/blau/git/pbn/curso/exemplos/04/a.out
563f84c75000-563f84c96000 rw-p 00000000 00:00 0 [heap]
7f6207f5f000-7f6207f62000 rw-p 00000000 00:00 0
7f6207f62000-7f6207f8a000 r--p 00000000 08:12 6584950 /usr/lib/x86_64-linux-gnu/libc.so.6
7f6207f8a000-7f62080ef000 r-xp 00028000 08:12 6584950 /usr/lib/x86_64-linux-gnu/libc.so.6
7f62080ef000-7f6208145000 r--p 0018d000 08:12 6584950 /usr/lib/x86_64-linux-gnu/libc.so.6
7f6208145000-7f6208149000 r--p 001e2000 08:12 6584950 /usr/lib/x86_64-linux-gnu/libc.so.6
7f6208149000-7f620814b000 rw-p 001e6000 08:12 6584950 /usr/lib/x86_64-linux-gnu/libc.so.6
7f620814b000-7f6208158000 rw-p 00000000 00:00 0
7f620817b000-7f620817d000 rw-p 00000000 00:00 0
7f620817d000-7f6208181000 r--p 00000000 00:00 0 [vvar]
7f6208181000-7f6208183000 r-xp 00000000 00:00 0 [vdso]
7f6208183000-7f6208184000 r--p 00000000 08:12 6584935 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f6208184000-7f62081ac000 r-xp 00001000 08:12 6584935 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f62081ac000-7f62081b7000 r--p 00029000 08:12 6584935 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f62081b7000-7f62081b9000 r--p 00034000 08:12 6584935 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f62081b9000-7f62081ba000 rw-p 00036000 08:12 6584935 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f62081ba000-7f62081bb000 rw-p 00000000 00:00 0
7fff88020000-7fff88041000 rw-p 00000000 00:00 0 [stack]
#+end_example
Neste arquivo, os dados são brutos e menos fáceis de analisar, mas nós
podemos observar algumas informações que ficaram de fora da listagem
simplificada do utilitário =pmap=. Por exemplo, aqui é possível visualizar
onde as funções e variáveis auxiliares do kernel foram mapeadas:
#+begin_example
7f620817d000-7f6208181000 r--p 00000000 00:00 0 [vvar]
7f6208181000-7f6208183000 r-xp 00000000 00:00 0 [vdso]
#+end_example
Na listagem do =pmap=, essas mesmas regiões aparecem como "anônimas", mas
é possível ver seus tamanhos:
#+begin_example
00007f620817d000 16K r---- [ anôn ]
00007f6208181000 8K r-x-- [ anôn ]
#+end_example
Outra região mais facilmente identificável no arquivo é o /heap/:
#+begin_example
563f84c75000-563f84c96000 rw-p 00000000 00:00 0 [heap]
#+end_example
Listada pelo =pmap= apenas como:
#+begin_example
0000563f84c75000 132K rw--- [ anôn ]
#+end_example
* Exemplo 2: mapeamento de memória de um programa em Assembly
Alocar dados no /heap/ (via chamadas de sistema =brk= ou =sbrk=) seria complexo
demais para este momento dos nossos estudos. Imprimir os valores e endereços
também exigiria um código muito maior e a antecipação de técnicas e instruções
que não compensariam ser vistas agora, Por isso, vamos nos limitar à definição
de dados.
Arquivo: [[exemplos/04/mappings.asm][mappings.asm]]
#+begin_src asm :tangle exemplos/04/mappings.asm
section .rodata
msg db `Salve, simpatia!\n`
len equ $ - msg
section .data
global_data dq 42
section .bss
uninit_data resq 1
section .text
global _start
_start:
; Fazer algo com os dados em .data e .bss
mov rax, [rel global_data] ; acessando .data
mov rcx, [rel uninit_data] ; acessando .bss
; Imprimir a mensagem em .rodata
mov rax, 1 ; syscall: write
mov rdi, 1 ; fd 1 = stdout
mov rsi, msg ; endereço da mensagem
mov rdx, len ; tamanho da mensagem
syscall
; Encerrar processo
mov rax, 60 ; syscall: exit
xor rdi, rdi ; status 0
syscall
#+end_src
Montando e executando o exemplo:
#+begin_example
:~$ nasm -f elf64 -g mappings.asm
:~$ ld -o mappings mappings.o
:~$ ./mappings
Salve, simpatia!
#+end_example
** Análise com o GNU Debugger (GDB)
Note que a opção =-g= foi utilizada para incluir os símbolos de depuração do
programa. Isso permite que a análise do mapeamento seja feita com o =gdb=,
já que precisamos observar o processo em execução.
*Inicializando o GDB:*
#+begin_example
:~$ gdb mappings
Reading symbols from mappings...
#+end_example
*Definindo um ponto de parada:*
#+begin_example
(gdb) break _start
Breakpoint 1 at 0x401000: file mappings.asm, line 16.
#+end_example
*Executando o programa:*
#+begin_example
(gdb) run
Starting program: /home/blau/git/pbn/curso/exemplos/04/mappings
Breakpoint 1, _start () at mappings.asm:16
16 mov rax, [rel global_data] ; acessando .data
#+end_example
*Listando o mapeamento de memória do processo:*
#+begin_example
(gdb) info proc mappings
process 2793082
Mapped address spaces:
Start Addr End Addr Size Offset Perms File
0x0000000000400000 0x0000000000401000 0x1000 0x0 r--p /home/blau/git/pbn/curso/exemplos/04/mappings
0x0000000000401000 0x0000000000402000 0x1000 0x1000 r-xp /home/blau/git/pbn/curso/exemplos/04/mappings
0x0000000000402000 0x0000000000403000 0x1000 0x2000 r--p /home/blau/git/pbn/curso/exemplos/04/mappings
0x0000000000403000 0x0000000000404000 0x1000 0x2000 rw-p /home/blau/git/pbn/curso/exemplos/04/mappings
0x00007ffff7ff9000 0x00007ffff7ffd000 0x4000 0x0 r--p [vvar]
0x00007ffff7ffd000 0x00007ffff7fff000 0x2000 0x0 r-xp [vdso]
0x00007ffffffde000 0x00007ffffffff000 0x21000 0x0 rw-p [stack]
#+end_example
Observe que a listagem segue o estilo do arquivo =/proc/<pid>/maps=, que,
como o programa está em execução, nós também podemos exibir em outro
terminal com:
#+begin_example
:~$ cat /proc/$(pidof mappings)/maps
00400000-00401000 r--p 00000000 08:12 4856707 /home/blau/git/pbn/curso/exemplos/04/mappings
00401000-00402000 r-xp 00001000 08:12 4856707 /home/blau/git/pbn/curso/exemplos/04/mappings
00402000-00403000 r--p 00002000 08:12 4856707 /home/blau/git/pbn/curso/exemplos/04/mappings
00403000-00404000 rw-p 00002000 08:12 4856707 /home/blau/git/pbn/curso/exemplos/04/mappings
7ffff7ff9000-7ffff7ffd000 r--p 00000000 00:00 0 [vvar]
7ffff7ffd000-7ffff7fff000 r-xp 00000000 00:00 0 [vdso]
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
#+end_example
Ou com o =pmap=:
#+begin_example
:~$ pmap $(pidof mappings)
2793082: /home/blau/git/pbn/curso/exemplos/04/mappings
0000000000400000 4K r---- mappings
0000000000401000 4K r-x-- mappings
0000000000402000 4K r---- mappings
0000000000403000 4K rw--- mappings
00007ffff7ff9000 16K r---- [ anôn ]
00007ffff7ffd000 8K r-x-- [ anôn ]
00007ffffffde000 132K rw--- [ pilha ]
total 172K
#+end_example
De qualquer forma, as regiões do layout de memória do processo são
facilmente localizadas:
#+begin_example
0000000000400000 4K r---- mappings <-- Tabelas ELF
0000000000401000 4K r-x-- mappings <-- .text
0000000000402000 4K r---- mappings <-- .rodata
0000000000403000 4K rw--- mappings <-- .data .bss
00007ffff7ff9000 16K r---- [ anôn ] <-- vvar
00007ffff7ffd000 8K r-x-- [ anôn ] <-- vdso
00007ffffffde000 132K rw--- [ pilha ] <-- pilha
#+end_example
*** Localizando o dado em =.rodata=
O dado iniciado no rótulo =msg= tem 17 bytes, portanto:
#+begin_example
(gdb) x /17bx &msg
0x402000 <msg>: 0x53 0x61 0x6c 0x76 0x65 0x2c 0x20 0x73
0x402008: 0x69 0x6d 0x70 0x61 0x74 0x69 0x61 0x21
0x402010: 0x0a
#+end_example
Como podemos ver, o endereço inicial dos dados no rótulo =msg= é =0x402000=,
que corresponde ao mapeamento da seção =.rodata=:
#+begin_example
0000000000402000 4K r---- mappings <-- .rodata
#+end_example
*** Localizando os dados em =.data= e =.bss=
O dado no rótulo =global_data= foi inicializado com o valor 42 (=0x2c=, em hexa)
e ocupa o espaço de uma /quad-word/ (8 bytes), que podemos exibir assim:
#+begin_example
(gdb) x /8bx &global_data
0x403014 <global_data>: 0x2a 0x00 0x00 0x00 0x00 0x00 0x00 0x00
#+end_example
Localizando seu endereço (=0x403014=) no mapeamento, nós vemos que os 8 bytes
foram escritos na região da seção compartilhada por =.data= e =.bss=:
#+begin_example
0000000000403000 4K rw--- mappings <-- .data .bss
#+end_example
O mesmo ocorre com o espaço de 8 bytes preenchidos com zeros reservados no
endereço identificado pelo rótulo =uninit_data=:
#+begin_example
(gdb) x /8bx &uninit_data
0x40301c <uninit_data>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
#+end_example
Repare, também, que o dado em =uninit_data= inicia exatamente 8 bytes após o
dado em =global_data=:
#+begin_example
:~$ echo $((0x40301c - 0x403014))
8
#+end_example
Novamente demonstrando que as seções =.data= e =.bss= são vizinhas.
** Executáveis independentes de posição (PIE)
Uma coisa que você deve ter notado, é a diferença de distância entre o
mapeamento do executável gerado com =nasm+ld= em relação aos mapeamentos do
sistema, se comparado com o mapeamento do exemplo em C:
#+begin_example
0x563f63a43000 --> 0x7fff88020000 : Programa compilado com gcc
0x000000400000 --> 0x7ffffffde000 : Programa montado com o nasm+ld
#+end_example
Isso acontece porque, por padrão, o =gcc= produz arquivos executáveis do tipo
PIE (/Position-Independent Executable/) que são programas que podem ser
carregados em qualquer lugar da memória, pois seu código não depende de
endereços fixos.
Isso permite que o sistema operacional utilize técnicas para aumentar a
segurança da memória (como ASLR, para randomizar o layout de memória),
dificultando explorações de falhas como, por exemplo, de /buffer overflow/.
Programas em Assembly também podem gerar executáveis PIE, mas isso depende
de que o código seja escrito com vistas à sua montagem e link-edição para
este tipo de executável. Por enquanto, é mais fácil demonstrar as diferenças
de arquivos PIE e não-PIE compilando o exemplo em C com a opção =-no-pie=:
#+begin_example
:~$ gcc -Wall mappings.c
:~$ ./a.out
local_var : 0x7fff26577c94 : 10
*heap_var : 0x13aa92a0 : 123
uninit_var : 0x404048 : 0
static_main: 0x404040 : 5
static_var : 0x40403c : 7
global_var : 0x404038 : 42
const_var : 0x402004 : 99
main() : 0x401166
Tecle algo para terminar...
#+end_example
Isso mostra como os endereços mapeados se parecem mais com os que vimos com
o exemplo em Assembly.
* Exercícios propostos
1. Adicione um vetor global de 10 inteiros no exemplo em C e verifique onde seus
elementos são mapeados.
2. Ainda no exemplo em C, investigue o efeito do qualificador =volatile= no
mapeamento de variáveis.
3. Modifique o código em C para usar =mmap= em vez de =malloc= e veja se há mudanças
quanto a região mapeada.
4. No exemplo em Assembly, mova as definições de =msg= e =len= para a seção =.data= e
investigue o que se altera.
* Referências
- =man pmap=
- =man proc_pid_maps=
- =man 3 maloc=
- =man 2 mmap=
- =man 2 brk=
- [[https://www.kernel.org/doc/gorman/pdf/understand.pdf][Mel Gorman: Understanding The Linux Virtual Memory Manager]]
- [[https://docs.kernel.org/mm/][Linux: Memory Management Documentation]]