这一章在干嘛?

第 3 章你已经会用 load/save 读写「规整矩阵」数据文件了。但真实世界的数据文件经常一行里混着数字和文字、各行列数不一致,load 直接罢工。这一章讲的就是 MATLAB 的底层文件 I/O(lower-level file I/O):先 fopen 拿到文件标识符(file identifier, fid),再逐行或整块读写,最后 fclose 关闭。学完你就能处理任意格式的文本数据 —— 而且整套流程和 C 语言的 fopen/fscanf/fprintf/fclose 几乎一模一样,C 程序员可以无缝衔接。

9.1 用 MAT 文件保存变量

9.2 读写电子表格文件

9.3 底层文件 I/O:打开与关闭

9.4 逐行读取:fgetl

9.5 写文件与追加:fprintf

9.6 整块读取:fscanf 与 textscan

9.7 四种读文件方法总结

9.8 进阶:二进制读写与文件定位

9.1 用 MAT 文件保存变量

先补一块前面欠的拼图:MAT 文件(MAT-file,扩展名 .mat)。它和数据文件完全是两码事 —— 数据文件只存「裸数据」,而 MAT 文件连变量名带变量值一起存。这相当于 C 里把整个内存结构序列化下来,只在 MATLAB 内部使用,不用于和别的程序交换数据。

>> mymat = rand(3,5);
>> x = 1:6;
>> y = x.^2;
>> who                      % 看当前工作区
Your variables are:
mymat  x  y
>> save sess1               % 全部变量存入 sess1.mat(扩展名自动加)
>> who -file sess1          % 不加载,只查看文件里有哪些变量
Your variables are:
mymat  x  y
>> save sess2 mymat         % 只把 mymat 存入 sess2.mat
>> save -append sess2 x     % 追加:把 x 加进 sess2.mat
>> load sess2               % 读回:变量 mymat、x 直接进工作区

要点:

  • save 文件名 存全部;save 文件名 变量名 存指定的;-append 追加(同名变量会被工作区里的新值覆盖)。
  • load 文件名 变量列表 可以只加载部分变量。
  • 文件存到「当前文件夹」,所以先确认路径正确。

什么时候用 MAT 文件?

中间结果太大、每次重算太慢时(比如训练好的权重矩阵、处理过的传感器数据),save 进 MAT 文件,下次 load 秒恢复。相关变量分组存进不同的 MAT 文件是好习惯,避免 load 一口气把几十个无关变量全灌进工作区。

9.2 读写电子表格文件

MATLAB 可以直接和 Excel 交互:xlswrite 写、xlsread 读(Windows 下需要装了 Excel)。最简形式:

>> ranmat = randi(100,5,3);      % 5x3 随机整数矩阵
>> xlswrite('ranexcel', ranmat)  % 写入 ranexcel.xls
>> ssnums = xlsread('ranexcel')  % 读回

真正的价值在处理数字与文字混排的表格。假设 texttest.xls 内容如下:

a123Cindy
b333Suzanne
c432David
d987Burt
>> [nums, txt] = xlsread('texttest.xls')
nums =
   123
   333
   432
   987
txt =
    'a'    ''    'Cindy'
    'b'    ''    'Suzanne'
    'c'    ''    'David'
    'd'    ''    'Burt'

注意:数字先进 nums,文本进 txt(元胞数组),且 txt 的形状和原表一样 —— 中间列因为是数字被抽走了,留下空字符串占位。之后可以用循环配合 txt{i,j} 把数据拼回去打印。

常见坑:新版 MATLAB 用 readmatrix/readcell

新版 MATLAB 已推荐用 readmatrix(读数值)和 readcell(读元胞)替代 xlsread/xlswrite,且不再依赖 Excel 安装。但教材和老代码里全是 xlsread,两边都要认识。

9.3 底层文件 I/O:打开与关闭

load 只能读「每行同样格式、全是同类型数据」的规整文件。一旦不规整(比如一行是 5.3 a,数字带字符编码),就必须上底层函数。整个流程和 C 语言完全同构:

┌─────────────┐    ┌──────────────────┐    ┌─────────────┐
│ fopen 打开   │ →  │  读 / 写 / 追加    │ →  │ fclose 关闭  │
│ 拿到 fid     │    │ (fgetl/fscanf/…) │    │ 检查返回值    │
└─────────────┘    └──────────────────┘    └─────────────┘

9.3.1 fopen 与 fid

fid = fopen('filename', '权限字符串');
  • 成功:fid 是一个正整数,之后所有读写函数都用它来指代这个文件 —— 概念上就是 C 的 FILE* 句柄。
  • 失败:返回 -1,同时第二个输出参数给出错误信息。
  • 权限字符串(permission string):'r' 读(默认,可省)、'w' 写、'a' 追加。C 语言里的 "r+" 读写模式这里也支持,加个加号即可。

每次 fopen 之后必须立刻检查返回值 —— 文件名拼错、文件不在当前文件夹,是最常见的失败原因:

>> [fid, msg] = fopen('sample.dat')
fid =
    -1
msg =
No such file or directory
>> fid = fopen('samp.dat')   % 打开成功
fid =
     3

为什么第一个打开的文件 fid 是 3?因为 0、1、2 已被 MATLAB 预留:0 = 标准输入(stdin)、1 = 标准输出(stdout,即屏幕)、2 = 标准错误(stderr) —— 和 C 语言的习惯完全一致。你平时写的 fprintf('...\n') 没给 fid,默认就是往 fid=1 的屏幕打。

还有个看不见但重要的概念:文件位置指示器(file position indicator)。fopen 成功后,指示器停在文件开头;每读/写一段数据,它就往后移动。fgetl 读一行它就移到下一行行首,feof 判断的就是它是否已到达文件末尾 —— 和 C 里文件流内部的读写指针是一个东西。这也解释了为什么循环里连续调 fgetl 能依次读出每一行:位置指示器一直在往前走,不会重复读。

如果把 fid 看成 C 的 FILE*,这一整套映射关系如下:

MATLABC 语言说明
fid = fopen('f','r')fp = fopen("f","r")打开文件拿句柄
fid == -1 检查fp == NULL 检查打开失败判断
aline = fgetl(fid)fgets(buf,n,fp)逐行读
fprintf(fid,...)fprintf(fp,...)格式化写(语法几乎照搬)
fscanf(fid,...)fscanf(fp,...)格式化读
fclose(fid)fclose(fp)关闭
feof(fid)feof(fp)是否到文件尾

9.3.2 fclose

closeresult = fclose(fid);     % 关指定文件,成功返回 0,失败返回 -1
closeresult = fclose('all');   % 关掉所有打开的文件

完整的「标准骨架」长这样,建议形成肌肉记忆:

fid = fopen('filename', 'r');
if fid == -1
    disp('File open not successful')
else
    % ... 读写文件 ...
    closeresult = fclose(fid);
    if closeresult == 0
        disp('File close successful')
    else
        disp('File close not successful')
    end
end

常见坑:打开后不关闭

忘了 fclose 的后果和 C 里一样严重:文件句柄泄漏,写入的数据可能还留在缓冲区没落盘(甚至损坏文件)。MATLAB 退出时通常会帮你收尾,但**「谁打开谁关闭」是底线**。另外用 'w' 打开一个已存在的文件会清空它的全部内容,想要保留原内容往末尾加数据,必须用 'a' —— 这是本章第一大坑。

交互式容错:让用户输入文件名

实际程序里,文件名常常是运行时由用户敲进来的,拼错是家常便饭。标准写法是把 fopen 放进循环里反复重试,直到成功为止:

% 循环提示,直到拿到一个能打开的文件
filename = input('请输入文件名: ', 's');
fid = fopen(filename);
while fid == -1
    fprintf('打不开 %s,请检查拼写和路径\n', filename);
    filename = input('请重新输入文件名: ', 's');
    fid = fopen(filename);
end
% ... 到这里才安全,可以放心读文件

这个模式回答了一个常见疑问:「为什么打开一定要检查、读的时候却不用?」因为 fopen 失败只是「拿不到句柄」,返回 -1 你还有机会挽救(让用户重输);而拿着 -1 去读,程序只会以更难理解的方式崩掉。在错误发生的第一现场处理它,是文件 I/O 编程的基本素养。

9.4 逐行读取:fgetl

fgetl(fid) 每次从文件读一行进一个字符串(自动去掉行尾换行符);它的兄弟 fgets 会保留换行符。因为一次只读一行,必然放进循环里,循环条件用 feof(fid) —— 到达文件末尾(end of file)时返回逻辑真:

