Авария

Это началось с попытки понять, почему интеграционный тест не удался, только в Linux с ARM64.

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

В конце концов я понял, что это ни к чему не ведет, и нашел время, чтобы настроить виртуальную машину ARM64 для дальнейшего исследования. После запуска теста с LLDB (см. Мою предыдущую статью, чтобы узнать, как получить символы для CLR), я обнаружил, что процесс вызывал две ошибки сегментации, а вторая вызвала сбой:

Я открыл проблему в репозитории dotnet / runtime, и Дэвид Мейсон быстро отследил ее до отсутствующей нулевой проверки в AdjustContextForVirtualStub.

Оказывается, утверждение «.NET проверяет аннулирование при выполнении виртуального вызова» не совсем верно. В некоторых ситуациях при проверке типа объекта среда выполнения предполагает, что значение не равно нулю, а затем обнаруживает нарушение прав доступа / ошибку сегментации при попытке разыменовать экземпляр. Затем неисправность преобразуется в NullReferenceException. Конечный результат такой же, как если бы .NET явно проверяла нулевое значение. Итак, что произошло в моем тестовом приложении:

  • Я пытался вызвать виртуальный метод по нулевой ссылке
  • Это вызвало ошибку сегментации, которая была обнаружена средой выполнения и преобразована в NullReferenceException. Важно понимать, что ошибка / исключение произошли во время отправки виртуального вызова, который не считается управляемым кодом.
  • Исключение было повторно сгенерировано в блоке catch метода.
  • При раскручивании стека он попадает в особый случай в UnwindManagedExceptionPass1, когда исключение исходит из машинного кода. В этом пути кода отсутствовала нулевая проверка, что вызвало фатальную ошибку сегментации.

Я создал собственную версию CLR с дополнительной нулевой проверкой и, как и предполагал, исправил сбой. Конец истории?

Написание репродукции

На этом история могла бы закончиться, но я чувствовал, что мне все еще не хватает чего-то, чтобы получить полную картину. .NET не широко используется в ARM64, но я понял, что если бы проблема была такой простой, как «сбой при вызове виртуального метода по нулевой ссылке», ошибка была бы обнаружена гораздо раньше.

Чтобы понять точные условия аварии, я решил попробовать написать репро. Я начал с простого виртуального вызова нулевой ссылки:

Без особого удивления, эта программа не вылетела. Таким образом, проблема заключалась не только в «виртуальном вызове нулевой ссылки».

При запуске программы воспроизведения с LLDB она сломалась из-за ошибки сегментации:

Ошибка сегментации произошла в методе Request, чего и следовало ожидать. Но если бы я попробовал то же самое в моем аварийном приложении, segfault произошел бы в неуправляемом методе:

Что это был за метод? Он не был экспортирован в символах .NET CLR, но и не был управляемым методом. Чтобы получить больше информации, я включил генерацию карты perf (задав переменную окружения COMPlus_PerfMapEnable). Карта perf - это простой текстовый файл, в котором JIT хранит имя и адрес методов, которые она компилирует. К счастью, я нашел там адрес таинственного метода с именем GenerateDispatchStub<GenerateDispatchStub>.

Затем я заглянул в код CLR, чтобы понять, с чем было связано это имя.

CLR создавала DispatchHolder:

Затем вызываем Initialize для выдачи кода:

Этот метод имел специфическую реализацию для ARM64:

Инструкции в комментарии в точности совпадают с тем, что мне показывал LLDB:

Но это отличалось от того, что я получал в своем репро-приложении:

Таким образом, казалось, что сбойное приложение использовало «заглушку диспетчеризации», но не мое приложение для воспроизведения. Это имело значение?

Оглядываясь назад на метод, при котором произошел сбой:

Сбой произошел из-за того, что pExceptionRecord был пустым, строка 48. Если мое приложение воспроизведения не аварийно завершилось, это либо означало, что метод не был вызван, pExceptionRecord не был пустым, либо метод был завершен ранее. Я подтвердил, установив точку останова, что метод был вызван с нулевым аргументом. Таким образом, это будет означать, что либо pThread было нулевым (что казалось невероятно маловероятным), либо возвращаемое значение VirtualCallStubManager::FindStubManager отличалось от SK_DISPATCH или SK_LOOKUP. «SK_DISPATCH»? Что бы это ни было, похоже, это соответствовало упомянутой ранее заглушке об отправке.

Изучение заглушек

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

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

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

Но все равно не рухнет. Хуже того, это больше не вызовет ошибки сегментации!

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

В моем приложении для воспроизведения, поскольку я вызывал метод только один раз, использовалась заглушка поиска. Мне нужно было вызвать метод несколько раз, чтобы убедиться, что заглушка отправки была отправлена, а затем вызвать NullReferenceException:

И на этот раз результат был ожидаемым:

$ ./bin/Release/net5.0/testconsole
Segmentation fault

Меня по-прежнему озадачивало одно: мне нужно было дважды позвонить Request(false), чтобы сгенерировал квитанцию ​​об отправке. Я ожидал, что JIT выдаст заглушку поиска во время компиляции, а затем заглушку поиска, которая выдаст заглушку отправки во время первого вызова. Так что понадобился бы только один Request(false). Зачем мне понадобились два?

Я нашел ответ в комментарии в исходном коде преобразователя (преобразователь - это фрагмент кода, вызываемый заглушкой поиска):

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

Следовательно, последовательность событий была такой:

  • Request компилируется JIT. На этом этапе контекстная информация отсутствует, и создается заглушка поиска.
  • Первый вызов Request(false): при вызове IClient.GetResponse вызывается заглушка поиска. Он разрешает вызов Client.GetResponse, но замечает, что этот метод не был JITted. Поскольку он не знает окончательного местоположения кода, if просто возвращает адрес пре-заглушки. Когда предварительная заглушка выполняется, она запускает JIT-компиляцию метода.
  • Второй вызов Request(false): при вызове IClient.GetResponse вызывается заглушка поиска. Он разрешает вызов Client.GetResponse и излучает заглушку отправки, которая указывает на адрес JITted-кода.
  • Вызов Request(true): для разрешения используется заглушка отправки, но экземпляр равен нулю. Это вызывает ошибку сегментации внутри заглушки, что, в свою очередь, приведет к сбою при раскручивании стека.

Если бы мой анализ был правильным, мне понадобился бы только один вызов Request(false), если бы я убедился, что Client.GetResponse уже был JIT-скомпилирован на момент вызова. Я подтвердил это, внося изменения в репродукцию:

И действительно, он разбился из-за ошибки сегментации.

Вернемся к моему исключению NullReferenceException

Между тем, несмотря на то, что это больше не вызывало сбоев с пропатченной CLR, мой интеграционный тест по-прежнему где-то выдавал NullReferenceException.

В обычных условиях отладка нулевой ссылки вряд ли заслуживает внимания. Но по разным причинам, таким как удаленная виртуальная машина ARM64 или тот факт, что проблема возникла только с подключенным профилировщиком, я не мог подключить отладчик Visual Studio, и мне было очень сложно внести какие-либо изменения в код. Все, что я знал, это то, что ошибка произошла в этом методе от клиента Elasticsearch.net:

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

Поэтому я снова запустил приложение с LLDB, пока не обнаружил первую ошибку сегментации. На этот раз, благодаря всему, что я узнал выше, я знал, что ошибка сегментации происходит в заглушке отправки, с уже знакомой инструкцией ldr x13, [x0]:

В комментарии в реализации заглушки указано:

Итак, декомпилировав заглушку, я смогу получить жестко запрограммированные _expectedMT и _implTarget:

Мы видим ldp x10, x12, [x9] инструкцию. Согласно комментариям, он загружает значения _expectedMT и _implTarget, которые мы ищем. ldp - это инструкция ARM, которая загружает два слова из целевого адреса (хранится в x9) и сохраняет их в назначенных регистрах (x10 и x12). Значение x9 было установлено инструкцией adr x9, #0x1c. Это означает «получить адрес текущей инструкции, добавить смещение 0x1c и сохранить его в x9». Адрес этой инструкции 0xffff7d866304, поэтому она сохраняет значение 0xffff7d866304 + 0x1c = 0xffff7d866320 в регистре. В 0xffff7d866320 мы видим последовательность значений:

0x7fdd0f50
0xffff
0x80be2a98
0xffff

Сшитые вместе, это означает, что инструкция ldp x10, x12, [x9] хранит 0xffff7dd0f50 в x10 и 0xffff80be2a98 в x12. На тот момент у меня была вся необходимая информация! Затем я мог проверить ожидаемый MT и оттуда посмотреть, какой метод хранится по адресу 0xffff80be2a98:

Отсюда я знал, что NullReferenceException произошло при попытке вызвать геттер свойства ApiCall в экземпляре Nest.CreateIndexResponse. С этой информацией найти точную строку отказа в исходном коде метода было тривиально:

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