February 29, 2024

Фундаментальные основы хакерства. Находим математические операторы в дизассемблированных программах

Крис Касперски, Юрий Язев

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

ИДЕНТИФИКАЦИЯ ОПЕРАТОРА +

В общем слу­чае опе­ратор + тран­сли­рует­ся либо в машин­ную инс­трук­цию ADD, «перема­лыва­ющую» целочис­ленные опе­ран­ды, либо, с уче­том наличия в про­цес­соре под­дер­жки SSE (а без нее про­цес­соры уже дав­ным‑дав­но не выпус­кают­ся), в инс­трук­цию ADDSS, обра­баты­вающую вещес­твен­ные зна­чения оди­нар­ной точ­ности, и ADDSD — двой­ной точ­ности.

Оп­тимизи­рующие ком­пилято­ры могут заменять ADD xxx, 1 более ком­пак­тной коман­дой INC xxx, а конс­трук­цию c = a + b + const тран­сли­ровать в машин­ную инс­трук­цию LEA c, [a + b + const]. Такой трюк поз­воля­ет одним махом скла­дывать нес­коль­ко перемен­ных, воз­вра­тив получен­ную сум­му в любом регис­тре обще­го наз­начения, — необя­затель­но в левом сла­гаемом, как это тре­бует мне­мони­ка коман­ды ADD. Одна­ко LEA не может быть непос­редс­твен­но деком­пилиро­вана в опе­ратор +, пос­коль­ку она исполь­зует­ся не толь­ко для опти­мизи­рован­ного сло­жения (что, в общем‑то, толь­ко побоч­ный про­дукт ее деятель­нос­ти), но и по сво­ему пря­мому наз­начению — для вычис­ления эффектив­ного сме­щения.

Рас­смот­рим при­мер demo_plus, демонс­три­рующий исполь­зование опе­рато­ра + со зна­чени­ями оди­нар­ной точ­ности:

#include <iostream>int main(){ float a = 0.7f, b = 1.4f, c; c = a + b; std::cout << c << std::endl; c = c + 0.3f; std::cout << c << std::endl;}

Ре­зуль­тат выпол­нения demo_plus

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

Ре­зуль­тат тран­сля­ции это­го при­мера ком­пилято­ром Microsoft Visual C++ 2022 с отклю­чен­ной опти­миза­цией дол­жен выг­лядеть приб­лизитель­но так:

; int __cdecl main(int argc, const char **argv, const char **envp)main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓ovar_c = dword ptr -18hvar_a = dword ptr -14hvar_b = dword ptr -10h; Резервируем память для локальных переменных sub rsp, 38h; Загружаем в регистр XMM0 значение из сегмента данных только для чтения movss xmm0, cs:__real@3f333333; Перекладываем это значение из регистра в переменную var_a movss [rsp+38h+var_a], xmm0; Загружаем в регистр следующее по порядку значение movss xmm0, cs:__real@3fb33333; Перекладываем его в переменную var_b movss [rsp+38h+var_b], xmm0; Первое значение возвращаем в регистр XMM0 из переменной var_a movss xmm0, [rsp+38h+var_a]; Складываем содержимое XMM0 со значением переменной var_b addss xmm0, [rsp+38h+var_b]; Копируем сумму var_a и var_b в переменную var_c, следовательно, var_c = var_a + var_b movss [rsp+38h+var_c], xmm0; Готовим параметры для передачи оператору <<; Второй слева — переменная var_c movss xmm1, [rsp+38h+var_c]; Первый слева — формат вывода mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Собственно вызов оператора вывода строки call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float); Плюс вывод символа новой строки lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Загружаем в XMM0 значение переменной var_c... movss xmm0, [rsp+38h+var_c]; ...прибавляем к этому значению значение из сегмента данных только для чтения addss xmm0, cs:__real@3e99999a; Обновляем var_c: var_c = var_c + const movss [rsp+38h+var_c], xmm0; Готовим параметры для передачи оператору <<; Второй слева — переменная var_c movss xmm1, [rsp+38h+var_c]; Первый слева — формат вывода mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Собственно вызов оператора вывода строки call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float); Плюс вывод символа новой строки lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 38h retnmain endp

А теперь пос­мотрим, как будет выг­лядеть тот же самый при­мер, ском­пилиро­ван­ный с клю­чом /Ox (мак­сималь­ная опти­миза­ция):

main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓o sub rsp, 28h; Как ловко! Компилятор подсчитал сумму во время компиляции; и подставил ее непосредственно для вывода; С чего бы ему идти на такие хитрости без последствий?; Значения-то представляют собой константы,; которые хранятся в сегменте данных только для чтения movss xmm1, cs:__real@40066666 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Второе для вывода значение — с ним такая же история, как с первым значением movss xmm1, cs:__real@40199999 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 28h retnmain endp

В опти­мизи­рован­ном вари­анте нет ни намека на сло­жение или дру­гие ариф­метичес­кие опе­рации! С воз­веден­ным фла­гом /O2 (мак­сималь­ная опти­миза­ция по ско­рос­ти) ком­пилятор соз­дает точ­но такой же код.

Embarcadero C++Builder генери­рует похожий код, а в слу­чае опти­миза­ции — еще хуже. Поэто­му при­водить резуль­таты его тру­да бес­смыс­ленно — никаких новых «изю­минок» они в себе не несут.

ИДЕНТИФИКАЦИЯ ОПЕРАТОРА –

В общем слу­чае опе­ратор – тран­сли­рует­ся либо в машин­ную инс­трук­цию SUB (если опе­ран­ды — целочис­ленные зна­чения), либо в инс­трук­цию SUBSS (если опе­ран­ды — вещес­твен­ные зна­чения оди­нар­ной точ­ности) или в SUBSD (ког­да опе­ран­ды двой­ной точ­ности). Опти­мизи­рующие ком­пилято­ры могут заменять SUB xxx, 1 более ком­пак­тной коман­дой DEC xxx, а конс­трук­цию SUB a, const тран­сли­ровать в ADD a, –const, которая ничуть не ком­пак­тнее и нис­коль­ко не быс­трей (и та и дру­гая укла­дыва­ется в один такт). Одна­ко хозя­ин (ком­пилятор) — барин.

