Текущий адрес приложения, за которым следуют расширения кучи и стека

У меня есть m.c:

extern void a(char*);

int main(int ac, char **av){
    static char string [] = "Hello , world!\n";
    a(string);
}

и a.c:

#include <unistd.h>
#include <string.h>

void a(char* s){
    write(1, s, strlen(s));
}

Я компилирую и строю их как:

g++ -c -g -std=c++14 -MMD -MP -MF "m.o.d" -o m.o m.c
g++ -c -g -std=c++14 -MMD -MP -MF "a.o.d" -o a.o a.c
g++ -o linux m.o a.o -lm -lpthread -ldl

Затем я изучаю исполняемый файл, linux таким образом:

objdump -drwxCS -Mintel linux

Вывод этого на моем Ubuntu 16.04.6 начинается с:

start address 0x0000000000400540

затем, позже, раздел init:

00000000004004c8 <_init>:
  4004c8:   48 83 ec 08             sub    rsp,0x8

Наконец, раздел fini:

0000000000400704 <_fini>:
  400704:   48 83 ec 08             sub    rsp,0x8
  400708:   48 83 c4 08             add    rsp,0x8
  40070c:   c3                      ret 

Программа ссылается на строку Hello , world!\n, которая находится в секции .data, полученной командой:

objdump -sj .data linux

Contents of section .data:
 601030 00000000 00000000 00000000 00000000  ................
 601040 48656c6c 6f202c20 776f726c 64210a00  Hello , world!..

Все это говорит мне о том, что исполняемый файл был создан таким образом, чтобы загружаться в фактический адрес памяти, начиная примерно с 0x0000000000400540 (адрес .init), и программа обращается к данным в фактическом адресе памяти, простирающемся по крайней мере до 601040 (адрес .data)

Я основываюсь на главе 7 линкеров & Loaders Джона Р. Левина, где он утверждает:

Компоновщик объединяет набор входных файлов в один выходной файл, который готов к загрузке по определенному адресу.

Мой вопрос касается следующей строки.

Если при загрузке программы память по этому адресу недоступна, загрузчик должен переместить загруженную программу, чтобы отразить фактический адрес загрузки.

(1) Предположим, у меня есть другой исполняемый файл, который в настоящее время работает на моей машине и уже использует пространство памяти между 400540 и 601040, как решается, где запустить мой новый исполняемый файл linux?

(2) В связи с этим в Главе 4 сказано:

... Объекты ELF... загружаются примерно в середине адресного пространства, поэтому стек может расти ниже текстового сегмента, а куча может расти с конца данных, сохраняя общее используемое адресное пространство относительно компактным.

Предположим, предыдущее запущенное приложение запускалось, скажем, в 200000, а теперь linux начинается примерно в 400540. Нет конфликта или перекрытия адресов памяти. Но по мере того, как программы продолжают работу, предположим, что куча предыдущего приложения увеличивается до 300000, а стек только что запущенного linux вырос вниз до 310000. Вскоре произойдет конфликт/перекрытие адресов памяти. Что происходит, когда столкновение в конце концов происходит?


person Tryer    schedule 06.08.2020    source источник


Ответы (2)


Если при загрузке программы память по этому адресу недоступна, загрузчик должен переместить загруженную программу, чтобы отразить фактический адрес загрузки.

Не все форматы файлов поддерживают это:

GCC для 32-битной Windows добавит информацию, необходимую для загрузчика в случае динамических библиотек (.dll). Однако в исполняемые файлы (.exe) информация не добавляется, поэтому такой исполняемый файл должен загружаться по фиксированному адресу.

В Linux это немного сложнее; однако также невозможно загрузить много (обычно более старых 32-разрядных) исполняемых файлов по разным адресам, в то время как динамические библиотеки (.so) можно загрузить по разным адресам.

Предположим, у меня есть другой исполняемый файл, который в данный момент работает на моей машине и уже использует пространство памяти между 400540 и 601040...

