○○を使わなかっただけなのに 〜 新卒一年目で私を悩ませたバグたち 〜
この記事の位置付け
みなさま、お疲れ様です!非常勤講師の高瀬 (@Guvalif) です。この記事は、大阪ハイテクノロジー専門学校 Advent Calendar 2024 における、4 日目の記事です。
突然ですが、みなさんロボットは好きですか?🤖
自分の場合は、ロボット作品などに興味は無いのですが、STEM 教材としての可能性は高く評価しています。そんな背景もあり、新卒で入社した会社では 教育用二足歩行ロボット の開発に携わっていました。ロボット開発というのは ソフトウェア × メカ × 電子回路の総合格闘技 であり、思いもよらぬバグを踏み抜くものです ...
今回は、その中でも思い出深い (悩み深かった) バグをいくつか取り上げ、当時を追体験できればと思います 🔄
wait
を使わなかっただけなのに
Case 1. ロボットはコンピュータ × アクチュエータ × センサの物理的構成になることが多く、とりわけ教育用途では開発環境をシンプルに保つためにも、コンピュータとして汎用マイコン (e.g. AVR / PIC / ARM Cortex など) を選択する場合が大半でしょう。
汎用マイコンは RAM も ROM も貧弱で、自分が用いていた ATmega32U4 の場合だと RAM = 2.5 kB
/ ROM = 32 kB
しかありません 🙄
そのような状況においては 1 byte をいかに削るか? の戦いになるわけですが、アクセス頻度がそこまで高くないデータに関しては、不揮発性の外部 ROM (e.g. EEPROM など) に退避することでメモリ効率を稼げます。
ATmega32U4 にも EEPROM として 1 kB の容量が備わっており、こうした効率化の頼もしい味方となってくれます:
void Config::initialize(const Settings* p_settings)
{
const uint8_t* filler = reinterpret_cast<const uint8_t*>(p_settings);
EEPROM[INIT_FLAG_ADDRESS] = INIT_FLAG_VALUE;
for (uint8_t index = 0; index < sizeof(m_SETTINGS); index++)
{
EEPROM[SETTINGS_HEAD_ADDRESS + index] = filler[index];
}
}
Q. このプログラムは確率的に失敗するのですが、それはなぜでしょうか?
A.
EEPROM の書き込みには高電圧が必要なため、安定して書き込みを行うための待機時間 (ATmega32U4 の場合だと最大 3.4 ms
) が存在する。
もし待機時間を待たずに追加の書き込みを行った場合、データの品質は保証されない ❌ (0xFF
などの不正値が書き込まれうる)
void Config::initialize(const Settings* p_settings)
{
const uint8_t* filler = reinterpret_cast<const uint8_t*>(p_settings);
EEPROM[INIT_FLAG_ADDRESS] = INIT_FLAG_VALUE;
eeprom_busy_wait(); // (*) 書き込み完了まで待機
for (uint8_t index = 0; index < sizeof(m_SETTINGS); index++)
{
EEPROM[SETTINGS_HEAD_ADDRESS + index] = filler[index];
eeprom_busy_wait(); // (*) 書き込み完了まで待機
}
}
uint16_t
を使わなかっただけなのに
Case 2. ロボットに対してなんらかの設定 (e.g. 関節の角度など) を外部から書き込みたいことは多く、そのために 1 〜 4 byte の 16 進文字列を 2 byte の整数に変換する関数[1]を作成していました。
1 〜 4 byte のいずれであっても、最上位 bit が 1
である場合は負数として扱いたく、以下の要領でアルゴリズムを組みました:
int16_t hexbytes2int16_impl(const char* bytes, uint8_t size)
{
//【 Step 1 】
// `[0-9a-zA-Z]{1,4}` という文字列を `uint16_t` に変換する
uint16_t temp = hexbytes2uint16_impl(bytes, size);
//【 Step 2 】
// 最上位 bit を 16 bit 目までズラす
temp <<= (((sizeof(int16_t) * 2) - size) * 4);
//【 Step 3 】
// `int16_t` に変換し、2 の補数表現で負数となるかを判定する
int16_t result = temp;
bool negative = (result < 0); // (*)
//【 Step 4 】
// 最上位 bit を元の位置までズラし、
// 負数判定が `true` であれば上位 bit をすべて `1` で穴埋めする
result >>= (((sizeof(int16_t) * 2) - size) * 4);
if (negative) result |= (0xFFFF << (size * 4)); // (*)
return result;
}
template<const int SIZE>
int16_t hexbytes2int16(const char* bytes)
{
typedef uint8_t SIZE_should_be_4_and_under[(SIZE > 4)? -1 : 1];
return hexbytes2int16_impl(bytes, SIZE);
}
Q. このプログラムにおける `(*)` の行は無くても動作に影響しないのですが、それはなぜでしょうか?
A.
符号なし整数 (uintN_t
) に対する右シフト演算と、符号つき整数 (intN_t
) に対する右シフト演算は、異なるニーモニックが用いられる。
後者は 算術シフト演算 と呼ばれ、最上位 bit が 1
であれば、自動的に上位 bit を 1
で穴埋めしてしまう ❌
int16_t hexbytes2int16_impl(const char* bytes, uint8_t size)
{
int16_t result = hexbytes2uint16_impl(bytes, size);
// (*) 正しくかつ簡潔な実装だが、一見すると意味がわからない ... 🙄
result <<= (((sizeof(int16_t) * 2) - size) * 4);
result >>= (((sizeof(int16_t) * 2) - size) * 4);
return result;
}
++
を使わなかっただけなのに
Case 3. 汎用マイコンでロボットを制御する際に困りがちなことといえば、必要なアクチュエータ数に対して GPIO が不足してしまうことが挙げられるでしょう。とりわけ二足歩行ロボットの場合、最低でも 8 関節以上はアクチュエータを仕込みたいです 🤖
安価なアクチュエータの代表例として PWM 制御のサーボモータ が考えられますが、愚直に ATmega32U4 でこれを制御しようと思うと、PWM 出力に対応する GPIO は 7 つ[2]しか無いため不可能です。
というわけで、次のような工夫をしてみましょう:
比較一致出力のあるタイマ割り込みに対して、デマルチプレクサで PWM 信号の出力先を切り替える[3]ことで、仮想的に PWM 出力に対応する GPIO を増やすことができました 🥳:
ISR(TIMER1_OVF_vect)
{
volatile static uint8_t output_select = 0;
//【 Step 1 】
// デマルチプレクサの出力先を、3 bit 分の GPIO で決定する
digitalWrite(Demultiplexer::SELECT0, bitRead(output_select, 0));
digitalWrite(Demultiplexer::SELECT1, bitRead(output_select, 1));
digitalWrite(Demultiplexer::SELECT2, bitRead(output_select, 2));
//【 Step 2 】
// デマルチプレクサに、所望のサーボモータに対応する PWM 信号を入力する
PWM_OUT_00_07_REGISTER = m_pwms[output_select];
//【 Step 3 】
// サイクリックに `output_select` をインクリメントする
(++output_select) &= (Demultiplexer::SELECTABLE_LINES - 1);
}
Q. このプログラムでは意図した PWM 制御ができないのですが、それはなぜでしょうか?
A.
HIGH
or LOW
の出力は即時に可能であるため、デマルチプレクサの出力先切り替えは意図した通りに動作する。
対してタイマの比較一致出力は、出力信号の乱れを防ぐためにダブルバッファリングされる ことが多い。そのため、デマルチプレクサの出力先を切り替えるに先立って、比較一致の値を 1 つ先読みしておく必要がある ❌
ISR(TIMER1_OVF_vect)
{
volatile static uint8_t output_select = 0;
digitalWrite(Demultiplexer::SELECT0, bitRead(output_select, 0));
digitalWrite(Demultiplexer::SELECT1, bitRead(output_select, 1));
digitalWrite(Demultiplexer::SELECT2, bitRead(output_select, 2));
// (*) `m_pwms` に対して `output_select` が先読みされるようにインクリメントする
(++output_select) &= (Demultiplexer::SELECTABLE_LINES - 1);
PWM_OUT_00_07_REGISTER = m_pwms[output_select];
}
まとめ
アルゴリズム上は正しく見えても、ハードウェアの都合により思わぬバグを踏み抜く経験は、なかなか辛いものです ... 😇
ですが、プログラミングを通じて現実世界のモノが動く経験 は、それを補って余りあると考えています。制作実習やインターンシップなどを通じて、ぜひ学生時代から経験できると良いのではないでしょうか 💪
Discussion