Pseudoterminals (PTY) How Programs Use PTYs, SSH Architecture,

 

Chapter 64: Pseudoterminals (PTY)
Part 2 — How Programs Use PTYs, SSH Architecture, and Complete Code Examples
fork/exec
PTY Setup Flow
SSH
Real World Example
select()
I/O Multiplexing

What Will You Learn?

In Part 1 we understood what a PTY is and why it exists. Now we will go deeper and understand how programs actually use PTYs in practice. We will cover:

  • The step-by-step process a program follows to set up and use a PTY
  • How SSH uses a PTY to give you a remote shell that feels exactly like a local terminal
  • The relay / bidirectional data flow pattern using I/O multiplexing
  • A complete working C code example of PTY usage with fork and exec

Key Terms in This Tutorial

fork() / exec() setsid() dup2() Relay / Driver Program I/O Multiplexing select() / poll() Session Leader Controlling Terminal sshd posix_openpt() grantpt() / unlockpt() ptsname()

1. How Programs Use a Pseudoterminal — Step by Step

An application that needs a PTY follows a well-defined sequence of steps. Let us walk through each step and understand why it is done, not just what it does.

PTY Setup: Complete Step-by-Step Flow

1
Driver program opens PTY master
Call posix_openpt(O_RDWR) → get master_fd.
Then call grantpt(), unlockpt(), and ptsname() to prepare and locate the slave device.

2
Driver calls fork() to create child process
After fork(), the parent continues as the driver/relay program, keeping master_fd.
The child will set up the slave side and exec the terminal-oriented program.

Child Process performs these steps:
2a
Call setsid() — create new session
This makes the child a session leader in a brand new session, and it loses any existing controlling terminal. This is required so the PTY slave can become the controlling terminal in the next step.
2b
Open the PTY slave device
Call open(slave_name, O_RDWR). Because the child is now a session leader without a controlling terminal, opening the PTY slave device automatically makes it the controlling terminal for the child’s session.
2c
dup2() — connect slave to stdin, stdout, stderr
Use dup2(slave_fd, STDIN_FILENO), dup2(slave_fd, STDOUT_FILENO), dup2(slave_fd, STDERR_FILENO) so the terminal-oriented program’s standard streams go through the PTY slave. Close the original slave_fd after.
2d
exec() — run the terminal-oriented program
Call execl("/bin/bash", "bash", NULL) or exec whatever program is needed. The program now starts with its stdin/stdout/stderr all connected to the PTY slave, and it has a controlling terminal.

Communication is established!
Driver writes to master_fd → terminal program reads it as stdin.
Terminal program writes to stdout → driver reads it from master_fd.
Important note about setsid():
The setsid() call in step 2a is critical. Without it, the child inherits the parent’s session and controlling terminal. If you then open the PTY slave, it would NOT become the controlling terminal because the child already has one. The PTY slave becomes the controlling terminal only when opened by a session leader that has no controlling terminal.

2. The Driver Program as a Relay — Bidirectional Data Flow

Once the PTY is set up, the driver program (the one that holds master_fd) acts as a relay or bridge. It sits between two I/O channels and passes data in both directions.

Driver Program: Bidirectional Relay Pattern

👤
User / Network
another socket,
stdin/stdout,
or pipe

user input
program output

Driver Program
uses select()/poll()
to monitor both sides
simultaneously

input sent to slave
output read from slave

🖥️
PTY Master fd
connected to
PTY slave where
bash/vi runs
The driver must read from both sides at the same time → must use select(), poll(), or epoll() — or two separate threads/processes.

The driver program uses I/O multiplexing so it can react to data arriving from either direction:

  • If data arrives from the user/network → write it to master_fd (goes to terminal program as stdin)
  • If data arrives from master_fd → send it to the user/network (this is the terminal program’s output)

This relay loop is the heart of terminal emulators, SSH servers, and the script command.

3. Complete Working Example — PTY with fork/exec

Here is a complete C program that opens a PTY, forks a child, and runs /bin/sh on the slave side. The parent acts as a simple relay, copying data between your terminal and the PTY master using select().

Compile and run:
gcc -o pty_demo pty_demo.c && ./pty_demo
You will get a shell running inside the PTY. Type commands and press Ctrl+D to exit.
pty_demo.c — Complete PTY fork/exec relay example
/*
 * pty_demo.c
 *
 * Opens a PTY pair, forks a child, runs /bin/sh on the PTY slave.
 * The parent relays data between the real terminal (your stdin/stdout)
 * and the PTY master using select().
 *
 * Compile: gcc -o pty_demo pty_demo.c
 * Run:     ./pty_demo
 */

#define _XOPEN_SOURCE 600   /* needed for posix_openpt, grantpt, unlockpt, ptsname */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <termios.h>
#include <sys/select.h>
#include <sys/wait.h>

#define BUF_SIZE 256

/*
 * put_terminal_raw():
 * Save old terminal settings, then switch terminal to raw mode.
 * In raw mode, characters are sent one at a time without line editing.
 * This is needed so keypresses go directly to the shell in the PTY.
 */
static void put_terminal_raw(struct termios *old_termios)
{
    struct termios new_termios;

    if (tcgetattr(STDIN_FILENO, old_termios) == -1) {
        perror("tcgetattr");
        exit(EXIT_FAILURE);
    }

    new_termios = *old_termios;

    /* cfmakeraw() sets all the flags needed for raw mode */
    cfmakeraw(&new_termios);

    if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &new_termios) == -1) {
        perror("tcsetattr");
        exit(EXIT_FAILURE);
    }
}

/*
 * restore_terminal():
 * Restore the terminal to its saved settings when we exit.
 */
static void restore_terminal(struct termios *old_termios)
{
    tcsetattr(STDIN_FILENO, TCSAFLUSH, old_termios);
}

int main(void)
{
    int master_fd, slave_fd;
    char *slave_name;
    pid_t child_pid;
    struct termios saved_termios;
    char buf[BUF_SIZE];
    ssize_t nread;
    fd_set read_fds;

    /* ---- STEP 1: Open PTY master ---- */

    master_fd = posix_openpt(O_RDWR | O_NOCTTY);
    if (master_fd == -1) {
        perror("posix_openpt");
        exit(EXIT_FAILURE);
    }

    /* Set correct ownership (typically /dev/pts/N owned by calling user) */
    if (grantpt(master_fd) == -1) {
        perror("grantpt");
        exit(EXIT_FAILURE);
    }

    /* Unlock the slave side so we can open it */
    if (unlockpt(master_fd) == -1) {
        perror("unlockpt");
        exit(EXIT_FAILURE);
    }

    /* Get the slave device name, e.g. /dev/pts/3 */
    slave_name = ptsname(master_fd);
    if (slave_name == NULL) {
        perror("ptsname");
        exit(EXIT_FAILURE);
    }

    /* Make a copy since ptsname() returns a static buffer */
    slave_name = strdup(slave_name);
    printf("PTY slave device: %s\n", slave_name);
    fflush(stdout);

    /* ---- STEP 2: fork() ---- */

    child_pid = fork();
    if (child_pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (child_pid == 0) {
        /* ========================================
         * CHILD PROCESS: set up PTY slave side
         * ======================================== */

        /* STEP 2a: setsid() — become session leader in new session.
         * This detaches from any existing controlling terminal. */
        if (setsid() == -1) {
            perror("setsid");
            exit(EXIT_FAILURE);
        }

        /* STEP 2b: Open PTY slave.
         * Since we are now a session leader with no controlling terminal,
         * opening the slave device automatically makes it our controlling terminal. */
        slave_fd = open(slave_name, O_RDWR);
        if (slave_fd == -1) {
            perror("open slave");
            exit(EXIT_FAILURE);
        }

        /* STEP 2c: Redirect stdin, stdout, stderr to the slave */
        if (dup2(slave_fd, STDIN_FILENO)  == -1 ||
            dup2(slave_fd, STDOUT_FILENO) == -1 ||
            dup2(slave_fd, STDERR_FILENO) == -1) {
            perror("dup2");
            exit(EXIT_FAILURE);
        }

        /* Close original slave_fd (we have it as fd 0,1,2 now) */
        if (slave_fd > STDERR_FILENO)
            close(slave_fd);

        /* Child does not need the master */
        close(master_fd);

        free(slave_name);

        /* STEP 2d: exec the terminal-oriented program */
        execl("/bin/sh", "sh", NULL);

        /* If we reach here, exec failed */
        perror("execl");
        exit(EXIT_FAILURE);
    }

    /* ========================================
     * PARENT PROCESS: relay between stdin/stdout and PTY master
     * ======================================== */

    free(slave_name);

    /* Put our terminal in raw mode so keypresses go straight to the shell */
    put_terminal_raw(&saved_termios);

    /*
     * RELAY LOOP:
     * Use select() to watch both stdin (fd 0) and the PTY master.
     * - Data from stdin  → write to master  (user typing → goes to shell)
     * - Data from master → write to stdout  (shell output → shown to user)
     */
    while (1) {
        FD_ZERO(&read_fds);
        FD_SET(STDIN_FILENO, &read_fds);  /* watch keyboard input */
        FD_SET(master_fd,    &read_fds);  /* watch PTY master output */

        /* select() blocks until one or both become readable */
        if (select(master_fd + 1, &read_fds, NULL, NULL, NULL) == -1) {
            perror("select");
            break;
        }

        /* Keyboard input available? → send to PTY master (becomes shell stdin) */
        if (FD_ISSET(STDIN_FILENO, &read_fds)) {
            nread = read(STDIN_FILENO, buf, BUF_SIZE);
            if (nread <= 0)
                break;
            if (write(master_fd, buf, nread) != nread) {
                perror("write to master");
                break;
            }
        }

        /* PTY master has data? → write to our stdout (shell output to screen) */
        if (FD_ISSET(master_fd, &read_fds)) {
            nread = read(master_fd, buf, BUF_SIZE);
            if (nread <= 0)
                break;  /* shell exited */
            if (write(STDOUT_FILENO, buf, nread) != nread) {
                perror("write to stdout");
                break;
            }
        }
    }

    /* Restore terminal settings before we exit */
    restore_terminal(&saved_termios);

    close(master_fd);

    /* Wait for the child (shell) to exit */
    waitpid(child_pid, NULL, 0);

    printf("\nPTY demo ended.\n");
    return 0;
}
What this program demonstrates:
— posix_openpt / grantpt / unlockpt / ptsname — the four PTY master functions
— setsid() to create new session before opening slave
— dup2() to redirect stdin/stdout/stderr to the slave
— select()-based relay loop to handle bidirectional data flow
— Raw terminal mode so your keystrokes pass through to the shell inside the PTY

4. Real World Example: How SSH Uses a PTY

SSH is the most well-known real-world application of PTYs. Understanding how SSH uses a PTY makes the concept click completely.

SSH Architecture with Pseudoterminal

LOCAL HOST
👤
User at Real Terminal
/dev/tty0 or /dev/pts/X
stdin / stdout / stderr
ssh client
reads your keystrokes,
sends over encrypted network,
displays remote output

Encrypted
Network
TCP Socket
→→
←←

REMOTE HOST
sshd (ssh server)
Driver program.
Holds PTY master fd.
Relays data between
socket and PTY master.
PTY Master
/dev/ptmx
PTY Slave
/dev/pts/N
Kernel PTY line discipline
login shell (bash)
stdin/stdout/stderr
connected to PTY slave.
PTY slave is its
controlling terminal.
Result: bash on the remote host thinks it is running on a real terminal. All interactive features work perfectly — command history, Ctrl+C, vi, top, etc.

Let us trace what happens when you type a command in an SSH session:

1
You press a key on your local keyboard → ssh client reads it from your local terminal
2
ssh client encrypts the character and sends it over the TCP socket to sshd on the remote host
3
sshd decrypts the character and writes it to the PTY master fd
4
The kernel PTY line discipline processes it (echo, line editing, Ctrl+C → SIGINT) and delivers it to the bash process reading from the PTY slave
5
bash writes its output (command result) to the PTY slave (its stdout)
6
sshd reads the output from the PTY master fd, encrypts it, sends back to ssh client
7
ssh client decrypts the output and writes it to your local terminal — you see the result
Note about sshd being a concurrent server:
The actual sshd daemon runs as a background daemon with a passive TCP listening socket. For each incoming SSH connection, it forks a child sshd process that handles that one client session end-to-end (authentication, PTY setup, shell exec, relay loop). Multiple simultaneous SSH sessions each get their own child sshd and their own PTY pair.

5. PTYs Between Arbitrary Processes (Not Just Parent/Child)

So far we have seen PTY used between a parent and child process created with fork(). But PTYs can also connect any two unrelated processes.

The only requirement is that the process opening the PTY master must somehow tell the other process the name of the slave device (/dev/pts/N). This can be done by:

  • Writing the slave name to a file that the other process reads
  • Sending it over a pipe, socket, or shared memory
  • Passing it as a command-line argument when launching the other process

When using fork(), this is easy because the child inherits the parent’s memory, so it can call ptsname(master_fd) itself. For unrelated processes, an explicit communication step is needed.

Passing slave name between arbitrary processes via command-line argument
/* process_a.c: opens master, launches process_b with slave name as argument */
int main(void)
{
    int master_fd;
    char *slave_name;
    char slave_copy[64];

    master_fd = posix_openpt(O_RDWR | O_NOCTTY);
    grantpt(master_fd);
    unlockpt(master_fd);

    slave_name = ptsname(master_fd);
    strncpy(slave_copy, slave_name, sizeof(slave_copy) - 1);

    /* Tell process_b the slave device name via argv[1] */
    /* process_b will open it as its terminal */
    execlp("process_b", "process_b", slave_copy, NULL);

    /* process_a keeps master_fd and acts as relay */
    return 0;
}

/* process_b.c: receives slave name, opens it, becomes a terminal program */
int main(int argc, char *argv[])
{
    int slave_fd;

    if (argc < 2) {
        fprintf(stderr, "Usage: process_b /dev/pts/N\n");
        exit(1);
    }

    /* Create new session (must do this to make PTY slave controlling terminal) */
    setsid();

    /* Open the slave device given to us by process_a */
    slave_fd = open(argv[1], O_RDWR);

    dup2(slave_fd, STDIN_FILENO);
    dup2(slave_fd, STDOUT_FILENO);
    dup2(slave_fd, STDERR_FILENO);
    if (slave_fd > STDERR_FILENO) close(slave_fd);

    /* Now run any terminal-oriented program */
    execl("/bin/sh", "sh", NULL);
    return 0;
}

6. How Your Terminal Emulator Uses PTYs

Every time you open a terminal window (GNOME Terminal, xterm, Konsole, alacritty, etc.), the terminal emulator creates a PTY pair. This is exactly the same mechanism as SSH, just running locally:

Terminal Emulator PTY Architecture

⌨️🖥️
User Input/Output
Keyboard & Screen

Terminal Emulator
(GNOME Terminal, xterm)
Renders output on screen.
Holds PTY master fd.
PTY Master
PTY Slave

💻
bash / zsh
stdin/stdout/stderr
= PTY slave
Run tty in any terminal window → shows the PTY slave, e.g. /dev/pts/2
Quick check — verify you are on a PTY slave right now
$ tty
/dev/pts/2

$ ls -la /dev/pts/
total 0
drwxr-xr-x  2 root   tty      0 Jun 18 10:00 .
drwxr-xr-x 22 root   root     0 Jun 18 09:58 ..
crw--w----  1 ravi   tty 136,0 Jun 18 10:05 0
crw--w----  1 ravi   tty 136,1 Jun 18 10:06 1
crw--w----  1 ravi   tty 136,2 Jun 18 10:07 2
c---------  1 root   root  5,2 Jun 18 09:58 ptmx

# Each number (0, 1, 2) is an active PTY slave = one open terminal window
# /dev/pts/ptmx is the per-directory clone of /dev/ptmx

7. Common Applications That Use PTYs
🖥️ Terminal Emulators
xterm, GNOME Terminal, Konsole, alacritty, kitty, tmux, screen.
Each terminal window/pane opens one PTY pair.
🔒 SSH / Telnet
sshd uses a PTY on the remote host to provide the remote shell session. The SSH client relays characters between your local terminal and the remote PTY.
📜 script(1) command
The script command records everything in a terminal session to a file. It opens a PTY, forks a shell on the slave, and sits on the master recording all I/O. Try: script session.log
🤖 Expect / pexpect
Automation tools that drive interactive programs (ftp, passwd, telnet). They use a PTY to send input and read output from programs that require a terminal.
🐛 GDB / debuggers
IDEs use PTYs to give the debuggee (the program being debugged) a proper terminal for interactive input while keeping the debugger in control.
📱 Serial Consoles
Tools like minicom and picocom use PTYs to give you a terminal interface over serial/UART connections to embedded boards.

Interview Questions — PTY Usage and SSH
Q1: Describe the complete sequence of steps to set up a PTY using fork().
1. Parent calls posix_openpt(), grantpt(), unlockpt(), ptsname() to open PTY master and find slave name.
2. Parent calls fork().
3. Child calls setsid() to start new session (becomes session leader, loses controlling terminal).
4. Child opens the PTY slave device — it becomes the controlling terminal automatically.
5. Child calls dup2(slave_fd, 0), dup2(slave_fd, 1), dup2(slave_fd, 2).
6. Child closes extra fds and calls exec() to start the terminal program.
7. Parent keeps master_fd and uses select() to relay data bidirectionally.
Q2: Why must the child call setsid() before opening the PTY slave?
setsid() creates a new session and makes the calling process the session leader. It also causes the process to lose its controlling terminal. The PTY slave device becomes the controlling terminal of a session leader only when the session leader has no existing controlling terminal. If setsid() is not called, opening the slave device will not make it the controlling terminal, and terminal-oriented programs will not receive job-control signals correctly.
Q3: Why does the driver program need I/O multiplexing (select/poll) in the relay loop?
The driver must simultaneously watch two file descriptors: the PTY master and the other I/O channel (network socket, stdin, etc.). Data can arrive from either direction at any time. A blocking read on one fd would block the other direction. Using select() or poll() lets the driver wait for whichever fd becomes readable first, then handle that data, without blocking the other direction. An alternative is to use two threads — one for each direction.
Q4: In an SSH session, who is the driver program and who is the terminal-oriented program?
On the remote host: sshd (a child process forked by the master sshd daemon) is the driver program — it opens the PTY master and relays data between the TCP socket and the PTY master. The login shell (bash) is the terminal-oriented program — it is connected to the PTY slave with its stdin/stdout/stderr, and the PTY slave is its controlling terminal.
Q5: What is the purpose of grantpt() and unlockpt()?
grantpt(master_fd) — sets the ownership and permissions of the slave device correctly (ownership changed to calling user, permissions typically 620) so the process can open it.
unlockpt(master_fd) — removes an internal lock on the slave device. The slave is locked by default when the master is opened, to prevent someone from opening it before the master is properly set up. You must call unlockpt() before opening the slave.
Q6: How does the script(1) command use a PTY?
script opens a PTY pair, forks a child, and execs a shell on the PTY slave. The parent holds the PTY master and acts as a relay: it copies data from its own stdin to the master (going to the shell), and copies data from the master (shell output) both to its stdout (your screen) and to a log file. This records a complete transcript of the terminal session without modifying the shell or any programs run inside it.
Q7: Can PTYs be used between processes that are not parent and child? How?
Yes. Any two processes can use a PTY. The only requirement is that the process holding the PTY master must communicate the slave device name (e.g., /dev/pts/3) to the other process. This can be done via a file, pipe, socket, shared memory, or command-line argument. The second process then calls setsid(), opens the slave, and proceeds normally.
Q8: What command shows you the PTY slave your current shell is connected to?
The tty command. It prints the pathname of the terminal device connected to stdin, typically something like /dev/pts/2. You can also check ls /dev/pts/ to see all active PTY slave devices on the system — each one represents an open terminal window, SSH session, or similar connection.

Chapter 64 Overview Complete!

You now understand pseudoterminals from first principles — what they are, why they exist, how to set them up in C, and how SSH uses them. More sections of Chapter 64 cover advanced PTY I/O details, the BSD PTY API, and complete application examples.

← Part 1: PTY Introduction EmbeddedPathashala Home

Leave a Reply

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