看我是如何用C#編寫一個小於8KB的貪吃蛇遊戲的

2023-01-12 12:00:58

譯者注:這是Michal Strehovský大佬的一篇文章,他目前在微軟.NET Runtime團隊工作,主要是負責.NET NativeAOT功能的開發。我在前幾天看到這篇文章,非常喜歡,雖然它的內容稍微有點過時(還是使用的.NET Core 3.0),不過其中的一些程式設計技巧和思維方式很受用,特意找到Michal大佬要到了授權,翻譯給大家看。

作者:Michal Strehovský
譯者:InCerry
原文連結:https://medium.com/@MStrehovsky/building-a-self-contained-game-in-c-under-8-kilobytes-74c3cf60ea04

作為一個在1.44MB軟碟和56kbit資料機時代長大的人,我一直喜歡小程式。我可以在隨身攜帶的軟碟上裝下許多小程式。如果一個程式不能放在我的軟碟上,我就開始思考為什麼-它有大量的圖形嗎?有音樂嗎?這個程式能做很多複雜的事情嗎?還是它根本就是臃腫的?

圖片來自 Brett Jordan Unsplash

現在,磁碟空間變得如此便宜(巨大的快閃記憶體盤無處不在),人們放棄了對程式大小的優化。

有一個場景的大小仍然很重要,那就是傳輸:當線上路上傳輸一個程式時,每秒只能傳遞兆位元組的資料。一個快速的100MBit連線在最好的情況下每秒只能傳輸12MB。如果線上路的另一端是一個等待下載完成的人,五秒和一秒之間的差異會對他們的體驗產生很大的影響。

此人可能直接(使用者通過網路下載程式)或間接(部署Severless服務以響應 Web 請求)暴露在傳輸時間中。

人們通常認為任何快於0.1秒的東西都是即時的,3.0秒大約是使用者的流量保持不間斷的極限,而你很難在10秒後讓使用者保持參與。

雖然更小一點程式不再是必須的,但它仍然是更好的。

這篇文章是作為一個實驗而出現的,目的是找出一個有用的自包含執行時的C#可執行檔案可以有多小。C#應用程式能否達到使用者會認為瞬間就能下載完畢的大小?它是否能使C#被用於現在還沒有被使用的場景?

究竟什麼是 「自包含」?

一個自包含的應用程式是指包括在作業系統的虛構安裝上執行所需的一切。

C#編譯器屬於一組以虛擬機器器為目標的編譯器(Java和Kotlin是該組的另一個知名的語言):C#編譯器的輸出是一個可執行檔案,需要某種虛擬機器器(VM)來執行。人們不能只安裝一個裸機作業系統,並期望能夠在上面執行由C#編譯器產生的程式。

至少在Windows上,過去人們可以依靠在整個機器上安裝.NET Framework來執行C#編譯器的輸出。現在,有許多Windows SKU不再攜帶.NET Framework(物聯網、Nano Server、ARM64......)。.NET Framework也不支援C#語言的最新增強功能。它有點像在走下坡路。

為了使C#應用程式自成一體,它需要包括執行時和它使用的所有類庫。在我們的計劃中,要把很多東西裝進只有8KB的預算中!這是很重要的。

8KB的遊戲

我們要建立一個克隆版的貪吃蛇遊戲,下面是完成後的演示:

如果你對遊戲機制不感興趣,請隨意跳到有趣的部分,我們在9個步驟中將遊戲從65MB縮小到8KB(向下捲動到你看到圖形的地方)。

遊戲將在文字模式下執行,我們將使用框畫字元來畫蛇。我相信Vulcan或DirectX會更有趣,但我們會用System.Console來搞定。

一個無分配的遊戲

我們將建立一個無分配的遊戲 - 我所說的無分配並不是指C#遊戲開發者中常見的 "不要在遊戲迴圈中分配"。我的意思是 "在整個程式碼庫中禁止使用參照型別的new關鍵字"。其原因將在縮小遊戲的最後階段變得明顯。

有了這樣的限制,人們可能會想,使用C#到底有沒有意義:沒有new關鍵字,我們就不會使用垃圾收集器,我們就不能丟擲異常,等等 - 像C語言一樣,也可以工作。

使用C#的一個原因是 "因為我們可以"。另一個原因是可測試性和程式碼共用 - 雖然遊戲整體上是無分配的,但這並不意味著它的一部分不能在沒有這種限制的不同專案中重複使用。例如,遊戲的部分內容可以包含在xUnit專案中,以獲得單元測試覆蓋。如果選擇C語言來構建遊戲,那麼即使程式碼從其他地方被重用,事情也必須受到C語言所能做到的限制。但由於C#提供了高水平和低水平結構的良好組合,我們可以遵循"預設為高水平,必要時為低水平(譯者注:也就是說C#語言下限很低,上限很高的意思,99%的情況可以直接編寫簡單的高抽象的程式碼,1%的情況可以直接寫類似C++低階程式碼)"的哲學。

為了達到8KB的部署大小,低階別的部分將是必要的。

遊戲結構

讓我們從一個表示幀緩衝器的結構體開始。幀緩衝器是一個元件,用來儲存要繪製到螢幕上的畫素(或者在這裡是字元):

unsafe struct FrameBuffer
{
    public const int Width = 40;
    public const int Height = 20;
    public const int Area = Width * Height;

    fixed char _chars[Area];

