Bit-Cell Management
At a slightly higher level, you need to perform bit-cell management. What I mean by this is that we need to know where one bit ends and another bit starts. Obviously, if the bits have different values, this is easy: When the value changes, one bit has ended and another has started. However, what if there are several bits all with the same value, one after another? This is the topic of "clock recovery."
Because the data stream is arriving at 1200 baud, you know that each bit cell is 1/1200 of a second long, or 833.333... microseconds. Next you calculate how many samples (at 8 kHz) 1/1200 of a second is. Well, this is the same number I came up with earlier when I calculated how many samples I needed to store one complete waveform at 1200 Hz. It's the value 6 and 2/3. This means that every 6 2/3 samples we have the end of one bit, and the beginning of another. However, since I am working with integral samples, it means that I have to compromise a little bit, and potentially introduce drift. (Drift is introduced not only by rounding errors, but also because your sound card will not be sampling at exactly 8 kHz, nor will the phone company be transmitting at exactly 1200 baud; there will be some variation.)
The saving grace here, though, is that you are dealing with a serial protocol that has a start bit and a stop bit, which are different from each other. This means that you are guaranteed to have at least two bit reversals during an 8-bit data byte. Recall that a bit reversal (a change from a mark to a space or vice-versa) tells you the definitive location of a bit-cell boundary. You therefore use those for synchronization. Listing Two is the code that does that part. The value celladj is a floating-point value that indicates the fraction of a bitcell that elapses with each sample. (Yes, this could have been done with integers, but I got lazy.)
// store previous bit in preparation handle -> previous_bit = handle -> current_bit; // compute current bit (as above) handle -> current_bit = (factors [0] * factors [0] + factors [1] * factors [1] > factors [2] * factors [2] + factors [3] * factors [3]); // if there's a bit reversal if (handle -> prevbit != handle -> current_bit) { // adjust cell position to be in the middle handle -> cellpos = 0.5; } // walk the cell along handle -> cellpos += handle -> celladj; // gone past the end of the bitcell? if (handle -> cellpos > 1.0) { // compensate handle -> cellpos -= 1.0; // tell the higher level application that we have a new bit value bit_outcall (handle -> current_bit); }
So far, you've accumulated a sequence of bits, and called bit_outcall with each bit as it arrived from the software modem. You now need to construct a software UART; something that looks for a start bit, accumulates 8 bits (ignores the stop bit), and calls a function to indicate that a completed data byte has arrived; see Listing Three. This implements a trivial state machine.
// if waiting for a start bit (0) if (!handle -> have_start) { if (bit) { // ignore it, it's not a start bit return; } // got a start bit, reset handle -> have_start = 1; handle -> data = 0; handle -> nbits = 0; return; } // here, we have a start bit // accumulate data handle -> data >>= 1; handle -> data |= 0x80 * !!bit; handle -> nbits++; // if we have enough... if (handle -> nbits == 8) { // ship it to the higher level byte_outcall (handle -> data); // and reset for next time handle -> have_start = 0; }
The state machine begins the operation with have_start set to zero, indicating that you have not yet seen the start bit. A start bit is defined as a zero, so while you receive 1s, you simply ignore them. Once you do get a zero, you set have_start to a 1, clear the data byte that you'll accumulate, and reset the number of bits that you've received to zero. As new bits come in, you shift them into place (using my favorite bit of obfuscated C, the "Boolean typecast operator," or "!!"), and bump the count of received bits. When the count reaches eight, you've received all of the bits you need to declare a completed byte, and you ship the completed data off to a higher level function (byte_outcall) and reset the have_start state variable back to zero to indicate that you are once again looking for a start bit.
If you are into such things, you could check to see that there is indeed a stop bit present. If there isn't a stop bit, you should declare a frame error in your serial transmission. I didn't bother for this application, but there is some commented-out code in the library that serves as a starting point. At this point, you have bytes coming from a sound cardpure magic.
What does this have to do with real time? Well, there's a lot of computations happening:
8000 x (24 x (multiply + accumulate) +
4 x multiplies + 2 accumulates) =
432,000 math operations per second
(to say nothing of the buffer management and housekeeping functions required).
So while I'd like to believe that I could do this on a late 1960s vintage PDP-8 minicomputer, the truth is that it would probably take at least an 8086-class processor to come close to the CPU requirements.
This ties in to the "real time enough" aspect. Just as certain tasks were completely out of the picture in terms of being done in software, so has the landscape changed in terms of "real-time" requirements. In 1993, I implemented the higher level caller-ID software, but relied on hardware FSK modems because it was inconceivable (to me, at least) that I could do this in software. The 1993 implementation relied on a real-time OS (QNX 4) to make sure that the CPU got allocated to the serial port handler when data arrived, and then got allocated to the high-level caller-ID software to ensure timely processing.
Today, I run the full software caller-ID package as described in this article on a free OS (FreeBSD 5.3) and I didn't even bother using the real-time scheduling features of the kernel. It barely uses enough CPU to show up as more than 0.00 percent on the CPU monitor. In short, it's "fast enough."