組込みソフト開発の、主にデバッグに関連するトピックスをまとめました。
前の職場への愚痴がかなりの割合で含まれているどうしようもない記事ですが、もしよろしければお付き合いくださいませ。世の中にはこういう職場もありますよ、的なことも含まれた読み物です。
printfデバッグ
Cのソフトウェア開発のデバッグ手法として、printf文を埋め込み、そこから検証に必要な各種情報を統合開発環境のコンソールなどに吐き出し、動かしながら内部状態をモニタするのは一般的だと思うのですが、職場ではprintfデバッグは活用されていない状況でした。
理由はいくつかあると思うのですが、第一に開発対象が組込み機器の制御ソフトであること、つまりそこそこタイムクリティカルなリアルタイムシステムなのですが、その中に非常に大きなオーバーヘッドを持つ(しかもブロッキング動作となる)printf文を入れることに対する強烈な忌避感が文化として根強く息づいているように感じました。
また、標準ヘッダファイルのインクルード禁止という規約というか牽制(stdio.h も当然導入不可という建前)が存在した、とう背景もあります。
それ以前に、Cの可変数個引数を使用することは禁止という背景から、デバッグ用途であっても可変数個引数が前提となる printf() にもアレルギーがあった、ということも多少あったかもしれません。
では皆さん、どうやってデバッグをしていたかというと、デバッガの変数ウォッチ機能をフル活用して、あるいはブレークポイントをこまめにかけて地道に追いかけていました(プログラムを止めるとアウトな部分については、あれこれと工夫を凝らしてました)。
で、今時のプロセッサアーキテクチャとコンパイラの組み合わせだと、Cソースコードと実行バイナリ(機械語)の命令の順序は(最適化抑止しない限りは)コンパイラが効率の良い命令順序に並び替えるアウトオブオーダーの関係になるので当然、期待したところにブレークがかからない(ソースコード上は変数への値の代入がされているポイントでも、実行バイナリ上はまだ値のストアが完了していなかい)とか、かなり苦戦していた模様です。コンパイラの最適か設定については別項目で少しばかり言及します。
そもそもprintfデバッグという手法など社内教育で教わっていない皆さまは、不自由さを感じることなく、このやり方を受け入れていたのでしょうか。いくら「もっと効率の良いデバッグのやり方もあるよ?」と働きかけても「ふぅん」という感じでした。
しかしストイックな皆様とは違い怠け者の私はこの煩雑な作業に嫌気をさし、こそこそとprintfデバッグを取り入れたのですが、職場の流儀とのすり合わせには苦慮しました。
そんなあれこれをつらつらと書き連ねます。
ベアメタル開発でのprintfデバッグのやり方
さて、ではprintfデバッグを始めようとした場合、何をしなければならないでしょう。手順を軽く解説したいと思います。
この辺りはきちんとした(上級な)アプリケーションソフト開発ではSDKであったりフレームワークが担うことになるため、ユーザーアプリ開発サイドではまず意識することのない裏方の仕事ですが、ベアメタル開発ではこういったことを含め、開発者が全て面倒を見る必要があります。
これが低レベルな組込みソフト開発の面倒臭さでもあり、同時に醍醐味でもあるという想いを込め、少し冗長ですが書き連ねていきます。
先ず、コンソールやターミナルを持たないデバイスにおいて、標準出力(STDOUT)をどこに結び付けるのか(表示先をどうするか)ということを考えなければなりません。
これについてはクロス開発用PCの統合開発環境(IDE)のターミナル画面(コンソールビュー)を対象とするのが素直なやり方です。
マイコンの UART を1系統、標準出力用(あるいは標準入出力用)として割り当て、デバッガの仮想COMポート機能でPCと接続し、コンソールに表示するというものです。PC からは COMポートとして見えてますので、IDE のデバッガに限らず、TeraTermのようなターミナル通信ソフトウェアで長時間のログを収集するといったことも可能です。
マイコン・デバッガ・IDEが対応していれば、UARTを割り当てずとも、そちらを中継することもできます。Arm系のマイコンの場合は ITM というトレース用のモジュールが入っていればこれのチャネルを割り当てたり、ルネサスのRXマイコンでは(半ば隠しポートのような感じで)メモリマップドされた専用のレジスタが用意されていたりするので、これらを利用します。
ファームウェア側の処理的には下記のような流れとなります。
・ユーザーアプリがprintf()を呼び出す
→ランタイムが書式に従い文字列を生成し、その文字列をストリームとして低水準IOに送り出す(低水準IO関数がランタイムから呼ばれる)
→低水準IO関数から、受け取った文字(文字列)をUARTで送信するコードを呼び出す
最後の低水準IOが呼ばれるところについての具体的な部分については、処理系(コンパイラや標準ライブラリ)により変わります。
例えば、ルネサスのCCRX(RXマイコン用コンパイラ)であればプロジェクト新規作成すると lowsrc.c とか lowlvl.src といったソースファイルがひな形として生成されるので、これを編集します。
GCC(Newlibc)を採用するSTマイクロのSTMCubeIDE場合は syscall.c というシステムコール関数をまとめたソースファイルがこれに相当します。各関数は処理が記述されていないダミー実装なので、ここにUART送信コードを記述するよう、このファイルを直接記述するか、weak 属性で宣言されているので、実体は別のところで記述しても良いです。
しかしprintfデバッグに対する強烈なジレンマ
さて。
周囲の冷たい目線を跳ね除けてprintfデバッグで楽をさせてもらう訳ですが、やがてその工程も終わり製造フェーズから検証フェーズ、そしてソフトウェアリリースへと移り行きます。
そこで直面する悩ましい事態。それは、
「リリース時には使わないコードを全て消すこと」
という規約です。
printf("result=%i(%04X)\n", (uint16_t)a, (uint16_t)a);
や
#include <stdio.h>
といったゴミをソースコード上から完全に抹消しなければなりません。
仮想COMポート用に割り当てるため構成した UART の関連コードや、低水準IOのコードも、
「使用しないペリフェラルはすべて無効とすること」
といった規約が重ねてあるため、レビュー時にすべてチェックされます。
もちろん、サイバーセキュリティ対策の面で攻撃の対象となり得る余計なインタフェースやポートを残さない、という重要な側面もありますが、それ以前に、こういったデバッグ用の仕掛けがバグを引き起こすリスクも当然ありますので、守るべきでしょう。
しかし、手戻りが起こってもう一度確認したいといった時、一旦消してしまったものをもう一度作り直すというのは、心理的にも実作業的にもハードルが高いものです。
またコードを再利用、あるいは修正した時のことを考えるとデバッグ用というか、コンポーネントの使用条件や動作条件が変わった際の変化点を見つけるというのに近いニュアンスで、デバッグ用コードを温存しておきたいというケースもあったりします。
そこで「見つからなければ良い」という開き直りで、ズルというか不正を働こうと画策をしたこともありました。
printfをそのまま使わず被せモノをしてしまおうという目論見です。
#ifdef DEBUG
#define DEBUGOUT(fmt, ...) do { printf(fmt, ##__VA_ARGS__); } while (0)
#else /* DEBUG */
#define DEBUGOUT(fmt, ...)
#endif /* DEBUG */
void func(void) {
int x;
...
DEBUGOUT("x = %d\n", x);
デバッグ時は、
#define DEBUG (1)
をどこか共通ヘッダファイルに仕込むか、共通のマクロ設定としてMakefileなりIDEに登録することでDEBUGOUTがprintfに置き換えられ、リリース時はこのマクロ定義文を削除することで、DEBUGOUT()は意味を持たない文としてコンパイル対象から外れます。
これでデバッグ用コードを(形式的には)残しつつ消えた形でリリースできるぞ、と意気込んだわけですが、結果としては見事撃沈でした。こっぴどく叱られました。悪いことはしちゃいけないですね。
時に鈍感であれ
printfのところで少しばかり取り上げた、リアルタイムシステムにおけるタイミング設計についてです。
職場の方針とは真逆の思想を持っていて、お互いに分かり合えずもやもやしていたことが今もわだかまりとして残っていて、自分なりの整理を付けるため、私のポリシーを一方的に書き連ねます。
「タイミングの揺らぎや遅延に対し鈍感であること」
というのが、私なりの一種の極意というか、大切なポイントだと考えていることです。
このポリシーを一貫して持つことで、printf()文などのような重い処理が入ろうが入るまいが、性能は出ないけれど破綻しないシステムにできます。
その上で、システムを外部から見た時の振る舞いとして、外部仕様として定めた性能なり指標を満たすよう、制約を加えられる設計、言い換えてみれば一種のスケーラビリティ耐性でしょうか、そんなアプローチでの作り込みが望ましいと考えています。
この辺りの感覚は、長いこと携わってきた通信ソフトウェアで培われたのかな、とも思います。他を許容し、自身は揺らぎや不確定性の無い動作とする(他人には優しく自分には厳しく…RFCのどっかで書かれていたリベラルにしかしコンサバに、というやつです)。本人は甘々でブレブレですが、せめて創造物にはそうあって欲しいという切なる想いとでも言わせてください。
逆に、良かれと考え、内部処理・内部構造のアルゴリズム自体を時間に対しセンシビリティに作ってしまうと、将来的に色々な弊害を引き起こす危険性を孕んでしまいます。
これはタイミング設計だけでなく、異常処理であったり例外処理であったり、そういった諸々を含んだ意味で、でもあります。
コードを組んでいると、外的要因次第でここは想定通りにならない条件も起こり得るかな? と気付くことも多々あります。鈍感な私でさえそうなのですから、勘の鋭い人なら尚更です。
規約(要領)でも異常処理はきちんと網羅すること(されていることをレビューすること)とありますので、ストイックで優秀な同僚の皆様は、ドライバレベルからアプリケーションレイヤまでの全体に渡り、コードのあちこちで異常時のケースを条件分岐に組み込み、ご丁寧に例外をエラーとして吐き出す機能まで作り込みます。
その結果生み出されるのが、スパゲッティなコードとなります。
そして、検証不可能なコード(再現不可能、かつ非常にレアケース、場合によっては起こり得ない条件もあるわけですから)でもあるわけです。
「条件分岐が一つ増えるたびに、検証コストが(最大で)倍になると思え」
そう思わず口走った時は後悔しました。冷たい視線で無視されればね。
「ならば、お前ならどうする」
そう聞かれたこともありませんでしたが、もし聞かれたならこう答えたと思います。
- 例外の内容を精査、取捨選択して、やる必要が無いものは除外したら(ただし、設計書なりコメントでその情報を残した上で)?
- ステートマシンで異常状態を定義し、ステートだけそこに飛ばすようにしたら(条件分岐でエラーを吐き出すコードに飛んだり戻ったりよりは良いよね)?
- デバッグレベルで検証できるなら assert 文で使ってみたら(効果は限定的かもしれないけど)?
- 例外を throw できないC言語なんて捨てちまえ(嘘)
現実的には、組織の様々なしがらみでどれも対応不可ではあったのでしょうが。
もし、アジャイル的な組織文化があれば定例のチームミーティング(あるいは日々のデイリースクラム的な寄り合い)で、これはどうよ? と問題提起や相談することもできたでしょう。
しかし、チームミーティングはそういう話し合いをする場ではなく、コードレビューは通過儀礼でしかなく、これが開催されるときは既に工程表の最後の方で後戻りできない段階、何か違和感があるなと思っても、致命的なものでなければ何となくゴニョゴニョとなって、良くて次の開発時の課題としましょう、で終わってしまうのがオチです。
チームミーティングは「進捗報告、作業は計画通り進んでいます」もしくは「計画の〇〇%遅れ。××の施策を行い対処します」という進捗を報告する場となっておりました。そして、その工程表は上の人が(上意下達により)あらかじめ設定された製品開発のマスタープランに合わせ、何を根拠に工数を決めたのか不明なもので、過去の開発データの統計を取るといった取り組みも無い状態で、にもかかわらず工程表通り事を進めていた皆様の能力の高さには驚かされるばかりでした。
そして、極稀にある(超貴重な機会である)次世代製品や派生品開発においても、開発計画時にこの部分の対処を工数に組み入れることはされず、リファクタリングは悪という企業文化(今動いているソースコードは検証され市場での実績もあるのだから信頼すべきもので、市場で発生する不具合がない限りは手を加えるなという文化)の下で、過去に再検証の話題に上がった仕様も永遠に残るわけです。
はい。弊社(かつての職場)はゴリゴリのウォーターフォール型のソフトウェア開発プロセスを採っております。アジャイルやCI/CDなどという軟弱な開発プロセスやその思想は憎むべき敵となっております(たぶん)。
もう一歩深掘りするなら、こういった例外系だったり異常系だったりの振る舞いやポリシーは上位設計(もしくはもっと上位のコンセプト設計)段階で、方向性なり仕様が明確にされている(下ごしらえができている)べきで、製造フェーズに入ってから処方箋を求められても、大したことができないというのが実情です。
開発の後工程で生じた様々な事象を、開発プロセスの中でフィードバックできない(しにくい)ウォーターフォール開発の弊害のような気がしてなりません。
ウォーターフォール開発そのものを否定するものでは無いです。組織によってはウォーターフォール開発スタイルを採用しつつ、アジャイル的なスパイラルを上手に組み入れ、柔軟に対応できるようなっているでしょう。各社の事例を見て見たいところです。
愚痴
職場では、タイミングをやたらと厳しく言われる割に、タイミング制約とか仕様範囲といったことは設計段階で明確にされていないのも不思議でした。
「この部分のタイミング設計に対する基準は無いの? 無きゃ検証も何もできませんぜ?」という問いかけにも、「何言ってるの? 過去・現行のソフトより遅く無いこと、これに決まってるだろ」という、あざけりに近い声色で返された時は、ちっぽけな自尊心をかなり削がれたのも、今となっては懐かしい思い出です。
assert文
これについてもちょっとだけ言及。
assert自体(アサーション)はデバッグ用の仕掛けであると同時に、「こういう条件にはならないはずだけと、一応、そういったことが起こるケースもきちんと想定した上で構築していますよ」というコード上の意志表示であり、それ以上でもそれ以下でもないというのが、私の認識です。
例えば
RTOSのシステムコールやスタックのAPIがエラーコードを返して来た時ですが、そのエラーの内容がリソース不足だった場合。これはもちろん、構造設計においてリソースがきちんと配分されていれば起こり得ない例外ですので、運用中に発生したりしなかったりということはあり得ない(起こるとしたら作り込み過程でのデバッグ中、あるいはコードを流用した先の、別の処理系や環境へ移植した時に限定される)、つまり、初期デバッグで活用される機能です。
RTOSなどという危険なものは使うべきではないという文化の職場でしたが、反逆者の私は、RTOSを使いだすという、職場での禁忌を犯すことに躊躇はありませんでした。
なので、こういったケースに対しては、わざわざ異常処理は作り込まず、assertに飛ばすことで例外発生個所を特定できるようにするといった、デバッグの補助を担う位置付けのもので、まぁ無くても何とかなってしまう類のものです。
特にこれをやったらコード品質の向上に寄与する、といったものでも無いと思います。
なおassertはCの言語仕様に組み込まれたものではなく、assert.hで定義されたマクロとなっていて、本番環境では(NDEBUG=1とすることで)無効化できるようになっています。
RTOSやフレームワークが独自のアサーションを提供している場合もあります。
ここでまたジレンマ
printf と同じく assert もリリース版に残すことは許されませんでした。
そもそも、assert の意義を共有する相手がほぼ居なかった(それどころか、その存在を知っている人間さえ限られていた)ため、assert は是か非かという論議さえできず、今までやってなかったのだからやる必要は無い、という意見に押し切られた形です(前述の通り、私自身もポリシーの問題で、どうしても必要とは考えていませんでしたので)。
他の開発チームと、静的解析ツールのスコアやメトリクスの数値で優劣を競うあうような、そんな雰囲気もありました。手段と目的が入れ代わっているというか。リーダーが脳筋だと、こんなことでも勝った負けたになります。
最適化とか
これもデバッグというお題と多少関係しますので。
かつては最適化は原則禁止、その後、緩くなりましたが、必要以上に最適化レベルを高くしないこと(「必要以上に」という、具体的な数値を示さないのがまた)という運用ルールは厳格に残っていました。
理由としては、コンパイラのバグに対するリスク低減策です。最適化レベルが高い設定でのみ起こるというものが多い傾向にあるため、そのリスクを避けるためというものです。
一方で、今時(といってもここ30年来の)マイクロプロセッサのアーキテクチャはコンパイラと二人三脚、ましてや(Cortex-M7クラスの)深いパイプライン構造を持つマイコンでは、最適化レベルは高くしないとパフォーマンス出ないし、省電力という視点でも不利だし、なんか勿体ないなという想いが強く、私は最適化レベル上げたがり野郎でした。周囲とのギャップはそれはもう、大きくて。
関係ないですがCISC対RISC論争というのもありました。昔話です。
開発チームにおける現場での運用は、規約を守るべく最適化無しで開発をスタートし、性能が出なかったりメモリ容量が足りなかったりすれば最適化レベルを上げていく、という手法でした。
これはこれでやり方としていいの? という疑問も無きにしも非ずでしたが、1円でも安いマイコンを選定しなければならないメーカーの宿命、しかも初期開発時は同じシリーズのマイコンでも ROM/RAM 容量の大きいもので進め、量産時にマイグレーションするという文化も無かったため、ギリギリの性能とメモリでデバッグを進めるという過酷な環境にありました。
「初期開発から量産開発まで量産基板で進めれば開発コストを最小化できるんじゃね?」というとてもクールな設計手法ですが、それによる開発の不効率化は全く念頭に置かれていないという、あるあるパターンでしょうか。
で、
「最適化レベルを上げたら動かなくなった! どうしてだ!」
と始まる訳です。
「お前さんのコードが悪いからだよ」
とは口が裂けても言えず、そうですかと相槌を打つしかできない私は社会人失格だったと、今更ながら思っております。
同僚の天才プログラマは、そのコードを一瞥するだけで、「ここをこう直せば動きますよ」と優しくアドバイスされてました。こんな一瞬で、しかも副作用だらけのスパゲッティコードを理解・把握し解決する能力は、私にはありませんよ。
最適化レベルを上げるにつれ、コンパイラはソースコードに対し言語仕様に厳格な記述を求めていく、そうすることで無駄な命令をそぎ落としていく、という関係があります。良く言う「ストライクゾーンギリギリを攻めていく」というやつです。
特に副作用関係や暗黙の型変換とか、その辺りをルーズに考えていると、無慈悲に斬り込まれてしまう、その覚悟が必要となってくるので、「開発を進めていったらメモリが足りなくなった」という安直な感じで最適化レベルを弄るのは、悪手の一種ですよ、というお題でした。
ファイルIO
デバッグというお題からは外れますが、printfデバッグの記事で触れたことに関連してファイルIOについても少しばかり。
低水準IOは標準入出力だけではく、ファイルIOにも使える(というか、本来はこちらが主眼)訳でして。
ファイル操作関数(fopenとかfcloseとか)が呼ばれると、ランタイムは低水準IOをコールしてきます。つまり、簡易的ななんちゃってファイルシステム的なものを作り、低水準IOを中継して、メモリなりストレージにアクセスするようにすれば、FATなどを導入しなくても、パラメータなどをまとめたファイルという形で持つことができます。
これを使い、例えば、
- 通信で送られてくる、出荷設定パラメータをJSONファイル化
このファイル自体は、校正モード時のみ、出荷検査治具から通信で送られてくるようにして、起動時のセットアップ処理でJSONファイルをファーム内部で構文解析、パラメータ値を抽出・数値化して、各モジュールに分配する。 - 通信で送られてくる、ファームウェア更新用ファイルのモトローラSレコードフォーマット化
ファーム更新時は、送ってもらったSレコードファイルを一旦FLASHに格納し、パリティチェックとバイナリ変換して、更新用バイナリを内部生成する。
とうことができます。主に通信と組み合わせての活用です。
バイナリではなくテキストでのデータ受け渡しとなるため、通信路での伝送速度が遅くなるというデメリットはありますが、デファクトのフォーマット(JSONなりSレコードなり)を活用することで、自社開発の独自フォーマットのバイナリデータを設計し、更にそれを転送するための独自プロトコルの作成や管理をしなければならない、という重い工程を省くことができます。
そうすることで、対抗局であるPC側のアプリ開発が容易になり、検証も楽になるというメリットを受益できます。
この例で挙げた2つの事例ですが、規約の壁さえなければこういうこともできるという方向性を、次世代製品に向けての要素開発という形で試作してみたのですが、やはり評判が悪く採用されることはありませんでした。
試しにかけてみた静的解析ツールの結果は惨憺たるものでした。特にひどかったのはフルスクラッチしたJSONのパーサーで、JSONの階層構造を処理するためのアルゴリズムが、データフロー解析で再帰だとみなされてしまい、再帰を作ったお前はソフトウェアエンジニアとして失格だ、お前はもうソフト作るな、という評価をいただくほどの酷い有様でした。
ファイルストリームのもう一つの応用は、整数や浮動小数点を含む書式をテキスト形式へ変換したいときに、printf文の書式指定構文を利用して、楽に行えるというものです。
過去に、そういったことが必要なソフトの開発をオフショアで依頼した時、アウトソーシング先がsprintf文で作り込んできたことがあります。そのソフトの受け入れ時、大もめにもめたのですが、規約では委託先が品質保証すれば、設計ルールから除外できるという抜け道がありましたので、条件付きで許容することになりました。
文字列をRAMバッファに展開し、そのデータを出力するというコードなのですが、sprintfは「オーバーフローさせずに出力バッファへ展開する」ことが保証できないという、非常に危険なものです。
一般的なコーディング規約では使用が禁止されている……と思う。
他の方法でやってくれないかと打診はしたのですが、委託先はこの納期ではsprintf以外での対応は無理ということで、バッファサイズをかなり大きくすることで妥協したという経緯です。
こういった時、sprintfではなくfprintfを使い、仮想的なファイルストリームでバッファ展開すればオーバーランを抑止できます。
書式指定に従い文字列を作るのはランタイムに任せて、低水準IOを経由して送られてきた文字列ストリームをバッファに展開する部分は自作し、そこでバッファオーバーランをしないようなコードにすれば良いだけです。
もちろんこのことは提案したのですが、なんか話が伝わらず却下となりました。