1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

KOZOSにdly_tskと同じ機能のサービスコールを実装してみた

Last updated at Posted at 2022-08-28

1. 今回のやりたいこと

KOZOSにdly_tskと同じ機能のサービスコールを実装したい。

2. 環境

  • 使用ボード:nucleo l4r5zi
    • Cortex-M4搭載、2MBのFlash、640KのSRAM、USB OTG
  • 開発環境:Atollic TrueSTUDIO® for STM32, Built on Eclipse Neon.1a.(Version: 9.3.0 )
  • 対象OS:KOZOS※
    ※下記書籍で紹介されているOSになります。ソースコードも展開されています。
    12ステップで作る 組込みOS自作入門

3. 前提

nucleo l4r5ziにKOZOSをポーティングしています。
この環境に対して、dly_tskを実装していきます。

ポーティングした際の作業内容について、
下記サイトで説明していますので興味ある方は是非見てみてください。
KOZOSをnucleo l4r5ziにポーティングしてみた

4. 背景

今後、KOZOS上で外部センサ等のデータをタスクで周期的に取得するということをしたいと考えています。周期的にデータを取得するという仕組みを実装するにはどうすればよいかを考えたときに、itronのサービスコールであるdly_tsk()のような指定した時間sleepする関数を使えばよいと考えました。タスクの先頭でこのサービスコールを実行すれば、その指定した時間で周期的に動作します。しかし、KOZOSにはそのようなサービスコールは提供されていないため、今回作ろうと思いました。

5. KOZOSの仕組み

dly_tskを実装するための作業内容を説明する前に、知っておく必要のあるKOZOSの中身について簡単に説明します。中身を知っている方は、本章は読み飛ばしていただいて大丈夫です。

5.1 タスクコントロールブロック

タスクコントロールブロックはタスクの情報を格納する領域のことを言います。KOZOSでは、タスクコントロールブロックとして下記情報を持っています。

タスクコントロールブロック
/* タスク・コントロール・ブロック(TCB) */
typedef struct _kz_thread {
  struct _kz_thread *next;
  char name[THREAD_NAME_SIZE + 1]; /* スレッド名 */
  int priority;   /* 優先度 */
  char *stack;    /* スタック */
  uint32 flags;   /* 各種フラグ */

  struct { /* スレッドのスタート・アップ(thread_init())に渡すパラメータ */
    kz_func_t func; /* スレッドのメイン関数 */
    int argc;       /* スレッドのメイン関数に渡す argc */
    char **argv;    /* スレッドのメイン関数に渡す argv */
  } init;

  struct { /* システム・コール用バッファ */
    kz_syscall_type_t type;
    kz_syscall_param_t *param;
  } syscall;

  kz_context context; /* コンテキスト情報 */
} kz_thread;

/* システム・コール呼び出し時のパラメータ格納域の定義 */
typedef struct {
  union {
    struct {
      kz_func_t func;
      char *name;
      int priority;
      int stacksize;
      int argc;
      char **argv;
      kz_thread_id_t ret;
    } run;
    struct {
      int dummy;
    } exit;
    struct {
      int ret;
    } wait;
    struct {
      int ret;
    } sleep;
    struct {
      kz_thread_id_t id;
      int ret;
    } wakeup;
    struct {
      kz_thread_id_t ret;
    } getid;
    struct {
      int priority;
      int ret;
    } chpri;
    struct {
      int size;
      void *ret;
    } kmalloc;
    struct {
      char *p;
      int ret;
    } kmfree;
    struct {
      kz_msgbox_id_t id;
      int size;
      char *p;
      int ret;
    } send;
    struct {
      kz_msgbox_id_t id;
      int *sizep;
      char **pp;
      kz_thread_id_t ret;
    } recv;
    struct {
      softvec_type_t type;
      kz_handler_t handler;
      int ret;
    } setintr;
  } un;
} kz_syscall_param_t;

タスクコントロールブロックは各タスクごとに作成されます。

5.2 サービスコールを使用した際の流れ

