Asm выделение памяти dos
Я успешно создал и показал на конференции игру DOS Defender. Программа работает в реальном режиме 32-битного 80386. Все ресурсы встроены в исполняемый COM-файл, никаких внешних зависимостей, так что игра целиком упакована в бинарник 10 килобайт.
Для игры понадобится джойстик или геймпад. Я включил поддержку мыши в релиз для Ludum Dare ради презентации, но потом удалил её, потому что она не очень хорошо работала.
Наиболее технически интересная часть заключается в том, что для создания игры не понадобились никакие инструменты разработки DOS! Я использовал только обычный компилятор Linux C (gcc). В реальности даже нельзя собрать DOS Defender под DOS. Я рассматриваю DOS только как встроенную платформу, что и есть единственная форма, в которой DOS всё ещё существует сегодня. Вместе с DOSBox и DOSEMU это довольно удобный набор инструментов.
Если вас интересует только практическая часть разработки, перейдите к разделу «Обманываем GCC», где мы напишем DOS COM программу “Hello, World” с GCC Linux.
Когда я начал этот проект, то не думал о GCC. В реальности я пошёл по этому пути, когда обнаружил пакет bcc (Bruce’s C Compiler) для Debian, который собирает 16-битные бинарники для 8086. Его держат для компиляции загрузчиков x86 и прочего, но bcc также можно использовать для компиляции DOS COM файлов. Это меня заинтересовало.
Для справки: 16-битный микропроцессор Intel 8086 вышел в 1978 году. У него не было никаких причудливых функций современных процессоров: ни защиты памяти, ни инструкций с плавающей запятой и только 1 МБ адресуемой RAM. Все современные десктопы и ноутбуки x86 всё ещё могут притвориться этим 16-битным процессором 8086 сорокалетней давности, с такой же ограниченной адресацией и всё такое. Это нехилая обратная совместимость. Такая функция называется реальным режимом. Это режим, в котором загружаются все компьютеры x86. Современные ОС сразу переключаются в защищённый режим с виртуальной адресацией и безопасной многозадачностью. DOS так не поступал.
К сожалению, bcc — не компилятор ANSI C. Он поддерживает подмножество K&R C, а также встроенный ассемблерный код x86. В отличие от других компиляторов 8086 C, у него нет понятия «дальних» или «длинных» указателей, поэтому для доступа к другим сегментам памяти (VGA, тактовые импульсы и т. д.) необходим встроенный ассемблерный код. Примечание: остатки этих «длинных указателей» 8086 до сих сохранились в Win32 API: LPSTR , LPWORD , LPDWORD и др. Тот встроенный ассемблер даже близко не сравнится со встроенным ассемблером GCC. На ассемблере нужно вручную загружать переменные из стека, а поскольку bcc поддерживает два разных соглашения о вызовах, то переменные в коде следует жёстко закодировать в соответствии с одним или другим соглашением.
Учитывая такие ограничения, я решил искать альтернативы.
DJGPP — порт GCC под DOS. Реально очень впечатляющий проект, который переносит под DOS почти весь POSIX. Многие портированные под DOS программы сделаны на DJGPP. Но он создаёт только 32-битные программы для защищённого режима. Если в защищённом режиме нужно работать с аппаратным обеспечением (например, VGA), то программа делает запросы к сервису интерфейса защищённого режима DOS (DPMI). Если бы я взял DJGPP, то не смог бы ограничиться единственным автономным бинарником, потому что пришлось бы поиметь и сервер DPMI. Производительность тоже страдает от запросов к DPMI.
Получить необходимые инструментальные средства для DJGPP сложно, мягко говоря. К счастью, я нашел полезный проект build-djgpp, который всё запускает, по крайней мере, на Linux.
Либо там серьёзная ошибка, либо официальные бинарники DJGPP опять заразились вирусом, но при при запуске моих программ в DOSBox постоянно возникала ошибка “Not COFF: check for viruses”. Для дополнительной проверки, что вирусы не на моей собственной машине, я настроил среду для DJGPP на своём Raspberry Pi, который действует как чистая комната. Это устройство на базе ARM невозможно заразить вирусом x86. И всё равно возникала та же проблема, и все двоичные хэши совпадали между машинами, так что это не моя вина.
Так что учитывая это и проблему DPMI, я начал искать дальше.
На чём я в итоге остановился — так это на хитром трюке по «обману» GCC для сборки DOS COM-файлов реального режима. Трюк работает до 80386 (что обычно и нужно). Процессор 80386 выпущен в 1985 году и стал первым 32-битным x86 микропроцессором. GCC по-прежнему придерживается этого набора инструкций, даже в среде x86-64. К сожалению, GCC никак не может производить 16-битный код, так что от изначальной цели сделать игру для 8086 пришлось отказаться. Впрочем, это не имеет значения, потому что целевая платформа DOSBox по сути является эмулятором 80386.
В теории трюк должен работать и в компиляторе MinGW, но там есть давняя ошибка, которая мешает ему работать правильно (“cannot perform PE operations on non PE output file”). Впрочем, её можно обойти, и я делал это сам: следует удалить директиву OUTPUT_FORMAT и добавить дополнительный шаг objcopy ( objcopy -O binary ).
Hello World в DOS
Для демонстрации создадим досовскую COM-программу “Hello, World” с помощью GCC на Linux.
В этом способе есть главное и значительное препятствие: стандартной библиотеки не будет. Это как писать операционную систему с нуля, за исключением нескольких служб, которые обеспечивает DOS. Это значит, нет printf() и тому подобного. Вместо этого мы попросим DOS вывести строку в консоль. Создать запрос к DOS требует запуска прерывания, что означает встроенный ассемблерный код!
В DOS девять прерываний: 0x20, 0x21, 0x22, 0x23, 0x24, 0х25, 0x26, 0x27, 0x2F. Самое главное, которое нас интересует, это 0x21, функция 0x09 (вывести строку). Между DOS и BIOS есть тысячи функций, названных по такому шаблону. Я не собираюсь пытаться объяснить ассемблер x86, но вкратце номер функции забивается в регистр ah — и прерывание 0x21 срабатывает. Функция 0x09 также принимает аргумент — указатель на строку для печати, который передается в регистрах dx и ds .
Вот функция print() встроенного ассемблера GCC. Строки, передаваемые этой функции, должны заканчиваться символом $. Почему? Потому что DOS.
Код объявлен volatile , поскольку у него побочный эффект (печать строки). Для GCC ассемблерный код непрозрачен, и оптимизатор полагается на ограничения выхода/входа/клоббера (последние три строки). Для таких DOS-программ любой встроенный ассемблер будет с побочными эффектами. Это потому что он пишется не для оптимизации, а для доступа к аппаратным ресурсам и DOS — вещей, недоступных простому C.
Нужно также позаботиться о вызывающем операторе, потому что GCC не знает, что память, на которую указывает string , когда-либо читалась. Вероятно, массив, который поддерживает строку, тоже придётся объявить volatile . Всё это предвещает неизбежное: любые действия в такой среде превращаются в бесконечную борьбу с оптимизатором. Не все из этих битв можно выиграть.
Теперь к основной функции. Её название по идее не важно, но я избегаю называть её main() , потому что у MinGW есть забавные идеи, как обрабатывать конкретно такие символы, даже если его просят не делать этого.
COM-файлы ограничены размером 65279 байт. Это связано с тем, что сегмент памяти x86 составляет 64 КБ, а DOS просто загружает COM-файлы в адрес 0x0100 сегмента и выполняет. Заголовков нет, только чистый бинарник. Поскольку программа COM в принципе не может иметь значительный размер, то не должно происходить и никакой реальной компоновки (freestanding), вся вещь компилируется как одна единица трансляции. Это будет один вызов GCC с кучей параметров.
Параметры компилятора
Вот основные параметры компилятора.
-std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding
Поскольку стандартные библиотеки не используются, то единственное различие между gnu99 и c99 заключается в отключенных триграфах (как и должно быть), и встроенный ассемблер можно записать как asm вместо __asm__ . Это не бином Ньютона. Проект будет настолько тесно связан с GCC, что я всё равно не озабочен расширениями GCC.
Параметр -Os насколько возможно уменьшает результат компиляции. Так и программа будет работать быстрее. Это важно с прицелом на DOSBox, потому что эмулятор по умолчанию работает медленно как машина 80-х. Я хочу вписаться в это ограничение. Если оптимизатор вызывает проблемы, то временно поставим -O0 , чтобы определить, тут ваша ошибка или оптимизатора.
Как видите, оптимизатор не понимает, что программа будет работать в реальном режиме с соответствующими ограничениями адресации. Он выполняет всевозможные невалидные оптимизации, которые ломают ваши совершенно валидные программы. Это не баг GCC, ведь мы сами тут делаем сумасшедшие вещи. Мне пришлось несколько раз переделывать код, чтобы помешать оптимизатору сломать программу. Например, пришлось избегать возврата сложных структур из функций, потому что они иногда заполнялись мусором. Настоящая опасность в том, что будущая версия GCC станет ещё умнее и будет ломать ещё больше кода. Здесь ваш друг volatile .
Следующий параметр -nostdlib , поскольку мы не сможем залинковаться ни с какими валидными библиотеками, даже статически.
Параметры -m32-march=i386 командуют компилятору выдавать код 80386. Если бы я писал загрузчик для современного компьютера, то прицел на 80686 тоже был бы нормальный, но DOSBox — это 80386.
Аргумент -ffreestanding требует, чтобы GCC не выдавал код, который обращается к функциям хелпера встроенной стандартной библиотеки. Иногда он вместо реально рабочего кода выдаёт код для вызова встроенной функции, особенно с математическими операторами. У меня это была одна из основных проблем с bcc, где такое поведение невозможно отключить. Такой параметр чаще всего используется при написании загрузчиков и ядер ОС. А теперь и досовских COM-файлов.
Параметры компоновщика
Параметр -Wl используется для передачи аргументов компоновщику ( ld ). Нам это нужно, поскольку мы всё делаем за один вызов GCC.
--nmagic отключает выравнивание страниц разделов. Во-первых, нам оно не требуется. Во-вторых, оно впустую отнимает драгоценное пространство. В моих тестах это не кажется необходимой мерой, но я на всякий случай оставляю эту опцию.
Параметр --script указывает, что мы хотим использовать особый скрипт компоновщика. Это позволяет точно разместить разделы ( text , data , bss , rodata ) нашей программы. Вот скрипт com.ld .
OUTPUT_FORMAT(binary) говорит не помещать это в файл ELF (или PE и т. д.). Компоновщик должен просто сбросить чистый код. COM-файл — это просто чистый код, то есть мы даём команду компоновщику создать файл COM!
Я говорил, что COM-файлы загружаются в адрес 0x0100 . Четвёртая строка смещает туда бинарник. Первый байт COM-файла по-прежнему остаётся первым байтом кода, но будет запускаться с этого смещения в памяти.
Далее следуют все разделы: text (программа), data (статичные данные), bss (данные с нулевой инициализацией), rodata (строки). Наконец, я отмечаю конец двоичного файла символом _heap . Это пригодится позже при написании sbrk() , когда мы закончим с “Hello, World”. Я указал выровнять _heap по 4 байтам.
Запуск программы
Компоновщик обычно знает нашу точку входа ( main ) и настраивает её для нас. Но поскольку мы запросили «двоичную» выдачу, то придётся разбираться самим. Если первой запустится функция print() , то выполнение программы начнётся с неё, что неправильно. Программе нужен небольшой заголовок для начала работы.
В скрипте компоновщика для таких вещей есть опция STARTUP , но мы для простоты внедрим её прямо в программу. Обычно подобные штуки называются crt0.o или Boot.o , на случай, если вы где-то на них наткнётесь. Наш код обязан начинаться с этого встроенного ассемблера, перед любыми включениями и тому подобным. DOS сделает за нас бóльшую часть установки, нам просто нужно перейти к точке входа.
.code16gcc сообщает ассемблеру, что мы собираемся работать в реальном режиме, так что он сделает правильную настройку. Несмотря на название, это не выдаст 16-битный код! Сначала вызывается функция dosmain , которую мы написали ранее. Затем он сообщает DOS с помощью функции 0x4C («закончить с кодом возврата»), что мы закончили, передавая код выхода в 1-байтовый регистр al (уже установленный функцией dosmain ). Этот встроенный ассемблер автоматически volatile , потому что не имеет входов и выходов.
Всё вместе
Вот вся программа на C.
Не буду повторять com.ld . Вот вызов GCC.
И его тестирование в DOSBox:
Тут если вы хотите красивой графики, то вопрос всего лишь в вызове прерывания и записи в память VGA. Если хотите звука, используйте прерывание PC Speaker. Я ещё не разобрался, как вызвать Sound Blaster. Именно с этого момента вырос DOS Defender.
Чтобы покрыть ещё одну тему, помните тот _heap ? Можем использовать его для реализации sbrk() и динамического выделения памяти в основном разделе программы. Это реальный режим и нет виртуальной памяти, поэтому можем писать в любую память, к которой мы можем обратиться в любой момент. Некоторые участки зарезервированы (например, нижняя и верхняя память) для оборудования. Так что реальной нужды в использовании sbrk() нет, но интересно попробовать.
Как обычно на x86, ваша программа и разделы находятся в нижней памяти (0x0100 в данном случае), а стек — в верхней (в нашем случае в районе 0xffff). В Unix-подобных системах память, возвращаемая malloc() , поступает из двух мест: sbrk() и mmap() . Что делает sbrk() , так это выделяет память чуть выше сегментов программы/данных, приращивая её «вверх» навстречу стеку. Каждый вызов sbrk() будет увеличивать это пространство (или оставлять его точно таким же). Данная память будет управляться malloc() и подобными.
Вот как можно реализовать sbrk() в программе COM. Обратите внимание, что нужно определить собственный size_t , потому что у нас нет стандартной библиотеки.
Он просто устанавливает указатель на _heap и увеличивает его по мере необходимости. Немного более умный sbrk() также будет осторожен с выравниванием.
В процессе создания DOS Defender произошла интересная вещь. Я (неправильно) посчитал, что память от моего sbrk() обнулилась. Так было после первой игры. Однако DOS не обнуляет эту память между программами. Когда я снова запустил игру, она продолжилась точно там, где остановилась, потому что те же структуры данных с тем же содержимым были загружены на свои места. Довольно прикольное совпадение! Это часть того, что делает забавной эту встроенную платформу.
Можно иначе. В начале программы грабишь через АПИ здоровенный кусок памяти. Потом пишешь свой менеджер памяти. И память выделяешь уже порциями из награбленного.
Со стеком лучше не играйся. Может больно закончиться. 8)
Выделение памяти на уровне OS - довольно сложный процесс. Никто не даст необходимых привилегий для того, чтобы управлять страницами памяти. Да и сложное это дело.
А почему западло использовать VirtualAlloc ?
1. VirtualAlloc - БОЛЬШОЙ тормоз!
2. VirtualAlloc выделяет память кусками, кратными 64Мб.
IronPeter
дело в скорости, ещё пытаюсь сократить вызов api функий до минимума
1. VirtualAlloc - БОЛЬШОЙ тормоз!
интересная новость, может назовешь под Win32 более быстрый способ (не считая стека конечно)?
2. VirtualAlloc выделяет память кусками, кратными 64Мб.
еще интереснее :-)
вообще то куски кратны 4Kb и начало выровнено на 64Kb.
2fr
1. Если для небольших блоков, то GlobalAlloc/HeapAlloc быстрее.
- VirtualAlloc обращается к ядру (0-му кольцу);
- VirtualAlloc обнуляет память
И вообще-то я не говорил о том, кто кого быстрее. Я говорил, что VA - тормоз, и это действителтно правда.
1. Да, насчет 64Мб я, конечно же, загнул. Allocation granularity = 64 KB. А "куски" выровнены не только по началу, но и по размеру - поэтому все блоки памяти, выделенные VA, кратны 64Кб.
fr
>интересная новость, может назовешь под Win32 более быстрый способ (не считая стека конечно)?
А память под стек, кстати, тоже через VirtualAlloc выделяется :)
prVovoik
Он имел в виду alloca(), видимо.
IPSer
> Если для небольших блоков, то GlobalAlloc/HeapAlloc быстрее.
Они реализованы через VirtualAlloc. Вообще, VirtualAlloc - самый низкоуровневый способ выделения памяти под виндой. Все остальное использует VirtualAlloc.
>- VirtualAlloc обращается к ядру (0-му кольцу);
HeapAlloc тоже
>- VirtualAlloc обнуляет память
неправда
> А "куски" выровнены не только по началу, но и по размеру - поэтому все блоки памяти, выделенные VA, кратны 64Кб.
Неправда. Куски кратны странице памяти, то есть 4кб.
1. Пройди в отладчике GlobalAlloc() и пойми, что он полностью реализован на 3-м кольце. VirtualAlloc практически сразу обращается к загадочному Int 2Eh, которое вызывает соответсвующую службу режима ядра.
2. Цитата из MSDN:
"MEM_COMMIT - Allocates physical storage in memory or in the paging file on disk for the specified region of memory pages. The function initializes the memory to zero. "
3. И еще 1-а:
"dwAllocationGranularity -
Granularity with which virtual memory is allocated. For example, a VirtualAlloc request to allocate 1 byte will reserve an address space of dwAllocationGranularity bytes. This value was hard coded as 64K in the past, but other hardware architectures may require different values."
GleanTheGreen
>дело в скорости, ещё пытаюсь сократить вызов api функий до минимума
Знакомая ситуация :)))
HeapAlloc или GlobalAlloc + менеджер памяти.
Точно знаю что под win98 есть возможность зверства: VirtualAlloc((void*)address, size, MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE|PAGE_NOCACHE);
Выбирай :)
Существует и третья особенность, о которой, возможно, не подозревают программирующие на языках высокого уровня, – технология выделения памяти для динамических объектов данных. Эта технология напрямую зависит от операционной среды, для которой разрабатывается программа. Так, в MS DOS динамическое выделение памяти во время работы приложения осуществляется с помощью двух функций прерывания int 21h-48h (выделение блока памяти) и 49h (освобождение блока памяти). Единицей выделения памяти при этом является параграф (16-байтный блок памяти). С точки зрения современного программирования – это примитивно и не очень интересно. Гораздо большими возможностями обладает наиболее распространенная в настоящее время операционная система Windows.
В Windows существует несколько механизмов динамического выделения памяти:
- виртуальная память;
- кучи;
- отображаемые в память файлы.
Пример использования последнего из перечисленных механизмов был приведен в уроке 20 "ММХ-технология микропроцессоров Intel" учебника. Также отображаемые в память файлы рассматриваются в главе 7 этой книги, посвященной работе с файлами. Для нашего изложения представляют интерес, хотя и в разной степени, первые два механизма.
Механизм виртуальной памяти Windows
Механизм виртуальной памяти Windows реализуется с помощью функций API Win32 VirtualAlloc и VirtualFree.
С помощью функции Virtual All ос приложение запрашивает в свое распоряжение область памяти (регион) в адресном пространстве с размером, указываемым параметром dwSize. Величина dwSize должна быть кратна 64 Кбайт, что, соответственно, является минимальным размером региона. Динамическое выделение памяти такими большими порциями может требоваться лишь для работы с большими массивами данных (структурами данных). Для нашего изложения этот способ выделения данных не подходит.
Отметим лишь, что работа функции VirtualAlloc имеет следующую особенность. Имеются три варианта обращения к функции VirtualAlloc: резервирование региона в адресном пространстве процесса; выделение физической памяти в зарезервированном предыдущим вызовом функции VirtualAlloc регионе; резервирование региона с одновременной передачей ему физической памяти. Освобождение региона производится функцией VirtualFree. Более подробную информацию о параметрах функций VirtualAlloc и VirtualFree можно посмотреть в MSDN.
Механизм работы с кучами Windows
Этот механизм наиболее эффективен для поддержки работы с такими структурами данных, как связные списки, деревья и т. п. Как правило, отдельные элементы этих структур имеют небольшой размер, в то время как общее количество памяти, занимаемое этими структурами в разные моменты времени работы приложения, может быть разным. Главное преимущество использования кучи – свобода в определении размера выделяемой памяти. В то же время это самый медленный механизм динамического выделения памяти.
Windows поддерживает работу с двумя видами куч: стандартной и дополнительной.
Во время создания система выделяет процессу стандартную кучу (или кучу по умолчанию), размер которой составляет 1 Мбайт. При желании можно указать компоновщику ключ /HEAP с новой величиной размера стандартной кучи. Создание и уничтожение стандартной кучи производится системой, поэтому в API не существует функций, управляющих этим процессом.
Основная нагрузка при работе компьютера ложится на процессор и память. Процессор выполняет команды, хранящиеся в памяти. В памяти хранятся также и данные. Между процессором и памятью происходит непрерывный обмен информацией. Процессор имеет свою небольшую память, состоящую из регистров. Команда процессора, использующая находящиеся в регистрах данные, выполняется много быстрее аналогичных команд над данными в памяти. Поэтому часто для того, чтобы выполнить какую-либо команду, данные для неё предварительно помещают в регистры. Результат команды можно при необходимости поместить обратно в память. Обмен данными между памятью и регистрами осуществляют команды пересылки. Кроме этого, можно обмениваться данными между регистрами, посылать и получать данные от внешних устройств. В регистр и ячейку памяти можно посылать и непосредственный операнд – число. Кроме этого имеются команды, с помощью которых можно помещать и извлекать данные из стека – специальной области памяти, используемой для хранения адресов возврата из функций, передаваемых в функцию параметров и локальных переменных.
Адресация и выделение памяти
Для процессора вся память представляет собой последовательность однобайтовых ячеек, каждая из которых имеет свой адрес. Для того, чтобы оперировать большими числами, пары ячеек объединяют в слова, пары слов – в двойные слова, пары двойных слов – в учетверенные слова. Чаще всего в программах оперируют байтами, словами и двойными словами (в соответствии с одно-, двух- и четырехбайтовыми регистрами процессоров). Адресом слова и двойного слова является адрес их младшего байта.
Здесь используется доступ к переменной типа BYTE по указателю – структура BYTE PTR [EAX]. Немного позже мы увидим, как этот прием используется при написании программ.
Задания.
Попробуйте записать по адресу переменной а, хранящемуся в регистре ЕАХ, число 260. Какой ответ вы получили? Почему? Задайте переменную b типа WORD и переменную c типа DWORD. Используя косвенную адресацию, запишите в эти переменные числа 1023 и 70000, соответственно. Поместите в переменную с число 70000, используя указатель типа BYTE:Объясните полученный результат (напоминаем, что адресом слова или двойного слова является адрес их младшего байта). Проделайте то же самое, используя указатель типа WORD.
На листинге 2 представлена программа, иллюстрирующая способы доступа к переменным по указателям. Наберите эту программу. Разберитесь с комментариями. Попробуйте поменять элементы массива. Попробуйте выводить результаты в шестнадцатеричной системе (вместо %u в строке формата функции printf() используйте %x).Доступ к переменной по указателю используется и в языках высокого уровня (очень часто – при создании динамических массивов).
Указатель – это переменная, которая содержит адрес другой переменной (говорят, что указатель указывает на переменную того типа, адрес которой он содержит). Существует одноместная (унарная, т.е. для одного операнда) операция взятия адреса переменной & (амперсанд, как в названии мультфильма Tom&Jerry). Если имеем объявление int a, то можно определить адрес этой переменной: &a. Если Pa – указатель, который будет указывать на переменную типа int, то можно записать: Pa=&a. Существует унарная операция * (она называется операцией разыменования), которая действует на переменную, содержащую адрес объекта, т.е. на указатель. При этом извлекается содержимое переменной, адрес которой находится в указателе. Если Pa=&a, то, воздействуя на обе части операцией * получим (по определению этой операции): *Pa=a. Исходя из этого, указатель объявляется так:
Это и есть правило объявления указателя: указатель на переменную какого-то типа – это такая переменная, при воздействии на которую операцией разыменования получаем значение переменной того же типа. На листинге 3 приведен пример использования указателя в языке Си.
На листинге 4 представлена программа, позволяющая получать адреса элементов массивов разных типов средствами Cи. Обратите внимание на значения соседних адресов элементов массива.
Один из наиболее часто встречающихся случаев – использование указателей для динамического выделения памяти при создании массивов (листинг 5).
Задание. Выведите на экран адреса элементов массива, созданного в программе, показанной на листинге 5. Попробуйте создать динамический массив типа double, заполнить его, вывести на печать элементы массива и их адреса.
Арифметические операции над целыми числами
Сложение и вычитание целых чисел
Рассмотрим 3 основные команды сложения. Команда INC осуществляет инкремент, т.е. увеличение содержимого операнда на 1, например, INC EAX. Команда INC устанавливает флаги OF, SF, ZF, AF, PF в зависимости от результатов сложения. Команда ADD осуществляет сложение двух операндов. Результат пишется в первый операнд (приемник). Первый операнд может быть регистром или переменной. Второй операнд – регистром, переменной или числом. Невозможно, однако, осуществлять операцию сложения одновременно над двумя переменными. Команда действует на флаги CF, OF, SF, ZF, AF, PF. Её можно использовать для знаковых и для беззнаковых чисел. Команда ADC осуществляет сложение двух операндов подобно команде ADD и флага (бита) переноса. С её помощью можно осуществлять сложение чисел, размер которых превышает 32 бита или изначально длина операндов превышает 32 бита.
Умножение целых чисел
В отличие от сложения и вычитания умножение чувствительно к знаку числа, поэтому существует две команды умножения: MUL – для умножения беззнаковых чисел, IMUL – для умножения чисел со знаком. Единственным оператором команды MUL может быть регистр или переменная. Здесь важен размер этого операнда (источника).
Если операнд однобайтовый, то он будет умножаться на AL, соответственно, результат будет помещен в регистр AX независимо от того, превосходит он один байт или нет. Если результат не превышает 1 байт, то флаги OF и CF будут равны 0, в противном случае – 1. Если операнд двухбайтовый, то он будет умножаться на AX, и результат будет помещен в пару регистров DX:AX (а не в EAX, как могло бы показаться логичным). Соответственно, если результат поместится целиком в AX, т.е. содержимое DX будет равно 0, то нулю будут равны и флаги CF и OF. Наконец, если оператор-источник будет иметь длину четыре байта, то он будет умножаться на EAX, а результат должен быть помещен в пару регистров EDX:EAX. Если содержимое EDX после умножения окажется равным нулю, то нулевое значение будет и у флагов CF и OF.Команда IMUL имеет 3 различных формата. Первый формат аналогичен команде MUL. Остановимся на двух других форматах.
operand1 должен быть регистр, operand2 может быть числом, регистром или переменной. В результате выполнения умножения (operand1 умножается на operand2, и результат помещается в operand1) может получиться число, не помещающееся в приемнике. В этом случае флаги CF и AF будут равны 1 (0 в противном случае).
В данном случае operand2 (регистр или переменная) умножается на operand3 (число) и результат заносится в operand1 (регистр). Если при умножении возникнет переполнение, т.е. результат не поместится в приемник, то будут установлены флаги CF и OF. Применение команд умножения приведено на листинге 8.
Листинг 8. Применение команд умножения
Деление целых чисел
Деление беззнаковых чисел осуществляется с помощью команды DIV. Команда имеет только один операнд – это делитель. Делитель может быть регистром или ячейкой памяти. В зависимости от размера делителя выбирается и делимое.
Делитель имеет размер 1 байт. В этом случае делимое помещается в регистре AX. Результат деления (частное) содержится в регистре AL, в регистре AH будет остаток от деления. Делитель имеет размер 2 байта. В этом случае делимое помещается в паре регистров DX:AX. Результат деления (частное) содержится в регистре AX, в регистре DX будет остаток от деления. Делитель имеет размер 4 байта. В этом случае делимое помещается в паре регистров EDX:EAX. Результат деления (частное) содержится в регистре EAX, в регистре EDX будет остаток от деления.Команда знакового деления IDIV полностью аналогична команде DIV. Существенно, что для команд деления значения флагов арифметических операций не определены. В результате деления может возникнуть либо переполнение, либо деление на 0. Обработку исключения должна обеспечить операционная система.
Сегодня мы попробуем закончить с азами управления массивами, а также познакомимся поближе с методикой выделения памяти в славном семействе Windows NT/2K/XP/Vista.
В предыдущих примерах наш массив располагался в секции данных исполняемого модуля. Такой подход вполне удобен при работе со статическим массивом. Но статические массивы используются достаточно редко. Чаще возникает необходимость использования динамического массива, количество элементов в котором не определено, а следовательно, размер памяти, занимаемой массивом, может изменяться за время работы программы.
Ранее, на рубеже перехода от семейства 9x к семейству NT, для выделения небольших объемов памяти рекомендовалось пользоваться функциями GlobalAlloc и LocalAlloc. Сейчас это по ряду причин считается нецелесообразным, а для выделения небольших объемов памяти (до нескольких мегабайт) рекомендуется использовать функции работы с кучами. Куча (от англ. Heap) — это область зарезервированного процессом адресного пространства, при помощи которой реализуется динамическое выделение памяти. При создании кучи под нее выделяется лишь виртуальная память, а физическая память выделяется специальным диспетчером куч (heap manager) уже по мере заполнения кучи данными. Физическая память выделяется определенными системой блоками — страницами, а по мере освобождения страниц возвращается системе. У каждого процесса есть стандартная куча, дескриптор которой можно получить вызовом функции GetProcessHeap. Параметры у этой функции отсутствуют, так как каждый процесс имеет лишь одну стандартную кучу. Также можно создавать дополнительные кучи при помощи функции HeapCreate. Ее параметры:
1. опции кучи: HEAP_CREATE_ENABLE_EXECUTE — разрешение на исполнение кода, содержащегося в куче, HEAP_GENERATE_EXCEPTIONS — системные уведомления при переполнении кучи и т.п., HEAP_NO_SERIALIZE — отказ от одновременного доступа к куче несколькими потоками немного
увеличивает производительность, но может вызвать сбой при попытке одновременного доступа.
2. Начальный размер кучи в байтах. Значение округляется в большую сторону до ближайшего значения, кратного размеру страницы, поэтому, если указать ноль, будет зарезервирована одна страница. Размер страницы можно узнать при помощи функции GetSystemInfo.
3. Максимальный размер кучи в байтах. Если при выполнении функций HeapAlloc и HeapReAlloc (выделение памяти в куче) запрашиваемый размер превышает начальный размер кучи, то система выделяет новые страницы физической памяти, но при этом суммарный размер кучи не может превысить значение данного параметра. Если максимальный размер кучи не равен нолю, то полученная куча не является "растущей", и существует ограничение на максимальный размер выделяемого блока, который не должен превышать 0x7FFF8 байт. Функция выделения памяти для такой кучи автоматически вернет ошибку при попытке выделить блок памяти большего размера. Если же в качестве максимального размера кучи указать ноль, то куча считается "растущей". Общий размер такой кучи ограничен лишь доступной системе памятью. Для такой кучи попытка выделения блока памяти размером более 0x7FFF8 байт уже не будет считаться ошибкой, — система сама произведет вызов функции VirtualAlloc для выделения памяти под большой блок данных. Приложения, которым требуется выделять большие блоки памяти, всегда должны устанавливать значение данного параметра в ноль.
При успешном выполнении функция возвращает в EAX-дескриптор созданной кучи. В случае ошибки возвращается ноль. Причем функция работает так, что точный максимальный размер блока в "нерастущей" куче никогда не известен. В официальной документации MSDN сказано лишь, что максимальный размер блока должен быть немного меньше 0x7FFF8 байт. Например, у меня не получилось выделить в "нерастущей" куче блок более 0x7EFF8. Так что старайтесь не использовать кучи фиксированного размера для выделения блоков памяти размером, близким к загадочной цифре 0x7FFF8. Для выделения блоков памяти в куче используется функция HeapAlloc, а для изменения размера выделенного блока — HeapReAlloc. Параметры первой из них:
1. Дескриптор кучи, в которой будет выделен блок (возвращается функциями GetProcessHeap и HeapCreate).
2. Опции блока: HEAP_GENERATE_EXCEPTIONS — системные уведомления при переполнении и т.п. (лучше указывать эту опцию сразу в HeapCreate для всех блоков кучи), HEAP_NO_SERIALIZE (тоже можно указать в HeapCreate для всех блоков кучи, а здесь не указывать), HEAP_ZERO_MEMORY — проинициализировать блок нулевыми значениями (занимает время, использовать по необходимости).
3. Размер выделяемого блока в байтах (если куча не является "растущей", то размер блока не должен превышать 0x7FFF8 байт).
В случае успешного выполнения функция возвращает указатель на выделенный блок памяти. При возникновении ошибки функция возвращает ноль, если не был указан параметр HEAP_GENERATE_EXCEPTIONS. Иначе функция может вернуть значения STATUS_NO_MEMORY (нехватка памяти) или STATUS_ACCESS_VIOLATION (неверные параметры) в зависимости от произошедшей ошибки. В отличие от большинства функций, функции HeapAlloc и HeapReAlloc не вызывают SetLastError в случае ошибки, поэтому обращение к GetLastError не даст более подробных сведений об ошибке. Параметры функции HeapReAlloc:
1. дескриптор кучи, в которой находится перераспределяемый блок (возвращается функциями GetProcessHeap и HeapCreate);
2. опции перераспределения: HEAP_GENERATE_EXCEPTIONS, HEAP_NO_SERIALIZE, HEAP_REALLOC_IN_PLACE_ONLY — запрещает перемещение блока: в случае недостатка свободной памяти для расширения блока по месту его расположения, функция не станет искать подходящее место в памяти, а вернет ошибку, оставив блок без изменений, HEAP_ZERO_MEMORY;
3. указатель на перераспределяемый блок памяти (возвращается функциями HeapAlloc или HeapReAlloc);
4. новый размер блока в байтах (если куча не является "растущей", то размер блока не должен превышать 0x7FFF8 байт).
Возвращаемые значения такие же, как и у HeapAlloc.
Обе вышеописанные функции могут выделить блок памяти как указанного размера, так и чуть больше требуемого. Точный размер выделенного блока можно узнать, обратившись к функции HeapSize. Ее параметры:
1. дескриптор кучи, в которой находится блок;
2. опции: HEAP_NO_SERIALIZE;
3. указатель на заданный блок.
В случае успешного выполнения возвращается размер заданного блока. В случае ошибки — минус единица. Данная функция также не указывает подробности о произошедшей ошибке через SetLastError.
Если выделенный в куче блок памяти вам больше не требуется, его следует удалить функцией HeapFree, чтобы освободить системную память. Параметры этой функции такие же, как и у HeapSize. В случае успешного освобождения памяти от заданного блока возвращается ненулевое значение. В случае ошибки возвращается ноль. Подробности можно узнать при помощи функции GetLastError. Для удаления кучи целиком используется функция HeapDestroy. Она имеет всего один параметр — дескриптор удаляемой кучи, возвращаемый функцией HeapCreate. Только не пытайтесь удалить кучу, дескриптор которой получен при помощи функции GetProcessHeap. Не вы ее создавали, не вам ее удалять. В случае успешного удаления кучи возвращается ненулевое значение. В случае ошибки возвращается ноль. Подробности можно узнать при помощи функции GetLastError.
Теперь подробнее о цикле удаления. Мы, как и прежде, командой repne scasb сканируем массив в поисках элемента, равного содержимому AL. Сканирование может быть прекращено либо если найдено равенство, либо если ECX сравнялся с нулем. Второй случай мы только что рассмотрели. Если же найден элемент, равный AL, то мы прыгаем на метку ".del_el", чтобы выполнить удаление элемента. В edi к этому моменту находится адрес следующего за удаляемым элементом. А нам для удаления при помощи команды rep movsb необходимо, чтобы в edi был адрес удаляемого элемента, а в esi — следующего за ним. Поэтому мы копируем содержимое edi в esi, а edi уменьшаем на единицу. Теперь мы сохраним регистры eax, ecx и edi, потому что текущие значения ecx и edi нам еще понадобятся для продолжения поиска других кандидатов на удаление с текущей позиции. На текущую позицию будет сдвинут следующий за удаляемым элементом, а он ведь тоже может оказаться подлежащим удалению, хотя пока мы об этом не знаем, а лишь готовимся к удалению текущего элемента, но предусматриваем все варианты. Содержимое EAX мы сохраняем по другой причине: после вызова функции удаления лишнего окошка в EAX вернется результат ее выполнения. А у нас в AL, который является частью EAX, если вы помните, хранится значение искомых элементов. Поэтому обязательно сохраняем.
Элемент удалили, уменьшили текущее количество элементов в переменной [n], удаляем последнее поле. Его дескриптор сейчас находится по адресу [hmas]+[n]*4. Но ввиду невозможности двойной адресации мы поместим значение [n] в edx, умножим на 4 (shl edx,2) и добавим к edx значение [hmas]. Теперь, когда edx хранит адрес дескриптора удаляемого окна, мы смело можем указать [edx] в качестве параметра функции SendMessage. После удаления окошка задвигаем в bl единицу в знак того, что хотя бы один элемент мы уже удалили. Восстанавливаем сохраненные регистры, соответственно, в обратном порядке. На всякий случай проверяем ecx на ноль — вдруг это был последний элемент массива. Если не ноль, то переходим на метку ".del_next", чтобы продолжить сканирование с того элемента, который мы еще не проверяли. Иначе обнуляем eax — успешное выполнение — и возвращаемся из процедуры. В процедуре вставки элемента так же, как и в остальных измененных процедурах, и по тем же причинам адрес массива копируется в регистр. Ну и для доступа к ячейке [hmas]+[n]*4 используется такой же прием, как и в процедуре удаления. Однако и сейчас, несмотря на то, что массив динамический, ему чего-то не хватает. Мы не можем вставить больше элементов, чем MAXMASSIZE. Для того, чтобы обойти это ограничение, заменим данную константу переменной. Например, икс:
…
x dd MAXMASSIZE
…
Теперь на метке ".in:" сравнивайте [n] с [x], естественно, через регистр. Если памяти не хватает для вставки очередного элемента — выделите ее функцией HeapReAlloc. Только постарайтесь сделать так, чтобы память выделялась сразу под несколько элементов, а не под один каждый раз — это уменьшит фрагментацию и общее время на выделение памяти. А еще лучше — чтобы память выделялась заранее: например, когда текущей выделенной памяти осталось меньше чем на 10 элементов, блоки увеличиваются на 20 элементов. Попробуйте сделать это сами — ваш уровень это позволяет. Вот, собственно, и все, что я вам хотел рассказать о динамических массивах. Понятно, что существуют массивы, в которых каждый элемент может быть строкой неопределенного размера, существуют двумерные и многомерные массивы, но общие принципы работы с ними остаются такими же. Просто усложняется алгоритм обработки. Однако, если вы разберетесь с простым типом массивов, то и более сложные типы без проблем поймете по мере надобности. Желаю вам успехов в этом, и до новых встреч!
Все приводимые примеры были протестированы на правильность работы под Windows XP и, скорее всего, будут работать под другими версиями Windows, однако я не даю никаких гарантий их правильной работы на вашем компьютере. Исходные тексты программ вы можете найти на форуме: сайт
Компьютерная газета. Статья была опубликована в номере 38 за 2008 год в рубрике программирование
Читайте также: