安卓與串口通信-實踐篇

語言: CN / TW / HK

前言

在上一篇文章中我們講解了關於串口的基礎知識,沒有看過的同學推薦先看一下,否則你可能會不太理解這篇文章所述的某些內容。

這篇文章我們將講解安卓端的串口通信實踐,即如何使用串口通信實現安卓設備與其他設備例如PLC主板之間數據交互。

需要注意的是正如上一篇文章所説的,我目前的條件只允許我使用 ESP32 開發版燒錄 Arduino 程序與安卓真機(小米10U)進行串口通信演示。

準備工作

由於我們需要使用 ESP32 燒錄 Arduino 程序演示安卓端的串口通信,所以在開始之前我們應該先把程序燒錄好。

那麼燒錄一個怎樣的程序呢?

很簡單,我這裏直接燒了一個 ESP32 使用 9600 的波特率進行串口通信,程序內容就是 ESP32 不斷的向串口發送數據 “e” ,並且監聽串口數據,如果接收到數據 “o” 則打開開發版上自帶的 LED 燈,如果接收到數據 “c” 則關閉這個 LED 燈。

代碼如下:

```arduino

define LED 12

void setup() { Serial.begin(9600); pinMode(LED, OUTPUT); }

void loop() { if (Serial.available()) { char c = Serial.read(); if (c == 'o') { digitalWrite(LED, HIGH); } if (c == 'c') { digitalWrite(LED, LOW); } }

Serial.write('e');

delay(100); } ```

上面的 12 號 Pin 是這塊開發版的 LED。

使用 Arduino自帶串口監視器測試結果:

1.gif

可以看到,確實如我們設想的通過串口不斷的發送字符 “e”,並且在接收到字符 “o” 後點亮了 LED。

安卓實現串口通信

原理概述

眾所周知,安卓其實是基於 Linux 的操作系統,所以在安卓中對於串口的處理與 Linux 一致。

在 Linux 中串口會被視為一個“設備”,並體現為 /dev/ttys 文件。

/dev/ttys 又被稱為字符終端,例如 ttys0 對應的是 DOS/Windows 系統中的 COM1 串口文件。

通常,我們可以簡單理解,如果我們插入了某個串口設備,則這個設備與 Linux 的通信會由 /dev/ttys 文件進行 “中轉”。

即,如果 Linux 想要發送數據給串口設備,則可以通過往 /dev/ttys 文件中直接寫入要發送的數據來實現,如:

echo test > /dev/ttyS1 這個命令會將 “test” 這串字符發送給串口設備。

如果想讀取串口發送的數據也是一樣的,可以通過讀取 /dev/ttys 文件內容實現。

所以,如果我們在安卓中想要實現串口通信,大概率也會想到直接讀取/寫入這個特殊文件。

android-serialport-api

在上文中我們説到,在安卓中也可以通過與 Linux 一樣的方式--直接讀寫 /dev/ttys 實現串口通信。

但是其實並不需要我們自己去處理讀寫和數據的解析,因為谷歌官方給出了一個解決方案:android-serialport-api

為了便於理解,我們會大致説一下這個解決方案的源碼,但是就不上示例了,至於為什麼,同學們往下看就知道了。另外,雖然這個方案歷史比較悠久,也很長時間沒有人維護了,但是並不意味着不能使用了,只是使用條件比較苛刻,當然,我司目前使用的還是這套方案(哈哈哈哈)。

不過這裏我們不直接看 android-serialport-api 的源碼,而是通過其他大佬二次封裝的庫來看: Android-SerialPort-API

在這個庫中,通過

```java // 默認直接初始化,使用8N1(8數據位、無校驗位、1停止位),path為串口路徑(如 /dev/ttys1),baudrate 為波特率 SerialPort serialPort = new SerialPort(path, baudrate);

// 使用可選參數配置初始化,可配置數據位、校驗位、停止位 - 7E2(7數據位、偶校驗、2停止位) SerialPort serialPort = SerialPort .newBuilder(path, baudrate) // 校驗位;0:無校驗位(NONE,默認);1:奇校驗位(ODD);2:偶校驗位(EVEN) // .parity(2) // 數據位,默認8;可選值為5~8 // .dataBits(7) // 停止位,默認1;1:1位停止位;2:2位停止位 // .stopBits(2) .build(); ```

