Авария
Это началось с попытки понять, почему интеграционный тест не удался, только в 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
. С этой информацией найти точную строку отказа в исходном коде метода было тривиально:
Благодаря этому я смог понять, почему это значение стало нулевым, и исправить проблему. И, наконец, закройте запрос на перенос.