По­кажем это на при­мере demo_minus, демонс­три­рующем исполь­зование опе­рато­ра – со зна­чени­ями двой­ной точ­ности:

#include <iostream>int main(){ double a = 3.1, b = 1.6, c; c = a - b; std::cout << c << std::endl; c = c - 10; std::cout << c << std::endl;}

Ре­зуль­тат выпол­нения demo_minus

Не­опти­мизи­рован­ный вари­ант будет выг­лядеть приб­лизитель­но так:

main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓ovar_c = qword ptr -28hvar_a = qword ptr -20hvar_b = qword ptr -18h; Резервируем память для локальных переменных sub rsp, 48h; Загружаем в регистр XMM0 значение из сегмента данных только для чтения movsd xmm0, cs:__real@4008cccccccccccd; Перекладываем это значение из регистра в переменную var_a movsd [rsp+48h+var_a], xmm0; Загружаем в регистр, заменяя имеющееся там значение следующим по порядку значением movsd xmm0, cs:__real@3ff999999999999a; Перекладываем его в переменную var_b movsd [rsp+48h+var_b], xmm0; Из переменной var_a возвращаем значение в регистр XMM0 movsd xmm0, [rsp+48h+var_a]; Вычитаем из var_a значение переменной var_b, записывая результат в XMM0 subsd xmm0, [rsp+48h+var_b]; Записываем в var_c разность var_a и var_b:; var_c = var_a — var_b movsd [rsp+48h+var_c], xmm0 movsd xmm1, [rsp+48h+var_c] mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Загружаем в регистр XMM0 значение переменной var_c — готовим к вычислению movsd xmm0, [rsp+48h+var_c]; Вычитаем из var_c значение, взятое из сегмента данных только для чтения; При этом результат записываем в регистр XMM0 subsd xmm0, cs:__real@4024000000000000; Обновляем содержимое переменной var_c:; var_c = var_c — const movsd [rsp+48h+var_c], xmm0 movsd xmm1, [rsp+48h+var_c] mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 48h retnmain endp

А теперь рас­смот­рим опти­мизи­рован­ный вари­ант того же при­мера:

main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓o sub rsp, 28h; Компилятор подсчитал разность во время трансляции и подготовил ее значение для вывода movsd xmm1, cs:__real@3ff8000000000000 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Результат второй разности также подсчитан во время компиляции movsd xmm1, cs:__real@c021000000000000 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 28h retnmain endp

Embarcadero C++Builder генери­рует прак­тичес­ки иден­тичный код без опти­миза­ции и нам­ного хуже с вклю­чен­ной опти­миза­цией, поэто­му здесь он не рас­смат­рива­ется.

ИДЕНТИФИКАЦИЯ ОПЕРАТОРА /

В общем слу­чае опе­ратор / тран­сли­рует­ся либо в машин­ную инс­трук­цию DIV (без­зна­ковое целочис­ленное деление), либо в IDIV (целочис­ленное деление со зна­ком), либо в DIVSS (деление вещес­твен­ных зна­чений оди­нар­ной точ­ности) или DIVSD (деление вещес­твен­ных зна­чений двой­ной точ­ности).

Ес­ли делитель кра­тен сте­пени двой­ки, то DIV заменя­ется более быс­тро­дей­ству­ющей инс­трук­цией битово­го сдви­га впра­во SHR a, N, где a — делимое, N — показа­тель сте­пени с осно­вани­ем два.

Нес­коль­ко слож­нее про­исхо­дит быс­трое деление зна­ковых чисел. Недос­таточ­но выпол­нить ариф­метичес­кий сдвиг впра­во (коман­да ариф­метичес­кого сдви­га впра­во SAR запол­няет стар­шие биты с уче­том зна­ка чис­ла), ведь если модуль делимо­го мень­ше модуля делите­ля, то ариф­метичес­кий сдвиг впра­во сбро­сит все зна­чащие биты в «битовую кор­зину», в резуль­тате чего получит­ся 0xFFFFFFFF, то есть -1, в то вре­мя как пра­виль­ный ответ — ноль.

Во­обще же деление зна­ковых чисел ариф­метичес­ким сдви­гом впра­во дает округле­ние в боль­шую сто­рону, что сов­сем не вхо­дит в наши пла­ны. Для округле­ния зна­ковых чисел в мень­шую сто­рону необ­ходимо перед выпол­нени­ем сдви­га добавить к делимо­му чис­ло 2N – 1, где N — количес­тво битов, на которые сдви­гает­ся чис­ло при делении. Лег­ко видеть, что это при­водит к уве­личе­нию всех сдви­гаемых битов на еди­ницу и перено­су в стар­ший раз­ряд, если хотя бы один из них не равен нулю.

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

Здесь N — раз­рядность чис­ла. Выходит, грань меж­ду умно­жени­ем и делени­ем очень тон­кая, а их иден­тифика­ция доволь­но слож­ная. Рас­смот­рим при­мер demo_divide, в нем, кро­ме все­го про­чего, мы уви­дим выпол­нение деления с опе­ран­дами раз­личных типов:

#include <iostream>int main(){ unsigned int a = 512; std::cout << a / 256 << " " << a / 64 << std::endl; int b = 256; std::cout << b / 32 << " " << b / 10 << std::endl; double d = 0x1024; std::cout << d / 512 << " " << d / 0x128 << std::endl; float f = 102.f; std::cout << f / 51.f << " " << f / 25.5f << std::endl;}

Ре­зуль­тат выпол­нения demo_divide

Ре­зуль­тат его ком­пиляции ком­пилято­ром Microsoft Visual C++ 2022 с отклю­чен­ной опти­миза­цией дол­жен выг­лядеть так:

