Skip to content

Tour of .NET Behavior on Windows 11 Arm64 #7709

@richlander

Description

@richlander

Tour of .NET Behavior on Windows 11 Arm64

We're going to take a tour of the basic behavior of .NET 6 and .NET Framework 4.8.1 on Windows 11 Arm64. This tour of the experience is going to give us a sense of which .NET variants can be installed on the machine, how to use them to target various architectures, and what various key APIs report. This tour will provide a good sense of the experience that you an expect and that is on offer.

The primary questions it answers are:

  • When I build an app, which architecture does it target/support by default?
  • How do I build my app for a different/particular architecture?
  • Is there a way to get my app to run a different architecture than what it was built for as part of launching it?

A subset of this document will be moved to official docs. In fact, it was created as a set of working notes to do just that. The author of those notes is sharing them with the hope that they will be a benefit for someone trying to figure out how best to target Windows Arm64.

The tour is on Windows 11 Arm64, however, you could just as easily use these instructions to build for Windows Arm64 on Windows x64 and then run the built apps on Windows Arm64. Also, much of what you'll see equally applies to macOS x64 and Arm64.

The key -- potentially surprising -- experience is that .NET Framework AnyCPU apps run as emulated x64 on Windows Arm64. That choice was made for the benefit of compatibility. The document describes multiple ways to get your .NET Framework apps to run natively, either as a build-time or launch-time decision.

I'm using .NET 6.0.8 (.NET SDK 6.0.400) and .NET Framework 4.8.1. I'll be using Arm64, x64, and x86 variants of both. .NET 6 is not included in Windows 11 (or any other Windows version), while all three .NET Framework architecture variants are included. I'm using a Windows 11 Insider build (10.0.22621). The machine and OS are Arm64.

There is also one honorable mention of .NET 7, where its behavior differs from .NET 6.

Much of the tour relies on the Developer Command Prompt that comes with Visual Studio 2022. Visual Studio 2022 will soon be supported on Windows 11 Arm64.

Test samples

We'll be using the following samples to test our environment:

Those are samples we maintain for containers, but they are also great for this use case.

PATH

All the .NET variants mentioned are installed on the Windows 11 Arm64 test machine that will be used for the tour.

The PATH is a key aspect of any dev experience and is critical to understand to using platform components (particularly tools).

Let's take a look at the PATH:

 $env:PATH
C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\;C:\Windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\Git\cmd;C:\Program Files\Microsoft SQL Server\150\Tools\Binn\;C:\Program Files\Microsoft SQL Server\Client SDK\ODBC\170\Tools\Binn\;C:\Users\rich\AppData\Local\Microsoft\WindowsApps;C:\Users\rich\.dotnet\tools;C:\Users\rich\AppData\Local\Programs\Microsoft VS Code\bin

The following path segments relate to .NET:

  • C:\Program Files\dotnet\
  • C:\Users\rich\.dotnet\tools

There are two interesting aspects here:

  • Only the native architecture .NET (Core) location is registered. That's Arm64.
  • There is just one location for .NET tools. That feature is built in such a way that tools from multiple architectures can be co-located without issue.

There is no PATH entry for .NET Framework. That's nothing new. There has never been a PATH entry for .NET Framework. It doesn't explicitly need one. If you want to use .NET Framework tools, you need to rely on the Developer Command Prompt for Visual Studio.

Building and running by default

First, .NET 6:

PS C:\Users\rich\git\dotnet-docker\samples\dotnetapp> dotnet run
         42
         42              ,d                             ,d
         42              42                             42
 ,adPPYb,42  ,adPPYba, MM42MMM 8b,dPPYba,   ,adPPYba, MM42MMM
a8"    `Y42 a8"     "8a  42    42P'   `"8a a8P_____42   42
8b       42 8b       d8  42    42       42 8PP"""""""   42
"8a,   ,d42 "8a,   ,a8"  42,   42       42 "8b,   ,aa   42,
 `"8bbdP"Y8  `"YbbdP"'   "Y428 42       42  `"Ybbd8"'   "Y428

.NET 6.0.8
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: Arm64
ProcessorCount: 8
TotalAvailableMemoryBytes: 31.41 GiB

The app is running on Arm64, as expected.

Next, .NET Framework 4.8.1:

C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>msbuild /t:restore;build
C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>bin\Debug\net48\dotnetapp.exe
                                 ad88
                        ,d      d8"
                        88      88
8b,dPPYba,   ,adPPYba, MM88MMM MM88MMM 8b,     ,d8
88P'   `"8a a8P_____88   88      88     `Y8, ,8P'
88       88 8PP"""""""   88      88       )888(
88       88 "8b,   ,aa   88,     88     ,d8" "8b,
88       88  `"Ybbd8"'   "Y888   88    8P'     `Y8

.NET Framework 4.8.9065.0
Microsoft Windows 10.0.22621

OSArchitecture: X64
ProcessArchitecture: X64
ProcessorCount: 8

In contrast, the .NET Framework app is running on x64.

.NET Framework apps run as x64 by default on Windows Arm64. That was a key design decision made to aid compatibility. .NET Framework apps have been primarily running as x64 for over a decade on pervasively deployed x64 desktops, laptops, and servers. It's quite likely that many .NET Framework apps have x64 dependencies (like native libraries). Running them as emulated seemed like the safest bet, and to make Arm64 opt-in.

Targeting Arm64

Targeting Arm64 is easy with .NET 6. Your app will target the same architecture as the SDK you are using. You'll produce an Arm64 app by default as long as you are using the Arm64 SDK. That was just demonstrated above.

However, if you want to force targeting Arm64 (which will be more important later on the tour), you can use -a arm64 as demonstrated in the following:

PS C:\Users\rich\git\dotnet-docker\samples\dotnetapp> dotnet run -a arm64
         42
         42              ,d                             ,d
         42              42                             42
 ,adPPYb,42  ,adPPYba, MM42MMM 8b,dPPYba,   ,adPPYba, MM42MMM
a8"    `Y42 a8"     "8a  42    42P'   `"8a a8P_____42   42
8b       42 8b       d8  42    42       42 8PP"""""""   42
"8a,   ,d42 "8a,   ,a8"  42,   42       42 "8b,   ,aa   42,
 `"8bbdP"Y8  `"YbbdP"'   "Y428 42       42  `"Ybbd8"'   "Y428

.NET 6.0.8
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: Arm64
ProcessorCount: 8
TotalAvailableMemoryBytes: 31.41 GiB

There is a similar option with MSBuild, for .NET Framework, using the Platform property:

C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>msbuild /t:restore;build /p:Platform=arm64
C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>bin\arm64\Debug\net48\dotnetapp.exe
                                 ad88
                        ,d      d8"
                        88      88
8b,dPPYba,   ,adPPYba, MM88MMM MM88MMM 8b,     ,d8
88P'   `"8a a8P_____88   88      88     `Y8, ,8P'
88       88 8PP"""""""   88      88       )888(
88       88 "8b,   ,aa   88,     88     ,d8" "8b,
88       88  `"Ybbd8"'   "Y888   88    8P'     `Y8

.NET Framework 4.8.9065.0
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: Arm64
ProcessorCount: 8

You can also use the .NET SDK for the same purpose:

C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>dotnet run -a arm64
                                 ad88
                        ,d      d8"
                        88      88
8b,dPPYba,   ,adPPYba, MM88MMM MM88MMM 8b,     ,d8
88P'   `"8a a8P_____88   88      88     `Y8, ,8P'
88       88 8PP"""""""   88      88       )888(
88       88 "8b,   ,aa   88,     88     ,d8" "8b,
88       88  `"Ybbd8"'   "Y888   88    8P'     `Y8

.NET Framework 4.8.9065.0
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: Arm64
ProcessorCount: 8

I'm using dotnet run for this demonstration, but dotnet build works the same way.

Targeting x64

Targeting x64 is just a variation on what you've just seen (mostly).

For .NET 6, we can continue using the Arm64 SDK, but target x64 with the same -a switch you just saw. That's the easiest and highest performance approach (the SDK is a lot of code and will run slowly when emulation).

PS C:\Users\rich\git\dotnet-docker\samples\dotnetapp> dotnet run -a x64
         42
         42              ,d                             ,d
         42              42                             42
 ,adPPYb,42  ,adPPYba, MM42MMM 8b,dPPYba,   ,adPPYba, MM42MMM
