DEV Community

Alex Volnei Galante
Alex Volnei Galante

Posted on

Kernel Linux para Desenvolvedores Backend - Gerenciamento de Memória Parte II

[!NOTE]
Este artigo é a continuação da Parte I, onde estabelecemos a hierarquia de memória, a evolução do gerenciamento sem abstração, o conceito de espaço de endereçamento e a introdução à MMU. Se você ainda não leu, recomendo começar por lá.


Sumário


Paginação: a ideia central

Na Parte I vimos que o swapping clássico tinha um defeito fatal: sua granularidade grosseira, movendo processos inteiros entre RAM e disco. A paginação resolve isso quebrando a memória em pedaços pequenos e de tamanho fixo, permitindo mover, mapear e proteger memória com granularidade fina.

Páginas e Frames

A paginação divide os dois mundos em unidades de mesmo tamanho:

  • O espaço virtual é dividido em páginas (pages).
  • A memória física é dividida em frames (page frames).
  • Página e frame têm o mesmo tamanho — no x86-64, o padrão é 4 KB (4096 bytes).

A tarefa da paginação é manter um mapeamento: qual página virtual está em qual frame físico. Como página e frame têm o mesmo tamanho, esse mapeamento é uma simples tabela de correspondência.

Mapeamento de Memoria

Anatomia de um endereço virtual

A grande sacada da paginação é como um endereço virtual é interpretado. Ele é dividido em duas partes: o número da página e o offset dentro da página.

Com páginas de 4 KB ($2^{12}$ bytes), os 12 bits mais baixos do endereço são o offset (0 a 4095), e os bits restantes identificam a página:

Endereço virtual de 48 bits (x86-64 típico):

 47                                  12 11                    0
┌──────────────────────────────────────┬───────────────────────┐
│         Número da Página (36 bits)   │    Offset (12 bits)   │
└──────────────────────────────────────┴───────────────────────┘

Tradução:
  - Número da página → consulta tabela → Número do Frame (PFN)
  - Offset → copiado DIRETAMENTE, sem tradução
Enter fullscreen mode Exit fullscreen mode

O detalhe elegante: o offset não é traduzido. Se um endereço virtual está a 100 bytes do início de sua página, o endereço físico correspondente está a 100 bytes do início do frame. Só o número da página passa pela tradução. Isso simplifica enormemente o hardware.


A Tabela de Páginas

A estrutura que guarda o mapeamento página → frame é a tabela de páginas (page table). Cada processo tem a sua própria — é isso que garante que o endereço virtual 0x1000 do processo A e do processo B apontem para frames físicos diferentes.

O formato de uma entrada (PTE)

Cada entrada da tabela de páginas — a PTE (Page Table Entry) — contém muito mais que apenas o número do frame. Ela carrega bits de controle que o hardware e o kernel usam constantemente:

Estrutura simplificada de uma PTE no x86-64:

 63    62         52 51            12 11        9 8 7 6 5 4 3 2 1 0
┌──┬─────────────────┬───────────────┬──────────┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│NX│  reservado      │  PFN (frame)  │  livre   │G│P│D│A│C│W│U│W│P│P│
└──┴─────────────────┴───────────────┴──────────┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘

Bits de controle essenciais:
  P  (Present)     → a página está na RAM? (0 = page fault ao acessar)
  W  (Writable)    → pode escrever? (proteção de escrita)
  U  (User)        → acessível em user mode? (proteção kernel/user)
  A  (Accessed)    → foi referenciada? (usado por algoritmos de reposição)
  D  (Dirty)       → foi modificada? (precisa gravar no disco antes de descartar)
  NX (No eXecute)  → impede execução de código (proteção contra exploits)
  PFN              → o Page Frame Number: qual frame físico
Enter fullscreen mode Exit fullscreen mode

Esses bits não são detalhes acadêmicos — eles são a base de mecanismos que impactam produção diretamente:

  • O bit Present é o que dispara page faults e viabiliza demand paging e swapping (Parte III).
  • O bit Writable é o que torna possível o Copy-on-Write no fork() (Parte III).
  • Os bits Accessed e Dirty alimentam os algoritmos de reposição de página (Parte V do Artigo 3).
  • O bit NX é uma defesa de segurança fundamental contra ataques de execução de código na stack/heap.

