65.9K
CodeProject 正在变化。 阅读更多。
Home

通用控制台重定向器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (33投票s)

2002年10月5日

11分钟阅读

viewsIcon

296612

downloadIcon

4453

从您的GUI应用程序启动控制台进程并接收其输出,即使是从Win9x

Sample Image - consolePipe.gif

引言

我想要一个像Developer Studio那样用于我的程序的控制台输出窗格——用于启动命令并有序地接收它们的输出,而不是让DOS提示符到处弹出。我在CodeProject上找到了一些相关的类[1, 2],它们通常能完成我想要的工作,但有一个共同的限制:它们只适用于NTxx,而不适用于9x。

正如您所做的,我做了一些研究,并最终从头开始编写了一个新类。它主要用纯Win32 API编写,除了对WTL(CString类)有一些轻微的依赖。我在9x和NT(包括UNICODE)上都测试过,它工作得很好。我希望您会发现它具有“工业级”强度,因此在您的程序中是可靠的。

适用于所有Windows平台的控制台重定向

Windows进程可以共享一个控制台——这就是DOS命令提示符的工作方式。CreateProcess API提供了选项,允许您指定启动的进程是继承父进程的控制台还是拥有自己的控制台。传统上,控制台与命令处理器相关联,但这只是一种用途。总的来说,它们是标准I/O句柄与普通(非GUI)进程绑定的窗口,允许使用printfcout等库函数进行简单的输入和输出。

如果您有一个GUI进程,那么大多数情况下您不需要控制台,因此任何启动的子控制台进程都需要拥有自己的控制台。在这种情况下,您可以考虑创建并提供一个控制台,但这充其量是麻烦的。控制台窗口属于一类特殊的窗口,您无法轻松地从中复制文本,甚至无法对其使用标准的子类化/挂钩机制。最好没有它们。

通常的解决方案是启动子进程,为其提供一个*隐藏*的控制台,并将其标准句柄重定向到匿名管道。如果您和我一样,以前从未直接使用过控制台或管道,那么这里有很多新的术语,但过程在Q190351 - HOWTO: Spawn Console Processes with Redirected Standard Handles 中描述得相当好。总而言之,以下是主要步骤:

  • 为子进程的标准输入和输出创建2个管道,确保子进程的管道端句柄是可继承的。
  • 使用CreateProcess启动子进程,并通过STARTF_USESTDHANDLES选项将管道的子进程端传递给它。
  • 创建一个监听线程,该线程等待stdout管道的“上游”端。每当子进程在其stdout中写入时,我们的线程就会从其ReadFile阻塞调用中唤醒,并检索子进程的输出。
  • 当子进程终止时,管道就会断开,线程也可以终止。

以类似的方式,如果需要,我们可以使用stdin管道向子进程发送输入。我们在我们的端使用WriteFile,子进程接受它,就像它从(不可见的)控制台读取一样。请注意,我们不需要为此任务创建一个线程。这就是在父子进程之间建立双向通信的所有工作。是这样吗?

Windows 9x的复杂性

如果没有所有这些特定于平台的怪癖,开发人员的生活就会枯燥乏味,对吧?:) 上述过程在Win9x中运行良好,但*仅当*启动的进程使用32位控制台时。碰巧您更有可能启动的子进程类型,即DOS命令处理器,使用16位控制台,这会破坏我们监听线程等待接收子进程输出的ReadFile调用。我们要么收不到输出,要么不知道子进程何时终止(或两者都有)。太棒了!

更糟糕的是,微软在其知识库文章Q150956 - INFO: Redirection Issues on Windows 95 MS-DOS Applications 中提供了一个非常愚蠢的解决方法。这毫不掩饰地建议启动一个中间存根Win32控制台进程,并从中启动预期的子进程(!)忽略了这带来的“轻微”效率问题。您可以将其留给您的公关部门,试图想出诸如“买一送一”之类的口号,进行绝望的掩盖。但很有可能这 hardly 会让Win9x用户信服/印象深刻——相信我,他们仍然有很多。

Q150956中的解决方法让我思考,整个问题在于使用具有32位控制台的父进程来启动16位控制台的子进程。我不知道为什么微软的那些人没有先想到这一点,但我发现如果原始进程有一个控制台,那么就不需要存根控制台进程了。所以您需要做的就是为您的GUI进程创建一个控制台,然后启动DOS处理器,确保它*共享*父控制台。从那时起,管道重定向就工作得很好了。

显然,您不希望用户看到一个丑陋的控制台窗口弹出现在您的酷炫皮肤对话框旁边,所以它必须保持隐藏。不幸的是,AllocConsole API不像CreateProcess那样接受用于创建隐藏控制台的参数。我发现WH_CBT挂钩对控制台窗口也无效。所以我不得不采取一种低技术的方法,在创建新窗口后使用FindWindow来查找它,然后用ShowWindow来隐藏它。但总的来说,这是付出的微小代价;如果任何读者有更好的解决方法,请告诉我。

使用CConsolePipe类

CConsolePipe类用于希望使用DOS命令处理器(根据Windows平台是cmd.execommand.com)启动子进程并读取其输出的GUI进程。它封装了所有管道工作、线程和平台差异,暴露了一个简单的接口,如下所示:

  • CConsolePipe(BOOL bWinNT, CEdit wOutput, DWORD flags)。此类应*在堆上*使用new创建,因为它会在子进程终止后自行销毁。构造函数需要一个布尔值来指定平台类型(NT或9x)、一些标志和一个编辑控件窗口句柄,重定向的输出将发送到该句柄。
  • int Execute(LPCTSTR pszCommand)。参数是要执行的命令,*不带*命令处理器。它可以包含环境变量,如%WINDIR%,这些变量会自动为您替换。它返回一个CPEXEC_xxx常量,取决于启动的成功与否。如果失败,则您负责*删除*对象,除非您想再试一次。
  • virtual void OnReceivedOutput(LPCTSTR pszText)。如果Execute成功并返回CPEXEC_OK,那么每次捕获到子进程输出时,都会调用此虚拟成员。默认操作是在构造函数中指定的编辑窗口中打印接收到的pszText缓冲区。您可以派生类来覆盖此函数以执行不同的操作。当子进程终止时,OnReceivedOutput会最后调用一次,其参数为NULL,提示此特殊事件。稍后,对象将通过delete this进行自毁。
  • static void Term()。在您的应用程序结束之前(例如,在WinMain中),您应该调用CConsolePipe::Term()来释放为Win9x分配的任何控制台。这只需要调用*一次*,而不是每次使用CConsolePipe对象时都调用。

这些是您最可能使用的方法。对于您想启动的每个命令,都必须在堆上创建一个新实例,并使用Execute方法。这对于我的项目需求很方便,因为我只想“启动并忘记”。

还有一些您可能想要使用的方法,请参阅consolePipe.h中的类定义。代码注释得很详细。您还会发现我非常喜欢频繁使用ATLASSERT。这看起来可能有点过度,但这就是我的风格:)而且在99%的情况下,我最终会感谢上天的存在,尤其是在6个月后懒惰地修改代码时。

构建您的项目

您只需要在您的stdafx.h中包含唯一的头文件consolePipe.h。目标平台是我正在工作的平台,混合了WTL7和ATL3。您不需要任何特殊的初始化(例如,CoInitialize或通用控件)来使用该类。支持UNICODE和MBCS构建。我仍然在使用VS6,但我希望它也能在VS.NET中正常构建。

对ATL/WTL的轻微依赖可以通过将ATLTRACE宏重新定义为纯MFC风格的TRACE等效项,甚至OutputDebugString API调用来移除。对于WTL,它只使用了CString类,但我假设(尽管未经测试)您也可以轻松地使用MFC版本的CString进行编译。最后,在MFC中,您*可能*需要将CEdit包装器引用更改为*指针*。我有一段时间没有使用MFC了,所以我记不清您是否可以通过值传递整个窗口对象。

WTL测试应用程序

我创建了一个简单的基于对话框的应用程序,演示了CConsolePipe类的使用。要构建它,您需要安装Windows Template Library(WTL),这是用于增强ATL窗口的模板类。只需看看可执行文件的大小,您就会明白这意味着什么。如果您没有WTL,您可能可以下载它,并将其路径添加到您的Include目录中。您会很高兴这么做的。

