文章目录
-
- 前言
- 一、项目概述
- 二、核心原理
-
- 2.1 三种测频方案为什么选等精度法
- 2.2 等精度法的误差推导
- 2.3 闸门如何与信号边沿同步
- 2.4 ITR 内部触发是硬连线,不是想连就连
- 三、硬件选型清单
- 四、硬件接线
- 五、软件设计
-
- 5.1 三个定时器的寄存器级配置
- 5.2 容易遗漏的三个步骤
- 5.3 核心代码实现
- 六、测试验证
-
- 6.1 全频段误差对比:等精度 vs 直接计数
- 6.2 理论 vs 实测对照一:量化误差模型
- 6.3 理论 vs 实测对照二:闸门时间参数扫描
- 6.4 标准时钟标定:把 −24 ppm 收进 ±3 ppm
- 七、故障排查
- 八、总结
摘要:直接计数法测频的误差随被测频率降低而恶化,10 Hz 信号在 1 s 闸门下偏差可达 ±10%,难以满足传感器标定与仪器校准需求。本文基于 STM32F103C8T6,利用 TIM3 触发模式让闸门起点与被测信号边沿硬件同步,TIM4 经 ITR2 级联为标准时钟计数器扩展至 32 位,再配合 EXTI 双端快照构成等精度(M/T 法)频率计。实测 1 Hz~1 MHz 范围内测量重复性优于 2 ppm,10 Hz 点误差由直接计数法的 ±10% 压缩三个数量级以上;闸门时间从 0.1 s 扫描到 2.0 s,量化误差实测曲线与 ±1/Ns 理论模型吻合。全文提供寄存器级配置说明、初始化顺序约束、故障排查链与晶振标定方法。
前言
做嵌入式开发绕不开频率测量:霍尔传感器的转速脉冲、流量计的脉冲输出、超声换能器的驱动频率,本质上都是"数边沿"。最常见的做法是开一个 1 秒闸门,在定时器更新中断里读计数器,也就是教科书上的直接计数法(M 法)。这个方法在高频段表现很好,1 MHz 信号 1 秒闸门的相对误差只有 0.0001%。但它的误差公式是 ±1/N,N 是闸门内的脉冲数——频率越低,N 越小,误差越大。10 Hz 信号误差 ±10%,1 Hz 信号误差直接 ±100%,读数完全不可信。
解决这个问题有一套经典方案叫等精度测量(也称 M/T 法或倒数计数法):闸门不再固定时长,而是与被测信号边沿同步开合,同时用两个计数器分别记录闸门内的被测脉冲数和标准时钟数,最终频率 = 脉冲数 / 时钟数 × 标准时钟频率。这样量化误差只取决于标准时钟,与被测频率无关——这正是"等精度"三个字的含义。STM32 的定时器主从模式恰好为这套方案提供了全硬件支撑,但把原理落到 F103 上会撞到几个很隐蔽的坑:内部触发 ITR 是硬连线不能任选、触发启动的同步延迟、HAL 库启动后的残留更新事件等,任何一个处理不对都会让低频段精度不升反降。
本文的目标是把这套方案在 F103C8T6 上完整落地:从 ITR 映射约束出发做设计决策,给出寄存器级的配置链和初始化顺序约束,用 DDS 信号发生器做 1 Hz~1 MHz 的实测对比与闸门参数扫描,并复盘两个我实际踩过的坑。读者需要具备 STM32 定时器基础(会配 PWM 和更新中断即可),硬件上需要两块 F103 最小系统板或一台信号发生器。完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。
一、项目概述
本项目实现一台基于 STM32F103C8T6 的等精度频率计,输入为 0~3.3 V 方波信号,测量范围 1 Hz~1 MHz,测量结果通过 USART1 打印到串口助手。整机只用三片片上定时器加一条 EXTI 中断线,不需要任何额外测量芯片,成本就是一块十几元的最小系统板。系统的工作方式可以概括为一句话:被测信号边沿负责"开关闸门",标准时钟负责"度量闸门到底有多长",两个计数器在同一窗口内的读数之比就是频率。
整体数据流如下图。被测信号同时送到 PA0(TIM2 通道输入,硬件计数脉冲)和 PA6(TIM3 通道输入,硬件触发启动闸门);TIM3 以 1 MHz 标准时钟计数,每次溢出通过 TRGO 打给 TIM4 级联累加,两者拼成 32 位标准时钟计数器;EXTI0 中断在闸门两端做快照,软件用快照差分算出频率。
#mermaid-svg-wNpVHEjXgTVHo5OL{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wNpVHEjXgTVHo5OL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wNpVHEjXgTVHo5OL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wNpVHEjXgTVHo5OL .error-icon{fill:#552222;}#mermaid-svg-wNpVHEjXgTVHo5OL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wNpVHEjXgTVHo5OL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wNpVHEjXgTVHo5OL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wNpVHEjXgTVHo5OL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wNpVHEjXgTVHo5OL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wNpVHEjXgTVHo5OL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wNpVHEjXgTVHo5OL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wNpVHEjXgTVHo5OL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wNpVHEjXgTVHo5OL .marker.cross{stroke:#333333;}#mermaid-svg-wNpVHEjXgTVHo5OL svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wNpVHEjXgTVHo5OL p{margin:0;}#mermaid-svg-wNpVHEjXgTVHo5OL .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-wNpVHEjXgTVHo5OL .cluster-label text{fill:#333;}#mermaid-svg-wNpVHEjXgTVHo5OL .cluster-label span{color:#333;}#mermaid-svg-wNpVHEjXgTVHo5OL .cluster-label span p{background-color:transparent;}#mermaid-svg-wNpVHEjXgTVHo5OL .label text,#mermaid-svg-wNpVHEjXgTVHo5OL span{fill:#333;color:#333;}#mermaid-svg-wNpVHEjXgTVHo5OL .node rect,#mermaid-svg-wNpVHEjXgTVHo5OL .node circle,#mermaid-svg-wNpVHEjXgTVHo5OL .node ellipse,#mermaid-svg-wNpVHEjXgTVHo5OL .node polygon,#mermaid-svg-wNpVHEjXgTVHo5OL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wNpVHEjXgTVHo5OL .rough-node .label text,#mermaid-svg-wNpVHEjXgTVHo5OL .node .label text,#mermaid-svg-wNpVHEjXgTVHo5OL .image-shape .label,#mermaid-svg-wNpVHEjXgTVHo5OL .icon-shape .label{text-anchor:middle;}#mermaid-svg-wNpVHEjXgTVHo5OL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wNpVHEjXgTVHo5OL .rough-node .label,#mermaid-svg-wNpVHEjXgTVHo5OL .node .label,#mermaid-svg-wNpVHEjXgTVHo5OL .image-shape .label,#mermaid-svg-wNpVHEjXgTVHo5OL .icon-shape .label{text-align:center;}#mermaid-svg-wNpVHEjXgTVHo5OL .node.clickable{cursor:pointer;}#mermaid-svg-wNpVHEjXgTVHo5OL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wNpVHEjXgTVHo5OL .arrowheadPath{fill:#333333;}#mermaid-svg-wNpVHEjXgTVHo5OL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wNpVHEjXgTVHo5OL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wNpVHEjXgTVHo5OL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wNpVHEjXgTVHo5OL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wNpVHEjXgTVHo5OL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wNpVHEjXgTVHo5OL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wNpVHEjXgTVHo5OL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wNpVHEjXgTVHo5OL .cluster text{fill:#333;}#mermaid-svg-wNpVHEjXgTVHo5OL .cluster span{color:#333;}#mermaid-svg-wNpVHEjXgTVHo5OL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wNpVHEjXgTVHo5OL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wNpVHEjXgTVHo5OL rect.text{fill:none;stroke-width:0;}#mermaid-svg-wNpVHEjXgTVHo5OL .icon-shape,#mermaid-svg-wNpVHEjXgTVHo5OL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wNpVHEjXgTVHo5OL .icon-shape p,#mermaid-svg-wNpVHEjXgTVHo5OL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wNpVHEjXgTVHo5OL .icon-shape .label rect,#mermaid-svg-wNpVHEjXgTVHo5OL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wNpVHEjXgTVHo5OL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wNpVHEjXgTVHo5OL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wNpVHEjXgTVHo5OL :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
PA0 / TIM2_CH1
PA6 / TIM3_CH1
TRGO 溢出脉冲ITR2 硬连线
更新中断 100Hz
PA0 / EXTI0
被测方波信号
TIM2外部时钟模式1计脉冲数 Nx
TIM3触发模式启动1MHz 标准时钟
TIM4外部时钟模式1级联高位
软件状态机闸门控制
EXTI0 中断双端快照
f = ΔNx / ΔNs × 1MHz串口输出
二、核心原理
2.1 三种测频方案为什么选等精度法
测频方案的选择本质上是误差特性与资源开销的权衡。下表把三条常见路线放在一起对比,数据以 1 s 闸门为基准。
| 误差来源 | ±1 个被测脉冲 | ±1 个标准时钟 | ±1 个标准时钟 |
| 10 Hz 误差 | ±10% | ±0.01%(依赖高频计数时钟) | ±0.0002% |
| 1 MHz 误差 | ±0.0001% | ±0.0001% | ±0.0002% |
| 误差随频率 | 低频急剧恶化 | 高频时中断/溢出压力大 | 与频率基本无关 |
| 硬件资源 | 1~2 个定时器 | 1~2 个定时器 | 3 个定时器 + 1 条 EXTI |
直接计数法的问题前面已经算过;测周法用高频时钟量单周期,低频精度很好,但每个周期都要捕获一次,1 MHz 信号意味着每秒 100 万次捕获,软件平均或 DMA 搬运的开销都不小。等精度法把"测频"变成"测比值",误差特性与频率无关,代价是多用一个定时器做级联和一条 EXTI 线做快照——对 F103 这类定时器富余的芯片来说是最划算的取舍。
相关阅读:《STM32定时器输入捕获深度解析:基于HAL库的PWM频率与占空比高精度测量》 — 输入捕获测周法的完整实现,与本文互为补充
2.2 等精度法的误差推导
设闸门实际时长为 Tg,被测信号频率 fx,标准时钟频率 f0。闸门两端与信号边沿对齐后,闸门内恰好包含 Nx 个完整信号周期,同时包含 Ns 个标准时钟周期,于是有:
Nx / fx = Ns / f0 → fx = (Nx / Ns) × f0
关键在于 Nx 是精确的整数(两端都对齐边沿,没有"半个周期"),唯一的不确定度来自 Ns 的读数量化,最多差 ±1 个时钟周期。因此相对误差:
δf / f ≈ ±1/Ns = ±1/(f0 × Tg)
代入 f0 = 1 MHz、Tg = 1 s,误差约 ±1 ppm,而且无论被测信号是 10 Hz 还是 1 MHz 都是这个量级——这就是"等精度"的由来。对比直接计数法的 ±1/(fx·Tg),低频段的改善是四个数量级的。需要说明的是,等精度法消除的是计数量化误差,测量的绝对准确度仍受标准时钟本身的晶振偏差限制,这一项靠 6.4 节的标定解决。
2.3 闸门如何与信号边沿同步
整套方案的时序如下图。软件使能三个定时器后,TIM3 处于"待触发"状态(触发模式,SMS=110);被测信号第一个上升沿 t0 到来时,TIM3 被硬件启动,同一个边沿被 EXTI0 捕获做起点快照。TIM3 计满标称闸门时长(100 次更新,1.000 s)后状态机转入"等关闸",被测信号下一个上升沿 tc 到来时 EXTI0 做终点快照。闸门两端都是信号边沿,ΔNx 自然是整数。
EXTI0
TIM3(标准时钟)
被测信号
软件
EXTI0
TIM3(标准时钟)
被测信号
软件
#mermaid-svg-15cFyIomJHoNdgyG{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-15cFyIomJHoNdgyG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-15cFyIomJHoNdgyG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-15cFyIomJHoNdgyG .error-icon{fill:#552222;}#mermaid-svg-15cFyIomJHoNdgyG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-15cFyIomJHoNdgyG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-15cFyIomJHoNdgyG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-15cFyIomJHoNdgyG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-15cFyIomJHoNdgyG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-15cFyIomJHoNdgyG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-15cFyIomJHoNdgyG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-15cFyIomJHoNdgyG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-15cFyIomJHoNdgyG .marker.cross{stroke:#333333;}#mermaid-svg-15cFyIomJHoNdgyG svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-15cFyIomJHoNdgyG p{margin:0;}#mermaid-svg-15cFyIomJHoNdgyG .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-15cFyIomJHoNdgyG text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-15cFyIomJHoNdgyG .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-15cFyIomJHoNdgyG .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-15cFyIomJHoNdgyG .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-15cFyIomJHoNdgyG .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-15cFyIomJHoNdgyG #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-15cFyIomJHoNdgyG .sequenceNumber{fill:white;}#mermaid-svg-15cFyIomJHoNdgyG #sequencenumber{fill:#333;}#mermaid-svg-15cFyIomJHoNdgyG #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-15cFyIomJHoNdgyG .messageText{fill:#333;stroke:none;}#mermaid-svg-15cFyIomJHoNdgyG .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-15cFyIomJHoNdgyG .labelText,#mermaid-svg-15cFyIomJHoNdgyG .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-15cFyIomJHoNdgyG .loopText,#mermaid-svg-15cFyIomJHoNdgyG .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-15cFyIomJHoNdgyG .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-15cFyIomJHoNdgyG .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-15cFyIomJHoNdgyG .noteText,#mermaid-svg-15cFyIomJHoNdgyG .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-15cFyIomJHoNdgyG .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-15cFyIomJHoNdgyG .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-15cFyIomJHoNdgyG .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-15cFyIomJHoNdgyG .actorPopupMenu{position:absolute;}#mermaid-svg-15cFyIomJHoNdgyG .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-15cFyIomJHoNdgyG .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-15cFyIomJHoNdgyG .actor-man circle,#mermaid-svg-15cFyIomJHoNdgyG line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-15cFyIomJHoNdgyG :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
使能三定时器(T3待触发)
边沿 t0 硬件启动 TIM3
同一边沿触发 EXTI0
起点快照 Nx0/Ns0
100 次更新(1.000s)转"等关闸"
边沿 tc(标称到期后首个沿)
终点快照 Nx1/Ns1
f = ΔNx/ΔNs × f0 串口输出
这里有一个容易忽略的细节:起点快照读的是 t0 之后约 0.3 µs 的计数器值(EXTI 中断有延迟),但终点快照同样延迟了差不多的时间,两个快照一减,窗口两端各平移了同样的量,窗口长度不变。这种"双端同延快照"让软件延迟几乎不引入误差,残余影响只有两次中断延迟的抖动差,实测约 0.2~0.3 ppm。
2.4 ITR 内部触发是硬连线,不是想连就连
方案里 TIM4 之所以能数 TIM3 的溢出,靠的是 TIM4 的从模式外部时钟 1(SMS=111)以 ITR2 为时钟源,而 F103 的 TIM4 内部触发连接是芯片出厂硬连线的:ITR0=TIM1_TRGO、ITR1=TIM1_CC1、ITR2=TIM3_TRGO、ITR3=TIM2_TRGO(RM0008 Table 71)。查这张表还能发现一个重要约束:TIM2 的四个触发源全部来自 TIM1,它根本收不到 TIM3/TIM4 的 TRGO。所以级联从机只能落在 TIM4 上,这不是偏好,是硬约束——如果有人想把脉冲计数器挂在 TIM2 的从模式上做门控,表一查就会碰壁。
相关阅读:《定时器的主模式选择 (Master mode selection)与从模式选择 (Slave mode selection)》 — SMS/TS/MMS 三个位段的完整语义,配合 RM0008 Table 71 食用
三、硬件选型清单
整机物料非常精简,除了信号源外都是常见库存件。
| 主控 | STM32F103C8T6 最小系统板 | 1 | 频率计本体 | 晶振建议选 ±20ppm 以内的 |
| 信号源 | JDS6600 DDS 发生器 | 1 | 提供标准频率 | 无发生器可用第二块板 PWM 输出中高频段 |
| 串口工具 | CH340 USB-TTL | 1 | 输出结果 | 波特率 115200 |
| 逻辑分析仪 | 24MHz 8 通道 | 1(可选) | 抓时序排障 | 调试期强烈建议备一台 |
| 杜邦线 | — | 若干 | 信号分配到两引脚 | 尽量短,高频时尤其重要 |
关于信号源多说一句:DDS 发生器的时基精度一般优于 ±50 ppm,用来考核 ±2 ppm 级的重复性没问题,但考核绝对误差时要把发生器自身的偏差计入。第二块 F103 板用 TIM1 输出 PWM 可以覆盖约 1.1 kHz~1 MHz(72 MHz / 65536 是单定时器 PWM 的频率下限),更低的频率段就只能靠发生器或者 RC 分频了。
四、硬件接线
被测信号需要同时接到两个引脚:PA0 负责 TIM2 计数,PA6 负责 TIM3 触发启动。这两个功能没有复用到同一个引脚,是因为 TIM3 的触发路径只能走自己的通道引脚,而 TIM2 的外部时钟模式 1 也只能走 TI1FP1。面包板上把信号节点用两根杜邦线分别引到 PA0 和 PA6 即可,属于同一电气节点。
| 被测信号 | 信号发生器 CH1 | PA0 (TIM2_CH1) | 计数路径,3.3V 方波 |
| 被测信号 | 同一节点 | PA6 (TIM3_CH1) | 触发路径,与 PA0 短接 |
| 串口 TX | PA9 (USART1_TX) | CH340 RX | 结果打印 |
| 共地 | 发生器/CH340 | 板 GND | 必须共地 |
两点提醒。第一,输入信号幅度必须在 0~3.3 V 之间,5 V 逻辑的信号要经过分压或电平转换再进来,否则可能损坏引脚;如果信号边沿缓慢或带毛刺,建议前端加一级施密特整形(74HC14)。第二,测试 1 MHz 以上信号时杜邦线要尽量短,长线的分布电感会让边沿振铃,造成多计数,这个现象在故障排查一节还会出现。
五、软件设计
5.1 三个定时器的寄存器级配置
配置基于 CubeMX + HAL 库,但每个选项我都标注了对应的寄存器位段,不用 CubeMX 的读者照着寄存器写也可以。先看 TIM2——脉冲计数器。Slave Mode 选 External Clock Mode 1(SMCR.SMS=111),Trigger Source 选 TI1FP1(SMCR.TS=001),Channel1 配 Input Capture Direct mode(CCMR1.CC1S=01,CCER.CC1P=0 上升沿,CCER.CC1E=1 使能捕获通路),数字滤波 IC1F 先设 0011(8 个时钟周期消抖),Counter Period 保持 65535(ARR=0xFFFF),并勾选 NVIC 的 TIM2 update interrupt——溢出软件累加全靠它。
再看 TIM3——标准时钟与闸门发生器。Prescaler 设 71(PSC=71,72 MHz/72 = 1 MHz 计数时钟),Counter Period 设 9999(ARR=9999,每 10 ms 溢出一次),Slave Mode 选 Trigger Mode(SMCR.SMS=110),Trigger Source 选 TI1FP1(SMCR.TS=101,注意这里走的是 TIM3 自己的通道,与 TIM2 的 TS 值不同),Channel1 同样配 Input Capture Direct mode,Trigger Output Parameters 选 Update Event(CR2.MMS=010,把溢出事件送到 TRGO 供 TIM4 级联),NVIC 勾选 TIM3 update interrupt。
最后是 TIM4——级联高位。Slave Mode 选 External Clock Mode 1(SMCR.SMS=111),Trigger Source 选 ITR2(SMCR.TS=010,即 2.4 节硬连线表里的 TIM3_TRGO),Counter Period 65535,不需要开中断。TIM4 每收到一个 TRGO 脉冲计一个数,于是标准时钟总读数满足 Ns = TIM4_CNT × 10000 + TIM3_CNT,等效 32 位。1 MHz 时钟下 16 位计数器 65.5 ms 就溢出一次,没有 TIM4 级联的话 1 s 闸门内要处理一万五千多次溢出中断,CPU 负载直接上 1.5%,这就是为什么标准时钟选 1 MHz 而不是直接用 72 MHz 的原因之一。
关于标准时钟频率的取舍值得单独展开,三个候选值我做过定量对比:
| 72 MHz (PSC=0) | 约 1098 次/s,负载 ~1.5% | ±0.014 ppm | 1098 个/s | 精度最好但中断偏密 |
| 1 MHz (PSC=71) | 100 次/s,负载 ~0.1% | ±1 ppm | 100 个/s | 精度与负载平衡,选用 |
| 10 kHz (PSC=7199) | 1 次/s,几乎为零 | ±100 ppm | 理论上无需级联 | 量化误差过大,放弃 |
另外 TIM2 为什么用外部时钟模式 1(TI1FP1)而不用外部时钟模式 2(ETR 引脚):一是 TI1FP1 通路挂接了数字滤波器 IC1F,可以灵活设置消抖宽度,ETR 通路的滤波档位(ETF)粒度不同且与触发模式组合有额外限制;二是 PA0 同时是 TIM2_CH1 和 TIM2_ETR,走 TI1FP1 一根线就能同时服务计数与 EXTI 快照,接线最省。
5.2 容易遗漏的三个步骤
这三件事 CubeMX 都不会替你做,漏了任何一个,症状都很迷惑。
其一,启动残留更新事件。 MX_TIM3_Init 末尾 HAL 会发一次 UG 事件让 PSC/ARR 立即生效,这会顺手把 UIF 置位。TIM3 以 Start_IT 启动后,第一个更新中断其实是这次残留事件,状态机会把它算进闸门计数,标称闸门因此缩短 10 ms。我在 7 节会展示它造成的离奇症状。处理办法是 Start_IT 之后立刻调一次 __HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE),或者约定回调里第一个计数不计。
其二,TIM4 必须先于 TIM3 使能。 TIM4 数的是 TIM3 的溢出脉冲,如果 TIM3 先跑起来,TIM4 使能前的溢出会永久丢失——1 s 闸门里晚 10 ms 使能就是丢 1 个溢出,折合 10000 个标准时钟,误差直接 1%。所以初始化顺序必须固定为:TIM4 → TIM2 → EXTI/状态机 → TIM3,最后使能 TIM3 让它待触发。
其三,EXTI 与状态机的就绪顺序。 状态标志必须在 TIM3 使能之前就置为"武装"状态。如果顺序反了,t0 边沿来了 EXTI 却处于忽略态,TIM3 照样被硬件启动,闸门起点就丢了,下次边沿才补快照——低频段会引入整整一个信号周期的系统误差。三个使能动作之间的间隙里若恰好来一个边沿,还会出现"TIM3 未启动但 EXTI 抓到了沿"的中间态,状态机的 armed 判断天然把这种情况过滤掉了。
相关阅读:《STM32F4定时器级联扩展计时范围》 — 主从级联把 16 位定时器拼成 32 位的同源思路,F4/F1 寄存器语义一致
5.3 核心代码实现
驱动按"初始化顺序严格分层 + 双端快照差分"组织。先看关键数据结构与状态机定义:
/* freq_meter.h */
#define FM_STD_CLK_HZ 1000000.0 /* 标准时钟: 72MHz/(PSC+1), PSC=71 */
#define FM_TICKS_PER_OVF 10000 /* TIM3 单次溢出对应 tick 数 = ARR+1 */
#define FM_NOM_GATE_OVF 100 /* 标称闸门 = 100 次溢出 = 1.000s */
#define FM_TIMEOUT_OVF 200 /* 2.000s 无关闸沿则判无信号 */
typedef enum {
FM_IDLE = 0, /* 未启动 */
FM_ARMED, /* 已武装, 等待首个边沿 t0 */
FM_RUNNING, /* t0 已捕获, 闸门进行中 */
FM_EXPIRED, /* 标称闸门已到, 等待关闸边沿 tc */
FM_DONE, /* 测量完成, 结果有效 */
FM_ERR_NO_SIG /* 超时, 无信号 */
} fm_state_t;
typedef struct {
uint32_t nx; /* 被测脉冲总数 = ovf2*65536 + TIM2->CNT */
uint32_t ns; /* 标准时钟总数 = TIM4->CNT*10000 + TIM3->CNT */
} fm_snap_t;
/* 中断与主循环共享, 必须 volatile */
volatile fm_state_t g_fm_state = FM_IDLE;
volatile uint32_t g_tim2_ovf = 0; /* TIM2 溢出计数 */
volatile uint32_t g_t3_ui = 0; /* TIM3 更新计数(闸门/超时用) */
static volatile fm_snap_t g_snap0, g_snap1;
初始化函数把 5.2 节的顺序约束固化下来,每一步都检查返回值:
/* freq_meter.c : 初始化, 顺序不可打乱 */
int FM_Start(void)
{
/* 1) TIM4 先使能: 它数 TIM3 的溢出, 晚使能会永久丢高位 */
if (HAL_TIM_Base_Start(&htim4) != HAL_OK)
return –1;
/* 2) TIM2 使能并开溢出中断: 1MHz 信号下 15.26 次/s, 负载可忽略 */
g_tim2_ovf = 0;
if (HAL_TIM_Base_Start_IT(&htim2) != HAL_OK)
return –2;
/* 3) 状态先武装, EXTI 再挂上, 两者都就绪后才允许 TIM3 待触发 */
g_t3_ui = 0;
g_fm_state = FM_ARMED;
HAL_NVIC_EnableIRQ(EXTI0_IRQn); /* PA0 上升沿 */
/* 4) TIM3 最后启动: 写 CEN 后处于待触发, 首边沿硬件开闸 */
if (HAL_TIM_Base_Start_IT(&htim3) != HAL_OK)
return –3;
/* HAL init 发过 UG, UIF 有残留, 不清会少 10ms 标称闸门(见 5.2) */
__HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE);
return 0;
}
快照函数是精度的心脏,读 TIM4/TIM3 拼接值时必须关中断防撕裂:
/* 双端快照: 在 EXTI0 上下文中调用, 读数前关中断 */
static fm_snap_t FM_Snapshot(void)
{
fm_snap_t s;
uint32_t primask = __get_PRIMASK();
__disable_irq();
s.nx = (uint32_t)g_tim2_ovf * 65536U + __HAL_TIM_GET_COUNTER(&htim2);
s.ns = (uint32_t)__HAL_TIM_GET_COUNTER(&htim4) * FM_TICKS_PER_OVF
+ __HAL_TIM_GET_COUNTER(&htim3);
if (!primask) { __enable_irq(); }
return s;
}
/* PA0 边沿: 兼职闸门起点与终点 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin != GPIO_PIN_0) return;
switch (g_fm_state) {
case FM_ARMED: /* t0: TIM3 已被同一沿硬件启动 */
g_snap0 = FM_Snapshot();
g_fm_state = FM_RUNNING;
break;
case FM_EXPIRED: /* tc: 标称到期后的首个边沿 */
g_snap1 = FM_Snapshot();
HAL_NVIC_DisableIRQ(EXTI0_IRQn);
g_fm_state = FM_DONE;
break;
default:
break; /* 其余状态忽略, 过滤中间态毛刺 */
}
}
闸门到期与超时都挂在 TIM3 的更新中断里,100 Hz 的频率对 CPU 几乎无感:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2) { /* 被测脉冲计数器溢出 */
g_tim2_ovf++;
} else if (htim->Instance == TIM3) { /* 标准时钟节拍 */
g_t3_ui++;
if (g_fm_state == FM_RUNNING && g_t3_ui >= FM_NOM_GATE_OVF)
g_fm_state = FM_EXPIRED; /* 转等关闸, 边沿随时可来 */
else if (g_fm_state != FM_IDLE && g_t3_ui >= FM_TIMEOUT_OVF)
g_fm_state = FM_ERR_NO_SIG; /* 2s 无关闸沿, 判无信号 */
}
}
主循环里做防越界检查后计算结果,用 64 位中间量避免溢出:
void FM_PollAndReport(void)
{
if (g_fm_state != FM_DONE && g_fm_state != FM_ERR_NO_SIG) return;
if (g_fm_state == FM_ERR_NO_SIG) {
printf("FM: no signal, retry\\r\\n");
} else {
uint64_t dnx = (uint64_t)g_snap1.nx – g_snap0.nx;
uint64_t dns = (uint64_t)g_snap1.ns – g_snap0.ns;
if (dns == 0 || dnx == 0) { /* 防除零, 窗口内无脉冲视为无效 */
printf("FM: invalid window\\r\\n");
} else {
double f = (double)dnx * FM_STD_CLK_HZ / (double)dns;
printf("FM: %.4f Hz (Nx=%llu, Ns=%llu)\\r\\n", f,
(unsigned long long)dnx, (unsigned long long)dns);
}
}
g_fm_state = FM_IDLE; /* 等待下次 FM_Start() */
}
六、测试验证
6.1 全频段误差对比:等精度 vs 直接计数
测试条件:JDS6600 输出 3.3 V 方波,占空比 50%,每点连续测 20 次取均值与标准差,闸门 1 s。直接计数法数据用同一块板跑 1 s 固定闸门获得。下表的"偏差"为相对发生器标称值的偏差(未做晶振标定)。
| 10 Hz | 9.99976 Hz | −24 ppm | 1.1 ppm | −10% ~ +10% |
| 100 Hz | 99.9976 Hz | −24 ppm | 0.9 ppm | ±1% |
| 1 kHz | 999.976 Hz | −24 ppm | 0.8 ppm | ±0.1% |
| 10 kHz | 9999.76 Hz | −24 ppm | 0.7 ppm | ±0.01% |
| 100 kHz | 99997.6 Hz | −24 ppm | 0.8 ppm | ±0.001% |
| 1 MHz | 999976 Hz | −24 ppm | 0.6 ppm | ±0.0001% |
两个结论一目了然:等精度法的重复性在四个数量级的频率范围内稳定在 1 ppm 上下,与被测频率基本无关;而直接计数法在 10 Hz 点偏差达到两位数百分比。所有频点一致的 −24 ppm 是系统性的,根源在标准时钟的晶振,下一节的标定就是冲它去的。
6.2 理论 vs 实测对照一:量化误差模型
按 2.2 节模型,1 s 闸门、1 MHz 标准时钟的最坏量化偏差为 ±1/Ns = ±1 ppm;若按 20 次测量的标准差口径折算,均匀分布的量化噪声预期 σ ≈ 0.6 ppm,叠加 EXTI 中断延迟抖动(约 0.2 µs,折合 0.2 ppm)后,预测区间为 0.7~0.9 ppm。
| 1 s | ±1.0 ppm | 0.7~0.9 ppm | 0.6~1.1 ppm | 吻合;高频点 σ 略低,与窗口内更多边沿平均了边沿抖动的趋势一致 |
实测值落在预测区间附近且不随频点漂移,说明误差模型没有遗漏主项,双端同延快照确实把中断延迟抖动抵消了大半。这个对照确认之后,后续的标定和参数扫描才有意义。
6.3 理论 vs 实测对照二:闸门时间参数扫描
固定输入 100 kHz(标定后),把标称闸门从 0.1 s 扫到 2.0 s(对应 FM_NOM_GATE_OVF 取 10/50/100/200),每点 20 次。100 kHz 信号周期 10 µs,关闸沿最多比标称到期时刻晚一个周期,实际闸门范围如表所列。理论 σ 按量化噪声口径折算 0.6/Ns。
| 0.1 s | 0.10000~0.10001 s | 5.8 ppm | 6.8 ppm | 与纯量化预期接近,叠加读数抖动 |
| 0.5 s | 0.50000~0.50001 s | 1.2 ppm | 2.4 ppm | 抖动与信号源短期漂移开始可见 |
| 1.0 s | 1.00000~1.00001 s | 0.6 ppm | 1.2 ppm | 吻合趋势 |
| 2.0 s | 2.00000~2.00001 s | 0.3 ppm | 1.0 ppm | 触及晶振短期稳定度地板 |
小闸门下实测 σ 与纯量化预期同量级,说明计数量化是主导项;随着闸门拉长,实测值不再跟随理论线下降,误差主导项切换为无源晶振的短期稳定度(约 1 ppm 量级),继续加闸门意义不大。要再往下压就得给板子换 TCXO 时钟,或者定期用标准源校准。这个拐点本身也是数据:它告诉你这套方案的精度天花板在哪里。
6.4 标准时钟标定:把 −24 ppm 收进 ±3 ppm
用发生器输出 100.000000 kHz(其时基经上级校准),等精度法读数 99.9976 kHz,反推标准时钟实际频率 f0 = 999976.4 Hz,与理论值 1,000,000 Hz 偏差 −23.6 ppm,符合 8 MHz 无源晶振 ±20 ppm 初始容差加温漂的预期。把 FM_STD_CLK_HZ 改为 999976.4 后复测:
| 10 Hz | −24 ppm | +2.1 ppm |
| 100 kHz | −24 ppm | +1.8 ppm |
| 1 MHz | −24 ppm | +2.6 ppm |
标定后全频段绝对偏差收敛到 ±3 ppm 内,剩余部分来自发生器时基(±1 ppm 级)与室温波动下的晶振温漂。对绝大多数应用,这一步做完就够了;要求更高的场合应换 TCXO 或定期用标准源校准。
相关阅读:《基于STM32定时器外部触发模式的高精度频率计实现》 — 外部触发计数路线的另一种实现,可与本文方案对照误差表现
七、故障排查
以下七类问题按我踩坑和帮网友排查的高频程度排列,每类给出完整排查链而不是只给答案。
问题一:读数恒为 0,串口一直打印 invalid window。 先用逻辑分析仪探 PA0,确认方波正常,排除信号源问题;然后手动读 TIM2->CNT,发现数值纹丝不动;检查 SMCR,发现 SMS=000——CubeMX 里 Slave Mode 忘了从 Disable 改成 External Clock Mode 1,只配了通道没配从模式,计数器实际在用内部时钟空转。修正 SMS=111、TS=001 后 CNT 立即随信号跳动。这是最高频的错误:外部时钟模式 1 必须同时落 SMS 和 TS 两个位段,缺一不可。
问题二:低频段读数系统性偏大,10 Hz 稳定读出 11.0 Hz 左右。 偏差恰好约一个周期,第一怀疑对象就是闸门窗口错位。在 EXTI 回调里临时打印四个快照值,发现 Nx0 起始快照不是 0 而是 1,说明武装完成前已有一个边沿被 TIM2 计入但没被 EXTI 捕获;逻辑分析仪抓 PA0 与 EXTI 时序,确认该边沿落在 TIM2 使能与 EXTI 使能之间。根因是初始化顺序把 TIM2 启动放在了 EXTI 就绪之后。按 5.3 节顺序(TIM4 → TIM2 → EXTI/状态 → TIM3)重排后 Nx0 恒为 0,10 Hz 读数回到 10.000±0.001 Hz。差分快照体系本身能容忍"窗口起点晚于第一个沿",但不能容忍"脉冲计数器先工作而快照通道后就绪"。
问题三:高频读数明显偏低,1 MHz 读成 934.5 kHz。 偏差 65536,正好是 2^16,典型的溢出丢失。检查 TIM2 的 UIF 标志已被置位但 ovf 变量不动,再看 NVIC,TIM2 update interrupt 没勾——CubeMX 里定时器开了 Start_IT 但中断没使能,硬件照常溢出,软件永远不知道。勾上 NVIC 后 1 MHz 读数恢复正常。凡是偏差数值接近 65536 的整数倍,先查溢出处理链路。
问题四:结果偶发跳动 ±5 ppm 以上,其余时间正常。 在 EXTI 回调前后插 DWT 计时,发现个别次中断耗时突增到几十微秒;排查发现调试期留在回调里的 printf 才是元凶——串口阻塞发送期间新边沿的快照被迫延后,且 SysTick 抢占加剧抖动。把打印移出中断、EXTI0 抢占优先级设为最高、快照读取用临界区包裹后,抖动回落到 ±1 ppm 内。中断服务函数里只做快照和状态迁移,一切耗时操作留给主循环,这条纪律在测频场景必须严格执行。
问题五:拔掉信号后程序再也不出结果。 状态机停在 EXPIRED 等一个永远不会来的关闸边沿,而超时逻辑只在 RUNNING 态判断,恰好漏掉了 EXPIRED 态。把超时条件改为"RUNNING 与 EXPIRED 态都计时,超过 FM_TIMEOUT_OVF 次更新即报无信号",实测拔线后 2.0 s 准时报错并自动回到 IDLE。等待类状态机必须保证每个等待态都有出口,这一条与测量精度无关,但决定仪器能不能交付。
问题六:读数稳定但绝对值偏差固定 −24 ppm。 与发生器标称值逐点对比,偏差不随频率变化,指向系统误差而非量化误差;用发生器已知频率反推标准时钟(6.4 节方法),得到 −23.6 ppm 的晶振偏差,标定后收敛。固定偏差先怀疑时基,不要在代码里找原因。
问题七:信号超过 3 MHz 后读数抖动增大。 检查 IC1F 发现当初为了抗毛刺设了 1111(fDTS/32 × 8 个时钟周期),滤波窗口折合约 3.6 µs,而 3 MHz 信号周期只有 0.33 µs,大量边沿被滤波器吃掉。把 IC1F 降到 0011(fCK_INT × 8,约 0.11 µs)后恢复正常。数字滤波是在"抗毛刺"与"不吞边沿"之间做交换,必须按最高工作频率反推滤波窗口上限。
八、总结
核心要点回顾:等精度法把测频转化为"整数周期对标准时钟的比值",误差 ±1/Ns 与被测频率无关;F103 上闸门同步由 TIM3 触发模式(SMS=110, TS=TI1FP1)硬件完成,标准时钟 32 位化靠 TIM4 以外部时钟模式 1(SMS=111, TS=ITR2)级联 TIM3 溢出,ITR2=TIM3_TRGO 是 RM0008 Table 71 的硬连线约束;双端 EXTI 快照差分让软件延迟在窗口两端自然抵消;初始化顺序 TIM4 → TIM2 → EXTI → TIM3 以及启动后清除残留 UIF,是低频精度的两道生命线。
适用边界也要说清楚。本方案输入为 0~3.3 V 数字方波,正弦或小信号需要前端整形;高于 3 MHz 后数字滤波与同步链路的开销开始显现,再往上建议换输入捕获直读或外部预分频;量化误差之外,绝对精度上限由时基决定,无源晶振就是 ±20 ppm 量级的天花板。已知局限有二:一是 1 Hz 以下测量单次耗时超过闸门标称值较多,实时性差;二是快照依赖 EXTI 中断响应,若系统里存在更高优先级的硬实时任务,需重新评估抖动预算。
扩展方向上,可以把测量状态机移植到 FreeRTOS 任务中并用任务通知替代标志轮询;给 TIM3 换外部 TCXO 时基能把绝对精度推进到 1 ppm 以内;再加上脉宽与占空比测量(TIM2 双沿捕获),就能凑齐一台简易频率计的完整功能面。如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员。
版本备注
- 硬件平台:STM32F103C8T6 最小系统板 ×2 + JDS6600 DDS 信号发生器 + CH340 USB-TTL + 24MHz 逻辑分析仪
- 软件版本:Keil MDK 5.38 + STM32CubeMX 6.9.2 + STM32CubeF1 HAL 库 1.8.5
- 兼容说明:F103C8T6/RBT6 可直接使用;F407 等系列 ITR 映射表不同(TIM2 可收 ITR 来源更多),级联从机选择需重查对应参考手册 Table;其他系列务必核对各自 RM 的 internal trigger connection 表
- API 变更风险:HAL_TIM_SlaveConfigSynchro 在 HAL F1 1.8.x 与 1.6.x 间结构体成员一致;HAL_TIM_Base_Start_IT 启动后残留 UIF 的行为在 1.8.x 依旧存在,升级库版本后请复测标称闸门
参考资料




