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)  & 0x3f) << 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.

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 1. Teclados suportam os conjuntos 2 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).

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!

Convertendo vídeos para texto com ffmpeg e caca-utils

Yep… você leu certo: caca-utils. Não me pergunte o que caca significa. Só sei que os caras chamam de Colour ASCII Art Library. O detalhe interessante é que alguns players conseguem renderizar vídeos usando essa biblioteca. É o caso do VLC e também do ffmpeg.

Então, dá para criar um vídeo usando ASCII art? Well… dá, mas não é direto. O problema desses codecs é que eles renderizam o vídeo em formato texto e, assim, não tem como incorporar cada “quadro” num container MP4, Matroska, AVI ou o raio que o parta… Infelizmente, temos que renderizar os quadros em formato gráfico para poder convertê-los e incorporá-los nos citados containers. Eis uma maneira de fazer:

  • O primeiro passo é converter o vídeo em quadros individuais e de tamanho bem definido. Isso pode ser feito com o ffmpeg da seguinte maneira:
$ ffmpeg -i video.mp4 -s hd720 -r 30 -f image2 %07d.png

Isso ai vai criar um porrilhão de arquivos PNG nomeados como 0000001.png, 0000002.png … até o último quadro do vídeo. Note que pedi, explicitamente, que o framerate seja de 30 quadros por segundo e que a resolução gráfica seja de 1280×720 (HD).

Aliás… esse é o método favorito de “misturar” vídeos, onde um deles (o sobreposto) tenha um canal Alpha, ou seja, transparência… A maioria dos editores de vídeo suporta essa feature. O OpenShot, por exemplo, usa esse recurso quando criamos um “efeito especial” usando Blender, por exemplo.

  • De posse de todos os quadros, temos que convertê-los para texto. É aqui que entra o caca-utils. Nele, temos um programinha chamado img2txt, que converte uma imagem qualquer (JPG, PNG etc) em texto, seja ASCII puro (sem cores), HTML, SVG ou um formato gráfico chamado TARGA. É esse que usarei, já que isso facilita a reconstrução do vídeo:
$ for i in *.png; do \
    img2txt -f tga -b ordered8 -W 160 "$i" > "${i%.*}.tga"; \
  done

A opção -b ordered8 usa subsampling em blocos de 8×8 para realizar uma emulação de dithering, em modo texto. A opção -W 160 informa ao img2txt que usaremos linhas com 160 caracteres… Você pode usar menos, como 80, que é o padrão para o modo terminal em muitas instalações (especialmente Windows), mas, veremos que usar linhas maiores (ou retângulos maiores) nos dará resoluções “textuais” melhores.

O loop acima converterá todos os PNG para TGA.

O padrão TARGA é muito usado por profissionais porque é um padrão lossless, se nenhuma compressão for usada.

  • Tudo o que temos que fazer agora é juntar todos esses arquivos TGA na mesma velocidade (framerate):
$ ffmpeg -r 30 -f image2 -i %07d.tga -s hd720 \
  -b:v 1200k -minrate 1200k -maxrate 1200k -bufsize 1200k \
  -c:v libx264 -an "out.mp4"

Voilà! Temos um arquivo out.mp4 com o vídeo em “ascii”. Para efeitos de comparação, eis um vídeozinho de 13 segundos (já usei ele por aqui antes) e o vídeo convertido logo abaixo:

E, abaixo, está a comparação da conversão dos PNGs com 80, 160 e 240 caracteres por linha:

Postmortem: Erros comuns que já vi em C++ e COM

Há mais ou menos uma década, ou um pouco mais, cheguei a trabalhar com consultoria a respeito de otimização e caça de erros em códigos de “frameworks” para algumas empresas. Lembro-me de 3 projetos grandes que apresentavam os mesmos erros que, até onde sei, jamais foram solucionados.

Como sempre acontece, trata-se do uso indiscriminado de “facilidades”, sem o devido conhecimento de como elas funcionam. Coisa comum no desenvolvimento usando “orientação a objetos”, afinal, alguns “comportamentos” estão “encapsulados” dentro de um conjunto de classes e o programador, em teoria, não deveria nem querer saber como, só usá-los, certo? Bem… errado!

Abaixo, mostro alguns erros que já encontrei e é apenas uma lista pequena contida num texto gigantesto, desculpe…

1º: Objetos anônimos temporários

O primeiro problema comum de ser encontrado em códigos escritos em C++ é o desconhecimento sobre como uma linguagem de programação funciona… E não estou falando somente de suas regras sintáticas, mas da semântica da resolução de expressões e passagem de argumentos de funções. Vejamos um exemplo simples com objetos de alguma classe complexa qualquer, usando sobrecarga de operadores:

a = b + c;

Essa simples expressão é composta de dois operadores (‘=’ e ‘+’), onde o operador de adição sempre retornará um “objeto anônimo temporário”. Por quê? Ora, nem o objeto ‘b’, nem o objeto ‘c’ devem ser molestados e, o que queremos, é a adição desses dois como resultado. Temos que criar, on-the-fly, um terceiro objeto que represente essa “adição”… É comum que um operador desse seja definido de acordo com a assinatura abaixo:

MyClass MyClass::operator+(const Myclass& o);

O objeto retornado por esse operador será usado como argumento para o operador “=”, que o copiará para o interior do objeto ‘a’ e, só então, o objeto anônimo temporário deixará de existir.

Para objetos pequenos, cuja cópia é simples de ser feita, a perda de performance é quase inócua, mas para objetos complexos, que contém em seu interior, agregações, listas, árvores etc, a operação de cópia pode ser bem demorada, bem como as necessidades de recursos podem crescer um bocado. Suponha que o objeto ‘c’ tenha uns 2 MiB de dados agregados em seu interior e o objeto ‘b’ tenha 1 MiB… Suponha, agora, que no processo de “adição” esses dados sejam manipulados de forma tal a gerar o consumo de 3 MiB (que pode ser bem mais, dependendo da transformação necessária!)… Temos o consumo de 3 MiB adicionais apenas no objeto anônimo temporário, retornado pelo operador ‘+’!

Nesses casos, para evitar a criação de objetos temporários, seria interessante usarmos funções-membro especializadas ao invés de operadores. A expressão acima poderia ser reescrita como:

a.append(b, c);

Onde a função-membro append aceitaria duas referências para os argumentos ‘b’ e ‘c’. Podemos controlar a criação de objetos temporários, se houver necessidade de um.

Mais um exemplo: Suponha agora que a expressão seja bem mais complexa, como a = -(b + c) * (d >>= e);. Dependendo do que os operadores ‘-‘ (unário), ‘*’ e ‘>>=’ fazem, teremos uns 3 objetos anônimos temporários em potencial (um para t1=(b + c), outro para t2=t1*(d >>= e) e outro para t3=-t2). Cada um com seus próprios recursos… Isso sem contar que podemos ter outras expressões que usem os objetos originais e, mesmo com a otimização de common subexpression elimination, que não funciona muito bem para classes customizadas, já que a semântica dos operadores muda radicalmente, podemos ter n objetos temporários anônimos em uso num mesmo bloco.

A criação de objetos temporários acontece, também, com chamadas de funções, mas, neste caso, os objetos não são “anônimos”, mas uma cópia do original, se fizermos algo assim:

MyClass f(MyClass a, MyClass b) { ... }

Ao passar instâncias para a função f(), automaticamente o compilador gerará uma cópia das instâncias originais, porque ‘a’ e ‘b’ não podem modificá-las, sendo locais à função… Note que a função retorna um objeto anônimo da classe ‘obj’ também!

É claro que para solucionar esse tipo de coisa, pelo menos no que se refere aos argumentos, podemos usar referèncias:

MyClass f(const MyClass& a, const MyClas& b) { ... }

Aqui, o qualificador const garante que a instância referenciada não poderá ser modificada no interior da função… Isso é óbvio para um desenvolvedor experiente, mas, nessa época de .NET e Java, onde argumentos de funções de tipos complexos são, na verdade, referências, costuma-se esquecer do fato acima, causando grande pressão por uso de recursos…

2º: Tipos primitivos versus objetos “constantes”

Outra coisa que já observei é a tendência a usar classes que oferecem facilidades, ao invés de tipos primitivos, especialmente quando estamos lidando com constantes. Um exemplo clássico é a definição de “constantes” do tipo “string”… Nesses “frameworks” era comum ver algo assim:

const std::string ERROR1 = "Erro genérico";
const std::string ERROR2 = "Erro de qualquer bobagem";
const std::string ERROR3 = "Erro errado";
...
const std::string ERROR1023 = "Erro de um monte de erros";

O problema aqui é que o programador não está criando constantes. Está criando instâncias do objeto basic_string contendo contantes. Todos esses 1023 objetos terão que ser construídos, ou seja, código de construtores serão chamados. Só para ilustrar, eis o código em assembly gerado pelo GCC, para x86-64 (linux), do construtor da “constante” ERROR1, acima:

_GLOBAL__sub_I_test.cc:
  movabs rax, 7954877705826234949 ; A string de ERROR1.
  mov rdx,__dso_handle
  mov rsi,_ZL6ERROR1   ; A referência ao objeto ERROR1.
  mov [_ZL6ERROR1+16],rax
  mov rdi, _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEED1Ev
  mov eax, 28515
  mov qword [_ZL6ERROR1],_ZL6ERROR1+16
  mov dword [_ZL6ERROR1+24],1769122243
  mov [_ZL6ERROR1+28],ax
  mov qword [_ZL6ERROR1+8],14
  mov byte [_ZL6ERROR1+30],0
  jmp __cxa_atexit

É claro que a rotina também registra o destrutor (pulando para <tt>__cxa_atexit</tt> no fim das contas)… Mas, neste construtor, porque a string é pequena (14 bytes), ela cabe no registrador RAX e o valor atribuído no início da rotina é exatamente essa string parcial no formato little endian (os primeiros 8 bytes: 0x6E6567206F727245, formando “Erro gené”), a 5ª instrução, de cima para baixo completa a string (0x6972A9C3 ou “rico”), e a penúltima coloca o ‘\0’ final. Mas existem outras 8 instruções para esse simples construtor de apenas uma das “constantes” (dentre eles o ajuste do tamanho da string na ante-penúltima instrução). Para strings maiores que 16 bytes, contando o terminador ‘\0’, o construtor fica mais complicado… Assim, um código enorme será executado para inicializar os objetos.

Compare isso à simples declaração de arrays:

