Site menu A característica mais odiada do x86

A característica mais odiada do x86

Acredito que a característica mais odiada da família Intel de processadores, mais conhecida como x86, é a segmentação de memória.

Ela é obsoleta há pelo menos 30 anos. Quem começou na profissión com Windows 95 ou Linux, mesmo em desenvolvimento de kernel ou driver, conseguiu passar ao largo da segmentação por 99% do tempo.

Mas o 1% vagabundo da segmentação permanece. Ela foi utilizada como uma "gaveta de tranqueiras" (*) para muitos recursos novos dos processadores intel, desde os 80286 até os atuais de 64 bits. Sim, por incrível que pareça o x86-64 segue usando segmentos de forma vestigial, e isto segue criando problemas.

Segmentação era um fator (entre muitos) que tornava o desenvolvimento para Windows 16 bits tão árido. O próprio Bill Gates afirmou que desenvolver para o 286 foi a coisa mais difícil que ele já fez na vida. Eu mesmo evitei ativamente o desenvolvimento para Win16. Pulei direto do MS-DOS para o Win32.

"Então por que escrever sobre isto agora, se é assunto morto e enterrado e nem fez parte relevante da sua vida?"

Porque é algo que aluga um triplex na minha cabeça. É tão deliciosamente barroco que faz pensar recorrentemente se não havia algum mérito nesse design. É como aquelas tralhas de família que não servem para nada, mas você carrega consigo, mudança após mudança, pelo significado totêmico.

Figura 1: A arquitetura do 80386/387. Editora Campus.

O livro acima é considerado uma referência no assunto. Pessoalmente, acho-o enrolado, mal escrito e mal traduzido. Mas foi meu primeiro contato com a complexidade da segmentação no modo protegido. Folheei-o pela primeira vez aos 14 anos, tomado emprestado de um conhecido.

Mais recentemente, comprei um exemplar usado, e ainda bem que o fiz, porque agora ficou bem mais difícil de achar. (Considerando as etiquetas coladas no meu exemplar, aparentemente foi subtraído de alguma biblioteca. Ou descartado de lá, por obsoleto.)

O processador 8086/8088

Tudo começou quando a Intel lançou o processador 8086 de 16 bits, sucessor do 8085 que era de 8 bits, dando origem à "família x86". O chip 8088, adotado pelo primeiro IBM PC, é idêntico ao 8086 do ponto de vista do software.

Desejava-se que o 8086 pudesse endereçar até 1MB de memória, porém usando apenas registradores de 16 bits. A solução foi combinar dois registradores: segmento e offset, segundo a fórmula

endereço linear = segmento x 16 + offset

O valor de segmento só tem efeito se estiver nos registradores CS, DS, ES ou SS. Isto significa que um programa enxerga, no máximo, quatro "janelas" de 64kB ao mesmo tempo. Ele não tem acesso 100% imediato a toda a memória. (Embora, na época, 256kB fosse um latifúndio. O primeiro IBM PC saiu com apenas 16kB de RAM, expansíveis para 64kB.)

Também significa que, na programação 8086, há dois tipos de ponteiros: o "curto" (16 bits) que referencia memória dentro do segmento corrente, e o "longo" (32 bits) que referencia um segmento diferente. Ponteiros longos eram mais lentos e usavam mais memória, então os programas procuravam usar ponteiros curtos sempre que podiam.

Não era algo impossível de tankar; usamos MS-DOS por 20 anos ou mais e sobrevivemos. Mas era chato, era fonte de bugs, e era ridicularizado por quem trabalhava com UNIX ou Mac, que usavam CPUs mais bem resolvidas como o Motorola 68000.

O processador 80286

O 286 foi um processador ambicioso: endereçava até 16MB de RAM, possuía suporte a memória virtual, memória protegida e multitarefa preemptiva. Era mais rápido e mais capaz que o 68000. Era muito mais rápido que o 8086/8088 mantendo 100% de compatibilidade retroativa, coisa importante pois o IBM PC já estava se firmando no mercado.

Só tinha um problema: teimou em continuar sendo 16 bits, com os mesmos registradores de 16 bits do 8086. As consequências disto, nós sentimos até hoje. E a Intel agora sente também :)

O 286 trabalha em dois modos: real e protegido. No modo real, ele funciona exatamente como um 8086, apenas mais rápido. No modo protegido, os registradores CS, DS, etc. mudam de significado, passam a ser seletores.

Um seletor não é um endereço; é uma estrutura de 16 bits com três membros: número, tipo e RPL (privilégio).

15           3 2 1 0
+-------------+-+-+--+
|    Número   |T|RPL |
+-------------+-+-+--+

// Fonte: Gemini

O número do seletor aponta para uma posição na tabela de descritores. O descritor de segmento é uma estrutura de 8 bytes onde finalmente se acha o endereço-base do segmento, entre outras informações.

