numworks移植记录:12.移植调试实战——运行时问题排查与解决

3 阅读8分钟

移植调试实战——运行时问题排查与解决

在前几篇文章中,我们成功完成了 NumWorks 各模块的编译适配,并生成了可烧录的固件。然而,当程序真正在 ESP32-S3 硬件上运行时,一系列意料之外的问题浮出水面。这些问题涉及链接、内存、显示同步、数据类型错误、图形接口缺陷以及内存越界等。本文将详细记录这些问题的现象、分析过程及最终解决方案,希望能为你的嵌入式调试之路提供一些实战经验。


1. 链接失败:组件注册与依赖

现象:所有模块编译通过,但在链接阶段报错,提示大量符号未定义(undefined reference)。错误信息涉及 omgpoincare 等内部库的函数。

分析:ESP-IDF 的构建系统要求每个组件(component)必须通过 idf_component_register 明确声明自己的源文件、头文件路径以及依赖的其他组件。我们的 NumWorks 移植将原有代码拆分为多个逻辑组件(如 omgionkandinskypoincare 等),但在 CMakeLists.txt 中可能遗漏了某些依赖关系,或者头文件路径未正确包含,导致链接器找不到符号。

解决方法:为每个组件编写正确的 CMakeLists.txt。以 omg 组件为例:

cmake

idf_component_register(
    SRCS
        src/bit_helper.cpp
        src/global_box.cpp
        # 其他源文件...
    INCLUDE_DIRS
        "include"
        "${CMAKE_CURRENT_LIST_DIR}/include"
        "${CMAKE_CURRENT_LIST_DIR}/../omg/include"   # 必要时使用绝对路径
    REQUIRES
        # 该组件依赖的其他组件,例如:
        # cxx
)
  • SRCS 列出该组件的所有源文件。
  • INCLUDE_DIRS 列出该组件对外提供的头文件路径,以及内部需要的其他路径。
  • REQUIRES 指明该组件依赖的其他组件名称(如 cxxfreertos 等),确保链接时能正确排序。

特别注意:INCLUDE_DIRS 中的路径最好使用绝对路径或相对于组件目录的路径,避免因构建系统解析错误导致头文件找不到。完成所有组件的注册后,链接错误消失。


2. 内存分配不足:分区表调整

现象:固件烧录后,程序运行到某处突然崩溃,或者出现奇怪的异常(如随机复位、访问非法地址)。使用 idf.py monitor 查看日志,有时会看到类似 Guru Meditation Error 的信息,指向某个地址无法访问。

分析:通过 GDB 调试,发现崩溃时程序计数器(PC)指向的地址往往在未映射的区域,或者堆栈指针异常。进一步检查分区表,发现默认的 factory 分区大小仅为 1MB,而我们的固件(包含所有应用和数学库)已经超过 1.5MB。当固件大小超过分区容量时,链接器并不会报错,但烧录后运行到超出部分时就会触发异常。

解决方法:修改 partitions.csv 分区表,增大 factory 分区的大小。同时,我们为存储预留的分区 storage 也需要足够大。调整后的分区表示例:

csv

# Name,   Type, SubType, Offset,  Size,   Flags
nvs,      data, nvs,     0x9000,  0x6000,
phy_init, data, phy,     0xf000,  0x1000,
factory,  app,  factory, 0x10000, 3M,        # 从1M改为3M
storage,  data, spiffs,  ,        0xF0000,   # 1MB

重新编译烧录后,程序稳定运行。通过 idf.py size 可以查看固件实际大小,确保其不超过分区容量。


3. LCD 显示撕裂问题:TE 信号同步

现象:LCD 能够正常显示画面,但在界面切换或快速绘图时,屏幕出现明显的撕裂(画面上下半部分不同步,类似滚动效果)。

分析:NumWorks 的绘图方式是在内存帧缓冲中绘制,然后通过 refreshDisplay() 将整个缓冲发送到 LCD。如果 LCD 正在刷新一帧的过程中,我们突然更新了帧缓冲并再次触发刷新,就会导致显示内容不完整。ST7789 控制器提供了一个 TE(Tearing Effect)引脚,该引脚在每帧开始和结束时会产生电平变化。通过检测这个引脚,我们可以确保只在两次 TE 信号之间(即垂直消隐期)更新帧缓冲,从而避免撕裂。

解决方案

  1. 硬件连接:将 ST7789 的 TE 引脚连接到 ESP32-S3 的一个 GPIO(如 GPIO 21)。
  2. 软件实现:在 waitForVBlank() 函数中检测 TE 引脚电平变化。

cpp

// ion/src/esp32s3/display.cpp

bool Ion::Display::waitForVBlank() {
    // 等待 TE 引脚从低变高(表示新帧开始)
    while (gpio_get_level(TE_GPIO) == 0) {}
    while (gpio_get_level(TE_GPIO) == 1) {}
    return true;
}

