exited normally

--back of the flyer--

haribote HL-2

hariboteは、はりぼて言語の処理系です。

gitを使わなくてもhariboteのhistブランチ(Commits on Mar 22, 2024)のソースコードが利用できるように、ここに置いておきます。

haribote HL-2

haribote HL-2は、getTokenCode()で割り当てたトークンコード(整数値)を使ってトークン列の並びを解析します。

トークンコードの割り当ては、プログラムのソースコードトークンに切り分ける字句解析のタイミングでおこないます。

そのため、main()のif...else文で命令を解釈実行する際は、トークン列を文字列のまま解析せずに、割り当てた整数値で高速に解析できます。

また、トークンコードを割り当てる際に、あわせてトークンコードiを使ってvars[i]のように領域に名前をつけています。

他のバージョンのソースコードを読む

/*
  Copyright (c) 2023 Masahiro Oono

  This software is a translator for HL.
  HL (Haribote Language) is a programming language
  designed in 2021 by Hidemi Kawai.
  https://essen.osask.jp/?a21_txt01
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef unsigned char *String;

void loadText(int argc, const char **argv, String text, int size)
{
  if (argc < 2) {
    printf("Usage: %s program-file\n", argv[0]);
    exit(1);
  }

  FILE *fp = fopen(argv[1], "rt");
  if (fp == NULL) {
    printf("Failed to open %s\n", argv[1]);
    exit(1);
  }

  int nItems = fread(text, 1, size - 1, fp);
  fclose(fp);
  text[nItems] = 0;
}

#define MAX_TOKEN_CODE 1000 // トークンコードの最大値
String tokenStrs[MAX_TOKEN_CODE + 1];
int    tokenLens[MAX_TOKEN_CODE + 1];

int vars[MAX_TOKEN_CODE + 1];

int getTokenCode(String str, int len)
{
  static unsigned char tokenBuf[(MAX_TOKEN_CODE + 1) * 10];
  static int nTokens = 0, unusedHead = 0; // 登録済みのトークンの数, 未使用領域へのポインタ

  int i;
  for (i = 0; i < nTokens; ++i) { // 登録済みのトークンコードの中から探す
    if (len == tokenLens[i] && strncmp(str, tokenStrs[i], len) == 0)
      break;
  }
  if (i == nTokens) {
    if (nTokens >= MAX_TOKEN_CODE) {
      printf("Too many tokens\n");
      exit(1);
    }
    strncpy(&tokenBuf[ unusedHead ], str, len); // 見つからなければ新規登録
    tokenBuf[ unusedHead + len ] = 0;
    tokenStrs[i] = &tokenBuf[ unusedHead ];
    tokenLens[i] = len;
    unusedHead += len + 1;
    ++nTokens;

    vars[i] = strtol(tokenStrs[i], NULL, 0); // 定数であれば初期値を設定(定数でなければ0になる)
  }
  return i;
}

inline static int isAlphabet(unsigned char ch)
{
  return ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || ch == '_';
}

inline static int isNumber(unsigned char ch)
{
  return '0' <= ch && ch <= '9';
}

int lexer(String str, int *tc)
{
  int pos = 0, nTokens = 0; // 現在読んでいる位置, これまでに変換したトークンの数
  int len;
  for (;;) {
    while (str[pos] == ' ' || str[pos] == '\t' || str[pos] == '\n' || str[pos] == '\r')
      ++pos;
    if (str[pos] == 0)
      return nTokens;

    len = 0;
    if (strchr("(){}[];,", str[pos]) != NULL)
      len = 1;
    else if (isAlphabet(str[pos]) || isNumber(str[pos])) {
      while (isAlphabet(str[pos + len]) || isNumber(str[pos + len]))
        ++len;
    }
    else if (strchr("=+-*/!%&~|<>?:.#", str[pos]) != NULL) {
      while (strchr("=+-*/!%&~|<>?:.#", str[pos + len]) != NULL && str[pos + len] != 0)
        ++len;
    }
    else {
      printf("Lexing error: %.10s\n", &str[pos]);
      exit(1);
    }
    tc[nTokens] = getTokenCode(&str[pos], len);
    pos += len;
    ++nTokens;
  }
}

int tc[10000]; // トークンコードを格納する

int main(int argc, const char **argv)
{
  unsigned char text[10000];
  loadText(argc, argv, text, 10000);

  int plus      = getTokenCode("+", 1);
  int minus     = getTokenCode("-", 1);
  int period    = getTokenCode(".", 1);
  int semicolon = getTokenCode(";", 1);
  int assign    = getTokenCode("=", 1);
  int print     = getTokenCode("print", 5);

  int nTokens = lexer(text, tc);
  tc[nTokens] = tc[nTokens + 1] = tc[nTokens + 2] = tc[nTokens + 3] = period; // エラー表示用

  int pc;
  for (pc = 0; pc < nTokens; ++pc) {
    if (tc[pc + 1] == assign && tc[pc + 3] == semicolon)
      vars[tc[pc]] = vars[tc[pc + 2]];
    else if (tc[pc + 1] == assign && tc[pc + 3] == plus && tc[pc + 5] == semicolon)
      vars[tc[pc]] = vars[tc[pc + 2]] + vars[tc[pc + 4]];
    else if (tc[pc + 1] == assign && tc[pc + 3] == minus && tc[pc + 5] == semicolon)
      vars[tc[pc]] = vars[tc[pc + 2]] - vars[tc[pc + 4]];
    else if (tc[pc] == print && tc[pc + 2] == semicolon)
      printf("%d\n", vars[tc[pc + 1]]);
    else
      goto err;

    while (tc[pc] != semicolon)
      ++pc;
  }
  exit(0);
err:
  printf("Syntax error: %s %s %s %s\n", tokenStrs[tc[pc]], tokenStrs[tc[pc + 1]], tokenStrs[tc[pc + 2]], tokenStrs[tc[pc + 3]]);
  exit(1);
}

他のバージョンのソースコードを読む

参考

「10日くらいでできる!プログラミング言語自作入門」
インタプリタC言語で実装しながらプログラミング言語自作に入門するテキストです。無料で読めます。続編ではインタプリタJITコンパイラに改造します。

「10日くらいでできる!プログラミング言語自作入門」の続編#1-1
プログラミング言語自作入門で実装したインタプリタを、x86用またはx64用のJITコンパイラに改造します。後半では、レジスタ変数の導入や無駄なメモリアクセスの削減、コンパイル時の定数計算などの最適化に取り組みます。

「10日くらいでできる!プログラミング言語自作入門」の「残された課題」に関する補足
作者の川合秀実さんが、a21_txt01_10の「残された課題」に取り組む順序について、再検討した内容を共有してくれました。残された課題は、記号表やスタックを自前で実装する必要があるため、はりぼて言語の公式処理系の構造をベースに言語開発をしたい人にとって良い課題となるでしょう。

haribote HL-1

hariboteは、はりぼて言語の処理系です。

gitを使わなくてもhariboteのhistブランチ(Commits on Mar 22, 2024)のソースコードが利用できるように、ここに置いておきます。

haribote HL-1

haribote HL-1は、text変数に書き込んだプログラムを解釈実行します。

他のバージョンのソースコードを読む

/*
  Copyright (c) 2023 Masahiro Oono

  This software is a translator for HL.
  HL (Haribote Language) is a programming language
  designed in 2021 by Hidemi Kawai.
  https://essen.osask.jp/?a21_txt01
*/
#include <stdio.h>
#include <stdlib.h>

unsigned char text[] =
/* BEGIN */
"a=1;"
"b=2;"
"c=a+b;"
"print c;"
/* END */
;