O problema do tamanho

Aqui surge um problema sério. Imagine uma tabela de páginas "plana" (single-level) para um espaço de 48 bits com páginas de 4 KB:

  • Número de páginas: $2^{48} / 2^{12} = 2^{36}$ páginas.
  • Se cada PTE ocupa 8 bytes: $2^{36} \times 8 = 2^{39}$ bytes = 512 GB de tabela de páginas.

Por processo! Isso é obviamente inviável. E o pior: a maioria dos processos usa apenas uma fração ínfima do seu espaço virtual, então a tabela seria quase toda preenchida com entradas vazias. Precisamos de algo mais inteligente.


Tabelas de Páginas Multinível

A solução é não alocar a tabela inteira de uma vez, mas organizá-la em uma hierarquia de níveis, alocando apenas os pedaços realmente usados. É o mesmo princípio de um índice de livro com capítulos e seções: você não lista todas as páginas, mas navega por níveis.

A hierarquia do Linux: PGD, PUD, PMD, PTE

O Linux implementa uma tabela de páginas de 4 níveis (no x86-64 clássico). O endereço virtual é fatiado em vários campos, cada um indexando um nível:

Endereço virtual de 48 bits, fatiado em 4 níveis + offset:

 47      39 38      30 29      21 20      12 11         0
┌──────────┬──────────┬──────────┬──────────┬────────────┐
│  PGD idx │  PUD idx │  PMD idx │  PTE idx │   offset   │
│ (9 bits) │ (9 bits) │ (9 bits) │ (9 bits) │ (12 bits)  │
└──────────┴──────────┴──────────┴──────────┴────────────┘
Enter fullscreen mode Exit fullscreen mode

Os nomes usados no código-fonte do kernel Linux:

  • PGD — Page Global Directory (nível mais alto; o registrador CR3 aponta para ele)
  • PUD — Page Upper Directory
  • PMD — Page Middle Directory
  • PTE — Page Table Entry (nível mais baixo, aponta para o frame físico)

O processo de tradução (o "page table walk") segue os níveis:

CR3 ──▶ [PGD] ──idx──▶ [PUD] ──idx──▶ [PMD] ──idx──▶ [PTE] ──▶ Frame físico
         │                                                          │
         └── cada nível tem 512 entradas (2^9)                      └── + offset = endereço físico
Enter fullscreen mode Exit fullscreen mode

A economia é brutal: um processo que usa poucos MB de memória precisa de apenas alguns níveis alocados, não os 512 GB da tabela plana. Os ramos não utilizados simplesmente não existem.

O custo? Cada acesso à memória agora exige 4 acessos adicionais à RAM (um por nível) só para traduzir o endereço. Um único mov do seu programa pode virar 5 acessos à memória. É exatamente esse custo que a TLB, que veremos a seguir, existe para eliminar.

4 e 5 níveis: endereçamento de 48 e 57 bits

Por muito tempo, 48 bits de endereçamento virtual (256 TB) foram mais que suficientes. Mas servidores com muitos terabytes de RAM e workloads como bancos de dados in-memory começaram a esbarrar nesse teto.

A partir do Kernel 4.14 (e amplamente disponível nos kernels 5.x), o Linux passou a suportar tabelas de páginas de 5 níveis, adicionando um nível chamado P4D entre o PGD e o PUD:

5 níveis → endereçamento virtual de 57 bits → 128 PB de espaço virtual

 56    48 47    39 38    30 29    21 20    12 11      0
┌────────┬────────┬────────┬────────┬────────┬──────────┐
│ PGD    │ P4D    │ PUD    │ PMD    │ PTE    │  offset  │
└────────┴────────┴────────┴────────┴────────┴──────────┘
Enter fullscreen mode Exit fullscreen mode

Para a maioria das aplicações backend isso é transparente — você não muda uma linha de código. Mas se você opera bancos de dados gigantes, caches in-memory de múltiplos terabytes ou máquinas com RAM massiva, é o kernel removendo um teto que antes existia.

