Написание 32-х битного кода (Unix, Win32, DJGPP) в ассемблере NASM
Эта глава повествует о наиболее распространенных проблемах, возникающих при написании 32-ух разрядного кода для Win32 или Unix, или для сборки (linking) с Си-кодом, полученным компилятором Си Unix-стиля, таким как DJGPP. Здесь также рассматривается как писать ассемблерный код для взаимодействия (interface) с кодом, полученным 32-ух битным компилятором с Cи и как создавать перемещаемый код для разделяемых библиотек.Почти весь 32-ух битный код и практически весь код, выполняемый под Win32, DJGPP или под любой вариант Unix для ПК выполняется в плоской (flat) модели памяти. Это означает, что сегментные регистры и механизм страничной адресации уже установлены заранее для того, чтобы дать вам одно и то же 32-битное адресное пространство в 4 Гб, не зависимо относительно какого сегмента вы работаете, и поэтому вы должны полностью игнорировать (не использовать) все сегментные регистры. Когда вы пишите приложение под плоскую модель памяти, вам никогда не потребуется замещение сегментов или изменение значения каких-либо сегментных регистров, и адреса сегмента кода, которые вы передаете инструкциям CALL и JMP остаются в том же адресном пространстве, что и адреса сегмента данных через которые вы обращаетесь к переменным и адреса сегмента стека, которые вы используете для доступа к локальным переменным и параметрам процедур. Каждый адрес имеет размер 32 бита и содержит только смещение (offset-ную) часть адреса.
8.1 Интерфейс с 32-ух битными программами на Си
Все рассуждения в параграфе 7.4, относящиеся к интерфейсу с 16-ти битными программами на Си также применимы и к 32-ух битным. Отсутствие моделей памяти или сегментации не должно вызывать у вас беспокойства.
8.1.1 Внешние символьные имена
Большинство 32-ух битных Си-компиляторов поддерживают конвенцию, используемую в 16-ти битных компиляторах: имена всех глобальных имен (функций или переменных) они определяют префиксом “подчеркивание” (например: extrn_link в Си будет _extrnlink в линкуемом файле), добавляемое к имени, действующем в Си-программе. Правда, не все это делают: спецификация ELF указывает, что все имена в Си-программе не имеют предваряющего подчеркивания в их эквивалентах на языке ассемблера.
Старый Си-компилятор в Линуксе a.out, все компиляторы
для Win32, DJGPP, NetBSD и FreeBSD, все они используют предваряющее “подчеркивание”;
для этих компиляторов макросы cextern и cglobal
,
как они описаны в параграфе 7.4.1,
будут работать. Для ELF, разумеется, предваряющее подчеркивание не используется.
8.1.2 Определение и вызов функций
Конвенция Си для вызова в 32-ух битных программах приведена ниже. В этом описании использованы выражения вызывающий код и вызываемая функция для того, чтобы указать делает ли функция вызов, или функция получает управление.
- Вызывающий код проталкивает параметры в стек один за другим в обратном порядке (справа налево, таким образом, первый объявленный аргумент функции будет помещен в стек последним).
- Затем вызывающий код выполняет ближний вызов, чтобы передать управление вызываемой функции.
- Вызываемая функция, получая управление, и обычно (хотя это не всегда необходимо в функциях, которые не обращаются к своим параметрам) начиная с сохранения значений ESP в EBP,чтобы можно было использовать EBP как базовый указатель для доступа к параметрам в стеке. Однако, вызывающий код возможно делает тоже самое (устанавливает EBP на свой стек), поэтому по этой части конвенции о вызове, состояние EBP должно быть сохранено во всех Си-функциях. Поэтому вызываемая функция, если она хочет установить EBP как указатель на свои параметры должна сначала сохранить в стеке его предыдущее значение.
- Вызываемая функция может обращаться к своим параметрам относительно EBP. Двойное слово по адресу [EBP] содержит предыдущее значение EBP, т.к. оно было сохранено в стеке; следующее двойное слово по адресу [EBP+4] содержит адрес возврата, протолкнутый туда инструкцией CALL. После этого начинаются параметры с адреса [EBP+8]. Самый левый параметр функции, т.к. он был помещен в стек последним, доступен по этому смещению от EBP; остальные расположены по бОльшим смещениям. Поэтому, в таких функциях как printf, которые имеют переменное число параметров, заталкивание параметров в обратном порядке позволяет функции узнать где находится первый параметр, в котором содержится информация о количестве и типах остальных.
- Вызываемая функция также может уменьшить значение ESP для того, чтобы зарезервировать место в стеке для локальных переменных, которые будут доступны как отрицательные смещения относительно EBP.
- Вызываемая функция, если она хочет вернуть значение вызывающему коду, должна оставить это значение в AL, AX или EAX, в зависимости от размера. Значения с плавающей запятой обычно возвращаются в ST0.
- Как только вызываемая функция закончила свои основные действия, она восстанавливает ESP из EBP если она резервировала место в стеке, затем выталкивает предыдущее значение EBP, и возвращает управление через RET (эквивалентно, RETN).
- Когда вызывающий код снова получает контроль от вызываемой функции, параметры функции все еще остаются в стеке, поэтому обычно к значению ESP прибавляется непосредственное значение, чтобы убрать их (вместо выполнения нескольких медленных инструкций POP). Поэтому, если функция случайно будет вызвана с неверным числом параметров, т.е. не соответствующем прототипу, стек будет возвращен вызывающим кодом в то состояние, в котором он находился до вызова этой функции, который знает сколько параметров было помещено в стек, и удаляет их оттуда.
Существует альтернативная конвенция вызова, используемая в Win32 программах для вызовов через Windows API, а также для функций, вызываемых Windows API, таких как оконные процедуры (window procedures): они описаны так, чтобы использовать Microsoft __stdcall конвенцию. Это очень похоже на конвенцию Паскаля, в вызываемой функции очищаются параметры из стека, используя параметр с инструкцией RET. Однако, параметры также передаются справа налево.
Если вы определите функцию в стиле языка Си следующим образом:
global _myfunc _myfunc: push ebp mov ebp,esp sub esp,0x40 ; 64 байта для локальных переменных mov ebx,[ebp+8] ; Первый параметр функции ; еще какой-нибудь код leave ; mov esp,ebp / pop ebp ret
С другой стороны, чтобы вызывать Си-функцию из вашего ассемблерного кода, вы должны сделать что-то вроде этого:
extern _printf ; и затем... push dword [myint] ; одна из моих переменных целого типа push dword mystring ; указатель в моем сегменте данных call _printf add esp,byte 8 ; `byte' уменьшит размер кода ; и теперь объявления данных... segment _DATA myint dd 1234 mystring db 'Это число -> %d <- должно быть 1234',10,0
Этот фрагмент кода – ассемблерный эквивалент Си-кода
int myint = 1234; printf("Это число -> %d <- должно быть 1234\n", myint);
8.1.3 Доступ к переменным
Чтобы получить доступ к Си-переменным, или чтобы объявить переменные, к которым может обращаться Си, вам достаточно объявить имена как GLOBAL или EXTERN. (Опять же, имена нужно предварять подчеркиванием, как это описано в параграфе 8.1.1.) Таким образом, Си-переменные, объявленные как int i могут быть доступны из ассемблера как
extern _i mov eax,[_i]
И чтобы объявить ваши собственные переменные, которые будут доступны Си-программе как int j, делайте это так (проверьте, что вы ассемблируете это в _DATA сегменте, если это необходимо):
global _j _j dd 0
Чтобы обращаться с Си-массивами необходимо знать размер элементов этого массива. Например, int переменные имеют размер 4 байта, поэтому если в Си-программе объявлен массив как int a[10], можно обращаться к a[3] так: mov ax,[_a+12]. ( Смещение 12 полчается в результате умножения номера в массиве, 3 на размер элемента массива, 4.) Размеры базовых типов в 32-х разрядных Си-компиляторах: 1 для char, 2 для short, 4 для int, long и float, и 8 для double. Указатель, содержащий 32-ух битный адрес имеет также размер 4 байта.
Чтобы обращаться к структурам языка Си, необходимо знать смещение от базового адреса структуры до интересующего вас поля. Вы можете просто это делать, конвертируя определения Си-структур в определения структур NASM (STRUC), или вычисляя одно смещение и используя только его.
Чтобы делать это проще, вам необходимо причитать руководство по вашему компилятору языка Си и найти как он организует структуры данных. NASM не делает специальных выравниваний членов структуры в его макросе STRUC, поэтому вы должны указывать выравнивания самостоятельно, если Си-компилятор генерирует их. Обычно вы можете обнаружить, что подобная структура
struct { char c; int i; } foo;
имеет размер не 5 байт, а 8, так как int поле будет выровнено на границу двойного слова. Однако, эту возможность иногда можно настроить в Си-компиляторе, используя параметры командной строки или #pragma директивы, поэтому можно найти как ваш компилятор это делает.
8.1.4 c32.mac: Вспомогательные макросы для 32-ух битного интерфейса с Си
Файл макроса c32.mac включен в архив NASM, в каталог misc. В этом файле определены 3 макроса: proc, arg и endproc. Они введены для использования в определении процедур в стиле Си, и она автоматизируют операции, необходимые для соблюдения конвенции вызова.
Пример ассемблерной функции, использующей этот набор макросов приведен ниже:
proc _proc32 %$i arg %$j arg mov eax,[ebp + %$i] mov ebx,[ebp + %$j] add eax,[ebx] endproc
Этот фрагмент определяет _proc32 как процедуру с двумя аргументами, первый (i) является integer и второй (j) является указателем на integer. Процедура возвращяет i + *j.
Заметьте, что макрос arg при его разворачивании содержит в первой строке EQU, которая в результате определяет %$i как смещение от BP. При этом используются контекстно-локальные переменные (локальные к контексту, сохраняемому в контекстном стеке макросом proc и удаляемому оттуда макросом endproc), поэтому в других процедурах может быть использовано то же самое имя аргумента. Конечно, вы можете этого не делать.
arg можно передать необязательный параметр, указывающий размер аргумента. Если размер не задан, он предполагается равным 4-ем, потому что абсолютное большинство параметров функции будут типа int или указателями.
8.2 Написание разделяемых библиотек для NetBSD/FreeBSD/OpenBSD и Linux/ELF
ELF замещает старый объектный формат a.out для Линукса, потому что он поддерживает перемещаемый код (position-independent code PIC), который позволяет писать разделяемые библиотеки намного проще. NASM поддерживает особености перемещаемого кода для ELF, поэтому вы можете писать разделяемый библиотеки для Линукс ELF на NASM.
NetBSD, и его близкие родственники FreeBSD и OpenBSD, используют другой подход, добавив поддержку перемещаемого кода в формат a.out. NASM поддерживает это как формат aoutb для скомпилированных файлов, поэтому вы можете писать разделяемые библиотеки для BSD на NASM тоже.
Операционная система загружает разделяемую PIC (пермещаемый код) библиотеку, делая отображение в память файла библиотеки в произвольно выбранное место в адресном пространстве выполняемого процесса. Поэтому содержимое секции кода библиотеки должно не зависеть от места в памяти, куда она загружена.
Поэтому, вы не можете обращаться к вашей переменной таким вот способом:
mov eax,[myvar] ; ОШИБКА
Вместо этого, линковщик предоставляет область памяти, называемую глобальной таблицей смещений (global offset table), или просто ГТС (GOT); ГТС расположена на постоянном расстоянии от кода вашей библиотеки, поэтому если вы сможете узнать куда загружена ваша библиотека (что обычно осуществляется комбинацией CALL и POP), вы сможете получить адрес ГТС, и затем загрузить адрес вашей переменной из сгенерированной линковщиком записи в ГТС.
Секция data PIC (перемещяемый код) разделяемой библиотеки не имеет подобных ограничений: поскольку секция данных доступна для записи, она может быть скопирована в память каким бы то ни было образом, не только отображением в страницы из файла библиотеки, поэтому как только она будет скопирована, она может быть также перемещена. Поэтому вы можете обычный способ для доступа в секции данных, особо не заботясь об этом (но посмотрите в параграфе 8.2.4 предостережения).
8.2.1 Получение адреса ГТС
Каждый фрагмент кода в вашей разделяемой библиотеке должен определить ГТС как внешнее имя:
extern _GLOBAL_OFFSET_TABLE_ ; в ELF extern __GLOBAL_OFFSET_TABLE_ ; в BSD a.out
В начале каждой функции вашей разделяемой библиотеки, которая собирается обращаться к вашим секциям data или BSS, должен вычисляться адрес ГТС. Это обычно осуществляется написанием функции в такой форме:
func: push ebp mov ebp,esp push ebx call .get_GOT .get_GOT: pop ebx add ebx,_GLOBAL_OFFSET_TABLE_+$$-.get_GOT wrt ..gotpc ; Тело функции начинается отсюда mov ebx,[ebp-4] mov esp,ebp pop ebp ret
(Для BSD, снова, имя _GLOBAL_OFFSET_TABLE нужно предварить вторым знаком подчеркивания.)
Первые две сточки этой функции это просто стандартное начало для Си, чтобы установить стековый кадр, и последние три строчки стандартное завершение Си-функции. Третья строка, и с четвертой по последнюю сктроки, сохраняют и восстанавливают регистр EBX, потому что PIC (перемещаемый код) разделяемая библиотека использует этот регистр для сохранения адреса ГТС.
Интересный фрагмент это инструкция CALL и следующие за ней две строки. Комбинация CALL и POP получают адрес метки .get_GOT, без узнавания в точности куда загружена программа (потому что инструкция CALL кодируется относительно текущей позиции). Инструкция ADD позволяет использовать специальный PIC (перемещаемый код) тип размещения: GOTPC размещение. Со спецификатором WRT ..gotpc размещение имен (здесь _GLOBAL_OFFSET_TABLE_, специальное имя, связанное с ГТС) дается как смещение от начала секции. (Вобще-то, ELF кодирует это как смещение от поля операндов инстуркции ADD, но NASM нарочно это упрощает, поэтому этот метод подходит для обоих ELF и BSD.) Таким образом, затем инструкция добавляет начало секции, чтобы получить настоящий адрес ГТС, и вычитает значение .get_GOT которое находится в EBX. Поэтому, в то время, когда инструкция завершится, EBX содержит адрес ГТС.
Если вы не следили за рассуждениями, не беспокойтесь: никогда не приходится получать адрес ГТС другими способами, поэтому вы можете поместить эти три инструкции в макрос и не обращать на них внимания:
%macro get_GOT 0 call %%getgot %%getgot: pop ebx add ebx,_GLOBAL_OFFSET_TABLE_+$$-%%getgot wrt ..gotpc %endmacro
8.2.2 Нахождение ваших локальных переменных
Имея ГТС, вы можете использовать ее для получения адресов ваших переменных. Большинство переменных будут постоянно находится в секциях, которые вы объявили; к ним можно получить доступ, используя ..gotoff специальный тип WRT. Вот как это работает:
lea eax,[ebx+myvar wrt ..gotoff]
Выражение myvar wrt ..gotoff вычисляется, затем разделяемая библиотека линкуется, чтобы оно стало смещением локальной переменной myvar от начала ГТС Поэтому, прибавление его к EBX, как это показано выше, помещает настоящий адрес myvar в EAX.
Если объявить переменные как GLOBAL без указания их размера, они разделяются между фрагментами кода в библиотеке, но не могут быть экспортированы из библиотеки в программу, которая ее загрузила. Они будут по прежнему в вашей обычных секциях data и BSS, поэтому доступ к ним можно получить таким же методом, что и к локальным переменных, используя описанный ранее механизм ..gotoff.
Обратите внимание, что из-за специфики в BSD a.out формата описатели этого перемещаемого типа, должно существовать хотя бы одно нелокальное имя в той же секции, что и адрес, к которому вы обращаетесь.
8.2.3 Нахождение внешних и общих переменных
Если вашей библиотеки требуется получить внешнюю переменную (внешнюю по отношению к library, не только к одному модулю в ней), необходимо использовать тип ..got, чтобы этого добиться. Тип ..got, вместо того, чтобы давать смещение от базы ГТС до переменной, дает смещение от базы ГТС до элемента ГТС, содержащего адрес переменной. Линковщик настраивает этот элемент ГТС когда делает библиотеку, и динамический линковщик помещает правильный адрес во время загрузки. Таким образом, чтобы получить адрес внешней переменной extvar в EAX, вам понадобиться написать:
mov eax,[ebx+extvar wrt ..got]
Эта строчка загружает адрес extvar из элемента ГТС. Линковщик, когда он делает разделяемую библиотеку, собирает вместе все перемещения типа ..got, и создает ГТС, поэтому это обеспечивает существование каждого необходимого элемента.
Общие переменные должны использоваться таким же образом.
8.2.4 Экспортирование имен в библиотеку пользователя
Чтобы экспортировать имена пользователю библиотеки, необходимо объявить являются ли они функциями или данными, и если это данные, необходимо указать размер переменной. Это нужно для того, чтобы динамический линковщик создает таблицу связей входов процедур для каждой экспортированной функции, а также перемещает экспортированные переменные из секции данных библиотеки в которой они были объявлены.
Таким образом, чтобы экспортировать функцию в библиотеку пользователя, необходимо использовать
global func:function ; объявить это как функцию func: push ebp ; etc.
А чтобы экспортировать переменные, такие как массивы, понадобится такой код
global array:data array.end-array ; и указать размер... array: resd 128 .end:
Будьте осторожны: если экспортировать переменную в библиотеку пользователя, объявив ее как GLOBAL и указать ее размер, переменная окажется в сегменте данных главной программы, вместо сегмента данных вашей библиотеки, в котором объявлена. Поэтому получать доступ к вашей глобальной переменной вам нужно, используя механизм ..got, вместо ..gotoff, как если бы она была внешней (которая, вобщем, таковой и стала).
В равной мере, если необходимо сохранить адрес экспортированной глобальной переменной в вашем сегменте данных, вам ен удастся это сделать стандартным образом:
dataptr: dd global_data_item ; ОШИБКА
NASM интерпретирует этот код как обычное резервирование, в котором global_data_item просто смещение от начала сегмента .data (или чего-то другого); поэтому это определение будет указывать на ваш сегмент данных, вместо экспортирования глобальной переменной, которая находится в другом месте.
Вместо вышеописанного кода, тогда, используйте
dataptr: dd global_data_item wrt ..sym
что использует специальный тип WRT ..sym, чтобы указать NASM-у искать в таблице имен особое имя для этого объявления, место простого определения относительно базы сегмента.
Тотже метод будет работать и для функций: объявление одной из ваших функций следующим образом
funcptr: dd my_function
предоставит пользователю адрес вашего кода, тогда как
funcptr: dd my_function wrt ..sym
предоставит адрес процедуры сборки (линковки) таблицы для этой функции, который вызывающая прогамма будет полагать как местоположение функции. Получение этого адреса правильный способ вызова функции.
8.2.5 Вызов процедур вне библиотеки
Вызов процедур извне вашей разделяемой библиотеки может быть осуществлен через таблицу сборки процедуры(procedure linkage table), или ТСП(PLT). ТСП помещается по известному смещению от того места, куда загружена библиотека, поэтому библиотечный код может делать вызовы к ТСП в переносимом (не зависимого от места) стиле. Внутри ТСП есть код для перехода на смещения, расположенные в ГТС, поэтому вызовы из функции к другим разделяемым библиотекам или к коду в главной программе могут быть незаметно направлены на их реальные местоположения.
Чтобы вызывать внешний код, необходимо использовать другой специальный PIC (переносимый код) тип переноса, WRT ..plt. Это намного проще, чем использовать ГТС: просто замещается вызовы, например, такие CALL printf на версию, использующую ТСП CALL printf WRT ..plt.
8.2.6 Создание библиотечного файла
Написав несколько модулей с кодом и ассемблировав их в .o файлы, вы создаете вашу разделяемую библиотеку командами наподобие таких:
ld -shared -o library.so module1.o module2.o # for ELF ld -Bshareable -o library.so module1.o module2.o # for BSD
Для ELF, если вашу разделяемую библиотеку предполагается использовать в системных каталогах, таких как /usr/lib или /lib, это обычно делают, использую параметр -soname при сборке (линковке), чтобы сохранить конечный библиотечный файл с именем, с номером версии в библиотеке:
ld -shared -soname library.so.1 -o library.so.1.2 *.o
А вы потом скопируете файл library.so.1.2 в библиотечный
каталог, и создадите символьную ссылку library.so.1
к нему.