☰
用Matlab解析ASC文件:CAN报文文本到物理信号的转换工具
2026/10/16 12:02:31 网站建设 项目流程

简介:面向汽车电子与CAN总线数据分析人员,这份Matlab 2020a源码工具用于批量解析asc格式的CAN总线日志,按用户设定的信号名称自动筛选,并将结果导出为CSV文件,适合需要快速处理多个log文件、提取指定DBC信号做后续分析的用户。压缩包共10个文件,主要为7个Matlab .m脚本和2个Perl .pl辅助程序,外加1个config.txt配置样例;借助Matlab与Perl混合编程显著提升了解析效率,整体仅9KB,结构紧凑、易于直接套用。包内提供源码级实现与关键注释,支持同时解析0.asc、1.asc等多份日志,用户只需在config.txt中维护CAN信号名称列表(需与DBC定义一致),再运行AnalysisTask.m即可完成全流程,省去手工逐条过滤的繁琐工作。该资源已有2546人学习下载,对刚接触asc日志解析或希望提高车载数据处理效率的工程师具有一定参考价值。

1. 一个啃 ASC 日志的 Matlab 脚本:让 CAN 信号从文本里“跳”出来

上个月我从测试台架拷回一个 470MB 的 asc 文件,那是整车 CAN 总线上半天的记录。用文本编辑器打开,满屏都是时间戳 CAN1 Tx 123 [8] 00 11 22 ...这种十六进制串,想找某个报文里“车速信号”的变化趋势,靠肉眼翻了几十分钟就放弃了。后来我写了这个 Matlab(2020a)脚本:它逐行扫描 asc 文件,把时间、方向、CAN ID、DLC、数据字节全部解析成结构体,再配合一份信号定义表,把字节直接算成物理值。整个过程不用开 Excel,不用装 Python,一条命令就出结果。适合做总线测试、台架标定日志分析,以及想学着用 Matlab 做文本解析与 CAN 信号处理的人。

2. 技术选型与文件结构:为什么用 Matlab 而不用 Python,入口函数怎么安排

2.1 先看懂 ASC 文件:一帧 CAN 报文在文本里长什么样

ASC 文件是一种纯文本的 CAN 总线记录格式,通常由汽车测试领域的上位机软件导出。它不像 Excel 那样有规整的列,而是每行一个文件头或一帧报文。一个典型的 ASC 文件开头长这样:

date TUE MAY 17 08:30:00 2024 base hex timestamps absolute no internal events logged ; This is a comment line 0.000000 CAN1 Tx 123 [8] 00 11 22 33 44 55 66 77 0.001000 CAN1 Rx 456 [8] 80 01 02 03 04 05 06 07 0.002500 CAN2 Tx 789 [2] A0 B1

把这段文本拆开看,每一行报文的结构其实很固定:

  • 前导空格后是绝对时间戳,单位是秒,浮点数;
  • 然后是总线名,比如CAN1、CAN2,也可能带数字编号;
  • 后面是方向标识Tx(发送)、Rx(接收),有的导出格式还会出现TxR、RxR代表远程帧或带错误标记;
  • 再往后是十六进制的 CAN ID,经典 CAN 一般 3 位十六进制,扩展帧可能是 8 位;
  • 接着是方括号内的 DLC,表示后面有几个数据字节;
  • 最后是空格分隔的十六进制字节,不到 DLC 对应的字节数时,常见导出工具会补00,但也有的工具直接省略。

文件头行和信息行都带date、base、no或分号开头,这一特点正好用来跳过。解析的核心就是:识别出“看起来像报文行”的文本,然后按固定分隔拆成字段。对文本处理来说,正则表达式干这个活最顺手。

这里有一个实际经验:不同工具导出的 ASC 在细节上有很多变体。有的时间戳写成整数0而不是0.000000,有的 ID 后面带x后缀表示 CAN FD 帧,有的报文数据后面还跟着 CRC 和位计数。所以脚本的实现不能只用一条正则打天下,而是先支持主流格式,再针对常见变体加容错。后面第 4 章的踩坑部分会专门讨论这些。

2.2 脚本目录结构与函数划分:一个主入口加三个支撑文件

整个资源不是一个大而全的“单文件脚本”,而是拆成了几个职责清晰的.m文件。这是我的习惯:文本解析、帧提取、信号解码三个环节分开,后续想替换解码逻辑或者扩展文件格式,不用动主循环。

