Поделиться через


Распространенные проблемы с миграцией MICROSOFT C++ ARM

В этом документе описываются некоторые распространенные проблемы, которые могут возникнуть при переносе кода из архитектуры x86 или x64 в архитектуру ARM. В нем также описывается, как избежать этих проблем и как использовать компилятор для их идентификации.

Замечание

Если эта статья относится к архитектуре ARM, она применяется как к ARM32, так и к ARM64.

Источники проблем с миграцией

Многие проблемы, возникающие при переносе кода из архитектуры x86 или x64 в архитектуру ARM, связаны с конструкциями исходного кода, которые могут вызывать неопределенное, определяемое реализацией или непредвиденное поведение.

Неопределенное поведение — это поведение, которое стандарт C++ не определяет, и это вызвано операцией, которая не имеет разумного результата: например, преобразование значения с плавающей запятой в целое число без знака или перемещение значения на несколько позиций, которые являются отрицательными или превышают количество битов в его повышенном типе.

Поведение, определяемое реализацией — это поведение, которое в соответствии со стандартом C++ должно быть определено и задокументировано поставщиком компилятора. Программа может безопасно полагаться на поведение, определяемое реализацией, даже если это может негативно сказаться на переносимости. К примерам поведения, определяемого реализацией, относятся размеры встроенных типов данных и требования к их выравниванию. Примером операции, которую может затрагивать поведение, определяемое реализацией, является доступ к списку переменных аргументов.

Неопределенное поведение — это поведение, которое стандарт C++ оставляет намеренно недетерминированным. Хотя поведение считается недетерминированным, определенные вызовы неопределенного поведения определяются реализацией компилятора. Однако поставщик компилятора не требует предопределить результат или гарантировать согласованное поведение между сопоставимыми вызовами, и нет никаких требований к документации. Примером неопределённого поведения является порядок вычисления вложенных выражений, которые содержат аргументы вызова функции.

Причинами других проблем миграции могут быть аппаратные различия между архитектурами ARM с одной стороны и x86 или x64 — с другой. Они по-разному взаимодействуют со стандартом C++. Например, строгая модель памяти архитектуры x86 и x64 дает volatile-квалифицированным переменным некоторые дополнительные свойства, которые использовались для упрощения взаимодействия между потоками в прошлом. Однако нестрогая модель памяти в архитектуре ARM не поддерживает это использование, и стандарт C++ его не требует.

Внимание

Хотя volatile приобретает некоторые свойства, которые можно использовать для реализации ограниченных форм взаимодействия между потоками в x86 и x64, эти свойства недостаточны для реализации взаимодействия между потоками в общем смысле. Стандарт C++ вместо этого рекомендует реализовывать такое взаимодействие с помощью надлежащих примитивов синхронизации.

Так как на разных платформах такие типы поведения могут реализовываться по-разному, перенос программного обеспечения, зависящего от особенностей работы конкретной платформы, на другие платформы может быть непростой задачей. Несмотря на то, что поведение может казаться стабильным, его использование может делать перенос невозможным, а в случае с неопределенным или непредвиденным поведением — также приводить к ошибкам. Даже поведение, указанное в этом документе, не должно зависеть от этого и может измениться в будущих компиляторах или реализациях ЦП.

Примеры проблем с миграцией

В оставшейся части документа описывается то, как разное поведение определенных элементов языка C++ может давать разные результаты на разных платформах.

Преобразование чисел с плавающей запятой в целые числа без знака

В архитектуре ARM при преобразовании значения с плавающей запятой в 32-разрядное целое число происходит округление до ближайшего значения, которое может представлять целое число, если значение с плавающей запятой выходит за пределы диапазона, представляемого целым числом. В архитектурах x86 и x64 при преобразовании происходит циклическое изменение, если целое число не имеет знака, или задается значение –2 147 483 648, если целое число имеет знак. Ни одна из этих архитектур не поддерживает прямое преобразование значений с плавающей запятой в целочисленные типы меньшего размера. Вместо этого выполняется преобразование в 32-разрядное значение, которое затем усекается до меньшего размера.

В архитектуре ARM сочетание насыщения и усечения означает, что преобразование в беззнаковые типы корректно насыщает меньшие беззнаковые типы при насыщении 32-разрядного целого числа. Однако для значений, которые превышают возможности представления меньшего типа, но слишком малы для насыщения полного 32-разрядного целого числа, результат усекается. При преобразовании насыщение для 32-разрядных целых чисел со знаком также происходит правильно, но усечение насыщенных целых чисел со знаком приводит к значению –1 для положительно насыщенных значений и 0 для отрицательно насыщенных значений. Преобразование в целое число со знаком меньшего размера приводит к усеченному результату, который является непредсказуемым.

Для архитектур x86 и x64 сочетание переноса при преобразовании целых чисел без знака и явной оценки при преобразовании целых чисел со знаком в случае переполнения вместе с усечением приводит к непредсказуемым результатам для большинства операций сдвига, если они слишком велики.

Эти платформы также отличаются способом преобразования нечисловых значений в целочисленные типы. В ARM NaN преобразуется в 0x00000000, а в x86 и x64 — в 0x80000000.

