LogiClover開発ブログ

LogiCloverは、趣味でFPGAを使った電子工作をしているサークルです。主に開発中の出来事や技術メモを投稿します

GKEでCronJobを使い、定期処理を実行する

以前Splatoonの戦績管理のためのDockerコンテナを作成し、Google Kubernetes Engine(GKE)にアップロードしました。

数日間の運用をしてみたところの課題感を以下に示します。

logiclover.hatenablog.jp

定期実行のために常にタスクが動いている

これは予想していたことですが、splatnet2statinkのモニターモードはtime.sleep(1)でカウントダウンしており*1定期実行のためだけに余計な処理をしています。

これはcronやsupervisorのようにいい感じの定期実行タスクに載せたいですね。

stackdriverのログ汚染

これもカウントダウンに関連する話ですが、残り時間のstdoutがそのままログに載ってくるので自ずとログがでかくなります。

ちゃんと動いているのか不明

更新されたときの結果ぐらいは、slackあたりで見たい...(次の記事で)

というような課題感があるため、Kubernetes 1.8から実装されたCronJobでバッチ処理に置き換え、結果もSlackに通知できるようにしてみます。


CronJobの導入

Web上のUIからでは、2018年7月現在はGKEのDeploymentしかワークロードが作成できません。ですのでCronJobなどを追加するにはgcloud sdk, kubectlを使ったコマンドライン操作が必須です。*2

公式ドキュメントを参考に進めていきます。

CronJobs  |  Kubernetes Engine  |  Google Cloud

これが毎分busyboxイメージで毎分こんにちわさせる設定ファイルです。

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: hello
spec:
  schedule: "*/1 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: hello
            image: busybox
            args:
            - /bin/sh
            - -c
            - date; echo "Hello, World!"
          restartPolicy: OnFailure

見てみるとかなりシンプルです。scheduleにcrontabと同じ書式で周期を書き、あとはspec/containersに実行したいイメージと引数を与えればいいようです。

前回デプロイしたコンテナであるsplatnet2statink-dockerで同じことができるように書いてみます。

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  labels:
    app: <name>
  name: <name>
  namespace: default
spec:
  schedule: 0 */3 * * *
    spec:
      template:
        spec:
          containers:
          - env: # ConfigMapからconfigMapKeyRefで引っ張ってくる
            - name: api_key
            - name: cookie
            - name: session_token
            - name: user_lang
            - name: run_flags
            # ここまで
            image: path/to/splatnet2statink-docker:1.0
            name: splatnet2statink-docker
          restartPolicy: OnFailure

ほとんどテンプレ通りです。envに指定した環境変数は直に値を書いてもいいですが、後のことを考えるとConfigMap,Secretに書いた値を持ってくるようにします。 あとは、splatnet2statinkをモニターモードで起動する必要がないのでConfigMapでrun_flags-rだけに変更しておきます。

此処から先はコマンドラインで作業をします。kubectlでターゲットのプロジェクトが使える状態にしておきます。

あとは新規作成であれば以下のコマンドだけでデプロイできます。yamlの中身で処理は変えてくれるのでCronJobに限らず、ConfigMapの編集やDeploymentも同様に扱えます。

$ kubectl create -f <設定ファイル.yml>

あとはWebコンソールを眺めに行くと、3時間ごとにPodが割当てられて、処理されていることが確認できます。

f:id:logiclover:20180728181257p:plain

おわりに

定期実行のタイマーをKubernetes側に任せることで、処理が必要なときのみPodを確保するようになります。これにより以下の利点があります。

Compute Engineのリソース削減

共有CPUリソースであるf1-microを使用していますが、これに限らず課金額はCPU使用時間(*使用率)で決定します。ただカウントダウンをするのにリソースを使うのはもったいないですね。

バッチ処理との共存

CronJobタスクを増やしていけば、同一クラスタ上で複数個のCronJobワークロードを実行できます。このおかげでVPSを解約しました。

プリエンプティブVMの使用

クラスタに使っている各ノードもGCEのマシンで常時確保されています。これはそのまま課金額に直結してしまうのですが、正直バッチ処理のときにスケジューリングできるノードがあれば、ほかは落ちていても良くない?という考えになります。

そこでプリエンプティブVMです。これは最長24hでシャットダウンされ可用性が保証されないVMです、その代わりに価格が安いというものです。

バッチ処理にしておけば、これらのVMクラスタを構成しても運用できるようになります。詳細は割愛します。

Kubernetes Engine でプリエンプティブ VM を使用する  |  Kubernetes Engine  |  Google Cloud

*1:https://github.com/frozenpandaman/splatnet2statink/blob/59af7bf4fe582ba56bca8858013d5c9ab841846a/splatnet2statink.py#L351

*2:慣れればこっちのほうが楽ですが、コマンドの規模感が分かりづらいので...

スプラトゥーン2の戦績をsplatnet2statinkとGKEを使って自動アップロードする

きっかけ

私(kamiya)は任天堂から発売されているスプラトゥーン2を遊んでいます。

www.nintendo.co.jp

そこでスプラトゥーン上での戦績は、スマホアプリから閲覧できるのですがこれが直近の50戦しか閲覧することができません。

そこで非公式ではあるのですが、この戦績を管理するサービスであるstat.inkとアップロードするツールであるsplatnet2statink を使って戦績を管理しています。(※公式の手法ではないため推奨はいたしません、運営サーバに負荷をかけるような使い方はサービス妨害と見なされ戦績公開自体がなくなってしまうかもしれないのでお辞めください)

今回はVPS上で運用しているsplatnet2statinkをGKE(Google Kubernetes Engine)に移行します。*1 既存のpythonアプリケーションをコンテナ化してクラウド運用する手順の紹介、という立ち位置で読んでいただけると幸いです。

手順

大まかには以下の手順を踏んで行います。3.項移行はgcloud sdkを使う方法とGoogle CloudのWebページからでもどちらでも行なえます。ここはGoogleの公式ドキュメントのチュートリアルに詳しく記載されています。

  1. 単体で動作確認をする

  2. Dockerコンテナ上で動作できるようする

  3. Google Docker Registry(もしくはDockerHub)にイメージをpushする

  4. Kubernetesクラスタを作成

  5. Kubernetesクラスタに登録済のコンテナイメージをデプロイする

splatnet2statinkの動作

pythonで書かれた任天堂のサーバから戦績情報を取得するソフトです。コンテナ化する上でも簡単に動作を確認してみます。*2

github.com

7~40行目

config.txtを読み出すか、作成しています。その内容はapi_key, cookie, user_lang, session_tokenの4つです。

注目すべきはコンフィグファイルの指定はpython実行時のカレントディレクトリになる点です。

1170行目 name == "main"

pythonで直接実行されているときのみ実行されます。今回ではメインルーチンになっています。

1171行目→228行目 : main()

以下の動作をするようです。

  • 224行目 → 194行目 : check_for_update()

どうやらgithubからコードの最新版があるか調べ、gitが実行できる環境ならgit pull、できなければrequest.getをしてリソース更新をしているようです。

よく見ると204行目でアップデートを行うかのプロンプトが必ず表示されるようです。

  • 237行目~270行目

argparseライブラリを使って、コマンドライン引数の設定・取得を行っています。動作としては-M <更新間隔>で 一定時間ごとの自動更新と、-rでアップロードいていない戦績のみを対象にするオプションが使えれば良さそうです。

1175行目→295行目 : monitor_battle()

一定時間ごとに結果を取得してアップしています。

他にもいろいろありますが、以下の3点がコンテナ化する上でのポイントです。

  1. 自身のアップデートにrequest及びgitを使用している

  2. 設定ファイルはconfig.txtを使うこと

  3. 実行時はpython splatnet2statink.py -M <秒数> -rを実行できればよい

単体で動作確認をする

python splatnet2statink.pyで正常に動作できることを確認します。認証などについては詳細は触れません。

Dockerコンテナ上で動作できるようにする

まずは本アプリケーションをコンテナ化します。コンテナ化する上で認証情報などはあとから環境変数などで与えられるようにするのが通例です。

まずはgitを使ったアップデートですが、プロンプトにyを打ち込ませるのも面倒なのでgit submoduleとしてsplatnet2statinkを追加してgitコマンドを叩いてアップロードします。

ですので、実際に実行指定するスクリプトとしてrunner.pyを作成していきます。

$ git submodule add https://github.com/frozenpandaman/splatnet2statink splatnet2statink

pythonからは$ git submodule foreach git pull origin masterを叩ければ良いのでsubprocessモジュールを使って以下のようにします

subprocess.call(["git", "submodule", "foreach", "git", "pull", "origin", "master", ])

次に設定ファイルですが、環境変数を読み出してconfig.txtを書き換えるようにします。os.getenvで簡単に取得できるのでこれを利用します。 ファイルの書き込みはjsonモジュールを使うと、dictionaryをそのままjsonテキストにできるのでこれを書きます。

def env_to_config(envs_src):
    envs = dict([(e, os.getenv(e, "")) for e in envs_src])
    envs_available = all(envs.values())
    if envs_available:
        with open('config.txt', 'w') as file:
            file.write(json.dumps(envs))
        print('#update config.txt')

最後にスクリプトの実行ですが、これはgitコマンドの実行と同じです。

args = ["python", "./splatnet2statink/splatnet2statink.py"]
flags = os.getenv("run_flags", "-M 14400 -r").split(" ") # monitor per 4hour
args.extend(flags)
subprocess.call(args)

これらを統合してhttps://github.com/kamiyaowl/splatnet2statink-docker/blob/master/runner.pyとしました。

これをpython runner.pyして問題なく動作することを確認します。

あとはsplatnet2statinkに必要なライブラリをインストールするだけのDockerfileを書きます

FROM python:3.6

COPY . /app
WORKDIR /app

RUN pip3 install --upgrade -r splatnet2statink/requirements.txt

CMD ["python", "-u", "runner.py"]

私は面倒くさがりでコマンドライン引数を大量に書くのが苦手なので、docker-composeファイルも一緒に作成しました。 あくまで動作確認用に作成しましたが、自分で行う場合このファイルの管理は注意してください。

splatnet2-statink:
  build: ./
  # volumes:
    # - cache:/app # splatnet2statinkのリポジトリ更新キャッシュ用
  environment: # Containerサービスの環境変数に設定する
    - api_key=your_api_key
    - cookie=your_cookie
    - session_token=your_session.token
    - user_lang=en-US

あとは正常に実行できることを確認します。

$ docker-compose up

Google Container Registryに作成したイメージをアップする

Google Cloud SDKのセットアップについては公式ドキュメントなどを参照してください。

// イメージ名を確認
$ docker image ls 

// タグ付けを行う
$ docker tag <イメージ名> gcr.io/<プロジェクト名><イメージ名>:<タグ>

//  作成したイメージをGCRにアップする
$ gcloud docker -- push gcr.io/<プロジェクト名><イメージ名>

// イメージ一覧を確認
$ gcloud container images list

不安なのでWebコンソールでも見てみます。

f:id:logiclover:20180716175256p:plain

良さそうです。

Google Kubernetes Engineにアップする

ここからはGUIでもkubectlでもできます。おおまかな説明だけするとまずクラスタを作成します。

構成は任意ですが、動作確認なのでus-central1-a, g1-small, auto-scaling=off, size=1で作成しました。

次にワークロードを作成します。作成時に先程GCRにアップしたイメージをベースイメージとして指定します。この際の環境変数にdocker-composeで指定していたconfig.txtの項目を設定します。

f:id:logiclover:20180716180407p:plain

この設定はGKEのConfigMapに保存されるのであとから編集できます。

静的IPやLBは外部ネットワークが必要ないので設定せず、あとはクラスタが構成されるまで少し待ちます。

無事に立ち上がった後、stat.inkの結果を見るなりログの内容を閲覧して正常に動作していることを確認できれば完了です。

f:id:logiclover:20180716180603p:plain

WebコンソールからStackdriverの起動時のログを見てみましたが、ローカル起動時と同様に出力されており問題なく動作できていることが確認できます。

まとめ

各種サービスをコンテナ化してクラウドサービスに移行していくと、何よりサーバ管理の手間がないので面倒が減るのでかなり楽です。

従量課金の具合などから構成を見直したりする必要はあると思いますが*3、ちょっとした小回りから大掛かりなものまで活用できそうです。

今回作成したものも、githubで公開しておきます。

github.com

2018/7/28追記

CronJobで定期実行できるようにしました。

logiclover.hatenablog.jp

*1:正直Kubernetesが使いたいだけなのでCompute EngineのVMに当てても何ら問題ありません。

*2:2018/07/16現在のコードです

*3:例えばGAEでcronするようなジョブを作ったほうが

Kerasで作った手書き文字認識をWebアプリにしてDockerコンテナにする

タイトルのとおりです。今更ながらではありますが機械学習に足を踏み入れたWebアプリを作ってみます。

機械学習に足を踏み入れかけの人をターゲットにしています、が投げやりなので不足点は公式ドキュメント等をご参照ください。

今回は機械学習をKerasで簡単に実装し、それを使ったWebアプリの作成を一通り行います。 また動作環境をDockerのコンテナにまとめ、どこでも使えるようなイメージにします。

MNISTとは

機械学習で言うところのHello Worldのようなものです。28×28の手書き文字画像が、どの数字が書かれているか予測する問題です。

f:id:logiclover:20180710205930p:plain

学習用に28×28の画像と、答えの数字(0~9)が与えられます。

TF.Kerasで学習する

今回はそんなに細かいことをやるわけではないので、Kerasを使います。

Kerasは(TensorFlowなどに比べ)、ニューラルネットの記述に特化している、評価関数などの初期値がいい感じに定まっているという使いやすい点があります。

さておき細かい構成や実装については、各種書籍や記事にまとめられているので割愛し以下の構成で実装しました。

Layer (type)                 Output Shape              Param #   
=================================================================
input_1 (InputLayer)         (None, 28, 28, 1)         0         
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 26, 26, 32)        320       
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 24, 24, 64)        18496     
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 12, 12, 64)        0         
_________________________________________________________________
dropout_1 (Dropout)          (None, 12, 12, 64)        0         
_________________________________________________________________
flatten_1 (Flatten)          (None, 9216)              0         
_________________________________________________________________
dense_1 (Dense)              (None, 120)               1106040   
_________________________________________________________________
dropout_2 (Dropout)          (None, 120)               0         
_________________________________________________________________
dense_2 (Dense)              (None, 10)                1210      
=================================================================
Total params: 1,126,066
Trainable params: 1,126,066
Non-trainable params: 0

28×28の画像を正規化し、学習させます。学習したモデルはあとのWebアプリで使用するので保存しておきます。

#%% インポート関連

import tensorflow as tf
# tf.enable_eager_execution()
print(tf.__version__)
print(tf.test.is_built_with_cuda())


from tensorflow.python import keras
print(keras.__version__)
from tensorflow.python.keras.callbacks import EarlyStopping

import numpy as np
from IPython.display import display
import matplotlib.pyplot as plt
from PIL import Image
%matplotlib inline
np.set_printoptions(threshold=100)


#%% データを読みこみ
(x_train_src, y_train_src), (x_test_src, y_test_src) = keras.datasets.mnist.load_data()
print(x_train_src.shape)
print(y_train_src.shape)
print(x_test_src.shape)
print(y_test_src.shape)

# channel last前提で処理
keras.backend.image_data_format()

#%% numpy配列に変換
input_shape =(28,28,1)
x_train = x_train_src.reshape(x_train_src.shape[0], 28, 28, 1)
x_test = x_test_src.reshape(x_test_src.shape[0], 28, 28, 1)

# テストデータを正規化
x_train = x_train / 255.0
x_test = x_test / 255.0
# 分類問題なのでone-hot enc
y_train = keras.utils.to_categorical(y_train_src, 10)
y_test = keras.utils.to_categorical(y_test_src, 10)

print(x_train.shape)
print(x_test.shape)

# 画像を表示、arrは28x28x1の正規化されたもの
def convert_image(arr, show=True, title="", w=28, h=28):
    img = Image.fromarray(arr.reshape(w,h) * 255.0)
    if show:
        plt.imshow(img)
        plt.title(title)
    return img

def convert_images(srcs, length, show=True, cols=5, w=28, h=28):
    rows = int(length / cols + 1)
    dst = Image.new('1', (w * cols, h * rows))
    for j in range(rows):
        for i in range(cols):
            ptr = i + j * cols
            img = convert_image(srcs[ptr], show=False, w=w, h=h)
            dst.paste(img, (i * w, j * h))
    if show:
        plt.imshow(dst)
    return dst

plt.subplot(1,2,1)
convert_images(x_train, 50,)
plt.subplot(1,2,2)
convert_images(x_test, 50,)
plt.show()

#%% モデル構築・学習
def MNISTConvModel(input_shape, predicates_class_n):
    inputs = keras.layers.Input(shape=input_shape)
    x = keras.layers.Conv2D(32, kernel_size=(3,3), activation='relu')(inputs)
    x = keras.layers.Conv2D(64, kernel_size=(3,3), activation='relu')(x)
    x = keras.layers.MaxPooling2D(pool_size=(2,2))(x)
    x = keras.layers.Dropout(0.25)(x)
    x = keras.layers.Flatten()(x) # 2D(12*12*64) -> 1d(9216)
    x = keras.layers.Dense(120, activation='relu')(x)
    x = keras.layers.Dropout(0.5)(x)
    predicates = keras.layers.Dense(predicates_class_n, activation='softmax')(x)
    return keras.models.Model(inputs=inputs, outputs=predicates)

model = MNISTConvModel(input_shape=input_shape, predicates_class_n=10)
model.summary()

# モデルをコンパイルして実行
batch_size = 128
epochs = 20

model.compile(
    loss=keras.losses.categorical_crossentropy,
    optimizer='adadelta',
    metrics=['accuracy']
)
tensorboard_cb = keras.callbacks.TensorBoard(log_dir="./tflogs/", histogram_freq=1)
history = model.fit(
    x_train, y_train,
    batch_size=batch_size,
    epochs=epochs,
    verbose=2,
    validation_data=(x_test, y_test),
    callbacks=[tensorboard_cb],
)

#%% 学習結果の確認
plt.subplot(2,1,1)
plt.plot(range(epochs), history.history['acc'], label='acc')
plt.plot(range(epochs), history.history['val_acc'], label='val_acc')
plt.legend(loc='center left', bbox_to_anchor=(1, 0.5))

plt.subplot(2,1,2)
plt.plot(range(epochs), history.history['loss'], label='loss')
plt.plot(range(epochs), history.history['val_loss'], label='val_loss')
plt.legend(loc='center left', bbox_to_anchor=(1, 0.5))
plt.show()

#%% 性能
scores = model.evaluate(x_test, y_test, verbose=2)
print('loss', scores[0], 'accuracy', scores[1])

#%% モデルの保存
model.save('model.h5')

これで学習させると正答率99.3%でした。グラフを見ても10epochs付近で過学習に陥っているのでEarlyStopping入れても良かったかもしれません。

f:id:logiclover:20180710211809p:plain

学習済みモデルを利用したREST APIサーバを作成する

モデルを変換したりKeras.jsを使う方法などがありますが、ユーザにモデルのダウンロードをさせるのは重荷なのでAPIとして提供します。

今回Pythonを使っているのでFlaskというライブラリを使用します。

下のようなコードhttpでjsonが返せるすぐれものです。

@app.route("/")
def index():
    return make_response(jsonify({"hello": "world"})

今回はkerasのモデルを読み込みpredictを使って予測値を返します。

# GPUは使わない
import os
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"

import time
import tensorflow as tf
from tensorflow.python import keras

from flask import Flask, jsonify, abort, make_response, request, send_from_directory
import numpy as np

graph = tf.get_default_graph()
model = None

app = Flask(__name__)

# 疎通確認
@app.route("/info")
def index():
    return make_response(jsonify({
        "name": "mnist-cnn server",
        "time": time.ctime(),
    }))

# 28*28の画像をPOSTで配列にして送ると、0~9の推論結果を返してくれる
@app.route("/predict", methods=['POST'])
def mnist():
    data = request.json
    if data == None:
        return abort(400)
    src = data["src"]
    if (src == None) | (not isinstance(src, list)):
        return abort(400)
    src = np.array(src)
    # 正規化する
    src = src.astype('float32') / 255.0
    src = src.reshape(-1,28,28,1)
    # 推論する
    with graph.as_default():
        start = time.time()
        dst = model.predict(src)
        elapsed = time.time() - start
        return make_response(jsonify({ 
            "predict" : dst.tolist(),
            "elapsed" : elapsed,
        }))

# 静的ファイル公開    
@app.route("/", defaults={"path": "index.html"})
@app.route("/<path:path>")
def send_file(path):
    return send_from_directory("dist", path)


if __name__ == '__main__':
    model = keras.models.load_model("./model.h5")
    app.run(host="0.0.0.0", port=3000, debug=True)

最後の行でhostを0.0.0.0にしないと外部からアクセスできないので注意します。

ついでにこのあと作成する静的ページもホスティングできるようにしています。

ここまでで、POSTすると学習済みモデルのpredictの結果が得られるようになりました。

f:id:logiclover:20180710220535p:plain

手書きができるWebページを作成する

最後に手書きされたデータを先程のFlaskの/predictに投げるWebページを作ります。

手書きにはhtml5canvasを使います。また作りやすくするためにvue.jsを読み込んで使います。

@touchmoveみたいな箇所はvue側のmethodsを呼び出してくれます。

v-modelは変数の双方向バインディング{{ variable_name }}はViewへの単方向バインディングです。

<!doctype html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>MNIST CNN Demo</title>
        <link href="style.css" rel="stylesheet">
    </head>
    <body>
        <div id="container">
            <h3>{{ message }}</h3>
            <div id="canvas_container">
                <canvas id="draw_canvas" width="28" height="28"
                    @touchmove="touch_draw"
                    @mousemove="drag_draw"
                    @mouseup="predict"
                    @touchend="predict"></canvas>
            </div>
            <input v-model="pen_size" type="range" min="1" max="100" step="1">
            <span>Pen Size:{{ pen_size }}</span>

            <button @click="clear">Clear</button>
        </div>

        <script src="vue.min.js"></script>
        <script type="text/javascript" src="index.js"></script>
    </body>
</html>

次に動作を書きます。.new Vueする際にelで指定した要素に対して適用されます。

dataにはバインドする変数を定義し、methodsに使用する関数を記述します。もっと複雑なロジックや状態遷移がある場合はvuexなども検討してもいいかもしれません。

ポイントはcanvasのドラッグやタッチした際の位置を修正することと、サイズと実際に表示されるサイズが異なるためその変換を行っています。

最後にctx.getImageDataを叩いて得られたデータから、Flaskに送る配列に変換しています。(一緒にRGBからGrayScale画像にしています)

あとはpredictの結果から一番近しい数字を表示して終わりです。

const container = new Vue({
    el: '#container',
    data: {
        message: '0から9の数字を書いたら識別します!',
        pen_size: "50",
        is_debug: false,
    },
    methods: {
        update_message: function(str) {
            this.message = str;
        },
        clear: function() {
            const canvas = document.getElementById('draw_canvas');
            const ctx = canvas.getContext('2d');
            ctx.fillStyle = 'black';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            this.update_message('また書いてね!');
        },
        touch_draw: function(e) {
            const rect = e.target.getBoundingClientRect();
            // 気分でマルチタッチ対応してみる
            for(const t of e.touches) {
                const x = t.clientX - rect.left;
                const y = t.clientY - rect.top;
                this.draw(x, y);
            }
        },
        drag_draw: function(e) {
            if(!e.buttons) return;

            const rect = e.target.getBoundingClientRect();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            this.draw(x, y);
        },
        draw: function(mx, my) {
            const canvas = document.getElementById('draw_canvas');
            // 表示サイズとcanvasサイズは異なるので変換しておく
            const x = mx / canvas.clientWidth * canvas.width;
            const y = my / canvas.clientHeight * canvas.height;
            if (x < 0 || y < 0 || canvas.width < x || canvas.height < y) return;
            // 点を書く
            const ctx = canvas.getContext('2d');
            const r = parseFloat(this.pen_size) / 100.0 * (canvas.width / 8);
            ctx.beginPath();
            ctx.fillStyle = 'white';
            ctx.arc(x, y, r, 0, Math.PI * 2, true);
            ctx.fill();
        },
        predict: function() {
            const canvas = document.getElementById('draw_canvas');
            const ctx = canvas.getContext('2d');
            // RGBA32
            const img = ctx.getImageData(0,0,28,28).data;
            const length = img.length / 4;
            // とりあえず面倒なので加重平均とかはしない
            const src = [];
            for(let i = 0 ; i < length ; ++i) {
                const ptr = i * 4;
                src.push(Math.floor((img[ptr] + img[ptr + 1] + img[ptr + 2]) / 3.0));
            }
            // flaskで作った推論機に投げる
            callback = this.update_message; // then内でthis参照させるのがかったるい
            fetch('/predict', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({'src': src }),
            }).then(function(res) {
                return res.json();
            }).then(function(data) {
                // predict[1][20], elapsed[sec]が帰ってくるので適当に表示する
                const predict = data.predict[0];
                let index = 0;
                for(let i = 0 ; i < predict.length ; ++i) {
                    if (predict[index] < predict[i]) {
                        index = i;
                    }
                }
                callback(`たぶん${index}だと思う。(${Math.floor(predict[index] * 100)}% ${data.elapsed}[sec])`);
            });
            // 確認用
            // this.debug_print(src, null);
        },
        debug_print: function(src, predict) {
            if (this.is_debug) {
                let debug = "";
                for(let j = 0 ; j < 28 ; ++j) {
                    for(let i = 0 ; i < 28 ; ++i) {
                        debug += `  ${src[j * 28 + i].toString(16)} `.slice(-3);
                    }
                    debug += '\r\n';
                }
                console.log(debug);
                console.log(predict);
            }
        }
    },
});

※面倒なのでhttpリクエストにfetchを使っていますが、vue公式としてはaxiosを推奨しています。

f:id:logiclover:20180710222604p:plain

作成したKeras+FlaskアプリケーションをDockerコンテナにまとめる

システムのポータビリティを考え、コンテナで実行できるようにします

特にPythonのパッケージバージョン管理はいろいろと難があるので

pipのインストールやregistryなどにアップすることを考えてDockerfileからビルドします。

まず現在動作している環境のライブラリをrequirements.txtに出力します

$ pip freeze > requirements.txt

以下のようにパッケージ一覧が得られます。乱雑する場合は最小限動作する仮想環境を作り直してから同作業を行います。

absl-py==0.2.2
astor==0.7.1
bleach==1.5.0
certifi==2018.4.16
click==6.7
Flask==1.0.2
gast==0.2.0
grpcio==1.13.0
h5py==2.8.0
html5lib==0.9999999
itsdangerous==0.24
Jinja2==2.10
Markdown==2.6.11
MarkupSafe==1.0
numpy==1.14.5
protobuf==3.6.0
six==1.11.0
tensorflow==1.8.0
termcolor==1.1.0
Werkzeug==0.14.1
wincertstore==0.2

あとは、Dockerfileで公式のPythonパッケージを引っ張ってきてファイルのコピーとライブラリの追加を行います。

FROM python:3.5

# file copy
COPY . /app
WORKDIR /app

# lib install
RUN pip3 install --upgrade -r requirements.txt 

# run flask server
EXPOSE 3000

CMD ["python", "mnist-server.py"]

EXPOSE 3000はポート開放なので忘れずに入れておきます。

最後にコンテナをビルドします

$ docker build -t <image-name> .

実行したいときはポート開放と合わせて以下のコマンドを実行します

$ docker run -p 3000:3000 -it <image-name>

pythonで実行したときと同様に動けば成功です。

毎回ビルドと実行が面倒なのでdocker-compose.ymlファイルも用意しておきます

mnist-server:
    build: ./
    ports:
      - 3000:3000

これによってビルドと起動を$ docker-compose upでできるようになります。

まとめ

今回は手書き文字認識をベースに、ポータビリティに優れたWebアプリ作成例を示しました。

環境構築の手間がなくなるだけかなり便利なのでぜひ試してください。

今回作成したアプリをgithubに上げておきます。

github.com

中華カメラとモニタで来客確認用ドアカメラを作る

こんにちは、今日はFPGAとは無関係ですが、Amazonで売っている中華バックカメラとモニターでドアスコープを作ってみたいと思います。

私の住んでいるアパートには来客確認用のカメラがついていないため、ドアスコープでのぞく必要があります。
宅配ならいいのですが、不要な勧誘訪問なども多く、すぐに外を確認できたらいいなあと思っていました。

ふとAmazonで車に取り付ける用のバックカメラが1000円程度で売っていることに気が付きました。
こちらです。
https://www.amazon.co.jp/dp/B0757GNZ5F/ref=cm_sw_r_tw_dp_U_x_UtsGAbMNFX3DM

f:id:logiclover:20180212143316j:plain
サイズは2cmほどです。とても小さいですね。
(画像は後述する改造を施した後です。)

今回これと組み合わせるモニターはこちらです。
https://www.amazon.co.jp/dp/B06Y16WSWM/ref=cm_sw_r_tw_dp_U_x_YDsGAbCPJDCW9
これも1000円程度で購入できます。

これらを使ってドアスコープを作ってみたいと思います。
月並みですが、本記事の内容の実施は自己責任でお願いします。

この2つを直接接続して動作を確認します。

車用なので、ACアダプタはついていません。
その辺に転がっている12Vのアダプタを接続し動作チェックします。
ここで注意が必要なのが、センターの極性です。
センタープラスが主流ですが、一部センターマイナスのものもあるため、付属のケーブルにテスターを当て確認します。
f:id:logiclover:20180212143756j:plain
このようなケーブルが付属しています。
テスターで測ったところ、センターはプラスでした。

f:id:logiclover:20180212144652j:plain
まずは普通に接続し、動作チェックです。
問題ありません。
ここで注意が必要なのは、今回購入したカメラはPAL方式です。
購入したモニタはNTSC/PAL両対応なので問題ありませんが、別のモニタを使用する場合は映像信号フォーマットに注意が必要です。

そしてこのカメラをドアの外に出してみたいと思います。
が、しかしケーブルが太すぎてドアが閉まりません。。
写真を撮るのを忘れましたが、付属のケーブルは3mm程度あります。

どうしたものかと考えていたら、信号が通ればいいのだからケーブルを変えてしまえば?と気づきました。

f:id:logiclover:20180212145011j:plain
とりあえず分解してみた

信号線は全部で5本出ていますが、必要な信号の本数は電源、GND、コンポジット信号の3本のはずです。
コンポジット信号のGNDを別の線で返していたとしても1本余ります。
そして、このケーブル、途中で緑と白の線が飛び出しています。

f:id:logiclover:20180212145201j:plain
何かの切り替え線であることを信じて、とりあえず切断しました。
コネクタ側をチェックすると、緑と白の線は、GNDと導通していました。
処理としては、カメラ側で緑と白をGNDに落としてしまえば3本しか必要ないことになります。

f:id:logiclover:20180212145342j:plain
切った状態で動かしてみたところ、画面が反転してバックガイドが表示されました。
それぞれ、バックガイドONと反転ONの信号のようです。
f:id:logiclover:20180212145451j:plain
今回の用途では不要なのでカメラ側でGNDに落としました。
これでカメラからは3本のケーブルのみ出してあげればOKです。

f:id:logiclover:20180212145552j:plain
3本ということで、3.5mmジャックを使用しようと思います。
100均で適当なイヤホンを買ってきて、ケーブルを切断します。

f:id:logiclover:20180212145646j:plain
このように加工しました。
3.5mmコネクタは差し込み途中で別の極に触れるため、チップを+12Vにしました。
念のため通電中の3.5mm抜き差しはしないことにします。
カメラのキャップを先に通すのをお忘れなく
f:id:logiclover:20180212145849j:plain
これでカメラのケーブルが細くなりました。
これならドアの隙間にも入りそうです。

f:id:logiclover:20180212145831j:plain
受けのケーブル側も加工をします。
3.5mmジャックを付けます。
(スリーブを通し忘れてやり直しました。。。)

f:id:logiclover:20180212150012j:plain
動作チェックです。問題ありません。

これで準備はすべて整いました。
ドアへのマウントですが、薄いアクリル板を使用しました。
f:id:logiclover:20180212150108j:plain
大丈夫そうです。

f:id:logiclover:20180212150136j:plain
これをドアの寸法に合わせて曲げてやります。
薄いので手で曲げることができます。

動作確認をしてみます。
f:id:logiclover:20180212151010p:plain
試しに外に出て立ってみました。夜でもばっちりですね。

これで居間にいながら来客を確認することができるようになりました!

Microblaze MCSをSpartan6で動かす(ISE)

FPGAを使っていると、ソフトウェアCPUを組み込みたくなることがあります。
CPUを組み込むことで複雑な分岐の処理などを楽に作ることができます。

今回はSpartan-6 FPGAMicroblaze MCSを実装する方法を説明します。
前提条件としてFPGAでLチカができる程度の基礎知識はあるものとします。

まずISEを開き、プロジェクトを作ったら、新規IPをプロジェクトに追加します。
f:id:logiclover:20170823232641p:plain

IPの選択でMicroblaze MCSのコアを選択します。
f:id:logiclover:20170823232650p:plain

IPが作成出来たらIPの設定画面が開きます。
ここではMicroblazeの設定をします。RAM容量や使用するペリフェラルなどを選択することができます。
注意する点としては、インスタンスネームの指定では実際にインスタンシエートするモジュールのパスを入力する点です。
例えば、topの下にcpu0という名前でmicroblazeインスタンス化する場合は、Instance Hierarcical Design Nameの欄にcpu0と入力します。
f:id:logiclover:20170823232654p:plain

次にペリフェラルの設定をします。
今回はLED点灯を試すため、GPOを使用する設定にします。
GPO1に4bitの出力ポートを作っておきます。
f:id:logiclover:20170823232429p:plain

OKを押してIPの設定を終了します。
次にMicroblazeインスタンス化します。
topモジュールを適当に作り、その下にMicroblazeインスタンス化します。
メニューのView Instantiation Templateからテンプレートを取得できます。
f:id:logiclover:20170823232519p:plain

topに図のようなコードを追加しました。

module top(
    input Clk,
    input Reset,
    output [3:0] GPO1
 );

    mb_mcs cpu0 (
	.Clk(Clk), // input Clk
	.Reset(Reset), // input Reset
	.GPO1(GPO1) // output [3 : 0] GPO1
    );

endmodule

ここまで来たら一度シンセサイズを行い問題なく通ることを確認します。

次に、Implement Designを正しく行うため、tclスクリプトを実行します。
TCLコンソールを使いますが、コンソールウインドウが初期設定では表示されていないので、図のようにView-Panelsから設定して表示させます。
f:id:logiclover:20170823232549p:plain

次にtclコンソールで下記のコマンドを実行します。

source microblaze_mcs_setup.tcl

なお、このtclスクリプトはデフォルトではipcore_dirディレクトリの中にあります。
f:id:logiclover:20170823232558p:plain

ここまでやったら通常と同様にbitファイル作成まで行います。
これでハードウェアの生成は完了です。

SDKでソフトウェアを作成する

次にソフトウェアを作成します。
tclコンソールでxsdkと入力しSDKを起動します。

BSPの作成

起動したらまずはBSPを作成します。
メニューからCreate BSP Projectを選択します。

ipcore_dirの中にハードウェア情報が入ったxmlファイルがあるため、hardware specificationに指定します。
BMMファイルも同じフォルダに入っているため、同様に指定します。
f:id:logiclover:20170823232604p:plain

OSはスタンドアロンを選択します。
f:id:logiclover:20170823232610p:plain

ここまでの手順でボードサポート部までは完成です。

アプリケーションプロジェクトの作成

次にアプリケーションプロジェクトを作成します。
基本デフォルトで良いですが、以前作ったBSPを選択するようにします。
f:id:logiclover:20170823232616p:plain

コーディングする

実際にLチカをさせるプログラムを記載していきます。
microblaze上のペリフェラルはすべてXIOModuleというモジュール経由で操作するようになっています。
下記のようにXIOModuleのドライバ経由でGPOを制御しました。
waitのカウンタは動作周波数に応じて適宜調整してください。

#include "platform.h"
#include "xparameters.h"
#include "xiomodule.h"

void wait(void)
{
    volatile int cnt = 100000;
    while(cnt--);
}

int main()
{
    XIOModule xio;
    init_platform();

    XIOModule_Initialize(&xio, XPAR_IOMODULE_0_DEVICE_ID);

    while(1){
    	XIOModule_DiscreteWrite(&xio, 1, 0xF);
    	wait();
    	XIOModule_DiscreteWrite(&xio, 1, 0);
    	wait();
    }

    return 0;
}

デバッグする

microblazeは通常の組み込みCPUと同様、デバッグを行うことができます。

デバッグをする前に、このままではFPGAがコンフィグされていないため、まずはFPGAをコンフィグします。
iMPACTでコンフィグしてもいいですが、SDKでやる場合はProgram FPGAメニューを使用します。
ただしソフトウェアのバイナリができていないと実行できないため、何かしらのアプリケーションプロジェクトをビルドしておく必要があります。
f:id:logiclover:20170823234340p:plain

書き込みができたらデバッグが可能となります。
最初にデバッグを押すとメニューが出るためGDBデバッグを選択します。
f:id:logiclover:20170823232625p:plain

あとは通常のEclipseデバッグと同様に使用できます。

SPI Flashの使い方 (2) - Arduinoから読み書き

組み込みでデータを保持したいときに使用するSPI Flashを実際にArduinoから制御してみます。

偶然手元にあった構成で、Arduino MicroとCypress製のSPI FlashであるS25FL032*1を使用します。

この記事は以下の記事の続きとなるので、部品の機能やコマンドについての詳細はこちらをご参照願います。

logiclover.hatenablog.jp

結線方法

基本的には前記事を参照していただければピン機能はわかると思います。

ArduinoなどでI/O電圧が5Vである場合、レベル変換が必要になります。

Arduino SPI Flash 備考
D10 CS# digitalWrite可能なピンであればどこでも良い
SCK SCK クロック
MOSI IO0/SI データ入力
MISO IO1/SO データ出力
- IO2/W# QSPIは使わず、W#も使わないためH固定
- IO3/HOLD# QSPIは使わず、HOLD#も使わないためH固定

まずはSPI転送とCS制御を書く

コマンドを幾つか実装する上で、まずはGPIOの制御とSPI自体の制御が必要になります。

Arduino以外のプラットフォームでも動作できるよう、ある程度汎用性をもたせるように設計します。

SPIはSPI.hを利用して、GPIOはpinMode, digitalWriteで実装します。

Serialでの表示はデバッグ用に残しているもので、不要であれば削除します。

#include <SPI.h>

#define FLASH_CS 10

void spi_init(){
  SPI.begin();
  pinMode(FLASH_CS, OUTPUT);
  spi_set_cs(0x1);
}
void spi_set_cs(uint8_t is_high) {
  Serial.print("#spi_set_cs is_high= ");
  Serial.print(is_high, DEC);
  digitalWrite(FLASH_CS, is_high & 0x1);
  Serial.print(" ... Done\n");
}
void spi_transfer(const uint8_t* tx_buf, uint8_t* rx_buf, uint16_t length){
  Serial.print("#spi_transfer length= ");
  Serial.print(length, DEC);
  for(uint16_t i = 0 ; i < length ; ++i){
    Serial.print(".");
    uint8_t data = SPI.transfer(tx_buf != NULL ? tx_buf[i] : 0x0);
    if(rx_buf != NULL){
      rx_buf[i] = data;
    }
  }
  Serial.print(" Done\n");
}

void spi_transfer(const uint8_t* tx_buf, uint8_t* rx_buf, uint16_t length) はlengthで指定した分のSPI転送を行います。

その際にtx_bufが指定されていなければ0x0のダミーデータを、rx_bufが指定されていたときのみ受信データを格納するように工夫します。

Flash制御の共通的な制御を書く

どのコマンドにも共通して、コマンドを送る、アドレスを送る、ダミーバイトを送る、データを送る、データを読み取る動作が存在します。

毎回spi_transferを呼び出してもいいですが、面倒なのと可読性を考慮して一段ラップします。呼び出しコストが気になるようであればコンパイル時にインライン展開されるような修飾を施します。

void flash_send_cmd(uint8_t cmd){
  spi_transfer(&cmd, NULL, 1);
}
void flash_send_addr(uint32_t addr){
  const uint8_t data[] = {
    (addr >> 16) & 0xff,
    (addr >>  8) & 0xff,
    (addr >>  0) & 0xff,
  };
  spi_transfer(data, NULL, 3);
}
void flash_send_dummy(uint16_t length){
  spi_transfer(NULL, NULL, length);
}
void flash_read_data(uint8_t* rx_buf, uint16_t length){
  spi_transfer(NULL, rx_buf, length);
}
void flash_write_data(const uint8_t* tx_buf, uint16_t length){
  spi_transfer(tx_buf, NULL, length);
}

基本的なコマンドを実装する

いよいよコマンドの実装です。データシートに記載された全種類を実装してもいいですが、冗長なので基本的な命令を一通り実装します。

メーカー間で差があるといえど全く異なるわけではないので、コマンドのニーモニックやダミーバイト数などを調節すれば他のデバイスも制御できます。

新しいコマンドを追加したい場合も既存の記述を参照すれば簡単に追加可能です。もっと高機能にしたくなりますが、高級な実装は後回しです。

//Flashのコマンドを発行(S25FL032)
#define CMD_RDID       (0x9f)
#define CMD_READ       (0x03)
#define CMD_WREN       (0x06)
#define CMD_WRDI       (0x04)
#define CMD_P4E        (0x20)
#define CMD_P8E        (0x40)
#define CMD_BE         (0x60)
#define CMD_PP         (0x02)
#define CMD_RDSR       (0x05)

void flash_rdid(uint8_t* rx_buf, uint16_t length){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_RDID);
  flash_read_data(rx_buf, length);
  spi_set_cs(0x1);
}
void flash_read(uint32_t addr, uint8_t* rx_buf, uint16_t length){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_READ);
  flash_send_addr(addr);
  flash_read_data(rx_buf, length);
  spi_set_cs(0x1);
}
void flash_wren(){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_WREN);
  spi_set_cs(0x1);
}
void flash_wrdi(){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_WRDI);
  spi_set_cs(0x1);
}
void flash_p4e(uint32_t addr){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_P4E);
  flash_send_addr(addr);
  spi_set_cs(0x1);  
}
void flash_be(){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_BE);
  spi_set_cs(0x1);  
}
void flash_pp(uint32_t addr, const uint8_t* wr_data, uint16_t length){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_PP);
  flash_send_addr(addr);
  flash_write_data(wr_data, length);
  spi_set_cs(0x1);    
}
void flash_rdsr(uint8_t* rx_buf, uint16_t length){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_RDSR);
  flash_read_data(rx_buf, length);
  spi_set_cs(0x1);
}

その他便利関数

EraseやProgramには完了を確認するためにステータスレジスタのWIP(Write In Progress)を監視する必要があるので、確認関数を追加します。

また、データのデバッグを楽にするためのデバッグプリントも実装しておきます。

