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
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.
posix_openpt(O_RDWR) → get master_fd.Then call
grantpt(), unlockpt(), and ptsname() to prepare and locate the slave device.master_fd.The child will set up the slave side and exec the terminal-oriented program.
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.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.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.master_fd → terminal program reads it as stdin.Terminal program writes to stdout → driver reads it from
master_fd.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.
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.
stdin/stdout,
or pipe
to monitor both sides
simultaneously
PTY slave where
bash/vi runs
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.
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().
gcc -o pty_demo pty_demo.c && ./pty_demoYou will get a shell running inside the PTY. Type commands and press Ctrl+D to exit.
/*
* 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;
}
— 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
SSH is the most well-known real-world application of PTYs. Understanding how SSH uses a PTY makes the concept click completely.
sends over encrypted network,
displays remote output
Network
TCP Socket
Holds PTY master fd.
Relays data between
socket and PTY master.
/dev/ptmx
/dev/pts/N
connected to PTY slave.
PTY slave is its
controlling terminal.
Let us trace what happens when you type a command in an SSH session:
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.
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.
/* 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;
}
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:
Renders output on screen.
Holds PTY master fd.
= PTY slave
tty in any terminal window → shows the PTY slave, e.g. /dev/pts/2$ 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
Each terminal window/pane opens one PTY pair.
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.logminicom and picocom use PTYs to give you a terminal interface over serial/UART connections to embedded boards.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.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.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.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.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./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.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.