サービスコールを使用した場合の流れを下記に示します。

  1. 実行したいサービスコールの種別を設定し、svc割込みを発生させる
  2. svc割り込みが発生
  3. 現在のスタックポインタを現在動作中のタスクコントロールブロックのcontextに保存する
  4. 割り込みの種別に対応した割込みハンドラがコールされる
  5. 現在、動作中のコントロールブロックをレディーキューから外す
  6. 指定した種別に対応したサービスコールを実行
  7. スケジューリングで、次に動作するタスクを決定する
  8. 次に動作すべきタスクのタスクコントロールブロックをディスパッチャーに渡す
  9. ディスパッチ

5.3 サービスコールの使用方法

KOZOSでは、下記サービスコールが提供されています。

  • int kz_sleep(void)
    自タスクを待ち状態に遷移させる。
  • int kz_wakeup(kz_thread_id_t id)
    指定したタスクを実行可能状態に遷移させる。
  • kz_thread_id_t kz_getid(void)
    自タスクの優先度を変更する
  • void *kz_kmalloc(int size)
    指定したサイズのメモリ確保を行う
  • int kz_kmfree(void *p)
    指定したメモリ領域を解放する
  • int kz_send(kz_msgbox_id_t id, int size, char *p)
    メッセージを送信する
  • kz_thread_id_t kz_recv(kz_msgbox_id_t id, int *sizep, char **pp)
    メッセージを受信する
  • int kz_setintr(softvec_type_t type, kz_handler_t handler)
    割込みハンドラを登録する

例えば、kz_sleepの中身は下記の通りになります。

kz_sleep
int kz_sleep(void)
{
  kz_syscall_param_t param;
  kz_syscall(KZ_SYSCALL_TYPE_SLEEP, &param);
  return param.un.sleep.ret;
}

kz_syscallで、サービスコールの種別とそのパラメータを自タスクのタスクコントロールブロックのtypeとparamに設定します。そして、最後にSVC割り込みを発生させます。(「5.2 割込みからディスパッチまでの流れ」で説明している処理1. に対応する処理になります)

kz_syscall
/* システム・コール呼び出し用ライブラリ関数 */
void kz_syscall(kz_syscall_type_t type, kz_syscall_param_t *param)
{
  current->syscall.type  = type;
  current->syscall.param = param;
  __asm volatile("    SVC %0 \n" : : "I" (0));
  __asm volatile("    NOP \n");
}

SVC割り込み内で、下記call_functions()が呼ばれ、設定したtypeから対応したサービスコールを実行します。(「5.2 割込みからディスパッチまでの流れ」で説明している処理4. に対応する処理になります)

call_functions
static void call_functions(kz_syscall_type_t type, kz_syscall_param_t *p)
{
  /* システム・コールの実行中にcurrentが書き換わるので注意 */
  switch (type) {
  case KZ_SYSCALL_TYPE_RUN: /* kz_run() */
    p->un.run.ret = thread_run(p->un.run.func, p->un.run.name,
			       p->un.run.priority, p->un.run.stacksize,
			       p->un.run.argc, p->un.run.argv);
    break;
  case KZ_SYSCALL_TYPE_EXIT: /* kz_exit() */
    /* TCBが消去されるので,戻り値を書き込んではいけない */
    thread_exit();
    break;
  case KZ_SYSCALL_TYPE_WAIT: /* kz_wait() */
    p->un.wait.ret = thread_wait();
    break;
  case KZ_SYSCALL_TYPE_SLEEP: /* kz_sleep() */
    p->un.sleep.ret = thread_sleep();
    break;
  case KZ_SYSCALL_TYPE_WAKEUP: /* kz_wakeup() */
    p->un.wakeup.ret = thread_wakeup(p->un.wakeup.id);
    break;
  case KZ_SYSCALL_TYPE_GETID: /* kz_getid() */
    p->un.getid.ret = thread_getid();
    break;
  case KZ_SYSCALL_TYPE_CHPRI: /* kz_chpri() */
    p->un.chpri.ret = thread_chpri(p->un.chpri.priority);
    break;
  case KZ_SYSCALL_TYPE_KMALLOC: /* kz_kmalloc() */
    p->un.kmalloc.ret = thread_kmalloc(p->un.kmalloc.size);
    break;
  case KZ_SYSCALL_TYPE_KMFREE: /* kz_kmfree() */
    p->un.kmfree.ret = thread_kmfree(p->un.kmfree.p);
    break;
  case KZ_SYSCALL_TYPE_SEND: /* kz_send() */
    p->un.send.ret = thread_send(p->un.send.id,
				 p->un.send.size, p->un.send.p);
    break;
  case KZ_SYSCALL_TYPE_RECV: /* kz_recv() */
    p->un.recv.ret = thread_recv(p->un.recv.id,
				 p->un.recv.sizep, p->un.recv.pp);
    break;
  case KZ_SYSCALL_TYPE_SETINTR: /* kz_setintr() */
    p->un.setintr.ret = thread_setintr(p->un.setintr.type,
				       p->un.setintr.handler);
    break;
  default:
    break;
  }
}

