Skip to content
kamaboko123 edited this page Apr 6, 2024 · 37 revisions

タイマは、一定の時間後あるいは一定の周期ごとに何かの動作を発生させるために使用します。
(プリエンプティブ)マルチタスクOSの場合、1つのタスクに割り当てる時間を決めておき、その時間が経過したら別のタスクに切り替えます。
このときタイマを使用して一定時間ごと(タスクの優先度に応じて割当時間を変える場合はタスクや優先度ごとに決められた時間ごと)に、タスクの切り替えを発生させます。
uroborosでのタイマは、まず8253/8254 PITと呼ばれるチップが一定周期ごとに割り込みを発生させるところから始まります。
このページでは、PITの初期化と利用方法、PITによる割り込みで動作するタイマの実装について解説します。

PIT

PC/AT互換機には、8253/8254と呼ばれるチップが搭載されており、これらはPIT(Programable Interval Timer)と呼ばれタイマ機能を持つLSIです。
(8253と8254は同様の機能を持つチップですが、後者は前者の改良版です。)
PITの設定により、指定した時間ごとに割り込みを発生させることができます。
(割り込みについては、PICの設定も必要となるので、Interruptのページも参照してください。)

参考
8253データシート
Programmable Interval Timer - OSDev Wiki

PITの動作

PITにはクロックが与えられ、このクロックを分周と呼ばれる仕組みを通して、任意の周期でタイマを利用できるようにします。
分周とは、ある周波数をカウンタを用いて割ることでより遅い周波数を得ることです。
例えば、100Hzの周波数の入力クロックがあるとして、クロックの立ち上がりごとにカウンタを繰り上げていきます。
そして、カウンタが10になったときに、出力クロックを変化させるようにすると、10で割られることになり10Hzのクロックが得られます。
PITではこの分周を利用して、基本になる周波数から任意の周波数(作れない周波数もある)で動作するタイマを設定することができます。

x86ではPITにおよそ1.193182MHzのクロックが与えられます。
8253/8254には3つの分周器があり、それぞれChannel 0, Channel 1, Channel 2と呼ばれます。

Channel 0は、PICのIRQ0に接続されています。
つまりこれがタイマによる割り込みを発生させるのに利用されます。
Channel 1は、もともとDRAMのリフレッシュタイマとして利用されたChannelです。
現在はPITによりリフレッシュ間隔を管理することがなくなったため、Channel 2は特に意味がなくなっています。
Channel 3は、スピーカーにつながっています。
周波数を制御することでスピーカーから出力される音を変化させることができます。

タイマ割り込みではチャンネル0のみを利用するため、このページでは他のチャンネルについては扱いません。

PITの設定方法と動作モード

PITは、以下のIOポートで制御することができます。

IOポート 用途
0x40 Channel 0 data
0x41 Channel 1 data
0x42 Channel 2 data
0x43 Mode/Command Register

Channel 0-3のデータポートは、読み込むと現在のカウンタの値を取得することができ、書き込むことでリロード値を変更することができます。
リロード値は、カウンタが2から1になり、タイマが作動したあとに、次のタイマ動作を行うためにカウンタに再設定される値です。
(カウンタ値とリロード値がどのように扱われるかは、PITの動作モードにより異なります。これはmode2の場合です)
つまり、各Channelのデータポートへの書き込みで、タイマの周期を変更することができます。

Mode/Command Registerは、書き込むことでPITの動作に関する設定ができます。読み込みは無視されます。
以下はMode/Command Registerのポートに対する制御データのフォーマットです。

  • bit0
    • BCDモード(1=有効, 0=無効)
  • bit1-3
    • 動作モード
    • 0-5までの値を3bitで設定します(uroborosではmode2を使用します)
  • bit4-5
    • アクセスモード
      • 00: カウンタ値ラッチモード
      • 01: アクセスモード lo_byte
      • 10: アクセスモード hi_byte
      • 11: アクセスモード lo_byte/hi_byte
  • bit6-7:
    • Channel選択
    • どのChannelに対する設定であるかを2bitで設定します
    • 11が設定されると、動作モードの設定ではなくリードバックコマンドとなります(uroborosでは使用してないので説明は省略します)

