Hacking the Tomlov DM-9 taught me my microscope prefers to identify as a dashcam.

21 min read Original article ↗

Learning my microscopes inner urges and changing it's boot logo

I bought a 7‑inch digital microscope for the bench. As i was wrapping up my last project, i reached out for something else to hack. I plugged a few USB devices into the my computer and dmesg'ed and lsusb'ed turned out they were just charging, until i plugged in my microscope. Six weeks later its own flash admitted it had been a car 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 that turns out to have no speaker in it at all.

What i wanted

Nothing clever. The thing was sold to me as a “Tomlov 7”, it sits on my desk looking at circuit boards, and every single time i switch it on it shows me a white TOMLOV / More Than A Microscope image for a few seconds. Let's see if I could put my own pretty face there instead. What i would find inside going down that route surprised me.

To change the splash i need the firmware. To get the firmware i need to know what the device actually is. And the device has absolutely nothing to say about itself.

No model number on the case. No silkscreen, no sticker, no label anywhere on the unit. Only the Tomlov logo. The only self-identification in the whole product is one line buried in a settings menu: firmware 20220905 V1. However, sometimes that's as much or as little as you need.

Three personalities, two of them lies

USB was the obvious first move. Plug it in and it enumerates differently depending on which mode you pick, and i figured at least one of those would tell me who made it.

USB identity by mode

verified on device

Mass storage   1b3f:8301  Generalplus       MSC + a live vendor command channel
PC camera      05e3:f12b  "GENERAL - UVC"   UVC only, no extension unit
Charging       does not enumerate           nothing at all

05e3 is Genesys Logic. That is a bridge chip's vendor ID, not the manufacturer's, and searching on it in camera mode walks you straight into a completely unrelated product. I did that first, obviously.

So the descriptors were close to useless. What was left was the data the thing produces, and that turned out to be much more talkative.

Three fingerprints, all pointing the same way

The first one came out of the microscope's own recordings. Every AVI it writes is stamped by whatever code muxed it, and the stamp is sitting there in clear text:

fingerprint 1 — the AVI muxer

verified on device

RIFF....AVI LIST....hdrl....avih ....strl....strf....JUNK
GP$322 Generalplus AviPackerV3 20140916....movi

In every captured .avi, and later found at 0x05d8e4 in the firmware too. Generalplus is a Taiwanese SoC vendor. Nothing there says Tomlov and nothing says microscope.

Second one, from mass storage mode. A SCSI INQUIRY comes back with 44 bytes of pure boilerplate — a generic disk name and a build tag, no product string anywhere:

fingerprint 2 — SCSI INQUIRY

verified on device

GENPLUS  USB-MSDC DISK A  1.00GP-PROD.

Months later i found the reference implementation's SCSIInquiryData[] array and it matches this byte for byte. Nobody ever edited it.

Third one is the good one. There is a private SCSI extension in there, gated behind two signature bytes — 'G' and 'P' at fixed offsets in the command block. The same command succeeds with them and gets rejected without them, which i confirmed on the actual device rather than reading it somewhere.

Three independent identifiers, one answer: this is a Generalplus part. And that is exactly where the whole thing forked, because of what you find when you go looking for Generalplus source.

There is basically one public Generalplus codebase

It is a GitHub repo called ZTKVR, and it is a complete SDK for a product line its own headers call 326B_CVR. CVR. Car Video Recorder.

It is a dashcam SDK. Nobody has published a microscope SDK, and at that point it looked like nobody had ever written one that got published.

The dashcam angle did not start as a theory about the product. It was simply the only code that i could find via google, so i went down that hole.

Everything i knew for the next several weeks came out of that repository: the resource container format, how the splash gets loaded, how a firmware update works, what the vendor command table looks like. All of it read out of code written for something that bolts to a windscreen.

This meant every finding there was a guess, until i got my hands on the firmware. 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 on the SD card. If the dashcam assumption was wrong anywhere that mattered, my first write would also be my last one.

So i split my notes into two columns — verified on the device, and inferred from the SDK — and refused to write anything to flash while the second column still held anything important. That discipline is the only reason this story does not end with a paperweight.

What the wrong map cost me

Reasoning off a sibling product gives you confident, plausible, wrong answers. Each of these ate real hours before it fell over.

  • GP$322I read it as a part number. It is a build tag inside the SDK's own source, not a die marking. Searching for it as silicon returns nothing, because it is not silicon.
  • GPCV1322 / GPL32680Two plausible reconstructions of a real Generalplus part number from that tag. Both wrong, both chased for an evening.
  • SPCA…A whole chip family that looks like it belongs to this vendor. It is Sunplus. Different company entirely. Generalplus parts are GPCV and GPL.
  • fvvlstore.comThe download mirror everyone references for this chip family. Dead domain.
  • au32ResFileLenList[]The SDK stores every resource length in a table compiled into the firmware. I built my whole size budget on that. It is not in this build — more on that below.

Four emails

The other route was just to ask. Tomlov does not publish there firmwares on their webpage, and i could not find it anywhere else either. So wrote a few lines to their support and ask for a copy of the firmware that is already running on the device i already own. Turns out this is much harder to communicate than it sounds.

Deflected twice. First reply: there are no firmware updates available for this model. Second reply: something about warranty and return terms that had nothing to do with what i asked. Front‑line support simply could not parse “a backup copy of the firmware i already have” as a different thing from “a firmware update”.

What finally worked was killing the newer‑version framing outright:

the sentence that broke it open

email 3

"I am not looking for a newer version. I would just like the version
 I paid for, should it ever experience issues."

Plus the point that being unable to reflash once the warranty is up costs the customer money. Support acknowledged the misunderstanding and escalated it to the factory.

The factory sent the file. 1,715,200 bytes, gp_cardvr_upgrade.bin. The lesson i took from it is that persisting past two deflections was the correct call — the first two nos were not the company saying no, they were one person not understanding the question.

The file arrives and the microscope stops pretending

First thing i checked was whether it is even the right build. At 0x01ae8c sits the ASCII string 20220905 V1.0, a byte‑for‑byte match for what the device's own menu reports. Right firmware, right device.

Then i parsed the resource container, and the dashcam stopped being an inference.

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, probably fonts
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,178the one i came for
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
All 18 resources in the GP container at 0x154000. Highlighted rows have no business being inside a microscope. The two splashes are byte‑identical to each other, in two separate slots.

And the update file the factory sent me has a name nobody ever bothered to change:

the hardcoded update path, twice in the image

verified in image

0x004b82   C:\gp_cardvr_upgrade.bin
0x023ea2   C:\gp_cardvr_upgrade.bin

cardvr. Car video recorder. The SDK documents this filename as a per‑customer compile‑time constant that gets changed for every brand licensing the platform. It was not changed. Which is handy, because it also tells me exactly where to put my file.

So nobody wrote microscope firmware. Somebody took a dashcam build, swapped out the JPEG that shows at boot, and shipped it — leaving the lane departure audio, the date‑stamp overlay widgets and the update filename exactly where they were.

The thing the SDK told me that was not true

Reading the real image also blew up something i had been holding for weeks.

The SDK stores every resource's length in an array compiled into the firmware, au32ResFileLenList[]. That mattered enormously to me, because it set the hard ceiling on my replacement splash: the new JPEG had to fit a size that lives in code, not in the container.

It is not there. I searched for every true resource size across the entire binary as a little‑endian 32‑bit value — 26178, 169139, 27628, 14692, 5320 — and not one of them appears anywhere in 1.7 MB. This build works out resource lengths some other way, probably just the span to the next entry's sector, or by decoding until it hits FFD9.

A conclusion inherited from a sibling product, carried for weeks, dissolving the moment it touched the actual artifact. That is roughly the expected failure rate for reasoning off the wrong map, and exactly why the two columns were worth keeping apart.

26 KB, and my first idea did not fit in it

The original splash is 26,178 bytes in a slot that spans 26,624. Since i can no longer prove which of those two numbers the firmware cares about, i aimed at the smaller one and let the packer pad the rest with 0xFF, which is what the vendor's own packer does to every short slot in the container.

26 KB at 800×480 is about 0.55 bits per pixel. That is tight, and how tight depends entirely on what you feed it.

Source imageFits atNotes
Flat graphic (the stock wordmark)q85+Easily. Big flat areas cost almost nothing.
Photographq5025,603 bytes. q55 was 27,395 and already over.
Crayon / pencil / hand drawnnever38 KB at quality 20. Not close. Not fixable.
Measured, not guessed. High‑frequency texture in every 8×8 block is the pathological case for JPEG, and a hand‑drawn image is nothing but that.

I really wanted the crayon one. I tried edge‑preserving smoothing, i tried downscaling and letting the firmware rescale it back up at decode time, i tried denoise passes. All of it bought maybe 10–20% and made the picture visibly worse, and i still had a 5× gap to close. In the end the lesson is just: if it will not fit, redraw it as flat art instead of crushing the quality of the wrong source.