    public void SetPixel(int x, int y, char character)
    {
        _chars[y * Width + x] = character;
    }

    public void Clear()
    {
        for (int i = 0; i < Area; i++)
            _chars[i] = ' ';
    }

    public readonly void Render()
    {
        Console.SetCursorPosition(0, 0);

        const ConsoleColor snakeColor = ConsoleColor.Green;

        Console.ForegroundColor = snakeColor;

        for (int i = 1; i <= Area; i++)
        {
            char c = _chars[i - 1];

            if (c == '*' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))
            {
                Console.ForegroundColor = c == '*' ? ConsoleColor.Red : ConsoleColor.White;
                Console.Write(c);
                Console.ForegroundColor = snakeColor;
            }
            else
                Console.Write(c);

            if (i % Width == 0)
            {
                Console.SetCursorPosition(0, i / Width - 1);
            }
        }
    }
}

我們提供了一些方法來設定各個畫素,清除幀緩衝區,並將幀緩衝區的內容渲染到System.Console中。渲染步驟對幾個字元進行了特殊處理,這樣我們就可以得到彩色的輸出,而不需要對幀緩衝區的每個畫素進行顏色跟蹤。

需要指出的一個有趣的事情是fixed _chars[Area]欄位:這是C#的語法,用於宣告一個固定陣列。固定陣列是一個陣列,其各個元素是結構的一部分。您可以將其視為一組欄位char _char_0, _char_1, _char_2, _char_3,...的快捷方式。_char_Area,可以作為一個陣列存取。這個陣列的大小需要是一個編譯時的常數,以便整個結構的大小是固定的。

我們不能過分追求固定陣列的大小,因為作為結構的一部分,陣列需要住在堆疊中,而堆疊往往被限制在很小的位元組數內(通常每個執行緒1MB)。但是,40*20*2位元組(width*height*sizeof(char))應該沒問題。

接下來我們需要的是一個亂數發生器。.NET自帶的亂數發生器是一個參照型別(有很好的理由!),我們禁止自己使用new關鍵字 - 我們不能使用它。一個簡單的結構就可以了。

struct Random
{
    private uint _val;

    public Random(uint seed)
    {
        _val = seed;
    }

    public uint Next() => _val = (1103515245 * _val + 12345) % 2147483648;
}

這個亂數發生器不是很好,但我們不需要任何複雜的東西。

現在,我們只需要一些東西來包裝蛇的邏輯。是時候建立一個 "蛇"結構了。

struct Snake
{
    public const int MaxLength = 30;

    private int _length;

    // 身體是一個打包的整數,打包了X座標、Y座標和字元。
    // 為蛇的身體。
    // 只有原始型別可以使用C#的`固定`,因此這是一個`int`。
    private unsafe fixed int _body[MaxLength];

    private Direction _direction;
    private Direction _oldDirection;

    public Direction Course
    {
        set
        {
            if (_oldDirection != _direction)
                _oldDirection = _direction;

            if (_direction - value != 2 && value - _direction != 2)
                _direction = value;
        }
    }

    public unsafe Snake(byte x, byte y, Direction direction)
    {
        _body[0] = new Part(x, y, DirectionToChar(direction, direction)).Pack();
        _direction = direction;
        _oldDirection = direction;
        _length = 1;
    }

    public unsafe bool Update()
    {
        Part oldHead = Part.Unpack(_body[0]);
        Part newHead = new Part(
            (byte)(_direction switch
            {
                Direction.Left => oldHead.X == 0 ? FrameBuffer.Width - 1 : oldHead.X - 1,
                Direction.Right => (oldHead.X + 1) % FrameBuffer.Width,
                _ => oldHead.X,
            }),
            (byte)(_direction switch
            {
                Direction.Up => oldHead.Y == 0 ? FrameBuffer.Height - 1 : oldHead.Y - 1,
                Direction.Down => (oldHead.Y + 1) % FrameBuffer.Height,
                _ => oldHead.Y,
            }),
            DirectionToChar(_direction, _direction)
            );

        oldHead = new Part(oldHead.X, oldHead.Y, DirectionToChar(_oldDirection, _direction));

        bool result = true;

        for (int i = 0; i < _length - 1; i++)
        {
            Part current = Part.Unpack(_body[i]);
            if (current.X == newHead.X && current.Y == newHead.Y)
                result = false;
        }

        _body[0] = oldHead.Pack();

        for (int i = _length - 2; i >= 0; i--)
        {
            _body[i + 1] = _body[i];
        }

        _body[0] = newHead.Pack();

        _oldDirection = _direction;

        return result;
    }

    public unsafe readonly void Draw(ref FrameBuffer fb)
    {
        for (int i = 0; i < _length; i++)
        {
            Part p = Part.Unpack(_body[i]);
            fb.SetPixel(p.X, p.Y, p.Character);
        }
    }

    public bool Extend()
    {
        if (_length < MaxLength)
        {
            _length += 1;
            return true;
        }
        return false;
    }

    public unsafe readonly bool HitTest(int x, int y)
    {
        for (int i = 0; i < _length; i++)
        {
            Part current = Part.Unpack(_body[i]);
            if (current.X == x && current.Y == y)
                return true;
        }

        return false;
    }

    private static char DirectionToChar(Direction oldDirection, Direction newDirection)
    {
        const string DirectionChangeToChar = "│┌?┐┘─┐??└│┘└?┌─";
        return DirectionChangeToChar[(int)oldDirection * 4 + (int)newDirection];
    }

    // 幫助結構來打包和解壓_body中打包的整數。
    readonly struct Part
    {
        public readonly byte X, Y;
        public readonly char Character;

        public Part(byte x, byte y, char c)
        {
            X = x;
            Y = y;
            Character = c;
        }

        public int Pack() => X << 24 | Y << 16 | Character;
        public static Part Unpack(int packed) => new Part((byte)(packed >> 24), (byte)(packed >> 16), (char)packed);
    }

    public enum Direction
    {
        Up, Right, Down, Left
    }
}

蛇需要跟蹤的狀態是。代表蛇的身體的每個畫素的座標:

  • 蛇的當前長度。
  • 蛇的當前方向。
  • 蛇的過去方向(以備我們需要畫 "彎 "字而不是直線)。

蛇提供了一些方法來"延長"蛇的長度(如果蛇已經長到一定長度則返回false),用蛇的身體來 "測試"一個畫素,"繪製"蛇到一個 "FrameBuffer"中,以及"更新"蛇的位置,作為對遊戲tick的響應(如果蛇吃了自己則返回false)。還有一個屬性用於設定蛇的當前"路線"。

我們使用與幀緩衝區相同的固定陣列技巧來保持蛇的無分配。這意味著蛇的最大長度必須是一個編譯時常數。

我們需要的最後一件事是遊戲迴圈:

struct Game
{
    enum Result
    {
        Win, Loss
    }

    private Random _random;

    private Game(uint randomSeed)
    {
        _random = new Random(randomSeed);
    }

    private Result Run(ref FrameBuffer fb)
    {
        Snake s = new Snake(
            (byte)(_random.Next() % FrameBuffer.Width),
            (byte)(_random.Next() % FrameBuffer.Height),
            (Snake.Direction)(_random.Next() % 4));

        MakeFood(s, out byte foodX, out byte foodY);

        long gameTime = Environment.TickCount64;

        while (true)
        {
            fb.Clear();

            if (!s.Update())
            {
                s.Draw(ref fb);
                return Result.Loss;
            }

            s.Draw(ref fb);

            if (Console.KeyAvailable)
            {
                ConsoleKeyInfo ki = Console.ReadKey(intercept: true);
                switch (ki.Key)
                {
                    case ConsoleKey.UpArrow:
                        s.Course = Snake.Direction.Up; break;
                    case ConsoleKey.DownArrow:
                        s.Course = Snake.Direction.Down; break;
                    case ConsoleKey.LeftArrow:
                        s.Course = Snake.Direction.Left; break;
                    case ConsoleKey.RightArrow:
                        s.Course = Snake.Direction.Right; break;
                }
            }

            if (s.HitTest(foodX, foodY))
            {
                if (s.Extend())
                    MakeFood(s, out foodX, out foodY);
                else
                    return Result.Win;
            }

            fb.SetPixel(foodX, foodY, '*');

            fb.Render();

            gameTime += 100;

            long delay = gameTime - Environment.TickCount64;
            if (delay >= 0)
                Thread.Sleep((int)delay);
            else
                gameTime = Environment.TickCount64;
        }
    }

    void MakeFood(in Snake snake, out byte foodX, out byte foodY)
    {
        do
        {
            foodX = (byte)(_random.Next() % FrameBuffer.Width);
            foodY = (byte)(_random.Next() % FrameBuffer.Height);
        }
        while (snake.HitTest(foodX, foodY));
    }

    static void Main()
    {
        Console.SetWindowSize(FrameBuffer.Width, FrameBuffer.Height);
        Console.SetBufferSize(FrameBuffer.Width, FrameBuffer.Height);
        Console.Title = "See Sharp Snake";
        Console.CursorVisible = false;

        FrameBuffer fb = new FrameBuffer();

        while (true)
        {
            Game g = new Game((uint)Environment.TickCount64);
            Result result = g.Run(ref fb);

            string message = result == Result.Win ? "You win" : "You lose";

            int position = (FrameBuffer.Width - message.Length) / 2;
            for (int i = 0; i < message.Length; i++)
            {
                fb.SetPixel(position + i, FrameBuffer.Height / 2, message[i]);
            }

            fb.Render();

            Console.ReadKey(intercept: true);
        }
    }
}

我們使用亂數發生器生成蛇的隨機位置和方向,我們隨機地將食物放在遊戲表面,確保它不與蛇重疊,然後開始遊戲迴圈。

在遊戲迴圈中,我們要求蛇更新它的位置並檢查它是否吃了自己。然後我們畫出蛇,檢查鍵盤的輸入,用食物對蛇進行測試,並將所有內容渲染到控制檯。

這就差不多了。讓我們看看我們在尺寸方面的情況。

.NET Core 3.0 貪吃蛇的大小

我把遊戲放在GitHub repo中,這樣你就可以跟著做了。該專案檔案將根據傳遞給publishMode屬性,以不同的設定製作遊戲。要用CoreCLR生成預設設定,請執行:

dotnet publish -r win-x64 -c Release

這將產生一個單一的EXE檔案,其容量高達65MB。產生的EXE包括遊戲、.NET執行時和作為.NET標準部分的基礎類庫。你可能會說 "仍然比Electron好",但讓我們看看我們是否能做得更好。

IL Linker

IL Linker是一個隨.NET Core 3.0出廠的工具 - 該工具通過掃描整個程式並刪除未被參照的程式集來刪除你的應用程式中未使用的程式碼。要在專案中使用它,需要傳遞一個PublishTrimmed屬性來發布。像這樣:

dotnet publish -r win-x64 -c Release /p:PublishTrimmed=true

在這種設定下,遊戲縮減到25MB。這是一個很好的開端,帶來了60%的縮減,但離我們10KB的目標還很遠。

IL Linker有更積極的設定,但沒有公開,它們可以進一步降低這個數位,最終,我們將受到CoreCLR執行時本身coreclr.dll(5.3MB的限制)。我們可能已經在通往8KB遊戲的道路上走到了死衚衕。

曲線救國: Mono

Mono是另一個.NET執行時,對很多人來說是Xamarin的同義詞。為了用C#貪吃蛇構建一個可執行檔案,我們可以使用Mono自帶的mkbundle工具。

mkbundle SeeSharpSnake.dll --simple -o SeeSharpSnake.exe

這將產生一個12.3MB的可執行檔案,它依賴於mono-2.0-sgen.dll,它本身有5.9MB - 所以我們看到總共有18.2MB。當試圖啟動它時,我碰到了 "錯誤的對映檔案:mono_file_map_error失敗",但是除了這個錯誤之外,還會有其它問題,mono最終的結果是18.2 MB。

與CoreCLR不同,Mono還依賴於Visual C++執行時再分配庫,而該庫在預設的Windows安裝中是不可用的:為了保持應用程式自成一體的目標,我們需要將該庫與應用程式一起攜帶。這使應用程式的佔用空間又增加了一兆位元組左右。

我們有可能通過新增IL連結器來縮小體積,但我們會遇到與CoreCLR相同的問題-執行時(mono-2.0-sgen.dll)的大小為5.9MB(加上它上面的C++執行時庫的大小),它代表了任何可能的IL級優化可能帶給我們的底限。

我們可以把執行時拿掉嗎?

很明顯,為了達到接近8KB的目標,我們需要把執行時從應用程式中剝離出來。唯一可以做到這一點的.NET執行時是CoreRT。雖然人們通常稱CoreRT為"執行時",但它更接近於一個"執行時庫"。它不是像CoreCLR或Mono那樣的虛擬機器器 - CoreRT的執行時只是一組函數,支援由CoreRT的AOT編譯器產生的原生程式碼。

CoreRT自帶的庫使CoreRT看起來像其他的.NET執行時:有一個新增GC的庫,新增支援反射的庫,新增JIT的庫,新增直譯器的庫,等等。但所有這些庫都是可選的(包括GC)。

更多關於CoreRT與CoreCLR和Mono的不同之處在這篇文章。當我在閱讀D語言的執行時間時,它讓我想起了CoreRT的很多內容。這篇文章也是一個有趣的閱讀。

讓我們看看我們在預設的CoreRT設定下的情況:

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT

這降到了4.7MB。這是迄今為止最小的,但仍然不夠好。

在CoreRT中設定節省級別為中等

CoreRT-AOT編譯器提供了大量影響程式碼生成的設定。預設情況下,編譯器試圖以犧牲生成的可執行檔案的大小為代價,最大限度地提高生成程式碼的速度和與其他.NET執行機制的相容性。

編譯器有一個內建的連結器,可以刪除未使用的程式碼。我們在Snake專案中定義的 "CoreRT-Moderate "設定放寬了對刪除未使用程式碼的一個限制,允許更多的刪除。我們還要求編譯器用程式速度換取一些額外的位元組。大多數.NET程式在這種模式下都能正常工作。

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-Moderate

我們現在是4.3 MB。

在CoreRT中設定節省級別為高

我把另外幾個編譯選項歸納為"高"模式。這個模式將刪除對許多會影響到應用程式的東西的支援,但Snake(作為低階別的東西)不會有問題。

我們將刪除:

  • 框架實施細節的堆疊跟蹤資料
  • 框架產生的異常中的異常資訊
  • 對非英語區的支援
  • EventSource工具化
dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-High

我們已經達到了3.0MB。這是我們開始時的5%,但CoreRT還有一招。

關閉反射

CoreRT執行時庫的很大一部分是用於實現.NET的反射。因為CoreRT是一個提前編譯的基於執行時庫的.NET實現,它不需要典型的基於虛擬機器器的執行時(如CoreCLR和Mono)需要的大部分資料結構。這些資料包括諸如型別、方法、簽名、基礎型別等的名稱。CoreRT嵌入這些資料是因為使用.NET反射的程式需要它,但不是因為執行時需要它。我把這些資料稱為 "反射開銷",因為它對執行時來說就是這樣的。

CoreRT支援一種無反射模式,可以避免這種開銷。你可能會覺得很多.NET程式碼在沒有反射的情況下無法運作,你可能是對的,但有很多東西確實可以工作,令人驚訝。Gui.cs、System.IO.Pipelines,甚至是一個基本的WinForms應用程式。貪吃蛇肯定會工作,所以讓我們把這個模式開啟。

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-ReflectionFree

我們現在是1.2MB。反映反射開銷是相當大的!

來點騷操作

現在我們已經走到了.NET SDK可能實現的盡頭,我們需要來點騷操作。我們現在要做的事情已經開始變得很荒謬了,我不指望其他人能做到這一點。我們要依靠CoreRT編譯器和執行時的實現細節。

