asm-extoptimize

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

крис касперски ака мыщъх, no-email

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

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

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

asm-extoptimize_image_0.jpg

Рисунок 1 программирование на ассемблере это путь в никуда, магистраль, ведущая в вечность!

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

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

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

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

Рисунок 2 некоторые программисты любят навороченные среды разработки типа WinAsmStudio (аналог MicrosoftVisualStudio) с окнами, мастерами и прочими «перламутровыми пуговицами»…

Грань между плюсами «мышиным» и «рукописным» кода очень тонка. Отклонение в одну строну — снижает продуктивность программы, в другую — увеличивает (причем зря) время разработки. Короче, не будем разводить демагогию, а рассмотрим фрагмент кода, запускающий процесс на выполнение стандартным способом через 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

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

push eax; lpApplicationName

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

Листинг 1 запуск процесса на выполнение через 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)

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

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

Листинг 2 запуск процесса на выполнение через «устаревшую» функцию WinExec - три команды и 1Eh байт машинного кода

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

Рисунок 3 …кто-то предпочитает простые, легковесные и аскетичные IDE по функциональности сравнимые с блокнотом (например, fasmw)

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

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

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

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

Листинг 3 запуск процесса на выполнение через функцию system библиотеки MSVCRT.DLL – три (две) команды и 1Dh (1Ah) байт кода

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

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

Рисунок 4 настоящие программисты (особенно старого поколения!) используют только консольные редакторы типа Multi-Edit или TSE-Pro с кучей специализированных функций и мощным макро-движком!

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

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

; переводим число в hex, dec и oct системы исчисления в ASCII-представлении subesp, 60h; резервируем память под буфер куда пойдет результат movebx, esp; сохраняем указатель на буфер в регистре EBX push eax; \ pusheax; + - передаем число для преобразования ф-ции sprintf push eax; / pushoffsets; передаем в стек указатель на строку спецификаторов pushebx; передаем указатель на буфер для получения результата callsprintf; прямой вызов функции sprintf ; вывод преобразованных данных на экран через диалоговое окно

xor eax,eax; eax := 0

push eax; uType

push eax; lpCaption

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

push eax; hWnd

call ds:[MessageBoxA]; косвенныйвызов API-функции MessageBox

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

sdb «%04X hex == %04d dec == %04o oct»,0

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

Листинг 4 фрагмент программы, принимающий число в регистре EAX и выводящий его на экран в шестнадцатеричной, десятеричной и восьмеричной формах

Рисунок 5 вывод на экран числа в разных системах исчисления

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

asm{ call GetVersion; прямойвызов API-функции } Листинг 5 «логичный», но неправильный способ вызова API-функций Компилируем файл с настройками по умолчанию и запускам. Программа тут же рушится. Почему? Смотрим в дизассемблере: .text:00401000 E8 FF 2F 00 00callnear ptr GetVersion … .idata:00404004 ?? ?? ?? ??extrn GetVersion:dword; DWORD GetVersion(void) Листинг 6 дизассемблер показываем, что вместо запланированного вызова API-функции, управление получает двойное слово с указателем на нее Так вот где собака порылась! Компилятор сгенерировал переход по адресу, где расположено двойное слово, принадлежащее таблице импорта (секция .idata) и содержащее указатель на API-функцию GetVersion. Рисунок 6 дизассемблер IDA Pro – мощное средство выявления ошибок в программах Неудивительно, что попытка интерпретации таблицы импорта как исполняемого кода приводит к краху и чтобы программа заработала правильно, необходимо использовать косвенную адресацию, заключив имя функции в квадратные скобки и выставив перед ними знак префикса cs: или ds: (без разницы, но ds работает чуточку быстрее). Без префиксов компилятор просто не поймет что мы от него хотим (любой ассемблер — понял бы). А между прочим, префикс — это не только лишний байт, но и большая головная боль для процессорного конвейера, приводящая к тормозам, впрочем, практически незаметным на фоне тормозов самих API-функций, особенно тем из них, что обращаются к ядру операционной системы (переход в режим ядра — это тысячи процессорных тактов!). Правильный код выглядит так: asm{

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

}

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

При вызове функций, представленных в двух вариантах — ASCII и UNICODE, мы можем указывать суффиксы A и W явно, а можем использовать «каноническое» имя функции без суффиксов, и тогда компилятор самостоятельно выберет нужный вариант в зависимости от настроек по умолчанию или ключей компиляции.

asm{ ; тут мы передаем аргументы callds:[CreateProcessW]; косвенный вызов функции с суффиксом W } Листинг 8 косвенный вызов функции CreateProcess с явным заданием суффикса W .text:0040101Edb3Eh; ds: .text:0040101EcallCreateProcessW; вызывается UNICODE-версияфункции Листинг 9 а вот его дизассемблерный листинг — вызывается именно та функция, которая была указана asm{

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

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

}

Листинг 10 косвенный вызов функции CreateProcess без указания суффиксов, предоставляющий компилятору свободу выбора одного из двух вариантов

.text:0040101Edb3Eh; ds:

.text:0040101Ecall CreateProcessA; вызывается ASCII-версияфункции

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

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

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

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

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

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

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