One more trap worth writing down, because it will bite anyone doing this. Progressive JPEG is the default in a lot of editors and the firmware simply will not decode it. Baseline only. -interlace none in ImageMagick, or uncheck “progressive” in a GUI, and check the result with file x.jpg — it will say baseline or progressive right there.

Writing to it

With a recovery baseline finally in my hands, i could actually do the thing i set out to do six weeks earlier. Five steps and one very long pause.

01

Parse the container

Find the GP header at 0x154000, walk 18 directory entries of 24 bytes each: a 20‑byte name with the dot stripped out, then a start offset counted in 512‑byte sectors.

02

Re-encode inside the budget

800×480, baseline, 4:2:0, stripped of EXIF, under 26,178 bytes. For a photo that lands at about quality 50. Mine came out at 25,603.

03

Patch the slot and pad it

Overwrite in place, pad the remainder with 0xFF to match the vendor packer, so every resource after it keeps its sector offset.

04

Prove only that slot moved

cmp -l 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. Then read it back and diff again.

05

Flash it

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

The pause is the interesting bit

The progress counter climbs to 100% and then the device just stops. Nothing moves. Minutes go by. On a write path with no signature and no checksum, sitting in front of a frozen screen that a moment ago said do not power off now is not a nice place to be, and i sat there for a while doing nothing.

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 message on the screen completely. “Remove SD card and restart now” is not a status line, it is an instruction to me. The device has no intention of restarting itself.

Two details made pulling the power provably safe rather than a gamble, and i worked them out before touching it rather than after. The progress value only ever gets 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 and not a rounding artifact. And the only thing that could still have been running at that point is the read‑back verify — interrupting a read cannot corrupt flash. There was no story in which power‑cycling was risky.

Failure would have looked completely different and completely unambiguous: Upgrade fail, drawn only when the retry counter runs out.

I pulled the card, pushed the button, and it came back up with my picture on it. First attempt, no bricks.

before · as shipped26,178 B

The stock boot splash: the word TOMLOV in white on a light grey background, with the line More Than A Microscope beneath it.
The factory splash, pulled out of slot POWER_ON_LOGO.JPG at 0x190c00. 800×480 baseline JPEG.

after · flashed25,603 B

The replacement boot splash: a close-up photograph of the author with the URL https://xecaz.com across the top.
Re‑encoded at quality 50 to fit the slot, then padded out to 26,624 with 0xFF. These are the literal bytes sitting in the device's flash right now.

The power‑off logo is still the Tomlov wordmark, by choice — i patched one slot on the first flash so that if anything went wrong i would know exactly which change caused it. It lives in its own separate slot and takes the same treatment whenever i get round to it.

While i was in there anyway

That is the job done. But by this point i had the whole image open in front of me and 1.7 MB is a lot of place to poke around in, so i kept going. Everything below is stuff nobody at Tomlov bothered to take out.

A sound pack with nothing to play it

The container ships POWERON_AUDIO.WAV and the startup code genuinely calls it. Unlike the lane departure prompts, that one is not dead code. And it never plays.

I had a lovely theory about this. It is the only sound in the pack recorded at 16 kHz, while every other file is 11025 Hz, so obviously the audio path rejects the odd one out. Very neat. Completely wrong — no sample rate is hardcoded anywhere in the code region, every hit for either value is inside a WAV header rather than an instruction.

The real answer took one sentence from someone who had actually held the thing: it makes no sound at all. No menu clicks, no shutter, no beep, ever. There is no speaker in it. A day of clever theory beaten by looking at the device.

While confirming that, the sound table gave up one more thing. 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 ask for and the firmware cannot supply. A latent bug shipped in a retail product, on a device where you could not have heard it even if it worked.

It is ready for a crash

Not one accelerometer driver but two, next to the whole parking surveillance state machine — the feature that wakes a dashcam up 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 someone forgot to delete.

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 sits there watching for a rear camera being plugged in and out. In a product sold with one lens.

Every file this device has ever written is named for camera A of two, as well — DCIMA\MOVA0001.avi, DCIMA\PICA0000.jpg. The entire DCIMB tree exists in the code and will never be written to. There is also a LOC* naming scheme in there, which is the locked‑clip mechanism: the files a dashcam protects from loop‑record overwrite after an impact.

It can be a USB host