asc_signal_parser/ ├── main_parse_asc.m % 主函数:入口,负责读文件、逐行扫描、过滤方向 ├── read_asc_line.m % 单行解析:把一行文本转换为一个 frame 结构体 ├── decode_signals.m % 信号解码:按信号定义把字节数组换算成物理值 ├── signalDefinitions.m % 用户自定义信号表:告诉脚本“哪个 ID 上的哪些位是车速” ├── examples/ │ ├── sample.asc % 几报文的示例文件 │ └── demo_run.m % 一键跑通的演示脚本 └── README.md

main_parse_asc.m是唯一对外暴露的 API。它返回一个结构体数组,每个元素包含time、bus、dir、id、dlc、data这些字段,data是一个uint8数组,方便后续做数值计算。read_asc_line.m接受一行字符串,返回我们定义好的 frame 结构体;如果这行不是报文行,返回空数组。decode_signals.m负责“翻译”,它不关心文本细节,只拿一个 frame 和信号定义表去算物理值,因此可以直接复用别人写的 DBC 解析库替代。

也许你会问:汽车行业解析 CAN 日志,Python 不是有更成熟的库吗?为什么用 Matlab?原因是多方面的。很多做台架测试的工程师手头没有 Python 环境,但公司已经买了 Matlab 正版许可;而且解析完之后通常要滤波、画曲线、做统计,Matlab 的交互式窗口比在 Python 里折腾 matplotlib 快得多。这个脚本本身不依赖任何工具箱,只要纯 Matlab 2020a 就能跑,对一些内网隔离、装不了第三方包的环境非常友好。

下面这个表概括了每个函数的核心输入输出,方便你在改造时决定“动哪里”:

文件输入输出主要职责
main_parse_asc.masc 路径、可选方向参数frame 结构体数组文件读取与逐行扫描
read_asc_line.m单行字符串单个 frame 或空正则解析
decode_signals.mframe 数组、信号定义在 frame 上追加信号字段物理值换算
signalDefinitions.m无参数信号结构体数组维护 ID 和位映射

3. 核心模块拆解:从一行文本到一组 CAN 报文的完整链路

3.1 主循环:用 inputParser 管理参数,用 fgetl 逐行扫描

整个解析器的入口是main_parse_asc.m,它负责打开文件、逐行读取、调用单行解析函数、最后按方向过滤。我习惯把文件句柄的处理交给onCleanup,这样即使在解析中途报错,文件也会被自动关闭,省掉一个到处写fclose的麻烦。

function frames = main_parse_asc(filename, varargin) % 主函数:解析ASC文件中的CAN帧 % 输入: % filename - ASC文件路径(必填) % 可选参数: % 'ignoreHeader' - 是否跳过文件头,默认 true % 'direction' - 过滤方向,'Tx'/'Rx'/'all',默认 'all' % 输出: % frames - 结构体数组,每个元素含 % time, bus, dir, id, dlc, data if nargin < 1 || isempty(filename) error('必须输入ASC文件路径'); end % 解析可选参数 p = inputParser; addParameter(p, 'ignoreHeader', true, @islogical); addParameter(p, 'direction', 'all', @ischar); parse(p, varargin{:}); fid = fopen(filename, 'r'); if fid == -1 error('无法打开文件: %s', filename); end cleanup = onCleanup(@() fclose(fid)); % 初始化空结构体数组 frames = struct('time', {}, 'bus', {}, 'dir', {}, ... 'id', {}, 'dlc', {}, 'data', {}); while true line = fgetl(fid); if ~ischar(line) break; % 读到文件末尾 end trimmed = strtrim(line); if isempty(trimmed) continue; end % 跳过注释行和文件头行 if p.Results.ignoreHeader if startsWith(trimmed, 'date') || startsWith(trimmed, 'base') ... || startsWith(trimmed, 'no') || startsWith(trimmed, ';') continue; end end % 调用单行解析 frame = read_asc_line(trimmed); if ~isempty(frame) frames(end+1) = frame; %#ok<AGROW> 结构体动态扩展 end end % 按方向过滤 if ~strcmpi(p.Results.direction, 'all') keep = strcmpi({frames.dir}, p.Results.direction); frames = frames(keep); end end

这段代码的逻辑很直白:fgetl每次读一行,strtrim去掉首尾空白,然后用startsWith跳过文件头。重点是read_asc_line返回的 frame 结构体字段顺序必须和初始化时一致,否则 MATLAB 会报错。frames(end+1) = frame是动态扩展,在小文件里没问题,处理大文件时性能会受影响,这一点在踩坑部分再展开。

inputParser是 MATLAB 内置的键值对参数解析器,addParameter设默认值,parse把用户传入的参数对填进来。如果你只想解析发送方向,可以这样调用:main_parse_asc('log.asc', 'direction', 'Tx'),非常直观。

3.2 单行解析:正则表达式里藏着时间、ID 和字节的边界

单行解析是性能敏感点。我用一条编译好的正则来切分整行文本,避免先split再判断字段个数。正则里的每一个分组都对应 frame 的一个字段,这样代码更紧凑,也方便扩展。

function frame = read_asc_line(line) % 解析单行ASC记录,返回frame结构体;如果不匹配则返回空 % 支持的格式: % 时间戳 总线 方向 CAN_ID [DLC] 字节1 字节2 ... % 时间戳是浮点数,ID是十六进制,字节是空格分隔的两位十六进制 % 分组顺序:时间、总线、方向、ID、DLC、剩余数据 pattern = '^\s*(\d+\.\d+)\s+(\S+)\s+(Tx|Rx|TxR|RxR)\s+' ... + '([0-9A-Fa-f]+)\s+\[(\d+)\]\s*(.*?)\s*$'; tok = regexp(line, pattern, 'tokens'); if isempty(tok) frame = []; return; end tok = tok{1}; frame.time = str2double(tok{1}); frame.bus = tok{2}; frame.dir = tok{3}; % ID统一转换为十进制,便于索引 frame.id = hex2dec(tok{4}); frame.dlc = str2double(tok{5}); % 把数据字节从十六进制字符串转为数值 data_str = regexp(strtrim(tok{6}), '\s+', 'split'); frame.data = zeros(1, frame.dlc, 'uint8'); n_bytes = min(frame.dlc, numel(data_str)); for i = 1:n_bytes frame.data(i) = uint8(hex2dec(data_str{i})); end end

这里有几个细节值得讲。首先是正则的(\d+\.\d+),它要求时间戳必须包含小数点,这一下就把一些不标准文件拒之门外了。我的经验是:先按严格格式跑通,遇到奇怪行再单独加分支,不要在第一条正则上兼容所有变体。其次是hex2dec会把1A字符串转成 26,而str2double一定会返回NaN,所以数据字节必须用hex2dec。

n_bytes = min(frame.dlc, numel(data_str))这行很关键。如果文件里某一行 DLC 写的是[8]但后来只有 4 个字节,脚本不会报索引越界,而是用0补齐,保证data长度始终等于dlc。这样后续信号解码时的位操作就不会出问题。

3.3 信号解码:按位提取,把字节序列翻译成物理值

解析出原始字节只是第一步,很多场景需要的是“车速到底是多少”。CAN 信号本质上是报文里的一段 bit,比如车速占第 0 到第 15 位,线性因子为 0.01,偏移量为 0。我的做法是定义一份简化的信号表,让用户按下面的格式填写:

function sigs = signalDefinitions() % 用户自定义信号映射表 % 每个元素是一个信号: % id - CAN ID(十进制) % name - 信号名称 % startBit - 起始位,小端模式下从第0位开始,跨字节时先低字节后高字节 % bitLength - 信号位宽 % factor - 线性缩放因子 % offset - 偏移量 % endian - 'little' 或 'big' sigs = struct('id', {}, 'name', {}, 'startBit', {}, ... 'bitLength', {}, 'factor', {}, 'offset', {}, 'endian', {}); sigs(1) = struct('id', 0x123, 'name', 'VehSpeed', ... 'startBit', 0, 'bitLength', 16, ... 'factor', 0.01, 'offset', 0, 'endian', 'little'); sigs(2) = struct('id', 0x456, 'name', 'EngineSpeed', ... 'startBit', 8, 'bitLength', 16, ... 'factor', 0.5, 'offset', 0, 'endian', 'big'); end

注意这里的endian字段:小端(Intel)格式用 LSB-first,大端(Motorola)格式用 MSB-first。CAN 总线大部分信号是小端,但发动机转速等信号有时被定义成“大端跨字节”,直接按小端取位会差出一个字节的倍数。下面的decode_signals.m负责把信号定义和 frame 数据合并:

