/dev/tty1..tty6, Alt+F1..F6 or `chvt N`. Each is an independent terminal --
its own screen, cursor, line discipline, foreground process group and
session. Two shells on two terminals are unrelated processes that share a
keyboard and a screen.
What made it possible is a backing store. The console drew straight into the
framebuffer and scrolled by moving pixels, which works exactly as long as
nothing else wants the screen: there was nowhere to put terminal 2 while
terminal 1 was visible, and nothing to repaint terminal 1 from. Each terminal
owns a grid of cells now and the framebuffer shows whichever grid is active,
so vt_switch() is set-index, repaint, return -- nothing is saved on the way
out because the outgoing grid has been current all along.
The rule the layer is built on: a terminal does not know whether it is
visible. Writes land in the grid, the cursor moves, the grid scrolls, all
unconditionally; drawing is one question asked at the bottom of vt.c. Every
alternative spreads "am I on screen" through the escape parser, and then a
terminal switched away from mid-sequence comes back wrong.
Everything that was a single global is per-terminal now: tty_fg_pgrp (deleted),
termios, the input ring, the escape parser, the UTF-8 decoder, and the cursor
-- which also stops being pixels in gfx.c and becomes cells in the terminal.
TIOCSCTTY was `return 0` and now records proc->ctty, which is what /dev/tty
resolves through; /dev/tty was an alias for /dev/console with the same minor,
and they are different questions.
kbd.c names Alt+F1..F6 as KEY_ALT_F1+n, above 0xFF on purpose -- every byte
value is already a character or a UTF-8 continuation, and a chord colliding
with one would be typed into the terminal instead of switching away from it.
What the chord means is decided in console.c, where the terminals are.
VT_ACTIVATE / VT_WAITACTIVE / VT_GETSTATE / VT_OPENQRY, Linux's numbers.
The argument to ACTIVATE is by value, not a pointer -- same shape as TCFLSH.
Two bugs found by screenshotting the result rather than reading the log:
- zsh emits 0x0F to leave alt-charset mode, because that is what the `linux`
terminfo says rmacs is, and the console drew CP437 glyph 0x0F: a sun, in
the prompt, on every line. A terminal never draws a C0 control character.
- SGR 7 was unimplemented, so zsh's partial-line marker rendered as a bare
'#' instead of a reversed block. Reverse video swaps the colours at draw
time, not at store time, so turning it off restores what was underneath.
Verified by screendump: tty1 and tty2 show different screens, and switching
back repaints tty1's earlier content from its grid. schfs-check passes.
docs/architecture/terminals.md, including what it does not do -- no
scrollback, no reflow, no VT_PROCESS mode for a display server to veto a
switch, which Schcom will need before it owns Alt+F7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
5.4 KiB
Virtual terminals
Six of them, /dev/tty1 through /dev/tty6, reached with Alt+F1..Alt+F6
or with chvt N. Each one is an independent terminal: its own screen, its own
cursor, its own line discipline, its own foreground process group and its own
session. Two shells on two terminals are two unrelated processes that happen to
share a keyboard and a screen.
Alt+F7 is deliberately unused. That is where a display server goes.
What makes switching possible
A backing store, and nothing else.
Before this, the console drew straight into the framebuffer and scrolled by moving pixels. That works exactly as long as nothing else ever wants the screen: there was nowhere to put terminal 2 while terminal 1 was visible, and nothing to repaint terminal 1 from when it came back.
So each terminal owns a grid of cells and the framebuffer shows whichever grid
is active. vt_switch() is three lines — set the active index, repaint from
the grid, return — because nothing has to be saved on the way out. The
outgoing terminal's grid has been current all along.
The rule the whole layer is built on:
A terminal does not know whether it is visible.
Writes land in the grid, the cursor moves, the grid scrolls, all of it
unconditionally. Drawing is a separate question, asked once, at the bottom of
kernel/dev/vt.c, answered by comparing against vt_active. Every alternative
spreads "am I on screen" through the escape parser, and then a terminal
switched away from mid-sequence comes back wrong.
A cell
Four bytes:
ch |
the glyph the font draws — not a codepoint |
fg, bg |
palette indices, 0-15 ANSI plus two theme defaults |
attr |
reverse video; room for underline and the rest |
ch stores the glyph rather than the codepoint because the console maps UTF-8
to CP437 before it gets here, and that is what include/font.h contains.
Storing the codepoint would mean re-running the mapping on every repaint and
getting a different answer for anything the font cannot draw.
The colours are indices rather than RGB because a cell holding two 32-bit colours would be twelve bytes instead of four, and the grids are static: six terminals at 240x75 is 432 KB of BSS, paid once at link time rather than failed to pay at run time. There is no contiguous multi-page allocator to ask.
What is per-terminal
Everything that used to be a single global:
| Was | Now |
|---|---|
int tty_fg_pgrp = 1; |
v->fg_pgrp, one per terminal |
one struct termios tio |
v->tio |
| one 256-byte input ring | v->in |
| one escape-parser state | v->esc_* |
| one UTF-8 decoder | v->u8_* |
one cursor, in pixels, in gfx.c |
v->cx, v->cy, in cells |
TIOCSCTTY used to be return 0 — it claimed to make a terminal the caller's
controlling terminal and did nothing, because there was one terminal and
nothing to choose between. It records proc->ctty now, which is what
/dev/tty resolves through.
The devices
| Path | Minor | What |
|---|---|---|
/dev/console |
1 | the active terminal, so kernel messages follow a switch |
/dev/tty |
10 | the caller's controlling terminal, via proc->ctty |
/dev/tty1..tty6 |
11-16 | that terminal, whether or not it is on screen |
/dev/tty was an alias for /dev/console with the same major and minor. They
are different questions and now have different answers.
The ioctls
Linux's numbers, because they do Linux's job.
VT_ACTIVATE |
switch. Argument is a 1-based number by value, not a pointer |
VT_WAITACTIVE |
returns immediately: vt_switch repaints synchronously, so there is nothing left to wait for |
VT_GETSTATE |
active terminal and a bitmask of those in use |
VT_OPENQRY |
the first terminal nobody has open |
VT_GETMODE/VT_SETMODE |
accepted and ignored — the kernel drives switching and there is no process mode to set |
The keyboard
kbd.c recognises Alt+F1..F6 and returns KEY_ALT_F1 + n, which is above
0xFF on purpose: every value that fits in a byte is already either a character
or a UTF-8 continuation byte, and a chord that collided with one would be typed
into the terminal instead of switching away from it.
What the chord means is decided in console.c, where the terminals are. The
keyboard driver names it and stops.
Input goes to whichever terminal is on screen. That is what makes a terminal a terminal: the keyboard follows the eyes.
What it does not do
- No scrollback. The grid is exactly the visible screen. Shift+PageUp does nothing because there is nothing above the top row to show.
- No reflow on resize. The geometry is read once at
vt_init(). Nothing resizes a framebuffer console today; a compositor will, and that is when this has to grow. - No process-mode switching.
VT_SETMODEis accepted and ignored. A display server that wants to veto a switch — which is whatVT_PROCESSis for — cannot yet, and will need to before Schcom owns Alt+F7. - No terminal past 6. Linux allows 63. Six is what the chords reach and what the grids are sized for.
- Only 240x75 of any screen. Larger framebuffers work; the grid is clamped
and the remainder stays background. Raising
VT_MAXCOLS/VT_MAXROWSis the fix, and costs BSS. - No UTF-8 the font lacks. CP437 has ~256 glyphs. Anything else draws as
?, which is deliberate: drawing a different character would be worse.