Embedded HandbookApps
All sections › XXV. Mini-projects
STM32G474 · level intermediate · 18 min read
Open in the interactive handbook

198н. Mini-project: a GPS receiver — NMEA, PPS and precise time

A GPS module gives you two things. Over UART it sends NMEA text: time, coordinates, fix quality, several sentences once a second. On a separate pin it outputs the PPS pulse, whose edge sits on the second boundary far more accurately than any crystal on the board. From these you get a device that knows where it is and what time it is, and along the way measures its own clocks: how many ticks the timer really counts per second and how fast the RTC watch crystal runs.

Here the receiver is built on a NUCLEO-G474RE. The text goes through a DMA ring and is parsed byte by byte with a checksum check; PPS is caught by a capture on the 32-bit TIM2 and by an RTC timestamp, and from the timestamps the core computes the RTC_CALR calibration and the RTC_SHIFTR shift from section 12а.

Tested without a board and without a module. The core (gps_core.c) was run in the sim_gps.c experiment on synthetic data: our own NMEA sentences with corruption, a byte stream at 9600 bit/s through a DMA ring, PPS with jitter, a missing pulse and a glitch, an RTC clock with a fast crystal. The experiment was built with gcc on a PC and with arm-none-eabi-gcc for Cortex-M4; in qemu-system-arm -M netduinoplus2 the output matched the PC line for line. A Python model (gps_host.py) recomputes the checksums independently and reproduces the PPS and RTC estimates bit for bit. The firmware gps_g474.c was built with armclang against the G4 pack headers without warnings; it has not been run on a board.

References: the NMEA 0183 standard is paid and was not checked here — the sentence format is taken from the commonly known description, and the examples in this section are our own, not taken from module documents. RM0440 — the DMAMUX input table, TIM2, RTC; DS12288 — pins and alternate functions, HSI16 accuracy (table 43); the NUCLEO-G474RE BSP from the pack — the example clock setup and the board pins. UART and the DMA ring — sections 16 and 16б, timer capture — 13.6, RTC, CALR, SHIFTR and the timestamp — sections 12 and 12а. Check — _verify/gps198n_check.py.

Terms used in the documentation

Term Meaning
NMEA sentence, talker ID an NMEA sentence; who is talking: GP — GPS, GN — several systems
RMC, GGA, GSA navigation minimum, fix with altitude, satellites used in the fix
checksum XOR of the characters between $ and *
PPS, pulse per second a pulse at the start of every second
fix, HDOP whether there is a position solution; horizontal dilution of precision
circular DMA, HT, TC, IDLE DMA in a circle; half, end of the buffer; silence on the line
input capture the timer counter latched at the moment of an edge
smooth calibration, shift fine RTC calibration; a shift by a fraction of a second

198н.1 What the device does and what it is made of

Function How
NMEA text USART1 at 9600 bit/s, DMA1 channel 1 in circular mode, 128-byte ring
parsing byte by byte, XOR checksum; from RMC — time, date, coordinates, from GGA — fix, satellites, altitude
clock frequency PPS to a TIM2 capture (32 bits, 170 MHz), averaging over a window
clock RTC timestamp on the PPS edge: crystal rate → CALR, phase → SHIFTR
output a line once a second to LPUART1 — the ST-LINK virtual COM port

GPS receiver on a NUCLEO-G474RE: the module sends NMEA text to USART1 at 9600 bit/s, DMA1 puts the bytes into a 128-byte ring, the core parses RMC and GGA sentences on the half, end and idle events; the PPS pulse goes to a TIM2 capture at 170 MHz and to the RTC timestamp on PC13; from the captures the clock error is computed, from the timestamps the CALR 0x81D9 calibration and the SHIFTR shift; over a day the clock drifts by −3.214 s before calibration and by −518 µs after

Signal Pin Peripheral
module TX → receive PA10 USART1_RX, AF7
module receive ← transmit PA9 USART1_TX, AF7
PPS PA0 TIM2_CH1, AF1
PPS PC13 RTC_TS — RTC timestamp
virtual COM PA2, PA3 LPUART1, AF12

Pins and functions follow the DS12288 tables, LPUART1 follows the board BSP. PPS goes to two pins at once: the TIM2 capture and the RTC timestamp each take the edge from their own pin. On the NUCLEO-G474RE, according to the BSP, PC13 also carries the user button — more on this below, in the pitfalls.

The clock is as in the board BSP example: HSI16 / 4 × 85 / 2 = 170 MHz. The accuracy of HSI16 per DS12288 (table 43) is 15.88 to 16.08 MHz at 30 °C, that is −0.75 to +0.5 %, and the trimming step is 0.2…0.4 %. For timekeeping you need a crystal; in the experiment the timer clock runs fast by +12.5 ppm, like a crystal, and the PPS pulse filter in the core accepts deviations up to ±1 % — good enough for HSI16 too.


198н.2 An NMEA sentence and its checksum

A sentence starts with $, then fields separated by commas, and after * come two hexadecimal checksum digits and the end of line \r\n. The checksum is the XOR of all characters between $ and *. An example from the experiment:

$GPRMC,120005.00,A,5545.1249,N,03737.5678,E,0.12,254.3,280926,,,A*6F

RMC fields in order: time hhmmss.ss, status (A — data valid, V — not valid), latitude ddmm.mmmm and hemisphere, longitude dddmm.mmmm and hemisphere, speed in knots, course, date ddmmyy. GGA adds the fix quality, the number of satellites, HDOP and altitude. The core parses the sentence in integers, without float: time in hundredths of a second since midnight, coordinates in degrees × 10⁷. Minutes are converted to fractions of a degree by dividing by 60:

Field Sentence Core
time 120005.00 4320500 hundredths — 12:00:05.00
latitude 5545.1249,N 557520817 — 55.7520817°
longitude 03737.5678,E 376261300 — 37.6261300°
fix, satellites, HDOP 1, 09, 0.9 1, 9, 9 tenths
altitude 156.3,M 1563 dm

The Python model computed the same checksums with its own code (0x6F for RMC, 0x35 for a GSA sentence of 51 characters) and the same degrees using floating-point division.

Parsing is a state machine fed byte by byte: wait for $, accumulate the body and the XOR, after * take two digits, then the end of line. Everything that does not fit is counted and discarded: a wrong checksum, $ in the middle of a sentence (the previous one was cut off), a sentence longer than the 96-byte buffer. The sentence length limit in the commonly known description is 82 characters; the margin is for non-standard modules. The core does not look at the source (GP, GN, GL…): RMC is RMC.

    case 4:
        r->state = 0;
        if (c != '\r' && c != '\n') {
            r->broken++;
            return NMEA_NONE;
        }
        if (r->cs != r->cs_rx) {
            r->bad_cs++;
            return NMEA_NONE;
        }

With status V the core does not take coordinates from RMC — it keeps the previous ones.


198н.3 Reception: the DMA ring

The module sends a burst of sentences once a second and stays silent between bursts. The bytes are put by DMA1 into a 128-byte ring in circular mode; the USART1_RX request goes through DMAMUX (input 24 per RM0440). Where DMA is writing right now is told by the CNDTR counter: the write position is 128 − CNDTR. The core takes everything from its read position up to the write position (ring_take, circular reception — section 16б).

The question is when to take it. At 9600 bit/s a byte takes 1.04 ms, and per second there are up to 960 bytes. A 128-byte ring fills in 133.3 ms: you must take the data sooner, otherwise DMA silently overwrites what has not been read. The experiment: 200 seconds, three sentences per second (RMC, GGA, GSA — 199 bytes, 207.3 ms on the line), with corruption: every 37th sentence has a wrong checksum, every 101st has no end of line, once every 50 s there is a 120-character sentence. In total 600 sentences, 40282 bytes.

When to take RMC GGA GSA Wrong checksum Cut off Too long
half, end of the ring, silence on the line 192 193 194 16 5 4
polling every 250 ms 0 0 190 5 5 0

On the DMA events (half and end of the ring — HT, TC) and the USART event (silence on the line — IDLE) the core caught exactly the injected errors: 16, 5 and 4, and lost nothing. During a burst the events come every 66.7 ms, and IDLE hands over the tail of the burst at once, without waiting for half the ring to fill.

Polling every 250 ms means up to 240 bytes between polls with a 128-byte ring. Only the end of each burst survives: all RMC and GGA are lost, 190 GSA sentences remain. The checksum did not help here: the overwritten sentences did not arrive with a wrong checksum — they did not arrive at all. You can notice this only from the sentence counter.


198н.4 PPS on a TIM2 capture

TIM2 on the G474 is 32-bit. It counts 170 MHz without a prescaler (a tick of 5.88 ns) and on the PPS edge latches the counter into CCR1. The difference between neighbouring captures is how many timer ticks fit into a GPS second. The difference modulo 2³² is correct even across an overflow: the counter wraps around in 25.26 s, and a second is much shorter.

The core accumulates differences over a window and returns the clock error in ppb. A difference that is further than ±1 % from 170000000 is not a second: a missing pulse gives two, a glitch gives a half. Such a difference resets the window. The experiment: 1300 seconds, the clock runs fast by +12500 ppb, the PPS edge jitters by ±100 ns, at second 700 the pulse is missing, at second 900.5 there is an extra one.

Window Windows Estimate, ppb Error bound
1 s 1296 12300…12694 ±205.9
16 s 79 12489…12510 ±12.9
64 s 19 12498…12502 ±3.2
256 s 3 12500 ±0.8

Three differences were rejected: one at the missing pulse and two at the glitch. The bound is the jitter of two edges plus one timer tick, divided by the window: the sum of differences over a window is simply the last capture minus the first, and the jitter of the inner edges cancels out. That is why accuracy grows as 1/window and not as a square root. The Python model reproduced all four rows.

The sum is in 64 bits. Over 64 s, 10880136016 ticks accumulate, while the difference of 32-bit captures "last minus first" gives 2290201424 — two full turns of 2³² are lost. So the core adds up the per-second differences in an int64_t instead of subtracting the ends of the window.


198н.5 The RTC clock from PPS

The clock runs from the LSE crystal, with dividers PREDIV_A = 3, PREDIV_S = 8191: a second is divided into 8192 steps of 122 µs, and calibration works fully (12а.2). PPS on the RTC_TS pin latches the calendar seconds and the SS counter into the timestamp. The clock phase at the edge is seconds × 8192 + (8191 − SS); comparing two phases 1024 s apart gives the crystal rate with a step of 1 / (1024 × 8192) — 0.119 ppm.

Then come the formulas of 12а: CALM − 512 · CALP ≈ 2²⁰ × the error, and the shift by a fraction of a second is SHIFTR. The experiment: the crystal is slow by 37.2 ppm, the clock started with a phase of 5027/8192 s.

Step Result
rate over 1024 s −37193 ppb (actually −37200)
CALR 0x81D9: CALP = 1, CALM = 473, effect +37195 ppb
full search of 1024 combinations the same 0x81D9
rate after calibration −6 ppb; a second 1024 s window measures 0 — less than one step
drift per day −3.214 s before calibration, −518 µs after
phase at the edge SS = 3476 — the clock is 0.5756 s past the second boundary
SHIFTR 0x8000126B: ADD1S and SUBFS = 4715 — forward by 1 − 0.5756 s
next edge SS = 8191 — the RTC second boundary is on the PPS edge

More than half a second — it is better to shift forward with ADD1S; less — shift back with a single SUBFS. The number of seconds after the shift is checked against the time from RMC: SHIFTR corrects only the fraction, the calendar is set separately (12а).


198н.6 Which second a sentence belongs to. The firmware

PPS marks the start of a second, and the sentence with the time arrives later — in the experiment the first sentence of a burst starts 50 ms after the edge. Which second it describes — the one that has just begun or the next one — is decided by the module, and there is no commonly known rule for all modules. In the model a sentence describes the second that has just begun; for a real module you have to take this from its description or check it: compare the RMC time with the pulse number over a few minutes.

The firmware gps_g474.c is all on CMSIS registers. The DMA and USART interrupts have the same priority, so ring_take cannot be re-entered. The PPS capture reads CCR1, and interrupt latency does not affect the result: the edge time is already latched. The RTC timestamp is read before TSF is cleared — clearing it resets the timestamp (12а.6). Build-time checks:

armclang 6.24 built the core and the firmware without warnings: 2010 and 1712 bytes of code, 352 bytes of RAM.


198н.7 Pitfalls

Polling the ring less often than it fills. 250 ms versus 133 ms: all RMC and GGA are lost, and there is no wrong checksum — the sentences simply vanished. Take data on HT, TC and IDLE.

A difference in 32 bits. You cannot subtract captures 64 s apart — two turns of 2³² will be lost. Add up the per-second differences in 64 bits.

Every pulse is a second. A missing PPS gives two seconds, a glitch gives a half. Without the ±1 % check a single glitch spoils the window estimate.

Too few seconds in the window. Over 1 s the estimate wanders by ±0.2 ppm, over 256 s by less than a thousandth of that (±0.8 ppb).

Status V. Coordinates in such a sentence are not usable. Check the RMC status and the GGA fix quality.

A shift without checking the second. SHIFTR corrects the fraction of a second, and the second number has to be checked against RMC.

The timestamp is cleared before it is read. Clearing TSF resets TSSSR and TSTR (12а).

The button on PC13. On the NUCLEO-G474RE, according to the BSP, the user button is on the same pin as RTC_TS: pressing it gives a false timestamp.

Time from HSI16. An error of up to −0.75…+0.5 % — PPS will show it, but the trimming step of 0.2…0.4 % does not let you remove it. For timekeeping you need a crystal.


198н.8 Summary

Full sources

gps_core.c — the GPS receiver core.

/* gps_core.c — раздел 198н: ядро приёмника GPS (см. gps_core.h). */
#include "gps_core.h"

_Static_assert(NMEA_MAX >= 82u, "строка NMEA 0183 — до 82 символов, буфер не меньше");

/* ── NMEA: поля ────────────────────────────────────────────────────────── */
static uint32_t field(const char *s, uint32_t n, unsigned idx, const char **p)
{
    uint32_t i = 0;
    while (idx && i < n) {                                     /* пропустить idx запятых */
        if (s[i++] == ',')
            idx--;
    }
    if (idx)
        return 0;
    *p = s + i;
    uint32_t k = i;
    while (k < n && s[k] != ',')
        k++;
    return k - i;
}

/* десятичное число с frac знаками после точки: «4507.1234», frac = 4 → 45071234 */
static int fixed(const char *p, uint32_t len, unsigned frac, int32_t *out)
{
    int32_t v = 0, sign = 1;
    unsigned got = 0, dot = 0, digits = 0;
    if (len && *p == '-') {
        sign = -1;
        p++;
        len--;
    }
    for (uint32_t i = 0; i < len; i++) {
        char c = p[i];
        if (c == '.' && !dot) {
            dot = 1;
            continue;
        }
        if (c < '0' || c > '9')
            return -1;
        if (dot && got == frac)
            continue;                                          /* лишние знаки отбросить */
        v = v * 10 + (c - '0');
        digits++;
        got += dot;
    }
    if (!digits)
        return -1;
    while (got++ < frac)
        v *= 10;
    *out = sign * v;
    return 0;
}

/* ddmm.mmmm или dddmm.mmmm → градусы × 10⁷ */
static int coord(const char *p, uint32_t len, const char *h, uint32_t hl, int32_t *out)
{
    int32_t v;
    if (fixed(p, len, 5, &v) || v < 0 || hl != 1)
        return -1;
    int32_t deg = v / 10000000, min_e5 = v % 10000000;         /* минуты × 10⁵ */
    int32_t e7 = deg * 10000000 + (int32_t)(((int64_t)min_e5 * 100 + 30) / 60);
    *out = (*h == 'S' || *h == 'W') ? -e7 : e7;
    return 0;
}

static int utc(const char *p, uint32_t len, uint32_t *cs)
{
    int32_t v;
    if (fixed(p, len, 2, &v) || v < 0)
        return -1;
    uint32_t hh = (uint32_t)v / 1000000u, mm = (uint32_t)v / 10000u % 100u, ss = (uint32_t)v / 100u % 100u;
    if (hh > 23u || mm > 59u || ss > 60u)
        return -1;
    *cs = ((hh * 60u + mm) * 60u + ss) * 100u + (uint32_t)v % 100u;
    return 0;
}

static int parse(const char *s, uint32_t n, nmea_fix *f)
{
    const char *p, *h;
    uint32_t l, hl;
    int32_t v;
    if (n < 6u || s[5] != ',')
        return NMEA_OTHER;
    int rmc = s[2] == 'R' && s[3] == 'M' && s[4] == 'C';      /* GP, GN, GL… — любой источник */
    int gga = s[2] == 'G' && s[3] == 'G' && s[4] == 'A';
    if (!rmc && !gga)
        return NMEA_OTHER;
    nmea_fix t = *f;
    l = field(s, n, 1, &p);
    if (utc(p, l, &t.time_cs))
        return NMEA_OTHER;
    if (rmc) {
        l = field(s, n, 2, &p);
        t.status = l == 1u ? *p : 'V';
        l = field(s, n, 3, &p);
        hl = field(s, n, 4, &h);
        if (t.status == 'A' && coord(p, l, h, hl, &t.lat_e7))
            return NMEA_OTHER;
        l = field(s, n, 5, &p);
        hl = field(s, n, 6, &h);
        if (t.status == 'A' && coord(p, l, h, hl, &t.lon_e7))
            return NMEA_OTHER;
        l = field(s, n, 9, &p);
        if (fixed(p, l, 0, &v) == 0)
            t.date = (uint32_t)v;
    } else {
        l = field(s, n, 6, &p);
        t.fix = (uint8_t)(fixed(p, l, 0, &v) ? 0 : v);
        l = field(s, n, 7, &p);
        t.sats = (uint8_t)(fixed(p, l, 0, &v) ? 0 : v);
        l = field(s, n, 8, &p);
        t.hdop_x10 = (uint16_t)(fixed(p, l, 1, &v) ? 0 : v);
        if (t.fix) {
            l = field(s, n, 2, &p);
            hl = field(s, n, 3, &h);
            if (coord(p, l, h, hl, &t.lat_e7))
                return NMEA_OTHER;
            l = field(s, n, 4, &p);
            hl = field(s, n, 5, &h);
            if (coord(p, l, h, hl, &t.lon_e7))
                return NMEA_OTHER;
            l = field(s, n, 9, &p);
            if (fixed(p, l, 1, &t.alt_dm))
                return NMEA_OTHER;
        }
    }
    *f = t;
    return rmc ? NMEA_RMC : NMEA_GGA;
}

uint8_t nmea_checksum(const char *body, uint32_t n)
{
    uint8_t cs = 0;
    while (n--)
        cs ^= (uint8_t)*body++;
    return cs;
}

static int hex(char c)
{
    return c >= '0' && c <= '9' ? c - '0' : c >= 'A' && c <= 'F' ? c - 'A' + 10 : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1;
}

int nmea_feed(nmea_rx *r, char c, nmea_fix *f)
{
    if (c == '$') {
        if (r->state)
            r->broken++;                                       /* начало новой строки посреди старой */
        r->state = 1;
        r->n = 0;
        r->cs = 0;
        return NMEA_NONE;
    }
    switch (r->state) {
    case 1:
        if (c == '*') {
            r->state = 2;
        } else if (c == '\r' || c == '\n') {
            r->broken++;
            r->state = 0;
        } else if (r->n >= NMEA_MAX) {
            r->overflow++;
            r->state = 0;
        } else {
            r->buf[r->n++] = c;
            r->cs ^= (uint8_t)c;
        }
        return NMEA_NONE;
    case 2:
    case 3: {
        int h = hex(c);
        if (h < 0) {
            r->broken++;
            r->state = 0;
            return NMEA_NONE;
        }
        r->cs_rx = (uint8_t)(r->state == 2 ? h << 4 : r->cs_rx | h);
        r->state++;
        return NMEA_NONE;
    }
    case 4:
        r->state = 0;
        if (c != '\r' && c != '\n') {
            r->broken++;
            return NMEA_NONE;
        }
        if (r->cs != r->cs_rx) {
            r->bad_cs++;
            return NMEA_NONE;
        }
        r->buf[r->n] = 0;
        {
            int t = parse(r->buf, r->n, f);
            r->ok += t != NMEA_OTHER;
            r->other += t == NMEA_OTHER;
            return t;
        }
    default:
        return NMEA_NONE;                                      /* мусор между строками */
    }
}

/* ── кольцо DMA ────────────────────────────────────────────────────────── */
uint32_t ring_take(const uint8_t *ring, uint32_t size, uint32_t *rd, uint32_t wr, byte_sink sink, void *ctx)
{
    uint32_t n = 0;
    while (*rd != wr) {
        sink(ring[*rd], ctx);
        *rd = *rd + 1u == size ? 0u : *rd + 1u;
        n++;
    }
    return n;
}

/* ── PPS ───────────────────────────────────────────────────────────────── */
void pps_init(pps_est *p, uint32_t nominal, uint32_t window)
{
    p->nominal = nominal;
    p->window = window;
    p->last = p->n = p->rejected = 0;
    p->have = 0;
    p->sum = 0;
}

int pps_add(pps_est *p, uint32_t capture, int32_t *ppb)
{
    if (!p->have) {
        p->have = 1;
        p->last = capture;
        return 0;
    }
    uint32_t d = capture - p->last;                            /* по модулю 2³²: переполнение счётчика не мешает */
    uint32_t tol = p->nominal / 100u;                          /* ±1 %: пропуск импульса даёт 2×, помеха — доли */
    p->last = capture;
    if (d < p->nominal - tol || d > p->nominal + tol) {
        p->rejected++;
        p->n = 0;
        p->sum = 0;
        return 0;
    }
    p->sum += d;
    if (++p->n < p->window)
        return 0;
    int64_t exp = (int64_t)p->nominal * p->n, num = (p->sum - exp) * 1000000000LL;
    *ppb = (int32_t)(num >= 0 ? (num + exp / 2) / exp : -((-num + exp / 2) / exp));
    p->n = 0;
    p->sum = 0;
    return 1;
}

/* ── RTC ───────────────────────────────────────────────────────────────── */
int64_t rtc_phase(uint32_t sec, uint32_t ss, uint32_t prediv_s)
{
    return (int64_t)sec * (prediv_s + 1u) + (int64_t)prediv_s - ss;   /* SS считает вниз */
}

int32_t rtc_drift_ppb(int64_t ph0, int64_t ph1, uint32_t seconds, uint32_t prediv_s)
{
    int64_t den = (int64_t)seconds * (prediv_s + 1u);
    int64_t num = (ph1 - ph0 - den) * 1000000000LL;
    return (int32_t)(num >= 0 ? (num + den / 2) / den : -((-num + den / 2) / den));
}

uint32_t rtc_calr_for(int32_t ppb)
{
    int64_t x = (int64_t)ppb * 1048576;                        /* CALM − 512·CALP ≈ 2²⁰ × ошибку */
    int32_t v = (int32_t)(x >= 0 ? (x + 500000000) / 1000000000 : -((-x + 500000000) / 1000000000));
    if (v > 511)
        v = 511;
    if (v < -511)
        v = -511;
    return v >= 0 ? (uint32_t)v : 0x8000u | (uint32_t)(512 + v);
}

int32_t rtc_calr_ppb(uint32_t calr)
{
    int64_t p = calr >> 15 & 1u, m = calr & 0x1FFu;
    int64_t num = (512 * p - m) * 1000000000LL, den = 1048576 + m - 512 * p;
    return (int32_t)(num >= 0 ? (num + den / 2) / den : -((-num + den / 2) / den));
}

uint32_t rtc_shift_for(uint32_t ss, uint32_t prediv_s)
{
    uint32_t frac = prediv_s - ss;                             /* тактов ck_apre после границы секунды */
    uint32_t add1s = 2u * frac >= prediv_s + 1u;               /* больше полсекунды — проще вперёд */
    return (add1s ? 0x80000000u : 0u) | frac;                  /* назад на frac или вперёд на 1 − frac */
}

Next: section 198о "Mini-project: an IR remote — receiving and transmitting the NEC protocol".

Open in the interactive handbook