SchOS/kernel/core/console.c
Nexoniarz 1c1b0c9570 tty: six virtual terminals, and the plumbing that makes them terminals
/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
2026-09-13 00:08:00 +02:00

637 lines
18 KiB
C

#include <stdarg.h>
#include "types.h"
#include "defs.h"
#include "vt.h"
#include "kbd.h" /* KEY_ALT_F1 -- the terminal-switch chords */
#include "gfx.h"
#include "theme.h"
#include "irq.h"
#include "param.h"
#include "traps.h"
#include "spinlock.h"
#include "sleeplock.h"
#include "fs.h"
#include "file.h"
#include "memlayout.h"
#include "mmu.h"
#include "proc.h"
#include "posix.h"
#include "x86.h"
void consputc(int);
// Set by panic() once the crash screen has been painted: after that, consputc
// spins instead of writing, so nothing else can scribble over the report.
int panicked = 0;
// When set, console output goes to the serial line only and the framebuffer
// is left alone.
//
// **Starts set, on purpose.** The display is the product; the serial line is
// the diagnostics. A machine that boots by printing "pci: 6 devices" and
// "vga: display controller 1234:1111, aperture fd000000" to the screen the
// user is looking at is showing them a developer's log, and that single fact
// is most of what makes an OS look like a hobby project.
//
// Nothing is lost: every one of those lines still goes out the UART, which is
// where tools/bootcheck.ps1 reads them from and where you want them when
// something goes wrong. console_takeover() clears this once userspace is up
// and the display genuinely belongs to the console. The F8 verbose boot never
// sets it, so "show me everything" still works.
int cons_serial_only = 1;
extern void gfx_screen_reset(void);
// Text colour used when drawing to the framebuffer. Changed by SGR escapes
// (see sgr() below); reset to the default by ESC[0m and ESC c.
//
// This was initialised to 7, a leftover from when it was a VGA text-mode
// attribute byte. gfx_putc takes a 32-bit RGB value, so 7 meant "very dark
// blue" -- it looked black against the cream background purely by accident.
static struct {
struct spinlock lock;
int locking;
} cons;
static char digits[] = "0123456789abcdef";
static void
print_int(int64 xx, int base, int sign)
{
char buf[64];
int i;
uint64 x;
if(sign && (sign = (xx < 0)))
x = -xx;
else
x = xx;
i = 0;
do {
buf[i++] = digits[x % base];
} while((x /= base) != 0);
if(sign)
buf[i++] = '-';
while(--i >= 0)
consputc(buf[i]);
}
void
cprintf(char *fmt, ...)
{
va_list ap;
int i, c, locking;
char *s;
va_start(ap, fmt);
locking = cons.locking;
if (locking)
acquire(&cons.lock);
if (fmt == 0)
panic("null fmt");
for (i = 0; (c = fmt[i] & 0xff) != 0; i++) {
if (c != '%') {
consputc(c);
continue;
}
c = fmt[++i] & 0xff;
if (c == 0)
break;
switch(c) {
case 'd':
print_int(va_arg(ap, int), 10, 1);
break;
case 'u':
print_int(va_arg(ap, uint32), 10, 0);
break;
case 'l':
print_int(va_arg(ap, int64), 10, 1);
break;
case 'x':
print_int(va_arg(ap, uint32), 16, 0);
break;
case 'p':
print_int(va_arg(ap, uint64), 16, 0);
break;
case 'b':
print_int(va_arg(ap, uint32), 2, 0);
break;
case 's':
if ((s = va_arg(ap, char*)) == 0)
s = "(null)";
while (*s)
consputc(*(s++));
break;
case '%':
consputc('%');
break;
default:
consputc('%');
consputc(c);
break;
}
}
if (locking)
release(&cons.lock);
}
// Hand the console over to panic().
//
// Two things change. The lock stops being taken, so a panic can report itself
// from anywhere — including from inside a routine that already holds it. And
// output stops going to the framebuffer, because from here on the framebuffer
// belongs to the crash screen: without this the report would scroll straight
// over the panel that is trying to display it.
void
cons_panic_mode(void)
{
cons.locking = 0;
cons_serial_only = 1;
}
// Hand the display over to the text console.
//
// Called once, when userspace is far enough along that the screen should stop
// being a boot splash and start being a terminal. Until this runs the kernel
// has been talking to the serial line only (see cons_serial_only), so this is
// the moment the framebuffer changes owner -- which is why it clears first
// rather than scrolling the shell prompt in underneath the splash.
void
console_takeover(void)
{
if (!cons_serial_only)
return; // verbose boot: the console never left
cons_serial_only = 0;
gfx_screen_reset();
}
#define BACKSPACE 0x100
// ---------------------------------------------------------------------------
// Output: an ANSI escape parser, and UTF-8
//
// The console used to print every byte it was given, which meant every escape
// sequence any program emitted appeared on screen as literal text -- `clear`
// drew "^[[2J^[[H" and left the screen exactly as it found it. Colour, cursor
// addressing, and anything full-screen were all equally impossible.
//
// The serial line does NOT go through any of this. On the other end of it is
// a real terminal emulator that already understands ANSI far better than this
// ever will, so escapes are passed through to it verbatim and interpreted
// only for the framebuffer. Two consumers, two right answers.
// ---------------------------------------------------------------------------
// Output goes to the terminal layer now. The escape parser, the palette, the
// cursor and the UTF-8 decoder all moved to kernel/dev/vt.c, because each of
// them is per-terminal and there used to be exactly one of each.
void
consputc(int c)
{
if (panicked) {
cli();
for(;;) hlt();
}
if (c == BACKSPACE) {
uartputc('\b'); uartputc(' '); uartputc('\b');
if (!cons_serial_only)
vt_putc(vt_current(), '\b');
return;
}
uartputc(c);
if (!cons_serial_only)
vt_feed(vt_current(), c & 0xff);
}
#define C(x) ((x)-'@')
// ---------------------------------------------------------------------------
// Terminals as devices
//
// The line discipline below used to operate on one `struct input` and one
// `struct termios`, because there was one terminal. Both live in struct vt
// now (kernel/dev/vt.c) and everything here takes the terminal it is acting
// on as an argument.
//
// One lock still guards every terminal's input rather than one lock each.
// The rings are only touched from the keyboard interrupt and from read(2),
// both of which are short, and a single lock cannot be acquired in the wrong
// order against itself. Per-terminal locks would buy nothing until there is a
// second input source to contend with it.
// ---------------------------------------------------------------------------
static struct spinlock input_lock;
// Which terminal a device minor names.
//
// 1 /dev/console the active terminal, so kernel messages and a
// shell started on the console both follow a switch
// 10 /dev/tty the caller's controlling terminal
// 11..16 /dev/tty1..6 that terminal, whether or not it is on screen
//
// Anything else is the console, which is what an unrecognised minor meant
// before there was more than one terminal to get wrong.
struct vt *
vt_for_minor(int minor)
{
if (minor >= 11 && minor < 11 + NVT)
return vt_get(minor - 11);
if (minor == 10) {
struct proc *p = myproc();
if (p && p->ctty > 0 && p->ctty <= NVT)
return vt_get(p->ctty - 1);
}
return vt_current();
}
// One output byte to a terminal.
//
// The serial line only hears from the terminal that is on screen. Two
// terminals interleaving into one serial log would be unreadable, and the
// serial console is meant to show what the monitor shows.
static void
vt_out(struct vt *v, int c)
{
if (v == vt_current())
uartputc(c);
if (!cons_serial_only)
vt_feed(v, c & 0xff);
}
void
cons_get_termios(int minor, struct termios *out)
{
struct vt *v = vt_for_minor(minor);
acquire(&input_lock);
*out = v->tio;
release(&input_lock);
}
void
cons_set_termios(int minor, const struct termios *in)
{
struct vt *v = vt_for_minor(minor);
acquire(&input_lock);
v->tio = *in;
// Switching out of canonical mode has to hand over whatever has been typed
// but not yet submitted, or a shell that turns off ICANON mid-line silently
// eats the characters already in the edit buffer.
if (!(v->tio.c_lflag & ICANON))
v->in.w = v->in.e;
release(&input_lock);
wakeup(&v->in.r);
}
// Discard everything typed but not yet consumed: completed lines waiting in
// the buffer and the partial line still being edited. TCFLSH (tcflush) is the
// caller, and a line editor issues one whenever it takes the terminal back --
// after an interrupt, or when a command that read from the tty exits. Without
// it the keys pressed while the last command ran arrive at the next prompt.
//
// A pending EOF goes too: end of input is buffer state like any other, and
// leaving it set would end the *next* read instead of the one it was typed at.
void
cons_flush_input(int minor)
{
struct vt *v = vt_for_minor(minor);
acquire(&input_lock);
v->in.e = v->in.w = v->in.r;
v->in.eof = 0;
release(&input_lock);
}
// Foreground process group, per terminal. This was one global int for the
// whole machine; tcsetpgrp on tty2 now says nothing about tty1, which is the
// entire point of having more than one.
int
cons_fg_pgrp(int minor)
{
return vt_for_minor(minor)->fg_pgrp;
}
void
cons_set_fg_pgrp(int minor, int pg)
{
vt_for_minor(minor)->fg_pgrp = pg;
}
static void
echo(struct vt *v, int c)
{
if (c == '\n' || c == '\r') {
if ((v->tio.c_lflag & ECHO) || (v->tio.c_lflag & ECHONL))
vt_out(v, '\n');
return;
}
if (!(v->tio.c_lflag & ECHO))
return;
// Control characters echo as ^X, the way every other terminal shows them.
if (c < 32 && c != '\t') {
vt_out(v, '^');
vt_out(v, c + '@');
} else {
vt_out(v, c);
}
}
// Rub out the last character of the current line.
static void
erase_one(struct vt *v)
{
if (v->in.e == v->in.w)
return;
v->in.e--;
if (v->tio.c_lflag & ECHOE) {
if (v == vt_current()) { uartputc('\b'); uartputc(' '); uartputc('\b'); }
if (!cons_serial_only)
vt_putc(v, '\b');
}
}
// ---------------------------------------------------------------------------
// Keyboard input
// ---------------------------------------------------------------------------
// Alt+F1..F6 switch terminals. kbd.c reports them as KEY_ALT_F1.. so that the
// decision of what a chord *means* is made here, where the terminals are,
// rather than in a driver that should not know they exist.
int
console_vt_hotkey(int key)
{
if (key < KEY_ALT_F1 || key >= KEY_ALT_F1 + NVT)
return 0;
vt_switch(key - KEY_ALT_F1);
return 1;
}
void
consoleintr(int (*getc)(void))
{
int c;
acquire(&input_lock);
while((c = getc()) >= 0){
if (c == 0)
continue;
// Terminal switching happens before the line discipline sees anything:
// Alt+F2 is not input for the terminal you are leaving.
if (c >= KEY_ALT_F1 && c < KEY_ALT_F1 + NVT) {
release(&input_lock);
console_vt_hotkey(c);
acquire(&input_lock);
continue;
}
// Input goes to whichever terminal is on screen. That is what makes a
// terminal a terminal: the keyboard follows the eyes.
struct vt *v = vt_current();
// Input mapping, before anything looks at the character.
if (v->tio.c_iflag & ISTRIP) c &= 0x7f;
if (c == '\r') {
if (v->tio.c_iflag & IGNCR) continue;
if (v->tio.c_iflag & ICRNL) c = '\n';
} else if (c == '\n' && (v->tio.c_iflag & INLCR)) {
c = '\r';
}
// Signals. These are the characters that must not reach the reader.
//
// Ctrl-C used to do `myproc()->killed = 1` -- and `proc` in an interrupt
// handler is whatever happened to be running on this CPU when the key was
// pressed, which is almost never the program the user is looking at. It
// also set a kill flag rather than raising a signal, so nothing could
// catch it and the shell never learned the job had died. Both wrong; both
// fixed by sending a real signal to the foreground process group -- which
// is now this terminal's group rather than the machine's only one.
if (v->tio.c_lflag & ISIG) {
int sig = 0;
if (c == v->tio.c_cc[VINTR]) sig = SIGINT;
else if (c == v->tio.c_cc[VQUIT]) sig = SIGQUIT;
else if (c == v->tio.c_cc[VSUSP]) sig = SIGTSTP;
if (sig) {
echo(v, c);
v->in.e = v->in.w = v->in.r; // discard the partial line
v->in.abort_read = 1;
release(&input_lock);
sigsend_pgrp(v->fg_pgrp, sig);
acquire(&input_lock);
wakeup(&v->in.r);
continue;
}
}
// Ctrl-P: dump the process table. Not POSIX, but it is the one debugging
// aid that works when everything else has stopped responding.
if (c == C('P')) {
procdump();
continue;
}
if (v->tio.c_lflag & ICANON) {
if (c == v->tio.c_cc[VERASE] || c == C('H')) {
erase_one(v);
continue;
}
if (c == v->tio.c_cc[VKILL]) {
while (v->in.e != v->in.w &&
v->in.buf[(v->in.e-1) % VT_INPUT_BUF] != '\n')
erase_one(v);
continue;
}
if (c == v->tio.c_cc[VWERASE]) {
while (v->in.e != v->in.w &&
v->in.buf[(v->in.e-1) % VT_INPUT_BUF] == ' ')
erase_one(v);
while (v->in.e != v->in.w &&
v->in.buf[(v->in.e-1) % VT_INPUT_BUF] != ' ')
erase_one(v);
continue;
}
if (c == v->tio.c_cc[VEOF]) {
if (v->in.e == v->in.w)
v->in.eof = 1; // empty line: end of input
v->in.w = v->in.e; // otherwise submit what is there
wakeup(&v->in.r);
continue;
}
}
if (v->in.e - v->in.r >= VT_INPUT_BUF)
continue; // buffer full: drop it
// Everything else goes in, including bytes above 127 -- those are UTF-8
// continuation bytes, and the old `c >= 32 && c <= 126` test silently
// refused every non-ASCII character anyone tried to type.
v->in.buf[v->in.e++ % VT_INPUT_BUF] = c;
echo(v, c);
// Canonical mode hands over a line at a time; raw mode hands over
// everything as it arrives.
if (!(v->tio.c_lflag & ICANON) ||
c == '\n' || v->in.e == v->in.r + VT_INPUT_BUF) {
v->in.w = v->in.e;
wakeup(&v->in.r);
}
}
release(&input_lock);
}
int
consoleread(struct inode *ip, uint64 off, char *dst, int n)
{
uint target;
int c;
static uint32 prng_seed = 0x9A7B3C1D;
if (ip->minor == 2)
return 0;
if (ip->minor == 4) {
for (int i = 0; i < n; i++) {
prng_seed ^= prng_seed << 13;
prng_seed ^= prng_seed >> 17;
prng_seed ^= prng_seed << 5;
dst[i] = prng_seed & 0xFF;
}
return n;
}
struct vt *v = vt_for_minor(ip->minor);
iunlock(ip);
target = n;
acquire(&input_lock);
while(n > 0){
while(v->in.r == v->in.w){
if (v->in.eof) {
v->in.eof = 0;
release(&input_lock);
ilock(ip);
return 0; // end of input
}
if (myproc()->killed || v->in.abort_read) {
v->in.abort_read = 0;
release(&input_lock);
ilock(ip);
return -1;
}
sleep(&v->in.r, &input_lock);
}
c = v->in.buf[v->in.r++ % VT_INPUT_BUF];
*dst++ = c;
--n;
// Canonical mode returns a line at a time. Raw mode returns whatever has
// arrived: a program that asked for a keypress must not be made to wait
// for a newline that is never coming.
if (v->tio.c_lflag & ICANON) {
if (c == '\n')
break;
} else {
if (target - n >= (uint)(v->tio.c_cc[VMIN] ? v->tio.c_cc[VMIN] : 1))
break;
}
}
release(&input_lock);
ilock(ip);
return target - n;
}
// Nonzero if a completed input line is waiting -- poll(2)'s POLLIN answer for
// a terminal. Mirrors the r/w cursors consoleread() blocks on.
int
console_input_ready(int minor)
{
struct vt *v = vt_for_minor(minor);
int ready;
acquire(&input_lock);
ready = v->in.r != v->in.w;
release(&input_lock);
return ready;
}
int
consolewrite(struct inode *ip, uint64 off, char *buf, int n)
{
int i;
if (ip->minor == 2 || ip->minor == 3 || ip->minor == 4)
return n;
struct vt *v = vt_for_minor(ip->minor);
iunlock(ip);
acquire(&cons.lock);
if (panicked) {
release(&cons.lock);
ilock(ip);
cli();
for(;;) hlt();
}
for(i = 0; i < n; i++)
vt_out(v, buf[i] & 0xff);
release(&cons.lock);
ilock(ip);
return n;
}
// A descriptor has been opened on a terminal, or the last one closed.
//
// This is the only thing that knows a terminal is in use, which is what
// VT_GETSTATE reports through v_state and what VT_OPENQRY looks for when it
// is asked for a free one. Without it `chvt` says every terminal is idle even
// while two shells are sitting on them.
static void
consoleopen(struct inode *ip)
{
if (ip->minor >= 11 && ip->minor < 11 + NVT)
vt_get(ip->minor - 11)->opened++;
}
static void
consoleclose(struct inode *ip)
{
if (ip->minor >= 11 && ip->minor < 11 + NVT) {
struct vt *v = vt_get(ip->minor - 11);
if (v->opened > 0)
v->opened--;
}
}
// The keyboard handler lives in kbd.c, but the console is what owns the
// keyboard as an input source, so this is where the line gets claimed.
static void
kbd_irq(void *arg)
{
(void)arg;
kbdintr();
}
void
consoleinit(void)
{
initlock(&cons.lock, "console");
initlock(&input_lock, "input");
devsw[CONSOLE].write = consolewrite;
devsw[CONSOLE].read = consoleread;
devsw[CONSOLE].open = consoleopen;
devsw[CONSOLE].close = consoleclose;
cons.locking = 1;
irq_register(IRQ_KBD, kbd_irq, 0, "ps2-kbd", 0);
}