воскресенье, 19 ноября 2017 г.

Windbg debugging series, part 3 of n, make_shared vs new

Третья часть, в которой я немного покажу, как восстанавливать информацию об объектах в windbg без приватных символов (только публичные), а также немного поясню почему make_shared лучше чем сырой вызов new, и как вообще это связано с отладкой.

Введение

Итак, снова дамп - был снят для обычного user-mode приложения, для которого, по странному стечению обстоятельств были только публичные части отладочной информации. Если мне не изменяет память, то дамп случился из-за одного assert, и для того, чтобы понять, почему выстрелил этот assert, необходимо было восстановить информацию об объектах и проанализировать их содержимое.

Что такое приватные и публичные символы и в чем их отличие

Начну немного издалека. Для PE образов (коими являются .exe, .dll, .sys) есть стандартный (для microsoft) формат хранения отладочной информации - PDB файлы. Так вот, эти pdb бывают двух видов - публичные и приватные. Приватные символы содержат в себе полную информацию о модуле - локальные переменные, всю информацию о типах и т.д. В то время как публичные символы содержат информацию только о non-static функциях и глобальных объектах - иными словами, все то, что торчит наружу из соответствующего объектного файла. Также, для публичных символов информация о локальных аргументах не сохраняется, поэтому тяжело смотреть call-stack без публичных символов. Еще один существенный минус - для публичных PDB не сохраняется привязка бинарного кода к исходному коду, другими словами, отладчик не в состоянии сопоставить строки исходного кода и соответствующую ассемблерную инструкцию.

Тестовое приложение

Ниже приведено тестовое приложение, в котором сделана самая примитивная (и самая неправильная) реализация shared_ptr:

#include <new>

struct shared_count_base {
    size_t strong = 0;
    size_t weak = 0;
};

template <typename T>
struct shared_count_value : public shared_count_base {
    T value;
};

template <typename T>
struct shared_count_ptr : public shared_count_base {
    T* value = nullptr;
};

template <typename T>
struct shared_ptr {
    shared_ptr() = default;
    shared_ptr(T* v) : ref(new shared_count_ptr<T>), ptr(v) {
    }
    shared_ptr(shared_count_base* sp, T* v): ref(sp), ptr(v) {
    }
    
    shared_count_base* ref = nullptr;
    T* ptr = nullptr;
    T* operator ->() const{
        return ptr;
    }
    T& operator*() {
        return *ptr;
    }
};

template <typename T, typename Y>
shared_ptr<Y> make_shared() {
    auto sp = new shared_count_value<T>;
    return shared_ptr<Y>{sp, &sp->value};
}


struct Iface {
    virtual ~Iface() = default;
    virtual void Do() = 0;
};

struct Impl : Iface {
    Impl() :val(make_shared<int, int>()) {
        *val = 42;
    }
    virtual void Do() override {
        __debugbreak();
    }
    shared_ptr<int> val;
};

void Foo(const shared_ptr<Iface>& i) {
    i->Do();
}

void MakeShared() {
    auto i = make_shared<Impl, Iface>();
    Foo(i);
}

void New() {
    auto ptr = new Impl();
    shared_ptr<Iface> i(ptr);
    Foo(i);
}

int main() {
    MakeShared();
    New();
}



В этом тестовом примере мы создаем один shared_ptr двумя разными способами, а затем передаем его в функцию Foo, которая делает некоторую полезную нагрузку в зависимости от значения Impl::val. Этот код, по сути, очень сильно упрощенная реализация того, с чем нам пришлось столкнуться в процессе анализа дампа.

Вот параметры компиляции для получения полной (private) отладочной информации:
cl /O2 /Oy- /Zi /Fd:private_dir/main.pdb /Fe:private_dir/main.exe /EHsc main.cpp /link /debug /pdb:private_dir/main.pdb

А вот параметры компиляции для получения только публичной (public) части отладочной информации
cl /O2 /Oy- /Zi /Fd:public_dir/main.pdb  /Fe:public_dir/main.exe /EHsc main.cpp /link /debug /pdb:public_dir/main.pdb /pdbstripped:public_dir/main_s.pdb

Здесь директории private_dir и public_dir - отдельные директории, в которые складываются соответственно информация при сборке приватных и публичных символов. Мы также себе немного усложним задачу - включим оптимизацию /O2. Если хочется совсем хардкора, то можно убрать флажок /Oy- чтобы включить Frame Pointer Omission.

Небольшое отступление - почему я собрал с /O2 - потому-что, в неоптимизированном билде компилятор упрощает отладку (дублирует аргументы из регистров на стек в x86-64, не инлайнит функции и методы). Поэтому в дебаге обычно все намного проще. Здесь же, если посмотреть дизассемблер, то можно увидеть, что компилятор соптимизировал метод Impl::Do вот до такого кода:

main!Impl::Do:
01336f10 cc              int     3
01336f11 c3              ret


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

Анализ дампа

Стандартное начало анализа:
.logappend windbg_analysis.log 
.reload /f
!analyze -v

Но после выполнения этих команд мы и остановились, собственно, команда analyze -v нам показала то место (в дизассемблере), в котором произошел assert, но почему он произошел - непонятно, для этого нужно восстановить состояние локальных объектов - в нашей тестовой программе нам нужно найти значение val.
Сравните два скриншота, на одном, windbg показывает локальные объекты, на другом нет




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


Адресное пространство процесса

Начнем с теории - вся виртуальная память в приложении разбита на несколько регионов (адресное пространство для процесса едино, но разные диапазоны этого пространства предназначены для разных целей).
В windbg есть команда !address, эта команда выдает информацию об адресном пространстве:

0:000> !address

  BaseAddr EndAddr+1 RgnSize     Type       State                 Protect      Usage
----------------------------------------------------------------------------------------
+        0    10000    10000             MEM_FREE    PAGE_NOACCESS             Free       

+    a0000    aa000     a000 MEM_PRIVATE MEM_COMMIT  PAGE_READWRITE            Heap       [ID: 0]
     aa000   19f000    f5000 MEM_PRIVATE MEM_RESERVE                           Heap       [ID: 0]


+   c40000   d3c000    fc000 MEM_PRIVATE MEM_RESERVE                           Stack      [~0; 3458.33f4]
    d3c000   d3e000     2000 MEM_PRIVATE MEM_COMMIT  PAGE_READWRITE|PAGE_GUARD Stack      [~0; 3458.33f4]
    d3e000   d40000     2000 MEM_PRIVATE MEM_COMMIT  PAGE_READWRITE            Stack      [~0; 3458.33f4]
    

+  1330000  1331000     1000 MEM_IMAGE   MEM_COMMIT  PAGE_READONLY             Image      [main]
   1331000  1384000    53000 MEM_IMAGE   MEM_COMMIT  PAGE_EXECUTE_READ         Image      [main]
   1384000  1390000     c000 MEM_IMAGE   MEM_COMMIT  PAGE_READONLY             Image      [main]
   1390000  1393000     3000 MEM_IMAGE   MEM_COMMIT  PAGE_READWRITE            Image      [main]
   1393000  1399000     6000 MEM_IMAGE   MEM_COMMIT  PAGE_READONLY             Image      [main]


Мы видим, что регион от [0x00000000, 0x00010000) не замаплен - любое обращение к памяти из этого диапазона генерирует SEH (Access Violation). По сути, этот регион ловит разыменование нулевого указателя.

Затем идет регион, который отведен под кучу - то есть, в этом регионе работают механизмы аллокации памяти (new, malloc, std::allocator и т.д.) - используемый регион [0x000A0000, 0x000AA000).


Дальше идет регион - это стек, причем, обратите внимание, нам даже показывают, для какого потока этот стек (поток ~0, или 33F4). Используемый регион [0x00D3E000, 0x00D40000)


И в конце идет регион, занятый PE образом нашего приложения. Он разбит на несколько кусочков - исполняемый код, неконстантные данные и константные данные. Нас интересует диапазоны [0x01384000, 0x01390000) и [0x01393000, 0x01399000). Почему именно - ответ в следующем пункте.