Преобразование значения с плавающей запятой можно использовать только в том случае, если известно, что значение находится в пределах диапазона целочисленного типа, в который оно преобразуется.

Поведение оператора shift (<<>>)

В архитектуре ARM значение может сдвигаться влево или вправо максимум на 255 бит, прежде чем шаблон начнет повторяться. В архитектурах x86 и x64 шаблон повторяется на каждом кратном 32, за исключением случаев, когда источник шаблона является 64-разрядной переменной. В этом случае шаблон повторяется при каждом кратном 64 на x64 и кратном 256 на x86, где используется программная реализация. Например, для 32-разрядной переменной, которая имеет значение 1 и сдвигается влево на 32 позиции, в ARM результат равен 0, в x86 — 1, а в x64 — также 1. Однако если источником значения является 64-разрядная переменная, результатом на всех трех платформах будет 4 294 967 296, а значение не будет переноситься до тех пор, пока не будет сдвинуто на 64 позиции в x64 или на 256 позиций в ARM и x86.

Так как результат операции смены, превышающей число битов в исходном типе, не определен, компилятор не должен иметь согласованное поведение во всех ситуациях. Например, если оба операнда сдвига известны во время компиляции, компилятор может оптимизировать программу с помощью внутренней подпрограммы для предварительного вычисления результата сдвига и последующей подстановки результата вместо операции сдвига. Если сдвиг слишком большой или отрицательный, результат внутренней подпрограммы может отличаться от результата того же выражения сдвига, выполняемого ЦП.

Поведение переменных аргументов (varargs)

В архитектуре ARM параметры из списка переменных аргументов, передаваемые в стек, подлежат выравниванию. Например, 64-битный параметр выравнивается по 64-битной границе. На x86 и x64 аргументы, передаваемые через стек, не подлежат выравниванию и плотно упаковываются. Это различие может привести к тому, что вариативная функция printf будет считывать адреса памяти, которые на ARM предназначены для заполнения, если ожидаемая структура списка переменных аргументов не совпадает точно, хотя для некоторых значений на архитектурах x86 или x64 функция может работать нормально. Рассмотрим следующий пример:

// 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);

В этом случае ошибку можно исправить, обеспечив использование правильной спецификации формата, учитывающей выравнивание аргумента. Вот правильный код:

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

Порядок вычисления аргументов

Так как процессоры ARM, x86 и x64 различаются, они могут предъявлять разные требования к реализации компилятора, а также разные возможности для оптимизации. По этой причине, наряду с другими факторами, такими как соглашение о вызовах и параметры оптимизации, компилятор может обрабатывать аргументы функции в разном порядке в разных архитектурах или при изменении других факторов. Это может привести к непредвиденному изменению в работе приложения, использующего определенный порядок вычисления.

Подобная ошибка может возникнуть, если аргументы функции побочно влияют на другие аргументы функции в том же вызове. Обычно такой зависимости легко избежать, но она может быть затенена зависимостями, которые трудно распознать, или перегрузкой операторов. Рассмотрим следующий пример кода:

handle memory_handle;

memory_handle->acquire(*p);

Это выглядит четко, но если -> и * являются перегруженными операторами, то этот код переводится на что-то подобное:

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

И если между operator->(memory_handle) и operator*(p) есть зависимость, код может полагаться на определенный порядок оценки, даже если исходный код выглядит так, как будто нет никакой возможной зависимости.

volatile Поведение ключевого слова по умолчанию

Компилятор Microsoft C++ (MSVC) поддерживает две различные интерпретации volatile квалификатора хранилища, которые можно указать с помощью параметров компилятора. Параметр /volatile:ms выбирает расширенную семантику volatile Майкрософт, которая обеспечивает строгий порядок, традиционно принятый в архитектурах x86 и x64 из-за строгой модели памяти. Параметр /volatile:iso выбирает строгую семантику volatile в соответствии со стандартом C++, которая не обеспечивает строгий порядок.

В архитектуре ARM (за исключением ARM64EC), значение по умолчанию — /volatile:iso , так как процессоры ARM имеют слабо упорядоченную модель памяти, и так как программное обеспечение ARM не имеет устаревшего варианта использования расширенной семантики /volatile:ms и обычно не требует интерфейса с программным обеспечением, которое делает. Однако иногда бывает удобно или даже необходимо компилировать программу ARM с использованием расширенной семантики. Например, перенос программы с использованием семантики ISO C++ может оказаться слишком дорогостоящим, либо для правильной работы программного драйвера может требоваться соблюдение традиционной семантики. В таких случаях можно использовать параметр /volatile:ms. Но чтобы воссоздать традиционную семантику volatile на целевой платформе ARM, компилятор должен добавлять ограничения памяти вокруг каждой операции чтения или записи переменной volatile для обеспечения строгого порядка, что может отрицательно сказаться на производительности.

В архитектурах x86, x64 и ARM64EC по умолчанию используется значение /volatile:ms , так как большая часть программного обеспечения, уже созданного для этих архитектур с помощью MSVC, опирается на эти архитектуры. При компиляции программ x86 x64 и ARM64EC можно указать переключатель /volatile:iso , чтобы избежать ненужной зависимости от традиционной переменной семантики и повышения переносимости.

См. также

Настройка Microsoft C++ для процессоров ARM