無限遠まで突撃中

ネット日記書きの徒然。

自作OSでマルチタスクやった

この記事は coins Advent Calendar 2019 3日目の記事です。

この記事は WORDIAN Advent Calendar 2019 3日目の記事です。。

この記事は OS-CPU Advent Calendar 2019 3日目の記事です。。。

師走の挨拶

どうもこんにちは、突撃隊です。

64bit自作OS「minOS」をちまちま作っとるのですが、11月でついにマルチタスクを実装できたので解説します。

github.com

設計

スレッドかプロセスか

OSのタスク単位としてとりあえずスレッドを実装することにしました。 プロセスはメモリ空間を分離しなくてはいけないので、その分コーディングが大変だからです。 スレッドならスタックを用意してあげるだけなので簡単簡単。

thread 構造体

スレッドごとの構造体はこんな感じにしてみました。

struct thread_func {
    void (*func)(int, char**);
    int argc;
    char **argv;
};

enum thread_state {
    RUNNABLE,
    SLEEP,
    DEAD
};

struct thread {
    uint64_t *stack;
    uint64_t rsp;
    // uint64_t rip;
    struct thread_func func_info;
    enum thread_state state;
    int index;
};

struct thread ですが、上から順に

  1. スレッドのスタック領域の開始アドレス
  2. スレッドごとのスタックポインタ
  3. スレッドで実行する関数の情報
  4. スレッドの状態
  5. スレッドキューに登録した際のインデックス

です。

順番に解説していきます。

スレッドのスタック

自作の malloc 関数を用いてスタック領域を確保します。 大きさは 0x1000 = 4kB としています。

malloc 関数のソースコードはこちら。

https://github.com/Totsugekitai/minOS/blob/develop/kernel/mm/memory.c

スレッドごとのスタックポインタ

汎用レジスタはスタックに push しますが、スタックポインタは構造体に持たせるほうがやりやすそうです。

スレッドで実行する関数の情報

関数ポインタとその引数を受け取ります。

スレッドの状態

RUNNABLE SLEEP DEAD の3種類を用意しておきました。

スレッドキューに登録した際のインデックス

minOSではスレッドのキューはただの配列です。 スレッドの削除時に配列に登録したときのインデックスが必要になるのでパラメータとして持たせてあります。

どのタイミングでスレッド切り替えをするか

タイマ割り込みのタイミングでスレッド切り替えをすることにしました。 タイマ割り込みの何回かに一回にスケジューラを呼び出してタスクを切り替えます。

coinsLT#110で話したときの資料が詳しいです。

docs.google.com

実装

今回のキモはスレッドのスタックの初期化処理とスレッドを切り替える瞬間の処理です。

スタックの初期化処理

/** スレッドの生成
 * thread構造体の初期化とスタックの初期化を行う
 */
struct thread thread_gen(void (*func)(int, char**), int argc, char **argv)
{
    struct thread thread;

    thread.stack = (uint64_t *)minmalloc(STACK_LENGTH);
    thread.rsp = (uint64_t)(thread.stack + STACK_LENGTH);
    thread.rip = (uint64_t)thread_exec;
    thread.func_info.func = func;
    thread.func_info.argc = argc;
    thread.func_info.argv = argv;

    put_str_num_serial("thread stack bottom: ", (uint64_t)thread.stack + STACK_LENGTH);
    put_str_num_serial("thread rsp: ", thread.rsp);

    return thread;
}

void thread_stack_init(struct thread *thread)
{
    thread->rsp = init_stack(thread->rsp, thread->rip, thread);
    put_str_num_serial("thread stack bottom: ", (uint64_t)thread->stack + STACK_LENGTH);
    put_str_num_serial("thread rsp: ", thread->rsp);
}

void thread_exec(struct thread *thread)
{
    thread->func_info.func(thread->func_info.argc, thread->func_info.argv);
    thread_end(thread->index);
    minfree(thread->stack);
    thread_scheduler();
}

thread_gen 関数でスレッドの生成を行います。 thread 構造体に各種情報を登録していきます。

thread_stack_init でスレッドのスタックの初期化を行います。この時スタックポインタの値も調整されます。

init_stack 関数の実装はレジスタをいじるのでアセンブラで書きます。

.global     init_stack
.align      16
init_stack:           # uint64_t init_stack(uint64_t stack_bottom, uint64_t rip, struct thread *thread);
    cli
    mov     rcx,rsp   # save rsp at rcx
    mov     rsp,rdi   # move rip to rsp
# push general registers to stack
    push    rsi       # push rip(second argument) to stack bottom
                      # because function switch_context requires return rip
    push    0         # rbp
    push    0         # r11
    push    0         # r10
    push    0         # r9
    push    0         # r8
    push    rdx       # rdi
    push    0         # rsi
    push    0         # rcx
    push    0         # rdx
    push    0         # rax
    mov     rax,rsp   # set current rsp to return value
    mov     rsp,rcx   # load rsp from rcx
    sti
    ret

まず現在の rsprcx に保存し、今のスタックに戻ってこれるようにします。 次に init_stack 関数の第1引数で受け取ったスレッドのスタックポインタをロードして、スレッドのスタックの初期化作業を始めます。 コード中のコメントと push している数値が初期化のデフォルト値に対応しています。

rdi だけ rdx つまり init_stack に渡された第3引数をセットしていますが、これは thread_exec に渡す引数を設定しています。

少しややこしいので説明すると、まずスレッドで実行する関数を thread_exec で包みます。 そして thread_exec をスレッドで実行する関数として登録します(thread_gen をよく見ると関数の設定で thread_exec を登録している)。 というのも、 thread_exec でスレッドの関数が終わった際の終了処理とスケジューラ呼び出しを行うからです。 thread_exec には thread 構造体へのポインタが必要なので、レジスタの初期化を行うときに rdi つまり第一引数に thread 構造体へのポインタを設定してやることで引数を渡します。 thread_exec の末尾3行はスレッドの状態を変更し、mallocした領域を開放し、スケジューラを呼び出します。

thread_genthread_stack_init はこんな感じで使います。

    // スレッドを生成
    struct thread thread0 = thread_gen(console, 0, 0);
    struct thread thread1 = thread_gen(task_a, 0, 0);
    struct thread thread2 = thread_gen(task_b, 0, 0);
    struct thread thread3 = thread_gen(task_c, 0, 0);

    // initialize stack
    thread_stack_init(&thread0);
    thread_stack_init(&thread1);
    thread_stack_init(&thread2);
    thread_stack_init(&thread3);

スレッドを切り替える処理

まずタイマの割り込みハンドラでスケジューラを呼び出す処理を見ましょう。

/**
 * タイマ割り込みハンドラ
 * クロック値を進めて、スケジューラを呼び出す
*/
__attribute__((interrupt))
void timer_handler(struct InterruptFrame *frame)
{
    // クロック値を進める
    milli_clock++;
    // EOIをPICに送る
    io_outb(PIC0_OCW2, PIC_EOI);
    io_outb(PIC1_OCW2, PIC_EOI);

    /** 周期が来たらスケジューラを呼び出す
     * 各種パラメータはint_handler.hで設定
     */
    if (milli_clock > previous_interrupt + timer_period && milli_clock > 100) {
        previous_interrupt = milli_clock;
        put_str_num_serial("timer_handler old rip: ", frame->rip);
        puts_serial("\n");
        thread_scheduler();
    }
}

if の条件式でスケジューラを呼び出す条件を設定しています。 間隔をグローバル変数 timer_period で指定しています。 一定時間が経ったら thread_scheduler を呼び出すようになっています。

次に thread_scheduler を見てみましょう。

void thread_scheduler(void)
{
    // update current_thread_index
    int old_thread_index = current_thread_index;
    int i = 1;
    while (current_thread_index == old_thread_index) {
        if (threads[(current_thread_index + i) % THREAD_NUM]->state == RUNNABLE) {
            current_thread_index = (current_thread_index + i) % THREAD_NUM;
        }
        i++;
    }

    put_str_num_serial("next thread index: ", (uint64_t)current_thread_index);
    put_str_num_serial("next start rsp: ", threads[current_thread_index]->rsp);
    put_str_num_serial("next start func address: ", (uint64_t)(threads[current_thread_index]->func_info.func));
    puts_serial("\n");
    puts_serial("dispatch start\n\n");

    switch_context(&threads[old_thread_index]->rsp, threads[current_thread_index]->rsp);
}

前半部でスレッドキューから次に実行するスレッドを選択しています。

後半部で重要な処理は switch_context です。見てみましょう。

switch_context:   # void switch_context(uint64_t *current_rsp, uint64_t next_rsp);
# push current task's general registers to stack
    push    rbp
    push    r11
    push    r10
    push    r9
    push    r8
    push    rdi
    push    rsi
    push    rdx
    push    rcx
    push    rax
# switch rsp
    mov     [rdi],rsp
    mov     rsp,rsi
# pop next task's general registers from stack
    pop     rax
    pop     rcx
    pop     rdx
    pop     rsi
    pop     rdi
    pop     r8
    pop     r9
    pop     r10
    pop     r11
    pop     rbp
# return to next task
    ret

レジスタをいじるためアセンブラでしか書けません。

まず現在実行しているスレッドのレジスタの値を保存するために、現在のスレッドのスタックに現在のスレッドのレジスタを保存しています。

次にスタックポインタ rsp の処理です。 現在のスタックポインタを現在のスレッドの構造体に保存し、次に実行するスレッドのスタックポインタを読み出します。

するとここでスタックが切り替わるため、次に実行するスレッドのスタックから次に実行するスレッドのレジスタを読み出すことができます。 ですので次に実行するスレッドのレジスタをスタックから読み出します。

最後に retrip を読み出し、次のスレッドにジャンプします。

最後の ret でどこにジャンプするのか?

これは少しややこしいですので説明します。 switch_context が呼ばれた瞬間のスタックが属するスレッドをAt、次に実行するスレッドをBtとします。

まず switch_context 中で rsp が切り替わったときにスタックがBtのものに切り替わります。 レジスタをpopしていって最後に ret を発行すると、 スタックはBtのもので ripthread_scheduler 関数の switch_context から抜けた所に飛びます。

thread_scheduler 関数はこれ以上の処理はないため、 スタックはBtのもので rip はタイマ割り込みハンドラの timer_handlerthread_scheduler から抜けた所に飛びます。

timer_handler はこれ以上の処理はないため、 スタックはBtのもので rip は割り込み前に実行していた所がロードされます。 スタックがBtのもの なので、ロードされる rip はスタックBtに積まれた関数中のアドレスであり、スタックBtに積まれる関数というのはスレッドBtに登録されている関数です。

実装は以上

たぶんこれで動くと思います。

まとめ

タスクスイッチの実装はスタックにレジスタを積まなくてはならず、かなりややこしい部分です。 実装は大変苦労しましたが、喜びもひとしおでしたね。

WORD編集部の本棚

この記事は WORDIAN Advent Calendar 2019 の1日目の記事です。

本棚を整理しました

そのままです。11月の上旬の深夜にガッとやりました。

もう誰も読まないであろう本やダブっている本をどかし、奥底に眠っていた有用と思われる本をゆるくジャンル分けして整頓しました。 ですので最近編集部に来ていない人は自分の本が見つからない場合は自分まで声をかけてください。

ピックアップ技術書の紹介

今回の整理であんな本やこんな本が見つかりました。 その中で自分が独断と偏見で、これは!と思う本を紹介していきたいと思います。

プログラミング言語

Programming in Lua

Programming in Lua プログラミング言語Lua公式解説書

Programming in Lua プログラミング言語Lua公式解説書

奥底から発掘しました。 自分はLuaを全く書いたことがないのですが、WORDの標準執筆環境がLuaLaTeXなので、いずれかは読んで覚えたいですね。

Smalltalkで学ぶオブジェクト指向プログラミングの本質

SMALLTALKで学ぶ オブジェクト指向プログラミングの本質

SMALLTALKで学ぶ オブジェクト指向プログラミングの本質

オブジェクト指向言語の原点Smalltalkの本です。 自分は普段Cばかり書いているのでオブジェクト指向はあまり馴染みがないのですが、どうせやるならオブジェクト指向の原点を学んでみたいものですね。

アルゴリズム・データ構造編

The Art of Computer Programming

The Art of Computer Programming Volume 1 Fundamental Algorithms Third Edition 日本語版

The Art of Computer Programming Volume 1 Fundamental Algorithms Third Edition 日本語版

クヌース先生のライフワークPart.1。 生きているうちにvol.7まで出してくり〜

計算困難問題に対するアルゴリズム理論

計算困難問題に対するアルゴリズム理論―組合せ最適化・ランダマイゼーション・近似・ヒューリスティクス

計算困難問題に対するアルゴリズム理論―組合せ最適化・ランダマイゼーション・近似・ヒューリスティクス

難しそうkonami

言語処理系

コンパイラ

コンパイラ―原理・技法・ツール (Information & Computing)

コンパイラ―原理・技法・ツール (Information & Computing)

アホじゃないよエイホだよ。 コンパイラ作るなら必読?

計算機プログラムの構造と解釈 第2版

計算機プログラムの構造と解釈 第2版

計算機プログラムの構造と解釈 第2版

魔術師本。Lisp書きたいときにどうぞ。

OS

詳解Linuxカーネル 第3版

詳解 Linuxカーネル 第3版

詳解 Linuxカーネル 第3版

辞書。 カーネル2.7だけどまだまだ読めます。

BSDカーネルの設計と実装

BSDカーネルの設計と実装―FreeBSD詳解

BSDカーネルの設計と実装―FreeBSD詳解

  • 作者: マーシャル・カークマキュージック,ジョージ・V.ネヴィル‐ニール,砂原秀樹,Marshall Kirk McKusick,George V. Neville‐Neil,歌代和正
  • 出版社/メーカー: アスキー
  • 発売日: 2005/10/18
  • メディア: 単行本
  • クリック: 122回
  • この商品を含むブログ (58件) を見る

悪魔本。 来年はBSDも触るのでその時お世話になりそう。

CPU・アーキテクチャ

マイクロプロセッサ・アーキテクチャ入門

並列処理アーキテクチャ2の授業を受けると読みたくなる。

コンピュータの構成と設計 第3版

コンピュータの構成と設計~ハードウエアとソフトウエアのインタフェース 第3版 (上)

コンピュータの構成と設計~ハードウエアとソフトウエアのインタフェース 第3版 (上)

コンピュータの構成と設計~ハードウエアとソフトウエアのインタフェース 第3版 (下)

コンピュータの構成と設計~ハードウエアとソフトウエアのインタフェース 第3版 (下)

パタヘネ第3版です。早く第6版出て欲しいね。

LaTeX

[改訂第7版]LaTeX2ε美文書作成入門

[改訂第7版]LaTeX2ε美文書作成入門

[改訂第7版]LaTeX2ε美文書作成入門

WORDIANのお供。ちょくちょく読みます。

(LuaLaTeX 微文書作成入門)

techbookfest.org

OBひだるま氏の著作。技術書典7で領布されました。 役立ちます。

数学編

多様体入門

多様体入門(新装版) (数学選書)

多様体入門(新装版) (数学選書)

多様体を勉強するときに読む本です。

論理と計算のしくみ

論理と計算のしくみ (岩波オンデマンドブックス)

論理と計算のしくみ (岩波オンデマンドブックス)

目はつけてたシリーズ。 なかなか買えないのよね、これ。

競技編

セキュリティコンテストチャレンジブック

セキュリティコンテストチャレンジブック -CTFで学ぼう! 情報を守るための戦い方-

セキュリティコンテストチャレンジブック -CTFで学ぼう! 情報を守るための戦い方-

実は著者の1人のサインが入っているとか……?

プログラミングコンテストチャレンジブック 第1版

プログラミングコンテストチャレンジブック

プログラミングコンテストチャレンジブック

だれか第2版置いてくり〜

その他

(Dragon University 2019.9)

booth.pm

編集部rizaudo氏とれっくす氏、そして自分が執筆した同人誌。びしょ〜じょ氏も協力。

邪悪編

関数型プログラミングに目覚めた!

いつかは消去したい。

いかがでしたか?

読書して知識を身に着けましょう。

セキュリティキャンプ2019 OS開発ゼミ体験記

この度セキュリティキャンプ2019全国大会を修了しました。関係者各位はお疲れ様でした。

この記事では自分が受講したYトラックのOS開発ゼミ、テーマ「フルスクラッチOSを書こう!」を中心に、キャンプについて感想を述べていきたいと思います。

事前学習期間

キャンプの選考に通ってから、キャンプ当日に向けて事前学習を行いました。

当初の予定

まず選考時点では自分のOSは

しかありませんでした。

当初は、キャンプ当日ではファイルシステムを実装して、コマンドを打ち込んで操作できる状態までやろうと思っていました。

なので、事前課題では以下のことをやろうと思いました。

  • 64bitモードへの移行
  • GDTとIDTの初期化
  • キーボード入力
  • ページングテーブルの初期化とページングの有効化
  • コンソールを作り、コマンド入力をできるようにする

実際の進行

kintoneで講師の内田さん (@uchan_nos) やチューターさんに質問したり進捗報告したりしていました。 やっていてだらけそうだなと思った(事前学習期間が2ヶ月くらいある)ので、途中から専用スレッドを立ててもらいそこで日報を書き始めました。

日報には、一日のはじめに目標とそれに対してやることを書き、一日の終わりにやったことと疑問点を書きました。 これは結構良かったです。最後の方は学校の行事とかぶって少し失速しましたが、概ねペースを保てたかなと思います。

最終的に事前学習でやれたことは

  • 64bitモードへの移行(実は最初からできていたことが調査でわかった)
  • GDT,IDTの初期化
  • キーボード入力
  • ページングテーブルの初期化とページングの有効化
  • コンソールを途中まで作った

でした。

キャンプ当日のやること決め

キャンプ数週間前に、当日にやることを決めました。

当初はファイルシステムの実装を目標にしていましたが、自分がファイルシステムがなんなのかわかっておらず、またファイルシステムについて勉強する時間が取れなさそうだったので、タイマ割り込みの実装と、タイマ割り込みを使ったコマンドの作成に変更しました。

前日のトラブル

事前学習中に運営局から実機検証用のボードが送られてきたのですが、自分はめんどくさがって実機検証を行っていませんでした。

キャンプ前日に実機のことを思い出し実機検証をしようとしたら、シリアル通信用のケーブルが入っていないことに気づきました(キーボード入力をシリアル通信で行うように設計していた)。 運営局も用意はしていないということで、前日に大慌てで秋葉原まで行き、シリアル通信ケーブルを購入しました。 秋月電子が夏季休業していたときは絶望しましたが、マルツが開いていたのでなんとか手に入れることができました。

これを読んでいる2020年以降のキャンパーさんたちは、キャンプ1週間前くらいには必要機材の確認などは済ませておきましょう。おじさんとの約束だぞ!!!

