🧑‍💻

#161 VSCodeのデバッガを作ってみる(Brainfuck)

に公開

導入

VSCodeには強力なデバッガ機能がありますが、これが実際にどんな仕組みで動いているのか気になり、拡張機能として自作してみました。対象としたのは、極めてシンプルな言語「Brainfuck」です。

Brainfuckは1993年に開発された、命令がたった8種類しかない極小言語です。記号だけで構成されていて、学習コストは高めですが、インタプリタやデバッガを自作するには最適な対象です。

Brainfuckの命令一覧

命令 意味
> ポインタを右に移動
< ポインタを左に移動
+ 現在のセルの値を+1
- 現在のセルの値を-1
. 現在のセルの値を出力
, 入力を受け取りセルに格納
[ 値が0なら対応する]へジャンプ
] 値が0でなければ対応する[へ戻る

Brainfuckのコード例: 'Hello World!'を出力するプログラム

++++++++
[
    >++++
    [
        >++>+++>+++>+<<<<-
    ]
    >+>+>->>+
    [
        <
    ]
    <-
]
>>.
>---.
+++++++..
+++.
>>.
<-.
<.
+++.
------.
--------.
>>+.
>++.

構成

以下の2つのワークスペースを使って確認しました:

1. デバッガ拡張機能実装用ワークスペース

VSCodeの拡張機能としてBrainfuckデバッガを作成するコードが入っています。

bf-debugger/
├── src/
│   ├── brainfuckRuntime.ts
│   ├── brainfuckDebug.ts
│   └── extension.ts
├── esbuild.js
├── language-configuration.json
├── package.json
├── tsconfig.json
└── .vscode/
    └── launch.json

以下、ファイル内容です。

extension.ts

拡張機能のエントリーポイントです。
VSCodeの拡張がアクティブになると、このファイルが読み込まれ、デバッグアダプターを初期化します。

import * as vscode from 'vscode';
import * as path from 'path';

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.debug.registerDebugAdapterDescriptorFactory(
      'brainfuck',
      new BrainfuckDebugAdapterFactory(context)
    )
  );
}

class BrainfuckDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory {
  constructor(private context: vscode.ExtensionContext) {}

  createDebugAdapterDescriptor(): vscode.DebugAdapterDescriptor {
    const command = 'node';
    const args = [path.join(this.context.extensionPath, 'out', 'brainfuckDebug.js')];
    return new vscode.DebugAdapterExecutable(command, args, {
      cwd: this.context.extensionPath
    });
  }
}

brainfuckDebug.ts

VSCodeのデバッグアダプターとの通信処理を記述します。各種リクエスト(launch, continue, stepIn など)に応答し、runtime を制御します。
今回は以下のような仕様としています。
・ステップオーバー⇒次の行の先頭まで処理を実行
・ステップイン⇒1文字分だけ処理を実行
・ステップアウト⇒実装なし
・Continue⇒次のブレークポイントまで処理を実行
・変数として、「各セルの値」「現在のコード実行箇所(X文字目)」「出力予定内容」を表示
・コード実行が完了した際、DebugConsoleに結果を出力

ts
import {
  LoggingDebugSession,
  InitializedEvent,
  StoppedEvent,
  Thread,
  StackFrame,
  Scope,
  Variable,
  DebugSession,
  Source,
  Breakpoint,
  OutputEvent
} from '@vscode/debugadapter';
import { DebugProtocol } from '@vscode/debugprotocol';
import * as fs from 'fs';
import * as path from 'path';
import { BrainfuckRuntime } from './brainfuckRuntime';

interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArguments {
  program: string;
}

class BrainfuckDebugSession extends LoggingDebugSession {
  private static THREAD_ID = 1;
  private runtime = new BrainfuckRuntime();
  private sourceFile = "";
  private steppingMode: "none" | "step" | "over" | "continue" = "none";

  constructor() {
    super("brainfuck-debug.log");
    this.setDebuggerLinesStartAt1(true);
    this.setDebuggerColumnsStartAt1(true);
  }

  protected initializeRequest(response: DebugProtocol.InitializeResponse): void {
    response.body = {supportsConfigurationDoneRequest: true };
    this.sendResponse(response);
    this.sendEvent(new InitializedEvent());
  }

  protected launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments): void {
    try {
      const code = fs.readFileSync(args.program, 'utf-8');
      this.runtime.load(code);
      this.sourceFile = args.program;
      this.sendResponse(response);
      this.sendEvent(new StoppedEvent("entry", BrainfuckDebugSession.THREAD_ID));
    } catch (err) {
      this.sendErrorResponse(response, 3002, `Failed to read program: ${err}`);
    }
  }

  protected setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments): void {
    const lines = (args.breakpoints ?? []).map(bp => bp.line);
    this.runtime.setBreakpoints(lines);
    response.body = { breakpoints: lines.map(line => new Breakpoint(true, line)) };
    this.sendResponse(response);
  }

  protected threadsRequest(response: DebugProtocol.ThreadsResponse): void {
    response.body = { threads: [new Thread(BrainfuckDebugSession.THREAD_ID, "main")] };
    this.sendResponse(response);
  }

   protected stackTraceRequest(response: DebugProtocol.StackTraceResponse): void {
    const pc = this.runtime.getPC();
    const line = this.runtime.getLineOfPC(pc);
    const column = this.runtime.getColumnOfPC(pc);
    const frame = new StackFrame(1, "brainfuck", new Source(path.basename(this.sourceFile), this.sourceFile), line, column);
    response.body = { stackFrames: [frame], totalFrames: 1 };
    this.sendResponse(response);
  }

  protected scopesRequest(response: DebugProtocol.ScopesResponse): void {
    response.body = { scopes: [new Scope("Memory", 1, false)] };
    this.sendResponse(response);
  }

  protected variablesRequest(response: DebugProtocol.VariablesResponse): void {
    const state = this.runtime.getState();
    const variables: DebugProtocol.Variable[] = [
      { name: "pointer", value: String(state.pointer), variablesReference: 0 },
      { name: "pc", value: String(state.pc), variablesReference: 0 },
      { name: "output", value: this.runtime.getOutput(), variablesReference: 0 },
      ...state.memory.map((val, i) => ({
        name: `cell[${state.offset + i}]`,
        value: `${val}${val >= 32 && val <= 126 ? ` '${String.fromCharCode(val)}'` : ''}`,
        variablesReference: 0
      }))
    ];
    response.body = { variables };
    this.sendResponse(response);
  }

  protected stepInRequest(response: DebugProtocol.StepInResponse): void {
    this.steppingMode = "step";
    this.stepExecution();
    this.sendResponse(response);
  }

  protected nextRequest(response: DebugProtocol.NextResponse): void {
    this.steppingMode = "over";
    this.stepExecution();
    this.sendResponse(response);
  }

  protected continueRequest(response: DebugProtocol.ContinueResponse): void {
    this.steppingMode = "continue";
    this.stepExecution();
    this.sendResponse(response);
  }

  private stepExecution() {
    const initialLine = this.runtime.getLineOfPC(this.runtime.getPC());
    let stepCount = 0;

    while (stepCount++ < 10000) {
      const beforePC = this.runtime.getPC();
      const beforeLine = this.runtime.getLineOfPC(beforePC);
      const stepped = this.runtime.step();
      const afterPC = this.runtime.getPC();
      const afterLine = this.runtime.getLineOfPC(afterPC);

      if (!stepped || this.runtime.isHalted()) {
        const output = this.runtime.getOutput();
        this.sendEvent(new OutputEvent(`\n[Program completed]\nOutput: ${output}\n`));
        break;
      }

      if (this.steppingMode === "step") {
        this.sendEvent(new StoppedEvent("step", BrainfuckDebugSession.THREAD_ID));
        break;
      }

      if (this.runtime.isAtBreakpoint(afterLine) && afterLine !== beforeLine) {
        this.sendEvent(new StoppedEvent("breakpoint", BrainfuckDebugSession.THREAD_ID));
        break;
      }

      if (this.steppingMode === "over" && afterLine !== initialLine) {
        this.sendEvent(new StoppedEvent("step", BrainfuckDebugSession.THREAD_ID));
        break;
      }
    }
  }
}

DebugSession.run(BrainfuckDebugSession);

brainfuckRuntime.ts

Brainfuckの実行ロジックそのものです。メモリ、ポインタ、実行位置、ブレークポイントの管理を行います。

ts
export class BrainfuckRuntime {
  private code = '';
  private memory = new Array(30000).fill(0);
  private pointer = 0;
  private pc = 0;
  private breakpoints = new Set<number>();
  private outputBuffer = '';
  private inputBuffer: string[] = [];

  public load(code: string) {
    this.code = code;
    this.memory.fill(0);
    this.pointer = 0;
    this.pc = 0;
    this.outputBuffer = '';
    this.inputBuffer = [];
  }

  public step(): boolean {
    while (this.pc < this.code.length) {
      const cmd = this.code[this.pc];

      if (!"><+-.,[]".includes(cmd)) {
        this.pc++;
        continue;
      }

      switch (cmd) {
        case '>': this.pointer++; break;
        case '<': this.pointer--; break;
        case '+': this.memory[this.pointer] = (this.memory[this.pointer] + 1) & 0xff; break;
        case '-': this.memory[this.pointer] = (this.memory[this.pointer] - 1 + 256) & 0xff; break;
        case '.': this.outputBuffer += String.fromCharCode(this.memory[this.pointer]); break;
        case ',': this.memory[this.pointer] = (this.inputBuffer.shift() ?? '\0').charCodeAt(0); break;
        case '[':
          if (this.memory[this.pointer] === 0) {
            let nest = 1;
            while (nest > 0 && ++this.pc < this.code.length) {
              if (this.code[this.pc] === '[') nest++;
              if (this.code[this.pc] === ']') nest--;
            }
            this.pc++; // ] の次へ
            return true;
          }
          break;
        case ']':
          if (this.memory[this.pointer] !== 0) {
            let nest = 1;
            while (--this.pc >= 0 && nest > 0) {
              if (this.code[this.pc] === ']') nest++;
              if (this.code[this.pc] === '[') nest--;
            }
            return true;
          }
          break;
      }

      this.pc++;
      return true;
    }
    return false;
  }

  public getOutput() {
    return this.outputBuffer;
  }

  public getState() {
    const start = Math.max(0, this.pointer - 3);
    const end = Math.min(this.memory.length, this.pointer + 4);
    return {
      pointer: this.pointer,
      pc: this.pc,
      memory: this.memory.slice(start, end),
      offset: start
    };
  }

  public getPC() {
    return this.pc;
  }

  public getLineOfPC(pc: number) {
    return this.code.slice(0, pc).split(/\r?\n/).length;
  }

  public getColumnOfPC(pc: number): number {
    const codeUpToPc = this.code.slice(0, pc);
    const lastLine = codeUpToPc.split(/\r?\n/).pop() ?? "";
    return lastLine.length + 1;
  }

  public isAtBreakpoint(line: number): boolean {
    return this.breakpoints.has(line);
  }

  public setBreakpoints(lines: number[]) {
    this.breakpoints = new Set(lines);
  }

  public getCode() {
    return this.code;
  }

  public isFirstExecutableOnLine(pc: number): boolean {
    const line = this.getLineOfPC(pc);
    for (let i = 0; i < pc; i++) {
      if (this.getLineOfPC(i) === line && "><+-.,[]".includes(this.code[i])) {
        return false;
      }
    }
    return true;
  }

  public isHalted(): boolean {
    return this.pc >= this.code.length;
  }
}

esbuild.js

VSCode拡張を素早くビルドするための簡易バンドラ設定ファイルです。

js
const esbuild = require("esbuild");

const production = process.argv.includes('--production');
const watch = process.argv.includes('--watch');

/**
 * @type {import('esbuild').Plugin}
 */
const esbuildProblemMatcherPlugin = {
        name: 'esbuild-problem-matcher',

        setup(build) {
                build.onStart(() => {
                        console.log('[watch] build started');
                });
                build.onEnd((result) => {
                        result.errors.forEach(({ text, location }) => {
                                console.error(`✘ [ERROR] ${text}`);
                                console.error(`    ${location.file}:${location.line}:${location.column}:`);
                        });
                        console.log('[watch] build finished');
                });
        },
};

async function main() {
        const ctx = await esbuild.context({
                entryPoints: [
                        'src/extension.ts'
                ],
                bundle: true,
                format: 'cjs',
                minify: production,
                sourcemap: !production,
                sourcesContent: false,
                platform: 'node',
                outfile: 'dist/extension.js',
                external: ['vscode'],
                logLevel: 'silent',
                plugins: [
                        esbuildProblemMatcherPlugin,
                ],
        });
        if (watch) {
                await ctx.watch();
        } else {
                await ctx.rebuild();
                await ctx.dispose();
        }
}

main().catch(e => {
        console.error(e);
        process.exit(1);
});

package.json

拡張機能のメタ情報、デバッグアダプターの定義、アクティベーションイベントなどを記載します。
※publisherを書いてしまうと、MarketPlaceから拡張機能を読み取ろうとする動作となってしまいエラーとなるため注意

{
  "name": "brainfuck-debugger",
  "version": "0.0.1",
  "main": "./out/extension.js",
  "engines": {
    "vscode": "^1.70.0"
  },
  "activationEvents": ["onDebug", "onDebugResolve:brainfuck"],
  "contributes": {
    "debuggers": [
      {
        "type": "brainfuck",
        "label": "Brainfuck Debug",
        "program": "${extensionPath}/out/brainfuckDebug.js",
        "runtime": "node",
        "languages": ["brainfuck"],
        "configurationAttributes": {
          "launch": {
            "properties": {
              "program": { "type": "string", "default": "${file}" }
            },
            "required": ["program"]
          }
        },
        "initialConfigurations": [
          { "type": "brainfuck", "request": "launch", "name": "Debug Brainfuck", "program": "${file}" }
        ]
      }
    ],
    "languages": [
      { "id": "brainfuck", "extensions": [".bf"], "aliases": ["Brainfuck"] }
    ]
  },
  "scripts": {
    "vscode:prepublish": "npm run compile",
    "compile": "tsc"
  },
  "devDependencies": {
    "typescript": "^4.8.0",
    "@types/node": "^24.0.3",
    "@types/vscode": "^1.70.0"
  },
  "dependencies": {
    "@vscode/debugadapter": "^1.68.0",
    "@vscode/debugprotocol": "^1.68.0"
  }
}

tsconfig.json

TypeScriptの設定ファイルです。ターゲットやルートパス、型情報の取り扱いを記述します。

{
  "compilerOptions": {
    "target": "ES6",
    "module": "commonjs",
    "outDir": "out",
    "rootDir": "src",
    "sourceMap": true,
    "strict": true
  },
  "include": ["src"]
}

language-configuration.json

拡張機能に言語としての .bf を認識させ、コメントや括弧のペア設定を定義します。

{
  "comments": {
    "lineComment": ""
  },
  "brackets": [["[", "]"]],
  "autoClosingPairs": [
    { "open": "[", "close": "]" }
  ]
}

launch.json

拡張機能のテスト(デバッグ)を行うための設定ファイルです。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch Extension",
      "type": "extensionHost",
      "request": "launch",
      "runtimeExecutable": "${execPath}",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}"
      ],
      "preLaunchTask": "npm: compile",
      "outFiles": ["${workspaceFolder}/out/**/*.js"]
    }
  ]
}

以上を配置してVSCodeのデバッガを開始すると、実装したデバッガ拡張機能を読み込んだ状態のVSCodeが新しいウィンドウで立ち上がります。

2. Extension Host(拡張機能テスト用ウィンドウ)で開くワークスペース例

bf/
├── .vscode/
│   └── launch.json
├── main.bf

main.bf

デバッグしたBrainfuckファイルです。
冒頭でご紹介したHello World!のコード等を配置します。

launch.json

このファイルにより、実装したBrainfuckデバッガを起動する構成を定義します。

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "brainfuck",
      "request": "launch",
      "name": "Debug Brainfuck",
      "program": "${file}"
    }
  ]
}

実行イメージ

実行手順

  • VSCodeで1のワークスペースを開き、デバッグを開始すると新しいVSCodeウィンドウ(Extension Host)が起動する
  • Extension Hostで2のワークスペースを開き、.bfファイルを開いて、デバッガを起動
  • ブレークポイント、ステップオーバー、ステップインを用いて、命令ごとのメモリの値を確認

↓デバッガ実行中。左ペインに変数の値が表示され、次に実行される命令文にフォーカスが当たっています。

↓デバッガ実行完了。Debug Consoleに結果が出力されます。

最後に

今回は、VSCode 拡張機能を使って Brainfuck 用のデバッガをゼロから作ってみました。
拡張の構造や Debug Adapter Protocol の流れを把握するのに非常に良い題材です。

この記事を読んで興味を持っていただいた方は是非ご自身でもデバッガ実装に挑戦してみてください。

参考

Discussion