while ~feof(fid)     % "还没到文件尾" 就继续读
    aline = fgetl(fid);
    % 用字符串函数拆解 aline、处理数据
end

实战:数据文件 subjexp.dat 每行是「一个数 + 空格 + 一个字符」,load 对它无能为力:

5.3 a
2.2 b
3.3 a
4.4 a
1.1 b

fgetl 逐行读、strtok 拆分、str2double 转数值:

% fileex.m —— fgetl 逐行读取示例
fid = fopen('subjexp.dat');
if fid == -1
    disp('File open not successful')
else
    while feof(fid) == 0
        aline = fgetl(fid);                    % 读一行字符串
        [num, charcode] = strtok(aline);       % 按空格拆成两段
        fprintf('%.2f %s\n', str2double(num), charcode)
    end
    closeresult = fclose(fid);
    if closeresult == 0
        disp('File close successful')
    end
end

运行结果:

>> fileex
5.30 a
2.20 b
3.30 a
4.40 a
1.10 b
File close successful

strtok 返回的两段都还是字符串,要参与计算必须先 str2double 转成 double —— 这一步初学者最容易漏。

顺带一提 fgetlfgets 的唯一区别:fgets 会把行尾的换行符一起读进字符串,fgetl 则把它剥掉。处理数据一般用 fgetl 省心;但有些场合(比如原样复制文件、统计行长度)反而需要保留换行符。

还有一个高频实用模式:文件第一行是标题,后面才是数据。比如 vendorcust.dat

Vendor Customer
Acme XYZ
Tulip2you Flowers4me

处理办法很简单 —— 先用 fgetl 把标题行「吃掉」,再交给 textscan 读正文:

fid = fopen('vendorcust.dat');
if fid == -1
    error('File open not successful');
end
fgetl(fid);                              % 跳过标题行
data = textscan(fid, '%s %s');
fclose(fid);

fgetl 读到的那行标题没接变量,直接丢弃 —— 这个「读一行扔一行」的技巧在处理带表头的 CSV、日志文件时天天用。

为什么「逐行」比「整块」更灵活?

逐行读取给了你对每一行的完全控制权:格式不一致的行可以单独处理,注释行可以跳过,坏数据可以容错。代价是代码长、要自己拆字符串。如果文件格式规整,后面讲的 fscanf/textscan 一条语句就能读完整个文件。取舍原则:格式越乱,越值得逐行。另外读文件务必用 while ~feof 这种条件循环,别用 for —— 因为你通常不知道文件有多少行。

9.5 写文件与追加:fprintf

你已经用了一整本书的 fprintf 其实就是文件输出函数!只是没给 fid 时默认写到屏幕(fid=1)。给它一个以 'w''a' 打开的文件 fid,输出就落到文件里:

>> fid = fopen('tryit.txt', 'w');
>> for i = 1:3
      fprintf(fid, 'The loop variable is %d\n', i);
   end
>> fclose(fid);

写矩阵时有一个反直觉的细节:fprintf按列消费矩阵的。下面的 %d %d\n 格式串会把 2×4 矩阵写成 4 行、每行 2 个数 —— 相当于把矩阵转置了:

>> mat = [20 14 19 12; 8 12 17 5]
>> fid = fopen('randmat.dat', 'w');
>> fprintf(fid, '%d %d\n', mat);
>> fclose(fid);
>> type randmat.dat
20 8
14 12
19 17
12 5

读回来时 load 会得到 4×2 矩阵,转置一下就还原了。想直接写出原形状,写之前先 mat' 转置即可。

追加模式把数据写到文件末尾,原内容保留:

fid = fopen('filename', 'a');   % 'a' = append,绝不丢原有数据

常见坑:该用 'a' 时用了 'w'

想给日志文件追加第 100 条记录,结果 'w' 一开、前 99 条全没了,且无法恢复。写代码前先问自己:这个文件是要「重写」还是「续写」。

9.6 整块读取:fscanf 与 textscan

这两个函数介于 loadfgetl 之间:文件仍需 fopen/fclose,但不需要循环,一次调用读完整个文件。

9.6.1 fscanf:读进矩阵

