Author: Dr. Foad S. Farimani
Socials: LinkedIn | X / Twitter | Stack Overflow
This project demonstrates a "canonical" approach to Manual Cross-Compilation in .NET. It uses a single, monolithic C# source file (Universal.cs) and a robust CLI helper script (Build.cmd) to compile executable binaries targeting completely different runtimes using nothing but the raw csc.exe compilers found on a standard Windows machine.
It proves that modern C# code does not require MSBuild or .csproj files to run; it simply requires the correct Reference Assemblies and Compiler Flags.
- Clone the repository
git clone https://github.com/MinimalWindowsDev/Universal-CSharp - Run
Build.cmd - Select a target environment:
| Option | Compiler | Target | Execution Method |
|---|---|---|---|
| [1] | .NET SDK 10.0.101 Roslyn | .NET 10 | dotnet Universal.exe |
| [2] | Visual Studio 18 Roslyn | .NET 8 | dotnet Universal.exe |
| [3] | .NET Framework 4.0 csc.exe | .NET Fx 4.0 | Native Universal.exe |
[Build-Time Information]
Compiler: Roslyn: 10.0.1 (System.Private.CoreLib), CoreLib: 10.0.1, Image: v4.0.30319
Ref Assemblies: net10.0
[Run-Time Information]
CLR Version: 10.0.1
Runtime: .NET 10.0.1
Timestamp: 2026-01-08 16:03:28
OS: Microsoft Windows NT 10.0.26200.0
64-bit Process: True
There are three distinct components involved in building and running a .NET application:
| Component | Role | Example |
|---|---|---|
Compiler (csc.exe) |
Translates C# to IL bytecode | dotnet\sdk\10.0.101\Roslyn\bincore\csc.exe |
| Reference Assemblies | Metadata-only DLLs for compilation | dotnet\packs\Microsoft.NETCore.App.Ref\10.0.1\ref\net10.0\ |
Runtime (dotnet host) |
Executes the IL bytecode | dotnet\shared\Microsoft.NETCore.App\10.0.1\ |
Key Insight: Environment.Version reports the runtime version, not the compiler or reference assemblies used at build time. To determine what you compiled against, you must use preprocessor defines (/define:) since that information is not retained in the IL.
The legacy .NET Framework compiler (v4.0.30319\csc.exe) operates in "smart" modeβit implicitly knows where mscorlib.dll lives and references it automatically.
Modern Roslyn compilers (in the SDK or VS) are implementation-agnostic. They operate in "dumb" mode and do not assume anything about the target runtime.
The Fix: You must explicitly tell the compiler where fundamental types live:
csc.exe /noconfig /nostdlib /r:Path\To\System.Runtime.dll ...The /nostdlib flag disables implicit mscorlib references, and /noconfig ignores the default csc.rsp response file.
We discovered a critical distinction when compiling against Visual Studio's internal folders:
| Assembly Type | Location | Characteristics |
|---|---|---|
| Reference Assemblies | dotnet\packs\...\ref\ |
Metadata-only; System.Runtime.dll defines System.Object |
| Implementation Assemblies | dotnet\shared\... or VS runtime folders |
Executable code; System.Runtime.dll is a Type Forwarder |
The Pitfall: Implementation assemblies' System.Runtime.dll forwards types to System.Private.CoreLib.dll. If you compile against implementation assemblies without explicitly referencing System.Private.CoreLib.dll, the build fails:
error CS0518: Predefined type 'System.Object' is not defined or imported
The Fix: When using implementation assemblies, add:
/r:Path\To\System.Private.CoreLib.dllA .exe produced by csc.exe targeting .NET Core/.NET 5+ is not a native Windows executableβit's a managed assembly that requires the dotnet host to execute.
The Fix: Generate a <AssemblyName>.runtimeconfig.json alongside the executable:
{
"runtimeOptions": {
"tfm": "net10.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "10.0.0"
}
}
}Then execute via: dotnet Universal.exe
Note: The runtime version in runtimeconfig.json determines which CLR loads, regardless of which reference assemblies you compiled against. The dotnet host may "roll forward" to a newer runtime if the exact version isn't installed.
You might expect Assembly.ImageRuntimeVersion to differ between .NET Framework 4, .NET 8, and .NET 10 binaries. Surprisingly, they all report v4.0.30319.
Explanation: The CLR metadata format has been frozen since .NET Framework 4.0 (2010). Microsoft chose not to increment this version for .NET Core and beyond because:
- The IL bytecode format hasn't fundamentally changed
- It maintains compatibility with existing tooling (debuggers, decompilers, etc.)
- Actual runtime version is communicated through other mechanisms
To determine the actual compiler/runtime version at execution time, inspect:
typeof(object).Assemblyβ Points tomscorlib(.NET Framework) orSystem.Private.CoreLib(.NET Core+)AssemblyInformationalVersionAttributeβ Contains version string with Git commit hash (e.g.,8.0.22+a2266c72...)RuntimeInformation.FrameworkDescriptionβ Human-readable runtime description
// Get detailed runtime info
var coreAsm = typeof(object).Assembly;
var infoVersion = coreAsm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
// Returns: "10.0.1+abcdef123..." for .NET 10The :: comment syntax is actually a label (::), not a true comment. Inside parenthetical if blocks, it causes parse errors:
if "%x%"=="1" (
:: This breaks the parser!
set "VAR=value"
)The Fix: Use REM inside blocks:
if "%x%"=="1" (
REM This works correctly
set "VAR=value"
)Variables set inside if blocks aren't accessible with %VAR% syntax because CMD expands variables at parse time, not execution time.
The Fix: Enable delayed expansion and use !VAR! syntax:
setlocal enabledelayedexpansion
if "%choice%"=="1" (
set "REF_PATH=C:\some\path"
REM Use !REF_PATH! not %REF_PATH%
echo Path is: !REF_PATH!
)For a basic console application, you need at minimum:
| Assembly | Purpose |
|---|---|
System.Runtime.dll |
Core types (Object, String, Int32, etc.) |
System.Console.dll |
Console.WriteLine, Console.ReadKey |
System.Runtime.Extensions.dll |
Environment.Version, DateTime |
System.Runtime.InteropServices.RuntimeInformation.dll |
RuntimeInformation.FrameworkDescription |
Gotcha: System.Collections.Generic.List<T> lives in System.Collections.dllβif you use it, you must add another /r: reference. To keep dependencies minimal, use arrays instead.
βββ Universal.cs # Self-inspecting C# source file
βββ Build.cmd # Hybrid batch script for compilation
βββ Universal.exe # Output binary
βββ README.md # This file
- Uses reflection to inspect its own runtime environment
- Reports CLR version, assembly metadata, and compiler-embedded attributes
- Uses preprocessor directives (
#if REF_NET10) for compile-time information - Avoids
System.Collections.dlldependency by using arrays - Compatible with .NET Framework 4.0 through .NET 10
- Interactive menu for compiler selection
- Automatic
runtimeconfig.jsongeneration - Proper delayed expansion for variable handling
- Displays compiler path, target TFM, and reference assembly path
- Error handling with
ERRORLEVELchecks
- Windows 10/11
- .NET SDK 10.x (for option 1)
- Visual Studio 2025 "18" (for option 2)
- .NET Framework 4.x (pre-installed on Windows)
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
