Это перевод Поста Allocating Memory for DMA in Linux

В этом посте мы рассмотрим распределение памяти в Linux с использованием очень больших страниц с тем, чтобы разрешить использование этой памяти из системы Linux совместно с устройствами PCIe, использующими DMA.


Недавно я имел удовольствие написать некоторый пользовательский код, который управляет картой Ethernet, в частности Intel i350 и ее родственниками. Часть интерфейса с устройством требует совместного использования памяти, содержащей дескрипторы пакетов и буферы (одновременного использования памяти из Linux и устройством на PCI шине). Устройство использует эту память для обмена принятыми и подготовленными для передачи пакетами Ethernet.

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

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

1. Как устройства могут получить доступ к памяти

В наши дни устройства, подключаемые к компьютеру, обычно подключаются через PCI Express (сокращенно PCIe). Такие устройства обычно имеют поддержку доступа к памяти через DMA (Прямой Доступ к Памяти, ПДП).

До появления DMA, когда устройство хотело записать данные в память, ему приходилось прерывать работу центрального процессора. Центральный процессор считывал данные с устройства PCI в регистр, а затем копировал значение из регистра в память. Это означало, что работа центрального процессора прерывалась каждый раз, когда устройство хотело прочитать/записать память. Это было далеко не идеальное решение, особенно по мере увеличения числа устройств, требующих доступа к памяти.

DMA был введен для того, чтобы позволить устройствам напрямую обращаться к системной памяти, не прерывая работу процессора. В этой модели дополнительное устройство (называемое контроллер DMA) будет обрабатывать детали передачи данных в память. Позднее устройства нового типа (часто называемые Bus Masters: контроллер шины данных) будут интегрировать функциональность DMA локально, устраняя необходимость в дискретном механизме DMA.

В этой более поздней модели PCI, North Bridge декодирует адрес и распознает себя как цель транзакции PCI, и во время фазы передачи данных общего цикла шины (включающего фазу передачи данных и адреса как минимум) данные передаются между устройством и Северным мостом, который, в свою очередь, выдает циклы шины DRAM для связи с системой памятью.

 Термин "Управление шиной" по-прежнему используется в PCI для того, чтобы позволить устройству инициировать DMA. Часто все еще необходимо включить управление шиной на многих устройствах, и регистр команд в пространстве конфигурации  configuration space PCI содержит флаг для разрешения bus mastering (управления шиной).

 При программировании устройства, подключенного через PCIe, вы, как правило, вводите базовый адрес для области памяти, которую вы подготовили для доступа устройством. Однако эта память не может быть выделена обычным способом. Это связано с тем, как адреса памяти преобразуются MMU  и операционной системой – память, которую мы традиционно получаем из операционной системы, является виртуальной.

2.    Виртуальные и физические адреса

Типичное распределение памяти, например, когда мы используем malloc или new, в конечном итоге использует память, зарезервированную операционной системой для нашего процесса. Адрес, который мы получаем от ОС, будет адресом в  виртуальной памяти, поддерживаемой ОС.

Этот виртуальный адрес используется нашим процессом при доступе к памяти, и ЦП (CPU) преобразует виртуальный адрес в адрес физической памяти с помощью MMU.

Устройства, использующие DMA для доступа к памяти, будут обходить ЦП при доступе к памяти, поскольку северный мост напрямую преобразует определенные сообщения PCI в циклы DRAM. Поскольку этот процесс не задействует MMU, преобразование виртуальных адресов невозможно.

 Это приводит к тому, что любая память, которую вы выделяете с помощью malloc или new, приведет к получению адреса виртуальной памяти, который, если передать устройству как есть, приведет к тому, что это устройство попытается получить доступ к совершенно другой области памяти, отличной от физического местоположения(-ий), связанного с виртуальным адресом. Поскольку центральный процессор больше не участвует в процессе, защита от устройств, обращающихся к произвольным областям системной памяти, практически отсутствует. Это может иметь множество непреднамеренных последствий – от перезаписи важных областей памяти до полномасштабной утечки конфиденциальных данных. Очень мило, вы не находите?

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

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

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

Чтобы решить эти две основные проблемы, нам нужно найти альтернативные способы выделения памяти, отличные от традиционных malloc и new. Более того, поскольку нам, скорее всего, понадобится больше места, чем стандартная страница памяти (обычно 4 КБ), нам необходимо выделять память, используя более крупные страницы памяти.

3.    Назначение физических адресов

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