Современные компьютеры (все 32-разрядные компьютеры с архитектурой x86) имеют MMU подкачки, который используется большинством современных операционных систем. Это некоторая схема (обычно в ЦП), которая переводит адреса, видимые программным обеспечением, в адреса, видимые ОЗУ. В вашем примере 400540 может быть переведено в 1234000, поэтому доступ к адресу 400540 фактически приведет к доступу к адресу 1234000 в ОЗУ.

Дело в том, что современные ОС используют разные конфигурации MMU для разных задач. Поэтому, если вы снова запустите свою программу, будет использоваться другая конфигурация MMU, которая преобразует адрес 400540, видимый программным обеспечением, в адрес 2345000 в ОЗУ. Обе программы, использующие адрес 400540, могут работать одновременно, потому что одна программа будет фактически обращаться к адресу 1234000, а другая будет обращаться к адресу 2345000 в ОЗУ, когда программы будут обращаться к адресу 400540.

Это означает, что какой-либо адрес (например, 400540) никогда не будет уже использоваться при загрузке исполняемого файла.

Адрес может уже использоваться, когда загружается динамическая библиотека (.so/.dll), поскольку эти библиотеки совместно используют память с исполняемым файлом.

... как решается, где запустить мой новый исполняемый файл Linux?

В Linux исполняемый файл будет загружен по фиксированному адресу, если он был связан таким образом, что его нельзя переместить на другой адрес. (Как уже было сказано: это было типично для старых 32-битных файлов.) В вашем примере строка Hello world будет располагаться по адресу 0x601040 если ваш компилятор и компоновщик создали исполняемый файл таким образом.

Однако большинство 64-разрядных исполняемых файлов можно загрузить по другому адресу. Linux загрузит их по какому-то случайному адресу из соображений безопасности, что затрудняет атаку вирусов или других вредоносных программ на программу.

... так что стек может расти ниже текстового сегмента ...

Я никогда не видел такой схемы памяти ни в одной операционной системе:

И под Linux, и под Solaris стек располагался в конце адресного пространства (где-то около 0xBFFFFF00), а текстовый сегмент загружался довольно близко к началу памяти (возможно, адрес 0x401000).

... и куча может расти с конца данных, ...

допустим наползает куча предыдущего приложения...

Многие реализации с конца 1990-х больше не используют кучу. Вместо этого они используют mmap() для резервирования новой памяти.

Согласно странице руководства brk(), куча была объявлена ​​устаревшей функцией в 2001 году, поэтому она больше не должна использоваться новыми программами.

(Однако, по словам Питера Кордеса, malloc() в некоторых случаях по-прежнему использует кучу.)

В отличие от простых операционных систем, таких как MS-DOS, Linux не позволяет вам просто использовать кучу, но вы должны вызвать функцию brk(), чтобы сообщить Linux, какой объем кучи вы хотите использовать.

Если программа использует кучу и использует больше кучи, чем доступно, функция brk() возвращает некоторый код ошибки, а функция malloc() просто возвращает NULL.

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

... в то время как стек только что запущенного Linux вырос вниз до ...

Вскоре произойдет конфликт/перекрытие адресов памяти. Что происходит, когда столкновение в конце концов происходит?

Действительно, размер стека ограничен.

Если вы используете слишком много стека, у вас возникает переполнение стека.

Эта программа намеренно использует слишком много стека — просто чтобы посмотреть, что произойдет:

.globl _start
_start:
    sub $0x100000, %rsp
    push %rax
    push %rax
    jmp _start

В случае операционной системы с MMU (например, Linux) ваша программа вылетит с сообщением об ошибке:

~$ ./example_program
Segmentation fault (core dumped)
~$

ИЗМЕНИТЬ/ДОБАВИТЬ

Стек для всех запущенных программ находится в конце?

В более старых версиях Linux стек располагался рядом (но не точно) с концом виртуальной памяти, доступной программе: программы могли получить доступ к диапазону адресов от 0 до 0xBFFFFFFF в этих версиях Linux. Начальный указатель стека располагался вокруг 0xBFFFFE00. (Аргументы командной строки и переменные окружения идут после стека.)

