Салимóненко Дмитрий Александрович

Операционные системы

Задание 3: Изучение виртуального адресного пространства

(C, Linux)

Введение

В этом задании мы рассмотрим простой пример изучения структуры виртуальной оперативной памяти, как распределяются виртуальные адреса при загрузке программы (в виде процесса) в физическую оперативную память компьютера.

Примечание: Могу скромно отметить, что подобные задания даются студентам не во всех ВУЗах, изучающих программирование и, в частности, предмет "Операционные системы". Поэтому, я так думаю, Вам довольно крупно повезло: после осознания и выполнения этого задания Вы получите возможность закрепить на практике знания, полученные на лекциях по "Операционным системам" в области управления оперативной памятью. Собственно говоря, это - одна из наиболее сложных областей. Вы, что называется, немного как бы руками потрогаете эту самую виртуальную оперативную память. Ведь это здорово - четко понимать, с чем имеешь дело и как оно.

Немного теории

Вначале посмотрим на общую структуру виртуальной оперативной памяти.

Схема структуры виртуального адресного пространства процесса

Которую, конечно же, не стоит путать с физической оперативной памятью и, тем более, с дисковой или иной внешней памятью. Также отметим, что приведенная схема справедлива для Linux. Тогда как в других операционных системах, например, в Windows структура виртуальной памяти - немного иная. Хотя, принципы и смысл ее компонентов – те же самые.

