Мост к DMR-модулю, разбор спектра REFV DualTachyon, карта запчастей прошивки

Баузбенд FM100B:
- найден прозрачный мост ПК<->модуль (включение с зажатой МЕНЮ), проверен живьём
- расшифрована контрольная сумма кадров (one's complement, BE), сверена с ответом рации
- инструмент tools/fm100b.py (ping/send/raw/scan)
- cmd 0x25 = запрос версии, модуль отвечает V1.2.0.32 (совпало с офиц. образом)
- разобран диспетчер входящих кадров: 193 записи, реальный код у 10 команд
- cmd 0x59 = индикация приёма, она же управляет гейтом звука PA14
- исправлено: boot-handshake это cmd 0x84, а не 0x64 (research/re/dmr.md)

Спектроанализатор REFV DualTachyon (docs/refw-spectrum.md):
- вызывается как функция горячей клавиши №22 Analog Spectrum
- вход 0x08009CAA -> обычный 0x080139E4 / по зоне 0x08015318
- спектр это экран №11; тик 0x08013BA4 (автомат на 4 состояния), клавиши 0x08013BFC

Декомпозиция (docs/firmware-parts.md):
- два процессора + внешний SPI = три канала внедрения
- карта ресурсов SPI: шрифты, пиньинь, голос, таблица Unicode
- найден штатный загрузчик ресурсов FontVoicePicture (шрифты/голос/картинки)
- дерево меню целиком: MIC/SPK Gain, RX/TX Limit, SMS Format уже в стоке
- аудио двухступенчатое: PA2 питание УНЧ, PA14 гейт от DMR-модуля

Прочее:
- везде исправлен режим прошивки: тангента PTT вместо клавиши "*"
- устаревший Ru-4D_Flasher.exe удалён из репозитория
- добавлены инструменты реверса: xref, refs, gpiomap, gpioscan, schem
Этот коммит содержится в:
2026-07-22 21:17:20 +09:00
родитель ae36c3b729
Коммит 5f4d207aa5
34 изменённых файлов: 21718 добавлений и 17823 удалений
+1 -1
Просмотреть файл
@@ -95,7 +95,7 @@ def main():
print("[i] handshake (0xFF probe)...")
if not handshake(p):
raise SystemExit("[E] no bootloader (radio not in flash mode). "
"Power off, hold * (or the flash-mode key) and power on.")
"Power off, hold PTT and power on.")
print("[i] bootloader ready")
print("[i] erasing application region...")
erase(p)
+160
Просмотреть файл
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""FM100B (DMR baseband) console — talk to the RT-4D's DMR chip from the PC.
Требуется: рация включена с зажатой клавишей МЕНЮ (режим «Обновление DMR чипа»),
в котором прошивка работает прозрачным мостом USB ↔ FM100B (USART3).
Формат кадра: 68 | cmd | b | sub | CKh CKl | lenH lenL | payload… | 10
CRC — «интернетная» one's-complement сумма 16-битных BE-слов по всему кадру,
причём во время расчёта поле CRC = 0xFFFF; результат кладётся BE в [4..5].
python3 fm100b.py ping # штатный handshake (cmd 0x84)
python3 fm100b.py send 84 --sub 1 --payload 00
python3 fm100b.py raw 6884010100000001 0010
python3 fm100b.py scan 01 20 # опрос диапазона команд (только чтение статуса)
Порт задаётся через --port (по умолчанию берётся первый CH343).
Под WSL доступ к COM идёт через powershell.exe (WSL2 не пробрасывает COM напрямую).
"""
import argparse, subprocess, sys, time
DEFAULT_BAUD = 115200
# ---------------- frame helpers ----------------
def cksum(frame: bytes) -> int:
"""one's complement sum of 16-bit big-endian words (CRC field must be 0xFFFF)"""
s = 0
i = 0
while len(frame) - i > 1:
s += (frame[i] << 8) | frame[i + 1]
i += 2
if len(frame) - i:
s += frame[i] << 8
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return (~s) & 0xFFFF
def build(cmd: int, b: int = 1, sub: int = 1, payload: bytes = b"") -> bytes:
f = bytearray([0x68, cmd, b, sub, 0xFF, 0xFF,
(len(payload) >> 8) & 0xFF, len(payload) & 0xFF])
f += payload
f.append(0x10)
ck = cksum(bytes(f))
f[4], f[5] = (ck >> 8) & 0xFF, ck & 0xFF
return bytes(f)
def parse(resp: bytes):
"""decode a response frame -> dict (or None)"""
if len(resp) < 9 or resp[0] != 0x68:
return None
cmd, b, status = resp[1], resp[2], resp[3]
ck = (resp[4] << 8) | resp[5]
ln = (resp[6] << 8) | resp[7]
payload = resp[8:8 + ln]
chk = bytearray(resp[:9 + ln])
chk[4] = chk[5] = 0xFF
ok = cksum(bytes(chk)) == ck
return dict(cmd=cmd, b=b, status=status, length=ln,
payload=bytes(payload), crc_ok=ok, raw=resp)
# ---------------- serial via powershell (WSL2) ----------------
PS = r'''
$p = New-Object System.IO.Ports.SerialPort "{port}",{baud},"None",8,"one"
$p.ReadTimeout = 120
try {{ $p.Open() }} catch {{ Write-Output "ERR:$($_.Exception.Message)"; exit }}
$p.DiscardInBuffer()
$tx = [byte[]]@({txlist})
if ($tx.Length -gt 0) {{ $p.Write($tx,0,$tx.Length) }}
$buf = New-Object System.Collections.Generic.List[byte]
$sw = [Diagnostics.Stopwatch]::StartNew()
$last = 0
while ($sw.ElapsedMilliseconds -lt {tmo}) {{
try {{ $buf.Add($p.ReadByte()); $last = $sw.ElapsedMilliseconds }} catch {{}}
if ($buf.Count -gt 0 -and ($sw.ElapsedMilliseconds - $last) -gt {idle}) {{ break }}
}}
$p.Close()
Write-Output ("OK:" + (($buf | ForEach-Object {{ "{{0:x2}}" -f $_ }}) -join ""))
'''
def find_port() -> str:
out = subprocess.run(["powershell.exe", "-NoProfile", "-Command",
"[System.IO.Ports.SerialPort]::GetPortNames() -join ','"],
capture_output=True, text=True).stdout.strip().replace("\r", "")
ports = [p for p in out.split(",") if p]
if not ports:
sys.exit("COM-порт не найден. Подключите рацию.")
return ports[0]
def xfer(port: str, tx: bytes, timeout_ms: int = 1200, idle_ms: int = 250) -> bytes:
txlist = ",".join(str(x) for x in tx) if tx else ""
script = PS.format(port=port, baud=DEFAULT_BAUD, txlist=txlist,
tmo=timeout_ms, idle=idle_ms)
r = subprocess.run(["powershell.exe", "-NoProfile", "-Command", script],
capture_output=True, text=True)
line = [l for l in r.stdout.replace("\r", "").split("\n") if l.startswith(("OK:", "ERR:"))]
if not line:
return b""
if line[0].startswith("ERR:"):
sys.exit(line[0])
return bytes.fromhex(line[0][3:])
def show(tx: bytes, rx: bytes):
print(f" TX: {tx.hex(' ')}")
if not rx:
print(" RX: (нет ответа)")
return None
print(f" RX: {rx.hex(' ')}")
p = parse(rx)
if p:
print(f" cmd=0x{p['cmd']:02X} status=0x{p['status']:02X} "
f"len={p['length']} crc={'ok' if p['crc_ok'] else 'BAD'}"
+ (f" payload={p['payload'].hex(' ')}" if p['payload'] else ""))
return p
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--port")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("ping")
s = sub.add_parser("send"); s.add_argument("code"); s.add_argument("--b", default="1")
s.add_argument("--sub", default="1"); s.add_argument("--payload", default="")
r = sub.add_parser("raw"); r.add_argument("hex", nargs="+")
sc = sub.add_parser("scan"); sc.add_argument("first"); sc.add_argument("last")
a = ap.parse_args()
port = a.port or find_port()
print(f"[порт {port} @ {DEFAULT_BAUD}]")
if a.cmd == "ping":
tx = bytes([0x68, 0x84, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x10])
show(tx, xfer(port, tx))
elif a.cmd == "send":
tx = build(int(a.code, 16), int(a.b, 0), int(a.sub, 0), bytes.fromhex(a.payload))
show(tx, xfer(port, tx))
elif a.cmd == "raw":
tx = bytes.fromhex("".join(a.hex))
show(tx, xfer(port, tx))
elif a.cmd == "scan":
lo, hi = int(a.first, 16), int(a.last, 16)
print(f"опрос команд 0x{lo:02X}..0x{hi:02X} (b=1 sub=1, пустой payload)\n")
for c in range(lo, hi + 1):
tx = build(c)
rx = xfer(port, tx, timeout_ms=600, idle_ms=150)
p = parse(rx) if rx else None
if p:
print(f" cmd 0x{c:02X}: status=0x{p['status']:02X} len={p['length']}"
+ (f" payload={p['payload'].hex(' ')}" if p['payload'] else ""))
elif rx:
print(f" cmd 0x{c:02X}: неразобранный ответ {rx.hex(' ')}")
time.sleep(0.03)
if __name__ == "__main__":
main()
+76
Просмотреть файл
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Map every GPIO write in the RT-4D app: find calls to the low-level GPIO
helpers and back-resolve (port, mask, state) from the preceding instructions."""
import struct, re, capstone, os
BASE = 0x08002800
P = "/home/viktor/claude/rt-4d/stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin"
IMG = open(P, "rb").read()
md = capstone.Cs(capstone.CS_ARCH_ARM, capstone.CS_MODE_THUMB)
PORTS = {0x40020000: "GPIOA", 0x40020400: "GPIOB", 0x40020800: "GPIOC",
0x40020C00: "GPIOD", 0x40021000: "GPIOE", 0x40021400: "GPIOF",
0x40021800: "GPIOG"}
# low-level writers discovered by disassembly
HELPERS = {0x08021C6E: "gpio_set_state(port,mask,state)",
0x08021C56: "gpio_bsrr_write(port,mask)",
0x08021C4C: "set_bit0(reg,val)"}
def word(va):
o = va - BASE
return struct.unpack_from("<I", IMG, o)[0] if 0 <= o <= len(IMG) - 4 else None
def lit_of(ins):
m = re.search(r"\[pc, #(?:0x)?([0-9a-fA-F]+)\]", ins.op_str)
if m and ins.mnemonic.startswith("ldr"):
return word(((ins.address + 4) & ~3) + int(m.group(1), 16))
return None
# linear sweep, keep a sliding window to resolve register values
def scan():
hits = []
win = []
for ins in md.disasm(IMG, BASE):
win.append(ins)
if len(win) > 14:
win.pop(0)
if ins.mnemonic == "bl" and ins.op_str.startswith("#"):
tgt = int(ins.op_str[1:], 16)
if tgt in HELPERS:
regs = {}
for p in win[:-1]:
mm = re.match(r"(r\d+)", p.op_str)
dst = mm.group(1) if mm else None
if not dst:
continue
v = lit_of(p)
if v is not None:
regs[dst] = v
elif p.mnemonic in ("movs", "mov.w", "mov") and "#" in p.op_str:
try:
regs[dst] = int(p.op_str.split("#")[1], 0)
except Exception:
pass
elif p.mnemonic == "movw" and "#" in p.op_str:
try:
regs[dst] = int(p.op_str.split("#")[1], 0)
except Exception:
pass
hits.append((ins.address, HELPERS[tgt], regs.get("r0"), regs.get("r1"), regs.get("r2")))
return hits
if __name__ == "__main__":
rows = scan()
print(f"{len(rows)} GPIO-helper call sites\n")
print(f"{'site':<12}{'helper':<32}{'r0(port/reg)':<22}{'r1(mask/val)':<14}{'r2'}")
for site, h, r0, r1, r2 in rows:
pn = PORTS.get(r0, f"0x{r0:08X}" if r0 is not None else "?")
mask = f"0x{r1:X}" if r1 is not None else "?"
bit = ""
if r1 and r1 and (r1 & (r1 - 1)) == 0:
bit = f" (bit{r1.bit_length()-1})"
print(f"0x{site:08X} {h:<32}{pn:<22}{mask+bit:<14}{r2 if r2 is not None else ''}")
+50
Просмотреть файл
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Find every GPIO-base literal load in the RT-4D app and show the surrounding
code, so each (port, register-offset, bit-mask) access can be identified."""
import struct, re, sys, capstone
BASE = 0x08002800
IMG = open("/home/viktor/claude/rt-4d/stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin", "rb").read()
md = capstone.Cs(capstone.CS_ARCH_ARM, capstone.CS_MODE_THUMB)
PORTS = {0x40020000: "GPIOA", 0x40020400: "GPIOB", 0x40020800: "GPIOC",
0x40020C00: "GPIOD", 0x40021000: "GPIOE", 0x40021400: "GPIOF",
0x40021800: "GPIOG", 0x40021C00: "GPIOH"}
def word(va):
o = va - BASE
return struct.unpack_from("<I", IMG, o)[0] if 0 <= o <= len(IMG) - 4 else None
def litval(ins):
m = re.search(r"\[pc, #(?:0x)?([0-9a-fA-F]+)\]", ins.op_str)
if m and ins.mnemonic.startswith("ldr"):
return word(((ins.address + 4) & ~3) + int(m.group(1), 16))
return None
def main(ctx=6, only=None):
ins_list = list(md.disasm(IMG, BASE))
idx = {i.address: n for n, i in enumerate(ins_list)}
hits = []
for n, i in enumerate(ins_list):
v = litval(i)
if v in PORTS and (only is None or v == only):
hits.append((n, i, v))
print(f"{len(hits)} GPIO-base literal loads\n")
for n, i, v in hits:
print(f"=== {PORTS[v]} (0x{v:08X}) referenced at 0x{i.address:08X} ===")
for k in range(n, min(n + ctx, len(ins_list))):
j = ins_list[k]
extra = ""
lv = litval(j)
if lv is not None:
extra = f" ; =0x{lv:08X}"
print(f" 0x{j.address:08X}: {j.mnemonic:8} {j.op_str}{extra}")
print()
if __name__ == "__main__":
only = int(sys.argv[1], 16) if len(sys.argv) > 1 else None
main(only=only)
+114
Просмотреть файл
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Exhaustive reference finder for the RT-4D app image.
Decodes one instruction at EVERY 2-byte alignment (linear sweep desyncs on
Thumb), so nothing is missed.
python refs.py addr <0xADDR> [ctx] who pc-relative-loads this address/constant
python refs.py gpio full GPIO map: (port, register, mask) per site
"""
import sys, struct, re, capstone
BASE = 0x08002800
IMG = open("/home/viktor/claude/rt-4d/stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin", "rb").read()
md = capstone.Cs(capstone.CS_ARCH_ARM, capstone.CS_MODE_THUMB)
PORTS = {0x40020000: "GPIOA", 0x40020400: "GPIOB", 0x40020800: "GPIOC",
0x40020C00: "GPIOD", 0x40021000: "GPIOE", 0x40021400: "GPIOF"}
# AT32F43x GPIO layout
REG = {0x00: "CFGR", 0x10: "IDT", 0x14: "ODT", 0x18: "SCR(set)",
0x1C: "CLR16", 0x28: "CLR(clear)", 0x2C: "TOGR(toggle)"}
def word(va):
o = va - BASE
return struct.unpack_from("<I", IMG, o)[0] if 0 <= o <= len(IMG) - 4 else None
def decode_at(va):
o = va - BASE
for i in md.disasm(IMG[o:o + 4], va):
return i
return None
def litval(i):
if i is None or not i.mnemonic.startswith("ldr"):
return None
m = re.search(r"\[pc, #(?:0x)?([0-9a-fA-F]+)\]", i.op_str)
if not m:
return None
return word(((i.address + 4) & ~3) + int(m.group(1), 16))
def all_lit_loads():
"""[(va, ins, value)] for every pc-relative ldr at any 2-byte alignment"""
out = []
for off in range(0, len(IMG) - 4, 2):
va = BASE + off
i = decode_at(va)
v = litval(i)
if v is not None:
out.append((va, i, v))
return out
def cmd_addr(target, ctx=8):
hits = [(va, i) for va, i, v in all_lit_loads() if v == target]
print(f"pc-relative loads of 0x{target:08X}: {len(hits)}")
for va, i in hits:
print(f"\n--- 0x{va:08X} ({i.mnemonic} {i.op_str}) ---")
a = va
for _ in range(ctx):
j = decode_at(a)
if j is None:
break
v = litval(j)
e = f" ; =0x{v:08X}" if v is not None else ""
print(f" 0x{j.address:08X}: {j.mnemonic:8} {j.op_str}{e}")
a += j.size
def cmd_gpio():
loads = all_lit_loads()
rows = []
for va, i, v in loads:
if v not in PORTS:
continue
reg = i.op_str.split(",")[0].strip()
a = va + i.size
mask = None
# small forward window: find store to [reg,#off]
for _ in range(6):
j = decode_at(a)
if j is None:
break
m = re.match(r"(r\d+), \[" + reg + r", #(0x[0-9a-fA-F]+|\d+)\]$", j.op_str)
if j.mnemonic.startswith("str") and m:
src, off = m.group(1), int(m.group(2), 0)
# back-window for the immediate loaded into src
b = va
for _ in range(10):
b -= 2
p = decode_at(b)
if p and p.op_str.startswith(src + ", #"):
try:
mask = int(p.op_str.split("#")[1], 0)
except Exception:
pass
break
rows.append((va, PORTS[v], off, mask))
break
a += j.size
print(f"{len(rows)} GPIO store sites\n")
print(f"{'site':<13}{'port':<7}{'register':<14}{'mask':<10}bit")
for va, p, off, m in rows:
bit = f"bit{m.bit_length()-1}" if m and (m & (m - 1)) == 0 else ("?" if m is None else "multi")
print(f"0x{va:08X} {p:<7}{REG.get(off, hex(off)):<14}{(hex(m) if m is not None else '?'):<10}{bit}")
if __name__ == "__main__":
if sys.argv[1] == "addr":
cmd_addr(int(sys.argv[2], 16), int(sys.argv[3]) if len(sys.argv) > 3 else 8)
elif sys.argv[1] == "gpio":
cmd_gpio()
+21
Просмотреть файл
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Render a clipped region of the RT-4D schematic PDF at high DPI.
python schem.py <x0> <y0> <x1> <y1> <dpi> <out.png> (coords in PDF points)
Sheets (approx, points): 1: y 12-582 | 2: y 600-1170 | 3: y 1200-1764 | 4: y 1794-2370
Page is 864 x 2400 pt.
"""
import sys, fitz
PDF = "/mnt/c/Users/vikto/Downloads/Telegram Desktop/RT4DDLT01.pdf"
def render(x0, y0, x1, y1, dpi, out):
doc = fitz.open(PDF)
p = doc[0]
clip = fitz.Rect(x0, y0, x1, y1)
pm = p.get_pixmap(dpi=dpi, clip=clip)
pm.save(out)
print(f"{out}: {pm.width}x{pm.height} clip=({x0},{y0})-({x1},{y1}) dpi={dpi}")
if __name__ == "__main__":
a = sys.argv[1:]
render(float(a[0]), float(a[1]), float(a[2]), float(a[3]), int(a[4]), a[5])
+119
Просмотреть файл
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""RT-4D firmware xref/disasm helper.
python xref.py dis <vaddr> [n] disassemble n bytes at vaddr
python xref.py callers <vaddr> who bl/blx's this address
python xref.py lit <value> where this 32-bit literal appears (pool refs)
python xref.py imm <value> movw/movt pairs building this constant
python xref.py find <hexbytes> raw byte search
"""
import sys, struct, capstone
BASE = 0x08002800
import os
_CANDS = [
"/home/viktor/claude/rt-4d/stock-fw/rt4d_stock_v3.25_abs_0x08002800.bin",
"/home/viktor/claude/rt-4d-repo/firmware/rt4d_stock_v3.25_abs_0x08002800.bin",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "firmware",
"rt4d_stock_v3.25_abs_0x08002800.bin"),
]
IMG = next(open(p, "rb").read() for p in _CANDS if os.path.exists(p))
md = capstone.Cs(capstone.CS_ARCH_ARM, capstone.CS_MODE_THUMB)
md.detail = True
def _word(va):
o = va - BASE
if 0 <= o <= len(IMG) - 4:
return struct.unpack_from("<I", IMG, o)[0]
return None
def dis(va, n=0x60):
"""disassemble, annotating PC-relative literal loads with the resolved value"""
off = va - BASE
import re as _re
for i in md.disasm(IMG[off:off + n], va):
ann = ""
m = _re.search(r"\[pc, #(?:0x)?([0-9a-fA-F]+)\]", i.op_str)
if m and i.mnemonic.startswith("ldr"):
pcv = ((i.address + 4) & ~3) + int(m.group(1), 16)
w = _word(pcv)
if w is not None:
ann = f" ; @0x{pcv:08X} = 0x{w:08X}"
print(f" 0x{i.address:08X}: {i.bytes.hex():<10} {i.mnemonic:8} {i.op_str}{ann}")
def _all_calls():
"""Linear sweep collecting bl/blx targets -> {target: [sites]}."""
xr = {}
for start in (0, 2): # both alignments, dedupe by site
for i in md.disasm(IMG[start:], BASE + start):
if i.mnemonic in ("bl", "blx") and i.op_str.startswith("#"):
t = int(i.op_str[1:], 16)
xr.setdefault(t, set()).add(i.address)
return {k: sorted(v) for k, v in xr.items()}
def callers(va):
xr = _all_calls()
hits = xr.get(va, []) + xr.get(va | 1, []) + xr.get(va & ~1, [])
hits = sorted(set(hits))
print(f"callers of 0x{va:08X}: {len(hits)}")
for h in hits:
print(f" 0x{h:08X}")
return hits
def lit(val):
hits = []
for off in range(0, len(IMG) - 4, 4):
if struct.unpack_from("<I", IMG, off)[0] == val:
hits.append(BASE + off)
print(f"literal 0x{val:08X}: {len(hits)} hit(s)")
for h in hits[:60]:
print(f" 0x{h:08X}")
return hits
def imm(val):
"""find movw/movt pairs that build `val` (constant loaded into a reg)."""
lo, hi = val & 0xFFFF, (val >> 16) & 0xFFFF
out = []
for i in md.disasm(IMG, BASE):
if i.mnemonic == "movw" and i.op_str.endswith(f"#{hex(lo)}"):
out.append(i.address)
print(f"movw #{hex(lo)} (for 0x{val:08X}): {len(out)} site(s)")
for a in out[:60]:
print(f" 0x{a:08X}")
return out
def find(hexs):
pat = bytes.fromhex(hexs)
off = 0
hits = []
while True:
j = IMG.find(pat, off)
if j < 0:
break
hits.append(BASE + j); off = j + 1
print(f"bytes {hexs}: {len(hits)} hit(s)")
for h in hits[:60]:
print(f" 0x{h:08X}")
return hits
if __name__ == "__main__":
cmd = sys.argv[1]
if cmd == "dis":
dis(int(sys.argv[2], 16), int(sys.argv[3], 0) if len(sys.argv) > 3 else 0x60)
elif cmd == "callers":
callers(int(sys.argv[2], 16))
elif cmd == "lit":
lit(int(sys.argv[2], 16))
elif cmd == "imm":
imm(int(sys.argv[2], 16))
elif cmd == "find":
find(sys.argv[2])