GoにいればGoに従え(38) ラズパイPicoでソフトウエアタイミング制御の限界?

Joseph Halfmoon

ラズパイPicoでTinyGoをするのに「あたり前」そうなことを実地に確かめてます。前回はPWM出力でした。今回はソフトウエアによるタイミングの制御のレゾリューションというか、ジッタというかについて調べてみます。ハードウエアタイマを使って割り込みで制御するのが一番だけれども、ソフトでやれるならソフトでやるのがお楽。

※「GoにいればGoに従え」Go関連記事の総Index

※実機動作確認は Arm Cortex-M0+コアのRP2040チップ搭載、Raspberry Pi Pico機にTinyGoのオブジェクトを書き込んで行っています。ビルドはWindows11上です。

※Go言語の標準パッケージ time についてのドキュメントはこちら

ソフトウエアループによるタイミング制御

典型的なソフトウエアループはLチカです。過去回でやった吉例LチカのTinyGo版は以下のようでした。

for {
    led.Low()
    time.Sleep(time.Second * 1)
    led.High()
    time.Sleep(time.Second * 1)
}

timeパッケージはGo言語の標準パッケージですが、TinyGoでも使用できるので、時間的なタイミングに関することはまずこれに頼ることになります。

ただし上記では秒単位の制御でした。今回は、timeパッケージを使ってどのくらいの細かさ(レゾリューション)で時間制御できそうか、そしてその時間はどのくらい「揺らぐ」のかを見積もってみたいと思います。

単純ソフトウエアループ版の被テストプログラム

吉例Lチカ同様の単純ソフトウエアループで制御してみるものが以下に。最初のforループが肝心の部分で、

    1. INTVL [μ秒]だけtime.Sleepし
    2. その後、timeパッケージ内部の実時間をマイクロ秒単位で読み出して配列に格納(タイムスタンプ)

しています。1000個(定数 SIZで制御)のタイムスタンプを取得できたら、その最大、最小、平均値を求めて表示して終わりというプログラムです。

INTVLについては1μ秒のときと1m秒(1000μ秒)のときの2通りを実験してます。

ソースが以下に。

package main

import (
    "fmt"
    "time"
)

const SIZ = 1000
const INTVL = 1

func main() {
    var buffer [SIZ]int64
    for i := 0; i < SIZ; i++ {
        time.Sleep(INTVL * time.Microsecond)
        buffer[i] = time.Time.UnixMicro(time.Now())
    }
    var maxINTVL int64 = 0
    var maxIDX int = 0
    var minINTVL int64 = 999999999
    var sumINTVL int64 = 0
    for i := 0; i < (SIZ - 1); i++ {
        d0 := buffer[i+1] - buffer[i]
        if maxINTVL < d0 {
            maxINTVL = d0
            maxIDX = i
        }
        if minINTVL > d0 {
            minINTVL = d0
        }
        sumINTVL += d0
    }
    for {
        fmt.Printf("max: %d(%d) min: %d avg: %d\n", maxINTVL, maxIDX, minINTVL, sumINTVL/(SIZ-1))
        time.Sleep(2000 * time.Millisecond)
    }
}
Goroutine+Channel版の被テストプログラム

上記のような単純ループであると、Sleepはともかく、その後の処理にかかる時間が「もろ見え」になってしまって「Sleep時間の調整」がメンドそうです。そこで、

    1. INTVL [μ秒]だけtime.SleepするGoroutineを「メイン」の処理とは分ける。time.Sleepした後、メイン側の別なGoroutineをチャネルを使ってトリガするだけとする。
    2. 「メイン」側のGoroutineでは、チャネルで突かれるとtimeパッケージ内部の実時間をマイクロ秒単位で読み出して配列に格納(タイムスタンプ)する。これが仮のメイン処理。

ちょっとメンドクセー感じですが、時間インターバルをメンテするループは単純化できるので「メイン処理」の負荷(時間)によってループを回る時間が長くなったり短くなったりしない(Sleep期間中に別Goroutineによってメイン処理がされるであろう)という期待です。

ソースはこんな感じ。

package main

import (
    "fmt"
    "time"
)

const SIZ = 1000
const INTVL = 1

func test(mess chan int) {
    for i := 0; i < SIZ; i++ {
        time.Sleep(INTVL * time.Microsecond)
        mess <- 111
    }
}

func main() {
    var buffer [SIZ]int64
    var idx int = 0
    mess := make(chan int)
    go test(mess)
    for {
        temp := <-mess
        if temp == 111 {
            buffer[idx] = time.Time.UnixMicro(time.Now())
            idx++
            if idx >= SIZ {
                break
            }
        }
    }
    var maxINTVL int64 = 0
    var maxIDX int = 0
    var minINTVL int64 = 999999999
    var sumINTVL int64 = 0
    for i := 0; i < (SIZ - 1); i++ {
        d0 := buffer[i+1] - buffer[i]
        if maxINTVL < d0 {
            maxINTVL = d0
            maxIDX = i
        }
        if minINTVL > d0 {
            minINTVL = d0
        }
        sumINTVL += d0
    }
    for {
        fmt.Printf("max: %d(%d) min: %d avg: %d\n", maxINTVL, maxIDX, minINTVL, sumINTVL/(SIZ-1))
        time.Sleep(2000 * time.Millisecond)
    }
}
実験結果

さて実機ラズパイPicoにTinyGoのオブジェクトを書き込んでみた結果が以下に。

    • 単一ループ、ITVL=1

シンプルなループでtime.Sleep()で1μ秒待たせてみましたが、前後の処理時間がモロ見えのようで、最短でも13μ秒、平均14μ秒、最大25μ秒かかるという結果です。最大値が発生しているのは初回であるので特異的とも想像されます。DirectLoop1

上記の結果からはμ秒単位の制御は無理そうですが、数十μ秒毎くらいの制御はできそうな雰囲気醸してますな。ただし、処理する負荷時間がモロ見えなので負荷次第でしょうかね。ただしその時も数μ秒といった単位で処理時間はバラつくであろうと。

    • 単一ループ、ITVL=1000

Sleep()時間を1000倍にしてみましたが、結果は上記の結果にほぼ1002か1001を足した「横滑り」時間になりました。そのまま負荷時間が見えていると。DirectLoop1000

ある意味納得。単一ループの場合、実処理にかかる時間を把握できれば(とてもメンドイけど)10μ秒単位くらいでの出し入れは可能じゃないかと。ソフト処理の割には速い?

    • Goroutine+channel、ITVL=1

「テク」使った方は、「テク」使った分、オーバヘッドが大きく、「メイン負荷」の影響は少ない筈といいつつ、1μ秒Sleepの場合は、単純ループより成績が悪かったです。RpicoMicroResult1

一応、想定の範囲。ホントか?

    • Goroutine+channel、ITVL=1000

一方、待ち時間を1000μ秒に長くしてやると、メイン負荷の影響を隠蔽できるせいか、単純ループより時間が短くなります。RpicoMicroResult1000

 

負荷が変動するようなケースでは「隠蔽」できるのは有利の筈。しかし、今回のようにほぼほぼ一定時間の短時間負荷では、テク使った分のオーバヘッドのバラツキは単純ループよりデカそう。。。

10kHzから20kHzくらいのサンプリングレートならソフトでやれそう?でもやっぱり本命はハードウエア制御じゃね。

GoにいればGoに従え(37) ラズパイPicoのPWM、全スライス使ってみる へ戻る

GoにいればGoに従え(39) ラズパイPicoでもレジスタ直接アクセス。最初はCPUID へ進む