allasm.ru |
|
Скачайте пример. Специальный пакет включаемых файлов, а так же авторский windows.mac. Настоящая статья призвана расставить некоторые точки над Ё. Автор предполагает, что вы уже неплохо разобрались в основах Win32, и теперь хочет внести некоторые коррективы в то, что вы уже усвоили. Автор надеется, что Вы прочитали Туториалы
Iczelion'а. В этой статье мы проведём очень важную итерацию на пути вашего самосовершенствования. Автор не пытается рассказать в корне что-то новое, но лишь отполировать старое. 2 Visual Studio - среда разработки, или в поисках оазиса.Тема среды разработки - больная тема для программистов на ассемблере. Она часто, время от времени проходит на форумах, но лучше от этого не становиться. Автор перепробовал множество сред, созданных различными энтузиастами, и не так давно использовал прелестный UltraEdit. (Настройки для подсветки ASM вы можете скачать). Однако ни что не могло полностью удовлетворить автора. В конечном счете, лучшим мог бы оказаться VS (жаль, нет такого энтузиаста, который смог бы написать расширение под VS, поддерживающее особенности ASM). А это, без сомнения, возможно. Откройте в VS пример. Слева, на закладке FileView вы увидите структуру проекта Tpl - шаблон. Для успешной компиляции проекта в среде VS должны быть установлены пути к файлам включения и компиляторам. Эта настройка выполняется по пути: Menu > Tools > Options… на вкладке Directories. Добавьте в соответствующие разделы пути. Вот пример того, как это выглядит.
Теперь, когда пути определены, вы можете:
Чтобы убедиться, что все верно, произведите нужные изменения в файле Win32API.inc, и скомпилируйте пример, нажав Ctrl+F5. Если всё получилось, мои поздравления! Если Вы ещё не работали с VS 6.0, автор уверен, что никаких проблем не случится. Если вы работали с VS, то вам можно вообще пропустить этот раздел. Для тех, кто ещё не работал в VS рекомендую прочитать статью SvetlOff. Пример
настройки VS вы можете посмотреть в проекте исходника. Единственное, что хочется
добавить: Современная VS позволяет делать настройки компиляции в мультирежиме.
Поэтому вы можете вставить в проект сразу несколько ASM файлов, а потом на контекстном
меню из под Source Files в Settings на закладке Custom Build установить нужные
параметры. При этом, параметры будут установлены сразу на всех файлах проекта.
Нездоровый пример минимального файла - результат
настройки выравнивания линковщика 1000h, то есть 4k. В зависимости от вашего
желания вы можете опустить эту планку до 16, советую до 64 при помощи директивы
/ALIGN:xx (не обращайте внимание на ругательства линковщика - это он умничает). Уверен, что большинство из вас, использует метод Invoke для вызова функций. Это действительно удобный метод. Однако, в нём есть два недостатка:
Что значит заглушек? В мире программирования существует древняя проблема терминологий. Заглушкой будем называть такой КОД, цель которого вызвать требуемую
функцию. Сам же код заглушки может выполнять подготовительные операции, такие
как обеспечение интерфейса, вызывающий код/вызываемый код. Переменной Переходником (или далее просто переходником) будем называть указатель на функцию, через который осуществляется её вызов. Если вы уже несколько разобрались в механизме импорта функций, то должны были уяснить, что, по сути, в адресном пространстве программы инициализируется таблица указателей на функции DLL, эта таблица жёстко фиксирована и привязана к базовому адресу загрузки. Так как, в зависимости от многих факторов, адреса функций DLL могут быть различны, то эта таблица указателей заполняется загрузчиком реальными адресами функций. Это значит, что вызов любой функции Win32 должен выглядеть следующим образом: Call dword ptr Win32Function_pointer На самом деле, можно, конечно, добиться и прямого вызова. Это действительно
выполнимо и очень эффективно не только с точки зрения некоторой шустрости call,
а и по причинам системного характера. Ещё более интересней то, что, без всякого микроскопа заглянув в LIB их легко
увидеть в начале как: * Автор замечает, что эти экспортируемые переменные находятся не в секции data, а в специальной секции IMPORT_DESCRIPTOR. Более подробную информацию читатель сможет найти в документации COFF формата. Я уверен, что читатель, если не знал, то догадается без подсказки, что это
таблица соответствий указателей и импортируемых имён функций. Когда вы пишите что-то вроде: call __imp__FunName@xx Вы получаете то же самое, если бы имели EXTERN __imp__FunName@xx:dword то есть косвенный вызов по указателю: call dword ptr __imp__FunName@xx Но, когда вы пользуетесь идентификатором типа _FunName@xx, линкёр преобразует такой вызов в 34: call _InitCommonControls@0 00401090 call InitCommonControls (0040129c) 35: где InitCommonControls (0040129c) это 0040129C jmp dword ptr [__imp__InitCommonControls@0 (00405170)] то есть функция заглушка. 0040122E _ExitProcess@4: 0040122E FF25A0514000 jmp dword ptr [ExitProcess] 00401234 _EndDialog@8: 00401234 FF2538524000 jmp dword ptr [EndDialog] А в версии Debug переходники есть всегда вне зависимости от того, используются
они или нет. Так что? Теперь вручную прописать inc файлы? Слава богу, нет! Нашёлся тот человек, который научился при помощи PROTO описывать косвенный вызов в Invoke. Я нашёл этот замечательный пример в пакете MASM32 7.0 в папке L2extia. Там есть подобная утилита, которая генерирует inc файлы, что позволяет использовать Invoke и, при этом, получать код без переходника. Но перед этим вы обязаны включить в windows.inc макрос. Я несколько улучшил и переделал эти файлы, разработав полноценный комплекс. Однако об этом попозже. Следующий шаг, на пути к раскрепощению -- избавиться от противной директивы STDCALL. Почему она такая противная? Да хотя бы, потому, что добавляет слева к имени функциями символ подчёркивания. Конечно, это редко когда мешает, и, тем не менее, программист на ассемблере обязан иметь полную свободу в именовании идентификаторов, иначе, зачем тогда ассемблер, который сковывает программиста. Решить эту проблему оказалось легко, только немного изменив макрос: ArgCount MACRO number LOCAL txt txt equ <typedef PROTO STDCALL :DWORD> REPEAT number - 1 txt CATSTR txt,<,:DWORD> ENDM EXITM <txt> ENDMpr0 typedef PROTO STDCALL pr1 ArgCount(1) pr2 ArgCount(2) …………………………………………………………………………… Теперь вы можете окончательно забыть о прошлых оковах, и использовать Invoke, получая хороший код, и освободившись от STDCALL влияния. Всё в ваших руках. 4 Бессознательная оптимизация в Win32Бессознательная оптимизация - это такая оптимизация, на которую программист не тратит, или почти не тратит, умственных сил и времени. Сама по себе, в разной степени, эта способность приходит постепенно, однако, начинать тренироваться стоит уже теперь. Громадную роль в оптимизации Win32 кода является учёт его особенностей. А так случилось, что код Win32 полон таких особенностей. Итак, в путь! К вершинам! 4.1 Использование регистров или культ STDCALL. Я напомню мат. часть:
Следует похвалить разработчиков MS, так как такая конвенция обладает высокой степенью эффективности вызова. Теперь можно сформировать очень чёткие рекомендации:
Не долго думая, автор, как рьяный поклонник Зубкова, сразу вспоминает о хорошей привычке использовать регистр EBX для хранения 0, констант или переменных частого использования. Под Win32 данная рекомендация очень особенна. Именно этот регистр остается свободным, когда EBP - хранит данные кадра стека, ESI, EDI - адрес буфера, буферов, а данные нужны во время вызовов STDCALL функций. Единственное спасение это EBX. Разбирая мой непричёсанный пример (и, слава богу, я не хочу навязывать свой
стиль) вы заметите, что ebx, он же $$$__null, везде, где попало,
хранит нулевую константу, которую очень просто получить. Вот так: Проблема пропадания регистра ecx создаёт две дополнительных
операции (или больше) в циклах. Это особенно ощутимо в небольших циклах (до
50-40 команд), пока ЯВУ продолжает быть верным ECX,
есть смысл перейти на другой регистр снова же EBX, конечно,
при условии, если это действительно имеет смысл. Invoke CreateWindowEx, …………………………………………………………………………… ;; Приём предварительного помещения в стек ;; Мы помещаем дескриптор окна дважды... один раз для ;; ShowWindow, другой раз для UpdateWindow push $$$__result push SW_SHOWNORMAL push $$$__result call ShowWindow call UpdateWindow После вызова CreateWindow возвращает хэндл окна, который после используется несколькими функциями подряд. Это и есть тот классический случай, когда следует использовать данный приём. Другой случай, отличается тем, когда API функции получающие результат другой функции находятся достаточно далеко друг от друга, и вам не хватает регистров, чтобы эффективно создать код. В этом случае предварительное помещение параметров в стек, или просто сохранение их там, экономит достаточно ресурсов. Обычно такая проблема решается локальными переменными. То есть после вызова функции, результат сохраняется в переменной, а потом используется. Но это характерно для ЯВУ, и не характерно для АСМ, где вы сами можете управлять методиками. Кроме того, более эффективен метод смешивания команд push и каких-нибудь других команд процессора, особенно команд состоящих из двух, одной микроопераций. Всё это повышает вероятность написания оптимального кода, так как наиболее легко спариваются команды в вереницах с push, не связанные с операциями промежуточного характера. Например, вы имеете код, который вычисляет что-то, а потом вызывает API функцию. Конечно, вы можете сделать так, как удобно человеку, но можете сделать и так, как удобно машине: смешать команды вычисления, которые очень часто не зависят от многих параметров API функции и только потом осуществить вызов. Пример: ;; Исходный код ………………………………………………………………………………………………………………… div esi mul edx,10 add eax,edx Invoke Win32Fun eax,CONST1,CONST2 ………………………………………………………………………………………………………………… ;; Или div esi push CONST2 mul edx,10 push CONST1 add eax,edx push eax call Win32Fun Ко всему прочему, вы так же можете заметить подобные приёмы и в основном цикле программы. 4.2 Стековый кадр и локальные переменныеНа сегодняшний день вошло в моду использовать только локальные переменные по-возможности, хотя, по-моему, это не столь существенно, в отличие от нежелания хоть сколь нибудь грамотно создавать софт. Все локальные переменные делятся на несколько групп:
Если вы используйте локальные переменные, которые обычно располагаются после базы стекового кадра, (то есть к ним следует ссылаться как [ebp-xx]) придерживайтесь рекомендаций. Чтобы разместить неинициализируемые переменные достаточно просто изменить esp и пользоваться ebp -- указатель базы стекового кадра. В общем случае это неплохо, если б не AGI (задержка при генерации адреса), которая впрочем, не возникает на современных процессорах. С инициализируемыми переменными более сложнее, здесь не только следует выделить пространство, а и произвести их инициализацию. Надеюсь мне не нужно разъяснять, насколько это неэффективно, по сравнению с простым объявлением переменных в памяти (статические переменные). А поэтому стоит всё-таки ещё семь раз подумать. Однако, если вам нужно разместить в стеке структуру заполненную нулями или чем-то заполненную, и размер этой структуры не так велик, то более грамотный способ - использовать старые добрые push. Это особенно верно, когда структура небольшая по размерам. Даже метод заполнения нулём при помощи push эффективней, чем все остальные. ……………………………………………………………………… xor eax,eax push eax push eax push eax push eax ;; Заполнение структуры из четырёх dword Как правило, заполнение нулями всей структуры не имеет смысла. Поэтому тренд с push рулит ещё более. Если же структура очень большая, разумно организовать цикл из push. Чтобы цикл был эффективным, следует использовать две или четыре push, если структура кратна 4 dword. Иначе лишние push (которые на самом деле не нужны) так же легко стереть pop, или увеличением esp. Правда здесь ещё можно поспорить с Win32 функцией ZeroMemory, однако, я думаю, не стоит. Если же вам приходиться изменять локальные данные уже после их создания, где-нибудь в коде, то, к сожалению, воспользоваться push можно только тогда, когда данные, стоящие в стеке после изменяемой структуры и в самой структуре вам не нужны. Делается это просто: Вы изменяете esp так, чтобы он указывал на структуру (а точнее на её последний элемент), а потом push делаете своё дело. В придачу между push, вы можете так же вызывать API функции, результат выполнения которой, значения переменных в этой структуре. Эффективность такого подхода просто восхищает по сравнению со всеми остальными. Пример: ;; Я хочу проинициализировать структуру POINTS(4*POINT(x,y)),
xchg ebp,esp sub esp,16 ;; После этих двух команд параметры процедуры не ;; доступны через ebp, если вам кадр стека нужен, ;; можно сделать и так, пожертвовав другим регистром ;; mov ebx,esp ;; lea esp,[ebp-16]
;; Всё пошли Invoke GetXCoord,параметры push eax Invoke GetYCoord,параметры push eax ………………………. ;; Организуем цикл, или без цикла… ………………………… xchg ebp,esp add ebp,16 Уверен, изобретательный читатель найдёт и свой вариант сохранения кадра стека, этим и замечателен ассемблер. А он, откровенно говоря, возможен, если увериться, что размер области локальных переменных постоянен. Посмотрим на этот фокус. При определении стекового кадра мы установим ebp не на область начала параметров процедуры, а на конец области определения локальных данных.
Если объём локальных данных известен заранее, и при модификации структур стек
будет пуст до ebp+0. То мы можем вообще не сохранять esp, так как его значение
будет храниться постоянно в ebp. Уверен, что у вас возник вопрос: «А если не вызывать функции? Данные стека хранящиеся после, и сама структура не разрушаться, не так ли?» Вообще говоря, верно, если только не одно «Но». Имя этому «Но»: SEH - структурная обработка исключений. Если вы помните, она так же использует стек, и соответственно без всякого на то предупреждения может затереть ваши данные. А ведь SEH используется чаще, чем вы можете догадываться. Оно используется при операциях с памятью, и выполняется в вашей программе даже тогда, когда вы сами не используйте её. Всё это означает, что увеличивать esp - опасно для данных стека. Лучше не создавать трудно обнаруживаемые ошибки. Но вернёмся к локальным данным переменной длины. Это хороший приём, когда
нужно быстро получить участок памяти. Об этом подробно описано здесь. Но автор надеться, что читатель имеет свою голову на плечах, и не будет впадать в крайности человеческой жадности, и будет размещать локальные данные не только в стеке, но и в обыкновенном сегменте данных. Или иначе обычное static директива как в С. Конечно, есть много "Но", из-за которых этот метод следует избегать:
Однако, если:
То стоит подумать о размещении структуры не в стеке, а в сегменте данных. Это:
Это всё может дать достаточно ощутимый выигрыш и по размеру и по скорости и по качеству кода. Но, увы, остерегайтесь подводных камней. Тем не менее, данная рекомендация сгодится не только асм-разработчикам, но и разработчикам C++. Так что думайте. Последнее замечание по доступу к данным стека - так это прекрастная возможность использовать для доступа к стеку не только регистр ebp, но и любой другой. И, при этом, без всяких там префиксов замены сегмента. То есть, такой код будет работать: push 1234h mov esi,esp mov eax,[esi] ;; eax = 1234h Посмотрите на картину DEBUG окна в VS: CS = 001B DS = 0023 ES = 0023 SS = 0023 FS = 0038 GS = 0000 Насколько можно увидеть у сегментов DS, ES, SS - селекторы одинаковые. Так, то всё очень строго научно, и скажем так, без риска. 4.3 Особенности оптимизация кода под Win32Если читатель уже ознакомился с премудростями оптимизации кода, это ещё не значит, что эти премудрости так хороши и верны под Win32. Win32 имеет свои подводные камни, о которых нужно знать. Красота и искусство оптимизации на ассемблере под Win32 - это умение сочетать где надо оптимизацию по скорости, а где надо - по размеру. Как правило, оптимизация по скорости и размеру одновременно случается редко. К счастью Win32 настолько хорошо подвержено аналитическому разбору, что автор смог выделить очень чёткие рекомендации по особенностям оптимизации кода под Win32. Эти рекомендации хорошо представить как таблицу основных мест где стоит и как стоит оптимизировать: Список мест, которые оптимизируются по скорости (то есть оптимизация по скорости - есть решающей):
Список оптимизации по размеру:
Теперь стоит остановиться, прочитать этот список ещё раз и глубоко вздохнуть.
То, что вы теперь прочитаете, не написано ни в одной книге. Автор удивляется,
почему об этом молчат профессионалы, или это настолько ужасный секрет, или об
этом просто не задумываются. Перед тем, как я объясню суть, я хочу, чтобы вы
заметили, что больше пунктов в оптимизации по размеру, однако это не значит,
что всегда и объём кода припадающий на вторую группу больше объёма кода первой
группы. Хотя это зависит от программы. Это противоречит современному утверждению
ЯВУшников: «Памяти много, и оптимизировать стоит по скорости, а не по размеру».
А теперь обратите внимание на пункт 6 в первом списке: «Код мультипотоковых взаимодействий». Это некоторое исключение из правил, но только подтверждающее правило. Это значит, что код, располагающийся после функций ожидания, или между функциями ожидания следует оптимизировать по скорости. Причина такой оптимизации - вероятность выполнения кода без прерываний, что так же повышает общую эффективность приложения. Рекомендации 7-10 второго списка характерны не только для Win32, а вообще универсальны для всего программирования. Причина отсутствия оптимизации по скорости в процедурах инициализации, обусловлена следующими факторами:
Это кстати, ещё один плюс метода предварительного помещения параметров в стек, или метод смешанного помещения. Так как код типа: push xx push xx call win32 call win32 Имеет намного высокую вероятность более быстрого выполнения, чем код push xx call win32 push xx call win32 Так как команды push стоящие подряд кэшируются, а после вызова функции кэш уже может содержать совсем другой код. 4.4 Рекомендации при построении WndProcБолее подробно оптимизация WndProc была описана в моей статье: «Эффективный анализ сообщений в WndProc». Однако, кроме этого я повторю основные рекомендации.
Некоторые комментарии. Отсутствие оптимизации для Процедур Диалога, связанно с малым влиянием их скорости на общую скорость обработки. Во-первых, диалоговая процедура - это процедура, вызываемая из какой-то WndProc, определённой в USER32, и поэтому основная производительность лежит на ней, а не на Вашей процедуре. Во-вторых - диалоговая процедура по определению управляет интерфейсом, а поэтому не может быть быстрее, чем функции прорисовки. Нет смысла скоростной оптимизации. Приём использования одной диалоговой процедуры - прекрасный приём. Это оказывается
естественным и лёгким, так как все контролы имеют разные идентификаторы. Единственную
разницу в процедурах можно учитывать с помощью дополнительных элементов в структуре
Wnd. Там как раз есть один такой элемент GWL_USERDATA, или,
в конце концов, GWL_ID, DWLP_USER, к которым
можно иметь доступ при помощи функции SetWindowLong/ GetWindowLong.
А для учёта типа диалога при инициализации можно воспользоваться значением lParam,
для передачи в процедуру диалога дополнительной информации. Когда заходит вопрос об оптимальном размещении кода в приложении, а значит и о выравнивании данных, мне становиться не по себе. Нет, ну почему ЯВУ не могут научить грамотно распределять данные? Ужасная проблема выравнивания на самом деле не столько проблема, сколько лень. Итак, данные должны быть выровнены! Но это не значит, что для их выравнивания требуются множество пустых незадействованных байт!!! Нет и ещё раз нет. При помощи грамотного распределения данных вы всегда сможете получить такое выравнивание, какое требуется. Обычно рекомендации таковы:
Это элементарные правила, как и многое из того, что было написано в статье, однако они не соблюдаются, по неизвестным причинам. Размещение данных по закону убывание кажется естественным:
И зачем вообще думать о выравнивании данных? Когда вы имеете код, которому вот-вот на несколько вызовов нужна память менее
2-4 килобайт, вы можете использовать хип. Однако,
если ваша функция не рекурсивна, и не выполняется в нескольких потоках, быстрее
и качественней выделить буфер или просто участок памяти в стеке. Это более эффективно,
чем выделение памяти функциями Win32. И при этом, что для доступа
к этой памяти можно использовать указатели в esi/edi - стековая память ни чем
не отличается от обычной.
При увеличении объёма стека картина изменяется:
И так далее, пока не будет исчерпано всё зарезервированное пространство, которое
по умолчанию составляет 1M.
Формирование стека под NT
Вообще Windows 9x не поддерживает флаг PAGE_GUARD, который
не отличается физически от флага NOACCESS. Чтобы этого не произошло, следует выделять память объёмом больше 4k не сразу, а по частям. Сперва 4k, а потом остальную часть, если нужно тоже по 4k - размер виртуальной страницы на Windows. 5.2 Страничная адресация - благо?Особенность EXE файла, от форматов для ДОС велика. И велика она хотя бы в том, что программа в формате PE Аля Windows - это буквально схема программы, которую уже можно загрузить без каких либо модификаций содержимого. Новичкам, только осваивающим страничную адресацию, обычно кажется, что вот, наконец - это и есть панацея от бед сегментного мира DOS. Однако так только кажется. Если под DOS программа не привязана к сегменту, в котором выполняется, то код созданный линковщиком, навсегда и всесильно привязан к адресу 400000h. Именно эту цифру вы и получаете, когда пытаетесь вызвать GetModuleHandle(0) А почему? А потому, что FLAT. Как говорится, за что боролись, в то и влипли.
Это значит, что если загрузчику вздумается загрузить всю или часть программы
по другому адресу он обязан будет модифицировать все ссылки и метки так, чтобы
код корректно работал. И хотя суть идентификации проста - прибавить, отнять
смещение от предполагаемого адреса загрузки, представьте себе, сколько нужно
сделать операций хотя бы для средненькой программки. Самое обидное - это цена, которую приходиться платить, и эта цена не только во времени запуска приложения или DLL. А так же другой фактор. Дело в том, что, модифицируя код программы, система естественно уже не может пользоваться отображением на файл. Она размещает по сути новый exe (DLL) в страничном файле. Это похоже на кошмар. Более того, DLL, по сути, перестаёт быть DLL, это просто кусок кода связанный с кодом вашего файла. DLL теряет все свойства совместного использования. Так как системой создаётся новая DLL да ещё в страничном файле. Интересно и какое право после этого DLL можно назвать DLL? Откровенно говоря, наиболее легче было бы при инсталляции просто слепить программу с учётом всех особенностей в один или несколько файлов, но который бы исполнялся. Вам будет интересно узнать, что такая возможность существует. Но думаю это тема уже для следующих статей. Однако какой после этого должен быть сделать вывод? Вывод должен быть таковым: «Программа всегда должна загружаться по базовому адресу!!!» Иначе лучше уже вообще её не загружать. Разбирая мой пример, вы, наверное, заметили, что я не использую функцию GetModuleHandle, для получения hInstance. Вместо этого я пользуюсь константой PROGRAM_IMAGE_BASE и в Release линковщику указан ключ /FIXED. На всех известных мне платформах Windows приложения без проблем могут загрузиться по адресу PROGRAM_IMAGE_BASE EQU 400000h. 6 Что дальше?В следующей статье «Записки Дzenствующего» мы поговорим о макросах, навсегда истребим проблему недоговорок документации по ним, и окончательно разберёмся со строками и головной болью UNICODE. |