Расширение SOS для WinDbg определяет команду! DumpStackObjects (или! Dso), которая позволяет вывести список всех ссылок в стеке для данного потока. Удобно получать значения аргументов или локальных переменных методов в стеке вызовов. Недавно для расследования я столкнулся с необходимостью сбросить эти значения для нескольких тысяч потоков. Очевидно, это не то, что вам нужно делать вручную, поэтому я проверил, что можно сделать с помощью ClrMD.

ClrMD определяет EnumerateStackObjects метод для объекта ClrThread, который, кажется, делает именно то, что нужно. Чтобы убедиться в этом, мы можем написать эту небольшую программу и сравнить результат с командой! DumpStackObjects:

Посмотрим, что здесь происходит. EnumerateStackObjects возвращает список ClrRoot экземпляров. Эти экземпляры представляют собой ссылки. Поле Address содержит адрес ссылки в стеке, тогда как поле Object содержит адрес объекта, на который указывает ссылка (значение, которое может вас заинтересовать). Если мы сравним вывод нашей программы с WinDbg, он действительно совпадает:

То есть… в Windows. Для моего исследования мне нужно было проделать то же самое с ядром ядра Linux. ClrMD совместим с Linux, так что это не должно быть проблемой, по крайней мере, я так думал. Но когда я запустил тот же код в Linux, никаких объектов стека обнаружено не было.

Копаясь в реализации EnumerateStackObjects, кажется, что она получает диапазон адресов стека, проверяя ClrThread.StackBase и ClrThread.StackLimit, а затем проверяет каждое значение в этом диапазоне, чтобы найти ссылки. В моем случае и StackBase, и StackLimit вернули 0, поэтому логично, что алгоритм ничего не найдет. Так как же реализованы StackBase и StackLimit?

В ClrMD StackBase и StackLimit считываются из блока среды потока (TEB). Содержимое TEB извлекается непосредственно из DAC с помощью метода GetThreadData. Если мы посмотрим на реализацию этой функции в репозитории CoreCLR, проблема станет совершенно очевидной:

FEATURE_PAL определен на всех платформах, кроме Windows. Это означает, что при запуске ядра .net в Linux информация TEB потока будет пустой. На самом деле это имеет смысл, поскольку TEB - это концепция Windows, но как тогда функция SOS DumpStackObjects работает в Linux?

На этот раз ответ лежит в репозитории dotnet / Diagnostics. Вот урезанная версия только с интересными деталями:

Код начинается с получения текущего указателя стека из регистров путем вызова метода GetStackOffset и присваивается ему StackTop. Затем, только в Windows, он читает TEB, чтобы получить адрес начала стека. Для других платформ существует резервный код, в котором StackBottom инициализируется значением StackTop + 0xFFFF, если значение не было присвоено. Этот прием меня немного озадачивает. 0xFFFF (64 КБ) намного меньше, чем размер стека по умолчанию, и в то же время я не думаю, что он предлагает какую-либо гарантию того, что мы не будем читать память за пределами стека (я некоторое время искал, но не смог найти какие-либо доказательства того, что защитная страница размещена над стеком).

Как бы удивительно это ни выглядело, наша цель - просто имитировать вывод DumpStackObject, поэтому можем ли мы реализовать ту же логику в ClrMD?

Для этого нам нужен способ получить значение регистра RSP, чтобы получить смещение стека. Это делается путем вызова метода GetThreadContext с идентификатором потока. Он принимает в качестве параметра массив байтов (или IntPtr), в котором сериализуется содержимое структуры, представляющей регистры. Эта структура может быть AMD64Context, ArmContext или Arm64Context в зависимости от целевой архитектуры. В нашем случае нас интересует только AMD64:

Получив эту информацию, мы можем начать просматривать стек и искать ссылки (код беззастенчиво копируется из образцов ClrMD):

Мы начинаем с попытки разыменовать любое значение, которое мы находим в стеке, затем вызываем ClrHeap.GetObjectType, чтобы узнать, указывает ли оно на действительный объект. Если да, то выводим результат в консоль.

Я попытался запустить этот код, и он отлично работал для нескольких сотен потоков, а затем вылетел из-за ошибки сегментации. Мне удалось отследить это до звонка на ClrHeap.GetObjectType с конкретным адресом. Вероятно, мы обнаружили указатель на значение за пределами управляемой кучи, что вызывает непредсказуемое поведение. Как SOS с этим справляется? Оглядываясь назад на реализацию, похоже, что мы забыли о проверке работоспособности:

С помощью ClrMD мы можем достичь того же результата, перечислив сегменты GC и убедившись, что наш адрес находится внутри одного из них:

Теперь у нас есть все необходимое для завершения реализации DumpStackObjects для Linux:

И на этот раз, как и ожидалось, мы получаем тот же результат, что и команда! DumpStackObjects: