Управление памятью – одна из главных задач ОС. Она критична как для программирования, так и для системного администрирования. Я постараюсь объяснить, как ОС работает с памятью. Концепции будут общего характера, а примеры я возьму из Linux и Windows на 32-bit x86. Сначала я опишу, как программы располагаются в памяти.

Каждый процесс в многозадачной ОС работает в своей «песочнице» в памяти. Это виртуальное адресное пространство, которое в 32-битном режиме представляет собою 4Гб блок адресов. Эти виртуальные адреса ставятся в соответствие (mapping) физической памяти таблицами страниц, которые поддерживает ядро ОС. У каждого процесса есть свой набор таблиц. Но если мы начинаем использовать виртуальную адресацию, приходится использовать её для всех программ, работающих на компьютере – включая и само ядро. Поэтому часть пространства виртуальных адресов необходимо резервировать под ядро.

image

Это не значит, что ядро использует так много физической памяти – просто у него в распоряжении находится часть адресного пространства, которое можно поставить в соответствие необходимому количеству физической памяти. Пространство памяти для ядра отмечено в таблицах страниц как эксклюзивно используемое привилегированным кодом, поэтому если какая-то программа пытается получить в него доступ, случается page fault. В Linux пространство памяти для ядра присутствует постоянно, и ставит в соответствие одну и ту же часть физической памяти у всех процессов. Код ядра и данные всегда имеют адреса, и готовы обрабатывать прерывания и системные вызовы в любой момент. Для пользовательских программ, напротив, соответствие виртуальных адресов реальной памяти меняется, когда происходит переключение процессов:

image

Голубым отмечены виртуальные адреса, соответствующие физической памяти. Белым – пространство, которому не назначены адреса. В нашем примере Firefox использует гораздо больше места в виртуальной памяти из-за своей легендарной прожорливости. Полоски в адресном пространстве соответствуют сегментам памяти таким, как куча, стек и проч. Эти сегменты – всего лишь интервалы адресов памяти, и не имеют ничего общего с сегментами от Intel. Вот стандартная схема сегментов у процесса под Linux:

image

Когда программирование было белым и пушистым, начальные виртуальные адреса сегментов были одинаковыми для всех процессов. Это позволяло легко удалённо эксплуатировать уязвимости в безопасности. Зловредной программе часто необходимо обращаться к памяти по абсолютным адресам – адресу стека, адресу библиотечной функции, и т.п. Удаленные атаки приходилось делать вслепую, рассчитывая на то, что все адресные пространства остаются на постоянных адресах. В связи с этим получила популярность система выбора случайных адресов. Linux делает случайными стек, сегмент отображения в память и кучу, добавляя смещения к их начальным адресам. К сожалению, в 32-битном адресном пространстве особо не развернёшься, и для назначения случайных адресов остаётся мало места, что делает эту систему не слишком эффективной.

Самый верхний сегмент в адресном пространстве процесса – это стек, в большинстве языков хранящий локальные переменные и аргументы функций. Вызов метода или функции добавляет новый кадр стека (stack frame) к существующему стеку. После возврата из функции кадр уничтожается. Эта простая схема приводит к тому, что для отслеживания содержимого стека не требуется никакой сложной структуры – достаточно всего лишь указателя на начало стека. Добавление и удаление данных становится простым и однозначным процессом. Постоянное повторное использование районов памяти для стека приводит к кэшированию этих частей в CPU, что добавляет скорости. Каждый поток выполнения (thread) в процессе получает свой собственный стек.

Можно прийти к такой ситуации, в которой память, отведённая под стек, заканчивается. Это приводит к ошибке page fault, которая в Linux обрабатывается функцией expand_stack(), которая, в свою очередь, вызывает acct_stack_growth(), чтобы проверить, можно ли ещё нарастить стек. Если его размер не превышает RLIMIT_STACK (обычно это 8 Мб), то стек увеличивается и программа продолжает исполнение, как ни в чём не бывало. Но если максимальный размер стека достигнут, мы получаем переполнение стека (stack overflow) и программе приходит ошибка Segmentation Fault (ошибка сегментации). При этом стек умеет только увеличиваться – подобно государственному бюджету, он не уменьшается обратно.

Динамический рост стека – единственная ситуация, в которой может осуществляться доступ к свободной памяти, которая показана белым на схеме. Все другие попытки доступа к этой памяти вызывают ошибку page fault, приводящую к Segmentation Fault. А некоторые занятые области памяти служат только для чтения, поэтому попытки записи в эти области также приводят к Segmentation Fault.

