Partilhar via


Problemas de migração ARM do Visual C++

Ao usar o MSVC (compilador do Microsoft C++), o mesmo código-fonte de C++ pode produzir resultados diferentes na arquitetura ARM em comparação com arquiteturas x86 ou x64.

Fontes de problemas de migração

Muitos problemas que você pode encontrar ao migrar código das arquiteturas x86 ou x64 para a arquitetura ARM estão relacionados a constructos do código-fonte que podem invocar um comportamento indefinido, definido pela implementação ou não especificado.

Comportamento indefinido é o comportamento que o padrão C++ não define, e é causado por uma operação que não tem nenhum resultado razoável: por exemplo, converter um valor de ponto flutuante em um inteiro sem sinal ou deslocar um valor uma série de posições negativas ou exceder o número de bits em seu tipo promovido.

Comportamento definido pela implementação é o comportamento que o padrão C++ exige que o fornecedor do compilador defina e documente. Um programa pode depender com segurança do comportamento definido pela implementação, embora ele possa não ser portátil. Exemplos de comportamento definido pela implementação incluem os tamanhos dos tipos de dados internos e os respectivos requisitos de alinhamento. Um exemplo de operação que pode ser afetada pelo comportamento definido pela implementação é acessar a lista de argumentos variáveis.

Comportamento não especificado é o comportamento que o padrão C++ mantém intencionalmente não determinístico. Embora o comportamento seja considerado não determinístico, invocações específicas do comportamento não especificado são determinadas pela implementação do compilador. No entanto, não há nenhum requisito para que um fornecedor de compilador determine previamente o resultado ou garanta um comportamento uniforme entre invocações comparáveis, e não há nenhum requisito de documentação. Um exemplo de comportamento não especificado é a ordem na qual subexpressões, que incluem argumentos para uma chamada de função, são avaliadas.

Outros problemas de migração podem ser atribuídos a diferenças de hardware entre as arquiteturas ARM e x86 ou x64, que interagem com o padrão C++ de maneiras diferentes. Por exemplo, o modelo de memória forte das arquiteturas x86 e x64 fornece a variáveis qualificadas por volatile algumas propriedades adicionais que foram usadas para facilitar determinados tipos de comunicação entre threads no passado. No entanto, o modelo de memória fraca da arquitetura ARM não dá suporte a esse uso, nem o padrão C++ o requer.

Importante

Embora volatile ganhe algumas propriedades que podem ser usadas para implementar formas limitadas de comunicação entre threads nas arquiteturas x86 e x64, essas propriedades adicionais não são suficientes para implementar a comunicação entre threads em geral. O padrão C++ recomenda que essa comunicação seja implementada usando os primitivos de sincronização apropriados.

Como diferentes plataformas podem expressar esses tipos de comportamento de maneiras diferentes, a portabilidade de software entre plataformas poderá ser difícil e propensa a bugs se depender do comportamento de uma plataforma específica. Embora muitos desses tipos de comportamento possam ser observados e possam parecer estáveis, confiar neles é, no mínimo, não portátil e, em casos de comportamento indefinido ou não especificado, também é um erro. Até mesmo o comportamento citado neste documento não é confiável e poderá mudar em implementações futuras de compiladores ou CPUs.

Exemplos de problemas de migração

O restante deste documento descreve como comportamentos diferentes desses elementos da linguagem C++ podem produzir resultados diferentes em diferentes plataformas.

Conversão de ponto flutuante em inteiro sem sinal

Na arquitetura ARM, a conversão de um valor de ponto flutuante em um inteiro de 32 bits satura para o valor mais próximo que o inteiro pode representar quando o valor de ponto flutuante está fora do intervalo que o inteiro pode representar. Nas arquiteturas x86 e x64, a conversão será encapsulada se o inteiro não estiver assinado ou será definida como -2147483648 se o inteiro estiver assinado. Nenhuma dessas arquiteturas dá suporte direto à conversão de valores de ponto flutuante em tipos inteiros menores. Em vez disso, as conversões são executadas em 32 bits e os resultados são truncados para um tamanho menor.

Para a arquitetura ARM, a combinação de saturação e truncamento significa que a conversão em tipos não assinados satura corretamente tipos menores sem sinal quando satura um inteiro de 32 bits, mas produz um resultado truncado para valores que são maiores do que o tipo menor pode representar, mas pequenos demais para saturar o inteiro completo de 32 bits. A conversão também satura corretamente para inteiros com sinal de 32 bits, mas o truncamento de inteiros saturados com sinal resulta em -1 para valores saturados positivamente e em 0 para valores saturados negativamente. A conversão em um inteiro com sinal menor produz um resultado truncado imprevisível.

Para as arquiteturas x86 e x64, a combinação do comportamento de encapsulamento para conversões de inteiro sem sinal e avaliação explícita para conversões de inteiros com sinal em estouro, juntamente com o truncamento, torna os resultados da maioria dos deslocamentos imprevisíveis quando elas são muito grandes.

Essas plataformas também diferem na forma como lidam com a conversão de NaN (não é um número) em tipos inteiros. No ARM, NaN é convertido em 0x00000000; em x86 e x64, é convertido em 0x80000000.

Você só poderá confiar na conversão de ponto flutuante se souber que o valor está dentro do intervalo do tipo inteiro para o qual a conversão está sendo feita.

Comportamento do operador de deslocamento (<<>>)

Na arquitetura ARM, um valor pode ser deslocado para a esquerda ou para a direita até 255 bits antes que o padrão comece a ser repetido. Nas arquiteturas x86 e x64, o padrão é repetido a cada múltiplo de 32, a menos que a origem do padrão seja uma variável de 64 bits. Nesse caso, o padrão se repete a cada múltiplo de 64 em x64 e a cada múltiplo de 256 em x86, quando uma implementação de software é empregada. Por exemplo, para uma variável de 32 bits que tem um valor de 1 deslocado para a esquerda em 32 posições, no ARM o resultado é 0, no x86 o resultado é 1 e no x64 o resultado também é 1. No entanto, se a origem do valor for uma variável de 64 bits, o resultado nas três plataformas será 4294967296 e o valor não será "encapsulado" até que seja deslocado 64 posições no x64 ou 256 posições no ARM e no x86.

Como o resultado de uma operação de deslocamento que excede o número de bits no tipo de origem é indefinido, o compilador não precisa ter um comportamento uniforme em todas as situações. Por exemplo, se os dois operandos de um deslocamento forem conhecidos em tempo de compilação, o compilador poderá otimizar o programa usando uma rotina interna para pré-computar o resultado do deslocamento e, então, substituir o resultado no lugar da operação de deslocamento. Se o valor do deslocamento for muito grande ou negativo, o resultado da rotina interna poderá ser diferente do resultado da mesma expressão de deslocamento executada pela CPU.

Comportamento dos argumentos variáveis (varargs)

Na arquitetura ARM, os parâmetros da lista de argumentos variáveis passados na pilha estão sujeitos ao alinhamento. Por exemplo, um parâmetro de 64 bits é alinhado segundo um limite de 64 bits. Em x86 e x64, os argumentos passados na pilha não estão sujeitos ao alinhamento e ao pacote estritamente. Essa diferença poderá fazer com que uma função variádica como printf leia endereços de memória destinados ao preenchimento no ARM se o layout esperado da lista de argumentos variáveis não corresponder exatamente, embora isso possa funcionar para um subconjunto de alguns valores nas arquiteturas x86 ou x64. Considere este exemplo:

// notice that a 64-bit integer is passed to the function, but '%d' is used to read it.
// on x86 and x64 this may work for small values because %d will "parse" the low-32 bits of the argument.
// on ARM the calling convention will align the 64-bit value and the code will print a random value
printf("%d\n", 1LL);

Nesse caso, o bug pode ser corrigido garantindo que a especificação de formato correta seja usada para que o alinhamento do argumento seja considerado. Este código está correto:

// CORRECT: use %I64d for 64-bit integers
printf("%I64d\n", 1LL);

Ordem de avaliação de argumentos

Como os processadores ARM, x86 e x64 são muito diferentes, eles podem apresentar requisitos diferentes para as implementações de compilador, bem como oportunidades diferentes de otimizações. Por isso, juntamente com outros fatores, como convenções de chamada e configurações otimização, um compilador pode avaliar argumentos de função em ordens diferentes em arquiteturas diferentes ou quando os outros fatores são alterados. Isso pode fazer com que o comportamento de um aplicativo que depende de uma ordem de avaliação específica seja alterado inesperadamente.

Esse tipo de erro pode ocorrer quando os argumentos de uma função têm efeitos colaterais que afetam outros argumentos da função na mesma chamada. Normalmente, esse tipo de dependência é fácil de evitar, mas às vezes pode ser ocultado por dependências difíceis de discernir ou pela sobrecarga do operador. Considere este exemplo de código:

handle memory_handle;

memory_handle->acquire(*p);

Ele parece bem definido, mas se -> e * forem operadores sobrecarregados, esse código será convertido em algo semelhante a isto:

Handle::acquire(operator->(memory_handle), operator*(p));

E se houver uma dependência entre operator->(memory_handle) e operator*(p), o código poderá depender de uma ordem de avaliação específica, embora no código original pareça que não há nenhuma dependência possível.

Comportamento padrão da palavra-chave volatile

O compilador do MSVC dá suporte a duas interpretações diferentes do qualificador de armazenamento volatile que você pode especificar usando opções do compilador. A opção /volatile:ms seleciona a semântica volátil estendida da Microsoft, que garante uma ordenação forte, como tem sido o caso tradicional para x86 e x64 devido ao modelo de memória forte nessas arquiteturas. A opção /volatile:iso seleciona a semântica volátil padrão estrita do C++, que não garante a ordenação forte.

Na arquitetura ARM (exceto pelo ARM64EC), o padrão é /volatile:iso porque os processadores ARM têm um modelo de memória ordenado fracamente e porque o software ARM não tem o histórico de depender da semântica estendida de /volatile:ms e geralmente não precisa fazer interface com software que depende. No entanto, às vezes ainda é conveniente, ou até mesmo necessário, compilar um programa ARM para usar a semântica estendida. Por exemplo, pode ser muito caro portar um programa para usar a semântica ISO do C++, ou o software do driver pode ter que aderir à semântica tradicional para funcionar corretamente. Nesses casos, você pode usar a opção /volatile:ms. No entanto, para recriar a semântica volátil tradicional em destinos ARM, o compilador precisa inserir barreiras de memória em torno de cada leitura ou gravação de uma variável volatile para impor a ordenação forte, o que pode ter um impacto negativo no desempenho.

Nas arquiteturas x86, x64 e ARM64EC, o padrão é /volatile:ms porque grande parte do software que já foi criado para essas arquiteturas usando MSVC depende dele. Ao compilar programas x86, x64 e ARM64EC, você pode especificar a opção /volatile:iso para ajudar a evitar uma dependência desnecessária da semântica volátil tradicional e promover a portabilidade.

Confira também

Configurar o Visual C++ para processadores ARM