BCDモードが有効になるとカウンタの値は、10進4桁(0-9999の範囲)で扱われます。
無効の場合は、バイナリモードになり2進16桁で扱われます。(uroborsではバイナリモードで使用しています。)
補足すると、BCDとは4bitを10進1桁として扱う数値の表現方式です。
例えば、0x86(b1000 0110)は10進数では134ですが、BCDモードではこれが86として扱われます。
BCDモードが有効の場合、カウンタ値・リロード値もBCDで扱われるため注意が必要です。
なお近年のチップセットに集積されたPITでは、BCDモードは実装が省略されていて、利用できない場合もあるようです。

アクセスモードは、各Channelのデータポートで扱われるデータの扱い方を設定します。
データポートで読み書きされる値は16bitですが、PITに対する操作は8bitで行われます。
そのため、読み書きされる8bitが、全16bitのうち上位8bitなのか下位8bitなのかが、このアクセスモードにより決定されます。
hi_byteの場合は上位8bit、lo_byteの場合は下位8bitをそれぞれ扱う設定になります。
lo_byte/hi_byteに設定されると、読み書き操作を行った場合に、下位→上位の順にデータが操作されます。(2回の操作が必要)

カウンタ値ラッチモードは、カウンタ値を読み出す際に利用されます。
利用したことないので、誤っているかもしれませんが、以下のように解釈しています。
前述の通り、1度の読み出しでは8bitしか扱うことができないため、カウンタ値を読み出すには2回の読み出しが必要になりますが、その間もカウンタの値は変化します。
正確にその瞬間のカウンタ値を読み出すために、PITはカウンタ値を内部でラッチ(保持)しておくことができます。
カウンタ値ラッチモードが有効にすると、その瞬間のbit6-7で設定したカウンタの値をPIT内部にラッチします。
カウンタ値ラッチモードを有効にする前に、アクセスモードがlo_byte/hi_byteに設定されていた場合、ラッチされた値を2回の読み出しで、下位→上位の順に読み出すことができます。
lo_byte, hi_byteの場合は、1回の読み出しで下位・上位いずれかの値を読み出します。
なお、カウンタ値ラッチが設定された場合、対象としたChannelのカウンタ値が読み出されるまで、同じChannelに対するラッチは受け付けられないようです。
(ただし更新されないのか、何かしらエラーのような状態になるのか不明)
また、このビットが設定されると、bit1-3は無視されます。(動作モードの設定は行われない)

PITには動作モードが5つあり、各動作モードごとに信号の出し方や、カウンタ値・リロード値の扱い方が変化します。
uroborosではmode2のみを利用するので、mode2の説明だけ記載します。
その他のモードについては、データシートやその他参考資料を参照してください。

mode2はレートジェネレータと呼ばれるモードです。
カウンタは分周器として動作し、指定されたリロード値でクロックを分周します。
リロード値が設定されると、カウンタはリロード値でリセットされ、クロックの入力のたびにカウンタはデクリメントされていきます。
通常、出力される信号(OUTピン)はHIであり、カウンタが2から1になるタイミングで、LOに切り替わります。
次のクロックの入力で、出力はHIに戻り、カウンタはリロード値でリセットされます。
ここから再びクロックの入力によりデクリメントが開始され、タイマとしての動作が繰り返されます。
image
8253のデータシートより

PITの初期化

アクセスモードや動作モードなど少し説明が長くなってしまいましたが、実際にPITを初期化して利用するのは難しくありません。

//IOポートアドレス
#define PORT_PIT_COUNTER0 0x40
#define PORT_PIT_COUNTER1 0x41
#define PORT_PIT_COUNTER2 0x42
#define PORT_PIT_CONTROL 0x43

