LoginSignup
23
16

More than 5 years have passed since last update.

STM32 Nucleo Boardでマルチタスクしたい

Last updated at Posted at 2018-04-16

はじめに

組み込みOS自作入門を読んでおり、学習のためOSを作りたいと思っています。
ちょうどSTM32 Nucleo Board(ARM Cortex-M4)が手元にあるのでARM版ができたらという感じです。
まずはマルチタスクを実装するところから始めます。

マルチタスクとは

マルチタスクは、コンピュータにおいて複数のタスクを実行できるシステムのことです。
今のOSでは当たり前の機能として搭載されています。

やり方

CPUは1個(シングルコアなら)なので基本的には一つの仕事しかできません。
マルチっぽくみせるには、複数のタスクを切り替えて実行する必要があります。
このタスクの切り替え処理のことをコンテキストスイッチといいます。
CPUが一定時間ごとにコンテキストスイッチを行って実行するタスクを切り替えることにより、
複数のタスクを同時に実行状態にすることができます。
ということでマルチタスク実装のポイントはコンテキストスイッチにあると考えます。

コンテキストスイッチ

コンテキストスイッチは、マイコンによりやり方が変わります。しかし、割り込みを利用しているという点は共通です。
割り込みが発生した時、一般的にS/W によってレジスタ一式(コンテキスト)がスタックに保存されます。
元の処理に戻る場合、保存したレジスタ一式を復帰させることによって、元に戻ります。
これだと、元の処理に戻るだけですが、スタック上に保存してあるレジスタ値を書き換えてあげると、コンテキストスイッチが可能です。
保存してあるレジスタ値の中には、pc(= program counter) も含まれています。
pcを含め、スタック上に保存してあるレジスタ値をうまく書き換えることができれば、別の処理にジャンプさせることができます。
ARM Cortex-Mでは一部のレジスタはH/Wで自動的にスタックに保存されるため注意が必要です。

context.png

作る

マルチタスクのみ行うOSを作成します。
ブートローダからOSを含むメインプログラムを転送して実行する形式です。
ブートローダはSTM32 Nucleo Boardでブートローダを作るを参照してください。
スレッドという言葉が出てきますが、ここではスレッド=タスクです。
ソースコード全体はこちらにおいてあります。

ブートローダ

VTORレジスタで割り込みベクタテーブルをRAMに

上記のコンテキストスイッチの仕組みから分かるように、割り込みでレジスタ退避と復帰処理が必要になります。
STM32 Nucleo Boardで割り込みベクタテーブルをRAMにでは、ブートローダにある割り込みベクタテーブルからメインプログラムのあるRAM上のテーブルに飛ばしていました。
そのためメインプログラムでは割り込み発生時のレジスタ状態をメモリに退避することができません。
そこで今回はVTORレジスタ(ARM Cortex-Mにあるレジスタ)で割り込みベクタテーブルをROMからRAMに切り替えます。
割り込み発生時に直接RAM上の割り込みハンドラが呼ばれるため、レジスタ退避処理をメインプログラムで記述することができます。

system_stm32f30x.c
  :
#define VECT_TAB_SRAM
#define VECT_TAB_OFFSET  0x200 /*!< Vector Table base offset field.
                                  This value must be a multiple of 0x200. */
  :
  SCB->VTOR = SRAM_BASE | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal SRAM */
  :

メインプログラム側でSRAM_BASE + 0x200の位置にベクタテーブルが配置されるようにリンカスクリプトの変更も必要です。
メインプログラム(RAM)のベクタテーブルはこのようにC言語で書けます。

OS

作成するOSは下図のようになります。
基本的な構造は組み込みOS自作入門を参考にしています。

os.png

最初にOSに実行するスレッドを登録します。OSは登録されたスレッドをスケジュールして実行するという形になります。

タスクコントロールブロック(TCB)

タスクの生成情報が格納されています。
・次のスレッドへのポインタ
・プログラム名
・プログラム(関数、引数)
・スタックアドレス
・コンテキスト情報
TCB生成処理は次のようなコードになります。

