Ассемблер - экстремальная оптимизация

         

Готовые функции на блюдечке


Апеллируя к житейской мудрости пса Фафика, пришедшего к выводу, что есть колбасу, иметь колбасу и пахнуть колбасой— это три большие разницы, мы можем сказать: изучать ассемблер, программировать на ассемблере и хвастаться знаниями ассемблера — совсем не одно и тоже!

Каждый уважающий себя программист должен пройти стадию познания "голого" железа, системных вызовов, чистого API, чтобы знать как устроена и работает операционная система, но писать большой GUI-проект с использованием win32 API — это медленное и мучительное самоубийство.

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

Естественно, не нужно впадать и в другую крайность, используя для постройки собачьей конуры бетонные блоки и подъемный кран, типа визуальных средств разработчики, к кучей мастеров. Монументально, но слишком тяжеловесно даже для современных процессоров. Все равно ведь программировать приходится руками, думать — головой, а мышью и мастерами, можно только соорудить только то, для чего они изначально предназначались, то есть быстро собрать еще одну типовую конуру, рыночная стоимость (в силу законов конкуренции) будет близка к нулю.



логичный", но неправильный способ вызова API-функций


Компилируем файл с настройками по умолчанию и запускам. Программа тут же рушится. Почему? Смотрим в дизассемблере:

.text:00401000 E8 FF 2F 00 00            call   near ptr GetVersion

...

.idata:00404004 ?? ?? ?? ??       extrn GetVersion:dword     ; DWORD GetVersion(void)



трюкаческий пример


Подвох в том, что переменная x возвращается в ячейке памяти, выделенной PUSH SP! То есть указатель на x указывает сам на себя, что хорошо видно в отладчике:

----------------------------------------------------------------

1832:FFB0¦ 02 11 54 12  B2 FF  00 00   00 00 00 00  00 00 00 00 ¦

         ¦ ^^^^^ ^^^^^  ^^^^^

         ¦    |     |      |



         ¦    |   push si  |

      адрес возврата   push sp



демонстрация передачи аргументов cdecl-функциям через однократно выделяемый блок памяти


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

как аргументы. Локальные переменные (если они есть) располагаются ниже их.

То есть, если мы резервируем 10h байт под все аргументы, то первый слева аргумент должен помещаться в ячейку [EBP-10h], второй — в [EBP-0Ch] и так далее. Главное не перепутать порядок засылки аргументов. По соглашению cdecl переменные передаются справа налево, следовательно, в момент вызова функции на вершине стека лежит крайний левый аргумент, а под ним — все остальные.

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



запуск процесса на выполнение через win32 API – 12 команд и 73h байта


Ассемблированный код занимает 1Fh байт и еще 54h байта расходуются на структуры PROCESS_INFORMATION и STARTUPINFO плюс длина имени файла. А вот что получится, если воспользоваться морально "устаревшей" функцией WinExec, доставшийся в наследство от 16-разрядной старушки Windows (вопреки распространенному заблуждению, она реализована одновременно как 16- и 32-разрядная функция, а потому перехода в 16-разрядный режим при вызове WinExec из 32-разрядного кода не происходит, а, значит, не происходит и падения производительности):

       push 00h             ; uCmdShow (короче чем XOR EAX,EAX/PUSH EAX)

       push offset file_name      ; имя исполняемого файла с аргументами

       call ds:[WinExec]    ; косвенный вызов API-функции через IAT



запуск процесса на выполнение


Всего три машинных команды, укладывающиеся в 1Eh байт (без учета имени файла) и никаких дополнительных структур! Расплатой за оптимизацию становится невозможность создания отладочных или "замороженных" процессов, не говоря уже про атрибуты безопасности и прочую хрень, реально необходимую в одном случаев из десяти-двадцати случаев, а то и реже.



запуск процесса на выполнение


Тоже самое относится и к функциям файлового ввода/вывода, преобразованиям данных и т. д., и т. п. Никто же не будет спорить, что вызов fopen намного короче, чем CreateFile, а скорость исполнения у них практически та же самая, тем более что, библиотека MSVCRT.DLL всегда

присутствует памяти, поскольку используются системными процессами. Windows просто спроецирует ее на наше адресное пространство — вот и все! Никакого увеличения потребляемой памяти не произойдет!

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



