Compartilhar via


Problemas comuns de migração do Microsoft C++ para ARM

Este documento descreve alguns dos problemas comuns que você pode encontrar ao migrar código de arquiteturas x86 ou x64 para a arquitetura do ARM. Ele também descreve como evitar esses problemas e como usar o compilador para ajudar a identificá-los.

Observação

Quando este artigo se refere à arquitetura do ARM, ele se aplica a ARM32 e ARM64.

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 um resultado razoável: por exemplo, converter um valor de ponto flutuante para um inteiro sem sinal, ou deslocar um valor por um número de posições que seja negativo ou que exceda 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++ deixa intencionalmente não determinístico. Embora o comportamento seja considerado não determinístico, invocações específicas de comportamento não especificado são determinadas pela implementação do compilador. No entanto, não há nenhum requisito para que um fornecedor do compilador predetermine o resultado ou garanta um comportamento consistente entre invocações comparáveis e não há nenhum requisito para 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 ganhe volatile algumas propriedades que podem ser usadas para implementar formas limitadas de comunicação entre threads em x86 e x64, essas propriedades 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. Mesmo o comportamento citado neste documento não deve ser confiado e pode mudar em futuros compiladores ou implementações de CPU.

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. Em arquiteturas x86 e x64, o padrão é repetido em 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 em cada múltiplo de 64 em x64 e em cada múltiplo de 256 no x86, em que 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 "circula" até ser deslocado por 64 posições no x64, ou por 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 consistente 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 pode fazer com que uma função variádica como printf leia endereços de memória que foram destinados como preenchimento no ARM, caso o layout esperado da lista de argumentos variáveis não seja exatamente igual, ainda que possa funcionar para um subconjunto de valores nas arquiteturas x86 e 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 de otimização, um compilador pode avaliar argumentos de função em uma ordem diferente dependendo da arquitetura ou quando 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 pode ser obscurecido por dependências difíceis de discernir ou por 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 pode depender de uma ordem de avaliação específica, mesmo que o código original pareça que não há nenhuma dependência possível.

volatile comportamento padrão da palavra-chave

O compilador do Microsoft C++ (MSVC) dá suporte a duas interpretações diferentes do volatile qualificador de armazenamento que você pode especificar usando comutadores 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 delas. 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 Microsoft C++ para processadores ARM