CLR

.NET-разработка для процессоров ARM

Эндрю Парду

Продукты и технологии:

Microsoft .NET Framework, процессоры ARM

В статье рассматриваются:

  • процессоры ARM и различные версии .NET;
  • некоторые соображения для .NET-разработчиков, создающих ПО для процессоров ARM;
  • технические сведения о поддержке ARM в .NET.

В наше время мощной движущей силой рынка технологий являются потребители. Как показывает тренд, известный под названием «ориентация IT на потребителей», очень важными для всех потребителей технологий являются длительная работа аккумуляторов, принцип «всегда на связи» и богатая медийная среда. Чтобы обеспечить устройствам максимально длительную работу от аккумуляторов, Microsoft вводит Windows 8 в системы, построенные на процессорах ARM с малым энергопотреблением, — в настоящее время такими процессорами снабжено большинство устройств. В этой статье я подробно рассмотрю вопросы, относящиеся к Microsoft .NET Framework и процессорам ARM, что должны учитывать .NET-разработчики и что нам в Microsoft потребовалось ввести в .NET для поддержки ARM.

Как .NET-разработчик вы понимаете, что написание приложений, способных выполняться на самых разнообразных процессорах, — дело далеко не простое. Архитектура набора инструкций (instruction set architecture, ISA) процессоров ARM несовместима с таковой в процессорах x86. Приложения, изначально рассчитанные на выполнение на процессорах x86, отлично работают и на процессорах x64, потому что ISA процессора x64 является надмножеством ISA процессора x86. Но это не так в отношении выполнения «родных» x86-приложений на ARM — их нужно перекомпилировать, чтобы они работали на несовместимой архитектуре. Возможность выбора из широкого спектра различных устройств прекрасна для потребителей, но создает сложности для разработчиков.

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

Путь к ARM: прошлое и настоящее .NET

.NET Framework уже работает на процессорах ARM, но это не совсем та версия .NET Framework, которая выполняется на настольных компьютерах. Еще когда мы только начинали работать над первой версией .NET, мы осознавали, что возможность написания легко портируемого между процессорными платформами кода является ключом к успеху нашего предложения для повышения производительности труда разработчиков. В те времена на настольных компьютерах доминировали процессоры x86, но существовало огромное разнообразие процессоров для встраиваемых и мобильных устройств. Чтобы разработчики могли использовать эти процессоры в качестве целевых для своих приложений, мы создали версию .NET Framework, которую назвали .NET Compact Framework, — она работала на устройствах с ограниченными процессорными ресурсами и памятью.

Первыми устройствами, которые стала поддерживать .NET Compact Framework, имели всего 4 Мб памяти и процессор с тактовой частотой 33 МГц. В архитектуре .NET Compact Framework основное внимание было уделено эффективной реализации (чтобы она работала на столь ограниченных устройствах) и портируемости (чтобы ее можно было выполнять на широком спектре процессоров, распространенных в то время на мобильных и встраиваемых устройствах). Но самые популярные мобильные устройства — смартфоны — теперь имеют конфигурации, сравнимые с компьютерами десятилетней давности. Напомню: первая версия .NET Framework для настольных компьютеров была рассчитана на машины под управлением Windows XP с процессором, тактовая частота которого была не менее 300 МГц, и 128 Мб памяти. Сегодня устройства с Windows Phone требуют минимум 256 Мб памяти и современного ARM-процессора Cortex.

.NET Compact Framework все еще является важной частью инструментария для разработчиков, использующих Windows Embedded Compact. Встраиваемые устройства по-прежнему имеют ограниченные конфигурации, зачастую на уровне 32 Мб памяти. Мы также создали версию .NET Framework, которую назвали .NET Micro Framework; она работает даже на устройствах с 64 Кб памяти. Так что на самом деле у нас три версии .NET Framework, каждая из которых выполняется на своем классе процессоров. Но теперь наш флагманский продукт, настольная версия .NET Framework, впервые присоединилась к инфраструктурам Compact и Micro и, наконец-то, работает на процессорах ARM.

Выполнение на ARM

Хотя .NET Framework проектировалась как нейтральная к платформам, за время своего существования она в основном использовалась на оборудовании с процессорами x86. А это означает, что некоторые специфичные для x86 шаблоны уже проникли в коллективный разум .NET-программистов. Разумеется, для того и существует эта инфраструктура, чтобы вы не думали о специфике процессоров, а сосредоточились на создании своих приложений, но при написании .NET-кода, который будет выполняться на процессорах ARM, все же следует учитывать несколько моментов. К ним относятся менее строгая модель памяти и более строгие требования к выравниванию данных, а также некоторые места, где параметры функций обрабатываются по-разному. Наконец, несколько операций по конфигурированию проектов в Visual Studio отличаются, если вы ориентируетесь на устройства. Я рассмотрю каждый из этих моментов.

