CLR via CSharp 01

开坑

开了个新坑,做读了CLR via C#的笔记。

Chapter 1 CLR的执行模型

1.1 托管模块是什么

托管模块是面向CLR的语言被编译之后的产出物,需要有CLR才能运行。

CLR,全称Common Language Runtime,中文公共语言运行时,一般随着.NET Framework的一部分安装。核心功能包括:内存管理、程序集加载、安全性、异常处理和线程同步。开发人员可以随意选择自己喜欢的面向CLR的编程语言。

支持CLR的语言最后都会被编译成托管模块(中间语言和元数据),并需要CLR才能执行。

托管模块包括了以下几个部分:

  1. PE32或PE32+头。标准Windows PE(Portable Executable可移植执行体)文件头,当采用PE32时可在Windows32位或64位上运行,当采用PE32+时只能在Windows64位上运行。
  2. CLR头。包含要求的CLR版本,一些标志flag,托管模块入口方法及其他信息
  3. 元数据表。包括类型库(Type Library)和定义接口语言(Interface Definition Language, IDL)及其他。用途如下:
    1. 避免了编译时堆C/C++头和库文件的需求(为什么C#不需要头文件?
    2. IDE可以使用元数据实现智能感知(IntelliSense)
    3. CLR的代码验证过程使用元数据保证代码只执行类型安全的操作
    4. 元数据支持了序列化和反序列化
    5. 元数据允许垃圾回收器跟踪对象生存期
  4. IL代码(也被称为托管代码)。在运行时IL代码会被CLR编译成本机CPU指令。

1.2 程序集是什么

程序集(Assembly)是一个抽象概念,它是一个或者多个模块/资源文件的逻辑性分组,是重用、安全性和版本控制的最小单元。对于CLR来说,程序集相当于“组件”。

程序集可以视为一个exe文件或者dll文件,可以包括资源文件如图片等,也可以只有代码。

1.3 加载CLR

要查看是否安装了.NET Framework,只需检查%SystemRoot%\System32中的MSCorEE.dll文件是否存在。检查以下目录可以知道安装了哪些版本的.NET Framework:

  • %SystemRoot%\Microsoft.NET\Framework
  • %SystemRoot%\Microsoft.NET\Framework64

一般而言代码可以在任何装有CLR的Windows上运行,不过也可以在编译的时候指定/platform参数来控制运行的CPU平台。可以右击项目进入属性,在debug标签页找到更改目标平台的选项。

1.4 执行程序集的代码

IL

IL与CPU无关,能访问和操作对象类型,并提供了指令来创建和初始化对象、调用对象上的虚方法以及直接操作数组元素,还提供了抛出和捕捉异常的指令来实现错误处理。

我们用的高级语言(C#,F#)只公开了IL全部功能的子集,想要使用IL全部功能,可以使用汇编编写IL。

上图中的JIT表示Just In Time。

很明显,当第二次执行Console.WriteLine()的时候已经不需要再次经过JITCompiler了,直接拿到CPU指令运行就可以了。

IL基于栈,没有提供操作寄存器的指令。将IL编译成本机CPU指令时,CLR会执行一个名为验证的过程,以确保代码所做的一切都是安全的。保证每个方法的参数数量都是对的,传给每个方法的参数都有正确的类型,每个方法的返回值都得到了正确的使用,每个方法都有一个返回语句。

不安全的代码

Microsoft C#编译器也允许开发人员编写不安全的代码,直接操作内存地址。通常只有在与非托管代码进行互操作,或者在提升对效率要求极高的一个算法的性能的时候,才需要这样做。

C#编译器要求包含不安全代码的所有方法都用unsafe关键字标记。

optimize和debug参数

当使用/optimize不启用时,生成的未优化IL代码中将包含许多空操作指令和跳转到下一行代码的分支指令,IDE利用这些指令提供调试功能,开发者可以设置断点并暂停。如果开启了/optimize,IL代码就会被优化,无法再支持单步调试。

指定了/debug(+/full/pdbonly)之后,编译器会生成Program Database, PDB文件,该文件能将IL指令映射到源代码,使得我们可以用Visual Studio调试正在运行的进程。

使用NGen.exe跳过JIT编译阶段

使用NGen可以把程序集的所有IL代码编译成本机代码并保存到一个文件中。在运行程序集的时候CLR会判断是否存在该程序集的预编译版本,如果有就加载并运行。

1.5 本地代码生成器NGen.exe

使用NGen的好处有二:

  1. 由于已经编译出了CPU指令,所以运行时不再需要编译
  2. 多个进程现在可以共享同一份本机代码,而不需要每个进程都编译并在内存中保存一份本机代码

NGen只是生成了一份本机代码,并不意味着原本的程序集中的IL代码就没有了。如果CLR找不到已编译的本机代码,就会如往常一样对IL代码进行JIT编译。

相对于NGen的有点,它又有两个缺点:

  1. 一旦现在的系统环境与生成本机代码时的环境不同,就无法运行本机代码。环境因素包括:
    1. CLR版本
    2. CPU类型
    3. Windows操作系统版本
    4. 程序集的标识模块版本ID
    5. etc…
  2. NGen无法像JIT编译器那样对执行环境进行许多假定,这会造成NGen.exe生成较差的代码,有时运行的执行效率反而要慢5%左右。

1.6 Framework类库

Framework类库(Framework Class Library, FCL),包括

  • Web服务
  • 基于HTML的Web窗体/MVC应用程序
  • “富”Windows控制台应用程序
  • Windows服务
  • 数据库存储过程
  • 组件库

1.7 通用类型系统

简单知识。

1.8 反汇编工具ILDasm.exe

使用这个工具可以分析最终生成的托管模块。位于C:\Program Files (x86)\Microsoft SDKs\Windows\v8.0A\bin\NETFX 4.0 Tools。直接打开一个c#可执行文件就可以看到其内容。

1.9 与非托管代码的互操作性

CLR支持三种互操作情形:

  • 托管代码调用DLL中的非托管函数
  • 托管代码使用现有COM控件
  • 非托管代码可以使用托管类型

Chapter 02 生成、打包、部署和管理应用程序及类型

2.1 DLL Hell

DLL hell指的是安装新应用程序时,它可能莫名其妙地破坏了另一个已经安装好的程序。

.NET Framework正在尝试彻底解决这个问题。

2.2 将类型生成到模块中

编译C#代码为exe

我们将使用csc.ex(C:\Windows\Microsoft.NET\Framework64\v4.0.30319)来把下面这段代码生成为一个exe可执行文件:

1
2
3
4
5
public sealed class Program {
public static void Main() {
System.Console.WriteLine("Hi");
}
}

以超级管理员权限打开cmd或者powershell,进入到csc.exe所在路径,放入Program.cs,键入:

1
.\csc.exe /out:Program.exe /t:exe /r:MSCorLib.dll Program.cs

/t表示target,影响PE头(关于PE头前文有述)。/r表示引用。

‘/t’支持三种平台:exe控制台用户界面,winexe图形用户界面GUI,appcontainerexe为Windows Store应用

由于程序中使用了System.Console,而该类型在MSCorLib.dll程序集中被定义,所以需要被引用进去。MSCorLib.dll是一个特殊文件,包括了所有的核心类型,包括Byte,Char,String等。由于它被使用的频率很高,C#编译器会自动引用它,所以不写入/r:MSCorLib.dll这一段也是可以的。

另外,/out:Program.exe'和'/t:exe也是C#编译器的默认设定,所以也可以不写。

最后我们可以简写成

1
.\csc.exe Program.cs

如果不想要编译器自动包含MSCorLib.dll,可以在参数中加入/nostdlib参数,告知不要加入标准库。

响应文件

如果每次编译都要输入这么一串参数是很麻烦的,我们可以把它放到一个响应文件中,在编译时引用即可。例如在上一节的例子中,我们新建一个文件,称为Program.rsp,里面包含以下文本

1
2
/out:Program.exe
/target:exe

然后在执行的时候使用@符号加上响应文件名:

1
./csc.exe @Program.rsp Program.cs

C#编译器支持多个响应文件。

如果没有指定响应文件,编译器会自动查找名为CSC.rsp的文件是否存在,路径一般是C:\Windows\Microsoft.NET\Framework(64)\vXXX\CSC.rsp,如果存在会使用。一些全局的参数可以放到该文件中。使用者自定义的响应文件里的参数优先级要高于CSC.rsp文件中参数的优先级。

打开全局的CSC.rsp文件可以看到里面配置了一堆默认的引用程序集,这些程序集我们开发者就不需要再引用了。

如果想要忽略本地和全局的CSC.rsp文件,可以指定/noconfig命令行开关。

2.3 PE文件(Portable Executable)

  • PE文件构成
    • PE32(+)头
    • CLR头
      • major版本号
      • minor版本号
      • 一些标志 flag
      • MethodDef token(指定模块的入口方法)
      • (可选)强名称数字签名
      • 元数据表的大小和偏移量
    • 元数据
    • IL代码

再论元数据

元数据由几个表构成,分别是:

  • 定义表 definition table
    • ModuleDef
    • TypeDef
    • MethodDef
    • FieldDef
    • ParamDef
    • PropertyDef
    • EventDef
  • 引用表 reference table
    • AssemblyRef
    • ModuleRef
    • TypeRef
    • MemberRef
  • 清单表 manifest table
    • AssemblyDef
    • FileDef
    • ManifestResourceDef
    • ExportedTypesDef

观察元数据

想要观察一个程序集的元数据,可以使用上面提到的ILDasm.exe,选择视图 | 原信息 | 显示!,即可看到信息如下:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
===========================================================
ScopeName : Program.exe
MVID : {7411BA65-73BE-43BC-85BC-4CAF5BE4371F}
===========================================================
Global functions
-------------------------------------------------------

Global fields
-------------------------------------------------------

Global MemberRefs
-------------------------------------------------------

TypeDef #1 (02000002)
-------------------------------------------------------
TypDefName: Program (02000002)
Flags : [Public] [AutoLayout] [Class] [Sealed] [AnsiClass] [BeforeFieldInit] (00100101)
Extends : 01000001 [TypeRef] System.Object
Method #1 (06000001) [ENTRYPOINT]
-------------------------------------------------------
MethodName: Main (06000001)
Flags : [Public] [Static] [HideBySig] [ReuseSlot] (00000096)
RVA : 0x00002050
ImplFlags : [IL] [Managed] (00000000)
CallCnvntn: [DEFAULT]
ReturnType: Void
No arguments.

Method #2 (06000002)
-------------------------------------------------------
MethodName: .ctor (06000002)
Flags : [Public] [HideBySig] [ReuseSlot] [SpecialName] [RTSpecialName] [.ctor] (00001886)
RVA : 0x0000205e
ImplFlags : [IL] [Managed] (00000000)
CallCnvntn: [DEFAULT]
hasThis
ReturnType: Void
No arguments.


TypeRef #1 (01000001)
-------------------------------------------------------
Token: 0x01000001
ResolutionScope: 0x23000001
TypeRefName: System.Object
MemberRef #1 (0a000004)
-------------------------------------------------------
Member: (0a000004) .ctor:
CallCnvntn: [DEFAULT]
hasThis
ReturnType: Void
No arguments.

TypeRef #2 (01000002)
-------------------------------------------------------
Token: 0x01000002
ResolutionScope: 0x23000001
TypeRefName: System.Runtime.CompilerServices.CompilationRelaxationsAttribute
MemberRef #1 (0a000001)
-------------------------------------------------------
Member: (0a000001) .ctor:
CallCnvntn: [DEFAULT]
hasThis
ReturnType: Void
1 Arguments
Argument #1: I4

TypeRef #3 (01000003)
-------------------------------------------------------
Token: 0x01000003
ResolutionScope: 0x23000001
TypeRefName: System.Runtime.CompilerServices.RuntimeCompatibilityAttribute
MemberRef #1 (0a000002)
-------------------------------------------------------
Member: (0a000002) .ctor:
CallCnvntn: [DEFAULT]
hasThis
ReturnType: Void
No arguments.

TypeRef #4 (01000004)
-------------------------------------------------------
Token: 0x01000004
ResolutionScope: 0x23000001
TypeRefName: System.Console
MemberRef #1 (0a000003)
-------------------------------------------------------
Member: (0a000003) WriteLine:
CallCnvntn: [DEFAULT]
ReturnType: Void
1 Arguments
Argument #1: String

Assembly
-------------------------------------------------------
Token: 0x20000001
Name : Program
Public Key :
Hash Algorithm : 0x00008004
Version: 0.0.0.0
Major Version: 0x00000000
Minor Version: 0x00000000
Build Number: 0x00000000
Revision Number: 0x00000000
Locale: <null>
Flags : [none] (00000000)
CustomAttribute #1 (0c000001)
-------------------------------------------------------
CustomAttribute Type: 0a000001
CustomAttributeName: System.Runtime.CompilerServices.CompilationRelaxationsAttribute :: instance void .ctor(int32)
Length: 8
Value : 01 00 08 00 00 00 00 00 > <
ctor args: (8)

CustomAttribute #2 (0c000002)
-------------------------------------------------------
CustomAttribute Type: 0a000002
CustomAttributeName: System.Runtime.CompilerServices.RuntimeCompatibilityAttribute :: instance void .ctor()
Length: 30
Value : 01 00 01 00 54 02 16 57 72 61 70 4e 6f 6e 45 78 > T WrapNonEx<
: 63 65 70 74 69 6f 6e 54 68 72 6f 77 73 01 >ceptionThrows <
ctor args: ()


AssemblyRef #1 (23000001)
-------------------------------------------------------
Token: 0x23000001
Public Key or Token: b7 7a 5c 56 19 34 e0 89
Name: mscorlib
Version: 4.0.0.0
Major Version: 0x00000004
Minor Version: 0x00000000
Build Number: 0x00000000
Revision Number: 0x00000000
Locale: <null>
HashValue Blob:
Flags: [none] (00000000)


User Strings
-------------------------------------------------------
70000001 : ( 2) L"Hi"


Coff symbol name overhead: 0
===========================================================
===========================================================
===========================================================

其中有几个值得玩味的点:

  • 定义了Program这个Type
  • 定义了两个方法:
    • Main
    • .ctor 构造函数,有一个this指针
  • 定义了程序集信息,包括版本号等
  • 程序集引用,这里引用了mscorlib
  • 用户字符串 user strings

观察程序集大小构成

点击ILDasm.exe中的视图 | 统计,可以看到程序集的构成统计信息。

2.4 模块合并成程序集

一个PE文件包括了元数据等以及程序集,而程序集的众多文件之中包括了一个清单(Manifest)文件。

CLR加载程序集的时候总是先加载清单元数据表,再根据清单来获取程序集中的其他文件的名称。

为什么要有程序集

程序集可以由多个文件构成:含有元数据的PE、资源文件。可以把程序集视为一个压缩包。

Microsoft引入程序集的概念,是为了让可重用类型的标识与物理表示分开。啥意思呢,就是可以程序集分为几个部分,需要的时候再从网上下载。开发者可以在程序配置文件中指定codeBase元素,在其定义的URL指向的位置可以找到程序集的所有文件。当CLR试图加载程序集中的某个文件的时候,会先检查机器的下载缓存,如果不在就会从URL下载并加载文件,如果在URL指定的位置还是找不到文件,CLR就会抛出FileNotFoundException异常。

chapter 03

3.8 Runtime如何解析类型引用

对于前面提到的代码

1
2
3
4
5
public sealed class Program {
public static void Main() {
System.Console.WriteLine("Hi");
}
}

使用ildasm,打开视图 | 显示字节,反编译Main函数可得到IL代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
.method public hidebysig static void  Main() cil managed
// SIG: 00 00 01
{
.entrypoint
// 方法在 RVA 0x2050 处开始
// 代码大小 13 (0xd)
.maxstack 8
IL_0000: /* 00 | */ nop
IL_0001: /* 72 | (70)000001 */ ldstr "Hi"
IL_0006: /* 28 | (0A)000003 */ call void [mscorlib]System.Console::WriteLine(string)
IL_000b: /* 00 | */ nop
IL_000c: /* 2A | */ ret
} // end of method Program::Main

CLR运行程序的时候,会读取程序集的CLR头,查找标识了入口方法Main的MethodDefToken,检索MethodDef元数据表找到方法的IL代码在文件中的偏移量,JIT编译它成本机代码,然后执行本机代码。

当JIT编译上面的IL代码的时候,会检查所有类型和成员饮用,加载它们的定义程序集。

上面的代码引用了System.Console.WriteLine,可以看出它其实是引用了元数据token(0A)000003。这个标识对应着MemberRef中0A表中的第3项。

Buy Me A Coffee / 捐一杯咖啡的钱
分享这篇文章~
0%
//