Foreword

這篇文章會稍微解釋 Unix 的 TTY 系統以及 Pseudo Terminal 的概念,接著討論如何使用 Python 的 pty 模組中的 pty.fork() 來建立並控制子行程的 TTY 系統,最後用 Python 來實作控制 madplay 這個 CLI MP3 Player。

有問題歡迎大家詢問,有任何錯誤也請大家幫忙指正 :D


Python MP3 Player

這幾天在玩 ReSpeaker (一個基於 MT7688 的聲控裝置開發板)遇到了個問題:在 ReSpeaker 中要如何使用 Python 播放 MP3 檔案?

好在 ReSpeaker 上已有 madplay 這個指令,能夠直接播放 MP3:

$ madplay MP3_FILE

更讚的是只要加上 --tty-control 就能直接控制播放狀態!

$ madplay MP3_FILE --tty-control MP3_FILE

例如按下 p 就能控制音樂播放的暫停 / 繼續。很好!這樣就可以用 subprocess 模組啟動 madplay 來控制音樂播放啦!然後對這個子行程的 stdin 寫入這些控制用的鍵應該就能控制音樂播放了吧?至少我是這樣想的…

The mystery TTY

俗話說的好,代誌往往不是憨人所想的那麼簡單,試了幾回後發現竟然沒效?!一氣之下翻了翻 madplay 的原始碼,發現 player.c 裡有些跟 TTY 這玩意兒有關的程式碼:

# define TTY_DEVICE "/dev/tty"
...
tty_fd = open(TTY_DEVICE, O_RDONLY);
...
count = read(tty_fd, &key, 1);
...

玩過 Linux 的朋友們應該對 TTY 這個字有強烈的熟悉感,好像三不五時就會看到這東西出現。於是我拿他去 Google 後得到下面幾個結論:

  1. TTY 源自於 Teletype 這個單字,中文稱為電傳打字機,是古早年代用來遠距離傳遞文字資訊用的機器以及機制。
  2. 很久很久以前並沒有 PC — Personal Computer 這種東西,有的只是一台螢幕加鍵盤組成的終端機,透過串列埠等等的傳輸方式與一台中央主機溝通,進行控制與運算的工作。Unix 下的 TTY 裝置概念就是從這裡出現的,細節可參考我列在最後面的 References。
  3. TTY 裝置架構中有一層叫做 Line discipline 。這東西介於軟體層(行程接收到的資訊)和驅動層(實際上與硬體打交道那一層)間,負責對從其中一層傳遞過來的資訊做前處理,再傳到另一層。舉例像是 Line editing(Buffering、Backspace、Echoing、移動游標等…)、字元轉換( \n\r\n 互相轉換…)、控制字元轉換為信號(ASCII 0x03→SIGINT)等等的功能。
  4. /dev/tty 裝置代表的是目前行程所連接著的終端(Terminal)裝置

player.c 中的程式碼看起來,madplay 是直接從 /dev/tty 這個裝置讀取鍵盤輸入,而不是從 stdin 讀取。聽起來有點多此一舉,但這麼做有個好處:

一個行程可以在從 stdin 接收資料的同時,接收來自鍵盤的訊息。

舉例來說, cat MP3_FILE | madplay -—tty-control - 這串指令中的 madplay 會讀取 stdin,而 cat MP3_FILE 這個指令會將 MP3_FILE 這個檔案輸出到 stdout,中間我們藉由 | 來將這些資料導向至 madplay 進行播放。在這一連串事情發生的同時,使用者同樣可以用鍵盤控制播放狀態。

既然如此,那有沒有辦法控制一個行程所連接著的終端裝置呢?更重要的是,Python 做的到嗎?

Pseudo Terminal

當然可以!針對控制一個行程的終端,Python 標準函式庫提供了 pty 這個模組來處理與 Pseudo Terminal 有關的概念。那什麼是 Pseudo Terminal?

現在 PC 當道,基本上已經不存在過去那種「使用(不具運算能力的)終端連上一台電腦進行控制與運算」的情境。但是我們想把 TTY 這個概念延續到現在繼續用該怎麼辦?於是就出現了 Pseudo Terminal(注意:跟 Virtual Terminal 是不同的概念)。

關於他的定義,我們直接來看一下 pty 的 Linux man page

A pseudoterminal (sometimes abbreviated “pty”) is a pair of virtual character devices that provide a bidirectional communication channel. One end of the channel is called the master; the other end is called the slave. The slave end of the pseudoterminal provides an interface that behaves exactly like a classical terminal. A process that expects to be connected to a terminal, can open the slave end of a pseudoterminal and then be driven by a program that has opened the master end. Anything that is written on the master end is provided to the process on the slave end as though it was input typed on a terminal.

Pseudo Terminal 建立了兩個虛擬字元裝置,分別稱為 master 與 slave,提供了一個雙向溝通的管道。讀寫 slave 端的的行程可以把該 slave 裝置完全當作是一個普通的 TTY 裝置軟體層,具有終端的行為模式。而另一個行程則能對 master 端進行讀寫,把 master 端當作是 TTY 裝置的硬體層。而其中對 master 或 slave 端寫入的資訊,同樣會經過 line discipline 的處理,再進到另一端。