; int __cdecl main(int argc, const char **argv, const char **envp)main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓ovar_a = dword ptr -38hvar_b = dword ptr -34hvar_f = dword ptr -30hvar_2C = dword ptr -2Chvar_28 = dword ptr -28hvar_24 = dword ptr -24hvar_d = qword ptr -20hvar_18 = qword ptr -18h; Резервируем память для локальных переменных sub rsp, 58h; Присваиваем переменной var_a значение 0x200 (512 в десятичной системе) mov [rsp+58h+var_a], 200h; Обнуляем EDX xor edx, edx; В регистр EAX копируем значение переменной var_a mov eax, [rsp+58h+var_a]; Копируем в регистр ECX значение 0x40 (64 в десятичной системе); Отсюда становится ясно, что это подготовка делителя mov ecx, 40h ; '@'; Выполняем деление: EAX = EAX / ECX. Обрати внимание, что для деления используется инструкция DIV. Можно сделать вывод, что мы имеем дело с беззнаковыми целочисленными значениями div ecx; Частное копируем в переменную var_2C mov [rsp+58h+var_2C], eax xor edx, edx; Очевидно, далее идет подготовка делимого и делителя; Делимое: 512 mov eax, [rsp+58h+var_a]; Делитель: 0x100 (256 в десятичной системе) mov ecx, 100h; Выполняем деление: EAX = EAX / ECX div ecx; Результат второго деления копируем в регистр EDX, тем самым готовим параметр перед вызовом оператора << mov edx, eax; Выводим строку на экран mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Первое слева число call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(uint) lea rdx, _Val ; " " mov rcx, rax ; _Ostr; «Пробел» — на вывод call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov ecx, [rsp+58h+var_2C] mov edx, ecx mov rcx, rax; Выводим частное от первого деления — второе слева число call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(uint) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; ...символ перехода на новую строку call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Переменную var_b инициализируем числом 0x100 (256 в десятичной системе счисления) mov [rsp+58h+var_b], 100h; Значение переменной var_b копируем в регистр EAX mov eax, [rsp+58h+var_b]; Расширяем EAX до четверного слова — EDX:EAX cdq; Заносим в ECX значение 0xA (10 в десятичной системе) mov ecx, 0Ah; Осуществляем деление, учитывая знак: EDX:EAX на 0xA, занося частное в EAX; EAX = var_b / 0xA; Судя по инструкции IDIV, имеем дело с целочисленными значениями со знаком idiv ecx; Частное помещаем в переменную var_28 mov [rsp+58h+var_28], eax; Готовимся выполнять второе в строке деление (второе справа):; подготавливаем делимое mov eax, [rsp+58h+var_b]; расширяем EAX до четверного слова — EDX:EAX cdq

Ес­ли в регис­тре EAX было положи­тель­ное зна­чение, в резуль­тате выпол­нения инс­трук­ции CDQ регистр EDX обну­лит­ся. Если же в EAX было отри­цатель­ное зна­чение, то EDX при­мет мак­сималь­ное 32-бит­ное зна­чение 0xFFFFFFFF (или -1). Тем самым EDX есть инди­катор зна­ка чис­ла EAX.

Ес­ли делитель пред­став­ляет собой сте­пень двой­ки, как в текущем слу­чае, то ком­пилятор избавля­ется от DIV/IDIV для опти­миза­ции вычис­лений, как было ска­зано ранее. Вто­рым опе­ран­дом сле­дующей далее инс­трук­ции AND выс­тупа­ет делитель — сте­пень двой­ки минус 1. Пос­ле выпол­нения AND над эти­ми опе­ран­дами, если в EDX был 0x0, это никак не пов­лияет на него. Если же там был 0xFFFFFFFF, то опе­рация AND при­ведет к тому, что в EDX будет находить­ся делитель, что озна­чает: в EAX — отри­цатель­ное чис­ло.

Дру­гими сло­вами, эта опе­рация про­водит­ся для кор­рек­тно­го сох­ранения зна­ка чис­ла. Как ты мог заметить, она зависит от зна­чения сте­пени двой­ки делите­ля, имен­но столь­ко млад­ших битов будет выделе­но из EDX. Количес­тво выделен­ных битов вли­яет на количес­тво позиций для ариф­метичес­кого сдви­га в пос­леду­ющей опе­рации. Одна­ко при­сутс­твие отри­цатель­ных чисел не огра­ничи­вает­ся этой про­вер­кой, для инверти­рова­ния зна­чения ком­пилятор встав­ляет инс­трук­цию NEG reg, где это необ­ходимо.

and edx, 1Fh; Складываем знак числа для округления отрицательных значений в меньшую сторону add eax, edx

Ариф­метичес­кий сдвиг впра­во на пять позиций (см. выше: пять выделен­ных битов) экви­вален­тен делению чис­ла на 25 = 32.

sar eax, 5

Та­ким обра­зом, пос­ледние четыре инс­трук­ции рас­шифро­выва­ются как EAX = var_b / 32.

Об­рати вни­мание: даже при вык­лючен­ном режиме опти­миза­ции ком­пилятор опти­мизи­ровал деление. И хотя прог­рамма здо­рово рас­пуха­ет от такой опти­миза­ции, этот код выпол­няет­ся нам­ного быс­трее, чем неоп­тимизи­рован­ный вари­ант!

; Готовим результат деления для передачи оператору << в качестве параметра mov edx, eax mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Выводим первое слева значение call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) lea rdx, asc_140003304 ; " " mov rcx, rax ; _Ostr; Выводим пробел call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov ecx, [rsp+58h+var_28] mov edx, ecx mov rcx, rax; Выводим второе слева значение call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Выводим символ начала новой строки call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Из секции данных только для чтения копируем значение двойной точности в регистр XMM0 movsd xmm0, cs:__real@40b0240000000000; Затем из регистра в переменную var_d типа double movsd [rsp+58h+var_d], xmm0 movsd xmm0, [rsp+58h+var_d]; Инструкция DIVSD говорит нам о делении вещественных значений двойной точности divsd xmm0, cs:__real@4072800000000000; Частное от первого деления сохраняем в локальной переменной var_18 movsd [rsp+58h+var_18], xmm0; Подготавливаемся ко второму делению: рабочим регистром выступает XMM1, в него копируется значение переменной var_d... movsd xmm1, [rsp+58h+var_d]; ...а затем с его участием происходит деление посредством инструкции DIVSD divsd xmm1, cs:__real@4080000000000000; Регистр XMM1 выбран неспроста, он использует для передачи вещественного параметра оператору << mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Выводим первое слева вещественное число call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) lea rdx, asc_140003308 ; " " mov rcx, rax ; _Ostr; Выводим пробел call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) movsd xmm0, [rsp+58h+var_18] movaps xmm1, xmm0 mov rcx, rax; Выводим второе слева вещественное число — частное первого деления call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Выводим std::endl call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Последняя пара операций деления; var_f = cs:__real@42cc0000 movss xmm0, cs:__real@42cc0000 movss [rsp+58h+var_f], xmm0 movss xmm0, [rsp+58h+var_f]; Инструкция DIVSS намекает на деление вещественных чисел одинарной точности divss xmm0, cs:__real@41cc0000 movss [rsp+58h+var_24], xmm0; var_24 = var_f / cs:__real@41cc0000 movss xmm1, [rsp+58h+var_f] divss xmm1, cs:__real@424c0000; XMM1 = var_f / cs:__real@424c0000; Выводим строку mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Первое слева вещественное число call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) lea rdx, asc_14000330C ; " " mov rcx, rax ; _Ostr; Пробел call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) movss xmm0, [rsp+58h+var_24] movaps xmm1, xmm0 mov rcx, rax; Второе слева число call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; std::endl call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 58h retnmain endp

Вот такой неп­ростой, но инте­рес­ный при­мер получил­ся, наш­пигован­ный опе­рато­рами деления и опе­ран­дами раз­ных типов.

Оптимизированный вариант

А теперь, засучив рукава и глот­нув пус­тырни­ка (или валерь­янки), рас­смот­рим опти­мизи­рован­ный вари­ант (ключ /Ox) того же при­мера (demo_divide):

main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓o sub rsp, 28h mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout mov edx, 2 call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(uint) mov rcx, rax ; _Ostr lea rdx, _Val ; " " call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov rcx, rax mov edx, 8 call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(uint) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout mov edx, 8 call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) mov rcx, rax ; _Ostr lea rdx, asc_1400032D4 ; " " call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov rcx, rax mov edx, 19h call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) movsd xmm1, cs:__real@4020240000000000 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) mov rcx, rax ; _Ostr lea rdx, asc_1400032D8 ; " " call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) movsd xmm1, cs:__real@402beb3e45306eb4 mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) movss xmm1, cs:__real@40000000 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) mov rcx, rax ; _Ostr lea rdx, asc_1400032DC ; " " call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) movss xmm1, cs:__real@40800000 mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 28h retnmain endp

Ком­мента­рии здесь излишни: ком­пилятор на эта­пе тран­сля­ции под­счи­тал все зна­чения и передал их опе­рато­ру вывода. С дру­гими клю­чами ком­пиляции — /O1, /O2 — ситу­ация при­мер­но такая же.

C++ Builder

Как спра­вит­ся с опти­миза­цией деления Embarcadero C++Builder? Рас­смот­рим вари­ант с отклю­чен­ной опти­миза­цией:

public mainmain proc near ; DATA XREF: __acrtused+29↑ovar_50 = qword ptr -50hvar_48 = qword ptr -48hvar_40 = qword ptr -40hvar_38 = qword ptr -38hvar_30 = qword ptr -30hvar_28 = dword ptr -28hvar_24 = dword ptr -24hvar_20 = qword ptr -20hvar_b = dword ptr -18hvar_a = dword ptr -14hvar_10 = qword ptr -10hvar_8 = dword ptr -8var_4 = dword ptr -4 push rbp sub rsp, 70h lea rbp, [rsp+70h] lea rax, xmmword_43A598 mov [rbp+var_4], 0 mov [rbp+var_8], ecx mov [rbp+var_10], rdx; Инициализируем переменную var_a значением 512 в десятичной системе mov [rbp+var_a], 200h; Копируем значение переменной var_a в регистр ECX — делимое для первой операции mov ecx, [rbp+var_a]; Выполняем логический сдвиг вправо на восемь позиций, до этого не додумался даже Visual C++ shr ecx, 8; Результат сдвига записываем в переменную var_28 mov [rbp+var_28], ecx mov rcx, rax; Готовим параметр для вывода mov edx, [rbp+var_28]; Выводим число call std::ostream::operator<<(uint) lea rdx, unk_42D050 mov rcx, rax; Выводим пробел call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const*); Значение переменной var_a копируем в регистр R8D — делимое для второй операции mov r8d, [rbp+var_a]; Выполняем логический сдвиг вправо на шесть позиций shr r8d, 6 mov rcx, rax; Результат сдвига готовим для передачи в качестве параметра mov edx, r8d; Выводим результат call std::ostream::operator<<(uint) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Выводим символ новой строки call std::ostream::operator<<(std::ostream & (*)(std::ostream &)) lea rcx, xmmword_43A598; В регистр R8D записываем число 32 в десятичной системе счисления — делитель mov r8d, 20h ; ' '; Инициализируем переменную var_b mov [rbp+var_b], 100h; В регистр R9D записываем число 256 в десятичной системе счисления — делимое mov r9d, [rbp+var_b] mov [rbp+var_30], rax; Делимое помещаем в EAX mov eax, r9d; Расширяем EAX до четверного слова — EDX:EAX cdq; Выполняем целочисленное деление с учетом знака, в R8D находится делитель idiv r8d; Результат сразу передаем на вывод mov edx, eax call std::ostream::operator<<(int) lea rdx, unk_42D050 mov rcx, rax; Выводим пробел call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const*); Место делителя занимает 0xA mov r8d, 0Ah; В R9D заносим делимое mov r9d, [rbp+var_b] mov [rbp+var_38], rax; Делимое помещаем в EAX mov eax, r9d; Расширяем EAX до четверного слова — EDX:EAX cdq; Выполняем целочисленное деление с учетом знака, в R8D находится делитель idiv r8d mov rcx, [rbp+var_38]; Результат деления передаем на вывод mov edx, eax call std::ostream::operator<<(int) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Выводим символ начала новой строки call std::ostream::operator<<(std::ostream & (*)(std::ostream &)) lea rcx, xmmword_43A598; Копируем значения в регистры XMM0 и XMM1 из секции данных только для чтения movsd xmm0, cs:qword_42D018 ; делитель movsd xmm1, cs:qword_42D020 ; делимое movsd [rbp+var_20], xmm1 movsd xmm1, [rbp+var_20]; Операция деления вещественных значений двойной точности divsd xmm1, xmm0 mov [rbp+var_40], rax; Выводим результат из регистра XMM1 call std::ostream::operator<<(double) lea rdx, unk_42D050 mov rcx, rax; Выводим пробел call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const*) movsd xmm0, cs:qword_42D010 ; Новый делитель movsd xmm1, [rbp+var_20] ; Старое делимое; Операция деления вещественных значений двойной точности divsd xmm1, xmm0 mov rcx, rax; Выводим число call std::ostream::operator<<(double) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Выводим символ новой строки call std::ostream::operator<<(std::ostream & (*)(std::ostream &)) lea rcx, xmmword_43A598; Последняя пара операций деления вещественных значений одинарной точности, думаю, не требует дополнительных пояснений, мы уже достаточно натренировались на делении movss xmm0, cs:dword_42D004 movss xmm1, cs:dword_42D008 movss [rbp+var_24], xmm1 movss xmm1, [rbp+var_24] divss xmm1, xmm0 mov [rbp+var_48], rax call std::ostream::operator<<(float) lea rdx, unk_42D050 mov rcx, rax call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const*) movss xmm0, cs:dword_42D000 movss xmm1, [rbp+var_24] divss xmm1, xmm0 mov rcx, rax call std::ostream::operator<<(float) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call std::ostream::operator<<(std::ostream & (*)(std::ostream &)) mov [rbp+var_4], 0 mov [rbp+var_50], rax mov eax, [rbp+var_4] add rsp, 70h pop rbp retnmain endp

