👨‍🎓

C言語プロジェクトのツール群を整えてみた

に公開

はじめに

こんにちは!KGモーターズ株式会社でエンジニアをしている中村です。

KGモーターズは、広島を拠点に1人乗り小型 EV mibot を開発しているスタートアップです。

KGモーターズではまだまだエンジニアチームの立ち上げ段階のため、現状一人がカバーする範囲が結構広くなっており、C言語に馴染みのない私のもとへコードレビューが飛んできたりします。

とはいえ、プロダクトのクオリティを担保するために、C言語開発で使用するツール群を一通り揃えるとどうなるのか?実際に試しつつレビューした内容をまとめました。

前提

開発ツールとは

ここでいう開発環境とは

  • エディター
  • コード管理
  • フォーマッター
  • ドキュメンテーション
  • Unit Test

など、エンジニアがプロジェクトに参画するとだいたい揃っているこの辺のツールについてです。

対象読者

🙆: 対象

  • C言語に馴染みがなく、これからという方
  • 遊びでいじってはいるが、C言語のチーム開発はどうするのか知りたい方

🙅: 対象ではない

  • 業務でバリバリC言語を書いており、チーム開発もしている方

開発ツール

開発環境

エディター

おそらくC言語ガチ勢からするとツッコミどこはあるかもしれませんが、ここではVSCodeにします(させてください)。
想定読者があまりC言語に馴染みのない方なので、最も使われているエディターという観点でVSCodeにしました。

デバッグについて

デバッグ方法はエディター依存になることが多いので簡単に触れます。

まずは以下のExtensionをインストールしてください。
https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools

サンプルプログラムを用意して

hello_world.c
#include <stdio.h>

int main(void) {
  char message[] = "Hello World";

  printf("%s\n", message);

  return 0;
}

実行したいファイルの右上にデバッグマークが出現しますので、ブレークポイントを設定した上でこれを押すとデバッグができます。

ちなみに実行すると.vscode/tasks.jsonにタスク定義ファイルができます。
ここで実行時のオプションが指定できます。
例えば実行ファイルに#include XXX.hがあり、ヘッダーファイルを参照する必要があるケースでは実行時に指定してあげる必要があります。例えば以下のような簡単な場合は...

.
├── main.c
├── hello.c
└── hello.h
main.c
#include "hello.h"

int main(void) {
  print_message();
  return 0;
}
hello.c
#include "hello.h"

#include <stdio.h>

void print_message(void) {
  char message[] = "Hello World";
  printf("%s\n", message);
}
hello.h
#ifndef HELLO_H 
#define HELLO_H

void print_message(void);

#endif

このような場合では、tasks.jsonのargs内で、以下を記載してあげる必要があります。

"args": [
...
    "${workspaceFolder}/main.c",
    "${workspaceFolder}/hello.c"
...
],

実行時のコマンドと同じのため、ヘッダーファイルやライブラリを参照するケースでは以下のように書く必要があります。

