Готовые функции на блюдечке
Апеллируя к житейской мудрости пса Фафика, пришедшего к выводу, что есть колбасу, иметь колбасу и пахнуть колбасой— это три большие разницы, мы можем сказать: изучать ассемблер, программировать на ассемблере и хвастаться знаниями ассемблера — совсем не одно и тоже!
Каждый уважающий себя программист должен пройти стадию познания "голого" железа, системных вызовов, чистого 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% выигрыш. Но это еще не значит, что ассемблер бесполезен. Просто, как и любой другой инструмент, он имеет границы своей применимости, с которыми следует считаться, чтобы не попасть впросак!