Расширенный ассемблер: NASM

Следующая глава | Предыдущая глава | Содержание | Указатель

Глава 7: Написание 16-битного кода (DOS, Windows 3/3.1)

Перевод: AsmOS group, © 2001
В данной главе рассмотрены некоторые общие вопросы создания 16-битного кода, выполняющегося под MS-DOS или Windows 3.x: как скомпоновать программы для получения .EXE или .COM файлов, как создать драйвер устройства .SYS, а также как ассемблерный код взаимодействует с 16-битными компиляторами и с Borland Pascal.

7.1 Получение .EXE файлов

Любые большие программы, написанные под DOS, необходимо создавать как .EXE файлы: только они имеют необходимую внутреннюю структуру для захвата более одного 64К сегмента. Программы Windows также требуется создавать как .EXE файлы, так как .COM файлы Windows не поддерживает.

Обычно .EXE файлы генерируются при помощи выходного формата obj (при этом создаются один или более .OBJ файлов, связываемых затем друг с другом компоновщиком). Однако при помощи выходного формата bin и некоторых макросредств NASM поддерживает также непосредственное создание простых .EXE файлов DOS (заголовок .EXE файла конструируется при помощи DB и DW). Спасибо Yann Guidon за содействие при кодировании этого.

В будущем NASM может быть станет поддерживать и "родной" выходной .EXE формат.

7.1.1 Использование формата obj для получения .EXE файлов

В данном параграфе описан обычный способ создания .EXE файлов путем компоновки друг с другом .OBJ файлов.

Большинство 16-битных языков программирования поставляются с собственным компоновщиком; если у вас нет ни одного, возьмите с x2ftp.oulu.fi свободно распространяемый компоновщик VAL, упакованный в формате LZH. LZH-архиватор можно найти на ftp.simtel.net. На www.pcorner.com имеется еще один бесплатный компоновщик FREELINK (только он без исходников), и наконец, на www.delorie.com можно взять компоновщик djlink, написанный DJ Delorie.

При компоновке нескольких .OBJ файлов в один .EXE файл вы должны убедиться, что только один из них (.OBJ) имеет точку входа (при помощи специального символа ..start, определяемого obj форматом: см. параграф 6.2.6). Если ни один из модулей не определяет точки входа, компоновщик не будет знать, какое значение записать в заголовок выходного файла в качестве стартового адреса; если же точка входа определена в нескольких файлах, компоновщик не сможет понять, какое именно значение использовать.

Здесь приводится пример исходного файла, который ассемблируется NASMом в .OBJ файл и им же компонуется в .EXE. На этом примере продемонстрированы основные принципы определения стека, инициализации сегментных регистров и объявления точки входа. Данный файл содержится также в подкаталоге test NASM-архивов под именем objexe.asm.

segment code ..start: mov ax,data mov ds,ax mov ax,stack mov ss,ax mov sp,stacktop

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

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

mov dx,hello mov ah,9 int 0x21

Здесь начинается основная программа: загрузка в DS:DX указателя на приветствующее сообщение (hello является неявной ссылкой на сегмент data, загруженный в DS настроечным кодом, поэтому полный указатель корректен) и вызов DOS-функции вывода строки на экран.

mov ax,0x4c00 int 0x21

Здесь программа завершается при помощи другого системного DOS-вызова.

segment data hello: db 'Привет, фуфел!', 13, 10, '$'

Сегмент данных содержит строку, которую нужно отобразить на экране.

segment stack stack resb 64 stacktop:

Данный код объявляет сегмент стека, содержащий 64 байта неинициализированного стекового пространства, где символ stacktop указывает на его вершину. Директива segment stack stack определяет сегмент под именем stack, тип которого также STACK. В нашем случае не требуется дальнейшее выполнение программы, но если в ней не определить сегмент STACK, компоновщики вероятнее всего выдадут предупреждение или сообщение об ошибке.

Приведенный выше файл будет ассемблироваться в .OBJ файл, затем компоноваться NASM в корректный .EXE файл, который при запуске будет выводить на экран строку 'Привет, фуфел!' и затем завершаться.

7.1.2 Использование формата bin для получения .EXE файлов

Формат .EXE является достаточно простым, поэтому построение .EXE файлов возможно путем написания чисто бинарной программы с последующим помещением в ее начало 32-битного заголовка. Структура заголовка несложная, поэтому он может быть создан обычными командами DB и DW. Исходя из вышесказанного, для непосредственного создания .EXE файлов может быть использован формат bin.

В архиве NASM имеется подкаталог misc, в котором находится файл макросов exebin.mac. В этом файле определены три макроса: EXE_begin, EXE_stack и EXE_end.

Для создания файла при помощи формата bin вы должны включить в свой исходный файл директиву %include exebin.mac, загружающую в него пакет требуемых макросов. Затем для генерации заголовка файла вы должны выполнить макрокоманду EXE_begin (не имеет аргументов). После этого следует обычный для формата bin код программы — вы можете использовать все три стандартные секции .text, .data и .bss. В конце файла вы должны вызвать макрос EXE_end (без аргументов), который для маркировки размеров секции определяет некоторые символы, ссылающиеся на заголовок кода, сгенерированный макросом EXE_begin.

В данной модели написанный вами код стартует с адреса 0x100, как и обычный .COM файл — в действительности, если вы удалите 32-битный заголовок из сгенерированного .EXE файла, то получите работающую .COM программу. Все базы сегментов в полученном файле одинаковы, поэтому размер программы ограничен 64К (опять же, как и .COM файл). Имейте в виду, что директива ORG используется макросом EXE_begin, поэтому вы не должны применять ее самостоятельно.

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

При запуске полученного .EXE файла пара SS:SP настраивается на указание вершины 2Кб стека. Вызвав макрос EXE_stack, вы можете изменить размер стека по умолчанию. Например, для изменения размера стека вашей программы до 64 байт вы должны вызвать EXE_stack 64.

В подкаталоге архива NASM содержится простая программа binexe.asm, из которой .EXE файл создается вышеописанным способом.

7.2 Получение .COM файлов

В то время, как большие DOS-программы должны писаться в виде .EXE файлов, небольшие часто лучше и проще написать как .COM файлы. .COM файлы являются чисто бинарными, поэтому большинство их может быть создано при помощи выходного формата bin.

7.2.1 Использование формата bin для получения .COM файлов

.COM файлы загружаются в свой сегмент по смещению 100h (сегмент может меняться). Выполнение начинается с адреса 100h, т.е. программа по этому адресу стартует. Таким образом, при написании .COM программы ваш исходный файл должен выглядеть наподобие следующего:

org 100h section .text start: ; сюда поместите код section .data ; сюда поместите данные section .bss ; здесь находятся неинициализированные данные

Формат bin помещает секцию .text в начале файла, поэтому вы можете объявлять данные или BSS перед написанием собственно кода.

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

Для ассемблирования приведенной выше программы вы должны использовать следующую командную строку:

nasm myprog.asm -fbin -o myprog.com

Если явно не указать имя выходного файла, формат bin создаст файл с именем myprog; в этом случае вы можете просто переименовать его так, как вам нужно.

7.2.2 Использование формата obj для получения .COM файлов

Если вы пишете .COM программу с применением более одного модуля, то возможно захотите ассемблировать несколько .OBJ файлов и затем собрать их в одну программу. Вы можете это сделать двумя путями: при помощи компоновщика, способного непосредственно создавать .COM файлы (TLINK это может) или применив конвертер EXE2BIN для преобразования .EXE файла, полученного на выходе компоновщика, в .COM файл.

Если вы это делаете, вам нужно позаботиться о нескольких вещах:

7.3 Получение .SYS файлов

Драйверы устройств MS-DOS — .SYS файлы — это чисто бинарные файлы, во всем похожие на .COM, за исключением того, что они запускаются по нулевому смещению, а не по смещению 100h. Поэтому если вы пишете драйвер при помощи формата bin, директива ORG вам не нужна, так как по умолчанию смещение для bin всегда нулевое. Соответственно вам не требуется указывать в начале кодового сегмента RESB 100h, если вы используете формат obj.

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

Дополнительную информацию о формате .SYS файлов и данных, содержащихся в их заголовочной структуре, вы можете почерпнуть в часто задаваемых вопросах конференции comp.os.msdos.programmer.

7.4 Взаимодействие с 16-битными C-программами

В данном параграфе описываются основы создания ассемблерных подпрограмм, которые вызывают или которые вызываются из программ С. Для осуществления этого обычно нужно написать ассемблерный модуль в виде .OBJ файла и скомпоновать его с С-модулями.

7.4.1 Внешние символьные имена

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

Если вам неудобно использовать знаки подчеркивания, вы можете определить макросы для замены директив GLOBAL и EXTERN следующим образом:

%macro cglobal 1 global _%1 %define %1 _%1 %endmacro

%macro cextern 1 extern _%1 %define %1 _%1 %endmacro

(Данные формы макросов принимают только один аргумент; если вам требуется больше, используйте конструкцию %rep).

Если вы определите внешний символ как

cextern printf

макрос развернет его в следующие строки:

extern _printf %define printf _printf

Thereafter, you can reference printf as if it was a symbol, and the preprocessor will put the leading underscore on where necessary.

После этого вы можете ссылаться на printf, а препроцессор, где это нужно, будет помещать ведущий знак подчеркивания. Макрос cglobal работает точно также.

7.4.2 Модели памяти

NASM прямо не поддерживает механизма различных моделей памяти, реализованных в С; вы должны отслеживать это самостоятельно. Это означает, что вы должны учитывать следующее:

В моделях с единственным сегментом кода этот сегмент называется _TEXT, поэтому ваш сегмент должен иметь то же самое имя для компоновки его в то же место, что и основной сегмент кода. В моделях с единственным сегментом данных или с сегментом данных по умолчанию последний именуется как _DATA.

7.4.3 Определения и вызовы функций

Соглашения по вызовам С в 16-битных программах описываются ниже.

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

Исходя из вышесказанного, вы можете определить С-подобную функцию следующим образом (в примере использована модель памяти small):

global _myfunc _myfunc: push bp mov bp,sp sub sp,0x40 ; 64 байта локального пространства стека mov bx,[bp+4] ; первый параметр функции ; некоторый код mov sp,bp ; отмена "sub sp,0x40" выше pop bp ret

Для больших моделей памяти вы должны заменить RET в данной функции на RETF и брать первый параметр не из [BP+4], а из [BP+6]. Естественно, если один из параметров будет указателем, смещения следующих параметров будут зависеть от модели памяти: дальние указатели занимают в стеке 4 байта, в то время как короткие — два.

Если посмотреть с другой стороны, то для вызова С-функции из вашего ассемблерного кода вы должны сделать что-то наподобие следующего:

extern _printf ; здесь идет супер-пупер-прога... push word [myint] ; целое значение - параметр push word mystring ; указатель на мой сегмент данных call _printf add sp,byte 4 ; 'byte' экономит размер ; затем следует сегмент данных... segment _DATA myint dw 1234 mystring db 'Это число -> %d <- должно быть 1234, фуфел!',10,0

Этот ассемблерный код, использующий модель памяти small, эквивалентен С-коду

int myint = 1234; printf("Это число -> %d <- должно быть 1234, фуфел!\n", myint);

В больших моделях памяти кодирование функции вызова выглядит похоже, но все-таки несколько отличается. В приведенном ниже примере подразумевается, что регистр DS уже содержит базу сегмента _DATA. Если это не так, вы должны его проинициализировать.

