What's Actually Inside Your GPS Module?
So I’ve been building a Linux CLI tool for the Waveshare LC29H GPS/RTK HAT - it’s a pretty capable little GNSS module that tracks L1+L5 dual-frequency signals, does RTK for centimeter-level positioning, and generally punches way above its weight class for a $40-ish hat you slap on a Raspberry Pi.
The problem? Quectel (who makes the LC29H chip) ships a Windows-only tool called QGNSS for configuration and monitoring. If you’re on Linux - and if you’re running a Pi with a GPS hat, you’re probably on Linux - you’re stuck with whatever documentation exists. Which… isn’t great.
I’d been poking through the wiki, downloading PDFs, mirroring sample code - the usual hardware-hacking prep work. Then I looked at the files that came in the QGNSS package. QGNSS.exe, QNMEA.dll, FlashUpdater4775_CL389797_r.exe, QGNSSLog.exe. Closed source, all of them.
“Do we have the source code for this Qt app?”
Nope. Proprietary Quectel tool. No source published.
So I did what any reasonable person would do.
“mcghidra”
One word. That’s all it took to fire up the NSA’s reverse engineering tool through an MCP server and start pulling these binaries apart.
The Setup
Section titled “The Setup”I should back up and explain the tooling because it’s kind of the whole point. I used Ghidra through mcghidra, an MCP server I built for Ghidra, which let me drive the decompiler directly from Claude Code. So instead of manually clicking through thousands of functions in Ghidra’s GUI, I could decompile functions, search strings, list symbols, and follow cross-references - all from my terminal. It turned what would’ve been weeks of tedious clicking into a couple of really fun late-night sessions.
And here’s the thing that made this extra chaotic in the best way - I was testing the actual hardware while decompiling the software that talks to it. At one point mid-analysis I went “hold up… um, I’ll put the antenna outside.” Because of course I was also watching live NMEA data come off the module at the same time. Antenna went out the window, signal went from 10 satellites at HDOP 1.49 to 25 satellites in view at HDOP 0.7. BeiDou went from barely 1 visible to 9 satellites with strong dual-band signals. Pretty wild seeing the theory from the decompilation play out in real data on the screen.
Round 1: Cracking Open QGNSS
Section titled “Round 1: Cracking Open QGNSS”The first targets were QGNSS.exe (15,013 functions, Qt5 C++ application) and QNMEA.dll (669 exported functions, the NMEA parser library).
I said “please be sure to document your findings as you find them” - and that turned out to be the right call. When you’re wading through 15,000 decompiled functions, things blur together fast. Having findings captured live, as they came out of the decompiler, meant nothing got lost.
The Parser Architecture
Section titled “The Parser Architecture”QNMEA.dll turned out to be a parser-only library - zero command construction logic. All the command building happens in QGNSS.exe itself. That’s actually a clean separation. The DLL has a class hierarchy rooted in NMEA_Base with a main parser (QGNSS_NMEA_Parse) that dispatches sentences by looking them up in a memory-mapped table.
The streaming parser uses what I’d call the “leftover buffer” pattern. Data arrives from serial, gets appended to a persistent buffer, the buffer gets scanned for $ (sentence start) and \r\n (sentence end), complete sentences get dispatched, and whatever’s left over stays in the buffer for next time. If you’ve ever written a stream parser over serial or TCP, you’ve probably written exactly this pattern. It’s not glamorous, but it works.
What was useful for building our Python CLI: the protocol constants. Extracted straight from the initialized data sections:
MAX_NMEA_LEN = 82 (standard NMEA 0183 maximum)NMEASTARTC = '$'NMEAENDC = '*'MIN_NMEA_LEN = 12Nothing surprising here, but having the exact constants Quectel uses means our parser matches theirs byte-for-byte.
The Command Catalog
Section titled “The Command Catalog”This was the real prize from round 1. By tracing through the UI construction code in QGNSS.exe, I mapped out every PAIR and PQTM command the tool sends. PAIR commands for system control (cold/warm/hot starts, factory reset), constellation configuration, AGNSS/EPO injection. PQTM commands for port configuration, navigation tuning, dead reckoning, CAN bus, geofencing.
The command build pattern is refreshingly simple - sprintf builds the sentence body, a checksum function appends *XX\r\n, and it goes straight out the serial port. No validation, no escaping. Just format strings with parameter interpolation. Two checksum functions exist - one for QByteArray (appends *XX\r\n) and one for QString (appends *XX only) - but both use the same XOR-from-byte-1 algorithm. (I’m not saying that’s good practice, but it does make it easy to replicate.)
Speaking of NTRIP - while all this decompilation was happening, I was also setting up real NTRIP infrastructure for testing. Set up a mount point on rtk2go.com called “nampaidaho” (guess where I live). Discovered the hard way that rtk2go.com requires your email address as the username - not obvious! Also found their “how to get your IP banned” page, which… is a page you want to read before you start hammering their caster with your janky test code. There are rules. Pay attention to them. I did.
Round 2: Down the Rabbit Hole
Section titled “Round 2: Down the Rabbit Hole”Round 1 was productive. Round 2 got weird.
Here’s the meta part: the second session started by mining the first session’s conversation history. Like, going through the actual chat logs with jq to extract findings that had been mentioned in passing but not fully documented. Reading your own conversation history to remember what you found at 2am is… a thing you do now, I guess?
Anyway. I searched the project tree for every binary file that might be worth analyzing - .exe, .dll, .so, .bin, .pkg, anything Ghidra could chew on. Four more targets emerged from the Waveshare wiki downloads: an ARM shared library (the QXWZ RTK positioning SDK), a Windows firmware update tool, a bootloader package, and a log analysis tool. This is where things got really interesting.
The QXWZ SDK: China’s National RTK Network in a .so File
Section titled “The QXWZ SDK: China’s National RTK Network in a .so File”libqxwz-pssdk-1.5.0 - an ARM ELF shared object, 32-bit, little-endian. And here’s the kicker: not stripped. 826 functions with full symbol names just sitting right there.
That’s a treasure trove.
This is the client SDK for QXWZ (Qianxun Spatial Intelligence), which operates China’s national high-precision positioning network. It was tucked into the Waveshare sample code’s c/libs/ directory, meant for Raspberry Pi integration. Being an ARM .so, it’s directly analyzable - no Windows PE overhead, no obfuscation, just symbols everywhere.
The architecture is actually really clean - six major subsystems (Account, Auth, Capabilities, Config, Data Pub/Sub, Manager) sitting on top of two transport layers: an HTTP REST API for authentication and an MQTT client for streaming corrections.
The string table alone was incredibly revealing. MQTT protocol identifiers, HMAC function names, server URLs, REST API paths - everything you’d need to understand how the service works. And then it got deeper.
The Signature Algorithm
Section titled “The Signature Algorithm”I mean, it works. The key-value pair count is probably always small enough that it doesn’t matter. But finding a bubble sort in a cryptographic signing function made me laugh out loud at 1am. It’s there to ensure deterministic signatures regardless of parameter order - the sort makes the signature reproducible no matter what order you feed the parameters in. Completely correct approach, just… implemented with the most textbook-naive sorting algorithm possible.
The full signing flow: format the secret as the HMAC key, format the timestamp as a nonce, sort all key-value pairs alphabetically by key (bubble sort…), then feed path + sorted k-v pairs + nonce into HMAC-SHA384. The output is 48 bytes, hex-encoded to a 96-character string, and appended as the _sign parameter to API requests. Clean protocol design, questionable sort implementation. Kind of endearing, honestly.
MQTT Authentication
Section titled “MQTT Authentication”The MQTT authentication is clever though. Every MQTT connection gets a fresh password that’s a JSON object containing the device info, a timestamp, and an HMAC-SHA384 signature. The broker verifies the signature on every connect, so you get per-session authentication tied to the device’s secret key. No static credentials floating around.
The client ID is a random UUID-like string generated with qxwz_gen_random_num() % 16 for each hex character. Five groups separated by dashes. The password gets rebuilt from scratch on every connection attempt. That’s actually solid security design for an IoT correction service.
SIDS: A Proprietary Correction Format
Section titled “SIDS: A Proprietary Correction Format”Buried in the QXWZ SDK is a decoder for something called “SIDS” - QXWZ’s proprietary compact correction format. Finding this one was a process. Started with 16 SIDS-related functions in the symbol table, then traced through the decoder chain piece by piece.
It uses RTCM3 framing (same 0xD2 preamble, same CRC-24Q checksum) but with a completely custom payload encoding. That’s actually clever engineering - existing GNSS infrastructure can route SIDS frames because they look like RTCM3 on the outside, but the payload is a dense bitstream that’s way more bandwidth-efficient than standard RTCM3 SSR messages.
The payload is packed at the bit level using a custom qxwz_ubit_get() function. 10 bits for constellation ID, 16 bits for signal ID, 30 bits for correction data, then satellite and signal bitmasks (64-bit and 32-bit respectively) that define which satellites and signals have corrections. For each satellite/signal combination where both mask bits are set, you get a 1-bit presence flag and a 2-bit qualifier. That’s it. Three bits per correction entry. When you’re serving corrections to millions of IoT devices over cellular, that kind of compression matters.
Constellation mapping: GPS=1, GLONASS=4, Galileo=8, BeiDou=0x20. Unknown gets N. The decoder writes these as single-character codes (G, R, E, C, N) - same convention as standard RINEX.
FlashUpdater: “Wait, That’s Not MediaTek”
Section titled “FlashUpdater: “Wait, That’s Not MediaTek””FlashUpdater4775_CL389797_r.exe - a 2.3 MB Windows PE with 14,425 functions. This one was stripped (no symbols), so I had to find my way around by searching for strings and following cross-references. Different technique than the QXWZ SDK, more like detective work.
The binary self-identifies as “BCM4775 Flash Updater”.
BCM4775. Broadcom.
(Side note: when I later queried the actual module’s firmware version, it came back as LC29HDANR11A03S_RSA with an Airoha AG3335A chip identifier buried in the full version string. Airoha is a MediaTek subsidiary that licenses the Broadcom GNSS IP. So it’s Broadcom silicon, made by Airoha, in a Quectel module, on a Waveshare hat. The supply chain for a $40 GPS board is… something.)
The firmware update protocol is a thing of beauty if you’re into state machines. An 18-state FSM at offset this+0x4B0 in firmware_downloader.cpp handles the entire download process:
- Auto-baud detection and ROM sync
- Chip ID validation (the binary has a hardcoded table of 13 supported BCM477x chip IDs - BCM4773, BCM4774, and BCM4775 across multiple revisions)
- Bootloader patch download to RAM
- Bootloader execution (jump from ROM to RAM code)
- Second sync handshake (this time with the RAM-resident bootloader)
- Flash operations via RPC
That chip ID table was a revelation. 13 specific silicon revisions: BCM4773 rev A20 and A30, BCM4774 rev A0, and BCM4775 across eight revisions from A0 through B1 with various variant prefixes. This is a Rosetta Stone for the BCM477x GNSS SoC family.
The RPC layer is the key. Once the bootloader patch is running, it registers six command handlers: flash read (6), flash write (9), flash erase (10), config query (35), CRC verify (36), and status (39). The whole flash update happens through these RPC calls tunneled over UART. 4KB sector-aligned operations. Backup with timestamped filenames (FlashBackup_YYYYMMDD-HHMMSS.bin). Read-back and CRC comparison for verification.
There’s also a reliable transport layer underneath all of this, with sequence numbers, ACKs, and automatic retry. It tracks metrics like RxPacketLost, TxPacketRetry, and MaxRetry. Someone at Broadcom cared about reliability.
Oh, and the source path leaked in debug strings? d:\gps\v10_4775bettab1\proprietary\deliverables\lhe2_dev\. Version 10 of the BCM4775 “betta b1” firmware branch. I love when debug strings tell stories.
The Bootloader Package
Section titled “The Bootloader Package”bootloader.pkg - 20,092 bytes with magic bytes f3 f2 f1 f0. Ghidra couldn’t auto-import it (no recognized file format - proprietary Broadcom container), but cross-referencing with the FlashUpdater analysis tells us exactly what it is.
The header encodes the payload length at offset 0x0C: 0x4E5C = 20,060 bytes, which is the file size minus the 32-byte header. There are what look like branch table entries at offset 0x20 (48 00 00 18, 48 00 00 8e).
This is the “Scratch APP” that the FlashUpdater downloads to the BCM4775’s RAM before any flash operations. Without it, the host can’t access the SPI flash through the GNSS chip - the ROM bootloader only supports download, not flash operations. The Scratch APP is what registers those six RPC flash command handlers. It’s the bootstrap that makes everything else possible.
To fully analyze the bootloader would need a manual Ghidra import - raw binary, probably ARM:LE:32:v7, with the base address from the FlashUpdater’s PatchAddress field. That’s a project for another day.
QGNSSLog: The Signal Rosetta Stone
Section titled “QGNSSLog: The Signal Rosetta Stone”The last binary was almost an afterthought. QGNSSLog.exe was just “the last unanalyzed Quectel binary” - everything else had been picked apart or was open source (RTKLIB) or commodity drivers (CP210x). But these companion diagnostic tools often contain protocol details that the main app hides behind a pretty UI.
And boy, did this one deliver.
First, it revealed a whole second API in QNMEA.dll that I missed in round 1. The real-time parsing path (used by QGNSS.exe) goes through read_buff. But QGNSSLog uses a completely separate batch analysis path: Read_log, set_qlog_data_cb, set_qlog_status_cb, Set_Read_PKG (1 MB buffer), and Set_Qlog_cfg. Seven imports total. A complete callback-driven log parsing pipeline that QNMEA.dll was designed to support from the start - two modes through a single parser core. Round 1 only found the streaming side.
But the real find was the GNSS signal catalog. QGNSSLog contains a complete table of 52 signals across 7 constellation systems plus 6 SBAS augmentation services. GPS (8 signals), GLONASS (4), Galileo (7), BeiDou (12!), QZSS (10), NavIC/IRNSS (5), plus WAAS, SDCM, EGNOS, BDSBAS, MSAS, and GAGAN.
That’s the definitive reference for what the BCM4775 receiver chip can potentially decode across all GNSS constellations. Every signal, every frequency, every band. BeiDou alone has 12 signals spanning B1I through B3A across the BDS-2 and BDS-3 generations. The LC29H variants expose different subsets (the AA tracks L1+L5 dual-band, the BA/DA add RTK), but the signal catalog represents the full hardware capability. I hadn’t found this level of detail in any public documentation.
And it confirmed something important: QGNSSLog contains zero PAIR/PQTM command strings. It’s purely a log viewer. No hidden commands lurking in the diagnostics tool. Sometimes knowing what isn’t there is just as valuable.
Why This Matters
Section titled “Why This Matters”So why spend late nights reading decompiled C++ and ARM assembly while simultaneously hanging a GPS antenna out the window?
Because I’m building a Linux tool for this hardware, and the documentation has gaps. Big ones. The PAIR command format for constellation configuration (PAIR066,gps,glo,gal,bds,qzss)? Found it in the binary, not the docs. The complete firmware update protocol? Completely undocumented publicly. The SIDS correction format? Good luck finding that anywhere.
The FlashUpdater findings are particularly useful. Right now, if you want to update firmware on a Waveshare LC29H HAT, you need a Windows machine. But the protocol is now documented well enough to build a Linux-native firmware updater. Auto-baud, ROM sync, patch download, RPC flash operations - it’s all mapped out. That’s the kind of thing that could actually matter for people running headless Pi setups in the field.
And those findings fed directly into the Python CLI we built. The checksum algorithm, ACK parsing, and command framing were all extracted from the disassembly. The QNMEA.dll architecture - parser-only DLL, command construction in the host app - directly informed our clean separation between parser.py and commands.py. Even the “leftover buffer” streaming pattern maps one-to-one to our async serial reader.
The Takeaway
Section titled “The Takeaway”Every time I crack open a binary, I’m reminded of something: the interesting stuff is almost never in the documentation. It’s in the debug strings someone forgot to strip, the hardcoded chip ID tables, the bubble sort in a crypto function, the source paths that reveal internal codenames.
The tools have gotten so much better for this kind of work. Ghidra is genuinely incredible (thank you, NSA, I guess?), and being able to drive it programmatically through MCP means I can cover a LOT more ground than manually clicking through 35,000 functions. The workflow was basically: type “mcghidra”, point it at a binary, and start asking questions. Decompile this function. Search for these strings. What calls this? What does this reference? It’s like having a conversation with the binary.
One more thing that makes me smile about this whole project. The second Ghidra session started by going back through the conversation history from the first session - pulling out findings that got mentioned in passing but hadn’t been formally documented. Mining your own chat logs to remember what you discovered at 2am. It’s a weird loop, but it worked. Nothing got lost.
If you’re working with hardware that only ships Windows tools, consider taking a decompiler to those binaries. You might be surprised what you find. I went in looking for NMEA command formats and came out knowing the chip manufacturer, the firmware update protocol, the MQTT authentication scheme, and a proprietary satellite correction format.
Not bad for a couple of late nights with the antenna hanging out the window.