После стека идёт сегмент отображения в память. Тут ядро размещает содержимое файлов напрямую в памяти. Любое приложение может запросить сделать это через системный вызов mmap() в Linux или CreateFileMapping() / MapViewOfFile() в Windows. Это удобный и быстрый способ организации операций ввода и вывода в файлы, поэтому он используется для подгрузки динамических библиотек. Также возможно создать анонимное место в памяти, не связанное с файлами, которое будет использоваться для данных программы. Если вы сделаете в Linux запрос на большой объём памяти через malloc(), библиотека C создаст такую анонимное отображение вместо использования памяти из кучи. Под «большим» подразумевается объём больший, чем MMAP_THRESHOLD (128 kB по умолчанию, он настраивается через mallopt().)

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

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

image

И вот мы добрались до самой нижней части схемы – BSS, данные и текст программы. BSS и данные хранят статичные (глобальные) переменные в С. Разница в том, что BSS хранит содержимое непроинициализированных статичных переменных, чьи значения не были заданы программистом. Кроме этого, область BSS анонимна, она не соответствует никакому файлу. Если вы пишете static int cntActiveUsers, то содержимое cntActiveUsers живёт в BSS.

Сегмент данных, наоборот, содержит те переменные, которые были проинициализированы в коде. Эта часть памяти соответствует бинарному образу программы, содержащему начальные статические значения, заданные в коде. Если вы пишете static int cntWorkerBees = 10, то содержимое cntWorkerBees живёт в сегменте данных, и начинает свою жизнь как 10. Но, хотя сегмент данных соответствует файлу программы, это приватное отображение в память (private memory mapping) – а это значит, что обновления памяти не отражаются в соответствующем файле. Иначе изменения значения переменных отражались бы в файле, хранящемся на диске.

Пример данных на диаграмме будет немного сложнее, поскольку он использует указатель. В этом случае содержимое указателя, 4-байтный адрес памяти, живёт в сегменте данных. А строка, на которую он показывает, живёт в сегменте текста, который предназначен только для чтения. Там хранится весь код и разные другие детали, включая строковые литералы. Также он хранит ваш бинарник в памяти. Попытки записи в этот сегмент оканчиваются ошибкой Segmentation Fault. Это предотвращает ошибки, связанные с указателями (хотя не так эффективно, как если бы вы вообще не использовали язык С). На диаграмме показаны эти сегменты и примеры переменных:

image

Изучить области памяти Linux-процесса можно, прочитав файл /proc/pid_of_process/maps. Учтите, что один сегмент может содержать много областей. К примеру, у каждого файла, сдублированного в память, есть своя область в сегменте mmap, а у динамических библиотек – дополнительные области, напоминающие BSS и данные. Кстати, иногда, когда люди говорят «сегмент данных», они имеют в виду данные + bss + кучу.

Бинарные образы можно изучать при помощи команд nm и objdump – вы увидите символы, их адреса, сегменты, и т.п. Схема виртуальных адресов, описанная в этой статье – это т.н. «гибкая» схема, которая по умолчанию используется уже несколько лет. Она подразумевает, что переменной RLIMIT_STACK присвоено какое-то значение. В противном случае Linux использует «классическую» схему:

image