const char ERROR1[] = "Erro genérico";

O compilador só fará isso:

section .rodata

        global ERROR1
ERROR1: db 'Erro genérico',0

Nenhum construtor é criado.. Os dados simplesmanente são colocados no segmento de dados read-only e acessíveis por ponteiro. Existe outra vantagem nisso: O qualificador const, para tipos primitivos, tende a criar constantes de fato em C++. Por exemplo, se tivéssemos, em dois módulos separados:

// No módulo errors.cc
const char ERRO1[] = "Erro";

// No módulo xpto.cc
const char * const error = "Erro";

O linker tenderá a fazer os ponteiros ERROR1 e error apontarem para o mesmo lugar porque existirá apenas uma string “Erro” no código inteiro (otimização “merge duplicate strings“). E, como vimos no caso do uso de “objetos constantes”, eles são construídos e a string é copiada para o interior do objeto, triplicando o uso dos recursos (teriamos 3 strings “Erro” na memória se o objeto ERROR1 e ERROR2 foram inicializados com a string “Erro”).

Isso não parece ser um problema sério, mas considere que as classes definidas nesse framework eram usadas em objetos COM que, de acordo com o ambiente, podem ser carregados e descarregados da memória… Esse comportamento é comum, no que a Microsoft chama de “objetos interoperáveis” que é a mistura de COM (unmanaged code) com objetos .NET (managed code)

Ao usar constantes “reais”, definidos com tipos primitivos, o único problema é que para usá-las teremos que instanciar objetos temporários anônimos do tipo string, como em:

std::cerr << std::string(ERROR1) << '\n';

Mas, a criação deste objeto menos traumática. O compilador, provavelmente, criará uma única função com um construtor string::string(const char *); (ou um construtor de conversão). Diferente da criação de múltiplos objetos “constantes”, este objeto temporário é criado na hora de sua necessidade e destruído assim que sairmos do escopo do bloco onde ele existe.

3º: Conversão de tipos antes da hora

De maneira similar, outro fato relacionado às funções da Win32 API e objetos COM (OLE) é o uso de um tipo “especial” de string chamada BSTR. Em C e C++ uma string é definida como um array contendo os caracteres e terminada em ‘\0’. Note que não falei “array de chars” porque podemos ter arrays de wchar_t, onde cada item tem 16 bits de tamanho…

No caso de COM (OLE), algumas vezes é necessário usar uma estrutura diferente para strings, onde cada caracter é um wchar_t e o terminador é um ‘\0’, também com 16 bits de tamanho… Além disso, o primeiro wchar_t (ou unsigned short) do array contém o tamanho da string contida no array.

Tanto no Visual Studio quando no Borland C++ Builder (usado em dois dos projetos de que falei) contém classes especializadas para conter esse tipo de string e é comum a conversão do tipo de string ANSI (na nomenclatura do Windows, são strings de chars) ou Wide Strings (onde cada ítem é um wchar_t) para o tipo BSTR (ou _bstr_t). A conversão não pode ser mais simples, usando classes:

CBSTR bstr = str;

Dependendo do tipo de str (CString ou CStringW) a classe CBSTR poderá ter um construtor de conversão mais ou menos assim:

// Note: CString contém strings "ansi".
CBSTR::CBSTR(const CString& s)
{
  size_t i, length;
  char *p = s.c_str();

  length = strlen(p);
  this->str = new wchar_t[length+1];
  for (i = 1; i <= length; i++) this->str[i] = (wchar_t)*p++;
  this->str[i] = '\0';
  this->str[0] = (wchar_t)(length + 1); //?
}

A função acima, é claro, pode muito bem ser substituída por uma chamada a StrAllocString(), no caso de Wide Strings

O destrutor de CBSTR verifica se existe um ponteiro não nulo em this->str, efetua um delete [] this->str;, se for o caso, e zera this->length. Mas, o ponto aqui é que, cada conversão envolve a alocação de novo espaço e a cópia da string original, potencialmente duplicando o espaço originalmente usado, e triplicando o uso de recursos.

Assim, usar BSTRs antecipadamente é gasto de recursos. Deveriam manter as strings confinadas aos tipos genéricos (que ocupam 2 ou 3 bytes a menos, pelo menos, já que não contém o campo length) e só quando forem chamar uma função OLE, efetuassemos:

{ SomeAPIOLEFunction(CBSTR(str).bstr()); }

Onde o membro bstr() retorna o ponteiro para a string BSTR… Note o bloco… O objeto temporário seria destruído logo após o retorno da função…

O ponto aqui é que BSTRs são necessárias apenas para as chamadas dessas funções da Win32 API. Não há necessidade de mantê-las instanciadas mais do que o tempo necessário de seu uso. A mesma coisa acontece quando uma função da API devolver uma BSTR. Poderíamos fazer algo assim:

// Note: CStringW contém Wide string.
CStringW str;

{
  wchar_t *p;
  
  SomeAPIOLEFuncionGetsBSTR((BSTR *)p);
  str = CBSTR((BSTR *)p); // supondo que CBSTR aceite um ponteiro void...
                          // supondo que CStringW tenha um construtor de
                          //   conversão para BSTR *.
  // A expressão acima poderia ser substituída por:
  //
  //   str = (BSTR *)p;
  //
  // Se CStringW tiver alguma conversão de ponteiros desse tipo.

  // Pode ser necessário chamar SysFreeString((BSTR *)p) aqui!
}

