Для чего используется сегмент стека процесса
Структура приложения на си
Сегментация приложения на си
В этой статье общё описывается структура приложения, которую обычно можно встретить на практике. Стандарт языка си не определяет структуры приложения, поэтому неверно говорить, например, о том, что в приложении на си автоматические перемные располагаются на стеке, а статические в bss сегменте и т.п. Однако, реальные примеры помогают глубже понять поведение компилятора и программы.
Data cостоит из статических и глобальных переменных, которые явно инициализируются значениями. Этот сегмент может быть далее разбит на ro-data (read only data) – сегмент данных только для чтения, и rw-data (read write data) – сегмент данных для чтения и записи. Например, глобальные переменные
Будут храниться в rw-области. Для выражения типа
указатель будет храниться в rw-области, а строкой литерал «hello world» в ro-области.
оба будут расположены в сегменте данных.
BSS-сегмент (block started by symbol) содержит неинициализированные глобальные переменные, или статические переменные без явной инициализации. Этот сегмент начинается непосредственно за data-сегментом. Обычно загрузчик программ инициализирует bss область при загрузке приложения нулями. Дело в том, что в data области переменные инициализированы – то есть затирают своими значениями выделенную область памяти. Так как переменные в bss области не инициализированы явно, то они теоретически могли бы иметь значение, которое ранее хранилось в этой области, а это уязвимость, которая предоставляет доступ до, возможно, приватных данных. Поэтому загрузчик вынужден обнулять все значения. За счёт этого и неинициализированные глобальные переменные, и статические переменные по умолчанию равны нулю.
Куча – начинается за BSS сегментом и начиная оттуда растёт, соответственно с увеличением адреса. Этот участок используется для выделения на нём памяти с использованием функции malloc (и прочих) и для очисти с помощью функции free.
Сегмент кода, или текстовый сегмент, или просто текст, содержит исполняемые инструкции. У него фиксированный размер и обычно он используется только для чтения, если же в него можно писать, то архитектура поддерживает самомодификацию. Сегмент кода располагается после начала стека, поэтому в случае роста он [стек] не перекрывает сегмент кода.
Фундаментальные концепции
Сегмент данных (data segment) содержит переменные, строки, массивы и другие данные программы. Он состоит из двух частей: инициализированных и неинициализированных данных. По историческим причинам вторая часть называется BSS (Block Started by Symbol). Инициализированная часть сегмента данных содержит переменные и константы компилятора, значения которых должны быть заданы при запуске программы.
Например, на языке C можно объявить символьную строку и в то же время проини- циализировать ее. Если программа запускается, она предполагает, что эта строка уже имеет свое начальное значение. Чтобы реализовать это, компилятор назначает строке определенное место в адресном пространстве и гарантирует, что в момент запуска программы по этому адресу будет располагаться необходимая строка. С точки зрения операционной системы инициализированные данные не отличаются от текста программы — и тот и другой сегменты содержат сформированные компилятором последовательности битов, которые должны быть загружены в память при запуске программы.
Неинициализированные данные необходимы лишь с точки зрения оптимизации. Если глобальная переменная не инициализирована явным образом, то, согласно семантике языка C, ее начальное значение устанавливается равным 0. На практике большинство глобальных переменных не инициализируются, таким образом, их начальное значение
|
равно 0. Это можно реализовать следующим образом: создать область исполняемого двоичного файла, точно равную по размеру числу байтов данных, и проинициализи- ровать всю эту область нулями.
Однако (из экономии места в исполняемых файлах) так не делается. Вместо этого файл содержит все явно инициализированные переменные прямо за текстом программы. Все неинициализированные переменные собираются вместе после инициализированных, так что компилятору нужно только записать в заголовок слово, содержащее количество подлежащих выделению байтов.
Рассмотрим это еще раз на нашем примере (см. рис. 10.6, а). Здесь текст программы занимает 8 Кбайт, инициализированные данные — также 8 Кбайт. Размер неинициализированных данных (BSS) равен 4 Кбайт. Исполняемый файл содержит только 16 Кбайт (текст + инициализированные данные) плюс короткий заголовок, в котором операционной системе дается указание выделить программе дополнительно 4 Кбайт (после инициализированных данных) и обнулить их перед выполнением программы. Этот трюк позволяет сэкономить 4 Кбайт нулей в исполняемом файле.
Для того чтобы избежать выделения полной нулей физической страницы, во время инициализации Linux выделяет статическую нулевую страницу (защищенную от записи страницу, заполненную нулями). Когда процесс загружается, указатель на область его неинициализированных данных устанавливается на эту нулевую страницу. Когда процесс пытается писать в эту область, то вмешивается механизм копирования при записи и процессу выделяется настоящая страница.
В отличие от текстового сегмента, который не может изменяться, сегмент данных изменяться может. Программы все время модифицируют свои переменные. Более того, многим программам требуется динамическое выделение памяти во время выполнения. Для этого операционная система Linux разрешает сегменту данных расти при выделении памяти и уменьшаться при освобождении памяти. Программа может установить размер своего сегмента данных при помощи системного вызова ЪтЬ. Таким образом,
чтобы выделить больше памяти, программа может увеличить размер своего сегмента данных. Этим системным вызовом активно пользуется библиотечная процедура malloc языка С, используемая для выделения памяти. Дескриптор адресного пространства процесса содержит информацию о диапазоне динамически выделенных областей памяти процесса (который обычно называется кучей — heap).
Третий сегмент — это сегмент стека (stack segment). На большинстве компьютеров он начинается около старших адресов виртуального адресного пространства и растет вниз к 0. Например, на 32-битной платформе х86 стек начинается с адреса 0xC0000000, который соответствует предельному виртуальному адресу, видимому процессам пользовательского режима. Если указатель стека оказывается ниже нижней границы сегмента стека, то происходит аппаратное прерывание, при котором операционная система понижает границу сегмента стека на одну страницу. Программы не управляют явно размером сегмента стека.
Когда программа запускается, ее стек не пуст. Напротив, он содержит все переменные окружения (оболочки), а также командную строку, введенную в оболочке для вызова этой программы. Таким образом, программа может узнать параметры, с которыми она была запущена. Например, когда вводится команда cp src dest
то запускается программа cp со строкой «cp src dest» в стеке, что позволяет ей определить имена файлов, с которыми ей предстоит работать. Строка представляется в виде массива указателей на символы строки, что облегчает ее разбор.
Когда два пользователя запускают одну и ту же программу (например, текстовый редактор), то в памяти можно было бы хранить две копии программы редактора.
Сегменты данных и стека никогда не бывают общими, кроме как после выполнения системного вызова fork, и то только те страницы, которые не модифицируются. Если размер одного из сегментов должен быть увеличен, то отсутствие свободного места в соседних страницах памяти не является проблемой, поскольку соседние виртуальные страницы памяти не обязаны отображаться на соседние физические страницы.
На некоторых компьютерах аппаратное обеспечение поддерживает раздельные адресные пространства для команд и данных. Если такая возможность есть, то система Linux может ее использовать. Например, на компьютере с 32-разрядными адресами (при наличии возможности использования раздельных адресных пространств) можно получить 2 32 бита адресного пространства для команд и дополнительно 2 32 бита адресного пространства для сегментов данных и стека. Условная или безусловная передача управления по адресу 0 будет восприниматься как передача управления по адресу 0 в текстовом пространстве, тогда как при обращении к данным по адресу 0 будет использоваться адрес 0 в пространстве данных. Таким образом, эта возможность удваивает доступное адресное пространство.
В дополнение к динамическому выделению памяти процессы в Linux могут обращаться к данным файлов при помощи отображения файлов на адресное пространство памяти (memory-mapped files). Эта функция позволяет отображать файл на часть адресного пространства процесса, чтобы можно было читать из файла и писать в файл так, как если бы это был массив байтов, хранящийся в памяти.
Отображение файла на адресное пространство памяти делает произвольный доступ к нему существенно более легким, нежели при использовании таких системных вызовов, как read и write. Совместный доступ к библиотекам предоставляется именно при помощи этого механизма. На рис. 10.7 показан файл, одновременно отображенный на адресные пространства двух процессов по различным виртуальным адресам.
|
Рис. 10.7. Два процесса совместно используют один отображенный на память файл
Дополнительное преимущество отображения файла на память заключается в том, что два или более процесса могут одновременно отобразить на свое адресное пространство один и тот же файл. Запись в этот файл одним из процессов мгновенно становится видимой всем остальным. Таким образом, отображение на адресное пространство памяти временного файла (который будет удален после завершения работы процессов) представляет собой механизм реализации общей памяти (с высокой пропускной способностью) для нескольких процессов. В предельном случае два или более процесса могут отобразить на память файл, покрывающий все адресное пространство, получая тем самым такую форму совместного использования памяти, которая является чем-то средним между процессами и потоками. В этом случае (как и у потоков) все адресное пространство используется совместно, но каждый процесс обслуживает, например, свои собственные открытые файлы и сигналы, что отличает этот вариант от потоков. Однако на практике такой способ никогда не применяется.
FasmWorld Программирование на ассемблере FASM для начинающих и не только
Учебный курс. Часть 20. Стек
Автор: xrnd | Рубрика: Учебный курс | 31-05-2010 |
Распечатать запись
Стеком называется структура данных, организованная по принципу LIFO («Last In — First Out» или «последним пришёл — первым ушёл»). Стек является неотъемлемой частью архитектуры процессора и поддерживается на аппаратном уровне: в процессоре есть специальные регистры (SS, BP, SP) и команды для работы со стеком.
Обычно стек используется для сохранения адресов возврата и передачи аргументов при вызове процедур (о процедурах в следующей части), также в нём выделяется память для локальных переменных. Кроме того, в стеке можно временно сохранять значения регистров.
Схема организации стека в процессоре 8086 показана на рисунке:
Стек располагается в оперативной памяти в сегменте стека, и поэтому адресуется относительно сегментного регистра SS. Шириной стека называется размер элементов, которые можно помещать в него или извлекать. В нашем случае ширина стека равна двум байтам или 16 битам. Регистр SP (указатель стека) содержит адрес последнего добавленного элемента. Этот адрес также называется вершиной стека. Противоположный конец стека называется дном 🙂
Дно стека находится в верхних адресах памяти. При добавлении новых элементов в стек значение регистра SP уменьшается, то есть стек растёт в сторону младших адресов. Как вы помните, для COM-программ данные, код и стек находятся в одном и том же сегменте, поэтому если постараться, стек может разрастись и затереть часть данных и кода (надеюсь, с вами такой беды не случится :)).
Для стека существуют всего две основные операции:
Добавление элемента в стек
Выполняется командой PUSH. У этой команды один операнд, который может быть непосредственным значением, 16-битным регистром (в том числе сегментым) или 16-битной переменной в памяти. Команда работает следующим образом:
Существуют ещё 2 команды для добавления в стек. Команда PUSHF помещает в стек содержимое регистра флагов. Команда PUSHA помещает в стек содержимое всех регистров общего назначения в следующем порядке: АХ, СХ, DX, ВХ, SP, BP, SI, DI (значение DI будет на вершине стека). Значение SP помещается то, которое было до выполнения команды. Обе эти команды не имеют операндов.
Извлечение элемента из стека
Выполняется командой POP. У этой команды также один операнд, который может быть 16-битным регистром (в том числе сегментым, но кроме CS) или 16-битной переменной в памяти. Команда работает следующим образом:
Обратите внимание, что извлеченный из стека элемент не обнуляется и не затирается в памяти, а просто остаётся как мусор. Он будет перезаписан при помещении нового значения в стек.
pop cx ;Поместить значение из стека в CX pop es ;Поместить значение из стека в ES pop [x] ;Поместить значение из стека в переменную x pop word [di] ;Поместить значение из стека в слово по адресу в DI
Соответственно, есть ещё 2 команды. POPF помещает значение с вершины стека в регистр флагов. POPA восстанавливает из стека все регистры общего назначения (но при этом значение для SP игнорируется).
Пример программы
Имеется двумерный массив — таблица 16-битных значений со знаком размером n строк на m столбцов. Программа вычисляет сумму элементов каждой строки и сохраняет результат в массиве sum. Первый элемент массива будет содержать сумму элементов первой строки, второй элемент — сумму элементов второй строки и так далее.
Как видите, в программе два вложенных цикла: внешний и внутренний. Внешний цикл — это цикл по строкам таблицы. Внутренний цикл вычисляет сумму элементов строки. Стек здесь используется для временного хранения счётчика внешнего цикла. Перед началом внутреннего цикла CX сохраняется в стеке, а после завершения восстанавливается. Такой приём можно использовать для программирования и большего количества вложенных циклов.
Turbo Debugger
В отладчике Turbo Debugger стек отображается в нижней правой области окна CPU. Левый столбец чисел — адреса, правый — данные. Треугольник указывает на вершину стека, то есть на тот адрес, который содержится в регистре SP. Если запустить программу в отладчике, то можно увидеть, как работают команды «push cx» и «pop cx».
Упражнение
Объявите в программе строку «$!olleH». Напишите код для переворачивания строки с использованием стека (в цикле поместите каждый символ в стек, а затем извлеките в обратном порядке). Выведите полученную строку на экран. Свои результаты пишите в комментариях 🙂
Статья 6. Стек
В предыдущих статьях вскользь упоминался стек. Рассмотрим это понятие более подробно.
Стеком называют область программы для временного хранения произвольных данных. Отличительной особенностью стека является своеобразный порядок выборки содержащихся в нем данных: в любой момент времени в стеке доступен только верхний элемент, т.е. элемент, загруженный в стек последним. Выгрузка из стека верхнего элемента делает доступным следующий элемент.
Элементы стека располагаются в области памяти, отведенной под стек, начиная со дна стека (т.е. с его максимального адреса) по последовательно уменьшающимся адресам. Адрес верхнего, доступного элемента хранится в регистре указателя стека SP. Как и любая другая область памяти программы, стек должен входить в какой-то сегмент или образовывать отдельный сегмент. В любом случае сегментный адрес этого сегмента помещается в сегментный регистр стека SS. Таким образом, пара регистров SS:SP описывают адрес доступной ячейки стека: в SS хранится сегментный адрес стека, а в SP — относительный адрес доступной (текущей) ячейки (рис. 6.1а). Обратите внимание на то, что в исходном состоянии указатель стека SP указывает на ячейку, лежащую под дном стека и не входящую в него.
Рис. 6.1. Организация стека
Загрузка в стек осуществляется специальной командой работы со стеком push (протолкнуть). Эта команда сначала уменьшает на 2 содержимое указателя стека, а затем помешает операнд по адресу, находящемуся в SP. Если, например, мы хотим временно сохранить в стеке содержимое регистра АХ, следует выполнить команду
Стек переходит в состояние, показанное на рис. 6.1б. Видно, что указатель стека смещается на два байта вверх и по этому адресу записывается указанный в команде проталкивания операнд. Следующая команда загрузки в стек, например,
переведет стек в состояние, показанное на рис. 6.1в. В стеке будут теперь храниться два элемента, причем доступным будет только верхний, на который указывает указатель стека SP. Если спустя какое-то время нам понадобилось восстановить исходное содержимое сохраненных в стеке регистров, мы должны выполнить команды выгрузки из стека pop (вытолкнуть):
Состояние стека после выполнения первой команды показано на рис. 6.1г, а после второй — на рис. 6.1д. Для правильного восстановления содержимого регистров выгрузка из стека должна выполняться в порядке, строго противоположном загрузке — сначала выгружается элемент, загруженный последним, затем предыдущий элемент и т.д.
Обратите внимание на то, что после выгрузки сохраненных в стеке данных они физически не стерлись, а остались в области стека на своих местах. Правда, при «стандартной» работе со стеком они оказываются недоступными. Действительно, поскольку указатель стека SP указывает под дно стека, стек считается пустым; очередная команда push поместит новые данные на место сохраненного ранее содержимого АХ, затерев его. Однако пока стек физически не затерт, сохраненными и уже выбранными из него данными можно пользоваться, если помнить, в каком порядке они расположены в стеке. Этот прием часто используется при работе с подпрограммами и в дальнейшем будет описан подробнее.
В примерах 1.1 и 5.1 мы не заботились о стеке, поскольку, на первый взгляд, нашей программе стек был не нужен. Однако на самом деле это не так. Стек автоматически используется системой в ряде случаев, в частности, при переходе на подпрограммы и при выполнении команд прерывания int. И в том. и в другом случае процессор заносит в стек адрес возврата, чтобы после завершения выполнения подпрограммы или программы обработки прерывания можно было вернуться в ту точку вызывающей программы, откуда произошел переход. Поскольку в нашей программе есть две команды int 21h, операционная система при выполнении программы дважды обращалась к стеку. Где же был стек программы, если мы его явным образом не создали? Чтобы разобраться в этом вопросе, изменим пример 5.1, введя в него строки работы со стеком.
Пример 6.1. Программа, работающая со стеком
В предложении 5 содержимое DS сохраняется в стеке, а в следующем предложении выгружается из стека в ES. После этой операции оба сегментных регистра, и DS. и ES, будут указывать на один и тот же сегмент данных. В нашей программе эти строки не имеют практического смысла, но вообще здесь продемонстрирован удобный прием переноса содержимого одного сегментного регистра в другой. Выше уже отмечаюсь, что в силу особенностей архитектуры микропроцессора для сегментных регистров действуют некоторые ограничения. Так, в сегментный регистр нельзя непосредственно загрузить адрес сегмента; нельзя также перенести число из одного сегментного регистра в другой. При необходимости выполнить последнюю операцию в качестве «перевалочного пункта» стек.
Запустите под управлением отладчика программу 6.1. Посмотрите, чему равно содержимое регистров SS и SP. Вы увидите, что в SS находится тот же адрес памяти, что и в CS; отсюда можно сделать вывод, что сегменты команд и стека совпадают. Однако содержимое SP равно 0. Первая же команда push уменьшит содержимое SP на 2, т.е. поместит в SP –2. Значит ли это, что стек будет расти, как ему и положено, вверх, но не внутри сегмента команд, а над ним, по адресам –2, –4, –6 и т.д. относительно верхней границы сегмента команд? Оказывается, это не так.
Если взять 16-разрядный двоичный счетчик, в котором записан 0, и послать в него два вычитающих импульса, то после первого импульса в нем окажется число FFFFh, а после второго — FFFEh. При желании мы можем рассматривать число FFFEh, как –2 (что и имеет место при работе со знаковыми числами, о которых будет идти речь позже), однако процессор при вычислении адресов рассматривает содержимое регистров, как целые числа без знака, и число FFFEh оказывается эквивалентным не –2, а 65534. В результате первая же команда занесения данного в стек поместит это данное не над сегментом команд, а в самый его конец, в последнее слово по адресу CS:FFFEh. При дальнейшем использовании стека его указатель будет смешаться в сторону меньших адресов, проходя значения FFFCh, FFFAh и т.д.
Таким образом, если в программе отсутствует явное объявление стека, система сама создает стек по умолчанию в конце сегмента команд.
Рассмотренное явление, когда при уменьшении адреса после адреса 0 у нас получился адрес FFFFh, т.е. от начала сегмента мы прыгнули сразу в его конец, носит название циклического возврата или оборачивания адреса. С этим явлением приходится сталкиваться довольно часто.
Расположение стека в конце сегмента команд не приводит к каким-либо неприятностям, пока размер программы далек от граничной величины 64 К. В этом случае начало сегмента команд занимают коды команд, а конец — стек. Если, однако, размер программы приближается к 64 К, то для стека остается все меньше места. При интенсивном использовании стека в программе может получиться, что по мере занесения в стек новых данных, стек дорастет до последних команд сегмента команд и начнет затирать эти команды. Очевидно, что этого нельзя допускать. В то же время система не проверяет, что происходит со стеком и никак не реагирует на затирание команд или данных. Таким образом, оценка размеров собственно программы, данных и стека является важным этапом разработки программы.
Современные программы часто имеют значительный размер (даже не помещаясь в один сегмент команд), а стек иногда используется для хранения больших по объему массивов данных. Поэтому целесообразно ввести в программу отдельный сегмент стека, определив его размер, исходя из требований конкретной программы. Это и сделано в следующем примере.
Выполняя программу 6.1 по шагам, пронаблюдайте, как команды push и pop изменяют содержимое регистров SP и ES. Выведите на экран дамп памяти начиная с адреса SS:FFF0h. Убедитесь, что содержимое DS действительно записалось в память по адресу SS:FFFEh, и так и осталось там после извлечения содержимого стека и восстановления его указателя.
Что еще имеется в нашей двухсегментной программе, кроме сегментов команд и данных? При загрузке программы в память она будет выглядеть так, как это показано на рис. 6.2.
Рис. 6.2. Образ памяти программы EXE со стеком по умолчанию
Образ программы в памяти начинается с очень важной структуры данных, которую мы будем называть префиксом программы. В оригинальной литературе эта структура носит не очень удачное название Program Segment Prefics (или сокращенно PSP), т.е. «префикс программного сегмента». PSP образуется и заполняется системой в процессе загрузки программы в память; он всегда имеет размер 256 байт и содержит поля данных, используемые системой (а часто и самой программой) в процессе выполнения программы. К составу полей PSP мы еще не раз будем возвращаться в этой книге.
Вслед за PSP располагаются сегменты программы. Поскольку объявления сегментов сделаны нами наипростейшим образом (операторы segment не сопровождаются операндами-описателями), порядок размещения сегментов в памяти совпадает с порядком их объявления в программе, что упрощает исследование и отладку программы. Для большинства программ не имеет значения, в каком порядке вы будете объявлять сегменты, хотя встречаются программы, для которых порядок сегментов существен. Для таких программ предложения с операторами segment будут выглядеть сложнее.
В процессе загрузки программы в память сегментные регистры автоматически инициализируются следующим образом: ES и DS указывают на начало PSP (что дает возможность, сохранив их содержимое, обращаться затем в программе к PSP), CS — на начало сегмента команд. SS, как мы экспериментально убедились, также в нашем случае указывает на начало сегмента команд. Как мы увидим позже, верхняя половина PSP занята важной для системы и самой программы информацией, а нижняя половина (128 байт) практически свободна.
Поскольку после загрузки программы в память оба сегментных регистра данных указывают на PSP, сегмент данных программы оказывается не адресуемым. Не забывайте об этом! Если вы позабудете инициализировать регистр DS так, как это сделано в предложениях 3 и 4 нашей программы, вы не сможете обращаться к своим данным. При этом транслятор не выдаст никаких ошибок, но программа будет выполняться неправильно. Поставьте поучительный эксперимент: уберите из текста программы 6.1 строки инициализации регистра DS (проще всего не стирать эти строки, а поставить в их начале знак комментария — символ «;»). Оттранслируйте, скомпонуйте и выполните такой вариант программы. Ничего ужасного не произойдет, но на экран будет выведена какая-то ерунда. Возможно, в конце этой ерунды будет и строка «Наука умеет много гитик». Почему так получилось? Когда начинает выполняться функция DOS 09h, она предполагает, что полный двухсловный адрес выводимой на экран строки находится в регистрах DS:DX (в DS — сегментный адрес, в DX — относительный). У нас же сегментный регистр DS указывает на PSP. В результате на экран будет выводиться содержимое PSP, который заполнен адресами, кодами команд и другой числовой (а не символьной) информацией.
Рассмотрим теперь программу с тремя сегментами: команд, данных и стека. Такая структура широко используется для относительно несложных программ.
Пример 6.2. Программа с тремя сегментами
В программе 6.2 вслед за сегментом данных объявлен еше один сегмент, которому мы дали имя stk. Так же, как и другие сегменты, сегмент стека можно назвать как угодно. Строка описания сегмента стека (предложение 16) должна содержать так называемый тип объединения — описатель stack. Тип объединения указывает компоновщику, каким образом должны объединяться одноименные сегменты разных программных модулей, и используется главным образом в тех случаях, когда отдельные части программы располагаются в разных исходных файлах (например, пишутся несколькими программистами) и объединяются на этапе компоновки. Хотя для одномодульных программ тип объединения обычно не имеет значения, для сегмента стека обязательно указание типа stack, поскольку в этом случае при загрузке программы выполняется автоматическая инициализация регистров SS (сегментным адресом стека) и SP (смешением его конца).
В приведенном примере для стека зарезервировано 128 слов памяти, что более чем достаточно для несложной программы.
Заметим, что получившаяся у нас программа является типичной и аналогичная структура будет использоваться в большинстве последующих примеров.
Подготовьте программу 6.2 к выполнению. Запустите ее под управлением отладчика CV, изучите расположение сегментов программы в памяти, обратив особое внимание на содержимое регистров SS и SP. Убедитесь, что в программе образовался отдельный сегмент стека размером 100h байт (128 слов = 256 байт). Поинтересуйтесь, где сохраняется значение DS при выполнении предложения 5.