Операционные системы на базе набора команд x86-64 в контексте низкоуровневого программирования

ПРИКЛАДНАЯ ИНФОРМАТИКА / JOURNAL OF APPLIED INFORMATICS

\ Vol. 10. No. 6 (60). 2015 \ _

В. Ю. Пирогов, канд. физ.-мат. наук, доцент, заведующий кафедрой прикладной информатики и экономики Шадринского государственного педагогического института, Vladislav-133@yandex.ru

Операционные системы на базе набора команд x86-64 в контексте низкоуровневого программирования

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

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

Введение

Термин x86-64 имеет несколько синонимов, таких, например, как Intel 64, AMD64 и др. Это вносит определенную нечеткость в изложение, хотя, по сути, имеется в виду одно и то же — 64-битовое расширение архитектуры x86. Расширение было разработано еще в конце 1990-х годов [6]. С начала 2000-х годов началось программное освоение возможностей 64-битовой архитектуры [6], вначале в форме новых 64-битовых операционных систем и обслуживающего их программного обеспечения. Основной режим, поддерживающий 64-битовые приложения, был назван Long Mode [3], т. е. длинный режим.

В то время казалось, что полный переход к 64-битовым системам уже не за горами. Другими словами: 1) с 64-битовыми процессорами будут использовать полноценные 64-битовые операционные системы; 2) для 64-битовых операционных систем будут разработаны полноценные 64-битовые приложения.

Однако процесс перехода затянулся более чем на 10 лет. Сейчас нельзя говорить о пол-

ном завершении 64-битовой революции для компьютеров, работающих на семействе процессоров x86-64. Основные причины такого медленного развития событий следующие. Во-первых, процессоры x86-64 имеют режим совместимости с 32-битовыми системами (в документации он называется Legacy Mode [3], т. е. наследуемый режим). Другими словами, 32-битовые операционные системы также могут на них эксплуатироваться. Но если хорошо зарекомендовавшие себя 32-битовые ОС годятся для использования, то можно по-прежнему эксплуатировать и огромный ресурс прикладного программного обеспечения, созданного для этих систем. А значит, можно продолжать разрабатывать новое программное обеспечение для 32-битовых ОС и продолжать сопровождать старое.

Во-вторых, в новой архитектуре в режиме поддержки 64-битового режима можно использовать подрежим, позволяющий запускать 32-битовое программное обеспечение (в документации это Compatibility Mode [3], т. е. режим совместимости), поддержка которого, разумеется, была включена в новые операционные системы. Следовательно, даже если и произошел переход на 64-битовые опера-

ционные системы, 32-битовое программное обеспечение можно по-прежнему эксплуатировать и не только использовать, но продолжать разрабатывать новые 32-битовые приложения.

Теоретически разработчики 32-битовых программ могут вообще не переходить к 64-битовому программированию, поскольку весьма вероятно, что производители операционных систем не решатся убрать режим совместимости. В качестве дополнительной причины того, почему переход осуществлялся столь медленно, можно указать на не слишком большой выигрыш в скорости выполнения 64-битовых приложений по отношению к 32-битовым. Другими словами, стоит ли затрачивать средства для разработки новой 64-битовой версии программы из-за выигрыша в производительности в каких-то 10-15%. Наконец, сыграла свою роль и невозможность использования в новых операционных системах драйверов, написанных для 32-битовых систем [2], что дало задержу в переходе на 64-битовые операционные системы на начальной стадии внедрения.

Особенности 64-битовой архитектуры

Следует отметить две важнейшие особенности, возникшие с появлением систем на основе архитектуры х86-64. Первая особенность является просто отражением самой архитектуры процессора, вторая связана с изменением в соглашении о вызовах в новых операционных системах. Есть, разумеется, и другие новации в 64-битовых системах, но они не столь существенны, и на них мы останавливаться не будем.

Основным изменением в новой архитектуре является расширение количества и разрядности регистров общего назначения. Проще всего продемонстрировать новую регистровую архитектуру х86-64 с помощью рисунка (рис. 1 — несколько переработанная автором схема из [3]).

Появление новых регистров общего назначения, несомненно, расширяет возможности программных систем в части хранения большего объема данных непосредственно в процессоре, без использования оперативной памяти. С другой стороны, увеличение разрядности регистров позволяет быстрее оперировать более точными данными. Действительно, если 64-битовое вещественное число изначально можно было хранить в регистрах математического сопроцессора (см. рис. 1), то для хранения его в регистрах общего назначения требовалось два регистра.

Но теперь с расширением разрядности эта проблема устранена. В 64-битовом режиме осталась возможность использовать 32-битовую часть регистров общего назначения (см. рис. 1) как самостоятельные регистры, т. е. старые алгоритмы достаточно легко портировать в новый режим, в том числе и на низком уровне. Этому, в частности, способствует продуманная система расширения 32-битовых команд. Например, 32-битовая команда mov eax, c, где c — некоторая числовая константа, по сути, эквивалентна 64-битовой команде mov rax, c. Отметим также, что переход к 64-битовым регистрам означает и переход к 64-битовой адресации (новый указатель команд rip имеет 64-битовую разрядность), что расширяет возможности программирования в таких системах. Особенно это касается программ, оперирующих большими массивами данных, например, при обработке медиаресурсов или больших баз данных, в системах автоматического проектирования и т. д.

Вторая особенность новых систем связана с соглашениями о вызовах (calling convention) (подробно о соглашениях вызова см. [4; 5]). Соглашение о вызовах описывает порядок передачи параметров в процедуру, механизм возвращения данных из процедуры, а также способ восстановления стека. В 32-битовых операционных системах как Windows, так и в разновидностях 32-битовых Unix-систем было распространено несколько соглашений

[ 71 ]

Регистры общего назначения

Мультимедийное расширение и регистры чисел с плавающей точкой

rax

rbx

rcx

rdx

rbp

rsi

rdi

rsp

r8

r9

r10

r11

r12

r13

r14

r15

63

0

Регистр флагов

eflags

31 0 Указатель команд

rip

Регистры потокового SIMD расширения

mm0/st0 mm1/st1 mm2/st2 mm3/st3 mm4/st4 mm5/st5 mm6/st6 mm7/st7

xmm0

xmm1

xmm2

xmm3

xmm4

xmm5

xmm6

xmm7

xmm8

xmm9

xmm10

xmm11

xmm12

xmm13

xmm14

xmm15

63

0 63

0

127

0

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

Регистры, поддерживаемые только в 64-битовом режиме

Рис. 1. Программные регистры в архитектуре x86-64 Fig. 1. Program registers of x86-64 architecture

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

В новых 64-битовых системах приняты единые соглашения о вызовах, хотя и разные для операционных систем Windows и Unix. В основе этих соглашений о передаче лежит принцип: часть параметров передается через регистры. Конечно, подобные соглашения существовали и в 32-битовых системах, но они

были не единственными. Кроме того, количество регистров общего назначения в 32-битовых процессорах было не столь велико, что могло вызвать определенные трудности.

В дальнейшем все операционные 64-битовые системы семейства Windows мы будем называть Windows-64, а все 64-битовые Unix-подобные системы — Unix-64.

Остановимся вначале на операционной системе Windows-64. Вот основные положения соглашения о вызовах [4]:

1. Первые четыре параметра передаются в процедуру через регистры: rcx, rdx, r8, г9 соответственно. Остальные параметры (если они есть) передаются через стек.

2. Перед вызовом процедуры резервируется область в стеке (теневая область — shadow space) на случай, если вызываемая процедура «захочет» временно сохранить параметры, передаваемые через регистры, в стеке. Те же параметры, которые передаются через стек, помещаются в старшие по отношению к выделенной области адреса.

3. Параметры, размер которых меньше 64 бит, передаются как 64-битовые значения. При этом обнуляются старшие биты. Параметры, большие 64 бит, передаются по ссылке.

4. Данные возвращаются через регистр rax. Если возвращаемое значение имеет размер, больший 64 бит, то данные передаются через область памяти, адрес которой передается в первом параметре (регистр rcx).

5. Все регистры при вызове функций сохраняются, за исключением rax, rcx, rdx, r8, r9, r10, r11, сохранность которых не гарантируется.

6. Стек восстанавливается вызывающей стороной.

7. Адрес вершины стека должен быть кратен 16.

8. Соглашением также предусмотрено, что для передачи параметров могут использоваться четыре 128-битовых регистра xmm0, xmml, xmm2, xmm3. Мы подробно не останавливаемся на алгоритме использования этих регистров. Их нужно задействовать для передачи чисел вещественного типа (double). Однако общее количество регистров, используемых для передачи параметров, все равно должно быть 4. Таким образом, если первые четыре параметра передаются регистрами общего назначения, то регистры xmm0-xmm3 вообще не используются. Если, например, второй параметр имеет тип double, то для его передачи должен использоваться регистр xmml, но тогда регистр rdx для передачи параметров уже использоваться не будет.