.text:00401018 systemprocnear; началофункции system

.text:00401018 55pushebp

.text:00401019 8B ECmovebp, esp

.text:0040101B 83 EC 10subesp, 10h

.text:0040101E 56pushesi

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

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

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

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

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

Рисунок 7 а вообще же, выбор конкретного инструментария — дело вкуса, о которых, как известно, не спорят, в частности, потребности мыщъх'а вполне удовлетворяет редактор, встроенный в FAR плюс несколько плагинов

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

Рассмотрим конкретный пример — функцию, одним из аргументов которой является указатель на переменную, принимающую возвращаемый результат: f(inta, word *x). Предельно компактный вызов (на 8086!) выглядел так:

pushsp; передаем указатель на x с одновременным выделением памяти под сам x

pushsi; передаем переменную a

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

Листинг 13 трюкаческий пример, передающий указатель на переменную с одновременным выделением под нее памяти (только для 8086/8088!)

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

▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄

1832:FFB0▐ 02 11 54 12 B2 FF 00 00 00 00 00 00 00 00 00 00 ▌

▐ ^^^^^ ^^^^^ ^^^^^

▐ | | |

▐ | push si |

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

Листинг 14 содержимое стека на момент вызова функции f на древней XT снабженной 8086 процессором

Рисунок 8 в отладчике хорошо видно, что в стек попадает уже уменьшенное значение регистра SP, в результате чего указатель *x указывает сам на себя!

Начиная с 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

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

| | | |

| pushesi | куда указывает esp

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

Листинг 15 содержимое стека на момент вызова функции f на современных процессорах

Рисунок 9 в отладчике хорошо видно, что в стек попадает такое значение регистра ESP, каким оно было _до_ модификации, в результате чего указатель *x указывает на _следующее_ двойное слово!

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

pusheax; выделяем память под переменную x (регистр — может быть любым)

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

pushesi; передаем переменную a

call f; зовем f

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

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

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

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

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

main()

{

charx[1024*1024]; выделяем 1 Мбайт стековой памяти return *x; обращаемся к наиболее «дальней» стековой ячейке

}

Листинг 17 пример программы на Си, выделяющий 1 Мбайт памяти под локальные переменные и обращающийся к самой «дальней» ячейке

.text:00401000 _mainproc near; CODE XREF: start+AF↓p

.text:00401000moveax, 100000h

.text:00401005callalloca_probe .text:0040100Amovsxeax, byte ptr [esp] .text:00401012addesp, 100000h .text:00401018retn .text:00401018 _mainendp; sp = 100000h .text:00401020 alloca_probeproc near; CODE XREF: _main+5↑p

.text:00401020

.text:00401020 arg_0= dword ptr 8

.text:00401020

.text:00401020pushecx

.text:00401021cmpeax, 1000h

.text:00401026leaecx, [esp+arg_0]

.text:0040102Ajbshort loc_401040

.text:0040102C

.text:0040102C loc_40102C:; CODE XREF: alloca_probe+1E↓j .text:0040102Csubecx, 1000h .text:00401032subeax, 1000h .text:00401037test[ecx], eax .text:00401039cmpeax, 1000h .text:0040103Ejnbshort loc_40102C .text:00401040 .text:00401040 loc_401040:; CODE XREF: alloca_probe+A↑j

.text:00401040subecx, eax

.text:00401042moveax, esp

.text:00401044test[ecx], eax

.text:00401046movesp, ecx

.text:00401048movecx, [eax]

.text:0040104Amoveax, [eax+4]

.text:0040104Dpusheax

.text:0040104Eretn

.text:0040104E alloca_probeendp Листинг 18 при выделении большого объема локальных переменных, компилятор вызывает недокументированную функцию alloca_probe, совершающую «пробежку» по стеку и при необходимости отодвигающую сторожевую страницу на требуемое расстояние, то же самое необходимо делать и в ассемблерных программах!

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

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

asm-extoptimize_image_9.jpg

Рисунок 10 проблемы выравнивания в оптимизации

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

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

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

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

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

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

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

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

…; (если надо — выделяем память для лок. пер.)

MOV [EBP-10h], arg2; кладем на вершину стека крайний левый аргумент

MOV [EBP-0Ch], arg1; кладем следующий аргумент

CALLfunc_1; вызываем функцию func_1

; не выталкиваем аргументы из стека

rool:; демонстрация вызова функции в цикле

MOV [EBP-10h], arg5; кладем на вершину стека крайний левый аргумент

MOV [EBP-0Ch], arg4; \

MOV [EBP-08h], arg3; + кладем все следующие аргументы

MOV [EBP-04h], arg2; +

MOV [EBP-00h], arg1; /

CALLfunc_2; вызываем функцию func_2

; не выталкиваем аргументы из стека

DECECX; мотаем цикл

JNZrool; (только не спрашивайте кто инициализирует ECX!)

MOV [EBP-10h], EAX; кладем на вершину стека крайний левый аргумент

CALLfunc_3; вызываем функцию func_3

MOVESP,EBP; закрываем кадр, освобождая память аргументов

POPEBP; восстанавливаем EBP

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

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

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

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

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

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

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