Менее строгая модель памяти (weaker memory model) Понятие «модель памяти» относится к видимости изменений, вносимых в глобальное состояние в многопоточной программе. Программа, разделяющая данные между двумя и более потоками, обычно использует блокировку для таких общих данных. В зависимости от конкретной блокировки, если один поток обращается к этим данным, остальные потоки, пытающиеся получить доступ к тем же данным, блокируются до тех пор, пока первый поток не закончит работу с общими данными. Но блокировки не обязательны, если вам известно, что каждый поток, обращающийся к общим данным, будет оперировать ими, не вмешиваясь в их представление у других потоков. Программирование в таком стиле называют алгоритмом, свободным от блокировок.

Проблема с алгоритмами, свободными от блокировок, возникает, когда вы не знаете точного порядка выполнения вашего кода. Современные процессоры переупорядочивают инструкции, чтобы достигать прогресса в работе на каждом такте, и объединяют операции записи в память для уменьшения латентности. Хотя эти оптимизации выполняют почти все процессоры, есть различия в том, как упорядочение операций чтения и записи представляется программе. На платформе x86 гарантируется, что внешне будет казаться, будто процессор выполняет большинство операций чтения и записи в том порядке, в каком они указаны в программе. Эту гарантию называют строгой моделью памяти (strong memory model), или строгим порядком записи (strong write ordering). ARM-процессоры дают меньше гарантий: в целом, они свободны в перемещении операций, если это не меняет то, как код выполнялся бы в однопоточной программе. ARM-процессор предоставляет определенные гарантии, которые позволяют с осторожностью конструировать код, свободный от блокировок, но это как раз то, что называют нестрогой моделью памяти (weak memory model).

Любопытно, что сама .NET Framework CLR имеет нестрогую модель памяти. Все ссылки на упорядочение записи в спецификации ECMA «Common Language Infrastructure (CLI)» (доступна в виде PDF по ссылке bit.ly/1Hv1xw) — стандарте, которому отвечает CLR, — относятся к доступу к изменяемым (volatile) переменным. В C# это означает доступ к переменным, помеченным ключевым словом volatile (см. раздел 12.6 в спецификации CLI). Но в последнее десятилетие большая часть управляемого кода выполнялась в x86-системах, и JIT-компилятор CLR мало чего добавил к переупорядочению, разрешаемому оборудованием, поэтому было сравнительно мало случаев, где при этой модели памяти проявились бы скрытые ошибки параллельной обработки. Это могло бы представлять проблему, если бы предполагалось, что управляемый код, написанный и протестированный только для x86-систем, будет работать в ARM-системах точно так же.

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

static bool isInitialized = false;
static SomeValueType myValue;
if (!isInitialized)
{
  myValue = new SomeValueType();
  isInitialized = true;
}
myValue.DoSomething();

Чтобы сделать этот код корректным, просто укажите volatile для флага isInitialized:

static volatile bool isInitialized = false;

Выполнение этого кода без переупорядочения показано в левом блоке иллюстрации на рис. 1. Поток 0 первым инициализирует SomeValueType в своем локальном стеке и копирует локально созданную SomeValueType в глобальный участок AppDomain. Поток 1 проверяет isInitialized и определяет, что ему тоже нужно создать SomeValueType. Но это не проблема, так как данные записываются обратно в тот же глобальный участок AppDomain. (Чаще всего, как в этом примере, любые изменения, вносимые методом DoSomething, являются идемпотентными.) 

Переупорядочение записи
Рис. 1. Переупорядочение записи

Execution Without Reordering Выполнение без переупорядочения
Execution Time Время выполнения
Thread 0 Start Старт потока 0
READ global isInitialized Чтение глобальной isInitialized
INITIALIZE local SomeValueType on Thread 0 stack Инициализация локальной SomeValueType в стеке потока 0
WRITE local SomeValueType to AppDomain global location Запись локальной SomeValueType в глобальный участок AppDomain
WRITE isInitialize to true Запись в isInitialize значения true
CALL myValue. DoSomething(), WRITE state to AppDomain global Вызов myValue. DoSomething(), запись состояния в глобальный участок AppDomain
Thread 1 Start Старт потока 1
READ global isInitialized Чтение глобальной isInitialized
INITIALIZE local SomeValueType on Thread 1 stack Инициализация локальной SomeValueType в стеке потока 1
Execution with WRITE Reordering Выполнение с переупорядочением записи
Create new local SomeValueType on Thread 0 stack Создание новой локальной SomeValueType в стеке потока 0
Context switch, execution stalls Переключение контекста, выполнение приостанавливается
ERROR! AppDomain global DoSomething is not initialized memory. Ошибка! Глобальный DoSomething в AppDomain не инициализировал память

