dotnet cross-platform interop with C via Environment.ProcessId system call
The goal of this article is to understand how high-level dotnet code interoperates with low-level C code in a cross-platform manner when making system call via Environment.ProcessId in dotnet. We'll delve into the differences between running it on windows and unix-like (macOS, Linux) operating systems in a cross-platform manner. We'll also write some C code to check and prove that we really understand what's going on. Before the start I assume my reader is a person who is well-versed with general programming concepts and have a curiosity towards inner working of dotnet platform. Just to note, I'm not an expert in system programming topic, so I may be wrong or misinterpret something. But for this article, I wanted to play around with C and prove the concepts that I want to understand. Basically, the whole point of this article is to document my findings. Pre-requisites If you want to follow along, this is the suggested list of tools that should be installed dotnet (.NET 9 is used for this article) and C# decompiler a copy of dotnet runtime IDE of choice - Visual Studio Code, Visual Studio, Rider any operating system (OS) you're most comfortable with - macOS, Linux, Windows docker and/or any virtualization technology - Parallels, UTM, virtualbox All provided examples were done on macOS M1 (aarch64 architecture), docker and Rider was used as an IDE and decompiler for C#. System call Before delving into the code, let's provide a definition to a system call. System call is a mechanism that allows user-level applications to request services from the operating system's kernel, such as accessing hardware, managing files, creating and terminating processes, and facilitating communication between processes. As an example, if your code creates a file (via File.Create high-level API) you're basically making a system call to the underlying operating system. If you make an HTTP request, you do a system call, and so on. The path of Environment.ProcessId We'll start exploring the simplest possible system call - Environment.ProcessId (get the unique identifier for the current process). Let's introduce various deepness levels: client-level developer-level calls, the decompilation starts here, this code is expected to be written by a developer and called in Production high-level decompiled C# code, implementation details can start to differ, this is not expected to be written directly by a developer low-level direct or indirect calls to C\C++ code, operating system specific implementation details, the lowest level we're aiming for Each level will be accompanied by a diagram for visual understanding of calls. For various operating systems, an appropriate decompiled code will be presented too. Let's get started! Client-level This is the code that you should write if you want to get a process id var processId = Environment.ProcessId; Console.WriteLine(processId); // output: 1 (this is an example value) Let's start outlining this call via a diagram High-level Now we need to decompile Environment.ProcessId which resides in System.Runtime.dll. We'll be decompiling for two major flavours of operating systems: unix-like - various Unix and Unix-like operating systems: macOS, gnu/Linux (Debian, Ubuntu, etc.), musl/Linux (Alpine), iOS, Android windows - Windows only info "Decompilation" unix-like flavour is decompiled on macOS M1 via Rider windows flavour is decompiled on Windows 11 on Parallels via Rider Decompiled code unix-like public static partial class Environment { public static int ProcessId { get { int processId = s_processId; if (processId == 0) { s_processId = processId = GetProcessId(); // Assume that process Id zero is invalid for user processes. It holds for all mainstream operating systems. Debug.Assert(processId != 0); } return processId; } } } windows public static partial class Environment { public static int ProcessId { get { int processId = Environment.s_processId; if (processId == 0) Environment.s_processId = processId = Environment.GetProcessId(); return processId; } } } note "Implementation details" These are the implementation details, important point here is that it can change between dotnet versions. Don't expect it to be be always the same. We can already notice differences between unix-like and windows operating systems. Although it looks quite similar, in reality these are two completely different calls. Notice that Environment class is declared as static partial meaning that during dotnet runtime build process we can use (substitute) platform-specific implementations. Low-level In order to really see this difference we need to go one level deeper. Let's decompile GetProcessId() for unix-like and Environment.GetProcessI

