---
title: "Адресное пространство: иллюзия приватной памяти"
description: "Два процесса работают в одно и то же время. У обоих объект лежит по адресу 0x14000010100, и при этом по этому адресу у каждого своё значение. Значит, адрес, который видит…"
canonical: https://graphlms.ru/os/book/address-space
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Адресное пространство: иллюзия приватной памяти

> Источник: https://graphlms.ru/os/book/address-space — курс «Операционные системы», GraphLMS.

## Один адрес, два значения

Начнём блок про память с эксперимента. Программа на Go создаёт объект в куче,
записывает туда свой PID, секунду ждёт и печатает адрес объекта и значение по нему:

```go
package main

import (
	"fmt"
	"os"
	"time"
)

func main() {
	p := new(int) // объект в куче
	*p = os.Getpid()
	time.Sleep(time.Second) // чтобы оба процесса точно работали одновременно
	fmt.Printf("PID %d: адрес %p, значение %d\n", os.Getpid(), p, *p)
}
```

Запустим два экземпляра одновременно (`./a & ./a; wait`). Один из запусков на
macOS дал:

```text
PID 72868: адрес 0x14000010100, значение 72868
PID 72869: адрес 0x14000010100, значение 72869
```

Два процесса работают в одно и то же время. У обоих объект лежит по адресу
`0x14000010100`, и при этом по этому адресу у каждого своё значение. Значит, адрес,
который видит программа, — не номер ячейки в микросхеме памяти. Каждому процессу
ОС показывает его **собственную** память, и одинаковые числа у разных процессов
ведут в разные места.

(Совпадение адресов не гарантировано на каждом запуске, но случается часто:
рантайм Go начинает кучу с одного и того же адреса, на Linux/amd64 это
`0xc000000000`.)

## Адресное пространство

**Адресное пространство** (address space) — это память в том виде, в каком её видит
процесс: непрерывный диапазон адресов, который принадлежит только ему. Адреса из
этого диапазона называют **виртуальными**. Настоящие номера ячеек в микросхемах RAM
называют **физическими адресами**.

На каждое обращение к памяти специальный блок процессора, **MMU** (memory management
unit), переводит виртуальный адрес в физический по таблице, которую подготовила ОС.
У каждого процесса таблица своя.

```text
  процесс A               MMU + таблица A           физическая RAM
  читает 0x14000010100 ──────────────────────────▶  ячейка 0x7A31100
  процесс B               MMU + таблица B
  читает 0x14000010100 ──────────────────────────▶  ячейка 0x1C08100
```

Зачем такие сложности, а не просто дать программам физическую память?

**Защита.** Если бы программы видели физическую память, ошибка в указателе одного
сервиса могла бы испортить данные базы на той же машине или само ядро ОС. Когда у
каждого процесса свои виртуальные адреса, назвать чужую память ему просто нечем.

**Простота программирования.** Компилятор и рантайм могут считать, что программа
всегда загружена по одним и тем же адресам, и не думать, какой кусок RAM ей
достался в этот раз.

**Гибкость для ОС.** ОС может переложить данные процесса в другое место RAM или
временно на диск, поменяв только таблицу. Программа этого не заметит. Ещё ОС может
пообещать процессам в сумме больше памяти, чем есть физически, и выдавать реальную
память только при первом обращении. Об этом в главах
[за пределами физической памяти](/os/book/swapping) и
[память в проде](/os/book/memory-cloud).

## Что лежит внутри

В адресном пространстве есть несколько областей с разным назначением. Классическая
учебная картинка:

```text
  большие адреса
  ┌──────────────────────────┐
  │  СТЕК (stack)            │  кадры вызовов функций, локальные переменные
  │      │ растёт к меньшим  │
  │      ▼ адресам           │
  ├──────────────────────────┤
  │                          │
  │  свободный диапазон      │  адреса не используются, памяти за ними нет
  │                          │
  ├──────────────────────────┤
  │      ▲ растёт к большим  │
  │      │ адресам           │
  │  КУЧА (heap)             │  память, выделенная во время работы
  ├──────────────────────────┤
  │  ДАННЫЕ (data, bss)      │  глобальные переменные
  ├──────────────────────────┤
  │  КОД (text)              │  машинные инструкции, только чтение и исполнение
  └──────────────────────────┘
  малые адреса (самая первая страница не отображена)
```

- **Код** — машинные инструкции программы. Помечен «только чтение и исполнение»:
  попытка записи сюда закончится аварией.
- **Данные** — глобальные и статические переменные. Их размер известен при
  компиляции.
- **Куча** — память, которую программа просит во время работы, когда размер заранее
  неизвестен. В учебной модели растёт в сторону больших адресов.
- **Стек** — кадры вызовов функций: аргументы, адрес возврата, локальные
  переменные. Растёт в сторону меньших адресов, навстречу куче.
- **Свободный диапазон** между кучей и стеком огромен и почти весь не используется.
  Это просто адреса, за которыми нет памяти. Обращение туда — ошибка.

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

Самая первая страница адресов (около нуля) специально ничем не занята. Поэтому
разыменование нулевого указателя всегда ловится: обращение к адресу 0 попадает в
неотображённую память. В Go это знакомая паника:

```text
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x...]
```

`SIGSEGV` — сигнал, который ядро ОС посылает процессу, обратившемуся к адресу, где
у него нет памяти (segmentation fault). Рантайм Go перехватывает этот сигнал и
превращает его в панику.

## Как выглядит настоящее адресное пространство

Учебная картинка — модель. В Linux можно посмотреть на реальное адресное
пространство любого процесса в файле `/proc/<PID>/maps`. Сокращённый вывод
`cat /proc/self/maps` (здесь `self` — сам процесс `cat`; адреса у вас будут другими):

```text
55d0c1a00000-55d0c1a02000 r--p 00000000 08:01 1835   /usr/bin/cat
55d0c1a02000-55d0c1a07000 r-xp 00002000 08:01 1835   /usr/bin/cat
55d0c1a0b000-55d0c1a0c000 rw-p 0000a000 08:01 1835   /usr/bin/cat
55d0c2b4e000-55d0c2b6f000 rw-p 00000000 00:00 0      [heap]
7f2a3c400000-7f2a3c428000 r--p 00000000 08:01 2130   /usr/lib/x86_64-linux-gnu/libc.so.6
7f2a3c428000-7f2a3c5bd000 r-xp 00028000 08:01 2130   /usr/lib/x86_64-linux-gnu/libc.so.6
7ffc9e3f1000-7ffc9e412000 rw-p 00000000 00:00 0      [stack]
7ffc9e5fc000-7ffc9e5fe000 r-xp 00000000 00:00 0      [vdso]
```

Каждая строка — непрерывная область: диапазон адресов, права (`r` чтение, `w`
запись, `x` исполнение, `p` приватная копия) и откуда она взялась. Видно код
программы (`r-xp`), её данные (`rw-p`), кучу, библиотеку C, стек и vDSO из главы
[прямое исполнение с ограничениями](/os/book/limited-direct-execution).

Отличия от учебной картинки:

- **Адреса случайные.** При каждом запуске ОС сдвигает код, кучу, стек и библиотеки
  на случайную величину. Это **ASLR** (address space layout randomization), защита от
  эксплойтов: атакующему труднее угадать, где лежит нужный код. (Стек и
  библиотеки сдвигаются всегда; код и глобальные переменные — только если бинарник
  собран как позиционно-независимый, PIE. Go на macOS собирает PIE, на Linux/amd64
  по умолчанию нет.)
- **Областей больше трёх.** Каждая разделяемая библиотека, каждый файл,
  отображённый в память, каждый стек потока — своя область.
- **Диапазон огромный, но не 2⁶⁴.** На x86-64 процессу доступна нижняя половина
  48-битного пространства, 2⁴⁷ байт = 128 ТиБ. Верхняя половина принадлежит ядру ОС.
  Процесс не может к ней обратиться, хотя она отображена в каждом процессе.

## Изоляция

Процесс не может прочитать чужую память, и дело не в договорённости, а в устройстве.
Все виртуальные адреса процесса переводятся через его собственную таблицу, а в ней
есть только его страницы. Если он наберёт случайное число как адрес, возможны два
исхода:

1. адрес попал в его собственную область — он прочитает или испортит **свои**
   данные;
2. адрес никуда не отображён — MMU сообщит ядру ОС, и процесс получит `SIGSEGV`.

Третьего варианта, «попал в память соседа», нет. Таблицы перевода может менять
только ядро ОС: это делается привилегированными инструкциями, которые запрещены в
пользовательском режиме (см. [прямое исполнение](/os/book/limited-direct-execution)).

Есть и обратная сторона: процессы, которым нужно обмениваться данными, не могут
просто передать указатель. Им нужны каналы ОС (пайпы, сокеты) или специально
запрошенная общая память.

## Что это значит для Go

Go-программа живёт в таком же адресном пространстве, но раскладывает его
по-своему.

- **Где окажется переменная, решает компилятор.** Локальная переменная, на которую
  не остаётся ссылок после выхода из функции, может жить на стеке. Если ссылка
  «убегает» (вы вернули `&x`), компилятор кладёт переменную в кучу. Этот анализ
  называется **escape analysis**, подробно в следующей главе
  [API памяти](/os/book/memory-api).
- **Стеки горутин не лежат в области `[stack]`.** У каждой горутины свой маленький
  стек (стартует с 2 КБ и растёт при необходимости). Эти стеки рантайм выделяет из
  своей собственной памяти, полученной у ОС, рядом с кучей. Область `[stack]` в
  `/proc/<PID>/maps` — это стек главного потока ОС, а не горутин.