В правом блоке на той же иллюстрации показано выполнение того же кода в системе, которая поддерживает переупорядочение записи (и удобно помещает команду приостановки выполнения). Этот код не будет выполняться корректно, так как поток 1, считав значение isInitialized, определяет, что SomeValueType не требует инициализации. Вызов DoSomething относится к неинициализированной памяти. Любые данные, считанные из SomeValueType, будут установлены CLR в 0.

Ваш код не будет часто выполняться некорректно из-за такого переупорядочения, но все же это будет случаться. Подобные переупорядочения совершенно законны — порядок операций записи не имеет значения при выполнении в одном потоке. При написании параллельного кода лучше всего помечать изменяемые переменные ключевым словом volatile.

CLR разрешено предоставлять более строгую модель памяти, чем требуется в ECMA-спецификации CLI. На платформе x86, например, модель памяти CLR строгая, поскольку модель памяти этих процессоров является таковой. Группа .NET могла бы сделать модель памяти на ARM такой же строгой, как и на x86, но обеспечение эффективного переупорядочения везде, где это возможно, способно значительно повысить быстродействие кода. Мы проделали целенаправленную работу по «усилению» модели памяти на ARM, а именно: мы вставили барьеры памяти в ключевые точки при записи в управляемую кучу, чтобы гарантировать безопасность типов, но старались добиться того, чтобы это лишь незначительно снижало производительность. Эта архитектура была многократно проанализирована экспертами, чтобы получить уверенность в корректности методик, примененных в ARM CLR. Более того, эталонные тесты производительности показывают, что быстродействие .NET-кода масштабируется точно так же, как неуправляемого кода на C++, при сравнении выполнения на платформах x86, x64 и ARM.

Если ваш код полагается на алгоритмы, свободные от блокировок, которые зависят от реализации x86 CLR (а не ECMA CLR), вам придется добавить ключевое слово volatile к релевантным переменным там, где в этом есть необходимость. После того, как вы пометите общее состояние изменяемым, об остальном позаботится CLR. Если вы похожи на большинство других разработчиков, вы готовы к работе на ARM, так как уже используете блокировки для защиты общих данных и должным образом пометили изменяемые переменные.

Требования к выравниванию данных Другое различие, которое может повлиять на некоторые программы, заключается в том, что ARM-процессоры требуют выравнивания некоторых данных. Конкретные шаблоны, где применяется выравнивание, — 64-битные значения (т. е. int64, uint64 или double), не выровненные по границе 64 бит. CLR берет выравнивание на себя, но есть два способа использования не выровненного типа данных. Первый из них — явно указать разметку структуры с собственным атрибутом [ExplicitLayout], а второй — некорректно задать разметку структуры, передаваемой между управляемым м неуправляемым кодом.

Если вы заметите, что P/Invoke-вызов возвращает мусор, вам стоит проверить все структуры, подвергаемые маршалингу. Так, мы устранили ошибку при портировании некоторых .NET-библиотек, в которых COM-интерфейс передавал структуру POINTL, содержащую два 32-битных поля, в функцию в управляемом коде, которая принимала 64-битный double как параметр. Эта функция использовала битовые операции, чтобы получить два 32-битных поля. Вот упрощенная версия этой ошибочной функции:

void CalledFromNative(int parameter, long point)
{
  // Распаковываем неуправляемый POINTL из long point
  int x = (int)(point & 0xFFFFFFFF);
  int y = (int)((point >> 32) & 0xFFFFFFFF);
  ...  // здесь что-то делаем с POINTL
}

Неуправляемый код не требовал выравнивания структуры POINTL по 64-битной границе, так как содержал два 32-битных поля. Но ARM требует выравнивания 64-битного double при передаче в управляемую функцию. Таким образом, если ваши типы требуют выравнивания, крайне важно убедиться, что указываемые вами типы одинаковы на обеих сторонах вызова между управляемым и неуправляемым кодом.