Срав­нивая дизас­сем­блер­ные лис­тинги прог­рамм, пос­тро­енных без опти­миза­ции ком­пилято­рами Visual C++ и C++Builder, мож­но ска­зать, что отли­чились оба, при­том в раз­ных ситу­ациях. На пер­вой паре опе­раций деления

unsigned int a = 512;std::cout << a / 256 << " " << a / 64 << std::endl;

Visual C++ пос­тупил пря­моли­ней­но, при­менив инс­трук­цию DIV в обо­их слу­чаях. C++Builder про­явил сме­кал­ку и вмес­то деления исполь­зовал логичес­кий сдвиг впра­во (в пер­вом и вто­ром слу­чаях на раз­ное количес­тво позиций, конеч­но).

Од­нако на вто­рой паре опе­раций нахож­дения час­тно­го

int b = 256;std::cout << b / 32 << " " << b / 10 << std::endl;

C++Builder бес­хитрос­тно вос­поль­зовал­ся инс­трук­цией IDIV в обо­их слу­чаях. Тем не менее хотя Visual C++ эле­мен­тарно при­вел вто­рое сле­ва выраже­ние b/10 к IDIV, зато над пер­вым b/32 выпол­нил осно­ватель­ную работу по опре­деле­нию зна­ка чис­ла и ариф­метичес­кому сдви­гу.

Две пос­леду­ющие пары деления вещес­твен­ных чисел ничем прин­ципи­аль­ным не отли­чают­ся.

Раз­бирать пос­тро­енный C++Builder опти­мизи­рован­ный вари­ант не име­ет никако­го смыс­ла, в нем уже все заранее сге­нери­рова­но, и ничего инте­рес­ного мы там не уви­дим, раз­ве что мно­жес­твен­ные вет­вле­ния.

ИДЕНТИФИКАЦИЯ ОПЕРАТОРА %

Спе­циаль­ной инс­трук­ции для вычис­ления остатка в наборе команд мик­ропро­цес­соров серии 80x86 нет, вмес­то это­го оста­ток с час­тным воз­вра­щает­ся инс­трук­циями деления DIV, IDIV и DIVSx.

Ес­ли делитель пред­став­ляет собой сте­пень двой­ки (2N = b), а делимое — без­зна­ковое чис­ло, то оста­ток будет равен N млад­шим битам делимо­го чис­ла. Если же делимое зна­ковое, необ­ходимо уста­новить все биты, кро­ме пер­вых N, рав­ными зна­ково­му биту для сох­ранения зна­ка чис­ла. При­чем, если N пер­вых битов рав­ны нулю, все биты резуль­тата дол­жны быть сбро­шены незави­симо от зна­чения зна­ково­го бита. С подоб­ным при­емом мы уже встре­чались, ког­да раз­бирали неоп­тимизи­рован­ный при­мер, под­готов­ленный Visual C++.

Та­ким обра­зом, если делимое — без­зна­ковое чис­ло, то выраже­ние a % 2N тран­сли­рует­ся в конс­трук­цию AND a, N, в про­тив­ном слу­чае тран­сля­ция ста­новит­ся неод­нознач­на — ком­пилятор может встав­лять явную про­вер­ку на равенс­тво нулю с вет­вле­нием, а может исполь­зовать хит­рые матема­тичес­кие алго­рит­мы, самый популяр­ный из которых выг­лядит так:

DEC xOR x, -NINC x

Весь фокус в том, что если пер­вые N битов чис­ла x рав­ны нулю, то все биты резуль­тата, кро­ме стар­шего, зна­ково­го бита, будут гаран­тирован­но рав­ны 1, a OR x, –N при­нуди­тель­но уста­новит в еди­ницу и стар­ший бит, то есть получит­ся зна­чение, рав­ное –1. В свою оче­редь, INC –1 даст ноль! Нап­ротив, если хотя бы один из N млад­ших битов равен одно­му, заимс­тво­вания из стар­ших битов не про­исхо­дит и INC x воз­вра­щает зна­чению пер­воначаль­ный резуль­тат.

Прод­винутые опти­мизи­рующие ком­пилято­ры могут путем слож­ных пре­обра­зова­ний заменять деление рядом дру­гих, более быс­тро­дей­ству­ющих опе­раций. К сожале­нию, алго­рит­мов для быс­тро­го вычис­ления остатка для всех делите­лей не сущес­тву­ет и делитель дол­жен быть кра­тен k ∙ 2t, где k и t — целые чис­ла. Тог­да оста­ток мож­но вычис­лить по сле­дующей фор­муле.

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

Рас­смот­рим при­мер demo_remainder, который поз­волит нам разоб­рать­ся в иден­тифика­ции опе­рато­ра %:

#include <iostream>int main(){ int a = 22; std::cout << a % 16 << " " << a % 10 << std::endl;}

Ре­зуль­тат выпол­нения demo_remainder

Ре­зуль­тат его ком­пиляции ком­пилято­ром Microsoft Visual C++ 2022 с отклю­чен­ной опти­миза­цией дол­жен выг­лядеть так:

