诺亚舟 NP1380 是一款基于 Linux 的电子词典(学习机)。它出厂自带一套完整的图形界面栈,却从来没有公开过官方 SDK。好消息是:固件里什么都有——Qt 库、编译器、控件库,我们只要摸清规则,就能写出和原厂程序一模一样风格的软件。

本系列共三篇:

  1. 环境搭建与第一个程序(本篇)
  2. 打造原厂风格的界面
  3. ABI 兼容与滚动布局实战

认识这台设备

先搞清楚我们面对的是什么:

项目详情
CPUIngenic JZ4740(MIPSel,小端 MIPS)
系统Linux 2.6.24.3 + BusyBox
屏幕320x240 触摸屏
GUI 栈Qt/Embedded 2.3.10 + QPE + 诺亚舟私有 NM 控件库
编译器设备自带 gcc 3.4.4(2006 年的工具链)

三个名词需要先解释一下,后面会反复出现:

  • Qt/Embedded:Qt 2 时代的嵌入式版本,自己直接往 framebuffer 上绘图,不需要 X11。库文件是 libqte.so
  • QPE(Qt Palmtop Environment):基于 Qt/Embedded 的“桌面环境“,提供系统标题栏、主题样式、输入法等。库文件是 libqpe.so。原厂程序的蓝灰色标题栏就是 QPE 画的。
  • NM 控件库:诺亚舟私有的控件集(libnm.solibNMCommonWidget.so 等),提供原厂风格的复选框、按钮、滑块、蓝色滚动条等。

关键结论:固件自带全部运行库。开发流程就是“写 C++ 代码 → 在设备上编译 → 链接固件里的库“,完全不需要自己移植 Qt。

没有真机?用模拟器

整个开发可以在 QEMU 模拟器里完成。OpenNoah 项目维护了一个定制版 QEMU,专门模拟 NP1380(机器类型 noah_np1380),相关资源:

模拟器通过 SPICE 协议输出画面(用 VirtViewer 连接,端口 41380),并模拟了一块 DM9601 USB 网卡。设备系统里运行着 Dropbear SSH 服务,宿主机通过 root@127.0.0.1:2222 就能登入设备传文件、跑命令。

真机的玩法完全一样:SSH 上去,剩下的步骤没有任何区别。

设备上的编译环境

NP1380 固件自带 gcc 3.4.4,可以直接在设备上编译,省去配置交叉编译工具链的麻烦。对这样一台小设备来说,就地编译一个单文件 GUI 程序只要几秒钟。

编译需要两类头文件:

  1. Qt 2.3.10 的头文件:固件没有安装完整开发头,需要自备一份 include_resolved/ 目录(来自 Qt/Embedded 2.3.10 SDK,请自行合法取得)。
  2. NM 控件的兼容声明:诺亚舟从未公开这些头文件,社区根据固件导出符号恢复了最小声明,放在项目的 include/np1380/ 目录。第二、三篇会详细用到。

编译命令(设备上执行):

export QTDIR=/opt/qt-2.3.10
export QPEDIR=/opt/QtPalmtop

g++ -DQWS -DQT_NO_DRAGANDDROP -fno-rtti -fno-exceptions \
    -I./include -I./include_resolved \
    -o qt_hello main.cpp \
    -L/opt/QtPalmtop/lib -L/opt/qt-2.3.10/lib \
    -lNMCommonWidget -lNMGui -lnm -lqpe -lqte \
    -lstdc++ -lm -ldl -lpthread

逐项说明:

  • -DQWS:告诉 Qt 头文件我们在 Qt/Embedded(QWS,Qt Window System)环境下编译。
  • -fno-rtti -fno-exceptions:Qt 2 不使用 RTTI 和异常,与固件库保持一致的编译配置。
  • -lNMCommonWidget -lNMGui -lnm -lqpe -lqte:链接顺序经过实机验证,从前向后依赖,不要随意调换。
  • 不需要 qmake,一行 g++ 命令就够了。

第一个程序

下面是一个完整可运行的最小程序:一个占满屏幕的窗口,带原厂系统标题栏,中间显示一行文字。

#include "np1380/abi_storage_compat.h"
#include "np1380/nm_titlebar_compat.h"
#include "np1380/qpe_application_compat.h"

#include <qapplication.h>
#include <qlabel.h>
#include <new>
#include <unistd.h>

/*
 * 先照做:每个诺亚舟/QPE 对象都要在这种 4 KiB 槽位里用 placement new
 * 构造,不能 delete,程序退出用 _exit()。原因在第三篇详细解释。
 */
static unsigned long g_storage[4][NP1380_ABI_STORAGE_WORDS];
static unsigned int g_used = 0;

static void *nextStorage()
{
    return g_storage[g_used++];
}

int main(int argc, char **argv)
{
    /* QPEApplication 取代普通的 QApplication,接入已经运行的 QPE 服务。 */
    QPEApplication *app = new(nextStorage())
        QPEApplication(argc, argv, QApplication::GuiClient);
    app->applyStyle();
    Global::setDefaultBlueStormStyle();

    /*
     * 客户区定为 320x214:QPE 会在窗口上方补一条 26 像素的原厂标题栏,
     * 加起来正好填满 320x240 的屏幕。
     */
    NMQWidget *window = new(nextStorage())
        NMQWidget(0, "hello", Qt::WType_TopLevel | Qt::WStyle_StaysOnTop, FALSE);
    window->setGeometry(0, 0, 320, 214);
    window->setFixedSize(320, 214);
    window->setCaption("Hello NP1380");

    /* 显式打开系统标题栏。 */
    NMTitleButtonsInterface *title = np1380TitleButtons(window);
    title->SetCustomButtons(0);
    title->SetTitleVisible(TRUE);

    QLabel *label = new QLabel("Hello, NP1380!", window, "label");
    label->setAlignment(Qt::AlignCenter);
    label->setGeometry(0, 80, 320, 40);
    label->show();

    /* TRUE 表示不让 QPE 再最大化客户区,保留我们算好的 320x214。 */
    app->showMainWidget(window, TRUE);

    int rc = app->exec();
    _exit(rc);
    return rc;
}

骨架和任何 Qt 程序一样:应用对象 → 主窗口 → 控件 → 事件循环。不一样的地方只有三处,都是这台设备的规矩:

  1. 应用对象是 QPEApplication 而不是 QApplication,它让程序接入已经在运行的 QPE 图形服务。
  2. 窗口用诺亚舟的 NMQWidget,并通过 np1380TitleButtons() 拿到标题栏控制接口。
  3. 所有 QPE/诺亚舟对象都用 placement new 构造在预留槽位里——这是硬性要求,不是风格偏好,第三篇会讲清楚原因。

运行的讲究

编译出的二进制不能直接双击跑,QPE 程序需要一组环境变量,而且必须从 /opt/QtPalmtop/bin 目录启动——QPE 按当前工作目录查找原厂图片、样式和输入法资源,换目录启动会出现图片加载失败、输入法不可用。

运行脚本 run_on_device.sh 的核心内容:

export HOME=/
export QTDIR=/opt/qt-2.3.10
export QPEDIR=/opt/QtPalmtop
export LD_LIBRARY_PATH=/opt/qt-2.3.10/lib:/opt/QtPalmtop/lib:/opt/QtPalmtop/3rdParty:/lib:/usr/lib
export QWS_DISPLAY=LinuxFb:0
export QWS_MOUSE_PROTO=TPanel:/dev/jz_ts
export QWS_KEYBOARD=TTY:/dev/input/event0

cd /opt/QtPalmtop/bin
exec /mnt/UsrDisk/qtdev/qt_hello

三个 QWS_* 变量分别告诉 Qt:画面画到 framebuffer、触摸来自 /dev/jz_ts 触摸面板、按键来自 /dev/input/event0

文件怎么进设备

这台设备的固件很精简,没有 sftp-server 也没有 scp,SSH 只能跑命令。解决办法朴素但有效:宿主机把文件字节转成十六进制文本,通过 SSH 标准输入喂过去,设备端用 xxd -r -p 还原成文件

# 宿主机 PowerShell:读字节 -> 转 hex -> 经 SSH 发送 -> 设备端 xxd 还原
$Bytes = [System.IO.File]::ReadAllBytes($LocalPath)
$Lines = for ($Offset = 0; $Offset -lt $Bytes.Length; $Offset += 32) {
    $Count = [Math]::Min(32, $Bytes.Length - $Offset)
    $Chunk = New-Object byte[] $Count
    [Array]::Copy($Bytes, $Offset, $Chunk, 0, $Count)
    ([BitConverter]::ToString($Chunk) -replace "-", "")
}
$Lines | & ssh -p 2222 -i $KeyPath root@127.0.0.1 "xxd -r -p > '$RemotePath'"

纯文本通道传输,不依赖设备上任何额外工具(xxd 是 BusyBox 自带的),二进制文件也能完好送达。

把上传、编译、启动串起来就是一个一键脚本 deploy_and_build.ps1

$env:NP1380_SSH_KEY = ".\np1380_id_ed25519"          # SSH 私钥
$env:NP1380_KNOWN_HOSTS = ".\np1380_known_hosts"    # 独立 known_hosts
$env:NP1380_QT_SDK_ARCHIVE = ".\qt_sdk_include_resolved.tar.gz"  # Qt 头文件包(首次)
.\deploy_and_build.ps1 -Run

脚本会把 main.cpp、构建脚本和 include/np1380/ 兼容头传到设备的 /mnt/UsrDisk/qtdev,就地编译,-Run 参数让它顺手结束旧进程、启动新版本并打印日志。改一行代码到看到新界面,一次命令完成。

小结与预告

到这里,环境链路已经打通:QEMU 模拟器(或真机)→ SSH 传文件 → 设备端 g++ 编译 → QPE 环境下运行,并且我们有了第一个带原厂标题栏的窗口。

不过现在这个窗口还很朴素。下一篇《打造原厂风格的界面》会用诺亚舟私有控件把界面武装起来:原厂蓝色滚动条、复选框、分段按钮、滑块、输入法,让程序和系统自带软件看不出差别。