ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MATLAB解析Comtrade录波文件:cfg与dat格式读取全攻略

MATLAB解析Comtrade录波文件:cfg与dat格式读取全攻略 简介本资源是一套面向电力系统工程师、高校研究人员及自动化专业学生的MATLAB实用工具脚本专为高效解析IEEE Std C37.111-1991标准的COMTRADE暂态录波文件而设计解决故障分析、设备校验与数据可视化中原始录波数据读取困难的问题。压缩包共含2个文件1个说明文档txt 1个核心函数m文件总大小仅2KB轻量简洁其中read_comtrade.m实现完整的头部信息解析采样率、通道名、时间戳等与二进制/ASCII数据段解码txt文件提供关键参数说明与调用示例便于快速集成到故障诊断、谐波分析或保护算法验证流程中。已有74人学习下载适用于具备基础MATLAB编程能力及电力系统背景的中级用户开箱即用无需额外工具箱可直接加载电压电流波形并支持后续时频分析与绘图。 做电力系统故障分析的人几乎没有谁绕得过Comtrade格式。无论是故障录波器抓下来的暂态波形还是继保测试仪导出的模拟量、开关量最后交付到我们手里的往往就是一套以.cfg和.dat为核心的文件包。以前我在现场处理这类数据最头疼的就是手里没有趁手的解析工具要么打开厂家配套软件被绑定在Windows上位机上要么手动复制数据到Excel里几万个采样点分分钟把电脑卡死。后来我基于MATLAB把这套读取Comtrade文件的源码完整写了出来专门处理各种版本、各种变体的录波文件直接读通道、读时间轴、换算一次二次值两三秒钟就能把整包数据装进工作区。这篇文章就把这套源码的思路、实现细节和踩过的坑全部摊开讲适合需要批量处理录波数据、做故障反演或者做二次开发的同学参考。1. 先把Comtrade文件解剖一遍再动手写代码很多人拿到.cfg和.dat文件第一反应就是直接写脚本fopen然后textscan。但如果你不了解Comtrade格式的设计逻辑后面做版本适配、通道换算的时候一定会被折腾到崩溃。这章我们从文件结构开始把它的底细摸清楚。1.1 一套Comtrade文件到底由哪几个文件组成Comtrade标准其实定义四个配套文件.cfg、.dat、.hdr、.inf。其中.cfg是配置文件描述通道信息、采样率、变比、时间戳等元数据.dat是数据文件存放实际采样值.hdr是头文件大部分厂家用它写录波说明、故障类型、装置名称这些信息读取数据时一般不依赖它.inf是信息文件在故障录波联网系统的文件传输中使用较多。大部分第三方设备导出时只给.cfg和.dat两个文件就够用了这也是我这套源码的主入口。不过源码里保留了.hdr的读取入口方便后续扩展。打开一个标准的.cfg文件你会看到它本质是逗号分隔的文本文件行与行之间的顺序有严格讲究。第一行通常是站名、设备ID、标准版本年份第二行是模拟通道总数、状态通道总数等数字接下来若干行逐一描述每个模拟通道再往下是数字通道描述然后是采样率信息最后是录波开始时间和触发时间。也就是说先读配置再去解析数据是唯一正确的打开方式。1.2 配置文件.cfg里到底存了什么以IEEE标准为例一个1999版本的.cfg文件大致长这样STATION_NAME,DEVICE_ID,1999 12,10A,1A 1,IA,A,I,A,1,0,0,0,0,1,1,P 2,IB,A,I,A,1,0,0,0,0,1,1,P 3,IC,A,I,A,1,0,0,0,0,1,1,P 4,UA,A,V,V,0.001,0,0,0,0,1,1,P ... 0,Prot,B,P,1 1,Remote,B,P,1 1,1000,2000 2024-01-01,00:00:00.123456 2024-01-01,00:00:00.100000 50逐行拆解一下第1行逗号分隔的3个字段分别是站名、设备ID、修订年份。有些老设备只有前两个字段这时年份字段缺省需要按默认值处理。第2行三个数字依次是总通道数模拟加状态、模拟通道数、状态通道数。模拟通道每行字段较多通道序号、通道名、相别、被测量类型电流/电压/功率等、单位、变比系数a、b、偏移量a、b、时间偏斜、最小值、最大值、一次值、二次值、PS标记。这里最关键的字段就是a和b因为原始采样值需要按公式实际值采样值*ab换算成一次值或工程值。状态通道行相对简单通道序号、通道名、相别、状态类型、有效状态y。状态通道保存的是开关量一般是0或1。第N行采样率组数。一个录波文件可能包含多段采样率比如故障前采样率4000Hz故障后采样率12800Hz。这个信息在后面时间轴装配时必须处理。再往下依次是录波开始日期时间、触发日期时间。最后是系统频率一般是50Hz或60Hz。看完这个结构你会发现解析.cfg文件的本质就是按行分类解析。每一行的格式是通过行号和内容特征判断的不能靠固定列数硬读。1.3 数据文件.dat的两种形态.dat文件有两种存储形态ASCII文本格式和二进制格式。ASCII格式最简单每行格式是1,0,123,456,0,0 2,0,125,458,0,1第一个数字是采样序号第二个数字是时间戳微秒之后依次是每个通道的采样值数字通道就是0或1。这种格式优点是直观任何文本编辑器都能打开缺点是文件体积大几万行数据跑下来几百MB很正常。二进制格式则紧凑很多1999标准里多采用IEEE单精度浮点或16位整数表示采样值。二进制文件没有固定行结构而是按照.cfg里描述的通道顺序连续存放读取时要用fread一次读入一块结构体。早期我做兼容时发现不同厂家的二进制格式在字节序大端/小端上还不太一样这一块必须做成可配置项。2. 为什么用MATLAB做这件事可能有人会问现在Python这么火为什么还要用MATLAB处理Comtrade这里我必须替MATLAB说几句公道话。不是Python不行而是滤波算法、暂态分析、Simulink仿真这些电力领域的生态在MATLAB里实在是太顺了。2.1 工业现场的选择逻辑电力系统二次侧研究无论是短路电流计算还是保护动作行为分析很多工程人员工作流本身就是“读录波→滤波→特征量提取→对比保护逻辑”。MATLAB自带Signal Processing Toolbox和Simulink处理录波数据时可以无缝衔接。另外很多继保测试仪厂家给出的样例脚本就是MATLAB/Octave写的用MATLAB解析Comtrade后续对接厂家Demo的成本最低。当然Python也有comtrade开源库但它基本只做数据解析后续做频谱分析、暂态特征提取还得自己搭框架。MATLAB里读进去之后直接变成矩阵用惯了MATLAB的人会觉得格外顺手。2.2 读取方案的总体设计代码整体分三层read_comtrade_main.m入口函数传入cfg和dat路径返回统一结构体。parse_cfg.m负责逐行解析配置文件输出通道信息、变比、采样率、时间信息等。parse_dat.m根据配置文件解析数据文件输出原始采样矩阵。结构体定义如下ComtradeData struct(... Station, , ... % 站名 DeviceID, , ... % 设备ID Standard, 1999, ... % 标准年份 AnalogChannels, {}, ... % 模拟通道名称 DigitalChannels, {}, ... % 数字通道名称 AnalogData, [], ... % 模拟数据矩阵行采样点列通道 DigitalData, [], ... % 数字数据矩阵 Time, [], ... % 时间轴单位秒 SampleRate, [], ... % 采样率可能是数组 StartTime, , ... % 录波开始时间 TriggerTime, ); % 触发时间后续所有处理比如画波形、求有效值、判断开关变位都基于这个统一结构体模块之间不用再传递一堆零散的变量。2.3 版本兼容怎么处理最省事Comtrade标准从1991年发布1999年更新2013年又引入了一堆高速采样和XML扩展字段。如果代码只面向一种版本很快会被现场五花八门的设备数据恶心到。但做兼容也不能上来就全支持我的策略是优先支持1999版本因为目前国内主流故障录波器和继保测试仪基本都兼容1999。1991版本差异并不大主要是字符字段没有引号、日期格式是MM/DD/YYYY只要在解析函数里判断年份关键字并走不同分支即可。2013版本文件头可能包含I或II通道类型字段还增加了采样率标签字段解析时不能漏掉。代码里统一用parseStandardField判断年份再根据年份选用对应的行解析规则。这个思路比针对每个设备写独立脚本要省心得多。3. 核心源码实现从配置文件解析到数据装配这章是重点我把源码拆开逐段讲。你拿到手之后不需要做太多修改只要保证MATLAB环境在R2016b以上把函数文件放到同一目录就能直接跑。3.1 解析配置文件parse_cfg函数先看主入口函数read_comtrade_mainfunction cdata read_comtrade_main(cfg_path, dat_path) % 读取Comtrade文件的主函数 % 输入: cfg_path - 配置文件路径如 xx.CFG % dat_path - 数据文件路径如 xx.DAT % 输出: cdata - 统一结构体 % 解析cfg文件 cfg parse_cfg(cfg_path); % 解析dat文件 dat parse_dat(dat_path, cfg); % 装配通道与时间轴 cdata assemble_data(cfg, dat); endparse_cfg函数是核心逐步读取每一行并使用strsplit拆分字段。我在这里使用了strtrim清理字段两边的空格和引号避免带引号的通道名称干扰参与后续处理。function cfg parse_cfg(cfg_path) fid fopen(cfg_path, r); if fid -1 error(无法打开配置文件: %s, cfg_path); end % 第一行站名、设备ID、版本年份 line1 strtrim(fgetl(fid)); parts1 strsplit(line1, ,); cfg.Station strtrim(parts1{1}); cfg.DeviceID strtrim(parts1{2}); if length(parts1) 3 cfg.Standard str2double(strtrim(parts1{3})); % 有些文件只有两位年份比如99需要补齐 if cfg.Standard 100 cfg.Standard cfg.Standard 1900; end else cfg.Standard 1991; end % 第二行通道数量 line2 strtrim(fgetl(fid)); parts2 strsplit(line2, ,); totalCh str2double(parts2{1}); analogCh str2double(parts2{2}); digitalCh str2double(parts2{3}); cfg.TotalChannels totalCh; cfg.AnalogCount analogCh; cfg.DigitalCount digitalCh; % 第三行起模拟通道 analogList cell(analogCh, 1); for k 1:analogCh line strtrim(fgetl(fid)); % 1999版本及以后字段用引号包裹但要做好没引号的兼容 parts parseConfigLine(line); analogList{k} struct(... Index, str2double(parts{1}), ... Name, strtrim(parts{2}), ... Phase, strtrim(parts{3}), ... Type, strtrim(parts{4}), ... Unit, strtrim(parts{5}), ... a, str2double(parts{6}), ... b, str2double(parts{7}), ... Skew, str2double(parts{8}), ... Min, str2double(parts{9}), ... Max, str2double(parts{10})); end cfg.AnalogList analogList; % 数字通道 digitalList cell(digitalCh, 1); for k 1:digitalCh line strtrim(fgetl(fid)); parts parseConfigLine(line); digitalList{k} struct(... Index, str2double(parts{1}), ... Name, strtrim(parts{2}), ... Phase, strtrim(parts{3}), ... Type, strtrim(parts{4})); end cfg.DigitalList digitalList; % 采样率信息 line strtrim(fgetl(fid)); parts strsplit(line, ,); rateGroups str2double(parts{1]); sampleRates zeros(rateGroups, 1); endSamples zeros(rateGroups, 1); for k 1:rateGroups line strtrim(fgetl(fid)); parts strsplit(line, ,); sampleRates(k) str2double(parts{1}); endSamples(k) str2double(parts{2}); end cfg.SampleRates sampleRates; cfg.EndSamples endSamples; % 开始时间与触发时间 startLine strtrim(fgetl(fid)); triggerLine strtrim(fgetl(fid)); cfg.StartTime parseComtradeTime(startLine); cfg.TriggerTime parseComtradeTime(triggerLine); % 最后一行是频率部分文件没有 freqLine fgetl(fid); if ischar(freqLine) ~isempty(strtrim(freqLine)) cfg.Frequency str2double(strtrim(freqLine)); else cfg.Frequency 50; end fclose(fid); end注意parseConfigLine这个辅助函数它要处理带引号和不带引号两种情况。我用它统一把一行拆成单元格数组function parts parseConfigLine(line) line strtrim(line); % 1999标准中的字符串字段带引号去引号更安全 parts strsplit(line, ,); for i 1:length(parts) parts{i} strrep(strtrim(parts{i}), , ); end end3.2 处理ASCII格式的数据文件ASCII格式的数据文件解析相对简单。使用textscan一次性读取避免逐行循环造成性能瓶颈。但数据文件很大不能无脑全部读入内存我加了一个按块读取的选项。这里先展示最常用的完整读取方案function dat parse_dat_ascii(dat_path, cfg) fid fopen(dat_path, r); if fid -1 error(无法打开数据文件: %s, dat_path); end % 数据行格式序号,时间戳,模拟通道值...,数字通道值... % 用textscan读取全部行但需要动态决定列数 firstLine strtrim(fgetl(fid)); fseek(fid, 0, bof); nCols 2 cfg.AnalogCount cfg.DigitalCount; formatSpec repmat(%f, 1, nCols); dataRaw textscan(fid, formatSpec, Delimiter, ,); fclose(fid); % 第一列是采样序号 recordIndex dataRaw{1}; % 第二列是时间戳单位微秒 timestampUs dataRaw{2}; % 组装模拟量矩阵和数字量矩阵 analogData zeros(length(recordIndex), cfg.AnalogCount); for k 1:cfg.AnalogCount analogData(:, k) dataRaw{2 k}; end digitalData zeros(length(recordIndex), cfg.DigitalCount); for k 1:cfg.DigitalCount digitalData(:, k) dataRaw{2 cfg.AnalogCount k}; end dat.RecordIndex recordIndex; dat.TimestampUs timestampUs; dat.AnalogData analogData; dat.DigitalData digitalData; end有两点特别说明。第一textscan处理大文件时用%f可以保证读取精度第二有的ASCII数据文件内容中间会混入空行或者注释行这在非标厂家文件里偶尔出现。所以我后来又加了一段预处理逻辑遇到非数字开头的行就跳过避免textscan报错% 在调用textscan前先跳过非数据行 while ~feof(fid) pos ftell(fid); line strtrim(fgetl(fid)); if isempty(line) || ~isempty(regexp(line, ^-?\d, once)) fseek(fid, pos, bof); break; end end3.3 处理二进制格式的数据文件二进制格式要复杂一点。核心思路是先根据.cfg中定义的通道数量计算出每个采样点需要读取的字节数再循环读取。二进制文件通常采用IEEE单精度浮点4字节保存模拟值数字通道则按位打包在一个整数里不同厂家的打包方式略有区别。这里我给出最常见的实现function dat parse_dat_binary(dat_path, cfg, endianType) % endianType: ieee-le 小端ieee-be 大端 if nargin 3 endianType ieee-le; end fid fopen(dat_path, r, endianType); if fid -1 error(无法打开数据文件: %s, dat_path); end % 假设每条记录: 4字节采样序号(uint32) 4字节时间戳(uint32) % N个模拟量通道(每通道4字节float) % 数字量按字节打包 analogCount cfg.AnalogCount; digitalCount cfg.DigitalCount; % 数字量通道按字节向上取整 digitalBytes ceil(digitalCount / 8); nSample 0; % 先统计文件大小估算样本数 fseek(fid, 0, eof); fileSize ftell(fid); fseek(fid, 0, bof); recordLen 4 4 analogCount * 4 digitalBytes; nSample floor(fileSize / recordLen); recordIndex zeros(nSample, 1); timestampUs zeros(nSample, 1); analogData zeros(nSample, analogCount); digitalData zeros(nSample, digitalCount); for i 1:nSample recordIndex(i) fread(fid, 1, uint32); timestampUs(i) fread(fid, 1, uint32); if analogCount 0 analogData(i, :) fread(fid, analogCount, float32); end if digitalBytes 0 byteVal fread(fid, digitalBytes, uint8); % 将字节按位展开为数字通道 for b 1:digitalBytes for bit 0:7 idx (b - 1) * 8 bit 1; if idx digitalCount digitalData(i, idx) bitand(byteVal(b), 2^bit) 0; end end end end end fclose(fid); dat.RecordIndex recordIndex; dat.TimestampUs timestampUs; dat.AnalogData analogData; dat.DigitalData digitalData; end二进制读取这里有几个容易栽的细节一是采样序号和时间戳如果没有按照头字段布局可能与文件实际结构不符二是有些厂家会用uint16保存时间戳而不是标准的uint32三是数字量打包顺序有正反两种最好加一个参数让用户切换。我的代码里在函数入口增加digitalLsbFirst参数默认为true遇到反序时改为false即可。3.4 时间轴的装配与通道数值换算时间轴是整个数据结构里最关键的一环。ASCII格式里时间戳单位是微秒从录波开始时刻为零点所以最简单的时间轴就是timestampUs/1e6。但遇到多段采样率时时间轴就不再是严格等间隔需要按采样段拼装。我写的assemble_data函数会处理这个问题function cdata assemble_data(cfg, dat) cdata struct(); cdata.Station cfg.Station; cdata.DeviceID cfg.DeviceID; cdata.Standard cfg.Standard; nPoints length(dat.RecordIndex); % 时间轴优先使用数据文件自带时间戳 if isfield(dat, TimestampUs) ~isempty(dat.TimestampUs) cdata.Time double(dat.TimestampUs(:)) / 1e6; else % 根据采样率生成时间轴 t zeros(nPoints, 1); pos 1; for k 1:length(cfg.SampleRates) fs cfg.SampleRates(k); endSamples cfg.EndSamples(k); count endSamples - pos 1; if count 0 count nPoints - pos 1; end dt 1 / fs; t(pos:pos count - 1) (0:count - 1) * dt; pos pos count; if pos nPoints break; end end cdata.Time t; end % 模拟量通道换算为一次值公式: 实际值 采样值 * a b cdata.AnalogName cell(cfg.AnalogCount, 1); rawAnalog dat.AnalogData; cdata.AnalogData zeros(size(rawAnalog)); for k 1:cfg.AnalogCount cdata.AnalogName{k} cfg.AnalogList{k}.Name; a cfg.AnalogList{k}.a; b cfg.AnalogList{k}.b; cdata.AnalogData(:, k) rawAnalog(:, k) * a b; end % 数字量通道 cdata.DigitalName cell(cfg.DigitalCount, 1); cdata.DigitalData dat.DigitalData; for k 1:cfg.DigitalCount cdata.DigitalName{k} cfg.DigitalList{k}.Name; end cdata.Frequency cfg.Frequency; cdata.StartTime cfg.StartTime; cdata.TriggerTime cfg.TriggerTime; end这里的变比换算值得多说一句。很多录波器在.cfg里已经把二次侧采样值存成了原始编码不乘变比的话你画出来的波形幅值会完全不对。但反过来如果.cfg里的a、b本身就是1和0说明文件内已经保存的是一次侧实际值这时乘1加0不会有影响。所以统一乘a加b的方案是通用且安全的。3.5 调用入口与绘图验证把上面的函数保存好之后调用非常简单cdata read_comtrade_main(demo.CFG, demo.DAT); figure; plot(cdata.Time, cdata.AnalogData(:, 1)); xlabel(时间 (s)); ylabel(幅值); title(cdata.AnalogName{1}); grid on;如果你想同时查看多个通道可以用plot加subplot或者用stackedplot直接画叠加波形stackedplot(cdata.Time, cdata.AnalogData);如果是数字量部分用户习惯直接画阶梯波形用stairs比plot更真实stairs(cdata.Time, cdata.DigitalData(:, 1));4. 实际使用中遇到的坑和排查方法我在真实项目里用这套源码处理过上千个录波文件遇到过各种乱七八糟的格式。下面这五个问题出现频率最高几乎每个新接触Comtrade的人都会踩其中一个。4.1 现象一解析第一行就报错这种情况通常不是代码问题而是文件开头带有不可见字符。有些国产录波器导出的.cfg文件会在开头加上BOM字节顺序标记MATLAB的fgetl读进来后第一行会出现一个\ufeff字符导致strsplit得到的字段数量和预期不符。排查方法很简单在parse_cfg函数最开始把第一行读取后用strrep去掉BOMline1 strtrim(fgetl(fid)); line1 strrep(line1, char(65279), ); % 移除UTF-8 BOM这个坑非常隐蔽我第一次遇到时排查了将近半小时。4.2 现象二数值整体偏移与现场波形对不上如果你画出来的波形幅值明显偏大或偏小或者带有固定直流偏移大概率是a和b没有正确使用。有一次我从一台老型号录波器导出数据发现所有电压通道都多了一个1600的偏移量后来在.cfg里看到b1600才明白这台设备把ADC编码的零点偏置也写进了配置里。按照公式换算后一切正常。另外还要留意a和b的顺序。1991版本里部分设备把二次值写在a里、一次值在b里但意义与1999版本相反。我的代码里没有强行改顺序而是按照配置文件里实际写入的顺序原样读取再由使用者根据设备文档确认。assemble_data中默认认为a是乘法系数、b是加法偏移遇到异常波形时先检查这个。4.3 现象三采样率变化了怎么办很多录波文件在触发前和触发后采样率不同比如触发前4000Hz、触发后12800Hz。如果你用一个固定dt生成时间轴波形后半段会明显变形。这套源码是根据.cfg中的采样率组数和结束采样点来逐段拼接时间轴的。需要注意的是某些设备并不会准确写入EndSamples而是写0或者写一个很大的近似值。遇到这种情况我建议采用数据文件自带的时间戳不要用采样率推算。所以在assemble_data里我优先使用TimestampUs字段。只要.dat文件里时间戳可靠就永远不会出现时间轴错位的问题。4.4 现象四时间轴显示成1970年用datetime(cdata.StartTime)转换时间字符串结果发现年份变成1970这通常是因为日期格式不是MATLAB默认的ISO格式而是美式MM/DD/YYYY或者不带年份的格式。Comtrade 1999标准用的是YYYY/MM/DD HH:MM:SS.ffffff但不少1991老设备用的是MM/DD/YYYY。我在parseComtradeTime函数里做了两种格式的自动判断function t parseComtradeTime(timeStr) timeStr strtrim(timeStr); % 尝试ISO格式 t datetime(timeStr, InputFormat, yyyy-MM-dd HH:mm:ss.SSSSSS); if isnat(t) % 尝试美式格式 t datetime(timeStr, InputFormat, MM/dd/yyyy HH:mm:ss.SSSSSS); end end这个函数虽然不长但能解决90%的日期解析问题。剩下的10%是时间字符串里没有小数秒或者有T分隔符需要再加几个InputFormat分支。4.5 现象五通道数量特别多读取很慢大型录波器可能会有上百个模拟通道和几百个数字通道数据文件动辄几GB。如果还用textscan一次性读入内存直接吃满。这种情况下我建议做分块读取blockSize 100000; % 每块10万行 dataBlock textscan(fid, formatSpec, blockSize, Delimiter, ,);读完一块解析一块最后再拼接到总矩阵里。MATLAB的循环在R2016b之后经过JIT优化分块读取的性能损失可以接受。不过更推荐的做法是如果数据量实在太大直接用二进制格式存储转换后的结果后续处理全部走二进制路径。5. 后续可以怎么扩展这套源码这套源码只是读取部分但它已经帮你把最难啃的解析环节解决了。基于这套读取结果可以做不少有价值的扩展。5.1 批量处理多个录波文件现场做故障分析时往往需要连续处理几十个录波文件。写个简单的批量脚本files dir(data/*.CFG); for k 1:length(files) [~, name, ~] fileparts(files(k).name); cfgFile fullfile(files(k).folder, files(k).name); datFile fullfile(files(k).folder, [name .DAT]); cdata read_comtrade_main(cfgFile, datFile); save([name .mat], cdata); end把每个文件存成.mat后续分析就不需要反复解析原始文件了。这个操作在提速方面的收益非常明显。5.2 故障分析自动识别暂态起始时刻读取数据之后结合简单的滑窗算法可以自动找故障发生时刻。比如计算每半个周波内的有效值当有效值突变超过阈值时就认为是扰动起始点。基于Comtrade数据做这种分析比手工拖动光标要高效得多。5.3 与Simulink/其他工具的联动读取后的数据可以直接作为Simulink中From Workspace模块的输入用于复现故障波形、验证保护逻辑。也可以导出成CSV给其他团队做数据交换。这套源码因为数据结构是标准结构体导出扩展非常方便。我在实际使用中最大的体会是解析Comtrade文件最麻烦的从来不是读数据本身而是它对格式细节要求极其严格。版本差异、变比换算、采样率分段、时间格式每一个细节都可能让整个流程翻车。这套源码把这些细节都处理好了你拿到手之后唯一要做的事情就是把心思放在后续的波形分析和故障研判上。最后再分享一个小技巧如果你手头有不确定格式的录波文件建议先用fopen加fgetl手动看前五行内容花一分钟确认文件类型再跑完整脚本能省下不少Debug时间。本文还有配套的精品资源点击获取
返回列表