避坑指南)
C語言學到進階這個階段如果你覺得自己已經能把指針和鏈表玩得轉但一碰到文件格式化輸入輸出fprintf/fscanf還是心里發(fā)虛那這篇文章就是給你準備的。文件格式化輸入輸出和緩沖區(qū)機制是C語言進階路上最容易被忽略、又最影響實戰(zhàn)體驗的一環(huán)。我見過太多人堆了一堆業(yè)務代碼最后在讀寫文件這一步翻車文本文件讀出來是亂碼、數(shù)據(jù)對不上、最后一行少讀了一次、程序明明調用了fwrite卻遲遲不見數(shù)據(jù)落盤……這些坑本質上都是沒把緩沖區(qū)的規(guī)則和格式化函數(shù)的行為吃透。這篇就著真實項目里出現(xiàn)的這些問題把文件I/O的底層規(guī)則和常用套路一次講清楚。1. 文件I/O的基本盤先搞懂流、緩沖與文件模式1.1 流與緩沖fprintf不會立刻寫盤先看最底層的一件事你的程序操作FILE*的時候數(shù)據(jù)并不是直接進磁盤的。fprintf、fputs這類函數(shù)先把內容寫到內存里的一個緩沖區(qū)等緩沖區(qū)滿了、文件關閉了、或者你主動調fflush才會真正發(fā)起系統(tǒng)調用把數(shù)據(jù)交給內核去寫盤。這個設計很好理解——每次寫一個字節(jié)都去觸發(fā)一次系統(tǒng)調用性能會差到沒法用。打個比方你往快遞站寄包裹驛站不是來一件發(fā)一件而是攢夠一車再統(tǒng)一發(fā)走緩沖區(qū)就是這個驛站。stdio庫把緩沖策略分成三種全緩沖、行緩沖和無緩沖。文件操作默認是全緩沖緩沖區(qū)大小一般就是BUFSIZ在多數(shù)平臺上通常是8192字節(jié)標準輸出 stdout 如果連的是終端默認是行緩沖遇到換行符就刷新stderr 則是無緩沖因為錯誤信息必須第一時間輸出哪怕程序下一秒就崩潰你也得先看到那行報錯。理解了這三條你就能解釋很多“詭異”現(xiàn)象為什么程序沒退出時寫進日志文件的內容看不到而同樣的printf卻能在終端立刻顯示出來——一個是被全緩沖壓著一個是行緩沖遇到換行就放了行。如果默認策略不滿足需求可以用setvbuf自定義緩沖方式比如把某個重要日志文件設定為行緩沖甚至無緩沖。不過實際項目里我更推薦的做法是默認全緩沖然后在關鍵節(jié)點主動fflush而不是把整個文件的緩沖策略改掉因為全局改掉往往會影響寫入性能。1.2 fopen的六種模式與文本/二進制差異再來談fopen的mode參數(shù)。很多人背過r/w/a但真正到了項目里碰上帶的模式就開始含糊。r、w、a單獨用都簡單r只讀且文件必須存在w只寫且不存在則創(chuàng)建、存在則清空a追加且不存在則創(chuàng)建。麻煩的是那三個帶的。r可讀可寫但文件必須存在不會清空內容寫操作從文件開頭覆蓋。w可讀可寫文件不存在則創(chuàng)建存在則直接截斷為空再讓你從頭讀寫。a可讀可寫不存在則創(chuàng)建但每次寫操作之前內部都會把位置強制挪到文件末尾。不少第一次用a的人都會被一個細節(jié)坑到明明用fseek定位到文件中間寫下去卻發(fā)現(xiàn)數(shù)據(jù)跑到末尾去了。這就是a的硬性約定——寫入永遠追加定位只對讀取生效。文本模式和二進制模式也值得單獨說一說。Windows下以文本模式寫文件\n會被自動變成\r\n存盤反過來讀的時候\r\n又會被還原成\n。Linux和macOS沒有這套轉換寫什么就是什么。于是常見的尷尬出現(xiàn)了同一份代碼在Windows下生成的數(shù)據(jù)文件拿到Linux上解析行尾常常多出一個\r字符串比較怎么都不相等??缙脚_處理文件時要么統(tǒng)一用二進制模式b由你自己處理行尾要么在解析時把\r過濾掉。2. fprintf/fscanf格式化讀寫的正確姿勢2.1 常用格式控制符與掃描集fprintf和fscanf是格式化I/O家族里文件側的大將。fprintf的格式控制符和printf幾乎一樣只是多了一個文件流參數(shù)fscanf同理從文件讀取內容并按格式串匹配。日常最常用的控制符無非%d、%f、%c、%s但有一個容易被忽略的東西在實際解析文本時非常好用——掃描集%[...]。比如%[0-9]表示只讀數(shù)字字符%[^,]表示一直讀到逗號為止%[^\n]表示讀取一整行直到換行符。為什么要專門提掃描集因為%s遇到空格就會停止這在解析結構化文本時非常無力。比如文件里存的是Tom 90 80你用%s能拿到Tom但如果你要讀取一個帶空格的字符串字段比如New York 10001這種直接用%s只能拿到New后面就全亂了。這個時候用%[^0-9]之類的掃描集配合跳過空白一條語句就能搞定不需要自己寫字符循環(huán)去拼。這里也順手解決一個新手疑惑寫%s的時候為什么不直接寫舒服就完事因為%s不會檢查目標數(shù)組邊界遇到長字符串就是緩沖區(qū)溢出的起點。規(guī)范寫法是%后面帶最大寬度比如一個char name[32]格式串就寫%31s最多讀31個字符留一個位置給\0。這個習慣在文件解析場景里尤其重要——文件內容不是你自己敲的長度不可控一旦超界破壞的可能是棧上相鄰變量的值。2.2 一個完整的成績文件讀取案例把上面的知識點串起來。假設有這樣一個成績文件score.txt1001 張三 88 92 76 1002 李四 95 89 91 1003 王五 72 84 90每一行是學號、姓名、三門成績。讀取并計算平均分的代碼可以這樣寫#include stdio.h int main(void) { FILE *fp fopen(score.txt, r); if (fp NULL) { perror(fopen); return 1; } int id; char name[32]; int s1, s2, s3; while (fscanf(fp, %d %31s %d %d %d, id, name, s1, s2, s3) 5) { printf(%d %s: 平均分 %.1f\n, id, name, (s1 s2 s3) / 3.0); } fclose(fp); return 0; }這段代碼里有幾個細節(jié)值得反復體會。第一name前面寫%31s不是裝樣子是為了防止緩沖區(qū)溢出數(shù)組長度32最多讀31個字符再加結尾的\0。第二while循環(huán)的判斷條件是fscanf的返回值等于5這才是規(guī)范做法。fscanf的返回值是成功匹配并賦值的輸入項個數(shù)如果文件讀到結尾返回EOF如果中間有字段格式不匹配返回值會小于5。第三循環(huán)結束后沒有額外判斷到底是正常讀到結尾還是中間出錯嚴格說應該檢查一下feof和ferror但在示例代碼里這種粒度已經足夠作業(yè)和多數(shù)小工具使用。2.3 返回值才是命根子談fscanf的坑很多人習慣用while(!feof(fp))控制讀取循環(huán)這個寫法已經被批評了無數(shù)次。feof標志只有在讀取操作已經失敗之后才會被置位也就是說當文件指針讀完最后一條有效數(shù)據(jù)后feof并不會立刻變成真而是在下一次讀取嘗試失敗后才置位。如果拿它做循環(huán)條件文件最后一行往往會被處理兩次。正確做法很簡單讓讀取函數(shù)自己做循環(huán)條件讀到什么是什么拿返回值判斷好過猜狀態(tài)標志。另外要把EOF和匹配失敗區(qū)分開。fscanf遇到文件結尾返回EOF也就是-1如果數(shù)據(jù)格式不匹配它會返回成功匹配的項目個數(shù)。舉個例子格式串是%d %d第一項匹配成功第二項失敗返回值就是1。很多新手看到返回值不是2下意識以為到文件尾了把格式錯誤誤判成讀完了結果就是數(shù)據(jù)丟失卻毫無察覺。簡單記憶方式把返回值理解成這次調用成功拿到幾個數(shù)據(jù)要讀5個字段就必須等于5才算這次讀取完整。3. 緩沖區(qū)細節(jié)與混合讀寫實戰(zhàn)3.1 緩沖刷新的四個時機緩沖數(shù)據(jù)的刷新時機歸納起來有四個緩沖區(qū)滿、遇到換行符僅在行緩沖模式下、主動調用fflush、fclose或程序正常退出。前面兩個是自動行為后面兩個是你可控的。關鍵點是fclose會先刷新緩沖區(qū)再關閉句柄所以忘記fclose的后果不只是文件句柄泄漏還可能丟失緩沖區(qū)內還沒寫盤的數(shù)據(jù)。舉個真實場景。程序運行到一半崩潰了你明明調用過fwrite但重啟后打開文件發(fā)現(xiàn)數(shù)據(jù)沒寫進去——正是因為數(shù)據(jù)還躺在緩沖區(qū)里沒來得及交給內核。解決思路是重要的數(shù)據(jù)要主動fflush比如寫日志的時候每寫一條記錄就fflush一次寧可犧牲一點性能也要保證日志能即時落盤。排查問題的時候靠一份半截日志和靠一份完整日志效率差別很大。有人會問既然無緩沖最安全為什么不把所有文件都設成無緩沖答案還是性能。無緩沖意味著每次寫操作都直接進入內核態(tài)在高頻寫入場景下性能會差幾十倍。平時開發(fā)用全緩沖關鍵節(jié)點手動刷新性能和安全都能兼顧。3.2 fseek/ftell與讀寫切換的緩沖問題ftell返回當前位置相對于文件開頭的偏移量fseek可以把位置移動到任意偏移配合SEEK_SET、SEEK_CUR、SEEK_END三個常量。這個組合用來實現(xiàn)隨機讀取非常順手比如讀取一個二進制文件的第N條記錄直接fseek到N乘以記錄大小再fread一條比逐條掃描快得多。這里有個隱藏很深的坑讀寫模式切換。C標準規(guī)定當文件流用于讀取后要轉為寫入或者寫入后要轉為讀取中間必須調用一次fflush或者fseek。這句話看起來抽象翻譯成人話就是你的程序先fscanf讀了一堆數(shù)據(jù)然后想直接fprintf寫東西中間不調整位置也不刷新緩沖區(qū)行為是未定義的。常見的表現(xiàn)是寫入不生效或者讀到的數(shù)據(jù)是臟數(shù)據(jù)。實戰(zhàn)中的建議是把讀階段和寫階段嚴格分開讀完就fclose或者在做模式切換之前明確調用一次fseek(fp, 0, SEEK_CUR)。fseek本身會清掉緩沖區(qū)里的不確定狀態(tài)讓流內部狀態(tài)重新一致。不要覺得多一行調用是多余的這一行能省掉一晚上的調試時間。3.3 while與do-while在讀取循環(huán)中的選擇再一個經常在讀取循環(huán)里被翻牌的問題while和do-while怎么選。教科書上的說法是do-while至少執(zhí)行一次while可能一次都不執(zhí)行。放到文件讀取場景里這個差異特別致命。舉個例子你想用do-while循環(huán)讀文件條件放在循環(huán)尾巴那哪怕一開始文件就是空的你也已經執(zhí)行了一次讀取。如果讀取函數(shù)的返回值同時被你拿來作為是否繼續(xù)的條件你就相當于拿著一次失敗的讀取結果強行湊數(shù)。所以文件讀取的循環(huán)我建議一律用while讓讀取函數(shù)本身作為判斷條件把什么時候結束讀取交給讀取函數(shù)決定而不是靠循環(huán)結構去兜底。反過來說do-while在文件操作里也有合理場景。比如先讀一次初始化數(shù)據(jù)如果成功至少處理一次這種語義用do-while表達就很自然。關鍵是動手之前想清楚第一次讀取失敗時你希望程序做什么大部分情況下你希望什么都不做直接跳過那答案就是while。4. 進階實戰(zhàn)用C語言實現(xiàn)類似C的pair讀取4.1 思路拆解結構體格式化字符串實戰(zhàn)里經常碰到這種需求文件里存的是一堆鍵值對或者坐標點格式類似3 5、x 100每次要讀一行把兩個值裝進一個結構體。C有現(xiàn)成的pairC語言沒有但這并不妨礙我們用結構體加格式化字符串實現(xiàn)同樣的效果。思路說起來很樸素定義一個結構體再用fscanf讀兩個字段填進去整個讀取過程封裝成一個函數(shù)調用方不需要關心底層細節(jié)。#include stdio.h typedef struct { int x; int y; } Pair; int read_pair(FILE *fp, Pair *p) { if (p NULL) return 0; return fscanf(fp, %d %d, p-x, p-y) 2; }這個read_pair的語義非常干凈讀成功返回1讀失敗包括文件尾返回0。調用方在循環(huán)里直接if或者while判斷即可。比起裸寫fscanf這種封裝把兩個整數(shù)變成一個邏輯單元代碼的表達力強很多后續(xù)改成數(shù)組管理也順手。4.2 完整的坐標點讀取與距離計算示例用上面的Pair設計一個完整場景文件points.txt里存了若干個坐標點每行兩個整數(shù)程序讀出坐標并計算相鄰點之間的距離。#include stdio.h #include math.h typedef struct { int x; int y; } Pair; int read_pair(FILE *fp, Pair *p) { if (p NULL) return 0; return fscanf(fp, %d %d, p-x, p-y) 2; } int main(void) { FILE *fp fopen(points.txt, r); if (fp NULL) { perror(fopen); return 1; } Pair prev, cur; if (!read_pair(fp, prev)) { fclose(fp); return 0; } while (read_pair(fp, cur)) { double dist hypot(cur.x - prev.x, cur.y - prev.y); printf((%d,%d) - (%d,%d) 距離 %.2f\n, prev.x, prev.y, cur.x, cur.y, dist); prev cur; } fclose(fp); return 0; }注意這里處理文件只有零個或一個坐標點的情況第一句read_pair的返回值直接決定程序是否繼續(xù)這也是我們前面反復強調的用返回值控制讀取流程的自然延伸。使用hypot函數(shù)記得編譯時鏈接數(shù)學庫Linux下是-lmWindows下一般在頭文件里已經處理好了。從這段代碼還能看出另一個經驗封裝讀取函數(shù)之后主邏輯里幾乎沒有和格式串有關的東西fscanf的細節(jié)全被關進了函數(shù)內部。如果哪天文件格式變了比如從空格分隔改成逗號分隔只需要改read_pair里的格式串調用的地方不動一個字。這種把變化收斂到一個點的編碼習慣在項目規(guī)模變大之后非常值錢。4.3 順帶講透a b的運算細節(jié)熱搜里有一個高頻問題a b到底怎么算。其實前綴和后綴的規(guī)則非常明確。b是先讓b自增1再把b的新值作為整個表達式的值賦給ab是先拿b的舊值作為表達式的值賦給a之后再讓b自增1。所以b初始等于5時a b的結果是a等于6、b等于6a b的結果是a等于5、b等于6。這個理解難度不大真正容易踩坑的是復雜表達式里的副作用順序。C標準規(guī)定同一個標量對象的兩次修改之間如果沒有序列點隔開行為就是未定義的。簡單點說像a (b) (b)這種代碼你可能覺得左邊的b先執(zhí)行右邊的后執(zhí)行但標準沒有承諾任何順序結果取決于編譯器的實現(xiàn)甚至不同優(yōu)化級別下結果都不一樣。寫代碼時遇到這類表達式我的原則是拆成多行一行只做一件事??此贫鄬懥藥仔械苊饬怂泻颓笾淀樞蛳嚓P的未定義行為調試成本低很多。5. 文件讀寫常見問題與排查技巧實錄5.1 高頻問題速查表把多年下來經常見到的文件讀寫問題整理成一張表對照著排查比盲猜效率高得多。現(xiàn)象大概率原因解決思路fopen返回NULL路徑不對、權限不足、目錄不存在打開后立刻判斷用perror打印錯誤原因讀出來的中文亂碼文件編碼與程序/終端預期不一致統(tǒng)一UTF-8或用二進制模式讀取后自行解碼最后一行被處理兩次用feof控制循環(huán)改用fgets/fscanf的返回值做循環(huán)條件數(shù)據(jù)對不上、少字段格式串寫錯、空格換行沒匹配格式串盡量寬松用%d %d方式跳過空白fscanf死循環(huán)格式不匹配導致文件指針停滯檢查返回值失敗后處理或跳出循環(huán)數(shù)據(jù)沒有立即落盤全緩沖未刷新重要數(shù)據(jù)主動fflush讀一半寫數(shù)據(jù)出問題讀寫模式切換未定位切換前調用fseek或fflush跨平臺解析行尾異常Windows的\r\n轉換Linux下過濾\r或統(tǒng)一二進制模式表格里每一條都是實戰(zhàn)里見過的問題。比如fopen返回NULL很多人第一反應是文件不存在但權限問題一樣會導致打開失敗而且原因在perror輸出里寫得明明白白比你自己盯著NULL猜半天高效得多。5.2 三個長期有效的實戰(zhàn)習慣再分享三個我從實戰(zhàn)里總結出來的習慣它們幫我躲掉了絕大多數(shù)文件讀寫的坑。第一個是fopen必判空。不管文件是自己生成的還是別人給的都假設它可能打不開打開成功后再繼續(xù)往下走。第二個是每次讀取之后檢查返回值。不管用fscanf還是fgets都要確認這次讀取真正拿到了預期數(shù)據(jù)不滿足就立刻打印當前文件位置和數(shù)據(jù)片段方便定位。第三個是寫日志必fflush。日志類程序每寫一條刷新一次保證程序崩潰時日志信息不會留在緩沖區(qū)里憑空消失。這三個習慣帶來的麻煩遠小于它們避免的災難。把它們當成肌肉記憶后你會發(fā)現(xiàn)自己花在文件問題排查上的時間明顯變少了。我個人做文件I/O相關開發(fā)這些年最大的體會是C語言的文件操作從來不難難的是你愿不愿意尊重每一個函數(shù)的行為約定。緩沖區(qū)是什么時候刷新的、fscanf遇到不匹配的數(shù)據(jù)會停在哪兒、feof標志是什么時候才置位的這些細節(jié)看著零碎但組合起來就是文件讀寫穩(wěn)不穩(wěn)的分水嶺。最后再分享一個小技巧排查文件問題時別急著看數(shù)據(jù)內容先看文件指針的位置和讀取函數(shù)的返回值這兩個信息往往比任何日志都能更快說明問題。