- **Куча Go — тоже набор областей,** которые рантайм запрашивает у ОС через `mmap`
  большими кусками и дальше раздаёт объектам сам. Как устроена раздача — в главе
  [управление свободной памятью](/os/book/free-space).

Модель памяти Go (какие записи одной горутины видны другой) — отдельная тема,
она разобрана в курсе Go: [модель памяти](/go/book/memory-model).

## Частые ошибки

- Считать, что адрес из `%p` — физический. Все адреса, которые видит программа,
  виртуальные; физических она не видит никогда.
- Думать, что большой диапазон адресов означает большой расход памяти. Свободный
  диапазон между кучей и стеком ничего не стоит, пока к нему не обращаются.
- Объяснять segfault как «процесс залез в чужую память». Он залез в адрес, где
  **у него** нет памяти; до чужой памяти он добраться не может.

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/address-space

## Проверьте себя

1. Два процесса одновременно печатают одинаковый адрес своей переменной. Может ли
   запись в эту переменную в одном процессе изменить значение в другом?
2. Почему разыменование nil-указателя в Go надёжно ловится, а не портит случайные
   данные?
3. В `/proc/<PID>/maps` строка с правами `r-xp`. Что там скорее всего лежит?
4. Сколько байт виртуальных адресов доступно процессу на x86-64 с 48-битной
   адресацией?

Ответы. (1) Нет, одинаковые виртуальные адреса переводятся через разные таблицы в
разные физические ячейки. (2) Страница около адреса 0 никогда не отображается,
обращение к ней всегда вызывает `SIGSEGV`. (3) Исполняемый код программы или
библиотеки. (4) 2⁴⁷ байт, 128 ТиБ (нижняя половина).

## Что спрашивают на собесе

- Что такое виртуальное адресное пространство и зачем оно нужно: изоляция, простота
  для программиста, гибкость для ОС (перемещение, ленивое выделение).
- Чем виртуальный адрес отличается от физического и кто их переводит (MMU по
  таблицам ОС).
- Почему один процесс не может прочитать память другого.
- Раскладка адресного пространства: код, данные, куча, стек; куда растут куча и
  стек; что такое ASLR.
- Что такое segmentation fault и почему nil-указатель всегда ловится.
- Где в Go живут переменные и стеки горутин.

## Вопросы для самопроверки

**Квиз «Адресное пространство»**

1. Что такое виртуальный адрес?
   - Адрес файла на диске
   - Адрес, который видит программа; железо переводит его в физический (верно)
   - Номер процесса (PID)
   - Настоящий номер ячейки в микросхеме RAM
   - Пояснение: Виртуальный адрес — это придуманная для процесса нумерация. Программа оперирует им, а MMU по таблицам ОС переводит его в настоящий физический адрес в RAM.
2. Куда растут стек и куча в классической раскладке адресного пространства?
   - Оба вверх, к большим адресам
   - Оба вниз, к нулю
   - Куча вверх, стек вниз — навстречу друг другу через дыру (верно)
   - Стек вверх, куча вниз
   - Пояснение: Их сажают на противоположные концы и пускают навстречу, чтобы они делили одну общую дыру-резерв. Куча растёт вверх (к большим адресам), стек — вниз.
3. Почему один процесс не может прочитать память другого процесса?
   - У каждого своя таблица перевода: его виртуальные адреса ведут только на его физические страницы (верно)
   - Процессы вежливо договариваются не лезть друг к другу
   - Память шифруется паролем процесса
   - Это возможно, ограничений нет
   - Пояснение: Изоляцию обеспечивает связка ОС и железа: виртуальные адреса процесса отображены только на его собственную физическую память. Назвать чужой адрес он просто не может.
4. Что из перечисленного — выгоды абстракции адресного пространства? (выберите все верные)
   - Изоляция и защита процессов друг от друга (верно)
   - Программист пишет код так, будто память своя и с нуля (верно)
   - ОС может переместить данные в RAM прозрачно для программы (верно)
   - Программа получает прямой доступ к физическим ячейкам RAM
   - Пояснение: Адресное пространство даёт изоляцию, простоту для программиста и перемещаемость данных. А вот прямого доступа к физической RAM программа как раз НЕ получает — в этом и смысл.
5. Какой блок железа на лету переводит виртуальные адреса в физические?
   - CPU cache
   - GPU
   - DMA-контроллер
   - MMU (Memory Management Unit) (верно)
   - Пояснение: MMU (блок управления памятью) на каждое обращение к памяти переводит виртуальный адрес в физический по таблицам, которые ведёт ОС.
6. Сколько гигабайт виртуального адресного пространства получает 32-битный процесс (адреса от 0 до 2^32 − 1)?
   - Ответ: 4
   - Пояснение: 2^32 байт — это 4 ГБ. Столько адресов процесс может назвать, сколько бы физической памяти ни стояло в машине: на ноутбуке с 2 ГБ RAM у процесса всё равно те же 4 ГБ виртуальных адресов, а большая часть из них просто ни на что не отображена.
