资讯动态

射频IQ数据格式转换:从二进制裸流到SigMF标准格式的工程实践

发布时间:2026/10/17 18:50:02 来源:尧图企业网站定制
简介这份资源是AMD/Xilinx官方发布的PG269文档全称为《RFSoC RF Data Converter v2.6 LogiCORE IP Product Guide》面向使用Zynq UltraScale RFSoC平台进行射频数据转换器开发的FPGA工程师与硬件设计人员。文档系统讲解RF-ADC与RF-DAC的架构特性、性能指标、资源占用与端口定义并覆盖Vivado设计套件中的IP核配置流程、寄存器空间说明及软件驱动使用方法可帮助读者理解AXI4-Stream接口的数据通路设计解决射频采样与数模转换环节的配置与调试问题。压缩包内仅含1个PDF文件大小约6.52MB内容按章节组织从简介、概述到产品规格与核心设计方法逐层展开目录结构清晰便于检索。目前已有317人学习下载适合需要查阅官方权威说明、对照寄存器与端口细节进行工程实现的开发者参考。1. 射频数据格式转换器从二进制 IQ 到可分析数据集的那道坎做射频信号分析的人多半遇到过这种场景采集设备吐出来的是裸 IQ 二进制流而下游的模型训练、频谱分析、协议逆向工具却只认特定格式的样本文件。pg269-rf-data-converter-en-us-2.6 这个标题指向的正是一类把原始射频采样数据转换成通用可读格式的工具链。它的核心价值不在于算法多复杂而在于把「设备私有格式」和「分析工具链」之间的鸿沟填平。适合谁用做无线信号采集的测试工程师、搞射频指纹识别的算法同学、以及需要把大批量 IQ 文件整理成训练集的开发者。你不需要精通 DSP但得知道采样率、中心频率、量化位宽这几个参数怎么对应到文件头里。2. 射频 IQ 数据的格式困局为什么不能直接喂给分析工具2.1 裸采数据与标准格式之间的三个断层射频采集设备输出的原始数据最常见的形式是连续的复数采样点每个点由 I 路和 Q 路两个分量组成分别用定点数或浮点数表示。问题在于不同设备厂商对「怎么存」这件事的理解完全不同。有的把 I 和 Q 交错存放有的先存完所有 I 再存所有 Q有的用 16 位有符号整数有的用 8 位偏移二进制有的在文件开头写一段 JSON 头有的干脆什么元信息都不留。这就造成三个断层。第一是排列断层交错式和分块式在读取时索引方式完全不同搞错了就会把 Q 路数据当成 I 路解出来的星座图是一团乱麻。第二是量化断层定点整数需要知道满量程和位宽才能还原成浮点否则幅度信息全丢。第三是元信息断层采样率、中心频率、增益这些参数如果不在文件里后续做频谱分析时横轴就对不上。常见做法是写一个转换脚本把裸流读进来按已知的排列方式和量化格式解析成复数数组再写入带元信息的标准容器。pg269 这个编号在业界通常对应某种射频采集模块的文档编号而 converter 工具要做的就是把这套流程固化下来。2.2 目标格式选型SigMF、WAV 还是自定义 HDF5选目标格式时我一般会看下游工具链吃什么。如果后续要用 GNU Radio 做处理SigMF 是目前比较通用的选择它用一对文件描述一段采集.sigmf-meta存 JSON 元数据.sigmf-data存裸 IQ。优点是元数据字段规范采样率、中心频率、数据类型都有固定键名工具生态也在逐步完善。如果下游是 MATLAB 或 Python 的 scipy 生态WAV 格式也能凑合用但 WAV 原生是实数格式存 IQ 需要把 I 和 Q 当成两个声道或者交错写入再用整数表示。这种做法在采样率不高时没问题但元信息表达能力弱中心频率没地方放。自定义 HDF5 则适合数据量特别大、需要分块读取的场景。HDF5 支持数据集分组和属性可以把 IQ 数据存成一个二维数据集把采样率、频率、增益存成属性。缺点是通用工具支持不如 SigMF 直接往往需要自己写读取函数。格式元信息能力工具兼容性适合场景SigMF强字段规范GNU Radio、部分 Python 库通用采集记录WAV弱仅采样率极广但需约定声道含义小规模、快速验证HDF5强可自定义Python、MATLAB大规模、分块处理选型时还要注意一点如果采集设备本身支持输出 SigMF那就没必要再转一道。converter 的价值在于处理那些「只吐裸流」的设备。2.3 用 Python 写一个最小可用的转换脚本下面这段代码演示从交错 16 位有符号整数 IQ 文件转到 SigMF 格式的核心逻辑。假设输入文件是input.iq采样率 1 MHz中心频率 915 MHz。import numpy as np import json import os def convert_iq_to_sigmf(input_path, output_prefix, sample_rate, center_freq, dtypenp.int16, interleavedTrue): 将裸 IQ 文件转换为 SigMF 格式。 input_path: 输入裸流路径 output_prefix: 输出文件前缀生成 .sigmf-meta 和 .sigmf-data sample_rate: 采样率 Hz center_freq: 中心频率 Hz dtype: 量化类型默认 int16 interleaved: True 表示 I/Q 交错False 表示 I 全部在前 Q 全部在后 raw np.fromfile(input_path, dtypedtype) if interleaved: # 交错格式奇数索引为 I偶数索引为 Q i_data raw[0::2].astype(np.float32) q_data raw[1::2].astype(np.float32) else: # 分块格式前半段 I后半段 Q half len(raw) // 2 i_data raw[:half].astype(np.float32) q_data raw[half:].astype(np.float32) # 归一化到 [-1, 1]假设满量程为 dtype 最大值 scale np.iinfo(dtype).max i_data / scale q_data / scale # 交错写入 float32 复数数据 complex_data i_data 1j * q_data interleaved_out np.empty(complex_data.size * 2, dtypenp.float32) interleaved_out[0::2] complex_data.real interleaved_out[1::2] complex_data.imag interleaved_out.tofile(output_prefix .sigmf-data) # 生成元数据 meta { global: { core:datatype: cf32_le, core:sample_rate: sample_rate, core:version: 1.0.0 }, captures: [ { core:sample_start: 0, core:frequency: center_freq } ], annotations: [] } with open(output_prefix .sigmf-meta, w) as f: json.dump(meta, f, indent2) # 调用示例 convert_iq_to_sigmf(input.iq, output, 1e6, 915e6)这段代码的关键点有三个。第一np.fromfile直接按指定 dtype 读二进制速度比逐字节解析快得多。第二交错和分块两种排列用不同切片方式处理raw[0::2]取奇数位raw[1::2]取偶数位这是 Python 切片的基本操作但容易写反。第三归一化时用np.iinfo(dtype).max作为满量程如果实际信号没到满量程幅度会偏小但相对关系正确。参数方面sample_rate和center_freq必须和采集时设置一致否则频谱横轴会错位。dtype要跟设备输出匹配常见的有np.int16、np.int8、np.float32。interleaved这个参数最容易被忽略如果转换后星座图看起来像噪声先检查这里。3. 批量转换与元信息校验让几千个文件不出错3.1 批量处理的目录约定与命名规则实际项目中不会只转一个文件。采集设备可能连续录了几百段每段几秒到几分钟不等。这时候需要一套目录约定让脚本能自动识别哪些文件要转、参数从哪来。我一般会要求采集时生成一个manifest.csv每行记录文件名、采样率、中心频率、增益、采集时间。转换脚本读这个 CSV逐行处理。如果采集时没生成那就得从文件名里解析比如capture_915MHz_1Msps_001.iq这种命名用正则提取参数。import csv import re import os def batch_convert(manifest_path, output_dir): 根据 manifest 批量转换 os.makedirs(output_dir, exist_okTrue) with open(manifest_path, newline) as f: reader csv.DictReader(f) for row in reader: input_file row[filename] sample_rate float(row[sample_rate]) center_freq float(row[center_freq]) base os.path.splitext(os.path.basename(input_file))[0] output_prefix os.path.join(output_dir, base) try: convert_iq_to_sigmf(input_file, output_prefix, sample_rate, center_freq) print(fOK: {input_file}) except Exception as e: print(fFAIL: {input_file} - {e}) def parse_from_filename(filename): 从文件名解析参数作为 manifest 缺失时的备选 pattern r(\d)MHz_(\d)Msps m re.search(pattern, filename) if m: return float(m.group(1)) * 1e6, float(m.group(2)) * 1e6 raise ValueError(fCannot parse: {filename})批量转换最怕的是「转了一半报错不知道从哪继续」。所以脚本里要记录已完成的文件或者干脆用输出文件是否存在来判断跳过。另外manifest.csv里的路径如果是相对路径要确认脚本的工作目录对不对否则会报文件找不到。3.2 转换后校验用频谱和星座图快速判断对错转完不校验等于没转。最直接的校验方法是画一张功率谱密度图看信号是否落在预期的中心频率附近。如果中心频率偏了说明元数据里的频率写错了如果频谱看起来像白噪声可能是 I/Q 排列搞反了或者量化方式不对。用 Python 做快速校验import numpy as np import matplotlib.pyplot as plt def validate_sigmf(data_path, sample_rate, expected_freq): 读取 SigMF 数据并绘制频谱 data np.fromfile(data_path, dtypenp.float32) complex_data data[0::2] 1j * data[1::2] # 计算功率谱 fft_size 4096 num_segments len(complex_data) // fft_size psd np.zeros(fft_size) for i in range(num_segments): segment complex_data[i*fft_size:(i1)*fft_size] spectrum np.fft.fftshift(np.fft.fft(segment)) psd np.abs(spectrum) ** 2 psd / num_segments psd_db 10 * np.log10(psd 1e-12) freqs np.fft.fftshift(np.fft.fftfreq(fft_size, 1/sample_rate)) plt.plot(freqs / 1e6, psd_db) plt.xlabel(Frequency offset (MHz)) plt.ylabel(Power (dB)) plt.title(fExpected center: {expected_freq/1e6} MHz) plt.grid(True) plt.show() # 简单判断峰值是否在零偏移附近 peak_idx np.argmax(psd_db) peak_offset freqs[peak_idx] if abs(peak_offset) sample_rate * 0.1: print(fWARNING: peak at {peak_offset/1e3:.1f} kHz offset) else: print(Peak near DC, looks OK)这段校验代码做的是把交错 float32 读回来按 4096 点分段做 FFT累加功率谱。如果信号确实在中心频率上峰值应该出现在零偏移附近。如果峰值偏了几百 kHz要么是采集时中心频率设错了要么是转换时频率字段写错了。星座图校验适合数字调制信号。把复数样本直接画成散点如果能看到清晰的星座点说明 I/Q 平衡和量化都正常如果是一团圆形的噪声那可能是信号太弱或者排列错了。3.3 元信息字段的常见映射关系不同采集设备导出的元信息字段名五花八门转换时需要做映射。下面这张表列出我遇到过的几种常见叫法和 SigMF 标准字段的对应关系。设备端字段名SigMF 字段说明sample_rate / fs / sampleratecore:sample_rate采样率单位 Hzcenter_freq / fc / rf_freqcore:frequency中心频率单位 Hzgain / rx_gain无标准字段放 annotations增益单位 dBformat / dtypecore:datatype数据类型如 ci16_letimestampcore:datetimeISO8601 时间映射时要注意单位。有些设备用 kHz 或 MHz 存频率转换时要统一乘到 Hz。有些增益是分贝有些是线性倍数这个如果搞错后续做功率计算会差很远。4. 避坑指南射频数据转换里那些让人返工的细节4.1 字节序搞反导致数据全废现象转换后的频谱看起来完全不对像是随机噪声但文件大小和预期一致。原因采集设备用小端字节序存储而读取时用了大端或者反过来。16 位整数高低字节颠倒后数值会变得面目全非。解决先确认设备的字节序。如果不确定用一小段数据试两种字节序看哪种能解出合理的星座图。NumPy 的 dtype 可以指定字节序比如i2是小端 16 位i2是大端。在np.fromfile里直接写dtypei2或dtypei2即可。4.2 交错顺序写反I 路 Q 路互换现象星座图旋转了 90 度或者频谱关于中心频率镜像。原因代码里raw[0::2]和raw[1::2]写反了把 Q 当成了 I。或者设备实际是分块存储但代码按交错处理。解决拿一段已知信号测试。比如采集一个单音信号正确的 I/Q 应该是一个旋转的复数如果 I/Q 互换旋转方向会反过来。更直接的办法是看设备文档里对数据排列的描述通常会说「I/Q interleaved」还是「I then Q」。4.3 量化位宽和满量程不匹配现象转换后信号幅度特别小或者出现削顶失真。原因设备输出可能是 12 位有效数据存在 16 位里高位是符号扩展或补零。如果直接按 16 位满量程归一化幅度会差 16 倍。或者设备输出是偏移二进制而代码按有符号整数处理。解决先看数据的实际取值范围。用np.min和np.max看原始整数落在什么区间。如果是 0 到 4095那可能是 12 位无符号如果是 -2048 到 2047那是 12 位有符号存在 16 位里。归一化时用实际位宽的最大值而不是 dtype 的最大值。4.4 大文件内存溢出现象转换几百 MB 的文件时程序崩溃报 MemoryError。原因np.fromfile一次性把整个文件读进内存如果文件有几个 GB内存扛不住。解决分块读取。用np.fromfile的count和offset参数每次读固定数量的样本处理完写入输出文件再读下一块。或者用np.memmap做内存映射让操作系统管理分页。def convert_large_file(input_path, output_path, chunk_samples10_000_000): 分块转换大文件 dtype np.int16 itemsize np.dtype(dtype).itemsize chunk_bytes chunk_samples * itemsize * 2 # I 和 Q 各一份 with open(input_path, rb) as fin, open(output_path, wb) as fout: while True: raw np.fromfile(fin, dtypedtype, countchunk_samples*2) if len(raw) 0: break i_data raw[0::2].astype(np.float32) / 32768.0 q_data raw[1::2].astype(np.float32) / 32768.0 out np.empty(len(i_data)*2, dtypenp.float32) out[0::2] i_data out[1::2] q_data out.tofile(fout)4.5 元信息里的时间戳格式不统一现象转换后的 SigMF 元数据里时间字段无法被下游工具解析。原因不同设备导出的时间格式不同有的是 Unix 时间戳有的是YYYY-MM-DD HH:MM:SS有的是 ISO8601 带时区。解决统一转成 ISO8601 格式带时区偏移。Python 里用datetime.fromtimestamp或datetime.strptime解析后再用isoformat()输出。如果原始数据没有时区信息默认按 UTC 处理并在元数据里注明。5. 进阶技巧用 SigMF 元数据做自动化流水线转换只是第一步真正省时间的是让整个流程自动化。我现在的习惯是转换脚本输出 SigMF 后自动触发一个校验脚本校验通过就写入一个done标记文件后续的训练或分析脚本只处理有标记的数据。这样即使中间某个文件转换失败也不会阻塞整个流水线。另一个技巧是利用 SigMF 的annotations字段记录信号事件。比如在转换时顺便做一个简单的能量检测把超过阈值的时段标记出来后续分析时可以直接跳到有信号的片段不用从头扫。def add_energy_annotations(meta_path, data_path, threshold_db-40): 在 SigMF 元数据里添加能量检测标注 with open(meta_path) as f: meta json.load(f) data np.fromfile(data_path, dtypenp.float32) complex_data data[0::2] 1j * data[1::2] # 按 1024 点窗口计算能量 window 1024 num_windows len(complex_data) // window energies np.array([ np.mean(np.abs(complex_data[i*window:(i1)*window])**2) for i in range(num_windows) ]) energies_db 10 * np.log10(energies 1e-12) # 找连续超过阈值的段 above energies_db threshold_db annotations [] start None for i, val in enumerate(above): if val and start is None: start i elif not val and start is not None: annotations.append({ core:sample_start: start * window, core:sample_count: (i - start) * window, core:label: signal_present }) start None if start is not None: annotations.append({ core:sample_start: start * window, core:sample_count: (num_windows - start) * window, core:label: signal_present }) meta[annotations] annotations with open(meta_path, w) as f: json.dump(meta, f, indent2) print(fAdded {len(annotations)} annotations)这个能量检测的逻辑很简单把数据切成 1024 点的窗口算每个窗口的平均功率超过阈值的窗口标记为有信号。阈值-40 dB是个经验值实际要根据噪声底调整。如果噪声底在 -50 dB阈值设 -40 dB 比较合适如果噪声底高阈值也要相应提高。标注的好处是下游工具可以直接读annotations字段知道哪些时间段有信号做切片分析时不用遍历整个文件。对于长时间采集的数据这个优化能省不少时间。最后说一个我踩过的坑SigMF 的core:sample_start和core:sample_count都是相对于数据文件开头的样本索引不是字节偏移。如果数据是复数 float32每个样本占 8 字节算字节位置时要乘 8。这个如果搞错标注的位置会偏得离谱。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