main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓ovar_a = dword ptr -18hvar_14 = dword ptr -14h; Резервируем память для локальных переменных sub rsp, 38h; Подготавливаем переменную var_a — делимое mov [rsp+38h+var_a], 16h; Копируем ее значение в EAX mov eax, [rsp+38h+var_a]; Расширяем EAX до четверного слова EDX:EAX cdq; Заносим в ECX значение 0xA — делитель mov ecx, 0Ah; Делим EDX:EAX (var_a) на ECX (0xA) idiv ecx mov eax, edx; Запоминаем остаток от деления var_a на 0xA в переменной var_14 mov [rsp+38h+var_14], eax; Заносим в EAX значение переменной var_a mov eax, [rsp+38h+var_a]; Расширяем EAX до четверного слова EDX:EAX cdq; «Вырезаем» знаковый бит числа and edx, 0Fh; Складываем, чтобы округлить до меньшего add eax, edx; «Вырезаем» четыре младших бита числа — в них содержится остаток от деления на 16 and eax, 0Fh; Из остатка вычитаем показатель знака для сохранения знака числа sub eax, edx

Мо­жем подыто­жить: пос­ледние пять инс­трук­ций рас­шифро­выва­ются как EAX = var_a % 16.

Го­товим резуль­тат для вывода, помещая его непос­редс­твен­но в тре­буемый регистр:

mov edx, eax mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Выводим первое слева число call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) lea rdx, _Val ; " " mov rcx, rax ; _Ostr; Выводим пробел call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov ecx, [rsp+38h+var_14]; Сохраненный ранее результат загружаем из переменной, помещаем в регистр — готовим к выводу mov edx, ecx mov rcx, rax; Выводим второе слева число call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Выводим символ новой строки call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); std::cout << var_a % 16 << " " << var_a % 10 << std::endl; xor eax, eax add rsp, 38h retnmain endp

Лю­бопыт­но, что опти­миза­ция не вли­яет на алго­ритм вычис­ления остатка. В опти­мизи­рован­ном вари­анте зна­чения под­став­лены уже под­счи­тан­ные. Увы, ни Visual C++, ни осталь­ные извес­тные мне ком­пилято­ры не уме­ют вычис­лять оста­ток умно­жени­ем.

ИДЕНТИФИКАЦИЯ ОПЕРАТОРА *

В общем слу­чае опе­ратор * тран­сли­рует­ся либо в машин­ную инс­трук­цию MUL (без­зна­ковое целочис­ленное умно­жение), либо в IMUL (целочис­ленное умно­жение со зна­ком), либо в MULSx (вещес­твен­ное умно­жение в зависи­мос­ти от поряд­ка точ­ности чисел).

Ес­ли один из мно­жите­лей кра­тен сте­пени двой­ки, то MUL (IMUL) обыч­но заменя­ется коман­дой битово­го сдви­га вле­во SHL или инс­трук­цией LEA, спо­соб­ной умно­жать содер­жимое регис­тров на 2, 4 и 8. Обе пос­ледние коман­ды выпол­няют­ся за один такт, в то вре­мя как MUL тре­бует в зависи­мос­ти от модели про­цес­сора от двух до девяти так­тов.

К тому же LEA за тот же такт успе­вает сло­жить резуль­тат умно­жения с содер­жимым регис­тра обще­го наз­начения и/или кон­стан­той в при­дачу. Это поз­воля­ет умно­жать на 3, 5 и 9, прос­то добав­ляя к умно­жаемо­му регис­тру его зна­чение. Ну раз­ве это не сказ­ка? Прав­да, у LEA есть один недочет — она может вызывать оста­нов­ку AGI, в конеч­ном сче­те «съеда­ющую» весь выиг­рыш в быс­тро­дей­ствии. Сто­ит отме­тить, что проб­лемой AGI стра­дали толь­ко ран­ние инте­лов­ские про­цес­соры, с Pentium Pro они этой «хворью» не боле­ют. Поэто­му поль­зовате­лям сов­ремен­ных про­цес­соров линей­ки Core вол­новать­ся об этом не сто­ит.

Рас­смот­рим при­мер demo_mul, демонс­три­рующий иден­тифика­цию опе­рато­ра * с опе­ран­дами раз­ных типов:

#include <iostream>int main(){ int a = 2; std::cout << a * 16 << " " << a * 4 + 5 << " " << a * 13 << std::endl; double d = 1.5; std::cout << d * 3 << " " << d * 5 + a << " " << std::endl; float f = 2.2f; std::cout << f * 3 << std::endl;}

Ре­зуль­тат выпол­нения demo_mul

Ре­зуль­тат его ком­пиляции ком­пилято­ром Microsoft Visual C++ 2022 с отклю­чен­ной опти­миза­цией дол­жен выг­лядеть так:

main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓ovar_a = dword ptr -28hvar_24 = dword ptr -24hvar_20 = dword ptr -20hvar_1C = dword ptr -1Chvar_d = qword ptr -18hvar_10 = qword ptr -10h; Резервируем место для локальных переменных sub rsp, 48h; Переменной var_a присваиваем значение 2 mov [rsp+48h+var_a], 2; Умножаем var_a на 0xD, записывая результат в EAX imul eax, [rsp+48h+var_a], 0Dh; Перекладываем произведение из EAX в переменную var_20 mov [rsp+48h+var_20], eax; Загружаем в ECX значение var_a mov ecx, [rsp+48h+var_a]; Умножаем RCX на 4 и добавляем к произведению 5, записывая результат в регистр ECX. И все это выполняется за один такт! lea ecx, ds:5[rcx*4]; Копируем результат выражения var_a * 4 + 5 в переменную var_24 mov [rsp+48h+var_24], ecx

IMUL с тре­мя опе­ран­дами выпол­няет умно­жение вто­рого на тре­тий, сох­раняя резуль­тат в пер­вый. Таким обра­зом, var_a умно­жаем на 0x10... Пос­той, а где же обе­щан­ный сдвиг вле­во в слу­чае, ког­да один из опе­ран­дов пред­став­ляет сте­пень двой­ки? Похоже, ком­пилятор решил, что в текущих усло­виях от замены опе­рато­ра не будет сущес­твен­ного выиг­рыша.

