はじめに
10年以上組込みエンジニアをしている@yagisawaです。
Qiitaのトップ記事では新しいフレームワークの使い方
とかモダン言語の新しい言語仕様の解説
とかが流れてきて、いつも羨ましいなーと思いながら読んでいます。
本記事は組込みシステム開発をしていて経験したこと・感じたことを自虐ネタとして書き連ねていこうと思います。他ITエンジニアの人に「うわっ、可愛そう」って思ってもらうのが目標です(ぇ
各業界それぞれに悩みはあると思うので、自分たちが一番不幸とかは思っていません。おそらく隣の芝が青く見えているだけです。それよりあまり組込みの記事は見かけないので、「組込みエンジニアの仕事ってこんな感じ」というのを知ってもらう意図があったりします。
内容によっては「それはあなたの設計が悪いからです」と言われてしまうようなところもあるとは思いますが、建設的なご意見は大歓迎ですが基本は温かい目で見ていただければと思います。
あと最近のマイコンはリソースが(昔と比べると)潤沢になってきており、一部「最近はそんなこと無いよ?」と思う内容もありますが、「昔はそんなこともあったなー」ぐらいで見ていただければと思います。実際は8bit・16bitマイコンが現役バリバリのシステムもあり、昔話にならないこともあったりします。
苦悩
- 基本
C
しか使えない。 -
C++
も使えるには使えるんだけど、組込みLinuxとかごく一部での話。 - 組込み製品は昔作ったシステムを改変…改変…で新しくしていくことや、そもそも開発期間が長いものも多いので、そういう理由でC++を採用できないパターンもある。私の経験だと、
1993年
の改版履歴が入ったシステムを1度だけ見たことがある。ワタシショウガクセイ。生まれてない人も多いんじゃないかな? - もっというと未だに
アセンブリ
を使ってるものとかもある。流石に現代のシステム規模をアセンブリで作るのは非効率すぎるのでアセンブリで作られたシステムをCに置き換えるなんて仕事もたまにある。でもやっぱりCなの。C++ではなく。 - また組込みシステムはとにかくリソース(CPUクロック・ROM・RAM)が少ないので、速度アップのために一部をアセンブリで組んだり、メモリ不足を補うために独自に
3bit変数
とか編み出しちゃう猛者もいたそうな。 - 参考までに今やってる仕事のマイコンは
システムクロック20MHz・ROM 64KB・RAM 4KBの16bitマイコン
です。リソースが少ないの意味をおわかりいただけただろうか…いや、これでもまだリソース多いほうだよってツッコミ入れたくなる方もいるはず。 - それから組込みシステムではリアルタイムOS(RTOS)っていう組込み向けのOSを使うこともあるんだけど、そのOSがC++に正式に対応していないという理由でC++を採用できないパターンもある。
- あと組込みエンジニアは自分の知らないところで何かしらの処理が走るのがイヤ(な人が多いと思ってる)。
コンストラクタ(RAII)
とか。理由は実行時間が見積りにくくなるとかバグの原因を突き止めにくいとかそんなところかと。 - で結局C。
- もっというと
C89
の場合が結構あったりする。理由はメーカのコンパイラがバージョンアップされないとか、さっきも言ったけど昔作ったものに継ぎ足してるからとか。 - なので、関数の頭に全てのローカル変数を宣言する。スコープが分かりづらい…
- あと
typedef int Bool;
とか書く。Bool型
無いんで。 - もっというと
typedef unsigned char Bool;
とか書いたりもする。1か0しか扱わないのに4byte(マイコンによっては2byteだったりもする)使うのもったいないでしょ。 -
inline関数
も使えない。なので機能単位で関数化し可読性を上げると実行速度が下がる。関数呼び出しのオーバヘッドがかかるので。そこも気になるんです、組込みシステムは。 - なので1関数が長くなっていく。AしてBしてCする関数とか。
- 最近他社が作ったシステムに機能追加する仕事をしたんだけど、
メインループ1000行超えてた
。やっべ。 - あ、
メインループ
って概念は組込み以外にもあるんですかね?NonOSのシステムとかだと
void main( void )
{
init();
while ( TRUE ) {
moduleA_loop();
moduleB_loop();
moduleC_loop();
}
}
- こんな感じででっかい無限ループ書く。プログラム終了しちゃうから。
- OSが使えるシステムだとタスク(スレッド)単位でこういうループを書くんだと思うけど、NonOSは基本的に無限ループは1つしか無い。その無限ループを回っている限り無限ループの外の処理は実行されないから(実際は割込み処理とうまいこと協力しながらやっていくんだけど)。
- 以下NonOSシステムでの話。
- OSが使えて非同期処理できるシステムだと
void onSomeEventOccurred( int arg )
{
doA();
doB();
doC();
}
- こんな感じで1つの出来事に対しやるべきことを全てかけると思うんだけど、NonOSではこうは書けない。メインループで書いた
moduleA_loop()
の中の処理が1秒かかるとすると、moduleB_loop()以降の処理が1秒以上遅れるから。 - なので、
void moduleA_loop( void )
{
switch ( state ) {
case STATE_NONE:
if ( hasEventOccurred(EVENT_SOME) ) {
state = STATE_A;
}
break;
case STATE_A:
doA();
state = STATE_B;
break;
case STATE_B:
doB();
state = STATE_C;
break;
case STATE_C:
doC();
state = STATE_NONE;
break;
default:
break;
}
}
- こんな感じで処理を細切れにし、早めにメインループへ処理を戻してあげる。関数が長くなり、制御フロー変数が追加され、可読性が下がっていく…
- また組込みシステムには
ウォッチドッグ
という怖い番犬(和訳しただけ)が住んでいることがある。時限爆弾のようになっていて、
void main( void )
{
init();
while ( TRUE ) {
clearWatchDogCounter();
loop();
}
}
- こんな感じで定期的にカウンタをクリアしてあげないと割込みが発生したりリセットがかかったりする。プログラムの暴走などを検知する役割を担っている。
- ちゃんと設計してあげないとこいつのせいで…
- 「隊長!
doA()
の処理が重すぎてウォッチドッグに引っかかります!」 - なんてことになる。
- 「うーんしょうがない、」
void moduleA_loop( void )
{
switch ( state ) {
case STATE_NONE:
if ( hasEventOccurred(EVENT_SOME) ) {
state = STATE_A1;
}
break;
case STATE_A1:
doA1();
state = STATE_A2;
break;
case STATE_A2:
doA2();
state = STATE_B;
break;
/* 以下略 */
}
}
- こうしてやる。更に可読性が下がる。
- しまいにはこれでも処理が間に合わなくて最終奥義が発動することもある。
void doA2( void )
{
doHeavy1();
clearWatchDogCounter(); // 最終奥義
doHeavy2();
}
- 今のところ私はやったことないけど、今やってる仕事のコードで発動するかしないかの瀬戸際になっているところがある。リファクタリングしたい…
- あとね、連想配列がすごく使いたいの。だってさっきのコードとか
func_table = {
STATE_NONE: doEventCheck,
STATE_A1 : doA1,
STATE_A2 : doA2,
STATE_B : doB,
STATE_C : doC
}
func_table[state]();
- 的な感じに書けると思うから。
- 配列を使うのも手だと思うけど、上記
state
が0からの連番ならいいんだけど、飛び飛びだとその分メモリを食う。組込みシステムでは致命的。それに添え字が整数型しか使えない。 -
match式
でもいいよ。
match state {
STATE_NONE=> doEventCheck(),
STATE_A1 => doA1(),
STATE_A2 => doA2(),
STATE_B => doB(),
STATE_C => doC()
}
- こんな感じだろうか。
- …ひらめいた。
switch ( state ) {
case STATE_NONE: doEventCheck(); break;
case STATE_A1: doA1(); break;
case STATE_A2: doA2(); break;
case STATE_B: doB(); break;
case STATE_C: doC(); break;
default: break;
}
- これでいいんじゃないか?ちょっと検討してくる。
- 話変わってCだと例外が使えないので、処理が失敗する可能性がある関数の戻り値は処理結果を返すのに使われる。PythonやGoみたいに多値返却もできないからね。
- だから何かを取得したい関数はポインタを使ってこう書く。
ER getSomething( int * const dat )
{
if ( isSomeError ) return E_SYS;
*dat = someValue;
return E_OK;
}
- そうそう、ポインタになっちゃったからNULLチェックも忘れずにね…
ER getSomething( int * const dat )
{
if ( dat == NULL ) return E_PAR;
if ( isSomeError ) return E_SYS;
*dat = someValue;
return E_OK;
}
- ぜーったいNULLにするつもり無いのにif文増えたー、単体テスト数増えたー。
- C++なら左辺値参照使えるのに。
- ちなみにだいぶ前にゲーム業界へ転職した元同期は、転職したての頃この書き方をして会社の人に「何だその書き方?」って言われたそう。
- それから例外が使えないとなるとクリーンアップコードが書けないので
goto文
で代用する。 - そう、悪者扱いされてるgoto文で。私は考えて使えばそこまで悪いものではないと思ってるけど。
- けど、組込み業界には
MISRA-C
っていう怖いパイセンがいて、プロジェクトによっては使わせてくれない場合がある。 - このパイセンがなかなかきびしくて、
- 「gotoは使っちゃいかん!」とか
- 「関数内にreturnは1個だけ!」とか
- 「if...else ifの最後には処理がなくてもelseを付けなきゃいかん!」
- とか言ってくる。
- 例えばファイルを読み込んで何かしらの処理をする関数をアーリー・リターンとgotoを使って書くと、
void foo( void )
{
FILE *fp;
int c;
fp = fopen("boo.txt", "r");
if ( fp == NULL ) return;
c = fgetc(fp);
if ( c == EOF ) goto EXIT;
/* 何らかの処理1 */
c = fgetc(fp);
if ( c == EOF ) goto EXIT;
/* 何らかの処理2 */
EXIT:
fclose(fp);
return;
}
- こんな風に書けると思うけど、MISRAパイセンの教えに従うと、
void foo( void )
{
FILE *fp;
int c;
fp = fopen("boo.txt", "r");
if ( fp != NULL ) {
c = fgetc(fp);
if ( c != EOF ) {
/* 何らかの処理1 */
c = fgetc(fp);
if ( c != EOF ) {
/* 何らかの処理2 */
}
}
fclose(fp);
}
return;
}
- こんな感じになる。
- if文が入れ子になるのが嫌で制御フロー変数を使うと
void foo( void )
{
ER ercd = E_OK;
FILE *fp;
int c;
fp = fopen("boo.txt", "r");
if ( fp == NULL ) {
ercd = E_SYS;
}
if ( ercd == E_OK ) {
c = fgetc(fp);
if ( c != EOF ) {
/* 何らかの処理1 */
}
else {
ercd = E_SYS;
}
}
if ( ercd == E_OK ) {
c = fgetc(fp);
if ( c != EOF ) {
/* 何らかの処理2 */
}
else {
ercd = E_SYS;
}
}
if ( fp != NULL ) {
fclose(fp);
}
return;
}
- こんな感じか。
- こうしてif文まみれの関数が出来上がっていく。可読性も悪いし単体テストがダルい。
- 又聞きの話だけど、医療機器の単体テストでパスが1億超えた事があったとかなかったとか。流石にそれは設計が悪そうだけど。
- もちろん適切な逸脱手順を踏むことで逸脱することは可能。
- けど、これは愚痴になるんだけど、MISRA-Cに丸投げして考えることを諦めたコーディング規約が結構あって、あまり逸脱手続きは踏めない。この記事に書かれた内容がほぼ答えなんじゃないだろうか。
- あと未だにシステムハンガリアンなコーディング規約がかなりの割合である。業界あるある的なものなのかもしれないがガラパゴス化しているような気がしてならない。
- この状況をなんとかしようと最近Qiitaのトップ記事やリーダブルコード・達人プログラマ・レガシーコードからの脱却辺りを読んで勉強している。
- 非常にためになっているが、同時に悲しくなることも結構ある。「ソレ、組込みじゃできねぇや」と。
- 例えば
DRY原則
等共通化に関する話。 - 組込みシステムだとUARTやI2C・SPI等の通信系ドライバは結構な確率で実装するので共通化できるならしておきたいんだけど、
- そもそもマイコンが違うので制御方法が異なる(これは
HAL層
に切り出したりしてなんとかするが、切り出した部分は共通化できない) - OSあり/なしで実装方法が異なる
- 品質優先/速度優先等基準が異なる(車載基準で作ったコードは他システムだと諸々コストがかかりすぎるとか)
- 等の理由で共通化できなかったりする。
- あと、達人プログラマの「データにおけるDRY原則の違反」にこんな例があった。
class Line {
Point start;
Point end;
double length;
}
- これはstartとendが分かればlengthはわかるからこう書こうよと。
class Line {
Point start;
Point end;
double length() { return start.distanceTo(end); }
}
- 組込みシステムでは
length()
を使う度に計算が走るので本当に軽い処理以外はあまりやらない。 - あと「やりたいことによって適切な言語を選びましょう」なんて話もどっかで見たことある気がするけど、まずその選択肢が少ないんだ、組込みは。
- で、結局C。
- 最初に戻る(組込みシステムは終了しちゃいけないのでw)。
おわりに
何か思い出話を思い出したら随時追加していこうと思います。
別の記事やプロフィールにも書いていますが、近い将来ある程度リソースが潤沢なシステムはRust
で記述するようになるのではないかと考えています。そうなったときにモダン組込みプログラマ
になれるよう、しっかり勉強していこうと思います。実はつい先日(2021/03/08現在)モダンC言語プログラミングという書籍を購入しました。この本を読んでとりあえずモダンC言語プログラマ
にジョブチェンジしようと思いますw
追記です。本記事の反響が割とあったので、組込みって楽しいという記事も書いてみました。
更新履歴
- 2021/03/10:MISRA-Cに従ったコードの例を差し替え
- @fujitanozomuさんと@g667408さんの指摘でMISRA-Cのルールを間違って解釈していたので、例を差し替えました。
- 2021/03/19:本記事のフォロー記事へのリンクを追加