MyToyOS: PCI e PCI Express

Já falei sobre PCI aqui neste artigo. Embora existam alguns pequeno errinhos de interpretação de minha parte nele, o artigo é, essencialmente, correto. Aqui eu vou corrigir esses errinhos e mostrar como usar o configuration space direito. Outra coisa que falarei é sobre as modificações do padrão PCI Express.

Em primeiro lugar, não é que PCI oferece acesso de I/O mapeado em memória apenas. Ele oferece isso preferencialmente. A especificação nos diz que o espaço de endereçamento de I/O (portas) são usados, tipicamente, para dispositivos legados ou, como está dito lá, “espalhados” (onde, apenas o endereço base é fornecido)… O header no configuration space nos dá, pelo menos, dois BARs (Base Address Registers), que são os registradores com os endereços base do dispositivo, codificados.

A outra coisa, que pode não ter ficado perfeitamente clara no artigo anterior, é que existem 3 “espaços” de endereçamento: Memória, I/O (portas) e Configuração… A ideia do PCI não é apenas fornecer um barramento de alta velocidade entre o processador e o dispositivo, mas também uma maneira padronizada de “perguntar” aos dispositivos como podemos acessá-los. Para isso este espaço adicional (o de Configuração) existe. No padrão PCI ele é acessado pelas portas 0xCF8 e 0xCFC, escrevendo e lendo DWORDs sempre… A tentativa de escrever ou ler outra coisa que não DWORDs não acessará o configuration space.

No PCI, o configuration space fornece blocos de 256 bytes (64 DWORDs) contendo significados fixos:

Configuration space

Essa estrutura aplica-se ao barramento PCI local e apenas se estamos lidando com um dispositivo que não seja uma PCI-to-PCI bridge. Mesmo assim, os 24 bytes iniciais aplicam-se a todos os dispositivos…

No offset 0 temos o VendorID e o DeviceID, onde VendorID é usado como condição para a existência do dispositivo. Se você obtiver um VendorID igual a -1 (ou 0xffff), não tem um dispositivo pendurado no barramento, de acordo com o configuration space. O DeviceID, claro, identifica o dispositivo, assim como o VendorID identifica o fabricante (0x8086, por exemplo, é “Intel”).

As rotinas abaixo, escritas para MS-DOS, usando o DJGPP, demonstra a varredura de todos os dispositivos conectados ao barramento PCI:

/* pci.c */
#include <pc.h>
#include <stdint.h>
#include "pci.h"

#ifndef __DJGPP__
#error Isso só compila no DJGPP!
#endif

/* Lê um DWORD do configuration space. */
__attribute__((regparm(3)))
uint32_t pciGetConfigLong(uint8_t bus,
                          uint8_t device,
                          uint8_t function,
                          uint8_t offset)
{
  uint32_t data;
  outportl(0xCF8, 
           PCI_CONFIG_ADDR(bus, 
                           device, 
                           function, 
                           offset));
  return inportl(0xCFC);
}

/* Retorna VendorID e DeviceID 
   (se deviceIDptr != NULL). */
__attribute__((regparm(3)))
uint16_t pciGetConfigVendorID(uint8_t bus, 
                              uint8_t device, 
                              uint8_t function, 
                              uint16_t *deviceIDptr)
{
  uint32_t data;

  data = pciGetConfigLong(bus, device, function, 0);
  if (deviceIDptr)
    *deviceIDptr = data >> 16;
  return data & 0xffffU;
}

/* Pega o Base Address Register 0 */
__attribute__((regparm(3)))
uint32_t pciGetConfigBAR0(uint8_t bus, 
                          uint8_t device, 
                          uint8_t function, 
                          enum bartype_e *type)
{
  uint32_t data;

  data = pciGetConfigLong(bus, device, function, 0x10);

  /* Repare que o bit 0 do BAR nos indica o
     espaço de endereçamento usado! */
  if ((*type = (enum bartype_e)(data & 1)) == BARTYPE_MEM)
    data &= 0xfffffff0U;  // memory addr.
                          // 16 bytes aligned.
  else	
    data &= 0xfffffffcU;  // I/O addr.
                          // 4 bytes aligned.

  return data;
}

/* Pega a classe do dispositivo. */
__attribute__((regparm(3)))
uint8_t pciGetConfigClass(uint8_t bus, 
                          uint8_t device, 
                          uint8_t function)
{
  return pciGetConfigLong(bus, device, function, 8) >> 24;
}

O arquivo pci.h define, entre outras coisas, o macro PCI_MASTERBUS_ADDR:

/* pci.h */
#ifndef __PCI_INCLUDED__
#define __PCI_INCLUDED__

#include <stdint.h>

/* Número máximo de devices num barramento PCI */
#define PCI_MAXDEVS 64

/* Número máximo de funções num device. */
#define PCI_MAXFUNCS 8

/* Monta o endereço do bus/device/func/reg
   para o configuration space */
#define PCI_CONFIG_ADDR(bus,dev,func,offset) \
  ( ( 1U << 31 ) | \
    ( ((uint32_t)(bus)  & 0xff) << 16 ) | \
    ( ((uint32_t)(dev)  & 0x1f) << 11 ) | \
    ( ((uint32_t)(func) & 0x07) << 8 )  | \
    ( (uint32_t)(offset) & 0xfc) )

__attribute__((regparm(3)))
uint32_t pciGetConfigLong(uint8_t,
                          uint8_t,
                          uint8_t,
                          uint8_t);

/* Retorna VendorID e DeviceID (se deviceIDptr != NULL). */
__attribute__((regparm(3)))
uint16_t pciGetConfigVendorID(uint8_t, 
                              uint8_t, 
                              uint8_t, 
                              uint16_t *);

enum bartype_e {
  BARTYPE_MEM,
  BARTYPE_IO
};

__attribute__((regparm(3)))
uint32_t pciGetConfigBAR0(uint8_t, 
                          uint8_t, 
                          uint8_t, 
                          enum bartype_e *);

__attribute__((regparm(3)))
uint8_t pciGetConfigClass(uint8_t, uint8_t, uint8_t);

#endif

A varredura dos devices no barramento 0 pode ser feita assim:

/* scan.c */
#include <stdio.h>
#include <stdint.h>
#include "pci.h"

/* Essas são as classes padrão da especificação PCI 3 */
const char *classes[] = {
  "Unknown [old]",
  "Mass Storage",
  "Network",
  "Display",
  "Multimedia",
  "Memory",
  "PCI Bridge",
  "Simple Communication",
  "Base System Peripheral",
  "Input Device",
  "Docking Station",
  "Processor",
  "Serial Bus",
  "Wireless",
  "Inteligent I/O",
  "Satellite Communications",
  "Encrypt/Decrypt",
  "DSP",
  [255]="Unknown"
};

void main(void)
{
  uint16_t vendorid, deviceid;
  uint8_t dev, func, class;
  const char *p;
  static const char *reserved = "Reserved";
  static const char *types[] = { "Memory", 
                                 "I/O", 
                                 "<none>" };
  enum bartype_e type;
  uint32_t addr;

  fputs("Dispositivos encontrados (Bus #0):\n", stdout);

  /* Varre todos os dispositivos. */
  for (dev = 0; dev < PCI_MAXDEVS; dev++)
  {
    /* E varre todas as funções de um dispositivo. */
    for (func = 0; func < PCI_MAXFUNCS; func++) { /* Lê VendorID para determinar se o dispositivo existe. */ vendorid = pciGetConfigVendorID(0, dev, func, &deviceid); /* Lê a classe do dispositivo para determinar o que ele é. */ class = pciGetConfigClass(0, dev, func); if (!(p = classes[class])) p = reserved; /* Pega o near pointer (endereço físico) do BAR0 */ addr = pciGetConfigBAR0(0, dev, func, &type); /* Se VendorID for diferente de 0xffff, temos um dispositivo! */ if (vendorid != 0xffff) printf("Device #0x%02hhx:0x%02hhx -> VendorID: %#hx, DeviceID: %#hx,\n"
               "                     Class: %#hhx (%s)\n"
               "                     BAR #0: %#x (%s)\n",
          dev, func, vendorid, deviceid, class, p,
          addr, addr ? types[type] : types[2]);
    }
  }
}

O resultado, no MS-DOS, rodando uma VM no VirtualBox, é este:

Estamos no caminho certo… Note que nem todos os dispositivos fornecem um endereço de acesso (pelo menos, não bo BAR0), mas o Display e Multimedia fornecem endereços base de I/O mapeados em memória e o dispositivo da classe Base System Peripheral fornece um endereço no espaço de I/O (porta).

Para determinar o que são, realmente, os dispositivos, existem outros campos no espaço de configuração que devem ser levados em conta… Além da classe temos a subclasse, que nos diz, por exemplo, se o dispositivo 2:0 é compatível com VGA ou não, ou se o dispositivo 5:0 é uma placa de áudio ou outra coisa “multimídia” qualquer…

O campo header type nos diz, também, a estrutura dos registros que o seguem. O bit 7, por exemplo, indica se estamos lidando com um dispositivo com múltiplas funções…

Se você está lidando com um dispositivo (que não seja uma ponte), o DWORD final, no offset 0x3C da estrutura do configuration space, lhe dará informações sobre interrupções.

Neste ponto, sabendo o endereço base, e algumas características do dispositivo, fica a cargo do próprio dispositivo qualquer tipo de protocolo para seu uso. Por exemplo, mesmo que você possa obter o BAR da sua placa nVidia, sem saber como lidar com ela não vai te ajudar muito… O que quero dizer é que, do ponto de vista do software a especificação PCI só lhe deixará chegar até o ponto onde você obterá configurações do dispositivo.

Aviso sobre o configuration space

Apenas os campos do offset 0 até o offset 15 (os primeiros 4 DWORDs) são comuns a todos os dispositivos PCI. Os demais campos podem ser diferentes. Os dispositivos do PIIX3, por exemplo, costumam deixar disponíveis o BAR em outro lugar que não no offset 16…

Você pode esperar padronização em dispositivos como PCI-to-ISA bridge e PCI-to-PCI bridge, mas todo o resto depende do que obtiver o VendorID, Class, Subclass e Header Type, bem como outros campos…

Configuration space e PCI Express

A especificação PCI Express estende um bocado o configuration space e não exige que o espaço de endereços de I/O (portas 0xCF8 e 0xCFC) sejam usados para acessá-lo. No PCIe, o configuration space é mapeado na memória e, cada função de um device tem um espaço de 1 página (4 KiB) e o acesso é feito de acordo com o endereço base do configuration space adicionado a sequência de bits que monta o barramento, device, function, deslocados 12 bits para a esquerda:

