# Указатели и память: от первого байта до последнего промаха кэша

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

Published: 25 мая 2026 г.
Canonical HTML: https://ru.glazastov.com/pointers-and-memory
Markdown: https://ru.glazastov.com/pointers-and-memory.md

Память относится к самому фундаментальному, что делает компьютер, и наименее понятному для большинства программистов. Эта статья начинается с самого начала (что такое байт, что такое адрес) и заканчивается проблемами, которые проявляются только в производственной среде, под нагрузкой, на многоядерных машинах. Никакой конкретный язык программирования не предполагается. Код на C появляется изредка как тончайший слой над аппаратной моделью, а не как основная тема.

## Часть 1. Пронумерованные ячейки памяти

Представьте очень длинный ряд маленьких ячеек. Каждая ячейка хранит ровно восемь переключателей, восемь **битов**. Вместе эти восемь переключателей могут кодировать любое значение от 0 до 255 (2⁸ = 256 возможностей). Эта восьмибитная единица называется **байтом**, наименьшим индивидуально адресуемым кусочком RAM в любом современном компьютере.

Пронумеруйте каждую ячейку последовательно, начиная с нуля. Этот номер является её **адресом**. На 64-битной машине адреса представляют собой 64-битные числа; машина теоретически может адресовать до 2⁶⁴ различных байт, около 18 эксабайт.

**Указатель представляет собой обычную переменную, чьё значение является адресом в памяти.**

Это полное определение. Указатель представляет собой просто число, интерпретируемое как «иди посмотри в эту ячейку». Всё остальное (синтаксис, системы типов, правила владения) строится поверх этой одной идеи.

### Зачем хранить адрес вместо значения?

Три причины:

**1. Большие данные.** Копировать структуру размером 100 МБ при каждом вызове функции катастрофично. Передайте указатель (8 байт) и получатель работает с оригиналом.

**2. Общая мутация.** Два фрагмента программы, которым нужно видеть изменения друг друга, должны договориться об одном месте в памяти. Указатель служит таким соглашением.

**3. Динамические структуры.** Связный список или дерево не имеют фиксированного размера во время компиляции. Структура строится во время выполнения; узлы выделяются и соединяются указателями.

### Размер указателя

На 64-битной системе указатель всегда занимает 8 байт, независимо от того, на что он указывает. На 32-битной системе указатели занимают 4 байта, ограничивая адресуемое пространство до 4 ГБ.

## Часть 2. Арифметика указателей

**Тип** указателя не влияет на хранящийся в нём адрес. Но он влияет на арифметику с указателем.

Если `p` является указателем на 4-байтовое целое и вы вычисляете `p + 1`, вам нужен адрес _следующего_ целого, на 4 байта дальше, а не на 1. Компилятор знает размер целевого типа и автоматически масштабирует смещение.

```c
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;

// Эти две записи идентичны:
int x = arr[3];        // стандартная индексация
int y = *(p + 3);      // арифметика указателей — тот же результат: 40
```

Индексация массива представляет собой арифметику указателей в маскировке. Запись `arr[i]` буквально определена как `*(arr + i)`. Поэтому выход за границы не обнаруживается автоматически: арифметика просто даёт адрес за концом массива, и процессор прочитает или запишет туда без предупреждения.

## Часть 3. Взгляд процесса на память

Ваша программа не видит физическую RAM. Она видит **виртуальное адресное пространство**, приватное отображение, которое операционная система создаёт специально для этого процесса. Каждый процесс получает собственное виртуальное адресное пространство, изолированное от всех остальных.

Виртуальное адресное пространство разделено на различные **регионы**:

```
┌─────────────────────────┐  Высокие адреса
│         Ядро            │  (зарезервировано — недоступно программам)
├─────────────────────────┤
│         Стек            │  растёт вниз ↓
│           ↓             │
│                         │
│           ↑             │
│   Область mmap          │  разделяемые библиотеки, mmap-файлы
│           ↑             │
│          Куча           │  растёт вверх ↑
├─────────────────────────┤
│   Сегмент BSS           │  неинициализированные глобальные переменные (нули)
├─────────────────────────┤
│   Сегмент данных        │  инициализированные глобальные переменные
├─────────────────────────┤
│   Сегмент кода          │  машинный код (только для чтения)
└─────────────────────────┘  Низкие адреса (адрес 0)
```

