Низкоуровневое программирование на Ассемблере под DOS
Предисловие (от редакции)
Эта статья впервые была опубликована еще в конце 2002-го года. В то время были актуальны совсем другие операционные системы. Однако в теоретической своей части может быть интересна тем программистам (или просто интересующихся тематикой), которые изучают более низкоуровневое программирование. На сайте проекта WASM.RU эта статья входила в рубрику «Низкоуровневое программирование для дZенствующих». Если коротко — эта тематика тех, кого интересует чуть больше, чем уровень «Hello World».
Содержание
- 2.1. Проба молотка
- 2.2. Несколько «тупых» процедурок
- 2.3. Печать «шестнадцатеричных циферек»
- 2.4. Печать десятичных циферек
- 2.5. «Hello, world!» или Изврат-2
- Диагноз
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 — расколупывайте и медитируйте до полного просветления…
Диагноз
Когда-то автор этого текста хотел осветить все вопросы низкоуровневого программирования, начиная от процессора 8086 до p3, от DOS’а до W2000 и линуха. Однако, практика показала, что при таких черепашьих темпах написания… к тому времени когда дойдет очередь хотя бы до w3.11 — windows-2000 уже станет вчерашним днем :(. Поэтому этот туториал, мягко говоря, не совсем завершен ;).
[C] Serrgio / HI-TECH
Источник WASM.RU