C# Windows控制台应用程序如何判断是否以交互方式运行

31

一款用C#编写的Windows控制台应用程序如何确定它是在非交互环境(例如从服务或作为计划任务)还是从具有用户交互功能的环境(例如命令提示符或PowerShell)中调用的?

6个回答

48

[编辑:2021年4月 - 新答案...]

由于Visual Studio调试器最近的更改,我的原始答案在调试时停止正常工作。为解决此问题,我提供了完全不同的方法。原始答案的文本包含在底部。


1. 只需要代码...

要确定.NET应用程序是否在GUI模式下运行:

[DllImport("kernel32.dll")] static extern IntPtr GetModuleHandleW(IntPtr _);

public static bool IsGui
{
    get
    {
        var p = GetModuleHandleW(default);
        return Marshal.ReadInt16(p, Marshal.ReadInt32(p, 0x3C) + 0x5C) == 2;
    }
}

这段代码检查PE头中的Subsystem值。对于控制台应用程序,该值将为3而不是2
2. 讨论
相关问题所述,最可靠的GUI与控制台指示器是可执行映像的PE头中的“Subsystem”字段。以下C#enum列出了允许(文档化)的值:
public enum Subsystem : ushort
{
    Unknown                 /**/ = 0x0000,
    Native                  /**/ = 0x0001,
    WindowsGui              /**/ = 0x0002,
    WindowsCui              /**/ = 0x0003,
    OS2Cui                  /**/ = 0x0005,
    PosixCui                /**/ = 0x0007,
    NativeWindows           /**/ = 0x0008,
    WindowsCEGui            /**/ = 0x0009,
    EfiApplication          /**/ = 0x000A,
    EfiBootServiceDriver    /**/ = 0x000B,
    EfiRuntimeDriver        /**/ = 0x000C,
    EfiRom                  /**/ = 0x000D,
    Xbox                    /**/ = 0x000E,
    WindowsBootApplication  /**/ = 0x0010,
};

就像其他答案中的代码一样简单,我们在这里的情况可以大大简化。由于我们只对自己正在运行的进程感兴趣(必须加载),因此您不需要打开任何文件或从磁盘读取以获取子系统值。我们的可执行映像保证已经映射到内存中。通过调用GetModuleHandleW函数,可以轻松检索任何已加载文件映像的基地址:
[DllImport("kernel32.dll")]
static extern IntPtr GetModuleHandleW(IntPtr lpModuleName);

虽然我们可能会向此函数提供文件名,但再次使用默认值更容易且不必要。传递 null,或在这种情况下,default(IntPtr.Zero)(与 IntPtr.Zero 相同),将返回当前进程的虚拟内存映像的基地址。这消除了先前所提到的获取入口程序集及其 Location 属性等额外步骤。不再拖延,这是新的简化代码:

static Subsystem GetSubsystem()
{
    var p = GetModuleHandleW(default);    // VM base address of mapped PE image
    p += Marshal.ReadInt32(p, 0x3C);      // RVA of COFF/PE within DOS header
    return (Subsystem)Marshal.ReadInt16(p + 0x5C); // PE offset to 'Subsystem' word
}

public static bool IsGui => GetSubsystem() == Subsystem.WindowsGui;

public static bool IsConsole => GetSubsystem() == Subsystem.WindowsCui;


[新答案的官方结束]


3. 奖励讨论

对于.NET来说,Subsystem或许是PE头文件中最有用的信息,但如果您对细节容忍度高,可能还有其他非常有价值的信息,而且可以使用上述技术轻松检索到这些数据。

显然,通过更改先前使用的最后一个字段偏移量(0x5C),您可以访问COFF或PE头文件中的其他字段。下面的代码片段演示了如何获取Subsystem(同上)以及其他三个具有各自偏移量的字段。

注意:为了减少混乱,在以下内容中使用的enum声明可以在此处找到

var p = GetModuleHandleW(default);  // PE image VM mapped base address
p += Marshal.ReadInt32(p, 0x3C);        // RVA of COFF/PE within DOS header

var subsys = (Subsystem)Marshal.ReadInt16(p + 0x005C);        // (same as before)
var machine = (ImageFileMachine)Marshal.ReadInt16(p + 0x0004);          // new
var imgType = (ImageFileCharacteristics)Marshal.ReadInt16(p + 0x0016);  // new
var dllFlags = (DllCharacteristics)Marshal.ReadInt16(p + 0x005E);       // new
//                    ... etc.

为了在访问未托管内存中的多个字段时改善情况,定义一个覆盖的struct是必不可少的。这允许使用C#进行直接和自然的托管访问。对于运行示例,我将相邻的COFF和PE头合并到以下C# struct定义中,并仅包括我们认为有趣的四个字段:
[StructLayout(LayoutKind.Explicit)]
struct COFF_PE
{
    [FieldOffset(0x04)] public ImageFileMachine MachineType;
    [FieldOffset(0x16)] public ImageFileCharacteristics Characteristics;
    [FieldOffset(0x5C)] public Subsystem Subsystem;
    [FieldOffset(0x5E)] public DllCharacteristics DllCharacteristics;
};

注意:完整版本的此结构体(不包含省略字段)可以在这里找到。

任何类似于这样的互操作struct都必须在运行时正确设置,并且有许多选项可供选择。理想情况下,最好直接在非托管内存上实施struct覆盖层 "in-situ",这样就不需要进行任何内存复制。为了避免在此进一步延长讨论,我将展示一种更简单的方法,但确实涉及复制。

var p = GetModuleHandleW(default);
var _pe = Marshal.PtrToStructure<COFF_PE>(p + Marshal.ReadInt32(p, 0x3C));

