Простая программа для выделения и освобождения динамической памяти:
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() ведет себя таким образом?
Функция free()
не гарантирует, что что-либо изменится. Все, что делает free()
, — это делает эту память доступной для последующих вызовов malloc
, calloc
и т. д. Это хорошо, что free()
работает таким образом, ведь зачем C
программисту тратить циклы на free()
изменение ячеек памяти?
Почему вы этого ожидаете?
Если это просто любопытство, то скажите об этом в вопросе. В противном случае, если у вас есть реальная проблема, с которой вы пытаетесь получить помощь, спросите об этом напрямую.
@PaulMcKenzie Что удивительно, так это то, что это меняет память.
@Barmar - Может быть, запускаемая сборка неоптимизирована?
@PaulMcKenzie Зачем неоптимизированному free()
изменять память? Это больше похоже на то, что они используют отладочный malloc, который использует это для обнаружения двойного освобождения.
Привет, Бармар, спасибо. Потому что в примечаниях glib malloc.c указано, что два указателя перезаписывают первую строку данных, а заголовок включает в себя предыдущий размер фрагмента и отбрасывает флаг P.
Поскольку это Ubuntu и, следовательно, предположительно glibc, нужно иметь в виду, что glibc использует кэширующий распределитель, т.е. не каждый malloc приводит к вызову ОС для получения дополнительной памяти, и не каждый свободный возвращает память обратно ОПЕРАЦИОННЫЕ СИСТЕМЫ. Вместо этого он поддерживает пул заранее выделенной памяти, из которого пытается удовлетворить требования программы, чтобы ускорить работу. Глядя на исходный код, становится ясно, что реализация тегов free() освободила память, указывая на то, что она теперь принадлежит glibc, а не приложению, и, следовательно, может повторно использовать ее для большего количества вызовов malloc(). Все во имя скорости.
Привет, Базза, спасибо за это, ты случайно не знаешь, какими могут быть данные во второй строке?
@bazza Именно так работает большинство реализаций malloc, в glibc нет ничего особенного.
откуда взялся адрес 0x555555559290
?
Привет, Яно, это на 16 байт меньше, чем указатель b1, показывающий первый заголовок.
@barmar glibc даже не был первым, но этот метод далеко не вездесущ, поскольку некоторые платформы предпочитают надежность, получаемую при постоянном получении выделений из ОС. Подход glibc быстрый, но более склонен к тому, что ошибки памяти остаются незамеченными. Например, у меня есть блок-графы для GNU Radio (предопределенные блоки C++, связанные вместе с Python), которые прекрасно работают в Linux, но тот же самый код, созданный и запущенный во FreeBSD (которая использует другой распределитель), приводит к сбоям в сегментировании. Следовательно, в GNU Radio есть ошибки памяти, которые никто не находит, потому что большинство из них запускают его на Linux/glibc...
@ rdre8 см. ответ Марко Бонелли
@Barmar, меня несколько позабавил рост популярности кэширующих распределителей, потому что для кода, который меня волновал, я годами делал нечто подобное в исходном коде приложений. Мотивация заключалась в первую очередь в том, чтобы ограничить использование резервных буферов трансляции (на PowerPC). Выделив всю память моего приложения в одном malloc(), а затем разделив ее в исходном коде, можно было бы избежать забивания TLB в процессорах внутри циклов DSP, которые должны были быть быстрыми и в реальном времени. К счастью, в таком коде все выделение памяти выполняется до горячего цикла, поэтому сделать это было не очень сложно.
Примечательно, что gcc может и будет полностью оптимизировать некоторые из этих вызовов malloc, если у вас включена оптимизация.
@bazza AFAIK, это самый старый метод, поскольку изначально в Unix был только один способ получить память от ОС, и это был sbrk()
. Это могло расширить/сжать сегмент данных только на одном конце, поэтому единственный раз free()
мог вернуть память, это если вы освобождали выделение на этом конце. Альтернативы стали доступны только тогда, когда был добавлен mmap()
(я думаю, в BSD), но немногие реализации malloc()
воспользовались этим.
@Бармар, я этого не знал - большое спасибо!. Я давно начал использовать другие POSIX-подобные операционные системы (например, VxWorks), которые всегда имели только простые распределители, которые каждый раз работали непосредственно с ОС (в такой RTOS, как VxWorks, штрафов было меньше). Я не осознавал, что старые Unix не реализовали сегодняшнее понимание malloc() и free(). Это напоминает мне о том, как много операционных систем не реализовали select() или потоки вплоть до начала 90-х годов.
Предполагая, что вы работаете с 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, и теперь у меня есть идеальный повод.
После ответа Марко я немного поигрался с GLIBC_TUNABLES следующим образом:
(gdb) set environment GLIBC_TUNABLES glibc.malloc.tcache_count=0
(gdb) set environment GLIBC_TUNABLES glibc.malloc.mxfast=0
Отключение tcache и быстрых контейнеров, как указано выше, дает поведение, которое я ожидал в своем исходном вопросе, - несортированный контейнер в ответ на вызов free().
Отлично, я не знал о настройке mxfast
.
Почему вы ожидаете чего-то особенного? Что происходит с освобожденной памятью, языком не определяется.