
上周有個做嵌入式開發(fā)的朋友問我在CLion里調(diào)STM32的printf輸出網(wǎng)上的教程都讓重寫fputc為什么你寫的方案卻是重寫_write這個問題其實很典型尤其是從Keil轉(zhuǎn)到CLion的開發(fā)者十有八九都會撞上。CLion的嵌入式開發(fā)環(huán)境默認的工具鏈是arm-none-eabi-gcc加上newlib而不是Keil里那套ARM Compiler加microlibprintf的內(nèi)部調(diào)用路徑完全不一樣。如果不搞清楚這一層照搬網(wǎng)上的fputc重寫方案就算編譯通過串口上也什么都看不到。今天就把這個“為什么”背后的原理一次說清楚。1. printf輸出到底誰在底層“干苦力”1.1 先摸清函數(shù)調(diào)用鏈想搞懂為什么重寫_write而不是fputc第一步不是打開代碼編輯器而是先要弄清楚printf這個函數(shù)在你項目里到底是怎么一層層調(diào)到底層硬件的。很多從單片機裸機開發(fā)入門的朋友對printf的理解往往停留在“格式化函數(shù)”這個層面給它一個格式化字符串和一堆參數(shù)它就能把整型、浮點、字符串轉(zhuǎn)成可讀的文本。但這個轉(zhuǎn)好的文本最終是怎么從串口發(fā)出去的不同環(huán)境里的答案是不一樣的。在桌面Linux或者Windows上用C語言寫程序printf最終會將格式化后的文本通過操作系統(tǒng)內(nèi)核的write系統(tǒng)調(diào)用寫到標準輸出文件描述符上。如果你在終端里運行程序這個文件描述符指向的是終端設備如果你做了重定向它指向的可能是一個文件。但在單片機上情況特殊得多。單片機上沒有操作系統(tǒng)也沒有通用的設備文件你調(diào)用printf時標準庫并不知道串口硬件在哪里更不知道你的串口波特率、引腳配置、是否開了DMA。所以標準庫只能把“最終輸出到硬件”這個動作抽象成一個可以由開發(fā)者自己定義的底層函數(shù)讓你來告訴它“字符該怎么發(fā)給串口”。這個可自定義的底層函數(shù)在不同的C庫里有不同的名字和實現(xiàn)路徑。這就是整個問題的核心。1.2 newlib里printf的完整鏈路在CLion中最常用的嵌入式工具鏈arm-none-eabi-gcc自帶的C標準庫通常是newlib或newlib-nano。在這個環(huán)境里printf的調(diào)用鏈大致是這樣的printf - vfprintf - 內(nèi)部緩沖區(qū)機制 - _write這里的_write是newlib定義的“底層輸出鉤子”它的原型是int _write(int file, char *ptr, int len);注意這個函數(shù)接收的不是單個字符而是一個緩沖區(qū)指針和長度。這意味著newlib的設計思路非常明確printf在格式化文本時會先把文本寫入內(nèi)部緩沖區(qū)然后調(diào)用一次或多次_write把緩沖區(qū)里的一整段數(shù)據(jù)交給底層輸出函數(shù)。舉個例子當你調(diào)用printf(Hello, %d\r\n, 42)時newlib會先把Hello, 42\r\n這串字符格式化到一個內(nèi)部緩沖區(qū)然后調(diào)用_write傳入緩沖區(qū)首地址和字符長度。所以你的_write實現(xiàn)只需要負責把這串字符完整地送往串口即可不需要關(guān)心格式化邏輯。這是一種比較合理的設計把“格式化”和“字符發(fā)送”兩個職責分離底層寫函數(shù)可以一次處理一批字符效率更高。如果你的串口驅(qū)動用了DMA傳輸甚至可以在_write里直接把緩沖區(qū)地址交給DMA實現(xiàn)異步發(fā)送。1.3 fputc在newlib體系里扮演什么角色那fputc呢在newlib里fputc確實存在它是一個標準的C庫函數(shù)功能是向指定的文件流寫入一個字符。它的實現(xiàn)大致是這樣的邏輯int fputc(int ch, FILE *stream) { // 向stream指向的文件流寫入一個字符 }但在newlib的實際實現(xiàn)中fputc并不在printf的調(diào)用路徑上。printf內(nèi)部走的是vfprintf加_write這條路并沒有經(jīng)過fputc。也就是說你就算把fputc重寫了printf也不會調(diào)用你的版本——除非你直接調(diào)用fputc本身。很多網(wǎng)上的STM32教程為什么讓你重寫fputc因為這些教程大多基于Keil MDK環(huán)境。Keil默認使用的是ARM Compiler自帶的microlib在microlib的實現(xiàn)里printf的輸出路徑最終會經(jīng)過fputc所以重寫fputc可以生效。這個經(jīng)驗在Keil環(huán)境里沒錯但直接搬到CLion的gcc工具鏈下就不適用了。我在CLion里第一次做串口重定向時也踩過這個坑照著Keil里的寫法定義了一個fputc然后在代碼里調(diào)用printf編譯完全通過下載到板子上串口終端什么都沒有。后來在_write里加了斷點才發(fā)現(xiàn)程序壓根就沒進過我的fputc。2. 網(wǎng)上教程大都寫fputc為什么你的CLion卻不行2.1 不同C庫的內(nèi)部實現(xiàn)差異很多從Keil遷移到CLion的開發(fā)者會先入為主地認為printf重定向是“一個標準做法”到哪個環(huán)境下都一樣。實際上不同C庫對標準IO的實現(xiàn)差別很大尤其是底層輸出鉤子的設計。Keil的ARM Compiler在使用了microlib時printf輸出路徑可以簡化為printf - fputc - 你重寫的fputc。這就是為什么你在Keil里重寫fputc調(diào)用printf就能在串口看到輸出。而CLion配合arm-none-eabi-gcc時默認使用newlib或newlib-nanoprintf的路徑是printf - vfprintf - _write。在這種情況下fputc和printf基本是兩條線fputc是標準文件流接口的一部分而printf走的是自己的格式化輸出路徑最終統(tǒng)一進入_write。我可以用一個簡單的類比來說明你給公司前臺打電話說“把這份文件送到A辦公室”前臺掛斷電話后會走內(nèi)部通道把文件送過去。在Keil的microlib里前臺門口就只有一道門你改造門鈴就能通知到前臺。在newlib里前臺內(nèi)部有專門的文件分發(fā)渠道你光改門鈴是沒用的得改分發(fā)渠道的出口。2.2 ARM Compiler與GCC工具鏈的路徑對比為了減少誤解我把兩種環(huán)境下printf最終輸出到串口的前后路徑整理成一個簡表工具鏈C庫重定向printf的關(guān)鍵函數(shù)printf內(nèi)部調(diào)用路徑Keil MDK ARM Compilermicrolibfputcprintf - fputc - 硬件驅(qū)動CLion arm-none-eabi-gccnewlib / newlib-nano_writeprintf - vfprintf - _write - 硬件驅(qū)動我在實際項目里同時維護過兩套工程一套給Keil的老客戶一套給CLion的新項目最深的體會是不要試圖在代碼里用條件編譯同時兼容兩套重定向方式很容易導致邏輯混亂。更好的做法是在底層抽象一層類似uart_write_buffer()的函數(shù)然后分別在fputc和_write里調(diào)用它這樣無論哪個環(huán)境都能正常工作。2.3 既然路徑不同那么如何快速判斷自己該改誰如果你不確定自己的項目里printf最終會調(diào)用誰最直接的辦法不是猜而是看map文件。在CLion編譯完成后去build目錄里找到.map文件搜索fputc和_write看這兩個符號是否被引用。如果fputc被調(diào)用了說明你的庫走的是fputc路徑如果出現(xiàn)了_write或_sbrk等系統(tǒng)調(diào)用符號說明你用的是newlib全家桶應該重寫_write。還有一種更快的驗證方法在代碼里同時定義fputc和_write各自往不同的串口發(fā)一段固定字符。然后調(diào)用printf看哪個串口收到了數(shù)據(jù)。這雖然是一個比較粗暴的測試方法但結(jié)果非常直觀能讓你在五分鐘內(nèi)弄清楚當前工具鏈的實際行為。3. 在CLion里重定向printf的正確姿勢3.1 先確保串口驅(qū)動已經(jīng)能正常工作說了這么多原理接下來進入實操部分。在CLion里重定向printf第一步不是寫_write函數(shù)而是先確保串口本身能收發(fā)數(shù)據(jù)。我在實際開發(fā)中會先寫一個最簡單的測試不調(diào)用printf直接調(diào)用串口發(fā)送函數(shù)往串口發(fā)一個固定的字符串例如uart_send_string(UART OK\r\n)。如果這一步在串口助手里能看到數(shù)據(jù)說明串口的GPIO配置、時鐘使能、波特率設置都是對的可以直接進行下一步。很多人在這個環(huán)節(jié)出錯先寫了_write然后調(diào)用printf發(fā)現(xiàn)沒有輸出就開始懷疑_write寫錯了。結(jié)果排查了半天最后發(fā)現(xiàn)是串口的TX引腳配置錯了或者GPIO復用功能沒開。這個順序問題很關(guān)鍵先把基礎的串口發(fā)送驗證通過再談printf重定向。3.2 一個可以直接抄的_write實現(xiàn)串口驗證通過之后下面是我在CLion STM32環(huán)境中常用的_write實現(xiàn)可以直接抄#include errno.h #include sys/unistd.h // STM32使用USART1寄存器級別的發(fā)送函數(shù) // 注意這里假設你已經(jīng)初始化了USART1并使能了TX static void uart_send_char(char c) { // 等待發(fā)送數(shù)據(jù)寄存器為空 while (!(USART1-ISR USART_ISR_TXE_TXFNF)); // 發(fā)送一個字節(jié) USART1-TDR (uint8_t)c; } int _write(int file, char *ptr, int len) { // newlib調(diào)用_write時file通常為1stdout或2stderr // 這里不做區(qū)分統(tǒng)一從串口輸出 (void)file; for (int i 0; i len; i) { uart_send_char(ptr[i]); } // 返回實際發(fā)送的字節(jié)數(shù)newlib會根據(jù)返回值判斷是否發(fā)送成功 return len; }這段代碼實現(xiàn)的功能是newlib的vfprintf格式好一段文本后會調(diào)用_write把緩沖區(qū)的地址和長度傳進來。我用一個循環(huán)把緩沖區(qū)里的每個字符逐個通過USART1發(fā)送出去。注意返回值必須返回len表示所有字符都發(fā)送成功了。如果返回值不等于lennewlib可能會認為發(fā)送出錯甚至會觸發(fā)錯誤處理邏輯。3.3 需要留意的小細節(jié)與排查邊界上面這段代碼雖然簡單但也有幾個需要注意的細節(jié)。第一個細節(jié)是寄存器名字。STM32F4系列和G0系列的寄存器命名有差異有的系列是ISRTDR有的老系列是SRDR。我上面寫的是較新系列的命名如果你的芯片是老型號需要換成對應的寄存器。此外HAL庫用戶可以直接調(diào)用HAL_UART_Transmit函數(shù)但要注意這個函數(shù)有超時機制傳遞一個較大的超時值會更穩(wěn)妥。第二個細節(jié)是如果你的_write會被多個地方調(diào)用例如printf和fprintf(stderr)都可能觸發(fā)它那么你的_write實現(xiàn)里最好加一個簡單的臨界區(qū)保護防止并發(fā)訪問串口導致的字符交錯。在裸機環(huán)境下最簡單的方式是關(guān)中斷或使用一個互斥的標志位。當然如果你只是在一個主循環(huán)里調(diào)用printf沒有多線程也不開中斷發(fā)送那么不加保護問題也不大。第三個細節(jié)是在使用newlib-nano時如果你調(diào)用printf輸出浮點數(shù)默認情況可能什么都打不出來。這是因為newlib-nano為了減小體積把浮點格式化支持裁剪掉了。解決方式是在CMakeLists.txt里加上add_compile_options(-u _printf_float)這個選項會強制鏈接浮點打印支持。我遇到過好幾次這個坑所以特意提一下否則你重寫完_write發(fā)現(xiàn)整數(shù)能輸出一到浮點數(shù)就空白很容易懷疑是_write的問題。3.4 為什么說CLion里重寫fputc不生效是有原因的綜合上面的分析可以看到CLion里printf輸出不經(jīng)過fputc本質(zhì)上是C庫的實現(xiàn)路徑差異導致的。但這里還要補充一種特殊情況如果你的代碼里顯式調(diào)用了fputc函數(shù)比如執(zhí)行fputc(A, stdout)那么你重寫的fputc是會生效的。問題只在于printf不會調(diào)用fputc而不是fputc本身不工作。這就解釋了為什么你會看到一種現(xiàn)象重寫fputc后先調(diào)用printf沒有輸出再直接調(diào)用fputc測試能看到字符發(fā)出來于是百思不得其解。這個現(xiàn)象其實說明你的fputc重寫是對的串口初始化也是對的只是printf根本沒走上這條路。4. 從printf重定向延伸到C庫的系統(tǒng)調(diào)用依賴4.1 底層鉤子不止_write一個在嵌入式C項目中一旦你使用了newlib標準庫的很多功能都會依賴一些“底層系統(tǒng)調(diào)用”。這些函數(shù)原本是為有操作系統(tǒng)的環(huán)境設計的在裸機上沒有現(xiàn)成實現(xiàn)需要開發(fā)者自己補齊或者讓庫使用精簡模式。除了_write常見的還有_sbrk、_read、_close、_lseek、_fstat、_isatty等。其中_sbrk負責堆內(nèi)存管理如果你的程序用了malloc或者newlib內(nèi)部需要申請堆空間就會用到它。在STM32的啟動文件里如果沒有正確設置堆指針malloc和printf的某些內(nèi)部操作可能崩潰。我覺得對只做串口輸出的小項目來說你可以不實現(xiàn)所有系統(tǒng)調(diào)用只實現(xiàn)_write就夠用了。但如果你的程序涉及文件操作、標準輸入或者使用了一些依賴文件描述符的庫函數(shù)就要補上對應的函數(shù)。一個常見的做法是寫一個syscall.c文件把這些鉤子統(tǒng)一放在一起管理。4.2 使用Retargeting需要注意的編譯告警在CLion的CMake工程里你可能需要告訴編譯器“這些函數(shù)是我自己實現(xiàn)的不要再從庫里面拉取默認版本”。否則某些工具鏈版本會報duplicate symbol錯誤或者出現(xiàn)鏈接警告。比較常用的做法有幾種最簡單的是直接用--specsnano.specs這樣newlib-nano會自動允許用戶改寫這些底層鉤子。還可以利用鏈接腳本和編譯器選項配合處理讓自定義的_write覆蓋庫里的弱定義。我個人的習慣是在CMakeLists.txt里明確加入set(COMMON_FLAGS --specsnano.specs) set(COMMON_FLAGS ${COMMON_FLAGS} -u _printf_float) set(CMAKE_C_FLAGS ${CMAKE_C_FLAGS} ${COMMON_FLAGS}) set(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} ${COMMON_FLAGS})這樣就少了很多重復的鏈接告警printf浮點支持也能正常使用。如果你的芯片內(nèi)存非常小還可以關(guān)閉浮點打印來省空間代價是調(diào)試時不能直接用printf輸出浮點數(shù)據(jù)。4.3 為什么不建議用HAL庫的HAL_UART_Transmit直接頂替在我看過的一些項目里有人在_write里直接調(diào)用HAL_UART_Transmit這個方法本身可以用但要注意兩個問題。第一個問題是HAL_UART_Transmit的阻塞式發(fā)送在調(diào)試階段問題不大如果以后要改成中斷或DMA發(fā)送_write里再等待發(fā)送完成會影響系統(tǒng)實時性。第二個問題是HAL_UART_Transmit內(nèi)部有超時循環(huán)它依賴一個系統(tǒng)時鐘源tick如果調(diào)試器暫停在斷點時間過長啟動時的tick沒有及時更新在特定情況下可能在發(fā)送大字符串時觸發(fā)超時退出導致丟數(shù)據(jù)。所以我的建議是測試階段可以用HAL庫函數(shù)快速跑通流程但正式產(chǎn)品代碼里_write內(nèi)部盡量操作寄存器或者單獨封裝一層中斷/DMA發(fā)送接口。這樣不依賴HAL的阻塞邏輯也不會因為tick被調(diào)試器凍結(jié)而產(chǎn)生不確定行為。5. 我的一個實際調(diào)試經(jīng)歷與建議5.1 排查日志里出現(xiàn)的bad file descriptor有段時間我用CLion調(diào)試一個stderr重定向時程序運行后串口沒有任何輸出但控制臺里出現(xiàn)了類似fatal: write failure on stdout: bad file descriptor的提示??吹竭@個提示第一反應是不是_write寫錯了檢查了好幾遍函數(shù)邏輯沒問題。后來深入查了一下發(fā)現(xiàn)問題出在newlib的stderr默認關(guān)聯(lián)的文件描述符與系統(tǒng)環(huán)境的差異上。在桌面系統(tǒng)里stderr通常也是有效的終端文件描述符但在裸機程序里如果你沒有初始化標準流調(diào)用fprintf(stderr, ...)會觸發(fā)內(nèi)部錯誤處理進而走進默認的_write但這個默認版本可能沒有正確實現(xiàn)于是返回一個錯誤值。實際上這種情況通常是因為我在newlib的配置里使用了NOSYSno system calls模式導致默認的_write被設置為一個永遠返回錯誤的版本而我又沒有正確覆蓋它。后來我在項目里統(tǒng)一注釋掉了NOSYS相關(guān)的宏并且在_write實現(xiàn)里對file參數(shù)做了忽略處理問題就解決了。5.2 最后的小建議從Keil遷移過來先確認環(huán)境再照搬我在寫了這么多樣例之后最想強調(diào)的一點是嵌入式開發(fā)里printf重定向不是一個“萬能統(tǒng)一”的問題它跟你用什么IDE、什么編譯器、什么C庫強相關(guān)。如果你正在用CLion做STM32開發(fā)請記住最核心的一句話當前環(huán)境默認的gcc工具鏈走的是newlib體系正確做法是重寫_write而不是fputc。至于那些讓你重寫fputc的教程要先看清它對應的編譯環(huán)境再考慮是否適用于你的項目。判斷環(huán)境的開銷很低——打開map文件看一眼符號表就夠了但照搬錯誤方案的排查成本卻高得多。從我個人經(jīng)驗來說當你懷疑“為什么我重寫fputc不起作用”的時候先別急著懷疑編譯器、懷疑啟動文件、懷疑調(diào)試器先去看一眼printf的底層調(diào)用鏈。C庫的源碼是開放的如果手頭不方便看源碼直接在_write和fputc里各放一個斷點讓程序跑一次哪個斷了說明printf走的哪個出口。這比任何理論分析都來得直截了當。