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 inProduction
high-level
decompiledC#
code, implementation details can start to differ, this is not expected to be written directly by a developerlow-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
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
- variousUnix
andUnix-like
operating systems:macOS
,gnu/Linux
(Debian
,Ubuntu
, etc.),musl/Linux
(Alpine
),iOS
,Android
windows
-Windows
only
Decompilation
unix-like
flavour is decompiled onmacOS M1
viaRider
windows
flavour is decompiled onWindows 11
onParallels
viaRider
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 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 dotent
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
dotnet
code 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
-.dylib
Linux
-.so
Windows
-.dll
macOS
-find /usr/local/share/dotnet -name "libSystem.Native.*"
Linux
- via dockerdocker run --rm mcr.microsoft.com/dotnet/runtime:9.0 find / -name "libSystem.Native.*"
Windows
- there is nolibSystem.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 <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-platofrm 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 improtant 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 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 completelly 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
add 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
-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
.