遊びで作っている組み込みソフトでTDD手法を使ってみました。なかなか良かったので以下に試したこととその結果を書き記しておきます。
組み込み用のデバイス・ドライバの開発は面倒なものです。
無論、「組み込み」は範囲の広い言葉ですので一般論として上の物言いは乱暴に過ぎます。が、やはり一般論としてもデバイス・ドライバ開発は面倒なものであり、それがデバッグ環境が貧弱な傾向のある組み込み分野となると輪をかけて面倒である、ということは言い切っていいように思えます。
ともかく、デバイス・ドライバというものは
「何か作ってみたい」
と思う時にはたいてい面倒な、しかも避けて通れない障壁です。そして、デバイス・ドライバ自身が「作ってみたい何か」になることは、私の場合ありません。
ということで、毎度毎度ですがデバイス・ドライバ開発には馬鹿みたいに時間がかかります。そもそも大半が私自身の腰の重さに起因するものだとしてもです。実機で動作するか試すことが面倒なうえ、ちゃんと動き出すまでの道のりが長いことがまた、このソフトウェアの面倒な所ともいえます。
そこで、TDD(テスト駆動開発)という奴を、やってみることにしました。
テスト駆動開発
TDDについては私が今更何かを書くまでもありません。IT分野では十分に普及している手法です。多くのツールもあります。しかしながら、私が強引に理解している範囲でまとめると、本質は
- ユニット(個別の関数や機能)を開発するたびに、必ずテストする。
- 関数を呼ぶためのテスト・ドライバを書く。
- 関数が呼ぶサブ関数の代わりとしてスタブを書く。
- 開発で何か書いたら必ずテストを書く。
といったことのようです。
要するに、はやる気持ちを抑えてコーディングの各ステップでテストを書いて行う習慣をつけろ、というこのようです。IT分野では、この「コードを書く、テストを書く、テストを実行する」サイクルをうまく回すためのユニット・テストツールが広く使われています。
組み込み分野のTDDの難しさ
TDDの本を読んだ時から、組み込み分野のTDDには大きく2つの難しさがあるように考えていました。
一つはTDDが本質的に時間を扱わないことです。これはこの手法の原典ともいえる『テスト駆動開発』にもはっきり書かれています。TDDはユニットの「機能」テストをサクサクこなしていく手法なので、「しばらくしたら割り込みがかかる」といったシステムの「時間的振る舞い」をうまくテストできません。やればできるのかもしれませんが、明らかにテスト環境を作ることが大変で、テストをサクサクと進めていくという趣旨から外れてしまいます。テスト環境のテストもしなければなりません。
もう一つの難しさは、ユニットが扱うものが実デバイスであるということです。レジスタを扱うようなソフトウェアは明らかにTDDの対象となりません。HALのようなAPIを使うソフトウェアについても、HALのシミュレータをスタブとして書くことになり、現実的ではありません。
こういったことから、以前から興味はあったものの、紫ライブラリの開発にはTDDは使用しませんでした。
上の層のドライバ
紫ライブラリの開発にあって、I2Cドライバについては上記のような理由で実機でのテストしかできませんでした。しかしながら、I2Cドライバの上の層のドライバについては、TDDが使えます。
いうまでもなくI2CドライバはマイコンのI2Cコントローラのドライバです。ですのでI2Cコントローラに接続されるADCやCODECといった外部ペリフェラルについては、別途開発が必要です。こういったペリフェラルのドライバはI2Cドライバの「上の層」として機能します。I2C ADCドライバを例にとると、このドライバが使用するのはI2Cドライバだけです。したがって、ハードウェア全部を厳密にシミュレートする必要はなく、I2Cドライバのインターフェースを通してハードウェアの機能の概要をシミュレートするスタブを書けばよいことになります。これはハードウェアのシミュレータやHALのシミュレータに比べるとずいぶん楽です。
私の場合、ミドルウェアに紫ライブラリを使用することでTDDの実行が楽になる恩恵を2つほど享受することできました。
まず時間に関する問題です。TDDは先に述べたように時間が絡むソフトウェアのテストに向いていません。一方、紫ライブラリのIO関数はすべて同期IOとして実装されています。つまり、紫ライブラリのIO関数は、出力なら出力が終わるまで、入力なら入力が終わるまで帰ってきません。このため、上位層についてはI2Cコントローラの挙動の時間的側面をすべて無視することが出来ます。
同期IOの場合、関数内でIO動作の完了を待つ必要があります。紫ライブラリはRTOSを使用しているために、この場合も単にほかのタスクに実行を譲るだけでCPUリソースの無駄は生じません。これらのことはすべてIO関数の中に閉じ込められており、外からは見えません。
紫ライブラリを使用することでTDDの実行が楽になる2つ目の恩恵はクラス・ベースのIOにあります。紫ライブラリはすべてのIOペリフェラルをクラスとして実装しています。例えばマイコンのI2C3をマスターとして使う場合は、CubeIDEのDevice Configuration Toolが生成する変数hI2c3を引数としてI2cMasterクラスのオブジェクトを一つ作ります。
実機でI2C ADCのようなペリフェラルを操作する場合は、この変数を通して操作します。ここでI2cMasterクラスはI2cMasterStrategyクラスを継承しており、ADCデバイス・ドライバ・クラスのコンストラクタはI2cMasterStrategyクラス・オブジェクトへのポインタを受け取るように設計します。
そこで、やはりI2cMasterStrategyクラスを継承するI2cMasterStubクラスをテスト・スタブとして作ってADCデバイス・ドライバに渡してしまえば、簡単にテスト・スタブとして使うことができます。
このようにスタブの作成が簡単で、しかもクラスを一つ実装するだけで必要なスタブ機能を提供できるためにドライバの試験が容易になっています。
テスト・プログラムの構造
TDDを実際に使ってみるにあたって、Silicon Labs社のPLLであるSi5153Aのデバイス・ドライバ開発を試験の場とすることにしました。
下の図で真ん中に陣取るのがテスト対象となるデバイス・ドライバ、右側がI2Cマスターをシミュレートするスタブ、左側がテスト・ドライバです。
スタブがI2Cマスターをシミュレートすると書きましたが、実際にはI2Cマスターの先にぶら下がったSi5351AのシミュレートをI2cMasterStrategyのインターフェースで行っています。また、シミュレートと言ってもシリコンのシミュレーションなどできません。そこで、次の二つを行っています。
- デバイス・ドライバがI2Cに書き込む(送信する)データの記録
- デバイス・ドライバがI2Cから読み込む(受信する)データの用意
受信データの用意はテストの直前にテスト・ドライバが行い、送信データはテストの後にテスト・ドライバが回収します。
テスト・ドライバはデバイス・ドライバの関数を呼び出して引数を与えます。その引数に応じてデバイス・ドライバがI2C経由で正しい読み書きをしているか否かを試験するのがテスト・ドライバの役割です。
実際に行ったTDD開発手順
以上の構造のテスト・プログラムを使ってTDDを実践しました。
TDDですのでデバイス・ドライバとテスト環境の開発は並行して行うことになります。具体的には、デバイス・ドライバに機能を一つ実装するたびにテスト・ドライバに機能を一つ実装します。テストの実行はNucleo基板上で行いました。原理的にはx86上でできるはずですが、紫ライブラリはSTM32マイコン上でしか動作しないので、この形に収まりました。
こうして、大局的な手順として以下のように進みました。
- デバイス・ドライバの開発を終える
- 実機でテストする
結論から言えばこれは大失敗でした。というのは、実機で動作しないことから詳細に調べたところ、データシートを誤読していたことが発覚したからです(苦笑い)。この件に関してあれこれ書いても役に立たないので結論を書きますが、以下のようにすべきでした。
- デバイス・ドライバのレジスタ・アクセス機能を実装する。
- 実機のレジスタにアクセスして、実装前に動作を確認する。
- デバイス・ドライバに機能を実装する。
- 当該機能のテストを実装する。
- 2に戻る
こうして積み残し機能がなくなれば実装は完了です。
先に大失敗と書きましたが、調査にあたっては実装済みのレジスタ・アクセス機能をそのまま使えましたから、大変スムーズに作業が進みました。また、紫ライブラリにはsyslog機能を実装しているため、細かにステータス・チェックを行えることも作業のスムーズな進行に寄与しています。ADCやDACのような単純機能のデバイス・ドライバならともかく、PLLのように複雑なデバイスだとこのように機能確認を交えて開発を進めた方が楽になるでしょう。
Si5351A PLLはレジスタの構造がいやらしく、また、設定値も直感的ではありません。ですので、レジスタのパッキング関数や、設定値計算関数が必要になります。それらをオフラインで試験し、あらゆる周波数で破綻が無いことを調べるには、PLL ICから離れた検証が必要です。TDDはこういったICに向いていると感じます。
まとめ
テスト駆動開発(TDD)を組み込みソフトウェアで行っていました。その結果、以下のようなことを理解することが出来ました。
- ハードウェアを直接触るソフトウェアのTDDは難しいが、その上の層なら可能。
- IO操作関数をクラス化しておくことで、その上の層のTDDが容易になる。
- 実装前に実機の機能確認を行うことで、手戻りを防ぐことが出来る。
SDRに向けて次はTIのCODICのデバイス・ドライバ開発が待ち構えていますので、これもTDDで当たるつもりです。