Миллион лет тому назад, ещё в университете, я делал курсовую по Unix на C++ и в какой-то момент мне пришлось дебаггить всё это третьекурсное великолепие из командной строки. Это было офигенно. И ощущение тотальной гикнутости происходящего, и поразительная эффективность процесса. Оказывается, в отсутствие UI отвлекаться больше не на что, и дебаггинг становится удивительно сфокусированным действом.
С тех пор как у .NET Framework появился кросс-платформенный брат близнец .NET Core, я всё выжидал, как бы это повторить давнишний подвиг ещё раз, но уже для C#, и недавно это-таки случилось. Не совсем гладко, но тем не менее. Давайте смотреть.
Подготовка
Для эксперимента потребуется Убунта, .NET Core SDK, lldb
дебаггер и какое-нибудь демо-приложение. В конце апреля 2018-го вышла целая куча обновлений и релизов, так что теперь можно и с Ubuntu 18.04 поиграться, и с .NET Core SDK 2.1 RC1 замутить. Последний, кстати, теперь собирается под более свежий lldb-3.9
, а не 3.6, как раньше, так что и тут обновка. Что касается демо-приложения, то любой hello-world с локальными переменными и параметрами у методов подойдёт.
Устанавливаем инструменты
По давней традиции я установлю всё в вируальную машину, и с этим поможет вот такой Vagrantfile с provision.sh
в придачу:
1 2 3 4 5 6 7 8 9 |
Vagrant.configure("2") do |config| config.vm.box = "ubuntu/bionic64" config.vm.provider "virtualbox" do |v| v.memory = 1024 end config.vm.provision "shell", path: "provision.sh" end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#!/bin/bash function install_lldb39 { echo "deb http://llvm.org/apt/trusty/ llvm-toolchain-trusty-3.9 main" | tee /etc/apt/sources.list.d/llvm.list wget -O - http://llvm.org/apt/llvm-snapshot.gpg.key | apt-key add - apt-get update -y apt-get install -y lldb-3.9 } function install_ms_package_source { wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.asc.gpg mv microsoft.asc.gpg /etc/apt/trusted.gpg.d/ wget -q https://packages.microsoft.com/config/ubuntu/18.04/prod.list mv prod.list /etc/apt/sources.list.d/microsoft-prod.list } function install_netsdk21rc1 { install_ms_package_source apt-get install -y apt-transport-https apt-get update -y apt-get install -y dotnet-sdk-2.1.300-rc1-008673 } function main { install_netsdk21rc1 install_lldb39 } main |
Демо-проект
Тут вообще всё просто. Пусть будет бесконечный цикл с паузой внутри, а так же функцией, которая рассчитывает, как долго эта пауза была. Программа абсолютно бесполезная, но с какими-никакими вычислениями, так что будет что подебагить.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
using System; namespace console { class Program { static void Main(string[] args) { while (true) { var lastTick = DateTime.Now.Ticks; System.Threading.Thread.Sleep(2000); var ticksElapsed = GetTicksElapsed(lastTick); } } static long GetTicksElapsed(long lastTicks) { var currentTicks = DateTime.Now.Ticks; var delta = lastTicks - currentTicks; return delta; } } } |
Подключаем дебаггер
Итак, запускаем приложение и пробуем прикрутить к нему дебаггер:
1 2 3 4 |
dotnet build # ... dotnet bin/Debug/netcoreapp2.1/console.dll & # [1] 6124 |
console.dll
запустился в фоновом процессе, так что теперь можно взять его айдишку (6124
), запустить lldb
, загрузить в него SOS плагин для дотнэта и подключиться-таки к процессу:
1 2 3 4 5 6 7 8 |
find /usr -name libsosplugin.so # find SOS plugin # /usr/share/dotnet/shared/Microsoft.NETCore.App/2.1.0-rc1/libsosplugin.so sudo lldb-3.9 # start LLDB (lldb) plugin load /usr/share/dotnet/shared/Microsoft.NETCore.App/2.1.0-rc1/libsosplugin.so (lldb) process attach -p 6124 # Process 6124 stopped # * thread #1: tid = 6124, 0x00007fef21a92ed9 libpthread.so.0`__pthread_cond_timedwait + 649, name = 'dotnet', stop reason = signal SIGSTOP # ... |
Всё просто. Теперь убедимся, что SOS действительно заработал (например, выполнив его команду вроде clrthreads
) и займёмся чем-нибудь полезным:
1 2 3 4 5 6 7 8 9 10 11 |
(lldb) clrthreads # ThreadCount: 2 # UnstartedThread: 0 # BackgroundThread: 1 # PendingThread: 0 # DeadThread: 0 # Hosted Runtime: no # Lock # ID OSID ThreadOBJ State GC Mode GC Alloc Context Domain Count Apt Exception # 1 1 17ec 00000000022884A0 2020020 Preemptive 00007FEE8002DE50:00007FEE8002FB30 000000000226F620 0 Ukn # 7 2 17f2 00000000022AB0D0 21220 Preemptive 0000000000000000:0000000000000000 000000000226F620 0 Ukn (Finalizer) |
Устанавливаем брейкпоинт
Для этого есть SOS команда bpmd
, на вход которой достаточно либо полного имени метода, либо адрес его дескриптора. Я, например, хочу поставить брейкпоинт в GetTicksElapsed
, который принадлежит классу Program
, что внутри console.dll
, так что вот как это будет выглядеть:
1 2 3 4 |
(lldb) bpmd console.dll Program.GetTicksElapsed MethodDesc = 00007FEEA63F57E8 Setting breakpoint: breakpoint set --address 0x00007FEEA7061777 [console.Program.GetTicksElapsed(Int64)] Adding pending breakpoints... |
Между прочим, ставить брейкпоинты по адресам дескрипторов методов иногда проще, чем по их именам. Всё-таки полное имя метода ещё выяснить нужно. А адреса дескрипторов валяются везде — в стеках, указателях на инструкции, в таблицах методов. Везде, в общем. Вот так, например, можно поставить брейкпоинт практически в любой метод текущего стека вызова:
1 2 3 4 5 6 7 8 |
(lldb) clrstack # OS Thread Id: 0x17ec (1) # Child SP IP Call Site # 00007FFE0D679AA8 00007fef21a92ed9 [HelperMethodFrame: 00007ffe0d679aa8] System.Threading.Thread.SleepInternal(Int32) # 00007FFE0D679BF0 00007FEEA7165ABB System.Threading.Thread.Sleep(Int32) # 00007FFE0D679C00 00007FEEA70616FC console.Program.Main(System.String[]) [/home/vagrant/console/Program.cs @ 12] # 00007FFE0D679F10 00007fef2023de1f [GCFrame: 00007ffe0d679f10] # 00007FFE0D67A310 00007fef2023de1f [GCFrame: 00007ffe0d67a310] |
Выбираем в качестве жертвы метод Main, запоминаем его IP (instruction pointer) и творим мини-магию:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
(lldb) ip2md 00007FEEA70616FC # MethodDesc: 00007feea63f57d8 # Method Name: console.Program.Main(System.String[]) # Class: 00007feea7131088 # MethodTable: 00007feea63f5800 # mdToken: 0000000006000001 # Module: 00007feea63f43e0 # IsJitted: yes # Current CodeAddr: 00007feea7061690 # Code Version History: # CodeAddr: 00007feea7061690 (Non-Tiered) # NativeCodeVersion: 0000000000000000 # Source file: /home/vagrant/console/Program.cs @ 12 (lldb) bpmd -md 00007feea63f57d8 # MethodDesc = 00007FEEA63F57D8 # Setting breakpoint: breakpoint set --address 0x00007FEEA7061690 [console.Program.Main(System.String[])] |
Легкотня. Теперь программу можно продолжить и ждать, когда она остановится на нашем брейкпоинте:
1 2 3 4 5 6 |
(lldb) process continue # Process 6124 resuming (lldb) Process 6124 stopped # * thread #1: tid = 6124, 0x00007feea7061777, name = 'dotnet', stop reason = breakpoint 1.1 # frame #0: 0x00007feea7061777 # ... |
Практически мгновенно. Теперь можно заглянуть внутрь.
Смотрим на стек вызова
Команда clrstack
(или sos ClrStack
) нужна как раз для этого:
1 2 3 4 5 6 |
(lldb) clrstack # OS Thread Id: 0x17ec (1) # Child SP IP Call Site # 00007FFE0D679BB0 00007FEEA7061777 console.Program.GetTicksElapsed(Int64) [/home/vagrant/console/Program.cs @ 18] # 00007FFE0D679C00 00007FEEA7061706 console.Program.Main(System.String[]) [/home/vagrant/console/Program.cs @ 13] # ... |
Вывод вполне понятен. Там даже имена файлов и номера строк есть. Но clrstack
может больше. Например, у него есть -p
параметр, который вдобавок к именам функций выведет значения их аргументов. А это уже что-то:
1 2 3 4 5 6 7 8 9 10 11 |
(lldb) clrstack -p # OS Thread Id: 0x17ec (1) # Child SP IP Call Site # 00007FFE0D679BB0 00007FEEA7061777 console.Program.GetTicksElapsed(Int64) [/home/vagrant/console/Program.cs @ 18] # PARAMETERS: # lastTicks (0x00007FFE0D679BE0) = 0x08d5bacfae88e0b3 # # 00007FFE0D679C00 00007FEEA7061706 console.Program.Main(System.String[]) [/home/vagrant/console/Program.cs @ 13] # PARAMETERS: # args (0x00007FFE0D679C40) = 0x00007fee8001e5c0 # ... |
Давайте, например, посмотрим, что же там за значения были. В lastTicks
лежит число 0x8d5bacfae88e0b3
, что в переводе в десятичный будет 636620323491995827
, и в переводе в дату действительно выдаёт текущее UTC время:
Что касается args
, то скорее всего это аргументы командной строки, переданные в нашу программу, и это очень легко подтвердить:
1 2 3 4 5 6 7 8 |
(lldb) dumpobj 0x00007fee8001e5c0 # Name: System.String[] # MethodTable: 00007feea6e26308 # EEClass: 00007feea65c64a8 # Size: 24(0x18) bytes # Array: Rank 1, Number of elements 0, Type CLASS # Fields: # None |
Так и есть — пустой массив строк.
Смотрим в локальные переменные
Вот тут засада. clrstack -i
, которой полагается выводить локальные переменные, в lldb-3.9
и libsosplugin.so
от .NET Core SDK RC1 безбожно валится с segmentation fault. Более старые версии дотнета я не проверял, но в этом у меня SEGFAULT в ста процентах случаев.
Переходим в/через/из инструкции
В отличие от WinDBG, в SOS и lldb совсем не видно CLR-совместимой команды для перехода между инструкциями. Но можно пользоваться нативными lldb’шными, которые хоть и работают с ассемблерными инструкциями, но дают хоть какое-то пространство для манёвра. А там и clrstack
можно подтянуть, чтобы проверить, в какой части управляемого кода мы сейчас находимся:
1 2 3 4 5 6 7 |
(lldb) next (lldb) Process 6124 stopped # * thread #1: tid = 6124, 0x00007feea7061778, name = 'dotnet', stop reason = instruction step into # frame #0: 0x00007feea7061778 # -> 0x7feea7061778: callq 0x7feea6af3cd0 # 0x7feea706177d: movq %rax, -0x38(%rbp) # 0x7feea7061781: movq -0x38(%rbp), %rdi |
callq
, если я не ошибаюсь, это вызов процедуры по x64 адресу, так что step
должен зайти внутрь её и потому увести нас куда-то очень далеко:
1 2 3 4 5 6 7 8 9 10 11 |
(lldb) step (lldb) Process 6124 stopped # * thread #1: tid = 6124, 0x00007feea6af3cd0, name = 'dotnet', stop reason = instruction step into # frame #0: 0x00007feea6af3cd0 # -> 0x7feea6af3cd0: pushq %rbp # ... (lldb) clrstack # OS Thread Id: 0x17ec (1) # Child SP IP Call Site # 00007FFE0D679BA8 00007FEEA6AF3CD0 System.DateTime.get_Now() # 00007FFE0D679BB0 00007FEEA706177D console.Program.GetTicksElapsed(Int64) [/home/vagrant/console/Program.cs @ 19] |
Так и есть, теперь мы в DateTime.Now
геттере. Кстати, это можно было предсказать, просто проверив адрес, по которому callq
собирался нас вести:
1 2 3 4 |
(lldb) ip2md 0x7feea6af3cd0 # MethodDesc: 00007feea66448c0 # Method Name: System.DateTime.get_Now() # ... |
Я не нашёл псевдоним для команды выхода из текущей функции, так что можно просто воспользоваться её полной формой: thread step-out
. Но с ней есть небольшая проблема. В половине случаев step-out
выводил меня больше, чем на один уровень вверх. Может, это из-за того, что реальный стек вызова состоит как из управляемых, CLR функций, так и нативных, от рантайма. clrstack
показывает только управляемые (-f
покажет все), step-out
учитывает все, так что, возможно, случается какой-то конфликт. А может, просто пальцы кривые.
Мораль
Вот так вот и выглядит он, дебаггинг .NET Core приложения из командной строки. Где-то логично, где-то не очень. Найти документацию по процессу почему-то достаточно сложно, так что я, скорее всего, что-то упустил. Но и того, что есть, вполне достаточно, чтобы провести начальное вскрытие запущенного процесса и проверить гипотезу-другую. Я вообще удивлён, насколько тонкая на самом деле грань между управляемым кодом и его ассемблерным представлением. Если по нативному адресу для callq
можно найти соответствующий ему управляемый метод, то, может, и в регистрах процессора можно выковырять какой-нибудь .NET объект. Как знать.