😱

○○を使わなかっただけなのに 〜 新卒一年目で私を悩ませたバグたち 〜

2024/12/04に公開

この記事の位置付け

みなさま、お疲れ様です!非常勤講師の高瀬 (@Guvalif) です。この記事は、大阪ハイテクノロジー専門学校 Advent Calendar 2024 における、4 日目の記事です。

https://qiita.com/advent-calendar/2024/osaka-hightech

突然ですが、みなさんロボットは好きですか?🤖

自分の場合は、ロボット作品などに興味は無いのですが、STEM 教材としての可能性は高く評価しています。そんな背景もあり、新卒で入社した会社では 教育用二足歩行ロボット の開発に携わっていました。ロボット開発というのは ソフトウェア × メカ × 電子回路の総合格闘技 であり、思いもよらぬバグを踏み抜くものです ...

今回は、その中でも思い出深い (悩み深かった) バグをいくつか取り上げ、当時を追体験できればと思います 🔄

Case 1. wait を使わなかっただけなのに

ロボットはコンピュータ × アクチュエータ × センサの物理的構成になることが多く、とりわけ教育用途では開発環境をシンプルに保つためにも、コンピュータとして汎用マイコン (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(); // (*) 書き込み完了まで待機
    }
}

Case 2. uint16_t を使わなかっただけなのに

ロボットに対してなんらかの設定 (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];
}

まとめ

アルゴリズム上は正しく見えても、ハードウェアの都合により思わぬバグを踏み抜く経験は、なかなか辛いものです ... 😇

ですが、プログラミングを通じて現実世界のモノが動く経験 は、それを補って余りあると考えています。制作実習やインターンシップなどを通じて、ぜひ学生時代から経験できると良いのではないでしょうか 💪

脚注
  1. バイナリ列をそのまま流し込める実装も効率的ですが、デバッグのしやすさを考慮しました ↩︎

  2. ペリフェラル利用を考慮しない場合の最大数なので、I2C などを有効化するとさらに減ります ↩︎

  3. 電子回路が簡単かつ安価なため、おもちゃのアクチュエータ制御でもよく用いられたりします ↩︎

GitHubで編集を提案

Discussion