yinghuaxia
ADB 擁有兩項特點,第一個是它的傳輸功能,可以選擇使用 TCP 亦或是 USB 作為傳輸媒介 ; 第二是健全的功能設計,例如 adb shell 可以對目標裝置的 shell 下達命令,adb push/pull 可以雙向傳輸檔案。
ADB 可以使用 TCP 或是 USB 作為傳輸媒介,是因為 TCP 和 USB 之間有什麼共通點嗎?
TCP/IP 是網路架構之一同時也是電腦之間的通訊方式,而 USB 是實體媒介,實際傳輸資訊需要靠 USB Host Driver 以及 USB Gadget Driver 去設定行為。
adbd 的全名為 android debug bridge deamon ,是一支在目標裝置背景執行的程式。不過在 Milk-v Duo 上執行前會遇到一個問題,也就是 Milk-v Duo 的大核 C906B 是 RISC-V 64 位元的架構,因此若要獲得 adbd 執行檔就必須先經過交叉編譯。
請問交叉編譯的概念是什麼?是因為架構不同所以才需要使用交叉編譯嗎?交叉編譯與一般的編譯差異為何?
編譯這項行為與平台相依,會因為執行檔與自身平台而有不同的編譯環境(包含 編譯器、連結器、標頭檔、靜態函式庫等)。而交叉編譯是編譯的一種,不過是指編譯出的執行檔所運行的平台與執行編譯的電腦平台相異的情形。而需要交叉編譯的原因以本專題來說是因為 Milk-v duo 的運算資源有限。
hugo0406
接著介紹 ADB 的運作機制,這也與實作內容相關。 ADB 共分成 3 個部分:在主機端的 adb 和 adb server 以及運行在目標裝置的 adbd 。其中 adb 負責擔當與使用者互動的 end point ,可以在參數列調整命令內容, adb server 則是擔當 adb 與 adbd 之間的溝通程式, adbd 則會呼叫副程式將命令下達到 shell ,再將副程式之結果回傳到 adb server 。
文章中提到 adb server 是 adb 與 adbd 之間溝通的橋樑,是否能詳細說明一下是如何進行溝通的呢?以及溝通的主要內容有哪些?
其中, adb server 與 adb 之間採用的是 TCP socket 的方式進行 IPC ,而 adbd 與 adb server 之間可以選擇採用 USB 或是 TCP socket 的方式溝通,本專題使用 USB 方式。至於詳細的溝通內容,我以 adb shell ls
這個命令為例,首先 adb 與 adb server 會先進行交握,包括確認 adb 版本以及目標裝置、虛擬機器,再來將 ls
這個命令從 adb 經由 adb server 送到 adbd ,而 adbd 經由子行程執行 shell 得到的結果會再經過 adb server 回傳到 adb 當中輸出。
本專題嘗試在物美價廉的 Milk-V Duo 硬體上面,利用其異質多核的 RISC-V,運作 Linux 核心和 ThreadX,並利用 OpenAMP 規範的 RPMsg,建立異質多核間的通訊。
針對 Milk-V Duo (及其 256M 的硬體型號)
移植 Eclipse ThreadX RTOS 到 RISC-V64 架構,驗證平台為 Milk-V Duo,ThreadX 執行無 MMU 的小核 C906L,Linux 運行於大核 C906B。
避免非必要的 backtick,後者用於程式碼、特別的數值,或有工程意義的位元組序列。反之,保持行文的流暢。
了解,下次會慎用 backtick
在 Milk-v Duo 當中,小核 C906L 預設運行的作業系統為 FreeRTOS , 但是基於我們隨後要加上的 RPMsg-lite 以及基於兩個核通訊而誕生的應用程式,在 Milk-v Duo 只有 64MB 的主記憶體之情況下,我們必須使用佔據記憶體空間較少並且利於通訊的作業系統,而這就是我們採用 Eclipse Threadx RTOS 作為我們小核作業系統的原因。
在實作方面,我們只須將 ThreadX-to-RISC-V64 這個 repository 使用命令 git clone 到我們 duo-buildroot-sdk 當中即可 , 接著是改動 build 目錄底下的 milkvsetup.sh 檔案 ,這個是 SDK 在建置環境時會使用到的腳本,其中 bash 變數 FREERTOS_PATH
要將其改為 SDK 當中 threadx 專案目錄的位置。
這樣的改動會讓最後映像檔包含的二進位檔從 FreeRTOS 轉變為 ThreadX ,至於二進位檔的生成, ThreadX-to-RISC-V64 專案採用的是 CMake 的方式, CMake 可以在 CMakeLists.txt 檔案透過簡單的指令引入 Source file 、 Header file 、 Static library 以及 Flag 、 Options 。 生成 Makefile 並完成編譯。
接著是驗證 threadx 是否成功運行,在 SDK 中的 threadx 專案中有幾個任務會並行執行,分別是 thread_0
、 thread_1
。 它們的內容分別是每 4 、 8 秒進行一次計數,並印出計數的內容,但是一般的連線方式如 ssh 亦或是本章的 ADB 都無法直接看到 2 個 RISC-V 核所印出來的資訊,所以我們改用 neocon 這項 utility software 觀察 Milk-v Duo 運行時印出的資訊。
neocon 是一個好用的 serial console utility ,它會嘗試不斷開啟 tty device 直到連線成功,並且建立連線來傳輸 terminal 的輸入及輸出。 我們也就用這個方法來觀察 tty device 接收到的字元訊息,其中也包含了 threadx 在小核上列印的字元輸出結果。
以下為部份輸出:
(開機時)
(執行約 1200 秒)
其中經由計數來觀察執行緒的執行狀況,會發現兩者皆呈現從第一次列印之後經過了 1248
秒。
因此 ThreadX 的運行在 Milk-v Duo 上算是穩定。
用語要精準,什麼「穩定」?
Linux 運行於大核 C906B,在其上移植 (Android) ADB,確保 USB gadget driver 運作符合預期,並在 Microsoft Windows 和 GNU/Linux 上驗證
改進漢語描述
ADB 的全名為 Android Debug Bridge,原先是針對 android 作業系統為目標裝置設計的基礎建設,但是其功能便利因此以 Linux 為核心的嵌入式裝置也會需要它的協助。 ADB 擁有兩項特點,第一個是它的傳輸功能,可以選擇使用 TCP 亦或是 USB 作為傳輸媒介 ; 第二是健全的功能設計,例如 adb shell
可以對目標裝置的 shell 下達命令,adb push/pull
可以雙向傳輸檔案。
接著介紹 ADB 的運作機制,這也與實作內容相關。 ADB 共分成 3 個部分:在主機端的 adb 和 adb server 以及運行在目標裝置的 adbd 。其中 adb 負責擔當與使用者互動的 end point ,可以在參數列調整命令內容, adb server 則是擔當 adb 與 adbd 之間的溝通程式, adbd 則會呼叫副程式將命令下達到 shell ,再將副程式之結果回傳到 adb server 。
adbd 的全名為 android debug bridge deamon ,是一支在目標裝置背景執行的程式。不過在 Milk-v Duo 上執行前會遇到一個問題,也就是 Milk-v Duo 的大核 C906B 是 RISC-V 64 位元的架構,因此若要獲得 adbd 執行檔就必須先經過交叉編譯。
首先我採用的是 riscv-gnu-toolchain 這個工具鏈,並且也成功編譯出了執行檔,但是交叉編譯出來的執行檔卻無法在大核 C906B 上執行。後來歸納出原因在於沒有使用 Milk-v Duo 所提供的 toolchain ,因為這個工具鏈當中有包含選項 (option) 專門用於編譯 C906B 的執行檔,然而 riscv-gnu-toolchain 卻沒有支援這個編譯選項。
編譯及連結階段所使用的選項:
因此我後來採用 Milk-v Duo 所提供的工具鏈 riscv64-linux-musl-x86_64 來進行交叉編譯,與第一次編譯較為不同的地方在於,因為採用的是 musl (讀作: muscle) 而非 GNU 的工具鏈,所以有一些函式庫內容並沒有支援,其中 musl libc 同 glibc 也是標準 C 函式庫的實作,但它更注重於執行檔的大小、靜態連結鏈結 的支援 以及 Race condition 等並行程式問題的排除。
強調相互結合的意境時,譯作「連結」,例如「動態連結函式庫」(dynamic-link library),而強調資料因結合而呈現鏈狀樣貌,用「鏈結」,如鏈結串列 (linked list)。本例要說「連結」
參見 https://hackmd.io/@l10n-tw/glossaries
感謝糾正 !
例如我在編譯過程中遇到 adbd 專案中有使用 GNU extension 的程式片段,後來經過老師提醒發現可以在編譯選項加上 -std = gnu++2a
,這樣編譯器可以認得 GNU dialect of -std=c++ 20 規範的 C++ 語言。 詳見 GCC manual 。再來還有 musl libc 未支援的 C 標準函式庫函式,也在 adbd 的編譯過程中遇到。而解決辦法是聽照老師從 newlib/glibc 中找到該方法的實作,並將其加入到專案當中,解決這項難題。
接著還面臨與 libc 無關的 OpenSSL 函式庫使用,也當然 musl libc 沒有支援這項函式庫。因此我後來在 OpenSSL 官方網站找到了 Git repository 並先對該函式庫進行交叉編譯。原先以為將標頭檔及相關的 C 程式實作放入對應的目錄即可,但是到了連結階段發現缺乏對應的 lib , 例如 libcrypto
、 libssl
,原來這些 lib 也是出自 OpenSSL 並且就是最後編譯出來的靜態函式庫 (static library),附檔名為 .a
並由 linker 與其他目標檔案組成執行檔。
再來還使用到了 GLib 函式庫, Glib 結合了多項平台底層函式的實作,因此 Glib 可以被不同的作業系統引用並編譯,但是在編譯函式庫的過程中遇到了不少問題,因此後來決定將使用到 Glib 函式庫的部份改為 Linux 標準 C 函式庫的方法實作。在此除了需要查詢 GLib manual 關於函式的用途,也需要使用到 man
命令查詢系統的標準 C 函式庫實作。
不過之所以改為 Linux 所支援的 C 實作,原因是在下達以下命令後,發現僅有一個檔案使用到這個關鍵的 Glib 標頭檔。關於 grep
的介紹,在 man grep
的手冊資訊當中可以得知 -r
是遞迴找對應目錄中所有檔案的內容,而 -v
則是排除在該目錄搜尋到的結果(以此例來說 sysroot 的交叉編譯工具鏈的環境目錄,與 adbd 無關)。
最後得到了交叉編譯得到的 adbd 執行檔,但是與第一次結果不同,這個是可以在 Milk-v Duo 上執行的檔案,並且透過 strace
命令可以看到這個執行檔所用到的系統呼叫,以下為 adbd 所用到的系統呼叫部份展示:
這個是 USB Peripheral 專用的 API ,如果是主機則是使用 USB Host API , USB 使用類似網路堆疊架構的方式傳輸資料,因此也會有實體層、資料連結層 及 網路層等等。至於 USB 所採用的是拓樸階梯型星狀拓樸 (tiered-star topologies) ,以 USB root-hub 為中心向外連結 hub 或是 USB Peripheral 。
上圖中的 Func 可以視為 USB 週邊裝置,而我們現在要觀察的是下圖的 USB 的網路堆疊,可以視為 OSI 網路堆疊的 Layer 1
~ Layer 3
。其中可以看到 Layer 1
是 USB 的實際硬體裝置,也就是我們的 USB 匯流排介面 ; Layer 2
則是會因為主機端跟裝置端有所不同,主機端位在這層的是 Host Controller Driver 而裝置端則是 USB Controller Driver 在運行,邏輯上連結它們的是 USB core 以及 Endpoint 0 ,其中兩者間可以互相傳輸封包資訊讓 USB core 對裝置進行設定 ; Layer 3
則是主機端的應用程式以及裝置端的 Gadget Driver ,這兩邊邏輯上是讓應用程式使用裝置端正常運行的 function 。
以我們 ADB 的例子來說, adb server
就是我們主機端的 Client SW , 而 functionfs
則是裝置端的 Function , 而 Pipe Bundle 則是維持 Endpoints 與應用程式的資訊傳輸。
在開始介紹實作前,先把一些重要的概念補充完。首先是 Gadget Driver ,這個是專門設定 USB 屬性以及運行方法的 driver , 這裡的方法指的是 Linux 核心已經內建好提供給 USB 裝置使用的功能,例如 Mass_Storage , RNDIS , FunctionFs 。
其中 functionfs 這項功能的目的為,將原先位在核心空間的 Endpoints 轉移到使用者空間使用,如此一來使用者空間的程式也可以透過 Endpoints 接收或傳輸資料。
最後是 Configfs , 它可以在使用者空間使用命令的方式告訴核心要新增、刪除、更動的核心物件,我們實作時的 gadget driver 就是透過 Configfs 來新增並改動設定並配置 functionfs 方法。
在原先 Milk-v Duo SDK 所建置產生的環境中, Linux 會在初始化時使用到與 Configfs 相關的腳本來配置 Gadget Driver , 但是配置的方法是 RNDIS , 因此我所要做的任務即是將方法改為 functionfs 以及將剛才交叉編譯完成的 adbd 放到 /bin
目錄並改寫腳本讓其運行。 (以下為型號 256m
的示範,僅有與型號相關的目錄及檔案有差異)
.config
資訊的檔案,在預設當中 Linux 使用者環境無法使用 Configfs 配置 functionfs 方法,因此我們必須增加以下配置。確認異質的二個 RISC-V 處理器核可藉由 mailbox 搭配 RPMsg-Lite,進行通訊
RPMsg 是基於 AMP (Asymmetric Multi-Processing) 建立的通訊框架,與 OSI 七層網路模型相符,在 RPMsg 之下還有實體層的 IPC 機制例如 Mailbox 、 Shared Memory 以及資料連結層的 Virtio 機制,最後是對應到傳輸層的 RPMsg Layer。
Mailbox 是讓多個核可以互相觸發中斷的硬體元件,以 Milk-V 來說可以讓 2 個 RISC-V 的核互相觸發中斷。一般的 Mailbox 會搭配 mailbox channel ops 來設計驅動程式,但是 Cvitek 則是簡化設計成一個字元驅動程式。
Milk-v Duo 核與週邊裝置互動的方式是採取 Memory-mapped I/O ,而 Mailbox 所使用的裝置也是如此,在 datasheet 中可以看到記憶體位址 0x01900000
~ 0x01900FFF
是提供給 Mailbox 裝置的暫存器使用。
其中最常使用到單元有兩個,分別是 mbox_reg
暫存器以及 mailbox_context
buffer ,第一個 mbox_reg
可以透過設定對應的控制暫存器,來達到對應的硬體操作,例如觸發中斷。而 mailbox_context
則是用來存放傳遞資料的區域,例如命令以及變數的參數。
mailbox_test.c
這個是使用者層級的程式,它透過 ioctl 的方法對核心模組進行讀寫,這支程式會寫入一個給 mailbox 傳遞的結構變數 cmdqu_t
,並且在 ioctl 系統呼叫之後,將結構變數的成員放到對應的 Mailbox buffer 當中。
rtos_cmdqu.c
這個是 Milk-V 提供的 Mialbox 驅動程式,而這個裝置也有註冊 IRQ 所對應到的 ISR ,中斷服務常式的 top half 是讀取緩衝區內容以及清除中斷 , bottom half 是喚醒在 wait_queue 的行程,讓行程將得到的結構體傳回使用者層級。
Interrupt Service Routine
裝置暫存器欄位,解除暫存器中斷。
將 buffer 欄位清空,暫存到令一個結構變數中。
Mailbox Send
裝置暫存器欄位,觸發中斷。
將 buffer 欄位,改為我們需要的結構變數。
首先 RPMsg 這個框架並沒有對應到實際的硬體元件 (只有參照其他硬體元件),因此無法像其他裝置被特定的 bus 探測並初始化,因此我們這裡採用 Linux 提供的 Platform 機制讓 RPMsg 框架也可以參照到裝置樹當中的其他硬體元件,例如共享記憶體以及 mailbox 。
cvi_rpmsg.c
這個是參考 linux-fslc 這個由 Freescale 公司 (現為 NXP) 所維護的 Linux 分支所實作的 imx_rpmsg.c ,它本質上是一個 platform driver ,除了擁有初始化 RPMsg 框架要使用的硬體元件之函式外,它還會註冊 virtio device 讓 virtio 的功能得以啟用。
原先的 imx_rpmsg.c 會取得裝置樹當中關於 MU 相關暫存器以及緩衝區位置、時脈週期、中斷請求號碼等資訊,而這裡則改成只需要知道 vring 記憶體位址區間 、 mailbox 的中斷請求號碼,關於 vring 的部份會在下一章作介紹。
中斷服務常式註冊,需要裝置樹中的中斷請求號碼
共享記憶體初始化,存放 vring 相關資料。
這個機制的目的是讓 Host 端可以與不同性質的裝置傳輸訊息,以我們這次專題而言這個裝置就是遠端運行 ThreadX 的微處理器。這裡會逐一介紹 virtio 所使用的資料結構,以及它搭配了哪一些函式來傳輸以及接收訊息。
struct vring_virtqueue
這個資料結構將會包含接下來提到的 vring 以及 virtqueue ,可以把它想像成是 virtio 的 channel 。這個結構的成員紀錄了這個 channel 的性質,包括是否使用 DMA 傳輸資料、最後一個讀取到的封包編號以及傳輸會呼叫的函式 notify 。
struct virtqueue
紀錄 channel 的基本資料,例如名字、編號,另外一個重要的是收到封包後的對應函式 callback ,這個會與 notify 一同介紹。
struct vring
這個是 channel 儲存傳輸資料的地方,與前兩個結構變數使用 kzalloc 這個記憶體管理 API 不同, vring 的空間是來自 platform_get_resource
由 platform device 擁有的記憶體空間,這個空間不會被核心映射到虛擬記憶體區塊,因此可以給這個 device 獨立使用。
struct vring_virtqueue
結合 virtqueue 以及 vring 結構體。
notify / callback
首先是 virtqueue_notify
這個函式顧名思義是要通知 virtio 裝置,例如 Linux 將資料放進 avail vring 後,必須讓遠端知道資料已經可以使用,這時就會使用 notify 函式讓遠端得知。接著是當對方接收到訊息時, virtqueue 會有對應的中斷服務常式 vring_interrupted
來處理 bottom half 的內容,在這個函式當中會因為 transfer/recieve 的不同而呼叫不同的 callback 函式。
virtqueue_add / virtqueue_get_buf
virtqueue_add
會將資訊放入 vring 當中,以 Linux 來說 virtqueue_add_inbuf
會更新 vring 當中 desc 以及 avail 的內容,讓遠端知道可以讀取幾個 buffer 。 virtqueue_get_buf
則是會把資料取出,並將其傳到傳輸層 rpmsg 對應的 endpoint 。
Virtio 資料傳遞流程
這裡會先從 Host 端的 Linux 談起,在 Virtio 驅動程式被 probe 之後,它會先建立 virtqueue 並且會連同 vring 一同初始化。在這之後有個 Linux 必須完成的任務,也就是 rpmsg buffer 的配置,這些 buffer 就是用來存放雙方要互相傳遞的原始資料,可以想成是網路封包要存放的空間。
在前面介紹中有談到重要的 vring ,這裡要來繼續探討它。 vring 本質上是 Linux 核心當中的 ring buffer ,並且會被分成三個區塊作使用,分別是 descripter area 、 avail ring 以及 used ring 。其中第一個區塊 descripter area 被用來管理 rpmsg buffer ,可以透過紀錄的 rpmsg buffer 實體位址、長度以及是否與下個區塊連續使用等性質,管理目前 Virtio 對 rpmsg buffer 的使用情形。
再來是 avail ring 以及 used ring ,這兩個區塊會一同紀錄 transfer / recieve (tx/rx) 對 rpmsg buffer 的使用情況。以 Linux 來說,如果今天 Linux 要傳輸訊息給 remote 端,我們會先將訊息紀錄到 rpmsg buffer 當中並一同更新 desripter area 的管理紀錄,當以上兩者更新後,我們就可以讓 avail ring 紀錄所使用到的 rpmsg buffer ,而紀錄的方式就是儲存 descripter area 對應的 entry ,並且依據現在 avail ring 紀錄到的位置更新 idx 值。
以下我畫了示意圖來呈現以上的說明,先從 descipter area 開始談起,其中 addr 、 length 欄位會用來存放 rpmsg buffer 的實體位址,而 flags 以及 next 欄位則會呈現這個區間是否連續。 再來是 avail ring ,其中 idx 欄位用來紀錄現在環狀佇列 tail 所指定到的位置,而 ring 欄位則是紀錄這個元素所對應到的 desripter entry 。當 avail ring 加入新的 desripter entry 時, idx 所紀錄的計數也會隨之更新。
RPMsg Communication Flow
這裡會從 RPMsg 的層面去探討資料如何傳遞,也會補充上個章節關於 remote 以及 host 使用 avail / used ring 的詳細規範。在 host 傳遞給 remote 的流程詳細可參照下圖或是上方參考連結,首先 host 會去檢查 used ring 當中是否有多的 buffer 可供使用,接著會將這個 buffer 從 used ring 搬移到 avail ring (實際上只是更新兩個 ring 的紀錄, used ring 作 dequeue 而 avail ring 進行 enqueue) ,然後會將資料寫進 rpmsg buffer 並存放到 desc entry 所指定的 addr ,最後會觸發中斷 notify 讓 remote 知道有來至 host 的資料。
Linux 傳遞訊息給 ThreadX
phase 3
Linux 觸發中斷通知 ThreadX (virtqueue_kick)。
THreadX 傳遞訊息給 Linux
phase 3
ThreadX 觸發中斷通知 Linux (virtqueue_kick)。
這個層次會對應到網路堆疊當中的傳輸層 (Transport Layer) ,因此不同的埠號會對應到不同的功能,首先我們會介紹幾個專有名詞,分別是 rpmsg channel 以及 rpmsg endpoint (endpoint) ,這些概念都與網路習習相關。
rpmsg channel
這個對應到主機間在傳輸層的邏輯連線,當有 remote 端與主機端建立連線時,就會形成這個通道,這個通道紀錄著 remote 端的名稱以及兩邊的地址。至於建立連線的方法,這裡會說明到連線時建立的交握,下圖是若 Linux 以及 Zephyr 同時被載入時的交握過程,如果是使用 remoteproc 載入則會略過 Zephyr 等待連線的過程, ThreadX 也適用此流程圖。
其中有個重要的步驟是遠端要發起 name service 請求,這個步驟等同是建立傳輸層的邏輯連線,這個宣告會通知主機端關於遠端相關的資訊,包含裝置名稱以及 endpoint 的號碼,並且會讓已經註冊的 rpmsg driver 進行 probe 。
rpmsg endpoint
Endpoint 是這個層次用來接收訊息的個體 (entity) ,每次 RPMsg 傳遞訊息時都必須指定接收端的 endpoint 。並且 endpoint 也會綁定到特定的服務 (callback) ,每當訊息傳遞到指定的 endpoint 時特定的服務就會被觸發,而這樣的模式也方便讓 Linux 去改變 ThreadX 的行為。
RPMsg 封包
排除 user payload 的部份,其他區塊屬於 RPMsg Header 。首先可以看到 source / destination address 這個紀錄的是 endpoint 的編號,再來 length 則是紀錄 payload 的長度。 flags 在規格書則指出留給開發者自行定義。
這裡呈現的硬體會由 Milk-V 轉為 KV260 ,在新的硬體延續了之前的異質多核架構,並且即時系統轉為了 Zephyr 。在實驗場景的設計下,兩個系統在傳輸的 Endpoint 會指定為同一者,並且在收到訊息後會立即傳遞給另一個系統,以此來達到乒乓的效果。
實驗設計是將共同變數 X 來回傳遞在 Linux 以及 Zephyr 之間,並且後者會將計數加一再傳回前者。因此結果會同解說錄影(二)的 31:10 時間段,詳細可看解說錄影。