組み込みまするβ

組み込みソフトウェア初学者向けブログ

IOアクセスの共通化

IOアクセスを共通化する理由は何でしょうか?

例えばCPU内蔵の周辺回路は、他のCPUから動かすことはないのでベンダ提供(専用)のIOアクセスで問題ありません。 ではCPUに依存しない単体のNOR-Flashバイス等はどうでしょうか? このような汎用的なデバイスの場合は移植性を考慮し、共通のIOアクセスでコーディングしておくと良いです。 NOR-Flashに限らずFPGAで動作させるユーザーロジックも、 ハードマクロのARM、SH、RXやソフトマクロのRISC-V、Nios2、MicroBlaze等、様々なCPUで動作させる可能性が高いので共通化しておく価値があります。

マクロを利用したIOアクセス

inlineが使えない環境でのIOアクセスは、 今でもマクロを用いた以下のような実装がされていると思います。

#define IO_READ8(base_addr, offset_addr)  *(volatile uint8_t  *)((base_addr) + (offset_addr))
#define IO_READ16(base_addr, offset_addr) *(volatile uint16_t *)((base_addr) + (offset_addr))
#define IO_READ32(base_addr, offset_addr) *(volatile uint32_t *)((base_addr) + (offset_addr))

#define IO_WRITE8(base_addr, offset_addr, value)  *(volatile uint8_t  *)((base_addr) + (offset_addr)) = (value)
#define IO_WRITE16(base_addr, offset_addr, value) *(volatile uint16_t *)((base_addr) + (offset_addr)) = (value)
#define IO_WRITE32(base_addr, offset_addr, value) *(volatile uint32_t *)((base_addr) + (offset_addr)) = (value)

volatileの必要性

volatileの意味合いは最適化の抑制ですが、IOアクセスには何故volatileが必要なのでしょうか?

例1:FIFO構造になっているレジスタに連続してアクセスするようなコードがあった場合、 volatileなしで次のようなコードを書いてみましょう。

#define IO_WRITE8(base_addr, offset_addr, value)  *(uint8_t  *)(base_addr + offset_addr) = (value)

IO_WRITE8(UART_BASE, TX_FIFO_REG, 'O');
IO_WRITE8(UART_BASE, TX_FIFO_REG, 'K');
IO_WRITE8(UART_BASE, TX_FIFO_REG, '\r');

このコードは最適化によってどのようになるでしょうか。 まず、同じメモリアドレスに対して連続書き込みをしていますが、 1文字ごとにメモリアクセスするよりは汎用レジスタ上で3文字分書き込んでからメモリアクセスをすれば、 1回目と2回目のメモリアクセスを無くすことができますよね。 このため、次のように最後のメモリアクセスのみに最適化される可能性が高くなります。

(汎用レジスタに'O''K'を書き込んでいる)
IO_WRITE8(UART_BASE, TX_FIFO_REG, '\r'); /* 汎用レジスタに'\r'を書き込み、汎用レジスタの値をメモリアドレスに書き込む */

何故このようなことが起きるのでしょうか? コンパイラには以下のように単なる変数と同様のメモリアクセスに見えるので、

uint8_t a;
a = 'O';  /* (1) */
a = 'K';  /* (2) */
a = '\r'; /* (3) */

最適化によって無駄と判断された(1)、(2)のメモリアクセスが削られてしまうことになります。

uint8_t a;
uint8_t temp; /* tempは通常汎用レジスタになると思われますが、汎用レジスタはC言語で表現できないのでaよりも高速にアクセス可能な場所にあると考えましょう */
temp = 'O';
temp = 'K';
temp = '\r';
a = temp; /* (3) */

例2:SPIコントローラのレジスタにアクセスするようなコードがあった場合、 volatileなしで次のようなコードを書いてみましょう。 このSPIコントローラの使用方法はADDR、DATAレジスタに値を設定してから、 CONTROLレジスタでWRITE、READ動作を開始させます(つまり順序依存があるということ)。 動作完了はSTATUSレジスタのBUSYフラグで判定するものとします。

#define IO_READ32(base_addr, offset_addr) *(uint32_t *)((base_addr) + (offset_addr))
#define IO_WRITE32(base_addr, offset_addr, value) *(uint32_t *)((base_addr) + (offset_addr)) = (value)

#define ADDR_REG 0x0000
#define DATA_REG 0x0004
#define CONTROL_REG 0x0008
#define STATUS_REG 0x000C

#define CONTROL_WRITE_START 0x00000001
#define CONTROL_READ_START 0x00000002
#define STATUS_BUSY 0x00000001

IO_WRITE32(SPI_BASE, ADDR_REG, 0x1000); /* (1) アドレスを設定 */
IO_WRITE32(SPI_BASE, DATA_REG, 0xA5A5); /* (2) 書き込むデータを設定 */
IO_WRITE32(SPI_BASE, CONTROL_REG, CONTROL_WRITE_START); /* (3) WRITEスタート */

while (IO_READ32(SPI_BASE, STATUS_REG ) & STATUS_BUSY) { /* (4) WRITE完了(BUSYフラグが落ちる)まで待つ */
    (タイムアウト判定等)
}

このコードは最適化によってどのようになるでしょうか。 (1)~(3)についてはそれぞれのメモリアドレスが異なるし、コード上からは依存関係が読み取れないですよね。 このため、単なるメモリならば処理順序を入れ替えても同じ結果となるはずなので、 最適化によっては処理順序の入れ替えが行われる可能性があります(今回の例では入れ替えの可能性は低いと思いますが)。

(4)についてはメモリアドレスのポーリングとなっていますが、最適化を行うとどうなるでしょう。 同じメモリアドレスに対してポーリングするのはメモリアクセスの無駄になるため、 最初の一度だけメモリアドレスから汎用レジスタに読み出し、以後は汎用レジスタをポーリングするようなコードになると思われます。

つまり、コンパイラには以下のようなC言語のコードに見えるため

uint32_t addr;
uint32_t data;
uint32_t control;
uint32_t status = 0xXXXXXXXX; /* 何等かの値が設定されていると仮定する */

addr = 0x1000;  /* (1) */
data = 0xA5A5;  /* (2) */
control = CONTROL_WRITE_START; /* (3) */

while (status & STATUS_BUSY) { /* (4) */
    (タイムアウト判定等)
}

最適化によって次のような意味合いのコードに変わってしまう可能性があるのです。

uint32_t addr;
uint32_t data;
uint32_t control;
uint32_t status = 0xXXXXXXXX; /* 何等かの値が設定されていると仮定する */

addr = 0x1000;  /* (1) */
control = CONTROL_WRITE_START; /* (3) */
data = 0xA5A5;  /* (2) */

const uint32_t temp = status; /* tempは通常汎用レジスタになると思われますが、汎用レジスタはC言語で表現できないのでstatusよりも高速にアクセス可能な場所にあると考えましょう */
while (temp & STATUS_BUSY) { /* (4) */
    (タイムアウト判定等)
}

メモリマップドIOを採用している場合、コンパイラはIOとメモリを区別することができません。 具体例で示すと、とあるアドレス空間SRAMを接続する場合もあれば、 IOアクセスが必要なFPGAレジスタ空間やNorFlashROM、DualPort-RAMのようなメモリを接続することもあります。 しかしながら、どのようなデバイスが接続されているのかを言語レベルでコンパイラに伝える方法がないので、 volatileを利用して意図したメモリアクセスをさせる必要があるのです。

なお、上記のようなアクセスをしない場合でもIOアクセスは一律にvolatileをつけるのが一般的です。 何故なら上記のレジスタ間での順序依存の可能性もあるし、そのようなレジスタが後から追加される可能性もあるからです。

通常、最適化は関数単位で行われ、汎用レジスタを上手に使い、低速なメモリアクセスを可能な限り減らそうとします。 また、高速化可能であれば依存関係がない演算も順序入れ替えを行います(C言語/C++レベルで順序依存がない場合)。 難しい言葉を使いたくないのですが、ここでの高速化というのは順序入れ替えによって演算器を並列に動かすことができれば、 オーバーラップ分のサイクル数を減らせるということをいっています。 有限の演算器を待たせずに効率よく使うため、コンパイラが命令の実行順序をスケジュールするということです。

データキャッシュ

IOアクセスで気をつけるのは最適化だけではなく、データキャッシュにも気をつけなければなりません。 IOアクセスは実体へアクセスしなければならないため、データキャッシュが効かないようにアクセスします。 これはCPU毎に方法が異なるため、ベンダ提供のマクロやIOアクセスの手段に従うことになります。

IOアクセスの共通化

昨今の組み込み開発環境では、最初からIOアクセスの手段が提供されている場合が多いと思います。 しかしながら、その正規の手段には他のCPUとの互換性がない場合が多いです。 以下にいくつか実例をあげておきます。

Nios2

IOアクセスするための専用マクロが提供されています。 IORD_xxx、IOWR_xxxを使用するとデータキャッシュをバイパスする命令に置き換えてくれます。

MicroBlaze

CPU周辺の設定を行うツールによって、アドレス空間毎にキャッシュの使用を設定するため、 ソフトウェア側からはデータキャッシュの制御はできません。 このためvolatileを使ったメモリアクセスのみで実現できますが、マクロや関数でIOアクセス手段が提供されています。 ※ツールのバージョンによって実装が異なるようですが、マクロ名(または関数名)は同じなので使い方は変わらないように管理されているようです。

厳密にはソフトウェアでキャッシュ制御できないわけではありませんが、 MSRの制御になるのでアセンブリ言語でしか記述できません。

Other

その他、RenesasのプロセッサタイプのSH系ならアドレスの上位ビットでキャッシュ領域を切り替えたりできたと思います。 base_addrで非キャッシュ領域を指定すれば、他のCPU環境と同じインライン関数が使えそうではありますね。

inline関数を利用したIOアクセス

C99以降ならばinlineキーワードが使えるのでIOアクセスをインライン関数で共通化してみましょう。

簡素化のため32bitアクセスのみのコード例にしています

/* device_io.h */

#include <stdint.h>

/*--- ALTERA Nios II --------------------------------------------------*/
#if defined(__NIOS2__)
#include <io.h>
static inline uint32_t device_read_io32(const uint32_t base_addr, const uint32_t offset_addr) {
    return IORD_32DIRECT(base_addr, offset_addr);
}
static inline void device_write_io32(const uint32_t base_addr, const uint32_t offset_addr, const uint32_t data) {
    IOWR_32DIRECT(base_addr, offset_addr, data);
}

/*--- XILINX MicroBlaze ------------------------------------------------------*/
#elif defined(__MICROBLAZE__)
#include "xil_io.h"
static inline uint32_t device_read_io32(const uint32_t base_addr, const uint32_t offset_addr) {
    return Xil_In32(base_addr + offset_addr);
}
static inline void device_write_io32(const uint32_t base_addr, const uint32_t offset_addr, const uint32_t data) {
    Xil_Out32((base_addr + offset_addr), data);
}

/*--- Other Processor --------------------------------------------------------*/
#else
static inline uint32_t device_read_io32(const uint32_t base_addr, const uint32_t offset_addr) {
    return *(volatile uint32_t*)(base_addr + offset_addr);
}
static inline void device_write_io32(const uint32_t base_addr, const uint32_t offset_addr, const uint32_t data) {
    *(volatile uint32_t*)(base_addr + offset_addr) = data;
}
#endif

まとめ

汎用的なデバイスの制御には共通のIOアクセスを採用することで移植性の高いコードにできることがわかりましたね。 また、具体的なIOアクセス方法とvolatileの必要性についても理解できたと思いますので、 デバイスドライバのコーディングの際に思い出してみてください。