【原創】linux實時應用如何printf輸出不影響實時性?

沐多發表於2023-01-16

版權宣告:本文為本文為博主原創文章,轉載請註明出處 https://www.cnblogs.com/wsg1100。如有錯誤,歡迎指正。
@

本文介紹為什麼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(共享記憶體、訊息佇列…)互動,如下所示。

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實時系統,分別是cobaltmercury詳見【原創】xenomai核心解析之xenomai初探mercury構建時,printf介面仍是非實時的。

實時應用POSIX介面庫libcobalt提供的printf(),完全解決了上節中的不足:

  1. 應用無需呼叫額外初始化,編譯連結即可使用
  2. 預先分配列印記憶體池,無需每次透過glibc動態申請
  3. IPC使用共享記憶體,freelock(無鎖)
  4. 引入執行緒特有資料,多執行緒安全,臨界區無需鎖保護
  5. 無縫連線,應用程式碼無需修改標準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 APIVxWorks® emulatorpSOS® 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工作流程

實時執行緒

  1. 每個實時執行緒列印時,先從pool中分配printf buffer

  2. 成功分配後,將分配的buffer設定為執行緒特有儲存資料pthread_setspecific(buffer_key, buffer),此後該執行緒只操作這個buffer;

  3. 若執行緒過多,預先分配的pool已無法分配,使用malloc增加一個printf buffer,放到全域性隊first_buffer裡,並設定為該執行緒特有儲存資料,供後續每次列印輸出使用。

  4. 將列印訊息格式化到buffer的資料區

非實時執行緒

以一定週期從first_buffer遍歷連結串列,處理每一個buffer中的entry_head,按順序取出entry_head,按照entry_head指定目的進行IO輸出。

到此上個實現中的不足全部解決,其中關於xenomai如何實現"無縫銜接,應用程式碼無需修改編譯連結即可使用",這個已在之前的文章中解析,詳見【原創】xenomai核心解析--雙核系統呼叫(二)--應用如何區分xenomai/linux系統呼叫或服務

4. 總結

以上就是一個實時linux下開發實時應用程式,由一個普普通通的printf()引發的實時效能問題解決,可以看出不起眼的printf()要做好遠比我們想象的複雜,做底層就是這樣,得耐得住寂寞。幾句話共勉:
"萬丈高樓平地起,勿在浮沙築高臺"。
"或許做上層業務能快速出活,大家看得到,不用瞭解其內部的實現和對底層的依賴,美其名日“站在巨人的肩膀上”。效率提升了,但同時也導致我們對巨人的成長過程不聞不問。殊不知巨人倒下之後,我們將無所適從,就算巨人只是生個病(發生漏洞)帶來的損失也不可估量"。

更多xenomai原理見本部落格其他文章,關於更多PREEMPT-RT的原理和坑敬請關注本部落格。

相關文章