Visual Studio Большинство разработчиков никогда не заметит обсуждавшиеся здесь различия, потому что .NET-код по своей природе не является специфичным для какой-либо процессорной архитектуры. Однако есть некоторые особенности при профилировании или отладке из Visual Studio приложений Windows 8 на ARM-устройстве, поскольку сама Visual Studio не работает на ARM-устройствах.

Если вы писали приложения для Windows Phone, то уже знакомы с таким процессом кросс-платформенной разработки. Visual Studio выполняется в вашей x86-системе, а вы запускаете свое приложение удаленно на устройстве или в эмуляторе. Приложение использует прокси, установленный на устройстве, для взаимодействия с вашим компьютером разработки через IP-соединение. Кроме начальных этапов установки, остальное, включая отладку и профилирование, выполняется одинаково на всех процессорах.

Еще один момент, о котором нужно знать: в параметрах проекта Visual Studio в качестве целевого процессора добавляется выбор ARM. Но обычно вы выбираете не ARM, а AnyCPU в качестве целевой платформы, когда пишете .NET-приложения для Windows на ARM, и ваше приложение будет работать на всех архитектурах Windows 8.

Углубляемся в поддержку ARM

Дочитав статью до этого места, вы уже не так мало знаете о процессорах ARM. Теперь я хотел бы поделиться некоторыми интересными, глубоко техническими подробностями о работе группы .NET над поддержкой ARM. Вам не придется делать эту работу в своем .NET-коде — это просто быстрый взгляд за кулисы, чтобы вы представляли, какие задачи мы решали.

Большинство изменений в самой CLR были достаточно прямолинейными, поскольку CLR изначально проектировалась с учетом портируемости на разные архитектуры. Но нам все же пришлось внести несколько изменений, удовлетворяющих ARM Application Binary Interface (ABI). Нам также потребовалось переписать в CLR ассемблерный код, чтобы он работал с ARM, и модифицировать наш JIT-компилятор для генерации инструкций ARM Thumb 2.

ABI определяет программируемый интерфейс процессора. Он аналогичен API, который указывает, какие функции ОС доступны через него программным способом. Три области ABI, которые влияли на нашу работу: соглашение по вызову функций, соглашение по регистрами информация для раскрутки стека вызовов (call stack unwind information). Я рассмотрю каждую из этих областей.

Соглашение по вызову функций Это контракт между кодом, вызывающим функцию, и вызываемой функцией. Соглашение определяет, как размещаются в памяти параметры и возвращаемые значения, а также какие регистры должны сохранять свои значения в течение вызова. Чтобы вызовы функций работали через границы (например, вызов ОС из программы), генераторы кода должны генерировать вызовы функций в соответствии с соглашением, устанавливаемым процессором, в том числе выравнивать 64-битные значения.

ARM был первым 32-разрядным процессором, где CLR приходилось выравнивать параметры и объекты в управляемой куче по 64-битной границе. Самым простым решением было бы выравнивание всех параметров, но ABI требует от генератора кода не оставлять «пузырей» в стеке, когда в выравнивании нет никакой нужды; это необходимо, чтобы избежать падения производительности. Тем самым на ARM-процессоре простая операция заталкивания в стек набора параметров усложняется. Поскольку пользовательская структура может содержать int64, в группе CLR было решено использовать один бит в каждом типе для указания того, требует ли он выравнивания. Это дает CLR достаточно информации, чтобы гарантировать, что вызовы функций, содержащие 64-битные значения, не повредят случайно стек вызовов.

Соглашение по регистрам Требование выравнивания данных распространяются и на регистры ARM-процессора, когда структура полностью или частично заполняет их. Это означает, что мы должны были модифицировать код внутри CLR, который перемещает часто используемые данные из памяти в регистры, чтобы гарантировать должное выравнивание данных в регистрах. Эта работа понадобилась в двух ситуациях: во-первых, 64-битные значения должны начинаться в четных регистрах, а во-вторых, гомогенные агрегаты с плавающей точкой (homogeneous floating-point aggregates, HFA) размещались в соответствующих регистрах.

Если генератор кода помещает int64 в регистры ARM, он должен записывать его в пару «четный-нечетный», т. е. R0-R1 или R2-R3. Протокол для HFA допускает до четырех значений с плавающей точкой типа double или single в гомогенной структуре. Если они помещаются в регистры, они должны храниться либо в наборе регистров S (single), либо D (double), но не в регистрах общего назначения (R).

Информация для раскрутки Запоминаются действия, оказываемые вызовом функции на стек, и фиксируется, где сохраняются неизменяемые регистры (nonvolatile registers) в ходе вызовов. На платформе x86 операционная система Windows проверяет FS:0, чтобы увидеть связанный список информации о регистрации исключения каждой функции в случае необрабатываемого исключения. В 64-разрядную Windows введена концепция информации раскрутки, позволяющая Windows проходить по стеку при возникновении необрабатываемого исключения. В архитектуре ARM эта информация расширена еще больше. Соответственно генераторы кода в CLR нужно было изменить для адаптации к новой архитектуре.

Самое заметное изменение, которое вы увидите при переносе своего приложения на ARM, — отличие в производительности от настольных процессоров.

Ассемблерный код Хотя движок исполняющей среды CLR написан в основном на C++, у нас есть ассемблерный код, который нужно переносить на каждый новый процессор. Большая часть этого ассемблерного кода представляет собой то, что мы называем функциями-переходниками (stub functions), или просто переходниками. Переходники выступают в роли «интерфейсного клея», позволяющего нам связывать воедино части исполняющей среды, скомпилированных C++ и JIT. Остальной ассемблерный код в CLR написан для максимальной производительности. Например, барьер записи сборщика мусора (garbage collector write barrier) должен работать чрезвычайно быстро, поскольку он часто вызывается — всякий раз, когда объектная ссылка записывается в объект, находящийся в управляемой куче.

Один из примеров переходника — шлюз перестановки (shuffle thunk). Такое название он получил потому, что переставляет значения параметров между регистрами. Иногда CLR приходится изменять размещение параметров в регистрах перед самым вызовом функции. CLR использует шлюз перестановки для выполнения этой задачи при вызове делегатов.

Концептуально, вызывая делегат, вы просто вызываете метод Invoke. Но на самом деле CLR выполняет неявный вызов через поле делегата, а не вызов именованного метода (кроме случая, когда вы явным образом вызываете Invoke через механизм отражения). Этот способ намного быстрее, чем вызов именованного метода, так как исполняющая среда может просто подменить экземпляр делегата (полученный по целевому указателю) на делегат в вызове функции. То есть для экземпляра foo делегата d вызов метода d.Member преобразуется в вызов метода foo.Member.

Если вы выполняете вызов закрытого экземпляра делегата (closed instance delegate call), указатель this сохраняется в первом регистре, используемом для передачи параметров, R0, а первый параметр записывается в следующий регистр, R1. Но это работает, тогда когда у вас есть делегат, связанный с методом экземпляра. Что будет, если вызвать открытый статический делегат? В этом случае ожидайте, что первый параметр будет записан в R0 (так как указателя this нет). Шлюз перестановки перемещает первый параметр из R1 в R0, второй параметр — в R1 и т. д., как показано на рис. 2. Поскольку предназначение шлюза перестановки — перемещать значения из регистра в регистр, его приходится переписывать под каждый процессор.

Рис. 2. Шлюз перестановки перемещает значения параметров между регистрами
Рис. 2. Шлюз перестановки перемещает значения параметров между регистрами

foo this pointer указатель this для foo
parameter 0 параметр 0
parameter 1 параметр 1
parameter 2 параметр 2
parameter 3 параметр 3
Call Through Instance foo Вызов через экземпляр foo
Call Through Static Delegate Вызов через статический делегат

Заключение

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

Уверен, что появление Windows 8 на платформе ARM станет великим событием как для разработчиков, так и для конечных пользователей. ARM-процессоры особенно хороши для продления непрерывной работы устройства от аккумуляторов, так как потребляют очень мало электроэнергии, поэтому на их основе можно создавать легкие, компактные и всегда подключенные к сети устройства. Самое заметное изменение, которое вы увидите при переносе своего приложения на ARM, — отличие в производительности от настольных процессоров. Но сначала убедитесь, что ваш код действительно работает на ARM, — не думайте, что достаточно разработки под x86. Для большинства разработчиков больше ничего не понадобится. А если у вас возникнут какие-либо проблемы, вы можете вернуться к этой статье и получить представление о том, откуда следует начинать поиск ошибок.


Эндрю Парду (Andrew Pardoe)менеджер программ в группе CLR, помогает разработчикам Microsoft .NET Framework охватить процессоры всех типов. Его любимым процессором остается Itanium. С автором можно связаться по адресу Andrew.Pardoe@microsoft.com.

Выражаю благодарность за рецензирование статьи экспертам Брэндону Брею (Brandon Bray), Лейле Дрисколл (Layla Driscoll), Эрику Эйлебрехту (Eric Eilebrecht) и Руди Мартину (Rudi Martin).