この記事は TinyGo Advent Calendar 2025 9日目の記事です。
タイトルは適当です。
Advent Calendar は初参加なのでお手柔らかにお願いします。
はじめに
先日友人と飲んだ際に半田付けの話になりまして、「半田付けできるイベントがあるよ!」と勧められたので TinyGo Conference 2025 in JAPAN に参加してきました。
ワークショップで gopher-board の半田付けをさせてもらい、半田欲も満たされてほこほこしながら帰宅したのですが、よく調べてみたら搭載されてるRP2040-Zeroは
- IOポート山盛り
- USB Type-C
- ADCが4chもある
ということが判明し、「これは以前作ったアレに移植できるんじゃ?」と思い立ったのでした。
というわけで、TinyGoの勉強をしながらRP2040-Zeroで遊ぼうとした顛末がこちらになります。
アレ
遡ること1989年、任天堂のファミリーコンピュータ向けにアスキーから発売された、「アスキースティックL5」というコントローラーがあります。
左手だけで操作する片手コントローラーなのですが、これにPSPのアナログスティックをつけてUSB化する改造を過去にしていました。
その当時は手持ちデバイスの都合もあり、mini EZ-USB に ATMEL(現Microchip) AVR AT90S2313 x2 をくっつけて動かしていました。
なぜAVR2個追加で使っていたかというと、アナログスティック用にADコンバータが必要で、それをAVRのアナログコンパレータで代用していたのでした。
たださすがにデバイス3つをL5の筐体内に収めるのはそれなりに大変で、アホみたいな合体基板を作って無理やり詰め込んだりしてました。
当時時間があったとはいえ、我ながらよく作ったなぁと思います。
そういう経緯があり、デバイス1個でしかもType-C化までできちゃったら最高だなぁと思ったのでした。
まずは gopher-board を動かす
開始時点でTinyGoどころかGo自体がミリしらだったので、とりあえずワークショップでゲットした基板を動かそうと、Lチカ目指して資料あさりを始めました。
環境構築してビルドできる状態にして、じゃあ何か焼いてみようと最初に参考にしたのがこちら。
とりあえず焼けたら何かしら動くだろ的に01_blinky1を焼いたところ、RP2040-Zero上のLEDが光ってしまいました。
この日はこれで満足してしまったのですが、気になってこの後少し調べたらちゃんとボード作成された方のサンプルがありました。
ということで今後はこちらをベースにさせてもらって、いろいろやりたいことの検証をしていくことにしました。
ボタンを認識させる
gopher-boardにはボタンが6個実装されています。
ハットスイッチに相当する4個と、AボタンBボタンの計6個です。
とりあえずこれら6個のボタンを認識できるよう、サンプルをまねてコードを書いてみました。
どのボタンがどのピンにつながってるのかわからなかったので、
の回路図を参考に記述してみました。
回路図を見るためにKicadもインストールしておきました。
あとGoはinit()の後にmain()が呼ばれるとか、ifの条件式は括弧いらないとか、:=はvarだっていうのもここで知りました。
func main() {
button1 := machine.D4
button1.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button2 := machine.D5
button2.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button3 := machine.D6
button3.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button4 := machine.D7
button4.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button5 := machine.D27
button5.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button6 := machine.D28
button6.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
for {
if !button1.Get() {
println("button 1 is pressed!!")
}
if !button2.Get() {
println("button 2 is pressed!!")
}
if !button3.Get() {
println("button 3 is pressed!!")
}
if !button4.Get() {
println("button 4 is pressed!!")
}
if !button5.Get() {
println("button 5 is pressed!!")
}
if !button6.Get() {
println("button 6 is pressed!!")
}
time.Sleep(time.Millisecond * 100)
}
}
flashしてmonitorして、あっさり動きました……と思ったら、一部のボタンが動きません。
回路図見てもよくわからないのでPCBファイルを見てみると、自分が見ているファイルは液晶の端子が4pinしかありません。手持ちの基板は液晶が8pin。どうも違う回路図を見ているようです。
しばらく探してみたのですがそれらしいものが見つけられず、仕方がないのでテスターでピンを触って調べました。最初からやれって話ですね。
どうやら3, 4, 5, 6, 15, 26pinに接続されているようでした。
コード書き換え。
button1 := machine.D3
button1.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button2 := machine.D4
button2.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button3 := machine.D5
button3.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button4 := machine.D6
button4.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button5 := machine.D15
button5.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button6 := machine.D26
button6.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
バッチリ動きました。
とりあえず半田付け失敗しているようなことはなさそうです(今頃)。
アナログスティックを認識させる
次はアナログスティックです。
さすがにgopher-boardにはアナログスティックはないので、L5を直接繋ぐことにします。
ADCで使えるピンは26, 27, 28, 29pinですが、26pinはボタン6で使っているのでここでは27, 28pinを使います。
アナログ入力のサンプルはこちらの06_joystickを参考にさせてもらいました。
書いたコードはこちら。
func main() {
machine.InitADC()
analogX := machine.ADC{Pin: machine.D28}
analogX.Configure(machine.ADCConfig{})
analogY := machine.ADC{Pin: machine.D27}
analogY.Configure(machine.ADCConfig{})
for {
x := analogX.Get()
y := analogY.Get()
fmt.Printf("%04X %04X\n", x, y)
time.Sleep(time.Millisecond * 1000)
}
}
とりあえず数値が増減しました(最下部のFFF0のところ)。
あっさりです。こんな簡単でいいのTinyGo。
USB HID化する
ボタンを認識させることができたので、今度はこの状態でUSBゲームパッドとして認識させないといけません。
TinyGo-Keeb界隈の方には説明不要かと思いますが、HIDとして認識されるようにしないといけないはずなのでそのための記述を追加していきます。
上記サンプルコードを見てみると、キーボードの場合は"machine/usb/hid/keyboard"というのを呼び出してる様子。
そして以前検索したときに過去のTinyGoのバージョンアップでJoystickに対応したような記述を見かけました。
なので多分"machine/usb/hid/joystick"みたいなのがうっかりあるんじゃないかと思ってTinyGo本家を見に行きました。
ありますね。
あったのはよかったのですが、ざっと眺めてさっぱりわかりませんでした。
こういうときは寝るに限るということで、続きは翌日以降に。
"machine/usb/hid/joystick" を理解する
パッケージ内のソースコードを頑張って読み進めていったところ、何かしらデフォルトで設定してくれそうな雰囲気はあるのですが結局答えは出ず。
このパッケージを使っているコードがないか、しばらくあちこち探してたのですが、灯台下暗しで本家の中にサンプルがありました。迂闊。
joystick.Port() だけで動くらしいので実装。
package main
import (
"machine"
"machine/usb/hid/joystick"
"time"
)
var js = joystick.Port()
func main() {
button1 := machine.D3
button1.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button2 := machine.D4
button2.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button3 := machine.D5
button3.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button4 := machine.D6
button4.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button5 := machine.D15
button5.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
button6 := machine.D26
button6.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
machine.InitADC()
analogX := machine.ADC{Pin: machine.D28}
analogX.Configure(machine.ADCConfig{})
analogY := machine.ADC{Pin: machine.D27}
analogY.Configure(machine.ADCConfig{})
for {
if !button1.Get() {
js.SetHat(0, joystick.HatUp)
}
if !button2.Get() {
js.SetHat(0, joystick.HatLeft)
}
if !button3.Get() {
js.SetHat(0, joystick.HatRight)
}
if !button4.Get() {
js.SetHat(0, joystick.HatDown)
}
if !button5.Get() {
js.SetButton(0, true)
} else {
js.SetButton(0, false)
}
if !button6.Get() {
js.SetButton(1, true)
} else {
js.SetButton(1, false)
}
x := analogX.Get()
y := analogY.Get()
js.SetAxis(0, int(x))
js.SetAxis(1, int(y))
js.SendState()
time.Sleep(time.Millisecond * 100)
}
}
動きました。
ここまで簡単だと、この環境を構築されてきた先人達の技術に頭が上がりません。
ボタンの数をちゃんと設定する
HIDとして認識させられたのはよかったのですが、どうもWindows上の画面を見ると16ボタンアナログ6軸ハットスイッチ1のPSコントローラみたいな認識をされている模様。
パッケージを頑張って読んでいくと
とかのあたりを適切に指定してあげないといけなさそう、ということで
var def = joystick.Definitions{
ReportID: 1,
ButtonCnt: 4,
HatSwitchCnt: 1,
AxisDefs: []joystick.Constraint{
{MinIn: -32767, MaxIn: 32767, MinOut: -32767, MaxOut: 32767},
{MinIn: -32767, MaxIn: 32767, MinOut: -32767, MaxOut: 32767},
},
}
var js = joystick.UseSettings(def, nil, nil, nil)
としてみるも、逆に全く動かなくなってしまう始末。
UseSettings()の引数、nil入れたら内部でデフォルト設定が読まれてそうな感じがあるんだけどなぁ、全部ちゃんと指定しないとだめなのか……と思いつつ、ダメ元でGitHub Copilotさんに聞いてみたところ、手元のソースをゴリゴリ読みだしたかと思ったら
「ちゃんとHIDレポートディスクリプタを指定してあげる必要がありますね」
とか言ってきました。そうなのじゃあちょっと作ってみてよと言ったところ、一発で動くレポートディスクリプタが出力されました。
怖。
ともかく動くことはわかったので、後学のためどうして動くようになったのか一通り説明をお願いしたのが以下。
(長いので適当に読み飛ばしてください)
GitHub Copilotさんのありがたいお話(クリックで展開)
いいですね — では最初から順を追って、なぜ今回の修正で動くようになったかを分かりやすく説明します。要点→技術的詳細→チェックリストの順でまとめます。
要点(結論)
USBホストは「HIDレポート記述子(hidDesc)」を見てどのバイトに何が入るかを期待します。
TinyGo の UseSettings は(引数で渡された)hidDesc を USB に登録します。
State.MarshalBinary() が実際に送信するバイト列は Definitions に基づいて作られます。
「hidDesc」と「Definitions(=Stateが作るバイト列)」が一致していないと、ホスト側は受信バイト列を正しく解釈できず、ボタン等が反応しません。
したがって“hidDesc を自分の Definitions に合わせて作り、UseSettings に渡す”ことで問題が解消しました。
以下で順を追って詳しく説明します。背景:何が問題だったか(最初の状況)
あなたは joystick.Definitions{ ... } を使ってカスタム(例:ボタン4、軸2 等)の設定を作っていました。
しかし TinyGo 側(ライブラリ)が USB に登録する HID のデフォルト記述子は descriptor.JoystickDefaultHIDReport(16ボタン・1ハット・6軸向け)です。
そのためホスト(Windows)は「16ボタン・6軸のレイアウト」を期待しているのに、デバイス側は「4ボタン・2軸としてパックしたバイト列」を送っている、という不整合が起きていました。結果:ホストにとってボタンはどのビットにあるかが正しく読めない → 反応しない。
補足:最初に出ていたコンパイルエラー(「cannot refer to unexported field descriptor」)は別問題です。Definitions 型の中に descriptor []byte という小文字の(非公開)フィールドがあり、別パッケージ(main)から直接そのフィールドを設定できないためでした。DefaultDefinitions() がそのフィールドを同パッケージ内で正しく埋めていたため、DefaultDefinitions() を使えばその問題は避けられます。TinyGo 側の処理(重要な実装ポイント)
UseSettings の挙動(重要箇所):js := &joystick{ State: def.NewState(), ... } → def.NewState() で State(送信用バッファと各フィールド)を作る。
if len(hidDesc) == 0 { hidDesc = descriptor.JoystickDefaultHIDReport } → 第4引数が空だとデフォルト descriptor を使う。
class.ClassLength(uint16(len(hidDesc))) と descriptor.CDCJoystick.HID[2] = hidDesc で、その hidDesc を USB 設定に組み込む。
machine.ConfigureUSBEndpoint(...) でエンドポイントを登録し USB の初期化を行う。
以降 js.SendState() は State.MarshalBinary() の出力を hid.SendUSBPacket 経由で送信する。
つまり「hidDesc を渡すかどうか」と「State が作るバイト列」が合っていることが必須です。State.MarshalBinary が作るバイト列(あなたのカスタム構成:4ボタン・1ハット・X/Y)
main.go に合わせて実際に作られる(SendState で送る)バイト列構造は次のようになります(先頭は ReportID):バイト列の構成(今回の設定):
Byte 0: ReportID (1)
Byte 1: Buttons(4個 → 4ビット) + padding(4ビット)
ボタンは LSB 側から 1-bit per button。State.MarshalBinary はボタンをそのまま Buttons バイト配列に入れます(1バイト内に最大8ボタン)。
Byte 2: HatSwitch(4ビット) + padding(4ビット)
実装ではハットを 4 ビット単位に詰め、2個で 1バイトになる実装です(今回1個なので残り4ビットはパディング)。
Bytes 3-6: Axis X (16-bit LE), Axis Y (16-bit LE) — 合計 4バイト
MarshalBinary の実装ポイント:先頭に ReportID を置く。
ボタン群を連続したビット配列として Buttons に入れる(1バイトごと)。
Hat は 4ビットずつ左にシフトし nibbles に詰める(State.MarshalBinary の hat packing を参照)。
各 Axis は int16 値に変換して little endian で AppendUint16 される。
HID レポート記述子(hidDesc)でやるべきこと
HID レポート記述子では「ホストにこういう並びでデータが来ます」と伝えます。今回作成した hidDesc の要点:Report ID を 1 と宣言(HIDReportID(1))。State 側も先頭に1バイトの ReportID を送るので一致させる必要がある。
Buttons: HIDReportSize(1), HIDReportCount(4) → 4 ビットを「Data,Var,Abs」で定義。続けて HIDReportSize(4), HIDReportCount(1), HIDInputConstVarAbs で残り4ビットをパディング。
Hat: HIDReportSize(4), HIDReportCount(1) で 4 ビットの HatSwitch を定義。続けて 4ビットパディング。
Axes: HIDReportSize(16), HIDReportCount(2) で X,Y を 16 ビットずつ定義。LogicalMinimum/Maximum を -32767/32767 にして signed 範囲を示す。
最後に必要な HIDCollectionPhysical/HIDCollectionEnd を閉じる。
重要:descripor の ReportSize/Count/Order は State が作る byte order & bit packing と厳密に一致させる必要があります。これで「なぜ動いたか」要約
以前:State(デバイス)が送るデータの形(4ボタン・ハット・2軸)と、ホストが期待する記述子(16ボタン・6軸)で不一致 → ホストは正しく解釈できず、ボタンが効かない状態だった。
今回の修正:あなたが作った Definitions(4ボタンなど)に合わせた hidDesc を用意して UseSettings(..., hidDesc) に渡した。これにより USB に登録される HID Descriptor が「実際に送るバイト列」と一致した。よって Windows が正しく解釈してボタン・軸が反応するようになった。
付随した小さな修正(型の話)
var js *joystick.Joystick がエラーになった理由:joystick パッケージ内の実際の型名は小文字 joystick(未エクスポート)で、joystick.Joystick のような大文字のエクスポートされた名前は存在しません。外部パッケージからは未エクスポート型名を参照できないためコンパイルエラーになります。
対応として var js = joystick.Port()(戻り値を型推論で受ける)や js = joystick.UseSettings(...) を init() 内で呼ぶことで型名前を明示せず安全に扱えます。
実務的なチェックリスト(カスタム HID を作るときに必ずやること)
Definitions を決める(ReportID, ButtonCnt, HatSwitchCnt, AxisDefs の数・範囲)。
State.MarshalBinary がその Definitions に従ってどのようにバイト列を作るかを確認する。
そのバイト列とビット配置が一致するよう HID レポート記述子(hidDesc)を作る。
UseSettings(def, rxHandler, setupFunc, hidDesc) を init() で呼ぶ(TinyGo 実装コメントに従う)。
ホスト側で実際の Report Descriptor を確認(必要なら USB キャプチャ)して期待どおりになっているか検証する。
送信されたレポート(State.MarshalBinary の出力)をデバッグ出力して、ホストが期待するビット配置と一致しているかを検証する。
追加でやると良いこと(任意)
State.MarshalBinary() の出力(送るバイト列)を一時的にシリアルに print して、ホストに渡るバイトを直接確認する。
便利化:ボタン数・軸数から hidDesc を自動生成するヘルパ関数を作っておく(今後の変更が楽になります)。
Windows での検証に USBPcap + Wireshark を使えば、ホストが受け取るレポートと Report Descriptor の実データを比較できます。
GitHub Copilotさんの出力したありがたいソース
すごすぎて訳わかんないですね。
仕事でこんなこと(訳わからないまま訳わからないものを使う)してたら怒られますが、趣味なのでOKです。勝てば官軍です。
修正後のmain.go。
package main
import (
"machine"
"machine/usb/hid/joystick"
"time"
"github.com/tieste/ascii-stick-l5-rp2040-zero-tinygo/pid"
)
var js = joystick.Joystick
func init() {
js = joystick.UseSettings(
joystick.Definitions{
ReportID: 1,
ButtonCnt: 4,
HatSwitchCnt: 1,
AxisDefs: []joystick.Constraint{
{MinIn: -32767, MaxIn: 32767, MinOut: -32767, MaxOut: 32767}, // X
{MinIn: -32767, MaxIn: 32767, MinOut: -32767, MaxOut: 32767}, // Y
},
},
nil,
nil,
pid.HidDescriptor,
)
if js == nil {
panic("UseSettings failed")
}
}
var js = joystick.Joystick のところがちょっと無理矢理な気もするんですが、ほかにうまい方法が見つかりませんでした。init()に入れなきゃいいんでしょうけどvar jsの宣言だけinit()の外に出したくて……。
若干のリファクタリング
話はちょっと前後して、HIDレポートディスクリプタですが最初はmain.goにべた書きでした。
たださすがに別ファイルに分けたいなと思い、Goのパッケージ仕様とかを調べました。
どうやらgo.modというファイルを用意するとモジュール化できるとのこと。
見よう見まねでpidパッケージを作ってそこに格納しました。
正しいかどうかはよくわかってませんがmain.goにインポートできたのでよしとします。
Kicadも使ってみる
回路図を残しておくために、Kicadで回路図を書きました。
というかPCBやガーバーまでこれ1つで管理できるんですね。すごいなぁ。
おそらく最後に趣味で基板設計したのが20年くらい前なのですが、当時は何を使ってたんだっけなぁ……
というわけで
無事完成しました。
実はロジックとしては完成したのですが、物理的にはコントローラーの外にRP2040-Zeroが飛び出してる状態になってしまっています。
最終的にはL5の中にRP2040-Zeroもちゃんと格納してType-Cの口の部分だけ外に出すような加工をしてあげたいのですが、プラスチック加工はそこそこの気合いが必要なので、もうすこしまとまった時間がとれるようになったら考えたいと思います。
あとよく考えたらチャタリング対策してないんですよねこれ。チャタリングしてる様子が見受けられなかったので一旦見なかったことにしていますが、本来なら何かしらロジック入れないとまずいような気はしています。
ハットスイッチの斜め入力判定もしてないや……使って困ったらその時考えましょう。うん。
まとめ
にしてもあっというまにやりたいことができてしまいました。
TinyGoありがとう楽しかったよ。
これを機にGoの勉強ももうちょっとやってみようかなと思ったのでした。
今回のソースはこちら。
おしまい。


