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