imul edx, [rsp+48h+var_a], 10h; Готовим строку для вывода результатов выражений mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) lea rdx, _Val ; " " mov rcx, rax ; _Ostr call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov ecx, [rsp+48h+var_24] mov edx, ecx mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) lea rdx, asc_140003300 ; " " mov rcx, rax ; _Ostr call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov ecx, [rsp+48h+var_20] mov edx, ecx mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Загрузка вещественного значения двойной точности из секции данных только для чтения в регистр XMM0 movsd xmm0, cs:__real@3ff8000000000000; Сохранение этого значения в переменную var_d movsd [rsp+48h+var_d], xmm0 movsd xmm0, [rsp+48h+var_d]; Умножение var_d на другое число, загруженное из секции данных только для чтения mulsd xmm0, cs:__real@4014000000000000; Преобразование знакового целого числа — var_a, занимающего два слова, в вещественное значение двойной точности cvtsi2sd xmm1, [rsp+48h+var_a]; Прибавление var_a к var_d с учетом разрядов и вещественности addsd xmm0, xmm1; Сохранение результата выражения var_d * 5 + var_a во временную переменную var_10 movsd [rsp+48h+var_10], xmm0 movsd xmm1, [rsp+48h+var_d]; Умножение var_d на вещественное значение двойной точности, загруженное из сегмента данных только для чтения mulsd xmm1, cs:__real@4008000000000000 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Выводим строку, содержащую результаты приведенных выше выражений call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) lea rdx, asc_14000330C ; " " mov rcx, rax ; _Ostr call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) movsd xmm0, [rsp+48h+var_10] movaps xmm1, xmm0 mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) lea rdx, asc_140003308 ; " " mov rcx, rax ; _Ostr call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Загружаем вещественное значение одинарной точности из сегмента данных movss xmm0, cs:__real@400ccccd movss [rsp+48h+var_1C], xmm0 movss xmm0, [rsp+48h+var_1C]; Перемножаем два вещественных значения одинарной точности mulss xmm0, cs:__real@40400000 movaps xmm1, xmm0 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Формируем строку для вывода итогового результата предыдущего умножения call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 48h retnmain endp

А что будет, если ском­пилиро­вать этот при­мер с клю­чиком /Ox? А будет вот что:

main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓o sub rsp, 28h mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout mov edx, 20h ; ' ' call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) mov rcx, rax ; _Ostr lea rdx, _Val ; " " call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov rcx, rax mov edx, 0Dh call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) mov rcx, rax ; _Ostr lea rdx, asc_1400032D0 ; " " call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov rcx, rax mov edx, 1Ah call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) movsd xmm1, cs:__real@4012000000000000 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) mov rcx, rax ; _Ostr lea rdx, asc_1400032DC ; " " call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) movsd xmm1, cs:__real@4023000000000000 mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) mov rcx, rax ; _Ostr lea rdx, asc_1400032D8 ; " " call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const *) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) movss xmm1, cs:__real@40d33334 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 28h retnmain endp

Ни одной локаль­ной перемен­ной, ни одно­го про­межу­точ­ного вычис­ления! Все выраже­ния вычис­ляют­ся на эта­пе ком­пиляции, их резуль­таты записы­вают­ся пря­миком в сек­цию дан­ных. При выпол­нении прог­рамме оста­ется прос­то заг­рузить эти дан­ные и вывес­ти на экран! Никако­го твор­ческо­го про­цес­са…

C++ Builder

Пос­мотрим, какой код сге­нери­рует на осно­ве этой же прог­раммы товарищ Embarcadero C++Builder 10.4 с отклю­чен­ной опти­миза­цией:

main proc near ; DATA XREF: __acrtused+29↑ovar_40 = qword ptr -40hvar_38 = qword ptr -38hvar_30 = qword ptr -30hvar_28 = dword ptr -28hvar_f = dword ptr -24hvar_d = qword ptr -20hvar_a = dword ptr -14hvar_10 = qword ptr -10hvar_8 = dword ptr -8var_4 = dword ptr -4 push rbp sub rsp, 60h lea rbp, [rsp+60h] lea rax, xmmword_43A4F8 mov [rbp+var_4], 0 mov [rbp+var_8], ecx mov [rbp+var_10], rdx; Инициализация переменной var_a значением 2 mov [rbp+var_a], 2; Загружаем в ECX значение переменной var_a mov ecx, [rbp+var_a]; А C++ Builder, в отличие от Visual C++, вместо умножения на число в степени двойки применил логический сдвиг влево; Умножаем var_a на 16 == var_a * (2 ^ 4) shl ecx, 4; Произведение помещаем в переменную var_28 mov [rbp+var_28], ecx mov rcx, rax mov edx, [rbp+var_28]; Результат умножения выводим на терминал — первое слева число call std::ostream::operator<<(int) lea rdx, unk_42D050 mov rcx, rax; Выводим пробел call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const*); В R8D помещается значение переменной var_a mov r8d, [rbp+var_a]; Далее прямолинейно выполняются две математических операции, составляющие выражение; Сначала происходит сдвиг влево на две позиции, что равносильно умножению на 4 (2^2) shl r8d, 2; Затем без лишнего словоблудия прибавляем к произведению число 5 add r8d, 5; Обрати внимание: C++Builder не стал применять инструкцию LEA для быстрого умножения и сложения чисел, а размазал выполнение на отдельные команды, что как бы не идет на пользу результирующей программе mov rcx, rax; Копируем результат из регистра R8D в регистр EDX для последующего вывода оператором << mov edx, r8d call std::ostream::operator<<(int) lea rdx, unk_42D050 mov rcx, rax; Вывод пробела call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const*); Умножение на 13 производится обычным imul imul edx, [rbp+var_a], 0Dh mov rcx, rax; Вывод произведения call std::ostream::operator<<(int) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Вывод символа начала новой строки call std::ostream::operator<<(std::ostream & (*)(std::ostream &)) lea rcx, xmmword_43A4F8; Загрузка двух значений двойной точности из сегмента данных только для чтения в 128-битные регистры movsd xmm0, cs:qword_42D018 movsd xmm1, cs:qword_42D020; Одно из загруженных значений сохраняем в переменной var_d movsd [rbp+var_d], xmm1; Производим умножение этих значений mulsd xmm0, [rbp+var_d] movaps xmm1, xmm0 mov [rbp+var_30], rax; Выводим результат произведения из регистра XMM1, скопированного туда из XMM0 call std::ostream::operator<<(double) lea rdx, unk_42D050 mov rcx, rax; Выводим пробел call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const*); Загрузка в регистр XMM0 значения из сегмента данных movsd xmm0, cs:qword_42D010; Умножение двух вещественных значений двойной точности: var_d * XMM0 mulsd xmm0, [rbp+var_d]; Преобразование знакового целого числа — var_a, занимающего два слова, в вещественное значение двойной точности cvtsi2sd xmm1, [rbp+var_a]; Прибавление к произведению значения переменной var_a: var_d * XMM0 + var_a addsd xmm0, xmm1 mov rcx, rax; Результат выражения временно сохраняется в регистре XMM1 movaps xmm1, xmm0; Вывод результата на терминал call std::ostream::operator<<(double) lea rdx, unk_42D050 mov rcx, rax; Вывод пробела call std::operator<<<std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &,char const*) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Вывод символа начала новой строки call std::ostream::operator<<(std::ostream & (*)(std::ostream &)) lea rcx, xmmword_43A4F8; Загрузка двух значений одинарной точности из сегмента данных в регистры XMM0 и XMM1 movss xmm0, cs:dword_42D000 movss xmm1, cs:dword_42D004; Одно из значений копируем в переменную var_f movss [rbp+var_f], xmm1; Перемножение значений mulss xmm0, [rbp+var_f]; Произведение копируем в регистр XMM1 для передачи в качестве параметра оператору << movaps xmm1, xmm0 mov [rbp+var_38], rax; Выводим произведение на экран call std::ostream::operator<<(float) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Выводим символ начала новой строки call std::ostream::operator<<(std::ostream & (*)(std::ostream &)) mov [rbp+var_4], 0 mov [rbp+var_40], rax mov eax, [rbp+var_4] add rsp, 60h pop rbp retnmain endp

