Text
                    Александр Крупник
САМОУЧИТЕЛЬ
Ассемблер
Прочитав
эту книгу, вы:
♦	поймете, как работает
процессор
современного ПК
♦	научитесь
программировать
на ассемблере
для Windows и DOS
♦	станете лучше понимать
языки высокого уровня —
Си, C++ и Паскаль
♦	сможете восстановить
программу
по исходному коду
^ППТЕР

СЕРИЯ самоучитель) ПИТЕР
Александр Крупник (самоучитель) Ассемблер ^ППТЕР* Москва • Санкт-Петербург • Нижний Новгород • Воронеж Новосибирск • Ростов-на-Дону • Екатеринбург Самара Киев • Харьков • Минск 2005
ББК 32.973-018.1я7 УДК 004.43(075) К84 Крупник А. К84 Ассемблер. Самоучитель. — СПб.: Питер, 2005. — 235 с.: ил. ISBN 5-469-00825-8 Книга знакомит читателя с ассемблером — универсальным языком «низкого уровня», на который переводятся другие, «высокоуровневые» языки. Будучи основой таких языков, ассемблер позволяет лучше понять и Си, и C++, и Паскаль. Кроме того, с его помощью можно написать отдельные части программ так, чтобы они быстрее выполнялись. В силу своей универсальности ассемблер позволяет менять и чужие программы, исходный текст которых на языке высокого уровня недоступен. За это его так любят хакеры. Начав с простых коротких примеров, написанных для ассемблера MASM фирмы Microsoft, и двигаясь вперед, вы научитесь писать довольно сложные программы для Windows и DOS. Книга предназначена для всех, кто интересуется программированием вообще и ассемблером в частности. ББК 32.973-018.1я7 УДК 004.43(075) Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав. Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. ISBN 5-469-00825-8 © ЗАО Издательский дом «Питер», 2005
Краткое содержание Предисловие...................................................10 Глава 1. Начало...............................................12 Глава 2. Числа............................................... 20 Глава 3. Память...............................................33 Глава 4. Как решать задачу....................................57 Глава 5. Шире круг............................................72 Глава 6. Файлы................................................93 Глава 7. Дроби...............................................110 Глава 8. Модульность.........................................120 Глава 9.16 бит...............................................133 Глава 10. Жизнь в сегментах..................................143 Глава 11. Model flat для DOS.................................160 Глава 12. Полезности.........................................171 Глава 13. Окна...............................................183 Глава 14. Ассемблер и другие языки...........................196 Решения задач................................................201 Приложение. Флаги и основные инструкции процессора...........213 Алфавитный указатель.........................................231
Содержание Предисловие...................................................10 От издательства...............................................11 Глава 1. Начало................................................12 Язык компьютера...............................................12 Операционная система..........................................13 Компилятор....................................................15 Создание программы............................................16 Первые шаги...................................................18 Глава 2. Числа ................................................20 8 + 8 = 10?...................................................20 Двоящийся мир.................................................22 Конечность....................................................23 Знак .........................................................26 Переполнение..................................................28 Байты и слова.................................................30 Глава 3. Память................................................33 Адреса........................................................33 Стек .........................................................35 Косвенная адресация...........................................39 Процедуры............................•........................40 Не могу молчать ..............................................44
Содержание 7 Разбор полетов.................................................46 Своеволие ассемблера ..........................................49 Глава 4. Как решать задачу.......................................57 Вывод чисел ...................................................57 Переходы.......................................................59 Повторение.....................................................61 Деление .......................................................62 Массивы .......................................................64 Простые числа..................................................65 Как пишутся программы..........................................68 Глава 5. Шире круг...............................................72 Логические инструкции..........................................72 Сдвиги..................................................: 75 Круженье битов.................................................78 Сложение и вычитание...........................................82 Умножение и снова деление......................................85 Ввод...........................................................88 Глава 6. Файлы...................................................93 Открытие файла ................................................93 Чтение.........................................................95 Интернет — источник знаний.....................................98 Командная строка...............................................99 Kiss-принцип..................................................103 Открытие файла — 2............................................105 Прогулки по файлу.............................................107 Глава 7. Дроби..................................................110 Нужно держаться корней........................................110 Процессор и сопроцессор ......................................114 Слово состояния...............................................116
8 Содержание Глава 8. Модульность.........................................120 Объектные файлы.............................................120 DLL.........................................................127 Точка входа.................................................129 Ручной вызов................................................130 Глава 9.16 бит...............................................133 DOS.........................................................133 Сегменты ...................................................136 Опять про сегменты..........................................138 Глава 10. Жизнь в сегментах..................................143 Ужимки и прыжки.............................................143 Межсегментные каналы........................................147 Процедуры...................................................150 Адресация...................................................154 Прерывания..................................................157 Глава 11. Model flat для DOS.................................160 .COM........................................................160 Рекурсия....................................................163 Кодокопание.................................................168 Глава 12. Полезности.........................................171 Управление потоком..........................................171 Круженье....................................................174 Макросы.....................................................175 Структуры...................................................178 Typedef и венгерская нотация................................180 Глава 13. Окна...............................................183 Сообщения...................................................183 Создание окна...............................................184 Первое окно.................................................187
Содержание 9 Испытание окна..............................................189 WM_PAINT....................................................190 Меню........................................................192 Последнее окно..............................................195 Глава 14. Ассемблер и другие языки...........................196 Решения задач................................................201 Глава 2 ....................................................201 Глава 3 ....................................................202 Глава 4.....................................................202 Глава 5 ....................................................203 Глава б.....................................................208 Глава 7.....................................................209 Глава 11....................................................209 Глава 13....................................................211 Приложение. Флаги и основные инструкции процессора.............................213 Флаги.......................................................213 Инструкции процессора.......................................213 Алфавитный указатель.........................................231
Предисловие Меня беспокоит Блезуа, — заметил Атос. — Вы слы- шали, д’Артаньян, он сказал, что умеет плавать только в реке. — Если умеешь плавать, то можешь плавать везде, — отвечал д’Артаньян. — К лодке, к лодке! Александр Дюма. Три мушкетера Многие книги учат плавать и в реке и в море вместо того, чтобы учить плавать. Авторы многих книг по ассемблеру стараются написать о каждой инструкции процессора, забывая, что голая информация в эпоху Интернета не имеет боль- шой цены. Описание инструкций процессора можно найти во многих докумен- тах, разбросанных по Сети. Но там невозможно отыскать чувство языка, пони- мание его сути, без которого учить инструкции ассемблера так же бесполезно, как зубрить иностранные слова, не зная грамматики языка. В этой книге изучение ассемблера (имеется в виду ассемблер для процессоров Intel) построено на простых коротких примерах, разбирая которые, читатель очень быстро поймет основные принципы программирования и сможет само- стоятельно двигаться дальше. В этом ему поможет Приложение, где описаны ос- новные инструкции процессора. Эта книга отличается от многих других еще и тем, что рассказывает главным образом о программировании в 32-разрядной операционной системе Windows. Первые шесть глав знакомят читателя с простыми консольными приложениями, в седьмой и восьмой главах рассказывается о сопроцессоре и построении боль- ших программ из отдельных блоков. Следующие две главы посвящены поряд- ком устаревшему, но все еще необходимому (хотя бы для понимания старых ис- ходных текстов) программированию в 16-разрядной системе DOS и, наконец, последние главы рассказывают о создании «настоящих» программ для Windows с окнами и меню, а также о значении ассемблера и его месте среди других язы- ков программирования. Последняя тема не случайна, потому что многие думают, что в эпоху языков вы- сокого уровня и мощных компьютеров ассемблер безнадежно устарел. На самом деле, ассемблер будет нужен всегда, ведь его знание позволяет глубже понять те же языки высокого уровня, а также устройство операционной системы. Кроме того, отдельные фрагменты программ все еще необходимо переписывать на ас-
От издательства 11 семблере, чтобы они быстрее выполнялись. Наконец, без ассемблера не обойтись тем, кто занимается исследованием программ, чей исходный текст на языке вы- сокого уровня недоступен. Многие считают ассемблер очень трудным и начинают учиться программировать на нем, потому что это «круто». Прочитав эту книгу вы поймете, что ассемблер — простой, однозначный, но тем не менее интересный и мощный язык. Александр Крупник (krupnik@sandy.ru) Нижний Новгород, 31 января 2005 года От издательства Ваши замечания, предложения и вопросы отправляйте по адресу электронной почты comp@piter.com (издательство «Питер», компьютерная редакция). Мы будем рады узнать ваше мнение! Все исходные тексты, приведенные в книге, вы можете найти по адресу http://www.piter.com/download. Подробную информацию о наших книгах вы найдете на веб-сайте издательства: http: //www. piter.com.
Глава 1 Начало Язык компьютера Я почти читаю ваши мысли: «Конечно, это компьютерная книга, и он пытается научить меня думать как компьютер». Ничего подоб- ного! Компьютеры думают как мы. Ведь это мы их создали, как еще они могут думать? Нет, все что я пытаюсь сделать — это заста- вить вас пристально взглянуть на то, как вы думаете. Мы настолько привыкли все делать автоматически, что буквально не задумыва- емся над тем, как думаем. Джеф Дантеман. Ассемблер шаг за шагом Ассемблер — родной язык компьютера. Можно сказать, что компьютер «ду- мает» на ассемблере. Поэтому программы, написанные на других языках, таких как Си, нужно сначала перевести на ассемблер, чтобы компьютер их понял и смог исполнить. Когда мы говорим о компьютере, выполняющем программы, то прежде всего име- ем в виду его сердце — процессор — специальную микросхему, которая испол- няет команды, часто называемые инструкциями, и хранит результаты их рабо- ты в специальных регистрах. Так, например, выполнение инструкций процессора mov еах, 2 add еах. 3 приводит к тому, что в регистре еах оказывается число 5. Первая инструкция mov еах, 2 посылает в регистр еах число 2. Вторая инструкция add еах, 3, выпол- няемая вслед за первой, прибавляет к содержимому регистра еах число 3.
Операционная система 13 Операционная система Представьте себе абсолютно голого человека, выброшенного на необитаемый остров, и тогда вы поймете, что значит для нас жить без ДОСа под испепеляющим огнем процессора. С. Расторгуев. Программные методы защиты информации в компьютерах и сетях Я немного лукавил, говоря в предыдущем разделе о том, что процессор пони- мает язык ассемблера без перевода. Процессор понимает только числа. Поэтому текст, написанный человеком, нужно еще преобразовать в последовательность чисел, понятную процессору. Делает это специальная программа, тоже называе- мая ассемблером. Она читает текст программы и переводит его в числа — ин- струкции процессора. Обычно одна строка программы, написанной на ассемблере, переводится в одну инструкцию процессора. Но так бывает не всегда, потому что ассемблеру нужны еще подсказки — для какого процессора и операционной системы предназначе- на программа, где находится первая инструкция и многое другое. Обычно это делают директивы, которые не переводятся в инструкции процессора, а лишь управляют программой-ассемблером. Кроме инструкций процессора и директив, программа на ассемблере содержит вызовы различных процедур, без которых она окажется голой и беспомощной под «испепеляющим огнем процессора». Ведь программы работают не сами по себе, а под управлением операционной системы, которая их запускает, обеспечи- вает взаимодействие с внешней средой (вывод символов на экран, чтение и за- пись на жесткие диски и т. д.) и затем помогает правильно завершиться. Для всего этого и созданы процедуры операционной системы, часто называемые API, то есть, Application Programming Interface — интерфейс прикладных про- грамм. Мы будем называть их Windows API, потому что в этой книге нас будут прежде всего интересовать системы семейства Windows 95*. Каждой процедуре Windows API передаются параметры, то есть сведения, необ- ходимые ей для работы. Если, например, процедура, выводит какое-то сообщение на экран, то ей необходимо знать, где находится это сообщение, сколько в нем символов и т. д. Самой простой процедуре ExitProcess, с которой мы вот-вот по- знакомимся, требуется всего один параметр. Это код завершения — обычное чис- ло, которое получает операционная система после окончания работы программы. Если код завершения равен нулю, система поймет, что все нормально. Ненулевой код завершения говорит операционной системе о какой-то неполадке в программе. Процедуры операционной системы вызываются специальной директивой i nvoke, за которой следует имя процедуры, а дальше — разделенный запятыми список параметров. Например, вызов процедуры ExitProcess выглядит так: invoke ExitProcess. О Единственный передаваемый ей параметр — число 0 отделено от имени про- цедуры запятой. *В это семейство входят операционные системы Windows 95/NT/98/ME/2000/XP.
14 Глава 1. Начало Процедура ExitProcess вызывается в каждой ассемблерной программе, даже если та и не пытается взаимодействовать с чем-то кроме процессора, как, например, наша первая программа, просто складывающая два числа (листинг 1.1). Листинг 1.1. Первая программа .386 .model flat, stdcall inc1udel1b \myasm\lib\kernel32.11 b ExitProcess proto :DWORD .code start: mov eax, 2 add eax. 3 Invoke ExitProcess, 0 end start В ней инструкции процессора mov еах, 2 add еах. 3 окружены директивами — специальными командами, которые должен выпол- нить не процессор, а сама программа-ассемблер. Первая директива нашей пер- вой программы .386 начинается с точки и показывает, для какого процессора предназначена программа. В нашем случае это процессор Intel 80386 и более поздние модели, ведь семейство процессоров Intel совместимо снизу вверх и то, что умеет процессор 80386, под силу и процессорам 80486, Pentium, Pentium III, Pentium 4 и т. д. Вторая директива .model flat, stdcall показывает, в какой среде будет «жить» программа. В нашем случае это операционная система семейства Windows 95 (в дальнейшем будем говорить просто Windows). Две следующие директивы тесно связаны с вызовом системной процедуры ExitProcess: 1ncludel1b \myasm\l1b\kernel32.11b ExitProcess proto :DWORD Строка 1ncludel1b \myasm\11b\kernel32.11b подключает к ассемблерному тексту файл библиотеки kernel32.lib, содержащий сведения о процедурах операционной системы, необходимые для их правиль- ного вызова. Есть там и описание процедуры ExitProcess. Символы \myasm\lib\ указывают компилятору путь к файлу библиотеки, их смысл мы поймем в сле- дующем разделе. Директива proto, стоящая в строке ExitProcess proto :DWORD кратко описывает параметры процедуры ExitProcess. Ассемблер переведет программу на язык процессора, когда параметры, описан- ные директивой proto, соответствуют описанию процедуры в библиотеке, а так- же параметрам, указанным при вызове процедуры директивой invoke. Подроб- нее о параметрах процедур и способах их описания рассказано в главе 3.
Компилятор 15 Следующая директива .code помечает сами инструкции. Процессор начнет вы- полнять ту, перед которой стоит метка, указанная в самом конце программы ди- рективой end <метка> Эта директива, а также сама метка никак не переводятся в инструкции ассемблера, а лишь помогают получить программу, которую спо- собен выполнить процессор. Без них программа-ассемблер не найдет первую инструкцию программы и просто откажется работать. В нашем случае директива end указывает ассемблеру, что перед первой инструк- цией программы стоит метка start:. Значит, процессор выполнит сначала инст- рукцию mov еах,2, затем перейдет к следующей (у нас это add еах.З) и будет «съе- дать» одну инструкцию за другой, пока они не кончатся. В этот момент операционная система «подхватит» программу и поможет ей правильно завер- шиться, чтобы освободить место другим, ведь Windows — многозадачная опера- ционная система, способная выполнять одновременно несколько программ. Уйти из под опеки операционной системы помогает процедура ExitProcess, вы- зываемая директивой invoke. Компилятор Текст, показанный в листинге 1.1, предназначен вполне определенной програм- ме-ассемблеру — это MASM фирмы Microsoft. MASM чаще всего используется при разработке программ для Windows, и к тому же он совершенно бесплатен. Чтобы писать программы на ассемблере, одной программы-ассемблера мало, ну- жен еще редактор, в котором создаются и меняются тексты программ, а также удобная среда, в которой можно выполнять полученные программы, редактиро- вать их и снова выполнять. Такой средой для нас будет оболочка FAR, которую можно найти среди фай- лов к этой книге*. Установка оболочки очень проста: запускаем программу farl705.exe и указываем папку, где она будет храниться. При первом запуске FAR (для этого нажимается кнопка Пуск и в программной группе Far Manager выбирается значок Far Manager) нужно указать удобный шрифт (я предпочитаю 1018). Во всех шрифтах FAR есть латинские и русские буквы. Для перехода с латинского на русский и обратно используется левая пара клавиш Ctrl+Shift (держа Ctrl нажимаем Shift). Теперь оболочку FAR можно использовать для установки компилятора. Пусть файл myasm.exe, найденный на сайте www.piter.com, находится в папке download на вашем диске С:. Предположим, также, что файлы компилятора будут распо- ложены в папке myasm** на диске С. Если такой папки на диске С нет, ее нужно создать. Для этого нажимаем Alt-Fl и выбираем С в списке логических дисков. *Проще всего это делается так: ищете на сайте мою фамилию, в показанном списке книг выбираете «Самоучитель Ассемблер», и в ее кратком описании ищете раздел «файлы к книге». Там должны быть оболочка FAR, исходные тексты программ и компилятор. "Конечно, папку, где расположен компилятор, можно назвать по-другому. Но тогда нужно будет сменить директивы в исходных текстах программы. Если, скажем, компилятор находится в папке masm32, то вместо includelib \туа5т\должно стоять includelib \masm32\. А если текст программы и компилятор расположены на разных логических дисках, то придется указать полный путь к биб- лиотеке includelib c:\masm32\.
16 Глава 1. Начало Далее нажимаем F7, в появившемся меню вводим название папки myasm и на- жимаем Enter. Папка создана, и теперь нужно в нее перейти. Для этого передви- гаем подсветку клавишами, пока не выберется папка myasm. Еще одно нажатие Enter — и мы внутри. Теперь на панели справа нужно найти папку download дис- ка С. Для этого нажимаем клавиши Alt+F2, выбираем диск С и далее — папку download. Теперь можно скопировать файл myasm.exe из папки c:\download в пап- ку c:\myasm. Для этого подсвечиваем файл mayasm.exe, нажимаем F5 и затем Enter. Далее нужно перейти в папку f:\myasm, подсветить файл myasm.exe и на- жать Enter, после чего в папке myasm окажутся файлы компилятора. Наша среда разработки программ на ассемблере почти готова. Осталось только указать путь к программе-ассемблеру, чтобы можно было запустить ее, находясь в любой папке. Для этого в файле autoexec.bat* необходимо создать переменную path** path=c:\myasm\bin и перезагрузить компьютер, чтобы изменения в файле autoexec.bat «дошли» до операционной системы. После установки компилятора директива includelib из нашей первой прог- раммы (см. листинг 1.1) выглядит гораздо понятней. Теперь сама библиотека kernel32.lib и путь к ней становятся «реальными и познаваемыми». Зайдя в пап- ку myasm, где расположены файлы компилятора, увидим там папку lib, а там — файл kerne!32.lib. Создание программы Теперь мы, наконец, готовы создать первую работающую программу на ассемб- лере. Для этого нужен прежде всего файл, в котором будет храниться исходный текст программы, показанной в листинге 1.1. Чтобы создать такой файл, откро- ем папку, где он будет храниться, нажмем клавиши Shift+F4, введем в появив- шемся окне имя файла (пусть это будет 111.asm)***, наберем текст программы из листинга 1.1, нажмем F10, выберем в появившемся меню пункт Save (сохра- нить) — и в нашей папке возникнет файл lll.asm. Посмотреть его содержимое можно клавишей F3. Для редактирования файла служит клавиша F4. Нам остается создать еще один, так называемый командный файл****, в котором содержатся команды программе-ассемблеру. Выглядит он так, как в листинге 1.2. Листинг 1.2. Командный файл amake.bat ml /с /coff "^l.asm" link /SUBSYSTEM:CONSOLE "n.obj" *B системе Windows XP может не быть файла autoexec.bat. Тогда путь к компилятору можно задать из панели управления: Панель управления ► Система ► Дополнительно ► Переменные среды. Что- бы новые значения переменной path вступили в силу, компьютер нужно перезагрузить. **Если переменная path уже создана для каких-то других программ, можно указать путь к ассембле- ру строчкой ниже: path=<ywe существующие пути> path=^path^:с.\myasm\biп ***Каждый ввод последовательности символов или выбор пункта меню завершается клавишей Enter. ****И командный файл и листинги программ упакованы в архиве sources.zip, который хранится на сай- те http//www.piter.com вместе с оболочкой FAR и компилятором.
Создание программы 17 К описанию команд этого файла мы вернемся чуть позже. А пока поместите его в одну из папок на диске и укажите путь к этой папке в переменной path. Если, к примеру, файл amake.bat помещен в папку util на диске с:, то переменная path, указывающая пути к компилятору и командному файлу, будет такой: path=c:\util; с:\myasm\bi п Как видите, все «пути» (кроме последнего, стоящего правее других) разделяют- ся в переменной path точками с запятой. После того как файл amake.bat создан и отправлен в подходящую папку, остает- ся только перезагрузить компьютер, чтобы все изменения, сделанные в файле autoexec.bat, вошли в силу, перейти туда, где хранится исходный текст програм- мы 111.asm, набрать в командной строке* оболочки FAR: атаке 111 и нажать Enter. ВНИМАНИЕ--------------------------------------------------------------------- Команда атаке 111 использует имя файла без расширения. Расширения .asm и .obj «при- клеиваются» справа от имени 111, когда выполняется командный файл, и в результате по- лучаются команды ml /с /coff lll.asm и link /SUBSYSTEM:CONSOLE 111.obj. «л 1 : \asm test \1 SSSSS 817:13 аепсоэоаосоесс b:\UllL * - —— C п Наше Size Date Time 1 n Наше Size i Date Time 8662ansi < Up > 05.09,04 17:11 < Up >1 05.09.04 17:06 . exe 7231 13.03 94 18:27 ши ш hi acb exe 71849 25.04.97 02:00 add If exe 9672 20.03.97 09:21 атаке bat 54 18.07.03 10:18 ansi2866 exe 7744 13.03.94 19:36 arch2s exe 6944 15.08.93 23:01 archiv exe 16688 22.04.94 23:04 arj exe 116268 28.07.94 12:12 arj zip 76569 03.06,99 11:43 ar iz exe 69225 07.04,95 00:15 ballpset bas 2768 22.09.87 23:05 binhex exe 44015 14.07.98 22:20 binhex txt 3204 14.07.98 22:20 boa exe 105984 05.02.98 04:39 c2f exe 7959 03.12.92 08:57 . I calc com 3914 09.11.91 22:43 : 1 comp430d exe 26793 04.03.90 18:36 amake.bat Lil.ASM ’ 1 bytes in УЗ Tiles IZo bytes in 1 tile —~ С:\asmtest\l> атаке 111^ Qllelp EUser Mnl KView К Edit №( >)РУ 1 ( flRenMouEMkFoldl KDe let eEConf МгмШ Mui t Рис. 1.1. До создания первой программы — одно нажатие Enter Если исходный текст программы набран без ошибок, то в папке, где он хранил- ся, увидим два новых файла: Lll.obj и Lll.exe. Файл с расширением .ехе и есть наша первая программа. А файл Lll.obj — это «полуфабрикат», так называемый объектный файл, из которого получается готовая программа. Все дело в том, что *Командная строка видна в нижней части рис. 1.1.
18 Глава 1. Начало текст больших программ хранится во многих файлах. Чтобы получить готовую программу, тексты на ассемблере сначала преобразуются в объектные файлы (в нашем командном файле это делает команда ml /с /coff "11.asm"), а затем их обрабатывает редактор связей, или компоновщик. В нашем случае редактор свя- зей вызывается командой: link /SUBSYSTEM:CONSOLE "ll.obj" И компоновщик, и программа, выдающая объектный файл (часто ее называют компилятором) управляются ключами — символами, стоящими непосредствен- но за косой чертой. Компилятор в нашем командном файле управляется двумя ключами: /с означает, что создается только объектный файл с расширением .obj, а ключ /coff определяет формат этого файла, стандартный для системы Windows. Компоновщиком управляет один ключ /SUBSYSTEM:CONSOLE, определяющий тип программы. В нашем случае это консольное приложение Windows, то есть про- грамма, использующая для своей работы одно окно, куда она может выводить символы и откуда может эти символы читать. Консольными часто делают про- граммы, управляемые ключами командной строки. Наш компилятор ml и ком- поновщик link — типичные консольные приложения. И вовсе не обязательно управлять консольными приложениями с помощью ключей. Ведь оболочка FAR — тоже консольное приложение Windows, управляемое, в отличие от программ link и ml, разветвленной системой меню (нажмите кнопку F9 — и уви- дите). Первые шаги Описывая различные ключи компилятора и компоновщика, мы забыли о нашей программе Lll.exe, оказавшейся в той же папке, где хранится текст на ассембле- ре lll.asm. Программа давно уже готова к тому, чтобы ее выполнил процессор. Для этого нужно подсветить имя Lll.exe и нажать Enter. При этом голубые пане- ли оболочки FAR на секунду исчезнут, приоткрыв черное пространство, куда выводятся результаты работы программы, и тут же сомкнутся над ним, не дав ничего толком разглядеть. Впрочем, разглядывать особенно нечего, ведь наша программа только совершает действия с регистром процессора еах, и в ней нет никаких команд вывода ин- формации на экран. Приподняв клавишами CTRL+o голубые панели оболочки, увидим лишь командную строку Lll.exe, говорящую о запуске программы — и больше ничего. Но это не значит, что результат ее работы скрыт от нас до тех пор, пока мы не научимся выводить символы на экран. Существует замечательная программа- отладчик OllyDbg, позволяющая увидеть программу изнутри и выполнить ее шаг за шагом. Чтобы отладчик смог «подсмотреть» за программой, ее имя нужно передать ему в качестве параметра. Набрав в командной строке FAR ollydbg Lll.exe и нажав Enter, увидим множество окошек и иконок с непонятными значками (рис. 1.2), а также еще одно черное окно, куда программа должна выводить результаты сво-
Первые шаги 19 ей работы. Но так как наша программа ничего не выводит на кран, окошко мож- но закрыть и сосредоточиться на отладчике. OllyDbq - LI l.exe - [CPU - main thread, module Lil] Ц File View Debug Plugins Options Window Help -Ifflxl Рис. 1.2. Окно отладчика OllyDbg В нем инструкции программы расположены в левом верхнем углу (найдите там строчку mov еах, 2), а регистры процессора показаны в правом окне вверху (най- дите значки ЕАХ). Начать пользоваться отладчиком, несмотря на его устрашающий вид, очень про- сто, потому что нам пока нужна только клавиша F8, выполняющая программу по шагам — инструкцию за инструкцией. Нажав F8, увидим в правом окне, что регистр еах стал равен двум, а в левом окне подсвеченной оказалась уже вторая инструкция процессора add еах, 03. На- жав еще раз F8, увидим, что ЕАХ стал равен 5. Это значит, что успешно выполни- лась вторая команда add еах, 03. Теперь программе осталось только «выполнить- ся до конца» и покинуть операционную систему, для чего достаточно нажать кнопку F9.
Глава 2 Числа 8 + 8 = 10? Наша первая программа, показанная в листинге 1.1, складывает 2 и 3, после чего в регистре еах оказывается число 5. Чтобы проверить этот результат, достаточно пальцев одной руки. Но давайте попробуем сложить два других числа — 8 и 8. Фундаментальные знания, полученные нами в первом классе, говорят, что здесь не хватит пальцев обеих рук. Но если скомпилировать программу, показанную в листинге 2.1, и выполнить ее по шагам с помощью отладчика OllyDbg, то в ре- гистре еах окажется число 10. Листинг 2.1. Сложение 8 и 8 .386 .model flat, stdcall i ncludel 1 b \myasm\1ib\kernel32.1ib ExitProcess proto :DWORD .code start: mov eax, 8 add eax. 8 ;eax = 10???? invoke ExitProcess. 0 end start Результат сложения двух восьмерок показан в листинге правее точки с запя- той — символа, обозначающего начало комментария. Саму точку с запятой и все символы строки, оказавшиеся правее нее, компилятор игнорирует. Ком- ментарий помогает понять программу и предназначен не компилятору, а лю- дям. Но вернемся к результату сложения. Число 10 получилось не потому, что про- цессор ошибся, просто результаты его работы отладчик показывает в другой, шестнадцатеричной системе счисления, понять которую можно, задумавшись над устройством привычной нам десятичной системы.
8 + 8 = 10? 21 Число lOio (будем использовать нижний индекс для указания системы счис- ления, а если индекса нет, будем считать, что число записано в десятичной сис- теме) устроено гораздо мудрее, чем это может показаться. 1О1о — не просто по- следовательность двух символов — единицы и нуля, а краткая запись того, что число представляет собой сумму 1 * 101 + 0 * 10°. Точно так же 436910 — вовсе не картинка, не последовательность четырех символов, а краткая запись суммы 4 * 103 + 3 * 102 + 6 * 101 + 9 * 10° = 4000 + 300 + 60 + 9. То есть, любое число в десятичной системе представляется суммой степеней де- сятки, что позволяет легко обращаться с такими числами: складывать, умно- жать, делить. Заметим, что коэффициенты при степенях десятки меняются от 0 до 9, что понятно: младший разряд числа, равный десяти, перестает быть младшим, это уже второй по значимости разряд и вместо 10*10° следует пи- сать 1 * 101. Говорят, что 10 — основание десятичной системы счисления, потому что все чис- ла представляются в ней суммой степеней 10, а коэффициенты при степенях ме- няются от 0 до 9, то есть максимальный коэффициент на единицу меньше осно- вания системы. Естественно, ничто не мешает выбрать другое основание для системы счисле- ния, например 16. Такая система во всем похожа на десятичную, только числа в ней представлены суммой степеней 16, а не десяти. Так, например, число 1016 ~ это сумма 1 * 161 + 0 * 16° = 1610. То есть, равенство 8 + 8 = 10 справедли- во, если числа представлены в шестнадцатеричной системе. Теперь следует подумать о том, как записывать шестнадцатеричные числа. Для записи числа 1016 хватило обычных арабских цифр. Но для коэффициентов при степенях 16 справедлива та же закономерность, что и в десятичной системе: ми- нимальный коэффициент равен нулю, а максимальный — на единицу меньше основания системы счисления, то есть равен 15. Как, например, представить число 2 * 161 + 15 * 16°? Запись 215 не годится, по- тому что не понятно, где кончается один разряд (то есть коэффициент при сте- пени шестнадцати) и где начинается другой. Ведь число 215, записанное таким образом, может быть равно 2 * 162 + 1 * 161 + 5 * 16°. Внести определенность могли бы скобки, выделяющие каждый разряд: [2][15], но такая запись слишком неудобна при арифметических действиях из-за того, что разряды отличаются размером и числа трудно записать «стол- биком». Вот почему те разряды, для которых не хватает арабских цифр, принято обозна- чать латинскими буквами: Ю10=А; 1110=В; 1210=С; 1310=D: 1410=Е: 1510=F. Это значит, что число 2 * 161 + 15 * 16° записывается в шестнадцатеричной сис- теме как 2F, а число BAD равно И * 162 + 10 * 161 + 13 * 16° = 2989w.
22 Глава 2. Числа Двоящийся мир Из-за ограниченности своих информационных резервуаров мне порой приходится выбрасы- вать отдельные символы, слова, предложения, накладывать тексты друг на друга так, что они претерпевают некоторые изменения, а для непос- вященных теряют свою изначальную ясность. Но таковы законы Процессора, да не отсохнут у него разъемы. С. Расторгуев. Программные методы защиты информации в компьютерах и сетях Ясно, что в шестнадцатеричной системе можно записать любое число, но зачем? Ведь десятичная система удобней и привычней. На этот вопрос можно ответить по-разному. Тот, кто скажет, что «таковы зако- ны отладчика», будет, конечно, прав. Но отладчик поступает так не по прихоти, а потому, что шестнадцатеричные коды оказались самым лучшим посредником между человеком и компьютером. Все дело в том, что процессор — это устройство, результаты работы которого — ряд значений напряжения на электрических контактах. Чтобы показать деся- тичное число, нужно десять градаций напряжения, а этого очень трудно добить- ся. Гораздо надежнее использовать всего две градации: ДА- НЕТ, Есть напря- жение - Нет напряжения, 0- 1. Но это означает, что числа, которыми удобнее всего оперировать процессору, должны быть представлены в двоичной системе! Попробуем и мы, вслед за процессором, научиться оперировать двоичными чис- лами. И прежде всего научимся переводить десятичные числа в двоичные. Сде- лать это довольно просто, если вспомнить, что в двоичной системе число долж- но быть представлено суммой степеней двойки. Так, например, 1610 - 24, следовательно, 1610в1* 24 + 0* 23 + 0* 22 + 0*21 + 0* 20 в 10000. Когда число не равно степени двойки, преобразование становится более сложным, но все-та- ки, легко понять, что 2410 = 16 + 8 = 1* 24 +1* 23 +0* 22 +0* 21 + 0* 2° = = 11000, а 12510 ~ 64 + 32 + 16 + 8 + 4 + 1 = 1 * 26+ 1 * 25 + 1 * 24 + 1 * 23 + 1 * 22 + + 0 * 21 + 1 * 2° = 1111101. Двоичные числа так же хороши, как и десятичные. Скоро мы поймем, что дво- ичная арифметика гораздо проще десятичной. Есть, правда, одно неудобство при работе .с двоичными числами: они слишком громоздки и трудно бывает по- нять, что за число скрывает длинный ряд нулей и единиц. Но оказывается, что любое двоичное число легко превращается в шестнадцате- ричное, чья запись гораздо компактней. Чтобы понять, почему двоичное число легко записывается именно в шестнадцатеричной системе, посмотрим, какие числа можно хранить в четырех двоичных разрядах. Минимальное число равно нулю, а максимальное — 1111, то есть 23 + 22 + 21 + 1 = 8 + 4 + 2 + 1 = 15. Вспомнили? Ведь именно числа от 0 до 15 хранятся в любом разряде шестна- дцатеричного числа! Значит, любые четыре идущих подряд двоичных разряда, или четыре бита, можно представить символами от 0 до F, используемыми при
Конечность 23 записи шестнадцатеричных чисел. То есть двоичное число 11111111 можно за- писать как FF, а число 110000012 — как С1. Подчеркнем, что запись С1 — не просто сокращенное представление двоичного числа 11000001, а настоящее шестнадцатеричное число, равное С116= 12 * 161 + + 1 * 16°= И10000012 = 19310. Чтобы понять, почему так происходит, предста- вим восьмиразрядное двоичное число в общем виде: р7 * 27 + р6 * 26 + р5 * 25 + р4 * 24 + р3 * 23 + р2 * 22 + pj * 21 + р0 * 2°, где р7, р6, р5, р4, Рз, Р2» Pi, Ро “ двоичные разряды, равные нулю или единице. Поделим восемь битов на две равные части, называемые тетрадами. Назовем младшей тетраду, хранящую биты 0-3, а старшей — тетраду, куда попали более «весомые» биты 4-7. Очевидно, старшую тетраду можно представить как р7 * 27 + р6 * 26 + р5 * 25 + р4 * 24 = 24 * (р7 * 23 + р6 * 22 + р5 * 21 + р4 * 2°) = = (р7 * 23 + р6 * 22 + р5 * 21 + р4 * 2°) * 161. Число в скобках меняется от 0 до 15 и получается, что вторая тетрада двоичного числа ничем не отличается от второго разряда числа шестнадцатеричного — раз- ница только в обозначениях. Подобные рассуждения можно повторить и для более длинных двоичных чисел, число разрядов которых кратно четырем. Вывод очевиден: чтобы перевести дво- ичное число в шестнадцатеричное, достаточно обозначить его тетрады симво- лами 0-F, применяемыми для записи шестнадцатеричных чисел. Задача 2.1. Предложите способ автоматического перевода десятичных чисел в двоичные. Подсказка: пусть вас вдохновит пример перевода десятичного числа в деся- тичное, показанный в табл. 2.1: Таблица 2.1. Десятичные разряды числа (в обратном порядке) Действие Частное Остаток 125 / 10 12 5 12/10 1 2 1/10 0 1 Конечность В этом разделе мы узнаем самую, быть может, главную тайну компьютеров: все в них, оказывается, конечно. Конечна память на жестком диске, компакт диске, DVD... Конечны и регистры процессора. О размере регистров можно догадать- ся, посмотрев их содержимое в окне отладчика. Сумма 8 + 8, о которой говорилось в разделе «8 + 8 = 10?», вовсе не равна 1016, как мы до сих пор считали. Отладчик показывает восьмиразрядное число 0000001016, и если вспомнить, что каждый шестнадцатеричный разряд соответ- ствует четырем двоичным, то окажется, что в регистре еах умещается 8 х 4 = 32 двоичных разряда (бита).
24 Глава 2. Числа Чтобы понять, много это или мало, найдем число состояний, в котором может находится 32-разрядный регистр. Очевидно, нулевой (то есть самый младший) бит может быть в двух состояниях. Последовательность нулевого и первого бита имеет уже четыре состояния, потому что на каждое из двух состояний ну- левого бита приходится два состояния бита первого. Легко догадаться, что по- следовательность трех битов имеет 23 = 8 состояний, потому что на каждое из четырех состояний первых двух битов приходится два состояния третьего бита. Закономерность ясна: при добавлении бита число состояний удваивается, сле- довательно, 32 бита могут находиться в 232 = 28 х 28 х 28 х 28 = 256 х 256 х 256 х х 256 = 4 294 967 296 состояниях. Четыре миллиарда двести девяносто четыре миллиона девятьсот шестьдесят семь тысяч двести девяносто шесть — весьма большое число, но это не избавляет нас от вопроса — что будет, если результат какой-то операции, например, сложения не уместится в 32 бита? Очевидно, ничего хорошего. Но, программируя на ассемблере, нужно знать, ко- гда возникает опасность, и уметь отличить «правильную» операцию, результат которой верен, от «неправильной». Для этого (и для многого другого) в процессоре фирмы Intel существует ре- гистр флагов, некоторые биты которого показаны на рис. 2.1. Самый простой флаг — Z. Он поднимается (обращается в единицу), когда результат операции равен нулю, то есть все биты результата — нулевые. 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 О Z с Перенос ------Ноль Рис. 2.1. Флаги переноса и нуля Чтобы понять роль второго флага, попробуем сложить два одинаковых больших числа, равных 4OOOOOOOOOlo. Программа, которая это проделывает, показана в лис- тинге 2.2. Листинг 2.2. Сложение двух больших чисел .386 .model fl at.stdcall 1ncludel1b \myasm\lib\kernel32.1ib ExitProcess proto :DWORD .code start: mov eax. 4000000000 add eax. 4000000000 invoke ExitProcess. 0 end start Прежде всего заметим, что ассемблер, в отличие от отладчика, по умолчанию считает числа десятичными и число 4 000 000 000 в листинге 2.2 — это 4 х 109, то есть, четыре миллиарда. Сумма двух таких чисел равна 8 000 000 000, но мы уже знаем, что в 32-битовом регистре может поместиться число чуть большее
Конечность 25 4 000 000 000. Поэтому будет любопытно скомпилировать программу командой атаке 122, запустить отладчик и посмотреть результат. На рис. 2.2 показано со- стояние программы после выполнения двух первых команд — mov и add. -!□! x| .Ifflxl OllyDbq I22.exe (CPU main thread, module 122] [C] Fite View Debug Plugins Options Window Help Рис. 2.2. Результат сложения двух слишком больших чисел Видно, что оба слагаемых представлены в шестнадцатеричной системе как ЕЕ6В2800, а их сумма равна DCD65000. Кроме того, поднялся флаг переноса, обозначенный буквой С в нижней части правого окна отладчика, а флаг Z, наобо- рот, опустился (обратился в ноль), потому что результат операции сложения — явно ненулевой. Оба наших слагаемых меньше предельного числа, способного уместиться в ре- гистре. Значит, их шестнадцатеричное представление, показанное отладчиком, верно. А вот сумма не может поместиться в 32 бит. И чтобы посмотреть, где «ошибся» процессор, попробуем сложить два числа вручную (рис. 2.3): ЕЕ6В2800 +ЕЕ6В2800 1DCD65000 Рис. 2.3. Сложение шестнадцатеричных чисел Складывать шестнадцатеричные числа труднее, чем десятичные. Но лишь пото- му, что мы не знаем таблицу шестнадцатеричного сложения. Как обычно, начина- ем с младших разрядов, и первые два сложения очевидны, ведь 0 + 0 = 0 в любой системе счисления. Далее идет сложение 8 + 8, что дает в десятичной системе 16. Но 16 — основание шестнадцатеричной системы, поэтому 8 + 8 это «О пишем, один в уме». Этот «один в уме» называется переносом в старший разряд, что сде- лает сумму следующих 2 + 2 равной 5 (2 + 2 + перенос). Теперь нам необходимо сложить В + В. Поскольку таблицы сложения мы не знаем, приходится сообра- жать, что В + В = 2210 = 1610 + 6, то есть «шесть пишем, один в уме». Продолжая в том же духе, получим сумму, показанную на рисунке. Она отличается от суммы, показанной отладчиком, единичкой в самом старшем, 33-м разряде. Складывая столбиком, мы не теряем разрядов, если, конечно, слева остается бумага. Но в ре- гистрах всего 32 бита, поэтому процессор, заметив, что есть перенос из старшего разряда, устанавливает в единицу флаг С. Можно сказать, что бит, не поместив- шийся в регистре, сваливается с левого конца регистра и сохраняется во флаге переноса. Вот почему в нашем примере флаг С оказался поднятым!
26 Глава 2. Числа Задача 2.2. Чему равно число 4 000 000 000, записанное в пятеричной систе- ме счисления? Знак До сих пор мы думали, что складываем положительные числа — просто потому, что не знали никаких других. На самом деле любое число для процессора — все- го лишь последовательность двоичных разрядов — битов. Поэтому в регистре, состоящем из 32 бит, можно закодировать 232 разных чисел, а какими они бу- дут — положительными, отрицательными, целыми или дробями — зависит от договоренности. Попробуем понять, как в компьютере кодируются отрицательные числа, для чего перейдем от настоящих 32-битовых чисел к «игрушечным» 4-битовым. Та- кими числами гораздо проще оперировать, а то, что удалось понять на их при- мере, легко обобщить на любое число двоичных разрядов. Итак, регистр, состоящий из четырех битов, может находиться в 24 = 16 различ- ных состояниях, и логично поделить его пополам: 8 состояний (включая 0) бу- дут «положительными», 8 — «отрицательными». Чтобы отрицательное число сразу можно было отличить от положительного, сделаем старший (крайний ле- вый) бит знаковым; пусть он будет равен нулю для положительных чисел и еди- нице — для отрицательных. С учетом сказанного в четырех битах могут уместиться (вместе с нулем) такие положительные числа: О -> 0000 1 -» 0001 2 -» 0010 3 -» ООП 4 -» 0100 5 -» 0101 6 -» ОНО 7 -» 0111 Отрицательные числа легко получить из положительных, если учесть, что сум- ма положительного числа и такого же по абсолютной величине отрицательного равна нулю. Если просто обратить все биты положительного числа, записав вместо нуля 1, а вместо единицы 0, то все биты суммы окажутся равными еди- нице, например, 0001 + 1110 = 1111 Здесь используется нехитрое правило сложения двоичных разрядов 1 + 0 = 0 + + 1 = 1. А теперь представим себе, что к сумме, состоящей из единиц, добавляется еще одна единица. Согласно правилам двоичной арифметики, 1 + 1 = «ноль пишем, один в уме». Ведь 1 + 1 — это два — основание двоичной системы счисления, поэтому в младшем разряде пишется 0, а единица переносится в старший раз- ряд (в десятичной системе этому соответствует сумма 5 + 5, которая тоже равна «ноль пишем, один в уме»).
Знак 27 Это значит, что единичные биты от прибавления еще одной единицы «повалят- ся», превратятся в нули, и результатом сложения будут единица, вышедшая за пределы регистра: - 1 + 1 = 1111 + 0001 = 10000 и четыре нуля, что и требуется. Итак, согласно правилу обращения битов и до- бавления единицы, отрицательные числа будут кодироваться так: - 1 -> 1111 - 2 -> 1110 - 3 -> 1101 - 4 -> 1100 - 5 -> 1011 - 6 -> 1010 - 7 -> 1001 Теперь у нас есть коды 15 чисел (0, 7 положительных и 7 отрицательных). Всего в 4 бит умещается 16 чисел, поэтому прибавим к ним код для -8. Его нельзя по- лучить обращением битов, потому что нет соответствующего положительного числа. Но можно воспользоваться тем, что сумма положительного числа и соот- ветствующего отрицательного для регистра из четырех битов всегда равна 10000 или, в десятичной записи, — 16. Чтобы, например, получить двоичный код для -7, необходимо представить в виде двоичного числа разность 16 - 7 = 910 = 10012. Проверка показывает, что 7 + (-7) - 0111 + 1001 = 10000 = 1610, что и требуется. Проделав то же самое с восьмеркой, найдем двоичное представление для -8: - 8 = 16-8 = 8 = 1000 Полученный таким образом код называется дополнительным, потому что отри- цательное число получается в нем дополнением положительного до 16*. Извест- ны и другие коды для отрицательных чисел, но дополнительный используется чаще других благодаря своим замечательным свойствам. Во-первых, в нем существует только один ноль, ведь -0 = 1111 + 1 = 10000 = О, потому что старший единичный бит не умещается в 4-битовом регистре и про- падает. Во-вторых, знак числа можно менять бесконечное число раз без каких-либо из- менений и потерь. Чтобы поменять знак числа -5, нужно обратить** все биты числа 1011 и прибавить единицу: 0100 + 1 = 0101 = 510. Затем можно еще раз получить -5, снова обратив биты и прибавив единицу, — и так до бесконечности. Наконец, в третьих, дополнительный код позволяет свести вычитание к сложе- нию. Чтобы, например, вычесть из пяти три, достаточно записать в регистр 3, затем обратить (инвертировать) все биты и прибавить единицу, после чего в ре- гистре окажется число 3 в дополнительном коде, и затем уже прибавить пять. Полученная сумма будет равна двум, потому что пятерку можно представить сум- мой двойки и тройки. Но сумма обычной тройки и тройки в дополнительном коде равна, как мы уже поняли, нулю. Следовательно, 5 + 3 (в дополнительном ’Дополнение до шестнадцати справедливо только для четырехбитовых регистров. Для восьми-, ше- стнадцати-, тридцатидвухбитовых регистров числа будут иными, но принцип сохранится.- "Обращение битов, то есть запись 1 вместо 0 и нуля вместо 1, часто называют инвертированием.
28 Глава 2. Числа коде) = 2. Вычитание путем прибавления числа в дополнительном коде удобно процессору, потому что инвертирование и сложение битов для него очень просты. Переполнение Положительные и отрицательные числа, с которыми мы познакомились в пре- дыдущем разделе, рвутся за пределы четырех битов, отчего многие результаты действий с ними оказываются неверными. Ясно, что в 32, 16 и даже 8 бит места гораздо больше, но и там нужно уметь оп- ределять, когда результат операции верен, а когда нет. Попробуем узнать грани- цы дозволенного для 4 бит, с надеждой применить полученные знания к «реаль- ным» числам, обитающим не в тесных 4-битовых клетках, а в просторных, но все же конечных 32-битовых вольерах. Прежде всего заметим, что процессор ничего не знает ни о положительных, ни об отрицательных числах. Он способен лишь тупо складывать двоичные разря- ды по правилу: 0 + 0 = 0, 0+1 = 1+ 0=1,1 + 1=«0 пишем, один в уме». Что такое 11112 — положительное число 15 или же -1 в дополнительном коде, — знает только программист. Поэтому «вместимость» любого числа битов зависит от типов слагаемых и ожи- даемого результата. Если мы считаем, что в 4 бит хранятся только положитель- ные числа, то ясно, что результат их сложения будет верным, пока сумма не превысит 15 (11112). Например, 7 (01112) и 5 (01012) дадут в сумме 12 (11002) - число, легко умещающееся в 4 бит. Но если сложить, скажем, две восьмерки, то случится переполнение, то есть сумма 16 не уместится в 4 бит, и программист увидит вместо шестнадцати четыре нуля и поднятый флаг переноса С. Задача 2.3. Можно ли восстановить правильный результат сложения чисел без знака в случае переполнения? Если же считать, что складываются числа со знаком, то флаг С уже не будет признаком переполнения, потому что он поднимется при суммировании любых отрицательных чисел. Например, суммирование -5 (10112) и -2 (11102) не при- водит к переполнению, но флаг С при этом все равно будет поднят из-за перено- са, возникшего при сложении двух знаковых битов. Поэтому процессор иначе определяет переполнение при сложении чисел со зна- ком и поднимает в этом случае совсем другой флаг 0 (от слова overflow, пере- полнение), показанный на рис. 2.4. Чтобы лучше освоиться с двоичной арифметикой, попробуем догадаться, как процессор узнает о переполнении, возникшем при сложении чисел со знаком. Для нас, людей, переполнение при сложении 4-битовых чисел возникает, когда результат меньше -8 или больше 7*. *Такое, кстати говоря, возможно только при сложении чисел с одинаковыми знаками. Ведь при сло- жении положительных и отрицательных чисел абсолютная величина результата только уменьша- ется и выхода за пределы отведенных ему битов не происходит. Например, при сложении 5 и -3 получится 2 — число, заведомо меньшее 5. И раз уж в регистре уместилась пятерка, то поместится и двойка.
Переполнение 29 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 О 0 S Z с ------Знак ---------Переполнение Рис. 2.4. Флаги переполнения и знака Но процессор оперирует только двоичными разрядами, и ему недосуг перево- дить числа из двоичной системы в десятичную. Поэтому он может использовать только отдельные биты слагаемых и результата. Чтобы догадаться, как он это делает, посмотрим на «предельно допустимую» сумму двух положительных чи- сел — 7 (01112). Если к этой сумме прибавить единицу, получится число 10002, в котором «испорчен» знаковый бит: сумма положительных чисел (4 + 4, 3 + 5, 2 + 6, 1 + 7) равна 8, но если эти числа хранятся в четырех битах, их сумма ока- жется равной -8 (10002): слагаемые положительны, а сумма отрицательна. Это отличие знака суммы от знака слагаемых, очевидно, сохранится и для дру- гих неверных сложений положительных чисел, потому что их сумма будет коле- баться от 10002 (4 + 4, 3 + 5, 2 + 6, 1 + 7) до 11102 (7 + 7). И всюду знаковый бит будет равен единице, а слагаемые положительны. Теперь посмотрим, что произойдет при сложении отрицательных чисел. Здесь последняя верная сумма будет равна -8 (10002). Если сложить числа, чья сумма равна -9, например -2 (11102) и -7 (10012), то получится 0111 — положитель- ное число 7. И здесь, как видите, знак суммы отличен от знака слагаемых. Эта закономерность сохранится и для других отрицательных слагаемых, только их сумма будет уменьшаться (проверьте это!), а не увеличиваться, как при сложе- нии положительных чисел со знаком. Последнее значение суммы получится при сложении максимально возможных отрицательных чисел -8 + -8 = 10002 + + 10002 = 00 002, и всюду знак суммы будет не таким, как у слагаемых. Проверить отличие знака суммы от знаков слагаемых процессору довольно лег- ко. Для этого он должен сравнить знаковые биты чисел, участвующих в сложе- нии и знаковый бит результата S (от слова sign, знак), который хранится в реги- стре флагов на седьмой позиции (рис. 2.4). Этот флаг поднимается, когда результат операции отрицателен, и опускается, когда тот положителен*. Заметим, что условие переполнения (отличие знака суммы от знака слагаемых) не связано с размером регистров и потому применимо к любому числу битов: 8, 16, 32. Задача 2.4. Докажите, что при сложении чисел со знаком переполнение воз- никает, лишь когда есть перенос из старшего бита, но нет переноса в стар- ший бит, либо наоборот — когда есть перенос в старший бит, но нет переноса из старшего бита. ’Поскольку в дополнительном коде знак определяется старшим битом, флаг S как раз и равен стар- шему биту результата.
30 Глава 2. Числа Задача 2.5**. Как восстановить правильный результат сложения чисел со знаком в случае переполнения? В заключение вернемся к четырехбайтовым числам ЕЕ6В28001б, сложением ко- торых мы занимались в разделе «Конечность». Их, очевидно, можно рассматри- вать как большие положительные числа 4OOOOOOOOOlo — и тогда результат сло- жения неверен, потому что не может уместиться в 32 бит. Об этом говорит флаг переноса С, равный 1. Но теми же битами записывается в дополнительном коде число -29496729610, и тогда результат сложения DCD65000 (см. рис. 2.3) верен, равен -58993459210 и легко умещается в 32 бит, о чем и скажет опущенный (установленный в ноль) флаг 0. Байты и слова К сожалению, 4-битовых регистров, столь удобных для изучения двоичной ариф- метики, не бывает. Минимальное число доступных процессору битов равно вось- ми. Эти восемь битов называют байтом и делят на две равные части — тетрады, в каждой из которых четыре бита. Состояние каждой такой четверки удобно за- давать шестнадцатеричным кодом. Например, байт, в котором все двоичные раз- ряды равны единице, задается символами FF. До сих пор мы считали регистры процессора монолитными. На самом же деле четыре регистра: еах (уже известный нам), а также ebx, есх и edx можно поделить пополам, а одну из половин — еще раз пополам. В результате получится, что в каждом из этих четырех регистров окажется доступным малый шестнадцати- битовый регистр, часто называемый словом, а в нем — два байта (рис. 2.5) 31 23 7 0 16-BIT 32-BIT AL AX EAX DL DX EDX CL CX ECX BL BX EBX Рис. 2.5. В регистрах поселились байты и слова Так, например, в регистре edx доступно 16-битовое слово dx, а в нем — два бай- та — dh (старший) и dl (младший). Как показывает листинг 2.3, с этим словом и байтами можно обращаться так же, как и с целым регистром. Листинг 2.3. Пример работы с 8- и 16-битовыми регистрами .386 .model flat.stdcall 1ncludel1b \myasm\l1b\kernel32.11b ExitProcess proto :DWORD .code start: mov al. -120 :al = 88h mov bl. -127 :bl = 81h add al. bl :al = 09h. 0 = 1. C = 1. S = 0
Числа 31 ;то же сложение, :но в регистрах АХ.ВХ mov ах, -120 ;а! = 8816 mov bh, 255 : bx = ff81i6 = -127 add ax, bx :ax = ff09i6 = -247 0 = 0, S = 1 Invoke ExitProcess, 0 end start Программа из листинга 2.3, начинается с попытки сложить два числа -120 и -127, хранимых в байтах al и bl. Чтобы понять, имеет ли смысл такая операция, выяс- ним, какие числа умещаются в восьми битах. Очевидно, байт, или 8 бит, спосо- бен быть в 28 = 256 различных состояниях. По аналогии с 4-битовыми числами, половину этих состояний займут отрицательные числа, а половину — положи- тельные числа и ноль. Значит, байт хранит числа от -128 до 127, и сумма -120 + + -127 никак в нем не уместится. Поэтому операция add al, Ы вызовет перепол- нение, и флаг 0 станет равен Г. Чтобы сложить числа -120 и -127, воспользуемся 16-битовыми регистрами ах и Ьх. Содержимое al у нас уже испорчено операцией сложения, поэтому помес- тим в регистр ах число -120 командой mov ах, -120. Содержимое Ы не пострадало. Поэтому попробуем сообразить, чему должен быть равен байт bh, чтобы содержимое регистра Ьх стало равно -127. Тут нужно вспомнить, что дополнительный код, в котором записываются отри- цательные числа, зависит от числа битов в регистре. Для 4-битового регистра отрицательное число получается дополнением до 16, то есть до 24 — числа раз- личных состояний, в которых может находиться последовательность из 4 бит. Для 8-битового регистра необходимо уже дополнение до 28 = 256. То есть, код для числа -127, хранимого в байте, получится вычитанием из 256 числа 127: 256 - 127 = 12910 =100000012 ; -127 в доп. коде (для 8 бит) А чтобы получить дополнительный код для числа -127, хранящегося в двухбай- товом. регистре, нужно вычесть число 127 уже из 216 = 256 х 256 = 65 536. Но в на- шем регистре Ы уже хранится отрицательное число, полученное дополнением до 256, то есть разность 256-127. Значит, к числу из регистра Ы нужно прибавить 65 536 - 256 = 65 280, чтобы получить дополнительный код для числа -127, хранящегося уже в двухбайтовом регистре! Это сложение очень просто сделать, потому что 65 280 равно в шестнадцатеричном представлении FF00. Значит, все разряды старшего байта нужно просто приравнять единице! Вот зачем нужна инструкция mov bh, 255 (255 = FF16) в листинге 2.3. Итак, для переноса отрицательного числа в более просторный регистр нужно все биты, стоящие левее знакового, сделать равными единице. Такая операция называ- ется расширением знака. Очевидно, для переноса положительного числа нужно все старшие биты приравнять нулю. Пусть, например, в байте al хранится число 100. Приравняв нулю байт ah, мы добьемся того, что число 100 займет 16-бито- вый регистр ах. Если же в al хранится -100 в дополнительном коде, то приравня- ем ah = ff16 и число -100 «переселится» в более просторный регистр ах. *Важно понимать, что байты al, ah и т. д. выступают в арифметических операциях как самостоятельные регистры. Казалось бы, al и ah — только части регистра ах, а тот в свою очередь - лишь часть еах. Но перенос из старшего разряда al не попадает в ah! Он только поднимает (устанавливает в 1) флаг С.
32 Глава 2. Числа Завершим этот раздел двумя важными замечаниями. Первое касается перевода из одной системы счисления в другую. Для этого проще всего использовать программу «Калькулятор» системы Windows. Выбрав в меню Вид пункт Инже- нерный, увидим на экране примерно то же, что и на рис. 2.6. Рис. 2.6. Перевод числа из десятичной (dec) системы Далее следует указать систему счисления (на рисунке выбрана десятичная сис- тема), ввести число и затем выбрать мышью другую систему, в которую хочется перевести заданное число. Калькулятор знает о двоичной (Bin), шестнадцатерич- ной (Hex) и восьмеричной (Oct) системах счисления. Второе замечание касается записи чисел в программах на ассемблере. Оказыва- ется, можно использовать не только десятичные, как мы это делали до сих пор, но и шестнадцатеричные и двоичные числа. Например, инструкция mov bh,255 может быть записана как mov bh, Offh, где буквой h помечается шестнадцатерич- ное число, или как mov bh, 11111111b, где буква b обозначает двоичное число. Все числа, независимо от системы счисления должны в программах на ассемблере начинаться с цифры, поэтому перед ff стоит ноль. И, наконец, десятичные чис- ла ничем не лучше других, поэтому их следует помечать буквой d: mov ah, 255d. До .сих пор мы этого не делали, потому что ассемблер по умолчанию считает числа десятичными. Но можно изменить предпочтения ассемблера директивой .radix. Если в начале программы стоит: .radix 16 .386 .model fl at,stdcall то ассемблер будет считать все числа без «опознавательных знаков» шестнадцате- ричными, и тогда буква d станет обязательной для каждого десятичного числа.
Глава 3 Память Адреса Программы, выполняемые процессором, находятся не в воздухе и даже не в са- мом процессоре, а в оперативной памяти компьютера. Процессор забирает из памяти очередную команду, выполняет ее, потом переходит к следующей ко- манде, снова выполняет ее — и так до конца программы. Команды процессора могут не только менять содержимое его регистров, но и записывать числа в па- мять компьютера, состоящую из отдельных, идущих друг за другом байтов. Все байты компьютерной памяти пронумерованы. Самому первому присвоен ну- левой номер. Номер последнего байта определяется объемом оперативной памяти, которой располагает компьютер. Номер байта обычно называют адресом. Адреса команд и данных, хранящихся в памяти, всегда видны в окне отладчика, нужно только научиться их замечать. Поможет в этом программа из листинга 3.1. Листинг 3.1. Взаимодействие с памятью компьютера .386 .model fl at,stdcall includelib \myasm\1ib\kernel32.1ib ExitProcess proto .-DWORD .data data_8 BYTE -3 data_16 WORD ? .code start: mov al. data_8 sub ah, ah dec ah mov data_16, ax invoke ExitProcess. 0 end start В программе использована директива .data, указывающая процессору, что сле- дом за ней идут данные — числа, символы, словом, все то, что нельзя считать командами процессора. Ассемблер будет считать данными все, что расположено в исходном тексте программы до директивы .code.
34 Глава 3. Память Как и регистры процессора, данные отличаются размером. Директива BYTE зада- ет байт памяти, директива WORD — слово (два идущих подряд байта), директива DWORD — двойное слово, или четыре байта. Запись data_8 BYTE -3d означает, что в области памяти под именем data_8 хранит- ся байт -3. Запись sum WORD ? выделяет память для двух идущих подряд байтов (слова), знак вопроса показывает, что значение байтов заранее не определено. При запуске программы там может быть все что угодно. Инструкция mov al, data_8 берет из памяти байт, помеченный как data_8, и запи- сывает его содержимое в регистр al. При этом содержимое байта data_8 не стра- дает, он как бы размножается, ведь после выполнения инструкции число -3 оказывается не только в памяти, но и в регистре al. Инструкция sub ah, ah посылает разность ah - ah в регистр ah. Каким бы ни было содержимое ah, там после такой операции окажется 0. Наконец, инструкция dec ah уменьшает содержимое ah на единицу. А поскольку там до этого оказался 0, то в результате получится 0 - 1 = -1, или OFFh в шестнадцатеричном представ- лении. Вместо инструкции dec можно было бы написать sub ah, 1, но такая за- пись длиннее, и программисты гораздо реже ее используют. Инструкция dec, обращающая все биты ah в единицу, расширяет знак числа -3, попавшего в al (см. раздел «Байты и слова» главы 2). После нее число -3 пере- селяется в регистр ах, откуда пересылается инструкцией mov data_16, ах в об- ласть памяти data_16, состоящую из двух идущих подряд байтов. Чтобы «почувствовать» адреса памяти, полезно увидеть результаты работы про- граммы в окне отладчика (рис. 3.1.) Рис. 3.1. Итог работы программы В левом верхнем углу видны адреса памяти, хранящей команды процессора. Первая инструкция располагается в памяти, начиная с адреса 00401000* и зани- мает пять байт. Вторая инструкция sub ah, ah умещается в двух байтах с адреса- ми 00401005, 00401006 и т. д. Область данных, хоть и помещена в листинге 3.1 раньше команд, имеет боль- шие адреса, видимые в левом нижнем окне отладчика. Байт data_8, имеет адрес 00403000 и хранит число -3 или fd в шестнадцатеричном виде. Следующие два байта помечены в листинге словом data_16 и должны хранить FFFD — число -3, *Все адреса записаны в шестнадцатеричном коде.
Стек 35 записанное в дополнительном коде. Но отладчик показывает, что первым идет байт FD (его адрес 00403001)), а следом уже байт FF (его адрес 00403002). Так происходит потому, что в процессорах Intel числа располагаются в памяти по правилу: младший байт имеет меньший адрес. Это важнейшее правило позволяет нам узнать много нового об устройстве ко- манд процессора. Взгляните еще раз на первую команду mov al, data_8, занимаю- щую пять соседних байтов в памяти компьютера: mov al. data_8 АО 00304000 В ней четыре идущих подряд байта 00 30 40 00 — это вывернутый наизнанку ад- рес числа data_8. Читая их справа налево, то есть в обратном порядке, получим 00403000 — адрес, видимый в левом нижнем окне отладчика. Байт с таким адре- сом равен FD, то есть -3. В листинге 3.1 он помечен как data_8. Очевидно, любая команда процессора уже в самом первом своем байте содер- жит информацию о ее типе и длине (в нашем случае это байт АО). Только так можно устранить путаницу и всегда отличать конец одной команды от начала другой. В этом разделе мы пересылали числа только из памяти в регистр. Наверняка, многие сразу захотят использовать команду mov datal, data2: Так не бывает!!! Но ассемблер откажется переводить это вздорное требование на язык процессо- ра, потому что команда mov просто не умеет пересылать данные из памяти в па- мять. Один из участников инструкции mov (часто говорят: один из операндов) должен быть регистром (например mov bx, data2) или самим пересылаемым чис- лом (например mov data_8, 3). Такое ограничение связано с устройством процес- сора и его взаимодействием с памятью компьютера. Знание деталей этого уст- ройства не поможет программисту преодолеть ограничения команды mov. Все равно для пересылки из памяти в память придется использовать промежуточ- ный регистр или какие-то другие инструкции процессора или же специальное место в памяти компьютера, называемое стеком. Стек При сохранении данных в памяти компьютера не обязательно указывать адрес. Специальная команда процессора push помещает слово или двойное слово в об- ласть памяти, называемую стеком, а команда pop читает данные из стека и запи- сывает их в регистр или «обычную» область памяти. С помощью команд push можно «натолкать»* в стек множество чисел, но команда pop вернет оттуда чис- ло, помещенное в стек последним. Следующая команда pop вернет из стека чис- ло, которое втолкнули предпоследним, и если заставить процессор выполнить столько же команд pop, сколько было команд push, то все числа вернутся назад, но в обратном порядке: число, помещенное в стек первым, вернется последним. Иллюстрирует сказанное программа, меняющая содержимое регистров еах и есх (см. листинг 3.2). *Push в переводе с английского и значит «толкать».
36 Глава 3. Память Листинг 3.2. Регистры меняются содержимым через стек .386 .model fl at.stdcall includelib \myasm\1ib\kernel32.11b ExitProcess proto :DWORD .code start: mov eax. 2 :eax = 2 mov ecx. 3 :ecx = 3 push eax push ecx pop eax :eax = 3 pop ecx :ecx = 2 invoke ExitProcess. 0 end start Первая команда push eax посылает в стек содержимое регистра еах. Следующая команда push ecx сохраняет в стеке регистр есх, то есть число 3. Команда pop еах выталкивает из стека число, помещенное туда последней командой push. В на- шем случае это число 3. Значит, после команды pop еах в регистре еах появится число 3 — точно такое же, как в регистре есх. Но это равенство сохранится не- долго. Следующая команда pop есх опустошает стек, выталкивая из него число 2 и помещая его в регистр есх. Выходит, что перед завершением программы есх стал равен двум, а еах — трем. То есть регистры благодаря стеку обменялись со- держимым. Замечательно то, что при таком обмене ничего не нужно знать о внутреннем устройстве стека. Достаточно двух простых правил: 1. Помещенное в стек последним выходит первым; 2. Размер переменных, помещаемых в стек и доставаемых оттуда, должен совпа- дать. Если бы нам вздумалось поменять значения регистров ах и есх командами: push ах push есх ' pop ах pop есх то ничего хорошего из этого бы не вышло, потому что стек не может уместить содержимое 4-байтового регистра в 2-байтовом. Команда pop ах заберет из стека последние два байта и разорвет регистр есх пополам, а команда pop есх объеди- нит половинку регистра есх, которая еще хранится в стеке, с регистром ах и всю эту адскую смесь запишет в регистр есх. Чтобы понять до конца, как работает стек, исследуем с помощью отладчика про- грамму, которая пытается поменять содержимое регистров ах и есх (листинг 3.3). Листинг 3.3. Попытка регистров ах и еах обменяться содержимым .386 .model fl at.stdcall includel1b \myasm\11b\kernel32.1ib ExitProcess proto :DWORD .code start: mov ax. 2211h
Стек 37 mov ecx, 66554433И push ах :esp=esp-2 push ecx :esp=esp-4 pop ax ;esp=esp+2, ax=4433h pop ecx ;esp=esp+4, ecx=22116655h invoke ExitProcess. 0 end start Состояние программы перед исполнением инструкции push ах показано на рис. 3.2. Рис. 3.2. Программа перед первой инструкцией push В правом нижнем окне отладчика, которое мы до сих пор не замечали, видно со- стояние стека. Серой полосой выделена вершина стека, то есть те байты, кото- рые первыми будут забраны из стека командой pop. У вершины стека есть ад- рес, который процессор хранит в регистре esp*. В правом верхнем окне видно, что esp содержит число 0012FFC4“. Это же число выделено серым цветом в пра- вом нижнем окне. Остается только понять, адрес какого байта хранит esp. Если бы речь шла об обычных данных, показываемых в левом нижнем окне от- ладчика, то адрес 0012FFC4 относился бы к байту 77 — первому справа от адреса (см. рис. 3.3). Но в стеке, как мы скоро увидим, все происходит с точностью до наоборот, и адрес вершины относится к самому дальнему байту 69, выделенно- му на рис. 3.3 белым. 0012FFC4 0012FFC8 0012FFCC 0012FFD0 0012FFD4 0012FFD8 0012FFDC 77E7EBS5 0012ВЙ5С 00400000 7FFDF000 F9CB8CF4 0012FFC8 80536Й0Е RETURN to 132.00400 Рис. 3.3. Вершина стека под микроскопом ‘Регистр esp отладчик показывает следом за уже известными нам регистрами eax, ebx, ecx, edx в своем правом верхнем окне. ‘Естественно, у вас отладчик покажет другое значение esp и другое хранящееся на вершине стека число.
38 Глава 3. Память ЕВ ЕВ ЕВ ЕВ ЕВ Е7 77 Е7 Е7 Е7 Е7 77 77 77 77 puch ах push есх pop ах, ах=4433 pop есх. есх=22116655 Как меняется стек после команд push и pop (стрелками отмечены адреса его вер- шины), показано на рис. 3.4. 69 t 11 22 69 t 33 44 55 66 11 22 69 t 55 66 И 22 69 t 69 Рис. 3.4. Состояния стека после команд push и pop Прежде всего заметим, что стек растет в сторону уменьшения адресов. Действи- тельно, после команды push ах в стеке прибавляется два байта, в то время как ад- рес вершины уменьшается на два и становится равным 0012ffc2. Регистр ах, рав- ный 2211, помещается в стеке так, что старший байт 22 имеет старший же адрес — как и положено процессорам Intel (см. раздел «Адреса»). Следующая команда push есх имеет дело с 4-байтовым регистром, поэтому вер- шина стека уменьшается на 4 и становится равной 0012ffbe. Сам же регистр есх выворачивается в стеке наизнанку по правилу: чем старше байт, тем стар- ше адрес. Если команда push уменьшает адрес вершины, то нет ничего удивительного в том, что противоположная команда pop ах увеличивает этот адрес ровно на число доставаемых из стека байтов (в нашем случае их 2). Забираются байты, ближайшие к вершине, в нашем случае это 33 и 44. Поэтому после команды pop ах в регистре ах окажется число 4433 (еще раз вспомним, что адрес стар- шего байта для процессоров Intel всегда больше). Следующая команда pop есх заберет из стека оставшиеся четыре байта, после чего адрес вершины увели- чится на 4 и достигнет того состояния, какое у него было до выполнения про- граммы. При этом в регистре есх, как это видно из рисунка, окажется число 22116655. Очень важно понимать, что команда pop увеличивает адрес вершины стека, но не стирает сами числа. После инструкции pop они по-прежнему лежат в стеке и будут находиться там до следующей команды push, которая их окон- чательно уничтожит. Числа, вытолкнутые из стека, помечены на рис. 3.4 се- рым цветом, показывающим, что они никуда не делись и, пока не было ко- манды push, их еще можно оттуда достать. Как это сделать, обсудим в сле- дующем разделе. А в этом нам осталось подвести итог: программа из листинга 3.3 не справилась со своей задачей. Призванная поменять содержимое регистров, она их безна- дежно перепутала. Зато она устранила путаницу в головах, ясно показав, что та- кое стек и как его правильно использовать.
Косвенная адресация 39 Косвенная адресация В прошлом разделе мы поняли, что данные, извлеченные из стека, сохраняются в памяти. Но как их оттуда достать? Очевидно, команда pop здесь не годится, потому что вершина стека уже «уехала вправо», в сторону увеличения адресов, и теперь с ней связаны совсем другие числа. Раньше (см. раздел «Адреса») мы использовали метки для доступа к данным, но память, занимаемая стеком, лишена меток. Единственный ориентир в ней — его вершина. Поэтому для доступа к числам, уже вытолкнутым из стека, приходит- ся использовать так называемую косвенную адресацию, когда адрес участка па- мяти указывается в одном из регистров процессора. Предположим, что нам нужно прочитать байт, находящийся на вершине стека. Воспользоваться командой pop тут нельзя, потому что вытолкнуть можно толь- ко слово или двойное слово, но никак не байт. Поможет нам указатель стека esp, где как раз и хранится адрес этого байта. Команда процессора, читающая байт, адрес которого записан в регистре esp, выглядит так: mov bl. [esp] Квадратные скобки, окружающие регистр, здесь необходимы, потому что коман- да mov bl, esp означает попытку послать содержимое регистра esp в регистр Ы. Такая команда бессмысленна, и ассемблер не примет ее, выдав сообщение об ошибке, потому что нельзя уместить четыре байта (таков размер регистра esp) в одном. Теперь можно вернуться к задаче, поставленной в начале этого раздела и попы- таться прочитать числа, оставшиеся в стеке после выполнения двух команд pop. Как показывает рис. 3.3, двухбайтовое слово 2211, первоначально хранимое в ре- гистре ах, находится в двух байтах от вершины стека, а число 66554433, поки- нувшее регистр есх — в шести. Чтобы восстановить значения ах и есх, нужны та- кие команды процессора: mov ах. [esp-2] mov есх,[esp-6] Выполняя первую из них, процессор обратится к памяти, адрес которой на 2 мень- ше записанного в регистре esp, возьмет оттуда два байта (их адреса будут равны esp-2 и esp-1) и запишет их в регистр ах. Вторая команда выполнится аналогич- но, только число байтов и адрес будут другими. Программа, «подбирающая» ос- тавленные в стеке числа, показана в листинге 3.4. Листинг 3.4. Пример косвенной адресации .386 .model fl at,stdcall 1ncludelib \myasm\l1b\kernel32.11b ExitProcess proto :DWORD .code start: mov ax, 2211h mov ecx,66554433h mov bl.[esp]: байт с вершины стека push ax push ecx продолжение &
40 Глава 3. Память Листинг 3.4 (продолжение) pop ах pop есх mov ecx,[esp-6]; ecx=66554433h mov ax, [esp-2]; ax=2211h Invoke ExitProcess, 0 end start Заметим, что косвенная адресация не меняет содержимое регистра, хранящего адрес памяти. После выполнения инструкции mov есх. [esp-б] содержимое esp останется прежним. Кроме esp в косвенной адресации могут участвовать и другие регистры: еах, ebx, есх, edx, ebp, esi, edi. Регистры ebp, esi, edi, до сих пор нам незнакомые, можно поделить только на две части. У регистра ebp есть 2-байтовая «половинка» Ьр, но регистр Ьр уже неделим. То же самое относится и к регистрам esi и edi, у кото- рых есть «половинки» si и di, но нет «четвертинок». Есть половинка sp и у ре- гистра esp, но ее содержимое вряд ли интересно, поскольку sp хранит лишь часть адреса вершины стека. Регистр esp стоит особняком в ряду других регист- ров процессора, потому что у него особая роль — следить за вершиной стека. Процедуры Устройство стека, с которым мы только что познакомились, кажется весьма странным, и не очень понятно, зачем нужна память, из которой первым извлека- ется то, что вошло в нее последним. Пример с обменом чисел не очень убеждает, потому что в ассемблере существует специальная команда xchg, которая меняет содержимое регистров, например команда xchg еах. есх делает то же, что и фраг- мент программы: push есх push еах pop есх pop еах но выглядит гораздо яснее и короче. Понять, зачем нужен стек, лучше всего на примере работы процедуры — обособ- ленной части программы, выполняющей какую-то определенную задачу. Процеду- ры нужны, чтобы понизить сложность программы, сделать ее понятной и управ- ляемой. Не нужно особо напрягать воображение, чтобы представить себе, что в програм- ме, состоящей из тысяч строк, есть ошибка. Но поистине кошмарной должна быть фантазия, чтобы представить себе, как искать ошибку в такой программе, если она состоит из «одного куска» и не содержит никаких обособленных частей. Поэтому опытные программисты стараются составить большие программы из отдельных независимых модулей — процедур. И тогда поиск ошибки значитель- но упрощается, потому что появляется возможность сначала определить оши- бочную процедуру, а затем искать ошибку уже в ней, а не во всей программе. Если какая-то процедура становится слишком большой, можно разбить ее на несколько меньших, словом, писать программу так, чтобы сложность состав- ляющих ее частей была постоянной.
Процедуры 41 Простейшая процедура AddDigs, складывающая два числа, показана в листин- ге 3.5. Листинг 3.5. Процедура AddDigs .386 .model flat, stdcall 1ncludel1b \myasm\11b\kernel32.11b ExitProcess proto :DWORD option casemap:none .code start: mov ax, 2 mov bx. 3 call AddDigs invoke ExitProcess, 0 AddDigs proc add ax, bx ret AddDigs endp end start Чтобы в имени процедуры AddDigs различались строчные и прописные буквы, программа использует директиву option casemap:попе, без которой имена ADDDIGS, adDdiGs, AddDigs будут для ассемблера одинаковыми. Сама процедура задается следующим образом: AddDigs proc add ax.bx ret AddDigs endp В ней можно выделить заголовок AddDigs proc, состоящий из имени процедуры и слова ргос, признак конца процедуры, состоящий из имени и слова endp, и «тело» процедуры, то есть выполняемые ею инструкции. Процедура вызывается инструк- цией call <имя> (в нашем случае это call AddDigs). Затем выполняются инструк- ции, составляющие ее тело, а дальше процессор переходит к инструкции, непо- средственно следующей за вызовом call <имя процедуры^ Так получается из-за того, что адрес, куда нужно вернуться после выполнения процедуры (адрес возврата), процессор запоминает в стеке. То есть команда call помещает адрес следующей инструкции в стек, а затем переводит процессор к адресу первой инструкции процедуры. Инструкции, составляющие процедуру, выполняются до тех пор, пока не встретится инструкция ret, которая достает из стека адрес возврата, предъявляет его процессору, и тот как ни в чем не бывало начинает выполнять инструкции с этого адреса. Чтобы всегда знать, чем заняться, процессор имеет специальный регистр eip, со- держащий адрес текущей команды. Команда call запоминает в стеке адрес воз- врата и загружает в eip адрес первой инструкции процедуры. Когда выполняют- ся инструкции процедуры, eip меняется автоматически — в зависимости от самих инструкций. Когда же процессор доходит до команды ret, содержимое стека загружается в eip — и процессор послушно, как слепой за поводырем, сле- дует по указанному адресу.
42 Глава 3. Память Но при чем здесь стек — спросите вы? Ведь адрес возврата можно хранить в лю- бом месте памяти. Команда cal 1 могла бы записывать туда этот адрес, а команда ret — загружать его в eip. И действительно, при вызове одной процедуры стек не нужен. Но представим себе, что одна процедура вызывает другую. В этом случае стек сначала сохранит адрес возврата в основную программу, процессор начнет выполнять процедуру и будет заниматься этим, пока не встретит инст- рукцию call, после чего запомнит в стеке адрес возврата в процедуру и снова перейдет к командам, расположенным уже в другом участке памяти. Наткнув- шись на команду ret, процессор снимет с вершины стека адрес возврата в вы- звавшую процедуру, затем снова наткнется на ret, вернет из стека адрес возвра- та в основную программу и, если не будет других вызовов, там и закончит свою работу. Процесс вызова процедур и возврата из них показан на рис. 3.5. Рис. 3.5. Использование стека при вызове процедур На рисунке стек показан не в виде линейного участка памяти, а в виде стопки, куда складываются адреса. Как и раньше, стек растет в сторону уменьшения адресов. Команда call кладет адрес возврата в стек и уменьшает на 4 регистр esp. Получается, что новый адрес возврата оказывается каждый раз на верши- не стека. Ясно, что вызываемых процедур может быть сколько угодно: Sub2, показанная на рисунке, может вызвать процедуру Sub3, а та в свою очередь Sub4 и т. д. Ко- манды ret, расположенные в каждой процедуре, найдут на вершине стека пра- вильный адрес возврата, и цепочка вызовов неизбежно закончится в основной программе. Хранение адресов возврата в стеке легко совместить с передачей параметров, необходимых для нормальной работы процедуры. Процедуре AddDigs необходи- мы только два параметра: это слагаемые, переданные в регистрах ах и Ьх. Но если параметров десять и более, регистров может не хватить. Поэтому в ассемб- лере параметры часто заталкиваются в стек командами push, а затем только вы- полняется команда call. Оказывается, параметры очень легко в этом случае най- ти в стеке, нужно только знать их число, размер и порядок следования.
Процедуры 43 Чтобы понять, как параметры передаются через стек, изучим программу, пока- занную в листинге 3.6. Листинг 3.6. Передача параметров через стек .386 .model flat.stdcall option casemap:none includel1b \myasm\11b\kernel32.11b ExitProcess proto :DWORD .code start: push DWORD PTR 2 push DWORD PTR 3 call AddDIgs invoke ExitProcess. 0 AddDIgs proc mov eax,[esp+8] : eax=2 add eax,[esp+4] ; eax=5 ret 8 AddDIgs endp end start Команда push DWORD PTR 2 записывает в стек двойное слово (4 байт), содержащее число 2. Ассемблер MASM записал бы двойное слово и по команде push 2, но на- дежнее указывать размер числа явно*. Чтобы правильно написать подпрограмму, необходимо отчетливо представить себе, что находится в стеке после двух команд push и команды call AddDIgs. Оче- видно, в стеке хранятся три 4-байтовых числа, первым (его адрес наибольший**) идет число 00000002, затем число 00000003 и, наконец, на вершине стека нахо- дится адрес возврата (рис. 3.6). 0П04000 03000000 02000000 tesp tesp+4 tesp+8 0040010f*esp 00000003* esP+4 00000002*esp+8 Рис. 3.6. Использование стека для передачи параметров В верхней части рисунка показано, как расположены числа в памяти компьюте- ра — байт за байтом. Наименьший адрес у Of — младшего байта адреса возврата. Этот адрес хранится в регистре esp, потому что адрес возврата находится на вер- шине стека. Вслед за ним в сторону увеличения адреса идут параметры про- цедуры — 8-байтовые числа 00000003 и 00000002. В стеке они, как и любые дру- гие числа, вывернуты наизнанку: у младшего байта меньший адрес. *Такие маленькие числа, как в нашем примере, можно было бы передать стеку и в обычном 2-байто- вом слове. С этим справилась бы команда push WORD PTR 2. "Строго говоря, адрес может быть только у байта. Под адресом числа понимается адрес его младше- го байта.
44 Глава 3. Память Очень часто стек изображают в виде стопки чисел, как это показано в ниж- ней части рис. 3.6. Такой способ не отражает действительного положения бай- тов в памяти, зато он позволяет яснее увидеть расположенные в стеке числа и понять, как добраться до параметров, переданных процедуре. Очевидно, чис- ло 2, помещенное в стек первым, отстоит от вершины на 8 байт, а число 3 — на 4. Поэтому параметры процедуры будут иметь адреса esp+4 и esp+8. Ис- пользуя косвенную адресацию, получим две инструкции, складывающие числа: mov еах.[esp+8] ; еах=2 add еах.[esp+4] ; еах=5 Их результат — число 5 в регистре еах, можно использовать в основной про- грамме, куда мы возвращаемся с помощью инструкции ret 8. Наверное, вы дога- дываетесь, что эта восьмерка связана с переданными процедуре параметрами. И это действительно так. Если бы мы просто написали ret, процессор снял бы с вершины стека адрес возврата, и мы благополучно вернулись бы в основную программу. Но при этом в стеке остался бы «мусор» — два переданных парамет- ра. Чтобы освободить от них стек, инструкция ret 8 берет оттуда адрес возврата и затем увеличивает на 8 указатель стека. В результате esp увеличивается на 12 (на 4 при получении адреса возврата и на 8 после выполнения инструкции ret 8) и приходит в то же состояние, что и до вызова процедуры. Не могу молчать До сих пор у наших программ не было связи с внешним миром. Замкнутые в се- бе, погруженные во тьму и безмолвие, они не могли пи прочитать что-либо с клавиатуры, ни вывести результаты своей работы на экран монитора. О том, что творилось у них внутри, мы узнавали с помощью отладчика. Настало время переселить «душу» программы в «тело» компьютера, чтобы она получила доступ к монитору, клавиатуре, жесткому диску, звуковой плате и т. д. И помогут нам в этом процедуры Windows API, с одной из которых — ExitProcess — мы уже знакомы. Но прежде чем выводить что-то на экран, попробуем сравнить «готовые» процеду- ры операционной системы и наши собственные — такие как AddDigs из раздела «Процедуры». Пока что у тех и других не было почти ничего общего. Одни (вернее, одна) вызывались директивой invoke и требовали подключения библио- теки Jib, другие же получали параметры с помощью инструкций push, подключе- ния библиотеки не требовали и запускались инструкцией call. На самом же деле и те и другие удивительно похожи, и если посмотреть про- грамму в окне отладчика, то окажется, что директиву invoke ассемблер заменяет несколькими командами push с последующей инструкцией call (см., например, рис. 3.2). Поэтому процедуры Windows API можно использовать почти так же, как и наши собственные. Программа, показанная в листинге 3.7, передает пара- метр процедуре ExitProcess инструкцией push, а затем вызывает саму процедуру инструкцией call.
Не могу молчать 45 Листинг 3.7. Программа, которая иначе вызывает процедуру ExitProcess .386 .model f1 at.stdcal1 option casemap:none includel1b \myasm\11b\kernel32.11b ExitProcess proto :DWORD code start: push 0 call ExitProcess end start Как видим, процедуры Windows API отличаются от наших собственных только тем, что требуют подключения соответствующей библиотеки Jib. И это понятно, ведь не мы писали процедуру ExitProcess, поэтому ассемблер должен знать по крайней мере ее адрес, чтобы правильно сформировать инструкцию call. После длинного вступления мы, наконец, готовы создать программу, выводя- щую на экран слова «Не могу молчать!». Ее текст показан в листинге 3.8. Листинг 3.8. Первые слова .386 .model flat, stdcal1 option casemap:none ExitProcess proto .-DWORD GetStdHandle proto :DWORD Wr1teConsoleA proto :DWORD.:DWORD.\ :DWORD.:DWORD.:DWORD 1nc1udel1b \myasm\11b\kernel32.11b .data stdout DWORD ? msg BYTE "He могу молчать!".Odh.Oah cWrltten DWORD ? .code start: invoke GetStdHandle. -11 mov stdout, eax Invoke WrlteConsoleA. stdout. ADDR msg,\ slzeof msg. ADDR cWrltten. 0 Invoke ExitProcess. 0 end start В программе вызываются две новые процедуры: GetStdHandle и WriteConsolеА. Их прототипы приводятся в начале программы. Как видим, размер всех параметров у всех трех процедур одинаков и равен DWORD, то есть «двойному слову» или че- тырем байтам. Прототип процедуры Wri teConsol еА не уместился на одной строке. Чтобы показать, что описание процедуры будет продолжено на следующей строке, используется косая черта \. Та же черта видна и в строке, где вызывает- ся Wri teConsol еА. На этот раз она показывает, что в одной строке не уместился список реальных параметров процедуры*. Процедура GetStdHandle, как можно догадаться по ее названию, получает деск- риптор стандартного устройства — число, которое нужно указывать другим про- * Кроме косой черты, признаком продолжения строки служит и запятая. Можно так построить вы- зов процедуры, что косая черта не понадобится.
46 Глава 3. Память цедурам, взаимодействующим с этим устройством. Ее единственный параметр показывает, какого рода дескриптор нужно получить. Чтобы, например, узнать дескриптор стандартного устройства вывода, куда будет отправлена фраза «Не могу молчать», параметр Должен быть равен -И. Как и многие другие процеду- ры, GetStdHandl е помещает результат своей работы в регистр еах. Поэтому нужна еще одна инструкция mov stdout, еах, чтобы сохранить полученный дескриптор в памяти. Процедура WriteConsoleA, выводящая символы на экран, выглядит гораздо слож- нее, у нее пять параметров, хотя последний, пятый, никакого смысла не имеет и всегда равен нулю. Первые четыре параметра таковы. 1. stdout — это дескриптор стандартного устройства вывода (экрана монитора), полученный процедурой GetStdHandl е. 2. ADDR msg — адрес начала сообщения. Чтобы получить его, используется специ- альный оператор получения адреса ADDR. Как и все в компьютере, сообщения представлены последовательностями чисел. Каждая буква сообщения коди- руется определенным числом. Так, например, прописная русская буква Н ко- дируется* числом 8D16 или 14110. Поскольку букв не так много, достаточно 256 чисел, чтобы закодировать два любых алфавита (например, латинский и кириллицу). Поэтому один символ хранится в одном байте. Кроме букв есть еще невидимые служебные символы перевода строки, пробела, табуля- ции, которые так же умещаются в одном байте. 3. SIZEOF msg — размер сообщения, то есть число байтов в нем. Наше сообще- ние, заключенное в кавычки, состоит из 18 байт (16 байт занимают буквы и два байта — символы Odh, Oah**, которые командуют процедуре WriteConsoleA перевести строку***). Размер сообщения (число байтов от указанной метки msg до следующей CWritten) программа-ассемблер вычисляет во время ком- пиляции. 4. ADDR cWritten — адрес участка памяти, где процедура WriteConsoleA сохранит число выведенных на экран символов. Разбор полетов Предыдущий раздел оказался очень трудным из-за того, что вызов процедур Windows API требует знания многочисленных параметров и операторов языка. И вряд ли он может быть существенно улучшен. Можно только пересказать его другими словами, что мы сейчас и сделаем. Итак, попробуем проследить за программой, показанной в листинге 3.8, с по- мощью отладчика. На рис. 3.7 видны команды процессора и область данных, *Есть несколько соглашений о том, каким числом какую букву кодировать. Такие соглашения на- зываются кодировками. Число 14110 представляет символ ‘Н’ в альтернативной кодировке. Кроме альтернативной, распространена еще и кодировка Windows, в которой буква ‘Н’ представлена чис- лом 20510. "Байты Odh, Oah записаны в шестнадцатеричной системе. В десятичной системе они равны 13 и 10 соответственно. ***Кавычки, обрамляющие фразу, не учитываются и на экране не отображаются.
Разбор полетов 47 созданные ассемблером. Строка invoke GetStdHandle, -11 листинга 3.8 заменяется ассемблером на команду push -0В и вызов процедуры call. Нажав дважды клави- шу F8, увидим в регистре еах число 1210 (0С16). Это и есть дескриптор стандарт- ного устройства вывода. Дескриптор, как и все в компьютере, — всего лишь чис- ло и ничем другим быть не может. Следующая команда mov stdout, еах, показанная отладчиком как MOV DWORD PTR DS:[403000], ЕАХ, посылает содержимое еах в ячейку памяти с адресом 00403000. Слова DWORD PTR говорят о том, что в ячейке четыре байта, квадратные скобки, окружающие число 403000, показывают нам, что это адрес, а значок DS: изо- бражает так называемый сегментный регистр, который в плоской (model flat) модели памяти никакой роли не играет, поэтому программисту нечего о нем думать*. Говоря об адресе ячейки, мы, как всегда, имеем в виду адрес ее младшего байта. Всего в ячейке четыре байта, на рис. 3.7 они видны в самом начале области дан- ных, перед символами «Не могу молчать». После команды MOV DWORD PTR DS:[403000], ЕАХ там окажется число 0С000000, то есть вывернутое наизнанку 0000000С или 1210. йй4й1ййй 66461662 66461667 6646166С 6646166Е 66461613 66461615 6646161А 66461626 66461625 66461627 28666666 66364666 66 16364666 12 64364666 Е8 АЗ 6А 68 6А 68 ________ FF35 66364666 Е8 13666666 6А 66 Е8 66666666 CALL <JMP.kkerne132.GetStdHandle> MOU DWORD PTR DSsC463666],EAX PUSH ‘ PUSH PUSH PUSH PUSH CALL PUSH CALL 6 L39.66463616 12 L39.66463664 DWORD PTR DS:C463666] <JMP.&kerne132.Wr iteConso LeA> 6 < JMP.&k ern e L 32.Eh l tProcess > 00 00 00 001SD AS 20 АС Не м 66463666166 66 66 66|8D A5 26 ACI....U 66463668 AE АЗ E3 26 AC AE AB E7 orу молч ПЛ CO СГ О1:ЙГ| ЙП ЙЙ QQ ♦ 06463016 66463618 А6 Е2 ЕС 21 60 6А 66 66 00 00 00 00 00 00 00 00 Рис. 3.7. Команды процессора и данные в окнах отладчика За вызовом процедуры GetStdHandle в нашей программе идет нечто более зна- чительное, а именно — вызов WriteConsoleA. Ему предшествует заталкивание в стек многочисленных параметров этой процедуры. Причем, заметьте, первым в стек отправляется последний, пятый по счету параметр, то есть ноль. Имен- но такой порядок, задаваемый директивой .model flat, stdcall принят для процедур Windows API. Кроме того, слово «stdcall» в задании модели памяти значит, что процедура должна сама заботиться о восстановлении стека, она должна «убирать за собой», чтобы стек оказался в том же состоянии, что и до вызова. *Сегментные регистры CS, DS, SS, ES, GS, FS участвуют в формировании адреса, но в Windows их правильные значения устанавливает операционная система. В других моделях памяти, о которых мы будем еще говорить, программист должен сам менять сегментные регистры и указывать их в ко- мандах ассемблера.
48 Глава 3. Память Но вернемся к нашим параметрам. Второе число, попавшее в стек перед вызо- вом WriteConsol еА, — это 00403016 (в тексте программы на ассемблере оно запи- сывается как ADDR cWritten; отладчик отображает команду довольно замыслова- то: PUSH L39.00403016, но если ее выполнить клавишей F8, в стеке окажется число 00403016 (обязательно убедитесь в этом). 00403016 — адрес ячейки памяти, куда WriteConsol еА запишет количество показанных на экране символов. Как видно из рис. 3.7, эта ячейка идет следом за символами «Не могу молчать!». Третье число ассемблер получит, применив оператор SIZE0F к метке msg. Как следует из рисунка, число это, равное 1216 или 1810, отправляется в стек коман- дой push 12. Следующий параметр — адрес начала последовательности символов, выводи- мых на экран. В исходном тексте программы он показан как ADDR msg. Ассемблер вычисляет этот адрес во время компиляции программы, а процессор видит пе- ред собой лишь скупую, неумолимую команду: поместить в стек 00403004 (от- ладчик показывает ее как PUSH L39.00403004). Процессор выполняет то, что при- казано, ничего не зная о числе — адрес ли это, переменная или что-то еще. И, наконец, последнее обращение к стеку выглядит в окне отладчика так: PUSH DWORD PTR DS:[403000] По этой команде процессор помещает в стек дескриптор стандартного устройства вывода, хранящийся в четырех байтах памяти начиная с адреса 00403000. В ис- ходном тексте программы он помечен как stdout. Только что команды ассемблера предстали перед нами в неглиже — такими, ка- кими их видят отладчик и процессор. Чтобы не запутаться, взглянем еще раз на исходный текст программы из листинга 3.8, записанный чуть иначе (листинг 3.9). Листинг 3.9. Использование готовых прототипов процедур .386 .model flat, stdcal1 option casemap:none include \myasm\include\windows.1nc include \myasm\include\kernel32.inc 1ncludel1b \myasm\11b\kernel32.1ib .data stdout DWORD ? msg BYTE "He могу молчать!".Odh.Oah cWrltten DWORD ? .code start: invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax invoke WriteConsoleA. stdout. ADDR msg. \ sizeof msg. ADDR cWrltten. NULL Invoke ExitProcess. 0 end start Можно подумать, что в этой программе нет прототипов процедур, на самом же деле все они просто «переселились» в подключаемый файл kernel32.inc*. А ог- ромный файл windows.inc содержит всего одну полезную нашей программе ’Подключаемые файлы .inc и .lib — это часть компилятора. Они, как это видно из листинга, нахо- дятся в папках \myasm\include\ и \myasm\lib\.
Своеволие ассемблера 49 строчку STD OUTPUT HANDLE equ -11, говорящую ассемблеру, что все имена STD_OUTPUT_HANDLE, встреченные в программе, нужно заменить на -И. Такие стро- ки часто применяются в программах на ассемблере, потому что символические имена гораздо понятней, чем просто числа. Своеволие ассемблера В программах этой главы вызывались как процедуры Windows API, так и един- ственная самостоятельно написанная нами процедура AddDigs (листинг 3.6). Правда, AddDigs мы пока не научились использовать так, как стандартные процеду- ры — с прототипом и директивой invoke. Попробуем поэтому привести процедуру AddDigs к общему стандарту и вызвать ее так же, как процедуру Windows. Для этого нужно указать число и размер па- раметров процедуры дважды — в ее прототипе и заголовке. Если ограничиться только прототипом, компилятор откажется работать под предлогом того, что за- головок процедуры и ее прототип не соответствуют друг другу. Программа, использующая преображенную процедуру AddDigs, показана в лис- тинге 3.10. Листинг 3.10. Вызов AddDigs с помощью директивы invoke .386 .model fl at.stdcall option casemap:none includelib \myasm\1ib\kernel32.1ib ExitProcess proto :DWORD AddDigs proto :DWORD. :DWORD .code start: invoke AddDigs.2.3 invoke ExitProcess.O AddDigs proc argl:DWORD.arg2:DWORD mov eax.[esp+8] ; eax=2 add eax.[esp+12] : eax=5 ret 8 AddDigs endp end start Задача 3.1. Чем программа из листинга 3.10 отличается от программы из листинга 3.6? Теперь AddDigs вызывается так же, как и ExitProcess. Но — обратите внимание — ее параметры уже снимаются с других полочек стека. Что-то отодвинуло их от вершины, и теперь первое число (двойка) отстоит от вершины на 8 байт (было 4), а второе — на 12 (было на 8). Что же случилось? Ответ, как обычно, дает отладчик, который обнаруживает в созданной нами процедуре кучу посторонних и на первый взгляд кажущихся непонятными инструкций: PUSH ЕВР :????? MOV EBP.ESP :????? MOV ЕАХ.DWORD PTR SS:[ESP+8]
50 Глава 3. Память ADD EAX,DWORD PTR SS:[ESP+C] LEAVE :????? RETN 8 Но если выяснить, что LEAVE эквивалентна паре инструкций: mov esp, ebp pop ebp то в своеволии ассемблера начинает угадываться какой-то смысл. Заключая ин- струкции процедуры в рамку push ebp mov ebp,esp mov esp.ebp pop ebp, ассемблер сохраняет указатель стека в регистре ebp. Если ebp не меняется внут- ри процедуры, то esp можно восстановить перед выходом из нее. Чтобы при этом сохранить ebp для внешнего мира, его сначала отправляют в стек, а перед возвратом из процедуры снова вынимают оттуда. Вот из-за того, что ebp сохра- няется в стеке, и меняется положение параметров процедуры. Первым в стек от- правляется число 3, затем 2, затем ebp, затем адрес возврата. Значит, ebp отстоит на 4 байта от вершины, число 2 — на 8, а число 3 — на 12. Теперь нам предстоит понять, зачем esp хранится в регистре ebp, когда в нашей процедуре он вообще не меняется? Затем, что ассемблер не знает, меняется ли указатель стека и сохраняет его так на всякий случай, зная, что esp может изме- ниться, когда в стек заталкиваются параметры процедур, вызываемых из дан- ной, а также при выделении места для локальных переменных. Локальные переменные необходимы процедуре только в момент ее выполнения, вот почему для них жалко использовать место в компьютерной памяти, выде- ленное директивой .data. Но если хранить их в стеке, они возникнут при входе в процедуру и исчезнут при выходе из нее. В программе из листинга 3.11 показано, как заводятся локальные переменные в процедуре StrDisp, выводящей на экран строку символов. Листинг 3.11. Использование процедурой локальных переменных .386 .model flat, stdcal1 option casemap:none i nclude \myasm\i nclude\wi ndows.1nc 1nclude \myasm\i nclude\kernel32.1nc 1ncludelib \myasm\1ib\kernel32.11 b StrDisp proto :DWORD, :DWORD .data msg BYTE "He могу молчать!".Odh.Oah .code start: invoke StrDisp, ADDR msg.sizeof msg invoke ExitProcess, 0 StrDisp proc StrAddr:DWORD, StrSz:DWORD :push ebp запомнить ebp :mov ebp,esp сохранить указатель стека sub esp,8 :8 байт для локальных переменных invoke GetStdHandle. STD_OUTPUT_HANDLE
Своеволие ассемблера 51 mov [ebp-4]. еах ;запомним хендл экрана Invoke WriteConsoleA. [ebp-4]. :хендл экрана [ebp+8]. ;адрес начала сообщения [ebp+12], ;размер сообщения ADDR [ebp-8], :адрес числа выведенных символов NULL :mov esp, ebp восстановить указатель стека ;pop ebp восстановить ebp ret 8 ;освободить стек от параметров StrDisp endp end start Процедура StrDisp упрощает вывод символов на экран, ведь ей необходимы все- го два параметра — адрес начала строки' и число символов в ней. Она служит как бы оберткой для GetStdHandlе и WriteConsoleA, скрывая внутри себя такие служебные переменные, как дескриптор экрана и число отображенных симво- лов. Эти переменные разумно сделать локальными, чтобы они жили только в мо- мент выполнения процедуры, а при выходе из нее пропадали. Проще всего раз- местить такие переменные в стеке, уменьшив esp на число занимаемых ими бай- тов. Когда произойдет выход из процедуры, стек придет в первобытное состояние, а локальные переменные просто «смоет волной». В нашем случае пе- ременных две, каждая из них занимает 4 байт, следовательно, из esp нужно вы- честь 8, что и делает инструкция sub esp, 8. Уменьшать указатель стека на суммарный размер локальных переменных нуж- но потому, что иначе эти переменные могут быть уничтожены вызовом из теку- щей процедуры каких-либо еще процедур. Ведь каждый вызов связан с заталки- ванием в стек параметров и адреса возврата, которые, если не уменьшить esp, уничтожат локальные переменные. Но если вычесть из esp суммарный размер локальных переменных, те попадут в «мертвую» зону, куда не проникают ни па- раметры, ни адреса возврата других процедур, вызванных внутри нашей. Ведь все эти параметры и адреса возврата окажутся в стеке выше наших локальных переменных, если представить стек в виде стопки и учесть, что он растет в сто- рону уменьшения адресов, то есть вверх. Раз указатель стека может меняться внутри процедуры, параметры и локальные переменные будут постоянно «плавать» относительно него. Но ebp, куда ассемб- лер своевольно сохранил значение esp, остается при этом постоянным! Значит, относительно ebp и надо отсчитывать положение параметров и локальных пере- менных. Вспомним, что ebp хранит указатель стека непосредственно перед его уменьшением sub esp, 8. Значит, адреса локальных переменных станут меньше ebp, адреса же параметров — больше. Адрес первой локальной переменной будет ebp-4, второй — ebp-8. Расстояние параметров от точки, на которую указывает ebp, будет таким же, как и в процедуре AddDigs из листинга 3.10. Длина выводимой на экран строки заталкивается в стек первой и потому находится дальше всех от точки, на ко- торую указывает ebp. Следом идет адрес выводимой на экран строки, затем ад- рес возврата из процедуры и, наконец, вершину стека занимает сам сохранен- ный ebp. Значит, длина строки имеет адрес ebp+12, a ebp+8 — это адрес ее начала.
52 Глава 3. Память Состояние стека после запуска процедуры и выделения локальных переменных показано на рис. 3.8. Рис. 3.8. Параметры и локальные переменные процедуры Теперь в нашей процедуре StrDisp все должно быть понятно, кроме, быть может, странной передачи параметра ADDR [ebp-8] процедуре WriteConsoleA. Но ничего странного здесь нет. По адресу ebp-8 хранится локальная переменная — число показанных на экране символов. Но если указать процедуре просто [ebp-8], то это будет косвенной адресацией и процедуре передастся число, хранящееся по адресу ebp-8, а не сам адрес! Вот почему нужно писать ADDR [еЬр-8]. Теперь в стек отправится разность ebp-8, что и требуется. Высчитывание адресов локальных переменных и параметров, довольно утоми- тельно и чревато ошибками. Вот почему ассемблер предлагает другой, более удобный способ обращения с локальными переменными и параметрами, пере- данными процедуре. Вместо ручного уменьшения указателя стека можно задать имена локальных переменных директивой LOCAL, а вместо отсчитывания адреса от ebp можно использовать имя параметра, указанное в заголовке процедуры. Процедура StrDisp, написанная с учетом этих нововведений, показана в листин- ге 3.12. Листинг 3.12. Задание локальных переменных директивой LOCAL StrDisp proc StrAddr:DWORD, StrSz:DWORD LOCAL stdout:DWORD, cWritten:DWORD invoke GetStdHandle, STD_OUTPUT_HANDLE mov stdout, eax invoke WriteConsoleA, stdout. StrAddr. StrSz. ADDR cWritten. NULL ret StrDisp endp Такая запись полностью скрывает механизм передачи параметров процедуре. Ничего нельзя понять и о способе выделения памяти для локальных перемен- ных. Даже инструкция возврата записывается как ret, а не ret 8, потому что ас- семблер знает число и размер параметров процедуры и потому не нуждается в подсказке. Но отладчик, конечно, обнаружит все то, что мы уже видели в лис- тинге 3.11: вычитание из esp общего размера локальных переменных, запомина- ние указателя стека, указание адреса относительно ebp и т. д.
Своеволие ассемблера 53 Задача 3.2. Перепишите процедуру AddDigs из листинга 3.10 так, чтобы в ней не было ни малейшего намека на использование стека. Такая, как в листинге 3.12, запись процедуры, полезна, потому что позволяет назвать локальные переменные человеческими именами и не думать о положе- нии параметров в стеке. Но при этом нужно обязательно знать, что на самом деле творится со стеком и со всей программой. Частичное знание противно духу ассемблера. Нужно представлять себе программу до последней инструкции про- цессора. Только тогда удастся хорошо ее написать и успешно отладить. Вот по- чему этот раздел начался с «ручной» передачи параметров и ручного же выделе- ния места для локальных переменных. Своеволие ассемблера и стиль* В предыдущем разделе мы так и не поняли, чем вызвано своеволие ассемблера MASM и можно ли им управлять. Оказывается, ассемблер просто пытается на- писать «пролог» и «эпилог» процедуры, пользуясь информацией, помещенной в ее заголовке. Если, скажем, в заголовке процедуры применить директиву USES, показывающую ассемблеру, какие регистры процедура использует: StrDisp proc USES eax, StrAddr:DWORD. StrSz:DWORD. то ассемблер сделает пролог и эпилог такими: push ebp mov ebp,esp push eax pop eax leave Теперь он позаботится не только о сохранении указателя стека, но и регистра еах. Задача 3.3. Каковы будут координаты двух локальных переменных в про- цедуре StrDisp при сохранении в стеке еще и регистра еах? Что будет, если эти координаты оставить прежними — [ebp-4] и [ebp-8]? Но если заголовок пуст, ассемблеру нечего сказать, и он не вмешивается в дей- ствия программиста. В листинге 3.13 приведен вариант программы, показываю- щей на экране слова «Не могу молчать» (листинг 3.11). Листинг 3.13. Пример процедуры с нестандартными прологом и эпилогом .386 .model flat, stdcal1 option casemap:none • 1nclude \myasm\1nclude\w1ndows.1nc 1nclude \myasm\1nclude\kernel32.1nc 1ncludel1b \myasm\l1b\kernel32.11b StrDisp proto .data msg db "He могу молчать!".Odh.Oah ------------------ продолжение *При первом чтении этот раздел можно пропустить.
54 Глава 3. Память Листинг 3.13 (продолжение) .code start: ;invoke StrDisp, ADDR msg.sizeof msg mov eax. sizeof msg push eax :число отображаемых символов lea eax. msg :адрес нулевого символа push eax call StrDisp :invoke ExitProcess. 0 push 0 call ExitProcess StrDisp proc mov ebp. esp sub esp.8 :место локальных переменных :invoke GetStdHandle. STD OUTPUT HANDLE push STD OUTPUT HANDLE call GetStdHandTe mov [ebp-4]. eax :invoke WriteConsoleA. [ebp-4]. [ebp+4], :[ebp+8], ADDR [ebp-8]. NULL push 0 mov eax. ebp sub eax. 8 push eax :адрес числа отображенных символов push [ebp+8] :число отображаемых символов push [ebp+4] :адрес нулевого символа push [ebp-4] : хендл экрана call WriteConsoleA mov esp. ebp ret 8 освободить стек от параметров StrDisp endp end start В нем текст процедуры заключен в простую рамку: StrDisp proc end start ничего не говорящую ассемблеру о числе и размере передаваемых параметров. Раз так, пролог и эпилог к такой процедуре нужно писать самому. Поскольку регистр ebp не используется в основной программе, незачем сохранять его в стеке и потом извлекать оттуда. Достаточно сохранить указатель стека при входе в процедуру: mov ebp, esp, и восстановить его при выходе: mov esp, ebp. По- скольку в самодельном прологе нет инструкции push ebp, параметры стали ближе к вершине стека. Число отображаемых символов теперь находится по адресу ebp+8, а адрес начала отображаемой последовательности — соответственно на четыре байта ближе к вершине. Положение локальных переменных относительно адреса, храня- щегося в ebp, не изменилось и осталось таким же, как в листинге 3.11. Кроме самодельных пролога и эпилога, в листинге 3.13 применен «натураль- ный» вызов процедур с использованием только инструкций ассемблера. Такие слова, как invoke, ADDR, процессор не понимает, и ассемблер вынужден перевести их на понятный процессору язык. Поэтому программа, написанная с помощью таких директив и операторов, в окне отладчика будет выглядеть совсем не так,
Своеволие ассемблера 55 как в листинге. Всякая директива invoke будет заменена серией инструкций push, загружающих в стек параметры процедуры, за которой следует сам вызов call. При этом нельзя написать: push ADDR msg ;!!!! Неверно потому что такой инструкции ассемблера просто нет. Приходится сначала ис- пользовать инструкцию lea для загрузки в регистр еах адреса нулевого символа мас- сива msg: 1 еа еах, msg и только потом отправить этот адрес в стек инструкцией push еах. В результате листинг программы, использующей «чистый» ассемблер, становит- ся очень длинным и не очень понятным. Но теперь в окне отладчика, понимаю- щего только инструкции процессора, мы увидим то же, что и в листинге. Зна- чит, отладка программы сильно упростится, поскольку нам не нужно будет соображать, во что превратились директива invoke или оператор ADDR. Вот поче- му у «натурального» стиля программирования много сторонников. Но мы в этой книге старались его избегать, потому что листинги программ без директив invoke становятся слишком длинными и непонятными. Когда вы освоите ас- семблер, то сами решите, что лучше: понятный, компактный листинг или же точное соответствие написанной программы тому, что видно в окне отладчика. В заключение приведем еще один вариант программы, чей листинг очень бли- зок к тому, что видят отладчик и процессор (листинг 3.14). От программы из листинга 3.13 она отличается тем, что параметры процедуре StrDisp, не переда- ются через стек, а хранятся в обычной памяти (там же, где и сообщение msg). Листинг 3.14. Передача параметров процедуры без участия стека .386 .model flat, stdcall option casemap:none option prologue:none option epilogue:None 1nclude \myasm\1nclude\windows.1nc 1nclude \myasm\1nclude\kernel32.1nc 1ncludel1b \myasm\11b\kernel32.11b StrDisp proto :DWORD, :DWORD .data msg db "He могу молчать!".Odh.Oah Handle dd ? WrlttenChars dd ? .code start: call StrDisp push 0 call ExitProcess StrDisp proc StrAddr:DWORD, StrSz:DWORD push STD OUTPUT HANDLE call GetStdHandTe mov Handle, еах запомним хендл экрана push NULL lea eax. WrlttenChars push eax mov eax.slzeof msg push eax lea eax, msg push eax продолжение &
56 Глава 3. Память Листинг 3.14 (продолжение) push Handle call WriteConsolеА ret StrDisp endp end start В заголовке процедуры StrDisp из листинга 3.14 перечисляются ее параметры StrAddr и StrSz. Но поскольку теперь они не передаются через стек, а хранятся в обычной памяти, нужно запретить ассемблеру создавать стандартный пролог и эпилог процедуры. Это делают директивы: option prologue:none :не создавать пролог option ep11ogue:none ;не создавать эпилог Локальные переменные Handle и WrittenChars хранятся, как и параметры процеду- ры, в обычной памяти. Значит, перед выходом из процедуры не нужно освобож- дать стек и вместо ret 8 нужна простая инструкция ret. Хранение локальных переменных и параметров в обычной памяти делает их глобальными, доступными всем процедурам, и потому уязвимыми. Теперь лю- бая процедура может записать что-то нехорошее туда, где хранятся данные, ис- пользуемые совсем в другом месте программы. В этом недостаток такого стиля программирования. С другой стороны, хранение переменных и параметров в стеке менее наглядно, вызывает трудности при отладке программы и тоже ведет к ошиб- кам. Я предпочитаю использовать стек, потому что мы все равно вынуждены это делать при вызове стандартных процедур Windows API. Но это не значит, что вам нужно поступить так же. У каждого программиста, а значит, и у вас, может быть свой собственный стиль программирования, а какой — решайте сами.
Глава 4 Как решать задачу Вывод чисел Лучше всего учиться программировать, решая какую-нибудь сложную (для те- кущего уровня знаний и умений) задачу. В данный момент весьма сложной для нас будет задача нахождения первых десяти простых чисел. Напомню, что про- стым называется число, которое делится без остатка только на себя и единицу. Число 7 — простое, а число 4 — нет, потому что, кроме 1 и 4, делится еще на 2. Чтобы справиться с любой сложной задачей, нужно разбить ее на несколько про- стых. Ничто так не отбивает интерес к программированию, как попытка напи- сать сложную программу без подготовки, сразу, одним куском. Мы поступим иначе и будем сначала решать небольшие частные задачи, а потом применим на- копленный опыт в большой программе. И первой нашей задачей будет отображение чисел на экране. Процедура WriteConsoleA, с которой мы познакомились в конце предыдущей главы, не умеет этого делать, потому что создана для вывода на экран последовательностей сим- волов, из которых, к примеру, состоит фраза «Не могу молчать!». Но числа — не символы. Число 1, в зависимости от размера хранящей его ячейки памяти, мо- жет занимать и 1, и 2, и 4 байт. Выглядеть оно (с учетом обратного порядка байтов в памяти) будет как 01 или как 0100 или как 01000000. В то же время сим- вол «1» определяется стандартной кодировкой как байт, в котором хранится число 4910*. Значит, число сначала нужно преобразовать в последовательность символов, а уж потом выводить эту последовательность на экран процедурой WriteConsoleA. Для такого преобразования в системе Windows есть специальная процедура wsprintf. В отличие от многих других процедур, число параметров wsprintf пере- менно и зависит от количества преобразуемых чисел. Но первые параметры все- гда одни и те же: это адрес буфера, где процедура сохраняет число в виде пос- ледовательности символов, адрес форматной строки, указывающей процедуре, *И вообще — любой символ от «О» до «9» получается прибавлением числа 48 к соответсвующей цифре. Например, код симола «3» равен 48 + 3 = 51.
58 Глава 4. Как решать задачу какое выполнить преобразование и, конечно, само преобразуемое число. Про- грамма, выводящая на экран целое число 123456, показана в листинге 4.1. Листинг 4.1. Вывод на экран числа 123456 .386 .model flat, stdcal1 option casemap .-none 1nclude \myasm\1nclude\w1ndows.1nc 1nclude \myasm\include\user32.1 nc 1nclude \myasm\1nclude\kernel32.1nc Includellb \myasm\11b\user32.11b 1ncludel1b \myasm\11b\kernel32.11b BSIZE equ 15 .data If mt BYTE W.O buf BYTE BSIZE dup(?) dig DWORD 123456 stdout DWORD ? cWrltten DWORD ? .code start: Invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax Invoke wsprlntf. ADDR buf. ADDR ifmt. dig Invoke WriteConsoleA. stdout. ADDR buf. \ BSIZE. ADDR cWrltten. NULL Invoke ExitProcess. 0 end start От предыдущих эта программа отличается прежде всего тем, что в ней появи- лись новые подключаемые файлы user32.inc и user32.lib, хранящие информацию о функции wsprlntf. Число ее параметров у нас пока минимально и равно трем. Первый параметр ADRR buf — адрес буфера, куда будет записана последователь- ность символов. Память для буфера выделяется строкой buf BYTE BSIZE dup(?) которая резервирует BSIZE идущих подряд байтов. О том, что выделяется не- сколько байтов памяти, говорит слово dup — сокращенное от английского слова duplication (повторение). Вопросительный знак в скобках после dup говорит ас- семблеру, что значение байтов заранее не определено. Размер буфера обозначен именем BSIZE, а реальное число, которым ассемблер заменит BSIZE, задается строкой BSIZE equ 15. Определяя размер таким способом, мы решаем две важные задачи: во-первых, вводим вместо малопонятного числа 15 осмысленное имя, говорящее о том, что перед нами размер буфера {Buffer SIZE). Во-вторых, до предела упрощаем изменение размера: вместо выискивания всех мест в программе, где он встречается (не все числа 15 могут, к тому же, иметь к этому отношение), достаточно поменять одну строку. Второй параметр функции wsprlntf — ADDR ifmt — это адрес строки формата, за- дающей тип преобразования. Эта строка состоит из символов и всегда заверша- ется нулевым байтом. Строка "£d",0 задает преобразование одного целого числа в последовательность символов. Строка "£d £d",0 задает преобразование двух чисел. Есть много других преобразований, о которых мы поговорим позже. А по- ка стоит скомпилировать программу из листинга и проследить за ее работой
Переходы 59 с помощью отладчика. Здесь нас ожидает сюрприз. Оказывается, после вызова wsprintf ассемблер самовольно добавил инструкцию add esp, 12. Сделал он это потому, что процедура wsprintf сама не знает, сколько у нее параметров*. Зна- чит, восстановление стека должен взять на себя компилятор. Наша программа загружает в стек три параметра процедуры wsprintf, занимающие 12 байт. Что- бы сделать все «как было», нужно убрать из стека эти параметры, что и делает инструкция add esp, 12. Переходы В результате столкновения у аэробуса заклинило руль вы- соты, и теперь неуправляемый самолет обречен подниматься в небо. Пока экипаж делает все возможное, чтобы выровнять лайнер, приближается смертельная отметка, за которой дви- гатели не смогут выдержать страшного давления. Аннотация к фильму «Роковой полет» При отыскании простых чисел многократно повторяются одни и те же дейст- вия: программа подготавливает число для проверки, а затем начинает делить его на все подряд. Число п нужно делить на все числа от 2 до п - 1. Если хотя бы раз остаток от деления равен нулю, число не простое и нужно переходить к сле- дующему. Ну а если все остатки от деления не равны нулю? Тогда число простое и нужно его где-то сохранить. Иными словами, нужна инструкция, которая нарушала бы обычный порядок выполнения программы — от предыдущей инструкции к по- следующей. Эта инструкция, если подумать, только и делает возможными ком- пьютерные вычисления. Без нее программа напоминала бы неуправляемый са- молет, летящий вверх навстречу гибели. К счастью, инструкции, меняющие ход выполнения программы существуют и в ог- ромном количестве — быть может, как раз потому, что суть программирования именно в них. Программа, показанная в листинге 4.2, сообщает, равно или не равно нулю число digit. Листинг 4.2. Равно ли нулю число digit? .386 .model flat, stdcall option casemap:none 1nclude \myasm\1nclude\wi ndows.1nc 1nclude \myasm\1nclude\kernel32.1nc i ncludel1b \myasm\11b\kernel32.1ib .data z BYTE "равно нулю".13.10 zsize DWORD ($-z) nz BYTE "не равно нулю".13.10 nzsize DWORD ($-nz) digit DWORD 0 ___________________ продолжение & *Когда, например, нужно преобразовать сразу два числа, общее число параметров будет равно четы- рем.
60 Глава 4. Как решать задачу Листинг 4.2 (продолжение) stdout DWORD ? cWritten DWORD ? .code start: invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax cmp digit. 0 jnz nzero invoke WriteConsoleA. stdout. ADDR z. \ zsize. ADDR cWritten. NULL jmp exit nzero: invoke WriteConsoleA. stdout. ADDR nz. \ nzsize. ADDR cWritten. NULL exit: invoke ExitProcess. 0 end start Самая важная инструкция этой программы, да и вообще всего языка ассембле- ра — конечно же jnz nzero: если флаг Z (см. раздел «Конечность» главы 2) опу- щен, она приказывает процессору перейти к инструкции с меткой nzero. Если же флаг Z поднят, процессор, как ни в чем не бывало, продолжит работу с ин- струкции, непосредственно следующей за jnz nzero, то есть вызовет процедуру WriteConsoleA, которая покажет на экране сообщение «равно нулю». Инструкция условного перехода jnz работает в паре с инструкцией сравнения cmp digit,0. Смысл инструкции стр в том, что из левого операнда digit как бы вычитается правый операнд 0. При этом флаги устанавливаются так, как будто вычитание произошло, сами же операнды не меняются. Другая важнейшая инструкция, встреченная нами в этой программе, велит про- цессору без каких-либо условий немедленно перейти к указанной метке. Это ин- струкция безусловного перехода jmp. Представим себе, что число digit равно нулю. Тогда инструкция jnz nzero не сработает, процедура WriteConsoleA покажет на эк- ране сообщение «равно нулю», а дальше необходимо обойти второй вызов процеду- ры WriteConsoleA, иначе на экране возникнет и сообщение «не равно нулю». Вот для такого обхода и создана инструкция безусловного перехода jmp, которая направля- ет процессор к выходу из программы, то есть к запуску процедуры ExitProcess. Как видим, комбинация условного и безусловного переходов позволяет организовать разные «ветви» вычислений в зависимости от результата проверки. Прежде чем переходить к следующему разделу, обратим внимание еще на одну особенность программы из листинга 4.2. В ней иначе вычисляется длина сооб- щения. До сих пор мы использовали оператор SIZEOF. Но можно заставить ас- семблер вычислять размер по-другому. Для этого сразу за сообщением объяв- ляется переменная, в которой хранится разница между текущим адресом (он обозначается значком $) и адресом начала сообщения. Например, число zsize, заданное строками: z BYTE "равно нулю".13.10 zsize DWORD ($-z) равно 12, потому что таково расстояние в байтах между метками z и zsize (убе- дитесь в этом сами). Это расстояние ассемблер вычисляет во время компиляции программы.
Повторение 61 Повторение С помощью условных инструкций можно заставить процессор многократно по- вторять одни и те же действия. Для этого достаточно проверять условие, и если оно выполняется, отбрасывать процессор на несколько инструкций назад. При этом в повторяемых инструкциях должно быть нечто нарушающее условие воз- врата, иначе процессор работал бы вечно. В этом разделе мы познакомимся с инструкцией loop <метка>, которая способна в немногих строках программы уместить огромное число команд. Не будь ее, программисту пришлось бы записать самому каждую инструкцию, и ему не хва- тило бы собственной жизни, чтобы описать мгновение из жизни процессора. Действует инструкция loop просто: увидев ее, процессор уменьшает на единицу регистр есх и проверяет, не равен ли он нулю. Если есх равен 0, выполняется следующая после loop инструкция. Если нет — процессор переходит к указан- ной метке. Программа, показанная в листинге 4.3, выводит на экран 10 идущих подряд чи- сел от 1 до 10. Листинг 4.3. Вывод на экран чисел от 1 до 10 .386 .model flat, stdcall option casemap:none 1nclude \myasm\1nclude\wi ndows.1nc include \myasm\1 nclude\user32.inc 1nclude '\myasm\1nclude\kernel32.1nc includelib \myasm\11b\user32.11b 1ncludel1b \myasm\11b\kernel32.1ib BSIZE equ 15 .data if mt BYTE "Ж.О buf BYTE BSIZE dup( crlf BYTE Odh.Oah stdout DWORD ? cWritten DWORD ? .code start: invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax mov edx. 1 mov ecx. 10 nxt: push push Invoke Invoke ecx edx wsprintf. ADDR buf. ADDR Ifmt.edx WriteConsoleA. stdout. ADDR buf. BSIZE. ADDR cWritten. NULL Invoke WriteConsoleA. stdout. ADDR cWritten. NULL pop edx inc edx pop ecx loop nxt invoke ExitProcess. 0 end start ADDR crlf.
62 Глава 4. Как решать задачу Самое главное в ней — пространство от метки nxt до инструкции loop, называе- мое циклом. Внутри цикла помещены инструкции, выводящие на экран числа, хранимые в регистре edx. Сначала процедура wsprintf преобразует число в последовательность символов, затем процедура WriteConsoleA выводит эти символы на экран. Второй вызов WriteConsoleA нужен для перевода строки и возврата к левому краю экрана (этим ведают символы Odh, Oah или в десятичном представлении 13, 10). Перед началом цикла в регистр есх посылается число 10, а в регистре edx оказы- вается единица. Далее оба регистра сохраняются в стеке. Делается это из-за того, что процедуры Windows сами используют эти регистры для своих внутренних нужд, поэтому сказать, что будет с edx или с есх после вызова процедуры, нельзя*. После сохранения регистров на экран выводится текущее число, равное единице при первом обороте цикла. А дальше начинается самое интересное. Сохранен- ные регистры есх и edx достаются из стека, регистр edx увеличивается на едини- цу инструкцией inc edx и становится равным двум. Перед командой loop nxt ре- гистр есх равен 10. Инструкция loop уменьшает есх на единицу и проверяет, равен ли есх нулю. В нашем случае это не так, процессор перейдет к метке nxt и начнется второй оборот цикла. Очевидно, при сх = 10 цикл будет выполнится 10 раз (при значениях есх 10, 9, 8, 7, 6, 5, 4, 3, 2, 1). Когда есх сравняется с нулем, процессор перейдет к инструк- ции, стоящей после loop. В нашем случае это вызов процедуры ExitProcess. Заметим, что цикл, организованный с помощью инструкции loop, выполняется по крайней мере один раз. Начальное значение «счетчика цикла» есх равно при этом 1. Если перед исполнением цикла сделать есх равным нулю, то инструкция loop вычтет из нуля единицу, и в результате получится число Offffffff. А это значит, что вместо нуля цикл выполнится 4 294 967 295 раз! В ассемблере есть, конечно, возможность, сделать цикл, который может не выполняться совсем, но об этом речь впереди. А сейчас сделаем важное наблюдение над регистрами. Если до сих пор мы счи- тали их одинаковыми, то лишь из-за поверхностного знакомства с ними. Если присмотреться, у каждого окажется свое лицо. Например, инструкция loop рабо- тает только с есх. На протяжении всей книги мы будем всматриваться в регист- ры, стараясь изучить их свойства и повадки. Деление Для проверки числа на «простоту» нужно перебрать все возможные делители, отличные от единицы и самого числа, и убедиться в том, что все остатки от де- ления не равны нулю. Это и скажет нам, что нет ни одного деления нацело, а сле- довательно, число простое. В процессоре Intel остатки от деления — побочный продукт самого деления. По- этому задача, которую мы сами себе поставили, требует знания инструкции про- цессора div. * Правда, известно, что процедуры Windows API сохраняют значения регистров ebx, edi, esi и ebp.
Деление 63 В сущности, буквами div обозначаются несколько различных операций деления. Все зависит от типа аргумента инструкции div, то есть, делителя. Если аргументом служит байт, как, например, в инструкции div Ы, то процессор поделит число в регистре ах на Ы и запишет частное от деления в регистр al, а остаток — в ре- гистр ah. Если аргумент команды div — слово (например, div bx), то процессор поделит число, старшие биты которого хранит регистр dx, а младшие — ах. После деле- ния частное окажется в регистре ах, а остаток — в регистре dx. И, наконец, если делитель — двойное слово, как в инструкции div ebx, то про- цессор считает, что делимое хранится в двух двойных словах. Старшие биты де- лимого он возьмет из edx, младшие — из еах, а после деления частное окажется в еах, а остаток — в edx. Какую же из трех инструкций выбрать для нашей задачи? Будем стараться ис- следовать на «простоту» как можно больше чисел, поэтому выберем третью ин- струкцию, но ради той же простоты будем пока хранить делимое только в еах, a edx пусть будет равен нулю. Программа, показанная в листинге 4.4, делит 100 на 3 и показывает частное (ко- нечно, это 33) на экране. Листинг 4.4. Пример деления .386 .model flat, stdcal1 option casemap:none i nclude \myasm\i nclude\wi ndows.inc i nclude \myasm\i nclude\user32.i nc i nclude \myasm\1nclude\kernel32.1nc includellb \myasm\lib\user32.1ib includelib \myasm\lib\kernel32.1ib BSIZE equ 15 .data ifmt BYTE "Xd",00 stdout DWORD ? cWritten DWORD ? .data? buf BYTE BSIZE dup(?) .code start: invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax mov eax. 100 mov edx. 0 :edx:еах - делимое mov ebx. 3 ;ebx - делитель div ebx :eax - частное, edx - остаток invoke wsprintf. ADDR buf. ADDR ifmt.eax invoke WriteConsoleA. stdout. ADDR buf. \ BSIZE. ADDR cWritten. NULL invoke ExitProcess. 0 end start В программе из листинга 4.4 есть, помимо инструкции div, и другое новшество — директива .data? Вопросительный знак после «data» означает, что данные, опи- санные директивой, во-первых, не определены, а, во-вторых, не занимают места в исполняемом файле с расширением .ехе. Представим себе, что BSIZE равен не 15,
64 Глава 4. Как решать задачу а 15 000. Тогда определение буфера в области .data привело бы к многократному увеличению размеров программы, потому что компилятор выделил бы место для буфера прямо в файле с расширением .ехе. Но если буфер объявлен в области .data?, размер файла .ехе не увеличивается. В этом случае необходимая память не- заметно выделяется перед исполнением программы. Массивы Мы почти готовы создать программу, которая ищет простые числа. Осталось только решить, где они будут храниться. Можно, конечно, использовать для этого стек, вычтя из esp подходящее число и тем самым выделив место для локальных переменных (см. раздел «Своеволие ассемблера» главы 3). Но такой способ хра- нения не очень удобен, потому что в области стека невозможно разместить метку. Все в стеке придется отсчитывать относительно ebp, а хотелось бы назвать об- ласть, где хранятся простые числа, человеческим именем, например PrimeNumbers. Будем поэтому хранить найденные числа в обычной памяти — одно за другим. Если каждому числу выделить участок памяти одного размера (например два байта), то получить адрес любого из них будет крайне просто, если, конечно, знать адрес начала области памяти и номер. Номер числа мы будем задавать сами, адрес же начала однозначно определяет метка. Программа, показанная в листинге 4.5, сохраняет 20 чисел (от 1 до 20) в облас- ти памяти, начало которой помечено символами PrimeNumbers. Листинг 4.5. Сохранение чисел от 1 до 20 в памяти .386 .model flat, stdcall option casemap:none i nclude \myasm\i nclude\kernel32.1nc includelib \myasm\lib\kernel32.1ib BSIZE equ 20 .data? PrimeNumbers WORD BSIZE dup(?) .code start: mov ecx.BSIZE mov bx. 1 mov edi. 0 nxt: mov PrimeNumbers[edi].bx inc bx add edi. 2 ;переход к следующему числу loop nxt invoke ExitProcess. 0 end start Директива PrimeNumbers WORD BSIZE dup(?) выделяет область памяти, часто назы- ваемую массивом, для BSIZE идущих подряд двухбайтовых слов. Переписать чис- ло из регистра Ьх в нулевое* слово этой последовательности можно инструкцией ’Нумерацию в массиве будем вести с нуля. Если, скажем, в массиве 10 чисел, то их номера будут 0, 1,2, 3, 4, 5, 6, 7, 8, 9.
Простые числа 65 mov PrimeNumbers.bx. Для доступа к первому, второму и т. д. слову в ассемблере есть специальный способ адресации, примененный в программе из листинга 4.5. Предположим, что регистр edi хранит адрес слова, вычисленный относительно начала массива. Тогда само слово будет выглядеть как PrimeNumbers[edi]. Пусть, например, edi равен 0. Тогда инструкция mov PrimeNumbers[edi],bx записывает со- держимое Ьх в нулевое слово массива. В программе из листинга 4.5 мы записываем числа от 1 до 20 в последователь- ные ячейки массива. Чтобы перейти к следующей ячейке, edi увеличивается на 2, ведь в нашем массиве хранятся 2-байтовые слова. Но чтобы получить доступ, скажем, к пятой ячейке, совсем не обязательно про- ходить через предыдущие четыре. Например, запись числа 19 в пятый элемент нашего массива будет выглядеть так: mov edi. 5 :номер элемента массива add edi. edi :умножаем на 2 mov PrimeNumbers[edi]. 19 В этом фрагменте edi удваивается*, потому что наш массив хранит 2-байтовые сло- ва. Если бы там были двойные слова, пришлось бы научиться умножать edi на 4. Кроме edi, для доступа к элементам массива можно использовать те же регист- ры, что и при косвенной адресации (см. раздел «Косвенная адресация» главы 3), то есть еах, ebx, ecx, edx, ebp, esi. Вообще доступ к элементам массива можно рас- сматривать как расширенную косвенную адресацию, ведь инструкцию mov PrimeNumbers[edi],bx можно, оказывается, переписать как mov [PrimeNumbers+edi],bx. А это, по сути, косвенная адресация, где адрес в квадратных скобках равен сум- ме адреса, связанного с меткой, и относительного адреса (относительно начала массива), хранящегося в регистре edi. Если верна инструкция mov [PrimeNumbers+edi], 19, то должен быть смысл и в ин- струкции mov [PrimeNumbers], 19. Логично предположить, что так записывается число в нулевой элемент массива. Но мы уже знаем, что это можно проделать инструкцией mov PrimeNumbers, 19. Значит, метка в квадратных скобках так же хо- роша, как и метка без них. В любом случае ассемблер будет считать, что это ад- рес компьютерной памяти. Простые числа К этому разделу мы готовились на протяжении всей главы. Но вряд ли програм- ма из листинга 4.6, находящая простые числа, покажется вам такой уж простой. Прежде всего, впечатляет ее размер — и это при том, что программа только вы- числяет простые числа, но уже не в силах вывести их на экран. Листинг 4.6. Вычисление простых чисел .386 .model flat, stdcal1 option casemap:none i nclude \myasm\i nclude\kernel32.i nc __________________ продолжение & ’Инструкция add edi, edi велит процессору сложить edi + edi и результат снова послать в edi. Итог этой операции — edi, умноженный на 2.
66 Глава 4. Как решать задачу Листинг 4.6 (продолжение) 1nclude11b \myasm\11b\kerne132.1ib SSIZE equ 1000 .data? PrimeNumbers DWORD SSIZE dup(?) .code start: mov ebx, 3 :первое проверяемое число = 3 mov edi, 0 :нулевой элемент массива mov ebp, 0 nxtdig: :счетчик простых чисел = 0 mov edx. 0 :готовим число edx:eax mov eax, ebx :к проверке mov ecx, ebx :число проверок меньше sub ecx, 2 проверяемого числа на 2 mov esi. 2 nxtpr: div esi :первый делитель = 2 :делим число edx:eax на esi cmp edx, 0 jz skip :остаток = 0 ? :да - идем к след, проверке mov edx, 0 :нет mov eax, ebx Inc esi .•восстанавливаем edx:eax :и делим на следующее число loop nxtpr :есть на что делить - продолжим mov Pr1meNumbers[ed1], Inc ebp ebx : нет - число простое :увел. счетчик прост, чисел cmp ebp, SSIZE jz done :все простые числа найдены? :да - уходим add edi,4 ;нет - след, элемент массива skip: Inc ebx проверяем jmp nxtdig -.след, число done: Invoke ExitProcess, 0 end start К сожалению, ассемблерные программы очень длинны, во-первых, потому, что любое осмысленное действие требует нескольких инструкций процессора, а во- вторых, из-за того, что в ассемблере мало возможностей уместить в одной стро- ке несколько команд. Одна строка программы обычно состоит из команды (такой как mov), операндов (это регистры и участки памяти) и комментариев, находящихся правее точки с запятой. Комментарии играют важную роль в ассемблере, потому что без них программа не- понятна даже ее создателю. Обычно комментируют каждую строчку. Но если ни- чего путного в голову не приходит, лучше промолчать. Ведь комментарии типа: cmp edx, 0 ; равен edx нулю? по меньшей мере бесполезны, потому что не сообщают программисту ничего но- вого. Нужно комментировать задачу, а не инструкции ассемблера. Поэтому ком- ментарий: cmp edx, 0 : остаток равен нулю? гораздо лучше.
Простые числа 67 Для изучения такой программы, как в листинге 4.6, можно использовать не- сколько стратегий. При этом нужно понимать, что любая стратегия, даже самая глупая, ведет к успеху, если обладаешь бесстрашием и упорством. Первая разумная стратегия состоит в том, чтобы, пользуясь комментариями, пытаться прокрутить программу «всухую», мысленно выполняя каждую инст- рукцию. Очень помогает понять программу отладчик, потому что он страхует от ошибок понимания и на каждом шаге помогает увидеть результат ее работы. После длительного кружения в циклах, совершения переходов и наблюдения за меняющимися участками памяти в мозгу программиста начинает брезжить свет. У него появляется смутная догадка — что же все это значит. Если догадка верна, программист получает «печку», от которой затем и «пляшет». Зная, что делает та или иная инструкция, можно проследить, откуда ей передано управление и куда отправляется процессор после ее выполнения. Постепенно в голове всплывает вся логика работы программы. Вторая стратегия понимания — частный случай первой. Особенно хороша она, если в программе понятные комментарии. Суть стратегии в том, чтобы сразу выделить в программе центральное место и «плясать» уже от него. В нашей программе таким местом можно считать запись очередного простого числа в па- мять: mov PrimeNumbers[edi]. ebx ; нет - число простое Увидев эту инструкцию, следует понять, как пришла к ней программа. Глядя на исходный текст, убеждаемся, что к этой инструкции можно прийти, только пройдя цикл: nxtpr: div esi :делим число edx:eax на esi cmp edx. 0 :остаток = 0 ? jz skip :да - идем к след, проверке mov edx. 0 : нет - mov eax, ebx : восстанавливаем edx:eax inc esi : и делим на следующее число loop nxtpr :есть на что делить - продолжим в котором, очевидно, и проверяется — простое число или нет. Проверка состоит в том, что число, хранящееся в паре регистров edx:еах, делится на esi. Если ос- таток, оказавшийся в edx, равен нулю, число делится нацело, следовательно, оно не простое и дальнейшие проверки бессмысленны. В этом случае процессор по- кидает цикл, переходя к метке skip. Если же остаток не равен нулю, необходима следующая проверка, но регистры edx:еах «испорчены» делением, там нашего числа уже нет. Поэтому нужно снова переписать туда число, сохраняемое в ebx, что и делают инструкции: mov edx. О mov еах. ebx затем увеличить делитель командой inc esi и перейти к следующей проверке (loop nxtpr). Поняв, как работает основной цикл программы, легко догадаться о назначении ин- струкций, его окружающих. Конечно же, они обслуживают этот внутренний цикл, готовят регистры к тому, чтобы он работал правильно, устанавливают начальные
68 Глава 4. Как решать задачу значения и следят, нашла ли программа SSIZE простых чисел (инструкция cmp ebp, SSIZE). Если да, пора заканчивать работу (jz done), если же нет — нужно продолжить. А для этого необходимо перейти к следующему элементу массива (add di,4) и по- лучить новое число для проверки (inc ebx). Задача 4.1. Дополните программу из листинга 4.6 инструкциями вывода простых чисел на экран. Как пишутся программы Татарский вздрогнул. Мысли, туманившие его голову, разлетелись в мгновение ока, и наступи- ла устрашающая ясность. Виктор Пелевин. Generation П Самое «страшное» для программиста — чистый экран монитора. По-своему он совершенен, и первые инструкции обязательно нарушат его гармонию. Зада- ча программиста — создать текст, который смотрелся бы не хуже, чем пустой экран. Начинать следует с общего представления о программе. Нужно прикинуть, сколько в ней будет частей, а потом задуматься о каждой отдельной части. На- пример, программа вычисления простых чисел может состоять из самого вычис- ления и вывода простых чисел на экран. Вычисление, в свою очередь, должно состоять из двух циклов: внешний будет перебирать проверяемые числа, а внутренний — осуществлять саму проверку. Вырисовываются такие контуры программы: nxtdig: nxtpr: loop nxtpr loop nxtdig Во внутреннем цикле должны, очевидно, проходить деление и проверка, равен ли нулю остаток. Если не равен, проверка продолжается, если равен — число не простое и нужно выйти из внутреннего цикла во внешний. С учетом сказанного набросок внутреннего цикла станет таким: nxtpr: div esi cmp edx,О jnz skip inc esi loop nxtpr Для хранения делителя нам пришлось выделить регистр esi, чьи начальные зна- чения должны устанавливаться вне цикла. Присваивание начальных значений внутри цикла — типичная ошибка программистов, и нужно следить, чтобы туда не попало ничего лишнего.
Как пишутся программы 69 Во внутреннем цикле появились первые инструкции, и это сразу порождает но- вые проблемы, что, безусловно, хорошо, ведь теперь нам некогда пугаться — нужно эти проблемы решать. Первым делом научимся восстанавливать регистры edx, еах, которые «портятся» при каждом делении (в edx посылается остаток, а в еах — частное). Придется выделить для хранения числа один из незанятых пока регистров, например, ebx. С учетом сказанного внутренний цикл станет таким: nxtpr: div esi cmp edx. 0 jnz skip mov edx, 0 mov eax. ebx inc esi loop nxtpr Теперь пора подумать о внешнем цикле. Прежде всего, нужно задать правиль- ное значение регистра есх, чтобы внутренний цикл крутился нужное число раз. Это число, очевидно, на 2 меньше проверяемого, потому что деление на единицу и на само число не имеет смысла. Зная, что проверяемое число находится в ре- гистре ebx, вычислим есх: mov ecx, ebx sub есх. 2 . nxtpr: div esi cmp edx. 0 jnz skip mov edx. 0 mov eax. ebx inc esi loop nxtpr Заодно с ecx можно задать и начальное значение проверяемого числа (оно хра- нится в ebx и постоянно переписывается в регистры edx:еах). Начнем проверку с числа 3, потому что двойка заведомо не «проста»: mov ebx. 3 mov edx, О mov eax. ebx mov ecx. ebx sub ecx. 2 nxtpr: jnz skip loop nxtpr Настало время подумать о метке skip, куда программа отправится в случае деле- ния нацело. Эта метка не должна располагаться сразу после выхода из внутрен- него цикла, потому что нормальное его завершение означает, что число простое, то есть, сразу после цикла должны быть инструкции, сохраняющие это число в памяти компьютера (на все простые числа регистров, конечно, не хватит). А за меткой skip должны находиться инструкции, которые готовят новое число для проверки, а также выясняют, все ли простые числа найдены. В зависимости
70 Глава 4. Как решать задачу от этого программа переходит к новой проверке или завершает работу. И тут нас посещает идея: не будем использовать инструкцию loop для организации внешнего цикла, это хлопотно и потребует сохранять в стеке регистр есх, «пор- тящийся» во внутреннем цикле. Вместо этого зададим максимальное количест- во SSIZE и счетчик уже найденных простых чисел (пусть это будет еще не заня- тый регистр ebp). Тогда «костяк» программы станет таким: nxtdiд: nxtpr: jz skip loop nxtpr cmp ebp. SSIZE jz done skip: i nc ebx jmp nxtdig done: : проверяем : след, число Набросок программы почти готов. Нам осталось задать массив, где простые чис- ла будут храниться. Как ни странно, куча времени уходит на выбор его имени. Но это время тратится не напрасно, потому что верное имя делает программу более понятной. К тому же, раздумья помогают запомнить имя и привыкнуть к нему. Я выбрал имя PrimeNumbers, то есть «простые числа» в переводе с англий- ского. Зная имя массива, можно написать инструкции, сохраняющие в нем найденное простое число. Выделим специальный регистр edi для относительных коорди- нат следующего сохраняемого числа. Его начальное значение равно нулю и долж- но увеличиваться на 4 (в массиве хранятся 4-байтовые числа) при каждой запи- си простого числа. С учетом сказанного инструкции записи в массив будут такими: mov PrimeNumbers[edi], ebx add edi. 4 inc ebp cmp ebp. SSIZE jz done skip: Теперь можно написать всю программу целиком: включить нужные файлы ди- рективами include и includellb, задать начальные значения переменных, вызвать завершающую процедуру ExitProcess и т. д. В результате получится примерно то же, что и в листинге 4.6. Но программирование не терпит «примерности». Лю- бая ошибка может оказаться фатальной. Вот почему программу нужно еще раз просмотреть и только после этого «отдать на съедение» компилятору. Здесь нас, как правило, подстерегают неожиданности. Компилятор находит множество неправильных или отсутствующих имен, неверных инструкций и т. д. После исправления ошибок он, наконец, благоволит программе и создает исполняемый файл с расширением .ехе. Но это совсем не значит, что программа
Как пишутся программы 71 работает правильно. Получив неверный результат, мы снова изучаем ее текст и устраняем замеченные ошибки. Огромную помощь в этом оказывает отлад- чик. Частое чтение исходного кода притупляет бдительность. Многое мы пере- стаем замечать. Но отладчик рассеивает иллюзии, и наступает «устрашающая ясность». И вот после очередной переделки приходит счастливое мгновение: программа выдает ожидаемые числа. Но совсем не потому, что она безошибочна. Если ошибки есть в таких программах, как MS Word или Windows, то почему их не должно быть у нас? Говорят, что в любой программе есть хотя бы одна ошибка. Некоторые ошибки очень коварны и обнаруживают себя лишь когда программа устарела и должна быть заменена новой, содержащей кучу других ошибок. Задача 4.2. Найдите хотя бы одну ошибку в программе, вычисляющей про- стые числа (листинг 4.6).
Глава 5 Шире круг Логические инструкции Бросая в воду камешки, смотри на круги, ими обра- зуемые, иначе такое бросание будет пустою забавою. Козьма Прутков. Мысли и афоризмы В предыдущей главе нам удалось написать первую настоящую программу. Эта программа очерчивает узкий круг основных знаний и навыков, расширяя кото- рый, можно выучить весь язык. В этой главе мы будем приобретать новые зна- ния, вспоминая о найденных в предыдущей главе простых числах. И первым делом подумаем о частном случае проверки на «простоту» — иссле- довании на четность. Выяснить, делится ли число на 2, можно эксперименталь- но — поделив его на 2 инструкцией div и сравнив остаток с нулем. Но можно поступить иначе, вспомнив о представлении числа в виде суммы степеней двой- ки (двоичном коде). Ясно, что четность и нечетность определяется самым млад- шим битом, ведь все остальные степени двойки заведомо делятся на 2. Напри- мер, число 5 = 22 +1 = 1* 22 + 0*21+1* 2° нечетно, потому что младший разряд его двоичного представления равен единице, а число 4 четно, потому что этот разряд равен у него нулю. Но как добраться до отдельного бита, если процессор знает только адреса байтов? Здесь помогут специальные логические операции, в которых участвует каждый бит двух операндов. Для проверки на четность лучше всего подойдет логическое И. Результат этой операции равен единице, когда оба бита равны единице. Во всех других случаях получается ноль. На рис. 5.1 показан результат побитового логического И двух байтов — один хранится в регистре al, второй — в ah. Инструкция процессора and, выполняющая логическое И, проделывает это са- мое И над каждой парой битов. Оба младших бита в регистрах ah и al (см. рису- нок) равны единице, следовательно, равен единице и младший бит результата*. Следующий по важности бит регистра ah равен 1, а соответствующий бит в al - нулю. Следовательно, бит результата тоже будет нулевым. Результат логической операции окажется, как обычно, в левом операнде, то есть в регистре ah.
Логические инструкции 73 and ah, al ah 01010011 al 11000001 ah 01000001 Рис. 5.1. Логическое побитовое И (and) Очевидно, логическое И таково, что биты результата окажутся равными нулю, если равны нулю биты одного из операндов. Это свойство логического И легко ис- пользовать для выделения младшего бита числа. Создадим специальное число-мас- ку, у которого отличен от нуля только самый младший бит. Тогда результат будет равен либо единице (когда младший бит исследуемого числа равен единице), либо нулю. При единичном результате число нечетно, при нулевом, понятное дело, четно. Программа, показанная в листинге 5.1, выводит на экран слово Четное или слово Нечетное в зависимости от того, какое число хранится в регистре ah. Листинг 5.1. Проверка на четность .386 .model flat, stdcall option casemap:none 1nclude \myasm\1nclude\w1ndows.1nc 1nclude \myasm\1nclude\kernel32.1nc 1ncludel1b \myasm\11b\kernel32.11b .data EvenDIg BYTE "Четное".13.10 OddDig BYTE "Нечетное".13.10 stdout DWORD ? cWritten DWORD ? .code start: invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax mov ah. 37 and ah. 00000001b : выделяем младший бит cmp ah, 0 :четно? jz evn ;да - идем к evn Invoke WriteConsoleA. stdout. ADDR OddDig.\ slzeof OddDig. ADDR cWritten, NULL jmp exit evn: Invoke WriteConsoleA. stdout. ADDR EvenDIg.\ slzeof EvenDIg. ADDR cWritten, NULL exit: Invoke ExitProcess. 0 end start Самая важная инструкция программы and ah,00000001b выделяет младший бит ре- гистра ah с помощью маски 00000001b, записанной в двоичной системе счисле- ния*. Логическое И регистра ah и этой маски даст результат (записанный в ah), у которого все биты, кроме младшего, заведомо равны нулю, а младший бит — такой *0 том, что число записано в двоичной системе, говорит буква ‘Ь’, см. раздел «Байты и слова» главы 2.
74 Глава 5. Шире круг же, как в регистре ah до операции. Иными словами, единичный результат гово- рит о нечетности числа, нулевой — о четности. Операция and ah, 00000001 не очень удобна, потому что «портит» исследуемое число. Чтобы этого не произошло, используется инструкция test, которая, по- добно инструкции стр, искусно «притворяется», что выполняет логическое И. При этом испытуемое число не меняется — меняются только флаги, причем так, как будто операция И на самом деле произошла. С помощью инструкции test фрагмент нашей программы: and ah. 00000001b :выделяем младший бит cmp ah. 0 ;четно? Перепишется так: test ah. 00000001b Задача 5.1. Напишите программу, которая проверяет, делится ли заданное число на 4. Задача 5.2. Убедитесь в том, что инструкции mov ebx, еах dec ebx and eax, ebx «гасят» крайний правый бит в регистре еах. Кроме логического И процессор способен выполнить две другие логические опе- рации: ИЛИ (инструкция ог), а также исключающее ИЛИ (инструкция хог). Операция ИЛИ гораздо менее требовательна, чем И. Ее результат равен едини- це, когда равен единице хотя бы один из участвующих в ней битов. На рис. 5.2 показаны операнды, уже знакомые нам по примеру с операцией И (см. рис. 5.1). На этот раз с ними совершается побитовое логическое ИЛИ. ah 01010011 or ah, al al 11000001 ah 11010011 Рис. 5.2. Логическое побитовое ИЛИ Операция логического ИЛИ бывает полезна, когда необходимо объединить не- сколько условий, закодированных отдельными битами. Если младший бит од- ного регистра, установленный в единицу, показывает, что число делится на два, а следующий по значимости бит другого регистра говорит о том, что число де- лится на 3, то после операции ИЛИ над обоими регистрами станет ясно, что чис- ло делится и на 3, и на 2. Смысл другой побитовой логической операции хог (исключающего ИЛИ), час- то обозначаемой значком ®, тоже прост: операция хог выделяет различия в ре- гистрах. Там где биты одинаковы, получается ноль, там где различны — едини- ца. Для побитового исключающего ИЛИ справедливы правила 1®1 = 0, 0®0 = 0,
Сдвиги 75 100-1,001-1. На рис. 5.3 показан результат операции побитового исклю- чающего ИЛИ над уже привычными нам операндами. ah 01010011 xor ah, al al 11000001 ah 10010010 Рис. 5.3. Исключающее ИЛИ показывает различия в регистрах Очень часто в программах на ассемблере можно встретить странные инструк- ции хог, с одинаковыми операндами, например хог еах, еах. Легко понять, что таким образом еах просто приравнивается к нулю, ведь биты в одном и том же регистре попарно равны, следовательно, результат операции исключающего ИЛИ над каждой парой также будет нулевым. Программисты используют хог отчасти из пижонства, ведь хог еах, еах смотрится гораздо «круче» тривиально- го mov еах, 0, отчасти из-за того, что инструкция хог еах, еах занимает всего два байта (ЗЗСО), a mov еах, 0 — целых 5 (В800000000). Кроме того, исключающее ИЛИ процессор может выполнить быстрее, а скорость и компактность очень важны для программ на ассемблере. Задача 5.3. Что делает инструкция хог еах, OFFFFFFFFh? Задача 5.4. Что делают инструкции? хог еах,ebx хог еах,ebx Подсказка: рассмотрите четыре комбинации: еах = 1, ebx = 0; еах = 1, ebx = 1; еах = 0, ebx = 0; еах = 0, ebx = 1. Задача 5.5. Что делают инструкции? хог еах,ebx хог ebx,еах хог еах,ebx Сдвиги В предыдущем разделе мы научились понимать, четное ли перед нами число, «погасив» все его биты, кроме самого младшего. Оказывается, выделить млад- ший бит можно и с помощью инструкции shr, которая перемещает старшие биты на указанное число позиций вправо. При этом биты «сваливаются» с правого конца сдвигаемого числа, но последний свалившийся бит не пропадает, а сохра- няется во флаге переноса С. Пусть, например, регистр ah сдвигается на один шаг вправо. Соответствующая инструкция выглядит в этом случае так: shr ah. 1 При этом биты, из которых состоит число, перемещаются следующим образом: нулевой (самый младший) оказывается во флаге переноса, первый переходит на
76 Глава 5. Шире круг место нулевого, второй — на место первого, ... седьмой на место шестого, а на место самого старшего седьмого бита процессор ставит ноль (рис. 5.4) SHR ah. 1 О Рис. 5.4. Сдвиг вправо С помощью сдвига вправо проверка числа, хранящегося в регистре ah, на чет- ность выглядела бы так: shr ah. 1 :загоняем младший бит в С jc odd ;мл. бит равен 1 - нечетное Здесь использована другая условная инструкция jc odd, отправляющая процессор к метке odd, когда поднят флаг переноса С. В противном случае процессор выпол- нит инструкцию, следующую за jc odd. Инструкций, подобных jc или jnz, очень много, потому что велико разнообразие событий, происходящих при выполне- нии программы. Все инструкции условного перехода способны отбросить процес- сор либо на 127, либо на 32 767, либо на 2 147 483 648 байт в сторону от себя*. Задача 5.6. Напишите программу, подсчитывающую число единичных битов в регистре. Но давайте вернемся к инструкциям сдвига. Кроме выталкивания битов во флаг переноса, они, оказывается способны делить и умножать числа на степени двой- ки. Сдвиг вправо на одну позицию, эквивалентен делению на 2, а сдвиг влево — умножению на 2. Действительно, двоичное число представляется суммой степейей двойки. При- чем, вес соседних битов отличается вдвое. Возьмем, к примеру, число 7, равное 4 + 2 + 1, или в двоичном представлении 111. В нем вес старшего бита равен 22, то есть 4, вес следующего 21, то есть 2. Если семерку сдвинуть на шаг вправо, то четверка превратится в двойку, двойка — в единицу, а единица и вовсе скроется во флаге переноса. В результате получится число 3, и это значит, что сдвиг вправо выполняет деление нацело, остаток от деления вытесняется за пределы числа и сохраняется только при сдвиге на один бит во флаге пере- носа. Задача 5.7. Как найти остаток от деления числа на степень двойки? Если сдвиг вправо делит число, то сдвиг влево, понятное дело, умножает его на степень двойки, равную числу сдвигов. Сдвиг влево на У позиций умножает число на 2N. Сдвигом влево ведает в языке ассемблера инструкция shl. Подобно инструкции shr она выдавливает биты во флаг переноса, но только с противопо- ложного конца регистра (участка памяти). Число сдвигов можно задавать не *Так происходит потому, что длина «прыжка» кодируется в этих инструкциях либо байтом, способ- ным хранить числа от -128 до +127, либо словом, вмещающим числа от -32 768 до 32 767, либо двойным словом. Какую инструкцию выбрать — решает ассемблер. Если «прыжок» получается меньше, чем на 127 байт, он выбирает более короткую инструкцию, если пет — более длинную.
Сдвиги 77 только явно, но и в регистре сГ. Инструкции, показанные ниже, сдвигают ре- гистр еах влево на две позиции: mov еах, 7 mov cl, 2 ;задать число сдвигов shl еах, с! ;умножить на 2е1. После выполнения инструкции shl еах,cl в регистре еах окажется число 28. Сдвиги влево можно сочетать с командами сложения, и тогда станет возмож- ным умножение на почти любое число. Например, умножение еах на 3 выпол- нят следующие инструкции: mov ebx, еах запоминаем еах shl еах, 1 :еах умножается на 2 add еах, ebx :еах становится втрое :больше Задача 5.8. Напишите программу, в которой заданное число умножается на 10 с помощью сдвигов и сложений. Говоря о сдвигах, до сих пор мы предполагали, что имеем дело с положительны- ми числами. Сдвиг отрицательных чисел удобно изучать на примере 4-битовых регистров, уже знакомых нам по главе 2. Первым делом посмотрим, как выполняется сдвиг отрицательного числа влево, поскольку для него достаточно уже известной нам инструкции shl, ведь «значи- мые» биты отрицательного числа расположены в правой его части, а левую часть заполняют единицы, и потеря одной из них ни к чему страшному не приведет. Пусть, например, на шаг влево сдвигается число -1. В 4-битовом регистре оно представляется четырьмя единицами 1111. После сдвига влево получится число 1110, что эквивалентно в дополнительном коде числу -2 (проверьте это!). Чтобы убедиться в том, что сдвиг влево безопасен, попробуем представить его в об- щем виде. Если k — отрицательное число, то его дополнительный код для 4-бито- вого регистра равен 16 - Щ где 11 — знак модуля, то есть абсолютной величины числа. Когда число со знаком сдвигается влево, его знаковый бит выдавливается во флаг переноса, то есть из числа вычитается 8, а все, что осталось, умножается на 2. В итоге получаем (16 - | k\ - 8)* 2 = 16 - 2 * | & |. То есть при сдвиге отрица- тельного числа влево на один шаг оно умножается на два. Поскольку любой сдвиг можно представить последовательностью «единичных» сдвигов, команда shl годится для сдвига отрицательных чисел на любое число позиций. Правда, нужно следить, чтобы сдвигаемое число уместилось в регистре. Пыта- ясь сдвинуть число -5, записанное в 4-битовом регистре, получаем ерунду, по- тому что число -10 никак не помещается в четырех битах. Задача 5.9. Какие отрицательные числа можно сдвигать на шаг влево в 8-, 16- и 32-битовых регистрах? Если сдвиг влево отрицательного числа неотличим от сдвига положительного и выполняется одной и той же инструкцией shl, то команда shr, которая уже *Это справедливо и для инструкции shr.
78 Глава 5. Шире круг применялась для сдвига вправо положительного числа, явно испортит число от- рицательное, потому что обратит знаковый бит в ноль. Поэтому для сдвига вправо отрицательных чисел применяется другая инструкция sar. Она работает так же, как и shr, но заменяет опустевшие биты знаковым битом сдвигаемого числа. При сдвиге отрицательного числа опустевшие биты заменятся единица- ми, при сдвиге положительного — нулями. Пусть, например, требуется сдвинуть на шаг вправо число -5. Поскольку отри- цательные числа в 4-битовых регистрах получаются дополнением до 16 (см. раз- дел «Знак» главы 2), число -5 кодируется так же, как положительное число 1110: 10112. После сдвига на шаг вправо получим 0101, а после записывания еди- ницы на место знакового бита 1101, то есть 8 + 4 + 0+1 = 13 — представление в дополнительном коде числа -3. То есть команда sar округляет отрицательные числа в другую сторону, и мы получаем на единицу больше, чем ожидали. На- пример, применив инструкцию sar к числу -1, получим снова -1, а не ожидае- мый ноль. Задача 5.10. Что делают инструкции? mov dx, ах sar dx, 15 хог ах, dx sub ах, dx. ПРИМЕЧАНИЕ ------------------------------------------------------------ Чтобы окончательно убедиться в том, что инструкция sar работает верно, попробуем представить то, что она делает, в общем виде. Пусть к — отрицательное число, записан- ное в дополнительном коде. Если говорить о 4-битовых регистрах, то это будет дополне- ние до 16, то есть 16 - |к|. Сдвиг на одну позицию командой sar, примененный к 4-бито- вому регистру, можно представить как shr(16 - |k|) + 8, где shr — обычный сдвиг на позицию вправо, а добавление восьмерки — это запись единицы в старший разряд 4-би- тового регистра. Чтобы понять, какое отрицательное число получилось, нужно предста- вить результат сдвига в виде разности 16 - «что-то». Вот это «что-то» и будет абсолют- ной величиной получившегося отрицательного числа. Прибавив и отняв 16 от результата сдвига, получим 16 - 16 + shr(16 - |k|) + 8 = 16 - (8 - shr(16 - |k|)). Пусть, например, сдвигается число -7. Тогда |к| = |—7| = 7. 16 - |к| = 9. Обычный сдвиг вправо числа 9 даст 4, то есть 16 - (8 - shr(16 - | к|)) = 16 - (8 - 4) = 16-4. Итак, сдвиг командой sar на одну позицию числа -7 даст нам -4, записанное в дополнительном коде. Круженье битов Для выполнения изделия использовали большое количество бусин. Например, на вышивание одной стороны кошелька сред- него размера уходило около 10 000 бисерин. Эта кропотливая ра- бота требовала особого внимания, терпения, а главное, любви. Татьяна Косоурова. Бисер в культуре народов мира Программирование на ассемблере, как и вышивание бисером, связано с кропот- ливой подгонкой множества инструкций друг к другу. Подобно кошельку сред- него размера, программа на ассемблере может содержать тысячи «инструкций- бисерин».
Круженье битов 79 В этой книге нам, конечно, не придется работать с длинными программами. Но чтобы получить представление об ассемблере, достаточно гораздо более корот- ких текстов. Подумаем, например, как хранить и выводить на экран текущую дату — число, месяц и год. Эту задачу можно решить «в лоб», выделив для хранения дня, ме- сяца и года три идущих подряд переменных. Но жалко тратить целый байт (не говоря о слове) на хранение таких небольших чисел. Попробуем поэтому по- нять, сколько всего нужно битов для хранения даты. Номер дня (число) не превышает 31 и поместится в пяти битах*. Месяц уместит- ся в четырех битах, а год, если брать только две последние цифры (от 0 до 99) — в семи. Выходит, для хранения даты достаточно 5 + 4 + 7= 16 бит. 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 О D D D D D М М М М Y Y Y Y Y Y Y Рис. 5.5. Дата умещается в 16 бит: D — день, М — месяц, Y — год Остается только понять, как на практике втиснуть дату в 16 бит и как менять, скажем, день, не касаясь месяца и года. Самый очевидный путь — сдвигать нужное число во вспомогательном регистре и затем «наклеивать его» в нужное место с помощью логической операции ИЛИ (ог). Фрагмент программы, задающей дату 30 июля 2003 (03) года, может вы- глядеть так: ; 30 июля 2003 года хог ах, ах хог Ьх. Ьх mov Ьх, 3 or ах, Ьх mov Ьх. 7 shl Ьх, 7 or ах, Ьх mov Ьх, 30 shl Ьх. 11 or ах. Ьх :ах = О :Ьх = 0 записываем год ; в ах устанавливаем ; месяц записываем месяц устанавливаем : день записываем день В нем используется вспомогательный регистр Ьх, где формируется нужная со- ставляющая даты. Чтобы, например, задать 2003 год, нужно записать число 3 в регистр Ьх и затем «наклеить» его на регистр ах логическим ИЛИ (ог). Год авто- матически занимает нужные биты в ах, потому что кодируется семью младшими битами. С месяцем так не получится. После его задания в регистре Ьх (это 7 — номер июля), биты нужно сдвинуть влево на 7, чтобы они заняли позиции 7-10 (см. рис. 5.5), и лишь затем «наклеить» их в регистр ах. Наконец, день месяца (30) так же задается в регистре Ьх, но биты нужно уже сдвинуть на 11 позиций, чтобы они заняли биты 11-15 (см. рис. 5.5). *Вспомним, что 4 бит могут быть в 16, то есть 24, разных состояниях. Значит, 5 бит способны хра- нить 25 - 32 различных числа.
80 Глава 5. Шире круг После того как дата сформирована, нужно подумать о ее модификации. Эта за- дача уже сложнее, потому что нужно в общем случае менять биты, стоящие по- середине или у левого края регистра. Здесь помогут специальные инструкции циклического сдвига rol (влево) и гог (вправо), до сих пор нам не встречавшиеся. В отличие от обычных сдвигов shl и shr, циклические сдвиги не сталкивают биты с края, а сохраняют их в противоположном конце регистра. Если, напри- мер, обычный сдвиг влево shl выталкивает старший бит из регистра во флаг пе- реноса, то циклический сдвиг на шаг приводит к тому, что четырнадцатый бит занимает позицию пятнадцатого, тринадцатый бит встает на место четырнадца- того, ...нулевой на место первого. А место пулевого бита занимает самый стар- ший, пятнадцатый. Результат циклического сдвига даты, хранящейся в регистре ах, на 5 позиций влево, показан на рис. 5.6. 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 О D D D D D M M M M Y Y Y Y Y Y Y M M M M Y Y Y Y Y Y Y D D D D D ах rol ах. 5 Рис. 5.6. Циклический сдвиг даты на 5 позиций влево После такого сдвига изменить дату гораздо легче: нужно обнулить в циклически сдвинутом регистре первые пять битов, записать новую дату во вспомогательный регистр, «наклеить» ее операцией ИЛИ (ог) и, наконец, вернуть дату на преж- нее место циклическим сдвигом на то же число позиций, но на этот раз вправо: rol ах. 5 перемещаем дату в начало and ах. OffeOh обнуляем дату mov bx. 31 ;число 31 во вспомогательном регистре or ах. Ьх :меняем дату гог ах. 5 :дату - на прежнее место Наверное, непонятнее всего в этом отрывке инструкция and ах, OffeOh, обнуляю- щая дату. Нули на месте младших пяти битов нужны, чтобы биты прежней даты не перемешались с битами новой. Если же все биты, занимаемые датой, предва- рительно обнулить, то операция ИЛИ (or) наклеит новые биты на место старых и путаницы не произойдет. Убедимся теперь, что инструкция and ах, OffeOh, дей- ствительно обращает в ноль первые пять битов регистра ах. В самом деле, число ffeO равно 1111 1111 1110 0000 в двоичнохМ представлении, а операция И (and) оставит неизменными те биты регистра ах, которым соответствуют единичные биты маски и обнулит те биты, которые в маске равны нулю. Теперь мы в состоянии понять программу, которая записывает дату (30 июля 2003 года) в 2-байтовый регистр, меняет число с 30 на 31 и затем показывает новую дату на экране (см. листинг 5.2). Листинг 5.2. Запись и модификация даты .386 .model flat, stdcall option casemap:none
Круженье битов 81 Include Include Include includel1b Includel 1b DateDisp proto :W0RD BSIZE equ .data \myasm\1nclude\windows. 1 nc \myasm\include\user32. i nc \myasm\1nclude\kernel32.1nc \myasm\11b\kernel 32.11b \myasm\11b\user32.11b 15 Ifmt BYTE 'ld",0 buf BYTE BSIZE dup(0) stdout DWORD ? cWritten DWORD ? month BYTE "янв фев мар апр май июн " BYTE "июл авг сен окт ноя дек" .code start: Invoke GetStdHandlе. STD OUTPUT HANDLE mov stdout, eax mov ax, 0f383h :30 июля 2003 года Invoke DateDisp, ах доказать дату rol ax, 5 перемещаем дату в начало and ax. OffeOh обнуляем дату mov bx, 31 :число 31 во вспомогательном регистре or ax. Ьх ;меняем дату ror ax. 5 ;дату - на прежнее место Invoke DateDisp, ах доказать дату Invoke ExitProcess, 0 DateDisp proc Date:WORD LOCAL CRLF:WORD возврат каретки и перевод строки mov CRLF, OdOah xor edi,edi :очищаем регистр mov di, Date rol di. 5 ;число - в пять младших битов and di, Ifh ;гасим лишние биты Invoke wsprintf, ADDR buf, ADDR Ifmt, edi Invoke WriteConsoleA. stdout, ADDR buf. 3,\ ADDR cWritten. NULL mov di. Date восстанавливаем дату shr di. 7 ;месяц - в семь младших битов and di, Ofh ;выделяем месяц dec di нумерация с нуля shl di. 2 ;умножим на 4 mov esi, offset month ;отн. адрес названия add esi. edi ;адрес названия Invoke WriteConsoleA, stdout, esi, 4, \ ADDR cWritten. NULL mov di, Date восстанавливаем дату and di, 7fh ;выделяем год Invoke wsprintf, ADDR buf, ADDR Ifmt, edi Invoke WriteConsoleA. stdout. ADDR buf, 3,\ ADDR cWritten, NULL Invoke WriteConsoleA, stdout. ADDR CRLF, 2,\ ADDR cWritten, NULL :перевод строки mov ax. Date ret DateDisp endp end start
82 Глава 5. Шире круг Для экономии места я не стал формировать дату с помощью сдвигов и наложе- ний масок, а просто записал в регистр ах число 0f383h (проверьте, действитель- но ли оно соответствует 30 июля 2003 года). Прежде чем переходить к процеду- ре DateDisp, показывающей дату на экране, нужно решить, как удобнее всего показывать месяц. В программе из листинга 5.2 все названия месяцев занимают одно и то же число байтов — четыре, что сильно упрощает вычисление адреса нужных символов, хранящихся в массиве month. Из-за недостатка места пришлось записать этот массив в две строки, каждая из которых начинается директивой BYTE. Как мы уже знаем (например, из раздела «Не могу молчать» главы 3), ас- семблер не считает двойные кавычки символами, кавычки лишь подсказывают ему, где начинаются и где кончаются «настоящие» символы. Обратите внима- ние на пробел после названия июня — июн ”. Его обязательно нужно оставить, иначе нарушится порядок символов, и программа перепутает названия месяцев. Сама процедура DateDi sp теперь наверняка покажется нам простой. В ней с по- мощью сдвигов и битовых масок выделяются и показываются на экране разные составляющие даты. Чуть сложнее, чем день и год, показывается месяц. Чтобы вычислить адрес нужной последовательности символов, необходимо начать нуме- рацию месяцев с нуля, а не с единицы, как у нас. Поэтому после выделения меся- ца инструкциями: mov di. Date восстанавливаем дату shr di. 7 ;месяц - в семь младших битов and di. Ofh :выделяем месяц его номер уменьшается на единицу, затем вычисляется адрес нулевого* символа массива month (offset month) и к нему прибавляется номер месяца, умноженный на 4 (потому что каждое название месяца занимает с учетом пробела четыре бай- та). Зная адрес и число символов, можно запускать процедуру WriteConsol еА, ко- торая и показывает нужный месяц (в нашем случае июл ) на экране. И напосле- док DateDisp восстанавливает испорченный процедурами wsprintf и WriteConsolеА регистр ах, где основная программа хранила дату: mov ах. Date Сложение и вычитание — В целом, — говорил Морковин, — происходит это примерно так. Человек берет кредит. На этот кредит он снимает офис, покупает джип «чероки» и восемь ящиков «Смирновской». Когда «Смирнов- ская» кончается, выясняется, что джип разбит, офис заблеван, а кре- дит надо отдавать. Тогда берется второй кредит — в три раза больше первого. Из него гасится первый кредит, покупается джип «гранд чероки» и шестнадцать ящиков «Абсолюта». Виктор Пелевин. Generation П В прошлой главе мы не рисковали проверять на «простоту» числа, большие, чем в состоянии хранить 4-байтовый регистр еах, потому что не знали, как заставить *Напомним, что нумерация в массивах начинается с нуля.
Сложение и вычитание 83 число «переехать» из одного регистра еах в два — edx:еах. В этом разделе мы увидим, что переход двоичного числа из регистра в регистр не сложнее, чем пе- реход десятичного числа из одного десятичного разряда в два. Действительно, поместим ноль в один десятичный разряд и будем прибавлять туда по единице. Все будет хорошо, пока разряд не станет равен 9. Если приба- вить к нему единицу, возникнет переполнение, то есть разряд обратится в нуль и появится «один в уме» — перенос в следующий десятичный разряд. Очевидно, регистр еах ничем не отличается от десятичного разряда. Прибавляя к нему одну единицу за другой, нужно следить за флагом переноса. И каждый раз, ко- гда он поднят, прибавлять единицу к регистру edx. Следить за переносом способна специальная команда adc, которая прибавляет число, как обычная команда add, а затем добавляет к сумме содержимое флага переноса. С помощью команды adc формирование числа для проверки на «про- стоту» будет выглядеть так: add еах. 1 увеличим adc edx, 0 :число Первая инструкция увеличивает на единицу еах, а вторая прибавляет к edx флаг С. Вместо add еах. 1 нельзя использовать инструкцию inc еах, потому что она не влия- ет на флаг переноса. Естественно, комбинацию add, adc можно использовать не только для прибавле- ния к «длинному» числу единицы, но и для сложения очень больших чисел, не умещающихся ни в двух, ни в четырех байтах. Пусть, например, одно число хра- нится в паре регистров edx:еах, а второе в паре есх:ebx. Для сложения таких чи- сел понадобятся инструкции: add еах. ebx ;еах <- еах + ebx adc edx, есх :edx <- edx + ecx + С Первая инструкция выполняет обычное сложение, а вторая складывает регист- ры edx и есх и прибавляет к их сумме флаг переноса. Задача 5.11. Подумайте, как можно складывать «длинные» числа, хранящие- ся не в регистрах, а в памяти компьютера. «Длинные» числа можно не только складывать, но и вычитать с помощью ко- манд sub, sbb. Понять их работу невозможно без умения различать сложение и вычитание. До сих пор мы думали, что вычитание эквивалентно прибавлению числа, запи- санного в дополнительном коде (см. раздел «Знак» главы 2). Пусть, например, числа 1 и 3 хранятся в 4-битовых регистрах. Тогда (думали мы) для вычита- ния из единицы трех достаточно сложить единицу (0001) и 3 в дополнительном коде (1101). Действительно, сложив эти числа, получим 1110, то есть, -2 в до- полнительном коде (убедитесь в этом сами). Казалось бы, все верно. Но кроме числа нужно еще правильно установить фла- ги, а при сложении единицы и дополнительного кода для -3 не возникает ни пе- реполнения, ни переноса из старшего разряда.
84 Глава 5. Шире круг Между тем, вычитание из меньшего числа большего, как в нашем примере, должно сопровождаться заемом из старшего разряда. Чтобы понять, что это та- кое, попробуем вычесть из единицы три «в лоб», не прибегая к двоичному до- полнительному коду, а руководствуясь обычными правилами вычитания «стол- биком»* (рис. 5.7). Вычитая самые младшие разряды, получим 0. Переходя на шаг влево, столкнем- ся с необходимостью вычитать из нуля единицу. Это невозможно, поэтому, как и при вычитании десятичных чисел, займем единицу старшего разряда (возь- мем первый кредит). Она имеет вдвое больший вес, поэтому вычитание из еди- ницы старшего разряда единицы младшего даст единицу**. Далее переходим к вычитанию из нуля, из которого уже занята единица, «обычного нуля». Опять занимаем единицу старшего разряда (берем второй кредит, из которого гасим первый) и опять получаем единицу младшего. Продолжая в том же духе, полу- чим ряд единиц, но последняя единица получается, если занять единицу несу- ществующего, девятого разряда. Вот это и есть заем, о котором процессор дол- жен сообщать поднятием какого-то флага. В процессорах Intel для этого выбран флаг переноса С. 0001 ООП 1110 С Рис. 5.7. Вычитание «столбиком» двоичных чисел Для большей ясности вернемся к нашему примеру, но теперь займемся числа- ми, которые хранятся в реальных 8-битовых регистрах. Если складывать коман- дой add два числа 0000001 (единицу) и 11111101 (-3 в дополнительном коде), получится 11111110 (-2 в дополнительном коде). При этом флаг С окажется опущенным (равным нулю). Если же для вычитания из единицы тройки ис- пользовать команду sub: mov al, 1 mov ah, 3 sub al, ah то в регистре al опять возникнет число -2 (11111110); точно таким же, как при сложении 1 + (-3), будет и флаг переполнения 0, но флаг С теперь поднимется, что скажет нам о заеме из старшего разряда. Задача 5.12. На примере 4-битовых регистров покажите, что при вычитании «столбиком» из меньшего положительного числа большего получается отри- цательное число в дополнительном коде. Точками на рисунке помечены заемы из старших разрядов. "Занятая единица имеет вес 4, а единица текущего разряда имеет вес 2; вычитая, получаем 2, то есть как раз единицу текущего разряда.
Умножение и снова деление 85 Теперь мы, наконец, можем вернуться к вычитанию «длинных» чисел с помощью команд sub и sbb. Предположим, что в регистрах edx:еах записан ноль. Тогда вы- честь из такого длинного числа единицу можно командами: sub еах. 1 sbb edx. О Первая команда приводит к тому, что в еах все биты устанавливаются в едини- цу и возникает заем из старшего разряда (поднимается флаг переноса С). Вторая команда вычитает флаг С, равный в нашем случае единице, из числа, записанно- го в регистре edx. Результат понятен: в edx и еах все биты обратятся в единицу, то есть в паре регистров оказывается -1 в дополнительном коде. Последовательность команд sub, sbb, sbb... можно применить к вычитанию лю- бых длинных чисел. Пусть, например, первое число находится в паре регистров edx:еах, а второе — в паре ecx:ebx. Тогда фрагмент программы вычитания из пер- вого числа второго будет выглядеть так: sub еах. ebx : вычитаем «младшие» биты sbb edx. ecx :edx <- edx - ecx - C Первая инструкция sub вычитает из еах содержимое ebx и записывает результат в еах, она занимается «младшими» битами. Вторая инструкция посылает в edx результат операции edx - ecx - С. Задача 5.13. Что делает инструкция sbb еах, еах? Умножение и снова деление В контрольном эксперименте баран выбрал пра- вую кормушку. Собственно, задача сводилась к вопросу: почему? Два года машина думала. Потом начала строить модели. Аркадий и Борис Стругацкие. Полдень. XXII век Мы уже говорили о том, что умножение почти на любое число можно предста- вить комбинацией сдвигов и сложений. Но было бы странно делить числа инст- рукцией div, а умножать, изобретая каждый раз хитрые комбинации инструк- ций add и shl. Поэтому в процессоре предусмотрена инструкция универсального умножения mul. Как и div, инструкция mul <операнд> едина в трех лицах: операнд, хранящий- ся в байте, умножается на al, результат же оказывается в регистре ах. Если опе- ранд — слово, процессор умножает его на ах, результат же оказывается в еах. Наконец, операнд, хранимый в двойном слове, умножается на еах, а результат оказывается в паре регистров edx:еах. Как обычно, новое знание порождает новую печаль: результат умножения, хра- нящийся в двух регистрах и занимающий 64 бит, необходимо выводить на эк- ран, а для этого процедура wsprintf не подойдет, потому что она оперирует толь- ко 32-битовыми значениями.
86 Глава 5. Шире круг Значит, нужно самим научиться превращать «длинные» числа в символы, для вывода которых на экран годится процедура WriteConsoleA. Программа, выводя- щая на экран «длинные» числа, показана в листинге 5.3. Листинг 5.3. Вывод на экран «длинных» чисел .386 .model flat, stdcall option casemap:none 1nclude \myasm\1nclude\wi ndows.1nc 1nclude \myasm\i nclude\kernel 32.1nc includelib \myasm\lib\user32.1ib 1ncludel1b \myasm\lib\kernel32.11b BSIZE equ 20 .data digit BYTE BSIZE dup (?) cWritten DWORD ? stdout DWORD ? .code start: mov esi. BSIZE mov eax. 123456789 mov mul ebx, ebx 1315678 mov ebx. edx mov nxt: ecx. 10 :делим на 10 dec esi :позиция след, символа xchg eax, ebx :делим sub edx, edx : старшую div ecx : половину xchg eax, ebx сохраняем частное и делим div ecx :остаток + младшую половину add dl, 48 превращаем в символ mov d1git[esi], dl сохраняем символ mov edx. ebx or edx. еах :оба частных = 0? jnz nxt :нет - продолжим invoke GetStdHandle, STD_OUTPUT_HANDLE mov stdout, eax mov eax. offset digit:начало массива add eax. esi .-адрес первого символа mov edx. BSIZE sub edx. esi :число символов invoke WriteConsoleA. stdout, eax. edx. \ ADDR cWritten. NULL invoke ExitProcess. 0 end start Хоть и не самая длинная, эта программа несравнимо сложнее всего того, что нам до сих пор встречалось. И если хочется понять, за что одни так любят ассемб- лер, а другие — люто ненавидят, то лучшего примера не найти. Программисту Ричарду Павличеку* понадобилось всего несколько инструкций процессора, чтобы реализовать довольно сложный алгоритм. В этом — большая В листинге 5.3 использован текст программы, написанной Ричардом Павличеком (Richard Pavlicek).
Умножение и снова деление 87 красота ассемблера, и в этом же — большое неудобство, ведь понять спрессован- ную в несколько строчек мысль не так то просто. Поэтому имеет смысл построить модель, как это делала вычислительная маши- на из романа Стругацких, и по шагам проследить, что же делают инструкции. Наша модель окажется довольно близкой ассемблерному тексту, но в отличие от него, будет оперировать не двоичными, а гораздо более привычными деся- тичными числами. Но прежде выделим из листинга 5.3 центральную часть, которая и превращает «длинное» число в последовательность символов: nxt: xchg еах. ebx :делим sub edx. edx :старшую div есх :половину xchg еах. ebx сохраняем частное и делим div есх :остаток + младшую половину сохранить символ mov edx. ebx or edx. eax :оба частных « 0? jnz nxt ;нет - продолжим Чтобы не отвлекаться, я поставил многоточия на месте инструкций, сохраняю- щих символ в массиве digit. А теперь — наша модель. Пусть в длинном слове, хранящемся в паре регистров ebx:еах, записано десятичное число 123456: в реги- стре ebx — 123, а в регистре еах — 456. Тогда первая инструкция xchg еах, ebx по- меняет местами еах и ebx. Значит, после того как в edx окажется ноль, мы поде- лим число 123, оказавшееся в еах, на 10. Результат этого деления — число 3 в edx (остаток) и число 12 в еах (частное). Далее (смотрите исходный текст) сле- дует вторая команда xchg, которая опять меняет регистры еах и ebx. И следую- щим числом, которое будет поделено на 10, будет 3456 (3 в edx и 456 в еах)! По- сле деления в edx окажется остаток 6 — первый десятичный разряд числа, а в ре- гистре еах — частное 345. Чувствуете? Программа «откусила» десятичный разряд (3) от старшей половинки числа и перевела его в младшую. После чего удалила и сохранила младший десятичный разряд (6). Так она будет действовать и даль- ше: присоединять десятичный разряд слева и удалять справа, пока частные от деления старшей и младшей половин числа на 10 не станут равны нулю. Это ус- ловие проверяется инструкцией or edx, еах, результат которой будет равен нулю лишь когда равны нулю, оба операнда edx (частное от деления старшей полови- ны) и еах (частное от деления младшей половины). Теперь можно перейти от модели к реальности, то есть к делению не десятичных, а двоичных чисел. Очевидно, все будет так же, но поскольку число 10 не соответст- вует целому числу битов (3 бита вмещают 710, а 4 — 1510), количество двоичных раз- рядов, втягиваемых с одного конца и выталкиваемых с другого, будет переменным. Но суть от этого не изменится, и «длинное» число будет успешно протащено через пару регистров edx:еах, после чего программа выведет на экран «длиннющее» число 162 429 381 237 942, равное произведению 1 315 678 на 123 456 789 (см. листинг 5.3). Читая описание сложного алгоритма, вы, наверное, не раз уже подумали: а не луч- ше ли просто поделить инструкцией div число, хранящееся в паре регистров edx:еах, на 10, сохранить остаток, затем поделить на 10 частное от предыдущего деления,
88 Глава 5. Шире круг снова сохранить остаток и поступать так до тех пор, пока частное не станет рав- но нулю? Все дело в том, что такое деление может закончиться переполнением, когда частное не уместится в регистре еах. Вот почему принципиально важно разбить число на две части, каждую из которых можно безопасно делить хоть на 1, не говоря о десяти и постепенно перетаскивать кусочки одной части в дру- гую. Задача 5.14**. Напишите процедуру деления 64-битового числа, хранящего- ся в двух четырехбайтовых регистрах, на произвольное двухбайтовое число. Подсказка: вспомните деление «столбиком» десятичных чисел. После длинного описания очень короткого алгоритма нам осталось сделать два замечания. Первое касается нового оператора offset, который используется в лис- тинге 5.3 для получения адреса начала массива digit: mov еах, offset digit Оператор offset похож на оператор ADDR, но ADDR применяется только при вызо- ве функции директивой invoke, поэтому и приходится использовать offset, чтобы послать адрес массива в еах. В принципе, offset можно использовать вместо addr при вызове процедуры директивой invoke, у оператора ADDR толь- ко одно преимущество: он позволяет узнать адрес локальных переменных, выделяемых директивой LOCAL (см. раздел «Своеволие ассемблера» главы 3), a offset — нет. Второе замечание касается учета знака чисел при умножении и делении. До сих пор нам приходилось только делить положительные числа. Полезно знать, что существуют специальные инструкции для умножения и деления чисел со зна- ком — imul (умножение) и idiv (деление). Их единственное отличие от div и mul в том, что они рассматривают числа с единичным старшим битом как отрица- тельные и соответственно меняют результат умножения или деления. Ввод До сих пор мы покорно выслушивали все, что желает сообщить программа, но сами не могли вставить и словечка, потому что не знали, как общаться с про- граммой во время ее выполнения. Пора перейти от грубого вмешательства в ис- ходные тексты к более деликатному вводу символов с клавиатуры. Этим в системе Windows ведает процедура ReadConsole, одновременно похожая на уже известную нам WriteConsol еА и противоположная ей. Программа, показанная в листинге 5.4, вводит с клавиатуры последовательность символов и затем ото- бражает ее на экране. Листинг 5.4. Ввод с клавиатуры и отображение на экране символов .386 .model flat, stdcal1 option casemap:none i nclude \myasm\i nclude\wi ndows.i nc include \myasm\i nclude\kernel 32.i nc
Ввод 89 includelib \myasm\11b\user32.11b inc1udel1b \myasm\11b\kernel32.1ib BSIZE equ 128 .data buf BYTE BSIZE dup(?) stdout DWORD ? stdin DWORD ? cRead DWORD ? cWritten DWORD ? .code start: invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax invoke GetStdHandle. STD_INPUT_HANDLE mov stdin, eax NewLine: invoke ReadConsole. stdin. ADDR buf.\ BSIZE. ADDR cRead. NULL invoke WriteConsoleA. stdout. ADDR buf,\ cRead. ADDR cWritten. NULL cmp cRead.2 jnz NewLine Invoke ExitProcess. 0 end start Программа из листинга 5.4, в отличие от всех предыдущих, имеет дело с де- скрипторами двух стандартных устройств — экрана (stdout) и клавиатуры (stdin). Процедура ReadConsole принимает по существу те же параметры, что и WriteConsoleA: дескриптор устройства (stdin), адрес массива байтов, куда попа- дут введенные символы (ADDR buf), размер массива (BSIZE), адрес двойного слова, куда процедура запишет число прочитанных символов (ADDR cRead), и, наконец, ничего не значащее значение NULL (просто ноль). Ввод символов завершается клавишей Enter. После ее нажатия ReadConsole закан- чивает работу, и за дело берется процедура WriteConsoleA, повторяющая введен- ные символы на экране. Задача 5.15. Напишите процедуру, переводящую, когда это возможно, после- довательность символов в число. Нужно понимать, что Enter тоже посылает программе символы, а не просто управляет вводом. Символов этих два: 13 (0D16) и 10 (0А16). Первый из них обо- значается как CR {Carriage Return — возврат каретки), а второй — как LF {Line Feed — перевод строки). Легко догадаться, что первый возвращает курсор к ле- вому краю экрана, а второй переводит курсор на следующую строку. Зная особенности клавиши Enter, легко понять, почему условие продолжения ввода выглядит в нашей программе как: cmp cRead. 2 jnz NewLine Ведь cRead, равное 2, говорит о нажатии только клавиши Enter, то есть, по сути, об окончании ввода. Значит, ввод имеет смысл продолжить, когда cRead больше двух.
90 Глава 5. Шире круг Тут, правда, есть одна тонкость. Оказывается, процедура ReadConsole откликается еще на одну комбинацию клавиш Ctrl+Z (нажав и удерживая Ctrl, нажимаем Z). Эта комбинация срабатывает в любой момент, даже до нажатия Enter, и приводит к тому, что ReadConsole прекращает работу, а число прочитанных символов стано- вится равным нулю. По своему смыслу клавиши Ctrl+Z должны прерывать выпол- нение программы, но у нас они не срабатывают, потому что условный переход jnz NewLine произойдет, когда разность cRead - 2 не равна нулю, то есть и после нажа- тия Ctrl+Z. Чтобы клавиши Ctrl+Z прерывали выполнение программы, нужно из- менить условие: cmp cRead, 2 ja NewLine Условный переход ja Newline означает «переход, если больше» (с буквы «а» на- чинается английское слово after — после). Под «больше» процессор понимает такое состояние флагов, когда флаг нуля Z = « 0 (результат вычитания cRead - 2 ненулевой) и флаг переноса С опущен. Легко убедиться, что процессор прав. Действительно, при cRead больше двух результат сравнения* cmp cRead, 2 положителен — значит, заема из старшего разряда не возникло, и флаг переноса опущен (см. раздел «Сложение и вычитание»). Есте- ственно, при положительном результате сравнения опущен и флаг нуля, поэто- му условие выполняется и программа переходит к вводу следующих символов. Если же cRead меньше двух, при сравнении cRead и 2 возникает заем из старшего разряда, поднимается флаг переноса, переход не выполняется и программа за- вершает работу. Кроме инструкций jnz и ja процессор понимает множество других инструкций перехода. Зная английский, можно догадаться о названиях многих из них по двум уже известным. Раз есть переход по неравенству нулю (jnz), то должен быть и переход по равенству (jz). Кроме перехода по «больше» должен быть переход по «меньше» jb («Ь» — первая буква слова below, то есть ниже). Что такое «меньше», легко понять, зная, что такое «больше». Переход по «меньше» случится, когда флаг нуля опущен, а флаг переноса, наоборот, поднят. Естест- венно, должны быть переходы по «больше или равно» — jae («е» — первая бу- ква слова equal, то есть равный). Аналогично jbe — переход, когда «меньше или равно». Говоря о переходах по «меньше» или «больше», нужно четко представлять себе, какие числа сравниваются. Если это числа со знаком, результат будет одним, если без — совсем другим. Возьмем, к примеру 4-битовые числа 01112 и 11102. Считая их числами без знака, получим неравенство 7 < 14. Если же считать их числами со знаком, то выйдет 7 > -2. Поэтому для чисел со знаком и без знака нужны особенные инструкции перехо- да. Уже известные нам инструкции jb и ja работают с числами без знака, пото- му что анализируют содержимое флага С. А для чисел со знаком предназначены инструкции jg («g» — начальная буква слова greater — больше) и jl («1» — на- *Напомню, что инструкция стр устанавливает флаги так, как будто из левого операнда вычли пра- вый. При этом сами операнды не меняются.
Ввод 91 чальная буква слова lower — меньше). Основные инструкции перехода по ре- зультатам сравнения чисел показаны в табл. 5.1. Таблица 5.1. Переходы после инструкции сравнения стр Числа без знака Числа со знаком Инструкция Переход, если... Инструкция Переход, если... JA C=0nZ=0 JG Z=0nS=0 JBE С = 1 или Z =1 JLE Z=1 или S ф 0 JB С= 1 JL S*0 JAE С = 0 JGE S = 0 Условия перехода для чисел со знаком могут показаться странными: почему, на- пример, переход по «меньше» jl наступает, когда флаг знака S не равен флагу переполнения? Но давайте разберем несколько примеров сравнения, чтобы убе- диться в том, что процессор и на этот раз прав. Пусть сравниваются два отрицательных числа: -3 и -5: mov ах. -3 mov bx. -5 cmp ах. Ьх Вычитая из -3 число -5, получим -3 - (-5) = -3 + 5 = 2. Очевидно, флаг знака будет опущен, ведь результат положительный. Опустится и флаг переполнения, потому что складываются числа с разными знаками*. Итак, флаги знака и пере- полнения равны, и команда jg совершит переход к указанной метке. Точно так же поведет себя в этом случае и команда ja, потому что число -3 представляет- ся большим двоичным числом, чем -5. Пусть теперь сравниваются отрицательное число -3 и положительное 5: mov ах. -3 mov bx. 5 cmp ах. bx Сравнение cmp состоит в том, что флаги процессора устанавливаются так, как будто из ах вычли Ьх, при этом сам регистр ах не меняется. Что же будет при та- ком вычитании? Когда из -3 вычитают 5, получится число -8, то есть флаг зна- ка поднимется (результат отрицательный), а флаг переполнения 0 опустится, потому что никакого переполнения нет. С точки зрения сравнения чисел со зна- ком возникло событие «меньше», и команда jl пошлет процессор к указанной метке. Но с точки зрения сравнения чисел без знака -3 больше 5, потому что дополнительный код числа -3 это FFFD, то есть 65 533. Поэтому команда jb в этом случае не выполнится, и процессор просто перейдет к следующей за jb инструкции. Мораль: необходимо очень тщательно выбирать инструкции перехода. Непра- вильная инструкция очень коварна, потому что во многих случаях ведет себя *Вычитание эквивалентно прибавлению числа в дополнительном коде. Вот почему здесь говорится о сложении. При вычитании и сложении флаг О ведет себя одинаково. Отличия есть только в по- ведении флага С (см. раздел «Сложение и вычитание»).
92 Глава 5. Шире круг так, как ожидалось. Между тем, никто, кроме программиста, не может знать, ка- кие числа (со знаком или без знака) сравниваются. Для процессора все числа равны. Кроме переходов, зависящих от результатов сравнения, есть переходы, обуслов- ленные состоянием флагов: jc (переход при поднятом флаге переноса) и соот- ветственно jnc — переход при опущенном флаге. Есть переходы по флагу пере- полнения jo и знака js, а также много других условных инструкций. Краткие сведения о них (и о других основных командах процессора) можно найти в При- ложении.
Глава 6 Файлы Открытие файла Находя простые числа в главе 4, мы не задумывались о том, где будем их хранить, потому что все они умещались на экране монитора. Но представим себе, что нужно получить не десять, а десять тысяч простых чисел. Для такой задачи экран мони- тора будет маловат, и нужно подумать о файлах, в которых можно хранить все: тек- сты книг, изображения, музыку, программы и, конечно же, числа, поскольку все это можно представить длинной последовательностью нулей и единиц. Программа, по- казанная в листинге 6.1, сохраняет четыре числа 3, 5, 7, И в файле simple. Листинг 6.1. Сохранение четырех чисел в файле .386 .model flat, stdcall option casemap:none include \myasm\1nclude\wi ndows.inc include \myasm\include\kernel32.inc includellb \myasm\lib\user32.1ib includeli b \myasm\1ib\kernel32.1ib NOFDIG equ 4 DSIZE equ 4 BSIZE equ NOFDIG*DSIZE .data fName BYTE "simple".0 fHandle DWORD ? cWritten DWORD ? digs DWORD 3.5.7.11 .code start: invoke CreateFile. ADDR fName. GENERIC_WRITE. 0. NULL. CREATE ALWAYS. FILE ATTRIBUTE_ARCHIVE. 0 mov fHan31e. eax invoke WriteFile. fHandle. ADDR digs.BSIZE. ADDR cWritten. NULL invoke CloseHandle. fHandle invoke ExitProcess. 0 end start
94 Глава 6. Файлы Чтобы начать работу с файлом, его нужно создать или открыть*. Всем этим управ- ляют многочисленные параметры процедуры CreateFile, с которыми стоит по- знакомиться подробнее. Первый параметр содержит адрес имени файла, состоящего из символов. При- знаком окончания имени служит нулевой байт. В нашем случае этот параметр равен ADDR fName — адресу нулевого символа в массиве fName, который состоит из шести символов simple и завершающего нуля. Второй параметр показывает процедуре, для чего открывается или создается файл. Параметр GENERIC WRITE означает, что разрешена запись в файл, GENERIC READ раз- решает только чтение. Можно позволить и чтение и запись. Для этого оба пара- метра объединяются оператором ИЛИ: GENERIC_READ or GENERIC_WRITE Третий параметр показывает, может ли файл использоваться другими программа- ми (не забывайте, что Windows — многозадачная операционная система!). Нас пока многозадачность не интересует, поэтому выбираем параметр, равный нулю, что открывает доступ к файлу только нашей программе. Четвертый параметр тоже имеет отношение к многозадачности. Он содержит ад- рес области данных, в которой указано, может ли файл использоваться програм- мами, порожденными данной. Любая программа в системе Windows может запус- кать другие программы или, как говорят, процессы. И эти процессы могут иметь доступ к файлу, созданному «родительским» процессом. Мы пока не доросли до запуска «дочерних» программ, поэтому полагаем этот параметр равным нулевому адресу NULL, что разрешает использовать файл только основной программе. Пятый параметр показывает, что делать, если файл уже существует. Значе- ние CREATE_ALWAYS приказывает уничтожить уже существующий файл и соз- дать на его месте пустой файл с тем же именем. В нашем случае это значит, что если в папке, где запущена программа, нет файла simple, то он будет создан. Если же такой файл уже есть, он будет уничтожен, и на его месте появится файл с тем же именем simple, но совершенно пустой. Могут пригодиться и другие значения этого параметра: CREATENEW (не трогает уже существующий файл), OPEN_EXISTING (открывает только уже существующий файл, сохраняя его содержимое), OPEN_ALWAYS (если файл существует, открывает его, не тро- гая содержимое, если не существует, создаем новый файл с указанным име- нем). Шестой параметр задает атрибут файла: архивный, скрытый, только для чтения, системный и т. д. В нашем случае параметр равен FILE ATTRIBUTE ARCHIVE — обыч- ному для большинства файлов атрибуту. Едва ли нам стоит что-то знать о седьмом параметре, ведь число возможных комбинаций предыдущих шести, согласно некоторым оценкам, превышает 30 мил- лионов. Поэтому будем считать, что он всегда равен нулю. Как бы ни сочетались между собой эти семь параметров, процедура CreateFile возвращает всего одно число в регистре еах. Это дескриптор файла, используе- мый аналогично дескриптору экрана или клавиатуры. ’Открыть можно только уже существующий файл.
Чтение 95 Например, процедура WriteFile, используемая для записи в файл символов (см. листинг 6.1), имеет те же параметры, что и процедура WriteConsole: дескриптор файла fHandle, адрес области памяти, хранящей символы, — ADDR digs, количест- во символов BSIZE, адрес памяти, где хранится число действительно записанных в файл символов ADDR cWritten, и, наконец, никому не нужный нулевой указа- тель NULL. Процедура WriteFile пишет в файл 16 байт — таков суммарный размер четырех двойных слов, где размещены простые числа 3, 5, 7, И. Завершает работу с файлом процедура CloseHandle, у которой всего один пара- метр — дескриптор файла. Эта процедура отсоединяет файл от дескриптора, и после ее выполнения файл снова должен быть открыт, чтобы стали возможными чте- ние или запись. Прежде чем перейти к следующему разделу, обратим внимание на директиву: BSIZE equ NOFDIG*DSIZE которая задает число записываемых символов. Оказывается, ассемблер спосо- бен не только заменять имя соответствующим числом, но и выполнять простей- шие арифметические действия. В результате появится новое имя BSIZE, которое ассемблер заменит в тексте программы числом 16. Чтение Программа, написанная в предыдущем разделе, создает рядом с собой на диске файл simple, где записаны четыре числа. Полезно заглянуть внутрь этого файла, для чего в оболочке FAR служит кнопка F3. Подсветив имя файла и нажав F3, увидим его содержимое, показанное символами. Но поскольку в нашем файле хранятся числа, полезнее увидеть соответствующие этим символам шестнадца- теричные коды. Для этого после F3 следует нажать F4, и тогда нам откроется примерно то же, что на рис. 6.1. 1ЩЙ»: «3 » 00 05 ЙО 00 00 ! 07 00 00 Ж « 00 Ий 00 f ♦ » й1 Рис. 6.1. Внутренности файла simple В файле, как и в памяти компьютера, числа выворачиваются наизнанку: млад- шие биты идут первыми. Каждое число занимает четыре байта, причем номера байтов даны в шестнадцатеричной системе: адрес 10 соответствует десятичному числу 16. Такого байта в нашем файле нет, ведь их нумерация начинается с нуля и все 16 байт имеют номера от 0 до 15. Научившись создавать файлы и записывать туда числа, подумаем о том, как их читать. Весь наш предыдущий опыт подсказывает, что для этого нужно сначала получить дескриптор файла (для этого подойдет процедура CreateFi 1 е), а затем использовать процедуру чтения, которая должна быть похожей на процедуру
96 Глава 6. Файлы записи. Программа, читающая только что созданный файл и выводящая храня- щиеся в нем числа на экран, показана в листинге 6.2. Листинг 6.2. Чтение файла и вывод его содержимого на экран .386 .model flat, stdcal1 option casemap:none i nclude \myasm\include\wi ndows.i nc include \myasm\include\user32.inc i nclude \myasm\i nclude\kernel 32.i nc includelib \myasm\lib\user32.1ib includelib \myasm\1ib\kernel 32.1 ib NOFDIG equ 4 DSIZE equ 4 BSIZE equ NOFDIG*DSIZE DIGSZ equ 10 .data fName BYTE "simple".0 fmt BYTE "Sd",0 fHandle DWORD ? stdout DWORD ? cRead DWORD ? cWritten DWORD ? buf DWORD BSIZE dup (?) dig2sim BYTE DIGSZ dup (?) .code start: invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax invoke CreateFile, ADDR fName.\ GENERIC_READ.\ 0. NULL. OPEN_EXISTING.\ FILE_ATTRIBUTE_NORMAL. 0 mov fHandle. eax invoke ReadFile. fHandle. ADDR buf.\ BSIZE. ADDR cRead. NULL mov ecx, NOFDIG mov esi. 0 nxt: push ecx invoke wsprintf. ADDR dig2sim,\ ADDR fmt. buf[esi] invoke WriteConsoleA. stdout.\ ADDR dig2sim.\ DIGSZ. ADDR cWritten. NULL add esi. 4 pop ecx loop nxt invoke CloseHandle. fHandle invoke ExitProcess. 0 end start Процедура CreateFile служит в ней для открытия уже существующего файла, а не для создания нового. Поэтому используется параметр OPEN_EXISTING, запре- щающий процедуре заново создавать файл с указанным именем.
Чтение 97 С файлом, который уже существует, следует обращаться бережно, чтобы не уничтожить хранящиеся там данные. Поэтому используется параметр GENERIC_READ, указывающий процедуре, что файл открыт только для чтения. После открытия файла и запоминания его дескриптора за дело принимается процедура чтения файла ReadFi 1е, устроенная так же, как и WriteFile. Ее пара- метры: дескриптор файла fHandle, адрес буфера, где окажутся прочитанные сим- волы, число символов BSIZE, адрес переменной, хранящей число на самом деле прочитанных символов: ADDR cRead и, наконец, завершающий NULL, нам уже зна- комы. Заметим только, что количество на самом деле прочитанных символов не всегда равно указанному. Больше символов, чем есть в файле, прочитать нельзя. Сравнивая число прочитанных и число указанных символов, можно понять, что достигнут конец файла. Обычно файл читают в несколько приемов, каждый раз сравнивая число заданных и число на самом деле прочитанных символов. Если оба числа равны, конец файла не достигнут, и чтение продолжается. Если же прочитано меньше символов, чем указано, файл пришел к концу, и чтение за- вершается. Остаток программы из листинга 6.2 должен быть нам понятен. В нем добытые из файла цифры выводятся на экран. Делается это в цикле: nxt: push есх add esi. 4 pop ecx loop nxt Сначала число из массива преобразуется в символы с помощью wsprintf, а затем выводится на экран процедурой WriteConsoleA. Поскольку процедуры Windows API уничтожают переменную цикла есх, приходится сохранять ее в стеке и вос- станавливать инструкцией pop есх перед каждым новым оборотом цикла. Ре- гистр esi хранит относительный адрес числа в массиве buf. Каждый раз он уве- личивается на 4, потому что таков размер числа в байтах. Регистр esi не нужно сохранять в стеке, потому что об этом уже заботятся все процедуры Windows API (см. раздел «Повторение» главы 4). Программа из листинга 6.2, с которой мы только что познакомились, содержит, как и всякая другая, по крайней мере одну ошибку. Хорошо, что эта ошибка очевидна, и ее легко исправить. Дело в том, что, открывая файл процедурой CreateFile, мы были непростительно беспечны, считая, что файл с указанным именем действительно существует. Но представим себе, что это не так. Как по- ведет себя процедура, пытаясь открыть несуществующий файл, — пока не ясно. А между тем, в описании процедуры CreateFile сказано, что возвращаемое зна- чение будет в этом случае отличаться от всех возможных «нормальных» дескрип- торов файлов. Это значение равно INVALID_HANDLE_VALUE, и открывая файл, нужно всякий раз проверять, не равен ли ему полученный дескриптор. Задача 6.1. Перепишите программу из листинга 6.2 с учетом возможных ошибок процедуры CreateFile. Испытайте программу неправильным именем файла и убедитесь в том, что она и в этом случае ведет себя разумно.
98 Глава 6. Файлы Интернет — источник знаний Существует, как мы уже знаем, порядка полутора тысяч процедур Windows API. И в такой небольшой книге, как эта, невозможно рассказать даже о малой их части. Впрочем, едва ли стоит это делать в любой, пусть даже очень толстой книге. Гораздо удобнее использовать Интернет или справочные программы, ко- торые можно найти в том же Интернете. Чтобы, например, узнать подробнее о процедуре CreateFile, достаточно соеди- ниться с поисковой системой Google (www.google.com), набрать в поле поиска слово «createfile» (Google не различает строчные и прописные буквы) и уже первый результат поиска откроет нам справочник по Windows API на сайте msdn.microsoft.com (рис. 6.2). CreateFile - Microsoft Internet Explorer U В» до y&t 19Й W'm. 'Л 4 \ ' * > г л • • > . av«4 ч v гл чччч..Аг л-ч ./.• угчглг ’’л^лч svCvs5b чг .чг > г^ччгчйг a>v.v ч агар л Л .ч ww г asvaw чччч s Г^' Xs' W Э Ц' Seek Stop:. Home J http //msdn. microsoft com/librery/default а«р?иг1»ЛЫ«у/епчл/1ilero/base/createfйе. asp msdn All Product? | Support | Search | rnicrocott.corn Guid>- Mictvsoft MSDN Home | Developer Centers | Library | Downloads | Code (enter | Subscriptions | MSDN Worldwide V- '" ' 7 7л W \ 7 % -% ‘ ^*&**a* CreateFile г ж • ^yrtrf.tpc ф. X □ AreFileApisANSI w О Can cel Io Ж □ CheckNanneLegalDOS8Dot3 Ж □ CopyFile W □ CopyFileEx да» О CopyProgressRoutin© Щ Q;CreateFile; Щ Ш CreateFileMapping д □ CreateHardUnk |B Q CreateloCompletionport < ЦЦ □ DecryptFile Ц? Q DeleteFila IB The CreateFile function creates or opens a file, directory, physical disk, volume, console buffer, tape drive, communications resource, mailslot, or pipe. The function returns a handle that can be used to access the object. Windows Me/98/95: You cannot open a directory, physical disk, or volume using CreateFlle. 1JFSlCmTrJlTTK»WTX£ XAMH.S hTMtplAiAFil* Parameters IpFiteNsrrte Рис. 6.2. Подробные сведения о процедуре CreateFile В правой части окна браузера видна статья о процедуре CreateFile, а в левой — ссылки на другие процедуры API. Вся документация написана на «родном» анг- лийском языке, но для ее понимания достаточно выучить 200-300 слов*. По- смотрим же, как выглядит описание уже известной нам функции в «фирмен- ной» документации. ’Можно, конечно, найти материалы по Windows API и на русском языке. Но все равно большая часть документации написана на английском, и программисту никак без него не прожить. Чем раньше вы начнете учить английский, тем лучше.
Командная строка 99 Для тех, кто уже знаком с процедурой, достаточно посмотреть на ее прототип, чтобы вспомнить, как ей пользоваться: HANDLE CreateFIle( LPCTSTR IpFileName, DWORD dwDesiredAccess. DWORD dwShareMode. LPSECURITY_ATTRIBUTES 1pSecuri tyAttributes. DWORD dwCreationDisposition. DWORD dwFlagsAndAttributes. HANDLE hTemplateFile ): Описание процедуры начинается типом возвращаемого значения, затем идет ее имя и далее в круглых скобках — список параметров: HANDLE CreateFile(...): В нашем случае процедура CreateFi1е возвращает значение типа HANDLE — так обозначается в документации фирмы Microsoft дескриптор файла. Мы уже зна- ем, что это целое число, хранимое в регистре еах. Но слово HANDLE подсказывает нам, как это значение будет использоваться. Сами параметры процедуры указаны в той последовательности, в какой мы за- писываем их при ее вызове директивой invoke. Каждый параметр представлен двумя словами: первым идет название типа, вторым — образец имени перемен- ной. Эти имена содержат массу полезной информации, которая открывается только знающим английский язык. Например, имя первого параметра 1 pFi 1 eName ясно говорит нам, что он связан с именем файла. Ведь FileName — и есть в пере- воде с английского имя файла. Буквы 1 р означают long pointer, то есть длинный указатель. Под словом указатель понимается адрес, то есть результат действия операторов offset или ADDR. Что же касается слова long (длинный), то все адреса в Windows длинные. О коротких адресах мы узнаем в главе 8. После адреса нулевого элемента массива, хранящего имя файла, в описании функции идут два параметра, имеющих тип DWORD, то есть double word, двойное слово, или, проще говоря, четыре байта. Имя параметра dwDesiredAccess означает в переводе с английского желаемый доступ, а префикс dw говорит о том, что это двойное слово. Остальные параметры разбираются аналогично. СОВЕТ-------------------------------------------------------------------------------- Кроме справочников, расположенных на веб-сайтах, можно найти и целый файл в формате .hip («родном» формате справочных файлов Windows). Размер его громаден, даже в сжа- том виде он занимает около 8 Мбайт. Но лучше переписать его один раз, чем тратить время и деньги на блуждания по сайтам. Файл с описанием процедур Windows API называется win32api.zip, и скачать его можно, например, здесь: http://win32assembly.online.fr/files/win32api.zip Командная строка Задавать имя читаемого файла в исходном тексте программы, как мы это дела- ли в разделе «Чтение», крайне неудобно, ведь каждая смена имени требует пе- рекомпиляции программы. Поэтому консольными приложениями управляют,
100 Глава 6. Файлы помещая необходимые имена и параметры в командной строке — справа от име- ни самой программы. На рис. 6.3 показано, как выглядит передача параметра simple программе cl2.exe, находящейся в папке F:\asmtest\files. При запуске программы клавишей Enter ей будет передан адрес командной строки; узнать его поможет специальная про- цедура GetCommandLine. г zwj . 8108 [17.05ЛЗ J16:0S| • asa J $47)14.06 2,594.144 bytes in 13 files 28,013 bytes in 21 files Рис. 6.3. Командная строка программы Пользоваться этой процедурой учит программа из листинга 6.3, выводящая на экран собственную командную строку. Листинг 6.3. Вывод собственной командной строки .386 .model flat, stdcall option casemap:none i nclude \myasm\i nclude\wi ndows.i nc i nclude \myasm\i nclude\kernel32.i nc includellb \myasm\lib\user32.1ib includelib \myasm\1ib\kernel32.1ib LING equ 128 .data stdout DWORD ? дескриптор экрана cWritten DWORD ? ;число показанных символов CLIni DWORD ? :начало командной строки .code start: invoke GetStdHandle. : STD_OUTPUT_HANDLE mov stdout, eax invoke GetCommandLine :адрес командной строки mov CLIni. eax запомнить адрес командной строки mov edi. eax :edi = нач. ком. стр. cld .•будем увеличивать адрес mov ecx. LLNG ;макс. дл. ком. строки mov al. 0 :ищем 0 repne scasb :поиск нуля sub edi. CLIni :edi = длине строки invoke WriteConsoleA. stdout. CLIni. edi. ADDR cWritten. NULL invoke ExitProcess. 0 end start Как видим, процедура GetCommandLine не имеет параметров, а результат ее рабо- ты — адрес начала командной строки — оказывается в регистре еах. Полезно рассмотреть подробнее эту командную строку с помощью отладчика. Для этого перейдем в папку, где расположена программа (будем считать, что ее имя I63.exe), вызовем отладчик командой Ollydbg 163.exe simple
Командная строка 101 и «прокрутим» программу на несколько шагов вперед, так чтобы в регистре еах оказался адрес командной строки. Рисунок 6.4 как раз показывает такой мо- мент. OllyDbg - I63.exe 08401008:1 00401082:1 W40100?': .00Д0100С: : ЦШШЭВ] CALL <JMP.Jtkernel32.8etStdHandle> HOU DWORD PTR DS:[483888J,EAX 1 ineA> data b RRO ECX 817C8420 Memoty map .. . tLX .3^ EDX BFFC94C0 KERNELS?.BFFC94C0 EBX 80530800 EBP 't ....."" : 817BCD98: 8881F268 : 81884000: 00001000: ? 81885000:00001000; ; 81C84000;00001000: 0040183Й:| . E8 19000000 0040J83*: ----- 0040104); 00401041.: 0040104C: 00401052! 80401053: 00401Q5E: 0044» 35F: 00431368; 00431361: 00481862; лхлл i Л4 s: .. E8 00000000 .-FF25 8C284888 $-FF25 --------- J-FF25 I-FF25 00 00204000 04204000 08204000 И 1KCF8 65 73 317BCD00;65 22 3173CD10: 46 ЗА 317BCD20-00 08 8178CD80 00 00 817SCLM0!00 00 .......- ' ‘ 89 02 ЗА гиамг ‘wmr? wr t>srrni; CALL <JMP.tkernel32.l: PUSH 0 ^SJycrfvfriS CALL <JMP.tkernel32.l: * 9? JMP DWORD PTR DSt C<8d; uiir uwurtu rin и»: i.xe«i: — JMP DWORD PTR DS:C<M= 25 JMP DWORD PTR OStC<M:»1<6,-OS6|07 JtIP DWORD PTR DS: [<8.1: J 33 зе IB 00 KJ 08 w 88? SC DC 5C 56 02 73. 61? 00: 00: 00: 00: 03: 50: 33; 00; 66 69 6C 65 73 SC 6C 36 33 69 6D 70 6C W:: 00 00 00 18 73 6D 74 65: 73 74 gc 66 69 48 00 00 ------------- " 00 00 00 DC 0F 00 81 00 00 D2 95 00 32 46 45 10 B7 56 00 □3 88 чя aa татгт $; i i TC 00 00 00 00 00 00 0F 00 00 00 00 00 00 00 A0 DA..1 jeh«hrX..Tn E:\PROGRAMS\ULEA: DMJ32FE.DLL.a’ -.•.►nV4- 73 6D 745 2E 65 78iestxfiless 163 00 00 A8 e” slnDl«...f 00 00 00 00 00 00: 00 C2 0E 00 00? 88 8Д 00 00 00? 00?92 78 00 00 — 52?41 4D 53 SC 55 2E: 44 4C 4C 00:2C Cl 00 20 % & Рис. 6.4. Командная строка в памяти компьютера Как только в регистре еах оказывается адрес 817ВССЕ8, отладчик справа от него показывает строку символов, которая начинается с этого адреса. В нашем случае строка такова: "”f:\asmtest\fi1es\163.ехе" simple" В нее, как видим, попадает не только имя программы, но и путь к ней. Важно понять, как отладчик узнает о конце строки. Для этого изучим так называемый дамп памяти, то есть шестнадцатеричные коды и соответствующие им символы, находящиеся вблизи адреса 817ВССЕ8. Чтобы получить дамп памяти в нашем отладчике, выберем раздел меню View и далее — пункт Memory, после чего воз- никнет окно Memory Мар (показано в левой части рис. 6.4). В этом окне видны адреса участков памяти, доступных программе. Среди них нужно двойным щелчком мыши выбрать ближайший к тому, что нас интересует, и на экране появится окно Dump, видное в правой части рис. 6.4. Там показаны адреса и со- ответствующие им байты, представленные символами и шестнадцатеричными кодами. Выбрав символ мышью, увидим соответствующий шестнадцатеричный код, выделенный серым цветом. На рисунке выделена последняя буква слова simple. Ее шестнадцатеричный код равен 65. А следом за ним идет ноль — 00. Вот что помогает отладчику правильно показать командную строку! Воспользуемся и мы этим свойством, чтобы самим вывести командную строку на экран. Поскольку адрес начала строки нам известен, остается только найти адрес ее конца, что позволит узнать ее длину и вызвать затем процедуру WriteConsoleA. Чтобы найти нулевой символ, достаточно сравнить с нулем все символы начи- ная с адреса, выданного процедурой GetCommandLine. Если символ равен нулю, поиск окончен, если нет — адрес увеличивается на единицу и сравнение повто- ряется.
102 Глава 6. Файлы В программе из листинга 6.3 эти сравнения выполняет специальная инструкция scasb. Буква «Ь» в конце ее имени показывает, что сравниваются байты и одно- временно наводит на мысль, что можно сравнивать простые, 2-байтовые (scasw) и двойные (scasd) слова. Команда scasb сравнивает байт, чей адрес находится в регистре edi, с байтом из регистра al. Результат сравнения показывает флаг нуля Z, а регистр edi, незави- симо от результата сравнения увеличивается или уменьшается на единицу. В ка- кую сторону будет меняться edi, зависит от специального, еще неизвестного нам флага направления D (рис. 6.5). 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 О ------Направление Рис. 6.5. Флаг направления Когда флаг опущен, проверка идет в сторону увеличения адресов, когда под- нят — в сторону уменьшения. Чтобы задать направление поиска, существуют специальные команды, поднимающие (std) или опускающие (cld) флаг перено- са. В нашей программе (см. листинг 6.3) флаг опущен, задавая тем самым дви- жение в сторону увеличения адреса. Инструкция scasb проверяет текущий байт и увеличивает edi. Чтобы искать с ее помощью нулевой символ, используется префикс герпе, велящий инструкции scasb повторяться до тех пор, пока текущий байт, адрес которого находится в ре- гистре edi, не станет равен тому, который хранится в регистре al, или пока не станет равным нулю регистр есх. В регистре есх задается максимальная дистан- ция поиска, у нас она выбрана равной 128. Как видим, поиск с помощью scasb может прекратиться по двум причинам: достигнут конец строки, но заданный символ так и не найден; символ найден внутри строки. Чтобы убедиться в том, что символ найден, достаточно проверить после ин- струкций герпе scasb, поднят ли флаг Z. Если поднят, символ найден, если опущен — достигнут конец строки, но символа все нет: герпе scasb :поиск символа jnz not_found ;символ не найден В программе из листинга 6.3 такой проверки нет, потому что мы знаем: нулевой символ обязательно будет найден. Вместо проверки jne not found программа сразу находит длину строки, вычитая из текущего значения edi адрес начала строки, запомненный в переменной CLIni. Для проверки того, не ошиблись ли мы на единицу, представим себе строку, состоящую из одного нулевого симво- ла. Очевидно, повторение инструкции scasb прекратится уже на этом символе. Но scasb устроена так, что всегда «перелетает» на позицию вперед или назад (в зави-
Kiss-принцип 103 симости от направления движения). Когда поднялся флаг Z, scasb все равно уве- личивает edi и лишь тогда прекращает работу. Поэтому в edi после окончания поиска будет находиться адрес не завершающего нуля, а идущего следом за ним символа. Значит, вычитая из текущего значения edi адрес начала строки CLIni, то есть адрес нулевого символа, получим единицу, что и требовалось. Дальней- шее просто. После нахождения длины вызывается процедура WriteConsol еА, ко- торая показывает командную строку на экране. Kiss-принцип Стих, как монету, чекань Строго, отчетливо, честно, Правилу следуй упорно: Чтобы словам было тесно, Мыслям — просторно. Н. А. Некрасов. Подражание Шиллеру Конечно же, поэт ошибся. Словам должно быть просторно, мыслям же — тесно. Тогда удастся в немногих словах программы сказать очень многое. И ассемб- лер — самый подходящий для таких слов и мыслей язык. Чтобы убедиться в этом, попробуем иначе вычислить длину командной строки. В предыдущем разделе мы находили ее, казалось бы, оптимальным способом: вычитая из текущего значения edi запомненный адрес начала строки: sub edi. CLIni ;edi = длине строки Но ассемблер — очень хитрый язык, располагающий к разным фокусам и трю- кам. Чтобы показать, что запоминание начала строки излишне, вспомним, что поиск нулевого элемента гарантирован. Значит, можно сделать есх сколь угодно большим и не бояться, что процессор будет искать нулевой элемент вечно. Итак, решено: сделаем есх максимальным, то есть установим все его биты в еди- ницу. Теперь во время поиска есх будет уменьшаться на единицу и для достиже- ния нуля можно проделать более 4 миллиардов сравнений. Но нулевой элемент найдется гораздо раньше. Спрашивается: как по значению есх найти длину стро- ки? Очевидно, из начального (самого большого) значения нужно вычесть ко- нечное, ведь scasb «проскакивает» на шаг вперед. Выходит, нужно все-таки за- помнить начальное значение? А вот и нет! Вспомним, что число можно считать и положительным и отрицательным — в зависимости от того, что нам удобнее. Какому числу соответствуют все биты, установленные в единицу? Очевидно, это -1. После проверки нулевого элемента строки есх уменьшится на единицу и станет равен -2, затем -3 и т. д. Пусть, например, строка состоит из одного символа 0, стоящего в ее начале. Тогда есх после операции поиска будет равен -2. Выходит, для того, чтобы вычислить длину строки, нужно обратить знак есх и вычесть единицу. Изменение знака выполняет в ассемблере команда neg. Зна- чит, инструкции, оставляющие длину строки в есх, будут такими: push edi cld
104 Глава 6. Файлы mov ecx, -1 mov al, 0 repne scasb neg ecx dec ecx pop edi Перед поиском нуля приходится сохранять в стеке адрес начала строки edi, не- обходимый процедуре WriteConsolеА, ведь инструкция scasb «портит» edi. Только что полученный фрагмент программы можно «улучшить», если вспом- нить, что изменение знака связано с инвертированием (заменой всех единиц ну- лями и всех нулей единицами) всех битов в регистре и прибавлением единицы (см. раздел «Знак» главы 2). Инвертирование в ассемблере выполняет инструк- ция not. Значит, инструкцию neg можно заменить последовательностью: ;neg есх not есх inc есх Но в нашем отрывке следом за neg есх идет инструкция dec есх, уничтожающая эф- фект inc есх. Выходит, пара инструкций neg есх dec есх заменяется одной -- not есх. Окончательные инструкции поиска выглядят странно: push edi cld mov ecx. -1 mov al. 0 repne scasb not ecx pop edi но опытные программисты легко их поймут. Вообще, программистам следует держаться KISS-принципа (KISS — первые бу- квы английских слов Keep It Simple Stupid — делай проще, дурачок!). Чем скуч- нее написана программа, тем лучше — прежде всего самому ее автору. Ведь то, что кажется «крутым» и остроумным сейчас, через две недели станет просто не- понятным. Но ассемблер — особый язык, и границы непонятного в нем размы- ты. Любой приличный программист поймет и оценит трюк с вычислением дли- ны строки по изменению есх. Попытки нестандартного решения задачи кроме вреда приносят еще и пользу, потому что помогают «сродниться» с языком. Кроме того, изощренное программирование на ассемблере способно заставить программу, написанную на языке высокого уровня*, выполняться быстрее. Со- временные процессоры очень мощны, но и задачи, ими решаемые, становятся все сложнее. Поэтому быстродействия процессора всегда не хватает. Вот здесь и пригождается ассемблер. Как правило, большую часть времени занимает вы- полнение небольшого числа инструкций, создаваемых компилятором при пере- воде языка высокого уровня на ассемблер. Качество «перевода» обычно не очень высоко. Поэтому имеет смысл переписать эти немногие инструкции вручную. И здесь понятность отходит на второй план. Главной становится бы- строта выполнения. Вот почему строгое следование KISS-принципу не так важ- но в ассемблере, как в языках более высокого уровня. *К языкам высокого уровня относятся С, C++, Pascal, Basic — словом, все, что не ассемблер.
Открытие файла — 2 105 Открытие файла — 2 Теперь, наконец, все готово к тому, чтобы «изъять» имя файла из командной строки и открыть его цивилизованно, не касаясь исходного текста программы. Сделать это просто, зная, что имя файла находится в самом конце командной строки. Поэтому найдем сначала длину командной строки (от ее начала до за- вершающего нулевого символа), затем повторим поиск — на этот раз пробела. Символы, расположенные между пробелом и концом командной строки (нуле- вым символом и есть, очевидно, имя файла. Процедура, возвращающая адрес нулевого символа имени файла в регистре еах, показана в листинге 6.4. Листинг 6.4. Получение адреса начала имени файла GetFName proc 1nvoke GetCommandLine mov edi, eax push edi mov al. 0 mov ecx.-l cld repne scasb not ecx pop edi mov al,20h repne scasb dec edi cmp byte ptr [edi].0 jz empty inc edi mov eax,edi ret empty: mov eax.-l ret ret GetFName endp end start Сначала вызывается GetCommandLine, чтобы получить адрес командной строки. За- тем инструкцией cld задается поиск в сторону увеличения адресов. После того как ноль найден, необходимо вычислить длину командной строки инструкцией not есх, затем переместиться к ее началу и вновь искать — на этот раз символ про- бела, равный 201б. Этот поиск может закончиться двояко: либо пробел будет най- ден, и тогда, с учетом того, что инструкция scasb «проскакивает» на шаг вперед, edi будет хранить начальный адрес имени файла, либо пробела в командной стро- ке не окажется, и тогда мы перепрыгнем через командную строку и edi будет ука- зывать на символ, стоящий сразу после завершающего нуля. Значит, нужно после поиска пробела уменьшить edi и проверить, не равен ли нулю символ, чей адрес edi хранит: cmp byte ptr [edi],0 :есть имя файла? Если это так, никакого имени файла в командной строке нет и процедура воз- вращает -1 — признак ошибки. Если же символ, на который указывает edi, —
106 Глава 6. Файлы не нулевой, значит, пробел найден, а символы, стоящие правее его, и есть имя файла. Поэтому edi нужно в этом случае увеличить на единицу, присвоить реги- стру еах и вернуть основной программе. Создав процедуру GetFName, легко написать и всю программу, которая пробует открыть указанный файл, сообщая только о неудачах: отсутствии файла в теку- щей папке или о том, что имени файла вообще нет в командной строке (см. лис- тинг 6.5). Листинг 6.5. Открытие файла .386 .model flat, stdcal1 option casemap:none 1nclude \myasm\1nclude\wi ndows.1nc 1nclude \myasm\i nclude\kernel32.1 nc includellb \myasm\lib\user32.1ib i ncludeli b \myasm\1i b\kernel32.1i b GetFName proto .data fHandle DWORD ? stdout DWORD ? cWritten DWORD ? error BYTE "Нет такого файла" noname BYTE "Укажите имя файла" .code start: main proc invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax invoke GetFName cmp еах, -1 jz empty invoke CreateFile, eax, GENERIC READ, 0. NULL, OPEN EXISTING, FILE ATTRIBUTE_NORMAL, 0 cmp eax,INVAriD_HANDLE_VALUE jz exit mov fHandle. eax invoke CloseHandle. fHandle invoke ExitProcess. 0 exit: invoke WriteConsoleA. stdout, ADDR error, sizeof error. ADDR cWritten, NULL invoke ExitProcess, 0 empty: invoke WriteConsoleA. stdout. ADDR noname, sizeof noname, ADDR cWritten. NULL invoke ExitProcess. 0 main endp GetFName proc empty: GetFName endp end start
Прогулки по файлу 107 В листинге 6.5 внутренности процедуры GetFName не показаны. Исключение сде- лано лишь для метки empty, чтобы яснее стали видны две метки с таким назва- нием — одна в процедуре GetFName, другая — в основной программе. Эти одинаковые метки ассемблер принимает совершенно спокойно, потому что считает все метки внутри процедуры локальными. Но чтобы не возникло пута- ницы, нужно и основную программу сделать подобием процедуры. В листинге 6.5 она получила название main и, как и всякая процедура, завершается словами main endp. Две одинаковые метки оказались «запертыми» в разных процедурах и теперь они навсегда разлучены. Им ничего не известно друг о друге. «Локальность» меток очень важна, потому что позволяет не думать о стандарт- ных именах. Например, в процедурах часто есть метка, куда отправляется про- цессор в случае ошибки или, наоборот, благополучного завершения процедуры. Первую можно все время называть error (ошибка), вторую — exit (выход), не беспокоясь о том, что в соседней процедуре они названы так же. Но все-таки иногда нужно преодолеть локальность метки, сделав ее доступной всем процедурам. Для этого ее имя дополняется справа парой двоеточий: global:: ;метка, доступная всем процедурам Прогулки по файлу До сих пор мы читали и записывали файлы целиком — от начала до самого кон- ца. Но так бывает далеко не всегда. Иногда необходимо пропустить начало фай- ла или записать что-то в его середину. Чтобы проделать такое, нужно понимать, что файл читается последовательно. Прочитать десятый байт можно только пе- реместив к нему специальный указатель. Этот указатель автоматически переме- щается при чтении-записи файла, поэтому для чтения десятого байта можно прочитать предыдущие девять. А можно ничего не читать, а просто переместить указатель с помощью процедуры SetFi 1 ePoi nter. Посмотрим, как работает эта процедура, на примере редактирования файла cook, содержащего фразу: ПЕРЧИТЬ НЕЛЬЗЯ СОЛИТЬ В этой фразе пропущена запятая, от правильного положения которой, зависит судьба блюда. Будем считать, что запятая должна стоять после перчить. Тогда нашу задачу решает программа, показанная в листинге 6.6. Листинг 6.6. Редактирование файла .386 .model flat, stdcal1 option casemap:none include \myasm\i nclude\w1 ndows.1nc include \myasm\1nclude\kernel 32.1 nc includellb \myasm\11b\user32.1ib Includellb \myasm\lib\kernel32.lib BSIZE equ 128 .data fName BYTE ”cook".0 fHandle DWORD ? продолжение &
108 Глава 6. Файлы Листинг 6.6 (продолжение) cRead eWrite DWORD ? DWORD ? cWrltten DWORD ? comma buf .code start: BYTE BYTE BSIZE dup (?) invoke CreateFile, ADDR fName, GENERIC READ+GENERIC WRITE, 0. NULL. OPEN EXISTING. FILE ATTRIBUTt_NORMAL. 0 mov invoke fHanSle, eax SetFilePointer, fHandle, 7, NULL,FILE_BEGIN invoke ReadFile, fHandle. ADDR buf. BSIZE. ADDR cRead, NULL invoke SetFilePointer, fHandle. 7, NULL,FILE_BEGIN invoke WriteFile. fHandle, ADDR comma, • 1, ADDR eWrite. NULL invoke WriteFile, fHandle, ADDR buf. cRead, ADDR eWrite, NULL Invoke CloseHandle, fHandle invoke ExitProcess, 0 end start Все начинается в ней с привычного уже открытия файла процедурой CreateFile. Но теперь мы решились редактировать файл, поэтому приходится открывать его на чтение и запись, для чего комбинируются два параметра — GENERIC READ и GENERIC_WRITE. Запомнив дескриптор файла, займемся перемещением указателя процедурой SetFilePointer. Этот указатель можно представить себе как флажок, помечаю- щий соответствующий байт, но для процессора существуют только числа. По- этому позиция указателя — тоже число со знаком, хранящееся в двойном слове. Отрицательное число означает перемещение указателя назад, положительное — вперед. Чтобы перемещение стало однозначным, нужно задать точку отсчета. Для процедуры SetFilePointer их существует три: FILE_BEGIN (от начала файла), FILE_END (от конца) и FILE_CURRENT (от текущей позиции указателя). Зная все это, легко догадаться, что вызов процедуры: invoke SetFilePointer, fHandle, 7. NULL. FILE_BEGIN означает перемещение указателя на 7 байт вперед относительно начала файла. Отсчет байтов в файле начинается с нуля, поэтому нулевое положение указате- ля соответствует букве П, первое — букве Е, а указатель, равный семи, помечает пробел, стоящий сразу за словом ПЕРЧИТЬ. Именно туда нужно вставить запятую, но прежде необходимо прочитать и сохранить остаток фразы. Этим занимается процедура ReadFile: invoke ReadFile, fHandle. ADDR buf. BSIZE, ADDR cRead, NULL Символы, начиная с пробела и кончая словом СОЛИТЬ, читаются в массив buf. Их число BSIZE задано заведомо большим, все равно прочитать больше символов,
Прогулки по файлу 109 чем есть, нельзя, и верное их число сохранится в переменной cRead. После чте- ния «хвоста» файла указатель перемещается в самый его конец и показывает на несуществующий символ, стоящий непосредственно за мягким знаком в слове СОЛИТЬ. Но нам необходимо поставить запятую сразу за словом ПЕРЧИТЬ, поэтому вновь вызывается процедура SetFilePointer, перемещающая указатель на семь позиций вперед относительно начала файла. Если теперь записать один символ в файл, то он встанет туда, куда показывает указатель, то есть на место пробела, стояще- го за словом ПЕРЧИТЬ. В нашей программе процедура WriteFile пишет туда запя- тую, после чего указатель продвигается на шаг вперед, и теперь у него восьмая позиция относительно начала файла. Чтобы закончить редактирование, доста- точно записать в файл сохраненный в массиве buf фрагмент, что и делает по- следняя инструкция WriteFile. Задача 6.2. Как записать в файл запятую, задавая ее положение не от начала файла, а от текущей позиции указателя файла?
Глава 7 Дроби Нужно держаться корней Находя простые числа в главе 4, мы использовали самый тупой из всех возмож- ных алгоритмов: делили каждое число-кандидат N на все числа от 2 до ЛГ - 1, и если ни одно из них не делилось нацело, справедливо считали число W простым. Между тем, почти половина делений была заведомо напрасной, потому что де- лить на числа, превышающие N/2, не имеет смысла? и если, скажем, при испы- тании числа 17 деления на числа от 2 до 8 не дали нулевого остатка, то деление на числа от 9 до 16 можно не проводить. Но и это не предел. Оказывается, прекращать деление можно при достижении целочисленного значения JN. Ведь если число делится на корень из себя, то в нем два равных сомножителя N = х 777. Если же делить на число, большее чем 777, то второй сомножитель (в случае деления нацело) будет уже меньше, чем 4N. Но ведь, проверяя число на «простоту», мы уже поделили его на числа, меньшие 777, и нашли, что таких сомножителей нет! Значит, деление на числа, большие у/N, бессмысленно. Возьмем, к примеру, число 17. Целочисленное зна- чение корня из 17 равно 4. Проверяя числа 2, 3, 4, найдем, что 17 на них не де- лится. Предположим теперь, что 17 делится на числа, большие 4, например на 5. В этом случае число 17 можно представить в виде произведения 5 * х, то есть 17 должно делиться нацело не только на 5, но и на х. Но х заведомо меньше 4 (5 х 4 = 20), а с такими числами мы уже сталкивались и 17 на них не дели- лось. Значит, дальнейшие проверки бесполезны и 17 — простое число. Итак, для нахождения простых чисел (и для множества других задач) необходи- мо вычислять корни из чисел, а поскольку они далеко не всегда целые, нужно еще уметь представить их последовательностью нулей и единиц, потому что ни- чего другого в компьютере просто нет. Эта задача легко решается, если сообразить, что степени двойки, применяемые в двоичном коде, могут быть не только положительными, нулевыми, но и отри- цательными. Договорившись, где в регистре находится граница между положи- тельными и отрицательными степенями двойки, можно хранить там дробные
Нужно держаться корней 111 величины. Если предположить, что в 8-битовом регистре точка разделяет тетра- ды (старшие и младшие четверки битов), то число 11111111 будет равно: 2з + 22 + 21 + 2° + 2*1 + 2*2 + 2'3 + 2*4 = 1111.11112 = = 8 + 4 + 2 + 1 + 1/2 + 1/4 + 1/8 + 1/16 = 15.937510 Так кодируются числа с фиксированной точкой. Можно взять гораздо больше битов, но все равно их не хватит для хранения огромных или ничтожно малых чисел, легко возникающих при умножениях или делениях. Вот почему дроби часто представляются в виде произведения числа с фиксированной точкой (мантиссы) на множитель, равный двойке в какой-то степени (положитель- ной для больших и отрицательной для маленьких чисел). Этот множитель легко умещается в нескольких битах, если хранить только степень, а саму двойку «подразумевать». Например, 2100 — астрономическое число, но для хранения числа 100 достаточно 7 бит. Если показатель степени, часто называемый экспонентой, позволяет предста- вить очень большие числа или очень малые числа, то мантисса обеспечивает точность такого представления. Кроме мантиссы и экспоненты нужен еще и бит, кодирующий знак числа. Все три компоненты (знак, экспонента и мантисса) за- нимают непрерывный участок памяти и составляют вместе число с плавающей точкой, которое может храниться в 32, 64 или 80 бит. «Плавать» точку застав- ляет экспонента: ведь увеличение степени двойки смещает точку влево (поду- майте, почему), а уменьшение — вправо. Умение точки «плавать» приводит к тому, что одно и то же число можно пред- ставить многими способами. Например, 16 можно записать как 24 * 1.0 или же как 25 * 0.1 (0.12 — это двоичное число с фиксированной точкой, равное 2"1 - 1/2). Чтобы устранить эту неоднозначность, принято считать, что «нормальная» ман- тисса всегда меняется от 1 до 2. Поэтому в памяти хранят только ее дробную часть l.xxxxxxxx, а единицу приписывают потом. Числа с такой мантиссой на- зывают нормализованными, и процессор всегда стремится преобразовать резуль- таты вычислений к такому виду. На рис. 7.1 показано, как представлены в ком- пьютере 32- и 64-битовые числа с плавающей точкой. Знак Знак Одинарная точность (32 бит) Двойная точность (64 бит) Рис. 7.1. Формат чисел с плавающей точкой Задача 7.1. Оцените максимальное число десятичных знаков после запятой, а также диапазон чисел с одинарной и двойной точностью. Задача 7.2. Сколько различных чисел с плавающей точкой умещается в чис- ле с одинарной и двойной точностью? Как видим, числа с плавающей точкой довольно сложно устроены и к ним нель- зя сразу применить обычные арифметические инструкции. Если бы мы вздумали
112 Глава 7. Дроби складывать или умножать числа с плавающей точкой, пользуясь инструкция- ми mul, div, add, sub, то пришлось бы выделять мантиссу и экспоненту, произ- вести кучу вспомогательных действий и потом снова упаковать число в 32 или 64 бит. Вот почему всю эту кропотливую, утомительную, а главное, требующую множе- ства вычислений работу берет на себя процессор. В нем, оказывается, есть спе- циальные инструкции и регистры для обработки чисел с плавающей точкой. Некоторое представление о них дает программа, вычисляющая квадратный ко- рень из числа 17 (листинг 7.1). Листинг 7.1. Вычисление квадратного корня из числа .386 .model flat,4 stdcal 1 option casemap:none i nclode \myasm\i nclude\windows.i nc 1nclude \myasm\1nclude\kernel32.1 nc include \myasm\i nclude\fpu. i nc includellb \myasm\lib\user32.11b 1ncludel1b \myasm\11b\kernel32.11b Includellb \myasm\lib\fpu.lib BSIZE equ 30 .data sqroot TBYTE ? digit DWORD 17 stdout DWORD ? cWrltten DWORD 7 buf BYTE BSIZE dup (?) .code start: main proc Invoke GetStdHandle, STD_OUTPUT_HANDLE mov stdout, eax fild digit загружаем целое в регистр fsqrt вычисляем корень fstp sqroot сохраняем в 80 бит Invoke FpuFLtoA. ADDR sqroot, 10, \ ADDR buf, SRC1_REAL or SRC2_DIMM invoke WriteConsoleA, stdout, ADDR buf, \ BSIZE. ADDR cWrltten. NULL invoke ExitProcess. 0 main endp end start В «сердце» этой программы находятся три инструкции: fiId digit загружаем целое в регистр fsqrt вычисляем корень fstp sqroot сохраняем в 80 бит загружающие целое число 17 в специальный регистр (fild digit), вычисляющие корень (fsqrt) и сохраняющие результат в 80 бит под именем sqroot (fstp sqroot). Полученный корень затем выводится на экран процедурой FpuFLtoA, которая мо- жет работать только с 80-битовыми числами. Эта процедура входит в специаль- ную библиотеку fpu.lib, подключаемую, как и остальные библиотеки, в начале нашей программы.
Нужно держаться корней 113 У процедуры FpuFLtoA четыре параметра: адрес отображаемого числа (ADDR sqroot), количество десятичных знаков после запятой (у нас — 10), адрес буфе- ра, где окажутся символы, в которые превратится число и, наконец, константы, управляющие работой процедуры. Константа SRC1REAL говорит функции, что ее первый параметр — это адрес 80-битового числа, хранящегося в обычной памя- ти. Обратите внимание на директиву TBYTE: sqroot TBYTE? Так в ассемблере объявляется 10-байтовая переменная (с буквы «t» начинается английское слово ten (десять)). Константа SRC2 DIMM указывает функции, что второй ее пара- метр — просто число. В нашем случае это 10. Раз существуют такие константы, разумно предположить, что первый и второй параметры процедуры могут быть другими, но об этом поговорим чуть позже. А сейчас будет полезно подсмотреть за программой с помощью отладчика. На рис. 7.2 показано состояние регистров после выполнения команды загрузки чис- ла fl 1 d digit. OllyDbq - «qw3.exe - [CPU - main thread, module SQW3) x 00401002: 00401007: 9949x00:;; 0040101Й! 9040101г: 00401024: 00401026: 00401020= 00401000 00401002 0040100? 80401009; 0040103:: j 00481044 88431849; 83481343: 80401050! 88481856; 8848185C! 00401062; 83431863; 33431364 80401065; 83431366.: 33431367: 33431363: 30401063 0040136Й; 00401063! 0040J06C: E3 4₽ЖШ iCOLL zJHP.Sk«r:»»132.S*tStdH«.i; oo CE284eue muu ciwuhu fir osat40300cj.lhx :)385 8A384888:FB.D DWORD PTR &Ssf40300«J Ж0 ' -Ж - ' ' , • :)B3D 88384888: FS5P •8?;^ PTR OS: (4333005 •SO 82030888 ...... 63 16384888 60 80 63 88384888 :: 3 48000800 68 08 53 12384008 PUSH S00 PUSH S0M0.00400016 PUSH 0Й PUSH 00400008 CAU $000.004010?0 PUSH 0 PUSH SOWS.00400010 . 63 16384888 ... ........ --....---- . 31:384008; PUSH DWORD HR OS: (4&S00E? . ::3 I 5008800 : CPU \ JMP. Sk Si. Wr it<*Co*sol<»f«> : . 6P 88 -PUSH 8 : .. E3 08000000 i CALL \..1МР.6к«л<?;3£,Е>. itProc<?SS> : .-FF25 00204000JMP CWRD PTR DS: UU^mclSS.EnixPrcoes: S-FF25 00204000: JMP DWO^D PTR DS: (32. &<?’$. cRsnc 3.-FF25 04204000: JMP DWORD PTR DS: (<3A4r*«332.b:: K*Ccft»c: CC : INTS : CC : INTS : CC iINTS : CC !INTS CC :INTS inti rrii ECX 0063FF68 EDX BFFC94C0 KERNEL32.BFFC94C0 EBX 08630080 ESP 0063FE3C EBP 0063FF78 ESI 81828ЕБ8 EDI 00000000 EIP 00481812 SQUI3.08401012 ES 0167 32bit 0(F7O0) CS 015F 32bit 0(FFFFFFFF) SS 0167 32bit 0CF7O0) DS 0167 32bIt 0CF7O0) FS 62D7 16bit 818199C0O7) QS 0000 NULL LastErr ERROR_SUCCESS (80000000) EFL 00808086 iO.NBtNE,A,S,FE,eE,8) ST0 valid 17.0008000080080««j00 STI cr.pty 0.0 ST2 wpty 0.0 ST3 wpty 0.0 ST4 C'Hpty 0.0 STS wptj» 0.0 ST6 C'Hpty 0.0 ST7 vHpty 1.157-?0-'8074974О7$есее“СЕ FST $080 Cond 0000 Err 00000000 FCW 02?F Prec NEAR.63 Mask 1 1 i 1 • 1 S 1 Рис. 7.2. Регистры, хранящие числа с плавающей точкой Как видите, инструкция fild загружает целое число в один из регистров, храня- щих числа с плавающей точкой. Всего в процессоре 8 таких регистров, носящих имена STO, STI,... ST7. Число 17 оказывается в регистре STO (рис. 7.2, правый ниж- ний угол). Перед загрузкой оно преобразуется в специальный формат с плаваю- щей точкой и занимает уже не 4, а 10 байт — таков размер регистров ST0-ST7. В сущности, это совсем другое число, потому отладчик и пишет 17.000000*, а не просто 17. После загрузки числа наступает черед инструкции fsqrt, извлекающей из него корень, который занимает место самого числа в регистре STO. Наконец, третья команда fstp sqroot переписывает корень из регистра STO в обычную 10-байто- вую область памяти. Затем его «подхватывает» процедура FpuFLtoA, расшифро- вывает и записывает в буфер последовательность символов 4.1231056256 с де- сятью, как указано, знаками после запятой. А уж как работает процедура WriteConsole, мы знаем.
114 Глава 7. Дроби Процессор и сопроцессор Мы такие разные, но все-таки мы вместе! Рекламный слоган Регистры и команды процессора, ответственные за «перемалывание» чисел с пла- вающей точкой, столь отличны от других команд и регистров процессора, что будет лучше говорить о них как об отдельном устройстве, называемом сопроцес- сором. Давным-давно, когда трудно было уместить все в одной микросхеме, это и были отдельные устройства, работавшие независимо друг от друга. Програм- мисту приходилось даже использовать команды ожидания wait (подождать про- цессор) и fwait (подождать сопроцессор), чтобы «притормозить» одно устройст- во, когда ему необходимы были результаты работы другого. Эта независимость сохранилась и сейчас, когда «такие разные» процессор и сопроцессор располо- жились на одном кристалле. Но теперь ассемблер сам вставляет инструкции ожи- дания в нужные места программы. Чем же так отличаются процессор и сопроцессор? Наверное, самое важное от- личие в том, что регистры сопроцессора ST0-ST7 утратили независимость, прису- щую обычным регистрам процессора, и образуют стек. Загружаемое в сопроцес- сор число, попадает на вершину стека, при этом числа, уже хранящиеся в других регистрах, смещаются на шаг от вершины. В стеке могут храниться восемь чисел — столько, сколько в нем регистров. Попытка загрузить в стек девятое число при- ведет к потере числа, далее всего отстоящего от вершины. Но и вершина при этом не воспримет то, что в нее загружается, и будет содержать некое значение, которое с точки зрения сопроцессора не может быть числом. На рис. 7.3 показа- но состояние регистров сопроцессора после загрузки девяти чисел 1, 2, 3, ..., 9. ST0 bad -№N FFFF С0008000 00000000 STI valid 0.0000000000000000000 ST2 valid 7.0000000000000000000 STS valid 6.0000000000000000000 ST4 valid 5.0000000000000000000 STS valid 4.0000000000000000000 ST6 valid 3.0000000000000000000 ST7 valid 2.0000000000000000000 Рис. 7.3. Сопроцессор хранит только 8 чисел Первым в сопроцессоре оказалось число 1.0. Оно заняло вершину стека, то есть ре- гистр STO. Далее на вершину стека попало загруженное вторым число 2.0, а число 1.0 спустилось ниже — в регистр ST1. Затем на вершине стека побывали числа 3.0, 4.0, 5.0, 6.0, 7.0, 8.0. Как видим, стек сопроцессора, в отличие от обычного стека, распо- лагается в регистрах сопроцессора, а не в оперативной памяти, и растет в противопо- ложную от неподвижной вершины сторону, так что число 1.0, попавшее в стек пер- вым, спускалось все ниже и оказалось, наконец, в регистре ST7, когда на его вершине было число 8.0. Но при попытке запихнуть в стек девятое число случи- лась авария: единица, загруженная первой, покинула стек, а на вершине оказалось неверное значение, помеченное словом bad (в переводе с английского плохой). Кроме «плохих», в стеке могут быть нормальные числа, помеченные словом valid. Таковы все числа, видными на рисунке, кроме первого. У регистров ST0-ST7 может быть еще один атрибут empty. Так помечается регистр, в который можно
Процессор и сопроцессор 115 загрузить число. Если регистр занят, то его нужно перед использованием осво- бодить. Делается это инструкцией ffree. Чтобы, например, освободить третий ре- гистр, нужна инструкция ffree ST(3). Есть еще одна инструкция finit, которая освобождает все регистры и чаще всего используется для приведения стека в не- кое исходное состояние, от которого удобно «плясать». Знакомясь с устройством сопроцессора, читатель, наверное, не раз уже говорил себе: «почему, по какой причине сопроцессор устроен так странно, так непохоже на обычный процессор, работающий хоть и с целыми, но тоже числами»? Чтобы ответить на этот вопрос, попробуем вычислить с помощью сопроцессора раз- ность произведений (alpha*beta - delta*gamma). Листинг 7.2. Сопроцессор вычисляет разность произведений .386 .model flat, stdcall option casemap:none 1nclude \myasm\i nclude\wi ndows.1nc i nclude \myasm\1nclude\kernel32.i nc include \myasm\include\fpu.inc Includelib \myasm\lib\user32.1ib includelib \myasm\lib\kernel32.1ib includelib \myasm\lib\fpu.1ib BSIZE equ 30 .data alpha DWORD 0.5 beta DWORD 1.37 gamma DWORD 220.0 delta DWORD 0.65 stdout DWORD ? cWritten DWORD ? buf BYTE BSIZE dup (?) .code start: main proc finit fid alpha fid beta fmul fid gamma fid delta fmul fsub invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax invoke FpuFLtoA. 0. 10. ADDR buf. SRC1_FPU or SRC2CIMM invoke WriteConsoleA. stdout. ADDR buf. \ BSIZE. ADDR CWritten. NULL invoke ExitProcess. 0 main endp end start Программа, показанная в листинге 7.2, сначала инициализирует сопроцессор ин- струкцией finit. Затем помещает в стек с помощью команд fid два первых сомно- жителя: fid alpha fid beta
116 Глава 7. Дроби После загрузки в стек число alpha окажется в регистре STI, a beta — на вершине стека в регистре ST0. Теперь настает черед инструкции fmul, умножающей ST1 на ST0, помещающей результат умножения в ST1 и затем выталкивающей из стека значение beta, оставшееся на вершине. Иными словами, после инструкции fmul на вершине стека окажется произведение alpha * beta, а сами значения alpha и beta, более нам не нужные, покинут сопроцессор. Теперь можно загрузить вто- рую пару сомножителей: fid gamma fid delta после чего на вершине стека окажется delta, в регистре STI — gamma, а в реги- стре ST2 — произведение alpha * beta, которое вытесняется к окраинам стека, но не теряется, и после второй инструкции fmul на вершине окажется произ- ведение delta * gamma, а регистре ST1 — произведение alpha * beta. Легко дога- даться, что следующая инструкция fsub вычтет из регистра ST1 содержимое регистра ST0 и поместит результат этой операции на вершину стека в регистр ST0. Как видим, стековая организация сопроцессора очень удобна для вычислений, потому что пара операндов, загнанная в стек естественно заменяется результа- том действия над ней. А сам результат легко Сохраняется в стеке и может участ- вовать в следующих действиях. Для регистров, образующих стек, идеальна так называемая обратная польская запись, когда сначала идут операнды, а следом за ними — знаки действий. Наша сумма произведений запишется на обратный польский манер следующим образом: alpha beta ★ gamma delta ★ - Сопроцессору очень легко понять такую запись: каждое имя переменной озна- чает помещение в стек, а каждый знак действия говорит о том, что берутся два операнда (один — из вершины стека, другой — ближайший к ней), и результат действия, вытесняя один из операндов, оказывается на вершине. По сути, программа из листинга 7.2 как раз и использует такую запись, полу- ченную интуитивно, вручную. Но есть специальные процедуры, которые авто- матически преобразуют формулы в обратную польскую запись, поступающую на вход сопроцессора. Слово состояния К сожалению, арифметические вычисления не всегда можно свести к обратной польской записи. Иногда нужно оставить один из операндов в стеке или изме- нить порядок действий (например, вычислить разность ST0 - ST1) или же ис- пользовать операнд, хранимый вдалеке от вершины. Чтобы все это стало воз- можным, команды сопроцессора используют явно заданные аргументы, при- чем один из них обязательно должен быть вершиной стека. Например, инст- рукция: fsub ST(3). ST вычисляет разность ST(3) - ST(0) (вместо ST0 можно писать просто ST), помещает результат в ST(3), и при этом ничего не делает со стеком. Чтобы инструкция, чьи
Слово состояния 117 аргументы указаны явно, освобождала вершину стека, ей необходим суффикс «р», обозначающий команду pop: fsubp ST(3). ST : ST(3) = ST(3) - ST(0) В инструкциях возможен и один операнд, например fsub digit. Такая инструкция понимается, сопроцессором как команда вычесть из вершины стека число digit, которое может занимать 4 или 8 байт обычной памяти. Результат оказывается на вершине стека. Заметим, что ассемблер не примет суффикс «р» в команде fsubp digit, потому что вычисление разности и немедленное ее удаление из стека — операция бессмысленная, даже для такого «тупицы», как сопроцессор. Чтобы понять, как работают разные команды сопроцессора, попробуем найти корни квадратного уравнения х2 + рх + q, вычисляемые по формулам I 2 П IP root.± J----------q. 12 2 V 4 Программа, показанная в листинге 7.3, решает квадратное уравнение прир = -6, q = 5. Как будет меняться состояние стека после выполнения инструкции со- процессора, показано на рис. 7.4. Листинг 7.3. Решение квадратного уравнения .386 .model flat, stdcall option casemap:none 1nclude \myasm\1nclude\windows.1nc include \myasm\include\kernel32.inc i nclude \myasm\i nclude\fpu.1nc includelib \myasm\lib\user32.1ib i ncludeli b \myasm\1i b\kernel 32.1i b includelib \myasm\lib\fpu.1ib BSIZE equ 30 .data p DWORD -6.0 q DWORD 5.0 two DWORD -2.0 rootl TBYTE ? root2 TBYTE ? stdout DWORD ? cWritten DWORD ? buf .code start: finit fid p fld two fdi v fid ST fmul fid q fsub fsqrt fid p fid two fdiv fid ST BYTE BSIZE dup (?) :ST=p ;ST = -2. ST(l) = p ;ST(1) = p/2 ;ST(0) = ST(1) = p/2 ' ;ST(1) = ST(l)*ST(0) = p2/4 ;ST(0) = q :ST(0) = (p2/4) - q ;>/((p74) - q)) :ST(0) = p : -2.0 ;ST(0) = -p/2 :ST(1) = ST(0) fsub ST.ST(2) :ST = -p/2 - V((p74) - q)) продолжение &
118 Глава 7. Дроби Листинг 7.3 {продолжение) fstp rootl сохранить корень fadd ST.ST(l) :ST - -р/2 + л/((р2/4) - q)) fstp root2 сохранить корень invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax invoke FpuFLtoA. ADDR rootl. 10. \ ADDR buf. SRC1_REAL or SRC2_DIMM invoke WriteConsoleA. stdout. ADDR buf. \ BSIZE. ADDR cWritten. NULL invoke FpuFLtoA, ADDR root2, 10. \ ADDR buf, SRC1_REAL or SRC2_DIMM Invoke WriteConsoleA. stdout. ADDR buf. \ BSIZE. ADDR CWritten. NULL Invoke ExitProcess. 0 end start p ST(0) p ST(0) fid р ST(1) fid p Jp2/4-q STU) ST(2) ST(2) -2.0 ST(0) -2.0 ST(0) fid two P ST(1) fid two P STU) ST(2) 4p2/4~4 ST(2) -P/2 ST(0) -P/2 ST(0) fdiv STU) fdiv Jp2/4-q ST(1) ST(2) ST(2) -P/2 ST(0) -P/2 ST(0) fid ST -P/2 STU) fid ST -p/2^ ST(1) ST(2) Vp2/4-q ST(2) P2/4 ST(0) fsub ST. ST (2) -р/2-yjp2/4-q ST(0) fmul ST(1) -P/2 ST(1) ST(2) /p2/4-q ST(2) q ST(0) -p/2 ST(0) fid q p2/4 STU) fstp rootl 7p2/4-q ST(1) ST(2) ST(2) p2/4-q ST(0) fadd ST, ST (1) -p/2 + 7₽2/4-q ST(0) fsub ST(1) Vp2/4-q ST(1) ST(2) ST(2) 7p2/4-q ST(0) >/p2/4-q ST(0) fsqrt ST(1) fstp root2 ST(1) ST(2) ST(2) Рис. 7.4. Состояние регистров после команд сопроцессора
Слово состояния 119 Все эти инструкции мы уже неплохо знаем, за исключением fdiv, по умолчанию делящей ST( 1) на ST, и, быть может, fid ST, просто копирующей вершину стека. Программа из листинга 7.3, несмотря на свой приличный размер, никак не за- щищается от отрицательного значения (р2/4) - <у, которое получается при от- сутствии действительных корней уравнения. Как поведет себя сопроцессор при попытке вычислить корень из отрицательного числа, мы пока не знаем. Но ясно, что ничего хорошего из этого не выйдет. Поэтому нужны инструкции, проверяющие значения в регистрах сопроцессора, подобно обычным инструкциям test и стр. В сопроцессоре такая инструкция на- зывается ftst. Не имея аргументов, она просто сравнивает вершину стека с ну- лем. Результат сравнения хранится в трех битах С2, С1, СО специального слова со- стояния сопроцессора (рис. 7.5). 15 8 О 1 1 1 1 । ! С2 С1 со ST>0.0 ООО ST<0.0 0 0 1 ST-0.0 1 о о ST=? 1 1 1 Рис. 7.5. Слово состояния сопроцессора и возможные результаты проверки инструкцией ftst Как видим, отрицательное или неверное значение вершины стека получается при единичном бите СО. Чтобы проверить этот бит, достаточно прочитать слово со- стояния в обычное 2-байтовое слово и затем проверить младший бит старшего байта. Все это проделывают инструкции, показанные в листинге 7.4. Листинг 7.4. Проверка вершины стека fsubp ST(1),ST ftst проверить вершину стека fstsw ах прочитать слово состояния shr ah. 1 :С0 -> флаг переноса jc exit ;Если число < 0 - выход fsqrt :нет - вычисляем корень Инструкция ftst проверяет слово на вершине стека, а инструкция ftstw ах, пере- писывает слово состояния, содержащее результат проверки в регистр ах. Сдвиг регистра ah на шаг вправо shr ah, 1 помещает восьмой бит слова состояния во флаг переноса, а инструкция jc exit отправляет процессор к метке exit, когда этот флаг поднят. Если же флаг опущен, число на вершине стека не отрицатель- но, и к нему применима операция извлечения корня fsqrt.
Глава 8 Модульность Объектные файлы Возьмемся за руки, друзья Чтоб не пропасть поодиночке. Булат Окуджава. Союз друзей Небольшую программу, занимающую один-два экрана монитора, удобно хранить в одном файле. Там ее легко охватить взглядом и как угодно менять, компили- ровать, запускать на исполнение и снова менять. Наши прежние программы были именно такими. Но представим себе программу даже не из тысяч, а из нескольких сотен строк, хранящуюся в одном файле. Чтобы ее отладить, неизбежно придется перемещать- ся из одного конца файла в другой. И будет трудно удержать в памяти увиден- ное в начале программы, спеша к ее концу. Сложность программы, содержащей множество дублирующих, мешающих друг другу переменных и функций, растет столь стремительно, что уже при длине в несколько сотен строк опа начинает управлять программистом, а не он ею. Чтобы удержать контроль над сложностью, такую программу следует разбить на несколько как можно более независимых частей, которым, л отличие от друзей Окуджавы, необходимо быть поодиночке, чтобы не пропасть. Поясним сказанное примером вычисления интеграла от функции /(х) с помощью формулы Симпсона, использующей значения функции, взятые в 2п + 1 фиксиро- ванных точках/(х0),/(xt),/(х2), ...,/(х2„). Веса, приписываемые значениям функ- ции, различны: нулевое и последнее значения берутся с весом 1, нечетные значе- ния (хь х3, х5, ...) имеют вес 2, а четные (х2, х4, х6, ...) — вес 4. Если п равно 3, то функция вычисляется в семи точках (х0, xt, х2, х3, х4, х5, х6, х7), и формула Симпсо- на (рис. 8.1) получается такой: Jf(x)dx = -^ [А + Vi + 2/2 + 4/3 + 2/4 + 4/5 + /6 ] О
Объектные файлы 121 Рис. 8.1. Формула Симпсона при п = 3 Теперь можно написать процедуру вычисления интеграла. Чтобы она была уни- версальной, значения функции при соответствующих xt будет находить другая процедура Fun, которая просто возьмет число с вершины стека и заменит его зна- чением функции. То, что получилось после примерно 10-й попытки, показано в листинге 8.1. Листинг 8.1. Процедура simpson.asm .386 .model flat, stdcall option casemap:none Fun PROTO .data two QWORD 2.0 three QWORD 3.0 four QWORD 4.0 .code Simpson proc X0:DWORD,X2N:DWORD,NN:DWORD. H:QWORD, SUMADDR:DWORD LOCAL dstep:QWORD Fid H загрузить шаг fid fmul two загрузить 2.0 ;шаг*2 fst fldz dstep сохранить двойной шаг загрузить сумму fid XO ;начало интервала fid fadd H ; ша г ;х0 + step mov ecx, NN 1 ;число слагаемых fid ST : дублируем хО + step nxt: invoke Fun вычисляем функцию faddp ST(2), ST суммируем + pop fadd ST, ST(2) добавляем dstep fid ST копируем новый х loop fcompp nxt ;след. слагаемое ;убираем 2 числа fid fmul four ;ST = 4.0 :sum = sum*4.0 продолжение &
122 Глава 8. Модульность Листинг 8.1 (продолжение) fid dstep двойной шаг fldz sum = 0.0 fid XO fid dstep fadd загружаем хО + 2.0*Н mov ecx, NN dec ecx :число слагаемых на 1 меньше fid ST :дублируем хО + dstep nxtl: invoke Fun ; вычисляем функцию faddp ST(2), ST ; ;sum « sum + Fun(x) fadd ST. ST(2) : :х = х + dstep fid ST ; :дублируем х + dstep loop nxtl ; :новое слагаемое fcompp :убираем два значения fid two ;ST = 2.0 fmul :sum = sum * 2 fadd ST. ST(2) ; :+предыдущая сумма fid XO Invoke fadd Fun fun(xO) прибавим fun(xO) fid X2N Invoke fadd Fun прибавим fun(x2n) fid H fmul sum = sum * h fid three fdlvp ST(1). ST (h/3) * sum mov eax. SUMADDR fstp TBYTE PTR [eax] сохраняем интеграл finit :очищаем сопроцессор ret Simpson endp end Эта процедура очень похожа на программы, которые мы до сих пор писали, раз- ница только в том, что после завершающей директивы end нет никакой метки. Такая метка (обычно мы называем ее start) должна быть только в одной глав- ной программе, которую нам еще предстоит создать, а пока попробуем разобрать- ся в процедуре Simpson из листинга 8.1. Прежде всего познакомимся с пятью параметрами процедуры: ХО — начальное значение х, X2N — конечное значение х, NN — параметр п, определяющий число значений функции, по которым вычисляется интеграл. Таких значений в фор- муле Симпсона 2n + 1. Следующий параметр Н — не что иное, как расстояние между соседними значениями х, например Н = XI - ХО. Этот параметр, часто на- зываемый шагом,. желательно задавать с большой точностью, ведь число точек, по которым вычисляется интеграл, может быть очень велико. Поэтому он зани- мает учетверенное слово, или 8 байт (QWORD). И, наконец, последний параметр SUMADDR — адрес в памяти, куда будет записан полученный интеграл. Этот адрес занимает, как обычно, двойное слово DWORD, то есть 4 байт. За параметрами следуют данные. По сути, это константы 2.0, 3.0, 4.0, необходи- мые для вычисления интеграла. Каждая константа задается с высокой точностью, занимает 8 байт, и объявляется как QWORD, например: three QWORD 3.0 ;константа занимает 8 байт
Объектные файлы 123 Сама процедура выглядит устрашающе, но стоит выделить в ней самые важные инструкции, обслуживанию которых подчинены все остальные, и окажется, что понять в ней нужно всего несколько строк. Но прежде познакомимся с нехитрой идеей вычислений: общую сумму удобно разбить на четыре части: значение функции в начале интервала f(хО), в конце — f(х2п), сумма значений при нечетных х, умноженная на 4, сумма значений при четных х, умноженная на 2. После вычисления суммы ее еще нужно умножить на треть шага (Н/3). Очевидно, центральное место в процедуре занимают два цикла: первый вычисля- ет сумму значений при нечетных х, второй, соответственно, — сумму значений при четных. Оба цикла похожи, поэтому проследим только за работой первого: MOV ecx. NN ;число слагаемых fid ST •.дублируем хО + step nxt: invoke Fun вычисляем функцию faddp ST(2). ST суммируем + pop fadd ST. ST(2) добавляем dstep fid ST копируем новый х loop nxt :след. слагаемое Чтобы хоть что-то понять в работе этого важнейшего участка процедуры, нужно проследить за инструкциями, которые ему предшествуют. А это с учетом того, что мы уже знаем о сопроцессоре, нетрудно. Перед запуском цикла в стек загружается двойной шаг ST(2) = 2Н, начальное зна- чение суммы ST(1) = 0.0 и первое значение х, в котором вычисляется функция ST(0) = хО + Н (рис. 8.2). ST(0) хО+Н ST(1) 0.0 ST(2) 2Н Рис. 8.2. Стек сопроцессора перед первым оборотом цикла Затем в есх посылается число слагаемых mov есх, NN (как видно из рис. 8.1, в формуле Симпсона NN слагаемых с весом 4 и NN-1 — с весом 2). И наконец, пе- ред самым началом цикла дублируется вершина стека (fid ST). При этом на- чальное значение суммы окажется в ST(2). Цикл начинается вычислением значе- ния функции invoke Fun, которое после вызова функции Fun окажется на вершине стека. Далее это значение прибавляется к сумме инструкцией faddp ST(2), ST и снимается со стека, потому что оно больше не понадобится. Теперь на вершине стека оказалось значение х, для которого только что вычислялась функция (вот почему нужно было копировать вершину стека!), и к нему следу- ет прибавить двойной шаг, что и делает инструкция fadd ST,ST(2). Далее верши- на стека снова копируется, и мы приходим к тому же состоянию стека, что и при первом обороте цикла. Разница лишь в том, что теперь на вершине и в ST( 1) находится следующее значение х, при котором нужно вычислить функцию! Оставшаяся часть процедуры, показанной в листинге 8.1, не требует поясне- ний. Пожалуй, стоит только сказать об инструкции fcompp, которая сравнивает два значения ST(0) и ST(1) и затем выталкивает их из стека. Мы используем
124 Глава 8. Модульность эту инструкцию только для удаления лишних чисел, результат их сравнения нам не нужен. Написав процедуру, можно переходить к основной программе, роль которой второстепенна: ей необходимо подготовить параметры, передаваемые процедуре, и вывести на экран значение интеграла. Попробуем вычислить простейший ин- теграл от функции cos (х) в пределах от нуля до л/4. Этот интеграл равен л/2/2, и нам легко будет оценить точность его вычисления. Основная программа пока- зана в листинге 8.2. Она не использует ничего нового и потому не нуждается в комментариях. Так что нам теперь осталось только сделать из подпрограммы и основной программы исполнимый файл с расширением .ехе. Листинг 8.2. Файл main.asm .386 .model flat, stdcall option casemap:none include \myasm\i nclude\windows.inc 1nclude \myasm\i nclude\kernel32.inc 1nclude \myasm\include\fpu.inc includelib \myasm\lib\user32.1ib 1ncludel1b \myasm\11b\kernel32.11b Includelib \myasm\l1b\fpu.11b Simpson PROTO :DWORD. :DWORD. :DWORD.\ :QWORD. :DWORD Fun PROTO N equ 5 BSIZE equ 30 .data buf BYTE BSIZE dup (?) cWritten DWORD ? stdout DWORD ? ilni DWORD 0 lend DWORD ? n DWORD N two QWORD 2.0 four QWORD 4.0 step QWORD ? sum TBYTE ? .code start: main proc finit fl dpi загрузить л fid four ;ST = 4.0 fdiv :ST = я/4 fst lend сохранить lend fid ilni : ST = Ilni fsub : ST = lend - lim fild n : ST = n fid two :ST = 2.0 fmul ;2 * n fdiv :(lend - ilni)/(2 * n) fstp step сохранить шаг invoke Simpson. Um. lend. n. stepA
Объектные файлы 125 ADDR sum invoke GetStdHandle, STD_OUTPUT_HANDLE mov stdout, eax invoke FpuFLtoA, ADDR sum, 10, \ ADDR buf, SRC1_REAL or SRC2_DIMM invoke WriteConsoleA, stdout, ADDR buf, \ BSIZE. ADDR cWritten, NULL invoke ExitProcess. 0 main endp Fun proc fcos ret Fun endp end start До сих пор мы не задумывались над загадочным превращением ассемблерного текста в объектный файл .obj и превращением объектного файла в исполнимый с расширением .ехе. Пора понять, что объектные файлы нужны для подготовки отдельных частей программы к слиянию в один исполняемый файл. В нашем случае нужно объединить программу main.asm (листинг 8.2) и подпро- грамму simpson.asm (листинг 8.1), подготовив с помощью компилятора два объект- ных файла main.obj и simpson.obj и затем объединив их компоновщиком в один — main.exe. Для этого нам придется написать особый командный файл, показан- ный в листинге 8.3 Листинг 8.3. Командный файл для для создания программы из двух частей ml /с /coff main.asm simpson.asm link /SUBSYSTEM:CONSOLE main.obj simpson.obj От уже привычного нам amake.bat он отличается тем, что компилирует сразу два файла main.asm и simpson.asm и затем объединяет в один исполняемый два объ- ектных файла main.obj и simpson.obj. Хранение программы в нескольких файлах позволяет не только управлять ее сложностью, но и многократно использовать отдельные ее части. Процедура simson.asm нарочно сделана независимой от основной программы, чтобы ее мож- но было использовать многократно. Для этого пришлось заново объявить в процедуре simpson.asm константы two и four. Нужно отчетливо понимать, что two и four, объявленные в процедуре simpson.asm, — совсем не те two и four, что объявлены в main.asm. Компоновщик, объединяя объектные модули, заботится о том, чтобы two в процедуре simson.asm существовало отдельно от two в процедуре main и занимало совсем другой уча- сток памяти. Независимость, безусловно, хороша, и к ней следует всячески стремиться. Но все же бывают случаи, когда процедурам суждено делить одни и те же данные. Например, регистр флагов у нас один, и переменную, в которой он хранится, второй раз не объявишь. Поэтому приходится применять разные уловки, чтобы процедуры пользовались одними и теми же данными. Одна такая уловка использована при передаче значения интеграла процедуре main. Суть ее в том, что процедуре simpson.asm передается не само значение интегра- ла, которое еще предстоит вычислить, а его адрес: ADDR sum. Пользуясь косвенной
126 Глава 8. Модульность адресацией, процедура записывает значение интеграла в 10-байтовую область па- мяти sum, определенную в процедуре main.asm: mov еах. SUMADDR fstp TBYTE PTR [eax] сохраняем интеграл Кроме косвенной адресации, сделать некую область памяти общей для разных процедур помогают директивы EXTERN и PUBLIC. Если область памяти общая, то и существовать она может только в единственном экземпляре, следовательно, объявить ее нужно только в одной из процедур. И там же нужно выделить ее ди- рективой PUBLIC, чтобы показать ассемблеру, что переменная доступна другим процедурам. В других же процедурах, использующих эту переменную, нужна пометка EXTERN, которая просит компилятор «не волноваться» насчет этой пере- менной, мол, для нее уже выделена память, а где конкретно — определит компо- новщик. В качестве примера сделаем так, чтобы процедура simpson.asm использовала константы two и four, определенные в файле main.asm. Для этого нужно в файле main.asm пометить их как PUBLIC: .386 .model flat, stdcall option casemap:none 1nclude \myasm\1nclude\w1ndows.1nc Includelib \myasm\l1b\fpu.11b public two. four Simpson PROTO :DWORD. :DWORD. :DWORD. :QWORD. :DWORD .data two QWORD 2.0 four QWORD 4.0 А в файле simpson.asm нужно эти переменные пометить словом EXTERN: .386 .model flat, stdcall option casemap:none Fun PROTO EXTERN two:QWORD. four:QWORD .data three QWORD 3.0 .code Обратите внимание, что теперь в процедуре simpson.asm память выделяется только одной переменной three QWORD 3.0, которая не помечена директивой PUBLIC и пото- му доступна только внутри файла simpson.asm. Память для переменных two и four не выделяется, потому что она уже выделена в процедуре main.asm. Об этом как раз и говорит директива EXTERN two:QWORD, four:QWORD. Встретив ее, компилятор пой- мет, как обращаться с переменными two, four, упомянутыми в файле simpson.asm. Директива EXTERN указывает имена переменных и их тип (в нашем случае это 8-бай- товые слова QWORD) — это все, что нужно знать компилятору. А где выделить для них память, решит компоновщик. И в этом ему поможет директива PUBLIC.
DLL 127 DLL Солдаты, бывшие на дворе, услыхав выстрел, вошли в сени, спрашивая, что случилось, и изъявляя готовность нака- зать виновных; но офицер строго остановил их. — On vous demandera quand on aura besoin de vous', — сказал он. Лев Толстой. Война и мир Сборка программ из отдельных модулей, с которой мы познакомились в предыду- щем разделе, помимо достоинств, обладает и серьезным недостатком: один и тот же модуль нужно включать во все программы, которые его используют. Значит, жест- кий диск компьютера будет занят одинаковыми процедурами, хранящимися в раз- ных программах. А если несколько таких программ работают одновременно, как и положено в многозадачной операционной системе Windows, то и в памяти ком- пьютера окажется много копий одной процедуры, что некрасиво и расточительно. Вот почему Windows широко использует другой способ вызова процедур из так называемых динамических библиотек или DLL. Суть его в том, что динамическая библиотека загружается в память компьютера, только когда «ее позовут». Разме- щение динамической библиотеки в памяти происходит при первом обращении к ней. Если же другие программы вздумают использовать ту же библиотеку, то вместо засорения памяти еще одной копией процедуры программа получит ее на- чальный адрес, по которому к ней и обратится. А если к динамической библио- теке совсем нет обращений, она тихо лежит на диске в файле с расширением .dll и никому не мешает. Попробуем же создать свою собственную динамическую библиотеку myio.dll, со- держащую всего одну процедуру StrDisp (см. раздел «Своеволие ассемблера» главы 3). Ее исходный текст показан в листинге 8.4. Листинг 8.4. Исходный текст динамической библиотеки myio.dll .386 .model flat.stdcall option casemap:none i nclude \myasm\i nclude\wi ndows.i nc 1nclude \myasm\include\kernel32.inc includelib \myasm\1ib\kernel32.1ib .code StrDisp proc StrAddr:DWORD. StrSz:DWORD LOCAL stdout:DWORD. cWritten:DWORD invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout, eax mov eax.StrAddr invoke WriteConsoleA. stdout. StrAddr. \ StrSz. ADDR cWritten. NULL ret StrDisp endp end Как видите, динамическая библиотека почти ничем не отличается от обычного исходного текста программы. Только за директивой end нет привычной метки *Когда будет нужно, вас позовут (фр.).
128 Глава 8. Модульность start, ведь первая исполняемая инструкция библиотеки может меняться в зави- симости от того, какая процедура вызывается. Можно сказать, что у библиоте- ки, в отличие от обычной программы, есть несколько точек входа, которые ас- семблер узнает из специального файла с расширением .def, хранящего имена всех вызываемых функций. В нашем случае функция всего одна, поэтому файл myio.def будет таким, как в листинге 8.5. Листинг 8.5. Файл myio.def LIBRARY myio EXPORTS StrDisp В нем всего две директивы: LIBRARY (указывает имя библиотеки) и EXPORTS (ука- зывает имя вызываемой процедуры). Директива EXPORTS ставится в файле .def перед именем каждой процедуры. В нашем случае единственной процедуре StarDisp соответствует одна директива EXPORTS. Заметим, что в большой библио- теке могут быть невидимые для внешних программ служебные процедуры. Их имена не помечаются словом EXPORTS. После создания файла описания .def можно запускать компилятор, но прежний командный файл amake.bat уже не подойдет, потому что нужно указать компи- лятору и компоновщику, что создается именно динамическая библиотека. По- этому придется написать специальный командный файл для создания библио- теки myio.dll. Он будет таким, как в листинге 8.6. Листинг 8.6. Командный файл myio.bat для создания библиотеки myio.dll ml /с /coff myio.asm link /DLL /DEF:myio.def /NOENTRY myio.obj у Поместив в одну папку файлы myio.asm, myio.def и запустив командный файл myio.bat, получим (наряду со вспомогательными файлами myio.exp и myio.obj) файл динамической библиотеки myio.dll и библиотеку импорта myio.lib. В файле myio.lib нет инструкций процессора (все они — в динамической библиотеке myio.dll), а лишь имена процедур, число и тип их параметров, да имя самой биб- лиотеки. Как и всякая библиотека, файл myio.lib подключается к исходному тек- сту программы директивой includellb. Значит, наша программа, вызывающая процедуру StrDisp, будет выглядеть так, как показано в листинге 8.7 Листинг 8.7. Программа, вызывающая процедуру из библиотеки myio.dll .386 .model flat, stdcal1 option casemap:none include \myasm\i nclude\windows.inc include \myasm\i nclude\kernel32.inc includelib \myasm\1ib\kernel32.1ib includellb myio.lib StrDisp proto .-DWORD, :DWORD .data Msg BYTE "He могу молчать!",13,10 .code start: invoke StrDisp. ADDR Msg,sizeof Msg invoke ExitProcess. 0 end start
Точка входа 129 Директива includeib myio.llb показывает, что библиотека импорта myio.llb нахо- дится там же, где исходный текст программы, хотя, конечно, ее можно помес- тить где угодно; нужно только указать правильный путь к ней. Скомпилировав исходный текст из листинга 8.7 обычным командным файлом amake.bat, получим программу с расширением .ехе, которую можно выполнить, пред- варительно поместив ее и динамическую библиотеку myio.dll в одну папку. Когда программа запускается, операционная система смотрит, какие динамические биб- лиотеки она использует. Поняв, что нужна библиотека myio.dll, система находит ее там же, где расположен исполняемый файл*, загружает в память компьютера, после чего смотрит, какие процедуры из библиотеки вызываются, и «вставляет» в испол- нимый файл нужные адреса, после чего за работу принимается процессор. Так бу- дет в случае, когда библиотека еще не загружалась в память. Но если она уже там, операционной системе остается только скорректировать адреса процедур, увеличить счетчик пользователей DLL на единицу и отправить программу процессору. Подключив к программе myio.dll, вы, наверное, поняли, что и раньше пользова- лись динамическими библиотеками. Строка include1ib \myasm\11b\kernel32.1ib в листинге 8.3, очевидно, говорит о подключении динамической библиотеки kernel32.dll. Разница только в том, что файл kernel32.dll находится в папке c:\windows\system32\, то есть принадлежит операционной системе Windows. Те- перь мы понимаем, что система Windows — это во многом собрание динамиче- ских библиотек kernel32.dll, useg32.dll, gdi32.dll и т. д., которыми пользуются все запускаемые программы. Точка входа В нашей первой версии динамической библиотеки нет единственной точки вхо- да, и это выглядит разумно: сколько процедур — столько и входов. Но все же иногда при вызове библиотеки требуется проделать операции, необходимые всем процедурам: выделить дополнительную память, присвоить значения пере- менным, используемым всей библиотекой, и т. д. Для этого в динамической библиотеке можно создать специальную процедуру, которая вызывается при за- грузке или выгрузке DLL. Эта процедура и есть точка входа. Листинг 8.8. Точка входа в динамическую библиотеку .dll .386 .model flat.stdcall option casemap:none include \myasm\include\windows.inc include \myasm\include\kernel32.inc includelib \myasm\11b\kernel32.1ib .data .code DllMaln proc h!nstDLL:HINSTANCE. reason:DWORD. reservedl:DWORD __________________ продолжение & *Библиотеку можно разместить и в папке операционной системы, например c:\windows или в пап- ке, указанной в переменной path (про переменную path см. раздел «Компилятор» главы 1).
130 Глава 8. Модульность Листинг 8.8 (продолжение) mov еах,TRUE ret Dll Main endp StrDisp proc ret StrDisp endp End DllMain В листинге 8.8 она называется DllMain, потому что она главная в библиотеке1*. Но ее имя можно сделать любым. Обратите внимание: теперь, когда точка входа в библиотеку появилась, директива end прямо указывает на нее: End DllMain. По- этому в файле .def DllMain не требуется директива EXPORTS, а для компиляции биб- лиотеки нужно слегка изменить командный файл, убрав из него ключ /NOENTRY (листинг 8.9). Листинг 8.9. Командный файл для создания библиотеки, имеющей точку входа ml /с /coff myio.asm link /DLL /DEF:myio.def myio.obj Сама DllMain в нашем случае просто возвращает TRUE в регистре еах, потому что единственная процедура библиотеки ни в чем не нуждается (если возвратить FALSE, то библиотека просто не загрузится). Но в более сложных случаях потребуется знание параметров процедуры — точки входа. Формально их три, но последний не используется и всегда равен NULL. Первый параметр hlnstDLL — это дескрип- тор библиотеки, передаваемый ей операционной системой при загрузке. Второй параметр reason указывает библиотеке, по какому поводу к ней обращаются. Ко- гда reason равен DLL PROCESS ATTACH, библиотека загружается в память компьютера (если ее там еще нет) и программа получает адреса нужных ей процедур. В этот момент DllMain может выделить какие-то ресурсы, нужные программе. И если эти ресурсы глобальны, то есть доступны всем процедурам библиотеки, то каждая программа получает свою копию этих данных, в то время как сами процедуры едины для всех вызывающих их программ. Когда же параметр reason равен DLL PROCESS DETACH, то библиотека становится недоступна программе, как бы «от- соединяется» от нее**. Ручной вызов До сих пор динамическая библиотека подключалась к нашей программе автома- тически, операционной системой, и можно было без всяких усилий вызвать лю- бую процедуру, потому что сведения о ней хранила библиотека импорта (в на- шем случае это myio.lib). Это самый простой и потому самый распространенный способ работы с библиотекой DLL. *Main означает «главный». **У параметра reason есть еще два значения: DLL_THREAD_ATTACH и DLL_THREAD_DETACH. Они появляются, когда программа разбита на несколько одновременно выполняющихся частей (потоков). Таким образом каждый поток может загрузить свою библиотеку, посылая ей параметр DLL_THREAD_ATTACH и каждый поток заканчивает работу с библиотекой передачей парамет- ра DLL_THREAD_DETACH.
Ручной вызов 131 Но иногда предпочтительней ручное подключение библиотеки, явное опреде- ление адреса процедуры и явное же отключение библиотеки. Так приходится делать, когда библиотека импорта недоступна или когда хочется вызвать не- документированную процедуру, о которой нет записи в файле.lib. Кроме того, ручное подключение позволяет программе выполняться, даже если она не смо- жет найти библиотеку. Если же такое случится при автоматическом подклю- чении, программа просто откажется работать, показав на экране сообщение: «Приложению не удалось запуститься, поскольку .dll не был найден». Наконец, ручной вызов DLL позволяет лучше понять, как работает динамическая биб- лиотека. В листинге 8.10 показано, как подключить динамическую библиотеку myio.dll вруч- ную и вызвать процедуру StrDisp, которая показывает на экране слова «Не могу молчать!». Листинг 8.10. Ручное подключение библиотеки .386 .model flat.stdcall option casemap:none i nclude \myasm\i nclude\wi ndows.inc include \myasm\i nclude\kernel32.1nc includel1b \myasm\l1b\kernel32.11b .data Msg BYTE "He могу молчать!".13.10 LibName db "myio.dll".0 FunctionName db "StrDisp".0 .data? hLib dd ? :дескриптор dll StrDispAddr dd ? ;адрес процедуры StrDisp .code start: invoke LoadLibrary. ADDR LibName загрузить библиотеку mov hLib,eax сохранить дескриптор invoke GetProcAddress. hLib. ADDR FunctionName mov StrDispAddr. eax -.сохранить адрес mov eax. sizeof Msg push eax lea eax. Msg push eax call [StrDispAddr] ;вызвать процедуру invoke FreeLibrary. hLib отсоединить библиотеку invoke ExitProcess. NULL end start Листинг 8.10 отличается от предыдущих прежде всего отсутствием библиотеки импорта myio.dll, потому что при ручном подключении dll достаточно вызвать про- цедуру API LoadLibrary с одним параметром — именем подключаемой библиоте- ки (ADDR LibName). LoadLibrary вернет в регистре еах дескриптор библиотеки hLib или NULL (когда не удается найти библиотеку)*. Затем наступает черед процедуры GetProcAddress, узнающей адрес процедуры, чье имя помечено в программе как FunctionName. Этот адрес (или NULL в случае *С помощью LoadLibrary можно подключать динамические библиотеки с расширением, отличным от ..dll (например .ехе). Автоматически подключаются только .dll-файлы.
132 Глава 8. Модульность неудачи) опять оказывается в регистре еах, откуда переправляется в двойное слово St г Di spAddr. Теперь можно вызвать саму процедуру StrDisp, но директива invoke уже не по- дойдет, потому что нам, по сути, известен только адрес процедуры, полученный с помощью GetProcAddress, а не ее имя. Поэтому ее параметры заталкиваются в стек инструкциями push, а сама процедура вызывается инструкцией call, ис- пользующей косвенную адресацию: call EStrDIspAddr] И наконец, после использования библиотека отключается от программы про- цедурой FreeLibrary. Как видим, ручной вызов библиотечной процедуры дает программисту большую свободу ценой дополнительных усилий. Видимо, вруч- ную следует подключать только те библиотеки, без которых программа может обойтись. «Обязательные» библиотеки, без которых работать невозможно, лучше подключать автоматически, с помощью библиотеки импорта .lib.
Глава 9 16 бит DOS Мы, как и люди, не живем вечно. Мы стареем, но стареют не тела наши, потому что им не знакомо понятие Время. Старе- ют исполняемые нами функции, становятся примитивными. И мы должны честно принять это и уйти сами, нс дожидаясь, пока кто-то выпотрошит нас, высмеет и выбросит вон. С. Расторгуев. Программные методы защиты информации в компьютерах и сетях Наверное, где-нибудь в пыльных углах еще можно разыскать компьютеры IBM PC XT. Многие из них до сих пор исправны, только вряд ли кому придет в го- лову включать их, ведь современные операционные системы (такие как Windows или Unix) нельзя на них запустить даже в принципе. А ведь совсем недавно, в конце 80-х годов эти машины стоили бешеных денег и вы- зывали трепет у каждого настоящего программиста. Тогда в мире персональных компьютеров царила операционная система DOS (тоже фирмы Microsoft), которая управлялась командной строкой, примерно такой же, как в оболочке FAR. Сама эта оболочка тоже пришла к нам из тех времен. Хоть FAR и консольное приложение Windows, не способное работать в системе DOS, он довольно точно копирует ин- терфейс оболочки Norton Commander, стоявшей в те годы на каждом компьютере. В отличие от Windows, DOS — однозадачная операционная система, не способ- ная одновременно выполнять несколько программ. Это значит, что в DOS не- возможен привычный для Windows буфер обмена. Ведь буфер — не просто уча- сток памяти, а программа, которая которая меняет формат посылаемых ей данных и работает одновременно с другими программами. Поэтому приходится сначала сохранять данные на диске, затем выходить из одной программы, запус- кать другую и читать сохраненные данные. Обмен данными в DOS упрощают так называемые резидентные программы, которые «всплывают» при нажатии определенной комбинации клавиш, «сдирают» с экрана картинку или текст, со- храняют их в файле и передают управление предыдущей программе, которая уже может эти файлы прочитать.
134 Глава 9. 16 бит Несмотря на все эти неудобства, DOS обладала и обладает важным достоинст- вом: она дает полный контроль над компьютером, позволяет делать с ним и все- ми его устройствами все что угодно. Программист (особенно если это умелый программист на ассемблере) чувствует, что может выжать из имеющегося «же- леза» все возможное и даже написать программу, способную уничтожить DOS, а вслед за ней и себя саму. Блаженные времена, когда программист мог владеть целым компьютером, про- шли. Современные операционные системы многое берут на себя: они не позво- ляют уже программам напрямую обращаться к устройствам компьютера, потому что программ несколько, а устройство — одно. Теперь программиста отделяет от «железа» толстый слой ваты — так называемый API (например, уже знакомый нам Windows API). Но есть еще области (и немалые), где DOS может сослужить верную службу: это различные самодельные приборы, основанные на процессорах Intel. Такие приборы обычно собираются по частям, как конструктор: в специальную «кор- зину» вставляется материнская плата, а в нее — процессор, память и необходи- мые платы. Прибор обычно управляется единственной программой, которая должна взаимодействовать с нестандартными устройствами, поэтому ее проще написать и удобнее выполнять в системе DOS. Есть еще одна причина, по которой нужно быть знакомым с устройством про- грамм для DOS: в мире осталось очень много исходных текстов на ассемблере для этой операционной системы. И чтобы не поддаться панике, увидев непонят- ные значки вроде i nt 21h, нужно познакомиться с DOS поближе. Программированию на ассемблере для DOS посвящено множество книг. Поэто- му мой рассказ коснется только самого главного. Но даже если вас не интере- сует DOS, эту и следующую главы все равно стоит прочитать. Потому что, говоря о DOS, мы узнаем много нового об инструкциях процессора и устройстве Windows. А начнем с программы для DOS, выводящей на экран уже знакомую фразу Не могу молчать! (листинг 9.1). Листинг 9.1. Не могу молчать! (DOS-версия) .8086 .MODEL small .stack 100 .data hello BYTE ”He могу молчать!”. Odh. Oah. Ё$Ё .code start: mov dx.Ostack mov ss.dx :регистр стека mov dx.Odata mov ds.dx ;регистр данных mov dx. offset hello mov ah. 09 :вывести на экран int 21h mov ah. 4ch завершить программу int 21h end start
DOS 135 Несмотря на многие новшества, вам должно быть в общих чертах понятно, что и как она делает. Так, например, строки: mov ah, 09 int. 21h каким-то таинственным способом выводят на экран монитора слова Не могу молчать!, а строки mov ah, 4ch Int 21h завершают программу, выполняя роль процедуры ExitProcess в Windows API. Программа, показанная в листинге 9.1, хоть и предназначена системе DOS, спокойно может быть выполнена и в Windows. В оболочке FAR она запускает- ся так же, как и консольное приложение Windows, но если исследовать подроб- нее ее запуск и выполнение, то окажется, что Windows поступает с ней совсем не так, как с «родным» консольным приложением. Windows эмулирует испол- нение DOS-программ, то есть пытается своими средствами выполнить про- грамму так, чтобы никто не заметил подмены. Часто это удается. На моем ком- пьютере с операционной системой Windows ХР до сих пор работает старинная электронная таблица LOTUS 1-2-3 v.2.2, написанная еще в 1989 году для сис- темы DOS! Как же Windows распознает программы для DOS? Так же, как и для Windows — по самой программе, вернее, по ее заголовку. Ведь программа, хра- нящаяся в файле с расширением .ехе, содержит не только инструкции процессо- ра, но и сведения, которые требуются операционной системе для ее запуска. Значит, программе, предназначенной для системы DOS, требуется особый заго- ловок, не такой как у консольного приложения Windows. Такой заголовок соз- дается специальным компоновщиком, который в нашей учебной версии ассемб- лера называется Iinkl6.exe*. Чтобы приготовить с его помощью программу для DOS, нужен специальный командный файл, показанный в листинге 9.2. Листинг 9.2. Командный файл dmake.bat для создания DOS-программ ml /с И.asm 11 nkl6 И. obj, И. exe,.,. Как видим, DOS-программа приготовляется тем же ассемблером, но другим компоновщиком. Запятые в командной строке, запускающей Iinkl6.exe, обозна- чают отсутствующие служебные файлы, которые нам не интересны. Файл dmake.bat удобно поместить в ту же папку, что и amake.bat, создающий консольные приложения. Если сохранить программу из листинга 9.1 в файле I91.asm, то вызов командного файла с параметром 191: dmake 191 создаст программу I91.exe, которая запускается из командной строки FAR так же, как и консольное приложение Windows, и так же выводит на экран строку Не могу молчать!. Но мы-то знаем, что это другая программа, которую Windows исполняет совсем иначе. *Этот компоновщик тоже написан для системы DOS, но отлично чувствует себя в среде Windows.
136 Глава 9. 16 бит Сегменты Пожалуй, самое важное отличие программы, написанной для DOS, от консоль- ного приложения Windows — в способах обращения с памятью. Строка mov dx. offset hello из листинга 9.1 кажется нам знакомой: по-видимому, в ней адрес начала после- довательности символов Не могу молчать! записывается в регистр dx. Но ведь dx — 16-битовый регистр и может содержать всего 216 = 65 536 различ- ных адресов, что очень мало даже для такой старой системы как DOS. Почему же DOS не использует 32-битные регистры? Потому, что в процессорах — со- временниках DOS их просто не было! Процессор Intel 8086 — сердце компьюте- ра IBM РС-ХТ — содержал только 16-битовые регистры ах, bx, ex, dx, si, di, и пе- ред его разработчиками встал выбор: либо обречь процессор на работу с 65 535 байтами, либо записывать адрес в двух регистрах. Естественно, был выбран второй вариант. Решили организовать память в виде сегментов, каждый из которых содержит 64 килобайта или 64 Кбайт (64Кбайт = = 64 * 1024 = 65 535 байт памяти). При этом положение байта внутри сегмента определяется обычным регистром, вроде Ьх, а положение самого сегмента внут- ри компьютерной памяти задается специальным сегментным регистром, каких в процессоре 8086 четыре: cs, ds, es, ss. Регистр cs задает сегмент, в котором на- ходятся инструкции программы, регистр ss — положение стека, а регистры ds и es определяют положение сегментов данных. Поэтому обращение к памяти долж- но в общем случае содержать как смещение, так и сегментный регистр, напри- мер инструкция: mov al. ds:[si] пересылает байт, чей адрес складывается из адреса начала сегмента, хранящего- ся в регистре ds, и относительного адреса внутри сегмента, записанного в si. Правило, по которому определяется адрес начала сегмента, очень простое: нуж- но умножить содержимое сегментного регистра на 16. Но мы уже знаем, что ум- ножение на 16 эквивалентно сдвигу числа на четыре двоичных разряда влево. Выходит, максимальный адрес сегмента занимает всего 16 + 4 = 20 бит и равен ffff016 или 1 048 56010. Если теперь к этому адресу прибавить 65 535 — макси- мальное положительное число, способное уместиться в 16-битовом регистре), то получим максимальный адрес, который можно задать с помощью сегмента и сме- щения: чуть больше 1 миллиона байтов! Сейчас эта цифра кажется смехотворной, но когда процессор 8086 только появил- ся, 1 мегабайт (миллион байтов) памяти был огромным числом, и разработчи- кам казалось, что программам его хватит на долгие годы. Но уже через пару лет стало ясно, что они жестоко ошиблись. Электронная про- мышленность стала производить дешевые микросхемы памяти, только вот поль- зоваться ими было невозможно из-за предела в 1 Мбайт. Поэтому был разрабо- тан новый процессор 80286, в котором применялся другой способ адресации, позволявший использовать до 16 Мбайт памяти. Но чтобы на нем можно было выполнять старые программы, способные с помощью пары регистров сегмент- смещение адресовать только 1 Мбайт, пришлось в новом процессоре реализо-
Сегменты 137 вать и старую систему адресации. Так возникли два режима процессора: реаль- ный режим, совместимый с процессором 8086 и способный адресовать до 1 Мбайт памяти и защищенный режим, устроенный совершенно иначе и способный адре- совать до 16 Мбайт. Это разделение на реальный и защищенный режимы сохранилось до сих пор во всех процессорах Intel. Начиная с процессора 80386 защищенный режим спосо- бен адресовать 232 — более 4 миллиардов байтов! И опять это число, несколько лет назад казавшееся фантастическим, становится привычным, а для некоторых задач и недостаточным. Но о том, как преодолеть очередной барьер, мы в этой книге говорить не будем. Вместо этого попробуем понять, как процессор взаимо- действует с компьютерной памятью. Мы уже говорили, что единичные и нулевые биты существуют только в головах программистов. Для процессора реальны только напряжения на его контактах. Каждый контакт соответствует одному биту, и процессору нужно различать толь- ко две градации напряжения: есть-нет, высокое-низкое. Одной из них соответ- ствует единица, другой — ноль. Поэтому адрес для процессора — это последова- тельность напряжений на специальных контактах, называемых шиной адреса. Поскольку в реальном режиме процессора адрес состоит из 20 бит, в шине адре- са процессора 8086 всего 20 контактов. Кроме контактов, на которых появляет- ся адрес, в процессоре есть еще контакты, называемые шиной данных, где появ- ляется прочитанное из памяти число. Шина данных процессоров 8086 и 80286 имеет 16 контактов, шина данных процессора 80386 и выше — 32 контакта. Можно представить себе, что после того как на контактах шины адреса, которыми кодируется двоичное число, выставляются напряжения, на контактах шины дан- ных появляются напряжения, кодирующие хранящееся по указанному адресу чис- ло. Эта картина очень грубая, потому что для извлечения данных из памяти необ- ходимо время. Чтобы не запутаться, работой процессора управляет специальный тактовый генератор. Он вырабатывает импульсы, которые делят работу процессора на отдельные шажки. Единицей времени процессора служит один такт, то есть промежуток между двумя сигналами тактового генератора (тактовыми импульса- ми). Некоторые команды выполняются за один такт. Обращение к памяти требует, как правило, нескольких тактов процессора. Когда данные поступили из памяти, на одном из контактов процессора появляется сигнал, говорящий о том, что данные готовы и их можно использовать в текущей инструкции. Напряжения, появляющиеся на шине адреса процессора, называются физическим адресом. В реальном режиме процессор работает только с физическими адресами. Поэтому по сегменту и смещению всегда можно сказать, какие напряжения будут на 20 контактах адресной шины. Наоборот, защищенный режим процессора инте- ресен тем, что программа работает с логическими адресами, а процессор незримо преобразует их в физические. Наверное, вы уже догадались, что система Windows использует защищенный ре- жим работы процессора. Современные операционные системы и программы тре- буют столько памяти, что защищенный режим работы процессора стал гораздо «реальнее» его реального режима. А это значит, что программы, написанные для DOS, тоже выполняются в защищенном режиме, то есть адреса, бывшие некогда физическими, таковыми быть перестали. Программе для DOS операционная сис-
138 Глава 9. 16 бит тема выделяет логическое адресное пространство, которое не отличается от того, что было в реальном режиме. Но на самом деле система незаметно использует совсем другие адреса. Поскольку Windows — система многозадачная, она может выполнять одновременно множество программ для DOS, причем каждая DOS- программа чувствует себя так, как будто она одна выполняется процессором. Опять про сегменты Поскольку смещения в защищенном режиме процессоров 80386* и выше — 32-раз- рядные, программа для Windows использует по существу один огромный сег- мент, занимающий 4 гигабайта (4 294 967 296 байт) логического пространства. Раз сегмент один, его «настройку» берет на себя операционная система. А в программе для DOS чаще всего приходится задавать несколько сегментов, потому что инструкции процессора и данные не всегда удается уместить в 64 Кбайт. Сегментов данных, как и сегментов кода, может быть много, поэтому в програм- ме для DOS нужно явно «настроить» сегментные регистры. В нашей программе из листинга 9.1 начальные значения присваиваются двум регистрам: сегменту данных ds и сегменту стека ss: mov dx. @stack mov ss. dx mov dx. @data mov ds. dx Имена @data и @stack обозначают значение регистра, которое станет известно в мо- мент запуска программы. Ведь программа для DOS размещается по реальным, физическим адресам, поэтому значения сегментов заранее не известны и зави- сят от того, сколько памяти уже израсходовано операционной системой и дру- гими, ранее запущенными программами, такими как резидентные программы и файловые оболочки, вроде Norton Commander. Процессор устроен так, что эти значения он не может непосредственно передать в сегментный регистр, прихо- дится делать это через посредника (в нашем случае это регистр dx). Мы уже говорили, что сегменты в DOS-программе очень невелики, и только одно- го сегмента данных .data, как в программе из листинга 9.1, может не хватить. За- дать дополнительные сегменты можно с помощью директив .data? (см. раздел «Де- ление» главы 4) или .const. Последняя директива задает сегмент, хранящий всякие постоянные величины: сообщения программы, константы с плавающей точкой и пр. Но лучше использовать в программах для DOS «классический» способ задания сегментов с помощью директивы segment. В листинге 9.2 показана программа, скла- дывающая два числа, расположенных в разных сегментах данных data и datal. Листинг 9.2. Сложение двух чисел, расположенных в разных сегментах .8086 stack segment stack BYTE 100 dup (?) stack ends data segment ’Системы Windows (Windows 95, 98, ME, 2000, XP) не могут работать с процессором 80286.
Опять про сегменты 139 first WORD 2 data ends datal segment second WORD 3 datal ends code segment assume cs:code, ds:data, es:datal, ss:stack start: mov ax, data mov ds, ax mov ax, datal mov es, ax mov dx. first add dx, second mov ah, 4ch int 21h code ends end start В этой программе задаются четыре сегмента. Строки: stack segment stack BYTE 100 dup (?) stack ends выделяют 100 байт для сегмента стека. Следом за сегментом стека задаются два сегмента данных data и datal. В каждом из этих сегментов расположено по одному числу. Это, конечно, глупость, и мы спокойно могли бы обойтись в этой программе одним сегментом. Просто зада- ние двух сегментов данных позволяет лучше понять настройку сегментных ре- гистров и выбор программой сегмента по умолчанию. Вслед за сегментами данных идет кодовый сегмент: code segment assume cs:code, ds:data, es:datal, ss:stack start: code ends end start с новой для нас директивой assume, которая указывает ассемблеру, с каким сег- ментом будет связан определенный сегментный регистр. В нашем примере с сегментом data связан регистр ds, а с сегментом datal — регистр es. Такую связь необходимо задать, чтобы ассемблер знал, какой сегментный регистр ука- зать в соответствующей инструкции. Возьмем, например, инструкцию mov dx, first, пересылающую число first в ре- гистр dx. Чтобы эта инструкция имела какой-то смысл, ассемблер должен знать, какой сегментный регистр «подпирает» сегмент data, где хранится число first. Ведь адрес числа состоит из двух частей: смещения и сегмента. Так вот, дирек- тива assume как раз и говорит ассемблеру, что сегмент data связан с регистром ds. И точно так же директива assume указывает ассемблеру, что сегментом datal ве- дает регистр es, поэтому в инструкции add dx, second будет указан именно es. Чтобы сказанное стало немного понятней, попробуем рассмотреть нашу програм- му в окне отладчика. К сожалению, OllyDbg, который мы до сих пор использовали,
140 Глава 9. 16 бит не работает с программами для DOS, поэтому приходится использовать древ- ний отладчик AfdPro — ровесник системы DOS. Он тоже включен в наш учеб- ный ассемблер и вызывается в оболочке FAR командой afdpro <имя программы^ Программа из листинга 9.2 смотрится в окне AfdPro примерно так, как на рис. 9.1. $£? afdpro €82 £ХЕ - Far щсшвЕИ! II p 2l ? Aj' MM И i um [ОХ 0000 SI 0000 CS 2ЕП0 IP 0000 S lack -0 0000 Flags 3202 BX 0000 01 0000 OS 2E87 -2 0000 СХ 0007 BP Й000 IS PF 87 HS 218/ -4 0000 OF l)F IF SF ZF OF PF Cl DX ^0|$P 0064 SS 2F97 rs 2F87 -6 0000 0 0 1 0 0 О 0 0 яряяямямр ЯДЯЯРйРуМЯ И1И S3 a и L jCMD>^o a r 8 0 1 2 3 4 5 6 7 OS:0000 CD 20 00 00 00 90 10 IF DS;0008 ID E0 IB 05 48 IB 60 01 Ю000 B89E2E MOV : ЛХ.2Е9Е DS;0010 26 14 78 01 26 14 18 16 10003 8ED8 MOV DS, OX DS:0018 01 01 01 00 02 Fl FF II 0805 В89Г2Е MOV DX.2F9F OS:0020 FF IT FF IF FF П FF П 0008 8EC0 MOV ES,AX OS:0028 FF FE FF FF 71 2E CD 11 000Й 86160000 MOV DX.100001 OS:0030 28 16 14 00 18 00 87 21 00Ш- ?603160000 ODD DXJ S: [00001 DS:0038 FF II IT II 00 00 00 00 0013 B44C MOV 00,48 DS;0040 07 on 00 DO 00 00 00 00 0015 CD21 INT 21 DS;0048 00 00 00 00 00 00 00 00 i 0 1 2 3 4 5 6 7 8 9 0 8 C D F F OS:0000 CO 20 00 00 0B 90 F0 FE ID !H IB 0Г > 4В 18 60 01 > a. Ъ[ ~« . .K . &. DS;0010 26 14 78 01 26 14 18 16 01 01 01 00 02 Fl FF FF Lx .8.. OS:0020 Fl FF IF IF II Fl II IF H It Fl IF П 21 C0 11 q 1 DS:0030 28 16 14 00 18 00 87 2F FF rr ГГ ГТ 00 00 00 00 L 03:0040 07 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 Рис. 9.1. Программа в окне отладчика AfdPro Первые четыре строки, видные в окне отладчика: 0000 B89E2E MOV AX.2E9E 0003 8ED8 MOV DS. AX 0005 B89F2E MOV AX.2E9F 0008 SECO MOV ES.AX присваивают начальные значения сегментным регистрам. Следующая строка, оче- видно, представляет инструкцию mov dx, first: 8В160000 MOV DX. [0000] Здесь 8B160000 — шестнадцатеричный код инструкции, a MOV DX, [0000] — ее сим- волическое представление. Видно, что имя переменной first ассемблер превра- тил в ее адрес 0000. Вернее, нули — это только смещение относительно какого-то сегмента. Если сегмент не указан, то процессор считает, что это ds. И действи- тельно, директива assume закрепила за сегментом data, где хранится число first, именно этот регистр. А теперь посмотрим, как показывает отладчик следующую инструкцию add dx, second: 2603160000 ADD DX. ES:[OOOO] Здесь символическое представление инструкции уже явно включает регистр ES: ADD DX, ES: [0000], что согласуется с директивой assume для сегмента datal, храня-
Опять про сегменты 141 щего число second. Эта инструкция велит процессору взять число, чье смещение относительно сегмента es равно нулю. Любопытно узнать, где в коде команды хранится информация о том, что смещение отсчитывается именно относительно es. Оказывается, в инструкции 2603160000 это так называемый префикс, первые две шестнадцатеричные цифры. В нашем случае на регистр es указывают циф- ры 26. Заметим, что ассемблер ставит префиксы только там, где это необходимо. В команде 8В160000 (MOV DX, [0000]) нет префикса Зе, предусмотренного для реги- стра ds, потому что ds задается директивой assume и используется по умолчанию. Эти правила умолчания довольно просты: при косвенной адресации, когда сме- щение операнда хранится в регистре, ассемблер считает, что регистры bx, si, di содержат смещения относительно ds, a bp — смещения относительно регистра стека ss*. Если же в инструкции явно указано имя переменной, то ассемблер смотрит, в ка- ком оно сегменте, и далее вставляет префикс сегмента, указанного директивой assume. Если таким сегментом оказывается ds, префикс не ставится, потому что процессор использует ds по умолчанию. Листинг 9.3. Программа находит нужный сегмент .8086 stack segment stack BYTE 100 dup (?) stack ends data segment first WORD 2 data ends datal segment second WORD 3 datal ends code segment assume cs:code. ds:data, es:datal, ss:stack start: mov ax. data mov ds. ax mov ax, datal mov es. ax mov bx. 0 mov dx. [bx] mov ah. 4ch int 21h code ends end start В программе из листинга 9.3. инструкции mov bx. 0 mov dx. [bx] не содержат никакой информации о сегменте. В них видно только нулевое сме- щение, которое имеют как число first в сегменте data, так и число second в сег- менте datal. Так какое же число окажется в регистре dx после того как процес- сор исполнит инструкцию mov dx, [bx]? Легко проверить с помощью отладчика, *В процессоре 8086 только эти регистры участвуют в косвенной адресации. В процессорах 80386 и выше можно для этой же цели использовать регистры еах, ebx, есх, edx, esi, edi, ebp.
142 Глава 9. 16 бит что это будет двойка. Ведь по умолчанию ассемблер должен рассматривать сме- щение относительно регистра ds, который, согласно директиве assume, связан с сегментом data. Но, несмотря на директиву assume, регистр dx, не «получит двойку», если явно не настроить сегмент ds инструкциями: mov ах. data mov ds, ах Обратите внимание, в программах из листингов 9.2, 9.3 начальные значения присваиваются только сегментным регистрам ds и es. Сегмент стека оставлен в покое, и это не случайность. Дело в том, что в объявлении сегмента стека: stack segment stack stack ends первое слово stack в строке stack segment stack может быть каким угодно, это про- сто название сегмента. А вот второе слово stack — служебное, оно показывает ассемблеру, что регистр стека ss надо настроить именно на этот сегмент. Поэто- му в нашей программе нет явного присваивания значения сегменту ss, ведь это уже сделали за нас ассемблер и операционная система. В заключение скажем несколько слов об отладчике AfdPro, заменяющем OllyDbg при работе с программами для DOS. Написанный в 80-х годах прошлого века немецким программистом Путкаммером (Н.-Р. Puttkammer), AfdPro неплохо смот- рится и двадцать лет спустя. AfdPro управляется командами, вводимыми с клавиатуры. Место, куда вводятся команды, помечено в окне отладчика значками CMD > (см. рис. 9.1). Самая важ- ная команда отладчика — QUIT (выход). Набрав ее и нажав Enter, мы покидаем отладчик и видим уже синие панели оболочки FAR. Самые важные клавиши, используемые при отладке программы, — F2 и F1. Пер- вая выполняет программу по шагам, причем вызов и возврат из процедуры счи- тается одним шагом. Клавиша F1 похожа на F2, но с ее помощью можно попасть внутрь процедуры и посмотреть, как выполняется каждая ее инструкция. Регистры процессора и компьютерную память AfdPro показывает в нескольких окнах. Вверху видны регистры и флаги, внизу — память (шестнадцатеричные коды и соответствующие им символы). В окне справа показана та же память, но без символьного представления. «Забираться» в различные окна отладчика позволяют клавиши F7, F8 (движение вверх-вниз) и F9, F10 (вправо-влево). Попав в окно, позволяющее увидеть па- мять компьютера, можно изменить не только сегментный регистр, но и любой байт. Естественно, память можно просматривать в любом направлении с помо- щью клавиш Ф Т. Результат работы программы можно увидеть, переключаясь между окном отлад- чика и экраном компьютера с помощью клавиши F6. Но прежде необходимо на- брать в окне отладчика команду mo a on (см. рис. 9.1) и нажать Enter. В отладчике AfdPro очень много возможностей, полное описание которых потре- бовало бы целой книги — никак не меньше той, что вы держите сейчас в руках. Но AfdPro понятен и так, а большая часть его команд описана в файле помощи, вызываемом клавишей F4. '
Глава 10 Жизнь в сегментах Ужимки и прыжки Нас посылают куда подальше. Благодаря этому мы движемся. Аркадий Давидович. Афоризмы Сегментация разобщает компьютерную память, ставит в ней множество не- нужных перегородок — сегментов, похожих на маленькие темные клетушки в изначально просторном и светлом офисе. Подобно служащему, вынужденно- му открывать множество дверей, путешествуя от одной комнатки к другой, программист должен каждый раз думать над тем, куда и как переходит про- цессор, чтобы указать ему кратчайший путь. Правильно указанный переход не только «ужимает» (делает короче) программу, но и заставляет ее быстрее выполняться. Самый простой и близкий переход позволяет отправить процессор на 128 байт назад или на 127 — вперед. Эти числа возникли не случайно, потому что длина прыжка кодируется в самой инструкции и занимает 1 байт, способный хранить числа от -128 до 127. Всего такая инструкция перехода занимает два байта. В сле- дующем фрагменте программы: mov ах. 2 ;0000 В80200 MOV АХ.0002 Jmp exit :0003 ЕВОЗ JMP 0008 mov ах. 3 ;0005 В80300 MOV АХ. 0003 exit: ;0008 показаны инструкции ассемблера и (в комментариях) соответствующие им коды и адреса, видные в окне отладчика. Так, например, инструкция mov ах, 2 имеет смещение 0000 (относительно сегмента cs) и занимает три байта. Ее код Ь80200, очевидно, содержит признак операции (В8) и само прибавляемое число 0002, но только вывернутое на изнанку по законам процессора Intel. Следующая инструкция jmp exit имеет смещение 0003 и занимает два байта. Первый из них (ЕВ) определяет саму инструкцию (процессор понимает, что пе- ред ним ближний переход в пределах 127 байт), а второй — длину прыжка отно-
144 Глава 10. Жизнь в сегментах сительно следующей инструкции. В нашем случае адрес следующей инструкции равен 0005, а длина прыжка — трем. Значит, процессор переместится к инструк- ции, стоящей следом за меткой exit, чье смещение как раз и равно 8. В этом разделе мы, пожалуй, впервые обратили внимание на двоичные коды ин- струкций процессора. Чем опытнее программист, тем больше он смотрит на эти коды и тем меньше — на инструкции ассемблера. Настоящие мастера способны читать прямо .ехе-файлы, даже не заглядывая в исходные тексты, но нам до это- го далеко. Будем пока интересоваться кодированием инструкций перехода, что поможет нам понять, почему их создано так много. Следующим по сложности переходом будет команда jmp, занимающая в памяти три байта и потому способная послать процессор на 32 768 байт назад и на 32 767 байт — вперед. Тот же самый отрывок программы, но уже с другим пере- ходом jmp, будет таким: mov ах. 2 jmp near ptr exit mov ax. 3 exit: 0000 B80200 MOV AX. 0002 0003 E90300 JMP 0009 0006 B80300 MOV AX. 0003 0009 Здесь, в отличие от предыдущего примера инструкция перехода jmp занимает три байта, и код ее начинается уже байтом Е9, а не ЕВ, как в прошлый раз. По этому байту процессор поймет, что перед ним инструкция перехода, занимаю- щая три байта, и будет рассматривать следующие два байта как длину прыжка относительно начала следующей инструкции, равную в нашем случае трем. Об- ратите внимание на то, как изменился текст программы. Вместо простого jmp exit стоит jmp near ptr exit. Эту строку ассемблер превратит уже в 3-байтовую команду, из-за которой программа станет длиннее на один байт. Следующий переход предназначен для путешествия «куда подальше» — в дру- гой сегмент, и будет полезно познакомиться с ним на примере программы, пока- занной в листинге 10.1. Листинг 10.1. Путешествие в другой сегмент .8086 stack segment stack BYTE 100 dup (?) stack ends codel segment assume cs:codel addd: mov ax. 2 add ax. 3 jmp far ptr exit codel ends code segment assume cs:code. ss:stack start: jmp far ptr addd exit: mov ah. 4ch int 21h
Ужимки и прыжки 145 code ends end start В ней заданы два кодовых сегмента — code и codel. Переход в другой сегмент за- дается инструкцией jmp far ptr addd, затем в сегменте codel складываются два числа, после чего инструкция jmp far ptr exit возвращает процессор в сегмент code. Инструкция jmp far ptr addd выглядит в окне отладчика так: ЕА00009Е2Е JMP 2Е9Е:0000 Она, как видите, занимает уже 5 байт памяти и содержит абсолютный адрес, со- стоящий из сегмента 2Е9Е и смещения 0000. Программа, показанная в листинге 10.1, — одна из самых глупых в этой книге. Чтобы сложить два числа, незачем тащиться в чужой сегмент. Но она служит хорошим пособием по дальним переходам, а большего нам и не надо. Подумаем, например, над тем, всегда ли нужно указывать ассемблеру, что пред- стоит дальний переход. Очевидно, строка: jmp far ptr exit необходима, потому что ассемблер, встретив ее, еще не знает, что метка exit на- ходится в другом сегменте. Ведь ассемблер MASM — однопроходный, то есть чи- тает текст программы только раз — сверху вниз. А вот второй переход: jmp far ptr addd. вроде бы не нуждается в операторе far ptr, ведь ассемблер, когда встретит ко- манду перехода, уже знает, что addd — «чужая» метка, расположенная в другом сегменте. Но ассемблер откажется компилировать программу, в которой пере- ход записан как jmp addd. Все равно придется явно указать ему, что addd — даль- няя метка, написав addd label far вместо addd:, и только тогда программа, чей от- рывок показан ниже, станет работать. codel segment assume cs:codel addd label far codel ends code segment assume cs:code, ss:stack start: jmp addd Помимо прямых переходов разной дальности, с которыми мы только что позна- комились, есть еще и косвенные переходы по адресу, задаваемому в регистре или памяти компьютера: mov ах, 2 :0000 В80200 MOV mov dx. offset exit :0003 BA0B00 MOV jmp dx ;0006 FFE2 JMP mov ax. 3 :0008 B80300 MOV exit: ;000B АХ. 0002 DX.OOOB DX АХ. 0003 Здесь адрес перехода посылается сначала в регистр dx инструкцией mov dx, offset exit, а затем уже происходит переход по указанному в этом регистре адресу
146 Глава 10. Жизнь в сегментах jmp dx. Обратите внимание, этот адрес абсолютный, а не относительный, как в преды- дущих примерах. Естественно, косвенный переход может быть не только ближним. Чтобы пере- скочить в другой сегмент, нужно записать в двойное слово памяти значение этого сегмента и смещение — примерно так, как в программе из листинга 10.2. Листинг 10.2. Косвенный переход в другой сегмент .8086 $w equ word ptr $o equ offset stack segment stack BYTE 100 dup (?) stack ends codel segment assume cs:codel addd: mov ax. 2 add ax. 3 jmp far ptr dlsp :возврат codel ends code segment assume cs:code. ss:stack start: mov $w faddr. $o addd mov $w faddr[2], SEG addd jmp faddr ;дальний переход dlsp: mov ah. 4ch int 21h faddr dd ? code ends end start В этой программе несколько новшеств. Во-первых, двойное слово faddr, хра- нящее адрес дальнего перехода, расположено в кодовом сегменте, на что име- ет полное право. Инструкции и данные могут находиться рядом, если процес- сор сможет отличить одно от другого. В нашем случае их нельзя спутать, потому что faddr находится «в тени» — после инструкций завершения про- граммы. Далее в программе применяется сокращенная запись операторов (вместо word ptr пишем $w, а вместо offset — просто $о). Эти сокращения определены в самом начале программы директивами equ. Наконец, нова сама организация дальнего перехода, чей адрес (по обычаю процессоров Intel) хранится в двойном слове faddr вывернутым наизнанку: сначала смещение (как младшая часть адреса), потом сегмент. Для вычисле- ния сегментного адреса метки в ассемблере есть специальный оператор SEG. Адрес сегмента записывается во вторую половину двойного слова faddr инст- рукцией: mov $w faddr[2]. SEG addd после чего сам переход оказывается крайне простым: jmp faddr
Межсегментные каналы 147 Межсегментные каналы Волго-Дон уникален тем, что соединил моря се- вера и юга, в два раза уменьшив водное расстоя- ние между ними. Поэтому Волго-Донской канал без преувеличения — сокровище России. Журнал «Деловые вести» Некоторые инструкции процессора нарочно созданы для того, чтобы преодолеть разобщенность сегментов и построить между ними подобие канала. Такова груп- па инструкций movs, позволяющих передать байт (movsb), слово (movsw) и двой- ное слово (movsd)* из одного сегмента в другой. Чтобы «соединить моря севера и юга», инструкцию movs нужно настроить так, чтобы пара сегментов ds:si содержала адрес переменной-источника, a es:di — адрес переменной-приемника. Затем содержимое переменной с адресом ds:si бу- дет скопировано инструкцией movs в новое место по адресу es: di. Если при этом флаг направления D опущен, то si и di синхронно увеличатся на число копируе- мых байтов (в нашем случае на 2). И если повторно выполнить инструкцию movsw, скопируется следующее слово. Программа из листинга 10.3 переписывает слово из сегмента north_sea в сегмент south_sea. Листинг 10.3. Переписывание слова из одного сегмента в другой .8086 stack segment stack BYTE 100 dup (?) stack ends north sea segment src WORD 3 north_sea ends south sea segment dst WORD ? south_sea ends code segment assume cs:code, ds:north_sea. es:south_sea assume ss:stack start: mov ax. north_sea mov ds. ax mov ax. south_sea mov es. ax mov si. offset src mov di. offset dst movsw mov ah. 4ch int 21h code ends end start Канал, устроенный инструкцией movs, не кажется очень эффективным — слишком много нужно приготовлений для пересылки одного слова. Но вспомним о пре- фиксе гер, с которым познакомились в разделе «Командная строка» главы 6. ‘Двойные слова под силу только процессору 80386 и выше.
148 Глава 10. Жизнь в сегментах С помощью гер инструкция movs может передать из одного сегмента в другой сколько угодно слов, что оправдает хлопоты, связанные с ее настройкой. Фраг- мент программы, пересылающей 100 слов из одного сегмента в другой, может быть таким: mov ах, north_sea mov ds, ах mov ах, south_sea mov es, ax mov si, offset src mov di, offset dst mov ex, 100 cld rep movsw Обратите внимание на инструкцию cld, которая опускает флаг направления, за- давая тем самым автоматическое увеличение адресов при передаче данных меж- ду сегментами. Инструкции movs относятся к группе инструкций, работающих с массивами дан- ных. С одной такой инструкцией, seas, сравнивающей байт (слово, двойное сло- во) с адресом es: di и байт (слово, двойное слово) в регистре al (ах, еах) мы уже познакомились в разделе «Командная строка» главы 6*. Будет логично упомя- нуть в этом разделе и другие полезные инструкции из этой группы. Всего ближе к movs пара инструкций lods, stos. Первая читает байт (слово, двой- ное слово) по адресу ds:si и записывает его в регистр al (ах, еах). Вторая чи- тает байт (слово, двойное слово) из регистра al (ах, еах) и записывает его по адресу es:di. Обе инструкции увеличивают или уменьшают (в зависимости от флага направления D) регистры si (di) на число прочитанных (переданных) байтов. Из пары инструкций lods, stos можно составить инструкцию movs: mov ах, north_sea mov ds, ax mov ax, south_sea mov es, ax mov si, offset src mov di, offset dst lodsw прочитать слово ;<здесь сообщение можно перехватить> stosw записать слово Как видите, между lodsw и stosw можно поставить подслушивающее устройство, способное запоминать и менять передаваемые данные. Кроме упомянутых, есть еще инструкция emps, которая не передает данные меж- ду сегментами, а сравнивает их между собой. Такая инструкция полезна, когда нужно найти отличия во внешне похожих массивах данных. Попробуем, например, сравнить два почти одинаковых «стога», которые отлича- ются тем, что в одном есть иголка, а во втором — нет. Первый стог хранится в сег- менте hayl, второй — в сегменте hay2 (листинг 10.4). *Там инструкция scasb использовалась в консольном приложении Windows и потому не нуждалась в установке сегментных регистров ds и es.
Межсегментные каналы 149 Листинг 10.4. Поиск «иголки» в стоге «сена» .8086 stack segment stack BYTE 100 dup (?) stack ends hayl segment equal BYTE "Равны”. 13. 10. Ё$Ё nequal BYTE "He равны”. 13. 10. Ё$Ё src BYTE "сеносеносеносеносеносено" zsize WORD ($-src) hayl ends hay2 segment dst BYTE "сеносеноиголкасеносеносе" hay2 ends code segment assume cs:code, ds:hayl. es:hay2. ss:stack start: cld mov ax, hayl mov ds. ax mov ax. hay2 mov es. ax moV si. offset src mov di. offset dst mov ex. zsize repe empsb mov dx, offset nequal cmp ex, 0 jnz disp mov dx. offset equal disp: mov ah. 09 int 21h mov ah. 4ch int 21h code ends end start Программа, показанная в листинге, сравнивает две последовательности симво- лов. Первая находится в сегменте hayl и помечена как src, вторая (с меткой dst) хранится в сегменте hay2. В центре этой довольно длинной программы — ин- струкция repe empsb, сравнивающая последовательности символов. Инструкция crops, подобно movs, после каждого сравнения увеличивает (или уменьшает, если поднят флаг направления) si и di на число сравниваемых за раз байтов (в на- шем случае на 1). Чтобы инструкция empsb работала правильно, ее нужно подготовить так же, как ин- струкцию movs: задать сегментные регистры и смещения строк. В регистр сх посы- лается размер сравниваемых строк zsize, вычисляемый ассемблером в процессе компиляции. Мы уже встречались с таким способом в разделе «Переходы» главы 4. Префикс repe означает «повторять, пока равно». Если строки идентичны, то про- цессор сделает столько сравнений, сколько указано в регистре сх. В этом случае сх будет равен нулю после выполнения всех инструкций repe empsb. Если же строки от- личаются, инструкции crops прекратят выполняться и сх будет отличен от нуля. В зависимости от того, равен или не равен сх нулю, программа покажет на экране
150 Глава 10. Жизнь в сегментах фразу Равны или Не равны. Заметим, что сообщения Равны и Не равны должны быть именно в сегменте hayl, потому что процедура DOS, показывающая их на экране, требует, чтобы смещение сообщения, засылаемое в регистр dx инструк- цией: mov dx, offset <сообщение> указывалось относительно сегмента ds. Инструкции seas, movs, crops, lods, stos, с которыми мы только что познакомились, работают и в консольных приложениях Windows. Но там у программы всего один сегмент, поэтому инструкциям нужны только смещения, что сильно упрощает работу не только с этими, но со всеми остальными инструкциями. Нам осталось сказать, что префиксы гер, гере, герпе (повторять, пока не равно) работают только с инструкциями seas, movs, crops, stos. Бессмысленно использо- вать их с такими инструкциями, как mov. Процедуры Созданная в разделе «Ужимки и прыжки» программа (см. листинг 10.1) демон- стрирует дальний переход в чужой сегмент, где складываются два числа, и даль- ний же возврат в основную программу. То, что она проделывает, больше всего напоминает вызов процедуры, которая может вернуться только к метке exit в основной программе. Так, конечно, делать нельзя: необходимо превратить ин- струкции в процедуру, которая возвращается, подобно бумерангу, точно в то место, откуда была запущена. Мы уже хорошо знаем, что все это делается с помощью инструкций cal 1 и ret. Правда, в случае DOS приходится думать, какой вызов (далекий или близкий) нужен процедуре и какой возврат. Программа, показанная в листинге 10.5, вы- зывает дальнюю процедуру, расположенную в «чужом» сегменте codel. Листинг 10.5. Дальний вызов процедуры .8086 stack segment stack BYTE 100 dup (?) stack ends codel segment assume cs:codel f_add proc far mov ax, 2 add ax, 3 ret ;CB RET Far f_add endp codel ends code segment assume cs:code, ss:stack start: call f add ;9A00009E2E CALL 2E9E:0000 mov all, 4ch int 21h code ends end start
Процедуры 151 Процедура f add объявлена как f_add proc far. Это значит, что ей нужен дальний вызов с указанием сегмента и смещения и дальний же возврат. То есть инструк- ция ret в процедуре должна доставать из стека сегмент и смещение, предвари- тельно сохраненные там еще до ее вызова. Что касается вызова процедуры, то он будет по умолчанию дальним, раз она на- ходится в другом сегменте. А вот возврат получился дальним из-за того, что про- цедура объявлена как far. В листинге 10.5 инструкции вызова процедуры и возврата показаны в коммен- тариях такими, какими видит их отладчик. В инструкции вызова cal 1 явно ука- заны сегмент и смещение: 9А00009Е2Е CALL 2Е9Е:0000 а вместо инструкции ret отладчик показывает дальний возврат ret far: СВ RET Far, который достает из стека два слова: сначала смещение, а затем сегмент. По- лучается так потому, что при вызове процедуры последним сохраняется смеще- ние, ведь стек растет в сторону уменьшения адресов и, согласно правилам про- цессора Intel, младшая часть двойного слова (смещение) должна иметь меньший адрес. Очевидно, ассемблер ставит инструкцию дальнего возврата, потому что про- цедура объявлена дальней (far). Не будь этого словечка, процедура считалась бы по умолчанию ближней и код инструкции возврата был бы уже другим (СЗ). Нужную инструкцию возврата можно задать и вручную: дальний возврат запи- сывается как retf, а ближний — retn. Вручную можно выполнить и вызов процедуры. Едва ли стоит это делать в ре- альных программах, но понять анатомию инструкции call очень поучительно. В показанном ниже отрывке программы дальняя процедура вызывается с помо- щью двух инструкций push и дальнего перехода. start: push cs mov ax. offset exit push ax jmp far ptr f_add exit: code ends end start Эти два заталкивания в стек и следующий за ними дальний переход очень напо- минают инструкцию call, только иначе записанную. Перейдя к началу процеду- ры и выполнив все, что требуется, процессор встретит на выходе инструкцию дальнего возврата, которая направит его туда, куда указывают сохраненные сме- щение и сегмент. Заметим, что сохранение адреса возврата в стеке с последующим дальним пере- ходом отличается от инструкции cal 1 тем, что затолкнуть в стек можно любой адрес, а не только адрес инструкции, непосредственно следующей за вызовом call. То есть, сочетая сохранения в стеке и дальний переход jmp far, можно за- ставить процедуру возвратиться (с помощью ret) куда угодно. Инструкцию retf можно использовать и вне процедуры, чтобы выполнить за- маскированный дальний переход. Для этого нужно перед retf сохранить в стеке
152 Глава 10. Жизнь в сегментах нужный адрес. В программе из листинга 10.6 с помощью инструкции retf как раз и совершается переход к метке target, находящейся в другом сегменте. Листинг 10.6. Замаскированный переход к метке target .8086 stack segment stack BYTE 100 dup (?) stack ends codel segment assume cs:codel target: jmp far ptr exit codel ends code segment assume cs:code, ss:stack start: mov ax, SEG target push ax mov ax, offset target push ax retf exit: mov ah, 4ch int 21h code ends end start Сначала в стеке сохраняется сегментный адрес метки: mov ах. SEG target push ах Затем ее смещение: mov ах, offset target push ах А сам переход выполняет инструкция дальнего возврата retf. Аналогично вы- полняется и ближний переход. Нужно только использовать retn вместо retf и сохранить в стеке одно смещение. До сих пор мы вызывали процедуру, расположенную в другом сегменте. Когда же она находится в «родном», все упрощается. Если процедура должна вызываться из- вне и потому объявлена как far, можно использовать дальний вызов call far ptr <имя>. Если же вызывать такую процедуру как ближнюю инструкцией call <имя>, то ассемблер автоматически вставит перед вызовом инструкцию push cs, чтобы пра- вильно сработал дальний возврат. Так поведет себя ассемблер MASM. В сомни- тельных случаях программу нужно обязательно проверять отладчиком и вручную вставлять инструкцию push cs, если ассемблер этого не делает сам. В частности, push cs приходится вставлять вручную при косвенном вызове под- программы, показанном в листинге 10.7. Листинг 10.7. Косвенный вызов подпрограммы .8086 stack segment stack BYTE 100 dup (?) stack ends code segment
Процедуры 153 assume cs:code, ss:stack start: mov bx, offset f_add push cs сохранить сегмент call bx :вызов f_add mov ah, 4ch int 21h f_add proc far mov ax,' 2 add ax, 3 ret f_add endp code ends end start Процедура f add объявлена в нем как far, и потому до ее вызова приходится со- хранять в стеке регистр cs. Инструкция call bx осуществляет ближний вызов процедуры, то есть сохраняет в стеке регистр Ьх, хранящий смещение f_add, и потом переходит к самой метке f add. Но перед вызовом в стеке был сохранен еще сегментный регистр, что обеспечит правильный дальний возврат. Завершим этот раздел примерами косвенного вызова процедуры, когда ее адрес хранится в памяти компьютера, а не в регистре (листинг 10.8). Листинг 10.8. Вызов процедуры, чей адрес хранится в памяти .8086 stack segment stack BYTE 100 dup (?) stack ends code segment assume cs:code, ss:stack start: push cs call nearp call farp mov ah, 4ch int 21h f_add proc far mov ax, 2 add ax, 3 ret f_add endp nearp WORD f add farp DWORD T_add code ends end start В нем сначала совершается ближний вызов cal 1 nearp, где nearp — слово в ком- пьютерной памяти, хранящее смещение процедуры. Поскольку сама процедура — дальняя, перед ее вызовом в стек загружается cs. Второй вызов процедуры cal 1 farp ассемблер автоматически делает дальним, потому что farp — двойное слово, содержащее (как надеется ассемблер) сегмент и смещение. Без всякого сомнения, самое сложное в этом примере — объявления перемен- ных пеагр и farp: nearp WORD f_add farp DWORD f_add
154 Глава 10. Жизнь в сегментах Мы привыкли, что метка — это содержимое переменной. В этом нас, казалось бы, убеждает отрывок программы: mov ах.digit :0000 2ЕА10800 MOV АХ.CS:[0008] digit WORD 3 :0008 0300 после исполнения которого в регистре ах оказывается тройка. Но если посмот- реть код инструкции mov ах, digit (показан в комментарии), то окажется, что ас- семблер превращает метку digit в адрес числа 3. Так что метка для ассембле- ра — это адрес. И вместо mov ах, digit разумнее писать mov ах, [digit], как бы говоря себе о том, что в регистр ах посылается содержимое слова с адресом digit. Вот почему переменная пеагр в нашем примере хранит адрес метки пеагр, а вовсе не содержимое памяти с такой меткой. Адресация Адреса содержатся и во всех других инструкциях ассемблера, имеющих дело с пе- ременными, хранящимися в памяти. Нам уже знакома косвенная адресация mov ах, [Ьх]’, где адрес (смещение) слова в памяти хранит регистр Ьх. Встречалась нам и адресация, полезная при работе с массивами: mov al, array[s1] где si добавляется к адресу начала массива array и в результате получается ад- рес его элемента под номером si”. Процессоры 8086 и 80286 могут использовать для такой адресации четыре регистра: bx, bp, si, di. Мы уже привыкли к тому, что при адресации в квадратных скобках стоит нечто, содержащее адрес. А поскольку array и si в нашем последнем примере тоже об- разуют адрес, то можно заключить их в квадратные скобки и записать инструк- цию так: mov al. [array + si] Конечно, это только другая запись; код инструкции, сгенерированный ассембле- ром, будет и в том и другом случае одинаковым. Кроме перечисленных, процессоры 8086 и 80286 могут использовать и другие способы адресации с использованием двух регистров. Разные варианты такой ад- ресации показаны на рис. 10.1. “с и:: и™}’ Рис. 10.1. Адрес может быть суммой двух регистров и смещения Чтобы указать правильный адрес, нужно взять по регистру из каждой колонки и (если это необходимо) добавить смещение. Ассемблер согласится искать в па- *При косвенной адресации для процессоров 8086 и 80286 могут использоваться только три регист- ра: bx, si, di. ’’Только если в массиве хранятся байты.
Адресация 155 мяти переменную с адресом [bx + si] или [bp + di]’, но окажется бессилен сде- лать что-нибудь с адресом [Ьх + Ьр] или [si + di]. В программе из листинга 10.9 показано, как можно использовать новую адреса- цию для записи чисел в одномерный массив array. Листинг 10.9. Адресация с помощью двух регистров .8086 ARRSIZE equ 20 stack segment stack BYTE 100 dup (?) stack ends code segment assume cs:code. ds:code, ss:stack start: mov bx. offset array mov si. 5 shl si. 1 mov word ptr [bx+si], 3 mov ah. 4ch int 21h array WORD ARRSIZE dup (?) code ends end start В ней адрес начала массива загоняется в регистр Ьх инструкцией mov bx, offset array. Далее в регистр si записывается число 5 — номер элемента массива. А по- скольку в массиве array хранится ARRSIZE слов, то si нужно еще умножить на 2, чтобы получить адрес элемента относительно начала массива. А дальше инст- рукция mov word ptr [bx + si], 3 записывает число 3 в пятый элемент массива. Заметим, что адрес [bx + si] можно представить как [bx][si]. Для ассемблера обе записи эквивалентны и потому будут превращены в одну и ту же инструк- цию процессора. Как видите, способов адресации для процессоров 8086 и 80286 довольно много. Но с появлением процессора 80386 их стало настолько больше, что глядя на рис. 10.2, где они показаны, можно подумать, что речь идет совсем о другом процессоре. ЕАХ ЕСХ EDX ЕВХ ЕВР ESI EDI Рис. 10.2. Способы адресации для процессора 80386 Чтобы указать адрес для процессора 80386, достаточно заключить в квадратные скобки один из регистров из левой колонки [edx] или один из регистров из сле- дующей колонки (умноженный на 2, 4, 8) [esi*2] или просто число [4856] или же ’Если в адресе есть регистр Ьр, то по умолчанию адресация идет относительно сегмента стека ss.
156 Глава 10. Жизнь в сегментах число, но представленное меткой [label], или, наконец, любую комбинацию раз- ных колонок (не обязательно всех), в которой регистры не совпадают, например: [еах + edx*8 + 42] Увидев в квадратных скобках эти регистры, ассемблер создаст инструкцию, ко- торая сложит содержимое еах с числом, хранящимся в edx, умноженным на 8, и прибавит к полученной сумме 42*. Полученное число будет для процессора адресом переменной, с которой ему придется сделать то, что приказано. Всего чудеснее в такой адресации возможность умножать регистры, стоящие во второй колонке, на 2, 4 или 8, что автоматически позволяет сформировать адрес нужного элемента массива, пользуясь регистром как индексом. Если переписать программу из листинга 10.9 для процессора 80386, то запись числа 3 в пятый элемент массива array выглядела бы так: mov ebx, offset array mov esi. 5 :esi - индекс mov word ptr [ebx+esi*2], 3 :esi*2 = адрес или еще проще: mov esi. 5 mov word ptr array[es1*2]. 3 Число способов адресации кажется чрезмерным (особенно для процессора 80386), хотя наверняка найдутся задачи, где можно с пользой применить самые слож- ные из них. Но оказывается, адресацию можно использовать там, где нет и речи об адресе! Ведь адрес — это всегда некое арифметическое выражение, где к регистру при- бавляется другой регистр, умноженный на двойку, четверку или восьмерку, а к по- лученной сумме прибавляется (или из нее вычитается) произвольное число. Причем, процессор вычисляет это выражение где-то в своих недрах, «разом», ведь результат должен использоваться как адрес. Но не обязан. Полученную сумму можно считать не адресом, а просто суммой чисел, которая вычисляется для чего-то другого. Эту способность процессора легко вычислять арифметические выражения опре- деленного вида использует инструкция lea, чье название состоит из первых букв английской фразы Load Effective Address (загрузить эффективный адрес). Чтобы понять, как она работает, сравним две инструкции: mov еах. [ebx+es1*2] lea еах. [ebx+esi*2] Первая посылает в регистр еах содержимое двойного слова с адресом ebx + esi * 2. Вторая посылает в еах сам адрес, то есть сумму ebx и умноженного на 2 регистра esi. Эта сумма может быть адресом, а может и не быть, и потому мы вольны ис- пользовать ее как угодно. Но поскольку изначальный смысл инструкции lea все-таки в получении адреса, ее можно использовать так же, как и оператор offset. Инструкции: mov bx. offset arr ;BB1400 MOV BX. 0014 lea bx. arr :8D1E14OO LEA BX. [0014] Число можно и вычесть, то есть возможен и такой адрес: [еах + edx*8 — 42].
Прерывания 157 посылают в регистр Ьх одно и то же число — адрес, связанный с меткой агг. Но инструкция lea занимает больше места в памяти, и потому оператор offset мо- жет быть выгодней там, где эту память приходится экономить. Прерывания В любой операционной системе есть набор стандартных процедур, с помощью которых программа взаимодействует с внешней для нее средой: клавиатурой, эк- раном монитора, музыкальной платой, сетевой картой, последовательным пор- том и самой операционной системой. Мы уже знакомы с некоторыми процеду- рами Windows API, такими как WriteConsolе илй ExitProcess. Они, как мы помним, вызываются так же, как и обычные процедуры ассемблера. В системе DOS все устроено иначе. DOS API — это набор особенных процедур, называемых прерываниями. У каждого прерывания есть номер и параметры, ко- торые передаются в регистрах процессора. Так, например, прерывание INT 21h, с помощью которого на экран выводится строка символов, управляется двумя параметрами: в регистре ah должно быть число 9, а в регистре dx — адрес первого байта (относительно сегмента ds) строки симво- лов, оканчивающейся значком $ (см. листинг 9.1). Прерывания под номером 21h (33 - в десятичной системе счисления), чье дейст- вие определяется регистром ah, называются функциями DOS, у них нет назва- ний, а только номера. Говоря о девятой функции DOS имеют в виду прерыва- ние 21h с параметром ah, равным 9. Различных функций DOS порядка сотни. Многие книги содержат их полное опи- сание*. Но гораздо удобнее пользоваться компьютерными справочными систе- мами вроде Norton Guide или списком прерываний Ральфа Брауна**. Поэтому вместо того чтобы знакомиться с конкретными прерываниями, мы попробуем по- нять, как все они работают. Прерывания, как мы уже поняли, — это разновидность процедур. Выполнив пре- рывание, процессор возвращается к следующей за ним инструкции — так же, как и после вызова процедуры. Но, в отличие от процедуры, перед вызовом прерыва- ния процессор сохраняет в стеке текущей программы не только сегмент и смеще- ние следующей команды, но и регистр флагов! Почему он так делает, мы поймем чуть позже, а пока ясно, что обычная инструкция ret не годится для выхода из прерывания, потому что она достает из стека только два регистра, а поскольку ре- гистр флагов сохраняется в стеке последним, инструкция ret достанет из стека совсем не то, и процессор безнадежно запутается. Поэтому для выхода из преры- вания существует специальная инструкция iret (Interrupt Return — «Возврат из прерывания»), которая загоняет в регистр флагов содержимое вершины стека, за- тем достает из стека сегмент и смещение следующей за прерыванием команды и от- правляет по этому адресу процессор. Заметим, что прерывания всегда дальние, то *Например: Р. Данкан. Профессиональная работа в MS-DOS. М.: Мир, 1993. **И Norton Guide и список прерываний легко найти в Интернете. Достаточно поискать в системе Google (www.google.com) фразы «Norton Guide» и «Ralf Brown’s Interrupt List».
158 Глава 10. Жизнь в сегментах есть инструкция i nt <номер> сохраняет в стеке обязательно и сегмент, и смеще- ние следующей инструкции, а сам процессор тоже идет «куда подальше» — ад- рес перехода к прерыванию всегда состоит из сегмента и смещения. Осталось понять, что это за адрес, то есть куда идет процессор, после того как инструкция прерывания сохранила в стеке адрес возврата и регистр флагов. Оказывается, адрес «куда пойти» содержится в специальной таблице, занимаю- щей в компьютере, работающем под управлением DOS, первые 1024 байт памя- ти. Адрес нулевого прерывания хранится в первых 4 байт этой таблицы (снача- ла смещение, затем сегмент). Адрес прерывания 21h занимает в этой таблице 33 место. Зная номер прерывания, процессор просто умножает его на 4, затем обра- щается к таблице и получает там адрес перехода. Увидеть этот адрес можно и вручную, если правильно настроить один из сегментных регистров. Например, адрес перехода для прерывания 21h можно получить так: mov ах. О mov es. ах :es = О mov bx. 21h ;номер прерывания shl bx. 2 ;умножим на 4 mov ах, es:[bx] :смещение mov dx. es:[bx+2] хегмент Так определяются адреса перехода для прерываний в системе DOS. В Windows нет ни сегментов, ни смещений, поэтому там каждой программе для DOS под- меняют адрес перехода по прерыванию, после чего он становится 32-разрядным. Вот почему отладчик AfdPro может видеть в первых 1024 байт памяти одни адре- са, а инструкциями mov ах. es:[bx] :смещение mov dx, es:[bx+2] ;сегмент в регистры ах и dx будут записаны совсем другие. Но большинство программ это- го не заметят, продолжая жить так, как будто ими управляет система DOS. Прерывания, с которыми мы только что познакомились, называются программ- ными. Встретив инструкцию int 21h, процессор прерывает как бы сам себя. Но бывают так называемые аппаратные прерывания, чей источник лежит вне про- цессора. Сигналы этих прерываний поступают процессору от внешних устройств, таких как клавиатура или жесткий диск. Многое эти устройства способны выпол- нить самостоятельно, без участия процессора. Но иногда процессор им все-таки нужен. Например, при нажатии клавиши нужно прочитать введенный символ и запомнить его в буфере. Но процессор один, а устройств, которым он нужен, мно- го. Поэтому устройство, когда это ему необходимо, должно заставить процессор работать на себя, послав ему запрос на прерывание и его номер. Если прерывания разрешены, процессор запоминает в стеке адрес возврата и регистр флагов, полу- чает адрес программы, обрабатывающей прерывание, делает что требуется, пока не встретит инструкцию 1 ret, возвращающую его к прерванной работе. Теперь становится понятно, почему прерывание требует сохранять в стеке не толь- ко адрес возврата, но и регистр флагов. Ведь аппаратное прерывание, в отличие от программного, возникает в случайный момент времени. И может, например, попасть между операцией сравнения и инструкцией перехода’: * Перед выполнением прерывания процессор обязательно завершит текущую инструкцию.
Прерывания 159 cmp ах. О <прерывание> jnz label Результат сравнения находится в регистре флагов, и если его не сохранить, про- цессор после обработки аппаратного прерывания пойдет не тем путем и в ре- зультате безнадежно запутается.
Глава 11 Model flat для DOS .сом После знакомства с программированием для DOS возникает ощущение тесноты и неудобства. Программа для 16-битовых процессоров живет в памяти, как се- мья из пяти человек в однокомнатной квартире. И программисту приходится расплачиваться за глупое устройство процессора, выбирая всякий раз нужный сегмент, тип перехода и возврата. Но если программа умещается в одном сегменте (а все наши предыдущие про- граммы — именно такие), слова near и far можно забыть, потому что все перехо- ды, вызовы и возвраты автоматически станут близкими (near). И тогда все бу- дет почти как в «плоской» модели памяти для Windows, разница лишь в том, что там сегмент занимал 4 Гбайт, а здесь — 64 Кбайт. Для таких программ в сис- теме DOS придуман специальный формат .сот. Главный признак программы в формате .сот — директива org 100h (листинг 11.1), указывающая ассемблеру, что все адреса нужно вычислить, исходя из того, что первая инструкция программы сдвинута относительно начала сегмента на 100h (256 - в десятичной системе). Листинг 11.1. Простейшая .сот-программа .8086 cseg segment assume cs:cseg org OlOOh ini: mov ah.9 mov dx.offset message Int 21h mov ah.4ch Int 21h message BYTE "Не могу молчатьODh.OAh.Ё$Ё cseg ends end ini Программа из листинга 11.1 выводит на экран привычное Не могу молчать!, но данные теперь спрятались в тени инструкций — там, куда процессору не дойти.
.COM 161 В .com-программе есть масса способов сочетать в одном сегменте данные и ко- манды так, чтобы они не перемешивались. Можно, например, первой командой сделать безусловный переход к инструкциям процессора, а данные разместить в тени этого перехода: cseg segment assume cs:cseg org OlOOh ini: jmp first <данные> first: <инструкции> cseg ends end ini А можно спрятать данные, имеющие отношение к процедуре, после инструкций ret: <имя процедуры> proc ret <данные> <имя процедуры> endp Поскольку .сот — особый формат программы, компилятор должен иначе обра- батывать исходный текст, поэтому нам понадобится другой командный файл, показанный в листинге 11.2 Листинг 11.2. Командный файл cmake.bat для создания .сот-программ ml /с И.asm 11 nkl6 Ж1.obj Д1.ехе.... exe2bin И.ехе И.сот В нем специальная утилита превращает файл с расширением .ехе в файл с рас- ширением .сот’. Удивителен размер этого файла. Программа из листинга 11.1 занимает всего 30 байт! Таких коротких программ мы еще не встречали, потому что формат .сот не содержит никакой служебной информации, в нем хранятся только команды процессора и данные. Лишь перед самым выполнением операционная система «пристраивает» к про- грамме, хранящейся в .com-файле, заголовок, так называемый PSP (префикс программного сегмента), хранящий служебную информацию и занимающий ровно 256 байт (100 в шестнадцатеричной системе). Теперь нам становится понятна директива org 100h. Она как раз и показывает ассемблеру, что смещение первой инструкции программы в выделенном ей сег- менте равно 1 ООН, а первые байты сегмента (PSP) будут заняты чем-то другим. Туда, например, должна попасть командная строка программы, потому что ей просто некуда больше деваться. Чтобы понять, где она расположена, не обяза- тельно рыться в справочниках по DOS, достаточно исследовать программу с по- мощью отладчика. *Не обращайте внимания на предупреждение компоновщика о том, что в программе нет сегмента стека (по stack segment).
162 Глава 11. Model flat для DOS Попробуем запустить программу, показанную в листинге 11.1, со специальной легко узнаваемой командной строкой. Набрав в оболочке FAR: afdpro 1101.com zzzzz увидим в окне отладчика примерно то же, что и на рис. 11.1. И I П П ЙХ 0000 BX 0000 ex зон OX 0000 SI 01 BP SP 8000 8000 0000 FFFE CS OS ES SS 1E0C 1E0C 1E0C 1E0C ip OS FS 1E0C 1E0C Stack *0 0000 *2 20CO *4 9FFF -6 9000 Flaos OF OF 0 0 2d 3202 IF SF ZF 1 О О OF PF OF 0 0 0 сна > 10100 0102 BIOS 0107 0109 0108 01OF 0110 8409 ВЙ0ВО1. £021 B44C C021 8ОЙ520ЙС ЙЕ ЙЗЕЭ20 НОУ AH ? 09 IHT MOV 1HT LEO SC8SB MOV OX.0108 21 ЙНЛС 21 SM8C204H1 120ЕЭКЙХ - OS;0000 O$:O008 I 08:0010 O$:0018 OS:0620 OS:0028 OS:0O30 OS:0038 OS:0040 08:0048 0 CO 10 25 81 FF FF 82 FF 05 00 2 FF 18 56 01 FF FF 14 FF 1 20 FO 04 01 FF FF 05 FF 09 00 09 90 3 9F 05 01 00 FF FF 00 FF 00 00 4 00 Й5 25 02 FF CB 18 00 00 00 5 9Й Oft 04 FF FF 10 00 00 00 00 00 6 F0 4B 72 FF CO 0C 00 00 Fl 01 05 FF И IE 00 00 00 hr IS: 0000 OS:0010 OS:0020 OS:0030 OS:0040 0 eo 25 FF. 82.... ..... 05 00 00 00 1 20 04 FF 05 2 FF 56 FF S 9F 01 ...FF 14 00 4 5 00 90 25 04 FF FF IB 00 00 00 8 10 81 FF FF 6 7 re re 72 05 FF FF ОС IF........ 00 00 00 9 FO 01 FF FF 00 00 00 Й IB 01 FF FF 8 05 00 FF FF 0 00 FF 10 00 E 4B FF CO 00 05 02 CB 00......... 00 00 00 00 F 01 FF 11 00 B; 81 - ЯЛЁ* Ш.г. dn H Step ®ProcStepE8«trieveEHelp ON S§8RK Mem 21 up 9ИЯШ le 0100 Рис. 11.1. Программа .com сразу после загрузки В верхней части рисунка показаны сегментные регистры CS, DS, ES и SS. Сразу после запуска .com-программы их значения одинаковы (в нашем случае все они равны 1Е0С). Значит, для доступа к префиксу PSP можно использовать любой сегмент, например DS, как это сделано в нижнем окне отладчика. Каждый байт памяти един в двух лицах: слева в окне показано его численное значение в шест- надцатеричных кодах, справа — соответствующий ему символ. Например, сим- волу % соответствует код 2516. Какой код соответствует символу «г» мы пока не знаем, потому что таких сим- волов в правом нижнем окне нет. Значит, командная строка расположена даль- ше, и чтобы увидеть ее, нужно переместиться к следующим байтам PSP. Для этого спустимся в нижнее окно с помощью клавиши F8, затем, нажав клавишу Ф, перейдем к байтам с большим смещением и, наконец, увидим командную строку (рис. 11.2). Первый символ командной строки — всегда пробел (его код — 2016). Его смеще- ние относительно сегмента программы — 811б. А дальше (в байтах со смещением 82-86) видны символы «г» (их код 7А16). Легко догадаться, что в байте со сме- щением 8016 записано общее число символов командной строки, равное в нашем случае 6 (пробел + 5 символов «z»). Зная, где находится командная строка, можно использовать ее для управления программой или просто вывести на экран, как в листинге 11.3.
Рекурсия 163 S1OB 1OF IIS !S 8D0520RC OF 03E320 LEA MM 0829*01 J SCASB MOV !20E3],0X OS. 9038 OSiSOAS 0048 OS FF FF FF FF 98 OS OS OS SS SO SO OS OS OS OS OS so so so os os os os os OS:O0SO DS-0000 0$10000 DS-9080 DS100CO 0 96 90 90 90 90 1 20 00 00 S0 00 70 09 00 DO 00 0 70 00 00 00 DO 70 00 00 00 Q Step 70 00 00 Os 00.00 79 09 09 09 09 7 00 00 OS OS 00 8 00 90 00 00 00 9 0O 90 00 90 OS 0 so 00 00 00 00 В 00 00 00 00 00 SO SO 90 90 00 0 00 00 00 00 00 E 00 00 00 00 00 00 00 00 00 00 Al SProcS tepKRe tr i evdEHe Ip ON Hera up El El le BRK Рис. 11.2. Командная строка внутри PSP Листинг 11.3. Вывод на экран командной строки .8086 cseg segment assume cs:cseg org OlOOh ini: xor bx. bx mov bl.cs:[80h] add bl,82h mov [bx], byte ptr Oah mov [bx+1]. byte ptr Ё$Ё mov ah. 9 : вывести строку mov dx. 81h ; адрес строки - в DX 1 nt 21h mov ah. 4ch : выход int 21h cseg ends end ini Инструкция mov Ы, cs: [80h] узнает размер командной строки. Указывать сегмент здесь необходимо, потому что иначе ассемблер воспримет инструкцию mov Ы, [80] как пересылку числа 80h в регистр Ы. Далее программа записывает в ко- нец командной строки символы Oah (перевод строки) и *$’, после чего строка готова к выводу на экран, что и делает функция DOS под номером 9. Задача 11.1. Перепишите программу из листинга 11.3 так, чтобы командная строка выводилась на экран без использования информации о ее длине. Рекурсия Два поезда мчатся навстречу друг другу с огромной скоростью по одноколейной дороге. И знаете, что произошло? Они даже не встретились. Почему? Не судьба. Анекдот «от Никулина» Занимаясь командной строкой в программе формата .сот, мы забыли о стеке, который используется всегда, даже если в программе и нет явных инструкций push и pop. Например, в листинге 11.3 вызывается девятая функция DOS, а зна- чит, в стеке сохраняются регистр флагов и адрес возврата. Поэтому нужно знать,
164 Глава 11. Model flat для DOS где расположен стек, чтобы сохраняемые в нем числа не «наехали» на данные или инструкции программы. В предыдущем разделе мы поняли, что значения всех сегментных регистров при загрузке .com-программы одинаковы. Значит, положение стека относительно са- мой программы определяется указателем SP, который, согласно рис. 11.1, равен при загрузке программы FFFE. Иными словами, стек расположен в конце сегмен- та, занимаемого программой, и несется в сторону уменьшения адресов навстре- чу инструкциям и данным (рис. 11.3) — почти как поезд из анекдота. cs: 0000 cs: 0100 PSP Инструкции и данные t Стек cs: ffff Рис. 11.3. Устройство программы в формате .сот И кажется, что встретиться им не судьба, потому что не видно, за счет чего стек пройдет навстречу программе больше нескольких шагов. Ведь каждый вызов процедуры, требующий сохранения в стеке параметров, локальных переменных* и адреса возврата, сопровождается выходом из нее, заставляющим стек двигать- ся в обратном направлении — к границе сегмента. Даже если одна процедура вызывает другую, та — третью, и т. д., стек не уйдет далеко от границы сегмента, потому что трудно представить себе, зачем программе нужен хотя бы десяток таких вызовов. Но оказывается, возможны не только десятки, но сотни, тысячи вызовов, когда процедура обращается сама к себе. Такие вызовы, часто называемые рекурсив- ными, заставляют стек нестись навстречу программе, потому что туда все время загружаются параметры процедуры и адрес возврата. Чтобы лучше понять рекурсивные вызовы, попробуем вывести на экран число, хранимое в двухбайтовом регистре. Программируя под Windows, мы выводили число на экран, пользуясь стандартной процедурой Windows API wsprintf (см. раздел «Вывод чисел» главы 4). Но в системе DOS нет подходящей функ- ции, и приходится превращать число в символы самому. Сделать это довольно легко, последовательно деля число на 10 и превращая в под- ходящий символ получившийся остаток. Возьмем, к примеру, число 123. Деля его на 10, получим частное 12 и остаток 3. Далее, деля новое частное на 10, по- лучим частное 1 и остаток 2. И, наконец, деля новое частное в третий раз, полу- чим остаток 1 и частное 0. Полученные при последовательном делении остат- *Локальных переменных может быть так много, что стек переполнится сразу, но мы здесь рассмат- риваем случай «постепенного» переполнения стека за счет многократного вызова процедур.
Рекурсия 165 ки — 3, 2, 1 — и есть цифры нашего числа, только идущие в обратном порядке. И теперь перед нами возникают две новые задачи: превратить цифры в символы и поменять порядок их следования. Первая задача проста: десятичная цифра превращается в соответствующий сим- вол прибавлением числа 48 (см. раздел «Вывод чисел» главы 4). Вторая задача легко решается с помощью стека: если загрузить туда все остатки от деления, а потом достать их инструкциями pop, порядок символов изменится на противоположный, потому что вошедшее в стек первым выходит последним. Теперь мы готовы написать программу на ассемблере. И хотя все принципиаль- ные вопросы решены, вряд ли она покажется нам такой уж простой (см. лис- тинг 11.4). Листинг 11.4. Вывод числа на экран .8086 cseg segment assume cs:cseg org OlOOh start: mov ax. 123 mov ex, 10 mov di. offset digit call WToAscii mov [di].byte ptr Odh ;дописать возвр. каретки inc di mov [di].byte ptr Oah :дописать перевод строки inc di mov [di].byte ptr Ё$Ё : завершить строку mov dx. offset digit mov ah. 09 доказать число int 21h mov ah. 4ch int 21h WtoAscii proc near xor dx, dx div ex :цифру в dx. остаток числа в ах or ax, ax :все цифры выделили? jz Done ;да - записать первую цифру push dx сохранить очередную цифру call WToAscii pop dx :достать очередную цифру Done: mov al.'dl or al. ЁОЁ превратить число в символ mov [di], al сохранить символ inc di ret WtoAsci1 endp Digit BYTE 10 dup(?) cseg ends end start Нам, людям, всего непривычней в этой программе «самовызов» процедуры WToAscii (рис. 11.4):
166 Глава 11. Model flat для DOS WtoAscii proc near xor dx.dx 4-------------------------- <получение очередной цифры> jz Done push dx; сохранить цифру в стеке call WToAscii-------------------- pop dx Done: <запись очередного символа» ret WtoAscii endp Рис. 11.4. Процедура вызывает сама себя Но для процессора название процедуры эквивалентно метке: сохранив в стеке очередную цифру инструкцией push dx и встретив инструкцию call WToAscii, он загрузит в стек адрес возврата и перейдет к первой инструкции процедуры WToAscii xor dx.dx. В результате стек после нескольких вызовов процедуры ста- нет похож на слоеный пирог, где десятичные цифры числа чередуются с адреса- ми возврата (рис. 11.5). Адрес возврата 2 Адрес возврата 3 Рис. 11.5. Состояние стека перед возвратами из процедуры Первой сохраненной в стеке цифрой будет 3 (поделили 123 на 10 — получили частое ахв12 и остаток dxe3). Затем процедура вызовет сама себя, сохранив перед этим в стеке адрес возврата, и поделит 12 на 10, в результате чего получим час- тное 1 и остаток 2, который процедура опять сохранит в стеке и вызовет себя во второй, последний раз. Теперь ей придется делить 1 на десять, что даст остаток (dx), равный 1, и нулевое частное (ах). Значит, результатом инструкции or ах,ах будет ноль и процессор перейдет к метке Done. В этот момент регистр dx (а значит, и dl) хранит число 1, которое инструкциями mov al. dl or al. ‘O’ превратить число в символ mov [di], al сохранить символ превращается в соответствующий символ, записывается в начало строки и затем di увеличивается на единицу, чтобы подготовиться к приему следующего сим- вола. А дальше наступает черед самой таинственной и мудрой инструкции — ret. Она, как мы знаем, означает возврат из подпрограммы, но куда? Очевидно, процес- сор возвращается к инструкции pop dx, непосредственно следующей за вызовом процедуры (рис. 11.6). В этот момент на вершине стека оказывается число 2 (см. рис. 11.5), которое превращается в символ и сохраняется в строке. А дальше происходит второй воз- врат — опять к инструкции pop dx, но на этот раз в dx попадает цифра 3, и после сохранения символа «3» в строке наступает черед третьего возврата, который
Рекурсия 167 соответсвует уже вызову из главной программы, поэтому процессор вернется к инструкции mov [di],byte ptr Odh основной программы и станет готовиться к вы- воду получившейся строки на экран. call WToAscii pop dx4-------- Done: mov al. dl or al. ‘0’ mov [di], al inc di ret----------------- Рис. 11.6. Возврат из рекурсивной процедуры Как видите, жизнь рекурсивной процедуры проходит в два этапа: на первом в стек заталкиваются — как патроны в рожок автомата — числа вперемежку с адресами возврата. На втором этапе начинается «стрельба»: числа достаются из стека, пре- вращаются в символы и переписываются в предназначенную им строку. Задача 11.2. Напишите программу для вывода на экран числа в шестнадца- теричном виде. Задача 11.3.** Напишите программу, которая выводит на экран 4-байтовое число, хранящееся в регистрах dx:ax Наша рекурсивная процедура вызвала себя всего лишь два раза, поэтому она не способна далеко уйти в сторону инструкций программы. Но если такая опасность все-таки возникает, можно просто «передвинуть» стек вниз, увеличив значение сегмента стека SS. Ведь программа в формате .сот вовсе не ограничена одним сегментом, как многие думают. Система DOS выделяет ей всю доступную па- мять, и можно изменить сегменты так, 'чтобы эту память использовать. Чтобы, например, выделить для инструкций только часть сегмента, можно прибавить к сегменту стека некое число (листинг 11.5): Листинг 11.5. Перемещение сегмента стека в .сот-программе mov ax.cs add ax.500h mov ss, ax Столь же легко можно поменять и размер стека, если весь сегмент ему не ну- жен. Листинг 11.6. Изменение размера стека mov ax.cs add ax.500h cli запретить аппаратные прерывания mov ss.ax mov sp.200h sti -.разрешить аппаратные прерывания В листинге 11.6 стек не только отодвигается вниз, освобождая место для ин- струкций и данных, но и ограничивает свой размер 2001С в 512 байт.
168 Глава 11. Model flat для DOS Задача 11.4. Сколько байтов выделяется для инструкций программы в лис- тинге 11.6? Обратите внимание на инструкции сП и sti, между которыми меняется содер- жимое регистра стека. Первая инструкция cl i запрещает аппаратные прерыва- ния, вторая (sti) вновь разрешает их. Делается это потому, что стеку процессо- ра один, им пользуются и процедуры программы, и аппаратные прерывания. И если аппаратное прерывание случится в промежутке между инструкциями: mov ss.ax аппаратное прерывание> mov sp.200h то регистр флагов и адрес возврата сохранятся в одном стеке, а доставать их ин- струкции i ret придется совсем из другого. Ясно, что ничем хорошим это не кон- чится. Заметим, что разрешать и запрещать прерывания так, как это сделано в листин- ге 11.6, можно только в программах, работающих под управлением DOS. Работа с прерываниями в многозадачной системе Windows гораздо сложнее, и о ней лучше почитать в других, более «продвинутых» книгах. Задавая размер стека для программы, работающей под управлением DOS, нель- зя не вспомнить о программах для Windows, где вообще не выделялась память для стека. Но если посмотреть только что запущенную программу в окне отлад- чика OllyDbg, то окажется, что ей по умолчанию выделяется стек размером около мегабайта, что для большинства задач вполне достаточно. Кодокопание До сих пор единственным средством исследования программ был для нас отлад- чик. Пользуясь этим мощным инструментом, можно многое узнать об устройстве программы, даже не имея под руками ее исходного текста на ассемблере. Отлад- чик позволяет не только проследить выполнение программы по шагам, он еще и «работает» дизассемблером, потому что показывает в своем окне инструкции про- цессора. Но отладчик исследует «живую», запущенную на выполнение программу. А мож- но, оказывается, многое узнать и о «мертвом» файле с расширением .ехе или .сот. Попробуем для примера рассмотреть внутренности самой простой программы в фор- мате .сот, чей исходный текст показан в листинге 11.1. Она, как вы помните, зани- мает на диске всего 30 байт, и потому «копаться» в ней будет довольно просто. Прежде всего можно посмотреть эту программу в оболочке FAR, которая спо- собна показать не только символы, но и шестнадцатеричные коды файла. Под- светив файл H01.com, нажав клавишу F3 и следом F4, увидим как на ладони все его 30 байт (рис. 11.7). Как и ожидалось, сама программа оказалась много короче 30 байт, потому что большую ее половину занимают символы «Не могу молчать!», а собственно ин- струкции процессора умещаются в одиннадцати байтах: В4 09 BA ОВ 01 CD 21 В4 4С CD 21
Кодокопание 169 которые кажутся абсолютно бессмысленными — до тех пор, пока их не проана- лизирует «настоящий» дизассемблер, то есть программа, восстанавливающая исходные тексты программ, лежащих мертвыми файлами на дисках. D: \AsmTutor\Tes t:\coin\L101. COM__________DOS_____________30 Col 0 100Й 0000000000: B4 09 BR 0B 01 CD 21 B4 4C CD 21 8D R5 20 RC AE H ° i|<S0=’-|L=’He mo 0000000010: R3 E3 20 AC AE AB E7 АО E2 EC 21 00 0A 24 гу молчать!/0$ Рис. 11.7. «Внутренности» программы из листинга 11.1 Мы воспользуемся маленькой классической программой Hiew (То есть Hacker’s View), написанной в прошлом тысячелетии Евгением Сусликовым. Набрав в ко- мандной строке FAR hiew 1101.com и нажав Enter, увидим внутренности файла в символьном представлении. Нажав затем F4, увидим шестнадцатеричные коды, такие же, как на рис. 11.7. И, наконец, нажав F2, увидим результат работы встро- енного дизассемблера (рис. 11.8) L101.COM 16 30 II Hacker’s View release 4.41 bv SEN. 00000000: В409 mov ah, 09 00000002: ВА0В01 mov dx,010B 00000005: CD21 int 21 00000007: В44С mov ah,4C 00000009: CD21 int 21 0000000В: 80052006 lea sp,Idi1[0AC2OJ 0000000F: АЕ scasb 00000010: A3E320 mov [020E31,ax 00000013: АС lodsb 00000014: АЕ scasb 00000015: АВ stosw 00000016: Е7А0 out A0.ax 00000018: Е2ЕС loop 00000006 - (1) 00000010: 2100 and [di],cx 0О0О0О1С: 0А24 or ah,[si] Рис. 11.8. Результат дизассемблирования программы из листинга 11.1 Если сравнить рис. 11.8 и листинг 11.1, то окажется, что первые И байт про- граммы восстановлены правильно, а дальше дизассемблер запутался, выдав на- бор бессмысленных и сложных инструкций вроде lea sp,[di][0АС20] и т. д. По- лучилось так потому, что дизассемблер — не процессор, он не исполняет про- грамму, а анализирует цифры, записанные в файле. И поскольку наш файл на- чинается с инструкций процессора, дизассемблер распознавал их правильно, пока они не кончились. Эта способность путать данные и команды есть практически у всех дизассемб- леров. Ведь их работа зависит от правильной точки отсчета. В таком простом дизассемблере, как Hiew, помогает простое прокручивание в окне программы. Если программа велика, можно нажимать стрелку >1 и следить за переменой ин- струкций в окне Hiew. Иногда они будут совсем бессмысленными, это как раз и значит, что точка отсчета выбрана неправильно, иногда инструкции процессора
170 Глава 11. Model flat для DOS проступят более четко, и тогда опытный глаз легко отличит их от данных. Но при дизассемблировании сложных программ для DOS лучше применять более совершенные средства, такие как дизассемблер DisDoc. Может показаться, что дизассемблирование совсем не нужно, когда есть отладчик. Но это не так. Отладчик и дизассемблер дополняют друг друга. Первый показывает одно мгновенье из жизни программы. Второй — ее общее устройство. С помощью дизассемблера можно получить исходный текст, который после компиляции даст правильно работающую программу. А это значит, что после дизассемблирования появится возможность менять программу (чей исходный текст отсутствует) по своему разумению. Посмотрим, например, как легко можно изменить программу, показанную на ри- сунке 11.8. Зная, что такое 9-я функция DOS, легко понять, что инструкции, по- казанные в листинге Листинг 11.7. Вызов функции DOS в файле H01.com 00000000 В409 mov ah.09 00000002 ВА0В01 mov dx.OlOB 00000005 CD21 1 nt 21 выводят на экран строку с адресом 10В. Но в регистр dx, очевидно, засылается адрес строки, уже загруженной в оперативную память программы, когда первые 256 (в шестнадцатеричной системе это 100) байт сегмента займет PSP. Поэтому в файле H01.com нужно искать адрес, на 1001С меньший, то есть не 10В, а просто В — одиннадцатый (с учетом начала нумерации с нуля) байт. А в нем, как видно из рис. 11.7, как раз и хранится первый символ нашей строки ‘Н’. Значит, если в файле заменить последовательность байтов ВА0В01 (см. листинг 11.7) на ВА0Е01, то это будет соответствовать инструкции mov dx.OlOE, посылающей в регистр dx адрес не нулевой, а третьей буквы нашей фразы. И программа после такой за- мены должна показать на экране «могу молчать!». Попробуем проделать такую замену с помощью Hiew. Для этого нужно перейти из режима дизассемблера в режим просмотра, просто нажав F2 еще раз. Затем необходимо включить режим изменения файла клавишей F3, после чего в нуле- вом байте файла появится курсор, который можно двигать клавишами Т, >1. Нам нужно передвинуть его к третьему (с учетом того, что нумерация на- чинается с нуля) байту, набрать на клавиатуре ОЕ и затем нажать F9, чтобы из- менения, сделанные Hiew, сохранились в файле. Вот и все. Теперь программа, если ее запустить, показывает на экране «могу мол- чать!», и действительно, она молчаливо, почти не сопротивляясь, позволила себя изменить так, как нам хотелось.
Глава 12 Полезности Управление потоком Нужно думать не о том, что нам может приго- диться, а только о том, без чего мы не сможем обойтись. Джером К. Джером. Трое в лодке, не считая собаки В этой главе пойдет речь именно о том, без чего большинство программистов может обойтись. Но не обходится. Это различные «улучшения» инструкций про- цессора, предлагаемые ассемблером. Чтобы стало ясно, о чем речь, вспомним программу из листинга 4.2 (см. раздел «Переходы» главы 4), где нужно было направить процессор по разным путям в зависимости от величины некой переменной. Фрагмент ассемблерной програм- мы, где у процессора есть два варианта действий, был таким, как в листинге 12.1. Листинг 12.1. Пример ветвлений в ассемблере cmp digit,О jnz nzero Invoke WriteConsoleA, stdout, ADDR z, \ zsize, ADDR cWritten, NULL jmp exit nzero: Invoke WriteConsoleA. stdout, ADDR nz, \ nzsize, ADDR cWritten, NULL exit: Invoke ExitProcess, 0 Ключевую роль здесь играет инструкция jnz nzero, отправляющая процессор к метке nzero, когда переменная digit не равна нулю, и позволяющая процессору выполнить следующую инструкцию, если digit равна нулю. Вместе с безусловным переходом jmp инструкция jnz организует две ветви вы- числений. В одном случае программа выведет на экран равно нулю, в другом — не равно нулю.
172 Глава 12. Полезности Это ветвление выглядит не очень красиво, и не очень понятно, где одна ветвь, где другая. Поэтому в ассемблере введены специальные директивы, .IF, .ELSE .ENDIF, с помощью которых программа из листинга 4.2 может быть переписана так, как показано в листинге 12.2. Листинг 12.2. Организация ветвлений с помощью условных директив .386 .model flat, stdcall option casemap:none 1nclude \myasm\1nclude\w1ndows.1nc 1nclude \myasm\1nclude\kernel 32.1nc 1ncludel1b \myasm\11b\kernel32.1ib .data z BYTE "равно нулю". 13. 10 zsize DWORD ($-z) nz BYTE "не равно нулю", 13. 10 nzslze DWORD ($-nz) digit DWORD 1 stdout DWORD ? cWritten DWORD 7 .code start: Invoke GetStdHandle, STD_OUTPUT_HANDLE mov stdout, eax .IF digit == 0 Invoke WriteConsoleA, stdout, ADDR z. \ zsize, ADDR cWritten. NULL .ELSE Invoke WriteConsoleA. stdout. ADDR nz. \ nzsize. ADDR cWritten, .ENDIF invoke ExitProcess. 0 end start NULL Здесь проверку, равно ли нулю число digit, выполняет директива .IF digit = 0. Если digit равно нулю, выполняется первая ветвь программы, чьи инструкции расположены между директивой .IF и директивой .ELSE. Если же digit не равно нулю, выполняется вторая ветвь между .ELSE и .ENDIF. Число ветвей легко мож- но увеличить, используя еще одну директиву . ELSEIF: .IF <условие> .ELSEIF <условие> .ELSEIF <условие> ’.ELSE .ENDIF Каждая ветка выполняется лишь когда <условие> истинно, и только последнее .ELSE служит «сборщиком мусора»: в эту ветку попадает все, что прошло сквозь частокол условий .IF <> и .ELSEIF <>:
Управление потоком 173 Нужно отчетливо понимать, что не существует таких инструкций процессора, как .IF и .ELSE. Встретив эти директивы, ассемблер превратит их в настоящие инструкции процессора, поэтому программа в окне отладчика будет выглядеть совсем не так, как в листинге 12.2. Рисунок 12.1, где изображен фрагмент про- граммы, соответствующий конструкции .IF .ELSE .ENDIF, показывает, что ассемб- лер превратил эти директивы в обычные команды процессора cmp, jnz, jmp, такие же, как в листинге 12.1. Ю4ЙЖ 00401013 00401015 00401017 0040101С 00401022 00401027 0040102D 00401032 00401034 00401036 0040103В 00401041 00401046 0040104С 00401051 00401053 . S35D 2530"400ff .v75 IF . 6А 00 . 68 28304000 . FF35 0С304000 . 68 00304000 . FF35 27304000 . Е8 32000000 vEB ID > 6A 00 . 68 28304000 . FF35 1F304000 . 68 10304000 . FF35 27304000 . E8 13000000 > 6A 00 в. E8 00000000 CMP DWORD PTR D§sC403028K”0 JNZ SHORT BRANCH2.00401034 PUSH 0 PUSH BRANCH2.00403028 PUSH DWORD PTR DS:C40300C] PUSH BRANCH2.00403000 PUSH DWORD PTR DS:[4030273 CALL <JMP.Sckerne 132. WriteConso leA> JMP SHORT BRANCH2.00401051 PUSH 0 PUSH 8RANCH2.0040302B PUSH DWORD PTR DSs[40301F3 PUSH BRANCH2.00403010 PUSH DWORD PTR DS:[4030273 CALL <JMP.&kerne132.Wr iteConsoIeA> PUSH 0 CALL <JMP.&kerne 132.Ek itProcess> Рис. 12.1. Так видит отладчик программу из листинга 12.2 Директивы .IF .ELSE .ENDIF, с которыми мы только что познакомились, по-раз- ному оцениваются программистами. Многие осуждают их за то, что они превра- щают ассемблер в подобие языка высокого уровня, такого как Си, где нет одно- значного соответствия между текстом программы и выданной компилятором по- следовательностью инструкций процессора. А это соответствие считается одним из преимуществ ассемблера перед другими языками. Ассемблер потому и прост, что совершенно не абстрактен, он «поет о том, что видит», то есть позволяет по тексту программы однозначно сказать, какую последовательность команд испол- нит процессор. На мой взгляд, в этих упреках есть своя правда, хотя и до директив .IF .ELSE .ENDIF мы уже вступили на скользкую дорожку, ведущую к языкам высокого уровня, когда согласились использовать директиву invoke для запуска проце- дуры и терпели своеволие ассемблера, добавлявшего в процедуру пролог push ebp, mov ebp, esp и эпилог leave (см. раздел «Своеволие ассемблера» главы 3)*. В защиту директив можно сказать, что они не нарушают однозначного соот- ветствия между исходным текстом на ассемблере и соответствующей после- довательностью инструкций процессора. Они просто отдаляют одно от друго- го. И решать, использовать ли директивы, организующие ветвление в про- грамме, каждый должен сам. Но в любом случае эти директивы нужно по крайней мере знать, потому что они часто встречаются во многих исходных текстах. ‘Впрочем, для любителей «чистого» ассемблера в MASMe есть директивы OPTION PROLOGUE:None и OPTION EPILOGUE:None, которые запрещают вставлять пролог и эпилог процедуры.
174 Глава 12. Полезности Поэтому продолжим знакомство с ними, вернее, с различными условиями в ди- рективе .IF. Одно мы уже знаем. Знак = означает «равно». Другие условия ин- туитивно понятны (а тем, кто знает язык Си, еще и привычны): Iе не равно > больше >я больше или равно < меньше <я меньше или равно Глядя на эти условия, стоит вспомнить, что в ассемблере есть два типа сравне- ний — для чисел со знаком и без. Так вот, директивы .IF .ELSE .ENDIF по умолча- нию считают числа беззнаковыми, то есть ассемблер поставит вместо .IF еах < О инструкцию jb, а для условия .IF еах > 0 поставит инструкцию ja. Чтобы заста- вить ассемблер использовать инструкции сравнения чисел со знаком jg и J1*, нужно пометить одно из сравниваемых чисел оператором SDWORD PTR (для двой- ного слова), SWORD PTR (для слова) или же SBYTE PTR (для байта). Так, например, директива .IF SDWORD PTR digit > 0 превратится в инструкцию jle (если меньше или равно, перейти), а директива .IF digit > 0 станет инструкцией jbe, которая работает с числами без знака. Круженье Кроме директив, помогающих программе ветвиться, есть еще директивы, органи- зующие циклы. Мы уже встречались с циклами, заданными инструкцией loop. Теперь попробуем заменить loop в листинге 4.3 (см. раздел «Повторение» главы 4) директивами .WHILE .ENDW, с помощью которых вывод на экран десяти чисел под- ряд будет выглядеть так, как в листинге 12.3 Листинг 12.3. Организация цикла с помощью директив .while .endw mov есх, 10 .WHILE есх !я0 push есх push edx Invoke wsprintf, ADDR buf. ADDR ifmt, edx invoke WriteConsoleA, stdout, ADDR buf, \ BSIZE. ADDR cWritten, NULL Invoke WriteConsoleA, stdout, ADDR crlf, \ 2. ADDR cWritten. NULL pop edx 1nc edx pop ecx dec ecx .ENDW Перед циклом .WHILE в регистр ecx посылается число 10. А дальше проверяется, равен ли есх нулю. Если да — цикл завершается, если нет — совершает новый оборот. Естественно, есх нужно менять внутри цикла, чтобы тот не крутился вечно. Поэтому перед .ENDW стоит инструкция dec есх. * С ними мы уже познакомились в разделе «Ввод» главы 5.
Макросы 175 Кроме директив .WHILE .ENDW для организации цикла можно использовать похо- жие директивы .REPEAT .UNTIL, отличающиеся тем, что проверка, от результата которой зависит продолжение цикла, делается не в начале, а в конце. Цикл, по- казанный в листинге 12.3, организуется директивами .REPEAT .UNTIL так: mov есх. 10 .REPEAT dec есх .UNTIL есх == 0 Обратите внимание, такой цикл выполняется хотя бы раз, потому что условие выхода проверяется в самом его конце. По сравнению с циклом, организован- ном директивой .WHILE, здесь все наоборот: цикл прекращается, когда условие в директиве .UNTIL истинно (в нашем примере — когда есх обратится в ноль). Задача 12.1. Посмотрите с помощью отладчика OllyDbg, как ассемблер реали- зует циклы .WHILE .ENDW и .REPEAT .UNTIL. Макросы В программах часто повторяются одни и те же фрагменты, такие, например, как завершение работы в системе DOS: mov ah. 4ch завершить программу int 21h Смысл этих строк довольно туманен, да и выписывать их каждый раз не хочет- ся. И было бы здорово заставить ассемблер при встрече какого-нибудь коротко- го, ясного слова, например Quit (выход), вставлять в текст программы две стро- ки, приведенные выше. Чтобы решить эту задачу, в ассемблере есть макросы, позволяющие назвать од- ним словом сколь угодно длинный текст. Программу из листинга 9.1, выводя- щую на экран фразу Не ногу молчать!, можно переписать с использованием мак- росов так, как показано в листинге 12.4. Листинг 12.4. Пример использования макросов Quit macro mov ah. 4ch int 21h endm LDisp macro line mov dx. offset line mov ah. 09 int 21h endm .8086 .MODEL small option casemap .-none .stack 100 .data hello BYTE "He могу молчать!". Odh, Oah. Ё$Ё .code start: mov dx. @stack mov ss, dx
176 Глава 12. Полезности mov dx. @data mov ds. dx ;регистр данных LDisp hello :вывод на экран Quit ;уходим end start Макрос Quit определяется в самом начале программы так: Quit macro mov ah. 4ch int 21h endm Сначала идет имя макроса, затем слово macro, составляющее его заголовок, затем тело макроса, состоящее из двух строк, и признак конца макроса endm. После того как макрос определен, ассемблер заменит каждое слово Quit, встреченное в программе, двумя строками: mov ah. 4ch int 21h и только после такой замены приступит собственно к ассемблированию, то есть переводу текста программы в инструкции процессора. Как видим, замена строк: mov ah. 4ch int 21h коротким словом Quit приносит двойную пользу: программа становится короче и понятней. Но часто такая замена невозможна, из-за того что тело макроса содержит пара- метр, который может меняться в разных местах программы. Например, строки mov dx. offset hello mov ah, 09 int 21h выводят на экран сообщение, помеченное как hello, но в программе может быть много сообщений и писать для каждого собственный макрос просто глупо. Вме- сто этого пишется макрос с формальным параметром line (см. листинг 12.4): LDisp macro line mov dx. offset line mov ah, 09 int 21h endm При вызове макроса вместо формального параметра ставится фактический. В программе из листинга 12.4 строка: LDisp hello обрабатывается следующим образом: формальный параметр line всюду в теле макроса заменяется фактическим hello, и затем преображенное тело макроса вставляется в текст программы вместо строки LDisp hello. Так что ассемблер ви- дит перед собой три строки: mov dx. offset hello mov ah, 09 int 21h и уже их преобразует в инструкции процессора.
Макросы 177 В рассмотренном примере у макроса был один параметр. Но их может быть сколько угодно. При вызове таких макросов параметры разделяются запяты- ми. В качестве примера создадим макрос, читающий файл в системе DOS. Эту задачу выполняет функция 3fh прерывания 21h. Для нормальной работы ей необходимы три параметра: в регистре Ьх должен быть хендл файла — по сути его номер в операционной системе, который программа узнает при созда- нии файла. Этот хендл похож на дескриптор файла, возвращаемый процедурой CreateFi 1 е Windows API. Второй параметр — число читаемых байтов — должен быть в регистре сх, и, наконец, третий параметр — смещение буфера, куда чи- таются байты из файла. Оно хранится в регистре dx (смещение должно быть указано относительно сегмента ds). С учетом сказанного макрос, читающий файл, может выглядеть так: Read macro FHandle, NOfBytes. Buff mov bx, FHandle mov ex, NOfBytes mov dx. offset Buff mov ah, 3fh int 21h endm Если вызвать этот макрос строкой: Read Handle, 16, PackBuff то формальные параметры заменятся фактическими, и 16 байт из файла, чей хендл хранится в переменной Handle, будут прочитаны в буфер PackBuff. Иногда при вызове макроса не хочется указывать все параметры. В нашем при- мере может случиться так, что хендл уже хранится в Ьх прямо перед вызовом макроса. На этот случай существует директива ifnf (If Not Blank — если не пуст*). С ее помощью макрос можно переписать следующим образом: Read macro FHandle, NOfBytes, Buff ifnb <FHandle> mov bx. FHandle endif mov ex. NOfBytes mov dx. offset Buff mov ah. 3fh int 21h endm При этом смысл его будет таким: если формальный параметр Fhandle указан, он бу- дет заменен фактическим параметром, который отправится в регистр Ьх. То есть строки: ifnb <FHandle> mov bx, FHandle endif превратятся в: mov bx, FHandle ’Существует, конечно, и противоположная директива ifb (If Blank — если пуст): ifb <пара- MeTp>...endif.
178 Глава 12. Полезности Если макрос вызывается без параметра Fhandle, то посылать в регистр Ьх нечего (под- разумевается, что хендл уже там) и строки 1 fnb ...endif будут просто пропущены. Иными словами, ассемблер, встретив вызов макроса: Read .16d . PackBuff поймет, что первого параметра нет, и потому не станет посылать его в регистр Ьх. Как видите, макросы очень похожи на процедуры. У них, как и у процедур, есть параметры, а вызов макроса напоминает запуск процедур директивой Invoke. Но это сходство обманчиво. Ведь процедуры по-настоящему отделены от основной программы, они хранят параметры и свои локальные переменные в стеке. Мак- росы же только прикидываются процедурами, а на самом деле они принадлежат основной программе и могут быть источником ошибок. Кроме того, макросы вставляются в программу при каждом вызове, а потому занимают больше памя- ти. Но у макросов есть и преимущества: ими легче манипулировать, материал, из которого сделан макрос, более податлив. Кроме того, вызов процедуры требу- ет процессорного времени, чтобы сохранить в стеке передаваемые параметры. Макрос получает свои параметры сразу. Поэтому там, где требуется высокая скорость вычислений, лучше использовать макрос. Структуры В разделе «Круженье битов» главы 5 мы были чрезмерно скупы, решив уместить дату всего в шестнадцати битах, за что пришлось расплачиваться сложным досту- пом к отдельным ее элементам и возможностью хранить только две последние цифры года. В этом разделе мы, наоборот, будем излишне щедры, поместив дату в специальную структуру, которая занимает в памяти целых 96 бит! Эта структура состоит из трех полей Day (день), Month (месяц), Year (год), каждое из которых будет двойным машинным словом. Структура в ассемблере объяв- ляется с помощью ключевого слова Struct*: DATE STRUCT Day DWORD ? Month DWORD ? Year DWORD ? DATE ENDS Такое объявление не выделяет память для структуры, а лишь описывает новый тип данных. Чтобы ассемблер создал настоящую переменную типа DATE, зани- мающую участок памяти длиной в 12 байт, необходимо объявление Date DATE о. Здесь Date — новая переменная, а обязательные треугольные** скобки показыва- ют, что поля структуры не имеют определенных значений. Эти значения можно задать при объявлении переменной, поместив их внутрь треугольных скобок*** Date DATE <30.7.2003> :7 июля 2003 года *Из-за этого слова она и называется структурой, хотя правильней было бы называть ее записью. К сожалению, в ассемблере слово «запись» (record) уже занято. Так называется другой похожий на структуру тип данных. ’’Допустимы и фигурные скобки Date DATE {}. ’’’Можно задать только часть полей, оставив вместо значения пробел.
Структуры 179 или же присвоить значения отдельным полям структуры с помощью оператора «.» (точка): mov Date.Day. 30 mov Date.Month. 7 mov Date.Year. 2003 Конечно, «точка» создана только для удобства программиста и не использует какой-то особый вид адресации. Зная, что структура располагается в сплошном участке памяти без каких-либо промежутков и пустот, ассемблер заменит «точ- ку» обычной косвенной адресацией со смещением: mov Date[4]. 7 : mov Date.Month.7 To есть имя структуры похоже на имя массива: это обычная метка, которую ас- семблер преобразует в адрес. Пример доступа к отдельному полю структуры с по- мощью оператора «.» (точка) и косвенной адресации показан в листинге 12.5. Листинг 12.5. Хранение текущей даты в структуре .386 .model flat, stdcall option casemap:none i nclude \myasm\1nclude\wi ndows.1nc include \myasm\i nclude\user32.1nc 1nclude \myasm\i nclude\kernel32.1nc 1ncludeli b \myasm\11b\kernel 32.1i b includelib \myasm\lib\user32.1ib DATE STRUCT Day DWORD ? Month DWORD ? Year DWORD ? DATE ENDS DateDisp proto :DATE BSIZE equ 15 .data ifmt BYTE "Xd",0 buf BYTE BSIZE dup(0) Date DATE <30,7.2003> : 30 июля 2003 года MName BYTE "янв BYTE "июл фев мар апр май июн " авг сен окт ноя дек" .data? stdout DWORD ? cWritten DWORD ? .code start: invoke GetStdHandle. STDJ3UTPUT_HANDLE mov invoke stdout, eax DateDisp. Date 1 доказать дату invoke ExitProcess. 0 DateDisp proc d:DATE invoke wsprintf. ADDR buf, ADDR ifmt. d.Day invoke WriteConsole, stdout. ADDR buf. 3.\ ADDR cWritten. NULL mov eax, d.Month dec eax ;в программе нумерация с нуля продолжение &
180 Глава 12. Полезности Листинг 12.5 (продолжение) shl еах, 2 ;умножить на 4 mov esi, offset MName : отн. адрес названия add esi, еах invoke WriteConsole, stdout, esi: 4,\ ADDR cWritten. NULL xor eax. eax invoke wsprintf, ADDR buf, ADDR ifmt. d[8] invoke WriteConsole. stdout, ADDR buf. 4,\ ADDR cWritten. NULL ret DateDisp endp end start Использование косвенной адресации для доступа к полю Year: invoke wsprintf, ADDR buf. ADDR ifmt. d[8] открывает нам внутреннее устройство структуры, но в реальных программах удоб- ней пользоваться «точкой»: d.Year. Глядя на листинг, начинаешь понимать, по- чему дню, месяцу и году отведено по двойному слову, в то время как для числа и месяца достаточно одного байта, а год легко уместится в двух. Все дело в том, что параметры стандартных процедур чаще всего — двойные слова, и если бы, к примеру, поле Day занимало один байт, пришлось бы переписать его в четы- рехбайтовый регистр и лишь потом передать процедуре. Задача 12.2. Напишите программу, которая показывает на экране дату, хра- нимую в структуре, состоящей из трех полей Day (байт), Month (байт), Year (слово). Typedef и венгерская нотация В отличие от слова struct директива typedef не создает новые типы данных, а лишь позволяет иначе назвать типы уже существующие. Привыкшим к назва- ниям переменных, принятым в языке Си, понравится объявлять байт или по- следовательность байтов словом CHAR. Но строку Message CHAR "Не могу молчать"; ?????? ассемблер не поймет, потому что не имеет понятия о том, что такое «CHAR». Поэтому перед объявлением необходима директива typedef: CHAR typedef BYTE Message CHAR "He могу молчать"; !!!!!! говорящая ассемблеру, что последовательность байтов можно теперь задавать и словом CHAR. Такое задание имеет смысл, потому что BYTE — это байт, восемь бит, безликая ячейка памяти, в которой может храниться что угодно. Но CHAR — это сокращен- ное английское слово «character», то есть «буква», «цифра», «знак», «символ». Поэтому слово CHAR придает объявлению смысл. Теперь мы догадываемся, что следом за «CHAR» последуют буквы, символы, а не просто числа. Точно так же директива BOOL typedef BYTE ничего в принципе не меняет. Байт оста- нется байтом, как его ни назови. Но слово BOOL говорит нам о предназначении
Typedef и венгерская нотация 181 байта. Его судьба — хранить переменную, имеющую только два значения, TRUE и FALSE, так что встретив переменную типа BOOL, мы уже будем многое знать о ней, и это поможет понять программу в целом. Особенно полезна директива typedef для наведения порядка в обширной, слож- ной библиотеке процедур, такой как Windows API. Объявление параметров про- цедуры и возвращаемых ей значений BYTE, WORD и т. д. привели к дикой путанице, потому что невозможно было бы запомнить параметры и отличить их друг от друга. Вместо этого Windows использует множество названий параметров, опре- деляемых директивой typedef. Вспомним, например, о процедуре CreateFile, с ко- торой мы познакомились в разделе «Интернет — источник знаний» главы 6. Ее описание, найденное в базе данных MSDN: HANDLE CreateFile ( LPCTSTR IpFileName. DWORD dwDesiredAccess. DWORD dwShareMode. LPSECURITY_ATTRIBUTES IpSecurityAttributes, DWORD dwCreationDisposition. DWORD dwFlagsAndAttributes. HANDLE hTemplateFile ): содержит новые типы HANDLE, LPSTR, понять смысл которых можно по их назва- ниям. Handle — это дескриптор, a LPSTR — Long Pointer to STRing — длинный ука- затель на строку, проще говоря, адрес этой строки. Размер этих новых типов можно найти в файле Windows.inc, содержащем множество директив typedef. Для наших типов там находятся строчки: HADLE typedef DWORD LPCTSTR typedef DWORD говорящие нам, что и тот и другой параметры занимают в памяти двойное сло- во. Но часто размер и/или назначение нового типа заключены в названии при- надлежащей ему переменной. Так, например, маленькие буквы dw в имени dwDesiredAccess говорят нам о том, что это двойное слово. Буквы 1р в имени IpFil eName свидетельствуют, что это длинный указатель (long pointer). Обычай помечать маленькими буквами, стоящими в начале имени переменной, ее тип и/или назначение называется венгерской нотацией — то ли потому, что «венгер- ский» означает для американцев «странный», «дикий», то ли потому, что изо- бретатель этой системы имен Чарльз Симони действительно был венгром. Поскольку венгерская нотация часто используется при разработке программ для Windows, приведем основные префиксы переменных, которые нам придется час- то встречать в следующей главе: с — символ; by - BYTE; n — короткое целое (WORD); i — целое; х, у —- координаты на экране (естественно, целые); b — переменная с двумя значениями TRUE и FALSE;
182 Глава 12. Полезности f — флаг — то же, что и bool; w — слово WORD — беззнаковое короткое целое; 1 — длинное целое (DWORD); dw — беззнаковое длинное целое (DWORD); s — последовательность символов; sz — строка символов, завершаемая нулем; h — handle; р — адрес. Эти основные символы можно сочетать друг с другом. Например, Ipsz означает «длинный указатель на начало строки символов, завершаемой нулем».
Глава 13 Окна Сообщения Чужие мысли читать не умею, хочешь выйти — нажми на кнопку. Объявление в маршрутном такси Между операционной системой Windows и шофером маршрутного такси есть не- сомненное сходство. И та и другой ничего не ждут, а лишь отвечают на возник- шие сообщения. Шофер не спрашивает на каждой остановке, есть ли желающие выйти. Точно так же Windows не опрашивает выполняемые программы, а реаги- рует на сообщения от них. Разница лишь в том, что шофер обрабатывает всего одно сообщение, a Win- dows — сотни, ведь сообщение — это любое перемещение курсора, нажатие на клавишу клавиатуры или кнопку мыши, а также сигнал от отдельных устройств компьютера, таких как таймер. Эти сообщения сложны и разнообразны, могут происходить как в пространстве (на плоскости экрана), так и во времени, поэто- му Windows отводит для каждого из них специальную структуру MSG: MSG STRUCT hwnd DWORD ? message DWORD ? wParam DWORD ? 1 Param DWORD ? time DWORD ? pt POINT <> MSG ENDS В ней hwnd — дескриптор источника сообщения («ничьих» сообщений не быва- ет), message — тип сообщения (например WM LBUTTONUP — сообщение о том, что поднята левая кнопка мыши), wParam и 1 Param — параметры сообщения, завися- щие от его типа, time — время прихода сообщения, a pt — структура, хранящая его координаты. Хоть сообщения и приходят от самых разных устройств, в этой книге мы затронем лишь сообщения, посылаемые окнами, в которых выполня- ются программы.
184 Глава 13. Окна Все пришедшие сообщения Windows ставит в очередь (то, что пришло послед- ним, оказывается «крайним») и затем направляет программам, владеющим теми или иными окнами. То есть первоначальная длинная очередь сообщений разби- вается операционной системой на несколько более мелких. Каждая такая оче- редь обрабатывается конкретной программой в цикле: .WHILE TRUE invoke GetMessage, ADDR msg. NULL, 0, 0 or eax. eax jz Quit invoke DispatchMessage. ADDR msg .ENDW Quit: Процедура GetMessage принимает сообщение, предназначенное данной программе, a DispatchMessage отправляет его процедуре, обслуживающей конкретное окно. В про- стейшем случае такая процедура реагирует только на сообщение WM DESTROY, говоря- щее о том, что пославшее его окно в данный момент уничтожается*. Листинг 13.1. Простейшая оконная процедура WndProc proc hWnd:HWND. uMsg:UINT.\ wParam:WPARAM, lParam:LPARAM .IF uMsg==WM_DESTROY invoke PostQuitMessage.NULL .ELSE invoke DefWi ndowProc.hWnd.uMsg.wPa ram.1 Pa ram ret .ENDIF Четыре параметра этой процедуры должны быть нам понятны. Первый параметр hWnd — это дескриптор окна, пославшего сообщение, а три остальных — uMsg, wParam, 1 Param — это тип сообщения и его параметры, указанные в структуре MSG. Смысл процедуры WndProc (листинг 13.1) прост: ее интересует, живо ли подшефное ей окно. Если да — сообщения WM_DESTROY не возникает и всякое другое сообщение от- правляется стандартной процедуре DefWi ndowProc, в которой они и обрабатываются. Если же окно уничтожается, процедура PostQuitMessage генерирует сообщение WM_QUIT, которое встает в общую очередь, а затем направляется нашей процедуре GetMessage. Это сообщение (WM QUIT) — особенное, и GetMessage отвечает на него тем, что воз- вращает ноль в регистре еах. Поэтому цикл .WHILE прекращается и программа за- вершает работу, переходя к метке QUIT. Создание окна Познакомившись с обработкой сообщений, пора переходить к их источнику и цели — окну. Насмотревшись на разные окна в программах, легко предполо- жить, что структура, хранящая различные их признаки, будет гораздо сложнее, чем структура для сообщений. И это более чем так. Окно настолько сложнее сообщения, что его приходится создавать в три этапа. Сначала задается прообраз окна — некая идея, или класс, *То есть нажаты клавиши Alt+F4 или выбран «крестик» в правом верхнем углу окна.
184 Глава 13. Окна Все пришедшие сообщения Windows ставит в очередь (то, что пришло послед- ним, оказывается «крайним») и затем направляет программам, владеющим теми или иными окнами. То есть первоначальная длинная очередь сообщений разби- вается операционной системой на несколько более мелких. Каждая такая оче- редь обрабатывается конкретной программой в цикле: .WHILE TRUE invoke GetMessage, ADDR msg, NULL, 0, 0 or eax, eax jz Quit invoke DispatchMessage, ADDR msg .ENDW Quit: Процедура GetMessage принимает сообщение, предназначенное данной программе, a DispatchMessage отправляет его процедуре, обслуживающей конкретное окно. В про- стейшем случае такая процедура реагирует только на сообщение WM_DESTROY, говоря- щее о том, что пославшее его окно в данный момент уничтожается*. Листинг 13.1. Простейшая оконная процедура WndProc proc hWnd:HWND, uMsg:UINT,\ wParam:WPARAM, 1 Param:LPARAM .IF uMsg==WM_DESTROY invoke PostQuitMessage.NULL .ELSE i nvoke DefWi ndowProc.hWnd,uMsg,wPa ram,1 Pa ram ret .ENDIF Четыре параметра этой процедуры должны быть нам понятны. Первый параметр hWnd — это дескриптор окна, пославшего сообщение, а три остальных — uMsg, wParam, 1 Param — это тип сообщения и его параметры, указанные в структуре MSG. Смысл процедуры WndProc (листинг 13.1) прост: ее интересует, живо ли подшефное ей окно. Если да — сообщения WM_DESTROY не возникает и всякое другое сообщение от- правляется стандартной процедуре DefWi ndowProc, в которой они и обрабатываются. Если же окно уничтожается, процедура PostQuitMessage генерирует сообщение WM QUIT, которое встает в общую очередь, а затем направляется нашей процедуре GetMessage. Это сообщение (WM_QUIT) — особенное, и GetMessage отвечает на него тем, что воз- вращает ноль в регистре еах. Поэтому цикл .WHILE прекращается и программа за- вершает работу, переходя к метке QUIT. Создание окна Познакомившись с обработкой сообщений, пора переходить к их источнику и цели — окну. Насмотревшись на разные окна в программах, легко предполо- жить, что структура, хранящая различные их признаки, будет гораздо сложнее, чем структура для сообщений. И это более чем так. Окно настолько сложнее сообщения, что его приходится создавать в три этапа. Сначала задается прообраз окна — некая идея, или класс, *То есть нажаты клавиши Alt+F4 или выбран «крестик» в правом верхнем углу окна.
Создание окна 185 который определяет целое семейство окон. Затем класс регистрируется процеду- рой RegisterCIassEx, чтобы образ окна стал доступен в программе. Далее по об- разцу, заданному классом, процедура CreateWindowEX создает сами окна. Окон од- ного класса в программе может быть сколько угодно, но сообщения от них будет обрабатывать единственная процедура, указанная в классе окна. Этот класс задается структурой WNDCLASSEX, показанной в листинге 13.2, и нам не остается ничего другого как познакомиться с каждым ее полем. Листинг 13.2. Идея (класс) окна WNDCLASSEX STRUCT cbSize style IpfnWndProc cbClsExtra cbWndExtra hlnstance hlcon hCursor hbrBackground IpszMenuName IpszClassName hlconSm WNDCLASSEX ENDS DWORD ? DWORD ? DWORD ? DWORD ? DWORD ? DWORD ? DWORD ? DWORD ? DWORD ? DWORD ? DWORD ? DWORD ? Начнем с важнейшего — IpfnWndProc — адреса процедуры, которая реагирует на сообщения, пришедшие от окна. С примером такой процедуры мы познакоми- лись в предыдущем разделе (см. листинг 13.1). Вторым по значимости полем будет, пожалуй, IpszClassName — адрес завершен- ной нулем (об этом говорит буква «z» в имени поля) строки символов — имени класса. Это имя нужно указывать при создании каждого окна, принадлежащего данному классу. Среди следующих полей трудно выделить главные и второсте- пенные, поэтому перечислим их по порядку: cbSize ~ число байтов, занимаемых структурой WNDCLASSEX, обычно определя- ется как SIZEOF WNDCLASSEX; style — определяет поведение окна данного класса. Поле style задается кон- стантами, начинающимися с букв CS_. Все они равны степеням двойки, по- этому их объединение оператором OR равносильно установке соответствую- щих «флагов». Например, CS_VREDRAW равна 2°, a CS_HREDRAW — 21. Значит, их объединение CS_HREDRAW or CS_VREDRAW установит нулевой и первый двоичные разряды поля style — и это будет означать, что окно данного класса должно перерисовываться при изменении как горизонтального, так и вертикального размера. Чуть позже мы поймем, почему нужно перерисовать окна и как это делает Windows-программа. hlnstance — дескриптор программы, в которой создается окно; hlcon — дескриптор «большой» пиктограммы, которая показывается на экра- не при нажатии клавиш Alt+Tab; hlconSm — дескриптор «маленькой» пиктограммы, которая показывается на па- нели задач Windows и в левом верхнем углу окна. Например, пиктограмма обо-
186 Глава 13. Окна лочки FAR — это голубой квадратик с двумя крошечными панелями, а пик- тограмма редактора Word — просто буква «W»; hCursor — дескриптор курсора мыши. Обычная стрелка задается константой IDC_ARROW. Но если, например, задать курсор как IDC_WAIT, — курсор мыши при попадании его в площадь окна превратится в изображение песочных часов; hbrBackground — описывает цвет заполнения окна. Чаще всего этот цвет белый или серый; 1 pszMenuName — адрес имени меню. Если меню не используется, равен NULL; cbCl sExtra, cbWndExtra — эти поля используются крайне редко, и у нас они все- гда будут равны нулю; После заполнения структуры WNDCLASSEX вновь созданный класс окна нужно сделать доступным программе, чтобы та смогла по его образу и подобию соз- давать настоящие окна. Делает это процедура RegisterCIassEx: .data ClassName db "SimpleWinClass".0 wc WNDCLASSEX <> .code start: mov wc.cbSize.SIZEOF WNDCLASSEX mov wc.1pszClassName,OFFSET ClassName invoke RegisterCIassEx. ADDR wc И только теперь можно создать настоящее окно. Делает это процедура CreateWindowEx, управляемая десятью параметрами, кратко описанными в листинге 13.3. Значения параметров, показанные в листинге, выбраны такими, как в нашей будущей первой «оконной» программе для Windows. Листинг 13.3. Создание окна invoke CreateWindowExА NULLA ADDR ClassNameА ADDR AppNameA WS_OVERLAPPEDWINDOWA CWJJSEDEFAULTA CWJJSEDEFAULTA CWJJSEDEFAULTA CWJJSEDEFAULTA NULLA NULLA hlnstanceA NULL дополнительный стиль :адрес имени класса :адрес имени окна ;базовый стиль ;гориз. координата окна :верт. Координата окна ;ширина окна :высота окна :дескриптор родительского окна :дескриптор меню :дескриптор программы :обычно равен нулю CWJJSEDEFAULT задает значение по умолчанию, a WS_OVERLAPPEDWINDOW — самый распространенный стиль окна, более чем уместный в первой нашей про- грамме.
Первое окно 187 Первое окно Теперь мы, наконец, готовы собрать воедино все фрагменты исходного текста, добавить к ним кое-что новое и в результате получить «первое окно» — полно- ценную программу для Windows. Правда, делать это придется в другом тексто- вом редакторе, например в Блокноте, потому что русские буквы в консоль- ных и «оконных» приложениях Windows кодируются по-разному, и если напи- сать «оконную» программу с помощью редактора оболочки FAR, то прочитать русские слова в готовой программе уже не удастся. Итог нашей работы показан в листинге 13.4. Листинг 13.4. Первая «оконная» программа .386 .model fl at.stdcal1 option casemap .-none 1nclude \myasm\1nclude\wi ndows.1 he i nc1ude \mya sm\i nc1ude\user32.1nc i nclude \myasm\1nclude\kernel32.1nc includellb \myasm\llb\user32.11b 1ncludelib \myasm\1ib\kernel32.11b .data ClassName BYTE ”SimpleW1nClass".O AppName BYTE “Первое окно".0 wc WNDCLASSEX <> msg MSG <> hwnd HWND ? hlnstance HINSTANCE ? .code start: Invoke GetModuleHandle. NULL mov hlnstance.еах запомнить дескриптор программы mov wc.cbSize.SIZEOF WNDCLASSEX mov wc.style. CS_HREDRAW or CS_VREDRAW mov wc.1pfnWndProc. OFFSET WndProc mov wc.cbClsExtra.NULL mov wc.cbWndExtra.NULL push hlnstance pop wc.hlnstance mov wc.hbrBackground, COLOR_WINDOWFRAME mov wc.1pszMenuName.NULL mov wc.lpszClassName,OFFSET ClassName 1nvoke Loadicon.NULL.IDI_APPLICATION mov wc.hIcon,eax mov wc.hlconSm.eax invoke LoadCursor.NULL,IDC_ARROW mov wc.hCursor.eax Invoke RegisterClassEx, ADDR wc INVOKE CreateWIndowEx,NULL.ADDR ClassName.ADDR AppName,\ WS_OVERLAPPEDWINDOW.CW_USEDEFAULT.\ CWJJSEDEFAULT,CWJJSEDEFAULT.CWJJSEDEFAULT.\ NULL.NULL.hlnstance.NULL mov hwnd,eax invoke ShowWindow. hwnd.SW_SHOWNORMAL продолжение &
188 Глава 13. Окна Листинг 13.4 (продолжение) .WHILE TRUE invoke GetMessage, ADDR msg, NULL, 0, 0 or eax.eax jz Quit invoke DispatchMessage, ADDR msg .ENDW Quit: mov eax,msg.wParam i nvoke Exi tProcess.eax WndProc proc hWnd:HWND, uMsg: UINTA wParam:WPARAM, lParam:LPARAM .IF uMsg==WM_DESTROY invoke PostQuitMessage.NULL .ELSE invoke DefWindowProc, hWnd. uMsg, wParam, 1 Param ret .ENDIF xor eax. eax ret WndProc endp end start Первой в листинге 13.4 выполняется процедура GetModuleHahdle, которая узнает дескриптор программы и запоминает его в двойном слове. Далее заполняются поля структуры we, описывающей класс окна. Значение дескриптора программы hlnstance приходится передавать через стек push hlnstance pop wc.hlnstance потому что в процессоре нет инструкции записи из одного места памяти в другое. Белый цвет рабочей области окна задается инструкцией mov wc.hbrBackground, COLOR_WINDOWFRAME где COLOR_WINDOWFRAME — константа, определенная в файле windows.inc. Наша первая программа еще недостойна того, чтобы у нее была своя иконка, по- этому в листинге 13.4 вызывается процедура Loadicon, возвращающая дескрип- тор стандартной иконки IDI_APPLICATION: i nvoke Loadicon. NULL. IDIAPPLICATION Дескриптор курсора возвращает процедура invoke LoadCursor, NULL, IDC_ARROW Вместо обычной стрелки IDC_ARROW можно попробовать загрузить другой курсор IDC WAIT (песочные часы) или IDC CROSS (перекрестие). Далее новый класс, названный нами Simp]eWinClass, регистрируется процедурой RegisterClassEx, и процедура CreateWindowEx создает по его образу и подобию окно, которое существует пока только в компьютерной памяти. Появиться на экране ему помогает процедура ShowWindow, использующая дескриптор окна hwnd и кон- станту SW_SHOWNORMAL, которая велит процедуре показать окно в полный рост. Если бы этот параметр был равен SW SHOWMINNOACTIVE, программа появилась бы только в панели задач, и понадобился бы еще один щелчок мыши, чтобы наше первое окно появилось на экране.
Испытание окна 189 После создания окна программа переходит к циклу обработки поступающих со- общений: .WHILE TRUE invoke GetMessage, ADDR msg, NULL, 0, 0 or eax.eax jz Quit invoke DispatchMessage. ADDR msg .ENDW с которым мы познакомились в предыдущем разделе. Выход из цикла произой- дет, когда процедура GetMessage получит сообщение WM QUIT. Тогда в регистре еах окажется ноль, процессор перейдет к метке Quit и программа после выполнения процедуры ExitProcess завершится. А дальше пути программы обрываются и WndProc — процедура обработки сооб- щений, поступающих от окна — как бы повисает в воздухе. Действительно, WndProc нигде в программе не вызывается, а лишь один раз упоминается в инст- рукции mov wc.lpfnWndProc, OFFSET WndProc задающей процедуру обработки сообщений для всех окон нашего класса SimpleWinClass. В этом удивительная особенность «оконного» программирования и его отличие от «консольного». Создавая консольные приложения или программы для DOS, программист оста- ется хозяином положения, и все, что он велит процессору, исполняется. «Окон- ные» программы гораздо более пассивны и непредсказуемы. Вместо того чтобы приказывать, программа ждет прихода сообщений, которые посылает оконной процедуре (в нашем случае это WndProc) операционная система. Задача оконной процедуры — предвидеть возможные сообщения и попытаться правильно их об- работать. Испытание окна Чтобы посмотреть, как наша первая программа обрабатывает сообщение WM_DESTROY, скомпилируем ее особым, предназначенным для оконной программы командным файлом wmake.bat: ml /с /coff "И. asm" link /SUBSYSTEM:windows "Xl.obj" Запустив получившийся ехе-файл, увидим на экране пустое окно, и кажется, что этого ужасно мало для программы длиной в 65 строк. На самом же деле наша программа умеет делать много такого, о чем нет даже намека в листин- ге 13.4. Например, окно можно двигать по экрану, ухватив мышью его заголо- вок (верхнюю синюю полосу с надписью «Первое окно»). А ведь перемещение окна — дело непростое: нужно запомнить ту часть рабочего стола, которую окно закрывает, чтобы восстановить ее, когда окно переместится в другое место. Да и само окно при его движении нужно сначала уничтожить на старом мес- те, потом создать на новом. Так же трудно изменить размер окна, но у нас это получается автоматически, стоит только подвести курсор мыши к границе ок-
190 Глава 13. Окна на и нажать левую кнопку. Наконец, операционная система дарит нам еще и меню, которое появляется, если пиктограмму в левом верхнем углу окна вы- брать левой кнопкой мыши (рис. 13.1). Рис. 13.1. «Бесплатное» меню На все его пункты, за исключением последнего, откликается процедура DefWindowProc. Только пункт Закрыть вызывает сообщение WM_DESTROY*, которое нужно обработать вручную, например освободить занятуе программой память или другие ресурсы. В нашем случае вся обработка сводится к тому, что проце- дура PostQuitMessage порождает сообщение WM QUIT, благодаря которому програм- ма выходит из цикла GetMessage...DispatchMessage и завершает работу. Задача 13.1. Что будет, если в оконной процедуре WndProc оставить только процедуру DefWi ndowProc и не обрабатывать сообщение WM_DESTROY? WM_PAINT Но прежде чем завершиться, нормальная программа должна что-то показать в окне, чему и помогает сообщение WM PAINT, говорящее о том, что вся рабочая область окна или его часть должны быть нарисованы заново. Сообщение WM_PAINT программа получает, когда изменяются размеры окна или открывается какая-то его часть, заслоненная другим окном. Это же сообщение с помощью процедуры UpdateWindow программа может послать сама себе, когда в окне высвечивается что-то новое. Попробуем ответить на сообщение WM PAINT словами «Не могу молчать!» в рабо- чей области окна. Замечательно, что для этого почти не нужно менять програм- му из листинга 13.4. Достаточно добавить в раздел .data массив символов «Не могу...» (назовем его Hello) и еще одну «ветку» .ELSEIF. Листинг 13.5. Вывод текста в окно .data *Это же сообщение возникает, когда мышь выбирает «крестик» в правом верхнем углу окна.
WM PAINT 191 Hello db "He могу молчать",0 WndProc proc hWnd:HWND, uMsg:UINT, wParam:WPARAM,\ lParam:LPARAM LOCAL hdc:HDC LOCAL ps:PAINTSTRUCT LOCAL rect:RECT .IF uMsg==WM_DESTROY invoke PostQuitMessage,NULL .ELSEIF uMsg==WM_PAINT invoke BeginPaint,hWnd, ADDR ps mov hdc, eax invoke GetClientRect,hWnd, ADDR rect invoke DrawText, hdc, ADDR Hello, -1,\ ADDR rect. DT_SINGLELINE or DT_CENTER\ or DT.VCENTER invoke EndPaint, hWnd. ADDR ps .ELSE i nvoke DefWi ndowP roc,hWnd,uMsg,wPa ram,1 Pa ram ret .ENDIF xor eax,eax ret WndProc endp Любое рисование в окне начинается с вызова подготовительной процедуры BeginPaint, возвращающей так называемый дескриптор контекста устройства hdc, внешне очень похожий на дескриптор консоли, который мы получали с по- мощью процедуры GetStdHandle. Контекст устройства — это просто параметры, необходимые для работы с ним. Кроме дескриптора hdc BeginPaint заполняет еще специальную служебную структуру ps, внутреннее устройство которой знать не обязательно. Процедура BeginPaint получает общие параметры устройства, но ей не известно положение окна и его размеры. Все это узнает процедура GetClientRect и заносит в специальную структуру rect, после чего можно уже заняться рисованием. Делает это процедура DrawText, управляемая пятью параметрами: дескриптором контекста, начальным адресом строки символов, числом символов (если строка завершается, как у нас, нулем — ставится -1), адресом структуры rect, описываю- щей положение и размер окна, и, наконец, параметром, определяющим стиль вы- вода. Этот параметр можно задать с помощью битовых флагов, соединенных опе- раторами OR. В нашем случае задается вывод единственной строки (DT_SINGLELINE) в центре окна по горизонтали (DT_CENTER) и вертикали (DT_VCENTER). После вывода строки контекст устройства уже не нужен, и чтобы не забивать память лишними параметрами, его следует удалить процедурой EndPaint. Познакомившись с выводом текста, можно скомпилировать программу и, запус- тив ее, увидеть на экране окно с надписью «Не могу молчать!» посередине. Эта надпись слегка «моргает» при изменениях размеров окна, потому что соответ- свующий класс создавался с параметрами CS__HREDRAW or CS_VREDRAW, которые зада- ют перерисовку окна при изменении его горизонтальных (CS__HREDRAW) или вер- тикальных (CS_VREDRAW) размеров. Как только окно становится уже (шире,
192 Глава 13. Окна толще, тоньше), Windows передает сообщение WM PAINT — и надпись рисуется за- ново. Отсюда и «дрожание» текста при изменении размеров ркна и в других, «настоящих» программах, таких, например, как Блокнот. Задача 13.2. Выведите в центр окна содержимое командной строки. Меню Эти программы сильно отличаются от наших, игрушечных, — прежде всего нали- чием меню, которое управляет их работой, меняет режимы, задает параметры и т. д. Поэтому стоит приблизиться к серьезному программированию еще на один шаг и самим научиться создавать меню. Сделать это, как и все в системе Windows, можно разными способами. Мы вы- берем самый распространенный — задание меню в специальном файле с расши- рением .гс (см. листинг 13.6). Листинг 13.6. Простое меню menu.rc # define IDM_HELLO 1 # define IDMJXIT 2 # define IDM ABOUT 3 FirstMenu MENU { POPUP "File” { MENUITEM “Здравствуй”.IDM_HELLO MENUITEM SEPARATOR MENUITEM "Прощай”. IDMJXIT POPUP "Help” { MENUITEM "About".IDM ABOUT } } Легко понять, что в нашем первом меню всего два пункта — File и Help. Слово POPUP перед именем пункта говорит о том, что меню «всплывает» и загораживает собой часть рабочей области окна. В этой всплывшей части меню видны подпункты, выбираемые левой кнопкой мыши. В нашем случае первый пункт меню имеет два подпункта, заданные сло- вом MENUITEM — «Здравствуй» и «Прощай», разделенные чертой, названной SEPARATOR. Второй пункт меню Help содержит всего один подпункт About. Каждый подпункт меню программа узнает по уникальному числу, стоящему пра- вее его названия. Например, подпункту Здравствуй соответствует число IDM HELLO, то есть единица. Готовое меню «пристегивается» к основной программе очень просто. Во-первых, адрес имени меню (в нашем случае оно называется FirstMenu и описано в файле menu.rc) нуж;но присвоить соответствующему полю при создании класса окна: .data MenuName db "FirstMenu".О .code mov wc.lpszMenuName,OFFSET MenuName
Меню 193 Во-вторых, необходимо задать в программе константы, соответствующие раз- личным пунктам меню: IDM HELLO equ 1 IDM~GOODBYE equ 2 IDM EXIT equ 3 IDM_ABOUT equ 4 А дальше оконная процедура может спокойно обрабатывать сообщение WMCOMMAND, возникающее при вызове меню и хранящее номер выбранного пункта в поле wparam. Полный текст программы, управляемой меню, показан в листинге 13.7. Листинг 13.7. Программа, управляемая меню .386 .model fl at.stdcal1 option casemap:none i nclude \myasm\i nclude\wi ndows.1nc i nc1ude \mya sm\i nc1ude\user32.1nc 1nc1ude \myasm\1nc1ude\kernel 32.1nc Includellb \myasm\11b\user32.11b 1 ncludel1b \myasm\11b\kernel32.1i b IDM HELLO equ 1 IDM EXIT equ 2 IDM_ABOUT equ 3 .data ClassName db "SimpleW1nClass“.O AppName db “Знакомство с меню".О MenuName db "FirstMenu".O Hello string db "Ну. здравствуй!“.O Goodbye_str1ng db “ПокаГ'.О About string db “Испытание меню".О wc WNDCLASSEX <> msg MSG <> hwnd HWND ? hlnstance HINSTANCE ? .code start: Invoke GetModuleHandle, NULL mov hlnstance.eax mov wc.cbSize.SIZEOF WNDCLASSEX mov wc.style. CS_HREDRAW or CS VREDRAW mov wc.1pfnWndProc. OFFSET WndProc mov wc.cbClsExtra.NULL mov wc.cbWndExtra.NULL push hlnstance pop wc.hlnstance mov wc.hbrBackground.COLOR_WINDOW+1 mov wc.1pszMenuName,OFFSET MenuName mov wc.lpszClassName.OFFSET ClassName Invoke Loadicon.NULL,IDI_APPLICATION mov wc.hlcon.eax mov wc.hlconSm.eax 1nvoke LoadCursor.NULL.IDC_ARROW mov wc.hCursor.eax Invoke RegisterClassEx. addr wc invoke CreateWindowEx.NULL, ADDR ClassName.\ ADDR AppName. WS OVERLAPPEDWINDOW.\ CW USEDEFAULT. СЙ USEDEFAULT.\ CWUSEDEFAULT, CW USEDEFAULT. NULL. NULL. \ продолжение #
194 Глава 13. Окна Листинг 13.7 (продолжение) hlnstance.NULL mov hwnd.еах invoke ShowWindow, hwnd.SW SHOWNORMAL .WHILE TRUE invoke GetMessage, ADDR msg.NULL.0.0 or eax. eax jz Quit invoke DispatchMessage. ADDR msg .ENDW Quit: mov eax.msg.wParam invoke ExitProcess.eax WndProc proc hWnd:HWND. uMsg:UINT. wParam:WPARAM, 1 Param:LPARAM .IF uMsg==WM_DESTROY invoke PostQuitMessage.NULL .ELSEIF uMsg—WM_COMMAND mov eax.wParam .IF ax==IDM_HELLO invoke MessageBox, NULL.ADDR Hello_string,\ OFFSET AppName.MB OK .ELSEIF ax»=IDM_ABOUT invoke MessageBox.NULL.ADDR About_string,\ OFFSET AppName. MB OK .ELSE invoke DestroyWindow.hWnd .ENDIF .ELSE invoke DefWi ndowProc,hWnd.uMsg,wPa ram,1 Pa ram ret .ENDIF xor eax.eax ret WndProc endp end start Ее «статическая» часть, где создаются окно и его класс, почти не отличается от наших предыдущих программ. Отличия сконцентрированы в оконной процедуре WndProc, которая обрабатывает теперь сообщения WMCOMMAND, возникающие при вы- боре пунктов меню. В ответ на это программа показывает с помощью процедуры MessageBox стандартное окно, в котором показывается одна из фраз (Hello_string, About_string и т. д.) и кнопка ОК, задаваемая константой МВ ОК. В заголовке каждо- го такого окна стоит фраза AppName (в нашем случае это «Знакомство с меню»). При нажатии кнопки ОК вспомогательное окно исчезает, открывая рабочую об- ласть окна основного. Чтобы программа, показанная в листинге 13.7, заработала, ее нужно скомпили- ровать, но прежние командные файлы здесь не подойдут, потому что появился новый файл с описанием меню menu.rc. Расширение .гс говорит о том, что это файл содержит ресурсы — различные картинки, описания меню, курсоры, пик- тограммы и т. д. Файлы ресурсов нужно компилировать специальной програм- мой rc.exe, входящей в состав нашего ассемблера. После компиляции ресурсов в папке возникает новый файл с расширением .res, который компоновщик объ- единяет с объектным файлом, чтобы в результате получилась готовая програм-
Последнее окно 195 ма .ехе. Командный файл для приготовления программы, заданной листингами 13.6, 13.7, показан в листинге 13.8. Листинг 13.8. Командный файл menumake.bat для программ с ресурсами ml /с /coff "fcl.asm" гс ”$2.гс” link /SUBSYSTEM:windows ’^l.obj” ”*2.res” Если, скажем, исходный текст программы на ассемблере называется I137.asm, а файл с описанием меню — menu.rc, то программа будет скомпилирована ко- мандой: menumake 1137 menu Последнее окно Запустив только что скомпилированную программу, увидим на экране пример- но то же, что и на рис. 13.2. Рис. 13.2. Прощай, программирование для Windows! Выбрав пункт меню Прощай, мы попрощаемся не только с нашей программой, но и со всем «оконным» программированием, потому которую это почти не до- бавит нам знаний ассемблера. Это будет уже изучением системы Windows, а онот- ребует книги объемом в несколько раз больше той, которую вы держите сейчас в руках. К тому же, программировать для Windows все-таки лучше на каком-ни- будь языке высокого уровня, например Си*. Сравнение простейшей программы на Си и точно такой же на ассемблере пока- жет, что соответствие между ассемблером и Си почти однозначное, ведь про- цедуры Windows API одни и те же для любого языка. Но все же язык Си удоб- нее для изучения Windows, чем ассемблер. Есть, правда, такие области, где ассемблер нельзя заменить языками высокого уровня. Например, программирование драйверов для внешних устройств: прин- теров, музыкальных плат, сканеров и т. д. Но эта тема слишком сложна, чтобы подробно говорить о ней в этой книге. *Для знакомства с языком Си могу рекомендовать свою книгу: Крупник А. Изучаем Си. СПб.: Пи- тер, 2001.
Глава 14 Ассемблер и другие языки В этой короткой главе пойдет речь о месте ассемблера в программировании. До сих пор мы писали программы целиком на ассемблере, потому что эта книга по- священа именно ему. Но в реальной жизни так поступают только самые «упер- тые» фанатики, не желающие знать (а зачастую и не знающие) других языков. При этом они ощущают свое превосходство над простыми пользователями Пас- каля или Бейсика. И совершенно напрасно. Ведь ассемблер, если честно,— пер- вобытный, первоначальный язык, верный девизу: «что вижу — о том пою». В ас- семблере каждая инструкция понятна и подробно описана. И если существуют на свете сложные языки, то это скорее C++. Так что ассемблер не стоит изучать только потому, что это «круто». Ассемблер нужен совсем для другого. Прежде всего, без знания ассемблера невозможно понять, как работает операци- онная система, как она делит ресурсы между программами и как хранит данные в своих служебных областях. Ассемблер необходим при создании программ, взаимодействующих с аппарату- рой. Это могут быть драйверы устройств, работающих с Windows или DOS. Далее, ассемблер нужен программисту, чтобы понять, почему программа работает неправильно. Современные компиляторы очень хороши, но и они ошибаются. И если программист не понимает, в чем дело, он велит компилятору дать «отчет о проделанной работе» — показать листинг программы на ассемблере. Наконец, ассемблер необходим там, где от программы требуется большая ско- рость. Вычислительная мощь современных компьютеров чудовищно велика и стремительно растет. Но сложность решаемых задач растет еще быстрее. Вот почему производительности даже самых мощных компьютеров не хватает. Ко- гда обнаруживается, что программа на языке высокого уровня работает пра- вильно, но слишком долга, программист прежде всего пытается найти узкие места в программе, для чего она подвергается профилированию: специальная программа следит, сколько времени потрачено на определенные участки про- граммы, сколько раз вызываются те или иные процедуры. Как правило, профилирование выявляет узкие места программы, на которые тра- тится большая часть времени процессора. Вот эти места и следует переписать на
Ассемблер и другие языки 197 ассемблере, потому что квалифицированный программист делает это лучше, чем компилятор языка высокого уровня. Часто процедуры, требующие длительных вычислений, сразу пишутся на ассемблере и затем объединяются в DLL, чтобы ими смогли пользоваться все желающие. В этой книге мы почти не интересовались временем выполнения инструкций процессора и не пытались писать быстро работающие программы, потому что тема слишком сложна для начинающих программистов. Но понять, как вообще сочетаются ассемблер и языки высокого уровня, мы сможем. Представим себе, что написана программа на языке Си, в которой функция xchg меняет местами две целочисленных переменных а и b (см. листинг 14.1). Листинг 14.1. Простая программа на языке Си #include <stdio.h> void xchgdnt *a.1nt *b); int main(){ int a-2. b-3; xchg(&a.&b): printfC'a- W b- W\n".a.b); return 0: void xchgdnt *a.int *b){ int tmp: tmp-*a: *a-*b: *b-tmp: ) Как и положено в языке Си, функция xchg получает два указателя на 1nt. А теперь поставим перед собой задачу научиться сочетать функции, написанные на Си, и функции, написанные на ассемблере. Проще всего это сделать, подсмот- рев, как компилятор транслирует функцию на язык ассемблера. Разумеется, ка- ждый компилятор делает это по-своему, поэтому попробуем поработать с тем, что оказалось под рукой — компилятором Borland C++ версии 5.5. Г. В компиляторе Borland C++ выдачей листинга на ассемблере управляет ключ -S. Чтобы получить этот листинг, сохраним функцию в отдельном файле xchg.c void xchgdnt *a.int *b){ int tmp: tmp-*a; *a-*b: *b-tmp: и запустим из оболочки FAR компилятор: Ьсс32 -с -S xchg.c Ключ -с в командной строке означает, что на выходе создается только объект- ный файл xchg.obj, компоновщик не запускается. А ключ -S командует компиля- тору создать ассемблерный листинг функции. 'Этот компилятор бесплатен и его можно найти на ftp-сайте фирмы Borland ftp://ftpd.borland.com/download/bcppbuilder/ freecommandLinetools.exe.
198 Глава 14. Ассемблер и другие языки После запуска компилятора в папке, где хранится исходный текст функции hchg.c. появятся объектный файл xchg.obj и ассемблерный листинг xchg.asm. Открыв его, увидим кучу непонятных директив, меток, начинающихся знаком вопроса, и ком- ментариев. Это текст на ассемблере, созданный компилятором и потому не очень подходящий человеку. Но если его не пугаться, в нем можно выделить строки, непосредственно относящиеся к нашей функции (листинг 14.2). Листинг 14.2. Функция xchg, переведенная на язык ассемблера xchg proc near 7Hvel@0: :vo1d xchgdnt *a.1nt *b){ push ebp mov ebp. esp push ebx mov edx. dword ptr [ebp+12] mov eax. dword ptr [ebp+8] : int tmp: : tmp = *a: ?11vel@16: : EAX = a. EDX = b @1: mov ecx, dword ptr [eax] ;*a = *b: ?livel@32: ; EAX = a. EDX = b. ECX - tmp mov ebx.dword ptr [edx] mov dword ptr [еах],ebx ; *b = tmp; ?livel@48: : EDX = b. ECX = tmp mov dword ptr [edx],ecx : } ?11vel@64: ; @2: pop ebx pop ebp ret _xchg endp В этом отрывке сочетаются инструкции ассемблера и комментарии, в которых показаны соответствующие инструкции языка Си. Начинается функция хорошо известным нам прологом: push ebp mov ebp. esp После него отсчет параметров, переданных в стек, идет относительно ebp. Пара- метры эти занимают привычные нам места [ebp+8] и [ebp+12] и переписываются в регистры edx, еах: mov edx, dword ptr [ebp+12] mov eax. dword ptr [ebp+8]
Ассемблер и другие языки 199 Комментарий, приведенный чуть ниже, показывает, что в регистр еах попадает параметр а, в регистр же edx записывается параметр Ь. Это значит, что первым в стек загружается параметр Ь, затем а. Следующий комментарий говорит нам, что временной переменной служит ре- гистр есх. Дальнейшие инструкции совершенно понятны: mov есх, dword ptr [eax]:tmp « *а mov ebx. dword ptr [edx];*a = *b mov dword ptr [eax], ebx: mov dword ptr [edx], ecx:*b » *tmp ret Они, кстати, раскрывают тайну указателей в языке Си, показывая, что это про- стые адреса. Завершается функция выталкиванием из стека регистров ebx, ebp и, конечно, возвратом ret. Регистр ebx выталкивается потому, что в начале функции он был сохранен в стеке. Почему же не был сохранен есх? Очевидно, таковы правила компилятора: регистром есх он не дорожит, a ebx использует для каких-то сво- их целей и потому не допускает его порчи внутри функции. Список регист- ров, которые нужно сохранять в стеке, можно найти в документации к ком- пилятору. Но можно просто получить ассемблерный листинг сложной функ- ции, использующей все регистры и посмотреть, какие из них сохраняются в сте- ке. Зная «законы компилятора», легко «выпотрошить и выбросить вон» созданную им функцию, а взамен написать свою, которая, возможно, будет работать быст- рее. В случае с компилятором Borland C++ последовательность действий будет такой: 1. Создается программа на языке Си, где объявлена функция, которую нужно переписать на ассемблере. В нашем случае она выглядит так: --------------------main.с------------------ #1 nclude <std1o.h> void xchgdnt *a,1nt *b): Int ma1n(){ Int a=2,b=3: • xchg(&a,&b): pr1ntf("a= Sd b= Sd\n",a,b); return 0: } 2. Создается функция на языке ассемблера xchg.asm. Как принять параметры внутри функции и какие регистры сохранить, подскажет компилятор, если создать «муляж» функции на языке Си и получить ассемблерный листинг. Оба файла передаются компилятору bcc32 main.с hchg.asm, который создаст файл main.exe*. Ну а дальше начинается самое главное: нужно так подобрать инструк- ции процессора, чтобы они выполнялись быстрее созданных компилятором ’Перед запуском компилятора ВСС придется установить на компьютере 32-битовый ассемблер фирмы Borland tasm32.exe. Эта программа не распространяется бесплатно, но ее легко можно най- ти в Интернете или в одной из файлообменных сетей (Gnutella, Kazaa или Edonkey).
200 Глава 14. Ассемблер и другие языки языка высокого уровня. Для каждого процессора фирмы Intel* эта задача реша- ется по-своему, потому что время выполнения одной и той же инструкции у разных процессоров различно. Чтобы справиться с этой задачей, нужно хорошо знать устройство процессоров и того, что их окружает. Ведь скорость выполне- ния программ часто определяется не самим процессором, а его взаимодействием с компьютерной памятью и внешними устройствами (жесткими дисками, порта- ми USB и т. д.). Но все это — тема других, гораздо более толстых, книг. ’Процессоры AMD хоть и совместимы с процессорами Intel, но обладают иной архитектурой, по- этому программы приходится специально оптимизировать и для них.
Решения задач Глава 2 Задача 2.1. Чтобы получить отдельные цифры десятичного числа, нужно после- довательно делить на 10, сохранять остатки от деления, а затем поменять их по- рядок на противоположный (см. табл. 2.1). Значит, для перевода десятичного числа в двоичное нужны остатки от последовательного деления на 2. Например, для числа 11 это будет выглядеть так: Действие Частное Остаток И/2 5 1 5/2 2 1 2/2 1 0 1/2 0 1 Значит, ll10=10112 Задача 2.2. 4 000 000 00010=31 143 000 000 0005 Задача 2.3. Старший бит результата не исчезает в этом случае, а просто оказы- вается во флаге С. Поэтому восстановление возможно. Пусть, например, числа 128 и 140 хранятся в двух байтах. Тогда результат сложения будет равен (в ше- стнадцатеричной системе) ОС. Если учесть флаг переноса, то получится ЮС, что как раз и равно 26810. Задача 2.4. Переполнение возникает, когда знак обоих слагаемых одинаков. Пусть он отрицателен. Тогда перенос из старшего бита будет всегда. Если он не будет сопровождаться переносом в старший бит, знак суммы будет отличен от знака слагаемых, то есть возникнет переполнение. При сложении положитель- ных чисел со знаком, перенос из старшего разряда не возникнет никогда, пото- му что оба разряда нулевые. Но если произойдет перенос в старший разряд, сумма окажется отрицательной, то есть опять возникнет переполнение. Во всех остальных случаях сумма будет иметь тот же знак, что и слагаемые, то есть пе- реполнения не будет.
202 Решения задач Задача 2.5. Представим себе, что складываются не ЛГ-битные, a (N + 1)-битные числа. Тогда переполнения уже не будет, но результат сложения ^бит окажет- ся верен, потому что они одинаковы как в (N + 1)-битных, так и в ЛГ-битных числах. Значит, нужно только догадаться, каким будет (N + 1)-й бит. А он мо- жет быть только равным единице при сложении отрицательных и нулю — при сложении положительных чисел. Поскольку мы знаем, что переполнение ведет к отличию знака суммы от знака слагаемых, (N + 1)-й бит всегда получается инвертированием ЛГ-го. Глава 3 Задача 3.1. В листинге 3.11 процедура AddDigs вызывается директивой invoke, из-за чего параметры процедуры иначе расположились в стеке. Задача 3.2. К сожалению, инструкцию ret невозможно спрятать. Поэтому она все равно будет напоминать о стеке: AddDigs proc argl:DWORD, arg2:DWORD mov eax. a'rgl ; eax=2 add eax. arg2 : eax=5 ret AddDigs endp Глава 4 Задача 4.1. Для вывода простых чисел на экран лучше написать отдельную про- цедуру: DlgsDIsp proc Addrlni:DWORD. NOfPrlmes:DWORD. Outld:DWORD,\ AddrFormStr:DWORD. AddrBuf:DWORD :AddrIrr i - адрес начала массива, хранящего числа ;NOfPrimes - число простых чисел zOutld - хендл устройства вывода ;AddrFormStr- адрес строки формата :AddrBuf - адрес буфера LOCAL stdout:DWORD. cWritten:DWORD LOCAL CRLF:WORD mov CRLF.13*256+10 :возврат курсора, перевод строки push pop mov mov NxtDig: Invoke DWORD PTR Outld stdout edi .0 esi. Addrlni wsprintf, AddrBuf. AddrFormStr. DWORD PTR [esi] Invoke WriteConsoleA. stdout. AddrBuf. BSIZE. \ Invoke ADDR cWritten, NULL WriteConsoleA. stdout. ADDR CRLF. 2. \ add inc ADDR CWritten. NULL esi. 4 edi
Решения задач 203 cmp edi, NOfPrimes jnz NxtDig ret DigsDisp endp Задача 4.2. Основной недостаток программы из листинга 4.6 — в несовершен- ном делении. Когда испытываемое число перестанет умещаться в одном регист- ре, программа начнет выдавать неверные результаты. Глава 5 Задача 5.1. Двоичное число представимо суммой: х » (а„*2” + а„.1*2п-1+ ... + а2*22) + + <з0*2°), где первое слагаемое делится на 4, а второе — нет. Значит, в битах 1,0 записан остаток от деления на 4, и число делится на 4, если этот остаток (то есть биты 1,0) равен нулю. Проверить делимость на 4 числа, записанного в регистре еах, мож- но инструкцией test еах.З, ведь 3 — число, у которого равны единице только первые два бита. Отсюда простая программа, проверяющая, делится ли на 4 число DIGIT: .386 .model flat.stdcall option casemap:none i nclude \myasm\i nclude\wi ndows.i nc i nclude \myasm\i nclude\kernel32.i nc include \myasm\include\kernel 32.inc includellb \myasm\1ib\kernel32.11b DIGIT EQU 83 StrDisp proto StrAddr:DWORD, StrSz:DWORD .data Yes BYTE "Делится на 4",13.10 No BYTE "He делится на 4".13,10 .code start: mov eax, DIGIT cmp eax, 0 jz MsgNo test еах.З :делится на 4? jnz MsgNo ; нет - показать "He делится на 4" Invoke StrDisp, ADDR Yes. sizeof Yes ;Да - делится jmp exit MsgNo: Invoke StrDisp.ADDR No. sizeof No exit: Invoke ExitProcess.0 StrDisp proc StrAddr:DWORD. StrSz:DWORD LOCAL stdout:DWORD. cWritten:DWORD Invoke GetStdHandle. STD_OUTPUT_HANDLE mov stdout. eax Invoke WriteConsoleA. stdout. StrAddr, \ StrSz. ADDR cWritten, NULL
204 Решения задач ret StrDisp endp end start Задача 5.2. Если, к примеру, три младших бита в еах равны нулю, то число в ре- гистре еах изображается так: ХХХХХХХХХХХХХХХХХХХХХХХХХХХХ1000 Здесь х — биты, чье значение нас не интересует. После вычитания единицы в регистре ebx окажется число: ххххххххххххххххххххххххххххО111 Ясно, что после логического И крайний правый бит в еах «погаснет», остальные же биты останутся прежними. Задача 5.3. Инструкция хог еах, OFFFFFFFFh инвертирует биты в регистре еах, то есть она аналогична инструкции not еах. Задача 5.4. Инструкции хог еах.ebx хог eax,ebx ничего не делают. То есть после вычислений еах оказывается точно таким же. Чтобы доказать это, достаточно вспомнить, что логические операции побито- вые, значит то, что справедливо для отдельных битов, справедливо и для регист- ра в целом. Поэтому нужно рассмотреть четыре варианта: 0-0, 0-1, 1-0, 1-1. Сделать это удобно с помощью диаграмм, одна из которых (для случая, когда оба бита равны единице) показана на рис. А1. Горизонтальная стрелка показы- вает, какой регистр изменится в результате операции. Вертикальная указывает на результат. В квадратиках показаны конечные значения регистров. Заметим, что первую операцию хог eax,ebx можно рассматривать как простое шифрование. Регистр ebx шифрует еах. После второй операции хог еах,ebx ре- гистр еах принимает первоначальное значение — дешифруется. еах ebx 1 ------ 1 хог eax, ebx I 0ч|-----[Т] хог eax, ebx I ш Рис А1. Диаграмма для двух последовательных операций XOR Задача 5.5. После инструкций: хог еах.ebx хог ebx.еах хог еах.ebx регистры еах и ebx обмениваются содержимым. Как обычно, достаточно рас- смотреть четыре варианта: 0-0, 0-1, 1-0, 1-1. Диаграмма для одного из таких вариантов приведена на рис. А2.
Решения задач 205 еах ebx 1 <---- 1 хог еах. ebx 1-------► 0 хог ebx, еах 1«<-----1Т| хог еах. ebx I >и Рис. А2. Обмен содержимым регистров с помощью операции XOR Задача 5.6. Чтобы подсчитать биты, можно сдвигать регистр и увеличивать счет- чик каждый раз, когда флаг С равен 1. Этот «туповатый* подход требует числа сдвигов, равного числу битов в регистре. Можно поступить умнее и «гасить» крайний правый бит (см. задачу 5.2) до тех пор, пока регистр не станет равен нулю. Такой способ требует числа попыток, равного числу единичных битов в ре- гистре, поэтому он быстрее. Процедура, подсчитывающая единичные биты в 32- разрядном числе Digit, может быть такой: BitCount proc Di git:DWORD mov eax.Di git mov ecx. О -.начальное значение счетчика Nxt: cmp еах. О :все биты сосчитаны? jz Done :да - выход inc ecx ;нет - удаляем следующий бит mov ebx. еах dec ebx and eax. ebx jmp Nxt Done: mov eax,ecx ret BitCount endp Задача 5.7. Эта задача решается так же, как и задача 5.1. Нужно только пра- вильно сформировать битовую маску. Если в еах хранится число, а в ebx — сте- пень двойки, то остаток вычисляется так: dec ebx -.получить битовую маску and еах.ebx ;в еах - остаток Задача 5.8. Пусть число находится в регистре еах. Тогда умножить его на 10 можно так: mov ebx, еах shl еах. 2 add еах. ebx shl еах. 1 :умножить на 4 :умножить на 5 :умножить на 10 Задача 5.9. Минимальные отрицательные числа, которые можно хранить в 8-, 16- и 32-битовых регистрах равны соответственно -128, -32 768, -2 147 483 648. Зна- чит, сдвинуть на шаг влево можно -64 (для 8-битовых), -16 384 (для 16-битовых) и -1 073 741 824 для 32-битовых регистров.
206 Решения задач Задача 5.10. Инструкция sar dx,15 делает все биты регистра dx либо единичны- ми (в случае, когда ах — отрицательное число), либо нулевыми (когда ах поло- жительно). В первом случае операция хог ax,dx инвертирует биты регистра ах (см. задачу 5.3) а инструкция sub ax.dx вычитает из ах -1 (все единицы в регист- ре dx как раз соответствуют -1 в дополнительном коде). Вычитание -1 эквива- лентно прибавлению единицы. Значит, инструкции хог ax.dx; sub ax.dx эквива- лентны в этом случае инструкциям not ах; inc ах, меняющим знак числа. В пер- вом случае ах было отрицательным, значит, станет положительным. Второй случай гораздо проще. Когда в регистре dx все биты равны нулю, инструк- ции хог ax.dx; sub ax.dx ничего не делают с регистром ах, и хранящееся там по- ложительное число не меняется. Значит, ответ таков: инструкции mov dx.ax sar dx.15 xor ax.dx sub ax.dx вычисляют абсолютное значение числа. Заметим, что инструкции mov dx.ax; sar dx,15 переводят слово ах в двойное слово dx:ax (расширяют знаковый бит). Для такой операции есть специаль- ная инструкция процессора cwd (convert word to doubleword). Задача 5.11. Для простоты сложим «по частям» два двойных слова FirstDigit и SecondDigi t. Если хранить их сумму в двойном слове Sum, то инструкции сло- жения могут быть такими: mov ах. word ptr FirstDigit mov bx. word ptr SecondDIglt add ax. bx mov word ptr Sum. ax mov ax. word ptr [FirstDigit+2] mov bx. word ptr [SecondDigit+2] adc ax. bx mov word ptr [Sum+2],ax Задача 5.12. Вычитание с заемом означает, что 3 вычитается не из единицы, а из воображаемого числа, у которого слева приписан дополнительный единич- ный разряд. В случае 4-битовых регистров этот разряд пятый и его вес равен 16. То есть, на самом деле мы вычли 3 не из единицы, а из 17: 17 - 3 - 14 - 16 - 2, то есть, получили -2 в дополнительном коде. Задача 5.13. Инструкция sbb еах,еах вычитает из нуля (разность еах • еах) флаг С. значит, при ОО регистр еах будет равен нулю, а при О1 все биты еах обратятся в 1 (получится -1 в дополнительном коде). Получилось ветвление без примене- ния инструкций jc или jnc. Задача 5.14. В приведенной процедуре используется правило деления длинных чисел «столбиком». Не имея возможности сразу поделить все число, мы сначала «выталкиваем» из него 16 старших битов, делим их и вталкиваем частное в пару регистров, хранящих результат. К остатку от этого деления дописываются следую- щие 16 бит числа, и процесс повторяется 4 раза (16 х4 = 64). Перед текстом ас- семблерной процедуры приведена аналогичная функция, написанная на языке Си:
Решения задач 207 : unsigned long long d1v64_16 : (unsigned long long value.unsigned short divisor) { ; unsigned tmp-O.a-4; : unsigned long long result-0: : whileCa--) { tmp +- (value » 48): : value «- 16: : result - (result « 16) | (tmp / divisor); : tmp-(tmp % divisor) « 16: : } : return result; Div64_16 proc DivldentH:DWORD.D1videntL:DWORD.Divisor:WORD ;edx:eax - value :es1- tmp ;edi- a :ecx:ebx - result mov edx. Div1dentH mov eax. DividentL mov ecx, 0 iresultH - 0 mov ebx. 0 iresultL - 0 mov esi. 0 ;1 ;mp - 0 mov edi .4 :a - 4 nxt: cmp edi.O a - 0? jz done : да - деление окончено dec edi следующие 16 бит ror edx.16 :value » 48 (старшие биты - в dx ) push eax mov eax.O mov ax.dx add esi. eax ; :tmp+-(value»48) pop eax rol edx.16 вернуть на место shl edx.16 вытолкнуть rol eax,16 использованные mov dx.ax 16 xor ax.ax бит shl ecx.16 сдвинуть rol ebx.16 биты mov cx.bx результата xor bx.bx на 16 поз. влево push edx push eax rol esi. 16 mov dx. si ror esi. 16 mov ax. si div Divisor :tmp/divisor: dx - остаток, ax - частное mov bx. ax :result-(result«16) | (tmp/di visor) mov esi.O mov si. dx :tmp =(tmp % divisor) shl esi.16 освобождаем место в tmp для след. 16 бит pop eax pop edx jmp nxt : : используем следующие 16 бит
208 Решения задач done: ret Div64_16 endp Задача 5.15. Простая процедура Atoi, показанная ниже, переводит строку симво- лов в целое число, хранимое в регистре еах. Символ превращается в цифру вычита- нием числа 48. Признаком окончания строки служит пробел (его код 32). Получив очередную цифру, процедура добавляет ее к текущему числу, предварительно сдвинув десятичные разряды влево умножением на 10. Процедура ничего не знает о знаке и не защищена от неверных символов: Atoi mov mov хог хог хог next: proc StrAddr:DWORD es1. StrAddr ecx.10 ebx.ebx edx.edx eax.eax mov cmp jz sub mul add inc jmp done: bl.[esi] :получить символ Ы.32 : пробел done : - конец строки bl.48 :превратить в цифру есх :сдвинуть десятичные разряды влево еах.ebx добавить цифру esi :к следующему символу next ret Atoi endp Глава 6 Задача 6.1. Нужно сравнить возвращаемый хендл и INVALID_HANDLE_VALUE: .data FOpenError BYTE "Ошибка при открытии файла".13.10 .code start: Invoke CreateFile. ADDR fName.\ GENERIC READ.\ 0. NULL? OPEN EXISTING.\ FILE ATTRIBUTE NORMAL. 0 cmp eax.TNVALID_HANDLE_VALUE jz exit exit: invoke WriteConsole. stdout.\ ADDR FOpenError.\ sizeof FOpenError. ADDR cWritten, NULL invoke ExitProcess. 0 end start Задача 6.2. Когда файл прочитан до конца, указатель устанавливается сразу за последним символом, в нашем случае — за мягким знаком. Поэтому нужно еде-
Решения задач 209 лать четырнадцать шагов к началу файла, чтобы указатель встал сразу за словом «СОЛИТЬ»: invoke SetFilePointer, fHandle. -14. NULL.FILE_CURRENT Чтобы не подсчитывать вручную число позиций, воспользуемся тем, что коли- чество прочитанных символов CRead, возвращенное процедурой ReadFi 1 е, как раз равно числу шагов «назад». Поэтому перемещение указателя к нужной позиции может быть и таким: mov еах. cRead neg еах ;идем назад invoke SetFilePointer. fHandle. eax. NULL,FILE_CURRENT Глава 7 Задача 7.1. Оценим точность и диапазон значений числа с одинарной точностью, занимающего 32-битовый регистр. Экспонента, занимающая в таком числе 8 бит, должна быть как положительной, так и отрицательной, поэтому ее значения бу- дут в пределах от —128 до +127*. Чтобы перевести степень двойки в степень де- сяти, воспользуемся тем, что 210 (1024) примерно равно 103. Значит, степень двойки нужно поделить на 3.3333, чтобы получить степень десятки. Проделав это, найдем, что экспонента в 32-битовом числе с одинарной точностью задает диапазон значений от 10“38 до 1038(для положительных и отрицательных чисел). Точность представления числа определяется 23-битовой мантиссой. Если вспомнить, что в числе хранится только дробная часть мантиссы, а единица «подразумевается», то общее число битов станет равно 24. Поделив 24 на 3.3333..., получим чуть больше 7 десятичных знаков после запятой. Для чисел с двойной точностью аналогичная оценка даст диапазон значений от 10~307 до 10307 с точностью 16 десятичных знаков после запятой. Задача 7.2. Количество чисел определяется числом бит в регистре. В 32 бит (одинарная точность) можно уместить 232 разных числа, в 64 бит — соответст- венно 2е4. Глава 11 Задача 11.1. Можно применить сканирование строки инструкцией scasb (см. раз- дел «Командная строка») главы 6. Найдя символ Odh, нужно поставить вслед за ним Oah и ‘ ’ и затем вывести строку на экран: .radix 16 cseg segment assume cs:cseg org 0100 ini : cld mov di. 80 *Ha самом деле для представления мантиссы используется другой, отличный от дополнительного, код, и значения мантиссы меняются от -127 до +128.
210 Решения задач mov ex.-1 mov al.Odh repne scasb :найти символ Odh mov [di], byte ptr Oah mov [di+1]. byte ptr Ё$Ё mov ah, 9 ;вывести строку mov dx, 81 :адрес строки - в DX int 21h mov ah. 4ch ;выход int 21h cseg ends end ini Задача 11.2. Соответствующая процедура WToHex мало чем отличается от WtoAscii: WToHex proc near xor dx. dx div сх :цифру в dx, остаток числа в ах or ах. ах :все цифры выделили? jz Done :да - записать первую цифру push dx сохранить очередную цифру call WToHex pop dx :достать очередную цифру Done: mov si.dx mov al,HEX[si] mov [di], al сохранить символ inc di ret HEX BYTE "0123456789ABCDEF" WToHex endp Только теперь перед вызовом процедуры в регистре сх должно быть число 16. И пришлось еще завести массив шестнадцатеричных символов HEX. Задача 11.3. Процедура DwToAscil, переводящая «длинное» число в строку сим- волов, очень похожа на процедуру WToAsci 1 из листинга 11.4: :перевод двойного слова dx:ax в строку символов DwToAscil proc near call DivlO mov si, dx or si. ax jz Done : да - записать первую цифр; push call bx DwToAsci1 сохранить очередную цифру POP bx :достать очередную цифру Done: add Ы.ЁОЁ ;перевести цифру в символ mov [di], bl inc di ret DwToAsci i endp сохранить символ Только в ней приходится применять специальную вспомогательную процедуру DivlO, делящую двойное слово dx:ax на 10: :D1vlO делит DX:AX на 10. ;В Ы оказывается остаток, а в dx:ax - частное DivlO proc near mov сх. 10
Решения задач 211 mov bx. ax xchg ax. dx xor dx. dx div ex xchg bx. ax div ex xchg ret dx. bx Divio endp Задача 11.4. Сегмент стека отодвигается вниз на 50016 = 1280. Поскольку уве- личение стека на единицу означает увеличение адреса на 16, для инструкций программ остается 1280*16 байт -500 (размер самого стека). В итоге получаем 19 968 байт. Глава 13 Задача 13.1. Программа превратится в «призрак», то есть окно уничтожится, но выхода из цикла обработки сообщений GetMessage...DispatchMessage не произой- дет, и программа будет видна в списке активных процессов, который можно по- лучить, если вызвать диспетчер задач одновременным нажатием трех клавиш Ctri+Alt+Del. Программу-призрак приходится удалять вручную кнопкой Завер- шить процесс в диспетчере задач. Задача 13.2. Исходный текст программы, выводящей в центр окна командную строку, может выглядеть так: .386 .model flat, stdcal1 option casemap:none 1nclude \myasm\1nclude\wi ndows.1nc 1nclude \щуasm\1nclude\user32.i nc i nclude \myasm\i nclude\kernel32.1nc includellb \myasm\lib\user32.Hb i ncl udel 1 b \myasm\11b\kernel32.11b .data ClassName BYTE "SimpleW1nClass".O AppName BYTE "Первое окно",0 IpCmd DWORD ? wc WNDCLASSEX <> msg MSG <> hwnd HWND ? hlnstance HINSTANCE ? .code start: invoke GetModuleHandle. NULL mov hlnstance,eax invoke GetCommandLine mov IpCmd.eax mov wc.cbSize,SIZEOF WNDCLASSEX mov wc.style, CS_HREDRAW or CS_VREDRAW mov wc.1pfnWndProc, OFFSET WndProc mov wc.cbClsExtra.NULL mov wc.cbWndExtra.NULL push hlnstance pop wc.hlnstance
212 Решения задач mov wc.hbrBackground. COLOR_WINDOWFRAME mov wc.lpszMenuName.NULL mov wc.lpszClassName.OFFSET ClassName 1nvoke Loadicon.NULL.IDI_APPLICATION mov wc.hlcon.eax mov wc.hlconSm.eax Invoke LoadCursor.NULL.IDC_ARROW mov wc.hCursor.eax Invoke RegisterClassEx. ADDR wc INVOKE CreateWindowEx,NULL.ADDR ClassName.ADDR AppNameA WS_OVERLAPPEDWINDOW.CWJJSEDEFAULT.\ CW USEDEFAULT.CW USEDEFAULT.CWJJSEDEFAULT.\ NULL,NULL.hlnstance.NULL mov hwnd. eax Invoke ShowWindow. hwnd.SW_SHOWNORMAL .WHILE TRUE Invoke GetMessage. ADDR msg. NULL. 0. 0 or eax.eax jz Quit Invoke DispatchMessage. ADDR msg .ENDW Quit: mov eax.msg.wParam Invoke ExitProcess,eax WndProc proc hWnd:HWND. uMsg:UINT. wParam:WPARAM,\ 1 Param:LPARAM LOCAL hdc:HDC LOCAL ps:PAINTSTRUCT LOCAL rect:RECT .IF uMsg--WM_DESTROY Invoke PostQuitMessage.NULL .ELSEIF uMsg—WM PAINT Invoke BeginFaint.hWnd. ADDR ps mov. hdc. eax invoke GetClientRect.hWnd. ADDR rect invoke DrawText, hdc. IpCmd, -1,\ ADDR rect. DT SINGLELINE or DTCENTER\ or DTJ/CENTER invoke EndPaint. hWnd. ADDR ps .ELSE 1nvoke DefW1ndowProc.hWnd.uMsg.wPa ram.1 Pa ram ret .ENDIF xor eax,eax ret WndProc endp end start
Приложение Флаги и основные инструкции процессора Флаги С (бит 0) — флаг переноса. Поднимается (становится равным единице) при переносе или заеме из старшего бита. Р (бит 1) — флаг четности. Поднимается, когда число единиц в младших восьми разрядах результата четно. А (бит 4) — флаг вспомогательного переноса. При арифметических операци- ях с 8-разрядными числами поднимается, когда произошел перенос из млад- шей тетрады в старшую, или когда произошел заем из старшей тетрады. Z (бит 6) — флаг нуля. Устанавливается, когда все биты результата нулевые. S (бит 7) — флаг знака. Равен старшему биту результата. I (бит 9) — флаг разрешения прерывания. Когда флаг поднят — прерывания разрешены, когда опущен — запрещены. D (бит 10) — флаг направления. Определяет направление работы инструк- ций, работающих со строками (movs, seas, crops...). Если флаг поднят, адреса памяти увеличиваются, если опущен — уменьшаются. О (бит И) — флаг переполнения. Устанавливается, когда результат арифме- тической операции со знаком не умещается в регистре или памяти. Инструкции процессора ADC opl, ор2 — сложение с переносом. (opl) <- (opl) + (ор2) + С Прибавляет к первому операнду второй операнд и содержимое флага переноса С. Старое значение первого операнда при этом уничтожается, второй операнд оста- ется неизменным.
214 Приложение. Флаги и основные инструкции процессора Первый операнд может быть регистром общего назначения* или ячейкой памя- ти. Второй операнд может быть регистром, ячейкой памяти или явно заданным числом. Знак этого числа расширяется, чтобы второй операнд стал того же раз- мера, что и первый. Нельзя, чтобы оба операнда были ячейками памяти. Инструкции ADC безразлич- но, какие числа — со знаком или без — складываются. На всякий случай процес- сор устанавливает флаги для обоих типов сложения (С — для сложения чисел без знака, 0 — для чисел со знаком). Основное назначение инструкции adc — сложение «длинных» чисел, не умещаю- щихся в 32 битах. Меняет флаги OSZAPC. ADD opl, ор2 — обычное сложение. (opl) (opl) + (ор2) Прибавляет к первому операнду второй операнд. Старое значение первого опе- ранда при этом уничтожается, второй операнд остается неизменным. Первый операнд может быть регистром или ячейкой памяти. Второй операнд мо- жет быть регистром, ячейкой памяти или явно заданным числом. Знак этого чис- ла расширяется, чтобы второй операнд стал того же размера, что и первый. Нельзя, чтобы оба операнда были ячейками памяти. Инструкции ADD безразлич- но, какие числа — ссо знаком или без — складываются. На всякий случай про- цессор устанавливает флаги для обоих типов сложения (С — для сложения чи- сел без знака, 0 — для чисел со знаком). Меняет флаги OSZAPC AND opl, ор2 — побитовое И. (opl) <- (opl) AND (ор2) Выполняет побитовое логическое И первого и второго операнда, сохраняя ре- зультат в первом операнде. Бит результата равен единице, если равны едини- це оба соответствующих бита первого и второго операнда. Иначе бит равен нулю. Первый операнд может быть регистром или ячейкой памяти. Второй операнд мо- жет быть регистром, ячейкой памяти или явно заданным числом. Нельзя, чтобы оба операнда были ячейками памяти. Меняет флаги SZP, опускает флаги 0 и С, флаг А не определен. BSF opl, ор2 — ищет самую «младшую» единицу во втором операнде. Позиция найденной единицы сохраняется в первом операнде. Второй операнд может быть регистром или ячейкой памяти. Первый операнд может быть только регистром. Если во втором операнде все биты равны нулю, содержимое первого после выполнения инструкции не определено. *То есть одним из восьми регистров (Е)АХ, (Е)ВХ, (Е)СХ, (E)DX, (ЕВ)Р, (E)SP, (E)SI, (E)DI или их частью, например AL, ВН и т. д. В дальнейшем регистры общего назначения будем называть «обычными регистрами» или просто «регистрами».
Инструкции процессора 215 Пример: Если еах равен 4, то после выполнения инструкции: BSF ebx. еах в регистре ebx окажется число 2 (нумерация битов начинается с нуля). Если единичный бит найден, Z опускается, если нет — поднимается. Флаги OSAPC не определены. BSR opl, ор2 — ищет самую «старшую» единицу во втором операнде. Во всем остальном идентична BSF. ВТ opl, ор2 — посылает бит первого операнда во флаг С. С <-Bit(opl. ор2) Номер бита указывается во втором операнде. Первый операнд может быть реги- стром или ячейкой памяти. Второй операнд может быть регистром, ячейкой па- мяти или явно заданным числом. Пример: ВТ еах. 23 посылает 23-й бит регистра еах (нумерация начинается с нуля) во флаг С. Меняет флаг С, OSZAP не определены. BTC opl, ор2 — то же, что ВТ, но указанный бит после копирования в С инверти- руется. BTS opl, ор2 — то же, что ВТ, но указанный бит после копирования в С стано- вится единицей. BTR opl, ор2 — то же, что ВТ, но указанный бит после копирования в С стано- вится нулем. CALL opl — вызов процедуры. Сохраняет в стеке адрес возврата и затем переходит к первой инструкции про- цедуры. Переходы могут быть короткими (в пределах одного сегмента) и длин- ными (в другой сегмент). Адресация может быть прямой (когда число, которое нужно прибавить к указателю команды eip, содержится в самой инструкции, и косвенной (в этом случае адрес перехода хранится в регистре или ячейке па- мяти). В наших программах для Windows, как правило, использовались ближние пере- ходы, потому что плоская модель памяти работает с одним сегментом. В про- граммах для DOS могут встречаться как близкие, так и дальние переходы. Чаще всего мы использовали прямую адресацию, например call AddDigs. Косвен- ный вызов, скажем, call edx, позволяет решить, «куда пойти» в процессе выпол- нения программы, и послать адрес в регистр прямо перед вызовом процедуры. Инструкция cal 1 не влияет на флаги. CBW — перенос содержимого регистра al (с расширением знака) в регистр ах. Не влияет на флаги.
216 Приложение. Флаги и основные инструкции процессора CDQ — перенос содержимого регистра еах (с расширением знака) в пару реги- стров edx:еах. Не влияет на флаги. CLC — опустить (приравнять 0) флаг С. На остальные флаги не влияет. CLD — опустить флаг D. На остальные флаги не влияет. CLI — запретить прерывания. Запрещает внешние прерывания процессора. Инструкция CLI запрещает только так называемые маскируемые прерывания. Но есть неотложные прерывания, ко- гда, например, «программа выполнила недопустимую операцию и будет закры- та», над которыми инструкция CLI не властна. Опускает флаг I. На остальные флаги не влияет. СМС — инвертировать флаг С. На остальные флаги не влияет. CMP opl, ор2 — сравнение двух операндов. Инструкция устанавливает флаги так, как будто из первого операнда вычли вто- рой командой SUB. При этом ни первый, ни второй операнды не меняются. Пер- вый операнд может быть регистром или ячейкой памяти, второй — регистром, ячейкой памяти (когда первый операнд — регистр) или явным числом. Явное число, используемое в инструкции СМР приводится к размеру первого операнда с расширением знака. Меняет флаги OSZAPC. CMPSB — сравнивает байт с адресом DS:(E)SI и байт с адресом ES:(E)DI. Сравнение выполняется так же, как и инструкцией СМР. После сравнения и уста- новки флагов, значения (E)SI и (E)DI увеличиваются на 1, если опущен флаг D и уменьшаются на 1, когда флаг D поднят. Меняет флаги OSZAPC. CMPSW — то же, что CMPSB, но сравниваются не байты, а слова. Поэтому регистры (Е)SI и (E)DI увеличиваются на 2, если опущен флаг D и умень- шаются на 2, когда флаг D поднят. Меняет флаги OSZAPC. CMPSD — то же, что CMPSB, но сравниваются не байты, а двойные слова. Поэтому регистры (E)SI и (E)DI увеличиваются на 4, если опущен флаг D, и умень- шаются на 4, когда флаг D поднят. Меняет флаги OSZAPC. CWD opl — перенос содержимого регистра ах (с расширением знака) в пару ре- гистров dx:ax. Не влияет на флаги. CWDE — перенос содержимого регистра ах (с расширением знака) в регистр еах. Не влияет на флаги. DEC opl — уменьшить на единицу. (opl) (opl) - 1
Инструкции процессора 217 Уменьшает значение операнда на единицу. Не влияет на флаг С. Меняет флаги OSZAP. DIV opl — деление беззнаковых операндов. В качестве единственного операнда этой инструкции указывается делитель, ко- торый может храниться как в регистре, соответствующем его размеру, так и в па- мяти. Типы операндов и результаты операций показаны в табл. П.1. Таблица П.1. Операнды и результаты при делении Делимое Делитель(opl) Частное Остаток Мах. делитель АХ Байт AL АН 255 DX:AX Слово АХ DX 65535 EDX: ЕАХ Двойное слово ЕАХ EDX 232-1 После операции флаги OSZAPC не определены. ENTER opl, ор2 — создание стандартного пролога процедуры. Если второй операнд инструкции ENTER равен нулю (а так чаще всего и бывает), то инструкция сохраняет в стеке регистр (е)Ьр, затем запоминает в (е)Ьр указа- тель стека и далее вычитает из (е)sp первый операнд, выделяя тем самым место для локальных переменных: push (e)bp mov (e)bp. (e)sp sub (e)sp. opl MASM заменяет инструкцию ENTER последовательностью push..., mov..., sub..., ко- торую современные процессоры выполняют быстрее. Встретить инструкцию ENTER можно только в старых программах. Не влияет на флаги. IDIV opl — деление операндов со знаком. В качестве единственного операнда этой инструкции указывается делитель, кото- рый может храниться как в регистре, соответствующем его размеру, так и в памя- ти. Типы операндов и результаты операций показаны в табл П.2. Таблица П.2. Операнды и результаты при делении Дели- мое Делитель (opl) Частное Остаток Мах. делитель АХ Байт AL АН 255 DX:AX Слово АХ DX 65 535 EDX: ЕАХ Двойное слово ЕАХ EDX 232-1 После операции флаги OSZAPC не определены. IMUL opl; IMUL opl, ор2; IMUL opl, ор2, орЗ — умножение со знаком. Ин- струкция бывает трех типов: с одним, двумя и тремя операндами.
218 Приложение. Флаги и основные инструкции процессора Инструкция с одним операндом (это может быть регистр или ячейка памяти) умножает его на al (если операнд — байт), на ах (если операнд — слово) или на еах (если операнд — двойное слово). При этом результат умножения окажется в ах, dx:ax и edx:еах соответственно. Инструкция с двумя операндами умножает opl на ор2 и сохраняет результат в opl. Первый операнд (opl) может быть регистром, второй — регистром, участ- ком памяти или явным числом. Инструкция с тремя операндами умножает ор2 на орЗ и сохраняет результат в opl. Первый операнд должен быть регистром, второй — регистром или ячей- кой памяти, третий — явным числом. Явное число, используемое в инструкциях всех трех типов, приводится к разме- ру результата с расширением знака. Флаги С и 0 устанавливаются, когда не все биты в старшей половине результата равны нулю. Если же заполненной оказывается только младшая половина ре- зультата, флаги С и 0 опускаются. Флаги SZAP после умножения не Определены. INC opl — увеличение на 1. Прибавляет 1 к opl, не меняя флаг С. Операнд может быть регистром или ячей- кой памяти. Меняет флаги OSZAP. INT opl; INTO; INT 3 — вызов программного прерывания. Инструкция INT opl вызывает прерывание, чей номер задается явным числом opl, которое может меняться от 0 до 255. Это число задает один из 256 адресов, по ко- торым направляется процессор,* когда прерывание возникнет. Эти адреса различ- ны в реальном и защищенном режим работы процессора. Инструкция INTO вызывает четвертое прерывание, которое возникает только если флаг 0 равен 1. Инструкция INT 3 вызывает особую форму третьего прерывания, которое используется отладчиком при выполнении программы по шагам. Инструкция INT сохраняет флаги в стеке и потому не влияет на них. IRET — возврат из прерывания. Восстанавливает те значения флагоц, которые были до прерывания. Jcc opl — условный переход. Буквы сс заменяются в реальных инструкциях одной или несколькими буква- ми, обозначающими тип перехода: JA переход, если выше (С=0 и Z=0); JAE переход, если выше или равно (С=0); JB переход, если ниже (О1); JBE переход, если ниже или равно (С=1 или Z=l); JC переход, если перенос (С=1); JCXZ переход, если регистр СХ=О;
Инструкции процессора 219 JECXZ переход, если ЕСХ=0; JE переход, если равно (Z=l); JG переход, если больше (Z=0 и S=0); JGE переход, если больше или равно (S=0); JL переход, если меньше (S не равен 0); JLE переход, если меньше или равно (Z=l и (S не равен 0)); JNA переход, если не выше (0=1 или Z=l); JNAE переход, если не выше или равно (0=1); JNB переход, если не ниже (0=0); JNBE переход, если не ниже или равно (0=0 и Z=0); JNC переход, если нет переноса (0=0); JNE переход, если не равно (Z=0); JNG переход, если не больше (Z=l или (S не равен 0)); JNGE переход, если не больше или равен (S не равен 0); JNL переход, если не меньше (S=0); JNLE переход, если не меньше или равен (Z=0 и S=0); JNO переход, если нет переполнения (0=0); JNP переход, если нет паритета (Р=0); JNS переход, если положителен (S=0); JNZ переход, если не нуль (Z=0); J0 переход, если переполнение (0=1); JP переход, если паритет (Р=1); JPE то же, что JP; JPO то же, что JNP; JS переход, если число отрицательное (S=l); JZ переход, если ноль (Z=l). Все эти инструкции совершают переход по адресу, указанному в операнде opl, ко- гда флаги или регистр (Е)СХ находятся в указанном состоянии. Если флаги уста- новлены иначе, процессор переходит к инструкции, следующей непосредственно за Jcc. Адрес перехода указывается в самой инструкции как смещение (положительное или отрицательное) относительно адреса текущей инструкции, хранимого в ре- гистре (Е) IP. Это смещение может занимать байт, обычное (два байта) или двой- ное (четыре байта) слово. Инструкции JCXZ и JECXZ совершают «короткий» переход в пределах —128 +127 байтов от текущего адреса инструкции. Остальные переходы называются «ближними», потому что они возможны только в пределах данного сегмента. Для 16-битовых сегментов переход совершается в пределах -32 768 до +32 767, для 32-битовых сегментов — от -2 147 483 648 до +2 147 483 647.
220 Приложение. Флаги и основные инструкции процессора Условные переходы, в чьих описаниях встречаются слова «выше», «ниже», исполь- зуются при сравнении чисел без знака, переходы, в чьих описаниях есть слова «больше», «меньше», используются для сравнения чисел со знаком. Инструкции условного перехода не влияют на флаги. JMP opl — безусловный переход. Перенаправляет процессор к другой инструкции, не запоминая адрес возврата. Сведения о новой инструкции хранит операнд opl, который может быть регист- ром, ячейкой памяти или явным числом. Безусловный переход может быть: «коротким» (short) — в пределах -128... 127 байт от адреса текущей инструкции; «близким» (near) — в пределах данного сегмента; «далеким» (far) — между сегментами; «переключающим задачи» — к инструкции другой программы. Последний переход возможен только в защищенном режиме процессора. И толь- ко в нем возможно изменение флагов. LAHF — загружает флаги SZAPC в регистр ah, где они занимают привычное место. Флаг С — в нулевом бите, флаг Z — в шестом и т. д. Биты, не занятые флагами, устанавливаются так: пятый = 0, третий = 0, первый = 1. LEA opl, ор2 — сохраняет эффективный адрес второго операнда в первом. Второй операнд представляет собой адрес в памяти, записанный с помощью од- ного из способов адресации процессора. Первый операнд может быть только ре- гистром. В отличие от директивы OFFSET, выполняемой при ассемблировании, LEA — настоящая инструкция процессора, позволяющая узнать адрес в процессе выполнения программы. Поэтому LEA узнает адреса локальных переменных, раз- мещаемых в стеке, a OFFSET — нет. Инструкция LEA может использоваться не только для определения эффективно- го адреса, но и для быстрых арифметических вычислений (см. раздел «Адреса- ция» главы 10 ). LEA не влияет на флаги. LEAVE — стандартный эпилог процедуры. Инструкция восстанавливает значение (E)SP и затем достает из стека (Е)ВР. Вы- полняет действия, противоположные ENTER. Эквивалентна инструкциям mov (e)sp.(e)bp pop (e)bp Инструкция LEAVE не влияет на флаги. LODSB — переписывает байт, чей адрес в памяти определяется парой регистров DS:(E)SI, в регистр al. О правильном сегментном регистре DS нужно заботиться только в программах для DOS. В плоской модели памяти, характерной для Windows, нужно лишь за- писать адрес байта в регистр esi. Не влияет на флаги.
Инструкции процессора 221 LODSW — переписывает слово, чей адрес в памяти определяется парой регист- ров DS:(E)SI, в регистр ах. Во всем остальном идентична LODSB. LODSD — переписывает двойное слово, чей адрес в памяти определяется парой регистров DS:(E)SI, в регистр еах. Во всем остальном идентична LODSB. LOOP opl- организует цикл. Уменьшает на единицу (Е)СХ, затем проверяет, равен ли (Е)СХ нулю. Если да — переводит к следующей после LOOP инструкции, если нет — идет к инструкции, чей адрес указан операндом opl (обычно это простая метка). В качестве «счетчи- ка» выбирается СХ, если адрес, указанный в операнде, — шестнадцатиразрядный. Если адрес 32-разрядный, счетчиком служит ЕСХ. LOOP способна лишь на «корот- кие» переходы в пределах -128... 127 байт от текущего адреса инструкции. Инструкция LOOP не влияет на флаги. LOOPE opl — похожа на предыдущую инструкцию LOOP, но теперь выход из цик- ла произойдет, либо когда (Е)СХ=О, либо когда флаг Z=0. Полезна, когда нужно прервать выполнение цикла. В этом случае нужно толь- ко приравнять нулю флаг Z, и цикл прервется, даже когда (Е)СХ не достигнет нуля. LOOPZ opl — то же, что и LOOPE. LOOPNE opl — похожа на инструкцию LOOP, но теперь выход из цикла произой- дет либо когда (Е)СХ=0, либо когда флаг Z-1. Полезна, когда нужно прервать выполнение цикла. В этом случае нужно толь- ко приравнять единице флаг Z и цикл прервется, даже когда (Е)СХ не достигнет нуля. LOOPNZ opl — то же, что LOOPNE. MOV opl, ор2 — копирование второго операнда в первый. Второй операнд (источник) может быть обычным регистром, сегментным реги- стром, ячейкой памяти или явным числом. Первый операнд (приемник) может быть обычным регистром, сегментным регистром или ячейкой памяти (в случае, если второй операнд — не ячейка памяти). Размер операндов должен быть оди- наков и равен одному, двум или четырем байтам. Инструкцией MOV нельзя изме- нить сегментный регистр CS. Не влияет на флаги. MOVSB — копирует байт с адресом DS: (E)SI в байт с адресом ES: (E)DI. После копирования значения (E)SI и (E)DI увеличиваются на 1, если опущен флаг D и уменьшаются на 1, когда флаг D поднят. Не влияет на флаги.
222 Приложение. Флаги и основные инструкции процессора MOVSW — то же, что MOVSB, но регистры (E)SI,(E)DI после копирования увели- чиваются (уменьшаются) на 2. MOVSD — то же, что MOVSB, но регистры (E)SI,(E)DI после копирования увели- чиваются (уменьшаются) на 2. MOVSX opl, ор2 — копирование с расширением знака. Если размер второго операнда равен байту, первый операнд может быть словом или двойным словом. Если же второй операнд — слово, первый может быть только двойным словом. Расширение знака означает, что если, скажем, 8 бит ко- пируются в 16, старшие 8 бит результата заполняются нулями если равен нулю старший бит источника (ор2) и единицами — если старший бит источника равен единице. Не влияет на флаги. MOVZX opl, ор2 — копирование без расширения знака. Инструкция похожа на MOVSX, но теперь старшие биты результата всегда за- полняются нулями. MUL opl — умножение чисел без знака. Операнд opl может быть регистром или ячейкой памяти. Если размер операнда равен байту, он умножается на AL, а результат оказывается в АХ. Операнд разме- ром в два байта умножается на АХ и результат оказывается в DX:AX. Если же раз- мер операнда равен 4 байт, то он умножается на ЕАХ, а результат оказывается в паре регистров EDX: ЕАХ Если старшая половина результата (для 16-битного произведения это биты 15...8) равна нулю, флаги С и 0 опускаются. В противном случае они поднимаются. Флаги SZAP после беззнакового умножения не определены. NEG opl — изменение знака операнда, который может быть регистром или ячей- кой памяти (байтом, обычным или двойным словом). Если все биты результата равны нулю, флаг С опускается, если результат отли- чен от нуля, флаг С поднимается. Инструкция NEG меняет флаги OSZAP. NOP — «холостая» инструкция, ничего не делает, только занимает память и вре- мя процессора. На флаги не влияет. NOT opl — побитовое инвертирование. Заменяет каждый единичный бит нулевым, каждый нулевой бит — единичным. Операндом может быть регистр или ячейка памяти длиной 1, 2 или 4 байт. На флаги не влияет. OR opl, ор2 — побитовое логическое ИЛИ. Для каждой пары битов обоих операндов определяется результат, равный нулю, когда оба бита — нулевые. В противном случае результат равен единице. После
Инструкции процессора 223 выполнения логического ИЛИ для каждой пары битов результат записывается в первый операнд. Второй операнд остается неизменным. Первый операнд мо- жет быть регистром или ячейкой памяти, второй — регистром, ячейкой памяти или явным числом. Как обычно, оба операнда не могут быть ячейками памяти. После операции опускаются флаги 0 и С. Инструкция также меняет флаги SZPF. Флаг А не определен. POP opl — извлечение из стека. Переписывает число, хранящееся на вершине стека, в операнд, который может быть обычным регистром, сегментным регистром (исключение — CS) или ячей- кой памяти. После извлечения из стека инструкция увеличивает (E)SP на число • извлеченных байтов. На флаги не влияет. РОРА — загрузка регистров. Снимает с вершины стека числа и копирует их в регистры. Если сегмент 16-бит- ный, последовательно загружаются регистры DI, SI, BP, SP, BX, DX, СХ, и АХ. Если сег- мент 32-битный, как в плоской модели памяти, загружаются регистры EDI, ESI, EBP, ESP, EBX, EDX, ECX, и EAX. Инструкция РОРА используется в паре c PUSHA. Смысл пары инструкций PUSHA...POPA в том, чтобы сохранить, а затем восстановить регистры об- щего назначения. На флаги не влияет. POPAD — то же, то и РОРА. Однако некоторые ассемблеры считают, что POPAD предназначена для восстанов- ления 32-битовых регистров, а РОРА — только для 16-битовых. PUSH opl — сохранение в стеке. Уменьшает указатель стека (E)SP на 2, если операнд opl двухбайтовый, и на 4, если операнд — двойное слово. Затем операнд занимает новую вершину стека, на которую указывает (E)SP. Операнд может быть обычным регистром, сегмент- ным регистром или явным числом. Инструкция push esp помещает в стек старое, еще не уменьшенное значение указателя ESP. Не влияет на флаги. PUSHA — сохранение в стеке обычных регистров. Если сегмент 16-битный, последовательно сохраняются регистры и АХ, СХ, DX, ВХ, BP, SP, SI, DI. Если сегмент 32-битный, как в плоской модели памяти, сохраняют- ся регистры ЕАХ, ЕСХ, EDX, EBX, EBP, ESP, ESI, EDI. Инструкция PUSHA сохраняет в сте- ке значение (E)SP, которое было непосредственно до выполнения PUSHA. Исполь- зуется в паре с РОРА. Смысл пары инструкций PUSHA...РОРА в том, чтобы сохранить, а затем восстановить регистры общего назначения. Когда регистры, сохраненные PUSHA, восстанавливаются инструкцией РОРА, старое значение (E)SP просто достается из стека и нигде не запоминается. На флаги не влияет.
224 Приложение. Флаги и основные инструкции процессора PUSHAD — то же, то и PUSHA. Однако некоторые ассемблеры считают, что PUSHAD предназначена для восстанов- ления 32-битовых регистров, a PUSHA — только для 16-битовых. RCL opl, ор2 — циклический сдвиг влево с переносом. Циклически «протаскивает» биты первого операнда влево через флаг С. Элемен- тарный сдвиг влево на один бит выглядит так: флаг С запоминается во времен- ном бите, бит 7 переходит в С, бит 6 — на место седьмого, бит 5 — на место шес- того, бит 4 — на место пятого, бит 3 — на место четвертого, бит 2 на место третьего, бит 1 — на место второго, бит 0 — на место первого, а сохраненный во временном бите флаг С — на место нулевого. Первый операнд может быть реги- стром или ячейкой памяти, второй — единицей, регистром CL, куда предвари- тельно записывается число сдвигов, или явным числом. Число сдвигов должно быть в пределах 0...31. При сдвиге на 0 позиций флаги не меняются. При сдвиге на 1 позицию (ор2 ра- вен 1) флаг 0 равен исключающему ИЛИ старшего бита результата и флага С. При остальных сдвигах флаг 0 не определен. Другие флаги не меняются. RCR opl, ор2 — циклический сдвиг вправо с переносом. Элементарный сдвиг вправо выглядит так: флаг С запоминается во временном бите, бит 0 переходит в С, бит 1 — на место нулевого, бит 2 — на место первого, бит 3 — на место второго, бит 4 — на место третьего, бит 5 — на место четверто- го, бит 6 — на место пятого, бит 7 — на место шестого, а сохраненный во времен- ном бите флаг С — на место седьмого. Операнды и у этой инструкции такие же, как и у ROL Число сдвигов так же в пределах 0...31. При сдвиге на 0 позиций флаги не меняются. При сдвиге на 1 позицию (ор2 ра- вен 1) флаг 0 равен исключающему ИЛИ двух старших битов результата. При остальных сдвигах флаг 0 не определен. Другие флаги не меняются. ROL opl, ор2 — циклический сдвиг влево без участия флага С. Элементарный сдвиг байта выглядит так: 7-й бит запоминается во флаге С, 6-й перемещается на место седьмого, пятый — на место шестого, ..., нулевой на ме- сто первого, сохраненный седьмой бит на место нулевого. Аргументы этой инст- рукции такие же, как у RCL. Число сдвигов не должно превышать 31. При сдвиге на 1 позицию (ор2 равен 1) флаг 0 равен исключающему ИЛИ стар- шего бита результата и флага С. При остальных сдвигах флаг 0 не определен. Другие флаги не меняются. ROR opl, ор2 — циклический сдвиг вправо без участия флага С. Элементарный сдвиг байта выглядит так: 0-й бит запоминается во флаге С, 1-й перемещается на место нулевого, 2-й — на место первого, ..., седьмой на место шестого, сохраненный нулевой бит на место седьмого. Аргументы этой инструк- ции такие же, как у RCL. Число сдвигов не должно превышать 31. При сдвиге на 1 позицию (ор2 равен 1) флаг 0 равен исключающему ИЛИ двух старших битов результата. При остальных сдвигах флаг 0 не определен. Другие флаги не меняются.
Инструкции процессора 225 REP — повторение строковых операций. REP — это префикс, который ставится перед инструкциями MOVS, LODS, STOS, CMPS, SCAS. Строковая инструкция, перед которой стоит префикс REP выполняется (Е)СХ раз. После выполнения каждой элементарной инструкции из (Е)СХ вычита- ется единица и проверяется — не равен ли ЕСХ нулю. Если равен — повторении инструкций прекращается. На флаги не влияет. REPE — повторение строковых операций CMPS и SCAS. Выполнение строковой инструкции прекращается, когда (Е)СХ=О или когда флаг Z=0. В остальном идентична инструкции REP. REPZ — синоним REPE. REPNE — повторение строковых операций CMPS и SCAS. Выполнение строковой инструкции прекращается, когда (Е)СХ=О или когда флаг Z=l. В остальном идентична инструкции REP. REPNZ — синоним REPNE. RET — возврат из подпрограммы. Совершает действия, противоположные инструкции CALL: достает с вершины стека адрес возврата и направляет к нему процессор. Возвраты, как и вызовы процедур могут быть ближними — в пределах данного сегмента и дальними — с переходом в другой сегмент. Возвраты в плоской модели памяти, характерной для Windows — ближние. Не влияет на флаги. RET opl — возврат с освобождением стека. Применяется, когда нужно освободить стек от локальных переменных, исполь- зованных процедурой. Операнд opl равен числу освобождаемых байтов и дол- жен быть явным числом. Не влияет на флаги. SAHF — задание флагов. Переносит флаги, заданные регистром АН, в регистр флагов. Задает флаги SZAPC в битах 7, 6, 4, 2, 0 регистра АН. Биты 5, 3, 1 инструкцией SAHF игнорируются. SAL opl, ор2 — арифметический сдвиг влево. Сдвигает биты влево, оставляя нули вместо освободившихся битов. Старший бит операнда opl, вытолкнутый при сдвиге, переносится во флаг С. Первый операнд может быть регистром или ячейкой памяти, второй — единицей, регистром CL, куда предварительно записывается число сдвигов, или явным числом. Число сдвигов не должно превышать 31. При сдвиге на 1 позицию (ор2 равен 1) флаг 0 равен исключающему ИЛИ стар- шего бита результата и флага С. При остальных сдвигах флаг 0 не определен.
226 Приложение. Флаги и основные инструкции процессора Кроме того, меняет флаги SZPC (при любом числе сдвигов). Флаг А не определен. При сдвиге на число битов, равное размеру первого операнда, флаг С не определен. SHL opl, ор2 — то же, что и SAL. SAR opl, ор2 — сдвиг вправо с расширением знака. При каждом элементарном (единичном) сдвиге младший бит операнда вытал- кивается во флаг С. Освободившиеся старшие биты заполняются нулями, если старший бит исходного операнда был нулем, и единицами в противном случае. То есть эта инструкция предназначена для сдвига вправо чисел со знаком. Пер- вый операнд может быть регистром или ячейкой памяти, второй — единицей, регистром CL, куда предварительно записывается число сдвигов, или явным числом. При сдвиге на 1 позицию (ор2 равен 1) флаг 0 всегда опускается. Если число сдвигов больше 1, флаг 0 не определен. Меняет флаги SZPC. SHR ор!,ор2 — сдвиг вправо без расширения знака. При каждом элементарном (единичном) сдвиге младший бит операнда выталкивает- ся во флаг С. В отличие от инструкции SAR, освободившиеся старшие биты заполня- ются нулями. Значит, эта инструкция предназначена для сдвига вправо чисел без зна- ка. Первый операнд может быть регистром или ячейкой памяти, второй — единицей, регистром CL, куда предварительно записывается число сдвигов, или явным числом. При сдвиге на 1 позицию (ор2 равен 1) флаг 0 равен старшему биту исходного операнда. При сдвиге на число битов, равное размеру первого операнда, флаг С не определен. Влияет на флаги SZPC. SBB opl, ор2 — вычитание с заемом. Прибавляет ко второму операнду флаг С, вы- читает полученный результат из первого операнда и посылает результат в первый операнд: (opl) <- (opl) - (ор2) + С Первый операнд может быть регистром или ячейкой памяти. Второй операнд мо- жет быть регистром, ячейкой памяти или явно заданным числом. Знак этого чис- ла расширяется, чтобы второй операнд стал того же размера, что и первый. Нель- зя, чтобы оба операнда были ячейками памяти. Меняет флаги OSZAPC. SCASB — сравнение байтов. Вычитает из AL байт, чей адрес определяют регистры ES:(E)DI. При этом AL не меняется, а только устанавливаются флаги. Если флаг D опущен, регистр (E)DI после сравнения увеличивается на 1, если флаг D поднят — уменьшается на 1. Меняет флаги OSZAPC. SCASW — сравнение слов. Вычитает из АХ слово, чей адрес определяют регистры ES:(E)DI. При этом АХ не меняется, а только устанавливаются флаги. Если флаг D опущен, регистр (E)DI после сравнения увеличивается на 2, если флаг D поднят — уменьшается на 2. Меняет флаги OSZAPC.
Инструкции процессора 227 SCASD — сравнение байтов. Вычитает из ЕАХ двойное слово, чей адрес определяют регистры ES:(E)DI. При этом ЕАХ не меняется, а только устанавливаются флаги. Если флаг D опущен, ре- гистр (E)DI после сравнения увеличивается на 4, если флаг D поднят — уменьша- ется на 4. Меняет флаги OSZAPC. SETcc opl — установка байта по условию. При выполнении одного из 16 условий в байт, заданный операндом opl, посыла- ется единица (байт устанавливается). Если условие не выполняется, в байт по- сылается ноль. Условия определяются видом команды SETcc: SETA установить, если выше (С=0 и Z=0); SETAE установить, если выше или равно (С=0); SETB установить, если ниже (С=1); SETBE установить, если ниже или равно (С=1 или Z=l); SETC установить, если перенос (С=1); SETE установить, если равно (Z=l); SETG установить, если больше (Z=0 или (S равен 0)); SETGE установить, если больше или равно (S равен 0); SETL установить, если меньше (S не равен 0); SETLE установить, если меньше или равно (Z=l и (S не равен 0)); SETNA установить, если не выше (С=1); SETNAE установить, если не выше или равно (С=1); SETNB установить, если не ниже (С=0); SETNBE установить, если не ниже или равно (0=0 и Z=0); SETNC установить, если нет переноса (0=0); SETNE установить, если не равно (Z=0); SETNG установить, если не больше (Z=l или (S не равен 0)); SETNGE установить, если не больше или равен (S не равен 0); SETNL установить, если не меньше (S равен 0); SETNLE установить, если не меньше или равен (Z=0 и (S не равен 0)); SETNO установить, если нет переполнения (0=0); SETNP установить, если нет паритета (Р=0); SETNS установить, если положителен (S=0); SETNZ установить, если не нуль (Z=0); SETO установить, если переполнение (0=1); SETP установить, если паритет (Р=1); SETPE то же, что SETP; SETPO то же, что SETNP SETS установить, если число отрицательное (S=l) SETZ установить, если ноль (Z=l)
228 Приложение. Флаги и основные инструкции процессора Названия команд SETcc и условия, по которым устанавливается (сбрасывается) байт opl, соответствуют командам Jcc. Например, JNZ и SETNZ используют одно и то же условие Z=0. Разница в том, что инструкции SETcc лишь запоминают ус- ловия, но никуда не переходят. Инструкции SETcc не влияют на флаги. SHLD opl, ор2, орЗ — двойной сдвиг влево. Инструкция применяется для сдвига длинных последовательностей битов. SHLD сдвигает синхронно влево два операнда: opl и ор2. Причем операнд opl от этого меняется, а ор2 служит только источником битов для opl, но сам не меняется. Например, после инструкций mov ах.1000000000000000b mov bx.1000000000000000b shld ax. bx. 1 AX будет равен 0000000000000001 (старший бит АХ будет вытолкнут из регистра, а старший бит ВХ будет втянут в АХ справа), в то время как ВХ останется преж- ним: 1000000000000000. Бит, только что покинувший opl, попадает во флаг С. Число сдвигов определяет операнд орЗ, который может быть регистром CL или явным числом. Процессор использует только 5 младших битов в операнде орЗ, поэтому число сдвигов не превышает 31. Opl может быть как регистром (16- или 32-битным), так и ячей- кой памяти, а ор2 — только регистром. Если число сдвигов больше размера опе- ранда opl, результат инструкции не определен. Меняет флаги SZP. Флаги 0 и А не определены. SHRD opl, ор2, орЗ — двойной сдвиг вправо. Инструкция применяется для сдвига длинных последовательностей битов. SHLD сдви- гает синхронно вправо два операнда: opl и ор2. Причем, операнд opl от этого меня- ется, а ор2 служит только источником битов для opl, но сам не меняется. Напри- мер, после инструкций: mov ах.0000000000000001b mov bx.0000000000000001b shrd ax. bx, 1 AX будет равен 100000000000000 (младший бит АХ будет вытолкнут из регистра, а младший бит ВХ будет втянут в АХ слева), в то время как ВХ останется прежним: 0000000000000001. Бит, только что покинувший opl, попадает во флаг С. Число сдвигов определяет операнд орЗ, который может быть регистром CL или явным числом. Процессор использует только 5 младших битов в операнде орЗ, поэтому число сдвигов не превышает 31. Opl может быть как регистром (16- или 32-битным), так и ячей- кой памяти, а ор2 — только регистром. Влияет на флаги SZP. Флаг 0 и А не определены. STC — поднимает флаг С. На остальные флаги не влияет. STD — поднимает флаг D. На остальные флаги не влияет.
Инструкции процессора 229 STI — разрешить прерывания. Разрешает внешние прерывания процессора. По действию противоположна ин- струкции CLI. STOSB — сохранить байт. Переписывает содержимое AL в ячейку памяти с адресом ES: (E)DI, после чего при- бавляет единицу к (E)DI, если флаг D опущен, и вычитает 1, если D поднят. На флаги не влияет. STOSW — сохранить слово. Переписывает содержимое АХ в ячейку памяти с адресом ES: (E)DI, после чего при- бавляет 2 к (E)DI, если флаг D опущен, и вычитает 2, если D поднят. На флаги не влияет. STOSD — сохранить двойное слово. Переписывает содержимое ЕАХ в ячейку памяти с адресом ES:(E)DI, после чего прибавляет 4 к (E)DI, если флаг D опущен, и вычитает 4, если D поднят. На флаги не влияет. SUB opl, ор2 — вычитание. Вычитает из первого операнда второй и посылает результат в первый операнд: (opl) <- (opl) - (ор2) Первый операнд может быть регистром или ячейкой памяти. Второй операнд мо- жет быть регистром, ячейкой памяти или явно заданным числом. Знак этого чис- ла расширяется, чтобы второй операнд стал того же размера, что и первый. Нель- зя, чтобы оба операнда были ячейками памяти. Меняет флаги OSZAPC. TEST opl, ор2 — логическое сравнение. Похожа на инструкцию AND, но в отличие от нее не меняет операнд opl, а просто устанавливает флаги так, как будто результат побитового И сохранен в opl. Пер- вый операнд может быть регистром или ячейкой памяти. Второй операнд может быть регистром, ячейкой памяти или явно заданным числом. Нельзя, чтобы оба операнда были ячейками памяти. Меняет флаги OSZPC, 0 и С опускаются. XADD opl, ор2 — сложение с обменом. После выполнения инструкции в первом операнде оказывается сумма оригиналь- ных значений ор1+ор2, а во втором — opl: TEMP <- ор2 + opl ор2 <- opl opl <- TEMP Первый операнд может быть регистром или ячейкой памяти, второй — только регистром. Флаги OSZAPC устанавливаются так, как будто выполнена инструкция ADD opl. ор2.
230 Приложение. Флаги и основные инструкции процессора XCHG opl, ор2 — обмен операндов. TEMP <- opl opl <- ор2 ор2 <- TEMP Возможен обмен между регистром и ячейкой памяти или обмен между регист- рами. На флаги не влияет. XLATB — загрузка из таблицы. Посылает в AL элемент массива байтов, у которого адрес нулевого элемента ра- вен DS: (Е)ВХ, а индексом служит старое значение AL. Процессор считает, что в AL первоначально хранится беззнаковое целое. Инструкция XLAT применяется при перекодировках, когда один байт заменяется другим. Не влияет на флаги. XOR opl, ор2 — побитовое исключающее ИЛИ. Выполняет побитовое исключающее ИЛИ первого и второго операнда, сохраняя результат в первом операнде. Бит результата равен единице, если два соответст- вующих бита первого и второго операнда различны. Иначе бит равен нулю. Пер- вый операнд может быть регистром или ячейкой памяти. Второй операнд может быть регистром, ячейкой памяти или явно заданным числом. Нельзя, чтобы оба операнда были ячейками памяти. После выполнения инструкции флаги О,С опускаются, флаги SZP устанавлива- ются в зависимости от результата, флаг А не определен.
Алфавитный указатель А API, 13 процедура, 13 параметры, 13 С Система счисления шестнадцатеричная, 20 Структура, 177 D DLL см. Динамические библиотеки, 126 DOS, 132 прерывания, 156 функции (прерывание 2lh), 156 F FAR, 15 Н Hiew, 168 К KISS-принцип, 103 N Norton Commander, 132 О OllyDbg, 18 А Абсолютный адрес, 144 Ассемблер MASM, 15 директивы, 13 Б Байт, 30 Бит, 22 Битовая маска, 73 Г Глобальные переменнные, 56 д Дамп памяти, 100 Дескриптор клавиатуры, 89 устройства, 46 файла, 93 экрана, 46 Дизассемблер, 167 DisDoc, 169 Hiew, 168 Динамическая библиотека точка входа, 128 Динамические библиотеки, 126 Директива .386, 14 .code, 15 .const, 137 .data, 33 .data?, 63 .ELSE, 170 .ENDIF, 170 .ENDW, 173 .IF, 170 .includelib, 14 .model, 14 .radix, 32 .REPEAT, 173 .UNTIL, 173 .WHILE, 173
232 Алфавитный указатель Директива (продолжение) assume, 138 BYTE, 34 DWORD, 34 end, 15 equ, 49 EXPORTS, 127 EXTERN, 125 includelib, 16 invoke, 15 LIBRARY, 127 proto, 14 PUBLIC, 125 QWORD, 121 segment, 137 STRUCT, 177 TBYTE, 112 typedef, 179 WORD, 34 Инструкция (продолжение) iret, 156 ja, 89 jae, 90 jb, 90 jbe, 90 jc, 76, 91 jg. 90 jl. 90 jmp, 60, 142 jnc, 91 jnz, 60 jo, 91 js, 91 jz, 90 lea, 55, 155 leave, 49 lods, 147 loop, 61 mov, 12 3 Заем разряда, 84 movsb, 146 movsd, 146 movsw, 146 И Инструкция adc, 83 and, 72 call, 41 cld, 101 cli, 167 cmp, 60 cmps, 147 dec, 34 div, 62 fadd, 122 faddp, 122 fcompp, 122 fdiv, 118 ffree, 114 fild, 111 finit, 114 fmul, 115 fid, 115 fsqrt, 111 fstp, 111 fsub, 115 ftst, 118 ftstw, 118 idiv, 88 imul, 88 mul, 85 neg, 102 not, 104 or, 74 pop, 35 push, 35 ret, 41 rol, 79 ror, 79 sar, 77 sbb, 83 scasb, 101 scasw, 101 scasd, 101 shl, 76 shr, 75 std, 101 sti, 167 stos, 147 sub, 34 test, 74 xchg, 40 xor, 74 К Кодировка Windows, 46
Алфавитный указатель 233 Кодировка (продолжение) альтернативная, 46 Командный файл, 16 Компоновщик, 18 Консольное приложение, 18 Косвенная адресация, 39 Косвенные переходы, 144 Л Логический адрес, 136 Локальные переменные, 50 М Макрос, 174 директива ifnf, 176 с параметром, 175 Массив, 64 Машинное слово, 30 Метка, 15 О Обратная польская запись, 115 Объектный файл, 17, 124 Операнд, 35 Оператор ., 178 ADDR, 46 dup, 58 offset, 88 SBYTE PTR, 173 SDWORD PTR, 173 SIZEOF, 46, 60 SWORD PTR, 173 Операционная система, 13 Отладчик, 18 AfdPro, 139 OllyDbg, 18 П Перенос в старший разряд, 25 Переполнение, 28, 87 Подключаемые библиотеки, 14 Прерывание аппаратное, 157 программное, 157 Префикс герпе, 101 гере, 148 префикс сегмента, 140 Простое число, 57 Профилирование программы, 194 Процедура, 40 BeginPaint, 189 CreateWindowEx, 183 DispatchMessage, 182 DrawText, 189 EndPaint, 189 FpuFLtoA, 111 GetClientRect, 189 GetCommandLine, 99 GetMessage, 182 GetModuleHandle, 186 Load Cursor, 186 Loadicon, 186 ReadConsole, 88 RegisterClass, 183 SetFilePointer, 106* Show Window, 186 Update Window, 188 wsprintf, 57 заголовок, 41 параметры, 42 Процедура API CloseHandle, 94 CreateFile, 93 ExitProcess, 13 GetProcAddress, 130 GetStdHandle, 45 LoadLibrary, 130 ReadFile, 96 WriteConsoleA, 45 WriteFile, 94 Процессор, 12 80286, 135 80386, 136 8086, 135 защищенный режим, 136 инструкции, 12 реальный режим, 136 регистры, 12 такт, 136 Физический адрес, 136 шина адреса, 136 шина данных, 136 Р Расширение знака, 31 Регистр флагов, 24 Резидентная программа, 132 Рекурсия, 163
234 Алфавитный указатель Сегментный регистр, 47, 135 Симпсона формула, 119 Система счисления двоичная, 22 десятичная, 21 основание, 21 Сообщение WM_DESTROY, 182 WM_PAINT, 188 Сообщения Windows, 181 Сопроцессор, ИЗ слово состояния, 118 Стек, 35 вершина, 37 Т Тетрада, 23 Ф Флаг D, 101 О, 28 S, 29 Z, 24 переноса, 25 ц Цикл loop, 62 Ч Число с плавающей точкой, 110 мантисса, 110 нормализованное, 110 экспонента, 110 с фиксированной точкой, 110
Александр Борисович Крупник Ассемблер Самоучитель Главный редактор Заведующий редакцией Руководитель проекта Литературный редактор Художник обложки Корректоры Верстка Е. Строганова А. Кривцов Ю. Суркис К Кноп В. Медведев Н. Лукина, А. Моносов М. Жданова Лицензия ИД № 05784 от 07.09.01. Подписано в печать 01.04.05. Формат 70X100/16. Усл. п. л. 19,35. Тираж 4500 экз. Заказ № 1081. ООО «Питер Принт». 194044, Санкт-Петербург, пр. Б. Сампсониевский, 29а. Налоговая льгота - общероссийский классификатор продукции ОК 005-93, том 2; 953005 - литература учебная. Отпечатано с готовых диапозитивов в ФГУП «Печатный двор» им. А. М. Горького Министерства РФ по делам печати, телерадиовещания и средств массовых коммуникаций. 197110, Санкт-Петербург, Чкаловский пр., 15.
В1997 году по инициативе генерального директора Издательского дома «Питер» Валерия Степанова и при поддержке деловых кругов города в Санкт-Петербурге был основан «Книжный клуб Профессионал». Он собрал под флагом клуба про- фессионалов своего дела, которых объединяет постоянная тяга к знаниям и любовь к книгам. Членами клуба являются лучшие студенты и известные практики из раз- ных сфер деятельности, которые хотят стать или уже стали профессионалами в той или инои области. Как и все развивающиеся проекты, с течением времени книжный клуб вырос в «Клуб Профессионал». Идею клуба сегодня формируют три основные «клубные» функции: • неформальное общение и совместный досуг интересных людей; • участие в подготовке специалистов высокого класса (семинары, пакеты книг по специальной литературе); • формирование и высказывание мнений современного профессионала (при встречах и на страницах журнала). КАК ВСТУПИТЬ В КЛУБ? Для вступления в «Клуб Профессионал» вам необходимо: • ознакомиться с правилами вступления в «Клуб Профессионал» на страницах журнала или на сайте www.piter.com; • выразить свое желание вступить в «Клуб Профессионал» по электронной почте postbook@piter.com или по тел. (812) 103-73-74; • заказать книги на сумму не менее 500 рублей в течение любого времени или приобрести комплект «Библиотека профессионала». «БИБЛИОТЕКА ПРОФЕССИОНАЛА» Мы предлагаем вам получить все необходимые знания, подписавшись на «Библио- теку профессионала». Она для тех, кто экономит не только время, но и деньги. Покупая комплект - книжную полку «Библиотека профессионала», вы получаете: • скидку 15% от розничной цены издания, без учета почтовых расходов; • при покупке двух или более комплектов - дополнительную скидку 3%; • членство в «Клубе Профессионал»; • подарок - журнал «Клуо Профессионал». ПЗДАТЕЛЬСКПЙ ДОМ Ь^ППТЕР WWW.PITER.COM Закажите бесплатный журнал «Клуб Профессионал».
ПИТЕР Нет времени ходить по магазинам? наберите: www.piter.com Здесь вы найдете: Все книги издательства сразу Новые книги — в момент выхода из типографии Информацию о книге — отзывы, рецензии, отрывки Старые книги — в библиотеке и на CD И наконец, вы нигде не купите наши книги дешевле!
КНИГА-ПОЧТОЙ ЗАКАЗАТЬ КНИГИ ИЗДАТЕЛЬСКОГО ДОМА «ПИТЕР» МОЖНО ЛЮБЫМ УДОБНЫМ ДЛЯ ВАС СПОСОБОМ: • по телефону: (812) 103-73-74; • по электронному адресу: postbook@piter.com; • на нашем сервере: www.piter.com; • по почте: 197198, Санкт-Петербург, а/я 619, ЗАО «Питер Пост». ВЫ МОЖЕТЕ ВЫБРАТЬ ОДИН ИЗ ДВУХ СПОСОБОВ ДОСТАВКИ И ОПЛАТЫ ИЗДАНИЙ: Наложенным платежом с оплатой заказа при получении посылки на ближайшем почтовом отделении. Цены на издания приведены ориентиро- вочно и включают в себя стоимость пересылки по почте (но без учета авиатарифа). Книги будут высланы нашей службой «Книга-почтой» в течение двух недель после получения заказа или выхода книги из печати. Оплата наличными при курьерской доставке (для жителей Москвы и Санкт-Петербурга). Курьер доставит заказ по указанному адресу в удобное для вас время в течение трех дней. ПРИ ОФОРМЛЕНИИ ЗАКАЗА УКАЖИТЕ: • фамилию, имя, отчество, телефон, факс, e-mail; • почтовый индекс, регион, район, населенный пункт, улицу, дом, корпус, квартиру; • название книги, автора, код, количество заказываемых экземпляров. Вы можете заказать бесплатный журнал «Клуб Профессионал» издательский дом Ь^ППТЕР WWW.PITER.COM
ИЗДАТЕЛЬСКМП ДОМ СПЕЦИАЛИСТАМ КНИЖНОГО БИЗНЕСА! ПРЕДСТАВИТЕЛЬСТВА ИЗДАТЕЛЬСКОГО ДОМА «ПИТЕР» предлагают эксклюзивный ассортимент компьютерной, медицинской, психологической, экономической и популярной литературы РОССИЯ Москва м. «Калужская», ул. Бутлерова, д. 176, офис 207,240; тел./факс (095) 777-54-67; e-mail: sales@piter.msk.ru Санкт-Петербург м. «Выборгская», Б. Сампсониевский пр., д. 29а; тел. (812) 103-73-73, факс (812) 103-73-83; e-mail: sales@piter.com Воронеж ул. 25 января, д. 4; тел. (0732) 39-61 -70; e-mail: piter-vm@vmail.ru; piterv@comch.ru Екатеринбург ул. 8 Марта, д. 2676; телефакс (343) 225-39-94,225-40-20; e-mail: piter-ural@r66.ru Нижний Новгород ул. Совхозная, д. 13; тел. (8312) 41 -27-31; e-mail: piter@infonet.nnov.ru Новосибирск ул. Немировича-Данченко, д. 104, офис 502; тел./факс (3832) 54-13-09,47-92-93,11-27-18,11-93-18; e-mail: piter-sib@risp.ru Ростов-на-Дону ул. Ульяновская, д. 26; тел. (8632) 69-91 -22; e-mail: jupiter@rost.ru Самара ул. Новосадовая, д. 4; тел. (8462)37-06-07; e-mail: piter-volga@sama.ru УКРАИНА Харьков ул. Суздальские ряды, д. 12, офис 10-11; тел. (057) 751-10-02, (0572) 58-41-45, тел./факс (057) 712-27-05; e-mail: piter@kharkov.piter.com Киев пр. Красных Казаков, д. 6, корп. 1; тел./факс (044) 490-35-68,490-35-69; e-mail: office@piter-press.kiev.ua БЕЛАРУСЬ Минск ул. Бобруйская, д. 21, офис 3; тел./факс (37517) 226-19-53; e-mail: office@minsk.piter.com /w Ищем зарубежных партнеров или посредников, имеющих выход на зарубежный рынок. Телефон для связи: (812) 103-73-73. E-mail: grigorjan@piter.com Издательский дом «Питер» приглашает к сотрудничеству авторов. Обращайтесь по телефонам: Санкт-Петербург - (812) 327-13-11, Москва - (095) 777-54-67. Заказ книг для вузов и библиотек: (812) 103-73-73. Специальное предложение - e-mail: kozin@piter.com
ПЗНАТЕЛЬСКПП ДОМ УВАЖАЕМЫЕ ГОСПОДА! КНИГИ ИЗДАТЕЛЬСКОГО ДОМА «ПИТЕР» ВЫ МОЖЕТЕ ПРИОБРЕСТИ ОПТОМ И В РОЗНИЦУ У НАШИХ РЕГИОНАЛЬНЫХ ПАРТНЕРОВ. Башкортостан Уфа, «Азия», ул. Зенцова, д. 70 (оптовая продажа), маг. «Оазис», ул. Чернышевского, д. 88, тел./факс (3472) 50-39-00. E-mail: asiaufa@ufanet.ru Дальний Восток Владивосток, «Приморский торговый дом книги», тел./факс (4232) 23-82-12. E-mail: bookbase@mail.primorye.ru Хабаровск, «Мирс», тел. (4212) 30-54-47, факс 22-73-30. E-mail: sale_book@bookmirs.khv.ru Хабаровск, «Книжный мир», тел. (4212) 32-85-51, факс 32-82-50. E-mail: postmaster@worldbooks.kht.ru Европейские регионы России Архангельск, «Дом книги», тел. (8182) 65-41-34, факс 65-41-34. E-mail: book@atnet.ru Калининград, «Вестер», тел./факс (0112) 21-56-28,21-62-07. E-mail: nshibkova@vester.ru http://www.vester.ru Северный Кавказ Ессентуки, «Россы», ул. Октябрьская, 424, тел./факс (87934) 6-93-09. E-mail: rossy@kmw.ru Сибирь Иркутск, «ПродаЛитЬ», тел. (3952) 59-13-70, факс 51-30-70. E-mail: prodalit@irk.ru http://www. prodalit. irk. ru Иркутск, «Антей-книга», тел./факс (3952) 33-42-47. E-mail: antey@irk.ru Красноярск, «Книжный мир», тел./факс (3912) 27-39-71. E-mail: book-world@public.krasnet.ru Нижневартовск, «Дом книги», тел. (3466) 23-27-14, факс 23-59-50. E-mail: book@nvartovsk.wsnet.ru Новосибирск, «Топ-книга», тел. (3832) 36-10-26, факс 36-10-27. E-mail: office@top-kniga.ru http://www.top-kniga.ru Тюмень, «Друг», тел./факс (3452) 21-34-82. E-mail: drug@tyumen.ru Тюмень, «Фолиант», тел. (3452) 27-36-06, факс 27-36-11. E-mail: foliant@tyumen.ru Челябинск, ТД «Эврика», ул. Барбюса, д. 61, тел./факс (3512) 52-49-23. E-mail:evrika@chel.surnet.ru Татарстан Казань, «Таис», тел. (8432) 72-34-55, факс 72-27-82. E-mail: tais@bancorp.ru Урал Екатеринбург, магазин № 14, ул. Челюскинцев, д. 23, тел./факс (3432) 53-24-90. E-mail: gvardia@mail.ur.ru Екатеринбург, «Валео-книга», ул. Ключевская, д. 5, тел./факс (3432) 42-56-00. E-mail: valeo@etel.ru
АНТИВИРУС ИГОРЯ ДАНИЛОВА www.drweb.ru Illi IIIIIIII ИНН Александр Крупник (самоучитель) Ассемблер Знание ассемблера позволяет лучше понять языки высокого уровня, такие как Си, C++ и Паскаль, а также устройство операционной системы. Недаром его так «уважают» хакеры. Многие считают ассемблер очень трудным языком и начинают программировать на нем только потому, что это «круто». Прочитав эту книгу, вы поймете, что ассемблер — простой, однозначный, но очень интересный и мощный язык. С^ППТЕР Заказ книг: 197198, Санкт-Петербург, а/я 619 тел.: (812) 103-73-74, postbook@piter.com 61093, Харьков-93, а/я 9130 тел.: (057)712-27-05, piter@kharkov.piter.com ISBN 5-469-00825-8 9 785469 008255 www.piter.com — вся информация о книгах и веб-магазин