February 20, 2024

Фундаментальные основы хакерства. Практикуемся в поиске циклов при реверсе

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

ЦИКЛЫ WHILE/DO

Visual C++ 2022 с отключенной оптимизацией

Для зак­репле­ния прой­ден­ного в прош­лой статье матери­ала рас­смот­рим нес­коль­ко живых при­меров. Нач­нем с самого прос­того — иден­тифика­ции цик­лов while/do:

#include <stdio.h>

int main()

{

int a = 0;

while (a++ < 10)

printf("Оператор цикла while\n");

do {

printf("Оператор цикла do\n");

} while (--a > 0);

}

От­компи­лиру­ем этот код с помощью 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:0000000140004018↓o

var_18 = dword ptr -18h

var_14 = dword ptr -14h

; Резервируем память для двух локальных переменных,

; Только откуда взялась вторая?

sub rsp, 38h

; Заносим в переменную var_18 значение 0

; Следовательно, это переменная a

mov [rsp+38h+var_18], 0

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

loc_1400010EC: ; CODE XREF: main+31↓j

; Загружаем в EAX значение переменной a (var_18)

mov eax, [rsp+38h+var_18]

; Загружаем в var_14 значение переменной a, вот, мы нашли,

; где используется вторая переменная

mov [rsp+38h+var_14], eax

; Зачем-то снова загружаем то же значение в регистр EAX

mov eax, [rsp+38h+var_18]

; Увеличение значения в регистре EAX на 1

inc eax

; Загружаем значение из регистра EAX в переменную var_18 ("a")

mov [rsp+38h+var_18], eax

; Сравниваем старое (до обновления) значение переменной a,

; ранее сохраненное в var_14, с числом 0xA

cmp [rsp+38h+var_14], 0Ah

Ес­ли (var_14 >= 0xA), дела­ем пры­жок «впе­ред», непос­редс­твен­но за инс­трук­цию безус­ловно­го перехо­да, нап­равлен­ного «назад». Если выпол­няет­ся пры­жок «назад», зна­чит, это цикл, а пос­коль­ку усло­вие выхода из цик­ла про­веря­ется в его начале, то это цикл с пре­дус­лови­ем.

Для его отоб­ражения на цикл while необ­ходимо инверти­ровать усло­вие выхода из цик­ла на усло­вие про­дол­жения цик­ла, дру­гими сло­вами, испра­вить >= на <.

Сде­лав это, мы получа­ем: while (a++ < 0xA)....

jge short loc_140001113

; Начало тела цикла:

; заносим ссылку на строку "Оператор цикла while\n"

lea rcx, _Format ; _Format

; Выводим на консоль

call printf

; Безусловный переход, направленный назад, на метку loc_1400010EC — в начало цикла,

; в область подготовки переменных для проверки

jmp short loc_1400010EC

Меж­ду loc_1400010EC и jmp short loc_1400010E есть толь­ко одно усло­вие выхода из цик­ла: jge short loc_140001113. Зна­чит, исходный код цик­ла выг­лядел так:

while (a++ < 0xA) printf("Оператор цикла while\n");

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

loc_140001113: ; CODE XREF: main+23↑j

; main+4E↓j

Ага, никако­го усло­вия в начале цик­ла не при­сутс­тву­ет, зна­чит, это цикл с усло­вием в кон­це или в середи­не.

; Заносим ссылку на строку "Оператор цикла do\n"

lea rcx, byte_140002278 ; _Format

; Печатаем в консоли

call printf

; Тело цикла

; Загружаем в EAX значение переменной var_18 ("a")

mov eax, [rsp+38h+var_18]

; Уменьшаем значение в EAX на 1

dec eax

; Возвращаем значение из EAX в переменную a — var_18

mov [rsp+38h+var_18], eax

; Сравниваем переменную a с нулем

cmp [rsp+38h+var_18], 0

; Если (a > 0), делаем переход в начало цикла

jg short loc_140001113

Пос­коль­ку усло­вие рас­положе­но в кон­це цик­ла, это цикл do:

do printf("Оператор цикла do\n");

while (--a > 0);

; Возвращаем 0

xor eax, eax

; Восстанавливаем стек

add rsp, 38h

retn

main endp

Visual C++ 2022 с включенной оптимизацией

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

; 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:000000014000400C↓o

; Сохраняем регистр в стеке

push rbx

; Подготавливаем стек, ни одной локальной переменной не объявлено

sub rsp, 20h

; В EBX кладем число 0xA. Для чего, пока неясно

mov ebx, 0Ah

nop dword ptr [rax+rax+00h]

; Судя по следующей перекрестной ссылке, направленной вниз, это цикл!

loc_140001080: ; CODE XREF: main+20↓j

; Заносим в регистр RCX ссылку на строку "Оператор цикла while\n"

lea rcx, _Format ; _Format

; Выводим строку на терминал

call printf

; Если это тело цикла, то где же предусловие?!

; Вычитаем из RBX число 1

sub rbx, 1

; Получается, что число 0xA, помещенное в EBX ранее, было начальным значением

Инс­трук­ция SUB подоб­но CMP изме­няет сос­тояние фла­га нуля. Если в резуль­тате вычита­ния получа­ется 0, флаг нуля воз­водит­ся в еди­ницу. Сле­дующая инс­трук­ция совер­шает пры­жок назад, ког­да флаг не воз­веден, то есть в резуль­тате вычита­ния регистр RBX не стал равен нулю.

jnz short loc_140001080

Ком­пилятор в порыве опти­миза­ции прев­ратил неэф­фектив­ный цикл с пре­дус­лови­ем в более ком­пак­тный и быс­трый цикл с пос­тусло­вием. Имел ли он на это пра­во? А почему нет?! Про­ана­лизи­ровав код, ком­пилятор понял, что этот цикл выпол­няет­ся по край­ней мере один раз. Сле­дова­тель­но, скор­ректи­ровав усло­вие про­дол­жения, его про­вер­ку мож­но вынес­ти в конец цик­ла.

Так­же в исходном тек­сте был инкре­мент счет­чика цик­ла от нуля до 0xA, а в под­готов­ленном тран­сля­тором коде мы видим обратный эффект: дек­ремент счет­чика от 0xA до нуля. Таким обра­зом, ком­пилятор while ((int a=0)+1) < 10) printf(...) заменил do printf(...) while ((int a=10)-1) > 0).

При­чем, что инте­рес­но, он не срав­нивал перемен­ную цик­ла с кон­стан­той, а помес­тил кон­стан­ту в регистр и умень­шал его до тех пор, пока тот не стал равен нулю! Зачем? А затем, что так короче, да и работа­ет быс­трее.

Хо­рошо, но как нам деком­пилиро­вать этот цикл? Непос­редс­твен­ное отоб­ражение на язык C/C++ дает сле­дующую инс­трук­цию:

var_RBX = 0xA;

do {

printf("Оператор цикла while\n");

var_RBX--;

} while (var_RBX > 0);

Впол­не кра­сивый и опти­маль­ный цикл с одной перемен­ной.

; Значение 0xB помещаем в регистр EBX. Это подготовка к следующему циклу

; Этот код выполняется после завершения предыдущего цикла

mov ebx, 0Bh

nop word ptr [rax+rax+00000000h]

; Перекрестная ссылка, направленная вниз, говорит нам о том, что это начало цикла

loc_1400010A0: ; CODE XREF: main+40↓j

; Предусловия нет, значит, это цикл do

; Заносим в регистр RCX ссылку на строку "Оператор цикла do\n"

lea rcx, byte_140002278 ; _Format

; Выводим строку на терминал

call printf

; Уменьшаем значение, загруженное в EBX, на единицу

dec ebx

; Проверяем EBX на равенство нулю

test ebx, ebx

; Продолжаем выполнение цикла, пока EBX > 0

jg short loc_1400010A0

