C free() на виртуальной машине Ubuntu, вопрос относительно кучи памяти

Простая программа для выделения и освобождения динамической памяти:

int main(int argc, char **argv) {
    char *b1, *b2, *b3, *b4, *b_large;
    b1 = malloc(8);
    memset(b1, 0xaa, 8);
    b2= malloc(16);
    memset(b2, 0xbb, 16);
    b3 = malloc(25);
    memset(b3, 0xcc, 25);
    b4= malloc(1000);
    memset(b4, 0xdd, 1000);
    free(b1);
    free(b2);
    free(b3);
    free(b4);

Перед первым free():

(gdb) x/20gx  0x555555559290 
0x555555559290: 0x0000000000000000  0x0000000000000021
0x5555555592a0: 0xaaaaaaaaaaaaaaaa  0x0000000000000000
0x5555555592b0: 0x0000000000000000  0x0000000000000021
0x5555555592c0: 0xbbbbbbbbbbbbbbbb  0xbbbbbbbbbbbbbbbb
0x5555555592d0: 0x0000000000000000  0x0000000000000031
0x5555555592e0: 0xcccccccccccccccc  0xcccccccccccccccc
0x5555555592f0: 0xcccccccccccccccc  0x00000000000000cc
0x555555559300: 0x0000000000000000  0x00000000000003f1
0x555555559310: 0xdddddddddddddddd  0xdddddddddddddddd
0x555555559320: 0xdddddddddddddddd  0xdddddddddddddddd

И после первого free():

(gdb) x/20gx  0x555555559290 
0x555555559290: 0x0000000000000000  0x0000000000000021
0x5555555592a0: 0x0000000555555559  0xd13e7903c502febc
0x5555555592b0: 0x0000000000000000  0x0000000000000021
0x5555555592c0: 0xbbbbbbbbbbbbbbbb  0xbbbbbbbbbbbbbbbb
0x5555555592d0: 0x0000000000000000  0x0000000000000031
0x5555555592e0: 0xcccccccccccccccc  0xcccccccccccccccc
0x5555555592f0: 0xcccccccccccccccc  0x00000000000000cc
0x555555559300: 0x0000000000000000  0x00000000000003f1
0x555555559310: 0xdddddddddddddddd  0xdddddddddddddddd
0x555555559320: 0xdddddddddddddddd  0xdddddddddddddddd

Я ожидал увидеть читаемые указатели вперед и назад во второй строке памяти, и в третьей строке 0x20 в обоих 8-байтовых сегментах.

Может ли кто-нибудь объяснить, почему функция free() ведет себя таким образом?

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

Barmar 25.06.2024 22:42

Функция free() не гарантирует, что что-либо изменится. Все, что делает free(), — это делает эту память доступной для последующих вызовов malloc, calloc и т. д. Это хорошо, что free() работает таким образом, ведь зачем C программисту тратить циклы на free() изменение ячеек памяти?

PaulMcKenzie 25.06.2024 22:43

Почему вы этого ожидаете?

0___________ 25.06.2024 22:43

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

Some programmer dude 25.06.2024 22:44

@PaulMcKenzie Что удивительно, так это то, что это меняет память.

Barmar 25.06.2024 22:45

@Barmar - Может быть, запускаемая сборка неоптимизирована?

PaulMcKenzie 25.06.2024 22:46

@PaulMcKenzie Зачем неоптимизированному free() изменять память? Это больше похоже на то, что они используют отладочный malloc, который использует это для обнаружения двойного освобождения.

Barmar 25.06.2024 22:51

Привет, Бармар, спасибо. Потому что в примечаниях glib malloc.c указано, что два указателя перезаписывают первую строку данных, а заголовок включает в себя предыдущий размер фрагмента и отбрасывает флаг P.

rdre8 25.06.2024 22:52

Поскольку это Ubuntu и, следовательно, предположительно glibc, нужно иметь в виду, что glibc использует кэширующий распределитель, т.е. не каждый malloc приводит к вызову ОС для получения дополнительной памяти, и не каждый свободный возвращает память обратно ОПЕРАЦИОННЫЕ СИСТЕМЫ. Вместо этого он поддерживает пул заранее выделенной памяти, из которого пытается удовлетворить требования программы, чтобы ускорить работу. Глядя на исходный код, становится ясно, что реализация тегов free() освободила память, указывая на то, что она теперь принадлежит glibc, а не приложению, и, следовательно, может повторно использовать ее для большего количества вызовов malloc(). Все во имя скорости.

bazza 25.06.2024 22:55

Привет, Базза, спасибо за это, ты случайно не знаешь, какими могут быть данные во второй строке?

rdre8 25.06.2024 22:56

@bazza Именно так работает большинство реализаций malloc, в glibc нет ничего особенного.

Barmar 25.06.2024 23:16

откуда взялся адрес 0x555555559290?

yano 25.06.2024 23:17

Привет, Яно, это на 16 байт меньше, чем указатель b1, показывающий первый заголовок.

rdre8 25.06.2024 23:26

@barmar glibc даже не был первым, но этот метод далеко не вездесущ, поскольку некоторые платформы предпочитают надежность, получаемую при постоянном получении выделений из ОС. Подход glibc быстрый, но более склонен к тому, что ошибки памяти остаются незамеченными. Например, у меня есть блок-графы для GNU Radio (предопределенные блоки C++, связанные вместе с Python), которые прекрасно работают в Linux, но тот же самый код, созданный и запущенный во FreeBSD (которая использует другой распределитель), приводит к сбоям в сегментировании. Следовательно, в GNU Radio есть ошибки памяти, которые никто не находит, потому что большинство из них запускают его на Linux/glibc...

bazza 26.06.2024 07:22

@ rdre8 см. ответ Марко Бонелли

bazza 26.06.2024 07:29

@Barmar, меня несколько позабавил рост популярности кэширующих распределителей, потому что для кода, который меня волновал, я годами делал нечто подобное в исходном коде приложений. Мотивация заключалась в первую очередь в том, чтобы ограничить использование резервных буферов трансляции (на PowerPC). Выделив всю память моего приложения в одном malloc(), а затем разделив ее в исходном коде, можно было бы избежать забивания TLB в процессорах внутри циклов DSP, которые должны были быть быстрыми и в реальном времени. К счастью, в таком коде все выделение памяти выполняется до горячего цикла, поэтому сделать это было не очень сложно.

bazza 26.06.2024 07:35

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

Lundin 26.06.2024 10:49

@bazza AFAIK, это самый старый метод, поскольку изначально в Unix был только один способ получить память от ОС, и это был sbrk(). Это могло расширить/сжать сегмент данных только на одном конце, поэтому единственный раз free() мог вернуть память, это если вы освобождали выделение на этом конце. Альтернативы стали доступны только тогда, когда был добавлен mmap() (я думаю, в BSD), но немногие реализации malloc() воспользовались этим.

Barmar 26.06.2024 17:59

@Бармар, я этого не знал - большое спасибо!. Я давно начал использовать другие POSIX-подобные операционные системы (например, VxWorks), которые всегда имели только простые распределители, которые каждый раз работали непосредственно с ОС (в такой RTOS, как VxWorks, штрафов было меньше). Я не осознавал, что старые Unix не реализовали сегодняшнее понимание malloc() и free(). Это напоминает мне о том, как много операционных систем не реализовали select() или потоки вплоть до начала 90-х годов.

bazza 27.06.2024 09:40
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
19
168
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Предполагая, что вы работаете с Ubuntu 22.04 и, следовательно, с glibc 2.36.

Современные распределители кучи в настоящее время... довольно сложны, и glibc является ярким примером этого. Не ожидайте, что так легко увидите такие приятные вещи, как простые указатели.

Случайное значение, которое вы видите (0xd13e7903c502febc), — это внутренний tcache_key. Это действительно случайное значение uintptr_t, которое инициализируется один раз при запуске программы с помощью getrandom(2) и позже вставляется в свободные фрагменты, которые хранятся в tcache.

Значение tcache_keyвставляется в фрагменты, которые помещаются в tcache, а затем проверяется бесплатно в качестве простого усиления защиты от двойного освобождения. Он удаляется при перераспределении фрагмента tcache. Это было реализовано в glibc 2.34. Раньше (glibc 2.29-2.33) вместо tcache_key использовалось фиксированное значение.

Если вам интересно, что такое «tcache», то это кэш каждого потока, состоящий из нескольких сегментов. Каждый сегмент зарезервирован для заданного размера распределения и содержит освобожденные фрагменты именно этого размера в односвязном списке, состоящем не более чем из 7 элементов в порядке LIFO (вновь освобожденные фрагменты вставляются в заголовок). Когда ведро заполнено, освобождение фрагмента такого размера не добавит его в tcache, а вместо этого будет следовать «обычной» процедуре освобождения и в конечном итоге окажется в одном из обычных «корзин» арены в соответствии с довольно запутанным алгоритмом glibc.

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


После free(b1); free(b2); у меня следующее:

(gdb) x/20gx 0x5555555592a0 - 0x10
0x555555559290: 0x0000000000000000  0x0000000000000021  -- b1
0x5555555592a0: 0x0000000555555559  0xdefa7fb306dd6989
0x5555555592b0: 0x0000000000000000  0x0000000000000021  -- b2
0x5555555592c0: 0x000055500000c7f9  0xdefa7fb306dd6989
0x5555555592d0: 0x0000000000000000  0x0000000000000031  -- b3
0x5555555592e0: 0xcccccccccccccccc  0xcccccccccccccccc
0x5555555592f0: 0xcccccccccccccccc  0x00000000000000cc
0x555555559300: 0x0000000000000000  0x00000000000003f1  -- b4
0x555555559310: 0xdddddddddddddddd  0xdddddddddddddddd
0x555555559320: 0xdddddddddddddddd  0xdddddddddddddddd

В бесплатной версии и b1, и b2 попадают в одно и то же ведро tcache, самое маленькое, для размера 16 (malloc(8) округляется до 16). У нас b2 в качестве главы, так как список LIFO.

tcache выглядит так:

{
  counts = {2, 0, 0, 0, ...},
  entries = {0x5555555592c0, 0x0, 0x00, 0x00, ...}
}

Как вы можете видеть выше, 0xdefa7fb306dd6989 — это значение tcache_key. Глядя на свободный фрагмент 0x5555555592c0, его указатель ->next искажен на 0x000055500000c7f9. Реальную стоимость можно получить как:

(0x5555555592c0UL >> 12) ^ 0x55500000c7f9UL == 0x5555555592a0UL

->next->next — это просто NULL (искаженный до 0x0000000555555559).


Когда вы говорите: «Я ожидал увидеть читаемые указатели вперед и назад во второй строке памяти», вы описываете поведение больших фрагментов без кэширования. Даже игнорируя/отключая tcache, достаточно маленькие фрагменты также могут снова храниться в односвязных списках (fastbins) до определенного фиксированного числа (IIRC). Только для больших размеров у вас действительно есть двусвязные списки, где оба указателя используются так, как вы ожидаете.

Если вы хотите поэкспериментировать дальше, сначала заполните tcache, освободив 7 кусков одинакового размера, затем сделайте еще несколько освобождений такого же размера и проверьте еще раз.

Установив символы отладки из пакета libc6-dbg и используя плагин pwndbg GDB, вы получите очень полезные команды для проверки кучи glibc и различных контейнеров. Например:

  • heap показаны все фрагменты
  • bins показывает состояние корзин арены
  • arena показывает фактическую структуру арены
  • tcache показывает состояние tcache

и более...


Для получения дополнительной информации также прочтите эту интересную статью: Ключи Tcache. Примитивная двойная защита .

Это выдающийся ответ, Марко, спасибо, что уделили время этому вопросу. Я искал предлог, чтобы углубиться в надстройки gdb gef/peda/pwndbg, и теперь у меня есть идеальный повод.

rdre8 26.06.2024 10:26

После ответа Марко я немного поигрался с GLIBC_TUNABLES следующим образом:

(gdb) set environment GLIBC_TUNABLES glibc.malloc.tcache_count=0
(gdb) set environment GLIBC_TUNABLES glibc.malloc.mxfast=0

Отключение tcache и быстрых контейнеров, как указано выше, дает поведение, которое я ожидал в своем исходном вопросе, - несортированный контейнер в ответ на вызов free().

Отлично, я не знал о настройке mxfast.

Marco Bonelli 26.06.2024 14:07

Другие вопросы по теме