int main(int argc, const char **argv)
{
  int vars[256];
  for (int i = 0; i < 10; ++i)
    vars['0' + i] = i;

  int pc;
  for (pc = 0; text[pc] != 0; ++pc) {
    if (text[pc] == '\n' || text[pc] == '\r' || text[pc] == ' ' || text[pc] == '\t' || text[pc] == ';')
      continue;

    if (text[pc + 1] == '=' && text[pc + 3] == ';')
      vars[text[pc]] = vars[text[pc + 2]];
    else if (text[pc + 1] == '=' && text[pc + 3] == '+' && text[pc + 5] == ';')
      vars[text[pc]] = vars[text[pc + 2]] + vars[text[pc + 4]];
    else if (text[pc + 1] == '=' && text[pc + 3] == '-' && text[pc + 5] == ';')
      vars[text[pc]] = vars[text[pc + 2]] - vars[text[pc + 4]];
    else if (text[pc] == 'p' && text[pc + 1] == 'r' && text[pc + 5] == ' ' && text[pc + 7] == ';')
      printf("%d\n", vars[text[pc + 6]]);
    else
      goto err;

    while (text[pc] != ';')
      ++pc;
  }
  exit(0);
err:
  printf("Syntax error: %.10s\n", &text[pc]);
  exit(1);
}

他のバージョンのソースコードを読む

参考

「10日くらいでできる!プログラミング言語自作入門」
インタプリタC言語で実装しながらプログラミング言語自作に入門するテキストです。無料で読めます。続編ではインタプリタJITコンパイラに改造します。

「10日くらいでできる!プログラミング言語自作入門」の続編#1-1
プログラミング言語自作入門で実装したインタプリタを、x86用またはx64用のJITコンパイラに改造します。後半では、レジスタ変数の導入や無駄なメモリアクセスの削減、コンパイル時の定数計算などの最適化に取り組みます。

「10日くらいでできる!プログラミング言語自作入門」の「残された課題」に関する補足
作者の川合秀実さんが、a21_txt01_10の「残された課題」に取り組む順序について、再検討した内容を共有してくれました。残された課題は、記号表やスタックを自前で実装する必要があるため、はりぼて言語の公式処理系の構造をベースに言語開発をしたい人にとって良い課題となるでしょう。

「10日くらいでできる!プログラミング言語自作入門」の「残された課題」に関する補足

作者の川合秀実さんが、a21_txt01_10の「残された課題」に取り組む順序について、再検討した内容を共有してくれました。

残された課題は、記号表やスタックを自前で実装する必要があるため、特にはりぼて言語の公式処理系の構造をベースに言語開発をしたい人にとって良い課題となるでしょう。

参考

「10日くらいでできる!プログラミング言語自作入門」の続編#2-1 (HL-30)
「[1]まずintだけではなくdoubleも使えるようにする(型の仕組みを作る)」実装方法を解説した公式テキストです。

「10日くらいでできる!プログラミング言語自作入門」の続編#2-2 (HL-31)
「[2]引数なしの関数を作って呼び出せるようにする」実装方法を解説した公式テキストです。

【Posts】『ゼロからのOS自作入門』を読んだ

『ゼロからのOS自作入門』を手を動かしながら読んだ際に書き残したX(旧Twitter)のPostです。デバッグ情報や参照した資料などの記録を含みます。

引用形式で表示するために、.twitter-tweetを削除して埋め込んでいるPostがあります。

プログラミング言語自作に取り組んでいて、OS自作にも興味がわいたというところ、わかる気がします。まだうまく言えないけれど、なんとなくつながっている気がするんだよなぁ。

— Masahiro Oono (@words_oono) May 1, 2022

言語自作入門のJITコンパイラ編に取り組んだときに調達した Intel NUC にちょうど Ubuntu 20.04.6 LTS が入っているのでこれを開発用にして、N5095 を積んだ Beelink MINI S を試験用にすることにしました。 #ゼロからのOS自作入門

— Masahiro Oono (@words_oono) June 21, 2023

※ この開発用マシンで動作確認をしたのは5章の内容までです

まだ写経の要領がつかめないな。ま、じっくりやりましょう。

— Masahiro Oono (@words_oono) June 26, 2023

実機でマウスを動かせなかった悲しみを乗り越えた。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) June 28, 2023

※ 精神的には乗り越えましたが技術的には乗り越えられなかったので、6章以降はQEMUで動作確認をしながら読み進めることになります

あっ、InitializeLAPICTimer()を呼ぶのを忘れてた(笑)#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) July 4, 2023

時間が溶けていく。

— Masahiro Oono (@words_oono) July 7, 2023

昨日10章まで写経して、ちょうど1/3読み終わったところ。目次を見る限り、ここから20章までの内容をどう実装するのかは特に興味を惹かれるところです。集中して写経していきます。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) July 8, 2023

電子の森ラジオ018 生物として機械語を見る ゲスト 大神祐真さん 12:32あたり
※ 読了後に振り返ってみると、20章までに学んだ内容については21章以降の実装を進める過程で腹落ちした感じです

たのしそう、と思って読みはじめたけど、思った以上にたのしいですね。

— Masahiro Oono (@words_oono) July 9, 2023

観てる。#ゼロからのOS自作入門
第7回 タイマー/第8回 マルチタスク https://t.co/OI8BRhzOXV @YouTubeより

— Masahiro Oono (@words_oono) July 13, 2023

観てる。#ゼロからのOS自作入門
第7回 タイマー/第8回 マルチタスク https://t.co/aSMkwIi7ql @YouTubeより

— Masahiro Oono (@words_oono) July 15, 2023

こ、これがぎっくり首ってやつか。動かせん(笑)

— Masahiro Oono (@words_oono) July 15, 2023

※ 首やっちゃいました。以降、サポーターを巻いて読むことになります

首が痛む割りに読めたので、よかったよかった。次は15章 ターミナルだー! #ゼロからのOS自作入門

— Masahiro Oono (@words_oono) July 17, 2023

痛みに耐えて写経したところの読み直しから今夜はやろう。タスクに優先順位を付けたりウィンドウをアクティブ化したりするしくみは、OS自作に限らずゲームAIを書く時などにも使えそうなのでしっかり理解しておきたい。

— Masahiro Oono (@words_oono) July 18, 2023

ちょうど16章の写経が終わりそうなタイミングで、輪読会の動画をアップしてくれていることに気づきました。https://t.co/4doAntmrPe #ゼロからのOS自作入門

— Masahiro Oono (@words_oono) July 20, 2023

なんか変だな、と思ったらコマンド履歴を実装するのを忘れていた。

— Masahiro Oono (@words_oono) July 22, 2023

観てる。#ゼロからのOS自作入門
第9回 ファイルシステム/第10回 アプリケーション https://t.co/RrSNvvQQZm @YouTubeより

— Masahiro Oono (@words_oono) July 24, 2023

今日からは19章 ページングを読んでいきます。実は、この章と20章 システムコールの内容はぜひ実装してみたかったんです。たのしみー! #ゼロからのOS自作入門

— Masahiro Oono (@words_oono) July 25, 2023

観てる。#ゼロからのOS自作入門
第11回 アドレス変換第/第12回 システムコール https://t.co/8cIWi8N7kG @YouTubeより

— Masahiro Oono (@words_oono) July 25, 2023

観てる。#ゼロからのOS自作入門
第11回 アドレス変換第/第12回 システムコール https://t.co/4RI2qVFa1S @YouTubeより

— Masahiro Oono (@words_oono) July 27, 2023

観てる。#ゼロからのOS自作入門
第11回 アドレス変換第/第12回 システムコール https://t.co/d80nj39aFD @YouTubeより

— Masahiro Oono (@words_oono) July 27, 2023

※ 第20章 システムコール

書籍とあわせて東工大の講義動画を観て写経していたら、じわじわと分かってきました。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) July 28, 2023

セグメントディスクリプタやCSレジスタ、ページング構造のエントリなどが関係し合いながらCPUの動作権限を切り替える処理を書くので混乱しないように、これって何だったかなー、とひとつひとつ確認しながら読み進めてる。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) July 29, 2023

8章でさらっと流したところをしっかり理解していく感じ。

— Masahiro Oono (@words_oono) July 29, 2023

「なんでも継続」Shiro Kawai まだ下書き

観てる。#ゼロからのOS自作入門
第11回 アドレス変換第/第12回 システムコール https://t.co/iW2TL4by9p @YouTubeより

— Masahiro Oono (@words_oono) July 30, 2023

おっ、講義動画はこれで終わりなんだね。

— Masahiro Oono (@words_oono) July 30, 2023

ふわっふわしたところを補足して読み返した。
CPL=3 のときに割り込みが発生して CPL=0 に切り替わるとき、CPU の仕様で TSS.RSP0 の値が RSP に設定されるが、アプリがシステムコールを実行中に割り込みが発生したときは CPU=0 なので RSP はアプリ用のスタックを指したまま変わらない。

— Masahiro Oono (@words_oono) July 30, 2023

アプリ用領域のマッピングは各コンテキストで固有だから、割り込みが発生して RestoreContext() が呼ばれコンテキストが切り替えが発生すると、PML4table を指す CR3 が更新されてアプリ用スタックが載っているページング構造のエントリの present フラグが0になる。

— Masahiro Oono (@words_oono) July 30, 2023

present フラグが0のときは有効な物理アドレスが設定されていないことを意味し、この状態で iret 命令を実行してスタックへのアクセスが発生すると、CPU がページフォルトを発生させてOSがクラッシュする。

— Masahiro Oono (@words_oono) July 30, 2023

この書き方なんか見たことある気がするなぁ、と思ったら、まあまあ言語自作入門テキストで似た感じのコードを書いていたりしてね、そういう繋がりを見つけるのもたのしみのひとつ。

— Masahiro Oono (@words_oono) August 6, 2023

※ 「言語自作入門テキスト」とは、「10日くらいでできる!プログラミング言語自作入門」のことです

目次をながめたときに24章以降は難しそうと思ったんだけど、やっぱり「ちょっと」難しくなるみたいだ(ごくり… "次章からはまたちょっと難しい内容になると思うので、今のうちに楽しんでしまいましょう。" #ゼロからのOS自作入門

— Masahiro Oono (@words_oono) August 7, 2023

後回しにしていた IST の設定(osbook_day21a)も済ませた。

— Masahiro Oono (@words_oono) August 8, 2023

そういえばgrepコマンドを追加する直前のb5cc526aで、Makefile.elfappのCPPFLAGSに-Dオプションを追加して定義している「__SCLE」は何なんだろう? 外してもいちおうgrepコマンドは動いているみたいだけど。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) August 9, 2023

おかしい、どんどんOSがバギーになってきてる(笑)

— Masahiro Oono (@words_oono) August 13, 2023

26.6「ファイル書き込み(2)」の内容をコードに反映するとcpコマンドが成功するはずなのだがページフォルトが発生するようになってしまった。どこかで写経ミスったかも、めんどくさそうなバグを引いてしまった。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) August 14, 2023

※ 後日、検証するために当該問題が発生したコミットまで戻り再現を試みましたがページフォルトは発生しませんでした(なぜだ…

しかし、うまいことに次章(27章)でデマンドページングを実装することに気付き、原因が気になったものの一旦バグを残して先に進むことにした。直ってくれー! と祈りながら27.1「デマンドページング」を写経するとcpコマンドが成功するようになった。ほっ。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) August 14, 2023

RustとWebAssemblyによるゲーム開発が届いていた。しかし、ゼロからのOS自作入門を読了 or 挫折するまでは他の技術書を読むことを禁止しているので、そろりそろりとページをめくって構文の面構えをたのしむだけにとどめている。ううむ、読みたい…。

— Masahiro Oono (@words_oono) August 15, 2023

図がとても分かりすくて助かります。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) August 16, 2023

※「分かりすくて」->「分かりやすくて」

※ なんだろう

システムコールまわりが初見ではよく分からなくて、いちどマルチタスクまで戻ってしっかり読み直したけど、こんどはメモリマップトファイルとコピーオンライトのところでコードを読むのが辛くなったので、ファイルシステムやFATまで戻ってもういちど読み直した。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) August 18, 2023

※「mikanos-buildリポトリ」->「mikanos-buildリポジトリ
https://github.com/uchan-nos/mikanos-build

PF祭り開催中

— Masahiro Oono (@words_oono) August 20, 2023

よーし、リダイレクト成功した! PF祭りを抜けた!

— Masahiro Oono (@words_oono) August 20, 2023

28.3「リダイレクト」の内容をコードに反映した後、ターミナルにて「echo deadbeef > piyo」を実行した際、ページフォルトが発生するようになった。調査したところapps/cpを実行した歳もページフォルトが発生するようになっていた。ファイル生成まわりの処理があやしい。#ゼロからのOS自作入門

— Masahiro Oono (@words_oono) August 21, 2023

※「実行した歳も」->「実行した際も」

コミットをたどって検証したところ、27.2「メモリマップトファイル」の内容をコードに反映した段階で「apps/cp memmap abc」の実行によりページフォルトが発生することを確認した。

— Masahiro Oono (@words_oono) August 21, 2023

※ 当該問題の対策とあわせて26.5「ファイル書き込み(1)」の「ABCという空のファイルが生成されるはずなのだが生成できなかった」問題の対策をしたコードです

「10日くらいでできる!プログラミング言語自作入門」の感想(HL-6aまで)

「10日くらいでできる!プログラミング言語自作入門」(以下、「言語自作入門」)のインタプリタをバージョンHL-6aまで実装した感想を書いておこうと思い立ちました。

いままさに言語自作入門を読んでいる人たち・はりぼて言語の処理系を実装している人たちと、わいわいにぎやかに雑談しているような気持ちで書いてみようと思います。

2021年の春ごろ、わたしは流れてきたツイートで言語自作入門を知り、強く興味を惹かれて読みはじめました。

このときは、変数名や関数名を説明的に変更するにとどめて、C言語で写経しました。

公式処理系の実装言語がC言語だったからです。

しかし、わたしは自作言語の処理系をC言語以外でも書きたいと考えていたので、移植先の言語でうまく書くことができるのか、という課題意識をずっと持っていました。

そして2021年の年末に、Swiftではりぼて言語の処理系を書くことにチャレンジしました

いやー、たのしかった。

なんとか目標としていたHL-6aまでは実装が終わっています。

ところで、はりぼて言語の生みの親であり言語自作入門の著者でもある川合秀実さんは、実は7章(HL-7)を推しています。

そして、はじめにC言語で処理系を実装しながら読んだときは、やはりわたしも7章を特に興味深く読んだのでした。

ただ、今回Swiftで処理系を実装しながら言語自作入門の1章から6章までを読みなおしてみると、前半もしっかりおもしろかったのです。

そういうわけで、背伸びせずに、のんびりとお茶でも飲みながら感想を書いてみます。

追記・お知らせ

2023年にC言語による実装をリライトしました

写経から改造へ踏み出す小さな一歩として、2021年に写経した実装を2023年にリライトしました。

注意

以下の感想文の中には、はりぼて言語の処理系がどのような処理をしているかについての記述や処理の流れを図示する箇所が含まれています。

これらは、あくまでもわたしが処理系を実装する際に一応の理解とした内容に基づいており、必ずしも公式の見解と一致するものではありません。

また、各見出のバージョン表記の横に書いたコメントは、公式サイトの「もくじ」>「説明」に書かれているコメントを引用しています。

HL-1 「初めの一歩、たった49行のシンプルすぎる言語処理系」

http://essen.osask.jp/?a21_txt01

49行です。

言語自作入門の冒頭に書かれている目的別ガイドでは「とにかく言語の本質だけが知りたければ、HL-1だけ読めばいいです。49行で処理系を理解できます」と紹介されています。

入門者でも「ちょっとやってみようかな」と気楽に始められそうですし、実際、わたしはそんなノリで読みはじめたのでした。

このバージョンには、ちょっとした制限があります。変数名を半角一文字のみ、数値定数を整数一桁のみ、命令を足し算と引き算、そしてprintのみに限定しているのです。

その代わりに、たったの49行で次のようなプログラムが実行できる処理系を実装してしまいます。

a=1;
b=2;
c=a+b;
print c;

Output:

3

わたしのSwiftによる実装(HL-1)では、Playgroundで気楽に試せるようにソースコードをファイルから読み込むのではなくプロパティに直接書くようにしました。

var text = """
   a=1;
   b=2;
   c=a+b;
   print c;
   """

Playgroundでプロパティの値をいじって出力される値を変えて遊んでいると、いつかわたしにもかっこいい言語が作れちゃうんじゃないかという妄想がふくらんでいきます。

おっと、妄想は置いておいて。

HL-1では、txt変数にソースコードの文字列を格納しておき、ソースコードの先頭からの相対位置をtxt[pc]のようにpcで指しながら命令を解析し実行する処理を繰り返します。

この処理って、いわゆるコンピュータアーキテクチャの話に出てくるあれみたいだよねとわたしは思っています。あれというのは、CPUがPC(プログラムカウンタ)の値を進めながらメモリの命令を読み込んで解釈実行する命令サイクルのことです。

はりぼて言語の公式処理系はあとのバージョン(HL-6)になると、ソースコードを内部コードへ変換してVMの上で実行するようになったり、さらに「10日くらいでできる!プログラミング言語自作入門」の続編JITコンパイラ編)まで読み進めると、今度は内部コードの代わりに機械語に変換して実行するようになったりします。

こう書くとむずかしそうに見えるし、実際、続編で意表をついて機械語が出てきたときは、わたしもひるみました。ひるみまくりました。

しかし、細かい話・むずかしい話を除いて、はりぼて言語の公式処理系が「結局、何をしているのか」と考えると、上で触れた命令サイクルに見立てた処理でプログラムを解釈実行していると言えそうです。

その意味においては、たったの49行で書かれているHL-1の処理系はたとえまだ低機能であるとしても、すでにHL-6や続編のJITコンパイラとの共通性を有していると思うのです。わたしはそこにおもしろみを感じます。

HL-2 「変数名は1文字じゃなくてもOK、見やすいスペースやインデントもOKに」

http://essen.osask.jp/?a21_txt01_2

HL-2では、レキサー(字句解析器)を実装します。

入力されたソースコードトークン(単語)に切り分けるのがレキサーです。

レキサーを実装して処理系がトークンを解釈できるようになると、 「変数名を半角一文字のみ」「数値定数を整数一桁のみ」とする制限がなくなり次のようなプログラムを実行できるようになります。

abc = 123;
def = 456;
ans = abc + def;
ans = ans - 321;
print ans;

Output:

258

見慣れた感じになりましたね。

はりぼて言語の公式処理系のレキサーは、トークンを切り分ける際にトークンコードを割り当てる工夫をしています。トークンコードは、トークンごとに割り当てられる整数値です。同じトークンには同じ値が割り当てられます。

これにより、プログラムを解釈する際にトークンを文字列のまま比較するのではなく、トークンコード(整数値)で比較できるようになるため処理が高速化するというわけです。

int main(int argc, const char **argv)
{
    int pc, pc1;
    unsigned char txt[10000];
    loadText(argc, argv, txt, 10000);
    pc1 = lexer(txt, tc);
    // これ以降のトークンの比較はすべてトークンコード(整数値)でおこなえる
    ...

そういえば、はじめてHL-2を読んだときは、レキサーも実装したしそろそろ構文解析のところでASTが出てくるのかなと思いました。しかし、はりぼて言語の公式処理系はASTを生成しません。

なぜこう勘違いしたかというと、言語自作入門の前に読んだ『Go言語でつくるインタプリタ』で実装した処理系が、再帰的下向き構文解析を用いて構築したASTをたどる評価戦略を採用していたからなんですね。

不勉強を反省して読み返してみると、ASTを構築しない評価戦略についてもちゃんと書かれていました(p117)。

この戦略のバリエーションには、ASTを構築しないものもある。構文解析器がASTを構築せずに直接バイトコードを出力するんだ。[…]どの戦略を選ぶかは、性能と移植性の要求、解釈しようとしているプログラミング言語、どこまでやりたいかによって大体決まる。再帰的にASTを評価するtree-walkingインタプリタはおそらく全てのアプローチの中で最も遅い。しかし、構築しやすく、拡張しやすく、考えやすく、実装に使う言語と同程度の移植性を備える。バイトコードコンパイルし、仮想マシンを使ってそのバイトコードを評価するインタプリタは、かなり速く動作することだろう。しかし、より複雑で、実装もむずかしい。

ふむふむ、はりぼて言語の公式処理系は、「ASTを構築しない」で「バイトコードコンパイルし、仮想マシンを使ってそのバイトコードを評価するインタプリタ」だと言えそうです。

この戦略を採用したのはおそらく作者が実行速度を重視しているためでしょう。プログラムを解析した結果を連続したメモリ領域に格納しておけば、局所性が高まりキャッシュのヒット率が上がります。

処理系の実行速度の参考として、足し算を1億回繰り返すプログラムを実行した速度をHL-6aの感想文に掲載しています。

そこでは他言語の処理系との比較はおこなっていませんが、たとえばこれと同等の処理をわたしの手元のCPython (Python 3.9.10) で実行した速度と比べてみると、Swiftによる実装でも1/17倍ほど速く、C言語による実装ではさらに1/2倍ほど速い結果となりました。

公式処理系のセキュリティに関して公式サイトの「(5) 重要な注意事項」には、「これは私の説明のための教材であって、製品や実用的な言語処理系ではありません」と書かれています。

しかし、実行速度に関してはその限りではなさそうです。改造してあんなところやこんなところで使ってみたいなぁ、なんて妄想がふくらんでいきます。

おっと、妄想は置いておいて。

HL-3 「条件分岐などをサポートしてループ処理が可能に」

http://essen.osask.jp/?a21_txt01_3

HL-3では、goto命令を追加します。

うわっ、goto命令だって? 処理が追いにくくなるから僕は使わないよ、という意見のプログラマがいる一方で、いやいや、使い方によっては便利だよ、という意見のプログラマがいます。

ただ、わたしはHL-3で追加するgoto命令について「は」、双方の意見を戦わせることはナンセンスだと考えています。

はりぼて言語の処理系を書き進めていく中で、ひとつわたしが勝手に思っていることがあって、それは、はりぼて言語の公式処理系にはあえて低水準言語のイディオムを利用して処理を実装しているところがあるのではないかということなんですね。

どういうことなのかというと、たとえばx86アセンブリには、高水準言語では提供されているifforという制御構文がありません。

わたしがはじめてこれを知ったときは、じゃあどうやってプログラムの実行を制御するの? と思いました。

じゃあどうするかというとjump命令を使うのです。jump命令は、次に実行する命令を指しているプログラムカウンタの値を書き換える命令です。

このjump命令を使うと、高水準言語が提供する抽象度が高い制御構造と等価な処理を実装することができます。

そして実は、はりぼて言語の公式処理系はあとのバージョン(HL-8)で、まるでアセンブリのjump命令のようにgoto命令を使ってifforという制御構造を追加するのです。

if文と、goto命令を使った等価な処理

if (条件式) {
    コードブロック
}
    if (!条件式) goto label;
    コードブロック
label:

for文と、goto命令を使った等価な処理

for(式0; 条件式; 式2) {
    コードブロック
}
    式0;
    if (!条件式) goto label2;
label0:
    コードブロック;
label1: // continue命令が来たらここにジャンプする
    式2;
    if (条件式) goto label0;
label2:

わたしはHL-1の感想文で次のように書きました。

はりぼて言語の公式処理系が「結局、何をしているのか」と考えると、バージョンによって実装の詳細は違ったとしても、[…]命令サイクルに見立てた処理でプログラムを解釈実行していると言えそうです。

この「結局、何をしているのか」ということ、つまり本質は何かということを制御構造について考えると、それはjump命令だよ、だってjump命令さえあれば抽象度が高い制御構造と等価な処理を書けちゃうからね、と言えると思います。

この理解のために、あえて低水準言語のイディオムを利用して処理を実装しているのであれば、HL-3で追加するgoto命令の良し悪しについて高水準言語のパラダイムだけにもとづいて意見を戦わせてもナンセンスだよなぁ、と考えるわけです。

HL-4 「REPLの導入(これは楽しい!)」

http://essen.osask.jp/?a21_txt01_4

REPLはたのしいです!

HL-5 「少し高速化」

http://essen.osask.jp/?a21_txt01_5

HL-5では、phrCmp関数を実装します。

HL-5までのはりぼて言語の公式処理系がやっていることの流れをおおまかに図示するとこうなります。

ソースコードを入力として受け取る -> 字句解析をする -> 構文解析をしながら実行する

この図の「構文解析をしながら実行する」の構文解析をしてくれるのがphrCmp関数です。

phrCmp関数は構文を表した文字列であるフレーズを引数に取り、それを関数本体で字句解析して、その結果のトークンコード列を変数に格納しておきます。そして格納したフレーズのトークンコード列と、ソースコードを字句解析して得たトークンコード列の並びを比較して、それらが一致するかを判定します。

また、フレーズを指定する際はワイルドカードが使えます。ワイルドカードにマッチしたトークンの位置はwpcという配列に入れておいてプログラムを実行する際に使います。

この大事なお仕事をしてくれるだけでも、phrCmpさん、おつかれさま、いつもありがとう、という気持ちになるんですけども今回Swiftによる実装をしながら、ふと、ユーザ(プログラマ)が言語を改造する際のインターフェースを提供しているという意味でも大事な関数と言えるんじゃないかな、と考えました。

わたしのSwiftによる実装では、HL-4まではトークンコード列の並びが正しいかを判定する処理をこんな感じで書いていました。

let assign    = getTokenCode("=")
let semicolon = getTokenCode(";")
...

pc = 0
while pc < nTokens {
  if tc[pc + 1] == assign && tc[pc + 3] == semicolon { // 単純代入
    // このブロックで実行して、その結果を変数に書き込む
  }
  ...

この処理が、HL-5でphrCmpメソッドを実装するとこんな感じで書けるようになります。

pc = 0
while pc < nTokens {
  if phrCmp(id: 1, phrase: "!!*0 = !!*1;", beginning: pc) { // 単純代入
    // このブロックで実行して、その結果を変数に書き込む
  }
  ...

さらに、わたしはextensionを使ってStringにラッパーを追加してこう書いています。

pc = 0
while pc < nTokens {
  if "!!*0 = !!*1;".compare(f, id: 1, beginning: pc) { // 単純代入
    // このブロックで実行して、その結果を変数に書き込む
  }
  ...

この最後の変更は、単にわたしは癖で大事な部分をできるだけ左に寄せて書いたほうが読みやすく感じるので変えただけのことです。引数の並び順を考えると、公式処理系の書き方のほうが意味が取りやすく自然に読めます。

ちょっと脱線しますが、気分的には"!!*0 = !!*1;".compare(beginning: pc)のような感じで書きたかったのです。

しかしこれをそのまま実装してしまうと高速化の観点からは悪手です。公式処理系の書き方で実装したときとの速度差が生じないように実測しながらあれこれ試しているうちに、結局、処理本体の記述は公式処理系とほぼ同じである、いまの実装に落ち着いたのでした。

さて、いま「大事な部分を」と書きましたけれど、これはフレーズのことですね。フレーズは構文を表した文字列でした。上で例示したコードでは代入を表す"!!*0 = !!*1;"という文字列をフレーズに指定しています。「!!*#」は任意の1トークンにマッチするワイルドカードです。

あとのバージョン(HL-7)で任意の式にマッチするワイルドカードを追加すると、"if (!!**0) {"""for (!!***0; !!***1; !!***2) {"などの構文を表す文字列もフレーズに指定できるようになります。

フレーズを使う前に比べるとずいぶん構文が読みやすくなりますね。

実際に自作言語の仕様を決めるときのことを想像してみると、書き心地を試しながら、よりよい記法はないだろうか? と納得するまでフレーズをいじり、試行錯誤を繰り返すことになるでしょう。

このように試行錯誤を繰り返すことを前提とすると、フレーズを読むこと・書き換えることにかかるコストを低減するほど、そのぶん言語を改造する生産性を高めることに貢献しそうです。

だから、phrCmp関数は高速化の観点から大事な関数というだけではなく、ユーザ(プログラマ)が言語を改造する際のインターフェースを提供しているという意味でも大事な関数だと思うのです。

HL-6 「もっと高速化(がんばった!)」

http://essen.osask.jp/?a21_txt01_6

HL-6では、高速化のためにソースコードを内部コードへコンパイルしてVMの上で実行する方式を採用します。

6章の冒頭には、JITコンパイラインラインアセンブラを使わない範囲での最速を目指すと書かれています。そして、あわせて次のように書かれています。

基本方針としては、tc[]をphrCmp()で一致しているかどうか調べながら実行するのはやめて、もっとシンプルで高速に実行できそうなデータ列に変換し(内部コードへのコンパイル)、その後そのデータ列だけを見て高速に実行します。

また、言語自作入門の冒頭に書かれている目的別ガイドには次のように書かれています。

実行速度を高める工夫に興味があれば、HL-5やHL-6まで読めばいいです。HL-6では内部に簡単なVMを作ります。徐々にコンパイラっぽくなってきます。

なにやら処理系がやることが増えてきました。いちど整理しておきます。

わたしはHL-5の感想文の冒頭で、HL-5までのはりぼて言語の公式処理系がやっていることの流れを次のように図示しました。

ソースコードを入力として受け取る -> 字句解析をする -> 構文解析をしながら実行する

この図にHL-6で実装する内容を描き加えると次のようになります。

ソースコードを入力として受け取る -> 字句解析をする -> 構文解析をしながら内部コードへコンパイルする -> VMの上で内部コードを読み取って実行する

構文解析をしながら実行する」が「構文解析をしながら内部コードへコンパイルする -> VMの上で内部コードを読み取って実行する」に変わっていますね。

JITコンパイラ編とのつながり

HL-6には、C言語による実装をしたときに印象に残った思い出があるんです。

若干JITコンパイラ編のネタバレのような感じになってしまうのですが、これはぜひ書いておきたいと思いました。

putIc関数は、引数で渡された内容の内部コードを変数icに書き込みます。この書き込みは書き込み用のポインタicqを介しておこないます。

公式処理系では関数本体はこうなっています。

void putIc(int op, IntP p1, IntP p2, IntP p3, IntP p4)
{
    icq[0] = (IntP) op;
    icq[1] = p1;
    icq[2] = p2;
    icq[3] = p3;
    icq[4] = p4;
    icq += 5;
}

第一引数は命令の種類を表す列挙型で、第二引数以降は命令を実行するのに必要なオペランドの格納先アドレスです。

たとえば、このputIc関数を使って代入のための内部コードを書き込む呼び出しはこう書きます。

putIc(OpCpy, &var[tc[wpc[0]]], &var[tc[wpc[1]]], 0, 0);

この代入のための内部コードを書き込む呼び出しを、JITコンパイラ編で機械語を書き込むように変更するとこうなります。

putIcX86("8b_%1m0; 89_%0m0;", &var[tc[wpc[0]]], &var[tc[wpc[1]]], 0, 0);

むむっ! ほとんど同じです。

なんと、HL-6の処理系が何をやっているかの理解が、JITコンパイラ編の処理系の理解につながるように実装が工夫されているのです。

わたしは機械語にひるみながらJITコンパイラ編を読みはじめましたが、これまでやってきたこととのつながりに気づくと一気に気持ちが軽くなったのでした。

SwiftでHL-6を実装する

さて、実はわたしは今回のチャレンジでは、SwiftでHL-6を実装しきれるか、また実装できたとしても高速化に成功するかを心配していました。

はりぼて言語の公式処理系は、原則的にポインタをやむをえないときに使い、ポインタに対する加算や減算はできるだけおこなわない方針のもとに実装されています。

ただしHL-6は高速かつ簡潔に書くために例外的にポインタを多用しています。その部分を使いだして間もないSwiftで実装しきれるかが不安だったのです。

しかしSwiftのポインタを使うと、わりと楽に公式処理系と似たように書くことができたのでラッキーでした。

ポインタを多用する部分はわりと楽に書けたのでしたが、しばらくは見えないコストを抑える方法がわからず、それに阻まれてなかなか思うようなタイムを出せずに苦しみました。しかし-whole-module-optimizationオプションを見つけました。

このオプションを指定してコンパイルした処理系で、HL-3のサンプルコードの1億回ループを実行して速度を測定した結果がこちらです。

Swiftによる実装(swiftc -O -whole-module-optimization)
0.624[sec]
C言語による実装(gcc -O3)
0.284[sec]

比較のためにあわせてC言語による実装の実行速度も載せてみました。C言語による実装との速度差がこの程度であればひとまず成功と考えます。

画面に「0.624[sec] 」と表示された瞬間は、ほんとうに安心しました。

HL-6a 「さらに高速化!(これをやるかどうかは好みが分かれるかも)」

http://essen.osask.jp/?a21_txt01_6a

使用頻度が高い命令についてはCISC的な内部命令を作ると速くなるようです。

実際に、LOOP命令のような専用命令を追加すると1億回ループがさらに速くなりました。

Swiftによる実装(swiftc -O -whole-module-optimization)
0.506[sec]
C言語による実装(gcc -O3)
0.256[sec]

はりぼて言語のVMは、次に実行する命令のアドレスを指すicpの値を書き換えながら内部コードを実行します。

この「icpに加算してcontinueしてそしてswitchするというのが、結構なコストになっている[…]だから2つの命令をくっつけて1つにするだけで、それが1回減らせて、かなりの高速化に」なるということらしいです。

これは覚えておきたいですね。

おわりに

はじめは49行だったはりぼて言語の処理系が、手を動かしながら読むうちに徐々にパワーアップしていくのを見ていると、これまでは手が届かないと思っていた処理系の世界に少しずつ足を踏み入れている実感が湧いて、とてもわくわくしたものです。

はりぼて言語の処理系は徐々にパワーアップしていくので、ある時点では全容が見えません。全容が見えないがゆえに、ああだろうかこうだろうかと考えます。

もしこの、ああだろうかこうだろうか、をリアルタイムに誰かと雑談できていたらたのしかったんだろうなぁ、と思いました。

だからわたしは、この感想文の冒頭に次のように書いたのです。

いままさに言語自作入門を読んでいる人たち・はりぼて言語の処理系を実装している人たちと、わいわいにぎやかに雑談しているような気持ちで書いてみようと思います。

でも感想文を書き終えたいまは、もしかするとこの感想文を書くこと自体が「過去のわたし」と「いまのわたし」の雑談だったのかもしれないな、という気がしています。

最後になりましたが、はりぼて言語の処理系の実装と説明を公開いただいていることにあらためて感謝いたします。

x86 (32bit) アセンブリでFizzBuzzを書く

プログラミング言語自作入門の続編(以下「続編」)を読むに当たり、機械語の基礎知識をインプットしておきたいと考えて、まず「作って分かる!x86_64機械語入門」を読んでみたのだった。

この本のおかげで今のところ続編のHL-12までスムーズに読み書きすることができている。

あくまでも現時点での感想にすぎないのだが、続編のコードを改造する際は、直に機械語を書くよりもディスアセンブルして機械語を生成した方が楽だと感じた。

そこで最低限のアセンブリ言語の読み書きができるようになっておこうと考えてインプットに使えそうなコンテンツを探した。

そして見つけたのがこの動画だ。ペアプロの様子を撮っているのでものすごく分かりやすい!

youtu.be

動画ではFizzBuzzIntel方式のx64-86アセンブリで書いているが、AT&T方式のx86 (32bit) アセンブリで書くことにする。

これは、続編の冒頭の「なぜ現在主流の64bitではなく、32bitにしたのか?」の内容を読んで、しばらくは(入門の際は)32bitで書こうと考えたためだ。

早速、見よう見まねで書いていこう。

ちなみに私の環境は、Pentium Silver J5005搭載 NUC (Ubuntu 20.04 LTS 日本語 Remix) 。build-essentialパッケージやlibc6-dev-i386パッケージはインストール済みだ。

まずは何もしないexitするだけのプログラムを作る

ここで「何もしない」とはexitシステムコールを呼ぶということだ。

動画 3:15

@hikalium
「あー、でもreturn 0だとexitシステムコールは呼ばれないかな、プログラムの中では。return 0すると戻った先でexitシステムコールが呼ばれるけど、そのexitシステムコールはプログラムの中にはない」

@d0iasm
「ふーん、確かに!」

x86 (32bit) でシステムコールを呼ぶ場合は、int n命令のオペランドに0x80番を指定して割り込みハンドラへのコールを生成する。

int $0x80を使う際は、あらかじめシステムコール番号をeaxに格納しておく。

システムコール番号を調べてみる。

$ vi /usr/include/i386-linux-gnu/sys/syscall.h
/* This file should list the numbers of the system calls the system knows.
   But instead of duplicating this we use the information available
   from the kernel sources.  */
#include <asm/unistd.h>

ふむ。

$ vi /usr/include/i386-linux-gnu/asm/unistd.h
# ifdef __i386__
#  include <asm/unistd_32.h>
# elif defined(__ILP32__)
#  include <asm/unistd_x32.h>
# else
#  include <asm/unistd_64.h>
# endif

ふむふむ。

$ vi /usr/include/i386-linux-gnu/asm/unistd_32.h
#ifndef _ASM_X86_UNISTD_32_H
#define _ASM_X86_UNISTD_32_H 1

#define __NR_restart_syscall 0
#define __NR_exit 1 // 見つけた!
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
#define __NR_open 5
#define __NR_close 6
...

exitのシステムコール番号は0x1だと分かったので、fizzbuzz.sを作成して次のように編集する。ebxに格納しているのは第一引数だ。

  .global main
main:
  mov $0x0,%ebx
  mov $0x1,%eax
  int $0x80

このアセンブリファイルをgccに-cオプションをつけてアセンブルした上で、ldに-eオプションでエントリーポイントを指定して実行可能バイナリを生成する。

$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o

fizzbuzz.binを実行すると何も表示されない(成功)。

$ ./fizzbuzz.bin
$

hello, worldする(文字列を表示する)

もっとテンションを上げたいので、お約束のhello, worldをやっておこう。

動画 16:12

@d0iasm
「syswriteは1番っぽい」(システムコール番号)
「引数が、ファイルディスクリプタを1番目の引数に取って、2番目がバッファのポインタ、3番目がサイズっぽい」

writeシステムコールを使ってfizzbuzz.sを次のように編集する。

  .global main
main:
  # write
  mov $0xd,%edx
  lea (string),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  # exit
  mov $0x0,%ebx
  mov $0x1,%eax
  int $0x80

string:
  .ascii "hello, world\n"

ここではlea命令(Load Effective Address)を使って、文字列を置いた領域の実効アドレスをロードしている。

@hikalium
「アドレスを代入するには、mov rsi,stringだと上手くいかなくって、lea rsi,[string]

fizzbuzz.binを実行する。

$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o
$ ./fizzbuzz.bin
hello, world

よし。アセンブリ言語に入門している実感が沸々と湧いてきたぞ。

ループを導入する

次は、hello, worldを3回表示するプログラムに改造してみよう。

動画 25:00

@hikalium 「決まった回数でやりたいなら0と比較するのが一番楽だから、最初に繰り返したい回数を入れて、それで0かどうかをチェックすればいいと思う」

@hikalium
x86では、分岐命令は条件フラグ方式というのになっていて、演算結果によってセットされる条件フラグを参照してジャンプする」

fizzbuzz.sを次のように編集する。

  .global main
main:
  mov $0x3,%ecx
loop:
  mov $0xd,%edx
  push %ecx
  lea (string),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx
  dec %ecx
  jz exit
  jmp loop
exit:
  mov $0x0,%ebx
  mov $0x1,%eax
  int $0x80

string:
  .ascii "hello, world\n"

writeを呼ぶ前後でpush popしているのは、システムコールの呼び出しによって値が破壊されないようにするためだ。

@hikalium
「rcxが保存されないのかも、syscallで」

@d0iasm
システムコールが使っているレジスタはクリアされちゃうってこと?」

@hilakium
「そうそう。呼び出し規約によって破壊していいレジスタと保存しなければいけないレジスタが決まっていて、たぶんrcxは破壊していいレジスタになっているんじゃないかな」

@d0iasm
「なるほどね」

@hikalium
「だから、それを防ぐためにsyscallの前後でrcxを保存する必要があるかも」
「とりあえず、syscallの前後にpush popを入れておけばいいんじゃないかな」

fizzbuzz.binを実行する。

$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o
$ ./fizzbuzz.bin
hello, world
hello, world
hello, world

よし、上手くいった。次はカウンタの値を表示できるようにしたい。

1桁の数を表示する

動画のfizzbuzzは数を表示する代わりに改行を表示する形式で書いてある。

カウンタの値を表示するだけなら大して難しくなさそうな気がしたので試しにやってみることにした。

手始めに、上でhello, worldした(ループを導入する前の)コードを改造して1桁の数を表示する。

fizzbuzz.sを次のように編集する。

  .global main
main:
  # write
  mov $0x1,%edx
  lea (numbers),%eax
  lea 1(%eax),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  # exit
  mov $0x0,%ebx
  mov $0x1,%eax
  int $0x80

string:
  .ascii "hello, world\n"
numbers:
  .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9

lea 1(%eax),%ecxの「1」は、numbersラベルを書いたアドレスからの相対位置を表す。

これが何なのかは、色々と説明するよりも実行結果を確認した方が話が早そうだ。

fizzbuzz.binを実行する。

$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o
$ ./fizzbuzz.bin
1$

改行を入れていないので若干読みづらいが、プロンプトの手前にちゃんと文字「1」が表示されている。

lea 1(%eax),%ecxlea 2(%eax),%ecxに変更して、fizzbuzz.binを実行すると次のようになる。

$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o
$ ./fizzbuzz.bin
2$

今度は、numbersラベルを書いたアドレスからの相対位置が2なので、その位置にある文字「2」が表示されている。

じゃあ相対位置を3にするとどうなるだろうか?

勘のいい人は、私のやりたいことに気づいたかもしれない。そう、できるだけ簡単な方法で済ませてしまおう。

この先の改造がやりやすいように、文字「1」を表示するための上のコードを次のように書き直しておく。

  .global main
main:
  # write
  mov $0x1,%edx
  mov $1,%eax
  lea (numbers)(%eax),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  # exit
  mov $0x0,%ebx
  mov $0x1,%eax
  int $0x80

string:
  .ascii "hello, world\n"
numbers:
  .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9

これが差分だ。

そして、再びループを導入する。

  .global main
main:
  mov $0x3,%ecx
loop:
  # print a number
  mov $0x1,%edx
  push %ecx
  mov %ecx,%eax
  lea (numbers)(%eax),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx
  dec %ecx
  # print a newline
  mov $0x1,%edx
  push %ecx
  lea (newline),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx
  jz exit
  jmp loop
exit:
  mov $0x0,%ebx
  mov $0x1,%eax
  int $0x80

string:
  .ascii "hello, world\n"
newline:
  .byte '\n
numbers:
  .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9

fizzbuzz.binを実行する。

$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o
$ ./fizzbuzz.bin
3
2
1

よし、とりあえず1桁の数は表示できるようになった。

もっと良い書き方があるかもしれないけど、今は気にしないでおこう!(と、自分に言い聞かせるのであった)

複数桁の数を表示する

1桁の数は表示できるようになったが、今の実装では2桁以上の数を表示することができない。

今の実装の延長で複数桁の数を表示するには、各桁の値を1つずつ取り出して並べ、最後に改行を出力すればいい。

各桁の値を取り出すには、まず元の数を基数で割って、その商をまた基数で割って、その商を…という具合に、余りが0になるまで繰り返し割る。

例えば「123」なら

  • 123 / 10 -> 商:12 余り:3
  • 12 / 10 -> 商:1 余り:2
  • 1 / 10 -> 商:0 余り:1
  • 商が0になったら、余りを1つずつ並べる -> 「1」「2」「3」

という具合になる。

動画 34:32

@d0iasm
「余りを求める命令とか、ないのかな?(困)」

@hikalium
「あるよ。あるんだけどね[…]それがx86的でね[…]ちなみにdivっていう命令なんだけど」

@d0iasm
「divは、ただの割り算?」

@hikalium
「そう。でもね、divはね、割り算をするのと同時に余りも返してくれるの」

@d0iasm
「あ、なるほどね! 余りもどっかのレジスタに入れてくれるわけだ」

@hikalium
「そう!」
divって書いた後にレジスタかメモリオペランドの64ビットの大きさのものを1つ置けるんだけど、それはどういう操作をするかというと、rdxとraxの組に入っている数値を、その指定されたレジスタの数値で割って、割った答えをraxに、割った余りをrdxに格納するっていう命令です」

@d0iasm
「ほお(笑)」

@hikalium
「うふふ(笑)」

これね、最後に二人が笑っている気持がわかるのよね。私もつい最近プログラミング言語自作入門のHL-11aを実装した際に、idiv命令とidiv命令が使うレジスタの関係を初めて知って「んん?」と戸惑ったからね(笑)

慣れるまでは面倒そうだけど、ぼちぼち書いてみよう。

div命令に係るインストラクションにコメントをつけておいた。

  .global main
main:
  mov %esp,%ebp
  mov $0xf,%ecx # カウンタ
  mov %ecx,%eax # 被除数
split_loop:
  mov $0xa,%ebx # 除数
  mov $0x0,%edx # ゼロ拡張
  div %ebx      # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る)
  push %edx     # 余りをスタックに格納
  cmp $0x0,%eax # 商がゼロか
  jne split_loop

print_loop:
  # print single digit number
  mov $0x1,%edx
  pop %eax # split_loopでpushした1桁の数をpopする
  push %ecx
  lea (numbers)(%eax),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx
  cmp %esp,%ebp # スタックが空か
  jne print_loop

  # print a newline
  mov $0x1,%edx
  push %ecx
  lea (newline),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx

  dec %ecx
  jz exit
  mov %ecx,%eax # 被除数
  jmp split_loop
exit:
  mov $0x0,%ebx
  mov $0x1,%eax
  int $0x80

string:
  .ascii "hello, world\n"
newline:
  .byte '\n
numbers:
  .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9

fizzbuzz.binを実行する。

$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o
$ ./fizzbuzz.bin
15
14
13
12
11
10
9
8
7
6
5
4
3
2
1

よし、複数桁の数も表示できるようになった!

Fizz、Buzz、FizzBuzzを表示する(完成)

3の倍数のときにFizzと表示し、5の倍数のときにBuzzと表示し、15の倍数のときFizzBuzzと表示するようにプログラムを改造する。

コードが長くなったが、よく見ると「print_fizzbuzz:」「print_fizz:」「print_buzz:」のラベル以下のそれぞれのコードは、オペランドに取る値(除数など)が変わるだけで処理は同じだ。

やっていることはこれまでと同じ。新しいことはしていない。

これで最後なので一気に書き上げてしまおう。

  .global main
main:
  mov %esp,%ebp
  mov $0x1,%ecx # カウンタ
loop:

print_fizzbuzz:
  mov %ecx,%eax # 被除数
  mov $0xf,%ebx # 除数
  mov $0x0,%edx # ゼロ拡張
  div %ebx      # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る)
  cmp $0x0,%edx # 余りがゼロか
  jne print_fizz
  # write
  mov $0x9,%edx
  push %ecx
  lea (fizzbuzz),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx
  jmp increment_counter

print_fizz:
  mov %ecx,%eax # 被除数
  mov $0x3,%ebx # 除数
  mov $0x0,%edx # ゼロ拡張
  div %ebx      # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る)
  cmp $0x0,%edx # 余りがゼロか
  jne print_buzz
  # write
  mov $0x5,%edx
  push %ecx
  lea (fizz),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx
  jmp increment_counter

print_buzz:
  mov %ecx,%eax # 被除数
  mov $0x5,%ebx # 除数
  mov $0x0,%edx # ゼロ拡張
  div %ebx      # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る)
  cmp $0x0,%edx # 余りがゼロか
  jne print_number
  # write
  mov $0x5,%edx
  push %ecx
  lea (buzz),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx
  jmp increment_counter

print_number:
  mov %ecx,%eax # 被除数
split_loop:
  mov $0xa,%ebx # 除数
  mov $0x0,%edx # ゼロ拡張
  div %ebx      # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る)
  push %edx     # 余りをスタックに格納
  cmp $0x0,%eax # 商がゼロか
  jne split_loop
print_digit:
  mov $0x1,%edx
  pop %eax # split_loopでpushした1桁の数をpopする
  push %ecx
  lea (numbers)(%eax),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx
  cmp %esp,%ebp # スタックが空か
  jne print_digit
  # print a newline
  mov $0x1,%edx
  push %ecx
  lea (newline),%ecx
  mov $0x1,%ebx
  mov $0x4,%eax
  int $0x80
  pop %ecx

increment_counter:
  inc %ecx
  cmp $0x10,%ecx
  jne loop
  # exit
  mov $0x0,%ebx
  mov $0x1,%eax
  int $0x80

fizzbuzz:
  .ascii "FizzBuzz\n"
fizz:
  .ascii "Fizz\n"
buzz:
  .ascii "Buzz\n"
newline:
  .byte '\n
numbers:
  .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9

もっと良い書き方があるかもしれないけど、今は気にしないでおこう!(と、自分に言い聞かせるのであった)

fizzbuzz.binを実行する。

$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o
$ ./fizzbuzz.bin
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz

おー、上手くいった。これでおしまい。

後日、書き換えたバージョン

おわりに

書き始めた時、この記事はコマンドとコードを羅列した備忘録でしたが、書いている途中でふと思いついて、アセンブリ言語に入門しようとしている数時間前の自分に向けて語りかけるような文を添えてみました。

何らかの言語の入門者向けに段階的にコードを追加していく記事をいつか書いてみたいと思っていたので、「そうだ自分が入門するついでに書いてしまえ」と思ったのです。

入門者の私がアセンブリ言語の説明をするのも変ですのでそれは動画に譲るとして、時には以前のコードに戻りながら小さくプログラムを作っていく様子が伝わると良いなと思います。

冒頭にも書いたように私が取り組んでいるプログラミング言語自作入門の続編の処理系を改造しようとすると機械語の知識が必要になります。

処理系を改造する際にディスアセンブルして機械語を生成したら少しは楽になるのではないかという単純な考えで、アセンブリ言語に入門することにしました。

でも、何をどーすればいいんだろう? と悩んでいたところ、偶然見つけた動画に救われて、なんとか無事に(?)アセンブリ言語に入門することができました。良い時代ですね、本当に。

動画を公開してくれたお二人と、この動画をシェアしてくれた人たちに感謝します。ありがとうございました。

アイキャッチ画像

『作って分かる!x86_64機械語入門』を読んだ

ゼロから機械語に入門するために『作って分かる!x86_64機械語入門』を読んだ。

本書はQEMUを使って手を動かしながら学ぶスタイルを採っているので、「動いた!」という感触自体がモチベーションに繋がる読者にぴったりだ。

基本的な機械語命令を使って、シリアルドライバやフレームバッファを使った画面描画処理、キーボードドライバを書く内容となっている。

画像は3章でシリアルドライバを作る手始めに、⽂字'A'をシリアル通信で送って動作確認をしているところ。

この節ではシリアル通信のコントローラの状態を確認をせずに書き込みを⾏っているのだが、このままでは未送信のデータを上書きして消してしまう恐れがあるので、次の節でコントローラの「送信バッファが空になった」事を⽰すフラグを確認してから書き込むように変更する。

このように、小さく始めて段階的に作っていくので理解しやすい。

章末まで読み進めてシリアルポートから受信したデータをエコーバックする処理を実装するとそれっぽくなった!

$ od -t x1 fs/kernel.bin 
0000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0000020 66 ba fd 03 ec 24 01 75 04 f3 90 eb f3 66 ba f8
0000040 03 ec 88 c3 66 ba fd 03 ec 24 20 75 04 f3 90 eb
0000060 f3 88 d8 66 ba f8 03 ee 3c 0d 75 d4 b3 0a eb e4
0000100

筆者は「完全に私の趣味であり一発ネタのような本」と言っているが、入門に使えそうな本やWebコンテンツを読んでみるとアセンブリ言語を使った説明が主のものが多く満足できずにいたところを本書に助けられた。

補足

楽しく手を動かしながら読了したが、私の環境では再現できない内容があった。

4章で、045_color_noise/sample.txtをアセンブルしてkernl.binを生成しQEMUを実行すると、「ランダム⾊の描画により、ノイズ画像のようなものが表⽰」されると説明があるが、画面は変化せず黒いままだった。

また、この時にシリアル通信用の画面に切り替えると「!!!! X64 Exception Type - 06(#UD - Invalid Opcode) CPU Apic ID - 00000000 !!!!」ではじまる次のメッセージが表示されていた(画像)。

解決したので、後日追記予定。解決方法を追記する予定だったが、この件について著者の大神さんとコンタクトを取ることができ、丁寧に解説いただいた上で公式ページの「追加・訂正情報」に私が報告した内容を反映してもらえたので、もし同様の現象が発生してその解決のためにこの記事にたどり着いた人は公式ページをご覧ください。