Do mesmo jeito, CBSTR “morrerá” assim que o bloco for encerrado… Apenas por um breve momento teremos muito recurso em uso, mas strings deste tipo não são tão grandes assim (64 KiB, no máximo) e logo o final dos blocos as destruiriam…

4º: Agregações com containers errados

É comum, nessas classes complexas de frameworks, que o desenvolvedor queira manter listas, filas, pilhas e outras estruturas. O que é comum encontrar é o uso de containers errados para a finalidade que o objeto se dispõe a resolver. Por exemplo, já vi muita classe agregando vector<T> ou list<T> para conter uma lista de objetos ordenados. É clarqo que dá para fazer isso e a STL disponibliza a função sort() no header algorithm. Só que existem containers que são feitos para manterem itens ordenados à medida que os inserimos… É o exemplo de set e map (e seus irmãos que aceitam várias chaves idênticas, multiset e multimap). Eles usam uma red black tree para implementar esse comportamento e, portanto, têm tempo de pesquisa na ordem de \log n. Sendo bem mais rápidos do que os containers mais “fáceis” de usar.

5º: O preconceito contra ponteiros

Eis um dos motivos da “fuga” da programação procedural para a “orientada a objetos”. Tem um monte de gente que tem medo de ponteiros! Embora a construção e destruição “automática” de objetos seja bem interessante, a alocação e dealocação dinâmica de blocos de dados é bem mais rápida e gera código mais eficiente. Isso pode ser visto nos dois fragmentos de código abaixo:

// Usando um array de objetos como uma lista.
// O último item é NULL (para emular o end()
// do iterator, abaixo).
obj *list, *p;

for (p = list; p; p++)
  p->doSomething();
-----%<-----%<-----
// Usando um container vector<>:
std::vector<obj> list;
std::vector<obj>::iterator i;

for (i = list.begin(); i != list.end(); i++)
  i->doSomething();

O segundo código parece ser mais limpo e simples que o primeiro, mas ele esconde um monte de detalhes de implementação. Para objetos simples os dois códigos são quase que exatamente os mesmos (quase! o primeiro é mais eficiente), mas se seu objeto tiver funções virtuais, herança múltipla (comum no caso de COM), classes base virtuais, … o container vector pode gerar código mais complicado (lembre-se que ele é um template). E, como demonstrei neste artigo, um container não se comporta exatamente como se espera, às vezes.

Além disso, graças ao conceito de “referência”, a turma do C++ prefere usá-las, ao invés de ponteiros só porque a “notação” é mais simples, do ponto de vista da linguagem. Mas, note que lidar com ponteiros é coisa que o processador faz com facilidade. Ao adicionar comportamentos à classes, a abstração permite ao programador mais liberdade, com o custo de uma pequena, mas significativa, ineficiência.

6º: O uso cego da MFC ou da VCL não é a melhor maneira de implementar um objeto COM

Ambas a Microsoft Foundation Classes (no Visual Studio) e a Visual Components Library (no Borland C++ Builder ou Delphi) contém templates prontinhos para usar herança na implementação de classes baseadas nas interfaces IUnknown ou IDispatch, que são as mais usadas nesse tipo de codificação. Esses templates, geralmente, são construídos através de wizzards que constroem classes com nossas funções membro hardcoded na classe, mais ou menos assim:

class IMyClass : public IUnknown {
public:
  virtual void doSomething(void);
};

Onde tudo o que você tem que fazer é criar o código de doSomething(). No entanto, especialmente com a interface IDispatch, graças ao conceito de late binding, esse tipo de artifício pode tornar seu código bem complicado.

Não parece, mas é bem mais fácil usarmos “composição” para criarmos essas classes, mais ou menos assim (este é apenas um código de exemplo não testado… Não me lembro se a MFC ou a VCL implementam as funções virtuais de IUnknown – estou assumindo que sim!):

class IMyClass : public IUnknown {
private:
  MyClassInternal *myobjptr;
public:
  IMyClass() : myobjptr(NULL) {}

  // métodos virtuais sobrecarregados de IUnknown.
  ULONG Release(void);
  HRESULT QueryInterface(REFIID riid, void **pObj);

  virtual void doSomething(void);
};

ULONG IMyClass::Release(void)
{
  ULONG ref;

  ref = IUnknonwn::Release();

  if (!ref && myobjptr)
  {  
    delete myobjptr;
    myobjptr = NULL;
  }

  // Quando retorna 0 a COM Library
  // faz um "garbage collection" e livra-se
  // da instância.
  return ref;
}

HRESULT IMyClass::QueryInterface(REFIID riid, void **pObj)
{
  // Se a interface que o usuário quer é
  // a de nosso objeto...
  if (riid == IID_MyClass)
  {
    // Cria o objeto interno
    if (!myobjptr)
    {
      myobjptr = new MyClassInternal;

      // Se não conseguiu alocar
      // objeto interno, retorna erro.
      if (!myobjptr)
      {
        // QueryInterface() exige isso,
        // em caso de erro.
        *pObj = NULL;

        // Retorna erro (E_NOINTERFACE é o ideal?)
        return E_NOINTERFACE;
      }
    }

    // Devolve o objeto, adiciona 1 à referência
    // e retorna S_OK.
    *pObj = this;
    AddRef();
    return S_OK;
  }

  return IUnknown::QueryInterface(riid, pObj);
}

// Wrapper.
void IMyClass::doSomething(void)
{ myobjptr->doSomething(); }

Na construção do objeto, via QueryInterface devemos instanciar o objeto da classe MyClassInternal, caso o GUID correto seja informado. Isso implica em realizar uma chamada a IUnknown::AddRef() para reference counting, que, de outra forma, seria feita pela função-membro base virtual, sobrecarregada.

As vantagens estão no fato que as funções-membro de MyClassInternal podem ser codificadas sem que tenhamos que nos preocupar com as regras impostas pela COM (OLE), a não ser no caso de multithreading… Ainda, no caso da sobrecarga de IUnknown::AddRef(), que criará a instância na sua primeira chamada, devemos também sobrecarregar IUnknown::Release() que se livrará de nosso objeto “interno” quando o contador chegar a zero, garantindo que não teremos memory leakage. A outra vantagem é que a classe interna pode ser desenvolvida de forma independente do contexto de um objeto COM. Ela pode até mesmo ser testada separadamente.

A desvantagem óbvia é que toda chamada é feita indiretamente e à partir de uma função virtual (implicando em tripla indireção). No entanto, não há motivos para que a função membro da classe interna também seja virtual…

7º: O seu objeto pode não estar no seu computador!

Quanto lidamos com COM ou OLE isso é importante: Os seus objetos podem estar em qualquer lugar onde haja maneira de comunicação… Por exemplo, o seu programa, que é um cliente de um objeto, pede ao Windows que instancie o objeto cuja identificação é IID_MyClass (um GUID). Graças às configurações no “arquivo de registro”, o sistema sabe que este objeto pode estar num servidor do outro lado do mundo, dai ele envia uma mensagem (marshalling) encapsulando tanto o tipo de objeto desejado quanto os métodos sendo chamados… Do outro lado, a OLE Library recebe a mensagem, a decodifica (unmarshalling), instancia o objeto desejado, chama a função membro indicada e monta uma mensagem de resposta (marshalling, de novo). A OLE Library, do seu lado, recebe a mensagem, decodifica (unmarshalling, de novo) e faz a função retornar o valor desejado.

Este caso de instanciamento fora do seu computador, chamamos de instancia Out-of-Process e o objeto cliente contém um “pseudo” objeto com as mesmas características do objeto original, mas sem a sua implementação. Trata-se de um “proxy” (ou um “procurador”, que age em benefício do cliente). Do lado do objeto real temos um “stub” (um “toco” ou “ponta”, em tradução livre), que receberá a mensagem e lidará com o objeto como se fosse o próprio cliente.

No caso do instanciamento ser feito na mesma máquina e na mesma thread, chamamos de In-Process, onde o par proxy/stub não é necessário:

Proxy/Stub

A falha em entender esse simples conceito causa grandes problemas no uso de COM…

8º: Modelo de threading errado

Nesses projetos que lidei não encontrei um objeto COM sequer que implementasse multithreading. Todos usavam o conceito de STA (Single Threaded Appartment). Isso porque este é o modelo mais simples, que deixa a COM Library lidar com a sincronização entre chamadas de várias threads para o mesma função-membro, bloqueando todas, exceto uma. De fato, apenas uma thread pode usar um objeto STA, se multiplas threads quiserem usar um objeto, cada uma delas terá que instanciar o seu…

O conceito de “Apartamento” é o mesmo do da vida normal: Existem apartamentos onde vivem pessoas sozinhas e outros onde vivem uma família com várias pessoas. No caso, não estamos falando de pessoas, mas threads.

É fácil desenvolver objetos COM em apartamentos de solteiros (STA), mas a desvantagem é que, num ambiente WEB, por exemplo, apenas um cliente terá acesso ao objeto por vez e, mesmo que vários clientes tenham seus próprios objetos, o consumo de recursos será enorme. Ou seja, seus objetos serão o “gargalo” de performance de todo o seu sistema.

Usar um modelo de threading diferente, como MTA (Multithreading Appartment) ou NTA (Neutral Threading Appartment) não é tarefa para o fraco de coração, especialmente porque a documentação detalhada sobre o assundo não é facilmente compreensível nas páginas da MSDN (ou seja, como diabos COM realmente funciona? De qualquer maneira, você pode ler muito aqui).

A escolha de um modelo de threading é essencial para que seus objetos possam ser usados com a máxima performance possível, de acordo com o ambiente. No caso de MTAs, o sincronismo entre threads deve ser feito pelo próprio objeto (usando critical sections, por exemplo), diferente das STAs, onde a COM Library usa um loop de mensagens para sincronia… Isso implica que o termo “Apartamento” é apenas um método de “marshaling” diferente, ou seja, de comunicação entre objetos (o cliente e o servidor, ou seja, a COM Library), o que torna todo o conceito ainda mais complicado… Por que essa comunicação? É que o objeto pode existir tanto no contexto do seu processo, quanto em algum outro processo ou até mesmo em um outro computador. COM pressupõe o uso de RPC (Remote Procedure Call), onde o objeto em uso pode estar, teóricamente, em qualquer lugar.

