As instruções MOVAPS e MOVUPS fazem a mesma coisa: Movem 4 valores float empacotados entre registradores XMM ou de/para memória principal. MOVAPD e MOVUPD porém lida com 2 valores double.
A diferença é que a instrução MOVAPS/MOVAPD espera que o endereço do valor na memória esteja alinhado a um valor de 16 bytes, caso não esteja a instrução dispara uma exceção #GP (General Protection ou "segmentation fault" como é conhecido no Linux). O motivo dessa instrução exigir isso é que acessar o endereço alinhado é muito mais performático.
Já a instrução MOVUPS/MOVUPD pode acessar um endereço de memória desalinhado (unaligned) sem ocorrer nenhum erro, porém ela é menos performática.
Um exemplo de uso da MOVAPS na nossa PoC:
Sem entrar em detalhes ainda sobre a convenção de chamada, o ponteiro recebido como argumento pela função assembly() está no registrador RDI.
Sobre o atributo align=16 usado na seção .rodata ele serve para fazer exatamente o que o nome sugere: Alinhar o endereço inicial da seção em um múltiplo de 16, que é uma exigência da instrução MOVAPS.
Um detalhe interessante que vale citar é que apesar da instrução ter sido feita para lidar com um determinado tipo de dado nada impede de nós carregarmos outros dados nos registradores XMM. No exemplo abaixo usei a instrução MOVUPS para mover uma string de 16 bytes com apenas duas instruções:
Move um único float/double entre registradores XMM, onde o valor estaria contido na double word (4 bytes) ou quadword (8 bytes) menos significativo do registrador. E também é possível mover de/para memória principal.
A instrução MOVLPS instrução é semelhante à MOVUPS porém carrega/escreve apenas dois floats. No registrador os dois floats ficam armazenados no quadword (8 bytes) menos significativo. O quadword mais significativo do registrador não é alterado.
Já MOVLPD faz a mesma operação porém com um double contido no quadword menos significativo.
MOVHP(S|D) | Move High Packed (Single|Double)-precision floating-point
Semelhante a instrução acima porém armazena/ler o valor do registrador XMM no quadword mais significativo. O quadword menos significativo do registrador não é alterado.
MOVLHPS | Move Packed Single-precision floating-point Low to High
Move o quadword (8 bytes) menos significativo do registrador fonte (a direita) para o quadword mais significativo do registrador destino. O quadword menos significativo do registrador destino não é alterado.
MOVHLPS | Move Packed Single-precision floating-point High to Low
Move o quadword (8 bytes) mais significativo do registrador fonte (a direita) para o quadword menos significativo do registrador destino. O quadword mais significativo do registrador destino não é alterado.
MOVMSKPS move os bits mais significativos (MSB) de cada um dos quatro valores float contido no registrador XMM para os 4 bits menos significativo do registrador de propósito geral. Os outros bits do registrador são zerados.
Já MOVMSKPD faz a mesma coisa porém com os 2 valores doubles contidos no registrador, assim definindo os 2 bits menos significativos do registrador de propósito geral.
Essa instrução pode ser usada com o intuito de verificar o sinal de cada um dos valores float/double, tendo em vista que o bit mais significativo é usado para indicar o sinal do número (0 caso positivo e 1 caso negativo).
Instruções com inteiros 128-bit
PAVGB/PAVGW | Compute average of packed unsigned (byte|word) of integers
Calcula a média da soma de todos os valores dos dois operandos somados. PAVGB calcula a média somando 16 bytes em cada operando, enquanto PAVGW soma 8 words em cada um.
Entendendo SSE
Aprendendo sobre SIMD, SSE e registradores XMM.
Na computação existe um conceito de instrução chamado SIMD (Single Instruction, Multiple Data) que é basicamente uma instrução que processa múltiplos dados de uma única vez. Todas as instruções que vimos até agora processavam meramente um dado por vez, porém instruções SIMD podem processar diversos dados paralelamente. O principal objetivo das instruções SIMD é ganhar performance se aproveitando dos múltiplos núcleos do processador, a maioria das instruções SIMD foram implementadas com o intuito de otimizar cálculos comuns em áreas como processamento gráfico, inteligência artificial, criptografia, matemática etc.
A Intel criou a primeira versão do SSE (streaming SIMD extensions) ainda no IA-32 com o Pentium III, e de lá para cá já ganhou diversas novas versões que estendem a tecnologia adicionando novas instruções. Atualmente nos processadores mais modernos há as seguintes extensões: SSE, SSE2, SSE3, SSSE3 e SSE4.
Instruções de conversão
Convertendo valores entre float, double e inteiro.
Essas instruções servem para conversão de tipos entre float, double e inteiro.
Conversão entre double e float
Instruções lógicas e de comparação
Instruções lógicas SSE
ANDP(S|D) | bitwise logical AND of Packed (Single|Double)-precision floating-point values
Soma 4 números float (ou 2 números double) de uma única vez no registrador destino com os quatro números float (ou 2 números double) do registrador/memória fonte. Exemplo:
Copia uma das 8 words contidas no registrador XMM e armazena no registrador de propósito geral de 32 ou 64 bits. O valor é movido para os 16 bits menos significativos do registrador e todos os outros bits são zerados. Também é possível armazenar a word diretamente na memória principal.
O operando imediato é um valor entre 0 e 7 que indica o índice da word no registrador XMM. Apenas os 3 bits menos significativos do valor são considerados, os demais são ignorados.
PINSRW | Insert word
Copia uma word dos 16 bits menos significativos do registrador de propósito geral no segundo operando e armazena em uma das words no registrador XMM. Também é possível ler a word da memória principal.
Assim como no caso do PEXTRW o operando imediato serve para identificar o índice da word no registrador XMM.
PMAXUB/PMAXUW | Maximum of packed unsigined (byte|word) of integers
Compara os bytes/words não-sinalizados dos dois operandos packed e armazena o maior deles em cada uma das comparações no operando destino (o primeiro).
PMINUB/PMINUW | Minimum of packed unsigned (byte|word) of integers
Faz o mesmo que a instrução anterior porém armazenando o menor número de cada comparação.
PMAXS(B|W|D) | Maximum of packed signed (byte|word|doubleword) integers
Faz o mesmo que PMAXUB/PMAXUW porém considerando o valor como sinalizado. Também há a instrução PMAXSD que compara quatro double words (4 bytes) empacotados.
PMINS(B|W) | Minimum of packed signed (byte|word) integers
Faz o mesmo que PMAXSB/PMAXSW porém retornando o menor valor de cada comparação.
PMOVMSKB | Move byte mask
Armazena nos 16 bits menos significativos do registrador de propósito geral cada um dos bits mais significativos (MSB) de todos os bytes contidos no registrador XMM.
PMULHW/PMULHUW | Multiply packed (unsigned) word integers and store high result
Multiplica cada uma das words dos operandos onde o resultado temporário da multiplicação é de 32 bits de tamanho. Porém armazena no operando destino somente a word mais significativa do resultado da multiplicação.
PMULHW faz uma multiplicação sinalizada, enquanto PMULHUW faz uma multiplicação não-sinalizada.
PSADBW | Compute sum of absolute differences
Calcula a diferença absoluta dos bytes dos dois operandos e armazena a soma de todas as diferenças.
A diferença dos 8 bytes menos significativos é somada e armazenada na word menos significativa do operando destino. Já a diferença dos 8 bytes mais significativos é somada e armazenada na quinta word (índice 4, bits [79:64]) do operando destino. Todas as outras words do registrador XMM são zeradas.
MOVDQA | Move aligned double quadword
Move dois quadwords (8 bytes) entre registradores XMM ou de/para memória RAM. O endereço na memória precisa estar alinhado a 16 se não uma exceção #GP será disparada.
MOVDQU | Move unaligned double quadword
Faz o mesmo que a instrução anterior porém o alinhamento da memória não é necessário, porém essa instrução é menos performática caso acesse um endereço desalinhado.
Multiplica o primeiro (índice 0) e o terceiro (índice 2) doublewords dos operandos e armazena o resultado como quadwords no operando destino. O resultado da multiplicação entre os primeiros doublewords é armazenado no quadword menos signfiicativo do operando destino, enquanto a multiplicação entre os terceiros doublewords é armazenada no quadword mais significativo.
Exemplo:
RDI é o primeiro ponteiro recebido como argumento e RSI o segundo.
PSLLDQ | Shift double quadword left logical
Faz uma operação de logical shift left com os dois quadwords do registrador XMM. O número de vezes que o shift deve ser feito é especificado pelo operando imediato de 8 bits. Os bits menos significativos são zerados.
PSRLDQ | Shift double quadword right logical
Faz o mesmo que a instrução anterior porém com um shift right. Os bits mais significativos são zerados.
Converte os dois doubles no operando fonte para dois inteiros sinalizados de 32-bit no operando destino. A instrução CVTPD2DQ faz o arredondamento normal do valor enquanto CVTTPD2DQ trunca ele.
Converte os dois inteiros sinalizados de 32-bit no operando fonte para dois doubles no operando destino.
CVTSD2SI/CVTTSD2SI | Convert scalar double-precision floating-point value to doubleword integer
CVTSD2SI converte o valor double no operando fonte em inteiro de 32-bit sinalizado, e armazena o valor no registrador de propósito geral do operando destino. O registrador destino também pode ser um registrador de 64-bit onde nesse caso o valor sofrerá extensão de sinal (sign extension).
CVTTSD2SI faz a mesma coisa porém truncando o valor.
CVTSI2SD | Convert doubleword integer to scalar double-precision floating-point value
Converte o valor inteiro sinalizado de 32 ou 64 bits do operando fonte e armazena como um double no operando destino.
Converte quatro floats do operando fonte em quatro inteiros sinalizados de 32-bit no operando destino. A instrução CVTPS2DQ faz o arredondamento normal dos valores enquanto CVTTPS2DQ trunca eles.
Converte quatro inteiros sinalizados de 32-bit no operando fonte para quatro floats no operando destino.
CVTSS2SI/CVTTSS2SI | Convert scalar single-precision floating-point value to doubleword integer
CVTSS2SI converte o valor float no operando fonte em inteiro de 32-bit sinalizado, e armazena o valor no registrador de propósito geral do operando destino. O registrador destino também pode ser um registrador de 64-bit onde nesse caso o valor sofrerá extensão de sinal (sign extension).
A instrução CVTTSS2SI faz a mesma coisa porém truncando o valor.
CVTSI2SS | Convert doubleword integer to scalar single-precision floating-point value
Converte o valor inteiro sinalizado de 32 ou 64 bits do operando fonte e armazena como um float no operando destino.
Processadores da arquitetura x86 têm diversas tecnologias SIMD, a primeira delas foi o MMX nos processadores Intel antes mesmo do SSE. Além de haver diversas outras como AVX, AVX-512, FMA, 3DNow! (da AMD) etc.
Na arquitetura x86 existem literalmente milhares de instruções SIMD. Esteja ciente que esse tópico está longe de cobrir todo o assunto e serve meramente como conteúdo introdutório.
Registradores XMM
A tecnologia SSE adiciona novos registradores independentes de 128 bits de tamanho cada. Em todos os modos de operação são adicionados oito novos registradores XMM0 até XMM7, e em 64-bit também há mais oito registradores XMM8 até XMM15 que podem ser acessados usando o prefixo REX. Isso dá um total de 16 registradores em 64-bit e 8 registradores nos outros modos de operação.
Esses registradores podem armazenar vários dados diferentes de mesmo tipo/tamanho, conforme demonstra tabela abaixo:
Intel Developer's Manuals | 4.6.2 128-Bit Packed SIMD Data Types
Esses são os tipos empacotados (packed), onde em um único registrador há vários valores de um mesmo tipo. Existem instruções SIMD específicas que executam operações packed onde elas trabalham com os vários dados armazenados no registrador ao mesmo tempo. Em contraste existem também as operações escalares (scalar) que operam com um único dado (unpacked) no registrador, onde esse dado estaria armazenado na parte menos significativa do registrador.
Na convenção de chamada para x86-64 da linguagem C os primeiros argumentos float/double passados para uma função vão nos registradores XMM0, XMM1 etc. como valores escalares. E o retorno do tipo float/double fica no registrador XMM0 também como um valor escalar.
Na lista de instruções haverá códigos de exemplo disso.
Entendendo as instruções SSE
As instruções adicionadas pela tecnologia SSE podem ser divididas em quatro grupos:
Instruções packed e scalar que lidam com números float.
Instruções SIMD com inteiros de 64 bits.
Instruções de gerenciamento de estado.
Instruções de controle de cache e prefetch.
A tabela abaixo lista a nomenclatura que irei utilizar para descrever as instruções SSE.
Para facilitar o entendimento irei usar o termo float para se referir aos números de ponto flutuante de precisão única, ou seja, 32 bits de tamanho e 23 bits de precisão. Já o termo double será utilizado para se referir aos números de ponto flutuante de dupla precisão, ou seja, de 64 bits de tamanho e 52 bits de precisão. Esses são os mesmos nomes usados como tipos na linguagem C.
Nomenclatura
Descrição
xmm(n)
Indica qualquer um dos registradores XMM.
float(n)
Indica N números floats em sequência na memória RAM. Exemplo: float(4) seriam 4 números float totalizando 128 bits de tamanho.
double(n)
Indica N números double na memória RAM. Exemplo: double(2) que totaliza 128 bits.
ubyte(n)
Indica N bytes não-sinalizados na memória RAM. Exemplo: ubyte(16) que totaliza 128 bits.
byte(n)
Indica N bytes sinalizados na memória RAM.
As instruções SSE terminam com um sufixo de duas letras onde a penúltima indica se ela lida com dados packed ou scalar, e a última letra indica o tipo do dado sendo manipulado. Por exemplo a instrução MOVAPS onde o P indica que a instrução manipula dados packed, enquanto o S indica o tipo do dado como single-precision floating-point, ou seja, float de 32 bits de tamanho.
Já o D de MOVAPD indica que a instrução lida com valores do tipo double-precision floating-point (64 bits). Eis a lista de sufixos e seus respectivos tipos:
Sufixo
Tipo
S
Single-precision float. Equivalente ao tipo float em C.
D
Double-precision float. Equivalente ao tipo double em C.
Ou inteiro doubleword (4 bytes) que seria um inteiro de 32 bits.
B
Inteiro de um byte (8 bits).
W
Inteiro word (2 bytes | 16 bits).
Q
Inteiro quadword (8 bytes | 64 bits).
Todas as instruções SSE que lidam com valores na memória exigem que o valor esteja em um endereço alinhado em 16 bytes, exceto as instruções que explicitamente dizem lidar com dados desalinhados (unaligned).
Caso uma instrução SSE seja executada com um dado desalinhado uma exceção #GP será disparada.
Faz uma operação E bit a bit (bitwise AND) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
ANDNP(S|D) | bitwise logical AND NOT of Packed (Single|Double)-precision floating-point values
Faz uma operação NAND bit a bit em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
ORP(S|D) | bitwise logical OR of Packed (Single|Double)-precision floating-point values
Faz uma operação OU bit a bit (bitwise OR) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
XORP(S|D) | bitwise logical XOR of Packed (Single|Double)-precision floating-point values
Faz uma operação OU Exclusivo bit a bit (bitwise eXclusive OR) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
Instruções de comparação SSE
As instruções de comparação do SSE recebem um terceiro operando imediato de 8 bits que serve como identificador para indicar qual comparação deve ser efetuada com os valores, onde os valores válidos são de 0 até 7. Na tabela abaixo é indicado cada valor e qual operação de comparação ele representa:
Valor
Mnemônico
Descrição
0
EQ
Verifica se os valores são iguais.
1
LT
Verifica se o primeiro operando é menor que o segundo.
2
LE
Verifica se o primeiro operando é menor ou igual ao segundo.
3
Felizmente para facilitar nossa vida os assemblers, incluindo o NASM, adicionam pseudo-instruções que removem o operando imediato e, ao invés disso, usa os mnemônicos apresentados na tabela como conditional code para a instrução. Como é demonstrado no exemplo abaixo:
Essa instrução compara cada um dos valores float/double contido nos dois operandos e armazena o resultado da comparação no operando fonte (o primeiro). O valor imediato passado como terceiro operando é um código numérico para identificar qual operação de comparação deve ser executada em cada um dos valores.
O resultado é armazenado como todos os bits ligados (1) caso a comparação seja verdadeira, se não todos os bits estarão desligados (0) indicando que a comparação foi falsa. Cada número float/double tem um resultado distinto no registrador destino.
CMPS(S|D)/CMPccS(S|D) | Compare Scalar (Single|Double)-precision floating-point value
Funciona da mesma forma que a instrução anterior porém comparando um único valor escalar. O resultado é armazenado no float/double menos significativo do operando fonte.
COMIS(S|D)/UCOMIS(S|D) | (Unordered) Compare Scalar (Single|Double)-precision floating-point value and set EFLAGS
As quatro instruções comparam os dois operandos escalares e definem as status flags em EFLAGS de acordo com a comparação sem modificar os operandos. Comportamento semelhante ao da instrução CMP.
Quando uma operação aritmética com números floats resulta em NaN existem dois tipos diferentes:
quiet NaN(QNaN): O valor é apenas definido para NaN sem qualquer indicação de problema.
signaling NaN (SNaN): O valor é definido para NaN e uma exceção floating-point invalid-operation (#I) é disparada caso você execute alguma operação com o valor.
A diferença entre COMISS/COMISD e UCOMISS/UCOMISD é que COMISS/COMISD irá disparar a exceção #I se o primeiro operando for QNaN ou SNaN. Já UCOMISS/UCOMISD apenas dispara a exceção se o primeiro operando for SNaN.
Funciona da mesma forma que a instrução anterior porém faz uma operação de subtração nos valores.
ADDS(S|D) | Add Scalar (Single|Double)-precision floating-point value
ADDSS faz a adição do float contido no double word (4 bytes) menos significativo do registrador XMM. Já ADDSD faz a adição do double contido na quadword (8 bytes) menos significativa do registrador.
Conforme exemplo abaixo:
SUBS(S|D) | Subtract Scalar (Single|Double)-precision floating-point value
Funciona da mesma forma que a instrução anterior porém subtraindo os valores.
Funciona como ADDPS/ADDPD porém dividindo os números ao invés de somá-los.
DIVS(S|D) | Divide Scalar (Single|Double)-precision floating-point value
Funciona como ADDSS/ADDSD porém dividindo os números ao invés de somá-los.
RCPPS | Compute Reciprocals of Packed Single-precision floating-point values
Calcula o valor aproximado do inverso multiplicativo dos floats no operando fonte (a direita) e armazena os valores no operando destino.
RCPSS | Compute Reciprocal of Scalar Single-precision floating-point value
Calcula o valor aproximado do inverso multiplicativo do float no operando fonte (a direita) e armazena o resultado na double word (4 bytes) menos significativa do operando destino.
SQRTP(S|D) | Compute square roots of Packed (Single|Double)-precision floating-point values
Calcula as raízes quadradas dos números floats/doubles no operando fonte e armazena os resultados no operando destino.
SQRTS(S|D) | Compute square root of Scalar (Single|Double)-precision floating-point value
Calcula a raiz quadrada do número escalar no operando fonte e armazena o resultado no float/double menos significativo do operando destino. Exemplo:
RSQRTPS | Compute Reciprocals of square roots of Packed Single-precision floating-point values
Calcula o inverso multiplicativo das raízes quadradas dos floats no operando fonte, armazenando os resultados no operando destino. Essa instrução é equivalente ao uso de SQRTPS seguido de RCPPS.
RSQRTSS | Compute Reciprocal of square root of Scalar Single-precision floating-point value
Calcula o inverso multiplicativo da raiz quadrada do número escalar no operando fonte e armazena o resultado no double word menos significativo do operando destino.
MAXP(S|D) | return maximum of Packed (Single|Double)-precision floating-point values
Compara cada um dos valores contidos nos dois operandos e retorna o maior valor entre os dois.
MAXS(S|D) | return maximum of Scalar (Single|Double)-precision floating-point value
Compara os dois valores escalares e armazena o maior deles no float/double menos significativo do operando destino.
MINP(S|D) | return minimum of Packed (Single|Double)-precision floating-point values
Funciona da mesma forma que MAXPS/MAXPD porém retornando o menor valor entre cada comparação.
MINS(S|D) | return minimum of Scalar (Single|Double)-precision floating-point value
Funciona da mesma forma que MAXSS/MAXSD porém retornando o menor valor entre os dois.