дизассемблер показываем


Так вот где собака порылась! Компилятор сгенерировал переход по адресу, где расположено двойное слово, принадлежащее таблице импорта (секция .idata) и содержащее указатель на API-функцию GetVersion.



не логичный", но правильный способ вызова API-функций


При вызове функций, представленных в двух вариантах — ASCII и UNICODE, мы можем указывать суффиксы A и W

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

__asm{

       ; тут мы передаем аргументы

       call ds:[CreateProcessW]          ; косвенный вызов функции с суффиксом W

}



косвенный вызов функции CreateProcess с явным заданием суффикса W


.text:0040101E             db     3Eh           ; ds:

.text:0040101E             call   CreateProcessW       ; вызывается

UNICODE-версия функции



а вот его дизассемблерный листинг — вызывается именно та функция, которая была указана


__asm{

       ; тут мы передаем аргументы

       call ds:[CreateProcess]           ; косвенный вызов функции без суффиксов

}



косвенный вызов функции


.text:0040101E             db     3Eh           ; ds:

.text:0040101E             call    CreateProcessA     ; вызывается ASCII-версия

функции



компилятор выбрал ASCII-вариант, что соответствует его настройкам по умолчанию


А вот при вызове функций типа system квадратные скобки ставить уже не надо, точнее нельзя! Функция system является частью библиотеки времени исполнения (RTL — Run Time Library), линкуемой статическим образом, поэтому call system сработает как и ожидалось, а вот call ds:[system]

передаст управление по адресу 83EC8B55h, попытавшись проинтерпретировать начало функции system как указатель:

.text:0040100B 3E FF 15 1A 10 40 00      call   dword ptr system

                                  ; косвенный вызов статически линкуемой функции

                                  ; приводит к тому, что первые 4 байта функции

                                  ; интерпретируются как указатель и управление

                                  ; передается по адресу 83EC8B55h

...

.text:00401018 system                    proc   near   ; начало

функции system

.text:00401018 55                 push   ebp

.text:00401019 8B EC              mov    ebp, esp

.text:0040101B 83 EC 10           sub    esp, 10h

.text:0040101E 56                 push   esi



косвенный вызов статически линкуемых функций приводит к краху


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

При программировании на чистом ассемблере подобная проблема не возникает, поскольку имена и типы вызовов функций всегда объявляются вручную (или через включаемые файлы) и мы заранее знаем как именно интерпретирует их транслятор. При работе с ассемблерными вставками подобной определенности у нас нет. В частности, если компилятор решил использовать инкрементную линковку, то имя функции интерпретируется уже не как указатель на двойное слово из таблицы импорта, а как указатель на "переходник", представляющего собой jmp [pFunc], то есть нам квадратные скобки снова отпадают!

Инкрементная линковка представляет собой попытку эмуляции секции .got, имеющийся в elf-файлах, но отсутствующей в Windows, и обычно включается в режиме оптимизации, а в отладочном варианте — отсутствующей. Сюрприз, да? При изменении ключей компиляции ассемблерные вставки изменяют свое поведение, причем безо всякого предупреждения!

Короче говоря, внешние функции из ассемблерных вставок лучше не вызывать, а если и вызывать, то очень осторожно.



трюкаческий пример, портированный на 286+ процессоры


Несмотря на то, что 8086/8088 процессоры уже давно не встречаются в дикой природе (ну разве что в виде эмуляторов, да и то…), многие программы, написанные под них, актуальны и сегодня. Это касается как уже откомпилированного машинного кода, так и различных ассемблерных библиотек, переносимых под современные процессоры. Одна из причин, по которой они могут не работать — это и есть различие в логике обработке команды PUSH ESP.

Вообще же, динамическое выделение памяти посредством PUSH + фиктивный регистр — вполне законный примем, которым пользуются не только люди, но и компиляторы. Это намного компактнее, чем обращение к локальным/глобальным переменным, выделяемым классическим способом.

Естественно, большие объемы памяти лучше всего выделять с помощью SUB ESP, XXh, но при этом следует помнить как минимум о двух вещах. Первое и главное — Windows-системы выделяют стековую память динамически, используя для этого специальную "сторожевую" страницу памяти (page guard). Как только к ней происходит обращение — система выделяет еще одну или несколько страниц памяти, перемещая сторожевую страницу наверх (в сторону меньших адресов памяти). При последовательном "росте" стека все работает нормально, но если попытаться прыгнуть за сторожевую страницу, сразу же возникнет непредвиденное исключение — ведь никакой памяти по данному адресу еще нет — и работа программы завершается в аварийном режиме. То есть, если у нас есть к примеру 1 Мбайт стекового пространства, это еще не значит, что код SUB ESP, 10000h/MOV [ESP],EAX

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

Компиляторы делают это автоматически, а вот многие ассеблерщики о таком коварстве Windows зачастую даже и не подозревают, а потом упорно ищут бага в своей программе, не понимая почему она не работает!

main()

{

       char x[1024*1024];   // выделяем 1 Мбайт стековой памяти

       return *x;           // обращаемся к наиболее "дальней" стековой ячейке

}



пример программы на


.text:00401000 _main       proc near            ; CODE XREF: start+AFvp

.text:00401000                    mov    eax, 100000h

.text:00401005                    call   __alloca_probe

.text:0040100A                    movsx  eax, byte ptr [esp]

.text:00401012                    add    esp, 100000h

.text:00401018                    retn

.text:00401018 _main       endp                 ; sp =  100000h

.text:00401020 __alloca_probe     proc near            ; CODE XREF: _main+5^p

.text:00401020

.text:00401020 arg_0       = dword ptr  8

.text:00401020

.text:00401020                    push   ecx

.text:00401021                    cmp    eax, 1000h

.text:00401026                    lea    ecx, [esp+arg_0]

.text:0040102A                    jb     short loc_401040

.text:0040102C

.text:0040102C loc_40102C:                      ; CODE XREF: __alloca_probe+1Evj

.text:0040102C                    sub    ecx, 1000h

.text:00401032                    sub    eax, 1000h

.text:00401037                    test   [ecx], eax

.text:00401039                    cmp    eax, 1000h

.text:0040103E                    jnb    short loc_40102C

.text:00401040

.text:00401040 loc_401040:                      ; CODE XREF: __alloca_probe+A^j

.text:00401040                    sub    ecx, eax

.text:00401042                    mov    eax, esp

.text:00401044                    test   [ecx], eax

.text:00401046                    mov    esp, ecx

.text:00401048                    mov    ecx, [eax]

.text:0040104A                    mov    eax, [eax+4]

.text:0040104D                    push   eax

.text:0040104E                    retn

.text:0040104E __alloca_probe     endp



при выделении большого


Но коварство Windows на этом не заканчиваются. Многие API-функции неявно закладываются на выравнивание стека и если нам, к примеру, требуется ровно 69h байт стековой памяти, ни в коем случае нельзя писать SUB ESP,69h, иначе все рухнет! Следует округлить 69h по границе двойного слова и запросить 6Ch байт или... между актами выделения/освобождения памяти не вызывать никаких API-функций.

Часто, в погоне за оптимизацией, программисты, борющиеся за каждый байт памяти, забывают о выравнивании и… часами ищут причину, по которой оптимизированный вариант программы отказывается работать.



некоторые программисты


Грань между плюсами "мышиным" и "рукописным" кода очень тонка. Отклонение в одну строну — снижает продуктивность программы, в другую — увеличивает (причем зря) время разработки. Короче, не будем разводить демагогию, а рассмотрим фрагмент кода, запускающий процесс на выполнение стандартным способом через win32 API-функцию CreateProcess:

       xor eax,eax          ; eax := 0

       push offset pi              ; lpProcessInformation

       push offset sis      ; lpStartupInfo

       push eax             ; lpCurrentDirectory

       push eax             ; lpEnvironment

       push eax             ; dwCreationFlags

       push eax             ; bInheritHandles

       push eax             ; lpThreadAttributes

       push eax             ; lpProcessAttributes

       push offset file_name      ; имя исполняемого файла с аргументами

       push eax             ; lpApplicationName

       call ds:[CreateProcess]; косвенный

вызов API-функции

через IAT



кто-то предпочитает


Но это еще не предел оптимизации! Воспользовавшись функцией system

из библиотеки MSVCRT.DLL (которая активно используется многими приложениями и практически всегда "болтается" в памяти), мы сократим код до 1Dh байт или даже до 1Ah, если отсрочим восстановление стека, выполнив команду add esp, x в конце функции, выталкивая все аргументы одним махом (подробнее см. "все аргументы в одном месте"):

       push offset file_name      ; имя исполняемого файла с аргументами

       call system          ; прямой вызов функции (почему так — см. врезку)

       add esp,4            ; выталкиваем аргументы из стека (можно сделать позже)



настоящие программисты


Приведенный ниже пример распечатывает число, содержащееся в регистре EAX в шестнадцатеричной, десятичной и восьмеричной форме, автоматически дописывая ведущие нули, растягивающие число до 4х разрядов. А теперь попробуйте осуществить тоже самое без использования библиотек и сравните размер полученного кода!

       mov eax, 666h        ; число, которое необходимо вывести на экран

      

       ; // переводим число в hex, dec и oct системы исчисления в ASCII-представлении

       sub esp, 60h         ; резервируем память под буфер куда пойдет результат

       mov ebx, esp         ; сохраняем указатель на буфер в регистре EBX

       push eax             ; \

       push eax             ;  + - передаем число для преобразования ф-ции sprintf

       push eax             ; /

       push offset s        ; передаем в стек указатель на строку спецификаторов

       push ebx             ; передаем указатель на буфер для получения результата

       call sprintf         ; прямой вызов функции sprintf

      

       ; // вывод преобразованных данных на экран через диалоговое окно

       xor eax,eax          ; eax := 0

       push eax             ; uType

       push eax             ; lpCaption

       push ebx             ; lpText

(наши преобразованные данные)

       push eax             ; hWnd

       call ds:[MessageBoxA]      ; косвенный

вызов API-функции MessageBox

      

       add esp, 60h + (5*4) ; выталкиваем аргументы из стека и уничтожаем буфер

       ...

       ...

       ...

s      db "%04X hex == %04d dec == %04o oct",0

; строка спецификаторов



дизассемблер IDA Pro – мощное средство выявления ошибок в программах


Неудивительно, что попытка интерпретации таблицы импорта как исполняемого кода приводит к краху и чтобы программа заработала правильно, необходимо использовать косвенную адресацию, заключив имя функции в квадратные скобки и выставив перед ними знак префикса cs:

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

Правильный код выглядит так:

__asm{

       call ds:[GetVersion]              ; косвенный

вызов API-функции

}



в отладчике хорошо видно


Начиная с 80286 логика работы инструкции PUSH ESP предательским образом изменилась и теперь процессор помещает в стек такое значение регистра ESP, каким оно было до модификации (кстати, псевдокод команды PUSH, приведенный в руководстве Intel содержит ошибку, из которой следует, что в стек помещается уменьшенное значение ESP, хотя на практике это не так!).

И пока программисты спорят какое из двух решений "идеологически" более "правильное", прежний код отказывается работать, потому что команда PUSH ESP вместо указателя, указывающего на себя, теперь заталкивает в стек указатель на следующее двойное слово!

0012FF68  0E 10 40 00  34 FA 12 00  74 FF 12 00 00 00 00 00

          ^^^^^^^^^^^  ^^^^^^^^^^^  ^^^^^^^^^^^ ^^^^^^^^^^^

               |          |               |          |

               |       push esi           |  куда указывает esp

         адрес возврата               push esp



в отладчике хорошо видно


Поэтому, при переходе с 8086 на 286+ приходится добавлять "лишнюю" команду PUSH EAX, резервирующую ячейку на стеке, на которую будет указывать значение ESP, засланное в стек инструкцией PUSH ESP

       push eax      ; выделяем память под переменную x

(регистр — может быть любым)

       push esp      ; передаем указатель на x как аргумент функции f

       push esi      ; передаем переменную a

       call f        ; зовем f



>>> Врезка вызов API-функций из ассемблерный вставок


При вызове API и DLL-функций из ассемблерных вставок возникает множество проблем, довольно туманно описанных в документации, прилагаемой к компилятору. Возьмем, к примеру, Microsoft Visual C++ и попробуем вызывать функцию GetVersion

так, как мы бы сделали бы это на чистом ассемблере:

__asm{

       call GetVersion      ; прямой

вызов API-функции

}



Все аргументы в одном месте


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

Во-первых, как уже говорилось, стек можно балансировать не сразу после выхода из функции, а спустя некоторое время, объединяя несколько команд ADDESP,XXh

в одну (конкретный пример показан в листинге 4). Однако, это не работает с циклами и ветвлениями. Функция, вызываемая в цикле, буквально пожирает память, если только аргументы немедленно не выталкиваются из стека. Но памяти — много и на небольшом количестве итераций с этим еще можно хоть как-то смириться (правда, выигрыша в производительности мы уже не получим, будет просто чудо, если такой объем вообще уместиться в кэше первого уровня).

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

А что если… передавать аргументы через однократно выделенный регион памяти? Это обеспечит максимальную скорость и минимальные потребности в стеке. Мы будем действовать так: на входе в функцию резервируем блок памяти равный наибольшему объему аргументов, передаваемых функции, а затем просто кладем туда аргументы по ходу дела. Регистр ESP уже не "пляшет" и циклы выполняются с предельной скоростью. Единственный минус в том, что передавать аргументы приходится не инструкций PUSH, а более длинной командой MOV [EBP?XXh],YYYY.

Конкретная реализация может выглядеть, например, так:

       PUSH EBP                   ; сохраняем EBP

       MOV EBP,ESP                ; открываем кадр стека

       SUB ESP,10h                ; выделяем память для аргументов



Путь начинающего ассемблерщика не только


Путь начинающего ассемблерщика не только долог, но еще и тернист. Повсюду торчат острые шипы, дорогу преграждают разломы, ловушки и капканы. В темной чаще горят злые глаза, доносятся какие-то ухающие звуки и прочие неблагоприятные факторы, нагнетающие мрачную атмосферу и серьезно затрудняющую продвижение вперед.
Большинство учебников затрагивают только MS-DOS, крайне поверхностно описывая практические проблемы программирования под Windows. Мыщъх делиться с читателями рецептами, которые известны любому профессионалу, но совершенно неочевидны новичку.


Выделение памяти на стеке


На процессорах 8086/8088 существовала замечательная возможность— затолкать в стек аргумент-указатель с одновременным выделением памяти всего одной (!) однобайтовой (!) машинной командой PUSH ESP, которая сначала уменьшала значение ESP, а только потом заталкивала его в стек. То есть, в стек попадало уже уменьшенное значение ESP, что способствовало трюкачеству.

Рассмотрим конкретный пример — функцию, одним из аргументов которой является указатель на переменную, принимающую возвращаемый результат: f(int a, word

*x). Предельно компактный вызов (на 8086!) выглядел так:

       push sp       ; передаем указатель на x

с одновременным выделением памяти под сам x

       push si       ; передаем переменную a

       call f ; зовем функцию



Системное программирование хранит множество секретов,


Системное программирование хранит множество секретов, загадок и тайн, постепенно становясь уделом небольшой горстки профессионалов, в то время как мир дружно сходит с ума, подсаживаясь на языки высокого уровня, которые чем дальше — тем все выше и выше. Об ассемблере вспоминают только тогда, когда требуется что-то очень сильно нестандартное, с чем компилятор уже не справляется или сгенерированный им код не отвечает требованиям производительности.
Вот тут-то и выясняется, что специалистов, владеющих ассемблеров, практически нет, а те что есть, уже утратили свои навыки и оптимизируют намного хуже компиляторов, разработчики которых за последние несколько лет сделали качественный рывок вперед и теперь просто так их не обгонишь! Сам по себе ассемблер не обеспечивает ни компактности кода, ни высокой скорости. Все решают хитрые трюки и приемы программирования, находчивость и инженерная смекалка наконец!
Главное — выбрать верную стратегию поведения. Не пытаться сократить программу на пару байт, которые все равно будут потеряны при выравнивании, а реально оценивать свой творческий потенциал, сопоставляя его с целями и задачами операциями. Алгоритмическая оптимизация зачастую ускоряет программу в десятки раз, в то время как перенос Сишного кода на ассемблер дает в среднем случае 10%-15% выигрыш. Но это еще не значит, что ассемблер бесполезен. Просто, как и любой другой инструмент, он имеет границы своей применимости, с которыми следует считаться, чтобы не попасть впросак!