Записки Дzenствующего

Дата публикации 21 ноя 2002

Записки Дzenствующего — Архив WASM.RU

Edmond :: HI-TECH group

Записки Дzenствующего

1 От автора
2 Visual Studio - среда разработки, или в поисках оазиса.
3 Вызов функций Win32
4 Бессознательная оптимизация в Win32

4.1 Использование регистров или культ STDCALL.
4.2 Стековый кадр и локальные переменные
4.3 Особенности оптимизация кода под Win32
4.4 Рекомендации при построении WndProc
4.5 Оптимальное Размещение кода и данных в приложении

5 Особенности Win32

5.1 Особенности выделения в стеке больших объёмов данных
5.2 Страничная адресация - благо?

6 Что дальше?

1 От автора

Скачайте пример.

 Специальный пакет включаемых файлов, а так же авторский windows.mac.

Настоящая статья призвана расставить некоторые точки над Ё. Автор предполагает, что вы уже неплохо разобрались в основах Win32, и теперь хочет внести некоторые коррективы в то, что вы уже усвоили.

Автор надеется, что Вы прочитали Туториалы Iczelion'а.
И ЭТО, и ЭТО.
Увы, но обучение не может идти плавно, оно всегда происходит скачками  или, как говорят математики: итерациями.

В этой статье мы проведём очень важную итерацию на пути вашего самосовершенствования. Автор не пытается рассказать в корне что-то новое, но лишь отполировать старое.

2 Visual Studio - среда разработки, или в поисках оазиса.

Тема среды разработки - больная тема для программистов на ассемблере. Она часто, время от времени проходит на форумах, но лучше от этого не становиться. Автор перепробовал множество сред, созданных различными энтузиастами, и не так давно использовал прелестный UltraEdit. (Настройки для подсветки ASM вы можете скачать). Однако ни что не могло полностью удовлетворить автора. В конечном счете, лучшим мог бы оказаться VS (жаль, нет такого энтузиаста, который смог бы написать расширение под VS, поддерживающее особенности ASM). А это, без сомнения, возможно.

Откройте в VS пример. Слева, на закладке FileView вы увидите структуру проекта Tpl - шаблон. Для успешной компиляции проекта в среде VS должны быть установлены пути к файлам включения и компиляторам. Эта настройка выполняется по пути: Menu > Tools > Options… на вкладке Directories. Добавьте в соответствующие разделы пути. Вот пример того, как это выглядит.

Вид проекта и настроект в VS

Теперь, когда пути определены, вы можете:

  1. Не прописывать их в переменных окружения.
  2. Не прописывать их в исходниках.

Чтобы убедиться, что все верно, произведите нужные изменения в файле Win32API.inc, и скомпилируйте пример, нажав Ctrl+F5.

Если всё получилось, мои поздравления! Если Вы ещё не работали с VS 6.0, автор уверен, что никаких проблем не случится. Если вы работали с VS, то вам можно вообще пропустить этот раздел.

Для тех, кто ещё не работал в VS рекомендую прочитать статью SvetlOff. Пример настройки VS вы можете посмотреть в проекте исходника. Единственное, что хочется добавить: Современная VS позволяет делать настройки компиляции в мультирежиме. Поэтому вы можете вставить в проект сразу несколько ASM файлов, а потом на контекстном меню из под Source Files в Settings на закладке Custom Build установить нужные параметры. При этом, параметры будут установлены сразу на всех файлах проекта. Нездоровый пример минимального файла - результат настройки выравнивания линковщика 1000h, то есть 4k. В зависимости от вашего желания вы можете опустить эту планку до 16, советую до 64 при помощи директивы /ALIGN:xx (не обращайте внимание на ругательства линковщика - это он умничает).

3 Вызов функций Win32

Уверен, что большинство из вас, использует метод Invoke для вызова функций. Это действительно удобный метод. Однако, в нём есть два недостатка:

  1. Необходимость использования заглушек.
  2. Параметры будут помещены в стек.

Что значит заглушек?

В мире программирования существует древняя проблема терминологий.

Заглушкой будем называть такой КОД, цель которого вызвать требуемую функцию. Сам же код заглушки может выполнять подготовительные операции, такие как обеспечение интерфейса, вызывающий код/вызываемый код.

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

Если вы уже несколько разобрались в механизме импорта функций, то должны были уяснить, что, по сути, в адресном пространстве программы инициализируется таблица указателей на функции DLL, эта таблица жёстко фиксирована и привязана к базовому адресу загрузки. Так как, в зависимости от многих факторов, адреса функций DLL могут быть различны, то эта таблица указателей заполняется загрузчиком реальными адресами функций. Это значит, что вызов любой функции Win32 должен выглядеть следующим образом:

Call dword ptr Win32Function_pointer  

На самом деле, можно, конечно, добиться и прямого вызова. Это действительно выполнимо и очень эффективно не только с точки зрения некоторой шустрости call, а и по причинам системного характера.
Интересно будет узнать, что при создании файла lib, наряду с привычными _Fun@x экспортируются указатели (dword) на функции DLL. В своей сущности эти указатели не что иное как переменные, находящиеся в массивах IMAGE_THUNK_DATA.

Ещё более интересней то, что, без всякого микроскопа заглянув в LIB их легко увидеть в начале как:
__IMPORT_DESCRIPTOR_USER32 __NULL_IMPORT_DESCRIPTOR USER32_NULL_THUNK_DATA _EditWndProc@16 __imp__EditWndProc@16 _RegisterClassA@4 __imp__RegisterClassA@4 _RegisterClassW@4

* Автор замечает, что эти экспортируемые переменные находятся не в секции data, а в специальной секции IMPORT_DESCRIPTOR. Более подробную информацию читатель сможет найти в документации COFF формата.

Я уверен, что читатель, если не знал, то догадается без подсказки, что это таблица соответствий указателей и импортируемых имён функций.
При этом посмотрите, какая зависимость в их именовании. Переходник именуется так: __imp__FunName@xx
А функция так: _FunName@xx

Когда вы пишите что-то вроде:

 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)]

то есть функция заглушка.
Любопытно будет отметить, что заглушки помещаются в конец кода, это хорошо видно при помощи dumppe.exe (когда Debugger VS искажает информацию)

0040122E _ExitProcess@4:
   0040122E FF25A0514000 jmp dword ptr [ExitProcess]
   00401234 _EndDialog@8:
   00401234 FF2538524000 jmp dword ptr [EndDialog]

А в версии Debug переходники есть всегда вне зависимости от того, используются они или нет.
Какой ваш вывод? Нет заглушкам? Надеюсь на это. Мало того, что это лишняя команда jmp, так он ещё и увеличивает код. А потом стыдно должно быть перед ЯВУшниками, так как хотя бы тот же С++ уже научили вызывать функции Win32 без заглушек!!!

Так что? Теперь вручную прописать 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>
   ENDM
 pr0 typedef PROTO STDCALL
   pr1 ArgCount(1)
   pr2 ArgCount(2)
   ……………………………………………………………………………

Теперь вы можете окончательно забыть о прошлых оковах, и использовать Invoke, получая хороший код, и освободившись от STDCALL влияния. Всё в ваших руках.

4 Бессознательная оптимизация в Win32

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

Сама по себе, в разной степени, эта способность приходит постепенно, однако, начинать тренироваться стоит уже теперь.

Громадную роль в оптимизации Win32 кода является учёт его особенностей. А так случилось, что код Win32 полон таких особенностей. Итак, в путь! К вершинам!

4.1 Использование регистров или культ STDCALL.

Я напомню мат. часть:
В соглашении вызова процедур STDCALL предусматривается следующее:

  1. Регистры EBX, ESI, EDI, EBP - не могут изменяться в STDCALL функции.
  2. Параметры помещаются по значению подобно C-конвенции: от последнего к первому.
  3. Функция сама отчищает стек от параметров.

Следует похвалить разработчиков MS, так как такая конвенция обладает высокой степенью эффективности вызова.

