Общие вопросы использования Visual C++ ARM

При использовании компилятора Microsoft C++ (MSVC) один и тот же исходный код на C++ может давать в архитектуре ARM результат, отличный от результата в архитектуре x86 или x64.

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

Многие проблемы, возникающие при переносе кода из архитектуры 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 нечисловое значение преобразуется в 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 аргументы, передаваемые в стек, не подлежат выравниванию и размещаются плотно. Это различие может привести к тому, что в ARM вариадическая функция, например printf, будет считывать адреса памяти, используемые для заполнения, если структура списка переменных аргументов не точно соответствует ожидаемой, хотя для некоторых значений в архитектуре 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 по умолчанию

Компилятор 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 , чтобы избежать ненужной зависимости от традиционной переменной семантики и повышения переносимости.

См. также

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