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