Ответ на этот вопрос содержится в карте страниц процесса. Карта страниц - это таблица, которая обеспечивает соответствие между номером виртуальной страницы и физическим адресом этой страницы, а также некоторыми флагами, которые сообщают нам информацию о местонахождении страницы. Каждая запись в таблице представляет собой 64-разрядное значение, причем биты с 63 по 55 задают различные флаги, а биты с 54 по 0 задают номер фрейма страницы (при условии, что страница находится в оперативной памяти).

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

Карта страниц находится в подкаталоге /proc для каждого процесса. Процесс может получить доступ к этой таблице, открыв файл /proc/self/pagemap.

///////открытие карты страниц процесса
int fd = open("/proc/self/pagemap", O_RDONLY);
assert(fd != -1);

Для заданного виртуального адреса нам нужно вычислить страницу, на которой находится адрес. Для этого нам нужно знать размер страницы. Обычно это будет 4 Кбайт, но он может варьироваться в зависимости от разных архитектур. Мы можем получить размер страницы, используя функцию sysconf и запросив _SC_PAGESIZE. Учитывая этот размер страницы, мы можем затем просто разделить виртуальный адрес (как uintptr_t) на размер страницы, чтобы получить номер виртуальной страницы.

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

////////Чтение записи из карты страниц
int res = lseek64(fd,
                  (uintptr_t)vaddr / page_size * sizeof(uintptr_t),
                  SEEK_SET);
assert(res != -1);

Теперь, когда мы прочитали запись из карты страниц, мы можем сначала проверить, действительно ли адрес присутствует в оперативной памяти. Сверившись с нашими флагами, мы увидим, что для этого должен быть установлен бит 63.

physical_address = PFN * page_size

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

physical_address = PFN * page_size + (vaddr % page_size)

Собрав все это вместе, мы получаем функцию virtual_to_physical, которая сопоставляет адрес в виртуальном адресном пространстве процесса физическому адресу.

///////////////Преобразование виртуального адреса в физический адрес
static uintptr_t virtual_to_physical(const void *vaddr) {
  auto page_size = sysconf(_SC_PAGESIZE);
  int  fd        = open("/proc/self/pagemap", O_RDONLY);
  assert(fd != -1);

  int res = ::lseek64(fd, (uintptr_t)vaddr / page_size * sizeof(uintptr_t), SEEK_SET);
  assert(res != -1);

  uintptr_t phy = 0;
  res = read(fd, &phy, sizeof(uintptr_t));
  assert(res == sizeof(uintptr_t));

  close(fd);

  assert((phy & BIT(63)) != 0);
  return (phy & 0x7fffffffffffffULL) * page_size
         + (uintptr_t)vaddr % page_size;
}

Теперь мы можем использовать функцию virtual_to_physical, чтобы определить физический адрес некоторой памяти, которую мы выделяем из операционной системы. Это адрес, который мы передадим нашему оборудованию.

4. Очень большие страницы памяти в Linux

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

Операционная система Linux предоставляет возможность выделять память страницами размером более 4 Кбайт: огромными страницами. Эти огромные страницы также и обрабатываются несколько иначе, чем обычная память процесса.

 Linux включает поддержку так называемой hugetlbpage, которая обеспечивает доступ к страницам большого размера, поддерживаемыми современными процессорами. Обычно процессор x86 поддерживает страницы размером 4 Кбайт и 2 Мб, а иногда и 1 Гб.

Выделенные огромные страницы резервируются ядром Linux в пуле огромных страниц. Эти страницы будут предварительно выделены и не могут быть заменены, когда система испытывает нехватку памяти. Резервирование этих огромных страниц зависит от наличия физически непрерывной памяти в системе. Ядру обычно дается указание организовать резервирование огромных страниц двумя способами (но перечислено почему-то три):

1.      Настраивается при загрузке путем указания параметра hugepages=N в командной строке загрузки ядра, или

2.      Динамически, записью в /proc/sys/vm/nr_hugepages, или

3.      Динамически, записью в соответствующий файл nr_hugepages для узла NUMA.

Например, количество страниц размером 2 Мб, зарезервированных для узла NUMA 0, находится в файле в следующем расположении:

/sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages

Запись в этот файл приведет к динамическому изменению количества огромных страниц, выделенных для соответствующего узла NUMA. Например, чтобы выделить 32 огромные страницы по 2 Мб на узел NUMA в системе с 8 узлами NUMA, вы могли бы запустить следующий скрипт:

