Skip to content

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

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

client-level code
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 - various Unix and Unix-like operating systems: macOS, gnu/Linux (Debian, Ubuntu, etc.), musl/Linux (Alpine), iOS, Android
  • windows - Windows only

Decompilation

  • unix-like flavour is decompiled on macOS M1 via Rider
  • windows flavour is decompiled on Windows 11 on Parallels via Rider
Environment.cs
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;
    }
  }
}
Environment.cs
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;
    }
  }
}

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.

Interop.cs
[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.

Interop.GetPid.cs
[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.

Interop.Libraries.cs
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.

Interop.GetPid.cs
[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.*"
    output
    /usr/local/share/dotnet/shared/Microsoft.NETCore.App/9.0.2/libSystem.Native.dylib
    
  • Linux - 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 on Windows as this shim is used for unix-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

C implementation
// 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.

libc_example.c
#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
    zig cc -o libc_example_macos libc_example.c
    
  • run
    ./libc_example_macos
    
  • output
    This is 'libc' call!
    exp(1) = 2.718282
    
  • 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
    
  • 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
    
  • 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
    

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-explanatory
  • C standard library (libc) the name by which libc is commonly known on each platform
  • Dynamic library name the actual dynamic library file that contains the implementation
  • Function 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.

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;
}
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;
}

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
    zig cc -o get_pid_macos get_pid_unix_like.c
    
  • run
    ./get_pid_macos
    
  • output
    Current Process ID: 67194
    
  • 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
    
  • 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
    
  • 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 ...

Interop.cs
[LibraryImport("kernel32.dll")]
[DllImport("kernel32.dll")]
internal static extern uint GetCurrentProcessId();

... while for unix-like it calls directly into C via getpid.

System.Native/pal_process.c
#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
    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)
    
  • 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 in libSystem.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 (including macOS and various Linux flavours: gnu, musl), calls getpid function (which is POSIX)
  • for Windows, calls GetCurrentProcessId function (which is WinAPI)
  • each unix-like operating system implements its own version of C Standard Library (libc) and has its own flavour of libc - glibc (Debian, Ubuntu, etc), musl (Alpine, etc)
  • libc and WinAPI 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.