Raspberry Pi PicoとTinyGoでSDカードを使う (FatFs)

2022年4月30日土曜日

Raspberry Pi Pico TinyGo

t f B! P L

マイコンで使えるGo言語であるTinyGoについて調べてみたら、実際にある程度の機能のプロジェクトでも動かせそうな感じでしたので、以前、C++でコーディングしたRaspberry Pi Picoで向けのFatFsのベンチマークプロジェクトをTinyGoに移植して動かしてみました。

TinyGoでFatFs動作確認

TinyGo開発環境の準備

TinyGo開発環境に関しては、直接ローカルにインストールする方法もありますが、ローカルの環境を汚さずに手っ取り早く動く環境を取得するには、Docker環境を使用するのがおすすめです。 以降、Docker Desktop WSL2環境を前提に話を進めます。 まずは、Dockerを起動する前に、今回のプロジェクトをGitHubからクローンするため、Git Bashシェルなどから以下を実行します。

$ cd /d/somewhere/share
$ git clone -b main https://github.com/elehobica/pico_tinygo_fatfs_test.git

次にDockerイメージの取得、実行と進みます。この説明ではDockerでの実行ディレクトリは共有ディレクトリ上で行っています。よりビルドを高速化したい場合はDockerコンテナ内のファイルシステム側にデータをコピーして行うと良いですが、今回のプロジェクト程度の規模ではWindowsファイルシステム上でビルドを行ってもほとんど問題にならないレベルと思います。

> wsl
(以下 WSL2 シェルにて)
$ docker pull tinygo/tinygo
$ docker images
$ docker run -it -v /mnt/d/somewhere/share:/share tinygo/tinygo:latest /bin/bash
(以下 Dockerコンテナにて)
# cd /share
# cd pico_tinygo_fatfs_test

プロジェクトのビルド

Goモジュール設定

ビルドの前に、Goモジュールの設定を行っておきます。 プロジェクトのルートとなるディレクトリ(pico_tinygo_fatfs_test)の設定を行ったあと、 go mod tidyを実行すれば残りの部分は自動的に設定されます。

# go mod init pico_tinygo_fatfs_test
# go mod tidy

モジュール設定出来た状態でのgo.modの内容は以下のようになっているはずです。

module pico_tinygo_fatfs_test

go 1.17

require tinygo.org/x/tinyfs v0.2.0

ビルド

あとは、ターゲットを指定してtinygo buildを行えば、バイナリファイルが生成されます。

# tinygo build -target=pico -o pico_tinygo_fatfs_test.uf2

詳細なサイズ情報を表示させるには以下のようにビルドすると良いでしょう。

# tinygo build -target=pico -o pico_tinygo_fatfs_test.uf2 -size full

生成されたUF2ファイルをWindows環境に戻って、RPI-RP2ドライブ直下にコピーすれば、OKです。

配線図 / ピン設定

配線図は以下の通りです。

配線図

コードの説明

サンプルコードはここに置いてあるものを使用します。

pico.go

TinyGoではターゲットに関する設定はmachineパッケージにまとめられており、Raspbery Pi Picoの基本設定はこちらにまとめられています。この情報を利用して初期設定をおこなっているのが、pico.goです。冒頭の2行はtinygo build時に指定したtargetと合致する場合にのみビルド対象となるように指定するためのGo Compiler Directiveです。1行目がgo 1.17以降の書式、2行目がgo 1.16以前の書式に対応するようです。init()関数はこの場合mainパッケージ内に定義されているので、main()より先に実行されmain.goで定義されている変数の初期化を行っています。

//go:build pico
// +build pico

package main

import (
	"machine"
)

func init() {
	spi = *machine.SPI0
	sckPin = machine.GP2
	sdoPin = machine.GP3
	sdiPin = machine.GP4
	csPin = machine.GP5

	ledPin = machine.LED
}

machine.Pinインターフェイス追加

main.goの最初のほうでLEDピン関連に対する操作を追加しています。

type Pin struct {
	*machine.Pin
}

func (pin Pin) Toggle() {
	pin.Set(!pin.Get())
}

func (pin Pin) ErrorBlinkFor(count int) {
	for {
		for i := 0; i < count; i++ {
			pin.High()
			time.Sleep(250 * time.Millisecond)
			pin.Low()
			time.Sleep(250 * time.Millisecond)
		}
		pin.Low()
		time.Sleep(500 * time.Millisecond)
	}
}

func (pin Pin) OkBlinkFor() {
	for {
		pin.High()
		time.Sleep(1000 * time.Millisecond)
		pin.Low()
		time.Sleep(1000 * time.Millisecond)
	}
}

手順としては既存インターフェイスを取り込んだ新規のTypeであるPinを定義して、そのTypeをレシーバとしたメソッドを定義していく流れになります。このようにすれば、既存のインターフェイスと新規に定義したメソッドを同様に新規のTypeに対して扱うことができ、継承のような効果を得ることができます。

FatFsライブラリ

フリーのCライブラリであるFatFsをcgo機能を利用してGoから呼び出す形で使用します。TinyGoのリポジトリにあるFatFsに対してfatfs_testと同等の機能を実現するためにカスタマイズして使用します。なお、TinyGoリポジトリのFatFsのCライブラリ自体は、ooFatというFatFsをオブジェクト指向化したものを導入しています。

今回のプロジェクトの機能追加は、go_fatfs.goに以下のGo側のメソッドを追加する形で行いました。

func (l *FATFS) GetFsType() (Type, error)
func (l *FATFS) GetCardSize() (int64, error)
func (f *File) Seek(offset int64) error
func (f *File) Tell() (ret int64, err error)
func (f *File) Rewind() (err error)
func (f *File) Truncate() error
func (f *File) Expand(size int64, flag bool) error

GoからCへの受け渡し

GoからCへの渡し方の部分をExpand()メソッドを例にとって説明します。

// #include <string.h>
// #include <stdlib.h>
// #include "./go_fatfs.h"
import "C"

...

func (f *File) Expand(size int64, flag bool) error {
	var fsz C.FSIZE_t = C.FSIZE_t(size)
	var opt C.BYTE;
	if flag { opt = 1 } else { opt = 0 }
	return errval(C.f_expand(f.fileptr(), fsz, opt))
}

まず、cgoを使用する場合はインクルードするCヘッダをGo Compiler Directiveで指定して、空行を入れずにimport "C"を記述する必要があります。このようにすることで、C側で定義された関数をC.f_expand()のように直接呼び出すことが出来ます。ただし、C関数に与える引数もC側の型に合わせて変換する必要があり、ここで値のコピーが発生するので、C関数をGo対応する際にはオーバーヘッドが発生します。

Cの構造体メンバへのアクセス

Cの構造体メンバへのアクセスについてはソースコードの以下の部分を抜粋して説明します。

fatfs/go_fatfs.go

...

type FATFS struct {
	dev tinyfs.BlockDevice
	fs  *C.FATFS
}

...

func (l *FATFS) Configure(config *Config) *FATFS {
	l.fs = C.go_fatfs_new_fatfs()
	l.fs.drv = gopointer.Save(l)
	return l
}

...

func (l *FATFS) GetCardSize() int64 {
	return int64(l.fs.csize) * int64(l.fs.n_fatent) * SectorSize
}

fatfs/go_fatfs.c

FATFS* go_fatfs_new_fatfs(void) {
    return malloc(sizeof(FATFS));
}

まず、Go側の構造体 FATFS のメンバーとしてfsを、ff.hで定義されているFATFS構造体、つまりC.FATFS型としてTinyGo側で宣言します。 (ここではTinyGo側のFATFS構造体と、C側のFATFS構造体は別のものであることに注意してください。) このメンバーfsに対してはC側で定義したgo_fatfs_new_fatfs()関数により、Configureコール時に構造体へのポインタが代入されます。 この状態において、TinyGo側では、Cの構造体メンバーに対しては、C側で行うのと同様に、FATFSインスタンス.fs.メンバー名 でアクセスすることができます。 具体的には、GetCardSize()メソッドにおける、l.fs.csize, l.fs.n_fatentの部分がその例になります。

Type Assertion

GoならではのType Assertionが行われている部分について説明します。