struct descriptor_286 {
    uint16_t limit_low;       // Parte baixa do limite do segmento
    uint16_t base_low;        // Parte baixa do endereço-base
    uint8_t  base_high;       // Parte alta do endereço-base
    uint8_t  access_byte;     // Tipo do descritor, direitos de acesso
    uint16_t reserved;        // Reservado (deve ser 0 no 286)
} __attribute__((packed));

// Fonte: Gemini

A (única) virtude deste esquema é facilitar o porte de programas 8086 para 80286, pois a semântica dos "ponteiros curtos" não muda. E sua maior desvantagem é que cada seletor só dá acesso a uma janelinha de 64kB de memória.

Um seletor pode ser do tipo local ou global. A depender do tipo, o número do seletor refere-se à tabela local de descritores (LDT) ou à tabela global (GDT). A ideia é que a GDT descreva segmentos do sistema operacional ou comuns a todas às tarefas, enquanto a LDT contém segmentos privativos da tarefa em execução no momento.

Supondo um seletor que exista na GDT, mais um offset, o cálculo do endereço linear seria algo como

numero = (seletor & 0xfffc) >> 3;
descritor = base_GDT[numero];
end_linear = descritor.base_high * 0x10000 + descriptor.base_low
             + offset

Seria muito lento fazer todas essas contas a cada acesso à memória. Porém o processador copia o descritor da GDT ou da LDT para um registrador-sombra quando o seletor é atribuído a um registrador de segmento. O custo da troca é pago apenas uma vez.

Isto significa que, se o descritor for modificado na GDT ou na LDT, o processador não vai tomar conhecimento; é preciso tocar o registrador de segmento para forçar a recarga. Por outro lado, um programa, mesmo em modo privilegiado, não pode nem ler o conteúdo do registrador-sombra que contém o descritor, muito menos gravá-lo (*6).

Apenas tarefas privilegiadas (e.g. kernel do sistema operacional) podem manipular as tabelas GDT e LDT. Se um processo precisa de mais memória, tem de solicitar ao sistema, que cria um novo segmento na LDT. Por outro lado, se não existe sistema operacional e o programa domina o computador inteiro (e.g. jogos da época), pode usar apenas a GDT.

Os descritores fazem muito mais que descrever segmentos de memória. Quase todos os recursos de multitarefa preemptiva do 286 foram enfiados nos descritores. Voltarei ao assunto mais adiante, para não cansar.

Do ponto de vista do desenvolvedor de software, o 286 era horrível e nunca deveria ter sido lançado. Quase nenhum sistema tirou total proveito dele.

Por outro lado, ele desempenhou muito bem no mercado, pois deu aos usuários da época o que eles queriam: um 8086 mais rápido. Isso o 286 realmente era; ganhava até das primeiras versões do 386. O 286 tinha ainda a vantagem de ser oferecido por diversos fornecedores, enquanto o 386 era inicialmente um monopólio da Intel.

Isto levou a IBM a lançar diversas versões do PS/2 baseados no 286, apostando na sobrevida do chip, o que teve consequências funestas sobre o sistema operacional OS/2, conforme falaremos adiante.

O processador 80386 e a arquitetura IA-32

Com o 386, a arquitetura Intel chegou à idade adulta, oferecendo tudo (**) que faltava ao 286 para ser considerado um processador de gente grande: 32 bits e memória virtual paginada. Com o 386, nasceu a longeva arquitetura IA-32.

Para variar, um objetivo pétreo do projeto do 386 era compatibilidade retroativa perfeita com o 286 e o 8086, e inclusive permitindo mistura de código 16 bits e 32 bits num mesmo programa. O esquema de seletores não foi abandonado. Foi até ampliado para acomodar as extensões de 32 bits.

Num dos poucos lances de clarividência da plataforma, os descritores 286 deixaram 2 bytes sobrando, de valor obrigatoriamente zero, o que permitiu adicionar suporte a descritores de 32 bits sem modificar o tamanho do descritor nem o formato das tabelas GDT e LDT.

Felizmente, no 386, um sistema operacional pode praticamente ignorar a segmentação, pois cada segmento pode ter até 4GB de tamanho. No Linux 32 bits, usa-se apenas quatro descritores imutáveis, todos na GDT: dois para o kernel, dois para processos comuns. Todos os processos usam os mesmos dois descritores. A segregação da memória de cada processo é realizada via paginação.

Em sistemas como o Novell Netware, usava-se a estratégia oposta: todas as tarefas do sistema eram privilegiadas, mas cada tarefa tinha seus próprios segmentos com o tamanho justo necessário, estabelecendo uma segregação primitiva, mas eficaz. Os servidores Novell eram bastante estáveis. E muito rápidos.

A principal "salada" no 386 é que os dois mecanismos de segregação de memória — paginação e segmentação — podem ser usados ao mesmo tempo. A soma do offset com o endereço-base do segmento (obtido no descritor) determina o endereço linear, de 32 bits, que pode ser real ou virtual. Se a paginação estiver ativada, o endereço linear é virtual, e ainda tem de ser traduzido para um endereço real ou físico, mediante consulta às tabelas de paginação.

