Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)

C++의 32비트 + Release 어셈블리 코드를 .NET으로 포팅할 때 주의할 점

예전에 아래의 글을 소개한 적이 있는데요,

C++의 inline asm 사용을 .NET으로 포팅하는 방법
; https://www.sysnet.pe.kr/2/0/1267

이번에는 저 소스 코드에서 예로 든 cpuid 명령어를 eax 레지스터에 기능 번호를 전달하기 위해 인자를 하나 더 추가해 봤습니다.

#include "stdafx.h"
#include <intrin.h>

__declspec(noinline) __declspec(dllexport)
void __cdecl getCpuId(int bits[], unsigned int functionId)
{
    __cpuid(bits, functionId); // 이 라인에 BP를 잡고 실행
}

int _tmain(int argc, _TCHAR* argv[])
{
  int bits[4];

  getCpuId(bits, 0x80000000); 
  printf("%x, %x, %x, %x\n", bits[0], bits[1], bits[2], bits[3]);

  getCpuId(bits, 0x80000002);
  printf("%x, %x, %x, %x\n", bits[0], bits[1], bits[2], bits[3]);

  return 0;
}

그런 다음, getCpuId의 기계어 코드를 최대한 간결하게 얻기 위해 32비트 + Release 모드로 컴파일해 Disassembly 창으로부터 다음과 같은 출력을 얻었습니다.

--- C:\temp\inline-asm-netcore\SystemInfo\SystemInfo.cpp 
         EC                   in          al,dx  
BP:      8B 45 0C             mov         eax,dword ptr [ebp+0Ch]  
         33 C9                xor         ecx,ecx  
         53                   push        ebx  
         56                   push        esi  
         57                   push        edi  
         53                   push        ebx  
         0F A2                cpuid  
         8B FB                mov         edi,ebx  
         5B                   pop         ebx  
         8B 75 08             mov         esi,dword ptr [ebp+8]  
         89 06                mov         dword ptr [esi],eax  
         89 7E 04             mov         dword ptr [esi+4],edi  
         89 4E 08             mov         dword ptr [esi+8],ecx  
         5F                   pop         edi  
         89 56 0C             mov         dword ptr [esi+0Ch],edx  
         5E                   pop         esi  
         5B                   pop         ebx  
         5D                   pop         ebp  
         C3                   ret  

왠지 첫 번째 "in al, dx" 명령어는 상관없는 것 같고, 두 번째 "mov eax, dword ptr [ebp+0Ch]" 명령어부터 유효한 함수의 시작일 것 같아서 저 바이트 코드부터 C#에서 사용했습니다.

그런데 ^^; 실행해 보면 비정상 종료를 합니다.

Unhandled Exception: System.AccessViolationException: Attempted to read or write protected memory. This is often an indication that other memory is corrupt.
   at ConsoleApplication1.SystemInfo.GetCpuId(Byte[] cpuIdBytes, UInt32 functionId) in C:\ConsoleApp\ConsoleApplication1\SystemInfo.cs:line 127
   at ConsoleApplication1.Program.Main(String[] args) in C:\ConsoleApp\ConsoleApplication1\Program.cs:line 12

왜일까요? ^^;




원인 파악을 위해 x86 실행에 대한 바이트 배열에 int 3(0xcc)를 다음과 같이 추가하고,

private readonly static byte[] x86CpuIdBytes =
{
    0x8B, 0x45, 0x0C,    // mov    eax,dword ptr [ebp+0Ch]
    0x33, 0xC9,          // xor    ecx,ecx
    0x53,                // push   ebx
    0x56,                // push   esi
    0x57,                // push   edi
    0x53,                // push   ebx
    0xcc, // int 3
    0x0F, 0xA2,          // cpuid
    0x8B, 0xFB,          // mov    edi,ebx
    0x5B,                // pop    ebx
    0x8B, 0x75, 0x08,    // mov    esi,dword ptr [ebp+8]
    0x89, 0x06,          // mov    dword ptr [esi],eax
    0x89, 0x7E, 0x04,    // mov    dword ptr [esi+4],edi
    0x89, 0x4E, 0x08,    // mov    dword ptr [esi+8],ecx
    0x5F,                // pop    edi
    0x89, 0x56, 0x0C,    // mov    dword ptr [esi+0Ch],edx
    0x5E,                // pop    esi
    0x5B,                // pop    ebx
    0x5D,                // pop    ebp
    0xC3,                // ret
};

