前两篇(环境搭建原厂界面)里出现了不少“照做就行“的规矩:对象要放进 4 KiB 槽位、不能 delete、退出用 _exit()、标题接口要加 0x9c 偏移、Noah 控件不能嵌套……这一篇把它们的成因一次讲透,并给出滚动的完整实现。

问题的起点:有库,没有头文件

C++ 调用一个库,通常需要头文件告诉编译器:类长什么样、方法怎么声明。NP1380 的麻烦在于,固件只提供 libqpe.solibnm.solibNMCommonWidget.so 这些二进制库,原厂头文件从未公开

解决办法是从三个证据来源恢复出“最小兼容声明“:

  1. 动态库的导出符号表——C++ 修饰符号里编码了类名、方法名和参数类型;
  2. 内置程序的调用点和 MIPS 反汇编——看出构造函数传什么参数、字段在什么偏移;
  3. 实机运行验证——声明对不对,跑起来才知道。

恢复出的声明只追求一个目标:让编译器生成固件能识别的正确调用,不代表还原了类的完整字段和继承关系。include/np1380/ 下的六个头文件就是这个思路的产物:

文件内容
abi_storage_compat.h超额 placement storage 的容量约定
qpe_application_compat.hQPEApplication、BlueStorm 样式、主窗口入口
nm_titlebar_compat.hNMQWidget、标题按钮接口
nm_followlist_compat.h原厂滚动条与内容定位接口
nm_widgets_compat.h复选框、按钮、滑块和集合控件
qlineedit_compat.hQLineEdit 输入法隐藏符号绑定

规矩一:sizeof 不可信,所以用 placement storage

兼容声明只写了我们用到的几个方法,编译器据此算出的 sizeof() 比固件真实对象——真实对象的尾部还有我们不知道的字段。如果用普通 new 或栈上构造,固件代码往尾部写字段时就会踩到别人的内存:堆溢出,玄学崩溃。

对策是超额分配、原地构造:给每个对象准备一个 4 KiB(1024 个 unsigned long)的字对齐槽位,用 placement new 在里面构造。4 KiB 远大于目前观察到的任何对象,留有充足余量:

#define NP1380_ABI_STORAGE_WORDS 1024

static unsigned long g_storage[40][NP1380_ABI_STORAGE_WORDS];
static unsigned int g_used = 0;

static void *nextStorage(const char *tag)
{
    if (g_used >= 40) {
        fprintf(stderr, "ABI storage exhausted at %s\n", tag);
        _exit(121);  // 槽耗尽立即退出,禁止越界覆盖相邻对象
    }
    return g_storage[g_used++];
}

NMCheckBox *check = new(nextStorage("check")) NMCheckBox("Enabled", root, "check");

配套的两条纪律:

  • 禁止 delete:槽位是静态数组,而且对象的真实析构布局未知,调用析构可能读到错误尺寸的数据;
  • 退出用 _exit(rc):跳过 C++ 正常退出流程中的静态/局部析构,事件循环结束后直接终止进程。

这不是普通 Qt 程序的通用写法,而是缺失原厂头文件时的 ABI 保护措施。

规矩二:+0x9c,多继承陷阱

NMQWidget 同时是一个 QWidget 和一个标题按钮接口(NMTitleButtonsInterface)。第一反应可能是写成多继承:

// 错误示范!
class NMQWidget : public QWidget, public NMTitleButtonsInterface { ... };

反汇编确认的事实是:接口子对象位于 this + 0x9c。而设备上安装的 Qt 头文件让编译器认为 sizeof(QWidget)0x98。照多继承写法,编译器会把接口指针调整到 +0x98——差 4 个字节,读写全部错位,程序当场去世。

正确做法:公开继承只写 QWidget,接口通过显式偏移取得:

class NMQWidget : public QWidget
{
public:
    NMQWidget(QWidget *parent = 0, const char *name = 0,
              unsigned int flags = 0, bool option = FALSE);
};

inline NMTitleButtonsInterface *np1380TitleButtons(NMQWidget *widget)
{
    /* 0x9c 是当前固件特定 ABI;换固件版本必须重新验证。 */
    return reinterpret_cast<NMTitleButtonsInterface *>(
        reinterpret_cast<char *>(widget) + 0x9c);
}

同类陷阱还有构造函数:NMQWidget 构造函数最后一个 bool 参数写入的是 +0xc5 字段,而标题可见状态存在 +0xc4。差一个字节,所以不能用构造参数控制标题栏,必须调用 SetTitleVisible()

规矩三:绑定头文件里不存在的方法

libqte.so 导出了两个 QLineEdit 的输入法扩展方法,但设备安装的公开 qlineedit.h 没有声明它们。不改第三方头文件的前提下,可以用 GNU C++ 的 asm 标签,把自定义函数名直接绑到真实 C++ 修饰符号上:

extern "C" void np1380_qlineedit_setInputMethodEnable(QLineEdit *, bool)
    asm("_ZN9QLineEdit20setInputMethodEnableEb");
extern "C" void np1380_qlineedit_setDefInputMethods(
    QLineEdit *, unsigned short, unsigned short)
    asm("_ZN9QLineEdit18setDefInputMethodsEtt");

inline void np1380EnableLineEditInput(QLineEdit *edit)
{
    np1380_qlineedit_setInputMethodEnable(edit, TRUE);
    np1380_qlineedit_setDefInputMethods(edit, 0, 0xffff);
}

链接器看到 asm 标签就会去库里找那个真实符号,声明上“不存在“的方法就这样被调用了。第二篇强调的调用顺序(构造 → 属性 → 显示 → 开输入法 → 焦点)也是实机验证出的稳定顺序。

重头戏:让内容滚动起来

NMFollowList 提供原厂蓝色滚动条,但它有个要命的限制:把 Noah 控件嵌进它的内容区(或任何普通嵌套容器)后,部分控件直接不绘制。标准 QScrollView 在这个固件上同样不稳定。

验证出的可行方案反直觉但可靠——控件不平移进容器,而是全部保持为根窗口的直接子控件,再写一个“绑定器“根据滚动条数值批量移动和显隐它们:

class ScrollBinder : public QObject
{
public:
    ScrollBinder(NMFollowList *list, int top, int height, ...)
        : followList(list), viewTop(top), viewHeight(height), ...
    {
        startTimer(80);  // 80ms 轮询一次滚动值
    }

    void add(QWidget *widget, int x, int y, int w, int h)
    {
        items[count].widget = widget;
        items[count].x = x; items[count].y = y;   // y 是"内容坐标"
        items[count].w = w; items[count].h = h;
        ++count;
        sync(TRUE);
    }

    void sync(bool force = FALSE)
    {
        /* 滚动条首个有效值是 1,内容偏移 = 原始值 - 1。 */
        int raw = followList ? followList->getScrollBarValue() - 1 : 0;
        if (raw < 0) raw = 0;
        if (!force && raw == lastValue) return;
        lastValue = raw;

        int viewBottom = viewTop + viewHeight;
        for (int i = 0; i < count; ++i) {
            int screenY = viewTop + items[i].y - raw;
            items[i].widget->setGeometry(items[i].x, screenY,
                                         items[i].w, items[i].h);
            /*
             * 只显示完整落入客户区的控件。半露出的 Noah 控件可能越过
             * 裁剪边界,画到系统标题栏或固定页脚上。
             */
            if (screenY >= viewTop && screenY + items[i].h <= viewBottom)
                items[i].widget->show();
            else
                items[i].widget->hide();
        }
    }

protected:
    void timerEvent(QTimerEvent *) { sync(); }
    ...
};

设计上的几个取舍,每一个都有出处:

  • 为什么是 80ms 轮询而不是信号槽:缺失的原厂头文件让 NMFollowList 的 signal/slot 声明无法可靠恢复,定时器主动同步是最稳妥的替代。80ms 的延迟人眼基本无感。
  • 为什么偏移要减 1initScrollBar() 后滚动条的第一个有效位置是 1 而不是 0,初始位置也要 setScrollBarValue(1)
  • 为什么半露出的控件直接隐藏:Noah 自绘控件不完全遵守 Qt 的裁剪边界,半个控件可能画到标题栏或页脚上,宁可先藏起来。

调用方只需要用“内容坐标“登记控件,滚动逻辑完全不用管:

ScrollBinder *binder = new ScrollBinder(scroll, viewTop, viewHeight, root, "binder");
binder->add(check, 14, 28, 150, 26);
binder->add(edit, 14, 80, 242, 28);

已验证的坑清单

这些结论都来自实机运行,照搬可以省掉大量试错:

现象结论
NMImageSets 列表少于 3 项固件直接访问前三项,触发信号 139(段错误)
标准 QScrollViewQSlider此固件上不稳定,用 NMFollowListNMSlider 替代
Noah 控件嵌套进容器经常不绘制,保持为根窗口直接子控件
直接嵌入 NMLineEdit不稳定,用标准 QLineEdit + 输入法扩展
窗口加 WStyle_Customize | WStyle_NoBorderExQWS 不再创建系统标题栏
QList<QString> 传栈上字符串列表存的是指针,字符串必须长期有效

调试技巧

程序内置了几个环境变量开关,发布版默认关闭,排查问题时随手打开:

NP1380_TRACE_START=1 ./run_on_device.sh   # 逐阶段输出启动日志,定位构造期崩溃
NP1380_UI_SCROLL=470 ./run_on_device.sh   # 启动即滚动到指定内容位置,方便截图
NP1380_LEGACY_TITLE=1 ./run_on_device.sh  # 回退到应用内模拟标题栏(对照用)

一种工程态度

最后值得单独说的是证据等级意识。逆向恢复的世界里,“能用“和“懂了“之间隔着深渊,include/np1380/ 的文档把每条结论分成三档:

  • 已确认:导出符号、+0x9c 偏移、输入法扩展符号等;
  • 已实机验证:本系列用到的全部控件、原生标题栏、滚动方案;
  • 尚未确认WindowDecorationInterface::Button 的完整枚举语义、custom-button 位掩码的应用专用动作、所有类的真实对象大小。

对未确认的东西,代码里用中性命名(ButtonValue0 而不是猜一个 CloseButton),文档里写明“不知道“,而不是编造一个看似合理的语义。程序里所有 0x9c 这类魔数都标注了“当前固件特定,换固件必须重新验证“。正是这种克制,让这套代码在固件细节变化时知道该重新检查哪里。

系列回顾

三篇下来,我们走完了完整的链路:

  1. 环境搭建与第一个程序:模拟器、SSH、设备端编译、最小窗口;
  2. 打造原厂风格的界面:系统标题栏、BlueStorm 主题、NM 控件全家桶;
  3. ABI 兼容与滚动布局实战(本篇):placement storage、偏移陷阱、隐藏符号、滚动绑定器。

这套方法论的适用范围其实超出 NP1380 本身:任何“有二进制库、没头文件“的老设备,都可以用同样的思路——从符号表恢复声明、用超额槽位隔离未知、把每条结论按证据分级——让现代代码安全地跑在二十年前的固件上。