// BCDモード有効
#define PIT_CW_BCD 0x01

//カウンタモード
#define PIT_CW_MODE0 0x00 << 1
#define PIT_CW_MODE1 0x01 << 1
#define PIT_CW_MODE2 0x02 << 1
#define PIT_CW_MODE3 0x03 << 1
#define PIT_CW_MODE4 0x04 << 1
#define PIT_CW_MODE5 0x05 << 1
#define PIT_CW_MODE6 0x06 << 1
#define PIT_CW_MODE7 0x07 << 1

//読み込み
#define PIT_CW_RL_LOAD 0x00 << 4
//書き込み下位バイト
#define PIT_CW_RL_WRITE_LSBYTE 0x01 << 4
//書き込み上位バイト
#define PIT_CW_RL_WRITE_MSBYTE 0x02 << 4
//書き込み順番: 下位->上位
#define PIT_CW_RL_WRITE 0x03 << 4

//カウンタセレクト
#define PIT_CW_SC_COUNTER0 0x00 <<  6
#define PIT_CW_SC_COUNTER1 0x01 <<  6
#define PIT_CW_SC_COUNTER2 0x02 <<  6

void init_pit(uint16_t c0_freq){
    io_out8(PORT_PIT_CONTROL, PIT_CW_MODE2 | PIT_CW_RL_WRITE | PIT_CW_SC_COUNTER0);
    io_out8(PORT_PIT_COUNTER0, c0_freq & 0xff);
    io_out8(PORT_PIT_COUNTER0, c0_freq >> 8);
}

色々な定数を宣言していますが、実際に使うのはたった3つだけです。
PITの制御用ポート(0x43, Mode/Command Register)に対して、Channel 0のカウンタを、モード2で、アクセスモードはlo_byte/hi_byteモードで、設定するようにデータを送ります。
そのあと、Channel 0のデータポート(0x40)に対して、リロード値を送信します。(これがカウンタの周期を決定します)
リロード値は16bitで設定されますが、lo_byte/hi_byteモードで設定することにしたので、下位→上位の順番で合計16bit(8bit * 2回)を送信します。

リロード値は分周されるときの逓倍値を設定します。
例えば、PITに与えられるクロックは約1.193182MHzなので、以下のように設定するとだいたい10msごとに割り込みが入ることになります。

init_pit(11932);

周期10ms = 周波数100Hz
1193182 / 1000Hz ≒ 11932

なお0を設定すると最大値(65536)が設定されたとみなされます。(0では割れないのでPITでは最大値とみなされる)
この場合は、だいたい18Hz(周期約56ms)くらいです。

PITによる割り込み

Interruptで、8259 PICの説明をしていますが、PICは割り込み要求のトリガモードをエッジトリガモードで設定しています。
これは、信号の変化(エッジ)をトリガ(動作を開始する)条件とするものです。
上述の通り、mode2ではPITでタイマが作動するときに、信号がHIからLOに変化します。
PITの出力はPICのIRピン(通常はIRQ0)と接続されているので、PICはこのHIからLOに変化したエッジを検出し、割り込み動作を開始します。
割り込み後の動作、割り込みハンドラの詳細については、Interruptのページを参照してください。

void int_handler(IntrFrame iframe){
    if(iframe.intrnum == PIC_INTR_VEC_BASE + PIC_IRQ0){
        // timer
        tick_timer();
        //先にEOIを送っておく
        //スケジューラを呼ぶとコンテキストスイッチが起こってしまうため
        pic_eoi(PIC_IRQ0);

        //タスクスイッチ用のタイマが作動したらスケジューラを呼び出してタスクを切り変える
        if(!q8_empty(SYSQ->task_timer)){
            q8_de(sys->task_timer);
            sched_handler();
        }
    }
    //省略
}

