Отладка утечек памяти в модуле ядра

В ОС Линукс есть средства для проверки и наблюдения за работой кода в режиме ядра. В частности, есть Детектор Утечек Памяти в Ядре [1], который отслеживает операции выделения и освобождения памяти.

Для поиска и отладки утечек памяти в своём модуле ядра нужно сделать 3 шага:

  1. включить kmemleak;
  2. делаем тестовые прогоны и получаем отчёт об утечках. Если утечек нет, то завершаем поиск;
  3. находим место утечки и исправляем. Переходим к п. 2.

Включаем kmemleak

kmemleak, как очевидно из названия, это средство ядра Линукс-а по поиску утечек памяти. В обычной сборке ядра это средство отключено (так как оно подъедает много ресурсов) и прежде всего его нужно включить. Включается это средство при сборке ядра и на выходе получается ядро Линукса, которое подходит для отладки, и не очень хорошо подходит для всего остального. Для разрешения этих противоречий можно либо жонглировать ядрами, либо (очень хороший вариант) сделать отдельную виртуальную машину, на которой будет крутиться «отладочное» ядро.

И в нашем случае (отдельная виртуальная машина) задача включить kmemleak немного разрастается:

  1. установить ПО виртуализации и ОС;
  2. настроить новое отладочное ядро;
  3. собрать отладочное ядро.

Поехали!

Установить ПО виртуализации и ОС

Для виртуализации можно использовать различные решения. В моём случае хорошим вариантом является qemu. Про установку qemu можно найти статьи в интернете, например [2].

Установку ОС лучше всего начинать с установки обычной, не отладочной, версии. Такой шаг позволяет получить настройки ОС, которые потребуются позже. Обычная установка не представляет особых сложностей: скачиваем образ (в нашем случае — Debian) и с него ставим операционную систему. Для удобства работы с ОС рекомендуется поставить midnight commander и ssh сервер (пакеты mc и ssh).

Далее нужно загрузить исходный код ядра. Делается это через команду:

sudo apt install linux-source

Отмечу также, что также можно собрать ядро немного другой версии (если вдруг потребуется)

посмотреть версии ядра:

sudo apt search ^linux-source

загрузить явно указанную версию ядра (например 5.10):

sudo apt install linux-source-5.10

В результате в папке /usr/src появится файл linux-source-5.10.tar.xz или аналогичный. Содержимое архива распаковываем в удобную папку, например в ~/kernel/

Кроме исходных файлов ядра потребуется конфигурационный файл, в котором расписаны все настройки для сборки ядра. Создавать такой файл с нуля занятие совсем неблагодарное, поэтому берём готовый (который получился при установке обычной версии): в папке /boot находим файл config-zzz.zzz и копируем его в папку с исходным кодом (например, ~/kernel/linux-source-5.10) под именем .config . Имя, в принципе, можно выбрать любое, однако учтите, что это «любое» имя тогда придётся вводить вручную. Что же касается имени .config — то оно ставится по умолчанию.

Кроме исходных файлов потребуется ещё ряд других пакетов (это с учётом того, что gcc ставится вместе с исходным кодом ядра):

build-essential libncurses-dev libelf-dev libssl-dev rsync dwarves

Всё, исходный код готов к настройке.

Настроить новое отладочное ядро

Отладочное ядро, которое отслеживает утечки памяти, по сути отличается одним параметром: CONFIG_DEBUG_KMEMLEAK — его нужно активировать (установить в «y»). В принципе, это всё можно сделать вручную, однако ошибиться в этом случае можно на раз-два. Поэтому будем делать через настроечную утилиту. Настроечная утилита также поможет изменить другие настройки, которые скорее всего потребуется поменять.

В корневой папке исходного кода (например, ~/kernel/linux-source-5.10) делаем сборку и запуск утилиты:

make menuconfig

Будет собрана и запущена утилита настройки ядра:

Отметим, что сразу же загрузился файл .config. Если же хочется поработать с другим настроечным файлом, то есть кнопочки Load и Save.