И это конец фактической физической памяти? Не перемешается ли тогда стек разных запущенных программ? У меня сложилось впечатление, что весь стек и память программы остаются непрерывными в реальной физической памяти,...

На компьютере с MMU программа никогда не видит физическую память:

Когда программа загружается, ОС будет искать какую-то свободную область ОЗУ - возможно, она найдет что-то по физическому адресу 0xABC000. Затем он настраивает MMU таким образом, что виртуальные адреса 0xBFFFF000-0xBFFFFFFF преобразуются в физические адреса 0xABC000-0xABCFFF.

Это означает: Всякий раз, когда программа обращается к адресу 0xBFFFFE20 (например, используя операцию push), фактически осуществляется доступ к физическому адресу 0xABCE20 в ОЗУ.

У программы вообще нет возможности получить доступ к определенному физическому адресу.

Если у вас запущена другая программа, MMU настроен таким образом, что адреса 0xBFFFF000-0xBFFFFFFF преобразуются в адреса 0x345000-0x345FFF при выполнении другой программы.

Таким образом, если одна из двух программ выполнит операцию push, а указатель стека равен 0xBFFFFE20, будет осуществлен доступ к адресу 0xABCE20 в ОЗУ; если другая программа выполняет операцию push (с тем же значением указателя стека), будет осуществлен доступ к адресу 0x345E20.

Поэтому стеки не будут смешиваться.

ОС, не использующие MMU, но поддерживающие многозадачность (примерами являются Amiga 500 или ранние Apple Macintosh), конечно, не будут работать таким образом. Такие ОС используют специальные форматы файлов (а не ELF), которые оптимизированы для запуска нескольких программ без MMU. Компиляция программ для таких ОС намного сложнее, чем компиляция программ для Linux или Windows. И даже есть ограничения для разработчика ПО (пример: функции и массивы не должны быть слишком длинными).

Кроме того, есть ли у каждой программы собственный указатель стека, базовый указатель, регистры и т. д.? Или у ОС есть только один набор этих регистров, который будет использоваться всеми программами?

(Предполагая одноядерный ЦП), ЦП имеет один набор регистров; и одновременно может работать только одна программа.

Когда вы запускаете несколько программ, ОС будет переключаться между ними. Это означает, что программа А работает (например) 1/50 секунды, затем программа В работает 1/50 секунды, затем программа А работает 1/50 секунды и так далее. Вам кажется, что программы работают одновременно.

Когда ОС переключается с программы А на программу Б, она должна сначала сохранить значения регистров (программы А). Затем он должен изменить конфигурацию MMU. Наконец, он должен восстановить значения регистров программы B.