push word [myint] push word seg mystring ; Теперь сохраняем в стеке сегмент, и... push word mystring ; ... смещение "mystring" call far _printf add sp,byte 6

Целое число по прежнему будет занимать в стеке одно слово, так как большая модель памяти не влияет на размер типа данных int. В то же время первый аргумент для printf (помещаемый в стек последним), является указателем и поэтому состоит из двух частей — сегмента и смещения. Сегмент должен сохраняться в памяти вторым, поэтому в стек он помещается первым. (Конечно PUSH DS будет иметь более короткую инструкцию, чем PUSH WORD SEG mystring, если DS инициализирован так, как подразумевается в приведенном примере). Затем следует дальний вызов call far, как это определено для больших моделей памяти; после возврата из подпрограммы регистр стека увеличивается на 6 (а не на 4) с целью коррекции на размер дополнительного слова, помещенного туда ранее.

7.4.4 Доступ к элементам данных

Для получения доступа к переменным С или объявления переменных, к которым С в свою очередь может обратиться, вы должны всего лишь объявить имена как EXTERN или GLOBAL соответственно. (Имена требуют лидирующего знака подчеркивания, см. параграф 7.4.1.) Таким образом, объявленная в С переменная int i может быть доступна из ассемблера как

extern _i mov ax,[_i]

Чтобы объявить собственную целую переменную, к которой С-программа сможет обратиться как extern int j, вы должны сделать следующее (убедитесь, что эта переменная находится в сегменте _DATA):

global _j _j dw 0

Для получения доступа к С-массиву вам нужно знать размер компонетов последнего. Например, переменные типа int имеют размер два байта (слово), поэтому если С-программа объявляет массив как int a[10], вы можете обратиться к элементу a[3] при помощи инструкции mov ax,[_a+6]. (Байтовое смещение 6 получено путем умножения индекса 3 требуемого элемента на размер элементов массива 2). Размеры базовых типов С для 16-битных компиляторов: 1 для char, 2 для short и int, 4 для long и float, 8 для double.

Чтобы получить доступ к структуре данных С, вам необходимо знать смещение интересующего вас поля от базы этой структуры. Вы можете сделать это либо преобразовав определение С-структуры в определение NASM-структуры (при помощи STRUC), либо рассчитать это смещение и использовать его "как есть".

Чтобы правильно использовать структуры С, вы должны изучить руководство по вашему С-компилятору, чтобы знать, как он организует структуры данных. NASM не делает специального выравнивания для членов его собственных структур STRUC, поэтому если С-компилятор делает это, вы можете задать такое смещение самостоятельно. Обычно вы можете предположить, что структура наподобие

struct { char c; int i; } foo;

будет иметь длину 4 байта, а не 3, так как поле int выравнивается по двухбайтной границе. Однако такие особенности имеют тенденцию конфигурироваться компилятором С при помощи ключей командной строки, либо директив #pragma, поэтому вы должны выяснить, как именно это делает ваш компилятор.

7.4.5 c16.mac: Макросы для 16-битного C-интерфейса

В подкаталоге misc архива NASM имеется файл макросов c16.mac. В нем определены три макроса: proc, arg и endproc. Они предназначены для использования в определениях С-подобных процедур и автоматизируют большинство работ по слежению за соблюдением соглашения о вызовах.

Ниже приведен пример ассемблерной функции, использующей этот набор макросов:

proc _nearproc %$i arg %$j arg mov ax,[bp + %$i] mov bx,[bp + %$j] add ax,[bx] endproc

Здесь определяется процедура _nearproc, принимающая два аргумента, первый (i) — это целое и второй (j) — указатель на целое. Процедура возвращает i + *j.

Заметьте, что макрос arg при его разворачивании содержит в первой строке EQU, которая в результате определяет %$i как смещение от BP. При этом используются контекстно-локальные переменные (локальные к контексту, сохраняемому в контекстном стеке макросом proc и удаляемому оттуда макросом endproc), поэтому в других процедурах может быть использовано то же самое имя аргумента. Конечно, вы можете этого не делать.

