前回 (とりあえずHello World編) : https://qiita.com/tanakmura/items/918cc1e80da3324367c0
なぜファームウェアプログラミングをするか
みなさん、OS自作楽しんでますか?
私はもうついていけなってきている。全てはUEFI実装が当然になってしまったことが悪いんだよな。
UEFIは起動後には
- USBつかえる
- USB,PS2問わずキーボード、USBマウス使える
- NVMe, USBストレージ, IDE, SATA 全部読める
- FAT読める
- ネットワークアクセスできる
- 画面表示できる
という状態で、「ここからOS自作するってこれ以上何をやったらいいんや…」という気持ちになりがち(もうあとwifiぐらいしかない)。legacy BIOSなら多少マシだが、今の世界でわざわざlegacy BIOS使うのも、歪んでいる気がするし、今のlegacy BIOSはUEFI上に実装された互換legacy BIOSであって、なんか、こう、手加減されてしまってる感がある。
そういう人におすすめなのが、PCファームウェアプログラミングだ。
PC ファームウェアプログラミングでは、本当に何もないところからプログラムを書く必要がある。どのくらい何もないかというと、DRAMアクセスができない。
これはつまり、push/pop, call/ret すらできないという状態からはじまるということである。スタックが使えないので、当然C言語という選択肢はない、ある程度初期化が進むまでははasmで書くしかないのだ。
(え、どうするの?どうするかもそのうち書くのでお待ちください)
あと、x86では、ファームウェアを自作する確実なメリットが一個ある。
x86には、SMM (System Management Mode) という仕組みがある。
これは、特殊な割り込みを入れる、特殊なI/Oポートにアクセスするなどすると、OSからも見えない領域でプログラムを動かせる、という仕組みだ。これは基本的にファームウェアからしか触れることはできず、OS自作では触れられない領域になる。(定期的に脆弱性見つかって触れるようになってるけど)
ファームウェアから自作すれば、このSMMへの完全アクセスが可能になる。全てを把握したいOS自作派なら、当然SMMも完全把握したいであろう。SMMを完全理解するには、ファームウェア自作が必須である。
(SMMは、Ring0 より下で動く Ring-1 とも呼ばれる VMM の下で動くので、Ring-2 と呼ばれたりもする。(https://en.wikipedia.org/wiki/System_Management_Mode) タイトルの-2はそういう意味です)
PCにおけるファームウェアとは
PCは、HDDやSSDを接続しなくても、いわゆる「BIOS画面」というものが起動する。
この「BIOS画面」というのがまさにファームウェアが出力している画面である。
(今はこんな地味な画面出ないけど、派手な画面になっててもやってることは変わってない)
また、OS自作派ならば MBR に入る512byteのプログラムを書いたことはあると思うが、なぜ、MBR 512byteの最後2byteに0xaa55を書くと、プログラムが起動するのだろうか?途中で書いてる int 10h, int 13h とは何か?という疑問を持ったことはないだろうか?
これは、ファームウェアに
- MBRの最後2byteに0xaa55が書かれてたらそれを0x7c00にロードして、開始する
- int 10h の割り込みがきたら画面操作する
- int 13h の割り込みがきたらディスク操作する
という機能が実装されているからだ。そう考えると、OS自作はまだまだフルスクラッチではない。その下にそこそこ巨大なソフトウェアが潜んでいるのが感じられるだろう。(大昔のBIOSならともかく、今のBIOSならint 13hするだけでUSBメモリにアクセスできることを考えよう)
このソフトウェアを自分で作ろう、というのがファームウェア自作作業における目標となる。
さて、このファームウェアは、SSDもHDDも付いてないのに、どこから起動してるのだろうか。
答は、マザーボードの上にある SPI Flash という不揮発メモリである。(この不揮発メモリの種類は時代によって色々だが、今はSPI Flashで統一されていると思ってよい)
これは特殊なメモリではなく、汎用品で、PC以外の基板でも使われている。(なので、CH341Aのような、そこらへんで入手できるツールでアクセスできる)
ここに、普通のプログラムと同じようにx86機械語とプログラム実行に必要なデータが含まれている。
つまり、特殊なことは何もなく、「ファームウェア」と名前が付いているが、本質的には普通のプログラムである。上で、「SMMで動くプログラムはファームウェアしかアクセスできない」と書いたが、これはあくまで今のマザーボードの実装上そうなっているだけで、やろうと思えばOSでSMMを制御する、という実装は作れる。今はそうなってないだけである。
ただ、まあ現代ではマザーボード依存の初期化は、BIOS、UEFIなどのファームウェアがやる、それが終わって、PCI、ACPIなどの標準化されたインターフェースが使えるようになった段階でOSに処理を渡す、OSは標準化されたインターフェースでハードウェア初期化をする、というのがデファクトスタンダードだろう。
ファームウェアは、マザーボード依存のハードウェア初期化をやるのがOSと比べて特殊とは言ってもいいかもしれない。
(マザーボード依存の部分はやりたくないけど、UEFIの上で動くのはイヤだという人向けには、coreboot payloadプログラミングという手段もあるね、という図)
ファームウェア Hello World とPCのマザーボード
それでは、前回のプログラムの解説をしていこう。
# init FIFO
mov $0b00000111, %al
mov $UART_FCR, %dx
outb %al, %dx
## dlab=1, parity no, stop=1, data=8
mov $0b10000011, %al
mov $UART_LCR, %dx
outb %al, %dx
# divider = 1
mov $UART_DIV_LO, %dx
mov $0x1, %al
outb %al, %dx
mov $UART_DIV_HI, %dx
mov $0x0, %al
outb %al, %dx
## dlab=0, parity no, stop=1, data=8
mov $0b00000011, %al
mov $UART_LCR, %dx
outb %al, %dx
mov $UART_DATA, %dx
mov $(Hello_end-Hello), %cx
mov $0xf000, %ax
mov %ax, %ds
lea Hello, %si
rep outsb
OS自作をしたことがあって、UART(シリアル)から文字を出したことがある人なら、この init_uart - body_end の間に何が起こっているかは説明しないでいいだろう。
(https://w0.hatenablog.com/entry/20141213/1418480111 自分でも解説書いたことがある)
しかし、ここより上の部分は、OS自作派の人でも分からない人が多いのではないだろうか。
init_lpc:
// enable superio & coma
mov $((1<<12)|(1<<0)), %cx
pci_write_config16 0, 0x1f, 0x0, 0x82
init_superio_uart:
# enter conf state
mov $0x55, %al
mov $0x2e, %dx
outb %al, %dx
# UART : LDN=0x4
superio_write $0x7, $0x4
# disable uart
superio_write $0x30, $0x0
# iobase = 0x60=0x300, 0x61=0x0f8
superio_write $0x60, $0x3
superio_write $0x61, $0xf8
# enable uart
superio_write $0x30, $0x1
# exit conf state
mov $0xaa, %al
mov $0x2e, %dx
out %al, %dx
つまり、このへんがファームウェアっぽい処理ということになる。
何が起こってるか、を把握するには、PCのマザーボードについて理解する必要がある。
ここで使っている D945GCLF2 は主に、
- CPU : Atom330
- NorthBridge : 82945GC
- SouthBridge : ich7 (82801GB)
- SuperIO : SMSC LPC47M997
というチップセット構成になっている(NICなど末端のチップは略)。(余談だが、D945GCLF2は、CPUよりNorth Bridgeのほうが発熱していて、CPUはファンレスなのにマザーボードとしてはファンレスにならない愉快なボードだった)
それぞれの役割は、簡単にいうと
- NorthBridge : DRAMメモリ、GPUなど高速デバイスをつなぐ
- SouthBridge : DRAMメモリほど速度のいらないIOデバイスをつなぐ
- SuperIO : もっと遅いけど昔のPCとの互換性のために必要なIOデバイスをつなぐ
今どきのデスクトップPCはNorthBridge 相当がCPUに含まれているし、ノートPCやスマホに入っているようなSoCではSouthBridge相当もCPUと同じチップに入っている。あと今のノートPCではSuperIOは省略されているのが普通だろう。SuperIOは必須ではない。
D945GCLF2 では、こんな感じの対応になっている。
(わざわざD945GCLF2とかいう古いマザーボードを用意しているのは、NorthBridgeが分離しているほうが理解しやすいからなのだった。MiniITXなのは趣味)
これのブロック図は、82945GCのマニュアルに書いてある https://www.intel.com/Assets/PDF/datasheet/307502.pdf。
このブロック図は大事なので、よく見ておいてほしい。
さて、
init_lpc:
// enable superio & coma
mov $((1<<12)|(1<<0)), %cx
pci_write_config16 0, 0x1f, 0x0, 0x82
まず、このプログラムである。これは、bus=0, dev=1f, fn=0 の PCI デバイスのconfig空間の 0x82 byte目に、 ((1<<12)|(1<<0))
を設定しているところである。
マクロを展開すると、こんな感じになる。
0xcf8にconfig空間のアドレスを書いて、0xcfc に値を書くとその値が書ける。
mov $((1<<12)|(1<<0)), %cx
mov $0xcf8, %dx
mov $(0x80000000 + (0x1f<<11) + (0x0<<8) + (0x82 & 0xfc)), %eax
out %eax, %dx
mov $(0xcfc+(0x82&0x3)), %dx
mov %cx, %ax
out %ax, %dx
このとき、
mov $0xcf8, %dx
mov $0xFooBar, %ax
out %ax, %dx
とすると、何が起こってるだろうか。
0xcf8は、pci configリクエストを出すユニットに繋がっているが、これはNorth bridgeの中にあるので、CPU から出た IO リクエストをそのままNorth bridge内で処理することになる。
では、例えば、UARTが付いてる、0x3f8 に out する場合はどうなるだろうか
mov $0x3f8, %dx
mov $'A', %al
out %al, %dx
する場合を考えよう。この場合
- North bridge は、リクエストをSouth bridgeに転送する
- South bridge は、リクエストをSuperIO に転送する
- SuperIO の中にあるUARTユニットで、リクエストをハンドルする
という手順がいる。
出力するポートによって、リクエストを通る経路が変わる、つまり、ポート毎に経路を設定してやる必要があるのだ。
その経路を設定しているのが、これになる。
// enable superio & coma
mov $((1<<12)|(1<<0)), %cx
pci_write_config16 0, 0x1f, 0x0, 0x82
しがらみのない環境なら、PCIと同じように、IOBase + 領域サイズなどで設定する場面だが、
IOポートの範囲が飛び飛びになっている古いPCとの互換を維持するために、実質、古いPCのデバイスごとに設定レジスタがある。
North Bridge は、0x3b4あたりにある VGA 関連のIOポート、PCIeや内蔵GPUで使うIOポートを内部でハンドルし、それ以外をSouth Bridgeへ転送する。これはつまり、UART で使う 0x3f8 は、初期状態では、South Bridgeへ転送されるので、何も設定しなくていい、ということになる。
South Bridge は、PCI bus=0, dev=0x1f, fn=0 にある、LPC bridge のコンフィグ空間にあるレジスタに書かれた値にしたがって、届いたI/Oリクエストを
- PCI slot
- LPC (SuperIO はLPCの下に繋がっている)
のどちらに転送するか、を決める。
I/O ポート 0x3f8 の設定は、ICH7 のマニュアルの、ここにある
まず、0x80 で、COMA の I/O ポートを決める。デフォルトで0x3f8 になっているので、変える必要はない。
続けて、0x82 で、COMA への I/O アクセスを LPC に転送するように設定する。これは、COMA_LPC_EN に対応している。
よって、
// enable superio & coma
mov $((1<<12)|(1<<0)), %cx
pci_write_config16 0, 0x1f, 0x0, 0x82
このように、bus=0, dev=0x1f, fn=0, の 0x82 にあるレジスタに (1<<0) を書き込むと、I/O ポート 0x3f8 にアクセスしたときに、SuperIO に I/O リクエストが行くようになる。
は?じゃあ (1<<12) は何?これは0x2e,0x2fにあるSuperIOのレジスタへのアクセスを転送する設定になる。
ここまでの設定で、「0x3f8へのI/OアクセスがSuperIOに届く」という状態になった。しかし、これだけだとSuperIOにI/Oアクセスが届くだけである。続けて、SuperIO の初期化が必要だ。
SuperIO の初期化は以下の手順になる
- SuperIO の lock をはずして conf state に入る
- UART のコンフィグレジスタを選択
- UART を 0x3f8 に割り当てる
- UART を 有効にする
- SuperIO の conf state から抜ける
それが、
init_superio_uart:
# enter conf state
mov $0x55, %al
mov $0x2e, %dx
outb %al, %dx
# UART : LDN=0x4
superio_write $0x7, $0x4
# disable uart
superio_write $0x30, $0x0
# iobase = 0x60=0x300, 0x61=0x0f8
superio_write $0x60, $0x3
superio_write $0x61, $0xf8
# enable uart
superio_write $0x30, $0x1
# exit conf state
mov $0xaa, %al
mov $0x2e, %dx
out %al, %dx
これだ。
昔のPCに付いていたデバイスは、ハードウェアごとにI/Oポート、IRQ番号が決め打ちで、衝突しないようにユーザが注意する必要があった。それではいけないと当時のバスであったISAにも実行時にI/OポートやIRQの割り当てを変えられるPnP(Plug and Play)の仕組みが取り入れられた。結局これはより洗練されてPCIにとって変わられて、広く使われることはなかったのだが、SuperIOだけはこの時代の名残りを引き継いでいて、「PCIではない方法」で、I/O ポートを割り当ててあげる必要がある。
筆者もあんまり事情を知らないので、細かいことはよくわからないが、次のようにする。
- SuperIO の lock をはずして conf state に入る
# enter conf state
mov $0x55, %al
mov $0x2e, %dx
outb %al, %dx
間違って設定かきかわることがないようにするためか、設定を変えるにはロックをはずす必要がある。これは、SuperIOのチップごとに手順が少し違うので、チップごとに仕様を調べる必要がある。
D945GCLF2 で使われているSMSC社製のSuperIO では、I/O ポートの 0x2e に 0x55 を書くことで、ロックをはずして conf state に入ることができる。
I/O ポート 0x2e, 0x2f は、SuperIO の内部にアクセスするときに使うポートになる。
// enable superio & coma
mov $((1<<12)|(1<<0)), %cx
pci_write_config16 0, 0x1f, 0x0, 0x82
さっき、0x3f8 のポート転送を設定していたところで、ついでに (1<<12) も設定していた。これは、このSuperIO の管理ポートにアクセスするための設定だったのだ。これを設定することで、0x2e, 0x2f へのアクセスは、LPC(SuperIO)へ転送されるようになる。
なお、マザーボードによっては、これは0x4e,0x4fになる場合もあるようだ。(これ確定してないと完全な Plug and Play するの無理では?という気がするけど)
- UART のコンフィグレジスタを選択
ロックをはずすと、SuperIO内の各デバイスの内部レジスタにアクセスできるようになる。
まずは、使うデバイスを選ぶ。SMSC LPC47M997 では、UART には、デバイス番号(LDNと呼ぶ)4番が割り当てられているので、4を設定する(このデバイスとLDNの対応ももSuperIOのチップごとに番号が違うので調べる必要がある)
.macro superio_write port, data
mov \port, %al
mov $0x2e, %dx
out %al, %dx
mov \data, %al
mov $0x2f, %dx
out %al, %dx
.endm
# UART : LDN=0x4
superio_write $0x7, $0x4
SuperIO の内部レジスタにアクセスするには、0x2e にレジスタ番号を書くと、0x2f 経由でアクセスできる。これをやるのが superio_write
マクロだ。
レジスタ7番に、4 を書くことで、LDN4 のデバイス、つまり、UARTを選べる。
- UART を 0x3f8 に割り当てる
# iobase = 0x60=0x300, 0x61=0x0f8
superio_write $0x60, $0x3
superio_write $0x61, $0xf8
UART にI/Oポート 0x3f8 を割り当てる。0x60 と 0x61 にポート番号を書く。
これでようやく、I/Oポート0x3f8とUARTが関連付けられるようになった。
- UART を 有効にする
# enable uart
superio_write $0x30, $0x1
- SuperIO の conf state から抜ける
# exit conf state
mov $0xaa, %al
mov $0x2e, %dx
out %al, %dx
最後に、SuperIOのconfig stateを終了する。0x2e に 0xaa を書くと終了になる。
以上、UARTを初期化して Hello, World! を表示するまでの解説でした。
次回、cache as ram 編に続く : https://qiita.com/tanakmura/items/ca0aaf4402ea0d399e0e
(雑談 : マザーボードの選びかた https://qiita.com/tanakmura/items/e177caba250c2bf36367 )