micro:bitでµT-Kernel 3.0を動かそう

目次に戻る

前回の連載記事に戻る

[第14回]2台のmicro:bitのPWMで合奏しよう

本連載では、小学生向きのプログラミング教育などに使われているBBC micro:bit(以下「micro:bit」)の上で動くようになったµT-Kernel 3.0をご紹介している。最終回となる連載第14回の本号では、前回に続いて2台のmicro:bitを使い、PWMによる音楽再生で合奏を試みる。2台の間は前回と同じくUARTEで通信するが、音楽再生の開始時刻を正確に合わせるために、UARTEの割込みを使う。MMLで書かれた楽譜を読めるようにしたので、インターネット上に出ているMMLの音楽データを「演奏」してみることも可能だ。

デバイスIDによるmicro:bitの個体識別

 

今回のように複数台のmicro:bitを連携して動かしたい場合、micro:bitの上で動かすプログラムの管理が面倒になりがちである。一般には、1台ごとに似て非なるプログラムを動かす必要があるので、ソースコードの編集、コンパイル、ダウンロードを1台ごとに別々に操作しなければならない。連載第13回で行ったUART通信の動作確認の際も、2台のmicro:bitで動くプログラムの大部分は共通であるにもかかわらず、使用するエッジコネクタ端子の定義を逆にする必要があったため、defineマクロの1か所のみが異なっていた(連載第13回のリスト2とリスト4)。また、今回は2台のmicro:bitで合奏するのが目標なので、一方のmicro:bitでは主旋律(メロディー)を演奏し、もう一方のmicro:bitでは副旋律(伴奏)を演奏する必要がある。そのため、プログラムの大部分は共通であるが、演奏する楽譜は両者で異なる。このような場合に、2台のプログラムのソースコードの管理やコンパイルを別々に行うのは煩雑であるし、間違いも起こりやすくなる。できれば、両者のプログラムを完全に共通化したい。


この課題を解決するには、プログラムの実行中に、自分自身がどのmicro:bitなのかを区別して認識する方法があればよい。合奏の例でいえば、自分自身が主旋律と副旋律のどちらを担当するmicro:bitかということを、実行時に判断できるようにする。主旋律担当のmicro:bitであれば主旋律用の楽譜を使い、副旋律担当であれば副旋律用の楽譜を使うといった場合分けの処理をすれば、両者のプログラムを共通化できる。


自分自身を区別して認識するためには、ボードごとに異なる値が取得できるような機能が必要である。このためには、micro:bitのTarget MCUであるnRF52833の持つFICR(Factory information configuration registers)という機能を利用する。FICRの中にはDEVICEID[0]、DEVICEID[1]という二つの読み出し専用の制御レジスタがあり、合わせて64ビットのデバイスIDが格納されている。このデバイスIDは個々のmicro:bitごとにユニークな値が割り当てられており、複数台のmicro:bitで値が重なることはない。したがって、主旋律担当のmicro:bitのボードのデバイスIDをあらかじめ調べてプログラムに含めておき、その値と実行時に取得したデバイスIDを比較すれば、自分自身が主旋律担当か否かを判断できる。


FICRのベースアドレスは0x10000000、DEVICEID[0]とDEVICEID[1]のアドレスのオフセットは0x060と0x064である(*1) 。このアドレスから読み出した値が、そのままデバイスIDとして利用できる。その動作確認プログラムをリスト1に、その実行時のコンソール出力をリスト2に示す。二つのDEVICEIDのレジスタを読み出して(リスト1の(※A))、それをコンソールに表示するだけの簡単なプログラムである。


本稿では、2台のmicro:bitを区別するために、メインボード(MAIN board)とサブボード(SUB board)という語を使うことにする。主旋律を担当するのがメインボード、副旋律を担当するのがサブボードの想定だが、両者では再生する楽譜が異なるのに加えて、連載第13回のリスト4で説明したようなエッジコネクタ端子の設定も異なる。すなわち、メインボードではP0が送信でP1が受信の状態(P0TX_P1RXが定義された状態)となるのに対し、サブボードではP1が送信でP0が受信の状態(P1TX_P0RXが定義された状態)となる。このような場合分けを行うために、自分自身がメインボードかどうかを返す関数is_main_boardを用意した(※B)。


is_main_boardが正しく動作するには、実際のメインボードとして使用するmicro:bitの個体でこのプログラムを実行し、デバイスIDをコンソールに表示して、その値をこのソースプログラムのmain_devid0とmain_devid1の部分に埋め込んでおく必要がある。そうすると、同じmicro:bitでこのソースプログラムを実行した場合はis_main_boardがTRUEを返し、別のmicro:bitで実行した場合はis_main_boardがFALSEを返す。これで、2台のmicro:bitを区別しつつ、共通のプログラムを動かすことができるようになった。


リスト1 micro:bitのデバイスIDを表示するプログラム
※デバイスIDの部分は架空の値に修正している。

/*---------------------------------------------------------------
*  micro:bitのデバイスIDの表示(μT-Kernel 3.0用)
*
*  Copyright (C) 2022-2024 by T3 WG of TRON Forum
*---------------------------------------------------------------*/
#include 
#include 

//-------- ボードIDの取得 ---------------------------------------
#define NRF5_FICR_BASE  0x10000000
#define DEVICEID_0      0x060       // Device identifier 0
#define DEVICEID_1      0x064       // Device identifier 1

