Введение в ARM: изучаем неопределенное, не заданное спецификациями и зависящее от реализации поведение языка C++

С появлением Windows RT для ARM-устройств многие разработчики программного обеспечения для Windows впервые встретятся с ARM-процессорами. Разработчики на чистом C++ могут столкнуться с неопределенным, не обусловленным спецификациями или зависящим от реализации поведением программного кода, как это определено в языке С++. Это означает, что некоторые элементы кода будут работать на ARM-архитектуре иначе, чем на привычных для разработчиков под Windows-архитектурах x86 и x64.

Результаты некоторых операций в C++ при определенных обстоятельствах, согласно самой спецификации языка, являются неопределенными. В спецификациях используется термин «неопределенное поведение». Попросту говоря, это означает следующее: «Может случиться что угодно или вообще ничего. Полагайтесь только на себя». Казалось бы, такая неоднозначность для языка программирования весьма нежелательна. Но на самом деле именно она позволяет поддерживать C++ на очень многих аппаратных платформах без ущерба для производительности. В случаях, когда от программного кода не требуется строго определенного поведения, разработчики компиляторов могут руководствоваться исключительно здравым смыслом. Обычно в такой ситуации выбирают то, что проще для компилятора или наиболее эффективно для конкретной аппаратной платформы. Однако поскольку результат неопределенной операции нельзя прогнозировать по стандарту языка, то программам не следует полагаться на поведение конкретной платформы. Согласно спецификациям C++, случаи, когда участок кода ведет себя по-разному для различных архитектур процессора, поколений процессоров, настроек компилятора, по причине оптимизации или любой другой, полностью соответствуют стандарту.

Другая категория поведения, известная как «зависящая от реализации», похожа на неопределенное поведение тем, что спецификацией языка C++ не предписывается конкретный результат. Отличие состоит в том, что стандарт требует явного указания результатов таких операций в документации компилятора. Это дает поставщику право реализовать поведение так, как он считает нужным, но в то же время обязывает его предоставить пользователям необходимые сведения, даже если это поведение отличается на различных платформах или изменится в следующей версии компилятора.

Наконец, есть «не обусловленное спецификациями поведение». Это все то же поведение, зависящее от реализации, только производитель компилятора не обязан его документировать.

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

Чтобы помочь вам плавно перейти к разработке приложений для Windows РТ и ARM, мы собрали некоторые наиболее распространенные случаи, когда разработчики могут столкнуться с неопределенным, не обусловленным спецификациями или зависящим от реализации поведением в «рабочем» коде — с полнофункциональными примерами того, как это поведение проявляется на платформах ARM, x86 и x64 с использованием цепочки инструментов Visual C++. Приведенный ниже список ни в коей мере не является исчерпывающим. Кроме того, хотя конкретное поведение, приведенное в этих примерах, можно продемонстрировать на конкретных платформах, на указанные в примерах варианты опираться нельзя. Мы приводим описания наблюдаемого поведения только для того, чтобы помочь вам понять, как ваш собственный код может полагаться на такое поведение.

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

В случае преобразования значения с плавающей запятой в 32-разрядное целое число на платформе ARM оно округляется до ближайшего целочисленного значения, если значение с плавающей точкой выходит за пределы целочисленного диапазона. Например, при преобразовании в целое число без знака отрицательное значение с плавающей запятой всегда превращается в 0. Если же значение с плавающей точкой превышает максимальное целочисленное значение, оно преобразуется в 4294967295. При преобразовании в целое знаковое число значение с плавающей запятой превращается в -2147483648, если выходит за нижнюю границу целочисленного диапазона, и в 2147483637, если выходит за верхнюю. На архитектурах x86 и x64 такого округления не происходит. Если целевой тип является знаковым, то в ходе преобразования имеет место деление по модулю. Если он знаковый, то устанавливается значение -2147483648.

Различия еще более выражены для целочисленных типов менее 32 бит. Ни одна из рассматриваемых архитектур не поддерживает преобразование чисел с плавающей запятой в целое число менее чем 32 бит. Поэтому вначале преобразование выполняется как для 32-битной переменной, а затем происходит усечение до нужного числа битов. Ниже приводится результат конвертации числа +/-5 млрд (5e009) в различные знаковые и беззнаковые типы на каждой платформе.

Результаты преобразования числа  +5e+009 в различные знаковые и беззнаковые целочисленные типы

+5e+009

ARM  32 бита

ARM  16 бит

ARM  8 бит

x86/x64  32 бита

x86/x64  16 бит

x86/x64  8 бит

беззнаковое

4294967295

65535

255

705032704

0

0

знаковое

+2147483647

-1

-1

-2147483648

0

0

 

Результаты преобразования числа  -5e+009 в различные знаковые и беззнаковые целочисленные типы

-5e+009

ARM  32 бит

ARM  16 бит

ARM  8 бит

x86/x64  32 бит

x86/x64  16 бит

x86/x64  8 бит

беззнаковое

0

0

0

3589934592

0

0

знаковое

-2147483648

0

0

-2147483648

0

0

Как видите, простого шаблона, который описывал бы все случаи, нет. Округление происходит не всегда, а усечение не сохраняет знак числа.

Тем не менее некоторые случаи можно пояснить. На платформе ARM результатом преобразования значения NaN (не число) из типа с плавающей запятой в целочисленный является 0x00000000. На x86 и x64 получаем 0x80000000.

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

Операторы сдвига

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

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

Размер пространства сдвигов для каждой  архитектуры

Размер переменной

ARM

x86

x64

8

256

32

32

16

256

32

32

32

256

32

32

64

256

256

64