fatfs/go_fatfs.go

...
type File struct {
	fs   *FATFS
	typ  uint8
	hndl unsafe.Pointer
	name string
}
...
func (l *FATFS) OpenFile(path string, flags int) (tinyfs.File, error) {
	...
	var file = &File{fs: l, name: path}
	...
	return file, nil
}

tinyfs/tinyfs.go

type File interface {
	FileHandle
	IsDir() bool
	Readdir(n int) (infos []os.FileInfo, err error)
}

// FileHandle is a copy of the experimental os.FileHandle interface in TinyGo
type FileHandle interface {
	// Read reads up to len(b) bytes from the file.
	Read(b []byte) (n int, err error)

	// Write writes up to len(b) bytes to the file.
	Write(b []byte) (n int, err error)

	// Close closes the file, making it unusable for further writes.
	Close() (err error)
}

main.go

	...
	f, err := filesystem.OpenFile("bench.dat", os.O_RDWR | os.O_CREATE | os.O_TRUNC)
	...
	ff, ok := f.(*fatfs.File)
	...
 	err = ff.Seek(0)
	...

go_fatfs.goのOpenFile()メソッド内では、File type (構造体)を生成してひとつ目の戻り値として返しますが、一方でOpenFile()メソッドの戻り値の定義部分には、File typeではなく、tinyfs.Fileが指定されています。tinyfs.Fileはtinyfs/tinyfs.goを参照するとtypeではなくインターフェイスとなっています。つまり、OpenFile()メソッドのリターン時に、File typeをtinyfs.Fileインターフェイスに代入したものを返していることになります。したがって、OpenFile()呼び出し側のmain.goでは、戻り値の変数からはtinyfs.Fileインターフェイスのみを扱うことができ、もとのfatfs.File typeをレシーバとしたメソッドをそのままの状態では扱うことが出来ません。このような場合に、インターフェイスに代入されたことにより、もとのtypeの情報を失ったインターフェイス変数に対して元の型を取り戻す操作 = Type Assertion を行うことができます。main.goのff, ok := f.(*fatfs.File)がそれに該当します。Type Assertionを行う際には、想定する元の型が異なり失敗することを想定に入れる必要がありますので、必ず2番目の戻り値 ok でType Assertionの成否を確認するようにします。このようにして、取り出したfatfs.File typeのff変数に対して、fatfs.FileがレシーバであるSeek()メソッドなどを作用させています。

defer文 (遅延処理)

main.go

func fatfs_test(led *Pin) (testError *TestError) {
	...
	f, err := filesystem.OpenFile("bench.dat", os.O_RDWR | os.O_CREATE | os.O_TRUNC)
	if err != nil {
		return &TestError{ error: fmt.Errorf("open error: %s", err.Error()), Code: 3 }
	}
	defer f.Close()
    ...
    ffに対する処理
}    

deferは遅延処理の予約で、関数を抜けるときに実行されます。上記の例では、ファイルのOpenとCloseは一対の処理ですので、正常にOpenが確認できたあとで、Close()をdeferしておけば、fatfs_test()関数からリターンされる直前で実行されます。
なお、TinyGoではGo言語のひとつの特徴である大域脱出としてのpanicと、panicを救済するためのrecoverは実装されていません。これはTinyGoが対象とするようなターゲットではメモリリソースが限られており、panic/recover機構にともなう利点よりも実装のコストが上回るとの判断によるようです。詳しくはここをご覧ください。TinyGoではpanicをコールした場合はそこでプログラムは異常終了しますので、処理を継続できないような深刻なエラーの場合(対処方法がないハードウェア異常など)に限定して使用するのが良さそうです。

errorにエラーコード追加

上記に記載した通り、TinyGoではpanicを上位で救済する機能はありませんので、エラー処理はpanicを使用せずに、エラーを戻り値で返すことになります。errorはインターフェイスとして定義されており、Error() stringのみを持ちますが、今回のプロジェクトではエラーコードを追加してみました。

main.go

type TestError struct {
	error
	Code int
}

func main() {
	...
	err := fatfs_test(led)
	if err != nil {
		fmt.Printf("ERROR[%d]: %s\r\n", err.Code, err.Error())
		led.ErrorBlinkFor(err.Code)
	}
	...
}