Não vou mostrar um objeto MTA e muito menos um NTA. A “neutralidade” foi uma modelo implementado no Windows 2000 para tornar MTAs ainda mais rápidos… Deixo esses detalhes para seus estudos ou, quem sabe, um dia volto a falar neles…

9º: Para complicar as coisas: COM+

Felizmente nunca peguei um projeto sério que usasse COM+… COM e OLE estão presentes no Windows desde a versão 3.1, para MS-DOS. COM+ é uma extensão do COM onde “transações” são incorporadas à complexidade do modelo. A ideia é criar objetos que permitam automatizar commits e rollbacks, do mesmo jeito que ocorre com bancos de dados. Garantindo que certas operações sejam atômicas.

Mas, sinceramente, COM e OLE já é um assunto complicado demais e eu quis ressaltar, ai em cima, que nos projetos que vi os desenvolvedores não faziam ideia do que fosse isso… Aliás, conheço poucos que fazem ideia, ainda hoje.

Quem tem medo de otimizações?

Já me disseram que eu não deveria usar as opções -O3 ou -Ofast em meus projetos. Bem… elas tendem a oferecer as melhores otimizações possíveis e, na tabela abaixo, mostro a diferença entre elas e a compilação sem opções de otimização (que chamei de “generic”) e a opção de “nenhuma” otimização (-O0). A opção -Os, em teoria, gera código pequeno (‘s’ de smaller), mas essencialmente, ele é a mesma coisa que -O2, exceto pela possível otimização de chamadas à strlen (que, na minha experiência, quase nunca o compilador consegue otimizar):

generic -Os -O0 -O1 -O2 -O3 -Ofast
-faggressive-loop-optimizations
-fasynchronous-unwind-tables
-fauto-inc-dec
-fchkp-check-incomplete-type
-fchkp-check-read
-fchkp-check-write
-fchkp-instrument-calls
-fchkp-narrow-bounds
-fchkp-optimize
-fchkp-store-bounds
-fchkp-use-static-bounds
-fchkp-use-static-const-bounds
-fchkp-use-wrappers
-fcommon
-fearly-inlining
-ffunction-cse
-fgcse-lm
-fira-hoist-pressure
-fira-share-save-slots
-fira-share-spill-slots
-fivopts
-fkeep-static-consts
-fleading-underscore
-flifetime-dse
-flto-odr-type-merging
-fpeephole
-fprefetch-loop-arrays
-freg-struct-return
-fsched-critical-path-heuristic
-fsched-dep-count-heuristic
-fsched-group-heuristic
-fsched-interblock
-fsched-last-insn-heuristic
-fsched-rank-heuristic
-fsched-spec
-fsched-spec-insn-heuristic
-fsched-stalled-insns-dep
-fschedule-fusion
-fsemantic-interposition
-fshow-column
-fsplit-ivs-in-unroller
-fstack-protector-strong
-fstdarg-opt
-fstrict-volatile-bitfields
-fsync-libcalls
-ftree-loop-if-convert
-ftree-loop-im
-ftree-loop-ivcanon
-ftree-loop-optimize
-ftree-parallelize-loops
-ftree-phiprop
-ftree-reassoc
-ftree-scev-cprop
-funit-at-a-time
-funwind-tables
-malign-stringops
-mavx256-split-unaligned-load
-mavx256-split-unaligned-store
-mfancy-math-387
-mfp-ret-in-387
-mfxsr
-mglibc
-mno-sse4
-mpush-args
-mred-zone
-msse
-msse2
-mtls-direct-seg-refs
-mvzeroupper
-fsigned-zeroes
-fdelete-null-pointer-checks
-fident
-finline-atomics
-ftrapping-math
-fbranch-count-reg
-fcombine-stack-adjustments
-fcompare_elim
-fcprop-registers
-fdefer-pop
-fforward-propagate
-fguess-branch-probability
-fhoist-adjacent-loads
-fif-conversion
-fif-conversion2
-finline
-finline-functions-called-once
-fipa-profile
-fipa-pure-const
-fipa-reference
-fmerge-constants
-fmove-loop-invariants
-fomit-frame-pointer
-fshrink-wrap
-fsplit-wide-types
-fssa-phiopt
-ftoplevel-reorder
-ftree-bit-ccp
-ftree-ccp
-ftree-ch
-ftree-coalesce-vars
-ftree-copy-prop
-ftree-copyrename
-ftree-cselim
-ftree-dce
-ftree-dominator-opts
-ftree-dse
-ftree-forwprop
-ftree-fre
-ftree-pta
-ftree-sink
-ftree-slsr
-ftree-sra
-ftree-ter
-falign-labels
-fcaller-saves
-fcrossjumping
-fcse-follow-jumps
-fdevirtualize
-fdevirtualize-speculatively
-fexpensive-optimizations
-fgcse
-findirect-inlining
-finline-small-functions
-fipa-cp
-fipa-cp-alignment
-fipa-icf
-fipa-icf-functions
-fipa-icf-variables
-fipa-ra
-fipa-sra
-fisolate-erroneous-paths-dereference
-flra-remat
-foptimize-sibling-calls
-fpartial-inlining
-fpeephole2
-free
-freorder-blocks
-freorder-blocks-and-partition
-freorder-functions
-frerun-cse-after-loop
-fschedule-insns2
-fstrict-aliasing
-fstrict-overflow
-fthread-jumps
-ftree-builtin-call-dce
-ftree-switch-conversion
-ftree-tail-merge
-ftree-vrp
-foptimize-strlen
-ftree-pre
-finline-functions
-fgcse-after-reload
-fipa-cp-clone
-fpredictive-commoning
-ftree-loop-distribute-patterns
-ftree-loop-vectorize
-ftree-partial-pre
-ftree-slp-vectorize
-funswitch-loops
-fassociative-math
-fcx-limited-range
-ffinite-math-only
-freciprocal-math
-funsafe-math-optimizations

