Глава 6. Пишем преносимa програма
1. Увод
По принцип, четните статии в цикъла са практически. Затова няма да Ви занимавам с много теория, а ще преминем по-бързичко към примерите.
В тази статия, ще напишем програма на асемблер, която ще може да се компилира за Windows и за Linux.
За целта ще използваме библиотеката FreshLib, която осигурява прост начин за писане на преносими програми на асемблер. Нещо повече, ако в бъдеще FreshLib започне да поддържа друга операционна система, то нашата програма ще може без промени да се компилира и за нея.
Библиотеката е предназначена главно за програмиране в средaта Fresh, но нищо не пречи да се използва с коя да е версия на flat assembler. Ние ще я използваме с FASMW.
За да е упражнението по-полезно и приятно, ще направим програма, която да върши нещо полезно. Да преброява думите в зададен файл и да извежда този брой на конзолата.
Особенното при използване на реален проект в урок като този е, че в първият момент, програмата може да ви се стори твърде сложна. Работата е там, че в реална програма се налага да се извършват дейности, които не са пряко свързани с пряката дейност на програмата. Става въпрос за всякакви проверки от рода на входните параметри, валидността на данните, обработка на всякакви видове грешки и др.под.
Аз обаче съзнателно избрах този подход към примерната програма, защото рано или късно всеки програмист трябва да се научи да прави тези неща правилно и колкото по-рано, толкова по-добре.
Още повече, че на "втори поглед" се оказва, че тази сложност е само видимост, а програмата всъщност е по-проста отколкото ни се струва.
Така че, смело напред!
2. Инсталиране на библиотеката FreshLib
Но първо, ще трябва да се снабдим с библиотеката. Това е просто - сваляме си я от интернет. Разопаковайте архива някъде на знайно място.
3. Настройка на FASMW
Ще ни трябва FASMW с версия от 1.70 нагоре. Как да инсталирате FASMW и как да го настроите, можете да прочетете в Глава 0 от този цикъл статии.
Допълнително, добавете във файла FASMW.INI (от папката на FASMW), в секцията "Environment", следните редове:
lib = знайното място
TargetOS = Win32
В горният код, "знайното място" е директорията, където сте разопаковали библиотеката FreshLib. В тази директория се намират файловете "freshlib.inc" и "freshlib.asm".
Запишете FASMW.INI файла и стартирайте FASMW.
От настройките на FASMW "Options|Compiler setup" задайте памет за компилатора 131072KB.
4. Въвеждане на кода, компилиране за Windows и Linux
Въведете следният сорс (можете да го изтеглите като zip файл. ):
include "%lib%/freshlib.inc"
_BinaryType console ; казваме на компилатора, че искаме конзолна програма.
include "%lib%/freshlib.asm"
start:
InitializeAll ; Инициализираме всички библиотеки.
stdcall GetCmdArguments ; тази функция връща масив пълен със стрингове, съдържащи
; аргументите от командната линия. Първият елемент от
; масива винаги съдържа името на програмата,
; тоест, на нас ни трябва вторият елемент от списъка.
mov edi, eax ; функцията връща указател към динамичен масив, който във
; FreshLib има тип TArray
cmp [edi+TArray.count], 2
jne .error_arguments ; ако аргумента не е точно един, печатаме
; грешка и излизаме.
stdcall LoadBinaryFile, [edi+TArray.array+4] ; вторият аргумент е името на файла.
; прочитаме го в паметта.
jc .error_file_read
mov esi, eax ; това е указател към файла в паметта.
mov ebx, eax ; запазваме адреса в ebx, за да можем да освободим
; паметта, когато приключим.
; проверяваме дали файла започва с BOM (windows notepad слага
mov eax, [esi]
and eax, $ffffff
cmp ax, $FEFF ; low endinan unicode - не се поддържа.
je .error_format
cmp ax, $FFFE ; big endian unicode - не се поддържа.
je .error_format
cmp eax, $BFBBEF ; UTF-8 BOM
jne .utf8_ok
add esi, 3 ; прескачаме символа BOM, за да не го преброим като дума.
.utf8_ok:
; започваме да броим думите.
; в AL ще прочитаме всеки пореден символ, а в AH ще пазим символът от
; предишното преминаване през цикъла.
; Дума ще преброяваме тогава, когато срещнем символ за интервал и предишният
; символ е бил различен от интервал.
xor ecx, ecx
xor eax, eax
.loop:
mov al, [esi]
inc esi
cmp al, ' ' ; ако поредният символ е интервал (всъщност всички контролни
; символи се броят за интервал, тоест и празните редове, табове
; и т.н., защото и те могат да разделят думи.
jbe .space
.next:
mov ah, al
jmp .loop
; ако е интервал...
.space:
cmp ah, ' '
jbe .not_a_word
inc ecx
.not_a_word:
test al, al
jnz .next
; Извеждаме резултата от броенето по интелигентен начин
.file_end:
; началото на съобщението: "The file contains "
stdcall FileWrite, [STDOUT], cOutputMessage1, cOutputMessage1.length ; начало на съобщението
; броят в ECX го превръщаме в стринг.
; за можем да го изведем.
stdcall NumToStr, ecx, ntsUnsigned or ntsDec
mov edx, eax ; запазваме стринга за да го освободим по-късно
stdcall StrLen, edx ; дължината на стринга отива в ESI
mov esi, eax
stdcall StrPtr, edx ; адреса на стринга се връща в EAX
stdcall FileWrite, [STDOUT], eax, esi ; извеждаме броя.
stdcall StrDel, edx ; изтриваме стринга, защото не ни трябва повече.
; сега трябва да решим дали ще извеждаме "word" ако броят е равен на 1
; или "words" ако броят е повече от 1.
mov eax, cOutputMessage3 ; приготвяме се за множествено число.
mov edx, cOutputMessage3.length
cmp ecx, 1
jne .suffix
mov eax, cOutputMessage2 ; броят все пак е 1, тоест трябва да извеждаме в единствено число.
mov edx, cOutputMessage2.length
.suffix:
stdcall FileWrite, [STDOUT], eax, edx
stdcall FreeMem, ebx
jmp .finish
; Обработка на различните грешки. Извеждането на съобщението става
; всъщност от .display_error като адреса на съобщението трябва да е в EAX,
; а дължината му в ECX
.error_format:
mov eax, cErrorFormat
mov ecx, cErrorFormat.length
jmp .display_error
.error_arguments:
mov eax, cErrorArguments
mov ecx, cErrorArguments.length
jmp .display_error
.error_file_read:
mov eax, cErrorFileRead
mov ecx, cErrorFileRead.length
.display_error:
stdcall FileWrite, [STDERR], eax, ecx
; Приключваме програмата
.finish:
FinalizeAll ; финализираме всички библиотеки.
stdcall Terminate, 0
; Съобщения за грешки.
cErrorArguments text 'Error! Wrong argument count.', 13, 10, 'Usage: word_count filename', 13, 10
cErrorFileRead text 'Error read file.', 13, 10
cErrorFormat text 'Error! Unsupported format.', 13, 10
; Текстове, с които се извежда резултата.
cOutputMessage1 text 'The file contains '
cOutputMessage2 text ' word.', 13, 10
cOutputMessage3 text ' words.', 13, 10
; Автоматично генериране на таблицата за импорт и блоковете с данни.
; В случая и данните и импортите се генерират вътре в кодовата секция на програмата.
;
; Ако се използват _AllImportSection и _AllDataSection тези блокове ще се генерират
; като отделни секции. Това обаче ще доведе до по-голям изпълним файл във Windows.
_AllImportEmbeded
_AllDataEmbeded
Запишете кода под името "word_count.asm" и го компилирайте с Ctrl+F9. Ще получите изпълним файл с
име "word_count.exe", тъй като до тук компилирахме за Windows - ако си спомняте реда: TargetOS = Win32
в FASMW.INI;
Сега, отворете отново FASMW.INI и сменете въпросният ред на TargetOS=Linux
.
Компилирайте отново. Сега ще получите файл с име "word_count" - това е програмата, компилирана за
Линукс.
Можете да тествате всяка от двете програми, като в конзолата напишете:
word_count word_count.asm
С което ще накараме програмата да преброи думите в собственият си текст.
5. Анализ на кода
Сега е време да анализираме сорса на програмата, блок по блок, за да разберем точно кое как работи.
Ако вече сте разгледали сорса на програмата, сигурно вече сте забелязали, че повече от половината са коментари, които разясняват каква е логиката на отделните редове.
В програмата могат да се обособят няколко логически блока:
5.1. Анализ на входните параметри
stdcall GetCmdArguments ; тази функция връща масив пълен със стрингове, съдържащи
; аргументите от командната линия. Първият елемент от
; масива винаги съдържа името на програмата,
; тоест, на нас ни трябва вторият елемент от списъка.
mov edi, eax ; функцията връща указател към динамичен масив, който във
; FreshLib има тип TArray
cmp [edi+TArray.count], 2
jne .error_arguments ; ако аргумента не е точно един, печатаме
; грешка и излизаме.
В този блок прочитаме от системата, аргументите зададени в конзолата при стартиране и ги анализираме за грешки.
В случая, анализът е простичък - проверяваме само броя на аргументите. Тъй като първият от аргументите е името на програмата, то на нас ни трябват точно 2 аргумента. Ако броят им е различен, отиваме да изведем съобщение и да приключим програмата.
5.2. Прочитане на файла в паметта
stdcall LoadBinaryFile, [edi+TArray.array+4] ; вторият аргумент е името на файла.
; прочитаме го в паметта.
jc .error_file_read
mov esi, eax ; това е указател към файла в паметта.
mov ebx, eax ; запазваме адреса в ebx, за да можем да освободим
; паметта, когато приключим.
Тук използваме функция от FreshLib, която прочита целият файл в паметта и връща указател към прочетената информация в EAX.
Паметта е динамично заделена, така че, когато не ни трябва повече, трябва да я освободим.
Забележете, че ако при четенето е възникнала грешка, флага CF ще има стойност 1 при връщане от процедурата. В този случай, EAX ще съдържа код на грешката, а не адрес в паметта. Трябва да обработваме този случай, за да се избегне обработката на несъществуващи данни и забиване на програмата.
5.3. Проверка на формата на файла
; проверяваме дали файла започва с BOM (windows notepad слага такъв и на UTF-8 файловете)
mov eax, [esi]
and eax, $ffffff ; трябват ни само първите 3 символа от файла, затова нулираме 4-тия.
cmp ax, $FEFF ; low endinan unicode - не се поддържа.
je .error_format
cmp ax, $FFFE ; big endian unicode - не се поддържа.
je .error_format
cmp eax, $BFBBEF ; UTF-8 BOM
jne .utf8_ok
add esi, 3 ; прескачаме символа BOM, за да не го преброим като дума.
.utf8_ok:
Тук се опитваме да установим в какъв формат е файла и можем ли да го обработим. Нашата програма ще обработва файлове в ANSI или UTF-8 формат.
Формати с 2 байта на символ няма да се поддържат и съответно е добре да можем да установим този случай и да завършим програмата културно - със съобщение за грешка.
Допълнително, при кодировка UTF-8 в началото на файла може да има, а може и да няма BOM символ (byte order mark) който да показва, че формата е UTF-8. Нас този символ не ни интересува, но трябва да го прескачаме, ако го има, за да не го преброим като дума от текста.
5.4. Същинската работа
; започваме да броим думите.
; в AL ще прочитаме всеки пореден символ, а в AH ще пазим символът от
; предишното преминаване през цикъла.
; Дума ще преброяваме тогава, когато срещнем символ за интервал и предишният
; символ е бил различен от интервал.
xor ecx, ecx
xor eax, eax
.loop:
mov al, [esi]
inc esi
cmp al, ' ' ; ако поредният символ е интервал (всъщност всички контролни
; символи се броят за интервал, тоест и празните редове, табове
; и т.н., защото и те могат да разделят думи.
jbe .space
.next:
mov ah, al
jmp .loop
; ако е интервал...
.space:
cmp ah, ' '
jbe .not_a_word
inc ecx
.not_a_word:
test al, al
jnz .next
В този блок от програмата се извършва същинската дейност, заради която започнахме всичко - броенето на думите.
Как става това? Прочитаме текста символ по символ в цикъл, като за всеки символ проверяваме дали не е "интервал".
В случая за "интервал" се приемат всички символи, които са по-малки или равни на шпация (код 32 десетично и $20 шестнайсетично). По този начин в понятието "интервал" включваме и символите за таб, нов ред и край на файл, които също могат да отделят думите една от друга.
За дума приемаме ситуацията в която срещнем символ за интервал, а предишният обработен символ е бил "не интервал". В този случай преброяваме дума.
За край на файла се смята символ с код 0.
5.5. Извеждане на резултата
; Извеждаме резултата от броенето по интелигентен начин
.file_end:
; началото на съобщението: "The file contains "
stdcall FileWrite, [STDOUT], cOutputMessage1, cOutputMessage1.length ; начало на съобщението
; броят в ECX го превръщаме в стринг.
; за можем да го изведем.
stdcall NumToStr, ecx, ntsUnsigned or ntsDec
mov edx, eax ; запазваме стринга за да го освободим по-късно
stdcall StrLen, edx ; дължината на стринга отива в ESI
mov esi, eax
stdcall StrPtr, edx ; адреса на стринга се връща в EAX
stdcall FileWrite, [STDOUT], eax, esi ; извеждаме броя.
stdcall StrDel, edx ; изтриваме стринга, защото не ни трябва повече.
; сега трябва да решим дали ще извеждаме "word" ако броят е равен на 1
; или "words" ако броят е повече от 1.
mov eax, cOutputMessage3 ; приготвяме се за множествено число.
mov edx, cOutputMessage3.length
cmp ecx, 1
jne .suffix
mov eax, cOutputMessage2 ; броят все пак е 1, тоест трябва да извеждаме в единствено число.
mov edx, cOutputMessage2.length
.suffix:
stdcall FileWrite, [STDOUT], eax, edx
stdcall FreeMem, ebx
jmp .finish
Извеждането, макар и дълго не е нещо особенно. Извеждаме началото на съобщението "The file contains " след това извеждаме преброеното количество, като първо чрез FreshLib процедурата NumToStr го превръщаме в стринг и го извеждаме на конзолата.
След това извеждаме края на съобщението "words." и символите за нов ред. В този код има една особенност, ако броя на думите е 1, то извеждаме думата "word" в единствено число, а ако броят е различен от 1, то извеждаме "words" в множествено число.
Разбира се това е донякъде излишно, но какво пък... :)
Накрая освобождаваме заетата памет и отиваме на края на програмата.
5.6. Обработка на грешки
; Обработка на различните грешки. Извеждането на съобщението става
; всъщност от .display_error като адреса на съобщението трябва да е в EAX,
; а дължината му в ECX
.error_format:
mov eax, cErrorFormat
mov ecx, cErrorFormat.length
jmp .display_error
.error_arguments:
mov eax, cErrorArguments
mov ecx, cErrorArguments.length
jmp .display_error
.error_file_read:
mov eax, cErrorFileRead
mov ecx, cErrorFileRead.length
.display_error:
stdcall FileWrite, [STDERR], eax, ecx
В обработката на грешки няма нищо особенно. Зареждаме адреса на съобщението за грешка в EAX, а дължината му в ECX и отиваме на .display_error, където грешката се извежда на изхода за грешки STDERR.
5.7. Дефиниране на текстовите константи
; Съобщения за грешки.
cErrorArguments text 'Error! Wrong argument count.', 13, 10, 'Usage: word_count filename', 13, 10
cErrorFileRead text 'Error read file.', 13, 10
cErrorFormat text 'Error! Unsupported format.', 13, 10
; Текстове, с които се извежда резултата.
cOutputMessage1 text 'The file contains '
cOutputMessage2 text ' word.', 13, 10
cOutputMessage3 text ' words.', 13, 10
Накрая дефинираме и текствовите константи, които са ни нужни в различните части на програмата. Всъщност, не е необходимо тези дефиниции да са на едно място. В случая съм ги събрал, за да могат да се възприемат заедно.
Обикновенно такива константи се декларират в мястото, където са нужни.
Когато дефинираме текстови константи с макроса "text", то те се разполагат в секцията за данни на програмата, а не там където са дефинирани. Това позволява "text" да се използва там където е нужен стринг, а подреждането остава за компилатора.
6. Използвани инструкции и кратък справочник.
Съвсем накратко кои инструкции се използват в примерната програма и какво прави всяка от тях. За подробно описание на инструкциите ще трябва да потърсите в интернет.
stdcall
- това всъщност не е инструкция, а макрос, който вкарва зададените аргументи в стека и вика зададената функция,
според конвенцията "STDCALL".
stdcall procedure, arg1, arg2, arg3
ще се компилира до следният код:
push arg3 ; вкарва в стека agr3
push arg2
push arg1
call procedure ; вика подпрограмата "procedure"
jc
- преход при флаг CF=1
jnc
- преход при флаг CF=0
je
, jz
- преход при равно (равно на нула) - това е всъщност една инструкция, която има две имена.
jne
, jnz
- преход при не равно (не равно на нула) - това също е една инструкция.
jbe
- преход при "под" или "равно". Представлява преход при по-малко или равно, при числа без знак.
jae
- преход при "над" или "равно". Преход при по-голямо или равно, при числа без знак.
jl
, jle
, jg
, jge
- преход съответно при по-малко, по-малко или равно, по-голямо, по-голямо или равно,
за числа със знак.
inc
- увеличава операнда с единица.
cmp
- сравнява двата операнда един с друг и установява съответните стойности на флаговете. Операндите не се променят.
След тази инструкция, обикновенно следва условен преход от някакъв вид.
xor ecx, ecx
извършва операция "изключващо или" на двата операнда. Ако двата операнда са еднакви, ресултата е нула.
Подобен начин за нулиране е общоприет, по-кратък е от mov ecx, 0
и е донякъде по-четлив, защото така декларираме, че
искаме именно да нулираме регистъра, а не да вкараме в него някаква константа, която случайно е нула.
and
- логическа "операция И".
add
- операция аритметично събиране.
7. Заключение
Разгледаната програма, макар и далеч от съвършенството, ще върши работата за която е създадена.
На много места, съзнателно са използвани конструкции, които биха могли да се напишат и по-икономично, но яснотата на кода би пострадала.
В качеството на упражнение, можете да се опитате да потърсите подобни места в програмата и да ги подобрите.
Например, думата "word" и "words" се различават по един символ. Можем да оптимизираме печатането на резултата, като първо напечатаме "word" и след това добавим "s" ако числото е по-различно от 1.
Това е засега. В следващата 7-ма статия ще се занимаваме с някой теоретичен въпрос.
©2012 John Found
Last modified on: 13.06.2012 20:02:46