タイムティック手法の概要と使い方についての解説です。
想定する読者
ベアメタル(RTOS無し)マイコンソフト開発を行っており、タイマモジュールを含むマイコン内蔵ペリフェラルの動作や使い方についてある程度理解している組み込みソフトウェア開発エンジニア(超低レベル≒ハード寄りのコードを日常的に書いている人)。
タイムティックとは
基準となるカウンタ(ティック)を使い、時間計測やスケジューリングを行う手法のことです。これに使うタイマのことをティックタイマと呼称します。
時間管理あるいはタイマ処理は、リアルタイムシステムにおいて重要な部位を担うものです。タイムティックの手法を用いることで、機能毎個別にタイマカウント処理を作り込むより、簡便で保守性が良く、システム全体で整合性の取れた設計とすることが期待できます。
一般的には OS (RTOS=リアルタイムOS)が提供する基本機能の一つですが、ベアメタル環境でこれと同じ(類似の)ものを作ってしまおうというのが今回の記事です(ベアメタル開発では、こういったところまで開発者が作り込むことになるのが、面倒くさくもありますが面白いところでもあります)。
タイムティックを使うことによるメリット
逆説的な話になりますが、タイムティックを使わずにタイマ処理をするとなると、どのような形態になるでしょう。
個別にタイマカウンタを作成し、それぞれそのカウント値で経過時間を取得したり、タイミング関連の処理をすることになるのでは、と思います。具体的には、
- マイコンに内蔵されているタイマを個別に割り当てる方法
- 1つのタイマ割込みで複数のカウンタを計数する方法
この2つのパターンの組み合わせになるでしょうか。
簡単なシステムではこれらのやり方でも十分ですが、通信などを行う複雑なシステムでは数10個以上必要になこともあり、ある程度以上の規模のソフトとなると、各タイマの動作を個別に検証したり保守したりというコストが馬鹿にならなくなります。
もう一つ大きな問題は、マイコンのハードウェアに大きく依存するデバイスレベルのコードと、アプリケーションとの間で、密接なつながりが生じてしまうことにあります。
デバイスレベル(低レベル階層)はアプリケーション(上位階層)に依存しない構造とするのがソフトウェア構造的には綺麗で、保守性や検証においても望ましいのですが、この関係がどうしても崩れてしまいます。
割込み処理がアプリの実装に引きずられ、ソフトウェア階層構造を下から上に貫く存在となってしまいます(これを回避する方法として、コールバックで分離する手法もありますが、オーバーヘッドが大きくタイマ処理にはあまり向きません)。
タイムティックの手法を有効に活用することで、割込み関数はアプリケーションに非依存となり、また、コード全体をずっとシンプルにすることができます。結果、開発コスト引き下げにもつながるはずです。
タイムティックが使えないケース
逆に、タイムティックが使えない用途です。
PWM生成やAD変換の周期トリガ、パルス信号の計測など、ハードウェアに密接したものには使えません。また、マイクロ秒オーダーの短いものや、時間精度が必要な用途には向いてないです。
それぞれの特徴やメリットデメリットについて、比較表としてまとめます。
| 項目 | タイムティック | タイマを個別に割り当て | 複数のカウンタを一つの割込みで計数 |
|---|---|---|---|
| ハードウェアリソース(タイマモジュール)の使用 | 1つのみ必要 | 機能の数だけ必要 | 1つのみ必要 |
| 時間精度 | 並み(ジッタあり) | 高い(設計次第でμsオーダーまで可能) | 並み(ジッタあり) |
| 時間単位 | 一般的にmsオーダー以上 | μsオーダーまで可能 | 一般的にmsオーダー以上 |
| 割込み処理の重さ | 非常に軽い | 中~重い(処理による) | タイマ数に応じて増加 |
| 保守性 | 良好(コア部分については再利用可能) | 悪い(場合によってはチョイ変でもタイミング検証やり直し) | ケースバイケース(タイマ数に応じ管理コスト増大) |
| 難易度 | 低(タイムティックの動作を理解すれば) | 高い(多重割込みや排他への配慮、時間精度を求めるとより高度な考慮が必要) | ケースバイケース(タイマが増えるに従い増大) |
保守性と難易度の項目については、主観やシステム毎の事情もありますので、参考程度に。
必ずしもタイムティックを使った方が良いということではなく、システムの要件に応じて、どの方式を選択(組み合わせ)するか、ということになろうかと思います。
実装
マイコンのタイマをコンペアマッチタイマとして一つ割り当て、設定したティック時間毎にISR(割込みサービスルーチン、あるいはそこから呼び出される割り込みハンドラ)を呼び出すように構成します。
Cortex-M3 など Arm 系マイコンであれば個別タイマを使わずとも、そのものずばり SysTick というティックタイマ用に最適化したハードウェアモジュールが入っていますので、これを利用することができます。
先ずはコンペアマッチ割込み。こんな感じに記述します。
#include <stdint.h>
static volatile uint32_t tick_count;
/**
* @brief SysTickタイマーのコンペアマッチ割り込み 1ms 周期で呼ばれる
*/
void HAL_SysTickTimerISR(void)
{
tick_count++;
}
これだけです。コンペアマッチ割込みの度に tick_count がインクリメントされます。
tick_count は外部に直接見せず、こんな感じで参照するようにしましょうか。
uint32_t HAL_GetTick(void)
{
return tick_count;
}
アプリケーション側(メイン側)の記述はこんな感じになります。
void APPL_ProcessMain(void)
{
uint32_t a;
uint32_t b;
uint32_t elapsed_tick;
...
a = HAL_GetTick(); // A ... 処理前のタイムティック取得
// 何らかの処理がここに入る
b = HAL_GetTick(); // B ... 処理後のタイムティック取得
// A から B までの経過時間を取得
elapsed_tick = (uint32_t)(b - a);
printf("Elapsed time while processing: %u[ticks(ms)]\n", elapsed_tick);
...
}
AからBまでの経過時間をティックという形で取得できます。
割込み周期が 1ms であれば 1 tick = 1[ms] ということになります。
ティックの間隔はシステムの設計仕様に応じて 1ms~10ms 程度とすることが一般的かと。 「ティック値⇔時間単位」 を相互換算するサポート用マクロか関数を用意し、アプリ側では時間単位で運用するようなコードにすれば、より抽象化が図ることができ、コード再利用性が向上します。
このティックをシステム全体の時間(タイマ)の基準として使うことで、タスク(あるいは機能ブロックや機能モジュール)毎に個別のタイマカウンタを設定する必要が無くなり、またタスク間の相互依存関係を排除する(関係を疎にする)ことにも役立つと思います。
タイムティックを使わず、機能毎に個別のタイマカウンタを用意する方法だと、機能が増えるたびにISR(割込み)の処理が増えていき、タイミング検証にかかる工数も増えていきます。
このタイプのタイミング検証は自動化が難しい上、タイマ割込みは割込み優先度を上げるケースが多いと思われ、システム全体の時間リソース管理に大きく影響し、ちょっとした変更が重大なバグを引き起こす可能性があり、最大限の慎重さが求められる部分です。
タイマの用途としては、
- 周期処理
- 遅延処理
- 経過時間計測
- タイムアウト処理
といった類型が挙げられるでしょうか。上記サンプルはこの中の経過時間計測ですが、他の用途についても応用は可能です。
タスクやシーケンス処理を待ち行列(レディキュー)で制御するようにし、このタイムティックと組み合わせれば、(優先度制御は別として)立派な RTOS 準拠のタスクスケジューラになります。
原理
先のサンプルでは符号無し32ビット整数をティックタイマとしましたが(理由は後述しますが、一般的にはこうすることが多いです)、分かり易くするため4ビットで解説します。
A (=3)
|
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ tick_count
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
: |
: B (=6)
+------->: B-A (=6-3) = 3
AからBへの経過時間は B-A という簡単な計算式で求まります。この例の場合、経過時間は3ティック、ということになります。
次に、AからBの間でラップアラウンド(オーバーフロー)が起こった時です。
A (=14)
|
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ tick_count
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| :
B (=1) :
+->: B-A (=1-14) = -13 +---> +
| | Wrap-around!
+ <---------------------------------------------+
(単純な引き算で考えると)結果は -13 となりますが、ここで 「符号無し型へのキャスト」 という操作が入るので、目的とするティックを求まります。
この動きをバイナリ演算で示します。
bit S x8 x4 x2 x1
B ( 1=0x1) : - 0 0 0 1 (unsigned)
A (14=0xE) : - 1 1 1 0 (unsigned)
B - A : 1 0 0 1 1 -> -13 (2's complement, resulting of integral promotion)
<- Bollow
(B - A) & 0xF : - 0 0 1 1 -> 3 (modulo operation of 2^4 as rejecting sign bit)
ということとなり、ティック値3が正しく求まります。
コンピュータによるバイナリの四則演算(=C言語の規格でもある)には、モジュロ演算が隠れているという特質を活用した、ちょっとした魔法です。
上のビット演算は微妙に嘘をついているので突っ込みがあるかもしれませんが。B-A の結果は32ビットintで表すなら 1111-1111-1111-1111-1111-1111-1111-0011 と等価です。
制限事項
この魔法が通じるのはティックタイマのカウントが一巡する手前まで、つまり、tick_count が32ビット変数の場合、(2^32)-1 ティックまでとなります。
割込み周期を1msとした場合、約71,582分 ≒ 1,193時間 と、49日以上連続稼働して、ようやく魔法が切れる計算になりますので、通常のリアルタイムシステムではまず問題にならないと思います。
ティックタイマを16ビットにしてしまうと、この制約が65秒ちょっとと、かなりしょっぱいものになってしまいます。
長時間の計数が必要となる、アワータイマのようなものが必要な場合は、例えばタイムティックで一分毎にイベントを発行し、別のタイマ変数をカウントアップ(もしくはカウントダウン)するタイマをアプリケーション側で実装するのが良いでしょう。この程度の時間単位であれば、ソフト検証も現実的です。
規約対応
このテクニックはラップアラウンド(オーバーフロー)と符号無し整数キャストの組み合わせを「2の補数による意図した巡回挙動」として活用したものですが、これらが静的解析ツールにより「AとBの大小関係によってはラップアラウンドの可能性があるよ? 意図してやってる? 情報が失われる可能性があるけど大丈夫かな?」と指摘を受ける可能性があります。
普通の職場であれば、これは意図的なラップアラウンドで、危険性は全くないことを説明すれば、コードレビューでも承認されると思います。承認済みコードとしてタイムティックのライブラリをコミットし、検証も済ませてしまえば、以降、問題なく再利用できるはずです。
上記サンプルでは、プロセス処理(アプリ側のコード)の中でタイムティック算出をやっていましたが、実運用で用いる場合は、タイムティック関連の処理はライブラリ化(カプセル化)の形で集約してください。こうすれば、タイムティック関連の警告は一つのまとまったファイルでしか出ないので、対処はしやすいかと思います。
しかし、ラップアラウンドを絶対悪と考える文化の職場では、かなりの困難を伴うことも予想されます(私のかつての職場もそうでした。「モジュロなぞ知らん!それより警告は潰せ。一つでも少なくしろ!できなければそんなやり方は許せん」 という感じでした)。
なお、MISRA規格では定数式以外の符号無し整数のラップアラウンド自体は別に禁止していないので、それを盾にして戦うという手も無きにしも非ず。職場での力関係(自身への評価)にもよりますが。
その他いろいろ
この記事を読んでいただき「タイムティック。使えそうかな?」と感じていただければ幸いです。
ここからは、細かな点をいくつか列挙します。
- ハードウェアセットアップ後自発的にカウントを開始
2つのティック値の相対値比較なので、ティックタイマ自体をいつ起動するかという厳密な管理は不要になります。なので起動停止やクリアといったアプリ層からの制御やタイマ変数への書き込みアクセスは不要となり、予期しないバグの抑え込みや、メイン処理と割込み処理の両方からタイマ変数の書き込みすることでピックアップされる、変数の排他関係の管理(タスク間共有変数リストの管理)も楽になります。 - CPU制御命令である割り込み禁止許可をアプリから頻繁に発行する必要も無くなる
ティックタイマを読み出すだけなので、排他処理(クリティカルセクションの制御)は不要です。入れ忘れによるバグ(再現性が乏しい最悪の部類)のリスク軽減、アプリ層からのCPU命令発行(処理系依存であり再利用性に難あり)排除が図れます。 - WCET(最悪実行時間)的な観点からも有利
ISR内部で条件分岐が起こらないため、割込み発生~終了間の処理時間の揺れは最小となります。割込み優先度が高く発生頻度も多いタイマ割込みでの不確定要因が減るので、通信など他の機能への影響・遅延時間のブレに対する評価をやりやすくするという意味でも有利です。 - 機能モジュールをまたいだ時間的相互関係の把握
共通ティックタイマが存在するので、別々の機能モジュール間の時系列を把握することも容易になります。
ティックレスOSについて
本題から外れるおまけ的な内容ですが、記事の中でRTOSとティックタイマの関係について言及したので、軽く触れます。
タイムティックはRTOSの根幹に係わる重要な要素で、カーネルのタスクスケジューラと密接に結びついています。しかし、RTOSによっては 「ティックレス動作」 というものをサポートしているものがあります。どういうことでしょう。
先ず、前提条件として(従来の)タイムティックを使う実装では、以下の課題があります。
- 省電力動作
バッテリー駆動機器など、ほとんどの間スリープやサスペンドさせ、必要な時だけ起床させるような使い方の場合、ティックタイマの割込みの度にスリープ解除する(そしてそのほとんどがティックタイマのインクリメントしかしない)のは、バッテリー使用の面で不利。 - リアルタイム性
モーション制御やリアルタイムプロトコルといったアプリケーションでは、マイクロ秒オーダーの時間管理が必要なことも考えられ、こういった用途には応用できない。ティックタイマの駆動は精々1ms~0.5ms程度が限界で、これ以上頻度を上げると時間リソースの浪費が激しく実用的ではない。
これらを解決するためのティックレス動作、つまり無駄にタイマ割込みを発生させないためのアルゴリズム、ということになります。
実際にRTOSのティックレスモードをハッキングした訳では無いので、実際にどうなっているかは憶測でしか無いのですが、「自分だったらこう構成するかな?」 という内容で少し解説します(正確性は怪しいので、あくまで「俺の考えた最強の〇〇」的なやつです)。
- ティックタイマ(SysTick)割込みでカーネルが呼び出されると、タスクの待ち行列(レディキュー)を頭の方から評価します。起床条件を満たしたものがあればレジューム(あるいは条件によってはプリエンプト)のための準備をします(ここまでは普通のタスクスケジューラの動作)。
- これと共に、次のティックタイマ割込みを構成・予約します(ティックレス動作の場合は、ティックタイマの動作を自動リロード・繰り返しではなく、単発モードで設定します)。この時の(コンペアマッチの)設定時間は、レディキューの中で、待ち状態になっている最初のタスクの、待機時間から換算して設定します。
- タイムティック値は、単純なインクリメントではなく、このレディキューから割り出した時間を加算していくことになります。
うんと単純化した動作はこんな感じかと思うのですが、様々なケースを考えるとはるかに複雑・高度なものとなりそうです(どうやって検証するかなど、考えたくも無いレベルで)。