Как в памяти процесса располагается объект с виртуальными методами.

Снова небольшое лирическое отступление. Когда я устраивался на работу в ЛК, у меня на собеседовании спрашивали, как в памяти располагаются объекты с виртуальными методами - всякие там vtable, указатели на vtable, где они располагаются, как происходит вызов виртуального метода, вот это вот все. Я тогда как-то смог ответить на эти вопросы, но я очень удивился, зачем нужно знать всю эту магию. Мне показалось, что эти вопросы были лишними, и меня просто хотели завалить 😃. Но поработав некоторое время, я понял, что эти знания - суровая необходимость, а не блажь собеседующего - позже станет понятно почему.

Итак, как в памяти представлен объект Impl с виртуальными методами:

|Offset      |Field             |
+------------+------------------+
|this    0x00|vtable_pointer    |
+------------+------------------+
|val     0x04|shared_ptr<int>   |
+------------+------------------+

Или, если развернуть поле val, то будет следующая картина

|Offset      |Field             |
+------------+------------------+
|this    0x00|vtable_pointer    |
+------------+------------------+
|val.ref 0x04|shared_count_base*|
+------------+------------------+
|val.ptr 0x08|int*              |
+------------+------------------+

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

Собираем все вместе

Давайте теперь посмотрим на наш код:

void MakeShared() {
    auto i = make_shared<Impl, Iface>();
    Foo(i);
}

  1.  Сам объект i располагается на стеке - это shared_ptr<Impl>
  2. Указатели в shared_ptr (ref и val) указывают в кучу, причем, разница между адресами составляет 2 * sizeof(size_t) (смотри shared_count_value). Первый указатель указывает на shared_count_base, а второй указатель указывает на T - разница между ними как раз равна 8 в нашем случае.
  3. Указатель i.val указывает на Impl, который тоже располагается в куче, и этот объект тоже содержит три указателя - один на vtable - этот указатель ссылается внутрь секции MEM_IMAGE  и два - на shared_ptr<int>(ref и val) - они находятся в куче.

Таким образом, посмотрев только на наш код, мы уже можем понять примерный алгоритм действий - сначала нужно искать на стеке два указателя, с разницей в 8 (на x86), причем эти указатели должны находиться в диапазоне [0x000A0000, 0x000AA000). Затем смотреть второй указатель и анализировать его содержимое аналогичным образом (учитываея еще vtable).

Осталось определить границы поиска на стеке - можно посмотреть в отладчике call stack где сейчас вершина стека, также можно взять регион [0x00D3E0000x00D40000) - текущий используемый регион стека, только нужно быть осторожным, т.к. на стеке могут лежат "фантомные" значения - значения, которые уже более не актуальны. Я взял последний сохраненный EBP на стеке:

 # ChildEBP RetAddr  Args to Child              
00 00d3f898 0133707a 000a8c08 000a8c10 00d3f8f0 main!Impl::Do

Можно посмотреть содержимое стека вот такой удобной командой:

0:000> dps 00d3f898
00d3f898  0133706f main!main+0xf
00d3f89c  0133707a main!main+0x1a
00d3f8a0  000a8c08
00d3f8a4  000a8c10
00d3f8a8  00d3f8f0
00d3f8ac  01337317 main!__scrt_common_main_seh+0xf9
00d3f8b0  00000001
00d3f8b4  000a4ea8
00d3f8b8  000a99c0
00d3f8bc  b144d927


Эта команда распечатывает значения, начиная с указанного адреса, трактуя их как указатели и пытаясь распознать, чему принадлежит указатель. В данном случае, он  распознает адреса возврата на стеке (сравните с call stack):

00 00d3f898 0133707a 000a8c08 000a8c10 00d3f8f0 main!Impl::Do
01 00d3f8a8 01337317 00000001 000a4ea8 000a99c0 main!main+0x1a
02 00d3f8f0 75cc8744 00a91000 75cc8720 60989f31 main!__scrt_common_main_seh+0xf9

