タグ: RISC-V

  • RISC-VのPMP、メモリ領域の境界と優先順位でアクセスを守る

    RISC-VのPMP、メモリ領域の境界と優先順位でアクセスを守る

    読み書きを許可したメモリ領域なのに、その中へのアクセスが拒否される。RISC-VのPMP(Physical Memory Protection、物理メモリ保護)では、権限のビットだけを見ても、この理由が分からないことがあります。複数の領域が重なるときの優先順位と、一度のメモリ操作が触れる範囲も判定に入るためです。

    PMPは、プロセッサの実行単位であるhartごとに、物理アドレスの領域へ読み取り・書き込み・命令実行の権限を設定する仕組みです。ここでは基本のPMPを扱い、二つの領域と小さなアクセス例から、設定を読む順序を整理します。[1]

    簡単にまとめると?

    • アクセス範囲のどれか1バイトに一致する設定のうち、番号が最も小さいPMPエントリーが判定を担当します。広い領域の許可を足し合わせる仕組みではありません。
    • 選ばれたエントリーが、そのメモリ操作の全バイトを覆う必要があります。一部しか覆わなければ、権限ビットやMモードの条件を見る前に失敗します。
    • 一つの命令が複数のメモリ操作へ分かれる場合は、それぞれを検査します。例外が起きても、先に通った書き込みまで元に戻るとは限りません。

    PMPで組み合わせるのは、領域とアクセス権限

    PMPの設定単位をエントリーと呼びます。エントリーにはアドレス範囲を指定する情報と、読み取りのR、書き込みのW、命令実行のXなどを持たせます。たとえばR=1、W=0、X=0なら、その権限が適用されるアクセスに対して、読み取りを許し、書き込みと命令実行を拒否します。[1]

    領域の指定にも方式があります。NAPOTは、サイズが8バイト以上の2のべき乗で、開始位置もそのサイズの境界にそろった領域を表します。TORは、前のエントリーのアドレス設定を下端、現在の設定を上端として範囲を表します。OFFにしたエントリーはアドレスに一致しません。今回の例は、二つの領域を独立に指定しやすいNAPOTを使います。[2]

    以下のアドレスは説明用で、特定の機器のメモリ配置ではありません。例の大きさを表現できる粒度のPMPが実装され、使用する二つ以外のエントリーはOFFとします。まずは通常のユーザー側のUモード、またはOS側のSモードを実効権限とするアクセスを考えます。ここで実効権限とは、実行中のモード名だけでなく、そのメモリアクセスの検査に使われる権限です。

    表1 説明に使う二つの領域。上端の次のアドレスは範囲に含めません。
    エントリー 物理アドレスの範囲 大きさ R / W / X L
    0:小さい領域 0x1800〜0x1BFF 1,024バイト 1 / 0 / 0 0
    1:広い領域 0x1000〜0x1FFF 4,096バイト 1 / 1 / 0 0

    小さい領域は、広い領域の内側にあります。広い領域を読み書きできるようにしつつ、その一部を読み取りだけにしたい、という配置です。Lは後で扱うロックのビットで、ここでは両方0にしています。

    重なった領域では、番号の小さい設定が先に決まる

    PMPは、メモリ操作が触れるどれか1バイトに一致するエントリーを探し、その中で番号が最も小さいものを選びます。小さい領域が自動的に優先されるのではなく、番号が優先順位を決める点が大切です。[3]

    たとえば0x1800〜0x1803へ4バイトを書き込む場合、エントリー0と1の両方が全体に一致します。選ばれるのは0です。そこでW=0を確認するため、この書き込みは拒否されます。後ろにあるエントリー1のW=1へ判定をやり直すことはありません。

    この配置で二つの設定の番号を入れ替えると、広い領域が先に選ばれます。すると内側も書き込み可能となり、小さい領域の読み取り専用設定はそのアクセスを止められません。例外となる領域を重ねる設計では、範囲だけでなく並び順まで確認する必要があります。

    境界をまたぐと、権限があっても通らない

    優先エントリーが決まった後には、もう一つの条件があります。そのエントリーが、一つのメモリ操作の全バイトに一致しなければなりません。先頭アドレスだけが入っていればよいわけではありません。[3]

    0x17FCから8バイト、つまり0x17FC〜0x1803を一つのメモリ操作として検査する場合を見ます。前半4バイトは小さい領域の外、後半4バイトは内側です。後半に一致するエントリー0が最優先になりますが、エントリー0は前半を覆えません。このため操作全体が失敗します。エントリー1が8バイトすべてを覆っていても、この結果は変わりません。

    上段はエントリー1の0x1000から0x1FFFに、番号が小さいエントリー0の0x1800から0x1BFFが重なる配置。下段は0x17FCから0x1803の8バイトを拡大し、後半4バイトだけに一致するエントリー0が優先され、全バイトを覆えないため一つのメモリ操作は拒否されることを示す。
    図1 重なりの全体像と、0x1800の境界を拡大した図。RISC-V特権仕様のPMP判定規則に基づく説明用の独自図。上段の右端は範囲に含まれない上端で、上段と下段の縮尺は異なります。画像を開くと拡大できます。

    この全バイト一致の条件は、L、R、W、Xの値にかかわらず適用されます。したがって同じ8バイトの単一操作なら、読み取りでも失敗し、実効権限がMでL=0の場合も失敗します。Mモードの権限上の扱いを先に当てはめて、境界の検査を省くことはできません。

    表2 実効権限がUまたはSの場合。各行を一つのメモリ操作としてPMPが検査する、という条件での判定です。
    アクセスするバイト 種類 優先エントリー PMPの判定
    0x17FC〜0x17FF(4バイト) 書き込み 1 許可。全体が一致し、W=1
    0x1800〜0x1803(4バイト) 読み取り 0 許可。全体が一致し、R=1
    0x1800〜0x1803(4バイト) 書き込み 0 拒否。W=0
    0x17FC〜0x1803(8バイト) 読み取り/書き込み 0 拒否。全体を覆わない
    0x1C00〜0x1C03(4バイト) 書き込み 1 許可。小さい領域の外で、W=1
    0x2000〜0x2003(4バイト) 読み取り 一致なし 拒否。PMPが実装されていて、許可する領域がない

    ここでの「許可」はPMP検査についての結果です。実際のアクセスには物理メモリ属性(PMA)などの条件も関わるので、PMPを通ったことだけで命令全体の成功が保証されるわけではありません。[1]

    MモードとLビットは、全バイトが一致した後で読む

    Mモードは、PMPの制御レジスターを操作する機械モードです。ただしPMPの判定では、M、S、Uをすべて同じ扱いにはしません。全バイトが一致したエントリーについて、実効権限がMでL=0なら、R/W/Xによる制限を受けずに通ります。L=1ならMにもその権限を適用します。実効権限がSまたはUの場合は、Lが0でも1でもR/W/Xを適用します。[4]

    表1のエントリー0へ収まる4バイトの書き込みなら、実効M・L=0では通り、実効U/SではW=0により拒否されます。エントリー0をL=1にした場合は、実効Mの書き込みも拒否されます。この違いと、先ほどの「全体を覆わないために拒否」を分けると、設定を読み違えにくくなります。

    Lには、設定を固定する役割もあります。L=1にすると、そのエントリーの設定と対応するアドレスレジスターへの書き込みは無視され、hartをリセットするまでロックが続きます。TORのエントリーをロックした場合は、下端を決める一つ前のアドレスレジスターも変更できなくなります。OFFのエントリーでもLによるロックは成立します。領域の形と、後で変更する必要がある設定を確認してから使うビットです。[4]

    どのエントリーにも一致しない場合は、実効Mのメモリ操作はPMP上は成功します。一方、PMPエントリーが少なくとも一つ実装されていれば、実効S/Uの操作は失敗します。全エントリーをOFFにすることは、S/Uへ全面的な許可を与えることにはなりません。PMP自体を実装しない場合とは区別が必要です。[3]

    また、「Mモードでコードを実行中だから、データアクセスも実効M」とは限りません。mstatusのMPRV=1かつMPP=S/Uなら、MモードからのデータアクセスにもそのS/UのPMP検査が適用されます。仮想アドレス変換に伴うページテーブルへのアクセスは、実効Sとして検査されます。命令フェッチとデータアクセスを一括りにせず、検査されるアクセス側の権限を確認します。[1]

    一つの命令が失敗しても、書き込みの全部が取り消されるとは限らない

    これまでの表は、一つのメモリ操作ごとの判定です。プログラム中の一つの命令が、常に一つのメモリ操作になるとは限りません。仕様は、実装によって非整列のロード・ストア・命令フェッチが複数の操作へ分かれ、それぞれにPMP検査が行われる場合を定めています。[3]

    先ほどの0x17FCからの8バイト書き込みについて、ある実装が0x17FC〜0x17FFと0x1800〜0x1803の二つの4バイト操作へ分け、実効U/Sで検査したと仮定します。前半はエントリー1により許可され、後半はエントリー0のW=0で拒否されます。失敗した側がアクセスフォールトを起こしても、通った側の書き込みが既に見える状態になっている可能性があります。

    これは、どの実装もこの分割や順序で動くという意味ではありません。0x17FCは8バイト境界に整列していないため、実機では非整列アクセスの例外処理も関わります。上の例は、指定した単一操作または分割操作がPMP検査へ渡った場合を比べたものです。また、XLENより広いストアでは、自然に整列していても一部だけが成功する同様の状況があり得ると仕様は説明しています。[3]

    そのため、エラー処理を考える際に「例外が出たので、メモリは必ず書き込み前のまま」と置くことはできません。境界をまたがないデータ配置を検討することに加え、対象実装がどのアクセスを分割し、何を原子的に扱うかを確認する必要があります。

    設定を確認するときは、範囲・順序・権限・実装条件を分ける

    アクセスが意図どおりに制限されないときは、まず物理アドレスとバイト数を取り出し、一部でも一致するエントリーを番号順に見ます。次に、最初に一致した領域が全体を覆うかを確かめます。その後で実効権限、L、R/W/Xを読むと、「範囲の失敗」と「権限の失敗」を切り分けられます。

    実機の設定へ進む際は、PMPの実装有無、エントリー数、粒度、書き込み後に読み戻される値も別に確認します。標準の符号化に4バイト領域があることは、すべてのプラットフォームでその粒度が使えるという意味ではありません。ページ方式の仮想メモリを実装するhartでは、PMP設定変更後にSFENCE.VMAなど必要な同期も確認します。仮想メモリをその時点で有効にしているかだけで、省略を判断しないことが大切です。[2][5]

    本記事の図と表は、固定版の仕様に基づく説明用の判定です。実機・エミュレーターの試験結果ではありません。Smepmpによる追加規則、DMAやIOMMUを含む機器全体の保護は範囲に含めていません。PMPを読む際の要点は、許可ビットを探すだけで終えず、どの領域が判定を担当し、どこまでを一つの操作として覆っているかを確かめることです。

    参考資料

    1. RISC-V International, The RISC-V Instruction Set Manual, Volume II: Privileged Architecture — Physical Memory Protection。公式ドキュメントv20260120、§2.1.7および§2.1.7.1。PMPの対象、実効権限、制御レジスターとR/W/X。
    2. 同、Address Matching、§2.1.7.1.1、表13・14。OFF、TOR、NA4、NAPOTと実装粒度。
    3. 同、Priority and Matching Logic、§2.1.7.1.3。最小番号の優先、全バイト一致、不一致時の扱い、分割アクセスの副作用。
    4. 同、Locking and Privilege Mode、§2.1.7.1.2。Lによる固定とMモードへの権限適用。
    5. 同、Physical Memory Protection and Paging、§2.1.7.2、およびPrefaceのVersion 1.12の変更説明。ページテーブルアクセスとPMP変更後の同期。各参照は同一仕様内の関連節であり、別々の実装実証ではありません。
  • RISC-Vベクトル拡張、処理する要素数をCPUに合わせる仕組み

    RISC-Vベクトル拡張、処理する要素数をCPUに合わせる仕組み

    配列に並んだ数値を足す、画像の画素を加工する。こうした処理では、一つの命令で複数の値を扱うベクトル演算が役立ちます。ただ、CPUによって一度に扱えるデータの幅が違うと、プログラムは何個ずつ進めればよいのでしょうか。

    RISC-Vのベクトル拡張(RVV)は、処理したい個数を伝え、今回扱う個数をCPUから受け取る仕組みを備えています。レジスタの長さを表すVLENと、今回の処理数を表すvlを分けると、異なる幅のCPUで同じループを動かせる理由が見えてきます。ここでは、公式マニュアル20260120版に収録されたV拡張Version 1.0をもとに整理します。[1]

    簡単にまとめると?

    • VLENはレジスタ1本のビット数、SEWは要素1個のビット数、LMULはレジスタをどうまとめて使うかの設定です。処理できる最大の要素数は、この組み合わせで決まります。
    • ループはCPUが返したvl個だけ処理し、その分だけ配列の位置と残り個数を進めます。幅の違いや最後の端数を、同じ仕組みで扱えます。
    • 大きなレジスタや大きなLMULが、そのまま高速化の倍率になるわけではありません。対応命令、実行器、メモリ供給も確かめる必要があります。

    VLEN・SEW・LMULは、何の大きさなのか

    まず、レジスタはCPUが計算に使う値を置く場所です。RVVには32本のベクトルレジスタがあり、1本の長さをビット数で表したものがVLENです。これはCPUの実装で決まり、vsetvli命令で伸び縮みするものではありません。[1]

    その入れ物をどの幅の値に区切るかが、SEW(Selected Element Width、選択する要素幅)です。たとえば128ビットのレジスタを32ビットの整数に区切れば4個、64ビットなら2個が入ります。同じ入れ物でも、要素の大きさが変われば個数は変わります。

    LMUL(ベクトル長乗数)は、1回の命令で使うレジスタのまとまり方を決めます。LMUL=2なら、通常の同じ幅の演算では2本を一組として扱います。整数の設定は1、2、4、8で、対応条件のもとでは1/2などの分数設定も使えます。[1]

    この組み合わせから求まる最大要素数がVLMAXです。式はVLMAX = LMUL × VLEN ÷ SEW。ビット数ではなく、何個の要素を扱えるかを表します。

    要素幅とレジスタのまとめ方で変わる最大要素数(仕様の式から作成)
    VLEN SEW LMUL VLMAX 読み方
    128ビット 32ビット 1 4個 1本に32ビットの値が4個
    256ビット 32ビット 1 8個 1本が長くなり、8個入る
    128ビット 32ビット 2 8個 2本をまとめ、8個入る
    128ビット 64ビット 1 2個 要素が大きくなり、2個入る

    表の値は、各設定に対応する実装を想定した計算例です。どれも実機の速度測定ではありません。また、256ビットの1本と128ビットの2本は、容量が同じでも、内部で同じように実行されるとは限りません。

    最大容量と、今回扱う個数は別に決まる

    配列に残っている個数など、ソフトウェアが処理したい数をAVL(Application Vector Length)として渡すと、CPUは現在の設定に合わせたvlを返します。ここでのvlは、マスクで要素を除外しない通常のループで、今回処理する個数です。[1]

    たとえばVLMAX=4のとき、残りが3個ならvl=3です。最大4個入るからといって、配列の外まで4個分を処理する必要はありません。

    ただし、vlは常に「残り個数と最大容量の小さい方」になるとは限りません。ここでは基本のV拡張における、通常の要素単位の演算を対象にします。その場合、仕様は次のように選び方を定めています。

    • AVL ≤ VLMAXなら、vl = AVL。
    • AVL ≥ 2 × VLMAXなら、vl = VLMAX。
    • その中間では、AVL ÷ 2を切り上げた値からVLMAXまでの範囲で選べます。

    したがって、最大4個で残り6個なら、最初に4個、次に2個でも、3個ずつ2回でも仕様に適合します。同じ実装で同じAVLとVLMAXを与えたときの結果は決定的ですが、別の実装でも同じ分け方になるとは限りません。プログラムが使うべきなのは、推測した個数ではなく実際に返されたvlです。[1]

    vsetvliで受け取り、その分だけループを進める

    配列を一度に扱える分ずつ切り分ける方法を、ストリップマイニングと呼びます。RVVでは、このループをvsetvli命令で組み立てられます。設定を命令の中に書くvsetvliに対し、vsetvlは要素幅などの設定値を別のレジスタから受け取る命令です。名前は似ていますが、設定の渡し方が違います。[1]

    32ビットの整数配列二つを足す場合、設定命令は次のように書けます。a0に残りの要素数を入れ、返されたvlをt0で受け取ります。

    vsetvli t0, a0, e32, m1, ta, ma

    e32はSEW=32、m1はLMUL=1です。taとmaは、今回の処理対象外となる末尾の要素やマスクで除外した要素の、レジスタ内の値を保持する必要がないことを示します。対象外の値がゼロになるという約束ではありません。この例はマスクを使わず、対象のvl個だけを読み書きします。

    処理全体を、説明用の擬似コードで表すと次のようになります。これは公式の32ビット整数加算例を参考にした手順の説明で、コンパイルできる言語のコードではありません。[2]

    残り個数 = 配列の要素数
    位置 = 0
    残り個数が 0 より大きい間、繰り返す:
        vsetvli で残り個数と e32, m1, ta, ma を渡し、vl を受け取る
        位置から vl 個を読み、対応する要素同士を足して保存する
        位置 = 位置 + vl
        残り個数 = 残り個数 - vl

    実際のメモリアドレスを進めるときは、要素数からバイト数への換算も必要です。32ビットの要素なら1個4バイトなので、入力・出力のアドレスをそれぞれ4 × vlバイト進めます。公式例も、返された個数を残りから引き、バイト数へ換算してポインタを更新しています。

    同じ16個の配列を扱うなら、SEW=32、LMUL=1のもとで、VLEN=128では4個ずつ4回、VLEN=256では8個ずつ2回になります。この場合の回数は、先ほどの仕様の制約から決まります。ソフトウェアが「このCPUは4個ずつ」と埋め込まなくても、同じ手順で全要素を処理できます。

    固定幅SIMDとの違いと、速さを判断する際の注意

    固定幅SIMDでは、たとえば128ビットを扱う命令なら、32ビットの値を4個まとめて扱う、というように命令の幅が基準になります。端数への対応には、命令セットに応じてマスクや別の処理を使います。

    RVVでは、残り個数を渡して処理数を受け取るため、ループを特定のVLENに依存させずに書けます。これがベクトル長に依存しない、vector-length agnosticなコードの考え方です。ただし、必要な要素型と命令を両方のCPUがサポートし、コードも幅を決め打ちしないなどの条件があります。RISC-VのCPUすべてにV拡張が備わる、あるいはどんなベクトルコードでもそのまま移せる、という意味ではありません。[1]

    また、LMULを大きくすると、より多くの要素を一つの命令の対象にできますが、使うレジスタの本数も増えます。32本を8本ずつまとめれば、独立したまとまりは4組です。複数の途中結果を同時に持ちたい処理では、この使い方が制約になります。さらに、命令が対象とする要素を内部の実行器が何サイクルで処理するか、メモリから十分にデータを供給できるかは、最大要素数だけではわかりません。

    CPUやコンパイラの説明を読むときは、まず対応する拡張・要素幅を確認し、次にVLENと実行性能の測定結果を分けて見ます。自分の処理で速さを比べるなら、要素型、配列の長さ、LMUL、メモリ条件をそろえた測定が必要です。本稿の個数とループ回数は仕様からの計算例であり、実行時間の測定値ではありません。

    出典

    1. RISC-V International, The RISC-V Instruction Set Manual, 20260120版、30.1. “V” Standard Extension for Vector Operations, Version 1.0。実装定数、レジスタモデル、SEW・LMUL、vl設定制約、ストリップマイニング。補助照合:固定commitの仕様ソース。
    2. RISC-V International, Vector-vector add example(vvaddint32.s)。上記固定commitの32ビット整数加算例。原資料の利用条件はCC BY 4.0。本文の擬似コードは同例を参考に、変数名・表現を説明用に改めたものです。