func fatfs_test(led *Pin) (testError *TestError) {
	...
    sd := sdcard.New(spi, sckPin, sdoPin, sdiPin, csPin)
	err := sd.Configure()
	if err != nil {
		return &TestError{ error: fmt.Errorf("configure error: %s", err.Error()), Code: 1 }
	}
    ...
}

errorにエラーコード Codeを追加するには全く新規のエラー typeを定義する方法もありますが、簡単な方法として既存errorを取り込んだ上で、新規の要素を追加する方法を用いました。それが冒頭のTestError typeの定義となります。これにより、TestErrorは errorインターフェイスであるError()に加えて、Codeというメンバー変数を持つことができますので、エラー送出の際は、errorとCodeを含むTestError構造体を生成して返すようにすれば、受け取り側のmain()で err.Error()と err.Codeという形で参照できるようになります。今回のプロジェクトとしてはサンプルとしてエラー内容をシリアル表示しながら、エラーコードが示す回数分、LEDが点滅をしてエラーを知らせる処理を記述しました。

その他

今回のプロジェクトで使用した、マイコンを使用する際に、よく使用されるであろうその他機能をまとめて紹介します。
// SPIのシリアルクロック周波数の設定
SPI_BAUDRATE_MHZ := 50
spi.SetBaudRate(SPI_BAUDRATE_MHZ * machine.MHz)

// 経過時間の取得
start := time.Now() // 冒頭でコール
...
millis := time.Since(start).Milliseconds() // 経過時間: ミリ秒
micros := time.Since(start).Microseconds() // 経過時間: マイクロ秒

ベンチマーク比較

最後に元のC++プロジェクトと今回のTinyGoプロジェクトでのSDカードアクセスのベンチマーク比較を記載しておきます。同じ、Sansung microSDHC EVO Plus 32GB (UHS-I U1)、FAT32フォーマットカードでの比較です。 read性能に顕著なパフォーマンス低下が見られますので、実際のアプリケーションの要求性能に応じた判断が求められると思います。

  • pico_fatfs_test (C++)

    =====================
    == pico_fatfs_test ==
    =====================
    mount ok
    Type is FAT32
    Card size:   32.00 GB (GB = 1E9 bytes)
    
    FILE_SIZE_MB = 5
    BUF_SIZE = 512 bytes
    Starting write test, please wait.
    
    write speed and latency
    speed,max,min,avg
    KB/Sec,usec,usec,usec
    447.7192, 6896, 1007, 1142
    446.4797, 7589, 1024, 1145
    
    Starting read test, please wait.
    
    read speed and latency
    speed,max,min,avg
    KB/Sec,usec,usec,usec
    974.9766, 1050, 403, 524
    974.4066, 1049, 402, 524
        

  • pico_tinygo_fatfs_test (TinyGo)

    ============================
    == pico_tinygo_fatfs_test ==
    ============================
    mount ok
    Type is FAT32
    Card size:   32.00 GB (GB = 1E9 bytes)
    
    FILE_SIZE_MB = 5
    BUF_SIZE = 512 bytes
    Starting write test, please wait.
    
    write speed and latency
    speed,max,min,avg
    KB/Sec,usec,usec,usec
    349.1884, 17431, 1042, 1442
    361.0920, 22377, 1062, 1391
    
    Starting read test, please wait.
    
    read speed and latency
    speed,max,min,avg
    KB/Sec,usec,usec,usec
    354.7382, 22428, 1284, 1417
    353.7843, 22470, 1293, 1421
        

速度改善編に続く。

自己紹介

自分の写真
電子工作&プログラミング、オーディオ・音楽

注目の投稿

Raspberry Pi Pico Wで電波時計を合わせる (JJY標準電波エミュレータ)

Raspberry Pi Pico Wのアプリケーションとして 最少の周辺部品で電波時計むけJJYエミュレータ(時刻合わせ用)を製作しました。 ※2023年6月6日: ソースコード修正の内容を反映させました。 時刻合わせ風景 概要 電波時計は電波が届くところで使...

QooQ