Теперь можно сформировать очень чёткие рекомендации:

  1. Используйте регистр EBX для хранения часто используемых констант, переменных в вызывающем коде.
  2. Используйте изменяемые регистры для хранения констант, переменных между вызовами STDCALL функций.
  3. Используйте приём предварительного помещения в стек, для вызовов Win32 функций.
  4. Если константа используется более 3-x раз и больше байта, сохраните константу в регистре для последующего использования. То же верно и для переменных.

Не долго думая, автор, как рьяный поклонник Зубкова, сразу вспоминает о хорошей привычке использовать регистр EBX для хранения 0, констант или переменных частого использования. Под Win32 данная рекомендация очень особенна. Именно этот регистр остается свободным, когда EBP - хранит данные кадра стека, ESI, EDI - адрес буфера, буферов, а данные нужны во время вызовов STDCALL функций. Единственное спасение это EBX.

Разбирая мой непричёсанный пример (и, слава богу, я не хочу навязывать свой стиль) вы заметите, что ebx, он же $$$__null, везде, где попало, хранит нулевую константу, которую очень просто получить. Вот так:
xor ebx,ebx
Как можно увидеть ЯВУ уже то же научилось этому простенькому фокусу, и поэтому человеку не пристало отставать.

Проблема пропадания регистра ecx создаёт две дополнительных операции (или больше) в циклах. Это особенно ощутимо в небольших циклах (до 50-40 команд), пока ЯВУ продолжает быть верным ECX, есть смысл перейти на другой регистр снова же EBX, конечно, при условии, если это действительно имеет смысл.
Подобным образом легко обеспечить значение TRUE, вот так:
inc al
Так частый прием, который вы можете увидеть в примере это помещение заранее параметров в стек. Это очень сильный приём и достаточно лёгкий. Обычно, при написании участка кода я «осматриваю» одновременно 10, 20 команд, и бессознательно 50, плюс чувство контекста. Этого вполне достаточно, чтобы, не задумываясь, выполнять приём предварительного размещения параметров в стеке. Со временем приём входит в привычку, и перестаёшь его замечать, концентрируя внимание на алгоритме.
Вот типичный пример использования приёма (здесь $$$__result это eax):

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 Стековый кадр и локальные переменные

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

Все локальные переменные делятся на несколько групп:

  1. Неинициализируемые
  2. Инициализируемые
  3. До базы стекового кадра ([ebp+xx])
  4. По месту применения

Если вы используйте локальные переменные, которые обычно располагаются после базы стекового кадра, (то есть к ним следует ссылаться как [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)),
;; ( (x,y) занимают вместе двойное слово) ;; которая расположена в стеке после 16 байт от базы ebp.
 
параметры функции
ebp ==>
адрес возврата
ebp-16
Другие переменные
ebp-16-16
структура POINTS(4*POINT(x,y))
esp ==>
где-то там далеко (меньшие адреса)
xchg ebp,esp
   sub esp,16
   ;; После этих двух команд параметры процедуры не
   ;; доступны через ebp, если вам кадр стека нужен,
   ;; можно сделать и так, пожертвовав другим регистром
   ;; mov ebx,esp
   ;; lea esp,[ebp-16]
 
параметры функции
ebp ==>
адрес возврата
 
Другие переменные
esp ==>
структура POINTS(4*POINT(x,y)) (конец)
 
структура POINTS(4*POINT(x,y)) (начало)
 
где-то там далеко (меньшие адреса)
;; Всё пошли
   Invoke GetXCoord,параметры
   push eax
   Invoke GetYCoord,параметры
   push eax
   ……………………….
   ;; Организуем цикл, или без цикла…
   …………………………
   xchg ebp,esp
   add ebp,16 

Уверен, изобретательный читатель найдёт и свой вариант сохранения кадра стека, этим и замечателен ассемблер. А он, откровенно говоря, возможен, если увериться, что размер области локальных переменных постоянен.

Посмотрим на этот фокус. При определении стекового кадра мы установим ebp не на область начала параметров процедуры, а на конец области определения локальных данных.

 
параметры функции
 
адрес возврата
ebp ==>
Локальные переменные
 
где-то там далеко (меньшие адреса)