Alternativas: tabelas invertidas e hashed

O modelo multinível é o dominante, mas vale conhecer as alternativas que Tanenbaum discute:

  • Tabela de páginas invertida: em vez de uma entrada por página virtual (que pode ser enorme), há uma entrada por frame físico. O tamanho passa a ser proporcional à RAM, não ao espaço virtual. A desvantagem é que a busca vira uma pesquisa (geralmente via hash), não uma indexação direta. Usada em arquiteturas como PowerPC e Itanium.

  • Hashed page tables: usam uma função hash sobre o número da página virtual para encontrar a entrada. Comuns em espaços de endereçamento esparsos de 64 bits.

O x86-64, arquitetura da esmagadora maioria dos servidores, usa o modelo multinível hierárquico que detalhamos acima.

"Gosto de pensar no Tanenbaum como cara com mesmo senso crítico que Albert Einstein, que não se contenta com o que é popular, mas sim com o que é elegante e eficiente. Ele discute alternativas, mas o x86-64 escolheu a hierarquia multinível por ser simples e eficiente na prática." Alex Volnei Galante (Eu mesmo kkkk)


A TLB: Translation Lookaside Buffer

Se cada acesso à memória exige um page table walk de 4 ou 5 acessos adicionais à RAM, a paginação seria proibitivamente lenta. A solução é um cache dedicado a traduções: a TLB (Translation Lookaside Buffer).

A TLB é um cache pequeno e ultrarrápido, dentro da MMU, que guarda as traduções página→frame usadas recentemente:

Acesso à memória com TLB:

  endereço virtual
        │
        ▼
   ┌─────────┐   TLB hit (~1 ciclo)
   │   TLB   │─────────────────────▶ endereço físico   ✔ rápido!
   └─────────┘
        │ TLB miss
        ▼
   ┌─────────────┐
   │ Page Table  │  walk de 4-5 níveis (~100+ ciclos)
   │   Walk      │─────────────────────▶ endereço físico + preenche TLB
   └─────────────┘
Enter fullscreen mode Exit fullscreen mode

Graças à localidade (de novo ela!), a TLB tem uma taxa de acerto (hit ratio) altíssima na prática — tipicamente acima de 99%. A maioria dos acessos nunca chega a fazer o walk completo.

Cálculo do tempo efetivo de acesso

Vale fazer a conta para sentir o impacto. Suponha:

  • Acesso à RAM: 100 ns
  • TLB hit: adiciona ~0 ns (acontece em paralelo)
  • TLB miss: exige um page walk que custa, digamos, 4 acessos à RAM = 400 ns extras

A lição prática: TLB misses são caros. Workloads que "pulam" por muita memória de forma aleatória (bancos de dados percorrendo índices grandes, por exemplo) sofrem com TLB misses. É por isso que huge pages, que veremos adiante, podem dar ganhos expressivos.

TLB e context switch: flush vs ASID

Aqui está um ponto que conecta diretamente com o Artigo 1 (context switching). Cada processo tem sua própria tabela de páginas, então o endereço virtual 0x1000 significa coisas diferentes para processos diferentes. O que acontece com a TLB quando o kernel troca de processo?

  • Abordagem ingênua (flush): esvaziar a TLB inteira a cada context switch. Correto, mas caro — o novo processo começa com a TLB "fria" e sofre uma rajada de TLB misses até reaquecê-la. Esse é um dos custos indiretos do context switch que mencionamos no Artigo 1.

  • Abordagem moderna (ASID/PCID): cada entrada da TLB é etiquetada com um identificador de espaço de endereçamento — ASID (Address Space ID) na terminologia genérica, PCID (Process Context ID) no x86-64. Assim, entradas de processos diferentes coexistem na TLB sem conflito, e o flush deixa de ser necessário na maioria dos switches.

Isso reforça uma ideia da série: aplicações com muitos context switches (por exemplo, muitas threads competindo, ou muito chaveamento entre goroutines que bloqueiam em syscalls) pagam um custo em memória, não só em CPU. Menos switches = TLB mais quente = menos latência.


Huge Pages e THP