Рассмотрим несколько примеров. Обратите внимание, что в первой таблице идентичны столбцы x86 и x64, а во второй — x86 и ARM.

Рассмотрим целочисленную 32-битную  переменную, которой присвоено значение 1.

Число шагов сдвига

ARM

x86

x64

0

1

1

1

16

32768

32768

32768

32

0

1

1

48

0

32768

32768

64

0

1

1

96

0

1

1

128

0

1

1

256

1

1

1

 

Рассмотрим целочисленную 64-битную  переменную, которой присвоено значение 1.

Число шагов сдвига

ARM

x86

x64

0

1

1

1

16

32768

32768

32768

32

4294967296

4294967296

4294967296

48

2^48

2^48

2^48

64

0

0

1

96

0

0

4294967296

128

0

0

1

256

1

1

1

Чтобы помочь вам избежать этой ошибки, компилятор генерирует предупреждение warning C4295. Оно сообщает о том, что код использует сдвиги, которые являются небезопасно большими (или отрицательными). Но предупреждение выдается только в том случае, если величина сдвига является постоянной или явно указанной величиной.

Поведение volatile-переменных

В архитектуре ARM используется слабо упорядоченная модель памяти. Это означает, что поток следит за упорядоченностью собственных записей в память, но запись в память других потоков происходит в любом порядке, если только не были приняты дополнительные меры для синхронизации потоков. На платформах x86 и x64 используется строго упорядоченная модель памяти. Поток отслеживает как собственные записи в память, так и записи других потоков в том порядке, в котором они вносятся. Иными словами, строго упорядоченная архитектура гарантирует, что если поток B производит запись в область памяти X, а затем в область Y, то другой поток A не увидит обновления Y раньше, чем обновления X. Слабо упорядоченная модель памяти такой гарантии не предоставляет.

В прошлом volatile-переменные на платформах x86 и x64 обычно использовались (не совсем корректно) для обмена данными между процессами. Это традиционный смысл ключевого слова volatile в компиляторе Microsoft, и в этом значении оно широко используется в различном программном обеспечении. Тем не менее спецификация языка C++11 не требует строгой упорядоченности памяти, и поэтому полагаться на такое поведение в коде, который планируется сделать портируемым и стандартизованным, нельзя.

По этой причине компилятор Microsoft C++ теперь поддерживает два различных значения ключевого слова volatile. Указать нужное можно с помощью параметра командной строки. /volatile:iso задает поведение, которое соответствует стандарту С++ и не гарантирует строгого упорядочивания. /volatile:ms определяет поведение, заданное Microsoft, которое такое упорядочивание гарантирует.

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

И здесь начинается самое интересное.

На архитектуре ARM по умолчанию используется значение /volatile:iso, потому что программам под ARM нет необходимости полагаться на расширенную семантику. А для платформ x86 и x64 по умолчанию используется /volatile:ms, поскольку программа, написанная в расчете на компилятор Microsoft, вполне может полагаться на такую расширенную семантику. И если в программе действительно используются эти расширенные возможности, то применение ключа /volatile:iso сделает ее поведение полностью непредсказуемым.

Тем не менее при компиляции ПО под ARM иногда удобно или даже необходимо использовать ключ /volatile:ms — например, переписывать исходный код так, чтобы в нем использовались явные примитивы синхронизации, может быть слишком затратно. Необходимо отметить, что для обеспечения корректной работы ключа /volatile:ms на архитектуре ARM компилятор должен вставить в программу явно заданные границы памяти в программе, а они могут значительно увеличить требования к ресурсам и время работы.

Аналогичным образом программы для платформ x86 и x64, которые не полагаются на расширенную семантику volatile, рекомендуется компилировать с ключом /volatile:iso — это улучшит их портируемость и избавит компилятор от необходимости выполнять более агрессивную оптимизацию.

Порядок обработки аргументов

Код, который опирается на обработку аргументов функцией в определенном порядке, некорректен на любой архитектуре, поскольку стандарт C++ гласит, что порядок, в котором функция обрабатывает аргументы, не определен. Это означает, что при вызове функции F (A, B)мы не можем знать наперед, какой из аргументов, A или B, будет считан и обработан первым. На порядок обработки аргументов влияет не только тип платформы и компилятор, но даже соглашение о вызовах и настройки оптимизации.

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

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

  Foo foo;
 foo->bar(*p);

Он выглядит вполне корректным, но что если -> и * являются перегруженными операторами? Тогда код может принять следующий вид:

  Foo::bar(operator->(foo), operator*(p));

Таким образом, если operator->(foo) и operator*(p) каким-либо образом взаимодействуют, то приведенный фрагмент кода полагается на определенный порядок обработки аргументов, при том что на первый взгляд кажется, будто функция bar() принимает только один аргумент.

Функции с переменным числом аргументов

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

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

  // Обратите внимание, что функции передается 64-битное целое число, но для него используется маска формата '%d'.
 // На x86 и x64 это может работать при малых значениях, поскольку %d будет считывать только младшие 32 бита аргумента.
 // На ARM стек заполняется для выравнивания до 64 бит, и код будет печатать то значение,
 // которое ранее было сохранено в этих заполняемых битах.
 printf("%d\n", 1LL); 

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

    // Правильный вариант: для 64-битных целых чисел использовать формат %I64d
 printf("%I64d\n", 1LL) 

Заключение

Windows RT, работающая на процессорах ARM, является новой платформой для разработчиков приложений под Windows. Надеюсь, этот блог указал на некоторые подводные камни переносимости, которые могут скрываться в вашем коде, и немного облегчил вам задачу создания продуктов для Windows RT и Магазина Windows.

У вас есть вопросы, комментарии или собственные советы по обеспечению переносимости кода? Оставьте сообщение!