引擎之旅 Chapter.4 紀錄檔系統

2022-10-05 12:00:11

關於近段時間為何沒有更新的解釋:Find a new job.

引言

一般來說,一個優質的商業級別的遊戲本質上就是一個複雜龐大的軟體系統。在龐大系統的開發過程中難免會出現錯誤。為了排查錯誤、校驗程式碼的正確性,遊戲引擎一般會提供一些偵錯和開發工具,一般有如下幾個:

  • 紀錄檔及程式碼追蹤:紀錄檔系統一般提供向控制檯等頁面列印字串的功能;在列印中也能夠清晰的顯示呼叫的堆疊資訊,以便於定位程式碼錯誤的位置。
  • 偵錯繪圖功能:引擎會提供在遊戲場景中繪製輔助線的功能,這些輔助線能清晰的表示範圍、方向等資訊以供遊戲開發者進行偵錯。
  • 內建選單:遊戲編輯器的一些全域性設定,通過不同的設定,方便遊戲開發者對特定渲染、邏輯等進行偵錯。
  • 內建控制檯:對於遊戲引擎來說,專案為非控制檯程式,因此我們無法用簡單的使用printf方法將紀錄檔輸出至控制檯。內建控制檯就是遊戲編輯器中收集和顯示紀錄檔的表單。
  • 效能剖析與統計:方便遊戲開發者定位效能瓶頸(一個重要的模組)

當然,僅僅這一章節無法去完成對這些偵錯工具的闡述。本文中的紀錄檔系統主要實現了紀錄檔及程式碼堆疊資訊的輸出功能(上述的第一點),其他部分的內容後續在將其慢慢的完善。本章中的紀錄檔系統主要實現一下幾點功能:

  • 紀錄檔語句可分類,且不同的分類有相關顏色的提示。
  • 紀錄檔可列印到控制檯表單、Vistual Studio輸出框。
  • 紀錄檔可儲存至特定的紀錄檔檔案中。
  • 紀錄檔語句可展示相關的堆疊資訊。

顯示效果如下:

  • 不顯示相關堆疊資訊
  • 顯示相關堆疊資訊

紀錄檔語句的分類

將紀錄檔語句分類可以讓開發者列印不同重要性的Log。比如Unity編輯器中的Console將紀錄檔語句分為了:Log、Warn、Error三個部分。在TurboEngine的設計中,我將紀錄檔分類寫為一個列舉類,並將不同的型別在二進位制不同的位中岔開,方便篩選。

//紀錄檔語句重要性等級
enum LogImportantLevel : int
{
	CodeTrace = 0b00001,   //最低階,用於記錄程式碼執行軌跡(白)
	Info = 0b00010,        //常規,顯示紀錄檔訊息(綠)
	Warn = 0b00100,        //較高階,用於紀錄檔警告資訊(警告)
	Error = 0b01000,       //高階,用於紀錄檔錯誤資訊(錯誤)
	Critiacal = 0b10000,   //最高階,用於關鍵紀錄檔資訊(關鍵資訊)
};

控制檯表單 和 VSOutput Tab的紀錄檔列印

這一部分很簡單。將紀錄檔列印到Console和VS Output主要使用以下兩個函數

//to Console
printf(const char* format,...);

//to VS Output
OutputDebugStringA(const char* lpOutputString);

我一般喜歡將特定的功能封裝在自己的函數中,一方面可以作為將函數用自己的命名形式統一命名方便呼叫。另一方面,我們需要對原生函數進行功能上的拓展。OutputDebugStringA 是一個列印字串的函數,我們要將其封裝為OutputDebugStringA(const char* format,...)的形式。

//In TEString.h
//VS函數,將字串列印到Visual Studio 輸出臺(分寬字元和常規字元版本)
//--------------------------------------------------------------------------------------------------
inline void TVSOutputDebugString(PCWSTR format, ...)
{
	char* pArgs = (char*)format + sizeof(format);
	_vstprintf_s(TurboCore::GetCommonStrBufferW(), TurboCore::CommonStringBufferSize, format, pArgs);
	::OutputDebugString(TurboCore::GetCommonStrBufferW());
}

inline void TVSOutputDebugString(PCSTR format, ...)
{
	char* pArgs = (char*)format + sizeof(format);
	vsnprintf(TurboCore::GetCommonStrBuffer(), TurboCore::CommonStringBufferSize, format, pArgs);
	::OutputDebugStringA(TurboCore::GetCommonStrBuffer());
}

//對printf()函數的重新命名
//--------------------------------------------------------------------------------------------------
inline void TConsoleDebugString(PCSTR format, ...)
{
	char* pArgs = (char*)format + sizeof(format);
	printf(format, pArgs);
}

vsnprintf(char* buffer,size_t bufferSize,const char* format,...) :用於將變數格式化為字串。

儲存至特定的檔案中

在Chapter3的檔案系統中,我們利用了C語言的檔案流函數封裝了檔案的讀寫功能。在紀錄檔中,我們要利用這一個封裝類將紀錄檔寫入檔案中。

相關連結:引擎之旅 Chapter.3 檔案系統

實現的思路如下:

  • 在紀錄檔類別建構函式中開啟一個檔案(若沒有相關的資料夾則需要建立相關的資料夾)
  • 當呼叫紀錄檔列印時,需要同時將字串寫入檔案流中。
  • 在解構函式中將檔案關閉
class TURBO_CORE_API TLogger
{
    //紀錄檔模式:
    enum class LogFileMode
    {
    	DiskFile,        //紀錄檔將儲存在磁碟中
    	TempFile         //紀錄檔將以臨時檔案的形式儲存(不常用)
    };
    
    TLogger(PCSTR loggerName, LoggerBuffer::BufferSize bufferSize = LOGGER_BUFFER_DEFAULT_SIZE, int logLevelFilter = 0b11111);
    TLogger(PCSTR loggerName,PCSTR logFileSavePath,LoggerBuffer::BufferSize bufferSizeLOGGER_BUFFER_DEFAULT_SIZE,int logLevelFilter = 0b11111);
    
    //注:紀錄檔檔案不應該支援拷貝函數
    TLogger(const TLogger& clone) = delete;
    ~TLogger();
    
    //輸入紀錄檔到各個平臺:(Console、VSOutputTab、檔案流)
    inline void InputLogToAll(PCSTR str);
    inline void InputLogToAll(CHAR c);
}


//Implement
TurboEngine::Core::TLogger::TLogger(PCSTR loggerName, PCSTR logFileSavePath, LoggerBuffer::BufferSize bufferSize, int logLevelFilter)
	:m_LogFileMode(LogFileMode::DiskFile),
	 m_LogBuffer(bufferSize),
	 m_LogLevelFilter(logLevelFilter),
	 m_IsShowCallstack(true)
{
    CHAR dirPath[MAX_PATH_LEN];
    //從檔案路徑中獲取檔案所在的資料夾
    TAssert(TPath::GetDirectoryName(dirPath, MAX_PATH_LEN, logFileSavePath));
    
    //判斷資料夾目錄是否存在,若不存在則建立
    if (!TDirectory::Exists(dirPath))
    	TDirectory::CreateDir(dirPath);
    
    //開啟檔案流
    TAssert(m_LogFile.Open(logFileSavePath, TFile::FileMode::Text, TFile::FileAccess::ReadWrite_CreateAndClean));
    
    //記錄紀錄檔的名稱和檔案路徑
    TStrCpy(m_LoggerName, LOGGER_NAME_MAX_LENGTH, loggerName);
    TStrCpy(m_LoggerPath, MAX_PATH_LEN, logFileSavePath);
}

//輸入字串
inline void TurboEngine::Core::TLogger::InputLogToAll(PCSTR str)
{
    m_LogFile.PutStringtLine(str);
    TConsoleDebugString(str);
    TVSOutputDebugString(str);
}

//輸入字元
inline void TurboEngine::Core::TLogger::InputLogToAll(CHAR c)
{
    m_LogFile.PutChar(c);
    TConsoleDebugString(&c);
    TVSOutputDebugString(&c);
}

展示堆疊資訊

我覺得這是一個可以單獨作為一個章節進行闡述,但是紀錄檔系統確實也涉及了這一部分的功能,因此,我把也把它寫入到本章節中。堆疊資訊在遊戲或遊戲引擎開發是一個十分重要的資訊,這個資訊可以清晰的展現了當前你列印的這一部分的具體函數呼叫路徑。
關於如何獲取到堆疊資訊,之後有時間我可以另起一章對這一部分內容進行分析。基本的類結構如下所示:

class TURBO_CORE_API TStackWalker
{
public:
	TStackWalker();
	TStackWalker(DWORD threadId);
	TStackWalker(DWORD threadId, PCSTR symPath);
	~TStackWalker();
}

public:
	inline bool IsInitialized();

    //獲取堆疊呼叫入口陣列
    bool GetStackFrameEntryAddressAddrArray(DWORD64 outFrameEntryAddress[STACK_MAX_RECORD]);

    //獲取堆疊資訊字串
    void GetCallstackFramesString(PSTR output, size_t outputBufLen, int getNum, int offset);

    //列印堆疊呼叫資訊
    void PrintCallstackFramesLog(DWORD64 frames[STACK_MAX_RECORD]);

    //列印單個棧幀資訊
    void PrintSingleCallbackFrameMessage(const CallstackEntry& entry, bool bShowInCosole = false);

protected:
    static BOOL _stdcall MyReadProcMem(HANDLE hProcess, DWORD64 qwBaseAddress, PVOID lpBuffer, DWORD nSize, LPDWORD lpNumberOfBytesRead);
    //初始化入口
    void Init();
    //獲取和初始化符號
    bool InitSymbols();
    //載入所以模組
    bool LoadModules();

    //初始化單個路徑的符號
    bool InitSymbol(PCSTR symPath);
    //載入單個模組
    DWORD LoadModule(HANDLE hProcess, LPCSTR img, LPCSTR mod, DWORD64 baseAddr, DWORD size);

關於如何實現,具體可去網上搜尋鍵碼 StackWalker