エンディアンの話


このテキストはFreeBSD Press No.12 特集「BSDで動かそう 後編」のNetBSD関連記事のコラムとして掲載されたものです。Webでの公開にあたり投げやりなhtml化を含め一部の表記は見直していますが、基本的にAPI解説としては当時のソースのままであり、最新のNetBSDバージョンにおける変更には追従していません。記事の内容に関わるような大きな変更は入っていませんが……。

本テキストの著作権は筒井泉が有しています。obsoleteな不正確な情報が拡散するのもあまりよろしくないので、転載は控えて下さい。


エンディアンとは

今までに挙げた bus_spacebus_dma などのAPIとは別の階層の話になるが、真にMIなデバイスドライバを記述しようとする場合に気をつけなければならない点としてバイトオーダーの問題がある。i386やalphaのバイトオーダーはリトルエンディアン[脚注1]でm68kやpowerpc [脚注2] のバイトオーダーはビッグエンディアンであるのでどちらのエンディアンでも正しく動くようなコードを書かなくてはならない……ということなのだが、リトルエンディアンとは何か、ビッグエンディアンとはどういうことかという説明はあまり見かけない。そこでバイトオーダーが異なると何が困るのかということを少し説明してみたいと思う。

エンディアン(バイトオーダー)の定義

まずバイトオーダーの話をする場合には「ビッグエンディアン」「リトルエンディアン」という用語よりも「MSB first」「LSB first」という用語の方がより実態を表している。MSBとはMost Significant Bit、LSBとはLeast Significant Bitの略であり、要するに2進数のデータを記録する際に大きい桁のビット側から記録していくか小さい桁のビット側から記録していくか、というのがバイトオーダーの定義である。つまり、ビッグエンディアンでは上の桁から、リトルエンディアンでは下の桁からデータを読み書きすることになる。

本来バイトオーダーというのはハードウェアの実装の話で、ソフトウェア上ではデータを本来のサイズでアクセスしている限りプログラマはバイトオーダーを意識する必要はない。つまり32ビットのデータは32ビット単位で、8ビットのデータを8ビット単位でアクセスしている限りはどちらのバイトオーダーであっても問題は発生しない。しかし逆に、データを本来のサイズと違うサイズでアクセスした場合にはハードウェアのバイトオーダーによって動作の結果に違いが出てくることになる。具体的には、32ビット(4バイト長)のデータを8ビット(1バイト)単位で読み書きする場合や、8ビット(1バイト)のデータを1ビットずつアクセスする場合、逆に1バイト単位のバイナリデータを4バイトまとめて一気に読み書きする場合などがこれに相当する。

バイトオーダー依存となる実装

現在の一般的なCPUはデータバス幅が32ビットないし64ビット(long型に相当することが多い)であるのに対し、データの最小アクセス単位は8ビットつまり1バイト(char型)である。プログラム上でバイトオーダーが問題になるのはlong型やint型のデータをchar型でアクセスする(あるいはその逆の)ケースである。

具体的に、32ビットの0x01020304というデータをアドレス0x1000に書き込んだ場合を考えてみよう。ビッグエンディアンの場合は「MSB first」であるから図の左側のように最上位バイトの0x01が最初[脚注3]に書き込まれることになる。逆にリトルエンディアンの場合は下位バイトの0x04が最初に書き込まれることになる。

------------------------------------------------
アドレス        データ          データ
       (ビッグエンディアン)(リトルエンディアン)
0x1000          0x01            0x04
0x1001          0x02            0x03
0x1002          0x03            0x02
0x1003          0x04            0x01
------------------------------------------------

よって、アドレス0x1000に32ビットデータの0x01020304を書き込んだ後にアドレス0x1000のデータをchar型の8ビット単位で読み込むと、ビッグエンディアンのマシンとリトルエンディアンのマシンで結果が異なることになる。

逆に、アドレス0x1000から図のようにバイト単位のデータが置かれている場合を考えてみよう。

------------------------------------------------
アドレス        データ
0x1000          0x11
0x1001          0x22
0x1002          0x33
0x1003          0x44
------------------------------------------------

この場合に0x1000のアドレスを32ビット型でアクセスすると、ビッグエンディアンのマシンでは0x11223344が読み出されリトルエンディアンのマシンでは0x44332211が読み出されることになる。なお、アドレス0x1000を8ビット型(char型)でアクセスした場合にはどちらのバイトオーダーでも同じデータ(0x11)が読み出されることに注意してもらいたい。

具体的に問題が出たことのあるコードの例を挙げると次のようなものがある。[脚注4]

--------------------------------------------------------
	u_int32_t *scr;
	u_int32_t dsa;
	u_int8_t *dsap = (u_int8_t *)&dsa;
  :

	scr[0] = 0x78100000 | (dsap[0] << 8);
	scr[1] = 0x78110000 | (dsap[1] << 8);
	scr[2] = 0x78120000 | (dsap[2] << 8);
	scr[3] = 0x78130000 | (dsap[3] << 8);
--------------------------------------------------------

このコードの意図していたのは32ビット変数であるdsaをLSB側バイトから順に4バイトscr[]の配列内の32ビット変数の下位2バイト目に格納するということであるが、上述のとおり32ビットデータをchar型でアクセスしてしまうとビッグエンディアンのマシンでは意図していたものとは違うデータを格納してしまう。このような場合は次のように書けばバイトオーダーの違いを気にする必要はなくなる。

--------------------------------------------------------
	u_int32_t *scr;
	u_int32_t dsa;
  :

	scr[0] = 0x78100000 | ((dsa & 0x000000ff) <<  8);
	scr[1] = 0x78110000 | ( dsa & 0x0000ff00       );
	scr[2] = 0x78120000 | ((dsa & 0x00ff0000) >>  8);
	scr[3] = 0x78130000 | ((dsa & 0xff000000) >> 16);
--------------------------------------------------------

上記のように、あるデータの一部のビットだけを操作する場合には元のデータと同じサイズのデータに対するANDやORを使用すべきである。構造体のビットフィールドもバイトオーダーによってどのメンバーがどのビットを示すのかという定義が変わってしまうので使うべきではない。 [脚注5]

プログラム作成時は上記のようにバイトオーダーに依存しない書き方をするのが前提であり#ifdefを用いてバイトオーダーにより動作を切り替えるようなやりかたはすべきではない。


[脚注1] 「エンディアン」という言葉自身は、ゆでたまごをとがった方の側から食べる種族と丸い側から食べる種族との対立を描いたというガリバー旅行記の一節が原典だという話を聞いたことがあるが、真相は定かではない。
2020/6/2 追記: バイトオーダーとガリバー旅行記については IEN 137 に記載があるそうです。詳しくは ネットワークバイトオーダーの公式な参照先はエイプリルフール の記事を参照してください。

[脚注2] powerpcはアーキテクチャ的にはビッグエンディアンとリトルエンディアンとどちらの動作もできる構造になっているが、ほとんどのpowerpcマシンはビッグエンディアンで動作しておりNetBSDでも今のところビッグエンディアンのみサポートしている。

[脚注3] 「最初」ってどっちだよ、という疑問を持たれる方もいると思うが、ここでは「アドレスが0に近いメモリが最初」という定義である。アドレスのどちら側ということを示したいならば単に上位下位という定義を使えばよいと思われるかもしれないが、アドレスのどちら側が上位でどちら側が下位かという定義自体がバイトオーダーによって変わってきてしまうので、ここでは上位下位という言葉は使えない。同様にビット0とかビット31とかいう言葉の定義もバイトオーダーによって異なるので、どちらがわのビットなのかということを絶対的に示すにはMSB、LSBという定義を使うべきである。

[脚注4] これはNetBSDのNCR53C8xx SCSIドライバである/dev/ic/siop.cのテストを依頼された時に筆者が修正した部分である。なお説明をわかりやすくするため一部簡略化してある。

[脚注5] Intelの100BASE-TX NIC用ドライバの fxp(4) のソースの一部分である sys/dev/ic/i82557reg.h ではビットフィールドが使われているが、この中の "#ifdef BYTE_ORDER == LITTLE_ENDIAN" 等で書かれた定義を見ると結構頭が痛くなる。 ;-p


バイトオーダーを意識した実装が要求されるケース

一般には上述のようにバイトオーダーを意識する必要のないコードを書くことが基本であるが、デバイスドライバ作成の際にはデバイス自身の仕様として特定のバイトオーダーを意識した操作が必要になる場合がある。これらについては真面目に考え出すと頭が溶けるとも言われるほどややこしい部分もあるが、順を追って説明する。

(1) CPUのバイトオーダーとバスのバイトオーダーが異なるケース

現在のPCIバスはあらゆるマシンに使われているが、PCIバス自身のバイトオーダーはリトルエンディアンである。リトルエンディアンであるPCIバスをビッグエンディアンのCPUのバスにつなげる場合には誰かがどこかでデータをひっくり返してやる必要がある。

この場合、単純に考えるとCPUとPCIの32ビットのデータバス [脚注6] をひっくり返してつなげてやればいいと思うかもしれないが、これはうまくいかない。単純にひっくり返しただけの場合32ビットデータをアクセスする分にはうまくいくが、16ビット幅アクセスをする場合やバイトデータを読み出す場合にそのデータを読み出すためのアドレスが変わってしまう。[脚注7] 前項で説明したように、1バイトデータについてはどちらのバイトオーダーであっても同じアドレスならば同じデータが読めなければならない。

実際の実装ではbus_space関数がデータをひっくり返している場合がほとんどである。NetBSD/macppcの場合を例に挙げると、sys/arch/macppc/include/bus.hではbus_space_read系の関数は次のように定義されている。

---------------------------------------------------------------
sys/arch/macppc/include/bus.h:

#define bus_space_read_1(t, h, o)       (in8(__BA(t, h, o)))
#define bus_space_read_2(t, h, o)       (in16rb(__BA(t, h, o)))
#define bus_space_read_4(t, h, o)       (in32rb(__BA(t, h, o)))
----------------------------------------------------------------

ここでは16ビット幅および32ビット幅アクセスの時のみin16rb()およびin32rb()を使ってデータをひっくり返している。[脚注8] ちなみに、macppcの場合には本体がビッグエンディアンであるもののほとんどのデバイスがPCI経由でつながるためか内蔵デバイスはすべてリトルエンディアンなっているため、bus_space関数も逆のエンディアンを前提としたものしか用意されていない。

ただ、いずれにせよここで挙げているCPUとバス間のバイトオーダーの違いはbus_space関数の階層で吸収される話であり、実際のMIドライバの階層ではあまり気にする必要はない。


[脚注6] 64ビットバスを持つPCIもあるが、ここではひとまず置いておく。

[脚注7] PCIの規格上では単純にバスをひっくり返すだけの実装は認められないはずであるが、詳細は確認していない。

[脚注8] PCIブリッジの種類によってはアクセス時のバス幅を検出して自動的にひっくり返したりそのまま読んだりしてくれるものもあるかもしれないが、それを頭に入れてドライバやインターフェースを記述するのはかなり困難を伴うことが予想される。


(2) バイトデータ転送に16ビットもしくは32ビットポートを使用する場合

古い時代のデバイスの場合、データバス幅が8ビットしかなくそのポートに対して連続してバイトデータを読み書きことでPIOによるデータ転送を行うものが多く存在した。後にCPUおよびバス側のデータバスが16ビットそして32ビットと拡張されていくと、そのようなPIO転送において16ビットや32ビットポートを使用するデバイスがいくつか登場した。[脚注9]

前項でCPUとバスのバイトオーダーが異なる場合に複数バイト幅のアクセスをする場合はbus_space等でデータをひっくり返す必要があると書いたが、ここで使われている複数バイトの転送は16ビット型ないし32ビット型のデータを読み書きするわけではなく、あくまでも1バイト単位のデータを2つないし4つまとめて送っているだけである。よってこの場合にはメモリ上のバイトデータのアドレス順序とバス上のバイトデータのアドレス順序をひっくり返してはいけないことになる。

このようなケースに対処するためにNetBSDのbus_spaceAPI群にはbus_space_read_stream_N()bus_space_write_stream_N()というAPIが用意されている。これらの関数ではバイトデータの順序がCPUバス上とデバイスのバス上とで同じものになるようにデータがアクセスされる。具体的には、CPUとバスのバイトオーダーが同じであればstream系と非stream系の関数は同じ動作をするが、CPUとバスのバイトオーダーが異なる場合にはstream系の関数ではデータをひっくり返さずそのまま読み書きする、という動作になる。

実際のデバイスドライバの作成や移植の際には、データ転送に使われるポートのバス幅と使用に応じてstream系のbus_space関数を使い分けるだけでよいが、変にひっくり返さなくてもよいデータをひっくり返してしまったりすると今度はビッグエンディアンのマシンでは動くが今まで動いていたはずのリトルエンディアンのマシンでは動かなくなった、とかいう事態も発生するので注意が必要である。


[脚注9] 代表的なのがPC互換機のIDEである。


(3) デバイス間と複数バイト幅のパラメータをDMAでやりとりする場合

昨今のバスマスタDMA転送機能を持つデバイスでは、データのDMA転送時にデバイスに対して設定するパラメータの数が非常に多くなってきているため、これらのパラメータのやりとりに通常のI/Oポート経由のアクセスを用いず、パラメータ自身もDMAを使って転送するものが多くなっている。[脚注10]

この場合、デバイス上のバスマスタチップがCPUのメインメモリ上に書かれたパラメータデータを直接読み込むわけであるが、この時受け渡しをするデータはほとんどの場合バイトデータではなく32ビット長のワードデータである。(1)項で述べたように、CPUとバスのバイトオーダーが異なる場合に32ビット長のデータをアクセスする場合にはデータをひっくり返してやらないといけないが、バスマスタがCPUのメモリを読み出す場合にわざわざデータをひっくり返してくれるかということについてはCPUは直接関与できない。

これに対する解決策であるが、PCIデバイスの場合はPCIがリトルエンディアンであるということもありほとんどのバスマスタデバイスが本体のメインメモリがリトルエンディアンを仮定して動作する。よって、DMA経由でパラメータを受渡しをするためのメモリに対する書き込みは常にリトルエンディアンでデータを書き、読み込む場合もリトルエンディアンでデータが書かれているとしてアクセスする、という方法を採ればよい。このためにNetBSDではhtole32(),le32toh(),htobe32(),be32toh()といった関数群が用意されている。 [脚注11] 動作としてはhtole32()le32toh()はホストがリトルエンディアンであればそのままデータを読み書きし、ホストがビッグエンディアンであればデータをひっくり返して読み書きする。

実際のデバイスドライバ作成の際には、DMAによるパラメータ交換を行うメモリをすべて洗い出して、それらのメモリに対するアクセスはすべてデバイス側のバイトオーダーで行う、ということをすればよい。[脚注12]

PCIのバスマスタデバイスの中には、CPU側がどちらのバイトオーダーであるか指定するビットがあり、そこでホストがビッグエンディアンであると指定するとDMAでのパラメータ受渡しの際にデータを自力でひっくり返してくれるものもごく一部であるが存在する。ただし、それらの中でもちゃんと意図しているとおりには動いてくれないものもあったりで、今まで見た中でhtole32()le32toh()の関数が必要なかったデバイスはSMCの100BASE-TX Ethernetデバイスのsmc83c17xだけである。


[脚注10] DMAで転送されるパラメータのうち、よくあるものはスキャッタ・ギャザのアドレスおよび転送長の組の配列である。

[脚注11] OpenBSDでは letoh32(), betoh32() といった名前を使っているようである。

[脚注12] それらのパラメータに対して演算をする場合はいちいちひっくり返して読み込んで計算した後またひっくり返して書く、という操作が必要になりかなり面倒であるが、通常はCPUのバイトオーダーでパラメータを置いておいて転送直前にひっくり返す、ということをしようとすると、どっちのバイトオーダーになっているのか把握できなくなる恐れがあるのでやめた方がよい。


(4) その他

(1)項において、CPUとバスのバイトオーダーが異なる場合に単純にデータバスの配線をひっくり返すだけではバイト単位アクセス時のアドレスが変わってしまうためにうまくいかないということはすでに述べた。しかし世の中にはいろいろなデバイスがあるもので、ハード的にビッグエンディアンモードとリトルエンディアンモードの切り替え機構が存在し、バイトオーダーを切り替えると内部の接続がまるごと入れ替わってしまうというデバイスも存在する。バスマスタ内蔵SCSIのNCR53C710がそれである。

NCR53C710用のドライバであるosiopのレジスタ定義ファイルsys/dev/ic/osiopreg.hを見てもらうとわかるが、このデバイスではビッグエンディアンの時とリトルエンディアンの時とでバイト幅データポートのアドレスが変わってしまう。

--------------------------------------------------------------------------------
/* byte lane definitions */
#if BYTE_ORDER == LITTLE_ENDIAN
#define BL0	0
#define BL1	1
#define BL2	2
#define BL3	3
#else
#define BL0	3
#define BL1	2
#define BL2	1
#define BL3	0
#endif

#define OSIOP_SCNTL0	(0x00+BL0)	/* rw: SCSI control reg 0 */
#define OSIOP_SCNTL1	(0x00+BL1)	/* rw: SCSI control reg 1 */
#define OSIOP_SDID	(0x00+BL2)	/* rw: SCSI destination ID */
#define OSIOP_SIEN	(0x00+BL3)	/* rw: SCSI interrupt enable */

#define OSIOP_SCID	(0x04+BL0)	/* rw: SCSI Chip ID reg */
#define OSIOP_SXFER	(0x04+BL1)	/* rw: SCSI Transfer reg */
#define OSIOP_SODL	(0x04+BL2)	/* rw: SCSI Output Data Latch */
#define OSIOP_SOCL	(0x04+BL3)	/* rw: SCSI Output Control Latch */
--------------------------------------------------------------------------------

その代わり(3)項で述べたDMAによるパラメータの受渡しではデバイスドライバ側でデータをひっくり返してやるという気遣いは必要ない。

しかし現在のosiopドライバはここで問題を抱えている。上記osiopreg.hで"if BYTE_ORDER == LITTLE_ENDIAN"という比較をしているが、ここでのバイトオーダーはあくまでもCPUのバイトオーダーである。CPUのバイトオーダーとNCR53C710でハード的に設定されているバイトオーダーが同じであればこれで問題はないが、仮にリトルエンディアン設定になっているNCR53C710がビッグエンディアンのホストに接続されるとこのままでは動かなくなってしまう。

そんな設定まで考える必要があるのかという意見もあるかもしれないが、実際にビッグエンディアンであるHPPA CPU搭載のHP9000/700シリーズのマシンには、ビッグエンディアン設定されたオンボード上のNCR53C710とリトルエンディアン設定されたEISA上のNCR53C710が同時に接続される可能性がある。CPUのバイトオーダーとデバイス側の設定が任意に変わるとすると、デバイスドライバ側ではポートアドレスやDMAパラメータ書き込み時のバイトオーダー入れ換えをダイナミックに判定する必要が出てくるが、実際にどのような実装になっていくのかは未定である。

終わりに

始めにも書いたが、デバイスドライバのバイトオーダーの話はいろんなケースを考え出すときりがなく、考えれば考えるほどわけがわからなくなる。そういったことをしていると、そもそもそこまでこだわってドライバを実装する必要があるのかとかそんなレアなケースまで考慮する価値があるのかという声も出てくる。

しかし、ここで取り上げたエンディアンの話に限らず、「単に動かすことを目的とするのではなく、理にかなった設計や実装方法を考える」というのもデバイスドライバ作成の楽しみ方の一つではないだろうか。


NetBSDのページに戻る
tsutsui@ceres.dti.ne.jp