Chapter 72: Inter-Process Communication (IPC): Overview of Mechanisms

Chapter Objectives

Upon completing this chapter, you will be able to:

  • Understand the fundamental need for Inter-Process Communication (IPC) in complex embedded Linux systems.
  • Compare and contrast the architecture, performance, and use cases of major Linux IPC mechanisms, including pipes, message queues, shared memory, and sockets.
  • Implement robust IPC solutions in C and Python on a Raspberry Pi 5 using named pipes, shared memory with semaphores, and Unix domain sockets.
  • Analyze system requirements to select the most appropriate IPC mechanism for a given task.
  • Debug common IPC-related issues such as race conditions, deadlocks, and resource leaks.
  • Configure and build multi-process applications that communicate reliably and efficiently.

Introduction

The era of a single, monolithic application controlling all hardware is largely over in modern embedded systems. From industrial controllers and automotive infotainment systems to smart home hubs and medical devices, functionality is increasingly distributed across multiple, independent processes. This modular design philosophy enhances stability, security, and maintainability. If one component fails, it doesn’t necessarily crash the entire system. However, this distribution of tasks introduces a fundamental challenge: how do these independent processes coordinate, share data, and signal one another? The answer lies in Inter-Process Communication (IPC).

IPC is the bedrock of concurrent programming in Linux. It provides a structured set of mechanisms that allow processes, which by default operate in isolated memory spaces, to interact in a controlled manner. Consider a smart security camera. One process might be dedicated to capturing and compressing the video stream from the camera sensor. A second process could be running a machine learning model to detect motion or recognize faces. A third process might handle network communication, streaming video to a user’s phone upon request. For this system to function, the video process must pass compressed frames to both the analysis process and the network process. The analysis process, upon detecting an event, must signal the network process to send an alert. This intricate dance of data and signals is orchestrated entirely through IPC.

This chapter serves as a comprehensive guide to the primary IPC mechanisms available in the Linux kernel. We will move beyond simple theory to explore the architectural trade-offs and practical applications of each method. You will learn not just what these mechanisms are, but why you would choose one over another based on criteria like data volume, speed, synchronization needs, and architectural complexity. Using your Raspberry Pi 5, you will build and run hands-on examples that demonstrate how to implement these powerful tools, transforming a collection of isolated programs into a cohesive, cooperative system.

Technical Background

At the heart of the Linux operating system is the concept of the process. A process is an instance of a running program, complete with its own private virtual address space, file descriptors, and execution context. This isolation is a cornerstone of process management, providing memory protection and ensuring that a misbehaving process cannot arbitrarily interfere with others. While essential for stability, this strict separation necessitates explicit mechanisms for communication. The kernel provides a rich toolkit for this purpose, ranging from simple, unidirectional data streams to high-performance shared data regions and sophisticated message-passing systems. Understanding the design philosophy behind each of these tools is crucial for any embedded systems developer.

IPC Mechanism Quick Comparison

Mechanism Type Speed Overhead Use Case Key Feature
Anonymous Pipe Byte Stream Medium Medium (Kernel buffered) One-way communication between a parent and child process. Simple read()/write() interface; limited to related processes.
Named Pipe (FIFO) Byte Stream Medium Medium (Kernel buffered) Communication between any two processes on the same system. Represented by a file in the filesystem, allowing unrelated processes to connect.
Message Queue Message-based Medium-High Medium (Kernel managed) Exchanging discrete packets or commands between processes. Kernel preserves message boundaries; supports message priorities (POSIX).
Shared Memory Direct Memory Access Fastest Lowest (no kernel intervention after setup) High-bandwidth data sharing (video, sensor data, large buffers). Zero-copy data transfer. Requires manual synchronization (e.g., semaphores).
Semaphore / Mutex Synchronization N/A (Control) Low Protecting shared resources (like shared memory) from race conditions. Atomic operations to enforce mutual exclusion. Not for data transfer.
Unix Domain Socket Stream or Datagram High Low-Medium Flexible client-server architecture on a single host. Bidirectional, connection-oriented communication with a familiar socket API.
Signal Notification Fast (for notification) Very Low Asynchronously notifying a process of an event (e.g., shutdown, child exit). Not for data transfer; acts as a software interrupt.

The Simplest Stream: Pipes and FIFOs

The oldest and arguably simplest form of IPC on Unix-like systems is the pipe. A pipe is a unidirectional, in-memory data channel that connects two related processes, typically a parent and a child. When a process creates a pipe, the kernel allocates a small memory buffer and provides two file descriptors: one for writing and one for reading. Data written to the write end of the pipe is buffered by the kernel until it is read from the read end. This mechanism beautifully extends the Unix philosophy of “everything is a file,” allowing programmers to use standard read() and write() system calls. However, this simplicity comes with a significant limitation: anonymous pipes, as they are known, can only exist between processes that share a common ancestor, as the file descriptors must be inherited through a fork() system call. This makes them unsuitable for communication between arbitrary, unrelated processes.

