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 all05e3 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....moviIn 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.
| 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, probably fonts |
| 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 | the one i came for |
| 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 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 image | Fits at | Notes |
|---|---|---|
| Flat graphic (the stock wordmark) | q85+ | Easily. Big flat areas cost almost nothing. |
| Photograph | q50 | 25,603 bytes. q55 was 27,395 and already over. |
| Crayon / pencil / hand drawn | never | 38 KB at quality 20. Not close. Not fixable. |
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
POWER_ON_LOGO.JPG at 0x190c00. 800×480
baseline JPEG.after · flashed25,603 B
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 failA 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