**Текстовый сегмент** содержит скомпилированные машинные инструкции и доступен только для чтения. Сегмент **BSS** заполняется нулями при запуске. **Куча** растёт снизу вверх; **стек** растёт сверху вниз. Если они встретятся, у процесса закончится память.

## Часть 4. Стек

При каждом вызове функции CPU выделяет на стеке блок памяти, называемый **стековым фреймом**. Он содержит локальные переменные функции, адрес возврата и сохранённые значения регистров.

Когда функция возвращается, её стековый фрейм немедленно уничтожается; просто корректируется регистр-указатель стека (SP). Никакой аллокации, никаких системных вызовов.

Стек имеет фиксированный максимальный размер, обычно около 8 МБ в Linux. Его превышение (чаще всего из-за глубокой рекурсии) вызывает **переполнение стека**.

### Висячие указатели на стек

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

```c
int *get_number(void) {
    int x = 42;
    return &x;   // ОШИБКА: x уничтожается при возврате функции
}
```

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

## Часть 5. Куча

Куча предназначена для памяти, которая должна жить дольше, чем создавшая её функция. **Аллокатор кучи** управляет сырой памятью: разбивает её на блоки разных размеров, отслеживает свободные блоки и рециклирует их при освобождении.

Память кучи не очищается автоматически (в языках без сборщика мусора). Именно здесь живут классические ошибки работы с памятью.

### Use-after-free (использование после освобождения)

```c
int *p = malloc(4);
*p = 42;
free(p);          // память возвращена аллокатору
*p = 99;          // ОШИБКА: аллокатор мог уже отдать эту память кому-то ещё
```

После `free` указатель `p` всё ещё хранит старый адрес. Байты там часто ещё содержат старое значение, однако они не были обнулены. Но аллокатор может немедленно отдать эту память другой аллокации. Запись туда перезапишет чужой объект.

### Двойное освобождение (double-free)

```c
free(p);
free(p);  // ОШИБКА: повреждает внутренние структуры аллокатора
```

Аллокатор использует освобождённую память для хранения служебных метаданных. Двойной вызов `free` повреждает эти метаданные; эффект часто виден гораздо позже, в несвязанном месте.

### Переполнение буфера (buffer overflow)

```c
char name[8];
strcpy(name, "Christopher");  // ОШИБКА: "Christopher" — 12 байт с нулём
```

Запись за конец массива перезаписывает соседние данные. На стеке это может быть адрес возврата, классическая цель эксплойтов.

### Утечка памяти

```c
while (1) {
    char *buf = malloc(1024);
    process(buf);
    // ОШИБКА: забыли вызвать free(buf)
    // 1 КБ исчезает из доступной памяти на каждой итерации
}
```

Утечка не приводит к немедленному сбою: она медленно поглощает доступную память. Долго работающие серверные процессы служат естественной средой обитания утечек.

## Часть 6. Виртуальная память, MMU и TLB

Как виртуальные адреса преобразуются в физические позиции RAM?

**Блок управления памятью (MMU)**, встроенный в каждый современный процессор, отвечает за эту трансляцию. MMU работает единицами по 4 КБ, называемыми **страницами**. Каждый виртуальный адрес делится на две части:

- **Номер виртуальной страницы** (VPN): какая страница?
- **Смещение** внутри страницы: где в странице?

Смещение проходит без изменений. VPN переводится в **номер физического фрейма** (PFN) с помощью **таблицы страниц**, которую ОС хранит в RAM.

```
Виртуальный адрес:  [ Номер виртуальной страницы | Смещение ]
                                 ↓
                          Таблица страниц
                                 ↓
Физический адрес:   [ Номер физического фрейма   | Смещение ]
```

### Буфер ассоциативной трансляции (TLB)

Обращение к таблице страниц при каждом доступе к памяти удвоило бы стоимость всех операций. Решением служит **буфер ассоциативной трансляции (TLB)**, небольшой сверхбыстрый кэш внутри MMU, хранящий последние использованные отображения виртуальный→физический.

- **Попадание в TLB**: трансляция закэширована. Стоимость: около одного такта.
- **Промах TLB**: трансляция не закэширована. CPU должен **обойти таблицу страниц** в RAM, на x86-64 четыре отдельных обращения к памяти.

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

### Страничные ошибки и ASLR

Когда программа обращается к виртуальному адресу без допустимой записи в таблице страниц, CPU вызывает **страничную ошибку** (page fault), передавая управление ОС. Если страница вытеснена на диск, ОС её подгружает и продолжает выполнение. Если адрес недопустим (разыменование NULL), ОС посылает сигнал, в Linux **SIGSEGV**, и процесс завершается.

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