The TTY demystified 這篇文章中的圖來說明:

How xterm works

換句話說,就是串列埠接頭變成了一個 file descriptor 。於是呢,像 xterm 之類的終端模擬器(Terminal Emulator)就能夠以程式的方式去模擬一台古早年代終端機,將使用者使用終端機對串列埠寫入及讀取的行為模式,改為寫入及讀取這個 file descriptor ,在同一台機器上模擬終端的輸入及輸出。

大概了解了 Pseudo Terminal,接下來看看 Python 怎麼做這件事。

The pty module

一句話解釋完 pty 模組:

starting another process and being able to write to and read from its controlling terminal programmatically.

Bingo,這聽起來就是我想要的啊!其中我們會需要用到 pty.fork 這個函式:

pty.fork() :Fork 一個子行程,並讓該子行程的控制終端接上一個 Pseudo Terminal 的 slave 端。父行程會得到該 Pseudo Terminal 的 master 端,以一個 file descriptor 表示。這個函式的回傳值是個 tuple:(pid, fd),子行程得到的 pid 會是 0,而父行程會得到一個非 0 的值,為子行程的 pid。

換句話說,我們可以啟動一個子行程,並使用父行程來控制該子行程的終端裝置,也就是 /dev/tty。在實做之前,先來測試一下 pty.fork()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import pty
import time
import os
import sys


pid, fd = pty.fork()
if pid == 0:
    # Child process
    while True:
        try:
            sys.stdout.write('Hello World!\n')
            time.sleep(100)
        except KeyboardInterrupt:
            sys.stdout.write('SIGINT Received!\n')
            sys.exit(1)
else:
    print('Parent wait for 1 sec then write 0x03...')
    time.sleep(1)
    print('Parent write 0x03')
    os.write(fd, b'\x03')
    # Read until EOF or Input/Output Error
    data = b''
    while True:
        try:
            buf = os.read(fd, 1024)
        except OSError:
            break
        else:
            if buf != b'':
                data += buf
            else:
                break
    print('Parent read from pty fd: {}'.format(repr(data)))
    print('Parent wait for child process {!r} to exit...'.format(pid))
    pid, status = os.waitpid(pid, 0)
    print('Parent exit')

執行以上程式碼後應該會出現以下結果 (Ubuntu 16.04 with Python 3.5):

$ python3.5 pty_fork_test.py
Parent wait for 1 sec then write 0x03...
Parent write 0x03
Parent read from pty fd: b'Hello World!\r\n^CSIGINT Received!\r\n'
Parent wait for child process 17676 to exit...
Parent exit

這段程式碼展示了父行程如何使用 pty.fork() 回傳的 file descriptor 與子行程溝通的過程:

  1. 子行程的 stdout 連接到 slave 端,因此子行程對 stdout 寫入的內容可以被父行程透過讀取 master 端,也就是 pty.fork() 回傳的 file descriptor,來接收。因此,父行程能夠讀取到子行程對 stdout 寫入的 Hello World!\n 字串。
  2. 子行程寫入的 Hello World\n 到了父行程變成了 Hello World\r\n ,多了一個 Carriage Return \r 字元,這是 Line discipline 正在作用的結果。這證明了中間並不是只有單純的資料交換,而是 Linux 的 TTY 系統在作動中。
  3. 父行程對 file descriptor 寫入數值 0x03 後,到了子行程變成了 SIGINT 信號而被 Python 捕捉為 KeyboardInterrupt 例外,接著子行程對 stdout 寫入 SIGINT Received!\n 字串,然後被父行程讀取並顯示為 ^CSIGINT Received!\r\n 。這也證明了 Line discipline 以及 TTY 系統的作用。

以上是對 pty.fork() 做的簡單測試。接下來來實做啦!

The MP3 player powered by madplay

針對「使用 Python + madplay 控制 MP3 檔案的播放」這件事,可以這樣做:

  1. 使用 pty.fork() Fork 出一個子行程,讓該子行程使用 Python 的 os.exec* 系列函式來啟動 madplay 取代目前行程,並播放一個 MP3 檔案。
  2. 父行程利用 pty.fork() 取得的 file descriptor 來控制子行程的終端裝置,進而控制 madplay。
  3. 沒事得清清 file descriptor 的 receive buffer,避免讓子行程持續寫入而塞爆 buffer(這是我自己想的,實際上可能不用,但買個保險嘛)。
  4. 子行程的 madplay 播放完畢後必須通知父行程,這時父行程必須使用 os.waitos.waitpid 來收拾子行程,否則會產生彊屍行程。

不囉嗦,直接上 code:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import logging
import select
import signal
import pty
import os


logger = logging.getLogger(__name__)


class Error(Exception):
    """Base error"""


class ReadTimeout(Error):
    """Polling timeout"""


class PlayerState(object):
    """The state of the player"""
    PLAY = 'play'
    PAUSE = 'pause'
    STOP = 'stop'


