OpenCover 코드 커버리지 도구의 동작 방식을 통해 살펴보는 Calli IL 코드 사용법
지난번에 OpenCover 도구를 설명했는데요.
OpenCover 오픈 소스를 이용한 .NET 코드 커버리지(Code coverage)
; https://www.sysnet.pe.kr/2/0/2881
.NET Profiler를 이용해 런타임에 변경한다는 사실만 다를 뿐 결과적으로 보면 "
Semantic Designs" 제품이 컴파일 타임에 코드를 변경한 것과 유사한 방식으로 동작합니다.
예를 들어, OpenCover.Console.exe로 다음의 콘솔 프로그램을 실행해 보면,
using System;
namespace ConsoleApplication1
{
class Program
{
static void Main(string[] args)
{
if (args.Length == 2)
{
Console.WriteLine("Args == 2");
}
else
{
Console.WriteLine("Args != 2");
}
TestIt();
Console.WriteLine("Main");
}
private static void TestIt()
{
Console.WriteLine("TestIt");
TestIt2();
}
private static void TestIt2()
{
Console.WriteLine("TestIt2");
}
}
}
TestIt 메서드의 IL 코드가 원래는 이렇게 빌드되었지만,
L_0000: /* 00 */ nop
L_0001: /* 72 */ ldstr 0x70001227
L_0006: /* 28 */ call STAT System.Console.WriteLine [mscorlib] // 0x0a00006d
L_000b: /* 00 */ nop
L_000c: /* 28 */ call STAT ConsoleApplication1.Program.TestIt2 [ConsoleApplication1] // 0x06000027
L_0011: /* 00 */ nop
L_0012: /* 2a */ ret
OpenCover.Profiler.dll .NET Profiler의 동작으로 인해 이렇게 변경된 것을 확인할 수 있습니다.
L_0000: /* 20 */ ldc.i4 0x00000706
L_0005: /* 28 */ call STAT System.CannotUnloadAppDomainException.SafeVisited [mscorlib] // 0x0a00007b
L_000a: /* 00 */ nop
L_000b: /* 20 */ ldc.i4 0x00000707
L_0010: /* 28 */ call STAT System.CannotUnloadAppDomainException.SafeVisited [mscorlib] // 0x0a00007b
L_0015: /* 72 */ ldstr 0x70001227
L_001a: /* 28 */ call STAT System.Console.WriteLine [mscorlib] // 0x0a00006d
L_001f: /* 00 */ nop
L_0020: /* 20 */ ldc.i4 0x00000708
L_0025: /* 28 */ call STAT System.CannotUnloadAppDomainException.SafeVisited [mscorlib] // 0x0a00007b
L_002a: /* 28 */ call STAT ConsoleApplication1.Program.TestIt2 [ConsoleApplication1] // 0x06000027
L_002f: /* 00 */ nop
L_0030: /* 20 */ ldc.i4 0x00000709
L_0035: /* 28 */ call STAT System.CannotUnloadAppDomainException.SafeVisited [mscorlib] // 0x0a00007b
L_003a: /* 2a */ ret
mscorlib.dll에 있는 System.CannotUnloadAppDomainException 타입의 SafeVisited 메서드를 호출하고 있는데요. 원래 이 메서드는 CannotUnloadAppDomainException 타입에 존재하지 않습니다. 즉, OpenCover.Profiler.dll .NET Profiler가 런타임시에 CannotUnloadAppDomainException 타입에 SafeVisited 메서드를 만들어 넣은 것입니다. 이어서 그 메서드의 IL 코드를 들어가면,
L_0000: /* 02 */ ldarg.0
L_0001: /* 28 */ call STAT System.CannotUnloadAppDomainException.VisitedCritical [mscorlib] // 0x06006bbc
L_0006: /* 2a */ ret
이렇게 VisitedCritical 메서드로 호출을 전달하는 역할만 합니다. (그 이유는,
.NET 4.0의 새로운 보안 모델 때문입니다.)
다시 VisitedCritical 메서드의 내부로 들어가면,
L_0000: /* 02 */ ldarg.0
L_0001: /* 21 */ ldc.i8 0x7ffa4f745ad0
L_000a: /* 29 */ calli 0x11000eaf
L_000f: /* 2a */ ret
이렇게 구성되어 있는데, 바로 여기서 ^^ calli 명령어가 나옵니다.
OpCodes.Calli Field
; https://learn.microsoft.com/en-us/dotnet/api/system.reflection.emit.opcodes.calli
calli 명령어는 사용하기 전, 그 메서드에 전달할 인자에 대한 처리가 먼저 선행됩니다. (물론, 대상 메서드가 인자를 받지 않는다면 생략~~~!) 위의 명령어에서, "ldarg.0"이 바로 calli로 호출되는 메서드에 전달할 인자입니다. 즉, VisitedCritical 메서드는 그 스스로에게 전달되었던 첫 번째 인자를 calli 메서드에 다시 전달합니다.
인자 전달 후에는 호출하게 될 메서드의 주소가 스택에 놓여집니다. 즉, "ldc.i8 0x7ffa4f745ad0" 명령에서 "0x7ffa4f745ad0" 주소가 바로 메서드의 주소입니다. "Process Explorer"에서 이 주소를 확인해 보면 OpenCover.Profiler.dll 모듈의 매핑 주소 내에 있음을 알 수 있습니다.
마지막으로 calli 명령어는 그 자신의 명령어에 대한 operand로 0x11000eaf 값을 받고 있는데요. 이 값이 참~~~ 미스테리합니다. ^^; "
OpCodes.Calli Field" 문서에 보면, "29 <T>" 형식으로 operand 영역에 대해 "callSiteDescr"라고 표현하고 있습니다. 도대체 "callSiteDescr"이 무슨 인자란 말입니까? 문서에는 이 값이 signature에 대한 메타데이터 토큰 값임을 밝히고 있습니다.
The method entry pointer is assumed to be a specific pointer to native code (of the target machine) that can be legitimately called with the arguments described by the calling convention (a metadata token for a stand-alone signature)
즉, 0x7ffa4f745ad0 주소에 있는 메서드의 static/instance 유형 및 인자 수, 반환 타입, 각각의 인자 타입을 알 수 있는 signature 항목의 토큰 값을 calli에 전달하고 있는 것입니다.
관련 코드를 보면, 좀 더 명확하게 의미가 다가옵니다.
IL rewriting : calli opcode and metadata token for a stand-alone signature
; https://social.msdn.microsoft.com/Forums/en-US/ada5917d-e714-40e9-b441-eb9074842b06/il-rewriting-calli-opcode-and-metadata-token-for-a-standalone-signature?forum=netfxtoolsdev
위의 글에 보면, native 메서드로 __fastcall 방식의 C++ 코드가 준비되어 있고,
static void __fastcall UnmanagedInspectValue(void)
{
printf("Hello\n");
}
이 메서드에 대한 signature를 .NET Profiler에서 동적으로 signature 테이블에 등록한 후, 등록된 그 항목의 메타데이터 토큰값을 구한 다음,
static COR_SIGNATURE unmanagedInspectValueSignature[] =
{
IMAGE_CEE_CS_CALLCONV_DEFAULT, // Default CallKind!
0x00, // Parameter count
ELEMENT_TYPE_VOID // Return type
};
void (__fastcall *pt)(void) = &UnmanagedInspectValue ;
mdSignature pmsig;
metaDataEmit->GetTokenFromSig(unmanagedInspectValueSignature,
sizeof(unmanagedInspectValueSignature),
&pmsig));
이를 기반으로 calli 명령어를 구성하고 있습니다.
BYTE ilCode[10];
ilCode[0] = 0x20; // ldc.i4
memcpy( ilCode[1], (void*)&pt, sizeof(pt) ); // ftn pointer
ilCode[5]= 0x29; // calli
memcpy( ilCode[6] (void*)&pmsig, sizeof(pmsig) ); // call site descr
역시, 코드를 보니까 이해가 빠르군요. ^^
다시 정리해 보면, OpenCover 도구는 .NET Profiler를 이용해 런타임시에 코드의 구분 구획마다 코드 커버리지가 되었음을 알 수 있는 식별자를 심고, 그 식별자를 .NET Profiler 내부에서 구현해둔 C/C++ 함수에 전달해 처리하는 방식을 취하고 있습니다.
실제로 OpenCover 소스 코드를 뒤져보면 "\main\OpenCover.Profiler\CodeCoverage.cpp" 파일이 있는데, 바로 아래의 함수가 System.CannotUnloadAppDomainException.VisitedCritical 메서드의 호출내부에서 최종 불리게 되는 native 메서드인 것입니다.
/// <summary>An unmanaged callback that can be called from .NET that has a single I4 parameter</summary>
/// <remarks>
/// void (__fastcall *pt)(long) = &SequencePointVisit ;
/// mdSignature pmsig = GetUnmanagedMethodSignatureToken_I4(moduleId);
/// </remarks>
static void __fastcall InstrumentPointVisit(ULONG seq)
{
CCodeCoverage::g_pProfiler->AddVisitPoint(seq);
}
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]