Содержание
2.1. Проба молотка
#1. Теоретически гвозди можно забивать и голыми руками. Но намного быстрее и безболезненнее делать
это с помощью молотка. Пользоваться им, как известно, каждый дурак умеет. Чего там сложного? Взял оный в
руки - и молоти: раз по гвоздю, два раза по пальцам (понимание приходит с опытом).
Молотки бывают разные: большие и маленькие, с длинной ручкой и с короткой ручкой, железные и
деревянные, приспособленные для забивания гвоздей и приспособленные для пробивания черепов...
Да и гвоздей разнообразие еще то! И разной длины, и разной толщины, и со шляпками разной формы, из разного
материала сделанные... есть даже специализированные для прибивания рук к кресту! :(.
Так вот: виды молотков и особенности их использования мы пока что оставим в покое. Для начала
просто возьмем гвоздь, который уже не раз забивали голыми руками (больно было?) и забьем его при помощи
молотка! Помните, как мы программу, выводящую окошки, без компилятора одними мнемониками писали?
А теперь ее же, дуру, "под компилятор" перелопатим... В общем, настало время, братья, тайну нервную
и страшную узнать. Сегодня мы познакомимся с компилятором.
#2. Нижеследующий текст набираем в текстовом редакторе:
;-[блок 2]--------------------------
CODESG segment
assume CS:CODESG
org 100
;-[блок 3]--------------------------
MAIN proc
xor AL,AL
mov BH,10h
mov CH,5
mov CL,10h
mov DH,10h
mov DL,3Eh
mov AH,6
int 10h
call WINDOW
call WINDOW
call WINDOW
call WINDOW
int 20h
MAIN endp
WINDOW proc
ADD BH,10h
ADD CH,1
ADD CL,1
SUB DH,1
SUB DL,1
INT 10h
RET
WINDOW endp
;-[блок 4]--------------------------
CODESG ends
end MAIN
Сохраняем получившуюся дуру под именем proga_1.asm.
Далее запускаем файл tasm.exe и в качестве параметра передаем ему имя файла с исходным текстом
программы...То есть командная строка у вас (в том же Windows Commander'е) вот какая должна быть:
tasm proga_1.asm
Если вы правильно набрали текст программы, то TASM должен выплюнуть вам вот что:
Turbo Assembler Version 4.1 Copyright (c) 1988, 1996 Borland International
Assembling file: proga_1.asm
Error messages: None
Warning messages: None
Passes: 1
Remaining memory: 406k
Самое тут главное - это чтоб "Error messages" был "None". Это значит, что ошибок в
программе нет.
Поехали дальше... Если ошибок у вас действительно никаких не было, то в том же каталоге, что и
proga_1.asm ищите файл proga_1.OBJ. Можете даже по F3 попробовать просмотреть его
содержимое... Что-нибудь поняли? Если нет - отсылаю вас к "Введению в машинный код".
А теперь запускаем хорошую программу TLINK следующим образом:
tlink /t proga_1.obj
Обратите внимание: линкуем мы именно файл с типом OBJ, а не ASM.
Что получилось? А получился файл proga_1.COM!! И этот .COM работает!
Посмотрите на его содержимое в DZEBUG'е :). Отличется ли оно чем от той проги, что мы
делали ранее?
Нет, не отличается!
Медитируем...
#3. А теперь несколько слов о том, почему не стоит писать программы так, как мы это делали
раньше:
1. Эти проклятые адреса! Пойди рассчитай на какой адрес прыгнуть нужно, с какого смещения
подпрограмма начинается, с какого блок данных... В принципе, как мы уже убедились, в этом нет ничего
сложного... просто занятие это очень уж муторное и неинтересное...
Как абсолютно верно заметил некто Евгений Олейников в RTFM_Helpers: "Когда я пишу программу,
я не знаю точного адреса начала будущей подпрограммы"...
2. Очень сложно было в том случае, когда возникала необходимость ВСТАВИТЬ ту или иную команду в
середину кода. Приходилось перебивать код по новой... по новой пересчитывать адреса... ужас, в общем!
Так вот: основная и самая главная функция "ассемблерного компилятора" - это как раз и есть
"просчитывание адресов"!
Смотрите, как хорошо и приятно: Мы готовим исходный текст в обыкновенном текстовом редакторе :).
Просто набиваем строчка за строчкой - нам не важны адреса (мы их вообще не видим!), не важны "точки входа"
в процедуру... Спокойненько вставляем какую надо команду, спокойненько удаляем... Без лишнего напряга!
Да вы посмотрите на блок 3 программы :). Там все те же хорошо знакомые команды :).
Особое внимание обратите на команду CALL, которая у нас, как известно, вызывает процедуру.
После нее идет не привычный адрес начала процедуры, а всего лишь ее "имя собственное"! А сама процедура
находится между строчками WINDOW proс (начало процедуры) и WINDOW endp (конец процедуры).
"WINDOW" - понятно, это "имя собственное". "Proc" - потому что процедура. "Endp"
- потому что конец процедуры...
Тут еще один момент... подобные "словеса" КОМАНДАМИ АССЕМБЛЕРА НЕ ЯВЛЯЮТСЯ. Они называются инече -
"директивами". Это не ПРИКАЗ делать то-то и то-то, а ЦЕННОЕ УКАЗАНИЕ компилятору (не процессору!),
что и как ему делать с данным куском кода...
Процедуры - вещь хорошая! Все процедуры хороши, и в большой программе их чертовски много! Это вам не языки
высокого уровня, где можно длиннющие простыни кода лабать и все будет работать! ("Дельфи-компилятор
не даст вам выстрелить себе в ногу, но будет так удивлен попыткой сделать это, что через некоторое время
сделает это сам." (С) DZ WhiteUnicorn).
Это ASM! Тут запутаться легче простого! Исходники неимоверно длинны и сложны! Все делается ручками
(хоть и с помощью компилятора), а ошибки довольно трудно отслеживаются (для не-дZенствующих это вообще
занятие безнадежное)! Вот и выкручиваются низкоуровневые программеры таким вот образом: всю программу
(даже в тех случаях, когда это не обоснованно!) делят на меленькие кусочки-процедурки... Напишут
процедурку, протестируют ее так и сяк... если работает - за следующую берутся...
Сии "методы проектирования" мы еще с вами еще не раз рассмотрим :). Пока что знайте вот что: любую
прогу можно/нужно рассматривать как КУЧУ ПРОЦЕДУР, которые все между собой повязаны...
Но есть среди этих процедур САМАЯ ГЛАВНАЯ! Это та, С КОТОРОЙ НАЧИНАЕТСЯ ВЫПОЛЕНИЕ ПРОГРАММЫ! Ее
никто не вызывает. Она - босс! Она всеми командует, все гребет под себя... Описывают ее те же
самые директивы, что и прочие "подчиненные процедуры"... Но есть у нас еще одна директивка,
которая указывает, КАКАЯ ИМЕННО процедура ИЗ ВСЕХ вроде бы "равноправных" является ГЛАВНОЙ.
Видите строчку end MAIN в конце исходного текста программы? Именно она и указывает ГЛАВНУЮ
ПРОЦЕДУРУ ("MAIN" - ее имя собственное). Если бы мы написали end WINDOW, то выполнение программы у
нас началось бы с первой строчки процедуры WINDOW, и ни одна строчка из MAIN выполнена не была бы...
Уф... в общем долго и упорно медитируйте...
#4. Наличие многочисленных директив - это своего рода плата за то, что компилятор избавляет
нас от необходимости просчитывать адреса. Как говорит дZенская программерская мудрость "любишь кататься,
люби и саночки возить"...
Как и в DZEBUG'е, "в TASM'e" мы также должны четко инструктировать компилятор (дабы он в свою
очередь также четко проинструктировал процессор) что у нас является кодом, а что - данными...
Посмотрите на исходник. Весь текст программы у нас хранится между директивами CODESG segment и
CODESG ends. Где CODESG - это "имя собственное", "segment" - потому что "CODESG" он,
собственно, и есть сегмент :), и "ends" - потому что конец сегмента... (сравните с процедурными директивами).
Но тут такой вопрос:
Директива ASSUME производит связывание сегментного решистра CS с "именем собственным".
Далее у нас следует директива org 100h. Нужна она нам для того, чтобы компилятор понял, что мы
хотим получить именно COM-файл, который, как известно, помещается в сегмент памяти начиная со смещения 100
(в общем-то, это необходимое, но вовсе не достаточное условие для получения COM-файл).
Директивка очень интересная, о ней мы, надеюсь, еще поговорим подробнее, когда коснемся вирмейкерства.
#5. Ладно... с директивами более-менее разобрались, исходник приготовили, пора разбираться
че там дальше происходит...
Дальше происходит так называемое "ассемблирование", т.е. перевод команд в соответствующие машинные
коды. При этом просчитываются адреса меток, адреса начала подпрограмм, адреса начала/конца сегментов... и
многое другое...
Причем ассемблирование происходит как минимум в два приема. Посудите сами: откуда компилятору знать,
с какого адреса начнется процедура WINDOW, если не известно, какая еще простыня команд ПОСЛЕ этого CALL'а
будет? В DZEBUG'е мы это "в уме на листике" считали...
TASM это тоже аналогичным образом делает :).
При первом проходе он подсчитывает, сколько какая команда занимает места, с каких адресов начинаются
процедуры и т. д. и только при втором проходе подставляет в call'ы КОНКРЕТНЫЕ АДРЕСА начала этих процедур...
всего лишь... ну еще и ваши "d" и "b" в машинные "h" (которые на самом деле "b") переводит...
(во завернул!)...
А вообще TASM много еще чего делает... программеры народ ленивый...
В результате ассемблирования мы получаем так называемый "объектный файл".
"И что это за дрянь?" - спросте вы и правильно спросите...
А вы сравните шестнадцатеричное содержимое OBJ и COM файлов. В OBJ присутствует та же
последовательность байтов, что и в OBJ. Но помимо этого и еще какая-то шестнадцатеричная ерунда
присутствует: имя сассемблированного файла, версия ассемблера, "имя собственное" сегмента и т. д.
Это своего рода "служебная" информация, предназначенная для тех случаев, когда ваш исполнимый
файл вы хотите собрать из нескольких. При разработке больших приложений исходный текст, как правило,
хранится в нескольких файлах в каждом из них прописаны свои сегменты кода/данных/стека. А исполнимый
получить нам нужно только один - с единым сегментом кода/данных/стека. Именно это TLINK и делает:
завершает определение адресных ссылок и объединяет, если это требуется, несколько программных модулей в
один...
И этот один у нас и является исполнимым... УРА!
#6. Итак, первую прогу с использованием компилятора мы написали. Теперь напишем вторую.
Набиваем ее исходный текст, и компилим:
assume CS:SUXXX, ES:SUXXX
SUXXX segment
org 100h
MAIN proc
lea bp,ABC
mov AH,13h
mov AL,3
xor bh,bh
mov bl,07h
mov cx,16d
xor DX,DX
int 10h
int 20h
MAIN endp
ABC db 'H',0Ah,'e',0Bh,'l',0Dh,'l',0Ch
db 'o',0Bh,',',0Ah,' ',0Ah,'W',09h
db 'o',08h,'r',07h,'l',06h,'d',05h
db '!',02h,'!',02h,'!',02h
SUXXX ends
end MAIN
Итак, в этой программе мы использовали функцию 13h прерывания 10h (INT 10h, AH=13h).
Вот ее описание:
[INT 10h, ФУНКЦИЯ 13h] - записывает на экран символьную строку, начиная от указанной позиции.
ВХОДНЫЕ ПАРАМЕТРЫ:
AH = 13h;
AL - код формата(0-3):
AL=0, формат строки{симв., симв.,..., симв.} и курсор не перемещается,
AL=1, формат строки{симв., симв.,..., симв.} и курсор перемещается,
AL=2, формат строки{симв., атр.,...,симв., атр.} и курсор не перемещается,
AL=3, формат строки{симв., атр.,...,симв., атр.} и курсор перемещается;
BH - страница дисплея;
BL - атрибут (для режимов AL=0, AL=1);
CX - длина строки;
DX - позиция курсора для записи строки;
ES:BP - указатель строки.
ВЫХОДНЫЕ ПАРАМЕТРЫ: отсутствуют.
А еще мы использовали команду LEA, которая загружает в регистр адрес (смещение), с которого у нас
начинается блок данных.
2.2. Несколько "тупых" процедурок
#1. Дабы сходу "обломать" нездоровую критику, сразу предупреждаем, что нами сознательно
допущена некоторая излишняя "процедуризация" исходного кода. А по сему, прежде чем взяться за изучение
нижеследующего материала, твердо уясните: в данном случае (как есть сейчас) дробление кода на процедуры -
дурь полная. А обосновываем мы эту дурь только тем, что впоследствии будем совершенствовать эти процедуры
до уровня, когда, собственно, сие "дробление" и будет обосновано.
И еще. Подобный модульный подход (это понятие немножко шире, чем просто "использование процедур")
в некоторых случаях снижает быстродействие и увеличивает размер исполнимого файла, однако он же и
значительно увеличивает "читабельность" исходника, что вполне обоснованно с точки зрения обучения. И с
точки зрения "командной" разработки программ - тоже. (В общем, критика по этому поводу не принимается!).
Итак, для начала набиваем вот какой текст (исходник это!):
assume CS:PROGA
PROGA segment
org 100h
;-[TESTING]---------------------------
;Здесь мы будем тестировать процедуры
;---------------------------------------
TESTING proc
call EXIT_COM
TESTING endp
;-[EXIT_COM, V1]--------------------
;Завершение работы программы
;На входе: пофиг
;На выходе: нихрена
;Прерывания: INT 20h
;Процедуры: ан нэту
;-----------------------------------
EXIT_COM proc
int 20h
EXIT_COM endp
PROGA ends
end TESTING
Здесь вам все должно быть понятно. INT 20h вынесен в отдельную процедуру и только.
Плюс еще какие-то нездоровые заголовки добавлены, которые и весят-то больше, чем сам код. Это
нормально. После точки с запятой в исходнике вы можете писать все, что угодно. Все равно при
компиляции это будет проигнорированно и, следовательно, на размер исполнимого файла не повлияет.
(Точно так, как и длина "имен собственных" процедур и меток).
Мы предлагаем использовать именно такую "шапку" комментария к каждой из ваших процедур.
Все очень просто. Первая строчка - это "что делает процедура". Вторая - какие ей необходимо передать
параметры. Третья - какие она возвращает параметры. Четвертая и пятая, соответственно, - какие в
процедуре использовались прерывания и хм... ранее написанные процедуры :). Это намного облегчит
понимание вашего исходника как вами самими, так и теми, кому вы его предоставите на поругание...
Для тех, кто в танке: последующие процедуры вставляйте между процедурами TESTING и EXIT_COM -
не ошибетесь :-p.
#2. Следующая процедура также основана на одном-единственном прерывании. Все,
что она делает - это возвращает текущие координаты курсора.
;-[CURSOR_READ, V1]-------------------
;Возвращает координаты курсора
;На входе: пофиг
;На выходе: DH - строка, DL - столбец
;Прерывания: INT 10h, AH=03h
;Процедуры: ан нэту
;------------------------------------------
CURSOR_READ proc
push AX
push BX
push CX
mov AH,3
xor BH,BH
int 10h
pop CX
pop BX
pop AX
ret
CURSOR_READ endp
Прежде всего обратите внимание на цепочку push'ей и pop'ов (далее - просто "поп"), на
очередность записи в стек (AX, BX, CX) и извлечения (CX, BX, AX - обратное то есть). Все
регистры, которые мы собираемся изменять внутри процедуры, должны обязательно сохраняться
в ее начале и восстанавливаться в ее конце. В важности соблюдения этого правила вы еще
не раз убедитесь на своем горьком опыте. Те, кто медитировал над заданием из главы 1.10 #4,
уже знают, о чем тут идет речь (а кто не пытался - самое время!).
Давайте посмотрим на нашу процедуру с точки зрения пушей и поп. AH мы изменяли для
указания функции прерывания, которую мы хотим использовать. BH обнуляли для указания
видеостраницы (пока будем только одну-единственную, нулевую, юзать). "А CX зачем" -
спросите. - "Вроде мы его не трогали...". И точно, мы - не трогали. А посмотрите в
описании, что в этот регистр нам засунуло прерывание в "результате" своего выполнения...
Посмотрели? Оно вам надо?? То, что нам надо - координаты - засунуты в DX (DH, DL), поэтому
их значения мы не сохраняем. Если бы нам нужно было получить информацию о типе курсора -
мы бы запушили DX, а CX бы оставили в покое...
Что? Без пива не разобраться?
Так в чем, черт подери, дело? Разбирайтесь под пиво!!
Вот вам еще одна аналогичная процедура, которая не определяет, а УСТАНАВЛИВАЕТ курсор в заданные
коодинаты...
;-[CURSOR_SET, V1]---------------------------------------
;Устанавливает курсор в заданные координаты
;На входе: DH - строка, DL - столбец
;На выходе: нихрена
;Прерывания: INT 10h, AH=02h
;Процедуры: ан нэту
;--------------------------------------------------------
CURSOR_SET proc
push AX
push BX
push CX
mov AH,2
xor BH,BH
int 10h
pop CX
pop BX
pop AX
ret
CURSOR_SET endp
Если кто чего не понимает - смотрите комментарии к предыдущей процедуре
(+ описание прерываний и команд! это обязательно!). Если кто не понял и предыдущую - снова отсылаю к
главе 1.10
#3. На основе двух предыдущих процедур мы напишем третью "курсорную" :), которая
будет сдвигать курсор на одну позицию вправо.
;-[CURSOR_RIGHT, V1]-------------------------------------
;Перемещает курсор на одну позицию вправо
;На входе: пофиг
;На выходе: нихрена
;Прерывания: ан нэту
;Процедуры: CURSOR_READ, CURSOR_SET
;--------------------------------------------------------
CURSOR_RIGHT proc
push DX
call CURSOR_READ
inc DL
call CURSOR_SET
pop DX
ret
CURSOR_RIGHT endp
А здесь очень простая идеология :)). Из двух процедур мы собрали третью :)). Вызвали CURSOR_READ,
получили в DX текущие координаты курсора. Ту координату, что столбец (DL, младшая часть DX), увеличили
на единицу. А потом вызвали процедуру CURSOR_SET, которая у нас устанавливает координаты курсора. Новые
координаты в нее передаются опять таки через тот же DX. Улавливаете?
Естественно, мы запросто можем отказаться от процедур CURSOR_SET и CURSOR_READ и решить данную задачу
внутри одной процедуры... В общем, свой выбор вы сделаете сами. Страшна Сцилла оптимизации по быстродействию,
еще страшнее - Харибда оптимизации по размеру, но тварь самая страшная - это Программер, который оптимизирует
свой код по собственной "удобноваримости"... (Хм... интересно, что бы сказали по этому поводу программеры
Мелкософта...)
Обратите также внимание, на push/pop DX внутри процедуры. Мы просто сдвигаем курсор вправо. ПРОСТО
СДВИГАЕМ на одну позицию. То, что в DX нам надо? Сто лет оно нам не надо... херим... А остальные
регистры - еще процедурами CURSOR_SET и CURSOR_READ неоднократно "похерены". В каком смысле "похерены"??
А в таком, что состояние регистров ПОСЛЕ вызова CURSOR_RIGHT в точности такое же, как и было ДО. Хотя
сами помните, что всю четверку регистров мы еще как юзали...
Вот теперь можно сделать паузу и (это мериканцы пускай свой пластмассовый твикс кушают)
ПОМЕДИТИРОВАТЬ...
#4. Следующая процедура ну вааще элементарна:
;-[WRITE_CHAR, V1]---------------------------------------
;Печатает символ и переводит курсор на позицию вправо
;На входе: DL - код символа.
;На выходе: нихрена
;Прерывания: INT 10h, AH=09h
;Процедуры: CURSOR_RIGHT
;--------------------------------------------------------
WRITE_CHAR proc
push AX
push BX
push CX
mov AH,9
xor BH,BH
mov BL,00000111b
mov CX,1
mov AL,DL
int 10h
call CURSOR_RIGHT
pop CX
pop BX
pop AX
ret
WRITE_CHAR endp
Она символ на монитор выводит. Через 9-ю функцию 10-го прерывания. А потом (после вывода) курсор
на позицию вправо перемещает. Догадайтесь сами, "путем вызова" какой процедуры... Ага, правильно :)),
CURSOR_RIGHT.
Посмотрите на описание этого прерывания. Код символа должен быть в AL. А у нас в комментариях он в
DL прописан. А перед INT 10h mov AL,DL нездоровый стоит. Нахрена он тут?? И правильно!! Этот mov можно
удалить, и передавать значение через AL. Но я тварь вредная. Привык я, понимаете-ли, через DX передавать...
привычка - сила страшная!! Лень с ней бороться... Лень, а поэтому и не буду... Кто-то в подобном мове
может и более глубокий смысл найдет - наверняка найдет!! В общем - ищите сами. На блюдечке с голубой
каемочкой вам это не преподнесу :)). Вредный.
mov BL,00000111b (не 07h) написано специально. Это чтоб вы посмотрели, как кодируется атрибут
(фон, цвет) энтого символа. В одном из предыдущих номеров даже табличка есть, из справочника
содранная...
#5. В языке "командного интерпретатора DOS" есть хорошая команда - CLS (то бишь
очистка дисплея). Хорошая команда! Кто не поленится заглянуть, внутрь command.com'а, увидят приблизительно
следующее (на самом деле все чуть-чуть навороченнее, но прерывание то же):
;-[CLS, V1]-----------------------------------------
;Oчистка дисплея
;На входе: пофиг
;На выходе: нихрена
;Прерывания: INT 10h, AH=06h
;Процедуры: ан нэту
;--------------------------------------------------------
CLS proc
push AX
push BX
push CX
push DX
mov AH,6
xor AL,AL
mov BH,00000111b
xor CX,CX
mov DH,24d
mov DL,79d
int 10h
pop DX
pop CX
pop BX
pop AX
ret
CLS endp
Короче, элементарный скроллинг, только заданы максимально возможные координаты скроллируемого
окошка и CX=0... в общем, окошки рисовали, помните...
#6. Тестируем, штоль?? Дописываем процедуру TESTING:
TESTING proc
mov DL,'*'
call WRITE_CHAR
call WRITE_CHAR
call WRITE_CHAR
call EXIT_COM
TESTING endp
Компилим... CLS тож тестируем... Все работает??
А теперь самое интересное :)) и благоприятно влияющее на нервную систему :). Прелесть модульного
подхода вот в чем: написали процедуру, протестировали успешно - и МОЖЕТЕ ЗАБЫТЬ нафиг, как она у вас
работает, какие функции там используются, какие хитроПОПые алгоритмы там применены...
Просто смотрите на заголовок, че она делает, чего ей надобно на входе, чего возвращает... а ее
"внутренности" вам глубоко фиолетовы. Работает - и ладно. Ааааaaa?? Круто?
Уф... медитируйте!!
2.3. Печать "шестнадцатеричных циферек"
#1. Мы уже неоднократно юзали хорошую мнемоническую (aka ассемблерную)
команду ADD :). Напомню, что в результате выполнения команд
mov AX,2
mov BX,3
add AX,BX
в регистр AX у нас помещалась сумма (AX=AX+BX).
Мы смотрели на это дело под отладчиком, и, к своей неописуемой радости,
убеждались в том, что эта дрянь действительно работает. Но толку нам
знать, что она работает?? Программа ведь не только работать должна, но
еще и диалог какой-нить между юзверем и компутером обеспечивать!
Например, спрашивать у него эти два числа и выплевавать на монитор
результат их сложения.
Вот именно - выводить на монитор, а не заносить в какой-то абстрактный
регистр.
С клавиатурным вводом пока обождем, а вот с выводом (на монитор)
разберемся прямо сейчас.
Как мы это сделаем? Вы уже неоднократно слышали, что "в ассемблере" все
делается "ручками" :). Сейчас вы лишний раз убедитесь в том (некоторые
замрут в ужасе), что это утверждение истинно. Для вывода значения
регистра мы вовсе не "познакомимся с новым прерыванием". Даже такая
простейшая операция, как "вывод на дисплей значения регистра
(переменной)" - это целая процедура. И не одна, как вы скоро в этом
убедитесь. Страшно?
Поехали!!
#2.Задача: вывести на монитор значение регистра DL.
Народ! Давайте сразу расставим границы между КОДОМ СИМВОЛА и его
НАЧЕРТАНИЕМ.
Например, у нас F3h в DL. Как мы хотим это вывести? Как символы 'F3'
или же как ASCII символ, соответствующий коду F3h? Определяемся.
Если в DL у нас F3h - то надо чтоб именно 'F3' у нас на монитор и
выводилась. Не 'э перевернутое', не '46 33', а именно 'F3'.
Помедитируйте. Уловите разницу между 'э', '46 33' и 'F3'.
Эту задачу мы немножко упростим :). Для начала напишем процедуру,
которая выводит только младшую тетраду регистра DL (цифру "3" в нашем
примере). Для этого мы обратимся к процедуре WRITE_CHAR из прошлого
номера. Именно она печатает нам на монитор символ, ASCII-код которого
находится в DL.
Но тут загвоздка: в DL-код, а печатается-то символ :). А нам,
собственно, именно две циферки шестнадцатеричного кода, как два символа,
и нужно напечатать. Ну, или хотя бы младшую циферку этого кода...
Решается эта задача элементарно :). Главное - это правильно ее
сформулировать!
Вот что я тут "нарисовал":
ЕСТЬ НУЖНО
код символ символ код
----------- ------------
00h '?' '0' 30h
01h '?' '1' 31h
02h '?' '2' 32h
03h '?' '3' 33h
04h '?' '4' 34h
05h '?' '5' 35h
06h '?' '6' 36h
07h '?' '7' 37h
08h '?' '8' 38h
09h '?' '9' 39h
0Ah '?' 'A' 41h
0Bh '?' 'B' 42h
OCh '?' 'C' 43h
ODh '?' 'D' 44h
OEh '?' 'E' 45h
0Fh '?' 'F' 46h
Только во второй колонке вместо вопросительных знаков должны быть
соответствующие всякие символы (посмотрите в ASCII-таблице, какие они на
вид страшные!).
Процедурка наша вот что должна делать - Всего-навсего перевести (конвертировать)
тетраду в код соответствующего ей символа... Завернуто? Если разобраться, то не очень-то и завернуто.
Смотрите: в DL у нас 03h. Хотим мы эту '3' на монитор вывести. Если
вызовем WRITE_CHAR, то у нас символ "сердечко" выплюнется. А надо, чтоб
символ '3' вывелся, код которого 33h.
Соответственно и для остальных смотри по табличке.
А теперь обратите внимание, насколько "шестнадцатеричная циферка"
(тетрада) отличается от ASCII-кода, этой "циферке" соответствующего. Сам
скажу: на 30h для цифр от '1' до '9', и на 37h для цифр от 'A' до 'F'.
То есть "переконвертацию" мы запросто можем сделать командами add DL,30h
(если тетрада в диапазоне 0...9) и add DL,37h (если тетрада в диапазоне
A...F).
Короче, вот код (пропиваю!):
;-[WRITE_HEX_DIGIT, V1]----------------------------------
;Печатает одну шестнадцатеричную цифру (младшую тетраду DL)
;(старшая тетрада должна быть равна 0)
;На входе: DL - цифра
;На выходе: нихрена
;Прерывания: ан нэту
;Процедуры: WRITE_CHAR
;--------------------------------------------------------
WRITE_HEX_DIGIT proc
push DX
cmp DL,0Ah
jae HEX_LETTER
add DL,30h
JMP WRITE_DIGIT
HEX_LETTER:
add DL,37h
WRITE_DIGIT:
call WRITE_CHAR
pop DX
ret
WRITE_HEX_DIGIT endp
Сначала, ессно, изменяемые регистры сохраняем (мы ж их изменяем!). (Ну,
и восстанавливаем в конце процедуры (PUSH и POP соответственно)).
Потом у нас логическое ветвление организовано. Сравниваем значение DL с
"общей границей" наших двух диапазонов (команда - CMP, "граница" - 0Ah).
Если это значение больше или равно 0Ah, то прыжок на метку HEX_LETTER,
прибавление к DL 37h и печать цифры (WRITE_CHAR). Иначе добавляем 30h и
безо всяких условий перепрыгиваем на вызов WRITE_CHAR (минуя add DL,37h
то бишь).
Все. Тестируем.
#3. Про тестирование - базар отдельный. В данном случае мы можем
запросто проверить нашу процедуру на абсолютно всех возможных значениях
этой тетрады (всего-то ничего 16 вариантов). Но намного правильнее,
проанализировав алгоритм, установить своего рода "критические" значения,
на которых целесообразно проводить проверку. Плюс, естественно,
минимальное и максимальное значения.
TESTING proc
mov DL,00h
call WRITE_HEX_DIGIT
mov DL,01h
call WRITE_HEX_DIGIT
mov DL,09h
call WRITE_HEX_DIGIT
mov DL,0Ah
call WRITE_HEX_DIGIT
mov DL,0fh
call WRITE_HEX_DIGIT
call EXIT_COM
TESTING endp
Если сия "тестовая" (она же - главная) процедура выведет на монитор
019AF
значит мы с высокой долей вероятности можем быть уверенными, что
процедура WRITE_HEX_DIGIT работает правильно на всех значениях младшей
тетрады DL.
Кто не просто скопировал процедуру из буфера обмена, а действительно
разобрался с тем, как она работает - сами знают, что значение старшей
тетрады нашей процедуре НЕбезразлично. Оно должно быть равным 0.
#4. Что мы имеем? Процедуру для вывода на дисплей одной
шестнадцатеричной циферки - младшей тетрады (в DL). Но нам-то нужно две
вывести! Сначала старшую циферку-тетраду, и только потом - младшую!
Таким образом очередная задача разбивается на две части: печать старшей
тетрады DL и печать младшей тетрады DL.
Первая "подзадача" решается легко: нужно просто старшую тетраду
переместить на место младшей и вызывать процедуру (основательно
протестированную и 99,9%-но работающую) WRITE_HEX_DIGIT.
А вторая подзадача - хм... заключается в восстановлении предыдущего (мы
ж тетраду перенесли) значения DL и снова - вызове WRITE_HEX_DIGIT.
(Хе! Вот теперь-то вы уж точно почувствуете всю прелесть "дробления кода
на процедуры"!)
Перенос тетрады мы осуществим при помощи команды SHR, которую в умных
книжках обзывают как "логический сдвига разрядов операнда вправо".
Объясню.
Представьте себе деревянную доску, длинной в 8 бутылок пива и шириной в
одну. (В принципе, доску эту можно и в два раза длиннее представить, но
тогда на ней надо "DX" написать, мы же пока только "DL" напишем). А еще
дурня, у которого на лбу SHR написано. Так вот, если этому придурку
стукнуть по хребту, то он слева от доски поставит ПУСТУЮ бутылку, а
остальные сдвинет на одну позицию вправо, в результате чего самая правая
бутылка, ессно, с доски упадет.
Бутылки, которые сразу стояли, могут быть пустыми или полными, а вот
дурень SHR - только пустые ставит. И только слева.
"исходное" 11110011
SHR 01111001
SHR 00111100
SHR 00011110
SHR 00001111
Ну тут и ежу все понятно. Четыре раза дурню по хребту надо дать, чтоб
старшая тетрада на место младшей переместилась.
(На самом деле самая правая бутылка перед тем как об землю разбиться,
на некоторое время в воздухе зависает, но вы пока этим голову не
забивайте).
Реализовывается этот сдвиг вот как:
mov DL,11110011b
mov CL,4
shr DL,CL
В DL - наша цепочка битов. (11110011b = F3h, естественно).
В CL заносим "на сколько позиций" нам нашу цепочку сдвинуть.
Ну и SHR - это дурень, который сдвигает вправо, а слева нули дописывает.
#5. Думаете, это все?? Разогнались!! Не все так просто :).
WRITE_HEX_DIGIT у нас требует, чтобы первой тетрадой были только одни
нули. Я заострял на этом ваше внимание.
При печати первой тетрады это условие соблюдается. SHR слева нули
дописывает.
А вот при печати второй цифры нужно вот что: ничего никуда не сдвигая,
обнулить старшую тетраду, а младшую (которая, собственно, и есть
"цифра") оставить в покое.
Решим мы эту команду при помощи логической операции "и" (and
по-аглицкому).
0 0 1 1
0 1 0 1
-------
0 0 0 1
А теперь и для особо одаренных:
0 and 0 = 0
0 and 1 = 0
1 and 0 = 0
1 and 1 = 1
Смотрите, интересно как получается:
Если мы AND чего-либо (нуля или единички) с 0 делаем, то у нас в
результате 0 и только 0 получается.
А если AND с единичкой - то ЧТО БЫЛО, ТО И ОСТАЕТСЯ.
(Это и есть потаенный дZенский смысл команды AND)
Решение нашей проблемы (обнулить старшую тетраду, а младшую оставить
без изменений) таким образом сводится к тому, что старшую тетраду нужно
"AND 0", а младшую - "AND 1".
То есть значению DL с 00001111b (оно же - 0Fh) "AND" сделать.
На ассемблере это вот как выглядеть будет:
and DL,00001111b
Естественно, 00001111b = 0Fh
Аминь!!
#6. Уфф... Вот что получиться в итоге должно:
;-[WRITE_HEX, V1]----------------------------------------
;Печатает две шестнадцатеричные цифры
;На входе: DL - типа цифры две :))
;На выходе: нихрена
;Прерывания: ан нэту
;Процедуры: WRITE_HEX_DIGIT
;--------------------------------------------------------
WRITE_HEX proc
push CX
push DX
mov DH,DL
mov CL,4
shr DL,CL
call WRITE_HEX_DIGIT
mov DL,DH
and DL,0Fh
call WRITE_HEX_DIGIT
pop DX
pop CX
ret
WRITE_HEX endp
Ну че тут объяснять?? Я уже объяснил все!! Единственное, что могу
добавить - это про mov DH,DL. Этой командой мы значение регистра копируем
перед тем как биты "ему" сдвинуть. А потом статус-кво mov DL,DH
восстанавливаем, чтоб и младшую цифру напечатать.
Все. Тестируем. Вроде должно работать.
#7. Так сказать "к вопросу о шаблонах мышления"...
Мы тут доооолго трахались с тетрадами. Вроде успешно.
Когда при тестировании понимаемости материала мы предложили пяти
"подопытным" самостоятельно написать процедуру для вывода на монитор
"большого" регистра (DX), они все как один начали сдвигать байты... :(
Народ!! Это не есть правильно!!
;-[WRITE_HEX_WORD, V1]-----------------------------------
;Печатает шестнадцатеричное слово
;На входе: DX - слово
;На выходе: нихрена
;Прерывания: ан нэту
;Процедуры: WRITE_HEX
;--------------------------------------------------------
WRITE_HEX_WORD proc
push DX
xchg DL,DH
call WRITE_HEX
xchg DL,DH
call WRITE_HEX
pop DX
ret
WRITE_HEX_WORD endp
Команды xchg DL,DH и xchg DH,DL, кстати, работают абсолютно одинаково. Операнды просто
меняются между собой значениями. В качестве одного из операндов может
выступать память.
#8. Ну, и напоследок, - информация к размышлению:
Команду shr можно использовать для деления целочисленных операндов без
знака на степени 2 :)).
mov cl,4
shr ax,cl
Думаете эти "фокусники" который в уме офигенны уравнения считать умеют,
шибко умные?? Нифига!! Они просто люди ЗНАЮЩИЕ. А делить на десять и вы умеете...
Над этим я настоятельно рекомендую дооооолго помедитировать. А еще над тем,
что девушки весьма и весьма любят, когда им фокусы показывают. Впрочем, это
(вычисления в уме) - тема отдельная. Мы ее тоже когда-нить коснемся :). MATRIX MUST DIE!!
2.4. Печать десятичных циферек
#1. Для тех, кто медитировал над
главой 1.1 алгоритм должен быть
понятен как 2+2=(вписать желаемое). Кто не въедит в алгоритм WRITE_DECIMAL - научитесь сначала
переводить числа между радиксами на листике в клеточку.
Итак, ставим новую задачу. В DX у нас шестнадцатеричное число.
Нам нужно напечатать его на монитор в "десятичном формате". Еще раз
обращаю ваше внимание на то, что существует множество способов ее решения.
Мы же выбрали способ, который:
а). Вам легче всего будет понять.
б). Требует минимального количества "новых" команд.
Кто скажет, что мы не правы - пусть первый кинет камень в эхо-конференцию
RTFM_Helpers.
#2. Прежде всего посмотрите
на процедуру WRITE_HEX_DIGIT и вспомните, какой алгоритм положен в основу
ее работы. Вспомнили? Рад за вас!!
А теперь мы познакомимся к командой деления. Что будем делить?? Ну
естественно, "регистры" :).
Новая команда называется "деление беззнаковое" (DIVide unsigned).
Что такое делимое/делитель/частное, я вас грузить не буду, это в учебнике
арифметики для младших классов более чем понятно расжевано. Ну во всяком
случае детишки это понимают.
(Кто скажет, что 10/3=3 а остаток 333 в периоде - тот дурак. Кто скажет,
что остаток будет равен 1, скажет правильно.)
Следующий кусок кода демонстрирует деление значения регистра AX на
значение регистра BL.
mov ax,10d
mov bl,3d
div bl
Обратите внимание: ДЕЛИМОЕ у нас может располагаться только в регистре
AX (другими словами, делимое задается неявно), а ДЕЛИТЕЛЬ - в любом
регистре.
Кто не верит - посмотрите под отладчиком...
1. Если делитель размером в байт, то после операции частное помещается в
AL, а остаток - в AH.
2. Если делитель размером в слово, то делимое должно быть
расположено в паре регистров DX:AX (младшая часть делимого в AX).
После операции частное помещается в AX, а остаток - в DX.
2. Если делитель размером в двойное слово, то делимое должно быть
расположено в паре регистров EDX:EAX (младшая часть делимого
находится в EAX.) После операции частное помещается в EAX, а остаток - в
EDX.
Внимание, подводный камень! В следующем примере:
mov ax,10d
mov bx,3d
div bx
у вас вовсе не AX на BX делиться будет, а парочка DX:AX на BX.
Помедитируйте над этим :))
#3. А теперь, собственно, пропиваю саму
процедуру вместа с традиционным расжевыванием оной:
;-[write_decimal, v1]------------------------------------
;печатает десятичное беззнаковое число
;на входе: dx - типа число
;на выходе: нихрена
;прерывания: ан нэту
;процедуры: write_hex_digit
;--------------------------------------------------------
write_decimal proc
push ax
push cx
push dx
push bx
mov ax,dx ;(1)
mov bx,10d ;(2)
xor cx,cx ;(3)
non_zero:
xor dx,dx ;(4)
div bx ;(5)
push dx ;(6)
inc cx ;(7)
cmp ax,0 ;(8)
jne non_zero
write_digit_loop:
pop dx ;(9)
call write_hex_digit ;(10)
loop write_digit_loop
pop bx
pop dx
pop cx
pop ax
ret
write_decimal endp
Алгоритм простой: пока частное не равно 0, делим его, делим и еще раз
делим на 10d, запихивая остатки в стек. Потом - извлекаем из стека.
Вот и вся "конвертация" из HEX в BIN :). Это если в двух словах.
А если подробно, то вот что получается:
Бряк 1 - подготавливаем делимое. Как уже говорилось, оно у нас задается
неявно - обязательно через AX. А параметр у нас - через DX процедуре
передается. Вот и перемещаем.
Бряк 2 - это, собственно, делитель.
Бряк 3 - очищаем CX. Он у нас будет в качестве счетчика. О нем мы еще
поговорим.
Бряк 4 - очищаем DX. Если не очистим, то мы не 1234h какое-нить на 10
делить будем, а 12341234h. Первое 1234 нам надо? Вот и я говорю - очищаем!
Бряк 5 - делим! Частное - в AX, остаток - в DX.
Бряк 6 - заносим остаток (DX) в стек ;).
Бряк 7 - CX=CX+1. Это мы считаем сколько раз "щемили остаток", который
"щемится", по кругу (прыжок на метку non_zero), пока AX не равно 0 (бряк 8).
То есть делим, делим AX, пока он не окажется, что делить, собственно,
нечего.
Так-с... Деление закончено, число раз, которое мы поделили AX до его полного
обнуления, хранится в CX.
Дальше все просто. Нам нужно такое же количество раз (CX) извлечь
значение (DX) из стека. И это будет "HEX", переведенный в DEC. (Оно же:
число в двоично коде, разобранное на последовательность десятичных цифр).
Помните, как у нас организуется цикл? Через loop и CX?
Если бы в качестве счетчика мы использовали какой-нибудь другой регистр,
то пришлось бы извращаться со всякими метками и прыжками... а так все
просто, все продуманно :). Цикл, в теле которого ИЗВЛЕЧЬ ЦИФЕРКУ (бряк 9) и
НАПЕЧАТАТЬ ЦИФЕРКУ (бряк 10). Столько же раз, сколько мы и делили наше
исходное шестнадцатеричное число.
Для тех, кто не понял: бряк - это брекпоинт. Для тех, кто еще не знает, что
такое брекпоинт - ищите объяснение в 'DZebug. Руководство юZверя'
#4.Тестируем!!
testing proc
mov dx,12345d
call write_decimal
call exit_com
testing endp
Двое из десяти "подопытных" чайников (есть такие) возмутились:
- В DX же только четыре циферки влазят!
- Ага! Аж два раза четыре!
mov dx,'DZ'
Как видите, туда еще и две буковки "влазят" ;)
А то и все четыре, если кавычки считать...
Медитируйте!!
2.5. Hello, world! или Изврат-2
#1. Мы уже писали программу "Hello, World!" с использованием
13-й функции 10 прерывания. Посмотрите на ее исходник...
Сегодня мы слабаем еще одно "Hello, World!", но уже несолько другим
способом. Какой из этих способов более дZенский - решайте сами ;).
Для начала мы создадим блок данных после всех-всех-всех процедур
но между директивами начала и конца сегмента.
Блок данных будет выглядеть следующим образом:
abc db 'Hello, World-2$'
Вы должны спросить "А почему мы хотим напечатать Hello, World-2, а в
конце строки у нас 2$? $ - это что? World за баксы продавать, штоль??
Да ну вас...
Эту строчку мы будем выводить на монитор особым дZенским способом -
посимвольно. То есть: возьмем первый символ из блока данных, выведем,
потом второй и т. д. аналогично пока не встретим символ '$'.
Опять-таки, это если в двух словах. Но ведь наверняка вам этого
покажется мало ;).
"Щемить" символы мы будем двумя способами. (Тут я хотел было написать
"неправильным" и "правильным", но потом передумал и решил обозвать
их "первым" и "вторым").
В алгоритм первого способа вы и сами без труда въедете (я только "рабочую
часть" приведу, пуши с попами сами проставляйте):
...
next:
mov dl,[BX]
cmp dl,'$'
je finish
call write_char
inc BX
jmp next
finish:
...
#2. А вот второй способ немножко навороченнее :).
Чтобы в нем разобраться, мы сначала познакомимся со следующей группой команд: LODSB, LODSW, LODSD
Их назначение - это загрузка элемента из последовательности (строки, цепочки)
в регистр-аккумулятор al/ax/eax.
Для тех, кто не понял - наша строчка "Hello, World-2$" как раз и
является "последовательностью/строкой/цепочкой" из элементов размером в байт.
Адрес цепочки передается через ds:esi/si, сами "элементы" (фиксированной
ширины) возвращаются в al (байт, команда LODSB), ax (слово, команда LODSW)
или eax (двойное слово, команда LODSD). В общем, последние буковки команд
как раз и указывают на размерность элемента: [B]yte, [W]ord,
[D]ouble word. Т. е. размер мы определяем неявно.
После выполнения одной из этих команд значение регистра si изменяется
на величину, равную длине элемента, но... хм...
Пришло время еще одну большую тайну познать, братья. В справочнике Юрова
написано, "знак этой величины зависит от состояния флага df:
df=0 - значение положительное, то есть просмотр от начала цепочки к
ее концу;
df=1 - значение отрицательное, то есть просмотр от конца цепочки к
ее началу."
Обидно, да? Флаги-то мы с вами еще не расколупали... Чего-ж дальше-то
делать, а?
Как что? "Пропивать" стандартную процедуру и колупать ее, колупать,
колупать, ногами ее, ногами, и по морде, по морде, по морде...
;-[write_string, v1]-------------------------------------
;печать строки символов на мониторе.
;(Строчка оканчивается символом '$'
;на входе: ds:dx - адрес строки
;на выходе: нихрена
;прерывания: ан нату
;процедуры: write_char
;--------------------------------------------------------
write_string proc
push ax
push dx
push si
pushf ;(1)
cld ;(2)
mov si,dx ;(3)
string_loop:
lodsb ;(4)
cmp al,'$' ;(5)
jz end_of_string ;(6)
mov dl,al ;(7)
call write_char ;(8)
jmp string_loop ;(9)
end_of_string:
popf ;(10)
pop si
pop dx
pop ax
ret
write_string endp
Итак, что делает команда lodsb, вы уже поняли. А вот с df=0/df=1 пока
что непонятки.
Будем разбираться.
Нам нужно, чтобы "просмотр цепочки" осуществлялся командой lodsb слева
направа, для этого нужно установить значение df=0. Делаем мы это при
помощи команды cld (бряк 2).
Если нам нужно, чтобы "просмотр цепочки" осуществлялся справа налево,
мы используем команду std. (Можете попробовать. Ерунда получится.)
А теперь вспомним "золотое правило". Все регистры, которые мы изменяли
ВНУТРИ процедуры, "на выходе" должны восстанавливать свои ПРЕДЫДУЩИЕ
значения (кроме тех регистров, через которые мы возвращаем РЕЗУЛЬТАТ).
Так вот: то же самое касается и флагов, которые мы изменяем.
Для регистров мы использовали команды PUSH и POP. Для регистра флагов
(изменять у которого мы можем только БИТЫ) используются команды pushf
(записать в стек значения флагов, бряк 1) и popf (извлечь из стека,
бряк 10).
ПРИМЕЧАНИЕ: Это несколько вольное положение (хотя, вобщем-то,
верное). Видимо, здесь следует хотя бы добавить, что сохранять/восстанавливать
флаги нужно при изменении специальных флагов (if, df). (С) Хемуль
Теперь колупаем дальше... (С) Serrgio
Бряк 3. Мне удобнее передавать данные "в процедуру" через регистр DX,
о чем и написано в заголовке. А lodsb (бряк 4) хотит, чтоб адрес ему
в SI подавали. Удовлетворим его желание :)
Бряк 4. После выполнения этой команды ASCII-код первого символа "цепочки"
"Hello, World-2$" помещается в AL, а значение регистра SI увеличивается
на 1 и указывает теперь на второй элемент цепочки (символ 'e').
Бряк 7. Удовлетворяем "пожелания" процедуры write_char. Из AL переносим
в DL и печатаем (бряк 9), write_char'ом.
Тэкс... Мы пропустили бряки 5 и 6-й...
Смотрите: на бряке 9 мы "безусловно" зацикливаем "извлечение" и печать
элементов цепочки. Безусловно - это значит до потери пульса. Чтоб этого
не произошло, каждый из элементов цепочки мы сравниваем с
символом-конца-цепочки (в нашем случае это '$', но можно использовать и
любой другой). Если текущий символ равен символу-конца-цепочки, то
выпрыгиваем (бряк 6) из этого безусловного цикла, восстанавливаем
статус-кво (бряк 10) и все на этом...
#3. Тестируем!!
testing proc
lea dx,abc
call write_string
call exit_com
testing endp
Напечаталось то, что надо?
Если true - читайте дальше.
Если false - расколупывайте и медитируйте до полного просветления...