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:
using System;
namespace new_console_with_dotnet_new
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
A modern .NET project #
If you do File / New / C# Console (.NET 5)
in Visual Studio 2019, you'll get this project file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
</Project>
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 RootNamespace
, AssemblyName
, 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:
<Project Sdk="Microsoft.NET.Sdk">
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.Web
and 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 .csproj
file:
<Project>
<!-- Implicit top import -->
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
...
<!-- Implicit bottom import -->
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
</Project>
SDKs and related files can be found at %ProgramFiles%\dotnet\sdk\[version]\Sdks\Microsoft.NET.Sdk\Sdks
:
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 Sdk.props
and Sdk.targets
These files are here:
Sdk.Props
references 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 props
files:
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:
<ItemGroup>
<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" />
</ItemGroup>
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 C:\Program Files\dotnet\packs
:
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 C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\5.0.0
.
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 ref
folder:
💡 TIP:
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 thepreprocess
parameter 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 ConsoleApp1.dll
.
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 global.json
file).
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:
using System;
using System.Reflection;
using System.Runtime.InteropServices;
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine($"Hello {System.Environment.GetEnvironmentVariable("USER")}");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Console.WriteLine("Linux - " + Environment.OSVersion.Version);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
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=5.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Private.CoreLib.dll ConsoleApp1, Version=1.0.0.0, Culture=neutral, C:\git\scratch\dotnet-anatomy-post\new-console-with-vs\ConsoleApp1\bin\Debug\net5.0\ConsoleApp1.dll System.Runtime, Version=5.0.0.0, Culture=neutral, C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Runtime.dll System.Console, Version=5.0.0.0, Culture=neutral, C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Console.dll System.Runtime.InteropServices.RuntimeInformation, Version=5.0.0.0, Culture=neutral, C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Runtime.InteropServices.RuntimeInformation.dll System.Threading, Version=5.0.0.0, Culture=neutral, C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Threading.dll System.Text.Encoding.Extensions, Version=5.0.0.0, 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 4.19.104.0 List of assemblies loaded in current appdomain: System.Private.CoreLib, Version=5.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Private.CoreLib.dll ConsoleApp1, Version=1.0.0.0, Culture=neutral, /mnt/c/git/scratch/dotnet-anatomy-post/new-console-with-vs/ConsoleApp1/bin/Debug/net5.0/ConsoleApp1.dll System.Runtime, Version=5.0.0.0, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Runtime.dll System.Console, Version=5.0.0.0, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Console.dll System.Runtime.InteropServices.RuntimeInformation, Version=5.0.0.0, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Runtime.InteropServices.RuntimeInformation.dll System.Threading, Version=5.0.0.0, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Threading.dll System.Text.Encoding.Extensions, Version=5.0.0.0, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Text.Encoding.Extensions.dll Microsoft.Win32.Primitives, Version=5.0.0.0, Culture=neutral, /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/Microsoft.Win32.Primitives.dll System.Collections, Version=5.0.0.0, 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 /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/
💡 TIP: The
/usr/
folder on WSL can be browsed (via Windows File Explorer) at "\\wsl.localhost\Ubuntu\usr\
", e.g.\\wsl.localhost\Ubuntu\usr\share\dotnet\shared\Microsoft.NETCore.App\5.0.7
What have we seen? #
We've 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
.csproj
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 Bluesky! 🦋
Leave a comment
Comments are moderated, so there may be a short delays before you see it.
Published
9 comments on this page
Dick Baker
useful as background, but happily not something I need to stress over daily.
Having lotsa SDKs & runtimes on my laptop, I would love to know how to purge all the old stuff safely (maybe creating equivalent forward pointers to appropriate latest version). Thanks!
Sean
Awesome article -- thank you for sharing your research into this!
Saurabh
It was a very informative article. Thank you for taking the effort to write this post for us. Cheers.
Andreas Gullberg Larsen
Well written and interesting details, it definitely became less black magic for me now 😀
Tim
Fantastic. Thanks for taking the time to walk through such a fundamental but often overlooked part of our life as developers.
Jan Vratislav
Great article!
Pankaj
Excellent post!!
Sharp Ninja
Great job. You gave more insight into MSBuild than any article I've seen since its release in Visual Studio 2005.
Bartłomiej Rosa
Wow, good article! It's good to know what's under the hood. Thanks :)