PhysAddr=BaseAddr+((bus\,shl\,20)\,or\,(dev\,shl\,15)\,or\,(func\,shl\,12))

Os 12 bits inferiores são o registrador no configuration space. Resta entender como obter o ponteiro para a região da memória usada como configuration space e isso é feito via ACPI (que está fora do escopo deste artigo).

Essencialmente, PCIe é a mesma coisa que PCI, mas com um método de obtenção das configurações diferente… Felizmente a especificação PCIe nos diz que o método antigo (portas 0xCF8 e 0xCFC) têm que continuar valendo…

MyToyOS: O controlador de teclado (e mouse)

Já falei sobre o PIC, o PIT e o DMAC aqui. Eis o controlador “de teclado”, KBDC (KeyBoarD Controller), cujo nome engana um bocado. O chip 8042 (legado) é, na verdade, um microcontrolador que provê um protocolo específico para troca de dados entre o processador e dispositivos “seriais”. Ele lida, no PC, principalmente com o teclado e com o mouse. Inesperadamente, os dados de ambos os dispositivos trafegam via as mesmas portas de I/O. Ou seja, do ponto de vista do software, teclado e mouse são, ambos, teclados com padrões de stream de dados diferentes. Veremos como isso funciona adiante…

As portas para acesso ao KBDC primário (podem existir 2) são 0x64 e 0x60, onde a porta 0x64 é alocada para obtenção de status (se lida) ou para enviar comandos (se escrita) para o 8042. A porta 0x60 é a porta de dados que deve ser lida ou escrita somente de acordo com o estado de alguns flags contidos na porta de status indicarem essa possibilidade. A porta de status tem o seguinte bitmap:

Bit Mnemônico Descrição
0 OBF Output Buffer Full
1 IBF Input Buffer Full
2 SYS (usada internamente)
3 A2 (usada internamente)
4 INH “Teclado” habilitado
5 TxTO Transmmit TIMEOUT
6 RxTO Receive TIMEOUT
7 PERR Erro de Paridade

Estamos interessados nos bits OBF e IBF apenas. Eles indicam se os “buffers” (registradores) de entrada ou saída contém ou não dados… Lembre-se que o KBDC lida com dispositivos seriais, então, ao escrevermos um comando para o KBDC, este tem que ser repassado para o dispositivo de forma serializada. Isso pode levar um tempo, no qual o “buffer” de “entrada” estará cheio e não pode receber novos comandos. Isso é indicado no bit IBF. A mesma coisa acontece quando o KBDC recebe um byte, quando ele for completamente recebido e colocado no buffer de “saída”, isso é indicado no bit OBF.

Note que “entrada” e “saída”, aqui, são consideradas do ponto de vista do controlador. Ao receber a notificação do teclado que existe uma tecla para ser lida, o KBDC receberá esse scan code e o colocará na “saída” (output) para a futura leitura do processador… Ao receber um comando vindo do processador, este é direcionado para o dispositivo, via KBDC, através de seu registrador (buffer) de “entrada”.

O termo “buffer” aqu nada tem a ver com o “buffer do teclado”, que suporta o enfileiramento de até 16 teclas…

O código abaixo testa OBF antes de tentar ler um scan code vindo do teclado…

wait_kbdc_ob_full:
  in   al,0x64
  test al,1              ; Testa OBF.
  jz   wait_kbdc_ob_full ; OBF=0? fica no loop.

  ; Aqui OBF será 1. Estamos prontos para ler um
  ; dado da porta 0x60.
  ret

Da mesma forma, para escrevermos um comando na porta 0x64 ou um dado na porta 0x60, temos que ter certeza que o “input” buffer esteja vazio verificando se IBF=0!

Teclado ou mouse?

O KBDC não é dedicado ao teclado. Tanto o mouse quanto o teclado são lidos no mesmo par de portas (0x60 e 0x64)…

O KBDC pode controlar até 2 dispositivos seriais e, também, gerar IRQs para anunciar a disponibilidade de dados deles… O método que mostrei anteriormente, fazendo polling do bit OBF, não é o ideal… Nos PCs, o primeiro dispositivo (teclado) está associado à IRQ1, e o segundo (mouse), à IRQ12… No caso do mouse, geralmente a IRQ12 é implementada pelo driver (no caso do MS-DOS, MOUSE.SYS) ou pelo sistema operacional e, simplesmente, lê o stream de dados que é composto das coordenadas X e Y e o estado dos 3 botões (pressionados ou não):

bit: 7 6 5 4 3 2 1 0
byte 1: Y overflow X overflow Y sign X sign 1 middle button right button left button
byte 2: X
byte 3: Y

X e Y são sinalizados. Ou seja, o movimento pode ser feito num sentido ou em outro, nos eixos correspondentes, mas note que o valor informado é o delta em relação à última leitura. Os bits de overflow estão ai para quando você desloca o mouse muito rápido (tipo, ficou com raiva de um bug no seu código e jogou o mouse na parede!).

O fato é que, se o mouse está associado à IRQ12, essa IRQ lerá os 3 bytes, limpando o buffer de saída do KBDC. Assim, ao ler a porta 0x60, fora da IRQ12, estaremos lidando garantidamente com o teclado, não com o mouse. Por default, o mouse está desabilitado e a BIOS tende a não configurá-lo.

Hoje em dia os mouses têm uma “roda” (mouse wheel) que é informada num 4º byte contendo a coordenada Z. Na inicialização do mouse deve-se determinar se ele informa essa 3ª coordenada… Isso é feito por algum comando enviado ao KBDC (veja, mais adiante, a explicação).

Os dados vindos do teclado

Outra vez, diferente da intuição, os dados vindos do teclado não correspondem ao código ASCII das teclas pressionadas, mas a um código da própria tecla… Afinal de contas, o “significado” da tecla nada mais é do que o silk screen que foi pintado em cima dela…

O teclado envia ao KBDC o que chamamos de scan codes. Num teclado ENG-US os scan codes são como mostrados no gráfico abaixo:

E, para as demais teclas:

Isso quer dizer que, se recebermos 0x70 na porta 0x60, então o usuário pressionou a tecla ‘0’ do teclado numérico… Se recebermos 0x45, ele pressionou a tecla ‘0’ do teclado “alfanumérico”… O bit 7 desse código nos diz se a tecla foi pressionada ou solta… Se recebermos 0xF0, o ‘0’ do teclado numérico foi liberado.

Repare que não existe um código 0xE0 ou 0xE1 isolados. Assim, se recebermos 0xE0 ou 0xE1, existirão mais 1 ou 2 bytes no scan code (os 4 bits inferiores de 0xE?) nos dizem quantos, basta adicionar 1)… As setas, por exemplo, têm scan codes de 2 bytes, começados por 0xE0… Note que o byte seguinte é o mesmo que gosta no teclado numérico (dê uma olhada no silk screen do seu teclado)… O mesmo aconte com PgUp, PgDn, Ins, Del, Home e End.

Outro detalhe importante… para demonstrar que scan codes nada têm a ver com os códigos ASCII, basta notar que o teclado não diferencia entre um ‘?’ e um ‘/’ ou um ‘A’ e um ‘a’. A tecla ‘A’ tem scan code 0x1C para ambos os casos. Quem faz a diferenciação é a BIOS ou o sistema operacional… A mesma coisa acontece com as teclas “especiais” como Alt, Shift e Ctrl (e a tecla “Super” — aquela com o logo do Windows — cujo scan code provavelmente é 0xE0,0x5B para o LWin e 0xE0,0x5C para o RWin). Note, também, que teclas adicionais terão scan codes próprios e, provavelmente, precedidos de 0xE0 ou 0xE1.

Além dos scan codes, o teclado pode enviar bytes de acknowledgement (0xFA) ou non-acknowledgement (ou “resend”, 0xFE) e códigos de erro (0x00 ou 0xFF), mas isso depois do envio de um comando, via porta 0x64. Por exemplo, se você enviar 0xED, seguido de um byte onde os bits de 0 a 2 correspondem a um dos leds (Scroll Lock, Num Lock ou Caps Lock, respectivamente), o teclado te responderá, na porta 0x60 com um ACK (0xFA).

Existem 3 conjuntos de scan codes diferentes!

Dependendo de como o teclado for configurado, os scan codes obtidos podem não ser os que mostrei acima… Esse ai é o conjunto 2 (PC-AT keyboard). Teclados suportam os conjuntos 1 (PC-XT keyboard) e 3. Para selecionar o conjunto basta enviar o comando 0xF0 e escrever 0x01, 0x02 ou 0x03 na porta de dados. Se escrever 0x00, podemos ler o conjunto atualmente selecionado (provavelmente 1). Não se esqueça de esperar pelo ACK.

Além do teclado e mouse

Como ficou claro, podemos enviar comandos para o KBDC para que ele faça coisas além de ler scan codes ou posições do mouse. Basta escrever em 0x64 o comando desejado (desde que o IBF esteja vazio) e obedecer a semântica do comando para ler/escrever na porta 0x60, de acordo com o necessário. Por exemplo, podemos acender ou apagar os LEDs de caps lock, scroll lock e num lock via comando 0xED, bastando enviar um valor de 3 bits (scroll lock no bit 0, num lock no bit 1 e caps lock no bit 2; os demais bits zerados) para a porta 0x60. Devemos depois esperar por um byte na mesma porta 0x60 que deve ser 0xFA (ACK) ou 0xFE (Resend).

Existem outros comandos que não exigem esse tipo de handshake… Por exemplo, o KBDC pode ser usado para habilitar o Gate A20, assim:

kbdc_enable_a20:
  call  wait_kbdc_ib_empty
  mov   al,0xad       ; Desabilita teclado.
  out   0x64,al

  call  wait_kbdc_ib_empty
  mov   al,0xd0       ; Lê "output port A".
  out   0x64,al

  call  wait_kbdc_ob_full
  in    al,0x60       ; Pega o dado da "output port A".
  mov   cl,al         ; Guarda.

  call  wait_kbdc_ib_empty
  mov   al,0xd1       ; Comando: escreve próximo na
  out   0x64,al       ;    byte na "output port A".

  call  wait_kbdc_ib_empty
  mov   al,cl         ; Recupera "output port A" lida
  or    al,2          ; antes e seta o bit "Gate A20".
  out   0x60,al

  call  wait_kbdc_ib_empty
  mov   al,0xae       ; Habilita teclado.
  out   0x64,al

  ; Espera até que o comando seja terminado...
  call  wait_kbdc_ib_empty
  ret

Existem mais comandos disponíveis… Obviamente alguns são para habilitar IRQs e configurar teclado e mouse… Uma referência dos comandos pode ser encontrada aqui. Neste mesmo artigo do OSDev você poderá observar como fazer a “detecção” do dispositivo ligado ao KBDC, como o mouse com mouse wheel ou até mouses com 5 botões!

MyToyOS: Usando a lib gnu-efi

Podemos, é claro, codificar um bootloader EFI manualmente, mas também podemos usar a libgnuefi e aproveitarmos todos os recursos que o padrão e a BIOS podem nos oferecer. Para tanto, basta instalar:

$ sudo apt-get install gnu-efi

Eis um “hello, world”:

#include <efi/efi.h>
#include <efi/efilib.h>

EFI_STATUS EFIAPI efi_main(
    EFI_HANDLE ImageHandle,
    EFI_SYSTEM_TABLE *SystemTable)
{
  InitializeLib(ImageHandle, SystemTable);
  Print(L"Hello, world!\n");
  return EFI_SUCCESS;
}

O detalhe é como compilar e montar o arquivo UEFI. Eis um Makefile:

#
# HELLO.EFI makefile
#

hello.efi: hello.so
    objcopy -j .text \
      -j .sdata \
      -j .data \
      -j .dynamic \
      -j .dynsym \
      -j .rel \
      -j .rela \
      -j .reloc \
      --target=efi-app-x86_64 \
      $^ $@

hello.so: hello.o
    ld $^ \
      /usr/lib/crt0-efi-x86_64.o \
      -nostdlib \
      -znocombreloc \
      -T /usr/lib/elf_x86_64_efi.lds \
      -shared \
      -Bsymbolic \
      -L /usr/lib \
      -l:libgnuefi.a \
      -l:libefi.a \
      -o $@

hello.o: hello.c
    gcc -c \
      -fno-stack-protector \
      -fpic \
      -fshort-wchar \
      -mno-red-zone \
      -I /usr/include/efi \
      -I /usr/include/efi/x86_64 \
      -DEFI_FUNCTION_WRAPPER \
      -o $@ $<

Eis a explicação para os comandos. O compilador, obviamente, irá criar um objeto que não contém referências à libc (opção -nostdlib do linker) e código independente de posição (opção -fpic, do compilador: Position Independent Code, ou PIC). É necessário informar o diretório dos headers da gnu-efi… Logo depois criamos um shared object “linkando” o objeto com as libs estáticas libgnuefi.a e libefi.a, também com o objeto de inicialização crt0-efi-x86_64.o. A opção -Bsymbolic é importante para manter todos símbolos auto contidos no shared object e eu não estou certo que as relocações não possam ser combinadas para diminuir as tabelas nas sections .rel, .rela e .reloc, mas deixei como está (de acordo com o Makefile de exemplo da libgnuefi) usando a opção -nocombreloc, no linker. Note também que esse executável não é para o Linux e não segue a SysV ABI para x86-64 (pelo menos não para as chamadas às funções da UEFI), por isso a red zone foi desabilitada para o modo x86-64 via -mno-red-zone… As funções da UEFI esperam o uso de char com 16 bits de tamanho (não 8!). Ao invés de usar o tipo char16_t (que está especificado na ISO 9989) preferi usar a opção -fshort-char. E, no compilador, como passo final para tornar o executável o mais curto possível (e independente de OS), desabilitei o stack protector.