Обратим внимание на следующую особенность соглашения о передаче параметров. Несмотря на то что первые четыре параметра передаются через регистры, для них все рав-

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

Рассмотрим в общих чертах вызов процедуры, имеющей пять параметров с использованием нотации языка ассемблера, в представленном ниже фрагменте (листинг 1), pari- par5 — передаваемые параметры.

Листинг 1. Схема вызова процедуры в Windows-64

Listing 1. Scheme of procedure call in Windows-64

sub rsp,4 0 ; резервируем стек

mov qword ptr [rsp+32],par5

mov r9,par4

mov r8,par3,

mov rdx,par2

mov cdx,par1

call proc1

add rsp,4 0 восстанавливаем стек

Как видим, отпала необходимость в использовании привычной для 32-битовых систем инструкции push. Причина неудобства данной инструкции заключается в том, что с ней пришлось бы резервировать и освобождать стек при каждом вызове процедуры. Если же использовать представленную выше схему вызова, то зарезервировать стек можно один раз в начале кода, а в конце восстановить стек, как это показано в тексте фрагмента с помощью команды add.

Обратим внимание на выравнивание указателя стека. Если считать, что изначально адрес вершины (содержимое регистра rsp) был кратен 16, то после вызова процедуры общее смещение указателя уменьшиться на 48 (40 плюс 8 байтов на адрес возврата).

[ 73 ]

Параметры в стеке располагаются в сторону больших адресов. То есть, например, для шестого параметра (par6) мы бы имели mov qword ptr [rsp+40], par6. Резервирование для теневой области 40 байтов вместо 32 байтов как раз и обусловлено необходимостью выравнивания стека.

На рис. 2 представлена схема стека для процедуры 64-битовой программы в операционной системе Windows-64. Еще раз акцентируем внимание на наличие теневой области, которая значительно увеличивает расход стековой памяти. Заметим, что наличие такой области не обязательно, так как при необходимости можно зарезервировать память для регистровых параметров в области, где хранят локальные переменные — это решение корпорации Microsoft.

В 64-битовых Unix-подобных системах (Linux, BSD, Mac) за основу также взят принцип передачи параметров через регистры [5]. Так же, как и в операционной системе Windows-64, предполагается, что восстанавливает стек вызывающая сторона, и адрес вершины стека должен быть кратен 16. При

этом для передачи параметров используется шесть регистров общего назначения: rdi, rsi, rdx, rcx, r8, r9. Для передачи последующих параметров используется стек. Кроме этого, для передачи параметров типа double предлагается использовать восемь регистров xmm0-xmm7. Причем механизм использования этих регистров в корне отличается от аналогичного механизма в 64-битовой системе Windows — регистры xmm не замещают регистры общего назначения, а дополняют их. Таким образом, в системах Unix-64 для передачи параметров одновременно может быть использовано до 14 регистров.

Схема передачи семи параметров в Unix-64 может быть проиллюстрирована следующим образом (листинг 2).

Как видим, в системе Unix-64 стек выделяется только для тех параметров, которым не хватает зарезервированных регистров. Разумеется, здесь возникает та же проблема, что и в Windows-64: где хранить переданные через регистры параметры в случае, если из вызванной процедуры нужно вызывать другие. Естественно было бы использовать

Рис. 2. Схема стека процедуры для Windows-64 Fig. 2. The stack diagram of a procedure for Windows-64

Листинг 2. Схема вызова процедуры в Unix-64 Listing 2. Scheme of procedure call in Unix-64

sub rsp,8 ; резервируем стек для одного параметра mov qword ptr [rsp],par7 mov r9,par6 mov r8,par5, mov rcx,par4 mov rdx,par3 mov rsi,par2 mov rdi,par1 call proc1

add rsp,8 ,восстанавливаем стек

для этой цели область для локальных переменных, которую, кстати, резервирует не вызывающая, а вызываемая сторона. Причем резервирование нужно только в том случае, если есть необходимость сохранить параметры. Как видим, здесь явно имеется рациональное зерно в сравнении с тем, как решается эта проблема в Windows-64. Дальше на примерах мы покажем, что компиляторы поступают именно таким образом.

/ Том 10. № 6 (60). 2015 /