初始化串口,然後通過:

java InputStream in = serialPort.getInputStream(); OutputStream out = serialPort.getOutputStream();

獲取到輸入/輸出流,通過讀取/寫入這兩個流來實現與串口設備的數據通信。

我們首先來看看初始化串口是怎麼做的。

2.png

首先檢查了當前是否具有串口文件的讀寫權限,如果沒有則通過 shell 命令更改權限為 666 ,更改後再次檢查是否有權限,如果還是沒有就拋出異常。

注意這裏的執行 shell 時使用的 runtime 是 Runtime.getRuntime().exec(sSuPath); 也就是説,它是通過 root 權限來執行這段命令的!

換句話説,如果想要通過這種方式實現串口通信,必須要有 ROOT 權限!這就是我説我不會給出示例的原因,因為我手頭的設備無法 ROOT 啊。至於為啥我司還能繼續使用這種方案的原因也很簡單,因為我們工控機的安卓設備都是定製版的啊,擁有 ROOT 權限不是基本操作?

確定權限可用後通過 open 方法拿到一個類型為 FileDescriptor 的變量 mFd ,最後通過這個 mFd 拿到輸入輸出流。

所以核心在於 open 方法,而 open 方法是一個 native 方法,即 C 代碼:

java private native FileDescriptor open(String absolutePath, int baudrate, int dataBits, int parity, int stopBits, int flags);

C 的源碼這裏就不放了,只需要知道它做的工作就是打開了 /dev/ttys 文件(準確的説是“終端”),然後通過傳遞進去的這些參數去按串口規則解析數據,最後返回一個 java 的 FileDescriptor 對象。

在 java 中我們再通過這個 FileDescriptor 對象可以拿到輸入/輸出流。

原理説起來是十分的簡單。

看完通信部分的原理後,我們再來看看我們如何查找可用的串口呢?

其實和 Linux 上也一樣:

```java public Vector getDevices() { if (mDevices == null) { mDevices = new Vector(); File dev = new File("/dev");

    File[] files = dev.listFiles();

    if (files != null) {
        int i;
        for (i = 0; i < files.length; i++) {
            if (files[i].getAbsolutePath().startsWith(mDeviceRoot)) {
                Log.d(TAG, "Found new device: " + files[i]);
                mDevices.add(files[i]);
            }
        }
    }
}
return mDevices;

} ```

也是通過直接遍歷 /dev 下的文件,只不過這裏做了一些額外的過濾。

或者也可以通過讀取 /proc/tty/drivers 配置文件後過濾:

