Как писать ассемблерную программу, и какие программы нужны для ее последующей трансляции.
Ассемблерную программу можно писать в любом редакторе. Однако, поскольку мы собираемся работать «под DOS», то есть, скорее всего, находимся в одной из таких сред как DOS NAVIGATOR или FAR (или нечто подобное), логично использовать встроенный редактор среды, в которой мы работаем. Смело жмем Shift-F4 и в ответ на запрос называем наш файл, например, оригинальным названием Lab1.asm. Самое главное не забыть поставить расширение ASM. Если мы назовем наш файл, допустим, Lab1, при трансляции нашей программы транслятор выдаст сообщение: Can’t locate file Lab1.asm.Это означает, что транслятор не нашел файл Lab1.asm. Иначе говоря, транслятор работает только с файлами, имеющими расширение ASM. Конечно, мы в любой момент можем присвоить нашему файлу нужное расширение, попросту переименовав наш файл. Но зачем создавать себе лишние трудности, лучше сразу действовать правильно.
После того как наша программа написана, ее надо оттранслировать и получить из нее исполняемый файл (с расширением COM или EXE). Пока мы не будем рассказывать, как это делается, а остановимся на том, какие программы нужны для трансляции, и где их можно найти. Собственно для нашей цели нужны две программы: TASM.EXE (собственно транслятор) и TLINK.EXE (редактор связей). Зачастую TLINK требует для своей работы наличия еще нескольких (чаще всего трех) сопровождающих файлов: DPMILOAD.EXE, DPMIMEM.DLL, DPMI16BI.OVL (или нечто подобное). Помните, если программе TLINK недостает какого-либо файла, TLINK сам при запуске об этом подробно напишет.
Кроме того, для отладки программ нам понадобится еще один файл: отладчик TD.EXE (Turbo Debugger). Этот отладчик позволяет выполнять программу в пошаговом режиме и одновременно следить за изменением информации в регистрах, памяти, стеке и на экране.
Все эти файлы уже давно стоят на Вашем компьютере, если на нем установлен хотя бы один язык высокого уровня (Паскаль, Си, Си++, Delphi и. т. д). В любом из этих языков в поддиректории BIN Вы найдете все вышеуказанные файлы.
Существуют и другие трансляторы, редакторы связей и отладчики, например MASM, LINK, AFD.Ими тоже можно успешно пользоваться.
Системы счисления
Любое число может быть задано в различных системах счисления. Например, число 130 в различных системах счисления выглядит так:
десятичная | |
двоичная | |
шестнадцатеричная | |
восьмеричная |
Наиболее часто при написании ассемблерных программ используются числа, заданные в 10-й и 16-й системах счисления. Реже, но все-таки достаточно интенсивно, используется двоичная система счисления. Именно ими мы и ограничимся, хотя надо честно признать, что здесь сознательно упущена довольно широко используемая «двоично-десятичная» система счисления.
Для того чтобы транслятор мог понять, о какой системе счисления идет речь при задании в программе какого-либо числа, справа от этого числа пишется соответствующая буква:
десятичная | |
10000010b | двоичная |
82h | шестнадцатеричная |
Проще говоря, если буквы нет, транслятор понимает число, как 10-е и сам переводит его в двоичный эквивалент, с которым уже и работает процессор. Если есть буква h (hexadecimal), транслятор понимает это число как 16-е и сам переводит его в двоичный эквивалент и. т. д. Буква b означает двоичную систему (binary).
Кстати, транслятор считает значительно быстрее и правильнее, чем «человек с калькулятором», поэтому если Вам надо в Вашей программе, допустим, занести в регистр ax значение 34*21/ 8, смело пишите:
mov ax, 34*21/8
транслятор сам все подсчитает лучше Вас.
Но не стоит быть слишком смелым (вот мы уже и отвлекаемся от основной темы раздела). Если Вы не уверены в том, как транслятор поймет Вашу конструкцию, лучше ее не пишите. Любую конструкцию обычно можно задать разными способами. При этом лучше выбрать способ, в котором Вы будете уверены, хотя, возможно, Вам при этом придется написать и больше команд. Например, программист ввел в программе три переменные x, y и z
x db 7; директивой db программист «попросил» транслятор выделить в памяти байт
; для переменной, которую программист назвал х, и занести в этот байт число 7
Y db 17
Z db 3
; затем где-то в программе программист написал команду
mov al, x+2
Если программист при этом рассчитывает, что в регистр al попадет 7+2=9, то он заблуждается. В al попадет 3 (адрес Х плюс 2 байта = адрес Z, по этому адресу из памяти и будет выбрана информация). Чтобы в al действительно попало 7+2=9, надо было писать:
mov al, x; занести в alзначение переменнойх
add al, 2 ; прибавить к содержимомуalдвойку
Вернемся к нашей основной теме. У новичков часто возникает вопрос: «А какую систему счисления лучше использовать в моей программе?» Отвечаем, та или иная система счисления используется в зависимости от ситуации. Например, в регистр al надо занести число 112, здесь удобно написать:
Mov al, 112
Теперь в al надо занести 12, а в регистр ah – 9. Можно написать:
mov al, 12; вal ß00001100b= 0ch
mov ah, 9; вah ß00001001b= 9h
Но ведь можно написать и короче, учитывая, что ah и al составляют вместе регистр ax:
mov ax, 90ch; вax ß0000100100001100b = 090ch.
Курсивом выделена та часть числа, которая попадает в ah. То есть здесь удобно использовать 16-ю систему счисления. (Если бы мы использовали 10-ю и написали бы mov ax, 912 это было бы неправильно, поскольку транслятор, переведя 912 в двоичный код, получил бы 0000001110010000 то есть ah попало бы 3, а в al – 144, что никак не соответствует заданию).
Двоичную систему часто используют, например, при задании управляющих слов, в которых каждый отдельный бит несет в себе определенную управляющую информацию.
Отметим следующий важный момент. В шестнадцатеричной системе счисления в качестве цифр используются буквы: a (10), b (11) ….. f (15). Если мы напишем в программе число с7а3h, транслятор не поймет, что речь идет о числе, он будет считать, что это идентификатор какой-то переменной. Поэтому, если число начинается с буквы, перед этой буквой надо писать цифру 0!. В этом случае транслятор поймет, что речь идет о числе, а не о переменной. Итак, правильная запись нашего числа - 0c7a3h. Начинающие программисты часто забывают об этом правиле, в результате транслятор начинает выдавать сообщения об ошибках.
Оперативная память.
С точки зрения программиста оперативная память представляет собой линейный массив ячеек, размером один байт каждая. Каждой ячейке ставится в соответствие ее адрес (номер). Этот адрес принято называть абсолютным или физическим (принятое обозначение – Аф). Считается что адреса ячеек памяти, с которой работает DOS, лежат в диапазоне от 00000h до fffffh, как показано на рис 2.1. То есть DOS работает с памятью объемом 1 Мбайт.
Рис. 2.1
Чтобы прочитать байт информации из какой-то ячейки памяти или записать в эту ячейку новый байт, необходимо написать соответствующую команду и в этой команде задать необходимый адрес. Например, если в результате выполнения команды формируется физический адрес 00000h, процессор обращается к младшему байту памяти.
Информация в памяти может располагаться не только в виде отдельных байт, но и в виде слов (2 смежных байта), двойных слов (4 байта) и. т. д. При этом, адресом любой структуры в памяти считается физический адрес младшего байта этой структуры. То есть если слово занимает в памяти два байта с адресами 0002аh и 0002bh, это слово имеет физический адрес 0002ah.
Стек
Стек – это особый вид памяти. Если при обращении к обычной памяти мы должны тем или иным образом задавать в команде адрес ячейки, к которой мы обращаемся, то при обращении к стеку, никакие адреса в команде не задаются. Для того чтобы это было возможно в состав процессора введен специальный регистр sp, называемый указателем стека. Содержимое этого регистра и адресует некоторую ячейку памяти, которую называют вершиной стека. При выполнении стековой операции (команды), процессор берет из sp адрес вершины стека и записывает в нее (или считывает из нее) информацию. При этом процессор автоматически изменяет содержимое sp, изменяя тем самым месторасположение вершины стека.
Собственно существует две стековых операции:
· затолкнуть информацию в стек (push)
· вытолкнуть информацию из стека (pop).
Например, команда push bx (затолкнуть в стек содержимое двухбайтового регистра bx)выполняется следующим образом. Сначала процессор вычтет из содержимого регистра sp двойку (поскольку формат bx –2 байта), изменив тем самым вершину стека, а затем в эту новую вершину загрузит содержимое регистра bx. Таким образом, при заталкивании стек растет в сторону младших адресов памяти.
Команда pop bx (вытолкнуть слово из вершины стека в регистр bx) выполняется в обратном порядке. Сначала процессор по содержимому sp определит вершину стека, считает из этой вершины слово и поместит его в bx, а затем прибавит к sp двойку, изменив тем самым вершину стека.
Приведем пример. Состояние стека на данный момент приведено на рис. 2.2.
sp à | 43h |
15h |
Рис. 2.2
Пусть в данный момент содержимое bx= 35c7h, а содержимое ax= 2233h. И пусть выполняется последовательность команд:
Push bx
Push ax
Pop bx
Состояние стека и регистров, после выполнения каждой команды показано соответственно на рис. 2.3-2.5.
sp à | с7h |
35h | |
43h | |
15h |
bx = 35c7h ax = 2233h
Рис. 2.3.
sp à | 33h |
22h | |
с7h | |
35h | |
43h | |
15h |
bx = 35c7h ax = 2233h
Рис. 2.4
33h | |
22h | |
sp à | с7h |
35h | |
43h | |
15h |
bx = 2233h ax = 2233h
Рис. 2.5
Стек интенсивно используется практически во всех программах, хотя начинающий программист может и не подозревать, что его программа работает со стеком. Связано это с тем, что ряд команд работает со стеком неявным образом:
· call - вызов подпрограммы (запоминает в стеке адрес возврата);
· ret- возврат из подпрограммы (берет из стека адрес возврата);
· int n– программное прерывание (запоминает в стеке адрес возврата и регистр флагов);
· iret-возврат из прерывания (выталкивает из стека адрес возврата и восстанавливает флаги).
Стек очень удобно использовать, когда надо на время сохранить, а затем восстановить содержимое какого-либо регистра.
Самая распространенная ошибка при написании стековых команд заключается в использовании восьмиразрядных регистров, что не допускается системой команд. Со стеком всегда обмениваются словами, а не байтами. Например, команда push cx вполне допустима, а на команде push cl транслятор выдаст сообщение об ошибке.
Другая ошибка, не столь очевидная, но приводящая к куда более тяжелым последствиям, заключается в том, что программист не следит за положением указателя стека, а это его прямая обязанность. Если sp указывает не туда, куда рассчитывает программист, то команда, выталкивающая что-либо из стека, приведет к непредсказуемым для этого программиста последствиям.
Собственно, работая со стеком, полезно помнить два простых правила:
1. Если Вы решили сохранить содержимое какого-то регистра в стеке (командой push), а потом восстановить его (командой pop), но в промежутке между этими операциями Вы также используете стековые команды, Вы обязаны помнить: число заталкиваний в стек на этом промежутке обязательно должно быть равно числу выталкиваний из стека (количество команд push должно быть равно количеству команд pop). Если это условие не соблюдается, наш регистр будет восстановлен неправильно;
2. Если Вы последовательно затолкнули в стек содержимое ряда регистров, то восстанавливать эти регистры из стека надо в обратном порядке. Например:
Push dx
Push bx
Push cx
.
.
.
Pop cx
Pop bx
Pop dx
Сегментация памяти.
Это очень важный раздел, рассказывающий о том, каким образом процессор формирует адреса памяти. Знать об этом должен любой человек, берущийся программировать на ассемблере.
Допустим, мы написали ассемблерную программу, оттранслировали ее и получили COM или EXE файл, который и запустили на выполнение. При этом DOS берет наш исполняемый файл, загружает его в память и передает управление первой команде нашей программы. Возникает вопрос: а в какое место памяти попадает наш файл?
Как уже говорилось выше, DOS работает с памятью объемом 1 Мбайт. Однако наша программа не может загрузиться в любое место этого адресного пространства. Например, пространство памяти с адресами, превышающими 640 Кбайт, отведено для системных нужд. По этим адресам располагается видеопамять, ПЗУ BIOS, стартовое ПЗУ и так далее. Младшие адреса памяти тоже заняты. Там располагаются таблица прерываний, переменные DOS и BIOS, ядро самой DOS и различные драйверы. Такое распределение памяти показано на рис. 2.6.
Наша программа может загрузиться только в свободную область памяти, причем DOS загрузит ее в самое начало этой области. Но какой адрес у этого «начала свободной области»? Ответить на этот вопрос невозможно. Все зависит от числа и объема драйверов, запущенных на конкретной ПЭВМ. DOS, конечно, знает где в данный момент начинается свободная память, но когда мы пишем программу, и когда транслятор ее транслирует ни нам не транслятору эта информация не известна.
Иначе говоря, при каждом запуске наша программа загружается в разные места памяти. Но ведь в нашей программе может быть выделена память под переменные, и мы к этим переменным периодически обращаемся, формируя соответствующие адреса в соответствующих командах. Как же эти переменные адресуются, если при каждой новой загрузке у переменной меняется адрес? Мы же не переписываем программу заново. А все дело в том, что в программе используется не абсолютная, а относительная адресация. То есть в команде задается не физический адрес переменной в памяти, а смещение, показывающее месторасположение этой переменной относительно начала программы.
? | Занятая область |
? 640К | Свободная память |
1М | Занятая область |
Рис. 2.6
Для того чтобы DOS могла сообщить программе, начиная с какого адреса последняя загружена в память, в состав процессора введены сегментные регистры, в которые DOS и заносит соответствующую информацию. Всего таких регистров четыре (в современных процессорах шесть): cs, ss, ds и es. Принято говорить, что содержимое этих регистров задает начальные адреса четырех сегментов, с которыми в данный момент работает процессор. При этом физический адрес любой ячейки памяти формируется как сумма начального адреса сегмента и внутрисегментного смещения. Последнее часто называют эффективным адресом (Аэф).
Однако тут существует небольшая проблема. Для адресации 1Мбайта памяти адрес должен быть 20 разрядным, а сегментные регистры 16 разрядные. Поэтому процессор, определяя по содержимому сегментного регистра начальный адрес сегмента, всегда дописывает к этому содержимому справа 4 двоичных нуля (умножает содержимое сегментного регистра на 16). Отсюда, кстати, видно, что начальный адрес любого сегмента всегда кратен шестнадцати (выровнен по границе параграфа).
Итак, процессор всегда формирует 20 разрядный физический адрес по формуле:
Аф = (sr)*16 + Аэф.
Здесь конструкция (sr) читается как «содержимое сегментного регистра». Например, пусть в каком-то сегментном регистре записано число 2234h, и Аэф = 55d0h, тогда Аф= 2233h*16 + 55d0h = 2233h*10h + 55d0h = 22330h + 55d0h = 27900h.
Необходимо понимать, что при обращении к конкретной ячейке памяти мы не можем в какой-либо команде сразу задать ее физический адрес. Мы всегда задаем этот адрес как пару (начальный адрес сегмента):(внутрисегментное смещение) или сокращенно сегмент:смещение. Например, нам надо обратиться к ячейке с адресом 03167h.Нам придется представить адрес этой ячейки в виде пары 0316:0007h (или 0310:0067h или…).
Далее рассмотрим назначение конкретных сегментных регистров.
Сегментный регистр CS (code segment) – задает начальный адрес сегмента, в котором располагается программа, которую в данный момент выполняет процессор. Регистр cs совместно с регистром ip (instruction pointer), в котором задается смещение в кодовом сегменте, всегда определяют адрес следующей команды программы. Иначе говоря, процессор, выбирая из памяти очередную команду, всегда формирует адрес этой команды по формуле:
Аф = (cs)*16 + (ip).
Сегментный регистр SS (stack segment) – задает начальный адрес сегмента, в котором располагается стек. Регистр ss, совместно с регистром sp (stack pointer), задающим смещение в сегменте стека, всегда определяют физический адрес вершины стека. То есть, при выполнении стековой операции (push, pop,…) процессор всегда формирует адрес памяти по формуле:
Аф = (ss)*16 + (sp).
Сегментный регистр DS (data segment) – задает начальный адрес текущего сегмента данных. Смещение в этом сегменте задает эффективный адрес, который процессор формирует по информации, заданной в текущей команде (той команде, которую процессор выполняет в данный момент). Например:
mov [2], bl | ; Команда записывает в память содержимое регистра bl. ; Адрес памяти при этом формируется по формуле: ; Аф = (ds)*16 + 2.(Аэф = 2). |
mov ax, [bx + si – 7] | ; Команда заносит считанное из памяти слово в ; регистр ax. Адрес памяти при этом формируется по ; формуле: Аф = (ds)*16 + (bx) + (si) – 7.То есть Аэф ; формируется как сумма содержимого регистров bxи si ; минус 7. |
mov perem, 15 | ; Команда записывает число15 в переменную, названую ; программистом perem.Смещение для этой переменной ; (Аэф) подсчитает транслятор. Адрес памяти будет ; считаться по формуле:Аф = (ds)*16 + Аэф. |
Ни в одной из трех приведенных выше команд регистр ds явно не указан, но именно этот сегментный регистр будет браться по умолчанию. Здесь, в отличие от выборки команд и стековых операций, у нас имеется возможность сменить сегментный регистр ds, прямо указав в команде другой сегментный регистр. Например:
mov cs:[2], bl | ; Команда записывает в память содержимое регистра bl. ; Адрес памяти при этом формируется по формуле: ; Аф = (cs)*16 + 2. (Аэф = 2). |
У начинающего программиста на ассемблере вряд ли возникнет потребность менять содержимое сегментных регистров cs, ss и ds, переходя тем самым к новым сегментам кода, стека и (или) данных. А вот менять содержимое сегментного регистра es ему возможно придется.
Сегментный регистр ES (extra segment) – «дополнительный сегмент». Он используется, если мы хотим обратиться к памяти, расположенной за пределами текущего сегмента данных. Приведем пример. Известно, что видеопамять для текстового режима располагается, начиная с адреса b8000h.При этом байт, расположенный по этому адресу, содержит в себе ASCII код символа, который высвечивается в левом верхнем углу экрана, а байт по адресу b8001h – атрибуты этого символа (цвет символа и цвет фона). Допустим, мы хотим вывести в левом верхнем углу экрана черным цветом на белом фоне букву «Ф». Мы можем сделать это следующим образом:
mov ax, 0b800h | ; Мы мысленно разбили Аф = b8000h на пару b800: 0000h. ; Теперь хотим b800 занести в сегментный регистр es. ; Сразу это сделать невозможно (нет таких команд). ; Приходится это делать через какой-либо 16 разрядный ; регистр. Мы выбрали регистр ах. ; Итак, эта команда загружает в ах число b800h |
mov es, ax | ; Эта команда переписывает содержимое ах в es. |
mov es:[0], ‘Ф’ | ; Эта команда загружает ASCII код буквы «Ф» в память ; по адресу b8000h. ; Аф = (es)*16 + 0 = b800h*10h + 0 = b8000h. ; ASCII код подставит в команду транслятор вместо ; конструкции ‘Ф’. |
mov es:[1], 70h | ;Эта команда заносит атрибуты по адресу b8001h. ; Аф = (es)*16 + 1 = b800h*10h + 1 = b8001h. ; 70h – «выводить черным по белому» |