Firmware teardown · Tomlov DM9

Is it a plane, is it a car, is it a microscope?

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.

0x0219e8LDW_TURNON.WAV
0x0219f8LDW_TURNOFF.WAV
0x021a08LDW_ALARM.WAV

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.

A device with nothing to say about itself

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 device
Mass 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.

Where the dashcam angle actually started

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 device
RIFF AVI hdrl avih strl strf JUNKGP$322 Generalplus AviPackerV3 20140916movi

Present 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 device
GENPLUS  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.

There is essentially one public Generalplus codebase

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.

What the wrong map cost

Reasoning from a sibling product generates confident, plausible, wrong answers. These each burned real time before collapsing.

Four emails

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 firmware confirms it, and then some

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.

The 18 resources in the firmware's GP container at 0x154000. Highlighted rows have no business being in a microscope.
ResourceBytesWhat it is
BACKGROUND.GPZP2,494UI sprite
BEEP.WAV4,076sound
CAMERA.WAV5,524shutter sound
CLICK.WAV616sound
DAY_SELECTBAR.GPZP118date-stamp overlay picker
GPRS.PAK169,139nested pack
INSERTSDC.GPZP410“insert SD card” prompt
LDW_ALARM.WAV5,320lane departure alarm
LDW_TURNOFF.WAV14,692spoken “LDW turn off”
LDW_TURNON.WAV17,382spoken “LDW turn on”
POWER_OFF_LOGO.JPG26,178shutdown splash
POWER_ON_LOGO.JPG26,178boot splash
POWERON_AUDIO.WAV27,628startup sound
SELECTBAR_LONG.GPZP796UI sprite
SELECTBAR_MIDD.GPZP592UI sprite
SELECTBOX_SUB.GPZP689UI sprite
TOPBAR.GPZP327UI sprite
YEAR_SELECTBAR.GPZP15,872date-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 device
0x004b7c   C:\gp_cardvr_upgrade.bin

cardvr. 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.

The correction

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.

Writing to it

With a recovery baseline finally in hand, the splash could be replaced. The procedure is five steps and one long pause.

01

Parse the container

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.

02

Re-encode within budget

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.

03

Patch the slot and pad

Overwrite in place and pad the remainder with 0xFF, matching the vendor packer's own convention, so every following resource keeps its sector offset.

04

Prove only the slot moved

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.

05

Flash and wait

Copy to the SD card root as gp_cardvr_upgrade.bin, insert, power on. It upgrades unconditionally — no button hold, no confirmation.

The pause is the interesting part

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 source
DBG_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.

Before · as shipped26,178 B
The stock boot splash: the word TOMLOV in black on a light grey background, with the line More Than A Microscope beneath it.
The factory splash, extracted from slot POWER_ON_LOGO.JPG at offset 0x190c00. 800×480 baseline JPEG.
After · flashed25,603 B
The replacement boot splash: a close-up photograph of the author's face with the URL https://xecaz.com across the top.
The replacement, re-encoded at quality 50 to fit the slot, then padded to 26,624 bytes with 0xFF. These are the literal bytes now in the device's flash.

A sound pack with nothing to play it

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.

Everything else it still believes it can do

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.

It is ready for a crash

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 build
G_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.

It is ready for a second camera

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 build
Alloc 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.

It can be a USB host

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 build
state_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.

And one thing that is not funny

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 read
Got 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

A dashcam that was told it was a microscope

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.