
简介毫米波雷达DSP实例是一套面向嵌入式雷达开发者的教学型工程代码基于TI 16xx平台srrdemo_16xx_dss从毫米波雷达原理出发演示了回波采集、距离/速度估计、DBSCAN聚类、EKF目标跟踪等完整DSP处理流程适合用于学习雷达数字信号处理与工程化实现。压缩包共32个文件以15个头文件与8个C源文件为核心头文件集中定义雷达参数、消息结构和算法接口C源文件则实现dss数据通路、聚类、滤波和FFT等关键模块另有cmd链接脚本、cfg配置、ccsproject工程文件辅助编译调试整体仅122KB结构清晰、模块边界明确。目前已有102人学习下载。对照该demo开发者既能掌握毫米波雷达DSP的模块划分、数据流衔接与参数配置思路也能学习从TI官方工程裁剪、移植到自定义项目的实用方法尤其适合刚接触雷达信号处理或DSP软件架构的初级工程师快速入门。1. 从一把原始ADC数据到目标点迹毫米波雷达DSP到底在算些什么拿到一片毫米波雷达的原始回波大多数人第一反应是打开MATLAB跑个range-FFT看看峰值在哪。但等真正要往DSP里移、要跑实时处理、要看每一帧的目标距离和速度时才发现问题根本不在FFT本身而在整条处理链路怎么串。ADC采回来的是几百个chirp乘几千个采样点的二维复数矩阵DSP要在这块矩阵上完成距离维FFT、多普勒维FFT、CFAR检测、相位测速、点迹输出每一步都涉及存储布局、窗函数选择、归一化和定点精度。这篇就直接给一套能跑的demoC写的、按DSP工程的思路组织的毫米波雷达测距测速实例代码可以抄参数可以改思路能直接挪到你的板子上。这个demo要解决的场景很具体单发单收FMCW雷达ADC数据已经按chirp×sample的格式存成二进制文件目标是输出每一帧的距离、速度和信号强度。适合正在接触毫米波雷达DSP开发的人不管你是刚把SDK跑通还是已经在调CFAR门限这套代码都能当成一个最小可用的参考骨架。全篇按信号模型→实现→调参→验证来推所有代码都围绕C和标准库不依赖特定厂商的SDK。2. 毫米波雷达DSP处理链的模型拆解从chirp参数到距离多普勒图2.1 FMCW测距测速的数学基础与DSP处理链划分FMCW雷达发的是频率随时间线性增长的chirp信号回波和本振混频后得到中频信号这个中频信号的频率正比于目标距离相位变化正比于目标速度。所以距离维FFT提取的是频率峰值多普勒维FFT提取的是跨chirp的相位变化。整套DSP处理链可以划成四段ADC数据重排、距离维加窗FFT、多普勒维加窗FFT、CFAR检测与参数提取。处理链的每一段都有明确的存储和计算约束。距离维FFT是在每个chirp内部沿着采样点方向做做完后每个chirp变成一条距离谱线多普勒维FFT是沿着chirp方向做做完后得到距离-多普勒二维谱也就是常说的Range-Doppler Map。在DSP工程里这两步FFT之间的数据转置是个容易被忽略的性能瓶颈尤其当chirp数和采样点数都不是2的幂次时。2.2 关键参数表ADC配置如何决定FFT尺寸代码里的所有数组维度都来自雷达配置参数。用一个典型的60GHz毫米波雷达参数做例子参数取值和含义如下表所示。参数名 示例值 说明 Fs 2 MHz ADC采样率决定最大中频频率 samples_per_chirp 128 每个chirp的采样点数即距离FFT长度 chirps_per_frame 64 每帧的chirp数即多普勒FFT长度 chirp_loop_time 80 us chirp周期包含空闲时间 start_freq 60 GHz 起始频率 slope 40 MHz/us 调频斜率这套参数下距离分辨率约等于c/(2×带宽)≈0.1875m最大不模糊速度由chirp周期决定λ/(4×chirp_loop_time)。改成任何其他雷达参数之前要先把这几个数算清楚因为它们直接决定FFT点数、存储大小和后续CFAR的参考窗设计。参数没定对后面所有处理都是白做。2.3 为什么用加窗FFT而不是直接做FFT直接对ADC原始数据做FFT会有频谱泄漏问题目标能量会散到旁边的距离单元里弱目标可能被强目标的旁瓣盖掉。常见做法是先乘窗函数再做FFT。窗函数的选择看你的目标Hamming窗主瓣稍宽但旁瓣衰减好适合一般检测场景Blackman-Harris旁瓣衰减更好但主瓣更宽适合动态范围要求高的场景。代码里用Hamming窗做距离维和多普勒维的加窗归一化因子要同时考虑窗能量和FFT点数。距离维FFT的幅度谱用于CFAR检测多普勒维FFT输出是复数用于后续相位测速。这个复数保留很关键很多人在这里直接把多普勒谱取模导致后续没法做相位提取。距离-多普勒图的存储格式应该是float complex rdm[chirps_per_frame][samples_per_chirp]行是chirp索引列是距离单元。3. 毫米波雷达DSP实例demoC实现的距离多普勒处理与CFAR检测3.1 demo代码的总体结构与数据流设计整个demo分成四个模块adc_reader负责读二进制ADC数据fft_engine封装FFT和窗函数detector实现CFAR检测radar_dsp把整条链路串起来。数据流是ADC_mat - range_fft - doppler_fft - cfar - target_list每帧处理完输出距离、速度和强度。这样做的好处是每个模块都可以单独测比如先把fft_engine用一个已知正弦波验证再挂到雷达数据上。代码全部用C17标准不依赖FFTW自己实现了一个基2的FFT方便直接移植到DSP汇编优化。如果你的平台有库替换成本不大因为接口只有fft_execute一个函数。存储方面用std::vectorstd::complexfloat但是在DSP上建议换成静态分配或内存池避免运行时alloc造成帧抖动。3.2 FFT引擎与窗函数实现先看FFT引擎的头文件接口设计得很薄方便后面替换成厂商优化库// fft_engine.h #pragma once #include complex #include vector class FFTPipeline { public: FFTPipeline(int range_bins, int doppler_bins, float fs); // 距离维FFT输入samples_per_chirp个复采样点输出range_bins条距离谱线 void range_fft(const std::complexfloat* chirp_in, std::complexfloat* range_out) const; // 多普勒维FFT对每个距离单元沿chirp方向做FFT void doppler_fft(const std::complexfloat* rdm_in, std::complexfloat* rdm_out) const; // 生成Hamming窗并缓存 const std::vectorfloat range_window() const { return range_win_; } private: void compute_hamming_window(std::vectorfloat win, int len) const; void fft_inplace(std::complexfloat* data, int n, bool inverse) const; int range_bins_; int doppler_bins_; float fs_; std::vectorfloat range_win_; std::vectorfloat doppler_win_; };这个类把窗和FFT绑在一起构造时算好窗处理时直接乘窗再变换。fft_inplace是内部方法用蝶形迭代实现外部只关心两个FFT接口。实现文件里关键的一段是Hamming窗和FFT的配合// fft_engine.cpp #include fft_engine.h #include cmath void FFTPipeline::compute_hamming_window(std::vectorfloat win, int len) const { win.resize(len); for (int i 0; i len; i) { // 标准Hamming窗alpha0.54, beta0.46 win[i] 0.54f - 0.46f * std::cos(2.0f * M_PI * i / (len - 1)); } } void FFTPipeline::range_fft(const std::complexfloat* chirp_in, std::complexfloat* range_out) const { // 先乘窗再拷贝到输出缓冲区避免修改输入数据 std::vectorstd::complexfloat tmp(range_bins_); for (int i 0; i range_bins_; i) { tmp[i] chirp_in[i] * range_win_[i]; } // 原地FFT fft_inplace(tmp.data(), range_bins_, false); std::copy(tmp.begin(), tmp.end(), range_out); }逻辑说明距离维FFT的输入是ADC原始复数数据先乘Hamming窗再做FFT。这里没有做FFT移位所以零频在数组两端实际检测时只查前半段有效距离单元。doppler_fft的做法类似但是沿另一个方向遍历对于每个距离单元索引r把所有chirp在该距离上的复数取出来乘多普勒窗做FFT写回rdm_out[r * doppler_bins k]这里k是多普勒索引。存储布局用range-major即先铺完所有距离单元再换下一个多普勒索引这样遍历时缓存友好。3.3 CFAR检测器门限怎么自适应CFAR的核心思想是对每个待检单元取它周围一圈距离-多普勒单元作为参考窗算噪声均值然后乘以一个系数得到门限。目标单元幅度要超过这个门限才算检测到目标。参考窗和待检单元之间要隔几个保护单元避免目标自己的旁瓣污染噪声估计。// cfar_detector.h #pragma once #include complex #include vector struct Target { int range_bin; int doppler_bin; float amplitude; }; class CFARDetector { public: CFARDetector(int guard_range, int guard_doppler, int ref_range, int ref_doppler, float threshold_factor); // 输入距离-多普勒复数谱的幅度输出检测到的目标 std::vectorTarget detect(const std::complexfloat* rdm, int range_bins, int doppler_bins) const; private: int guard_range_; int guard_doppler_; int ref_range_; int ref_doppler_; float threshold_factor_; };实现里直接遍历每个距离-多普勒单元对每一个待检单元统计矩形环内的幅度均值。这个做法在小矩阵上可以接受但实时系统里一般会用滑窗求和做优化把求和复杂度从O(N²)降到O(N)。demo先保证逻辑清晰优化留给后面。std::vectorTarget CFARDetector::detect(const std::complexfloat* rdm, int range_bins, int doppler_bins) const { std::vectorTarget targets; for (int ri guard_range_ ref_range_; ri range_bins - guard_range_ - ref_range_; ri) { for (int di guard_doppler_ ref_doppler_; di doppler_bins - guard_doppler_ - ref_doppler_; di) { float noise_sum 0.0f; int noise_cnt 0; // 遍历参考窗 for (int rr -ref_range_; rr ref_range_; rr) { for (int rd -ref_doppler_; rd ref_doppler_; rd) { // 跳过保护窗和待检单元自身 if (std::abs(rr) guard_range_ std::abs(rd) guard_doppler_) { continue; } int idx (ri rr) * doppler_bins (di rd); noise_sum std::abs(rdm[idx]); noise_cnt; } } float noise_mean noise_sum / noise_cnt; float current std::abs(rdm[ri * doppler_bins di]); // 门限系数乘噪声均值可加一个固定底噪floor避免除零或门限过低 float threshold threshold_factor_ * noise_mean 1e-6f; if (current threshold) { targets.push_back({ri, di, current}); } } } return targets; }这段代码的逻辑说明保护窗大小guard_range_和guard_doppler_决定目标主瓣附近哪些单元不参与噪声统计。目标如果跨好几个距离单元保护窗设小了会把目标能量算进噪声导致门限被抬高、弱目标漏检设大了又会把噪声排除太多门限偏低、虚警变多。threshold_factor_典型取值在8到15之间信噪比低的场景可以降到6但要接受更多虚警。输出里面的doppler_bin如果大于doppler_bins/2说明目标在靠近雷达这个在最后输出时要换算成速度负值。3.4 主流程把ADC数据变成目标列表主函数做的事情是读取配置、构造处理链、循环处理每一帧。数据文件格式约定为每帧先存chirps_per_frame * samples_per_chirp个std::complexfloat按chirp内采样点连续、chirp间顺序排列。实际雷达SDK导出的数据可能是奇偶交织的实虚部需要先转成complex数组这里demo直接读complex二进制。// radar_dsp_demo.cpp #include fstream #include iostream #include vector #include complex #include fft_engine.h #include cfar_detector.h int main() { // ---------- 1. 配置参数 ---------- const int samples_per_chirp 128; const int chirps_per_frame 64; const float fs 2e6f; // ADC采样率 2MHz const float slope 40e12f; // 40MHz/us 40e12 Hz/s const float start_freq 60e9f; const float chirp_loop_time 80e-6f; // ---------- 2. 构造DSP模块 ---------- FFTPipeline fft_pipe(samples_per_chirp, chirps_per_frame, fs); CFARDetector cfar(2, 2, 4, 4, 10.0f); // 保护窗2x2参考窗4x4系数10 // ---------- 3. 读ADC数据 ---------- std::ifstream file(adc_data.bin, std::ios::binary); if (!file) { std::cerr 无法打开ADC数据文件 std::endl; return -1; } std::vectorstd::complexfloat adc_buf(samples_per_chirp * chirps_per_frame); file.read(reinterpret_castchar*(adc_buf.data()), adc_buf.size() * sizeof(std::complexfloat)); // ---------- 4. 距离维FFT ---------- // 临时存chirp数据做完距离FFT后放二维矩阵 std::vectorstd::complexfloat range_fft_mat(samples_per_chirp * chirps_per_frame); std::vectorstd::complexfloat chirp_tmp(samples_per_chirp); for (int ci 0; ci chirps_per_frame; ci) { const std::complexfloat* chirp_ptr adc_buf.data() ci * samples_per_chirp; fft_pipe.range_fft(chirp_ptr, chirp_tmp.data()); // 转置存储行是距离索引方便后续按列做多普勒FFT for (int ri 0; ri samples_per_chirp; ri) { range_fft_mat[ri * chirps_per_frame ci] chirp_tmp[ri]; } } // ---------- 5. 多普勒维FFT ---------- std::vectorstd::complexfloat rdm(samples_per_chirp * chirps_per_frame); for (int ri 0; ri samples_per_chirp; ri) { fft_pipe.doppler_fft(range_fft_mat.data() ri * chirps_per_frame, rdm.data() ri * chirps_per_frame); } // ---------- 6. CFAR检测 ---------- std::vectorTarget targets cfar.detect(rdm.data(), samples_per_chirp, chirps_per_frame); // ---------- 7. 换算物理量并输出 ---------- const float c 3e8f; const float range_res c / (2.0f * slope * (static_castfloat(samples_per_chirp) / fs)); const float doppler_res 1.0f / (chirps_per_frame * chirp_loop_time); const float wavelength c / start_freq; for (const auto t : targets) { float range_val t.range_bin; // 距离FFT没做fftshift有效目标范围是range_bins/2以内 range_val * range_res; float vel_val 0.0f; if (t.doppler_bin chirps_per_frame / 2) { vel_val (t.doppler_bin - chirps_per_frame) * doppler_res; } else { vel_val t.doppler_bin * doppler_res; } std::cout 目标: 距离 range_val m, 速度 vel_val m/s, 幅度 t.amplitude std::endl; } return 0; }这段主流程的逻辑说明距离维FFT之后做了一次转置存储把range_fft_mat[ri * chirps_per_frame ci]写成距离索引优先这样后续多普勒FFT每次操作的chirps_per_frame个数据在内存里是连续的。多普勒维FFT用fft_pipe.doppler_fft的接口它内部会按距离索引逐行处理。CFAR检测输入rdm布局和range_fft_mat一致都是距离-多普勒索引所以detect里的索引计算是正确的。速度换算时对多普勒索引做了折叠处理大于chirps_per_frame/2的索引表示负速度因为FFT输出的后半段对应负频率。4. 参数怎么调、坑在哪距离分辨率、多普勒模糊和CFAR门限的取舍4.1 距离FFT和速度FFT的频谱泄漏与栅栏效应距离FFT点数固定时目标真实距离在两个距离单元之间频谱峰值会同时出现在相邻两个bin上这就是栅栏效应。加窗能缓解但无法完全消除。实测中不要用最大峰值对应的bin直接算距离更稳的做法是找峰值后做抛物线插值用相邻两个bin的幅度估算峰值位置。插值公式是bin_offset 0.5*(log(A_{i-1}) - log(A_{i1})) / (log(A_{i-1}) - 2*log(A_i) log(A_{i1}))算出来的是相对峰值bin的小数偏移。多普勒维的情况类似但更麻烦因为chirp数通常比采样点数少FFT点数小多普勒分辨率差。如果chirp周期是80us64个chirp多普勒分辨率是1/(64×80us)195.3Hz对应速度分辨率约0.489m/s。想提高速度分辨率只能加chirp数或者增大chirp周期但这两者都会增加帧时间。DSP上的权衡是帧率要求10Hz以上那chirp数不能随便加到256否则一帧处理时间太长。4.2 CFAR的边沿处理和虚警控制CFAR实现里对边沿做的是直接截断这会让靠近边界的目标检测不到因为它们的参考窗有一部分落在有效区域外。工程上有两种解法一是把检测范围往内缩只检测距离谱的中间区域丢掉两端靠近直流和最大距离的单元二是对边沿用非对称参考窗例如左边参考窗有效就只用左边算噪声均值。虚警控制的重点在threshold_factor_和环境底噪的配合。毫米波雷达室内场景底噪相对平坦固定系数勉强够用户外有树木、护栏等扩展目标CFAR门限要加一个时域滤波平滑否则树木叶子晃动会在连续帧里产生大量闪烁点。常见做法是检测输出后加一个多帧凝聚同一距离-多普勒位置连续三帧出现才算真目标。这个逻辑放在demo的target_list输出之后不影响CFAR本身。4.3 相位法测速的模糊问题和与多普勒FFT的关系多普勒FFT输出的目标速度存在模糊问题最大不模糊速度由chirp周期决定。如果雷达最大测速范围要求30m/s根据v_max λ/(4*chirp_loop_time)反推λ0.005m时chirp_loop_time必须小于约41.7us这会迫使减少每chirp采样点数或降低采样率进而牺牲距离分辨率。这就是FMCW雷达设计里最核心的折中。相位法测速是对同一距离单元、连续两个chirp的复数做相位差分测的是短时间内的微小速度变化能测很慢的移动但相位差超过±π就会模糊。实际中通常用多普勒FFT测粗速度、相位差分测精速度两者结合。demo里只用了多普勒FFT的结果因为相位测速还需要知道哪个距离单元是目标这个信息由CFAR边界决定。在CFAR检测到目标后取它的复数相位再做帧间差分才是完整的测速逻辑demo简化了这一步。4.4 一帧数据到一批目标的验证路径拿demo跑完不直接上板子先用MATLAB或Python做交叉验证。常见做法是把ADC bin文件在MATLAB里用相同的FFT流程处理一遍对比峰值位置和幅度。如果MATLAB和C的结果对不上优先检查数据读取格式——毫米波雷达采集板导出的可能会带文件头长度不一定是samples_per_chirp * chirps_per_frame * sizeof(complexfloat)。代码里std::abs(rdm[idx])用的是复数模没有做对数缩放。CFAR门限直接作用在线性幅度域。如果雷达的ADC位宽是16bit幅值范围从0到65535CFAR在近距强目标旁边容易饱和这时要在CFAR前先做一次归一化把整个RD谱按最大值缩放到0到1之间。这样门限系数的含义更清晰跨场景移植时不至于因为雷达增益设置不同而完全失灵。5. 一个必须掌握的进阶技巧用Goertzel算法做特定距离单元的相位提取多普勒FFT做的是全谱分析但如果你只关心某一个固定距离单元的变化——比如生命体征检测里胸腔微动对应的距离——用Goertzel算法比整条FFT链路省得多。Goertzel本质是单点DFT的递归实现适合在DSP上针对特定频率计算幅度和相位不需要为此分配一整个FFT缓冲区。毫米波雷达生命体征监测是热词里出现的典型应用方向原理是胸腔随呼吸和心跳产生毫米级位移在复数距离谱上体现为该距离单元的相位连续变化。相位变化幅度只有几度到几十度直接取atan2(imag, real)会看到明显的抖动需要先做相位解卷绕再对随时间变化的相位序列做带通滤波。这里Goertzel用于提取特定呼吸频率的幅度。// goertzel.h #pragma once #include complex #include vector // 计算序列在目标数字频率上的幅度和相位 std::complexfloat goertzel_magnitude(const std::vectorstd::complexfloat data, float target_freq, float fs); // 对距离-多普勒谱中某一距离单元的跨chirp相位序列做单频提取 void extract_phase_trend(const std::complexfloat* rdm, int range_bin, int chirps, float fs, float signal_freq, std::vectorfloat phase_out);Goertzel实现的核心是两个递推变量s_prev和s_prev2每输入一个样本更新一次最后算复数输出std::complexfloat goertzel_magnitude( const std::vectorstd::complexfloat data, float target_freq, float fs) { float w 2.0f * M_PI * target_freq / fs; float coeff 2.0f * std::cos(w); float s_prev 0.0f; float s_prev2 0.0f; for (const auto x : data) { float s x.real() coeff * s_prev - s_prev2; s_prev2 s_prev; s_prev s; } float real_out s_prev - s_prev2 * std::cos(w); float imag_out s_prev2 * std::sin(w); return std::complexfloat(real_out, imag_out); }逻辑说明系数coeff由目标频率和采样率决定整个计算过程只涉及两个递归变量不需要蝶形运算和位反转代码量小且适合逐样本流式处理。输出幅度是abs(real_out i*imag_out)相位是atan2(imag_out, real_out)。呼吸信号在0.2到0.5Hz心跳在1到1.7Hz这两个频率落在一个chirp帧的重复频率附近时直接用Goertzel在相位序列上做单频提取能省掉一段完整的FFT。在DSP工程里这段代码甚至可以丢进中断服务函数逐样本处理因为复杂度是O(N)而且不占额外缓冲区。提示Goertzel输出直接用于相位解卷绕时如果相位跳变超过±π要在连续相位差上加减2π。这个和速度模糊的处理思路一致先解卷绕再做滤波不能先滤波再解卷绕。实际到DSP板子上部署时需要再看一下定点化问题。上面这些代码用的是float全志HiFi4这类DSP有硬件浮点单元的话压力不大但如果你用的是定点的DSP核得把FFT和Goertzel里的乘法都换成q15或q31定点格式窗函数和旋转因子查表存成定点数。处理链里最容易在定点化后出问题的是CFAR的除法运算noise_sum / noise_cnt在定点下要改成查表或移位近似否则门限偏差会直接导致虚警率漂移。把这几个地方处理完这套demo才算真正从PC搬到了DSP上。本文还有配套的精品资源点击获取