LoginSignup
30
4

More than 1 year has passed since last update.

SymbolブロックチェーンでワールドCPUを作ってみた

Last updated at Posted at 2021-12-19

1.概要

今回のテーマはCPUです。

CPUが何者なのか、IT系の方であれば当然ご存知でしょう。
スマートフォンやパソコン、その他多くの電気製品に使われている部品なので、非IT系の方でも普段から知らず知らずの内に使っているはず。
車とかだと数十個以上CPUが使われていたりします。

CPUは簡単に言うと、計算したい値を与えると答えを返してくれる部品です。
まぁ電卓みたいなものです。

そんな感じのCPUを無駄にSymbolブロックチェーンを活用しつつ作ってみましたので、ご紹介したいと思います。
ちなみに使い道については自分でも分かりません。遊んでみただけです。

【追記】 全然分からんと言うご意見が大半なので以下要約。

  • Symbolと言うブロックチェーンにはユーザが任意に編集できる領域として、1度書き込んだら変更できないメッセージ領域と、何度でも変更できるメタデータ領域がある
  • RISC-V CPUを題材として、メッセージ領域に命令セットのスクリプト、メタデータ領域にレジスタとメモリを配置したアプリを試作した
  • CPU以外にもマイクロサービスを作ってみたら面白そうだね

2.CPUの概要

今回作ったCPUの概要を説明します。

まずは今回作ったCPUの構成なのですが、大雑把に言うと演算器、レジスタ、メモリの3つから成り立っています。
それぞれ軽く説明します。

演算器

CPUは先ほども述べた様に、電卓みたいなものです。
足し算等の四則演算を始めとした数字を使った様々な計算をしてくれます。
その計算をしてくれるのが演算器です。
演算器は足し算や掛け算といった単純な演算の集まりです。
この集まりのことを命令セットと呼びます。
命令セットには色々と種類があるのですが、今回はRISC-Vという種類の命令セットで32bitCPUを作りました。
いわゆるRV32Iというやつです。

レジスタ

演算器は計算する機能しか持っておらず、計算対象の数字や計算結果の数字を覚えておく機能が必要となります。
これらの数字を記憶しておく機能がレジスタです。
例えば 1 + 2 を計算するときは、1を記憶する領域、2を記憶する領域、そして答えである3を記憶する領域が必要になります。
RISC-Vでは、このような計算に使う記憶領域を32個持ちます。

メモリ

CPU(RISC-V)は32個のレジスタを持ちますが、レジスタは計算のために数字を一時的に入れておく領域です。
計算対象の数字や計算結果をずっと入れっぱなしと言うわけにはいきません。
そこでこれらをまとめて記憶しておくものがメモリです。
メモリには計算対象の数字や計算結果、計算の手順(プログラム)などのデータを記憶します。

以上の演算器、レジスタ、メモリによるCPUを、無駄にSymbolブロックチェーンを駆使して動かしました。
CPUと言うより命令セットシミュレータかも知れませんが、まぁ細かいことは良いでしょう。
考えるな、感じろ!

3.ブロックチェーンへの実装

以下が構成図となります。
(日本以外の方も見られるかもしれませんので、画像は英語表記とさせていただきます)

構成図.png

使うのはSymbolブロックチェーンのトランザクションのメッセージ領域と、メタデータです。

トランザクションのメッセージはブロックが承認されると以後変更ができません。
ですので、変更が不要なソースコードはメッセージ領域に記録しました。
図の緑のブロックです。

メタデータはブロックチェーンにデータが刻まれますが、何度でも内容を変更可能なものとなっています。
先ほどご説明したレジスタやメモリの値は都度変更する必要があるため、メタデータにデータを記録しました。
図の青のブロックです。

以下、それぞれ説明します。

演算器

演算器にあたる部分はPythonで実装しています。
流石にPythonをブロックチェーン上で実行させることはできないので(できないですよね?)、ブロックチェーンに格納したコードをローカルPCのスクリプトにインポートして実行します。
処理の流れとしては、緑色のコードが矢印の順に実行されていく感じです。
各コードの概要は以下の通り。

Local script

メッセージ領域のコードをインポートして実行するPythonスクリプトです。
他にはトランザクションやメタデータのハッシュ、鍵の情報を定義したりしています。
ローカルPC上に置いてあるのはこいつだけ。
regにはメタデータのアドレスとかハッシュを登録します。
ここは各自でメタデータを作って入力してください。メタデータだけ各自で作って値を初期化してあげれば、誰でも本CPUを実行できます。

import sha3
import urllib.request as ur
import json
import http.client as hc
import datetime as dt
import time
from binascii import hexlify, unhexlify
from symbolchain.core.CryptoTypes import PrivateKey
from symbolchain.core.symbol.KeyPair import KeyPair
from symbolchain.core.facade.SymbolFacade import SymbolFacade
from symbolchain.core.CryptoTypes import PublicKey
from symbolchain.core.symbol.IdGenerator import generate_namespace_id
from symbolchain.core.symbol.MerkleHashBuilder import MerkleHashBuilder as MHB
from symbolchain.core.CryptoTypes import Hash256

F = SymbolFacade("mainnet")
hasher = sha3.sha3_256()
digest = hasher.digest()
metaKey = int.from_bytes(digest[0:8], "little")
GEN = 1615853185
URL = "zzz-symbol.link"
traURL = "http://zzz-symbol.link:3000/transactionStatus/"
metaURL = "http://zzz-symbol.link:3000/metadata/"
FEE = 1000000
memSize=32

reg={
    0:{"hash":zero_hash,"keypair":zero_keypair,"pubkey":zero_pubkey,"addr":zero_addr}, # zero
    5:{"hash":t0_hash,"keypair":t0_keypair,"pubkey":t0_pubkey,"addr":t0_addr}, # t0
    6:{"hash":t1_hash,"keypair":t1_keypair,"pubkey":t1_pubkey,"addr":t1_addr}, # t1
    7:{"hash":t2_hash,"keypair":t2_keypair,"pubkey":t2_pubkey,"addr":t2_addr}, # t2
    32:{"hash":pc_hash,"keypair":pc_keypair,"pubkey":pc_pubkey,"addr":pc_addr}, # pc
    33:{"hash":imem_hash,"keypair":imem_keypair,"pubkey":imem_pubkey,"addr":imem_addr}, # instruction memory
    34:{"hash":dmem_hash,"keypair":dmem_keypair,"pubkey":dmem_pubkey,"addr":dmem_addr} # data memory
}

# コードをインポート
# メタデータ書き込み
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/955D47DF18891887B20AD5F810C516D5D778C6C2297575D4267B9228B2D9BAE5"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# レジスタ読み出し
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/D2ADC40CDD7230E2E5FA75A35A68D2BF177FC809DAE5F321AF994B88BF96F327"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# レジスタ書き込み
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/B8EA157DE74F34806AFC4441860BB013DCF28D5B828CE24CD98A7593E6603FBA"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# メモリ読み出し
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/2FA11549EF3C04CBCDE186887F57C4D8D13F963F20D4047E619F859FA2AF8445"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# メモリ書き込み
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/AB8BC418509CA7B15557EBFAE16FEFEC03F286750476E68781BE762935AF572D"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# Fetch unit
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/52F8620A530AE39CC8BD3CA2579279F3B9331677E6FD0B261B5028177E3E006B"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# Decode unit
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/77A81D10CAB82D926C25E78796A7117B0333568E368385CDC4A9686F0D9C9B95"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# addi
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/F5D0C6F31C6D4DEFF87D621533893B28F47D5861979A4070E732E4DB9DEF5325"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# add
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/EE872D9316EFA1A19746277BE061C6930D094014AF913A682AD7D84B928DC856"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# bne
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/D62848D86B70FCDF0F10D4D70616007F2BDCE132CE3E78B0D9A913B74B9CD57A"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# lw
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/14CFB482B35A5B8D0AF12B75697245C0117ED91183A7A49352EF5D2ACB8AC351"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# sw
data = json.load(ur.urlopen("http://zzz-symbol.link:3000/transactions/confirmed/9E112FC705817667C3C32FFC541CA1375E4A5814B013BB0D0A700F4609D06E10"))
exec(unhexlify(data["transaction"]["message"]).decode("utf-8").strip("\0"))

