Skip to content

Instantly share code, notes, and snippets.

@eterekhin
Last active April 30, 2020 16:48
Show Gist options
  • Save eterekhin/5642c0dcb9b62a5493b8ff76679b5162 to your computer and use it in GitHub Desktop.
Save eterekhin/5642c0dcb9b62a5493b8ff76679b5162 to your computer and use it in GitHub Desktop.

Если в коде поставить точку останова, то debugger дойдет до нее и остановится, как это происходит?

Я буду описывать отладку в windows. Когда мы ставим точку останова, debugger, еще до начала процесса знает на какой инструкции будет стоять эта точка останова(Это реализуется с помощью pdb файлов, в которых хранится маппинг имен переменных и IL инструкций методов, которые однозначно преобразуются байт код. Этот маппинг корректно работает при отключенных jit оптимизациях) И заменяет ее инструкцией int 3(interrupt 3).

Встречая эту инструкцию процессор генерирует прерывание, а windows exception с кодом 0X8000003. Информация об этом исключение передается процессу, совершающему отладку и тот должен ее как-то обработать.

Windows предоставляет такое API для отладки :

Процесс который занимается отладкой, должен выделить отдельный поток для этого, который в бесконечном цикле будет слушать Win32 Debugevents, другой же поток может прерывать выполнение debuggee в любой момент, вызвав DebugBreakProcess.

Это Api extern для c#, можно дотянуться так:

[System.Runtime.InteropServices.DllImport("kernel32.dll")]
extern static bool ContinueDebugEvent(int ProcessId, int ThreadId, uint dwContinueStatus);

Когда debugger вызовет DebugBreakProcess, Windows прервет исполнение кода debuggee и вызовет там int 3. Это исключение будет сгенерировано из отдельного потока, который не является потоком debuggee. Далее нужно обработать int 3 прерывание, и разрешить продолжить выполнение вызвав ContinueDebugEvent.

Это жесткое прерывание, в нашей практике мы чаще всего используем stepping, когда ставим точку останова, прерываемся и далее начинаем идти Step over, Step in, Step out

Какие виды отладки существуют

Stepping: Instruction, Step over, Step in, Step out

Как реализуется каждый вид отладки

Начнем с простого прерывания на инструкции. Debugger умный он знает о всех точках останова которые мы поставили, потому что IDE сообщает ему об этом.

Stepping on instruction реализуется проще всего. Debugger, Он знает строчку asm кода на которой нужно остановиться, для остановки эта инструкция сохраняется и перезаписывается инструкцией int 3, которая генерирует прерывание и отдает управление debugger'у. Далее debugger блокируется и ждет действия пользователя(например нажатие f8), предположим, что пользователь нажал f8(Step over) тогда debugger делает несколько действий:

  1. смещает program pointer на одну инструкцию вверх(после этого смещения исполнение находится прямо над инструкцией int 3).
  2. вместо int 3 вставляет реальную инструкцию, которая была заменена.
  3. Проставляет trap flag (регистр EFLAGS). Если этот флаг выставлен процессор генерирует прерывание после каждой инструкции (int 1).

Таким образом когда мы пройдем только что возвращенную обратно инструкцию debugger снова заменит ее на int 3.

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

Возможно придется ставить точку останова на адрес возврата, если метод уже закончился

Step In: Также нужно поставить точку останова на следующую инструкцию в функции куда мы входим или на следующую инструкцию текущей функции, если мы не сможем войти в функцию(если не находимся на инструкции call). Возможно придется также поставить точку останова на адресе возврата

Step Out: Нужно на стеке найти адрес возврата оставленный вызывающей функцией и поставить точку на него

Отладка в CLR

Какой API предоставляет

ICoreDebugManagedCallback - уведомляет отладчик о таких событиях CLR как Break, BreakPoint, CreateAppDomain, CreateProcess, CreateThread,LoadModule и так далее

IClrRuntimeInfo - интерфейс возвращает сведения о текущей среде CLR.

Отладку в .net framework можно запустить так:

IClrMetaHost h = ClrCreateInstance(ref iidIClrMetaHost, ref IIDIClrMetahost)

IEnumUnknow runtimes = р.ENumerateInstalledRuntimes(); // получаем доступные рантаймы

ICLRRuntimeINfo singleRuntime = GetLatestRuntime(runtimes);

