引擎之旅 Chapter.4 日誌系統

關於近段時間為何沒有更新的解釋: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