static rtos_thread_id_t thread_run(rtos_func_t func, char* name, int stacksize, int argc, char* argv[])
{
    int i;
    rtos_thread *thp;
    uint32_t *sp;
    extern char _userstack; // from Linker script
    static char *thread_stack = &_userstack;

    for(i = 0; i < THREAD_NUM; i++){
        thp = &threads[i];
        if(!thp->init.func)
            break;
    }
    if(i == THREAD_NUM)
        return -1;

    memset(thp, 0, sizeof(*thp));

    /* set TCB */
    strcpy(thp->name, name);
    thp->next = NULL;

    thp->init.func = func;
    thp->init.argc = argc;
    thp->init.argv = argv;

    /* allocate stack area */
    memset(thread_stack, 0, stacksize);
    thread_stack += stacksize;

    thp->stack = thread_stack;

    sp = (uint32_t*)thp->stack - 16;

    sp[15] = (uint32_t)0x01000000;  // xPSR
    sp[14] = (uint32_t)thread_init; // PC
    sp[8]  = (uint32_t)thp;         // r0:Argument

    thp->context.sp = (uint32_t)sp;

    current = thp;
    putcurrent();

    return (rtos_thread_id_t)current;
}

sp(スタックポインタ)のアドレスからコンテキストの初期情報を作成しています。このコンテキスト情報をCPUに復帰させるとスレッドが実行されます。

レディキュー

実行可能なスレッドをキューにつないで管理します。ここではすべてのスレッドを実行可能(レディ)とします。
キューの先頭にあるエントリ(Head)をカレントスレッド(current)にします。
readyque.png
レディキューにスレッドを追加する処理は以下のようになります。

static int putcurrent(void)
{
    if(current == NULL){
        return -1;
    }

    if(readyque.tail){
        readyque.tail->next = current;
    }else{
        readyque.head = current;
    }
    readyque.tail = current;

    return 0;
}

生成したスレッドはカレントスレッドになっていますが、そのスレッドをレディキューの末尾に追加しています。

スケジューリング

次に実行するスレッドを決定することをスケジューリングと呼びます。
ここでは、単純にタスクを順番に実行します。カレントスレッドの実行後はキューの末尾に接続します。

void schedule(void)
{
    if(!readyque.head)
        RtosSysdown();

    current = readyque.head;

    readyque.head = current->next;
    readyque.tail->next = current;
    readyque.tail = current;
    readyque.tail->next = NULL;
}

コンテキストスイッチ

S/Wで割り込みを発生させ、コンテキストスイッチを行います。
ARM Cortex-MにはPendSVというS/Wから要求できる割り込みがあり、それを利用します。
PendSVはシステムサービスの要求に使用され、優先度が高い割り込みに対しては処理が保留されます。
そのため、優先度を適切に設定しておけば、すべての割り込み処理が終わってからPendSVハンドラが実行されます。
PendSVハンドラでは実行中のコンテキストの退避と次に実行するコンテキストの復帰を行います。

コンテキストの退避

ARM Cortex-Mでは割り込み発生時にxPSR、PC、LR、R12、R3、R2、R1、R0は自動でスタックに退避されます。
残りのレジスタは自動で退避されないためS/Wで退避させる必要があります。
スタックはメインスタックとプロセススタックという2つがあり、それぞれを指定するため、
メインスタックポインタ(msp)とプロセススタックポインタ(psp)があります。
ユーザースレッドではプロセススタック使用したいため、レジスタ退避と復帰にはpspを指定します。

str.png

コードは次のようになります。

    __asm(                      // R12をワーク用スタックとして利用
            "mrs    r12,psp;"           // R12にPSPの値をコピー
            "stmdb  r12!,{r4-r11};"     // 自動退避されないR4~R11を退避
            "movw   r2,#:lower16:current;"  // *(current->context) = R12;
            "movt   r2,#:upper16:current;"
            "ldr    r0,[r2,#0];"
            "str    r12,[r0,#44];"
         );

コンテキストの復帰

復帰するときは逆の処理になります。

load.png

コードは次のようになります。

   __asm (
           "movw    r2,#:lower16:current;"  // R12 = *(current->context);
           "movt    r2,#:upper16:current;"
           "ldr     r0,[r2,#0];"
           "ldr     r12,[r0,#44];"

           "ldmia   r12!,{r4-r11};"     // R4~R11を復帰

           "msr     psp,r12;"           // PSP = R12;
           "bx      lr;"                // (RETURN)
         );

PendSVハンドラ

現スレッドレジスタ退避→次のスレッドのスケジューリング→次のスレッドのレジスタ復帰の流れになります。

void PendSV_Handler(void) __attribute__ ((naked));
void PendSV_Handler(void)
{
    __asm(                      // R12をワーク用スタックとして利用
            "mrs    r12,psp;"           // R12にPSPの値をコピー
            "stmdb  r12!,{r4-r11};"     // 自動退避されないR4~R11を退避
            "movw   r2,#:lower16:current;"  // *(current->context) = R12;
            "movt   r2,#:upper16:current;"
            "ldr    r0,[r2,#0];"
            "str    r12,[r0,#44];"
         );

   // 次スレッドのスケジューリング
   __asm(
           "push    {lr};"
           "bl      schedule;"
           "pop     {lr};"
        );

   __asm (
           "movw    r2,#:lower16:current;"  // R12 = *(current->context);
           "movt    r2,#:upper16:current;"
           "ldr     r0,[r2,#0];"
           "ldr     r12,[r0,#44];"

           "ldmia   r12!,{r4-r11};"     // R4~R11を復帰

           "msr     psp,r12;"           // PSP = R12;
           "bx      lr;"                // (RETURN)
         );
}

__attribute__ ((naked));は、

indicate that the specified function does not need prologue/epilogue
sequences generated by the compiler

ということで、コンパイラによるレジスタ退避などを行わせないためのアトリビュート。

スレッド切り替え

Cortex-Mはマイコンの周辺機能とは別にSysTickタイマ(システムタイマ)を持っています。
カウント終了割り込み発生機能が付いているため、定期的な割り込みを発生させることができます。
周期的な割り込みでPendSVを発生させコンテキストスイッチの契機とします。
SysTickタイマ割り込みの優先度をPendSV割り込みより高く設定することで、SysTickハンドラ後にPendSVハンドラが実行されコンテキストスイッチされます。
下記はSysTickタイマ割り込みのハンドラです。

unsigned int sticks = 3;
void SysTick_Handler()
{
    if (rtos_start) {
        if (sticks)
            sticks--;
        else {
            sticks = 3;
            SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
        }
    }
}

3回のSysTick割り込みでPendSV割り込み要求が行われます。

OS起動

OSの起動はシステムコール(OSのサービスを呼び出す命令)により行います。
Cortex-MではSVC(Supervisor Calls)という、OSに対する要求を行うのに便利な命令があります。
SVC命令を実行すると、その時点であたかもH/Wによる割り込みが受け付けられたのと同じような割り込み処理動作が行われます。
OS起動前にTCBを作成し、システムコールでPendSV要求を発行すると、最初のスレッドのコンテキストが復元され、処理が開始されます。

ユーザースレッドの動作および実行モード

ここでOSがユーザースレッドを開始する際に設定する動作および実行モードについて説明します。
Cortex-M4プロセッサは、以下のモードをサポートしています。
・スレッドモードとハンドラモードの2つの動作モード
・特権モードとユーザモードの2つの実行モード
スレッドモードは、リセット時、および割り込みからの復帰時に開始されます。
スレッドモードでは、特権モードまたはユーザモードのいずれかでコードを実行できます。
ハンドラモードは、割り込みが発生した結果、開始されます。 コードはすべて特権モードで実行されます。
割り込みが発生すると、コアは自動的に特権モードに切り替わります。
特権モードにはフルアクセス権が備わっています。