## Часть 7. Кэш CPU и истинная стоимость обращения к памяти

RAM быстра по сравнению с жёстким диском, но невероятно медленна по сравнению с CPU. Обращение к основной памяти обходится примерно в **200 тактов** на современном процессоре.

Решением служит иерархия кэшей.

| Уровень  | Типичный размер | Типичная задержка | Общий для   |
| -------- | --------------- | ----------------- | ----------- |
| Регистры | ~1 КБ           | 0 тактов          | Одного ядра |
| L1-кэш   | 32–64 КБ        | ~4 такта          | Одного ядра |
| L2-кэш   | 256 КБ–1 МБ     | ~12 тактов        | Одного ядра |
| L3-кэш   | 4–64 МБ         | ~40 тактов        | Всех ядер   |
| RAM      | 8–512 ГБ        | ~200 тактов       | Всех ядер   |
| NVMe SSD | ТБ              | ~50 000 тактов    | Машины      |

### Строки кэша

Кэш работает не побайтово, а **строками кэша**, обычно по 64 байта. При обращении к любому байту CPU загружает всю 64-байтовую строку. Последовательный обход массива идеально использует это; каждые 64 байта дают одну загрузку строки. Связный список, узлы которого разбросаны по куче, требует новой загрузки строки для каждого узла.

### Ложное разделение (false sharing)

На многоядерной машине каждое ядро имеет свой L1 и L2. Если два ядра хранят копию одной строки кэша и одно из них записывает в неё, оборудование должно аннулировать копию другого ядра, даже если они писали в разные байты строки.

Если поток A многократно записывает в `счётчик_a`, а поток B записывает в `счётчик_b`, и обе переменные лежат на одной 64-байтовой строке кэша, ядра тратят большую часть времени на борьбу за строку. Потери производительности могут быть значительными.

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

```c
struct Счётчик {
    long значение;
    char _pad[56];  // дополнение до 64 байт
};
```

## Часть 8. Упорядочивание памяти, когда CPU лгут

Современные CPU не выполняют инструкции в том порядке, в котором они написаны. Кроме того, у них есть **буферы записи**: запись сначала попадает в буфер и асинхронно фиксируется в кэше. Другие ядра могут не видеть запись ещё некоторое время.

Для конкурентных программ это имеет конкретные последствия:

```
Поток A:            Поток B:
x = 1;              while (готово == 0) {}
готово = 1;         print(x);
```

Без синхронизации поток B может напечатать `0`, поскольку запись в `x` может ещё находиться в буфере записи потока A, когда поток B наблюдает `готово = 1`.

Решением служит **барьер памяти** (memory barrier / fence), инструкция, принуждающая завершить все предшествующие записи до начала последующих чтений или записей. Это аппаратный механизм, лежащий в основе `volatile`, атомарных операций и мьютексов в любом языке программирования.

## Часть 9. Отображение файлов и копирование при записи

### Файлы, отображённые в память (mmap)

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

Преимущества:

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

### Копирование при записи (Copy-on-Write, CoW)

При создании нового процесса через `fork()` дочерний процесс начинает как точная копия родительского. Физическое копирование всей памяти было бы непосильно дорогим. Вместо этого оба процесса сначала делят одни физические страницы; при первой записи происходит аппаратный сбой, ОС копирует именно эту страницу, и только после этого у процесса есть собственная копия.

### Огромные страницы (Huge Pages)

Стандартный размер страницы (4 КБ) был установлен в 1980-х. При 128 ГБ RAM насчитывается 32 миллиона страниц. TLB обычно хранит около 1000 записей, перекрывая лишь 4 МБ. **Огромные страницы** (2 МБ или 1 ГБ на x86-64) резко снижают давление на TLB: каждая запись TLB покрывает 2 МБ вместо 4 КБ. Базы данных и высокопроизводительные приложения конфигурируют огромные страницы, чтобы устранить промахи TLB как узкое место.

## Итог

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

Их никто об этом не просил.

TLB имеет фиксированное число записей и вытесняет старые отображения по мере заполнения. Буфер записи опустошается в своём темпе, а не в темпе программиста. Строка кэша занимает 64 байта независимо от того, сколько из них нужно, и когда два ядра пишут в переменные, которые случайно делят одну строку, аппаратура тратит больше времени на разрешение конфликтов, чем на полезную работу. В какой-то момент в этой статье, вероятно, стало ощутимо расстояние между моделью и машиной. Это ощущение и было целью.

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

