Skip to content

Kann das nicht der Compiler machen? Maßgeschneidertes C/C++ Tooling mit Clang

Einleitung

Der ESE Kongress ist die Leitveranstaltung für Embedded Software Engineering in Deutschland.

In diesem Jahr fand er erstmals digital statt, so dass die Teilnahme auch per Video möglich war. An fünf Tagen gab es 3 Keynotes und 96 Fachvorträge aus allen Bereichen der Embedded Softwareentwicklung.

Anton Kreuzkamp von KDAB sprach über maßgeschneidertes Code-Refactoring mit Clang-Tooling. Im Folgenden präsentieren wir noch einmal seinen Beitrag zum ESE-Tagungsband:

Gute statische Analyse kann viel Mühe und Zeit sparen. Mit maßgeschneideter statischer Codeanalyse kann der Projektcode nicht nur auf allgemeine Programmierfehler, sondern auch auf projektspezifische Konventionen und Best Practices überprüft werden. Das Clang Compiler Framework bietet hierfür die ideale Grundlage.

Die Programmiersprache C++ stemmt den Spagat zwischen maximaler Performance, wie sie unter anderem im Embedded-Bereich unerlässlich ist, auf der einen Seite und maximaler Codekorrektheit durch hohes Abstraktionsvermögen auf der anderen Seite. Der Spagat gelingt durch eine Ausrichtung auf Compilezeit-Prüfungen und Optimierbarkeit. Berechnungen, die durch Low-Level-Code effizienter durchgeführt werden könnten, sollen wo möglich nicht durch den Entwickler, sondern durch den Compiler umgeschrieben werden und Fehler bereits während des Kompilierens ausgeschlossen werden, anstatt zur Laufzeit kostbare Rechenzeit für Überprüfungen in Anspruch zu nehmen.

Clang hat in den letzten Jahren stark an Beliebtheit gewonnen und sich längst als einer der wichtigsten C und C++ Compiler etabliert. Nicht zuletzt liegt dieser Erfolg an der Architektur von Clang selbst. Clang ist nicht einfach ein weiterer Compiler, sondern ein Compiler Framework. Die wesentlichen Teile des Compilers sind als sorgfältig designte Bibliothek ausgestaltet und ermöglichen damit die vielfältige Landschaft an Analyse- und Refactoring-Werkzeugen, die bereits rund um das beim LLVM-Projekt angesiedelte Framework entstanden ist.

Das Kommandozeilentool clang-tidy bietet statische Codeanalyse und prüft unter anderem die Einhaltung von Coding-Konventionen, kann aber auch selbstständig Code refaktorieren. Das Tool clang-format kann den Coding-Stil automatisiert vereinheitlichen. Das in der Firma des Autors entstandene Tool clazy ergänzt den Compiler um eine Vielzahl von Warnungen rund um das Software-Framework Qt und warnt vor häufigen Anti-Patterns in der Verwendung desselben. Darüber hinaus existieren viele weitere nützliche Tools im Clang-Universum. Selbst integrierte Entwicklungsumgebungen wie Qt Creator oder CLion setzen auf das Clang Compiler Framework für Syntaxhighlighting, Codenavigation, Autovervollständigung und Refactoring.

Wer die Werkzeuge der Clang-Welt in ihrer Gesamtheit kennt, ist als C oder C++ Entwickler gut aufgestellt. Doch wer alles aus der Technik herausholen möchte, ist damit noch nicht am Ende. Die Bibliothek, die den meisten clangbasierten Werkzeugen zugrunde liegt, LibTooling, erlaubt mit wenig Aufwand auch das Erstellen eigener, maßgeschneiderter Codeanalyse- und Refactoring-Werkzeuge.

Ein Beispiel: Ein kleines, aber immer wiederkehrendes Puzzleteil einer Embedded-Software sei das Potenzieren von reellen Zahlen. Meistens mit statischen, natürlichen Exponenten. Selbstverständlich würde dafür die Funktion std::pow verwendet, wäre nicht in umfangreichem Profiling festgestellt worden, dass auf der Zielarchitektur std::pow(x, 4) um ein Vielfaches langsamer ist als x*x*x*x und in besonders performancekritischem Code ein Nadelöhr bildet. Der Seniorentwickler des Projekts hat daher eine Templatefunktion erstellt, verwendbar als utils::pow<4>(x) und dank Compileroptimierungen genauso flink wie die händische Variante1. Trotzdem hat sich seitdem wieder an diversen Stellen im Code die gewohnte std::pow Variante eingeschlichen, und auch mehrere hunderttausend Zeilen Code sind nicht durchgängig portiert.

Die ersten Versuche, das Refactoring zu automatisieren, bildet natürlich das Suchen und Ersetzen mit einem regulärem Ausdruck. std::pow\((.*), (\d+)\) findet schon die einfachsten Fälle. Aber wie sieht es mit den Fällen aus, in denen das “std::” weggelassen wurde oder der zweite Parameter komplizierter ist als ein Integer-Literal?

1 Hinweis: Auf vielen gängigen Platformen lässt sich die gleiche Optimierung durch die Verwendung des Compiler-Flags -ffast-math erreichen. Der Compiler ersetzt dann selbständig den std::pow-Aufruf durch entsprechende CPU-Anweisungen.

LLVM und Clang installieren

Wer Clang bzw. LLVM nicht über den Paketmanager seines Vertrauens installieren kann, bekommt das Framework über Github. Voraussetzungen für die erfolgreiche Installation sind Git, CMake, Ninja und ein bestehender C++ Compiler.

git clone https://github.com/llvm/llvm-project.git
cd llvm-project
mkdir build; cd build
cmake -G Ninja ../llvm -DLLVM_ENABLE_PROJECTS="clang;clang-tools-extra" -DLLVM_BUILD_TESTS=ON  # Enable tests; default is off.
ninja
ninja check       # Test LLVM only.
ninja clang-test  # Test Clang only.
ninja install

 

Die ersten Schritte

Als Basis für unser eigenes Clang-Tool, verwenden wir ein Codebeispiel aus der Clang-Dokumentation, hier auf das Wesentliche reduziert. [1]

#include "clang/Frontend/FrontendActions.h"
#include "clang/Tooling/CommonOptionsParser.h"
#include "clang/Tooling/Tooling.h"
#include "llvm/Support/CommandLine.h"

using namespace clang::tooling;
using namespace llvm;

int main(int argc, const char **argv) {
  CommonOptionsParser optionsParser(argc, argv,
    llvm::cl::GeneralCategory
  );
  ClangTool tool(optionsParser.getCompilations(),
                 optionsParser.getSourcePathList());

  return tool.run(
    newFrontendActionFactory<clang::SyntaxOnlyAction>().get()
  );
}

Damit haben wir bereits das erste lauffähige Programm. Mit CMake lassen sich die nötigen Build-Skripte unkompliziert erstellen. Wir müssen lediglich das Paket Clang finden und unser Programm mit den importierten Targets clang-cpp, LLVMCoreund LLVMSupport verlinken:

cmake_minimum_required(VERSION 3.11)
project(kdab-supercede-stdpow-checker)

find_package(Clang REQUIRED)

add_executable(kdab-supercede-stdpow-checker
    kdab-supercede-stdpow-checker.cpp)
target_link_libraries(kdab-supercede-stdpow-checker
    clang-cpp LLVMCore LLVMSupport)

install(TARGETS kdab-supercede-stdpow-checker
    RUNTIME DESTINATION bin)

Über unsere Entwicklungsumgebung oder die Kommandozeile können wir nun unser Programm kompilieren und gegen unseren Code laufen lassen.

Bevor wir das neu geschaffene Tool testen, empfiehlt es sich, es ins selbe Verzeichnis zu installieren, in dem auch der Clang Compiler liegt (z.B. /usr/bin). Denn clangbasierte Tools benötigen einige eingebaute Header, die sie relativ zu ihrem Installationspfad je nach Version zB. in ../lib/clang/10.0.1/include suchen. Wer beim Start des Programms Fehler erhält – im analysierten Code würden z.B. der Header wie stddef.h fehlen – ist aller Wahrscheinlichkeit nach in diese Falle getappt.