////////////выделение 32 огромных страниц на восьми узлах NUMA
NUMA_DIR="/sys/devices/system/node"
HUGEPAGE_DIR="hugepages/hugepages-2048kB"

for i in {0..7}; do
  echo 32 > $NUMA_DIR/node$i/$HUGEPAGE_DIR/nr_hugepages
done

Следует отметить, что ядро попытается выделить и распределить пул огромных страниц для всех узлов NUMA. Более того, если резервирование физически непрерывной памяти под подмножеством узлов NUMA завершится неудачей, ядро может попытаться завершить резервирование, выделив дополнительные страницы на других узлах. Это может привести к некоторым узким местам, если предположить, что все ваши огромные страницы находятся в одном и том же наборе NUMA.

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

5. Обеспечение доступности огромных страниц

Первым шагом к выделению огромных страниц является определение того, какие огромные страницы нам доступны. Для этого мы собираемся запросить некоторые файлы в каталоге /sys/kernel/mm/hugepages. Если настроены какие-либо огромные страницы, этот каталог будет содержать подкаталоги для каждой огромной страницы:

$ ls /sys/kernel/mm/hugepages
hugepages-1048576kB hugepages-2048kB

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

$ tree /sys/kernel/mm/hugepages
/sys/kernel/mm/hugepages/
├── hugepages-1048576kB
│   ├── free_hugepages
│   ├── nr_hugepages
│   ├── nr_hugepages_mempolicy
│   ├── nr_overcommit_hugepages
│   ├── resv_hugepages
│   └── surplus_hugepages
└── hugepages-2048kB
    ├── free_hugepages
    ├── nr_hugepages
    ├── nr_hugepages_mempolicy
    ├── nr_overcommit_hugepages
    ├── resv_hugepages
    └── surplus_hugepages

Краткое описание каждого из этих файлов приведено ниже, а более точное описание можно найти в hugetlbpage.txt  документации.

  • free_hugepages – Количество огромных страниц в пуле, которые еще не выделены.

  • nr_hugepages – Количество постоянных огромных страниц в пуле. Это страницы, которые после освобождения будут возвращены в пул.

  • nr_hugepages_mempolicy – следует ли выделять огромные страницы через интерфейс /proc или /sys.

  •   nr_overcommit_hugepages – Максимальное количество избыточных огромных страниц.

  • resv_hugepages – количество огромных страниц, которые операционная система обязалась зарезервировать, но распределение которых еще не завершено.

  • surplus_hugepages – Количество избыточных огромных страниц, которые находятся в пуле выше, чем указано в nr_hugepages, ограничено nr_overcommit_hugepages.

Вы можете использовать эти файлы для определения такой информации, как, успешно ли выполнено распределение, на основе количества доступных огромных страниц, указанного в free_hugepages. Для простоты, поскольку на самом деле нас интересует только процесс выделения этих огромных страниц, мы проигнорируем содержание этих файлов в этом посте.

Для того чтобы наша программа могла обрабатывать доступные огромные страницы, мы загрузим некоторую информацию из каталога /sys/kernel/mm/hugepages и инкапсулируем ее в структуру HugePageInfo.

namespace fs = std::experimental::file_system;

struct HugePageInfo {
  std::size_t size; // The size of the hugepage (in bytes)

  HugePageInfo(const fs::directory_entry &);

  // Allocate a huge page in this pool
  HugePage::Ref allocate() const;

  // Load all the available huge page pools
  static std::vector<HugePageInfo> load();
};

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

Когда мы создаем HugePageInfo структуру, мы передаем directory_entry, который представляет подкаталог в /sys/kernel/mm/hugepages. У этого подкаталога будет имя, которое включает размер огромных страниц, которые могут быть размещены в этой таблице огромных страниц. Мы будем использовать регулярное выражение, чтобы извлечь размер страницы из имени каталога, прежде чем мы его проанализируем.

static const std::regex HUGEPAGE_RE{"hugepages-([0-9]+[kKmMgG])[bB]"};

HugePageInfo::HugePageInfo(const fs::directory_entry &entry) {
  // Extract the size of the hugepage from the directory name
  std::smatch match;
  if (std::regex_match(entry.path().filename(), match, HUGEPAGE_RE)) {
    size = parse_suffixed_size(match[1].str());
  } else {
    throw std::runtime_error("Unable to parse hugepage name");
  }
}

Чтобы загрузить все доступные огромные страницы, мы можем просканировать каталог /sys/kernel/mm/hugepages и создать экземпляр HugePageInfo для каждого подкаталога. Эта задача выполняется методом Huge pageInfo::load.