Идём в раздел Kernel hacking:

Далее в раздел Memory Debugging:

В разделе Memory Debugging выбираем Kernel memory leak detector:

Выбранный раздел активируется и покажет несколько вложенных пунктов:

Здесь можно оставить настройки без изменений. Таких настроек хватает, чтобы сохранить порядка 20 записей об утечках памяти. Если нужно больше, то можно увеличить размер пула.

ЕСЛИ ОШИБКА и вы не видите соответствующие пункты меню, например, как на следующем скрине:

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

Ещё немного настроек: если вы не собираетесь завязываться на проверку собранного ядра (а вы не собираетесь, т.к. это отладочное ядро для внутреннего применения), то лучше запретить соответствующий функционал. Заходим в Cryptographic API:

Идём в раздел сертификатов Certificates for signature checking:

и имя файла с сертификатом uefi-pem вычищаем до пустой строки:

После всех настроек сохраняем результат.

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

Собрать отладочное ядро

Запускаем сборку ядра:

make deb-pkg

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

sudo dpkg -i linux-image-zzzz.zzzz_zzzz.zzzz.deb

sudo dpkg -i linux-headers-zzzz.zzzz_zzzz.zzzz.deb

Установка нового ядра сразу же пропишет его в загрузчик grub. Нужно только перегрузиться и вуаля! — появился файл /sys/kernel/debug/kmemleak. А если не появился, то проверяем по шагам описанную выше последовательность действий.

Тестовый прогон

Для тестового прогона нам потребуется сделать несколько действий (которые лучше всего загнать в скрипт):

  • собрать модуль в новом ядре с новыми заголовочными файлами;
  • получаем ассемблерный код модуля:
    objdump -DS yourmodule.ko > ~/disasm.txt
  • очищаем состояние kmemleak:
    echo clear | sudo tee /sys/kernel/debug/kmemleak
  • запускаем модуль
  • получаем адреса, по которым загрузился модуль:
    cd /sys/module/yourmodule/sections
    sudo cat .text .data .bss >> ~/sections.txt
  • тестируем функционал модуля, в котором предполагаем утечку памяти
  • сканируем память на предмет утечек:
    echo scan | sudo tee /sys/kernel/debug/kmemleak
  • сохраняем дамп скана:
    sudo cat /sys/kernel/debug/kmemleak > ~/memleak.txt

Пройдёмся по этапам подробнее и уточним некоторые детали.

Модуль

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

В дальнейшем в процессе отладки утечек памяти вам потребуются две вещи:

  • ассемблерный код модуля (его мы получаем через команду objdump);
  • адреса секций, по которым модуль загрузился (содержимое файлов .text и др.). Адреса необходимо получать «текущие», на уже запущенном модуле. Секции могут быть разные, соответственно их имена нужно смотреть в зависимости от вашего модуля.

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

Дамп kmemleak

Kmemleak взаимодействует с пользователем через файл (очевидно с именем kmemleak), в который можно записывать команды (через tee, чтобы работать из-под пользователя) и вычитывать результат. По умолчанию средство отслеживания утечек включено сразу же, оно постоянно мониторит ядро и модули, записывает информацию в свой пул.

Чтобы отслеживать утечки памяти именно нашего модуля используем две команды:

clear — очистить пул от предыдущей информации — обычно делается перед запуском модуля. Как вариант, очистку можно делать перед предполагаемой утечкой;

scan — просканировать текущее состояние и записать в пул — делается после выполнения «плохих» действий с утечками.

Для получения текущего состояния пула вычитываем этот файл.

Итак, собрали всю информацию по утечкам, приступаем к разбору.

Находим место утечки

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

Из дампа получаем набор записей. Что-то наподобие следующего:

unreferenced object 0xffff941b880c5a00 (size 256):
  comm "ln", pid 25594, jiffies 4297528255 (age 69.936s)
  hex dump (first 32 bytes):
    00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
    08 00 08 00 31 00 00 00 00 00 00 00 00 00 00 00  ....1...........
  backtrace:
    [<0000000084843d48>] 0xffffffffc0d3c090
    [<00000000a65c3060>] 0xffffffffc0d3c276
    [<00000000c7ed9d90>] 0xffffffffc0d3d653
    [<000000006b97b67e>] 0xffffffffc0d3dd08
    [<00000000201065fd>] vfs_symlink+0x122/0x1d0
    [<000000005201bd7c>] do_symlinkat+0x11d/0x130
    [<0000000012388a50>] do_syscall_64+0x30/0x80
    [<0000000090c08e48>] entry_SYSCALL_64_after_hwframe+0x62/0xc7

Что мы здесь видим? Есть некоторый объект по адресу 0xffff941b880c5a00, размером 256 байт. Объект появился в результате команды «ln». Также приведены первые 32 байта этого объекта, возможно вы сможете быстро понять, что это за объект.

Ещё приведён стэк вызовов, которые привели к появлению «объекта». В данном случае мы видим вызовы в ядре do_syscall_64 и т.п. И, очевидно, верхние четыре записи относятся к нашему коду и нам придётся в них разобраться. В целом, всё не так сложно, но немного неудобно. Разберём на примере адреса 0xffffffffc0d3d653. Итак:

Берём адреса секций (из файла sections.txt). В моём случае это:

.text 0xffffffffc0d3a000
.data 0xffffffffc0d44240
.bss 0xffffffffc0d44800

Здесь мы видим, что адрес 0xffffffffc0d3d653 относится к первой секции (больше 0xffffffffc0d3a000 и меньше 0xffffffffc0d44240). В таком случае адрес секции будет смещением и если мы вычтем из адреса инструкции смещение, то получим адрес в ассемблерном коде:

0xffffffffc0d3d653 — 0xffffffffc0d3a000 = 0x3653

Находим наш код (из файла disasm.txt):

 for (i = 0; i < fba; ++i) {
    3636:	4d 85 ed             	test   %r13,%r13
    3639:	74 59                	je     3694 <tagfs_get_fileino_by_name+0xd4>
    363b:	45 31 f6             	xor    %r14d,%r14d
    struct qstr res;
    int cmp;
    if (GetFileInfo(sr, i, mask, &res, NULL)) {
    363e:	45 31 c0             	xor    %r8d,%r8d
    3641:	48 8d 4d b8          	lea    -0x48(%rbp),%rcx
    3645:	48 89 da             	mov    %rbx,%rdx
    3648:	4c 89 f6             	mov    %r14,%rsi
    364b:	4c 89 e7             	mov    %r12,%rdi
    364e:	e8 00 00 00 00       	callq  3653 <tagfs_get_fileino_by_name+0x93>
    3653:	85 c0                	test   %eax,%eax
    3655:	75 34                	jne    368b <tagfs_get_fileino_by_name+0xcb>
      continue;
    }
    cmp = compare_qstr(res, name);
    3657:	48 8b 55 a8          	mov    -0x58(%rbp),%rdx
    365b:	48 8b 7d b8          	mov    -0x48(%rbp),%rdi
 

Из кода получаем команду по адресу 0x3653: 3653: 85 c0 test %eax,%eax

Здесь может быть два вариант: либо сама команда выделяет память (не наш случай), либо в предыдущей команде есть вложенный вызов кода, который выделяет память. Второй вариант — наш случай и предыдущая команда:

callq 3653 <tagfs_get_fileino_by_name+0x93>

Команда является вызовом функции. Какой функции? И здесь листинг кода нам подсказывает:

GetFileInfo(sr, i, mask, &res, NULL)

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

Ну и далее всё стандартно: ide — bugfix — compile — etc. В общем, удачи!

Кислов Евгений.

Внешние ссылки:

  1. Kernel Memory Leak Detector
  2. virt-manager в Debian 10 и 11, ставим вместо VirtualBox