a8"    `Y42 a8"     "8a  42    42P'   `"8a a8P_____42   42
8b       42 8b       d8  42    42       42 8PP"""""""   42
"8a,   ,d42 "8a,   ,a8"  42,   42       42 "8b,   ,aa   42,
 `"8bbdP"Y8  `"YbbdP"'   "Y428 42       42  `"Ybbd8"'   "Y428

.NET 6.0.8
Microsoft Windows 10.0.22621

OSArchitecture: X64
ProcessArchitecture: X64
ProcessorCount: 8
TotalAvailableMemoryBytes: 31.41 GiB

We can also use the x64 SDK, which will produce x64 apps by default. That's not the recommended approach (due to the emulation performance cost), but works fine.

C:\Users\rich\git\dotnet-docker\samples\dotnetapp>"c:\Program Files\dotnet\x64\dotnet" run
         42
         42              ,d                             ,d
         42              42                             42
 ,adPPYb,42  ,adPPYba, MM42MMM 8b,dPPYba,   ,adPPYba, MM42MMM
a8"    `Y42 a8"     "8a  42    42P'   `"8a a8P_____42   42
8b       42 8b       d8  42    42       42 8PP"""""""   42
"8a,   ,d42 "8a,   ,a8"  42,   42       42 "8b,   ,aa   42,
 `"8bbdP"Y8  `"YbbdP"'   "Y428 42       42  `"Ybbd8"'   "Y428

.NET 6.0.8
Microsoft Windows 10.0.22621

OSArchitecture: X64
ProcessArchitecture: X64
ProcessorCount: 8
TotalAvailableMemoryBytes: 31.41 GiB

There isn't a C:\Program Files (x64) on Windows Arm64. As a result, the x64 variant of .NET is installed in C:\Program Files\dotnet in the child x64 directory. That's what you see demonstrated above. the x64 directory will only be there if you install the x64 .NET SDK. It doesn't come with the Arm64 SDK.

To complete the demonstration, we can use the x64 SDK to produce Arm64 apps. That's also not recommended, but works.

C:\Users\rich\git\dotnet-docker\samples\dotnetapp>"c:\Program Files\dotnet\x64\dotnet" run -a arm64
         42
         42              ,d                             ,d
         42              42                             42
 ,adPPYb,42  ,adPPYba, MM42MMM 8b,dPPYba,   ,adPPYba, MM42MMM
a8"    `Y42 a8"     "8a  42    42P'   `"8a a8P_____42   42
8b       42 8b       d8  42    42       42 8PP"""""""   42
"8a,   ,d42 "8a,   ,a8"  42,   42       42 "8b,   ,aa   42,
 `"8bbdP"Y8  `"YbbdP"'   "Y428 42       42  `"Ybbd8"'   "Y428

.NET 6.0.8
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: Arm64
ProcessorCount: 8
TotalAvailableMemoryBytes: 31.41 GiB

For .NET Framework, x64 is the default target as demonstrated above. There isn't anything more to show.

Targeting x86

For .NET 6, this experience is very similar to targeting x64. We'll use the same approach.

First, using Arm64 SDK, again using the -a switch:

C:\Users\rich\git\dotnet-docker\samples\dotnetapp>dotnet run -a x86
         42
         42              ,d                             ,d
         42              42                             42
 ,adPPYb,42  ,adPPYba, MM42MMM 8b,dPPYba,   ,adPPYba, MM42MMM
a8"    `Y42 a8"     "8a  42    42P'   `"8a a8P_____42   42
8b       42 8b       d8  42    42       42 8PP"""""""   42
"8a,   ,d42 "8a,   ,a8"  42,   42       42 "8b,   ,aa   42,
 `"8bbdP"Y8  `"YbbdP"'   "Y428 42       42  `"Ybbd8"'   "Y428

.NET 6.0.8
Microsoft Windows 10.0.22621

OSArchitecture: X64
ProcessArchitecture: X86
ProcessorCount: 8
TotalAvailableMemoryBytes: 2.00 GiB

Huh. Why did we drop from (near) 32GB to 2GB? Are 32-bit apps running in some small virtual machine used for emulation? How do I make it bigger? That's not it. 32-bit processes are limited to 4GB of memory and it's split 50/50 between kernel and user mode. That's why we're seeing 2GB.

We see slightly difference behavior with .NET 7 (specifically for OSArchitecture).

