Esegui un comando e ottieni sia l'output che lo stato di uscita in C++ (Windows e Linux)

Esegui un comando e ottieni sia l'output che lo stato di uscita in C++ (Windows e Linux)

Recentemente ho dovuto analizzare alcuni output della riga di comando all'interno di un programma C++. Eseguire un comando e ottenere solo lo stato di uscita è facile usando std::system , ma anche ottenere l'output è un po' più difficile e specifico del sistema operativo. Usando popen , un POSIX C funzione possiamo ottenere sia lo stato di uscita che l'output di un determinato comando. Su Windows sto usando _popen , quindi il codice dovrebbe essere multipiattaforma, ad eccezione dello stato di uscita su Windows sempre 0, quel concetto non esiste lì. Questo articolo inizia con un esempio di overflow dello stack per ottenere solo l'output di un comando e si basa su quello in una versione più sicura (gestione dei byte null) che restituisce sia lo stato di uscita che l'output del comando. Implica anche molti dettagli su fread rispetto a fgets e come gestire i dati binari.

L'esempio di codice completo con esempi di utilizzo può essere trovato su github qui o in fondo a questa pagina. Un esempio funzionante è compilato su azioni github per piattaforme diverse (Windows e Linux).

Normalmente consiglierei di non analizzare l'output della riga di comando. È soggetto a errori, dipendi dalla lingua selezionata dall'utente, versioni diverse potrebbero avere flag diversi (OS X rispetto a Linux ) e altro ancora. Se hai la possibilità di utilizzare una libreria nativa, dovresti usarla. Un esempio potrebbe essere l'analisi di curl output per ottenere alcuni dati da un'API. Probabilmente ci sono una tonnellata metrica di http librerie disponibili per il tuo linguaggio di programmazione preferito da usare invece di analizzare il curl o wget o fetch produzione.

Nel mio caso devo usare un vecchio programma per analizzare un file closed-source per ottenere un output binario. Questa è una situazione temporanea, è in fase di sviluppo anche una libreria di analisi nativa. Il binario è sotto il mio controllo, così come le impostazioni di sistema, la lingua, altri strumenti e simili, quindi per questo caso d'uso specifico la soluzione per analizzare l'output della riga di comando era accettabile per il momento.

Nota che in questo post scambierò il termine nullbyte, carattere nullo, terminazione nulla e terminato da null. Hanno tutti lo stesso significato, il carattere null-byte utilizzato per terminare una stringa C (\0 o ^@ , U+0000 o 0x00 , hai l'essenza).

Se hai bisogno di più funzionalità, più multipiattaforma o esecuzione asincrona, boost.Process è un'ottima alternativa. Tuttavia, non posso utilizzare boost sull'ambiente in cui questo codice verrà eseguito a causa di vincoli di dimensioni e compilatore.

L'esempio di stackoverflow usando fgets

Su StackOverflow l'esempio fornito è una buona base su cui basarsi, tuttavia, per ottenere il codice di uscita e l'output, è necessario modificarlo. Poiché vogliamo anche prendere il codice di uscita, non possiamo usare l'esempio che usa il std::unique_ptr . Che di per sé è un ottimo esempio di utilizzo di un unique_ptr con un cancellatore personalizzato (cmd è un const char* con il comando da eseguire:

std::unique_ptr<FILE, decltype(&pclose)> pipe(popen(cmd, "r"), pclose);

Il codice è copiato di seguito:

std::string exec(const char* cmd) {
    char buffer[128];
    std::string result = "";
    FILE* pipe = popen(cmd, "r");
    if (!pipe) throw std::runtime_error("popen() failed!");
    try {
        while (fgets(buffer, sizeof buffer, pipe) != NULL) 
            result += buffer;
        }
    } catch (...) {
        pclose(pipe);
        throw;
    }
    pclose(pipe);
    return result;
}

Questo esempio fa ciò che afferma, ma con alcuni trucchi. Utilizza un FILE* (puntatore), char allocazione del buffer e chiusura manuale del FILE* quando qualcosa va storto (catch ). Il unique_ptr esempio è più moderno, poiché non è necessario gestire l'eccezione e utilizzare un std::array<char, 128> invece di un char* in stile C respingente. Il lancio di eccezioni è tutta un'altra questione, ma non entriamo nel merito oggi. Quello di cui parleremo oggi è il codice in stile C da leggere da FILE* e come vengono gestiti i dati binari.

L'esempio di stackoverflow probabilmente va bene se hai solo bisogno di un output testuale in un std::string . Il mio caso d'uso, tuttavia, era un po' più complesso, come scoprirai leggendo il resto di questo articolo.

paura contro fget

Stile del codice a parte, il mio problema più grande era l'utilizzo di fgets in questo modo combinato con l'aggiunta di un const char* a un std::string si interrompe quando incontra un nullyte (\0 ). Per l'output di stringhe regolare che spesso non è un problema, la maggior parte dei comandi emette solo alcune stringhe e lo chiama un giorno. Il mio output restituisce un BLOB binario, che potrebbe includere nullbyte. fread legge una quantità di byte e restituisce quanto ha letto con successo, che possiamo usare quando aggiungiamo l'output al nostro std::string compresi i nullbyte.

L'esempio sopra fa result += buffer , aggiungendo un const char* a un std::string ,in questo caso secondo cppreference su operator+=su std::string:Appends the null-terminated character string pointed to by s.

Il problema sta nel fatto che anche i caratteri dopo il nullbyte dovrebbero essere aggiunti nel mio caso. fgets non restituisce la quantità di dati letti. Usando fgets e un buffer di 128, se ho un nullbyte a 10 e una nuova riga a 40, i primi 10 byte più quelli che sono dopo 40 byte verranno restituiti. In effetti stiamo perdendo tutto tra il nullbyte e la nuova riga, o fino alla fine del buffer (128) se non c'è una nuova riga in mezzo.

fread restituisce la quantità di byte che ha letto. Combinandolo con un costruttore di std::string che accetta un const char* e un size_t possiamo forzare l'intero contenuto all'interno della stringa. Questo è sicuro, poiché astd::string conosce le sue dimensioni, non si basa su un carattere di terminazione nullo. Tuttavia, altro codice che utilizza const char* non sarà in grado di lavorare con questi nullbyte, tienilo a mente.

Questo post sull'overflow mi è stato molto utile per capire fread , oltre all'aiuto di un collega che sogna in C , ha spiegato molto del funzionamento interno.

E se, dopo tutto questo, ti stai chiedendo perché sto inserendo dati binari all'interno di un std::string , ottima domanda. Probabilmente ne parlerò un'altra volta poiché ciò richiederebbe un post più lungo rispetto all'intero articolo.

Esecuzione del comando inclusi output e codice di uscita

Il mio codice controlla lo stato di uscita del binario eseguito (per la gestione degli errori) e utilizza i dati restituiti per un'ulteriore elaborazione. Per mantenere tutto questo in un unico posto a portata di mano, iniziamo con la definizione di una struttura per contenere quei dati. Conterrà il risultato di un command , quindi il nome CommandResult suona abbastanza descrittivo.

Di seguito troverai il codice della struttura, incluso un operatore di uguaglianza e un operatore di output del flusso.

struct CommandResult {
    std::string output;
    int exitstatus;

    friend std::ostream &operator<<(std::ostream &os, const CommandResult &result) {
        os << "command exitstatus: " << result.exitstatus << " output: " << result.output;
        return os;
    }
    bool operator==(const CommandResult &rhs) const {
        return output == rhs.output &&
               exitstatus == rhs.exitstatus;
    }
    bool operator!=(const CommandResult &rhs) const {
        return !(rhs == *this);
    }
};

La carne e le patate dello struct sono ovviamente il output e exitstatus . Sto meditando un int per lo stato di uscita per motivi.

La parte successiva è il Command classe stessa, ecco quel codice:

class Command {

public:
    /**
     * Execute system command and get STDOUT result.
     * Like system() but gives back exit status and stdout.
     * @param command system command to execute
     * @return CommandResult containing STDOUT (not stderr) output & exitstatus
     * of command. Empty if command failed (or has no output). If you want stderr,
     * use shell redirection (2&>1).
     */
    static CommandResult exec(const std::string &command) {
        int exitcode = 255;
        std::array<char, 1048576> buffer {};
        std::string result;
#ifdef _WIN32
#define popen _popen
#define pclose _pclose
#define WEXITSTATUS
#endif
        FILE *pipe = popen(command.c_str(), "r");
        if (pipe == nullptr) {
            throw std::runtime_error("popen() failed!");
        }
        try {
            std::size_t bytesread;
            while ((bytesread = fread(buffer.data(), sizeof(buffer.at(0)), sizeof(buffer), pipe)) != 0) {
                result += std::string(buffer.data(), bytesread);
            }
        } catch (...) {
            pclose(pipe);
            throw;
        }
        exitcode = WEXITSTATUS(pclose(pipe));
        return CommandResult{result, exitcode};
    }
}

Il fread il comando verrà eseguito fino a quando non ci saranno più byte restituiti dall'output del comando. Conosco il tipo di output con cui sto lavorando, quindi il mio buffer è 1MiB, che probabilmente è troppo grande per i tuoi dati. Nel mio caso l'ho confrontato e tra 10 KiB e 1 MiB è stato il più veloce sull'architettura di destinazione. 128 o 8192 probabilmente vanno bene, ma dovresti fare un benchmark per te stesso. Un test piuttosto semplice consiste nell'output di un file enorme con cat e prendi il tempo di esecuzione più la CPU e l'utilizzo della memoria. Non stampare il risultato, guarda queste tre cose e scegli quale rapporto è accettabile per te.

Perché non inizializzare anche il std::string con 1 MiB di caratteri? std::strings non possono essere assegnati per una data dimensione in costruzione, se non riempindoli o successivamente chiamando il .reserve() , i miei benchmark non hanno mostrato alcun aumento significativo della velocità o delle prestazioni in questo modo.

Usare il codice sopra è facile. Poiché è una funzione statica, non è necessaria un'istanza di classe per utilizzarla. Ecco un esempio:

std::cout << Command::exec("echo 'Hello you absolute legends!'") << std::endl;

Che si traduce in:

command exitstatus: 0 output: Hello you absolute legends!

Dal momento che stiamo attraversando una shell, anche il reindirizzamento funziona. Reindirizzamento di stdout a stderr non produce alcun output, solo uno stato di uscita:

std::cout << Command::exec("echo 'Hello you absolute legends!' 1>&2") << std::endl;

L'output è su stderr nel mio guscio invece, che è previsto:

Se hai bisogno di acquisire stderr quindi reindirizzi l'output al contrario, in questo modo:

std::cout << Command::exec("/bin/bash --invalid  2>&1") << std::endl;

Le pipe funzionano bene come nella tua shell, ma tieni presente che tutto questo utilizza sh e non hai alcun controllo sulle variabili di ambiente o sulla shell predefinita. Maggiori informazioni sulla pagina POSIX su popen per scoprire perché.

Una nota su Windows

Ecco un esempio per Windows, dove dobbiamo usare _popen e _pclose :

std::cout << "Windows example:" << std::endl;
std::cout << Command::exec("dir * /on /p") << std::endl;

Il codice di uscita sarà sempre zero poiché quel concetto non si traduce in Windows. C'è %ErrorLevel% , ma è solo una variabile di ambiente per le applicazioni console, non lo stato di uscita effettivo.

La pagina microsoft rileva inoltre che _popen non funzionerà con le applicazioni GUI, solo con i programmi della console. Se ne hai bisogno, usa Boost.process o system .

Nullbyte nell'esempio di output:

Nel codice di esempio su github vedrai anche un execFgets funzione, l'ho lasciato lì per mostrare la differenza nella gestione dei nullbyte. Per riferimento mostrerò anche un esempio qui. La parte rilevante del comando usando fgets :

while (std::fgets(buffer.data(), buffer.size(), pipe) != nullptr)
    result += buffer.data();

La parte che utilizza fread :