function frames = decode_signals(frames, sigs) % 按信号定义对帧数组解码,结果写入frames的动态字段 % 输入: % frames - main_parse_asc 返回的结构体数组 % sigs - signalDefinitions 返回的信号结构体数组 % 输出: % frames - 每个命中的帧增加一个字段,字段名是信号名称 for i = 1:numel(frames) fid = frames(i).id; for s = 1:numel(sigs) if sigs(s).id == fid % 从帧数据中提取原始整数 raw = extract_bits(frames(i).data, ... sigs(s).startBit, ... sigs(s).bitLength, ... sigs(s).endian); % 线性换算成物理值 frames(i).(sigs(s).name) = raw * sigs(s).factor + sigs(s).offset; end end end end

这里的字段名是动态生成的,意味着frames(1).VehSpeed这种形式在运行前不需要预定义。MATLAB 的struct允许这种运行时加字段,比一开始定死模式灵活很多。extract_bits是底层位操作函数,我把小端和大端合并到同一个循环里:

function raw = extract_bits(data, start_bit, bit_length, endian) % 从字节数组按位提取原始整数 % 小端:start_bit=0指向第一个字节的LSB,跨字节时从低位字节向高位字节拼接 % 大端:把字节数组反转后再按小端取位,等效于Motorola字节序 if strcmpi(endian, 'big') data = fliplr(data); % 反转后,start_bit需要映射到新数组位置: % 原字节序的起始位,在新数组中要重新计算 end raw = 0; for k = 0:bit_length-1 bit_pos = start_bit + k; byte_idx = floor(bit_pos / 8) + 1; bit_in_byte = mod(bit_pos, 8); % 0 表示LSB bit_val = bitget(data(byte_idx), bit_in_byte + 1); % bitget从1开始 raw = bitset(raw, k + 1, bit_val); % 按LSB-first累积 end end

上面这一段只是为了演示小端算法;大端处理需要额外计算反转后的起始位,实际使用中更可靠的做法是:先把大端信号的 startBit 转换成字节内的位序号再调小端取位。我在项目里就是这么做的,转换成位操作时先fliplr字节序,再重新计算位偏移,避免把简单问题复杂化。这部分的验证方法在第 5 章会给出一个手工对照技巧。

4. 运行避坑与排查:从报错信息到正确结果的实操记录

4.1 环境准备与三步跑通示例

运行这个脚本不需要任何扩展工具箱,只需 MATLAB 2020a 或更高版本。用 2020a 主要是为了startsWith和inputParser这些内置函数,2016b 以上其实也都支持,但为了减少版本差异带来的问题,建议直接在 2020a 里跑。

第一步,把asc_signal_parser文件夹加入 MATLAB 路径:

addpath('D:/work/asc_signal_parser');

第二步,进入examples目录,直接跑演示脚本:

frames = main_parse_asc('sample.asc', 'direction', 'all'); disp(frames(1));

第三步,如果需要解码信号,先调用signalDefinitions把信号表加载进来,再传给decode_signals:

sigs = signalDefinitions(); frames = decode_signals(frames, sigs); disp(frames(1).VehSpeed);

这时如果VehSpeed出现在结构体字段里,说明整条链路已经通了。如果解码报错“Subscripted assignment between dissimilar structures”,多半是初始化结构和实际写入结构字段次序不一致,检查main_parse_asc里struct初始化的字段名和read_asc_line里赋值字段名是否完全一致。

4.2 高频踩坑记录与排查方法

踩坑1:正则匹配率为零,解析结果始终是空数组

现象:frames返回0×0,但文件里明明有大量报文行。
原因:时间戳被写成了整数格式,比如0 CAN1 Tx ...,而不是0.000000。我最初的正则\d+\.\d+要求必须有小数点,遇到这种文件就会一整行跳过。另外,某些工具在时间戳和总线名之间用的是制表符,strtrim之后\s+也能匹配,这个一般没问题。
解决:把正则以价格式改成(\d+(?:\.\d+)?),兼容整数和浮点时间戳;同时检查文件里是否用了全角空格,全角空格属于非 ASCII 字符,\s匹配不到,需要先用strtrim后手工替换成全半角。

踩坑2:CAN FD 帧全部被丢弃,解析结果少了一大半

现象:文件中带 CAN FD 的帧没有出现在结果里,报文的 ID 后面有一个小写x后缀,比如123x。
原因:我最初的正则 ID 部分([0-9A-Fa-f]+)只匹配纯十六进制,遇到123x时整个行匹配失败。CAN FD 帧的 DLC 可以到 64 字节,但数据字节和经典 CAN 一样是十六进制,只是 ID 带x或y后缀表为 FD 帧和 BRS 帧。
解决:把正则改成([0-9A-Fa-f]+)x?y?,读取后先去掉末尾的x或y,再hex2dec。这可以用regexprep或str2double前检查最后一个字符。

踩坑3:500MB 大文件跑起来越来越慢,内存占用飙升

现象:脚本运行到一半,CPU 占用满,内存从 500MB 一路涨到 2GB,最后 MATLAB 提示“Out of memory”。
原因:frames(end+1) = frame这种动态扩展会在底层反复复制结构体,文件行数超过几十万后,复杂度接近 O(n²)。每增加一帧,都要把前面所有帧的全部字段重新拷贝一次。
解决:常用的解法是“两遍读”。第一遍先用一个循环统计有效报文行数,不对号入座;第二遍预分配frames = repmat(emptyFrame, 1, count),再按索引填充。如果你不想写两遍循环,也可以改用textscan按列格式读取,但那样正则的灵活性就差了。我这边的折中是先用fgetl扫描一遍统计数量,再预分配,速度提升非常明显。

踩坑4:解出的信号数值跟期望值差了几百倍,符号还经常不对

现象:同一帧数据,用 CAN 分析软件看到的物理值是 12.5,脚本算出来却是 3200,或者出现负值时被读成无符号正数。
原因:这个基本都出在信号定义的位序上。比如 DBC 文件里起始位写的是8,按 Motorola(大端)约定是从第二个字节的高位开始;我按小端去取第一个字节的高位,结果完全不对。另一个常见原因是忽略了factor或offset,直接把 raw 当物理值。
解决:先在signalDefinitions.m里把每个信号的endian字段写对,然后用一个“已知帧”做验证。验证方法很简单:挑一条固定 ID 的报文,手动打开 Windows 计算器,切换成“程序员”模式,输入十六进制字节,选好字节序,算出整数,再乘 factor 看是否等于期望值。这个流程我会在下一章具体演示。

5. 二次开发技巧:把解析脚本改成你自己的前处理工具

5.1 快速扩展:批量扫描目录并合并结果

实际项目中,一次台架测试会导出几十个 ASC 文件,手工一条一条调用main_parse_asc不现实。我常用的做法是写一个批量遍历脚本,把所有文件的结果合并到一个结构体数组里,再统一做信号解码:

files = dir('logs/*.asc'); allFrames = []; for k = 1:numel(files) frames = main_parse_asc(fullfile(files(k).folder, files(k).name)); if k == 1 allFrames = frames; else allFrames = [allFrames, frames]; %#ok<AGROW> end end sigs = signalDefinitions(); allFrames = decode_signals(allFrames, sigs);

这里有一个坑:直接[allFrames, frames]追加结构体数组,如果两个数组的字段顺序不一致,MATLAB 会报“Unable to concatenate struct arrays”。所以我建议把main_parse_asc返回结构体时的字段顺序就当作文档契约,后续扩展时不要随便调字段位置;真要加字段,统一在decode_signals阶段加。

5.2 验证方法:构造一条已知信号值的报文

这是我在每一个解析项目里都会做的动作:手工构造一帧报文,写入 sample.asc,然后验证解码结果。比如我要验证VehSpeed(ID=0x123,起始位 0,长度 16,小端,factor 0.01)能不能正确解出 12.3 km/h,那么原始字节应该是1234,小端表示为[0x34, 0x12],所以 asc 文件里写成:

0.100000 CAN1 Tx 123 [8] 34 12 00 00 00 00 00 00

跑完脚本后,frames.data是[52, 18, 0, ...],extract_bits取出的 raw 应该是 0x1234 即 4660,乘以 0.01 得到 46.6,而不是 12.3。这说明我设置 factor 或期望值写错了。正确做法是先决定物理值 12.3,factor 0.01,倒推 raw = 1230,小端存储为[0x32, 0x04]。这个验证做一次,基本就能把所有位序问题都暴露出来。

从那以后,我每写一个 CAN 解析脚本,都会先花十分钟构造几条带已知值的 asc 样例,把解析结果打印出来对照,确认无误再上真实日志。这一步省掉了我后面非常多“玄学”问题——比如信号值偶尔对、偶尔错,或者大文件跑到一半才出错。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询