質問やフィードバックがありましたら、フォームからお願いします
本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください
樹莓派財団は2021年初頭にマイクロコントローラRaspberry Pi Picoを発表しました。当時は新しいコンセプトに興味を持ち、樹莓派にはこれまでなかった製品ラインだと感じましたが、一枚購入した後はあまり深く調べることはありませんでした。
じっくりと見てみると、実はスペックが非常に良いことに気づきました:
Raspberry Pi pico | スペック | Arduino nano |
---|---|---|
ARM M0+ デュアルコア | MCU | ATmega328p |
133MHz | クロック周波数 | 16MHz |
32-bit | bit | 8-bit |
264kB | SRAM | 2.5kB |
2MB (RP2040 本体にフラッシュなし) | Flash | 32kB |
UARTx2 USBホスト 1.1 SPI タイマー RTC | 周辺機器 | UARTx1 SPI タイマー |
デュアルコア、32bit、264kBのメモリ、2MBのフラッシュメモリを搭載しており、Arduino Nanoと比較すると非常に優れています。さらには、一部のSTM32よりも性能が上回ることもあります。価格も非常に手頃で、100元強で手に入ります。C/C++に加えてMicroPythonもサポートしており、GDBを通じてメモリや変数の状態を観察できるデバッグ機能も提供されています。
私が驚いたもう一つの機能はPIO、Programmable I/Oです。しかし、紹介に進む前に、まずはGPIOとPIOが解決しようとしている問題についてお話ししましょう。
GPIOとは?
GPIO(General Purpose Input/Output)は、マイクロコントローラの中でピンの出力または入力を制御する機能を持ち、プログラムを通じて特定のピンの出力を高電位または低電位に設定できます。
最もシンプルな例としてLEDを使って説明します。LEDの一つの端子をグラウンドに接続し、もう一方をGPIOピンに接続して、プログラムを通じて出力の電位を制御することで、LEDを点滅させることができます。LEDの点滅を制御するだけでなく、GPIOはI2CやUARTなどのデータ通信にも使用されます。
周辺機器
マイクロコントローラが外部デバイスと通信できるように、一般的な通信プロトコルを内蔵していることが多いです。例えば、ArduinoではUARTがサポートされています。Pro Microを使用すると、AVRチップATmega32U4にはUSB機能も内蔵されています。
しかし、デメリットは、マイクロコントローラにこれらの通信プロトコル機能が内蔵されていない場合、開発者は相応しいICを購入して実装する必要があり、そうでなければGPIOピンを使って自分で通信プロトコルを実装する必要があります。この概念はハードウェアデコードとソフトウェアデコードの違いに似ています。
例えば、ArduinoではSoftwareSerialを使用してソフトウェアレベルでUARTプロトコルを実現できます。私が昨年書いたArduinoの二酸化炭素センサーの実装1(https://blog.kalan.dev/2020-07-24-arduino-esp32-co2-sensor-2/)でも使用しました:
// https://github.com/kjj6198/MH-Z14A-arduino/blob/master/co2.ino#L14
...
SoftwareSerial co2Serial(3, 4); // RX, TX
co2Serial.write(commands, 9); // コマンドを送信
co2Serial.readBytes(response, 9);
SoftwareSerialの実装の裏にはGPIOピンを使用してUARTプロトコルを実現する仕組みがあります。SoftwareSerialを使用する利点は、ArduinoのネイティブUARTがコンピュータと簡単に通信できることです。これにより、Arduinoと他の外部デバイスとの通信が可能になります。
データ通信は正確なタイミング制御に依存
ハードウェアのデータ通信では、非常にタイミングが重要で、CPUのサイクルを正確に計算する必要があります。SoftwareSerialの実装の中で:
void SoftwareSerial::begin(long speed)
{
// 略
// さまざまな遅延を前もって計算
uint16_t bit_delay = (F_CPU / speed) / 4;
// スタートビットから最初のビットまでのサイクル数
// ビット間のサイクル数
// 最後のビットからストップビットまでのサイクル数
// これらはほぼ同じで、ビット間のタイミングが最も重要です
_tx_delay = subtract_cap(bit_delay, 15 / 4);
// RXの設定はPCINTがこのピンで有効な場合のみ
if (digitalPinToPCICR((int8_t)_receivePin)) {
#if GCC_VERSION > 40800
// タイミングはgcc 4.8.2の出力からカウント。
// これは16Mhzで115200まで、8Mhzで57600まで動作。
...
tunedDelay(_tx_delay); // もし低かったら、これが終点を確立
}
...
}
コードは少ないですが、正確なタイミングを計算するために、各gccバージョンのCPUサイクル数を計算し、遅延を行っています。データ通信においてタイミングがいかに重要かがわかります。タイマーや割り込みメカニズムを使用して実装することも可能ですが、ハードウェアタイマーの数は限られています。
ビットバンキング
プログラムコードでデータ通信プロトコルを実装するのは便利ですが、その反面、こうした通信はプロセッサのリソースを多く消費します。通信の頻度が高くなるほど、プロセッサはタイミング計算により多くのリソースを消費することになります。したがって、正確なタイミングの出力が必要な場合や、プロセッサが通信プロトコルに過剰なリソースを消費しないようにしたい場合は、PIOを利用して達成することができます。
PIO(Programmable GPIO)
概要
先ほども述べたように、通信プロトコルが要求するタイミングがプロセッサのリソースを消耗する問題を解決します。PIOは、プロセッサのリソースを消費することなく、プロセッサと同じ周波数(133MHz)で要件を満たすことができます。PIOをGPIOの中にある小さなプロセッサと考えることができ、この小さなプロセッサはメインプロセッサのリソースを占有せず、GPIO用に特別に設計されており、FIFOやIRQを通じてメインプロセッサと通信することができます。
RP2040には2つのPIOブロックがあり、各ブロックには4つのステートマシンがあります。各ステートマシンはプログラムを再設定でき、動的な期間に異なる通信インターフェースを実装できます。
PIOは簡略化されたアセンブリ言語を提供します。合計9つの命令と2つのレジスタがあり、最大で32の命令を実行できます。見た目は非常にシンプルですが、これだけの機能があればほとんどの通信プロトコルの要件を満たすことができます。
(画像はRP2040のデータシートから)
この画像から、4つのステートマシンが同じプログラムコードを共有し、命令メモリには4つの読み取りポートがあるため、各ステートマシンが同時にコードにアクセスできてブロッキングが発生しないことがわかります。
ステートマシンの紹介
各PIOブロックには4つのステートマシンがあり、同じプログラムメモリを共有していますが、各ステートマシンは異なるGPIOピンに設定することができます。例えば、UARTを実装した場合、4つのステートマシンを使用して最大4つの完全に独立したUARTを設定できます。
ステートマシンは以下の部分で構成されています:
- OSR(出力シフトレジスタ):32ビット、メインプロセッサからFIFOを通じてデータを受け取ります
- ISR(入力シフトレジスタ):32ビット、FIFOを通じてデータをメインプロセッサに送ります
- X、Yレジスタ:各ステートマシンには2つの汎用レジスタがあります
- PC:プログラムカウンタ
- クロックディバイダ:各ステートマシンはメインプロセッサの周波数に達することができ、ほとんどの通信プロトコルには速すぎます。クロックディバイダを使用して周波数を調整できます。(範囲は1〜65536)
- プログラムコード
IOマッピング
IOマッピングは他のマイクロコントローラよりもやや複雑で、最初は少し混乱しますが、一度理解すればこの設計が非常に理にかなっていることがわかります。各IOは4つの状態を持つことができます:input、output、set、sideset。
- input:外部センサーや外部デバイスのデータを読み取ることができます(Arduinoの
digitalRead
に似ています) - output:プログラムを通じて電位を制御できます(Arduinoの
digitalWrite
に似ています) - set:ピンの電位を設定できます(outputに似ていますが、いくつかの違いがあります)
- sideset:命令を実行しながら、他のピンの電位または方向を変更できます
ここで、setとsidesetは理解しにくい部分かもしれませんが、後で詳しく説明します。同じGPIOが同時に複数の状態を持つことができ、例えば1つのGPIOをinputとして設定しながらoutputとしても設定することができます。
各IOマッピングの設定方法は、ベースピンとピンカウントを使用して実現できます。例えば、GPIO0とGPIO1をSETとして設定したい場合、ベースピンをGPIO0に、カウントを2に設定します。ここから、各状態のピンは連続していることがわかります。つまり、OUTPUTピンがGPIO0、GPIO3、GPIO5のように不連続になることはありません。
INPUTとOUTPUTは最大で32ピンをサポートし、Pico上では30ピンしかありません。setとsidesetは最大で5ピンのサポートです。
要約すると、IOマッピングにはいくつかの特徴があります:
- 同じピンが同時に複数の状態を持つことができます。例えば、setとoutputの両方です。
- inputとoutputは最大で32ピンをサポート。setとsidesetは最大で5ピンをサポート。
- ピンは連続している必要があります。例えば、GPIO0〜GPIO3のように。
IRQ(割り込み要求)
IRQフラグを使用して割り込みをトリガーしたり、ステートマシン間の状態を同期させたりすることができます。
PIOアセンブリ言語の紹介
PIOはシンプルで強力なアセンブリ言語を提供し、合計9つの命令があります。これらは次の通りです:
- SET
- IN
- OUT
- PULL
- PUSH
- JMP
- WAIT
- MOV
- IRQ
基本的に書き方は一般的なアセンブリ言語と同じですが、PIOアセンブリ言語にはいくつかの変数を覚えておく必要があります:
- pins:このPIOが選択したピンを表します。例えば、GPIO0から始まる場合、pin0はGPIO0になります;GPIO2から始まる場合、pin0はGPIO2になります。
- pindirs:ピンの方向を設定します。0は
input
、1はoutput
です。 - X、Y:レジスタ
- osr:output shift register
- isr:input shift register
- data:最大5ビットの即時値、つまり0〜32です。
レジスタもありjmpもあるので、チューリング完全性の基本要件が満たされています。理論的にはPIOを使用して加減乗除の計算を行うことも可能ですが、PIOの設計は本来計算用ではありませんので、あくまで実験的に楽しむことができます。
待機機能(delay)
正確なタイミング制御を実現するために、命令メモリを浪費することなく、PIOは非常に便利な機能を提供します。命令を実行する際に、後ろに[]
を追加して遅延するサイクル数を指定することができます。例えば:
loop:
set pins, 1 [1] ; setは1サイクル必要、さらに1サイクル待機
set pins, 0
jmp loop
これは次のように効果があります:
loop:
set pins, 1
nop
set pins, 0
jmp loop
これにより、ピンを高電位で2サイクル、低電位で2サイクル維持することができ、nop
を挿入してサイクルを調整する必要はありません。[]
内の数字の範囲も1〜32です。
サイドセット
命令を実行する同時にサイドセットピンの電位を設定できます。プログラム内で使用するピンの数を明示的に宣言する必要があります:
.side_set 1
これは1つのピンをサイドセットとして使用することを意味します。最大で5ピンを設定でき、他のマッピング(input、outputなど)と重複させることができます。
状態遷移を行う際に非常に便利です。例えば、UARTのアイドル状態では高電位を維持し、スタートビットでは低電位になります。データを引き始めると同時に電位を変更することができます:(例はpico-examples/uart_tx.pio
からの参照です)
.program uart_tx
.side_set 1
pull side 1 [7]
set x, 7 side 0 [7]
loop:
out pins, 1
jmp x-- bitloop [6]
この例では、pull
を呼び出すたびにサイドセットピンが1に設定され、ストップビットとして機能します。xレジスタを設定する際にも、サイドセットを直接0に設定してスタートビットとして利用できます。これにより、別のサイクルと命令を消費することなく、非常に便利です。
ここでは、各ビットに必要なサイクル数として8を使用しており、1ビット内で最大8命令を実行できます。未使用のサイクルが残っている場合でも、delay機能を使用して正確なタイミングを簡単に達成できます。
特に注意すべき点は、side-set
を使用すると、delayで使用できるサイクル数が減少することです。例えば:
.side_set 3 ; 2つのピンをside_setとして設定
set x, 1 side 0 [3] ; 3ビットはすでにside_setに使われているため、delayは最大2ビット(5-3)、つまり1〜3になります。
SET
データを宛先に放置します。宛先がpinsの場合、SETとして設定されたピンにデータが配置されます。
set X, 30 ; Xを30に設定
set pins, 1 ; SETピンを1に設定
set pins, 5 ; SETピンを5に設定(2進数で0b101になります)
IN
ソースからビットを右または左にシフトします。例えば:
in osr, 1
これはOSRの内容を1ビットシフトしてISRに配置します。または:
in pins, 4
これはinput pinsから4ビットのデータを読み取り、ISRに配置します。
OUT
OSRのデータをシフトして宛先に配置します。例えば:
out pins, 2
これはOSRから2ビットを取得して出力ピンに送ります。
PULL
Tx FIFOから32ビットをOSRに読み取ります。引数を指定しない場合、デフォルトはnoblock
で、プログラムはTxにデータが入るまで待機し続けます(32ビット満杯でなくても問題ありません)。例えば:
loop:
pull ; Txにデータがあるまで次の命令に進まない
out pins ; OSRのデータを出力ピンに送信
jmp loop
他にもいくつかの引数を設定できます:
ifempty
:OSRが空のときのみ実行し、そうでなければ何もしないblock
:Txにデータがないときに待機し続けるnoblock
:レジスタXのデータをOSRに配置し、結果はMOV OSR, X
と同じです。
UARTのようなプロトコルでは、データ伝送がないときは常にスタートビットを待機し、データを受信後に後続の処理を始めます。この場合、block
を使用することで簡単にその効果を達成できます。
PUSH
ISRのデータをRx FIFOに格納し、ISRを0にクリアします。
iffull
:ISRが満杯のときのみ実行し、そうでなければ何もしないblock
:Rxデータが満杯のときに待機し続ける
JMP
条件が成立した場合、指定されたアドレスにジャンプします。PIOのjmpは異なる条件を設定できます:
-
条件なし:条件が指定されていない場合は
always
-
!X
:X=0
のときにジャンプ -
X--
:X=0
のときにジャンプし、実行後にX - 1
されます -
!Y
:Y=0
のときにジャンプ -
Y--
:Y=0
のときにジャンプし、実行後にY - 1
されます -
X!=Y
:X!=Y
のときにジャンプ -
PIN
:入力ピンの電位に基づいてジャンプします。(sm_config_set_jmp_pin
関数で設定が必要)- 高電位のときにジャンプ
- 低電位のときにジャンプしない
-
!OSRE
:OSRが空のときにジャンプ
loop:
set x, 30 ; xを設定
jmp x-- loop; xが0でない間、loopにジャンプし、同時にx-1
WAIT
conditionが真になるまで待機します。いくつかの特別な引数があります:
- GPIO:インデックスから対応するGPIOピンを選択します。この点はステートマシンのIOマッピングとは関係ありません(絶対的です)。
- PIN:インデックスから対応する入力ピンを選択します(ステートマシンのIOマッピング)。
- IRQ:指定されたインデックスのIRQフラグがポラリティになるまで次の行を実行しません。
wait 1 pin 0 ; input pin0が1になるまで次の命令を実行しません
wait 0 gpio 1 ; GPIO1が0になるまで次の命令を実行しません
MOV
ソースからデータを宛先にコピーします。助ける特別な構文が2つあります:
!
または~
:コピー時にビット単位のNOTを適用::
:ビットを反転してからコピーします
この設計は命令空間を節約するためです。
mov X, Y ; YをXにコピー
mov pins, X ; Xをpinsにコピー
mov pins, ::X ; Xのビットを反転してからpinsにコピー
IRQ
IRQフラグを設定またはクリアします。IRQフラグが設定されると、メインプログラムの設定に従って割り込みがトリガーされるかどうかが決まります。
irq wait 1 rel
使用できる引数には:
- wait:フラグがクリアされるまで実行を待機します
- nowait:フラグがクリアされなくても実行を続けます
- clear:IRQフラグをクリアします
引数がない場合、デフォルトはnowait
であり、フラグがクリアされるのを待たずに続行します。rel
を追加すると、最後の1ビットと現在のステートマシンのインデックスを使用してmod 4されます。これにより、メインプログラムが異なるステートマシンを処理することができます。
プログラムコードの実行位置の指定
ステートマシンが停止していない場合、ステートマシンはプログラムコードを実行した後、先頭に戻って繰り返し実行しますが、特定のラベルを追加することで、PIOにどこから実行を開始し、どこで終了するかを指定できます:
set x, 8
.wrap_target
set pins, 1
set pins, 0
.wrap
.wrap_target
と.wrap
で囲むことで、ステートマシンが繰り返し実行する部分を指定できるため、jmpを一つ少なくすることができます。
メインコードとPIOの統合(C/C++)
pico-examplesには多くのpioのサンプルがあり、ここではシンプルなblink機能を例にとります。2
まず、pioプログラムコードを.pio
という拡張子で作成します:
.program blink
.wrap_target
set pins, 1
nop [19]
nop [19]
set pins, 0
nop [19]
nop [19]
.wrap
このプログラムは非常にシンプルで、setを使用してピンを1に出力し、40サイクル待機してから0に設定します。これにより、LEDが点滅する効果が得られます。次に、初期化のコードを書きます。公式ドキュメントでは直接pio内での記述を推奨しています:
.program blink
.wrap_target
set pins, 1
nop [19]
nop [19]
set pins, 0
nop [19]
nop [19]
.wrap
% c-sdk {
void blink_program_init(PIO pio, uint sm, uint offset, uint pin, float div) {
pio_sm_config c = blink_program_get_default_config(offset);
pio_gpio_init(pio, pin);
pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true); // この行は加えなくても動作します。本例ではset pinのみ使用。
sm_config_set_set_pins(&c, pin, 1);
sm_config_set_clkdiv(&c, div);
pio_sm_init(pio, sm, offset, &c);
}
%}
% c-sdk{ %}
で囲むことで。
blink_program_get_default_config
を通じて設定を取得します(この関数は自動生成です)。pio_gpio_init(pio, pin)
でPIOのGPIOピンを設定します。pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true)
でピンの方向を設定します(falseはinput、trueはoutput)。sm_config_set_set_pins(&c, pin, 1)
でsetピンを設定します。sm_config_set_clkdiv(&c, div)
でディバイダーを設定します。pio_sm_init(pio, sm, offset, &c)
でステートマシンを初期化します。
set_set_pins
の他にも、set_sideset_pins
などもあります。
PIO機能を使用するためには、target_link_libraries
にhardware_pio
を追加する必要があります:
cmake_minimum_required(VERSION 3.12)
include($ENV{PICO_SDK_PATH}/external/pico_sdk_import.cmake)
include($ENV{PICO_SDK_PATH}/tools/CMakeLists.txt)
project(pio C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
pico_sdk_init()
add_executable(${PROJECT_NAME}
main.c
)
pico_add_extra_outputs(${PROJECT_NAME})
pico_generate_pio_header(${PROJECT_NAME}
${CMAKE_CURRENT_LIST_DIR}/blink.pio
)
+ target_link_libraries(${PROJECT_NAME}
+ pico_stdlib
+ hardware_pio
+)
pico_enable_stdio_usb(pio 1)
pico_enable_stdio_uart(pio 1)
次に、main.c
に以下を書きます:
#include <stdio.h>
#include "pico/stdlib.h"
#include "hardware/pio.h"
#include "hardware/clocks.h"
#include "blink.pio.h" // pioをコンパイルした後に自動生成されるヘッダファイル
void blink(PIO pio, uint sm, uint offset, uint pin, uint freq);
int main()
{
stdio_init_all();
PIO pio = pio0;
uint offset = pio_add_program(pio, &blink_program);
blink(pio, 0, offset, 2, 2000);
while (true)
{
printf("test");
sleep_ms(200);
}
}
void blink(PIO pio, uint sm, uint offset, uint pin, uint freq)
{
float div = clock_get_hz(clk_sys) / freq;
blink_program_init(pio, sm, offset, pin, div); // PIO内で宣言された関数
pio_sm_set_enabled(pio, sm, true);
}
クロック情報とPIO関連の操作を取得するために、hardware/pio.h
とhardware/clocks.h
をインクルードする必要があります。コンパイルしてプログラムをアップロードすると、LEDが点滅し、シリアルでtest
という文字列が継続して表示され、両方の機能が完全に独立していることが確認できます!
結論
この文章では、PIOについての初歩的な理解と構文の紹介を行いました。次回は、PIOを使用してUARTのような一般的な通信プロトコルを実装したり、PIOとDHT11温度センサーとの通信を試みたりして、PIOの理解を深めていきたいと思います。PIOは私にとって非常に新しい概念で、マイクロコントローラがこのように設計されているのを初めて見たと言えます。
この設計は、プロセッサが通信プロトコルに過剰なリソースを消費するのを避けるだけでなく、開発者がハードウェアの制約に縛られることなく、PIOを通じて自分の望む機能を実現できるため、非常に期待しています。その上、公式はRP2040を単独で販売しており、公式のドキュメントを参照してボードを作成することも可能です3。
公式ドキュメント4は非常に詳細に書かれており、一度は読んでみることをお勧めします。なぜこのように設計されているのかが明確に理解できるでしょう。SDKの関数について何か疑問がある場合はこちらで確認できます。
Footnotes
-
https://blog.kalan.dev/2020-07-24-arduino-esp32-co2-sensor-2/ ↩
-
開発環境の設定は https://www.raspberrypi.com/documentation/microcontrollers/raspberry-pi-pico.html を参照してください。 ↩
-
https://datasheets.raspberrypi.com/rp2040/hardware-design-with-rp2040.pdf ↩
-
https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf ↩
この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨
☕Buy me a coffee