Ра­дует, что C++Builder заменил умно­жение с учас­тием чис­ла в сте­пени двой­ки логичес­ким сдви­гом вле­во. Одна­ко в сов­ремен­ных про­цес­сорах этот спо­соб не поз­воля­ет мно­го выиг­рать в быс­тро­дей­ствии, поэто­му Visual C++ не стал с этим замора­чивать­ся. Разоча­рова­ло то, что C++Builder не исполь­зовал инс­трук­цию LEA для одновре­мен­ного сло­жения и умно­жения, на этом ком­пилятор мно­го потерял в быс­тро­дей­ствии резуль­тиру­ющей прог­раммы.

Оп­тимизи­рован­ный вари­ант кода на C++Builder рас­смат­ривать не сто­ит. Если у Visual C++ код получа­ется мини­атюр­ным и акку­рат­ным, то у пер­вого рас­пуха­ет до неп­рилич­ных раз­меров. Опти­миза­цией такое язык не повора­чива­ется наз­вать!

КОМПЛЕКСНЫЕ ОПЕРАТОРЫ

Язык С/С++ выгод­но отли­чает­ся от боль­шинс­тва сво­их кон­курен­тов под­дер­жкой ком­плексных опе­рато­ров: х= (где x — любой эле­мен­тарный опе­ратор), ++ и --.

Ком­плексные опе­рато­ры семей­ства a x= b тран­сли­руют­ся в a = a x b, и они иден­тифици­руют­ся так же, как и эле­мен­тарные опе­рато­ры.

Опе­рато­ры ++ и -- в пре­фик­сной фор­ме выража­ются в три­виаль­ные конс­трук­ции a = a + 1 и a = a – 1, не пред­став­ляющие для нас никако­го инте­реса, но вот пос­тфиксная фор­ма — дело дру­гое.

Раз­берем малень­кий при­мер­чик (operatorplusplus):

#include <iostream>int main(){ int i = 0; std::cout << ++i << std::endl; std::cout << i++ << std::endl; std::cout << i; // Отладочный вывод}

Ре­зуль­тат вывода при­мера operatorplusplus

Ском­пилиру­ем его с помощью Visual C++ 2022 с отклю­чен­ной опти­миза­цией:

main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓ovar_i = dword ptr -18hvar_14 = dword ptr -14hvar_10 = qword ptr -10h sub rsp, 38h; Инициализация переменной var_i нулем mov [rsp+38h+var_i], 0; Копирование значения переменной var_i в EAX mov eax, [rsp+38h+var_i]; Инкрементирование значения inc eax; Возвращаем увеличенное значение в переменную var_i mov [rsp+38h+var_i], eax; Копируем значение переменной var_i в EDX для вывода mov edx, [rsp+38h+var_i] mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Выводим var_i в измененном состоянии call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int)

К текуще­му момен­ту име­ем такой код:

var_i = 0;var_i++;cout << var_i; lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax; Вывод символа начала новой строки call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Копируем в EAX значение переменной var_i mov eax, [rsp+38h+var_i]; Из EAX копируем значение в переменную var_14 mov [rsp+38h+var_14], eax; Загружаем значение переменной var_14 в EDX явно для передачи параметра mov edx, [rsp+38h+var_14]; Посмотри-ка, инкремент не выполнился до вывода значения mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Выводим var_i в НЕизмененном состоянии call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) mov [rsp+38h+var_10], rax; Загружаем значение переменной var_i в регистр EAX mov eax, [rsp+38h+var_i]; Вот он, инкремент, только уже поздно — число-то уже выведено! inc eax; Возвращаем значение из регистра EAX в переменную var_i mov [rsp+38h+var_i], eax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, [rsp+38h+var_10]; Вывод символа начала новой строки call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Это уже «отладочный» вывод значения переменной var_i, он погоды не меняет mov edx, [rsp+38h+var_i] mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(int) xor eax, eax add rsp, 38h retnmain endp

Без уче­та пос­ледней коман­ды вывода у нас получил­ся такой лис­тинг:

var_i = 0;var_i++;cout << var_i;cout << var_i;var_i++;

Что нем­ного не соот­ветс­тву­ет изна­чаль­ным пла­нам. Поэто­му при исполь­зовании ком­плексных опе­рато­ров язы­ка C/C++ будь начеку.

Те­перь все точ­ки над i рас­став­лены, оста­лось под­вести ито­ги, и мож­но зак­руглять­ся.

ВЫВОДЫ

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

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