Trace.WriteLine($@"
    MachineType:        {_pe.MachineType}
    Characteristics:    {_pe.Characteristics}
    Subsystem:          {_pe.Subsystem}
    DllCharacteristics: {_pe.DllCharacteristics}");


4. 演示代码的输出结果

控制台程序运行时,以下是输出结果...

机器类型:Amd64
特征:可执行映像、大地址空间感知
子系统:WindowsCui (3)
DLL特征:高熵VA、动态基址、Nx兼容、无SEH、TSAware

...相比于GUI(WPF)应用程序:

机器类型:Amd64
特征:可执行映像、大地址空间感知
子系统:WindowsGui (2)
DLL特征:高熵VA、动态基址、Nx兼容、无SEH、TSAware


要确定.NET应用程序是否在GUI模式下运行:
bool is_console_app = Console.OpenStandardInput(1) != Stream.Null;

1
+1 是因为我有一个案例,这种方法有效,而 Environment.UserInteractive 方法则无效。这个案例是一个 NUnit 单元测试,当我按下 ESC 键时,我想要中止测试。在从 NUnit GUI 运行时,您无法调用 Console.KeyAvailable,因此我需要一个测试来知道何时跳过该代码。Glenn 的答案正确地识别了我是在 NUnit GUI 中运行还是在控制台窗口中运行,而 Environment.UserInteractive 属性在两种情况下都为 TRUE。 - Brad Oestreicher
2
@Trafz 注意,System.IO是一个命名空间,这里引用的部分(Console)是在mscorlib.dll中实现的,因此您可能既没有额外的程序集需要引用,也没有运行时的过度绑定。 - Glenn Slayden
1
这个方法在我这里已经运行了好几年。然而,最新版的Visual Studio(版本16.9.3)不再支持此方法。看起来是VS在从其中运行应用程序时创建了自己的标准输入。如果您独立启动编译后的.exe文件,则仍然可以工作,但您无法从VS中进行调试。 - 00jt
2
@00jt 很有趣,你刚刚提到这个问题 - 在今天看到你的评论后,我的回归测试中几乎立即出现了同样的问题(也是在VS 16.9.3上)。显然发生了一些变化;正如你所提到的,这个方法已经使用了很长时间,但显然调试器现在决定连接stdin,这意味着这个长期存在的黑客攻击可能已经结束了... - Glenn Slayden
1
@00jt 我猜你指的是这个,这可能是一开始就应该采用的方法。这是一个很好的答案,所以感谢你指出来。我设法改进了那个想法,使它不必打开任何磁盘文件,并且我在这里彻底改进了我的答案,以展示如何做到这一点。 - Glenn Slayden
显示剩余3条评论

44

6
FYI:"Environment.UserInteractive" 返回 true 表示当 "允许服务与桌面交互" 选项被勾选时,一个服务可以与桌面交互。 - James Wilkins
我的解决方案很简单,只需传递一个命令行参数以知道我处于服务模式。当我四处查看时,我认为这是任何其他人也能想到的唯一确定的方法。;) 我相信有一种方法或黑客,我只是从来没有需要花时间去找它。;) 也许有一种方法可以知道您如何与服务主机连接(父进程?不确定)。也许您可以使用此页面上的其他答案(https://dev59.com/A3M_5IYBdhLWcg3w3nbz#8711036)来测试窗口是否打开。 - James Wilkins
请注意:关于如何使用命令行开关(启动参数)安装Windows服务(使用InstallUtil),您可以在这里找到相关信息:此处链接一此处链接二 - James Wilkins
如果已调用 FreeConsole()(在 kernel32.dll 中),则此方法不起作用。在我们的情况下,情景是一个既支持命令行模式又支持交互模式的程序。它作为控制台程序启动,但当用户没有提供命令行选项时,使用 FreeConsole() 关闭控制台。之后,Environment.UserInteractive 仍为 true。所以最好检测 GetConsoleWindow() 是否返回有效指针。如果没有,则没有控制台。 - Menno Deij - van Rijswijk

6
如果您只是想确定在程序退出后控制台是否仍然存在(例如,您想提示用户在程序退出前按下“Enter”键),那么您只需要检查您的进程是否是唯一一个附加到控制台的进程。如果是,则当您的进程退出时,控制台将被销毁。如果有其他进程附加到控制台,则控制台将继续存在(因为您的程序不会是最后一个)。
例如*:
using System;
using System.Runtime.InteropServices;

namespace CheckIfConsoleWillBeDestroyedAtTheEnd
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            // ...

            if (ConsoleWillBeDestroyedAtTheEnd())
            {
                Console.WriteLine("Press any key to continue . . .");
                Console.ReadKey();
            }
        }

        private static bool ConsoleWillBeDestroyedAtTheEnd()
        {
            var processList = new uint[1];
            var processCount = GetConsoleProcessList(processList, 1);

            return processCount == 1;
        }

        [DllImport("kernel32.dll", SetLastError = true)]
        static extern uint GetConsoleProcessList(uint[] processList, uint processCount);
    }
}

(*) 本文内容改编自此处发现的代码


1
我非常确定当我第一次提出这个问题时,来自Windows API的GetConsoleProcessList()函数不能直接从C#中调用,所以这是一个非常好的更新。 - Jeff Leonard
@JeffLeonard GetConsoleProcessList 可以直接通过 P/Invoke 在任何 .NET 版本中调用,只要您运行的是 Windows XP 或更高版本的 Windows - https://learn.microsoft.com/en-us/windows/console/getconsoleprocesslist#requirements - C. Augusto Proiete

6

3
格伦·斯莱登的解决方案可能需要改进:
bool isConsoleApplication = Console.In != StreamReader.Null;

1
谢谢分享! 它是否是一种改进取决于您正在寻找什么。 Console.In将受到Console.SetIn的影响。在我的用例中,我想查看正在执行的程序集是否为WinForms。在这种情况下,我认为Glenn的解决方案(Console.OpenStandardInput)更合适。 但拥有多种选择总是好的! - Pennidren

2

在交互式控制台中提示用户输入,但在没有控制台或输入被重定向时不执行任何操作:

if (Environment.UserInteractive && !Console.IsInputRedirected)
{
    Console.ReadKey();
}

1
Console.IsInputRedirected 似乎也可以与 docker build 一起使用。 - Matthias

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接