正如我們前面所看到的,CoreRT是一套執行時庫,加上一個超前的編譯器。如果我們用一個最小的重新實現來取代執行時庫呢?我們已經決定不使用垃圾收集器,這使得這項工作更加可行。

讓我們從簡單的事情開始:

namespace System.Threading
{
    static class Thread
    {
        [DllImport("api-ms-win-core-synch-l1-2-0")]
        public static extern void Sleep(int delayMs);
    }
}

namespace System
{
    static class Environment
    {
        [DllImport("api-ms-win-core-sysinfo-l1-1-0")]
        private static extern long GetTickCount64();

        public static long TickCount64 => GetTickCount64();
    }
}

在這裡我們重新實現了Thread.SleepEnvironment.TickCount64(用於Windows),同時避免了對現有執行時庫的所有依賴。

讓我們對遊戲使用的System.Console子集做同樣的事情:

namespace System
{
    static class Console
    {
        private enum BOOL : int
        {
            FALSE = 0,
            TRUE = 1,
        }

        [DllImport("api-ms-win-core-processenvironment-l1-1-0")]
        private static unsafe extern IntPtr GetStdHandle(int c);

        private readonly static IntPtr s_outputHandle = GetStdHandle(-11);

        private readonly static IntPtr s_inputHandle = GetStdHandle(-10);

        [DllImport("api-ms-win-core-console-l2-1-0.dll", EntryPoint = "SetConsoleTitleW")]
        private static unsafe extern BOOL SetConsoleTitle(char* c);
        public static unsafe string Title
        {
            set
            {
                fixed (char* c = value)
                    SetConsoleTitle(c);
            }
        }

        [StructLayout(LayoutKind.Sequential)]
        struct CONSOLE_CURSOR_INFO
        {
            public uint Size;
            public BOOL Visible;
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleCursorInfo(IntPtr handle, CONSOLE_CURSOR_INFO* cursorInfo);

