Конвенции вызова: Microsoft vs Linux
Понятие модульности является одним из базовых в программировании. А если есть модули, то должны быть обеспечены эффективные методы их взаимодействия. Когда речь о подпрограммах в составе одного выполняемого файла, разработчик имеет право выбрать архитектуру самостоятельно. В иных случаях задачи сопряжения модулей, написанных различными командами разработчиков, подключение низкоуровневых ассемблерных библиотек и вызов функций API операционной системы невозможны без стандартизации «с точностью до бита». Сравним конвенции вызова, применяемые в 64-битных реализациях ОС Microsoft Windows и Linux, подсознательно продумывая вопросы унификации подходов к разработке кроссплатформенных приложений.
Обратимся к систематизирующей таблице из фундаментальной работы Агнера Фога.
Рис.1 Систематизация конвенций вызова:
- Scratchregisters— регистры, состояние которых может быть изменено вызываемой процедурой.
- Callee—saveregisters— регистры, которые вызываемая процедура обязана сохранять неизменными.
- Registersforparameterstransfer— регистры, используемые для передачи параметров от вызывающей к вызываемой процедуре (Input).
- Registersforreturn— регистры, используемые для возврата параметров от вызываемой к вызывающей процедуре (Output).
Microsoft x64 Calling Convention
Четыре первых параметра передаются в регистрах, последующие в стеке. В случае целочисленных значений или указателей это регистры RCX,RDX,R8,R9, в случае параметров с плавающей точкой это младшие биты векторных регистров XMM0-XMM3. Отсюда следует интересный вывод: полноценное функционирование 64-битной ОС на процессоре без поддержки SSE, невозможно. Для параметров, передаваемых в регистрах, место в стеке также резервируется, эта особенность называется Parameters Shadow. Отсюда следует, что пятый параметр (первый, передаваемый в стеке) после входа в подпрограмму будет находится по адресу RSP+40. 32 байта затрачено на Parameters Shadow для четырех 64-битных регистров, и 8 байт занимает 64-битный счетчик команд RIP. 32+8=40. Этот пример для внутрисегментного (NEAR) 64-битного вызова подпрограммы. Другая особенность состоит в так называемом пропуске регистра. Это означает, что если передается два параметра, первый целочисленный, второй с плавающей точкой, то будут использованы регистры RCX и XMM1. Пропускается XMM0. Аналогично будет пропущен RCX, если первый параметр с плавающей точкой второй целочисленный. Такое ограничение видимо аргументировано обеспечением регулярности модели передачи параметров, так как не связано с архитектурой процессора.
Функция может вернуть одно целое число в регистре RAX, либо одно число с плавающей точкой в младших битах регистра XMM0. Указатель стека RSP должен быть выровнен (кратен 16) на момент выполнения инструкции CALL.
Linux x64 Calling Convention
Для передачи целочисленных параметров используются 6 регистров: RDI, RSI, RDX, RCX, R8, R9. Для чисел с плавающей точкой 8 векторных регистров XMM0-XMM7, в младших битах регистров передаются скалярные величины. Предусмотрено расширение протокола для применения большего количества векторных регистров без нарушения совместимости с ранее написанным программным обеспечением. Подход Linux не содержит таких решений, как Parameters Shadow для параметров-регистров и пропуск регистра, поэтому здесь больше шансов обойтись без стековых переменных, снижающих производительность.
Функция может вернуть одно целое число в регистре RAX, либо одно число с плавающей точкой в младших битах регистра XMM0. В таблице упомянуты и другие регистры для выходных параметров, но в целях совместимости рекомендуется ограничиться указанными рамками.
Указатель стека RSP должен быть выровнен (кратен 16) на момент выполнения инструкции CALL. Здесь подходы Microsoft и Linux аналогичны, так как обусловлены применением SSE-инструкций для доступа к параметрам в стеке.
Linux x64 Calling Convention для системных вызовов
Инструкция системного вызова SYSCALL применяемая для Linux API, использует регистр RCX в качестве альтернативы стеку для быстрого сохранения содержимого счетчика команд RIP (об этом подробнее в следующем разделе). Поэтому регистр RCX не может содержать входной параметр. С учетом сказанного, список регистров хранящих 6 входных целочисленных параметров, для Linux API примет вид: RDI, RSI, RDX, R10, R8, R9.
Инструкции SYSCALL и SYSENTER
Требования безопасности во многом определяют модель взаимодействия программных компонентов. В архитектуре x86 определены 4 уровня привилегий, высший (Ring 0) принадлежит ОС, низший (Ring 3) отдается рядовому пользователю. Численное значение уровня привилегий хранится в двух младших битах регистра селектора сегмента кода CS. Переключение между уровнями требует большого количества проверок и вспомогательных операций. Одним из путей повышения производительности является упрощение этой операции для такого частного случая:
- Вызывающая процедура всегда является пользовательским приложением с уровнем привилегий Ring 3.
- Вызываемая процедура всегда является частью ядра ОС с уровнем привилегий Ring 0.
- Вложенные или рекурсивные вызовы подпрограмм не предусмотрены. Это дает возможность избавится от дополнительных операций с памятью, применяя для сохранения контекста процессора регистры общего назначения, либо Model-Specific регистры вместо стека.
В современных реализациях Linux, использование инструкции SYSCALL определено для вызова привилегированных процедур ОС непосредственно из пользовательских приложений.
Функции WinAPI вызываются обычной внутрисегментной инструкцией CALL, управление при этом передается системным библиотекам DLL, загруженным в адресное пространство приложения, внутри которых и скрыты процедуры межсегментной передачи управления.
Используется ли при этом SYSCALL или похожая инструкция SYSENTER? Интересующимся предлагаем дизассемблировать код Microsoft и ответить на этот вопрос самостоятельно.
Рис.2 Описание инструкции SYSCALL в документации Intel. Значения счетчика команд EIP(RIP) и регистра флагов EFLAGS(RFLAGS) сохраняются в регистрах RCX и R11 соответственно. Это быстрее, чем сохранять их в памяти (в стеке). После этого, в указанные регистры а также регистр сегмента кода CS загружается контекст подпрограммы из Model—Specific регистров процессора. Так реализована высокопроизводительная альтернатива классической инструкции вызова подпрограммы CALL FAR с операндом в памяти.
Рис.3 Описание инструкции SYSENTER в документации Intel. Значения регистров сегмента кода CS, счетчика команд EIP(RIP) и указателя стека ESP (RSP) загружаются из Model—Specific регистров процессора. Это высокопроизводительная альтернатива классической инструкции передачи управления JMP FAR с операндом в памяти.
Резюме
Было бы большой ошибкой, сказать «Linux лучше Windows потому, что использует больше регистров для передачи параметров» или «Windows лучше Linux так как обеспечивает единую модель вызова для системных и пользовательских функций». Спор сторонников двух ОС часто находится далеко за пределами дизайна аппаратно-программных платформ, уходя в политико-философские аспекты существования личности и цивилизации, а иногда и в «аналогии по Фрейду». А мы всего лишь рассмотрели частный технический аспект.
Литература
[1] Intel 64 and IA-32 Architectures Software Developer’s Manual. Combined Volumes: 1, 2A, 2B, 2C, 3A, 3B, 3C and 3D. Order Number: 325462-056US. September 2015.
[2] Calling conventions for different C++ compilers and operating systems. By Agner Fog. Technical University of Denmark. Copyright (C) 2004-2015. Last updated 2015-12-23.
CLI calli on x64 calling convention
Calli opcode requires a calling convention. By default it is stdcall , while extern «C» in native libraries uses cdecl . JIT recently allowed to inline methods with calli , but only with default calling convention. When I call a method with calli without unmanaged cdecl it works on x64 and performance is 58% faster than DllImport and 2.2x faster than unmanaged function pointer . (on netcoreapp2.1 , on net471 the difference is bigger: 82% and 5.5x ) When I run a method with calli unmanaged cdecl , performance is on par with DllImport (around 1% slower). I have read that on x64 there is no longer a mess with stdcall vs cdecl and all methods use cdecl (or fastcall , seen that in another place, cannot find a link). The difference only applies to x86 , where my call without unmanaged cdecl does indeed crash the app with segfault. The method in question is the following. For tests I use noop native method only to measure native call overhead.
.method public hidebysig static int32 CalliCompress(uint8* source, native int sourceLength, uint8* destination, native int destinationLength, int32 clevel, native int functionPtr) cil managed aggressiveinlining < .custom instance void System.Runtime.Versioning.NonVersionableAttribute. ctor() = <>// .maxstack 6 ldarg.0 ldarg.1 ldarg.2 ldarg.3 ldarg 4 ldarg 5 calli unmanaged cdecl int32 (uint8* source, native int sourceLength, uint8* destination, native int destinationLength, int32 clevel) ret >
My questions: 1) Is it safe to omit unmanaged cdecl after calli on x64 «by design» or I am just lucky with this example? If on x64 all calls are cdecl then I could use JIT treating static readonly fields as constants dispatch to appropriate methods for free just using if(IntPtr.Size == 8) else 2) What does caller or callee cleans the stack mean? My native function returns an int that is on the stack after the call. Is this the issue about who removes this int from the stack? Or there is some other work needs to be done with stack inside native function? I am in control of native function and could return the value via a ref parameter — will this make the issue with the stack cleaning irrelevant since no stack changes are made during the call?