PITからの割り込みがあると、割り込みハンドラでは後述するソフトウェア上でのタイマを動作させるための処理を呼び出します。
このとき、タスクスイッチ用のタイマが作動すると、タスクスイッチの処理に移ります。
PITの割り込みは、マルチタスク処理でCPUの計算資源を時分割するためのタスク切り替えの契機に利用されます。
(タスクスイッチについては、Multi Taskのページを参照してください。)

タイマの実装と利用例

概要

PITの利用により、タイマが実現できますが、OS上ではタイマは複数利用できる方が都合が良いです。
タスクごとに異なる周期でのタイマが必要になったりするためです。
ここでは、PITの割り込みを利用して複数のタイマを動作させることを目標にします。

基本的なアイデアとしては、PIT内部の分周と同じです。
複数のカウンタを用意しておき、それらをPITの割り込みにより駆動させる仕組みです。
まずタイマ用のカウンタを複数用意しておき、各カウンタはそれぞれ初期値を持ちます。
PITからの割り込みがあるたびに全てのタイマのカウンタをデクリメントしていき、カウンタは0になるとそのタイマが作動したとみなします。
(一定周期ごとに利用する場合は、このあとカウンタを再び初期値に戻し、カウントアップを再開します)
作動したカウンタは、それを検知できるように、そのタイマに紐づくFIFOにデータを入れておき、あとから実際にそのタイマを必要とする処理がタイマの作動を検知できるようにします。

例えば、PITの割り込みを0.1秒単位にしておいて、タイマAを1秒後、タイマBを5秒後に作動するように設定します。
タイマAのカウンタの初期値は10(1/0.1=10)、タイマBのカウンタの初期値は50(5/0.1=50)にします。
PITの割り込みが入ると、それぞれのタイマのカウンタをデクリメントします。
そして各カウンタが0になったときに作動し、それぞれ割り当てられたFIFOに何かしらのデータを入れます。

上記のように単純な仕組みでタイマを複数利用できるようにします。

実装

タイマ管理

それぞれのタイマは双方向リストの要素として管理します。
固定長の配列などで管理するのも悪くないですが、固定長とすると利用していないタイマについてもメモリを割り当てる必要が出てきます。
また、割り当てられるタイマに上限を作ってしまうことになります。
タイマは多くのプログラムで利用されることが想定されるため、制限なく割当ができるようにしつつ、メモリも効率よく使えるようにしたいです。
そこで、各タイマのメモリは動的に割り当てを行い、各タイマを双方向リストとして管理することで拡張出来るようにします。

+--------+     +--------+     +--------+     +----------+
| timer0 | <-> | timer1 | <-> | timer2 | <-> | timer(N) | 
+--------+     +--------+     +--------+     +----------+

まずはタイマ用の構造体を用意します。

typedef struct TIMER{
    struct TIMER *next;
    struct TIMER *prev;
    Queue8 *q;
    uint32_t interval;
    uint32_t count;
    uint8_t mode;
} TIMER;


typedef struct TIMERCTL{
    TIMER *t;
} TIMERCTL;

TIMER構造体は、タイマごとに作られます。
前述の通り双方向リストとするため、前方と後方へのポインタを持ちます。
Queue8のポインタは、タイマが作動した際にその旨を通知するためのFIFO(キュー)です。
FIFOについては、FIFO(Queue)のページを参照してください。
intervalは、タイマが作動する間隔です。
countは、このタイマ用のカウンタです。intervalの値で初期化され、割り込みが入るたびにデクリメントされて、0になると作動とみなされintevalの値に戻ります。

TIMERCTL構造体は、タイマ全体を管理するための構造体で、OS上に1つだけ作成されます。
現在は、リストの先頭タイマへのポインタのみを保持しているだけです。

初期化

extern SYSTEM *sys;

void init_timer(){
    sys->timerctl =(TIMERCTL *) kvmalloc(sizeof(TIMERCTL));

    // dummy
    sys->timerctl->t = (TIMER *)kvmalloc(sizeof(TIMER));
    sys->timerctl->t->next = NULL;
    sys->timerctl->t->prev = NULL;
    sys->timerctl->t->q = NULL;
    sys->timerctl->t->interval = 0;
    sys->timerctl->t->count = 0;
    sys->timerctl->t->mode = 0;
}

sysはシステム全体の情報を保持する構造体です。Common Functions and Variablesのページを参照してください。

まずは、TIMERCTL用の領域を確保します。
続いてリストの先頭のTIMER用の領域を確保し、初期化していきます。
先頭のタイマはダミーであり、これ自体はタイマとして動作しません。
(ダミーがあると各タイマがリストの先頭である場合の条件分岐を省略でき、コードの実装が簡単になるのでとりあえず作っています。)
そのため、FIFOも不要ですし、intervalもとりあえず0としておきます。後述しますがintervalが0のタイマは動作させません。

タイマの作成と割り当て

タイマを新しく作って割り当てる処理です。
まずは、現在の割り込みフラグを取得し、割り込みを禁止しておきます。
タイマの確保中にタイマ割り込みが入って、他のタイマが作動されたりするのを防ぐためです。
次に新しくタイマ用のメモリを確保し、指定された間隔や、動作モードに初期化します。
前述の通り、タイマは双方向リストになっているので、最後尾に新たに作成するタイマを追加します。
割り込みフラグをもとに戻し、最後に新しいタイマを呼び出し元に返します。

// タイマの動作モード
// CONTINUES: タイマは指定されたキューにデータが入っていても定期的にデータを入れ続けます
// ONESHOT  : タイマは指定されたキューにデータが入っていない場合、データを入れません
#define TIMER_MODE_CONTINUES 0
#define TIMER_MODE_ONESHOT   1

TIMER *alloc_timer(Queue8 *q, uint32_t interval, uint8_t mode){
    //自動的にタイマを作動させたくない場合は、この関数を呼ぶ前にqueueになにかデータを入れておく
    bool iflag = load_int_flag();
    io_cli();
    TIMER *t = sys->timerctl->t;

    while(t->next != NULL) t = t->next;
    TIMER *new_timer = (TIMER *)kvmalloc(sizeof(TIMER));

    new_timer->next = NULL;
    new_timer->prev = t;
    new_timer->q = q;
    new_timer->interval = interval;
    new_timer->count = interval;
    new_timer->mode = mode;
    t->next = new_timer;
    store_int_flag(iflag);

    return new_timer;
}

void free_timer(TIMER *t){
    bool iflag = load_int_flag();
    io_cli();

    if(t->next == NULL){
        t->prev->next = t->next; //NULL
    }
    else{
        //次がある場合は繋ぎ変え
        t->prev->next = t->next;
        t->next->prev = t->prev;
    }
    //先頭にはdummyのtimerがあるので、前がないケースは考慮不要
    kvfree(t);
    store_int_flag(iflag);
}

割り込みによるカウントダウン

この関数は、タイマ割り込みが入った場合に毎回呼ばれることになります。
タイマをたどりながら、各タイマのカウンタをデクリメントしていきます。
もしカウンタが0になったら、タイマは作動したとみなして、FIFOにデータを入れます。

タイマの動作モードにより、FIFOの状態に応じて動作が異なります。
TIMER_MODE_CONTINUES: FIFOにデータが残っている場合でもintervalごとにデータを入れ続ける(タイマが利用側でFIFOをクリアしなくてもタイマの作動回数が記録される)
TIMER_MODE_ONESHIT: FIFOにデータが残っている場合は、カウンタを更新せずFIFOは更新されない(タイマが利用側でFIFOをクリアしないと次回のタイマは作動しない)

また、intervalが0の場合は、そのタイマは無効とみなしてカウンタを更新しません。

void tick_timer(){
    for(TIMER *t = sys->timerctl->t; t != NULL; t = t->next){
        if(t->interval == 0) continue;

        if((t->mode == TIMER_MODE_ONESHOT) && !q8_empty(t->q)){
            t->count = t->interval;
            return;
        }

        t->count--;
        if(t->count == 0){
            q8_in(t->q, 1);
            t->count = t->interval;
        }
    }
}

タイマの作動

タイマが作動すると、そのタイマに紐づくFIFOにデータが入ります。
タイマの利用側ではFIFOを確認することで、タイマが作動したかどうかを確認することができます。
以下はタイマが作動するまでFIFOの状態を監視し、待機する例です。

//qはタイマのFIFO
for(;;){
    if(!q8_empty(q)){
        //タイマが作動したときの処理
        //次に備えて空にしておく
        q8_de(q);
        break;
    }
}

利用例

タイマは様々な用途に利用されますが、OSにおいて一番大切であろうマルチタスクのタスク切り替えでの利用例を紹介します。

タスク切り替え

CPUの1つのコアは同時に1つの作業しか行うことができません。
マルチタスクを行うには、時分割でこのCPUの計算資源をタスクに割り当てていく必要があります。
実際には1つの処理しか行われていませんが、1つのタスクに割り当てる時間を短く(数ms~数百ms程度)しておき、次々と処理するタスクを切り替えていくことで、同時に処理が行われているように見せます。

計算資源の割り当ては時分割で行われるため、CPUは定期的にタスクを切り替える必要があります。
そこで、この定期的なタスク切り替えのきっかけとして、タイマ割り込みが使用されます。

タイマの初期化とタスクスイッチ用タイマの設定
//PITの初期化(約10msごとにタイマが作動する)
init_pit(11932);
//IDTの設定(PITの割り込みで呼び出されるハンドラを設定する)
set_idt((IDT *)IDT_ADDR, 0x20, int20_handler);
//タイマの初期化
init_timer();

//タスクスイッチタイマ用のFIFOを確保する
system->task_timer = q8_make(256, 0);
//タスクスイッチタイマ用のタイマの割り当て(interval=1, タイマ割り込みのたびに毎回作動する)
alloc_timer(system->task_timer, 1, TIMER_MODE_ONESHOT);
割り込みハンドラ

割り込み時の動作や、スタック、ハンドラの詳細などはInterruptのページを参照してください。

PITからの割り込み時に呼び出されるハンドラは以下のようなものです。

global int20_handler
int20_handler:
    push 0
    push 0x20
    push ds
    push es
    push fs
    push gs
    pusha
    jmp all_interrupt

global all_interrupt
global all_interrupt_ret
all_interrupt:
    call int_handler
all_interrupt_ret:
    popa
    pop gs
    pop fs
    pop es
    pop ds
    add esp, 8
    iret

スタックを揃えたあとに、int_handlerを呼び出します。
IRQ0(PIT)からの割り込みは、タイマのカウントを進める処理(tick_timer)を呼び出します。
そして、タスクスイッチ用のタイマが作動する(FIFOにデータが入る)と、FIFOからデータを取り出して、sched_handlerを呼び出します。

void int_handler(IntrFrame iframe){
    if(iframe.intrnum == PIC_INTR_VEC_BASE + PIC_IRQ0){
        // timer
        tick_timer();
        //先にEOIを送っておく
        //スケジューラを呼ぶとコンテキストスイッチが起こってしまうため
        pic_eoi(PIC_IRQ0);
        
        //タスクスイッチ用のタイマが発火したらスケジューラを呼び出してタスクを切り変える
        if(!q8_empty(sys->task_timer)){
            q8_de(sys->task_timer);
            sched_handler();
        }
    }

    //省略
}

sched_handlerはタスクスケジューラで、呼び出されると現在のタスクを中断して、次のタスクへの切り替えが行われます。
(sched_handlerの詳細は、Interruptを参照してください)
そして、切り替え先のタスクの実行中に再びPITからの割り込みが入ることで、タイマのカウントが更新されてタイマが作動し、再びsched_handlerが呼び出されることでまたタスク切り替えが行われます。
これを繰り返すことで、マルチタスクが実現されています。