What happens when you build a .NET app? What happens the instant you run it? The last time I studied this was when .NET Framework was in its infancy. That was nearly 20 years ago! Things have changed in those two decades: apps are now cross-platform; .NET has lost its "Framework" moniker; and I've lost my hair!
This post looks at:
- what's in a modern .NET 5 (C#) project
- what's generated when you build
- what happens when you run it
We won't go into a massive amount of detail, but it'll help if you know roughly what MSBuild is and does, and know a little of the tooling around .NET.
The project used throughout is a C# console app targetting .NET 5. It is unmodified from what you get when you do
File / New Project / Console from Visual Studio. It just writes Hello World to the console:
static void Main(string args)
A modern .NET project #
If you do
File / New / C# Console (.NET 5) in Visual Studio 2019, you'll get this project file:
You can do the same with
dotnet new console from a
command prompt Windows Terminal.
If you're following along and you end up with a newer
TargetFramework, then that just means you have a newer SDK installed and hence are running a newer
dotnet. This doesn't matter - just be aware that when you see
5.x in this post, it'll be something different for you. In fact, you'll see in this post that there are references to .NET 6. This is because I have a preview of .NET 6 installed and some commands use the latest installed SDK (as explained later on).
The project file above is called an SDK Style project. These are a relatively new format and are much smaller compared with the old project files back in the .NET Framework days. Creating the same console app file for say, .NET Framework 4.7.2, results in a
.csproj file weighing in at 53 lines, as can be seen below (it's intentionally small as you don't need to read it, just know there's a lot of it!)
There's a lot more stuff in the old style project than the new style project. Things like
FileAlignment etc. MSBuild still needs these things, and we'll discover their whereabouts shortly.
The biggest space saver in new SDK Style projects is down to the fact that you no longer need to specify what's included, you just need to specify what should not be included.
The very first line of an SDK style project, has this:
This means that the project references a Project SDK by the name of
Microsoft.NET.Sdk . A project SDK is a set of MSBuild targets and tasks that compile/pack/publish the code.
Microsoft.NET.Sdk is the base project SDK. There are other project SDKs, such as
Microsoft.NET.Sdk.Razor. They all reference the base project SDK.
One interesting point to note is that during the build, MSBuild adds implicit imports at the top and bottom of your
<!-- Implicit top import -->
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<!-- Implicit bottom import -->
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
SDKs and related files can be found at
If we look at the contents of the
Microsoft.NET.Sdk folder, we see these subfolders:
In those subfolders, you'll find files used during the build.
So, going back to the
.csproj file, we explicitly reference
Microsoft.NET.Sdk, and MSBuild implicitly references
These files are here:
Microsoft.NET.Sdk.props, and if we take a peek at that file, we discover some of the things that are no longer present in the SDK-style project that were in the full-fat Framework project, for example:
There are also references to other
If we look in the
DefaultItems.props file, we discover how MSBuild gets to know what to compile in our project:
The screenshot of the full-fat Framework project earlier was deliberately small to show the size compared to new projects, but in that file there were references to Framework assemblies:
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
So, where are these references in the new world of SDK-style projects?
To find out, I built the app (using verbose output) and looked at what was produced. I didn't look directly at the build output as that hurts your eyes! Instead, I used the excellent open source MSBuild Structured Log Viewer. This is a much much more pleasant way of viewing MSBuild output.
Here it is showing one of the build tasks that were run as part of the build. It finds Framework references:
We can see the task is passed a set of
KnownFrameworkReferences, including the one for .NET 5 (①). Notice that there's others, e.g. .NET Core 3.1 (②)
That task returns the following:
The term packs is used. So, what are 'packs'? From the documentation, a pack is:
a collection of files used by the build. A pack can either be deployed globally alongside dotnet or wrapped in a NuGet package
There are a few different types of packs:
- targeting packs - reference assemblies, docs, etc.
- runtime packs - runtime assets for self-contained publish
- app host packs - template to generate a native 'app host' executable (more on 'app hosts' in the next section)
🤓 Interesting fact
Packs are bundled with the SDK that you download. They are said to be 'globally installed'. You can build a framework-dependent app offline using the globally installed packs. However, you can also target packs that are not installed. When you do this, the SDK uses NuGet to download them. More info here.
Let's take a peek into the packs folder at
We can see it has reference dependencies (
.Ref - for building), and real dependencies (runtime) for Windows x64 etc.
Going back to the build output above, MSBuild decided to use the packs directory at
Let's take a look in that folder:
We can see that reference assemblies will be used from the
ref folder for our chosen target of net5.0.
Our console app only does one thing: it writes Hello World to the console. It references
System.Console. We can use ILSpy to look at
System.Console in the
Above, we performed a lot of manual steps and jumped around a fair few files. To speed things up, we can view an aggregated MSBuild project file containing all of the files necessary for a build by running with the
preprocessparameter in MSBuild:
dotnet msbuild consoleapp1.csproj -preprocess:output.xml
The resulting file contains everything MSBuild uses to build the app and it weighs in at over 11,000 lines of XML! But it's nice to see it all in one place and be able to search through it for anything interesting.
So, what happens to the reference assemblies? Are they copied to the output folder? Is it the 'runtime' that decides which ones to use?
Next, we'll explore what happens when we run the app.
Running the app #
If you're following along, take a look in the
bin\debug\net5.0 folder. You'll see that there's a
ConsoleApp1.exe and a
You'll also see that the reference assemblies aren't copied to the output folder. Why would they, after all, they contain no useful functionality and exist just for the purpose of building. The main app (
ConsoleApp1.exe) doesn't reference them. But...
ConsoleApp1.dll does reference them, as can be seen in ILSpy:
So, why is there an
.exe and a
.dll? In the old days, when you built a .NET Framework exe, you just got the
.exe, as can be seen here with a .NET Framework 4.6.1 console app:
But in the new world, this has changed. The exe is called an app host; it's a native framework-dependent executable, and is built by default in .NET Core 3.0 and later.
🤓 Interesting fact on how .NET Framework assemblies are loaded (from CLR via C#)
After Windows has examined the EXE file’s header to determine whether to create a 32-bit process, a 64-bit process, or a WoW64 process, Windows loads the x86, x64, or IA64 version of MSCorEE.dll into the process’s address space. … Then, the process’ primary thread calls a method defined inside MSCorEE.dll. This method initializes the CLR, loads the EXE assembly, and then calls its entry point method (Main). At this point, the managed application is up and running.
So we know the exe (the app host) is a native app and that the dll is a managed app (we can run it in Windows and Linux using
dotnet consoleapp1.dll), but how does the app host know how to load our managed dll?
I found this great post from Matt Warren that describes the process in detail. I'll provide a quick summary below...
When you type
dotnet run the app host is loaded. The app host will figure out what runtime and SDK to use, and will load the appropriate runtime binaries into memory.
We can view more information into what's going on here by turning on some host flags. This causes the host to write detailed output when it's run:
SET COREHOST_TRACE=1 COREHOST_TRACEFILE=out.txt dotnet run
This will write the output to
out.txt. Load that file in your editor and carefully study each of the 6,050 lines! Alternatively, here's an overview:
- THE first thing that happens is that it resolves which runtime to use:
Reading fx resolver directory=[C:\Program Files\dotnet\host\fxr] Considering fxr version=[2.2.8]... ... Considering fxr version=[6.0.0-preview.5.21301.5]... Detected latest fxr version=[C:\Program Files\dotnet\host\fxr\6.0.0-preview.5.21301.5]
To paraphrase from this page: the fx resolver contains the framework resolution logic used by the host (
dotnet). It selects the appropriate runtime. The host will use the latest installed hostfxr
- IT then resolves which SDK to use - here's a snippet of the output:
--- Resolving .NET SDK with working dir [C:\git\scratch\dotnet-anatomy-post\new-console-with-vs\ConsoleApp1] Probing path [C:\git\scratch\dotnet-anatomy-post\new-console-with-vs\ConsoleApp1\global.json] for global.json Probing path [C:\git\scratch\dotnet-anatomy-post\new-console-with-vs\global.json] for global.json Probing path [C:\git\scratch\dotnet-anatomy-post\global.json] for global.json ... Probing path [C:\global.json] for global.json Terminating global.json search at [C:\] Resolving SDKs with version = 'latest', rollForward = 'latestMajor', allowPrerelease = true Multilevel lookup is true Searching for SDK versions in [C:\Program Files\dotnet\sdk] Version [2.2.207] is a better match than [none] ... Version [6.0.100-preview.5.21302.13] is a better match than [6.0.100-preview.5.21228.17] Ignoring invalid version [NuGetFallbackFolder] SDK path resolved to [C:\Program Files\dotnet\sdk\6.0.100-preview.5.21302.13] Using .NET SDK dll=[C:\Program Files\dotnet\sdk\6.0.100-preview.5.21302.13\dotnet.dll]
I replaced some of the repetitive and similar lines with
... - but we can still see from the output that it searches for a
global.json file and then resolves the latest runtime to use (or alternatively, the specific version you have in
So far, we've built the app, have run it, and have seen how the runtime version is resolved.
Let's modify the app slightly so that we can see which SDK the dependencies are loaded from:
static void Main(string args)
Console.WriteLine("Linux - " + Environment.OSVersion.Version);
Console.WriteLine("Windows - " + Environment.OSVersion.Version);
Console.WriteLine("List of assemblies loaded in current appdomain:");
foreach (Assembly assem in AppDomain.CurrentDomain.GetAssemblies())
Console.WriteLine(assem.ToString() + " " + assem.Location);
Running it on Windows (with
dotnet run), we see:
Hello We're on Windows! Version 10.0.22000.0 List of assemblies loaded in current appdomain: System.Private.CoreLib, Version=188.8.131.52, Culture=neutral, PublicKeyToken=7cec85d7bea7798e C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Private.CoreLib.dll ConsoleApp1, Version=184.108.40.206, Culture=neutral, C:\git\scratch\dotnet-anatomy-post\new-console-with-vs\ConsoleApp1\bin\Debug\net5.0\ConsoleApp1.dll System.Runtime, Version=220.127.116.11, Culture=neutral, C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Runtime.dll System.Console, Version=18.104.22.168, Culture=neutral, C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Console.dll System.Runtime.InteropServices.RuntimeInformation, Version=22.214.171.124, Culture=neutral, C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Runtime.InteropServices.RuntimeInformation.dll System.Threading, Version=126.96.36.199, Culture=neutral, C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Threading.dll System.Text.Encoding.Extensions, Version=188.8.131.52, Culture=neutral, C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Text.Encoding.Extensions.dll
Fire up WSL and and run it again on Linux:
cd /mnt/c/[the path where you compiled it to] dotnet run
You'll see something similar to this:
steve@STEVEDESKTOP:/mnt/c/git/scratch/dotnet-anatomy-post/new-console-with-vs/ConsoleApp1$ dotnet run Hello steve We're on Linux! Version 184.108.40.206 List of assemblies loaded in current appdomain: System.Private.CoreLib, Version=220.127.116.11, Culture=neutral, PublicKeyToken=7cec85d7bea7798e /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Private.CoreLib.dll ConsoleApp1, Version=18.104.22.168, Culture=neutral, /mnt/c/git/scratch/dotnet-anatomy-post/new-console-with-vs/ConsoleApp1/bin/Debug/net5.0/ConsoleApp1.dll System.Runtime, Version=22.214.171.124, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Runtime.dll System.Console, Version=126.96.36.199, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Console.dll System.Runtime.InteropServices.RuntimeInformation, Version=188.8.131.52, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Runtime.InteropServices.RuntimeInformation.dll System.Threading, Version=184.108.40.206, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Threading.dll System.Text.Encoding.Extensions, Version=220.127.116.11, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Text.Encoding.Extensions.dll Microsoft.Win32.Primitives, Version=18.104.22.168, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/Microsoft.Win32.Primitives.dll System.Collections, Version=22.214.171.124, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Collections.dll
We can see the main difference is the locations where the files are loaded from. On Windows, they're loaded from
C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\. On Ubuntu, they're loaded from
💡 TIP: The
/usr/folder on WSL can be browsed (via Windows File Explorer) at "
What have we seen? #
- how MSBuild finds all of the required information when using the new slimline (SDK Style) projects
- what the app host is and how starting up a modern .NET application differs from a .NET Framework application
- how the runtime and SDKs are resolved; the runtime is generally the newest one available, and the SDK is generally the one you specify in the
Hopefully this post has been interesting and useful. There's still tons to explore though; things like stand-alone publishing etc.
Thank you for reading and please feel free to use the comments below and/or share via Twitter etc.dotnet msbuild
Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated 💖! For feedback, please ping me on Twitter.
Leave a comment
Comments are moderated, so there may be a short delays before you see it.