主要操作发生在CMainDlg::OnRun处理程序中,该处理程序读取输入字段,创建管道对象并执行命令,并在事情出错时采取规避措施。

TCHAR buf[128];
GetDlgItemText(IDC_COMMAND, buf, sizeof(buf)/sizeof(buf[0]));

CEdit out(GetDlgItem(IDC_OUTPUT));
m_pLastCommand = new CMyConsolePipe(m_bIsWindowsNT, out, 0);

int status = m_pLastCommand->Execute(buf);
if(CPEXEC_OK == status) {
   // all's well, deactivate run button...
   ...
}
else {
   // error conditions are rare since the intermediate command processor
   // is always started; however if status==CPEXEC_MISC_ERROR you can try
   // a no-frills CreateProcess which will probably succeed

   // if the thread didn't start we have to cleanup manually
   delete m_pLastCommand;
   m_pLastCommand = 0;
   ...
}

您可能已经注意到我正在使用派生类CMyConsolePipe。这是为了覆盖虚拟的OnReceivedOutput成员,以便在子进程终止时向对话框发送消息,从而更新GUI的*运行*/*中断*按钮的激活状态。请注意,OnReceivedOutput在后台线程的上下文中调用;这可能会限制您在此处理程序中可以执行的操作。

您可以使用一个保证不会正常终止的命令来测试*中断*按钮,例如"more <CON:"。单击该按钮会强制终止子进程,使用Break方法。这应该只在情况危急时使用,因为它就像一个真正的霸道做法。对于Win9x,它甚至可能导致Windows崩溃,因此应不惜一切代价避免。

更新(2002年12月)

正如承诺的那样,这里是对原始类的一些改进。各种CPF_xxx标志可以通过在CConsolePipe构造函数中指定来提供额外的可调性。以下常量可以进行OR组合:

  • CPF_REUSECMDPROC。强制创建持久的命令处理器(通过/K选项),该处理器在第一个命令终止后不会停止。这是一种优化的行为,因为您不必每次想运行某些内容时都创建一个新对象。一个具有单一命令处理器和监听线程的单一对象可以处理所有输入。后续命令可以通过ExecuteSendChildInput方法发出。这是通过向子进程的stdin句柄发送输入来实现的,就像有人在控制台窗口中输入一样。当设置此标志时,子进程不会终止,除非调用StopCmd成员(该成员只是发送“exit”命令)。此标志的一个小缺点是,您无法知道命令处理器是否实际上正在忙碌,还是只是在那里待着。命令只是排队等待执行。
  • CPF_NOAUTODELETE。没什么特别的,它只是关闭了对象的默认自毁(通过delete this)。因此,该类可以被重用,但好处并不大。也许可以将其与CPF_REUSECMDPROC结合使用来创建基于堆栈的对象,以防止程序终止时发生泄漏。

演示项目已更新为使用CPF_REUSECMDPROC。此外,它现在演示了向子进程发送输入的过程,适用于那些需要输入的命令(例如,"del /P *.*"等命令的Y/N回答)。请注意,响应可以在输出窗口或指定主命令的框中输入。这有点笨拙,但它有效。主类CConsolePipe主要用于仅生成输出的命令。

更新#2(2003年11月)

这看起来是最后的改进。主要增加项:

  • SendCtrlBrk()方法。这会向正在运行的控制台发送一个Ctrl-Break事件,适用于停止某些程序,如more等。不幸的是,此方法似乎对Win9x没有效果,但无论如何,它比旧的Break()方法那种粗暴的做法要好。请注意,此功能需要一个隐藏的控制台窗口,即使对于Windows NT也是如此。
  • OEM感知。所有标准的命令处理器都使用OEM代码页。假设您的程序处理“Windows”字符串(ANSI或Unicode),则需要对出站命令和接收到的输出进行一些转换。这些转换确保文件名中的“特殊”字符(代码>=128)能够正确编码。有一个标志可以关闭这种默认的OEM假设(CPF_NOCONVERTOEM),但您不太可能想使用它,除非您与自定义命令处理器通信。

演示项目已更新,增加了一个按钮,突出了新的“软中断”功能。如果启动的程序似乎卡住了,请先尝试新的*中断*按钮,然后再诉诸于粗暴的终止。

© . All rights reserved.