Obviamente, seria muito barroco um sistema operacional usar os dois esquemas ao mesmo tempo. Embora haja casos pontuais em que isto é útil ou necessário, como no Windows 95 que permite rodar aplicativos de 16 bits, e até mesmo instalar drivers de 16 bits. Mas, em geral, usa-se ou um ou outro.

No caso do sistema Novell, não se usa memória virtual nem paginação, apenas segmentos. O endereço linear é o endereço físico.

No Linux, é o oposto: o endereço-base de todos os segmentos é zero, e todas as tarefas usam os mesmos dois segmentos. Porém, via de regra, um mesmo endereço linear é traduzido para diferentes endereços físicos, pois cada tarefa tem sua própria tabela de paginação.

Antes que alguém pergunte, paginação é o esquema de memória virtual mais utilizado, desde o chip 68000 e o sistema VAX. O esquema de segmentação não é completamente sui generis da Intel, mas é pouco utilizado e muito amaldiçoado pelos conhecedores do assunto.

Novamente, de um ponto de vista técnico, o 386 deveria ter rompido completamente com o passado, mas acertou na mosca de um ponto de vista mercadológico. Compatibilidade retroativa era importante essa era em que dominava o código fechado, não raro escrito em assembler. Muita, muita gente usava softwares cujo fabricante já tinha desaparecido há anos. Não era como hoje em que existe a cultura do código portável e aberto.

Um recurso extremamente útil do 386, que fazia muita falta no 286, é o modo virtual 8086. Ele dá suporte em hardware para a execução segura de diversas tarefas em modo real, com multitarefa preemptiva. Graças a isto, mesmo no Windows 3.1 era tranquilo abrir diversos aplicativos MS-DOS ao mesmo tempo.

Se o modo "virtual 8086" tivesse sido disponibilizado no 286, o OS/2 1.1 poderia ter emplacado forte. O Windows poderia ter emplacado já na versão 2.x. O Xenix/286 poderia ter ganhado a habilidade de rodar programas MS-DOS. Em resumo, a história poderia ter sido bem diferente do que foi.

Arquitetura x86-64

A Intel tentou repetidas vezes romper com o legado x86 criando processadores completamente novos, como o iAPX432 e o Itanium, sem sucesso. Já a AMD decidiu "superar o mestre" desenvolvendo a arquitetura x86-64, conduzindo o velho x86 para o mundo dos 64 bits.

Os descritores continuam tendo os mesmos 8 bytes de tamanho. Descritores de 16, 32 e 64 bits convivem na GDT e na LDT. O x86-64 usou literalmente o último bit sobrando no descritor para acomodar segmentos de 64 bits.

Novamente, o objetivo do x86-64 é manter compatibilidade retroativa. Os processadores x86-64 rodam código de 16 e 32 bits sem problemas. Porém, há um rompimento relativo:

A arquitetura cumpriu seu papel: dominou de vez o mercado de servidores e workstations, que já estava sendo erodido pelo x86 de 32 bits. Até a Apple, bastião de longa data dos detratores do x86, adotou a arquitetura Intel para o Mac.

O futuro

Hoje, a arquitetura x86 está sob ataque. Ela ainda é suficiente e adequada, mas seu nicho principal, que são os computadores de uso geral, está encolhendo. Celulares, Macs e até parte da nuvem migraram para ARM, e namoram o RISC-V. Computação de alta perfomance migrou para GPUs, em que a nVidia detém um quase-monopólio no momento.

Em computação de uso geral, ou seja, para aquelas tarefas em que você ainda compra um computador físico e instala em seu escritório ou em seu datacenter, um PC com x86 ainda é o que resolve. Rápido, barato, compatível com tudo e todos, performance por watt não perde para quase ninguém. (Os melhores chips ARM são "vendas casadas", como os Macs e as instâncias AWS Graviton.)

Um Raspberry Pi gasta menos energia que um mini PC, não existe um mini PC lento que gaste tão pouco quanto um Raspberry. Porém, qualquer mini PC é muito mais rápido que qualquer Raspberry. Intel e AMD não fazem chips x86 de baixíssimo consumo porque não querem. Isto é um erro? Talvez. Mas certamente não é por falta de capacidade técnica. É porque o mercado embedded não lhes interessa.

Intel e AMD têm trabalhado em iniciativas para abandonar de vez o legado e ganhar fôlego para o futuro. O projeto x86s estabelece uma arquitetura de 64 bits "pura", sem a possibilidade de rodar código 32 bits. O projeto Intel APX visa modernizar o conjunto de instruções e dobrando o número de registradores.

Mas que se dane o futuro, queremos mesmo é chafurdar no passado!

Tipos de descritores

Como foi dito antes, muitos novos recursos do 80286 acabaram sendo metidas nos descritores de segmentos. Assim, eles existem em diversos tipos e subtipos:

Descritores comuns