キャンプ期間中

1日目

メシ食って開会式や全体講義、グループワークがありました。

講義は法律についてとコミュニティについてでした。倫理観身につけていきたいです。

名刺交換もいろんな時間にしました。200枚持っていったんですけどコミュ力不足で50枚位しか消費しませんでした。自分のコミュ力に合った枚数を持っていこうな。

2日目

この日から専門講義が始まりました。と言っても自分のゼミはひたすら開発!開発!開発!でした。

消すと動かなくなるバグが発生

この日の前半はバグを潰す活動をしていました。バグの症状としては、無駄な処理を消すと動作が止まってしまうというものでした。 例外ハンドラは作成していて検知できるようになっていましたが、例外ハンドラを読み込む部分より前でバグっているようだったのでそれは使えません。

そこでシリアルに出力をしてどこが原因かまず特定したらどうかと講師の内田さんから提案されたのでそうすることにしました。 色々やった結果、GDTの設定周辺の無駄な処理を消すと動作が止まることがわかりました。 バイナリを逆アセンブルしてみたりQEMU Monitorでメモリを覗いたりと四苦八苦していたところ、なんだかわからないけど安定動作するようになりました。 何だったんだろう……

ACPI PMタイマとLocal APICの設定

後半はLocal APICのタイマを動作させるべく、ACPI PMタイマの設定を始めました。

ACPI PMタイマは周波数が決まっているので、PMタイマでLocal APICタイマの周波数を計測して、その周波数をもとにメインのタイマとしてLocal APICを使用するといったことをしました。 チューターのPG_MANAさんが持ってきてくれた「Local APICタイマー入門」(著者は講師の内田さんです)を読んで設定をしました。 この日はPMタイマの設定まで終わりました。

3日目

この日も開発です。

タイマ割り込みが呼び出せない

この日は1日中タイマの設定がうまく行かず苦しんでいました。

タイマ割り込みのハンドラを呼び出そうとすると一般保護例外が出ているようだったので、一般保護例外のエラーコードを取ろうということになりました。 コンパイラの拡張の __attribute__((interrupt)) を関数の頭につけると引数にエラーコードが取れるので、それを使おうとしましたが、GCCではうまくいかなかったのでclangに変更したらうまくいきました。 そうしてエラーコードを読もうとしましたがエラーコードを正しく取れておらず、結局QEMU Monitorでメモリを直接覗いて確認しました。 そうしてエラーコードを見たのですが、なんだかよくわからない値が入っていて解決ならず。

IDTへのハンドラの登録がおかしいのではないかという話になったので確認してみてもおかしいところは……ありました。 IDT自体の設定がミスっていて、IDTのリミットを設定する部分が小さくなっていました…… 一般保護例外とページフォルトのハンドラはギリギリカバーできるリミット設定で、タイマ割り込みのハンドラはリミット外に位置していたので範囲外にアクセスしていたようです……

講義時間終了後にちょっと作業して、タイマが動くようになりました。

4日目

開発最終日! sleepコマンドとdelayコマンドを作るべく頑張りました。

sleepから戻ってこないバグ

午前中はsleepコマンドを作っていました。

簡単にできるかと思いきや、デバッグ用の関数を消してsleepしたら一生戻ってこないバグが出ました。 一般保護例外やページフォルトも出なかったのでobjdumpしてみると [rbp+0x8] のようなアドレスを読んでいて、むちゃくちゃな値がsleepの引数に渡っていることがわかりました。 しかしそうなった原因がわからず、ウンウン唸ってもどうにもわからなかったので、同じ部屋で活動しているコンパイラゼミの講師であるhikaliumさんに助けを求めたところ、Red Zoneの可能性があると言われました。 そこでRed Zoneを抑制する -mno-red-zoneコンパイラオプションにつけてビルドしてみると、正常に動くようになりました(Red Zoneについては下にアップするスライドで解説しています)。

時間切れ

午後はdelayコマンドの実装をはじめました。

delayコマンドは第一引数に秒数を、第二引数以降に行いたいコマンドを取り、指定秒数後に指定コマンドを実行する、なお指定秒数までは通常のコマンド操作はできるように実装する、といったようなものを想定して作りました。

戦略としては、

  • コンソール側にタスクキューとフラグをもたせる
  • タスクは実行する予定時刻と文字列を持つ
  • delayコマンドはタスクキューにタスクを詰める
  • タイマはタスクキューを見て、予定時刻を過ぎているタスクが詰まっていたらフラグを立てる
  • コンソールはフラグが立ったらキューに詰まっているタスクを実行する

というものを考えました。

これを実装しようとガチャガチャやっていましたが、自分のコンソール関数の設計上の問題でプログラムが難しく、あえなく時間切れになってしまいました。

5日目

成果報告会で発表しました。

speakerdeck.com

その後は他の人の発表を聴いたり、閉会式で修了証書を受け取るのを見たりしました。

そんなこんなしてセキュリティキャンプ2019は閉会しました。

感想

月並みですが参加してよかったです。

もちろん講師さんやチューターさんが横にいる環境で開発できたというのもそうですが、同年代の優秀な人たちと触れ合えたというのも良かったし、モチベーションが高まったというのも得たことの1つです。 今後も精進するぞ。

今後の予定

OS開発は続けていきます(学校での自主課題でもあり、ラボユースのテーマでもあるので)。 キャンプ中にできなかったdelayコマンドの実装もやりたいと思います。

また並行してOSのドキュメントも作っていきたいと思います。

最後になりましたが

講師陣の皆さん、チューターの皆さん、運営に関わった皆さん、全ての関係者各位に感謝します。

ありがとうございました!

終了

終了!

セキュリティキャンプ受かりました

受かった

今年が年齢的に最後だということで出してたら通りました。やったぜ!

集中開発トラック(だっけ?)のOS開発ゼミです、同じゼミの人はよろしくお願いします。

恒例の応募シート晒し

GitHubに上げときます。全文は seccamp_apply_sheet.txt を見てください。

github.com

応募の経緯と意気込み

「30日でできる!OS自作入門」をやって、今度はx64のOSを作ってみたくなったので、セキュリティキャンプに応募してみました。 募集が4月から始まって時間があったので、わりと練って書いたのですが、今見ると結構短いですね……10000字無かった……

友達や先輩がキャンパーで、キャンプの存在を身近に感じていたのも応募した要因の一つです。キャンパーの知り合いはみんないい経験だったと言っているので当日が楽しみです。

現在開発している自作OSは以下のリポジトリなのですが、

github.com

当日までに

  • メモリ管理(ページング)
  • 割り込み
  • タスク管理

くらいは実装しておきたいですね。がんばり〼。

来年応募する人へ

応募シートですが、自分はただ闇雲に長く書くのではなく、凝縮して書くことを努めました。 審査員の方たちは何百通と応募シートを見るので、わかりやすい言葉遣いで、意図がしっかりと伝わる文章にするのが良いと思います。読みにくい文章は読むのが嫌になってしまいますからね。 学校の先生とかに見てもらうのもいいかもしれません。

プログラムの課題が出たときは、最低限の要件は満たしたほうがいいですが、できなくても、ここをこう試行錯誤したとか、ここはできなかったけど代わりにこの機能を追加したとか、とにかく食らいついてみるのがいいと思います。逆にできる人はもっと機能追加するとか、性能測定するとか(自分はこれですね)、いろいろやってみるといいかもしれません。

最後に

essen.osask.jp

SecHackのページなので微妙に違いますが、多分似たところはあるので引用させていただきます。選考に関するコメントの部分です。

SecHack365で私を指名してくれたけど落ちてしまった人たちは、実力がなくて落ちたのではなく、相対的な競争や、この人はできれば今年ではなく来年以降に!ということで選にもれただけなのです。自信を持ってください。そして通過した人も自分よりも実力がありそうな人が落ちたということを忘れずに、是非努力をしてください(その人が「こんな成果が出たのなら自分が翌年に回されたのもしょうがないな」と思えるように)。

まさにこのとおりだと思います。これから夏にかけて頑張っていきます。

「30日でできる!OS自作入門」を今更ながらやってみる - 5日目

今日もOSがんばるぞい
4日目は こちら

5日目 - 構造体と文字表示とGDT/IDT初期化

1 - 起動情報の受け取り

ポインタを用いて asmhead.nas の情報を受け取っていますね。それだけです。

2 - 構造体を使ってみる

構造体の登場です。構造体は変数をかたまりで扱える便利なものです。まあここも難しくないでしょう。
もし構造体のポインタの扱い方に混乱したら、また 3日目 を見直すか、本のコラムをじっくり読んでください。わからなくなったら基本に立ち返ることが重要です。

3 - 矢印表記を使ってみる

(*binfo).scrnxbinfo->scrnx は等価です。この本では -> を積極的に使っていくようです。
おそらくこの -> にはもっと深遠な機能があると思うのですが、自分はC言語ナニモワカラナイのでここで詳しくは解説しません。

4 - とにかく文字を出したい

ドットをちょんちょんちょんと打って「A」を描いています。気が遠くなりますね…

5 - フォントを増やしたい

OSASKに用いられているフォントデータとコンパイラを用いてフォントを導入しています。省略〜

6 - 文字列を書きたい

文字列を描いています。はい。
文字列の補足ですが、文字列は文字コードを順番にメモリに並べて最後に 0x00 をつけたもの…のようです。

7 - 変数の値の表示

GO(ジーオー)というコンパイラsprintf という関数を提供してくれます。
sprintf 関数は指定した文字列をメモリの中に生成する機能を持っています。これを使ってどこかの番地に文字列を保存しておいて、それを putfonts8_asc() で読み出せば無事文字が表示されます。やったネ!

8 - マウスカーソルも描いてみよう

はい描くだけ〜カット!!!

9 - GDTとIDTを初期化しよう

ここまでで1000文字ですが、ここから多分3000文字くらい書くことになりそうです〜んご〜頑張るお

まず何がしたいかをはっきりさせておきましょう。私達は マウスカーソルを動かしたい のです。それには マウスが動いたときの信号をキャッチして、マウスカーソルの絵を動かせばいい のです。信号をキャッチするには IDT を設置する必要があります。 IDT を設定するには今度は GDT なるものを設定する必要があります。こういう流れで GDTIDT を設定する必要があるのです。それでは書いていきます〜

まずはセグメンテーションです。手っ取り早く言うと メモリを切り分けてそれぞれのブロックの最初の番地を0として扱える という機能です。今まで ORG 0x7c00 とか書いていましたが、セグメンテーションを設定すれば自動的に ORG 0 が設定されるというわけです。3日目 にセグメントレジスタというのがありましたが、今回もそれを使います、使いますが、あの頃はまだ16ビットモードでした。あの時 MOV AL,[SI]MOV AL,[DS:SI] で、 DS の16倍が SI に足されていました。しかし今は32ビット、事情が違います。 MOV AL,[DS:EAX] という時 AL に代入されるのは、 DSの開始番地 + EAXの値 です。ご注意を。
セグメントレジスタを省略したら自動的に DS が足されるというのは32ビットモードでも16ビットモードでも共通です。

そのセグメンテーションですが、セグメントを表すには次の情報が必要です。

  • セグメントの大きさ
  • セグメントの開始番地
  • セグメントの管理用属性(書き込み禁止、実行禁止、システム専用etc.)

CPUではこれらのデータを64ビット(8バイト)で表していますが、セグメントを指定するためのレジスタは16ビットしかありません。じゃあできないのかと思いますがそんなことはありません。以下の図がわかりやすいですので、この図を参照しながら説明します。

f:id:hrstmyk811m:20190318162730p:plain
http://softwaretechnique.jp/OS_Development/Image/Kernel_Loader2/gdt_segment_register.png より引用

まずセグメントレジスタにはセグメントの番号(一般にセグメントセレクタと呼ぶ)を格納します。セグメントレジスタとは別に、メモリ上に GDT(Global Descriptor Table) というセグメントの必要情報(箇条書きで挙げた3つですね)とセグメントセレクタを記載したテーブルを用意します。そしてメモリの空き領域を都合よく区切ります。そしたらセグメントレジスタに格納したセレクタGDTセレクタを紐づけて、更に GDT のそれぞれの情報と合致するメモリ領域を紐づけます。
こうすると、セグメントセレクタを指定したら対応する GDT の要素が指定され、 GDT の要素と対応するメモリが指定される、というわけです。2段クッションがかまされているんですね。
セグメントセレクタの数ですが 0~8191 が扱えます。セグメントレジスタは16ビットですが、CPUの都合上、下位3ビットが使えないためです。ということは8192個の GDT の要素が作れるわけで、つまり GDT8192 * 8 = 65,536 バイト(64KB)扱えるわけです。
GDT の作り方ですが、メモリの何処かにセグメントの情報をずらっと並べて、その先頭の番地と有効設定個数をCPUの GDTR というレジスタに設定すればよいです。