Если объём локальных данных известен заранее, и при модификации структур стек будет пуст до ebp+0. То мы можем вообще не сохранять esp, так как его значение будет храниться постоянно в ebp.
Естественно, такой приём не сработает, если вы собираетесь содержать локальные данные переменной длины.

Уверен, что у вас возник вопрос: «А если не вызывать функции? Данные стека хранящиеся после, и сама структура не разрушаться, не так ли?» Вообще говоря, верно, если только не одно «Но». Имя этому «Но»: SEH - структурная обработка исключений. Если вы помните, она так же использует стек, и соответственно без всякого на то предупреждения может затереть ваши данные. А ведь SEH используется чаще, чем вы можете догадываться. Оно используется при операциях с памятью, и выполняется в вашей программе даже тогда, когда вы сами не используйте её. Всё это означает, что увеличивать esp - опасно для данных стека. Лучше не создавать трудно обнаруживаемые ошибки.

Но вернёмся к локальным данным переменной длины. Это хороший приём, когда нужно быстро получить участок памяти. Об этом подробно описано здесь.
Как я уже говорил, официальная политика современного программирования есть отказ от глобальных переменных, и вообще от переменных статического характера. Несколько комментариев по этому поводу состоят в том, что программирование движется в сторону «окультуривания к человеку», то есть создание такой программы, которая была бы удобна человеку, а не машине. Только хотя бы эта фраза кажется абсурдом, и, тем не менее, тенденция остается.

Но автор надеться, что читатель имеет свою голову на плечах, и не будет впадать в крайности человеческой жадности, и будет размещать локальные данные не только в стеке, но и в обыкновенном сегменте данных. Или иначе обычное static директива как в С. Конечно, есть много "Но", из-за которых этот метод следует избегать:

  1. Рекурсивные процедуры
  2. Код, исполняющийся в нескольких потоках

Однако, если:

  1. Структура достаточно велика
  2. Структуру следует заполнить в основном статическими данными (то есть константами), или заполнить один раз на этапе инициализации.
  3. Не все данные в структуре в последствии изменяемы функцией.

То стоит подумать о размещении структуры не в стеке, а в сегменте данных. Это:

  1. Сэкономит память, так как размещение структуры в сегменте данных занимает меньше памяти, чем код её размещения в стеке и инициализации
  2. Вне конкуренции по скорости инициализации - вообще не тратит время на инициализацию, или тратит, но незначительно
  3. Быстрее в доступе, нежели тяжёлые команды c ebp-xxh.

Это всё может дать достаточно ощутимый выигрыш и по размеру и по скорости и по качеству кода. Но, увы, остерегайтесь подводных камней. Тем не менее, данная рекомендация сгодится не только асм-разработчикам, но и разработчикам 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. Эти рекомендации хорошо представить как таблицу основных мест где стоит и как стоит оптимизировать:

Список мест, которые оптимизируются по скорости (то есть оптимизация по скорости - есть решающей):

  1. Процедура окна (анализ сообщений)
  2. Длинные вычисления при прорисовке.
  3. Циклы автономных функций и конверторов, которые не содержат в нутрии вызовы Win32 API системных функций, особенно из kernel32.dll (функций ядра)
  4. Процедуры конвертирования чего угодно, имеющие автономное значение
  5. Любой значимый объём кода, не имеющий вызовов Win32
  6. Код анализа состояния в мультипотоковых системах.

Список оптимизации по размеру:

  1. Код, насыщенный вызовами системных функций ядра
  2. Код, выполняющийся рядом с переключением задач
  3. Код, перенасыщенный другими тяжёлыми функциями Win32
  4. Код, конвертирования, и других преобразований для вывода на экран, и код связанный с GUI, кроме пунктов подходящих к предыдущей таблице.
  5. Код процедуры диалога
  6. Код инициализации чего угодно.
  7. Код удаления и ининициализации
  8. Код обработки ошибок
  9. Код фреймов SEH и проверки ошибок.
  10. Код мультипотоковых взаимодействий.