// メインボード用micro:bitの実際のデバイスID
const W main_devid0 = 0x01234567;   // (★要設定★)
const W main_devid1 = 0x89abcdef;   // (★要設定★)

W devid0, devid1;                   // 実行中のmicro:bitのデバイスID

// 自分自身がメインボードの場合にTRUEを返す関数(※B)
LOCAL BOOL is_main_board(){
   return(devid0 == main_devid0 && devid1 == main_devid1);
}

// ボードのデバイスIDを取得してコンソールに表示
LOCAL void get_device_id(void)
{
   // DEVICE[0]の読み出し(※A)
   devid0 = in_w(NRF5_FICR_BASE + DEVICEID_0);
   // DEVICE[1]の読み出し(※A)
   devid1 = in_w(NRF5_FICR_BASE + DEVICEID_1);

   tm_printf("This devid_0_1: %08x_%08x\n", devid0, devid1);
   tm_printf("Main devid_0_1: %08x_%08x\n", main_devid0,
   main_devid1);
   if(is_main_board())
       tm_printf("This is MAIN board, P0TX_P1RX\n\n");
   else
       tm_printf("This is  SUB board, P1TX_P0RX\n\n");
}

//---------------------------------------------------------------
EXPORT void usermain( void ){

   get_device_id();        // デバイスIDの取得と表示
   tk_slp_tsk(TMO_FEVR);   // 永久待ち、以下は実行しない
}



リスト2 デバイスIDを表示するプログラムのコンソール出力

microT-Kernel Version 3.00

This devid_0_1: 01234567_89abcdef
Main devid_0_1: 01234567_89abcdef
This is MAIN board, P0TX_P1RX
       


UARTEの割込みを使う

 

2台のmicro:bitで合奏するには、2台で演奏を始める時刻を正確に合わせる、すなわち同期させる必要がある。そのために、連載第13回で紹介したUARTEを使い、2台の間でシリアル通信を使う。しかし、前回のようにデータ受信の完了をポーリングで待つ方式を使った場合、最大でポーリングの時間間隔だけ受信完了後の動作が遅れてしまう。システムタイマのティック時間(タイマ割込みの時間間隔)が標準値の10msの場合、ポーリングの時間間隔は最小でも20ms、すなわち50分の1秒になる。かなり音感の良い人でないかぎり、この程度の時間的な音のズレは分からないかもしれないが、せっかくリアルタイムOSを使っているのに動作が20msもズレてしまうのは、気持ちのよいものではない。


そこで今回は、UARTEの割込みを使った通信を行い、メインボードとサブボードの動作を正確に同期させることを目指す。連載第13回で説明したように、UARTEでは指定したデータの送信が終了するとEVENTS_ENDTXが1になり、指定したバイト数のデータの受信が終了するとEVENTS_ENDRXが1になる。これらの状態変化に対する割込みを許可しておけば、送信や受信が終了した直後のタイミングで割込みハンドラを起動できるので、送受信完了後の処理をリアルタイムに開始できる。ポーリングのような待ち時間を入れる必要はない。


UARTEの割込みを制御するレジスタには、複数の割込み要因に対する割込み許可と禁止をまとめて設定できるINTENのほか、一部の要因に対して割込みの許可や禁止を行うINTENSETとINTENCLRがある(*2) 。今回はEVENTS_ENDTXとEVENTS_ENDRXに対する割込みを許可し、他の要因に対する割込みは一括して禁止のままでよいので、INTENを使うことにする。レジスタINTENの構成を図1に示す。


UARTE1の初期化関数init_uarte1は、連載第13回のリスト1とほとんど同じであるが、最後にEVENTS_ENDTXとEVENTS_ENDRXに対する割込みを許可する処理を追加している(リスト3)。ここでは、UARTEのINTENレジスタに0x00000110を設定し、図1の位置DのENDRXのビットと位置FのENDTXのビットをセットしている。


µT-Kernelのシステムコールtk_def_intを使って割込みハンドラを定義するには、割込み番号(intno)を知る必要がある。割込み番号については連載第6回で説明しているが、GPIOTEの場合はベースアドレスが0x40006000であることから、Target MCUにおけるGPIOTEの周辺デバイスIDが6であることがわかり、これがそのままµT-Kernelで扱う割込み番号になっていた。今回使用するUARTE1の場合はベースアドレスが0x40028000なので、周辺デバイスIDは0x28となり、割込み番号も0x28を使えばよい。ちなみに、コンソールに使用しているUART(UARTEではない)については、ベースアドレスが0x40002000なので、割込み番号は0x02であり、UARTE1の割込み番号とは競合しない。


なお、実際の動作確認プログラム(後述のリスト6)では、0x28という具体的な値を書くのではなく、UARTE1のベースアドレスからマクロを使って割込み番号を定義している。また、割込み優先度レベルについては、GPIOTEの場合と同じく6を使う。


 

図1 UARTEのINTENレジスタの構成と各ビットフィールドの機能
図1 UARTEのINTENレジスタの構成と各ビットフィールドの機能 (*2の資料の一部を要約)

リスト3 割込み許可を含めたUARTE1の初期化関数

