EXOTIC SILICON
“Feel my pins, but don't exploit me!”
Implementing new kernel devices to support accessibility
Join Crystal Kolipe for a wild ride today that takes us through a mixture of kernel and userspace code!
Our starting point is tactile terminals, as she first implements a proof of concept for native support of these devices on OpenBSD, then has a look at implementing something similar to the vcs family of devices found on Linux.
Not content with that, she proceeds to invent her own display buffer read-out device and in the course of adding new features to it not only re-invents TIOCSTI, but also unexpectedly uncovers yet another bug lurking in the wsdisplay code.
The framebuffer console on OpenBSD stores the displayed data in two different ways:
  • Character data
  • Bitmap data
Each character is represented as a bitmap in the video memory and also as part of an array of unicode codepoints. In many cases, this second representation is essentially just ASCII text stored in 32 bits, so nothing complicated there.
The text representation is used to re-draw the graphical framebuffer when scrolling, to implement copy and paste with the mouse, and a few other things. Being able to copy the text out character by character was also the basis for my text mode screendump program.
But it has another potential use which would be supporting tactile terminals.
A quick primer for readers who are unfamiliar with tactile terminals
This hardware is most commonly used by visually impaired users.
Essentially, all tactile terminals have some kind of mechanism for creating raised bumps on an otherwise flat surface. Most commonly, this is achieved with rows of pins on individual actuators, but other mechanisms also exist. The contents of the display can be changed and updated analogously to a video display, and the surface bumps can then be read as text using the fingers, by anybody who is skilled in the art of such reading. Usually the text is output as braille, although other representations of text using raised dots do exist and could also be supported. In common usage, these terminals are often referred to as ‘braille displays’.
Specific considerations:
Screen size
It's rare to be using a visual terminal that's smaller than 80×25, and often they are much larger. In contrast, a braille terminal will almost always be significantly smaller, especially in terms of the number of rows. Typically a device might only offer two lines of 80 characters. In a dedicated system where software is specifically designed to output braille, this isn't so much of a limitation.
However, most regular command line utilities will happily send several lines of text to the console at once, especially in the case of error output. Without some way to scroll back, this output would be lost. Full screen programs such as those which use the curses library and those that simulate a graphical environment in text mode are yet another step further away from being usable on such a small display.
Connected to a regular system, a typical tactile terminal might just about be usable for simple tasks at the shell prompt if we were happy emulating a dumb glass TTY with only horizontal cursor positioning, but beyond that things pretty much stop working.
Just imagine trying to operate your OpenBSD machine using an 80×2 character LCD display. Even with scrollback support, reading the output of ifconfig or netstat is going to be, let's say, a challenge.
Text alignment
Text is often presented in tables, with columns that are supposed to line up. Whilst this potentially translates fairly well to a tactile display, perhaps better than using a screen reader to access the same information, there are caveats. Depending on the exact configuration, character set that needs to be displayed, hardware limitations, and user preferences, there is no guarantee of a one to one relationship between printed characters and braille cells.
So an arbitrary 80 characters can't always be guaranteed to fit on an 80 cell braille terminal, and more generally, without further processing text layout on some lines might not correspond to the layout on previous lines.
A visual analogy might be using a proportionally spaced font for your terminal display. It's certainly possible, but most current software isn't designed to expect a terminal to have varying maximum line lengths, or for the same column on successive rows not to be at the same horizontal offset.
With these two problems addressed, a tactile display becomes minimally usable for many tasks. Without further functionality, the user experience will still be fairly poor, but it's a reasonable starting point.
In simple terms, most software continues to format it's output for a regular visual terminal, and the tactile terminal lets the user move around that output and read it line by line.
A visual analogy, for those readers old enough to remember them, would be using a microfiche reader. You can enlarge a small part of the otherwise unreable text to make it readable, and move around the various pages on the microfiche to read all of the content, but you can't see the content of the entire fiche at the same time.
# export TERM=?? 
Unfortunately, not that simple!
Readers of this article with a background in unix-like operating systems may well naïvely assume that getting one of these devices to work on OpenBSD would be as simple as connecting it to a serial port, (or in modern times, a USB port where it can happily present itself as a serial device), and maybe needing to configure a custom termtype.
Unfortunately, this is not the case and the reality is somewhat more complex. Several of the specific reasons for this have been explained above, but the overall upshot is that we need some additional and non-trivial software on the host machine in order to convert the output from typical text-mode applications to a suitable form for a braille terminal. In practice, since we need to read arbitrary bits of screen data from memory, this is usually going to involve both kernel and userland components.
This is all quite interesting from a technical point of view, and since I've recently been working on the wscons code, I decided to implement a simple proof of concept on OpenBSD.
A native kernel-side proof of concept
Clearly, the core functionality that we need to implement is the ability to copy out a small area of the current visual display from the screen memory to elsewhere, and to control whereabouts on the visual display that we are copying from.
Interfacing with a real hardware tactile terminal would in most cases require device-specific driver code, and since most people reading this article won't have access to the real hardware anyway, we'll stick to simulating this by passing the screen data we read out to a userland program which just displays it on another terminal, or outputs it as some kind of audio tones.
Since we want to operate this ‘copy out’ code independently of any userspace programs that are running, and possibly even when not logged in, it makes sense to implement it directly in the kernel. That way we can easily intercept certain keypresses, and we also have full access to the state of the display including the scroll back buffer.
Another slight benefit is that we only need to copy out the minimum of screen content to the userspace process, rather than letting it have arbitrary access to the entire display.
The keyboard controls will be implemented in wskbd.c as new command keys, similar to the way that the VT switching keys, brightness adjustment hot keys and so on work. This will involve defining some new keysyms in wsksymdef.h and assigning them to various keys in the keyboard map by editing wskbdmap_mfii.c.
Our copying code will live in /sys/dev/wscons/wsdisplay.c, and will make it's output available via a new device file, which we'll call /dev/copy. Like all of the devices implemented in wsdisplay.c, it'll have major number 12. Although we could basically pick any minor number that isn't already in use, it seems logical to use 254 since ttys are allocated sequentially from zero upwards, whereas the ttyCcfg device is at minor number 255. Growing new device numbers downwards from there seems most likely to avoid clashes with existing assignments.
Pedantic note
Device minor numbers
Strictly speaking, the explanation above is a simplified version of the actual minor number allocation scheme which assumes that your system only has a single wsdisplay device.
If your system actually has two or more wsdisplay devices then the second and subsequent units, (the wscons code calls refers to this as a ‘unit’ number), will use special device files with minor numbers greater than 255. The second unit, (typically wsdisplay1), will be /dev/ttyD* with a configuration device /dev/ttyDcfg, and so on.
Basically, the unit number is stored in the high bits of the minor device number.
If we wanted to fully support multiple units with independent viewpoints and access permissions this would need to be taken into consideration. To keep the example code easy to follow, we'll just ignore it.
Making the device node for our new device is simple enough:
# mknod -m 640 /dev/copy c 12 254
Since this device will allow any user with access to it to read whatever screen data we copy from the console, it shouldn't be made world readable.
At the same time, we don't necessarily want to run all of our userland support code as root. We can avoid the need to do this by giving group read permissions to /dev/copy, and setting the group ownership to that of a trusted but non-privileged user, or we could just create a brand new user specifically to run the userland components.
We'll need a total of five new keysyms, four directions and a copy key. We'll add them after the existing command keysyms which are defined in group 4, and as of OpenBSD 7.3 the last keysym defined in this group is KS_Cmd_KbdReset at 0xf42e, so our new ones can be added to wsksymdef.h as follows:
#define KS_Cmd_CopyUp 0xf42f
#define KS_Cmd_CopyDown 0xf430
#define KS_Cmd_CopyLeft 0xf431
#define KS_Cmd_CopyRight 0xf432
#define KS_Cmd_CopyAction 0xf433
These newly defined keysyms can now be assigned to any convenient keys in the keyboard map file wskbdmap_mfii.c. On the test machine, I used the four keys at the top right of the main keyboard, namely minus/underscore, equals/plus, and the opening and closing square brackets, for up, down, left, and right, and assigned zero to CopyAction.
Moving on to the actual code, we'll store the location of the ‘viewpoint’ that we're copying from as a simple pair of row and column values, which we can store in two global variables copy_x and copy_y. We'll also need a pointer to a buffer that we will allocate to store a copy of the data until it's read out by userspace. Shortly we'll introduce a couple of flags and a struct proc pointer, but for the key processing code that's all we'll need.
So right at the beginning of wskbd.c we add three lines:
unsigned char copy_x;
unsigned char copy_y;
unsigned char * copy_buffer;
Note: as globals, copy_x and copy_y will automatically be initialized to zero.
The code to actually process the keys goes in the large switch (ksym) statement at the end of the function internal_command. The code for the directional keys is trivial:
We check and clamp attempts to move before row zero or column zero here, but allow the values to increase arbitrarily as we are going to bounds check them within the actual copying code.
case KS_Cmd_CopyUp:
if (copy_y > 0) {
copy_y--;
}
return (1);
case KS_Cmd_CopyDown:
copy_y++;
return (1);
case KS_Cmd_CopyLeft:
if (copy_x > 0) {
copy_x--;
}
return (1);
case KS_Cmd_CopyRight:
copy_x++;
return (1);
The copying code itself will be added to wsdisplay.c, so for the CopyAction keysym we basically just have a call to an as-yet unseen function, although we also allocate memory for copy_buffer if it hasn't already been allocated:
case KS_Cmd_CopyAction:
if (copy_buffer == NULL) {
copy_buffer = malloc(4096, M_DEVBUF, M_NOWAIT);
if (copy_buffer == NULL) {
return (1);
}
}
copy_to_copy_buffer(sc->sc_displaydv, copy_buffer, copy_x, copy_y);
return(1);
And of course we add a reference to the external function in global scope, which can go immediately after the new variables we defined earlier:
extern int copy_to_copy_buffer();
At this point, we have all the code that we'll need for the basic hotkey handling. This lets us move our viewpoint around and trigger a call to the copying function.
As mentioned above, the actual copying function is implemented in wsdisplay.c. First we'll import the new variables we defined in wskbd.c, and define three more, two flags and a pointer to a struct proc, which we'll use later to send a signal to the userland process.
int flag_copy_open;
int flag_copy_ready;
struct proc * copy_proc;
extern unsigned char copy_x, copy_y;
extern unsigned char * copy_buffer;
These definitions are added to the beginning of wsdisplay.c in global scope.
Our new function is short, and can be conveniently added right at the end of wsdisplay.c:
int copy_to_copy_buffer(struct device * dev, unsigned char * copy_buffer, unsigned char copy_x, unsigned char copy_y)
{
struct wsdisplay_softc * mydev;
struct wsdisplay_charcell cell;
int pos;
int i;
mydev=(struct wsdisplay_softc *) dev;
copy_x = copy_x % N_COLS(mydev->sc_focus->scr_dconf);
copy_y = copy_y % N_ROWS(mydev->sc_focus->scr_dconf);
pos=copy_x + copy_y * N_COLS(mydev->sc_focus->scr_dconf);
*copy_buffer=copy_x;
*(copy_buffer+1)=copy_y;
for (i=0; i < 80; i++) {
if (copy_x + i < N_COLS(mydev->sc_focus->scr_dconf)) {
GETCHAR(mydev->sc_focus, pos+i, &cell);
*(copy_buffer+i+2)=cell.uc;
} else {
*(copy_buffer+i+2)=0;
}
}
flag_copy_ready=1;
if (flag_copy_open==1) {
psignal (copy_proc, SIGIO);
}
return(0);
}
This is pretty straightforward stuff, we cast the supplied dev pointer to a struct wsdisplay_softc pointer so that we can use the N_COLS and N_ROWS macros to get the current screen size. We need this to avoid reading past the end of the display or wrapping from the end of one line to the next.
The display co-ordinates that have been set up by the keyboard handling code are reduced modulo the display width and height, (remember that they were only lower bound checked before), and written to the first two bytes of the copy buffer. We then proceed to read out up to 80 characters of the specified row of the display, substituting 0x00 bytes for any positions that would extend past the end of the visible screen.
Important note
Reducing 32 bits to 8 bits
Note that the above version of the code reduces the 32-bit unicode codepoint value from cell.uc to an unsigned char by simply discarding the high bits.
This isn't generally a good idea, and in fact we'll see later on how a similar issue in the exising kernel code could potentially be a vector for an exploit. However the only reason for making copy_buffer an array of unsigned char rather than an array of uint32_t in the first place is to make the example code easier to follow, (including the userspace code that we'll see shortly). Any practical application of this code should really be passing the original 32-bit codepoint values through the /dev/copy device and out to userspace.
Nevertheless, in practice just reducing the codepoint value modulo 256 won't cause any problems for most systems since none of the console fonts in the base system include glyphs for codepoints above 255 anyway, so even in UTF-8 mode where we can set character cells to higher codepoints, the display code will replace them with a question mark character, ASCII 0x3f.
We could easily do the same substitution in our code:
*(copy_buffer+i)=(cell.uc > 255 ? '?' : cell.uc);
But unless you're using a custom font with extra glyphs, the above code is a no-op.
Note that the kernel psignal function is completely different from the userland library function of the same name.
Finally, we check the value of flag_copy_open and if it's set then we use the kernel psignal function to send SIGIO to a userland process specified in copy_proc. Both the flag and copy_proc will be set by the next piece of code that we look at which is called when a userland process opens /dev/copy.
This signaling mechanism allows us to avoid the need for the userland process to periodically poll /dev/copy to check for new data. However, we only want to send the signal at all if a process actually has the device open, hence the need to check the flag, (otherwise copy_proc would contain nonsensical values).
To implement the new /dev/copy device, we need to add code to the wsdisplayopen, wsdisplayclose, wsdisplayread, and wsdisplaywrite functions.
Open
At the top of wsdisplayopen, we add the following:
if (minor(dev)==254) {
if (flag_copy_open==1) {
return (EPERM);
}
flag_copy_open=1;
copy_proc=p;
return(0);
}
Here we check flag_copy_open and return immediately with EPERM if it's already set. This ensures that only one process can read from the copy device at a time, but this flag also has the secondary function that we saw above in the copy_to_copy_buffer code.
If it's not already set, we set it and then also assign the supplied value p to copy_proc. This is a struct proc * and contains, amongst other things, the PID of the calling process, in other words the userland process that is opening the /dev/copy device. We'll use this later on to send a signal from the kernel to userspace.
Close
The device closing code to add to wsdisplayclose is even simpler:
if (minor(dev)==254) {
flag_copy_open=0;
return (0);
}
Nothing special here, this just resets the flag.
Read
The read code can also be quite simple, as we don't need to support seeking or partial reads.
if (minor(dev)==254) {
if (flag_copy_ready==0) {
return (0);
}
uiomove(copy_buffer, 82, uio);
flag_copy_ready=0;
return (0);
}
Any attempt to read data from /dev/copy will first check flag_copy_ready. This will have been set by copy_to_copy_buffer if there is fresh data to read out, when that function is triggered by whichever command key has been assigned KS_Cmd_CopyAction. If copy_to_copy_buffer hasn't run, or we've already copied the data out and therefore reset the flag, then we just return without sending any data, in other words an immediate EOF rather than an error condition.
The data format is very simple, two bytes containing the cursor position immediately followed by the 80 bytes of character data in the copy buffer.
Write and mmap
The last pieces of kernel code we need are simple stubs. Firstly for wsdisplaywrite:
if (minor(dev)==254) {
return (ENXIO);
}
If we didn't include this then the kernel would happily wander off into the code below and quickly crash with a uvm fault trying to access an element of array sc_scr beyond it's defined size of WSDISPLAY_MAXSCREEN.
Then also similar same code at the start of wsdisplaymmap to prevent a similar crash in case somebody tries to mmap our new device:
if (minor(dev)==254) {
return (-1);
}
Testing the new device.
Once we've re-compiled and rebooted in to the kernel, we can easily test our new device.
If we try to access it before copying anything, we get no output. In this case it's essentially the same as reading from /dev/null.
# dd if=/dev/copy
0+0 records in
0+0 records out
0 bytes transferred in 0.000 secs (0 bytes/sec)
If we now hit control-alt-zero, or whichever combination we've assigned to KS_Cmd_CopyAction, then the next read of /dev/copy will give us whatever was on the top line of the display. If we're using the first virtual terminal, this will probably be one of the boot messages.
# hexdump -C /dev/copy
00000000 00 00 77 73 64 69 73 70 6c 61 79 30 3a 20 73 63 |..wsdisplay0: sc|
00000010 72 65 65 6e 20 31 2d 31 31 20 61 64 64 65 64 20 |reen 1-11 added |
00000020 28 73 74 64 2c 20 76 74 31 30 30 20 65 6d 75 6c |(std, vt100 emul|
00000030 61 74 69 6f 6e 29 20 20 20 20 20 20 20 20 20 20 |ation) |
00000040 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 | |
00000050 20 20 | |
00000052
We can clearly see the row and column indicies output before the text data. Of course, if you've been moving the viewpoint around with the new direction keys you'll have non-zero values here.
Subsequent reads from /dev/copy will return no data again, until we send another KS_Cmd_CopyAction keystroke.
Userland support code
Obviously we didn't implement this new device just to read it with hexdump.
A very basic readout program might look something like this:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/select.h>
int fd;
unsigned char * buffer;
void signal_io()
{
read(fd, buffer, 82);
*(buffer+82)=0;
printf ("row %d, column %d : >> %s <<\n", *(buffer+1), *buffer, buffer+2);
}
int main()
{
struct fd_set fds;
if ((fd=open("/dev/copy", O_RDONLY))==-1) {
printf ("Failed to open /dev/copy\n");
return (1);
}
pledge ("stdio", NULL);
buffer=malloc(83);
if (buffer==NULL) {
return (1);
}
printf ("Copy process initialized using file descriptor %d\n", fd);
FD_ZERO(&fds);
FD_SET(fd, &fds);
signal (SIGIO, &signal_io);
printf ("Sleeping...\n");
while (1) {
select(1, &fds, NULL, NULL, NULL);
}
}
With this program running on one VT, we can switch to another VT, move the viewpoint to the desired location, hit copy, and voilà!
Switching back to the first VT we see the copied text automatically displayed.
Whilst the implementation might be fairly rudimentary at this point, we do have most of the essential ingredients to operate a tactile terminal.
Since switching back and forth between VTs just to see the copied text isn't very interesting, let's try converting the data to some kind of audio representation.
Morse code output
One possible audio representation of the text data is, of course, morse code.
We can easily extend the program above to output the contents of the copy buffer as morse code using the regular system sound device.
First we include three more header files, define constants for the sample rate and dit duration, and add a new global variable sd to hold the sound device descriptor.
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/select.h>
#include <sndio.h>
#include <string.h>
#include <math.h>
#define SAMPLE_RATE 48000
#define DIT (SAMPLE_RATE/15)
int fd;
unsigned char * buffer;
struct sio_hdl * sd;
Next, a new function that we'll call to initialize the sound device and set the basic parameters such as sample rate and bit depth.
struct sio_hdl * sound_init()
{
struct sio_hdl * sd;
struct sio_par params;
sd=sio_open(SIO_DEVANY, SIO_PLAY, 0);
if (sd==NULL) {
return (NULL);
}
sio_initpar(&params);
params.bits=8;
params.bps=1;
params.sig=0;
params.le=0;
params.msb=0;
params.pchan=1;
params.rate=SAMPLE_RATE;
if (sio_setpar(sd, &params)==0) {
sio_close(sd);
return (NULL);
}
return (sd);
}
Now we have another short new function that will output count samples of either silence, (when type is set to zero), or a sine wave, (if type is non-zero), to the sound output buffer.
It also keeps track of our current position in the output buffer by updating the supplied offset pointer.
int sound_output_samples(unsigned char * buffer, unsigned int * offset, unsigned int count, unsigned char type)
{
int i;
for (i=0; i<count; i++) {
*(buffer+(*offset)+i)=(type==0 ? 128 : 128+64*sin((double)i/10));
}
*offset+=count;
return (0);
}
At this point we can add the main bulk of the actual new code.
This is a two-step process for each character, as we first encode it in to morse code symbols then convert those to a sine wave suitable to output as 8-bit mono audio at the rate defined above as the constant SAMPLE_RATE.
Consecutive spaces are also collapsed here to a single space to avoid long periods of silence in the output.
int sound_output(unsigned char * buffer, int len)
{
unsigned char * morse_table[256] = {
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
"", NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
"-----", ".----", "..---", "...--", "....-", ".....", "-....", "--...", "---..", "----.", "---...", "-.-.-.", NULL, "-...-", NULL, "..--..",
".--.-.", ".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", "..", ".---", "-.-", ".-..", "--", "-.", "---",
".--.", "--.-", ".-.", "...", "-", "..-", "...-", ".--", "-..-", "-.--", "--..", NULL, NULL, NULL, NULL, "..--.-",
NULL, ".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", "..", ".---", "-.-", ".-..", "--", "-.", "---",
".--.", "--.-", ".-.", "...", "-", "..-", "...-", ".--", "-..-", "-.--", "--..", NULL, NULL, NULL, NULL, NULL,
};
unsigned char * sound_buffer;
struct sio_hdl * sd;
int flag_space;
int inpos;
int symbol;
int offset;
int i;
for (i=0; i<256; i++) {
if (morse_table[i]==NULL) {
morse_table[i]=morse_table['?'];
}
}
sound_buffer=malloc(8*1024*1024);
offset=0;
flag_space=1;
#define CHAR *(buffer+inpos)
#define MCHAR morse_table[CHAR]
#define SYMBOL (*(MCHAR+symbol))
for (inpos=0; inpos<len; inpos++) {
for (symbol=0; symbol < strlen(MCHAR); symbol++) {
sound_output_samples(sound_buffer, &offset, (SYMBOL=='.' ? 1 : 3) * DIT, 1);
sound_output_samples(sound_buffer, &offset, DIT, 0);
}
sound_output_samples(sound_buffer, &offset, (CHAR == ' ' ? (flag_space==0 ? 7*DIT : 0) : 2*DIT), 0);
flag_space=(CHAR == ' ' ? 1 : 0);
}
sio_write(sd, sound_buffer, offset);
free(sound_buffer);
return (0);
}
We just need to add one extra line to our signal_io function to call sound_output().
void signal_io()
{
read(fd, buffer, 82);
*(buffer+82)=0;
printf ("row %d, column %d : >> %s <<\n", *(buffer+1), *buffer, buffer+2);
sound_output(buffer+2, 80);
}
Finally, the main function is modified to call sound_init() once at startup, and the pledge call is expanded to include the audio pledge as well as stdio.
int main()
{
struct fd_set fds;
sd=sound_init();
if (sd==NULL) {
return (1);
}
if (sio_start(sd)==0) {
sio_close (sd);
return (1);
}
if ((fd=open("/dev/copy", O_RDONLY))==-1) {
printf ("Failed to open /dev/copy\n");
return (1);
}
pledge ("stdio audio", NULL);
buffer=malloc(83);
[...]
When compiling after making the above modifications, remember to include the sndio and math libraries:
$ cc -O3 -lsndio -lm copy_morse.c
Now that we have all of this new code in place, hitting the KS_Cmd_CopyAction key will reproduce the copied text as morse code through the default system sound device.
Not using sndio?
The example code above is intended to be used on systems that are running the sndiod daemon, (which is the default configuration on an OpenBSD system). This assumption guarantees that the audio stream parameters that our code requests, (48 Khz, 8-bit unsigned mono), will be granted. It also permits multiplexed access by several programs to each of the system sound devices.
In this configuration, there should be few or no issues with other programs running as the same user that want to play audio at the same time as morse code is being output by the copy program.
(Actually, technically sharing is possible between all programs that have mutual access to the sndiod session cookie that was used by the first program to open the sound device, rather than being strictly based on user account.)
If for some reason you are not running sndiod, but instead accessing the raw device, then you may want to modify the code in one or more of the following ways:
Or just use sndio!
Auditory aesthetics
Sine wave generation
The sound_output_samples() function which actually generates the sine wave always starts it from the mid-point, (in other words sample value 0x80), and ends it abruptly at whatever value it happened to be at when the specified number of samples had been output.
When outputting silence, the same function always outputs 0x80 bytes.
The upshot of this is that we get an audible ‘click’ at the end of each sinewave pulse. Personally, I actually quite like this effect as it simulates the sound of manual morse keying using a simple oscillator circuit.
In case this is not to your taste, though, it can easily be avoided by either modifying the program to artificially extend the pulses until they naturally fall closer to the centre point, or alternatively keeping track of the current ‘resting’ position of the wave, using that when inserting silence, and starting each new pulse from there.
Code to implement the first option might look something like this:
if (type==1) {
if (i==(count-1) && abs((int)(64*sin((double)i/10))) > 15) {
count++;
}
}
Modem-like sound output
Whilst the morse code output is useful, it does have the limitation that we can't distinctly represent all character values, (unless we added some non-standard sequences of symbols to cover such cases).
An alternative which would allow us to represent arbitrary byte sequences is to generate a waveform where each bit of the input is converted to one of two possible square wave tones, in other words Frequency Shift Keying, or FSK. If we add some start and stop bits then we're basically re-inventing old analogue modem technology from the 1960s, (more modern analogue modem standards typically used other non-FSK modulations).
At relatively slow bit rates it's not too difficult to recognise certain characters by ear, so we don't actually have to implement an existing modem standard and can instead just pick tones that ‘sound nice’.
Here are the required code changes to produce the ‘modem tones’ version, presented against the original text-only output program.
We don't need the string and maths libraries this time, although we obviously still need to include sndio.h.
There are a few new defines, SAMPLE_RATE as before, and some others to define the frequencies of the tones as well as the bit rate of the output, (in other words, the speed of the bits we're feeding in, rather than the bytes of audio data).
As before, we also add a new global variable sd to hold the sound device descriptor.
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/select.h>
#include <sndio.h>
#define SAMPLE_RATE 48000
#define BPS 20
#define SAMPLES_PER_BIT (SAMPLE_RATE / BPS)
#define TONE2
#ifdef TONE1
#define MARK 15
#define SPACE 25
#endif
#ifdef TONE2
#define MARK 24
#define SPACE 32
#endif
int fd;
unsigned char * buffer;
struct sio_hdl * sd;
The sound_init function is exactly the same as it was for the morse code output program.
struct sio_hdl * sound_init()
{
struct sio_hdl * sd;
struct sio_par params;
sd=sio_open(SIO_DEVANY, SIO_PLAY, 0);
if (sd==NULL) {
return (NULL);
}
sio_initpar(&params);
params.bits=8;
params.bps=1;
params.sig=0;
params.le=0;
params.msb=0;
params.pchan=1;
params.rate=SAMPLE_RATE;
if (sio_setpar(sd, &params)==0) {
sio_close(sd);
return (NULL);
}
return (sd);
}
Unsurprisingly, the sound_output function is completely different.
The two #ifdef's VISUALISE_SERIAL_DATA and SILENCE_STOP_BIT are explained in detail below.
int sound_output(unsigned char * buffer, int len)
{
int inpos;
int i;
int bit;
static int polarity=0;
unsigned char * sound_buffer;
sound_buffer=malloc(SAMPLES_PER_BIT * 10 * len);
if (sound_buffer==NULL) {
return (1);
}
inpos=0;
for (inpos=0; inpos<len; inpos++) {
for (bit=0; bit<10; bit++) {
#ifdef VISUALISE_SERIAL_DATA
printf ("%x",( ((((*(buffer+inpos))<<1) | 0x200) >> bit) & 1));
#endif
for (i=0; i < SAMPLES_PER_BIT; i++) {
*(sound_buffer+(inpos * 10 + bit) * SAMPLES_PER_BIT + i) = (polarity == 1 ? 160 : 96);
#ifdef SILENCE_STOP_BIT
if (bit==9) {
*(sound_buffer+(inpos * 10 + bit) * SAMPLES_PER_BIT + i) = 128;
}
#endif
if ( (i % ( ( ((((*(buffer+inpos))<<1) | 0x200) >> bit) & 1)==1 ? MARK : SPACE)) ==0) {
polarity=1-polarity;
}
}
}
#ifdef VISUALISE_SERIAL_DATA
printf (" ");
#endif
}
#ifdef VISUALISE_SERIAL_DATA
printf ("\n");
#endif
sio_write(sd, sound_buffer, len * SAMPLES_PER_BIT * 10);
free (sound_buffer);
return (0);
}
Finally, we have the same changes to signal_io() and main(), that we made for the morse code program.
void signal_io()
{
read(fd, buffer, 82);
*(buffer+82)=0;
printf ("row %d, column %d : >> %s <<\n", *(buffer+1), *buffer, buffer+2);
sound_output(buffer+2, 80);
}
int main()
{
struct fd_set fds;
sd=sound_init();
if (sd==NULL) {
return (1);
}
if (sio_start(sd)==0) {
sio_close (sd);
return (1);
}
if ((fd=open("/dev/copy", O_RDONLY))==-1) {
printf ("Failed to open /dev/copy\n");
return (1);
}
pledge ("stdio audio", NULL);
buffer=malloc(83);
printf ("Copy process initialized using file descriptor %d\n", fd);
FD_ZERO(&fds);
FD_SET(fd, &fds);
signal (SIGIO, &signal_io);
printf ("Sleeping...\n");
while (1) {
select(1, &fds, NULL, NULL, NULL);
}
}
Compile as before, only this time we don't need the math library:
$ cc -O3 -lsndio copy_modem.c
Now, tapping the KS_Cmd_CopyAction key lets us enjoy the sound of pseudo modem tones representing our screen data.
Features
Data format
The data is output as 8N1, with one start bit, eight data bits, one stop bit, no partity, and the least significant bit sent first.
Compile time options
There are various options that you can set at compile time.
The speed of the output can be controlled by changing BPS. If you are trying to decode the output by ear, reducing the default of 20 to 10 or even 5 might help.
The individual frequencies for the mark and space tones are defined by MARK and SPACE, although these are not expressed in Hz, but rather as dividers of the sample rate. Two sets are provided by default, selectable by defining TONE1 or TONE2, but custom values for MARK and SPACE can also be set.
Defining VISUALISE_SERIAL_DATA will cause the program to output groups of ten binary digits separated by spaces. This display is intended to assist in following the audio tones by ear. Each group of ten bits represents an eight bit character, preceeded by a start bit, and followed by a stop bit. The bits are displayed with the least significant bit first, (in other words, in the opposite order to that which the corresponding binary number would usually be written). Reading from left to right, and ignoring spaces, (which are inserted solely to improve the visual layout), this matches the order in which the bits are played as audio tones. Note that this textual representation of the audio tones is not affected by the SILENCE_STOP_BIT define described below.
Defining SILENCE_STOP_BIT will cause the stop bit to be emitted as silence in the audio output, rather than the MARK tone. This is intended purely to assist decoding of the audio data by ear, as a real serial line or modem link will almost always rest at the MARK state. Note that this does not affect the textual output generated by VISUALISE_SERIAL_DATA, and if that option is defined than the stop bits will be displayed as ‘1’ characters irrespective of the setting of SILENCE_STOP_BIT.
Alternative approaches
The code I've presented so far is really intended as an explainer of how we might implement a text copy out device, and how to pass signals from the kernel to a userland program. It also does a good job of showing how, in just under 100 lines of kernel code, we could in principle add support for this class of output device directly to the OpenBSD kernel in a compact and integrated way.
This is all quite theoretical, though, and certainly not a practical solution to using actual tactile terminals on an OpenBSD system right now, today.
That can of course be done indirectly, by accessing a shell on an OpenBSD system from another machine which does have support for the hardware. In practice, that is often a Linux system, as comprehensive software for using tactile terminals along with support for modern devices is available on that platform.
Part of the difficulty of porting this software to run natively on OpenBSD is that Linux provides a set of devices that give userland easy and convenient access to a copy of the video memory data in various forms, and this is pretty much the go to method for doing this kind of stuff on that platform. OpenBSD doesn't have any analogous devices exposed to userspace, and the approach used in the past to gain access to the character data when running on OpenBSD was to use a patched copy of the screen utility.
This is a pure userspace solution, and as such obviously suffers from various limitations. Most notably, it can only really be used within an existing login session - you can't, at least not easily, just have it running on a raw VT and log in and out as different users.
Needless to say, a device file that allows access to the current screen contents is a potential security issue. On a Linux system where the device exists anyway, it makes some sense to use it. On the other hand, introducing such a device to OpenBSD systems where it wouldn't normally be found would certainly require careful thought in order to avoid unexpected mis-use.
Nevertheless, we might as well see how it would be done and whether this would get us closer to being able to port existing Linux-based software to OpenBSD.
Overview of the vcs devices in Linux
The family of devices in question is usually present on Linux systems as character devices /dev/vcs*, using major number 7. There is a separate device for each virtual console using the lower six bits of the corresponding minor number, and zero is additionally used to represent the currently active console, (since VTs are numbered starting with 1 on Linux systems).
vcs class devices in Linux
Typical nameMinor numberDescription
vcs*0-63Character data only, as 8-bit values
vcsa*64-127Character data plus attributes, in 16 bits
vcsu*128-191Character data only as 32-bit unicode codepoints
192-255Currently unused
Originally there were two classes of vcs device, vcs and vcsa, which were introduced in kernel 1.1.92. The vcs devices present the character data as a continuous set of 8-bit values with nothing else, similar to the way my text mode console screendump program reads character data out on OpenBSD. So there are no newlines, there is no attribute data, and so on.
The vcsa devices, (which use minor numbers with bit 7 set, so starting at 128), provide the attribute data interleaved with the character values, as well as a short four-byte header that includes the cursor position and screen size, but they are otherwise similar in concept.
Note that the ‘8-bit values’ I just mentioned are not guaranteed to correspond to ASCII codes or unicode codepoints. They are actually indicies to the currently loaded font. In the days of the VGA text mode console it could at least mostly be assumed that any 7-bit values would have almost certainly been ASCII, but this is no longer necessarily the case on modern installations.
A mechanism exists on Linux systems to translate the font glyph indicies back to unicode, so software can easily get the character values it wants and unless several codepoints have been mapped to the same glyph this is more of an inconvenience than a problem. In any case, since the character store on OpenBSD uses unicode values, we don't really have to worry about any of this, and our values will just be regular ASCII and ISO-8859-1 character values anyway.
More recently, in 2018, new devices were added to the Linux kernel which provide the character data directly as unicode codepoints. These devices are usually named vcsu*, and have bit 6 set in the minor number, so they range from 64 - 127. Unfortunately, the vcsu devices don't provide attribute data.
Whilst it would seem logical to have used the 192 - 255 minor number range, (bits 6 and 7 set), to implement a further class of device that enabled the readout of unicode data along with attributes, as of the time of writing no such functionality is present in the mainline Linux kernel.
Implementing similar devices in the OpenBSD kernel
We'll look at a sample implementation of each of the three classes of device that we've already discussed, as well as an additional custom one with more features.
The device special files will use the following major and minor numbering plan on OpenBSD:
Our vcs-like devices on OpenBSD
NameMajor numberMinor numberDescription
/dev/vcs12253Character data only, as 8-bit ASCII / ISO 8859-1 values
/dev/vcsa12252Character data plus attributes, in 16 bits
/dev/vcsu12251Character data only as 32-bit unicode codepoints
/dev/vcse12250Character data as unicode codepoints, plus attributes
The minor numbers are allocated from 253 downwards, so the following code can happily co-exist with the ‘copy’ device that used minor 254.
Note that we are only implementing one device of each type, which will operate on whichever VT currently has the focus. Although we could more closely reproduce the setup that exists in Linux, with a set of otherwise identical devices one for each VT identified by the lower bits of the device's minor number, doing so would increase the complexity of the example code for little benefit.
Warning!
Time wasting
Before we look at some code for a sample implementation of these devices, I should point out that this is a fairly pointless exercise.
If it wasn't already clear just from the description two sections above, this interface to the display subsystem is very specific to Linux. The main upshot here is that userland code which uses it is also likely to freely use other Linux specific interfaces, having been written with the perhaps reasonable assumption that it will always be running on a Linux system, since that's the only platform where the vcs* devices would usually be present.
As a result, just implementing these devices usually won't in itself be enough to make third party software that requires them ‘just work’. In many cases other Linux specific ioctls would need to be implemented to do things such as retrieving the table mentioned earlier that maps font glyphs to unicode codepoints, switching between VTs, and more. Additionally, some software may try to create any missing vcs* device special files itself and make assumptions about the major and minor numbers in the process.
Furthermore, once we look at the format of the attribute data we'll see that it retains a lot of idiosyncrasies from the layout of the VGA video RAM. This means that 512 character fonts and underlining are supported by re-using bits that would otherwise hold color data. Working with such an arcane format on to which the features now available on the OpenBSD console don't really map very well is tedious to say the least.
/dev/vcs
The most basic /dev/vcs device is very similar and arguably even simpler to what we've already done. To implement it we only need to read out the raw character data for the whole visible display.
As noted above, for further simplicity we'll only code support a single device to access the current virtual terminal rather than a set of devices to access specific VTs based on the device minor number.
First we create a new device file:
# mknod -m 640 /dev/vcs c 12 253
The choice of minor number 253 is arbitrary, and just comes from following the principle of numbering downwards to avoid clashes with existing allocations.
We don't need to do any special initialisation on device open or clear anything up on close, so our code for wsdisplayopen and wsdisplayclose is trivial:
if (minor(dev)==253) {
return (0);
}
Since neither our implementation of the vcs device, nor any of the other new devices that we're going to look at will support mmap, we'll add some code to wsdisplaymmap that will simply return -1 if we try to mmap any of them:
if ((minor(dev) & 0xff) >= 249) {
return (-1);
}
As before, this is necessary to avoid the kernel crashing with a uvm fault due to the array access in the code below.
The main body of code obviously needs to go in wsdisplayread. This can go immediately after the existing variable declarations and assignment of sc, as we'll be using that value in our code.
We'll start with some useful macros:
#define VTROWS (sc->sc_focus->scr_dconf->scrdata->nrows)
#define VTCOLS (sc->sc_focus->scr_dconf->scrdata->ncols)
#define VTSIZE (VTROWS*VTCOLS)
#define VTEOF (VTSIZE-1)
Followed by the actual logic to implement vcs:
if (minor(dev)==253) {
unsigned char * vtbuffer;
struct wsdisplay_charcell cell;
int i;
int res;
if (uio->uio_offset > VTEOF) {
return (0);
}
res=uio->uio_resid;
if (uio->uio_offset + uio->uio_resid > VTSIZE) {
res=VTSIZE-uio->uio_offset;
}
vtbuffer=malloc(res, M_DEVBUF, M_WAITOK);
for (i=0; i < res; i++) {
GETCHAR(sc->sc_scr[sc->sc_focusidx], uio->uio_offset + i, &cell);
*(vtbuffer+i)=(cell.uc & 0xff);
}
uiomove (vtbuffer, res, uio);
free (vtbuffer, M_DEVBUF, res);
return (0);
}
Since we're only reading 8-bit character data, each byte of the output corresponds to exactly one character from the screen. There are also no alignment issues, in other words it's not possible to read just part of the data for a single screen character. Because of this simplicity, in this case we don't need to read out the characters from the whole display but only the range which is specifically requested. We can also allocate a buffer which is exactly sized for this purpose. We'll see later that things are not as convenient for the vcsa and vcsu devices.
Our new device can easily be tested from the shell:
# dd if=/dev/vcs of=/tmp/screenshot
Write support
Version 1 - nothing
On Linux, the vcs devices support being written to, and unsurprisingly this results in the written characters being displayed at the corresponding locations on screen.
I was and still remain skeptical about the usefulness of this feature, especially with regards to supporting tactile terminals. The main use we would have for the vcs devices is directly scraping the screen content so that we can reproduce part of it elsewhere, and this is clearly a read operation.
Of course, some tactile terminals may well be input devices as well, but just being able to change the screen content character by character doesn't do anything to help us support piping input from these devices in to the wscons subsystem. Doing that would require emulating a keyboard, or something similar, and is a completely different requirement, (which we will return to later).
So I was tempted to just leave the wsdisplaywrite function as a stub:
if (minor(dev)==253) {
return (EOPNOTSUPP);
}
Indeed, part of me still thinks that this is the more correct way to implement vcs on OpenBSD.
However, in the interests of completeness, and because the code is so trivial anyway, I thought we might as well see a way to implement writing as well.
Write support
Version 2 - really writing
After seeing it work I did actually begin to think that it could possibly be useful for displaying some kind of additional cursor, if we wanted a visual indication of whereabouts on the visual screen the tactile terminal was taking it's input from. Maybe. Certainly during code development or debugging it might be useful.
This implementation is basically the same as the read code, but uses the PUTCHAR macro to write the supplied data back to the screen. Note that we have to retrieve the current attribute value with GETCHAR first so that we can put it back along with the new character. Or we could just use the default attributes.
One thing that we have to watch out for is not to supply a codepoint value to PUTCHAR which doesn't exist in the current font. Unlike the higher level character output routines which validate the input by calling their designated mapchar function or otherwise, the PUTCHAR macro basically sends our arguments directly to the display driver.
In the case of rasops32.c, something like echo foo > /dev/vcs will crash the kernel, because the trailing newline, (0x0a), will end up having the value of firstchar, (which will typically be the ASCII space, 0x20), subtracted from it and wrapping round past zero to a very high value. Since this value is then used an an index in to the font data, we access memory that we shouldn't do and the kernel panics.
Dealing with this in a fully comprehensive and correct way is somewhat tedious, because the exact set of characters which can be safely sent to PUTCHAR depends on which display driver we're using. The VGA driver, for example, will happily let us do echo foo > /dev/vcs, and the final 0x0a is printed as an inverted bullet character, (◙), consistent with the standard VGA character set.
The mapchar functions will accept a supplied codepoint as an argument and return an appropriate character which PUTCHAR can accept. However, this isn't really what we want, since the data we would be sending to the vcs device isn't intended to be interpreted as unicode codepoints, but as raw values to place in the text display buffer memory, (a concept which doesn't really exist in the same way anymore anyway once we move away from hardware text modes).
Being pedantic about things and creating a correct implementation of a writable vcs device would probably involve modifying all of the lower level display drivers and adding a function that checked whether a particular byte value, (as opposed to codepoint), was valid or not. In the case of the rasops abstraction layer, this would additionally depend on the characteristics of the currently loaded font.
We'll avoid this by just replacing values below 32 with 0x3f, (the ASCII question mark), and passing anything else unmodified, (remember that the input buffer is an array of unsigned char, so we don't have to deal with anything above 255). Readers who want to play with the VGA text mode can easily remove this part of the code, as can anyone who is using the bold8x16 font which also has glyphs in the 1-31 range, (but lacks one at 0x00 and also at 0xff, so you'd probably want to replace these values with something else, too).
Write support
The actual code
Putting all of this theory in to practice gives us the following block of code for wsdisplaywrite:
if (minor(dev)==253) {
unsigned char * vtbuffer;
struct wsdisplay_charcell cell;
int i;
int res;
int offset;
if (uio->uio_offset > VTEOF) {
return (0);
}
res=uio->uio_resid;
if (uio->uio_offset + uio->uio_resid > VTSIZE) {
res=VTSIZE-uio->uio_offset;
}
vtbuffer=malloc(res, M_DEVBUF, M_WAITOK);
offset=uio->uio_offset;
uiomove (vtbuffer, res, uio);
for (i=0; i < res; i++) {
GETCHAR(sc->sc_scr[sc->sc_focusidx], offset + i, &cell);
PUTCHAR(sc->sc_scr[sc->sc_focusidx]->scr_dconf, offset + i, *(vtbuffer+i) >= 32 ? *(vtbuffer+i) : '?', cell.attr);
}
free (vtbuffer, M_DEVBUF, res);
return (0);
}
This also needs to go after the existing assignment of sc as we use that here too.
Now we can have endless fun doing things like:
# dd if=/dev/random of=/dev/vcs
... and filling the display with random characters.
Attributes with /dev/vcsa
Having implemented the plain vcs device without too much difficulty, the next logic step is obviously to implement vcsa.
# mknod -m 640 /dev/vcsa c 12 252
The code here looks somewhat different, as instead of just reading out the required characters we convert the whole display to the 16-bit character plus attribute format in a temporary buffer, then read out whatever had actually been requested.
If we wanted to read out just the required characters, then we would need to be careful to correctly handle byte offsets that were not aligned to a 16-bit boundary, in other words odd numbered byte offsets. In those cases, we read either a character without it's matching attribute, or an attribute without it's matching character, depending on the system's native endianness. We'd also need to deal with reads starting from byte 1, 2, or 3, in which case we'd need to include some but not all of the header.
All of this complexity can be avoided by implementing vcsa in the way shown below, and the overhead of processing the entire display on each read should be minimal on any modern hardware.
Since the code below uses struct wsemul_vt100_emuldata which is defined in wsemul_vt100var.h, we need to include this file at the beginning of wsdisplay.c, after the existing includes:
#include "wsemul_vt100var.h"
And of course, we need stubs for wsdisplayopen and wsdisplayclose:
if (minor(dev)==252) {
return (0);
}
Then the rest of the code goes in wsdisplayread:
#define VTASIZE ((VTROWS * VTCOLS * 2) + 4)
#define VTAEOF (VTASIZE - 1)
if (minor(dev)==252) {
unsigned char * vtbuffer;
uint16_t * nevtbuffer;
struct wsdisplay_charcell cell;
struct wsemul_vt100_emuldata *edp;
int i;
int res;
int fg, bg;
edp = sc->sc_focus->scr_dconf->wsemulcookie;
if (uio->uio_offset > VTAEOF) {
return (0);
}
res=uio->uio_resid;
if (uio->uio_offset + uio->uio_resid > VTASIZE) {
res=VTASIZE-uio->uio_offset;
}
vtbuffer=malloc(VTASIZE, M_DEVBUF, M_WAITOK);
nevtbuffer=(uint16_t *)(vtbuffer+4);
scr=sc->sc_scr[sc->sc_focusidx];
#define NFG ((fg & 0x8) | ((fg & 0x4) >> 2) | (fg & 0x2) | ((fg & 0x1) << 2))
#define NBG ((bg & 0x8) | ((bg & 0x4) >> 2) | (bg & 0x2) | ((bg & 0x1) << 2))
for (i=0; i < VTSIZE; i++) {
GETCHAR(scr, i, &cell);
(*scr->scr_dconf->emulops->unpack_attr)(scr->scr_dconf->emulcookie, cell.attr, &fg, &bg, NULL);
nevtbuffer[i]=(cell.uc & 0xff) | ((NFG | (NBG << 4)) << 8);
}
*vtbuffer=VTROWS;
*(vtbuffer+1)=VTCOLS;
*(vtbuffer+2)=edp->ccol;
*(vtbuffer+3)=edp->crow;
uiomove (vtbuffer+uio->uio_offset, res, uio);
free (vtbuffer, M_DEVBUF, VTASIZE);
return(0);
}
RGB vs BGR
One interesting and easily overlooked caveat is that whilst the 4-bit color values used with wscons are IBGR, in other words bit 0 controls the red component, the VGA hardware uses an IRGB bit layout, in other words bits 0 controls the blue component.
This explains the need to further shuffle the bits in the code above.
Top tip!
Good programming practice notes
The code above casts an unsigned char pointer to a uint16_t pointer without making any checks for correct alignment.
This is fine here, because the OpenBSD kernel malloc code guarantees suitable alignment in this case and clearly this code is not intended to be portable to other systems. In general, though, casting a pointer to a larger type should only be done if we are sure that the resulting address is indeed suitably aligned for dereferencing as the new type.
We can test this from the shell just as we tested the vcs implementation, although the output is probably best viewed as a hexdump due to the header and attributes. Here we can see the output from the start of the the first line of VT 0, which was still showing some of the white on blue boot messages from the kernel:
# cat /dev/vcsa > /tmp/screendump
# hexdump -C /tmp/screendump
00000000 31 a0 07 30 77 47 73 47 64 47 69 47 73 47 70 47 |1..0wGsGdGiGsGpG|
00000010 6c 47 61 47 79 47 30 47 20 47 61 47 74 47 20 47 |lGaGyG0G GaGtG G|
[...]
There are still some limitations with our vcsa device:
The vcsa implementation in Linux includes the possibility to use of one of the bits of the attribute byte as bit 8 of the character index, in other words the character data can be 9 bits per character instead of 8 bits, allowing us to represent a 512 character font. Adding this functionality to the code above would be trivial, but then to actually use it from userland we'd also have to either implement the Linux-specific ioctl that userland software uses to find out which attribute bit has been repurposed, or otherwise modify the userland software to either use a fixed bit, or find out this information some other way.
At the end of the day such an exercise would be mostly pointless, since on OpenBSD the character data is stored natively as unicode codepoints anyway and reducing this to 512 characters rather than 256 doesn't do much to solve the limitation of having only 256 codepoints. Since it won't make any existing software ‘just work’, we might as well find a way to move existing software to using unicode character data directly.
The code above also completely ignores the underline flag. This is represented in the single byte attribute format by bit 0, and since this is also used as part of the foreground color there is no way to unambiguously represent both color and underline independently at the same time.
Write support
An exercise in pain
This is an absolutely awful format to have to work with when you're not actually using hardware in VGA text mode. I honestly don't recommend that any new software uses the vcsa interface in any way, but especially not as an output device for writing data to the display.
However, as a programming exercise, adding write support to our vcsa device gives us an opportunity to see ways of dealing with the various complications.
Here is the code:
if (minor(dev)==252) {
unsigned char * vtbuffer;
struct wsemul_vt100_emuldata *edp;
int i;
int res;
int output_offset;
int original_offset;
uint32_t attr;
edp = sc->sc_focus->scr_dconf->wsemulcookie;
if (uio->uio_offset > VTAEOF) {
return (0);
}
if ((uio->uio_offset & 1) != 0) {
return (ENXIO);
}
if ((uio->uio_resid & 1) != 0) {
return (ENXIO);
}
res=uio->uio_resid;
if (uio->uio_offset + uio->uio_resid > VTASIZE) {
res=VTASIZE-uio->uio_offset;
}
vtbuffer=malloc(res, M_DEVBUF, M_WAITOK);
output_offset=(uio->uio_offset - 4) / 2;
original_offset=uio->uio_offset;
uiomove (vtbuffer, res, uio);
scr=sc->sc_scr[sc->sc_focusidx];
#if _BYTE_ORDER == _LITTLE_ENDIAN
#define SFG (*(vtbuffer + i + 1) & 0xf)
#define SBG ((*(vtbuffer + i + 1) & 0xf0) >> 4)
#define CHAR *(vtbuffer + i)
#endif
#if _BYTE_ORDER == _BIG_ENDIAN
#define SFG (*(vtbuffer + i) & 0xf)
#define SBG ((*(vtbuffer + i) & 0xf0) >> 4)
#define CHAR *(vtbuffer + i + 1)
#endif
#define BG ((SBG & 0x8) | ((SBG & 0x4) >> 2) | (SBG & 0x2) | ((SBG & 0x1) << 2))
#define FG ((SFG & 0x8) | ((SFG & 0x4) >> 2) | (SFG & 0x2) | ((SFG & 0x1) << 2))
#define FLAGS WSATTR_WSCOLORS | (BG & 8 ? (edp->scrcapabilities & WSSCREEN_BLINK ? WSATTR_BLINK : 0) : 0)
for (i=0; i < res; i += 2) {
if (original_offset + i == 2) {
if (*(vtbuffer + i) < edp->ncols) {
edp->ccol=*(vtbuffer + i);
}
if (*(vtbuffer + i + 1) < edp->nrows) {
edp->crow=*(vtbuffer + i + 1);
}
}
if (original_offset + i > 3) {
(scr->scr_dconf->emulops->pack_attr)(scr->scr_dconf->emulcookie, FG, BG, FLAGS, &attr);
PUTCHAR(scr->scr_dconf, output_offset + (i / 2), CHAR >= 32 ? CHAR : '?', attr);
}
}
free (vtbuffer, M_DEVBUF, res);
return (0);
}
This routine uses the _BYTE_ORDER macro to determine how we should interpret the byte-oriented data stream that's being supplied to our vcsa device. This macro is defined in the kernel's architecture-specific endian.h file. The way we use it here is fairly straightforward, we just end up with slightly different definitions for our own macros, FG, BG, and CHAR, referencing either byte offset i or byte offset i + 1 depending on endianness.
Somewhat more problematic is the fact that we have to decide exactly how we are going to support the various attribute values that are ambiguous. Readers who are familiar with the VGA text mode memory layout will already know that for historical reasons the underline bit is shared with the lower bit of the foreground color, and the upper bit of the background color is also used as the blink flag.
However, the wscons subsystem lets us specify attributes such as underline and blink completely independently of the foreground and background colors, (even if, as in the case of the VGA driver, the lower level code then ends up combining them and imposing limitations, or otherwise just not supporting blink at all which is the case if we are using framebuffer hardware via rasops).
The upshot of this is that if our vcsa device is supplied an attribute byte of 0x80, we can either set the background to color 8, or set it to color 0 and enable blink. If we get an attribute byte of 0x01, we can either set blue text or underline. We could even do both.
For this implementation I decided to interpret the attributes as colors and not bother with support for underlining. Since the VGA driver won't let us set bright background colors, but does support blink, and since blink is a fun attribute to play with on VGA hardware, I decided to set the wscons blink flag if the supplied attribute byte has bit 7 set.
Note that if we are going to do this successfully, we have to check the WSSCREEN_BLINK capability and only set WSATTR_BLINK if the capability is actually available. If we don't, then when we run our code on a display that's using the rasops subsystem we won't get any color at all for those characters. The rasops code will ignore the request altogether, because there is an explicit check for WSATTR_BLINK being set in rasops_pack_cattr, which will cause it to immediately return EINVAL.
Unfortunately, due to the limitations of the existing unpack_attr functions, (which we'll look at in more detail shortly), our vcsa device doesn't support reading of the blink attribute. The upshot of this is that if you use the VGA driver, display blinking text, create a screendump using the vcsa device, and then try to restore it to the screen by writing it back to the vcsa device, then the blinking text will appear as non-blinking text in the restored version. This is probably not a serious limitation in practice.
Sidenote
Blink and you'll miss it
Unlike foreground and background colors, the blink attribute won't be preserved across a round trip through the vcsa device, as can be demonstrated by running the following command, and observing that any blinking text on the screen becomes non-blinking:
# cat /dev/vcsa > /dev/vcsa
This is because our read out code doesn't have any way to read out the blink attribute in the first place.
Overall this write functionality for vcsa works surprisingly well.
Especially considering the inherent limitations of the attribute format that we're using.
Although this all means that the vcsa device can now theoretically be used as a primative way to save screenshots to a file and and restore them afterwards, doing so isn't recommended as much better ways exist.
One arbitrary limitation that this code has is that writes have to be aligned to 16-bit boundaries and use a block size that's a multiple of 2. The main reason for this is that we really want to avoid changing just a character without it's attribute, or just an attribute without it's character. To do that we would need to first fetch the current character and attribute to provide whichever one of the values we were not supplying back to the PUTCHAR macro, (unless we wanted to delve even deeper in to the low level driver code and implement new functions to modify just one or the other). Fetching the data and writing it back wouldn't be too bad in principle, but I just don't see the point in continuing to add functionality to what is clearly an obsolete programming interface that no new userland code should really be using.
Moving the cursor position by writing to header bytes 2 and 3 is supported, but writes to the first two header bytes will be ignored since trying to change the overall screen size here doesn't really make much sense.
To paint random characters and attributes to the screen we can use the following command, which will avoid moving the cursor by skipping the initial four header bytes:
# dd if=/dev/random of=/dev/vcsa bs=4 seek=1
This will result in a display full of random multi-colored characters, and when using the VGA driver about half of them will be blinking.
Unicode output with vcsu
Compared with the complexity of dealing with the attribute data, implementing a version of the vcsu device is quite simple:
#define VTUSIZE (VTSIZE * 4)
#define VTUEOF (VTUSIZE - 1)
if (minor(dev)==251) {
uint32_t * vtbuffer;
struct wsdisplay_charcell cell;
int i;
int res;
if (uio->uio_offset > VTUEOF) {
return (0);
}
res=uio->uio_resid;
if (uio->uio_offset + uio->uio_resid > VTUSIZE) {
res=VTUSIZE-uio->uio_offset;
}
vtbuffer=malloc(VTUSIZE, M_DEVBUF, M_WAITOK);
for (i=0; i < VTSIZE; i++) {
GETCHAR(sc->sc_scr[sc->sc_focusidx], i, &cell);
vtbuffer[i]=cell.uc;
}
uiomove ((void*)vtbuffer + uio->uio_offset, res, uio);
free (vtbuffer, M_DEVBUF, VTUSIZE);
return (0);
}
Here we are doing nothing more than reading the data for the whole display in to an array of 32-bit values one character at a time, which obviously results in them being stored in the system's native byte order. Then we just read out the particular byte range that's been requested as single bytes. Simple.
Nothing complicated. There are no alignment issues, so partial reads of 32-bit characters are supported.
Write support
Fairly easy this time
Write support is also fairly easy to implement:
if (minor(dev)==251) {
uint32_t * vtbuffer;
struct wsdisplay_charcell cell;
int i;
int res;
int offset;
if (uio->uio_offset > VTUEOF) {
return (0);
}
if ((uio->uio_offset & 3) != 0) {
return (ENXIO);
}
if ((uio->uio_resid & 3) != 0) {
return (ENXIO);
}
res=uio->uio_resid;
if (uio->uio_offset + res > VTUSIZE) {
res=VTUSIZE-uio->uio_offset;
}
offset=uio->uio_offset/4;
vtbuffer=malloc(res, M_DEVBUF, M_WAITOK);
uiomove (vtbuffer, res, uio);
scr=sc->sc_scr[sc->sc_focusidx];
for (i=0; i < res/4; i++) {
GETCHAR(sc->sc_scr[sc->sc_focusidx], offset+i, &cell);
cell.uc=vtbuffer[i];
PUTCHAR(scr->scr_dconf, offset + i, (cell.uc >= 32 && cell.uc <=255) ? cell.uc : '?', cell.attr);
}
free (vtbuffer, M_DEVBUF, res);
return (0);
}
In this case, we do require 32-bit alignment, and don't support writing to parts of 32-bit codepoints. That wouldn't really make much sense anyway, so we don't exactly miss out on anything. We read the supplied data byte by byte, and store it in the order that it's received. Then we just read out unsigned 32-bit values in the system's native byte order and pass them to PUTCHAR, after doing a similar sanity check on the codepoint values to what we did before, and having retrieving the current attribute value with GETCHAR first so that we can pass that back along with the new character data.
Of course, unless the system has been modified to use a font that includes glyphs past codepoint 255, the write functionality of this device is almost completely pointless. As we've already seen, there is no pre-existing way to tell if any particular codepoint can be safely passed to PUTCHAR or not. With the vcs and vcsa writing code we worked around this problem by arbitrarily limiting ourselves to codepoints between 32 and 255, which obviously works just fine for the the vast majority of systems. The code above does the same thing to avoid crashing the kernel, but clearly there isn't much benefit to making vcsu writable and passing it 32-bit values if we're never going to use it to output codepoints above 255.
Testing
If you test this device with something like:
# dd if=/dev/random of=/dev/vcsu
... you'll almost certainly get a screen full of question marks, since it's unlikely that random data will have the necessary three 0x00 bytes to create a character between 32 and 255.
Instead, use something like:
# echo -n "f\000\000\000o\000\000\000o\000\000\000" > /dev/vcsu
... which will work on a little-endian architecture, or if you're on a big-endian machine:
# echo -n "\000\000\000f\000\000\000o\000\000\000o" > /dev/vcsu
Side note
Using mapchar to validate codepoints
It might seem as if we could use the mapchar function to determine whether a particular codepoint value could be passed to PUTCHAR or not, by supplying mapchar with the value we want to test, and then seeing if it returned the same value, (in which case it's OK), or something different, (in which case it's not).
In fact, in most cases this would indeed work, basically as long as the font in use has font encoding WSDISPLAY_FONTENC_ISO, which all but one of the supplied console fonts do. In this case, the mapchar routines effectively do just check that the supplied character is within the range of the current font, (in the case of rasops), or < 256, (in the case of the VGA driver).
However, in the case of a font which isn't of encoding WSDISPLAY_FONTENC_ISO, the mapchar function might return a different value from the supplied codepoint, even though the supplied codepoint did happen to be a valid glyph, because it wasn't the correct glyph.
If we then interpreted that differing return value as meaning, ‘the supplied byte value is not OK to send to PUTCHAR’, and especially if we sent a replacement value instead, then our code would be incorrect.
Assuming that what we are trying to simulate by writing a byte value to the display using the vcs* devices is the equivalent of storing that value directly in to the video RAM of a text mode display, then unless that value is going to cause the putchar functions to crash we want to store it with no further modification. Otherwise, the vcs device isn't really giving us direct access to the display.
Remember, too, that on a Linux system the returned values are technically not character values anyway but indicies to the relevant font glyphs. Our implementation already differs from this, so ultimately it all really depends on exactly what behaviour we're expecting from the vcs* devices on an OpenBSD system.
In any case, I don't like the approach of using the mapchar functions as pure character validation functions, because it's clearly not the way that they are intended to be used. This specific behaviour could quite easily change as the console unicode support matures, and that might cause subtle bugs to appear in our code due to incorrect assumptions.
Summary of our vcs* device implementations
We've seen that it's possible to replicate most of the functionality of the various vcs devices found on a typical Linux system on OpenBSD with just a few lines of new code in the kernel. Even a more complete implementation would probably not take too much further work, although it would touch far more distinct areas of the kernel code than what we've looked at so far.
However, we've also seen that these are fairly antiquated interfaces which are cumbersome to use, and don't map particularly well on to the featureset of the wscons subsystem. Their use in the real world also typically relies on other non-portable features available on Linux systems.
Given these limitations, implementing these devices on OpenBSD doesn't seem in any way like a sensible method of facilitating the porting of existing Linux software that supports tactile terminals.
In fact, this is a good example of when backwards compatibility with old standards should just be dropped. There is absolutely no logical reason why new code to support a tactile terminal on OpenBSD in 2023 should be in any way influenced or constrained by aspects of the design of the VGA hardware from four decades ago. Sure, if an existing system works on Linux then there is an argument for leaving it alone, but that's part of a completely different discussion.
Creating our own, custom screen capture device
Successfully implementing the most important functionality of the Linux vcs* devices on OpenBSD was certainly an interesting programming exercise. Unfortunately, as stated above, most existing software that actually uses them on Linux is likely to to require considerable modification itself to run on OpenBSD.
Obviously, if the software is going to require such modification anyway, we might as well just design our own interface or interfaces from scratch that can be more easily and directly implemented on OpenBSD and convert the software to use those. We can also take the opportunity to overcome the arbitrary limitations faced by using the vcs* family of devices, principally the inability to read both unicode codepoints and attributes from the same device.
Of course, there is no obligation to implement this functionality as a new, ‘device file that provides something that looks like a screendump when read’, at all. We could instead add ioctls to the existing wscons devices, for example. But the basic idea of a screen capture device seems reasonable, will likely be trivial to implement, and will also hopefully minimise the required changes to the userland software.
Data format
We're free to choose whatever data format we like, but the basic idea of having a header with the cursor position and screen dimensions, followed by interleaved character and attribute data seems perfectly logical.
The native OpenBSD struct wsdisplay_charcell stores both the unicode codepoint and the attribute for each cell as unsigned 32-bit values. We might naïvely assume that the best policy would be to just dump these values directly out, 64 bits for each character, but there is a slight catch - the attribute data as stored here is device specific.
In reality this doesn't actually complicate matters very much at all. When the attribute value is required by the wscons code, it is constructed from the separate background, foreground and flags values by calling whatever function has been defined as pack_attr in the display's table of emulation function pointers, (which is a struct wsdisplay_emulops as defined in wsdisplayvar.h).
Virtually all of the displays currently supported use the functions defined in rasops.c, which simply stores the foreground color in attribute bits 24-27, (or 24-31 with my 256 color patch), the background color in bits 16-19, (16-23 with 256 colors), and the WSATTR_* flags unmodified in bits 0-15. The main exception is the vga text mode driver which has it's own vga_pack_attr function to create a 16-bit attribute value compatible with the vga hardware.
A corresponding unpack_attr function pointer also exists, so we can easily get the foreground and background colors back out of the attribute value. Unfortunately, whilst this does provide a standard interface to get the color values back, rather than give us back the original values for the other flags it only provides the status of the underline bit.
On a standard OpenBSD system this doesn't result in much lost functionality. The bold or intensity attribute, (WSATTR_HILIT), is passed back as part of the foreground color, (even on monochrome displays), so the main thing we miss out on is being able to get the status of the blink attribute.
However, if you've been following my recent work on enhancing the functionality of the console, you'll know that the patches define various new WSATTR_* values, to support dim, strikethrough, double underline, invisible, and italic text rendering. Obviously it would be nice to include these flags in our readout of screen data.
Although we could add extra functionality to the unpack_attr functions to return these new attributes and then set flag bits accordingly in our output, a simpler way is to make use of the fact that these new attributes are not supported by the VGA driver anyway. If we make the assumption that any new hardware that does support them will also use the rasops abstraction layer, then we can just directly include the relevant bits from the wsdisplay_charcell attribute value.
The wsdisplayvar.h header file is already available from userland in /usr/include/dev/wscons/, so userland programs that care to parse the raw attribute data can do so using the WSATTR_* values defined there. Of course, if the system is using the VGA driver rather than the rasops subsystem then interpreting these bits in that way will produce nonsense, but in that case the system would still be providing a way for userland programs aware of that fact to retrieve the value of the VGA blink bit.
Since it's obviously far from ideal to have userland need to know the specifics of the hardware that it's running on, we'll also include the underline bit at a fixed known position.
In this way, we end up with a standardised way to check for color and underlining, and anything else requires userspace to know what hardware it's running on. This seems like an acceptable approach, especially since any new display hardware is most likely going to use the rasops subsystem.
So each character in our output will be represented by 8 bytes, the first four being the unicode codepoint, then one byte each for background and foreground values, followed by two bytes containing the lower 15 bits of the attribute value in bits 0-14, with bit 15 being set if underlining is on. Of course, a device specific bit will also be set for underline, which userland should ignore in favour of checking bit 15 instead.
Using 64 bits per character also ensures that the output looks nice when viewed with hexdump -C, and that console lines with a lot of repeating characters, (such as spaces at the ends of short lines), are grouped and collapsed in the hexdump output.
Implementation decision
Packing into 64 bits rather than 32 bits
Technically, we could pack the minimally required data in to 32 bits, since valid unicode codepoints only require 21 bits, leaving 11 bits free to encode background and foreground colors as 4 bits each, and the underline flag bit, with 2 bits unused.
However, there would be numerous disadvantages to such a scheme. At least one bit of attribute data would have to be stored in the same byte as the character value, which is tedious for userland programs that just want to process character data. It would also prevent us from accurately representing the full color attributes for 256 color displays. Finally, if any characters beyond the 21-bit range did somehow end up in the display buffer, we wouldn't be able to represent those either.
Even with a console size of 49 rows by 160 columns, the amount of output from the 64 bits per character encoding is still less than 64K, which shouldn't be a burden on any modern hardware.
We might as well make the header 8 bytes as well, so that character cell data aligns neatly on 64-bit boundaries. The first four header bytes will be total columns, total rows, current column, and current row. Note that the first two bytes are reversed compared with the Linux vcs* devices, purely because it seems more logical to me to keep both total and current co-ordinate values in the same byte order.
Now we have four more bytes of header space left over. I decided to use byte 4 to store the current VT that we're capturing data from, and bytes 5 - 7 to store a literal string, ‘SCR’, mainly intended to be used as a simple file magic if the output from our device is written to a file.
Note that although three bytes of file magic is not really enough to be a reliable identifier of this data, we can also check that the cursor position bytes are within the total screen size, that the first byte of each 32-bit character value is zero, (as it should be for valid unicode codepoints), and that the total file size is correct given the dimensions in the first two bytes. These additional checks would give a high confidence of the file being a valid screendump.
Finally, we need to make a decision about endianness for the multi-byte values. The Linux vcsa and vcsu devices encode such values in the machine's native endianness, which is convenient when the output is being consumed immediately by another process running on the same machine. However, I prefer to define a fixed endianness for the output, and here we'll use big-endian.
The main block of code to implement all this is only about 40 lines:
#define VTESIZE ((VTROWS * VTCOLS * 8) + 8)
#define VTEEOF (VTESIZE - 1)
if (minor(dev)==250) {
unsigned char * vtbuffer;
struct wsdisplay_charcell cell;
struct wsemul_vt100_emuldata *edp;
int i;
int fg, bg, ul;
edp = sc->sc_focus->scr_dconf->wsemulcookie;
if (uio->uio_offset > VTEEOF) {
return (0);
}
vtbuffer=malloc(VTESIZE, M_DEVBUF, M_WAITOK);
*vtbuffer=VTCOLS;
*(vtbuffer+1)=VTROWS;
*(vtbuffer+2)=edp->ccol;
*(vtbuffer+3)=edp->crow;
*(vtbuffer+4)=sc->sc_focusidx;
*(vtbuffer+5)='S';
*(vtbuffer+6)='C';
*(vtbuffer+7)='R';
scr=sc->sc_scr[sc->sc_focusidx];
for (i=0; i < VTSIZE; i++) {
GETCHAR(sc->sc_scr[sc->sc_focusidx], i, &cell);
(*scr->scr_dconf->emulops->unpack_attr)(scr->scr_dconf->emulcookie, cell.attr, &fg, &bg, &ul);
*(vtbuffer + 8 + i * 8)=(cell.uc & 0xff000000) >> 24;
*(vtbuffer + 8 + i * 8 + 1)=(cell.uc & 0xff0000) >> 16;
*(vtbuffer + 8 + i * 8 + 2)=(cell.uc & 0xff00) >> 8;
*(vtbuffer + 8 + i * 8 + 3)=(cell.uc & 0xff);
*(vtbuffer + 8 + i * 8 + 4)=bg;
*(vtbuffer + 8 + i * 8 + 5)=fg;
*(vtbuffer + 8 + i * 8 + 6)=(ul ? 0x80 : 0) | (cell.attr & 0x7f00) >> 8;
*(vtbuffer + 8 + i * 8 + 7)=(cell.attr & 0xff);
}
uiomove (vtbuffer + uio->uio_offset, uio->uio_offset + uio->uio_resid > VTEEOF ? VTESIZE - uio->uio_offset : uio->uio_resid, uio);
free (vtbuffer, M_DEVBUF, VTESIZE);
return (0);
}
No write support
ENOSUPP!
For this new device I have no intention to support writing character or attribute data to the display, it's strictly read-only.
If writing to the header bytes as a way to position the cursor was considered useful, it could easily be implemented as we saw for the vcsa device.
(Lack of) optimisation
It's all or nothing...
The code above again fills the buffer with data generated for the entire display, regardless of how much we are actually reading out.
Early in the development process I did test various versions that only re-formatted and prepared the required characters, thinking that this would be a faster and more efficient approach. In fact the difference was minimal, and it made the code considerably more complicated as we then needed to deal with reads that were not aligned to 64-bit boundaries, (in other words seeking to a position in the middle of the data for any particular character). Besides, most real-world applications are going to want to read the data for the entire display at once, anyway.
One exception to this might be if they were just getting the 8 byte header to check the cursor position and screen size, with no actual character data being read out at all. This could be more easily special-cased, but at the end of the day it just didn't seem to be worth doing.
As before, we add a stub to wsdisplayopen and wsdisplayclose:
if (minor(dev)==250) {
return (0);
}
Create the special device file in the usual way:
# mknod -m 0640 /dev/vcse c 12 250
In this case we're naming the device vcse, with ‘e’ standing for enhanced.
Finally, test it:
# clear ; echo hello ; cat /dev/vcse > /tmp/screendump
# hexdump -C /tmp/screendump
00000000 a0 31 00 01 09 53 43 52 00 00 00 68 00 07 00 00 |.1...SCR...h....|
00000010 00 00 00 65 00 07 00 00 00 00 00 6c 00 07 00 00 |...e.......l....|
00000020 00 00 00 6c 00 07 00 00 00 00 00 6f 00 07 00 00 |...l.......o....|
00000030 00 00 00 20 00 07 00 00 00 00 00 20 00 07 00 00 |... ....... ....|
*
0000f500 00 00 00 20 00 07 00 00 |... ....|
0000f508
Here for comparison is the beginning of the same screen of boot messages we saw before when we were looking at the output of the vcsa device:
00000000 a0 31 07 30 00 53 43 52 00 00 00 77 04 07 00 10 |.1.0.SCR...w....|
00000010 00 00 00 73 04 07 00 10 00 00 00 64 04 07 00 10 |...s.......d....|
00000020 00 00 00 69 04 07 00 10 00 00 00 73 04 07 00 10 |...i.......s....|
00000030 00 00 00 70 04 07 00 10 00 00 00 6c 04 07 00 10 |...p.......l....|
00000040 00 00 00 61 04 07 00 10 00 00 00 79 04 07 00 10 |...a.......y....|
00000050 00 00 00 30 04 07 00 10 00 00 00 20 04 07 00 10 |...0....... ....|
00000060 00 00 00 61 04 07 00 10 00 00 00 74 04 07 00 10 |...a.......t....|
The unicode values, as well as the background and foreground colors are clearly identifyable in the output, and this does indeed seem like a reasonable format to pass on to userspace programs.
File magic
Identifying screendumps saved from /dev/vcse
A simple test for this screendump format can be added to the /etc/magic file with the following lines:
5 ubelong 0x53435200 Console screendump
>4 ubyte x from VT %d,
>0 ubyte x %d columns by
>1 ubyte x %d rows,
>2 ubyte x cursor at column %d,
>3 ubyte x row %d.
Additional features, and bug discovery
Our new devices are pretty much fully functional as far as reading out the screen contents goes, but that is only one part of supporting tactile terminals on OpenBSD. Two features that have been mentioned to me as being potentially welcome additions are notifications of updates to the display, and the ability to inject input in to the wscons system.
Both of these are fairly easy to implement, and whilst looking at ways to implement the second one I discovered an interesting bug in the exising wscons code.
Notifying a userland process of screen output
The most obvious way to do this, at least to me, is to use the same mechanism as is used to unblank the screen when the console screen blanker is active.
A suitable place to add code can be found in wsdisplay.c, in the function wsdisplaystart. Here we can already see calls to wsdisplay_burn and mouse_remove, and we can simply add three lines that are very similar to what we used in copy_to_copy_buffer to send SIGIO to our userland process when the copy action keysym was activated:
if (flag_signal_console_output==1) {
psignal (proc_console_monitor, SIGIO);
}
In fact, all we've done is use different variable names. These new variables now need to be defined in global scope near the start of wsdisplay.c:
int flag_signal_console_output;
struct proc * proc_console_monitor;
Then we just add some code to wsdisplayopen and wsdisplayclose to set and reset the flag, and store the PID of the calling userland process. So our wsdisplayopen stub function becomes:
if (minor(dev)==250) {
flag_signal_console_output=1;
proc_console_monitor=p;
return (0);
}
and on close, we reset the flag:
if (minor(dev)==250) {
flag_signal_console_output=0;
return (0);
}
Now, when we open the vcse device from userland, the userland process will receive SIGIO whenever there is console output.
The following program demonstrates this by cycling the keyboard LEDs in response to the signal:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#include <sys/select.h>
#include <sys/ioctl.h>
#include "/usr/include/dev/wscons/wsconsio.h"
int counter;
int led_bitmap;
int kfd;
void leds()
{
ioctl (kfd, WSKBDIO_SETLEDS, &led_bitmap);
led_bitmap++;
led_bitmap &= 0x7;
sleep(1);
return ;
}
void signal_info()
{
printf ("%d\n", counter);
return ;
}
void signal_io()
{
leds();
counter++;
return ;
}
int main()
{
int fd;
struct fd_set fds;
fd=open("/dev/vcse", O_RDONLY);
kfd=open("/dev/tty", O_RDWR);
if (fd == -1 || kfd == - 1) {
printf ("Error opening devices\n");
return(1);
}
FD_ZERO(&fds);
FD_SET(fd, &fds);
signal (SIGIO, &signal_io);
signal (SIGINFO, &signal_info);
printf ("Sleeping...\n");
while (1) {
select(1, &fds, NULL, NULL, NULL);
}
}
To use it, simply leave it running on a spare VT, switch to another VT and use the console as normal.
Individual keypresses will cause the LEDs to cycle in a binary sequence as the characters echo to the console, (although depending on the physical arrangement of LEDs on your keyboard, the bits might not be in the standard binary bit order). Sending a continual flood of text to the console, with something like ‘yes’ or ‘find /’, will cause the LEDs to cycle once per second, although obviously this is fully customiseable by changing the call to sleep in the leds function.
Injecting input
Warning
Don't try this at home!
Before we even start looking at how to implement this concept, I want to point out that allowing userland programs to send arbitrary input to the terminal is quite clearly a security nightmare.
BSD systems have traditionally had an ioctl TIOCSTI to allow userland to do exactly that, and this ioctl has been exploited in the past. Although we're going to be looking at implementing similar functionality via a device file rather than an ioctl, it's not hard to see how any such input mechanism could potentially be mis-used.
OpenBSD removed the traditional TIOCSTI ioctl some time ago, so even if we wanted to use it we couldn't. Many other systems do continue to implement it, for example it's still present in NetBSD, although the implementation in NetBSD includes various additional checks of the caller's credentials and therefore significantly reduces the scope for potential exploits.
For the practical application in hand - allowing tactile terminals to send input to the console - the best and correct way to avoid the issue completely is not to pass regular console input out to userland only to bring it back in again at all, but instead have the whole process handled directly within the kernel. If you've read this article from the beginning, you'll know that this has pretty much been my preferred way of doing things all along.
Nevertheless, the alternative approach of porting existing software from Linux does require a mechanism for injecting input from userspace, so we'll see now how that can be accomplished. It's also a convenient opportunity to cover a few interesting and likely non-obvious details of the low-level kernel tty code.
The point here is really to look and learn but not to use this input injection technique in any kind of production code.
Interestingly, and perhaps ironically, it was whilst I was implementing this, ‘inject input in to the terminal’, device, that I discovered an aspect of the existing wscons code which could, conceivably, be used to create an exploit. More on that shortly.
Sending input to the terminal can be done one character at a time using the ttyinput function in kern/tty.c.
Instead of calling the ttyinput function directly, the more correct way is to call it via the l_rint function pointer defined in it's line discipline table:
(*linesw[tp->t_line].l_rint)(int character, struct tty * tp)
... where tp is the terminal's corresponding struct tty.
In fact, this is basically what the old TIOCSTI iocol did. The line above is almost exactly the code that exists all the way back in 4BSD in the file /usr/src/sys/dev/tty.c to implement ioctl TIOCSTI.
Explainer
The line discipline table
A brief explainer for those readers who are not overly familiar with this part of the kernel:
If we look in kern/tty_conf.c, we can find a global variable linesw being defined. This is an array of struct linesw, (struct linesw being defined in sys/sys/conf.h), the name being short for line discipline switch table.
Each element of the array, in other words each instance of a struct linesw, contains a set of eight function pointers and the functions they point to implement various low-level terminal functions, (described further below).
Looking at scr_tty, (which is a struct tty *), it has an element t_line, (a u_char), and this is simply an index in to the linesw[] array we just mentioned. This index is telling us which set of function pointers to use. This whole arrangement is basically a method of supporting various different types of terminal hardware that each require quite different low-level driving routines.
The actual low-level functions are specifically: open, close, read, write, ioctl, receive input, start, and modem control.
The first five are fairly standard functions for any type of device, so shouldn't require much further explanation. These are the low-level functions that ultimately get called by wsdisplayopen, wsdisplayclose, wsdisplayread, wsdisplaywrite, and wsdisplayioctl. In the case of read and write, not much extra processing is done and the parameters are more or less passed straight through. For open, close, and especially ioctl, there is somewhat more functionality implemented by the higher level code which is not line-discipline specific, such as clearing the contents of the copy and paste buffer on close.
The receive input function allows us to add data to the terminal's input buffer as if it was being typed in, (so the same data will then be available to be read by the read function). This is precisely the function we'll be using shortly to inject our desired input. The start function checks the value of the function pointer t_oproc in the supplied struct tty, and if it's not NULL, then calls that function. On a modern OpenBSD system which is typically using the wscons subsystem, this calls wsdisplaystart.
Modem control is of little relevance on modern systems, and basically just lets us check the DCD, (Data Carrier Detect), line of a terminal that's connected to a serial port. This could have been used in the past as a way to log out users who were connected via a dial-up modem and who dropped carrier on the host system for whatever reason.
Note that having the variable linesw use the same name as the struct is not good C programming practice.
However, this code pre-dates the first release of OpenBSD by many years.
The kern/tty_conf.c file defines linesw[] as far back as 1989 in 4.3BSD Reno, and in fact even further back in 3BSD we can find a definition of linesw[] elsewhere in sys/sys/conf.c.
Our input injection device will have major 12 and minor 249:
# mknod -m 0640 /dev/input c 12 249
Since we don't need to do anything particular on device open or close, the open and close functions can just be stubs:
if (minor(dev)==249) {
return (0);
}
If we wanted to restrict the use of this new device beyond what the usual file permissions would allow us to do then we could add code to wsdisplayopen to perform whatever additional checks we wanted. For example, we could disallow it completely when the system is in securelevel 2 with the following:
if (minor(dev)==249) {
if (securelevel == 2) {
return (EPERM);
}
return (0);
}
Reading from the device doesn't make much sense as it's designed to be write only, so the read code just returns ENODEV:
if (minor(dev)==249) {
return (ENODEV);
}
Writing to the device copies the supplied data in to a temporary buffer, and then writes it out one byte at a time to the l_rint routine we discussed above:
if (minor(dev)==249) {
int res;
int i;
unsigned char * buffer;
struct tty * tp;
res=uio->uio_resid;
buffer=malloc(res, M_DEVBUF, M_WAITOK);
uiomove (buffer, res, uio);
scr=sc->sc_scr[sc->sc_focusidx];
tp=scr->scr_tty;
for (i=0; i<res; i++) {
(*linesw[tp->t_line].l_rint)(*(buffer+i), tp);
}
free (buffer, M_DEVBUF, res);
return (0);
}
When testing the device from the shell, remember that by default the echo command will include a newline character. So the following command will cause the whoami command to be run:
# echo whoami > /dev/input
whoami
# whoami
root
#
Of course, by design we're not limited to typing in to our own VT. Assuming that we are currently logged in as root on VT 0, and that nobody is logged in on VT 1, we can switch to VT 1 and log in as another user by entering the following:
# wsconsctl display.focus=1 ; sleep 0.5 ; echo "USERNAME" > /dev/input ; sleep 0.5 ; echo "PASSWORD" > /dev/input
This input mechanism certainly works, and could indeed be used to allow a tactile terminal that was being driven by userspace software to operate an OpenBSD machine fairly comprehensively.
Overall, though, I don't recommend it.
An interesting oversight in the existing wscons code
In the process of preparing this article, I was browsing through the wscons code and looking for existing uses of l_rint. The most obvious one is the mouse copy and paste code, usually used via the wsmoused daemon. Pasting copied text basically just simulates typing it in on the console.
The wscons functions to support this are near the end of wsdisplay.c, and the function that actually calls l_rint is mouse_paste. Looking at mouse_paste, we can see that it copies out characters, or more accurately bytes, from sc_copybuffer. The definition of sc_copybuffer is near the beginning of wsdisplay.c, in the definition for struct wsdisplay_softc, and here we can see that sc_copybuffer is a char *.
Unfortunately, if we look at mouse_copy_selection which is the function where the character data from the screen is actually copied in to sc_copybuffer, we can see that it comes directly from the output of the GETCHAR macro. The value used is cell.uc, which of course is a 32-bit unicode codepoint.
This is bad because copying a codepoint with a value greater than 255 will result in it being reduced modulo 256, in other words the value stored in sc_copybuffer will have bits 8-31 set to zero because we're casting to a char. If this data is subsequently pasted back to the console as input using mouse_paste, then we will be inputting different characters to those which were copied.
In theory, this creates an exploit - if we could arrange for a user to copy and paste innocent-looking text on the screen which is using characters above 255, but which when copied to the console is transformed in to a malicious command that the user wouldn't otherwise run.
This is made somewhat easier by the fact that many fonts have large numbers of blank glyphs, in which case the malicious code could be disguised as a blank line or lines within otherwise legitimate output, such as a list of firewall rules.
However, with the default console configuration using the supplied console fonts this won't happen, as the rasops mapchar code will substitute the ASCII question mark, 0x3f, for any codepoint that doesn't have a glyph in the current font. Since none of the standard console fonts include characters beyond 255, values of 256 and above won't be stored in the character cells in the first place.
So in reality this bug is almost impossible to exploit ‘in the wild’, because it not only requires the non-standard configuration of using a console font with extra glyphs, but also requires that a logged in user performs a copy and paste action. Nevertheless, it's worth seeing because future changes to the wscons code or the inclusion of a font with more glyphs in the base system could suddenly make it much more easily exploitable.
Summary
Today we've looked at two possible ways that tactile terminals could be supported on OpenBSD.
A native kernel-based solution could be implemented in a similar way to the simple proof concept code I presented at the beginning, which allowed us to copy out sections of the screen to a userspace program. Obviously such an approach would require native OpenBSD device drivers to be written for each tactile terminal that we wanted to support, but has the advantage of more easily providing access to the early boot time output and potentially the kernel debugger as well.
A mostly userspace solution based on porting existing software used on Linux systems would require various facilities in the kernel that a standard OpenBSD installation doesn't provide, such as a screen readout device, a way to signal that the screen has been updated, and a way to inject arbitrary input. We saw how to implement these.
Diverging somewhat from the original focus on tactile terminals, we also looked at some interesting aspects of the kernel tty code, and explored in detail how to enhance the screen readout device beyond the minimum necessary functionality to provide full unicode codepoints and attribute data, along with the possibility of using it to write back to the display.
Finally, we saw a limitation of the existing wscons code that could cause some unexpected results when copying and pasting non-ASCII characters on the console.
So, if you're interested in using tactile terminals directly on an OpenBSD machine, at least you now have some building blocks to play with.