5.3 スケジューリング

スケジューリングとは、次に実行するタスクを決定することです。タスクには優先度という情報があり、これに従いスケジューリングを行います。KOZOSでは、下記のように優先度の値と同じだけキューを持っています。

レディー・キュー
/* スレッドのレディー・キュー */
static struct {
  kz_thread *head;
  kz_thread *tail;
} readyque[PRIORITY_NUM];

このキューを用いてスケジューリングを行っています。スケジューリングの実装は下記のとおりです。(「5.2 割込みからディスパッチまでの流れ」で説明している処理7. に対応する処理になります)

スケジューリング
/* スレッドのスケジューリング */
static void schedule(void)
{
  int i;

  /*
   * 優先順位の高い順(優先度の数値の小さい順)にレディー・キューを見て,
   * 動作可能なスレッドを検索する.
   */
  for (i = 0; i < PRIORITY_NUM; i++) {
    if (readyque[i].head) /* 見つかった */
      break;
  }
  if (i == PRIORITY_NUM) /* 見つからなかった */
    kz_sysdown();

  current = readyque[i].head; /* カレント・スレッドに設定する */
}

currentは、KOZOS内では実行中のタスクと説明されていますが、ここで実行中のタスクと説明すると少しおかしくなるため、次に動作すべきタスクのタスクコントロールブロックを意味していると思ってください。優先度の高いキューから、実行可能状態のタスクを検索していきます。見つかった場合、それをcurrentに設定しています。このスケジューリング後、currentをディスパッチします。(スケジューリングの実装をまだ知らないときはかなり複雑な実装になると思っていました。中身を知った時には、こんなにも簡単にかけるんだと感動した記憶があります。)

6. 作業

今回実装するサービスコールの仕様を下記のようにします。
プロトタイプ:int kz_tsleep(int time)
機能:コールしたタスクを、timeに指定した時間待ち状態にさせる。time時間経過後、実行可能状態に遷移させる。timeの単位はms。

6.1 タスクコントロールブロックの修正

下記の通りにタスクコントロールブロックに対して、メンバを新しく追加しています。追加したメンバに対して、/* 追加箇所1 /、/ 追加箇所2 */とコメントをつけています。追加箇所2のtimeは、スリープする時間を格納しておくための変数です。追加箇所1のt_nextは、「6.3 キューの設定」で詳しく説明します。

タスクコントロールブロックの修正
/* タスク・コントロール・ブロック(TCB) */
typedef struct _kz_thread {
  struct _kz_thread *next;
  struct _kz_thread *t_next; /* 追加箇所1 */
  int time;                  /* 追加箇所2 */
  char name[THREAD_NAME_SIZE + 1]; /* スレッド名 */
  int priority;   /* 優先度 */
  char *stack;    /* スタック */
  uint32 flags;   /* 各種フラグ */

  struct { /* スレッドのスタート・アップ(thread_init())に渡すパラメータ */
    kz_func_t func; /* スレッドのメイン関数 */
    int argc;       /* スレッドのメイン関数に渡す argc */
    char **argv;    /* スレッドのメイン関数に渡す argv */
  } init;

  struct { /* システム・コール用バッファ */
    kz_syscall_type_t type;
    kz_syscall_param_t *param;
  } syscall;

  kz_context context; /* コンテキスト情報 */
} kz_thread;

6.2 サービスコールの種別追加

指定した時間だけsleepするサービスコールの種別:KZ_SYSCALL_TYPE_TSLEEPを追加します。

サービスコールの種別の追加
typedef enum {
  KZ_SYSCALL_TYPE_RUN = 0,
  KZ_SYSCALL_TYPE_EXIT,
  KZ_SYSCALL_TYPE_WAIT,
  KZ_SYSCALL_TYPE_SLEEP,
  KZ_SYSCALL_TYPE_WAKEUP,
  KZ_SYSCALL_TYPE_GETID,
  KZ_SYSCALL_TYPE_CHPRI,
  KZ_SYSCALL_TYPE_KMALLOC,
  KZ_SYSCALL_TYPE_KMFREE,
  KZ_SYSCALL_TYPE_SEND,
  KZ_SYSCALL_TYPE_RECV,
  KZ_SYSCALL_TYPE_SETINTR,
  KZ_SYSCALL_TYPE_TSLEEP, /* 追加箇所 */
} kz_syscall_type_t;

KZ_SYSCALL_TYPE_TSLEEPに対するパラメータも追加します。

パラメータの追加
typedef struct {
  union {
    struct {
      kz_func_t func;
      char *name;
      int priority;
      int stacksize;
      int argc;
      char **argv;
      kz_thread_id_t ret;
    } run;
    struct {
      int dummy;
    } exit;
    struct {
      int ret;
    } wait;
    struct {
      int ret;
    } sleep;
    struct {
      kz_thread_id_t id;
      int ret;
    } wakeup;
    struct {
      kz_thread_id_t ret;
    } getid;
    struct {
      int priority;
      int ret;
    } chpri;
    struct {
      int size;
      void *ret;
    } kmalloc;
    struct {
      char *p;
      int ret;
    } kmfree;
    struct {
      kz_msgbox_id_t id;
      int size;
      char *p;
      int ret;
    } send;
    struct {
      kz_msgbox_id_t id;
      int *sizep;
      char **pp;
      kz_thread_id_t ret;
    } recv;
    struct {
      softvec_type_t type;
      kz_handler_t handler;
      int ret;
    } setintr;
    struct {    /* 追加箇所1 */
      int ret;  /* 追加箇所2 */
    } tsleep;   /* 追加箇所3 */
  } un;
} kz_syscall_param_t;

6.3 制御用キューの追加

kz_tsleepをコールして待ち状態に遷移したタスクをキューで管理します。下記グローバル変数のtimqueを追加します。

制御用のキュー
/* スレッドのレディー・キュー */
static struct {
  kz_thread *head;
  kz_thread *tail;
} timque[1];

タスクA、Bがkz_tsleepをコールした場合の動作を下記に示します。

  • タスクAがkz_tsleepをコールしたとき
    timqueには、まだ何も接続されていないため、timqueのhead,tailをタスクAに設定します。
    Add_taskA.PNG
  • 続けて、タスクBがkz_tsleepをコールしたとき
    既にタスクAがtimqueに接続されているため、タスクAのt_nextをタスクBに設定します。tailをタスクAからタスクBに変更します。
    Add_taskB.PNG
  • タスクAをtimqueから取り除くとき
    タスクAをキューから外す際は、timqueのheadをtask Bに設定します。タスクAのt_nextはNULLに設定します。
    Remove_taskA.PNG

「6.1 タスクコントロールブロックの修正」で追加したt_nextは、上記の通り、このキューを管理するための変数であり、次のタスクコントロールブロックを示す変数となっています。

6.4 サービスコールの実装

ここでは今回実装するkz_tsleepの中身の実装について説明します。まずは、5.2で説明した処理6.の修正です。

パラメータの追加
static void call_functions(kz_syscall_type_t type, kz_syscall_param_t *p)
{
  /* システム・コールの実行中にcurrentが書き換わるので注意 */
  switch (type) {
  case KZ_SYSCALL_TYPE_RUN: /* kz_run() */
    p->un.run.ret = thread_run(p->un.run.func, p->un.run.name,
			       p->un.run.priority, p->un.run.stacksize,
			       p->un.run.argc, p->un.run.argv);
    break;
  case KZ_SYSCALL_TYPE_EXIT: /* kz_exit() */
    /* TCBが消去されるので,戻り値を書き込んではいけない */
    thread_exit();
    break;
  case KZ_SYSCALL_TYPE_WAIT: /* kz_wait() */
    p->un.wait.ret = thread_wait();
    break;
  case KZ_SYSCALL_TYPE_SLEEP: /* kz_sleep() */
    p->un.sleep.ret = thread_sleep();
    break;
  case KZ_SYSCALL_TYPE_WAKEUP: /* kz_wakeup() */
    p->un.wakeup.ret = thread_wakeup(p->un.wakeup.id);
    break;
  case KZ_SYSCALL_TYPE_GETID: /* kz_getid() */
    p->un.getid.ret = thread_getid();
    break;
  case KZ_SYSCALL_TYPE_CHPRI: /* kz_chpri() */
    p->un.chpri.ret = thread_chpri(p->un.chpri.priority);
    break;
  case KZ_SYSCALL_TYPE_KMALLOC: /* kz_kmalloc() */
    p->un.kmalloc.ret = thread_kmalloc(p->un.kmalloc.size);
    break;
  case KZ_SYSCALL_TYPE_KMFREE: /* kz_kmfree() */
    p->un.kmfree.ret = thread_kmfree(p->un.kmfree.p);
    break;
  case KZ_SYSCALL_TYPE_SEND: /* kz_send() */
    p->un.send.ret = thread_send(p->un.send.id,
				 p->un.send.size, p->un.send.p);
    break;
  case KZ_SYSCALL_TYPE_RECV: /* kz_recv() */
    p->un.recv.ret = thread_recv(p->un.recv.id,
				 p->un.recv.sizep, p->un.recv.pp);
    break;
  case KZ_SYSCALL_TYPE_SETINTR: /* kz_setintr() */
    p->un.setintr.ret = thread_setintr(p->un.setintr.type,
				       p->un.setintr.handler);
    break;
  case KZ_SYSCALL_TYPE_TSLEEP/* kz_tsleep() */            /* 追加箇所1 */
    p->un.tsleep.ret = thread_tsleep(p->un.tsleep.time);  /* 追加箇所2 */
    break;                                                /* 追加箇所3 */
  default:
    break;
  }
} 

続いては、上記の追加箇所2でコールしているthread_tsleepの実装についてです。ここでは、制御用のtimqueに現在動作中のタスクを繋げます。変数currentが現在動作中のタスクを意味しています。この関数は、「 6.3 制御用キューの追加」で載せている図の動作をします。

thread_tsleep
int thread_tsleep(int time)
{
	// timeを設定
	current->time = time;

    // timqueの末尾に接続する
    if(timque.tail){
        // 末尾に接続されている場合は、その末尾の次を動作中のタスクに設定する
        timque.tail->next = current;
    }else{
        // 末尾に接続されていな場合は、先頭に動作中のタスクを設定する
	    timque.head = current;
    }
    // 末尾を動作中のタスクに設定する
    timque.tail = current;

    return 0;
}

6.5 Systickタイマの設定

kz_tsleepを実装するためにはタイマが必要不可欠です。Cortex M4には、Systickタイマというシステムタイマがあります。今回はこれを使うこととします。

6.5.1 Systickタイマの設定

systickの割り込み設定は、Systickレジスタで行います。タイマを設定する際に重要なことは、どのクロックを使用しているかになります。

現在は、STMicroelectronics提供の環境を使用しています。この環境ではSystickをコンフィグレーションする関数:Systick_Configが用意されています。Systick_Configには、RELOADレジスタに設定する値を引数として渡します。Systick_Configでは、引数として渡された値をRELOADレジスタに設定し、さらに、クロックソースをコアクロックに設定しています。コアは4MHzで動作しているため、コアクロック/1000の値を渡すことで1msで割込みを発生させるための設定ができます。下記の通りになります。

SysTick_Config
SysTick_Config(SystemCoreClock / 1000);

※今回は、多重割り込みを防ぐためにすべての割り込みを同じ優先度にしています。SysTick_Configのなかで、Systick割込みの優先度を15に下げる処理があったため、これをコメントアウトしています。

6.5.2 Systick割込みハンドラの実装

Systick割込みを下記の通りに作成します。ここでは、「6.3 制御用キューの追加」で追加したキューを用いて、待ち状態に遷移中のタスクに対して、指定したスリープ時間経過していないかをチェックし、経過している場合はレディーキューへ登録します。登録後は、制御用のキューのtimqueから登録したタスクを取り除いています。(この取り除く処理は、もっと簡単にかけるような気がします。汚くて申し訳ないです、、、。もっと良い書き方があればアドバイスいただきたいです)

Systick割込みハンドラ
/* Systick割り込み */
void SysTick_Handler(void)
{
    int i;
    kz_thread *current_task;
    kz_thread *prev_task;

    current_task = timque[0].head;
    prev_task = timque[0].head;

    while(NULL != current_task){
        if(0 == current_task->time){
            /* レディーキューへ接続*/
            if(readyque[current_task->priority].tail) {
            	readyque[current_task->priority].tail->next = current_task;
            }else{
            	readyque[current_task->priority].head = current_task;
            }
            readyque[current->priority].tail = current_task;
            current_task->flags |= KZ_THREAD_FLAG_READY;
            
            /* timqueから外す */
            /*   前タスクのt_nextを現在のタスクのt_nextに設定する(レディーキューに接続したタスクをtimqueから外す) */
            prev_task->t_next = current_task->t_next;
            /*   timqueから外したタスクがtimqueの先頭の場合 */
            if(current_task == timque[0].head){
            	/*   外したタスクしかtimqueに接続されていなかった場合 */
            	if(NULL == current_task->t_next){
            		/*    timqueの先頭と末尾をNULLに設定 */
            		timque[0].head = NULL;
            		timque[0].tail = NULL;
            	/*   外したタスク以外にも接続されたタスクがあった場合 */
            	}else{
            		/*   timqueの先頭を現タスクのt_nextに設定する */
            		timque[0].head = current_task->t_next;
            	}
            /*   外したタスクがtimqueの末尾の場合 */
            }else if(current_task == timque[0].tail){
            	/*   timqueの末尾を前タスクに設定する */
            	timque[0].tail = prev_task;
            }
            /* 前のタスクを更新 */
            prev_task = current_task;
            /* 次のタスクを更新 */
            current_task = current_task->t_next;
            /* timqueから外したので、ディスパッチ対象のタスクのnextポインタをクリア */
            prev_task->t_next = NULL;

        }else{
        	/* 時間を1減らす */
        	current_task->time--;
            /* 前のタスクを更新 */
        	prev_task = current_task;
            /* 次のタスクを更新 */
            current_task = current_task->t_next;
        }
    }
}

7. 簡単な動作確認

簡単な動作確認を行うため、下記テストコードを作成しました。
start_threadsは一番初めに起動するタスクで、ここで1000ms/1500ms/2000ms周期で動作するタスクを生成しています。それぞれのタスクにブレークポイントを設定し、期待する周期で動作することを確認しました。

テストコード
int task_1000ms(int argc, char *argv[])
{
	while(1)
	{
		kz_tsleep(1000);
		a++;
	}
}
int task_1500ms(int argc, char *argv[])
{
	while(1)
	{
		kz_tsleep(1500);
		a++;
	}
}
int task_2000ms(int argc, char *argv[])
{
	while(1)
	{
		kz_tsleep(2000);
		a++;
	}
}
/* システム・タスクとユーザ・タスクの起動 */
static int start_threads(int argc, char *argv[])
{
  kz_run(task_1000ms, "task_1000ms",  8, 0x200, 0, NULL);
  kz_run(task_1500ms, "task_1500ms",  8, 0x200, 0, NULL);
  kz_run(task_2000ms, "task_2000ms",  8, 0x200, 0, NULL);

  kz_chpri(15); /* 優先順位を下げて,アイドルスレッドに移行する */
  while (1) {
    //TASK_IDLE; /* 省電力モードに移行 */
  }

  return 0;
}

8. 最後に

今回作成したソースコードを下記githubに置きました。
Nucleo-L4R5ZI_System
簡単な動作確認しかできていませんので、使用される場合は自己責任でお願いいたします。

ここからは私の感想です。
現在使用している開発環境では、Systick設定用の関数が用意されていました。これを使用したのですが、
この関数では、Systick割込みの優先度を下げていました。これにより、他の割り込みの優先度と異なるようになったため、多重割り込みが発生していました。この多重割り込みによって、OSの処理が複数の割り込みからされるようになり、動作しなくなってしまいました。これに気づくまでかなりの時間がかかりました。今回は、上記に記載している通り、すべての割込み優先度を同じにし多重割り込みを発生させないようにしています。今後は、多重割り込みについても考えていきたいです。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?