Обратим внимание, что и в данном фрагменте после вызова процедуры значение указателя стека будет кратно 16. Если бы количество параметров было больше семи, то следующий параметр (par8) должен был бы располагаться по старшему адресу: mov qword ptr [rsp+8], par8 и т. д. Схема стека процедуры для 64-битовых Unix-систем представлена на рис. 3.

Интересно, что в Unix-64 предусмотрена организация в стеке так называемой красной зоны (red zone) [5] (см. рис. 3). Смысл этой области заключается в том, что 128 байтов, идущих сразу за вершиной стека в сторону убывания адресов, не должны изменяться какими-либо системными программами. Таким образом, компилятор или программист, пишущий на языке ассемблера, может использовать эту область для хранения данных. Конечно, если из данной процедуры будет осуществлен вызов другой процедуры, то эта область будет изменена, так что есть смысл использовать красную зону только в процедуре, из которой не вызываются другие процедуры (последняя в цепочке вызываемых

Свободная область стека

Красная зона

Область

локальных

переменных

Сохраненное в стеке значение rbp

Адрес возврата

Параметры, передаваемые через стек

rsp

rsi

rdx

rcx

r8

r9

Рис. 3. Схема стека процедуры для Unix-64 Fig. 3. The stack diagram of aprocedure for Unix-64

процедур). Дальше мы приведем пример такого использования. Красная зона является еще одним плюсом систем Unix-64, позволяющим экономить стековую память и в определенных случаях увеличивать производительность программного обеспечения.

Примеры низкоуровневого анализа

Для анализа того, как реализуются возможности систем x86-64 на низком уровне, мы рассматриваем исполняемые модули, созданные компиляторами языка C из набора GCC (версии 4.8.1) в системах Windows-64 и Unix-64 (были использованы 64-битовые операционные системы Windows 2008 R2 и FreeBSD 9.0). Исследование исполняемого кода осуществлялось на 64-битовом интерактивном дизассемблере IDA PRO версии 5.5 [1].

Рассмотрим программу на C с вызовом библиотечной функции (листинг 3).

Программа очень проста: из главной функции (main) вызывается хорошо известная всем программистам на языке C библиотечная функция printf, осуществляющая форматный вывод параметров на стандартное устройство вывода. Количество параметров (восемь параметров) подобрано таким образом, чтобы можно было полно рассмотреть реализацию данной программы в исполняемом виде как для системы Windows-64, так и для систем Unix-64. Все параметры имеют тип long long, т. е. мы имеем дело с заведомо 64-битовыми величинами. Пример позволяет рассмотреть основные особенности структуры вызова в вызывающем коде.

В листинге 4 представлена дизассембли-рованная функция main для программы из листинга 3, скомпилированная для 64-битовой Windows. Как видим, первые четыре параметра попадают в регистры rcx, rdx, r8, r9 соответственно. Остальные помещаются в стек: d — в [rsp+32], e — в [rsp+40], f — в [rsp+48], g — в [rsp+56]. Заметим, что параметры помещаются по порядку в сторону старших адресов. Теперь обратим внимание на команду sub rsp, 128. Если учесть также команду push rbp, то после вызова процедуры (еще восемь байтов, таким образом, получаем 144 байта) указатель стека будет иметь адрес, кратный 16. Естественно, подразумевается, что на входе функции main указатель стека также был кратен 16.

Из зарезервированных 128 байтов 56 предназначены для локальных переменных: 7 = 56/8. 32 байта используется для передачи последних четырех параметров. Наконец, 32 байта — это теневая область для хранения четырех параметров, которые передаются через регистры. Оставшиеся 8 байтов никак не используются, а необходимы только для выравнивания указателя стека. Заметим, что, поскольку мы вызываем библиотечную функцию, из которой будут вызываться другие функции, то скорее всего, теневая область будет использована по назначению. С другой стороны, выделение дополнительной области требует и дополнительного расхода стековой памяти (32 байта на каждый вызов), но не требует дополнительного расхода процессорного времени. Подчеркнем еще раз, что если бы в данном коде было несколько вызовов функций, то выделение и восстанов-

Листинг 3. Программа на C с вызовом стандартной библиотечной функции

Listing 3. C program with the standard library function call

#include <stdio.h> int main(){

long long a,b,c,d,e,f,g;

a=1; b=2; c=3; d=4; e=5; f=6; g=7;

printf("%llu %llu %llu %llu %llu %llu %llu \n",a,b,c,d,e,f,g); return 0;

}

