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 the 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 the 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 inProductionhigh-leveldecompiledC#code, implementation details can start to differ, this is not expected to be written directly by a developerlow-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
flowchart TB
subgraph client-level
Environment.ProcessId
end
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,Androidwindows-Windowsonly
Decompilation
unix-likeflavour is decompiled onmacOS M1viaRiderwindowsflavour is decompiled onWindows 11onParallelsviaRider
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;
}
}
}
Implementation details
These are the implementation details, important point here is that it can change between dotnet versions.
Don't expect it to 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.
flowchart TB
subgraph client-level
Environment.ProcessId
end
subgraph high-level
subgraph first-level-decompilation-unix [Environment.cs]
val1["GetProcessId()"]
end
subgraph first-level-decompilation-windows [Environment.cs]
val2["Environment.GetProcessId()"]
end
end
client-level -->|unix-like| first-level-decompilation-unix
client-level -->|windows| first-level-decompilation-windows
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.
// 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();
}
}
// 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();
}
}
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.
flowchart TB
subgraph client-level
Environment.ProcessId
end
subgraph high-level
subgraph first-level-decompilation-unix [Environment.cs]
val1["GetProcessId()"]
end
subgraph first-level-decompilation-windows [Environment.cs]
val2["Environment.GetProcessId()"]
end
subgraph second-level-decompilation-unix [Environment.Unix.cs]
val3["Interop.Sys.GetPid()"]
end
subgraph second-level-decompilation-windows [Environment.cs]
val4["Interop.Kernel32.GetCurrentProcessId()"]
end
end
client-level -->|unix-like| first-level-decompilation-unix
client-level -->|windows| first-level-decompilation-windows
first-level-decompilation-unix --> second-level-decompilation-unix
first-level-decompilation-windows --> second-level-decompilation-windows
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";
}
}
shim explained
In the context of dotnet and system libraries, a shim 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
dotnetcode to run on multiple OSes 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!
dynamic libraries on various operating systems
Various operating systems use different dynamic library extensions:
macOS-.dylibLinux-.soWindows-.dll
- run
- output
- run via
docker - output
- 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.
flowchart TB
subgraph client-level
Environment.ProcessId
end
subgraph high-level
subgraph first-level-decompilation-unix [Environment.cs]
val1["GetProcessId()"]
end
subgraph first-level-decompilation-windows [Environment.cs]
val2["Environment.GetProcessId()"]
end
subgraph second-level-decompilation-unix [Environment.Unix.cs]
val3["Interop.Sys.GetPid()"]
end
subgraph second-level-decompilation-windows [Environment.cs]
val4["Interop.Kernel32.GetCurrentProcessId()"]
end
end
subgraph low-level
direction TB
subgraph third-level-decompilation-windows [Environment.cs]
direction TB
val6["GetCurrentProcessId"]
end
subgraph third-level-decompilation-unix [Interop.GetPid.cs]
direction TB
val7["GetPid"]
end
subgraph forth-level-decompilation-unix [pal_process.c]
direction TB
val8["getpid"]
end
third-level-decompilation-unix --> forth-level-decompilation-unix
end
client-level -->|unix-like| first-level-decompilation-unix
client-level -->|windows| first-level-decompilation-windows
first-level-decompilation-unix --> second-level-decompilation-unix
first-level-decompilation-windows --> second-level-decompilation-windows
second-level-decompilation-windows -->|windows/kernel32.dll| third-level-decompilation-windows
second-level-decompilation-unix -->|"unix/shim (libSystem.Native)"| third-level-decompilation-unix
classDef lowLevelBackground fill:cyan
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!
compiling C in a cross-platform manner
I want to compile C program for various operating systems from one machine, that's why on macOS M1 I use zig drop-in replacement compiler (can be used on Linux, Windows too) for cross-platform compilation.
There are also clang, gcc (usually pre-installed on macOS and Linux). For Windows there are
Visual Studio installer or mingw (which installs gcc).
Another important thing to remember is that I compile for aarch64 architecture for M1 series of processors, if you use Intel or AMD you'd need to
compile for x64/x84 architecture.
- compile
- run
- output
- compile
- run via
docker - output
- compile
- run via
docker - output
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.
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 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.
POSIX support for Windows
Altough, built-in libc on Windows doesn't support POSIX API and dotnet uses WinAPI instead, POSIX support can be
added externally via cygwin, WSL or
MinGW.
Remember that dotnet as a platform has been on Windows 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 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.
#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;
}
#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;
}
Compile and run
Linux: glibc vs musl
For gnu/Linux the actual implementation of libc and POSIX API is called glibc while for musl 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, as getpid will work on any Linux flavour.
For the next example, I specifically excluded musl/Linux as for musl compilation static linking is used instead of dynamic linking.
During static linking, the call to getpid will be directly included into the resulting program so we won't be able to see the actual
dynamic library file whereabouts.
- compile
- run
- output
- compile
- run via
docker - output
- compile
- run via
docker - output
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
- run
otool - output
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.