asm32.info
Keep it simple — code in asm

Глава 7. Общи конструкции

1. Увод

Програмистите на езици от високо ниво, много често смятат асемблера за труден език, защото са свикнали да мислят в определени конструкции и срещат трудности да опишат (и обмислят) алгоритъма в процесорни инструкции.

Става въпрос за фундаментални за езиците от високо ниво конструкции като "if-then-else", "while-do", "for", "case" и т.н.

Отгоре на всичко, не стига, че в асемблер няма такива конструкции, но се използва и прокълнатият оператор "goto", табуиран във всички съвременни езици от високо ниво.

Допълнителна трудност е, че псевдо-езиците използвани за водене на бележки и планиране на работата също са базирани на езици от високо ниво и затова описание на алгоритъм с използването им е безполезно за написването на този алгоритъм на асемблер.

Стига се до парадокси, когато програмист принуден да пише на асемблер написва програмата на C и след това на практика я компилира ръчно до асемблер. Или я компилира до асемблерен междинен сорс и след това представя машинно генерирания асемблер като свой. Резултата от подобни упражнения обикновено е посредствен, защото пречи на описанието на алгоритъма със средствата на езика.

А истината е, че за написване на добра програма на асемблер се изисква да се мисли на асемблер. И това съвсем не означава да се мисли на ниво отделни инструкции.

Асемблерните програмисти също мислят в абстрактни категории и използват готови набори от инструкции за да напишат програмата. Често тези готови набори по логиката си на работа много приличат на класическите конструкции от езиците от високо ниво. Разликата е в това, че асемблерните конструкции не са фиксирани, а се променят в зависимост от мястото на приложението им и текущият контекст на програмата. Това ги прави изключително гъвкави за да опишат точно това, което е нужно в момента по най-добрия начин.

Въпреки тази си изменчивост, тези конструкции се разпознават лесно и по тях логиката на програмата се проследява без да се анализира кода инструкция по инструкция.

Точно с тези конструкции ще се запознаем в текущата статия. Тоест, как на асемблер се правят проверки, цикли, разклонения, подпрограми и въобще се описва алгоритъм.

Преди да завърша този проточил се увод, искам да направя едно предупреждение. Често, начинаещите (и не само) асемблерни програмисти, научавайки за макросите решават, че подобни шаблонни набори от инструкции е много удобно да се кодират в макроси, които да имитират конструкциите от езиците от високо ниво, като "if-then-else", "while" и т.н. Разбира се, с достатъчно развит макро-език, това е възможно и дори лесно. Но в повечето случаи това е много лоша практика. Порочността ѝ се състои в премахването на изменчивостта на конструкциите. По този начин се унищожава гъвкавостта на въпросния код, който му позволява да се адаптира към локалните нужди на програмата. Много ясно, че в този случай, компилатор от високо ниво, който оптимизира кода винаги ще генерира по-добър код, от ерзац конструкциите изградени с макроси. Затова възможностите на макроасемблерите трябва да се използват с мярка и само там, където това е наистина необходимо. (Ако за даден код си зададете въпроса "Трябва ли да използвам макроси тук?", значи не трябва да използвате макроси.)

2. if-then-else: Хиляди видове условни оператори.

2.1. Механизъм на условните оператори в асемблер

Как въобще работят условните оператори в асемблер? В процесора има регистър наречен "регистър на състоянията". Всеки бит от него има определен смисъл, име и се нарича флаг. По същество, флаговете са булеви променливи, които заемат стойности TRUE или FALSE (1 или 0).

След всяка операция на АЛУ (аритметично-логическо устройство) на процесора, битовете от този регистър се установяват в стойности, съответстващи на резултата от операцията.

В x86 процесорите, главните флагове са:

CF - флаг за пренос/заем - "carry flag". Става 1 когато последната аритметична операция завърши с пренос в най-старшия бит. (Тоест, когато резултата не се събере в променливата в която искаме да го поставим. При 32 битова архитектура, по същество е 33-тия бит на резултата. При 64 битова, съответно 65-тия бит.

OF - флаг за препълване - "overflow flag". Става 1 когато има пренос от предпоследния в последния бит на резултата. Показва, че при операция с числа със знак, резултата е грешен, защото не са стигнали битовете на променливата. Като последствие се е сменил знака на резултата. Например при събиране на $7f (+127) с 1 се получава числото $80 (-128), защото 8 бита не стигат за да се запише резултата +128;

ZF - флаг за нула - "zero flag". Става 1, когато резултата от последната операция е 0.

SF - флаг за знак - "sign flag". Става 1, когато резултата от последната операция е отрицателно число. По същество е същият като най-старшия бит от резултата.

Има и други флагове, но те се използват по-рядко и няма да ги описвам тук.

Флаговете се използват в условните инструкции, които могат да извършват две различни действия в зависимост от стойността на някой от флаговете. В x86 архитектурата има два вида такива инстукции: Условни преходи и условен оператор mov. Тук ще разгледаме само условните преходи.

Общото обозначение за условен преход е Jcc, където J е общ префикс, идващ от "jump" (скочи), a "cc" е абревиатура на някое от условията които ще се проверяват. Абревиатурите са много, описани са във всички справочници в табличен вид с описание на флаговете, които участват в проверката. Затова тук няма да помествам подробни описания, а ще опиша само принципите по който аз ги помня.

Има две големи групи, които не могат да се смесват:

Проверки със знак. Тук се използва една от буквите G от "greater", "по-голямо" или L - "less", "по-малко"

Проверки без знак. Тук се използва една от буквите А - "above", "над" или B - "below", "под".

Към горните може да се добави отзад или да се използва самостоятелно E - "equal", "равно".

Винаги може да се добавя отпред на абревиатурата N - "not", "не" за инвертиране на значението.

Примери: JNE - преход при неравно. JLE - преход при по-малко или равно при операции със знак. JNAE - преход при не по-голямо или равно за операции без знак. Може да забележите, че последната инструкция е еквивалентна на JB - преход при по-малко без знак. И двете форми са позволени.

Освен аритметичните условия от по-горе има и условия проверяващи отделни флагове: S - "sign", "знак", Z - "zero", "нула", C - "carry", "пренос", O - "overflow", "препълване" проверяват състоянието на флаговете SF, ZF, CF и OF.

Префикса N може да се използва и с тях.

Пример: JNS - преход при не-отрицателно. JC - преход при CF=1.

Въобще, едно и също условие може да се зададе по няколко начина (както горния пример: jnae и jb). Всички варианти са валидни и еквивалентните условия генерират един и същ машинен код. Кой точно код ще се използва, зависи от предпочитанията на програмиста.

Аз лично препоръчвам да се избира винаги този код, който най-точно отговаря на вътрешната логика на програмата в това място.

Един малък пример: инструкциите je и jz са синоними и напълно еквивалентни. Обаче, ако сравняваме две числа, то по-логично е да използваме аритметичната версия: je.

Ако извършваме някакво аритметично действие и искаме да проверим дали неговия резултат е нула, по-правилно е да използваме директната проверка на флаговете: jz.

За проверките по-малко, по-голямо аз лично винаги използвам положителните форми: jae, jb, jg, jle и не използвам формите с отрицание: jnb, jnae, jnle, jng. Смятам, че така логиката е по-ясна. Някой друг може да е на друго мнение.

Както казах, флаговете се установяват като резултат от аритметични действия. Сравнението между две числа, също е аритметична операция - изважда се второто число от първото. Ако резултата е положителен, то второто число е по-малко от първото. Ако резултата е нула, то числата са равни и ако резултата е отрицателен, то първото число е по-малко от второто.

Често обаче не ни трябва резултата от изваждането, а искаме само да сравним две стойности. За това служи инструкцията cmp - "compare", "сравни". Всъщност тази инструкция извършва аритметично изваждане на второто число от първото, само че не записва резултата никъде, а само установява флаговете във регистъра на състоянията.

Много подобна по действие инструкция е test. С тази разлика, че вместо изваждане, тя извършва операция логическо И, тоест действието ѝ е същото като на инструкцията and. Резултата не се записва никъде, а флаговете се установяват както след and. Тази инструкция променя флаговете ZF и SF, но не променя CF. Затова след нея обикновено се използват инструкциите JS, JZ, JNS, JNZ, но не и аритметичните условни инструкции.

2.2. Общ вид и разновидности

Същността на условната конструкция е, че искаме да проверим някакво условие, да изпълним някакъв код ако условието е изпълнено и евентуално да изпълним друг код, ако условието не е изпълнено. След това изпълнението да продължи след цялата конструкция.

Общият вид на асемблер на тази конструкция изглежда така:

проверка Jcc else_code ; прескачаме, ако не е изпълнено. ; секция then ... ... ... jmp continue_code else_code: ; секция else. ... ... ... continue_code: ...

Забележете, че условието в Jcc трябва да е обратното на това, което ни трябва за да се изпълни кода в "then". Разбира се, всичко това са условности. Какво ще наречем "then" и какво "else" зависи само и единствено от нашите предпочитания. Обикновено обаче, в асемблерните програми се опитваме да направим главния поток на логиката да върви отгоре надолу без разклонения. Това подобрява четимостта на кода, а често и скоростта на изпълнение.

Разновидностите на горния код могат да са много, но общият шаблон винаги се запазва:

Без else е по-просто, а кода има по-малко разклонения:

проверка Jcc continue_code ; условен код ... ... ... continue_code: ...

При x86, където част от инструкциите не влияят на флаговете (тоест на резултата от проверката), може да се използва и отложена проверка:

проверка *** ; инструкции, които не променя тези флагове, които ни интересуват *** ; mov, lea, понякога може inc/dec (не променя CF) и др. *** Jcc continue_code ... ... ... continue_code: ...

В горния случай, на пръв поглед четимостта на кода се влошава, но ако се помни, че инструкция за условен преход обикновено не стои след инструкция не променяща флаговете, то шаблона лесно се различава. Например, ако видим следния код:

mov ecx, 0 lea eax, [eax+2] ; увеличава eax с 2 без да променя флаговете. jnz .continue ... ... ... .continue:

Очевидно е, че след като нито mov нито lea променят флаговете, условието на конструкцията трябва да се търси по-горе в кода.

Огромната гъвкавост на асемблер се проявява изключително силно в кода, който проверява условието. В много случаи самата проверка, като такава отсъства, тъй като резултата ѝ (стойностите на флаговете) се получава при някое от другите действия.

Следващият код не е оптимален:

add ecx, edx cmp ecx, 0 je .ecx_is_zero

Защо да използваме cmp, когато нужните флагове се установяват от инструкцията add? Правилния код е:

add ecx, edx jz .ecx_is_zero

2.3. Множествени разклонения (switch/case)

Буквалния аналог на тази конструкция най-често изглежда като поредица от проверки и условни преходи:

mov eax, [case_var] cmp еax, case_1 je case1 cmp eax, case_2 je case2 ... ... cmp eax, case_N je caseN default: ... ... jmp continue case1: ... ... jmp continue case2: ... ... jmp continue ... ... ... caseN: ... ... continue: ...

Както винаги, има и обратен вариант в който блоковете за всеки случай са вградени в тялото на конструкцията:

mov eax, [case_var] cmp eax, case_1 jne not_case1 ;case1 ... ... ... jmp continue not_case1: cmp eax, case_2 jne not_case2 ;case2 ... ... ... jmp continue not_case2: ... ... ... not_caseN_1: cmp eax, case_N jne default ;caseN ... ... ... jmp continue default: ... ... continue: ...

Като цяло подобни конструкции са доста обемисти и нечетливи. Често скоростта на работа също не е идеална. Затова в практиката, понякога се използват таблици за преходи, особено, ако възможните разклонения са много. Варианта на switch с таблица е малък, компактен и четлив:

mov eax, [case_var] cmp eax, maxN ja default jmp [JumpTable+4*eax] maxN = N JumpTable dd case0, case1, ... caseN default: ... ... jmp continue case1: ... jmp continue ... ... caseN: ... ... continue: ...

Още по-компактно може да се получи, ако различните случаи се оформят като подпрограми:

mov eax, [case_var] cmp eax, maxN ; дали не излизаме от таблицата? jbe callit mov eax, maxN+1 callit: call [CallTable+4*eax] ... ... ; някъде в сорса, извън главния поток инструкции. maxN = N CallTable dd case0, case1, ... caseN, default case0: ... ret case1: ... ret ... ... caseN: ... ret default: ... ret

Вижда се, че последния вариант е най-изчистен. Вярно, за сметка на бързодействието.

Таблиците с преходи са много удобни, ако разклоненията са за всички стойности от 0 до някакво число. Ако има стойности, които не искаме да проверяваме, можем да оставим нули на тези места в таблицата, но ако такива стойности са много, таблицата ще стане неприемливо голяма.

Ето и варианта с липсващи стойности (в случая не се обработва стойността 1):

mov eax, [case_var] cmp eax, maxN ; дали не излизаме от таблицата? jbe checkit mov eax, maxN+1 checkit: mov eax, [CallTable+4*eax] test eax, eax jnz callit: mov eax, default callit: call eax ... ... ; някъде в сорса, извън главния поток инструкции. maxN = N CallTable dd case0, 0, ... caseN, default case0: ... ret ... ... caseN: ... ret default: ... ret

3. Цикли

Под цикъл в асемблер се разбира, когато имаме преход назад в програмата и даден участък се повтаря повече от един път. Явно деление на видове: "while", "for" и т.н. няма. Циклите могат и често носят признаците на няколко вида цикъл от езиците от високо ниво.

Общия вид на цикъла е:

cycle: ... ... ... проверка Jcc cycle

Единственият постоянен признак за цикъл е преходът назад в програмата на място, което ще осигури повтаряне на част от кода. Вида на прехода, както и начина на излизане от цикъла могат да варират значително. Горния пример може да се смята за цикъл със след-проверка (примерно като "repeat-until" в Паскал или "do-while" в C). Цикъла с пред-проверка (подобен на while) също е възможен, но се използва по-рядко, само когато не може да се замести с цикъл със след-проверка:

cycle: проверка Jcc end_cycle ... ... ... jmp cycle end_cycle: ...

Разбира се, в асемблер, проверката и излизането от цикъла може да се намира на произволно място, а съвсем не само в началото или края. Когато се прави цикъл, много лесно е да се навреди на четимостта на кода. Затова програмирането на цикли трябва да се прави с повишено внимание и да се пишат коментари, където може да бъде неясно.

Когато цикъла трябва да бъде изпълнен определен брой пъти, за брояч се използва някой от регистрите на процесора. Най-често броенето се прави надолу, защото е по-лесно за проверка:

mov edx, 42 cycle: ... ... ... dec edx jnz cycle

Сравнете със случая, когато се брои напред. Вижда се, че проверката изисква една инструкция повече, а и броя повторения е закопан на дъното на цикъла, а това влошава четимостта:

xor edx, edx cycle: ... ... ... inc edx cmp edx, 42 jne cycle

Разбира се, ако променливата на цикъла се използва за някакви изчисления, може да се окаже задължително да броим напред. Но в практиката тази конструкция се избягва. При наличие на свободни регистри, често удачно се оказва решението с отделяне на променливата на цикъла в отделен регистър:

mov edx, 42 ; брояч на цикълаа. xor ebx, ebx ; променлива, която ще се увеличава. cycle: ... ... ... inc ebx dec edx jnz cycle

Използването на регистър не е задължително. Спокойно можем да използваме и променлива в паметта, макар че ще е по-бавно:

mov [i], 42 cycle: ... ... ... dec [i] jnz cycle

4. Подпрограми (Процедури и функции).

Първо за терминологията. В C/C++ подпрограмите се наричат "функции" и винаги връщат стойност, която ако не ни трябва не ползваме. В Паскал има два вида подпрограми "процедури" и "функции", като процедурите не връщат стойност, а функциите връщат стойност, която трябва да използваме.

В асемблер, няма установена терминология. Използват се всички термини, под влияние на езиците от високо ниво.

Аз лично използвам термина "подпрограма" в общият случай, "процедура", когато се използват макроси за дефиницията (виж по-долу) и "функция", когато става въпрос за външна подпрограма - от библиотека или системните функции на операционната система.

Връщането на стойност в асемблер зависи единствено от програмиста - може да се връщат няколко стойности едновременно, или да не се връща нищо.

В асемблер, подпрограмите са просто код, завършващ с инструкцията ret. Подпрограмата се вика с инструкцията call в която се указва адреса на подпрограмата. Начина на предаване на параметри и връщане на резултат е изцяло оставен на програмиста.

На практика се използват няколко стандартни варианта:

4.1. Предаване на параметри в регистри.

Най-простият и най-бързият начин за предаване на параметри в подпрограмите е да заредим стойностите в няколко регистъра откъдето подпрограмата да ги използва. Примерно подпрограма за намиране на средно-аритметично на две числа може да изглежда така:

mov eax, 42 mov ebx, 24 call mean ... mean: add eax, ebx ; eax = eax + ebx sar eax, 1 ; eax = eax / 2 ret

За съжаление този метод има някои недостатъци, които го правят подходящ само за съвсем прости подпрограми и то такива, които се викат от ограничени места на главната програма. Проблемите са няколко.

Първо, регистрите в които изпращаме параметрите може да се окажат заети на мястото, където искаме да извикаме подпрограмата. Колкото повече параметри има подпрограмата, толкова по-вероятно е поне някои от нужните регистри да са заети. В този случай се налага съдържанието на регистрите да се запише в паметта (обикновено в стека) преди извикването на подпрограмата, да се заредят стойностите на аргументите и след извикването на подпрограмата да възстановим стойностите на регистрите от паметта. Тези процедури със сигурност ще анулират всички предимства на дадения метод.

Второ, ако подпрограмата извършва достатъчно сложна обработка, то самата тя ще има нужда от свободни регистри. А те са заети с аргументите. Тоест подпрограмата ще трябва да ги запомни някъде в паметта преди да започне обработката. В този случай, ако и първия случай е валиден ще имаме няколкократно записване на регистрите в паметта и след това четене на същите регистри от паметта. Това няма как да е икономично

Трето - при регистровото предаване на параметри е доста трудно викането на подпрограмата рекурсивно. Това отново е свързано със записване на параметрите в паметта, презареждането им с нови параметри и отново след това възстановяване на предишните стойности.

За щастие, параметрите могат да се предават и по друг начин:

4.2. Предаване на параметрите през стека.

В x86 архитектурата има специално разработени механизми за предаване на параметри чрез стека. Най-важния от тях е това, че инструкцията за връщане от подпрограма ret допуска числова константа, която указва колко байта от стека трябва да се извадят освен адреса за връщане. По този начин, подпрограмата може сама да почисти от стека изпратените ѝ аргументи, при това лесно и бързо. Този начин на предаване на параметри се нарича STDCALL. Процесорите нямащи тези възможности, са принудени да използват т.н. CDECL конвенция, където стека се почиства от викащата програма. Тази конвенция определено не е подходяща за програмиране на асемблер, защото замърсява сорса и вреди на четимостта.

Като код (32 bit) всичко това изглежда така:

push argN .... push arg1 call procedure .... procedure: push ebp mov ebp, esp mov eax, [ebp+8] ; arg1 add eax, [ebp+4+4*N] ; argN ... ... pop ebp ret N*4

Вижда се, че регистъра EBP се използва за да сочи към аргументите. Реално, може да се използва и регистъра ESP (указателя на стека) обаче използването му има един голям недостатък - ако стека се използва, то регистъра esp си променя стойността и тогава аргументите трябва да се търсят на друго отместване. Затова в началото на ebp се присвоява текущата стойност на стековия указател, така че, дори и по-късно той да се промени, аргументите ще могат да се ползват, сочени от ebp.

Горния код има един голем недостатък - достъпа до аргументите е много нечетлив. По-добре е да ги използваме чрез някакви символни имена, вместо чрез индиректната адресация с разни числови отмествания.

Проблема е лесен за решаване чрез използване на макро-инструкции, каквито предоставя всеки добър асемблер. Ако си спомняте предупреждението за макросите от по-горе, то това е точно случая в който използването на макроси е оправдано и не води до почти никакви отрицателни ефекти. Работата е там, че структурата на подпрограмата при предаване на параметри чрез стека е стандартна и неизменна. Тоест, използването на макроси няма да ни лиши от гъвкавост и адаптивност, защото те и така липсват в този случай.

Горния пример, при използване на макросите от FreshLib ще изглеждат така (ще се генерира абсолютно същия код):

stdcall Subroutine, arg1, arg2, ... , argN ... ... proc Subroutine, .arg1, .arg2, ... , .argN begin mov eax, [.arg1] add eax, [.argN] .... return end

Разбира се, известна гъвкавост се запазва и тук. Например, ако аргументите не са достъпни едновременно, можем да ги вкарваме в стека постепенно, като не забравяме, че трябва да се вкарват отзад-напред:

push argN ... ; изчисления ... push arg2 push arg1 call Subroutine ; аргументите са вече в стека.

4.3. Локални променливи в подпрограмите

Освен аргументите, подпрограмите често имат нужда от допълнителни променливи. Разбира се могат да се използват обикновени променливи в паметта (глобални променливи). Проблема с тях е, че те не позволяват викането на подпрограмите едновременно от няколко нишки. Тоест, подпрограмите използващи глобални променливи не са реентрантни. Същите проблеми ще се появят и при рекурсивно викане на подпрограмата. И в двата случая, всяка извикана подпрограма трябва да си създава отделно копие на нужните променливи.

Това се прави чрез отделяне на определено пространство в стека, което се използва за локалните променливи:

Без използване на макроси, това изглежда така:

push argN .... push arg1 call procedure .... procedure: push ebp mov ebp, esp sub esp, 1024 ; отделяме 1024 байта за локални променливи. mov eax, [ebp+8] ; arg1 add eax, [ebp+4+4*N] ; argN mov [ebp-1024], eax ; запомняме резултата в локална променлива. ... ... mov esp, ebp ; освобождаваме локалните променливи. pop ebp ret N*4

Вижда се, че и аргументите и локалните променливи се сочат от ebp, само че, аргументите са на положителни отмествания, а локалните променливи на отрицателни.

Отново, нещата могат да се направят значително по-четливи, без ущърб за качеството, ако се използват макроси:

stdcall Subroutine, arg1, arg2, ... , argN proc Subroutine, .arg1, .arg2, ... , .argN .sum dd ? .other rb 1020 begin mov eax, [.arg1] add eax, [.argN] mov [.sum], eax .... return end

При този синтаксис, всички променливи, декларирани между "proc" и "begin" се смятат за локални, изчислява се необходимото пространство в стека и необходимия код се генерира автоматично.

Максималния размер на локалните променливи не е ограничен, но ако искаме да отделим по този начин повече от 4096 байта, следва да се спазва специална процедура, чието разглеждане излиза извън рамките на настоящата статия. Ще кажа само, че това ограничение идва от операционната система, а не от процесора.

©2014 John Found

Last modified on: 13.12.2014 14:30:50

Preview

Comments

Title: Filename: