Partilhar via


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

Este documento descreve alguns dos problemas comuns que você pode encontrar ao migrar código de arquiteturas x86 ou x64 para a arquitetura 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 ARM, ele se aplica a ARM32 e ARM64.

Fontes dos 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 construções de código-fonte que podem invocar comportamento indefinido, definido pela implementação ou não especificado.

Comportamento indefinido é um comportamento que o padrão C++ não define e que é causado por uma operação que não tem resultado razoável: por exemplo, convertendo um valor de ponto flutuante em um inteiro não assinado ou deslocando um valor por um número de posições que é negativo ou excede o número de bits em seu tipo promovido.

O comportamento definido pela implementação é o comportamento que o padrão C++ requer que o fornecedor do compilador defina e documente. Um programa pode confiar com segurança no comportamento definido pela implementação, mesmo que isso possa não ser portátil. Exemplos de comportamento definido pela implementação incluem os tamanhos dos tipos de dados internos e seus requisitos de alinhamento. Um exemplo de uma operação que pode ser afetada pelo comportamento definido pela implementação é acessar a lista de argumentos variáveis.

Comportamento não especificado é um comportamento que o padrão C++ deixa intencionalmente não determinístico. Embora o comportamento seja considerado não determinístico, invocações particulares de comportamento não especificado são determinadas pela implementação do compilador. No entanto, não há nenhum requisito para um fornecedor de compilador para predeterminar o resultado ou garantir um comportamento consistente 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 as 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 arquiteturas ARM e x86 ou x64 que interagem com o padrão C++ de forma diferente. Por exemplo, o modelo de memória forte da arquitetura x86 e x64 fornece volatilevariáveis qualificadas com algumas propriedades extras que foram usadas para facilitar certos tipos de comunicação entre threads no passado. Mas o modelo de memória fraco da arquitetura ARM não suporta esse uso, nem o padrão C++ o exige.

Importante

Embora volatile ganhe 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 primitivos de sincronização apropriados.

Como diferentes plataformas podem expressar esses tipos de comportamento de forma diferente, a portabilidade de software entre plataformas pode 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 é, pelo menos, não portátil e, nos casos de comportamento indefinido ou não especificado, também é um erro. Mesmo o comportamento citado neste documento não deve ser confiável, e pode mudar em futuros compiladores ou implementações de CPU.

Exemplo de problemas de migração

O restante deste documento descreve como o comportamento diferente desses elementos de linguagem C++ pode produzir resultados diferentes em plataformas diferentes.

Conversão de ponto flutuante em inteiro não assinado

Na arquitetura ARM, a conversão de um valor de ponto flutuante para um inteiro de 32 bits satura para o valor mais próximo que o inteiro pode representar se o valor de ponto flutuante estiver fora do intervalo que o inteiro pode representar. Nas arquiteturas x86 e x64, a conversão envolve se o inteiro não estiver assinado ou é definida como -2147483648 se o inteiro estiver assinado. Nenhuma dessas arquiteturas suporta diretamente a conversão de valores de vírgula flutuante em tipos inteiros menores; Em vez disso, as conversões são realizadas para 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, ao saturar um inteiro de 32 bits, a conversão para tipos não assinados satura corretamente tipos de menor capacidade. No entanto, produz um resultado truncado para valores que são maiores do que o tipo menor pode representar, mas ainda assim pequenos demais para saturar o inteiro completo de 32 bits. A conversão também satura corretamente para inteiros assinados de 32 bits, mas o truncamento de inteiros assinados saturados resulta em -1 para valores saturados positivamente e 0 para valores saturados negativamente. A conversão para um inteiro assinado menor produz um resultado truncado que é imprevisível.

Para as arquiteturas x86 e x64, a combinação do comportamento de wrap-around para conversões de inteiros não assinados e a avaliação explícita para conversões de inteiros assinados em caso de estouro, juntamente com o truncamento, torna os resultados da maioria dos deslocamentos imprevisíveis se forem demasiado grandes.

Essas plataformas também diferem em como lidam com a conversão de NaN (Not-a-Number) para tipos inteiros. No ARM, NaN converte para 0x00000000; em x86 e x64, ele converte para 0x80000000.

A conversão de ponto flutuante só pode ser confiável se você/tu souberes que o valor está dentro do intervalo do tipo inteiro para o qual está a ser convertido.

Comportamento do operador de turno (<<>>)

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 se repetir. 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, onde 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 por 32 posições, em ARM o resultado é 0, em x86 o resultado é 1 e em x64 o resultado também é 1. No entanto, se a origem do valor for uma variável de 64 bits, o resultado em todas as três plataformas será 4294967296, e o valor não será "enrolado" até que seja deslocado 64 posições em x64, ou 256 posições em ARM e 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 ambos os operandos de um turno são conhecidos em tempo de compilação, o compilador pode otimizar o programa usando uma rotina interna para pré-calcular o resultado do turno e, em seguida, substituindo o resultado no lugar da operação de turno. Se a quantidade de deslocamento for muito grande ou negativa, o resultado da rotina interna pode ser diferente do resultado da mesma expressão de deslocamento executada pela CPU.

Comportamento de argumentos variáveis (varargs)

Na arquitetura ARM, os parâmetros da lista de argumentos variáveis que são passados para a pilha estão sujeitos ao alinhamento. Por exemplo, um parâmetro de 64 bits é alinhado em um limite de 64 bits. Em x86 e x64, os argumentos passados na pilha não estão sujeitos a alinhamento e compactam de forma apertada. Essa diferença pode fazer com que uma função variádica, como printf, leia endereços de memória destinados a atuar como preenchimento no ARM, se o layout esperado da lista de argumentos variáveis não for correspondido exatamente, mesmo que funcione 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);

Neste caso, o bug pode ser corrigido certificando-se de que a especificação de formato correta é 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 do argumento

Como os processadores ARM, x86 e x64 são tão diferentes, eles podem apresentar requisitos diferentes para implementações de compiladores e também diferentes oportunidades 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 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 argumentos para uma função têm efeitos colaterais que afetam outros argumentos para a função na mesma chamada. Normalmente, este tipo de dependência é fácil de evitar, mas pode ser obscurecido 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);

Isso parece bem definido, mas se -> e * são operadores sobrecarregados, então este código é traduzido para algo que se assemelha 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 não haver dependência possível.

volatile comportamento padrão da palavra-chave

O compilador Microsoft C++ (MSVC) suporta duas interpretações diferentes do volatile qualificador de armazenamento que você pode especificar usando opções de 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 C++ estrita que não garante uma ordenação forte.

Na arquitetura ARM (exceto ARM64EC), o padrão é /volatile:iso porque os processadores ARM têm um modelo de memória fracamente ordenado e porque o software ARM não tem um legado de depender da semântica estendida de /volatile:ms e geralmente não precisa interagir com o software que o faz. 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 C++ ou o software de 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 deve inserir barreiras de memória em torno de cada leitura ou gravação de uma volatile variável para impor uma ordem 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 a dependência desnecessária da semântica volátil tradicional e promover a portabilidade.

Ver também

Configurar o Microsoft C++ para processadores ARM