"args": [
...
    // ヘッダーファイルを指定する場合
    "-I${workspaceFolder}/<path to header dir>",
    // ライブラリを指定する場合
    "-L${workspaceFolder}/<path to library dir>",
    // ライブラリを指定する場合(アーカイブファイルのリンク用)
    `-l<ライブラリ名}>
...
],

コード管理

これはGithubです。つまらなくてすみません。

リンター

コーディング規則を定めておくことはどのプロジェクトでも重要です。
ここでは、clang-tidyというC/C++のコーディングチェックツールを使います。VSCodeにExtensionがあります。

https://marketplace.visualstudio.com/items?itemName=CS128.cs128-clang-tidy

ですが、LLVMのClangdを入れておくと、clang-tidyも入ってくるそうです!便利!
https://marketplace.visualstudio.com/items?itemName=llvm-vs-code-extensions.vscode-clangd

環境構築

⚠️ 私自身はMacOS環境で構築しています。
clang-tidy自体はbrewには用意されていないので、clang-tidyが入っているllvmをインストールしてパスを通すのが正攻法?ぽいです。以下を参考にしました。

https://stackoverflow.com/questions/53111082/how-to-install-clang-tidy-on-macos

brew install llvm

インストールが終わるとHomebrewが注意書きを出してくれます。

If you need to have llvm first in your PATH, run:
  echo 'export PATH="/opt/homebrew/opt/llvm/bin:$PATH"' >> ~/.zshrc

無事完了です!

$ clang-tidy --version
Homebrew LLVM version 21.1.1

設定ファイルを作る

2つのファイルを用意する必要があります。

  • compile_commands.json
    • 「どうコンパイルするか」を記録したファイル
  • .clang-tidy
    • 「何をチェックするか」ルールを定義するファイル

以下で詳細を記載します。

compile_commands.jsonを作る

clang-tidyではCのコードがどのようにコンパイルされるかを知る術がないのでcompile_commands.jsonに記述しておく必要があります。

つまりコンパイル設定が分からないと、まともにリンターが効かないのですが、必ず一回コンパイルしないといけないわけではなく、clang-tidyにコンパイル条件を教えてあげれば良いということです。
これをcompile_commands.jsonで書くというわけです。

記述するkeyとvalueは

  • directory: そのファイルをビルドしたディレクトリ
  • command: 実際のコンパイルコマンド
    • argumentsとして書くことも可能
  • file: ソースファイルの絶対パス

一応こちらを参考にしました。
https://qiita.com/syoyo/items/0e75410c44ed73d4bdd7#準備する

上記では自分でJSONを書いていますが、ビルドシステムから自動生成することもできます。おそらくプログラムの規模が大きくなってきたら自動生成の方が良いかと思います。

Makefile を使っている場合は Bearというツールを使うのが定番みたいです。
通常の make の代わりに bear -- make を実行するだけで、ビルド時のコンパイルコマンドを収集して compile_commands.json を出力してくれます

brew install bear
bear -- make

.clang-tidyを作る

clang-tidyのチェック項目1つ1つに名前がついており、項目ごとにON/OFFを設定する事ができます。
設定項目は.clang-tidyに記述します。配置場所はプロジェクトルートでも良いですし、フォルダ毎に用意するのも良いです。
また、Githubでバージョン管理して、チーム内で共有するのが良いと思います。

実際に何を書くかですが、こちらにまとまっていましたので、参考にしております。よく使いそうなものをピックアップします。

項目 意味
Checks '-*' いったん全てのチェックを無効化する
clang-diagnostic-* コンパイラ相当の基本診断(未使用変数、未初期化、未定義参照など)
clang-analyzer-* 静的解析(メモリリーク、ヌルポインタ、未初期化利用などのランタイム系バグ)
bugprone-* 典型的なバグパターンを検出(マクロ副作用、ループ変数、冗長条件など)
cert-err33-c CERT コーディング規約「ライブラリ関数の戻り値を必ずチェックせよ」
readability-else-after-return return の直後に不要な else があったら警告
CheckOptions bugprone-unused-return-value.CheckedFunctions 「戻り値を無視したら警告する関数」を指定
value: f;open;fopen;strtol f()(自作関数), open, fopen, strtol の戻り値を無視したら警告
WarningsAsErrors '' 空なら「警告は警告のまま」。ここに '*' とか入れると全部エラー扱いになる
FormatStyle none clang-format を連動させる場合のスタイル指定。none は無効化(別で設定する)

実際に書くとこんな感じになります。

.clang-tidy
Checks: '-*,
    clang-diagnostic-*,
    clang-analyzer-*,
    bugprone-*,
    cert-err33-c,
    readability-else-after-return,
'

CheckOptions:
  - key: bugprone-unused-return-value.CheckedFunctions
    value: f;open;fopen;strtol

WarningsAsErrors: ''
FormatStyle: none

試してみる

こんなふざけたコードを用意します。

main.c
#include <stdio.h>
#include <string.h>

int f(void) { return 42; }

int main(void) {
  int unused;  // ← 未使用変数 (clang-diagnostic-*)
  int x;
  printf("%d\n", x);  // ← 未初期化変数の使用 (clang-analyzer-*)

  f();  // ← 戻り値を無視 (bugprone-unused-return-value / cert-err33-c)

  if (1) {
    return 0;
  } else {  // ← return後のelse (readability-else-after-return)
    return 1;
  }
}

VSCode上で下線の警告が出ております。

さらにコマンドでも指定したチェックをしてくれていることが確認できます。
⚠️ Optionは標準ライブラリをclang-tidyが見つけられるように指定しているだけです。

clang-tidy main.c -p . -- -isysroot $(xcrun --show-sdk-path) -std=c11
2204 warnings generated.
/Users/snakamura/Documents/development/vcu-bus-crypto-package/main.c:9:3: warning: the value returned by this function should not be disregarded; neglecting it may lead to errors [bugprone-unused-return-value]
    9 |   printf("%d\n", x);  // ← 未初期化変数の使用 (clang-analyzer-*)
      |   ^~~~~~~~~~~~~~~~~
/Users/snakamura/Documents/development/vcu-bus-crypto-package/main.c:9:3: warning: 2nd function call argument is an uninitialized value [clang-analyzer-core.CallAndMessage]
    9 |   printf("%d\n", x);  // ← 未初期化変数の使用 (clang-analyzer-*)
      |   ^              ~
/Users/snakamura/Documents/development/vcu-bus-crypto-package/main.c:8:3: note: 'x' declared without an initial value
    8 |   int x;
      |   ^~~~~
/Users/snakamura/Documents/development/vcu-bus-crypto-package/main.c:9:3: note: 2nd function call argument is an uninitialized value
    9 |   printf("%d\n", x);  // ← 未初期化変数の使用 (clang-analyzer-*)
      |   ^              ~
/Users/snakamura/Documents/development/vcu-bus-crypto-package/main.c:11:3: warning: the value returned by this function should not be disregarded; neglecting it may lead to errors [bugprone-unused-return-value]
   11 |   f();  // ← 戻り値を無視 (bugprone-unused-return-value / cert-err33-c)
      |   ^~~
/Users/snakamura/Documents/development/vcu-bus-crypto-package/main.c:15:5: warning: do not use 'else' after 'return' [readability-else-after-return]
   15 |   } else {  // ← return後のelse (readability-else-after-return)
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
   16 |     return 1;
      |     ~~~~~~~~~
   17 |   }
      |   ~

フォーマッター

リンターが結構重めでしたが、フォーマッターは軽いです。ここではclang-formatを使います。
例によって設定ファイルを作成します。

.clang-formatを作る

.clang-formatというYAMLファイルでどのような設定でフォーマットするかを記述します。

BasedOnStyleというオプションがあるのですが、ここに以下の値を設定することができます。

  • LLVM
  • Google
  • Microsoft
  • etc...

これを踏まえて以下のように設定します。

.clang-format
BasedOnStyle: Google

フォーマットする際は以下のコマンドになります。

$ clang-format -i {path to file.c}

ちなみにBasedOnStyleオプションの違いは以下のDocmentに書いてあります。

https://clang.llvm.org/docs/ClangFormatStyleOptions.html#basedonstyle

量がすごかったので、AIにまとめてもらったのが以下です...

スタイル インデント 波括弧スタイル カラム幅 ポインタ表記 その他の傾向
LLVM 2 スペース Attach/K&R(行末に { 80 前後 int *pRight 寄せ) LLVM コーディング規約に準拠。必要最小限の改行でコンパクト。 (clang.llvm.org)
Google 2 スペース Attach/K&R 80 int* pLeft 寄せ) テンプレ宣言は改行を優先(AlwaysBreakTemplateDeclarations: Yes など)。 (Google GitHub)
Microsoft 4 スペース Allman(次の行に {)が多い 120 以上や無制限を採る例あり int* pLeft 寄せ) 名前空間内をインデントする例が多い(NamespaceIndentation: All)。 (GitHub)

ドキュメント生成

開発する中で、仕様書とコードがずれていくのはよくあることで、コード内にルール通りのコメントを書くと仕様書を生成してくれると楽ですよね。
PythonだとDocstringに指定のフォーマットで記載するとSphinxというツールでドキュメント生成をしてくれたりします。

Cだと何が使われるのか?と調べているとDoxygenと出会ったので簡単にまとめます。

https://doxygen.nl/

環境構築

インストールします。

brew install doxygen graphviz

サンプル作成

簡単なサンプルコードを用意します。

.
└── src/
    ├── calc.c
    └── calc.h

コメントは規則に則って書いてください。

  • calc.h
calc.h
/* src/calc.h */
/**
 * @file calc.h
 * @brief 簡単な四則演算の宣言。
 */
#ifndef CALC_H
#define CALC_H

/**
 * @brief 2つの整数を加算。
 * @param a 左オペランド
 * @param b 右オペランド
 * @return a + b
 */
int add(int a, int b);

#endif
  • calc.c
calc.c
/* src/calc.c */
/**
 * @file calc.c
 * @brief 四則演算の実装。
 */
#include "calc.h"

/** @copydoc add */
int add(int a, int b) { return a + b; }

実行

以下を実行するとDoxyfileが生成されます。

doxygen -g

これは設定ファイルのようなものなので、このファイル内のパラメータを変更していきます。

INPUT                  = ./src
RECURSIVE              = YES

OUTPUT_DIRECTORY       = ./docs

# Graphvizがある場合
HAVE_DOT               = YES
CALL_GRAPH             = YES
CALLER_GRAPH           = YES

以下を実行すると.docsファイルが生成されると思います。

doxygen Doxyfile

.docs/html/index.htmlを開くと以下のようなページが見れると思います。
これがコードに即したドキュメントになるので、あるブランチにマージされた時に生成しておくなどのCIを組めば効率よく開発ができます!

Unit Test

調べてみると、C言語でUnitTestをする場合の代表的なフレームワークは

  • CUnit
  • Unity
  • Check
  • GoogleTest

があるそうです。今回は軽量で小規模プロジェクト向きなCUnitを使ってみます。

環境構築

MacなのでHomebrewでインストールします。

$ brew install cunit

サンプル作成

以下のような構成にします。
簡単にテストが実行できるようにMakefileも作成しておきます。

.
├── src/
│   ├── calc.c
│   └── calc.h
├── tests/
│   └── test_calc.c
└── Makefile

テストのため簡単なスクリプトを用意します。
ちなみにCUnitは、(Pytestとかではお馴染みの)テスト関数を自動検出してくれるような仕組みはないため、自分でテスト関数を登録してビルドする必要があります。

  • calc.h
calc.h
#ifndef CALC_H
#define CALC_H

int add(int a, int b);

#endif

  • calc.h
calc.c
#include "calc.h"

int add(int a, int b) { return a + b; }

  • tests_calc.c
    ここの細かい説明は後述します
tests_calc.c
#include "calc.h"
#include <CUnit/Basic.h>
#include <CUnit/CUnit.h>

static void test_add(void) {
  CU_ASSERT_EQUAL(add(2, 3), 5);
  CU_ASSERT_EQUAL(add(-1, 1), 0);
}

int main(void) {
  if (CU_initialize_registry() != CUE_SUCCESS)
    return CU_get_error();

  CU_pSuite suite = CU_add_suite("calc_suite", 0, 0);
  if (!suite) {
    CU_cleanup_registry();
    return CU_get_error();
  }

  if (!CU_add_test(suite, "test_add", test_add)) {
    CU_cleanup_registry();
    return CU_get_error();
  }

  CU_basic_set_mode(CU_BRM_VERBOSE); // 詳細ログ
  CU_basic_run_tests();

  int fails = CU_get_number_of_failures();
  CU_cleanup_registry();
  return fails ? 1 : 0;
}

  • Makefile
    ここも後ほど解説します。
Makefile
CC = gcc
CFLAGS = -Wall -Wextra -I./src
CUNIT_CFLAGS := $(shell pkg-config --cflags cunit)
CUNIT_LIBS   := $(shell pkg-config --libs cunit)

SRC = src/calc.c
TEST = tests/test_calc.c

all: test

test: $(SRC) $(TEST)
	$(CC) $(CFLAGS) $(CUNIT_CFLAGS) $^ -o test_runner $(CUNIT_LIBS)
	./test_runner

clean:
	rm -f test_runner

解説

Makefile

まずはMakefileから。

CFLAGS = -Wall -Wextra -I./src`**
  • コンパイル時のオプションをまとめた変数
  • -Wall : よくある警告を全部出す
  • -Wextra : さらに追加の警告も出す
  • -I./src : #include "calc.h" でヘッダーファイルを探すときに ./src フォルダも探すようにする
**CUNIT_CFLAGS := $(shell pkg-config --cflags cunit)**
**CUNIT_LIBS   := $(shell pkg-config --libs cunit)**
  • CUnitは外部ライブラリのため、ビルド時に以下の2つをコンパイラーに場所を教える必要がある
    • #include <CUnit/Basic.h>, #include <CUnit/CUnit.h>
    • ライブラリファイル(test内でCUnitのライブラリを使うために必要)
  • しかし環境によりインストール先が異なるので、ライブラリ情報を教えてくれるツールpkg-configを使う
test: $(SRC) $(TEST)
	$(CC) $(CFLAGS) $(CUNIT_CFLAGS) $^ -o test_runner $(CUNIT_LIBS)
	./test_runner
  • testターゲットの定義
  • 依存関係として以下を指定。つまり以下が変更された再度コンパイルされるようになる
    • SRC = src/calc.c
    • TEST = tests/test_calc.c
  • $^: 依存ファイル(SRC, TESTにあるファイル)を全て対象にする
    • src/calc.c tests/test_calc.cと同義
  • -o test_runner: 出力ファイル名をtest_runnerにする
  • ./test_runner: ビルドが成功したらテストを実行する

test_calc.c

さてここが本題...
流れとしては、ざっくり以下のようになります。

  1. 初期化
  2. テストスイートを作成
  3. テスト登録
  4. 実行

ちなみにテストケース=1つのテスト関数テストスイート=テストケースの集合体と定義しています。

自作関数の宣言と、CUnitとBasicランナーのヘッダを読み込みます。

#include "calc.h"
#include <CUnit/Basic.h>
#include <CUnit/CUnit.h>

テストを作成します。
自作のadd関数のテストケースを作成します。CU_ASSERT_EQUALは第1引数と第2引数が等しいかをチェックするCUnitの関数です。

static void test_add(void) {
  CU_ASSERT_EQUAL(add(2, 3), 5);
  CU_ASSERT_EQUAL(add(-1, 1), 0);
}

レジストリを初期化します。CUnitが内部で保持するテストの登録機能のようなもので、失敗したら終了し、エラーコードが返ってくるようにしています。

int main(void) {
  if (CU_initialize_registry() != CUE_SUCCESS)
    return CU_get_error();

スイートを追加します。ここではcalc_suiteというテストスイートを新規で追加しています。
CU_add_suiteの第1引数と第2引数はそれぞれテスト時のsetupとteardownで、0だと特になし=NULLになります。
このスイートの中にテストケースを後段で入れていきます。

  CU_pSuite suite = CU_add_suite("calc_suite", 0, 0);
  if (!suite) {
    CU_cleanup_registry();
    return CU_get_error();
  }

自作のテストを追加していきます。先ほど作成したsuitetest_addというテストケースを入れます。ここが面倒なのですが、Pytestなどのように自動検出機能がないため、明示的に登録する必要があります。

  if (!CU_add_test(suite, "test_add", test_add)) {
    CU_cleanup_registry();
    return CU_get_error();
  }

失敗数を取得して終了コードに反映します。これがあるとCIで便利ですね。

  int fails = CU_get_number_of_failures();
  CU_cleanup_registry();
  return fails ? 1 : 0;
}

CUnitの関数を少しまとめる

最後に上記で出てきた関数も含めてCUnitでよく使われる関数をまとめました。

https://cunit.sourceforge.net/doc/writing_tests.html

関数 説明
CU_ASSERT(expr) 条件がtrueか確認 CU_ASSERT(x > 0);
CU_ASSERT_TRUE(x)
CU_ASSERT_FALSE(x)
値がtrueかfalseか確認 CU_ASSERT_TRUE(flag);
CU_ASSERT_EQUAL(a, b)
CU_ASSERT_NOT_EQUAL(a, b)
2つの整数や列挙値が同じか(または違うか)を確認 CU_ASSERT_EQUAL(rc, 0);
CU_ASSERT_PTR_NULL(p)
CU_ASSERT_PTR_NOT_NULL(p)
ポインタがNULLか(またはNULLでないか)を確認 CU_ASSERT_PTR_NOT_NULL(buf);
CU_ASSERT_PTR_EQUAL(p1, p2)
CU_ASSERT_PTR_NOT_EQUAL(p1, p2)
2つのポインタが同じ場所を指しているかを確認 CU_ASSERT_PTR_EQUAL(dst, src);
CU_ASSERT_STRING_EQUAL(s1, s2)
CU_ASSERT_STRING_NOT_EQUAL(s1, s2)
2つの文字列が同じか(または違うか)を確認 CU_ASSERT_STRING_EQUAL(name, "KG");
CU_ASSERT_NSTRING_EQUAL(s1, s2, n)
CU_ASSERT_NSTRING_NOT_EQUAL(s1, s2, n)
先頭n文字だけを比較 CU_ASSERT_NSTRING_EQUAL(buf, "OK", 2);
CU_ASSERT_DOUBLE_EQUAL(a, b, delta)
CU_ASSERT_DOUBLE_NOT_EQUAL(a, b, delta)
小数同士がほぼ同じか(誤差delta以内)を確認 CU_ASSERT_DOUBLE_EQUAL(pi, 3.1415, 1e-3);
CU_PASS(msg) テストを明示的に成功扱いにして、メッセージを表示 CU_PASS("処理が正常に完了");
CU_FAIL(msg) テストを明示的に失敗扱いにして、メッセージを表示 CU_FAIL("まだ未実装");

実行

make test

成功したので以下のようになります。

gcc -Wall -Wextra -I./src -I/opt/homebrew/Cellar/cunit/2.1-3/include src/calc.c tests/test_calc.c -o test_runner -L/opt/homebrew/Cellar/cunit/2.1-3/lib -lcunit
./test_runner


     CUnit - A unit testing framework for C - Version 2.1-3
     http://cunit.sourceforge.net/


Suite: calc_suite
  Test: test_add ...passed

Run Summary:    Type  Total    Ran Passed Failed Inactive
              suites      1      1    n/a      0        0
               tests      1      1      1      0        0
             asserts      2      2      2      0      n/a

Elapsed time =    0.000 seconds

失敗させてみます。

-int add(int a, int b) { return a + b; }
+int add(int a, int b) { return a + b + 1; }

実行すると以下のように拾えていることがわかります。

$ make test

make test
gcc -Wall -Wextra -I./src -I/opt/homebrew/Cellar/cunit/2.1-3/include src/calc.c tests/test_calc.c -o test_runner -L/opt/homebrew/Cellar/cunit/2.1-3/lib -lcunit
./test_runner


     CUnit - A unit testing framework for C - Version 2.1-3
     http://cunit.sourceforge.net/


Suite: calc_suite
  Test: test_add ...FAILED
    1. tests/test_calc.c:6  - CU_ASSERT_EQUAL(add(2, 3),5)
    2. tests/test_calc.c:7  - CU_ASSERT_EQUAL(add(-1, 1),0)

Run Summary:    Type  Total    Ran Passed Failed Inactive
              suites      1      1    n/a      0        0
               tests      1      1      0      1        0
             asserts      2      2      0      2      n/a

Elapsed time =    0.000 seconds
make: *** [test] Error 1

さいごに

C言語の開発ツールや環境についてまとめました!
私自身Cを本格的にやり出したのは最近で、組み込みのエンジニアの方に教えてもらいつつ勉強しています!

またKGモーターズでは制御開発などの組み込み系エンジニアも募集しています!
「ちょっと興味ある」、「話をきいてみたい」、でも構いませんのでお気軽にご連絡ください!

https://kg-m.jp/recruit

KGモーターズ Tech Blog

Discussion