This is my favourite one. On top of the mass storage and webcam modes it advertises, there is an entire additional state dedicated to being a USB host — enumerating an external UVC camera, negotiating isochronous transfers, 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 record video from a camera you plug into it. Whether any of it survives to runtime i genuinely do not know yet — but it is compiled in and it is not small.

Reading the code, which needed one trick

I wanted to check whether any of that vestigial stuff is actually reachable, and for that i needed to read the binary properly rather than grep it. That went badly for a while.

The code region is plain uncompressed ARM32 and radare2 finds about 2,092 functions in it without complaining. What i could not do was connect any of those functions to the strings they print. I hunted for a load base for days: voting literal‑pool constants against string offsets, demanding that the five strings the upgrade routine provably uses cluster in one pool, running radare2's own reference analysis. All negative. A brute‑force base hunt is worse than useless here, by the way — with 87,700 distinct aligned words, about 5,000 candidate bases pass any five‑pointer test purely by chance, and raw scans keep surfacing ARM opcodes that look exactly like plausible addresses.

The reason none of it worked is that the image stores no absolute pointers to its strings at all. They are computed at runtime with ADD Rd, PC, #imm and never stored anywhere. I was searching for a thing that does not exist.

Decode those instead and the binary opens right up:

resolving PC-relative string references

verified in image

0x023a94   e0008fe2   add r0, pc, 0xe0    ; string address, computed
0x023a44   ec009fe5   ldr r0, [0x23b38]   ; =0xdfc5c, a BSS variable

1,388 PC-relative address computations found
1,223 (88%) resolve to printable strings
2,158 string <-> code references mapped

Watch the instruction mask if you ever do this. Matching ADD/SUB Rd, PC, #imm needs 0xffff0000. Use 0xfff0f000 and you zero the Rn nibble, so it can never equal PC, so you get zero hits — which looks exactly like a real negative result. That one cost me a wrong conclusion for a day.

With that in place i could finally look at the vendor command channel properly. And that is where the fun stopped.

And one thing that is not funny

Remember the private SCSI channel behind the 'G' 'P' signature. The SDK documents four subcommands. This build has nine. And this is what one of the handlers does:

0x2fd28 — the “register write”

arbitrary 32-bit write

add  r0, sp, 4
ldr  r1, [r4, 0x18]        ; length
bl   0x4b338               ; RECEIVE bytes from the host
ldrb r0,[sp,4] ; ldrb r1,[sp,5] ; ldrb [sp,6] ; ldrb [sp,7]
lsl/orr  ->  r2 = big-endian u32 assembled from 4 HOST-SUPPLIED bytes
ldr  r0, [r5, 0x14]        ; target ADDRESS from the control block
str  r2, [r0]              ; *** write ***

That is not “a register write”. That is a host writing an arbitrary 32‑bit value to an address, over USB, on a device you can buy for a hundred euros.

And there is no read counterpart anywhere in the image. I looked properly, with the string map, not by grepping hopefully. The two handlers that return data send fixed buffers of one and two bytes and nothing else.

Which is the worst possible combination. 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 up. What actually shipped is only the half that can destroy. Four of those nine subcommands are also gated behind a state check i have not worked out yet, and i am not going to find out what they do by sweeping them on my only device.

So what is it, then

A dashcam that was told it was a microscope.

The categories were never really separate. Underneath it is one Generalplus reference platform. A brand licenses it, swaps a splash screen, ships it into a completely different market, and the economics never once justify removing the parts the new product does not use.

So my 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 after a car video recorder, two sound effects that exist only as names in a table, 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 somebody a day and left the device working exactly as well.

The bit that deserves more attention than the joke: that update mechanism takes any file with the right name off the SD card root and writes it straight to flash. No signature, no checksum, no version check. That is not one brand's sloppy build — it is the reference implementation, which makes it the behaviour of every device on this platform.

Which is also the only reason i got my picture on the screen. The exact missing safety check that makes this thing trivially modifiable is the one that makes it trivially destroyable. And the manufacturer spent four emails telling me there was no firmware to give.

Still on the list

The power‑off splash is still stock. The four state‑gated vendor subcommands are unanalysed and staying that way until i have a real SPI dump to fall back on — the image the factory sent ends at 0x1A2C00 and whatever lives past that was never captured. The GPZP sprite format is uncracked, which is what stands between me and reskinning the whole UI. And i would still like to know whether any of the dashcam machinery is actually reachable at runtime, or whether it is just sitting there in the dark.

xecaz

26,178 bytes · 4 emails · 1 flash · 0 bricks