Note: I've just migrated to a different physical server to run Spivey's Corner,
with a new architecture, a new operating system, a new version of PHP, and an updated version of MediaWiki.
Please let me know if anything needs adjustment! – Mike

Lecture 9 – Serial I/O (Digital Systems)

Copyright © 2024 J. M. Spivey
Jump to navigation Jump to search

Serial communication

GPIO pins let us communicate from a program to the outside world, but they are little more (as far as we've seen) than a direct connection between the bits of a register in the microcontroller and signal wires in the external circuit. A serial communications interface provides an example of a more elaborate I/O device, capable of actions independent of the CPU; the complexity this adds is necessary, because serial transmission must be done with timing that is more precise than we can achieve with direct CPU control – or at least, more precise unless the CPU is doing nothing else.

[9.1] Here is a trace (obtained with a logic analyser) of a character being sent over a serial interface at a speed of 9600 baud: that is, 9600 bit-clocks per second.

RS-232 waveform

The relevant traces are the signal (marked D1) that appears on the wire, and below it two analyses performed by the logic analyser software starting from the captuted data. One of these (marked UART) shows the individual bits that are transmitted, and the characters they make up, and the other (marked Timing) shows the duration of each low or high period of the signal

The character begins with a start bit during which the signal is 0V; the start bit lasts for precisely 104μs = 1/9600s. There then follow 8 bits of data, sent LSB first, with 0 encoded by 0V and 1 encoded by +3.3V. The bits (in order of transmission) are 10010110, making (reading backwards) the character 0x69, or lower-case 'i' in ascii. The transmission ends with a stop bit at the high level, 3.3V. The signal could then stay at a high level for a while, but just disappearing off the right-hand side of the screen, you can see that the start bit for the next character in this case follows immediately. This pattern, a start bit followed by 8 data bits, (no hardware parity bit), and a stop bit, is the most common variant of serial transmission, and is described by the abbreviation 8N1. There are ten bit-periods per character transmitted, so 9600 8N1 is capable of a peak transmission rate of 960 characters/sec. These conventions are standardised and often go by the term RS-232: but strictly speaking that standard requires voltage levels of ±12–15V suitable for use with old-fashioned teleprinters, and recent computers more commonly use voltages of 5V and 0V or (as here) 3.3V and 0v. Although we shall not use it, it's possible to add a parity bit to each character so that the receiving end can check whether a bit of the character has been corrupted in transmission.

Note that there is no clock signal shared between receiver and transmitter, so precise timing is essential. In order to achieve this, both transmission and reception are commonly implemented in hardware. The transmitter makes sure each bit-period lasts precisely 104μs, and the receiver, after seeing the falling edge of the start bit, will wait for 1.5 bit-periods in order to sample the first bit in the middle of its period; then it samples at 2.5, 3.5, ... bit-periods after the start so as to capture successive bits. (Interfaces commonly sample multiple times per bit-period and use majority voting to improve their immunity to noise.) The receiver can look for the stop bit to verify that the timing is reasonably accurate before announcing that it has received a character.

The serial interface is called a UART (for Universal Asynchronous Receiver/Transmitter) – Universal because it can deal with speeds other than 9600 baud and encodings different from 8N1; and Asynchronous because of the lack of a shared clock. The UART derives its timing by dividing the 16MHz system clock, and part of the configuration process is to set the division ratio to give an accurate bit-rate. The UART has two halves – a receiver and a transmitter – and a serial connection usually consists of two wires, on linking TX on one device to RX on the other, and the second linking RX to TX, for "full duplex" operation. It's possible to add two additional wires labelled RTS and CTS for 'hardware flow control', so that either end can signal to the other that it is not ready to receive any more characters and transmission should be delayed. As is common, we will not bother with these signals, assuming that both ends are able to consume characters at the highest rate they can be transmitted. At 9600 baud, this should not cause any problems.

A basic driver

The nRF51822 has a single UART that can be connected to the USB interface chip on the micro:bit board, and then appears as a USB serial interface on the host computer. We have already used this interface to experiment with assembly language programming. The UART is presented to a program running on the nRF51822 as a collection of device registers at fixed addresses, and predefined contants like UART_TXD let us access these registers. Some of the registers are used for configuration, and we need not list them in detail, but two are used in the process of transmitting characters. By putting a character in the register UART_TXD, the program can start the UART transmitting the character over the serial line. To find out when the transmission is finished, the program can check another register, UART_TXDRDY, which is set to 1 when the UART is ready for another character. If another character is immediately put in UART_TXD, then it will follow the first character without a gap, as shown in the scope trace above. If there is some delay before the program provides another character, then the TX line will just remain high, and the device on the other end of the wire will wait.

[9.2] The main difficulty is that we mustn't ask the UART to transmit another character until it has finished with the previous one, or it will get confused, either cutting off the transmission of the first character in the middle, or maybe ignoring the second character. So before sending a character, we must ensure that UART_TXDRDY has been set to 1 by the device. The simplest way of doing this is to wait in a busy loop.

void serial_putc(char ch) {
    while (! UART_TXDRDY) { /* do nothing */ }
    UART_TXDRDY = 0;
    UART_TXD = ch;
}

(This code omits a detail connected with transmitting the very first character.) This works, but it suffers from the same problem as the LED program – while the serial_putc function is waiting, no useful work is being done.

[9.3] We can illustrate the problem with a program for printing the first 500 primes.

void init(void) {
     int n = 2, count = 0;

     serial_init();
     start_timer();

     while (count < 500) {
          if (prime(n)) {
               count++;
               printf("prime(%d) = %d\r\n", count, n);
          }
          n++;
     }

     stop_timer();
}

The program uses printf to print its results, a wrapper around serial_putc that looks like the standard C function for formatted output. I've included calls to functions start_timer and stop_timer that turn on and off an LED, so that we can time how long the program takes. We can run the program and connect to the micro:bit with minicom as before and see the primes scrolling past:

prime(1) = 2
prime(2) = 3
prime(3) = 5
...
prime(500) = 3571

Lab 3 has a version of this program: there, prime(n) is implemented by a simple algorithm that is quite slow, particularly because it is using a naïve algorithm for division. The time to find the next prime can get quite long, particularly when large gaps begin to appear, such as the one between 3229 and 3251. (And yes, we are testing the even numbers as well as the odd ones!)

[9.4] Initialising the UART is a bit involved, but the code can be copied from manufacturer's example programs, and amounts to making proper settings of several device registers. The two pins (defined by constants USB_TX and USB_RX) must be configured one as an output and the other as an input, both with pullup resistors enabled. Then there is a device register that is set according to the baud rate, and another to set the format to 8N1. At the end come some assignments to start the sending and receiving processes and clear the flags that indicate the interface is ready for a character.

/* serial_init -- set up UART connection to host */
void serial_init(void) {
    UART_ENABLE = 0;
    UART_BAUDRATE = UART_BAUD_9600;     // 9600 baud
    UART_CONFIG = 0;                    // format 8N1
    UART_PSELTXD = USB_TX;              // choose pins
    UART_PSELRXD = USB_RX;
    UART_ENABLE = UART_Enabled;
    UART_STARTTX = 1;
    UART_STARTRX = 1;
    UART_RXDRDY = 0;
    UART_TXDRDY = 0;
    txinit = 1;
}

[9.6] Actually, this program allows a small amount of overlap between printing one prime and looking for the next one. (How?) But once the primes start to thin out, pauses start to appear between lines of output. We can show this by connecting the micro:bit up to a logic analyser and capturing a trace. I am using an inexpensive PC-based logic analyser bought on eBay: similar devices are easily found by googling "logic anaylser 24MHz 8ch". They are all based on the same Cypress microcontroller, and all the smarts are in the associated open-source software.

Connecting a logic analyser

In the picture, the micro:bit is connected to two channels of the logic analyser, using an edge connector breakout to make the signals available on header pins.

  • Channel 0 (black wire) is connected to a GPIO pin that is used to drive one of the LEDs. This shows when the LED signal is illuminated. (Detail: the column I've used is not one of the ones that is lit, so it is high when the LED pattern is placed on the GPIO pins, and reverts to low when the output register is reset to zero.)
  • Channel 1 (brown wire) is connected to the transmit line of the UART. The transmit and receive lines between the UART and the USB interface chip are not brought out to the edge connector, but are available at certain test points on the board. In the picture, a bodge wire connects the relevant test point to a header glued to the edge of the board, on the back as we look at it. The orange wire joins the header to the brown lead of the logic analyser.
  • The third connection (white wire) is ground.

The logic analyser lets us see traces of multiple signals over time: unlike an oscilloscope, it can record data from 8 channels for an almost unlimited time, but it records only a digital logic level for each channel, and not an analogue voltage. The logic analyser's software is equipped with decoders for a variety of protocols including serial data, so we can ask it to annotate the trace with the decoded characters.

[9.7] Here is a trace of the beginning of a run of the program, on a smaller timescale than the trace we saw earlier.

Start of execution
  • In the picture, you can see two traces: one (Channel 0) is the long pulse that powers the LED, showing that it takes 11.618 seconds to find all 500 primes; the other (Channel 1) is the signal on the serial line, and above it a decoding of the characters that are being sent. You can see the string "prime(1) = 2", followed by a carriage return ([0D]) and line feed ([0A]). The p that begins the next line of output "prime(2) = 3" follows immediately after the line feed without any delay.
  • The scale at the top is in milliseconds, and you can see that transmitting each character takes just a bit more than 1ms.

Further along the trace, we can see delays start to appear between one line of output and the next. Two successive lines of output read

prime(457) = 3229
prime(458) = 3251

Here is the corresponding part of the logic analyser trace, zoomed about a bit to show the length of the gap between them.

Gap in the output

From the times at the top, you can see that this portion comes from late in the run, about 10.2s in. There is a period of 25ms or so when the serial line is idle, between the line feed that ends one line of output and the p that begins the next one. During this period, the machine is testing successive numbers to see whether they are prime, and it is spending most of its time in a loop inside the division subroutine. Once the next prime has been found, the activity changes, and the machine spends most of its time in the loop shown above, waiting for UART_TXDRDY to become true. This behaviour is silly, because instead of idly waiting for transmission of a character to finish, the micro:bit could be doing useful work towards finding the next prime. As we shall see later, we can arrange for this to happen (without having to restructure our whole program) by introducing a buffer to store pending output characters, and using interrupts to make the processor respond whenever the UART is ready for the next character, without having it sit in a loop waiting.

Lecture 10