IDT(Interrupt Descriptor Table) は、割り込み番号とそれに対応した呼び出し関数の対応表です。まずは割り込みについて説明しましょう。割り込みとは、 CPU外部のデバイスが変化した時や内部エラーが起きたときに臨時の処理を発生させる機能 です。CPU外部のデバイスの変化とは、例えばキーボードが押されたとか、マウスがクリックされたとか、そういった類のことです。
なぜそういったものがあるかというと、CPUの処理スピードに比べて外部デバイスの変化が圧倒的に遅いからです。CPUは1秒間に100万回以上(今のCPUは1億回以上)動作します。それに比べればキーボードだって1秒に3回押されるくらいですし、マウスだって16連打が限界です。100万分の1秒とは次元が違うわけです。なので外部デバイスからの信号は、一種の例外として処理したほうが都合がいいのです。説明終わ〜り。

プログラムの解説は本を読んでください、わかると思います。ひとつだけ注意する点としては、ポインタの足し算です。 ポインタに足し算すると、もとの型のバイト分だけn倍されたものを足します。 何を言っているかわからないと思うので例を出しましょう。以下のポインタ型変数を宣言したとします。

int *p = (int *) 0x1000;

これに足し算をしていくと、結果は以下のようになります。

printf("+0 : %d\n", p); // +0 : 4096
printf("+1 : %d\n", p + 1); // +1 : 4100
printf("+3 : %d\n", p + 2); // +3 : 4108

int 型は4バイトなので、1足すと4バイト、2足すと8バイト増えていますね。 char だと1バイトずつ、 short だと2バイトずつ増えます。8バイトの構造体だったら8バイトずつ増えます。覚えておきましょう。
追記: どうやら型のサイズはCPUによって変わるようです。 ここ とか ここ などを参考にするとよい。

今日はここまで

ふー疲れましたね、それではまた明日〜

「30日でできる!OS自作入門」を今更ながらやってみる - 4日目

さてさて今日もOSやるぞ

3日目は こちら

4日目 - C言語と画面表示の練習

1 - C言語からメモリに書き込みたい

ここはまだアセンブリですね。プログラムをみると以下のようになっています。

_write_mem8:                  ; void write_mem8(int addr, int data);
        MOV    ECX,[ESP+4]    ; [ESP+4]にaddrが入っている
        MOV    AL,[ESP+8]     ; [ESP+8]にdataが入っている
        MOV    [ECX],AL
        RET

コメントをみるとわかると思いますが、 void write_mem8(int addr, int data); のようなC言語の関数と対応しています。ここでやりたいことは、 addr 番地に data を書き込むことです。
ここでC言語の引数とアセンブリの対応ですが、

第一引数 : [ESP+4]
第二引数 : [ESP+8]
第三引数 : [ESP+12]
...

となっております。
現在32ビットモードでプログラムを組んでいるので積極的に32ビットレジスタを使っています。16ビットレジスタを使うこともできるのですが、機械語のバイト数が増え、実行速度も遅くなるらしいです。
C言語と連携する際には、自由に使ってもいいレジスタが制限されていて、 EAX , ECX , EDX の3つだけです。他のレジスタは読み込みはOKですが書き込みはだめなので注意です。

C言語側のプログラムは簡単ですな。 0xa0000 ~ 0xaffff 番地に 15 を書き込んでいます。

さてどうでしょう、みなさんは画面が真っ白になったでしょうか?

2 - しましま模様

AND演算が登場です。AND演算は2進数の世界でよく出てきます。こんな感じの演算です。

11111111 AND 10010001 = 10010001

筆算するとこんな感じです。

     11111111
AND) 10010001
-------------
     10010001

...法則性がわかったでしょうか?
AND演算は 同じ桁どうしを比べて、両方1だったら1、それ以外は0を出力する演算 です。コンピュータの世界ではよく出てくるので要チェックです。

本ではこれを使って縞模様を出力していますね。しましまになりましたか?

3 - ポインタに挑戦

さて幾多のプログラミング入門者を苦しませてきたポインタです。ポインタはアセンブリと密接な関係があるので、アセンブリと比較しながら見ていきましょう。

まずC言語にはメモリの番地に直接データを書き込むための表現があります。つまり以下のことをする表現です。

MOV    [0x7ffc],0x0f    ; メモリの0x7ffc番地にデータ0x0fを書き込む

これをC言語で表現しようとすると以下のようになります。

char *p;
p = (char *) 0x7ffc;
*p = 0x0f;

ゆっくり見ていきましょう。まず char *p; があります。どうやら変数宣言のようですが、変数 p の前に * がついています。この記号で 変数 p はメモリの番地 を表すことになります。つまり、この p をいじることによって、メモリのとある番地のデータを読み込んだり、これまたメモリのとある番地にデータを書き込んだりすることができるのです。
しかしなぜ宣言に char を用いているのでしょうか。というのもアセンブリC言語に対応があって、以下のようになっています。

char *p;  // BYTE用番地の場合
short *p;  // WORD用番地の場合
int *p;  // DWORD用番地の場合

今回は1バイト分のメモリ番地を指定したかったので char を用いています。

次に p = (char *) 0x7ffc; を見ましょう。これは p にメモリの番地を指定する作業 です。変数 p を宣言してメモリ内のデータを操りたいのですが、メモリのどの番地を操作するのかまだ指定してませんでした。ということでここで(例として) 0x7ffcp に指定しています。注意する点として、メモリの番地を指定するときは * はつけません。ここテストで出ます。 (char *) でキャストらしきことをしているのは、Cコンパイラではどうやら普通の数値とメモリの番地用の数値を区別するようで、警告が出るのでそれを回避するためです。

最後に *p = 0x0f; です。これは p に指定されたメモリ番地にデータを書き込むこと を表現しています。今回の場合は 0x7ffc 番地に 0x0f を書き込んでいますね。注意ですが、データを書きこむときは * をつけます。ここもテストで出ますので注意。

4 - ポインタの応用(1)

for (i = 0; i <= 0xffff; i++) {
    *(p + i) = i & 0x0f;
}

これは p + i 番地にデータ i & 0x0f を書き込んでいますね。for文で i が1つずつ増えていくので実はさっきとやっていることは変わりません。

