A timer driven PWM servo controller - part 3
This is part three of a series of articles about the servo controller that I’m building for use in the hexapod robot that I intend to build. The first two articles in the series have presented the timer driven PWM generation code and the code used to take the configuration data that is managed by the serial port protocol and convert it into the data that is needed by the PWM generation code. Now we will develop some simple serial port handling code that will be used to allow maintenance of the configuration data that is used to generate the PWM signals.
Since the purpose of this design is to allow the servo controller to produce rock solid PWM signals without any interference from the serial protocol code the serial code will not use interrupts. This allows the timer driven PWM generation code to run with the highest priority and for it to always run when it needs to run. Using polling rather than interrupts for the serial port code also simplifies how the serial protocol is implemented. With an interrupt driven serial system not only could the processing of the serial receive interrupt delay the PWM generation timer interrupts but we’d also need to explicitly deal with buffering serial data that arrived whilst we were processing commands; if we had buffer space for 3 bytes of protocol data and we were busy processing the command that had just arrived we’d need to deal with any new serial data received somehow. With the polled approach we simply don’t look for more serial data until we are ready to process it. The protocol itself can require that the computer at the other end of the serial link only sends one command at a time and waits for a response to the current command before sending the next. If the remote computer doesn’t still to that protocol then we may end up with garbled commands but we wont need to worry about buffering pending commands… Whilst the way we’re separating out the serial and PWM generation code is overkill for the simple 3 byte SSC commands that we’ll implement here it will become more important as we create a more complex command structure later on. The thing to realise is that it doesn’t matter how long it takes us to process the serial protocol, the PWM generation will always be correct and adjusting the serial protocol code will not require us to tinker with the PWM generation code to ensure that the timing stays correct.
Similarly to the part 2, I’m going to present the serial handling code in isolation here. Once it’s working we can merge it with the code in parts 1 and 2 to get the final serial controller firmware.
Step one is to define a few constants and set up the UART to run at 9600 baud, 8N1. For this code we’ll use a system clock at 7.3728MHz rather than the 8.0MHz clock we were using for the earlier PWM examples. This clock is ‘baud rate friendly’ so we’ll have less scope for serial data errors. We will adjust the PWM code to work with this slightly slower clock rate when we merge the code together.
.nolist .include "tn2313def.inc" .list .equ POSITION_DATA_START = SRAM_START .equ NUM_SERVOS = 64 .equ PWM_DATA_START = POSITION_DATA_START + NUM_SERVOS .equ PWM_SERVOS_PER_CYCLE = 8 .equ PWM_BYTES_PER_SERVO = 3 .equ PWM_DATA_SIZE = (PWM_SERVOS_PER_CYCLE + 1) * PWM_BYTES_PER_SERVO .equ SERIAL_DATA_START = PWM_DATA_START + PWM_DATA_SIZE .equ SERIAL_DATA_LENGTH = 2 .equ SERIAL_DATA_END = SERIAL_DATA_START + SERIAL_DATA_LENGTH .equ clock = 7372800 .equ baudrate = 9600 .equ baudconstant = (clock/(16*baudrate))-1
The equates define some constants for our data areas, we’ve kept the PWM data area definitions in this code so that we can be sure that everything fits nicely together. The important things for the serial code are the fact that we have a 2 byte serial protocol buffer and that we know the start and end locations of it. The baud rate calculation gives us the constant that we need to use to configure the UART for the required baud rate. As with the earlier code examples, we clear down the whole of SRAM to zero before we start so that debugging in the simulator is easier.
.def count = r16 .def serialChar = r17 .def temp20 = r20 .def temp21 = r21 .org 0 ldi temp20, LOW(RAMEND) out SPL, temp20 ; initialise the sram ldi XL, LOW(SRAM_START) ; clear down the area we'll be working in ldi XH, HIGH(SRAM_START) ; to make it easier to debug ldi count, SRAM_SIZE clr temp20 fill: st X+, temp20 dec count brne fill
The serial port is straight forward to set up
; Set up the serial port ldi temp20, HIGH(baudconstant) ; Set the baud rate out UBRRH, temp20 ldi temp20, LOW(baudconstant) out UBRRL, temp20 ldi temp20, (1 << RXEN) | (1 << TXEN) ; enable rx and tx out UCSRB, temp20 ldi temp20, (3 << UCSZ0) ; 8N1
And once that’s done we can enter the endless loop that forms the serial protocol handling code. The purpose of this code is to take serial commands in the popular, three byte, SSC format of 0xFF <servo> <value> and update the control byte in the “position data store” for the appropriate servo. We start by setting up the X pointer to point to the end of our serial data buffer. We do this so that we can simplify the error handling. The protocol requires 3 bytes, the first of which is 0xFF, when we get an 0xFF byte we set the X pointer to point at the start of the 2 byte serial data buffer and store any subsequent bytes. If get a 4th byte (which would be the 1st byte of a new command) which is not 0xFF then we report an error. By starting off as if we’ve already processed a command we are ready to check for 0xFF with no additional code… The serial loop is pretty much straight from the ATTiny2313 datasheet. We loop waiting for a byte to arrive and then act on it once it does. If it’s an 0xFF we set the X pointer to the start of our data buffer, if it isn’t we jump to
SerialStart : ; Set up pointer to serial buffer, we start by pointing to the end so that ; we need a valid 0xff to start the first command... ldi XL, LOW(SERIAL_DATA_END) ldi XH, HIGH(SERIAL_DATA_END) SerialLoop: sbis USR, RXC ; check for serial data rjmp loop in serialChar, UDR ; read the character cpi serialChar, 0xFF ; is it the start character? brne SerialDataCharacter ldi XL, LOW(SERIAL_DATA_START) ldi XH, HIGH(SERIAL_DATA_START) rjmp SerialLoop ; wait for a data character...
The SerialDataCharacter handling code checks to see that we’re not trying to overflow our serial buffer by storing more than we have space for and then stores the new byte. If that’s the end of the command we jump off to SerialProcessCommand to process it, if not we loop again to get more data.
SerialDataCharacter : cpi XL, SERIAL_DATA_END ; if we have already filled our buffer space breq SerialError ; that's an error st X+, serialChar cpi XL, SERIAL_DATA_END ; have we filled the buffer? breq SerialProcessCommand rjmp SerialLoop
SerialProcessCommand reads the first byte from our serial buffer and checks that it refers to a servo that is between 0 and the number of servos that we support. It then checks that the next byte is not 0xFF as the valid servo control value range is 0-254. Of course this check is redundant as the command start sentinel check that resets the X pointer will catch any 0xFF bytes, it’s handy to leave it in for now, however, as it will become useful as we redesign the serial protocol.
SerialProcessCommand : ; we have a 2 byte serial command... ldi XL, LOW(SERIAL_DATA_START) ldi XH, HIGH(SERIAL_DATA_START) ld temp20, X+ cpi temp20, NUM_SERVOS ; check the servo index is valid brge SerialServoOutOfRange ld temp21, X+ cpi temp21, 0xFF ; check the servo position is valid breq SerialServoOutOfRange ; deal with it... ; we have a servo index 0-NUM_SERVOS in temp20 ; we have a control value 0-254 in temp21 ldi XL, LOW(POSITION_DATA_START) ldi XH, HIGH(POSITION_DATA_START) add XL, temp20 brcc SerialStoreData ; not really needed as our data area is so small.. inc XH
Now that we know our servo index is value we index into the position data store using X and store the new control value there. We then echo back the entire command to the computer on the other end of the serial link and loop to process the next command.
SerialStoreData: st X, temp21 ; echo the command back to the sender... ldi XL, LOW(SERIAL_DATA_START) ldi XH, HIGH(SERIAL_DATA_START) ldi serialChar, 0xFF rcall SendSerial ld serialChar, X+ rcall SendSerial ld serialChar, X+ rcall SendSerial rjmp SerialStart
The rest is just error handling and data sending…
SerialError : ; we could light a led and only unlight it on valid data... rjmp SerialStart SerialServoOutOfRange : ; send the error message back to the sender... ldi XL, LOW(SERIAL_DATA_START) ldi XH, HIGH(SERIAL_DATA_START) ldi serialChar, 0xFF rcall SendSerial ld serialChar, X+ rcall SendSerial ldi serialChar, 0xFF rcall SendSerial rjmp SerialStart ; Send the contents of serialChar out of the serial port... SendSerial : sbis UCSRA,UDRE ; wait for transmitter ready rjmp SendSerial out UDR, serialChar ret
As you can see, the serial protocol handling code is quite simple and very isolated from the complexities of the PWM generation code. The interface between the two pieces of code is the ‘position data store’ a sequence of single byte control values that are stored at the offset of the servo that they relate to. Once all of the code is merged together it will be relatively easy to test new serial protocols against the PWM generation code. It would also be straight forward to switch out the serial configuration code and, instead, or additionally, replace it with code that uses the USI two wire or USI three wire protocol, or something else entirely. The source code to this stand alone code example can be found here. In the next part we’ll merge the code from this and the previous 2 parts into a fully operational 64 channel servo controller.