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

7. Общи конструкции
Filename:
Title: