NEX-5T干净HDMI输出:固件逆向、PMCA打包与踩坑速查

从固件分析到可用的 PMCA 应用

NEX-5T干净HDMI输出:固件逆向、PMCA打包与踩坑速查

目录

  1. 问题的技术本质:两层显示架构
  2. 为什么固件里没有「HDMI Info. Display」
  3. Backup.bin 格式与 service mode 逆向
  4. PMCA 应用的打包要求
  5. CameraEx:反射调用序列
  6. 按键扫描码的提取
  7. 危险的 DisplayManager 接口
  8. APP 关键代码
  9. 工具链清单
  10. 踩坑速查表

一、问题的技术本质:两层显示架构

NEX-5T 的显示由两层合成后输出到 LCD 和 HDMI:

┌─────────────────────────────────────────┐
│  视频层(硬件层)                        │
│    · 相机实时画面                        │
│    · 按相机自己的分辨率渲染              │
│    · 不经过 Android 的 SurfaceFlinger    │
├─────────────────────────────────────────┤
│  图形层(UI 层)                         │
│    · 相机 OSD(快门/光圈/ISO/曝光挡位)  │
│    · 各种拍摄图标                        │
│    · 菜单                                │
│    · PMCA 应用的界面                     │
│    · 由 Android 合成                     │
└─────────────────────────────────────────┘
              ↓ 合成
        LCD / HDMI 输出

关键性质(实测确认):

  1. HDMI 是 LCD 的镜像 —— 不是独立通路。按 DISP 键,两边同步变化;「宽影像 → 全屏」同时改变两边。
  2. PMCA 应用运行时,相机的取景器渲染停止 —— 相机系统不再往屏幕上画自己的东西。
  3. 视频层的画质不受 Android 侧影响 —— 应用只能拿到画面,不能改变它的分辨率或处理方式。

推论:干净画面是怎么来的

相机 OSD 属于图形层,由相机系统在"拍摄界面"状态下绘制。

PMCA 应用占屏时,相机处于"应用界面"状态,取景器渲染停止 → OSD 根本不参与渲染。

所以 APP 只要: - 把相机的实时画面显示出来 - 上面什么都不画

HDMI 上就是干净的实时画面。

实测验证:SurfaceView 尺寸/格式只影响图形层

一度以为"把 SurfaceView 做大能让相机按更大尺寸渲染预览"。实测否定:

实验 结果
SurfaceView 640×480 → 1920×1080 画质无变化,视野无变化
色深 RGB_565 → RGBA_8888(format=1 生效) 因为不绘制内容,视觉无差别
surfaceFrame 确认缓冲区确实是 1920×1080 但视频层不受影响

结论:SurfaceView 的尺寸和格式属于图形层的事,视频层按相机自己的方式走。


二、为什么固件里没有「HDMI Info. Display」

这是整个项目前期投入最大的一段。目标:确认 NEX-5T 到底有没有这个设置(哪怕是被隐藏的)。

对照机型的选择

同期 CXD4132 平台的机型里,只有 SLT-A99 有「HDMI Info. Display」(说明书 p.173):

MENU → [HDMI Info. Display] → [Off]

对比了几台 CXD4132 机型的 0x0107(TV/HDMI)子系统大小:

机型 属性数
SLT-A77 2381
SLT-A65 2381
NEX-7 2389
NEX-5T 2828
SLT-A99 2833
DSC-RX100M2 3181(在那几个索引处结构不同,排除)

三条独立证据

证据 1:索尼规格表 NEX-5T 的规格表里,「HDMI Info. Display」这一行标为不支持。

证据 2:设置数据库的结构对比

解析两台机器的设置库后发现:

  • A99 的 0x0107 子系统 = NEX-5T 的 + 末尾 5 个属性(0x01070b0c – 0x01070b10)
  • 结构对齐(size + attr)率 100.0%
  • NEX-5T 最大 ID 0x01070b0b;A99 最大 ID 0x01070b10

结构对齐测试是必须先做的 —— 否则跨机型按索引比较会得到纯粹的错误结论。

证据 3:用实机查询

相机有 service mode,可以按 ID 直接读设置。喂给它 A99 独有的那个 ID:

readBackup(0x01070b0f)  →  Senser adjust control error 129

关键设计:同时跑了正对照和负对照。

类别 ID 结果
正对照(真实存在) 0x01070148 (palNtscSelector) 等 全部成功
负对照(随机 ID) 若干 全部 error 129
被测 0x01070b0f error 129

被测项与随机 ID 的反应完全一致 → 确认为"不存在的属性",而不是"存在但被锁"。

补充实验:改固件也没用

我们还构造了一个嫁接版 Backup.bin:

  • 把 A99 那 5 个属性移植进 NEX-5T 的设置库
  • numProperties 14507 → 14512,子系统 0x0107 2828 → 2833
  • 原有 13494 个属性字节级完全一致(保证没有破坏别的东西)

产物:0110_backup_MODIFIED_HDMI.bin(672,655 字节)

但静态分析给出了致命结论:

两台机器的固件里,都没有任何代码引用 0x01070b0f —— 不是 u32、不是 u16 对、也不是大端形式。

也就是说,这个设置即使被加进去,也没有代码去读它 —— 是死的。

而且刷入需要进入 updater 模式,会触发"恢复相机默认设置"(设置全丢),收益为零。

最终结论:不是隐藏,是这台机器的固件里就没有这套机制。

方法论教训(这一段值钱)

前期走了不少弯路,都是在密集二进制里做模式匹配导致的:

做法 结果
在固件里按 u16 搜设置 ID 命中几千个巧合
直接扫 Backup.bin 找 ID 字节 产出 3 字节步进的假阳性

教训: 1. 永远先跑正对照和负对照,确认工具本身能分辨真假,再去扫真实目标。 2. 跨机型按索引比较前,必须先做结构对齐验证(size + attr)。


三、Backup.bin 格式与 service mode 逆向

Backup.bin 结构

相机的设置存在一个 Backup.bin 里,格式如下:

偏移 0x00 ─┬─ 256 字节文件头
           │
           ├─ 子系统表:numSubsystems × 6 字节
           │    u16 numProperties
           │    u32 指向属性表的偏移
           │
           ├─ 属性表:numProperties × 5 字节(revision 2)
           │    u8  attr
           │    u32 指向数据区的偏移
           │
           └─ 数据区

属性 ID 编码:ID = (subsystem << 16) | index

注意:只有当 index < subsystem.numProperties 时 ID 才有效。这是判断"设置是否存在"的结构依据。

指针编码:ptr = (size << 24) | absoluteFileOffset

size 值 含义
0 变长数据,在 offset 处有 u16 size + u16 maxSize
0xff 超长数据(u16 长度)
其他 定长数据,长度即 size

附加规则:若 attr & 0x74 非零,则在 offset + maxSize 处还有一份 resetData(恢复默认值用的数据)。

校验和:存放在 0x20 处,等于

sum(bytes[0x00 : 0x20]) + sum(bytes[0x24 : dataOffset + dataSize])

service mode

相机的 service mode 通过 MSC(海量存储器)模式 + serviceshell 进入,提供 XPD 服务:

服务 状态
readBackup / writeBackup ✅ 可用
readFile / writeFile(FileControl) ✅ 可用
readHasp ❌ Senser product info error 128(ProductInfo 类,NEX-5T 不支持)
setTerminalEnable ❌ 同上

FileControl 可用这点很重要 —— 它意味着可以读写相机上的文件,是做逆向的关键通道。

辅助线索:字符串文件

string_*.uxc 文件的格式:

[u16 string_id][u16 marker][ASCII string \0]

marker 值:0x1e00(NEX-5T)/ 0x1e04(A99)。

但要注意:字符串文件是跨机型的超集 —— 某个字符串在文件里存在,不能证明对应功能在该机型上存在。

「宽影像」= Wide Image 就是这样定位到的(记录 id 0x037d,Setup 菜单区,紧邻 Disp. Basic Info)。


四、PMCA 应用的打包要求(为什么安装器拒绝我们的包)

症状

Starting task
Uploading 0% → 100%
Downloading 100%
Installing 0%            ← 卡在这里
Communication error 100: Error completed

一个不提供任何信息的错误。

排除过程

逐项排除,每一步都要重走完整安装流程:

尝试 结果
SHA-256 → SHA-1 签名 ❌ 仍失败
补上图标和 app_name 资源 ❌
加 CAMERA 权限 ❌
targetSdkVersion 18 → 10 ❌
确认 dex 版本 035(与可用 APP 一致) ❌
包体积 6 KB → 2 MB ❌
SPK 加密容器往返测试 ✅ 容器本身没问题
对齐(手写 zipalign) ❌

关键对照:重装一个已知可用的第三方 APP(胶片坊 RecipeLab_CN_1.3.2.apk):

Uploading 0% → 100%
Downloading 67% → 100%
Installing 0% → 100%     ← 走完了
Task completed successfully

证明:相机的安装通道正常,第三方签名的包也能装 —— 问题在我们的包本身。

根因

jarsigner 会重写整个 ZIP 文件。

相机端的安装器对 ZIP 内部结构敏感,期望:

AndroidManifest.xml → 资源 → classes.dex → META-INF/(签名在最后)

而 jarsigner 签名时会重排整个压缩包,丢掉了这个结构(顺带也破坏了 zipalign 的对齐)。

正确工具链

aapt2 compile --dir res -o res.zip
aapt2 link -o base.apk -I android.jar --manifest AndroidManifest.xml res.zip
cp base.apk unsigned.apk
( cd dex-dir && aapt add -f unsigned.apk classes.dex )     # 用 aapt add,不是 zip
zipalign -f 4 unsigned.apk aligned.apk
apksigner sign \
    --v1-signing-enabled true --v2-signing-enabled false --v3-signing-enabled false \
    --min-sdk-version 10 --max-sdk-version 18 \
    --out CleanHDMI.apk aligned.apk

换成这套之后,一次装上。

其他约束

项目 要求
minSdkVersion 10
targetSdkVersion 10
dex 版本 035
签名 v1(JAR signing),SHA-1 或 SHA-256 均可
zipalign 需要(但 v1 签名后仍可对齐,因为摘要算的是条目数据而非偏移)

五、CameraEx:反射调用序列

第三方 PMCA 应用通过 com.sony.scalar.hardware.CameraEx 访问相机。不需要索尼 SDK,用反射即可(胶片坊就是这么做的)。

API 结构(从 sony.cameraex.odex 反汇编得到)

// 静态方法
CameraEx open(int id, CameraEx$OpenOptions options)

// 实例方法
Camera   getNormalCamera()      // ← 返回标准 android.hardware.Camera
void     release()
// 以及一堆监听器注册方法(预览分析、放大倍率、快门速度等)

CameraEx$OpenOptions 的字段:

I  inherit_setting    setInheritSetting(boolean)
I  preview            setPreview(boolean)         // 只是"要不要预览"
I  target_media       setTargetMedia(String)      // 存储介质
注意:没有任何分辨率设置。 一度寄希望于此,实测否定。

调用序列

// onCreate
holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);   // ★ 必须
holder.setFormat(PixelFormat.RGBA_8888);

// 打开相机
Class<?> c  = Class.forName("com.sony.scalar.hardware.CameraEx");
Class<?> oo = Class.forName("com.sony.scalar.hardware.CameraEx$OpenOptions");
Method open = c.getMethod("open", int.class, oo);
Object cameraEx = open.invoke(null, 0, null);              // id=0, options=null
Camera camera = (Camera) c.getMethod("getNormalCamera").invoke(cameraEx);
holder.addCallback(this);

// surfaceCreated
camera.setPreviewDisplay(holder);
camera.startPreview();

三个关键发现

① setType(SURFACE_TYPE_PUSH_BUFFERS) 是必需的

缺这一行,startPreview() 必然失败。这是 v2 → v3 的分水岭。

来源:反汇编胶片坊的 onCreate:

const/4 v0, 0x3
invoke-interface {p1, v0}, Landroid/view/SurfaceHolder;->setType(I)V

② 相机 HAL 是个薄壳

camera.getParameters().getPreviewSize()              → null
camera.getParameters().getSupportedPreviewSizes()    → null
camera.getParameters().getPreviewFormat()            → 0 (NV21)
camera.getParameters().getFocusMode()                → "dmf"(Sony 特有)

预览尺寸既不可查也不可设 —— 真正的预览由索尼自己的层驱动,标准 Camera API 只是个接口壳。

③ OpenOptions 传 null 完全可行


六、按键扫描码的提取

这是整个项目里最"取证"的一段。

背景:为什么 keyCode 不够用

相机的物理键(播放、菜单、快门等)由相机系统处理,映射成 Sony 自定义扫描码。

反汇编胶片坊的按键处理,看到:

public boolean onKeyDown(int keyCode, KeyEvent e) {
    return runKey(e.getScanCode());      // ← 用扫描码,不是 keyCode
    ...
    if (scan == 0xe8) { ... }            // 确定键
    if (scan == 0xcf) { ... }            // 播放键
}

所以 APP 是收得到按键的,只是必须用 getScanCode() 去比对。

常量表的位置

相机的 Android 框架里有:

framework/com.sony.scalar.sysutil.ScalarInput.odex

反汇编后能看到 89 个按键常量,但——值全是空的:

.field public static final ISV_KEY_PLAY:I

因为 Java 的 static final int 常量的值存在 dex 的 static_values 编码数组里,而 baksmali 不打印它们。

读 static_values

DEX 的 class_def_item 第 8 个字段就是 static_values_off:

class_def_item (32 字节):
  class_idx          u32
  access_flags       u32
  superclass_idx     u32
  interfaces_off     u32
  source_file_idx    u32
  annotations_off    u32
  class_data_off     u32
  static_values_off  u32   ← 这里

static_values 是一个 encoded_array:uleb128 size + size 个 encoded_value。

每个 encoded_value 的格式:

第 1 字节 = (value_arg << 5) | value_type
紧随其后 value_arg + 1 个字节的数据(小端,有符号类型需符号扩展)
⚠️ 这里踩了个坑:我第一次写解析器时,把数据长度写成了按 value_type 固定的字节数(比如 int 就固定 4 字节)。 正确做法是 value_arg + 1 字节。 结果第一版输出每个值尾巴都带着 0x24(= (1<<5)|0x04,正是"2 字节 int"的头部字节),一眼就能看出错位。

修正后的完整映射表:

按键 扫描码 常量名 说明
播放图像 0xcf ISV_KEY_PLAY 会切回放界面
菜单 0x202 ISV_KEY_MENU
MOVIE 0x220 ISV_KEY_MODE_MOVIE 会切录制界面
开始/停止 0x203 ISV_KEY_STASTOP
快门半按 0x204 ISV_KEY_S1_1 对焦
快门全按 0x206 ISV_KEY_S2 拍摄
确定 0xe8 ISV_KEY_ENTER
上 / 下 / 左 / 右 0x67 / 0x6c / 0x69 / 0x6a ISV_KEY_UP/DOWN/LEFT/RIGHT
FN / AEL / AF-MF 0x208 / 0x214 / 0x215 ISV_KEY_FN/AEL/AFMF
红外遥控各键 0x228 – 0x24b ISV_KEY_IR_*
拨轮 0x209 – 0x211 ISV_DIAL_*

交叉验证

两套完全独立的数据源互相印证:

  • 胶片坊(能正常工作的第三方 APP)里比较的是 0xe8 和 0xcf
  • 我们从固件 static_values 里读出的 ISV_KEY_ENTER = 0xe8、ISV_KEY_PLAY = 0xcf

完全一致。


七、危险的 DisplayManager 接口

相机的 Android 层还有一个显示管理接口:

com.sony.scalar.hardware.avio.DisplayManager

可安全使用的读取方法:

getActiveDevices()        // 当前活动的显示设备
getDeviceInfo(String)
getDisplayedVideoRect()

⚠️ 危险方法:

controlGraphicsOutputAll(boolean onoff)

名字很有诱惑力("关掉图形输出"听起来正是我们要的),但实测结论是:

它关掉的是整个显示输出,不是叠加层。

实测表现(app-osd/ 就是这个实验):

调用 controlGraphicsOutputAll(false)
  → 画面全黑(连相机实时画面也没了)
  → 约 10 秒后看门狗检测到"无显示输出"
  → 相机强制重启

结论:不可用,且会让相机重启。 对应的 CleanOSD 应用建议从相机上删除。


八、APP 关键代码

三个必须做对的地方

① 预览初始化

holder = surface.getHolder();
holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);   // ★ 缺这行必失败
holder.setFormat(PixelFormat.RGBA_8888);                   // 32 位色(默认 RGB_565 有色带)

② 按键处理 —— 只对会切换界面的键退出

private static final int SCAN_MENU    = 0x202;
private static final int SCAN_PLAY    = 0x0cf;
private static final int SCAN_MOVIE   = 0x220;
private static final int SCAN_STASTOP = 0x203;

@Override
public boolean dispatchKeyEvent(KeyEvent e) {
    int scan = e.getScanCode();                 // ★ 必须用扫描码
    if (e.getAction() == KeyEvent.ACTION_DOWN) {
        // 电源键:释放相机但放行事件(不能拦截,否则关不了机)
        if (e.getKeyCode() == KeyEvent.KEYCODE_POWER) {
            release(); finish();
            return super.dispatchKeyEvent(e);   // ★ 放行
        }
        // 会切换显示界面的键 → 主动退出避让
        if (scan == SCAN_MENU || scan == SCAN_PLAY
                || scan == SCAN_MOVIE || scan == SCAN_STASTOP) {
            release(); finish();
            return true;
        }
        // 其余(快门、拨轮、AEL、方向键…)放行给相机
    }
    return super.dispatchKeyEvent(e);
}

③ 生命周期 —— 及时交还相机

@Override protected void onPause()  { super.onPause();  release(); ... }
@Override protected void onStop()   { super.onStop();   release(); ... }
@Override protected void onDestroy(){ super.onDestroy(); release(); }

release() 做三件事:stopPreview() → camera.release() → cameraEx.release()。

为什么这三处都不能省

位置 不做的后果
setType(PUSH_BUFFERS) startPreview() 失败,黑屏
按键处理(避让 + 放行) 按播放键 → 死循环重启;一刀切 → 快门失效
生命周期释放 按关机键 → 硬断电,镜头不缩回

九、工具链清单

构建(全部免 root 装在 ~/dl-android)

组件 用途
JDK 17(Adoptium) javac / keytool
android.jar(platform-23) 编译用的 API 桩
aapt2(2.20) 资源编译 + 链接 + 生成二进制 Manifest
aapt(build-tools 内) aapt add 塞 classes.dex(关键)
zipalign(build-tools) 4 字节对齐
apksigner(build-tools) v1-only 签名(关键)
r8.jar(D8) class → dex

逆向调查(项目里自己写的)

工具 用途
dexdump.py 极简 DEX 解析器 —— 遍历类/方法/字段,从 .odex 挖 API 签名
read-static-values.py 读 dex 的 static_values,还原 static final 常量值(按键表来源)
run-readbackup.py service mode 下按 ID 读设置(内置正/负对照)
run-service-probe.py service mode 自动化驱动
fetch-models.sh 多机型固件批量下载 + 解包流水线
build-modified-backup.py Backup.bin 结构改写(属性跨机型嫁接)
build-apk.sh 一键构建(含全部工具链步骤)
make-icon.py 图标生成器(超采样抗锯齿)

固件解包路径(备忘)

容器 处理方式
.uxc(magic uxc\x07) 直接解
IMT 线 .exe(FDAT / Packman) fwtool.py unpack -f <dat> -o <out>
Mac .dmg(未压缩 Apple Partition Map) 7z 提取 → 得 FirmwareData_*.dat → 再走 fwtool

十、踩坑速查表

# 坑 症状 解法
1 jarsigner 重写 ZIP 安装失败 error 100 改用 apksigner(v1-only)
2 zip 追加 dex 破坏结构 同上 用 aapt add
3 缺 setType(PUSH_BUFFERS) startPreview() 失败 加上(= 3)
4 用 keyCode 比按键 按键拦截无效 用 getScanCode()
5 一刀切"任意键退出" 半按快门无法对焦 按扫描码区分,拍摄类放行
6 只在 onDestroy 释放相机 关机硬断电、镜头不缩回 onPause/onStop 也释放
7 拦截电源键 关不了机 释放但放行事件
8 试图用 setPreviewSize 返回 null,无从下手 接受:视频层分辨率由相机决定
9 试图放大 SurfaceView 提画质 画质/视野都不变 只影响图形层,无意义
10 controlGraphicsOutputAll(false) 全黑 → 10 秒后重启 不要用
11 dex encoded_value 长度 常量值错位(尾巴带 0x24) 长度 = value_arg + 1,不是固定值
12 在密集二进制里模式匹配 大量假阳性 先跑正/负对照,再扫目标
13 跨机型按索引比设置 错误结论 先做结构对齐(size+attr)验证
14 相机装完 APP 自动关机 每次都要重连 属正常行为;把「自动关机」设长
15 按键拦截后仍死循环 光"吞事件"挡不住相机系统 必须主动 release() 避让

故障恢复

相机卡死(按键全无响应,包括电源键)时:

1. 取出电池(唯一有效办法)
2. 等 10 秒
3. 装回电池,开机
4. 相机应正常落到拍摄界面(已验证有效)

附:最终产物

文件 说明
build-app/CleanHDMI.apk 最终 APP(11.6 KB)
app/src/com/nex5t/cleanhdmi/MainActivity.java 源码(关键点均有注释)
app/res/ 图标(4 个密度)+ 字符串
build-apk.sh 构建脚本
README.md 方案文档

记录于 2026-10-14。所有结论均为实机验证,而非推测。

转发至

微信扫一扫分享

WeChat QR Code

有AI相关的想法,但不知道怎么落地?

我是国冰,独立AI开发者。十多年技术一线——半导体显示、智慧城市到自动驾驶;现在一个人+AI,接AI落地项目,从梳理需求到系统落地,陪你走通。

微信聊聊 微信二维码 看我能做什么