diff --git a/docs/sensor-driver-extraction.md b/docs/sensor-driver-extraction.md index 9b557f8..ec90860 100644 --- a/docs/sensor-driver-extraction.md +++ b/docs/sensor-driver-extraction.md @@ -286,7 +286,8 @@ Splits the raw log into phases: | Phase | What it contains | |---|---| | `pre_sensor` | Bus probe, MIPI/VI struct dumps, pre-init noise | -| `init` | From `sensor_write_register(0x100, 0x0)` (reset) to `sensor_write_register(0x100, 0x1)` (stream-on) | +| `init` | From the sensor's standby/reset write to its stream-on write (see "Sensor-family init patterns" below) | +| `mode_switch_N` | Each subsequent stream-off → reconfigure → stream-on cycle | | `post_init` | A short burst of AE/exposure prime writes between stream-on and the steady-state loop | | `runtime` | Per-frame writes during steady-state (e.g. AE updating exposure registers) | @@ -296,6 +297,72 @@ The init/post-init split exists for diff-friendliness: the AE loop in `init`-only against the reference's init function avoids spurious mismatches. +#### Sensor-family init patterns + +Different sensor vendors gate "stream on" through different registers. +The segmenter has a small table of `(family, reg, init_val, stream_val)` +patterns and tries each in order; the first that finds both endpoints +in a trace wins. The matched family is recorded as `init_pattern` in +the segments JSON. + +| Family | Register | Init value | Stream-on value | Sensors | +|---|---|---|---|---| +| `smartsens` | `0x0100` | `0x00` (reset) | `0x01` (stream-on) | SC2315E, SC2335, SC*, SmartSens generally | +| `sony_imx` | `0x3000` | `0x01` (standby) | `0x00` (release) | IMX291, IMX385, IMX307, Sony IMX line | + +Adding a family is one entry in `INIT_PATTERNS` at the top of +`trace_segment.py`. If your trace is recognised but no init phase is +detected, your sensor probably uses a third pattern — write the +addresses and values in here and the segmenter will pick it up. + +If no pattern matches, the segmenter emits everything as `pre_sensor` +and the generator skips the function-body emission. Most often that +means your sensor uses a third stream-control register convention not +yet in `INIT_PATTERNS`. Check the raw trace for the obvious bracket +(a register written once near the start, then again near the end with +the opposite value) and add an entry. + +### Decoder coverage across HiSilicon families + +Different HiSilicon families take different paths to the I2C bus. +ipctool's ptrace decoder handles each: + +| Family | Sensor driver path | What ipctool decodes | +|---|---|---| +| HISI_V1 | `ioctl(/dev/hi_i2c, CMD_I2C_WRITE, &I2C_DATA_S)` | `xm_i2c_*` callbacks decode the structured payload | +| HISI_V2 / V2A | `write(/dev/i2c-X, buf, reg+data)` little-endian, after `I2C_16BIT_REG/DATA` ioctls | `i2c_write_exit_cb` infers widths from `nbyte`, picks LE for V2/V2A; `hisi_gen2_ioctl_exit_cb` decodes `I2C_SLAVE_FORCE` | +| HISI_V3 / V3A / V4 / V4A | `ioctl(/dev/i2c-X, I2C_RDWR, &i2c_msg)` big-endian | `hisi_i2c_read_*_cb` decodes the rdwr message | + +uClibc on some V1/V2 firmwares wraps the libc `write()` call as a +single-iovec `writev()` rather than direct `__NR_write`. ipctool +handles both — `syscall_writev_exit` decodes the iovec and forwards +to the same fd callback as plain `write()`. + +When threads share an fd table (`CLONE_FILES`, the standard for +multi-thread streamers), opening a fd in one thread makes it usable +in all of them. ipctool maintains this invariant explicitly: on +`open()` it broadcasts the new fd state to every tracked process; on +`close()` it clears it everywhere. Without this, a thread peer's +write on a fd opened by the parent silently drops to no callback. + +### When the trace is empty anyway + +A V1/V2 capture that shows `0` `sensor_write_register` lines despite +the streamer reporting init success usually means one of: + +* **Sensor `.so` opens its own I2C handle in a path our trace + doesn't see.** Check `/proc//fd` while it's running: + if the live `/dev/i2c-N` fd in the running process is different + from the one the trace caught (or arrived later than the kill), + capture for longer. +* **Sensor `.so` uses a HiSilicon-specific `/dev/*` device that + isn't in our dispatch table** (e.g. `/dev/sys`, `/dev/sns_drv0`). + The signature is the trace ending shortly after `i2c-N` banner + with no writes; live `/proc//fd` shows the unfamiliar device + open. Add it to `syscall_open`'s dispatch in `src/ptrace.c`. +* **Sensor `.so` invokes a ptrace-incompatible code path** (some + vendor binaries detect ptrace and skip the writes; rare). + ```bash python3 tools/trace_segment.py tools/dumps/cap.log # wrote tools/dumps/cap.log.segments.json diff --git a/src/ptrace.c b/src/ptrace.c index cd4c0aa..025a13c 100644 --- a/src/ptrace.c +++ b/src/ptrace.c @@ -135,6 +135,7 @@ void linux_new_mempeek() { #define SYSCALL_CLOSE 6 #define SYSCALL_IOCTL 54 #define SYSCALL_NANOSLEEP 0xa2 +#define SYSCALL_WRITEV 146 #define SYSCALL_OPENAT 322 static void *copy_from_process(pid_t child, size_t addr, void *ptr, @@ -513,6 +514,16 @@ static void hisi_gen2_read_exit_cb(process_t *proc, int fd, size_t remote_addr, } } +// Catches I2C_SLAVE_FORCE on Hi3518/Hi3516CV200 (HISI_V1/V2/V2A) so a +// `sensor_i2c_change_addr` line is emitted on the same path V3+ already +// gets from hisi_i2c_read_exit_cb. I2C_16BIT_REG / I2C_16BIT_DATA fall +// through silently - the actual widths are inferred from write nbyte. +static void hisi_gen2_ioctl_exit_cb(process_t *proc, int fd, unsigned int cmd, + size_t arg, ssize_t sysret) { + if (cmd == I2C_SLAVE_FORCE) + printf("sensor_i2c_change_addr(0x%x);\n", arg << 1); +} + static void default_read_exit_cb(process_t *proc, int fd, size_t remote_addr, size_t nbyte, ssize_t sysret) { #if 0 @@ -529,16 +540,35 @@ static void default_write_exit_cb(process_t *proc, int fd, size_t remote_addr, static void i2c_write_exit_cb(process_t *proc, int fd, size_t remote_addr, size_t nbyte, ssize_t sysret) { + if (nbyte < 2 || nbyte > 4) + return; unsigned char *buf = alloca(nbyte); - void *res = copy_from_process(proc->pid, remote_addr, buf, nbyte); - if (!res) { + if (!copy_from_process(proc->pid, remote_addr, buf, nbyte)) { printf("ERROR: write(%d, 0x%x, %d) -> read from addrspace\n", fd, remote_addr, nbyte); return; } - u_int16_t addr = buf[0] << 8 | buf[1]; - u_int8_t val = buf[2]; - printf("sensor_write_register(0x%x, 0x%x);\n", addr, val); + + // hisi_gen2_sensor_write_register on Hi3518/Hi3516CV200 (V1/V2/V2A) packs + // reg_addr little-endian; hisi_sensor_write_register on V3+ packs big- + // endian. Gen2 sets reg/data widths via I2C_16BIT_REG / I2C_16BIT_DATA + // ioctls before writing; gen3+ varies per call. Both use plain write() + // on /dev/i2c-X with reg_width + data_width bytes. We infer widths from + // nbyte and pick endianness from the chip family. + bool le = chip_generation == HISI_V1 || chip_generation == HISI_V2 || + chip_generation == HISI_V2A; + unsigned int reg, val; + if (nbyte == 2) { // 1-byte reg + 1-byte data (e.g. JXF22 on V2) + reg = buf[0]; + val = buf[1]; + } else if (nbyte == 3) { // 2-byte reg + 1-byte data (typical modern) + reg = le ? (buf[0] | buf[1] << 8) : (buf[0] << 8 | buf[1]); + val = buf[2]; + } else { // nbyte == 4: 2-byte reg + 2-byte data + reg = le ? (buf[0] | buf[1] << 8) : (buf[0] << 8 | buf[1]); + val = le ? (buf[2] | buf[3] << 8) : (buf[2] << 8 | buf[3]); + } + printf("sensor_write_register(0x%x, 0x%x);\n", reg, val); } static void gpio_write_cb(process_t *proc, int fd, size_t remote_addr, @@ -833,6 +863,51 @@ static void clone_fds(process_t *parent, process_t *new) { fprintf(stderr, "Cloned %d fds\n", cnt); } +// Threads cloned with CLONE_FILES share the kernel fd table - opening a +// new fd in one thread makes that fd valid in all peers immediately. But +// ipctool tracks fds per process_t, so an open in the parent leaves +// peer->fds[N] empty and any write the peer does on fdN gets dropped +// (the only effect that surfaced was an empty trace from libsns_jxf22.so +// during the cross-platform sweep). Broadcast helpers below mirror the +// kernel's fd-table sharing across our process_t entries: on open we +// copy the new fd state to every peer; on close we clear it everywhere. +typedef struct { + int fd; + process_t *src; +} fd_broadcast_ctx_t; + +static void broadcast_fd_open_cb(void *key, void *value, void *user) { + fd_broadcast_ctx_t *ctx = user; + process_t *peer = value; + if (peer == ctx->src) + return; + if (peer->fds[ctx->fd].file) + delete_arc_str(peer->fds[ctx->fd].file); + peer->fds[ctx->fd] = ctx->src->fds[ctx->fd]; + if (peer->fds[ctx->fd].file) + peer->fds[ctx->fd].file->ref_cnt++; +} + +static void broadcast_fd_close_cb(void *key, void *value, void *user) { + fd_broadcast_ctx_t *ctx = user; + process_t *peer = value; + if (peer == ctx->src) + return; + if (peer->fds[ctx->fd].file) + delete_arc_str(peer->fds[ctx->fd].file); + memset(&peer->fds[ctx->fd], 0, sizeof(peer->fds[ctx->fd])); +} + +static void broadcast_fd_open(process_t *src, int fd) { + fd_broadcast_ctx_t ctx = {.fd = fd, .src = src}; + ht_iterate(&pids, &ctx, broadcast_fd_open_cb); +} + +static void broadcast_fd_close(process_t *src, int fd) { + fd_broadcast_ctx_t ctx = {.fd = fd, .src = src}; + ht_iterate(&pids, &ctx, broadcast_fd_close_cb); +} + static void syscall_open(process_t *proc, int fd, int offset) { CHECK_FD; @@ -854,18 +929,18 @@ static void syscall_open(process_t *proc, int fd, int offset) { proc->fds[fd].ioctl_enter = xm_i2c_ioctl_enter_cb; proc->fds[fd].ioctl_exit = xm_i2c_ioctl_exit_cb; show_i2c_banner(fd); - return; + goto done; } if (!strcmp(filename, "/dev/ssp")) { proc->fds[fd].ioctl_enter = ssp_ioctl_enter_cb; proc->fds[fd].ioctl_exit = ssp_ioctl_exit_cb; - return; + goto done; } if (!strcmp(filename, "/dev/xm_gpio")) { proc->fds[fd].ioctl_exit = xm_gpio_ioctl_exit_cb; - return; + goto done; } if (IS_PREFIX(filename, "/dev/i2c-")) { @@ -874,6 +949,7 @@ static void syscall_open(process_t *proc, int fd, int offset) { case HISI_V2: case HISI_V2A: proc->fds[fd].read_exit = hisi_gen2_read_exit_cb; + proc->fds[fd].ioctl_exit = hisi_gen2_ioctl_exit_cb; break; case HISI_V3: case HISI_V3A: @@ -903,6 +979,14 @@ static void syscall_open(process_t *proc, int fd, int offset) { } else if (!strcmp(filename, "/dev/vi")) { proc->fds[fd].ioctl_exit = hisi_vi_ioctl_exit_cb; } + +done: + // CLONE_FILES siblings share the kernel fd table; mirror that here so + // a thread peer can decode write()/ioctl()/read() on the fd that was + // opened in this process. Without this, libsns_*.so workers that + // share fd state with the parent would silently drop sensor I/O + // (jxf22 was the canary). + broadcast_fd_open(proc, fd); } static void syscall_close(process_t *proc, ssize_t sysret) { @@ -916,6 +1000,7 @@ static void syscall_close(process_t *proc, ssize_t sysret) { delete_arc_str(proc->fds[fd].file); } memset(&proc->fds[fd], 0, sizeof(proc->fds[fd])); + broadcast_fd_close(proc, fd); } static void syscall_write_exit(process_t *proc, ssize_t sysret) { @@ -929,6 +1014,69 @@ static void syscall_write_exit(process_t *proc, ssize_t sysret) { proc->fds[fd].write_exit(proc, fd, remote_addr, nbyte, sysret); } +// uClibc on some HiSilicon V1/V2 builds maps the libc write() function to +// __NR_writev (146) with a single iovec instead of __NR_write (4). Without +// this handler, sensor I/O on those targets is invisible (jxf22 was the +// canary). Decode by reading the iovec(s) from the tracee, concatenating +// the buffers into one contiguous block, and delegating to the existing +// fd write_exit_cb. Bound the assembled buffer at 16 bytes - any sensor +// I/O is well under that, and a runaway iovcnt would otherwise let us +// allocate arbitrarily. +struct iovec_remote { + uint32_t base; + uint32_t len; +}; +static void syscall_writev_exit(process_t *proc, ssize_t sysret) { + int fd = proc->regs.regs.uregs[0]; + CHECK_FD; + if (sysret <= 0 || !proc->fds[fd].write_exit) + return; + + size_t iov_addr = proc->regs.regs.uregs[1]; + size_t iovcnt = proc->regs.regs.uregs[2]; + if (iovcnt == 0 || iovcnt > 8) + return; + + struct iovec_remote iov[8]; + if (!copy_from_process(proc->pid, iov_addr, iov, sizeof(iov[0]) * iovcnt)) + return; + + unsigned char buf[16]; + size_t total = 0; + for (size_t i = 0; i < iovcnt && total < sizeof(buf); i++) { + size_t take = iov[i].len; + if (take == 0) + continue; + if (total + take > sizeof(buf)) + take = sizeof(buf) - total; + if (!copy_from_process(proc->pid, iov[i].base, buf + total, take)) + return; + total += take; + } + if (total < 2) + return; + + // The write_exit_cb signature takes (proc, fd, remote_addr, nbyte, sysret). + // We can't easily expose our local `buf` through that interface; instead, + // the existing i2c_write_exit_cb does its own copy_from_process from the + // tracee. Since uClibc's write()->writev wrapper passes a single iovec + // whose iov_base IS the original write buffer, just pass that addr/len + // through. For multi-iovec callers (libc stdio puts/printf), the call + // semantics differ but the i2c_write_exit_cb's nbyte sanity check + // (2..4) drops them as not-a-sensor-write. + if (iovcnt == 1) { + proc->fds[fd].write_exit(proc, fd, iov[0].base, iov[0].len, sysret); + } else { + // For 2-iovec writes that look like sensor-shaped payloads (total + // 2/3/4 bytes), the buffers may be on separate addresses; we can't + // forward a single remote_addr. Best-effort: forward the first iov + // only, which carries the reg_addr in jxf22-style layouts and is + // enough for the decoder to log a sensor_write_register line with + // the right reg even if val is 0. + proc->fds[fd].write_exit(proc, fd, iov[0].base, iov[0].len, sysret); + } +} + static void syscall_read_exit(process_t *proc, ssize_t sysret) { int fd = proc->regs.regs.uregs[0]; CHECK_FD; @@ -981,6 +1129,9 @@ static void exit_syscall(process_t *proc) { case SYSCALL_WRITE: syscall_write_exit(proc, sysret); break; + case SYSCALL_WRITEV: + syscall_writev_exit(proc, sysret); + break; case SYSCALL_IOCTL: syscall_ioctl_exit(proc, sysret); break; @@ -1034,7 +1185,12 @@ static void do_trace(pid_t tracee) { wait(NULL); - long ptraceOption = PTRACE_O_TRACECLONE; + // TRACECLONE catches CLONE_VM threads (most modern multi-threaded + // streamers). TRACEFORK/TRACEVFORK catch genuine forked children; + // not strictly necessary for any tested target so far but cheap + // defensive coverage in case a streamer spawns a worker via fork(). + long ptraceOption = + PTRACE_O_TRACECLONE | PTRACE_O_TRACEFORK | PTRACE_O_TRACEVFORK; ptrace(PTRACE_SETOPTIONS, tracee, NULL, ptraceOption); ptrace(PTRACE_SYSCALL, tracee, 0, 0); diff --git a/tools/test_pipeline.sh b/tools/test_pipeline.sh index 81aff44..40eec1f 100755 --- a/tools/test_pipeline.sh +++ b/tools/test_pipeline.sh @@ -111,4 +111,32 @@ python3 tools/trace_diff.py "$tmp/driver.c" "$tmp/ref_old_style.c" \ grep -q 'address match: 4 / 4' "$tmp/cross.out" \ || { echo "cross-style ref didn't match (relaxed regex broken?)"; exit 1; } +# Sony-IMX init pattern: 0x3000=1 starts standby, 0x3000=0 releases. +# Reverse polarity from SmartSens. Validates the per-family pattern table. +echo "== sony_imx pattern detection ==" +cat > "$tmp/sony.log" <<'TRACE' +[200] child 201 created +sensor_i2c_change_addr(0x34); +sensor_write_register(0x3000, 0x1); +sensor_write_register(0x3005, 0x1); +sensor_write_register(0x3007, 0x0); +sensor_write_register(0x3009, 0x2); +sensor_write_register(0x3000, 0x0); +TRACE +python3 tools/trace_segment.py "$tmp/sony.log" --out "$tmp/sony.json" 2>&1 +python3 - "$tmp/sony.json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +assert d.get("init_pattern") == "sony_imx", \ + f"expected sony_imx, got {d.get('init_pattern')!r}" +assert d["summary"].get("init", 0) >= 3, \ + f"sony init too short: {d['summary']}" +print(f" detected: {d['init_pattern']}, init={d['summary'].get('init')} events") +PY +python3 tools/trace_to_driver.py "$tmp/sony.json" \ + --sensor sonyimx --out "$tmp/sony.c" +gcc -Wall -Wextra -fsyntax-only "$tmp/sony.c" +grep -q '^void sonyimx_linear_init' "$tmp/sony.c" \ + || { echo "sony scaffold missing linear_init"; exit 1; } + echo "OK: pipeline test passed" diff --git a/tools/trace_segment.py b/tools/trace_segment.py index a081a46..d64b772 100644 --- a/tools/trace_segment.py +++ b/tools/trace_segment.py @@ -122,26 +122,43 @@ def collapse_struct(events): return out -def find_init_bounds(events): - """Return (init_start_idx, init_end_idx). - - Heuristic: - - init_start = first 'write' whose reg == 0x100 and val == 0 (reset). - - init_end = first subsequent 'write' whose reg == 0x100 and val == 1 - (stream-on). +# Stream-control register conventions per sensor family. Each entry is +# (family_name, reg, init_val, stream_val): writing init_val to reg starts +# the init phase, writing stream_val to reg ends it (stream-on). Patterns +# are tried in order; first one that matches both endpoints in a trace +# wins. Add more entries here as new sensor families show up. +INIT_PATTERNS = [ + # SmartSens (SC2315E, SC2335, SC*) - reset register at 0x100 + ("smartsens", 0x100, 0, 1), + # Sony IMX (IMX291, IMX385, IMX307, ...) - standby register at 0x3000 + ("sony_imx", 0x3000, 1, 0), +] + + +def find_init_bounds(events, patterns=INIT_PATTERNS): + """Return (init_start_idx, init_end_idx, pattern). + + Tries each pattern in order. For the first pattern where both the + init_val write AND a subsequent stream_val write exist on `reg`, + returns the indices of those two writes plus the matching pattern + tuple. If no pattern fully matches, returns (None, None, None). """ - start = None - for i, (k, p) in enumerate(events): - if k == "write" and p["reg"] == 0x100 and p["val"] == 0: - start = i - break - if start is None: - return (None, None) - for j in range(start + 1, len(events)): - k, p = events[j] - if k == "write" and p["reg"] == 0x100 and p["val"] == 1: - return (start, j) - return (start, len(events) - 1) + for pattern in patterns: + _, reg, init_val, stream_val = pattern + start = None + for i, (k, p) in enumerate(events): + if k == "write" and p["reg"] == reg and p["val"] == init_val: + start = i + break + if start is None: + continue + for j in range(start + 1, len(events)): + k, p = events[j] + if k == "write" and p["reg"] == reg and p["val"] == stream_val: + return (start, j, pattern) + # Init started but no stream-on follows; treat trace tail as init body. + return (start, len(events) - 1, pattern) + return (None, None, None) def find_runtime_start(events, init_end): @@ -169,12 +186,16 @@ def find_runtime_start(events, init_end): return None -def find_mode_switches(events, init_end): +def find_mode_switches(events, init_end, pattern): """Find mode-switch boundaries after init_end. - A mode switch on a HiSilicon-style sensor cycles 0x100 (stream control): - write 0x100=0 to halt, reconfigure mode-specific registers, write - 0x100=1 to resume. Each such cycle is a `mode_switch_N` phase. + A mode switch cycles the same stream-control register that find_init_bounds + matched on - write init_val to halt, reconfigure mode-specific registers, + write stream_val to resume. Each such cycle is a `mode_switch_N` phase. + + `pattern` is the (name, reg, init_val, stream_val) tuple returned by + find_init_bounds. Pass None and the function is a no-op (no init was + detected, so no mode-switch frame of reference exists). Sensors that hot-swap modes via group-hold (e.g. 0x3812 toggling 0x00 -> writes -> 0x30) are not detected by this heuristic. Add a @@ -183,18 +204,19 @@ def find_mode_switches(events, init_end): Returns a list of (start, end) tuples, both inclusive, in trace order. """ - if init_end is None: + if init_end is None or pattern is None: return [] + _, reg, init_val, stream_val = pattern switches = [] i = init_end + 1 while i < len(events): k, p = events[i] - if k == "write" and p["reg"] == 0x100 and p["val"] == 0: + if k == "write" and p["reg"] == reg and p["val"] == init_val: start = i end = None for j in range(start + 1, len(events)): k2, p2 = events[j] - if k2 == "write" and p2["reg"] == 0x100 and p2["val"] == 1: + if k2 == "write" and p2["reg"] == reg and p2["val"] == stream_val: end = j break if end is None: @@ -230,11 +252,13 @@ def main(): events = [e for e in (parse(line) for line in f) if e is not None] events = collapse_struct(events) - # Phase boundaries. - init_s, init_e = find_init_bounds(events) - mode_switches = find_mode_switches(events, init_e) + # Phase boundaries. find_init_bounds also reports which sensor-family + # init pattern matched; pass it through so find_mode_switches uses the + # same stream-control register convention. + init_s, init_e, init_pattern = find_init_bounds(events) + mode_switches = find_mode_switches(events, init_e, init_pattern) # post_init and runtime live AFTER any mode switches, so anchor on the - # last 0x100=1 we saw (init_end if no switches, last switch end otherwise). + # last stream-on we saw (init_end if no switches, last switch end otherwise). last_streamon = mode_switches[-1][1] if mode_switches else init_e runtime_s = find_runtime_start(events, last_streamon) @@ -258,11 +282,18 @@ def main(): phases["post_init"] = serialize(events[post_init_start:]) summary = {phase: len(events) for phase, events in phases.items()} + pattern_name = init_pattern[0] if init_pattern else None out_path = args.out or args.input + ".segments.json" with open(out_path, "w") as f: - json.dump({"summary": summary, "phases": phases}, f, indent=2) + json.dump( + {"summary": summary, "init_pattern": pattern_name, "phases": phases}, + f, + indent=2, + ) print(f"wrote {out_path}", file=sys.stderr) + if pattern_name: + print(f" init_pattern: {pattern_name}", file=sys.stderr) for phase, count in summary.items(): print(f" {phase:12s} {count} events", file=sys.stderr)