Last time, we discussed a strategy for handling a reference to an un-managed resource in the event that you want to run your managed software in both 32-bit and 64-bit modes. As commenter TheAnimus over at Reddit pointed out in response to our article, the approach has some shortcomings. Our approach last week was based on selecting the appropriate (x86 or x64) un-managed DLL at compile time, which requires us to create two separate platform-specific builds of our software. This approach makes life difficult down the road, as it requires us to maintain separate x86 and x84 platform configurations for any managed software that depends on that un-managed DLL. It would be much better if we could continue to build our managed apps in “Any CPU” mode and handle the complexity of referencing the correct un-managed DLL at runtime. Luckily for us, there is a better way, and our friend from Reddit pointed us in the right direction.
Let’s go back to our demo application from last week. In our previous approach, we created two distributions of our application (one for each platform), and only distributed one copy of the un-managed resource (“Unmanaged.dll” in our example) with each. In the new approach, we will create a single distribution of our application, and distribute two copies of the un-managed resource (one for each platform), and we will decide which one to load at runtime.
To switch from two distributions to one, we must first eliminate the explicit x86 and x64 configurations we created last week. Go to the Configuration Manager (right-click the solution and select “Configuration Manager…”) and add back the “Any CPU” platform configuration to the project (this will add an “Any CPU” configuration to the solution), and then delete the “x86” and “x64” configurations from both the project and the solution. Next, remove the single link to “Unmanaged.dll” included in the project (you may also want to remove those extra PropertyGroups we added to the csproj file manually, as the GUI won’t delete those for us). This gets us roughly back to square one. Confirm that the application no longer works by cleaning out the output directory, rebuilding, and running. You should get a “DllNotFoundException” because “Unmanaged.dll” is no longer in the output directory.
To implement the new approach, start by creating two folders inside the “UnmanagedDemo” project — called “x86” and “x64”. Next, add the corresponding version of “Unmanaged.dll” as a link in each folder. Right click the folder, select “Add existing item…”, select the appropriate DLL, then select “Add as link” (remember not to use “Add”, as that creates a copy). On each DLL’s “Properties” screen, set “Build Action” to “Content” and “Copy to Output Directory” to “Copy always”. When you build the project, you should now see the “x86” and “x64” directories in the output folder, each containing “Unmanaged.dll”.
Of course, the application still will not work, because it doesn’t know to search those subdirectories for “Unmanaged.dll”. We get the same “DllNotFoundException” when running. To fix this, we need have our process explicitly load the correct version of the DLL. The basic strategy we’ll use was suggested by our commenter — we move the call into our un-managed DLL out to a separate static class, then explicitly load the correct version of “Unmanaged.dll” in the new class’ static constructor, thereby ensuring that the correct version of the DLL is loaded before we make the call to the un-managed function. In our example, we’ll start by moving the definition of “GetPtrSize” out to a separate static class:
Next, we introduce a couple of helper methods we will need to explicitly load up our library. We will place these in a separate static class, so that they can be reused elsewhere. First, we need a P/Invoke wrapper for LoadLibraryEx, which is the kernel32 function we need to call to explicitly load a DLL into our process. Luckily, our friends over at PInvoke.net have one ready to go for us:
Our next helper method takes the name of the DLL we wish to load (the same string you pass to DllImport) and loads the correct platform-specific version, assuming the 32-bit version is in an “x86” subdirectory and the 64-bit version is in an “x64” subdirectory:
We used two very useful built-in functions there that deserve some side commentary. Environment.Is64BitProcess is pretty self-explanatory (it is nice to have an explicit property for this, as opposed to having to rely on IntPtr.Size). AppDomain.BaseDirectory is the directory containing the executable — this is the directory you should use when you need to create an absolute path relative to your executable. Environment.CurrentDirectory is the current working directory, which can change out from under you and is certainly not guaranteed to be the directory containing the executable (though it often is, which can be misleading). Assembly.Location and Assembly.CodeBase are also not reliable for this purpose — CodeBase is not even guaranteed to be set, and Location will change if “shadow copying” is being used, which is common with unit test runners.
With our utility methods are in place, all we need to do is create a static constructor in our UnmanagedHelper class that loads up our un-managed assembly:
When we run our application, it is now working again:
Based on the output, it is clear that our application is running in 32-bit mode. This is happening because “Prefer 32-bit” is set, as we discussed last time. Switching it off flips us to “classic” Any CPU mode, and causes the application to run in 64-bit mode on my 64-bit box:
This is an improvement over last week’s approach, because we no longer have to manage two separate configurations and two separate distributions of our software to support both platforms. However, this approach requires us to copy the two versions of the un-managed assembly around. This works fine when we are referencing our managed wrapper via a project reference, because Visual Studio takes care of copying the “content” files around for us in that case, but it makes things hard on dependent applications if we distribute the wrapper as a binary library. Next time, we will explore a method to get around that difficulty.
UPDATE: Make sure to check out part three of this series.