///Загрузка доступной информации об огромных страницах из /sys/kernel/mm/hugepages
static const fs::path SYS_HUGEPAGE_DIR = "/sys/kernel/mm/hugepages";

std::vector<HugePageInfo> HugePageInfo::load() {
  std::vector<HugePageInfo> huge_pages;
  for (auto &entry : fs::directory_iterator(SYS_HUGEPAGE_DIR)) {
    huge_pages.emplace_back(entry);
  }

  return huge_pages;
}

6. Выделение огромной страницы

Каждое выделенная область в огромных страницах описывается HugePage структурой. Эта структура инкапсулирует виртуальный и физический адрес выделенной огромной страницы вместе с размером страницы в байтах.

struct HugePage {
  using Ref = std::shared_ptr<HugePage>;

  void *      virt;
  uintptr_t   phy;
  std::size_t size;

  HugePage(void *v, uintptr_t p, std::size_t sz)
    : virt(v), phy(p), size(sz) {
  }

  ~HugePage();
};

Чтобы выделить огромную страницу, нам нужно использовать системный вызов mmap с флагом MAP_HUGETLB. Если бы мы не использовали флаг MAP_HUGEPAGE, тогда нам нужно было бы смонтировать hugetlbfs требуемого размера где-нибудь в каталоге и создать файлы в этой файловой системе. Мы бы предпочли избежать этого метода, поэтому вместо этого мы используем MAP_HUGETLB.

Поскольку мы не создаем файла для этого выделения, нам нужно использовать флаг MAP_ANONYMOUS. Портируемое приложение, использующее MAP_ANONYMOUS, должно установить файловый дескриптор равным -1 и передать ноль в качестве смещения.

////Выделение огромной страницы и отображение ее в память процесса
HugePage::Ref HugePageInfo::allocate() const {
  // Map a hugepage into memory
  void *vaddr = (void *)mmap(NULL, size,
                             PROT_READ | PROT_WRITE,
                             MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
                             -1, 0);
  assert(vaddr != MAP_FAILED);

  return std::make_shared<HugePage>(vaddr, virtual_to_physical(vaddr), size);
}

Значение, которое мы возвращаем из allocate, создает огромную страницу с виртуальным адресом, который мы получили из mmap, эквивалентным физическим адресом, вычисленным нашей функцией virtual_to_physical, и размером этой страницы.

7. Деалокация огромной страницы

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

Деструктор HugePage будет использовать системный вызов munmap для удаления огромной страницы из процесса.

////освобождение огромной страницы обратно в ОС
HugePage::~HugePage() {
  int rc = munmap(virt, size);
  assert(rc != -1);
}

8. Разделение огромной страницы на буферы

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

<пропущено>

9. Заключение

Подготовка памяти для использования с DMA может показаться немного более сложной, чем необходимо. Как разработчики, мы часто защищены от деталей управления памятью полезными абстракциями, такими как те, которые предоставляются malloc и new. Это может означать, что мы редко сталкиваемся с тем, как операционная система и наши программы управляют памятью.

Я надеюсь, что этот пост может быть полезен тем из вас, кому необходимо взаимодействовать через память с устройствами, подключенными к шине PCI. Вы можете найти полный список в виде GitHub gist:

<ссылку вы найдете в исходном тексте поста>

Комментарии (2)


  1. firehacker
    28.12.2023 22:12

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

    Топорный перевод. Обычно топорный перевод палится по огромному количеству местоимений «это». Что-то вроде «это хорошо, потому что это делает вас счастливее».

    А что «это» в данном месте? Переводчик не удосужился вернуться на предложение назад и увидеть, что местоимение «it» у автора ссылается на «биты с 54 по 0». И, соответственно, писать надо:

    При других обстоятельствах они (биты) могут обозначать ...


  1. checkpoint
    28.12.2023 22:12

    В чем смысл всего этого текста ? Даже добравшись до физических адресов из userspace всё равно запрограммировать DMA не выйдет, так как это нарушает целостность системы. Если даже каким-то образом удатся запрограммировать DMA, то смысла в этом тоже не много - операционная система может в любой момент перераспределить память без ведома процесса, в итоге DMA будет писать (или читать) в чужую область памяти. Ну и вишенка - физические адреса уже давно не физические. Автор скорее всего не слышал про IOMMU.

    Работа с DMA должна происходить только из ядра ОС, используя специальный API для выделения непрерывного куска памяти и преобразования адресов.