java Vector<Driver> getDrivers() throws IOException { if (mDrivers == null) { mDrivers = new Vector<Driver>(); LineNumberReader r = new LineNumberReader(new FileReader("/proc/tty/drivers")); String l; while ((l = r.readLine()) != null) { // Issue 3: // Since driver name may contain spaces, we do not extract driver name with split() String drivername = l.substring(0, 0x15).trim(); String[] w = l.split(" +"); if ((w.length >= 5) && (w[w.length - 1].equals("serial"))) { Log.d(TAG, "Found new driver " + drivername + " on " + w[w.length - 4]); mDrivers.add(new Driver(drivername, w[w.length - 4])); } } r.close(); } return mDrivers; }

關於讀取可用串口設備,其實從這裏的路徑也可以看出,都是系統路徑,也就是説,如果沒有權限,大概率也是讀取不到東西的。

這就是使用與 Linux 一樣的方式去讀取串口數據的基本原理,那麼問題來了,既然我説這個方法使用條件比較苛刻,那麼更易用的替代方案是什麼呢?

我們下面就會介紹,那就是使用安卓的 USB host (USB主機)的功能。

USB host

Android 3.1(API 級別 12)或更高版本的平台直接支持 USB 配件和主機模式。USB 配件模式還作為插件庫向後移植到 Android 2.3.4(API 級別 10)中,以支持更廣泛的設備。設備製造商可以選擇是否在設備的系統映像中添加該插件庫。

在安卓 3.1 版本開始,支持將USB作為主機模式(USB host)使用,而我們如果想要通過 USB 讀取串口數據則需要依賴於這個主機模式。

在正式開始介紹USB主機模式前,我們先簡要介紹一下安卓上支持的USB模式。

安卓上的USB支持三種模式:設備模式、主機模式、配件模式。

設備模式即我們常用的直接將安卓設備連接至電腦上,此時電腦上顯示為 USB 外設,即可以當成 “U盤” 使用拷貝數據,不過現在安卓普遍還支持 MTP模式(作為攝像頭)、文件傳輸模式(即當U盤用)、網卡模式等。

主機模式即將我們的安卓設備作為主機,連接其他外設,此時安卓設備就相當於上面設備模式中的電腦。此時安卓設備可以連接鍵盤、鼠標、U盤以及嵌入式應用USB轉串口、轉I2C等設備。但是如果想要將安卓設備作為主機模式可能需要一條支持 OTG 的數據線或轉接頭。(Micro-USB 或 USB type-c 轉 USB-A 口)

而在 USB 配件模式下,外部 USB 硬件充當 USB 主機。配件示例可能包括機器人控制器、擴展塢、診斷和音樂設備、自助服務終端、讀卡器等等。這樣,不具備主機功能的 Android 設備就能夠與 USB 硬件互動。Android USB 配件必須設計為與 Android 設備兼容,並且必須遵守 Android 配件通信協議。

設備模式與配件模式的區別在於在配件模式下,除了 adb 之外,主機還可以看到其他 USB 功能。

usb-host-accessory.png

使用USB主機模式與外設交互數據

在介紹完安卓中的三種USB模式後,下面我們開始介紹如何使用USB主機模式。當然,這裏只是大概介紹原生APi的使用方法,我們在實際使用中一般都都是直接使用大佬編寫的第三方庫。

準備工作

在開始正式使用USB主機模式時我們需要先做一些準備工作。

首先我們需要在清單文件(AndroidManifest.xml)中添加:

```xml

```

一個完整的清單文件示例如下:

xml <manifest ...> <uses-feature android:name="android.hardware.usb.host" /> <uses-sdk android:minSdkVersion="12" /> ... <application> <activity ...> ... <intent-filter> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> </intent-filter> </activity> </application> </manifest>

聲明好清單文件後,我們就可以查找當前可用的設備信息了:

kotlin private fun scanDevice(context: Context) { val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager val deviceList: HashMap<String, UsbDevice> = manager.deviceList Log.i(TAG, "scanDevice: $deviceList") }

將 ESP32 開發版插上手機,運行程序,輸出如下:

3.png

可以看到,正確的查找到了我們的 ESP32 開發版。

這裏提一下,因為我們的手機只有一個 USB 口,此時已經插上了 ESP32 開發版,所以無法再通過數據線直接連接電腦的 ADB 了,此時我們需要使用無線 ADB,具體怎麼使用無線 ADB,請自行搜索。

另外,如果我們想要通過查找到設備後請求連接的方式連接到串口設備的話,還需要額外申請權限。(同理,如果我們直接在清單文件中提前聲明需要連接的設備則不需要額外申請權限,具體可以看看參考資料5,這裏不再贅述)

首先聲明一個廣播接收器,用於接收授權結果:

```kotlin private lateinit var permissionIntent: PendingIntent

private const val ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION"

private val usbReceiver = object : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
    if (ACTION_USB_PERMISSION == intent.action) {
        synchronized(this) {
            val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)

            if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
                device?.apply {
                    // 已授權,可以在這裏開始請求連接
                    connectDevice(context, device)
                }
            } else {
                Log.d(TAG, "permission denied for device $device")
            }
        }
    }
}

} ```

聲明好之後在 Acticity 的 OnCreate 中註冊這個廣播接收器:

kotlin permissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), FLAG_MUTABLE) val filter = IntentFilter(ACTION_USB_PERMISSION) registerReceiver(usbReceiver, filter)

最後,在查找到設備後,調用 manager.requestPermission(deviceList.values.first(), permissionIntent) 彈出對話框申請權限。

連接到設備並收發數據

完成上述的準備工作後,我們終於可以連接搜索到的設備並進行數據交互了:

```kotlin private fun connectDevice(context: Context, device: UsbDevice) { val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager

CoroutineScope(Dispatchers.IO).launch {
    device.getInterface(0).also { intf ->
        intf.getEndpoint(0).also { endpoint ->
            usbManager.openDevice(device)?.apply {
                claimInterface(intf, forceClaim)
                while (true) {
                    val validLength = bulkTransfer(endpoint, bytes, bytes.size, TIMEOUT)
                    if (validLength > 0) {
                        val result = bytes.copyOfRange(0, validLength)
                        Log.i(TAG, "connectDevice: length = $validLength")
                        Log.i(TAG, "connectDevice: byte = ${result.contentToString()}")
                    }
                    else {
                        Log.i(TAG, "connectDevice: Not recv data!")
                    }
                }
            }
        }
    }
}

} ```

在上面的代碼中,我們使用 usbManager.openDevice 打開了指定的設備,即連接到設備。

然後通過 bulkTransfer 接收數據,它會將接收到的數據寫入緩衝數組 bytes 中,並返回成功接收到的數據長度。

運行程序,連接設備,日誌打印如下:

4.png

可以看到,輸出的數據並不是我們預料中的數據。

這是因為這是非常原始的數據,如果我們想要讀取數據,還需要針對不同的串口轉USB芯片或協議編寫驅動程序才能獲取到正確的數據。

順道一提,如果想要將數據寫入串口數據的話可以使用 controlTransfer()

所以,我們在實際生產環境中使用的都是基於此封裝好的第三方庫。

這裏推薦使用 usb-serial-for-android

usb-serial-for-android

使用這個庫的第一步當然是導入依賴:

groovy // 添加倉庫 allprojects { repositories { ... maven { url 'http://jitpack.io' } } } // 添加依賴 dependencies { implementation 'com.github.mik3y:usb-serial-for-android:3.4.6' }

添加完依賴同樣需要在清單文件中添加相應字段以及處理權限,因為和上述使用原生API一致,所以這裏不再贅述。

和原生 API 不同的是,因為我們此時已經知道了我們的 ESP32 主板的設備信息,以及使用的驅動(CDC),所以我們就不使用原生的查找可用設備的方法了,我們這裏直接指定我們已知的這個設備(當然,你也可以繼續使用原生API的查找和連接方法):

```kotlin private fun scanDevice(context: Context) { val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager

val customTable = ProbeTable()
// 添加我們的設備信息,三個參數分別為 vendroId、productId、驅動程序
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)

val prober = UsbSerialProber(customTable)
// 查找指定的設備是否存在
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)

if (drivers.isNotEmpty()) {
    val driver = drivers[0]
    // 這個設備存在,連接到這個設備
    val connection = manager.openDevice(driver.device)
}
else {
    Log.i(TAG, "scanDevice: 無設備!")
}

} ```

連接到設備後,下一步就是和數據交互,這裏封裝的十分方便,只需要獲取到 UsbSerialPort 後,直接調用它的 read()write() 即可讀寫數據:

```kotlin port = driver.ports[0] // 大多數設備都只有一個 port,所以大多數情況下直接取第一個就行

port.open(connection) // 設置連接參數,波特率9600,以及 “8N1” port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)

// 讀取數據 val responseBuffer = ByteArray(1024) port.read(responseBuffer, 0)

// 寫入數據 val sendData = byteArrayOf(0x6F) port.write(sendData, 0) ```

此時,一個完整的,用於測試我們上述 ESP32 程序的代碼如下:

```kotlin @Composable fun SerialScreen() { val context = LocalContext.current

Column(
    Modifier.fillMaxSize(),
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally
) {
    Button(onClick = { scanDevice(context) }) {
        Text(text = "查找並連接設備")
    }

    Button(onClick = { switchLight(true) }) {
        Text(text = "開燈")
    }
    Button(onClick = { switchLight(false) }) {
        Text(text = "關燈")
    }

}

}

private fun scanDevice(context: Context) { val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager

val customTable = ProbeTable()
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)

val prober = UsbSerialProber(customTable)
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)

if (drivers.isNotEmpty()) {
    val driver = drivers[0]

    val connection = manager.openDevice(driver.device)
    if (connection == null) {
        Log.i(TAG, "scanDevice: 連接失敗")
        return
    }

    port = driver.ports[0]

    port.open(connection)
    port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)

    Log.i(TAG, "scanDevice: Connect success!")

    CoroutineScope(Dispatchers.IO).launch {
        while (true) {
            val responseBuffer = ByteArray(1024)

            val len = port.read(responseBuffer, 0)

            Log.i(TAG, "scanDevice: recv data = ${responseBuffer.copyOfRange(0, len).contentToString()}")
        }
    }
}
else {
    Log.i(TAG, "scanDevice: 無設備!")
}

}

private fun switchLight(isON: Boolean) { val sendData = if (isON) byteArrayOf(0x6F) else byteArrayOf(0x63)

port.write(sendData, 0)

} ```

運行這個程序,並且連接設備,輸出如下:

5.png

可以看到輸出的是 byte 的 101,轉換為 ASCII 即為 “e”。

然後我們點擊 “開燈”、“關燈” 效果如下:

6.gif

對了,這裏發送的數據 “0x6F” 即 ASCII “o” 的十六進制,同理,“0x63” 即 “c”。

可以看到,可以完美的和我們的 ESP32 開發版進行通信。

實例

無論使用什麼方式與串口通信,我們在安卓APP的代碼層面能夠拿到的數據已經是處理好了的數據。

即,在上一篇文章中我們説過串口通信的一幀數據包括起始位、數據位、校驗位、停止位。但是我們在安卓中使用時一般拿到的都只有 數據位 的數據,其他數據已經在底層被解析好了,無需我們去關心怎麼解析,或者使用。

我們可以直接拿到的就是可用數據。

這裏舉一個我之前用過的某型號驅動版的例子。

這塊驅動版關於通信的信息如圖:

7.png

可以看到,它採用了 RS485 的通信方式,波特率支持 9600 或 38400,8位數據位,無校驗,1位停止位。

並且,它還規定了一個數據協議。

在它定義的協議中,第一位為地址;第二位為指令;第三位到第N位為數據內容;最後兩位為CRC校驗。

需要注意的是,這裏定義的協議是基於串口通信的,不要把這個協議和串口通信搞混了,簡單來説就是在串口通信協議的數據位中又定義了一個自己的協議。

而且可以看到,雖然定義串口參數時沒有指定校驗,但是在它自己的協議中指定了使用 CRC 校驗。

另外,弱弱的吐槽一句,這個驅動版的協議真的不好使。

在實際使用過程中,主機與驅動版的通信數據無法保證一定會在同一個數據幀中發送完成,所以可能會造成“粘包”、“分包”現象,也就是説,數據可能會分幾次發過來,而且你不好判斷這數據是上次沒發送完的數據還是新的數據。

我使用過的另外一款驅動版就方便的多,因為它會在幀頭加上開始符號和數據長度,幀尾加上結束符號。

這樣一來,即使出現“粘包”、“分包”我們也能很好的給它解析出來。

當然,它這樣設計協議肯定是有它的道理的,無非就是減少通信代價之類的。

我還遇到過一款十分簡潔的驅動版,直接發送一個整數過去表示執行對應的指令。

驅動版回傳的數據同樣非常簡單,就是一個數字,然後事先約定各個數字表示什麼意思……

説歸説,我們還是繼續來看這款驅動版的通信協議:

8.png

這是它的其中一個指令內容,我們發送指令 “1” 過去後,它會返回當前驅動版的型號和版本信息給我們。

因為我們的主板是定製工控主板,所以使用的通信方式是直接用 android-serialport-api。

最終發送與接收回復也很簡單:

```kotlin /* * 將十六進制字符串轉成 ByteArray * / private fun hexStrToBytes(hexString: String): ByteArray { check(hexString.length % 2 == 0) { return ByteArray(0) }

return hexString.chunked(2)
    .map { it.toInt(16).toByte() }
    .toByteArray()

}

private fun isReceivedLegalData(receiveBuffer: ByteArray): Boolean {

val rcvData = receiveBuffer.copyOf()  //重新拷貝一個使用,避免原數據被清零

if (cmd.cmdId.checkDataFormat(rcvData)) {  //檢查回覆數據格式
    isPkgLost = false
    if (cmd.cmdId.isResponseBelong(rcvData)) {  //檢查回覆命令來源
        if (!AdhShareData.instance.getIsUsingCrc()) {  //如果不開啟CRC檢驗則直接返回 true
            resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
            coroutineScope.launch(Dispatchers.Main) {
                cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
            }
            return true
        }

        if (cmd.cmdId.checkCrc(rcvData)) {  //檢驗CRC
             resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
            coroutineScope.launch(Dispatchers.Main) {
                cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
            }

            return true
        }
        else {
            coroutineScope.launch(Dispatchers.Main) {
                cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseCrcError, ByteArray(0), -1, -1, cmd.cmdId)
            }

            return false
        }
    }
    else {
        coroutineScope.launch(Dispatchers.Main) {
            cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseNotFromThisCmd, ByteArray(0), -1, -1, cmd.cmdId)
        }

        return false
    }
}
else {  //數據不符合,可能是遇到了分包,繼續等待下一個數據,然後合併
    isPkgLost = true
    return isReceivedLegalData(cmd)
    /*coroutineScope.launch(Dispatchers.Main) {
        cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseWrongFormat, ByteArray(0), -1, -1, cmd.cmdId)
    }

    return false  */
}

}

// ……省略初始化和連接代碼

// 發送數據 val bytes = hexStrToBytes("0201C110") outputStream.write(bytes, 0, bytes.size)

// 解析數據 val recvBuffer = ByteArray(0) inputStream.read(recvBuffer)

while (receiveBuffer.isEmpty()) { delay(10) }

isReceivedLegalData() ```

本來打算直接發我封裝好的這個驅動版的協議庫的,想了想,好像不太合適,所以就大概抽出了這些不完整的代碼,懂這個意思就行了,哈哈。

總結

從上面介紹的兩種方式可以看出,兩種方式使用各有優缺點。

使用 android-serialport-api 可以直接讀取串口數據內容,不需要轉USB接口,不需要驅動支持,但是需要 ROOT,適合於定製安卓主板上已經預留了 RS232 或 RS485 接口且設備已 ROOT 的情況下使用。

而使用 USB host ,可以直接讀取USB接口轉接的串口數據,不需要ROOT,但是隻支持有驅動的串口轉USB芯片,且只支持使用USB接口,不支持直接連接串口設備。

各位可以根據自己的實際情況靈活選擇使用什麼方式來實現串口通信。

當然,除了現在介紹的這些串口通信,其實還有一個通信協議在實際使用中用的非常多,那就是 MODBUS 協議。

下一篇文章,我們將介紹 MODBUS。

參考資料

  1. android-serialport-api
  2. What is tty?
  3. Text-Terminal-HOWTO
  4. Terminal Special Files
  5. USB host
  6. Android開啟OTG功能/USB Host API功能

本文正在參加「金石計劃 . 瓜分6萬現金大獎」