Комментарии (24)


  1. SLY_G
    26.08.2015 22:08
    +13

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


  1. Des333
    27.08.2015 00:47
    +3

    Мне кажется, что эта статья уже была переведена тут.


    1. SLY_G
      27.08.2015 02:17

      Да, жаль что они имя её изменили — поэтому я её и не нашёл.


      1. fsmorygo
        27.08.2015 11:10
        +3

        А ведь можно встроить в Хабрахабр маленькую проверку на уникальность: «Перевод этой статьи уже опубликован по адресу <url_here>».


      1. tangro
        27.08.2015 14:43

        Они там и остальные статьи попереводили.


  1. qmax
    27.08.2015 03:02

    А зачем строковые литералы в сегменте текста?


    1. khim
      27.08.2015 16:46

      Чтобы их менять было нельзя :-) В эру до NX бита этого хватало. Сейчас — они в отдельном сегменте.


  1. StrangerInRed
    27.08.2015 08:54
    +2

    Также он хранит ваш бинарник в памяти. Попытки записи в этот сегмент оканчиваются ошибкой Segmentation Fault. Это предотвращает ошибки, связанные с указателями (хотя не так эффективно, как если бы вы вообще не использовали язык С).


    Но я же… системное программирование…
    А все таки, кто же будет писать вам рантаймы на С#, Java?


    1. svd71
      27.08.2015 11:18

      одно другому не мешает.

      С# маппирует нужные библиотеки фреимворка .Net.

      Java по сути говоря имеет единственный исполняемый файл, который просто интерпретирует байткод. А всю осталъную структуру в виде куч, стека и прочих данных организует внутру запрошенного исполняемым файлом пространства.


      1. StrangerInRed
        27.08.2015 11:22

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


        1. svd71
          27.08.2015 12:07

          У него и написано на низкоуровневых. К ним можно отнести ассемблеры и «С». Но вот с учетом фреймворков и квази-интерпретаторов байткода я бы к низкоуровневым не относил никак.


          1. StrangerInRed
            27.08.2015 13:43
            +1

            Мы по ходу друг друга не поняли. В цитате выше, используется юмор типа «у нас вот есть защита от печальки с указателями, но она не так хороша как вообще не использование арфиметики указателей и ручного управления памятью», а я говорю, что до того, как появилось автоматическое управление памятью, его еще надо было написать, используя ручное. Т.е. все было бы хорошо, но ручное управление используется до сих пор, и как бы кто не писал «не используйте С», все равно будут использовать, потому что еще есть ниша.


    1. khim
      27.08.2015 16:47

      А все таки, кто же будет писать вам рантаймы на С#, Java?
      Кроме C# и Java есть и другие языки. Ada какая-нибудь вполне совмещает безопасность и низкоуровневость. Вот только писать на ней тяжко — много разных ограничений.


  1. aml
    27.08.2015 10:13
    +3

    Тема 64 бит не раскрыта


    1. gaelpa
      27.08.2015 22:21

      А я бы еще про физическую память почитал в таком формате с картинками. Со всякими DMA и прочими соответствиями адресов регистрам физических устройств.


      1. CodeRush
        27.08.2015 22:55

        Там будет слишком специфично для конкретного процессора даже внутри одной архитектуры.
        Вот тут есть немного, но почти без деталей, плюс в память отображается далеко не все, очень многое по прежнему реализовано только через CPU IO и PCI IO.
        Добавлю к написанному там, что 0xC0000000 — это TOLUD = 3Gb, популярное значение по умолчанию, но иногда его дают перенастроить через BIOS Setup.


        1. gaelpa
          28.08.2015 20:51

          На некоторых ARM SoC'ах почти всё на памяти, а там еще любопытности вроде гарвардской архитектуры…
          Ну и интересна не конкретная конкретика, а сами принципы.


      1. kwas
        31.08.2015 20:00
        +1

        people.freebsd.org/~lstewart/articles/cpumemory.pdf — What Every Programmer Should Know About Memory



  1. dyadyaSerezha
    28.08.2015 18:00
    -1

    Но если мы начинаем использовать виртуальную адресацию, приходится использовать её для всех программ, работающих на компьютере – включая и само ядро. Поэтому часть пространства виртуальных адресов необходимо резервировать под ядро.

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


    1. khim
      28.08.2015 20:45
      +2

      Строго говоря не следует (и в природе существует даже такое явление, как 4G/4G split), но на практике — это страшный костыль, который чудовищно замедляет работу ядра.

      Вы верно заметили, что приложениям не нужно обращаться к памяти ядра. Никак — ни «напрямую», ни «накривую». Иначе бы даже на 64-битной операционке 32-битные процессы имели бы доступ к 3GiB, а не 4GiB, как это происходит на практике.

      А вот ядру — нужно обращаться и к своей памяти и с к памяти процесса. Проще и быстрее всего это сделать если ядро может видеть оба диапазона одновременно и без извращений, потому разделение адресного пространства на «ядрёную» и «пользовательскую» — решение, принятое почти всеми операционками.


  1. AlexanderG
    11.09.2015 20:24

    Для чего ядро резервирует так много памяти? 1 ГБ «должно хватить всем» с лихвой. Если я не ошибаюсь, и в линуксе, и в окнах размеры ядра в памяти исчисляются всего лишь десятками мегабайт.

    Кстати, а /3GB разве не 3.5 ГБ процессу даёт на деле?


    1. khim
      12.09.2015 23:42

      Я понимаю, что «чукча не читатель — чукча писатель», но как бы размещать вопрос непосредственно после ранее написанного ответа на него странно.

      Глядя же на ваш послдений вопрос вспоминаешь бессмертное "Правда ли, что шахматист Петросян выиграл в лотерею тысячу рублей? Правда, только не шахматист Петросян, а футболист «Арарата» Акопян, и не тысячу, а десять тысяч, и не рублей, а долларов, и не в лотерею, а в карты, и не выиграл, а проиграл." У вас в голове, похоже, всё смешалось в кучу. Таки да, ключ /3GB даёт процессу именно-что 3GB. А 3.5GB (примерно) — это максимум, который может адресовать вся оперционка, если она 32-битная. А чтобы 32-битный процесс получил больше — ядро должно быть 64-битное.


      1. Adnako
        21.09.2015 23:01

        Таки да, ключ /3GB даёт процессу именно-что 3GB. А 3.5GB (примерно) — это максимум, который может адресовать вся оперционка, если она 32-битная.

        Но если вспомнить про PAE…