Tutorial - C++ Runtime Code Reload
Quick prototyping and fast iteration times are incredibly important concepts for modern game development. Being able to hot swap an asset while the game is up and running can make the difference between a succesfull or a mediocre game simply because artists and designers can spend more time improving the game rather then waiting for loading/building/conditioning. The same benefit can be extended to code production by allowing to reload the source code while the application is still running and in this tutorial we will see how.
From an implementation point of view the only way to load code dynamically into a running application is via dynamic linking, and in Windows this means using DLLs. DLLs can be loaded into your program in two different ways, an implicit way, which is really similar to how you would link a normal lib file, or an explicit way. This second option is the key to make the runtime code reload component that we will see in this tutorial.
From an architectural point of view there are a few ways to make the code aware of the newly provided functions without having to change it. One way is to hijack the virtual table mechanism to make your function call jump at the new code when this is loaded, but this solution would require some level of symbol extraction (to locate the virtual table) and would also restrict an important language feature (polymorphism).
Another way, which is the one I have decided to present in this tutorial, is to use C++ macros to wrap our method and function definitions and expand the macro in different ways depending whether we are compiling the code to reload, or compiling the code that uses the reloadable module, or even building the release version of both which we probably don't want to have any code reload support at all.
The overall idea will be to have the reloadable code compiled into a DLL, and the static code, including our c++ runtime reload module, compiled into the executable. When we trigger the code reload the runtime reload module will recompile the DLL, unload the current one and link the new one in. We will also organize the code so that all the functions get patched behind the scenes and the new memory locations are injected in place of the old ones. The build process will be implemented using MSBuild. The Visual Studio solution will have three projects, one for the Runtime Code Reload component, one for the reloadable code (the DLL) and one that is the UnitTest or the target application. The Runtime Code Reload will compile to a LIB, so in a more realistic case you would only have the target application and the DLL while linking against the RCR component.
Let's start from seeing how to compile the DLL and how to use explicit linking to load it in.
DLL and explicit linking
The key mechanism that we will use is the explicit linking of the DLL. This means that the executable we will build will actually miss some of the function calls and we will need to provide the code for those functions before being able to invoke them. To load a DLL from disk we will use the Windows' function LoadLibrary that, if succesfull, will return a HINSTANCE* that allows us to comunicate with the DLL. The other important function that we'll use is GetProcAddress which takes a symbol name and a DLL HINSTANCE and returns a memory address. So if we want to find where the function foobar is we just call GetProcAddress(hDLL, "foobar") and we will receive the address of that function. If we have succesfully received an address all we need to do is to cast it to a function pointer and we are finally able to call it. The annoying thing of all this process is that we are forced to use function/method pointers to invoke our code, and this is not good enough for what we want. We need to create a system that hides as much as possible all this mechanism. To do so we will use a few macros tricks.
These macros expand differently depending what defines are set:
#ifdef RCR_DLL_EXPORTS
// MACROS EXPANSION FOR THE DLL - These are the same whether RCR is enabled or not
#else // RCR_DLL_EXPORTS
#ifdef RCR_ENABLED
// MACROS EXPANSION FOR THE EXECUTABLE - When RCR is enabled
#else // RCR_ENABLED
// MACROS EXPANSION FOR THE EXECUTABLE - When RCR is disabled, for example in RELEASE
#endif // RCR_ENABLED
#endif // RCR_DLL_EXPORTS
As a first step we create a new Visual Studio project which in the provided code is named UnitTest_RCR_DLL. The target output of this project must be a dll and the compiler should define RCR_DLL_EXPORTS. The project will contain a dllmain.cpp for loading and unloading the resources when the DLL is linked, and two files (header and source) called UnitTest_RCR_DLL. The header file for UnitTest_RCR_DLL will be shared with the static/non reloadable executable, so this is where we use the polymorphic macros. To define a function we use the following code:
RCR_FUNCTION(int, testFunction1, (int a, float b), (a,b));
Which in this project will expand to:
int testFunction1(int a, float b);
The RCR_FUNCTION macro is defined to behave differently whether we are building the DLL or the executable; for the DLL it will expand as follows:
#ifdef RCR_DLL_EXPORTS
#define RCR_FUNCTION( returnType, name, args, vals ) __declspec(dllexport) returnType name args
#else // RCR_DLL_EXPORTS
...
Pretty simple, all we are doing is decorating the function with the export flag and then defining the function. In the CPP equivalent we have the function itself decorated with the RCR_API macro:
RCR_API int testFunction1(int a, float b)
{
return (int)(a + b);
}
The RCR_API macro simply expands to __declspec(dllexport) if we are building the DLL.
This is all we need on the DLL side. With these few lines we have declared a function, exposed it to the DLL's interface, and provided some implementation. Please note that the provided code has two additional files called UnitTest_RCR_DLL_1.cpp and UnitTest_RCR_DLL_2.cpp which get copied onto UnitTest_RCR_DLL.cpp during the UnitTest execution; obviously this is to demostrate the code reload feature!
User Code - The unit test
In our simple case the user code will be the actual unit test. There is only one relevant file stored into the UnitTest project and it's called UnitTest.cpp. This file is represnet the user code, so this is where the RCR_ENABLED flag will be defined if the code is in Debug, and not defined in Release. Release will also need to link to the DLL in the project proprieties since the whole reload mechanism won't be there. Let's see the code.
#include <tchar.h>
#include <iostream>
#include "../UnitTest_RCR_DLL/UnitTest_RCR_DLL.h"
#ifdef RCR_ENABLED
#define RCR_DLL_NAME UnitTestRCRDLL
#include "../RuntimeCodeReload/RuntimeCodeReloader.h"
RCR_DEFINE_EXTERNALS
#endif
#define UNIT_TEST_ASSERT(a, mex) if(!(a)){ std::cout << "[!]\t"<< mex << " failed." << std::endl; testPassed = false; }else{ std::cout << "\t" << mex << " passed." << std::endl; }
int _tmain(int, _TCHAR*[])
The #ifdef RCR_ENABLED code is used to define the DLL name for the Runtime Code Reloader. This is because that name is hardwired into some of the variables so it needs to be defined just before the include. The other important line is the RCR_DEFINE_EXTERNALS macro. This will expand into two variables, an HINSTANCE for the DLL and a std::map called RCR_SYMBOL_MAP:
// From RCR.h
#define RCR_DLL_HISTANCE RCR_CONCAT(g_hGetProcIDDLL_,RCR_DLL_NAME)
#define RCR_SYMBOL_MAP RCR_CONCAT(g_symbolMap_,RCR_DLL_NAME)
extern HINSTANCE RCR_DLL_HISTANCE;
extern std::map<std::string, std::string> RCR_SYMBOL_MAP;
#define RCR_DEFINE_EXTERNALS HINSTANCE RCR_DLL_HISTANCE; std::map<std::string, std::string> RCR_SYMBOL_MAP;
These two global variables are used to access into the DLL. Now let's take a look at the main function:
int _tmain(int, _TCHAR*[])
{
using namespace UnitTest_RCR;
bool testPassed = true;
std::cout << "[RCR Unit Test]" << std::endl;
#ifdef RCR_ENABLED
auto attributes = GetFileAttributesA("..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp");
SetFileAttributesA("..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp", FILE_ATTRIBUTE_NORMAL);
CopyFileA("..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL_1.cpp", "..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp", false);
SetFileAttributesA("..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp", attributes);
std::cout << "\tCompiling DLL..." << std::endl;
std::vector<std::string> configurations;
configurations.push_back("Debug");
// !! ------------------------------------------------------------------------------------------------------------------------
// YOU WILL NEED TO REPLACE THE <<vcvarsall>> PATH TO POINT TO THE CORRECT FOLDER ON YOUR PC
// !! ------------------------------------------------------------------------------------------------------------------------
RCR::RuntimeCodeReloader codeReloader(&RCR_DLL_HISTANCE, &RCR_SYMBOL_MAP, "C:\\Program Files (x86)\\Microsoft Visual Studio 10.0\\VC\\vcvarsall.bat","..\\RuntimeCodeReload.sln", "..\\bin\\UnitTest_RCR_DLL_d.dll", "C:\\temp\\UnitTest_RCR_DLL_d\\", "UnitTest_RCR_DLL", configurations );
std::cout << "\tDone..." << std::endl << std::endl;
#endif
TemplateStruct<float> paramTemplateStruct;
paramTemplateStruct.value = 2.0f;
UNIT_TEST_ASSERT( testFunction1(10, 8.0f) == 80, "testFunction1 code test ");
UNIT_TEST_ASSERT( testFunction2(¶mTemplateStruct) == 4.0f, "testFunction2 code test ");
GET_RCR_VARIABLE_PTR(size_t, UnitTest_RCR:: , g_globalValue);
UNIT_TEST_ASSERT( *g_globalValue == 90, "g_globalValue default test ");
testFunction3( 23 );
UNIT_TEST_ASSERT( *g_globalValue == 23, "g_globalValue change test ");
UNIT_TEST_ASSERT( testFunction4("AB") == 'A', "testFunction4 code test ");
{
TestClass testObj(12,30);
UNIT_TEST_ASSERT( testObj.test(5) == 12 + 30 * 5, "object method invocation test ");
}
#ifdef RCR_ENABLED
std::cout << std::endl << "\tModifing source code..." << std::endl;
attributes = GetFileAttributesA("..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp");
SetFileAttributesA("..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp", FILE_ATTRIBUTE_NORMAL);
CopyFileA("..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL_2.cpp", "..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp", false);
SetFileAttributesA("..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp", attributes);
std::cout << "\tRecompiling DLL..." << std::endl;
codeReloader.reload(RCR::RuntimeCodeReloader::eCleanCompileAndReload);
std::cout << "\tDone..." << std::endl << std::endl;
UNIT_TEST_ASSERT( testFunction1(10, 8.0f) == 18, "testFunction1 code test ");
UNIT_TEST_ASSERT( testFunction2(¶mTemplateStruct) == 1.0f, "testFunction2 code test ");
UNIT_TEST_ASSERT( *g_globalValue == 13, "g_globalValue default test ");
testFunction3( 20 );
UNIT_TEST_ASSERT( *g_globalValue == 10, "g_globalValue change test ");
UNIT_TEST_ASSERT( testFunction4("AB") == 'B', "testFunction4 code test ");
{
TestClass testObj(12,30);
UNIT_TEST_ASSERT( testObj.test(5) == 12 + 30 / 5, "object method invocation test ");
}
CopyFileA("..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL_1.cpp", "..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp", false);
#endif
if(testPassed)
std::cout << "[PASSED]" << std::endl;
else
std::cout << "[FAILED]" << std::endl;
return 0;
}
Take your time to read through the code; the highlighted bits are where the code reload features kick in. The CopyA call is to simulate that the file "..\\UnitTest_RCR_DLL\\UnitTest_RCR_DLL.cpp" has changed, in the real case you probably want to have a directory watcher on your source code folder, and when a file changes you can call the reload function.
This is all you need to use the library. If you are interested in finding out how it works behind the scene, then keep on reading.
Runtime loading of the DLL
The Runtime Code Reload component is the library that offers the reload functionality and it's composed of two main header files: RuntimeCodeReloader.h and RCR.h. The first file offers a class that can be used by the user's source code to trigger a reload, and its interface looks like this:
class RuntimeCodeReloader
{
public:
enum Flag
{
eCleanCompileAndReload,
eCleanCompileAndNoReload,
eCompileAndReload,
eCompileNoReload,
eNoCompileAndReload,
};
// Ctor/Dtor
// ------------------------------------------------------------------------
RuntimeCodeReloader(HINSTANCE* hDLLInstance, std::map<std::string, std::string>* dllSymbolMap, const char* vcvarsallPath, const char* solutionFullPath, const char* dllFullPath, const char* dllTemporaryDir, const char* projectName, std::vector<std::string> &configurations);
~RuntimeCodeReloader();
// Methods
// ------------------------------------------------------------------------
bool reload(Flag flag = eCompileAndReload);
...
In the user code we will construct one of these objects providing the relevant parameters. The first two are globally defined in the RCR.h file and we'll see them in a moment, while the rest of the parameters are all paths to different places that we need. VcvarsallPath is the path to the vcvarsall.bat batch file in the Visual Studio directory, something like "C:\\Program Files (x86)\\Microsoft Visual Studio 10.0\\VC\\vcvarsall.bat". SolutionFullPath and dllFullPath in the tutorial's layout are "..\\RuntimeCodeReload.sln" and "..\\bin\\UnitTest_RCR_DLL_d.dll". dllTemporaryDir is where our compiled output is going to be stored during the reload, and it must be an absolute path because it's used by two different processes during the reload and the relative path would be different for each. Project name is the Visual Studio project name for the DLL project in our solution ("UnitTest_RCR_DLL") and configurations is a vector of strings that specify which configurations to build (in our case "Debug" and nothing else).
The other function exposed is called reload and is the function to call to trigger the code reload. The input flag should be pretty self explanatory. The CPP code of this is the following:
bool RuntimeCodeReloader::reload(Flag flag)
{
bool needCompiling = flag == eCleanCompileAndReload || flag == eCleanCompileAndNoReload || flag == eCompileAndReload || flag == eCompileNoReload;
bool needReloading = flag == eCleanCompileAndReload || flag == eCompileAndReload || flag == eNoCompileAndReload;
if(needCompiling)
{
if( !compileDLL(flag) )
MessageBoxA(0, "Failed to compile the required DLL; check that all the paths are correct and retry.", "Runtime Code Reload - ASSERT", MB_ICONERROR);
}
if(needReloading)
{
// Release the DLL
if(m_hGetProcIDDLL)
{
FreeLibrary(*m_hGetProcIDDLL);
*m_hGetProcIDDLL = 0;
}
// The new DLL is created in a temporary folder; retrieve the DLL and copy it to the right place
bool result = CopyFile( (std::string(m_dllTemporaryDir) + '\\' + getFilename(m_dllFullPath) ).c_str(), m_dllFullPath, false) == TRUE;
if(!result)
{
char auxString[256];
sprintf_s(auxString, "Failed to copy the DLL from %s to %s", (std::string(m_dllTemporaryDir) + '\\' + getFilename(m_dllFullPath) ).c_str(), m_dllFullPath);
MessageBoxA(0, auxString, "Runtime Code Reload - ASSERT", MB_ICONERROR);
}
// Copy all the files from the directory; this will copy the DLL again, useless but won't fix for now
CopyAllFiles(m_dllTemporaryDir, getDirectories(m_dllFullPath).c_str());
DeleteAllFiles(m_dllTemporaryDir);
RemoveDirectory(m_dllTemporaryDir);
// Even if we filed we try to load it, we may just have failed to compile due to code issues but the old dll is actually there
*m_hGetProcIDDLL = LoadLibraryA(m_dllFullPath);
if (!*m_hGetProcIDDLL)
{
RCR_ASSERT(false, "Failed to load the required DLL; check that the DLL's path is correct and retry.");
return false;
}
*m_dllSymbolMap = createDLLExportedSymbolsConversionTable(m_dllFullPath);
}
return true;
}
If the flag requires the DLL to be compiled, then compileDLL is called. If reloading is needed then the code release the current DLL, copies the new one from the temporary folder to the path we specified during construction. If the copy failes an error is popped, otherwise the whole folder is copied across; this is mainly to take in the PDB file and avoid losing symbols. Once the DLL is in place we call LoadLibrary to link it in. The last function call createDLLExportedSymbolsConversionTable is used by the code to create a map between the DLL internal naming convention and the more C++ like naming convention we are used to. The reason for this is that when we will try to call a function we'll need to search for it into the DLL, so we will need to adhere to the DLL naming convention, but the name we will provide is going to C++ like, so we need a map between the two.
The compileDLL function is quite simple, so I am not going to cover it here. The more tricky bit that may be difficult to unravel is the RCR.h file. This is where all the magic happens and being heavily macro based it's hard to read. Let's analyze how it works for a simple function. The RCR_FUNCTION macro that we have seen before expands differently in the user code since it will need to search the function into the DLL and then call it.
#define RCR_FUNCTION( returnType, name, args, vals )\
static returnType name args { \
typedef returnType (*name##_type) args; \
GET_RCR_FUNCTION(func, name##_type, RCR_findSymbol(RCR_MAKE_STRING(returnType), RCR_CONCAT(RCR_CURRENT_NAMESPACE_SEQUENCE,RCR_MAKE_STRING(name)),RCR_MAKE_STRING(args))); \
return func##vals;\
}
The macro defines a static function, then a typedef that is a pointer to that function type and then it creates a function pointer of that function type called func. Eventually it invoke func with the correct arguments and return the value.
Of this macro the important bit is the GET_RCR_FUNCTION macro, which expands as follow:
#define GET_RCR_FUNCTION(functionName, functionType, sym) functionType functionName = (functionType)GetProcAddress(RCR_DLL_HISTANCE, sym);
So, given a dll symbol name and the RCR_DLL_HINSTANCE the code (finally!) calls the GetProcAddress to find where the function lives in memory. The symbol sym is in the DLL mangled name convetion which we obtain from the previous macro from:
.. RCR_findSymbol(RCR_MAKE_STRING(returnType), RCR_CONCAT(RCR_CURRENT_NAMESPACE_SEQUENCE,RCR_MAKE_STRING(name)),RCR_MAKE_STRING(args)) ..
RCR_findSymbol is a function that looks up into the map and transform the name extracted from the macros to the DLL mangled equivalent. RCR_CURRENT_NAMESPACE_SEQUENCE is a nasty hack I couldn't find any way around; to match the name we need the namespace we are in, but there is no way to get the namespace name from the function definition, so the user will need to define this macro every time it modifies the namespace sequence. This can be seen in the UnitTest_RCR_DLL file:
namespace UnitTest_RCR
{
#undef RCR_CURRENT_NAMESPACE_SEQUENCE
#define RCR_CURRENT_NAMESPACE_SEQUENCE "UnitTest_RCR::"
...
RCR_FUNCTION(int, testFunction1, (int a, float b), (a,b));
Ugly but necessary. The code will create our function symbol string, which in the testFunction1 case would be "int UnitTest_RCR::testFunction1(int,float)", then run it through RCR_findSymbol to get the DLL equivalent ("?testFunction1@UnitTest_RCR@@YAHHM@Z").
Variables and methods follow the same principle, so feel free to check the RCR.h code file to see the details.