Os descritores comuns dão acesso a um segmento da memória. Podem existir tanto na GDT quanto na LDT. Possuem bits de permissão de leitura, escrita, execução e direção de crescimento. Nem todas as combinações são permitidas.

Um descritor de segmento de código tem de ter pelo menos a permissão de execução, e não pode ter permissão de escrita. A permissão de leitura é opcional, o que é um recurso interessante se segurança.

Um descritor de segmento de dados tem de ter permissão de leitura, não pode ter permissão de execução. A permissão de escrita é opcional.

Um descritor de segmento de pilha é similar ao de dados, com a direção de crescimento para baixo, pois no x86 a pilha cresce para baixo (diminuindo o valor do endereço). A permissão de escrita é obrigatória.

Descritores de 16 bits (286) e 32 bits (386) possuem diferenças internas. Baseado no descritor, o 386 "sabe" que o segmento de código ou de dados é padrão 286, e funciona de acordo. Uma mesma tarefa pode misturar segmentos de 16 e 32 bits.

Sistemas de 32 bits típicos usam os descritores de forma vestigial: apenas um descritor para código, outro para dados e pilha, ambos mapeando a mesma área de memória.

LDT

O descritor de segmento LDT, como o nome diz, indica um segmento de memória onde reside uma tabela LDT. Por sua vez, esse descritor só pode residir na GDT. Esta área de memória só deve ser manipulada pelo kernel, e é lida pelo processador.

Num sistema operacional típico, cada tarefa terá sua tabela LDT, e cada tabela LDT terá seu descritor listado na GDT. Ao comutar a tarefa, o kernel ou o processador carrega um seletor (instrução LLDT) que aponta a LDT em uso para o descritor correto.

No Linux antes da versão 2.4, o kernel criava um descritor LDT para cada processo. Dali em diante, só se o processo requisitar uma LDT. Geralmente quem faz isso são apps que lidam com código de 16 bits, tipo DOSEmu (esse é velho, hein?), WINE, etc.

TSS

O segmento de estado de tarefa, conhecido como TSS, é uma área de memória onde são salvas as informações de uma tarefa ou processo: registradores, segmento e offset da pilha, entre outras informações. O respectivo descritor de TSS tem de residir na GDT.

Os processadores 286 e 386 fazem boa parte do trabalho de chaveamento de tarefas (mais sobre isso adiante), e tudo gira em torno das TSS. A tarefa em execução corrente é conhecida através do registrador TR (Task Register), que contém o seletor do descritor TSS da tarefa.

Num sistema operacional típico, cada tarefa tem um segmento TSS, e o respectivo descritor na GDT. Há duas formas básicas de chavear a tarefa. Uma é chamar o seletor TSS como se fosse um ponteiro longo. Outra é modificar o registrador TR diretamente.

A arquitetura do x86 permite chavear tarefas como se fosse uma chamada de subrotina, a fim de acomodar sistemas de arquitetura bem "horizontal", onde tarefas invocam umas às outras sem a intermediação de um kernel. Mas não acredito que algum sistema realmente tenha usado isso.

Uma vez que a GDT possui no máximo 8192 entradas, em tese o x86 estaria limitado a 8192 tarefas ou processos. Além disso, o chaveamento de tarefas por hardware é relativamente lento. Apesar disto, o Linux usou o recurso até o kernel 2.2, e era limitado a 4090 processos.

Sistemas mais modernos (e.g. Linux 2.4 em diante) contornam estas limitações alocando apenas um segmento TSS por processador, e manipulando seu conteúdo por software. O sistema OS/2 nunca usou TSS individual por tarefa. Sistemas UNIX BSD para 386 oscilaram entre não usar TSS por performance, ou usar para melhorar a segurança.

No x86-64, o chaveamento de tarefa por hardware foi abolido, e o chaveamento de tarefa por software tornou-se a norma. Para quem tiver curiosidade, este excelente artigo mostra a evolução do chaveamento de contexto do Linux desde a versão 0.0.1 até o x86-64.

Portões ou "gates"

Os portões ou gates são descritores especiais e indiretos. Eles apontam para outros descritores, não para segmentos de memória. Há quatro tipos de portões.

O portão de chamada (call gate) é o mais genérico. Ele contém um seletor e um offset, que aponta para algum outro segmento de código. Seu objetivo é abstrair a interface de um recurso, seja ele uma chamada do sistema operacional ou uma rotina de biblioteca.

Para invocar um portão de chamada, tudo que o programa-cliente precisa saber é o seletor do portão. O offset é ignorado quando se invoca um portão.

O portão de chamada pode apontar um segmento de código mais privilegiado. Isto serve, por exemplo, para um processo comum invocar um serviço do kernel. Do ponto de vista do processo, ele está invocando uma simples subrotina usando um ponteiro longo. O processador se encarrega da transição de nível de privilégio.

A ideia é boa. O OS/2 faz uso de portões de chamada para syscalls, com a peculiaridade de usar um portão por de syscall, em vez do mais usual que é usar um único portão para todas.

No mundo UNIX e mesmo no Windows, preferiu-se usar interrupções para syscalls (INT 80h nos UNIXes, INT 2Eh no Windows). Não encontrei uma justificativa técnica para isto. Provavelmente preferiram interrupções por ser um mecanismo quase universal, enquanto portões são sui generis do x86.

(O x86-64 possui uma instrução específica SYSCALL para chamar o kernel, enterrando esta discussão de uma vez por todas.)

O portão de tarefa ("task gate") contém um seletor de descritor TSS. Seu objetivo é permitir que uma tarefa passe o controle para outra.

Conforme dissemos antes, é possível invocar outra tarefa diretamente pelo seletor da TSS. Para que serve então o portão de tarefa? Acontece que a permissão da TSS é o nível de privilégio da própria tarefa, tornando-a inacessível a chamadores de baixo privilégio. Um portão de tarefa pode ter permissões diferentes, dando acesso indireto à tarefa privilegiada.

É outro recurso que poderia servir para invocar serviços de um microkernel, quiçá para comunicação interprocessos, mas não sei se algum sistema realmente fez uso.

Antes de prosseguir, devo esclarecer que existe uma terceira tabela de descritores, a IDT, para o tratamento de interrupções. A IDT não admite descritores comuns de código. Todo descritor na IDT é indireto, aponta para algum outro objeto na GDT.

Uma interrupção pode ser de hardware ou de software. Como dito antes, o Linux clássico usa a interrupção de software 80h para chamar o kernel. Para que isto funcione, o respectivo descritor na IDT deve dar permissão para que tarefas não privilegiadas provoquem esta interrupção.

Portões de chamada não são aceitos na IDT. Portões de tarefa podem ser usados, caso o tratamento de interrupção deva acontecer no contexto de uma tarefa específica (em tese, interessante para drivers de um sistema microkernel).

A IDT também aceita mais dois tipos de portão, vistos a seguir.

O portão de interrupção é o que o nome diz: uma espécie de portão de chamada dedicado a interrupções. É distinto do portão de chamada pois, quando invocado por uma interrupção de hardware, desliga automaticamente outras interrupções. O tratamento da pilha também é diferente.

Este tipo de portão não chaveia tarefas, e é mais rápido que um portão de tarefa. As interrupções são tratadas no contexto da tarefa em execução, seja ela qual for. É assim que o Linux clássico de 32 bits tratava interrupções.

O portão de trap é semelhante ao portão de interrupção, porém dedicado a interrupções que são exceções: divisão por zero, acesso ilegal à memória, etc. Distinto do anterior pois não desliga interrupções de hardware.

Todo sistema operacional x86 tem de lidar com a IDT, mas como todo processador usa uma tabela de interrupções para interagir com o hardware, o x86 não é fundamentalmente diferente dos demais neste aspecto em particular.

Resumo

A mensagem final é que o 286, e portanto seus sucessores, oferecem uma miríade de possibilidades de montar um sistema operacional, oferecendo "n" formas de chavear tarefas, invocar o kernel, invocar bibliotecas, etc. além de um forte apoio de hardware a diversas obrigações do sistema operacional.

Porém, são esquemas tão complicados, e tão pouco portáveis (porque nenhum outro processador faz a coisa deste jeito) que os desenvolvedores preferiram quase sempre passar ao largo.

Memória virtual e segmentação

Memória virtual é a possibilidade de um sistema usar mais memória do que realmente existe.

O excedente tem de ser guardado em algum lugar, como swap de disco ou outro tipo de memória. O processador e o sistema operacional trabalham em conjunto para detectar acessos a memória que está fora da RAM, a fim de providenciar que volte para a RAM, sem que as tarefas percebam.

O 80286 implementa memória virtual com base em segmentos, o que é ok, pois nele os segmentos têm no máximo 64kB cada. Em tese, cada tarefa 80286 pode ter até 1GB de memória virtual: 16384 segmentos (metade na GDT, metade na IDT) de 64kB cada.

O processador só pode acessar no máximo 16MB de RAM física, mas nada impede de usar um esquema de chaveamento de bancos de memória, acoplado aos segmentos, que seria muito mais rápido que um swap de disco.

Uma tarefa não pode conhecer o conteúdo de um descritor, só pode usá-lo, através do seletor. Isto permite que o sistema operacional mova o segmento na memória sem que a tarefa perceba, o que é vital no caso da memória virtual.

Alguém usou, além do Xenix/286 e do OS/2? Difícil dizer. Processadores "de gente grande" como o DEC VAX e a família 68000, contemporâneos do x86, sempre usaram o esquema de paginação, mais simples e mais padrão.

É bem verdade que muitas outras arquiteturas implementaram memória virtual baseada em segmentação, porém são antigas (anos 1960 e 1970). Processadores mais modernos oferecem segmentação (e.g. PowerPC de 32 bits) porém ela é muito menos intrusiva que no x86.

Na paginação, a memória é virtualizada em páginas de tamanho fixo (4kB no 80386, opcionalmente maiores em processadores mais novos). Em tarefas com memória virtual ativada, os endereços lineares das páginas são "falsos", eles são traduzidos para endereços da RAM física usando-se outra tabela, o diretório de páginas.

Em geral, o sistema operacional cria um diretório de páginas distinto para cada tarefa, o que permite isolar as tarefas umas das outras, e dar a cada tarefa a ilusão de estar sozinha na máquina e ser dona de toda a memória.

O 80386 suporta memória virtual por paginação, mas continuou a suportar memória virtual por segmentação ao mesmo tempo, o que em tese permite uma virtualização em dois níveis. Mas é claro que ninguém faria isto. Sistemas operacionais feitos para o 386 usam apenas paginação.

O sistema OS/2 suportava a mistura de código de 16 e 32 bits, inclusive em drivers e dentro do kernel, compartilhando áreas da memória. Para facilitar a convivência, adotou-se um esquema de "LDT tiling", onde o endereço linear de um segmento é proporcional ao número do seletor, permitindo que código de 16 bits faça "aritmética de ponteiros" com os seletores LDT, e que o código de 32 bits enxergue a mesma área de memória como um bloco único.

Se a paginação é tão boa, porque não foi utilizada desde o começo? Porque ela gasta mais memória. Proporcionar às tarefas a ilusão de uma memória virtual grande e contínua ("flat") tem seu custo. Segmentação onera o desenvolvedor, mas racionaliza o uso da memória.

Tanto é assim que a migração do ecossistema Windows para 32 bits, e a própria trajetória de sucesso do Linux, são correlacionáveis com a queda do preço da RAM nos anos 1990.

Segurança

Além da isolação da memória de diferentes tarefas, um processador multitarefa precisa oferecer mecanismos de segurança para que as tarefas invoquem o kernel, ou umas às outras, com segurança.

Em quase todos os processadores, desde o venerável DEC VAX (***), existem apenas dois níveis de segurança: tarefa comum, e kernel/supervisor. Há apenas uma instrução para que uma tarefa invoque o kernel. Interrupções de hardware são tratadas em modo supervisor.

No 80286, há quatro níveis de segurança: anel 0, 1, 2, 3, sendo o anel 0 o mais privilegiado. Não bastasse isso, vimos antes que existem diversos métodos de uma tarefa invocar o supervisor, invocar outra tarefa, ou entregar o controle à próxima tarefa. Cada método exige um tipo de checagem de segurança.

É possível que os projetistas do 286 tenham ouvido o canto da sereia do microkernel, e criaram diversos níveis de segurança a fim de colocar o microkernel no anel 0, os drivers no anel 1, e assim por diante. No papel, é bonito. Na prática, ninguém usa (nem 4 anéis, nem microkernel).

Uma vez que o 286 utiliza segmentos e descritores como interface da multitarefa, também é ali que a questão da segurança se aloja. Vamos começar pelo começo.

Via de regra, todo descritor de segmento possui um campo de 2 bits chamado DPL (Descriptor Privilege Level). Seu significado é diferente para cada tipo de descritor, mas ele está sempre lá.

Também dissemos antes que o seletor de 16 bits, que usa os mesmos registradores de segmento do 8086, possui três campos internos: número, tipo e RPL.

O RPL (Requested Privilege Level) é utilizado para informar o nível de privilégio desejado quando o seletor é passado como parâmetro, e.g. numa chamada a uma biblioteca, tarefa ou ao kernel.

Porém, quando o seletor está armazenado num registrador de segmento (CS, DS, ES, SS) não se pode colocar qualquer valor ali. O processador sobe o valor da RPL automaticamente, se era menor que o privilégio corrente (lembrando que valor maior = privilégio menor).

E como o processador sabe o privilégio da tarefa corrente? Este é o CPL (Current Privilege Level), que é simplesmente o RPL do seletor contido no registrador CS.

Segurança em segmentos de código

Uma vez que o kernel tenha iniciado uma tarefa, colocando no CS um seletor com RPL alto, e se cada vez que essa tarefa tentar mudar o valor de CS, o processador força o novo seletor a ter RPL igual ou maior o que já está lá, temos que a tarefa nunca pode aumentar seu privilégio. Só pode diminuir.

Um programa não pode invocar um descritor de código cujo DPL seja menor (mais privilegiado).

Existe uma exceção: o descritor tem um bit chamado "Conformante", em que o código reduz ou "conforma" sua permissão ao chamador. Este recurso é útil se o segmento for e.g. uma bibioteca invocada por tarefas com diferentes privilégios. Neste caso o DPL especifica o privilégio máximo admissível do chamador (o 80286 proíbe invocar um segmento de privilégio menor que o atual).

No Linux, que usa segmentos de forma vestigial, temos apenas dois segmentos de código. Um com DPL=0 para o kernel, e outro com DPL=3 para os processos. Segmentos conformantes não são usados, nem os anéis 1 ou 2. (****)

Este uso mínimo dos segmentos ainda é necessário, pois como foi dito o processador usa o RPL do registrador CS para conhecer o CPL, ou seja, o privilégio da tarefa corrente. Não há como escapar disto.

Segurança em segmentos de dados

Nos descritores de segmentos de dados, o campo DPL indica o privilégio necessário para acesso. Portanto, o CPL do código em execução tem de ser numericamente menor que o DPL do segmento de dados que esse código pretende acessar.

Assim como acontece com segmentos de código, o Linux também usa dois segmentos de dados, um para o kernel (DPL=0) e um para os processos (DPL=3). Este mesmo segmento é usado como pilha.

Segurança de segmentos x paginação

Num sistema de memória virtual paginada, como cada tarefa recebe seu próprio diretório de páginas, em tese todo esse mecanismo de CPL/RPL/DPL não é necessário para proteção de dados, pois a tarefa não enxerga memória que não esteja listada no diretório.

Porém, cada entrada de página do 80386 possui um bit de permissão, com apenas dois estados: privilegiado (acessível por código com CPL=0,1,2) e não-privilegiado (CPL=3).

Isto faculta ao sistema operacional mapear áreas privilegiadas na memória virtual da tarefa, e ainda assim ela não ter acesso a elas, salvo quando isso é conveniente e.g. quando invoca o kernel. Foi um método muito utilizado no passado, pois permite que o kernel atenda syscalls dentro do contexto da tarefa.

O Linux fazia uso deste recurso. Mas, por conta do bug MELTDOWN, o kernel passou a "morar" numa memória virtual distinta dos processos.

Segurança de portões

No 286, os portões são o mecanismo oferecido para uma tarefa menos privilegiada fazer solicitações a tarefas mais privilegiadas, ou ao kernel.

O DPL do descritor de portão informa o privilégio necessário para chamar o portão. O portão referencia um segmento de código, cujo DPL especifica o privilégio do código invocado. Então, por exemplo, um portão para o kernel teria de ter DPL=3, e referenciar um segmento de código do kernel com DPL=0.

O 80286 previu a situação de uma tarefa invocar outra diretamente, sem passar pelo sistema operacional. Neste caso, o DPL do descritor TSS indica o privilégio de execução da tarefa chamada. Uma tarefa com CPL=3 não pode invocar diretamente um TSS com DPL=0. Porém, se há um portão de tarefa com DPL=3 que aponta para o TSS, então a tarefa com CPL=3 pode fazer a chamada, através do portão.

Portões de interrupção podem possuir DPL diferente de zero, caso seja admitido que uma tarefa comum possa invocar uma interrupção por software. Isto acaba criando mais um caminho para chamadas ao sistema. É justamente o que o Linux 32 bits usa (processo invoca interrupção 80h para chamar o kernel), talvez porque acaba sendo mais simples e claro do que usar portões de chamada ou de tarefa.

Um complicador da segurança ao cruzar os limites de uma tarefa ou de um anel de segurança, é a passagem de seletores como parâmetros. Por exemplo, se uma tarefa pede ao kernel para ler um arquivo do disco, precisa passar um ponteiro longo para o buffer de destino, composto por seletor e deslocamento. No 80286 isso é inevitável pois cada segmento possui apenas 64kB, qualquer programa não-trivial usará muitos segmentos.

Porém, seletores de segmento podem ser "falsificados" pelo chamador, pois são passados pela pilha ou por registradores de uso geral, não por registradores de segmento. Quiçá o seletor pode ter um RPL mais privilegiado que o chamador, poderia apontar para um segmento da GDT que normalmente a tarefa não teria privilégio para acessar. E aí?

O processador oferece algumas instruções para verificar o RPL e a validade dos seletores: ARPL, VERR, VERW e LAR. Na vida real, é preciso fazer verificações adicionais, por software, portanto mais lentas. Para começo de conversa, se o seletor é LDT, o kernel precisa verificar que o respectivo descritor realmente existe no contexto da tarefa que fez a chamada.

Em resumo, o esquema é super complicado e pouco defensável em comparação com arquiteturas com apenas dois níveis de segurança e sem segmentos.

Este problema não existe num sistema de memória paginada rodando no 80386, pois nunca é preciso passar "ponteiros longos" com seletor. Basta um "ponteiro curto" (de 32 bits) para apontar para qualquer local da memória virtual do chamador.

Mesmo que um seletor pudesse ser passado como parâmetro no caso acima, por mais "falsificado" que ele fosse, o respectivo endereço linear ainda apontaria para dentro da memória virtual da tarefa. O kernel precisa apenas verificar que a tarefa tem permissão para acessar aquela parte da sua própria memória virtual (o que ele já teria de fazer em todo caso).

Eu quero me incomodar!

Se você faz questão rodar um sistema operacional que funcione no modo protegido segmentado do 286, pode tentar o Minix 1.7 ou 2.0, que tem código-fonte aberto e implementa multitarefa preemptiva no 286.