mat = fscanf(fid, '格式串', [维度]);

关键规则:fscanf 把文件每一行读进矩阵的一列(按列填充),格式串描述的是文件里每一行的样子。还是 subjexp.dat

>> fid = fopen('subjexp.dat');
>> mat = fscanf(fid, '%f %c', [2, inf])   % 2 行、列数自动
mat =
    5.3000    2.2000    3.3000    4.4000    1.1000
   97.0000   98.0000   97.0000   97.0000   98.0000
>> fclose(fid);
>> nums = mat(1,:);
>> charcodes = char(mat(2,:))
charcodes =
abaab

两个细节要嚼透:

  1. 矩阵不能装混合类型,所以字符被存成 ASCII 码('a'=97),要用 char 转回字符。
  2. 格式串里的空格很重要'%f %c' 匹配「数、空格、字符」;写成 '%f%c' 的话,%c 会把空格本身当字符读走(ASCII 32),后续解析全乱。此外 %f 这类数值格式会自动跳过空白(含换行符),但 %c连换行符也照读的 —— 所以在逐行格式严格的文件里,格式串末尾常常要显式写 \n

9.6.2 textscan:读进元胞数组

cellarray = textscan(fid, '格式串');

textscan 把文件的每一列读成元胞数组里的一个列向量 —— 类型天然分开,不用再从 ASCII 码转回来:

>> fid = fopen('subjexp.dat');
>> subjdata = textscan(fid, '%f %c');
>> fclose(fid);
>> subjdata
subjdata =
    [5x1 double]    [5x1 char]
>> subjdata{1}(3)        % 花括号取元胞内容,圆括号再取向量元素
ans =
    3.3000

访问要两层索引subjdata{1}(3) —— 花括号拆元胞,圆括号取向量元素。返回的向量是列向量,这也是 textscan 的固定行为。

fscanf vs textscan 怎么选?

数据能装进同一个数值矩阵(容忍 ASCII 码形式)→ fscanf;各列类型不同、想各拿各的向量 → textscan(更常用,也是 MATLAB 官方更推荐的方向)。两者共同前提:文件每行格式一致。格式不一致,回退到 9.4 的 fgetl 逐行方案。

9.7 四种读文件方法总结

用同一个例子串起全部方法。文件 xypoints.dat 存点的坐标,格式很别扭:每行是 x2.3y4.56 这样的「字符夹数字」:

x2.3y4.56
x7.7y11.11
x12.5y5.5

目标是把 x、y 坐标各抽成一个向量用于绘图。四种写法:

% 方法一:fgetl 逐行 + 字符串拆解(最通用,代码最多)
x = []; y = [];
while ~feof(fid)
    aline = fgetl(fid);
    aline = aline(2:end);              % 去掉开头的 'x'
    [xstr, rest] = strtok(aline, 'y'); % '2.3' 和 'y4.56'
    x = [x str2double(xstr)];
    y = [y str2double(rest(2:end))];
end
 
% 方法二:fscanf,字符也读进矩阵(注意末尾 \n 必须写!)
mat = fscanf(fid, '%c%f%c%f\n', [4, inf]);
x = mat(2,:);   y = mat(4,:);
 
% 方法三:fscanf,把已知的 'x' 'y' 直接写进格式串(不读入矩阵)
mat = fscanf(fid, 'x%fy%f\n', [2, inf]);
x = mat(1,:);   y = mat(2,:);
 
% 方法四:textscan,格式串描述「列」的样子,无需 \n
xydat = textscan(fid, 'x%fy%f');
x = xydat{1};   y = xydat{2};
 
plot(x, y, 'k*')    % 四种方法之后都是同样的绘图收尾

对比着看就很清楚了:fgetl 是「逐行苦力但万能」,fscanf 是「一张矩阵打天下但字符变 ASCII」,textscan 是「按列分家、格式串写起来最舒服」。此外还有两个本文没展开的工具值得知道:dlmread/dlmwrite 读写任意分隔符的 ASCII 文件;MATLAB 的 Import Tool(导入工具)则在图形界面下应付各种格式。

方法需 fopen/fclose需循环读进什么适用场景
load数值矩阵每行格式统一、纯数字
fgetl字符串(每行一个)格式乱、需要精细控制
fscanf矩阵(字符→ASCII)每行格式统一,列数未知可用 inf
textscan元胞数组(按列分向量)每行格式统一、混合类型