Se páginas de 4 KB geram pressão sobre a TLB (cada entrada cobre só 4 KB), a solução é usar páginas maiores. O x86-64 suporta:

  • Páginas de 2 MB (usando um nível a menos no walk — o PMD aponta direto para o frame)
  • Páginas de 1 GB (o PUD aponta direto para o frame)

Uma única entrada de TLB para uma página de 2 MB cobre o que exigiria 512 entradas de 4 KB. Para workloads com grande footprint de memória, isso reduz drasticamente os TLB misses.

Há duas formas de usá-las no Linux:

  • HugeTLB (explícito): você reserva páginas grandes antecipadamente e a aplicação as requisita explicitamente. É o modelo usado por bancos de dados como Oracle e PostgreSQL (via huge_pages).

  • Transparent Huge Pages (THP): o kernel tenta usar huge pages automaticamente, promovendo regiões de 4 KB para 2 MB quando possível, sem intervenção da aplicação.

# Verificar status do THP no sistema
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never

# Ver quanta memória está em huge pages
grep -i huge /proc/meminfo
# AnonHugePages:    524288 kB
# HugePages_Total:       0
# HugePages_Free:        0
Enter fullscreen mode Exit fullscreen mode

⚠️ Cuidado em produção: THP nem sempre é benéfico. Muitos bancos de dados (Redis, MongoDB, PostgreSQL) recomendam desabilitar o THP, porque a promoção/compactação de páginas em background pode causar picos de latência imprevisíveis e fragmentação. A recomendação comum é usar madvise (só onde a aplicação pede via madvise(MADV_HUGEPAGE)) em vez de always. Voltaremos a huge pages nas boas práticas da Parte IV.


Segmentação

Antes de fechar, vale entender a segmentação — uma abordagem alternativa (e complementar) à paginação, que Tanenbaum trata em detalhe.

Enquanto a paginação divide a memória em pedaços de tamanho fixo e sem significado semântico (uma página é só 4 KB de bytes), a segmentação divide o espaço de endereçamento em unidades lógicas de tamanho variável que correspondem à estrutura do programa:

Visão segmentada de um processo:

┌──────────────────┐  Segmento 0: código (text)
├──────────────────┤  Segmento 1: dados globais (data)
├──────────────────┤  Segmento 2: heap
├──────────────────┤  Segmento 3: stack
└──────────────────┘

Cada segmento tem base, limite e permissões próprias.
Enter fullscreen mode Exit fullscreen mode

A vantagem da segmentação é semântica: cada segmento pode ter proteções próprias (código = read-only + executável; stack = read-write + no-execute) e crescer independentemente. A desvantagem é que segmentos de tamanho variável trazem de volta o fantasma da fragmentação externa.

Segmentação vs Paginação

Aspecto Paginação Segmentação
Tamanho da unidade Fixo (4 KB) Variável
Fragmentação Interna Externa
Visão do programador Transparente Explícita (às vezes)
Proteção Por página Por segmento (semântica)

Segmentação paginada (x86)

A arquitetura x86 historicamente combinou as duas: segmentação paginada. Um endereço passava primeiro pela segmentação (segmento + offset → endereço linear) e depois pela paginação (endereço linear → físico).

Na prática, porém, o x86-64 praticamente aposentou a segmentação. No modo de 64 bits, a maioria dos registradores de segmento tem base fixada em 0 e limite "infinito" — ou seja, a segmentação é neutralizada, e o Linux usa essencialmente um modelo de memória plano baseado só em paginação. Os conceitos de "segmentos" que sobrevivem (text, data, heap, stack) são hoje uma organização lógica implementada sobre paginação, não segmentação de hardware. Veremos esse memory layout na próxima parte.


Continua na Parte III, onde entramos no coração dinâmico da memória virtual: page faults (minor e major), demand paging, o Copy-on-Write que torna o fork() barato, o memory layout de um processo real (com mmap, ASLR) e o swapping moderno.
:D

Conteúdo parcialmente gerado com auxílio de IA generativa (me ajudou a organizar tudo isso kkkk)

Referências Bibliográficas

Top comments (0)