Как видим, виртуальная оперативная память состоит из ряда сегментов, среди которых можно особо выделить сегмент кода (текст, на рисунке показан зеленым цветом), сегмент данных (включающий в себя инициализированные, неинициализированные данные, сегмент стека, а также так называемую кучу, представляющую собой динамическую память.

Сегмент неинициализированных данных, входящий в состав сегмента данных, называют еще сегментом BSS. Между стеком и кучей присутствует разрыв (достигающий, как правило, достаточно большого объема). В этом разрыве могут присутствовать также динамически подключаемые библиотеки и отображения файлов.

Таким образом, структура виртуальной оперативной памяти имеет определенные особенности. Код программы загружается. начиная с самых начальных адресов виртуальной памяти, а дальше идут данные и т.п.

Инициализированные данные – это константы, переменные (массивы и т.п.), которым в программе заданы значения. Т.е. их значения известны еще до момента загрузки программы в оперативную память.

Неинициализированные данные – это такие данные, которые лишь объявлены, но значения которых не являются заданными. Они хранятся в сегменте BSS.

Если же данные задаются уже при объявлении, то это – инициализированные данные.

В стек помещаются, как правило, данные двух типов:

  1. Локальные переменные (имена массивов и т.п.), которые сразу уничтожаются после того, как производится выход из задающей их функции (процедуры).
  2. Данные процесса, выполнение которого временно прервано операционной системой.

Для нас с Вами актуален первый вариант использования стека.

Куча - это динамическая память, выделяемая, при необходимости, процессу (программе, загруженной в оперативную память). Например, из кучи может быть выделена память для строки динамического массива. В языке С есть функции, которые производят выделение динамической памяти, к примеру, malloc().

Рассмотрим код программы на языке С, демонстрирующей работу с адресами виртуальной оперативной памяти в Linux. Код взят отсюда, но, немного изменен с целью большей наглядности.

/* Демонстрация адресов сегментов  кода, данных и стека, а также BSS и динамической памяти. */
#include <stdio.h>
#include <malloc.h> /* для определения ptrdiff_t в GLIBC */
#include <unistd.h>
#include <alloca.h> /* лишь для демонстрации */
#include <stdlib.h>

extern void afunc(void); /* функция, показывающая рост стека */

int bss_var; /* автоматически инициализируется в 0, должна быть в сегменте BSS */
int data_var = 42; /* инициализируется в не 0, должна быть в сегменте данных */
int main(int argc, char **argv) /* аргументы не используются */
{
char *p, *b, *nb;
system("clear");

printf("Text Locations (CODE Segments):\n");
printf("\tAddress of main: %p\n", main);
printf("\tAddress of afunc: %p\n", afunc);

printf("Stack Locations:\n");
 afunc();

p = (char*)alloca(10);
//  p = (char*)malloc(10);  // Что будет, если использовать эту функцию вместо alloca() ?
printf("\nВыделяем в СТЕКЕ место для элементов массива p[] при помощи функции alloca():\n");
 if (p != NULL) {
  printf("\t 1 elem. of alloca()'ed array: %p\n", &*p);
  printf("\tStart    of alloca()'ed array: %p\n", &p[0]);
  printf("\t 2 elem. of alloca()'ed array: %p\n", &p[1]);
  printf("\tEnd      of alloca()'ed array: %p\n", &p[12]);
 }

printf("\nData Locations:\n");
printf("\tAddress of data_var (инициализированная): %p\n", &data_var);

printf("BSS Locations:\n");
printf("\tAddress of bss_var (неинициализированная): %p\n", &bss_var);

b = sbrk((ptrdiff_t)64); /* увеличить адресное пространство для сегмента данных (за счет кучи)  */
nb = sbrk((ptrdiff_t)0);
printf("\nHeap Locations:\n");
printf("\tInitial end of heap: %p\n", b);
printf("\tSecond end of heap:  %p\n", nb);

b = sbrk((ptrdiff_t)-16); /* сократить его */
nb = sbrk((ptrdiff_t)0); // показать конец адресного пространства
printf("\tThird end of heap: %p\n", b);
printf("\tFinal end of heap: %p\n", nb);
}

void afunc(void)
{
 static int level = 0; /* уровень рекурсии */
 auto int stack_var; /* объявлена АВТОМАТИЧЕСКАЯ переменная, чтобы она точно была в стеке */

 if (++level == 6) /* избежать бесконечной рекурсии */
 return;

 printf("\tStack level     %d:  %p  address of stack_var (автомат.): %p\n", level, &level, &stack_var);

stack_var = 10000;
printf("\tStack level NEW %d:  %p  address of stack_var (автомат.): %p\n", level, &level, &stack_var);
   afunc(); /* рекурсивный вызов, чтобы продемонстрировать резервирование переменных на новых адресах в стеке */
}

Описание наиболее важных команд

Рассмотрим подробно те команды, которые могут вызвать затруднение.

void *alloca(size_t size)

Функция описана в заголовочном файле malloc.h

Она не определена стандартом ANSI С. Функция alloca() выделяет size байт памяти из стека системы (не из кучи!) и возвращает на него указатель. Если запрос на выделение памяти не выполнен, то возвращается нулевой указатель. Иными словами, при помощи этой функции можно управлять виртуальной памятью стека.

Память, которая выделена при помощи alloca(), автоматически освобождается при выходе из процедуры (функции, вызвавшей alloca()). Следовательно, указатель, возвращенный функцией ею, никогда не может служить аргументом функции free(). Т.е. применять освобождение памяти в данном случае НЕ НУЖНО (в отличие от функции malloc()).

Примечание. По техническим причинам, чтобы гарантировать сохранность стека, всякая функция, в которой используется вызов alloca(), должна содержать хотя бы одну локальную переменную, которой присваивается значение.

void *malloc(size_t size)

Прототип описан в файле stdlib.h

Данная функция возвращает адрес на первый байт области памяти размером size байт, которая была выделена из кучи (в этом ее отличие от alloca()). Если памяти недостаточно, чтобы удовлетворить запрос, функция malloc() возвращает нулевой указатель. Поэтому следует обязательно(!) проверять значение, которое возвращает эта функция, на предмет соответствия значению NULL, прежде чем пытаться использовать этот указатель. Ибо использование нулевого указателя может повлечь за собой сбои и даже влечет крах системы.

void *sbrk(int amount)

Прототип описан в файле alloc.h

Эта функция является нестандартной, т.е. не определена в ANSI С.

Функция наращивает на величину amount байт (или уменьшает, если задано отрицательное значение) память, выделенную для сегмента данных. В случае успеха возвращается указатель на старый адрес останова (break address). Иначе будет возвращено -1, при этом глобальная переменная errno будет равна ENOMEM (т.е. недостаточно памяти).

Как видим, аргумент этой функции является целым числом. Однако, рекомендуется вместо целого int использовать более универсальный заменитель целых чисел, одинаково работающий как в 32-разрядных, так и в 64-разрядных операционных системах. Это, в частности, тип ptrdiff_t.

Вызвав ее с аргументом, равным 0, можно определить, где в настоящее время заканчивается адресное пространство.

Тип данных ptrdiff_t

Это – аналог типа int,  базовый знаковый целочисленный тип языка Си/Си++. Размер типа выбирается таким образом, чтобы в него можно было записать максимальный размер теоретически возможного массива любого типа. На 32-битной системе ptrdiff_t будет занимать 32-бита, тогда как на 64-битной - 64-бита. При этом, как и в типе данных size_t, в переменную типа ptrdiff_t может быть безопасно помещен указатель, за исключением указателя на функцию класса. Кроме того, при вычитании указателей (pt1-pt2) один из другого тип ptrdiff_t будет представлять тип результата выражения.

Этот тип целесообразно применять для инициализации переменных счетчиков циклов, индексов массивов, хранения размеров, адресной арифметики. Тип ptrdiff_t имеет синоним intptr_t. Этот синоним более похож на целый тип, судя по аббревиатуре.

Формат вывода (команды printf)

Этот формат позволяет выводить на экран значения указателей, т.е. виртуальных адресов, которые имеют данные, объявленные при помощи указателей. Например, написав

printf("%d", level);

- мы выведем на экран значение целой переменной level (равное, например, 1, 153, 2005 и т.д.).

Тогда как, написав

printf("%p", &level);

- выведем значение базового виртуального адреса (указатель на переменную level), примерно следующего вида: 0x602060.

Формат %p реализован специально для того, чтобы выводить указатели на данные (переменные, массивы и др.).

Результат работы программы

Результат работы приведенного выше кода представлен на рисунке. Вначале

Результаты работы программы

Вначале идут адреса самой программы (main) и процедуры afunc(). Они равны, соответственно, 0x400666 и 0x400885. Как видим, они расположены в области нижних адресов, т.е. в области программного кода.

Попробуем отобразить шестнадцатеричные адреса в их байтовом десятичном представлении

Шестнадцатеричный адрес может быть несколько непривычен для восприятия. Кроме того, не совсем понятно, какой именно области виртуальной памяти соответствует тот или иной (виртуальный) адрес. Поэтому целесообразно бы перевести соответствующие шестнадцатеричные значения – в байтовые, хотя бы для наглядности. Для указанного преобразования можно использовать следующий программный код:

// Вначале получаем указатель на main (т.е. ее адрес), затем преобразуем его в целый тип (в байты)
int i;
sprintf(str, "%p", main);
// sscanf("0xffff", "%x", &i);
sscanf(str, "%x", &i);
printf("В байтах= %d\n",  i);

Как уже говорилось, %p представляет собой формат, который выводит указатель на соответствующую функцию, процедуру или переменную. Функция sprintf используется для вывода в строчное значение (точнее, не в строку, а в символьный массив; в языке С НЕТ понятия строк, так как нет типа данных string или аналогичного).

К сожалению, со «строками» в С (в отличие от С++) присутствует терминологическая путаница – вот уже на протяжении многих десятков лет, с самого момента создания этого языка. Почему-то, с одной стороны, строки, как функциональная особенность, отсутствуют, хотя вслух и письменно - везде и всюду заявляется о них. То, что понимается под «строкой», на самом деле, является символьным массивом. Т.е. массивом из литералов вида ‘a’, ‘b’, ‘c’, которые являются элементами данных типа char. При этом, для «простоты» (по ложному мнению разработчиков языка С) даже допускается конструкция навроде “abc”. Т.е. вместо одинарных кавычек используются двойные. Это, по сути, некоторое искажение смыслы языковой конструкции, допускающей неявное существование «строк», с отсутствующим, повторимся, типом данных для этого.

И это в противовес тому, что уж где-где, а в С существует ТАКОЕ огромное множество разных типов данных, что. Одних целых типов и правниваемых к ним – сколько. Поэтому, почему разработчики не ввели в язык С элемент данных под названием «строка», хотя повторюсь, дали, все-таки, возможность использования «строк» - мне совершенно непонятно. И, увы и ах, так оно и будет дальше. Ибо иначе, если пытаться кардинально менять язык, добавлять новый тип данных в его конструкцию, скорее всего, потом придется переписывать все то, что написано на С. А написано там – ОЧЕНЬ многое. Скажу даже так: на С написано ВСЕ ОСНОВНОЕ. Поэтому язык С++, дополняя С, за редким исключением, не меняет ничего в С, а лишь добавляет новую функциональность и возможности.

Поэтому, да, с языком С, конечно, нельзя поступать так, как поступают с РНР (делая несовместимыми разные версии, скажем, РНР3, РНР5, РНР7), отчасти, с javascript, Python и т.д. И как, я в этом убежден, поступят в недалеком будущем с разными Haskel, Kotlin, Go… И многочисленными другими аналогичными языками, коих развелось нынче – как (…). Это, грубо говоря, языки-однодневки. То ли – переходная стадия к искусственному интеллекту, то ли – так, для развлечения программистов, то ли для ускорения процесса разработки ценой медленной работы получающихся так называемых «программных продуктов».

Впрочем, если пользователей «устраивает» такое положение дел – так бога ради, видимо. Это уж – как и везде, не только в программировании.

Поэтому с ними, да - можно поступать, как заблагорассудится, а можно и вовсе вдруг прекратить поддержку. Мало для кого будет это актуально. Но, еще раз, нельзя так поступать с основами. Да, с ОСНОВАМИ, как раз на которых вышеуказанные (и многие другие) языки – написаны.

Т.е. понятно, что, мол, привычно, «удобно» и т.п. Но, я говорю – о системности, строгости и типизированности языка (С). Точнее, о том, что эти критерии в данном случае не выполняются в должной мере.

Функция sscanf, работая со строкой с символьным массивом str, как с шестнадцатеричным числом (формат %x), переводит его в переменную i, которая должна иметь целый тип.

Кстати, при достаточно больших значениях шестнадцатеричных чисел (что актуально для виртуальных адресов в 64-разрядных операционных системах) целого типа может быть недостаточно. Тогда следует использовать

long unsigned int i

- длинный беззнаковый целый тип. Форматом вывода его значения является %lu (если же захотим вывести не само значение, а указатель, то, конечно, применяем формат %p).

На рисунке приведен примерный вид программного кода, который получится в результате реализации преобразования шестнадцатеричных виртуальных адресов в байты.

Результаты работы программы 1

Анализ результатов работы программы

Как видим, Процедура main (главная функция) и afunc() разместились в начале виртуального адресного пространства. Очевидно, это – сегмент кода.

Стек разместился в ОЧЕНЬ высоких адресах виртуальной памяти, по порядку величины, в области 140 ТБ. По всей видимости, это связано с фактом 64-разрядности моей операционной системы и адреса, соответственно, 12-значные. В 32-разрядной операционной системе, скорее всего, адреса будут гораздо более низкими.

Программа размещает в стеке, во-первых, переменную stack_var. Так как процедура afunc() является рекурсивной, при каждом вызове ее переменная stack_var инициализируется вновь и содержится в (виртуальной) оперативной памяти до тех пор, пока вызвавший ее (рекурсивный) вызов не закончит работу. А работу он может закончить только тогда, когда завершится самый последний вызов. Иными словами, выстраивается цепочка рекурсивных вызовов и, соответственно, переменные stack_var будут содержаться в памяти вплоть до завершения работы вызовов.

Обратим внимание, что переменные stack_var - РАЗНЫЕ для каждого вызова, несмотря на одинаковое название. Ибо каждая из них инициализируется заново при очередном вызове. Соответственно, потому память выделяется заново каждый раз при очередном вызове. Хотя, наверное, это – и так очевидно.

Обратим внимание, что адрес каждой «версии» переменной stack_var снижается на каждом очередном рекурсивном вызове. Судя по схеме, видим, что стек заполняется СВЕРХУ ВНИЗ. Именно поэтому адрес вновь поступившего в стек элемента данных НИЖЕ, чем адрес предыдущего (который поступил в стек ранее).

Также видим, что адрес первого элемента массива p[] (т.е. элемент p[0]) совпадает адресом самого массива. Кроме того, адреса следующих элементов УВЕЛИЧИВАЮТСЯ. Например, адрес p[1] увеличен на 1, а адрес p[12] увеличен на 11. В самом деле,

0x7fffa68008a9c - 0x7fffa68008a91 = 0x11.

Таким образом операционная система резервирует место для хранения элементов массива p[], несмотря на то, что пока, на данный момент, инициализируются лишь его элементы под номерами 0, 1, 12. Почему так? Потому – что стек.

А почему адреса в стеке УВЕЛИЧИВАЮТСЯ? Вроде бы, раз стек растет вниз, они должны бы снижаться, как в примере выше, с переменными stack_var? Дело в том, что операционная система, «зная» поведение стека и последовательность его заполнения, предусмотрительно (на низком уровне) сортирует поступающие туда данные в ОБРАТНОМ ПОРЯДКЕ и уже потом записывает в стек.

Т.е. перед записью происходит заблаговременная сортировка данных. Это делается для того, чтобы данные впоследствии считывались в правильном порядке, т.е. чтобы не нужно было их сортировать при считывании.

А почему же тогда в вышеприведенном примере с переменными stack_var порядок записи в стек, все же, был в последовательности «вниз»? Видимо, потому, что эти переменные не были никак связаны друг с другом: ведь они принадлежали к РАЗНЫМ рекурсивным вызовам функции afunc(). Кстати, это такой порядок записи в стек донельзя лучше подходит именно для рекурсивных вызовов. В самом деле, при рекурсии переменные, поступившие в стек в первых вызовах функции, потребуются позже, чем переменные, поступившие в конце. Ведь рекурсивные вызовы завершаются в обратном порядке по сравнению с их возникновением.

Тогда как p[0], p[1],…, p[12] – это элементы ОДНОГО массива. Который будет, скорее всего, считываться в рамках работы одного вызова той или иной функции. Именно поэтому целесообразно заранее расположить их в стеке так (т.е. в обратном порядке), чтобы потом при считывании не было проблем с сортировкой.

Наконец, посмотрим на инициализированные и неинициализированные (находящиеся в сегменте BSS) переменные. Видим, что адреса и тех, и других лежат чуть выше, чем адреса процедур main() и afunc(). Однако, разница не очень существенная:

0x7ac000-0x400666 = 3848602 Байт = 3,85 МБ.

Т.е. разница, и в самом деле, невысока, по сравнению с общим объемом физической оперативной памяти (несколько гигабайт). Это означает, что сегмент данных (в том числе и BSS) располагается практически рядом с сегментом кода нашей программы. Что и подтверждает приведенную выше теоретическую схему.

Также следует обратить внимание, что факт инициализации переменной bss_var не обуславливает ее перемещения на другой адрес виртуальной оперативной памяти: ее виртуальный адрес не меняется.

Таким образом, данная программа дает возможность демонстрации распределения адресов виртуальной памяти, которые назначаются операционной системой при ее (программы) запуске.

Окончательные замечания

  1. В программе приведены «совсем низкоуровневые» системные вызовы, в частности, alloca() и, особенно, sbrk(). Что касается sbrk() – это уже практически Ассемблер, только в формате С. Это – очень простой системный вызов, предназначенный для управления кучей оперативной памяти. Применять эти вызовы в реальных программах, как правило, не стоит, если Вы, конечно, не являетесь разработчиком операционных систем, низкоуровневых драйверов и т.п. Не стоит применять по той причине, что их использование является опасным. При недосмотре программиста – возможны ошибки сегментации, переполнение стека и т.д., что может привести к очень плохим последствиям, вплоть до краха системы.
    Вместо sbrk() (или brk()) целесообразнее применять (тоже низкоуровневый, но, уж не настолько) системный вызов malloc() и его аналоги. Он гораздо менее опасен (при неграмотном применении). Кстати, malloc() сам использует указанные системные вызовы. Т.е. эта функция написана с их использованием. С другой стороны, выделение оперативной памяти при ее использовании осуществляется не вручную (как при помощи sbrk()), а автоматически – самой операционной системой (программист задает лишь размер выделяемой памяти, и все). Точнее, ее ядром. Последнее представляет собой, как правило, усовершенствованный, оптимизированный код. Какой попало программист может не быть в состоянии создать код, аналогично оптимизированный.
  2. В программе есть команды для управления как кучей, так и стеком. Иными словами, программист, при желании, может вручную распределять память. Например, по каким-то причинам, может статься, что для временного хранения данных конкретной программы лучше использовать стек, тогда как операционная система может, по умолчанию, поместить эти данные в кучу.
  3. Естественно, указанные рассуждения абсолютно не касаются «программистов», которые вообще не имеют понятия о вышесказанном. Для которых «проще» написать одну-две строчки, скажем, инициализации массива, чем вникать в «дебри» виртуальной оперативной памяти. Это касается тех, кто работает на C# и/или, отчасти, С++. Не говоря уже о разного рода java, Python, PHP и т.д. Там, да, конечно. Главное, чтобы, мол, «выполняло задачу» (ну, т.е. чтобы работало как-то там, да и ладно). А где там какая куча или сегмент данных – да зачем, типа, это нужно.
  4. Естественно, данное задание – явно не для таких. Тем более, что работой в высокоуровневых языках в (возможно, недалеком) будущем станет заниматься искусственный интеллект или т.п., посему любители Си-шарпов если и будут нужны, то в довольно ограниченном количестве. А вот указанные низкоуровневые особенности – это автоматизировать гораздо сложнее. Если подразумевать автоматизацию применительно к конкретной ситуации (ибо в общем случае-то она уже давно выполнена разработчиками тех или иных операционных систем). Но, именно она – ключ к экономной, быстрой работе программ, ключ к минимизации использования ими ресурсов компьютера. Потому-то и известная антивирусная программа NOD32 практически не замедляет работу компьютера (ну, пока, по крайней мере). Потому как написана на Ассемблере (именно потому, скажем, антивирусной программе под названием "Касперский" по быстродействию до нее - как до луны, наверное). И это – в век, казалось бы, широкого распространения разного рода высокоуровневых языков и прикладных программных сред - разных яв, котлинов и пр. Вот такие дела.
  5. Итак, еще раз: низкоуровневые эксперименты с оперативной памятью могут быть опасными, приводящими к краху системы. Так что – аккуратнее. Если Вы не обладаете достаточной квалификацией в области операционных систем и опасаетесь за последствия – не стоит браться за это дело.

Задание для самостоятельной разработки:

  1. Выполните предложенное преобразование формата вывода виртуальных адресов на экран: вместе шестнадцатеричными значениями вида 0х00abcd… должны выводиться и адреса в байтах.
  2. После запуска и окончания работы программы у Вас должен получиться результат, аналогичный приведенному на рисунке. При этом должны выводиться виртуальные адреса как в шестнадцатиричном, так и в десятичном представлении. На основании полученных данных, постройте (например, в Word, но, можно и вручную, на листе бумаги) аналогичную схему с указанием конкретных виртуальных адресов. Т.е. должны быть обозначены адреса границ сегментов кода, стека, данных, а также - кучи. Если каких-либо данных не хватает для построения схемы - объясните, каких именно и почему.
  3. Позапускайте программу несколько раз. Как видно, некоторые виртуальные адреса каждый раз меняются. С чем это связано, как это влияет на безопасность работы компьютера?
  4. Попробуйте (только в целях тестирования!) вызвать переполнение стека и кучи. Для этого потребуется написать циклы, которые будут добавлять (в кучу и стек) все более новые и новые переменные (элементы массивов). Сделайте вывод на экран через каждые 10 ГБ выделенных адресов, назначенным куче и стеку (в 64-разрядных операционных системах). Проанализируйте результаты.

С уважением, Салимоненко Д.А.