По умолчанию представленный набор макросов создает код для ближних функций (модели памяти tiny, small и compact). Чтобы генерировать код для дальних функций (модели medium, large и huge), вы должны определить %define FARCODE. Данное определение изменяет тип возвращаемой endproc инструкции, а также начальную точку смещения аргументов. Набор макросов совершенно не зависит от того, являются ли указатели данных дальними или нет.

Макрос arg может принимать дополнительный параметр, представляющий собой размер аргумента. Если размер не задан, по умолчанию принимается 2, т.к. большинство параметров функций вероятно будут иметь тип int.

Эквивалент представленной выше функции для модели памяти large будет таким:

%define FARCODE proc _farproc %$i arg %$j arg 4 mov ax,[bp + %$i] mov bx,[bp + %$j] mov es,[bp + %$j + 2] add ax,[bx] endproc

Так как j теперь будет дальним указателем, в этом примере используется аргумент макроса arg, определяющий параметр размером 4. Когда мы читаем значение по адресу j, мы должны загрузить как смещение, так и сегмент.

7.5 Взаимодействие с программами Borland Pascal

Взаимодействие с программами на Паскале в концепции схоже по взаимодействию с 16-битными С-программами, однако имеются следующие различия:

7.5.1 Соглашение о вызовах в Pascal

Ниже описываются соглашения о вызовах в 16-битных Паскаль-программах.

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

global myfunc myfunc: push bp mov bp,sp sub sp,0x40 ; резервируется 64 байта в стеке mov bx,[bp+8] ; первый аргумент функции mov bx,[bp+6] ; второй аргумент функции ; код, который наверное что-то делает mov sp,bp ; отмена "sub sp,0x40" выше pop bp retf 4 ; общий размер аргументов 4

С другой стороны, для вызова Паскаль-функции из вашего ассемблерного кода, вы должны сделать что-то вроде следующего:

extern SomeFunc ; тут идет какой-то код... push word seg mystring ; Теперь в стек помещается сегмент и... push word mystring ; ... смещение строки "mystring" push word [myint] ; одна из переменных call far SomeFunc

Этот код эквивалентен следующим строкам на Паскале:

procedure SomeFunc(String: PChar; Int: Integer); SomeFunc(@mystring, myint);

7.5.2 Ограничение имен сегментов в Borland Pascal

Так как внутренний формат модуля Borland Pascal полностью отличается от OBJ, при компоновке модуля с реальным OBJ файлом выполняется очень поверхностная работа по чтению и пониманию различной информации из последнего. Вследствие этого объектные файлы, предназначенные для компоновки с Паскаль-программами, должны удовлетворять нескольким ограничениям:

7.5.3 Использование c16.mac с Pascal-программами

Пакет макросов c16.mac, описанный в параграфе 7.4.5, может быть также использован для облегчения написания функций, вызываемых из программ на Паскале. Для этого вам нужно определить %define PASCAL. Данное определение делает все функции дальними (это подразумевает FARCODE), а также генерирует инструкции возврата из подпрограммы в форме, имеющей операнд.

Определение PASCAL не изменяет код, рассчитывающий смещения аргументов; вы должны объявлять аргументы вашей функции в обратном порядке. Например:

%define PASCAL proc _pascalproc %$j arg 4 %$i arg mov ax,[bp + %$i] mov bx,[bp + %$j] mov es,[bp + %$j + 2] add ax,[bx] endproc

Концептуально здесь определяется та же самая подпрограмма, что и в параграфе 7.4.5: функция принимает два аргумента, целое число и указатель на целое и возвращает сумму целого и содержимого, на которое ссылается указатель. Отличие между этим кодом и версией С для большой модели памяти состоит в том, что вместо FARCODE определяется PASCAL, а аргументы объявляются в обратном порядке.

Следующая глава | Предыдущая глава | Содержание | Указатель