Прежде чем познакомиться с программированием в защищенном режиме, рассмотрим механизм адресации, применяющийся в нем. Так же как и в реальном режиме, адрес складывается из адреса начала сегмента и относительного смещения, но если в реальном режиме адрес начала сегмента просто лежал в соответствующем сегментном регистре, деленый на 16, то в защищенном режиме не все так просто. В сегментных регистрах находятся специальные 16-битные структуры, называемые селекторами и имеющие следующий вид:
биты 15 – 3: номер дескриптора в таблице
бит 2: индикатор таблицы 0/1 — использовать GDT/LDT
биты 1 – 0: уровень привилегий запроса (RPL)
Уровень привилегий запроса — это число от 0 до 3, указывающее уровень защиты сегмента, для доступа к которому используется данный селектор. Если программа имеет более высокий уровень привилегий, при использовании этого сегмента привилегии понизятся до RPL. Уровни привилегий и весь механизм защиты в защищенном режиме нам пока не потребуется.
GDT и LDT — таблицы глобальных и локальных дескрипторов соответственно. Это таблицы восьмибайтных структур, называемых дескрипторами сегментов, в которых и находится начальный адрес сегмента вместе с другой важной информацией:
слово 3 (старшее):
биты 15 – 8: биты 31 – 24 базы
бит 7: бит гранулярности (0 — лимит в байтах, 1 — лимит в 4-килобайтных единицах)
бит 6: бит разрядности (0/1 — 16-битный/32-битный сегмент)
бит 5: 0
бит 4: зарезервировано для операционной системы
биты 3 – 0: биты 19 – 16 лимита
слово 2:
бит 15: бит присутствия сегмента
биты 14 – 13: уровень привилегий дескриптора (DPL)
бит 12: тип дескриптора (0 — системный, 1 — обычный)
биты 11 – 8: тип сегмента
биты 7 – 0: биты 23 – 16 базы
INT 31h, AX = 0 — Выделить локальные дескрипторы
Ввод: | АХ = 0 СХ = количество необходимых дескрипторов |
Вывод: | если CF = 0, АХ = селектор для первого из заказанных дескрипторов |
Эта функция только выделяет место в таблице LDT, создавая в ней дескриптор сегмента данных с нулевыми базой и лимитом, так что пользоваться им пока нельзя.
INT 31h, AX = 1 — Удалить локальный дескриптор
Ввод: | АХ = 1 ВХ = селектор |
Вывод: | CF = 0, если не было ошибки |
Эта функция действует на дескрипторы, созданные при переключении в защищенный режим, и на дескрипторы, созданные функцией 0, но не на дескрипторы, созданные функцией 2.
INT 31h, АХ = 2 — Преобразовать сегмент в дескриптор
Ввод: | АХ = 2 ВХ = сегментный адрес (A000h — для видеопамяти, 0040h — для данных BIOS) |
Вывод: | если CF = 0, АХ = готовый селектор на сегмент, начинающийся с указанного адреса, и с лимитом 64 Кб |
Так, программы в защищенном режиме могут обращаться к различным областям памяти ниже границы 1 Мб, например для прямого вывода на экран.
INT 31h, AX = 6 — Определить базу сегмента
Ввод: | АХ = 6 ВХ = селектор |
Вывод: | если CF = 0, CX:DX = 32-битный линейный адрес начала сегмента |
INT 31h, AX = 7 — Сменить базу сегмента
Ввод: | АХ = 7 ВХ = селектор CX:DX = 32-битная база |
Вывод: | CF = 0, если не было ошибок |
INT 31h, AX = 8 — Сменить лимит сегмента
Ввод: | АХ = 8 ВХ = селектор CX:DX = 32-битный лимит (длина сегмента минус 1) |
Вывод: | CF = 0, если не было ошибок (чтобы определить лимит сегмента, можно пользоваться командой LSL) |
INT 31h, AX = 9 — Сменить права доступа сегмента
Ввод: | АХ = 9 ВХ = селектор CL = права доступа/тип (биты 15 – 8 слова 2 дескриптора) СН = дополнительные права (биты 7 – 4 соответствуют битам 7 – 4 слова 3 дескриптора, биты 3 – 0 игнорируются) |
Вывод: | CF = 0, если не было ошибок (чтобы определить права доступа сегмента, можно пользоваться командой LAR) |
Ввод: | АХ = 000Ah ВХ = селектор (сегмента кода или данных) |
Вывод: | если CF = 0, АХ = селектор на сегмент данных с теми же базой и лимитом |
Ввод: | АХ = 000Bh ВХ = селектор ES:EDI = селектор:смещение 8-байтного буфера |
Вывод: | если CF = 0, в буфер помещен дескриптор |
Ввод: | АХ = 000Ch ВХ = селектор ES:EDI = адрес 8-байтного дескриптора |
Вывод: | CF = 0, если не было ошибок |
Ввод: | АХ = 000Dh ВХ = селектор на один из первых 16 дескрипторов (значения селектора 04h – 7Ch) |
Вывод: | CF = 0, если нет ошибок (CF = 1 и АХ = 8011h, если этот дескриптор занят) |
Спецификация DPMI создана в 1990 – 1991 годах и представляет собой развитую систему сервисов, позволяющих программам переключаться в защищенный режим, вызывать обработчики прерываний BIOS и DOS, передавать управление другим процедурам, работающим в реальном режиме, устанавливать обработчики аппаратных и программных прерываний и исключений, работать с памятью с разделяемым доступом, устанавливать точки останова и т.д. Операционные системы, такие как Windows 95 или, например, Linux, предоставляют DPMI-интерфейс для программ, запускаемый в DOS-задачах; многочисленные расширители DOS, о которых говорится в следующей главе, предоставляют DPMI для DOS-программ, запускаемых в любых условиях, так что сейчас можно считать DPMI основным интерфейсом, на который следует ориентироваться при программировании 32-битных приложений для DOS.
Спецификация этого интерфейса была создана в 1989 году, вскоре после появления процессора 80386, компаниями Phar Lap Software и Quaterdeck Office Systems. Программа, пользующаяся VCPI для переключения в защищенный режим, должна поддерживать полный набор системных таблиц — GDT, LDT, IDT, таблицы страниц и т.д., то есть фактически VCPI-сервер обеспечивает следующее — процессор находится в защищенном режиме, и различные программы, пользующиеся им, не будут конфликтовать между собой.
VCPI является своего рода расширением интерфейса EMS, и все обращения к нему выполняются при помощи прерывания EMS, INT 67h с АН = 0DEh и кодом подфункции VCPI в AL.
INT 67h АХ = DE00h — Проверка наличия VCPI
Ввод: | AX = 0DE00h |
Вывод: | АН = 00, если VCPI есть ВН, BL — версия (старшая, младшая цифры) |
INT 67h АХ = DE01h — Получить точку входа VCPI
Ввод: | AX = 0DE01h ES:DI = адрес 4-килобайтного буфера для таблицы страницDS:SI = адрес таблицы дескрипторов |
Вывод: | АН = 0, если нет ошибок DS:SI — первые три дескриптора заполняются дескрипторами VCPI-сервера ES:SI — адрес первой не используемой сервером записи в таблице страниц ЕВХ — адрес точки входа VCPI относительно сегмента, дескриптор которого лежит первым в таблице DS:SI. Можно делать far call из 32-битного защищенного режима на этот адрес с АХ = DE00h – DE05h, чтобы пользоваться функциями VCPI из защищенного режима |
INT 67h AX = DE0Ch — VCPI: переключиться в защищенный режим (для вызова из V86)
Ввод: | AX = 0DE0Ch ESI = линейный адрес таблицы со значениями для системных регистров (в первом мегабайте) +00h: 4 байта — новое значение CR3 +04h: 4 байта — адрес 6-байтного значения для GDTR +08h: 4 байта — адрес 6-байтного значения для IDTR +0Ch: 2 байта — LDTR +0Eh: 2 байта — TR +10h: 6 байт — CS:EIP — адрес точки входа прерывания должны быть запрещены |
Вывод: | Загружаются регистры GDTR, IDTR, LDTR, TR. Теряются регистры ЕАХ, ESI, DS, ES, FS, GS. SS:ESP указывает на стек размером в 16 байт, и его надо изменить, прежде чем снова разрешать прерывания |
Ввод: | Перед передачей управления командой call в стек надо поместить регистры в следующем порядке (все значения — двойные слова): GS, FS, DS, ES, SS, ESP, 0, CS, EIP. Прерывания должны быть запрещены |
Вывод: | Сегментные регистры загружаются, значение ЕАХ не определено, прерывания запрещены |
Ввод: | АХ = 0DE02h |
Вывод: | АН = 0, если нет ошибок EDX = физический адрес самой старшей 4-килобайтной страницы, которую можно выделить |
Ввод: | АХ = 0DE03h |
Вывод: | АН = 0, если нет ошибок EDX = число свободных 4-килобайтных страниц для всех задач |
Ввод: | АХ = 0DE04h |
Вывод: | АН = 0, если нет ошибок EDX = физический адрес выделенной страницы |
Ввод: | АХ = 0DE05h EDX = физический адрес страницы |
Вывод: | АН = 0, если нет ошибок |
Ввод: | АХ = 0DE06h СХ = линейный адрес страницы, сдвинутый вправо на 12 бит |
Вывод: | АН = 0, если нет ошибок EDX = физический адрес страницы |
Ввод: | АХ = 0DE07h |
Вывод: | АН = 0, если нет ошибок ЕВХ = содержимое регистра CR0 |
Ввод: | АХ = 0DE08h ES:DI = буфер на 8 двойных слов |
Вывод: | АН = 0, если нет ошибок, в буфер не записываются DR4 и DR5 |
Ввод: | АХ = 0DE09h ES:DI = буфер на 8 двойных слов с новыми значениями для регистров |
Вывод: | АН = 0, если нет ошибок (DR4 и DR5 не записываются) |
Ввод: | АХ = 0DE0Ah |
Вывод: | АН = 0, если нет ошибок ВХ = номер обработчика для IRQ0 СХ = номер обработчика для IRQ8 |
Ввод: | АХ = 0DE0Bh ВХ = номер обработчика для IRQ0 СХ = номер обработчика для IRQ8 |
Вывод: | АН = 0, если нет ошибок |
Прежде чем мы рассмотрим первый пример программы, использующей DPMI, остановимся еще на одной группе его функций — операции с обработчиками прерываний. Когда происходит прерывание или исключение, управление передается сначала по цепочке обработчиков прерываний в защищенном режиме, последний обработчик — стандартный обработчик DPMI — переходит в режим V86, а затем управление проходит по цепочке обработчиков прерывания в реальном режиме (в реальном режиме обработчики прерываний и исключений совпадают).
INT 31h, AX = 0200h — Определить адрес реального обработчика прерывания
Ввод: | АХ = 0200h BL = номер прерывания |
Вывод: | CF = 0 всегда, CX:DX — сегмент:смещение обработчика прерывания в реальном режиме |
INT 31h, АХ = 0201h — Установить реальный обработчик прерывания
Ввод: | АХ = 0201h BL = номер прерывания CX:DX = сегмент:смещение обработчика прерывания в реальном режиме |
Вывод: | CF = 0 всегда |
INT 31h, АХ = 0204h — Определить адрес защищенного обработчика прерывания
Ввод: | АХ = 0204h BL = номер прерывания |
Вывод: | CF = 0 всегда, CX:EDX = селектор:смещение обработчика |
INT 31h, АХ = 0205h — Установить защищенный обработчик прерывания
Ввод: | АХ = 0205h BL = номер прерывания CX:EDX = селектор:смещение обработчика |
Вывод: | CF = 0 |
INT 31h, АХ = 0202h — Определить адрес обработчика исключения
Ввод: | АХ = 0202h BL = номер исключения (00h – 1Fh) |
Вывод: | если CF = 0, CX:EDX = селектор:смещение обработчика исключения |
INT 31h, АХ = 0203h — Установить обработчик исключения
Ввод: | АХ = 0203h BL = номер исключения (00h – 1Fh) CX:EDX = селектор:смещение обработчика исключения |
Вывод: | CF = 0, если не было ошибок |
Если обработчик исключения передает управление дальше по цепочке на стандартный обработчик DPMI-сервера, следует помнить, что только исключения 0, 1, 2, 3, 4, 5 и 7 передаются обработчикам из реального режима, а остальные исключения приводят к прекращению работы программы.
Вызов любого программного прерывания, кроме INT 31h и INT 21h/АН = 4Ch (функция DPMI: завершение программы), приводит к тому, что DPMI-сервер переключается в режим V86 и вызывает это же самое прерывание, скопировав все регистры, кроме сегментных регистров и стека (состояние сегментных регистров не определено, а стек предоставляет сам DPMI-сервер). После того как обработчик прерывания возвратит управление, DPMI-сервер возвратиться в защищенный режим и вернет управление программе. Таким способом можно вызывать все прерывания, передающие параметры только в регистрах, например проверку нажатия клавиши или вывод символа на экран. Чтобы вызвать прерывание, использующее сегментные регистры, например вывод строки на экран, а также в других ситуациях, требующих вызова процедуры, работающей в другом режиме, применяются следующие функции.
INT 31h, AX = 0300h — Вызов прерывания в реальном режиме
Ввод: | АХ = 0300h ВН = 0, BL = номер прерывания СХ = число слов из стека защищенного режима, которое будет скопировано в стек реального режима и обратно ES:EDI = селектор:смещение структуры регистров v86_regs (см. ниже) |
Вывод: | если CF = 0, структура в ES:EDI модифицируется |
Значения регистров CS и IP в структуре v86_regs игнорируются. Вызываемый обработчик прерывания должен восстанавливать стек в исходное состояние (например, INT 25h и INT 26h этого не делают).
INT 31h, АХ = 0301Н Вызов дальней процедуры в реальном режиме
Ввод: | АХ = 0301h ВН = 0 СХ = число слов из стека защищенного режима, которое будет скопировано в стек реального режима и обратно ES:EDI = селектор:смещение структуры регистров v86_regs (см. ниже) |
Вывод: | если CF = 0, структура в ES:EDI модифицируется |
Вызываемая процедура должна заканчиваться командой RETF.
INT 31h, AX = 0302h Вызов обработчика прерывания в реальном режиме
Ввод: | АХ = 0302h ВН = 0 СХ = число слов из стека защищенного режима, которое будет скопировано в стек реального режима и обратно ES:EDI = селектор:смещение структуры регистров v86_regs (см. ниже) |
Вывод: | если CF = 0, структура в ES:EDI модифицируется |
Ввод: | АХ = 0303h DS:ESI = селектор:смещение процедуры в защищенном режиме (заканчивающейся IRET), которую будут вызывать из реального режима ES:EDI = селектор:смещение структуры v86_regs, которая будет использоваться для передачи регистров |
Вывод: | если CF = 0, CX:DX = сегмент:смещение точки входа |
Ввод: | АХ = 0304h CX:DX = сегмент:смещение точки входа |
Вывод: | CF = 0, если точка входа удалена |
Все основные сервисы DPMI доступны только в защищенном режиме через прерывание INT 31h, так что переключение режимов — первое, что должна сделать программа.
INT 2Fh AX = 1687h — Функция DPMI: получить точку входа в защищенный режим
Ввод: | АХ = 1687h |
Вывод: | АХ = 0, если DPMI присутствует ВХ: бит 0 = 1, если поддерживаются 32-битные программы, 0 — если нет CL: тип процессора (02 — 80286, 03 — 80386 и т.д.) DH:DL — версия DPMI в двоичном виде (обычно 00:90 (00:5Ah) или 01:00) SI = размер временной области данных, требуемой для переключения в 16-байтных параграфах ES:DI = адрес процедуры переключения в защищенный режим |
Вызвав эту функцию, программа должна выделить область памяти размером SI * 16 байт и выполнить дальний CALL на указанный адрес. Единственные входные параметры — регистр ES, который должен содержать сегментный адрес области данных для DPMI и бит 0 регистра АХ. Если этот бит 1 — программа собирается стать 32-битным приложением, а если 0 — 16-битным. Если по возвращении из процедуры установлен флаг CF, переключения не произошло и программа все еще в реальном режиме. Если CF = 0, программа переключилась в защищенный режим и в сегментные регистры загружены следующие селекторы:
CS: 16-битный селектор с базой, совпадающей со старым CS, и лимитом 64 Кб
DS: селектор с базой, совпадающей со старым DS, и лимитом 64 Кб
SS: селектор с базой, совпадающей со старым SS, и лимитом 64 Кб
ES: селектор с базой, совпадающей с началом блока PSP, и лимитом 100h
FS и GS = 0
Разрядность сегментов данных определяется заявленной разрядностью программы. Остальные регистры сохраняются, а для 32-битных программ старшее слово ESP обнуляется. По адресу ES:[002Ch] записывается селектор сегмента, содержащего переменные окружения DOS.
После того как произошло переключение, можно загрузить собственные дескрипторы для сегментов кода и данных и перейти в режим с моделью памяти flat.
Перечислим теперь основные функции, предоставляемые DPMI для работы с дескрипторами и селекторами.
Теперь воспользуемся интерфейсом DPMI для переключения в защищенный режим и вывода строки на экран. Этот пример будет работать только в системах, предоставляющих DPMI для запускаемых в них DOS-программ, например в Windows 95.
; dpmiex.asm ; Выполняет переключение в защищенный режим средствами DPMI .386 ; 32-битный защищенный режим появился в 80386 ; 16-битный сегмент - в нем выполняется подготовка и переход в защищенный режим RM_seg segment byte public use16 assume cs:RM_seg, ds:PM_seg, ss:RM_stack
; точка входа программы RM_entry: ; проверить наличие DPMI mov ax,1687h ; номер 1678h int 2Fh ; прерывание мультиплексора, test ax,ax ; если АХ не ноль, jnz DPMI_error ; произошла ошибка (DPMI отсутствует), test bl,1 ; если не поддерживается 32-битный режим, jz DPMI_error ; нам тоже нечего делать
; подготовить базовые адреса для будущих дескрипторов mov eax,PM_seg mov ds,ax ; DS - сегментный адрес PM_seg shl eax,4 ; EAX - линейный адрес начала сегмента PM_seg mov dword ptr PM_seg_addr,eax or dword ptr GDT_flatCS+2,eax ; дескриптор для CS or dword ptr GDT_flatDS+2,eax ; дескриптор для DS
; сохранить адрес процедуры переключения DPMI mov word ptr DPMI_ModeSwitch,di mov word ptr DPMI_ModeSwitch+2,es
; ES должен указывать на область данных для переключения режимов, ; у нас она будет совпадать с началом будущего 32-битного стека add eax,offset DPMI_data ; добавить к EAX смещение shr eax,4 ; и превратить в сегментный адрес inc ax mov es,ax
; перейти в защищенный режим mov ах, 1 ; АХ = 1 - мы будем 32 приложением ifdef _WASM_ db 67h ; поправка для wasm endif call dword ptr DPMI_ModeSwitch jc DPMI_error ; если переключения не произошло - выйти
; теперь мы находимся в защищенном режиме, но лимиты всех сегментов ; установлены на 64 Кб, а разрядности сегментов - на 16 бит. ; Нам надо подготовить два ; 32-битных селектора с лимитом 4 Гб - один для кода и один для данных push ds pop es ; ES вообще был сегментом PSP с лимитом 100h mov edi,offset GDT ; EDI - адрес таблицы GDT ; цикл по всем дескрипторам в нашей GDT, mov edx,1 ; которых всего два (0, 1) sel_loop: xor ах,ах ; функция DPMI 00 mov cx,1 int 31h ; создать локальный дескриптор mov word ptr selectors[edx*2],ax ; сохранить селектор mov bx,ax ; в таблицу selectors mov ax,000Ch ; функция DPMI OCh int 31h ; установить селектор add di,8 ; EDI - следующий дескриптор dec dx jns sel_loop
Все, о чем рассказано до этой главы, рассчитано на работу под управлением DOS в реальном режиме процессора (или в режиме V86), унаследованном еще с семидесятых годов. В этом режиме процессор неспособен адресоваться к памяти выше границы первого мегабайта. Кроме того, из-за того, что для адресации используются 16-битные смещения, невозможно работать с массивами больше 65 536 байт. Защищенный режим лишен этих недостатков, в нем можно адресоваться к участку памяти размером 4 Гб как к одному непрерывному массиву и вообще забыть о сегментах и смещениях. Этот режим намного сложнее реального, поэтому, чтобы переключить в него процессор и поддерживать работу в этом режиме, надо написать небольшую операционную систему. Кроме того, если процессор уже находится под управлением какой-то операционной системы, которая перевела его в защищенный режим, например Windows 95, она, скорее всего, не разрешит программе устранить себя от управления компьютером. С этой целью были разработаны специальные интерфейсы, позволяющие программам, запущенным в режиме V86 в DOS, переключаться в защищенный режим простым вызовом соответствующего прерывания — VCPI и DPMI.
Расширитель DOS (DOS Extender) — это средство разработки (программа, набор программ, часть компоновщика или просто объектный файл, с которым нужно компоновать свою программу), позволяющее создавать 32-битные приложения, запускающиеся в защищенном режиме с моделью памяти flat и с работающими функциями DPMI. Расширитель сам выполняет переключение в защищенный режим, причем, если в системе уже присутствует DPMI, VCPI или другие средства переключения в защищенный режим из V86, он пользуется ими, а если программа запускается в настоящем реальном режиме, DOS-расширитель сам переводит процессор в защищенный режим и выполняет все необходимые действия для поддержки его работы. Кроме полного или частичного набора функций DPMI расширители DOS обычно поддерживают некоторые функции прерывания 21h, за что и получили свое название. В частности, практически всегда поддерживается функция DOS 09h вывода строки на экран: в DS:EDX помещают селектор:смещение начала строки, и расширитель это правильно интерпретирует (многие DPMI-серверы, включая встроенный сервер Windows 95, тоже эмулируют эту функцию DOS).
Так как расширитель должен первым получить управление при старте программы, для того чтобы выполнить переключение режимов, код расширителя нужно объединить с нашей программой на стадии компиляции или компоновки.
Первые популярные DOS-расширители, такие как Start32, Raw32, System64, 386Power, PMODE и другие, распространяются в виде исходных текстов (Start32 и PMODE оказали решающее влияние на развитие DOS-расширителей в целом). Чтобы использовать такой расширитель, надо скомпилировать его любым ассемблером в объектный файл, который необходимо скомпоновать вместе со своей программой. В большинстве случаев надо назвать точку входа своей программы main или _main и закончить модуль директивой end без параметра, тогда DOS-расширитель получит управление первым и передаст его на метку main после того, как будут настроены все сегменты для модели памяти flat.
Самым популярным из профессиональных компиляторов, поддерживающих расширители DOS, стал компилятор Watcom C/C++, использующий модификацию коммерческого DOS-расширителя DOS4G, названную DOS/4GW. Дело в том, что компоновщик wlink.exe поддерживает, среди большого числа различных форматов вывода, формат линейных исполнимых файлов LE, применяющийся в операционной системе OS/2 (а также, с небольшими модификациями, для драйверов в Windows). Оказалось, что достаточно просто дописать файл в формате OS/2 LE в конец загрузчика DOS-расширителя, написанного соответствующим образом, чтобы потом его запускать. Загрузчик расширителя можно указать прямо в командной строке wlink (командой op stub) или скопировать позже. В комплект поставки расширителей часто входит специальная утилита, которая заменяет загрузчик, находящийся в начале такой программы, на свой.
Чтобы скомпилировать, например, программу lfbfire.asm, которую мы рассмотрим далее, следует воспользоваться следующими командами:
Компиляция:
wasm lfbfire.asm
Компоновка с DOS/4GW (стандартный расширитель, распространяемый с Watcom С):
wlink file lfbfire.obj form os2 le op stub=wstub.exe
Компоновка с PMODE/W (самый популярный из бесплатных расширителей):
wlink file lfbfire.obj form os2 le op stub=pmodew.exe
Компоновка с ZRDX (более строгий с точки зрения реализации):
wlink file lfbfire.obj form os2 le op stub=zrdx.exe
INT 31h, AX = 0100h — Выделить память ниже границы 1 Мб
Ввод: | АХ = 0100h ВХ = требуемый размер в 16-байтных параграфах |
Вывод: | если CF = 0, АХ = сегментный адрес выделенного блока для использования в реальном режиме; DX = селектор выделенного блока для применения в защищенном режиме |
Обработчик этой функции выходит в V86 и вызывает функцию DOS 48h для выделения области памяти, которую потом можно использовать для передачи данных между нашей программой и обработчиками прерываний, возвращающими структуры данных в памяти.
INT 31h, АХ = 0101h — Освободить память ниже границы 1 Мб
Ввод: | АХ = 0101h DX = селектор освобождаемого блока |
Вывод: | CF = 0, если не было ошибок |
INT 31h, АХ = 0102h — Изменить размер блока, выделенного функцией 0100h
Ввод: | АХ = 0102h ВХ = новый размер блока в 16-байтных параграфах DX = селектор модифицируемого блока |
Вывод: | CF = 0, если не было ошибок |
INT 31h, АХ = 0500h — Получить информацию о свободной памяти
Ввод: | АХ = 0500h ES:EDI = адрес 48-байтного буфера |
Вывод: | CF = 0 всегда, буфер заполняется следующей структурой данных:
+00h: 4 байта — максимальный доступный блок в байтах +04h: 4 байта — число доступных нефиксированных страниц +08h: 4 байта — число доступных фиксированных страниц +0Ch: 4 байта — линейный размер адресного пространства в страницах +10h: 4 байта — общее число нефиксированных страниц +14h: 4 байта — общее число свободных страниц +18h: 4 байта — общее число физических страниц +1Ch: 4 байта — свободное место в линейном адресном пространстве +20h: 4 байта — размер swap-файла или раздела в страницах +24h: 0Ch байт — все байты равны FFh |
INT 31h, AX = 0501h — Выделить блок памяти
Ввод: | АХ = 0501h ВХ:СХ = размер блока в байтах, больше нуля |
Вывод: | если CF = 0, ВХ:СХ = линейный адрес блока; SI:DI = идентификатор блока для функций 0502 и 0503 |
Ввод: | АХ = 0502h SI:DI = идентификатор блока |
Вывод: | CF = 0, если не было ошибки |
Ввод: | АХ = 0503h ВХ:СХ = новый размер в байтах SI:DI = идентификатор блока |
Вывод: | если CF = 0, ВХ:СХ = новый линейный адрес блока; SI:DI = новый идентификатор |
Ввод: | АХ = 0800h ВХ:СХ = физический адрес SI:DI = размер области в байтах |
Вывод: | если CF = 0, ВХ:СХ = линейный адрес, который можно использовать для доступа к этой памяти |
Ввод: | АХ = 0801h ВХ:СХ = линейный адрес, возвращенный функцией 0800h |
Вывод: | CF = 0, если не было ошибок |
; lfbfire.asm ; Программа, работающая с SVGA при помощи линейного кадрового буфера (LFB), ; демонстрирует стандартный алгоритм генерации пламени. ; Требуется поддержка LFB видеоплатой (или загруженный эмулятор univbe), ; для компиляции необходим DOS-расширитель ; .486p ; для команды xadd .model flat ; основная модель памяти в защищенном режиме .code assume fs:nothing ; нужно только для MASM _start: jmp short _main db "WATCOM" ; нужно только для DOS/4GW
; начало программы ; на входе обычно CS, DS и SS указывают на один и тот же сегмент с лимитом 4 Гб, ; ES указывает на сегмент с PSP на 100h байт, ; остальные регистры не определены _main: sti ; даже флаг прерываний не определен, cld ; не говоря уже о флаге направления
mov ax,0100h ; функция DPMI 0100h mov bx,100h ; размер в 16-байтных параграфах int 31h ; выделить блок памяти ниже 1 Мб jc DPMI_error mov fs,dx ; FS - селектор для выделенного блока
; получить блок общей информации о VBE 2.0 (см. главу 4.5.2) mov dword ptr fs:[0],'2EBV' ; сигнатура VBE2 в начало блока mov word ptr v86_eax,4F00h ; функция VBE 00h mov word ptr v86_es,ax ; сегмент, выделенный DPMI mov ax,0300h ; функция DPMI 300h mov bx,0010h ; эмуляция прерывания 10h xor есх,есх mov edi,offset v86_regs push ds pop es ; ES:EDI - структура v86_regs int 31h ; получить общую информацию VBE2 jc DPMI_error cmp byte ptr v86_eax,4Fh ; проверка поддержки VBE jne VBE_error movzx ebp,word ptr fs:[18] ; объем SVGA-памяти в EBP shl ebp,6 ; в килобайтах
; получить информацию о видеорежиме 101h mov word ptr v86_eax,4F01h ; номер функции INT 10h mov word ptr v86_ecx,101h ; режим 101h - 640x480x256 ; ES:EDI те же самые mov ax,0300h ; функция DPMI 300h - эмуляция mov bx,0010h ; прерывания INT 10h xor ecx,ecx int 31h ; получить данные о режиме jc DPMI_error cmp byte ptr v86_eax,4Fh jne VBE_error test byte ptr fs:[0],80h ; бит 7 байта атрибутов = 1 - LFB есть jz LFB_error
; построение дескриптора сегмента, описывающего LFB ; лимит mov eax,ebp ; видеопамять в килобайтах shl eax,10 ; теперь в байтах dec еах ; лимит = размер - 1 shr еах,12 ; лимит в 4-килобайтных единицах mov word ptr videodsc+0,ax ; записать биты 15-0 лимита shr еах,8 and ah,0Fh or byte ptr videodsc+6,ah ; и биты 19 - 16 лимита mov eax,ebp ; видеопамять в килобайтах shl eax,10 ; в байтах mov edx,dword ptr fs:[40] ; физический адрес LFB shld ebx,edx,16 ; поместить его в CX:DX, shld esi,eax,16 ; а размер - в SI:DI mov ax,800h ; и вызвать функцию DPMI 0800h int 31h ; отобразить физический адрес в линейный shrd edx,ebx,16 ; перенести полученный линейный адрес mov dx,cx ; из BS:CX в EDX mov word ptr videodsc+2,dx ; и записать биты 15-0 базы mov byte ptr videodsc+4,dl ; биты 23 - 16 mov byte ptr videodsc+7,dh ; и биты 31 -24 ; права mov bx,cs lar cx,bx ; прочитать права нашего сегмента and cx,6000h ; и перенести биты DPL or byte ptr videodsc+5,ch ; в строящийся дескриптор
Графические программы для Windows практически никогда не ограничиваются одним меню, потому что меню не позволяет ввести никакой реальной информации — только выбрать тот или иной пункт из предложенных. Конечно, можно в цикле после GetMessage или PeekMessage обрабатывать события передвижения мыши и нажатия клавиш, и так делают в интерактивных программах, например в играх, но, если требуется ввод текста с возможностью его редактирования, выбор файла на диске и любые другие нетривиальные действия, основным средством ввода информации в программах для Windows оказываются диалоги.
Диалог описывается, так же как и меню, в файле ресурсов, но, если меню было очень просто написать вручную, ради диалогов, скорее всего, придется пользоваться каким-нибудь редактором диалогов, идущим в комплекте с вашим любимым компилятором, если, конечно, вы не знаете в точности, по каким координатам будет располагаться каждый контрол (активный элемент диалога).
// windlg.rc // Файл ресурсов, описывающий диалог, используемый в программе windlg.asm. // Все следующие определения можно заменить на #include // <winuser.h>, если он есть:
// стили для диалогов #define DS_CENTER 0x0800L #define DS_MODALFRAME 0x80L #define DS_3DLOOK 0x0004L
// стили для окон #define WS_MINIMIZEBOX 0x00020000L #define WS_SYSMENU 0x00080000L #define WS_VISIBLE 0x10000000L #define WS_OVERLAPPED 0x00000000L #define WS_CAPTION 0xC00000L
// стили для редактора #define ES_AUTOHSCROLL 0x80L #define ES_LEFT 0 #define ZDLG_MENU 7
// идентификаторы контролов диалога #define IDC_EDIT 0 #define IDC_BUTTON 1 #define IDC_EXIT 2
// идентификаторы пунктов меню #define IDM_GETTEXT 10 #define IDM_CLEAR 11 #define IDM_EXIT 12
ZZZ_Dialog DIALOG 10,10,205,30 // x, у, ширина, высота STYLE DS_CENTER | DS_MODALFRAME | DS_3DLOOK | WS_CAPTION | WS_MINIMIZEBOX | WS_SYSMENU | WS_VISIBLE | WS_OVERLAPPED CAPTION "Win32 assembly dialog example" // заголовок MENU ZDLG_MENU // меню BEGIN // начало списка контролов EDITTEXT IDC_EDIT,15,7,111,13,ES_AUTOHSCROLL | ES_LEFT PUSHBUTTON "E&xit",IDC_EXIT,141,8,52,13 END
Кроме обычных приложений в Windows появился специальный тип файла — динамические библиотеки (DLL). DLL — это файл, содержащий процедуры и данные, которые доступны программам, обращающимся к нему. Например, все системные функции Windows, которыми мы пользовались, на самом деле были процедурами, входящими в состав таких библиотек, — kernel32.dll, user32.dll, comdlg32.dll и т.д. Динамические библиотеки позволяют уменьшить использование памяти и размер исполнимых файлов для тех случаев, когда несколько программ (или даже несколько копий одной и той же программы) используют одну и ту же процедуру. Можно считать, что DLL — это аналог пассивной резидентной программы, с тем лишь отличием, что DLL не находится в памяти, если ни одна программа, его использующая, не загружена.
С точки зрения программирования на ассемблере DLL — это самый обычный исполнимый файл формата РЕ, отличающийся только тем, что при входе в него в стеке находятся три параметра (идентификатор DLL-модуля, причина вызова процедуры и зарезервированный параметр), которые надо удалить, например командой ret 12. Кроме этой процедуры в DLL входят и другие, часть которых можно вызывать из других программ. Список этих экспортируемых процедур должен быть задан во время компиляции DLL, и поэтому команды для компиляции нашего следующего примера будут отличаться от обычных.
Компиляция MASM:
ml /с /coff /Cp /D_MASM_ dllrus.asm link dllrus.obj @dllrus.lnk
Содержимое файла dllrus.lnk:
/DLL /entry:start /subsystem:windows /export:koi2win_asm /export:koi2win /export:koi2wins_asm /export:koi2wins
Компиляция TASM:
tasm /m /x /ml /D_TASM_ dllrus.asm tlink32 -Tpd -c dllrus.obj,,,,dllrus.def
Содержимое файла dllrus.def:
EXPORTS koi2win_asm koi2win koi2wins koi2wins_asm
Компиляция WASM:
wasm dllrus.asm wlink @dllrus.dir
Содержимое dllrus.dir:
file dllrus.obj form windows nt DLL exp koi2win_asm,koi2win,koi2wins_asm,koi2wins
; dllrus.asm ; DLL для Win32 - перекодировщик из koi8 в ср1251 .386 .model flat ; функции, определяемые в этом DLL ifdef _MASM_ public _koi2win_asm@0 ; koi2win_asm - перекодирует символ в AL public _koi2win@4 ; CHAR WINAPI koi2win(CHAR symbol) public _koi2wins_asm@0 ; koi2wins_asm - перекодирует строку в [ЕАХ] public _koi2wins@4 ; VOID WINAPI koi2win(CHAR * string) else public koi2win_asm ; те же функции для TASM и WASM public koi2win public koi2wins_asm public koi2wins endif
В Windows, так же как и в DOS, существует еще один вид исполнимых файлов — драйверы устройств. Windows 3.x и Windows 95 используют одну модель драйверов, Windows NT — другую, a Windows 98 — уже третью, хотя и во многом близкую к модели Windows NT. В Windows 3.x/Windows 95 используются два типа драйверов устройств — виртуальные драйверы (VxD), выполняющиеся с уровнем привилегий 0 (обычно имеют расширение .386 для Windows 3.x и .VXD для Windows 95), и непривилегированные драйверы, исполняющиеся, как и обычные программы, с уровнем привилегий 3 (обычно имеют расширение .DRV). Windows NT использует несовместимую модель драйверов, так называемую kernel-mode (режим ядра). На основе модели kernel-mode с добавлением поддержки технологии PNP и понятия потоков данных в 1996 году была основана модель WDM (win32 driver model), которая теперь используется в Windows 98 и NT и, по-видимому, будет играть главную роль в дальнейшем.
Как и следовало ожидать, основным средством создания драйверов является ассемблер, и хотя применение С здесь возможно (в отличие от случая драйверов DOS), оно оказывается гораздо сложнее, чем применение ассемблера, — функции, к которым обращается драйвер, могут передавать параметры в регистрах, сегменты, из которых состоит драйвер, должны называться определенным образом, и т.д. Практически при создании драйверов часто пользуются одновременно С и ассемблером или С и специальным пакетом, который включает в себя все необходимые для инициализации действия.
Чтобы самостоятельно создавать драйверы для любой версии Windows, необходим комплект программ, документации, включаемых файлов и библиотек, распространяемый Microsoft, который называется DDK (Drivers Development Kit). (DDK для Windows NT и Windows 98 распространяются бесплатно.)
Мы не будем рассматривать программирование драйверов для Windows в деталях, так как этой теме посвящено достаточно много литературы, не говоря уже о документации, прилагающейся к DDK. Чтобы создать драйвер, в любом случае лучше всего начать с одного из прилагающихся примеров и изменять/добавлять процедуры инициализации, обработчики сообщений, прерываний и исключений, обработчики для API, предоставляемого драйвером, и т.д. Рассмотрим только, как выглядит исходный текст драйвера, потому что он несколько отличается от привычных нам ассемблерных программ.
Несмотря на то что Windows 95 и Windows NT кажутся более сложными операционными системам по сравнению с DOS, программировать для них на ассемблере намного проще. С одной стороны, Windows-приложение запускается в 32-битном режиме (мы не рассматриваем Windows 3.11 и более старые версии, которые работали в 16-битном режиме) с моделью памяти flat, так что программист получает все те преимущества, о которых говорилось в предыдущей главе, а с другой стороны — нам больше не нужно изучать в деталях, как программировать различные устройства компьютера на низком уровне. В настоящих операционных средах приложения пользуются только системными вызовами, число которых здесь превышает 2000 (около 2200 для Windows 95 и 2434 для Windows NT). Все Windows-приложения используют специальный формат исполнимых файлов — формат PE (Portable Executable). Такие файлы начинаются как обычные EXE-файлы старого образца (их также называют MZ по первым двум символам заголовка), и, если такой файл запустить из DOS, он выполнится и выдаст сообщение об ошибке (текст сообщения зависит от используемого компилятора), в то время как Windows заметит, что после обычного MZ-заголовка файла идет PE-заголовок, и запустит приложение. Ёто будет означать только то, что для компиляции программ потребуются другие параметры в командной строке.
Исполнимые программы для Windows делятся на два основных типа — консольные и графические приложения. При запуске консольного приложения открывается текстовое окно, с которым программа может общаться функциями WriteConsole()/ReadConsole() и другими (соответственно при запуске из другого консольного приложения, например, файлового менеджера FAR, программе отводится текущая консоль и управление не возвращается к FAR, пока программа не закончится). Графические приложения соответственно не получают консоли и должны открывать окна, чтобы вывести что-нибудь на экран.
Для компиляции консольных приложений мы будем пользоваться следующими командами:
MASM:
ml /с /coff /Cp winurl.asm link winurl.asm /subsystem console
TASM:
tasm /m /ml /D_TASM_ winurl.asm tlink32 /Тре /ар /с /x winurl.obj
WASM:
wasm winurl.asm wlink file winurl.obj form windows nt runtime console op с
Попробуйте скомпилировать программу winurl.asm этим способом, чтобы увидеть, как отличается работа консольного приложения от графического.
В качестве примера полноценного консольного приложения напишем программу, которая перечислит все подключенные сетевые ресурсы (диски и принтеры), используя системные функции WNetOpenEnum(), WNetEnumResource() и WNetCloseEnum().
; netenum.asm ; Консольное приложение для win32, перечисляющее сетевые ресурсы include def32.inc include kernel32.inc include mpr.inc
.386 .model flat .const greet_message db 'Example win32 console program',0Dh,0Ah,0Dh,0Ah,0 error1_message db 0Dh,0Ah,'Could not get current user name',0Dh,0Ah,0 error2_message db 0Dh,0Ah,'Could not enumerate',0Dh,0Ah,0 good_exit_msg db 0Dh,0Ah,0Dh,0Ah,'Normal termination',0Dh,0Ah,0 enum_msg1 db 0Dh,0Ah,'Local ',0 enum_msg2 db ' remote - ',0 .data user_name db 'List of connected resources for user ' user_buff db 64 dup (?) ; буфер для WNetGetUser user_buff_l dd $-user_buff ; размер буфера для WNetGetUser enum_buf_l dd 1056 ; длина enum_buf в байтах enum_entries dd 1 ; число ресурсов, которые в нем помещаются .data? enum_buf NTRESOURCE <?,?,?,?,?,?,?,?> ; буфер для WNetEnumResource dd 256 dup (?) ; 1024 байт для строк message_l dd ? ; переменная для WriteConsole enum_handle dd ? ; идентификатор для WNetEnumResource .code _start: ; получим от системы идентификатор буфера вывода stdout push STD_OUTPUT_HANDLE call GetStdHandle ; возвращает идентификатор STDOUT в eax mov ebx,eax ; а мы будем хранить его в EBX
Меню — это один из краеугольных камней идеологии Windows. Похожие друг на друга меню позволяют пользоваться совершенно незнакомыми программами, не читая инструкций, и знакомиться с их возможностями, просто посмотрев содержание различных пунктов меню. Давайте добавим меню и в нашу программу window.asm.
Первое, что мы должны будем сделать, — это само меню. Меню, так же как и иконки, диалоги и другая информация (вплоть до версии программы), записывают в файлы ресурсов. Файл ресурсов имеет расширение *.RC для текстового файла или *.RES для бинарного файла, скомпилированного специальным компилятором ресурсов (RC, BRCC32 или WRC). И те, и другие файлы ресурсов можно редактировать специальными программами, входящими в дистрибутивы C/C++ или других средств разработки для Windows, но мы не будем делать слишком сложное меню и напишем RC-файл вручную, например так:
// winmenu.rc // файл ресурсов для программы winmenu.asm // #define ZZZ_TEST 0 #define ZZZ_OPEN 1 #define ZZZ_SAVE 2 #define ZZZ_EXIT 3
ZZZ_Menu MENU { POPUP "&File" { MENUITEM "&Open",ZZZ_OPEN MENUITEM "&Save", ZZZ_SAVE MENUITEM SEPARATOR MENUITEM "E&xit",ZZZ_EXIT } MENUITEM "&Edit",ZZZ_TEST }
Чтобы добавить этот файл в программу, его надо скомпилировать и указать имя скомпилированного *.RES-файла для компоновщика:
MASM:
ml /c /coff /Cp winmenu.asm rc /r winmenu.rc link winmenu.obj winmenu.res /subsystem:windows
TASM:
tasm /m /ml /D_TASM_ winmenu.asm brcc32 winmenu.rc tlink32 /Tpe /aa /c /x winmenu.obj,,,,,winmenu.res
WASM:
wasm winmenu.rc wrc /r /bt=nt winmenu.rc wlink file winmenu.obj res winmenu.res form windows nt
А теперь сам текст программы. Чтобы показать, как мало надо внести изменений в программу window.asm, комментарии для всех строк, перенесенных оттуда без изменений заменены, на символ «*».
; winmenu.asm ; Графическое win32-приложение, демонстрирующее работу с меню ; звездочками отмечены строки, скопированные из файла window.asm ;
Теперь, когда мы знаем, как просто выводится окно с предопределенным классом, возьмемся за вывод собственного окна — процедуры, на которой будут базироваться все последующие примеры, познакомимся с понятием сообщения. В DOS основным средством передачи управления программам в различных ситуациях служат прерьшания. В Windows прерывания используются системой для своих нужд, а для приложений существует аналогичный механизм — механизм событий. Так, нажатие клавиши на клавиатуре, если эта клавиша не используется Windows, генерирует сообщение WM_KEYDOWN или WM_KEYUP, которое можно перехватить, добавив в цепь обработчиков события собственное при помощи SetWindowHookEx(). События затем преобразуются в сообщения, которые рассылаются функциям — обработчикам сообщений и которые можно прочитать из основной программы при помощи вызовов GetMessage() и PeekMessage().
Нам пока потребуется только обработка сообщения закрытия окна (WM_DESTROY и WM_QUIT), по которому программа будет завершаться.
; window.asm ; Графическое win32-приложение, демонстрирующее базовый вывод окна ; include def32.inc include kernel32.inc include user32.inc .386 .model flat .data class_name db "window class 1",0 window_name db "win32 assembly example",0 ; структура, описывающая класс окна. wc WNDCLASSEX <4*12,CS_HREDRAW or CS_VREDRAW,offset win_proc,0,0,?,?,?, COLOR_WINDOW+1,0,offset class_name,0> ; здесь находятся следующие поля ; wc.cbSize = 4*12 - размер этой структуры ; wc.style - стиль окна (перерисовывать при изменении размера) ; wc.lpfnWndProc - обработчик событий окна (win_proc) ; wc.cbClsExtra - число дополнительных байтов после структуры (0) ; wc.cbWndExtra - число дополнительных байтов после окна (0) ; wc.hInstance - идентификатор нашего процесса (?) ; wc.hIcon - идентификатор иконки (?) ; wc.hCursor - идентификатор курсора (?) ; wc.hbrBackground - идентификатор кисти или цвет фона+1 ; (COLOR_WINDOW+1) ; wc.lpszMenuName - ресурс с основным меню (в этом примере - 0) ; wc.lpszClassName - имя класса (строка class_name) ; wc.hIconSm - идентификатор маленькой иконки (только в windows 95, ; для NT должен быть 0) .data? msg_ MSG <?,?,?,?,?,?> ; а это - структура, в которой возвращается ; сообщение после GetMessage .code _start: xor ebx,ebx ; в EBX будет 0 для команд push 0 ; (короче в 2 раза) ; определим идентификатор нашей программы push ebx call GetModuleHandle mov esi,eax ; и сохраним его в ESI ; заполним и зарегестрируем класс mov dword ptr wc.hInstance,eax ; идентификатор предка ; выберем иконку push IDI_APPLICATION ; стандартная иконка приложения push ebx ; идентификатор модуля с иконкой call LoadIcon mov wc.hIcon,eax ; идентификатор иконки для нашего класса ; выберем форму курсора push IDC_ARROW ; стандартная стрелка push ebx ; идентификатор модуля с курсором call LoadCursor mov wc.hCursor,eax ; идентификатор курсора для нашего класса push offset wc call RegisterClassEx ; зарегистрируем класс ; создадим окно mov ecx,CW_USEDEFAULT ; push ecx короче push N в пять раз push ebx ; адрес структуры CREATESTRUCT (здесь NULL) push esi ; идентификатор процесса, который будет получать ; сообщения от окна (то есть, наш) push ebx ; идентификатор меню или окна-потомка push ebx ; идентификатор окна-предка push ecx ; высота (CW_USEDEFAULT - по умолчанию) push ecx ; ширина (по умолчанию) push ecx ; y-координата (по умолчанию) push ecx ; x-координата (по умолчанию) push WS_OVERLAPPEDWINDOW ; стиль окна push offset window_name ; заголовок окна push offset class_name ; любой зарегистрированный класс push ebx ; дополнительный стиль call CreateWindowEx ; создать окно (eax - идентификатор окна) push eax ; идентификатор для UpdateWindow push SW_SHOWNORMAL ; тип показа для для ShowWindow push eax ; идентификатор для ShowWindow ; больше идентификатор окна нам не потребуется call ShowWindow ; показать окно call UpdateWindow ; и послать ему сообщение WM_PAINT
Для того чтобы вывести на экран любое окно, программа обычно должна сначала описать его внешний вид и все свойства, то есть то, что называется классом окна. О том, как это сделать, — немного позже, а для начала выведем одно из окон с предопределенным классом — окно типа MessageBox. MessageBox — это маленькое окно с указанным текстовым сообщением и одной или несколькими кнопками. В нашем примере сообщением будет традиционное «Hello world!», и кнопка будет всего одна — ОК.
; winhello.asm ; Графическое win32-приложениe ; Выводит окно типа mesagebox с текстом "Hello world!" ; include def32.inc include kernel32.inc include user32.inc
.386 .model flat .const ; заголовок окна hello_title db "First win32 GUI program",0 ; сообщение hello_message db "Hello world!",0 .code _start: push MB_ICONINFORMATION ; стиль окна push offset hello_title ; адрес строки с заголовком push offset hello_message ; адрес строки с сообщением push 0 ; идентификатор предка call MessageBox
push 0 ; код выхода call ExitProcess ; завершение программы end _start
Естественно, нам потребуется добавить к файлу def32.inc строку:
; из winuser.h MB_ICONINFORMATION equ 40h
и создать новый файл, user32.inc, в который будут входить определения функций из user32.dll — библиотеки, куда входят все основные функции, отвечающие за оконный интерфейс:
; user32.inc ; включаемый файл с определениями функций из user32.dll ; ifdef _TASM_ includelib import32.lib ; имена используемых функций extrn MessageBoxA:near ; присваивания для облегчения читаемости кода MessageBox equ MessageBoxA else includelib user32.lib ; истинные имена используемых функций extrn __imp__MessageBoxA@16:dword ; присваивания для облегчения читаемости кода MessageBox equ __imp__MessageBoxA@16
Теперь можно скомпилировать эту программу аналогично тому, как мы компилировали winurl.asm, и запустить — на экране появится маленькое окно с нашим сообщением и кнопкой ОК, которое пропадет после того, как будет нажата эта кнопка. Если скомпилировать winhello.asm как консольное приложение, ничего не изменится, текстовое окно с именем программы будет открыто до тех пор, пока окно с нашим сообщением не будет закрыто.
В качестве нашего первого примера посмотрим, насколько проще написать под Windows программу, которая загружает другую программу. В DOS (см. главу 4.10) нам приходилось изменять распределение памяти, заполнять специальный блок данных EPBВ и только затем вызывать DOS. Здесь же не только вся процедура сокращается до одного вызова функции, а еще оказывается, что можно точно так же загружать не только программы, но и документы, графические и текстовые файлы и даже почтовые и WWW-адреса — все, для чего в реестре Windows записано действие, выполняющееся при попытке открытия.
; winurl.asm ; Пример програмы для win32. ; Запускает установленный по умолчанию броузер на адрес, указанный в строке URL ; аналогично можно запускать любую программу, документ, и любой другой файл, ; для которого определена операция open ; include shell32.inc include kernel32.inc
.386 .model flat .const URL db 'http://www.lionking.org/~cubbi/',0 .code _start: ; метка точки входа должна начинаться с подчеркивания xor ebx,ebx push ebx ; для исполнимых файлов - способ показа push ebx ; рабочий каталог push ebx ; командная строка push offset URL ; имя файла с путем push ebx ; операция open или print (если NULL - open) push ebx ; идентификатор окна, которое получит сообщения call ShellExecute ; ShellExecute(NULL,NULL,url,NULL,NULL,NULL) push ebx ; код выхода call ExitProcess ; ExitProcess(0) end _start
Итак, в этой программе выполняется вызов двух системных функций Win32 — ShellExecute() (открыть файл) и ExitProcess() (завершить процесс). Чтобы вызвать системную функцию Windows, программа должна поместить в стек все параметры от последнего к первому и передать управление дальней командой CALL. Все эти функции сами освобождают стек (завершаясь командой RET N) и возвращают результат работы в регистре ЕАХ. Такая договоренность о передаче параметров называется STDCALL. С одной стороны, это позволяет вызывать функции с нефиксированным числом параметров, а с другой — вызывающая сторона не должна заботиться об освобождении стека. Кроме того, функции Windows сохраняют значение регистров ЕВР, ESI, EDI и EBX, этим мы пользовались в нащем примере — хранили 0 в регистре EBX и применили 1-байтную команду PUSH EBX вместо 2-байтной PUSH 0.
Теперь, когда мы знаем, как строятся программы с меню и диалогами, напишем одно настоящее полноценное приложение, включающее в себя все то, что требуется от программы, — меню, диалоги, комбинации клавиш для быстрого доступа к элементам меню и т.д. В качестве примера создадим простой текстовый редактор, аналогичный Notepad. В этом примере мы увидим, как получить параметры из командной строки, прочитать и записать файл, выделить и освободить память.
// winpad95.rc // Файл ресурсов для программы winpad95.asm // идентификаторы сообщений от пунктов меню #define IDM_NEW 0x1001 #define IDM_OPEN 0x101L #define IDM_SAVE 0x102L #define IDM_SAVEAS 0x103L #define IDM_EXIT 0x104L #define IDM_ABOUT 0x105L #define IDM_UNDO 0x106L #define IDM_CUT 0x107L #define IDM_COPY 0x108L #define IDM_PASTE 0x109L #define IDM_CLEAR 0x10AL #define IDM_SETSEL 0x10BL
// идентификаторы основных ресурсов #define IDJ1ENU 0x700L #define ID_ACCEL 0x701L #define ID_ABOUT 0x702L
// если есть иконка - можно раскомментировать эти две строки // #define ID_ICON 0x703L // ID_ICON ICON "winpad95.ico"
// основное меню ID_MENU MENU DISCARDABLE { POPUP "&File" { MENUITEM "&New\tCtrl+N", IDM_NEW MENUITEM "&Open...\tCtrl+O", IDM_OPEN MENUITEM "&Save\tCtrl+S", IDM_SAVE MENUITEM "Save &As...\tCtrl+Shift+S", IDM_SAVEAS MENUITEM SEPARATOR MENUITEM "E&xit\tCtrl+Q", IDM_EXIT } POPUP "&Edit" { MENUITEM "&Undo\tCtrl-Z", IDM_UNDO MENUITEM SEPARATOR MENUITEM "Cu&t\tCtrl-X", IDM_CUT MENUITEM "&Copy\tCtrl-C", IDM_COPY MENUITEM "&Paste\tCtrl-V", IDM_PASTE MENUITEM "&Delete\tDel", IDM_CLEAR MENUITEM SEPARATOR MENUITEM "Select &All\tCtrl-A", IDM_SETSEL } POPUP "&Help" { MENUITEM "About", IDM_ABOUT } }
// комбинации клавиш ID_ACCEL ACCELERATORS DISCARDABLE { "N", IDM_NEW, CONTROL, VIRTKEY "O", IDM_OPEN, CONTROL, VIRTKEY "S", IDM_SAVE, CONTROL, VIRTKEY "S", IDM_SAVEAS, CONTROL, SHIFT, VIRTKEY "Q", IDM_EXIT, CONTROL, VIRTKEY "Z", IDM_UNDO, CONTROL, VIRTKEY "A", IDM_SETSEL, CONTROL, VIRTKEY }
В предыдущем разделе, занимаясь программированием для Windows, мы уже обращались к процедурам, написанным на языке высокого уровня из программ на ассемблере, и создавали процедуры на ассемблере, к которым можно обращаться из языков высокого уровня. Для этого нужно было соблюдать определенные договоренности о передаче параметров — параметры помещались в стек справа налево, результат возвращался в ЕАХ, стек освобождался от переданных параметров самой процедурой. Эта договоренность, известная как STDCALL, конечно, не единственная, и разные языки высокого уровня используют разнообразные способы передачи параметров.
Компиляторы Microsoft С (а также многие компиляторы в UNIX, как мы увидим далее) изменяют названия процедур, чтобы отразить используемый способ передачи параметров. Так, к названиям всех процедур, использующих С-конвенцию, приписывается символ подчеркивания. То есть, если в С-программе записано
some_proc();
то реально компилятор пишет
call _some_proc
и это означает, что, если эта процедура написана на ассемблере, она должна называться именно _some_proc (или использовать сложную форму записи директивы proc).
Названия процедур, использующих STDCALL, как можно было видеть из примера DLL-программы в разделе 7.4, искажаются еще более сложным образом: спереди к называнию процедуры добавляется символ подчеркивания, а сзади — символ @ и размер занимаемой параметрами области стека в байтах, (то есть в точности число, стоящее после команды ret в конце процедуры).
some_proc(a:word);
превращается в
push a call _some_proc@4
Самый очевидный способ выражения вызова процедуры или функции языка высокого уровня, после того как решено, что параметры передаются в стеке и возвращаются в регистре АХ/ЕАХ, — это способ, принятый в языке PASCAL (а также в BASIC, FORTRAN, ADA, OBERON, MODULA2), — просто поместить параметры в стек в естественном порядке. В этом случае запись
some_proc(a,b,c,d,e)
превращается в
push a push b push с push d push e call some_proc
Это значит, что процедура some_proc, во-первых, должна очистить стек по окончании работы (например, завершившись командой ret 10) и, во-вторых, параметры, переданные ей, находятся в стеке в обратном порядке:
some_proc proc push bp mov bp,sp ; создать стековый кадр a equ [bp+12] ; определения для простого ; доступа к параметрам b equ [bp+10] c equ [bp+8] d equ [bp+6] e equ [bp+4]
; текст процедуры, использующей параметры а, Ь, с, d, e
ret 10 some_proc endp
Этот код в точности соответствует усложненной форме директивы proc, которую поддерживают все современные ассемблеры:
some_proc proc PASCAL,а:word,b:word,с:word,d:word,e:word
; текст процедуры, использующей параметры а, Ь, с, d, e. ; Так как ВР используется в качестве указателя стекового кадра, ; его использовать нельзя!
ret ; эта команда RET будет заменена на RET 10 some_proc endp
Главный недостаток этого подхода — сложность создания функции с изменяемым числом параметров, аналогичных функции языка С printf. Чтобы определить число параметров, переданных printf, процедура должна сначала прочитать первый параметр, но она не знает его расположения в стеке. Эту проблему решает подход, используемый в С, где параметры передаются в обратном порядке.
Этот способ передачи параметров используется в первую очередь в языках С и C++, а также в PROLOG и других. Параметры помещаются в стек в обратном порядке, и, в противоположность PASCAL-конвенции, удаление параметров из стека выполняет вызывающая процедура. Запись
some_proc(a,b,c,d,e)
превращается в
push e push d push с push b push a call some_proc add sp,10 ; освободить стек
Вызванная таким образом процедура может инициализироваться так: some_proc proc push bp mov bp,sp ; создать стековый кадр a equ [bp+4] ; определения для простого доступа к параметрам b equ [bp+6] с equ [bp+8] d equ [bp+10] e equ [bp+12]
; текст процедуры, использующей параметры a, b, с, d, e
pop bp ret some_proc endp
Ассемблеры поддерживают и такой формат вызова при помощи усложненной формы директивы proc с указанием языка С:
some_proc proc С,а:word,b:word,с:word,d:word,e:word
; текст процедуры, использующей параметры a, b, с, d, e. ; Так как BP применяется как указатель стекового кадра, ; его использовать нельзя!
ret some_proc endp
Мы не пользовались до сих пор этими формами записи процедур в ассемблере потому, что они скрывают от нас тот факт, что регистр ВР используется для хранения параметров и его ни в коем случае нельзя изменять, и, в случае PASCAL, что команда ret на самом деле — команда ret N.
Преимущество по сравнению с PASCAL-конвенцией заключается в том, что освобождение стека от параметров в С возлагается на вызывающую процедуру, что позволяет лучше оптимизировать код программы. Например, если мы должны вызвать несколько функций, принимающих одни и те же параметры подряд, можно не заполнять стек каждый раз заново:
push param2 push param1 call proc1 call proc2 add sp,4
эквивалентно
proc1(param1,param2); proc2(param1,param2);
и это — одна из причин, почему компиляторы с языка С создают более компактный и быстрый код по сравнению с компиляторами с других языков.
Большинство языков высокого уровня передают параметры вызываемой процедуре в стеке и ожидают возвращения параметров в регистре АХ (ЕАХ) (иногда используется DX:AX (EDX:EAX), если результат не умещается в одном регистре, и ST(0), если результат число с плавающей запятой).
В главе 7 мы встречались с договоренностью о передаче параметров STDCALL, отличавшейся и от С, и от PASCAL-конвенций, которая применяется для всех системных функций Win32 API. Здесь параметры помещаются в стек в обратном порядке, как в С, но процедуры должны очищать стек сами, как в PASCAL.
Еще одно интересное отклонение от С-конвенции можно наблюдать в Watcom С. Этот компилятор активно использует регистры для ускорения работы программы, и параметры в функции также передаются по возможности через регистры. Например, при вызове функции с шестью параметрами
some_proc(a,b,с,d,e,f);
первые четыре параметра передаются соответственно в (Е)АХ, (E)DX, (Е)ВХ, (Е)СХ, а только начиная с пятого, параметры помещают в стек в обычном обратном порядке:
e equ [bp+4] f equ [bp+6]
Если требуется выполнить совсем небольшую операцию на ассемблере, например вызвать какое-то прерывание или преобразовать сложную битовую структуру, часто нерационально создавать отдельный файл ради нескольких строк на ассемблере. Чтобы этого избежать, многие языки высокого уровня поддерживают возможность вставки ассемблерного кода непосредственно в программу. Например, напишем процедуру, возвращающую слово, находящееся по адресу 0040h:006Ch, в BIOS — счетчик сигналов системного таймера, который удобно использовать для инициализации генераторов случайных чисел.
function get_seed:longint var seed:longint begin asm push es mov ax,0040h mov es,ax mov ax,es:[006Ch] mov seed,ax pop es end; get_seed:=seed; end;
int get_seed() int seed; { _asm { push es mov ax,0040h mov es,ax mov ax,es:[006Ch] mov seed,ax pop es }; return(seed); };
В этих ситуациях ассемблерная программа может свободно пользоваться переменными из языка высокого уровня, так как они автоматически преобразуются в соответствующие выражения типа word ptr [bp+4].
AGI— это ситуация, при которой регистр, используемый командой для генерации адреса как базовый или индексный, был приемником предыдущей команды. В этой ситуации процессор тратит один дополнительный такт. Последовательность команд
add edx,4 mov esi,[edx]
выполняется с AGI на любом процессоре.
Последовательность команд
add esi,4 ; U-конвейер - 1 такт (на Pentium) pop ebx ; V-конвейер - 1 такт inc ebx ; V-конвейер - 1 такт mov edi,[esi] ; в U-конвейер - *AGI*, затем 1 такт
выполняется с AGI на Pentium за три такта процессора.
Кроме того, AGI может происходить неявно, например при изменении регистра ESP и обращении к стеку:
sub esp,24 push ebx ; *AGI*
или
mov esp,ebp pop ebp ; *AGI*
но изменение ESP, производимое командами PUSH и POP, не приводит к AGI, если следующая команда тоже обращается к стеку.
Процессоры Pentium Pro и Pentium II не подвержены AGI.
Процессор Pentium включает в себя два 8-килобайтных блока кэш-памяти, один для кода и один для данных с длиной линейки 32 байта. Кэш данных состоит из восьми банков, причем он доступен из обоих конвейеров одновременно, только если обращения происходят к разным банкам. Если данные или код не находятся в кэше, минимальная дополнительная задержка составляет 4 такта.
Если происходит два кэш-промаха одновременно при записи в память из обоих конвейеров и обе записи попадают в одно учетверенное слово (например, при записи двух слов в последовательные адреса), процессор затрачивает столько же времени, сколько и на один кэш-промах.
Процессоры Pentium Pro включают в себя 8-килобайтный кэш L1 для данных и 8-килобайтный кэш L1 для кода, а процессоры Pentium II соответственно по 16 Кб, но не все кэш-промахи приводят к чтению из памяти — существует кэш второго уровня — L2, который маскирует промахи L1. Минимальная задержка при промахе в оба кэша составляет 10 – 14 тактов в зависимости от состояния цикла обновления памяти.
Микрооперации чтения и записи могут произойти одновременно, если они обращаются к разным банкам кэша L1.
LEA можно использовать (кроме прямого назначения— вычисления адреса сложно адресуемой переменной) для следующих двух ситуаций:
быстрое умножение
lea еах,[еах*2] ; ЕАХ = ЕАХ * 2 (shl eax,1 ; лучше) lea еах,[еах+еах*2] ; ЕАХ = ЕАХ * 3 lea еах,[еах*4] ; ЕАХ = ЕАХ * 4 (shl eax,2 ; лучше) lea еах,[еах+еах*4] ; ЕАХ = ЕАХ * 5 lea еах,[еах+еах*8] ; ЕАХ = ЕАХ * 9
трехоперандное сложение
lea ecx,[eax+ebx] ; ЕСХ = ЕАХ * ЕВХ
Единственный недостаток LEA — увеличивается вероятность AGI с предыдущей командой (см. ниже).
Конвейер исполнения команд FPU состоит из трех участков, на каждом из которых команда тратит по крайней мере один такт. Многие команды, однако, построены таким образом, что позволяют другим командам выполняться на ранних участках конвейера, пока эти команды выполняются на более поздних. Кроме того, параллельно с длинными командами FPU, например FDIV, могут выполняться команды в целочисленных конвейерах.
Команда FXCH может выполняться одновременно почти с любой командой FPU, что позволяет использовать ST(n) как неупорядоченный набор регистров практически без потерь в производительности.
Команды ММХ, так же как команды FPU, используют дополнительный конвейер, содержащий два блока целочисленной арифметики (и логики), один блок умножения, блок сдвигов, блок доступа к памяти и блок доступа к целочисленным регистрам. Все блоки, кроме умножителя, выполняют свои стадии команды за один такт, умножение требует трех тактов, но имеет собственный буфер, позволяющий принимать по одной команде каждый такт. Так как блоков арифметики два, соответствующие операции могут выполняться одновременно в U- или V-конвейере. Команды, использующие блок сдвигов или умножитель, способны осуществляться в любом конвейере, но не одновременно с другими командами, использующими тот же самый блок. А команды, обращающиеся к памяти или обычным регистрам, в состоянии выполняться только в U-конвейере и только одновременно с ММХ-командами.
Если перед командой, копирующей ММХ-регистр в память или в обычный регистр, происходила запись в этот ММХ-регистр, затрачивается один лишний такт.
Если команда обращается к 32-битному регистру, например ЕАХ, сразу после команды, выполнявшей запись в соответствующий частичный регистр (АХ, AL, АН), происходит пауза минимум в 7 тактов на Pentium Pro и Pentium II и в 1 такт — на 80486, но не на Pentium:
mov ax,8 add ecx,eax ; пауза
На Pentium Pro и Pentium II эта пауза не появляется, если сразу перед командой записи в АХ была команда XOR ЕАХ,ЕАХ или SUB ЕАХ,ЕАХ.
Так как процессоры Intel используют весьма сложный набор команд, большинство операций можно выполнить на низком уровне очень многими способами. При этом иногда оказывается, что наиболее очевидный способ— не самый быстрый или короткий. Часто простыми перестановками команд, зная механизм выполнения команд на современных процессорах, реально заставить ту же процедуру выполняться на 50 – 200% быстрее. Разумеется, переходить к этому уровню оптимизации можно только после того, как текст программы окончательно написан и максимально оптимизирован на среднем уровне.
Перечислим основные рекомендации, которым нужно следовать при оптимальном программировании для процессоров Intel Pentium, Pentium MMX, Pentium Pro и Pentium II.
Перед тем как команды распределяются по конвейерам, они загружаются из памяти в одну из четырех 32-байтных очередей предвыборки. Если загружается команда перехода и блок предсказания переходов утверждает, что переход произойдет, начинает работать следующая очередь предвыборки. Это сделано для того, чтобы, если предсказание было неверным, первая очередь продолжила бы выборку команд после невыполненной команды перехода.
Если условный переход не был предугадан, затрачивается 3 такта, если команда перехода находилась в U-конвейере, и 4 такта, если в V.
Если безусловный переход или вызов процедуры не был предугадан, затрачивается 3 такта в любом случае.
Очередь предвыборки считывает прямую линию кода 16-байтными выровненными блоками. Это значит, что следует организовывать условные переходы так, чтобы наиболее частым исходом было бы отсутствие перехода, и что полезно выравнивать команды на границы слова. Кроме того, желательно располагать редко используемый код в конце процедуры, чтобы он не считывался в очередь предвыборки впустую.
Наиболее популярным применением ассемблера обычно считается именно оптимизация программ, то есть уменьшение времени выполнения программ по сравнению с языками высокого уровня. Но если просто переписать текст, например с языка С на ассемблер, переводя каждую команду наиболее очевидным способом, часто оказывается, что С-процедура выполнялась быстрее. Вообще говоря, ассемблер, как и любой другой язык, сам по себе не является панацеей от неэффективного программирования — чтобы действительно оптимизировать программу, требуется не только знание команд процессора, но и знание алгоритмов, навык оптимальных способов их реализации и подробная информация об архитектуре процессора.
Проблему оптимизации принято делить на три основных уровня:
Выбор наиболее оптимального алгоритма — «высокоуровневая оптимизация».
Наиболее оптимальная реализация алгоритма — «оптимизация среднего уровня».
Подсчет тактов, тратящихся на выполнение каждой команды, и оптимизация их порядка для конкретного процессора — «низкоуровневая оптимизация».
Реализация алгоритма на данном конкретном языке программирования — самая ответственная стадия оптимизации. Именно здесь можно получить выигрыш в скорости в десятки раз или сделать программу в десятки раз медленнее, при серьезных ошибках в реализации. Многие элементы, из которых складывается оптимизация, уже упоминались — хранение переменных, с которыми выполняется активная работа, в регистрах, использование таблиц переходов вместо длинных последовательностей проверок и условных переходов и т.п. Тем не менее даже плохо реализованные операции не вносят заметных замедлений в программу, если они не повторяются в цикле. Практически можно говорить, что все проблемы оптимизации на среднем уровне так или иначе связаны с циклами, и именно поэтому мы рассмотрим основные правила, которые стоит иметь в виду при реализации любого алгоритма, содержащего циклы.
Используйте регистр ЕАХ всюду, где возможно. Команды с непосредственным операндом, с операндом — абсолютным адресом переменной и команды XCHG с регистрами занимают на один байт меньше, если другой операнд — регистр ЕАХ.
Используйте регистр DS всюду, где возможно. Префиксы переопределения сегмента увеличивают размер программы на 1 байт и время на 1 такт.
Если к переменной в памяти, адресуемой со смещением, выполняется несколько обращений — загрузите ее в регистр.
Не используйте сложные команды — ENTER, LEAVE, LOOP, строковые команды, если аналогичное действие можно выполнить небольшой последовательностью простых команд.
Не используйте команду MOVZX для чтения байта — это требует 4 тактов для выполнения. Заменой может служить такая пара команд:
xor еах,еах mov al,source
Используйте TEST для сравнения с нулем или для других проверок равенства:
test eax,eax jz if_zero ; переход, если ЕАХ = 0 test eax,source jz if_zero ; переход, если ЕАХ = source
Исцользуйте команду XOR, чтобы обнулять регистр (конечно, если текущее состояние флагов больше не потребуется), эта команда официально поддерживается Intel как команда обнуления регистра:
xor еах,еах ; ЕАХ = 0
Не используйте умножение или деление на константу — его можно заменить другими командами, например:
; ЕАХ = ЕАХ * 10 shl eax,1 ; умножение на 2 lea eax,[eax+eax*4] ; умножение на 5 ; ЕАХ = ЕАХ * 7 mov ebx,eax shl еах,3 ; умножение на 8 sub eax,ebx ; и вычитание сохраненного ЕАХ ; АХ = АХ/10 mov dx,6554 ; DX = 65 536/10 mul dx ; DX = AX/10 (умножение ; выполняется быстрее деления) ; ЕАХ = ЕАХ mod 64 (остаток от деления на степень двойки) and eax,3Fh
Используйте короткую форму команды jmp, где возможно (jmp short метка).
Как можно реже загружайте сегментные регистры.
Как можно меньше переключайте задачи — это очень медленная процедура. Часто, если надо сохранять небольшое состояние процесса, например для реализации нитей, переключение быстрее организовать программно.
Процессоры Pentium Pro и Pentium II включают в себя целый набор средств для ускорения выполнения программ. В них применяется выполнение команд не по порядку, предсказание команд, аппаратное переименование регистров и предсказание переходов.
Циклы типа WHILE или FOR, которые так часто применяются в языках высокого уровня, оказываются менее эффективными по сравнению с циклами типа UNTIL из-за того, что в них требуется лишняя команда перехода:
; цикл типа WHILE mov si,counter ; число повторов mov dx,start_i ; начальное значение loop_start: cmp dx,si ; пока dx < si - выполнять jbn exit_loop
; [тело цикла]
inc dx jmp loop_start
; почти такой же цикл типа UNTIL mov si,counter mov dx,start_i loop_start: ; выполнять
; [тело цикла]
inc dx cmp dx,si ; пока dx < si jb loop_start
Естественно, цикл типа UNTIL, в отличие от цикла типа WHILE, выполнится по крайней мере один раз, так что, если это нежелательно, придется добавить одну проверку перед телом цикла, но в любом случае даже небольшое уменьшение тела цикла всегда оказывается необходимой операцией.
Процессор поддерживает 512-байтный буфер выполненных переходов и их целей. Система предсказания может обнаруживать последовательность до четырех повторяющихся переходов, то есть четыре вложенных цикла будут иметь процент предсказания близкий к 100%. Кроме того, дополнительный буфер адресов возврата позволяет правильно предсказывать циклы, из которых происходят вызовы подпрограмм.
На неправильно предсказанный переход затрачивается как минимум девять тактов (в среднем — от 10 до 15). На правильно предсказанный невыполняющийся переход не затрачивается никаких дополнительных тактов вообще. На правильно предсказанный выполняющийся переход затрачивается один дополнительный такт. Именно поэтому минимальное время выполнения цикла на Pentium Pro или Pentium II — два такта, и, если цикл может выполняться быстрее, он должен быть развернут.
Если команда перехода не находится в буфере, система предсказания делает следующие предположения:
безусловный переход предсказывается как происходящий, и на его выполнение затрачивается 5 – 6 тактов;
условный переход назад предсказывается как происходящий, и на его выполнение также затрачивается 5 – 6 тактов;
условный переход вперед предсказывается как непроисходящий. При этом команды, следующие за ним, предварительно загружаются, и начинают исполняться, поэтому не размещайте данные сразу после команды перехода.
Префиксы LOCK, переопределения сегмента и изменения адреса операнда увеличивают время выполнения команды на 1 такт.
Для небольших циклов время выполнения проверки условия и перехода на начало цикла может оказаться значительным по сравнению с временем выполнения самого тела цикла. Более того, на Pentium Pro/Pentium II цикл не в состоянии выполняться меньше, чем за два такта процессора, хотя его тело может выполняться даже меньше, чем за такт. С этим легко справиться, вообще не создавая цикл, а просто повторив его тело нужное число раз (разумеется, только в случае, если нам заранее известно это число!). Для очень коротких циклов можно, например, удваивать или утраивать тело цикла, если, конечно, число повторений кратно двум или трем. Кроме того, бывает удобно часть работы сделать в цикле, а часть развернуть, например продолжая цепочку циклов из предыдущего примера:
; цикл от 10 до -1 mov dx,10 loop_start:
; [тело цикла]
dec dx ; уменьшить DX, jns loop_start ; если DX не отрицательный - ; продолжить цикл ; [тело цикла]
Совершенно естественно, что эти простые методики не перечисляют все возможности оптимизации среднего уровня, более того, они не описывают и десятой доли всех ее возможностей. Умение оптимизировать программы нельзя сформулировать в виде набора простых алгоритмов — слишком много существует различных ситуаций, в которых всякий алгоритм оказывается неоптимальным. При решении любой задачи оптимизации приходится пробовать десятки различных небольших изменений, далеко не все из которых оказываются полезными. Именно потому, что оптимизация всегда занимает очень много времени, рекомендуется приступать к ней только после того, как программа окончательно написана. Как и во многих других ситуациях, с оптимизацией нельзя торопиться, но и нельзя совсем забывать о ней на любой стадии создания программы.
Самым очевидным и самым важным правилом при создании цикла на любом языке программирования является вынос всех переменных, которые не изменяются на протяжении цикла, за его пределы. В случае ассемблера имеет смысл также по возможности разместить все переменные, которые будут использоваться внутри цикла, в регистры, а старые значения нужных после цикла регистров сохранить в стеке.
Циклы, в которых значение счетчика растет от двойки, единицы или нуля до некоторой константы, можно реализовать вообще без операции сравнения, выполняя цикл в обратном направлении (и мы пользовались этим приемом неоднократно в наших примерах). Дело в том, что команда DECcounter устанавливает флаги точно так же, как и команда СМР counter,1, то есть следующая команда условного перехода будет обрабатывать результат сравнения счетчика с единицей:
; цикл от 10 до 2 mov dx,10 loop_start:
; [тело цикла]
dec dx ; уменьшить DX, ja loop_start ; если DX больше 1 - продолжить цикл
; цикл от 10 до 1 mov dx,10 loop_start:
; [тело цикла]
dec dx ; уменьшить DX, jae loop_start ; если DX больше или равно 1 - продолжить цикл
; цикл от 10 до 0 mov dx,10 loop_start:
; [тело цикла]
dec dx ; уменьшить DX, jns loop_start ; если DX не отрицательный - продолжить цикл
Конечно, не все циклы можно заставить выполняться в обратном направлении сразу. Например, иногда приходится изменять формат хранения массива данных также на обратный, иногда приходится вносить другие изменения, но в целом, если это возможно, всегда следует стремиться к циклам, выполняющимся задом наперед. Кроме того, если цикл построен в этой манере, выполняется до значения счетчика, равного нулю, и регистр СХ можно освободить для выполнения роли счетчика, есть вариант воспользоваться командой LOOP, хотя в некоторых случаях в низкоуровневой оптимизации команды DEC/JNZ оказываются более эффективными.
Процессор Pentium содержит два конвейера исполнения целочисленных команд (U и V) и один конвейер для команд FPU. Он может выполнять две целочисленные команды одновременно и поддерживает механизм предсказания переходов, значительно сокращающий частоту сброса очереди предвыборки из-за передачи управления по другому адресу.
Процессор перед выполнением команды анализирует сразу две следующие команды, находящиеся в очереди и, если возможно, выполняет одну из них в U-конвейере, а другую в V. Если это невозможно, первая команда загружается в U-конвейер, а V-конвейер пустует.
V-конвейер имеет определенные ограничения на виды команд, которые могут в нем исполняться. Приложение 2 содержит для каждой команды информацию о том, может ли она выполняться одновременно с другими командами и в каком конвейере. Кроме того, две команды не будут запущены одновременно, если:
команды подвержены одной из следующих регистровых зависимостей:
Первая команда пишет в регистр, а вторая читает из него.
Обе команды пишут в один и тот же регистр (кроме записи в EFLAGS).
Исключения из этих правил— пары PUSH/PUSH, PUSH/POP и PUSH/CALL, выполняющие запись в регистр ESP;
одна из команд не находится в кэше команд (кроме случая, если первая команда — однобайтная);
одна из команд длиннее семи байт (для Pentium);
одна команда длиннее восьми байт, а другая — семи (для Pentium ММХ).
Помните, что простыми перестановками команд можно выиграть до 200% скорости в критических ситуациях.
Каждый такт процессора до трех команд может быть прочитан и декодирован на микрооперации из очереди предвыборки. В этот момент работают три декодера, первый из которых может декодировать команды, содержащие до четырех микроопераций, а другие два— только команды из одной микрооперации. Если в ассемблерной программе команды упорядочены в соответствии с этим правилом (4–1–1), то все время на каждый такт будет происходить декодирование трех команд, например, если в последовательности команд
add eax,[ebx] ; 2m - в декодер 0 на первом такте mov есх,[еах] ; 2m - пауза 1 такт, пока декодер 0 ; не освободится add edx,8 ; 1m - декодер 1 на втором такте
переставить вторую и третью команды, команда add edx,8 будет декодирована в тот же такт, что и первая команда.
Число микроопераций для каждой команды приведено в приложении 2, но можно сказать, что команды, работающие только с регистрами, как правило, выполняются за одну микрооперацию, команды чтения из памяти — тоже за одну, команды записи в память — за две, а команды, выполняющие чтение-изменение-запись, — за четыре. Сложные команды содержат больше четырех микроопераций и требуют несколько тактов для декодирования. Кроме того, команды длиннее семи байт не могут быть декодированы за один такт. В среднем время ожидания в этом буфере составляет около трех тактов.
Затем микрооперации поступают в буфер накопления, где они ждут, пока все необходимые им данные не будут доступны. Затем они посылаются в ядро системы неупорядоченного исполнения, состоящей из пяти конвейеров, каждый из которых обслуживает несколько блоков исполнения. Если все данные для микрооперации готовы и в ядре есть свободный элемент, исполняющий данную микрооперацию, в буфере накопления не будет потрачено ни одного лишнего такта. После выполнения микрооперации скапливаются в буфере завершения, где результаты их записываются, операции записи в память упорядочиваются и микрооперации завершаются (три за один такт).
Время выполнения | Скорость | |
Конвейер 0 | ||
Блок целочисленной арифметики | 1 | 1 |
Блок команд LEA | 1 | 1 |
Блок команд сдвига | 1 | 1 |
Блок целочисленного умножения | 4 | 1 |
Блок команд FADD | 3 | 1 |
Блок команд FMUL | 5 | 2 |
Блок команд FDIV | 17 для 32-битных 36 для 64-битных 56 для 80-битных |
17 36 56 |
Блок MMX-арифметики | 1 | 1 |
Блок MMX-умножений | 3 | 1 |
Конвейер 1 | ||
Блок целочисленной арифметики | 1 | 1 |
Блок MMX-арифметики | 1 | 1 |
Блок MMX-сдвигов | 1 | 1 |
Конвейер 2 | ||
Блок чтения | 3 при кэш-попадании | 1 |
Конвейер 3 | ||
Блок записи адреса | не меньше 3 | 1 |
Конвейер 4 | ||
Блок записи данных | не меньше 1 | 1 |
Восьмибайтные данные должны быть выравнены по восьмибайтным границам (то есть три младших бита адреса должны быть равны нулю).
Четырехбайтные данные должны быть выравнены по границе двойного слова (то есть два младших бита адреса должны быть равны нулю).
Двухбайтные данные должны полностью содержаться в выравненном двойном слове (то есть два младших бита адреса не должны быть равны единице).
80-битные данные должны быть выравнены по 16-байтным границам.
Когда нарушается выравнивание при доступе к данным, находящимся в кэше, теряются 3 такта на каждое невыравненное обращение на Pentium и 9 – 12 тактов — на Pentium Pro/Pentium II.
Так как линейка кэша кода составляет 32 байта, метки для переходов, особенно метки, отмечающие начало цикла, должны быть выравнены по 16-байтным границам, а массивы данных, равные или большие 32 байт, должны начинаться с адреса, кратного 32.
Выбор оптимального алгоритма для решения задачи всегда приводит к лучшим результатам, чем любой другой вид оптимизации. Действительно, при замене пузырьковой сортировки, время выполнения которой пропорционально N2, на быструю сортировку, выполняющуюся как N * log(N), всегда найдется такое число сортируемых элементов N, что вторая программа будет выполняться быстрее, как бы она ни была реализована. Поиск лучшего алгоритма — универсальная стадия, и она относится не только к ассемблеру, но и к любому языку программирования, поэтому будем считать, что оптимальный алгоритм уже выбран.