Итак, мы можем увидеть, что внутри кадра функции main есть два значения, которые оба указывают на кучу, и разница между адресами составляет 8 байт, наверное это и есть наш объект! Давайте посмотрим, что лежит по первому адресу:
0:000> dd 000a8c08
000a8c08  00000000 00000000
Скорее всего, это наши счетчики shared_ptr (strong и weak счетчики) - напомню, у нас самый "неправильный" shared_ptr :)

А вот что лежит по второму адресу:
0:000> dd 000a8c10
000a8c10  01384e64 000a8c38 000a8c40
Тут, скорее всего, лежит наш объект: первый адрес принадлежит диапазону  [0x01384000, 0x01390000) - это указатель на таблицу виртуальных функций. Мы это можем легко проверить:

0:000> x main!*Impl*vftable*
01384e64          main!Impl::`vftable' =


Команда x позволяет искать символы, при этом, можно указывать только часть имени, что я здесь и сделал. Компилятор Microsoft называет таблицы виртуальных функций словом vftable (Virtual Functions Table). Обратите внимание, мне распечатался адрес этой таблицы, и он полностью совпадает с тем что мы нашли.

Второй и третий адреса снова указывают в кучу и разница между ними все те же 8 байт - это, наверное, наше значение.

Проверяем их:
0:000> dd 000a8c38 
000a8c38  00000000 00000000
Тут, скорее всего,  тоже счетчики shared_ptr

0:000> dd 000a8c40
000a8c40  0000002a 
А вот и наше значение - 0x2A == 42 - проблема решена, ура!


Причем здесь shared_ptr и make_shared

Итак, мы нашли нужное нам значение и разобрались в причине бага. Но причем здесь make_shared VS new?

У нас используется свой самописный shared_ptr, для "среды без исключений (tm)", вот здесь есть реализация с аналогичным функционалом. В свое время у меня с одним коллегой возник холивар, что лучше, make_shared, или же голый new. Тогда мы закончили холивар на аргументе моего коллеги, что в дизассемблере удобнее смотреть процесс конструирования объекта, когда используется оператор new. Теперь у меня есть аналогичный аргумент - в пользу make_shared. Гораздо удобнее разбирать содержимое памяти в дампе, когда объект создан с помощью make_shared, т.к в этом случае сохраняется инвариант, что разница между указателями на счетчик ссылок и на сам объект равна 2 * sizeof(size_t), в то время как, создание shared_ptr через new такой гарантии не даст.

p.s. Я посмотрел реализацию std::shared_ptr в Visual Studio 2015, там используется аналогичный подход в реализации shared_ptr и make_shared - и поэтому, там тоже сохраняется такой инвариант.

Послесловие

На самом деле, в этом тестовом примере можно было сделать немного проще - устроить поиск по Heap нужного нам указателя на vtable:

0:000> x main!*Impl*vftable*
01384e64          main!Impl::`vftable' =

0:000> !address -f:Heap -c:"s -d %1 %2 01384e64"

                                     
Mapping file section regions...
Mapping module regions...
Mapping PEB regions...
Mapping TEB and stack regions...
Mapping heap regions...
Mapping page heap regions...
Mapping other regions...
Mapping stack trace database regions...
Mapping activation context regions...
000a8c10  01384e64 000a8c38 000a8c40 abababab  dN8.8...@.......
000a8c60  01384e64 000a8c88 000a8c90 abababab  dN8.............


И мы сразу нашли два наших объекта (почему два - да потому-что shared_ptr неправильный - не очищает за собой память 😀).

Но я решил пойти по сложному пути, так как сложный путь может сработать даже в том случае, если у вас вообще нет символов. Мы оперировали только исходным кодом и общей информацией о процессе - команду x main!*Impl*vftable* можно было и не исполнять - она лишь подтвердила, что наш анализ правильный.

Напоследок, подсказки по использованным командам:
  • !address - информация о всей памяти процесса (системы)
    • -c - поиск по указанным диапазонам памяти
    • -f - задать список диапазонов
  • x - вывести имя символа
  • s - поиск значений в заданном диапазоне
  • dps - распечатать диапазон адресов и попытаться найти известную информацию об адресе
  • dd - печать DWORD значений

Комментариев нет:

Отправить комментарий