Acredito que a característica mais odiada da família Intel de processadores, mais conhecida como x86, é a questão da 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,9% do tempo.
Não 100%, porque a segmentação 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 isso segue criando problemas.
Segmentação era um dos motivos que tornava o desenvolvimento para Windows 16 bits (até a versão 3.11) 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. Provavelmente disse isso no contexto do Xenix/286, um dos pouquíssimos sistemas operacionais que usava todos os recursos de multitarefa do 80286. (O Windows não usava, por isso quebrava tanto.)
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 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.
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.)
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 mais memória, até 1MB, 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ó pode ir em registradores específicos para este fim: CS, DS, ES e SS. Isto significa que um programa enxerga, no máximo, quatro "janelas" de 64kB a cada instante. Ele não tem acesso 100% imediato a toda a memória. (Embora, na época, 256kB era muita coisa. O primeiro IBM PC saiu com apenas 16kB de RAM, expansíveis para 64kB.)
Também significa que haverá 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, para desempenhar um pouco melhor.
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 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). O número do seletor aponta para uma posição na tabela de descritores. Lá nessa tabela é que está o endereço-base do segmento, entre outras informações.
A princípio, isto onera bastante o cálculo do endereço linear, mas o processador possui otimizações internas para que este custo seja pago apenas quando o segmento é modificado, não a cada acesso à memória.
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 (LDT) ou à tabela global (GDT). A ideia é que a GDT contenha segmentos do sistema operacional ou comuns a todas às tarefas, enquanto a LDT contém segmentos privativos da tarefa em execução no momento.
Apenas tarefas privilegiadas (e.g. kernel do sistema operacional) podem manipular 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.
Todos os demais recursos novos do 286, necessários à multitarefa preemptiva, foram enfiados na tabela de descritores, direta ou indiretamente. Voltarei ao assunto mais adiante, para não cansar.
Em termos tecnológicos, o 286 era horrível e nunca deveria ter sido lançado, tanto que quase nenhum sistema realmente o aproveitou na totalidade. 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.
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.
Felizmente, no 386, um sistema operacional pode restringir muito o uso de seletores, pois cada segmento pode ter até 4GB de tamanho. No Linux 32 bits moderno, usa-se apenas quatro seletores, todos na GDT: dois para o kernel, dois para processos comuns. 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 uma tinha seus próprios seletores, o que estabelecia uma proteção primitiva, diminuindo a chance de uma tarefa invadir a memória da outra.
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 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 é 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, apenas segmentos, então 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 quatro segmentos. Porém, via de regra, um mesmo endereço linear é traduzido para diferentes endereços físicos, pois cada tarefa tem sua 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. Apenas sistemas de nicho fizeram uso dele.
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. Principalmente numa era em que o código fechado, não raro escrito à mão em assembler, ainda dominava. Não é como hoje que todo mundo procura escrever código C portável.
Um recurso extremamente útil do 386, que fazia muita falta no 286, é o modo virtual 8086. Ele dava 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, enquanto fazer o mesmo com apps Windows tendia a travar.
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.
Novamente, o objetivo do x86-64 é manter compatibilidade retroativa. Os primeiros processadores x86-64 eram capazes de rodar código de 16 bits e de 32 bits com boa performance. Porém, houve 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.
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 mais 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 o Raspberry mais novo. Intel e AMD não fazem chips x86 de baixo consumo (e lentos) porque não querem. Isto é um erro? Talvez. Mas está claro que esse mercado 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!
Como eu disse lá no começo, muitos recursos novos do 80286 acabaram sendo enfiados nos segmentos e seus respectivos descritores. Assim, eles existem em diversos tipos e subtipos.
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.
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.
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. Sistemas mais modernos (e.g. Linux 2.4 em diante) contornam esta limitação alocando apenas um segmento TSS por processador e manipulando seu conteúdo por software.
Do ponto de vista do processador, o Linux moderno nunca chaveia tarefas, está permanentemente executando sempre a mesma tarefa. Isto resolve outro problema, que é a relativa lentidão do chaveamento de tarefas por hardware.
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.
Os portões ou gates são descritores indiretos. Eles apontam para outros descritores, não para segmentos de memória. Há quatro tipos de portões.
O portão de chamada é o mais genérico. Ele aponta para um descritor de código na LDT ou na GDT. 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 número do seletor. O offset é ignorado quando se invoca um portão.
O portão de chamada pode apontar a 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, sistemas como OS/2 fizeram uso de portões de chamada. Mas no mundo UNIX sempre preferiu-se usar interrupções, em particular a de número 80h, também adotada pelo Linux. Não encontrei uma explicação convincente para os UNIXes usarem interrupções em vez de portões de chamada. Provavelmente porque era um mecanismo sui generis.
(x86-64 possui uma instrução específica SYSCALL para chamar o kernel, aposentando tanto portões quanto interrrupções nesta função, enterrando a discussão de uma vez por todas.)
O portão de tarefa ("task gate") aponta para um descritor de TSS na GDT. Seu objetivo é permitir que uma tarefa passe o controle para outra.
Conforme dissemos antes, é possível invocar outra tarefa 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 tarefa. Código com baixo privilégio não pode invocar uma TSS de alto privilégio. Mas pode invocar um portão de tarefa, se este portão for permissivo, que então invoca a TSS.
É outro recurso que poderia servir para invocar o kernel, quiçá fazer 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, que são utilizados para tratar interrupções. Apenas o sistema operacional enxerga e manipula esta tabela.
A IDT não admite segmentos de código, portanto todo descritor na IDT aponta para algum outro na GDT. Portões de chamada também não são aceitos. Portões de tarefa podem ser usados, para o caso do sistema desejar que um tratador de interrupção rode no contexto de uma tarefa específica (em tese, interessante para sistemas microkernel).
A IDT também aceita mais dois tipos de portão a seguir.
O portão de interrupção é o que o nome diz, uma espécie de portão de chamada, mas próprio para interrupções. É um descritor distinto do portão de chamada pois o tratamento da pilha é diferente.
Uma interrupção pode ser de hardware ou de software. Como disse antes, o Linux clássico usa uma 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 chamem esta interrupção.
Este tipo de interrupção não chaveia tarefa, e é mais rápida que um portão de tarefa. O Linux clássico (pre-MELTDOWN) é novamente um exemplo notório em que as interrupções de hardware são tratadas no contexto da tarefa em execução, seja qual for.
O portão de trap é semelhante ao portão de interrupção, porém dedicado a interrupções que são exceções. Por exemplo: divisão por zero, acesso ilegal à memória, etc.
É preciso um descritor distinto neste caso porque, quando uma exceção acontece, não há chaveamento da tarefa (porque a exceção é responsabilidade da tarefa corrente) e o tratamento de pilha também é diferente dos outros dois casos.
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.
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 é 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. O descritor de segmento possui bits que indicam se foi tentado acesso e se ele está presente na RAM.
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 ler o conteúdo de um descritor, apenas 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? Difícil dizer. Processadores "de gente grande" como o DEC VAX e a família 68000 sempre usaram o esquema de paginação, mais simples e mais padrão.
Na paginação, a memória é virtualizada em páginas de tamanho fixo (4kB no 80386). 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.
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 tivessem 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 o valor 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.
Uma vez que o kernel tenha iniciado a 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 não 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.
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.
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).
O Linux fazia uso deste recurso, mapeando o kernel na memória virtual de todos os processos, mas bloqueando o acesso não-privilegiado pelo mecanismo acima. Depois do bug MELTDOWN, o kernel passou a "morar" numa memória virtual distinta dos processos.
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 inter-tarefas é a passagem de segmentos como parâmetros. No 286 isso é inevitável pois cada segmento possui apenas 64kB, então qualquer programa não-trivial tem de passar seletores para indicar áreas de memória. Porém, seletores de segmento poderiam ser passados como valores arbitrários de 16 bits, quiçá com RPL falsificado, e aí?
O processador oferece algumas instruções para verificar o RPL e a validade dos seletores: ARPL, VERR, VERW e LAR. Não me parecem suficientes, provavelmente um sistema real teria de fazer verificações adicionais, por software e portanto lentas.
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 cada tarefa possui sua própria memória virtual. Os endereços lineares dos segmentos são virtuais, então mesmo um seletor "falsificado" não pode apontar para memória que não pertença ao processo chamador.
Se você realmente quer rodar um sistema operacional que faça uso dos recursos do 80286, você poderia tentar o Minix 1.7 ou 2.0, que tem código-fonte aberto e realmente 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.
(*) 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.