シングルタスクで動作しているときは、特権モードで動作してもそれほど問題はないと思います。
しかしOSを使った場合、特権モードのユーザースレッドの暴走はシステムを壊す恐れがあります。
そのためユーザースレッドはユーザーモードで動かすようにします。
ユーザーモードに移行するために、割り込みからの復帰時にリンクレジスタ(LR)内のEXC_RETURN[3:0]を1101にする操作を行います。

unsigned int svcop;
void SVC_Handler(void) __attribute__ ((naked));
void SVC_Handler(void)
{
    __asm(
            "mov    r0,lr;"     // if ((R0 = LR & 0x04) != 0) {
            "ands   r0,#4;"     //          // LRのビット4が'0'ならハンドラモードでSVC
            "beq    .L0000;"    //          // '1'ならスレッドモードでSVC
            "mrs    r1,psp;"    //  R1 = PSP;   // プロセススタックをコピー
            "b      .L0001;"    //
            ".L0000:;"          // } else {
            "mrs    r1,msp;"    //  R1 = MSP;   // メインスタックをコピー
            ".L0001:;"          // }
            "ldr    r2,[r1,#24];"   // R2 = R1->PC;
            "ldr    r0,[r2,#-2];"   // R0 = *(R2-2);    // SVC(SWI)命令の下位バイトが引数部分

            "movw   r2,#:lower16:svcop;"    // svcop = R0;      // svcop変数にコピー
            "movt   r2,#:upper16:svcop;"
            "str    r0,[r2,#0];"
         );

    switch(svcop & 0xff){
        case RTOS_SYSCALL_CHG_UNPRIVILEGE:
            __asm(
                    "movw   r2,#:lower16:current;"
                    "movt   r2,#:upper16:current;"
                    "ldr    r0,[r2,#0];"
                    "ldr    r2,[r0,#44];"
                    "msr    PSP, r2;"
                    "orr    lr, lr, #4;" // Return back to user mode
                                         // 1001:msp使用(プロセス) 1101:psp使用(スレッド)
                                         // なので、セットするとユーザーモードになる
                 );
            rtos_start = 1;
            SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
            break;
        default:
            break;
    }
    __asm(
            "bx     lr;"
         );
}

テスト

ユーザースレッドとして次の関数を動作させます。

static int test(int argc, char *argv[])
{  
    while(1){
        printf("Hello\n");
    }
    return 0;
}

static int demo(int argc, char *argv[])
{  
    while(1){
        printf("Money\n");
    }
    return 0;
}

static int proto(int argc, char *argv[])
{  
    while(1){
        printf("Gold\n");
    }
    return 0;
}

そしてmain関数は次のようになります。

int main(void)
    __attribute__ ((section (".entry_point")));
int main(void){  

    printf("main boot succeed!\n");  

    SysTick_Config(SystemCoreClock/10); // 1/10秒(=100ms)ごとにSysTick割り込み
    NVIC_SetPriority(SVCall_IRQn, 0x80);    // SVCの優先度は中ほど
    NVIC_SetPriority(SysTick_IRQn, 0xc0);   // SysTickの優先度はSVCより低く
    NVIC_SetPriority(PendSV_IRQn, 0xff);    // PendSVの優先度を最低にしておく

    __enable_irq(); // enable interrupt

    /* OSの初期化とスレッドの作成 */  
    RtosInit();
    RtosThreadCreate((rtos_func_t)test, "test", 0x100, 0, NULL);
    RtosThreadCreate((rtos_func_t)demo, "demo", 0x100, 0, NULL);
    RtosThreadCreate((rtos_func_t)proto, "proto", 0x100, 0, NULL);

    /* OSの動作開始 */  
    RtosStart();  

    /* ここには戻ってこない */  
    return 0;
}

実行。
hello.PNG

money.PNG

gold.PNG
切り替わっているね!!

おわりに

OSを設計するには力が足りませんが、マルチタスクの基礎的な仕組みは理解できました。
また、CPUのアーキテクチャに依存する部分もあるためARMの勉強になりました。

参考書籍

12ステップで作る組み込みOS自作入門
ARMでOS超入門

23
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
16