Note que somente com as opções -O3 e -Ofast podemos ter o recurso de vetorização de um loop que, é claro, poderia ser “ligada” usando a chave -ftree-loop-vectorize, mas, de qualquer maneira, Essas duas opções oferencem muitas vantagens, tanto do ponto de vista da performance e do tamanho do código gerado pelo compilador.

Se você usa apenas a opção -O2, note que algumas coisas não são oferecidas, como o recurso de organizar funções de pouco uso como inline, algumas otimizações inter process também são são realizadas e além da vetorização, algumas eliminações de sub expressões globais e da previsão de loops (para auxiliar no branching predition) podem ficar seriamente comprometidas.

Tá certo que a opção -Ofast só é realmente útil em dois pontos: Quando não precisamos da conformidade estrita com IEEE 754 e quanto otimizações ainda mais agressivas (porém “unsafe”) sejam interessantes. Eu evitaria o -Ofast a não ser que você saiba o que faz. Mas adotaria -O3 como padrão.

UPDATE: Eu já tinha observado isso antes mas fiz questão de fazer alguns testes… Ao que parece, -O2 realmente gera código mais rápido que -O3 ou -Ofast!

strlen_sse42() usando função “intrínseca”.

Pode parecer que a instrução PCMPISTRI seja meio complicada de usar em C, já que ela oferece dois resultados diferentes: ECX, contendo um índice de acordo com a comparação, e os flags. Mas, felizmente, o valor de ECX será 16 se InRes2 estiver totalmente zerado! Assim, a função anterior, escrita em assembly, pode ser reescrita em C assim:

/* test.c

  Compilar com:
    gcc -Ofast -msse4.2 -o test test.c
*/
#include <stddef.h>
#include <x86intrin.h>

size_t strlen_sse42_c(const char *s)
{
  unsigned int index;
  size_t result;
  static const char ranges[16] = { 1, 255 };

  result = 0;
  do
  {
    index = _mm_cmpistri(*(__m128i *)ranges, 
                         *(__m128i *)s,
                         _SIDD_UBYTE_OPS         | 
                         _SIDD_CMP_RANGES        | 
                         _SIDD_NEGATIVE_POLARITY |
                         _SIDD_LEAST_SIGNIFICANT);

    result += index;
    s += sizeof(__m128i);
  } while (count == 16);

  return result;
}

O código final ficará semelhante, mas menos performático, ao anterior:

bits 64

section .rodata

  align 16
_ranges:  db 1, 255
          times 14 db 0

section .text

global strlen_sse42:
  align 16
strlen_sse42:
  movdqa xmm0,[_ranges]
  xor    eax,eax

.loop:
  pcmpistri xmm0, [rdi], 0x0_01_01_0_0  
  mov    edx,ecx
  add    rdi,16
  add    rax,rdx
  cmp    ecx,16
  jz     .loop
  
  ret

Algumas diferenças óbvias: a instrução MOVDQA é mais rápida que MOVDQU e exige que o array _ranges esteja alinhado. Eu deveria ter previsto isso no código em assembly no artigo anterior… O compilador escolheu fazer DUAS comparações, como instruído. Como não temos como verificar o flag ZF à partir da função intrínseca _mm_cmpistri, só nos restava comparar o valor retornado com 16.

Agora… é evidente que PCMPISTRI só está disponível se seu processador suportar SSE 4.2. Um método bem simples de usar essa função OU a função padrão do compilador é este:

#include <stddef.h>
#include <string.h>
#include <x86intrin.h>

// Daqui para frente, strlen será chamada por esse ponteiro!
size_t (*__strlen)(const char *);

static size_t strlen_sse42_c(const char *s)
{ ... }

// Esse atributo faz com que a função seja executada
// ANTES de main(). É interessante ter apenas uma dessas
// funções em seu programa, embora o atributo permita definir
// a ordem de execução...
static __attribute__((constructor)) void ctor(void)
{
  if (__builtin_cpu_supports("sse4.2"))
    __strlen = strlen_sse42_c;
  else
    __strlen = strlen;
}

As chamadas a __stlen, evidentemente, serão sempre indiretas, mas assim você garante a compatibilidade entre processadores ao usar a rotina. Além do mais, a quase totalidade das funções da libc são chamadas de forma indireta, já que localizam-se em libc6.so.

strlen() usando SSE 4.2

Há uns meses atrás (ou será que já faz um ano?) topei com uma dica do amigo Cooler, em seu blog, mostrando como comparar duas strings usando SSE 4.2. A rotina é realmente rápida porque compara 16 bytes de cada vez, ao invés de byte por byte ou, até mesmo, dword por dword (ou qword, se otimizarmos para o modo x86-64). Confesso que não tinha nenhuma intimidade com a instrução PCMPISTRI, disponibilizada pelo SSE 4.2 e fiquei maravilhado com o achado.

