开坑
开了个新坑,做读了CLR via C#的笔记。
Chapter 1 CLR的执行模型
1.1 托管模块是什么
托管模块是面向CLR的语言被编译之后的产出物,需要有CLR才能运行。
CLR,全称Common Language Runtime,中文公共语言运行时,一般随着.NET Framework的一部分安装。核心功能包括:内存管理、程序集加载、安全性、异常处理和线程同步。开发人员可以随意选择自己喜欢的面向CLR的编程语言。
支持CLR的语言最后都会被编译成托管模块(中间语言和元数据),并需要CLR才能执行。
托管模块包括了以下几个部分:
- PE32或PE32+头。标准Windows PE(Portable Executable可移植执行体)文件头,当采用PE32时可在Windows32位或64位上运行,当采用PE32+时只能在Windows64位上运行。
- CLR头。包含要求的CLR版本,一些标志flag,托管模块入口方法及其他信息
- 元数据表。包括类型库(Type Library)和定义接口语言(Interface Definition Language, IDL)及其他。用途如下:
- 避免了编译时堆C/C++头和库文件的需求(为什么C#不需要头文件?)
- IDE可以使用元数据实现智能感知(IntelliSense)
- CLR的代码验证过程使用元数据保证代码只执行类型安全的操作
- 元数据支持了序列化和反序列化
- 元数据允许垃圾回收器跟踪对象生存期
- 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的好处有二:
- 由于已经编译出了CPU指令,所以运行时不再需要编译
- 多个进程现在可以共享同一份本机代码,而不需要每个进程都编译并在内存中保存一份本机代码
NGen只是生成了一份本机代码,并不意味着原本的程序集中的IL代码就没有了。如果CLR找不到已编译的本机代码,就会如往常一样对IL代码进行JIT编译。
相对于NGen的有点,它又有两个缺点:
- 一旦现在的系统环境与生成本机代码时的环境不同,就无法运行本机代码。环境因素包括:
- CLR版本
- CPU类型
- Windows操作系统版本
- 程序集的标识模块版本ID
- etc…
- 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 | public sealed class Program { |
以超级管理员权限打开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 | /out:Program.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 | =========================================================== |
其中有几个值得玩味的点:
- 定义了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 | public sealed class Program { |
使用ildasm,打开视图 | 显示字节
,反编译Main函数可得到IL代码如下:
1 | .method public hidebysig static void Main() cil managed |
CLR运行程序的时候,会读取程序集的CLR头,查找标识了入口方法Main的MethodDefToken,检索MethodDef元数据表找到方法的IL代码在文件中的偏移量,JIT编译它成本机代码,然后执行本机代码。
当JIT编译上面的IL代码的时候,会检查所有类型和成员饮用,加载它们的定义程序集。
上面的代码引用了System.Console.WriteLine
,可以看出它其实是引用了元数据token(0A)000003
。这个标识对应着MemberRef中0A
表中的第3项。