调试历险记:宿主进程、Windows 子系统和其他神奇事物





5.00/5 (16投票s)
尽管有用且大部分无害,但陷阱可能会让粗心的开发者误入歧途。
引言
几年前,当我正在开发一个辅助类库,以进一步加快我与用户交互的软件方法时,我发现了关于启用 Visual Studio 宿主进程进行调试如何导致程序产生意外结果的几个惊喜中的第一个。这些发现中的第一个是当我集成了一个报告程序执行子系统(我基于此决定默认启用异常日志类中的哪些功能)的非托管函数时。由于我几乎总是将此类测试程序实现为控制台应用程序,当我第一次尝试使用新例程报告我的测试程序在 Windows GUI 子系统中运行时,我感到非常震惊!
进一步调查揭示了原因,即启用 Visual Studio 宿主进程进行调试(默认情况下是启用的)会导致您正在调试的程序作为 Visual Studio 宿主进程(一个在 Windows GUI 子系统中运行的图形模式程序)的子程序运行。更复杂的是,System.Reflection.Assembly.GetEntryAssembly
将宿主进程程序集识别为入口程序集。
最近,我注意到了另一个更微妙的问题,我的控制台程序显示了不正确的启动时间,通常比我实际按下 F5 开始调试早几分钟甚至几小时。过去几天的进一步调查发现,您对 Visual Studio 宿主进程何时启动的控制相对较少,这意味着 System.Diagnostics.Process.StartTime
报告的是 Visual Studio 宿主进程启动的时间,这可能远早于您开始调试的时间。
注意:由于默认项目配置启用了 Visual Studio 宿主进程,因此除非您禁用它,否则您正在使用它。我将在文章末尾的演示部分向您展示如何禁用它。
更新:从 Visual Studio 2017 起,Visual Studio 宿主进程已停用。请参阅 调试和宿主进程。除非您仍在使用 Visual Studio 2015 或更早版本,否则本文的唯一剩余价值是学术和历史性的。作者不会怀念 Visual Studio 宿主进程。
背景
接下来的讨论涉及基本类库中的以下命名空间,所有这些命名空间都由其核心库公开。
- System.Reflection
- System.Diagnostics
- System.IO
当我创建了几乎所有字符模式程序都用于生成启动横幅并跟踪其实时运行时间的辅助类时,我希望启动时间是进程实际启动的时间,这可能比初始显示格式化和写入的时间早得多,尤其是在您单步调试启动时。碰巧的是,启动和退出时间是 Windows 在新进程启动时生成的庞大对象中保留的众多属性中的两个,并且一直伴随该进程,直到 Windows 重启或将相同的 ID 分配给另一个进程(以先发生者为准)。其中大部分细节都可以通过 System.Diagnostics.Process
对象在托管代码的整个生命周期中访问。
我决定使用 System.Diagnostics.Process.StartTime
作为我的启动时间,并编写了我的标准启动例程,将其值保存到一个私有 System.DateTime
结构中,既用作初始显示(徽标或横幅)中的启动时间,也最终用作计算程序运行时间的起点,该时间写入控制台上的最后一条消息。我当时还没有发现,当启用 Visual Studio 宿主进程时,它拥有 Process
对象,该对象在您的调试会话开始之前很久就已经存在,并且它篡夺了入口程序集的角色。
什么是 Visual Studio 宿主进程?
每次您要求 Visual Studio IDE 构建 Visual C# 或 Visual Basic 程序时,它都会创建两个程序。
MyProgram.exe
,其中MyProgram
是项目属性表“应用程序”选项卡上“程序集名称”文本框中给定的名称。MyProgram.vshost.exe
是一个简单的存根程序,它为您的调试会话实现 Visual Studio 宿主进程。例如,图 1 显示演示程序集的名称是VSHostingProcess_Demo
,它分别变为VSHostingProcess_Demo.exe
(演示程序)和VSHostingProcess_Demo.vshost.exe
(宿主进程程序集),如 图 2 所示。
图 1 是演示项目的项目属性表的“应用程序”选项卡。
a
图 2 是调试构建输出目录,其中包含演示程序集、其 Visual Studio 宿主进程程序集及其配置文件。
图 2 中显示的文件资源管理器窗口显示了构建 图 1 中所示项目时创建的文件。
我早期注意到,每个项目输出目录中的 Visual Studio 宿主进程程序集似乎非常相似。研究本文促使我更深入地挖掘。我选择了一个大约同时构建且目标框架相同的另一个项目,从 图 3 中所示的并排目录列表开始,这表明这两个文件的大小完全相同。
图 3 是来自两个项目的 Visual Studio 宿主进程程序集的并排目录列表,两者都针对 Microsoft .NET Framework,版本 3.5 Client Profile。
Windows 自带的逐字节文件比较器 fc.exe
和 IDM Computer Solutions 的 UltraCompare 都显示,尽管它们大小相同,但文件中存在细微差异。
在 ILDAsm.exe
中并排比较它们,得到了预期的结果,即两个程序集具有相同的对象模型和清单。图 4 显示了并排的对象模型。深入挖掘发现所有生成的 IL 都相同。证明留作求知欲旺盛的读者的练习,我已经为他们提供了 VSHostingProcess_SideBySide_20170401_154841.ZIP
中必要的资源。
图 4 显示了 ILDAsm
窗口中并排的两个宿主进程程序集。图 3 中列出的第二个程序集在左侧,而演示文件在右侧。
我确认这两个程序集基本相同后,就把它们搁置一旁了;也许我会深入研究,以精确识别差异,但这会偏离本文的目标。
一个健壮的解决方案
为了解决一个不相关的问题,我最近开始调查应用程序域的作用,并在该类及其许多成员中进行了大量的深入研究,但这将是另一天的课题,也许是另一篇文章。
要了解我是如何解决这个问题的,您必须了解关于应用程序域的两件事。
- 每个进程都至少有一个应用程序域,它由
System.AppDomain
类上的静态CurrentDomain
属性公开。 - 每个在 Visual Studio 宿主进程下运行的进程的默认应用程序域都有一个
DomainManager
属性,它公开一个EntryAssembly
属性,该属性标识真正的入口程序集(您正在调试的那个)。
应用程序域提供了无限的可能性,我就说到这里。您的胃口已经被吊起来了。
每个 Visual Studio 宿主进程都通过其默认 AppDomain
的 CurrentDomain
属性来标识其入口程序集。当 CurrentDomain.DomainManager
属性为 null 时该怎么办?您可以使用我多年来一直知道并最初使用的另一个属性,即静态 System.Reflection.Assembly.GetEntryAssembly
方法返回的 Assembly
对象。
- 对于托管进程,
Assembly.GetEntryAssembly
返回对宿主程序集的引用,例如VSHostingProcess_Demo.vshost.exe
。 - 对于独立进程,
Assembly.GetEntryAssembly
返回对预期程序集的引用,即您正在调试的程序集。
我之所以提到应用程序域,有一个非常具体的原因;它们为我研究的下一阶段和问题的直接解决方案铺平了道路,该解决方案体现在 PESubsystemInfo
类的私有 InitializeInstance
方法中,该方法实现了单例设计模式,接下来在 清单 1 中显示。
if ( _intDefaultAppDomainSubsystemID == IMAGE_SUBSYSTEM_UNKNOWN )
{ // Use the work done by the first invocation.
Assembly asmDomainEntryAssembly = AppDomain.CurrentDomain.DomainManager != null
? AppDomain.CurrentDomain.DomainManager.EntryAssembly
: Assembly.GetEntryAssembly ( );
_asmDefaultDomainEntryAssemblyName = asmDomainEntryAssembly.GetName ( );
_strDomainEntryAssemblyLocation = asmDomainEntryAssembly.Location;
_intDefaultAppDomainSubsystemID = GetPESubsystemID ( _strDomainEntryAssemblyLocation );
} // if ( _intProcessSubsystemID == IMAGE_SUBSYSTEM_UNKNOWN )
清单 1 是 PESubsystemInfo
类的完整 InitializeInstance
方法。
该解决方案归结为清单 1 所示 IF
块内的第一个语句,这需要稍加解释。
- 在 Visual Studio 宿主进程中运行的应用程序的当前(默认)应用程序域具有
DomainManager
属性,并且其EntryAssembly
属性指向“真实”的入口程序集,即您构建并正在测试的那个。 - 自行启动的应用程序(没有宿主进程的帮助)的默认域没有
DomainManager
,但您可以通过调用静态Assembly.GetEntryAssembly
方法来获取对其入口程序集的引用。
该块中剩余的语句将入口程序集的 AssemblyName
和 Location
属性保存到私有对象变量中,以供立即使用和将来参考。
_strDomainEntryAssemblyLocation
属性是一个简单的字符串,是加载入口程序集的文件(名称)的完全限定路径,它被立即用于派生其 Windows 子系统 ID。_asmDefaultDomainEntryAssemblyName
属性是一个AssemblyName
实例,它公开程序集全名的各个部分,这些部分对于启动横幅字符串、窗口标题等很有用。
由于 InitializeInstance
在调用 GetTheSingleInstance
返回 PESubsystemInfo
单例的引用时被调用,并且反射调用相对昂贵,因此 InitializeInstance
在第一次调用后通过利用 IMAGE_SUBSYSTEM_UNKNOWN
是 _intDefaultAppDomainSubsystemID
的无效值以及 GetPESubsystemID
应该重置它的事实来短路。
识别程序集运行的子系统
最后,是时候将注意力转向引发这次探险的问题了:这是一个字符模式程序还是一个成熟的图形 Windows 程序?
每个程序集都从 Windows 可移植可执行文件 (PE) 中加载,就像在 Windows 操作系统上运行的其他所有程序一样。唯一值得关注的例外是仍然可以在 32 位版本的 Microsoft Windows 上运行的旧 16 位 MS-DOS 程序。严格来说,DOS 程序不在 Windows 下运行,而是使用虚拟化的 MS-DOS 机器。其他所有程序,包括命令行实用程序,如 fc.exe
(上面提到)、Xcopy.exe
、RoboCopy.exe
(它的后继者)、cmd.exe
及其名义上的后继者 PowerShell.exe
,都是可移植可执行文件。此后,我将它们称为 PE 文件。
每个 PE 文件的开头大约一千字节是其 PE 头,它具有相当复杂的格式,由可变长度的表和指向其起始位置的指针组成,并辅以一组标志,这些标志告诉 Windows 它是哪种文件、应该如何加载、它是否是调试版本以及程序加载到内存中和运行时有用的许多其他信息。PE 头在 Windows 平台 SDK 中有相当详细的文档,组成它的结构在 WinNT.h
中定义。
任何探索过 PE 文件内部的人都无疑应该感谢 Matt Pietrek 的两篇文章:“深入探究 PE:Win32 可移植可执行文件格式之旅”、“深入了解 Win32 可移植可执行文件格式”和“深入了解 Win32 可移植可执行文件格式,第二部分”,以及他著名的 PEDump.exe
,该程序在 1994 年的原始文章(引用的三篇文章中的第一篇)中得到了彻底的描述和文档。我当然也这么认为,我编写的用于收集这些信息的原始非托管(纯 C)版本的例程的大部分代码都改编自构成 PEDump.exe
一小部分的代码。我还要感谢我以前的邻居 Allan Winston 挖掘了这些文章,这些文章自首次发表以来已经迁移了好几次。
回顾原始 C 代码后,我决定将其包含在这里会严重混淆故事,因此我将其省略了。任何想学习如何在 C 中完成的人都可以研究 PEDump
,它仍然可以在 Matt 的网站上找到。
public static Int16 GetPESubsystemID (
string pstrFileName )
{
// ----------------------------------------------------------------
// Everything that remotely smacks of being a magic number is
// defined as a constant, everything that is the object of an
// integer comparison is the same size as the integer against which
// it is compared, and every integer that is involved in a compare
// against data read from the file has its size specified as an
// integer of the appropriate number of bits (16 for the EXE magic
// and the subsystem ID, and 32 for the NT header magic. The goal
// of this much precision is to enable the program to perform
// correctly on both 32 and 64 bit processors.
// ----------------------------------------------------------------
const int BEGINNING_OF_BUFFER = 0;
const int INVALID_POINTER = 0;
const int MINIMUM_FILE_LENGTH = 384;
const int NOTHING_READ = 0;
const int PE_HEADER_BUFFER = 1024;
const int PE_HDR_OFFSET_E_LFANEW = 60;
const int PE_HDR_OFFSET_SUBSYSTEM = 92;
const Int16 IMAGE_DOS_SIGNATURE = 23117;
const Int32 IMAGE_NT_SIGNATURE = 17744;
const char QUOTE_CHAR = '"';
// ----------------------------------------------------------------
// Verify that the string that is expected to contain the file name
// meets a few minimum requirements before we incur the overhead of
// opening a binary stream.
// ----------------------------------------------------------------
if ( string.IsNullOrEmpty ( pstrFileName ) )
throw new ArgumentException (
pstrFileName == null // Differentiate between null reference and empty string.
? Properties.Resources.MSG_GETSUBSYST_NULL_FILENAME_POINTER // Display this message if the pointer is null.
: Properties.Resources.MSG_GETSUBSYST_FILENAME_POINTER_EMPTY_STRING ); // Display this message if the pointer is the empty string.
FileInfo fiCandidate = null;
if ( File.Exists ( pstrFileName ) )
{ // File exists. Check its length.
fiCandidate = new FileInfo ( pstrFileName );
if ( fiCandidate.Length < MINIMUM_FILE_LENGTH )
{ // File is too small to contain a complete PE header.
throw new ArgumentException (
string.Format (
Properties.Resources.MSG_GETSUBSYST_FILE_TOO_SMALL , // Format control string
new object [ ]
{
pstrFileName , // Format Item 0 = File name, as fed into the method
QUOTE_CHAR , // Format Item 1 = Double Quote character to enclose file name
fiCandidate.Length , // Format Item 2 = Actual length (size) of file
MINIMUM_FILE_LENGTH , // Format Item 3 = Minimum file length
Environment.NewLine // Format Item 4 = Embedded Newline
} ) );
} // if ( fiCandidate.Length < MINIMUM_FILE_LENGTH )
} // TRUE (anticipated outcome) block, if ( File.Exists ( pstrFileName ) )
else
{ // The specified file cannot be found in the current security context.
throw new ArgumentException (
string.Format (
Properties.Resources.MSG_GETSUBSYST_FILE_NOT_FOUND , // Format control string
pstrFileName , // Format Item 0 = File name, as fed into the method
QUOTE_CHAR ) ); // Format Item 1 = Double Quote character to enclose file name
} // FALSE (Unanticipated outcome) block, if ( File.Exists ( pstrFileName ) )
// ----------------------------------------------------------------
// Since the file name string passed the smell test, open the file.
// read up to the first kilobyte into memory, and search for the
// magic flags.
// ----------------------------------------------------------------
Int16 rintSubystemID = IMAGE_SUBSYSTEM_UNKNOWN;
try
{
int intBytesToRead =
( fiCandidate.Length >= PE_HEADER_BUFFER )
? PE_HEADER_BUFFER
: ( int ) fiCandidate.Length;
int intBytesRead = NOTHING_READ;
byte [ ] abytPeHeaderBuf;
// ------------------------------------------------------------
// Since the file I/O happens within a Using block guarded by a
// try/catch block, proper disposal of its unmanaged resources
// is guaranteed by the runtime engine.
//
// Before the buffer is processed, the number of bytes actually
// read is compared against the expected count, which is the
// lesser of 1024 (1 KB) or the length of the file.
// ------------------------------------------------------------
using ( FileStream fsCandidate = new FileStream (
pstrFileName ,
FileMode.Open ,
FileAccess.Read ,
FileShare.Read ) )
{
abytPeHeaderBuf = new byte [ intBytesToRead ];
intBytesRead = fsCandidate.Read ( abytPeHeaderBuf , // Store bytes read into this array.
BEGINNING_OF_BUFFER , // Start copying at this offset in the buffer, which happens to be its beginning.
intBytesToRead ); // Store up to this many bytes, which happens to be the size of array abytPeHeaderBuf.
if ( intBytesRead < intBytesToRead )
{ // An I/O error occurred while reading input file {0}{3}Only {1} of the expected {2} bytes were read.
throw new Exception ( string.Format (
Properties.Resources.MSG_GETSUBSYST_FILE_READ_SHORT , // Format Control String
new object [ ]
{
pstrFileName , // Format Item 0 = File Name, as tendered for processing
intBytesRead , // Format Item 1 = Count of bytes actually read from file
intBytesToRead , // Format Item 2 = Count of bytes expected to be read
Environment.NewLine // Format Item 3 = Embedded newline
} ) );
} // if ( intBytesRead < intBytesToRead )
// --------------------------------------------------------
// Though it could be moved outside the using block, or the
// enclosing try block, for that matter, I chose to leave
// the testing inline, which makes the program flow very
// clean. Since it's all over in a matter of nanoseconds,
// leaving the file open won't make that much difference.
// --------------------------------------------------------
Int16 intPEMagic = BitConverter.ToInt16 ( abytPeHeaderBuf , BEGINNING_OF_BUFFER );
if ( intPEMagic == IMAGE_DOS_SIGNATURE)
{ // Checking for the presence of the magic WORD is the very first task.
Int32 intPEOffsetNTHeader = BitConverter.ToInt32 (
abytPeHeaderBuf ,
PE_HDR_OFFSET_E_LFANEW );
if ( intPEOffsetNTHeader > INVALID_POINTER && intPEOffsetNTHeader <= intBytesToRead )
{ // The location of the NT header is variable, but the DOS header has a pointer, relative to its own start, at a fixed location.
Int32 intNTHeaderMagic = BitConverter.ToInt32 (
abytPeHeaderBuf ,
intPEOffsetNTHeader );
if ( intNTHeaderMagic == IMAGE_NT_SIGNATURE )
{ // Though the Subsystem is at a fixed offset within the NT header, the location of the start of said header is variable, but known.
rintSubystemID = BitConverter.ToInt16 (
abytPeHeaderBuf ,
intPEOffsetNTHeader + PE_HDR_OFFSET_SUBSYSTEM );
} // TRUE (anticipated outcome) block, if ( intNTHeaderMagic == IMAGE_NT_SIGNATURE )
else
{ // The NT header magic DWORD is missing.
throw new Exception (
string.Format (
Properties.Resources.MSG_GETSUBSYST_NO_NT_MAGIC , // Format control string
pstrFileName ) ); // Format Item 0 = File Name as submitted
} // FALSE (unanticipated outcome) block, if ( intNTHeaderMagic == IMAGE_NT_SIGNATURE )
} // TRUE (anticipated outcome) if ( intPEOffsetNTHeader > INVALID_POINTER && intPEOffsetNTHeader <= intBytesToRead )
else
{ // The pointer to the NT header is missing.
throw new Exception (
string.Format (
Properties.Resources.MSG_GETSUBSYST_NO_NT_SIGNATURE , // Format control string
pstrFileName , // Format Item 0 = File Name as submitted
intPEOffsetNTHeader , // Format Item 1 = Offset of PE header
Environment.NewLine ) ); // Format Item 2 = Embedded newline
} // FALSE (unanticipated outcome) block, if ( intPEOffsetNTHeader > INVALID_POINTER )
} // TRUE (anticipated outcome) block, if ( intPEOffsetNTHeader > INVALID_POINTER && intPEOffsetNTHeader <= intBytesToRead )
else
{ // The PE header magic WORD is absent.
throw new Exception (
string.Format (
Properties.Resources.MSG_GETSUBSYST_NO_MAGIC , // Format control string
pstrFileName ) ); // Format Item 0 = File Name as submitted
} // FALSE (unanticipated outcome) block, if ( intPEMagic == IMAGE_DOS_SIGNATURE)
} // using ( FileStream Candidate = new FileStream ( pstrFileName , FileMode.Open , FileAccess.Read , FileShare.Read ) )
} // The normal flow of control falls through to the return statement at the very end of the function block.
catch ( IOException exIO )
{
throw new Exception (
string.Format (
Properties.Resources.MSG_GETSUBSYST_FILE_READ_ERROR , // Format control string
exIO.GetType ( ).FullName , // Format Item 0 = Fully qualified Type of the exception
pstrFileName , // Format Item 1 = File name, as fed into the method
Environment.NewLine ) , // Format Item 2 = Embedded Newline
exIO ); // The original exception gets the InnerException seat.
}
catch ( Exception exMisc )
{ // If the exception is our own, it contains the file name; pass it up the call stack. Otherwise, wrap a new exception around it that can pass the file name up the call stack.
if ( exMisc.TargetSite.Name == System.Reflection.MethodBase.GetCurrentMethod ( ).Name )
throw;
else
throw new Exception (
string.Format (
Properties.Resources.MSG_GETSUBSYST_GENERAL_EXCEPTION , // Format control string
exMisc.GetType ( ).FullName , // Format Item 0 = Fully qualified Type of the exception
pstrFileName , // Format Item 1 = File name, as fed into the method
Environment.NewLine ) , // Format Item 2 = Embedded Newline
exMisc ); // The original exception gets the InnerException seat.
}
// ----------------------------------------------------------------
// Leaving the return here, and letting execution fall through is
// easier than arguing with the compiler about whether all code
// paths return a value.
// ----------------------------------------------------------------
return rintSubystemID;
} // public static Int16 GetPESubsystemID
清单 2 是 GetPESubsystemID
的全部内容,它作为 PESubsystemInfo
上的静态方法实现,并由其实例初始化器内部使用。
从 PE 头提取信息相当简单,尤其是当您拥有 PEDump 源代码和 Windows API 头文件作为参考时。用 C# 编写 PE 头解析器与在 C 中编写大致相同,但有两个主要区别。
- 我的 C# 实现从
System.IO.FileStream
对象填充字节数组,而 C 实现使用从传统文件句柄创建的内存映射文件,该文件表现为字节数组。 - 由于无法访问
WinNT.h
中定义的结构,我的实现使用了字节数组中的偏移量,这些偏移量是根据WinNT.h
中定义的结构手动计算的,并结合调用静态BitConverter
方法将字节转换为测试魔术值(用作地标)和恢复子系统 ID 所需的 16 位和 32 位整数。
由于这个方法以某种方式向公众公开,因此对文件名字符串进行了几项健全性检查,而 Matt 没有将其应用于 PEDump。
- 如果字符串是空引用或空字符串,则抛出
ArgumentException
,并使用区分空引用和空字符串的两种消息之一。 - 该字符串被送入
System.IO.File.Exists
,它必须返回TRUE
,否则将抛出ArgumentException
。请注意,异常消息中包含导致异常的字符串。我最讨厌的例外之一是省略了如此重要细节的异常。虽然在某些情况下这信息过多,或披露它会造成安全风险,但我宁愿提供信息,并将该决定权交给调用者,也不愿因为字符串是嵌套方法调用的输出而导致信息丢失的风险。 - 如果文件存在,则围绕它创建一个
System.IO.FileInfo
对象,并测试其长度。如果文件包含少于MINIMUM_FILE_LENGTH
(384) 字节,则会引发另一个ArgumentException
,该异常报告长度以及文件名和未超过的阈值。
只有在文件通过了上述所有三项测试后,其第一个千字节才会被读入内存,这足以确保它包含子系统 ID。读取文件是调用 FileStream
对象的 Read
方法一次性完成的任务,该方法填充一个 1024 字节的数组,并验证它是否获得了这么多字节。
例程的其余部分很简单,并遵循了我自己的 C 代码和 PEDump
使用的模式。
将子系统 ID 转换为有意义的东西非常简单。由于只有 13 个值,范围从 0 到 14,其中有几个未分配的值,将子系统 ID 视为数组下标使转换非常直接。
- 静态数组
s_astrShortNames
存储了WinNT.h
中描述的短名称列表。 - 静态数组
s_astrLongNames
存储了WinNT.h
中描述的长名称列表。
为了便于对子系统 ID 进行编程测试,PESubsystemInfo
定义了 PESubsystemID
枚举,以及一组用于最常见子系统的 Int16
常量。为了最大程度的灵活性,我两者都定义了,并使整数子系统 ID 和 PESubsystemID
可以双向自由转换。在内部,类使用整数,但所有内容都可以通过原始整数或枚举进行访问。
Using the Code
演示程序看似简单。
- 静态成员
s_dtmStartedUtc
是一个System.DateTime
结构,它通过静态初始化器初始化为System.DateTime.UtcNow
。- 使用带初始化器的静态成员保证其值尽快设置。
- 由于它必须保留其初始值才能用于计算程序的总运行时间,因此它被标记为只读。
- 我使用 UTC 时间,而不是本地时间,因为 UTC 是明确的,因为它完全忽略夏令时,而且转换为本地时间就像调用
ToLocalTime
实例方法一样简单。
- 在主例程内部,
peMainInfo
被定义为PESubsystemInfo
,并被分配给PESubsystemInfo
的单一实例的引用。 - 接下来,字符串
strLogoBanner
由string.Format
初始化,这有三个值得注意的原因。- 同一条消息被写入两次,首先写入控制台,然后写入跟踪日志。否则,我将使用字符模式输出的主力
Console.WriteLine
。我很快会解释跟踪日志。 - 格式项被组织成一个对象数组。
- 虽然我通常使用字符串数组并完全控制每个项目的格式,但我选择接受默认格式,这样我就不必引用我用于处理大部分工作的自定义辅助类,也不必将更多类拉入项目,该项目已经有我两个辅助类的适配版本。
- 使用参数对象数组需要编写更少的代码,因为运行时隐式地在每个对象上调用
ToString
。
- 行注释中显示的标签方案是记录数组元素与格式项映射的非常刻意的尝试,并确保数组中的项数与格式控制字符串中的格式项数相同。
- 同一条消息被写入两次,首先写入控制台,然后写入跟踪日志。否则,我将使用字符模式输出的主力
- 跟踪日志的使用有点非正统,因为最后一个跟踪记录直到操作员按下回车键退出程序之后才写入。目的是尽可能准确地记录宿主进程何时退出,因为调试器不会在其输出窗口中为自己的条目加盖时间戳,而且,由于当您在调试器中运行代码时(计时最有趣的时候)输出只可见一瞬间,因此它会进入跟踪日志。
- 演示程序的其余部分平平无奇。
另外三个类各值得用一段左右的文字来介绍。
TraceLogger
这个静态类导出了十个方法,涵盖了本地和 UTC 时间戳的每种可想象的组合。只有一个 WriteWithBothTimesLabeledLocalFirst
实际使用。我编写了其他九个来完成这套方法,我希望将它们移到一个库中,在该库中,整个类将被标记为 public,并合并到库的命名空间中。
AppDomainDetails
这个静态类导出了两个方法,都在主例程中使用。我将它们放入自己的类中,因为我希望它们最终会进入一个库,很可能就是包含 TraceLogger
的那个库。将它们定义在自己的类中简化了将它们合并到另一个库中的过程,因为所需要的只是复制模块,更改命名空间,并将类标记为 public。
GenericSingletonBase
这个类是基于我有一天偶然发现的一篇文章,带泛型的单例模式基类。
更新:2021年8月1日(星期日),我发现上述文章已经消失了。谢天谢地,它被一篇CodeProject文章取代了:C# 中单例模式的可重用基类。
我的实现通过为我的抽象基类配备一个受保护的默认构造函数,解决了原始(已消失的)文章中提出的关于为派生类提供默认构造函数的需求问题。由于抽象类必须被继承,并且受保护的方法会随之而来,派生类实际上继承了一个什么都不做的默认构造函数。
我曾定期争论是否应该添加一个公共的 GetTheSingleInstance
方法,尽管静态的 TheOnlyInstance
属性实质上以更低的成本履行了它的职责。到目前为止,我得出的结论是,我可能写入基类的任何 GetTheSingleInstance
方法几乎肯定会被派生类覆盖。注意:尽管它是否是初始实现的一部分还是后来添加的已无从考证,但 WizardWrx .NET API 类库中 GenericSingletonBase
的当前实现有一个公共的 GetTheSingleInstance
方法。
关注点
由于我已经涵盖了代码中大部分有趣的方面,我将利用本节来解决一些遗留问题。
禁用 Visual Studio 宿主进程
在本文的开头,我承诺会展示如何禁用 Visual Studio 宿主进程。就像使用 Microsoft 工具的许多其他事情一样,一旦你知道去哪里找,它就非常简单了。通过选择“项目”菜单上的最后一项,或者通过其快捷键 ALT-F7(除非你更改了它)来显示项目属性。由于此页面不是特别繁忙,它通常是完全可见的,除非你严重缩小了主 Visual Studio 窗口,你应该会在页面底部看到 Visual Studio 宿主进程设置。图 5 显示了默认值,而 图 6 显示了宿主进程被禁用。
与这些属性表中的其他任何内容一样,更改此设置会使属性页“脏”,并强制进行完整的项目构建。由于该设置存储在项目配置文件中,您也可以预期您的 .CPROJ
或 .VBPROJ
文件将得到更新。如果配置文件处于源代码控制之下,它将使用默认锁定规则进行签出以进行编辑。
图 5 显示了项目属性表的“调试”选项卡上的默认设置。请注意标有“启用 Visual Studio 宿主进程”的复选标记。
图 6 显示了禁用 Visual Studio 宿主进程的项目属性表的“调试”选项卡。
图 7 显示了当您在启用 Visual Studio 宿主进程的情况下运行项目时显示的显示器最后一部分。这张图片展示了我在本文开头提到的所有问题。这张图片不仅展示了我提出的所有问题,还展示了解决方案,在文本的前两行中,它们报告了默认应用程序域的信息。
图 7 显示了演示程序生成的控制台输出的最后一部分。请注意“进程启动时间”和“当前机器时间”之间的差异。这是因为 Visual Studio 运行了一个多小时,然后我才按下 F5 键来生成此处所示的输出。
图 8 显示了当演示程序在 Visual Studio 宿主进程禁用时运行的控制台输出的最后一部分。两个时间戳几乎相同,程序名称是您期望看到的名称,Windows 子系统 ID 是 3,映像在 Windows 字符子系统下运行。
输出比标准 24 行屏幕能容纳的要多一点,但不多到无法通过控制菜单轻松捕获所有内容。Alt-空格键、E、S、Enter,无需离开键盘即可捕获所有内容。控制菜单及其编辑飞出菜单如 图 9 所示。创建此图片需要一些屏幕捕获和图像编辑技巧,这得益于我忠实的屏幕捕获和图像编辑工具 JASC Paint Shop Pro 7.02。诀窍是设置一个计时器,并告诉它在计时器到期时捕获整个桌面。下面显示的图像是通过裁剪桌面图像创建的。
图 9 显示了上下文菜单及其“编辑”飞出菜单,这是捕获演示程序所有输出的最快方法。我首次使用 Paint Shop Pro 中的屏幕捕获计时器,以便留出足够的时间来设置图片。
跟踪
该项目配置了一个 TextWriterTraceListener
,其输出到一个非限定文件 VSHostingProcess_Demo.LOG
,该文件在构建输出目录中创建,该目录也是程序使用的工作目录。令我有些惊讶的是,System.Diagnostics.TextWriterTraceListener
类将加载程序集的目录视为其当前工作目录,这可能在您将新的实用程序程序投入生产时导致一些令人不快的意外。
疯狂的东西
文章模板建议您说一些关于您所做的任何疯狂的事情,所以就这么说吧。
- 当我配置跟踪侦听器时,我决定看看是否可以使用 MSBuild 宏来设置输出文件名。显然不行,或者至少,如果不编写自定义构建工具将源代码目录中的
app.config
文件转换为输出目录中的VSHostingProcess_Demo.exe.config
,就不行。嗯,值得一试。 - 虽然不完全是疯狂的,但对于一个演示程序来说,从字符串资源中提取大部分输出文本有点大材小用。然而,由于
PESubsystemInfo
查找表和错误消息已经来自资源,我决定不妨把所有其他东西都放在那里。 - 说到
PESubsystemInfo
,GetPESubsystemID
中的第一个异常报告使用三元表达式为 null 引用和空字符串提供单独的异常消息,使得评估pstrFileName
是 null 引用还是空字符串的代码非常紧凑。 - 我仍然使用我修改过的匈牙利命名法,我坚持我的选择,原因有二。
- 范围。名称的第一个字符将其标识为参数。我使用另外两个非常规前缀,以及一个常见的
s
表示静态,下划线表示私有,a
表示数组。您将在本示例中看到这三个。 - 类型。直到第一个大写字母为止的所有内容都简洁地标识了类型,而无需追溯定义。
- 范围。名称的第一个字符将其标识为参数。我使用另外两个非常规前缀,以及一个常见的
差不多就是这样了。祝您调试愉快!
历史
2017年4月3日,星期一 - 文章发布。
2017年4月3日,星期一 - 恢复被 CP 系统管理员丢失的图片。
2017年6月12日,星期一 - 添加了 Matt Pietrek 引用过的三篇文章中的两篇链接,鸣谢 Allan Winston 找到它们,并对文本进行了一些美化清理。
2021 年 8 月 1 日,星期日 - 将 GenericSingletonBase 类覆盖中引用的第一篇文章替换为引用 CodeProject 上基本涵盖相同材料的文章,并添加了关于 Visual Studio 2017 起 Visual Studio 宿主进程消亡的历史注释,并更正了一两个印刷错误。