$ cd /path/to/kdab-supercede-stdpow-checker
$ mkdir build; cd build
$ cmake -G Ninja -DCMAKE_INSTALL_PREFIX=/usr ..
$ ninja
$ sudo ninja install
$ cd /path/to/our/embedded/project
$ kdab-supercede-stdpow-checker -p build/dir fileToCheck.cpp

Bisher prüft unser Tool die Syntax der C++ Datei und wirft Fehler aus, wenn z.B. nicht existente Funktionen aufgerufen werden. Als nächstes wollen wir nun diejenigen Codestellen finden, die unser Problem verursachen.

Relevante Codestellen finden mit AST-Matchern

Der AST, der Abstract Syntax Tree, ist eine Datenstruktur bestehend aus einer Vielzahl von Klassen mit Verlinkungen untereinander, die die Struktur des zu analysierenden Codes repräsentiert. Zum Beispiel verlinkt ein IfStmt auf ein Expr-Objekt, das die Bedingung eines if-Statements repräsentiert sowie jeweils ein Stmt-Objekt, das den “then” bzw. den “else”-Zweig repräsentiert.

Einen AST-Matcher kann man sich wie einen regulären Ausdruck auf dem AST vorstellen — eine Datenstruktur, die ein bestimmtes Muster im AST repräsentiert und findet. AST-Matcher werden für Clangs LibTooling in einer speziellen Syntax programmiert. Für jede Art von Sprachkonstrukt bzw. Knoten im AST gibt es eine Funktion, die einen Matcher des entsprechenden Typs zurückgibt. Diese Funktionen nehmen als Parameter wiederum andere Matcher, die zusätzliche Bedingungen an den Code stellen. Mehrere Parameter werden als UND-Verknüpfung behandelt. Der folgende Codeschnipsel erzeugt beispielsweise einen Matcher, der auf Funktionsdeklarationen passt, die “draw” heißen und als Rückgabetyp void haben.

auto drawMatcher
    = functionDecl(hasName("draw"), returns(voidType()));

Dieser passt beispielsweise auf die folgenden beiden Deklarationen:

void draw();

void MyWidget::draw(WidgetPainter *p) {
    p->drawRectangle(…);
}

Um später auf die einzelnen Teile des interessanten Codesegments zugreifen zu können, können mit einer bind-Anweisung den Submatchern Namen zugewiesen werden, über die dann der auf den Matcher passende AST-Knoten referenziert werden kann. Wollen wir bspw. Funktionsaufrufe finden, deren zweites Argument ein IntegerLiteral ist und wollen später auf dieses zugreifen, können wir das mit dem folgenden Matcher vorbereiten:

auto m =
  callExpr(hasArgument(1, integerLiteral().bind("secondArg")));

Eine vollständige Liste aller verfügbaren Matcher findet man unter [2].

Um das erstellen von AST-Matchern zu beschleunigen, bringt Clang das Kommandozeilentool clang-query mit, über das Matcher interaktiv getestet werden können und der gefundene AST-Ausschnitt inspiziert werden kann. Mit dem Befehl enable output detailed-ast wird die Ausgabe des vom AST-Matcher gefundenen AST-Ausschnittes aktiviert, mit dem Befehl match wird ein AST-Matcher erstellt und gestartet. Die in clang-query verwendete Syntax gleicht der C++ Syntax.

$ clang-query -p build main.cpp
clang-query> enable output detailed-ast
clang-query> match  callExpr(callee(functionDecl(hasName("pow"), isInStdNamespace())), hasArgument(1, expr()))

Match #1:

/path/to/ClangToolingTestApp/main.cpp:27:14: note: "root" binds here
    auto x = std::pow(pi, getExp(pi));
                             ^~~~~~~~~~~~~~~~~~~~~~~~ 
Binding for "root":                                                                                                                                                                          
CallExpr
|-ImplicitCastExpr <FunctionToPointerDecay> 
| `-DeclRefExpr Function 'pow'  (FunctionTemplate 'pow')                                                                                                      
|-ImplicitCastExpr 'double' <LValueToRValue>
| `-DeclRefExpr 'const double' lvalue Var 'pi'
`-CallExpr 'int' 
 |-ImplicitCastExpr <FunctionToPointerDecay> 
 | `-DeclRefExpr 'int (double)' lvalue Function 'getExp'
 `-ImplicitCastExpr 'double' <LValueToRValue> 
   `-DeclRefExpr 'const double' lvalue Var 'pi'


1 match.
clang-query> quit

Der Matcher kann so interaktiv Stück für Stück verfeinert werden. Für unser Ziel, Aufrufe an std::pow zu finden, die durch einen Aufruf an die templatisierte Funktion utils::pow ersetzt werden können, ist der folgende Matcher zielführend:

callExpr(
    callee(
        functionDecl(hasName("pow"), isInStdNamespace())
          .bind("callee")
    ),
    hasArgument(0, expr().bind("base")),
    hasArgument(1, expr().bind("exponent"))
).bind("funcCall");

Dieser Matcher findet Funktionsaufrufe an std::pow (Name der aufgerufenen Funktion ist “pow” und die Funktion ist im Namensraum std definiert), wenn sie ein zweites Argument (Index 1) hat, das ein beliebiger Ausdruck ist. Diesen Ausdruck betiteln wir als “exponent”, die aufgerufene Funktion als “callee” und den Funktionsaufruf selbst als “funcCall”.

Analyse, Diagnose und automatische Codekorrektur

Um mit den gefundenen Codestellen jetzt etwas anfangen zu können, muss zu dem Matcher noch ein MatchCallback registriert werden. Das Callback ist eine von uns zu implementierende Klasse, die von MatchFinder::MatchCallback ableitet und die Methode run(const MatchFinder::MatchResult &Result) implementiert. Darin findet dann unsere Analyse der gefundenen Codeschnipsel statt. Außerdem definieren wir eine SupercedeStdPowAction Klasse, die (um später unsere Codekorrekturen anwenden zu können) von der Klasse FixitAction ableitet und sowohl unser MatchCallback als auch einen MatchFinder beinhaltet, über den wir das Durchsuchen des AST initiieren können. Schließlich ersetzen wir in der main-Funktion die clang::SyntaxOnlyAction durch unsere SupercedeStdPowAction.

class StdPowChecker : public MatchFinder::MatchCallback {
public :
    StdPowChecker() = default;

    void run(const MatchFinder::MatchResult &result) override {}
};

class SupercedeStdPowAction : public FixItAction {
public:
    SupercedeStdPowAction() {
        m_finder.addMatcher(stdPowMatcher, &m_stdPowChecker);
    }

    std::unique_ptr<ASTConsumer>
    CreateASTConsumer(CompilerInstance &, StringRef) override {
        return m_finder.newASTConsumer();
    }

public:
    MatchFinder m_finder;
    StdPowChecker m_stdPowChecker;
};

int main(int argc, const char **argv) {
  // [...]

  return tool.run(
      newFrontendActionFactory<SupercedeStdPowAction>().get()
  );
}

Die Funktion StdPowChecker::run füllen wir nun mit unserem eigentlichen Prüfcode. Zunächst können wir anhand der den Submatchern zugewiesenen Namen die AST-Knoten als Pointer erhalten:

const CallExpr *callExpr
    = result.Nodes.getNodeAs<CallExpr>("funcCall");
const FunctionDecl *callee
    = result.Nodes.getNodeAs<FunctionDecl>("callee");
const Expr *base = result.Nodes.getNodeAs<Expr>("base");
const Expr *exponent = result.Nodes.getNodeAs<Expr>("exponent");

Die so gewonnenen Objekte liefern umfangreiche Informationen über die Entitäten, die sie repräsentieren, z.B. Anzahl, Namen und Typen der Funktionsparameter, den Typ und die Value-category (LValue-/RValue) des Ausdrucks, den Wert eines Integer-Literals. Aber nicht nur der Wert eines Literals, auch der Wert beliebiger Ausdrücke kann abgefragt werden, wenn er zur Compilezeit bekannt ist. In unserem Fall interessiert uns, ob das zweite Argument auch in einem Template-Parameter stehen könnte — dafür muss der Ausdruck constexpr sein. exponent->isCXX11ConstantExpr(*result.Context) liefert uns die Antwort. Wenn die Antwort true ist, wissen wir, dass utils::pow anwendbar und die performantere Alternative ist.

Um eine Warnung auszugeben, wie man sie von Compilerwarnungen kennt, verwenden wir die sog. DiagnosticsEngine, auf die wir über den AST-Kontext zugreifen können:

auto &diagEngine = result->Context->getDiagnostics();
unsigned ID = diagEngine.getDiagnosticIDs()->getCustomDiagID(
    DiagnosticIDs::Warning,
    "std::pow is called with integer constant expression. "
    "Use utils::pow instead.");
diagEngine.Report(exponent->getBeginLoc(), ID);

Wollen wir nicht nur warnen, sondern direkt den Code verbessern, können wir dem Report einen sogenannten FixitHint hinzufügen. In unserem Fall müssen wir die Argumente des Funktionsaufrufs umsortieren, dazu brauchen wir den Code der Argumente als String. Das lässt sich mit dem folgenden Code erreichen:

auto &sm = result->Context->getSourceManager();
auto &lo = result->Context->getLangOpts();
auto baseRng
    = Lexer::getAsCharRange(base->getSourceRange(), sm, lo);
auto expRng
    = Lexer::getAsCharRange(exponent->getSourceRange(), sm, lo);
auto callRng
    = Lexer::getAsCharRange(callExpr->getSourceRange(), sm, lo);

auto baseStr = Lexer::getSourceText(baseRng, sm, lo);
auto expStr = Lexer::getSourceText(callRng, sm, lo);

Daraus können wir ein FixitHint basteln, indem wir das Zeichenbereich des Funktionsaufrufs als Input nehmen und mithilfe des Argument-Codes den neuen Code zusammensetzen. Den so erstellten FixitHint können wir über den Stream-Operator an das Diagnose-Objekt übergeben, das der DiagEngine.Report() -Aufruf zuvor erstellt hat. llvm::Twine hilft beim effizienten Zusammenbau von Strings.

diagEngine.Report(exponent->getBeginLoc(), ID)
  << FixItHint::CreateReplacement(callRng,
     (llvm::Twine("utils::pow<") + expStr + ">(" + baseStr + ")"
     ).str());

 

Der Praxistest

Nachdem wir alle Teile zusammengesetzt und den Code kompiliert haben, wollen wir unser Ergebnis auch an Code testen. Um es Clang nicht zu leicht zu machen, übergeben wir an std::pow einmal ein Makro und einmal einen Aufruf an eine constexpr Funktion, die sich jeweils auf eine ganzzahlige Konstante reduzieren lassen. Außerdem geben wir dem Standard-Namensraum einen Alias und rufen std::pow darüber auf.

#include <iostream>
#include <cmath>
#include "utils.h"

#define DIM 2
constexpr int getExp(double x) {
    return static_cast<int>(x);
}

namespace StdLib = std;
using namespace std;

int main() {
  constexpr double pi = 3.141596;

  std::cout << "(2Pi)^2 = " << std::pow(2*pi, DIM) << endl;
  std::cout << "Pi^3 = " << StdLib::pow(pi, getExp(pi)) << endl;
}

Nutzt unsere zu analysierende Software auch CMake als Buildsystem, dann können wir dieses mit dem Parameter -DCMAKE_EXPORT_COMPILE_COMMANDS=ON dazu bringen, eine sogenannte Compilation-Database zu erstellen, die unser Clang-Tool verwenden kann, um die nötigen Include-Pfade und Compiler-Flags zu erhalten. Diese Datenbank übergeben wir unserem Tool, indem wir das Build-Verzeichnis, in dem wir zuvor CMake ausgeführt haben, als Parameter übergeben. Ist das nicht verfügbar, können wir per Hand die Compiler-Parameter an das Tool übergeben, indem wir hinter die zu analysierenden Quelldateien doppelte Bindestriche gefolgt von den Compiler-Parametern anhängen.

$ cd /path/to/ClangToolingTestApp/build
$ cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
$ cd ..
$ kdab-supercede-stdpow-checker -p build main.cpp -- -std=c++17

main.cpp:16:49: warning: std::pow is called with integer constant expression. Use utils::pow instead. 
   std::cout << "(2Pi)^2 = " << std::pow(2*pi, DIM) << endl;
                                ~~~~~~~~~~~~~~~^~~~ 
                                utils::pow<DIM>(2*pi) 
main.cpp:16:49: note: FIX-IT applied suggested code changes

main.cpp:17:47: warning: std::pow is called with integer constant expression. Use utils::pow instead. 
   std::cout << "Pi^3 =" << StdLib::pow(pi, getExp(pi)) << endl;
                            ~~~~~~~~~~~~~~~~^~~~~~~~~~~ 
                            utils::pow<getExp(pi)>(pi) 
main.cpp:17:47: note: FIX-IT applied suggested code changes 
2 warnings generated.

 

Fazit

Setzen wir alle Teile zusammen, haben mit nur knapp 100 Zeilen Code ein auf unsere projektspezifischen Erfordernisse maßgeschneidertes Refactoringtool geschaffen. Nicht nur ist das Werkzeug im Gegensatz zu einem rein textbasierten Refactoring in der Lage, Makros, Aliases und constexpr-Ausdrücke zu interpretieren. Mit Clangs LibTooling als Grundlage steht uns die ganze Welt der statischen Codeanalyse und volles Codeverständnis zur Verfügung. Über den ASTContext verfügen wir Symboltabellen und mit einem einzigen Aufruf an die Funktion CFG::buildCFG, können wir aus dem AST einen Control-Flow-Graphen generieren. Die Preprocessor-Klasse erlaubt uns Makroexpansionen und Includes zu inspizieren und in die andere Richtung gibt uns clang::EmitLLVMOnlyAction Zugriff auf die LLVM Intermediate Representation — eine sprach- und maschinenunabhängige Abstraktion des erzeugten Maschinencodes.

Um eine Übersicht über die Möglichkeiten der Clang-internen Bibliotheken zu bekommen, empfiehlt sich das “Internals Manual” der Clang Dokumentation [3]. Den zusammengesetzten Code des in diesem Artikel erstellten Refactoring-Tools finden Sie unter [4].

Literaturverzeichnis

  1. https://clang.llvm.org/docs/LibTooling.html
  2. https://clang.llvm.org/docs/LibASTMatchersReference.html
  3. http://clang.llvm.org/docs/InternalsManual.html
  4. https://github.com/akreuzkamp/kdab-supercede-stdpow-checker

 

Autor

Anton Kreuzkamp ist Softwareentwickler bei KDAB, entwickelt dort unter anderem Tooling zur Analyse von C++ und Qt basierter Software und ist als Trainer und technischer Berater tätig. KDAB ist eines der führenden Software-Beratungsunternehmen für Architektur, Entwicklung und Design von Qt, C++ und OpenGL-Anwendungen auf Desktop-, Embedded- und mobilen Plattformen. Außerdem ist KDAB einer der größten unabhängigen Kontributoren zu Qt. Die Tools von KDAB und die umfangreiche Erfahrung in der Erstellung, Fehlersuche, Profilerstellung und Portierung komplexer Anwendungen helfen Entwicklern weltweit, erfolgreiche Projekte zu realisieren.

Brauchen Sie Unterstützung?

Falls Sie ein ähnliches Problem in Ihrem Software-Projekt lösen wollen:

Contact us

FacebookTwitterLinkedInEmail

Categories: C++ / Embedded / KDAB Blogs / Tooling

Tags: / /
Leave a Reply

Your email address will not be published. Required fields are marked *