Note que existe uma section estranha na chamada ao objcopy chamada .sdata. Ela é colocada lá pelo script do linker, usado via opção -T. O utilitário objcopy é usado aqui para copiar as sessões dela para o novo arquivo EFI e para criar o PE+ (via opção --target, já que o GCC e o Linker criação arquivos ELF. As opções -j dizem quais sessões devem ser copiadas (se existirem) e --target informa o formato do arquivo de saída: efi-app-x86_64 para aplicações EFI, efi-bsd-x86_64 para “bootstrap driver” (ou bootloader) e efi-rtd-x86_64 para “runtime driver”.

Pode ser uma boa idéia acrescentar as opções -ffreestanding e -nostdlib na linha de comando do GCC.

Criando uma partição GPT de teste

Para criarmos um disco “virtual” de 100 MiB (195312 setores) com uma partição GPT e colocarmos o programinha acima na partição EFI, podemos fazer:

# dd if=/dev/zero of=disk.img bs=512 count=195312
# gdisk disk.img
GPT fdisk (gdisk) version 1.0.1

Partition table scan:
  MBR: not present
  BSD: not present
  APM: not present
  GPT: not present

Creating new GPT entries.

Command (? for help): o
This option deletes all partitions and creates a new protective MBR.
Proceed? (Y/N): Y

Command (? for help): n
Partition number (1-128, default 1): 1
First sector (34-63966, default = 2048) or {+-}size{KMGTP}: 
Last sector (2048-63966, default = 195279) or {+-}size{KMGTP}: 
Current type is 'Linux filesystem'
Hex code or GUID (L to show codes, Enter = 8300): ef00
Changed type of partition to 'EFI System'

Command (? for help): w

Final checks complete. About to write GPT data. THIS WILL OVERWRITE EXISTING
PARTITIONS!!

Do you want to proceed? (Y/N): Y
OK; writing new GUID partition table (GPT) to disk.img.
Warning: The kernel is still using the old partition table.
The new table will be used at the next reboot or after you
run partprobe(8) or kpartx(8)
The operation has completed successfully.

# losetup --offset 1048576 --sizelimit 98934272 /dev/loop0 disk.img
# mkdosfs -F32 /dev/loop0
mkfs.fat 3.0.28 (2015-05-16)
Loop device does not match a floppy size, using default hd params
# mount /dev/loop0 /mnt
# cp hello.efi /mnt/
# umount /dev/loop0
# losetup -d /dev/loop0

Os valores passados para losetup merecem uma explicação. Como criamos a partição EFI no setor 2048 (como sugerido pelo gdisk), o offset inicial é 2048\cdot512. O tamanho dessa partição é de (195279-2048)\cdot512=98934272 bytes, onde 195279 é o último setor da partição. Usar losetup permite mapear um pedaço de um arquivo num dispositivo de loopback e ai podemos formatá-lo e montá-lo…

Se você pretende criar uma imagem de um disco mais “realista”, coloque uma partição pequena para EFI (FAT32) — digamos, uns 32 MiB — e outra partição vazia que será formatada depois (com o sistema de arquivos do MyToyOS).

Testando o novo “HD” no QEMU

Temos que, antes, instalar um pacote chamado ovmf. Ele contém uma BIOS que suporta UEFI 2.6 e, para iniciar a emulação basta fazer:

$ qemu-system-x86_64 -bios /usr/share/ovmf/OVMF.fd \
  -drive file=disk.img,if=ide

Repare que não temos um bootstrap, mas uma aplicação EFI. Podemos executá-la do shell, bastando selecionar o drive FS0: e digitar hello.efi:

efiapp
Grab da tela do qemu-system-x86_64

Vantagens e desvantagens:

As óbvias vantagens são as de que este programinha executa no modo protegido x86-64! A lib gnu-efi toma conta disso pra você… A outra é que todas as funções padronizadas EFI estão a sua disposição…

A desvantagem, também óbvia, é que o executável fica grande. Note que para imprimir uma simples string de 14 caracteres (incluindo o salto de linha final) criou um arquivão de quase 45 KiB!

O bootloader:

Para fazer um bootloader você deve nomear seu arquivo como BOOTIA32.EFI (para i386), BOOTx64.EFI (para x86-64), BOOTARM.EFI ou BOOTAA64.EFI (para ARM AArch32 e AArch64, respectivamente) e colocá-lo no diretório /EFI/BOOT/ da partição.

Toda a referência do UEFI pode ser obtida da especificação.

MyToyOS: UEFI, a assombração reaparece!

Quanto mais eu penso que me livrei dos padrões da Microsoft, mais eles me perseguem…

Recentemente, no grupo “C & Assembly para x86-64”, no Facebook, eu disse que migraria o esboço do bootloader do MyToyOS para o padrão UEFI. O motivo é simples: O particionamento tradicional não permite discos muito grandes e UEFI tem a vantagem de não precisamos lidar com vários estágios de carga do bootloader.

Como UEFI funciona?

Nada mais simples: Trata-se de um simples arquivo executável (.EXE, mas com a extensão .EFI) no formato Portable Executable da Microsoft! Eis a assombração! O formato é esse:

Teremos que lidar com essa "engronha"...
Teremos que lidar com essa “engronha”…

O significado de cada campo pode ser obtido na especificação do padrão UEFI (aqui) e na especificação do formato PE (aqui).

Mas é interessante notar: O formato usado no UEFI é simplificado com relação a esse ai. Algumas sessões não existem nos arquivo .efi e a diferenciação sobre o que o executável faz é determinada pelo subsistema… Existem, pelo menos, 3 tipos de executáveis: Aplicações, bootloaders e firmware drivers.

Em essência, tudo o que temos que fazer é um arquivo em assembly contendo o header do formato cima, preenchido, e todas as demais rotinas podem ser feitas com o GCC (4.9 ou superior, porque eles permitem geração de código em 16 bits!).

Sim… o código é em 16 bits, como um executável para MS-DOS…

O que a ROM BIOS de sistemas que suportam UEFI faz é obter o arquivo da partição EFI e executá-lo… O arquivo pode ter, em teoria, até 2 GiB de tamanho. Na prática, é prudente mantê-lo com um tamanho inferior a 128 KiB (dois segmentos – não sei onde esse arquivo é carregado!), mas isso não é um limite rígido… A vantagem, é claro, é que não estamos mais restritos a um único setor de boot carregado pela BIOS! De fato, o código do MBR (Master Boot Record) desaparece completamente. Em uma de minhas máquinas de teste, onde tenho somente o UBUNTU 16.04 instalado. Obtendo o setor da MBR (LBA 0):

# dd if=/dev/sda of=disk.img bs=512 count=1

Obtenho isso:

part
Cadê a MBR? Não existe! Ou seja, temos apenas uma única entrada da tabela de partição tradicional (a partir do offset 0x1be, usada pela especificação da GUID Partition Table) e, mesmo assim, trata-se de uma entrada desabilitada, pois começa com 0x00, ao invés de 0x80. Provavelmente essa entrada aponta para a partição EFI.

A partição EFI, por sua vez, é uma partição FAT32:

# fdisk -l /dev/sda
Disk /dev/sda: 465,8 GiB, 500107862016 bytes, 976773168 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 4096 bytes
I/O size (minimum/optimal): 4096 bytes / 4096 bytes
Disklabel type: gpt
Disk identifier: 55DC090F-5E38-4521-A4E6-7B669BBB9ED6

Device         Start       End   Sectors   Size Type
/dev/sda1       2048   1050623   1048576   512M EFI System
/dev/sda2    1050624 960253951 959203328 457,4G Linux filesystem
/dev/sda3  960253952 976771071  16517120   7,9G Linux swap

# fdisk -l /dev/sda1
Disk /dev/sda1: 512 MiB, 536870912 bytes, 1048576 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 4096 bytes
I/O size (minimum/optimal): 4096 bytes / 4096 bytes
Disklabel type: dos
Disk identifier: 0x00000000

O que tem dentro dessa partição? Os arquivos executáveis e, eventualmente, configurações:

# df /dev/sda1
Filesystem     1K-blocks  Used Available Use% Mounted on
/dev/sda1         523248  3684    519564   1% /boot/efi

# tree /boot/efi/
/boot/efi/
└── EFI
    └── ubuntu
        ├── fw/
        ├── fwupx64.efi
        ├── grub.cfg
        ├── grubx64.efi
        ├── MokManager.efi
        └── shimx64.efi

O grub colocou esses arquivos todos ai… Cada um deles, se estiver no formato correto, é executado pela BIOS quanto o sistema inicia. Simples assim. No caso, grubx64.efi lê o arquivo grub.cfg para “bootar” o sistema na partição correta. O executável fwupx64.efi, provavelmente, é usado pelo grub para o caso de termos firmwares fora do padrão, onde drivers são colocados no diretório fw/. Não me pergunte o que seria o MokManager e o shimx64… pergunte à documentação do grub 2.

Por que a especificação é tão grande?

O documento da especificação UEFI mostra um conjunto de facilidades disponíveis quando você usa o UEFI SDK (Software Development Kit). Por exemplo, o executável pode pedir para a BIOS iniciar o modo protegido pra você, tanto em 32 bits quanto em 64… Mas, prepare-se para adotar um esquema bem diferente de convenções de chamadas de funções e obter um executável gigantesco no processo…

Não recomendo o uso do SDK por esse motivo e por outro mais prático: Uma das reclamações da adoção do padrão EFI é justamente o lobby que empresas como a Microsoft têm feito para criar bootloaders “seguros” que exijam o uso de criptografia. Na prática, eles querem controlar quem tenta instalar seus sistemas (somente comprando uma chave vocẽ poderia “bootar” o Windows, por exemplo)… Isso só é possível ao usar o UEFI SDK.

E quanto ao MyToyOS?

Bem… sem a limitação do tamanho dos estágios 0 e 1 podemos criar um bootloader mais complexo, até mesmo em modo gráfico (320×200 de 256 cores, VGA, por exemplo, ou usando um dos modos VESA). Além do header MZ e PE, o código pode ser quase totalmente feito em C e terá que achar onde se encontra a partição na GPT, pular para o modo protegido, carregar o kernel na memória alta e pular para ele… Vale a pena dar uma olhada no código de boot do Linux (no diretório arch/x86/boot/ dos fontes mais recentes). O arquivo header.S é o início de tudo…

Em teoria, tudo o que temos que fazer é colocar o executável num diretório EFI/mytoyos/ na partição EFI… Bem como criar a partição com o kernel… Dá até vontade de fazer uma brincadeira como o logo do AmigaDOS:

amigados

Hehehe…

Softwares de virtualização aceitam EFI?

Aceitam… No VirtualBox basta habilitar a opção:

vbox

No caso do QEMU, provavelmente você terá que instalar o pacote ovmf.

Ao que parece, no VMWare, existe um tweaker que habilita EFI também (não tenho certeza, não uso VMWare!).

MyToyOS: Acessando o disco sem usar a BIOS!

Uma das coisas mais simples (e tem um monte de exemplos por ai) para fazer é o código do MBR (Master Boot Record). Geralmente, alguém faz um pequeno código que mostra uma string na tela e entra em modo halt, assim:

;=======================
; BOOT.ASM
;=======================
bits 16
org 0
  ; Geralmente queremos um jump aqui para acomodar
  ; variáveis logo abaixo. Por exemplo, a string que
  ; mostraremos!
  jmp 0x7c0:_start

;------------
; Vars & Consts
;------------
msg:  db  "Hello!",13,10,0

;------------
; Início do código.
;------------
_start:
  cld             ; Certifica-se que instruções que
                  ; lidam com strings incrementarão
                  ; os ponteiros.

  mov   ax,cs     ; Ajusta DS e SS
  mov   ds,ax
  cli             ; Desabilita maskable ints antes de
                  ; ajustar a pilha.
  mov   ax,0x9000 ; Aponta para o final da RAM baixa.
  mov   ss,ax
  mov   sp,0xfffc
  sti
  
  call  setup_screen

  mov   si,msg    ; Mostra a string...
  call  puts

.halt_loop:
  hlt              ; Coloca o processador em estado de
  jmp   .halt_loop ; halt. Se houver uma int, coloca de
                   ; novo...

;------------
; Rotina que ajusta o modo de vídeo.
;------------
setup_screen:
  mov   ax,2
  int   0x10
  ret

;------------
; Rotina que imprime string na tela
; usando serviço de TTY da BIOS.
; Entrada: DS:SI = ptr.
;------------
puts:
  lodsb
  test  al,al
  jz    .puts_exit
  mov   ah,0x0e
  int   0x10
  jmp   puts
.puts_exit:
  ret

;============
; Assinatura EXIGIDA no final do setor
;============
  times 510-($-$$) db 0
  db    0x55,0xaa

Adivinha só o que acontecerá se você compilar e executar isso num ambiente virtualizado!

$ nasm -f bin -o boot.bin boot.asm
$ dd if=/dev/zero of=disk.img bs=1024 count=720
$ dd if=boot.bin of=disk.img conv=notrunc
$ qemu-system-i386 disk.img
Surpresa!
Surpresa!

Aqui cabe uma pequena explicação: Com o comando dd estou emulando um disco de 720 KiB. Não é preciso! Eu poderia, muito bem, executar o setor de boot diretamente no qemu assim: qemu-system-i386 boot.bin. O primeiro dd cria um disco vazio. O segundo, copia o setor de boot para o seu lugar… Prefiro fazer isso porque, no futuro, os dois estágios iniciais de boot podem ser copiados independentemente para seus devidos lugares na mesma imagem, usando o dd.

O programinha acima não é nada surpreendente… Mas isto não é um bootloader porque não carrega coisa alguma. No caso de um setor de boot real, provavelmente você terá que fazer uso do serviço de I/O de disco da BIOS para ler os demais setores do disco. Neste ponto, também não há nada supreendente, mas devemos lembrar que o nosso OS, eventualmente, mudará o modo de operação do processador para protegido, onde as rotinas da BIOS são inúteis. Ficamos, então, com um grande problema: Como ler/escrever setores sem usar a BIOS?

Modos de endereçamento de setores:

A BIOS usa um esquema geométrico para localizar um setor específico no disco. Três coordenadas são fornecidas: Cilindro, Cabeça e Setor. Esse esquema é chamado de CHS (‘H’ de Head) e, na INT 0x13, AH=2 essas coordenadas são passadas nos registradores DH e CX, assim:

Serviço AH=2 (Read Disk Sector)
Entrada: DL:    : disco (bit 7 setado se HD).
         AL     : número de setores para ler.
         ES:BX  : buffer onde os setores serão colocados.
         CH     : lsb do cilindro.
         CL[7:6]: 2 bits do msb do cilindro.
         CL[5:0]: setor inicial.
         DH:    : cabeça (head, de 0 a 15, no máximo!).

Saída:   CF=0 (sucesso).
         CF=1 (erro), AH=código de erro,
                      AL=setores transferidos.

Note a limitação de 10 bits para o número do cilindros e de 6 bits para o setor inicial. A controladora também limita o número de cabeças em 16. Acontece que o setor inicial de cada cilindro não começa em 0 (zero), mas em 1. Assim, a BIOS limita o número total de stores endereçáveis em 1024*63*16, ou seja, 1032192 setores (ou 504 MiB). Vale lembrar que esse serviço foi concebido numa época em que HDs de 40 MB eram enormes!

Felizmente a BIOS nos fornece serviços que permitem ultrapassar essas limitações. É o caso do serviço 0x42:

Serviço AH=0x42 (Extended Read Disk Sector)
Entrada: DL     : disco (bit 7 setado se HD).
         DS:SI  : Ponteiro para a DAP (Disk Address Packet).

Saída:   CF=0 (sucesso).
         CF=1 (erro), AH=código de erro,

Tabela DAP:
         Offset  Tamanho  Descrição
      ---------  -------  -----------------
              0    WORD   Tamanho da DAP (sempre 16).
              2    WORD   número de setores a ler.
              4    DWORD  segmento:offset do buffer.
              8    QWORD  endereço LBA.

Esse serviço tem a vantagem de usar o método de endereçamento chamado de LBA (Logical Block Addressing). Por causa das limitações do modelo CHS o modo LBA foi criado para resolver dois problemas:

  • Precisamos de mais setores que o modelo CHS pode fornecer;
  • Podemos, finalmente, nos livrar da noção de “geometria” do disco.

O LBA enxerga o disco como um grande array de setores começando no setor 0 (zero)! E, como existem dois sabores de LBA (de 28 e 48 bits, chamados respectivamente de LBA28 e LBA48), podemos endereçar até 2^{28} setores, ou seja, 128 GiB usando LBA28, chegando até ao máximo de 2^{48} setores ou 128 PiB, usando LBA48! Não é difícil conceber que a maioria das implementações de hardware atuais suporta LBA48.

O endereçamento LBA é, na verdade, uma maneira linear de agrupar os parâmetros CHS com base nos valores máximos suportados pelo disco (não é uma relação de campos binários simplesmente!). Com base nos parâmetros CHS e nos máximos, podemos calcular o LBA com a seguinte equação:

\displaystyle A_{lba}(C,H,S)=(C\cdot n_{heads}+H)\cdot n_{sectors}+(S-1)

Pelo fato do serviço 0x42 da BIOS suportar um LBA de 64 bits na tabela DAP, devo supor que a rotina seja inteligente o suficiente para usar LBA28 ou LBA48 de acordo com os bits contidos no endereço… LBA28 é mais simples de lidar e um cadinho mais rápido.

Se você precisar lidar com CHS e tem um endereço LBA, as equações também são simples:

\displaystyle \left\{\begin{matrix}  C=\frac{A_{lba}}{n_{heads}\cdot n_{sectors}}\\  \\  H=(\frac{A_{lba}}{n_{sectors}})\mod\,n_{heads}\\  \\  S=(A_{lba}\mod\,n_{sectors})+1  \end{matrix}\right.

É claro, os valores A_{lba}, n_{sectors} e n_{heads} devem estar dentro das faixas permissíveis pelo modelo CHS, citado acima.

Mas, não estamos interessados na BIOS, estamos?

Os serviços da BIOS são simples atalhos para o padrão chamado ATA. HDs que não sejam SCSI, seguem o padrão ATA (de AT Attachment) e ele dita as regras de características elétricas e de acesso via registradores, uso de interrupções e DMA. A documentação oficial do padrão ATA-6 pode ser obtido aqui. Existem documentos mais recentes, mas a maioria dos dispositivos atuais segue este padrão. Para mais documentos, você pode acessar o comitê T13 do ANSI (aqui).

Outra documentação interessante é as do Intel I/O Controller Hub (ICH) e do PIIX 3. Lá vocẽ verá que as portas de I/O para duas controladoras de HD estão mapeadas diretamente ao PCI Local Bus. Cada controladora suporta dois discos (um mestre e um escravo, no caso dos IDEs – lembra do strap chamado de Cable Select, na traseira do disco?). A primeira controladora é acessível através das portas de I/O no intervalo de 0x1f0 até 0x1f7. A segunda, de 0x170 até 0x177. Ambos os blocos são idênticos, exceto por serem de controladoras diferentes.

Os chipsets modernos aceitam até 6 HDs SATA (pode ver o manual da sua placa mãe!). Isso significa que eles possuem 3 controladoras de HD (SATA) e, cada uma suporta dois HDs. Mas, como acessar a terceira controladora? Infelizmente ela não está mapeada em I/O do mesmo jeito que as duas primeiras. Para usá-la você precisará obter os endereços de I/O mapeados em memória para o dispositivo, através do espaço de configuração para o device correspondente, no PCI Local Bus. Abaixo, mostro os 8 registradores dessas controladoras:

Offset Registrador
0  Dados
1  Erro
2  Contador de setores
3  S ou LBA[7:0]
4  C[7:0] ou LBA [15:8]
5  C[15:0] ou LBA [23:16]
6 drive[4]/H[3:0] ou drive[4]/LBA[27:24]
bit 6=0, use CHS; bit 6=1, LBA28.
7 Status (Read)/Comando (Write)

Note que podemos tanto usar o esquema de endereçamento CHS quanto o LBA28. A correspondência de CHS e LBA28, como pode ficar parecendo acima, é puramente incidental. Lembre-se que o cálculo do valor LBA depende dos limites impostos pelo dispositivo.

Para ler/escrever um ou mais setores no disco, tudo o que temos que fazer é ajustar os registradores nos offsets de 2 até 6 e escrever um comando no registrador no offset 7. Feito isso, se nao estivermos lidando com DMA e IRQs, podemos verificar o estado do HD até que ele não esteja mais “ocupado” e, se houver um erro, verificar o registrador no offset 1, caso contrário, ler/escrever os dados no registrador 0.

É simples assim… você ajusta parâmetros e envia um comando. Basta apenas seguir o protocolo do comando…

E se eu tiver um disco com mais de 128 GiB?

LBA48 parece não ser possível com a distribuição dos registradores acima. No entanto, a especificação ATA-6 nos diz que basta escrevermos duas vezes em cada um dos registradores do offset 2 até 6 para informarmos o LBA48… Note que do offset 3 até 5 temos 24 bits dos 28. Ao escrever duas vezes temos 48! A primeira escrita fornece os bits superiores em cada faixa. Na segunda escrita, os 24 bits inferiores, na ordem abaixo:

Offset 2ª escrita 1ª escrita
2 Setores [7:0] Setores [15:8]
3 LBA[7:0] LBA[31:24]
4 LBA [15:8] LBA [39:32]
5 LBA [23:16] LBA [47:40]
6 Bit 6=1 (LBA)
drive[4]
não escreve

Isso funciona com comandos de leitura/escrita extendidos.

Todos os registradores são de 8 bits, exceto o de dados

O registrador no offset 0 é sempre de 16 bits. Podemos ler/escrever 8 bits lá, mas os 8 bits superiores serão zerados. Da mesma forma, podemos ler/escrever 32 bits, mas duas escritas/leituras de 16 bits serão feitas.

Soft reset

Algumas vezes a controladora entra num estado “indefinido” ou “errado”. Para corrigir isso, ou, de outra forma, para ter um ponto de partida estável, é prudente resetá-la! No entanto, existem dois tipos de reset. Através de um comando você pode realizar um DEVICE RESET, que é mais drástico! Outra maneira é efetuar um soft reset na controladora inteira.

Isso só pode ser feito pela porta do Device Controller Register (0x3f6 para as controladoras ligadas no barramento principal do seu PC). Basta ligar o bit 2 e a controladora é colocada num estado inicial estável… E é sempre bom fazer isso, se formos lidar com transferências de dados via PIO.

Exemplo de leitura de N setores, usando LBA28, usando PIO

PIO é o modo de acesso ao dispositivo que realiza leituras/escritas sem o recurso de DMA ou IRQ. O processo deve fazer pooling do registro de status para verificar se o a controladora está ocupada ou não, antes de enviar qualquer comando ou ler/escrever dados. O exemplo abaixo lê N setores à partir do setor S do disco. Usarei algo parecido no segundo estágio do bootloader para ler o kernel e colocá-lo na memória alta. Será feito assim porque o estágio 1 (começa em 0, com o MBR) salta para o modo protegido e não poderei usar a BIOS…

; Lê N setores a partir do setor S do disco para
; a memória.
; Entrada: CL=N
;          EBX=S (LBA28)
;          ES:EDI=ptr do buffer.
;          DX=endereço base de I/O da controladora.
; Saída:   CF=0, ok
;          CF=1, erro.
; Atenção!
;   O valor de N não pode ser grande o suficiente para que o cache do
;   HD sofra overflow. É preciso verificar o máximo de setores que
;   podem ser lidos de uma única vez antes de chamar essa rotina.
;
read_lba28:
  ; OBS: Esse "soft reset" não é realmente necessário.
  ;      Está aqui para termos um ponto de partida estável.
  push dx
  mov  dx,0x3f6        ; Device Controller Register
  in   al,dx
  or   al,0x04         ; Seta apenas o bit SRST.
  out  dx,al
  pop  dx

  add  dx,2            ; aponta para sector count.
  mov  al,cl           ; Ajusta contagem de setores.
  out  dx,al
  
  inc  dx              ; Ajusta LBA28
  mov  eax,ebx
  out  dx,al
  shr  eax,8
  inc  dx
  out  dx,al
  shr  eax,8
  inc  dx
  out  dx,al
  inc  dx
  shr  eax,8
  and  al,0x0f         ; bit 4=0 (drive 0) e lba [27:24]
  or   al,0x40         ; seta bit 6 (LBA).
  out  dx,al           

  inc  dx              ; Envia comando READ_SECTORS
  mov  al,0x20
  out  dx,al

  ; Espera até a controladora terminar...
  ; OBS: Aqui temos um macete... A especificação nos
  ;      garante que cada leitura do registrador de
  ;      status toma 100ns. 4 leituras em sequência
  ;      tomarão 400ns, que é um tempo razoável para
  ;      verificar o status depois de enviar um
  ;      comando...
  times 3 in al,dx
.loop:
  in    al,dx
  mov   bl,al
  and   al,0xc0         ; BSY=0 e RDY=1?
  cmp   al,0x40
  jne   .loop

  ; Podemos testar erro aqui (bit 0)...
  test  bl,1
  jz    .continue
  stc                  ; seta CF em caso de erro.
  ret

.continue:
  ; Cada setor tem 512 bytes ou 256 words
  sub   dx,7           ; Aponta para o registro de dados.
  movzx ecx,cl
  shl   ecx,8          ; ECX=256*CL
  cld
  rep   insw           ; Lê 256*N words no buffer.

  clc                  ; zera CF indicando sucesso.
  ret

Obtendo informações sobre um drive

É claro que, antes de fazermos qualquer coisa com um disco, devemos obter informações essenciais sobre ele: Quantos setores ele têm? Ele suporta LBA? Senão suporta, qual é a geometria (CHS)? Felizmente o padrão ATA fornece um comando chamado IDENTIFY_DEVICE (comando 0xec), onde uma série de informações podem ser obtidas. Este comando funciona mais ou menos da mesma forma que a leitura de um setor, como acima, exceto que o endereço de um setor não é fornecido… O comando 0xec faz com que a controladora disponibilize 1 setor inteiro contendo informações úteis… mas, não é um setor real, vindo do disco.

Outro detalhe é que a quantidade de WORDs disponibilizadas pode estar incompleta (podemos ter menos que 256 WORDs). Por exemplo, se o disco estiver em estado de hibernação e certos dados não puderem ser obtidos até que o disco seja “acordado”. Consulte a especificação para ficar atento a esse detalhe. Abaixo um código simples, escrito usando o Borland C++ 3.1 para MS-DOS, onde obtenho esse “setor” de informação:

#include <stdio.h>
#include <dos.h>

int main(void)
{
  FILE *f;
  unsigned int data;

  // Escreve em outro disco!
  if (!(f = fopen("a:\\hdainfo.bin", "wb")))
  {
    fputs("Erro ao escrever 'a:\\hdainfo.bin'.\n", stderr);
    return 1;
  }

  /* Soft Reset */
  outp(0x3f6, inp(0x3f6) | 4);

  outp(0x1f6, 0);     // disk 0.
  outp(0x1f7, 0xec);  // IDENTIFY_DEVICE.

  // Espera por !BUSY && RDY.
  inp(0x1f7);
  inp(0x1f7);
  inp(0x1f7);
  while (((inp(0x1f7) & 0xc0) != 0x40);

  // Lê as 256 words e escreve no arquivo.
  for (i = 0; i < 256; i++)
  {
    data = inpw(0x1f0);
    fwrite(&data, sizeof(unsigned int), 1, f);
  } 

  fclose(f);

  return 0;
}

Abaixo, uma captura de tela do arquivo obtido. Compare esse dump com a estrutura retornada por IDENTIFY_DEVICE, na especificação…

virtualbox_ms-dos-6_06_01_2017_14_58_26

Dando uma olhada na tabela da especificação ATAPI poderíamos ver que este dispositivo suporta a transferência de blocos de até 128 setores, no máximo e que os comandos READ MULTIPLE e WRITE MULTIPLE são suportados. Ainda, que suporta apenas LBA28, já que o número máximo de setores disponíveis é de 4194304 (disco de 2 GB de tamanho). A controladora suporta DMA e IORDY (interrupção). A especificação suportada pelo dispositivo é ATAPI-6, dentre outras informações, como número serial (“BVf9cc8df6c-027dc7”), por exemplo.

Usando DMA e IRQ

Vou deixar isso para outro artigo. No momento, o modo PIO é suficiente para ler/escrever registros durante os estágios do bootloader. Especialmente se lidarmos com LBA…

MyToyOS: Acesso direto à memória

Continuando a série sobre controladores, além das requisições de interrupções alguns dispositivos podem pedir para o processador que este libere os seus barramentos de endereço e dados para que o próprio dispositivo lide com blocos de dados contidos na memória… É o caso de HDs, por exemplo, quando pedimos para que alguns setores sejam lidos o circuito do HD pode colocar esses dados diretamente na memória, sem a interferência do processador…

Da mesma forma que ocorre com as interrupções, o processador tem apenas um sinal de pedido de liberação do barramento (DRQ, de DMA Request, ou HREQ, de Hold [Bus] Request). No entanto, podemos ter n dispositivos que podem pedir que o processador entregue o controle. Como fazer para lidar com todos eles? Novamente entra a figura de um controlador, o DMAC (Direct Memory Access Controller).

Observação: Editei o link abaixo para que você obtenha o datasheet do controlador da Intel, ao invés do da AMD… Aparentemente existe um bug na documentação da versão da AMD (apontada por Axey Gabriel Müller Endres – Thanks, Axey!).

Vou esboçar aqui o Intel 8237A (datasheet pode ser baixado aqui), que é o controlador de DMA legado da arquitetura dos PCs. Esse chip tem sérias limitações porque acompanha os PCs desde sua primeira concepção, na época em que usávamos o processador 8086, de 16 bits (a mesma coisa acontece com o PIC, descrito nos artigos anterior)… Por exemplo: Esse circuito integrado só permite transferências de blocos de dados de, no máximo, 64 KiB e o endereço base também está limitado em 16 bits. Isso significa que, se usarmos o DMAC legado (que ainda está presente de forma emulada) nos sistemas atuais, o buffer onde os dados serão transferidos só podem ser localizados na memória baixa.

Em outro artigo descreverei o novo DMAC, que permite transferências com tamanhos de 32 bits… Este “novo” DMAC está presente em chipsets especializados e depende da arquitetura, por isso o DMAC tradicional, diferente do que acontece com o PIC e o APIC, ainda é o mais usado…

Como o DMA funciona?

Desconsiderando o DMAC, um dispositivo coloca, no sinal HREQ, um sinal de nível 1 pedindo ao processador que este libere o barramento (coloque os barramentos de dados, endereço e controle, em estado de alta impedância). O processador, quando resolver atender o pedido, coloca nível 0 no sinal HACK# (Hold Acknowledge) e abre os circuitos dos barramentos… Neste ponto o dispositivo tem total controle do acesso à memória. Quando o sinal HREQ for zerado, o processador assume, de volta, o controle do barramento.

E como o DMAC ajuda?

O 8237A possui 4 entradas de requisição de DMA, nomeadas de DRQ0 até DRQ3. Da mesma forma como ocorre com o PIC, esses DMA são priorizados (se dois ocorrerem, um deles será atendido antes do outro). Em essência, o DMAC pedirá ou enviará dados de ou para o dispositivo, bem como de ou para a memória e, quando acabar, informa o fim do processo (sinal EOP# ou End Of Process) e, por sua vez, libera os barramentos de volta para o processador.

O DMAC também substitui, do ponto de vista do dispositivo, os sinais HREQ e HACK#, do processador, pelos sinais DRQn e DACKn#, onde n corresponde ao canal de DMA usado pelo dispositivo. É fácil perceber que o 8237A funciona, então, como um “agente”, do ponto de vista dos dispositivos.

De acordo com a especificação, a transferências da memória e para a própria memória também é possível. Mas, ao que parece, isso não foi implementado nos PCs… Não dá para pedir pro DMAC copiar, por demanda de software, um bloco de memória de um lugar para o outro. Em teoria isso seria uma boa ideia para aliviar alguma pressão em loops de cópias de dados, por parte do processador. Na prática, o 8237A é bem lento, permitindo transferências com uma taxa máxima de 2,5 MiB/s (existem versões com taxas de 5 MiB/s, mas, hoje em dia, isso não é lá muito rápido!).

Outro detalhe interessante são os modos de operação do DMAC… Alguns dispositivos são bem lentos e, por isso, aceitam apenas transferências simples, uma palavra (byte?) por vez… Outros, são rápidos, e permitem a transferência de um bloco de dados inteiro, mas ainda existem outros que precisam de algumas requisições para completar a transferência (transferem por demanda). Esses diferentes modos de operação o 8237A aceita e o PC está preparado para elas.

PCs adicionam um detalhe ao endereço base de transferência:

Note que, até aqui, o endereço base de transferência tem apenas 16 bits de tamanho. Isso limitaria o uso do DMAC a um único segmento da memória. Não é assim que funciona nos PCs… O padrão ISA adiciona o conceito de “página de DMA”, estendendo o endereço físico em 8 bits… Assim, o endereço base tem 24 bits de tamanho.

Isso não significa que podemos transferir, via DMA, blocos de 16 MiB! Os blocos continuam limitados a 64 KiB, e a página é fixa, mesmo quando há um overflow (ou underflow) no endereço. Por exemplo, se a página for 0xB8 (como é o caso com os antigos circuitos de vídeo VGA, no modo texto), se o DMAC transferir um byte do endereço físico 0xB8FFFF, o próximo endereço não será 0xB90000, mas 0xB80000. A página não muda.

O que é preciso para programar o DMAC?

O DMAC tem 12 tipos diferentes de registradores. Neles podemos ajustar o endereço base da transferência, a quantidade de bytes a serem copiados e o modo como esses dados serão transferidos (em bloco? um byte por vez?). Existem, ainda, registradores de status e máscara de requisições (do mesmo jeito que ocorre no PIC, exceto que apenas 3 bits são usados – os dois bits inferiores selecionam o canal e o bit 2 ajusta a máscara).

Para programar um canal do DMAC precisamos informar o endereço base (veja abaixo), a quantidade de bytes a serem transferidos, o modo de transferência e desmascarar o canal… O dispositivo requisitando a transferência vai indicar a transferência via sinal DRQn (uma vez que o DMAC responda com o DACKn).

É costumeiro que o dispositivo, depois de terminar a transferência envie uma interrupção ao processador para que o tratador saiba que existem dados colocados no buffer, pelo DMAC… É o caso de transferências feitas por HDs, sem o uso do modo PIO, por exemplo (por isso as IRQs 14 e 15 são alocadas para as duas controladoras possíveis de HD!)… É claro, as IRQs podem ocorrer por outro motivo e, neste caso, o tratador poderá ler o status do respectivo canal do DMAC para determinar o término da transferência…

As portas de I/O:

Existem dois DMACs no seu PC. As portas de 0x00 até 0x0F são usadas para o DMAC1, já as portas 0xC0 até 0xCF para o DMAC2… O primeiro DMAC lida com os canais de 0 até 3, o segundo, com os canais de 4 até 5, onde o canal 4 não é usável (o DMAC2 é escravo do DMAC1 via canal 4). Eis uma listagem para o DMAC1:

Porta Tamanho Registrador
0x00 word Start Address (Channel 0)
0x01 word Count (Channel 0)
0x02 word Start Address (Channel 1)
0x03 word Count (Channel 1)
0x04 word Start Address (Channel 2)
0x05 word Count (Channel 2)
0x06 word Start Address (Channel 3)
0x07 word Count (Channel 3)
0x08 byte Status(R)/Command(W)
0x09 byte Request
0x0a byte Single Channel Mask
0x0b byte Mode
0x0c byte Flip-Flop Reset
0x0d byte Intermediate(R)/Master Reset(W)
0x0e byte Mask Reset
0x0f byte Multi Channel Mask

Um cuidado ao lidar com o 8237A é que os registradores de 16 bits devem ser acessados 8 bits de cada vez, enviando (ou lendo) o LSB, primeiro e depois o MSB. Mas é importante que antes escrevamos qualquer valor no registrador Flip-flop reset. Isso deve ser feito porque o 8237A não muda o estado do flip-flop interno e, se não o fizermos “manualmente”, a próxima escrita num registrador de 16 bits ficará escrevendo apenas o MSB. Ao escrever em Flip-flop reset este flip-flop interno é resetado.

A porta Master Reset coloca o DMAC num estado inicial, como se tivéssemos feito um Power on reset. Não é interessante mexer nele. A porta Mask reset zera as máscaras de requisição de todos os canais do DMAC. Isso quer dizer que todos os canais estarão “desmascarados”…

As portas Single Channel Mask e Multi Channel Mask ligam ou desligam a máscara de requisições. A primeira o faz com apenas um canal, a última com todos os quatro, ao mesmo tempo. A porta Single Channel Mask recebe, nos dois primeiros bits (bits 0 e 1), o número do canal (de 0 a 3), tanto para o DMAC1, quanto para o DMAC2. e o bit 2 indica se a requisição para esse canal deve ser mascarada ou não. No caso de Multi Channel Mask, os bits de 0 a 3 são as máscaras de cada canal correspondnte.

Os registradores Request e Command são inúteis… O registrador Status devolve, nos 4 primeiros bits o estado da contagem… Para cada byte transferido a contagem decrementa. Se ela chega a zero, o bit correspondente ao canal estará setado, indicando um “término de contagem”. Já os quatro bits superiores indicam se existe alguma requisição de DMA pendente para o canal correspondente (bit 4 para o canal 0 até o bit 7, para o canal 3)… Ao lermos esse registrador, os bits TCn (terminal count) que estiverem setados serão automaticamente zerados.

As portas com as páginas de DMA, para o DMAC1, são as listadas abaixo e todas têm apenas 1 byte de tamanho. Note que a perfeita sequência que existe, acima, não existe aqui:

Porta Registrador
0x87 Page (Channel 0)
0x82 Page (Channel 1)
0x81 Page (Channel 2)
0x82 Page (Channel 3)

No caso do DMAC2 os endereços de I/O desses registradores assumem os valores de 0x8f (não usável), 0x8b, 0x89 e 0x8a (o mesmo que acima, mas com o bit 3 setado).

Resta-nos entender o registrador de modo… Para isso, é prudente que você consulte a documentação do 8237A. No contexto desse artigo, basta que eu diga que, geralmente, o modo usado por dispositivos são demand ou block. A não ser que o dispositivo seja bem velho, como antigos floppy drives, talvez o modo single seja útil… Ainda, nos PCs, o modo de autoinicialização não é usado por dispositivo algum e, geralmente, o endereço de transferência cresce (ao inves de ser decrementado)… No registrador de modo temos que dizer a direção que o DMAC vai trafegar os dados (do dispositivo para memória, da memória para o dispositivo ou da memória para a própria memória). As transferências Memória⇒memória geralmente não são suportadas e devem ser evitadas… Assim como no registrador Single Channel Mask, os 2 bits inferiores indicam para qual canal o modo se aplica.

ATENÇÃO: Novamente o leitor e amigo atento, Axey, vem em meu auxílio e indica que há uma diferença essencial nos datasheets da AMD (do link que forneci) e o da Intel (link aqui). No datasheet da Intel o “Terminal Count” é sinalizado quado o contador corrente vai de zero a 0xffff, ou seja, quando ha um underflow. Para o datasheet da Intel isso parece estranho, porque ao atingir zero nenhuma transferência adicional deveria ser feita, bem como decremento do contador… Considere a transferência de um bloco de 1 único byte: O DMAC faz algo assim:

...
while (current_count > 0)
{
  transfer_data(current_address++);
  current_count--;
}

No entanto, se esse for o caso, como poderíamos transferir 65536 bytes, ou exatamente 64 KiB? Segundo o datasheet da AMD seria impossível, já que o contador base tem 16 bits de tamnho. O contador máximo seria 65535… No caso da Intel, o contador base contém o valor da contagem requerida mais um byte. O que faz todo sentido… Para transferir 1 único byte teríamos que colocar 0 neste contador!

De fato, isso é descrito em ambos os datasheets, mas no da AMD eles esquecem de dizer que o terminal count é sinalizado no underflow. Afinal, não tem muito sentido programar o DMAC para transferir 0 bytes, tem?

Valeu, de novo, Axey!

MyToyOS: Machine Specific Registers

Os processadores Intel possuem mais registradores, além dos de uso geral, controle, debugger, SIMD e testes. Existe um “porrilhão” (grandeza técnica usada para indicar grandes quantidades!) de registradores usados para configurar o processador. Esses registradores são chamados de Machine Specific Registers porque muitos deles são específicos para a arquitetura do processador onde se aplicam.

Um exemplo: O MSR chamado de IA32_APIC_BASE contém, entre outras informações, o endereço básico do APIC local do processador lógico onde este registrador é consultado… O nome IA32_APIC_BASE é apenas um mnemônico, o registrador verdadeiro é identificado por um número que é passado pelo registrador ECX antes da chamada à instrução RDMSR (leitura) ou WRMSR (escrita). Cada um desses MSRs têm 64 bits de tamanho e, portanto, RDMSR retorna o valor no par de registradores EDX:EAX.

As instruções mencionadas só funcionam no ring 0. Caso contrário ela gerará um General Protection Fault, que ocorrerá também se o MSR consultado não existir… Isso quer dizer que você não tem como usar essas instruções num ambiente como Linux ou Windows, no userspace

Como o MS-DOS funciona no modo real e, por definição, executa no ring 0, eis um programinha usando o NASM e o Borland C++ 3.1 para obter o endereço base do Local APIC do processador atual:

bits 16

segment _TEXT public class=CODE align=16 use16

struc rdmsrsrk
.oldbp:   resw 1
.retaddr: resw 1
.msr:     resd 1
.ptr:     resw 1
endstruc

  global _rdmsr
  align 4
_rdmsr:
  push bp
  mov  bp,sp
  mov  ecx,[bp+rdmsrstk.msr]
  rdmsr
  mov  di,[bp+rdmsrstk.ptr]
  mov  [di],eax
  mov  [di+4],edx
  leave
  ret

O programinha em C que usa a rotina acima pode ser assim:

#include <stdio.h>

#define IA32_APIC_BASE 0x1b

struct rdmsr_s { unsigned long a, d; };

extern void rdmsr(unsigned long, struct rdmsr_s *);

void main(void)
{
  struct rdmsr_s r;

  rdmsr(IA32_APIC_BASE, &r);

  // Mostra os bits 31..12 do registrador.
  printf("LAPIC Base Addr = %lx\n",
    (r.a & 0xfffff000UL));
}

Compilando, linkando e executando, temos:

C:\WORK> nasm -f obj -o rdmsr.obj rdmsr.asm
rdmsr.asm:3: warning: segment attributes specified on redeclaration of segment: ignoring

C:\WORK> bcc -3 -ms -otest.obj -c test.c
C:\WORK> tlink \bcc\lib\c0s.obj+test.obj+rdmsr.obj,test.exe,nul,\bcc\lib\cs.lib

C:\WORK> test
LAPIC Base Addr = fee00000

O aviso do NASM indiva que o que é ignorado é o aviso, não a redeclaração do segmento…

A lista completa dos MSRs pode ser obtida no capítulo 35 do volume 3 dos manuais de desenvolvimento de software para as arquiteturas Intel 64 e IA-32 da Intel (link aqui).

Existe mais entre ISA e PCI do que supõe nossa vã filosofia…

Se você acompanha este blog deve ter percebido que editei o artigo anterior umas quinze vezes, acertando pequenos erros de interpretação quanto ao funcionamento fundamental da arquitetura de barramentos PCI. De fato, estou aprendendo o bicho e compartilhando minhas conclusões com você. Coisa que costumo fazer por aqui, como deve ter reparado…

No começo da era dos PCs, especialmente na arquitetura PC-AT, o padrão ISA imperava… A sigla nos diz que é um “padrão de indústria”, onde o “barramento” é o próprio do processador, junto com uma série de controladores padrão para tratamento de interrupções, DMA, teclado e outras coisinhas. Só que esse padrão foi ficando velho e lento. Rapidamente surgiram outros: EISA, MicroChannel, VESA Local Bus, …

O objetivo, é claro, é oferecer um barramento rápido o suficiente para suportar os “novos” dispositivos: HDs maiores com taxas maiores de transferência, memórias mais rápidas (com zero wait-states), placas de vídeo com aceleração e baixa latência… Coisas que, no velho ISA, ficava difícil de fazer e exigia alguns malabarismos de hardware.

O padrão PCI oferece um barramento isolado do processador de 32 bits… Tanto os dados quanto os endereços compartilham todo o espectro de endereçamento: 32 bits de dados e 32 bits de endereços. Isso quer dizer que o barramento é multiplexado na totalidade e, é claro, com a maior demanda por memória, existe uma extensão para 64 bits… Com o isolamento, PCI funciona na base de troca de mensagens: Um comando é enviado para a controladora para realizar uma transação… Mas isso é transparente para o processador, uma vez que os dispositivos ligados nesse barramento estejam configurados. Isso implica que temos, do ponto de vista do ISA-to-PCI host temos, agora, 3 “espaços de endereçamento”.

Num processador temos dois espaços de endereçamento diferentes: O primeiro é o espaço de acesso à memória e o outro, aos dispositivos de I/O. No caso do PCI, temos também o espaço de endereçamento de configuração… O motivo é simples: Os endereços de configuração devem ser fixos e facilmente conhecidos, enquanto os endereços de acesso à memoria (I/O mapeada em memória) podem variar de dispositivo para dispositivo. Por exemplo, num dos PCs que uso como teste para meus códigos o espaço de endereçamento de memória para acesso aos registradores da “placa de vídeo” (vídeo on-chip da Intel) está compreendido entre 0xf7800000 e 0xf7bfffff, e um bloco adicional entre 0xe0000000 até 0xefffffff. Isso não significa que o espaço de I/O esteja vazio… A porta 0xf000 também é mapeada para este dispositivo.

Para obter esses endereços é preciso acessar o espaço de configuração usando o esquema de endereçamento especificado no artigo anterior… No caso desse dispositivo de vídeo, o device é 0 (zero) e a função é 2. Assim, ao consultar o bloco de 256 bytes do espaço de configuração no endereço 0x80000200, obtemos a estrutura que descreve as características do dispositivo. A estrutura, como mostrei anteriormente, é esta (para o padrão PCI Local Bus 3.0):

#include <stdint.h>

union pcicfg_addr_t {
  uint32_t value;
  struct {
    uint32_t reg:8;
    uint32_t function:3;
    uint32_t device:5;
    uint32_t bus:8;
    uint32_t :7;          // reservados.
    uint32_t enabled:1;
  };
};

A regra para listar todos os dispositivos conectados ao barramento local (sempre o barramento 0) é varrer todo o espaço de endereçamento de configuração e verificar os primeiros bits do registrador 0 (Vendor ID). Se ele for 0xffff, então o dispositivo/função não existe, caso contrário, as outras informações do dispositivo devem ser válidas:

int i, j;
uint32_t data;

for (i = 0; i < 32; i++)
  for (j = 0; j < 7; j++)
  {
    union pcicfg_addr_t addr = { .enabled = 1 };

    addr.function = j;
    addr.device = i;
    outpd(0xcf8, addr.value);
    if (((data = inpd(0xcfc)) & 0xffff) != 0xffff)
      register_device(data);
  }

Aqui register_device() obterá as características do dispositivo e adicionará numa lista.

O padrão PCI permite que existam vários barramentos isolados no mesmo computador. O barramento “local” é sempre o zero, mas podem existir pontes entre o barramento local e outros barramentos. Para isso usa-se uma PCI-to-PCI bridge. Por isso o campo bus na estrutura do endereço no espaço de configuração.

Resta-nos apenas entender como obter essas características… Isso é complicado. Existem protocolos, padrões, para lidarmos com diversos tipos diferentes de dispositivos. É bom lembrar que PCI é uma especificação de barramento e, para tal, é preciso obter ou ajustar configurações tanto dos espaços de endereçamento de memória e I/O, mas também o comportamento de sinais como DMA, Interrupções, timings, etc. Uma visão superficial de como isso pode ser feito pode ser visto no código fonte do kernel do Linux, no diretório arch/x86/pci/. Observe as rotinas em early.c e verá algo similar com o código mostrado no artigo anterior (e no listado acima), mas repare que existem códigos específicos para processadores AMD e Intel, bem como para dispositivos como ACPI, chipsets de rede da BroadCom e barramentos NUMA (Non Uniform Memory Access). Ou seja, a obtenção das configurações não é assim tão simples porque cada dispositivo tem lá sua maneira de fazer as coisas, mesmo que o método seja unificado.

No final das contas, a maioria das portas de I/O de dispositivos são mapeadas no espaço de endereçamento de memória, mas alguns continuam no espaço de I/O, só que, diferente do padrão ISA, essas portas não são fixas. Elas dependem da configuração default do dispositivo ou, em alguns casos explícitos, da configuração que o seu sistema faz no dispositivo (as portas de I/O 0xCF8 e 0xCFA, usadas para acessar o espaço de configuração, podem ser escritas também!).

MyToyOS: Como PCI funciona?!

O padrão usual de leitura e escrita em portas de I/O é através das instruções IN e OUT do processador. Essas instruções usam um “espaço de endereçamento” diferente do usado para acessar memória, onde os endereços podem ter, no máximo 16 bits de tamanho… Além disso, as instruções IN e OUT são lerdas! Por esses dois motivos os fabricantes de PCs meio que deixaram o padrão ISA (Industry Standard Architecture) de lado e começaram a lançar padrões mais avançados, como o EISA, o VESA Local Bus, o AGP e, o mais recente, PCI.

Nos PCs atuais o padrão EISA continua existindo. Ele é um upgrade do barramento ISA, com suporte para vários processadores compartilhando o mesmo barramento e tamanho de 32 bits… Já o padrão PCI trata-se de um isolamento entre diversos barramentos e o barramento EISA. É por isso que dizemos que existe uma “ponte” EISA-PCI (EISA/PCI Bridge).

O padrão PCI suporta até 256 barramentos diferentes. Cada barramento é dividido em até 32 “dispositivos” (devices) e cada dispositivo em até 8 “funções” (functions) e cada função suporta 64 registradores de 32 bits.

O espaço de endereçamento de configuração de dispositivos

O mais importante é saber que esses barramentos/dispositivos/funções/registradores não são acessados no espaço de endereçamento de I/O normal, mas são mapeados diretamente na memória. No entanto, o endereço base de um dispositivo deve ser obtido através das portas de I/O 0xCF8 (chamada de CONFIG_ADDRESS) e 0xCFC (chamada de CONFIG_DATA) da seguinte maneira:

Escrevemos o valor de 32 bits abaixo na porta CONFIG_ADDRESS:

0b10000000_bbbbbbbb_ddddd_fff_rrrrrr_00

Onde os bits ‘b’ especificam o barramento. Os bits ‘d’, o dispositivo no barramento. Os bits ‘f’, uma das funções do dispositivo e os bits ‘r’, um dos registradores da função. Note que os registradores são alinhados por DWORD (veja na figura abaixo) e eles devem ser lidos ou escritos uma DWORD por vez… Assim, os bits ‘r’ compõem o offset no configuration space, mas por causa da restrição do alinhamento apenas os 6 bits superiores são informados e os 2 bits inferiores têm que ser zerados.

Na literatura você pode encontrar a especificação do barramento/dispositivo/função assim, por exemplo: B0:D1:F4, para barramento 0 (zero), dispositivo 1 e função 4. É comum encontrar a especificação apenas do dispositivo e da função, já que o barramento do PC costuma ser um só. Por exemplo, o SATA Host Controller, no chipset ICH9 da Intel, está localizado em D31:F2 e D31:F5. Outro exemplo é relativo ao USB ECHI Host Controller, do mesmo chipset, localizado em D29:F7 e D26:F7.

Uma vez escrito em CONFIG_ADDRESS a requisição da configuração do dispositivo, podemos ler o registrador de dados de configuração via porta CONFIG_DATA. O valor lido deve ser de 32 bits também… Leituras e escritas de bytes ou words nessas portas resultam em nada (ou seja, os dados lidos/escritos virão ou irão para o barramento EISA, não para o EISA-PCI bridge). Deve-se sempre ler/escrever dwords.

No espaço de configuração de um disposito a seguinte estrutura é padronizada:

450px-pci-config-space-svg

A partir do offset 0x40 os registradores são dependentes do dispositivo…

A forma de interpretar esses campos de endereços base (Base Address Registers) dependem também do dispositivo. Os campos DeviceID, VendorID, Header Type, Subsystem ID e Subsystem Vendor ID, além de Class Code e Revision ID podem ser usados para determinar quel é exatamente o dispositivo com qual estamos lidando.

ATENÇÃO: Embora dê para ler o DeviceID e o VendorID dos dispositivos atachados no barramento PCI no modo real, e até mesmo alguns outros campos, a maioria deles não é confiável nesse modo. Ao que parece, é necessário que o processador esteja configurado no modo protegido para obter os endereços base, por exemplo. O programinha abaixo, escrito para o MS-DOS, usando o Borland C++ 3.1 parece demonstrar esse fato:

#include <stdio.h>

#define CFGREG(bus,dev,func,reg) \
  ((1UL << 31) | \
   (((unsigned long)(bus) & 0xff) << 16) | \
   (((unsigned long)(dev) & 0x1f) << 11) | \
   (((unsigned long)(func) & 7) << 8) | \
   ((reg) & 0xfc))

extern unsigned long inpd(unsigned short);
extern void outpd(unsigned short, unsigned long);

void main(void)
{
  int i;

  for (i = 0; i < 256; i += 4)
  {
    // Neste teste quero os registradores do
    // bus 0, device 0 e function 0.
    outpd(0xcf8, CFGREG(0,0,0,i));
    printf("%08lx <- %02x\n", inpd(0xcfc), i);
  }
}

Onde as funções inpd() e outpd() são definidas, num código em assembly, ussando a sintaxe do MASM/TASM:

.386

; Ao invés de usar os atalhos '.model' e '.code',
; o MASM/TASM precisa do modo tradicional para definir 
; segmentos de 16 bits quando a diretiva .386 é usada. 
; O alinhamento por parágrafo é preciosismo meu...

_TEXT segment para public 'CODE' use16
      assume cs:_TEXT

; Estrutura da pilha para inpd().
inpdstk struc
oldbp   dw ?
retaddr dw ?
inport  dw ?
inpdstk ends

      public _inpd
      align 4
_inpd proc near
  push bp
  mov  bp,sp
  mov  dx,[bp].inport
  in   eax,dx
  mov  edx,eax  ; long retornado em DX:AX, não em EAX!
  shr  edx,16
  pop  bp
  ret
_inpd endp

; Estrutura da pilha para outpd().
outpdstk struc
oldbp   dw ?
retaddr dw ?
outport dw ?
data    dd ?
outpdstk ends

       public _outpd
       align 4
_outpd proc near
   push bp
   mov  bp,sp
   mov  eax,[bp].data
   mov  dx,[bp].outport
   out  dx,eax
   pop  bp
   ret
_outpd endp

_TEXT ends

end

Compilando, linkando e executando o teste, temos:

C:\WORK> bcc -3 -c -otest.obj test.c
C:\WORK> tasm -m2 inout.asm,inout.obj
C:\WORK> tlink \bcc\lib\c0s.obj+test.obj+inout.obj,test.exe,nul,\bcc\lib\cs.lib
C:\WORK> test
12378086 <- 00
00000000 <- 04
06000002 <- 08

...
00000000 <- f8
00000000 <- fc

Neste caso o Vendor ID 0x8086 indica “Intel Corporation” e o Device ID 0x1237 é a controladora “PCI 440LX”, emulada pelo VirtualBox (eis uma listagem dos Vendor IDs e Device IDs: aqui). Repare também que todos os outros campos, exceto Class CodeRevision ID, estão zerados!! Isso até poderia ser esperado para o D0:F0, mas experimente obter as configurações de outro dispositivo e verá algo parecido… Os dois primeiros campos estão corretos, mas o resto estará meio maluco. Só posso supor que isso ocorra no modo real. Não tive paciência, ainda, para testar isso no modo protegido…

Uma vez obtido o endereço base de I/O nos registradores de configuração do dispositivo, escrever e ler dados de I/O num dispositivo PCI qualquer fica tão fácil quando usar uma simples instrução MOV, ou usar um ponteiro em C. É claro que essa região de memória é protegida pelo kernel para que os processos do userspace não possam bagunçar com os dispositivos…

Se quiser dar uma olhada na especificação 3.0 do PCI Local Bus, baixe o pdf aqui.

MyToyOS: Interrrupções (complemento)

Uma coisa que esqueci de mencionar no artigo anterior é que o processador suporta dois estilos de interrupções: Existem as interrupções mascaráveis, ou seja, que podem ser desabilitadas, e uma única interrupção não mascarável ou NMI (Non Maskable Interrupt).

Nos PCs, a NMI geralmente é usada para eventos catastróficos de hardware ou, em muitos sistemas, não ser usada de forma alguma…

Quanto às interrupções mascaráveis, como vimos no artigo anterior, elas podem ser desabilitadas setando o bit correspondente na OCW1, do PIC… Note também que o processador tem um FLAG de habilitação de interrupções mascaráveis (flag IF) e ele é manipulado pelas instruções CLI e STI… A diferença desses métodos está no ponto onde as interrupções são “escondidas” do processador.

Na máscara em OCW1 podemos mascarar qualquer interrupção isoladamente. Por exemplo, podemos esconder a IRQ3, assim:

  in al,0x21      ; Lê a máscara do PIC1.
  or al,(1 shl 3) ; Seta a máscara para IRQ3.
  out 0x21,al     ; Escreve a nova máscara no PIC1.

E o processador jamais a receberá, até que a máscara seja retirada… No caso das instruções CLI e STI, todas as interrupções mascaráreis são desabilitadas ou habilitadas. Isso é útil nas rotinas de tratamento de interrupções porque evita que outras interrupções sejam aceitas pelo processador enquanto estamos tratando alguma, em curso… O PIC continuará pedindo interrupções, mas o processador simplesmente não as reconhecerá (o sinal INTA# não será ativado e o processamento normal não será interrompido).

Essas instruções são privilegiadas e só podem ser executadas no ring 0. Veja o que acontece se tentarmos usá-las no userspace:

#include <stdio.h>

void main(void)
{
  __asm__ ("cli");
  puts("Será que chega aqui?");
  __asm__ ("sti");
}

Compilando, linkando e executando, temos:

$ gcc -o test test.c
$ ./test
Segmentation fault

A mesma coisa ocorre ao tentar usar as instruções OUT e IN que, embora não sejam instruções privilegiadas, elas dependem de um mapa de permissão de I/O que é fornecido pelo sistema operacional na estrutura de uma task