Данному образовательному сайту пришлось несколько раз менять свое имя. С 2022 года доступ к нему обеспечивается по URL
emc.km.ru (2001-2007) ==> educomp.org.ru (2007-2011) ==> educomp.runnet.ru (2011-2021) ==> emc.orgfree.com (2022-...)
Более подробно об истории сайта можно прочитать здесь.
|
Об образовательных возможностях Debug(переход к другим статьям из этой серии) 2. Что такое ассемблер?Если кто-то считает программирование на ассемблере «прямым» общением с компьютером, то он ошиба- Данная статья продолжает публикацию материалов на тему как можно использовать отладчик Debug для изучения принципов работы компьютера. Сегодня будет рассмотрен вопрос о том, как соотносятся набор текстовых машинных инструкций программы и фундаментальный принцип двоичного кодирования. И конечно, согласно установившейся традиции, вам будут предложены новые эксперименты. Бытует мнение, что любая деятельность с машинными кодами есть работа на ассемблере. На самом деле это не так, и ассемблер есть некоторый более высокий уровень общения с процессором. Рассмотрим данную проблему подробнее. 2.1. Двоичный код – основа функционирования компьютераВ любом уважающем себя учебнике информатики сказано, что вся информация хранится и обрабатывается в компьютере в двоичной системе. Сама программа обработки также представляет собой не что иное, как двоичный код. В настоящее время, когда пользователь отделен от компьютерного «железа» несколькими слоями программного обеспечения, данный факт не очевиден: вы вводите в электронную таблицу десятичное число, и в соседней ячейке ответ выводится также в десятичном виде. Так что тезис о том, что внутри компьютера вычисления были произведены в двоичном коде, во многом приходится принимать на веру [1]. Сейчас мы не будем пытаться доказать или продемонстрировать справедливость тезиса о двоичном способе хранения и обработки информации в компьютере (желающие познакомиться с некоторыми экспериментальными свидетельствами в его пользу могут, например, обратиться к книге автора [1]). Будем ссылаться на данный тезис как на твердо установленный факт. Итак, компьютер способен непосредственно обрабатывать только двоичную информацию. Принять бинарную информацию он может из устройств внешней памяти, а также от других компьютеров через посредство сети. Хотя устройства внешней памяти весьма разнообразны, их объединяет именно то, что они хранят любые данные в двоичном виде, т.е. в виде, пригодном для наиболее быстрого и оперативного использования. Что же касается человека, то он, разумеется, нуждается в специальных устройствах, которые преобразуют его информацию во внутреннюю компьютерную форму и обратно. Для ввода бинарных машинных кодов человек легко может использовать клавиатуру. Важно понимать, что прием данных с клавиатуры (даже если предположить, что некий оригинал вводит чисто двоичный код!) всегда происходит по определенной программе, которая преобразует поток набираемых символов в двоичные числа и отправляет последние в необходимое место ОЗУ. Замена громоздких двоичных чисел более компактными восьмеричными или даже шестнадцатеричными числами [2] дела не меняет, лишь несколько усложняя программу кодирования. Мы видим, что простейшим способом организовать ввод цифровых кодов в память является несложное преобразование последовательности символов, соответствующих допустимым значениям цифр, в двоичный код. Именно такая программа, называемая монитором, имелась в ПЗУ отечественных «ДВК-образных» машин (семейство ДВК, БК, УКНЦ). Монитор позволял вводить коды в виде восьмеричных цифр и записывать их в набранный таким же образом адрес ОЗУ. Еще он умел запустить машинную программу и перехватить ее завершение, а также выполнял некоторые другие действия. Если внимательно подумать, то по своей сути такой монитор являлся непосредственным предшественником отладчиков типа Debug. Главной особенностью последнего по сравнению с монитором является возможность символьного представления машинных инструкций. Таков первый шаг от машинных кодов к ассемблеру. 2.2. Ассемблер: символьная запись командЦифровой ввод, легко распознаваемый машиной, довольно неудобен для человека. В целом большинству людей проще запомнить некоторое слово (даже на неродном языке, например, ADD или LOOP) чем комбинации цифр, им соответствующие. Аналогичным образом часто отказываются от обращения ко внутренним регистрам микропроцессора по номерам, заменяя их буквенными обозначениями (EAX, AX, AH, AL, BX, IP и т.д.) Подобная замена особенно уместна, если регистры микропроцессора не являются универсальными и имеют выделенное назначение (например, A – аккумулятор). Заметим, что неуниверсальность рабочих регистров и их жестким образом фиксированное использование, что свойственно семейству процессоров Intel, вовсе не единственный возможный вариант. Скажем, в семействе PDP логическая структура процессоров была более стройной: все регистры общего назначения РОН могли использоваться в машинных инструкциях на равных основаниях; в такой ситуации давать регистрам имена было менее удобно, чем просто пронумеровать. Описанные символьные обозначения стали называться мнемониками (мнемонический значит облегчающий запоминание). Вот несколько мнемоник для записи машинных инструкций процессора Intel.
Примечание. В таблице жирным шрифтом выделен код, описывающий операцию и способы обращения к данным; обычным шрифтом набраны непосредственные данные и адреса ячеек ОЗУ. Обратите внимание, что использованные в командах двухбайтовые числа хранятся «задом наперед»: число 0110 лежит в памяти не как 01 10, а, наоборот, 10 01. Подобный способ хранения, принятый в IBM PC, носит название обратный порядок хранения байтов. Думается, читатели согласятся, что мнемонический способ записи команд легче понимается и запоминается. Именно поэтому при работе с Debug обычно пользуются именно им. Мнемоническое представление команд является неотъемлемой частью ассемблера, но отнюдь не главной его частью. Наибольшее достоинство языка ассемблер состоит в возможности заменять символическими именами не только операции и регистры, но и конкретные адреса памяти. В последнем случае программист получает возможность освободиться от «привязки» к конкретным адресам памяти, что, в свою очередь, позволяет элементарно, как в обычном текстовом редакторе, удалять или дополнять команды программы. Такая замечательная возможность заслуживает более подробного обсуждения. 2.3. Ассемблер: идентификаторы и директивыИмеется как минимум две ситуации, когда программа однозначно привязывается к конкретным адресам памяти. Во-первых, при обращении к некоторым переменным (данным), которые находятся не во внутренних регистрах микропроцессора, но хранятся в памяти. И, во-вторых, при переходах, без которых невозможно организовать разветвляющиеся и циклические программы. Хотя обе ситуации по смыслу отличаются (происходит обращение к данным или программе), с формальной точке зрения оба этих случая выглядят необычайно похоже: требуется сослаться на адрес некоторой ячейки памяти – неважно, что именно там находится. Появление в программах конкретных адресов приводит к потере ее мобильности. Например, если по какой-либо причине потребуется изменить адрес переменной, то придется внести коррекцию во все команды, где на эту переменную содержится ссылка. Или при вставке всего одной машинной инструкции вся последующая часть программы сдвигается и приходится аналогичным образом модифицировать все переходы на ее адреса. Чтобы читатели получили некоторое представление об описанных трудностях, реализуем в Debug простейшую программу. 2.3.1. Задача, подводящая к идентификаторамПусть мы хотим решить на компьютере простейшую задачу, которая состоит вычислении по формуле r = x + y. Напишем и реализуем программу решения, а затем посмотрим, что потребуется исправлять, если мы захотим добавить к нашей формуле еще одну операцию, например: r = x + y – z. Договоримся, что все переменные являются двухбайтовыми целыми. Примем, что область хранения переменных будет находиться «в самом начале» – начиная с адреса 102. Поскольку по принятым в MS-DOS соглашениям программа стартует, начиная с адреса 100, придется предусмотреть «обход» области данных.
Примечание. Размещением данных указанным способом имеет свои достоинства и недостатки. С одной стороны, требуется обход области данных, возникают трудности при дизассемблировании (см. ниже), зато с другой – данные сохраняются на диск вместе с программой единым массивом и адреса информации в момент написания программы уже определены. При работе с Debug последнее немаловажно. Итак, память для данных спланирована и можно, постоянно заглядывая в табл. 2, набрать программу (см. следующий ниже протокол; директива dw в нем означает двухбайтовое число). Добавим, что в протоколе жирным шрифтом показаны символы, которые нам пришлось набирать; все остальные выведены на экран отладчиком. Причем текст, выделенный при редактировании курсивом, может отличаться от того, который получился на компьютере автора, а многоточие заменяет выводимые на экран строки, содержимое которых для нашей задачи абсолютно несущественно.
Теперь проконтролируем набор директивой u. Из протокола видно, что наличие данных посреди программы «сбивает» дизассемблер отладчика: пытаясь интерпретировать значения переменных как машинные команды, Debug «не попадает» на адрес 108. В результате первая команда программы выглядит неверно. Проверка директивой u 108 подтверждает, что в памяти все сохранено правильно. Примечание. Из последней распечатки запомним тот факт, что программа начинается с адреса 108 и завершается байтом 113. Эти сведения нам потребуются позднее при переделке программы. Остается запустить нашу программу и посмотреть ответ, для чего вывести содержимое памяти командой d 102. При расшифровке шестнадцатеричных чисел 3, 7, A (1010) следует обязательно вспомнить о примечании к таблице 1 по поводу обратного хранения байтов в памяти. Для удобства анализа протокола результирующие байты переменной r в протоколе подчеркнуты. Переходим теперь к наиболее интересной части нашего эксперимента. Что придется поменять, чтобы переделать нашу программу для расчета по исправленной формуле r = x + y – z. Увы, придется проделать довольно много операций, хотя каждая из них сама по себе несложная. Начнем с расширения таблицы переменных. Новое распределение памяти приведено в табл. 3 (советуем читателям внимательно сравнить ее с предыдущей таблицей; все изменения в табл. 3 выделены цветом и подчеркнуты).
Теперь будем вносить необходимые изменения. Все требуемые действия зафиксированы в приведенном ниже протоколе, который является продолжением предыдущего.
Передвинем основную часть программы, чтобы освободить 2 байта под новую переменную. Для этого наберем команду m 108 113 10a, которая означает задание отладчику подвинуть (move) байты со 108 по 113 (эти значения мы запомнили из предыдущих экспериментов с программой). Проверим, что программа действительно теперь находится с адреса 10a. Далее можно было бы аналогичным образом освободить байты под команду вычитания и ввести ее, но проще набрать 3 последние команды заново, тем более, что в команде записи результата в переменную r все равно потребовалось бы изменить адрес. Так что введем эти команды с адреса 111, а затем не забудем поменять адрес в стартовой инструкции перехода. Далее командой e 106 4 0 занесем в переменную z ее числовое значение (опять-таки в обратном порядке!) и все еще раз проверим. Наконец, запустим программу и убедимся в правильности получившегося результата: 3 + 7 – 4 = 6. Таким образом, поработав с простейшей программой, мы убедились в том, что замена адресов и переменных, и переходов при модификации программы представляет собой весьма трудоемкую работу, требующую предельного напряжения внимания (но, несмотря на все усилия, ошибки неизбежно случаются). От указанных трудностей можно избавиться, если для реализации программы в командах процессора использовать ассемблер. Главное преимущество ассемблера перед программой Debug заключается в том, что ассемблер нигде не использует конкретные адреса ОЗУ – вместо них везде указываются символические имена, которые называются идентификаторы. При трансляции текста программы ассемблер автоматически связывает идентификаторы с адресами ячеек памяти, в которых они будут располагаться. Как следствие, модификация текста программы не потребует никакого пересчета адресов, поскольку ассемблер при новой трансляции распределит их уже в соответствии с модифицированной программой. Текст нашей программы на ассемблере будет выглядеть так. jmp start x: dw 3 y: dw 7 z: dw 4 r: dw 0 start: mov ax,x add ax,y sub ax,z mov r,ax int 20 Легко видеть, что вставка двух выделенных строк это все, что потребуется при рассмотренной выше модификации программы. К сожалению, Debug не позволяет использовать идентификаторы, а значит, заметно уступает ассемблеру в данном вопросе. Впрочем, никто и не обещал, что данное программное средство является полноценным ассемблером. 2.3.2. Директивы ассемблераЯзык ассемблер содержит целый ряд специальных управляющих операторов (обычно их называют директивами), из которых Debug поддерживает только некоторые. В частности, команда dw 3, определяющая в памяти некоторую константу, суть одна из таких поддерживаемых отладчиком директив. В то же время, ассемблер содержит целый ряд директив, которые отсутствуют в Debug [2]. Между прочим, в свете наличия дополнительных директив, некоторые из которых в ассемблерной программе приходится писать обязательно, программа для Debug вводится проще. Зато ассемблер засчет добавочных директив становится мощнее; например, отдельные участки ассемблерной программы можно помещать внутрь условий, так что в зависимости от их выполнения или невыполнения будет генерироваться различный код. Таким образом, оказывается, что Debug не является в полном смысле слова ассемблирующей программой, хотя и позволяет вводить ассемблерные мнемоники команд. Следовательно, работая в Debug, мы не реализуем всех возможностей, которыми обладает настоящий язык ассемблер. Отметим, что хорошее понятие об ассемблере дают школьные учебники [3-6], где описываются учебные ЭВМ «Малютка» и «Нейман» (в книгах [4,6] изложение проведено более подробно). Особенно полезна практическая работа с программными реализациями указанных моделей, в которых имеется поддержка ассемблера. 2.4. Конструируем ассемблер самиВ качестве еще одного эксперимента предлагаем сконструировать свой собственный ассемблер. Главное достоинство идеи заключается, конечно, не в практической полезности полученного продукта, а в том, что в процессе написания программы мы лучше поймем принципы ассемблирования программ. Разумеется, мы возьмем максимально простую систему команд, которая предложена в учебнике [7]; она достаточно известна и называется «Кроха». Полный перечень команд «Крохи» приведен в табл. 4, где к каждой операции также добавлена ее мнемоника (в исходном учебнике не использовалась). Мнемонические имена команд выбраны вполне стандартными и совпадают с принятыми в ассемблере IBM PC.
Для уменьшения технической работы используем некоторые упрощения; они не повлияют на понимание принципов ассемблера, зато существенно ускорят программирование.
Благодаря принятым упрощениям ассемблирующая программа на Паскале получается совсем небольшой (ниже приводится ее листинг). PROGRAM kroha_asm(INPUT,OUTPUT); {Демо-ассемблер для учебной ЭВМ «Кроха»} CONST kop:ARRAY [0..7] OF STRING[3]= {мнемоники команд} ('MOV','ADD','DIV','SUB','JE','MUL','JG','HLT') prg:ARRAY [0..7] OF STRING= {ассемблируемая программа!!!} ('B: MUL F K F', ' ADD K E K', ' JG N K B', ' HLT F F F', 'K: DN 1', 'F: DN 1', 'N: DN 3', 'E: DN 1'); VAR tab: ARRAY [0..15] OF RECORD sym:STRING[3]; num:BYTE END; {tab – это таблица всех идентификаторов (их имя и код), включая операции и метки} i,Nid,k,e,c:INTEGER; {рабочие переменные} FUNCTION get_code(i:INTEGER; VAR k:INTEGER):BYTE; {выделяет из строки с номером i начиная с позиции k очередной идентификатор и находит в tab его код} VAR p,id:STRING; j,m,q:INTEGER; {рабочие переменные} BEGIN p:=prg[i]+' '; {добавим пробел для удобства выделения последней метки} WHILE (k<LENGTH(p))AND(p[k]=' ') DO k:=k+1; {пропустим пробелы} {а теперь выберем все до следующего пробела, т.е. получим имя идентификатора} id:=''; WHILE p[k]<>' ' DO BEGIN id:=id+p[k]; k:=k+1 END; q:=255; {найдем, если идентификатор есть, в таблице и поместим его код в q} FOR j:=0 TO Nid DO IF id=tab[j].sym THEN q:=tab[j].num; get_code:=q {результатом функции является код, заносимый в программу} END BEGIN FOR i:=0 TO 7 DO {занесем в tab коды операций} BEGIN tab[i].sym:=kop[i];tab[i].num:=i END; tab[8].sym:='DN';tab[8].num:=8; Nid:=8; {добавим условный код DN} FOR i:=0 TO 7 DO {проход I – занесение в таблицу меток программиста} IF prg[i][2]=':' THEN {после метки двоеточие} BEGIN inc(Nid); {добавляем метку в таблицу; ее адрес=номеру строки!} tab[Nid].sym:=prg[i][1];tab[Nid].num:=i; prg[i][1]:=' '; prg[i][2]:=' '; {удалим метку из текста} END; FOR i:=0 TO Nid DO {для контроля выведем tab} WRITELN(tab[i].sym:4, tab[i].num:4); FOR i:=0 TO 7 DO {проход II – «генерация» кода на экран} BEGIN k:=1; c:=get_code(i,k); {код операции} CASE c OF {выделяем команды или DN} 0..7: BEGIN WRITE(c); {<код операции} WHILE k<=LENGTH(prg[i]) DO {3 адреса} WRITE(' ',get_code(i,k)) END; 8:{DN}BEGIN {выделить 10-число и перевесит в 8 с/с} VAL(COPY(prg[i],k,255),k,e); WRITE(' '); e:=512; REPEAT WRITE(k DIV e); k:=k MOD e; e:=e DIV 8; UNTIL e=0 END END; WRITELN {конец вывода команды} END; END Программа работает следующим образом. Ядром ее данных служит таблица идентификаторов, которая содержит их имена в поле sym и коды, которые вместо этих имен надо подставлять в программу (поле num). Например, операция ADD имеет код 1 (см. табл. 4), поэтому tab[1].sym=’ADD’ и tab[1].num=1. Становится очевидным, что центральная идея ассемблера состоит в замене, пользуясь таблицей, каждого символьного имени соответствующим ему числовым кодом. После занесения в строки таблицы с номерами от 0 до 7 мнемоник команд «Крохи», добавим далее оператор определения константы DN и все найденные в программе метки, которые поставил пользователь в тексте. Метки пользователя распознаются по наличию после них двоеточия; чтобы обработанные метки не мешали ассемблеру в дальнейшем, программа их просто стирает. Обработка меток в литературе носит название первого прохода, поскольку сначала надо «пройти» всю программу и подготовить полную таблицу идентификаторов, и только потом повторно просмотреть текст (второй проход), заменяя с помощью таблицы все идентификаторы их кодами. При выполнении второго прохода наш ассемблер опирается на «мощную» функцию get_code, которая выделяет из текста программы очередной идентификатор. Несмотря на сложный вид, идея функции достаточно проста: в заданной строке, начиная с заданной позиции, она пропускает пробелы, а затем, наоборот, «собирает» все отличающиеся от пробела символы, формируя тем самым имя очередного идентификатора. Когда имя выделено, оно ищется в таблице и в качестве результата выдается его код (или 255, если имя в таблице отсутствует). Примечание. Особо отметим, что алгоритм функции построен так, чтобы автоматически подготовить значение переменной k для нового вызова с целью выделения следующего в этой строке идентификатора. Пользуясь описанной функцией, ассемблер выделяет первый идентификатор текущей строки программы и определяет его код. Если он попадает в диапазон от 0 до 7, то это машинная команда и в ней с помощью таблицы кодируются идущие следом 3 метки переменных (3 адреса). Если же обнаруживается, что код равен 8, то это определение числа. Последнее извлекается из строки и переводится в восьмеричную систему счисления. Все остальные случаи являются ошибкой и нашим ассемблером просто игнорируются. Заметим, что итоговый восьмеричный код программы просто выводится на экран дисплея и нигде не сохраняется. Для тестирования демо-ассемблера реализована традиционная задача – вычисление факториала числа n. Обозначим k рабочую переменную, которая является текущим множителем для факториала и меняется от 1 до n. Тогда итоговая программа приобретает вид, приведенный в табл. 5.
Второй столбец данной таблицы может быть использован для проверки результатов ассемблирования, которые наша программа выводит на экран. Более подробно программа вычисления факториала для «Крохи» рассматривалась в недавней публикации [8]. В качестве продолжения нашего эксперимента предлагаем читателям самостоятельно написать обратную программу – дизассемблер, которая по восьмеричному 4-разрядному коду восстанавливает ассемблерную мнемонику команд. Для решения задачи советуем воспользоваться таблицей, аналогичной tab, если потребуется, разделив ее на части. Разумеется, первоначальные имена меток по коду не восстановить, поэтому можно присваивать им по мере появления последовательные значения букв латинского алфавита. А теперь подведем итоги того, что мы узнали. В основе ассемблирования программы лежит относительно простая идея табличной замены имен идентификаторов соответствующими им кодами. В то же время реализация этой идеи ведет к замечательным последствиям: в программе исчезают все адреса и ее исправление становится не сложнее, чем исправление текста в текстовом редакторе: поскольку адреса переменных и команд распределяются теперь автоматически, следить за их изменением вовсе не нужно. Еще раз подчеркнем, что именно в этом, а не в замене кодов операций и регистров буквенными мнемониками, заключается истинная мощь ассемблера. В отладчике Debug, предназначенном в основном для отладки уже готовой программы, поддержка механизма меток не предусмотрена. Подчеркнем, что ассемблер – это язык программирования, хотя и весьма низкого уровня, жестко ориентированный на систему команд того процессора, для которого он предназначается. Очень важно отметить, что каждая исполняемая строка ассемблерного текста в точности соответствует одной машинной команде, что является характерной особенностью ассемблера. Отказ от данного ограничения и разрешение автоматической замены строки программы серией команд открывает дорогу (через так называемый макроассемблер) к простейшим языкам высокого уровня. В последних текст программы уже больше не содержит прямых ссылок на инструкции процессора, следовательно, сам язык становится машинно-независимым. Генерацию конкретных машинных инструкций полностью принимает на себя программное обеспечение – транслятор языка, который в традиционных реализациях является машинно-зависимым. Примечание. В последнее время появляются и более причудливые схемы трансляции с языков высокого уровня. Например, в языке Ява компилятор транслирует текст программы в код некоторой виртуальной машины; тем самым, итоговый результат компиляции также перестает быть машинно-зависимым. Вся «привязка» к конкретному компьютеру сосредоточена теперь в реализации Ява-машины, которая разрабатывается один раз и способна исполнить на данной платформе любую Ява-программу вне зависимости от того, на какой машине она была откомпилирована. Похожие идеи заложены и в идеологию платформы .NET (читается «дот-нэт» или «точка-нэт») фирмы Microsoft: компилятор с любого языка программирования генерирует код для единой виртуальной машины. Таким образом, делаются попытки сделать машинно-независимым не только исходный текст программы на языке высокого уровня, но и результат его компиляции. Литература
1 - по мнению автора, это один из принципиальных недостатков многих современных курсов информатики: сама по себе двоичная теория, и сам по себе Excel; все, что между ними, не обсуждается... 2 - «провокационный» вопрос, в каком виде (двоичном или восьмеричном) данные действительно хранятся в памяти, не должен вызывать никаких колебаний – разумеется, в двоичном! © Е.А.Еремин, 2007 Публикация: Еремин Е.А. Debug и язык ассемблер. "Информатика", 2007, N 5, с.37-39; N 6, с.37-38; N 7, с.42-44. |