Листинг 4. Вызов процедуры. Реализация в Windows-64 Listing 4. Procedure call. Windows-64 implementation

main proc near

push rbp

mov rbp, rsp

sub rsp, 128

mov [rbp -8], 1 a

mov [rbp -16], 2 b

mov [rbp -24], 3 c

mov [rbp -32], 4 d

mov [rbp -40], 5 e

mov [rbp -48], 6 f

mov [rbp -56], 7 g

mov r8, [rbp-2 4] c- >r8

mov rcx, [rbp-16] b- >rcx

mov rax, [rbp-8] a- >rax

mov rdx, [rbp-5 6] g- >rdx

mov [rsp +56], rdx g

mov rdx, [rbp-4 8] f- >rdx

mov [rsp +48], rdx f

mov rdx, [rbp-4 0] e- >rdx

mov [rsp +40], rdx e

mov rdx, [rbp-3 2] d- >rdx

mov [rsp +32], rdx d

mov r9, r8 c- >r9

mov r8, rcx b- >r8

mov rdx, rax a- >rdx

lea rcx, aLluLluLluLluLl ; "%llu %llu %llu %llu %llu %llu %llu \n"

call printf

mov eax, 0

add rsp, 128

pop rbp

retn

main endp

ление стека было бы по-прежнему в начале и конце кода, а не для вызова каждой из функций в отдельности.

В листинге 5 представлена дизассембли-рованная функция main для программы из листинга 3, скомпилированная для 64-битовой Unix. Для передачи первых шести параметров используются соответственно регистры rdi, rsi, rdx, rcx, r8, r9. Оставшиеся два параметра помещаются в стек: [rsp], [rsp+8]. Всего в коде резервируется 80 байтов: 16 — для передаваемых через стек параметров, 56 — для локальных переменных и, как и в предыдущем случае (см. листинг 4 и комментарий к нему), 8 байтов для выравнивания адреса вершины стека. Как видим, выигрыш в сте-

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

Заканчивая рассмотрение дизассембли-рованного кода для программы из листинга 3, хочется сравнить также листинги 4 и 5 в отношении управления структурой стека. Заметим, что оба дизассемблированных кода созданы фактически одним и тем же компилятором, но реализованным для разных операционных систем. Первые две команды в листинге одинаковы. Это push rbp и mov rbp, rsp.

[ 77 ]

ПРИКЛАДНАЯ ИНФОРМАТИКА / JOURNAL OF APPLIED INFORMATICS

\ Vol. 10. No. 6 (60). 2015 \ _

Листинг 5. Вызов процедуры. Реализация в Unix-64 Listing 5. Procedure call. Unix-64 implementation

main proc near

push rbp

mov rbp, rsp

sub rsp, 80

mov [rbp-56], 1 a

mov [rbp-48], 2 b

mov [rbp-40], 3 c

mov [rbp-32], 4 d

mov [rbp-24], 5 e

mov [rbp-16], 6 f

mov [rbp-8], 7 g

mov rdx, [rbp-2 4] e- >rdx

mov rcx, [rbp-3 2] d- >rcx

mov rsi, [rbp-40] c- >rsi

mov rdi, [rbp-4 8] b- >rdi

mov r10, [rbp-56] a- >r10

mov rax, [rbp-8] g- >rax

mov [rsp+8], rax g

mov rax, [rbp-16] f- >rax

mov [rsp], rax f

mov r9, rdx e

mov r8, rcx d

mov rcx, rsi c

mov rdx, rdi b

mov rsi, r10 a

mov edi, offset format ; "%llu %llu %llu %llu %llu %llu %llu \n"

mov eax, 0

call _printf

mov eax, 0

leave

retn

main endp

Первая команда сохраняет в стеке значение регистра rbp, который должен сохранить свое значение после возвращения из процедуры, поскольку в вызывающем коде он также используется для указания на локальные переменные и параметры. Продолжая сравнивать листинги 4 и 5, мы замечаем, что конец процедур оказывается разным. В листинге 4 для Windows-64 мы имеем последовательность команд add rsp, 128 и pop rbp. Первая команда восстанавливает указатель стека до значения на момент запуска процедуры. После этого выполняется команда pop rbp, восстанавливающая значение регистра rbp и передвигающая указатель стека на адрес возврата из процедуры для выполнения команды retn.

