Atari 2600: Microprocessador 6507 (parte 2)

O processador do Atari 2600 é uma versão resumida do MOS Tech 6502, usando no Apple ][ e no Commodore 64 (de fato, esse último usava o 6510, que é uma versão melhor do 6502!). As diferenças são que o 6507, do Atari, não possui pinagem para lidar com interrupções — nem IRQs, nem NMIs — e muito menos com DMA, o que torna o circuito do Atari muito simples. Ele também possui um barramento de endereços de apenas 13 bits, resultando num espaço de endereçamento máximo de 8 KiB. No mais, ele é essencialmente um 6502.

Embora não tenhamos interrupções e DMA, o 6507 (assim como o 6502) possui um pino chamado RDY (de Ready) que, quando é zerado, faz com que o processador pare de processar instruções. Isso, como veremos no artigo sobre o TIA, será bem útil… Normalmente esse pino é ligado à Vcc e o processador fica processando instruções o tempo todo…

A memória, para o 6502, é dividida em páginas de 256 bytes:

Como um endereço de memória, para o 6502, é um valor de 16 bits, os 8 bits superiores (MSB) correspondem a uma “página” da memória. Assim, o endereço absoluto $F000 está na página $F0 (ou página 240). Esse conceito é importante porque existem duas página especiais para o 6502:

  • Página zero: Endereçar um byte nessa página cria instruções menores porque apenas o LSB do endereço é fornecido no operando;
  • Página 1: É onde fica a pilha usada pelo processador (não tem como mudar isso!).

Como vimos na parte 1, a página 1 é um espelho da página 0 na arquitetura do Atari 2600, sendo que apenas os 128 bytes superiores são usados para RAM. Isso permite endereçamento da RAM via página zero e o ajuste do stack pointer, que usa sempre a página 1.

Registradores:

O 6502 possui apenas 3 registradores que podem ser considerados como “de uso geral”: A, X e Y. Desses, o acumulador (A) é especializado: Todas as instruções lógicas e aritméticas são sempre feitas usando A, nunca usando X ou Y, exceto:

  • X ou Y podem ser usados como índices do operando da instrução que acessa memória;
  • X ou Y podem ser incrementados e decrementados via instruções específicas.

Note que você não pode usar instruções de adição, subtração, AND, OR ou XOR (não existe NOT) com os registradores X ou Y.

Além desses 3, temos o registrador P (de Processor status word), que acomoda os flags N (negativo), V (oVerflow), C (Carry), Z (Zero); bem como os flags de desabilitação de interrupção (I), que é inútil no 6507; o flag de aritmética decimal (D), usada para manipular BCD; e o flag de BREAK (read only), que indica se uma instrução BRK foi executada.

Temos também o registrador S (de Stack pointer), que contém o offset da página 1. Diferente de outros processadores, o 6502 sempre aponta para uma posição não usada da pilha. Instruções como PHA (PusH A) gravam A na posição $0100+S e depois decrementa S. PLA (PulL A) faz o contrário: incrementa S e depois pega o byte apontado por $0100+S e coloca em A.

O único registrador de 16 bits é o PC (Program Counter), que contém o endereço da próxima instrução que será executada.

Instruções e modos de endereçamento:

Uma instrução em assembly, no 6502, corresponde a um mnemônico e um operando (que pode ser opcional para certas instruções). Por exemplo, a instrução de carga de A é LDA (de LoaD Accumulator), mas carregar A com o quê? Muitas instruções desse tipo aceitam os seguintes operandos:

  • Imediato: O valor que será usado faz parte da instrução e segue, imediatamente, o opcode. Por exemplo, se quero carregar A com o valor 2, faço: LDA #2. Esse # ai é a maneira de indicar que estamos lidando com valores imediatos e, em certos assemblers, ele pode ser substituído pelo símbolo =.
  • Página zero: O argumento é um endereço na página zero. LDA $1B carregará A com o byte que está no endereço $001B. Note que não temos o # na frente do valor em hexadecimal, mas um $… esse $ não é uma designação de ponteiro, mas um marcador que diz que o valor que segue é hexadecimal (é o ‘0x’ do C)… Note que a mesma instrução poderia ter sido escrita como LDA 27.
  • Absoluto: O argumento é um endereço absoluto, ou seja, de 16 bits. A sintaxe e semântica é exatamente a mesma do modo “página zero”, acima: LDA $2000. A diferença é que essa instrução usa 2 bytes como operando, ficando maior que a instrução que use “página zero”.
  • Página zero, indexado: Podemos usar o registrador X (e só ele) como um índice que será somado a um endereço base para obter o endereço de onde o byte será lido. LDA $80,X lerá o byte contido em $0080+X e colocará em A. Esse modo não usa jamais o registrador Y. As únicas exceções a essa regra são as instruções LDX (LoaD X) e STX (STore X), que não podem usar o próprio X como índice, então usa Y.
  • Absoluto, indexado: Mesma coisa que o indexado acima, mas permite o uso de X ou Y como indexador. Exemplo: LDA $2000,X ou LDA $2000,Y. No caso de LDX, LDY, STX e STY o mesmo registrador que está sendo manipulado não pode ser usado como índice.
  • Os modos indiretos: Aqui o operando indica o endereço onde o endereço, absoluto, onde o dado está é lido. Como não temos como armazenar ponteiros em registradores (que são todos de 8 bits), então é necessários armazená-los num endereço da página zero (sempre lá!) e usar um dos modos indiretos para acessar os dados apontados. Eis os dois modos de endereçamento: LDA ($00,X) e LDA ($00),Y.O primeiro soma X ao endereço de página zero fornecido na instrução para obter o endereço (na página zero) onde a instrução lerá o endereço que será usado para ler o byte e colocá-lo em A (ufa!)… A segunda, lê o endereço absoluto que está contido na página zero e soma-o a Y, usando-o para ler o byte e colocá-lo em A.

Quanto ao conjunto de instruções, ele é bem simplificado:

As instruções de movimentação são LDA, LDX e LDY para carregar os respectivos registradores; STA, STX e STY para armazenar os respectivos registradores na memória. TAX, TAY, TXA e TYA para mover o conteúdo de A para X, A para Y, X para A e Y para A, respectivamente. Ainda temos TXS e TSX, onde, você já deve ter adivinhado, X é movido para S e S para X, respectivamente.

Temos 4 instruções de manipulação de pilha: PHA e PLA, onde A é empurrado (Push) ou puxado (Pull) da pilha; PHP e PLP, faz o mesmo com o registrador P.

Apenas cinco instruções aritméticas: ADC e SBC, onde A é adicionado com o operando e também com o flag carry (A := A + op + C), o mesmo vale, mas para subtração (A := A – op – C)… Note que não existem instruções de soma e subtração que desconsiderem o carry como argumento de entrada.

Também temos as instruções de comparação CMP, CPX e CPY, que realizam uma subtração entre A, X ou Y, respectivamente, e o operando (sem considerar o carry, como SBC), mas afetam apenas os flags.

Duas instruções, cada, de incremento e decremento dos registradores X e Y: INX, INY, DEX, DEY. Repare que, se quisermos incrementar X ou Y em mais de uma unidade teremos que usar mais de uma dessas instruções, como em:

  ; faz X := X + 4.
  inx
  inx
  inx
  inx

Ou fazer algo assim (se não precisarmos preservar A):

  txa      ; A := X
  clc      ; zera carry
  adc #4   ; A := A + 4
  tax      ; X := A

Ambos fragmentos consomem a mesma quantidade de ciclos de clock, mas a segunda tem as desvantagens de modificar A e de usar 1 byte a mais.

Temos duas outras instruções de incremento e decremento, que afetam apenas conteúdo de memória: INC e DEC.

Instruções lógicas: AND, ORA e EOR, que fazem, respectivamente, AND, OR e XOR entre A e o operando (que pode ser o próprio e, neste caso, é comum que o operando não seja informado. Ainda, temos ASL (Arithmetic Shift Left) e LSR (Logical Shift Right) que fazem shift para a esquerda e direita, respetivamente, colocando 0 no lugar do bit vago… Também temos instruções de rotação ROL e ROR, que fazem rotação de 9 bits (o bit C, dos flags, é usado como o 9º bit [msb]).

Repare que não existe uma instrução NOT, já que essa pode ser obtida facilmente com EOR: EOR #$FF.

Existe uma instrução lógica extra chamada BIT que copia os bits 7 e 6 do operando para os flags N e V, respectivamente, e seta o flag Z com a operação AND entre A e o operando.

Instruções de salto: JMP, JSR, RTS (salto incondicional, salto para sub-rotina [Jump to Sub Routine] — usando a pilha e retorno de sub-rotina [ReTurn from Sub routine] — também usando a pilha), e a série de “pulos” (branch) condicionais: BEQ, BNE, BCC, BCS, BMI, BPL, BVC e BVS. Onde EQ (equal, ou Z=1), CC (Carry cleared, C=0), MI (minor, ou N=1), PL (plus, ou N=0), VC (Overflow cleared, V=0)… CS e VS você pode imaginar o que sejam com base nessa lista, não?

Os branches, além se sempre serem condicionados aos flags, têm como operando um endereço de salto é relativo do registrador PC e que tem apenas 1 byte. Assim, “pulos” condicionais só podem saltar de -128 a +127 bytes em relação ao endereço da próxima instrução. Ou seja, seus loops não podem ser muito grandes sem que você use algum “macete”.

Algumas instruções para manipulação direta de flags, como CLC, SEC, CLD e SED, CLI  e SEI. Mais sobre flags no próximo tópico…

E, por fim, existem instruções que raramente (ou nunca) serão usadas: RTI (ReTurn from Interrupt não tem sentido, num 6507, a não ser que tenhamos uma rotina de “break”) e BRK (que funciona como se fosse uma interrupção que salta para o endereço que conta dos 2 últimos bytes no espaço de endereçamento do processaor).

Uma lista completa das instruções e seus op-codes, bem como os modos de endereçamento permitidos para cada uma delas, pode ser visto aqui.

Instruções que afetam os flags:

Diferente de outros processadores, não são apenas as instruções aritméticas e lógicas que afetam os flags. Por exemplo, as instruções de carga (LDA, LDY, LDX, TAX, TAY, TXA, TYA e TSX), ao carregar A, X ou Y, afetam os flags de sinal (N) e zero (Z):

LDA #0   ; Z=1, N=0
LDX #$80 ; Z=0, N=1

Note que o flag N é sempre a cópia do bit 7 do registrador. Ele pode ser usado para determinar se a representação em complemento 2 resultou num valor negativo ou não.

As únicas instruções que não afetam flags são as de salto (todas, incluindo branches, exceto RTI), push (PHA e PHP), as de armazenamento (STA, STX e STY) e a instrução TXS.

Reset e Power-up:

O 6502 (e o 6507) leem os penúltimos 2 bytes na memória (em $FFFC, no 6502; $1FFC no 6507) e usam esses 2 bytes como o endereço onde o processador vai começar a processar instruções. Este é o “vetor de reset”. Os últimos 2 bytes são o “vetor de interrupção mascarável”, que é usado quando uma interrupção é pedida ou pela instrução BRK (ela empilha o endereço de retorno e o registrador S, depois salta para o endereço contido em $FFFE [$1FFE], como se fosse uma interrupção). O 6502 também usa o vetor de NMI, em $FFFA, mas esse não está presente no 6507.

Não há garantias a respeito do estado inicial do processador depois de um power-up ou reset a não ser pelo flag B estar zerado e o processador realizar essa leitura de $FFFC ($1FFC) e saltar. Todos os outros registradores (A, X, Y, S e P) podem conter “lixo”. Daí, é prudente que, no início do código, algum ajuste seja feito, como, por exemplo:

_init:
  sei   ; desabilita interrupções mascaráveis (I=1).
  cld   ; desabilita o modo bcd (D=0)
  ... continua aqui ...

Mesmo que o 6507 não tenha linhas de interrupção, é conveniente desabilitá-la setando o flag I. E, é claro, queremos trabalhar com valores binários (fazndo D=0).

Exemplo de rotina:

O 6502 não possui instruções especializadas de multiplicação e divisão, então, se quisermos fazer isso, temos que criar nossas próprias rotinas. Eis um exemplo de rotina de multiplicação de dois valores de 8 bits, resultando em um de 16 bits (a rotina não está “otimizada”):

      ORG $80
; Repare que essas "variáveis" estão na página 0!
temp:  .word
a_tmp: .byte

      ORG $1000  ; início da EPROM (4 KiB).

...

;=====================
; Multiplicação SEM sinal de A com X,
; Resposta (em 16 bits) em X (MSB) e A (LSB).
;
: XA = A * X.
;=====================
MULT:
      CMP #0
      BEQ .zero   ; A == 0? sai com X,A=0
      CPX #0      ; Testa se X == 0
      BEQ .zero   ; X == 0? sai com X,A=0

      ; temp = a_tmp = A.
      STA temp
      STA a_tmp

      ; zera MSB de temp.
      LDA #0      ; A := A XOR A.
      STA temp+1

.loop:
      DEX         ; X := X - 1
      BEQ .exit   ; X == 0? Sai do loop.

      ; temp = temp + A.
      LDA a_tmp
      CLC
      ADC temp
      STA temp
      LDA temp+1   ; LDA NÃO afeta Carry!
      ADC #0       ; Aproveita o Carry da soma anterior!
      STA temp+1

      JMP .loop

.exit:
      ; Carrega X e A de temp.
      LDA temp
      LDX temp+1
      RTS

.zero:
      LDA #0
      TAX
      RTS
...

Atari 2600: Criando um jogo (parte 1)

Graças a um conhecido do grupo do Discord do mente binária, interessei-me (com um atraso de uns 40 anos!) na arquitetura e desenvolvimento para o Atari 2600. Esse “console” é aquele que foi muito vendido no Brasil, no final dos anos 70 e início dos 80, sob o simples nome de “Atari”.

Qual é o processador do bicho? O circuito é todo dedicado? Qual é o circuito do “cartucho”? Quais são as limitações? Pode colocar mais que um joystick nele?

Passados quase 50 anos de seu lançamento (e o fim da empresa que o criou — faliu em 2013), todo o circuito, as infos do único chip dedicado e as especificações foram liberadas para domínio público… Essencialmente, o “console” tem um processador 6507, que é um 6502 “capado” (com apenas 13 linhas de endereço e sem suporte a interrupções); um chip de I/O (PIA 6532 – ou “Riot”) e um chip (TIA 6526 – não confundir com o CIA 6526) que manipula os sinais de TV e audio. Quanto ao “cartucho”, consiste de apenas uma EEPROM de, no máximo, 4 KiB e um circuitinho lógico para decodificar os endereços nos 4 KiB superiores (o limite do tamanho de um cartucho que, como veremos mais tarde, existem meios de sobrepujar).

Não vou mostrar o circuito aqui, mas todo ele pode ser encontrado neste site. E, neste link, temos também referências para o funcionamento do circuito (bem como a especificação do TIA 6526), ou seja, este aqui.

O mapa da memória do Atari:

Com apenas 13 linhas de endereços (de A0 até A12), o 6507 pode endereçar apenas 8 KiB de memória. Isso pode parecer estranho, já que a sequência de reset/power-up do 6502 exige a leitura do endereço $FFFC, para obtenção da posição de salto… Esse endereço tem 16 bits de tamanho, não 13! Por causa disso, os 3 bits superiores são ignorados e o espaço de endereçamento fica entre $0000 e $1FFF. Onde o espaço entre $0000 e $0FFF pertence ao console, já o espaço entre $1000 e $1FFF, ao cartucho… Esse espaço de 8 KiB é “espelhado” em todo o espaço de 16 bits… Assim, é seguro seus programas se limitarem aos 8 KiB. No caso do código de inicialização do programa (que sempre fica no cartucho – o Atari não tem memória!), é mais que seguro fazer algo assim (se usar um cartucho de 4 KiB):

  org $1000   ; eprom de 4 KiB!
start:
  ldx #$ff    ; inicializa a pilha.
  txs
  ...

  org $1ffc
  .word start ; vetor de reset.
  .word 0     ; vetor de breakpoint (não usado).

Geralmente os códigos que se vêem por ai usam o endereço $F000 como início do cartucho, mas, como vimos, isso é opcional…

O Atari só tem 128 bytes (sim! bytes!) de RAM:

Na verdade não há nenhum chip com RAM estática no Atari. O chip dedicado para I/O (o Riot) disponibiliza uma RAM de 128 bytes para uso do 6507. Esse é o único espaço disponível para a pilha e para suas “variáveis globais” e esse espaço é compartilhado por ambos. Assim, rotinas recursivas ou que fazem muitas chamadas são proibitivas no Atari… Vale lembrar que a pilha é composta apenas de um espaço, máximo, de 256 bytes (o registrador S é de 8 bits) e sempre localiza-se entre os endereços $0100 e $01FF… Já a manipulação da pilha, nos MOS-Tech 6500 é diferente do que é feito nos processadores Intel. O registrador S sempre contém o endereço (o offset da “página 1”) da entrada livre (ainda não escrita) da pilha. Ao executar a instrução PHA, por exemplo, o registrador A é escrito em $0100+S e depois S é decrementado, apontando para a próxima posição “livre” da pilha.

Note que em processadores como o 80×86 isso não é assim: Lá, ESP aponta para a posição usada por último… um PUSH EAX primeiro decrementa ESP (ESP = ESP – 4) e depois salva o EAX em [ESP]…

No caso da família 6500, apenas a página 1 (o MSB do endereço é a “página”) pode ser usada como pilha e, portanto… Mas, como temos que usar essa região como RAM para manter estados do programa, é conveniente que se reserve espaço para os estados e para a pilha, não ultrapassando-os NUNCA (felizmente, o 6507 não suporta interrupções, o que facilita a coisa).

Não é possível usar mais RAM! O conector do cartucho não fornece sinais de controle que permitam o circuito externo saber se estamos lendo ou escrevendo. Ele só fornece os 13 bits do barramento de endereços, os 8 do de dados e a alimentação (+Vcc e GND), num total de 23 pinos (24, um deles é inutilizado).

Graças ao esquema de decodificação de endereços do circuito do Atari, o mapa de memória fica assim:

0000 - 007F: TIA registers
0080 ~ 00FF: RAM
0180 ~ 01FF: espelho da RAM
0280 ~ 02FF: RICO registers
1000 ~ 1FFF: EPROM (4 KiB)

Os “buracos” na memória abaixo de $1000 costumam ser “espelhos” das demais regiões porque o circuito decodifica A12, A9, A7 apenas. O bit A7 seleciona entre o TIA e o RICO e bit A9 seleciona entre a RAM e os registradores do RICO:

Para A12 = 0:

A9 A8 A7
 0  0  0  - TIA regs
 0  0  1  - RAM
 0  1  0  - TIA regs*
 0  1  1  - RAM*
 1  0  0  - TIA regs*
 1  0  1  - RICO regs
 1  1  0  - TIA regs*
 1  1  1  - RICO regs*

Onde os itens parcados com * são espelhos dos de mesmo nome… Repare também que os bits A10 e A11 não são importantes. Isso significa que, se A12 for 0, o padrão acima se repete, como espelhamento. Fiquemos com o mapa anterior que é mais simples de entender, ok?

Não existe uma BIOS:

Todo o programa que lida com o hardware do Atari deve estar no cartucho. O console não fornece uma ROM com rotinas prontas, como a ROM-BIOS do PC faz, por exemplo. Isso quer dizer que o programador tem que saber, necessariamente, lidar com o hardware (o TIA e o RIOT). Isso dá uma flexibilidade relativa ao desenvolvedor, mas, ao mesmo tempo, significa que o hardware não pode ser modificado nunca, senão jogos que foram feitos para o Atari parariam de funcionar…

Como não existe uma BIOS, é prudente que seu código comece com a inicialização da pilha (para ter certeza que S será #$FF), inicialização do PIA (para configurar ambas as portas A e B como entrada!) e a inicialização do TIA (que segue um padrão estrito!)… Depois disso você pode se preocupar com seu código…

Recomendo algo assim:

  ; inclui definições de nomes...
  include atari2600.inc

  org $1000  ; eprom de 4 KiB!
  extern init_pia
  extern init_tia

_init:
  ldx #$ff
  txs
  jsr init_pia
  jsr init_tia
main:
  ... seu código aqui (ele nunca "retorna") ...

  org $1ffc
  .word _init
  .word 0

As duas chamadas (via JSR) são seguras nesse ponto porque ainda não inicializamos nossas “variáveis globais” e podemos usar toda a pilha, mas usamos apenas 2 bytes de cada vez para os saltos.

A rotina de inicialização do PIA é simples:

init_pia:
  lda #0
  sta pia_ddra
  sta pia_ddrb
  rts

Onde pia_ddra e pia_ddrb são os registradores de direcionalidade das portas A e B , respectivamente. A rotina é tão pequena que nao precisamos “chamá-la”, podemos fazê-la inline para poupar espaço na EPROM. Já a inicialização do TIA também é bem simples e pode ser feita inline, com a vantagem de reusarmos o registrador X para ajustar a pilha… Basta preencher os registradores, na página 0. com zeros (veremos isso em outro artigo). Podemos aproveitar para zerar toda a RAM também e, de lambuja, inicializar o stack pointer:

  include atari2600.inc

  org $1000    ; Para EPROMs de 4 KiB!

_init:
  ; Por precaução ajusta esses flags.
  sei
  cld

  ; inicializa PIA.
  lda #0
  sta pia_ddra
  sta pia_ddrb

  ; inicializa TIA, zera a RAM e
  ; ajusta o stack pointer, aproveitando
  ; que A é 0.
  tax
.loop:
  sta $00,x
  txs        ; sairá do loop com S=#$FF.
  inx
  bne .loop
  ; ... continua o código aqui ...

  ...

  ; vetores no final da memória...
  org $1ffc
  .word _init  ; vetor de reset
  .word 0      ; vetor de break (não usado).

A inicialização ocupa apenas 16 bytes da EPROM e, repare, coloquei as instruções SEI e CLD no início do código. A primeira (SEI) desabilita interrupções mascaráveis, o que não tem muito sentido no 6507 (já que ele não suporta interrupções), mas essa parece ser uma prática corriqueira em todos os códigos de jogos que vi para o atari. A segunda (CLD) é uma garantia de que estamos trabalhando no modo binário… Isso é necessário porque não podemos garantir que esses flags estejam apropriadamente setados depois de um power-up ou reset.

O espaço das “variáveis”:

Já que a pilha cresce “para baixo”, a alocação das variáveis deve ser feita à partir do início da RAM. Podemos definir um RAM_START como:

RAM_START equ $80

Todas as nossas variáveis serão definidas a partir desse ponto. Suponha que você precise guardar a posição X e Y, no seu código. Poderíamos definir:

xpos equ RAM_START
ypos equ RAM_START+1

Assim, para ler a posição X é só fazer:

  lda xpos

Repare que a instrução usando zeropage será usada, diminuindo o tamanho do código que será contido na EPROM.

Como eu disse antes, tome cuidado para jamais usar a pilha além da última posição livre dessa pequena RAM de 128 bytes. Uma prática aconselhável é reservar apenas alguns bytes para a pilha e todo o resto para as variáveis e temporários. Por exemplo, uns 8 bytes para a pilha e os 120 restantes para as variáveis… Isso nos permitirá fazer 4 níveis de chamadas de sub rotinas, se precisarmos, ou empilhar até 8 bytes, descontando os empilhamentos de chamadas.

Essa prática é importante porque “ponteiros”, para a família de processadores 6500, têm sempre 2 bytes de tamanho, mesmo que a maioria de nossas “variáveis” venham a ter apenas 1 byte! Mais sobre organização de nossa RAM na 4ª parte dessa série…

Streams versus File descriptors

Quase todo novo aprendiz da linguagem C acaba aprendendo que existe um tipo mágico chamado FILE que pode ser usado para manipular arquivos (daí o nome! duh!). No entanto, esse nome de tipo é apenas uma conveniência, já que ele pode ser usado para outras coisas além de “manipular” arquivos. Exemplo, as entradas e saídas do terminal podem também ser acessadas por variáveis pré definidas stdin, stdout e stderr, todas do tipo FILE. Variáveis desse tipo são chamadas de streams, porque o biblioteca padrão de C implementa entrada e saída bufferizada, quando o usamos.

Além dos streams, existem também funções de I/O especializadas, declaradas no arquivo de cabeçalho (header file) chamado de stdio.h (de STandarD I/O). Algumas lidam diretamente com os streams stdin e stdout. Exemplos: printf é apenas um atalho para a função fprintf, onde o stream é stdout. As duas chamadas, abaixo são absolutamente idênticas:

printf( "hello, world!\n" );
fprintf( stdout, "hello, world!\n" );

Para sorte de muitos, desde tempos imemoriais, algumas funções são tão usadas que foram incorporadas (built in) no próprio compilador. No exemplo do “hello, world!” anterior seu compilador decidirá qual é a melhor função a ser chamada de acordo com a lista de argumentos. Ele, alegremente, substituirá a chamada por um simples puts ou write, no exemplo acima. Não acredita? Vejamos:

$ cc -xc -O2 -S -masm=intel -o test.s - <<EOF
#include <stdio.h>
int main(void){printf("hello, world!\n");}
EOF

$ cat test.s | sed -n '/^main/,/ret/{/^[ \t]\+\./d;p}'
main:
.LFB23:
  lea  rdi, .LC0[rip]
  sub  rsp, 8
  call puts@PLT
  xor  eax, eax
  add  rsp, 8
  ret

O label .LC0 é o ponteiro para a string…

A diferença entre streams e file descriptors:

Vale ressaltar que para obter um stream podemos usar a função fopen e essa retornará o ponteiro para uma estrutura opaca (quer dizer, cuja estrutura não pode ser conhecida) chamada FILE:

FILE *f = fopen( "teste.txt", "r" );

A estrutura não existe para ter seus membros usados por quaisquer outras funções que não as declaradas em stdio.h, e quando digo que é opaca, isso não significa que não exista uma estrutura definida, mas que ela não deve ser conhecida pelo usuário/desenvolvedor. De fato, no código fonte da glibc vemos sua definição em libio/libio.h:

...
struct _IO_FILE {
  int _flags;    /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
  /* The following pointers correspond to the C++ streambuf protocol. */
  /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
  char* _IO_read_ptr; /* Current read pointer */
  char* _IO_read_end; /* End of get area. */
  char* _IO_read_base; /* Start of putback+get area. */
  char* _IO_write_base; /* Start of put area. */
  char* _IO_write_ptr; /* Current put pointer. */
  char* _IO_write_end; /* End of put area. */
  char* _IO_buf_base;  /* Start of reserve area. */
  char* _IO_buf_end;   /* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base; /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */

  struct _IO_marker *_markers;
  struct _IO_FILE *_chain;

  int _fileno;
  int _flags2;
  _IO_off_t _old_offset; /* This used to be _offset but it's too small.  */

  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];

  _IO_lock_t *_lock;
};
...
typedef struct _IO_FILE FILE;

Dá pra ver que a estrutura ocupa algumas dezenas de bytes, sem contar com os dados apontados pelos ponteiros, mas, dentre eles encontra-se, lá no meio, escondidinho, um membro _fileno, do tipo int.

O detalhe é que toda e qualquer manipulação de arquivos (e isso vale tanto para Unix quanto para Windows) é feita através de um “file descriptor“, que nada mais é que um valor inteiro (do tipo int). Nada de ponteiros, nada de estruturas. O valor desse inteiro corresponde ao “arquivo” que se quer ler/escrever. Sob esse aspecto, um file descriptor é melhor que um stream por usar muito pouca memória e as funções que manipulam file descriptors são bem mais variadas do que aquelas que manipulam streams.

Por exemplo, para abrir um arquivo basta fazer:

int fd = open( "test.txt", O_RDWR );

Com a vantagem de que a função open é um wraper em torno da chamada de sistema (syscall) de mesmo nome. E, de acordo com a especificação, ela retorna valores positivos ou -1, em caso de erro.

Na realidade a função open é um bem mais flexível do que a equivalente fopen. O segundo argumento pode assumir diversos valores, definidos em fcntl.h e nomeados O_??? (bitmapeados e portanto devem ser adicionados via operador ‘|‘). Por exemplo, poderíamos indicar que esse descritor será fechado automaticamente se o processo for substituído por outro, via chamada para uma das funções exec, simplesmente fazendo um OR com a constante O_CLOEXEC. Ainda, se o arquivo não existir a função acima falhará, já que é necessário o bit O_CREAT para criar um arquivo que não existe… Mas, se o arquivo já existe e usarmos a combinação de O_CREAT e O_EXCL, a abertura acima falhará… Existem vários outros flags, eis alguns bem usados: O_RDWR (abre para leitura/escrita), O_RDONLY (o arquivo não pode ser escrito), O_CREAT (se o arquivo não existe, cria-o), O_TRUNC (se o arquivo existe, trunca-o!), além das citadas antes.

Outro detalhe é que open aceita um terceiro argumento para as permissões. Podemos usar a notação octal ou as constantes S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IWGRP, S_IXGRP, S_IROTH, S_IWOTH ou S_IXOTH. Por exemplo:

#include <unistd.h>
#include <fcntl.h>
...
// Se text.txt não existe, cria-o
// com as permissões rwxrw-r--
int fd = open( "test.txt", O_RDWR | O_CREAT, 0764 );

// é a mesma coisa que:
int fd = open( "test.txt", O_RDWR | O_CREAT,
               S_IRUSR | S_IWUSR | S_IXUSR |  // user: rwx
               S_IRGRP | S_IWGRP |            // group: rw-
               S_IROTH );                     // others: r--
...

Mas, e quanto a printf usando file descriptors?

Aparentemente, usando file descriptors, perdemos a capacidade de usar várias funções interessantes declaradas em stdio.h. Mas, isso não é tão ruim assim. No caso de printf, podemos usar um file descriptor com a função dprintf, que foi padronizada pelo POSIX.1-2008. Ao invés de tomar um ponteiro para FILE, como em fprintf ela toma um file descriptor.

Infelizmente, não parece existir um dscanf, mas isso é fácil de contornar.

Outro detalhe interessante é que as constantes STDIN_FILENO, STDOUT_FILENO e STDERR_FILENO são definidas no header unistd.h como 0, 1 e 2, respectivamente, e esses descritores estão automaticamente abertos em qualquer processo.

Por que os streams existem?

Já que os file descriptors são mais rápidos e ocupam menos espaço, por que os streams existem? Um stream mantém um buffer. Em teoria, o stream stdout, por exemplo, é bufferizado por linha. Isso quer dizer que ao fazer um printf, a string só vai ser impressa em duas condições:

  1. Quando o buffer (que é limitado) ficar cheio, ou;
  2. Quando for encontrado um ‘\n’ na string.

Esse é um dos motivos que é recomendável usar a função fflush no stream stdout após uma chamada que imprima strings que não tenham o ‘\n’. Como por exemplo:

...
printf( "Entre com um valor: " );
fflush( stdout );
scanf( "%d", &x );
...

Sem esse fflush( stdout ) você corre o risco da string ficar “presa” no buffer do stream stdout e seu programa parecer que ficou “travado” (quando está apenas esperando pela entrada de dados via stdin na chamada de scanf).

Mas, o motivo principal são os arquivos… Lá nos primórdios, leitura e escrita (especialmente escrita) em discos demoravam bastante. Assim, a turma do Unix preferiou usar um esquema de bufferização, deixando o maior tempo possível os dados em memória antes de “despejá-los” (flushing) no disco… Todos aqueles ponteiros lá na estrutura _IO_FILE são apenas estados de controle para um buffer, alocado dinâmicamente.

Lendo, escrevendo dados e fechando o descritor

O nome das funções de manipulação de um file descriptor são bem simples de lembrar: open, read, write, close. E seu uso também é mais simples do que as funções equivalentes da stdio.h:

int open( char *path, int flags );
ssize_t read( int fd, void *buffer, size_t size );
ssize_t write( int fd, void *buffer, size_t size );
int close( int fd );

Como vimos, open returna -1 em caso de erro ou o descritor desejado. close só toma o descritor como argumento e faz quase o mesmo (-1 = erro, 0 = sucesso). As funções read e write a mesma coisa, mas elas retornam -1 (erro) ou o tamnho lido ou gravado, respectivamente.

Você pode até escrever na tela usando write usando o descritor 1 (stdout):

#include <unistd.h>

int main( void )
{
  static char msg[] = "hello, world!\n";

  write( STDOUT_FILENO, msg, sizeof msg - 1 );
}

Eis o resultado:

$ cc -O2 -w -o test test.c
$ cc -O2 -w -S -masm=intel -o test.s test.c
$ cat test.s | sed -n '/^main/,/ret/{/^[ \t]\+\./d;p}'
main:
.LFB11:
  lea  rsi, msg.2642[rip]
  sub  rsp, 8
  mov  edx, 14
  mov  edi, 1
  call write@PLT
  xor  eax, eax
  add  rsp, 8
  ret

Ora, ora… quase a mesma coisa, não? A diferença é que EDX tem, agora, o tamanho do buffer que serã impresso no descritor informado em EDI. Com uma diferença essencial: Quem imprime é o kernel, enquanto puts necessariamente terá que procurar pelo fim da string (o NUL char), write não precisa fazê-lo (já que nós é que fornecemos o tamanho!).

File descriptors são mais versáteis que streams

Lembra do adágio “tudo no Unix é arquivo?”. Bem, isso não é bem verdade, mas a ideia vem justamrnte de que quase tudo pode ser manipulado por um “file descriptor”! I/O do terminal, arquivos, sockets, FIFOs etc. Veja o caso de sockets, por exemplo:

int fd = socket( AF_INET, SOCK_STREAM, IPPROTO_TCP );
...
close( fd );

A função socket retorna um descritor como se fosse a função open e podemos usar até mesmo as funções read e write, descritas anteriormente, para ler ou escrever, respectivamente, nesse socket (embora existam funções especializadas para isso!).

Você pode, também, usar streams para lidar com sockets do tipo SOCK_STREAM, cmo conexões via TCP:

int fd = socket( AF_INET, SOCK_STREAM, IPPROTO_TCP );
FILE *f = fdopen( fd, "rw" );
...
close( fd );

A função fdopen cria a estrutura FILE à partir de um descritor e podemos usar a facilidade de buffering das funções da stdio.h, mas elas não têm a capacidade de lidar com as idiossincrasias de um socket (coisa que funções como recv e send têm!). Mas, ai vai uma recomendação: Embora você possa fazer isso, não faça! Especialmente se o socket for do tipo SOCK_DGRAM e/ou use UDP. O problema é que UDP é connectionless, o que significa que o outro lado pode mandar um pacote e fechar o socket em seguida…

Se for lidar com dados binários, prefira file descriptors:

Tem sentido usar streams quando manipula-se texto. Funções como fgets e getline são especializadas nisso e exigem streams. Mas se for usar funções como fread ou fwrite, prefira usar file descriptors. Eles são menores e as chamdas são mais rápidas, quase que diretas ao kernel, via read e write.

No caso de arquivos, ainda temos a função lseek, que muda a posição do “cursor”, da mesma maneira que fseek da stdio.h… Você pode sentir falta da função tell, em comparação a ftell, mas ela não é necessária. O equivalente, através de syscalls é:

off_t pos = lseek( fd, 0, SEEK_CUR );

A função lseek retorna -1 (erro) ou a posição resultante à partir do início do arquivo. Note o tipo off_t. Ele é definido como long, assim como o resultado de ftell, mas também existe a função lseek64 que retorna o tipo off64_t, com a mesma semântica.

Dicas de assembly para o x86-64

Se você já tem alguma familiaridade com assembly para o modo x86-64 dos processadores da família 80×86, sabe que existem um monte de registradores de 64 bits disponíveis, que o SIMD (SSE) também tem o dobro de registradores e que existem novidades no modo de endereçamento e passagem de argumentos para funções (se usar uma calling convention padronizada). Aqui vou mostrar algumas dicas para que você evite algumas implementações ineficientes… Não que algumas práticas sejam erradas, mas elas tendem a criar imagens binárias (executáveis) maiores e potencialmente mais lentos. Meu foco é para sistemas Unix que implementem a convenção SysV ABI. Outro detalhe: Todos os fragmentos de código em assembly aqui serão na sintaxe do NASM.

Evitando criar entradas na tabela de relocação (GOT)

Quando você define um símbolo, uma variável, você está dizendo ao compilador que reserve espaço para ela, no tamanho de seu tipo. Onde essa variável será colocada na memória é tarefa para o linker decidir. Isso é verdade mesmo em assembly. Ao fazer algo do tipo:

section .bss
n:  resd 1

Está dizendo ao NASM que reserve um espaço de 4 bytes e rotule-o de “n”. O NASM não sabe onde esses 4 bytes estarão na memória. Apenas sabe que estarão no segmento .bss. Por quê? Suponha que você tenha um programa composto de dois arquivos em assembly. Os símbolos definidos no primeiro são “mesclados” com os símbolos definidos no segundo pelo linker e esse define a ordem em que esses símbolos serão arranjados.

Agora, vamos dar uma olhada nesse pequeno código, ao estilo “hello world”, em assembly para o x86-64:

; hello.asm
  bits  64

  section .rodata

hellostr:     db  `hello, world!\n`
hellostr_size equ $ - hellostr

  section .text

  global  _start
_start:
  mov   eax,1   ; SYS_WRITE syscall
  mov   edi,eax ; STDOUT_FILENO
  mov   rsi,hellostr  ; buffer ptr.
  mov   edx,hellostr_size
  syscall

  mov   eax,60  ; exit(0).
  xor   edi,edi
  syscall

Compilando e executando:

$ nasm -felf64 -o hello.o hello.asm
$ ld -o hello hello.o
$ ./hello
hello, world!

Observando a listagem de hello via objdump:

_start:
  400080: b8 01 00 00 00                 mov    eax,0x1
  400085: 89 c7                          mov    edi,eax
  400087: 48 be a4 00 40 00 00 00 00 00  movabs rsi,0x4000a4
  400091: ba 0e 00 00 00                 mov    edx,0xe
  400096: 0f 05                          syscall 
  400098: b8 3c 00 00 00                 mov    eax,0x3c
  40009d: 31 ff                          xor    edi,edi
  40009f: 0f 05                          syscall

Repare que a instrução mov rsi,hellostr é bem grande. O offset é um endereço absoluto (0x4000a4), resolvido pelo linker, mas o problema aqui é que esse programa não pode ter seu endereço de carga “randomizado”… No contexto do assembly, os endereços de símbolos nos segmentos são calculados pelo linker de forma a montar um endereço absoluto, a não ser que digamos para o NASM que o endereço deve ser relativo, mas, relativa a quê?

No caso, relativo ao registrador RIP. Sim! O instruction pointer! O linker criará uma imagem onde os segmentos são carregados em uma ordem específica, tornando possível para o programa conhecer o endereço de qualquer região da memória em seu espaço de endereçamento com base no RIP (é como dizer: este dado está a N posições do endereço da próxima instrução!). Isso pode ser obtido no NASM de duas formas:

  1. Adicionando um prefixo rel antes do “endereço”;
  2. Adicionando a diretiva default rel no início do seu código.

A segunda possibilidade torna todas referências a endereços (nos modos de endereçamento) relativos a RIP. A primeira, funciona apenas para a instrução corrente… Mas, há um problema: Não há como usar esse macete com mov rsi,hellostr. O símbolo hellostr será resolvido pelo linker como um endereço abosluto de qualquer forma, mas podemos substituí-lo por lea rsi,[rel hellostr]. Isso não só criará um endereço relativo a RIP, como criará uma instrução menor:

_start:
  400080: b8 01 00 00 00        mov    eax,0x1
  400085: 89 c7                 mov    edi,eax
  400087: 48 8d 35 12 00 00 00  lea    rsi,[rip+0x12]  # 0x4000a0
  40008e: ba 0e 00 00 00        mov    edx,0xe
  400093: 0f 05                 syscall 
  400095: b8 3c 00 00 00        mov    eax,0x3c
  40009a: 31 ff                 xor    edi,edi
  40009c: 0f 05                 syscall

No caso, RIP+18 será exatamente 0x4000a0. Repare que RIP é sempre o endereço da próxima instrução, neste caso 0x40008e. Agora, esse código pode ser “randomizado” à vontade.

Mas, onde diabos está a relocação?! Quando você compila um programa em C, para o x86-64, o compilador tende a criar código “independente de posição” (PIC). Assim, todas as referências a símbolos globais tendem a ser calculados como offsets em relação aos seus segmentos e uma tabela de relocação, que é um array do offset onde as referências estão, é criado… o loader do executável (o sistema operacional) coloca a imagem binária na memória, na ordem correta, e depois percorre a “tabela de offsets globais” (GOT, de Global Offsets Table) adicionando o endereço onde a imagem foi carregada na memória a essas referências.

O GCC, por exemplo, prefere criar um “executável independente de posição” (PIE, Position Intependent Executable), que usa referências em relação a RIP, diminuindo muito o tamanho da tabela GOT.

Existe uma desvantagem em usar referências relativas a RIP: O offset só pode ter 32 bits de tamanho (para frente ou para trás). Qualquer referência maior que ±2 GiB em relação ao RIP não usará endereçamento relativo, mas absoluto. Felizmente isso é extremamente raro.

Evite usar os registradores de uso geral, de 64 bits

Eles estão lá, mas para usá-los o compilador precisa acrescentar um prefixo REX na instrução, bem como estender qualquer valor imediato ou referências à memória (exceto endereçamento relativo a RIP, como vimos acima). Isso cria instruções maiores e, algumas vezes, desnecessárias. Por exemplo, para zerar RAX vocẽ poderia fazer: xor rax,rax, e eis o que o NASM criará xor eax,eax.

Em primeiro lugar, usar registradores E?? gera instruções menores (sem o prefixo REX), em segundo, sempre que alguma instrução manipular E??, os 32 bits superiores de R?? são automaticamente zerados. Esse foi o motivo de eu usar EAX, EDI e EDX no código exemplo hello.asm. Se você der uma olhada na lista de system calls para x86-64 (aqui), verá que os registradores deveriam ser RAX, RDI e RDX, de acordo com a convenção de chamada SysV ABI, mas, para quê usar instruções que lidem com R??, se as partes superiores dos valores contidos nesses registrador serão zeradas de qualquer forma?

Não faz mal usá-las, se você deixar o NASM com sua opção de otimização default (-Ox), mas se desabilitar otimizações as instruções serão maiores do que deveriam.

No mesmo exemplo hello.asm, note que usei mov rsi,hellostr (ou a versão melhor: lea rsi,[rel hellostr], para aproveitar o RIP). Em ambos os casos o compilador colocará um prefixo REX (0x48) antes da instrução porque o destino é um registrador R??. Aqui tive que fazer isso porque o endereço do buffer, em teoria, pode estar em qualquer lugar da memória… Mas, não é incomum você observar códigos gerados pelo GCC que use ESI nesse caso. Ele assume que o endereço virtual de carga será sempre de 32 bits (eu não acho que isso seja prudente!) e zera a parte superior de RSI.

Note que, no modo x86-64, o processador espera que, no modo de endereçamento, registradores de 64 bits sejam usados. Se usarmos de 32 ele colocará o prefixo 0x67 na instrução. É interessante saber disso ao vermos o código abaixo:

; função em C original:
;   int f(int x) { return x+x; }
f:
  lea eax,[rdi+rdi]
  ret

O tipo de x é int, então tem 32 bits, mas o compilador preferiu fazer RDI+RDI para evitar colocar um prefixo 0x67 na instrução… No final das contas o resultado será 32 bits (destino: EAX) e os 32 bits superiores de RAX estarão zerados!

Evite use saltos relativos a $

Em listagens para o modo real, às vezes, era comum temos instruções como:

jmp $+2

O símbolo $ é usado para designar o endereço corrente da instrução, o ‘+2’ faz com que o endereço calculado seja o da próxima instrução depois do jmp (assumindo que esse jmp com salto relativo tenha 2 bytes de tamanho). Isso ainda funciona, no modo x86-64, acontece que, hoje, instruções como jmp suportam saltos relativos (a RIP) em 16, 32 ou 64 bits. Sem saber qual dessas o compilador vai gerar, o cálculo do endereço relativo pode ficar errado e você só saberá dando uma olhada no código gerado.

Repare que usei o $ para obter o endereço do “próximo” byte depois da string referenciada pelo símbolo hellostr na definição:

hellostr_size equ $ - hellostr

Isso é perfeitamente válido, já que o que eu quis é o cálculo do tamanho da string. Não estou usando $ para o cálculo do endereço efetivo, mas apenas para colher a diferença desta e da outra posição da memória.

Ainda… No NASM, o símbolo $ equivale à posição (não ao endereço) da linha corrente. $$, por outro lado, equivale à posição relativa ao início do segmento. A diferença é sutil: Você pode pensar em $ como sendo sempre 0 e $$ como sendo o tamanho do segmento usado até a sua referência (exceto que $, usada numa expressão, como $ - hellostr resultará na “posição corrente” menos a posição de hellostr. Evite usar $ para cálculos de endereços efetivos relativos, a não ser que você saiba o que está fazendo.

Alinhe sem código

A manipulação do uso do cache L1 não é útil apenas para acesso a dados, mas também para a execução de seu código. É prudente que alguns pontos de entrada sejam alinhados para que a maior parte de seu código caiba numa única linha do cache L1i… Aqui é prudente observar que existem dois caches L1: Um para dados (L1d) e um para código ou instruções (L1i), ambos com 32 KiB de tamanho e linhas de 64 bytes.

A Intel recomenda que os pontos de entrada de funções estejam alinhados em 16 bytes (ou seja, que os 4 bits menos significativos do endereço sejam zero!) e que pontos de entrada de loops sejam, pelo menos, alinhados por DWORD (4 bytes). Bem… essa última recomendação não é bem da Intel, mas é uma dedução empírica para o melhor uso possível do cache.

Sabendo que uma instrução não pode ter mais que 15 bytes de tamanho sem que haja uma exceção, a primeira recomendação faz sendido, mesmo porquê o alinhamento a cada 16 bytes garante que a primeira instrução esteja no início de uma das vias de associatividade de uma linha de cache (isso é complicado explicar!)… A segunda recomendação garante que algum alinhamento exista (para evitar que o loop começe num endereço ímpar) e não causa muita perda no uso da memória (no máximo, 3 NOPs)… A adição de NOPs, neste caso, não causa atrasos, já que o reordenador de instruções, contido no seu processador, se livrará das instruções excedentes assim que detectar um loop (e seu processador faz isso sempre).

Assim, considere a função abaixo:

  bits 64
  default rel

  section .text
; int f( int *p, size_t sz )
; { 
;   int sum = 0;
;
;   while ( sz-- )
;     sum += *p++;
;
;   return sum;
; }
  global f
f:
  xor  eax,eax
.loop:
  test rsi,rsi  ; size_t tem 64 bits!
  jz   .exit
  add  eax,[rdi]
  add  rdi,1
  sub  rsi,1
  jmp  .loop
.exit:
  ret

Se compilarmos esta rotina com o NASM e obtermos a listagem veremos que o label f parece estar alinhado, mas isso é falso, porque o endereço fornecido é “virtual” (VMA), para ser resolvido pelo linker, que poderá colocar a função num endereço ímpar, se quiser. Isso fará com que os demais labels possam ser colocados em endereços ímpares e/ou não alinhados. Vejamos como fica a rotina acima, adicionadas diretivas de alinhamento em pontos de saltos:

  bits  64
  default rel

  section .text

  global  f
  align 16  ; alinha por 16 bytes...
f:
  xor  eax,eax
  align 4
.loop:
  test rsi,rsi  ; size_t tem 64 bits!
  jz   .exit
  add  eax,[rdi]
  add  rdi,1
  sub  rsi,1
  jmp  .loop
  align 4
.exit:
  ret

O compilador vai criar código assim:

                    f:
00000000 31C0         xor  eax,eax
00000002 90           nop           ; ignorados pelo reordenador
00000003 90           nop           ;
                    .loop:
00000004 4885F6       test rsi,rsi
00000007 740F         jz   .exit
00000009 0307         add  eax,[rdi]
0000000B 4883C701     add  rdi,1
0000000F 4883EE01     sub  rsi,1
00000013 EBEF         jmp  .loop
00000015 90           nop            ; jamais executados!
00000016 90           nop            ;
00000017 90           nop            ;
                    .exit:
00000018 C3           ret

Ele colocou 2 NOPs antes de .loop e 3, antes de .exit. Ainda não há qualquer dica nessa listagem sobre o alinhamento do símbolo f, mas se observarmos um dump do arquivo objeto:

$ objdump -x test.o
test.o:     file format elf64-x86-64
test.o
architecture: i386:x86-64, flags 0x00000010:
HAS_SYMS
start address 0x0000000000000000

Sections:
Idx Name   Size      VMA               LMA               File off  Algn
  0 .text  00000019  0000000000000000  0000000000000000  00000180  2**4
           CONTENTS, ALLOC, LOAD, READONLY, CODE
SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 test.asm
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000004 l       .text  0000000000000000 f.loop
0000000000000018 l       .text  0000000000000000 f.exit
0000000000000000 g       .text  0000000000000000 f

Veremos que o segmento .text neste código tem alinhamento 2⁴, ou 16.

Sua função ficou um pouquinho maior, mas, provavelmente, mais rápida porque aproveita melhor as linhas de cache… Bem… Neste caso, provavelmente, não faz muita diferença, já que todo o código tem 25 bytes de tamanho e uma linha tem 64. Assim, vale apenas apenas alinhar o ponto de entrada (f). Mas, loops maiores podem se beneficiar da técnica.

Adicionando o ARM AArch64 para o contador de ciclos.

Esse é um “headerzinho” que eu uso para medir a quantidade de clocks gastos em rotinas. Adicionei duas outras características:

  1. Se o símbolo SYNC_MEM estiver definido antes da inclusão do arquivo, coloca um mfence antes do início da contagem;
  2. Agora temos suporte para o ARM AArch64;
  3. Note que defini o tipo counter_T como volatile para impedir que o compilador otimize as chamdas.

Também alterei o código que, ao invés de usar macros, usa agora funções inline.

Eis o arquivo modificado:

#ifndef CYCLE_COUNTING_INCLUDED__
#define CYCLE_COUNTING_INCLUDED__

#ifndef __GNUC__
# error Works only on GCC
#endif

/* ==========================================
    Quick & Dirty cycle counting...

    As funções usadas para contar a quantidade de ciclos
    de clock gastos num bloco de código.

    Exemplo de uso:

      counter_T cnt;

      cnt = BEGIN_TSC();      
      f();
      END_TSC(&cnt);

    Para i386 e x86-64 defina SYNC_MEM se quiser uma
    serialização mais completa (mfence).
   ========================================== */
#include <stdint.h>

// É necessário usar esse "tipo" para os contadores
// porque o compilador vai tentar otimizar qualquer variável
// não volátil.
typedef volatile uint64_t counter_T;

#if defined(__x86_64__)
  inline counter_T BEGIN_TSC( void )
  {
    uint32_t a, d;

    __asm__ __volatile__ (
# ifdef SYNC_MEM
      "mfence\n\t"
# endif
      "xorl %%eax,%%eax\n\t"
      "cpuid\n\t"
      "rdtsc" : "=a" (a), "=d" (d) :: "%rbx", "%rcx"
    );

    return a | ((uint64_t)d << 32);
  }

  inline void END_TSC( counter_T *cptr )
  {
    uint32_t a, d;

    __asm__ __volatile__ (
      "rdtscp" : "=a" (a), "=d" (d) :: "%rcx"
    );

    *cptr = (a | ((uint64_t)d << 32)) - *cptr;;
  }
#elif defined(__i386__)
  inline counter_T BEGIN_TSC( void )
  {
    uint32_t a, d;

    __asm__ __volatile__ (

    // Precisa de SSE2 para poder usar mfence no modo i386.
# ifdef __SSE2__
#   ifdef SYNC_MEM
      "mfence\n\t"
#   endif
# endif
      "xorl %%eax,%%eax\n\t"
      "cpuid\n\t"
      "rdtsc" : "=a" (a), "=d" (d) :: "%ebx", "%ecx"
    );

    return a | ((uint64_t)d << 32);
  }

  inline void END_TSC( counter_T *cptr )
  {
    uint32_t a, d;

    __asm__ __volatile__ (
      "rdtscp" : "=a" (a), "=d" (d) :: "%ecx"
    );

    *cptr = (a | ((uint64_t)d << 32)) - *cptr;
  }
#elif defined(__aarch64__)
  inline counter_T BEGIN_TSC( void )
  {
    uint64_t count;

    __asm__ __volatile__ ( 
# ifdef SYNC_MEM
     "dmb\n\t"
# endif
     "mrs %0,cntvct_el0" : "=r" (count)
    );

    return count;
  }

  inline void END_TSC( counter_T *cptr )
  {
    uint64_t count;

    __asm__ __volatile__ ( 
      "mrs %0,cntvct_el0" : "=r" (count)
    );

    *cptr = count - *cptr;
  }
#else
# error i386, x86-64 and AArch64 only.
#endif

#endif

Ponto flutuante: GCC sem suporte a __float128?

Se você descobriu que o GCC tem suporte ao tipo __float128, bem como ao tipo __int128, descobriu também que funções da família printf() e da família scanf() não suportam esses tipos! O que fazer?

Felizmente, junto com o GCC, é distribuída uma biblioteca chamada libquadmath que contém várias funções e, entre elas:

__float128 strtoflt128(const char *, char **);
int quadmath_snprintf(char *, size_t, const char *, ...);

A primeira, obviamente, converte um valor em ponto flutuante em __float128, do mesmo jeito que strtof() ou strtod fazem. A outra é a versão do snprintf() com suporte ao __float128 e as sub strings de conversão %f, %e ou %g devem ser precedidas de Q, por exemplo:

/* fragmento de código...
   note que quadmath.h foi incluido e, depois, na linkagem,
   precisaremos informar o uso de libquadmath com
   -lquadmath */
#include <quadmath.h>
extern __float128 x;
char buffer[48];
...
quadmath_snprintf( buffer, sizeof buffer, "%Qf", x );

Lembrando que snprintf tem um comportamento especial: Ela sempre retorna o número de caracteres que seriam impressos, independente do tamanho do buffer passado no segundo argumento. Isso é sempre útil para fazer algum esquema de alocação dinâmica, como mostrei na implementação de um asprintf() para o Windows (aqui).

Além dessas duas rotinas de I/O, libquadmath implementa as rotinas da libm, onde as funções são nomeadas com um sufixo ‘q’, como em sinq(), sqrtq() etc.

Outro lembrete é de que o tipo __float128 tem 113 bits de precisão e um expoente do fator multiplicativo de 15 bits. Em contraste, o tipo long double (que é suportando apenas pelo “co-processador matemático”, embutido em seu processador [Intel ou AMD]) tem 64 bits de precisão com expoente de 15.

Outro detalhe, mas que é independente da libquadmath é que as constantes em ponto flutuante do tipo __float128 têm o sufixo ‘Q’, como em:

__float128 x = 3.14Q;

ATENÇÃO: Infelizmente o GCC não tem suporte para funções da glibc ou libquadmath para o tipo __int128. Nem mesmo um sufixo especial para literais desse tipo (por exemplo, literais long long podem ter sufixo ‘LL’, como em -10LL). O tipo __int128 existe, basicamente, como alternativa para evitar o overflow em operações envolvendo long long, não para ser usado como tipo primitivo.

Ainda, o sufixo ‘Q’ de literais do tipo __float128 é uma extensão do GCC (possivelmente também no CLANG), mas não é “padrão”.

É claro, vale lembrar que seu processador não tem suporte nativo a esses tipos e, por isso, as rotinas são lentas, em relação aos tipos primitivos, e o compilador tende a não otimizá-las muito bem… Evite usá-los!

Um bug do GCC: Raízes quadradas em ponto flutuante

ATENÇÃO: Não é um BUG! Obtive uma resposta convincente do Bugzilla do GCC onde sou informado de que a segunda chamada a sqrtf() é feita apenas para ajustar errno. Mesmo assim o código é ineficiente! Outra coisa: Não precisamos de assembly inline para usarmos apenas uma instrução. Temos o mesmo resultado usando -ffast-math na linha de comando.


Durante o início de meus estudos sobre game engines topei com uma coisa meio estranha ao calcular o tamanho de um vetor de 3 dimensões:

float vec3_length( float *v )
{
  return sqrtf( v[0]*v[0] + v[1]*v[1] + v[2]*v[2] );
}

A chamada à função sqrtf() era bem estranha. Eis um código simplificado, apenas chamando-a:

; float f( float x ) { return sqrtf( x ); }
f:
  pxor    xmm2,xmm2
  sqrtss  xmm1,xmm0
  ucomiss xmm2,xmm0
  ja      .lessthan0
  movaps  xmm0,xmm1
  ret
.lessthan0:
  sub     rsp,24              ;*
  movaps  dword [rsp+8],xmm1  ;*
  call    sqrtf
  movaps  xmm1,dword [rsp+8]  ;*
  add     rsp,24              ;*
  movaps  xmm0,xmm1           ;*
  ret

Em primeiro lugar, porque o compilador testa se x é menor que zero se a instrução sqrtss vai retornar NAN, neste caso, de qualquer modo? E porque o compilador chama sqrt() da libm só para ignorar o retorno, usando o retorno prévio de sqrtss?

Quebrei a cabeça com isso por algum tempo até perceber que isso é um BUG. O código que dever ser gerado não deveria ter as instruções marcadas com ‘*’ se, por algum motivo, sqrtss não estivesse em conformidade com o padrão de retorno de sqrtf(), no caso de x < 0 (mas, está!). Ahhh e no GCC do MinGW-w64 o código fica pior:

f:
  sub     rsp,56
  movaps  xmmword [rsp+32],xmm6
  pxor    xmm1,xmm1
  sqrtss  xmm6,xmm0
  ucomiss xmm1,xmm0
  ja      .lessthan0
.exit:
  movaps  xmm0,xmm6
  movaps  xmm6,xmmword [rsp+32]
  add     rsp,56
  ret
.lessthan0:
  call    sqrtf
  jmp     .exit

Mas o resultado é o mesmo… sqrtf() da libm é chamado se x < 0, mas o resultado é ignorado e usado o da instrução sqrtss, que está em xmm6!

Provavelmente você está se perguntando se isso só acontece se usarmos SSE. Não! Se forçarmos a barra e pedirmos ao compilador para usar fp87, o mesmo problema acontece…

Note que esse bug é inocente. SQRTSS e sua contraparte, para double, SQRTSD, bem como a instrução fsqrt, funcionam corretamente para a condição onde x < 0, ou seja, retorna NAN. Todas as outras condições de inviabilidade do cálculo de raiz quadrada também são observadas (talvez!). Assim, o código mais eficiente pode ser este:

float f( float x )
{
  return _mm_cvtss_f32( _mm_sqrtss( _mm_set_ss( x ) ) );
}

Mas, mesmo isso nos dá um pequeníssimo overhead:

f:
  movss [rsp-12],xmm0
  movss xmm0,[rsp-12]  ; Apenas zera os bits superiores!
  sqrtss
  ret

Parece que as duas instruções iniciais estão lá à toa, mas, o segundo movss garante que os 3 float‘s que seguem o primeiro sejam zerados. Podemos fazer melhor:

// Código para x86-64
// (-mfpmath=sse). O default da SysV ABI.
inline float sqrtf_( float x )
{
  float r;

  // AT&T syntax.
  __asm__ __volatile__ (
    "sqrtss %1,%0" : "=x" (r) : "xm" (x)
  );

  return r;
}

De qualquer modo, este é o código efetivo que o GCC, pelo menos até a versão 8.2, cria. Testando contra valores inválidos, temos:

/* test.c */
#include <stdio.h>
#include <x86intrin.h>
#include <math.h>

static inline float sqrtf_ ( float x )
{
  float r;

  __asm__ __volatile__ (
    "sqrtss %1,%0" : "=x" ( r ) : "xm" ( x )
  );

  return r;
}

int main ( void )
{
  float invalid[] = { -0.0, -1.0, NAN, -NAN, INFINITY, -INFINITY };
  int i;

  for ( i = 0; i < sizeof invalid / sizeof invalid[0]; i++ )
    printf ( "%f -> %f\n", invalid[i], sqrtf_ ( invalid[i] ) );
}

O que nos dá:

$ cc -o test test.c
$ ./test
-0.000000 -> -0.000000
-1.000000 -> -nan
nan -> nan
-nan -> -nan
inf -> inf
-inf -> -nan

Então aquele “talvez” que escrevi lá em cima seja, agora, uma certeza de correção! De fato, a raiz quadrada de qualquer valor negativo (exceto o -0.0) é mesmo um NAN, incluindo infinito negativo. E a raiz quadrada de +infinito só pode ser +infinito! O teste por x < 0 é desnecessário.

Ahhh… esse bug do GCC não ocorre apenas na plataforma Intel. Para ARM temos o mesmo problema. Eis a listagem em assembly para AArch64 (para o Cortex A53):

# float f( float x ) { return sqrtf( x ); }
f:
  fsqrt s1,s0
  fcmp  s0,#0.0
  bmi   .lessthan0
  fmov  s0,s1
  ret
.lessthan0:
  stp   x29,x30,[sp,-32]!
  add   x29,sp,0
  str   s1,[x29,28]    ; salva S1, vindo de fsqrt
  bl    sqrtf
  ldr   s1,[x29,28]    ; recupera S1
  ldp   x29,x30,[sp],32
  fmov  s0,s1          ; usa S1 de qualquer modo!
  ret

Então, pro ARM, uma rotina em assembly também é uma boa ideia:

inline float sqrtf_( float x )
{
  float r;

  // ARM não usa AT&T syntax! :)
  __asm__ __volatile__ (
    "fsqrt %0,%1" : "=w" (r) : "w" (x)
  );

  return r;
}

PS: clang 6 não tem esse bug. O código gerado, com o mesmo nível de otimização é:

f:
  xorps   xmm1,xmm1
  ucomiss xmm0,xmm1
  jb      .lessthan0
  sqrtss  xmm0,xmm0
  ret
.lessthan0:
  jmp     sqrtf

Que, aliás, é um código melhor que o gerado pelo GCC (se fosse correto)… Mesmo assim, a chamada a sqrtf() parece ser inútil, porque devolve os mesmos valores que a instrução sqrtss!