與實(shí)戰(zhàn)部署)
簡(jiǎn)介MMDVMHost 是 MMDVM 數(shù)字語(yǔ)音模塊的核心控制程序面向業(yè)余無(wú)線(xiàn)電愛(ài)好者、嵌入式開(kāi)發(fā)者和數(shù)字通信學(xué)習(xí)者。這份資源提供完整的 C/C 源碼便于理解 D-Star、YSF、DMR、P25 等多種數(shù)字模式的信號(hào)收發(fā)與數(shù)據(jù)處理流程。壓縮包內(nèi)共 328 個(gè)文件大小約 10.07MB以 117 個(gè) h 頭文件和 106 個(gè) cpp 源文件為主體同時(shí)包含 Makefile、Dockerfile、systemd 服務(wù)腳本、HD44780/Adafruit/OLED 顯示屏驅(qū)動(dòng)、TFT/HMI 界面素材、以及多份說(shuō)明文檔基本覆蓋從代碼編譯到硬件集成的完整鏈路。目前已有 226 人學(xué)習(xí)下載。閱讀源碼可學(xué)習(xí) C 面向?qū)ο笤O(shè)計(jì)在協(xié)議棧和網(wǎng)絡(luò)通信中的應(yīng)用也能掌握 C 語(yǔ)言對(duì)串口、GPIO、音頻 I/O 等底層硬件的操作技巧參考其目錄結(jié)構(gòu)與配置示例還能了解樹(shù)莓派等嵌入式平臺(tái)上的交叉編譯、服務(wù)注冊(cè)和二次開(kāi)發(fā)方法。這套資料是深入探索業(yè)余無(wú)線(xiàn)電數(shù)字通信技術(shù)的實(shí)用參考。1. MMDVM 宿主程序的任務(wù)邊界C/C 在多模協(xié)議棧里的位置拿到 MMDVM的宿主程序_C_C_下載.zip 這類(lèi)包的人通常不是想讀源碼而是想盡快把數(shù)字中繼或網(wǎng)關(guān)跑起來(lái)。MMDVM 是多模數(shù)字語(yǔ)音調(diào)制解調(diào)器的縮寫(xiě)它負(fù)責(zé)從射頻收發(fā)電路里讀出 DMR / P25 / YSF / NXDN 等模式的基帶符號(hào)而宿主程序最常見(jiàn)的是 MMDVMHost才是真正調(diào)度協(xié)議棧、管理串口、維護(hù)網(wǎng)絡(luò)連接的大腦。宿主程序用 C 寫(xiě)主邏輯用 C 接口和系統(tǒng)層打交道是這套系統(tǒng)里最值得讀也最值得改的部分。下文按模塊拆解、編譯部署、配置聯(lián)網(wǎng)、排錯(cuò)技巧四層往下走新手可以照命令做有 C/C 基礎(chǔ)的人則能看出協(xié)議棧和主循環(huán)的結(jié)構(gòu)習(xí)慣。2. 拆解 MMDVM 宿主程序的模塊與 C 主循環(huán)數(shù)據(jù)流2.1 MMDVM 宿主程序的六個(gè)模塊先分清誰(shuí)在調(diào)度誰(shuí)把源碼包打開(kāi)之后目錄里常見(jiàn)的子目錄或類(lèi)文件可以分成六塊配置解析、調(diào)制解調(diào)器驅(qū)動(dòng)、模式協(xié)議棧、網(wǎng)絡(luò)客戶(hù)端、日志監(jiān)控、主控制器。配置解析負(fù)責(zé)把 MMDVM.ini 讀進(jìn)內(nèi)存調(diào)制解調(diào)器驅(qū)動(dòng)通過(guò)串口和板子收發(fā)幀協(xié)議棧是 DMR、P25、YSF、NXDN 各自的編解碼與狀態(tài)機(jī)網(wǎng)絡(luò)客戶(hù)端把語(yǔ)音幀封裝成網(wǎng)絡(luò)包發(fā)向?qū)Χ朔?wù)日志監(jiān)控輸出運(yùn)行信息主控制器把所有模塊按時(shí)序串起來(lái)。這六塊的職責(zé)邊界值得記一下因?yàn)楹芏嗳烁拇a時(shí)會(huì)改錯(cuò)地方。配置解析只做 ini 到結(jié)構(gòu)體的映射不應(yīng)該嵌業(yè)務(wù)邏輯調(diào)制解調(diào)器驅(qū)動(dòng)只保證幀完整到達(dá)不關(guān)心幀里是 DMR 還是 YSF協(xié)議棧只處理符合幀規(guī)范的數(shù)據(jù)不做硬件訪(fǎng)問(wèn)。有一個(gè)通用表格可以對(duì)照模塊實(shí)現(xiàn)語(yǔ)言?xún)A向職責(zé)進(jìn)門(mén)看什么配置解析C讀 ini校驗(yàn)參數(shù)范圍配置結(jié)構(gòu)體定義調(diào)制解調(diào)器驅(qū)動(dòng)C串口讀寫(xiě)、幀同步fd、讀寫(xiě)緩沖區(qū)DMR 協(xié)議棧CDMR 語(yǔ)音幀編解碼、時(shí)隙調(diào)度slot 狀態(tài)機(jī)P25 / YSF / NXDNC各自模式的狀態(tài)處理模式選擇分支網(wǎng)絡(luò)客戶(hù)端CUDP 封裝、反射器連接目標(biāo)地址與端口主控制器C輪詢(xún)、超時(shí)、模塊間轉(zhuǎn)發(fā)主循環(huán)代碼我個(gè)人讀源碼的習(xí)慣是先看主控制器再回來(lái)看協(xié)議棧因?yàn)橹骺刂破髂芨嬖V你每個(gè)模塊在什么時(shí)間被調(diào)用這比從底層往上讀省去很多力氣。2.2 C 主循環(huán)誰(shuí)先讀、誰(shuí)先發(fā)、超時(shí)多少宿主程序的核心是一個(gè)無(wú)界的 C 主循環(huán)。這個(gè)循環(huán)每個(gè)周期做三件事先嘗試從調(diào)制解調(diào)器讀一幀再?lài)L試從網(wǎng)絡(luò)客戶(hù)端讀一幀最后按優(yōu)先級(jí)決定這一幀是發(fā)往網(wǎng)絡(luò)還是發(fā)往調(diào)制解調(diào)器。讀的順序之所以重要是因?yàn)閿?shù)字語(yǔ)音對(duì)端到端時(shí)延敏感DMR 的一個(gè)時(shí)隙固定 20 毫秒循環(huán)周期必須小于這個(gè)量級(jí)否則會(huì)出現(xiàn)語(yǔ)音斷字。while (m_running) { bool fromModem false; Frame frame; // 串口帶上 5ms 超時(shí)避免板子無(wú)數(shù)據(jù)時(shí) CPU 空轉(zhuǎn) if (m_modem.readFrame(frame, 5)) { fromModem true; } else { // 沒(méi)有硬件幀也給網(wǎng)絡(luò)側(cè)留時(shí)間 if (m_network.hasData()) { m_network.readFrame(frame); fromModem false; } } if (fromModem) { // 按幀里的模式字段分流到對(duì)應(yīng)協(xié)議棧 switch (frame.mode) { case MODE_DMR: m_dmr.processFromModem(frame); break; case MODE_YSF: m_ysf.processFromModem(frame); break; default: break; } } else { m_modem.writeFrame(frame); } // 精確休眠到本周期結(jié)束周期一般取 10ms 或 20ms m_timer.sleepUntilNextSlot(); }這段代碼里的 readFrame 帶超時(shí)參數(shù)是 C 程序里常見(jiàn)的非阻塞讀法。若不設(shè)超時(shí)串口 read 會(huì)把主循環(huán)掛死若超時(shí)太長(zhǎng)語(yǔ)音時(shí)延又會(huì)變大。5 毫秒只是起點(diǎn)實(shí)際要按板子的 USB 緩沖深調(diào)。后面的 sleepUntilNextSlot 用絕對(duì)時(shí)間點(diǎn)校準(zhǔn)周期而不是簡(jiǎn)單 usleep否則 Linux 調(diào)度器的累積誤差會(huì)每過(guò)幾千幀就多出一個(gè)時(shí)鐘抖動(dòng)。2.3 C 接口層extern C 與串口幀的邊界MMDVM 相關(guān)代碼里 C 和 C 共存通常不是刻意為之而是因?yàn)榇谧x寫(xiě)、內(nèi)存拷貝這類(lèi)底層邏輯用 C 寫(xiě)更直接而協(xié)議狀態(tài)機(jī)用 C 對(duì)象封裝更好維護(hù)。于是源碼里會(huì)看到不少 C 風(fēng)格接口被 C 類(lèi)包了一層。extern C { int modem_uart_open(const char *device, int baud); int modem_uart_read(int fd, void *buf, size_t len, int timeout_ms); int modem_uart_write(int fd, const void *buf, size_t len); void modem_uart_close(int fd); }用 extern C 聲明這些函數(shù)是為了告訴 C 編譯器不要做名字修飾。C 編譯器導(dǎo)出的符號(hào)是 modem_uart_openC 編譯器默認(rèn)會(huì)把它修飾成帶參數(shù)類(lèi)型信息的內(nèi)部符號(hào)兩者對(duì)不上鏈接階段就會(huì)報(bào) undefined reference。常見(jiàn)做法是把硬件抽象接口放在一個(gè) .c 文件里把協(xié)議邏輯放在 .cpp 文件里并且在 .h 中用 extern C 統(tǒng)一包圍。這個(gè)習(xí)慣在嵌入式配套軟件里很常見(jiàn)也解釋了為什么標(biāo)題里會(huì)同時(shí)出現(xiàn) C 和 C 兩個(gè)關(guān)鍵詞它們一個(gè)管上層調(diào)度一個(gè)管底層字節(jié)。3. 編譯 .zip 里的 C/C 源碼依賴(lài)、Makefile 與常見(jiàn)鏈接錯(cuò)誤3.1 開(kāi)包第一件事識(shí)別構(gòu)建系統(tǒng)解壓后先不要急著找 .sln 或 .vcxproj先看根目錄有沒(méi)有 Makefile、CMakeLists.txt、configure 腳本。不同來(lái)源的 MMDVM 宿主程序包差異很大官方主線(xiàn)長(zhǎng)期用 Makefile社區(qū)派生版本有人遷到了 CMakeWindows 移植版則常見(jiàn) Visual Studio 工程。確認(rèn)構(gòu)建系統(tǒng)的命令很簡(jiǎn)單ls -l Makefile CMakeLists.txt configure *.sln 2/dev/null如果同時(shí)看到 Makefile 和 CMakeLists.txt優(yōu)先用 CMake 流程因?yàn)?CMake 會(huì)讓你更容易切換編譯器和調(diào)試選項(xiàng)如果只有 Makefile就按下面那套最小命令直接 make。這里要注意版本后綴有些包名帶_master或日期不代表代碼新舊關(guān)鍵看系統(tǒng)頭文件里定義的協(xié)議版本號(hào)。3.2 Linux 上編譯 MMDVM 宿主程序的最小命令我一般在干凈的 Ubuntu 或 Debian 環(huán)境編譯除了 build-essential最可能缺的是 libusb 開(kāi)發(fā)頭文件。給一套可以直接執(zhí)行的流程unzip MMDVM的宿主程序_C_C_下載.zip cd MMDVMHost # 安裝編譯器和 USB 開(kāi)發(fā)庫(kù) sudo apt install -y build-essential libusb-1.0-0-dev # 按包內(nèi) Makefile 編譯 make clean make -j$(nproc) # 運(yùn)行前先看下動(dòng)態(tài)庫(kù)依賴(lài)是否齊 ldd ./MMDVMHostmake -j$(nproc)用機(jī)器的全部核心并行編譯第一次編譯能省不少時(shí)間。ldd列出的庫(kù)里如果有l(wèi)ibusb-1.0.so.0 not found說(shuō)明運(yùn)行時(shí)庫(kù)缺失用 apt 裝 libusb-1.0-0 即可。如果包內(nèi) Makefile 里鏈接方式比較老可能還需要裝 libudev-dev這類(lèi)缺失在編譯階段就會(huì)以頭文件找不到的形式暴露出來(lái)。如果包內(nèi)沒(méi)有 Makefile只有一堆 .cpp 和 .c可以自己寫(xiě)一個(gè)最小 CMakeLists 來(lái)重建工程我一般這樣起頭cmake_minimum_required(VERSION 3.16) project(mmvm_host CXX C) add_executable(mmvmhost src/main.cpp src/Modem.cpp src/DMR.cpp src/uart.c ) target_compile_options(mmvmhost PRIVATE -O2 -Wall -pthread) target_link_libraries(mmvmhost PRIVATE pthread) # 如果代碼中調(diào)用了 libusb取消注釋下面兩行 # find_library(USB_LIB usb-1.0) # target_link_libraries(mmvmhost PRIVATE ${USB_LIB})這個(gè) CMakeLists 的關(guān)鍵是把項(xiàng)目語(yǔ)言同時(shí)聲明為 C 和 CXX否則 CMake 只按文件后綴選擇編譯器C 文件里的modem_uart_open符號(hào)可能進(jìn)不了鏈接流程。-pthread要同時(shí)出現(xiàn)在編譯和鏈接階段只寫(xiě)鏈接階段會(huì)有運(yùn)行時(shí)棧溢出類(lèi)問(wèn)題。若原本的包依賴(lài)沒(méi)有 usb 字樣就保持注釋狀態(tài)避免鏈接不存在的庫(kù)。3.3 Windows 下 C/C 編譯與運(yùn)行庫(kù)的坑Windows 移植版通常不是官方主線(xiàn)產(chǎn)物常見(jiàn)兩種構(gòu)建路線(xiàn)用 Visual Studio 編譯 C/C 源碼或用 MinGW-w64 走 Makefile。兩條路線(xiàn)都會(huì)遇到同一個(gè)高頻問(wèn)題編譯出來(lái)的 exe 在本機(jī)能跑拷到別的 Windows 機(jī)器上彈窗提示缺少VCRUNTIME140.dll。這是因?yàn)榘l(fā)行版沒(méi)有把 Visual C Redistributable 運(yùn)行時(shí)打進(jìn)去。要解決要么在目標(biāo)機(jī)器上安裝對(duì)應(yīng)版本的運(yùn)行庫(kù)要么在編譯器里設(shè)置靜態(tài)鏈接。Visual Studio 里改靜態(tài)鏈接的方式是項(xiàng)目屬性 - C/C - 代碼生成 - 運(yùn)行庫(kù)把 /MD 改成 /MT。MinGW 則是加-static-libgcc -static-libstdcC 語(yǔ)言的運(yùn)行庫(kù)用-static帶上。還要注意串口 API 差異Linux 代碼里的 termios 結(jié)構(gòu)在 Windows 上不存在需要換成 CreateFile / ReadFile / DCB 那套 Win32 API這通常是移植包里改動(dòng)量最大的部分。3.4 依賴(lài)表與兩條典型編譯錯(cuò)誤整理一份我在不同環(huán)境遇到過(guò)的依賴(lài)映射依賴(lài)作用缺失現(xiàn)象build-essentialg/gcc/make編譯器與構(gòu)建工具make 命令不存在libusb-1.0-0-dev板卡 USB 通信頭文件 libusb.h 找不到libudev-dev設(shè)備節(jié)點(diǎn)枚舉udev 相關(guān)符號(hào)未定義pthread多線(xiàn)程庫(kù)undefined reference to pthread_createVisual C RedistributableWindows 運(yùn)行庫(kù)缺少 VCRUNTIME140.dll鏈接錯(cuò)誤里高頻出現(xiàn)的是undefined reference to pthread_create這通常不是代碼問(wèn)題而是 Makefile 漏了-pthread。第二個(gè)高頻問(wèn)題是 C 文件與 C 文件混編時(shí)的符號(hào)未定義多半是 extern C 沒(méi)寫(xiě)對(duì)。先看錯(cuò)誤里提到的函數(shù)名如果是_ZN...這種修飾后名字說(shuō)明編譯時(shí)沒(méi)問(wèn)題但聲明處沒(méi)有 extern C如果函數(shù)名是原始 C 名字但鏈接不到則說(shuō)明對(duì)應(yīng) .c 文件沒(méi)進(jìn)編譯列表。4. MMDVM.ini 配置參數(shù)與聯(lián)網(wǎng)聯(lián)調(diào)從本地回環(huán)到真實(shí)網(wǎng)關(guān)4.1 配置文件三段結(jié)構(gòu)先讀哪一段MMDVM 宿主程序啟動(dòng)時(shí)會(huì)加載一個(gè) ini 配置文件文件名通常是 MMDVM.ini。配置結(jié)構(gòu)大致分三段通用段、調(diào)制解調(diào)器段、協(xié)議模式段。通用段管理本機(jī)身份和基礎(chǔ)行為調(diào)制解調(diào)器段決定程序與硬件之間的參數(shù)協(xié)議模式段各自獨(dú)立DMR、P25、YSF、NXDN 互不影響。第一次拿到配置我建議先只改通用段和調(diào)制解調(diào)器段讓宿主程序能識(shí)別板子再說(shuō)聯(lián)網(wǎng)的事。[General] CallsignBG1XXX Id4601234 Timeout180 Duplex0 [Modem] Port/dev/ttyUSB0 Protocoluart TXLevel50.0 RXLevel50.0 TCXO0 [DMR] Enable1 Address127.0.0.1 Port62031 Passwordpassw0rdCallsign 和 Id 會(huì)作為身份信息出現(xiàn)在網(wǎng)絡(luò)數(shù)據(jù)包中Id 對(duì)應(yīng)你的個(gè)人 DMR ID這個(gè) ID 要在合法渠道申請(qǐng)隨便填一個(gè)會(huì)導(dǎo)致對(duì)端拒絕連接或語(yǔ)音無(wú)法解出。Duplex0 表示半雙工也就是單頻點(diǎn)收發(fā)輪流來(lái)這是多數(shù)網(wǎng)關(guān)的做法Duplex1 表示雙工需要中繼器支持頻率分離。4.2 調(diào)制解調(diào)器參數(shù)影響能不能解出語(yǔ)音調(diào)制解調(diào)器段里最值得反復(fù)調(diào)的是 TXLevel、RXLevel 和 TCXO。TXLevel 控制發(fā)射到板子的音頻電平RXLevel 控制從板子收到的采樣增益。電平調(diào)到多少取決于具體硬件沒(méi)有通用值常見(jiàn)起點(diǎn)是 40.0 到 60.0 之間。如果發(fā)射音調(diào)偏、接收聲音沉悶多半是電平?jīng)]對(duì)齊需要用對(duì)講機(jī)或儀器做對(duì)比。Protocoluart是串口協(xié)議有些板子也支持 USB 直接枚舉成串口設(shè)備。Port 在 Linux 上通常是 /dev/ttyUSB0 或 /dev/ttyACM0Windows 上是 COM3 這樣的名字。插上板子后用dmesg | tail看設(shè)備節(jié)點(diǎn)比直接猜端口名快得多。TCXO 字段填 0 表示普通晶振填 1 表示溫補(bǔ)晶振這個(gè)必須和硬件一致否則頻率偏差會(huì)讓整個(gè)頻段無(wú)法工作。4.3 網(wǎng)絡(luò)參數(shù)和連通性驗(yàn)證先本地后遠(yuǎn)端DMR 段里的 Address 和 Port 是宿主程序要連接的網(wǎng)絡(luò)對(duì)端。Address 指向的服務(wù)端分兩種一種是 DMR 組呼映射服務(wù)一種是 IPSC 網(wǎng)絡(luò)配置含義都一樣。先不要急著填遠(yuǎn)端地址我把 Address 設(shè)為 127.0.0.1用一個(gè)本地 UDP 監(jiān)聽(tīng)工具看包能不能發(fā)出來(lái)。# 終端 A監(jiān)聽(tīng) 62031 端口看是否有入站 UDP 包 sudo tcpdump -i lo udp port 62031 -vv -c 30 # 終端 B啟動(dòng)宿主程序 ./MMDVMHost MMDVM.initcpdump 打印源端口、目的端口和數(shù)據(jù)長(zhǎng)度。如果終端 A 在宿主程序啟動(dòng)后幾秒內(nèi)看到大量 UDP 包說(shuō)明配置解析和網(wǎng)絡(luò)發(fā)送鏈路已經(jīng)通了。此時(shí)再檢查包內(nèi)是否攜帶了你填的 Id 和 Callsign這一步能確認(rèn)配置段互相獨(dú)立且沒(méi)有串位。本地回環(huán)測(cè)試通過(guò)后再把 Address 改成遠(yuǎn)端服務(wù)端域名或 IP用同樣的 tcpdump 看外發(fā)包觀察對(duì)端是否回包?;匕l率不會(huì)像發(fā)送這么高只要在幾十秒內(nèi)出現(xiàn)一次即可說(shuō)明連接建立。5. 排錯(cuò)技巧用日志級(jí)別、TCXO 校準(zhǔn)和 ASan 處理 MMDVM 宿主程序5.1 日志級(jí)別決定排查效率宿主程序的日志一般有 DisplayLevel 和 LogLevel 兩個(gè)開(kāi)關(guān)前者控制屏幕輸出后者控制文件輸出。排查時(shí)把兩者都設(shè)為 1等于只保留錯(cuò)誤級(jí)輸出設(shè)為 4 會(huì)把每幀的調(diào)制解調(diào)器數(shù)據(jù)都打出來(lái)適合看協(xié)議處理是否走到預(yù)期分支。注意日志級(jí)別調(diào)高后會(huì)被刷屏先做短時(shí)間復(fù)現(xiàn)抓完日志立刻調(diào)回低級(jí)別。5.2 用頻譜儀或?qū)χv機(jī)校準(zhǔn) TCXO很多連不上的問(wèn)題其實(shí)是頻率偏差。找一臺(tái)支持該數(shù)字模式的設(shè)備讓宿主程序持續(xù)發(fā)射測(cè)試幀用頻譜儀觀察發(fā)射頻點(diǎn)與設(shè)定頻率的偏差。常見(jiàn)做法是把 TCXO 字段從 0 改成 1 對(duì)比變化若偏差穩(wěn)定在 100 Hz 以上多半是晶振類(lèi)型填錯(cuò)了若偏差抖動(dòng)優(yōu)先檢查板子供電。沒(méi)有頻譜儀時(shí)用另一臺(tái)接收設(shè)備聽(tīng)是否有持續(xù)解調(diào)聲也能做個(gè)粗判。5.3 用 AddressSanitizer 定位內(nèi)存越界C 版宿主程序運(yùn)行幾天后崩潰常見(jiàn)原因是幀緩沖越界或?qū)ο笊芷诠芾沓鲥e(cuò)。重新編譯一遍帶上 ASan 的版本比 gdb 單步快得多g -fsanitizeaddress -g -O1 -fno-omit-frame-pointer \ src/*.cpp src/*.c -o MMDVMHost_asan -lpthread ./MMDVMHost_asan MMDVM.iniASan 會(huì)在越界寫(xiě)入發(fā)生的瞬間打印出調(diào)用棧直接指向源碼行號(hào)。看到棧頂是 std::string 相關(guān)操作時(shí)優(yōu)先查網(wǎng)絡(luò)接收緩沖區(qū)的長(zhǎng)度校驗(yàn)看到是 C 文件里的 memcpy 時(shí)優(yōu)先查幀長(zhǎng)度字段是否被協(xié)議棧改寫(xiě)過(guò)。配合-fno-omit-frame-pointer能讓回溯更完整代價(jià)是二進(jìn)制變大這類(lèi)帶調(diào)試信息的版本不要直接跑生產(chǎn)負(fù)載。本文還有配套的精品資源點(diǎn)擊獲取