Этот цикл пря­миком отоб­ража­ется в конс­трук­цию язы­ка C/C++:

var_EBX = 0xB;

do { printf("Оператор цикла do\n"); }

while (--var_EBX > 0);

; Возвращаем ноль

xor eax, eax

; Восстанавливаем стек

add rsp, 20h

; Восстанавливаем регистр

pop rbx

retn

main endp

C++Builder 10 без оптимизации

Нес­коль­ко ина­че обра­баты­вает цик­лы ком­пилятор Embarcadero C++Builder 10.4. Смот­ри при­мер while-do_cb:

; int __cdecl main(int argc, const char **argv, const char **envp)

public main

main proc near ; DATA XREF: __acrtused+29↑o

; Объявляем шесть переменных

var_1C = dword ptr -1Ch

var_18 = dword ptr -18h

var_14 = dword ptr -14h

var_10 = qword ptr -10h

var_8 = dword ptr -8

var_4 = dword ptr -4

; Сохраняем в стеке RBP

push rbp

; Резервируем память для локальных переменных

sub rsp, 40h

; Помещаем в RBP указатель на дно стека

lea rbp, [rsp+40h]

; Инициализируем переменные:

; в var_4 записывает 0, вероятно, это переменная a из исходного кода

mov [rbp+var_4], 0

mov [rbp+var_8], ecx

mov [rbp+var_10], rdx

; Еще одна переменная, изначально равная нулю, возьмем на заметку

mov [rbp+var_14], 0

; Ниже перекрестная ссылка, направленная вниз, значит, это начало какого-то цикла

loc_40141F: ; CODE XREF: main+3E↓j

; В начале цикла условие не обнаружено, видимо, цикл с постусловием,

; хотя не будем спешить с выводами

; В регистр EAX копируем значение из переменной var_14

mov eax, [rbp+var_14]

; Копирование EAX в ECX

mov ecx, eax

; Увеличиваем значение в регистре ECX на 1

add ecx, 1

; Увеличенное значение из регистра ECX копируем в переменную var_14,

; из которой берется значение для счетчика в начале итерации

mov [rbp+var_14], ecx

; Сравнение неувеличенного значения с 0хА

cmp eax, 0Ah

; Если это значение больше константы или равно ей,

; выполняем прыжок за пределы цикла в область старших адресов

jge short loc_401440

; В случае продолжения выполнения помещаем ссылку на строку в регистр

; и выводим ее на консоль

lea rcx, aOperatorIklaWh ; "Оператор цикла while\n"

call printf

; Зачем-то сохраняем текущее значение регистра EAX в переменной var_18...

mov [rbp+var_18], eax

; ... и выполняем безусловный переход в начало цикла

jmp short loc_40141F

Вот так‑то C++Builder опти­мизи­ровал код! Началь­ный цикл с пре­дус­лови­ем выпол­нения он прев­ратил в бес­конеч­ный цикл с усло­вием выхода посере­дине (за под­робнос­тями обра­тись к прош­лой статье)! Как мы можем деком­пилиро­вать этот цикл? Нап­рашива­ется такой вари­ант:

int var_14 = 0;

do {

int var_EAX = var_14;

int var_ECX = var_EAX;

var_ECX++;

var_14 = var_ECX;

if (var_EAX >= 0xA) break;

printf("Оператор цикла while\n");

} while (TRUE);

Этот вари­ант кар­диналь­но отли­чает­ся от пер­воначаль­ного, и я очень сом­нева­юсь, что в луч­шую сто­рону! Что ж, издер­жки про­изводс­тва...

; --------------------------------

loc_401440: ; CODE XREF: main+2D↑j

; Сюда происходит переход при выходе из предыдущего цикла

; Как мы знаем, эта инструкция только переводит управление через себя

jmp short $+2

; --------------------------------

loc_401442: ; CODE XREF: main:loc_401440↑j

; main+5D↓j

; Новый цикл!

; Как видим, он начинается с вывода строки, нет условия, значит, цикл с постусловием

lea rcx, aOperatorIklaDo ; "Оператор цикла do\n"

call printf

Про­маты­ваем дизас­сем­блер­ный лис­тинг вверх, что­бы вспом­нить, какое зна­чение находит­ся в регис­тре EAX. Зна­чит, в этом мес­те прог­раммы зна­чение в регис­тре EAX рав­но 0хА. Записы­ваем это зна­чение в перемен­ную var_1C (непонят­но, для каких целей, ведь в будущем она не исполь­зует­ся). Выходит, локаль­ную перемен­ную a исходной прог­раммы пред­став­ляет регис­тро­вая перемен­ная EAX.

mov [rbp+var_1C], eax

; Записываем в регистр EAX значение переменной var_14

; А в ней содержится значение на 1 больше, чем в EAX! То есть 0xB

mov eax, [rbp+var_14]

; Какой хитрый C++Builder!

; Вместо реального вычитания он прибавляет к значению в EAX -1

add eax, 0FFFFFFFFh

; Присваивает результат переменной var_14

mov [rbp+var_14], eax

; И сравнивает уменьшенное значение с нулем

cmp eax, 0

; Если (EAX > 0), то мы прыгаем назад к началу «нового цикла»

; и осуществлению очередной итерации

jg short loc_401442

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

var var_14 = 0xB;

do

{

int var_EAX = var_14;

var_EAX--;

var_14 = var_EAX;

printf("Оператор цикла while\n");

} while (var_EAX > 0);

; В ином случае, когда (EAX <= 0), пропускаем переход

; и продолжаем выполнение кода программы

mov [rbp+var_4], 0

; Возвращаем ноль

mov eax, [rbp+var_4]

; Восстанавливаем стек

add rsp, 40h

; Восстанавливаем регистр

pop rbp

retn

main endp

C++Builder 10 с оптимизацией

И сов­сем пря­моли­ней­ный код тран­сли­рует C++Builder с вклю­чен­ной опти­миза­цией по ско­рос­ти.

; int __cdecl main(int argc, const char **argv, const char **envp)

public main

main proc near ; DATA XREF: __acrtused+29↑o

push rbp

push rsi

sub rsp, 28h

lea rbp, [rsp+20h]

lea rsi, aOperatorIklaWh ; "Оператор цикла while"

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

lea rsi, aOperatorIklaDo ; "Оператор цикла do"

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

mov rcx, rsi

call puts

xor eax, eax

add rsp, 28h

pop rsi

pop rbp

retn

main endp

Без вся­ких ухищ­рений этот код десять раз выводит стро­ку «Опе­ратор цик­ла while» и один­надцать — стро­ку «Опе­ратор цик­ла do». Быть мак­сималь­но прос­тым зна­чит быть мак­сималь­но быс­трым!

Delphi 10

На закус­ку пос­мотрим, как раз­бира­ется с цик­лами Embarcadero Delphi 10 (при­мер while_repeat_d). Нем­ного изме­ним код, что­бы его прог­лотил ком­пилятор:

program while_repeat_d;

{$APPTYPE CONSOLE}

{$R *.res}

uses

System.SysUtils;

var

a : Integer;

begin

a := 0;

while a < 10 do

begin

writeln('Оператор цикла while');

a := a + 1;

end;

repeat

writeln('Оператор цикла repeat/until');

a := a - 1;

until a < 0;

end.

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

Пос­мотрим на резуль­тат в дизас­сем­бле­ре:

public _ZN14While_repeat_d14initializationEv

_ZN14While_repeat_d14initializationEv proc near

; DATA XREF: HEADER:0000000000400128↑o

; .pdata:0000000000448328↓o

; __unwind { // _ZN6System23_DelphiExceptionHandlerEPNS_16TExceptionRecordEyPvS2_

; Блок кода инициализации приложения

push rbp

sub rsp, 20h

mov rbp, rsp

mov rax, cs:off_432120

mov byte ptr [rax], 1

nop

lea rcx, qword_429080

call _ZN7Sysinit8_InitExeEPv

; Инициализация переменной a

mov cs:_ZN14While_repeat_d1aE, 0

Да­лее идет ее срав­нение с чис­лом 0xA. Мож­но подумать, что мы име­ем цикл с пре­дус­лови­ем, одна­ко не будем торопить­ся с вывода­ми. Ведь мы еще не встре­тили ни одной перек­рес­тной ссыл­ки!

cmp cs:_ZN14While_repeat_d1aE, 0Ah

; Если (a >= 0xA), пропускаем последующий цикл и переходим к следующему блоку кода

jge short loc_428FA2

loc_428F72: ; CODE XREF: _ZN14While_repeat_d14initializationEv+60↓j

; Перекрестная ссылка говорит нам о начале цикла

; Ниже идет блок кода для вывода строки, не будем на нем подробно останавливаться...

mov rcx, cs:off_431F40

lea rdx, dword_42900C

call _ZN6System14_Write0UStringERNS_8TTextRecENS_13UnicodeStringE

mov rcx, rax

call _ZN6System8_WriteLnERNS_8TTextRecE

call _ZN6System8__IOTestEv

; ... здесь он заканчивается и переменная а увеличивается на 1

add cs:_ZN14While_repeat_d1aE, 1

; Сверка получившегося значения с 0xA, значит, все-таки имеем цикл с постусловием,

; компилятор изменил цикл с предусловием, который был в исходном коде!

cmp cs:_ZN14While_repeat_d1aE, 0Ah

; Если (a < 0xA), прыгаем назад в начало цикла

jl short loc_428F72

loc_428FA2: ; CODE XREF: _ZN14While_repeat_d14initializationEv+30↑j

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

nop

loc_428FA3: ; CODE XREF: _ZN14While_repeat_d14initializationEv+91↓j

; Перекрестная ссылка — явный намек на начало цикла

; Он начинается с блока кода вывода строки, значит, имеем дело с циклом с постусловием

mov rcx, cs:off_431F40

lea rdx, dword_429044

call _ZN6System14_Write0UStringERNS_8TTextRecENS_13UnicodeStringE

mov rcx, rax

call _ZN6System8_WriteLnERNS_8TTextRecE

call _ZN6System8__IOTestEv

; Уменьшаем значение переменной a на 1

sub cs:_ZN14While_repeat_d1aE, 1

; Сравниваем ее значение с нулем

cmp cs:_ZN14While_repeat_d1aE, 0

; Если (a >= 0), переходим в начало цикла...

jge short loc_428FA3

; когда переменная примет значение меньше нуля,

; выполняем блок кода завершения приложения

call _ZN6System6_Halt0Ev

; --------------------------------

jmp short loc_428FE2

; --------------------------------

align 4

call _ZN6System19_UnhandledExceptionEv

; --------------------------------

align 2

loc_428FE2: ; CODE XREF: _ZN14While_repeat_d14initializationEv+98↑j

lea rsp, [rbp+20h]

pop rbp

retn

; } // starts at 428F40

_ZN14While_repeat_d14initializationEv endp

Как ока­залось, опти­мизи­рован­ный вари­ант ничем не отли­чает­ся от текуще­го, поэто­му рас­смат­ривать пер­вый нет никако­го смыс­ла. Луч­ше перей­дем к сле­дующе­му типу цик­лов.

ЦИКЛЫ FOR

Visual C++ 2022 без оптимизации

Рас­смот­рим сле­дующий при­мер for_cycle:

#include <stdio.h>

int main()

{

int a;

for (a = 0; a < 10; a++)

printf("Оператор цикла for\n");

}

Ре­зуль­тат ком­пиляции 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:0000000140004018↓o

; Объявляем локальную переменную

var_18 = dword ptr -18h

; Резервируем для нее место в стеке

sub rsp, 38h

; Инициализируем переменную a нулем

mov [rsp+38h+var_18], 0

; Непосредственный переход на код проверки условия продолжения цикла —

; характерный признак цикла for

jmp short loc_1400010F8

; --------------------------------

loc_1400010EE: ; CODE XREF: main+2B↓j

; Загрузка в EAX значение переменной a

mov eax, [rsp+38h+var_18]

; Увеличение EAX на единицу

inc eax

; Обновление переменной a. Следовательно, исходный код выглядел так: a++

mov [rsp+38h+var_18], eax

loc_1400010F8: ; CODE XREF: main+C↑j

; Сравниваем переменную a со значением 0xA

cmp [rsp+38h+var_18], 0Ah

; Выходим из цикла, если a >= 0xA

jge short loc_14000110D

; Иначе продолжаем выполнение и выводим строку

lea rcx, _Format ; _Format

call printf

; Безусловный переход в начало цикла

jmp short loc_1400010EE

; --------------------------------

loc_14000110D: ; CODE XREF: main+1D↑j

xor eax, eax

add rsp, 38h

retn

main endp

Итак, что мы име­ем?

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

for (int a = 0; a < 0xA; a++) printf("Оператор циклаfor\n");

Дей­стви­тель­но, цикл for сна­чала ини­циирует перемен­ную‑счет­чик, затем про­веря­ет усло­вие про­дол­жения цик­ла (опти­мизи­руемое ком­пилято­ром в усло­вие завер­шения), далее выпол­няет опе­ратор цик­ла, модифи­циру­ет счет­чик, вновь про­веря­ет усло­вие и так далее.

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:000000014000400C↓o

push rbx

sub rsp, 20h

; Инициализируем регистровую переменную-счетчик

; Внимание! В исходном коде начальное значение счетчика равнялось нулю!

mov ebx, 0Ah

nop dword ptr [rax+rax+00h]

loc_140001080: ; CODE XREF: main+20↓j

Вы­пол­няем опе­ратор цик­ла! При­чем безо вся­ких про­верок! Хит­рый ком­пилятор про­ана­лизи­ровал код и понял, что цикл выпол­няет­ся по край­ней мере один раз.

lea rcx, _Format ; _Format

call printf

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

Ну пра­виль­но: sub reg, const / jnz xxx короче, чем: INC / CMP reg, const / jnz xxx.

Ой и муд­рит ком­пилятор! Кто же ему давал пра­во так изме­нять цикл?!

А дело вот в чем: он понял, что параметр цик­ла в самом цик­ле исполь­зует­ся толь­ко как счет­чик и нет никакой раз­ницы, уве­личи­вает­ся он с каж­дой ите­раци­ей или умень­шает­ся.

sub rbx, 1

; Переход в начало цикла, если RBX > 0

jnz short loc_140001080

xor eax, eax

add rsp, 20h

pop rbx

retn

main endp

Внеш­не это типич­ный код сле­дующе­го вида:

int a = 0xA;

do {

printf("Оператор цикла while\n");

} while (--a);

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

for (int a = 0; a < 10; a++) printf("Оператор цикла for\n");

Пос­той, пос­той! На каком осно­вании мы выпол­нили такое пре­обра­зова­ние? А на том же самом, что и ком­пилятор: раз параметр цик­ла исполь­зует­ся толь­ко как счет­чик, закон­на любая запись, выпол­няющая цикл ров­но десять раз. Оста­ется выб­рать ту, которая удоб­нее и кра­сивее. Ник­то же не будет утвер­ждать, что for (a = 10; a > 0; a--) более при­выч­но, чем for (a = 0; a < 10; a++)?

C++Builder 10

А что покажет нам товарищ C++Builder без опти­миза­ции? Ком­пилиру­ем и смот­рим при­мер for_cycle:

main proc near ; DATA XREF: __acrtused+29↑o

; Со стула упасть можно! пять переменных для маленького цикла!

var_18 = dword ptr -18h

var_14 = dword ptr -14h

var_10 = qword ptr -10h

var_8 = dword ptr -8

var_4 = dword ptr -4

push rbp

sub rsp, 40h

lea rbp, [rsp+40h]

; Инициализация переменных

mov [rbp+var_4], 0

mov [rbp+var_8], ecx

mov [rbp+var_10], rdx

mov [rbp+var_14], 0

loc_40141F: ; CODE XREF: main+3D↓j

Пос­ле ини­циали­зации перемен­ных сра­зу сле­дует начало цик­ла, где на пер­вом мес­те нас ожи­дает про­вер­ка перемен­ной a. Из это­го сле­дует, что C++Builder цикл for прев­ратил в цикл while с пре­дус­лови­ем.

cmp [rbp+var_14], 0Ah

jge short loc_40143F

; Если (a >= 0xA), выходим из цикла

lea rcx, aOperatorIklaFo ; "Оператор цикла for\n"

; Выводим строку

call printf

mov [rbp+var_18], eax

; Копируем в EAX значение переменной a

mov eax, [rbp+var_14]

; Увеличиваем значение в регистре на 1

add eax, 1

; Возвращаем его в переменную

mov [rbp+var_14], eax

; Безусловный переход в начало цикла

jmp short loc_40141F

; --------------------------------

loc_40143F: ; CODE XREF: main+23↑j

; Блок завершения программы

mov [rbp+var_4], 0

mov eax, [rbp+var_4]

add rsp, 40h

pop rbp

retn

main endp

Вид­но, что C++Builder 10 не дотяги­вает до Visual C++ 2022. Ему даже не хва­тило ума про­верить, выпол­няет­ся ли цикл хотя бы один раз, что­бы прев­ратить цикл for в цикл с пос­тусло­вием. Вмес­то это­го он сде­лал цикл с пре­дус­лови­ем, одна­ко заменил усло­вие про­дол­жения выпол­нения на усло­вие прек­ращения!

С вклю­чен­ной опти­миза­цией C++Builder ком­пилиру­ет цикл for точ­но так же, как цик­лы while и do. То есть выкиды­вает любое упо­мина­ние о цик­ле и прос­то десять раз выводит задан­ную строч­ку.

ЦИКЛЫ С УСЛОВИЕМ В СЕРЕДИНЕ

Идентификация break

Те­перь нас­тала оче­редь цик­лов с усло­вием в середи­не или цик­лов, завер­шаемых вруч­ную опе­рато­ром break. Рас­смот­рим сле­дующий при­мер.

#include <stdio.h>

int main()

{

int a = 0;

while (1)

{

printf("1-й оператор\n");

if (++a > 2) break;

printf("2-й оператор\n");

}

do

{

printf("1-й оператор\n");

if (--a < 0) break;

printf("2-й оператор\n");

}

while (1);

}

Visual C++ 2022 без оптимизации

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

; 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:0000000140004018↓o

var_a = dword ptr -18h

; Резервируем место для единственной локальной переменной

sub rsp, 38h

; Присваиваем переменной var_a значение 0

mov [rsp+38h+var_a], 0

; Перекрестная ссылка, направленная вниз, — цикл

loc_1400010EC: ; CODE XREF: main+3E↓j

; Обнуляем регистр EAX

xor eax, eax

Об­рати вни­мание: ког­да опти­миза­ция отклю­чена, ком­пилятор тран­сли­рует безус­ловный цикл «слиш­ком бук­валь­но». Так, обну­лив EAX, он педан­тично про­веря­ет его зна­чение на равенс­тво еди­нице, чего про­изой­ти не может! Если в кои‑то веки FALSE будет рав­но TRUE — про­изой­дет выход из цик­ла.

Сло­вом, все эти инс­трук­ции — глу­пый и бес­полез­ный код цик­ла while (1)!

cmp eax, 1

; Если операнды одинаковые, то результат станет 0 и будет возведен флаг нуля,

; в результате чего произойдет выход из цикла

jz short loc_140001120

; printf("1-й оператор\n")

lea rcx, _Format ; "1-й"

call printf

; В EAX помещаем значение переменной a

mov eax, [rsp+38h+var_a]

; Инкрементируем EAX

inc eax

; Возвращаем значением из EAX в переменную var_a

mov [rsp+38h+var_a], eax

; Сравниваем значение переменной var_a с константой 2 — пределом цикла

cmp [rsp+38h+var_a], 2

Пе­реход выпол­няет­ся, если (var_a <= 0x2). Но куда ведет этот переход? Во‑пер­вых, переход нап­равлен вниз, то есть это уже не переход к началу цик­ла. Сле­дова­тель­но, и усло­вие — не усло­вие цик­ла, а резуль­тат ком­пиляции конс­трук­ции IF — THEN. Вто­рое — переход пры­гает на пер­вую коман­ду, сле­дующую за безус­ловным jmp short loc_140001120. А тот переда­ет управле­ние инс­трук­ции, сле­дующей за коман­дой jmp short loc_1400010EC — безус­ловно­го перехо­да, нап­равлен­ного вверх, — в начало цик­ла. Сле­дова­тель­но, jmp short loc_140001120 осу­щест­вля­ет выход из цик­ла, а jle short loc_140001112 про­дол­жает его выпол­нение.

jle short loc_140001112

Ни­же идет переход на завер­шение цик­ла. А кто у нас завер­шает цикл? Ну конеч­но же, break! Сле­дова­тель­но, окон­чатель­ная деком­пиляция выг­лядит так: if (++var_a > 0x2) break;.

Мы инверти­рова­ли <= в >, так как JLE переда­ет управле­ние на код про­дол­жения цик­ла, а вет­ка THEN в нашем слу­чае — на break.

jmp short loc_140001120

; --------------------------------

; Перекрестная ссылка направлена вверх, следовательно, это не начало цикла

loc_140001112: ; CODE XREF: main+2E↑j

; printf("2-й оператор\n")

lea rcx, a2 ; "2-й"

call printf

; Прыжок в начало цикла. Вот мы и добрались до конца цикла

jmp short loc_1400010EC

Вос­ста­нав­лива­ем исходный код:

while(1)

{

printf("1-й оператор\п");

if (++var_a > 0x2) break;

printf("2-й оператор\n");

}

; --------------------------------

; Сюда происходит переход в момент выхода из первого цикла. По всей видимости, это перекрестная ссылка — начало второго цикла

loc_140001120: ; CODE XREF: main+11↑j

; main+30↑j ...

; printf("1-й оператор\n");

lea rcx, a1 ; "1-й"

call printf

mov eax, [rsp+38h+var_a]

; Уменьшаем значение в EAX: --var_a

dec eax

mov [rsp+38h+var_a], eax

; После возврата значения в память сравниваем значение переменной с нулем

cmp [rsp+38h+var_a], 0

; Если var_a >= 0, выполняем переход вниз

jge short loc_14000113F

Опе­ратор break цик­ла do ничем не отли­чает­ся от break цик­ла while! Поэто­му не будем раз­гла­голь­ство­вать, а сра­зу его деком­пилиру­ем: if (var_a < 0) break;.

; Очевидно, это break, ведущий в конец программы

jmp short loc_140001152

; --------------------------------

loc_14000113F: ; CODE XREF: main+5B↑j

lea rcx, a2_0 ; "2-й"

call printf

xor eax, eax

cmp eax, 1

; А это проверка продолжения цикла: while (1);

jnz short loc_140001120

loc_140001152: ; CODE XREF: main+5D↑j

xor eax, eax

add rsp, 38h

retn

main endp

Что ж, опе­ратор break в обо­их цик­лах выг­лядит оди­нако­во и эле­мен­тарно рас­позна­ется (прав­да, не с пер­вого взгля­да, но при отсле­жива­нии нес­коль­ких перехо­дов — да). А вот с бес­конеч­ными цик­лами неоп­тимизи­рующий ком­пилятор под­качал: получен­ный код про­веря­ет усло­вие, истинность (неис­тинность) которо­го оче­вид­на. Инте­рес­но, как поведет себя опти­мизи­рующий ком­пилятор?

Visual C++ 2022 с оптимизацией

Да­вай откомпи­лиру­ем тот же самый при­мер ком­пилято­ром Microsoft Visual C++ 2022 с клю­чом /O2 (опти­миза­ция по ско­рос­ти) и пос­мотрим, что получит­ся:

; 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:000000014000400C↓o

arg_0 = qword ptr 8

; Объявляем и инициализируем аргумент

mov [rsp+arg_0], rbx

; Сохраняем RDI в стеке

push rdi

sub rsp, 20h

; printf("1-й оператор\n"); — выводим строку до начала какого-либо цикла!

; Значит, компилятор предсказал хотя бы одно выполнение итерации цикла

lea rcx, _Format ; "1-й"

call printf

; Инициализация регистровых переменных:

mov ebx, 2

; Записав в EBX значение 2: var_EBX = 2, очень похожее на потолок нашего цикла,

; копируем это значение в EDI: var_RDI = 2

mov edi, ebx

nop dword ptr [rax]

; Перекрестная ссылка, направленная вперед. Это начало цикла

loc_140001090: ; CODE XREF: main+3C↓j

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

; printf("2-й оператор\n");

lea rcx, a2D ; "2-й"

call printf

; printf("1-й оператор\n");

lea rcx, _Format ; "1-й"

call printf

Пос­мотри‑ка! Ока­зыва­ется, ите­рация сос­тоит из вывода двух строк, а перед началом цик­ла была выведе­на толь­ко одна. Зна­чит, меж­ду коман­дами печати находит­ся усло­вие выхода из цик­ла!

Вы­чита­ем из RDI чис­ло 1 (var_RDI--)... Пос­той, в исходном коде в пер­вом цик­ле был инкре­мент! Такие пре­обра­зова­ния ком­пилято­ру ник­то делать не зап­реща­ет. Если ему кажет­ся, что так быс­трее, его пра­во.

sub rdi, 1

Инс­трук­ция SUB поз­волила не при­менять CMP для срав­нения зна­чений (вот тебе и уско­рение!). Если (var_RDI > 0), перехо­дим к началу цик­ла. Сле­дова­тель­но, выход из цик­ла про­изой­дет, ког­да выпол­нится усло­вие (var_RDI <= 0).

jnz short loc_140001090

Как вид­но, опти­мизи­рующий ком­пилятор выкинул никому не нуж­ные про­вер­ки усло­вия, упростив код и облегчив его понима­ние. Про­изво­дим деком­пиляцию пер­вого цик­ла:

var_RDI = 2;

for (;;) // Вырожденный for представляет собой бесконечный цикл

{

printf("1-й оператор\n");

--var_RDI;

if (var_RDI <= 0) break;

printf("2-й оператор\n");

}

Та­кой цикл явно луч­ше, чем while(true)!

Пос­ле кон­ца пер­вого цик­ла до начала вто­рого вызыва­ется пер­вый опе­ратор для вывода стро­ки, точ­но так же, как это было в начале прог­раммы:

; printf("1-й оператор\n");

lea rcx, _Format ; "1-й"

call printf

nop word ptr [rax+rax+00h]

; Перекрестная ссылка — начало второго цикла

loc_1400010C0: ; CODE XREF: main+6B↓j

; А цикл начинаем с оператора № 2 вывода строки, подобно тому, как это было в первом цикле

; printf("2-й оператор\n");

lea rcx, a2D ; "2-й"

call printf

; printf("1-й оператор\n");

lea rcx, _Format ; "1-й"

call printf

; Уменьшаем значение в регистре EBX на 1: var_EBX--

; Как мы помним, в EBX находится потолок цикла, равно как было в RDI

sub ebx, 1

Пе­реход в начало цик­ла осу­щест­вля­ется, ког­да флаг SF == 0. Сле­дова­тель­но, выход из цик­ла про­исхо­дит, ког­да флаг SF == 1, а он будет равен еди­нице толь­ко в слу­чае, если в резуль­тате пре­дыду­щего вычита­ния получи­лось отри­цатель­ное зна­чение.

jns short loc_1400010C0

Все это намека­ет на сле­дующий код вто­рого цик­ла:

var_EBX = 2;

for (;;)

{

printf("1-й оператор\n");

--var_EBX;

if (var_EBX < 0) break;

printf("2-й оператор\n");

}

; Восстанавливаем регистры перед выходом

mov rbx, [rsp+28h+arg_0]

xor eax, eax

add rsp, 20h

pop rdi

retn

main endp

Для пред­став­ления бес­конеч­ных цик­лов в деком­пилиро­ван­ном коде мы вос­поль­зовались вырож­денным for, который не име­ет ни пред-, ни пос­тусло­вия, потому что дизас­сем­бли­рован­ный лис­тинг не дал нам ни малей­шего намека на при­сутс­твие цик­ла дру­гого типа!

Да... тут есть над чем подумать! Ком­пилятор нор­маль­но «перева­рил» пер­вую стро­ку цик­ла printf("1-й оператор\n"), а затем «напорол­ся» на вет­вле­ние: if (--a < 0) break. Хит­рые пар­ни из Microsoft зна­ют, что для супер­ска­ляр­ных супер­конвей­ерных про­цес­соров, к которым отно­сят­ся сов­ремен­ные чипы от Intel и AMD, вет­вле­ния все рав­но что чер­тополох для Тиг­ры. Вот и при­ходит­ся ком­пилято­ру исправ­лять ляпы прог­раммис­та, что он делать в прин­ципе не обя­зан, но за что ему боль­шое челове­чес­кое спа­сибо!

Ком­пилятор как бы «прок­ручива­ет», «слеп­ляя» вызовы фун­кций printf и вынося вет­вле­ния в конец. Образно исполня­емый код мож­но пред­ста­вить трас­сой, а про­цес­сор — гон­щиком. Чем длин­нее учас­ток дороги без поворо­тов, тем быс­трее его прос­кочит гон­щик. Выносить усло­вие из середи­ны цик­ла в его конец впол­не допус­тимо, ведь перемен­ная, отно­ситель­но которой выпол­няет­ся вет­вле­ние, не модифи­циру­ется ни фун­кци­ей printf, ни какой‑либо дру­гой. Поэто­му не все ли рав­но, где ее про­верять? Конеч­но же, не все рав­но!

К момен­ту, ког­да усло­вие --а < 0 ста­новит­ся истинно, успе­вает выпол­нить­ся пер­вый printf, а вот вто­рой уже не получа­ет управле­ния. Вот для это­го‑то ком­пилятор и помес­тил код про­вер­ки усло­вия сле­дом за пер­вым вызовом пер­вой фун­кции printf, а затем изме­нил порядок вызова printf в теле цик­ла. Это при­вело к тому, что на момент выхода из цик­ла по усло­вию пер­вый printf выпол­няет­ся на один раз боль­ше, чем вто­рой, так как он встре­чает­ся дваж­ды.

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

А еще кто‑то руга­ет авто­мати­чес­кую опти­миза­цию. Да уж! Конеч­но, руками‑то мож­но и кру­че опти­мизи­ровать (осо­бен­но понимая смысл кода), но ведь эдак и моз­ги недол­го вывих­нуть! А ком­пилятор, даже будучи стис­нут со всех сто­рон кри­вым кодом прог­раммис­та, за доли секун­ды успе­вает его доволь­но при­лич­но окуль­турить!

C++Builder 10

Весь­ма разитель­но отли­чает­ся тот же при­мер, ском­пилиро­ван­ный с помощью Embarcadero C++Builder. При этом с вклю­чен­ной опти­миза­цией по ско­рос­ти (O2), как и в прош­лые разы, получа­ется гру­да пос­ледова­тель­ных вызовов фун­кций вывода строк. А вот вари­ант без опти­миза­ции впол­не зас­лужива­ет нашего вни­мания и кри­тики:

