Неестественное выравнивание
Вот уже 10 лет прошло, как я переводил свои средства программирования в среду x86–64 для Windows 7. А как будто вчера было! Поскольку тогда многие особенности этой среды были для меня внове, они вызывали недоумение. Вот типичный пример. Моя программа вызывала тривиальную функцию WinAPI с названием InternetGetConnectedState. Ниже для иллюстраций я буду использовать собственный маленький интерактивный отладчик, который автоматически вставляется в каждый мой EXE-файл, т.е. он всегда под рукой.
Та программа и сегодня используется. Вот так выглядит место вызова этой функции в моей программе сейчас:
Рис. 1. Вызов функции WinAPI с выравниванием указателя стека на 16
Выделена команда выравнивания указателя стека на 16. 10 лет назад в начале перехода на x86–64 ее еще не было, поскольку я пропустил мимо ушей требования по выравниванию стека. Если сейчас я эту команду прямо в отладчике специально «забью» пустыми командами с кодом 90 (рис. 2.), то получится ситуация, которая возникла тогда, 10 лет назад:
Рис. 2. Вызов функции WinAPI без выравнивания указателя стека
Запускаю программу без выравнивания. Упс! Безвредная подпрограмма, всего лишь выдающая статус подключения к сети, вдруг вызвала исключение процессора, да еще по такому странному адресу команды 7FEFF122378, превышающему 2**32, т.е. явно не в моих кодах, а в кодах «высоко» по адресам загруженной библиотеки Wininet.dll.
Рис. 3. Исключение «неверный адрес» при обращении к WinAPI с не выровненным стеком
Что же там, в библиотеке, за странная команда по этому адресу?
Рис. 4. Запись в стек регистров XMM внутри Wininet.dll
Оказывается, там всего лишь команда запоминания (а ниже явно идет восстановление) регистров XMM. Вообще-то, эти регистры предназначены для параллельной обработки нескольких данных типа float. На кой черт здесь-то они нужны? Я не поленился отдельно поисследовать библиотеку Wininet.dll на предмет применения регистров XMM. Выяснилось, что эти «длинные» регистры используются просто для переписи данных порциями сразу по 16 байт, поскольку это «модно, стильно, молодежно», например:
Рис. 5. Применение регистров XMM в Wininet.dll для переноса произвольных данных по 16 байт
Но эти же 16-байтные регистры имеют особенность: записывать в память их надо по адресам, кратным 16, иначе произойдет аппаратное исключение. Строго говоря, это не совсем так. Например, буква U в аббревиатурах команд на рис. 5. расшифровывается как Unaligned, но в большинстве случаев действительно нужна кратность 16.
Таким образом, если разработчик WinAPI возжелал использовать внутри своей функции регистры XMM (якобы для скорости), то ему приходится запоминать эти регистры в стеке и затем восстанавливать, чтобы не портить программу пользователя, вдруг тот тоже их использует. Но запоминать их в стеке надо по адресу, кратному 16. Отсюда вытекает неестественное требование — при вызове функций WinAPI указатель стека должен быть кратен 16.
Почему это требование неестественное? А что происходит при выполнении одной процессорной команды вызова? В стек помещается адрес возврата, а затем управление передается на первую команду функции, правильно? Значит, указатель стека, подготовленный по правилам и кратный 16, уменьшится на 8 байт, а, значит, внутри функции WinAPI он тут же становится гарантированно НЕ кратен 16.
Разумеется, компиляторы все это учитывают и, в конце концов, регистры XMM внутри WinAPI можно писать в стек без возникновения исключений. Но это лишь иллюстрация того, что естественное выравнивание указателя стека именно на 8, а не на 16.
Казалось бы, а тебе-то, пользователю, какое дело, что там внутри системных функций Windows? Один раз при запуске программы выровни стек на 16, как положено, и все, забудь про выравнивание, обращайся к WinAPI на здоровье. Ну да, если я вызову ту же InternetGetConnectedState в самом начале своей программы, все сработает нормально. Но если я хочу вызывать эту же функцию из своей подпрограммы в программе, то указатель стека, уменьшившись на 8, уже не будет равным первоначально выровненному, и без дополнительного выравнивания все вылетит с указанной на рис. 3. ошибкой. А если хочу вызвать из подпрограммы внутри подпрограммы — опять можно обойтись без дополнительного выравнивания (так как стек уменьшился уже на 16) и т.д. Шансы на удачу 50%. Как у пресловутой вероятности встретить динозавра.
Причем, в самих системных библиотеках Windows, конечно, никаких лишних выравниваний стека нет — ведь компилятор знает о всегда не выровненном на 16 байт указателе стека при входе в каждую WinAPI-функцию. А вот в программе пользователя таких предположений компилятор сделать не может, так как глубина вложенности подпрограмм может зависеть от ряда условий и в целом от текущей оперативной обстановки. Вот и приходится на всякий случай выравнивать указатель стека перед каждым вызовом WinAPI.
Я как пользователь, понятия не имею, приспичит или нет разработчику WinAPI использовать регистры XMM, а, следовательно, реально нужно ли ему выравнивание стека на 16, при том, что при нормальной работе указатель стека всегда кратен 8, а не 16. Поскольку все адреса имеют размер 8 байт, указатель стека просто невозможно обычными действиями сделать не кратным 8. Конечно, можно его специально сделать не кратным ни 8, ни 16, но зачем?
Правильнее было бы выравнивать указатель стека на 16 лишь внутри некоторых WinAPI, и именно в тех местах, где идет запоминание XMM, а не заставлять это делать в сотнях программ и тысяче мест перед всеми вызовами системных функций, даже там, где вообще не используются регистры XMM.
Но здесь так принято! (Как в известном анекдоте про формирование традиций у обезьян).
Еще приходит на ум аналогия с посыпанием коммунальными службами дорог солью зимой. То, что они, тем самым, засаливают почву на века и портят обувь пешеходов, их не волнует — ведь свою задачу они выполняют с улучшением! Так и здесь: за счет 16 байт длинного XMM я ускорил свою функцию на несколько наносекунд! А то, что всем пользователям моей WinAPI теперь нужно кувыркаться с выравниванием стека на 16 перед каждым вызовом моей функции, и из-за этого объем кода растет, а быстродействие падает, меня не волнует. Это же снаружи!
Кстати, а какой командой лучше всего выравнивать стек? Поскольку нужно лишь обнулить младшие 4 бита указателя стека, в x86–64 можно использовать для этого разные части одного и того же регистра: SPL, SP, ESP или весь RSP.
Вот коды соответствующих команд:
Рис. 6. Виды команд выравнивания указателя стека на 16
Пусть Вас не смущает, что в последнем варианте константа указана лишь 4 байта, а не 8 байт, как вроде бы следовало. В таких случаях процессор автоматически расширяет старший бит на следующие 4 байта.
По длине, казалось бы, самый лучший — это самый короткий вариант №3. Однако, в общем случае, как раз его и нельзя использовать, поскольку при изменении значения ESP (но не SPL или SP), старшая часть RSP автоматически обнулится. В подавляющем большинстве случаев это очень удобно. Например, обнулить регистр RAX можно более короткой командой
XOR EAX,EAX
, а не «полной» командой
XOR RAX,RAX
Но в данном случае, можно испортить указатель стека, если его значение было больше 2**32.