        public static unsafe bool CursorVisible
        {
            set
            {
                CONSOLE_CURSOR_INFO cursorInfo = new CONSOLE_CURSOR_INFO
                {
                    Size = 1,
                    Visible = value ? BOOL.TRUE : BOOL.FALSE
                };
                SetConsoleCursorInfo(s_outputHandle, &cursorInfo);
            }
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleTextAttribute(IntPtr handle, ushort attribute);

        public static ConsoleColor ForegroundColor
        {
            set
            {
                SetConsoleTextAttribute(s_outputHandle, (ushort)value);
            }
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct KEY_EVENT_RECORD
        {
            public BOOL KeyDown;
            public short RepeatCount;
            public short VirtualKeyCode;
            public short VirtualScanCode;
            public short UChar;
            public int ControlKeyState;
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct INPUT_RECORD
        {
            public short EventType;
            public KEY_EVENT_RECORD KeyEvent;
        }

        [DllImport("api-ms-win-core-console-l1-2-0", EntryPoint = "PeekConsoleInputW", CharSet = CharSet.Unicode)]
        private static unsafe extern BOOL PeekConsoleInput(IntPtr hConsoleInput, INPUT_RECORD* lpBuffer, uint nLength, uint* lpNumberOfEventsRead);

        public static unsafe bool KeyAvailable
        {
            get
            {
                uint nRead;
                INPUT_RECORD buffer;
                while (true)
                {
                    PeekConsoleInput(s_inputHandle, &buffer, 1, &nRead);

                    if (nRead == 0)
                        return false;

                    if (buffer.EventType == 1 && buffer.KeyEvent.KeyDown != BOOL.FALSE)
                        return true;

                    ReadConsoleInput(s_inputHandle, &buffer, 1, &nRead);
                }
            }
        }

        [DllImport("api-ms-win-core-console-l1-2-0", EntryPoint = "ReadConsoleInputW", CharSet = CharSet.Unicode)]
        private static unsafe extern BOOL ReadConsoleInput(IntPtr hConsoleInput, INPUT_RECORD* lpBuffer, uint nLength, uint* lpNumberOfEventsRead);

        public static unsafe ConsoleKeyInfo ReadKey(bool intercept)
        {
            uint nRead;
            INPUT_RECORD buffer;
            do
            {
                ReadConsoleInput(s_inputHandle, &buffer, 1, &nRead);
            }
            while (buffer.EventType != 1 || buffer.KeyEvent.KeyDown == BOOL.FALSE);

            return new ConsoleKeyInfo((char)buffer.KeyEvent.UChar, (ConsoleKey)buffer.KeyEvent.VirtualKeyCode, false, false, false);
        }

        struct SMALL_RECT
        {
            public short Left, Top, Right, Bottom;
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleWindowInfo(IntPtr handle, BOOL absolute, SMALL_RECT* consoleWindow);

        public static unsafe void SetWindowSize(int x, int y)
        {
            SMALL_RECT rect = new SMALL_RECT
            {
                Left = 0,
                Top = 0,
                Right = (short)(x - 1),
                Bottom = (short)(y - 1),
            };
            SetConsoleWindowInfo(s_outputHandle, BOOL.TRUE, &rect);
        }

        [StructLayout(LayoutKind.Sequential)]
        struct COORD
        {
            public short X, Y;
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleScreenBufferSize(IntPtr handle, COORD size);

        public static void SetBufferSize(int x, int y)
        {
            SetConsoleScreenBufferSize(s_outputHandle, new COORD { X = (short)x, Y = (short)y });
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleCursorPosition(IntPtr handle, COORD position);

        public static void SetCursorPosition(int x, int y)
        {
            SetConsoleCursorPosition(s_outputHandle, new COORD { X = (short)x, Y = (short)y });
        }

        [DllImport("api-ms-win-core-console-l1-2-0", EntryPoint = "WriteConsoleW")]
        private static unsafe extern BOOL WriteConsole(IntPtr handle, void* buffer, int numChars, int* charsWritten, void* reserved);

        public static unsafe void Write(char c)
        {
            int dummy;
            WriteConsole(s_outputHandle, &c, 1, &dummy, null);
        }
    }
}

讓我們用這個替換框架重建遊戲:

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-ReflectionFree /p:IncludePal=true

不出所料,這並沒有為我們節省多少。我們要替換的API已經是相對輕量級的了,重寫它們只獲得了幾千位元組,不值得一提。但這是通往我們旅程中最後一步的重要墊腳石。

替換所有的執行時庫

在Snake遊戲中剩下的1.2MB的程式碼和資料是用來支援我們看不到的東西,但卻在那裡 - 在我們需要它們的時候準備好了。有垃圾收集器,對例外處理的支援,當發生未處理的異常時格式化和列印堆疊痕跡到控制檯的程式碼,以及許多其他隱藏在底層的東西。

編譯器可以檢測到這些都不需要,並避免生成它們,但我們要做的事情非常奇怪,不值得新增編譯器功能來支援它。避免這種情況的方法是簡單地提供一個替代的執行時庫。

讓我們從重新定義一個最小版本的基本型別開始:

namespace System
{
    public class Object
    {
        // 物件的佈局是與編譯器的契約.
        public IntPtr m_pEEType;
    }
    public struct Void { }

    // 原始型別的佈局是特例,因為它將是遞迴的。
    // 這些真的不需要任何欄位來工作。
    public struct Boolean { }
    public struct Char { }
    public struct SByte { }
    public struct Byte { }
    public struct Int16 { }
    public struct UInt16 { }
    public struct Int32 { }
    public struct UInt32 { }
    public struct Int64 { }
    public struct UInt64 { }
    public struct IntPtr { }
    public struct UIntPtr { }
    public struct Single { }
    public struct Double { }

    public abstract class ValueType { }
    public abstract class Enum : ValueType { }

    public struct Nullable<T> where T : struct { }
    
    public sealed class String
    {
        // 字串型別的佈局是與編譯器的契約。
        public readonly int Length;
        public char _firstChar;

        public unsafe char this[int index]
        {
            [System.Runtime.CompilerServices.Intrinsic]
            get
            {
                return Internal.Runtime.CompilerServices.Unsafe.Add(ref _firstChar, index);
            }
        }
    }
    public abstract class Array { }
    public abstract class Delegate { }
    public abstract class MulticastDelegate : Delegate { }

    public struct RuntimeTypeHandle { }
    public struct RuntimeMethodHandle { }
    public struct RuntimeFieldHandle { }

    public class Attribute { }
}

namespace System.Runtime.CompilerServices
{
    internal sealed class IntrinsicAttribute : Attribute { }

    public class RuntimeHelpers
    {
        public static unsafe int OffsetToStringData => sizeof(IntPtr) + sizeof(int);
    }
}

namespace System.Runtime.InteropServices
{
    public enum CharSet
    {
        None = 1,
        Ansi = 2,
        Unicode = 3,
        Auto = 4,
    }

    public sealed class DllImportAttribute : Attribute
    {
        public string EntryPoint;
        public CharSet CharSet;
        public DllImportAttribute(string dllName) { }
    }

    public enum LayoutKind
    {
        Sequential = 0,
        Explicit = 2,
        Auto = 3,
    }

    public sealed class StructLayoutAttribute : Attribute
    {
        public StructLayoutAttribute(LayoutKind layoutKind) { }
    }
}
namespace Internal.Runtime.CompilerServices
{
    public static unsafe partial class Unsafe
    {
        // 這個方法的主體是由編譯器生成的。
        // 它將做Unsafe.Add應該做的事情。只是不可能用C#來表達它。
        [System.Runtime.CompilerServices.Intrinsic]
        public static extern ref T Add<T>(ref T source, int elementOffset);
    }
}

在這一點上,讓我們放棄專案檔案和dotnet CLI,直接啟動各個工具。我們首先啟動C#編譯器(CSC)。我建議從 "x64 Native Tools Command Prompt for VS 2019 "啟動這些命令 - 如果你安裝了Visual Studio,它就在你的開始選單中。正確的工具版本在該視窗的PATH上。

/noconfig/nostdlib/runtimemetadataversion是編譯定義System.Object的東西需要的神奇開關。我選擇了.lexe副檔名而不是.exe,因為.exe將被用於成品。

csc.exe /debug /O /noconfig /nostdlib /runtimemetadataversion:v4.0.30319 MiniBCL.cs Game\FrameBuffer.cs Game\Random.cs Game\Game.cs Game\Snake.cs Pal\Thread.Windows.cs Pal\Environment.Windows.cs Pal\Console.Windows.cs /out:zerosnake.ilexe /langversion:latest /unsafe

這將成功地用C#編譯器編譯出遊戲的IL位元組碼版本。我們仍然需要某種執行時來執行它。

讓我們嘗試將其送入CoreRT提前編譯器,從IL中生成原生程式碼。如果你按照上面的步驟,你會在你的NuGet軟體包快取中找到ilc.exe,即CoreRT提前編譯器(類似於%USERPROFILE%\.nuget\packages\runtime.win-x64.microsoft.dotnet.ilcompiler\1.0.0-alpha-27402-01\Tools的地方)。

ilc.exe zerosnake.ilexe -o zerosnake.obj --systemmodule zerosnake --Os -g

這將會以 "預期型別'Internal.Runtime.CompilerHelpers.StartupCodeHelpers'未在模組'zerosnake'中找到"的異常而崩潰。事實證明,除了一個託管的開發者所期望的明顯的最低限度外,還有一個CoreRT編譯器編譯輸入的最低限度。

讓我們跳到後面去,新增需要的東西:

namespace Internal.Runtime.CompilerHelpers
{
    // 編譯器尋找的一個類,它有幫助器來初始化
    // 程序。編譯器可以優雅地處理不存在的幫助器。
    // 但是類本身不存在則無法處理。讓我們新增一個空類。
    class StartupCodeHelpers
    {
    }
}

namespace System
{
    // 一種特殊的型別,編譯器用它來實現通用介面
    // (例如IEnumerable<T>)的陣列。我們的陣列將不會實現任何通用介面。
    class Array<T> : Array { }
}

namespace System.Runtime.InteropServices
{
    // 自定義屬性,標誌著一個類具有特殊的"呼叫"。
    // 編譯器有特殊的邏輯處理型別,有這個屬性。
    internal class McgIntrinsicsAttribute : Attribute { }
}

namespace System.Runtime.CompilerServices
{
    // 一個負責執行靜態建構函式的類。編譯器將呼叫這個
    //程式碼以確保靜態建構函式的執行,並且只執行一次。
    [System.Runtime.InteropServices.McgIntrinsics]
    internal static class ClassConstructorRunner
    {
        private static unsafe IntPtr CheckStaticClassConstructionReturnNonGCStaticBase(ref StaticClassConstructionContext context, IntPtr nonGcStaticBase)
        {
            CheckStaticClassConstruction(ref context);
            return nonGcStaticBase;
        }

        private static unsafe void CheckStaticClassConstruction(ref StaticClassConstructionContext context)
        {
            // 非常簡化的類建構函式執行器。在現實世界中,類構造器執行器
            // 需要能夠處理潛在的多個執行緒競相初始化的問題。
            // 一個單一的類,並需要能夠處理潛在的死鎖
            // 類建構函式之間的潛在死鎖。

            // 如果該類已經被初始化,我們就完成了。
            if (context.initialized == 1)
                return;

            // 將該類標記為初始化。
            context.initialized = 1;

            // 執行類別建構函式。
            Call<int>(context.cctorMethodAddress);
        }

        // 這是一個特殊的編譯器內在因素,呼叫pfn所指向的方法。
        // 編譯器會為此生成程式碼,我們只需將其標記為 "extern"。
        // 一旦C#得到適當的函數指標支援(計劃在C#9中),就不需要這個了。
        [System.Runtime.CompilerServices.Intrinsic]
        private static extern T Call<T>(System.IntPtr pfn);
    }

    // 這個資料結構是與編譯器的契約。它持有一個靜態
    // 建構函式的地址,以及一個指定該建構函式是否已經執行的標誌。
    [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
    public struct StaticClassConstructionContext
    {
        // 指向靜態類構造方法程式碼的指標。這是由
        // 繫結器/執行時。
        public IntPtr cctorMethodAddress;

        // 該類的初始化狀態。這被初始化為0
        // 時,執行時都會呼叫類庫的CheckStaticClassConstruction,並使用這個上下文。
        //結構,除非初始化==1。這個檢查是特定的,以允許類庫為每一個Cctor儲存更多的
        // 比二進位制狀態更多,如果它想這樣做的話。
        public int initialized;
    }
}

讓我們用這些新新增的程式碼重建IL位元組碼,並重新執行ILC。

csc.exe /debug /O /noconfig /nostdlib /runtimemetadataversion:v4.0.30319 MiniRuntime.cs MiniBCL.cs Game\FrameBuffer.cs Game\Random.cs Game\Game.cs Game\Snake.cs Pal\Thread.Windows.cs Pal\Environment.Windows.cs Pal\Console.Windows.cs /out:zerosnake.ilexe /langversion:latest /unsafeilc.exe zerosnake.ilexe -o zerosnake.obj --systemmodule zerosnake --Os -g

現在我們有了zerosnake.obj - 一個標準的物件檔案,與其他本地編譯器(如C或C++)產生的物件檔案沒有區別。最後一步是連線它。我們將使用link.exe工具,它應該在我們的 "x64本地工具命令提示字元 "的PATH中(你可能需要在Visual Studio中安裝C/C++開發工具)。

link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main

__managed__Main符號名稱是與編譯器的契約 - 它是ILC建立的程式的託管入口的名稱。

但它並沒有發揮作用:

error LNK2001: unresolved external symbol RhpPInvoke
error LNK2001: unresolved external symbol SetConsoleTextAttribute
error LNK2001: unresolved external symbol WriteConsoleW
error LNK2001: unresolved external symbol GetStdHandle
...
fatal error LNK1120: 17 unresolved externals

其中一些符號看起來很熟悉 - 連結器不知道在哪裡尋找我們呼叫的Windows API。讓我們來新增這些的匯入庫:

link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main kernel32.lib ucrt.lib

這看起來更好 - 只有4個未解決的符號:

error LNK2001: unresolved external symbol RhpPInvoke
error LNK2001: unresolved external symbol RhpPInvokeReturn
error LNK2001: unresolved external symbol RhpReversePInvoke2
error LNK2001: unresolved external symbol RhpReversePInvokeReturn2
fatal error LNK1120: 4 unresolved externals

其餘缺失的符號是編譯器希望在執行時庫中找到的輔助工具。它們的缺失只有在連結時才會被發現,因為這些輔助工具通常是在組合中實現的,而且編譯器只用它們的符號名稱來指代它們(而不是我們上面提供的其他編譯器需要的型別和方法)。

當本機程式碼呼叫到受控程式碼,以及受控程式碼呼叫到本機程式碼時,這些幫助程式會建立和拆除堆疊框架。這對於GC的執行是必要的。由於我們沒有GC,讓我們用一段C#和另一個編譯器能理解的神奇屬性來存根它們。

namespace System.Runtime
{
    // 編譯器理解的自定義屬性,指示它
    // 在給定的符號名稱下匯出方法。
    internal sealed class RuntimeExportAttribute : Attribute
    {
        public RuntimeExportAttribute(string entry) { }
    }
}

namespace Internal.Runtime.CompilerHelpers
{
    class StartupCodeHelpers
    {
        // 這些方法的包含型別並不重要。
        // 讓我們把它們放在StarupCodeHelpers中。
        
        [System.Runtime.RuntimeExport("RhpReversePInvoke2")]
        static void RhpReversePInvoke2(System.IntPtr frame) { }
        [System.Runtime.RuntimeExport("RhpReversePInvokeReturn2")]
        static void RhpReversePInvokeReturn2(System.IntPtr frame) { }
        [System.Runtime.RuntimeExport("RhpPInvoke")]
        static void RhpPinvoke(System.IntPtr frame) { }
        [System.Runtime.RuntimeExport("RhpPInvokeReturn")]
        static void RhpPinvokeReturn(System.IntPtr frame) { }
    }
}

在用這些修改重建C#原始碼並重新執行ILC後,連結終於會成功。

我們現在已經只有27KB,而且遊戲還能正常執行!

擾亂連結器

剩餘的幾千位元組可以通過使用本地開發者用來縮小其本地應用程式的技巧來削減。

我們要做的是

  • 禁用增量連結
  • 剝離重定位資訊
  • 合併可執行檔案中的類似部分
  • 將可執行檔案中的內部對齊設定為一個小值
link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main kernel32.lib ucrt.lib /merge:.modules=.rdata /merge:.pdata=.rdata /incremental:no /DYNAMICBASE:NO /filealign:16 /align:16

成功! 最後只有8176位元組,不到8KB !

遊戲仍然可以執行,有趣的是,它仍然是完全可偵錯的 - 請在Visual Studio中開啟EXE(檔案->開啟解決方案),開啟作為遊戲一部分的一個C#檔案,在其中設定一個斷點,點選F5啟動EXE,並看到斷點被擊中。你可以在ILC中禁用優化,使可執行檔案更容易被偵錯 - 只要放棄--Os引數。

我們可以把它編譯得更小嗎?

可執行檔案仍然攜帶著一些並非必要的資料 - ILC編譯器只是沒有提供命令列選項來禁止其生成。

其中一個被生成但我們不需要的資料結構是各個方法的GC資訊。CoreRT有一個精確的垃圾收集器,它要求每個方法描述GC堆的參照在方法主體的每個指令中的位置。由於我們在Snake遊戲中沒有垃圾收集器,這些資料是不必要的。其他執行時(例如Mono)使用保守的垃圾收集器,不需要這些資料(它只是假設堆疊和CPU暫存器的任何部分都可能是GC參照)- 保守的垃圾收集器以GC效能換取額外的大小節省。CoreRT中使用的精確的垃圾收集器也可以在保守模式下執行,但它還沒有被連線起來。這是一個潛在的未來補充,我們可以利用它來使程式編譯得更小。

也許有一天,我們可以使我們的遊戲的簡化版本適合於512位元組的啟動磁區。在那之前,祝你駭客行動愉快.

.NET效能優化交流群

相信大家在開發中經常會遇到一些效能問題,苦於沒有有效的工具去發現效能瓶頸,或者是發現瓶頸以後不知道該如何優化。之前一直有讀者朋友詢問有沒有技術交流群,但是由於各種原因一直都沒建立,現在很高興的在這裡宣佈,我建立了一個專門交流.NET效能優化經驗的群組,主題包括但不限於:

  • 如何找到.NET效能瓶頸,如使用APM、dotnet tools等工具
  • .NET框架底層原理的實現,如垃圾回收器、JIT等等
  • 如何編寫高效能的.NET程式碼,哪些地方存在效能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET效能問題和寶貴的效能分析優化經驗。目前一群已滿,現在開放二群,可以直接掃碼進入。

如果提示已經達到200人,可以加我微信,我拉你進群: ls1075
微信長按下圖即可加群

image-20230107220326809

另外也建立了QQ群,群號: 687779078,歡迎大家加入。

image-20230107220536830