; int __cdecl main(int argc, const char **argv, const char **envp)

public main

main proc near ; DATA XREF: __acrtused+29↑o

; Объявляется восемь локальных переменных!

var_24 = dword ptr -24h

var_20 = dword ptr -20h

var_1C = dword ptr -1Ch

var_18 = dword ptr -18h

var_14 = dword ptr -14h

var_10 = qword ptr -10h

var_8 = dword ptr -8

var_4 = dword ptr -4

push rbp

sub rsp, 50h

lea rbp, [rsp+50h]

; Инициализация некоторых из них

mov [rbp+var_4], 0

mov [rbp+var_8], ecx

mov [rbp+var_10], rdx

mov [rbp+var_14], 0

; Начало первого цикла

loc_40141F: ; CODE XREF: main+4D↓j

Все дос­таточ­но пря­моли­ней­но: вызов опе­рато­ра вывода пер­вой стро­ки — printf("1-й оператор\n"). Заметь, про­верок нет, зна­чит, все‑таки ком­пилятор переде­лал цикл с пре­дус­лови­ем в цикл с пос­тусло­вием или усло­вием в середи­не.

lea rcx, a1IOperator ; "1-й оператор\n"

call printf

; Регистру EDX присваиваем значение переменной var_14, а там у нас изначально 0,

; var_EDX = 0

mov edx, [rbp+var_14]

; Увеличиваем значение в регистре на 1...

add edx, 1

; ...и возвращаем значение в память

mov [rbp+var_14], edx

; Сравниваем значение var_EDX с константой 2

cmp edx, 2

mov [rbp+var_18], eax

; Если (var_EDX <= 2), продолжаем выполнять цикл...

jle short loc_40143E

; ...иначе «выпрыгиваем» из первого цикла

jmp short loc_40144F

; --------------------------------

loc_40143E: ; CODE XREF: main+3A↑j

Про­дол­жая выпол­нение пер­вого цик­ла, выводим вто­рую стро­ку — printf("2-й оператор\n"). Отчетли­во вид­но, что в теле цик­ла, меж­ду дву­мя опе­рато­рами вывода строк, находит­ся усло­вие для выхода из цик­ла.

lea rcx, a2IOperator ; "2-й оператор\n"

call printf

mov [rbp+var_1C], eax

; Безусловный переход к началу первого цикла

jmp short loc_40141F

Ре­зуль­тиру­ющая деком­пиляция пер­вого цик­ла:

var_EDX = 0;

for (;;) {

printf("1-й оператор\n");

var_EDX++;

if (var_EDX > 2) break;

printf("2-й оператор\n");

}

; --------------------------------

loc_40144F: ; CODE XREF: main+3C↑j

jmp short $+2

; --------------------------------

; Начало второго цикла

loc_401451: ; CODE XREF: main:loc_40144F↑j

; main+83↓j

; В начале нет условия,

; выводим надпись № 1

lea rcx, a1IOperator ; "1-й оператор\n"

call printf

; Значение переменной var_14 присваиваем регистру EDX, а в ней у нас осталось число 2:

; var_EDX = 2

mov edx, [rbp+var_14]

; var_EDX = var_EDX +- 1

add edx, 0FFFFFFFFh

; var_14 = var_EDX

mov [rbp+var_14], edx

; Сравнение var_EDX с 0

cmp edx, 0

mov [rbp+var_20], eax

; Если (var_EDX >= 0), продолжаем выполнение цикла

jge short loc_401470

; Иначе идем в конец программы прибирать свои нули

jmp short loc_401487

; --------------------------------

loc_401470: ; CODE XREF: main+6C↑j

; В продолжение выполнения цикла выводим строку № 2

lea rcx, a2IOperator ; "2-й оператор\n"

call printf

mov [rbp+var_24], eax

mov al, 1

test al, 1

; Если (ZF == 0), переходим к началу второго цикла,

; в данных условиях ZF никогда не будет равна единице, так как ((test al, 1) != 0)

jnz short loc_401451

; Перепрыгнув инструкцию, идем на выход

jmp short $+2

Ито­говая деком­пиляция вто­рого цик­ла:

var_EDX = 2;

for (;;) {

printf("1-й оператор\n");

var_EDX--;

if (var_EDX < 0) break;

printf("2-й оператор\n");

}

; --------------------------------

loc_401487: ; CODE XREF: main+6E↑j

; main+85↑j

; На выходе прибираем за собой

mov [rbp+var_4], 0

mov eax, [rbp+var_4]

add rsp, 50h

pop rbp

retn

main endp

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

Идентификация continue

Те­перь, пос­ле break, рас­смот­рим, как ком­пилято­ры тран­сли­руют его «астраль­ный анти­под» — опе­ратор continue. Взгля­нем на сле­дующий при­мер (cycle_continue).

#include <stdio.h>

int main()

{

int a = 0;

while (a++ < 10)

{

if (a == 2) continue;

printf("%x\n", a);

}

do

{

if (a == 2) continue;

printf("%x\n", a);

} while (--a > 0);

}

Visual C++ 2022 с выключенной оптимизацией

Ре­зуль­тат работы ком­пилято­ра 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:0000000140004018↓o

var_a = dword ptr -18h

var_14 = dword ptr -14h

sub rsp, 38h

; Присваиваем локальной переменной var_a значение 0

mov [rsp+38h+var_a], 0

loc_1400010EC: ; CODE XREF: main+2C↓j main+3E↓j

; ^^^^^^^^^ ^^^^^^^^^

Две перек­рес­тные ссыл­ки, нап­равлен­ные впе­ред, говорят о том, что это либо начало двух цик­лов (один из которых вло­жен­ный), либо переход в начало цик­ла опе­рато­ром continue.

; Загружаем в EAX значение var_a

mov eax, [rsp+38h+var_a]

; Загружаем в var_14 значение EAX

mov [rsp+38h+var_14], eax

mov eax, [rsp+38h+var_a]

; Инкрементируем значение в регистре EAX

inc eax

; Обновляем переменную var_a

mov [rsp+38h+var_a], eax

; Сравниваем значением var_a до увеличения с числом 0xA

cmp [rsp+38h+var_14], 0Ah

; Выход из первого цикла (переход в начало второго цикла), если var_a >= 0xA

jge short loc_140001120

; Сравниваем var_a со значением 0x2

cmp [rsp+38h+var_a], 2

Ес­ли (var_a != 2), выпол­няет­ся пры­жок на коман­ду, сле­дующую за инс­трук­цией безус­ловно­го перехо­да, которая нап­равле­на вверх — в начало цик­ла. Очень похоже на усло­вие выхода из цик­ла, но не будем спе­шить с вывода­ми! Вспом­ним, в начале цик­ла нам встре­тились две перек­рес­тные ссыл­ки. Безус­ловный переход jmp short loc_1400010EC как раз обра­зует одну из них. А кто «отве­чает» за дру­гую? Что­бы это узнать, необ­ходимо про­ана­лизи­ровать осталь­ной код цик­ла.

jnz short loc_14000110E

Нап­равлен­ный в начало цик­ла безус­ловный переход — это либо конец цик­ла, либо continue. Пред­положим, что это конец цик­ла. Тог­да что же пред­став­ляет собой jge short loc_140001120? Пре­дус­ловие выхода из цик­ла? Не похоже, в таком слу­чае оно пры­гало бы гораз­до «бли­же» — на мет­ку loc_14000110E.

А может, jge short loc_140001120 — пре­дус­ловие одно­го цик­ла, а jnz short loc_14000110E — пос­тусло­вие дру­гого, вло­жен­ного в него? Впол­не воз­можно, но малове­роят­но: в этом слу­чае пос­тусло­вие пред­став­ляло бы собой усло­вие про­дол­жения, а не завер­шения цик­ла. Поэто­му с некото­рой долей неуве­рен­ности мы можем при­нять конс­трук­цию: CMP var_a, 2 \ JNZ loc_14000110E \ JMP loc_1400010EC за if (a == 2) continue.

jmp short loc_1400010EC

; --------------------------------

loc_14000110E: ; CODE XREF: main+2A↑j

mov edx, [rsp+38h+var_a]

; printf("%x\n", a);

lea rcx, _Format ; "%x\n"

call printf

А вот это явно конец цик­ла, так как jmp short loc_1400010EC — самая пос­ледняя ссыл­ка на начало цик­ла. Итак, подыто­жим. Рас­положен­ное в начале цик­ла усло­вие кру­тит этот цикл до тех пор, пока var_a < 0xA, при­чем инкре­мент парамет­ра цик­ла про­исхо­дит до его срав­нения. Затем сле­дует еще одно усло­вие, воз­вра­щающее управле­ние в начало цик­ла, если var_a == 2. Строй замыка­ет опе­ратор цик­ла printf и безус­ловный переход в его начало.

jmp short loc_1400010EC

Та­ким обра­зом, получа­ется сле­дующая кар­тина.

Ус­ловие «ближ­него» про­дол­жения не может быть кон­цом цик­ла, так как тог­да усло­вию «далеко­го» выхода приш­лось бы вый­ти аж из над­лежаще­го цик­ла, на что ни break, ни дру­гие опе­рато­ры не спо­соб­ны. Таким обра­зом, усло­вие «ближ­него» про­дол­жения может быть толь­ко опе­рато­ром continue, и на язы­ке C/C++ вся эта конс­трук­ция будет выг­лядеть так:

while(a++ < 10) // <- инкремент var_a и условие далекого выхода

{

if (a == 2) continue; // <- условие «ближнего» продолжения

printf("%x\n",var_a); // <- тело цикла

} // <- безусловный переход на начало цикла

; --------------------------------

loc_140001120: ; CODE XREF: main+23↑j main+68↓j

; ^^^^^^^^^ ^^^^^^^^^

; Начало цикла

; Сравниваем переменную var_a с числом 0х2

cmp [rsp+38h+var_a], 2

; Если var_a != 2, то продолжение цикла

jnz short loc_140001129

; Переход к коду проверки условия продолжения цикла

jmp short loc_140001139

; Это, бесспорно, continue, и вся конструкция выглядит так:

; if (a == 2) continue;

; --------------------------------

loc_140001129: ; CODE XREF: main+45↑j

mov edx, [rsp+38h+var_a]

; printf("%x\n",var_a);

lea rcx, asc_140002254 ; "%x\n"

call printf

loc_140001139: ; CODE XREF: main+47↑j

mov eax, [rsp+38h+var_a]

; var_a--;

dec eax

; Обновление переменной в памяти значением из регистра

mov [rsp+38h+var_a], eax

; Сравнение var_a с нулем

cmp [rsp+38h+var_a], 0

; Пока (var_a > 0), продолжать цикл

jg short loc_140001120

По­хоже на пос­тусло­вие. В таком слу­чае исходный код дол­жен выг­лядеть так:

do

{

if (a == 2) continue;

printf("%x\n", var_a);

} while (--var_a > 0);

xor eax, eax

add rsp, 38h

retn

main endp

Visual C++ 2022 с включенной оптимизацией

А теперь пос­мотрим, как пов­лияла опти­миза­ция (/O2) на вид цик­лов:

; 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:000000014000400C↓o

push rbx

sub rsp, 20h

; Обнуление EBX: var_EBX = 0

xor ebx, ebx

loc_140001078: ; CODE XREF: main+20↓j

; Начало цикла:

; var_EBX++

inc ebx

; Сравнение var_EBX с 2

cmp ebx, 2

; Переход на метку loc_14000108D, если var_EBX == 2

jz short loc_14000108D

mov edx, ebx

; Если не ушли на метку в случае var_EBX != 2, выводим var_EBX на экран:

; printf("%x\n", var_EBX);

lea rcx, _Format ; "%x\n"

call printf

Сле­дова­тель­но, эту вет­ку кода мож­но изоб­разить так:

"if (var_EBX ! = 2) printf("%x\n", var_EBX);"

loc_14000108D: ; CODE XREF: main+D↑j

cmp ebx, 0Ah

; Продолжение цикла, пока (var_EBX++ < 0xA)

jl short loc_140001078

inc ebx

В ито­ге мы можем деком­пилиро­вать цикл сле­дующим обра­зом:

do

{

if (var_EBX != 2)

printf("%x\n", var_EBX);

} while (var_EBX++ < 0xA);

А что, выг­лядит впол­не читабель­но, не прав­да ли? Ничуть не хуже, чем

if (var_EBX == 2) continue.

loc_140001094: ; CODE XREF: main+3B↓j

; Начало цикла

cmp ebx, 2

; Переход на метку loc_1400010A7, если (var_EBX == 2)

jz short loc_1400010A7

mov edx, ebx

lea rcx, _Format ; "%x\n"

; printf("%x\n", var_EBX);

; Эта ветка выполняется, лишь когда (var_EBX != 2)

call printf

loc_1400010A7: ; CODE XREF: main+27↑j

; var_EBX--;

dec ebx

test ebx, ebx

; Условие продолжения цикла — крутить, пока (var_EBX > 0)

jg short loc_140001094

В ито­ге деком­пилиро­ван­ный цикл выг­лядит при­мер­но так:

do

{

if (var_EBX != 2)

printf("%x\n", var_EBX);

} while (--var_EBX > 0)

xor eax, eax–

add rsp, 20h

pop rbx

retn

main endp

C++Builder 10 с включенной оптимизацией

До­воль­но инте­рес­ный обра­зец кода получа­ется у C++Builder 10.4 с вклю­чен­ной опти­миза­цией по ско­рос­ти — /O2:

; int __cdecl main(int argc, const char **argv, const char **envp)

public main

main proc near ; DATA XREF: __acrtused+29↑o

; Ни одной локальной переменной, зато массовое использование регистров

push rbp

push rsi

push rdi

sub rsp, 20h

lea rbp, [rsp+20h]

; var_EAX = 1

mov eax, 1

; В RSI помещаем указатель на форматную строку

lea rsi, unk_44D000

nop dword ptr [rax+rax+00000000h]

loc_401420: ; CODE XREF: main+2A↓j main+3C↓j

;^^^^^^^^^ ^^^^^^^^^

; Начало первого цикла

; var_EDI = var_EAX

mov edi, eax ; Format

mov eax, 3

; Сравнение var_EDI и 2

cmp edi, 2

; Если (var_EDI == 2), переходим в начало цикла,

; это означает условие с continue

jz short loc_401420

; Первый параметр для printf — форматная строка

mov rcx, rsi

; Второй параметр для printf — число для вывода

mov edx, edi

call printf

; Таким хитрым образом C++Builder инкрементирует значение регистровой переменной

lea eax, [rdi+1]

; Сравниваем var_EDI с 0xA,

; В var_EDI хранится значение счетчика до инкремента

cmp edi, 0Ah

; Прыгаем в начало цикла, пока (var_EDI < 0xA)

jl short loc_401420

На осно­ве име­ющих­ся дан­ных мож­но деком­пилиро­вать этот код:

var_EAX = 1;

do

{

var_EDI = var_EAX;

var_EAX = 3;

if (var_EDI == 2) continue;

printf("%x\n", var_EDI);

}

while (var_EDI++ < 0xA);

Он получил­ся излишне перег­ружен­ным, опти­мизи­руем:

do

{

if (var_EDI != 2)

printf("%x\n", var_EDI);

}

while (var_EDI++ < 0xA);

; Подготовка для второго цикла

