C#語法糖系列 —— 第一篇:聊聊 params 引數底層玩法

一線碼農發表於2022-04-19

首先說說為什麼要寫這個系列,大概有兩點原因。

  1. 這種文章閱讀量確實高...
  2. 對 IL 和 彙編程式碼 的學習鞏固

所以就決定寫一下這個系列,如果大家能從中有所收穫,那就更好啦!

一:params 應用層玩法

首先上一段 測試程式碼


    class Program
    {
        static void Main(string[] args)
        {
            Test(100, 200, 300);
            Test();
        }

        static void Test(params int[] list)
        {
            Console.WriteLine($"list.length={list.Length}");
        }
    }

輸出結果如下:

可以看出如果給 方法形參 加上 params 字首,在傳遞 方法實參 上就特別靈活,點贊。

接下來我們來看看,這麼靈活的 實參傳遞 底層到底是怎麼玩的?我們先從 IL 層面探究下。

二:IL 層面解讀

ILSpy 開啟我們的 exe ,看看 IL 程式碼


.method private hidebysig static 
	void Main (
		string[] args
	) cil managed 
{
	// Method begins at RVA 0x2050
	// Code size 37 (0x25)
	.maxstack 8
	.entrypoint

	IL_0000: nop
	IL_0001: ldc.i4.3
	IL_0002: newarr [mscorlib]System.Int32
	IL_0007: dup
	IL_0008: ldtoken field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=12' '<PrivateImplementationDetails>'::'9AC9CF706FBD14D039E0436219C5D852927E5F69295F2EF423AE897345197B2A'
	IL_000d: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
	IL_0012: call void ConsoleApp2.Program::Test(int32[])
	IL_0017: nop
	IL_0018: ldc.i4.0
	IL_0019: newarr [mscorlib]System.Int32
	IL_001e: call void ConsoleApp2.Program::Test(int32[])
	IL_0023: nop
	IL_0024: ret
} // end of method Program::Main

.method private hidebysig static 
	void Test (
		int32[] list
	) cil managed 
{
	.param [1]
		.custom instance void [mscorlib]System.ParamArrayAttribute::.ctor() = (
			01 00 00 00
		)
	// Method begins at RVA 0x2076
	// Code size 26 (0x1a)
	.maxstack 8

	IL_0000: nop
	IL_0001: ldstr "list.length={0}"
	IL_0006: ldarg.0
	IL_0007: ldlen
	IL_0008: conv.i4
	IL_0009: box [mscorlib]System.Int32
	IL_000e: call string [mscorlib]System.String::Format(string, object)
	IL_0013: call void [mscorlib]System.Console::WriteLine(string)
	IL_0018: nop
	IL_0019: ret
} // end of method Program::Test

上面是 MainTest 方法的IL程式碼,我們逐一看一下。

1. Test 方法

int32[] list 引數看並沒有所謂的 params,這也就說明它是 C#編譯器 玩的一個手段而已,在方法呼叫前就已經構建好了。

2. Main方法

可以看到 IL 層面的 Test(100, 200, 300) 已經變成了下面五行程式碼。


	IL_0002: newarr [mscorlib]System.Int32
	IL_0007: dup
	IL_0008: ldtoken field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=12' '<PrivateImplementationDetails>'::'9AC9CF706FBD14D039E0436219C5D852927E5F69295F2EF423AE897345197B2A'
	IL_000d: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
	IL_0012: call void ConsoleApp2.Program::Test(int32[])

邏輯大概就是:

  • newarr 構建初始化 int[] 陣列。
  • ldtoken 從程式後設資料中提取 1,2,3
  • InitializeArray 來將 1,2,3 構建到陣列中。

有了這些指令,我相信 JIT 就知道怎麼做了。

再看 Test() 的 IL 指令只有一行 newarr [mscorlib]System.Int32 初始化。

所以本質上來說,就是提前構建好 array,然後進行引數傳遞,僅此而已。。。

三:彙編層面解讀

有了 newarr + ldtoken + call 三條指令,接下來我們讀一下彙編層做了什麼,使用 windbg 開啟 exe,簡化後的彙編程式碼如下:


0:000> !U /d 009b0848
Normal JIT generated code
ConsoleApp2.Program.Main(System.String[])
Begin 009b0848, size 77

D:\net5\ConsoleApp4\ConsoleApp2\Program.cs @ 14:
>>> 009b0848 55              push    ebp
009b0849 8bec            mov     ebp,esp
009b084b 83ec10          sub     esp,10h
009b084e 33c0            xor     eax,eax
009b0850 8945f4          mov     dword ptr [ebp-0Ch],eax
009b0853 8945f8          mov     dword ptr [ebp-8],eax
009b0856 8945f0          mov     dword ptr [ebp-10h],eax
009b0859 894dfc          mov     dword ptr [ebp-4],ecx
009b085c 833df042710000  cmp     dword ptr ds:[7142F0h],0
009b0863 7405            je      009b086a
009b0865 e816f55264      call    clr!JIT_DbgIsJustMyCode (64edfd80)
009b086a 90              nop

D:\net5\ConsoleApp4\ConsoleApp2\Program.cs @ 15:
009b086b b95e186763      mov     ecx,offset mscorlib_ni!System.Text.Encoding.GetEncodingCodePage(Int32)$##6006719 <PERF> (mscorlib_ni+0x185e) (6367185e)
009b0870 ba03000000      mov     edx,3
009b0875 e8b229d5ff      call    0070322c (JitHelp: CORINFO_HELP_NEWARR_1_VC)
009b087a 8945f4          mov     dword ptr [ebp-0Ch],eax
009b087d b9e04d7100      mov     ecx,714DE0h
009b0882 e819c91f64      call    clr!JIT_GetRuntimeFieldStub (64bad1a0)
009b0887 8945f8          mov     dword ptr [ebp-8],eax
009b088a 8d45f8          lea     eax,[ebp-8]
009b088d ff30            push    dword ptr [eax]
009b088f 8b4df4          mov     ecx,dword ptr [ebp-0Ch]
009b0892 e8f9c61f64      call    clr!ArrayNative::InitializeArray (64bacf90)
009b0897 8b4df4          mov     ecx,dword ptr [ebp-0Ch]
009b089a ff15904d7100    call    dword ptr ds:[714D90h] (ConsoleApp2.Program.Test(Int32[]), mdToken: 06000002)
009b08a0 90              nop

D:\net5\ConsoleApp4\ConsoleApp2\Program.cs @ 16:
009b08a1 b95e186763      mov     ecx,offset mscorlib_ni!System.Text.Encoding.GetEncodingCodePage(Int32)$##6006719 <PERF> (mscorlib_ni+0x185e) (6367185e)
009b08a6 33d2            xor     edx,edx
009b08a8 e87f29d5ff      call    0070322c (JitHelp: CORINFO_HELP_NEWARR_1_VC)
009b08ad 8945f0          mov     dword ptr [ebp-10h],eax
009b08b0 8b4df0          mov     ecx,dword ptr [ebp-10h]
009b08b3 ff15904d7100    call    dword ptr ds:[714D90h] (ConsoleApp2.Program.Test(Int32[]), mdToken: 06000002)
009b08b9 90              nop

D:\net5\ConsoleApp4\ConsoleApp2\Program.cs @ 17:
009b08ba 90              nop
009b08bb 8be5            mov     esp,ebp
009b08bd 5d              pop     ebp
009b08be c3              ret

1. newarr

可以很清楚的看到,newarr 呼叫了 CLR 中的jithelper函式 CORINFO_HELP_NEWARR_1_VC 下的 JIT_NewArr1 方法,大家有興趣可以看下 jitheapler.cpp,呼叫完之後,初始化陣列就出來了。

從dp看,陣列只申明瞭 length=3,還並沒有陣列元素,也就說所謂的初始化。

2. ldtoken & InitializeArray

