nptclのブログ

Common Lisp処理系nptの開発メモです。https://github.com/nptcl/npt

amd64ユーザーランド(とりあえず実行)

amd64機械語を勉強していきます。

本投稿はnptに最小限のamd64コンパイラを実装できるか検討したときのメモです。
まあ結局nptamd64コンパイラは実装しないんですけど。

ユーザーランドとは、つまり一般ユーザーが作るプログラムのことです。
OSを作るわけじゃないので、カーネルモードは対象外になります。

条件は次の通り。

  • CPUはamd64形式
  • 64bitモードのみ
  • ユーザーランドのみ

まず覚えなければいけないのは、機械語を実行して確認する方法です。
自分にとってはC言語から実行する方法が簡単だったので、その方法を示します。
方法はOS依存なので、下記の環境を対象に考えます。

機械語とは、結局は単なるバイナリデータの集合です。
C言語で言うならば、unsigned charの配列でも作って、
それを強制的に関数ポインタとみなして実行すればいいことになります。

例えばこんな感じ。

unsigned char data[] = {
    /* exit(9)を呼び出す例文です */
    0xB8, 0x01, 0x00, 0x00, 0x00,       /* mov eax <- 1 */
    0xBF, 0x09, 0x00, 0x00, 0x00,       /* mov edi <- 9 */
    0xCD, 0x80                          /* int 0x80 */
};

int main()
{
    void (*call)(void);

    call = (void (*)(void))data;
    call();  /* ★たぶんうまく行かない */

    return 0;
}

注釈に記載した通り、たぶんうまく行きません。
FreeBSDで実行した結果が下記の通り。

$ cc main.c
$ ./a.out
Segmentation fault (core dumped)

大昔のOSだったらうまく行ったことでしょう。
しかしセキュリティが大切だともてはやされた結果、 メモリに対して実行可能な許可が無いとOSレベルでエラーになってしまいます。

実行許可があるメモリを用意する方法は大きく2つあり、 すでに確保されたメモリに実行許可を与える方法と新しく確保する方法です。
どちらの方法でも、mmap関数か、VirtualAlloc関数を使います。

今回は新規にメモリを確保することにします。
malloc/freeのかわりに、mmap/munmapを使う方法を示します。

ではC言語にて下記の命令を実行することを考えます。

ptr = malloc(size);

これを実行可能なメモリ領域の確保に変えると次のようになります。

FreeBSD, Linux

ptr = mmap(0, size, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_ANON|MAP_SHARED, -1, 0);

Windows

ptr = VirtualAlloc(NULL, size, MEM_COMMIT, PAGE_EXECUTE_READWRITE);

ptrが指しているメモリ空間は、関数と同様に実行可能な領域となります。
それでは、簡単な例として下記のコードを自力で機械語に翻訳してみます。

int testcode(int a, int b)
{
    return a + b;
}

関数testcodeは、単純に二つの引数を足すだけです。
面倒なことにOSによって機械語の翻訳結果が異なります。

FreeBSD, Linux

unsigned char code[] = {
    0x89, 0xF8,     /* mov eax <- edi */
    0x01, 0xF0,     /* add eax <- esi */
    0xC3            /* ret */
};

Windows

unsigned char code[] = {
    0x89, 0xC8,     /* mov eax <- ecx */
    0x01, 0xD0,     /* add eax <- edx */
    0xC3            /* ret */
};

どうして内容が異なるのでしょうか?
それは「呼出規約」というものが異なるからです。

呼出規約とは、関数の呼び出しと返却をどのようにして行うかという、 関数間のインターフェイスのことです。
例えば、第一引数はスタックに積むのか、あるいはレジスタに格納するのか、 格納するならどういう順番で何を使うかなどを規定したものです。

呼出規約は、CPU、OS、言語の種類ごとに異なります。
例えばCPUのSPARCIntelでは違っていますし、同じIntelでもi386x86-64(amd64)で違います。
FreeBSDLinuxは同じ場合も違う場合もあります。
FreeBSDWindowsはどうも絶対に異なっているようです。

つまりコンパイラを作るためには、次の2つを勉強する必要があるという事です。

一気に説明することはできないので次の投稿になるとは思うのですが、 とりあえずは例で挙げた機械語のコードを実行するためのソースを示します。

FreeBSD, Linux amd64

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>

unsigned char code[] = {
    0x89, 0xF8,     /* mov eax <- edi */
    0x01, 0xF0,     /* add eax <- esi */
    0xC3            /* ret */
};

void execute(void *ptr)
{
    int (*proc)(int, int);
    int result;

    memcpy(ptr, code, 5);
    proc = (int (*)(int, int))ptr;
    result = proc(30, 10);
    printf("%d\n", result);
}

int main()
{
    void *ptr;
    size_t size;

    size = 1024;
    ptr = mmap(0, size, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_ANON|MAP_SHARED, -1, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    execute(ptr);

    if (munmap(ptr, size)) {
        perror("munmap");
        return 1;
    }

    return 0;
}

Windows amd64

#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

unsigned char code[] = {
    0x89, 0xC8,     /* mov eax <- ecx */
    0x01, 0xD0,     /* add eax <- edx */
    0xC3            /* ret */
};

void execute(void *ptr)
{
    int (*proc)(int, int);
    int result;

    memcpy(ptr, code, 5);
    proc = (int (*)(int, int))ptr;
    result = proc(30, 10);
    printf("%d\n", result);
}

int main()
{
    void *ptr;
    size_t size;

    size = 1024;
    ptr = VirtualAlloc(NULL, size, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    if (ptr == NULL) {
        fprintf(stderr, "VirutalAlloc error\n");
        return 1;
    }

    execute(ptr);

    if (! VirtualFree(ptr, 0, MEM_RELEASE)) {
        fprintf(stderr, "VirutalFree error\n");
        return 1;
    }

    return 0;
}

実行結果は、どちらも40が出力されます。
これで思い通りのバイトコードを実行できるようになりました。

次からはamd64機械語を紹介できればいいなと思います。