ESP32のULPアセンブリ言語入門 その7 マシン語の構造と逆アセンブラ

概要

前回まででCマクロで定義されている命令をすべて説明しました。今回はマシン語の構造と、逆アセンブラの説明をしたいと思います。

命令の概要

ESP32のULPは、1命令が4バイト固定のRISCアーキテクチャです。一般的なパソコンに使われているIntelのCPUはCISCアーキテクチャで、単純な命令は1命令が1バイトと、命令によってバイト数が異なります。

そのため、CISCの場合にはレジスタに0を代入するのでも、 MOVE命令で0を代入すると2バイトになってしまうので、同じレジスタ同士XORをして0クリアすると1バイトになるなどのテクニックがありました。

ULPの場合には、各命令でサイズは共通ですのであまり気にする必要はありません。ただし、命令によって実行に必要なサイクル数が異なります。

MOVEで数値を代入するのはサイクル数は2ですが、LDで外部メモリからデータを取得するサイクル数は4と、より多くの時間が必要になります。とはいえ、ULPの場合にはそれほどサイクル数を気にする必要はないと思います。

マシン語の構造

ULPの命令は4バイトですので32ビット固定です。先頭4ビットをオペレーションコード(OpCode)とよび、大まかな命令の種類を定義しています。

OpCodeコード値(10進数)コード値(2進数)
REG_WR10001
REG_RD20010
I2C_RD30011
I2C_WR30011
WAIT40100
ADC50101
ST60110
演算系(ALU)70111
JUMP系81000
WAKE/SLEEP91001
HALT111011
LD131101

おそらく0がNOPになると思いますが、リファレンスでは定義されていませんでした。STとLDの番号が離れているのがちょっと気になりますが、OpCode自体の数値にあまり意味はないと思います。

4桁ですので16種類のコードが定義できますが、実装順で割り振っている気もします。ここで気をつけないといけないのがデータを表すOpCodeが定義されていません。この実装は失敗で、データにもOpCodeを割り振った方が良かったと思います。

それでは実際の命令の構造を何個か見ていきたいと思います。

演算系(ALU)

上記のテクニカルリファレンスにULPの命令が記載されています。OpCode7が一番複雑なので、こちらを解説したいと思います。

まず一番最初の図が32ビットの命令に対して、どこのどの役割を割り当てているのかを書いてあります。

上の画像です。左側の上位から見ていくと、28ビット目から31ビット目までは4’d7と書いてあります。これは4ビットで10進数(d)の7が入ることを表します。一番左の4ビットがOpCodeになります。

次が25ビットから27ビット目に3’b0をあるので、3ビットで2進数の0が入ることを表します。ここの項目は次のページを見ないとわからないのですが、0の場合には引数がレジスタになります。I_ADDRなどのCマクロですね。1の場合には数値を引数にするIがつくI_ADDIなどのCマクロに相当します。2の場合にはCマクロで定義されていないステージカウンタの命令になります。

3番目が21ビットから24ビットでALU_selとあります。

ALU_selでどの演算になるのかを設定しています。

6ビットから20ビットまでは何もかかれていませんので、未使用です。

最後に2ビットずつ3つのレジスタを指定する場所があります。R0は0、R1は1、R2は2、R3は3の数値が対応しています。

こちらが、一番左のOpCodeが7で、2つ目のSubOpCodeが1で、引数に数値をとる命令のリファレンスになります。

Rsrc2の変わりに、Immで16ビットの数値を指定するように変わっています。

ST

ちょっと特徴的なのがSTです。

命令の構造的には普通なのですが、中央に以下の表記があります。

Mem [ Rdst + Offset ]{31:0} = {PC[10:0], 5’b0, Rsrc[15:0]}

STで代入する数値は16ビット分です。上位16ビットにはプログラムカウンタ(PC)を5ビットシフトした数値が入っていると記載されています。

データ領域は32ビットですが、実際に使えるのは下位16ビットのみとなります。また、PCとはSTが実行されたときのアドレスになります。

つまり、データをみることで、このアドレスのSTから書き込まれたのかがわかるようになっています。この機能自体は便利なのですが、OpCodeのエリアにも書き込んでしまうため、データと命令の区別がつきません。後ろにある5ビット分の00000を先頭に書き込んでくれればOpCodeの0はデータかNOPのどちらかと区別がつくのですが。。。

ちなみに5ビット分の0は、実機で動かすと00000ではなく、00011と仕様書と違った数値が代入されています。

逆アセンブラの仕組み

逆アセンブラとは、マシン語に変換されている命令を、アセンブリ言語に戻す処理のことです。アセンブリ言語からマシン語に変換するアセンブラとは逆の処理ですので、逆アセンブラと呼ばれます。

自作ライブラリのESP32 ULP Debuggerでは、32ビットのメモリ上の命令を読み込み、OpCodeなどの情報から、どんな命令かを解析してシリアルに出力する処理を行っています。

上記が解析したときに利用したデータです。この他にCPU温度を取得するTSENSがCマクロではOpCode10であるのですが、未実装と書いてあり、テクニカルリファレンスにも記述していませんので、除いてあります。

Cマクロの仕組み

Cマクロでは、ulp_insn_tという共用体(union)を利用しています。共用体よりはunionの方がよく使う呼び方な気がします。

unionは、変数を複数の使い方でアクセスすることができます。

typedef union {
    struct {
        uint32_t cycles : 16;       /*!< Number of cycles to sleep */
        uint32_t unused : 12;       /*!< Unused */
        uint32_t opcode : 4;        /*!< Opcode (OPCODE_DELAY) */
    } delay;                        /*!< Format of DELAY instruction */

    struct {
        uint32_t unused : 28;       /*!< Unused */
        uint32_t opcode : 4;        /*!< Opcode (OPCODE_HALT) */
    } halt;                         /*!< Format of HALT instruction */
} ulp_insn_t;

かなりの命令を省略していますが、上記のような使い方ができます。

    struct {
        uint32_t unused : 28;       /*!< Unused */
        uint32_t opcode : 4;        /*!< Opcode (OPCODE_HALT) */
    } halt;                         /*!< Format of HALT instruction */

HALTの命令はOpCodeが4ビットで、残り28ビットは利用していません。unionの場合は下位から何ビット使うかを指定するので、下位から28ビットは未使用で、上位4ビットをopcodeに使うと定義されています。

    struct {
        uint32_t cycles : 16;       /*!< Number of cycles to sleep */
        uint32_t unused : 12;       /*!< Unused */
        uint32_t opcode : 4;        /*!< Opcode (OPCODE_DELAY) */
    } delay;                        /*!< Format of DELAY instruction */

上記はスリープをするDELAY命令です。下位16ビットでスリープするサイクル数を指定して、上位4ビットがDELAYのOpCodeが入ります。

#define I_DELAY(cycles_) { .delay = {\
    .cycles = cycles_, \
    .unused = 0, \
    .opcode = OPCODE_DELAY } }

実際のCマクロが上記になります。OPCODE_DELAYはDELAYのOpCodeである4を代入し、cyclesに16ビットのサイクル数を代入しています。

#define M_LABEL(label_num) { .macro = { \
    .label = label_num, \
    .unused = 0, \
    .sub_opcode = SUB_OPCODE_MACRO_LABEL, \
    .opcode = OPCODE_MACRO } }

ちょっと特徴的なのがM_LABEL()のマクロです。OPCODE_MACROは15なのですが、実際のULPで15は未定義の命令です。

ジャンプ先のラベルとして、未定義の命令をとりあえず入れておき、ulp_process_macros_and_load()関数で転送するときに、実際のアドレスが決まるので置換しています。

この仕組があるのでラベルなども使えるよになっているのですが、転送時に変換している関係で、一度に転送できる命令がArduino IDEでは128命令に制限されています。

この数値は本来設定ファイルで変更できるのですが、ライブラリ内部でコンパイル済みの値が保存されているため変更できませんので注意してください。

ネクストステップ

ここまでで、一般的なULP入門の内容は完了です。記事自体はCマクロで定義されていないI2Cなどを自分で定義して動かすなどの内容を続けたいと思いますが、これまでに書いてあったことをなんとなく理解できていれば、他のアセンブリ言語を理解する足場になると思います。

一夜漬けCASL

私はこの本でアセンブラ言語の基礎を学びました。かなり古い本なので図書館などで借りて見るのがいいと思います。ざっくりとアセンブリ言語ってどんなものかを理解するのに適しています。

仕様的にはULPよりちょっとだけ複雑ですが、非常に単純な処理系です。ただし、この記事を読んでいるのであればCASLを勉強する必要はあまりないと思います。

LASM体験版(8086)

Windowsを使っている人には、上記をおすすめします。100行までしかアセンブリできませんが、Intel系CPUの8086アセンブリが無料で使うことができます。

ヘルプファイルなどが提供されていますので、できれば全部のページを見てほしいと思います。

参考図書としては、上記あたりでしょうか?

みんな絶版ですね。こちらも図書館で探すのがよいと思います。一番重要なのはアセンブリ言語自体を理解するよりは、アセンブラやリンカの使い方を含めて学ぶことです。

LSI C試食版

こちらもWindows版ですが、アセンブリではなくC言語の開発環境です。こちらのアプリもマニュアルが充実しており、中身を読むとかなり勉強になります。

ゴールとしてはLASMで作った関数を、LSI Cのmain関数から呼び出してみるところになります。アセンブラで行う処理自体は単純で構いません。引数をインクリメントして返すぐらいの処理でも構わないので、makefileからオブジェクトファイルを作成して、リンクして実行ファイルにするところまでできると、メモリマップとかも意識できます。

makeを含めた低レベルなコマンドラインの仕組みを勉強するのは非常に難しいのと、いま勉強している人は少ないのでおすすめです。

RISC-V

リスクファイブと読むそうです。オープンソースのCPUで、ESP32-S2のULPにも採用されています。

非常に伸びてきており、アセンブリ言語で実際に使う確率が高いのが、このRISC-Vになると思います。

ただ、敷居がものすごい高いので、予備知識なしでは歯が立たないと思います。

その他

大きめの書店などで中身を見てみるのがよいと思いますが、適当に目についた書籍をあげておきます。

RISC-Vの前に、なにか1つか2つ他のアーキテクチャを勉強してから勉強することをおすすめします。定番であればZ80などですが、実際に動かしたい場合にはPICやIntel系(8086, 486など)が動かしやすいと思います。

ESP32のメインコアにも、Arduino UNOのAVRなどにもアセンブリでプログラミングすることはできますが、絶対的に資料が少ないのでおすすめしません。

勉強であれば、一般的で資料が多いアーキテクチャでチャレンジするのがよいと思います。

まとめ

ULPは入門用としてはかなりシンプルでおすすめです。しかしながらより深く勉強するほどの価値はありませんので、概要までわかったら他の環境を勉強することをおすすめします。

ここまでで、他のCPUを理解するための基礎力はついているはずです。ぜひRISC-Vにチャレンジして、撃沈してみてください。

続編

コメントする

メールアドレスが公開されることはありません。

管理者承認後にページに追加されます。公開されたくない相談はその旨本文に記載するかTwitterなどでDM投げてください。またスパム対策として、日本語が含まれない投稿は無視されますのでご注意ください。