person Martin Rosenau    schedule 06.08.2020
comment
Современные дистрибутивы Linux создают 32-битные PIE. Вы говорите, что 64-битные исполняемые файлы Linux обычно перемещаемы, но большинство 32-битных исполняемых файлов — нет. Это только учитывая вес истории? x86-64 уже был широко распространен за много лет до того, как исполняемые файлы PIE стали популярными; например Ubuntu 16.04 OP по умолчанию делает исполняемый файл не-PIE; они не могут быть ASLRed. GCC будет использовать такие инструкции, как mov edi, offset .LC0, для помещения статических адресов в регистры, потому что модель кода по умолчанию, отличная от PIE, гарантирует, что статический код/данные находятся в младших 31 бите адресного пространства. - person Peter Cordes; 06.08.2020
comment
Также вы говорите о программе ОП в том же абзаце, что и 32-битная. Он 64-битный, как мы можем точно сказать из дизассемблирования. Кроме того, binutils ld имеет другой базовый адрес по умолчанию для .text в 32-битном режиме. Программа OP представляет собой исполняемый файл x86-64, отличный от PIE (ELF type EXEC). Неперемещаемый: нет метаданных перемещения для применения исправлений к статическим данным или коду, а также нет требования, чтобы они были независимыми от положения. - person Peter Cordes; 06.08.2020
comment
Текущий glibc malloc по-прежнему использует brk для небольших выделений, mmap для больших выделений (поэтому он определенно может отдавать страницы ОС, не застревая с ней в списке свободных). Есть эвристика настройки, IIRC ограничивает пару страниц или, может быть, даже 64k. strace ls и посмотрите, как он использует brk системные вызовы. (Общая точка вашего ответа, конечно, верна; виртуальная память делает это не проблемой. Но, к сожалению, некоторые конкретные детали неверны.) - person Peter Cordes; 06.08.2020
comment
@PeterCordes Я предположил, что большинство современных дистрибутивов Linux x86 являются 64-битными и часто даже не поддерживают 32-битные программы без установки дополнительных пакетов. Поэтому, когда писал о 32-битных программах, я имел в виду старые программы. Поэтому слово «типичный» относится к средней программе 1995-2018 годов, а не к средней программе в одном из немногих 32-битных дистрибутивов, которые все еще существуют. - person Martin Rosenau; 06.08.2020
comment
@PeterCordes Я обновил предложения о 32-битных исполняемых файлах в своем ответе. Я также добавил объяснение того, как куча используется в Linux и что происходит, если кучи больше нет. - person Martin Rosenau; 06.08.2020
comment
@MartinRosenau, стек для всех запущенных программ находится в конце? И это конец фактической физической памяти? Не перемешается ли тогда стек разных запущенных программ? У меня сложилось впечатление, что весь стек и память программы остаются непрерывными в реальной физической памяти, увеличиваясь и уменьшаясь по мере необходимости, но при этом оставаясь непрерывными. Кроме того, есть ли у каждой программы собственный указатель стека, базовый указатель, регистры и т. д.? Или у ОС есть только один набор этих регистров, который будет использоваться всеми программами? - person Tryer; 07.08.2020
comment
@Tryer Пожалуйста, смотрите мой раздел EDIT в моем ответе. - person Martin Rosenau; 07.08.2020

Да, objdump этого исполняемого файла показывает адреса, по которым будут отображаться его сегменты. (Связывание собирает разделы в сегменты: В чем разница? раздела и сегмента в формате файла ELF) .data и .text связываются с разными разделами с разными правами доступа (чтение+запись против чтения+исполнение).

Если при загрузке программы хранилище по этому адресу недоступно

Это могло произойти только при загрузке динамической библиотеки, а не самого исполняемого файла. Виртуальная память означает, что каждый процесс имеет свое собственное виртуальное адресное пространство, даже если они были запущены из одного и того же исполняемого файла. (Вот почему ld всегда может выбрать один и тот же базовый адрес по умолчанию для сегментов text и data, не пытаясь разместить каждый исполняемый файл и библиотеку в системе в другом месте в одном адресном пространстве.)

Исполняемый файл - это первое, что может претендовать на части этого адресного пространства, когда он загружается/сопоставляется программным загрузчиком ELF ОС. Вот почему традиционные (не PIE) исполняемые файлы ELF могут быть неперемещаемыми, в отличие от общих объектов ELF, таких как /lib/libc.so.6.

Если вы пошагово выполняете программу с помощью отладчика или включаете спящий режим, у вас будет время просмотреть less /proc/<PID>/maps. Или cat /proc/self/maps чтобы кошка показала вам свою карту. (Также /proc/self/smaps для получения более подробной информации о каждом отображении, например, насколько оно грязное, с использованием огромных страниц и т. д.)

(Более новые дистрибутивы GNU/Linux настраивают GCC для создания исполняемых файлов PIE по умолчанию: 64-linux">32-битные абсолютные адреса больше не разрешены в x86-64 Linux?. В этом случае objdump будет видеть только адреса относительно базы 0 или 1000 или что-то в этом роде. И сгенерированный компилятором asm будет иметь используется адресация относительно ПК, а не абсолютная.)

person Peter Cordes    schedule 06.08.2020