테스트 용도로 CPUID를 담을 버퍼의 주소를 출력하는 코드를 살짝 추가한 후,

internal void GetCpuId(byte[] cpuIdBytes, uint functionId)
{
    if (_cpuIdDelg == null)
    {
        throw new ObjectDisposedException("GetCpu");
    }

    GCHandle handle = default(GCHandle);

    try
    {
        handle = GCHandle.Alloc(cpuIdBytes, GCHandleType.Pinned);
        Console.WriteLine("buffer address: 0x" + handle.AddrOfPinnedObject().ToString("X"));
        _cpuIdDelg(cpuIdBytes, functionId);
    }
    finally
    {
        if (handle != default(GCHandle))
        {
            handle.Free();
        }
    } 
}

WinDbg를 이용해 실행하면 (제 상태로는) 화면에 버퍼 주소가 0x30E260C로 출력된 후 int 3에서 bp가 걸립니다.

(e50c.e7e0): Break instruction exception - code 80000003 (first chance)
eax=030e260c ebx=030e260c ecx=00000000 edx=01710000 esi=030e2604 edi=0121c9a8
eip=01710009 esp=00daf170 ebp=00daf1bc iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
01710009 cc              int     3

// eip == 01710009이고, VirtualAlloc의 페이지 정렬에 따라 함수의 시작 주소는 01710000로 추측
0:000> u 01710000
01710000 8b450c          mov     eax,dword ptr [ebp+0Ch]
01710003 33c9            xor     ecx,ecx
01710005 53              push    ebx
01710006 56              push    esi
01710007 57              push    edi
01710008 53              push    ebx
01710009 cc              int     3
0171000a 0fa2            cpuid

보는 바와 같이, cpuid에 전달한 function ID 값이 제 코드에서는 0x80000000이었는데 위의 eax 레지스터에는 엉뚱하게 (화면에 버퍼 주솟값으로 출력됐던) 030e260c 값이 있습니다. 게다가 ebp+8 위치의 값에는 원래 버퍼의 주솟값이 들어가 있어야 하는데,

0:000> ? dwo(ebp+8)
Evaluate expression: -2147483648 = 80000000

오히려, 두 번째 인자로 전달한 0x80000000 값이 들어가 있습니다. x86 환경의 (cdecl 호출 규약이라면) ebp 레지스터에 따른 인자 구조로 봤을 때,

ebp + 12: 두 번째 함수 인자
ebp + 8:  첫 번째 함수 인자
ebp + 4:  리턴 주소
ebp + 0:  이전 ebp 값
ebp - 4:  첫 번째 지역 변수
ebp - 8:  두 번째 지역 변수

저 규칙이 전혀 적용되지 않고 있는 것입니다. 결국, 저런 상황에서는 높은 확률로 System.AccessViolationException 예외가 발생할 수밖에 없습니다. 어쨌든, 문제가 있다는 것은 확인했지만,,, 여전히 원인을 모르겠습니다.




자, 그럼 CLR Jitter가 만들어 주는 곳부터 어떤 문제가 있는지 살펴볼까요? 가능한 쉬운 트레이스를 위해 복잡한 중간 코드를 생성하는 delegate보다는 함수 포인터를 이용하는 방법으로 소스 코드를 변경했습니다.

.NET 8 - 함수 포인터에 대한 Reflection 정보 조회
; https://www.sysnet.pe.kr/2/0/13475

public class SystemInfo
{
    // ...[생략]...

    delegate* unmanaged[Cdecl, SuppressGCTransition]<byte*, void> _cpuIdFunc;

    public SystemInfo()
    {
        byte[] codeBytes = (IntPtr.Size == 4) ? x86CpuIdBytes : x64CpuIdBytes;

        _codePointer = VirtualAlloc(IntPtr.Zero, new UIntPtr((uint)codeBytes.Length), 
            AllocationType.COMMIT | AllocationType.RESERVE, MemoryProtection.EXECUTE_READWRITE 
        );

        Marshal.Copy(codeBytes, 0, _codePointer, codeBytes.Length);

        _cpuIdFunc = (delegate* unmanaged[Cdecl, SuppressGCTransition]<byte*, void>)_codePointer;
    }

    // ...[생략]...
}

그다음, 저 _cpuIdFunc를 호출하기 전 WinDbg에서 추적이 쉽도록 이렇게 코드를 변경했습니다.

Debugger.Launch();
_cpuIdFunc(pBuffer, functionId);

이제 WinDbg를 이용해 실행한 후 _cpuIdFunc를 호출하는 지점을 쉽게 찾을 수 있는데요,

    _cpuIdFunc(pBuffer, functionId);
07be26c9 90             nop     
07be26ca 8b45e4         mov     eax, dword ptr [ebp-1Ch]
07be26cd 8b400c         mov     eax, dword ptr [eax+0Ch]
07be26d0 8945d0         mov     dword ptr [ebp-30h], eax
07be26d3 8b45d0         mov     eax, dword ptr [ebp-30h]
07be26d6 8b5508         mov     edx, dword ptr [ebp+8]
07be26d9 8b4dd4         mov     ecx, dword ptr [ebp-2Ch]
07be26dc bb70229c02     mov     ebx, 29C2270h
07be26e1 e800632058     call    coreclr!_GenericPInvokeCalliHelper@0 (5fde89e6)

__clrcall 호출 규약에 따라 ecx, edx에 각각 첫 번째/두 번째 인자를 전달한 후 delegate unmanaged 호출을 대행하는 _GenericPInvokeCalliHelper를 부르고 있습니다. 물론, 이때의 인자에는 정상적인 값이 들어가 있습니다.

ecx == 0494b820   // cpuid를 담기 위해 전달한 버퍼
edx == 80000000   // cpuid에 전달한 기능 번호

재미있게도, 마이크로소프트가 symbol 서버를 잘 운영해 준 덕분에 WinDbg는 저 코드에서 step-into(F11)로 진행하면 소스 코드 창에 다음과 같이 _GenericPInvokeCalliHelper 원본을 함께 보여줍니다.

;==========================================================================
; Invoked for marshaling-required unmanaged CALLI calls as a stub.
; EAX       - the unmanaged target
; ECX, EDX  - arguments
; EBX       - the VASigCookie
;
_GenericPInvokeCalliHelper@0 proc public

    cmp     dword ptr [ebx + VASigCookie__StubOffset], 0
    jz      GoCallCalliWorker

    ; Stub is already prepared, just jump to it
    jmp     dword ptr [ebx + VASigCookie__StubOffset]

GoCallCalliWorker:
    ;
    ; call the stub generating worker
    ; target ptr in EAX, VASigCookie ptr in EBX
    ;

    STUB_PROLOG

    mov         esi, esp

    ; save target
    push        eax

    push        eax                         ; unmanaged target
    push        ebx                         ; pVaSigCookie (first stack argument)
    push        esi                         ; pTransitionBlock

    call        _GenericPInvokeCalliStubWorker@12

    ; restore target
    pop     eax

    STUB_EPILOG

    ; jump back to the helper - this time it won't come back here as the stub already exists
    jmp _GenericPInvokeCalliHelper@0

소스 코드가 길지만 사실상 "jmp _GenericPInvokeCalliHelper@0" 호출로 바로 실행되고, 이후의 코드는 동적으로 생성된 코드라 소스 코드 연동이 안돼 disassembly 창에서만 확인이 됩니다.

07be2760 55           push    ebp
07be2761 8bec         mov     ebp, esp
07be2763 57           push    edi
07be2764 83ec18       sub     esp, 18h
07be2767 8945e4       mov     dword ptr [ebp-1Ch], eax
07be276a 894df8       mov     dword ptr [ebp-8], ecx
07be276d 8955f4       mov     dword ptr [ebp-0Ch], edx
07be2770 33c0         xor     eax, eax
07be2772 8945f0       mov     dword ptr [ebp-10h], eax
07be2775 90           nop     
07be2776 8b45f8       mov     eax, dword ptr [ebp-8]
07be2779 8945ec       mov     dword ptr [ebp-14h], eax
07be277c 90           nop     
07be277d 90           nop     
07be277e 8b45f4       mov     eax, dword ptr [ebp-0Ch]
07be2781 8945e8       mov     dword ptr [ebp-18h], eax
07be2784 90           nop     
07be2785 ff75e8       push    dword ptr [ebp-18h] // 두 번째 인자 전달
07be2788 ff75ec       push    dword ptr [ebp-14h] // 첫 번째 인자 전달
07be278b 8b45e4       mov     eax, dword ptr [ebp-1Ch] // _cpuIdFunc 함수 주소
07be278e ffd0         call    eax
07be2790 83c408       add     esp, 8
07be2793 90           nop     
07be2794 90           nop     
07be2795 90           nop     
07be2796 90           nop     
07be2797 e8b4141d58   call    coreclr!JIT_PollGC (5fdb3c50)
07be279c 8d65fc       lea     esp, [ebp-4]
07be279f 5f           pop     edi
07be27a0 5d           pop     ebp
07be27a1 c3           ret     

그래도 역시 어렵지 않죠? ^^ 저 위치의 call 명령어는 우리가 C#에서 바이트 코드로 만든 _cpuIdFunc를 호출합니다. 그리고 call 위치에서 각각의 값을 확인해 보면,

0:000> ? dwo(ebp-18h)
Evaluate expression: -2147483648 = 80000000

0:000> ? dwo(ebp-14h)
Evaluate expression: 76855328 = 0494b820

0:000> ? @eax
Evaluate expression: 137232384 = 082e0000

보는 바와 같이 정상적으로 cdecl 호출 규약에 맞게 인자를 스택에 쌓은 다음 call을 호출했습니다. 즉, CLR Jitter가 생성한 코드는 아무런 오류도 없었던 것입니다.

그러고 보니, 이렇게 확인하고 나니까 우리가 Visual C++ disassembly 창으로부터 따온 바이트 코드의 이상한 점이 눈에 보입니다.

082e0000 8b450c mov     eax, dword ptr [ebp+0Ch]
082e0003 33c9   xor     ecx, ecx
082e0005 53     push    ebx
082e0006 56     push    esi
082e0007 57     push    edi
082e0008 53     push    ebx
082e0009 0fa2   cpuid   
082e000b 8bfb   mov     edi, ebx
082e000d 5b     pop     ebx
082e000e 8b7508 mov     esi, dword ptr [ebp+8]
082e0011 8906   mov     dword ptr [esi], eax
082e0013 897e04 mov     dword ptr [esi+4], edi
082e0016 894e08 mov     dword ptr [esi+8], ecx
082e0019 5f     pop     edi
082e001a 89560c mov     dword ptr [esi+0Ch], edx
082e001d 5e     pop     esi
082e001e 5b     pop     ebx
082e001f 5d     pop     ebp
082e0020 c3     ret     

즉, 자신의 스택 프레임을 구성하지 않고 멋대로 ebp를 기준으로 함수의 인자를 접근하고 있습니다. 도대체 이유가 뭘까요? ^^; 처음에는 저게 Visual C++의 최적화 때문이라고 생각했는데요, 답은 의외의 곳에서 나왔습니다.

바로 BP의 문제였는데요, 아래 화면은 BP를 __cpuid 라인에 잡고 실행했을 때의 disassembly 창이고,

[그림 1: __cpuid 라인에서 BP 잡았을 때 정상적이지 않은 disassemble 결과]
vc_disassembe_1.png

아래는 BP를 함수 시작 위치에 잡고 실행했을 때의 disassembly 창입니다.

[그림 2: 함수의 시작 블록에서 BP 잡았을 때 정상적인 disassemble 결과]
vc_disassembe_2.png

그렇습니다. 이 글의 처음에서 무시했던 "in al, dx" 명령어가 괜히 있는 게 아니었고, 뭔가 disassemble을 잘못하고 있다는 신호였던 것입니다.

결국, 이렇게 해서 원인을 알았으니 정상적인 스택 프레임 구성을 갖는 코드로 보완하면,

private readonly static byte[] x86CdeclCpuIdBytes =
{
    0x55,           // push        ebp
    0x8B, 0xEC,     // mov         ebp,esp

    0x8B, 0x45, 0x0C,    // mov    eax,dword ptr [ebp+0Ch]
    0x33, 0xC9,          // xor    ecx,ecx
    0x53,                // push   ebx
    0x56,                // push   esi
    0x57,                // push   edi
    0x53,                // push   ebx
    0x0F, 0xA2,          // cpuid
    0x8B, 0xFB,          // mov    edi,ebx
    0x5B,                // pop    ebx
    0x8B, 0x75, 0x08,    // mov    esi,dword ptr [ebp+8]
    0x89, 0x06,          // mov    dword ptr [esi],eax
    0x89, 0x7E, 0x04,    // mov    dword ptr [esi+4],edi
    0x89, 0x4E, 0x08,    // mov    dword ptr [esi+8],ecx
    0x5F,                // pop    edi
    0x89, 0x56, 0x0C,    // mov    dword ptr [esi+0Ch],edx
    0x5E,                // pop    esi
    0x5B,                // pop    ebx
    0x5D,                // pop    ebp
    0xC3,                // ret
};

이후 예제 코드가 올바른 출력을 얻게 됩니다.

namespace ConsoleApplication1;

partial class Program
{
    static void Main(string[] args)
    {
        using (SystemInfo sysInfo = new SystemInfo())
        {
            byte[] cpuIdBytes = new byte[4 * 4];
            sysInfo.GetCpuId(cpuIdBytes, 0x80000000);
            Console.WriteLine(BitConverter.ToString(cpuIdBytes));

            sysInfo.GetCpuId(cpuIdBytes, 0x80000002);
            Console.WriteLine(BitConverter.ToString(cpuIdBytes));
        }
    }
}

/* 출력 결과:
08-00-00-80-00-00-00-00-00-00-00-00-00-00-00-00
31-32-74-68-20-47-65-6E-20-49-6E-74-65-6C-28-52
*/

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




이번 글을 쓰면서 또 하나 알게 된 사실이 있는데요, __declspec(dllexport) 지시자의 유무에 따라 Visual C++가 생성하는 함수의 호출 규약이 다르다는 점이 있습니다.

예를 들어, 다음과 같이 dllexport 지시자를 제거하면,

// __declspec(noinline) __declspec(dllexport) void __cdecl getCpuId(int bits[], unsigned int functionId)

__declspec(noinline) void __cdecl getCpuId(int bits[], unsigned int functionId)
{
    __cpuid(bits, functionId);
}

/* x86 + Release disassembly 
53                   push        ebx  
56                   push        esi  
57                   push        edi  
8B F9                mov         edi,ecx  
8B C2                mov         eax,edx  
33 C9                xor         ecx,ecx  
53                   push        ebx  
0F A2                cpuid  
8B F3                mov         esi,ebx  
5B                   pop         ebx  
89 07                mov         dword ptr [edi],eax  
89 77 04             mov         dword ptr [edi+4],esi  
89 4F 08             mov         dword ptr [edi+8],ecx  
89 57 0C             mov         dword ptr [edi+0Ch],edx  
5F                   pop         edi  
5E                   pop         esi  
5B                   pop         ebx  
C3                   ret  
*/

__cdecl을 명시했는데도 생성된 코드는 fastcall 호출 규약을 따르는 것으로 추측이 됩니다. 물론, dllexport 지시자를 추가하면 다시 __cdecl 호출 규약을 따르는 코드로 생성됩니다. 즉, dllexport 지시자 없이는 __stdcall, __cdecl을 명시해도 한결같이 __fastcall 호출 규약을 따릅니다.

이게 좀 이상한 것이, 프로젝트의 Release 모드의 컴파일 옵션은 이렇게 돼 있는데,

/Yu"stdafx.h" /ifcOutput "Release\" /GS /GL /analyze- /W3 /Gy /Zc:wchar_t /Zi /Gm- /O2 /Ob0 /Fd"Release\vc143.pdb" /Zc:inline /fp:precise /D "WIN32" /D "NDEBUG" /D "_CONSOLE" /D "_UNICODE" /D "UNICODE" /errorReport:prompt /WX- /Zc:forScope /Gd /Oy- /Oi /MD /FC /Fa"Release\" /EHsc /nologo /Fo"Release\" /Fp"Release\SystemInfo.pch" /diagnostics:column


이 중에서 /Gd 옵션의 효과로 __cdecl이 기본값입니다. 즉, /Gd 대신 /Gr 옵션을 설정해야만 __fastcall을 기본 호출 규약으로 사용하는 것이고, 그나마도 __cdecl, __stdcall을 지정하면 그것에 따라 바뀌어야 하는데도 문서와 다르게 결과가 나온 것입니다.

아마도 이것은 __declspec(noinline)과의 연계에서 발생하는 부작용인 듯한데요, 테스트를 위해 noinline 지시자를 없애면 함수 구성이 안 되므로 확인이 불가능합니다. ^^;

혹시 이와 관련된 정보를 아시는 분이 계시다면 덧글 부탁드리겠습니다. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 6/2/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




... [31]  32  33  34  35  36  37  38  39  40  41  42  43  44  45  ...
NoWriterDateCnt.TitleFile(s)
13186정성태12/6/202214205오류 유형: 831. The framework 'Microsoft.AspNetCore.App', version '...' was not found.
13185정성태12/6/202215209개발 환경 구성: 653. Windows 환경에서의 Hello World x64 어셈블리 예제 (NASM 버전)
13184정성태12/5/202213268개발 환경 구성: 652. ml64.exe와 link.exe x64 실행 환경 구성 [1]
13183정성태12/4/202213145오류 유형: 830. MASM + CRT 함수를 사용하는 경우 발생하는 컴파일 오류 정리 [1]
13182정성태12/4/202215000Windows: 217. Windows 환경에서의 Hello World x64 어셈블리 예제 (MASM 버전)
13181정성태12/3/202213660Linux: 54. 리눅스/WSL - hello world 어셈블리 코드 x86/x64 (nasm)
13180정성태12/2/202214853.NET Framework: 2074. C# - 스택 메모리에 대한 여유 공간 확인하는 방법파일 다운로드1
13179정성태12/2/202213377Windows: 216. Windows 11 - 22H2 업데이트 이후 Terminal 대신 cmd 창이 뜨는 경우
13178정성태12/1/202214491Windows: 215. Win32 API 금지된 함수 - IsBadXxxPtr 유의 함수들이 안전하지 않은 이유파일 다운로드1
13177정성태11/30/202215136오류 유형: 829. uwsgi 설치 시 fatal error: Python.h: No such file or directory
13176정성태11/29/202212071오류 유형: 828. gunicorn - ModuleNotFoundError: No module named 'flask'
13175정성태11/29/202216609오류 유형: 827. Python - ImportError: cannot import name 'html5lib' from 'pip._vendor'
13174정성태11/28/202213129.NET Framework: 2073. C# - VMMap처럼 스택 메모리의 reserve/guard/commit 상태 출력파일 다운로드1
13173정성태11/27/202214168.NET Framework: 2072. 닷넷 응용 프로그램의 스레드 스택 크기 변경
13172정성태11/25/202213820.NET Framework: 2071. 닷넷에서 ESP/RSP 레지스터 값을 구하는 방법파일 다운로드1
13171정성태11/25/202213162Windows: 214. 윈도우 - 스레드 스택의 "red zone"
13170정성태11/24/202214655Windows: 213. 윈도우 - 싱글 스레드는 컨텍스트 스위칭이 없을까요?
13169정성태11/23/202215978Windows: 212. 윈도우의 Protected Process (Light) 보안 [1]파일 다운로드2
13168정성태11/22/202213173제니퍼 .NET: 31. 제니퍼 닷넷 적용 사례 (9) - DB 서비스에 부하가 걸렸다?!
13167정성태11/21/202213942.NET Framework: 2070. .NET 7 - Console.ReadKey와 리눅스의 터미널 타입
13166정성태11/20/202214149개발 환경 구성: 651. Windows 사용자 경험으로 WSL 환경에 dotnet 런타임/SDK 설치 방법
13165정성태11/18/202212607개발 환경 구성: 650. Azure - "scm" 프로세스와 엮인 서비스 모음
13164정성태11/18/202214972개발 환경 구성: 649. Azure - 비주얼 스튜디오를 이용한 AppService 원격 디버그 방법
13163정성태11/17/202215264개발 환경 구성: 648. 비주얼 스튜디오에서 안드로이드 기기 인식하는 방법
13162정성태11/15/202216570.NET Framework: 2069. .NET 7 - AOT(ahead-of-time) 컴파일 [1]
13161정성태11/14/202215059.NET Framework: 2068. C# - PublishSingleFile로 배포한 이미지의 역어셈블 가능 여부 (난독화 필요성) [4]
... [31]  32  33  34  35  36  37  38  39  40  41  42  43  44  45  ...