5 - ポインタの応用(2)

for (i = 0; i <= 0xffff; i++) {
    p[i] = i & 0x0f;
}

p[i] という表現は *(p + i) と完全に同じです。なので p[i] == i[p] なのです…と言われても納得行かないかもしれません。でも自分の力ではこれ以上説明できそうにありません(泣) この本 とか読むといいんじゃないでしょうか。申し訳ない(汗)

6 - 色番号設定

色番号の章ですが、色番号の解説は割愛します。これはただの決まりですもんね。

さてこの章では、値をまとめて宣言する方法が紹介されます。 C言語の特性ですが、

char a[3];

と書くと、アセンブラでは

a:
    RESB  3

に相当します。でも微妙にアセンブラC言語では違いがあって、 RESB では0で埋めてくれましたが、C言語の書き方だと0で埋めてくれなくて、中にゴミデータが入っているかもしれませんので注意です。そのため、データの初期値を設定することもできます。

char a[3] = { 1, 2, 3 };

これは以下の表記とほぼ同等です。

char a[3];
a[0] = 1;
a[1] = 2;
a[2] = 3;

ここで注目してほしいのが、 a が最初からポインタとして認識されていることです。上のアセンブラを見てもらえるとわかるのですが、 a はラベルで、つまりメモリのどこかの番地を指しているわけです。

今回のプログラムだと、上の方法では48個の値を MOV しなくてはなりません。これは(機械語的に)大変なので DB 命令で一気に指定したいところです。C言語では DB 命令に相当する表記があって、それが static です。今回のプログラムでは static を宣言の前につけていますね。

次に bootpack.cset_palette() を解説します。

CPUにはメモリ以外にも沢山のデバイスが接続されていて、そこに命令を送るのが OUT 命令と IN 命令です。この命令は装置番号(portといいます)を指定してデータを送ったり受け取ったりします。この命令はC言語では書けないのでアセンブラで書く必要があります。

さてどんなことをすればいいのでしょうか。本によると、以下の操作をすれば良さそうです。

  1. 割り込みを禁止する
  2. 0x03c8 に設定したいパレット番号を書き込み、続いてR,G,Bの順に 0x03c9 に書き込む
  3. パレット状態の読み込みは、 0x03c7 にパレット番号を書き込み、続いて 0x03c9 を3回読み出す
  4. 割り込み状態をもとに戻す

割り込み禁止処理と割り込み許可処理ですが、 CLISTI が登場します。 CLI というのはclear interrupt flagの略で、割り込みフラグを0にして割り込みを禁止する処理です。 STI というのはset interrupt flagの略で、割り込みフラグを1にして割り込みを動作させる処理です。
割り込みが何かって?その説明はちょっと先になります。もう少し我慢。

次に EFLAGS という特別なレジスタの説明です。
簡単に言うといろんなフラグが詰まったレジスタで、キャリーフラグや割り込みフラグもここに入っています。キャリーフラグは JC とか JNC などで簡単に中身が調べられるのですが、割り込みフラグはそういう命令がないので、 EFLAGS を読み込んでから第9ビットが0か1かチェックするしかないのです。

f:id:hrstmyk811m:20190318092019p:plain
EFLAGSマップ

EFLAGSC言語からは操作できないのでアセンブラでがんばります。 naskfunc.nas へ移りましょう。

ここで私達がやりたいのはつまり MOV EAX,EFLAGS なわけですが、これはできません。そういう命令がないのでしょうがないです。なのでスタックを使う必要があります。スタックって何って?それはまた後日。
PUSHFDPOPFD 命令が初登場です。それぞれ、push flags double-wordとpop flags double-wordの略で、フラグをスタックに押し込む/取り出すという意味です。つまり、

_io_load_eflags:
        PUSHFD
        POP      EAX
        RET

というのは、スタックに PUSHFDELFAGS を押し込んで、 POP で飛び出してきたところを EAX に代入しているのです。

また値を返す関数ですが、C言語の規約では RET したときに EAX に入っていた値が戻り値としてみなされます。要チェックです。

7 - 四角形を描く

いままでのことを総動員して絵を描いていますね。特に難しそうなところはなさそうですね。

8 - 今日の仕上げ

根気よく絵を描いています。これ大変そうだなー

今日はここまで

お疲れ様でした〜それではまた明日〜
5日目は こちら

「30日でできる!OS自作入門」を今更ながらやってみる - 3日目

今日も今日とてOSやるぞ
2日目は こちら

3日目 - 32ビットモード突入とC言語導入

今日は重いですね…頑張ってまとめます。

1 - さあ本当のIPLを作ろう

さて書き足された部分のまとめをしていきます。

フロッピーディスクの構造を学ぼう

INT 0x13 は前回やったとおり割り込み命令ですね。

INT : ソフトウェア割り込み命令のこと これだけだと何がなんだかわかりませんが、今のところはBIOSの関数呼び出しの一種だと思っておけばいいらしいです。 BIOSというのは「basic input output system」の略です。いかにも画面に文字表示したりキーボード入力できそうな雰囲気ですね。これを用いてハロワを表示させていたんですね〜

「30日でできる!OS自作入門」を今更ながらやってみる - 2日目 - 無限遠まで突撃中

さて何をさせるのでしょうか。本によると
ディスクの読み込み、書き込み、セクタのベリファイおよびシーク
らしいです。パラメータの解説をします。

  • AH : モードの選択
  • AL : 処理するセクタ数
  • CH : シリンダ番号 & 0xff
  • CL : セクタ番号(bit0-5) | (シリンダ番号 & 0x300) >> 2
  • DH : ヘッド番号
  • DL : ドライブ番号
  • ES:BX : バッファアドレス(ベリファイ時とシーク時は参照しない)
  • 戻り値 :
    • FLAGS.CF == 0 : エラーなし、 AH == 0
    • FLAGS.CF == 1 : エラーあり、 AH にエラーコード(リセットファンクションと同じ)

今回は AH = 0x02 でディスクの読み込みです。

まずは戻り値です。
CF というのは「キャリーフラグ」というもので、1ビット記録するレジスタです。本来はキャリー状態というものを記録するのですが、扱いやすいのでいろいろ使われるらしいです。
ちなみにキャリーというのは carry 、つまり けた上げ のことです。計算していてレジスタが桁あふれしたときにキャリーフラグがぴょこっと1になることでうまいこと計算ができるんですね〜大学の論理回路の授業でやりました。詳しい解説は この方の記事 を読むといいです。(キャリーフラグはどうやら符号なし演算のときに使うらしいですね)