# CPU実行開始
fetch()

Fetch unit

プログラム用のメモリ(Instruction memory)からプログラムを1つずつ読み出してDecode unitに渡します。
PCには読み出すプログラムの位置が格納されているので、まずPCのレジスタから値を読み出します。
(レジスタやメモリのリード/ライトは多用するので関数化しています。別途説明します。)
これが次に実行すべき命令の場所を示していますので、今度はこのPCの値を使ってプログラムメモリからプログラムを読み出します。
読み出したプログラムはDecode unitへ渡します。
また、読み出したプログラムが0x0000_0000だった場合は実行ループを抜けるようにしています。

コードは以下の通り。エクスプローラのリンクも置いておきます。
Fetch unit

def fetch():
    while 1:
        inst=memRead(reg[33],regRead(reg[32]),0,4)
        if inst == 0x0:
            print("Program is over.")
            break
        decode(inst)

Decode unit

プログラムを解読します。どの様な数字をどの様に計算するのかがここで決まります。
解読した結果をExecution unitに渡します。

Decode Unit

def decode(inst):
 rd=(inst&0x00000f80)>>7
 rs1=(inst&0x000f8000)>>15
 rs2=(inst&0x01f00000)>>20
 # addi
 if(inst&0x707f)==0x00000013:
  tmp=(inst&0xfff00000)>>20
  if tmp<0x800:
   imm=tmp
  else:
   imm=-(tmp&0x800)|(tmp&0x7ff)
  addi(rd,rs1,imm)
 # add
 elif (inst&0x707f)==0x00000033:
  add(rd,rs1,rs2)
 # bne
 elif (inst&0x707f)==0x00001063:
  tmp=((inst&0x80000000)>>19)+((inst&0x7e000000)>>20)+((inst&0x00000f00)>>7)+((inst&0x00000080)<<4)
  if tmp<0x1000:
   imm=tmp
  else:
   imm=-(tmp&0x1000)|(tmp&0x0fff)
  bne(rs1,rs2,imm)
 # lw
 elif (inst&0x707f)==0x00002003:
  tmp=(inst&0xfff00000)>>20
  if tmp<0x800:
   imm=tmp
  else:
   imm=-(tmp&0x800)|(tmp&0x7ff)
  lw(rd,rs1,imm)
 # sw
 elif (inst&0x707f)==0x00002023:
  tmp=((inst&0xfe000000)>>20)+((inst&0x00000f80)>>7)
  if tmp<0x800:
   imm=tmp
  else:
   imm=-(tmp&0x800)|(tmp&0x7ff)
  sw(rs1,rs2,imm)
 else:
  print("invalid instruction")
  exit()

Execution unit

Decode unitの解読した結果に基づいて計算をします。
今回はRISC-Vの命令の内、addi、add、bne、lw、swだけ実装しました。
計算が終わったら結果をRegisterに書き込みます。
また、次の読み出すプログラムの位置を進めるためにPCを更新します。
データメモリのリード/ライトがある場合は、メタデータの読み書きも行います。
今回実装した命令ですと、lwでメタデータの読み出し、swでメタデータの書き込みをします。
アドレスとオフセットを指定することにより任意のアドレス番地に読み書きをできるようにしています。
1バイト、2バイト、4バイトアクセスが可能ですが、今回はlwとswしか実装していないので4バイトアクセスしか使いません。
また、メタデータ更新のトランザクションをアナウンスした後は、そのトランザクションが承認されるまで待ちます。
「http//node_url:3000/transactionStatus/hash」の「group」を見ればトランザクションが"unconfirmed"から"confirmed"になったことが分かります。
各命令のコードは以下の通り。

addi
add
bne
lw
sw

※まとめて記載します。

# addi命令
def addi(rd, rs1, imm):
    print("addi")
    tmp=regRead(reg[rs1])+imm
    hash1=regWrite(reg[rd],tmp)
    hash2=regWrite(reg[32],regRead(reg[32])+4)
    while 1:
        time.sleep(3)
        res1 = ur.urlopen(traURL+str(hash1))
        data1 = json.load(res1)
        res2 = ur.urlopen(traURL+str(hash2))
        data2 = json.load(res2)
        if (data1["group"] == "confirmed") & (data2["group"] == "confirmed"):
            break

# add命令
def add(rd, rs1, rs2):
    print("add")
    tmp=regRead(reg[rs1])+regRead(reg[rs2])
    hash1=regWrite(reg[rd],tmp)
    hash2=regWrite(reg[32],regRead(reg[32])+4)
    while 1:
        time.sleep(3)
        res1 = ur.urlopen(traURL+str(hash1))
        data1 = json.load(res1)
        res2 = ur.urlopen(traURL+str(hash2))
        data2 = json.load(res2)
        if (data1["group"] == "confirmed") & (data2["group"] == "confirmed"):
            break

# bne命令
def bne(rs1,rs2,imm):
    print("bne")
    if regRead(reg[rs1]) != regRead(reg[rs2]):
        hash=regWrite(reg[32],regRead(reg[32])+imm)
    else:
        hash=regWrite(reg[32],regRead(reg[32])+4)
    while 1:
        time.sleep(3)
        res = ur.urlopen(traURL+str(hash))
        data = json.load(res)
        if data["group"] == "confirmed":
            break

# lw命令
def lw(rd,rs1,imm):
    print("lw")
    tmp=memRead(reg[34],regRead(reg[rs1]),imm,4)
    hash1=regWrite(reg[rd],tmp)
    hash2=regWrite(reg[32],regRead(reg[32])+4)
    while 1:
        time.sleep(3)
        res1 = ur.urlopen(traURL+str(hash1))
        data1 = json.load(res1)
        res2 = ur.urlopen(traURL+str(hash2))
        data2 = json.load(res2)
        if (data1["group"] == "confirmed") & (data2["group"] == "confirmed"):
            break

# sw命令
def sw(rs1,rs2,imm):
    print("sw")
    hash1=memWrite(reg[34],regRead(reg[rs1]),regRead(reg[rs2]),imm,4)
    hash2=regWrite(reg[32],regRead(reg[32])+4)
    while 1:
        time.sleep(3)
        res1 = ur.urlopen(traURL+str(hash1))
        data1 = json.load(res1)
        res2 = ur.urlopen(traURL+str(hash2))
        data2 = json.load(res2)
        if (data1["group"] == "confirmed") & (data2["group"] == "confirmed"):
            break

レジスタ

レジスタは格納した値を変更できるようにメタデータを使用しました。
レジスタは基本の32個と、PC用の1個で合わせて33個です。
(今回はzero, t0, t1, t2, PCしか使いませんでしたが)
RISC-Vの内、32bitCPUであるRV32を想定しているので、レジスタに格納する値も32bitにしました。
レジスタはこんな感じで1つずつ別のアドレスに紐づけました。
scopeMetadataKeyが異なれば同じアドレスでも複数のメタデータを持てるのでアドレスを分ける必要はありませんが、アカウントが分かれていた方が見やすいので分けてみました。

レジスタ.png

レジスタのリード/ライトは以下の関数で行います。
トランザクションだけ作成して、アナウンスは後述するmetadataWrite関数で行います。

レジスタ読み出し

def regRead(reg):
    print("regRead")
    res = ur.urlopen(metaURL+reg["hash"])
    data = json.load(res)
    return(int(unhexlify(unhexlify(data["metadataEntry"]["value"]).decode("utf-8")).decode("utf-8"),16))

レジスタ書き込み

def regWrite(r,v):
    print("regWrite")
    res = ur.urlopen(metaURL+r["hash"])
    data = json.load(res)
    oldValue = unhexlify(data["metadataEntry"]["value"]).decode("utf-8")
    newValue = "".join([format(ord(data),"x") for data in format(v,"08x") ])
    xorValue = "".join([chr(ord(data) ^ ord(code))
        for (data, code) in zip(oldValue, newValue)]).encode().hex()
    tx=F.transaction_factory.create_embedded({
        "type":"accountMetadata",
        "signer_public_key":r["pubkey"],
        "target_address":r["addr"],
        "scoped_metadata_key":metaKey,
        "value_size_delta":0,
        "value":unhexlify(xorValue)
    })
    return(metadataWrite(r,tx))

メモリ

メモリもレジスタと同様に値を変更できるようにしないといけないので、こちらもメタデータを使用しました。
特に分ける必要はないのですが、今回はデータ用とプログラム用に1つずつメタデータを用意しました。
実際にメタデータにデータを書き込んで試してみたところ、半角英数字512文字まで記録することが出来ました。
これより大きいと「メタデータ値が大きすぎるため、検証に失敗しました」と表示されます。
(ドキュメントには1024文字と書いてありますが、この差は何なんだろう?ちなみにメッセージは1023文字まででした)
今回はちょっとした動作が確認できればOKということもあって32Byteにしています。
プログラムメモリには実行対象のプログラムを格納するのですが、プログラムは機械語のバイナリコードとなります。
符号拡張などを省略しているので完全なものではありませんが、一応バイナリ互換っぽく動きます。
こちらもトランザクションのアナウンスはmetadataWrite関数で行います。

以下コードです。
文字数がギリギリだったのでインデントを減らしています。

メモリ書き込み

def memWrite(r,ad,v,of,len):
 print("memWrite")
 res = ur.urlopen(metaURL+r["hash"])
 data = json.load(res)
 oldValue = unhexlify(data["metadataEntry"]["value"]).decode("utf-8")
 oV=unhexlify(unhexlify(data["metadataEntry"]["value"]).decode("utf-8")).decode("utf-8")
 if len==4:
  tmp=0xffffffff
 elif len==2:
  tmp=0xffff
 elif len==1:
  tmp=0xff
 else:
  return()
 oV_partial=(int(oV,16)>>(memSize-ad-of-len)*8)&tmp
 xor=oV_partial^v
 mask=xor<<(memSize-ad-of-len)*8
 maskedValue=int(oV,16)^mask
 newValue="".join([format(ord(data),"x") for data in format(maskedValue,"064x")])
 xorValue = "".join([chr(ord(data) ^ ord(code))
  for (data, code) in zip(oldValue, newValue)]).encode().hex()
 t=F.transaction_factory.create_embedded({
  "type":"accountMetadata",
  "signer_public_key":r["pubkey"],
  "target_address":r["addr"],
  "scoped_metadata_key":metaKey,
  "value_size_delta":0,
  "value":unhexlify(xorValue)
 })
 return(metadataWrite(r,t))

メモリ読み出し

def memRead(reg,addr,offset,length):
    print("memRead")
    res = ur.urlopen(metaURL+reg["hash"])
    data = json.load(res)
    tmp = int(unhexlify(unhexlify(data["metadataEntry"]["value"]).decode("utf-8")).decode("utf-8"),16)
    shift_size=(memSize-addr-offset-length)*8
    if length == 0x4:
        mask = 0xffffffff
    elif length == 0x2:
        mask = 0xffff
    elif length == 0x1:
        mask = 0xff
    else:
        return()
    ret = (tmp&mask<<shift_size)>>shift_size
    return(ret)

メタデータの変更

上に記載したレジスタやメモリの変更のトランザクションはこいつがアナウンスしています。
書き込み先のメタデータと変更内容を引数で受け取って書き込む感じです。
メタデータ変更

def metadataWrite(r,t):
    h=MHB()
    h.update(Hash256(sha3.sha3_256(t.serialize()).digest()))
    a=F.transaction_factory.create({"type":"aggregateComplete","signer_public_key":r["pubkey"],"fee":FEE,"deadline":(int((dt.datetime.today()+dt.timedelta(hours=2)).timestamp())-GEN)*1000,"transactions_hash":h.final(),"transactions":[t]})
    a.signature=F.sign_transaction(r["keypair"],a).bytes
    hc.HTTPConnection(URL,3000).request("PUT","/transactions",json.dumps({"payload":hexlify(a.serialize()).decode("utf-8").upper()}),{"Content-type":"application/json"})
    return(F.hash_transaction(a))

5.テストプログラム

では、テストプログラムを本CPUで実行してみます。
テストプログラムの処理の流れはこんな感じ。

  1. Data memoryのアドレス0x0番地からt0レジスタに4バイト読み出す(0x00000001がリードされる)
  2. t1レジスタに0x00000001を書き込む
  3. t2レジスタに0x00000003を書き込む
  4. t0レジスタとt1レジスタの値を足して、結果をt0レジスタへ書き込む
  5. t0レジスタとt2レジスタを比較し、一致していなければ4.の命令に戻る。一致していたら次へ。
  6. Data memoryのアドレス0x4番地にt0レジスタの値を4バイト書き込む(0x00000003がライトされる)
  7. 終了

以上の実行手順を機械語命令に直すと以下のような感じになります。
ついでにアセンブラに直したコードも載せときます。
いわゆるリセットベクタってやつは0x00000000にしました。
つまりアドレス0x00000000の命令からプログラムがスタートします。

アドレス:機械語命令:アセンブラ
00:00002283:lw t0,0(zero)
04:00100313:addi t1,zero,1
08:00300393:addi t2,zero,3
0c:006282b3:add t0,t0,t1
10:fe729ee3:bne t0,t2,-4
14:00502223:sw t0,4(zero)
18:00000000:N/A
2c:00000000:N/A

先述の通り本CPUは機械語命令で動かすので、上記の機械語命令の部分をInstruction memoryに書き込みます。
アドレス計算が面倒になるので改行コードは無しです。
先述の通りメモリは32Byteにしています。

命令メモリの中身はこんな感じ。
Instruction memory

000022830010031300300393006282b3fe729ee3005022230000000000000000

6.実行結果

では実行して結果を見てみましょう。
実行するとレジスタとData memoryに値が残るので、それを見てみます。
確認するのは以下のメタデータです。

t0レジスタ
t1レジスタ
t2レジスタ
PC
Data memory

実行前

t0:00000000
t1:00000000
t2:00000000
PC:00000000
Data memory:0000000100000000000000000000000000000000000000000000000000000000

実行後

t0:00000003
t1:00000001
t2:00000003
PC:00000018
Data memory:0000000100000003000000000000000000000000000000000000000000000000

はい。良いんじゃないでしょうか。
ちゃんと動いています。

が、とにかく遅いですね。
これだけ計算するのに数分かかりました。
大体30秒に1命令なので、MIPSに換算すると0.0000000333ですね。

7.おわりに

と言うわけで、なんちゃってCPUを作ってみました。
世界中のどこからでも、いつでも、誰にでも実行できるCPUです。
また、ブロックチェーン上にコードとレジスタ、メモリを配置しているので、CPUの挙動が世界中のどこからでも検証できます。
史上最も透明性のあるCPUと言えるでしょう。
そう、これがワールドCPUなのです!(タイトル回収)

とは言ってみたものの、さすがにCPUを模擬するのでは抽象度が低すぎて使い道がないかも知れません。
もっと抽象度を上げてマイクロサービス的なものを実装してみたら何か面白いことができるんじゃないでしょうか。

30
4
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
30
4