然后在 refreshDisplay() 前调用此函数:

cpp

void Ion::Display::refreshDisplay() {
    waitForVBlank();   // 确保在安全窗口内更新
    esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, Width, Height, sFrameBuffer);
}

这样修改后,画面撕裂问题消失。


4. 数据类型转换错误:native_int 与 native_uint 混淆

现象:在绘图功能中,某些点没有绘制出来;在解方程时,结果完全错误,甚至导致程序崩溃。例如,输入 x^2 - 2 = 0,期望得到 ±√2,却得到大数或 NaN。

分析:使用 GDB 单步调试,发现关键变量 -2 在某个环节变成了 4294967294(即 0xFFFFFFFE)。这正是有符号负数被错误解释为无符号数的典型表现。进一步检查代码,定位到一处强制转换:

cpp

// 错误代码
some_function(static_cast<native_uint_t>(value));

native_uint_t 被定义为 uint32_t,当 value 为负数时,位模式保持不变,但解释为无符号数。正确的做法应该是使用有符号类型:

cpp

some_function(static_cast<native_int_t>(value));

native_int_t 通常定义为 int32_t。修改所有类似错误后,解方程结果恢复正常,绘图点也正确显示。

经验教训:在跨平台移植中,务必明确数据的符号性,特别是在使用强制转换时。对于可能为负数的值,优先使用有符号类型。


5. 图形接口实现错误:pullRect 导致黑边

现象:绘制曲线时,曲线周围出现不应有的黑边,或者某些区域颜色异常。

分析:NumWorks 的图形库有时需要从屏幕读取像素(例如窗口拖动时的内容恢复),这通过 pullRect 函数实现。我们的初始实现中,pullRect 只是简单地从帧缓冲复制数据,但忽略了帧缓冲与屏幕颜色格式可能存在的差异(如字节序)。此外,当读取区域超出帧缓冲边界时,未做裁剪处理,导致读取到脏数据。

解决方法:完善 pullRect 的实现:

cpp

void Ion::Display::Context::pullRect(KDRect rect, KDColor* pixels) {
    initFrameBuffer();

    int x = rect.x();
    int y = rect.y();
    int width = rect.width();
    int height = rect.height();

    // 严格裁剪到屏幕范围内
    if (x < 0) { width += x; x = 0; }
    if (y < 0) { height += y; y = 0; }
    if (x + width > Ion::Display::Width)  width = Ion::Display::Width - x;
    if (y + height > Ion::Display::Height) height = Ion::Display::Height - y;
    if (width <= 0 || height <= 0) return;

    uint16_t* fb_line_start = sFrameBuffer + y * Ion::Display::Width + x;
    for (int row = 0; row < height; row++) {
        memcpy(pixels + row * width,
               fb_line_start + row * Ion::Display::Width,
               width * sizeof(uint16_t));
    }
}

同时,确保 KDColor 的字节序与帧缓冲一致(均为 RGB565 小端序)。修正后,曲线黑边消失。


6. 数组越界导致存储损坏:快速切换页面卡死

现象:在快速切换不同应用(如从计算器切换到图形)时,有时会突然卡死,无法响应任何操作。重启后,之前保存的设置可能丢失。

分析:使用 GDB 设置硬件观察点监控关键数据区域。我们怀疑存储系统(Ion::Storage)的记录名被意外修改,因此对某个记录的 baseName 指针指向的内存设置写断点:

bash

(gdb) watch *(char[8]*)0x3ffb1234   # 假设记录名 "sys" 所在地址

继续运行,当卡死发生时,GDB 断下,显示调用栈。发现一个数组写入操作越界,覆盖了存储区。该数组原本只有 3 个元素,但代码中写入了第 4 个元素(索引 3),恰好覆盖了紧随其后的字符串 "sys" 的最后一个字符 s,将其改为 \0,导致记录名变为 "sy",后续查找记录失败,系统陷入死循环。

解决方法:检查相关数组的定义和使用,确保所有索引均在合法范围内。增加边界检查断言,防止越界写入。

cpp

assert(index >= 0 && index < array_size);
array[index] = value;

修复后,快速切换页面不再卡死,存储数据完好无损。


结语

通过这一系列调试实践,我们不仅解决了具体问题,更积累了宝贵的嵌入式调试经验。总结几点心得:

  • GDB 硬件观察点是追踪内存非法修改的利器。
  • 分区表配置直接影响程序稳定性,务必根据实际固件大小调整。
  • 显示同步需要硬件支持,充分利用 TE 信号可以避免撕裂。
  • 数据类型转换需格外小心,尤其是涉及负数和无符号类型时。
  • 边界检查断言能提前捕获潜在的内存错误。

希望这些记录能帮助你在未来的移植项目中少走弯路。下一篇文章,我们将对整体移植工作进行总结,并展望未来可能的优化方向。