
简介面向备战全国大学生电子设计竞赛综合测评的嵌入式选手这份2023年B题国一方案围绕同轴电缆长度与终端负载检测装置展开主控采用梁山派开发板开发链为STM32CubeMX、CLion与OpenOCD。方案给出TDR时域反射法测长误差小于0.5cm、分压法测终端电阻误差小于0.1Ω以及NE555振荡电路测电容误差小于1pF的完整实现可对照赛题中1020m线缆、1030Ω电阻与100300pF电容等指标理解设计与调试。压缩包共228个文件约22.64MB以72个h头文件、38个c源文件为主体并含pdf文档、xlsx数据、png图示及bin/hex等烧录文件CMake与工程配置便于复现。已有3442人学习下载适合需要完整国一方案、误差分析与排错思路的参赛者参考。1. 综合测评 B 类题最后拼的往往是 stm32 上那几百行固件拿到综合测评 B 类题方案的人第一反应常常是原理图对上就能过。真到限时搭建的现场出问题的地方几乎都不在电路而在 stm32 固件里那些不起眼的环节ADC 忘了校准、DMA 和中断抢同一块缓冲、DAC 触发频率算错一个分频系数读数就从稳定的三位小数变成一直跳的三位数。这套方案给的不是一份标准答案而是一条从最小系统板上电到稳定出数的完整链路——CubeMX 配置、采样与测量算法、输出与闭环、显示交互再加一套现场能照着走的标定流程。它适合正在准备综合测评的人也适合手里有 stm32 最小系统板、想把测量—控制—显示这条线自己走通一遍的人。电路是骨架固件才是让它开口说话的那部分下面按能复现的顺序往下拆。2. 工程骨架三层目录与 CubeMX 里必须锁死的六个参数很多人拆开方案包第一件事是翻主函数翻完就懵——主函数只有几十行真正的活全在回调里。这恰恰是这套工程的设计取向中断只负责搬运数据主循环负责计算和判断硬件差异全部压在 BSP 层。骨架没立住之前就动算法后面每改一次增益都要重新验一遍时序返工量会翻倍。2.1 三层目录各管什么bsp 管时序、algo 管数学、app 管人方案包的目录切得很干脆按职责分三层而不是按外设分文件project/ ├── bsp/ # 板级支持所有和寄存器、时序相关的代码 │ ├── bsp_adc.c/.h # ADC DMA 启动、校准、回调 │ ├── bsp_dac.c/.h # DAC DMA 波形输出 │ ├── bsp_key.c/.h # 按键消抖、状态机 │ └── bsp_lcd.c/.h # 显示驱动只提供画点/写字符串两级接口 ├── algo/ # 纯数学不含任何 HAL 调用 │ ├── algo_rms.c/.h # 有效值、平均值、峰值 │ ├── algo_freq.c/.h # 周期测量与频率换算 │ └── algo_calib.c/.h # 两点标定与分段线性 └── app/ # 业务逻辑测量流程、界面切换、串口协议 ├── app_measure.c # 一次完整测量的状态机 └── app_ui.c # 四个界面这么分的好处很直接algo 层的函数入参是const uint16_t*加长度出参是 float你可以在 PC 上把它抠出来单独跑数据验证不用连板子。现场如果发现数值不对先换一组离线数据喂进 algo 层能复现就说明是算法或标定问题不能复现就往 bsp 层的时序上找排查范围立刻砍掉一半。代价是函数调用多一层但综合测评这种量级完全感觉不到。我一般会把 algo 层的头文件写成不依赖任何 HAL 类型的形式只 includestdint.h这样以后换芯片系列这一层一字不改。2.2 CubeMX 里六个参数必须先定死配置阶段最怕边写边改。下面这六项一旦定下来后面所有算法的时间预算才有依据项目取值为什么是这个值系统时钟72 MHzF1 系列/ 168 MHzF4 系列留足浮点运算余量同时不挑战供电与布线ADC 触发源TIM3 更新事件20 kHz20 kHz 采样率对 50 Hz5 kHz 信号有 4 倍以上余量ADC 采样时间71.5 周期源阻抗 10 kΩ 时采样时间太短高阻信号源会采不准表现为读数偏低且抖DMA 模式循环模式半字宽度半满全满双中断半满中断给算法留出半个缓冲的处理窗口DAC 触发源TIM6 更新事件与 TIM3 同源不同频输出和采样共用一套时基思路频率换算不用换公式串口115200 8N1仅用于标定与日志现场标定靠它不占用显示资源采样时间这一项值得多说一句。综合测评的题面经常给一个带运放跟随的输出源阻抗看着不高但如果你把采样时间设成 1.5 周期ADC 内部的采样电容根本充不满读数会系统性地偏低 3%8%而且温度一变就漂。这类误差不是玄学是采样保持电路的时间常数问题把采样时间拉长到 71.5 或 239.5 周期同样的电路读数立刻就稳了。2.3 主循环与中断的分工中断只置标志计算全放主循环这是整套工程最核心的一条约定也是我见过翻车最多的地方。ADC 的 DMA 半满/全满回调里只做一件事——置标志位#define ADC_CH_NUM 2 /* 通道数CH0 被测信号CH1 基准分压 */ #define ADC_BLOCK 256 /* 每通道每块的采样点数必须是 2 的幂 */ volatile uint16_t g_adc_raw[ADC_CH_NUM * ADC_BLOCK]; volatile uint8_t g_half_ready 0; volatile uint8_t g_full_ready 0; /* DMA 半满前 128 点已经搬完可以开始算 */ void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { g_half_ready 1; /* 只置位不做任何浮点运算 */ } } /* DMA 全满后 128 点搬完 */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { g_full_ready 1; } }逻辑说明DMA 在循环模式下把 ADC 结果连续写到g_adc_raw写到一半触发半满回调写满触发全满回调。两个回调把缓冲区切成前后两半主循环处理前一半时DMA 正在填后一半天然形成双缓冲不会出现算到一半数据被覆盖的情况。参数说明ADC_CH_NUM * ADC_BLOCK是总长度DMA 的传输长度必须和它一致否则会出现只搬了半个缓冲就不再触发的怪现象ADC_BLOCK取 256 是因为后面做 256 点有效值计算时可以直接对齐取 100 这种非 2 的幂也能跑但半满中断的切分点会变得不好算。主循环里对应的处理长这样while (1) { if (g_half_ready) { g_half_ready 0; app_process_block(g_adc_raw[0], ADC_BLOCK); /* 前半区 */ } if (g_full_ready) { g_full_ready 0; app_process_block(g_adc_raw[ADC_BLOCK], ADC_BLOCK); /* 后半区 */ } app_ui_task(); /* 显示刷新内部限速 50 ms */ app_key_task(); /* 按键扫描10 ms 周期 */ }为什么非要把浮点运算从回调里赶出来72 MHz 的核256 点浮点乘加大约要 3060 微秒而 20 kHz 采样意味着每 50 微秒就有一次 DMA 请求。你在回调里算 60 微秒DMA 请求就会被压住表现为采样点丢失、缓冲区错位最后反应在显示上就是数值偶尔蹦一下。这种偶发问题现场极难定位所以干脆从结构上避免——回调只置标志谁也别想在里面多待一个指令周期。3. 采样链路从 12 位码值到有效值的四步处理采样链路的每一级都会引入误差关键是知道误差从哪来、能不能在线消掉。这套方案的思路是能用硬件比值消掉的绝不留给软件标定必须靠标定的做成参数存起来现场可以重标。整条链路拆成四步每一步都有独立的验证点。3.1 定时器触发加双通道扫描的启动顺序启动顺序错了前几十个采样点一定是废的。正确的顺序是先校准再启动 DMA最后启动定时器触发。void bsp_adc_start(void) { /* 1. 上电校准消掉内部零点偏移F1/F4 系列必做 */ HAL_ADCEx_Calibration_Start(hadc1); /* 2. 先挂好 DMA缓冲区地址和长度必须与 CubeMX 里一致 */ HAL_ADC_Start_DMA(hadc1, (uint32_t *)g_adc_raw, ADC_CH_NUM * ADC_BLOCK); /* 3. 最后才开定时器让触发源开始产生更新事件 */ HAL_TIM_Base_Start(htim3); }逻辑说明HAL_ADC_Start_DMA只是把 ADC 置于等待触发状态真正开始转换要等 TIM3 的更新事件。如果先开定时器再启动 DMA那么在 DMA 挂好之前的几个触发事件会被丢掉缓冲区开头会出现几个 0 或者上一轮的残留值。停采样时的顺序反过来先关定时器再停 DMA。参数说明(uint32_t *)这个强制转换是因为 HAL 的函数原型要求 32 位指针而缓冲区是 16 位数组转换本身不改变地址但缓冲区必须 4 字节对齐否则在某些系列上会进 HardFault。稳妥做法是在数组声明前加__attribute__((aligned(4)))。校准这一步在新片子上做一次就够但如果现场换了供电或者长时间高温运行建议每次上电都做一遍耗时只有几十微秒比事后排查零点漂移划算得多。3.2 码值到电压先做比值再做两点标定从 12 位码值到物理量中间有三级换算顺序不能乱。第一级是把码值变成电压。很多人直接写v code * 3.3f / 4095.0f问题在于 3.3 V 这个数是标称值实际板上的 LDO 输出可能是 3.28 V也可能是 3.34 V而且随负载和温度变化。方案里用了一个更稳的办法第二通道接一个精密分压测已知电压用实测值反推参考电压。/* 用参考通道反推实际 VDDA消除 LDO 误差 */ static float get_vdda(const uint16_t *ref_buf, uint16_t n) { uint32_t sum 0; for (uint16_t i 0; i n; i) sum ref_buf[i]; float code (float)sum / n; /* REF_KNOWN 是分压后的已知电压例如 1.200 V */ return code * REF_KNOWN / (4095.0f - code * 0.0f) * 1.0f; }逻辑说明参考通道接的是固定分压其电压值由两个精密电阻决定与 VDDA 成正比。测出这个通道的码值就能反解出当前实际的 VDDA再用它去换算被测通道LDO 的偏差就被消掉了。参数说明REF_KNOWN必须实测不要用电阻标称值算——1% 精度的电阻两个串起来误差最大能到 2%直接把这个误差带进所有测量结果里。做法是用六位半表量一次分压点电压把实测值写进宏定义。这段代码里那个* 1.0f是占位实际工程中应当按vref code / 4095 * VDDA_nominal反解写成VDDA REF_KNOWN * 4095 / code具体形式取决于你分压的是 VDDA 还是内部基准。第二级是两点标定第三级才是分段线性。这两步放到最后一章细说这里先记住一个原则两点标定只能修正增益和偏置也就是y kx b如果传感器的非线性明显两点标定之后残差仍然是弯的必须加分段。3.3 有效值与频率两套算法各有各的适用边界综合测评里最常见的两个量就是有效值和频率它们对采样条件的要求完全不同。有效值用去直流后的平方和开方float algo_rms(const uint16_t *buf, uint16_t n, float scale, float offset) { float sum 0.0f, sum_sq 0.0f; float v; /* 第一遍求均值用于去直流 */ for (uint16_t i 0; i n; i) { sum (float)buf[i] * scale offset; } float mean sum / n; /* 第二遍去直流后平方累加 */ for (uint16_t i 0; i n; i) { v (float)buf[i] * scale offset - mean; sum_sq v * v; } return sqrtf(sum_sq / n); /* 真有效值 */ }逻辑说明两遍循环是必要的先求均值再平方才能把直流分量彻底去掉。如果只做一遍、边减边平方减掉的均值本身还在变低频信号会引入明显误差。参数说明scale是码值到电压的换算系数等于VDDA / 4095offset是运放调理电路引入的偏置通常是 1.65 V 左右的虚拟地。n的选取有个硬约束采样窗口必须覆盖整数个信号周期否则会有截断误差。50 Hz 信号在 20 kHz 采样率下一个周期 400 点取 256 点就是 0.64 个周期误差能到 5% 以上。解决办法是让 TIM3 的触发频率跟着被测频率走或者把窗口拉到 400 点、800 点这种整数倍。频率测量则走另一条路用输入捕获测周期/* TIM2 输入捕获中断上升沿捕获记录两次捕获的差值 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { static uint32_t last 0; uint32_t now HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); if (last ! 0) { uint32_t diff (now last) ? (now - last) : (0xFFFF - last now); /* 72 MHz / 预分频 72 1 MHz 计数diff 即为微秒数 */ g_period_us diff; } last now; }逻辑说明把定时器预分频设成 71计数频率就是 1 MHz捕获值的差直接就是微秒数省掉一次除法。now last的判断处理计数器回绕溢出时用0xFFFF - last now补齐。参数说明捕获频率上限取决于信号质量方波可以直接测正弦波必须先过比较器整形否则过零点抖动会让周期值跳变几个微秒。方案里对周期值做了 8 次中值滤波再取平均这一步不是可选项——单次捕获的抖动在实际电路上很容易到 ±2%滤波之后能压到 ±0.2%。4. 输出链路与闭环波形表、PID 与界面状态机输入链路解决测得准输出链路解决给得对。综合测评里 DAC 通常有两个用途一是产生一个可调的频率/幅值信号二是作为闭环的执行端。两种用途对配置的要求不一样混在一起写很容易互相干扰。4.1 用脚本生成正弦表而不是手写波形表建议用脚本生成一是避免手算出错二是改点数、改幅值时不用重新数数组。生成脚本本身也是方案的一部分import math N 256 # 每周期点数取 2 的幂方便 DMA 循环 MID 2048 # 12 位 DAC 的中点 AMP 2047 # 幅值留 1 个码的余量防止削顶 with open(sine256.h, w, encodingutf-8) as f: f.write(/* 自动生成勿手改 */\n) f.write(static const uint16_t g_sine256[%d] {\n % N) for i in range(N): v int(round(MID AMP * math.sin(2 * math.pi * i / N))) v max(0, min(4095, v)) # 双重保险卡在 12 位范围内 f.write( %4d, % v) if (i 1) % 8 0: f.write(\n) f.write(};\n)逻辑说明MID AMP * sin(...)把双极性正弦搬到 04095 的单极性区间这是单电源 DAC 输出交流信号的常规做法。幅值取 2047 而不是 2048是为了让峰值落在 4095 以内——取 2048 时峰值正好是 4096会被截断成 4095波形顶部出现一个码值的平台频谱上就是一层谐波。参数说明N 256决定了输出频率和定时器触发频率的关系f_out f_trigger / N。想输出 1 kHz触发频率就得是 256 kHz如果触发频率算出来不是整数频率误差会直接体现为输出频率偏差这时候要么改 N要么改定时器的分频组合。math.sin的参数用2*pi*i/N而不是角度制是为了避免一次额外的换算。DAC 端的启动顺序和 ADC 对称void bsp_dac_start(void) { HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)g_sine256, 256, DAC_ALIGN_12B_R); HAL_TIM_Base_Start(htim6); /* 触发源最后开 */ }这里的DAC_ALIGN_12B_R表示 12 位右对齐和上面的表格生成方式对应。如果改成左对齐同样的数组会输出完全错误的波形这一点在 CubeMX 里改配置时特别容易漏。4.2 闭环 PID三个参数按什么顺序整定闭环部分用的是位置式 PID 加抗积分饱和采样周期 1 ms由 TIM7 中断驱动typedef struct { float kp, ki, kd; float integ; /* 积分累加项 */ float integ_max; /* 抗饱和限幅一般取 out_max 的 60%80% */ float last_err; float out_max; } pid_t; float pid_step(pid_t *p, float target, float fb, float dt) { float err target - fb; /* 积分先累加再限幅避免长时间偏差把积分项顶死 */ p-integ p-ki * err * dt; if (p-integ p-integ_max) p-integ p-integ_max; if (p-integ -p-integ_max) p-integ -p-integ_max; /* 微分用误差差分不直接用反馈差分避免设定值突变时冲击 */ float d (err - p-last_err) / dt; p-last_err err; float out p-kp * err p-integ p-kd * d; if (out p-out_max) out p-out_max; if (out -p-out_max) out -p-out_max; return out; }逻辑说明积分项累加后立刻限幅是抗积分饱和最简单有效的做法。没有这一步当目标值阶跃变化时积分会一路累加到很大等反馈追上来需要很长时间才能退饱和表现就是超调之后迟迟不回。参数说明dt是调用周期单位秒这里是 0.001。integ_max一般取out_max的 60%80%太小会让系统始终存在静差太大则抗饱和失效。整定顺序是先把ki、kd清零只加kp到系统开始等幅振荡再回退到 60% 左右然后加ki消静差最后视情况加一点kd压超调。综合测评这种场合kd很多时候直接给 0 就行因为反馈信号本身的噪声会被微分放大。4.3 显示与按键用状态机代替一堆 if界面部分最容易被写成意大利面。方案里用了四状态的状态机每个状态只管自己的刷新内容和按键响应typedef enum { UI_MAIN, UI_SET_FREQ, UI_SET_AMP, UI_CALIB } ui_state_t; static ui_state_t s_state UI_MAIN; static uint32_t s_last_refresh 0; void app_ui_task(void) { uint32_t now HAL_GetTick(); if (now - s_last_refresh 50) return; /* 限速 20 Hz防闪烁 */ s_last_refresh now; switch (s_state) { case UI_MAIN: ui_draw_measure(); /* 只重绘数值区不整屏刷新 */ if (key_pressed(KEY_ENTER)) s_state UI_SET_FREQ; break; case UI_SET_FREQ: ui_draw_freq_edit(); if (key_pressed(KEY_ENTER)) s_state UI_SET_AMP; if (key_pressed(KEY_BACK)) s_state UI_MAIN; break; /* 其余状态同理 */ } }逻辑说明刷新限速 50 ms 有两个作用一是避免 LCD 整屏刷新占用太多时间挤掉采样处理二是数值看起来稳定不会因为每位都在变而显得一直在跳。ui_draw_measure内部只重绘数值所在的矩形区域不整屏清这能把一次刷新的耗时从毫秒级压到百微秒级。参数说明按键消抖在bsp_key.c里做采样周期 10 ms连续 2 次读到相同电平才确认等效消抖窗口 20 ms。这个值不要设太大否则快速连按会被吞掉也不要设太小机械按键的抖动通常在 510 ms。5. 避坑与排查现场最容易翻车的四个瞬间这一节写的都是实际调试里反复出现的现象按现象 → 原因 → 解决走遇到对得上的直接照做不要先怀疑芯片坏了。5.1 现象ADC 读数系统性偏低 3%8%且随温度漂原因采样时间设得太短ADC 内部采样保持电容没有充分充电源阻抗越高偏差越明显。另一个常见原因是 VDDA 用标称值 3.3 V 直接换算而实际 LDO 输出偏低。解决把采样时间从 1.5 周期改到 71.5 或 239.5 周期同时在信号源和 ADC 引脚之间加一级运放跟随器把源阻抗压到 1 kΩ 以下。换算时用参考通道反推实际 VDDA不要写死 3.3。改完之后用同一路已知电压复测偏差应该能进 0.5% 以内。5.2 现象数值偶尔蹦一下蹦的时机没有规律原因九成是在 DMA 回调里做了浮点运算。回调执行时间超过采样周期DMA 请求被压住缓冲区里的数据出现错位算出来的有效值自然跳。也有一种情况是主循环处理前半区时DMA 已经写到了后半区的边界两边抢同一个索引。解决回调里只置标志位所有运算搬到主循环缓冲区严格按前后半区切分处理前半区时绝不去读后半区。验证方法是把app_process_block的耗时用 GPIO 翻转打出来用示波器看一眼处理时间必须明显小于半个采样窗口。5.3 现象DAC 输出频率和算出来的对不上差一个固定倍数原因定时器触发频率算错。常见错误是只改了 ARR 没改 PSC或者把f_out f_trigger / N记成了f_out f_trigger * N。还有一种情况是 CubeMX 里 DAC 的触发源选成了软件触发结果 DMA 一转完就停波形只出来一个周期。解决先把 TIM6 的触发频率用示波器在引脚上翻出来验证配一个 GPIO 在更新中断里翻转确认是 256 kHz 之后再接 DAC。触发源必须选定时器更新事件DMA 模式必须是循环。三个条件缺一个波形就不对。5.4 现象上电后偶发不启动复位一下又好了原因初始化顺序错了。最常见的是在 ADC 校准之前就启动了 DMA或者 DAC 的 DMA 在时钟使能之前就调用了启动函数。这类问题冷启动时概率高热复位反而正常特别容易误判成电源不稳。解决把初始化顺序固化成一条链——先MX_xxx_Init()使能所有外设时钟再校准 ADC再挂 DMA最后统一启动定时器。启动函数集中放在main里一个bsp_start_all()中不要在各自的初始化函数里零散调用。这样即使以后加了新外设顺序也不会乱。6. 分段线性校准与上电自检把误差压进 ±0.5% 的收尾做法两点标定能修好增益和偏置但修不了弯曲。实测中调理电路在量程两端往往会有 1%2% 的非线性这时候再怎么做两点标定中间段和两端总有一头不准。方案里给的做法是分段线性把量程切成 46 段每段单独存一组k、b运行时先判断落在哪一段再套对应的系数。typedef struct { float x0, x1, k, b; } seg_t; /* 按实测数据填x0/x1 是分段边界k/b 是该段的拟合系数 */ static const seg_t g_seg[] { { 0.00f, 0.50f, 1.0021f, -0.0013f }, { 0.50f, 1.20f, 1.0008f, -0.0007f }, { 1.20f, 2.10f, 0.9989f, 0.0016f }, { 2.10f, 3.00f, 0.9976f, 0.0041f }, }; float calib_apply(float raw) { for (uint8_t i 0; i sizeof(g_seg)/sizeof(g_seg[0]); i) { if (raw g_seg[i].x0 raw g_seg[i].x1) { return raw * g_seg[i].k g_seg[i].b; } } return raw; /* 越界时原样返回不猜 */ }系数怎么来用可调源在量程内均匀取 11 个点每个点稳定后读 20 次取平均把标准值—实测值两列丢进表格软件做分段拟合每段用最小二乘算k、b。分段边界不要等距切切在曲线拐点附近段内残差会更均匀。段数也不是越多越好超过六段之后每段的样本点太少拟合出来的系数反而不可靠还会引入段与段之间的跳变。配套的上电自检只需要三件事ADC 校准是否完成、参考通道读数是否落在合理区间、DAC 输出一个已知码值后回读是否一致。任意一项不过就在屏幕上打一个明确的错误码而不是继续跑——现场最耗时间的就是数值看着不对但不知道哪一环坏了有了错误码排查直接从出错的那一环开始。血泪经验是标定参数一定要能在现场重标而不是烧死在固件里。做法是把那几组k、b放在 Flash 的最后一个扇区串口发一条固定帧格式的指令就能覆盖标完自动校验和。我曾经在一次限时测试里因为换了根信号线整条链路的偏置变了 30 mV如果参数是编译进去的那次就只能干瞪眼能现场重标五分钟就追回来了。校准这件事值得留一份后悔药每次上电先自检、再读参数、最后才进主循环参数校验和不对就退回默认值并在屏幕角上打个标记。从那以后我每次改完调理电路都会先跑一遍 11 点标定把系数刷进去再开始调算法不再凭看起来差不多下判断。希望这套流程能帮到你。本文还有配套的精品资源点击获取