Теперь стоит остановиться, прочитать этот список ещё раз и глубоко вздохнуть. То, что вы теперь прочитаете, не написано ни в одной книге. Автор удивляется, почему об этом молчат профессионалы, или это настолько ужасный секрет, или об этом просто не задумываются. Перед тем, как я объясню суть, я хочу, чтобы вы заметили, что больше пунктов в оптимизации по размеру, однако это не значит, что всегда и объём кода припадающий на вторую группу больше объёма кода первой группы. Хотя это зависит от программы. Это противоречит современному утверждению ЯВУшников: «Памяти много, и оптимизировать стоит по скорости, а не по размеру».
На самом деле, в идеале, оптимизировать действительно следовало бы, отдавая предпочтение времени выполнения, нежели размеру кода. Однако в пунктах первого списка оптимизация по скорости не имеет смысла. А раз так, то смысл имеет оптимизация по размеру. То есть: «Если мы не можем выиграть в скорости, мы можем выиграть в размере». Причина невозможности оптимизации по времени кроется в длительности исполнения самих функций Win32. Нет смысла оптимизировать код из 10-50 команд (кроме чистого цикла) по скорости, если потом идёт вызов тяжёлой Win32 функции. Тяжёлыми можно считать все функции ядра, так как в любом случае, при переходе в режим ядра тратится более 1000 тактов (как это любит писать Джеффри Рихтер). Кроме того, тяжёлыми следует считать многие функции GDI и другие. То есть скоростной оптимизации может подлежать только тот код, который не содержит в себе вызовов Win32. А это значит следующее:

  • Если возможно, избегайте вызовов функций внутри циклов, или делайте так, чтобы все вызовы в цикле находились в одном месте, или в двух, но так, чтобы большая часть цикла была «чистым кодом».
  • Создавайте код таким образом, чтобы не смешивать Win32 API с кодом от него «независящим». Это не только даст возможность скоростной оптимизации, но и возможность повышения степени переносимости вашего кода.

А теперь обратите внимание на пункт 6 в первом списке: «Код мультипотоковых взаимодействий». Это некоторое исключение из правил, но только подтверждающее правило. Это значит, что код, располагающийся после функций ожидания, или между функциями ожидания следует оптимизировать по скорости. Причина такой оптимизации - вероятность выполнения кода без прерываний, что так же повышает общую эффективность приложения. Рекомендации 7-10 второго списка характерны не только для Win32, а вообще универсальны для всего программирования. Причина отсутствия оптимизации по скорости в процедурах инициализации, обусловлена следующими факторами:

  1. Выполнение этих процедур один или малое количество раз
  2. Требования к стабильности кода в этих процедурах
  3. Операции выделения памяти/записи в файлы не могут быть быстрыми, и соответственно нет смысла в оптимизации кода по времени (скорости).

Это кстати, ещё один плюс метода предварительного помещения параметров в стек, или метод смешанного помещения. Так как код типа:

   push xx
   push xx
   call win32
   call win32
   Имеет намного высокую вероятность более быстрого выполнения, чем код
   push xx
   call win32
   push xx
   call win32

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

4.4 Рекомендации при построении WndProc

Более подробно оптимизация WndProc была описана в моей статье: «Эффективный анализ сообщений в WndProc». Однако, кроме этого я повторю основные рекомендации.

  1. Анализируйте первыми те сообщения, которые приходят в процедуру окна чаще.
  2. Не создавайте стековый кадр для анализа сообщений (как это показано в моём примере)
  3. Не оптимизируйте код анализа сообщений в процедуре диалога, это имеет малый смысл.
  4. Используйте одну процедуру диалога на всю программу, для идентификации используйте lParam.
  5. Назначайте контролам порядковые идентификаторы
  6. Назначайте меню порядковые идентификаторы, чтобы можно было воспользоваться таблицей переходов - это очень эффективно.
  7. Группируйте анализ в WM_COMMAND WndProc, не по ID сообщения от контрола, а по идентификатору контрола.
  8. Группируйте анализ в WM_COMMAND DlgProc, не по идентификатору контрола, а по ID сообщения от контрола.

Некоторые комментарии.

Отсутствие оптимизации для Процедур Диалога, связанно с малым влиянием их скорости на общую скорость обработки. Во-первых, диалоговая процедура - это процедура, вызываемая из какой-то WndProc, определённой в USER32, и поэтому основная производительность лежит на ней, а не на Вашей процедуре. Во-вторых - диалоговая процедура по определению управляет интерфейсом, а поэтому не может быть быстрее, чем функции прорисовки. Нет смысла скоростной оптимизации.

Приём использования одной диалоговой процедуры - прекрасный приём. Это оказывается естественным и лёгким, так как все контролы имеют разные идентификаторы. Единственную разницу в процедурах можно учитывать с помощью дополнительных элементов в структуре Wnd. Там как раз есть один такой элемент GWL_USERDATA, или, в конце концов, GWL_ID, DWLP_USER, к которым можно иметь доступ при помощи функции SetWindowLong/ GetWindowLong. А для учёта типа диалога при инициализации можно воспользоваться значением lParam, для передачи в процедуру диалога дополнительной информации.
Так же следует уделить внимание способу разбора WM_COMMAND / WM_NOTIFY. Обычно автор видит примеры, где процедуры анализируют HIWORD(wParam), когда более эффективней группировать сообщения не по кодам нотификации, а по контролам, то есть чинить разбор wID = LOWORD(wParam). Это должно быть понятным читателю, так как обычно приложение не отвечает на все коды нотификаций, но за то обрабатывает все контролы.
В диалоговой процедуре, как не странно, имеет смысл поменять такой порядок. Там, например, хорошо определить код нотификации, а потом от кого он пришёл. Это действительно удобно. Например, у вас много кнопок. Вы определяете, что это код нотификации BN_CLICKED, а после (если контролы кнопок идут подряд), сделать косвенный вызов по таблице указателей, или нечто другое.

4.5 Оптимальное Размещение кода и данных в приложении

Когда заходит вопрос об оптимальном размещении кода в приложении, а значит и о выравнивании данных, мне становиться не по себе. Нет, ну почему ЯВУ не могут научить грамотно распределять данные? Ужасная проблема выравнивания на самом деле не столько проблема, сколько лень. Итак, данные должны быть выровнены! Но это не значит, что для их выравнивания требуются множество пустых незадействованных байт!!! Нет и ещё раз нет. При помощи грамотного распределения данных вы всегда сможете получить такое выравнивание, какое требуется. Обычно рекомендации таковы:

  1. Размещайте данные по закону убывания. Более сложные, большие вперёд, более меньшие назад. (Единственная оговорка состоит в том, что начало сегмента обязано удовлетворять запросам выравнивания данных)
  2. Размещайте данные одного размера в одном месте
  3. Размещайте данные, используемые в одной зоне кода в одной зоне сегмента данных, и не раскидывайте их впустую.

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

  1. STRUCTURS
  2. QWORDS
  3. DWORDS
  4. WORDS
  5. BYTES

И зачем вообще думать о выравнивании данных?
Другое вообще никогда не используется: это оптимизация размещения кода.
Хочу напомнить читателю мат. часть, в которой говориться, что Windows использует механизм отображения файлов для файлов EXE во время исполнения. Это означает, что если команда перехода, например, обратилась по адресу незагруженного в память блока кода (данных), то система подгружает его по правилам виртуального адресного пространства: она обнаруживает, что запрошенная виртуальная страница отсутствует в памяти, и загружает данные. То, что этот процесс тратит сравнительно много времени, говорить не приходится.
Говорить приходится о том, что программист, не обязательно асмовец, хотя ему легче, обязан так группировать код и данные, чтобы взаимосвязанный код и данные находились физически в одном месте адресного пространства. Отсюда же образуется правило: Держать код обработки ошибок сбоев, настроек, запуска, инициализации и так далее в отдельном месте от «функционирующего кода». К сожалению, такое положение неудобно для человека, и, тем не менее, вы не представляете, насколько данная оптимизация может поднять производительность в объемных корпоративных продуктах. Или просто в программах больше одного, двух мегабайтов.

5 Особенности Win32

5.1 Особенности выделения в стеке больших объёмов данных

Когда вы имеете код, которому вот-вот на несколько вызовов нужна память менее 2-4 килобайт, вы можете использовать хип. Однако, если ваша функция не рекурсивна, и не выполняется в нескольких потоках, быстрее и качественней выделить буфер или просто участок памяти в стеке. Это более эффективно, чем выделение памяти функциями Win32. И при этом, что для доступа к этой памяти можно использовать указатели в esi/edi - стековая память ни чем не отличается от обычной.
Но есть один, два подводных камня подстерегающих вас на пути.
Стек Windows, это обычная страничная память с правами чтения записи. Однако система благоразумно не выделяет все страницы памяти, а только резервирует их. Точнее говоря, на момент старта приложения, вы получаете такую картину:

 

Сторожевой блок
Выделенный блок
Сторожевой блок

При увеличении объёма стека картина изменяется:

Сторожевой блок
Выделенный блок
Выделенный блок
Сторожевой блок

И так далее, пока не будет исчерпано всё зарезервированное пространство, которое по умолчанию составляет 1M.
Windows использует механизм SEH, чтобы выделять дополнительное пространство стека. Сторожевой блок представляет собой виртуальную страницу памяти, при обращении к которой, возбуждается исключение. Результатом обработки такого исключения есть изменение прав доступа к данной странице, и передача памяти следующей страницы, которая становиться очередным сторожевым блоком. И так далее….
В действительности, механизм реализации стека несколько отличен для ядер NT и 9x типа. Механизм для 9x несколько проще, чем для NT. Главное отличие состоит в этом:
Формирование стека под 9x

Сторожевой блок
Выделенный блок
Выделенный блок
Сторожевой блок (Переданная страница с атрибутом NOACCESS)
Reserved Page NOACCESS
Reserved Page NOACCESS

Формирование стека под NT

Сторожевой блок
Выделенный блок
Выделенный блок
Сторожевой блок (Переданная страница с атрибутом PAGE_GUARD)
Reserved Page NOACCESS
Reserved Page NOACCESS

Вообще Windows 9x не поддерживает флаг PAGE_GUARD, который не отличается физически от флага NOACCESS.
Проблема возникает тогда, когда программа пытается обратиться к области стека за предел сторожевой страницы. В этот момент происходит исключение 0xC0000005, то есть, и, получив такое исключение, стандартный обработчик Windows, что самое интересное, даже не выдаст никакого окошка. Он просто уничтожит Ваш процесс.

Чтобы этого не произошло, следует выделять память объёмом больше 4k не сразу, а по частям. Сперва 4k, а потом остальную часть, если нужно тоже по 4k - размер виртуальной страницы на Windows.

5.2 Страничная адресация - благо?

Особенность EXE файла, от форматов для ДОС велика. И велика она хотя бы в том, что программа в формате PE Аля Windows - это буквально схема программы, которую уже можно загрузить без каких либо модификаций содержимого.

Новичкам, только осваивающим страничную адресацию, обычно кажется, что вот, наконец - это и есть панацея от бед сегментного мира DOS. Однако так только кажется. Если под DOS программа не привязана к сегменту, в котором выполняется, то код созданный линковщиком, навсегда и всесильно привязан к адресу 400000h. Именно эту цифру вы и получаете, когда пытаетесь вызвать GetModuleHandle(0)

А почему? А потому, что FLAT. Как говорится, за что боролись, в то и влипли. Это значит, что если загрузчику вздумается загрузить всю или часть программы по другому адресу он обязан будет модифицировать все ссылки и метки так, чтобы код корректно работал. И хотя суть идентификации проста - прибавить, отнять смещение от предполагаемого адреса загрузки, представьте себе, сколько нужно сделать операций хотя бы для средненькой программки.
И что самое увлекательное, что этот недостаток присущ не только EXE, но и DLL, которые отличаются от EXE только каким-нибудь флагом в PE заголовке и несколькими другими особенностями.

Самое обидное - это цена, которую приходиться платить, и эта цена не только во времени запуска приложения или 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.

© Edmond / HI-TECH

0 1.272
archive

archive
New Member

Регистрация:
27 фев 2017
Публикаций:
532