What This Tutorial Covers
In earlier parts we learned how to put a terminal into cbreak mode and raw mode using ttySetCbreak() and ttySetRaw(). Now we look at a complete working program test_tty_functions.c that actually uses those functions. This program shows how to handle signals correctly when the terminal is in a non-canonical mode, and how to safely switch modes when the process is stopped and resumed (SIGTSTP/SIGCONT handling).
Understanding this program is critical because in real-world embedded or system tools (like serial terminal emulators, custom shells, or BLE diagnostic tools), you will run into exactly these problems: how to restore terminal settings when something goes wrong, and how to handle job control signals safely.
The program reads one character at a time from stdin and echoes it back in a modified form:
- Letters โ printed as lowercase
- Newline or carriage return โ printed as-is
- Control characters โ printed as
^A,^B, etc. - Everything else โ printed as
* - Typing
qโ exits the loop
If a command-line argument is given (any argument), it uses cbreak mode. Otherwise it uses raw mode.
else โ raw mode (ttySetRaw)
(SIGINT, SIGQUIT, SIGTSTP, SIGTERM)
The very first thing the program does (inside ttySetCbreak() or ttySetRaw()) is save the current terminal settings into a global variable called userTermios.
This is important. If the program crashes, gets a signal, or exits without restoring settings, the terminal becomes broken โ user sees garbage or can’t type. So the original settings must be saved before any modification, and restored at exit or on any signal.
/* Global: stores what terminal looked like BEFORE our changes */
static struct termios userTermios;
/* ttySetCbreak() or ttySetRaw() fills this BEFORE modifying anything */
if (tcgetattr(STDIN_FILENO, &userTermios) == -1)
errExit("tcgetattr");
The global variable is used by signal handlers. Signal handlers need to restore the terminal before the process exits. If userTermios was a local variable inside main(), the signal handlers could not reach it.
handler()The handler() function is installed for SIGINT (Ctrl+C), SIGQUIT (Ctrl+\), and SIGTERM. All three signals mean “the program should terminate”. So the handler does two things:
- Restore terminal settings using
tcsetattr() - Exit with
_exit()โ notexit()
static void
handler(int sig)
{
/* Restore terminal to the saved original state */
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &userTermios) == -1)
errExit("tcsetattr");
/* _exit() instead of exit() โ avoids running atexit() handlers
or flushing stdio buffers in an async-signal-unsafe context */
_exit(EXIT_SUCCESS);
}
Why _exit() and not exit()? Signal handlers must only call async-signal-safe functions. exit() calls stdio flush routines which are not signal-safe. _exit() is safe โ it terminates immediately without any cleanup.
| Signal | Triggered By | Handler Used | Mode |
|---|---|---|---|
| SIGINT | Ctrl+C | handler() | cbreak only |
| SIGQUIT | Ctrl+\ | handler() | cbreak only |
| SIGTERM | kill command | handler() | both modes |
| SIGTSTP | Ctrl+Z | tstpHandler() | cbreak only |
Why only in cbreak mode? In raw mode, terminal special characters are disabled. Ctrl+C does not generate SIGINT in raw mode โ it arrives as a raw byte (value 3). So there is no need to catch these signals in raw mode.
Also notice: before installing SIGINT and SIGQUIT handlers, the program first checks whether those signals are already being ignored (e.g., the parent shell may have set SIG_IGN). It only installs a handler if the signal is not already ignored. This is standard practice.
/* Check existing disposition before overriding */
if (sigaction(SIGQUIT, NULL, &prev) == -1)
errExit("sigaction");
if (prev.sa_handler != SIG_IGN) /* Only install if not ignored */
if (sigaction(SIGQUIT, &sa, NULL) == -1)
errExit("sigaction");
This is the most complex part of the program. When the user presses Ctrl+Z, the kernel sends SIGTSTP to the foreground process group. Normally this suspends the process. But if our terminal is in cbreak or raw mode at that moment, the terminal settings remain modified even while the process is stopped โ which breaks any other foreground process.
So tstpHandler() does the following sequence:
tcgetattr() โ save our current terminal settings into ourTermiostcsetattr() โ restore user’s original terminal settings (userTermios) so the terminal is clean while we are stoppedSIG_DFL (default), then call raise(SIGTSTP) to actually stop ourselvesโ Process is now suspended here โ
fg)tstpHandler as the SIGTSTP handler (we reset it to SIG_DFL in step 4)userTermios โ user may have changed them while we were stoppedourTermios) so the program continues working correctlystatic void
tstpHandler(int sig)
{
struct termios ourTermios;
sigset_t tstpMask, prevMask;
struct sigaction sa;
int savedErrno;
savedErrno = errno; /* Step 1: save errno */
/* Step 2: save our current terminal settings */
if (tcgetattr(STDIN_FILENO, &ourTermios) == -1)
errExit("tcgetattr");
/* Step 3: restore original user terminal settings */
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &userTermios) == -1)
errExit("tcsetattr");
/* Step 4a: reset SIGTSTP to default */
if (signal(SIGTSTP, SIG_DFL) == SIG_ERR)
errExit("signal");
/* Step 4b: raise SIGTSTP */
raise(SIGTSTP);
/* Step 5: unblock SIGTSTP so the raise() actually stops us */
sigemptyset(&tstpMask);
sigaddset(&tstpMask, SIGTSTP);
if (sigprocmask(SIG_UNBLOCK, &tstpMask, &prevMask) == -1)
errExit("sigprocmask");
/* === PROCESS IS STOPPED HERE ===
execution resumes below after SIGCONT === */
/* Step 7: reblock SIGTSTP */
if (sigprocmask(SIG_SETMASK, &prevMask, NULL) == -1)
errExit("sigprocmask");
/* Step 8: reinstall our handler */
sa.sa_handler = tstpHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if (sigaction(SIGTSTP, &sa, NULL) == -1)
errExit("sigaction");
/* Step 9: re-read terminal settings (user may have changed them) */
if (tcgetattr(STDIN_FILENO, &userTermios) == -1)
errExit("tcgetattr");
/* Step 10: restore our working terminal settings */
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &ourTermios) == -1)
errExit("tcsetattr");
errno = savedErrno;
}
The main function is straightforward once you understand the signal handlers. Here is the full flow:
int
main(int argc, char *argv[])
{
char ch;
struct sigaction sa, prev;
ssize_t n;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if (argc > 1) {
/* ---- CBREAK MODE ---- */
if (ttySetCbreak(STDIN_FILENO, &userTermios) == -1)
errExit("ttySetCbreak");
/* Catch SIGQUIT only if not ignored */
sa.sa_handler = handler;
if (sigaction(SIGQUIT, NULL, &prev) == -1)
errExit("sigaction");
if (prev.sa_handler != SIG_IGN)
if (sigaction(SIGQUIT, &sa, NULL) == -1)
errExit("sigaction");
/* Catch SIGINT only if not ignored */
if (sigaction(SIGINT, NULL, &prev) == -1)
errExit("sigaction");
if (prev.sa_handler != SIG_IGN)
if (sigaction(SIGINT, &sa, NULL) == -1)
errExit("sigaction");
/* Catch SIGTSTP */
sa.sa_handler = tstpHandler;
if (sigaction(SIGTSTP, NULL, &prev) == -1)
errExit("sigaction");
if (prev.sa_handler != SIG_IGN)
if (sigaction(SIGTSTP, &sa, NULL) == -1)
errExit("sigaction");
} else {
/* ---- RAW MODE ---- */
if (ttySetRaw(STDIN_FILENO, &userTermios) == -1)
errExit("ttySetRaw");
}
/* Both modes catch SIGTERM */
sa.sa_handler = handler;
if (sigaction(SIGTERM, &sa, NULL) == -1)
errExit("sigaction");
setbuf(stdout, NULL); /* Disable buffering โ important in raw mode */
/* Read loop */
for (;;) {
n = read(STDIN_FILENO, &ch, 1);
if (n == -1) { errMsg("read"); break; }
if (n == 0) break; /* Terminal disconnected */
if (isalpha((unsigned char) ch))
putchar(tolower((unsigned char) ch)); /* A-Z, a-z โ lowercase */
else if (ch == '\n' || ch == '\r')
putchar(ch); /* Newline/CR as-is */
else if (iscntrl((unsigned char) ch))
printf("^%c", ch ^ 64); /* Ctrl-A โ ^A */
else
putchar('*'); /* Everything else โ * */
if (ch == 'q') /* q exits */
break;
}
/* Restore terminal before exit */
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &userTermios) == -1)
errExit("tcsetattr");
exit(EXIT_SUCCESS);
}
Notice setbuf(stdout, NULL) is called to disable stdout buffering. In raw mode, characters arrive one at a time and we want them echoed immediately. If stdout is line-buffered (the default), output would be delayed until a newline appears.
The ch ^ 64 trick converts a control character to its printable letter: Ctrl+A has value 1, 1 ^ 64 = 65 = 'A', so we print ^A.
Raw Mode Run:
$ stty # terminal is normal (cooked) before we start
speed 38400 baud; line = 0;
$ ./test_tty_functions # no args โ raw mode
abc # type abc and Ctrl+J
def # type DEF, Ctrl+J, Enter
^C^Z # type Ctrl+C, Ctrl+Z (shown as ^C^Z, no signal!)
q$ # type q to exit โ shell prompt on same line
In raw mode, Ctrl+C does not generate SIGINT โ it arrives as ASCII 3, which is a control character, so it prints as ^C. No signal is delivered, no special behavior happens.
Cbreak Mode Run:
$ ./test_tty_functions x # arg given โ cbreak mode
XYZ # type XYZ
^Z # type Ctrl+Z โ SIGTSTP delivered!
[1]+ Stopped ./test_tty_functions x
$ stty # terminal IS restored (handler worked!)
speed 38400 baud; line = 0;
$ fg # resume foreground
./test_tty_functions x
*** # type 123 โ all shown as *
$ # Ctrl+C โ SIGINT โ handler โ terminal restored
$ stty # confirm terminal is normal again
speed 38400 baud; line = 0;
| Concept | Why It Matters |
|---|---|
| Save terminal settings globally | Signal handlers need access to restore on any exit path |
Use _exit() in signal handlers |
exit() is not async-signal-safe |
| Check SIG_IGN before installing handlers | Parent may have ignored signal for a reason โ respect it |
| SIGTSTP needs terminal restore before stopping | Other processes need a working terminal while we are stopped |
| Disable stdout buffering | Characters must appear immediately, not wait for newline |
| In raw mode, no signal handlers needed for Ctrl+C etc. | Terminal special character processing is disabled in raw mode |
| Re-read userTermios after SIGCONT | User might have run stty while we were stopped |
main(). To let the signal handlers restore the terminal, userTermios must be in a scope both main() and the handlers can see โ which means file-scope (global) or static._exit() instead of exit()?exit() calls stdio flush routines and atexit handlers, which are not async-signal-safe and can deadlock or corrupt state if called from a signal handler. _exit() terminates immediately without any library cleanup, so it is safe to call from a signal handler.raise(SIGTSTP) is called, the signal would invoke our handler again (recursion) instead of stopping the process. By first setting the disposition to SIG_DFL, the raised SIGTSTP causes the default action which is to stop the process.tstpHandler() is running, SIGTSTP is blocked. Calling raise(SIGTSTP) while SIGTSTP is blocked delivers the signal but does not stop the process โ the signal is just pending. We must unblock it with sigprocmask(SIG_UNBLOCK) to allow the raised signal to actually take effect and stop the process.setbuf(stdout, NULL) called in this program?setbuf(stdout, NULL) ensures every character is output immediately as it is processed.ch ^ 64 XORs the value with 64 (0x40). For Ctrl+A (value 1): 1 ^ 64 = 65 = 'A'. For Ctrl+B (value 2): 2 ^ 64 = 66 = 'B'. So printf("^%c", ch ^ 64) prints the human-readable representation of any control character.userTermios after resuming?stty to change terminal settings manually, or another program may have changed them. If we kept our old copy of userTermios, restoring it at program exit would put the terminal back to a state that no longer matches what the user expects. Re-reading after resume ensures we always restore to the current “user normal” state.ISIG flag is cleared). This means Ctrl+C does not generate SIGINT and Ctrl+\ does not generate SIGQUIT. These keys arrive as raw bytes (values 3 and 28 respectively). Since no signals are generated from these keys in raw mode, there is no need to install handlers.SA_RESTART in this program?SA_RESTART flag tells the kernel to automatically restart any slow system call (like read()) that was interrupted by the signal, instead of returning with EINTR. Without this flag, the main read loop would need to explicitly check for EINTR and retry the read. With SA_RESTART, the loop is simpler.