本文介紹為什么linux實時任務不能直接調用printf(),首先簡單介紹一下終端輸出原理,然后就如何實現終端輸出不影響實時任務實時性給出一個方案,最后介紹xenomai中是如何做到完美printf()的。
1. 前言
開始前,回顧下實時(Real-Time):
實時的本質是確定性、可預期性。即實時系統是必須在設置的截止時間內對特定環境中的事件做出反應的系統,不僅依賴于計算結果的正確性,還依賴于計算結果的?返回時間。實時任務運行過程中,不論軟件硬件,一切造成時間不確定的因素都是實時性的影響因素。
我們在linux上開發普通應用程序時,最常用的調試手段是gdb單步、終端打印。除調試外,一般應用程序運行過程中或多或少都會輸出一些應用運行信息、錯誤信息、警告信息等,這些信息格式化后可能會輸出到終端、syslog、記錄到文件等(本文僅介紹終端打印操作,其他的類似)。
但如果我們開發的是實時應用程序,還能一樣嗎?硬實時應用開發調試,部分情況下可以使用gdb跟蹤調試,但在一些涉及時間敏感的業務調試時,程序不能停下來,這時好的調試方式只有打印。非調試時也需要打印輸出和紀錄一些應用信息,總之我們要在實時路徑上打印信息,就需要考慮打印這個操作的實時性,即打印操作耗時必須是確定的,同時耗時不能影響實時應用結果輸出的deadline。
這個問題的本質是:實時任務該如何進行非實時IO 操作?
(1) 任務具有高優先級,不代表該任務所有IO操作實時 。
(2) 部分IO操作可能會帶來嚴重的不確定性,如實時任務中通過標準輸入輸出打印、讀寫文件等。
那glibc中printf()操作是實時的嗎?為什么?
2. linux終端輸出
在linux中,glibc提供了標準IO接口(printf、fwrite(stdout)...),其底層通過讀寫linux內核tty設備進行IO輸入輸出,終端輸出簡單流程如下所示。
應用程序終端打印可以直接通過系統調用write()輸出,這樣的話我們要處理更多的底層細節,比如指定文件描述符,要區分向終端打印字符還是寫入到文件。為屏蔽底層操作細節,C標準庫提供了統一和通用的IO接口,讓我們不必關注底層操作系統相關細節,做到一次編碼到處編譯。
但是,系統調用的過程涉及到進程在用戶模式與內核模式之間的轉換,過多的系統調用和上下文切換,會將原本運行應用的CPU時間,消耗在寄存器、內核棧以及虛擬內存數據保護和恢復上,縮短應用程序真正運行的時間,其成本較高。為了提升 IO 操作的性能,同時保證開發者所指定的 IO 操作不會在程序運行時產生可觀測的差異,標準 IO 接口在實現時通過添加緩沖區的方式,盡可能減少了低級 IO 接口的調用次數。使用標準 IO 接口實現的程序,會在用戶輸入的內容達到一定數量或程序退出前,再更新文件中的內容。而在此之前,這些內容將會被存放到緩沖區中。
通過系統調用進入系統后,數據經過TTY 核心、線路規程、tty驅動最終到達硬件外設,如果終端是串口的話,由UART driver操作串口外設發送,如果終端是VGA顯示器或xtrem虛擬終端,則通過對應的路徑進行輸出。
綜上printf()由linux C標準庫提供,其執行時間的長短取決于用戶態glibc緩沖方式、內存分配,內核態TTY driver、UART driver的具體實現(全路徑是否實時)等。所以glibc提供的標準IO并不是個實時的接口(低端arm平臺,實測glibc緩沖后輸出到波特率為115200的串口終端,執行需要330ms左右,如果在實時上下文使用,對實時應用來說這就是災難)。
雖然PREEMPT-RT通過修改Linux內核使linux內核提供硬實時能力,但整個路徑不僅僅只有內核,還涉及內核中的各種子系統,還有硬件驅動,應用層的標準庫glibc等,存在很多非實時的行為,沒有明確說明哪些是執行時間確定的,哪些是不確定的,只能遇到問題解決問題。
3. 常見的NRT IO輸出方案
實時應用中,對于此類問題,一般將非實時的IO操作交給非實時任務來處理,實時任務與非實時IO操作任務之間通過實時進程間通信IPC(共享內存、消息隊列…)交互,這個IPC通訊時間是確定的,如下所示。
3.1 一種實現方式
根據上圖,我們容易實現如下可在實時上下文調用的打印輸出接口。
實時與非實時任務使用消息隊列通信,創建的消息隊列大小固定,實時方通過非阻塞的方式發送消息,非實時方阻塞接收消息。
rt_printf()接口每次調用先分配一片內存msg,然后將要打印的內容通過sprintf()格式化到該內存中,接著將內存首地址通過非阻塞方式放到消息隊列,待高優先級的任務讓出CPU,低優先級的任務printf_task得到運行后,從消息隊列取出消息,最后通過printf()進行輸出,輸出完成后將內存釋放。
該實現方式有沒有問題?這個rt_printf接口并不是實時的,我們在一個PREMPT-RT的生產環境中就是這樣實現的,在實時應用中應用時發現有很大問題。
你可能覺得不實時是因為不能在實時上下文使用glibc提供的malloc()來動態分配內存,這里malloc()是原因之一,這是顯而易見的問題。我們在排查問題時,也一度以為抖動是malloc或實時應用其他業務部分產生的。但經過排查,發現一些過大的抖動產生時與內存分配并沒有關系,并且抖動比malloc()分配內存產生的pagefult抖動還大,能達到幾百ms,這明顯不正常。
這里簡單吐槽一下,linux雖然有很多debug和training的工具,如gdb、ftrace、tracepoint、bpf、strace、...,但這些都是會嚴重影響實時任務的運行實時序,在debug一個實時應用的問題時,由于這些工具的干預,要么問題不復現,要么整個系統卡死等等,特別是在一些資源受限的小型嵌入式linux系統上,很難排查系統或應用實時性問題,共性問題最好在x86上調試。
筆者這里要給大家介紹該實現里我們遇到的坑,從應用角度來看格式化字符串接口sprintf()與打印輸出接口printf()是兩種行為,他們之間沒有什么直接聯系。但通過調試發現,在glibc的實現中它們底層共用一個函數,存在鎖互斥,就會導致低優先級任務的printf()持有鎖刷新緩沖區,前面說到刷新緩沖區的時間可長達300ms,這時候高優先級任務只能阻塞等待鎖釋放,影響高優先級實時性。
這里想說的是,用戶態的glibc誕生之初就是針對高吞吐量設計的,而非實時性。此外雖然PREEMPT-RT在內核調度層面保證了linux的實時性,但內核中仍有許多機制和子系統、driver是非實時的,最嚴重的是driver,目前linux內核代碼量三千多萬行,其中85%以上為bsp驅動,這些驅動來自全球無數開發者和芯片廠商,這些驅動編寫之初就不是為實時應用而設計,這只是upstream的代碼,代碼質量比較優秀,問題相對好查找,但還有未上游化的驅動,那才是痛苦的根源。
由于ARM IP核授權方式,各個芯片廠商不同芯片外設各式各樣,這些外設驅動代碼并沒有上游化,只存在于芯片廠商提供的SDK中,如果廠商沒有明確支持PREEMPT-RT,那使用到的實時外設對應的實時驅動基本得debug一遍,特別是一些國產ARM芯片需要注意。
總之我們在開發實時應用時,全路徑都需要注意,分清楚哪些實時的哪些是非實時的,這也是為什么xenomai用戶庫、調度核、中斷、驅動到底層硬件全路徑實時。
3.3 改進
如何解決這個問題?printf()的作用是輸出到終端,所有直接使用fwrite寫終端stdout替換即可解決。
需要注意,fwrite需要知道寫的數據長度,所以通過消息隊列發送給實時任務的就不僅僅是個內存地址了,我們可以為每個輸出流添加如下頭,申請內存附加這個頭,這里就不贅述了。
?
struct out_head { size_t len;/*數據長度*/ char data[0];/*格式化后的數據*/ };
?
到此,只要不是在實時上下文頻繁調用,一個基本滿足實時應用調試的rt_printf()接口就完成了,如果我們要實現一個完美的rt_printf()接口,那它還有以下不足:
存在動態內存分配,導致不確定性增加。
IPC方式效率過低,消息隊列需要內核頻繁參與。
共用一個消息隊列、malloc內存分配,多線程同時調用時這些會成為瓶頸(消息隊列在內核中也存在鎖),相互影響實時性。
消息隊列的大小有限,若某個實時線程突發大量信息打印時,可能導致消息隊列耗盡,其他實時任務的消息無法輸出到終端,造成打印信息丟失。
原實時應用源代碼需要修改,應用中所有printf()接口都要修改為rt_printf(),導致應用代碼可移植性,可維護性差。
使用需要添加初始化代碼相關,如消息隊列創建、非實時線程創建等。
3. Xenomai3 printf()接口
xenomai3于2015年正式發布,在xenomai3之前的xenomai2,實時應用程序打印需要調用特定的接口rt_printf(),從xenomai3開始實時應用無需修改printf(),只有正確編譯鏈接實時應用POSIX接口庫libcobalt就可實現實時上下文調用printf()不影響實時性。
需要說明的是:xenomai3支持兩種方式構建linux實時系統,分別是cobalt?和?mercury詳見【原創】xenomai內核解析之xenomai初探,mercury構建時,printf接口仍是非實時的。
實時應用POSIX接口庫libcobalt提供的printf(),完全解決了上節中的不足:
應用無需調用額外初始化,編譯鏈接即可使用
預先分配打印內存池,無需每次通過glibc動態申請
IPC使用共享內存,freelock(無鎖)
引入線程特有數據,多線程安全,臨界區無需鎖保護
無縫連接,應用代碼無需修改標準IO接口
以下內容僅做概要,不對源碼逐行分析,若有興趣可自行閱讀libcobalt源碼。
3.1 應用運行前環境初始化
用戶無需調用代碼初始化,那只能在應用代碼執行前將環境printf相關準備好,如何做?回想我們使用C語言開發裸機程序時,我們通常認為CPU是從main()函數開始執行的,但實際上裸機開發時需要先用匯編為C程序執行準備環境,然后再調用main()開始執行,這種情況下我們可以在main()執行前做一些額外操作。
回到我們linux環境,這時我們要在main()之前做一些操作,又該如何實現?到這熟悉C++的同學應該會聯想到C++中全局對象,它們在main()之前就調用構造函數完成全局對象的創建了,而且main()結束后,程序即將結束前其析構函數也會被執行。
1. GCC特定語法
在GCC中,可以通過GCC提供的兩個GCC特定語法實現:
__attribute__((constructor)) 當與一個函數一起使用時,則該函數將會在main()函數之前。
__attribute__((destructor)) 當與一個函數一起使用時,則該函數將會在main()函數之后執行。
它們的工作原理為:共享文件 (.so) 或者可執行文件包含特殊的部分(ELF上的.ctors Section和.dtors Section,可用通過readelf -S查看Section信息),GCC編譯時會將標有構造函數和析構函數屬性的函數符號放到這兩個Section中,當庫被加載/卸載時,動態加載器程序檢查這些部分是否存在,如果存在,則調用其中引用的函數。
關于這些,有幾點是值得注意的。
a. 當一個共享庫被加載時,__attribute__((constructor))運行,通常是在程序啟動時。
b. 當共享庫被卸載時,__attribute__((destructor))運行,通常在程序退出時。
c. 兩個小括號大概是為了區分它們與函數調用。
d. __attribute__是GCC特有的語法;不是一個函數或宏。
使用destructor和constructor的好處是,如果我們有很多模塊,原來的方式是每個模塊內的初始化都需要去調用一遍,刪除某一個模塊就需要刪除相應的初始化代碼,然后重新編譯。有了destructor和constructor,我們就可以為每一個模塊設置對應的constructor,應用程序使用時就不需要統一寫代碼一個模塊一個模塊進行初始化,只需要編譯鏈接需要對應的模塊即可,爽歪歪。
xenomai 實時庫libcobalt利用該特性在實時應用程序前執行了大量初始化,如如Alchemy API、VxWorks emulator、pSOS emulator 等 API環境的初始化,這樣我們才能無縫使用libcobalt提供的服務。
這樣的應用很多,比如DPDK中,我們需要支持什么網卡驅動直接選中編譯鏈接即可,業務代碼還未執行,就已經完成所有網卡驅動注冊了,應用程序后續執行掃描硬件,匹配直接執行對應驅動進行probe。
2. libcobalt printf初始化流程
3.2 libcobalt printf內存管理
1. print_buffer
實時線程與負責打印輸出的非實時線程通過一片共享內存來實現IPC,該內存為環形隊列,print_buffer是管理這片內存的結構,與環形隊列緩沖區一一對應,其維護著環形隊列生產者與消費者的位置,print_buffer每個線程一個。
2. entry_head
entry_head用來抽象每條消息,從緩沖隊列中分配,包含消息長度,序號,目的(stdio、syslog)等信息。
3. printf pool
cobalt_print_init初始化過程中,預先分配打印內存池pool,分配成N份,其分配信息通過bitmap來記錄,無需每次通過glibc動態申請,當實時線程第一次調用printf()接口時,查詢bitmap未分配的print_buffer,取出設置為該線程的特有數據,并將其添加到全局鏈表first_buffer。
注:線程特有數據(TSD)是解決多線程臨界區需要保護,影響多線程并發性能的一種方式。更多詳見《Linux/UNIX系統編程手冊 第31章 線程:線程安全與每線程存儲》
3.2 libcobalt printf工作流程
實時線程
每個實時線程打印時,先從pool中分配printf buffer
成功分配后,將分配的buffer設置為線程特有存儲數據pthread_setspecific(buffer_key, buffer),此后該線程只操作這個buffer;
若線程過多,預先分配的pool已無法分配,使用malloc增加一個printf buffer,放到全局隊first_buffer里,并設置為該線程特有存儲數據,供后續每次打印輸出使用。
將打印消息格式化到buffer的數據區
非實時線程
以一定周期從first_buffer遍歷鏈表,處理每一個buffer中的entry_head,按順序取出entry_head,按照entry_head指定目的進行IO輸出。
到此上個實現中的不足全部解決,其中關于xenomai如何實現"無縫銜接,應用代碼無需修改編譯鏈接即可使用",這個已在之前的文章中解析,詳見【原創】xenomai內核解析--雙核系統調用(二)--應用如何區分xenomai/linux系統調用或服務?。
4. 總結
以上就是一個實時linux下開發實時應用程序,由一個普普通通的printf()引發的實時性能問題解決,可以看出不起眼的printf()要做好遠比我們想象的復雜,做底層就是這樣,得耐得住寂寞。幾句話共勉:
"萬丈高樓平地起,勿在浮沙筑高臺"。
"或許做上層業務能快速出活,成果直接,不用了解其內部的實現和對底層的依賴,美其名日“站在巨人的肩膀上”。效率提升了,但同時也導致我們對巨人的成長過程不聞不問。殊不知巨人倒下之后,我們將無所適從,就算巨人只是生個?。òl生漏洞)帶來的損失也不可估量"。
審核編輯:陳陳
評論
查看更多