行の構造とLISTコマンド

戻る



 シリアル端末から入力されたBASICプログラムはメモリに蓄えられます。それがどんな形式で、どう表示されるのかを今回は見ていきましょう。
今回は入力されたプログラムをメモリに格納する処理(ラベル名でいうとENTPRG)の解説はしません。そのおおざっぱな働きのみ述べて、それがLISTコマンドで出力されるところについて詳しく解説します。先に行の構造を知っておいてからENTPRGを見たほうが理解しやすいと思います。

 1行の構造は次のようになっています。

内容

サイズ


例の16進表示

行番号

4バイト

10
00 00 00 0A

長さ

1バイト

15
0F

文字列

可変長

PRINT "ABC"
50 52 49 4E 54 20 22 41 42 43 22

行末の'\0'

1バイト

'\0'
00

境界合わせの詰め物

可変長

'\0' '\0' '\0'
00 00 00


 1行が入力されると、まず行番号は4バイトの符号無しバイナリ値に変換されます。アスキーコードのままだと行番号の検索が大変だからです。さて、皆さんご存知のとおり、SHは4バイト数値は必ず4バイト境界に置かれなければいけません。したがってCCMBでも行の先頭は必ず4バイト境界に置かれます。
 次に余分な空白を取り除きます。行番号の直後に空白があれば1個だけ取り除かれます、また行末の空白はすべて削除されます。
 そして、行末には最低限1個の値がH'00のバイトが追加されます。その結果次の行の先頭番地が4バイト境界に合わないようなら合うまで値がH'00のバイトが追加されます。

 行の長さバイト(行の先頭から4バイト目)の値は文字列の長さではなく、「長さバイトを取り込んだあとその値を加算すると次の行の先頭アドレスになる」ように計算された値です。つまり、
	MOV.L	@R3+,R2	; 行番号取り込み
	MOV.B	@R3+,R0	; 長さバイト取り込み
	EXTU.B	R0,R0	; 符号無し化
	ADD	R0,R3	; この結果、R3は次の行の先頭を指す
; (このコード例はパイプラインストールをまったく考慮していません)

とすれば簡単に次の行の先頭番地を求めることができます。LISTコマンドなどの実行時には値がH'00のバイトが見つかるまで出力すればよいのですから文字列の厳密な長さは必要ありません。

 実際にメモリ上にプログラムが格納されている様子を見てみましょう。CMBならDUMP文一発なのですがCCMBにはDUMP文がないので同等のプログラムを組んで自分自身をダンプすることにします。
 10 FOR I=0 TO 25
 20   FOR J=0 TO 15 
 30     D=PEEK(I*16+J-12288)
 40     H=(D/16) AND 15:L=D AND 15
 50     IF(H>9) H=H+7
 60     IF(L>9) L=L+7
 70     PRINT CHR(H+48),CHR(L+48)," ",
 80   NEXT
 90   PRINT " ",
100   FOR J=0 TO 15 
110     D=PEEK(I*16+J-12288)
120     IF((D<32) OR (D>126)) D=46
130     PRINT CHR(D),
140   NEXT
150   PRINT
160 NEXT
170 END

 プログラム中の-12288とは16進でH'FFFFD000で、SH7046内蔵RAMの先頭番地です。BASICプログラムは内蔵RAMの先頭から格納されます。では実行してみましょう。以下の実行結果は、わかりやすくするために行番号は青長さバイトは赤行末の'\0'と詰め物は緑に着色しました。最後の方の灰色に着色された部分はBASICプログラムではなく、そのときたまたまメモリ上にあった値です。
RUN
00 00 00 0A 0F 46 4F 52 20 49 3D 30 20 54 4F 20  .....FOR I=0 TO
32 35 00 00 00 00 00 14 13 20 20 46 4F 52 20 4A  25.......  FOR J
3D 30 20 54 4F 20 31 35 00 00 00 00 00 00 00 1E  =0 TO 15........
1B 20 20 20 20 44 3D 50 45 45 4B 28 49 2A 31 36  .    D=PEEK(I*16
2B 4A 2D 31 32 32 38 38 29 00 00 00 00 00 00 28  +J-12288)......(
	 :
	(中略)
	 :
52 49 4E 54 00 00 00 00 00 00 00 A0 07 4E 45 58  RINT.........NEX
54 00 00 00 00 00 00 AA 07 45 4E 44 00 00 00 00  T........END....
00 00 00 00 03 00 00 00 01 00 03 0A 5E 9E 57 FF  ............^.W.
OK

 まず先頭に 00 00 00 0A とあり、これが最初の行番号10を表しています。次に 0F とあります。その 0F から H'0F、すなわち15バイト進むと00 00 00 14 とあり、行番号20、つまり次の行の先頭番地であることがわかります。
 話は最初の行に戻りますが、ここからは行番号10の内容が、先頭の1個の空白を取り除かれてそのまま格納されています。末尾には 00 が、4バイト境界が合うまで追加されています。
 そして、行番号170番の最後の行の直後には 00 00 00 00 03 00 00 00 という「見えない行」が付加されています。行番号は0で、次の行へのオフセット3ですがその3バイトはすべて 00 で、実質有効な文字列がありません。
 行番号0の行は、シリアル端末からの入力では入れられません。インタプリタが自動的にプログラムの最後に付加します。何のためかというと、RUNやLISTを実行するとき、プログラムの終わりを判断するためです。インタプリタは行番号が0であるような行を見つけるとそこがプログラムの終わりだと判断します。

 プログラムの最後を判断するには、このような「見えない行」を置くやり方の他にプログラムの最後の番地を保持するポインタを設ける方法もありますが、いちいちポインタの比較をやっていると速度が落ちます(SHクラスだとほとんど気にならない程度でしょうが、8ビットマイコンなどではかなりの痛手です。CMBは最初はZ80用に作られました)。次の行の先頭を読んで、行番号が0なら終わり、と判断するほうが簡単です。

 ではこれから、LISTコマンドの中身を見ていきましょう。
XLIST:	BSR	SKIPSP	; LISTの後の空白をスキップ
	MOV	EXEP,R3	; 実行ポインタをR3にコピー(遅延スロット)

	MOV	R3,EXEP	; 空白スキップ後のR3を実行ポインタに書き戻す
	BSR	GETRNG	; 行範囲獲得ルーチン
	NOP		; しまった! 今気づいたけど、MOV R3,EXEPをここに入れればよかった・・・
	; 言い訳がましいようですが、CMBではここは NOP を入れざるをえないプログラム構造になっていました

	MOV.L	@(LRANGT-WRKTOP,GBR),R0	; 獲得した行範囲の最初の行番号
	BSR	SCNHSL			; R2と等しいか大きい行番号を探す、結果の番地はR3に
	MOV	R0,R2			; 最初の行番号をR2にセット(遅延スロット)

 この部分については、SKIPSP や SCNHSL という重要なルーチンの説明がまだなので詳しい説明は省きますが、ようするにLISTコマンドのパラメータとして行範囲をコマンドラインから獲得するということをやっています。その結果としてR3には最初にLIST表示すべき行の開始番地が入っています。

LISTLP: MOV.L	@R3,R2			; 行番号を獲得
	MOV.L	R3,@-R15		; 行先頭番地を保存
	MOV.L	@(LRANGE-WRKTOP,GBR),R0	; 行範囲の最後の行番号を獲得
	TST	R2,R2			; 今見ている行の行番号は0か?
	BT	LISTE			; 0ならプログラムの最後なので終了
	CMP/HI	R0,R2			; 行番号が行範囲の最後の行番号より大きいか?
	BT	LISTE 			; 大きければその行は表示せずに終了

 ここはループの先頭です。ここで繰り返し処理を続けるかどうか判断しています。LISTコマンドは、指定範囲内に行が無かった場合などはまったく処理をしないこともあるので、どうしても前判断WHILEループになります。

 まず、R3は行の先頭の行番号を指しているはずですからそれをR2にロードします。そしていったんR3をスタックに保存します。
 次に、行範囲の最後の行番号をR0にロードします。そして TST でR2(今見ている行の行番号)が0かどうか調べ、0なら LISTE(LISTコマンド正常終了)に分岐します。
 さらにR0と比較し、指定の行範囲よりも大きな行番号だった場合も LISTE に分岐します。

 ここで、R2に行番号をロードしてからそれが0かどうか調べるまで結構間が開いていることに気づいた人もいると思います。なぜロードしてすぐ調べないのか? それはパイプラインストールを防ぐためです。SHはパイプライン動作をしているので、メモリからの読みこみ命令が実行されてもそれが実際にレジスタにロードされ使用可能になるのは3クロック後です。もしその値を使うような命令を直後に書くと、ロードが完了するまでCPUは待たされ、せっかくのパイプラインの速度を殺してしまいます。これが「パイプラインストール」です。
 パイプラインストールを防ぐには、値をロードする命令とそれを使う命令の間に他の処理を入れます。「いかにパイプラインを止めないコードを書くか」がSHプログラミングの醍醐味です。

 パイプラインとは別の話ですが、「いかに遅延スロットに有効な命令を入れるか」もまたSHプログラミングの醍醐味です。SHのアセンブラプログラムをイヤがる人が多いのはパイプラインやら遅延スロットやらが煩わしいからだと思いますが、趣味でやるぶんにはパズルを解くような楽しさがあり、やめられません。さんざん悩んだ末ツボにはまったコードを書けたときの爽快感は格別です。
 初心者の人は、いきなりあまり難しく考えると手が止まってしまいますので、とりあえずパイプラインは考えずH8のようにコードを書き、また遅延スロットにはとりあえず NOP を入れて、あとで順番を入れ換えるようにすると良いようです。



	BSR	LSTLIN		; 1行表示
	NOP

	BSR	CHKBRK		; CTRL-Cチェック
	NOP

	MOV.L	@R15+,R3	; 保存したR3を復元
	MOV.B	@(4,R3),R0	; 次の行までのオフセット
	ADD	#5,R3		; 行番号と長さをスキップ
	EXTU.B	R0,R0	 	; 符号無し化

	BRA	LISTLP		; ループ先頭に戻る
	ADD	R0,R3		; 次の行の先頭を計算(遅延スロット)

 これがループ処理本体です。LSTLIN はR3が指す1行を表示するルーチンです。1行表示処理はLISTコマンドだけでなくエラー発生時にも使うのでLISTコマンドのループの中には入れずサブルーチンとしています。CHKBRK は、CTRL-C キーが押されたかどうかを調べるルーチンで、押されていなければ何事も無かったかのように戻ってきます。押されていた場合は呼び出し元には戻ってきません。

 そして先ほど保存したR3を復元します。R3を保存したのは LSTLIN がR3を壊すからです。
 次の MOV.B @(4,R3),R0 で長さを獲得します。(非常に悔しいのですが、ここ、パイプラインストールしてます)
 そのあと次の行の先頭番地をR3に求めてループ先頭に戻ります。

LISTE:	BRA	TOPPRM
	NOP

 LISTコマンドの正常終了処理です。ただトップレベルプロンプトに飛んでいるだけです。
 ちょっとSHのプログラムをかじった人なら、「 LSTLIN や CHKBRK を呼ぶ前になぜPRを保存しないの?」と疑問を感じたかもしれません。それは、LISTは文ではなくコマンドだからです。文ならその次の文を実行するために呼び出し元に戻る必要がありますが、コマンドなら実行後はトップレベルプロンプトにジャンプしてしまうので戻り番地を覚えておく必要は無いのです。覚えていたところでトップレベルではR15のハードスタックもR14のソフトスタックも初期化されてしまいます。

 こんどは LSTLIN の中身を見てみましょう。
LSTLIN:	STS.L	PR,@-R15	; PRを保存

	BSR	UDOTR		; R2の値を十進符号無し右詰め表示
	MOV.L	@R3+,R2		; R2に行番号獲得(遅延スロット)

	BSR	SPACE		; 空白1文字表示
	ADD	#1,R3		; 長さバイトをスキップ(遅延スロット)

	BSR	PUTSTR		; R2が指す文字列表示
	MOV	R3,R2		; 行の文字列部分の先頭→R2(遅延スロット)

	BRA	CRLF		; 改行
	LDS.L	@R15+,PR	; PRを復帰(遅延スロット)

 これは呼び出し元に戻らなければいけないのでPRを保存します。その後R3は行番号部分を指しているはずなので行番号を獲得します。LISTコマンドから呼ばれた場合はすでにR2には行番号が入っているのですが、エラー表示のときにはこれは必要です。そして UDOTR でその値を表示します。
 長さバイトは表示する必要無いのでスキップし、1個空白を出力します。これで入力時に行番号と文字列の間に空白を入れていなくてもLIST表示時には空白が少なくとも1つは表示されます。

 R3は文字列の先頭番地を指していますが文字列表示ルーチン PUTSTR はR2をパラメータとするのでR2にコピーしてから PUTSTR を呼び出します。PUTSTR はC言語でいうところの puts() で、値が H'00 のバイトが見つかるまで文字列を表示します。そのあとの CRLF は改行ルーチンです。 SPACE、CRLF、UDOTR、PUTSTR などの表示ルーチン群は別の回にまとめて説明しようと思います。

 LSTLIN はサブルーチンなのですが普通に RTS で終わっていません。ここはちょっと説明が必要ですね。
 まず、なんでCRLF の呼び出しが BSR でなく BRA か、ですが、これは「テールリカーシブの除去」としてよく行われる最適化です。サブルーチンの最後の、RTS 直前の命令が BSR または JSR の場合それを BRA もしくは JMP に換えてしまうことにより RTS を省略でき、さらに無駄なスタックアクセスも減ってスピードも上がります。
 話を簡単にするために、最初はSHではなくH8のような遅延スロットのないCPUで説明します。次のようなコードがあったとします。メインルーチン(以下メイン)からサブルーチンA(以下A)を呼び出し、Aの最後でサブルーチンB(以下B)を呼び出し、Bの処理が終了するとAの最後に戻り、そこの RTS でメイン戻るというコードです。(コード例H8_1)
; メインルーチン
MAIN0:	BSR	SUB_A		; Aを呼び出す
MAIN1:	(なんたら)		; メイン処理の続き

 :

; サブルーチンA
SUB_A:	(かんたら)		; A本来の処理
	BSR	SUB_B		; Bを呼び出す
SUB_A1:	RTS

 :

; サブルーチンB
SUB_B:	(うんたら)		; B本来の処理
	RTS

このコードだとBの頭に飛んできた時点でスタックにはAの最後に戻るための値 SUB_A1 と、メインに戻るための値 MAIN1 が積まれているはずです。これを次のように直したらどうでしょうか。(コード例H8_2)
; メインルーチン
MAIN0:	BSR	SUB_A		; Aを呼び出す
MAIN1:	(なんたら)		; メイン処理の続き

 :

; サブルーチンA
SUB_A:	(かんたら)		; A本来の処理
	BRA	SUB_B		; Bに飛ぶ

 :

; サブルーチンB
SUB_B:	(うんたら)		; B本来の処理
	RTS

 Aの頭に飛んできた時点でスタックにはメインに戻るための値 MAIN1 が積まれているはずです。(かんたら)を実行した後、Bを呼んだらAの仕事は終わり、あとはただメインに戻るだけです。Bを BSR で呼ぶ前にちょっと考えてみてください。もうAでやることはないのですから、Aに戻るための番地をスタックに積むのは無駄です。BRA でBに飛べば、スタックには MAIN1 だけが積まれていますから、Bの RTS では直接 MAIN1 に戻ります。命令を1個減らせて、さらにスタックアクセスも減らせることがわかります。

 さて、ここからはSHの話に戻ります。コード例H8_1をSH用に直すとこうなります。(コード例SH_1)
; メインルーチン
MAIN0:	BSR	SUB_A		; Aを呼び出す
	NOP			; 遅延スロット
MAIN1:	(なんたら)		; メイン処理の続き

 :

; サブルーチンA
SUB_A:	STS.L	PR,@-R15	; PRを保存
	(かんたら)		; A本来の処理(B以外のルーチンを呼び出す BSR もある)
	BSR	SUB_B		; Bを呼び出す
	NOP			; 遅延スロット

SUB_A1:	LDS.L	@R15+,PR	; PRを復帰
	RTS
	NOP			; 遅延スロット

 :

; サブルーチンB
SUB_B:	(うんたら)		; B本来の処理
	RTS
	NOP			; 遅延スロット

 SHの場合、サブルーチンコールをすると戻り番地はスタックではなくPRに保存されます。PRは1つしかありませんから、何も考えずH8と同じようなコードを書くと、2度目の BSR でPRが上書きされてしまい、大元のメインルーチンには戻れなくなってしまいます。そこで、内部で別のサブルーチンを呼ぶようなサブルーチンはコード例SH_1のサブルーチンAのように自主的にPRを保存する必要があります。

 こういうアーキテクチャはアセンブラプログラマの立場からすると面倒くさくて困るのですが、スピードの点からは有利なのです。サブルーチンBのような末端ルーチンをループ中から呼びだす場合、H8のような戻り番地をスタックに積むCPUだとループを回るごとに毎回戻り番地のスタックへの保存と復帰が行われますが、SHのようにレジスタに保存するCPUだと戻り番地保存のためのスタックアクセスは1回も行われません。

 ちなみに、「PRの保存と復帰、遅延スロットに置けばいいんじゃね?」と考えて次のようなコードを書くと正しく動きません。
SUB_X:	BSR	SUB_Y		; サブルーチンYを呼び出す
	STS.L	PR,@-R15	; Yを呼ぶ前にPRを保存したつもり(実はうまくいかない)

SUB_X1:	RTS			; メインルーチンに戻る
	LDS.L	@R15+,PR	; メインに戻る前にPRを復帰するつもり(実はうまくいかない)

 BSR、RTS 自体は遅延スロットより先に実行されます。つまりPRの保存や復帰を遅延スロットに書いても手遅れなのです。



 ここで「テールリカーシブの除去」を行ったらどうなるでしょうか? BRA はPRをいじらないので BRA なら遅延スロットにPRの復帰コードを書いても大丈夫です。結局、コード例SH_1の最終形は次のようになります。(コード例SH_2)
; メインルーチン
MAIN0:	BSR SUB_A		; Aを呼び出す
	NOP			; 遅延スロット
MAIN1:	(なんたら)		; メイン処理の続き

 :

; サブルーチンA
SUB_A:	STS.L	PR,@-R15	; PRを保存
	(かんたら)		; A本来の処理(B以外のルーチンを呼び出す BSR もある)
	BRA	SUB_B		; Bに飛ぶ
	LDS.L	@R15+,PR	; PRを復帰(遅延スロット)

 :

; サブルーチンB
SUB_B:	(うんたら)		; B本来の処理
	RTS
	NOP			; 遅延スロット

 サブルーチンAの記述が飛躍的に簡単になっていることに気づくと思います。RTS が無くなるだけではなく、遅延スロットも有効に使えています。このようにSHでは「テールリカーシブの除去」は非常に効果的です。以上の説明によりサブルーチン LSTLIN の一見不思議なコードも理解してもらえると思います。

 今回はこのへんまでにしておこうと思います。なお、行の構造が理解できたらDELETEコマンドの動作も難しくないと思います(ラベル名 XDEL)。いい練習問題になると思うので読解してみてください。