Compiling a program on a UNIX or UNIX-like system is often as simple as running
cc main.c
and having your executable ready to go. Things are not so simple on
Windows. When it comes to compilation and running your code, Windows follows
the mantra of make simple things difficult and make hard things possible. In
this post, I will attempt to save you and my future self much head-scratching
over seemingly simple tasks.
Hello World
The first program we want to write is a simple “hello world”:
#include <stdio.h>
int main(void)
{
puts("Hello, world!");
return 0;
}
Now if you save this file as main.c
on your Desktop and try to run cc
main.c
, you will be greeted with this message:
'cc' is not recognized as an internal or external command,
operable program or batch file.
This is understandable since we are on Windows and not a UNIX OS. The first
step you’ll need to do is download Build Tools for Visual Studio
20xx. Open the installer,
select C++ build tools and hit Install. Despite the name, these tools also
support C, specifically C99 at the time of writing. The typical way of
compiling programs on Windows involves using the full version of Visual Studio
and doing everything there, but this guide will be focused on using the
terminal exclusively. If you already have an existing version of Visual Studio
installed, you do not need to install the Build Tools and can simply replace
the references to BuildTools
in the environment variables below with the
version you have, e.g., Community
.
Once you’ve done that, the next thing you’ll want to do is set up some environment variables to use these tools. Hit the Windows key and search for “environment variables” and you’ll find an option mentioning them. You’ll want to edit the User variables and not the System variables.
For simplicity, the rest of this post will assume the latest version of Visual Studio (currently 2022) and a 64-bit version of Windows 10. The first environment variable to be set is the location of our new installation:
Variable | Value |
---|---|
VSInstallDir |
C:\Program Files\Microsoft Visual Studio\2022\BuildTools\ |
Then, add %VSInstallDir%VC\Auxiliary\Build
to your PATH
variable.
This is sufficient for us to compile our “hello world” program. Close your
existing terminal window and open a new one for the PATH
to refresh, and cd
to where you stored your main.c
file. Now, run the following:
vcvars64
cl main.c
main
The first line sets the environment up for compiling a 64-bit program (use
vcvars32
for 32-bit), and adds where cl.exe
is to your path. The next line
compiles the program, creating an executable with the same name as the source
file (main.exe
) and the third line runs it.
We could stop here and simply call vcvars64
once before using the compiler.
However, that’s not very convenient. You might have also noticed that
vcvars64
isn’t exactly the fastest program to run. Furthermore, if you try to
call vcvars64
a few times in one terminal session, say 5 or more times you’ll
eventually see this lovely message:
The input line is too long.
The syntax of the command is incorrect.
This is because vcvars64
isn’t the smartest program either. It keeps adding
the same paths to the PATH
environment variable every time you call it until
it exceeds the terminal limit. Run set path
to see for yourself.
Beyond vcvars
To avoid waiting for vcvars64
to finish whatever it’s taking several seconds
to do, we can set up our environment beforehand to bypass it completely. First,
add the following environment variable:
Variable | Value |
---|---|
VCToolsInstallDir |
C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\xx.xx.xxxxx\ |
Replace the xx.xx.xxxxx
with the current version of the compiler which you
can get by running dir /b "%VSInstallDir%VC\Tools\MSVC"
. You might need to
update this environment variable after updating Visual Studio.
Next, add the following environment variables:
Variable | Value |
---|---|
WindowsSDKDir |
C:\Program Files (x86)\Windows Kits\10\ |
WindowsSDKVersion |
xx.x.xxxxx.x\ |
Replace the xx.x.xxxxx.x
with the latest version of the Windows SDK, which
you can get by running dir /b /o-d "%WindowsSDKDir%Lib"
and selecting the top
entry. You might also need to update this environment variable when you update
Windows.
So far, we have only added convenience variables that we will be making use of now. Add the following environment variables:
Variable | Value |
---|---|
INCLUDE |
%VCToolsInstallDir%include;%WindowsSDKDir%Include\%WindowsSDKVersion%shared;%WindowsSDKDir%Include\%WindowsSDKVersion%ucrt;%WindowsSDKDir%Include\%WindowsSDKVersion%um |
LIB |
%VCToolsInstallDir%lib\x64;%WindowsSDKDir%Lib\%WindowsSDKVersion%ucrt\x64;%WindowsSDKDir%Lib\%WindowsSDKVersion%um\x64 |
This will add the necessary header files (in our case, stdio.h
) and library
files (in our case, the C library libcmt.lib
) to our search paths. Add
%VCToolsInstallDir%bin\Hostx64\x64
to your PATH
variable (this will give us
cl.exe
).
Now, close your terminal window and open a new one. Go back to your project
folder and run cl main.c
again. You’ll see that it now compiles successfully
— no vcvars
needed.
What are those folders
Name | Description |
---|---|
ucrt |
Universal C Runtime Library |
um |
User mode APIs (contain the bulk of the Windows SDK) |
shared |
Shared components |
Incorporating the Windows API
Now that we’ve written our program, we can start to incorporate some Windows API features. Let’s say we want to add a “tada” sound every time the program is run, instead of a “hello world” message. We’d also like to hide the console window when the program runs, such as when double-clicking it from Explorer. This is what the new program looks like:
#include <windows.h>
int main(void)
{
PlaySound("C:\\Windows\\Media\\tada.wav", NULL, SND_FILENAME);
return 0;
}
If you try to compile this program now, you will get this message:
main.obj : error LNK2019: unresolved external symbol __imp_PlaySoundA referenced in function main
main.exe : fatal error LNK1120: 1 unresolved externals
As you can guess by the __imp_PlaySoundA
, it’s having trouble with the
PlaySound
function. In order to use a Windows API function, we need to link
the correct library. To know which one to link, you can look up the
function
on the internet. At the top of the function’s page on the Microsoft Docs
website, you’ll find the function declaration, which can be deciphered using a
helpful
table.
Towards the end of the page you’ll find the name of the library required. In
this case, it’s winmm.lib
. Try to compile again, this time running cl main.c
winmm.lib
. No more linker error! Let’s also change the name of the output file
using the /Fe
flag. Run cl /Fe:tada main.c winmm.lib
. Now, say the magic
word - tada
!
Hiding the console window
To get rid of the console window, we have to compile our program with the
/subsystem:windows
flag passed to the linker. This is done by adding the
linker flags at the end of the compile command, following the /link
flag. The
full command will be cl /Fe:tada main.c winmm.lib /link /subsystem:windows
.
You might be disappointed to stumble upon yet another error message:
LIBCMT.lib(exe_winmain.obj) : error LNK2019: unresolved external symbol WinMain referenced in function "int __cdecl __scrt_common_main_seh(void)" (?__scrt_common_main_seh@@YAHXZ)
tada.exe : fatal error LNK1120: 1 unresolved externals
What’s happening here is that since we decided to link with
/subsystem:windows
instead of the default mode (/subsystem:console
), the
linker is now expecting our main
function to be called WinMain
. Now we
could
change our main
function to WinMain
with the correct parameters, but what
if we want to keep things simple, especially since we won’t be using any of
those parameters. The answer lies in the /entry
linker flag. Despite our
program seemingly having a main
entry function, the true entry point to
console programs is a hidden function known as mainCRTStartup
which calls
main
. In the case of non-console applications, the entry function is
WinMainCRTStartup
which calls WinMain
. To get back the old behavior while
still hiding the console window, add /entry:mainCRTStartup
to the linker
flags. Our current compilation command will be:
cl /Fe:tada main.c winmm.lib /link /entry:mainCRTStartup /subsystem:windows
Now when we open tada
by double-clicking it, we won’t see an ugly console
window.
Dealing with Unicode
Instead of only playing a single sound, it would be nice to be able to tell the program which sound to play and fallback to a default sound if we don’t. This can be done like so:
#include <windows.h>
int main(int argc, char *argv[])
{
const char *default_sound = "C:\\Windows\\Media\\tada.wav";
const char *sound = default_sound;
if (argc > 1)
sound = argv[1];
PlaySound(sound, NULL, SND_FILENAME | SND_NODEFAULT);
return 0;
}
We can test it by calling the program with a different sound:
tada "C:\Windows\Media\notify.wav"
Now let’s copy C:\Windows\Media\notify.wav
to the same place as tada
and
rename it to something like صوت.wav
. If you try to run tada صوت.wav
, you’re
probably not going to hear anything. This is because our program, as of now,
does not support Unicode. To support Unicode we have to do a couple of changes:
#include <tchar.h>
#include <windows.h>
int _tmain(int argc, _TCHAR *argv[])
{
const _TCHAR *default_sound = _T("C:\\Windows\\Media\\tada.wav");
const _TCHAR *sound = default_sound;
if (argc > 1)
sound = argv[1];
PlaySound(sound, NULL, SND_FILENAME | SND_NODEFAULT);
return 0;
}
We’ve made four changes here:
- Include
tchar.h
- Change
main
to_tmain
- Change
char
to_TCHAR
- Wrap string literals in
_T()
What this aims to do is to allow us to switch between 8-bit ANSI characters and 16-bit Unicode characters at will. Other programs might require additional changes. To complete the transformation, we will modify our compile command to the following:
cl /Fe:tada /D_UNICODE /DUNICODE main.c winmm.lib /link /entry:wmainCRTStartup /subsystem:windows
We added two preprocessor definitions - _UNICODE
and UNICODE
. _UNICODE
is
used by tchar.h
to transform _tmain
into wmain
instead of main
,
_TCHAR
into wchar_t
instead of char
, and _T
into L
instead of a
no-op. UNICODE
transforms all Windows API functions (in this case
PlaySound
) into their Unicode variants (PlaySoundW
) instead of the default
ANSI variant (PlaySoundA
).
We also changed the /entry
flag from mainCRTStartup
to its Unicode sibling
wmainCRTStartup
.
Unicode on Windows 10 - UTF-8 edition
If you’ve already written a program that uses char *
everywhere, changing it
to support Unicode can be a lot of work. Luckily, in recent versions of Windows
10, there’s a new beta option that extends the ANSI functions with UTF-8
support, since ANSI is a subset of UTF-8 encoding. To enable it, go to the
language settings, click Administrative language
settings, then Change system locale… and tick the Beta: Use Unicode UTF-8
for worldwide language support option. Once you restart your computer, change
the program back to the char *
version then compile and run it with the same
test file. I recommend you use the new Windows
Terminal for
this. If all goes well, you’ll be experiencing the full fruits of Unicode
without any extra effort!
Using a Makefile
Now that we have our program up and running with full Unicode support, we’d
like to automate the build so it’s as simple as running make
. Well, as simple
as running nmake
, the Windows flavor of it. The first thing we’ll need to do
is create a new file named Makefile
. This file will contain our desired
output (tada.exe
), our input files (main.c
and winmm.lib
) and a recipe
for how to generate the former from the latter (using cl.exe
). We’ll be using
the tchar
version of our program. This is how it looks:
tada.exe: main.c
cl /Fe:tada /D_UNICODE /DUNICODE main.c winmm.lib /link /entry:wmainCRTStartup /subsystem:windows
Now, simply run nmake
. You might get a message that tada.exe
is up-to-date
as nmake
is smart enough to realize that there haven’t been any edits to
main.c
since we compiled it. You can force the program to build by running
nmake /a
. You can make the Makefile a little neater by using a backslash for
line continuation:
tada.exe: main.c
cl /Fe:tada /D_UNICODE /DUNICODE main.c winmm.lib /link \
/entry:wmainCRTStartup /subsystem:windows
Also, make sure the file is indented with tabs and not spaces.
A cross-platform program
We can modify our program to compile on non-Windows platforms by adjusting it slightly. Instead of playing a sound, the program will only print a message on those platforms. The new program looks like this:
#include <stdio.h>
#ifdef _WIN32
#include <windows.h>
#endif
int main(int argc, char *argv[])
{
#ifdef _WIN32
const char *default_sound = "C:\\Windows\\Media\\tada.wav";
const char *sound = default_sound;
if (argc > 1)
sound = argv[1];
PlaySound(sound, NULL, SND_FILENAME | SND_NODEFAULT);
#endif
puts("Tada!");
return 0;
}
The _WIN32
macro can be used to detect if a program is being compiled on
Windows. Ideally, you’d replace any use of the Windows API with a corresponding
API on other platforms by checking similar platform-specific macros.
A cross-platform Makefile
We can also create a cross-platform Makefile for our cross-platform program.
This is done by creating an additional Windows-specific makefile called
tools.ini
that will be automatically read by nmake
:
[NMAKE]
EXEEXT=.exe
LDLIBS=winmm.lib
LDFLAGS=/link /entry:mainCRTStartup /subsystem:windows
And the main Makefile:
tada$(EXEEXT): main.c
$(CC) -o $@ $(CPPFLAGS) $(CFLAGS) main.c $(LDLIBS) $(LDFLAGS)
$@
in the rule refers to the output, in this case tada$(EXEEXT)
.
If you have WSL installed, try using nmake && tada
in Windows and then make
&& ./tada
in WSL - both should work just fine. For more complex builds, using
the same Makefile with make
and nmake
might require additional
tweaks.
To compile in release mode on Windows, do nmake CPPFLAGS=/DNDEBUG CFLAGS="/O2
/GL"
. The NDEBUG
macro turns off asserts, /O2
enables optimization and
/GL
enables whole program optimization which includes link-time optimization.
Adding third-party libraries
Getting vcpkg
To use third-party libraries, we can use vcpkg. You’ll need to have git installed for this.
Run the following to install vcpkg in your preferred directory (the home directory is a good choice):
git clone https://github.com/microsoft/vcpkg.git
cd vcpkg
bootstrap-vcpkg
Once it’s done, run echo %cd% | clip
in the same directory to copy the path
of vcpkg. Set the VCPKG_ROOT
environment variable to the copied value. Then,
set the VCPKG_DEFAULT_TRIPLET
environment variable to x64-windows
. This
will ensure that vcpkg downloads 64-bit libraries by default. Add
%VCPKG_ROOT%
to your PATH
- this will give us access to the vcpkg tool.
Next, add the include directory of vcpkg to the INCLUDE
environment variable
— this will be %VCPKG_ROOT%\installed\%VCPKG_DEFAULT_TRIPLET%\include
.
Make sure to separate it from existing entries by using a semicolon. Do the
same for the lib directory, adding it to the LIB
environment variable —
%VCPKG_ROOT%\installed\%VCPKG_DEFAULT_TRIPLET%\lib
. This will allow the
compiler to find the header and library files.
Close any terminal windows you have open for the changes to take effect, and open a new one.
Installing a library
In our example, we’re going to be using the popular SDL2 library. We’ll need to install it and copy the required DLLs over. To do so, run the following:
vcpkg install sdl2
vcpkg export sdl2 --raw --output=export
copy "%VCPKG_ROOT%\export\installed\%VCPKG_DEFAULT_TRIPLET%\bin\*.dll" .
Now we’re finally ready to use the library in our program. Instead of calling
the Windows-specific PlaySound
, we’re going to use SDL2’s WAV playing
capabilities. Modify the main.c
file to the following:
#include <stdio.h>
#define SDL_MAIN_HANDLED
#include <SDL2/SDL.h>
int main(int argc, char *argv[])
{
SDL_SetMainReady();
const char *sound = NULL;
#ifdef _WIN32
sound = "C:\\Windows\\Media\\tada.wav";
#endif
if (argc > 1)
sound = argv[1];
if (sound) {
puts("Tada!");
/* Make sure to check for errors in a real program */
SDL_Init(SDL_INIT_AUDIO);
SDL_AudioSpec spec;
Uint8 *buffer;
Uint32 length;
SDL_LoadWAV(sound, &spec, &buffer, &length);
SDL_AudioDeviceID id =
SDL_OpenAudioDevice(NULL, 0, &spec, NULL, 0);
SDL_QueueAudio(id, buffer, length);
SDL_PauseAudioDevice(id, 0);
while (SDL_GetQueuedAudioSize(id))
;
SDL_CloseAudioDevice(id);
SDL_FreeWAV(buffer);
SDL_Quit();
}
return 0;
}
Then, modify the Makefile
to this:
tada$(EXEEXT): main.c
$(CC) -o $@ $(CPPFLAGS) $(CFLAGS) main.c $(LDLIBS) $(LDFLAGS)
LDLIBS=-lSDL2
This adds the SDL2 library for POSIX/UNIX machines. LDLIBS
is specified after
the make rule so that it can be overridden on Windows. Modify tools.ini
to
the following, adding SDL2.lib
to LDLIBS
:
[NMAKE]
EXEEXT=.exe
LDLIBS=SDL2.lib
LDFLAGS=/link /entry:mainCRTStartup /subsystem:windows
Now, run nmake
and then tada
- you should hear the tada sound! You can
build and run the same code on a UNIX machine (you can build it in WSL too, but
sound won’t work when you run the program).
Using CMake
For more complex projects, it might be beneficial to use CMake. CMake lets you
generate Makefiles, Visual Studio project files, and myriad
others
that can be used to build your project on different platforms. Conveniently,
Visual Studio comes with CMake and should have been installed when you
installed the C++ build tools. If not, use the Visual Studio installer to add
it. To use CMake, add
%VSInstallDir%Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin
to your
PATH
.
To build the project with CMake, create a CMakeLists.txt
file in the project
directory with the following:
cmake_minimum_required(VERSION 3.13)
project(tada)
find_package(SDL2 REQUIRED)
add_executable(tada main.c)
target_link_libraries(tada SDL2::SDL2)
if(MSVC)
target_link_options(tada PUBLIC /entry:mainCRTStartup /subsystem:windows)
endif()
The first two lines specify the minimum supported CMake version and the name of
the project. The find_package
command adds the SDL2 library to the project.
The add_executable
command specifies the name of the output executable
(tada
) and its prerequisite files. Finally, the target_link_libraries
command specifies the libraries that will be linked to generate the given
target, in this case the executable tada
. You can get the names to use for
the find_package
and target_link_libraries
commands from vcpkg - it prints
them after installing a package. The last part checks if the compiler is the
Visual Studio one by checking the MSVC
variable and then appends the same
linker flags as before.
Now, create a directory called build
in the project folder and cd
into it.
We will run CMake in this directory as it generates a lot of files that would
otherwise mess up the main folder. Then, run cmake
-DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%\scripts\buildsystems\vcpkg.cmake ..
to
generate the project files that will be used to build the project. The
CMAKE_TOOLCHAIN_FILE
option helps CMake find the libraries installed by
vcpkg. Finally, run cmake --build .
to build the project. Once it’s done, you
should find the program in the Debug
folder, and can run it via Debug/tada
.
To build a release version, add -DCMAKE_BUILD_TYPE=Release
to the first
command and --config Release
to the second.