//-------- 割込み許可を含めたUARTE1の初期化とピン設定 -----------
//          最後の割込み許可以外は連載第13回のinit_uarte1と同じ
LOCAL void init_uarte1(INT pin_txd, INT pin_rxd)
{
              ・
              ・
              ・
    // EVENTS_ENDTXとEVENTS_ENDRXの割込み許可の設定
    out_w(UARTE(1, INTEN), 0x00000110);
}


割込みを使う場合のUARTEの送受信

 

割込みを使ったUARTE通信の動作確認を行うが、まずはその準備として、UARTEのデータ送信やデータ受信を開始する関数を作成する(リスト4)。連載第13回のリスト1では、送信や受信の完了を待ってから戻る関数としてuarte1_txとuarte1_rxを作成したが、今回は送信、受信ともに完了は割込みで通知されるので、送信や受信の完了を待たずに戻る形になる。一方、簡単化のために、一度に通信するデータは1バイトのみとし、送受信データを置く送信バッファや受信バッファのメモリアドレスもtxbufとrxbufに固定した。


UARTE1で1バイトの送信を開始する関数がuarte1_start_tx1である(リスト4の(※C))。引数は1バイトの送信データtxdatである。この中ではまず、現時点でデータの送信中ではないことを確認するために、tk_wai_semを実行する(※D)。これは、送信データを入れる送信バッファがtxbufに固定されており、それ以前に開始した送信処理の完了前に新しい送信データを送信バッファに書き込むと、まだ送信中かもしれない以前の送信データを壊してしまう可能性があるためだ。セマフォを使って送信バッファに対する排他制御を行い、以前に開始した送信処理が終わるのを待ってから、送信データを送信バッファtxbuf[0]に書き込む。次に、TXD_PTRとTXD_MAXCNTを設定し、送信完了を示すEVENTS_ENDTXをクリアしてから、TASKS_STARTTXに1を書き込んで送信を開始する。これらの処理は連載第13回のuarte1_txと同様である。


一方、UARTE1で1バイトの受信を開始する関数がuarte1_start_rx1である(※E)。これは連載第13回のuarte1_rxよりも簡単で、RXD_PTRとRXD_MAXCNTを設定し、受信完了を示すEVENTS_ENDRXをクリアしてから、TASKS_STARTRXに1を書き込んで受信を開始するだけである。受信の完了は割込みで通知されるので、この関数の中で受信完了を待つ必要はない。


 

リスト4 UARTE1の送信開始と受信開始を行う関数

//-------- UARTE1からtxdatの1バイトを送信開始(※C) --------------
//          ボタン状態変化により呼ばれる
LOCAL void uarte1_start_tx1(UB txdat){

    // 送信バッファの排他制御(※D)
    tk_wai_sem(semid, 1, TMO_FEVR);

    // 送信データを送信バッファに格納
    txbuf[0] = txdat;

    // 送信バッファ先頭アドレス
    out_w(UARTE(1, TXD_PTR), (UW) txbuf);
    // 送信するバイト数
    out_w(UARTE(1, TXD_MAXCNT), 1);

    // 送信終了フラグのクリア
    out_w(UARTE(1, EVENTS_ENDTX), 0);
    // 送信開始
    out_w(UARTE(1, TASKS_STARTTX), 1);
}

//-------- UARTE1で1バイトの受信開始(※E) -----------------------
//          起動後および受信完了の割込みハンドラから呼ばれる
LOCAL void uarte1_start_rx1(){
    // 受信バッファ先頭アドレス
    out_w(UARTE(1, RXD_PTR), (UW) rxbuf);
    // 受信するバイト数
    out_w(UARTE(1, RXD_MAXCNT), 1);

    // 受信終了フラグのクリア
    out_w(UARTE(1, EVENTS_ENDRX), 0);
    // 受信開始
    out_w(UARTE(1, TASKS_STARTRX), 1);
}


UARTEの割込みハンドラ

 

次に、UARTEの割込みハンドラを作成する(リスト5)。割込みハンドラの関数名はuarte1_inthdrである。受信完了時も送信完了時も同じ割込みハンドラが起動されるので、この中では受信完了時の処理と送信完了時の処理を続けて行う。


まず、受信完了による割込みかどうかを判断するため、EVENTS_ENDRXをチェックする(※F)。この値が0でなければ受信完了の割込みだったことがわかるので、受信したデータを受信バッファから読み出してtxrxdatに入れる。受信バッファのアドレスは、今回の使い方ではrxbufに固定されているのだが、他の用途にも使えるように、UARTEのRXD_PTRに設定されている値を使用する。このアドレスを入れる変数がtxrxadrである。受信時の基本的な処理はこれだけであるが、さらに次のUARTEの受信に備えて、その準備をしておく。具体的には、ここでuarte1_start_rx1を実行し(※G)、TASKS_STARTRXを設定して次の受信を開始する。なお、EVENTS_ENDRXをクリアする必要があるが、その処理はuarte1_start_rx1の中で実行される。


引き続き、送信完了による割込みかどうかを判断するため、EVENTS_ENDTXをチェックする(※H)。この値が0でなければ送信完了の割込みだったことがわかるので、送信したデータを送信バッファから読み出して、8ビット左シフトしてから、txrxdatに格納する(※J)。一般には、送信済のデータを送信後に知る必要はないのだが、今回は動作確認が目的であることと、できるだけ送信側と受信側の動作、またメインボードとサブボードの動作を共通化したかったので、送信済のデータも受信したデータと同様に他のタスクに通知できるようにした。送信バッファのアドレスの扱い方は受信完了の場合と同じである。次に、送信終了フラグであるEVENTS_ENDTXをクリアする(※K)。割込みハンドラ内でこの処理を行わないと、割込みハンドラから戻ってもEVENTS_ENDTXが0でないため、再度同じ割込みが発生し、割込みがかかりっぱなしの状態になってしまう。また、送信完了により送信バッファの排他制御を解除するので、tk_sig_semを実行しておく(※L)。


