After researching TinyGo, a Go language variant for microcontrollers, it seemed like it could actually run projects with a reasonable level of functionality. So I ported and ran a FatFs benchmark project originally coded in C++ for Raspberry Pi Pico to TinyGo.
![]() |
| FatFs Operation Verification with TinyGo |
Preparing the TinyGo Development Environment
Regarding the TinyGo development environment, there is a method to install directly to local, but to quickly get a working environment without polluting your local setup, using a Docker environment is recommended. From here on, the discussion assumes a Docker Desktop WSL2 environment. First, before starting Docker, execute the following from a Git Bash shell or similar to clone this project from GitHub.
$ cd /d/somewhere/share $ git clone -b main https://github.com/elehobica/pico_tinygo_fatfs_test.git
Next, proceed to pulling and running the Docker image. In this explanation, the Docker execution directory is on a shared directory. For faster builds, you can copy the data to the Docker container's internal filesystem, but for a project of this scale, building on the Windows filesystem should be practically no issue.
> wsl (In WSL2 shell below) $ docker pull tinygo/tinygo $ docker images $ docker run -it -v /mnt/d/somewhere/share:/share tinygo/tinygo:latest /bin/bash (In Docker container below) # cd /share # cd pico_tinygo_fatfs_test
Building the Project
Go Module Configuration
Before building, configure the Go modules. After configuring the project root directory (pico_tinygo_fatfs_test), running go mod tidy will automatically configure the remaining parts.
# go mod init pico_tinygo_fatfs_test # go mod tidy
The contents of go.mod after module configuration should look like the following.
module pico_tinygo_fatfs_test go 1.17 require tinygo.org/x/tinyfs v0.2.0
Build
Simply specify the target and run tinygo build to generate the binary file.
# tinygo build -target=pico -o pico_tinygo_fatfs_test.uf2
To display detailed size information, build as follows.
# tinygo build -target=pico -o pico_tinygo_fatfs_test.uf2 -size full
Return to the Windows environment and copy the generated UF2 file to the root of the RPI-RP2 drive.
Wiring Diagram / Pin Configuration
The wiring diagram is as follows.
![]() |
| Wiring Diagram |
Code Description
The sample code used is available here.
pico.go
In TinyGo, target-related settings are organized in the machine package, and the basic settings for Raspberry Pi Pico are summarized here. The file that performs initialization using this information is pico.go. The first two lines are Go Compiler Directives that specify the file should only be included in the build when it matches the target specified during tinygo build. The first line appears to correspond to the go 1.17+ format, and the second line to the pre-go 1.16 format. Since the init() function is defined within the main package in this case, it is executed before main() and initializes the variables defined in 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
}
Adding machine.Pin Interface
Operations related to the LED pin are added near the beginning of main.go.
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)
}
}
The procedure involves defining a new Type called Pin that incorporates the existing interface, and then defining methods with that Type as the receiver. This way, both the existing interface and newly defined methods can be used with the new Type, achieving an effect similar to inheritance.
FatFs Library
The free C library FatFs is used by calling it from Go using the cgo feature. The FatFs in the TinyGo repository is customized to implement functionality equivalent to fatfs_test. Note that the FatFs C library in the TinyGo repository itself introduces ooFat, an object-oriented version of FatFs.
The functionality additions for this project were made by adding the following Go-side methods to go_fatfs.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
Passing Data from Go to C
The data passing from Go to C will be explained using the Expand() method as an example.
// #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))
}
First, when using cgo, you need to specify the C headers to include using Go Compiler Directives and write import "C" without a blank line. This allows you to directly call functions defined on the C side, such as C.f_expand(). However, arguments passed to C functions also need to be converted to match C-side types, and value copying occurs here, so there is overhead when wrapping C functions for Go.
Accessing C Struct Members
Accessing C struct members will be explained by excerpting the following parts of the source code.
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));
}
First, fs is declared as a member of the Go-side FATFS struct with the type C.FATFS, which is the FATFS struct defined in ff.h. (Note that the TinyGo-side FATFS struct and the C-side FATFS struct are different things.) A pointer to the struct is assigned to this fs member by the go_fatfs_new_fatfs() function defined on the C side during the Configure call. In this state, on the TinyGo side, C struct members can be accessed as FATFSinstance.fs.memberName, just as on the C side. Specifically, l.fs.csize and l.fs.n_fatent in the GetCardSize() method are examples of this.
Type Assertion
This section explains the parts where Go-specific Type Assertion is performed.
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
}
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)
...
Inside the OpenFile() method in go_fatfs.go, a File type (struct) is created and returned as the first return value. However, in the return value definition of the OpenFile() method, tinyfs.File is specified instead of File type. Referring to tinyfs/tinyfs.go, tinyfs.File is an interface, not a type. In other words, when the OpenFile() method returns, it returns the File type assigned to the tinyfs.File interface. Therefore, in main.go on the calling side of OpenFile(), only the tinyfs.File interface can be handled from the return variable, and methods with the original fatfs.File type as the receiver cannot be used directly. In such cases, a Type Assertion can be performed to recover the original type from an interface variable that has lost its original type information by being assigned to an interface. The line ff, ok := f.(*fatfs.File) in main.go corresponds to this. When performing a Type Assertion, you must account for the possibility that the assumed original type may differ and the assertion may fail, so always check the success of the Type Assertion with the second return value ok. In this way, methods such as Seek() with fatfs.File as the receiver are applied to the extracted ff variable of fatfs.File type.
defer Statement (Deferred Processing)
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()
...
processing on ff
}
defer schedules deferred processing that is executed when the function exits. In the above example, since file Open and Close are paired operations, deferring Close() after confirming a successful Open ensures it is executed just before returning from the fatfs_test() function.
Note that TinyGo does not implement panic as a mechanism for global escape (one of Go's characteristic features) or recover for rescuing from panics. This appears to be because the targets TinyGo is designed for have limited memory resources, and the implementation cost outweighs the benefits of the panic/recover mechanism. For details, see here. In TinyGo, calling panic causes the program to terminate abnormally at that point, so it is best to limit its use to serious errors where processing cannot continue (such as hardware failures with no workaround).
Adding Error Codes to error
As described above, TinyGo does not have a mechanism to rescue panics at a higher level, so error handling returns errors as return values without using panic. error is defined as an interface with only Error() string, but in this project, an error code was added.
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 }
}
...
}
To add an error code Code to error, one could define an entirely new error type, but a simpler method was used: incorporating the existing error and adding new elements. This is the TestError type definition at the beginning. This allows TestError to have a member variable Code in addition to the Error() from the error interface. By creating and returning a TestError struct containing error and Code when sending errors, the receiving side in main() can reference them as err.Error() and err.Code. As a sample for this project, code was written that displays error contents via serial while the LED blinks the number of times indicated by the error code to signal the error.
Other
Here is a summary of other commonly used features when working with microcontrollers that were used in this project.// Setting SPI serial clock frequency SPI_BAUDRATE_MHZ := 50 spi.SetBaudRate(SPI_BAUDRATE_MHZ * machine.MHz) // Getting elapsed time start := time.Now() // call at the beginning ... millis := time.Since(start).Milliseconds() // elapsed time: milliseconds micros := time.Since(start).Microseconds() // elapsed time: microseconds
Benchmark Comparison
Finally, here is a benchmark comparison of SD card access between the original C++ project and this TinyGo project. The comparison uses the same Samsung microSDHC EVO Plus 32GB (UHS-I U1), FAT32 formatted card. A noticeable performance degradation in read performance is observed, so judgment according to the actual application's performance requirements will be needed.
- 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



No comments:
Post a Comment