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