Fortunately, today we have AI we have many more excellent and comprehensive documents on the subject, and more importantly, we've recently brought back up an oddball platform that doesn't have networking either: our DEC Professional 380 running the System V-based PRO/VENIX V2.0, which you met a couple articles back. The DEC Professionals are a notoriously incompatible member of the PDP-11 family and, short of DECnet (DECNA) support in its unique Professional Operating System, there's officially no other way you can get one on a network — let alone the modern Internet. Are we going to let that stop us?
Recall from our lengthy history of DEC's early misadventures with personal computers that, in Digital's ill-advised plan to avoid the DEC Pros cannibalizing low-end sales from their categorical PDP-11 minicomputers, Digital's Small Systems Group deliberately made the DEC Professional series nearly totally incompatible despite the fact they used the same CPUs. In their initial roll-out strategy in 1982, the Pros (as well as their sibling systems, the Rainbow and the DECmate II) were only supposed to be mere desktop office computers — the fact the Pros were PDP-11s internally was mostly treated as an implementation detail. The idea backfired spectacularly against the IBM PC when the Pros and their promised office software failed to arrive on time and in 1984 DEC retooled around a new concept of explicitly selling the Pros as desktop PDP-11s. This required porting operating systems that PDP-11 minis typically ran: RSX-11M Plus was already there as the low-level layer of the Professional Operating System (P/OS), and DEC internally ported RT-11 (as PRO/RT-11) and COS.
PDP-11s were also famous for running Unix and so DEC needed a Unix for the Pro as well, though eventually only one official option was ever available: a port of VenturCom's Venix based on V7 Unix and later System V Release 2.0 called PRO/VENIX. After the last article, I had the distinct pleasure of being contacted by Paul Kleppner, the company's first paid employee in 1981, who was part of the group at VenturCom that did the Pro port and stayed at the company until 1988. Venix was originally developed from V6 Unix on the PDP-11/23 incorporating Myron Zimmerman's real-time extensions to the kernel (such as semaphores and asynchronous I/O), then a postdoc in physics at MIT; Kleppner's father was the professor of the lab Zimmerman worked in. Zimmerman founded VenturCom in 1981 to capitalize on the emerging Unix market, becoming one of the earliest commercial Unix licensees. Venix-11 was subsequently based on the later V7 Unix, as was Venix/86, which was the first Unix on the IBM PC in January 1983 and was ported to the DEC Rainbow as Venix/86R. In addition to its real-time extensions and enhanced segmentation capability, critical for memory management in smaller 16-bit address spaces, it also included a full desktop graphics package.
Notably, DEC themselves were also a Unix licensee through their Unix Engineering Group and already had an enhanced V7 Unix of their own running on the PDP-11, branded initially as V7M. Subsequently the UEG developed a port of 4.2BSD with some System V components for the VAX and planned to release it as Ultrix-32, simultaneously retconning V7M as Ultrix-11 even though it had little in common with the VAX release. Paul recalls that DEC did attempt a port of Ultrix-11 to the Pro 350 themselves but ran into intractable performance problems. By then the clock was ticking on the Pro relaunch and the issues with Ultrix-11 likely prompted DEC to look for alternatives. Crucially, Zimmerman had managed to upgrade Venix-11's kernel while still keeping it small, a vital aspect on his 11/23 which lacked split instruction and data addressing and would have had to page in and out a larger kernel otherwise. Moreover, the 11/23 used an F-11 CPU — the same CPU as the original Professional 350 and 325. DEC quickly commissioned VenturCom to port their own system over to the Pro, which Paul says was a real win for VenturCom, and the first release came out in July 1984 complete with its real-time features intact and graphics support for the Pro's bitmapped screen. It was upgraded ("PRO/VENIX Rev 2.0") in October 1984, adding support for the new top-of-the-line DEC Professional 380, and then switched to System V (SVR2) in July 1985 with PRO/VENIX V2.0. (For its part Ultrix-11 was released as such in 1984 as well, but never for the Pro series.)
Keep that kernel version history in mind for when we get to oddiments of the C compiler. As for networking, though, with the exception of UUCP over serial, none of these early versions of Venix on either the PDP-11 or 8086 supported any kind of network connectivity out of the box — officially the only Pro operating system to support its Ethernet upgrade option was P/OS 2.0. Although all Pros have a 15-pin AUI network port, it isn't activated until an Ethernet CTI card is installed. (While Stan P. found mention of a third-party networking product called Fusion by Network Research Corporation which could run on PRO/VENIX, Paul's recollection is that this package ran into technical problems with kernel size during development. No examples of the PRO/VENIX version have so far been located and it may never have actually been released. You'll hear about it if a copy is found. The unofficial Pro 2.9BSD port also supports the network card, but that was always an under-the-table thing.) Since we run Venix on our Pro, that means currently our only realistic option to get this on the 'Nets is also over a serial port.
Fortunately for us, the Pros have two. Notionally its "serial port" is the DB-25 male RS-423 port, which can be functionally treated as RS-232 for this purpose, but the DE-9 ("DB-9") port designated as the console/printer port is also a serial port. The main difference between the two, besides their size, is their speed: the RS-423 serial port can run up to 9600bps, but the DE-9 port when used as a general purpose serial port is fixed at 4800bps. (The other port in this picture is for the Pro's video monitor and keyboard.)In this case, however, we're going to use the lower speed port for our serial IP implementation. PRO/VENIX supports using only the RS-423 port as a remote terminal, and because it's twice as fast, it's more convenient for logins and file exchange over Kermit (which also has no TCP/IP overhead). Using the printer port also provides us with a nice challenge: if our stack works acceptably well at 4800bps, it should do even better at higher speeds if we port it elsewhere. On the Pro, we connect to our upstream host using a BCC05 cable (in the middle of this photograph), which terminates in a regular 25-pin RS-232 on the other end.
Now for the software part. There are other small TCP/IP stacks, notably things like Adam Dunkel's lwIP and so on. But even SVR2 Venix is by present standards a old Unix with a much less extensive libc and more primitive C compiler — in a short while you'll see just how primitive — and relatively modern code like lwIP's would require a lot of porting. Ideally we'd like a very minimal, indeed barely adequate, stack that can do simple tasks and can be expressed in a fashion acceptable to a now antiquated compiler. Once we've written it, it would be nice if it were also easily portable to other very limited systems, even by directly translating it to assembly language if necessary.
What we want this barebones stack to accomplish will inform its design:
- Using a slower-speed serial link, especially one with no hardware assistance, will noticeably impair this machine's utility as a server. While we could implement a Telnet daemon or even (if we were crazier than usual) VNC, it would likely perform rather badly providing an interactive connection — to say nothing of several at once. The same could likely be said for file exchange, even just to one person on a LAN, and we'd need to be running the daemon and the hardware 24-7 to make such a use case meaningful. The Ethernet option was reportedly competent at server tasks, but Ethernet has more bandwidth, and that card also has additional on-board hardware. Let's face the cold reality: as a server, we'd find interacting with it over the serial port unsatisfactory at best and we'd use up a lot of power and MTBF keeping it on more than we'd like to. Therefore, we really should optimize for the client case, which means we also only need to run the client when we're performing a network task.
- We can assume we would be the only user on the system executing client transactions — after all, on this Venix install there can be at most two users, one on the console and one on the remote terminal, and my very loving and tolerant wife isn't likely to be a second simultaneous login. On a system with no remote login capacity, like, I dunno, a C64, the person on the console gets it all. Therefore, we really should optimize for the single user case, which means we can simplify our code substantially by merely dealing with sockets sequentially, one at a time, without having to worry about routing packets we get on the serial port to other tasks or multiplexing them. Doing so would require extra work for dual-socket protocols like FTP, but we're already going to use directly-attached Kermit for that, and if we really want file transfer over TCP/IP there are other choices. (On a larger antique system with multiple serial ports, we could consider a setup where each user uses a separate outgoing serial port as their own link, which would also work under this scheme.)
Some of you may find this conflicts hard with your notion of what a "stack" should provide, but I also argue that the breadth of a full-service driver would be wasted on a limited configuration like this and be unnecessarily more complex to write and test. Worse, in many cases, is better, and I assert this particular case is one of them.
Keeping the above in mind, what are appropriate client tasks for a microcomputer from 1984, now over 40 years old — even a fairly powerful one by the standards of the time — to do over a slow TCP/IP link?
- Check that the network is up. That means ICMP and IP on top of our serial connection.
- DNS support, of course. That adds UDP. We would set this up to talk to a recursive nameserver — no need to do the work all by itself.
- Another common task is setting the system time to an external source. NTP is also UDP, so we already have the prerequisites.
- Yet another common task is sending a command to a server and possibly doing something with the contents, like saving it to disk as a file. There are a fair number of simple protocols that use a single TCP connection to accept a client command, send back data and close the link. Gopher, whois and finger are three highly appropriate examples, but a more common one today is unencrypted HTTP/1.x. (A tool like Crypto Ancienne's carl can serve as an HTTP-to-HTTPS proxy to handle the TLS part, if necessary.) We could use protocols like these to download and/or view files from systems that aren't directly connected, or to send and receive status information.
One task that is also likely common is an interactive terminal connection (e.g., Telnet, rlogin) to another host. However, as a client this particular deployment is still likely to hit the same sorts of latency problems for the same reasons we would experience connecting to it as a server. These other tasks here are not highly sensitive to latency, require only a single "connection" and no multiplexing, and are simple protocols which are easy to implement. Let's call this feature set our minimum viable product.
Because we're writing only for a couple of specific use cases, and to make them even more explicit and easy to translate, we're going to take the unusual approach of having each of these clients handle their own raw packets in a bytewise manner. For the actual serial link we're going to go even more barebones and use old-school RFC 1055 SLIP instead of PPP (uncompressed, too, not even Van Jacobson CSLIP). This is trivial to debug and straightforward to write, and if we do so in a relatively encapsulated fashion, we could consider swapping in CSLIP or PPP later on. A couple of utility functions will do the IP checksum algorithm and reading and writing the serial port, and DNS and some aspects of TCP also get their own utility subroutines, but otherwise all of the programs we will create will read and write their own network datagrams, using the SLIP code to send and receive over the wire.
The C we will write will also be intentionally very constrained, using bytewise operations assuming nothing about endianness and using as little of the C standard library as possible. For types, you only need some sort of 32-bit long, which need not be native, an int of at least 16 bits, and a char type — which can be signed, and in fact has to be to run on earlier Venices (read on). You can run the entirety of the code with just malloc/free, read/write/open/close, strlen/strcat, sleep, rand/srand and time for the srand seed (and fprintf for printing debugging information, if desired). On a system with little or no operating system support, almost all of these primitive library functions are easy to write or simulate, and we won't even assume we're capable of non-blocking reads despite the fact Venix can do so. After all, from that which little is demanded, even less is expected.
The first step is to mock it up. For the "hardware," I ended up attaching two USB serial dongles to my Fedora-based POWER9 Raptor workstation and connecting them with a null modem.For the server end, we'll need something that can provide the SLIP connection. My very first home Internet link, in my undergraduate university days, was a SLIP connection with my very own static IP using a super-hot 14.4kbps modem. I'm pretty sure the system I was calling was a terminal server with a SLIP feature, but in olden days the upstream end might have been a utility like slattach which effectively makes a serial port directly into a network interface. Such an arrangement would be the most flexible approach from the user's perspective because you necessarily have a fixed, bindable external address, but obviously such a scheme didn't scale over time. With the proliferation of dialup Unix shell accounts in the late 1980s and early 1990s, closed-source tools like 1993's The Internet Adapter ("TIA") could provide the SLIP and later PPP link just by running them from a shell prompt. Because they synthesize artificial local IP addresses, sort of NAT before the concept explicitly existed, the architecture of such tools prevented directly creating listening sockets — though for some situations this could be considered a more of a feature than a bug. Any needed external ports could be proxied by the software anyway and later network clients tended not to require it, so for most tasks it was more than sufficient.
Closed-source and proprietary SLIP/PPP-over-shell solutions like TIA were eventually displaced by open source alternatives, most notably SLiRP. SLiRP (hereafter Slirp so I don't gouge my eyes out) emerged in 1995 and used a similar architecture to TIA, handing out virtual addresses on an synthetic network and bridging that network to the Internet through the host system. It rapidly became the SLIP/PPP shell solution of choice, leading to its outright ban by some shell ISPs who claimed it violated their terms of service. As direct SLIP/PPP dialup became more common than shell accounts, during which time yours truly upgraded to a 56K Mac modem I still have around here somewhere, Slirp eventually became most useful for connecting small devices via their serial ports (PDAs and mobile phones especially, but really anything — subsets of Slirp are still used in emulators today like QEMU for a similar purpose) to a LAN. By a shocking and completely contrived coincidence, that's exactly what we'll be doing!
Slirp has not been officially maintained since 2006. There is no package in Fedora, which is my usual desktop Linux, and the one in Debian reportedly has issues. A stack of patch sets circulated thereafter, but the planned 1.1 release never happened and other crippling bugs remain, some of which were addressed in other patches that don't seem to have made it into any release, source or otherwise. If you tried to build Slirp from source on a modern system and it just immediately exits, you got bit. I have incorporated those patches and a couple of my own to port naming and the configure script, plus some additional fixes, into an unofficial "Slirp-CK" which is on Github. It builds the same way as prior versions and is tested on Fedora Linux. I'm working on getting it functional on current macOS also.
Next, I wrote up our four basic functional clients: ping, DNS lookup, NTP client (it doesn't set the clock, just shows you the stratum, refid and time which you can use for your own purposes), and TCP client. The TCP client accepts strings up to a defined maximum length, opens the connection, sends those strings (optionally separated by CRLF), and then reads the reply until the connection closes. This all seemed to work great on the Linux box, which you yourself can play with as a toy stack (directions at the end). Unfortunately, I then pushed it over to the Pro with Kermit and the compiler immediately started complaining.
SLIP is a very thin layer on IP packets. There are exactly four metabytes, which I created preprocessor defines for:
#define SLIP_END 192 #define SLIP_ESC 219 #define SLIP_ESC_END 220 #define SLIP_ESC_ESC 221
A SLIP packet ends with SLIP_END, or hex $c0. Where this must occur within a packet, it is replaced by a two byte sequence for unambiguity, SLIP_ESC SLIP_ESC_END, or hex $db $dc, and where the escape byte must occur within a packet, it gets a different two byte sequence, SLIP_ESC SLIP_ESC_ESC, or hex $db $dd. Although I initially set out to use defines and symbols everywhere instead of naked bytes, and wrote slip.c on that basis, I eventually settled on raw bytes afterwards using copious comments so it was clear what was intended to be sent. That probably saved me a lot of work renaming everything, because:
% make -f makefile.venix cc -O -DDEBUG -DVENIX -c slip.c slip.c: 11: SLIP_ESC redefined slip.c: 12: SLIP_ESC redefined
Dimly I recalled that early C compilers, including System V, limit their identifiers to eight characters (the so-called "Ritchie limit"). At this point I probably should have simply removed them entirely for consistency with their absence elsewhere, but I went ahead and trimmed them down to more opaque, pithy identifiers. That wasn't the only problem, though. I originally had two functions in slip.c, slip_start and slip_stop, and it didn't like that either despite each appearing to have a unique eight-character prefix:
slip.c:207: names slip_sto and slip_sta conflict *** Error code 1 Stop.
That's because their symbols in the object file are actually prepended with various metacharacters like _ and ~, so effectively you only get seven characters in function identifiers, an issue this error message fails to explain clearly.
The next problem: there's no unsigned char, at least not in PRO/VENIX Rev. 2.0 which I want to support because it's more common, and presumably the original versions of PRO/VENIX and Venix-11. (This type does exist in PRO/VENIX V2.0, but that's because it's System V and has a later C compiler.) In fact, the unsigned keyword didn't exist at all in the earliest C compilers, and even when it did, it couldn't be applied to every basic type. Although unsigned char was introduced in V7 Unix and is documented as legal in the PRO/VENIX manual, and it does exist in Venix/86 2.1 which is also a V7 Unix derivative, the PDP-11 and 8086 C compilers have different lineages and Venix's V7 PDP-11 compiler definitely doesn't support it:
% cat >test.c main() { unsigned char c; exit(0); } ^D% cc -o test test.c test.c:3: Misplaced 'unsigned'
I suspect this may not have been intended because unsigned int works (unsigned long would be pointless on this architecture, and indeed correctly generates Misplaced 'long' on both versions of PRO/VENIX). Regardless of why, however, the plain char type on the PDP-11 is signed, and for compatibility reasons here we'll have no choice but to use it. Recall that when C89 was being codified, plain char was left as an ambiguous type since some platforms (notably PDP-11 and VAX) made it signed by default and others made it unsigned, and C89 was more about codifying existing practice than establishing new ones. That's why you see this on a modern 64-bit platform, e.g., my POWER9 workstation, where plain char is unsigned:
% uname -om ppc64le GNU/Linux % cat >test.c #include <stdio.h> main() { char x; x = 0xc0; printf("%02x\n", x); printf("%u\n", x); printf("%d\n", x); printf("%d\n", (unsigned int)x); if (x==0xc0) { puts("equal1"); } if ((x&0xff)==0xc0) { puts("equal2"); } puts("end"); exit(0); } ^D% gcc -std=c89 -o test test.c [warnings elided] % ./test c0 192 192 192 equal1 equal2 end
If we change the original type explicitly to signed char on our POWER9 Linux machine, that's different:
% ./test ffffffc0 4294967232 -64 -64 equal2 end
and, accounting for different sizes of int, seems similar on PRO/VENIX V2.0 (again, which is System V):
% cc -o test test.c % ./test ffc0 65472 -64 -64 equal2 end
but the exact same program on PRO/VENIX Rev. 2.0 behaves a bit differently:
% cc -o test test.c % ./test ffc0 65472 -64 192 equal2 end
The differences in int size we expect, but there's other kinds of weird stuff going on here. The PRO/VENIX manual lists all the various permutations about type conversions and what gets turned into what where, but since the manual is already wrong about unsigned char I don't think we can trust the documentation for this part either. Our best bet is to move values into int and mask off any propagated sign bits before doing comparisons or math, which is agonizing, but reliable. That means throwing around a lot of seemingly superfluous & 0xff to make sure we don't get negative numbers where we don't want them.
Once I got it built, however, there were lots of bugs. Many were because it turns out the compiler isn't too good with 32-bit long, which is not a native type on the 16-bit PDP-11. This (part of the NTP client) worked on my regular Linux desktop, but didn't work in Venix:
long ntime; ntime = (packet[68] & 0xff) <<24 | (packet[69] & 0xff) <<16 | (packet[70] & 0xff) <<8 | (packet[71] & 0xff); ntime -= 2208988800;
The first problem is that the intermediate shifts are too large and overshoot, even though they should be in range for a long. Consider this example:
% cat >test.c #include <stdio.h> main() { char w; long l; w = 0xc0; l = ((w & 0xff) << 8); printf("%lx\n", l); l = ((w & 0xff) << 24); printf("%lx\n", l); exit(0); }
On the POWER9, accounting for the different semantics of %lx,
% cc -std=c89 -o test test.c [warnings elided] % ./test c000 ffffffffc0000000
But on Venix, the second shift blows out the value.
^D% cc -o test test.c % ./test ffffc000 0
We can get an idea of why from the generated assembly in the adb debugger (here from PRO/VENIX V2.0, since I could cut and paste from the Kermit session):
% adb test - ? start: 0170011 = setd [...] ~main: 04067 = jsr r0,csav ~main+04: 0460 = br ~main+0146 ~main+06: 0112765 = movb $0300,0177770(r5) ~main+014: 0116500 = movb 0177770(r5),r0 ~main+020: 042700 = bic $0177400,r0 ~main+024: 072027 = ash $010,r0
(Parenthetical notes: csav is a small subroutine that pushes volatiles r2 through r4 on the stack and turns r5 into the frame pointer; the corresponding cret unwinds this. The initial branch in this main is used to reserve additional stack space, but is often practically a no-op.) The first shift is here at ~main+024. Remember the values are octal, so 010 == 8. r0 is 16 bits wide — no 32-bit registers — so an eight-bit shift is fine. When we get to the second shift, however,
[...] ~main+054: 04767 = jsr pc,_printf ~main+060: 062706 = add $06,sp ~main+064: 0116500 = movb 0177770(r5),r0 ~main+070: 042700 = bic $0177400,r0 ~main+074: 072027 = ash $030,r0 ~main+0100: 010065 = mov r0,0177766(r5) ~main+0104: 06765 = sxt 0177764(r5) ~main+0110: 016546 = mov 0177766(r5),-(sp) ~main+0114: 016546 = mov 0177764(r5),-(sp) ~main+0120: 012746 = mov $06217,-(sp) ~main+0124: 04767 = jsr pc,_printf ^C adb ^D%
it's the same instruction on just one register (030 == 24) and the overflow is never checked. In fact, the compiler never shifts the second part of the long at all. The result is thus zero.
The second problem in this example is that the compiler never treats the constant as a long even though statically there's no way it can fit in a 16-bit int. To get around those two gotchas on both Venices here, I rewrote it this way:
long ntime; long epoch = 2208988800; ntime = (packet[68] & 0xff); ntime <<= 8; ntime |= (packet[69] & 0xff); ntime <<=8; ntime |= (packet[70] & 0xff); ntime <<=8; ntime |= (packet[71] & 0xff); ntime -= epoch;
An alternative to a second variable is to explicitly mark the epoch constant itself as long, e.g., by casting it, which also works.
Here's another example for your entertainment. At least some sort of pseudo-random number generator is crucial, especially for TCP when selecting the pseudo-source port and initial sequence numbers, or otherwise Slirp seemed to get very confused because we would "reuse" things a lot. Unfortunately, the obvious typical idiom to seed it like srand(time(NULL)) doesn't work:
% cat >rand.c #include <stdio.h> main() { srand(time(NULL)); printf("%d\n", rand()); exit(0); } ^D% cc -o rand rand.c % ./rand 29994 % ./rand 29994 % ./rand 29994
srand() expects a 16-bit int but time(NULL) returns a 32-bit long, and it turns out the compiler only passes the 16 most significant bits of the time — i.e., the ones least likely to change — to srand(). Here's the disassembly as proof (contents trimmed for display here; since this is a static binary, we can see everything we're calling):
% adb rand - ? [...] ~main: 04067 = jsr r0,csav ~main+04: 0423 = br ~main+054 ; no-op in this case ~main+06: 05016 = clr (sp) ~main+010: 04737 = jsr pc,*$_time ~main+014: 010016 = mov r0,(sp) ~main+016: 04737 = jsr pc,*$_srand ~main+022: 04767 = jsr pc,_rand ~main+026: 010016 = mov r0,(sp) ~main+030: 012746 = mov $06324,-(sp) ~main+034: 04737 = jsr pc,*$_printf [...] _srand: 04067 = jsr r0,csav _srand+04: 016567 = mov 04(r5),06332 _srand+012: 05067 = clr 06330 _srand+016: 0167 = jmp cret [...] _time: 010546 = mov r5,-(sp) _time+02: 010605 = mov sp,r5 _time+04: 0104415 = sys time _time+06: 010246 = mov r2,-(sp) _time+010: 016502 = mov 04(r5),r2 _time+014: 01402 = beq _time+022 _time+016: 010022 = mov r0,(r2)+ _time+020: 010122 = mov r1,(r2)+ _time+022: 012602 = mov (sp)+,r2 _time+024: 012605 = mov (sp)+,r5 _time+026: 0207 = rts pc [...] csav: 010516 = mov r5,(sp) csav+02: 010605 = mov sp,r5 csav+04: 010446 = mov r4,-(sp) csav+06: 010346 = mov r3,-(sp) csav+010: 010246 = mov r2,-(sp) csav+012: 05746 = tst -(sp) csav+014: 010007 = mov r0,pc
At the time we call the glue code for time from main, the value under the stack pointer (i.e., r6) is cleared immediately beforehand since we're passing NULL (at ~main+06). We then invoke the system call, which per the Venix manual for time(2) uses two registers for the 32-bit result, namely r0 (high bits) and r1 (low bits). We passed a null pointer, so the values remain in those registers and aren't written anywhere (branch at _time+014). When we return to ~main+014, however, we only put r0 on the stack for srand (remember that r5 is being used as the frame pointer; see the disassembly I provided for csav) and r1 is completely ignored.
Why would this happen? It's because time(2) isn't declared anywhere in /usr/include or /usr/include/sys (the two C include directories), nor for that matter rand(3) or srand(3). This is true of both Rev. 2.0 and V2.0. Since the symbols are statically present in the standard library, linking will still work, but since the compiler doesn't know what it's supposed to be working with, it assumes int and fails to handle both halves of the long.
One option is to manually declare everything ourselves. However, from the assembly at _time+016 we do know that if we pass a pointer, the entire long value will get placed there. That means we can also do this:
long ltime; int itime; /* you can't just srand(time(NULL)) */ time(<ime); itime = ltime; srand(itime);
Now this gets the lower bits and there is sufficient entropy for our purpose (though obviously not a cryptographically-secure PRNG). Interestingly, the Venix manual recommends using the time as the seed, but doesn't include any sample code.
At any rate this was enough to make the pieces work for IP, ICMP and UDP, but TCP would bug out after just a handful of packets. As it happens, Venix has rather small serial buffers by modern standards: tty(7), based on the TIOCQCNT ioctl(2), appears to have just a 256-byte read buffer (sg_ispeed is only char-sized). If we don't make adjustments for this, we'll start losing framing when the buffer gets overrun, as in this extract from a test build with debugging dumps on and a maximum segment size/window of 512 bytes. Here, the bytes marked by dashes are the remote end and the bytes separated by dots are what the SLIP driver is scanning for framing and/or throwing away; you'll note there is obvious ASCII data in them.
45 00 00 28 0d 0b 00 00 40 06 57 3f 0a 00 02 0f 0a 00 00 78 1c fe 00 46 85 03 05 11 00 5c c7 fc 50 10 02 00 27 9d 00 00 .72..00..8b..00..00..69..74..20..6c..61..73..74..65..64..2e..20..57..68..6f..20. .6b..6e..6f..77..73..20..77..68..65..72..65..0d..0a..74..68..69..73..20..63..6c. .61..73..73..69..63..20..74..65..63..68..6e..6f..6c..6f..67..79..20..77..69..6c. .6c..20..6c..65..61..64..20..74..6f..6d..6f..72..72..6f..77..3f..0d..0a..0d..0a. .2d..2d..20..43..61..6d..65..72..6f..6e..20..4b..61..69..73..65..72..0d..0a..0d. .0a..2e..0d..0a..c0..45..00. -45 -00 -00 -28 -15 -69 -00 -00 -40 -06 -4e -e0 -0a -00 -00 -79 -0a -00 -02 -0f -00 -4f -db -c1 -00 -25 -1c -f4 -9c -39 -3e -c8 -50 -11 -22 -38 -a2 -e8 -00 -00
If we make the TCP MSS and window on our client side 256 bytes, there is still retransmission, but the connection is more reliable since overrun occurs less often and seems to work better than a hard cap on the maximum transmission unit (e.g., "mtu 256") from SLiRP's side. Our only consequence to dropping the TCP MSS and window size is that the TCP client is currently hard-coded to just send one packet at the beginning (this aligns with how you'd do finger, HTTP/1.x, gopher, etc.), and that datagram uses the same size which necessarily limits how much can be sent. If I did the extra work to split this over several datagrams, it obviously wouldn't be a problem anymore, but I'm lazy and worse is better!
The connection can be made somewhat more reliable still by improving the SLIP driver's notion of framing. RFC 1055 only specifies that the SLIP end byte (i.e., $c0) occur at the end of a SLIP datagram, though it also notes that it was proposed very early on that it could also start datagrams — i.e., if two occur back to back, then it just looks like a zero length or otherwise obviously invalid entity which can be trivially discarded. However, since there's no guarantee or requirement that the remote link will do this, we can't assume it either. We also can't just look for a $45 byte (i.e., IPv4 and a 20 byte length) because that's an ASCII character and appears frequently in text payloads. However, $45 followed by a valid DSCP/ECN byte is much less frequent, and most of the time this byte will be either $00, $08 or $10; we don't currently support ECN (maybe we should) and we wouldn't find other DSCP values meaningful anyway. The SLIP driver uses these sequences to find the start of a datagram and $c0 to end it. While that doesn't solve the overflow issue, it means the SLIP driver will be less likely to go out of framing when the buffer does overrun and thus can better recover when the remote side retransmits.
And, well, that's it. There are still glitches to bang out but it's good enough to grab Hacker News:
The code for Slirp-CK is on Github, along with this atrocity which I've christened BASS, the Barely Adequate SLIP Stack. Slirp-CK builds the usual way; go into its src/ directory, run configure and then run make (parallel make is fine, I use -j24 on my POWER9). Connect your two serial ports together with a null modem, which I assume will be /dev/ttyUSB0 and /dev/ttyUSB1. Start Slirp-CK with a command line like ./slirp -b 4800 "tty /dev/ttyUSB1" but adjusting the baud and path to your serial port. Take note of the specified virtual and nameserver addresses:% ./slirp -b 4800 "tty /dev/ttyUSB1" Slirp-CK v1.0.18 (BETA) Copyright (c) 1995, 2025 Danny Gasparovski, Cameron Kaiser and others. All rights reserved. This program is copyrighted, free software. Please read the file COPYRIGHT that came with the Slirp package for the terms and conditions of the copyright. Opening device /dev/ttyUSB1... Setting baudrate to 4800 IP address of Slirp host: X.X.X.X IP address of your DNS(s): Y.Y.Y.Y Your address is 10.0.2.15 (or anything else you want) Type five zeroes (0) to exit. [autodetect SLIP/CSLIP, MTU 1500, MRU 1500, 4800 baud] SLiRP Ready ...
Unlike the given directions, you can just kill it with Control-C when you're done; the five zeroes are only if you're running your connection over standard output such as direct shell dial-in (this is a retrocomputing blog so some of you might).
To see the debug version in action, next go to the BASS directory and just do a make. You'll get a billion warnings but it should still work with current gcc and clang because I specifically request -std=c89. If you use a different path for your serial port (i.e., not /dev/ttyUSB0), edit slip.c before you compile.
You don't do anything like ifconfig with these tools; you always provide the tools the client IP address they'll use (or create an alias or script to do so). Similarly, there are no explicit commands for bringing the SLIP link "up" or "down." Try this initial example, with slirp already running:
% uname -om ppc64le GNU/Linux % gcc -v [...] gcc version 14.2.1 20240912 (Red Hat 14.2.1-3) (GCC) % make -j24 gcc -O2 -g -std=c89 -DDEBUG -c -o slip.o slip.c gcc -O2 -g -std=c89 -DDEBUG -c -o ping.o ping.c gcc -O2 -g -std=c89 -DDEBUG -c -o nslookup.o nslookup.c gcc -O2 -g -std=c89 -DDEBUG -c -o dns.o dns.c gcc -O2 -g -std=c89 -DDEBUG -c -o ntp.o ntp.c gcc -O2 -g -std=c89 -DDEBUG -c -o minisock.o minisock.c gcc -O2 -g -std=c89 -DDEBUG -c -o tcp.o tcp.c gcc -o nslookup nslookup.o slip.o dns.o gcc -o ntp ntp.o slip.o dns.o gcc -o ping slip.o ping.o gcc -o minisock minisock.o tcp.o slip.o dns.o % ./ping 10 0 2 15 10 0 2 2 45 00 00 54 d8 34 00 00 40 01 8a 64 0a 00 02 0f 0a 00 02 02 08 00 da 7f e6 c4 00 01 67 d7 65 97 00 06 7e 42 08 09 0a 0b 0c 0d 0e 0f 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37 .c0..45..00. -45 -00 -00 -54 -29 -60 -00 -00 -ff -01 -7a -38 -0a -00 -02 -02 -0a -00 -02 -0f -00 -00 -e2 -7f -e6 -c4 -00 -01 -67 -d7 -65 -97 -00 -06 -7e -42 -08 -09 -0a -0b -0c -0d -0e -0f -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -1a -1b -1c -1d -1e -1f -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -2a -2b -2c -2d -2e -2f -30 -31 -32 -33 -34 -35 -36 -37 reply from 10.0.2.2 (packet 0001) 45 00 00 54 68 fa 00 00 40 01 f9 9e 0a 00 02 0f 0a 00 02 02 08 00 da 7e e6 c4 00 02 67 d7 65 97 00 06 7e 42 08 09 0a 0b 0c 0d 0e 0f 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37 .c0..45..00. -45 -00 -00 -54 -29 -61 -00 -00 -ff -01 -7a -37 -0a -00 -02 -02 -0a -00 -02 -0f -00 -00 -e2 -7e -e6 -c4 -00 -02 -67 -d7 -65 -97 -00 -06 -7e -42 -08 -09 -0a -0b -0c -0d -0e -0f -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -1a -1b -1c -1d -1e -1f -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -2a -2b -2c -2d -2e -2f -30 -31 -32 -33 -34 -35 -36 -37 reply from 10.0.2.2 (packet 0002) ^C
Because I'm super-lazy, you separate the components of the IPv4 address with spaces, not dots. In Slirp-land, 10.0.2.2 is always the host you are connected to. You can see the ICMP packet being sent, the bytes being scanned by the SLIP driver for framing (the ones with dots), and then the reply (with dashes). These datagram dumps have already been pre-processed for SLIP metabytes. Unfortunately, you may not be able to ping other hosts through Slirp because there's no backroute but you could try this with a direct SLIP connection, an exercise left for the reader.
If Slirp doesn't want to respond and you're sure your serial port works (try testing both ends with Kermit?), you can recompile it with -DDEBUG (change this in the generated Makefile) and pass your intended debug level like -d 1 or -d 3. You'll get a file called slirp_debug with some agonizingly detailed information so you can see if it's actually getting the datagrams and/or liking the datagrams it gets.
For nslookup, ntp and minisock, the second address becomes your accessible recursive nameserver (or use -i to provide an IP). The DNS dump is also given in the debug mode with slashes for the DNS answer section. nslookup and ntp are otherwise self-explanatory:
% ./ping usage: ./ping so ur ce ip re mo te ip % ./ntp usage: ./ntp [-i] so ur ce ip se rv er ip [hostname] % ./nslookup usage: ./nslookup so ur ce ip re so lv er name
minisock takes a server name (or IP) and port, followed by optional strings. The strings, up to 255 characters total (in this version), are immediately sent with CR-LFs between them except if you specify -n. If you specify no strings, none are sent. It then waits on that port for data and exits when the socket closes. This is how we did the HTTP/1.0 requests in the screenshots.
% ./minisock usage: ./minisock [-in] so ur ce ip se rv er ip [servername] port [string] [string] ...
There are examples for all of these commands in the BASS documentation, which is as barely adequate as the code.
On the DEC Pro, this has been tested on my trusty DEC Professional 380 running PRO/VENIX V2.0. It should compile and run on a 325 or 350, and on at least PRO/VENIX Rev. V2.0, though I don't have any hardware for this and Xhomer's serial port emulation is not good enough for this purpose (so unfortunately you'll need a real DEC Pro until I or Tarek get around to fixing it). The easiest way to get it over there is Kermit. Assuming you have this already, connect your host and the Pro on the "real" serial port at 9600bps. Make sure both sides are set to binary and just push all the files over (except the Markdown documentation unless you really want), and then do a make -f Makefile.venix (it may have been renamed to makefile.venix; adjust accordingly).
Establishing the link is as simple as connecting your server's serial port to the other end of the BCC05 or equivalent from the Pro and starting Slirp to talk to that port (on my system, it's even the same port, so the same command line suffices). If you experience issues with the connection, the easiest fix is to just bounce Slirp — because there are no timeouts, there are also no retransmits. I don't know if this is hitting bugs in Slirp or in my code, though it's probably the latter. Nevertheless, I've been able to run stuff most of the day without issue. It's nice to have a simple network option and the personal satisfaction of having written it myself.
There are many acknowledged deficiencies, mostly because I assume little about the system itself and tried to keep everything very simplistic. There are no timeouts and thus no retransmits, and if you break the TCP connection in the middle there will be no proper teardown. Also, because I used Slirp for the other side (as many others will), and because my internal network is full of machines that have no idea what IPv6 is, there is no IPv6 support. I agree there should be and SLIP doesn't care whether it gets IPv4 or IPv6, but for now that would require patching Slirp which is a job I just don't feel up to at the moment. I'd also like to support at least CSLIP in the future.
In the meantime, if you want to try this on other operating systems, the system-dependent portions are in compat.h and slip.c with a small amount in ntp.c for handling time values. You will likely want to make changes to where your serial ports are and the speed they run at and how to make that port "raw" in slip.c. You should also add any extra #includes to compat.h that your system requires. I'd love to hear about it running other places. Slirp-CK remains under the original modified Slirp license and BASS is under the BSD 2-clause license. You can get Slirp-CK and BASS at Github.