受信あるいは送信した1バイトのデータは、tk_set_flgを使って割込みを待っていたタスクに通知する(※M)。イベントフラグによる通知とデータの送信を兼ねた使い方なので、0というデータを送ることはできないが、今回の用途では特に問題ない。


この割込みハンドラでは、受信完了と送信完了の割込みが同時に発生した場合を考慮している。2台のmicro:bitの間では双方向の通信ができるので、たとえばメインボード側に注目した場合、サブボードへの送信とサブボードからの受信が同時に起こる可能性がある。送信と受信のタイミングがたまたま一致した場合には、EVENTS_ENDRXとEVENTS_ENDTXの両方が0以外になった状態でこの割込みハンドラが起動されるため、受信完了と送信完了の処理をどちらも行う必要がある。すなわち、(※F)と(※H)のif文の中がどちらも実行されることになる。このような場合には、受信データと送信データの両方をタスクに通知しなければならない。幸いにして、tk_set_flgでは32ビット(4バイト)のデータを送ることができるので、各1バイトの送受信データを合わせて一つの2バイトデータにまとめてから、これをtk_set_flgで送ればよい。この処理を行うため、送信データが2バイトデータの上位8ビットに入るような処理を行っている(※J)。


 

リスト5 UARTE1の送受信完了時に起動される割込みハンドラ

//-------- UARTE1の送信完了と受信完了の割込みハンドラ -----------
//          送信完了と受信完了が同時に起こる可能性もあるので、
//          8ビットの送信データと受信データを1つの16ビットデータに
//          まとめてからset_flgで送る
LOCAL void uarte1_inthdr(UINT intno)
{
    // 受信データと送信データを入れてset_flgで送る変数
    UW      txrxdat = 0;
    // 送信バッファまたは受信バッファの先頭アドレス
    _UB*    txrxadr;

    // 受信完了の割込みの場合(※F)
    if(in_w(UARTE(1, EVENTS_ENDRX))){
        txrxadr = (_UB *) in_w(UARTE(1, RXD_PTR));
        // 下位8ビットに受信データを格納
        txrxdat |= (UW) txrxadr[0];
        // 次の受信の準備(※G)
        uarte1_start_rx1();
    }

    // 送信完了の割込みの場合(※H)
    if(in_w(UARTE(1, EVENTS_ENDTX))){
        txrxadr = (_UB *) in_w(UARTE(1, TXD_PTR));

        // txrxdatの上位8ビットに送信データを格納(※J)
        txrxdat |= ((UW) txrxadr[0] << 8);

        // 送信終了フラグのクリア(※K)
        out_w(UARTE(1, EVENTS_ENDTX), 0);
        // 送信バッファの排他制御を解除(※L)
        tk_sig_sem(semid, 1);
    }

    // 受信データと送信データの通知(※M)
    tk_set_flg(flgid, txrxdat);
}


UARTEの割込みの動作確認プログラム

 

割込みを使ったUARTEの動作確認プログラムをリスト6に、その全体構成を図2に示す。前回の動作確認プログラム(連載第13回のリスト2)と似た構成になっており、送信側と受信側のプログラムが別タスクで並行動作を行う。


送信側タスクbtn_tx_task(※N)は、ボタンスイッチAやBの状態を監視しており、変化があった場合に1バイトのデータをUARTEで送信する。連載第13回リスト2のtx_taskとほとんど同じであり、ボタンの変化をチェックする関数がcheck2_btnに変わった点のみが異なる。check2_btn(※P)も連載第13回リスト2のcheck_btnと似ているが、もっと簡単な処理になっており、ボタンを離したときには何もしない。ボタンを押したときには、‘A’または‘B’の1バイトのデータをuarte1_start_tx1で送信する。


受信側タスクflg_rx_task(※Q)は、割込みハンドラから通知された送受信データをコンソールに表示するだけである。イベントフラグ経由で通知された送受信データはflgptnの下位16ビットに入っているが、送信完了の割込みだった場合はこの16ビット中の上位8ビットが送信データなので、それをtxchrに入れる(※R)。受信完了の場合は下位8ビットが受信データなので、それをrxchrに入れる。その後、これらのデータをtm_printfでコンソールに表示する。その際、関数disp_chを使って、表示できない文字を‘.’に変換している。なお、受信側タスクとよんでいるが、UARTEで受信したデータだけでなく、UARTEから送信した場合にも送信完了時にはコンソールに送信データが表示される。

動作確認プログラムのusermainでは、is_main_boardを使ってメインボードとサブボードを判断し(※S)、送信と受信に使用するエッジコネクタ端子のピン番号を設定する。すなわち、連載第13回のP0TX_P1RXマクロで切り替えていた処理を自動的に行う。


