DevSecOps

16 jan, 2012

Tradução e segmentação de memória

Publicidade

Este artigo vai tratar sobre a memória e a proteção em computadores compatíveis com o Intel (x86), buscando saber como os kernels trabalham. Como nas séries boot, farei a ligação entre as fontes do Linux kernel, mas darei também exemplos com Windows (desculpe, sou ignorante a respeito de BSDs e Mac, porém a maior parte da discussão se aplica a eles). Peço que me informem sobre erros.

Nos chipsets que compõem as placa-mãe da Intel, a memória é acessada pela CPU via o front side bus, que o conecta ao chip northbridge. Os endereços de memória trocados no front side bus são endereços de memória físicos; números brutos de zero até o final  da memória física disponível. Esses números são mapeados pela northbridge através de sticks de RAM físicos.

Endereços físicos são concretos e definitivos – não há tradução, paginação, checagem de privilégios – você os coloca no bus e é isso aí. Na CPU, contudo, os programas usam endereços de memória lógicos, que precisam ser transformados em endereços físicos antes que o acesso à memória possa acontecer. Conceitualmente, as traduções de endereço são dessa maneira:

Tradução do endereço de memória em CPUs x86 com paginação habilitada

Este diagrama não é físico, apenas uma descrição do processo de tradução de endereço, especificamente para quando a paginação da CPU for habilitada. Se você desligar a paginação, o output da unidade de segmentação já é um endereço físico em 16-bit real mode. A tradução se inicia quando a CPU executa uma instrução que se refere a um endereço de memória. O primeiro passo é fazer a tradução desse endereço lógico em um endereço linear. Mas porque fazer isso ao invés de fazer com que o software use diretamente endereços lineares (ou físico)? Mais ou menos pela mesma razão que nós humanos temos um apêndice cuja função primária é ficar infeccionado. É um tropeço evolutivo. Para a segmentação do x86 fazer sentido, precisamos voltar a 1978.

O 8086 original tinha registros de 16-bit, e suas instruções usavam principalmente operadores de 8-bit ou 16-bit. Isso permitia ao código trabalhar com 216 bytes, ou 64K de memória, embora os engenheiros da Intel tenham se empenhado para que a CPU usasse mais memória sem expandir o tamanho dos registros e instruções. Assim, eles introduziram registros de segmentos como forma de dizer à CPU quais pedaços de 64K de memória uma instrução de programa ia utilizar. Seria uma solução razoável: primeiro se carrega um registro de segmento, dizendo efetivamente “veja, quero trabalhar no pedaço de memória começando em X”; depois disso, os endereços de memória de 16-bit utilizados pelo seu código são interpretados como offsets em seu pedaço, ou segmento. Havia quatro registros de segmento: um para o stack (ss), um para o código do programa (cs) e dois para dados (ds, es). A maioria dos programas era pequeno naquela época para ajustar-se a todo stack, código e dados, cada um em um segmento de 64K, de forma que a segmentação era frequentemente transparente.

Hoje em dia, a segmentação ainda está presente e é sempre habilitada em processadores x86. Cada instrução que acessa a memória implicitamente usa um registro de segmento. Por exemplo, uma jump instruction usa o registro de segmento de código (cs), enquanto que uma instrução stack usa o registro de segmento do stack (ss). Na maioria dos casos você pode passar por cima do registro de segmento usado em uma instrução. Os registros de segmento armazenam seletores de segmento de 16-bit; eles podem ser carregados diretamente com instruções como MOV. A única exceção é cs, que só pode ser mudada por instruções que afetem o fluxo de execução, como CALL ou JMP. Embora a segmentação esteja sempre ligada, ela trabalha de formas diferentes em modo real, ou protegido.

Em modo real, como durante o early boot, o seletor de segmento é um número 16-bit que especifica o endereço físico de memória para o início de um segmento. Esse número precisa ser dimensionado, porque de outra forma também será limitado a 64K, anulando o propósito da segmentação. Por exemplo, a CPU poderia usar o seletor de segmento como os 16 bits mais significativos do endereço da memória física (através da mudança dos 16 bits para a esquerda, o que equivale a multiplicar por 216). Esta regra simples habilitaria segmentos a endereçar quatro gigas de memória em pedaços de 64K, mas aumentaria os custos de chip packaging, porque demandaria mais pins de endereços físicos no processador. Dessa forma, a Intel tomou a decisão de multiplicar o seletor de segmento por somente 24 (ou 16), o que com um simples golpe limitou a memória a aproximadamente 1MB e complicou a tradução. Abaixo um exemplo mostrando uma jump instruction em que o cs contém  0×1000:

Segmentação em real mode

O segmento modo real varia de 0 até 0xFFFF0 (16 bytes limitados a 1 MB) em incrementos de 16 bit. A esses valores você adiciona um offset de 16-bit (o endereço lógico) entre 0 e 0xFFFF. Acontece que há múltiplas combinações segmento/offset que apontam para a mesma locação de memória, e os endereços físicos vão além de 1MB se o segmento for grande para isso (veja a linha A20). Além disso, ao escrever em C em modo real, um far pointer é um pointer que contém tanto o seletor de segmento, quanto o endereço lógico, o que permite endereçar 1MB de memória. A medida que os programas foram crescendo e passando de segmentos de 64K, a segmentação e seus caminhos complicaram o desenvolvimento da plataforma x86. Pode soar um tanto estranho agora, mas foi isso que direcionou os programadores para as profundezas da loucura.

No modo protegido 32-bit, um seletor de segmento não é mais um número bruto, mas contém um índice em uma tabela de descritivos de segmentos. A tabela é simplesmente uma matriz contendo registros de 8-bit, onde cada registro descreve um segmento, e aparece assim: 

Descritivo de segmento

Há três tipos de segmentos: código, dado e sistema. Para simplificar, somente as características comuns do descritor são mostradas aqui. O endereço base é um dos endereços 32-bit lineares apontado para o início do segmento, enquanto o limite especifica o seu tamanho.

Adicionando o endereço base a um endereço lógico de memória, chega-se a um endereço linear. O DLP é o nível de privilégio do descritor. É um número de 0 (o mais privilegiado, modo kernel) a 3 (o menos privilegiado, modo do usuário), que controla o acesso ao segmento.

Os descritores do segmento são armazenados em duas tabelas: a Global Descriptor Table (GDT) e o Local Descriptor Table (LDT). Cada CPU, (ou core) em um computador contém um registro chamado gdtr, que armazena o endereço de memória linear do primeiro byte no GDT. Para escolher um segmento, você deve carregar um registro de segmento com um seletor de segmento nesse formato: 

Seletor de segmento

O bit TI é 0 para o GDT e 1 para o LDT, enquanto o index especifica o seletor do segmento desejado na tabela. Lidaremos o com RPL – Requested Privilege Level mais tarde. Quando a CPU está em modo 32-bit, os registros e instruções podem endereçar o espaço de endereçamento linear de qualquer forma. Assim, porque não ajustar o endereço base para zero e deixar os endereços lógicos coincidirem com os lineares? Os doutores da Intel chamam isto de “flat model”, e é exatamente o que kernels x86 modernos fazem (eles usam o basic flat model). O basic flat model equivale a desabilitar a segmentação ao fazer a tradução dos endereços de memória. Assim, em toda sua glória, aqui está o exemplo da transposição rodando em 32-bit modo protegido, com valores do mundo real para um app Linux no modo de usuário:

Modo de segmentação protegido

Uma vez acessados, os conteúdos para um descritor de segmentos vão para cache, de forma que não há necessidade de ler o GDT em acessos subsequentes, o que derrubaria a performance. Cada registro de segmento tem uma parte escondida para guardar o descritor em cache que corresponde a seu seletor de segmento. Para mais detalhes, incluindo mais informação sobre LDT, veja o capítulo 3 do Intel System Programming Guide Volume 3a. Os volumes 2a e 2b, que cobrem todas instruções do x86, também lançam luz sobre os vários tipos de x86 operadores de endereçamento– 16-bit, 16-bit com seletor de segmento (que pode ser usado por apontadores remotos), 32-bit, etc.

No Linux, somente três descritores de segmentos são usados durante boot. Eles são definidos com a macro GDT_ENTRY e armazenados na matriz  boot_gdt. Dois dos segmentos são flat, endereçando todo o espaço do 32-bit: um segmento de código é carregado no cs, e um segmento de dados é carregado nos outros registros de segmentos. O terceiro segmento é um segmento de sistema chamado Task State Segment. Depois do boot, cada CPU tem sua própria cópia do GDT.

Elas são praticamente iguais, mas algumas entradas mudam dependendo do processo que está rodando. Você pode ver o layout do Linux GDT no segment.h e sua instanciação está aqui. Há quatro entradas GDT primárias: duas flat para códigos e dados no kernel mode, e outras duas para user mode. Ao examinar o Linux GDT, note os buracos colocados de propósito para alinhar os dados com as linhas de cache da CPU  – um artifício do   von Neumann bottleneck, que se tornou uma praga. Finalmente, a mensagem de erro clássica do Unix “Segmentation fault”, não é devida ao x86 style segments, mas a endereços de memória inválidos normalmente detectados pela unidade de paginação – e aqui está um tópico para um post futuro.

A Intel laboriosamente contornou seu arranjo original de segmentação, oferecendo a opção entre segmentar ou seguir flat. Uma vez que é mais simples manejar endereços lógicos e lineares coincidentes, eles tornaram-se padrão, tanto que o modo 64-bit de agora reforça um espaço flat de endereço linear Mas mesmo em flat mode os segmentos ainda são cruciais para proteção do x86, sendo o mecanismo que defende o kernel dos processos user-mode, e todos os processos uns dos outros. No próximo post daremos uma espiada nos níveis de proteção e como os segmentos os implementam.

Grato a  Nate Lawson pela revisão deste post.

***

Texto original disponível em: http://duartes.org/gustavo/blog/post/memory-translation-and-segmentation