В листинге 3 в конце процедуры вместо двух описанных команд стоит команда leave. Но из описания этой команды известно, что она как раз эквивалентна двум указанным ранее командам. Почему же компилятор в двух случаях реализовал в сущности один и тот же алгоритм по-разному? Скорее всего, потому, что в требовании Microsoft указывается необходимость восстановления стека после вызова процедуры (или последней процедуры в данном коде), а не в конце кода. Разработчики компилятора, таким образом, строго последовали логике этих требований.

Поставим теперь задачу определить, как компиляторы используют теневую область и красную зону. Для этого необходима про-

ПРИКЛАДНАЯ ИНФОРМАТИКА / JOURNAL OF APPLIED INFORMATICS

_ /Том 10. № 6 (60). 2015 /

Листинг 6. Программа на C для демонстрации использования теневой области и красной зоны Listing 6. C program to demonstrate the use of the shadow space and the red zone

#include <stdio.h>

long long fun2(long long a, long long b, long long c){

long long xx = a + 2;

long long yy = b + 3;

long long zz = c + 4;

long long sum = xx + yy + zz;

return sum; }

void fun1(long long a, long long b, long long c){ fun2(1,2,3);

printf("Fun1 %llu %llu %llu \n",a,b,c);

return; }

int main(){ fun1(1,2,3);

return 0; }

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

Программа в листинге 6 содержит главную функцию main, функцию funl, вызываемую из главной функции и функцию fun2, которая вызывается из функцииfun 1, но сама не содержит в себе вызовов других функций. Функция fun2, таким образом, является идеальным кандидатом для использования красной зоны (см. рис. 2), так как в ней есть локальные переменные. Функция funl получает три параметра, и поскольку они используются после вызова функции fun2 (вызов функции printf с этими параметрами), то наверняка компилятор «захочет» сохранить эти параметры перед вызовом функцииfun2. Интересно сравнить то, как это делается компилятором и для Windows-64 и для Unix-64.

Обратимся к листингу 7, где представлен низкоуровневый текст функции fun1, созданный компилятором для 64-битовой операционной системы Windows. Обратим внимание, что на входе процедуры fun1 теневая зона начинается с адреса [rsp+8] и распространяется в сторону старших адресов на 32 байта. После выполнения команд push rbp/

mov rbp, rsp адрес начала теневой зоны будет [rbp+16]. Таким образом, инструкции mov [rbp+16], rcx/ mov [rbp+24], rdx/mov [rbp+32], r8 как раз и помещают переданные в процедуру параметры в теневую область, т. е. используют ее по назначению. После вызова функции funl хранящиеся в теневой области параметры возвращаются в регистры (rdx, r8, r9, регистр rcx предназначен для адреса форматной строки) для отправки в функцию printf. Поскольку функция fun2 содержит всего три параметра и не содержит локальных переменных, то резервирование стека сводится к резервированию только места для теневой зоны.

Отметим также последовательность команд mov r8d, 3/mov edx, 2/mov ecx,1. Некая на первый взгляд их странность (использование 32-битовых регистров) объясняется очень просто. Компилятор считает константы 32-битовыми. В действительности это никак не может повлиять на результат, поскольку, скажем, команда mov ecx,1, в сущности, эквивалентна команде mov rcx,1, так как еще и обнуляет старшие 32 бита регистра.

В листинге 8 представлен низкоуровневый текст функции fun2, созданный компилятором для 64-битовой операционной системы Unix. Как видно из листинга 6, функция имеет три 64-битовых параметра и четыре локаль-

[ 79 ]

ПРИКЛАДНАЯ ИНФОРМАТИКА / JOURNAL OF APPLIED INFORMATICS

\ Vol. 10. No. 6 (60). 2015 \ _

Листинг 7. Использование теневой области в исполняемом модуле для Windows-64

Listing 7. The use of the shadow space in executable module for Windows-64

fun1 proc near

push rbp

mov rbp, rsp

sub rsp, 32

mov [rbp + 16], rcx

mov [rbp + 24], rdx

mov [rbp + 32], r8

mov r8d, 3

mov edx, 2

mov ecx, 1

call fun2

mov rdx, [rbp- 32]

mov rax, [rbp- 24]

mov r9, rdx

mov r8, rax

mov rdx, [rbp- 16]

lea rcx, aFunction1LluLl ; "Fun! %llu %llu %llu \n"

call printf

nop

add rsp, 32

pop rbp

retn

fun1 endp

ных переменных. Поскольку вызовов других функций из нее нет, то возникают необходимые условия для использования красной зоны. Красная зона (см. рис. 3) начинается после вершины стека и простирается на 128 байтов в сторону младших адресов.

Данный листинг интересен также и тем, что рассматриваемая функция имеет параметры, которые надо где-то хранить. Поскольку теневая область здесь не предусмотрена, то имеется два способа сохранить их в стеке: выделить для них локальные переменные (место в области, где должны храниться локальные переменные) или использовать красную зону. Поскольку объем красной зоны составляет 128 байтов, то ее должно хватить и для хранения параметров, и для локальных переменных.

Проследим, что происходит с параметрами и переменными в представленном коде. Параметр a содержится в регистре гШ и далее тоу [гЬр-40], г& — таким образом, параметр

сохраняется в красной зоне, за вершиной стека. И далее: mov rax, [rbp-40]/add rax, 2/mov [rbp-32], rax. То есть переменная xx оказывается также в красной зоне, но с большим адресом. Из кода ниже видно, таким образом, что и параметры, и переменные хранятся в красной зоне — параметры хранятся по адресам: [rbp-40] — параметр a, [rbp-48] — параметр b, [rbp-56] — параметр c. Локальные переменные располагаются по адресам: [rbp-8] — переменная sum, [rbp-16] — переменная zz, [rbp-24] — переменная yy, [rbp-32] — переменная xx.

Интересно теперь сравнить то, как реализуется функцияfun2 для компиляторов в Unix-64 и Windows-64. Дизассемблированный код функции для Windows-64 представлен в листинге 9. Прежде всего заметим, что код в листинге 7 оказался более длинным. Это понятно — компилятор для Windows-64 действует по своей обычной схеме, т. е. выделяет определенный объем стека для четырех локальных переменных. Это команда sub rsp, 32.

Листинг 8. Красная зона в исполняемом модуле для 64-битовой Unix

fun2 proc near

push rbp

mov rbp, rsp

mov [rbp-40], rdi ;a

mov [rbp-48], rsi ;b

mov [rbp-56], rdx ;c

mov rax, [rbp-4 0] ;a

add rax, 2

mov [rbp-32], rax ;xx

mov rax, [rbp-4 8] ;b

add rax, 3

mov [rbp-24], rax ;yy

mov rax, [rbp-5 6] ;c

add rax, 4

mov [rbp-16], rax ;zz

mov rax, [rbp-2 4] ;yy

add rax, [rbp-3 2] ;xx

add rax, [rbp-16] ;zz

mov [rbp-8], rax sum

mov rax, [rbp-8] sum

leave

retn

fun2 endp

Параметры же, как и следовало ожидать, должны быть помещены в теневую область. Заметим, кстати (см. листинг 7), что для трех параметров все равно выделяется 32 байта. Параметры помещаются в стек, где располагается теневая область начиная с младших адресов в сторону старших: mov [rbp+16], rcx/ mov [rbp+24], rcx/ mov [rbp+32], rcx (см. рис. 2). Соответственно, локальные переменные располагаются в области выше, чем теневая область, и располагаются по адресам: [rbp-8] — переменная xx, [rbp-16] — переменная yy, rbp-24] — переменная zz, [rbp-32] — переменная sum.

Заключение

Переход к архитектуре x86-64, таким образом, привел к изменению низкоуровневой структуры программного обеспечения. Подведем краткий итог рассмотренных выше материалов.

Листинг 9. Функция fun2 в исполняемом модуле для Windows-64

fun2 proc near

push rbp

mov rbp, rsp

sub rsp, 32

mov [rbp + 16], rcx ;a

mov [rbp + 24], rdx ;b

mov [rbp + 32], r8 c

mov rax, [rbp +16] ;a

add rax, 2

mov [rbp- 8], rax ; xx

mov rax, [rbp +24] ;b

add rax, 3

mov [rbp- 16], rax ;yy

mov rax, [rbp +32] ;c

add rax, 4

mov [rbp- 24], rax ;zz

mov rax, [rbp -16] ;yy

mov rdx, [rbp -8] xx

add rdx, rax

mov rax, [rbp -24] ;zz

add rax, rdx

mov [rbp- 32], rax ;sum

mov rax, [rbp -32] ;sum

add rsp, 20h

pop rbp

retn

fun2 endp

1. Архитектура x86-64 позволяет использовать как 64-битовые регистры общего назначения, так и их младшие 32-битовые части. Таким образом, упрощается перенос 32-битовых алгоритмов на 64-битовую платформу.

2. Увеличение количества регистров общего назначения позволило перейти к новым единым соглашениям о вызовах, в которых часть параметров передается через регистры.

3. Соглашения о вызовах в 64-битовых операционных системах Windows и 64-битовых Unix-системах кардинально отличаются друг от друга.

4. Оба соглашения о вызовах предусматривают свои механизмы хранения передаваемых через регистры параметров: использование области для локальных переменных и красной зоны в Unix-системах и теневой области в системах Windows.

[ 81 ]

5. Соглашение о вызовах в 64-битовых Unix-системах выглядит более предпочтительно как с точки зрения возможности передачи гораздо большего количества параметров через регистры, так и использования красной зоны для хранения параметров, и области локальных переменных. В случае большого количества вызовов процедур, в которых отсутствует вызов других процедур, использование красной зоны может дать определенный выигрыш в производительности кода.

6. Использование в Unix-64 области, где хранятся локальные переменные, для хранения параметров также выглядит более предпочтительно по сравнению с использованием теневой зоны в Windows-64, поскольку позволяет экономить стековую память.

Список литературы

1. Пирогов В. Ю. Ассемблер и дизассемблирование. СПб.: БХВ-Петербург, 2006. — 464 с.

2. Террот Пауль. 64-битовые XP // Мир ПК. 2005. № 3. С. 11-15.

3. AMD64 Technology: AMD64 Architecture Program-

mer's Manual. Vol. 1. Application Programming. Advanced Micro Devices, 2009. — 304 p.

4. Calling Convention //MSDN. URL: http://msdn.mi-crosoft.com/en-us/library/9b372w95.aspx

5. Fog Agner. Calling conventions for different C++ compilers and operating systems. Technical University of Denmark, 2014. — 57 p.

6. Mashey Joohn R. The Long Road to 64 Bits // Magazine «Queue». 2006, Vol. 4. Issue 8. October. P. 45-53.

References

1. Vlad Pirogov. Disassembling Code: IDA Pro and SoftICE. A-List Publishing, 2005. 600 p. (in Russian).

2. Paul Thurrott. XP Goes to 64 Bits. PCWorld, 2005, no. 2, pp. 10-14.

3. AMD64 Technology: AMD64 Architecture Programmer's Manual, vol. 1, Application Programming. Advanced Micro Devices, 2009. 304 p.

4. Calling Convention. MSDN. URL: http://msdn.mi-crosoft.com/en-us/library/9b372w95.aspx

5. Fog Agner. Calling conventions for different C++ compilers and operating systems. Technical University of Denmark, 2014. 57 p.

6. Mashey Joohn R. The Long Road to 64 Bits. Magazine «Queue». 2006, vol. 4, issue 8, October, pp. 45-53.

V. Pirogov, Shadrinsk Pedagogical Institute, Shadrinsk, Russia, Viadisiav-133@yandex.ru

Operating systems supporting x86-64 instruction set, with a low-level programming point of view

As the title implies the paper considers 64-bit enhancement of x86 (x86-64) and features of the 64-bit operating systems. There are some new characteristics of the architecture x86-64 such as 64-bit integer capability, additional registers, larger physical address space, RIP-relative addressing, additional XMM registers, etc. Some of these issues are reflected in our article. We focus on the main features of this specification with the point of view of programming. Much attention in our paper is given to such concepts as «red zone» and «shadow space», which are associated with calling conventions. The paper speaks in detail on the differences in the approaches (regarding calling conventions) adopted in families of 64-bit operating systems Windows and UNIX. We compare the low-level program architecture for different operating systems supporting the x86-64 specification. The paper gives examples of analysis of the executable code, which are compiled for operating systems with different calling conventions. For the purity of the experiment the programs for different operating systems are translated by compilers of C from the GNU Compiler Collection (GCC). For the study of executable code for 64-bit operating systems Windows and UNIX, we used the disassembler IDA PRO version 5.5.

Keywords: processor, 64-bit architecture, analysis of program code, calling convention, 64-bit programming. About author: Pirogov V., PhD in Physics & and Mathematics, Assistant Professor

For citation: Pirogov V. Operating systems supporting x86-64 instruction set, with a low-level programming point of view. PMadnaya Informatika — Journal of Applied Informatics, 2015, vol. 10, no. 6 (60), pp. 70-82 (in Russian).