Firmware teardown · Tomlov DM9
A 7-inch digital microscope. No labels, no model number, no firmware download. Six weeks later its own flash admitted it had been a dashcam the whole time.
LDW is Lane Departure Warning. Played back, the first two are a voice saying “LDW turn on” and “LDW turn off”. They ship inside a microscope, on hardware with no speaker.
The unit was sold as a “Tomlov 7”. It arrived with no markings of any kind — no model number on the case, no silkscreen, no sticker. The only self-identification anywhere in the product was a line buried in a settings menu: firmware 20220905 V1.
The goal was small and entirely cosmetic: replace the boot splash. That turned out to require the firmware image, and the firmware image turned out to require knowing what the device actually was.
USB was the obvious first move, and it was a dead end in an interesting way. The device presents three different personalities depending on how you plug it in, and two of them are lies.
USB identity by mode
Verified on deviceMass storage 1b3f:8301 Generalplus MSC + live vendor command channel PC camera 05e3:f12b "GENERAL - UVC" UVC only, no extension unit Charging does not enumerate nothing at all
05e3 belongs to Genesys Logic — a vendor ID the manufacturer does not own, borrowed from a USB bridge chip. Searching by vendor ID in camera mode leads somewhere entirely unrelated to the actual device.
So the USB descriptors were useless for identification. What was left was the data the device produces.
Not with a guess about the product. With three identifiers, none of which mention a microscope, all of which point at the same silicon vendor.
The first came from the device's own recordings. Every video the microscope captures is stamped by whatever code muxed it, and that stamp reads:
Fingerprint 1 · the AVI muxer
Verified on deviceRIFF AVI hdrl avih strl strf JUNKGP$322 Generalplus AviPackerV3 20140916moviPresent in every captured .avi, and at 0x05d8e4 in the firmware itself. Generalplus is a Taiwanese SoC vendor. Nothing here says Tomlov, and nothing says microscope.
The second came from the mass-storage identity. A SCSI INQUIRY returns 44 bytes that are pure boilerplate — a generic disk name and a build tag, with no product string at all:
Fingerprint 2 · SCSI INQUIRY
Verified on deviceGENPLUS USB-MSDC DISK A 1.00GP-PROD.
Later found to match a reference implementation's SCSIInquiryData[] array byte for byte. The manufacturer never edited it.
The third was a working vendor command channel: a private SCSI extension gated by a two-byte signature, 'G' and 'P' at fixed offsets in the command block. The same command succeeds with those bytes and is rejected without them — confirmed live on the device.
Three independent identifiers, one conclusion: this is a Generalplus part. And that is the moment the investigation forked, because of what happens when you go looking for Generalplus source code.
It is a GitHub repository called ZTKVR — a complete SDK for a product line identified in its own headers as 326B_CVR. CVR stands for Car Video Recorder.
It is a car dashcam SDK. Not a microscope SDK. Nobody has published a microscope SDK, because at the time it looked like nobody had ever built one.
The dashcam angle didn't start as a theory about the product. It started as the only map anyone had, and it was a map of the wrong vehicle.
Everything then known about the device came out of that repository: the resource container format, the boot splash loading path, the firmware update mechanism, the vendor command table. All of it read out of code written for a device that bolts to a windscreen.
Which meant every conclusion carried an asterisk, and the asterisks were load-bearing. The update path this SDK describes has no signature, no checksum, no magic number and no version check. It erases and rewrites SPI flash from offset zero with whatever bytes it finds in a file. If the dashcam assumption was wrong anywhere that mattered, the first write would be the last one.
So the working notes were split into two columns — verified on device and inferred from SDK — and nothing was written to the hardware while the second column contained anything important.
Reasoning from a sibling product generates confident, plausible, wrong answers. These each burned real time before collapsing.
The other route was to simply ask the manufacturer for a copy of the firmware already running on the device. This proved harder to communicate than it sounds.
The request was deflected twice. First: no firmware updates are available for this model. Then an unrelated reply about warranty and return terms. Front-line support could not parse “a backup copy of the firmware I already have” as a different thing from “a firmware update”.
What eventually worked was rejecting the newer-version framing outright — not asking for an upgrade, but for the version already paid for, as a backup should the device ever fail — plus the observation that being unable to reflash after the warranty expires costs the customer money. Support acknowledged the misunderstanding and escalated to the factory.
The factory sent the file.
The image self-identifies immediately. At 0x01ae8c sits the ASCII string 20220905 V1.0 — a byte-for-byte match for the version the device's own menu reports. Right firmware, right device.
Then the resource table loads, and the dashcam stops being an inference.
| Resource | Bytes | What it is |
|---|---|---|
| BACKGROUND.GPZP | 2,494 | UI sprite |
| BEEP.WAV | 4,076 | sound |
| CAMERA.WAV | 5,524 | shutter sound |
| CLICK.WAV | 616 | sound |
| DAY_SELECTBAR.GPZP | 118 | date-stamp overlay picker |
| GPRS.PAK | 169,139 | nested pack |
| INSERTSDC.GPZP | 410 | “insert SD card” prompt |
| LDW_ALARM.WAV | 5,320 | lane departure alarm |
| LDW_TURNOFF.WAV | 14,692 | spoken “LDW turn off” |
| LDW_TURNON.WAV | 17,382 | spoken “LDW turn on” |
| POWER_OFF_LOGO.JPG | 26,178 | shutdown splash |
| POWER_ON_LOGO.JPG | 26,178 | boot splash |
| POWERON_AUDIO.WAV | 27,628 | startup sound |
| SELECTBAR_LONG.GPZP | 796 | UI sprite |
| SELECTBAR_MIDD.GPZP | 592 | UI sprite |
| SELECTBOX_SUB.GPZP | 689 | UI sprite |
| TOPBAR.GPZP | 327 | UI sprite |
| YEAR_SELECTBAR.GPZP | 15,872 | date-stamp overlay picker |
And the firmware update file the factory sent has a name it never bothered to change:
The hardcoded update path
Verified on device0x004b7c C:\gp_cardvr_upgrade.bincardvr. Car video recorder. The SDK documents this filename as a per-customer compile-time constant, changed for each brand that licenses the platform. It was not changed.
Nobody wrote microscope firmware. Somebody took a dashcam build, swapped the JPEG that appears at boot, and shipped it — leaving the lane-departure audio, the date-stamp overlay widgets, and the update filename exactly where they were.
Reading a real image also disproved something the dashcam SDK had confidently supplied.
The SDK stores every resource's length in an array compiled into the firmware, au32ResFileLenList[]. That detail mattered a great deal, because it set the hard limit on a replacement splash: the new JPEG had to match a size recorded in code, not in the container.
It isn't there. Every true resource size was searched for across the entire binary as a 32-bit value — 26178, 169139, 27628, 14692, 5320 — and not one of them appears anywhere. This build derives resource length some other way.
A conclusion inherited from a sibling product, held for weeks, dissolved on contact with the actual artifact. Which is roughly the expected failure rate for reasoning off the wrong map, and the reason for keeping the two columns separate in the first place.
With a recovery baseline finally in hand, the splash could be replaced. The procedure is five steps and one long pause.
Locate the GP header, walk 18 directory entries of 24 bytes each — a 20-byte name with the dot stripped, then a start offset in 512-byte sectors.
800×480, baseline JPEG, 4:2:0, under 26,178 bytes. For a photograph that ceiling lands at about quality 50 — roughly 0.55 bits per pixel.
Overwrite in place and pad the remainder with 0xFF, matching the vendor packer's own convention, so every following resource keeps its sector offset.
Diff against the original: file length unchanged, every differing byte inside the intended range, container still parses, image still decodes as baseline with a valid end marker.
Copy to the SD card root as gp_cardvr_upgrade.bin, insert, power on. It upgrades unconditionally — no button hold, no confirmation.
The progress counter reaches 100% and then the device stops. Nothing moves. On a write path with no signature and no checksum, sitting in front of a frozen screen that recently said do not power off now is an unpleasant place to be.
It is not frozen. It is finished. The upgrade routine deliberately never returns:
End of the upgrade routine
From SDK sourceDBG_PRINT("system hold ..."); i = 0; while (1) { /* never exits */ if (gpio_read_io(PW_KEY)) i++; else i = 0; if (i >= 3) { gpio_write_io(POWER_EN, 0); } /* power key only */ OSTimeDly(5); }
Which reframes the on-screen message. “Remove SD card and restart now” is not a status line — it is an instruction. The device has no intention of restarting itself, and the apparent hang is the success state.
Two details made power-cycling provably safe rather than a gamble. The progress value is only ever set to 100 inside the branch taken when the written total has met the file size, so a displayed 100% is proof the write loop drained rather than a rounding artifact. And the only thing that could still have been running was a read-back verify — and interrupting a read cannot corrupt flash.
It came back up with a new splash. First attempt, no bricks.
POWER_ON_LOGO.JPG at offset 0x190c00. 800×480 baseline JPEG.0xFF. These are the literal bytes now in the device's flash.One thread was left. The container ships POWERON_AUDIO.WAV, and the startup code genuinely calls it — unlike the lane-departure prompts, it is not dead code.
It never plays. Chasing that produced a good-looking suspect: it is the only sound in the pack recorded at 16 kHz, while every other file is 11025 Hz. A format the audio path might reject.
Wrong. No sample rate is hardcoded anywhere in the code region — every hit for either value sits inside a WAV header, not an instruction. And the real answer arrived by simply listening to the device: it makes no sound at all. No menu clicks, no shutter, no beep. There is no speaker.
While confirming that, the sound table gave up one last detail. It declares nine effects; the container ships seven.
Sound effect table · 0x21990
Referenced, never shipped[0] CAMERA.WAV present [1] CLICK.WAV present [2] POWERON_AUDIO.WAV present [3] POWEROFF_AUDIO.WAV missing from container [4] GOTIT.WAV missing from container [5] BEEP.WAV present [6] LDW_TURNON.WAV present [7] LDW_TURNOFF.WAV present [8] LDW_ALARM.WAV present
Two effects the code can request and the firmware cannot supply — a latent bug sitting in a shipping retail product, on a device where no sound could be heard even if it worked.
The sound pack is vestigial data. Underneath it sits vestigial code, and there is a great deal more of that.
Only one 26 KB resource was ever the target. A survey of the remaining 1.7 megabytes turns up entire subsystems with no conceivable use in a device that sits on a desk looking at circuit boards.
Not one accelerometer driver but two, alongside the complete parking-surveillance state machine — the feature that wakes a dashcam when something hits the parked car.
G-sensor subsystem
Strings present in buildG_Sensor_DA380_Init G_Sensor_SC7A30E_Init G_Sensor_park_mode_init G sensor on time over restart recording! G sensor on NO SDC Power off! G sensor Who am I = %02X
DA380 and SC7A30E are real, specific accelerometer parts — this is driver code for named silicon, not a stub.
The dual-channel recording path is fully built out. Front and rear each get their own JPEG buffers, scalers, AVI working memory, index writers and timestamp queues — and the firmware watches for a rear camera being plugged in and out.
Dual-channel recording
Strings present in buildAlloc Front Scaler A/B Buffer addr=0x%x, size=%d Alloc rear avi_workmem addr=0x%x, size=%d Alloc rear index_write_buffer addr=0x%x, size=%d Alloc rear time_stamp_buffer Q addr=0x%x, size=%d BACK SENSOR PLUG IN BACK SENSOR PLUG OUT
Two complete video pipelines, in a product sold with one lens.
This is the strangest one. Beyond the mass-storage and webcam modes the device advertises, there is an entire additional state dedicated to acting as a USB host — enumerating an external UVC camera, negotiating isochronous transfers and pulling frames off it.
USB host UVC state
Strings present in buildstate_usbh_uvc_entry Host: do uvc_host_enum_device Host: Enum UVC device successfully reinit_flag[%d]cmd[%d] Host: MSG_USB_HOST_UVC_PLUG_IN Alloc ITD1 buffer for USBH UVC failed [ERROR]USBH iso in trans fail
A microscope carrying the machinery to ingest video from a camera you plug into it. Whether any of it survives to runtime is unknown — but it is compiled in.
The private SCSI command channel — the one gated behind those two signature bytes — turned out to carry something the reference SDK leaves unrouted.
Vendor command handler
Write with no readGot vendor command 0x%x Vendor REG write, cnt = %d, val = 0x%x Vendor REG write,reg 0x%x, data len %d
A hardware register write, reachable over USB. Searched thoroughly, there is no read counterpart anywhere in the image — which is the worst of both worlds. Useless for reading the device out, entirely sufficient for writing it into a wall.
That asymmetry is worth sitting with. A register read would have been the single most useful thing an investigation like this could ask for — a way to dump the device over USB without opening it. What shipped instead is only the half that can destroy.
What it turned out to be
The answer to the question in the title is that the categories were never really separate. Underneath, this is one Generalplus reference platform. A brand licenses it, changes a splash screen, and ships it into a different market — and the economics never justify removing what the new product doesn't use.
So the microscope carries three and a half seconds of spoken lane-departure warnings it cannot play, a date-stamp overlay picker for footage it will never record, an update mechanism named for a car video recorder, and two sound effects that exist only as names in a table.
And underneath that, the parts that are actually load-bearing somewhere else: drivers for two named accelerometers, a parking mode waiting for an impact that will never come, a second video pipeline for a camera that was never fitted, and the machinery to act as a USB host for one you might plug in. None of it removed, because removing it would have cost someone a day and left the device working exactly as well.
The part that deserves more attention than the joke: that update mechanism accepts any file of the right name from the SD card root and writes it straight to flash, with no signature, no checksum and no version check. That is not a quirk of one brand's build. It is the reference implementation, which makes it the behaviour of every device on this platform.
Which is also the only reason any of this was possible. The same missing safety check that makes the device trivially modifiable is the one that makes it trivially destroyable — and the manufacturer spent four emails insisting there was no firmware to give.