singleRuntime.GetInterface(ref clsID_ICorDebug, ref IID_ICorDebug, out object debugger);

ICorDebug corDebug  = (ICorDebug) res;

corDebug.Initialize(); // 
corDebug.SetManagerHandler( /* передаем объект реализующий ICorDebugManagedCallback*/); // Далее этот объект будет дергаться на события происходящие в хосте.

STARUPINFO info = new STARTUPINFO(); // 

si.hStdInput = new Microsoft.Win32.SafeHandles.SafeFileHandle(new IntPtr(0), false);
si.hStdOutput = new Microsoft.Win32.SafeHandles.SafeFileHandle(new IntPtr(0), false);
si.hStdError = new Microsoft.Win32.SafeHandles.SafeFileHandle(new IntPtr(0), false);

PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
            
ICorDebugProcess proc;
codebugger.CreateProcess(
                                pathToExe,
                                pathToExe,
                                null,
                                null,
                                1, // inherit handles
                                (UInt32)CreateProcessFlags.CREATE_NEW_CONSOLE,
                                IntPtr.Zero, 
                                ".",
                                si,
                                pi,
                                CorDebugCreateProcessFlags.DEBUG_NO_SPECIAL_OPTIONS,
                                out proc);

Далее в отдельном процессе будет запущена консоль, а объект, переданный в метод SetManagerHandler будет принимать уведомления от CLR.

Для чего используются .pdb файлы

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

PDB файлы генерируются компилятором Rolsyn при компиляции сборки. PDB - Program Database, хранит маппинг IL и C# кода. Тем самым можно по строчке IL кода получить информацию какую строчку C# кода мы сейчас отлаживаем.

Пример pdb файла, открыто через DotPeek:

PDB файлы используются jit'ом, при компиляции функции он сохраняет маппинг между IL и asm кодом. Чтобы предоставить CLR возможность профайлинга. Но это будет работать только с флажком Suppress Jit Optimization, потому что иначе JIT начнет инлайнить методы, некоторый код может вообще вырезать, тем самым маппинг IL кода в asm уже нельзя будет построить.

Как можно поставить точку останова

Предположим мы ставим точку останова в файле в строке 13, если в ICorDebugManagedCallback::LoadModule запомнить все загруженные модули, потом итерироваться по каждому модулю и искать документ который подходит по URL(пути к файлу). Для этого нужно использовать интерфейс ISymbolReader. Который позволит по пути к файлу и строке кода(если для выполняемого кода загружен файл .pdb) узнать метод и offset il кода строки этого метода.

После этого нужно вызвать метод интерфейса ICorDebugCode Breakpoint(uint iiloffset), ICorDebugCode будет работать с сегментом кода метода. После активировать полученный ICorDebugBreakpoint.

ICorDebugBreakpoint breakpoint;
code.CreateBreakpoint(iiloffset,out cobreak);
breakpoint.Activate(1);

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

На уровне asm, это специальный регистр, куда кладется вершина фрейма текущего метода. CLR предотавляет апи для этого. todo :: рассмотреть пример использования