さて、残りを解説するにはフロッピーディスクの構造を説明しなくてはいけません。以下の図を見てください。

f:id:hrstmyk811m:20190314181457g:plain
http://kccn.konan-u.ac.jp/information/cs/cyber07/cy7_fdd.htm より引用

この図はフロッピーディスク内部の磁気ディスクの構造です。円筒状にシリンダ(図ではトラック)が配置されており、セクタという区分で分割統治されていて、磁気ヘッドから読み込みます。シリンダは全部で80あり、セクタは各シリンダ内に18あります。磁気ヘッドは表と裏で2つあります。今の解説で上のパラメータは大体わかりましたね。
ドライブ番号というのは、フロッピーディスクドライブが沢山つながっている場合に識別するための番号です。ドライブが1つの場合は0番を指定します。

残りはバッファアドレスですね。これは メモリのどこに読み込むか を表すものです。なぜ2つのレジスタで表すのでしょう?というのも 前回 を思い出してほしいのですが、 BX というレジスタは16ビットでしたね。ということは、 BX 一つだけだと 0x0000 ~ 0xffff までしか、つまり64KBまでしか表すことができないのです。
これを回避するために2つレジスタを用いてメモリの番地を表します。具体的にはバッファアドレスは ES * 16 + BX で表されます。 ES でおおざっぱに指定してから BX で細かく指定、という感じですね。これで 0xffff * 16 + 0xffff = 1,114,095 バイト、つまり約1MB使えます。やったー。

セグメントレジスタ

ところで先程登場した ES レジスタですが、これはセグメントレジスタと呼ばれるレジスタです。末尾に S がつくレジスタですね。セグメントとは何ぞやと思ったので調べてみました。

セグメント方式 (memory segmentation)は、メモリ管理の方式の一つ。プログラムやデータをセグメントまたはセクションという「可変な」大きさのまとまりで管理する。セグメントは、メモリ空間上で、情報の属性などによって分類されたグループである。セグメント方式でメモリ位置を参照するには、セグメントを識別する値とセグメント内のオフセットを指定する。 (Wikipediaより)

なんだか難しくてよくわかりませんが、つまりセグメントレジスタでメモリをおおざっぱに区切っているということなのかな?(違うよ!ということであればコメントください)
大事なのは最後の文章で、「メモリ位置を参照するには、セグメントを識別する値とセグメント内のオフセットを指定」とあります。さっきの例だと、セグメントを識別する値とは ES のことで、セグメント内のオフセットとは BX のことなのでしょう。

メモリの番地指定の裏設定

ここで新事実が発覚。なんと今までメモリの番地指定では、実は暗黙的に DS がセグメントレジスタとして指定されていたんだよ!!!(ΩΩΩ < ナ、ナンダッテー!?)
つまり我々が

MOV CX,[1234]

だと思っていたのは、実は

MOV CX,[DS:1234]

だったのです。この理由から、 MOV DS,0 の初期化が必要だったのです。(0以外にすると番地の計算がややこしいことになりますから…)

2 - エラーになったらやり直そう

AH = 0x00DL = 0x00INT 0x13 してやると、ドライブのシステムリセットが走るようです。

3 - 18セクタまで読んでみる

4 - 10シリンダ分を読み込んでみる

特に難しいところはないので省略!!!

5 - OS本体を書き始めてみる

ここでやることは、 OSのプログラムが保存されているメモリ番地にブートセクタからジャンプする ことです。そのためにはまずOSのプログラムがどの番地に保存されているか調べる必要があります。とりあえず普通にコピーしてバイナリエディタで覗いてみましょう。

本によって進めると、空ディスクにファイルを保存すると

  • ファイル名は 0x002600 以降に入る
  • ファイルの中身は 0x004200 以降に入る

ということがわかりました。ブートセクタから 0x004200 に飛べばいいですね。

6 - ブートセクタからOS本体を実行させてみる

ブートセクタからOS本体に飛ばす処理をしていますね。

7 - OS本体の動作を確認してみる

真っ黒画面表示です。画面関係なので INT 0x10 で割り込みさせればいいですね。
パラメータは以下のとおりです。

  • AH = 0x00
  • AL = モード
    • 0x03 : 16色テキスト、80x25
    • 0x12 : VGAグラフィクス、640x480x4bitカラー、独自プレーンアクセス
    • 0x13 : VGAグラフィクス、320x200x8bitカラー、バックドピクセル
    • 0x6a : 拡張VGAグラフィクス、800x600x4bitカラー、独自プレーンアクセス
  • 戻り値 : なし

8 - 32ビットモードへの準備

C言語で開発したいので32ビットモードへの移行を目標にします…が32ビットモードではBIOSの機能が使えなくなります。(BIOSは16ビットモードを前提に書かれているので…)なのでI/O関係のことは今のうちにやっておきましょう。具体的にはキーボードの状態をBIOSから教えてもらいます。

  • AH = 0x02 : シフトフラグステータス取得

詳細は ここ で調べました。

あとVRAMはメモリマップの色々なところに散らばっているらしいです。びっくりしないようにしましょう。

9 - ついにC言語導入へ

asmhead.nas に100行くらい書き足されたようです…が今はその内容は明かされません。今後の解説に期待ですね。

C言語で書かれたOS本体は簡単ですね。割愛します。

さてこのcファイルをコンパイルして ipl10.nasasmhead.nas なんかとリンクするわけなんですが、そこら辺は筆者であるKさんが用意した(のを先人が整備した)ツールが大活躍です。あまり気にせず進められるでしょう…と言いたいのですが、一つ悩んだのがあって、hikalium氏のツールの使い方で1時間位つまりました。ちょっと分かりづらいのでここで共有しておきます。

z_tools 内の haritol concat の使い方ですが、

copy hoge+fuga piyo

は、

haritol concat piyo hoge fuga

と対応しているようです。参考にしてください。

10 - とにかくHLTしたい

C言語ではHLTできないのでアセンブリでHLTする関数を書きます。少し気になったところなのですが、 naskfunc.nas

[SECTION .text]

ってリンカスクリプト.text セクションと同じなんですかね?知っている人、教えてくださいm(-_-)m
あと内部のリンクの仕組みがまだわからないですね…分かり次第追記します。

今日はここまで

お疲れ様でした。また明日〜
4日目は こちら