剛才也說到了, ldtoken 是在執行時提取後設資料,那就必須要解析 PE 頭,在 clr 層面有一個 PEDecoder::GetRvaData 方法就是用來解析執行時資料,它是發生在 ArrayNative::InitializeArray 方法中,所以我們下兩個 bu 命令攔截。


0:000> bu clr!ArrayNative::InitializeArray
0:000> bu clr!PEDecoder::GetRvaData
0:000> g
Breakpoint 3 hit
eax=0019f500 ebx=0019f5ac ecx=024c2338 edx=006fb930 esi=00000000 edi=0019f520
eip=64bacf90 esp=0019f4f0 ebp=0019f508 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
clr!ArrayNative::InitializeArray:
64bacf90 6a78            push    78h
0:000> g
Breakpoint 0 hit
eax=00713448 ebx=00624044 ecx=0071344c edx=0071dc28 esi=00624044 edi=00624de0
eip=64befcfc esp=0019f400 ebp=0019f410 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
clr!PEDecoder::GetRvaData:
64befcfc 55              push    ebp
0:000> k 
 # ChildEBP RetAddr      
00 0019f410 64b63be5     clr!PEDecoder::GetRvaData
01 0019f410 64b63bb3     clr!Module::GetRvaField+0x40
02 0019f44c 64bad0a7     clr!FieldDesc::GetStaticAddressHandle+0xdd
03 0019f4ec 00680897     clr!ArrayNative::InitializeArray+0x11a
0:000> bp 64b63be5
0:000> g
eax=00402928 ebx=00624044 ecx=0071344c edx=0071dc28 esi=00624044 edi=00624de0
eip=64b63be5 esp=0019f40c ebp=0019f410 iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
clr!Module::GetRvaField+0x40:
64b63be5 5e              pop     esi

從輸出看,上面的 GetRvaField 方法的返回值會送到 eax 上,接下來我們驗證下 eax 上的值是不是引數 100,200,300 。


0:000> dp eax L3
00402928  00000064 000000c8 0000012c

上面三個就是 16進位制的表示,接下來我們再驗證下這三個值是怎麼賦到初始化陣列中的,可以用 ba 命令對 記憶體地址 進行攔截。


0:000> ba r4 023e2338 + 0x8
0:000> ba r4 023e2338 + 0x8 + 0x4
0:000> ba r4 023e2338 + 0x8 + 0x4 + 0x4
0:000> g
Breakpoint 3 hit
eax=0000000c ebx=00000000 ecx=00000003 edx=00000064 esi=00402928 edi=023e2340
eip=6a91d68b esp=0019f440 ebp=0019f4ec iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
VCRUNTIME140_CLR0400!memcpy+0x50b:
6a91d68b 83c704          add     edi,4
0:000> g
Breakpoint 4 hit
eax=0000000c ebx=00000000 ecx=00000002 edx=000000c8 esi=0040292c edi=023e2344
eip=6a91d68b esp=0019f440 ebp=0019f4ec iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
VCRUNTIME140_CLR0400!memcpy+0x50b:
6a91d68b 83c704          add     edi,4
0:000> g
Breakpoint 5 hit
eax=0000000c ebx=00000000 ecx=00000001 edx=0000012c esi=00402930 edi=023e2348
eip=6a91d68b esp=0019f440 ebp=0019f4ec iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
VCRUNTIME140_CLR0400!memcpy+0x50b:
6a91d68b 83c704          add     edi,4

0:000> dp 023e2338 L5
023e2338  6368426c 00000003 00000064 000000c8
023e2348  0000012c

接下來稍微解釋下 ba r4 023e2338 + 0x8 命令。

  • 023e2338 是初始化陣列的首地址。

  • 023e2338+0x8 初始化陣列第一個元素的地址。

  • 023e2338 + 0x8 + 0x4 初始化陣列第二個元素的地址。

  • r4 按4byte對地址塊讀寫進行監控。

當三個斷點命中後,可以看到初始化陣列 023e2338 上的三個元素值都已經填上了,就說這麼多吧,相信大家對 params 機制有一定的理解。

圖片名稱

相關文章