//(WIP)Write In Progressかどうか確認
uint8_t flash_is_wip(){
  uint8_t data;
  flash_rdsr(&data, 0x1);
  return (data & 0x1);//WIP
}
//配列を表示
void data_debug_print(const uint8_t* data, uint16_t bytes){
  Serial.print("+0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F\n");
  Serial.print("-----------------------------------------------\n");
  uint16_t depth = ((bytes - 1) >> 4) + 1;
  for(uint16_t j = 0 ; j < depth ; ++j){
    for(uint16_t i = 0 ; i < 16 ; ++i){
      uint8_t d = data[i + (j << 4)];
      Serial.print((d >> 4) & 0x0f, HEX);
      Serial.print((d >> 0) & 0x0f, HEX);
      Serial.print(" ");
    }
    Serial.print("\n");
  }
}

まずは動作確認

まずは確実に定値が読み出せるコマンドで、デバイスと正しく疎通できているか確認します。うまくいかない場合はコマンドか結線を疑います。オシロがあると楽です。

今回の場合、Read Identification(RDID)を使えば{Manufacturer Id, Device Id, Extended Bytes}の固定値が読み出せそうなのでこれを使います。

期待値は{0x01, 0x02, 0x15, 0x4d}です。

検証コード

void setup() {
  uint8_t rx_buf[256];

  spi_init();
  Serial.begin(9600);  
  while (!Serial) {}

  Serial.print("RDID\n");
  flash_rdid(rx_buf, 4);
  data_debug_print(rx_buf, 4);
}

結果

RDID
#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_transfer length= 4.... Done
#spi_set_cs is_high= 1 ... Done
+0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
-----------------------------------------------
01 02 15 4D 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 

CSをLowにした後1byte(コマンド) -> 4byte(RDIDのデータ)の順にspi_transferが実行されています。

読み出した結果も先頭に注目すると期待通りの結果が読み出せています。(残りはゴミです)

直接関係ないのですが、USBを内蔵したATMELマイコンをベースにしたArduino特有の現象でSerial.begin()の後に初期化が終わるまで待たなければならないようです。

これはSerialの読み書きを、外部ではなく内部のUSB CDCを利用していることに起因しているようです。*2

特定の領域でRead, Erase, Program, Verifyをしてみる

バイスとの疎通は出来ているようなので、以下のシーケンスで任意のデータを書き込んでみます。

  • 現在のデータを読み出し
  • セクタイレースを実行 (全体削除のBulk Eraseは検証には時間がかかりすぎるため)
  • 適当な値を書き込み(シーケンシャルなデータを書き込む)
  • データを読み出して書き込んだ値と相違ないか確認

注意すべき点として、保持されているデータを変化させる命令にはWriteEnableを事前実行する必要があることです。

ちなみにステータスレジスタあたりから確認できます。またWriteDisable命令もあります。

検証コード

void setup() {
  uint8_t tx_buf[256];
  uint8_t rx_buf[256];
  uint32_t addr = 0x0;
  uint16_t length = 0x100;

  spi_init();
  Serial.begin(9600);  
  while (!Serial) {}

  Serial.print("RDID\n");
  flash_rdid(rx_buf, 4);
  data_debug_print(rx_buf, 4);

  Serial.print("Read\n");
  flash_read(addr, rx_buf, length);
  data_debug_print(rx_buf, length);

  Serial.print("Read after P4E\n");
  flash_wren();//P4Eの前に必要
  flash_p4e(addr);
  while(flash_is_wip()){//消去待ち
    Serial.print(".");
  }
  Serial.print("\n");
  flash_read(addr, rx_buf, length);
  data_debug_print(rx_buf, length);

  Serial.print("PP Sequential Data\n");
  for(uint16_t i = 0 ; i < length ; ++i){
    tx_buf[i] = i & 0xff;
  }
  flash_wren();
  flash_pp(addr, tx_buf, length);
  while(flash_is_wip()){//書き込み完了待ち
    Serial.print(".");
  }
  Serial.print("\n");

  Serial.print("Read after PP\n");  
  flash_read(addr, rx_buf, length);
  data_debug_print(rx_buf, length);
  
}

結果

一部見やすいように加工しています

RDID
#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_transfer length= 4.... Done
#spi_set_cs is_high= 1 ... Done
+0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
-----------------------------------------------
01 02 15 4D 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 
(RDIDの結果、正しく通信できている)

Read
#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_transfer length= 3... Done
#spi_transfer length= 256..(省略).. Done
#spi_set_cs is_high= 1 ... Done
+0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
-----------------------------------------------
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 
10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F 
20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F 
30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F 
40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F 
50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F 
60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 
70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F 
80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F 
90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F 
A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF 
B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF 
C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF 
D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF 
E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF 
F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF 
(同じコードを何度か実行しているので前のデータがそのまま見えている)

Read after P4E
#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_set_cs is_high= 1 ... Done
(ここまでがWrite Enableコマンド、データを改変するコマンドには事前にWRENする必要がある)

#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_transfer length= 3... Done
#spi_set_cs is_high= 1 ... Done
(ここがP4Eコマンド、指定したセクタのデータが全部1になる、コマンドの完了には時間がかかるためWIPを見て待つ)

(ここからWIP待ち)
#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_transfer length= 1. Done
#spi_set_cs is_high= 1 ... Done
(長いので省略...)


#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_transfer length= 3... Done
#spi_transfer length= 256..(省略).. Done
#spi_set_cs is_high= 1 ... Done
+0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
-----------------------------------------------
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 
(削除後のデータ、全セルの値が1なのでffで埋め尽くされている)

PP Sequential Data
(WREN)
#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_set_cs is_high= 1 ... Done

(PP)
#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_transfer length= 3... Done
#spi_transfer length= 256...(省略).. Done
#spi_set_cs is_high= 1 ... Done

(WIP待ち)
#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_transfer length= 1. Done
#spi_set_cs is_high= 1 ... Done



Read after PP
#spi_set_cs is_high= 0 ... Done
#spi_transfer length= 1. Done
#spi_transfer length= 3... Done
#spi_transfer length= 256..(省略).. Done
#spi_set_cs is_high= 1 ... Done
+0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
-----------------------------------------------
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 
10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F 
20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F 
30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F 
40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F 
50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F 
60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 
70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F 
80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F 
90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F 
A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF 
B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF 
C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF 
D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF 
E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF 
F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF 
(書き込み後のデータ、正しくインクリメンタルな値になった)

ログ中に注釈したとおり、Erase後にffになったデータがPPで書き込めていることが確認できます。

全面書き換えを行う

動作確認が出来たので、全面書き換えをしてみます。

Page Programはページごとに処理して、WIP待ちをしなければならないので0x100刻みに適当なデータを作って処理します。 ページをまたがなければ分ける必要はないので、今回は考慮していませんが0x3から100byteだけ書き換えたりなどは大丈夫です。

検証コード

  uint8_t tx_buf[256];
  uint8_t rx_buf[256];
  uint32_t length = 0x400000;

  /* 全面書き換え */
  flash_wren();

  Serial.print("Bulk Erase ...");
  flash_be();
  while(flash_is_wip()){
    //Serial.print(".");
  }
  Serial.print(" Done\n");

  //1Pageごと(256byteごと)処理をする
  for(uint32_t addr = 0x0 ; addr < length ; addr += 0x100){
    Serial.print("PP @");
    Serial.print(addr, HEX);
    //適当なデータを作る
    for(uint16_t i = 0 ; i < 0x100 ; ++i){
      tx_buf[i] = (i + (addr >> 8)) & 0xff;
    }
    flash_wren();
    flash_pp(addr, tx_buf, 0x100);
    while(flash_is_wip()){//書き込み完了待ち
      Serial.print(".");
    }
    Serial.print(" Done\n");    
  }

  //読み出して照合する
  bool is_all_match = 0x1;
  for(uint32_t addr = 0x0 ; addr < length ; addr += 0x100){
    Serial.print("Verify @");
    Serial.print(addr, HEX);
    flash_read(addr, rx_buf, 0x100);
    //結果を比較
    bool is_match = 0x1;
    for(uint16_t i = 0 ; i < 0x100 ; ++i){
      tx_buf[i] = (i + (addr >> 8)) & 0xff;
      if(tx_buf[i] != rx_buf[i]) {
        is_match = 0x0;
        is_all_match = 0x0;
        Serial.print(" Fail\n");    
        break;
      }
    }
    if(is_match){
      Serial.print(" Pass\n");    
    }
  }

  //先頭だけでも見ておく
  Serial.print("\n\n\n@0x000000\n");
  flash_read(0x0, rx_buf, 0x100);
  data_debug_print(rx_buf, 0x100);

  if(is_all_match){
    Serial.print("Pass: spi_flash_test2()\n");          
  } else {
    Serial.print("Fail: spi_flash_test2()\n");      
  }

結果

(めちゃくちゃ長いため省略)
Verify @3FF800 Pass
Verify @3FF900 Pass
Verify @3FFA00 Pass
Verify @3FFB00 Pass
Verify @3FFC00 Pass
Verify @3FFD00 Pass
Verify @3FFE00 Pass
Verify @3FFF00 Pass



@0x000000
+0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
-----------------------------------------------
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 
10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F 
20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F 
30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F 
40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F 
50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F 
60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 
70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F 
80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F 
90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F 
A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF 
B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF 
C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF 
D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF 
E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF 
F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF 
Pass: spi_flash_test2()

動画

どうやら全データ書き換えられているようです。

終わりに

コード全体は以下に示します。自己責任でお願い致します。

間違い等ございましたらご指摘いただけると幸いです。

#include <SPI.h>

#define FLASH_CS 10

/////////////////////////////////////////////////////////////////////////////
//デバイスによって書き換える
void spi_init(){
  SPI.begin();
  pinMode(FLASH_CS, OUTPUT);
  spi_set_cs(0x1);
}
void spi_set_cs(uint8_t is_high) {
  //Serial.print("#spi_set_cs is_high= ");
  //Serial.print(is_high, DEC);
  digitalWrite(FLASH_CS, is_high & 0x1);
  //Serial.print(" ... Done\n");
}
void spi_transfer(const uint8_t* tx_buf, uint8_t* rx_buf, uint16_t length){
  //Serial.print("#spi_transfer length= ");
  //Serial.print(length, DEC);
  for(uint16_t i = 0 ; i < length ; ++i){
    //Serial.print(".");
    uint8_t data = SPI.transfer(tx_buf != NULL ? tx_buf[i] : 0x0);
    if(rx_buf != NULL){
      rx_buf[i] = data;
    }
  }
  //Serial.print(" Done\n");
}
/////////////////////////////////////////////////////////////////////////////
//Flashの制御
void flash_send_cmd(uint8_t cmd){
  spi_transfer(&cmd, NULL, 1);
}
void flash_send_addr(uint32_t addr){
  const uint8_t data[] = {
    (addr >> 16) & 0xff,
    (addr >>  8) & 0xff,
    (addr >>  0) & 0xff,
  };
  spi_transfer(data, NULL, 3);
}
void flash_send_dummy(uint16_t length){
  spi_transfer(NULL, NULL, length);
}
void flash_read_data(uint8_t* rx_buf, uint16_t length){
  spi_transfer(NULL, rx_buf, length);
}
void flash_write_data(const uint8_t* tx_buf, uint16_t length){
  spi_transfer(tx_buf, NULL, length);
}
/////////////////////////////////////////////////////////////////////////////
//Flashのコマンドを発行(S25FL032)
#define CMD_RDID       (0x9f)
#define CMD_READ       (0x03)
#define CMD_WREN       (0x06)
#define CMD_WRDI       (0x04)
#define CMD_P4E        (0x20)
#define CMD_P8E        (0x40)
#define CMD_BE         (0x60)
#define CMD_PP         (0x02)
#define CMD_RDSR       (0x05)

void flash_rdid(uint8_t* rx_buf, uint16_t length){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_RDID);
  flash_read_data(rx_buf, length);
  spi_set_cs(0x1);
}
void flash_read(uint32_t addr, uint8_t* rx_buf, uint16_t length){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_READ);
  flash_send_addr(addr);
  flash_read_data(rx_buf, length);
  spi_set_cs(0x1);
}
void flash_wren(){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_WREN);
  spi_set_cs(0x1);
}
void flash_wrdi(){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_WRDI);
  spi_set_cs(0x1);
}
void flash_p4e(uint32_t addr){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_P4E);
  flash_send_addr(addr);
  spi_set_cs(0x1);  
}
void flash_be(){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_BE);
  spi_set_cs(0x1);  
}
void flash_pp(uint32_t addr, const uint8_t* wr_data, uint16_t length){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_PP);
  flash_send_addr(addr);
  flash_write_data(wr_data, length);
  spi_set_cs(0x1);    
}
void flash_rdsr(uint8_t* rx_buf, uint16_t length){
  spi_set_cs(0x0);
  flash_send_cmd(CMD_RDSR);
  flash_read_data(rx_buf, length);
  spi_set_cs(0x1);
}

/////////////////////////////////////////////////////////////////////////////
//その他
//(WIP)Write In Progressかどうか確認
uint8_t flash_is_wip(){
  uint8_t data;
  flash_rdsr(&data, 0x1);
  return (data & 0x1);//WIP
}
//配列を表示
void data_debug_print(const uint8_t* data, uint16_t bytes){
  Serial.print("+0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F\n");
  Serial.print("-----------------------------------------------\n");
  uint16_t depth = ((bytes - 1) >> 4) + 1;
  for(uint16_t j = 0 ; j < depth ; ++j){
    for(uint16_t i = 0 ; i < 16 ; ++i){
      uint8_t d = data[i + (j << 4)];
      Serial.print((d >> 4) & 0x0f, HEX);
      Serial.print((d >> 0) & 0x0f, HEX);
      Serial.print(" ");
    }
    Serial.print("\n");
  }
}
/////////////////////////////////////////////////////////////////////////////
//Arduinoの適当な制御コード
void spi_flash_test(){
  uint8_t tx_buf[256];
  uint8_t rx_buf[256];
  uint32_t addr = 0x0;
  uint16_t length = 0x100;

  Serial.print("RDID\n");
  flash_rdid(rx_buf, 4);
  data_debug_print(rx_buf, 4);

  Serial.print("Read\n");
  flash_read(addr, rx_buf, length);
  data_debug_print(rx_buf, length);

  Serial.print("Read after P4E\n");
  flash_wren();//P4Eの前に必要
  flash_p4e(addr);
  while(flash_is_wip()){//消去待ち
    Serial.print(".");
  }
  Serial.print("\n");
  flash_read(addr, rx_buf, length);
  data_debug_print(rx_buf, length);

  Serial.print("PP Sequential Data\n");
  for(uint16_t i = 0 ; i < length ; ++i){
    tx_buf[i] = i & 0xff;
  }
  flash_wren();
  flash_pp(addr, tx_buf, length);
  while(flash_is_wip()){//書き込み完了待ち
    Serial.print(".");
  }
  Serial.print("\n");

  Serial.print("Read after PP\n");  
  flash_read(addr, rx_buf, length);
  data_debug_print(rx_buf, length);
}

void spi_flash_test2(){
  uint8_t tx_buf[256];
  uint8_t rx_buf[256];
  uint32_t length = 0x400000;

  /* 全面書き換え */
  flash_wren();

  Serial.print("Bulk Erase ...");
  flash_be();
  while(flash_is_wip()){
    //Serial.print(".");
  }
  Serial.print(" Done\n");

  //1Pageごと(256byteごと)処理をする
  for(uint32_t addr = 0x0 ; addr < length ; addr += 0x100){
    Serial.print("PP @");
    Serial.print(addr, HEX);
    //適当なデータを作る
    for(uint16_t i = 0 ; i < 0x100 ; ++i){
      tx_buf[i] = (i + (addr >> 8)) & 0xff;
    }
    flash_wren();
    flash_pp(addr, tx_buf, 0x100);
    while(flash_is_wip()){//書き込み完了待ち
      Serial.print(".");
    }
    Serial.print(" Done\n");    
  }

  //読み出して照合する
  bool is_all_match = 0x1;
  for(uint32_t addr = 0x0 ; addr < length ; addr += 0x100){
    Serial.print("Verify @");
    Serial.print(addr, HEX);
    flash_read(addr, rx_buf, 0x100);
    //結果を比較
    bool is_match = 0x1;
    for(uint16_t i = 0 ; i < 0x100 ; ++i){
      tx_buf[i] = (i + (addr >> 8)) & 0xff;
      if(tx_buf[i] != rx_buf[i]) {
        is_match = 0x0;
        is_all_match = 0x0;
        Serial.print(" Fail\n");    
        break;
      }
    }
    if(is_match){
      Serial.print(" Pass\n");    
    }
  }

  //先頭だけでも見ておく
  Serial.print("\n\n\n@0x000000\n");
  flash_read(0x0, rx_buf, 0x100);
  data_debug_print(rx_buf, 0x100);

  if(is_all_match){
    Serial.print("Pass: spi_flash_test2()\n");          
  } else {
    Serial.print("Fail: spi_flash_test2()\n");      
  }
}

void setup() {
  spi_init();
  Serial.begin(9600);  
  while (!Serial) {}
  
  spi_flash_test2();
}

void loop() {
}

*1:2017年8月現在 新規設計非推奨となっていてS25FL-Lシリーズが推奨されています

*2:https://www.arduino.cc/en/Guide/ArduinoLeonardoMicro#toc6

CP2130でC#からUSB-SPIを実現する

FPGAマイコンでアプリケーションを作成しているときに悩ましい項目としてPCとの通信方法があります。

低速なものであればFT232に代表されるようなUSB-UARTでいいのですが、UARTはRTLと相性がよくありません。

項目 利点 欠点
USB-UART 手軽 遅い
USB-I2C I2C I/Fのデバイスを直接制御できる、マルチスレーブ対応 PC側でアービトレーションを実装する必要があり
USB-SPI SPI I/Fのデバイスを直接制御できる、ある程度速く出来る、実装が簡単

FPGAで実装するにはSPIを使うとかなり簡単にアクセスできそうです。今回はSilicon Laboratory社が販売しているUSB-SPI Bridge IC CP2130を使ってみます。このICは最大12MHzでUSB-SPIを実現することが出来ます。

jp.silabs.com

余談ですが、もっと高速なUSB通信を行う場合Cypress社のEZ-USB FXに代表されるような製品が使われます。

http://japan.cypress.com/products/ez-usb-fx3-superspeed-usb-30-peripheral-controller

評価ツールをダウンロード

評価ボードのダウンロード先がどういうわけかCP210xの別製品のものになっているので*1、サポートページからダウンロードします。

CP2130 Classic USB Bridge | Silicon Labs

上記サイト下側にある「CP2130 Software package for Windows」をダウンロードしてインストールします。Linuxの場合はfor Linuxを使用します。

サンプルアプリケーションを眺める

以後Windowsで解説します。インストールが完了すると “C:\SiliconLabs\MCU\CP2130_SDK\Software"にライブラリとサンプルアプリケーションが追加されています。

CP2130 Demo ApplicationがC#+WPFで記述されたソースコード付きのサンプルになっています。

細かな制御の方法や作例はこちらを眺めると良いでしょう。

動作させてみる

サンプルアプリケーションを読んでみると、SLAB_USB_SPI.csがDllImport経由でSLAB_USB_SPI.dllの関数を呼び出していることがわかります。

このままでは扱いづらいので転送だけ出来るWrapperを作成します。

CSをGPIO経由で手動で上げ下げすることも考慮してIsCSEnableとGPIO制御もつけておきます。

using SLAB_USB_SPI_DLL;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace LogiClover.Lib {
    class CP2130SPI : IDisposable {
        private IntPtr deviceHandle = IntPtr.Zero;
        public bool IsAvailable => deviceHandle != IntPtr.Zero;

        #region Static Method
        /// <summary>
        /// 現在接続されているデバイス数を返します
        /// </summary>
        /// <returns></returns>
        public static int GetNumDevices() {
            uint n = 0;
            SLAB_USB_SPI.CP213x_GetNumDevices(ref n);
            return (int)n;
        }
        /// <summary>
        /// デバイス名のリストを返します
        /// </summary>
        /// <returns></returns>
        public static IEnumerable<string> GetDeviceNames() {
            var n = GetNumDevices();
            for (uint i = 0; i < n; ++i) {
                var sb = new StringBuilder(SLAB_USB_SPI.MAX_PATH);
                SLAB_USB_SPI.CP213x_GetDevicePath(i, sb);
                yield return sb.ToString();
            }
        }
        #endregion

        #region Device Connection

        /// <summary>
        /// デバイスと接続します
        /// </summary>
        /// <param name="index"></param>
        /// <returns>正常に接続できた場合true</returns>
        public bool Open(int index) {
            if (index < 0) return false;
            this.Close();
            var result = SLAB_USB_SPI.CP213x_OpenByIndex((uint)index, ref deviceHandle);
            if (result == SLAB_USB_SPI.USB_SPI_ERRCODE_SUCCESS) {
                return true;
            } else {
                Debug.WriteLine($"#Exception Errorcode:{result}");
                Close();
                return false;
            }
        }

        /// <summary>
        /// デバイスを切断します
        /// </summary>
        public void Close() {
            if (IsAvailable) {
                SLAB_USB_SPI.CP213x_Close(deviceHandle);
                deviceHandle = IntPtr.Zero;
            }
        }

        #endregion

        #region Parameters
        public enum ClockRate {
            F12M = SLAB_USB_SPI.SPICTL_CLKRATE_12M,
            F6M = SLAB_USB_SPI.SPICTL_CLKRATE_6M,
            F3M = SLAB_USB_SPI.SPICTL_CLKRATE_3M,
            F1M5 = SLAB_USB_SPI.SPICTL_CLKRATE_1M5,
            F750K = SLAB_USB_SPI.SPICTL_CLKRATE_750K,
            F375K = SLAB_USB_SPI.SPICTL_CLKRATE_375K,
            F187K5 = SLAB_USB_SPI.SPICTL_CLKRATE_187K5,
            F93K75 = SLAB_USB_SPI.SPICTL_CLKRATE_93K75,
        };
        /// <summary>
        /// 転送周波数
        /// </summary>
        public ClockRate SCKRate { get; set; } = ClockRate.F12M;
        /// <summary>
        /// CS番号
        /// </summary>
        public int CSIndex { get; set; } = 0;
        /// <summary>
        /// CSを駆動するか
        /// </summary>
        public bool IsCSEnable { get; set; } = true;

        /// <summary>
        /// タイムアウト時間[ms]
        /// </summary>
        public uint TimeOutMS { get; set; } = 1000;

        #endregion

        public bool Configuration() {
            if (!IsAvailable) return false;
            if (IsCSEnable) {
                SLAB_USB_SPI.CP213x_SetChipSelect(deviceHandle, (byte)CSIndex, SLAB_USB_SPI.CSMODE_ACTIVE_OTHERS_IDLE);
            } else {
                //CS無効化
                SLAB_USB_SPI.CP213x_SetChipSelect(deviceHandle, (byte)CSIndex, SLAB_USB_SPI.CSMODE_IDLE);
            }

            return true;
        }

        /// <summary>
        /// SPI通信を行います
        /// </summary>
        /// <param name="srcData">転送したいデータ、受信だけ行う場合はダミーデータを渡す</param>
        /// <returns>受信したデータ、データ長はsrcDataと同じ</returns>
        public byte[] Transfer(params byte[] srcData) {
            if (!IsAvailable) return new byte[] { };
            uint bytesTransferred = 0;
            var dstData = new byte[srcData.Length];

            SLAB_USB_SPI.CP213x_TransferWriteRead(
                deviceHandle,
                srcData,
                dstData,
                (uint)srcData.Length,
                true,
                TimeOutMS,
                ref bytesTransferred);

            return dstData;
        }

        /// <summary>
        /// GPIOに値を書き込みます
        /// </summary>
        /// <param name="index">制御するGPIO番号</param>
        /// <param name="value"></param>
        public void WriteIO(int index, bool value) {
            if (IsAvailable) {
                SLAB_USB_SPI.CP213x_SetGpioModeAndLevel(deviceHandle, (byte)index, SLAB_USB_SPI.GPIO_MODE_OUTPUT_PP, (byte)(value ? 0x1 : 0x0));
            }
        }
        /// <summary>
        /// GPIOの値を読み込みます
        /// </summary>
        /// <param name="index"></param>
        /// <returns></returns>
        public bool? ReadIO(int index) {
            if (!IsAvailable) return null;
            byte mode = 0;
            byte level = 0;

            SLAB_USB_SPI.CP213x_GetGpioModeAndLevel(deviceHandle, (byte)index, ref mode, ref level);
            return level != 0x0;
        }

        public void Dispose() {
            this.Close();
        }
    }
}


あとはこのクラスの利用例ですが

ユーザーにデバイスを選ばせる

コンボボックスなどで表示しておくとわかりやすいかもしれません。

var numofDevice = CP2130SPI.GetNumDevices();//デバイス数を取得
var deviceNames = CP2130SPI.GetDeviceNames();//デバイス名一覧を取得

バイスと通信を行う

開いて保持したままにしてもいいのですが、常時SPIを送り続けるアプリケーションではないので今回はお行儀良く送ったらCloseする制御にします。

//送る
int index = 0;//通信に使うデバイスインデックス
using (var spiBridge = new CP2130SPI()) {
    try {
        if (!spiBridge.Open(index)) {
            //デバイスオープンに失敗
            return;
        }
    } catch (Exception ex) {
        //ほかの例外
        return;
    }

    //SPIの設定
    spiBridge.CSIndex = 0x0;//CSとして駆動させるピン番号
    spiBridge.IsCSEnable = true;//CSは転送時に自動駆動させる
    spiBridge.SCKRate = CP2130SPI.ClockRate.F375K;//転送周波数
    spiBridge.TimeOutMS = 1000;//タイムアウト時間
    spiBridge.Configuration();

    //適当なデータを作る
    var sendData = Enumerable.Range(0, 0xff).ToArray();
    //データ転送
    var recvData = spiBridge.Transfer(sendData);//byte[]が返ってくる
}

動作させる

SLAB_USB_SPI.dllが実行ディレクトリに無いとDllImportに失敗するのでプロジェクトに入れて、プロパティを出力ディレクトリに新しい場合はコピーするようにします。

スクリーンショットはComet*2に使われているエフェクトの検証ツールで使用している例です。

f:id:logiclover:20170612013548p:plain

終わりに

これでc#から直接SPIデバイスを制御できるようになりました!最高ですね!

SPI FlashやSPI DAC/ADCなどを繋いでも良いですし、FPGAの自作ペリフェラル検証に使用してもいいと思います。