За Heartbleed стояли четыре байта, не четыре мегабайта и не катастрофическая ошибка в расчётах; просто четыре байта за концом буфера кучи в обработчике heartbeat в OpenSSL. Червь Морриса использовал стековый буфер фиксированного размера в fingerd, утилите Unix, о которой большинство операторов давно не думали. Cloudbleed возник из-за use-after-free в HTTP-парсере Cloudflare, сгенерированном Ragel из описания конечного автомата. В каждом случае код делал именно то, что ему велели. Представление программиста о том, что это означает во время выполнения, оказалось неверным.

## Источники

1. Брайант, Р. Э.; О'Халларон, Д. Р. _Computer Systems: A Programmer's Perspective_, 3-е изд. Pearson, 2015. Стандартный учебник курса CMU 15-213.
2. Carnegie Mellon University. CS 15-213: _Introduction to Computer Systems — Virtual Memory_. [cs.cmu.edu/afs/cs/academic/class/15213-s09/www/lectures/17-virtual-memory.pdf](https://www.cs.cmu.edu/afs/cs/academic/class/15213-s09/www/lectures/17-virtual-memory.pdf)
3. Stanford University. CS 107: _Pointers, Arrays and the Heap_ (Lab 3). [web.stanford.edu/class/archive/cs/cs107/cs107.1216/lab3](https://web.stanford.edu/class/archive/cs/cs107/cs107.1216/lab3/)
4. Stanford University. CS 107: _More Pointers and Arrays_ (Lecture 6). [web.stanford.edu/class/archive/cs/cs107/cs107.1212/lectures/6/Lecture6.pdf](https://web.stanford.edu/class/archive/cs/cs107/cs107.1212/lectures/6/Lecture6.pdf)
5. MIT OpenCourseWare. 6.004 Computation Structures: _Caches and the Memory Hierarchy_. [ocw.mit.edu/courses/6-004-computation-structures-spring-2017/pages/c14](https://ocw.mit.edu/courses/6-004-computation-structures-spring-2017/pages/c14/)
6. MIT OpenCourseWare. 6.004 Computation Structures: _Virtual Memory_. [ocw.mit.edu/courses/6-004-computation-structures-spring-2017/pages/c16](https://ocw.mit.edu/courses/6-004-computation-structures-spring-2017/pages/c16/)
7. MIT OpenCourseWare. 6.172 Performance Engineering: _Memory Systems and Performance Engineering_. [ocw.mit.edu/courses/6-172-performance-engineering-of-software-systems-fall-2010](https://web.archive.org/web/20120202120920/http://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-172-performance-engineering-of-software-systems-fall-2010/video-lectures/lecture-7-memory-systems-and-performance-engineering/)
8. University of Illinois Urbana-Champaign. CS 225: _Stack and Heap Memory_. [courses.grainger.illinois.edu/cs225/sp2022/resources/stack-heap](https://courses.grainger.illinois.edu/cs225/sp2022/resources/stack-heap/)
9. Technische Universität Darmstadt. _Rechnerorganisation, Kapitel 6: Speicherhierarchie_. [esa.informatik.tu-darmstadt.de](https://www.esa.informatik.tu-darmstadt.de/archive/twiki/pub/Lectures/Rechnerorganisation16DE/RO_Kapitel06kommentiert.pdf)
10. Таненбаум, Э. С. _Modern Operating Systems_, 4-е изд. Pearson, 2014. Гл. 3: «Memory Management».
11. Дреппер, У. _What Every Programmer Should Know About Memory_. Red Hat, Inc., 2007. [akkadia.org/drepper/cpumemory.pdf](https://akkadia.org/drepper/cpumemory.pdf)
12. OWASP Foundation. _Buffer Overflow Attack_. [owasp.org/www-community/attacks/Buffer_overflow_attack](https://owasp.org/www-community/attacks/Buffer_overflow_attack)
13. Прешинг, Дж. _Weak vs. Strong Memory Models_. preshing.com, 2012. [preshing.com/20120930/weak-vs-strong-memory-models](https://preshing.com/20120930/weak-vs-strong-memory-models/)
14. Intel Corporation. _Intel® 64 and IA-32 Architectures Software Developer's Manual_, Vol. 3A. Гл. 4: «Paging». [intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html)