graph TD
    subgraph Parent Process
        A[Start] --> B("pipe() syscall");
        B --> C{"fork()"};
        C --> D[Parent Execution];
        D --> E("close(pipe_fd[0])");
        E --> F["write(pipe_fd[1], data)"];
    end

    subgraph Child Process
        C --> G[Child Execution];
        G --> H("close(pipe_fd[1])");
        H --> I["read(pipe_fd[0], buffer)"];
    end

    F --> K((Data Stream));
    K --> I;

    classDef primary fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff;
    classDef process fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff;
    classDef decision fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff;
    classDef check fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff;
    classDef stream fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff;

    class A,B primary;
    class C decision;
    class D,G,F,I process;
    class E,H check;
    class K stream;

    linkStyle 0 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 1 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 2 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 3 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 4 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 5 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 6 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 7 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 8 stroke-width:2px,fill:none,stroke:gray,stroke-dasharray: 5 5;
    linkStyle 9 stroke-width:2px,fill:none,stroke:gray,stroke-dasharray: 5 5;

To overcome this limitation, named pipes, or FIFOs (First-In, First-Out), were introduced. Unlike an anonymous pipe, a FIFO is represented by a special file in the filesystem, created with the mkfifo() system call. Once created, any process that has the necessary file permissions can open the FIFO to read from or write to it, just like a regular file. This allows for persistent communication channels between entirely unrelated applications. For example, a shell script could be writing system health metrics to a FIFO, while a separate C application, started independently, reads from that FIFO to display the data on an LCD screen. Data flows in the order it was written, and the kernel handles all the underlying buffering and synchronization, blocking writers if the pipe’s buffer is full and blocking readers if it is empty.

graph TD
    subgraph "Process A (Producer)"
        A1[Start] --> A2{"Open FIFO for Writing<br>"/tmp/my_fifo""};
        A2 --> A3["write(fd, data)"];
        A3 --> A2;
    end

    subgraph "Process B (Consumer)"
        B1[Start] --> B2{"Open FIFO for Reading<br>"/tmp/my_fifo""};
        B2 --> B3["read(fd, buffer)"];
        B3 --> B2;
    end

    subgraph Filesystem
        F["/tmp/my_fifo<br><i>(Special File - 'p')</i>"];
    end

    A2 -- Connects to --> F;
    B2 -- Connects to --> F;
    A3 -- Data written to --> F;
    F -- Data read from --> B3;

    classDef primary fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff;
    classDef process fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff;
    classDef system fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff;

    class A1,B1 primary;
    class A2,A3,B2,B3 process;
    class F system;

While flexible, pipes and FIFOs are fundamentally byte-stream oriented; the kernel sees a continuous flow of bytes, not distinct messages. If a producer writes three separate “messages,” the consumer must have a way to know where one message ends and the next begins, which often requires implementing an application-level protocol.

Structured Communication: Message Queues

For applications that require a more structured, message-based form of communication, message queues offer a significant step up from the simple byte streams of pipes. A message queue is a kernel-persistent linked list of messages, where each message is a discrete block of data with a specific type or priority. Processes can add messages to a queue and retrieve them, without having to be running at the same time. The kernel stores the messages until they are retrieved, making this a powerful tool for asynchronous communication.

Linux supports two primary APIs for message queues: the older System V API and the more modern POSIX API. System V message queues are identified by a system-wide key and offer features like message typing. A process can request a message of a specific type, allowing it to selectively process the queue’s contents rather than being restricted to a strict FIFO order. While powerful, the System V IPC interfaces (which also include semaphores and shared memory) are often considered clunky and non-intuitive by modern standards.

POSIX message queues were designed to address these shortcomings. They are identified by human-readable, slash-prefixed names (e.g., /my_mq) that look like file paths but are not part of the standard filesystem. The API is simpler, based on mq_send() and mq_receive() functions, and it integrates better with other POSIX features. For instance, a process can use select() or poll() to wait for a message to arrive on a queue, or it can request asynchronous notification via a signal or thread creation when a message becomes available. This makes POSIX message queues a robust choice for embedded systems where different components need to exchange discrete commands or data packets, such as a sensor process sending packaged readings to a data logging process.

graph LR
    subgraph Kernel Space
        MQ["POSIX Message Queue<br><i>/my_mq</i>"];
        M1["Message 1<br>(Prio: 10)"];
        M2["Message 2<br>(Prio: 5)"];
        M3["Message 3<br>(Prio: 10)"];
        MQ --- M1;
        MQ --- M2;
        MQ --- M3;
    end

    subgraph User Space
        P1["Process A<br>(Sensor Reader)"] -- "mq_send(msg1)" --> MQ;
        P2["Process B<br>(Network Service)"] -- "mq_send(msg2)" --> MQ;
        P3["Process C<br>(Data Logger)"] -- "mq_receive()" --> M1;
        P4["Process D<br>(UI Display)"] -- "mq_receive()" --> M3;
    end
    
    style MQ fill:#8b5cf6,stroke:#8b5cf6,stroke-width:2px,color:#ffffff
    style M1 fill:#f0f9ff,stroke:#0284c7,stroke-width:1px,color:#1f2937
    style M2 fill:#f0f9ff,stroke:#0284c7,stroke-width:1px,color:#1f2937
    style M3 fill:#f0f9ff,stroke:#0284c7,stroke-width:1px,color:#1f2937
    
    style P1 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style P2 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style P3 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style P4 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff

The Need for Speed: Shared Memory

While pipes and message queues are effective, they both involve data copying and kernel intervention. For every write() operation, data is copied from the process’s user space buffer into a kernel buffer. For every read(), it is copied back out from the kernel buffer to the receiving process’s user space. For high-bandwidth applications, such as real-time video processing or high-frequency sensor data acquisition, this overhead can become a significant bottleneck.

Shared memory is the fastest form of IPC available because it eliminates these copies entirely. The fundamental principle is to map the same region of physical RAM into the virtual address space of multiple processes. Once this mapping is established, processes can read and write to this memory region as if it were their own, using simple pointer operations. The kernel’s involvement is limited to the initial setup; after that, data exchange occurs at the speed of memory access, without any system calls.

This incredible performance, however, comes with a critical responsibility: synchronization. Because multiple processes can access the same memory location concurrently, there is a high risk of race conditions. Imagine one process is in the middle of writing a data structure to shared memory when the kernel preempts it and schedules another process. If the second process tries to read that same data structure, it will see a corrupt, partially updated version. To prevent this, shared memory must almost always be used in conjunction with a synchronization mechanism, such as a semaphore or a mutex, to ensure that only one process is accessing the shared region at any given time. Like message queues, shared memory has both System V and POSIX variants, with the POSIX API (shm_open()mmap()) being the preferred modern choice for its cleaner interface and filesystem-based naming scheme.

The Traffic Cop: Semaphores and Mutexes

Semaphores are not a mechanism for data exchange themselves, but rather a crucial tool for synchronizing access to shared resources, making them an indispensable companion to shared memory. A semaphore is essentially an integer counter managed by the kernel, combined with two atomic operations: wait (often called P or sem_wait) and post (or Vsem_post). The wait operation decrements the semaphore’s value. If the value becomes negative, the calling process is blocked until another process increments it. The post operation increments the value, and if any processes are blocked waiting on the semaphore, one of them is unblocked.

binary semaphore (or a mutex) is a semaphore whose value is restricted to 0 or 1. It functions like a lock or a key to a resource. Before accessing a shared memory segment, a process must acquire the lock by performing a wait operation. If the lock is available (value is 1), the process successfully decrements it to 0 and proceeds. If another process tries to acquire the lock, its wait operation will find the value is 0, and it will be blocked. Once the first process is finished with the shared resource, it releases the lock with a post operation, incrementing the value back to 1 and allowing a waiting process to proceed. This protocol strictly enforces mutual exclusion, preventing data corruption in shared memory.

The Network Analogy: Sockets

For many developers, the term “socket” evokes network communication—TCP/IP connections between a client and a server across the internet. However, the socket API also provides a powerful IPC mechanism for processes on the same machine, known as Unix Domain Sockets (UDS). A Unix domain socket uses the local filesystem for its address space, meaning a socket is identified by a path (e.g., /tmp/my_app.sock) rather than an IP address and port number.

They provide a bidirectional, reliable, and flow-controlled communication channel, behaving much like a network connection but without the overhead of network protocols like TCP or IP. The kernel optimizes data transfer for UDS since it knows the communication is local. This makes them significantly faster than loopback network connections. Furthermore, they provide a client-server model that is more flexible than the simple stream of a named pipe. A server process creates a socket, binds it to a filesystem path, and listens for incoming connections. Client processes can then connect to that socket to establish a private, bidirectional communication channel with the server. This model is ideal for creating modular applications where one central process provides “services” that multiple client processes can consume. For example, a dedicated hardware driver process could run as a server, and various application processes could connect to it as clients to request data or command the hardware.

graph TD
    subgraph " "
        direction LR
        
        subgraph Server Process
            S1["socket()"] --> S2["bind(<i>/tmp/app.sock</i>)"] --> S3["listen()"] --> S4{"accept()"};
        end

        subgraph Client A
            C1A["socket()"] --> C2A["connect(<i>/tmp/app.sock</i>)"];
        end
        
        subgraph Client B
            C1B["socket()"] --> C2B["connect(<i>/tmp/app.sock</i>)"];
        end

        subgraph Client C
            C1C["socket()"] --> C2C["connect(<i>/tmp/app.sock</i>)"];
        end
    end

    C2A -- "Connection Request" --> S4;
    C2B -- "Connection Request" --> S4;
    C2C -- "Connection Request" --> S4;

    S4 -- "Connection 1" --> COM_A((Private<br>Channel A));
    S4 -- "Connection 2" --> COM_B((Private<br>Channel B));
    S4 -- "Connection 3" --> COM_C((Private<br>Channel C));
    
    C2A <--> COM_A;
    C2B <--> COM_B;
    C2C <--> COM_C;

    classDef primary fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff;
    classDef process fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff;
    classDef decision fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff;
    classDef system fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff;

    class S1,S2,S3,C1A,C2A,C1B,C2B,C1C,C2C process;
    class S4 decision;
    class COM_A,COM_B,COM_C system;

