[Qt] PySide 如何 debug 沒有 stack trace 的 Segmentation Fault

雖然 PySide 是 open source 也很好用,但如果我們用 PySide 寫了一些有 bug 的程式,GUI 在做了某個操作後就會無預警 crash 消失不見,只留下哀傷的一行訊息在 console 上:


Segmentation fault

現在是要怎麼 debug? 

最近我就想用 QTableView + QAbstractItemModel 做一個可以動態新增資料的 table,但是以下的程式碼跑起來就會遇到 segmentation fault (完整的程式碼在文章最下面):


from typing import Any, List
import sys

from PySide2 import QtCore, QtWidgets

class DataModel(QtCore.QAbstractItemModel):
    ...

class MainDialog(QtWidgets.QDialog):
    ...

if __name__ == "__main__":
    ...

看了一下我也不知道問題在哪,我也不太曉得 QAbstractItemModel 哪幾個 functions 是一定要 override 的,現在該怎辦?

解法1: 暫時切換成 PyQt

當我們把一開始的 import 全部換成:


from PyQt5 import QtCore, QtWidgets

再跑一次程式,這時候 GUI 不僅不會 crash,console 上還會多一行完整的訊息:


NotImplementedError: QAbstractItemModel.index() is abstract and must be overridden

Oh 原來我忘記要 override "index" function 了。

解法2: 用 gdb 來 debug

如果遇到提示還不夠明顯的狀況,我們可以用 gdb 來 debug。

等等! gdb 不是給 C++ 用的嗎? 和 Python 有什麼關係?

原來 PySide (Qt for Python) 只是像是用 Python 的殼把 Qt C++ 的程式碼包起來,詳細的運作原理可以參考 Qt for Python/Shiboken。簡言之,可以想成 PySide 在 Python 的責任就只有幫你呼叫 C++ Qt 的程式碼,不會在 Python 這邊多做什麼複雜的邏輯。

這個 "Segmentation fault" 錯誤是在 C++ 那邊很常會看到的,當然我們就會想說是不是可以用 gdb 來 debug。剛好在 Python Wiki 就有一個頁面教我們怎麼用 gdb 來 debug,簡單的步驟如下:

  1. gdb python
  2. run test.py

遇到錯誤時 gdb 會停下來可以打字,我們輸入 bt 就可以看到詳細的 backtrace:


(gdb) bt
#0  PyErr_SetObject () at Python/errors.c:84
#1  0x00007ffff7c5b46d in PyErr_SetString () at Python/errors.c:157
#2  0x00007fffe0b791b0 in QAbstractItemModelWrapper::index(int, int, QModelIndex const&) const ()
   from /xxx/.local/lib/python3.6/site-packages/PySide2/QtCore.abi3.so
#3  0x00007fffdab63ae5 in QTableView::paintEvent(QPaintEvent*) ()
   from /xxx/.local/lib/python3.6/site-packages/PySide2/Qt/lib/libQt5Widgets.so.5
#4  0x00007fffdb530e97 in QTableViewWrapper::paintEvent(QPaintEvent*) ()
   from /xxx/.local/lib/python3.6/site-packages/PySide2/QtWidgets.abi3.so
#5  0x00007fffda90b740 in QWidget::event(QEvent*) ()
   from /xxx/.local/lib/python3.6/site-packages/PySide2/Qt/lib/libQt5Widgets.so.5
#6  0x00007fffda9b421e in QFrame::event(QEvent*) ()
   from /xxx/.local/lib/python3.6/site-packages/PySide2/Qt/lib/libQt5Widgets.so.5
#7  0x00007fffdab182bc in QAbstractItemView::viewportEvent(QEvent*) ()
   from /xxx/.local/lib/python3.6/site-packages/PySide2/Qt/lib/libQt5Widgets.so.5
#8  0x00007fffdb536675 in QTableViewWrapper::viewportEvent(QEvent*) ()
   from /xxx/.local/lib/python3.6/site-packages/PySide2/QtWidgets.abi3.so
#9  0x00007fffe056b5a0 in QCoreApplicationPrivate::sendThroughObjectEventFilters(QObject*, QEvent*) ()
---Type <return> to continue, or q <return> to quit---

雖然沒有像 PyQt 直截了當告訴我們錯誤在哪,但我們看到 #2 應該也猜的到我們忘記寫 "QAbstractItemModelWrapper::index" 了。

Python 版本小陷阱

在 Linux 系統上 "python" 有時候指到 python 2 有時候卻是 python 3。

如果你是用 Python 3 去開發,怕 "gdb python" 找錯 Python 版本的話最好是說的更明確一點: "gdb python3"。

失敗方法: 用 faulthandler

如果我們去 Google 搜尋 "python debug segmentation fault" 我們可以在 StackOverflow 上找到一個看起來很簡單的解法,可以加在一開始程式執行的地方或是即將 crash 前:


import faulthandler; faulthandler.enable()

但很遺憾的是針對這次的 buggy code 還是沒有用,他會在 console 上寫:


Fatal Python error: Segmentation fault

Current thread 0x00007ffff7f9c740 (most recent call first):
  File "/xxx/test.py", line 109 in <module>
Segmentation fault

結語

看起來切換成 PyQt 解法可以最快找到問題在哪並且修正。比較複雜的 case 我們也有能力用 gdb 來做 debug。faulthandler 說不定在其他地方能派上用場,只是對 PySide 沒什麼作用。

在用 Python 寫 Qt 比較複雜的功能時我們還是要盡量去看 C++ 的 documentation,避免我們誤用他預期的使用方式。