std::size_t bytesread;
while ((bytesread = fread(buffer.data(), sizeof(buffer.at(0)), sizeof(buffer), pipe)) != 0)         
    result += std::string(buffer.data(), bytesread);

Il comando di test, inclusa un'esclusione di avviso clang-tidy (// NOLINT ):

int main() {
    using namespace raymii;

    std::string expectedOutput("test\000abc\n", 9); //NOLINT
    commandResult nullbyteCommand = command::exec("/usr/bin/printf 'test\\000abc\\n'"); // NOLINT(bugprone-string-literal-with-embedded-nul)
    commandResult fgetsNullbyteCommand = command::execFgets("/usr/bin/printf 'test\\000abc\\n'"); // NOLINT(bugprone-string-literal-with-embedded-nul)

    std::cout << "Expected output: " << expectedOutput << std::endl;
    std::cout << "Output using fread: " << nullbyteCommand << std::endl;
    std::cout << "Output using fgets: " << fgetsNullbyteCommand << std::endl;
    return 0;
}

Uscita:

Expected output: test\0abc
A command with nullbytes using fread: exitstatus: 0 output: test\0abc
A command with nullbytes using fgets: exitstatus: 0 output: test

Il carattere nullbyte viene sostituito con \0 nell'output di cui sopra. Ecco uno screenshot che mostra come appare nel mio terminale:

Nota ancora una volta che questo è sicuro da usare con std::strings , metodi che accettano un string_view ora const char* probabilmente non reagirà molto bene ai nullbyte. Per il mio caso d'uso questo è sicuro, il tuo migliaggio può variare.

Prova a giocare con buffer dimensione e quindi guardando l'output. Se lo imposti su 4, l'output con fgets è testbc . Divertente vero? Mi piacciono queste cose.

Codice completo

Di seguito puoi trovare il file di intestazione command.h . È anche sul mio github. Se vuoi esempi di utilizzo li puoi trovare nel progetto github main.cpp file.

#command.h
#ifndef COMMAND_H
#define COMMAND_H
// Copyright (C) 2021 Remy van Elst
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU General Public License for more details.
//
//     You should have received a copy of the GNU General Public License
//     along with this program.  If not, see <http://www.gnu.org/licenses/>.
#include <array>
#include <ostream>
#include <string>
#ifdef _WIN32
#include <stdio.h>
#endif

namespace raymii {

    struct CommandResult {
        std::string output;
        int exitstatus;
        friend std::ostream &operator<<(std::ostream &os, const CommandResult &result) {
            os << "command exitstatus: " << result.exitstatus << " output: " << result.output;
            return os;
        }
        bool operator==(const CommandResult &rhs) const {
            return output == rhs.output &&
                   exitstatus == rhs.exitstatus;
        }
        bool operator!=(const CommandResult &rhs) const {
            return !(rhs == *this);
        }
    };

    class Command {
    public:
        /**
             * Execute system command and get STDOUT result.
             * Regular system() only gives back exit status, this gives back output as well.
             * @param command system command to execute
             * @return commandResult containing STDOUT (not stderr) output & exitstatus
             * of command. Empty if command failed (or has no output). If you want stderr,
             * use shell redirection (2&>1).
             */
        static CommandResult exec(const std::string &command) {
            int exitcode = 0;
            std::array<char, 1048576> buffer {};
            std::string result;
#ifdef _WIN32
#define popen _popen
#define pclose _pclose
#define WEXITSTATUS
#endif
            FILE *pipe = popen(command.c_str(), "r");
            if (pipe == nullptr) {
                throw std::runtime_error("popen() failed!");
            }
            try {
                std::size_t bytesread;
                while ((bytesread = std::fread(buffer.data(), sizeof(buffer.at(0)), sizeof(buffer), pipe)) != 0) {
                    result += std::string(buffer.data(), bytesread);
                }
            } catch (...) {
                pclose(pipe);
                throw;
            }
            exitcode = WEXITSTATUS(pclose(pipe));
            return CommandResult{result, exitcode};
        }

    };

}// namespace raymii
#endif//COMMAND_H