【nRF52】Non-OS環境でのapp_schedulerによるタスク管理

# はじめに

nRF52 SDKを知っている人向けの記事です。

複数のタスクを並列に動作させる、いわゆるマルチタスク動作は、汎用PCのみならず組み込みシステムにおいても要件とされることが多いです。例えばセンサICと継続的にI2C通信しながらスマホとBLE通信をする、といった具合です。

ここではNon-OS(OSが無い環境)でのマルチタスク動作実現をサポートする機能である[app_scheduler](https://infocenter.nordicsemi.com/index.jsp?topic=%2Fcom.nordic.infocenter.sdk5.v15.2.0%2Flib_scheduler.html)について紹介します。

nRF52 SDKを使っているけどapp_schedulerを知らない人、app_schedulerの機能をデータシートで知ったけど使ったことがない人、が読むと利益になるかも。

# app_schedulerについて

Non-OSにおいて有用な、複数のタスクをスケジューリングして実行する機能です。

これを使うことで「割り込みハンドラをトリガーにして、タスク処理自体はスレッドモードで実行する」という組み込み開発における定石パターンも簡単かつスマートに実装することができます。

app_schedulerの内部処理は単純なFIFO動作です。トリガーとなるイベント発生時にapp_schedulerのFIFOキューにタスクがpushされ、それがmainループでpopされることでタスク処理が開始されます。

Non-OSでのタスク管理方法としてapp_schedulerを導入することにデメリットがあるとすれば、FIFOキューによるstatic領域の専有サイズ増加くらいだと思われます。このデメリットはユーザー側でFIFOキューサイズを必要最小限に調整することで軽減可能です。

# 基本的な使い方

## 機能を有効にする

app_scheduler機能を有効にするために、sdk_config.hに下記定義を追加します。

```c:sdk_config.h
#ifndef APP_SCHEDULER_ENABLED
#define APP_SCHEDULER_ENABLED 1
#endif
```

## 初期化とスケジューリング実行

app_schedulerの機能は"app_scheduler.h"に集約されています。

```c
#include "app_scheduler.h"
```

最初にスケジューラを初期化しておく必要があります。この初期化はmainループに入る前に1度のみ実行します。初期化時に指定する最大キューイング数とイベントデータサイズを大きくするとそのぶんFIFOキューによるstatic領域の占有サイズが増えますが、逆に小さくしすぎるとタスク登録に失敗してしまうことがあるので注意してください。

```c
    // スケジューラの初期化
    // ここでは256byte以下のイベントデータを最大16個キューイングできる設定としている
    APP_SCHED_INIT(256, 16);
```

スケジューリングはmainループの中で実行します。

```c
    // mainループ
    for (;;)
    {
        // スケジューリング実行
        // FIFOキューが空になるまで実行される
        app_sched_execute();

        // sleep処理など
        sleep_hogehoge();
    }
```

## タスクを登録する

app_sched_event_put関数でタスク実行関数を登録します。スレッドモード、ハンドラモード、どちらからでも登録可能です。

```c
// タスク実行関数
static void event_callback(void* event_data, uint16_t event_size)
{
    // 第一引数はapp_sched_event_putで渡したデータのコピー先のポインタ
    uint32_t data = *((uint32_t*) event_data);

    // 第二引数はapp_sched_event_putで渡したデータのサイズ
    ASSERT(event_size == sizeof(uint32_t));

    // 何かしらのタスク処理を実行
    exec_hogehoge(data);
}
```

```c
// タスクのトリガー(割り込みハンドラなどを想定)
static void task_trigger(void)
{
    // イベントデータを用意する
    // イベントデータは渡した先でコピーされるので、実体を保持し続ける必要はない
    uint32_t data = 0x12345678;

    // mainループ内のapp_sched_executeからevent_callbackが呼ばれる
    APP_ERROR_CHECK(app_sched_event_put(&data, sizeof(data), event_callback));
}
```

# タスク処理を一時中断して他のタスクを実行可能にする

app_scheduler上で動作する各タスクには、自身の処理を一旦中断して他のタスクに実行権を明け渡すことが必要とされる場合があります。

一般論として、マルチタスクで動作するシステムにおいて、あるタスクがトリガーされてから処理開始までの時間が長くなってしまうと、結果としてシステムの性能要求を満たせなくなる恐れがあります。Non-OSにおけるマルチタスクはノンプリエンプティブ方式なので、タスクの開始遅延時間を短くするためには、個々のタスクが"自身の判断で"処理を一時中断しなければなりません。

例として、app_schedulerを使った既存システムに"1回の実行に100ミリ秒かかるexec_hogehoge関数を3000回実行するタスク"を追加することを考えます。

愚直にexec_hogehoge関数を3000回繰り返し実行する方法だと、このタスクによって後続のタスクの開始が最大300秒(100ミリ秒×3000回)程度も待たされてしまいます。

```c
// タスク実行
static void exec_task(void* event_data, uint16_t event_size)
{
    for(int i = 0; i < 3000; ++i)
    {
        // 100ミリ秒かかる処理を実行
        exec_hogehoge(data);
    }
}

// タスクのトリガー
static void task_trigger(void)
{
    APP_ERROR_CHECK(app_sched_event_put(NULL, 0, exec_task));
}
```

一方、タスク実行途中にスケジューリングを挟むことで後続のタスクの待ち時間を減らすことが可能です。

下記コードではexec_hogehoge関数を1回実行するごとにスケジューリングを挟んでいます。この場合の後続のタスクが待たされる時間は、最大100ミリ秒(100ミリ秒×1回)程度です。

```c
// タスク実行関数
static void exec_task(void* event_data, uint16_t event_size)
{
    int data = *((int*) event_data);

    // 100ミリ秒かかる処理を実行
    exec_hogehoge(data);

    if(data < 3000)
    {
        ++data;

        // この関数をもう一度スケジューリングする
        APP_ERROR_CHECK(app_sched_event_put(&data, sizeof(data), exec_task));
    }
}

// タスクのトリガー
static void task_trigger(void)
{
    int start = 0;

    APP_ERROR_CHECK(app_sched_event_put(&start, sizeof(start), exec_task));
}
```

タスクの開始遅延時間の許容範囲はシステム要件によって異なります。ノンプリエンプティブ方式ではタスクの開始遅延時間の保障が難しいため、システム要件においてこれがクリティカルになりうる場合は、RTOSの導入を検討しましょう。