A rotina que ele publicou foi essa (publico aqui apenas a versão x86-64):

; Original code by Cooler_  c00f3r[at]gmail[dot]com
bits 64
section .text

global strcmp_sse42

; int strcmp_sse42(char *ptr1, char *ptr2);
strcmp_sse42:
  mov    rax,rdi
  sub    rax,rsi
  sub    rsi,16
   
strloop:
  add    rsi,16
  movdqu xmm0,[rsi]
  pcmpistri xmm0,[rsi+rax],0b0011000
  ja     strloop
  jc     blockmov
  xor    eax,eax
  ret
  
blockmov:
  add    rax,rsi    
  movzx  rax,byte [rax+rcx]
  movzx  rsi,byte [rsi+rcx]
  sub    rax,rsi    
  ret

O detalhe, é claro, está na instrução PCMPISTRI e no byte de controle… Ele torna a instrução bastante flexível e, ao mesmo tempo, difícil de entender… Eis uma outra rotina da libc que pode ser acelerada (de novo, para x86-64, mas, agora, com meus comentários):

bits 64

section .rodata
; Pares de bytes contendo faixas (ranges).
  align 16
_ranges:
  db 1, 0xff      ; Faixa entre (char)1 e (char)0xff.
  times 14 db 0   ; As demais "faixas" não são "usadas".

section .text

global strlen_sse42

; size_t strlen_sse42(const char* s1)
  align 16
strlen_sse42:
  xor ecx,ecx           ; Necessário. Garante que os bits
                        ; superiores, [63:5], de RCX 
                        ; estejam zerados.

  lea rax,[rdi-16]
  movdqu xmm0,[_ranges]

.loop:
  add rax,16

  ; Compara os 16 bytes do buffer apontado por RAX
  ; Com os pares de faixas em XMM0...
  ;
  ; PCMPISTRI é "Packed Compare Implicit-ending String 
  ; returning Index" ou algo assim... "implícito" 
  ; significa string terminada com '\0'.
  ;
  ; O byte de controle da instrução representa:
  ;
  ; +-+-+---+---+-+-+
  ; |0|0|0 1|1 0|0|0|-----------> 0 = char
  ; +-+-+---+---+---+             1 = short
  ;    |  |   |  |
  ;    |  |   |  +--------------> 0 = unsigned
  ;    |  |   |                   1 = signed
  ;    |  |   |
  ;    |  |   |                   00 = Equal any
  ;    |  |   |                   01 = "Ranges"
  ;    |  |   +-----------------> 10 = Equal each (string compare)
  ;    |  |                       11 = Equal ordered
  ;    |  |
  ;    |  |                       00 = Positive Polarity
  ;    |  +---------------------> 01 = Negative Polarity
  ;    |                          10 = Masked (+)
  ;    |                          11 = Masked (-)
  ;    |
  ;    +------------------------> 0 = Least significant index
  ;                               1 = Most significant index

  pcmpistri xmm0,[rax],0b0_01_01_0_0  ; LSB InRes2 offset; 
                                      ; InRes2=~InRes1; 
                                      ; Range compare; 
                                      ; unsigned char.

  jnz .loop   ; ZF=1 só se um dos bytes de [rax] for 0 
              ; (o 'i' do mnemônico pcmp(i)stri garante isso).
              ; ECX é índice (dos 16 bytes) onde achou o '\0'.
              ; (o 'i' de pcmpistr(i) garante isso).

  add rax,rcx

  lea rdx,[rdi]
  sub rax,rdx
  ret

Bem… Como é que PCMPISTRI funciona? Ela toma dois valores de 16 bytes (sim! bytes!) e os comparara de acordo com os bits de 0 a 3 do valor imediato, segundo o comentário que você pode ver acima. No caso de usarmos “unsigned char”, cada byte comparado setará ou zerará um bit de um registrador interno chamado InRes1. Depois que a comparação for feita, podemos inverter ou não o valor contido em InRes1 e colocá-lo em InRes2 – a isso é dado o nome de “polarização” (informada nos bits 4 e 5).

No caso da instrução PCMPISTRI, o bit 6 diz como o valor de ECX será calculado. Isso é feito fazendo uma varredura em InRes2 e, o primeiro bit setado, encontrado (do primeiro ao último ou vice-versa) é colocado em ECX. Se todos os bits estiverem zerados, ECX retornará 16 (será?), mas o flag ZF também estará zerado por causa do I no final do nome do mnemônico (ou seja, a instrução não achou um fim-de-string, um char ‘\0’).

Na rotina acima eu peço para PCMPISTRI comparar cada byte da string com uma faixa de bytes variando de 1 a 255. Os valores zerados na faixa nao contam porque PCMPISTRI irá parar a busca ao encontrar um ‘\0’, de qualquer maneira…

Só um aviso… existem instruções similares, como PCMPESTRI, onde o tamanho da string tem que ser informado no par EDX:EAX. Existem também PCMPISTRM e PCMPESTRM que retornam uma “máscara”, isto é, o próprio InRes2.

Outra dica importante: Essas instruções são feitas para lidar com strings e, por isso, a Intel não impõe a necessidade dos dados estarem alinhados em 16 bytes, como é comum com as instruções SSE. Alinhei _range acima só por vício mesmo… :)