C:\Users\rich\git\dotnet-docker\samples\dotnetapp>dotnet run -a x86
         42
         42              ,d                             ,d
         42              42                             42
 ,adPPYb,42  ,adPPYba, MM42MMM 8b,dPPYba,   ,adPPYba, MM42MMM
a8"    `Y42 a8"     "8a  42    42P'   `"8a a8P_____42   42
8b       42 8b       d8  42    42       42 8PP"""""""   42
"8a,   ,d42 "8a,   ,a8"  42,   42       42 "8b,   ,aa   42,
 `"8bbdP"Y8  `"YbbdP"'   "Y428 42       42  `"Ybbd8"'   "Y428

.NET 7.0.0-preview.7.22375.6
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: X86
ProcessorCount: 8
TotalAvailableMemoryBytes: 2.00 GiB

Let's try with .NET Framework.

C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>msbuild /t:restore;build /p:Platform=x86
C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>bin\x86\Debug\net48\dotnetapp.exe
                                 ad88
                        ,d      d8"
                        88      88
8b,dPPYba,   ,adPPYba, MM88MMM MM88MMM 8b,     ,d8
88P'   `"8a a8P_____88   88      88     `Y8, ,8P'
88       88 8PP"""""""   88      88       )888(
88       88 "8b,   ,aa   88,     88     ,d8" "8b,
88       88  `"Ybbd8"'   "Y888   88    8P'     `Y8

.NET Framework 4.8.9065.0
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: X86
ProcessorCount: 8

We can try the same thing with the .NET SDK:

C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>dotnet run -a x86
                                 ad88
                        ,d      d8"
                        88      88
8b,dPPYba,   ,adPPYba, MM88MMM MM88MMM 8b,     ,d8
88P'   `"8a a8P_____88   88      88     `Y8, ,8P'
88       88 8PP"""""""   88      88       )888(
88       88 "8b,   ,aa   88,     88     ,d8" "8b,
88       88  `"Ybbd8"'   "Y888   88    8P'     `Y8

.NET Framework 4.8.9065.0
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: X86
ProcessorCount: 8

Hmmm. That's interesting. OSArchitecture is reported differently for .NET 6 on one side and .NET 7 and .NET Framework 4.8.1 on the other when running x86 apps. They must use different Windows APIs to report OSArchitecture. They don't all report the same thing.

Coercing already-built apps to Arm64

With .NET 6, you can (in some cases) run apps targeted for one architecture to another. We won't go into all the details on that, but will demonstrate the mechanism.

We'll first build and run the app natively, as has been demonstrated earlier.

C:\Users\rich\git\dotnet-docker\samples\dotnetapp>dotnet run
         42
         42              ,d                             ,d
         42              42                             42
 ,adPPYb,42  ,adPPYba, MM42MMM 8b,dPPYba,   ,adPPYba, MM42MMM
a8"    `Y42 a8"     "8a  42    42P'   `"8a a8P_____42   42
8b       42 8b       d8  42    42       42 8PP"""""""   42
"8a,   ,d42 "8a,   ,a8"  42,   42       42 "8b,   ,aa   42,
 `"8bbdP"Y8  `"YbbdP"'   "Y428 42       42  `"Ybbd8"'   "Y428

.NET 6.0.8
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: Arm64
ProcessorCount: 8
TotalAvailableMemoryBytes: 31.41 GiB

Now, we'll use the x64 dotnet to run the app.

C:\Users\rich\git\dotnet-docker\samples\dotnetapp>"C:\Program Files\dotnet\x64\dotnet" bin\Debug\net6.0\dotnetapp.dll
         42
         42              ,d                             ,d
         42              42                             42
 ,adPPYb,42  ,adPPYba, MM42MMM 8b,dPPYba,   ,adPPYba, MM42MMM
a8"    `Y42 a8"     "8a  42    42P'   `"8a a8P_____42   42
8b       42 8b       d8  42    42       42 8PP"""""""   42
"8a,   ,d42 "8a,   ,a8"  42,   42       42 "8b,   ,aa   42,
 `"8bbdP"Y8  `"YbbdP"'   "Y428 42       42  `"Ybbd8"'   "Y428

.NET 6.0.8
Microsoft Windows 10.0.22621

