如何读取未刷新的进程输出?

15

考虑这个小程序被编译为application.exe

#include <stdio.h>

int main()
{
    char str[100];
    printf ("Hello, please type something\n");
    scanf("%[^\n]s", &str);
    printf("you typed: %s\n", str);
    return 0;
}

现在我使用这段代码启动application.exe并获取其输出。

#include <stdio.h>
#include <iostream>
#include <stdexcept>

int main()
{
    char buffer[128];
    FILE* pipe = popen("application.exe", "r");
    while (!feof(pipe)) {
        if (fgets(buffer, 128, pipe) != NULL)
            printf(buffer);
    }
    pclose(pipe);
    return 0;
}
我的问题是,只有在我输入后才会有输出。然后两个输出行都被获取。 我可以通过在第一个 printf 语句之后添加此行来解决这个问题。
fflush(stdout);

在我输入预期之前,首先获取第一行。

但是,我如何获取那些我无法修改且不使用fflush()在“实时”(即在它们退出之前)的应用程序的输出呢? 并且Windows CMD是如何做到这一点的?


1
请定义您所说的“实时”是什么意思。 - too honest for this site
2
while (!feof(pipe)) 是错误的。 - too honest for this site
https://dev59.com/GlHTa4cB1Zd3GeqPSIS3 - sameerkn
1
@AlgirdasPreidžius 对我来说也是一样的,在Ubuntu上。 - rustyx
@RustyX 那么你没有使用我所说的Windows cmd。我很久以前用过Linux,但我记得Linux终端和Windows cmd之间有一些根本性的区别。 - Algirdas Preidžius
显示剩余10条评论
5个回答

12

你遇到了这样一个问题:在C程序中自动打开的流的缓冲区根据连接的设备类型而改变,这有点奇怪。*nix系统(反映在C标准库中)的一大特点就是进程不太关心数据来源和输出位置。您可以随意地使用管道和重定向,通常都是即插即用,速度非常快。

但显然交互是其中一个规则破坏的明显场所。正如您提供的一个很好的例子。如果程序的输出采用块缓冲,则在累计4K数据或进程退出之前您看不到输出。

但是程序可以检测到它是否通过isatty() (以及可能通过其他方式)写入终端。终端概念上包括用户,暗示它是一个交互式程序。打开标准输入和标准输出的库代码会检查并更改其缓冲策略为行缓冲:遇到换行符时,流被刷新。 这对于交互式、基于行的应用程序来说非常完美。(对于行编辑,如bash所做的那样,完全禁用缓冲并不是最佳选择。)

open group man page for stdin相对于缓冲区模糊不清,以便给实现提供足够的灵活性以提高效率,但它确实说明了:

当且仅当可以确定流不参考交互式设备时,标准输入和标准输出流才是完全缓冲的。

这就是您程序所遇到的情况:标准库发现它在“非交互式”下运行(写入管道),尝试变得聪明而高效,并切换为块缓冲。换行符不再刷新输出。通常这是一件好事:想象一下编写二进制数据,平均每256个字节写一次磁盘!可怕。

需要注意的是,在您和磁盘之间可能存在一整个缓冲区级联;在C标准库之后是操作系统的缓冲区,然后是磁盘的缓冲区。

现在来看你的问题:用于存储待写字符的标准库缓冲区位于程序的内存空间中。尽管外观上看起来,数据尚未离开您的程序,因此(正式地)不可被其他程序访问。我认为你很倒霉。你并不孤单:大多数交互式控制台程序在通过管道操作时性能表现都很差。


非常感谢您提供这个详细的解答,我现在清楚多了。 - ArcticLord

8

在我看来,这是IO缓冲中较不合理的部分之一:当输出被定向到终端或文件/管道时,它会呈现不同的行为。如果将IO定向到文件或管道,通常情况下会进行缓冲,这意味着只有在缓冲区满或出现显式刷新时才会实际写入输出 => 当您通过popen执行程序时,就会看到这种情况。

但是当IO定向到终端时,会发生特殊情况:在从相同终端读取之前,所有未决的输出都会自动刷新。这种特殊情况是必要的,以允许交互式程序在读取之前显示提示。

不好的地方在于,如果您尝试通过管道驱动交互式应用程序,那您就输了:只有在应用程序结束或足够多的文本输出填满缓冲区时才能读取提示。这就是Unix开发人员发明所谓的“伪终端”(pty)的原因。它们被实现为终端驱动程序,以便应用程序使用交互式缓冲区,但实际上由拥有pty主控端的另一个程序来处理IO。

不幸的是,由于您在写application.exe,我假设您使用的是Windows,而我不知道Windows API中是否有一个等效的机制。被调用的程序必须使用无缓冲IO(stderr默认为无缓冲),以便调用者在发送答案之前读取提示。


感谢您的回答。实际上,我正在寻找一种独立于操作系统的解决方案。正如您所解释的和这个问题显示的,在Linux中似乎是可能的,但在Windows中不可能。 - ArcticLord

3
我的问题已经在其他答案中得到了很好的解释。
控制台应用程序使用名为isatty()的函数来检测它们的stdout处理程序是否连接到管道或实际控制台。 如果是管道,所有输出都会被缓冲并以块方式刷新,除非直接调用fflush()。如果是实际控制台,则输出是不带缓冲区的,并直接打印到控制台输出。
在Linux中,您可以使用openpty()创建一个伪终端,并在其中创建进程。结果,该进程将认为它在实际终端上运行并使用不带缓冲区的输出。
Windows似乎没有这样的选项。

经过大量查阅winapi文档,我发现这是不正确的。实际上,您可以创建自己的控制台屏幕缓冲区,并将其用于进程的stdout,然后该输出将不带缓冲区。
可惜这不是一个非常舒适的解决方案,因为没有事件处理程序,我们需要轮询新数据。此外,目前我不确定如何处理当此屏幕缓冲区满时的滚动。
但是即使还有一些问题没有解决,我认为我已经为那些曾经想要获取未缓冲(未刷新)的Windows控制台进程输出的人创建了一个非常有用(且有趣)的起点。

#include <windows.h>
#include <stdio.h>

int main(int argc, char* argv[])
{
    char cmdline[] = "application.exe"; // process command
    HANDLE scrBuff;                     // our virtual screen buffer
    CONSOLE_SCREEN_BUFFER_INFO scrBuffInfo; // state of the screen buffer
                                            // like actual cursor position
    COORD scrBuffSize = {80, 25};       // size in chars of our screen buffer
    SECURITY_ATTRIBUTES sa;             // security attributes
    PROCESS_INFORMATION procInfo;       // process information
    STARTUPINFO startInfo;              // process start parameters
    DWORD procExitCode;                 // state of process (still alive)
    DWORD NumberOfCharsWritten;         // output of fill screen buffer func
    COORD pos = {0, 0};                 // scr buff pos of data we have consumed
    bool quit = false;                  // flag for reading loop

    // 1) Create a screen buffer, set size and clear

    sa.nLength = sizeof(sa);
    scrBuff = CreateConsoleScreenBuffer( GENERIC_READ | GENERIC_WRITE,
                                         FILE_SHARE_READ | FILE_SHARE_WRITE,
                                         &sa, CONSOLE_TEXTMODE_BUFFER, NULL);
    SetConsoleScreenBufferSize(scrBuff, scrBuffSize);
    // clear the screen buffer
    FillConsoleOutputCharacter(scrBuff, '\0', scrBuffSize.X * scrBuffSize.Y,
                               pos, &NumberOfCharsWritten);

    // 2) Create and start a process
    //      [using our screen buffer as stdout]

    ZeroMemory(&procInfo, sizeof(PROCESS_INFORMATION));
    ZeroMemory(&startInfo, sizeof(STARTUPINFO));
    startInfo.cb = sizeof(STARTUPINFO);
    startInfo.hStdOutput = scrBuff;
    startInfo.hStdError = GetStdHandle(STD_ERROR_HANDLE);
    startInfo.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
    startInfo.dwFlags |= STARTF_USESTDHANDLES;
    CreateProcess(NULL, cmdline, NULL, NULL, FALSE,
                  0, NULL, NULL, &startInfo, &procInfo);    
    CloseHandle(procInfo.hThread);

    // 3) Read from our screen buffer while process is alive

    while(!quit)
    {
        // check if process is still alive or we could quit reading
        GetExitCodeProcess(procInfo.hProcess, &procExitCode);
        if(procExitCode != STILL_ACTIVE) quit = true;

        // get actual state of screen buffer
        GetConsoleScreenBufferInfo(scrBuff, &scrBuffInfo);

        // check if screen buffer cursor moved since
        // last time means new output was written
        if (pos.X != scrBuffInfo.dwCursorPosition.X ||
            pos.Y != scrBuffInfo.dwCursorPosition.Y)            
        {
            // Get new content of screen buffer
            //  [ calc len from pos to cursor pos: 
            //    (curY - posY) * lineWidth + (curX - posX) ]
            DWORD len =  (scrBuffInfo.dwCursorPosition.Y - pos.Y)
                        * scrBuffInfo.dwSize.X 
                        +(scrBuffInfo.dwCursorPosition.X - pos.X);
            char buffer[len];
            ReadConsoleOutputCharacter(scrBuff, buffer, len, pos, &len);

            // Print new content
            // [ there is no newline, unused space is filled with '\0'
            //   so we read char by char and if it is '\0' we do 
            //   new line and forward to next real char ]
            for(int i = 0; i < len; i++)
            {
                if(buffer[i] != '\0') printf("%c",buffer[i]);
                else
                {
                    printf("\n");
                    while((i + 1) < len && buffer[i + 1] == '\0')i++;
                }
            }

            // Save new position of already consumed data
            pos = scrBuffInfo.dwCursorPosition;
        }
        // no new output so sleep a bit before next check
        else Sleep(100);
    }

    // 4) Cleanup and end

    CloseHandle(scrBuff);   
    CloseHandle(procInfo.hProcess);
    return 0;
}

0

你不能这样做。 因为未刷新的数据是由程序本身拥有的。


但是Windows的cmd和Linux的ttys似乎可以这样做。我想知道为什么。 - ArcticLord
你有一个例子吗? - apple apple
我的例子在我的问题中。第一段代码在Windows cmd中运行良好。但是在我的输出获取器代码中,除非我使用flush,否则无法正常工作。 - ArcticLord
1
不,问题的重点是:为什么当stdout是控制台时,printf会刷新每一行,但当stdout是文件时,它会缓冲数据。 - rustyx
好的,至少它根本不需要刷新,因此人们永远不应该依赖它。 - apple apple
显示剩余5条评论

-1

我认为你可以将数据刷新到stderr,或者封装一个fgetcfungetc函数来避免破坏流,或者使用system("application.ext >>log"),然后mmap日志到内存中以执行所需的操作。


你认为当你重定向时,application.exe的标准输出变得无缓冲吗?(我不是在讽刺。我不太确定。但我几乎可以确定:stdout的缓冲编码在putchar宏中,它是C标准库的一部分,位于系统上;它不受不同进程(如shell或建立管道的程序)控制。) - Peter - Reinstate Monica
回答我的问题:是的,它会改变;标准库会根据stdin/out是否连接到终端来更改缓冲。至于你的建议:日志文件不会包含程序的输出,无论是否使用mmapped。;-) - Peter - Reinstate Monica

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