综合实战:把一个「脏」文件读成结构体

把本章工具串一遍。假设 patwts.dat 存病人体重,每行是「名 姓 体重」:

Darby George 166.2
Helen Dee 143.5
Giovanni Lupa 192.4
Cat Donovan 215.1

每行有三个字段且含文字,load 出局。行格式统一,textscan 一网打尽;再配合循环打印:

% readpatwts.m —— textscan 读混合文件并统计
fid = fopen('patwts.dat');
if fid == -1
    error('File open not successful');
end
% 格式串描述「每行的列」:%s 名 %s 姓 %f 体重
data = textscan(fid, '%s %s %f');
fclose(fid);                            % 读完了就立刻关,别拖到函数末尾
 
first = data{1};  last = data{2};  wts = data{3};
n = length(wts);
for i = 1:n
    fprintf('%s, %s %6.1f\n', last{i}, first{i}, wts(i));
end
fprintf('The average weight is %.2f\n', sum(wts)/n);

这段代码的骨架值得背下来:fopen 检查 → textscan 一次读完 → 立刻 fclose → 对内存里的向量做统计。文件 I/O 和数据处理被干净地分成两段,出错时容易定位。

先 fclose 还是最后 fclose?

数据一次性读完的场景,读完就关(如上例),句柄最短时间打开。而 fgetl 逐行处理的场景,循环读的过程中文件必须保持打开,只能在循环结束后关。原则:文件保持打开的时间越短越好

9.8 进阶:二进制读写与文件定位

前面所有函数处理的都是文本文件:数据以人眼可读的字符形式存放,5.3 占 3 个字节。另一条路线是二进制文件:数据按内存中的原始格式直接落盘 —— 一个 double 固定 8 字节,不管数值是 0.5 还是 1e300。两者取舍:

文本文件二进制文件
可读性记事本直接能看乱码,需程序解读
文件体积大(每个字符占 1 字节)小(固定字节数)
读写速度慢(要做格式转换)快(原样搬字节)
随机访问麻烦(行长不等)容易(每元素定长,偏移可直接算)

二进制路线的四件套:

fid = fopen('bin.dat', 'w');
fwrite(fid, [1.5 2.5 3.5], 'double');   % 把矩阵按 double 原样写入
fclose(fid);
 
fid = fopen('bin.dat', 'r');
x = fread(fid, 3, 'double')';           % 按 double 读回 3 个值
frewind(fid);                           % 位置指示器拨回文件头
fseek(fid, 8, 'bof');                   % 或精确跳到第 8 字节(第 2 个 double)
fclose(fid);
  • fwrite(fid, 数据, 精度) / fread(fid, 数量, 精度):按指定精度('double''uint8''int32' 等)成块搬运数据,fread 返回列向量
  • fseek(fid, 偏移, 起点):把位置指示器跳到任意字节(起点 'bof' 文件头 / 'cof' 当前 / 'eof' 文件尾),frewind 是跳回头部的快捷方式。
  • 同时读和写同一个文件,权限字符串要加加号:'r+''w+'

给有 C 经验的读者一个忠告:MATLAB 的 fwrite/fread 用「精度字符串」代替了 C 的指针强制转换,读写两端的精度必须配对,一边写 'double' 一边按 'single' 读,出来的就是乱数 —— 这相当于 C 里类型双关(type punning)的事故,MATLAB 同样会发生。

选型口诀

给人看的报告、配置文件、和其他程序交换的数据 → 文本文件(本章 9.3~9.7);大规模数值中间结果、需要随机访问、体积敏感 → 二进制(fread/fwrite)或直接 MAT 文件(save/load,本质也是二进制且额外存了变量名)。

本章通关标准:

  1. 能默写「fopen → 检查 fid==-1 → 读写 → fclose → 检查返回值」标准骨架,并说清 fid 与 C 语言 FILE*、标准输入输出 0/1/2 的对应关系。
  2. 面对一个「每行数字+字符」的文件,能用 fgetl+strtok 和 textscan 两种方法分别写出读取代码,且不查书。
  3. 能准确回答:'w''a' 的区别?fscanf 为什么按列填充?格式串里 %c 前的空格为什么不能丢?