This is post #4 in a series covering the hardware and software design for my work-in-progress GPS-guided rocket recovery project. The main index to the series of posts is here, and an introduction to the project (a PowerPoint presentation) is here.
This post covers main.c, which has just one function in it, main(), the program that runs on power-up or reset.
main() starts by calling SetupHardware() in hardware.c, which initializes the PIC internal registers. Many of the values used to initialize things are computed from macros based on tweaks.h. OSCCON is set to control the CPU clock frequency, INTCON and PIE are set to enable the global, peripheral, UART, and Timer1 interrupts. SetupHardware() has comments on how Timer1 is used to generate the servo control pulses, which I won’t repeat here, but see my discussion of this in my posting about the ISR.
Next 16-bit timers Timer1 and Timer3 are initialized to run at a rate of 250 kHz (1/32 of the 8 MHz CPU clock) and started.
Then, the I/O ports are setup (direction and weak pullups on Port B), CVRef is setup to produce a reference voltage and an analog comparator is configured for level-conversion of the incoming received GPS data (see the hardware description for details of that) and an ADC is setup to read AN1 for the pressure sensor.
Finally Timer2 (TMR2) and CCP2 are setup to produce the 47 kHz square wave modulation for the camera control IR pulses (again, see the hardware description). Finally, the magnetic compass is initialized, if the compilation switch COMPASS is defined (it isn’t, now).
main() then turns on all 3 LEDs (to indicate that the system is powered up and running), sets the servo to the middle position, initializes the leaky integrator values used to smooth out pressure sensor readings, calls InitGPS() to send GPS commands to set the desired baud rate and mode (38,400 bps, $GPRMC and $GPGGA messages only), and initializes the state machine.
The last thing main() does before entering the main (endless) software loop is:
nextRTC8 = RTC8 + LOOP_PERIOD;
This deserves some explanation, as I use this technique a lot (mostly in stateMachine.c – see next post).
Global 32-bit variable RTC holds a count of real-time-clock (RTC) interrupts since power-up. Macros RTC24, RTC16, and RTC8 (all in globals.h) return the low-order 24, 16, and 8 bits of this count (respectively – note that the C18 compiler supports 24 bit wide variables).
This statement assigns the modulo-256 (8 bit) sum of the lower 8 bits of this count (RTC8) and a constant called LOOP_PERIOD to an 8-bit variable called nextRTC8.
LOOP_PERIOD is defined in tweaks.h as the number of RTC ticks in 40 milliseconds (25 Hz). So nextRTC8 represents the future value RTC8 will have 40 milliseconds from now.
By comparing nextRTC8 to RTC8, we can easily determine whether the 40 milliseconds have expired. Macros YET8(), YET16(), YET24(), YET32() and NOTYET8(), NOTYET16(), NOTYET24() and NOYET32() (all in globals.h) implement this comparison and allow for the possibility that by the time the comparison is made (between nextRTC8 and RTC8), RTC8 might have already moved on to a later value (if the check isn’t performed between each tick of the RTC interrupt).
So, when later in main() we say:
we will continue in the while() loop as long as the current time has not yet reached future time “nextRTC8” (40 milliseconds from the time we set the value of nextRTC8).
Of course, nextRTC8 and RTC8 are only 8 bits wide, so the time represented in nextRTC8 had better not be more than 127 RTC ticks in the future (127 mS). If you need to deal with longer time periods than that, you’ll have to use one of the wider versions – RTC16, RTC24, or RTC32.
The main loop of the program starts with while(1) and runs once every 40 milliseconds (25 Hz). All system functions are driven by this loop, except what the interrupt service routine (ISR) does.
A 32-bit variable CycleCount is incremented each time through.
Then CheckForNewGPSFix() is called (from nmeaGps.c). Any incoming data from the GPS has been received by the ISR and placed in a small text buffer, NMEAbuffer. CheckForNewGPSFix() looks at the text buffer to see if a complete message has been received from the GPS. If so, it parses the message. ($GPGGA or $GPRMC; these are the only two messages that are handled.)
If CheckForNewGPSFix() finishes parsing a complete GPS fix (meaning one each of both $GPGGA and $GPRMC), the fix data is stored in a structure of LOG_ABSOLUTE_GPS_RECORD type (defined in logging.h), which is pointed at by global variable LastGPSFixPtr, and the global flag AnyFix is set to TRUE. Otherwise (if a complete new GPS fix has not yet been received), AnyFix is set to FALSE.
Note that AnyFix == TRUE means only that a complete fix message has been received and parsed from the GPS receiver; it doesn’t mean the fix is valid or trustworthy (there’s a separate global flag, PositionFix, which tells you that).
After CheckForNewGPSFix() returns, if a new GPS fix was received (if AnyFix == TRUE), then the GPS_LED is inverted to flash the LED. This provides a visual “heatbeat” indicating that the GPS receiver is running and communicating OK with the board. A few lines later, after reading the pressure sensor and checking the buttons, the LED will be inverted again back to its original state. The GPS_LED is the yellow one, as mentioned in my post about the hardware (see hardware.h for the LED hardware abstractions).
main() then records the current (16 bit) RTC count in rtcPressure, and reads the pressure sensor using the macro ReadPressureSensor() from peripherals.h. ReadPressureSensor() accumulates multiple 10-bit ADC readings to improve the effective ADC resolution. The RTC count is recorded so that the exact time the pressure sensor was sampled can be used to plot the pressure (altimeter) readings post-flight; when the rocket is accelerating, small amounts of time can represent a significant change of altitude.
Note that the “Pressure” value returned by ReadPressureSensor() is at this stage is in ADC units related to the voltage output by the sensor, not in units directly comparable to kPa pressure or altitude. The routine PressureToFeet() in peripherals.c will convert the pressure readings to kPA and ISA pressure altitude (in feet above ground level), but the calculation is time-consuming, and unnecessary for the purpose of data logging or launch detection – the ADC values can be converted to altitude post-flight. I have a separate MATLAB/Octave program that does this on a PC. (Calculation of altitude above mean sea level requires also taking into account temperature and local sea-level-equivalent pressure due to weather; these factors don’t have a huge effect on calculation of altitude relative to the launch site. I’ve found Ed William’s “Aviation Formulary” a useful reference for this.)
Next, the pressure value is filtered through two “leaky bucket” integrators (each with a different time constant) to smooth out noise in the individual pressure readings.
This results in two values, SlowTCpressure (slow time constant), and FastTCpressure (fast time constant).
The slow time constant of SlowTCpressure results in a smoother but slower-tracking value than FastTCpressure (noisier but faster-tracking). DeltaTC is computed as the difference between the two values, and is used to detect launch of the rocket. When DeltaTC exceeds the value LaunchThreshold (a value found from a combination of theory and experience), this indicates that the rocket’s altitude has risen significantly in a short amount of time, and “LDet” (launch detect) becomes TRUE. (In other words, FastTCpressure is significantly lower than SlowTCpressure, indicating a high rate-of-climb).
My experience is that this rate-of-climb method based on filtered values works more quickly and reliably than looking for a given pressure drop (regardless of how long it takes), which some other rocket altimeters use. Launch is reliably detected within 1/4 second of leaving the pad (often sooner), and no false launch detections have occurred.
Detection of the rocket reaching apogee (global variable ADet) is performed by:
ADet = FastTCpressure <= Pressure;
As long as the rocket is rising rapidly, the smoothed and slightly time lagged FastTCpressure value will be reliably higher than the most recent Pressure value. After motor burnout, gravity gradually slows the rise of the rocket until it starts to arc over. At that point the lagging FastTCpressure value catches up to the Pressure value, and apogee is detected.
There is no need to filter the (somewhat noisy) Pressure readings. The noise ensures that apogee is detected as soon as the rocket goes horizontal. Filtering would delay detection of apogee until past the point where the rocket is at its maximum altitude and minimum velocity, which is the best time for parachute deployment (to minimize deployment stress on the parachute).
There is no provision for dual deployment (deployment of a drogue at apogee, followed by the main parachute at a lower altitude). Since the ultimate goal of the project is to have the software and servo steer the parachute back to the launch point, it’s best to deploy at the maximum altitude to allow as much time as possible for gliding back.
Of course, this algorithm detects “apogee” whenever the rocket isn’t climbing rapidly, including when it’s sitting on the launch pad prior to flight. That’s not a problem, as the ADet global variable is only used to trigger parachute ejection when the software is in the FLIGHT state (after launch has been detected).
In practice, this has proved 100% reliable, and gives very fast at detection of apogee in flight. Based on post-flight analysis of the flight data, apogee is detected within 1/25 of a second.
The combination of rate-of-climb based launch detection and rapid apogee detection has proven to be a useful safety feature – more than once a rocket suffering stability problems or structural failure has been “saved” at low altitude by immediate deployment of the parachute. Once launch is detected, the parachute is ejected almost almost instantly as soon as the rocket is headed downward or horizontally for any reason – even if no significant altitude has been reached or the motor is still firing. (I have dramatic video of this happening due to a forward closure failure with the motor firing out both ends at about 50′ AGL; I’ll try to post it here one day.)
Switches and state machine
Next, main() calls PollSwitches() (in peripherals.c) to check the state of the three push buttons.
PollSwitches() checks the status of each switch and updates a global variable (SwRedHeld, SwYellowHeld, and SwGreenHeld) based on the state of the button. If the button is depressed, the variable is incremented by LOOP_PERIOD (the number of RTC ticks in one loop iteration). If it’s not depressed, the variable is reset to zero.
The result is that each of the three global variables always stores the number of RTC ticks for which the button has been held down. This makes it easy to determine if the button has been pressed only momentarily, or for some longer period, which sometimes has a different meaning.
Then AnyFix is checked again to determine if the GPS_LED should be restored to its former state.
The call to StateMachine() runs the finite state machine (in stateMachine.c) thru one step. This is where most of the logic of the system is contained, and will be covered in my next post.
Logging of flight data
Flash memory space for logging of flight data is limited (it’s 32k bytes in this version of the software). Several tactics have been adopted to conserve this space:
- Data is logged to Flash only in the ARMED DANGER and FLIGHT states.
- While waiting for launch in ARMED DANGER, a data record is stored only once every 3 seconds (SLOW_LOG_PERIOD).
- From launch to apogee, data is logged at the full rate – once every 40 milliseconds (25 Hz, FAST_LOG_PERIOD).
- During descent (a lot less exiting than ascent), data is logged at a medium rate – every 200 mS (5 Hz, MED_LOG_PERIOD).
- GPS fixes are losslessly compressed using delta coding (each GPS fix differs only slightly from the previous one, so storing only the differences between fixes requires less space than storing the fixes themselves).
These make it possible to store an entire flight’s data, plus pre-flight data at the slow rate of 1/3 Hz, in the limited Flash memory.
As can be seen in logging.h, four types of log records are defined:
Each record type begins with a “recordType” marker that indicates which type of record has been stored (and, implicitly, its size and structure). Reading the log involves parsing the records, on at a time – that is done post-flight on a PC by my MATLAB/Octave program.
The LOG_INIT_RECORD is used once at the beginning of the log, and records various information about the the software build that will be useful when reading stored logs in the future.
The LOG_STATUS_RECORD records the status of the system as of the most recent run through the state machine (which happens once per main() loop iteration). This includes the state of the SAFE/ARM switch (swArmed), whether or not parachute ejection has been detected optically (ejDetect), circuit continuity for the parachute ejection system (ejCont), the CycleCount, the RTC count value just prior to reading the pressure sensor (rtc16), the Pressure reading, and the State and Substate of the finite state machine.
The LOG_ABSOLUTE_GPS_RECORD is used to record a GPS fix. This record type does not use delta coding, and is used for the first GPS fix recorded and then again for the first valid GPS fix (validity as reported by the GPS receiver) after an invalid fix (because the fix values may be very different on a valid fix compared to an invalid fix) .
The LOG_DIFFERENTIAL_GPS_RECORD is used to record a GPS fix using delta coding. The only advantage to this is that the record requires less Flash memory space.
Capturing the launch event: The “lagged” buffer
The rocket doesn’t switch from ARMED DANGER into the FLIGHT state until launch is detected, which can be as much as 1/4 second after the actual liftoff. In order to capture full-rate data of the liftoff itself and the period immediately before it, a slightly complicated scheme is used.
After each call to stateMachine() returns, the system status is copied into local variable “status” of type LOG_STATUS_RECORD.
This status is then written to a buffer in RAM by LogStatusToLaggedBuffer() (in logging.c). The buffer stores only the most recent 25 status records (1 second of data) and is called a “lagged” buffer because the data will not be written to Flash at this time, and may never be. As the rocket waits on the launch pad for ignition in the ARMED DANGER state, the lagged buffer is continuously over-written with the most recent 25 status records. To conserve Flash memory, only a single status record is written to Flash memory every 3 seconds (SLOW_LOG_PERIOD).
When launch is detected, the state machine enters the FLIGHT state using enterFLIGHT() (in stateMachine.c). At that point, FlushLaggedRecords() is called to write the contents of the lagged buffer (the most recent 1 second of status data, recorded at the full rate of 25 Hz) to the log in Flash memory.
Depending on when launch occurs, some of these lagged status records may represent a time point earlier than the final status record written to Flash while still in the ARMED DANGER state, in which case some of the logged records are not stored in time-sequential order. However, each record has both a RTC time stamp and an identifying CycleCount value, so the records can be re-ordered post-flight.
This “lagged buffer” mechanism records full-rate data of the launch event, despite the up to 1/4 second latency in detecting launch.
Two global flags, DoLogStatus and DoLogFix (both defined in globals.c) are used to control logging in main().
If DoLogStatus is set to TRUE, this means the state machine logic has determined that the current status should be written to Flash memory. If so WriteToFlashBuffer() (in hardware.c) is called, which stores the status record to RAM in global variable FlashBuffer (not to be confused with the separate “lagged buffer”). When this function returns, DoLogStatus is reset in main().
WriteToFlashBuffer() does not directly write to Flash memory because writing to Flash stops PIC instruction execution for the duration of the write, which prevents the PIC from servicing interrupts. If the write to Flash were to occur when the GPS was sending fix data to the PIC, the ISR wouldn’t run to service the UART receive interrupt, the UART receive buffer would overrun, and the data would be lost.
To avoid that, data to be written to Flash is stored in RAM until it is “safe” to stop instruction execution, at a time when no critical interrupts are expected. (This is handled in the idle loop, discussed below.)
Similarly, DoLogFix == TRUE indicates that the most recent GPS fix is to be logged in Flash memory. If so, LogGPSRecord() in logging.h is called to construct an absolute or differential GPS record and then calls WriteToFlashBuffer(). As with DoLogStatus, DoLogFix is then reset (until the next time the state machine determines a GPS record should be logged).
By this point in the main loop, the GPS data has been read and parsed, the pressure read, the switches polled, the state machine run, and the status of the system logged. The main loop functions are complete, and the MCU has no more work to do until it’s time for the next iteration of the loop (after the remainder of the 40 mS loop interval). Therefore the system enters the idle loop.
starts the idle loop, which runs while it is not yet time to start the next iteration of the loop.
The idle loop performs four functions repeatedly:
This macro, defined in debug.h, drops output pin RC4 (symbol SPEEDO in hardware.h), which indicates for debugging purposes that the system is now in the idle loop with nothing to do. When the PIC exits the idle loop, the SPEEDO output will be raised (symbol CPU_BUSY), which will indicate when the PIC is busy executing system functions.
As I mentioned, writing to Flash memory stalls the CPU and prevents interrupt servicing. Therefore data to be logged to Flash is buffered in RAM, and FlushFlashBufferSafe() in hardware.c is called while in the idle loop.
The only critical interrupts are from the UART receive buffer – when each byte of data arrives from the GPS, an interrupt is generated and the UART receive buffer must be emptied before the next byte arrives (in about 260 microseconds at 38,400 bps). The write to Flash memory takes far longer than this, so it can’t be allowed to happen while the GPS is transmitting.
Happily, the GPS sends data in bursts. Once for each fix (once a second for most GPS receivers), it sends data for 40 to 60 milliseconds (at 38,400 bps), and then nothing until the next fix. There is plenty of time between GPS data bursts to write to Flash memory.
The GPS data bursts are very predictable. The ISR (in isr.c) tracks the time (the RTC tick count) when the most recent byte was received from the GPS in global LastUARTrxRTC. FlushFlashBufferSafe() calls BetweenGPSFixesFor(), which uses the time since the most recent received GPS byte, the known GPS fix interval (GPS_FIX_INTERVAL, usually 1 second), and the known maximum length of the GPS data burst (GPS_BURST_PERIOD, about 60 milliseconds) to determine whether the GPS is currently sending a data burst, and if not, how long it will be until the next burst is expected. When enough time is available, FlushFlashBufferSafe() writes the FlashBuffer contents to Flash memory.
Last, the piezo transducer (sloppily, sometimes I call it a “buzzer”) and loud horn (smoke alarm horn) are serviced in the idle loop.
The buzzer output, if enabled by the global variable Beep, is toggled each time through the idle loop (#define ServiceBuzzer() in perhiperals.h). The sound that is produced reflects the amount of time spent in the idle loop, amount of time in the ISR, and the rate at which the idle loop is executed, which is influenced by interrupt servicing and writes to Flash memory. Once you get used to it, this provides the experienced listener with an amazing amount of information about what the system is doing.
The horn output controls the sounding of the loud horn, if attached and enabled. This can be turned on, off, or made to cycle at high or low rates to indicate different states.
Exit from the idle loop
When the RTC tick count reaches nextRTC8 the idle loop is exited, as the time to begin the next iteration through the main loop has arrived, so macro SET_DEBUG_STATE() raises the SPEEDO output to indicate that the PIC is again busy executing main loop code.
Finally, nextRTC8 is again incremented by LOOP_PERIOD, so that nextRTC8 represents the time when the next main loop iteration will end. Always incrementing nextRTC8 by the same value, regardless of the exact time when the increment occurs, ensures that the start of each main loop period doesn’t drift against the RTC.
In my next posts, I’ll discuss the state machine and interrupt service routine.