Asynchronous Alerts: Signals

Finally, signals are one of the oldest forms of IPC, providing a way to send a simple, asynchronous notification to a process. A signal is a software interrupt, a numeric message sent by the kernel or another process. Every signal has a default action, such as terminating the process (SIGTERM), but a process can install a signal handler—a custom function that is executed when the signal arrives.

sequenceDiagram
    participant P_Sender as Process A (Sender)
    participant Kernel
    participant P_Receiver as Process B (Receiver)

    P_Sender->>Kernel: kill(pid_B, SIGUSR1)
    
    activate Kernel
    Note over Kernel: Kernel delivers signal
    Kernel->>P_Receiver: Interrupt with SIGUSR1
    deactivate Kernel
    
    activate P_Receiver
    Note right of P_Receiver: Normal execution is paused
    
    P_Receiver->>P_Receiver: Execute custom signal_handler()
    
    Note right of P_Receiver: Handler performs a simple task<br>(e.g., sets a flag, reloads config)
    
    P_Receiver-->>P_Receiver: Return from handler
    
    Note right of P_Receiver: Normal execution resumes
    deactivate P_Receiver

Signals are not designed for transferring data; their purpose is to notify a process of an event. For example, the SIGINT (interrupt) signal is sent when you press Ctrl+C in a terminal, and the SIGCHLD signal is sent to a parent process when one of its children terminates. While it’s possible for a process to send a signal to another (using the kill() system call), their use as a general-purpose IPC mechanism is limited. They are best suited for simple event notifications, such as telling a worker process to reload its configuration file or to shut down gracefully. Over-reliance on signals for complex communication can lead to convoluted and difficult-to-debug code, especially due to the constraints on what operations are safe to perform inside a signal handler.

Practical Examples

Theory provides the foundation, but true understanding comes from hands-on implementation. In this section, we will use the Raspberry Pi 5 to build three practical examples, each showcasing a different IPC mechanism and highlighting its ideal use case. We will start with a simple data feed using a named pipe, move to a high-performance data exchange with shared memory, and conclude with a flexible client-server model using Unix domain sockets.

Tip: Before you begin, ensure your Raspberry Pi 5 is running a recent version of Raspberry Pi OS (or another Linux distribution) and that you have the build-essential package installed (sudo apt-get install build-essential) to get the GCC compiler and related tools.

Example 1: Producer-Consumer with a Named Pipe (FIFO)

This example demonstrates a classic producer-consumer pattern. A “producer” process will generate simulated sensor data and write it to a named pipe. A “consumer” process, running independently, will read this data from the pipe and print it to the console. This is a common pattern for decoupling a hardware-facing process from a data-processing or logging process.

Build and Configuration Steps

First, we need to create the named pipe in the filesystem. Open a terminal on your Raspberry Pi and use the mkfifo command.

Bash
# Create a named pipe called 'sensor_pipe' in the /tmp directory
mkfifo /tmp/sensor_pipe

You can verify its creation with ls -l /tmp/sensor_pipe. You will notice the file type is p, indicating a pipe.

Code Snippets

Now, let’s create the two C programs.

producer.c: This program will open the pipe for writing, then loop, sending a simulated temperature reading every two seconds.

C
// producer.c: Writes simulated sensor data to a named pipe.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <time.h>
#include <string.h>

#define FIFO_PATH "/tmp/sensor_pipe"

int main() {
    int fd;
    char buffer[128];
    float temperature;

    // Seed the random number generator
    srand(time(NULL));

    printf("Producer: Opening FIFO for writing...\n");

    // Open the FIFO. This will block until a reader opens the other end.
    fd = open(FIFO_PATH, O_WRONLY);
    if (fd == -1) {
        perror("open");
        return EXIT_FAILURE;
    }

    printf("Producer: FIFO opened. Starting to send data.\n");

    while (1) {
        // Generate a simulated temperature reading (e.g., 20.0 to 25.0 C)
        temperature = 20.0f + (float)rand() / ((float)RAND_MAX / 5.0f);
        
        // Format the data into a string
        snprintf(buffer, sizeof(buffer), "Temperature: %.2f C", temperature);
        
        printf("Producer: Writing '%s'\n", buffer);

        // Write the data to the FIFO
        if (write(fd, buffer, strlen(buffer) + 1) == -1) {
            perror("write");
            close(fd);
            return EXIT_FAILURE;
        }

        // Wait for 2 seconds before sending the next reading
        sleep(2);
    }

    // This part is unreachable in this example, but good practice
    close(fd);
    return EXIT_SUCCESS;
}

consumer.c: This program opens the same pipe for reading and prints any data it receives.

C
// consumer.c: Reads data from a named pipe and prints it.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>

#define FIFO_PATH "/tmp/sensor_pipe"
#define BUFFER_SIZE 256

int main() {
    int fd;
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read;

    printf("Consumer: Opening FIFO for reading...\n");

    // Open the FIFO. This will block until a writer opens the other end.
    fd = open(FIFO_PATH, O_RDONLY);
    if (fd == -1) {
        perror("open");
        return EXIT_FAILURE;
    }

    printf("Consumer: FIFO opened. Waiting for data...\n");

    while (1) {
        // Read data from the FIFO. This call blocks until data is available.
        bytes_read = read(fd, buffer, sizeof(buffer));
        if (bytes_read > 0) {
            // Data was successfully read
            printf("Consumer: Received '%s'\n", buffer);
        } else if (bytes_read == 0) {
            // End-of-file: The writer has closed its end of the pipe.
            printf("Consumer: Writer has closed the pipe. Exiting.\n");
            break;
        } else {
            // An error occurred
            perror("read");
            break;
        }
    }

    close(fd);
    return EXIT_SUCCESS;
}

Build, Flash, and Boot Procedures

1. Compile the code: Open two separate terminal windows. In each, compile one of the source files using GCC.

Bash
# In terminal 1 
gcc producer.c -o producer 
# In terminal 2 
gcc consumer.c -o consumer

2. Run the programs: The order is important here. You must start the process that will be blocked first. In this case, both open() calls will block until the other end is opened.

In terminal 2, start the consumer:

Bash
./consumer


You will see the message “Consumer: Opening FIFO for reading…” and then it will wait.

In terminal 1, start the producer:

Bash
./producer

3. Observe the output: As soon as the producer starts, both programs will unblock. The producer will begin writing data, and the consumer will immediately read and display it.

Producer Output (Terminal 1):

Plaintext
Producer: Opening FIFO for writing...
Producer: FIFO opened. Starting to send data.
Producer: Writing 'Temperature: 22.45 C'
Producer: Writing 'Temperature: 24.12 C'
...

Consumer Output (Terminal 2):

Plaintext
Consumer: Opening FIFO for reading...
Consumer: FIFO opened. Waiting for data...
Consumer: Received 'Temperature: 22.45 C'
Consumer: Received 'Temperature: 24.12 C'
...

To stop the programs, press Ctrl+C in the producer’s terminal. This will close its end of the pipe, causing the read() call in the consumer to return 0, which makes the consumer exit gracefully. Finally, remember to clean up the named pipe: rm /tmp/sensor_pipe.

Example 2: High-Speed Data Exchange with Shared Memory and Semaphores

This example addresses a high-performance use case. A writer process will update a complex data structure in a shared memory segment, and a reader process will access it. We will use a POSIX semaphore to act as a mutex, ensuring the reader never accesses the data while the writer is in the middle of an update.

File Structure and Code

We will create a common header file to define the shared data structure and the names for our IPC objects.

ipc_defs.h:

C
// ipc_defs.h: Common definitions for shared memory and semaphore example.
#ifndef IPC_DEFS_H
#define IPC_DEFS_H

#define SHM_NAME "/shm_sensor_data"
#define SEM_NAME "/sem_data_lock"

// A structure representing complex sensor data
typedef struct {
    int sequence_id;
    double gps_latitude;
    double gps_longitude;
    float accelerometer_xyz[3];
    long timestamp_us;
} sensor_data_t;

#endif // IPC_DEFS_H

shm_writer.c: This program creates the shared memory segment and the semaphore. It then repeatedly writes updated sensor data into the shared structure, locking the semaphore before each write.

C
// shm_writer.c: Creates and writes to a shared memory segment, protected by a semaphore.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <semaphore.h>
#include <time.h>
#include "ipc_defs.h"

void die(const char* msg) {
    perror(msg);
    exit(EXIT_FAILURE);
}

int main() {
    int shm_fd;
    sem_t *sem;
    sensor_data_t *shared_data;
    int seq = 0;

    // Create the shared memory segment
    shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
    if (shm_fd == -1) die("shm_open");

    // Configure the size of the shared memory segment
    ftruncate(shm_fd, sizeof(sensor_data_t));

    // Map the shared memory segment into the process's address space
    shared_data = (sensor_data_t *)mmap(0, sizeof(sensor_data_t), PROT_WRITE, MAP_SHARED, shm_fd, 0);
    if (shared_data == MAP_FAILED) die("mmap");

    // Create the semaphore, initializing it to 1 (unlocked)
    sem = sem_open(SEM_NAME, O_CREAT, 0666, 1);
    if (sem == SEM_FAILED) die("sem_open");

    printf("Writer: Shared memory and semaphore are set up. Press Ctrl+C to exit.\n");

    while (1) {
        // Wait for (lock) the semaphore
        sem_wait(sem);

        printf("Writer: Locked semaphore. Updating data (ID: %d).\n", seq);

        // Update the shared data structure
        shared_data->sequence_id = seq++;
        shared_data->gps_latitude = 34.0522 + (rand() % 1000 / 10000.0);
        shared_data->gps_longitude = -118.2437 + (rand() % 1000 / 10000.0);
        shared_data->accelerometer_xyz[0] = (float)rand() / RAND_MAX;
        shared_data->accelerometer_xyz[1] = (float)rand() / RAND_MAX;
        shared_data->accelerometer_xyz[2] = 9.8f + (float)rand() / RAND_MAX;
        
        struct timespec ts;
        clock_gettime(CLOCK_REALTIME, &ts);
        shared_data->timestamp_us = ts.tv_sec * 1000000L + ts.tv_nsec / 1000L;

        // Simulate some work
        usleep(50000); // 50ms

        printf("Writer: Unlocking semaphore.\n");
        // Post (unlock) the semaphore
        sem_post(sem);

        // Wait before next update
        sleep(1);
    }

    // Cleanup (unreachable in this loop, but important)
    munmap(shared_data, sizeof(sensor_data_t));
    close(shm_fd);
    shm_unlink(SHM_NAME);
    sem_close(sem);
    sem_unlink(SEM_NAME);

    return EXIT_SUCCESS;
}

shm_reader.c: This program opens the existing shared memory and semaphore. It loops, locking the semaphore, reading the data, printing it, and then unlocking the semaphore.

C
// shm_reader.c: Opens and reads from a shared memory segment, using a semaphore for synchronization.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <semaphore.h>
#include "ipc_defs.h"

void die(const char* msg) {
    perror(msg);
    exit(EXIT_FAILURE);
}

int main() {
    int shm_fd;
    sem_t *sem;
    sensor_data_t *shared_data;

    // Give the writer a moment to create the objects
    sleep(1);

    // Open the existing shared memory segment
    shm_fd = shm_open(SHM_NAME, O_RDONLY, 0666);
    if (shm_fd == -1) die("shm_open");

    // Map the shared memory segment
    shared_data = (sensor_data_t *)mmap(0, sizeof(sensor_data_t), PROT_READ, MAP_SHARED, shm_fd, 0);
    if (shared_data == MAP_FAILED) die("mmap");

    // Open the existing semaphore
    sem = sem_open(SEM_NAME, 0);
    if (sem == SEM_FAILED) die("sem_open");

    printf("Reader: Attached to shared memory and semaphore. Press Ctrl+C to exit.\n");

    while (1) {
        // Wait for (lock) the semaphore
        sem_wait(sem);

        // --- Critical Section Start ---
        printf("\n--- Reading Shared Data (ID: %d) ---\n", shared_data->sequence_id);
        printf("  Timestamp: %ld us\n", shared_data->timestamp_us);
        printf("  GPS:       Lat=%.4f, Lon=%.4f\n", shared_data->gps_latitude, shared_data->gps_longitude);
        printf("  Accel:     x=%.3f, y=%.3f, z=%.3f\n",
               shared_data->accelerometer_xyz[0],
               shared_data->accelerometer_xyz[1],
               shared_data->accelerometer_xyz[2]);
        // --- Critical Section End ---

        // Post (unlock) the semaphore
        sem_post(sem);

        // Read at a slightly different interval than the writer
        usleep(750000); // 750ms
    }

    // Cleanup
    munmap(shared_data, sizeof(sensor_data_t));
    close(shm_fd);
    sem_close(sem);

    return EXIT_SUCCESS;
}

Build and Run Procedures

1. Compile the code: POSIX IPC functions require linking with the real-time library (-lrt) and the pthreads library (-lpthread).

Bash
# In terminal 1
gcc shm_writer.c -o shm_writer -lrt -lpthread

# In terminal 2
gcc shm_reader.c -o shm_reader -lrt -lpthread

2. Run the programs: You must start the writer first, as it is responsible for creating the shared memory and semaphore.

In terminal 1, start the writer:

Bash
./shm_writer

In terminal 2, start the reader:

Bash
./shm_reader

3. Observe the output: You will see the writer locking, updating, and unlocking. The reader will periodically lock, read the complete, consistent data, and unlock. The semaphore prevents the reader from ever seeing a half-written data structure.

Warning: It is critical to clean up POSIX IPC objects. If your programs crash, the shared memory segment and semaphore will persist in /dev/shm/. You may need to manually remove them (rm /dev/shm/shm_sensor_data and rm /dev/shm/sem.sem_data_lock) before restarting. A robust application would include signal handlers to ensure cleanup on exit.

Example 3: Service-Based Communication with Unix Domain Sockets

Our final example uses Python to create a more flexible client-server architecture. A server process will act as a “system status service,” and a client can connect to it to request either the system uptime or the current CPU temperature. This demonstrates bidirectional, message-based communication.

sequenceDiagram
    actor client as Client<br>(socket_client.py)
    participant server as Server<br>(socket_server.py)

    Note over server: os.remove(SOCKET_PATH)<br>server.bind(SOCKET_PATH)<br>server.listen()

    client->>server: connect(SOCKET_PATH)
    activate server
    server-->>client: Connection Accepted
    
    Note over client: User runs: python3 client.py 'uptime'

    client->>server: send("uptime")
    
    Note over server: Receives 'uptime', calls get_uptime()
    
    server-->>client: send("up 2 hours, 15 minutes")
    
    client->>server: close()
    deactivate server
    
    Note over client, server: --- New Interaction ---

    Note over client: User runs: python3 client.py 'temp'

    client->>server: connect(SOCKET_PATH)
    activate server
    server-->>client: Connection Accepted
    
    client->>server: send("temp")
    Note over server: Receives 'temp', calls get_cpu_temp()
    server-->>client: send("45.5 C")
    
    client->>server: close()
    deactivate server

Code Snippets

socket_server.py: This server creates a Unix domain socket, listens for connections, and handles client requests.

Python
# socket_server.py: A service providing system status over a Unix domain socket.
import socket
import os
import subprocess

SOCKET_PATH = "/tmp/system_status.sock"

def get_uptime():
    """Returns system uptime string."""
    try:
        return subprocess.check_output(['uptime', '-p']).decode('utf-8').strip()
    except Exception as e:
        return f"Error getting uptime: {e}"

def get_cpu_temp():
    """Returns CPU temperature for Raspberry Pi."""
    try:
        with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f:
            temp_milli_c = int(f.read())
            return f"{temp_milli_c / 1000.0:.1f} C"
    except Exception as e:
        return f"Error getting temp: {e}"

# Make sure the socket does not already exist
if os.path.exists(SOCKET_PATH):
    os.remove(SOCKET_PATH)

server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(SOCKET_PATH)
server.listen(1)

print(f"Server listening on {SOCKET_PATH}")

try:
    while True:
        connection, client_address = server.accept()
        print("Connection from client established")
        try:
            while True:
                data = connection.recv(1024)
                if not data:
                    break # Client disconnected
                
                command = data.decode('utf-8').strip().lower()
                print(f"Received command: '{command}'")

                if command == 'uptime':
                    response = get_uptime()
                elif command == 'temp':
                    response = get_cpu_temp()
                else:
                    response = "Unknown command. Use 'uptime' or 'temp'."
                
                connection.sendall(response.encode('utf-8'))
        finally:
            print("Client disconnected.")
            connection.close()
except KeyboardInterrupt:
    print("\nServer shutting down.")
finally:
    server.close()
    os.remove(SOCKET_PATH)

socket_client.py: This client connects to the server and sends a command provided via the command line.

Python
# socket_client.py: Connects to the status service and sends a command.
import socket
import sys

SOCKET_PATH = "/tmp/system_status.sock"

if len(sys.argv) != 2:
    print(f"Usage: python3 {sys.argv[0]} <command>")
    print("Commands: 'uptime' or 'temp'")
    sys.exit(1)

command_to_send = sys.argv[1]

client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)

try:
    client.connect(SOCKET_PATH)
except socket.error as e:
    print(f"Error connecting to socket: {e}")
    sys.exit(1)

try:
    print(f"Sending command: '{command_to_send}'")
    client.sendall(command_to_send.encode('utf-8'))

    # Wait for the response
    response = client.recv(1024).decode('utf-8')
    print(f"Server response: {response}")

finally:
    print("Closing connection.")
    client.close()

Build and Run Procedures

1. Run the server: In a terminal, start the server process. It will run indefinitely, waiting for clients.

Bash
python3 socket_server.py

2. Run the client: Open one or more new terminals and run the client, passing it a command.

Bash
# In terminal 2
python3 socket_client.py uptime

# In terminal 3
python3 socket_client.py temp

# In terminal 4 - send an invalid command
python3 socket_client.py status

3. Observe the output: The server will log each connection and command. Each client will connect, send its request, receive a tailored response, and then disconnect. This demonstrates a clean, on-demand, service-oriented architecture that is highly extensible.

Common Mistakes & Troubleshooting

Implementing IPC can be complex, and several common pitfalls can trap even experienced developers. Being aware of these issues can save hours of debugging.

Mistake / Issue Symptom(s) Troubleshooting / Solution
Leaked IPC Resources Program fails on second run with “File exists” or “Identifier removed” errors. shm_open() or sem_open() fail unexpectedly. Solution: Implement signal handlers for SIGINT/ SIGTERM to call cleanup functions (shm_unlink, sem_unlink, etc.).
Debug: Manually check and remove files from /dev/shm/ and /tmp/.
Race Conditions Corrupted or incomplete data is read from shared memory. The issue is intermittent and hard to reproduce. Solution: ALWAYS protect shared memory access with a synchronization primitive like a semaphore or mutex. Wrap every read/write block in a lock/unlock sequence (e.g., sem_wait()sem_post()).
Deadlocks Two or more processes hang indefinitely, and the system becomes unresponsive. Both processes are stuck waiting for a resource. Solution: Enforce a strict locking order. If multiple locks are needed, ensure all processes acquire them in the exact same sequence (e.g., always lock Semaphore A before Semaphore B).
Stream vs. Message Reads When using a pipe or socket, a read() call returns only part of a message, or multiple messages merged together. Solution: Implement an application-level framing protocol. Either prefix each message with a fixed-size header containing its length, or use a special delimiter character (e.g., newline) to mark message boundaries. The receiver must buffer and parse accordingly.

Exercises

  1. Bidirectional Communication with FIFOs:
    • Objective: Modify the producer-consumer example from this chapter to allow for bidirectional communication.
    • Guidance: Create two named pipes (e.g., /tmp/client_to_server and /tmp/server_to_client). The original producer will now be a “client” that sends a request and then waits for a response on the second pipe. The original consumer will become a “server” that reads a request from the first pipe, performs a simple calculation (e.g., converts Celsius to Fahrenheit), and writes the result back to the second pipe.
    • Verification: The client should print both the value it sent and the converted value it received from the server.
  2. Shared Memory with a “Ready” Flag:
    • Objective: Enhance the shared memory example to provide more robust synchronization.
    • Guidance: Add a second semaphore to the ipc_defs.h file, let’s call it SEM_DATA_READY. The writer process will behave as before, but after writing the data and unlocking the mutex semaphore (SEM_DATA_LOCK), it will post to the SEM_DATA_READY semaphore to signal that new data is available. The reader process will wait on SEM_DATA_READY before attempting to lock the mutex. This prevents the reader from spinning and repeatedly locking the mutex only to find the same old data.
    • Verification: The reader should now only print new data when it is produced by the writer, rather than reading the same data multiple times. The output should appear more synchronized.
  3. Centralized Logging Service:
    • Objective: Implement a multi-client logging service using Unix domain sockets.
    • Guidance: Write a Python server that listens on a Unix domain socket. When a client connects and sends a message, the server should prepend a timestamp and the client’s process ID (PID) to the message and append it to a log file (e.g., /tmp/app.log). Create a separate Python client program that takes a string from the command line and sends it to the logging server.
    • Verification: Run the server in one terminal. In several other terminals, run multiple instances of the client simultaneously with different messages. The app.log file should contain all messages from all clients, correctly timestamped and in the order the server processed them, without being garbled.

Summary

  • Inter-Process Communication (IPC) is essential for building modular, multi-process embedded applications in Linux, allowing isolated processes to cooperate.
  • Pipes and Named Pipes (FIFOs) provide a simple, byte-stream-oriented channel. Anonymous pipes are for related processes, while FIFOs allow unrelated processes to communicate via the filesystem.
  • Message Queues (POSIX and System V) offer a structured, message-based approach where the kernel manages a list of discrete data packets, ideal for command-and-control scenarios.
  • Shared Memory is the fastest IPC mechanism as it eliminates data copying by mapping the same physical memory into multiple processes. It is ideal for high-bandwidth applications but requires explicit synchronization.
  • Semaphores and Mutexes are synchronization tools, not data channels. They are used to protect shared resources, like a shared memory segment, from concurrent access, thereby preventing race conditions.
  • Unix Domain Sockets provide a powerful, bidirectional, client-server communication model on a single host, offering the flexibility of network sockets without the protocol overhead.
  • Choosing the right IPC mechanism involves a trade-off between performance, complexity, and the type of data being exchanged. The selection should be driven by the specific requirements of the application architecture.

Further Reading

  1. The Linux Programming Interface by Michael Kerrisk. Chapters 44-57 provide an exhaustive and authoritative reference on all major IPC mechanisms.
  2. POSIX Standards Documentation (IEEE Std 1003.1): The official specifications for POSIX IPC functions. Can be found on the Open Group’s website.
  3. Linux Kernel Documentation: The source of truth for how these features are implemented. The Documentation/ directory in the kernel source tree is invaluable. https://docs.kernel.org
  4. LWN.net: An excellent online publication with in-depth articles on Linux kernel development, including detailed explanations of new and existing IPC features. https://lwn.net
  5. Beej’s Guide to Unix IPC: A classic, accessible online tutorial that provides practical examples and clear explanations of various IPC methods.
  6. Advanced Programming in the UNIX Environment by W. Richard Stevens and Stephen A. Rago. A foundational text covering system calls and IPC in great detail.
  7. Raspberry Pi Documentation – The Linux kernel: Official documentation from the Raspberry Pi Foundation regarding the Linux kernel configuration used in Raspberry Pi OS. https://www.raspberrypi.com/documentation/computers/linux_kernel.html

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top