Пример как Roslyn умеет генерировать PDB и для чего это используется

            var code = @"
        namespace Debuggable
        {
            using System.Diagnostics;

            public class HelloWorld
            {
                public string Greet(string name)
                {
                    var result = ""Hello, "" + name;
                      Debugger.Break();
                    return result;
                }
            }
            }";
            var assemblyName = Path.GetRandomFileName();
            var symbolsName = Path.ChangeExtension(assemblyName, "pdb");
            var sourceCodePath = "generated.cs";

            var buffer = Encoding.UTF8.GetBytes(code);
            var sourceText = SourceText.From(buffer, buffer.Length, Encoding.UTF8, canBeEmbedded: true);

            var syntaxTree = CSharpSyntaxTree.ParseText(
                sourceText,
                new CSharpParseOptions(),
                path: sourceCodePath);

            var encoded = CSharpSyntaxTree.Create(
                (CSharpSyntaxNode) syntaxTree.GetRoot(),
                null,
                sourceCodePath,
                Encoding.UTF8);

            CSharpCompilation compilation = CSharpCompilation.Create(
                assemblyName,
                syntaxTrees: new[] {encoded},
                references: new[]
                {
                    MetadataReference.CreateFromFile(typeof(int).GetTypeInfo().Assembly.Location),
                },
                options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
                    .WithOptimizationLevel(OptimizationLevel.Debug)
                    .WithPlatform(Platform.AnyCpu)
            );

            using (var assemblyStream = new MemoryStream())
            using (var symbolsStream = new MemoryStream())
            {
                var emitOptions = new EmitOptions(
                    debugInformationFormat: DebugInformationFormat.PortablePdb,
                    pdbFilePath: symbolsName);

                var embeddedTexts = new List<EmbeddedText>
                {
                    EmbeddedText.FromSource(sourceCodePath, sourceText),
                };

                compilation.Emit(
                    peStream: assemblyStream,
                    pdbStream: symbolsStream,
                    embeddedTexts: embeddedTexts,
                    options: emitOptions);

                assemblyStream.Seek(0, SeekOrigin.Begin);
                symbolsStream?.Seek(0, SeekOrigin.Begin);

                var assembly = Assembly.Load(
                    assemblyStream.GetBuffer(),
                    symbolsStream.GetBuffer());
                    
                 dynamic instance = assembly.CreateInstance("Debuggable.HelloWorld");
                 string result = instance.Greet("Roslyn");     
                             

Debugger в Rider видит фрейм метода Greet, но не позволяет перейти к коду(возможно я что-то неправильно настроил):

VS позволяет отлаживать динамически сгенерированный код:

Динамическая генерация популярна в IOC контейнерах, например для реализации динамических декораторов.

Roslyn также позволяет сгенерировать PDB embedded, Portable(введено в .net core), и обычный PDB (проверить Встроенные в dll dpb, насколько больше dll по размеру)

Почему нельзя поставить точку останова на локальную переменную(неинициализированную)

Потому что место под локальные переменные выделяется один раз при входе в метод sub rsp 0X30(x64) Может быть не 0X30, зависит от веса локальных переменных. Регистр rsp указывает на вершину стека. Заходя в метод мы сразу ее двигаем, а далее читаем сверху(стек растет сверху вниз)

Как работает Edit and Continue

Roslyn Api предоставляет метод:

Compilation.EmitDifference(
      EmitBaseline baseline,
      IEnumerable<SemanticEdit> edits,
      Stream metadataStream,
      Stream ilStream,
      Stream pdbStream,
      ICollection<MethodDefinitionHandle> updatedMethods,
      CancellationToken cancellationToken = default(CancellationToken))

Он заполняет ilStream и metadataStream, это delta стримы, которые информируют CLR о разнице между модулем до компиляции и после. EmitBaseLine - структура, описывающая модуль. Модуль - живет внутри сборки и может быть только 1, если использовать для msbuild. Я Для создания нужно использовать :

public static EmitBaseline CreateInitialBaseline(
      ModuleMetadata module,
      Func<MethodDefinitionHandle, EditAndContinueMethodDebugInformation> debugInformationProvider);

ModuleMetadata - можно получить используя csharp ModuleMetadata.CreateFromFile(pathToExeOrPathToDll)

А вот с debugInformationProvider сложно, нужно создать экземпляр ISymUnmanagedReader'a, который умеет читать pdb файлы и вызвать его метод GetEncMethodDebugInfo После получения EmitBaseLine, остается дополнительно получить IEnumerable<SemanticEdit>. Для этого нужно дернуть CSharpEditAndContinueAnalyzer.AnalyzeDocumentAsync(params) Передать в этот метод оригинальный Solution, неизменяемый массив типа Span, отредактированный Document, CancellationToken

В итоге мы получим EmitBaseline, после чего сможем

Далее, когда мы получим dll и pdb дельты, сможем вызвать метод EmitDifference, который заполнит pdbDeltaStream и ilStream. Далее нужно связаться с CLR используя интерфейс ICorDebugProcess остановить исполняемый процесс и применить изменения к модулю :

// для того чтобы это сработало, нужно вручную запустить CLR, загрузить runtime , потом создать дебаггер и процесс, передав путь к  // исполняемому файлу, и подписаться на событие от CLR OnModuleLoad, для того, чтобы для нашего загружаемого модуля(только для 
// него, пототу что для уже скомпилированных модулей будут ошибки) проставить флаг JitCompilerFlags = CORDEBUG_JIT_ENABLE_ENC

corProcess.Stop(-1);
module.ApplyChanges(metadataStream.ToArray(), ilStream.ToArray())
corProcess.Continue(false);

Отладка лямбд

Все что связано с генерацией кода компилятором отлаживать может быть сложно, потому что пользователь никак не ожидает, что в случае expcetion'a в асинхронном методе он будет дебаггер перейдет к выходу из метода, или что простая лямбда в LINQ не будет исполняться. В первом случае ничего поделать нельзя, это цена которую приходится планить за механизм async await, а вот во втором можно. Я сгенерировал portable pdb с таким простым кодом:

Во время компиляции в PDB были добавлены точки начала и окончания реального места метода, который был скомпилирован в том же классе что и метод Main(в собственном nested классе). Это позволяет CLR и дебаггеру реализовать отладку скомпилированных в отдельный метод лябмд, как будто они встроены в код, в том же месте, где их написал программист

Как работает ImmediateWindow

Функциональность ImmediateWindow(Остановимся на самом интересном):

  1. Писать сколько угодно Statement
  2. Вывод результатов в удобном виде (для IEnumerable, не вызов ToString(), а итерирование и вывод каждого элемента)
  3. Intellisense в рамках метода в котором остановлено исполнение

Для того, чтобы взаимодействовать с CLR в microsoft написали свою реализацию IDkmLanguageExpressionEvaluator. Этот интерфейс позволяет отладчику взаимодействовать с CLR, просить вычислять выражения. Для пояснения рассмотрим такой рисунок.

Managed EE - Managed Expression Evaluator, он получает на вход код, который нужно выполнить, далее использует Expression Compiler, в нашем случае используется CSharpExpressionCompiler реализованный Roslyn. ExpressionCompiler также знает в какой функции и на какой строке сейчас происходит исполнение.

Задача ExpressionCompiler'a вернуть PE(Portable Executable) файл c реализацией функции(если мы оцениваем переменные из окна Watches, то функций), которую нужно выполнить. Сигнатура функции в PE файле, должна быть такой же как и сигнатура исполняемой функции. Для каждого Expression'a значение которого нужно вычислить будет сгенерирован статический класс с единственным методом:

После того как ExpressionCompiler заканчивает работу, начинает работать Clr Inspector. Он выполняет выражение, при необходимости запрашивая нужную информацию у ICorDebug интерфейса. Как только он все выполнил, он имеет необработанное значение из CLR, которое нужно отформатировать. Этим занимается Result Formatter.Это тот formatter, который умеет убирать кавычки у строк (nq). Насколько я понимаю за все способы форматирование объектов в Immediate Window, Locals, Autos, отвечает именно этот форматтер. После окончания результат отправляется в Managed EE и далее отображается в дебаггере. Так может работать Immediate, Locals и т.д. Вот пример взятый из тестов к компилятору Roslyn:

А для синтетических переменных VS использует свой апи:

Тоже самое касается объектов, которые имеют TrackId.

В интерфейсе IDkmClrExpressionCompiler, есть реализация метода CompileAssignment. Он используется для изменения значений локальных переменных. Оба метода CompileExpression и CompileAssignment инициализируют DkmCompiledClrInspectionQuery. Используя последний интерфейс можно сделать вызов DkmCompiledClrInspectionQuery.Create можно поставить запрос на получение результата. Также метод Create принимает на вход многие из параметров, которые VS позволяет настроить, например DkmClrCompilationResultFlags.PotentialSideEffect, запрет на оценивание выражений, если оно может спровоцировать side effect, или спецификаторы формата (nq,raw и т.д)

Еще есть метод GetClrLocalVariableQuery, для его работы нужно сгенерировать в PE файле все обращения к переменным до которых можно дотянуться.

Пример теста на извлечение всех локальных переменных:

Так как нужно показывать еще и alias'ы их тоже нужно добавить:

В Roslyn'e есть класс абстрактный EvaluationContext с методами CompileExpression,CompileAssignement,CompileGetLocals.

В репо Roslyn'a я нашел вот такой тест, который поясняет его работу:

Кстати я попробовал в VS Immediate window запомнить Action в одном домене и дернуть его из другого домена, был сгенерирован InvalidCastException в методе .<>m0(this).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment