Files
viktorиClaude Opus 4.8 ae36c3b729 RT-4D: реверс прошивки, русификация, кастомный UI, флешеры
- Полный RE стока V3.25 (Cortex-M4F) + FM100B: карта памяти, протокол, codeplug, UI-архитектура
- Русификация: свой CP1251-шрифт + патч рендера, перевод меню и надписей, ребренд Ru-4D V3.25
- Блюпринт переделки UI + C-тулчейн (clang thumbv7em), доказан инъекцией
- Готовые флешеры: WebSerial .html и Windows .exe со вшитой прошивкой
- Дамп SPI рации, стоковая прошивка, инструменты сборки

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:47:22 +09:00

20 KiB

RT-4D Firmware — Main Loop, UI State Machine & UI-Rewrite Hook Points

Key: main-loop · Target: stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin (ARM Cortex-M4F Thumb, load/vaddr base 0x08002800). All disassembly via capstone CS_ARCH_ARM + CS_MODE_THUMB.

This is the hook-point document for a full UI rewrite. It nails down (1) the boot→superloop path, (2) the single UI screen-state variable and the two mirror-image dispatch tables (draw + input) keyed on it, (3) how screens redraw and transition, and (4) the exact vaddrs and strategy to redirect rendering + navigation into custom code while keeping all lower-level radio/codeplug/serial APIs intact.


0. TL;DR — the hook

  • Screen-state variable: g_screen = *(uint8_t*)0x200008B3 (0..11). A shadow *(uint8_t*)0x200008B2 holds the previous screen. 0x200008B3 is referenced by 55 literal-pool words across the image — it is the central UI selector.
  • Draw dispatcher: ui_draw_dispatch @0x0801E1BCtbb-jumps on g_screen (12 entries) to the current screen's incremental redraw routine. Called every UI tick from the scheduler at 0x080207E6.
  • Input dispatcher: ui_process_key @0x0801E6EC — pulls a keycode/keystate from the key struct at 0x20000B57, then tbb-jumps on g_screen (12 entries, table @0x0801E780) to the current screen's key handler handler(u8 keycode, u8 keystate). Called every UI tick from the scheduler at 0x08020816.
  • Hook strategy (recommended): patch the two bl call sites in the scheduler — 0x080207E6 (bl ui_draw_dispatch) and 0x08020816 (bl ui_process_key) — to call your own router. Your router owns rendering + navigation and calls the stock lower-level APIs (draw_string, keys, RF, DMR, codeplug). This is a 2-instruction redirect and leaves the serial/CPS path and codeplug format completely untouched. See §5.

1. Boot → application superloop

1.1 Reset / CRT startup (library glue, not app logic)

Reset 0x08002AC0: SystemInit(0x0801DA2D) ; then bx __main(0x080029E1)
__main 0x080029E0: bl 0x080029E8 (__scatterload / RW+ZI init, RLE decompressor @0x08002A1E)
                   bl 0x08002AA0 (__rt_entry)
__rt_entry 0x08002AA0: sequence of ARM C-lib inits, then:
                   bl 0x0802136C   <-- the real application main / superloop

The 0x08002AA0..0x08002D18 cluster is ARM compiler runtime (heap/stack setup, bkpt 0xAB semihosting stubs, the ADC ISR tail). The application proper is app_main @0x0802136C.

1.2 app_main @0x0802136C — the top-level superloop ★ HARD ENTRY POINT

0x0802136C  movs r4,#0
0x0802136E  bl 0x0801DAD0                       ; low-level HW/clock/periph bring-up
0x08021372  ldr r0,=0x08002800 ; ldr r1,=0xE000ED08 ; str r0,[r1]  ; VTOR = app vector base
0x08021378  bl 0x08021024                       ; init B
0x0802137C  movs r0,#0xC8 ; bl 0x08007946       ; init C (display/boot?)
0x08021382  bl 0x080127D4                       ; init D
0x08021386  bl 0x08003060                       ; init E (codeplug/settings load)
0x0802138A  b  0x08021414                       ; -> jump to loop top
--- LOOP TOP ---
0x0802138C  if (*(u8*)0x20000C54) bl 0x0801A38C  ; reboot flag -> NVIC_SystemReset
0x08021396  modeA = *(u8*)0x20000B66            ; serial/session mode gate
            modeB = *(u8*)0x20000C12
            spiMode = *(u8*)0x20000C57
            if (modeA==0 && modeB!=2 && modeB!=4 && spiMode==0)
0x080213B2      bl 0x080207DC                    ; ★ NORMAL UI TICK (radio running)
            else  ...                            ; PC-programming / SPI-write session paths:
0x080213C2      modeB==2 -> bl 0x08019A7C        ; CPS mode handler
0x080213E2      modeB==4 -> bl 0x0801F84C        ; serial framer
0x080213EE      modeA!=0 -> bl 0x0801F84C ; bl 0x0801F540 ; drain FM100B rx (0x200082EF, 0x08006CB8)
0x0802140C  bl 0x0801F854                        ; serial poll (every iteration)
0x08021410  bl 0x0801FE50                        ; housekeeping (every iteration)
0x08021414  b  0x0802138C                        ; loop

Interpretation. The superloop first honours a reboot request, then branches on serial-session state: when the radio is not in a PC-programming / SPI-write session (0x20000B66==0, 0x20000C12∉{2,4}, 0x20000C57==0), it runs the normal UI tick 0x080207DC. Otherwise it services the CPS/serial paths. 0x0801F854 (serial poll) and 0x0801FE50 (housekeeping) run unconditionally every pass.

Boundary note (respect the CPS/codeplug contract). The serial session vars 0x20000B66 / 0x20000C12 / 0x20000C57 / 0x20000C54 and the handlers 0x08019A7C / 0x0801F84C / 0x0801F540 / 0x0801F854 are the CPS protocol + SPI region-write engine (documented in RT-4D_RE_Report §4). A UI rewrite must leave this entire else branch and the two unconditional serial calls exactly as-is — that is the serial/CPS boundary. Only replace what happens inside the normal UI tick 0x080207DC.

vaddr name signature notes
0x0802136C app_main void app_main(void) __attribute__((noreturn)) The application superloop. Sets VTOR, runs inits, then loops. Do not relocate — reset path branches here.
0x0801DAD0 hw_init void hw_init(void) clock/peripheral bring-up (called first)
0x08003060 codeplug_load_init void(void) last init; loads settings/channels from SPI (candidate)
0x0801A38C nvic_system_reset void(void) noreturn writes AIRCR 0x05FA0004 (reboot). Gated by 0x20000C54.
0x080207DC ui_tick_normal void(void) the normal-mode UI scheduler (see §2)
0x0801F854 serial_poll void(void) runs every loop; part of CPS path — keep
0x0801FE50 housekeeping void(void) runs every loop (battery/timers)

2. ui_tick_normal @0x080207DC — the cooperative UI scheduler

This is not the state machine itself; it is a fixed list of periodic subsystems, several gated by down-counters so they run at different rates. The screen draw + key dispatch are two of its calls.

0x080207DE  bl 0x0801F84C   ; serial framer (shared)
0x080207E2  bl 0x0801F9C0   ; ? (reads 0x20000C8A menu-key state, 0x200029BB struct)
0x080207E6  bl 0x0801E1BC   ; ★ ui_draw_dispatch  (SCREEN DRAW — hook here)
0x080207EA  bl 0x0801E050   ; ui_sidekey_dispatch (side/long-press hotkeys — see §3.3)
0x080207EE  bl 0x0801E3F8   ; build display line buffer (reads 0x200008B3)
0x080207F2  bl 0x0801FAC8   ; ...
0x080207F6  bl 0x0801F504
0x080207FA  bl 0x0801FDA4
0x080207FE  bl 0x0801FDDC
0x08020802  bl 0x0801FE0C
0x08020806  bl 0x0801DFD0
--- period-gated tasks: counter at 0x20000BF1.. reloads to a period when it hits 0 ---
0x0802080A  if(--tick@0x20000BF1==0){reload 1;   bl 0x0801E6EC(key?) ...}   see note
0x08020816  bl 0x0801E6EC   ; ★ ui_process_key   (SCREEN INPUT — hook here)
0x0802081A  bl 0x08020288
   ... 0x20000BF2 (reload 4), 0x20000BF3 (reload 0xA), 0x20000BF5 (0xC8),
       0x20000BF6 (0x1F4), 0x20000BF8 (0x3E8): slower periodic tasks (RSSI, battery, scan, etc.)
0x080208DC  return

Concretely, the two dispatch calls you care about are both unconditional every UI pass:

  • 0x080207E6 bl 0x0801E1BC → screen draw dispatch
  • 0x08020816 bl 0x0801E6EC → screen key dispatch

(Down-counter reloads observed: 0x20000BF1→1, 0x20000BF2→4, 0x20000BF3→0xA, 0x20000BF5→0xC8, 0x20000BF6→0x1F4, 0x20000BF8→0x3E8 — these throttle the slower periodic tasks; the two dispatchers themselves are not throttled.)


3. The UI state machine

3.1 The state variable

g_screen  = *(uint8_t*)0x200008B3   ; current screen id, 0..11 (0x0B)
g_screen_prev = *(uint8_t*)0x200008B2   ; previous screen (used for restore/back)

Both dispatchers guard with cmp g_screen,#0x0C ; bhs <default> before the tbb, so valid ids are 0..11.

3.2 The two mirror dispatch tables (draw + input)

Both are tbb [pc,r0] byte-offset jump tables indexed by g_screen. They are positionally parallel: index i in the draw table and index i in the input table are the same screen.

DRAW dispatcher ui_draw_dispatch @0x0801E1BC:

0x0801E1BE  bl 0x080136E4                 ; clear per-field dirty flags (row @0x20002512)
0x0801E1C2  r0 = *(u8*)0x200008B3
0x0801E1C6  cmp r0,#0x0C ; bhs 0x0801E228 (default/no-op)
0x0801E1CA  tbb [pc,r0]                   ; table @0x0801E1CE

INPUT dispatcher ui_process_key @0x0801E6EC (tbb inside at 0x0801E780):

0x0801E6EE  bl 0x08005A14                 ; keypad scan/debounce
0x0801E6F4  if(*(u8*)0x20000B5A==0) return ; keystate (0x20000B57+3) == no-key -> bail
0x0801E6FC  if(*(u8*)0x20000B59==0xFF) return ; keycode (0x20000B57+2) == none -> bail
            ... global lock / special-mode guards ...
0x0801E778  r0 = *(u8*)0x200008B3
0x0801E77C  cmp r0,#0x0C ; bhs 0x0801E804 (default)
0x0801E780  tbb [pc,r0]                   ; table @0x0801E784
   each case: r2=0x20000B57; r1=[r2,#3](keystate); r0=[r2,#2](keycode); bl <handler>
0x0801E806  after dispatch: *(u8*)(0x20000B57+2) = 0xFF  ; consume keycode

The key event struct is at 0x20000B57: byte +2 = keycode, byte +3 = keystate/edge (1 = press/repeat). Handlers receive (r0=keycode, r1=keystate).

3.3 Screen table — id → {draw handler, input handler}

Verified by decoding both tbb tables (bytes are half-word offsets from the table base):

id draw handler input handler inferred screen evidence
0 0x08013BD8 0x08017F60 Home / VFO-A main (default landing) most strb g_screen,#0 "return home" sites; 0x08013BD8 reads big status struct 0x200009C3+0x42
1 (default no-op 0x0801E228 / input 0x0801E804) blank / transient table byte 0x2D→no-op; input 0x40→no-op
2 0x08013980 0x08007B10 VFO/standby key screen 0x08007B10 handles MENU key (10) → sets g_screen=0xA (menu entry, §3.4); EXIT/side keys
3 0x08013A44 0x0800FE3C screen w/ icon @0x0802505C (freq-input / dial) draws bitmap via 0x08008D62; input checks 0x20000855
4 0x08014978 0x0802074C screen w/ icon @0x08025115 bitmap blit; input digit 0xC handling
5 0x08013940 0x08007784 numeric entry A (0–F) input: cmp r4,#0xF; bl 0x08012EF8 (hex digit)
6 0x080139EC 0x08007CD0 numeric entry B (0–9) input: cmp r4,#9; bl 0x08012F78 (dec digit)
7 0x08013804 0x08007E680x08017F60 alt of screen 0 input forwards to id-0 handler 0x08017F60
8 0x08013B6C 0x08007D…/via 0x0801E7E4→0x0800F930 list/scroll screen draw calls 0x080085D6; several handlers special-case g_screen==8 (0x0800F930)
9 0x08013F7C (input tbl byte 0x33→no-op region 0x0801E7EA) status/info screen draw reads 0x20000A3D+0x39, 0x20000A7B
10 (0xA) 0x080142C0 0x08017294 MENU system draw = the menu render loop (facts §Display); input = full menu key handler (largest, sub sp,#0x64)
11 (0xB) 0x08014104 0x0801D3B0 SMS / editor (text) draw blits chars at fixed cols; input handles 0–9 (cmp r4,#9)

Notes:

  • Standby "home" screen (id 0/7) is the radio's idle/operating screen (frequency, channel, RX/TX, DMR-rx overlay). The full home rendering also runs through the period-gated tasks and 0x0801E3F8 (line-buffer builder) — the dispatched draw handler 0x08013BD8 does the incremental field redraw.
  • id 1 is a genuine no-op slot (both tables route it to the shared "do nothing" tail). Treat as "transition/blank".
  • All input handlers share the uniform prototype void screen_key(uint8_t keycode, uint8_t keystate).

3.4 How transitions happen (state writes)

Screen changes are plain byte stores strb rN, [=0x200008B3]. Enumerated immediate-write sites (subset; movs rN,#imm immediately before the strb):

new id example write sites meaning
0 0x08003490, 0x080094AA, 0x0800AA70, 0x0800AE4C, 0x0800B200, 0x0801E120, 0x0801F06C, 0x0801F81E, … (many) return to Home / EXIT
1 0x080094AE, 0x080095DC, 0x08009760, 0x0801E5DA, 0x0801F698, … enter transient/blank
2 0x0801A2BE enter VFO/standby key screen
3 0x0800B112, 0x08018344, 0x0801A2DE enter freq-input/dial
4 0x0800BC58, 0x0801E15A enter screen 4
5 0x08009842 enter numeric-entry A
7 0x0801A66C enter alt-home
8 0x0800AF0A enter list/scroll
9 0x080094D2 enter status/info
10 (0xA) 0x0800B92A (from Home MENU key, §below) enter MENU
11 (0xB) 0x0800B85C enter SMS/editor
0x11 0x080094A4 (id 17 — value out of 0..11 range; likely a sub-mode byte, not a screen; used by a specialized editor)
0x2A 0x08009A9C (id 42 — same: sub-mode marker, not a screen dispatch id)

Canonical transition example — Home → Menu (in the id-2 standby key handler 0x08007B10, MENU key path lands in 0x0800B900):

0x0800B91C  bl 0x0801AD9C            ; menu-open side effects (build top-level list)
0x0800B926  movs r0,#0xA
0x0800B928  ldr r1,=0x200008B3
0x0800B92A  strb r0,[r1]             ; g_screen = 10 (MENU)

So navigation = write the target id to 0x200008B3 (optionally saving the old value to 0x200008B2 for "back"), plus per-screen enter side-effects. A custom router replicates exactly this.


4. How a screen is (re)drawn each loop

The draw model is incremental / dirty-flag driven, not full-frame:

  1. ui_draw_dispatch @0x0801E1BC first calls 0x080136E4, which walks a dirty-flag row at 0x20002512 (loop of 8+) and force-marks fields dirty on screen entry.
  2. It then tbb-dispatches to the current screen's redraw routine (§3.3). Each routine reads its per-field "changed?" bytes (e.g. 0x200008F5, 0x20000914, 0x20000A84) and only when set calls the text/glyph primitives:
    • draw_string @0x08008A50void draw_string(u8 y_page /*r0*/, u8 x /*r1*/, const char* s /*r2*/, u8 len /*r3*/, u8 mode /*[sp+0x28]*/); mode 0=normal,1=inverse,2=outline. (Confirmed head: mov sl,r0; mov r7,r1; mov r5,r2; mov fp,r3.)
    • 0x08008D62 — bitmap/icon blitter (draw_bitmap(mode,x,y,const u8* bmp))
    • 0x080089AC, 0x080085D6, 0x08008798 — box/line/clear helpers.
  3. The framebuffer is flushed to the LCD over SPI2 by the period-gated tasks; the ASCII glyph blitter 0x08007FB8 and wide/GBK blitter 0x08008454 pull font bitmaps from SPI flash (per facts).

Consequence for a rewrite: because draw is gated behind dirty flags and a tbb on g_screen, replacing the dispatched routine (or the dispatcher call) cleanly takes over rendering for that screen without fighting the stock partial-redraw logic — as long as your code marks the whole area dirty / clears+redraws each frame itself.


Goal: our code owns rendering + navigation + which key does what, while calling stock lower-level APIs (draw_string 0x08008A50, keypad 0x08005A14, RF/DMR/codeplug helpers, and — untouched — the serial/CPS engine). Three options, most-preferred first.

Patch the two bl instructions in ui_tick_normal:

patch site stock instr change to
0x080207E6 bl 0x0801E1BC (ui_draw_dispatch) bl my_draw_router
0x08020816 bl 0x0801E6EC (ui_process_key) bl my_key_router
  • Your my_key_router reads the same key struct at 0x20000B57 (+2 keycode, +3 keystate) — call stock 0x08005A14 first if you want the stock debounce, or read raw. After handling, write 0xFF to 0x20000B57+2 to consume, exactly as stock does at 0x0801E806.
  • Your my_draw_router renders via draw_string @0x08008A50 and the blit/clear helpers, keyed on your own screen model. You may keep or ignore g_screen@0x200008B3.
  • Everything else in the superloop and scheduler is preserved, so RF, DMR RX, scan, battery, and the entire serial/CPS + SPI region-write path (0x080213B8 else-branch, 0x0801F854, 0x0801FE50) keep working unchanged. This is the minimal, cleanest cut: 2 instructions.
  • Keep 0x080207EA bl 0x0801E050 (side-key/long-press handler) if you still want stock side-key semantics, or repoint it too for full control.

Option B: replace the two tbb jump tables (per-screen, incremental)

Repoint individual entries in the draw table (@0x0801E1CE, 12 bytes) and input table (@0x0801E784, 12 bytes) to your own handlers, one screen at a time. Because entries are 1-byte half-word offsets from the table base, a target must be within +0..+0x1FE of the base; to jump far, keep a stock case as a 2-instruction trampoline (b.w my_handler) inside range. This lets you migrate screens gradually while stock screens still work. More fiddly than Option A.

Option C: own the screen id + provide new handlers

Keep the dispatchers, but treat 0x200008B3 as your state var and point all 12 draw/input slots at your dispatch trampolines. Effectively Option B for all 12 at once; no advantage over Option A.

Lower-level APIs to reuse (stable call targets)

purpose vaddr prototype
draw text 0x08008A50 draw_string(u8 y_page, u8 x, const char* s, u8 len, u8 mode@sp+0x28)
draw icon/bitmap 0x08008D62 draw_bitmap(u8 mode, u8 x, u8 y, const u8* bmp)
ascii glyph blit 0x08007FB8 (per facts; SPI font @0x19C000)
wide/GBK glyph blit 0x08008454 (per facts; SPI font @0x19E000)
keypad scan/debounce 0x08005A14 void keypad_scan(void) (fills 0x20000B57)
SPI codeplug read 0x08021828 spi_read(dst, byteaddr, len)keep format
menu-open helper 0x0801AD9C builds top-level menu list (call if reusing stock menu data)
reboot 0x0801A38C nvic_system_reset()

Do NOT touch (serial/CPS + codeplug boundary — hard constraint): the superloop else-branch 0x080213B8..0x0802140A, serial_poll 0x0801F854, the framer/dispatcher 0x0801F84C / 0x08019790 / 0x080188D4, and the SPI region-write engine. These implement the stock CPS protocol and the on-flash codeplug format; leaving them byte-identical is what keeps the stock Radtel CPS working.


6. Key RAM state variables (UI)

addr width name role
0x200008B3 u8 g_screen current UI screen id (0..11) — the state machine selector
0x200008B2 u8 g_screen_prev previous screen (back/restore)
0x20000B57 struct g_key key event: +2 keycode, +3 keystate(1=press)
0x20000C3D u8 g_sidekey side/long-press keycode consumed by 0x0801E050
0x20000BF1..BF8 u8/u16 scheduler down-counters throttle slow periodic tasks in ui_tick_normal
0x20002512 u8[8+] dirty-flag row per-field "needs redraw" flags (cleared by 0x080136E4)
0x20000C54 u8 reboot request superloop → nvic_system_reset
0x20000B66 / 0x20000C12 / 0x20000C57 u8 serial-session mode gates select UI vs CPS/SPI-write path (do not repurpose)

7. Confidence

  • HIGH — superloop (0x0802136C), normal-UI scheduler (0x080207DC), the single screen-state var 0x200008B3, and the two mirror tbb dispatchers (draw 0x0801E1BC, input 0x0801E6EC) with their 12-entry tables. All directly disassembled and cross-checked (55 xrefs to the state var; both tbb tables decoded; the Home→Menu transition traced end-to-end).
  • HIGH — the recommended hook (patch bl at 0x080207E6 and 0x08020816); both call sites verified in the scheduler disassembly, and the serial/CPS boundary is cleanly separated in the superloop.
  • MEDIUM — the English names assigned to screen ids 3/4/5/6/8/9/11: the dispatch structure and handler vaddrs are certain, but exact screen semantics are inferred from handler behaviour (digit ranges, bitmap vs text, forwarding) rather than from a label string on each. ids 0/7 (Home), 2 (standby-key), 10 (Menu), 11 (SMS/editor) are well-anchored.