Распределение памяти при выполнении программ
Напомним, что Турбо Паскаль, разработанный фирмой Borland, работает под управлением MS–DOS. В Турбо Паскале нет специальных средств, поддерживающих работу с дополнительной памятью, поэтому адресное пространство компьютера составляет 1 Мбайт. Рассмотрим распределение памяти для выполнимого кода на Турбо Паскале (рис. 3).
Рис. 3. Распределение памяти при выполнении программы
При запуске программы (EXE–файла) MS–DOS организует в памяти нечто вроде анкеты на этот файл длиной 256 байт, которая называется префиксом структуры программы. После префикса начинается код EXE–файла. Код ЕХЕ–файла состоит из рабочего кода системного модуля, рабочих кодов подключаемых модулей и рабочего кода основного блока программы. Статические глобальные переменные основного блока и все типизированные константы, включая локальные, располагаются в сегменте данных, общий объем которого не может в сумме превышать 64К. За сегментом данных следует область стека. В ней располагаются локальные переменные и параметры-значения процедур и функций во время их работы по вызову. Область стека не может превышать 64К (обычно 16К). Стек заполняется от своей верхней границы (она может быть назначена директивой компилятору $М) по направлению к началу, т.е. к старту сегмента. Выше стека программа отводит себе память под буфер для работы оверлеев – перекрывающихся частей программ. Если они не используются, то буфер не отводится. Еще выше располагается область памяти для размещения динамических переменных и структур данных, называемая областью кучи или просто кучей (еще она называется Heap‑областью).
По мере того, как программы становятся более сложными и требуется работа с большим количеством данных, область объемом в 64К, зарезервированная в Турбо Паскале для данных, может оказаться недостаточной, чтобы содержать все необходимые программе данные.
Предположим, есть программа, требующая массива в 400 строк по 100 символов каждая. Для этого массива требуется примерно 40К, что меньше максимума в 64К. Если остальные переменные помещаются в оставшиеся 24К, массив такого объема проблемы не представляет. Но если нужно два таких массива? Это потребует 80К. Чтобы работать с большими объемами данных, нужно использовать динамически распределяемую область памяти.
Динамически распределяемая область памяти – это вся память, которую операционная система делает доступной для программы и которая не используется ее кодом, сегментом данных и стеком. Объемом распределяемой динамической памяти можно управлять с помощью директивы компилятора $M.
Известно, что все переменные, встречающиеся в программе, должны быть описаны. Перед началом выполнения программы каждой переменной для размещения ее значений выделяется место в сегменте данных. Размер выделяемого места зависит от типа переменной. Например, для переменной типа Integer выделяется 2 байта. Обращение в программе к объекту, размещенному в некотором месте памяти, осуществляется с помощью имени переменной. Соответствие между переменной и сопоставленным ей местом в памяти сохраняется для описанных в программе переменных на всем протяжении выполнения программы. В Паскале имеются средства, позволяющие заниматься отведением и освобождением памяти для размещения объектов того или иного типа непосредственно по ходу выполнения программы. Память в этом случае отводится в динамической области. Данные, размер которых задается непосредственно во время выполнения программы, называются динамическими. Для объявления динамических данных в Паскале используется ссылочный тип,называемый ещетипом-указателем. С помощью ссылочного типа можно объявлять переменные, значением которых будет адрес ячейки памяти.
Ссылочные переменные
Описание ссылочного типа выглядит следующим образом:
Type
Ptr = ^t,
где t – стандартный или заранее описанный тип данных, называемый базовым типом. Сами адреса будут храниться в ссылочных переменных, которые описываются обычным образом, например, Var P : Ptr. Такие переменные для хранения адресов динамической памяти называются ссылками или указателями.
Пример.
Type
Pint = ^Integer; W = array [1..20] of Real; p = ^W;
Var
N : Pint; U : p;
Под переменную N и переменную U будет отведено по 4 байта памяти в сегменте данных. Переменные будут содержать адрес какой–либо ячейки памяти, расположенной в динамической области. Но прежде, чем переменная ссылочного типа примет значение, необходимо в ходе выполнения программы выполнить специальную процедуру. Ссылка представляет собой адрес начала, т.е. первой ячейки, некоторого места в памяти, выделенного для объекта базового типа. Переменная U будет содержать адрес первой ячейки, выделенной под массив W в динамической области памяти.
В объявлениях ссылочных типов после символа “^” может стоять только простое имя типа.
В случае сложных имен используется переопределение типов, как в приведенном примере.
Указатели, связанные с адресами значений конкретных базовых типов, называются типизированными. N и U – типизированные указатели. В Турбо Паскале можно объявлять указатель и не связывать его при этом с каким–либо конкретным типом данных. Такие указатели называются нетипизированными. Описание нетипизированных указателей осуществляется с помощью служебного слова Pointer, например, Var P : Pointer.
Адрес хранится как два слова: одно из них определяет сегмент, а другое – смещение. Значение указателя не может быть в явном виде выведено на экран или печать. Его надо предварительно расшифровать. Для работы с указателями вводится специальный набор функций (табл. 1).
Таблица 1
Специальный набор функций для работы с указателями
Так как значение указателя состоит из двух слов (Word), хранящих сегмент и смещение, можно вывести их в отдельности, используя функции Seg и Ofs:
Writeln(‘Сегмент ’, Seg(p), ‘ смещение ‘, Ofs(p));
Указатели могут обмениваться значениями через оператор присваивания. Типизированному указателю можно присвоить значение либо указателя того же типа, что и он сам, либо нетипизированного указателя. Если, например,
Var P1, P2 : ^Integer;
P3 : ^Real;
PP : Pointer;
то присваивание P1 := P2 вполне допустимо, в то время как P1 := P3 запрещено, поскольку P1 и P3 указывают на разные типы данных. Это ограничение не распространяется на нетипизированные указатели. Можно записать PP := P3; P1 := PP и достичь нужного результата. Присутствие
в программе таких переприсвоений говорит о том, что программист делает это осознано и в программе действительно нужны такие действия. Указателю можно присвоить значение Nil. Nil–
это предопределенная константа типа Pointer, соответствующая адресу 0000:0000 (пустая ссылка). Если указателю присвоено значение Nil, то этот указатель ни на какие данные не ссылается.
Указатели могут сравниваться с помощью операций отношения = или <> (не равно). Сравнение для указателей – ненадежная операция. Если два указателя содержат один и тот же адрес в памяти, но записанный в них разными способами, то они считаются различными. Зато можно проверить, ссылается ли указатель р на что–нибудь или нет путем сравнения p <> Nil.
Содержимое ячейки доступно через имя указателя. Чтобы обратиться к данным, находящимся по адресу, содержащемуся в указателе, используется символ “^”, который ставится сразу после имени ссылочной переменной. Эта операция называется операцией разыменования. Суть ее состоит в переходе от ссылочной переменной к значению, на которое она указывает. Пусть имеется следующее описание:
Var a, b: ^Real;
тогда в программе с переменными а^ и b^ допустимы все действия, что и с любыми переменными типа Real, например,
a^ := b^;
b^ := sin(a^); и т.д.
Память под любую динамически размещаемую переменную выделяется процедурой New(p). Только после выполнения процедуры New имеет смысл обращаться к ссылочным переменным. Параметром обращения к этой процедуре является типизированный указатель. В результате обращения указатель приобретает значение, соответствующее адресу, начиная с которого, можно разместить данные. Это адрес динамической области данных или кучи. Начало кучи хранится в стандартной переменной Heaporg, конец – в переменной Heapend. Текущую границу незанятой динамической памяти указывает указатель Heapptr. Если Var i^ integer, то после выполнения New(i) указатель i приобретет значение, которое перед этим имел указатель кучи Heapptr, а сам Heapptr увеличит свое значение на 2, так как длина внутреннего представления типа Integer, с которым связан указатель i, составляет 2 байта. Надо понимать, что на самом деле механизм выделения памяти сложнее, но для нас важно понять принципиальную схему (рис. 4).
Рис. 4. Расположение кучи в памяти ПК
Динамическую память можно не только забирать из кучи, но и возвращать обратно. Для этого используется процедура Dispose. Dispose(i) для предыдущего примера вернет в кучу 2 байта. Процедура Dispose не изменяет значение указателя, а лишь возвращает в кучу память, ранее связанную с этим указателем.
Проанализируем результаты вывода в следующей программе:
Var p1, ip : ^integer;
Begin
{1}Writeln(ip^,’ ‘,p1^);
{Выводятся значения, размещенные в динамической памяти по адресу Heapptr}
new(ip); new(p1);
{2}Writeln(ip^,’ ‘,p1^);
{Выводятся значения, размещенные в динамической памяти по адресу Heapptr}
ip^ := 7; p1^ := 20;
{3}Writeln(ip^,’ ‘,p1^); {7 20}
ip := p1;
{4}Writeln(ip^,’ ‘,p1^); {20 20}
dispose(ip);
{dispose(p1);}
{Использовать нельзя, так как р1 и ip указывают на одну и ту же ячейку памяти}
{5}Writeln(ip^,’ ‘,p1^); {20 20}
ip := nil;
{6}Writeln(ip^,’ ‘,p1^); {Случайное значение и 20}
End.
Несмотря на то, что первый оператор вывода предшествует процедуре New, ошибки не произойдет. Однако за указателями ip и p1 ячейки еще не закреплены и могут быть использованы другими динамическими переменными. Первый оператор является некорректным: работать с динамическими переменными можно только после выполнения процедуры New. Второй оператор Writeln выведет ту информацию, которая на момент начала выполнения программы расположена в ячейках с адресом Heapptr и Heapptr + 2. Следующим оператором в эти ячейки будет записана информация: 7 и 20 соответственно. Третий оператор вывода выдаст эти значения. Значение указателя ip изменилось после выполнения присваивания ip := p1. Теперь оба указателя указывают на одну и ту же ячейку памяти, в которой хранится число 20. К числу 7 нет доступа. Это значение теперь пассивно занимает память, т.е. 7 превратилось в мусор. Освободить память можно только для одного указателя ip или p1, так как оба эти указателя указывают на одну и ту же ячейку памяти. Пятый оператор Writeln выведет те же значения 20 и 20, но это лучше рассматривать как случайность. После выполнения процедуры Dispose(ip) значения ссылок ip и p1 не определено, как и значения ip^ и p1^. Выполнение шестого оператора Writeln также не приведет к ошибке, хотя его присутствие не имеет никакого смысла.
Приведем еще один пример.
Type PInteger = ^Integer;
Var
SomeNumber: Integer;
Begin
SomeNumber := 17;{присвоить SomeNumber 17}
SomeAddress := @SomeNumber; {SomeAddress указывает на SomeNumber}
Writeln(SomeNumber);{напечатать 17}
{Writeln(SomeAddress); не допускается; указатели печатать нельзя}
Writeln(SomeAddress^);{напечатать 17}
AnotherAddress := SomeAddress;{также указывает на SomeNumber}
AnotehrAddress^ := 99; {новое значение для SomeNumber}
Writeln(SomeNumber);{напечатать 99}
End.
Перед использованием указателей им всегда нужно присваивать значения. Если разыменовывается указатель, которому еще не присвоено значение, то считанные из него данные могут представлять собой случайные биты, а присваивание значения указываемому элементу может затереть другие данные, программу или даже операционную систему. Чтобы избежать разыменования указателей, которые не указывают на что-либо значащее, нужен некоторый способ информирования о том, что указатель недопустим. В Паскале предусмотрено зарезервированное слово Nil, которое можно использовать в качестве содержательного значения указателей, которые в данный момент ни на что не указывают. Указатель Nil является допустимым, но ни с чем не связанным. Перед разыменованием указателя нужно убедиться, что он отличен от Nil (не пуст).