その後、init_uarte1によるUARTE1の初期化とuarte1_start_rx1による最初の受信の開始、割込み通知用のイベントフラグと送信バッファ排他制御用のセマフォの生成、送信側タスクと受信側タスクの生成および起動を行ってから、UARTEの割込みハンドラを定義して割込みを許可する。送信側タスクが動作してボタンスイッチの監視を始めるので、ボタンを押すとUARTE1によるデータ送信が実行される。


図2 割込みを使ったUARTE通信の動作確認プログラムの全体構成
図2 割込みを使ったUARTE通信の動作確認プログラムの全体構成

 

リスト6 割込みを使ったUARTE通信の動作確認プログラム

/*---------------------------------------------------------------
 *  割込みを使ったUARTE通信の動作確認(μT-Kernel 3.0用)
 *
 *  Copyright (C) 2022-2024 by T3 WG of TRON Forum
 *---------------------------------------------------------------*/
              ・
              ・
              ・
// UARTEの送信データと受信データを入れるバッファメモリ
#define MAXBUF  2
static _UB  txbuf[MAXBUF];
static _UB  rxbuf[MAXBUF];

LOCAL ID    flgid;                  // 割込み通知用イベントフラグのID
LOCAL ID    semid;               // 送信バッファ排他制御用セマフォのID
              ・
              ・
              ・
// 以前のボタンの状態を保持する変数
LOCAL BOOL prev_a = FALSE;
LOCAL BOOL prev_b = FALSE;

//---------------------------------------------------------------
// ボタンスイッチの状態をチェック(※P)
LOCAL void check2_btn(BOOL new_btn, BOOL *prev_btn, UB btnchr)
{
    UB actchr;

    // 状態変化が無ければ何もせずに戻る
    if(new_btn ==  *prev_btn) return;

    *prev_btn = new_btn;

    if(new_btn){                        // ボタンが押された場合
        uarte1_start_tx1(btnchr);    // btnchrのデータをUARTE1で送信
    }
}

// ボタンスイッチの入力判定を行う送信側タスク(※N)
LOCAL void btn_tx_task(INT stacd, void *exinf){
    UW gpio_p0in;
    BOOL btn_a, btn_b;

    for (;;) {              		// 約100msごとに永久に繰り返し

        // GPIO P0のINレジスタを読んでgpio_p0inに設定
        gpio_p0in = in_w(GPIO(P0, IN));

        // P0.14が0の場合にTRUE
        btn_a = ((gpio_p0in & (1 << 14)) == 0);
        // P0.23が0の場合にTRUE
        btn_b = ((gpio_p0in & (1 << 23)) == 0);

        // ボタンAの変化のチェックと送信
        check2_btn(btn_a, &prev_a, 'A');
        // ボタンBの変化のチェックと送信
        check2_btn(btn_b, &prev_b, 'B');

        tk_dly_tsk(100);                    	// 100msのディレイ
    }
}

// 表示できない文字を '.' に変換する関数
LOCAL UINT disp_ch(UINT ch){
    ch &= 0xff;
    if(ch < ' ' || ch > '~')    return('.');
    else                        return(ch);
}

// イベントフラグで通知された値を表示する受信側タスク(※Q)
LOCAL void flg_rx_task(INT stacd, void *exinf){
    UINT flgptn;
    UB  txchr, rxchr;

    for (;;) {              		// 永久に繰り返し

        // 割込み通知用イベントフラグの下位2バイトをOR待ち
        tk_wai_flg(flgid, 0xffff, (TWF_ORW | TWF_CLR), &flgptn,
        TMO_FEVR);

        // 送信データをtxchrに入れる(※R)
        txchr = (flgptn >> 8) & 0xff;
        // 受信データをrxchrに入れる
        rxchr = flgptn & 0xff;

        tm_printf("flg_rx_task: dat=%04x, tx='%c', rx='%c'\n",
            (UINT) flgptn, disp_ch(txchr), disp_ch(rxchr));
    }
}

//---------------------------------------------------------------
// タスクのオブジェクトID番号
ID txtskid;             // 送信側タスク
ID rxtskid;             // 受信側タスク

// タスク、イベントフラグ、セマフォの生成情報
const T_CTSK ctxtsk = {0, (TA_HLNG | TA_RNG3), &btn_tx_task,
  10, 1024, 0};
const T_CTSK crxtsk = {0, (TA_HLNG | TA_RNG3), &flg_rx_task,
  10, 1024, 0};
const T_CFLG cflg = {0, (TA_TFIFO | TA_WSGL), 0};
const T_CSEM csem = {0, (TA_TFIFO | TA_FIRST ), 1, 1};

// 割込みハンドラ定義情報
const T_DINT dint_uarte = {TA_HLNG, uarte1_inthdr};

// UARTE1の割込み番号と割込み優先度レベル
// 割込み番号
#define INTNO_UARTE1    ((UARTE1_BASE >> 12) & 0x3f)
// 割込み優先度レベル
#define INTPRI_UARTE1   6

// メインプログラム
EXPORT void usermain( void ){
    // UARTEの送受信用エッジコネクタ端子の定義
    INT tx_pin, rx_pin;

    get_device_id();
    if(is_main_board()){     // メインボードとサブボードの判定(※S)
        // メインボードの場合はP0TX_P1RX相当
        tx_pin = 2; rx_pin = 3;
    } else {
        // メインボード以外の場合はP1TX_P0RX相当
        tx_pin = 3; rx_pin = 2;
    }

    btn_init();        // ボタンスイッチ入力のためのGPIOの初期設定

    // UARTE1の初期化と送受信用端子の指定
    init_uarte1(tx_pin, rx_pin);
    uarte1_start_rx1();            // 最初の受信の準備

    // 割込み通知用のイベントフラグ生成
    flgid = tk_cre_flg(&cflg);
    // 送信バッファ排他制御用のセマフォ生成
    semid = tk_cre_sem(&csem);
    txtskid = tk_cre_tsk(&ctxtsk);   // 送信側タスクの生成
    rxtskid = tk_cre_tsk(&crxtsk);   // 受信側タスクの生成
    tk_sta_tsk(txtskid, 0);              // 送信側タスクの起動
    tk_sta_tsk(rxtskid, 0);              // 受信側タスクの起動

    // UARTEの割込みハンドラ定義
    tk_def_int(INTNO_UARTE1, &dint_uarte);
    EnableInt(INTNO_UARTE1, INTPRI_UARTE1); // 割込み許可

    tk_slp_tsk(TMO_FEVR);            // 永久待ち、以下は実行しない
}


UARTEの割込みの動作を確認する

 

まずは1台のmicro:bitを使って、UARTEの割込みの動作を確認する。実際の通信をしなくても、UARTE1からデータを送信すれば、送信完了の割込みが発生して割込みハンドラが起動され、コンソールの表示を確認できるはずだ。


開発用ホストPCでTera Termなどの端末ソフトを動かした状態で、リスト6のプログラムをmicro:bitに転送して実行する。ボタンスイッチAを押すと、送信側タスクbtn_tx_taskから‘A’の1バイトデータが送信される。通信相手がいなくても、データの送信完了時には割込みが発生するので、割込みハンドラuarte1_inthdrが起動され、続いて受信側タスクflg_rx_taskが動作してコンソールに送信データの‘A’が表示される(リスト7)。これで、UARTEの送信完了時の割込みが正しく処理できていることを確認できた。


次はいよいよ、2台のmicro:bitでUARTEの割込みを使ったシリアル通信を行う。連載第13回の図5と同じように、2台のmicro:bitの3.3V、P1、P0、GNDを接続し、一方をメインボード、もう一方をサブボードとする。実行するプログラムは両者で共通だが、ボードが正しく区別できるように、メインボード用に使用するmicro:bitの実際のデバイスIDをソースプログラムの中に設定しておく必要がある。


メインボードとサブボードでリスト6のプログラムを実行する。どちらか一方のボードは、USBで開発用ホストPCの端末ソフトに接続しておく。どちらかのボードのボタンスイッチBを押すと、そのボードの送信側タスクから‘B’の1バイトデータが送信され、送信側のボードで送信完了の割込みが発生するとともに、通信相手側のボードでは受信完了の割込みが発生する。開発用ホストPCの端末ソフトでは、この動作を確認するメッセージが表示される(リスト8)。


 

リスト7 割込みを使ったUARTE通信のコンソール出力例(送信側1台のみの場合)

microT-Kernel Version 3.00

This devid_0_1: 01234567_89abcdef
Main devid_0_1: 01234567_89abcdef
This is MAIN board, P0TX_P1RX

flg_rx_task: dat=4100, tx='A', rx='.'	←ボタンスイッチAを押す
flg_rx_task: dat=4100, tx='A', rx='.'	←ボタンスイッチAを押す
flg_rx_task: dat=4200, tx='B', rx='.'	←ボタンスイッチBを押す


リスト8 割込みを使ったUARTE通信のコンソール出力例(2台で通信した場合)

microT-Kernel Version 3.00

This devid_0_1: 01234567_89abcdef
Main devid_0_1: 01234567_89abcdef
This is MAIN board, P0TX_P1RX

flg_rx_task: dat=4100, tx='A', rx='.'	←このボードのボタンスイッチA
                                                           を押す
flg_rx_task: dat=4200, tx='B', rx='.'	←このボードのボタンスイッチB
                                                           を押す
flg_rx_task: dat=0042, tx='.', rx='B'	←通信相手ボードのボタンスイッ
                                                           チBを押す
flg_rx_task: dat=0041, tx='.', rx='A'	←通信相手ボードのボタンスイッ
                                                           チAを押す


MMLの楽譜を演奏するプログラム

 

割込みを使ったUARTEの通信の動作が確認できたので、連載第10回で紹介したPWMによる音楽再生の機能と組み合わせて、2台のmicro:bitで合奏してみよう。


そのためには、まず演奏する「楽譜」と、その演奏用プログラムを用意する必要がある。連載第10回では、MML(Music Macro Language)を少しだけ参考にした独自形式で楽譜のデータを表現していたが、今回はネット上に出ているMMLの楽譜を再生できるように、ある程度本格的なMMLの処理プログラムを作成した。とはいえ、micro:bitのPWMというハードウェアの制約があるので、音量設定や音色設定のように、対応できないMMLコマンドも多い。あくまでも実験的なものであり、まだ不備な点も残っているが、いくつかのMMLの楽譜を試してみて、馴染みのメロディーが再現できることは確認できた。


実装したMMLコマンドの仕様とその処理方法については、µT-Kernelやmicro:bitという本題から外れるので、ここでは説明を省略する。詳細については、ダウンロードしたリスト9のソースプログラムとコメントを見ていただきたい。この中で、MMLのコマンドを解析するために重要な関数がread_mmlである。read_mmlでは、MMLの楽譜の文字列から一つのコマンドを切り取って、別の関数exec_one_mmlを使ってコマンドを実行してから、次のコマンドの手前まで読み進む。exec_one_mmlではMMLの一つのコマンドを実行するが、音符や休符を再生する場合には、さらに別の関数play_one_mmlを呼び出す。play_one_mmlでは、連載第10回で作成したPWMによる音程の再生やLEDのイルミネーションの機能をそのまま利用し、曲に合わせてLEDを点灯する。これらの処理を行うpwm_speaker_start、pwm_duty_startなどの関数は、連載第10回と同じである。また、繰り返しread_mmlを実行して一つの曲全体を再生する関数として、play_all_mmlを作成した。


本稿のまとめとして、MMLの楽譜を2台で合奏するプログラムを作成する(リスト9)。2台のmicro:bitの間でUARTEによる通信を行い、UARTEの割込みを使って演奏開始の時刻を正確に同期させる。この部分のプログラムは、割込みハンドラuarte1_inthdrやusermainも含めてリスト6とほとんど同じなので、紙面のリストでは省略した。ただし、usermainでは、PWMによる音の再生やLEDイルミネーションを行うための初期設定の処理を追加している。また、uarte1_inthdrでは、MML演奏中を示すmml_playingという変数をFALSEにする処理を追加した。これは、割込みの発生時、すなわちボタンスイッチが押された直後のタイミングにおいて、そのときにMMLの演奏途中だった場合にはそれを中止し、別の曲の演奏に移るための機能である。一つの曲全体を再生するplay_all_mmlでは、一つのMMLコマンドを実行するごとにmml_playingをチェックし、これがFALSEになっていた場合は曲の途中でも即座に戻る。


MMLの楽譜はC言語の文字列データとして表現される。複数の楽譜から選んで演奏できるように、MMLの文字列データを配列で扱う。さらに、メインボード用とサブボード用の2種類の楽譜データが必要なので、メインボード用のMMLの楽譜の文字列を入れる配列をmml_main(※T)、サブボード用をmml_subとした(※U)。これらの配列のインデックスとなる変数がmml_idxであり、現在何番目の楽譜を演奏中かという曲番号を示す。


現在指定されている一つの曲全体の演奏を行う関数がplay_mml_idxである(※V)。play_mml_idxでは、メインボードの場合にmml_main、サブボードの場合にmml_subの楽譜データを使い、mml_idxの曲番号で指定されたMMLの文字列を引数としてplay_all_mmlを実行する。


受信側タスクflg_rx_taskでは、リスト6と同じようにtk_wai_flgでUARTEの割込み発生を待っているが、割込み発生後には、押されたボタンスイッチによってMMLの演奏を始める処理を追加している。ここでは、動作を単純化するため、送信の場合と受信の場合を区別せず、どちらのボードのボタンを押しても同じ動作をするようにした。そのため、送信データtxchrと受信データrxchrのうち、実際にデータの入っているほう(0でないほう)をcmdchrに入れる(※W)。その後、そのデータが‘A’だった場合、すなわちどちらかのボードのボタンスイッチAが押されていた場合には、次の曲に進んで演奏するために、mml_idxを一つ増やしてからplay_mml_idxを実行する(※X)。‘B’の場合は、mml_idxを変えず、同じ曲をもう一度最初から演奏する。


 

リスト9 MMLの楽譜を2台で合奏するプログラム

/*---------------------------------------------------------------
 *  MMLの楽譜を2台で合奏(μT-Kernel 3.0用)
 *
 *  Copyright (C) 2022-2024 by T3 WG of TRON Forum
 *---------------------------------------------------------------*/
              ・
              ・
              ・
//---------------------------------------------------------------
//  MMLの楽譜データ
//      mm_XXX がメインボード用(主旋律)
//      ms_XXX がサブボード用(伴奏)

// 起動時のチャイム
B mm_start[] = "t720>ceg>c2.";
B ms_start[] = "t720>>c<gec2.";

// きらきら星
B mm_kira[] = "ccggaag_ ffeeddc_ ggffeed_ ggffeed_ ccggaag_
ffeeddc_";
B ms_kira[] = "ccccffc_ ddcc<bb>c_ ccddcc<b_> ccddcc<b_>
 l8c<a>c<a>ececfdfdedc<<b> d<b>d<<b>c<a>
 c<a><baba>c__";

// かえるの合唱(輪唱)
B mm_kaeru[] = "     cdefedc.r8 efgagfe.r8 crcrcrcr l8ccddeeffl4
edc.r8";
B ms_kaeru[] = "r1r1 cdefedc.r8 efgagfe.r8 crcrcrcr l8ccddeeffl4
edc.r8";

// メインボード用のMMLの楽譜を入れた文字列の配列(※T)
B *mml_main[] = { mm_kira, mm_kaeru };

// サブボード用のMMLの楽譜を入れた文字列の配列(※U)
B *mml_sub[]  = { ms_kira, ms_kaeru };

// 演奏中の楽譜のインデックス(曲番号)
LOCAL INT mml_idx = 0;

// mml_idxで指定された楽譜の1曲を演奏(※V)
LOCAL void play_mml_idx(void){

    if(is_main_board()){
        tm_printf("play_mml_idx: mml_idx=%d (MAIN)\n", mml_idx);
        play_all_mml(mml_main[mml_idx]);
    } else {
        tm_printf("play_mml_idx: mml_idx=%d (SUB)\n", mml_idx);
        play_all_mml(mml_sub[mml_idx]);
    }
    tm_printf("play_mml_idx: mml_idx=%d -- END\n", mml_idx);
}
              ・
              ・
              ・
// イベントフラグによりPWMのMML演奏を開始する受信側タスク
LOCAL void flg_rx_task(INT stacd, void *exinf){
    UINT flgptn;
    UB  txchr, rxchr, cmdchr;

    if(is_main_board())
        play_all_mml(mm_start);
    else
        play_all_mml(ms_start);

    for (;;) {              		// 永久に繰り返し

        // 割込み通知用イベントフラグの下位2バイトをOR待ち
        tk_wai_flg(flgid, 0xffff, (TWF_ORW | TWF_CLR), &flgptn,
        TMO_FEVR);

        txchr = (flgptn >> 8) & 0xff;  // 送信データをtxchrに入れる(※R)
        rxchr = flgptn & 0xff;         // 受信データをrxchrに入れる

        tm_printf("flg_rx_task: dat=%04x, tx='%c', rx='%c'\n",
            (UINT) flgptn, disp_ch(txchr), disp_ch(rxchr));

        // 送信データと受信データのうち、データのある方をcmdchrに
        // 入れる(※W)
        if(txchr != 0){
            cmdchr = txchr; // 送信データをcmdchrに入れる
            if(rxchr != 0) // 送信完了と受信完了の割込みが同時に発生
                           // この場合は受信データrxchrを無視するので警告
            tm_printf("flg_rx_task: rxchr(%c) ignored.\n",
            disp_ch(rxchr));
        } else if(rxchr != 0){
            cmdchr = rxchr; // 受信データをcmdchrに入れる
        }

        if(cmdchr == 'A'){          // 次の曲に進んで演奏する(※X)
            mml_idx = (mml_idx + 1) % (sizeof(mml_main) /
            sizeof(B *));
            play_mml_idx();
        } else if(cmdchr == 'B'){   // 同じ曲をもう一度最初から演奏する
            play_mml_idx();
        }
    }
}
              ・
              ・
              ・


2台のmicro:bitで合奏する

図3 2台のmicro:bitで合奏している様子
図3 2台のmicro:bitで合奏している様子

作成したリスト9のプログラムを、メインボードとサブボードの2台のmicro:bitで同時に実行して合奏する(図3)。合奏といっても、各パートを演奏するのは1台ずつなので、正確には重奏とよぶべきかもしれない。接続方法はリスト6で動作確認したときと同じである。リスト9には「きらきら星」と「かえるの合唱」のMMLの楽譜が入っているので、ボタンスイッチを押すとこれらの曲が演奏される。「きらきら星」は、メインボードで主旋律を演奏し、サブボードで伴奏しているつもりだが、伴奏用のMMLの楽譜は筆者が即興で作ったものなので、もっとアレンジできる余地はありそうだ。音楽に達者な方は、ぜひ工夫してみて欲しい。「かえるの合唱」はメインボードとサブボードで輪唱するように、サブボード用のMMLでは2小節だけ遅れて同じ旋律を演奏するようにした。このほか、ネット上にはいろいろなMMLの楽譜が出ており、楽器に合わせた合奏用のMMLもあるようなので、著作権の扱いに注意したうえで、リスト9のプログラムに組み込んで演奏してみることができる。あるいは、自分で作ったメロディーをMMLにしてもよいだろう。きれいなメロディーが流れるように、楽譜となるMMLのデータを作ってみてほしい。







* * *

2022年6月から始まって2年以上続いた本連載であるが、第14回の今回が最後だ。連載記事の目的はµT-Kernel 3.0の技術的な説明とその普及だったのだが、記事の内容としては、µT-Kernel自体の説明よりも、micro:bitの各種周辺デバイスをµT-Kernelからどうやって使うかという実践的な説明に主眼を置いたものとなった。本連載で説明したプログラムを組み合わせることで、micro:bitの多くの周辺デバイスをµT-Kernel 3.0からリアルタイムに操作できるはずだ。


micro:bitは子供向けのおもちゃのようなコンピュータであるが、その中の技術は決しておもちゃではなく本格的なものであるし、µT-Kernel 3.0の実行環境としても最適なものであることがわかった。本連載をきっかけとして、µT-Kernelを含めた組込みコンピュータや、その周辺デバイスの技術に関心を持っていただければ幸いである。


■ 本稿で説明したプログラムのソースコードのダウンロード
https://www.personal-media.co.jp/book/tw/tw_index/386.html

ソースコードをご利用になる前に、必ず上記ページ掲載の「ご利用条件およびご利用上のご注意」をお読みください。

(*1)
 nRF52833のFICRのマニュアル https://infocenter.nordicsemi.com/topic/ps_nrf52833/ficr.html
(*2)
 nRF52833のUARTEのマニュアル https://infocenter.nordicsemi.com/topic/ps_nrf52833/uarte.html
  • 本ページは、「TRONWARE Vol.209」の掲載記事「micro:bitでµT-Kernel 3.0を動かそう 第14回 2台のmicro:bitのPWMで合奏しよう」をWebで公開したものです。

ページの先頭に戻る