編集履歴一覧に戻る
asknのアイコン画像

askn が 2026年10月04日22時17分15秒 に編集

初版

タイトルの変更

+

[modernAVR] AVR Dx シリーズで効率よく NeoPixel を制御する話

タグの変更

+

AVR-DU

+

Neopixel

メイン画像の変更

メイン画像が設定されました

記事種類の変更

+

セットアップや使用方法

Lチカの変更

Lチカが設定されました

ライセンスの変更

+

(MIT) The MIT License

本文の変更

+

## 概説 "汎用ライブラリだから"ということもあるが、Arduino用の NeoPixelライブラリは結構脳筋な実装になっている。何種類かのコードを読んでみたが、大概は約 300nsから 800nsの GPIOパルスを生成するのに NOPループを使用しているのだ。自ずと MPUの動作周波数によって必要な NOP数は異なるため、ソースコードはかなり荒れた記述になっている。なので例えば 16x64 素子の電光板フルアニメーション表示などともなれば満足な fps を稼げない。まあ USARTや SPIや タイマーといったハードウェア資源を自由に使えない汎用的な状況を想定すれば、必然的にそうなるのも理解はできる。 だけどさ、逆にハードウェア資源を無制限に使って良いのなら、もっと良い実装があるワケですよ。実際に Corte-X 用や ESP32 用の実装はハードウェア資源が潤沢なこともあって、8192素子 30fps程度のスループットは普通に絞り出せる実装が多々ある。それでいて CPU負荷は低いので、アニメーションフレーム制御にも無理なく対応できる。 そこで、ここでは AVR Dx シリーズでならどういう NeoPixel 低レベル実装が可能かというのを考えてみる。それが実用的かどうかはまでは考慮しない。趣味だから。 ## NeoPixel プロトコル これは NeoPixel 1素子当たり GRB各8ビットの色情報を連ねた 24ビットの PWMパルスから成る。これを直列に連結された素子数ぶん連続して送信し、最後に一定の沈黙時間を設けることで 1フレームぶんの通信が確定する。1ビットの周期は標準値 1250ns なので公称値は 800kbps だ。ただしマージン幅は ±15% あるので、最短 950ns 周期(1052kbps)まで高速化できる。ビットデータは Duty比によって判別され、約1:2 なら `0`、約2:1 なら `1` と解釈される。 ![](https://camo.elchika.com/3e4ae2f0c81bfc3e93843a7dc388043ee5b8325b/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f30363063643130392d313965382d343066362d383034362d3764346632396136643761362f39303239633930372d623065342d346664612d386661612d666563383865623235623936/) 少し難があるのは 沈黙時間 = Treset の長さで、これが初期製品では 50us以上、10年くらい前からの現行製品では 280us以上 になっている。この 50us というのは低廉低速なマイコンには結構厳しい値で、複数素子の連続制御ができない〜次のデータ送信準備が間に合わない〜という問題として現れる。このためスループット性能低下と引き換えで 280us にデチューンされるまでは高性能マイコンでないと思ったように扱えないという状況だったようだ。メーカーに仕様変更を迫るとはげに恐ろしき Adafruit。 ### Bit-Banding の場合 NOPループによる "Bit-Banding" 制御では、1素子当たり 30us 中 48回(各ビット毎に2回ずつ)の GPIO 書き換えが必要だ。しかもその度々にビット操作や条件分岐が必要になる。必然的にこの間の割り込みは全面禁止だ。各素子制御の間には割り込みを許可しても良いが、その時間的余裕は 初期製品では(15%のマージンを見込んで)42us 未満、現行製品では 238us 未満で完了しなければならない。そりゃあキツイ。 ### SPI 制御の場合 1ビット分の波形を、1回の SPI 操作で生成するケースを考える。つまり x8 SPIクロックにストレッチして、`1 == 0x11111000`、`0 == 0x11100000` を SPIデータレジスタに書く方式。これだと GPIO に比べてハードウェアレジスタ書き込み回数は半減するし、NOPループも必要ない。CPU負荷はざっくり半分以下になる。ビット操作やレジスタ書き込みには相変わらず 1ビット(1.25us)間隔という制限はあるものの、割り込み処理中に頑張れなくもない。 だが、割り込み云々以前に SPIクロックを生成するための "分周比の選択肢が少ない" という制約が、このケースでは問題になる。AVR 系統では CPUクロックに対して `2,4,8,16,32,64,128` の 7通りしか分周比を選べないのが普通なので、逆算すると 800 kbps ちょうどを達成するには CPUクロック=12.8 MHz でなければならない。16 MHz では 1 Mbps 固定になる。なので Arduino汎用ライブラリでこの実装方法があまり見られないのは当然だろう。 ### USART-MSPI 制御の場合 USART 周辺機能を MSPI 動作モードに変更して活用すると、上記の制約はいくらか緩和される。というのも分周比を `2,4,6,8,10,12...2048` という偶数倍系列の中から自由に選択できるからだ。この場合 20 MHz で 833.33 kbps という選択肢も選べるようになる。もっとも SPIストレッチ方式である以上、ビット演算から逃れられないので CPU負荷の低減はやはり見込めない。貴重な USART 周辺機能を使う割には報われない。 ### CCL で PWM 切り替え制御 ここからが本題。modernAVR 世代では CCL での波形制御ができる。そこで SPI(USART-MSPI)出力の `1` と `0` の各ビットで直接、T1HL か T0HL それぞれの PWM 波形を選択して出力してしまうことを考える。CCLは一度動き出したらもう何も CPUから指示すべきことはないので、それができれば 8ビット分のデータを直接 1回の データレジスタ書き込みで済むだけになり、レジスタ書き込み間隔は 10us(1 Mbps なら 8us)に広がる。Bit-Banding に比べたら実に 1/48 しかレジスタ書換がない。割り込み動作にしても、前述までの実装では不可避だったビットシフト演算なしで、静的に用意されたバッファを単にポインタで 1バイトずつ舐めれば良いだけのため、8us未満間隔でも無理がない。 ![](https://camo.elchika.com/ebd2aaed0b0b1888c32a03e8a7f7a3a221b11354/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f30363063643130392d313965382d343066362d383034362d3764346632396136643761362f32643464643766312d393831642d343530372d393837322d306364323934333939336130/) 1サイクル当たりの タイミングチャート例は上図の通り。この場合 XCK(SCK)にはデータ送信中のみ SPIクロックが出力され、かつ必ず 8の倍数だけのパルスが、TXD(MOSI)データビットと共に出現する。さもなければデータビットはアイドル状態なので不定値だ。そこで XCK の上昇端を起点に、TXD に応じて T1HLか T0HL を選択し、OUT に波形を出力する。これが LOW に変化したら 1ビットぶん終了で、次に XCK の上昇端が観測されるまで OUT は LOW に留めるようにする。 この方式の問題点は、使い回しの効く汎用的な応用コードを書けないことだ。つまりライブラリ化が難しい。実装対象 MPU を絞り、CCL の仕様を調べ、そのハードウェアに密着した出力先やコーディングを調整する必要がある。 ## ハードウェア設定例 以下の応用記述は、AVR DU 系統向けに書かれている。使われるハードウェア資源は TCA0、USART0、CCL_LUT2、CCL_LUT3 および EVSYS_CHANNEL0 だ。他の AVR Dx 系統でも基本的にはそのまま使える。AVR DU 基準で書いているのは USB-HID や USB-CDC と直接連携できて、Firmata プロトコル等と組み合わせれば ホストPC から直接 LED 制御するのが容易だからに過ぎない。 ### タイミング設定 最初に NeoPixel の制御パラメーターを決めておく。また前提として `F_CPU` は 12,16,20,24 MHz のいずれかとする。これは AVR DU 系統の USB 周辺機能が正常動作する内臓発振器の動作速度が、この 4種類に限られるためだ。外部発振器を使用する場合は微調整が必要かもしれない。AVR DU系統以外なら(あるいは USB周辺機能と併用しないなら)最低動作速度は 8 MHz(1us あたり 8 CPUクロック)である。 ```c #define NP_BT (-1) #define NP_1US (F_CPU / 1e6) #define NP_1LP ((uint8_t)(NP_1US * 1.25)) #define NP_T0 ((uint8_t)(NP_1LP * 0.30)) #define NP_T1 ((uint8_t)(NP_1LP * 0.65)) #define NP_RT ((uint16_t)(NP_1US * 280 + NP_1LP)) #define NP_BAUD (((uint16_t)(NP_1LP * 32)) & ~1) ``` これらのうち USART動作クロック分周比を示す `NP_BAUD` は必ず 64の倍数に制限する。これはハードウェア仕様で要求される絶対条件なので譲れない。そしてこの値は PWM 8ビット周期につき 8 USARTクロック(=16 CPUクロック)が生成される必要がある。 ### TCA0 の設定 16ビット精度単一傾斜動作で使用する TCA0 は、T1HL および T0HL の "最初の" 1ビット分のパルスを、それぞれ TCA0_WO1(CMP1一致)、TCA0_WO2(CMP2一致)信号として吐く。全体の周期を決める TCA0_PER レジスタには最大値を、TCA0_CMP0 レジスタには「Treset + 1ビット」時間に相当するカウント数(`NP_RT`)を与える。 ```c TCA0_SINGLE_CTRLD = 0; TCA0_SINGLE_PER = ~0; TCA0_SINGLE_CMP0 = NP_RT + NP_BT; TCA0_SINGLE_CMP1 = NP_T0 + NP_BT; TCA0_SINGLE_CMP2 = NP_T1 + NP_BT; TCA0_SINGLE_EVCTRL = TCA_SINGLE_CNTBEI_bm | TCA_SINGLE_EVACTB_RESTART_POSEDGE_gc; TCA0_SINGLE_CTRLB = TCA_SINGLE_CMP1EN_bm | TCA_SINGLE_CMP2EN_bm | TCA_SINGLE_WGMODE_SINGLESLOPE_gc; TCA0_SINGLE_CTRLA = TCA_SINGLE_ENABLE_bm | TCA_SINGLE_CLKSEL_DIV1_gc; ``` そして TCA0_EVCTRL レジスタには EVSYS 事象網から "パルス上昇端で TCA0 を再起動" するイベントを受け取れるように設定する。ここに XCK パルスを流すと 8個セットのパルスが生成され、流れてこなければ Treset 沈黙時間のあいだ、なにも生成されないことが保証される仕組みになる。 > TCA には 8ビット精度(分割PWM)モードがあるが、ここでは使えない。事象イベントによるタイマー再起動がサポートされないためだ。 ### USART0 の設定 USART0は、主装置 SPI動作モードで使用し、かつ GPIO 直接出力をしないように PORTMUX を設定する。RXD は使用しない。出力される TXD と XCK 信号は EVSYS および CCL_LUT にのみ伝達される。また XCK は上昇端先行で使用するため、UCPHA ビットを設定する。 ```c PORTMUX_USARTROUTEA |= PORTMUX_USART0_NONE_gc; /* USART0 is not connected */ USART0_BAUD = NP_BAUD; USART0_CTRLC = USART_UCPHA_bm | USART_CMODE_MSPI_gc; USART0_CTRLB = USART_TXEN_bm; USART0_TXDATAL = 0; loop_until_bit_is_set(USART0_STATUS, USART_TXCIF_bp); ``` USART0_BAUD には CPUクロックに対する分周比 `NP_BAUD` を直接与える。これは下位 6ビットを使用できないので、設定値は必ず 64の倍数だ。上位 10ビットが `1024,512...4,2` の重みに対応しており、2から 2046 の分周比が設定できる。 > 例外的に全部ゼロだと 2048分周になるが、これはデータシートに書かれていない保証外動作だ。 > 8 MHz 動作の場合、1ビットあたりの 1.25us は x10 CPUクロックとなる。なので USART 分周比はその半分の 5(の 64倍の 320)を設定する。そして T1HL には x6、T0HL には x3 CPUクロックを割り当てれば、マージン幅の範囲で NeoPixel 通信が成立する。 最後に、USART0 を使用可能にしたらまずダミーデータを 1バイト送信し、その完了を TXCIF フラグが立つまで待つ。これによって TCA0 が沈黙時間動作に入ったことを保証し、余分なノイズが CCL_LUT に渡って GPIO に現れないことを確実にする。 ### EVSYS の設定 USART0 の XCK 信号は、EVSYS でふたつの経路に分配する。ひとつは TCA0 再起動に使われ、ひとつは CCL_LUT2 に送って、データラッチに使われる。これらは CCL_LUT 設定以前に済ませておかないと、余分なノイズが伝播してしまう。 ```c EVSYS_CHANNEL0 = EVSYS_CHANNEL0_USART0_XCK_gc; EVSYS_USERTCA0CNTB = EVSYS_USER_CHANNEL0_gc; /* -> TCA0_RESTART */ EVSYS_USERCCLLUT2A = EVSYS_USER_CHANNEL0_gc; /* -> LUT2_EVENTA */ // PORTMUX_CCLROUTEA = PORTMUX_LUT2_ALT1_gc; /* LUT2_OUT -> PD6(14p) */ ``` CCL_LUT2 の OUT 信号は、既定では PD3 に出力する。しかしこれは 14ピン外囲器には存在しないピンなので、ここで PD6 に代替設定しても良い。 ### CCL_LUT3 の設定 CCL_LUT3 は、TCA0 の出力するふたつの波形の一方を、TXD 信号レベルに応じて選択出力する ”セレクター” として使われる。 ```c /* TRUTH : _ _ 2 6 _ _ 5 7 */ /* 2:WO2 : 0 1 0 1 0 0 1 1 */ /* 1:WO1 : 0 0 1 1 0 1 0 1 */ /* 0:SEL : 0 0 0 0 1 1 1 1 <- TXD */ CCL_TRUTH3 = CCL_TRUTH_2_bm | CCL_TRUTH_5_bm | CCL_TRUTH_6_bm | CCL_TRUTH_7_bm; CCL_LUT3CTRLC = CCL_INSEL2_TCA0_gc; /* <- TCA0_WO2 */ CCL_LUT3CTRLB = CCL_INSEL1_TCA0_gc /* <- TCA0_WO1 */ | CCL_INSEL0_USART0_gc; /* <- USART_TXD */ CCL_LUT3CTRLA = CCL_ENABLE_bm; ``` ### CCL_LUT2 の設定 CCL_LUT2 は、XCK 信号パルスに応じて、実際の OUT 出力ピンに CCL_LUT3 で選択した PWM 波形を送信する。このとき自分自身の出力を帰還入力としてフィードバックするように構成し、これを 1ビットラッチ(フリップフロップ)として扱う。このラッチは XCK が HIGH でセット、PWM が LOW でリセットされるので、XCK が変化しない間は PWM 入力を無視して、OUT 出力は沈黙し続ける。 ```c /* TRUTH : 3 7 6 _ _ _ _ _ */ /* 2:FDB : 0 1 1 1 1 0 0 0 <- LUT2_OUT */ /* 1:PWM : 1 1 1 0 0 0 1 0 <- LUT3_OUT */ /* 0:XCK : 1 1 0 1 0 1 0 0 <- USART_XCK */ CCL_TRUTH2 = CCL_TRUTH_3_bm | CCL_TRUTH_6_bm | CCL_TRUTH_7_bm; CCL_LUT2CTRLC = CCL_INSEL2_FEEDBACK_gc; /* <- FEEDBACK */ CCL_LUT2CTRLB = CCL_INSEL1_LINK_gc /* <- CCL_LUT3 */ | CCL_INSEL0_EVENTA_gc; /* <- USART_XCK */ CCL_LUT2CTRLA = CCL_ENABLE_bm | CCL_OUTEN_bm; /* -> PD3 */ /* CCL_LUT 活性化 */ CCL_CTRLA = CCL_ENABLE_bm; ``` ## NeoPixel データ送信 ここまでの準備が終わると、NeoPixel 素子にデータを送信するできるようになる。することは単純で、USART_TXDATA レジスタが空であることを USART_DREIF フラグで確認してから、8ビットデータ塊を書くだけだ。これを3回繰り返すと 1素子ぶんのデータ送信が終わる。 ```c void np_send (const uint8_t _data) { loop_until_bit_is_set(USART0_STATUS, USART_DREIF_bp); USART0_TXDATAL = _data; } void np_pixel (const uint8_t _g, const uint8_t _r, const uint8_t _b) { np_send(_g); np_send(_r); np_send(_b); } ``` > USART_DREIF フラグは USART0_TXDATA に何かを書くと同時に連動してクリアされる。 この USART_DREIF フラグ検査は、ポーリングに代えて USART0_DRE 割込に置き換えてもよい。その場合は全素子全ピクセルぶんのバッファを用意して、割り込み 1回毎に 1バイトずつデータを流すように実装する。当然この処理には 1.25us 以上かけるべきではない。 ## NeoPixel Treset 待機 素子数 x GRB 3バイトの連続送信を終えたら、Treset 待機時間の経過を待たなければならない。これは TCA_CMP0 フラグを確認することで達成できる。 ```c /* 最後の素子への np_send 直後に実行 */ void np_space (void) { bit_set(TCA0_SINGLE_INTFLAGS, TCA_SINGLE_CMP0_bp); loop_until_bit_is_set(TCA0_SINGLE_INTFLAGS, TCA_SINGLE_CMP0_bp); } ``` > TCA_CMP0 フラグは自動ではクリアされないので、それがセットされる前のタイミングでクリアする(*1を書く*)必要がある。 この TCA_CMP0 フラグ検査は、ポーリングに代えて TCA0_CMP0 割込に置き換えることができる。もっとも大抵はその必要もないだろう。280us待っている間に、次のフレームデータをバッファに用意して、それを終えたら Treset 待機時間に達しているか調べれば済むからだ。それだけの時間があれば 1024ピクセルぶん 3072バイトのデータを展開/書き換えするのはそう難しくはない。SRAM に余裕があるならダブルバッファにすることで(30fps目標なら)1フレーム 33.33ms − 割込オーバーヘッドの CPU時間をまるまるフレーム準備に費やせる。その実装だと Treset = 50us の旧型製品でも十分対応できる。 ## まとめ ==Bit-Banding実装に比べ、USART+TCA+CCL実装では CPU負荷を 1/48 に抑え、かつ割り込み処理を実装できるだけの余裕を持たせることができる。引き換えに、ハードウェア資源の設定には綿密な計画が必要になる。== ++NeoPixel 素子は起動時に結構な突入電流を伴うので、VDD投入タイミングには注意が必要だ。Curiosity Nano のようにインテリジェントな過電流保護機能付きの USB接続開発ボードでは、VDDが確実に 5Vに達するまで NeoPixel 素子に通電しないようにしないと、正常に動作しない。つまり NeoPixel の VDD線には、独立した電源スイッチ(や遅延通電回路)を設けた方が良い。++ ++elchikaの PlantUML 対応は結構古いままなので、タイミングチャート図をプレビューで描くことができなかった。ここでは仕方がないので別に作図したタイミングチャート図を画像化して貼り付けている。++