The goal of this article is to understand how high-level dotnet
code interoperates with low-level C
code in a cross-platform manner when making system call via Environment.ProcessId in dotnet
.
We'll delve into the differences between running it on windows
and unix-like
(macOS
, Linux
) operating systems in a cross-platform manner. We'll also write some C
code to check and prove that we really understand what's going on.
Before the start
I assume my reader is a person who is well-versed with general programming concepts and have a curiosity towards inner working of dotnet
platform.
Just to note, I'm not an expert in system programming topic, so I may be wrong or misinterpret something. But for this article, I wanted to play around with C
and prove the concepts that I want to understand. Basically, the whole point of this article is to document my findings.
Pre-requisites
If you want to follow along, this is the suggested list of tools that should be installed
-
dotnet (.NET 9 is used for this article) and
C#
decompiler - a copy of dotnet runtime
- IDE of choice - Visual Studio Code, Visual Studio, Rider
- any operating system (OS) you're most comfortable with - macOS, Linux, Windows
- docker and/or any virtualization technology - Parallels, UTM, virtualbox
All provided examples were done on macOS M1
(aarch64 architecture), docker
and Rider
was used as an IDE
and decompiler
for C#
.
System call
Before delving into the code, let's provide a definition to a system call
.
System call is a mechanism that allows user-level applications to request services from the operating system's kernel, such as accessing hardware, managing files, creating and terminating processes, and facilitating communication between processes.
As an example, if your code creates a file (via File.Create high-level API) you're basically making a system call
to the underlying operating system. If you make an HTTP request, you do a system call
, and so on.
The path of Environment.ProcessId
We'll start exploring the simplest possible system call
- Environment.ProcessId (get the unique identifier for the current process).
Let's introduce various deepness levels:
-
client-level
developer-level calls, the decompilation starts here, this code is expected to be written by a developer and called inProduction
-
high-level
decompiledC#
code, implementation details can start to differ, this is not expected to be written directly by a developer -
low-level
direct or indirect calls toC\C++
code, operating system specific implementation details, the lowest level we're aiming for
Each level will be accompanied by a diagram for visual understanding of calls. For various operating systems, an appropriate decompiled code will be presented too.
Let's get started!
Client-level
This is the code that you should write if you want to get a process id
var processId = Environment.ProcessId;
Console.WriteLine(processId); // output: 1 (this is an example value)
Let's start outlining this call via a diagram
High-level
Now we need to decompile Environment.ProcessId
which resides in System.Runtime.dll
. We'll be decompiling for two major flavours of operating systems:
-
unix-like
- variousUnix
andUnix-like
operating systems:macOS
,gnu/Linux
(Debian
,Ubuntu
, etc.),musl/Linux
(Alpine
),iOS
,Android
-
windows
-Windows
only
info "Decompilation"
unix-like
flavour is decompiled onmacOS M1
viaRider
windows
flavour is decompiled onWindows 11
onParallels
viaRider
Decompiled code
-
unix-like
public static partial class Environment
{
public static int ProcessId
{
get
{
int processId = s_processId;
if (processId == 0)
{
s_processId = processId = GetProcessId();
// Assume that process Id zero is invalid for user processes. It holds for all mainstream operating systems.
Debug.Assert(processId != 0);
}
return processId;
}
}
}
-
windows
public static partial class Environment
{
public static int ProcessId
{
get
{
int processId = Environment.s_processId;
if (processId == 0)
Environment.s_processId = processId = Environment.GetProcessId();
return processId;
}
}
}
note "Implementation details"
These are the implementation details, important point here is that it can change between
dotnet
versions. Don't expect it to be be always the same.
We can already notice differences between unix-like
and windows
operating systems. Although it looks quite similar, in reality these are two completely different calls. Notice that Environment
class is declared as static partial
meaning that during dotnet
runtime build process we can use (substitute) platform-specific implementations.
Low-level
In order to really see this difference we need to go one level deeper. Let's decompile GetProcessId()
for unix-like
and Environment.GetProcessId()
for windows
.
-
unix-like
// Environment.Unix.cs
public static partial class Environment
{
[MethodImplAttribute(MethodImplOptions.NoInlining)] // Avoid inlining PInvoke frame into the hot path
private static int GetProcessId() => Interop.Sys.GetPid();
}
// Interop.GetPid.cs
internal static partial class Interop
{
internal static partial class Sys
{
[LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_GetPid")]
internal static partial int GetPid();
}
}
-
windows
// Environment.cs
public static partial class Environment
{
[MethodImpl(MethodImplOptions.NoInlining)]
private static int GetProcessId() => (int) Interop.Kernel32.GetCurrentProcessId();
}
// Interop.cs
internal static class Interop
{
internal static class Kernel32
{
[LibraryImport("kernel32.dll")]
[DllImport("kernel32.dll")]
internal static extern uint GetCurrentProcessId();
}
}
note "Combined results"
These are combined results from several levels of decompilation.
Here we've been introduced to Interop
class which is a bridge between managed and unmanaged (native) worlds.
From now on, we'll investigate each operating system flavour separately starting with windows
and then moving on to unix-like
, which will be covered in more depth.
windows
Firstly, we'll look into windows
chain of calls.
[LibraryImport("kernel32.dll")]
[DllImport("kernel32.dll")]
internal static extern uint GetCurrentProcessId();
All C
interop calls are facilitated via DllImport (old approach) and/or LibraryImport (new approach). DllImport
(or LibraryImport
) is the real bridge that connects managed and unmanaged worlds in dotnet
. This is part of dotnet
Platform Invoke (P/Invoke) technology.
This code is telling the following: import kernel32.dll
dynamic library (only exists on Windows
) allowing access to native GetCurrentProcessId function. Using the above declaration the function name must fully match the native counterpart, otherwise it won't work.
This is it for windows
. It's just a direct call to GetCurrentProcessId
from kernel32.dll
, that's it. But for unix-like
it's not that simple.
unix-like
Now it's turn to delve into unix-like
chain of calls.
[LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_GetPid")]
internal static partial int GetPid();
Shim via Libraries.SystemNative
This is where the things start to diverge quite a bit. Firstly, instead of kernel32.dll
a lib called Libraries.SystemNative
is being loaded instead. Secondly, LibaryImport.EntryPoint property says that there is a function called SystemNative_GetPid
that needs to be used in order to get process id.
Let's go step by step. Looking into dotnet runtime we can find that Libraries.SystemNative acts as a shim
(adapter) to a dynamic library called libSystem.Native
.
internal static partial class Interop
{
internal static partial class Libraries
{
internal const string libc = "libc";
// Shims
internal const string SystemNative = "libSystem.Native";
internal const string NetSecurityNative = "libSystem.Net.Security.Native";
internal const string CryptoNative = "libSystem.Security.Cryptography.Native.OpenSsl";
internal const string CompressionNative = "libSystem.IO.Compression.Native";
internal const string GlobalizationNative = "libSystem.Globalization.Native";
internal const string IOPortsNative = "libSystem.IO.Ports.Native";
internal const string HostPolicy = "libhostpolicy";
}
}
info "
shim
explained"In the context of
dotnet
and system libraries, ashim
is a small compatibility layer that acts as an intermediary between your application and the actual system APIs. It does the following:
- hides platform-specific details and provides a unified API
- allows
dotnet
code to run on multiple operating systems without changing how it calls system functions
libSystem.Native on various unix-like operating systems
So, it seems that for unix-like
operating systems there is an additional library called libSystem.Native
that's supplied by dotnet runtime
. Let's inline it's name and check the call again.
[LibraryImport("libSystem.Native", EntryPoint = "SystemNative_GetPid")]
internal static partial int GetPid();
In order for this to work libSystem.Native
dynamic library must be physically present on unix-like
operating system.
Let's find it!
info "dynamic libraries on various operating systems"
Various operating systems use different dynamic library extensions:
macOS
-.dylib
Linux
-.so
Windows
-.dll
macOS
- run
find /usr/local/share/dotnet -name "libSystem.Native.*"
- output
/usr/local/share/dotnet/shared/Microsoft.NETCore.App/9.0.2/libSystem.Native.dylib
Linux
- run via
docker
docker run --rm mcr.microsoft.com/dotnet/runtime:9.0 find / -name "libSystem.Native.*"
- output
/usr/share/dotnet/shared/Microsoft.NETCore.App/9.0.2/libSystem.Native.so
Windows
- there is no
libSystem.Native.dll
onWindows
as thisshim
is used forunix-like
only
So, depending on the operating system dotnet
supplies a specific libSystem.Native
version of the library: .dylib
for macOS
, .so
for Linux
.
SystemNative_GetPid and System.Native
We've found where libSystem.Native
resides, it's provided by dotnet runtime
, now it's time to understand what SystemNative_GetPid
call is.
The real implementation of SystemNative_GetPid
can be found in pal_process.c
(with accompanying header file) which is inside System.Native folder.
But before we continue, let's get acquainted with PAL
concept first. In dotnet runtime
, PAL
stands for Platform Abstraction Layer
. It's a component that enables dotnet
to run on multiple operating systems and hardware platforms. It provides a consistent interface between dotnet runtime
and the underlying operating system, abstracting away platform-specific details. This allows the majority of dotnet runtime
code to be platform-agnostic.
Now onto C
code
// System.Native/pal_process.h, declaration
PALEXPORT int32_t SystemNative_GetPid(void);
// System.Native/pal_process.c, implementation
#include // 'getpid' resides here
int32_t SystemNative_GetPid(void)
{
return getpid();
}
// System.Native/entrypoints.c, exporting to be available in C# interop
static const Entry s_sysNative[] =
{
DllImportEntry(SystemNative_GetPid)
}
source code: pal_process.h, pal_process.c, entrypoints.c
The crux of provided snippet is the real implementation that works on all unix-like
operating systems. Let's finalize the flow of calls by adding low-level
chain of calls into the diagram.
Conclusion? Not yet
getpid()
is the function that's being called on unix-like
operating systems.
We've basically covered all flows for Environment.ProcessId
call and the article could finish here. But my curiosity was still thirsty and I needed to know exactly what getpid
function does, how it works on different unix-like
systems and where it resides.
If you're like me, then continue reading.
C Standard Library (libc) and POSIX
Here is getpid()
function and it gets current process id. But wait a sec, how does it do that? If getpid()
is being called from libSystem.Native
then where is the lib where the actual getpid()
resides? Also, how it handles various unix-like
operating systems?
I'm glad you've asked! This is the topic we'll start exploring now. But first, we need to understand what C Standard Library (libc)
and POSIX
are.
libc
C Standard Library (libc) is the standard library for the C
programming language, also called libc
(this term will be used from now on). libc
provides various macros, type definitions and functions for tasks such as string manipulation, mathematical computation, input/output processing, memory management, and so on.
From C
language perspective libc
defines a set of header files which can be used in programs: stdio.h, math.h, etc. (full list can be found here). libc
is available on all C-compliant
platforms, it works on Windows
, macOS
, Linux
.
Example time!
Let's write a simple C
program which will be using libc
function calls and which will work on all major operating systems.
#include // '' header resides in 'libc' library
#include // '' header resides in 'libc' library
int main(void) {
printf("This is 'libc' call!\n"); // 'printf' function is from '' header which resides in 'libc' library
printf("exp(1) = %f\n", exp(1)); // 'exp' function is from '' header which resides in 'libc' library
return 0;
}
It's time to compile and run it!
info "compiling
C
in a cross-platform manner"I want to compile
C
program for various operating systems from one machine, that's why onmacOS M1
I use zig drop-in replacement compiler (can be used onLinux
,Windows
too) for cross-platform compilation.
There are also clang, gcc (usually pre-installed onmacOS
andLinux
). ForWindows
there are
Visual Studio installer or mingw (which installs gcc).Another important thing to remember is that I compile for
aarch64
architecture forM1
series of processors, if you useIntel
orAMD
you'd need to
compile forx64/x84
architecture.
macOS (native)
- compile
zig cc -o libc_example_macos libc_example.c
- run
./libc_example_macos
- output
This is 'libc' call!
exp(1) = 2.718282
gnu/Linux (cross-compile)
- compile
zig cc -o libc_example_linux_gnu libc_example.c -target aarch64-linux-gnu
- run via
docker
docker run --rm -v "$PWD":/app -w /app debian:latest sh -c "./libc_example_linux_gnu"
- output
This is 'libc' call!
exp(1) = 2.718282
musl/Linux (cross-compile)
- compile
zig cc -o libc_example_linux_musl libc_example.c -target aarch64-linux-musl
- run via
docker
docker run --rm -v "$PWD":/app -w /app alpine:latest sh -c "./libc_example_linux_musl"
- output
This is 'libc' call!
exp(1) = 2.718282
Windows (cross-compile)
- compile
zig cc -o libc_example_windows.exe libc_example.c -target aarch64-windows
- run via
virtual machine
libc_example_windows.exe
- output
This is 'libc' call!
exp(1) = 2.718282
info "Linux flavours: gnu and musl"
There are various
Linux
flavours. Broadly speaking there are two main ones: gnu (Debian, Ubuntu, etc) and musl (Alpine, etc).
Awesome! libc_example.c
program works everywhere!
But where is libc
actually lives? We're ready to answer that: each operating system implements its own version of libc
. libc
can be treated as an interface
and each operating system implements its own version. Let's visualize that.
info "How to read a table"
Below is a reference table comparing how the
libc
is >implemented across major operating systems. Each row describes the >following:
Operating system
self-explanatoryC standard library (libc)
the name by whichlibc
is commonly known on each platformDynamic library name
the actual dynamic library file that contains the implementationFunction name
an example function name that remains consistent across platforms despite the different underlying implementations
Operating system | macOS | Linux | Windows |
---|---|---|---|
C standard library (libc) | BSD | gnu or musl | MSVRT/UCRT |
Dynamic library name | libSystem.dylib | libc.so.6 or libc.so | msvcrt.dll |
Function name | printf | printf | printf |
Each operating system links its own version of libc
(via dynamic or static linking) during C
compilation phase. It means that all functions from libc
are always available and can be used from any C
program without any additional setup. Important thing to note is that it can actually be several physical files (dynamic libraries) that implement libc
and dynamic library name can also differ (we'll see it in the context of macOS
later, as it's an operating system level implementation detail).
Phew!
We covered libc
, but we still haven't figured out where getpid
lives and how it's connected to libc
, the next section will provide more details on that.
POSIX
POSIX is a family of standards for maintaining compatibility between operating systems. It defines a common set of APIs and
behaviors for unix-like
operating systems. Before POSIX
, Unix
systems were highly fragmented — each vendor had different APIs,
making cross-platform development difficult. POSIX
was introduced to standardize system calls and libraries. On unix-like
systems POSIX API
is part of libc
(aka superset of libc
).
And you know what? getpid()
is POSIX API call! Here's getpid for Linux and
getpid for macOS.
Important distinction between unix-like
and windows
is that POSIX API
is not supported on Windows
. Windows
uses WinAPI instead and GetCurrentProcessId
from kernel32.dll
is WinAPI
call. That's why we have two completely different flows from dotnet
PAL
perspective.
info "POSIX support for
Windows
"Altough, built-in
libc
onWindows
doesn't supportPOSIX API
anddotnet
usesWinAPI
instead,POSIX
support can be added externally via cygwin, WSL or MinGW.Remember that
dotnet
as a platform has been onWindows
for tens of years already (via .NET Framework which is outdated) and a final cross-platform support was added only starting from.NET Core
. There is also Mono runtime but let's leave it for now as it's not related for the current article. Overall, here's the [history of dotnet (https://en.wikipedia.org/wiki/.NET) if you need more details.
Are you confused yet?
I guess some examples are needed here! So, let's call POSIX API
for unix-like
and WinAPI
for windows
to get process id in C
language.
unix-like (file name: get_pid_unix_like.c
)
#include // '' header resides in 'libc' library
#include // '' header resides in 'libc' library and it's 'POSIX API'
int main() {
pid_t pid = getpid(); // 'getpid' function is from '' header which resides in 'libc' library and it's 'POSIX API'
printf("Current Process ID: %i\n", pid);
return 0;
}
windows (file name: get_pid_windows.c
)
#include // '' header resides in 'libc' library
#include // '' header resides in 'WinAPI' library
int main() {
DWORD pid = GetCurrentProcessId(); // 'GetCurrentProcessId' function is from '' header which resides in 'WinAPI' library
printf("Current Process ID: %lu\n", (unsigned long)pid);
return 0;
}
Now it's time to compile and run.
info "Linux: glibc vs musl"
For
gnu/Linux
the actual implementation oflibc
andPOSIX API
is calledglibc
while formusl
it's called...musl
. By the way,musl
is mostly POSIX-compliant, not fully. But for the sake of current discussion it doesn't matter, asgetpid
will work on anyLinux
flavour.For the next example, I specifically excluded
musl/Linux
as formusl
compilationstatic linking
is used instead ofdynamic linking
. Duringstatic linking
, the call togetpid
will be directly included into the resulting program so we won't be able to see the actual dynamic library file whereabouts.
macOS (native get_pid_unix_like.c
)
- compile
zig cc -o get_pid_macos get_pid_unix_like.c
- run
./get_pid_macos
- output
Current Process ID: 67194
gnu/Linux (cross-compile get_pid_unix_like.c
)
- compile
zig cc -o get_pid_linux_gnu get_pid_unix_like.c -target aarch64-linux-gnu
- run via
docker
docker run --rm -v "$PWD":/app -w /app debian:latest sh -c "./get_pid_linux_gnu"
- output
Current Process ID: 7
musl/Linux (cross-compile get_pid_unix_like.c
)
- compile
zig cc -o get_pid_linux_musl get_pid_unix_like.c -target aarch64-linux-musl
- run via
docker
docker run --rm -v "$PWD":/app -w /app alpine:latest sh -c "./get_pid_linux_musl"
- output
Current Process ID: 1
Windows (cross-compile get_pid_windows.c
)"
- compile
zig cc -o get_pid_windows.exe get_pid_windows.c -target aarch64-windows
- run via
virtual machine
get_pid_windows.exe
- output
Current Process ID: 17256
Based on the provided examples, when get_pid_unix_like.c
is compiled it will work only on unix-like
systems and get_pid_windows.c
will work only on windows
respectively. As we've seen previously, for windows
, dotnet
calls into GetCurrentProcessId()
function directly, without any explicit C
code ...
[LibraryImport("kernel32.dll")]
[DllImport("kernel32.dll")]
internal static extern uint GetCurrentProcessId();
... while for unix-like
it calls directly into C
via getpid
.
#include // 'getpid' resides here
int32_t SystemNative_GetPid(void)
{
return getpid();
}
Now, where does getpid
actually live?
"I want to physically know the library this function lives in!" (c) me
getpid
whereabouts
We need to understand what operating system we're targeting. We'll focus only on two "flavours" of them: macOS
and gnu/Linux
(Debian
, Ubuntu
).
As we already compiled get_pid_macos
and get_pid_linux_gnu
from previous step, we can check which dynamic libraries were linked during the compilation phase.
macOS
- run
otool
otool -L ./get_pid_macos
- output
./get-process-id-macos:
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1351.0.0)
gnu/Linux
- run
ldd
docker run --rm -v "$PWD":/app -w /app debian:latest sh -c "ldd ./get_pid_linux_gnu"
- output
linux-vdso.so.1 (0x0000ffffaa71a000)
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffffaa520000)
/lib/ld-linux-aarch64.so.1 (0x0000ffffaa6dd000)
Based on provided outputs we can conclude the following:
- on
macOS
-getpid
function resides inlibSystem.B.dylib
dynamic library - on
gnu/Linux
(Debian
) -getpid
function resides in/lib/aarch64-linux-gnu/libc.so.6
dynamic library
When program is compiled, the system standard libraries (glibc/POSIX
, WinAPI
) are linked automatically so all of their functionality is available by default.
Conclusion
As a reminder from where we started
var processId = Environment.ProcessId;
Console.WriteLine(processId); // output: 1 (this is an example value)
Environment.ProcessId
call does the following:
- for
unix-like
operating systems (includingmacOS
and variousLinux
flavours:gnu
,musl
), callsgetpid
function (which isPOSIX
) - for
Windows
, callsGetCurrentProcessId
function (which isWinAPI
) - each
unix-like
operating system implements its own version ofC Standard Library (libc)
and has its own flavour oflibc
-glibc
(Debian
,Ubuntu
, etc),musl
(Alpine
, etc) -
libc
andWinAPI
libraries are linked automatically during program compilation
We've only covered one simple system call
, but it gave us a proper view into the inner details of how low-level interoperability with C
is being done in dotnet
.
runtime
There are a lot of other system calls such as: working with files (IO), making HTTP requests, etc. All of them follow a similar pattern.
We also need to remember that all of that are implementation details and the actual chain of calls may change in future versions of dotnet
.