class Mp3FilePlayer(object):

    def __init__(self, file_path):
        self.file_path = file_path
        self.player_state = PlayerState.STOP
        self.child_tty_fd = None
        self.child_pid = None
        self.poller = select.poll()

    def _start_play(self):
        """This method forks a child process and start exec 'madplay' to play
            the mp3 file. Since 'madplay' can ONLY be controlled by tty, we have
            to use pty.fork and use the return fd in the parent process (which
            connects the child's controlling terminal) to control the playback.
        """
        # Register SIGCHLD to get notified when the child process terminated
        signal.signal(signal.SIGCHLD, self._sigchld_handler)

        pid, fd = pty.fork()
        if pid == 0:
            # Child process. Exec madplay
            os.execl('/usr/bin/madplay', '--tty-control', self.file_path)
        else:
            # Parent process
            self.child_tty_fd = fd
            logger.debug('Forked child TTY fd: {}'.format(self.child_tty_fd))
            self.child_pid = pid
            logger.debug('Forked child PID: {}'.format(self.child_pid))
            self._clear_tty()

    def _read_tty(self, n, timeout=None):
        """Read the TTY fd by n bytes or raise ReadTimeout if reached specified timeout.
            The timeout value is in milliseconds.
        """
        self.poller.register(self.child_tty_fd, select.POLLIN)
        events = self.poller.poll(timeout)
        self.poller.unregister(self.child_tty_fd)  # Immediately after the polling
        if not events:
            raise ReadTimeout

        assert len(events) == 1, 'Number of polled events != 1'

        fd, event = events[0]
        if event != select.POLLIN:
            raise Error('Unexpected polled event: {}'.format(event))
        else:
            data = os.read(self.child_tty_fd, n)
            return data

    def _clear_tty(self):
        """Clearing the TTY fd. Preventing the receiving buffer to overflow."""
        while True:
            # Keep reading until timeout, which means nothing more to read.
            try:
                self._read_tty(1024, timeout=0)
            except ReadTimeout:
                return

    def _sigchld_handler(self, signum, frame):
        """Handler function of SIGCHLD"""
        logger.info('SIGCHLD signal received.')
        self.stop()

    def play(self):
        """Start the playback or resume from pausing"""
        if self.player_state == PlayerState.STOP:
            self._start_play()
            self.player_state = PlayerState.PLAY
        elif self.player_state == PlayerState.PAUSE:
            os.write(self.child_tty_fd, 'p')
            self._clear_tty()
            self.player_state = PlayerState.PLAY
        else:
            pass

    def pause(self):
        """Pause the playback"""
        if self.player_state == PlayerState.PLAY:
            os.write(self.child_tty_fd, 'p')
            self._clear_tty()
            self.player_state = PlayerState.PAUSE
        else:
            pass

    def stop(self):
        """Stop the playback. This will stop the child process."""
        if self.player_state != PlayerState.STOP:
            # Unregister the signal (set to SIG_DFL) to prevent recusively calling stop()
            signal.signal(signal.SIGCHLD, signal.SIG_DFL)

            logger.debug('Kill pid {}'.format(self.child_pid))
            os.kill(self.child_pid, signal.SIGTERM)
            logger.debug('Wait pid {}'.format(self.child_pid))
            os.waitpid(self.child_pid, 0)
            logger.debug('Child process {} died.'.format(self.child_pid))
            self.player_state = PlayerState.STOP

這段程式碼定義了類別 Mp3FilePlayer 來控制播放。以下是幾個重點:

  1. Mp3FilePlayer 定義了 playpausestop 這三個方法來控制 MP3 檔案的播放、暫停及停止。
  2. stop 方法會藉由送出 SIGTERM 信號來停掉子行程,並使用 waitpid() 來收拾善後。
  3. 使用 select.poll() ,而非直接使用 os.read() 直接讀取 file descriptor。原因是我需要對讀取這件事設定 timeout,而 os.read() 這個函式無法做到。
  4. 設定 Mp3FilePlayer._sigchld_handler 方法當 SIGCHLD 信號的處理函式,以便在 madplay 播放完 MP3 檔後,讓父行程呼叫 stop 方法來收拾子行程,避免產生彊屍行程。

Mp3FilePlayer 可以這樣使用:

>>> from mp3_player import Mp3FilePlayer
>>> p = Mp3FilePlayer('/tmp/test.mp3')
>>> p.play()
# The music should be started. The play method return immediately.
>>> p.pause()
# The music should be paused now. The pause method also return
# immediately.
>>> p.play()
# The playback should be resumed from where it was paused.
>>> p.stop()
# The music should be stopped now.
>>> p.play()
# The music should be started from the beginning.

Conclusion

經過這幾天的研究總算稍微理解了 TTY 這東西,也理解了如何使用 Python 的 pty 模組來控制其他行程的終端。希望這篇文能幫助大家🎉

Share on: TwitterFacebookEmail

comments powered by Disqus

Published

Category

Python

Tags

Contact