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-leveldeveloper-level calls, the decompilation starts here, this code is expected to be written by a developer and called inProduction -
high-leveldecompiledC#code, implementation details can start to differ, this is not expected to be written directly by a developer -
low-leveldirect 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- variousUnixandUnix-likeoperating systems:macOS,gnu/Linux(Debian,Ubuntu, etc.),musl/Linux(Alpine),iOS,Android -
windows-Windowsonly
info "Decompilation"
unix-likeflavour is decompiled onmacOS M1viaRiderwindowsflavour is decompiled onWindows 11onParallelsviaRider
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
dotnetversions. 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 "
shimexplained"In the context of
dotnetand system libraries, ashimis 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
dotnetcode 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-.dylibLinux-.soWindows-.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.dllonWindowsas thisshimis used forunix-likeonly
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 <unistd.h> // '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 <stdio.h> // '<stdio.h>' header resides in 'libc' library
#include <math.h> // '<math.h>' header resides in 'libc' library
int main(void) {
printf("This is 'libc' call!\n"); // 'printf' function is from '<stdio.h>' header which resides in 'libc' library
printf("exp(1) = %f\n", exp(1)); // 'exp' function is from '<stdio.h>' header which resides in 'libc' library
return 0;
}
It's time to compile and run it!
info "compiling
Cin a cross-platform manner"I want to compile
Cprogram for various operating systems from one machine, that's why onmacOS M1I use zig drop-in replacement compiler (can be used onLinux,Windowstoo) for cross-platform compilation.
There are also clang, gcc (usually pre-installed onmacOSandLinux). ForWindowsthere are
Visual Studio installer or mingw (which installs gcc).Another important thing to remember is that I compile for
aarch64architecture forM1series of processors, if you useIntelorAMDyou'd need to
compile forx64/x84architecture.
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
Linuxflavours. 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
libcis >implemented across major operating systems. Each row describes the >following:
Operating systemself-explanatoryC standard library (libc)the name by whichlibcis commonly known on each platformDynamic library namethe actual dynamic library file that contains the implementationFunction namean 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
libconWindowsdoesn't supportPOSIX APIanddotnetusesWinAPIinstead,POSIXsupport can be added externally via cygwin, WSL or MinGW.Remember that
dotnetas a platform has been onWindowsfor 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 <stdio.h> // '<stdio.h>' header resides in 'libc' library
#include <unistd.h> // '<unistd.h>' header resides in 'libc' library and it's 'POSIX API'
int main() {
pid_t pid = getpid(); // 'getpid' function is from '<unistd.h>' 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 <stdio.h> // '<stdio.h>' header resides in 'libc' library
#include <windows.h> // '<windows.h>' header resides in 'WinAPI' library
int main() {
DWORD pid = GetCurrentProcessId(); // 'GetCurrentProcessId' function is from '<windows.h>' 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/Linuxthe actual implementation oflibcandPOSIX APIis calledglibcwhile formuslit's called...musl. By the way,muslis mostly POSIX-compliant, not fully. But for the sake of current discussion it doesn't matter, asgetpidwill work on anyLinuxflavour.For the next example, I specifically excluded
musl/Linuxas formuslcompilationstatic linkingis used instead ofdynamic linking. Duringstatic linking, the call togetpidwill 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 <unistd.h> // '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-getpidfunction resides inlibSystem.B.dylibdynamic library - on
gnu/Linux(Debian) -getpidfunction resides in/lib/aarch64-linux-gnu/libc.so.6dynamic 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-likeoperating systems (includingmacOSand variousLinuxflavours:gnu,musl), callsgetpidfunction (which isPOSIX) - for
Windows, callsGetCurrentProcessIdfunction (which isWinAPI) - each
unix-likeoperating system implements its own version ofC Standard Library (libc)and has its own flavour oflibc-glibc(Debian,Ubuntu, etc),musl(Alpine, etc) -
libcandWinAPIlibraries 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.




Top comments (0)