附錄

GUI

一開始 QTableView 會顯示 2 rows:


點了 "Add more data" 後會多 3 rows:


會 Segmentation Fault 的 PySide2 Code


from typing import Any, List
import sys

from PySide2 import QtCore, QtWidgets


class DataModel(QtCore.QAbstractItemModel):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)

        self._headers: List[str] = ["Column A", "Column B", "Column C"]
        self._data: List[List[str]] = [
            ["item 1", "item 2", "item 3"],
            ["item 4", "item 5", "item 6"],
        ]

    def columnCount(self, parent=QtCore.QModelIndex()) -> int:
        return len(self._headers)

    def rowCount(self, parent=QtCore.QModelIndex()) -> int:
        return len(self._data)

    def headerData(
        self,
        section: int,
        orientation: QtCore.Qt.Orientation,
        role: int = QtCore.Qt.DisplayRole,
    ) -> Any:
        if (
            role == QtCore.Qt.DisplayRole
            and orientation == QtCore.Qt.Horizontal
        ):
            return self._headers[section]

        return None

    def data(
        self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole
    ) -> Any:
        if not index.isValid():
            return None

        if role == QtCore.Qt.DisplayRole:
            return self._data[index.row()][index.column()]

        return None

    def append_data(self, new_data: List[List[str]]) -> None:
        parent = QtCore.QModelIndex()
        first = len(self._data)
        last = first + len(new_data) - 1

        self.beginInsertRows(parent, first, last)

        self._data.extend(new_data)

        self.endInsertRows()


class MainDialog(QtWidgets.QDialog):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)

        self._layout = QtWidgets.QHBoxLayout(self)

        self._table_view = QtWidgets.QTableView()
        self._model = DataModel(self._table_view)
        self._table_view.setModel(self._model)

        self._button = QtWidgets.QPushButton()
        self._button.setText("Add more data")
        self._button.clicked.connect(self._add_more_data)

        self._layout.addWidget(self._table_view)
        self._layout.addWidget(self._button)

    def _add_more_data(self) -> None:
        new_data: List[List[str]] = [
            ["item 7", "item 8", "item 9"],
            ["item 10", "item 11", "item 12"],
            ["item 13", "item 14", "item 15"],
        ]
        self._model.append_data(new_data)


if __name__ == "__main__":
    app = QtWidgets.QApplication([])

    dialog = MainDialog()
    dialog.show()

    sys.exit(app.exec_())

修正的 PySide2 Code

在 class DataModel 補上了 "index" 和 "parent" 後就可以正常執行了。

from typing import Any, List
from typing import Any, List
import sys

from PySide2 import QtCore, QtWidgets


class DataModel(QtCore.QAbstractItemModel):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)

        self._headers: List[str] = ["Column A", "Column B", "Column C"]
        self._data: List[List[str]] = [
            ["item 1", "item 2", "item 3"],
            ["item 4", "item 5", "item 6"],
        ]

    def index(
        self, row: int, column: int, parent=QtCore.QModelIndex()
    ) -> QtCore.QModelIndex:
        if (
            row >= 0
            and row < len(self._data)
            and column >= 0
            and column < len(self._headers)
        ):
            ptr = self._data[row][column]
            return self.createIndex(row, column, ptr)

        return QtCore.QModelIndex()

    def parent(self, child: QtCore.QModelIndex) -> QtCore.QModelIndex:
        return QtCore.QModelIndex()

    def columnCount(self, parent=QtCore.QModelIndex()) -> int:
        return len(self._headers)

    def rowCount(self, parent=QtCore.QModelIndex()) -> int:
        return len(self._data)

    def headerData(
        self,
        section: int,
        orientation: QtCore.Qt.Orientation,
        role: int = QtCore.Qt.DisplayRole,
    ) -> Any:
        if (
            role == QtCore.Qt.DisplayRole
            and orientation == QtCore.Qt.Horizontal
        ):
            return self._headers[section]

        return None

    def data(
        self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole
    ) -> Any:
        if not index.isValid():
            return None

        if role == QtCore.Qt.DisplayRole:
            return self._data[index.row()][index.column()]

        return None

    def append_data(self, new_data: List[List[str]]) -> None:
        parent = QtCore.QModelIndex()
        first = len(self._data)
        last = first + len(new_data) - 1

        self.beginInsertRows(parent, first, last)

        self._data.extend(new_data)

        self.endInsertRows()


class MainDialog(QtWidgets.QDialog):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)

        self._layout = QtWidgets.QHBoxLayout(self)

        self._table_view = QtWidgets.QTableView()
        self._model = DataModel(self._table_view)
        self._table_view.setModel(self._model)

        self._button = QtWidgets.QPushButton()
        self._button.setText("Add more data")
        self._button.clicked.connect(self._add_more_data)

        self._layout.addWidget(self._table_view)
        self._layout.addWidget(self._button)

    def _add_more_data(self) -> None:
        new_data: List[List[str]] = [
            ["item 7", "item 8", "item 9"],
            ["item 10", "item 11", "item 12"],
            ["item 13", "item 14", "item 15"],
        ]
        self._model.append_data(new_data)


if __name__ == "__main__":
    app = QtWidgets.QApplication([])

    dialog = MainDialog()
    dialog.show()

    sys.exit(app.exec_())

留言

此網誌的熱門文章

[試算表] 追蹤台股 Google Spreadsheet (未實現損益/已實現損益)

[Side Project] 互動式教學神經網路反向傳播 Interactive Computational Graph

[插件] 在 Chrome 網頁做區分大小寫的搜尋