OSArchitecture: X64
ProcessArchitecture: X64
ProcessorCount: 8
TotalAvailableMemoryBytes: 31.41 GiB

Every .NET (Core) app has a native launcher. It's a component of every app and ties each app to an operating system and architecture. However, you don't need to launch an app with the native launcher that the SDK provides. You can launch it with the dotnet host instead. That's what is being demonstrated above.

.NET Framework works differently. It has special executables that are not tied to an architecture in quite as direct a way. Lets try some techniques to coerce an x64 app to Arm64.

We'll rebuild the app again to ensure we're starting from a clean slate.

C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>msbuild /t:restore;build
C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>bin\Debug\net48\dotnetapp.exe
                                 ad88
                        ,d      d8"
                        88      88
8b,dPPYba,   ,adPPYba, MM88MMM MM88MMM 8b,     ,d8
88P'   `"8a a8P_____88   88      88     `Y8, ,8P'
88       88 8PP"""""""   88      88       )888(
88       88 "8b,   ,aa   88,     88     ,d8" "8b,
88       88  `"Ybbd8"'   "Y888   88    8P'     `Y8

.NET Framework 4.8.9065.0
Microsoft Windows 10.0.22621

OSArchitecture: X64
ProcessArchitecture: X64
ProcessorCount: 8

That looks good for our baseline.

Let's first try start:

C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>start /MACHINE arm64 /B /WAIT bin\Debug\net48\dotnetapp.exe
                                 ad88
                        ,d      d8"
                        88      88
8b,dPPYba,   ,adPPYba, MM88MMM MM88MMM 8b,     ,d8
88P'   `"8a a8P_____88   88      88     `Y8, ,8P'
88       88 8PP"""""""   88      88       )888(
88       88 "8b,   ,aa   88,     88     ,d8" "8b,
88       88  `"Ybbd8"'   "Y888   88    8P'     `Y8

.NET Framework 4.8.9065.0
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: Arm64
ProcessorCount: 8

Our app now runs as Arm64.

runas is another option that you can use to get a .NET Framework app to run as Arm64.

C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>runas /machine:arm64 /trustlevel:0x20000 bin\Debug\net48\dotnetapp.exe

This launches another console windows, which follows:

                                 ad88
                        ,d      d8"
                        88      88
8b,dPPYba,   ,adPPYba, MM88MMM MM88MMM 8b,     ,d8
88P'   `"8a a8P_____88   88      88     `Y8, ,8P'
88       88 8PP"""""""   88      88       )888(
88       88 "8b,   ,aa   88,     88     ,d8" "8b,
88       88  `"Ybbd8"'   "Y888   88    8P'     `Y8

.NET Framework 4.8.9065.0
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: Arm64
ProcessorCount: 8

In this case, I had to add a Console.ReadLine() to the app. Otherwise, it would just open and close immediately. start offers /wait and /b to work around that. For UI apps, that won't be a problem.

You can also specify that you want your app to always run in the registry as Arm64.

Create the following registry key (rename for your app):

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\dotnetapp.exe

It would have this DWORD item and hex value:

PreferredMachine=aa64

or as decimal:

PreferredMachine=43620

You can see my registy entry.

image

Note: Is there a way to specify the absolute path for this key, not just the filename?

Let's try that:

C:\Users\rich\git\dotnet-framework-docker\samples\dotnetapp>bin\Debug\net48\dotnetapp.exe
                                 ad88
                        ,d      d8"
                        88      88
8b,dPPYba,   ,adPPYba, MM88MMM MM88MMM 8b,     ,d8
88P'   `"8a a8P_____88   88      88     `Y8, ,8P'
88       88 8PP"""""""   88      88       )888(
88       88 "8b,   ,aa   88,     88     ,d8" "8b,
88       88  `"Ybbd8"'   "Y888   88    8P'     `Y8

.NET Framework 4.8.9065.0
Microsoft Windows 10.0.22621

OSArchitecture: Arm64
ProcessArchitecture: Arm64
ProcessorCount: 8

That works as expected.

For folks that start processes from code, you can specify machine type by using the UpdateProcThreadAttribute function. See PROC_THREAD_ATTRIBUTE_MACHINE_TYPE.

Visual Studio

The targeting experience is the same in Visual Studio as it is for MSBuild.

image

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions