こんにちは。42 Tokyo Advent Calendar 2022の3日目を担当致します、42 Tokyo在校生の者です。
Overview
C言語で開発する際のデバッグ方法について纏めます。
2022/12/03(土)迄にミニマムで公開し、その後、随時加筆予定です。
TOC
Introduction
42 Tokyoでは、まずUNIX Programmingを学びます。UNIX OSは主にC言語で開発されていますので、課題の大半もC言語を使用するものです(因みにUNIX互換OSを目指して開発され、多数の派生版があるLinuxも、やはり主にC言語で開発されています)。
カリキュラムの公開に関しては42の規定で制限があるため、
ハーバード大のCS50やニューヨーク市立大学のCSc 82010でイメージを掴んで頂きたく。
C言語を学ぶ事に意義はあるのか、疑問に思う方もいるかも知れません(このページを訪れるのは、C言語に興味がある方が殆どだと思いますが)。
Web検索でプログラミングの勉強方法を調べると、プログラミングスクールや通信講座の類のランキングやまとめサイトが多くヒットします。そして、スクールで学べるものとしてはRuby on Railsが目立って多い印象です。確かに最近は、クラウド化の流れもありWeb系の需要が高く、Firebaseのようなモバイル端末との親和性も高いプラットフォームもあります。しかし、他の分野の需要が大幅に減っている訳ではありません。近年需要が急増しているAWS等のクラウドプラットフォームやデータサイエンス・機械学習でもC言語を扱う事は稀でしょう(高速化にこだわらない限り)。C言語が必須になるのは、自動運転など組み込み系に限られると思います。
組み込み系では、機能豊富なJavaなどと比較してメモリ消費が少なく実行速度が高速なC/C++が使われている事が多いようです。このC++を習得するには、まずC言語を理解出来ている必要があるのです。
【C++は難しい?】理由や学習コストについてわかりやすく解説 | 侍エンジニアブログ
因みにC#は、C++とJavaを基に作られたプログラミング言語ですが、文法的にJavaを習得した後に学習するのが良さそうです。
専門知識いらず!C#とは?言語の特徴やメリットを網羅的に徹底解説
UNIXが主にC言語を用いて開発された経緯から、C言語はコンピュータの基礎技術となるもの、即ちコンピュータサイエンス(計算機科学)の必修単元です。組み込み系やデスクトップアプリケーションに興味が無くても、個人的にはコンピュータサイエンスを一度学んでおく方が障害に強くなったり応用が利いて良いと思っています。
さて、コーディングをする上で度々遭遇する事になるのが、コンパイル時のエラーや実行時の意図しない挙動です。開発者は、デバッグによりコードにおかしな箇所が無いかを見ていきながら開発します。アプリケーションのリリースに当たっては、テストコードを書いて、あらゆる条件やデータに耐え得るプログラムになっているかを検査する事でしょう。
本記事では、C言語のプログラマーを対象としてデバッグ方法を幾つか取り上げていきます。
Environment
-
Ubuntu 20.04.5 LTS on Windows 11 (22H2) WSL2
- gcc version 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04.1)
Methods
dprintf
※本記事をお読みの方には説明の必要は無いでしょうが、流れとして書いておきます。
プログラミング言語の初歩のチュートリアルとして必ずと言って良いほど載っている "Hello World!" を表示するだけのプログラムですが、C言語で文字を画面に表示する(標準出力に出力する)関数がprintf
です。
Linux上でman 3 printf
を実行すると、printf関数グループのプロトタイプ宣言が確認出来ます。
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int dprintf(int fd, const char *format, ...);
dprintf
は、printf
の出力先として任意のファイル記述子 ("File Descripter", shortly "fildes", "FD") を指定して使うものです。
プログラムの正常動作として文字を画面(ターミナル)上に表示させるプログラムも多いため、それと分離出来た方がデバッグがやり易いように感じます。正常動作としての画面出力には大抵の場合は標準出力 (fd = 1
) を使用するため、デバッグ情報として表示させるのは標準エラー出力 (fd = 2
) を使用すると良いでしょう。
デバッグのためのdprintf
をON/OFF切り替えられると更に便利です。筆者はmake
ターゲットでデバッグのON/OFFを切り替える方法を採り入れています。
コード例を以下に示します。通常のmake all
(make clean all
) でデバッグプリント無し、make debug
でデバッグプリント有りとなります。
#include "debug_common.h"
int debug_dummy(const char *format, ...)
{
va_list ap;
int ret;
return (0);
va_start(ap, format);
ret = dprintf(FD_DEBUG, format, ap);
va_end(ap);
return (ret);
}
int debug_dprintf(const char *format, ...)
{
va_list ap[2];
ssize_t ret;
if (DEBUG_MODE == 0)
return (0);
errno = 0;
va_start(ap[0], format);
va_copy(ap[1], ap[0]);
ret = my_dprintf_sub(FD_DEBUG, format, ap);
va_end(ap[1]);
va_end(ap[0]);
if (ret > INT_MAX || errno != 0)
return (ERR_PRF);
return (ret);
}
#ifndef DEBUG_COMMON_H
# define DEBUG_COMMON_H
# include <stdio.h>
# include <stdarg.h>
# include <errno.h>
# include "my_printf.h"
# define FD_DEBUG 2
# ifndef DEBUG_MODE
# define DEBUG_MODE 0
# endif
# if DEBUG_MODE == 0
# define debug_printf(...) debug_dummy(__VA_ARGS__)
# else
# define debug_printf(...) dprintf(FD_DEBUG, __VA_ARGS__)
# endif
# ifndef ERR_PRF
# define ERR_PRF -1
# endif
int debug_dummy(const char *format, ...);
#endif
# ... Something above
SRCS += debug_common.c
# Redefination when the specific target
ifeq ($(MAKECMDGOALS), debug)
DEF = -D DEBUG_MODE=1
endif
# Phonies
.PHONY: all clean debug
# Regular targets
all: $(NAME)
clean:
-$(RM) $(RMFLAGS) $(OBJDIR)
# Additional targets
debug: clean all
# Something below ...
# ......
# ... Something above
$(OBJDIR)/%.o: $(SRCDIR)/%.c | $(OBJDIR)
$(CC) $(CFLAGS) $(DEF) $(INCLUDES) -o $@ -c $<
まずMakefile
に着目すると、make debug
時はDEBUG_MODE = 1
のマクロ定数が定義された状態でコンパイルされます。debug_common.h
に着目すると、debug_printf()
という名前の関数はエイリアスとしてdprintf()
に置き換えられます。
対して、通常のmake all
時は、debug_printf()
の箇所はdebug_dummy()
が呼ばれる事になり、debug_common.c
のdebug_dummy()
関数内で早期リターンされます。
自作dprintfがある場合は、debug_dprintf
のように、マクロ定数DEBUG_MODE
を事前に定義しない場合にDEBUG_MODE = 0
を定義し、DEBUG_MODE == 0
の場合に早期リターンされるようにすると、関数エイリアスの手法が不要になるでしょう。my_dprintf_sub()
は、自作dprintfの一部分であり my_printf.h
によりインクルードされます。
printf
関数のフォーマットを毎回記述するのが面倒だという場合には、下記の関数定義を利用する事で、手軽にデバッグが行えるようになります。
# ... Something above
# include <stdio.h>
# define DEBUGV(v_fmt, v) \
printf(#v ": " v_fmt "\t(file \"%s\", line %d, in %s)\n", \
v, __FILE__, __LINE__, __FUNCTION__);
# define DC(v) DEBUGV("%c", v);
# define DS(v) DEBUGV("%s", v);
# define DI(v) DEBUGV("%d", v);
# define DF(v) DEBUGV("%f", v);
# define DD(v) DEBUGV("%lf", v);
# define DX(v) DEBUGV("%x", v);
# define DL(v) DEBUGV("%ld", v);
# define DEBUGF(fmt, ...) \
printf(fmt "\t(file \"%s\", line %d, in %s)\n", \
__VA_ARGS__, __FILE__, __LINE__, __FUNCTION__);
# Something below ...
ソースファイル中、変数の値を確認したい箇所で
DI(count);
変数名、値、ファイル名、行番号、関数名が出力される
count: 42 (file "hoge.c", line 17, in main)
printf
関数を使用するメリット (Pros.) / デメリット (Cons.) を考えますと、
Pros.
- デバッグ用に別のツールを用意する必要が無い
- デバッガ用のフラグを使用すると動作に影響がある場合にも使える
- 初学者にも解り易い
Cons.
- 変数の値とアドレス以外(メモリリークなど)は判らない
- 開発中のプログラムのコードにdprintfの行を仕込む必要がある
- リリース時にはそれらを除去する手間が掛かる
このように一長一短があるため、以下に挙げるデバッガを使い熟す必要が出てきます。
leaks
JavaやPythonのようなガベージコレクションが元々備わっているプログラミング言語とは異なり、C言語ではメモリの動的割り当てとその解放をコードに明示的に入れ込む必要があります。メモリ割り当てmalloc()
を行ったオブジェクトがある状態で、そのアドレスを示している変数の値を書き換えた場合、スコープの関係で変数が失われた場合、そのオブジェクトはfree()
出来ない事になり、このような状態はmemory leakと呼ばれています。
確保されるメモリサイズに比べて利用可能なメモリ領域が潤沢にある場合や、実行時間が短いプログラムでプログラム終了後にOSがメモリを解放する場合はメモリリークはそれほど問題にはならないでしょう。逆に解放されないメモリ領域がどんどん増加すると、他のプログラムが正常に動作しなくなり、終いにはシステム全体が不安定になる原因となります。
leaks
はmacOSで利用可能なメモリリークを検出してくれるプログラムです。Linux用に移植された話は聞かないため、Linuxで開発する場合には、他のツールが必要になります。
自動的に実行されるようにするには、デストラクタ属性を定義した下記のようなファイルを用意すると良いでしょう。
#include "debug_macos.h"
__attribute__((destructor))
void destructor(void)
{
system("leaks -q a.out 1>&2");
}
#ifndef DEBUG_MACOS_H
# define DEBUG_MACOS_H
# include <stdlib.h>
void destructor(void);
#endif
// ... Something above
# ifdef __MACH__
# include "debug_macos.h"
# endif
// Something below ...
# ... Something above
# Check the platform
OS = $(shell uname)
# Sources for debugging
SRCS_DEBUG_MAC = ./src/debug_macos.c
# Flags for GCC debugger
# Redefination when the specific target
ifeq ($(MAKECMDGOALS), debug)
ifeq ($(OS), Darwin)
SRCS += $(SRCS_DEBUG_MAC)
endif
endif
# Something below ...
実行時にリークが発生した場合には、標準エラー出力に下記のように出力されます。
leaks Report Version: 4.0
Process 10955: 147 nodes malloced for 13 KB
Process 10955: 0 leaks for 0 total leaked bytes.
なお、対話形式のプログラムなど、起動してから直ぐに終了する訳ではなく、ユーザー操作などにより段階的に処理が走るプログラムの場合は、実行時に随時メモリリークの検知を行うようにすると便利でしょう。別ターミナルを開いて、下記のように実行すれば、指定したプログラムのメモリリーク状況が1秒毎に表示されます。
while (true) do sleep 1; leaks -q a.out; done
ターミナル画面を一つしか開けない環境の場合は、標準エラー出力を/dev/null
へリダイレクトかつバックグラウンド実行させたり、ファイルへ出力すると良いでしょう。
while (true) do sleep 1; leaks -q a.out 2>/dev/null; done &
GCC
C言語のコンパイラとしては、Linux OSにおいて標準のgcc
("GNU Compiler Collection", formerly "GNU C Compiler") とmacOSやFreeBSDにおいて標準のClang
が挙げられます。
どちらのコンパイラにも、サニタイザーと呼ばれる、バグの元を取り除く作業を支援する機能が盛り込まれており、コンパイル・リンク時にフラグとも呼ばれるコマンドラインオプションを付加して使用します。
フラグの
# ... Something above
# Check the platform
OS = $(shell uname)
# Flags for GCC debugger
DEBUGCFLAGS = -g -ggdb -fstack-usage -fno-omit-frame-pointer
DEBUGLDFLAGS = -fsanitize=address
# Redefination when the specific target
ifeq ($(MAKECMDGOALS), debug)
ifneq ($(OS), Darwin)
LDFLAGS += $(DEBUGLDFLAGS)
CFLAGS += $(DEBUGCFLAGS)
endif
endif
# Something below ...
-
-g
オプションは、バイナリにデバッグに必要な情報を含めます。 -
-ggdb
オプションは、後述のGDBを使用するためのフラグです。 -
-fstack-usage
オプションは、各関数が利用する静的領域のサイズをファイルに出力します。 -
-fno-omit-frame-pointer
オプションは、フレームポインター情報の省略を無効化します。 - -fsanitize=address`オプションがサニタイザーを有効にするためのもので、リンク時に付加するフラグです。
実行時にリークが発生した場合には、標準エラー出力に下記のように出力されます。
=================================================================
==15474==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 3200 byte(s) in 1 object(s) allocated from:
#0 0x7ff598f7e808 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cc:144
#1 0x55a658c82685 in set_var src/set_var.c:21
#2 0x55a658c8235f in main src/main.c:23
#3 0x7ff598c80082 in __libc_start_main ../csu/libc-start.c:308
Indirect leak of 7960 byte(s) in 1 object(s) allocated from:
#0 0x7ff598f7e808 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cc:144
#1 0x55a658c82570 in mutex_init src/mutex_init.c:21
#2 0x55a658c826ab in set_var src/set_var.c:24
#3 0x55a658c8235f in main src/main.c:23
#4 0x7ff598c80082 in __libc_start_main ../csu/libc-start.c:308
SUMMARY: AddressSanitizer: 11160 byte(s) leaked in 2 allocation(s).
実行時にSegmentation Faultが発生した場合には、標準エラー出力に下記のように出力されます。
AddressSanitizer:DEADLYSIGNAL
=================================================================
==16155==ERROR: AddressSanitizer: SEGV on unknown address 0x61f000027188 (pc 0x55d54301e1f3 bp 0x7fff332afcc0 sp 0x7fff332afca0 T0)
==16155==The signal is caused by a READ memory access.
#0 0x55d54301e1f2 in seek_arr src/seek_arr.c:21
#1 0x55d54301d3e0 in main src/main.c:29
#2 0x7fe4e4f53082 in __libc_start_main ../csu/libc-start.c:308
#3 0x55d54301d24d in _start (/home/Bob/test+0x124d)
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV src/seek_arr.c:21 in soph_destroy
==16155==ABORTING
使用可能なオプションの一覧や詳しい情報については、GCCのドキュメント及びClangのドキュメントをご覧頂きたく。
GDB
GDBとは "The GNU Project Debugger" とあるように、GCCと同じくGNUプロジェクトの一部として開発されている、Linux OSで標準的なデバッガです。
macOSでは、Homebrewからインストール可能なものの、動かすのは難しいらしく、後述のLLDBを使用する方が良さそうです。
公式のユーザーマニュアルもありますが、基本的な使用方法はGDB入門 - とほほのWWW入門をご覧頂くのが解り易いかと思います。
簡単に説明しますと、
- デバッグしたいプログラムを
gcc
で-g -ggdb
フラグを付加してコンパイルします。 - デバッグ用にコンパイルしたプログラムのパスを引数にとって、
gdb
を実行します。 - 対話モードでGDBが起動します。
- コマンドライン引数を設定する場合は、
arg set
に続けて入力します。 -
run
を実行すると、プログラムが起動し、デバッグ情報が出力されます。 -
quit
でGDBを終了します。
LLDB
LLVMプロジェクトの一部として開発されているデバッガです。macOSのCコンパイラであるClangはコンパイラのフロントエンド機能を提供しており、そのバックエンドに利用しているのがLLVMのようです。このように、macOSではLLDBが標準的なデバッガとなります。
GDB to LLDB command map — The LLDB Debuggerを見てわかる通り、GDBで出来る事は殆どLLDBでも出来る感じですが、LLDBでは更にGUIモードも備えており、視覚的に操作し易くなっています。
Valgrind
メモリのデバッグによく利用されるツールです。KCachegrindと呼ばれるビジュアライザや、スレッドエラーの検出が可能なHelgrindが同梱されています。
IDE
ここまではCLI上で利用可能なデバッガを取り上げましたが、ソフトウェア開発を行うのは開発用のWindowsパソコン又はMacBookシリーズ上で作業する場合が殆どであり、GUI環境であればGUIなデスクトップアプリケーションで作業出来た方が一般的に楽だと思います。
そこで登場するのが統合開発環境 (Integrated Development Environment) です。コードエディタ、ビルダー、デバッガが一製品に統合されているのが特徴です。
主なIDEを挙げます。
- Microsoft Visual Studio
- Xcode (Apple, Inc.)
- C++ Builder (Formerly, "Borland C++ Builder")
- Visual Studio Code (VSCode, Microsoft Corporation)
VSCodeはテキストエディタであり統合開発環境という位置付けではないものの、プログラミングに特化した拡張機能が多く、ターミナル画面やデバッグ機能をVSCode上で使用する事が出来ます。例えば、拡張機能 (Extensions) のAWS Toolkit for VS Codeを使用する事でAWSに接続し、デバッグ・デプロイまで一貫して実行する事が可能です。
一方で、AmazonはAmazonで、Cloud9というWebブラウザベースの統合開発環境を提供しています。
そして、Microsoftにもご存じの通りAzureと呼ばれるクラウドサービスがあり、GitHub Codespacesという同様のサービスが提供されています。
筆者は、VSCodeは常用しているものの、恥ずかしながらVSCode上のデバッガは使用経験がありません。下記サイトを読んで今後採り入れていけたら良いなと思います。
【初心者向けに解説】Visual Studio CodeでC言語/C++のデバッグ方法
VSCodeでMacOSにC言語デバッグ環境を構築
Editorial_notes
前回の記事執筆から1年半以上も空いてしまいました。書きたい事が出て来ても中々重い腰が上がらず、今回のAdvent Calendarとなってしまいました。このような機会を与えて下さったぐーすかきつねさんに感謝申し上げます。
今回はデバッグの方法について幾つか取り上げましたが、まだ初学者の域でありprintfを手動で仕込んでばかりだったので、認識や説明が不十分な点や最良とは言えない書き方をしている箇所もあるかも知れません。今後、ブラッシュアップしていきたいと思います。
誤りのご指摘などお気付きの点があれば、コメント又は編集リクエストをお送り頂けますと幸いです。