Debug vs Release - v2

Four and half a year ago (how time flies), I wrote a post about the potential issues mixing debug and release CRT in the same program.

At the end, it says, 'It's fortunate that this is a linker error. Otherwise, you'll waste lots of time in debugging to find out the subtle incompatibility'. Today, in v2, I'd like to share another example which I encountered in real-world code that demonstrates the subtle incompatibility.

Here are the two source files, which are used to generate a dll and an exe:

dll.cpp
__declspec(dllexport) void useenv();

#include <stdio.h>
#include <stdlib.h>

void useenv()
{
    wchar_t *value;
    errno_t err = _wdupenv_s(&value, nullptr, L"ENV");
    if (err == 0)
    {
        wprintf(L"ENV = %s\n", value);
        free(value);
    }
}

exe.cpp
#include <stdlib.h>

__declspec(dllimport) void useenv();

void setenv(const wchar_t *value)
{
    _wputenv_s(L"ENV", value);
}

int main()
{
    setenv(L"1");
    useenv();
    setenv(L"2");
    useenv();
}

Build the program using the following command line:

cl dll.cpp /MDd /LD /nologo
cl exe.cpp /MD /nologo /link dll.lib

What output would you expect? If everything goes as expected, it should output '1' and '2' on two lines.

However, if you try the example above, the actual output are two '1's. Unlike linker errors, this kind of runtime issues is much harder to find and the fix normally involves changes to the build system / project files which are normally non-trivial.

Now, let's take a closer look at the example.

The magic is in the compiler options. The dll is compiled with /MDd (debug CRT) and the exe is compiled with /MD (release CRT). So 'setenv' calls '_wputenv_s' from release CRT, and 'useenv' calls '_wdupenv_s' from debug CRT,

'Why does this matter?', you may ask. This is because CRT maintains many global states internally. Debug CRT and release CRT both have their own copies, changes to one don't affect the other.

In this case, CRT maintains a cache to the wide string version of the environment variables and the cache is created when you access the environment variable for the first time. The complete process can be illustrated as follows:

  • The first 'setenv' calls OS API to set the environment variables of the process and they are also cached in the release CRT.
  • The first 'useenv' tries to read the environment variables and they are not cached in the debug CRT yet. So it reads them from OS and creates the cache.
  • The second 'setenv' calls OS API and also updates the cached environment variables in the release CRT.
  • The second 'useenv' reads the environment variables directly from the cache in the debug CRT which are not changed.

So the bug only appears when you call 'useenv' the second time because the cache is created but not correctly updated (I'm not sure whether the behavior of CRT is by-design, but apparently it assumes one CRT in the binary and no direct call to SetEnvironmentString).

It is much more subtle than always fail. Actually it took me quite a while to have a small example from the original code. The first few attempts always output '(null)' instead and you can easily know that sometime goes wrong from that :-)

This is just one of thousands of possibilities how mixing debug and release CRT (or any components compiled with different options) can mess up things. The best practice is to make sure everything is compiled with the same compiler options, but this is not always possible (for example, some libraries are from third party). Then you'd better be very cautious about mixing them in the same binary. Move the incompatible part to a separate dll with an abstract layer (so that there are no assumptions across binary boundary) may help.