Фундаментальные основы хакерства. Находим математические операторы в дизассемблированных программах
На первый взгляд кажется, что в распознавании арифметических операций нет ничего сложного. Однако даже неоптимизирующие компиляторы используют ряд хитрых приемов, которые превращают нахождение математических операторов в головную боль. Давай изучим теорию и потренируемся на практике обнаруживать математические операции в бинарном коде программ, подготовленных разными компиляторами.
ИДЕНТИФИКАЦИЯ ОПЕРАТОРА +
В общем случае оператор + транслируется либо в машинную инструкцию 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;}
Прежние версии компиляторов по умолчанию позволяли использовать неинициализированные переменные. Теперь же это не так, и компилятор обрывает трансляцию программы, встретив неинициализированную переменную. Это лишает его возможности загружать значение откуда угодно, тем самым защищая программиста от фатальных ошибок, которые могут произойти, если он забудет присвоить значение объявленной переменной.
Результат трансляции этого примера компилятором 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;}
Неоптимизированный вариант будет выглядеть приблизительно так:
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;}
Результат его компиляции компилятором 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.
Таким образом, последние четыре инструкции расшифровываются как 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
, в противном случае трансляция становится неоднозначна — компилятор может вставлять явную проверку на равенство нулю с ветвлением, а может использовать хитрые математические алгоритмы, самый популярный из которых выглядит так:
Весь фокус в том, что если первые 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;}
Результат его компиляции компилятором 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;}
Результат его компиляции компилятором 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; // Отладочный вывод}
Скомпилируем его с помощью 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 расставлены, осталось подвести итоги, и можно закругляться.
ВЫВОДЫ
После определения пути выполнения программы математические операции составляют основу ее действий. Следовательно, они играют важнейшую роль в программах. Во время анализа дизассемблерных листингов важно правильно определить математические операторы, не запутаться в их последовательности.
Многообразие арифметических операторов, как мы увидели, зависит от типа данных или размера операндов. Для оптимизации вычислений компилятор вправе менять порядок математических операторов, упрощать выражения, заменять их рядом более быстродействующих операций. Разобраться во всем этом хитросплетении подчас очень непросто! Но, я надеюсь, сегодняшняя статья поможет тебе сориентироваться во всем многообразии сопровождающей любую программу математики.