Rustで自作OSをしているときのデバッグ例 - syscall 命令と仲良くなりたい!前編

この記事は自作OS Advent Calendar 2022の17日目の記事です。他の記事も是非お楽しみください!(そして書ける方はぜひ参加してみてください!!)

前回(?)までのあらすじ

hikaliumは自作OS上で動くアプリからsyscall命令を使ってシステムコールを呼べるようにしようと頑張っていたが、なぜか発生するトリプルフォルトによりQEMUが再起動してしまい、3時間のデバッグの末力尽きてしまった。一体なぜ例外が発生するのか、その謎を解くため、我々は数日の休息をとったのち、バイナリの森へと旅立った…。

前回(という名の配信アーカイブ):

www.youtube.com

状況を整理しよう

バイナリの森は危険だ。無闇に動きまわっては、x86の沼に足をとられて命を落としかねない。まずは我々の向かっていた先と、これまでに得た情報をまとめることにしよう。

どこへ向かっていたのか

我々のひとまずの目標は、自作OS上で実行されるアプリケーションから、Rustのprintln!()マクロを介して文字列を出力することであった。Rustはとてもよくできていて、coreクレートの中に、C言語でいうsprintfに相当する、fmtモジュールが存在しており、既存のOSの支援が期待できないno_std環境でも変数の中身などを容易にかつ美しく表示する関数およびマクロを容易に実装することができる。そう、我々はもう闇のようなlibc++やnewlibに頼らなくてもよくなったのだ。10年前のように、自前でsnprintfもどきを実装するまでもない。世界は日増しによくなりつつある。

ではprintln!マクロを実装するにあたり、自作OSが提供しなければならない機能は一体何かというと、それは「文字列を表示する」という機能である。というのも、coreクレートにはOSに依存するような機能は含められないため、「文字を画面に出す」などといった、物理世界とのインタラクション、つまりはハードウエアの制御などのOSが支配している領域の操作はcoreクレートの外で自前で用意してやらなければならない。core::fmtは、フォーマット文字列と引数をもとに、メモリ上に「表示するべき文字列」を生成してはくれるが、それを表示するのは我々の責務なのだ。

というわけで、我々はprint_stringというシステムコールを用意した。これは、メモリ上に存在する文字列へのポインタと、文字列の長さを指定すると、OSがそれを画面上に表示してくれる、というものである。実は、このシステムコール自体はどうも動作しているようで、実際にアプリケーションからprint_stringシステムコールを介して固定の文字列を出力してみた場合は、想定通りに文字列が出力されていたことが確認できている。

それならば、あとはprint_stringに対して、core::fmtによって生成された文字列を渡してあげればよいのではないか、我々はそう踏んでいたのだ。

なにがあったのか

…しかし、その考えはあまりにも浅はかだった。実際に、以下のようにprint_stringを置き換えてみたところ、なんとOSが再起動してしまったのだ。

#![no_std]
#![no_main]

use noli::*;

fn main() -> i64 {
    //sys_print("hello!");
    println!("{}", 42);
    return -42;
}

entry_point!(main);

どうも、何らかの理由でCPUの例外が発生し、トリプルフォルトに至って再起動してしまう、というところまではわかったのだが、我々はそこで一旦力尽きてしまったのだった…。

まずは何が起きているのかを理解しよう

現状は先ほど伝えた通りだ。次は、我々がどちらに向かえばよいのか、それを考えることにしよう。そのためには、我々がいま直面している現象を正しく理解することが必要不可欠だ。まずは、具体的にどのような例外でトリプルフォルトに至っているのか、それを確認することにしよう。

QEMUには、例外が発生した際にログを出力する機能が存在している。これはデフォルトでオフになっているが、-d というフラグを介して制御することが可能だ。

$ qemu-system-x86_64 -d ?
Log items (comma separated):
out_asm         show generated host assembly code for each compiled TB
in_asm          show target assembly code for each compiled TB
op              show micro ops for each compiled TB
op_opt          show micro ops after optimization
op_ind          show micro ops before indirect lowering
int             show interrupts/exceptions in short format
exec            show trace before each executed TB (lots of logs)
cpu             show CPU registers before entering a TB (lots of logs)
fpu             include FPU registers in the 'cpu' logging
mmu             log MMU-related activities
pcall           x86 only: show protected mode far calls/returns/exceptions
cpu_reset       show CPU state before CPU resets
unimp           log unimplemented functionality
guest_errors    log when the guest OS does something invalid (eg accessing a
non-existent register)
page            dump pages at beginning of user mode emulation
nochain         do not chain compiled TBs so that "exec" and "cpu" show
complete traces
plugin          output from TCG plugins

strace          log every user-mode syscall, its input, and its result
tid             open a separate log file per thread; filename must contain '%d'
trace:PATTERN   enable trace events

Use "-d trace:help" to get a list of trace events.

おっと、言い忘れていたが、我々は以下のバージョンのQEMUを自前ビルドして利用している。一応伝えておこう。

$ qemu-system-x86_64 --version
QEMU emulator version 7.2.0 (v7.2.0-dirty)
Copyright (c) 2003-2022 Fabrice Bellard and the QEMU Project developers

さて、本筋に戻るとしようか。というわけで、以下のようなオプションをQEMUに追加すれば、例外に関するログが、カレントディレクトリの qemu_debug.log というファイルに出力されるはずである。(出力先のファイルは-Dオプションで指定している。) また、 --no-reboot をつけておくと、トリプルフォルトでCPUにリセットがかかった際に、再起動するのではなくQEMUを終了することができるようになる。再起動のループを楽しみたい気分であるなら話は別だが、これも設定しておくとデバッグが楽になるだろう。

-d int,cpu_reset -D qemu_debug.log --no-reboot

さて、これでもう一回自作OSを起動してみる。アプリは自動で起動するように設定してあるから、クラッシュしてすぐQEMUが落ちるはずだ。

…予想通り落ちた。ログを確認しよう。

$ cat qemu_debug.log | grep -A 1 -e check_exception -e Triple
check_exception old: 0xffffffff new 0xe
   159: v=0e e=0000 i=0 cpl=0 IP=0008:0000000000200fd0 pc=0000000000200fd0 SP=0010:000000003e286be0 CR2=0000002000000000
--
check_exception old: 0xffffffff new 0xd
   160: v=0d e=0000 i=0 cpl=0 IP=0008:000000003e290f91 pc=000000003e290f91 SP=0010:000000003e286928 env->regs[R_EAX]=0000002000000000
--
check_exception old: 0xffffffff new 0xd
   161: v=0d e=0000 i=0 cpl=0 IP=0008:000000003e290f91 pc=000000003e290f91 SP=0010:000000003e286668 env->regs[R_EAX]=0000002000000000
--
...
--
check_exception old: 0xffffffff new 0xd
  6458: v=0d e=0000 i=0 cpl=0 IP=0008:000000003e290f91 pc=000000003e290f91 SP=0010:000000003de4c1a8 env->regs[R_EAX]=0000002000000000
--
check_exception old: 0xffffffff new 0xd
  6459: v=0d e=0000 i=0 cpl=0 IP=0008:000000003e290f91 pc=000000003e290f91 SP=0010:000000003de4bee8 env->regs[R_EAX]=0000002000000000
--
check_exception old: 0xd new 0xe
  6460: v=0e e=0002 i=0 cpl=0 IP=0008:000000003e290f91 pc=000000003e290f91 SP=0010:000000003de4bee8 CR2=000000003de4bed8
--
check_exception old: 0xe new 0xe
  6461: v=08 e=0000 i=0 cpl=0 IP=0008:000000003e290f91 pc=000000003e290f91 SP=0010:000000003de4bee8 env->regs[R_EAX]=0000002000000000
--
check_exception old: 0x8 new 0xe
Triple fault

おお、結構な量のログが出ている。はて、一体何が起きているのだろうか…。

例外コードを順に追っていくと、以下のような流れで例外が起きていることがわかる。

  • ページフォルト(0xe)が0x200fd0の命令を実行中に発生した。ページフォルトの要因となったアドレスは CR2=0x2000000000 である。
  • 一般保護例外(0xd)が0x3e290f91の命令を実行中に発生した。
  • 同じ例外が次々と発生し、スタックポインタが0x3e286928から0x3de4bee8まで変化した。
  • 最終的に、スタックポインタと同じアドレスであるCR2=0x3de4bed8 にアクセスしようとして、再度ページフォルト(0xe)が発生した。
  • ここでさらにページフォルト(0xe)が発生し、これによってダブルフォルト(0x8)が発生した。
  • ダブルフォルト(0x8)の発生中にさらにページフォルト(0xe)が発生し、最終的にトリプルフォルトとなってリセットがかかった。

つまるところ、

  • 最初のきっかけは、0x200fd0の命令がアドレス0x2000000000にアクセスしようとしてページフォルト(0xe)を発生させたことにある。
  • そのページフォルトを処理している最中に一般保護例外(0xd)が発生し、一般保護例外を処理中にもさらに一般保護例外が発生することで、順次スタックが食い潰されていった。
  • 例外ハンドラのスタックが枯渇し、0x3de4bed8にアクセスしようとした段階でページフォルトが発生した。これにより、ダブルフォルトが発生した。
  • ダブルフォルトハンドラも同一のスタックを使用していたため、再度ページフォルトが発生し、トリプルフォルトとなってリセットがかかった。

ということのようだ。 …例外を正しく処理できないOSでごめんなさい…(仕方ないよ、完璧なOSなんて存在しないもの。直していきましょう!)

さて、ここらへんで日付変更線が近づいてきたようだ。続きはまた明日…(睡眠は大事ですよ!おやすみなさい!)

「すいみんpreparation」解説

注意: この文章は教科書ではありませんし正確性も保証されていません。より詳細で正確な情報を知りたい場合には、ここで登場したキーワードをもとに自分で調べてみてください。

はじめに

眠れない夜にOSのことを考えていたら、ふとメロディを思いついてしまったのでそれを打ち込んだのが2/2のことでした。

そこから勢いで曲を打ち込んで歌詞をつくり(この段階で歌詞は出来上がっていたので伏線)

歌って動画を作った結果がこちらです。

なお歌はあまり上手くないので、声なしのバージョンがいいよという方はSoundCloudにありますのでそちらもどうぞ!

soundcloud.com

さて、この曲に対する人々の反応ですが、

はい、極めて正常な反応だと思います。

実のところ、この歌詞にはOSネタを大量に盛り込んでいたので、おそらくそのあたりの知識がないと完全には面白さが伝わらなかった可能性があります。

そこで本記事では、「すいみんpreparation」の歌詞を追いつつ、どのようにOSネタとの関連があるのか、どのような意図をこめた歌詞になっているのかについて解説したいと思います。

全体のテーマ

動画の概要欄に「やりたいことで手一杯なOSのみなさんのために」と書かれている通り、OSの果たす役割のうちの一つである「資源の管理」という側面について、その難しさを日常生活になぞらえて描写することが今回のテーマでした。

特に、タスクのスケジューリング問題に関しては、かなり強い仮定をおいてもなおNP完全な問題であることが "NP-complete scheduling problems" (J.D. Ullman, 1975) で示されています。ちなみにUllman氏は、コンパイラを実装したことのある皆様にはおなじみ、通称ドラゴンブックの作者の一人です。

www.sciencedirect.com

OSのみなさんだけでなく、人間のみなさんにとっても、日々遂行しなければならないタスクが山積していて、その処理に追われているという状況はよくみられることでしょう。

そのような困難な状況下にあっても、最優先で処理すべきタスクのひとつ、それが睡眠である、というのが本作品の主要な論点になります。

歌詞とその背景

では、最初から順番に歌詞を追ってゆきましょう。

いつも気づいたら 時計の針はとびまわり 
明日という日が 足音立てず向かってくるよ

作業に没頭していると、時間があっという間に過ぎてしまって、気づけば夜中になっていた!という経験はみなさんよくあるのではないでしょうか。

OSにおいては、並行処理における競合状態を回避するために「割り込み」を禁止することがあります。(このような競合状態の発生する可能性のある処理の範囲のことを、「クリティカルセクション」と呼びます。)

「割り込み」は、外部デバイスからの信号などを契機として、現在実行している処理に優先してなんらかの処理を行わせる仕組みのこと、またそれを引き起こす信号のことを指します。

割り込みを発生させる外部デバイスの状態変化の例としては、キーボードのキーを押下した時や、ネットワークを介してパケットが到着した時、外部記憶装置に対する書き込み要求が完了した時などが挙げられます。

他にも、重要かつ基本的な割り込みの一つとして、タイマー割り込みというものが存在します。これは、ある時間(たとえば10ms)が経過するごとに割り込みを発生させる、というものです。

OSは、このタイマー割り込みを活用することにより、時分割マルチタスクを実現しています。マルチタスクは、CPUの処理能力という限られた資源を有効活用するというOSの大事な役割のひとつです。 したがって、それを支えるタイマーや時刻の管理も、実はOSの重要な仕事のひとつなのです。(Linuxではtimekeepingサブシステムがこれを担当しています。)

さて、以上のことを念頭に一つ考えてみることにしましょう。もしも割り込みをずっと止めていたら、つまり割り込みを無視し続けたら、一体何が起こるでしょうか?

……そうです、時分割マルチタスクの仕組みが正しく動作せず、現在実行しているタスクが終わるまでずっとそのタスクを処理しつづけることになります。

これは、何かに没頭して時間の経過を忘れてしまうことと対応します。(人間においてこのような状況は「過集中」と呼ばれることもあります。)

その結果として、いつの間にか意図せず夜ふかしをしてしまうことを「明日という日が 足音立てず向かってくるよ」と表現しています。

また、タイマー割り込みによって定期的に行われていた時刻の管理も正しく行われなくなるため、時計が狂ってしまうこともあります。

それが「いつも気づいたら 時計の針はとびまわり」という部分の意味です。

今日も 下手したら 同じところを周ってばかりで
明日できるさ 帰納法によれば成り立たぬ

得てして長時間の割り込み禁止が発生してしまうのはバグの存在を意味します。というのも、先ほど説明した通り、長時間の割り込み禁止は様々な問題を引き起こす可能性があるため、そのような状況が発生しないように気をつけてコードを書く責任が開発者にはあるからです。

そもそも、アプリケーションレベルでは、割り込みを禁止することは一般に許可されていません。OSのみが割り込みに関連する制御を行うことができるよう、CPUの保護機構を活用して制限をかけている場合がほとんどです。(ユーザーモードカーネルモード・特権命令 等々で調べてみてください。)

したがって、この種のバグは、OS側で処理が無限ループするなどの要因で発生していることが考えられます。これを描いたのが「今日も 下手したら 同じところを周ってばかりで」という部分です。

これを日常生活における類推に落とし込むと「明日から本気出す」などの、問題を先送りにする場面が想像できます。(耳が痛いです…。)

残念ながら「明日になればできる」と考えているときは、永遠に来ない相対的な「明日」に対して問題の先送りを繰り返し、目標が達成されないか、達成されたとしても非常に長い時間がかかることが経験上知られています。

このように、いくつかの事実から一般的な規則を導く手法を枚挙的帰納法といいます。ただ、枚挙的帰納法数学的帰納法とは異なり、導かれた規則が正しいとは限りません。(ということで希望を失わずにがんばりましょう!)

やりたいことばかりで リソース不足まっしぐらなの
コンテキストスイッチだけで 手一杯だよ

これ…ほんとこれなんですよ。

時間は有限な資源です。それはOSにおいても同様です。 CPUのコア一つ一つは、単純な操作を繰り返しているに過ぎず、同時に複数の処理を行うことができません。 そのため、並行して処理を進めるためには、何らかの工夫をする必要があります。 複数のコアを利用して処理を進める並列計算はひとつの手段ですが、それでもコア数分しか並行に演算を行うことができません。 そこで、ほとんどのオペレーティングシステムでは、あるコアの上で実行されるアプリケーションを短い時間間隔(これをタイムスライスといいます)で切り替えることにより、大局的にみれば複数のアプリケーションが並行して動作しているようにみせる機能を有しています。これを、マルチタスキングといいます。

あるCPU上で実行されているアプリケーションを別のアプリケーションに切り替える際には、現在のCPUの内部状態の一部(CPUコンテキスト)を保存してあげる必要があります。この作業をコンテキストスイッチといいます。 より具体的には、CPUのレジスタに入っている値を、各プロセスごとに別々に用意されたメモリ領域にコピーして退避するなどの処理を行なっています。

コンテキストスイッチもCPU上で実行されるプログラムにすぎませんから、その処理には一定の時間がかかります。

ここまでくれば、もう察しの良い方は気づいているかもしれません…そうです、もしも非常に多くのアプリケーションを単一のマシン上で実行しようとすると、コンテキストスイッチにかかる時間が非常に大きくなり、さらには各アプリケーションが実行される頻度もどんどん下がってゆくことになります。つまり、アプリケーションの処理がほとんど進まなくなってしまう可能性があるわけです。これを飢餓状態(resource starvation)といいます。

…ですから、やりたいこと(アプリケーション)の数が多すぎるとリソース(資源、ここではCPU時間)不足に陥り、コンテキストスイッチだけでCPU時間を食い尽くす飢餓状態になる、ということです。

みなさんもやりたいことスタックの大きさには気をつけておきましょう。(耳が痛いです…。)

ぐーるぐるぐる回る run queue
このスケジューラーは壊れてる
panic 寸前のシステムを
救えるのは君だけさ ほらね

さて、前項で説明したコンテキストスイッチを司るのは、OSの中でもスケジューラーと呼ばれる部分になります。スケジューラーは、実行可能だが現在CPU上では実行されていないタスクの一覧をrun queueとよばれるデータ構造に保持しており、その中から次に実行すべきタスクを選択して、そのプロセスにコンテキストスイッチを行うことで、タスクを順々に実行していきます。(ちなみにここではアプリケーション・プロセス・・タスクという用語が入り乱れていますが、これらの厳密な定義はOSの実装に依存しますので、まあとにかく「処理の管理単位」なんだなあ、と思っていただければ助かります。)

実を言うと近年のほとんどのOSでは、タスクごとの優先度やリアルタイムタスクの実行などを考慮するために、単純なqueueを用いてスケジューラーが実装されているわけではないのですが、シンプルなモデルとしてここではqueueで考えることにします。

queueの中には実行を待っているタスクが入っており、OSはそれらを順番に取り出してCPUに割り当ててゆくことでマルチタスクを実現するので、まるでタスクがキューの中をぐるぐる回っているかのような感じになります。

もしもスケジューラーにバグがあったら、あるプログラムが永遠に実行されなかったり、色々困ったことが起きるかもしれません。下手をすると、システムの動作に必要なプロセスまでもがいつまでも実行されず、OSが固まってしまう、ということもあるかもしれません。そういった、OSにとって重大な問題が発生して、回復できる見込みがない場合は、OSはエラーメッセージを出力して再起動します。これをpanicと呼ぶことがあります。(Windowsでいえばブルースクリーンのようなものです。)

panicが発生するようなギリギリの状態にOSがある際は、十中八九割り込みが禁止されています。このような状況下では、キーボード割り込みなどの通常の割り込みは受け付けられません。

でも、こんなときでもなんとかなる割り込みが実はあるのです。

すいみん睡眠preparation 最高優先度でよろしくね
すいみん睡眠preparation 問答無用で割り込むよ
すいみん睡眠preparation これは無視不可能なシグナル
すいみん睡眠preparation いますぐおふとんへIRET

x86には、NMIという割り込みがあります。これは、Non-Maskable Interruptの略で、CPUでたとえ割り込みを禁止していたとしても、この割り込みに関しては即座に処理が行われる、という特別な割り込みです。(実はAMDのハイパーバイザまわりの文脈ではNMIをマスクできるとかいうNMIのアイデンディティ崩壊級の面白い話があったりするのですがそこらへんは一旦忘れましょう。)当然、この割り込みは、あらゆる割り込みに優先して(他の割り込み処理を実行中でも関係なく)処理が行われます。つまり、無視できないシグナルなわけです。

みなさんが何かの作業に熱中し過ぎている時には、きっと割り込みは無効化されているのでしょう。たとえば、宅配便が届いてインターホンが鳴っても気づかなかったり、呼びかけられても気づけなかったりするのがその一例です。

しかし、時には何よりも優先して実行すべきことがあるケースがあります。それが睡眠です(要出典)。というかそうしたいですね。難しいですが。

言い換えれば、我々の生活の健全性を担保するためには「マスク不可能な、おふとんへ今すぐ行け割り込み」が必要なわけです。

で、IRETというのは、RETurn from Interrtuptという命令で、割り込み処理を終了し、割り込みが起こった際に実行されていたコードの実行を再開するという命令です。 この命令はコンテキストスイッチでも利用されているので、まあ「マスク不可能な、おふとんへ今すぐ行け割り込み」が発生したら、熱中していたタスクから一旦コンテキストスイッチして、睡眠プロセスへ切り替えるというのが重要ですよ、という話をここでしています。

たまにどうしても 無理なときは
仕方ないから 全部吐き出して
今度直せばいいのさ 
次はもっとうまくやれるはず

まあ、無事におふとんへ辿り着けたらいいわけですが、ときには睡眠失敗するケースもあります。そういうときはまあpanicするしかないわけですが、panicする際は、後でなぜpanicしたのかデバッグできるように、メモリの状態をダンプすることがあります。これをコアダンプと言います。また、コアダンプを「吐く」とか「吐かせる」という慣用表現があり(dumpなので)、つまるところどうしても眠れないなら、現在のメモリの状態(頭の中のもやもや)を全部紙かなんかに書き出して、次の修正の参考にするといいよ、ってことを言ってます。

プログラムに完璧はありません。常にバグがあります。なので、コアダンプは大事です。失敗しないことよりも、次の失敗を防ぐ方法をつくること、そしてそのデバッグを容易にする仕組みを用意することが重要です。

だから
すいみん睡眠preparation 入出力は一時停止で
すいみん睡眠preparation 机の上を片付けようね
すいみん睡眠preparation 頭の中身書き残したら
すいみん睡眠preparation おやすみ世界

こっちは正常系の話で、suspend to diskの処理を表現しています。 よく、コンピューターにおけるメモリは、作業をするための机として例えられます。机が広ければよりたくさんのことを同時に効率よくできる、というアナロジーから来ているのでしょう。 さて、コンピューターがsuspendする際には、電力消費を抑えるために、メインメモリの内容をディスクなどの不揮発性の記憶装置に書き出します。(メモリが不揮発性とは限らないよ!NVDIMMがあるよ!と叫んでいる私のことは無視してください。)というのも、通常のコンピューターのメインメモリとして利用されているDRAMは、電荷コンデンサにためるような感じでデータを保持しており、その電荷は時間経過で抜けてしまうため、定期的にデータを読み出して再度書き込むという操作(リフレッシュ)を行う必要があり、継続的に電力を消費するため、ここにデータを置いたままにしておくのは不都合だからです。

とはいえ、OSのほとんどの状態はメモリ上にありますから、OSが必要な処理、たとえば入出力装置とのやりとりなどがsuspend処理中に起こるとよろしくありません。というわけで、一般的にコンピューターをsuspendする場合には、アプリケーションの動作を停止し、さらに入出力装置を一旦止め、そしてメモリの内容をディスクに書き出し、電源を落とす、という処理になります。

みなさんが睡眠の準備をする際も、うっかりSNSなどを見て割り込み処理が発生してしまうと、睡眠失敗panic路線まっしぐらになる可能性が高いです。というわけで、この順序を守って、安全にシステムをsuspendするようにしましょう。

ということで、おやすみなさい!よい夢を!

光造形3Dプリンターはじめてみた(ELEGOO Mars 2 Pro)

きっかけ

この前の記事で、私もついに自作キーボードに足を踏み入れたわけですが、keyball46の個人的につらいポイントとして、キーがとても少ないというのが挙げられます。 というのも、私はこれまでErgoDox EZを使用していたため、キーの数が十二分にあるような環境に慣れすぎており、さすがにキーの数が76から46に激減するとかなりの厳しさを感じます。(もちろん慣れの問題だとは思いますが。)

せめて、親指あたりにもう数キーくらい欲しいところです。

さて、実はkeyball46をよく見てみると、キーマトリクス的には左右それぞれ6*4=24キーの合計48キーに対応できるはずなのに、左右共に1キーずつ不足していることに気づくはずです。

f:id:hikalium:20220105211520p:plain
実はそれぞれもう1キーずつサポートできる余裕がありそう

この不足しているキーに対応する接点は、keyball46のオリジナルのファームウエアでは、キーボードの左右を自動で検出するためのジャンパ代わりに利用されています。

f:id:hikalium:20220105211737j:plain
SWと書かれている接点(中央左下)が、欠けているキーにあたる

ですが、私は自前でファームウエアを書いたので、これらの接点はただ「どこにもつながっていない幻のキー」になっているだけなのです。かなしいですね。

…ということは、ここにスイッチを生やしたらもう1キーずつ手に入れられるのでは?

そう思った私は、3Dプリンタを買うことにしました(論理の飛躍が激しい)

買ったもの

数時間ネットの海をさまよった結果、ELEGOO MARS PRO 2という光造形方式の3DプリンターAmazonから34999円で購入しました。

また、光造形3Dプリンターは、紫外線を当てると液体から固体に変化するUVレジンというものを使用して物体を出力します。これには通常のレジンと水洗いレジンの2種類が大きく存在し、通常のレジンは印刷後にアルコール系の溶剤で余剰レジンを洗浄する必要があるのですが、水洗いレジンだと水で同じことができるため便利らしいと聞き、SK水洗いレジンという商品を購入しました。これはAmazonで1000mlが6980円]でした。

これだけあれば印刷自体はできるようになりますが、色々作業をするときに下に敷くバットだったり、洗浄時に使うタッパーだったり、掃除につかうペーパータオルとかアルコールティッシュとかも買っておくといいみたいです。他にも、二次硬化という、印刷後の物体を乾燥させた後に、より十分に固化させるために紫外線を当てる手順も場合によっては行われるので、そのための器具が色々販売されていますが、最悪太陽光に当てれば紫外線はタダで手に入りますし、必要になったらそのときに購入すればいいかなと思って、ひとまず私は最初の時点では購入しませんでした。

さらに、マスクやゴム手袋・ビニール手袋などの保護具もそろえておく必要があります。(上記の水洗いレジンはそこまで匂いはきつくないですが、それでもある程度は吸ったり触れたりすると有害なはずですので、忘れずに準備しましょう。)

あとは、使用後のレジンを濾過するための、ろうととか濾紙とか、濾過したレジンを入れるふたつきの容器とかあるといいかもしれないです。(私は一通りやってから必要だと学んだのでポチりました。)

開封からはじめての印刷まで

とりあえず、適当な場所に開封して設置してみた図がこれです。

f:id:hikalium:20220105213719j:plain
設置完了した図

組み立てる必要のある部分はほんの僅かで、基本的にはレジンを入れるタンクを紫外線を発するスクリーンの上にのせて、印刷物をくっつけて引っ張るプラットフォームを取り付けてあげるだけです。

ただ、この「プラットフォームを取り付ける」というステップが結構重要で、しかも取扱説明書の方法だとうまくやるのが難しいです。

ネットで調べたところ、手順としては

  1. レジンタンクをスクリーンの上におく
  2. プラットフォームの角度調整ネジ(六角レンチで回す2ヶ所のやつ)をゆるめる(手で回す固定ネジ自体はこの段階できっちり締めておく)
  3. プラットフォームをホームポジションに移動するボタンを押して下げる(大まかな向きはこの段階で合わせておく)
  4. プラットフォームの上下を微調整して、トレイの底≒スクリーンに密着するようにする
  5. 角度調整ネジを動かない程度にそっと締めて、Z=0をこの位置で設定する
  6. 少しプラットフォームを上げてから、ネジを本締めする(ネジを締める力でスクリーンが割れないようにするために浮かせている)

みたいな感じで調節するといいみたいです。(要するに、プラットフォームがZ=0にあるときに、スクリーンに接触している必要があるわけです。)

そんなわけで、付属しているUSBメモリをプリンタに挿して、ファイルを選択し(なんと説明書に書いてある拡張子とは違うやつを選ぶ必要があった…モデルのプレビューが表示されるファイルが印刷できるやつです(.ctbファイル))、あとは待っていると…

f:id:hikalium:20220105214914j:plain
印刷途中のようす(液体から固体ができあがってくるの、見てて楽しい)

こんな感じで生えてきます。(うまくいっていると、各層の露光が終わってプラットフォームが上がる時に、軽く「ペリペリッ」って音がします。これは、レジンタンクの底から、固化したレジンが剥がれて、プラットフォームの側についている証拠なので、これが聞こえたら成功の望みが高めな気がします。(必要条件でも十分条件でもないですが。))

さらに部屋を片付けつつ2時間37分くらい待つと、こんなかんじで出来上がります。

f:id:hikalium:20220105215154j:plain
2h37mで完成!(想像より早かった)

出来上がった物体はプラットフォームにくっついているので、これを同封されていたヘラで剥がします。

最初、力加減がわからなくて戦々恐々としていたのですが、プラットフォームは金属でできているので、プラットフォームの面を垂直にしてバットなどの上におき、同封されていた金属製のヘラをその面に添わせて印刷物の底を「えいやっ!」って何回か叩くと取れました。(ヘラとプラットフォームが削れてできた汚れが印刷物に若干ついてしまったが、まあ許容範囲でしょう。印刷物は印刷完了直後の段階ですでに結構硬いので、そこまで手加減しなくても大丈夫みたいです。)

f:id:hikalium:20220105215526j:plain
印刷物をプラットフォームから取り外した様子(最初はけっこう難儀した)

あとは適当な大きめの容器に水をいれて、そこに印刷物をいれて余剰レジンを洗い流し、ペーパータオルで拭けば完成です。わーい!

あとかたづけ系のTips

印刷後にどう片付ければ/次回の印刷の準備をすればいいのか、いまいち体系的な解説がなかったので、色々調べた結果私がやったことを書いておきます。

  • プラットフォームに残っている固化したレジンは金属のヘラで綺麗に除去し、アルコールティッシュなどで汚れをとる
    • 固形物が残っていると、プラットフォームが十分に下がらずスクリーンに密着しないので、最初の層がうまく生成されない原因になる
  • 印刷に失敗した場合は、固化したレジンがレジンタンクの底のフィルム(つまりスクリーン側)に付着しているので、レジンタンクの底のフィルムやディスプレイを破壊しないように手やプラスチックのヘラでそっと除去する
    • プリンターについているクリーニング機能を使うと楽。
      • これは、印刷面全体に紫外線を短時間(デフォルト15秒)照射することで、レジンタンクの底にきれいに1層固体化したレジンを生成してくれるので、それを剥がせばOKという機能。
      • 底に生成されたレジンの層をはがすために、適当にまあまあ丈夫な紙の切れ端とかを先にレジンタンクの底に触れるように入れてから、クリーニング機能の照射を行い、紙切れをひっぱりだすと、ペリペリと剥がれてくれる(たのしい)
    • 他の方法としては、固化したレジンは1層だけでも意外と硬いので、レジンタンクを外して下側から指で押してフィルムを少したわませてあげると、ぺりっと剥がれてくれる。
      • ただし、このときレジンタンクの外側にレジンがつかないように気をつけたほうがよい。そうしないと、レジンがスクリーンに付着して、それが次の印刷時に固化して取れなくなってしまう。そうなったらthe endなので、再度レジンタンクを設置する前にアルコールティッシュなどできれいにふいておいたほうがよさそう。
  • 印刷に失敗したりして、固化したレジンが混ざってしまったりしたときや、しばらく(1週間くらい?)印刷しないときは、一旦中身を全部外に出して、ろうとで濾過する。
    • 紫外線に当てなければ固化しないので、少しの間使わないくらいならそのまま放置でも大丈夫そう?(未検証)
  • 洗浄に使った水は、太陽光に当てるなどしてレジンを固化してから、固形物を濾過して除去し、残りの液は下水に流せばよいみたいです。固形物は燃えないゴミみたい。(レジンの取扱説明をよく読みましょう。)

3Dデータ作成Tips

適当にBlenderでモデルをつくってexportする際にいくつかハマりポイントがあったのでそれについてもメモ。

  • 最初にScene propertyのUnit scaleを0.001に設定しておく。こうしておかないと、Blender上と実際の印刷時の縮尺がずれる。逆に言えば、これを設定しておけばBlender内でcmとかの単位でモデリングすれば、それがそのまま出力される。

f:id:hikalium:20220105222505p:plain
BlenderのScene propertyの設定

  • エクスポート時はSTL形式を選ぶ。スケールはさっき設定したので、デフォルトの1.0で大丈夫。他のプロパティもデフォルトでOK。Apply ModifiersをOnにしておけば、ちゃんとModifierが適用された後のモデルが出力されるので、Blender上でModifierをApplyしておかなくても大丈夫なので便利。Selection Onlyにチェックを入れれば、選択したObjectだけを書き出せるので便利。

  • Blender上でのマイナスZ方向が、印刷時にプラットフォームに接する側になる。(もちろん、CHITUBOXでも回転はできるので後で修正もできるが、最初から合わせておくと楽。)

  • サポートをつけて印刷するの、むずかしい。接地面が小さいobjectを印刷するのは難しい。

  • でも上の図みたいな平面は割と簡単に出せる。Z軸方向に薄いオブジェクトだと印刷もはやい。

f:id:hikalium:20220105223357j:plain
適当にBlenderでつくったモデル(test.blend)の出力結果

  • 印刷物をプラットフォームから剥がしやすいように、モデルの接地面側に隙間をつくっておくと楽かも?(test.blendでは角をひとつ隙間があくようにカットしたところ、かなり楽に外せたのでよかった。)

3Dデータをスライスして3Dプリント可能にするCHITUBOXというソフトの注意点

  • プリンタのプロファイル設定はちゃんとしましょう(最初忘れててよくわからなくなった)
    • ファイル形式もどうやらプロファイル設定に依存して自動で変わるみたい
    • とりあえずデフォルト設定でふつうに簡単なモデル(上に示したtest.blendのようなもの)なら印刷できた
  • 接地面が小さいと難易度が上がるのでがんばりましょう(サポートを生やすなどの工夫が必要みたいだが私の鍛錬が足りないのでまだ印刷には成功していない)

生成物のクオリティについて

  • かなりクオリティは高い。積層痕が全然見えないレベル。サンプルデータのチェスのコマの天面に印刷されている、2mmくらいの大きさの文字もばっちり見えている。
  • 使用するレジンにもよるとは思うが、見た目や感触は完全に切り餅みたいな感じ。だが切り餅よりも硬い。(年末年始なのでなおそう見える(なお私は今季まだ一度も餅を食べていない…。))
  • 出力された物体はかなり硬い。厚さ6mmもあれば全くしならない。
  • 精度もかなり高い。test.blendにはキースイッチがはまる穴をあけたが、ふつうに綺麗にはまって衝撃を受けた。(乾燥や時間が経つにつれて収縮はあるかもしれないが、未確認。)
    • ただし、最初の層は他の層にくらべて硬く、さらに多少XY平面方向に太る。各方向に0.1~0.2mmくらい。これは初期層は他の層にくらべて露光時間が長く設定されているためである。

f:id:hikalium:20220105225053j:plain
キースイッチをはめてみた図。引っ張るとキーキャップの方が先にとれるレベルでしっかりはまっている。

まとめ

もっと早く買えばよかった。みんなも3Dプリンタを買ってあそぼう!

2021年の振り返り

今年は何があったかを振り返ってまとめておく。

1月

やっとNoogler Hat (物理)が届いた

2月

Writing an OS in Rust 輪読会をやっていた(Pagingの回を担当した)

3月

hikalium(物理)をPixivFACTORYで発注したのが届いた

あと前の年にもらっていた賞の賞状が届いた

4月

nekoi7yuさんと一緒に低レイヤ雑談する配信をした

www.youtube.com

あと私のL2キャッシュ(本棚)が低レイヤすぎて、みんな結構面白がってくれた

そしてKOBA789氏と共に、自作RDBMSを自作OSに移植するコラボ配信をした

ダイジェスト版も789氏が作ってくれた

5月

サイボウズ・ラボユース10周年記念の対談記事が公開された。

gihyo.jp

6月

KAT WALK Cが届いた(これでVR内で走れる!)

とてもでかいです。

あとLinuxの動くRISC-Vマシンで少し遊んだ(もっと遊んであげなきゃ…)

あと人々にmallocを実装させるなどしていた

7月

hikaliumのファンアートが初めて作成された(うれしかった!)

そしてさらに散財機材がアップデートされた

あとはRasPiと低いレイヤでおはなしして仲良くなった

hikalium.hatenablog.jp

8月

kurenaifさんにPOODLE Attackを教わった(させていただいた)

FLAGがとれたときの様子

この時期の作業環境はこんな感じだった

あと2回目のセキュリティアップデートをインストールした際のログがこちら

そしてセキュキャンの時期が近づく…(Tシャツのデザイン決めにほんの少しだけ関わった)

背中の暗号はみなさん解けましたか?

そしてお仕事で最近やっていたことについて話した。低レイヤな仕事、一緒にやりませんか!

www.youtube.com

スライドはここから辿れます。

あと、WebMIDI経由でnanoKONTROL2と戯れたりもしていた。

9月

hikaliumロゴアクリルキーホルダーの試作品が届いた

なんとここから買えます(この記事執筆現在、8個くらい売れていて驚きました)

セキュキャンのOS自作ゼミでポインタについて熱く語るなどの社会貢献をおこなった(?)

そしてついに低レイヤーガールチャンネルの収益化が承認された!(ありがとうございます!)

あとInfraStudyという勉強会で、人々を低レイヤーへ誘い込むオタク早口トークをした

www.youtube.com

10月

語り得ぬものについては沈黙せねばならない。

童心に返ってスライムをmake (smileではない)

がんばったので昇進して「(ふつうの)Software Engineer」になりました。(入社から約1年半でなので、まあまあ早いほう。)

お給料結構上がってびっくりした。

C言語はおしまいです(?)

JINS MEMEというIoTメガネで遊び始めた

そしてついに、ついに、1年半のおあずけを経て、健康を享受しはじめた!

ハロウィーンも低レイヤでした

11月

789氏のJINS MEME hackの結果を再利用させてもらって、hikaliumの頭をぐるぐるさせるなどした

12月

そしてキーボードが増えた

PiyoConfというイベントでライブLinuxカーネルいじりをしてみた

www.youtube.com

unameシステムコールを無理やり改造することでLinuxがPiyoxになったのでした

その余波でスパチャの嵐を受けた…(ありがとうございます!)

ラテアートを作ったり

OS自作の歌を作って歌ったり(生声注意)

www.youtube.com

キーボードを作るはずが、買うパーツを間違えたのでRustでファームウエアを書いたりしていた。詳細な顛末は別記事にて。

hikalium.hatenablog.jp

通年

  • 低レイヤーガール - YouTube
    • d0iasm氏と一緒に、自作ブラウザとか自作OSをする配信を土曜日に不定期開催している
    • 12/25に、おすすめのコンピューター関連書籍について語り合う配信をしたので、もし興味があればご覧ください!

youtu.be

  • Writing an OS in Rust 輪読会
    • Writing an OS in Rustという記事をみんなで交代で読んでゆく会。ひとまず掲載されている分の記事はすべてやってアーカイブが残っているので、興味のある方はぜひ。再生リストはこちら

www.youtube.com

  • Rust Raspberry Pi OS Tutorials 輪読会

  • 自作OSもくもく会

    • osdev-jpとして、uchan_nos氏と一緒に開催している。今年は5回開催された。隔月開催が目標だったので、まあまあといえよう。 発表の録画も発表者の許可があるものについては公開している。(まだアップロードしてないものがあるかも。)

www.youtube.com

まとめ

意外と色々やってたんだなあ。えらい!

(毎年思っていることだけど)来年はゆっくり過ごしたいと思います…。

もっと気楽に生きたいですね。なかなか難しいですが。

hikaliumと関わりのあった皆様、今年も大変お世話になりました。来年も、よいお年をお過ごしください!

Keyball46組み立て日記 〜間違えてRP2040を買ったそこのあなたに〜

TL; DR

  • Keyball46はいいぞ
  • ProMicroにはAVR版とRP2040版がある
    • ピン配置の互換性はあれど、命令セットは完全に異なるので「ファームウエアを書けとささやくのよ、私のゴーストが」という方以外はAVR版を間違えずに買いましょう
  • まあ最悪間違えても一週間くらいでなんとかなる(当社調べ)
  • みんな自作キーボードをやろう!

はじまり

みなさんは、Keyball46という素晴らしい自作キーボードキットをご存知ですか?

そう、なんと、キーボードにトラックボールがついてるんです!最高ですよね!

ということで、秋葉原にある自作キーボードのお店こと遊舎工房さんから速攻でポチりました。ちょうど安いキーボードを一個水没させたところですし、自作キーボードも本格的にやりたかったし、年末休みで暇を持て余していますからね。

期待に胸を躍らせつつ、はじめての表面実装ダイオードのハンダづけを難なくこなし、

さて、動作チェックとしゃれこみますか、と思ってファームウエアを焼こうとしたところ、自分の犯したミスに気づきます。

はい、パーツを間違えました。ゲームオーバーです。…本当に?

どこで道を踏み外したのか

さて、Keyball46の組み立て説明には、このように書かれていました。

(念の為、これは組み立て説明の落ち度ではなく、私の無知が原因です。私が失敗に至るまでの思考過程をお伝えするために引用しており、責任は私にあります。)

1-2.組立前にお客様自身で準備いただく部品

...

ProMicro 2個 安価なものやUSB Type-C対応のもの等選べます

...

…なるほど!Pro Microっていうマイコン基板を買えばいいのね! 遊舎工房で「Pro Micro」検索、っと…。

f:id:hikalium:20211230144749p:plain
遊舎工房での検索結果(当時)

お、何種類かあるんだ。せっかくだし USB Type-C のがいいなあ、これを買うか!(SparkFun Qwiic Pro Micro - USB-C (ATmega32U4)の商品ページに飛んでカートに入れようとする)

(ここでも念を押しておきますが、すべての落ち度は買うものを間違えた私にあります。みなさんは適当にポチらずにきちんと調べてから買いましょう。)

f:id:hikalium:20211230145141p:plain
売り切れ - SparkFun Qwiic Pro Micro - USB-C (ATmega32U4) - 遊舎工房

あれ、これは売り切れかあ。半導体不足の影響もあるのかなあ。じゃあこっちはどうかな?(もう一個の似た見た目のやつをクリック)

f:id:hikalium:20211230145337p:plain
商品がカートに追加されました! - SparkFun Pro Micro - RP2040 - 遊舎工房

わーい、買えそうだ!これで行こう!楽しみだなあ!!

…おわかりいただけただろうか。(ここが運命の分かれ道だった。)

Pro Microの種類

実はPro Microと名のつくボードには、コネクタの形状や互換品か否かという些細な点を除けば、大きく分けて少なくとも2種類が存在します。

見た目は非常に似ているのですが、何がちがうのかというと、載っているマイコンが根本的に違います。

前者はATmega32U4, つまりAVRマイコンです。 後者は、RP2040…これはRaspberry Piと同じ、ARMマイコンです。

はい、アーキテクチャからして完全に異なりますね。

さて、Keyball46が標準で提供しているファームウエアは…

# MCU name
MCU = atmega32u4
...

github.com

はい、ATmega32U4用ですね。おつかれさまでした。

「ファームウエアを書けとささやくのよ、私のゴーストが」

さて、困りましたね。再度ATmega32u4のPro Microを注文するのも時間がかかるだろうし、たとえそうしても、今手元にあるこのRP2040は無駄になってしまいます。それはマイコンがかわいそうですよね。

「…ファームウエアがないなら、書けばいいじゃない!」

たしかに!そうしよう!(白目)

方針を考える

とりあえず脳内で盛り上がってるマリーアントワネットと草薙素子は放置して、さっさと先に進みましょう。

最初はQMK firmwareをRP2040向けにビルドできないかとか少し調べたのですが、面倒なのでやめました(面倒とは!?)

あとベアメタルな環境でRustを書きたかったんです、はい。

幸い、ファームウエアのソースコードも回路図も公開されています。なんとかなりそうですね。(keyballの作者であるYowkeesさん、ありがとうございます!)

きっとRP2040のサンプルコードはインターネットにたくさん転がっているでしょうし、まずはそれを試してから、Rustでどう書くか考えることにしましょう。

まずは Hello, world だよね

そもそも、プログラムの書き込み方からわからないマイコンを相手にファームウエアを書き始めるのは無謀というものですから、まずは入門からやっていきましょう。

幸い、ネットの海を調べたところ、RaspberryPi公式でサンプルプログラムが公開されていました。

github.com

しかも、いくつかのプログラムはprebuiltのバイナリまで提供されています。便利!(上記リンク中のテーブルの Link to prebuilt UF2 というカラムから飛べます。)

とりあえずhello_usbのプログラムを書き込んでみましょう。

…え、どうやって書き込むの?

BOOTSEL mode, picotool

とりあえずネットの海を探ると、picotoolというCLIツールが見つかり、ボードをBOOTSELモードというのに持っていくと、picotoolを使ってプログラムを書き込める(もしくはストレージがUSBメモリのように認識されるので、そこにファイルをコピーする方法も可)ということがわかりました。

BOOTSELモードの入り方は、ボードの提供元であるSparkfunのページに画像付きで書かれています。

learn.sparkfun.com

基本的に、ボード上にある2つのボタンはRESETボタンとBOOTSELボタンになっているので、BOOTSELを押しながらRESETを押せばBOOTSELモードでボードが起動してプログラムが書き込めます。

というわけで、いずれかの方法でhello_usbのバイナリ(hello_world.uf2)を書き込むと、なんとPro MicroがUSB modemとして振る舞い始めます。

$ ls /dev/tty.usb*
/dev/tty.usbmodem0000000000001

これの出力をscreenコマンドで見てみると、

$ screen /dev/tty.usbmodem0000000000001
Hello, world!
Hello, world!
Hello, world!
...

OK, RP2040の世界にこんにちはできました。

でもね、そのボタン、押せないの…

さて、じゃあこの調子でファームウエアを書いていきましょう…と言いたいところなのですが、一つ困ったことがわかりました。

この画像を見てください。(逆転裁判で証拠品を提示するときの効果音)

f:id:hikalium:20211230153257j:plain
Pro Microの裏表に注目…(ボタンが押せない!)

「この写真には、制作途中のKeyball46にPro Microが挿さっている様子が写っています。」

「いたって普通のプリント基板とPro Microですね。何か問題でも?」

「Pro Microの部分を見てください。何かに気づきませんか?」

「いえ、いたって普通のPro Microだと思いますけど。先ほどあなたがおっしゃっていた通り、RP2040版であるという点は異常ですが、それはファームウエアを書くという話で決着がついたはずですよね。さっさとファームウエアを書き始めたらどうなんですか?」

「ええ、私もそうしたいところです。ファームウエアが書き込めれば、の話ですが。」

「そんなのボタンを押せばいいってさっき…ああああああっ!!!」

「そう、ないんですよ。ボタンがね。」

…おわかりいただけただろうか?なんとこの基板、Pro Microを裏面に装着するように設計されているため、ボタンが押せないのです。

AVR版のPro Microだと、電源投入時にリセットボタンを長押しや連打することで書き込みモードに移行できるため、リセットボタンはタクトスイッチで基板上から操作できるように設計されているのですが、RP2040版の場合は、リセットボタンだけではなくBOOTSELボタンを押す必要があるのです。しかし、それは押すことができない!

ラズパイでラズパイにプログラムを書き込む - SWDって便利だな

ソフトウエア開発において、開発とデバッグの繰り返しを迅速に行うことは、開発効率の面からも非常に重要です。 最悪、Pro Microを毎回外して書き込み、そして基板上に戻す、ということも、コンスルーピンのおかげでできなくはないでしょうが、非常に面倒ですし、そもそもそう何度も抜き差ししていたら接触不良の原因になるでしょう。なにしろやる気がダダ落ちです。

では、どうするか…上の画像を見ると、Pro Microの下側、qwiicと書いてあるところの上に、DとCと書かれたランドが露出しているのが見えます。

これは一体なんでしょうか?

SWD Pins For advanced users, there are two pins (i.e. D for data/SWDIO and C for clock/SWCLK) on the back broken out for SWD programming on the back of the board. You'll need to solder wire to connect to these pins.

Pro Micro RP2040 Hookup Guide - learn.sparkfun.com

ええ、私たちはAdvanced usersなので、これを使いましょう(満面の笑顔)。

CoreSight Architecture | Serial Wire Debug – Arm Developer

ARM社のドキュメントによれば、SWD(Serial Wire Debug)を利用すると、JTAGと同じようなことができる、と書いてあります。

まあ簡潔にいえば、これをよしなにopenocd経由で操作してあげれば、レジスタ読み書きし放題、メモリ書き換え放題、要するになんでもやりたい放題できるということです。

まあファームウエアを開発するんだから、これくらいやってもよいでしょう。

まずはさくっとジャンパワイヤをSWDのランドにはんだ付けして、

f:id:hikalium:20211230171833p:plain
SWD の D, C および GND にジャンパワイヤを接続したようす

もう一台どこのご家庭にも転がっているRaspberry Piを持ってきて、DをGPIO24, CをGPIO25に繋ぎます。(このピンアサインは、openocdに付属しているconfigファイルに定義されています。)

forums.raspberrypi.com

あとは、Raspberry Pi上で、Raspberry Piのフォーラムに書いてあったいい感じのopenocdのコマンドラインを参考に適当にopenocdさんを呼び出すと

openocd -f interface/raspberrypi-swd.cfg -f target/rp2040.cfg -c '"targets rp2040.core0; program ${PATH_TO_ELF} verify reset exit"

${PATH_TO_ELF}に指定したプログラムが書き込まれてターゲットが再起動します。これは便利!(書き込まれたバイナリはちゃんと永続化されているので、次回以降の起動時もこれが使われます。)(先ほど紹介したprebuiltのUF2ファイルはこの方法では読めないので、サンプルを手元でビルドして、UF2に変換される前のELFファイルを指定してあげてください。)

よし、これでPro MicroをKeyball46の基板に挿しっぱなしでも大丈夫になりました。やっとファームウエアを書くステップに行けますね!

RustでRP2040をターゲットにしたプログラムを書く

先程紹介した raspberrypi/pico-examples は、残念ながらC言語で書かれています。

せっかく書くなら、もっと現代的な言語で書きたいですよね?ということで調べてみると、RP2040向けのHALを提供してくれるcrateが見つかりました。

github.com

フルスクラッチで書くのも面白そうですが、動くとわかってからでも遅くはないので、ひとまずはこれを使うことにしましょう。

先ほどと同様に、まずはサンプルを試してみます。

boards/sparkfun-pro-micro-rp2040/examples/sparkfun_pro_micro_rainbow.rs に、Pro Micro (RP2040) 用のサンプルがあるので、これをREADME.mdに書いてある通りにビルドします。

cargo build --release --example sparkfun_pro_micro_rainbow

そうすると、target/thumbv6m-none-eabi/release/examples/pro_micro_rainbowにELFファイルが生成されるので、これを先程の方法で書き込んでみます。すると、LEDが虹色に光り始めます。やったね!(下の画像で緑色のLEDがそれです。)

f:id:hikalium:20211230180209p:plain
RustでRP2040のLチカ!

USB HID として振る舞いたい

さて、私たちはもう終わったクリスマスの電飾を作りたかったわけではなくて、キーボードとマウスとして振る舞う何かを作りたかったのでした。

これを達成するためには、Pro Microさんに USB HID として振る舞ってもらう必要があるのですが、それをゼロからちょっと実装するのは大変です。

そこで、rp-rs/rp-hal内の他のboard向けのサンプルを眺めてみたところ、boards/rp-pico/examples/pico_usb_twitchy_mouse.rs という、まさにこれがほしかった!みたいなサンプルが見つかりました。なので、これをさくっとsparkfun_pro_micro向けに移植してあげればOKです。

実はpico向けにビルドしたバイナリを書き込むと普通に動いてしまうんですが(まあ同じSoCを使ってるからそれはそう)、せっかくHALがボードごとにあるので、ちょっと手直ししてあげるといいと思います。(適当にやった。)

はい、これで「PCに繋ぐとマウスが小刻みに上下するので画面ロックを回避できる邪悪バイス」ができあがりました。意外と簡単だ!

キーボード機能を追加する

ではこの調子でBadUSBキーボード入力もできるようにしましょう。まずは、さっきのサンプルの実装を読んでみます。

    // Create a USB device with a fake VID and PID
    let usb_dev = UsbDeviceBuilder::new(bus_ref, UsbVidPid(0x16c0, 0x27da))
        .manufacturer("Fake company")
        .product("Twitchy Mousey")
        .serial_number("TEST")
        .device_class(0xEF) // misc
        .build();
    unsafe {
        // Note (safety): This is safe as interrupts haven't been started yet
        USB_DEVICE = Some(usb_dev);
    }

ここでデバイスを作って、

    // Move the cursor up and down every 200ms
    loop {
        delay.delay_ms(100);

        let rep_up = MouseReport {
            x: 0,
            y: 4,
            buttons: 0,
            wheel: 0,
            pan: 0,
        };
        push_mouse_movement(rep_up).ok().unwrap_or(0);

        delay.delay_ms(100);

        let rep_down = MouseReport {
            x: 0,
            y: -4,
            buttons: 0,
            wheel: 0,
            pan: 0,
        };
        push_mouse_movement(rep_down).ok().unwrap_or(0);
    }

あとはここでマウスの動きをpushしているようです。push_mouse_movementの実装は、以下のような感じです。

fn push_mouse_movement(report: MouseReport) -> Result<usize, usb_device::UsbError> {
    cortex_m::interrupt::free(|_| unsafe {
        // Now interrupts are disabled, grab the global variable and, if
        // available, send it a HID report
        USB_HID.as_mut().map(|hid| hid.push_input(&report))
    })
    .unwrap()
}

ここで、自作OSでUSB HIDドライバを実装していた際の知識が役に立ってきます。

まず、USB HID では、マウスの移動やキーボードの押し下げなどの情報は、レポートというデータとしてデバイスから送られてきます。

そして、ここが重要なのですが、USBはプロトコル的に、常にホスト側からしかデータの転送を開始できない仕組みになっています。これはどういうことかというと、USBプロトコルのレイヤでは、割り込みという概念が存在せず、ポーリングしかできない、ということです。

ですが、キーボードやマウスが動くのは(CPUさんの時間軸から見たら)稀なので、ポーリングをするのは非効率的な気がしますよね?そこで、USBでは、USBコントローラがUSBデバイスに対して定期的にデータ転送を要求して(ポーリング)、データがあればUSBコントローラからCPUに割り込みを発生させる、という仕組みになっています。

というわけで、上のpush_mouse_movementが呼ばれても、実際にはまだホスト側にはその情報は伝わりません。その代わり、USBコントローラーが定期的に読んでくるとデバイス側で割り込みが発生するので、そのタイミングでデータを送りつけてあげることになります。

#[allow(non_snake_case)]
#[interrupt]
unsafe fn USBCTRL_IRQ() {
    // Handle USB request
    let usb_dev = USB_DEVICE.as_mut().unwrap();
    let usb_hid = USB_HID.as_mut().unwrap();
    usb_dev.poll(&mut [usb_hid]);
}

これが、その「デバイス側の割り込みハンドラ」です。これが呼ばれるタイミングで、デバイスからホストへとデータ転送が走ることになります。

この割り込みの周期については、もっと上の方で

    let usb_hid = HIDClass::new(bus_ref, MouseReport::desc(), 60);

とすることで、HIDClassを初期化する際に、60msに設定されています。詳細は、docs.rsにあるドキュメントを読むとわかりやすいと思います。

ここまでが、大まかなサンプルのデータの流れです。

では、これを改造して、キーボードの情報も送るにはどうすればよいでしょうか?

usbd-hidのドキュメントをさらに読むと、MouseReportの隣にKeyboardReportがあることがわかります。これを使えばなんとかいけそうですね。

見よう見まねで、こんな感じにしてみます。(振り返って書いているので雰囲気だけ掴んでください。)

static mut USB_HID_KEYBOARD: Option<HIDClass<hal::usb::UsbBus>> = None;

fn main() -> ! {
    ...
    let usb_hid_keyboard = HIDClass::new(bus_ref, KeyboardReport::desc(), 10);
    unsafe {                                                                                                                                                           
        USB_HID_KEYBOARD = Some(usb_hid_keyboard); 
    }
    ...
    loop {
      ...
      let rep = KeyboardReport {
          modifier: 0,
          reserved: 0,
          leds: 0,
          keycodes: [0x04 /* A */, 0, 0, 0, 0, 0],
      };
      push_key_event(rep).ok().unwrap_or(0);
      ...
    }
    ....
}

pub fn push_key_event(report: KeyboardReport) -> Result<usize, usb_device::UsbError> {
    cortex_m::interrupt::free(|_| unsafe {
        USB_HID_KEYBOARD.as_mut().map(|hid| hid.push_input(&report))
    })
    .unwrap()
}

...

#[allow(non_snake_case)]
#[interrupt]
unsafe fn USBCTRL_IRQ() {
    // Handle USB request
    let usb_dev = USB_DEVICE.as_mut().unwrap();
    let usb_hid_mouse = USB_HID.as_mut().unwrap();
    let usb_hid_keyboard = USB_HID_KEYBOARD.as_mut().unwrap(); // new
    usb_dev.poll(&mut [usb_hid_mouse, usb_hid_keyboard /* new */]);
}
...

KeyboardReportのkeycodesについては、HID Usage Tables FOR Universal Serial Bus (USB)10: Keyboard/Keypad Page (0x07) に定義があります。また、ここにpdfよりも軽い表があります。

これで、マウスの上下移動とともに、aが入力され続けるはずです。

実際、動きました。…Linuxでは。

しかし、なぜかmacOSではマウスもキーボードも動かなくなってしまいました。一体なぜでしょうか?

Boot keyboard のフォーマットに切り替える

osdev.orgの記事によれば、USBキーボードのReportの形式は、

pub struct KeyboardReport2 {                                                                                                                                                                                                                                                                              
    pub modifier: u8,                                                                                                                                                                                                                                                                                     
    pub reserved: u8,                                                                                                                                                                                                                                                                                     
    pub keycodes: [u8; 6],                                                                                                                                                                                                                                                                                
}

のような形式になっているようです。…あれっ、先ほど利用したusbd-hidのKeyboardReportには存在する、ledsのフィールドがないですね…。

これは完全に想像ですが、macOSではUSBキーボードのレポートディスクリプタをきちんと解釈せず、Boot protocolに沿っていると仮定しているのではないでしょうか。

レポートディスクリプタというのは、Reportがどのような形式になっているのかをUSBデバイスがUSBホスト側に伝えるためのデータで、

    let usb_hid_keyboard = HIDClass::new(bus_ref, KeyboardReport::desc(), 10);

この行の KeyboardReport::desc()がそれにあたります。このdescは、ソースを確認すると

#[gen_hid_descriptor(
    (collection = APPLICATION, usage_page = GENERIC_DESKTOP, usage = KEYBOARD) = {
        (usage_page = KEYBOARD, usage_min = 0xE0, usage_max = 0xE7) = {
            #[packed_bits 8] #[item_settings data,variable,absolute] modifier=input;
        };
        (usage_min = 0x00, usage_max = 0xFF) = {
            #[item_settings constant,variable,absolute] reserved=input;
        };
        (usage_page = LEDS, usage_min = 0x01, usage_max = 0x05) = {
            #[packed_bits 5] #[item_settings data,variable,absolute] leds=output;
        };
        (usage_page = KEYBOARD, usage_min = 0x00, usage_max = 0xDD) = {
            #[item_settings data,array,absolute] keycodes=input;
        };
    }
)]

このようなマクロによって生成されています。

おそらく、Linuxはこれを解釈して正しくReportを読み取れるのですが、macOSは期待しているデータ構造と送られてきたReportが一致しないため、そこであきらめてしまっているのではないかと考えられます。

というわけで、osdev.orgやUSBのドキュメントに記載されている、Boot protocolの形式のほうを利用することにしましょう。これで、macOSでもLinuxでも動きます!

その他のハマりポイント

勢いで書いていたらめっちゃ記事が長くなってきたので、残りはさくっといきます。もし詳しく知りたい方がいらっしゃったら、Twitterなどでhikalium宛にリプライを飛ばすか、低レイヤーガールチャンネルの配信の際にでもコメントで聞いてください。

トラックボール基板がお返事してくれない…

Pro Micro にピンをつけた際、はんだが流れて NCSとMOSIがショートしてた

状況: トラックボール制御基板さんにSPI通信でお話ししても0xFFしか返ってこない。ロジアナで見たらNCSとMOSIが仲良く同じ波形を出してた。

f:id:hikalium:20211230185805p:plain
上から順にNCS, CLK, MOSI, MISOの波形

解決策: 一回該当するピンのハンダを除去してやりなおし。

NCSがトランザクションが終わるよりも前にhighに戻っちゃう

状況: ショートを直してもまだお返事もらえない。波形を見てみると、NCSが一瞬しかlowになってなかった。

原因: rp-rs/rp-halのSpi::writeは、データレジスタに書き込むだけで、送信完了を待ってはくれないのであった。(涙)

解決策:

rp-halをforkしてSpi::deviceをとれるようにし、それを直接使ってステータスレジスタを読み、sendが完了するのをloopで待つようにした。

        loop {
            let sspsr = spi.borrow_mut().device().sspsr.read();
            if sspsr.tfe().bit_is_set() && sspsr.bsy().bit_is_clear() {
                break;
            }
            delay.borrow_mut().delay_us(10);
        }

まとめ

とりあえず動くようになった。

f:id:hikalium:20211231145356p:plain
完成した図。トラックボールもキーボードも動く!(SWDの線がまだ生えてるのはご愛嬌)

まだキーマップが練りきれていないのと、TRRSケーブルを介した接続には対応していないほか、私はこれまでErgo Dox EZを使っていたので、いかにこのキーの少なさで生きていくかなど課題は山積みですが、少しずつ実運用に投入していきたいと思います。(この記事の一部はこれで書きました。)

自作キーボード、たのしい!みんなもやろう!!

謝辞

Keyball46を設計されたYowkeesさん、自作キーボードに必要なパーツを販売している遊舎工房さん、そして数年前のCookpad自作キーボードインターン(?)で自作キーボードの基礎を教えてくれたKOBA789さん、ありがとうございました!

成果物

github.com

Raspberry Pi 4をJTAGデバッグしてみる(FTDI C232HM-DDHSL-0使用)

f:id:hikalium:20210718212859j:plain
JTAGアダプタとシリアルケーブルがつながったRaspberry Pi 4

概要

Raspberry Piでベアメタルプログラミングをするときに、CPUが今実際にどこの命令を実行しているか、メモリ上にどのような値が存在するか…などの情報を確認できると、デバッグが非常に楽になります。それを可能にしてくれるのが、JTAGというインターフェイスです。

今回は、FTDI社製のケーブルを使用して、Raspberry Pi 4のJTAGにアクセスしてみました。前半部分はケーブル固有の話もあるので、他のJTAGケーブルをお持ちの方は、それぞれのマニュアルを参照して適宜読み替えていただく必要があります。一方後半部分は、openocdを使ってJTAG経由でRaspberry Pi 4の情報を取得する一般的な方法について説明しますので、みなさんの環境でも役立つかもしれません。参考にしていただければ幸いです。

主な登場人物

  • Raspberry Pi 4 Model B
    • BCM2711というSoCが載っています。
  • FTDI C232HM-DDHSL-0
    • このケーブルはMPSSE(Multi-Protocol Synchronous Serial Engine) といって、モードを切り替えればJTAG以外にもSPIやI2Cといった通信もできるようです。
    • C232HM-DDHSL-0とC232HM-EDHSL-0の違いは、通信に使う電圧レベルです(3.3Vと5.0V)。Raspberry PiのGPIOは3.3Vなので、間違えないようにしましょう。
  • FTDI TTL-232RG-VREG3V3-WE
    • このケーブルはJTAG通信には関係ないですが、Raspberry PiのUART(シリアルポート)を読むために使っています。ベアメタルプログラミングするなら、基本的にシリアルは生命線になるので、シリアルを読めるケーブルは何らかのものを持っておくとよいと思います。
    • 例によって、これも電圧レベルによって似た品番のものがあるので、他の変換ケーブルやボードを使う際も、3.3Vのものを買うようにしましょう。
    • ちなみにこのケーブルは末端が電線剥き出しでコネクタがついてないので、私はそれをブレッドボードに刺してそこからRasPiに延ばしています。コネクタがついているタイプのものも他の製品だとあるようなので、そっちを買った方が便利かもしれません。

決戦結線の時

というわけで早速ケーブルとRasPiをつないでゆきます。RasPiの電源を落としてから作業しましょう。

落とし穴としては、RasPiのGPIOピンの番号と、物理ピン番号(GPIOヘッダ上の位置)は異なる、というところです。

詳しくはRaspberry Pi 公式のドキュメントに解説があるので、それをチェックしてください。

(以下はあくまでも参考として、鵜呑みにせずに自分で本当に合っているかチェックしながら接続してください!)

JTAG ⇔ C232HM-DDHSL-0

ケーブル自体のデータシートも参考にしてください。

ケーブルの色 GPIOピン番号 物理ピン番号 GPIOヘッダを右上においたときの場所
Brown (TMS) 27 (ARM_TMS) 13 上から7番目の左
Grey (TRST) 22 (ARM_TRST) 15 上から8番目の左
Blue (RTCK) 23 (ARM_RTCK) 16 上から8番目の右
Green (TDO) 24 (ARM_TDO) 18 上から9番目の右
Orange (TCK) 25 (ARM_TCK) 22 上から11番目の右
Yellow (TDI) 26 (ARM_TDI) 37 上から19番目の左=下から2番目の左
Black (GND) GND 39, etc... 一番左下など

RTCKを使わない記事もインターネット上にはありましたが、これをつなぐと通信が格段に安定したのでこのようにしています。

UART ⇔ TTL-232RG-VREG3V3-WE

ケーブル自体のデータシートも参考にしてください。

ケーブルの色 GPIOピン番号 物理ピン番号 GPIOヘッダを右上においたときの場所
Yellow(RXD) 14 8 上から4番目の右
Black(GND) GND 9, etc... 上から5番目の左など
Orange(TXD) 15 10 上から5番目の右

接続イメージ

全部つないでみたときの参考写真を貼っておきました。(写真の向きは、GPIOヘッダを左上にみたときの例なので、上の説明と対応させる場合は、90度首を傾けてみてください。)

シリアル変換ケーブルから伸びている線は、写真上側のブレッドボードでジャンパワイヤに変換されてきているので、上の説明に書かれている色と、写真中の線の色は一致しません。

画面左側、JTAGアダプタから伸びている線の色は、上に書いてある説明と一致します。

f:id:hikalium:20210718213206j:plain
JTAGアダプタとシリアル出力の接続例

ソフトウエアの設定

今回の環境は macOS 11.4ですが、Linuxなどの他の環境でも、似たような設定をすれば動作するはずです。

openocdをインストールする

最新のopenocdをHomebrew経由でインストールします。

brew install openocd --HEAD

私の環境では、以下のようなエラーメッセージが出ました。

$ brew install openocd --HEAD
...
==> Installing open-ocd --HEAD
==> ./bootstrap nosubmodule
==> ./configure --prefix=/usr/local/Cellar/open-ocd/HEAD-cff0e41 --enable-buspir
==> make install
Error: An unexpected error occurred during the `brew link` step
The formula built, but is not symlinked into /usr/local
Cannot link open-ocd
Another version is already linked: /usr/local/Cellar/open-ocd/0.10.0
Error: Cannot link open-ocd
Another version is already linked: /usr/local/Cellar/open-ocd/0.10.0

このエラーは、すでに他のバージョンのopenocdがインストールされている場合に表示されます。この場合、インストールした最新のopenocdにはPATHが通っていない状態になっていますので、パスを直接指定して実行してあげる必要があります。(もちろん、強制的にPATHを通す(link)することも可能ですが、各自の判断にお任せします。)

私の環境では、/usr/local/Cellar/open-ocd/HEAD-cff0e41/bin/openocdにインストールされていましたので、これを直接実行できることを確認します。

$ /usr/local/Cellar/open-ocd/HEAD-cff0e41/bin/openocd --version
Open On-Chip Debugger 0.11.0+dev-00236-gcff0e417d-dirty (2021-06-28-20:42)
Licensed under GNU GPL v2
For bug reports, read
    http://openocd.org/doc/doxygen/bugs.html

バージョン情報が正しく表示されればOKです。

Raspberry Pi 側でJTAGインターフェイスを有効化する

デフォルトでは、JTAGインターフェイスは無効になっており、該当するピンは通常のGPIOポートとして機能するように設定されています。

JTAGを有効化するには、Raspberry PiのSDカード内にある config.txtというファイルの[all]セクションの配下に、以下のような記述を追加します。

enable_jtag_gpio=1

これにより、起動時にRaspberry Piのファームウエアがこの設定ファイルを読み込んだ際に、JTAGを有効化してくれます。

詳しくは、公式のドキュメントを参照してください。

ついでにシリアルポート出力とかも有効化しておく

enable_uart=1

参考: 私のconfig.txtの末尾はこうなっている

他にもいろいろ設定を入れてこんな感じになっています。参考までに。(この記事で解説したことを試すには、上で説明した2つを設定するだけで大丈夫なはずです。)

...
[all]
#dtoverlay=vc4-fkms-v3d
arm_64bit=1
init_uart_clock=48000000
init_uart_baud=115200
enable_uart=1
enable_jtag_gpio=1

openocdの設定

それでは早速openocdを使っていきたいのですが、使うためにはいくつか設定ファイルを作成する必要があります。

インターフェイスの設定

まず、以下のような内容のファイルを作成しc232hm-edhsl-0.cfgという名前で保存します。

adapter driver ftdi
ftdi_vid_pid 0x0403 0x6014
ftdi_device_desc C232HM-DDHSL-0

# ftdi_layout_init <values> <directions>
# initial value:
# 0078 = 0000 0000 0001 1000
# TRST, TMS=1, all others zero
# initial direction:
# 0111 = GPIOL3=RTCK=input, GPIOL2=dontcare=output, GPOL1=SRST=output, GPIOL0=TRST=output
# 1011 = [1=TMS=output, 0=TDO=input, 1=TDI=output, 1=TCK=output]
ftdi_layout_init 0x0018 0x007b

# GPIOL0 is TRST
ftdi_layout_signal nTRST -data 0x0010

(参考にしたブログ記事 のものに比べると、最新のopenocdに合わせて少し修正してあります。)

このファイルには、使用するJTAGアダプタ(今回の場合はFTDI C232HM-DDHSL-0)に固有の設定が記載されています。そのため、別のJTAGアダプタを使用する際は、それに合ったファイルを作成する必要があります。場合によっては標準で提供されていることもあるので、他のアダプタをお使いの方は各自で調べてみてください。

ターゲットの設定

次に、以下のような内容のファイルを作成しraspi4.cfgという名前で保存します。

set _CHIPNAME bcm2711
set _DAP_TAPID 0x4ba00477

adapter speed 1000

transport select jtag
reset_config trst_and_srst

telnet_port 4444

# create tap
jtag newtap auto0 tap -irlen 4 -expected-id $_DAP_TAPID

# create dap
dap create auto0.dap -chain-position auto0.tap

set CTIBASE {0x80420000 0x80520000 0x80620000 0x80720000}
set DBGBASE {0x80410000 0x80510000 0x80610000 0x80710000}

set _cores 4

set _TARGETNAME $_CHIPNAME.a72
set _CTINAME $_CHIPNAME.cti
set _smp_command ""

for {set _core 0} {$_core < $_cores} { incr _core} {
    cti create $_CTINAME.$_core -dap auto0.dap -ap-num 0 -baseaddr [lindex $CTIBASE $_core]

    set _command "target create ${_TARGETNAME}.$_core aarch64 \
                    -dap auto0.dap  -dbgbase [lindex $DBGBASE $_core] \
                    -coreid $_core -cti $_CTINAME.$_core"
    if {$_core != 0} {
        set _smp_command "$_smp_command $_TARGETNAME.$_core"
    } else {
        set _smp_command "target smp $_TARGETNAME.$_core"
    }

    eval $_command
}

eval $_smp_command
targets $_TARGETNAME.0

(こちらも、参考にした記事をベースに、最新のopenocdに合わせて少し修正を加えたものになります。)

このファイルには、デバッグ対象のデバイス(今回の場合はRaspberry Pi 4 / BCM2711)に固有の設定が記載されています。そのため、もし異なるボードをJTAGデバッグしたい場合には、それぞれに合ったファイルを作成して使用する必要があります。

動かしてみる

では、編集済みのconfig.txtが入ったRaspberry Pi OS入りのSDカードをRaspberry Pi 4に挿入し、JTAGケーブルやシリアル変換ケーブルをPCにさして、最後にRaspberry Pi 4に電源をさして起動してみましょう。その後、以下のコマンドを実行してみてください。

/usr/local/Cellar/open-ocd/HEAD-cff0e41/bin/openocd -f c232hm-edhsl-0.cfg -f raspi4.cfg

(最初のopenocdへのパスは、適宜環境に合わせて読みかえてください。)

すべてが正しく動作していれば、以下のような出力が得られ、サーバーが待受状態になります。

$ /usr/local/Cellar/open-ocd/HEAD-cff0e41/bin/openocd -f c232hm-edhsl-0.cfg -f raspi4.cfg
Open On-Chip Debugger 0.11.0+dev-00236-gcff0e417d-dirty (2021-06-28-20:42)
Licensed under GNU GPL v2
For bug reports, read
    http://openocd.org/doc/doxygen/bugs.html
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : clock speed 1000 kHz
Info : JTAG tap: auto0.tap tap/device found: 0x4ba00477 (mfg: 0x23b (ARM Ltd), part: 0xba00, ver: 0x4)
Info : bcm2711.a72.0: hardware has 6 breakpoints, 4 watchpoints
Info : bcm2711.a72.1: hardware has 6 breakpoints, 4 watchpoints
Info : bcm2711.a72.2: hardware has 6 breakpoints, 4 watchpoints
Info : bcm2711.a72.3: hardware has 6 breakpoints, 4 watchpoints
Info : starting gdb server for bcm2711.a72.0 on 3333
Info : Listening on port 3333 for gdb connections

openocdとのやりとり

参考:

上の出力に書かれている通り、telnetなどで4444番ポートにアクセスすると、openocdとおはなしできます。

$ telnet localhost 4444
Trying ::1...
telnet: connect to address ::1: Connection refused
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Open On-Chip Debugger
> 

ここにコマンドを打つと、いろいろ見れます。

targets: 各ターゲット(今回の場合CPUコア)を一覧表示できる

> targets
    TargetName         Type       Endian TapName            State       
--  ------------------ ---------- ------ ------------------ ------------
 0* bcm2711.a72.0      aarch64    little auto0.tap          running
 1  bcm2711.a72.1      aarch64    little auto0.tap          running
 2  bcm2711.a72.2      aarch64    little auto0.tap          running
 3  bcm2711.a72.3      aarch64    little auto0.tap          running

targets bcm2711.a72.1のように、TargetNameを指定すれば、現在選択しているtargetを変更できる。

halt: 実行を停止させる。

> halt
bcm2711.a72.1 cluster 0 core 1 multi core
bcm2711.a72.2 cluster 0 core 2 multi core
bcm2711.a72.2 halted in AArch64 state due to debug-request, current mode: EL2H
cpsr: 0x000003c9 pc: 0x80
MMU: disabled, D-Cache: disabled, I-Cache: disabled
bcm2711.a72.3 cluster 0 core 3 multi core
bcm2711.a72.3 halted in AArch64 state due to debug-request, current mode: EL2H
cpsr: 0x000003c9 pc: 0x80
MMU: disabled, D-Cache: disabled, I-Cache: disabled
bcm2711.a72.0 cluster 0 core 0 multi core
bcm2711.a72.0 halted in AArch64 state due to debug-request, current mode: EL2H
cpsr: 0x600003c9 pc: 0x813d8
MMU: disabled, D-Cache: disabled, I-Cache: disabled
bcm2711.a72.1 halted in AArch64 state due to debug-request, current mode: EL2H
cpsr: 0x000003c9 pc: 0x80
MMU: disabled, D-Cache: disabled, I-Cache: disabled
> targets
    TargetName         Type       Endian TapName            State       
--  ------------------ ---------- ------ ------------------ ------------
 0  bcm2711.a72.0      aarch64    little auto0.tap          halted
 1* bcm2711.a72.1      aarch64    little auto0.tap          halted
 2  bcm2711.a72.2      aarch64    little auto0.tap          halted
 3  bcm2711.a72.3      aarch64    little auto0.tap          halted

reg: レジスタを表示する

> reg                  
===== Aarch64 registers
(0) x0 (/64): 0x000000000000006c (dirty)
(1) x1 (/64): 0x00000000ffffffff
(2) x2 (/64)
...

dirtyと書かれているデータは、openocdがキャッシュしているものなので、もし生の値を反映させたかったら、以下のようにすると強制的に読み書きできる。

reg x0 force

キャッシュ情報とかも見れます

> aarch64 cache_info
L1 I-Cache: linelen 64, associativity 3, nsets 256, cachesize 48 KBytes
L1 D-Cache: linelen 64, associativity 2, nsets 256, cachesize 32 KBytes
L2 D-Cache: linelen 64, associativity 16, nsets 1024, cachesize 1024 KBytes

メモリの読み書きもできちゃいます!たとえば0x80000(raspberry pi 4におけるカーネルのロードアドレス)をみると

>  mdw 0x80000
0x00080000: d53800a1

と書いてありますが、今回起動したイメージの先頭を確認してみると

$ hexdump -C kernel8.img | head -n 1
00000000  a1 00 38 d5 21 04 40 92  42 01 00 58 3f 00 02 eb  |..8.!.@.B..X?...|

たしかに一致していますね!

gdbとあわせて使う

参考: GDB and OpenOCD (OpenOCD User’s Guide)

openocdの起動時のメッセージを注意深く読むと、gdbのサーバーも待ち受けていることがわかります。

Info : starting gdb server for bcm2711.a72.0 on 3333
Info : Listening on port 3333 for gdb connections

ということで、さっそくつないでみましょう。今回使ったgdbのバージョンは以下の通り。

$ gdb --version | head -n 1
GNU gdb (GDB) 9.2

まず、gdbを引数なしで起動します。

gdb

そのあと、gdbのプロンプトに、アーキテクチャとターゲット情報を打ち込んで、リモートデバッグをはじめます。

私が試した例では、config.txtにarm_64bit=1と書いた状態でaarch64のバイナリを実行している状態なので、以下のようにaarch64をターゲットとして指定します。

(gdb) set architecture aarch64
The target architecture is assumed to be aarch64
(gdb) target extended-remote localhost:3333
Remote debugging using localhost:3333
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x00000000000813d8 in ?? ()

一方、Raspberry Pi OSは記事執筆現在は32bit(armv7l)で動作しているので、もしそのようなターゲットをデバッグしたい場合は、aarch64の代わりにarmv8-aにするとよいでしょう。

set architecture armv8-a
target extended-remote localhost:3333

もし間違ったarchを指定していたら、以下のようなエラーメッセージが出るので、そのときは別のarchを指定してやり直せばOKです。

warning: Selected architecture armv8-a is not compatible with reported target architecture aarch64
warning: Architecture rejected target-supplied description

さて、これでgdbがつながったので、デバッグし放題です。いつものinfo registersとかもばっちり動きます。

(gdb) info registers
x0             0x0                 0
x1             0x8192d             530733
x2             0x2                 2
x3             0x80264             524900
...

もちろん、今実行しているバイナリが手元にあれば、それを読み込んでgdbデバッグすることもできます。

$ file target/aarch64-unknown-none-softfloat/release/kernel
target/aarch64-unknown-none-softfloat/release/kernel: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, with debug_info, not stripped

$ gdb target/aarch64-unknown-none-softfloat/release/kernel
GNU gdb (GDB) 9.2
...
(gdb) target extended-remote localhost:3333
Remote debugging using localhost:3333
0x00000000000813d8 in kernel::kernel_main ()
(gdb) disas
Dump of assembler code for function _ZN6kernel11kernel_main17h947565f396a80a38E:
   0x0000000000080fd4 <+0>:   sub sp, sp, #0xc0
   0x0000000000080fd8 <+4>:   stp x30, x27, [sp, #112]
(gdb) c
Continuing.

loadコマンドをつかえば、バイナリを動的にメモリ上にロードすることだってできちゃいます。

(gdb) load
Loading section .text, size 0x1674 lma 0x80000
Loading section .rodata, size 0x4c5 lma 0x81678
Loading section .got, size 0x10 lma 0x81b40
Loading section .data, size 0x20 lma 0x81b50
Start address 0x0000000000080000, load size 7017
Transfer rate: 63 KB/sec, 1754 bytes/write.

raspberry piJTAGインターフェイスにはSRSTピンが生えていないため、gdbmonitor resetコマンドを利用してチップをリセットしてロードしたプログラムを再実行させることはできませんが、プログラムカウンタ(pc)を変更することはできるので、代わりに使うといいかもしれません。

(gdb) set $pc=0x80000
(gdb) c

というわけで、とっても便利なJTAGを活用して、皆様も楽しいRasPiベアメタル開発の時間をお過ごしください!

付録: openocdがうまくいかないとき

Error: no device found
Error: unable to open ftdi device with vid 0403, pid 6014, description 'C232HM-DDHSL-0', serial '*' at bus location '*'

→そもそもJTAGアダプタがPCにつながっていない/認識されていない。

Error: JTAG scan chain interrogation failed: all ones
Error: Check JTAG interface, timings, target power, etc.
Error: Trying to use configured scan chain anyway...
Error: auto0.tap: IR capture error; saw 0x0f not 0x01
Warn : Bypassing JTAG setup events due to errors
Error: Invalid ACK (7) in DAP response
Error: JTAG-DP STICKY ERROR

デバッグ対象のデバイスの電源が入っていない/まだ起動直後でJTAGが有効化されてない/配線が間違っている/enable_jtag_gpio=1の設定変更が正しく反映されていない。

liumOS 2020年の進化を振り返る

この記事は自作OS Advent Calendar 2020の「最初の4つの素数の和」日目の記事として作成されました。

liumOSについて

liumOSは、NVDIMM(Non-volatile DIMM, 不揮発性メインメモリ)をネイティブにサポートしている、珍しいタイプの自作OSです。2018年7月26日に開発を開始して以来、記事執筆時点で682 commitsを数えるほどに成長しました。主要開発者はhikaliumですが、後述する通り、ここ数ヶ月間はd0iasmさんにも手伝っていただいていました。

2018年には、自作OSからNVDIMMを触る方法について自作OS Advent Calendar 2018の記事で紹介しましたので、NVDIMMについて知らないよという方や、興味のある方はそちらもぜひお読みください。

hikalium.hatenablog.jp

今年は、かなり色々なものをliumOS上に実装して遊んでいたので、この記事ではそのうちのいくつかについて紹介してみたいと思います。

Adlibを利用したMIDI再生機能

これはOSの中核的な機能ではありませんが、デバイスドライバの実装の一環として、Adlibという非常に古いサウンドバイスのドライバを書いて、さらにMIDIファイルのパーサを書いて再生できるようにしました。実は中学生の頃に開発していた自作OSでもMIDIファイルの再生を試みていたことはあるのですが、当時はビープ音で無理やりなんとか和音を出そうとして結局失敗した記憶があり、その当時のMIDIパーサを再利用してN年越しの目標を達成したのでした。

デモ動画はこちらです。(音が鳴るので注意。)


liumOS Adlib MIDI再生デモ

virtio-netドライバの追加とICMP, DHCPへの対応

前菜を楽しんでいただけたところで、メインディッシュですが、なんとliumOSにネットワーク通信機能を追加しました。

たとえば、pingに応答するデモはこちらです。


liumOS pingデモ

冒頭でさらっと流されていますが、なんとDHCPIPアドレスその他が自動的に割り当てられている点にも注目です!

f:id:hikalium:20201217022248p:plain
ipコマンドで割り当てられたIPアドレスを確認している図

NICドライバとしては、仮想入出力デバイスvirtioのうちのひとつである、virtio-netを実装しています。また、GPD Micro PCで採用されているRTL8168というRealtek社製のNICについても現在ドライバの実装を進めているところです。

UDP通信への対応、ソケットシステムコールの部分的実装、簡単なブラウザアプリの実装

さて、pingができるなら、もうなんでもできますね!だって、パケットを送受信するだけですから。(本当に?)

ということで、UDPプロトコルと、ソケット関連システムコールをいくつか実装し、d0iasmさんによって書かれた簡易ブラウザアプリケーションが動くように環境を整備しました。

たとえば、このようにliumOS内のクライアントから、Linux上で動くサーバーにリクエストを投げればHTMLが返ってきますし、

f:id:hikalium:20201217025756p:plain
liumOS内からHTTPのやりとりをしている図

これをパースしてMarkdownに整形するという簡易ブラウザまで動いてしまいます!

f:id:hikalium:20201217025918p:plain
同じHTTPのやりとりだが、出力をパースしてMarkdownに整形して出力している簡易ブラウザの図

どうですか?自作OSでも、これくらいなら少し頑張ればできるんです!

Web+DB Press Vol.120の特集「自作OS×自作ブラウザで学ぶ Webページが表示されるまで」をd0iasmさんと執筆した

さて、このようなことをしていましたら、ありがたいことに技術評論社Web+DB Press編集部よりお声がけをいただき、自作OSと自作ブラウザを通して、Webページが表示されるまでの流れを、文字通り上から下まで一通り追ってゆく記事を執筆させていただく機会を得ました。

gihyo.jp

Webページが表示されるまでに、HTTPのリクエストがどのようにしてパケットにまで落とし込まれて、レスポンスとしてのHTMLがどのようなパスを通って運ばれてゆき、最終的に目に見える形になるのか、ということを解説した、合計36ページの特集となっておりますので、面白そう!と思っていただけたなら、ぜひ書店で内容を確認したり、購入して読んでいただけたりしたら大変嬉しく思います。12/24発売です!

この特集の概要の紹介や、デモを交えた動画を近日中にアップロードする予定ですので、そちらもお楽しみに!

www.youtube.com

今後の展望など

やりたいことは無限にあって永遠に楽しめるのが自作OSのよいところですが、ある程度目標を立てておいたほうが物事を進めやすいのは確かです。ということで、いくつかここで私がやりたいと思っていることを書き連ねておくことにします。

GUIサポートを向上させる

現在のところ、liumOSにはほとんどGUIと呼べるものがありません。画面描画を行うAPIはアプリケーションに対して提供されていませんし、入力インターフェイスについても、ほんの少し前にPS/2マウスのサポートがやっと入ったほどです。そのため、先ほど紹介した特集でも、CUIブラウザを実装するのがやっとの状態でした。

せっかくブラウザの下回りができたなら、CSSで文字を大きく表示したり色を変えたり、なんなら文字列を<blink>させたいですよね?(とはいえblinkタグはすでに廃止されていますが…。)

ということで、ある程度の描画がアプリケーション側からできるような仕組みを近いうちに用意し、ついでにブラウザの実装も拡張したいなあと思っています。

RustでOS本体を書き直す

現在のliumOSはC++で書かれています。C++が使えるだけでもC言語に比べれば大きな進歩だったのですが、やはりメモリ安全性という面ではC言語と五十歩百歩と言わざるを得ません。最近応用が広がってきたRustであれば、より安全にプログラムを書くことができます。ということで、Rustでカーネルを書き直すことを現在検討しています。すでに、Rustで書かれたカーネルを従来のliumOSローダーが起動できるところまでは確認しているため、あとは実装するだけという状況なのですが、うまいことインクリメンタルにできないものか、考えつつ現在進めているところです。(まあ、いつか気分が乗った時にえいやっと書き換えることになりそうな気がしますが…。)

ネットワーク機能の拡張

現在のliumOSのネットワークまわりはかなりアドホックに実装されており、複数のNICをうまく扱えない、ソケットの実装がはりぼてすぎる、たまにドライバがこけるなどの問題が発生しています。特に、ドライバを書くというのは自作OSにとって頭の痛い問題ですが、私としてはネットワークドライバほど実装しておいしいものは他にないのではないかと考えています。というのも、基本的な機能としては「パケットを送信する」「パケットを受信する」の2つしかありませんし、ネットワークの先になにがあるかを私たちは意識する必要がないからです。たとえば、自作OSでファイルを保存できるところまで到達しているケースはかなり少ない(liumOSはNVDIMMに対応しているのでなんでも保存できる!…と言い張ることができるかもしれない)ですが、ネットワーク通信さえ対応してしまえば、保存すべきファイルをネットワーク越しに送信するようにすることで、自作OS側でドライバを書かなくて済む可能性が出てきます。(もちろん、各種プロトコルを実装する必要がありますが…。)あと、自作OS同士が独自のプロトコルで通信するのをみてみたい、という気持ちもかなりあります。ということで、おそらくネットワーク周りの機能はそれなりに重点的に進めていくことになるような気がしています。

おわりに

そういうわけで、今年もいろいろな機能がliumOSには生えましたし、今後も生やしていくつもりではいるのですが、ここで大事なことをひとつ。

自分が楽しいと思えることをやっていきましょう!最初から完璧なものを作る必要はありません。なにせ、一番のユーザーは自分自身なのですから。

自分以外のユーザーが出てきたら、そのときはそのときでまた考えましょう!(なおliumOSにはついにアプリ開発者(@d0iasm)が登場したので、大急ぎでテストを書いたりドキュメントの修正をしたりしていました。もちろんこれも楽しいからやってるんですけどね!)

ということで、このご時世で家にいる時間も多くなってきていると思いますので、皆様ぜひ暖かい部屋でゆっくりしながら、のんびりとした自作OSライフをお楽しみください!ではでは!