Versões anteriores do Minix rodam em modo real (8086), se bem entendi o Minix 1.7 adicionou suporte a 80286 e 80386 ao mesmo tempo.

Nem o Andrew Tanenbaum tankou o 80286 completamente. Ao que parece, no modo 286 cada processo só ganha um segmento de código e um segmento de dados, 64kB cada, e era isso. Os processos invocam o kernel via interrupção, mesmo esquema do Linux e muitos outros UNIX.

Os sistemas comerciais mais conhecidos que usaram o modo protegido 286 foram o OS/2 1.x e o Xenix/286. Infelizmente, nem um nem outro tem código-fonte aberto, apesar de obsoletos por décadas. Há algumas imagens na Internet, dos disquetes e de máquinas virtuais prontas para uso.

Outros sistemas menos conhecidos que eram multitarefa no 286: QNX 2.x, Concurrent DOS/FlexOS e Coherent UNIX (este último teve o código-fonte aberto em 2015).

Briga Intel x Microsoft x IBM

A dificuldade de programar o 80286 deve ter causado muita calvície e acabado com muitos casamentos. Um matrimônio notório que afundou por causa desse processador foi a parceria Microsoft-IBM.

Bill Gates pode reclamar do 286 com razão, pois a Microsoft desenvolveu tanto o Xenix/286 quanto o OS/2 1.1 — talvez os únicos sistemas conhecidos do grande público que faziam uso dos recursos de multitarefa do 286.

O Xenix/286 foi lançado em 1983, quando o 386 nem existia, então fazia sentido suportar o 286. Já o OS/2 1.1 foi lançado em 1988. Para este, a Microsoft defendia "pular" o 286 e suportar apenas o 386. Porém a IBM insistia no suporte à multitarefa no 286, pois vendia muitas máquinas PS/2 com 286 e tinha prometido que o OS/2 funcionaria bem nelas.

No Windows, a Microsoft fez valer sua visão. O Windows 2.x/3.x rodava em "modo real" no 80286, como se fosse um 8086; memória virtual e multitarefa só eram suportados no 386, muito embora o Windows 3.x fosse um sistema de 16 bits do ponto de vista das aplicações.

A Microsoft ainda acreditava que o OS/2 tinha uma chance real de ser o sucessor do MS-DOS, então mantinha uma panela em cada boca do fogão: MS-DOS, OS/2, Windows e Xenix (*5). Com o enorme sucesso comercial do Windows 3 em 1990, a Microsoft começou a acreditar mais no seu produto, e menos no OS/2 desenvolvido a quatro mãos com a IBM, até o divórcio oficial em 1992.

A Microsoft também atritou-se com a Intel por causa do 286. Como dito há muitos parágrafos atrás, os descritores de segmento x86/x86-64 possuem o tamanho fixo de 8 bytes. No 286, dois desses bytes são reservados e devem ser sempre zero. Porém, essa obrigação está apenas na documentação; o processador não conferia.

O Xenix/286 tirava proveito disso e usava esses dois bytes para seus próprios fins. E por conta disto, não rodava no 386. Bill Gates tentou convencer a Intel a manter compatibilidade retroativa para esta característica não-documentada, sem sucesso.

Notas

(*) Sugestão do ChatGPT para tradução informal de "kitchen sink". É, agora até eu uso esse negócio às vezes.

(**) Exceto virtualização. A arquitetura x86 só foi suportar virtualização decentemente em 2005.

(***) Tenho citado o DEC VAX pois é uma arquitetura particularmente influente. Foi nela que o UNIX BSD ganhou corpo. Muitas características dela acabaram virando padrões de fato, ou pelo menos padrões que os programadores esperam encontrar nas demais arquiteturas; e espantam-se quando não é o caso. Vide o verbete "VAXocentrismo" do Hacker's Dictionary.

(****) Acho que o esquema de paravirtualização Xen usava o anel 1 para posicionar o kernel abaixo do hipervisor.

(*5) A Microsoft já tinha vendido o Xenix para a SCO em 1987, porém detinha uma grande participação acionária na SCO, que só foi vendida em 2000.

O Xenix foi um grande sucesso em sua época: chegou a representar mais de 50% das instalações UNIX nos anos 1980. Muita gente apostava (corretamente?) que o UNIX era o futuro, e que os PCs acabariam representando uma boa fatia do mercado de workstations UNIX. Então a Microsoft tinha de manter um pezinho nesse mercado.

(*6) A instrução não-documentada LOADALL permitia manipular diretamente os registradores-sombra, que normalmente são apenas caches de descritores. Entre outros truques, esta instrução permite que um programa em modo real acesse memória acima de 1MB.

Isto significa que os descritores estão ativos o tempo todo no 80286/386. O modo real é portanto um caso especial do modo protegido; alterar o valor do registrador de segmento não provoca uma consulta à GDT ou LDT, mas é copiado diretamente para o endereço-base de segmento do descritor.