lea rsi, unk_44D000

db 66h, 66h, 2Eh

nop word ptr [rax+rax+00000000h]

loc_401450: ; CODE XREF: main+5A↓j main+6C↓j

; ^^^^^^^^^^ ^^^^^^^^^

; Начало второго цикла

; После выполнения первого цикла в var_EAX находится максимальное значение — 0xA

mov edi, eax ; Format

mov eax, 1

; Сравниваем var_EDI с 2

cmp edi, 2

; Если (var_EDI == 2), переходим в начало (второго) цикла

jz short loc_401450

; Подготовка параметров для вызова printf

mov rcx, rsi

mov edx, edi

call printf

; На этот раз декрементируем регистровую переменную

lea eax, [rdi-1]

cmp edi, 1

; Переходим в начало цикла, пока (var_EDI > 1)

jg short loc_401450

На осно­ве име­ющих­ся дан­ных этот код мож­но деком­пилиро­вать таким обра­зом:

var_EAX = 0xA;

do

{

var_EDI = var_EAX;

var_EAX = 1;

if (var_EDI == 2) continue;

printf("%x\n", var_EDI);

}

while (var_EDI-- > 1);

Оп­тимизи­рован­ный вари­ант ты с лег­костью пос­тро­ишь без моей помощи.

xor eax, eax

add rsp, 20h

pop rdi

pop rsi

pop rbp

retn

main endp

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

ЦИКЛЫ FOR С НЕСКОЛЬКИМИ СЧЕТЧИКАМИ

Нас­тала оче­редь цик­лов for, вра­щающих нес­коль­ко счет­чиков одновре­мен­но. Рас­смот­рим сле­дующий при­мер.

#include <stdio.h>

int main()

{

for (int a = 1, b = 10; (a < 10 || b > 1); a++, b--)

printf("%x %x\n", a, b);

}

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

Ди­зас­сем­блер­ный лис­тинг этой прог­раммы, пос­тро­енной в 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:0000000140004018↓o

var_b = dword ptr -18h

var_a = dword ptr -14h

; Резервируем память для двух локальных переменных

sub rsp, 38h

; Инициализируем локальные переменные:

; присваиваем var_a значение 0x1

mov [rsp+38h+var_a], 1

; присваиваем var_b значение 0xA

mov [rsp+38h+var_b], 0Ah

; Прыжок на код проверки условия выхода из цикла

; Это характерная черта неоптимизированных циклов for

jmp short loc_14000110A

; --------------------------------

; Перекрестная ссылка, направленная вниз, говорит о том, что это начало цикла

; А выше мы уже выяснили, что тип цикла — for

loc_1400010F6: ; CODE XREF: main+4D↓j

; var_a++;

mov eax, [rsp+38h+var_a]

inc eax

mov [rsp+38h+var_a], eax

; var_b--;

mov eax, [rsp+38h+var_b]

dec eax

mov [rsp+38h+var_b], eax

loc_14000110A: ; CODE XREF: main+14↑j

cmp [rsp+38h+var_a], 0Ah

; Если (var_a < 0xA), прыгаем в секцию продолжения цикла и вывода строки

jl short loc_140001118

cmp [rsp+38h+var_b], 1

; Выход из цикла, если (var_b <= 0x1)

jle short loc_14000112F

loc_140001118: ; CODE XREF: main+2F↑j

; Готовим параметры для вызова printf

mov r8d, [rsp+38h+var_b]

mov edx, [rsp+38h+var_a]

lea rcx, _Format ; "%x %x\n"

; Сам вызов printf:

; printf("%x %x\n", var_a, var_b);

call printf

; Безусловный переход в начало цикла

jmp short loc_1400010F6

; --------------------------------

loc_14000112F: ; CODE XREF: main+36↑j

xor eax, eax

add rsp, 38h

retn

main endp

Как и полага­ется, ком­пилятор учел оба усло­вия. Так как меж­ду ними сто­ит OR, если пер­вое выпол­няет­ся, вто­рое про­верять не име­ет смыс­ла, и прог­рамма переп­рыгива­ет на ста­дию вывода стро­ки. Если же пер­вое усло­вие не сра­баты­вает, то прог­рамма обра­баты­вает вто­рое усло­вие.

Оно было инверти­рова­но ком­пилято­ром на про­тиво­полож­ное по срав­нению с исходным. Поэто­му в слу­чае его выпол­нения про­исхо­дит выход из цик­ла и переход в завер­шающую сек­цию. Пос­ле это­го выпол­нение прог­раммы самоте­ком перехо­дит к под­готов­ке парамет­ров и выводу стро­ки. Пос­ле чего пос­редс­твом безус­ловно­го перехо­да осу­щест­вля­ется пры­жок в начало цик­ла.

Этот цикл мож­но пред­ста­вить как:

while(1)

{

var_a++;

var_b--;

if (var_a < 0xA || var_b > 0x1)

printf("%x %x\n", var_a, var_b);

}

Но по сооб­ражени­ям удо­бочи­таемос­ти име­ет смысл ском­поновать этот код в цикл for:

for (var_a = 1, var_b = 0xA; (a < 10 || b > 1); var_a++, var_b--)

printf("%x %x\n",var_a,var_b);

Ком­пилятор без опти­миза­ции встав­ляет безус­ловный переход на код про­вер­ки выхода из цик­ла. А как поведет себя этот же ком­пилятор с вклю­чен­ной опти­миза­цией?

; 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:000000014000400C↓o

; Один аргумент

arg_0 = qword ptr 8

mov [rsp+arg_0], rbx

push rdi

; Резервируем для него место в стеке

sub rsp, 20h

; Регистровые переменные:

; var_EDI = 0x1

mov edi, 1

; var_EBX = 0xA

lea ebx, [rdi+9]

loc_140001082: ; CODE XREF: main+31↓j

cmp edi, 0Ah

; Если (var_EDI < 0xA), переход на метку loc_14000108C, где происходит вывод строки

; и манипуляции со счетчиками

jl short loc_14000108C

cmp ebx, 1

; Если (var_EBX <= 1), идем на выход

jle short loc_1400010A3

loc_14000108C: ; CODE XREF: main+15↑j

; Готовим параметры, выводим строку

mov r8d, ebx

lea rcx, _Format ; "%x %x\n"

mov edx, edi

call printf

; Манипуляции со счетчиками:

; var_EDI++;

inc edi

; var_EBX--;

dec ebx

; Осуществляем безусловный переход в начало цикла

jmp short loc_140001082

; --------------------------------

loc_1400010A3: ; CODE XREF: main+1A↑j

mov rbx, [rsp+28h+arg_0]

xor eax, eax

add rsp, 20h

pop rdi

retn

main endp

Оп­тимизи­рован­ный код выг­лядит гораз­до понят­нее и яснее!

Ос­таль­ные ком­пилято­ры, в том чис­ле Visual C++, обра­баты­вают выраже­ния ини­циали­зации и модифи­кации счет­чиков кор­рек­тно в поряд­ке их объ­явле­ния в тек­сте прог­раммы.

ЗАКЛЮЧЕНИЕ

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

На этом мож­но пос­тавить точ­ку в наших изыс­кани­ях с цик­лами. Мы раз­грыз­ли все име­ющиеся типы цик­лов язы­ков C/C++ и Pascal/Delphi. Уви­дели, как их тран­сли­руют сов­ремен­ные вер­сии ком­пилято­ров этих язы­ков прог­рамми­рова­ния с вклю­чен­ной авто­мати­чес­кой опти­миза­цией и без нее. Авто­мати­чес­кая опти­миза­ция тво­рит с кодом чудеса! Она умень­шает количес­тво усло­вий и перехо­дов, уско­ряя прог­рамму. Иног­да пос­ле ее исполь­зования дизас­сем­блер­ный код ста­новит­ся более понятен, чем до нее.

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