简介:这是一套面向嵌入式初学者与电子设计实践者的 STM32 电容测量项目资料,围绕便携式电容测量仪展开,测量范围覆盖 0pF 至 50uF,并具备较高分辨率与自动调零思路。压缩包共 417 个文件、约 29.19MB,包含 99 个 C 源文件与 84 个头文件,便于梳理主控逻辑与外设驱动;同时配有工程编译中间文件、链接与启动配置、PCB 与原理图文档、批处理脚本及 PDF 说明等,可还原从代码到硬件的完整项目结构。内容涉及 STM32F103RC 主控、RC 充放电测量、LMV393 比较器、液晶显示与电池供电等环节,适合用来理解定时器采集、自动调零和低功耗设计。已有 2100 人学习,可作为课程设计、毕业设计或 DIY 仪器的参考样例,帮助读者快速定位源码模块、硬件资料与调试线索。
1. 一块几毛钱的 555,把 STM32 变成一台电容表
上个月修一批电源板,要从料盘里挑出容值偏低导致纹波偏大的贴片电容,结果手头万用表电容档量程只到 200μF,测 100pF 以下的直接跳 0,测 100nF 的读数又在 80~130 之间飘。那天下午我把一颗 555 定时器接到 STM32 的定时器捕获脚上,让待测电容变成振荡频率,再由 MCU 去数这个频率,反推出容值的想法就这么成形了。这份「电容测量3_stm32」实现的就是这个思路:量程大约 100pF~1μF,读数 5 次/秒,OLED 直显 + 串口上报,带开路零点扣除和两点标定。它适合手上已经有最小系统板、会一点 CubeMX、想拿输入捕获和定时器溢出中断练手的人;也适合当课设、毕设的底板,因为外设少、链路短、每个环节都能单独拿示波器验证。
2. 方案选型与硬件链路:从待测电容到一路方波
2.1 三种测电容的路子,为什么最后落在 555 振荡上
测电容在嵌入式里有三条常见路径,各有各的脾气。
第一条是 RC 充放电测时间常数。用一个已知电阻给电容充电,测电压爬到 63.2% 的时间,τ=RC,反解 C。原理最干净,但落地最难:小电容时 τ 只有几百纳秒,要高速比较器加高分辨率计时;大电容时又得等几百毫秒,刷新率直接垮掉。中间还要处理运放失调和比较器迟滞,一旦 ADC 采样点踩在充电曲线上,噪声会被 1/RC 放大。
第二条是恒流源充电测斜率。给电容灌一个恒定电流,dv/dt = I/C,测固定时间内的电压增量就能算出 C。这条路精度能做得很高,代价是恒流源本身难做——pA 级漏电流、运放偏置、PCB 表面漏电都会直接把结果带偏,而且量程切换要换电流档,硬件复杂度翻倍。
第三条就是本项目用的:把电容接进 555 的无稳态振荡回路,让容值决定振荡频率,STM32 只用定时器输入捕获测周期。选它的理由很实际——频率是数字量,抗干扰天然比模拟电压强;测量精度只取决于 MCU 的晶振和定时器,不依赖任何昂贵模拟器件;整个模拟前端就一颗 555、两颗电阻、一颗去耦电容,成本压到一块钱以内。
代价也很明确:量程被 555 的极限频率和 MCU 的计数能力夹在中间,一段硬件只能覆盖两三个数量级,超出范围要么频率高到捕获不准,要么周期长到刷新难看。这个取舍在后面选电阻值时还要再碰一次。
| 方案 | 精度潜力 | 硬件成本 | 量程跨度 | 落地难度 |
|---|---|---|---|---|
| RC 充放电测 τ | 中 | 中(需比较器) | 窄 | 中 |
| 恒流源测 dv/dt | 高 | 高 | 中 | 高 |
| 555 振荡 + 输入捕获 | 中高 | 极低 | 窄 | 低 |
2.2 硬件连接与元件选型:R1、R2 怎么定,555 选哪种
电路本身是 555 的经典无稳态接法。1 脚接地,8 脚接 3.3V,4 脚(复位)直接拉高,5 脚串一颗 0.01μF 到地做内部比较器的旁路。R1 从 VCC 接到 7 脚(DISCH),R2 从 7 脚接到 2、6 脚(TRIG 与 THRESH 短接),待测电容 Cx 从 2、6 脚接到地。输出 3 脚直接进 STM32 的 PA0,也就是 TIM2 的通道 1。
选型上有三个点必须抠。
第一,555 要选 CMOS 版本。双极型 555 在 3.3V 供电下输出高电平只能爬到 1.7V 左右,STM32 的 VIH 门槛是 0.7×VDD ≈ 2.31V,边沿会被判成低电平或者反复抖动,捕获结果就是一团乱码。这是很多人第一次搭这个电路时最容易翻车的地方,示波器上看波形“有”,MCU 就是数不到。
第二,R1 和 R2 要选 1% 金属膜。频率公式里 R1+2R2 直接乘在容值上,5% 的碳膜电阻意味着 5% 的系统误差,标定都救不回来——标定只能修线性偏差,修不了随温度漂的电阻。
第三,R2 尽量取大。R2 越大,同样的电容对应频率越低,小电容段才不至于跑到几十 kHz 以上让捕获分辨率吃亏。但 R2 也不能无限大,超过 1MΩ 后 555 内部比较器的输入偏置电流开始显形,振荡会不稳。
我最后定的参数是 R1=1kΩ、R2=100kΩ,这样 R1+2R2=201kΩ。
| 元件 | 参数 | 作用 | 备注 |
|---|---|---|---|
| U1 | CMOS 555(TLC555 类) | 无稳态振荡 | 3.3V 下输出摆幅接近满幅 |
| R1 | 1kΩ 1% 金属膜 | 充电支路 | 兼做 DISCH 限流 |
| R2 | 100kΩ 1% 金属膜 | 充放电支路 | 决定频率量级 |
| C1 | 0.01μF 瓷片 | 5 脚旁路 | 抑制比较器抖动 |
| C2 | 0.1μF 瓷片 | 电源去耦 | 靠近 555 的 8 脚 |
| Cx | 待测 | 决定频率 | 建议用 C0G/NP0 做标定件 |
提示:待测电容的两个焊盘之间不要铺铜,也不要在附近走信号线。走线电容会被并联进 Cx,1cm 的平行走线就能贡献接近 1pF,小量程段这是几个百分点的误差。
2.3 从频率到容值的换算模型:把公式落到定时器刻度上
555 无稳态的周期由两段组成。充电时电流经 R1+R2 灌进 Cx,阈值从 1/3 VCC 爬到 2/3 VCC,时间 t_high = 0.693×(R1+R2)×Cx;放电时电流经 R2 流进 7 脚,阈值从 2/3 VCC 掉回 1/3 VCC,时间 t_low = 0.693×R2×Cx。合起来:
T = 0.693 × (R1 + 2R2) × Cx,f = 1.44 / ((R1 + 2R2) × Cx)
反过来就是固件里真正用到的那一行:
Cx = 1.44 / ((R1 + 2R2) × f)
代入 201kΩ,几个典型容值对应的频率和周期:
| 容值 Cx | 频率 f | 周期 T | 72MHz 下的计数值 |
|---|---|---|---|
| 100pF | 71.6kHz | 13.96μs | 1005 |
| 1nF | 7.16kHz | 139.6μs | 10052 |
| 10nF | 716Hz | 1.396ms | 100527 |
| 100nF | 71.6Hz | 13.96ms | 1005270 |
| 1μF | 7.16Hz | 139.6ms | 10052700 |
这张表直接决定了定时器怎么配。最右一列是我把 TIM2 设成 72MHz、不分频、1 tick = 13.89ns 之后算出来的计数值。100pF 时周期只占 1005 个计数,1 tick 的量化误差就是 0.1%;但 100nF 时周期已经超过 16 位计数器的 65536 上限,要翻 15 次;1μF 时要翻 153 次。所以捕获逻辑里必须带溢出计数,否则读数会在某个容值附近突然变成原来的几十分之一——这是这个项目最典型的一个坑,后面第 5 章还会专门说。
量程为什么卡在 100pF 到 1μF?下限这边,555 的 2、6 脚输入电容、芯片内部寄生、PCB 焊盘加起来有 5~10pF,跟 100pF 是同一量级,再往下测基本是在测板子自己。上限这边,1μF 对应周期 140ms,刷新一次要等好几个周期才滤波,操作体验很差;而且待测电解电容的漏电流会让振荡频率偏移,读数没意义。想要更宽的量程,只能换 R2,而这正是第 6 章要做的事。
3. 工程结构与核心代码:捕获、换算、滤波三条线
3.1 目录结构与 CubeMX 关键配置
工程按 CubeMX 生成的骨架组织,业务代码全部塞在 Core 目录下,不碰 Drivers,这样重新生成代码时不会丢东西。
CapMeter_STM32/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── cap_capture.h # 捕获、滤波、换算接口 │ │ ├── cap_calib.h # 零点与两点标定 │ │ └── oled.h # SSD1306 驱动 │ └── Src/ │ ├── main.c │ ├── cap_capture.c │ ├── cap_calib.c │ ├── oled.c │ └── stm32f1xx_it.c # 只做转发,不放业务 ├── Drivers/ # HAL + CMSIS,CubeMX 生成 ├── MDK-ARM/ # Keil 工程,或换成 build/Makefile └── README.mdCubeMX 里真正需要手调的只有几项,其余默认即可:
| 外设 | 配置项 | 值 | 说明 |
|---|---|---|---|
| RCC | HSE | 8MHz 晶振 ×9 | 系统时钟 72MHz |
| TIM2 | Clock Source | Internal Clock | 时基用于计数 |
| TIM2 | Prescaler | 0 | 1 tick = 13.89ns |
| TIM2 | Counter Period | 65535 | 16 位满量程 |
| TIM2 | Channel1 | Input Capture direct | 上升沿,无分频 |
| TIM2 | NVIC | CC1IE + UIE | 捕获和溢出都要中断 |
| USART1 | Baud Rate | 115200 | 上报读数 |
| I2C1 | Speed | 400kHz | 驱动 OLED |
注意 TIM2 的 NVIC 里必须同时勾上 Capture Compare 和 Update 两个中断。只勾捕获中断的话,计数器翻过 65535 会静悄悄地回零,捕获值看起来一切正常,但周期算出来永远偏小。
3.2 输入捕获:把一整个周期的计数值数准
捕获逻辑单独成文件,中断服务函数里只做“存数”这一件事,所有浮点运算和打印都扔到主循环。头文件定义如下:
/* cap_capture.h */ #ifndef CAP_CAPTURE_H #define CAP_CAPTURE_H #include <stdint.h> #define CAP_BUF_LEN 16 /* 一帧采 16 个周期做中值滤波 */ #define CAP_TIM_CLK_HZ 72000000u /* TIM2 计数时钟 */ #define R1_OHM 1000.0f /* 1kΩ */ #define R2_OHM 100000.0f /* 100kΩ */ void CapCapture_Init(void); void CapCapture_OnCapture(void); /* 捕获中断里调用 */ void CapCapture_OnOverflow(void); /* 溢出中断里调用 */ float Cap_GetRawNf(void); /* 未标定的原始容值,nF */ #endif实现部分的核心是那一行模运算。用(溢出次数 << 16) + 当前值 - 上次值计算周期时,即使计数器刚好回绕过,无符号减法的下溢也会自动补回来,不需要写 if-else 分支判断谁大谁小:
/* cap_capture.c */ #include "cap_capture.h" #include "main.h" extern TIM_HandleTypeDef htim2; static volatile uint32_t s_last_cap = 0; /* 上次捕获值 */ static volatile uint32_t s_ovf_cnt = 0; /* 两次捕获之间的溢出次数 */ static volatile uint32_t s_period[CAP_BUF_LEN]; static volatile uint8_t s_idx = 0; static volatile uint8_t s_frame_rdy = 0; void CapCapture_Init(void) { s_last_cap = 0; s_ovf_cnt = 0; s_idx = 0; s_frame_rdy = 0; __HAL_TIM_SET_COUNTER(&htim2, 0); HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); __HAL_TIM_ENABLE_IT(&htim2, TIM_IT_UPDATE); /* 溢出中断单独开 */ } void CapCapture_OnOverflow(void) { s_ovf_cnt++; /* 只累加,不清零 */ } void CapCapture_OnCapture(void) { uint32_t now = HAL_TIM_ReadCapturedValue(&htim2, TIM_CHANNEL_1); if (s_last_cap == 0 && s_ovf_cnt == 0) { s_last_cap = now; /* 第一个边沿只做基准,不产数据 */ return; } /* 无符号模运算,回绕自动处理 */ uint32_t ticks = ((s_ovf_cnt << 16) + now) - s_last_cap; s_last_cap = now; s_ovf_cnt = 0; /* 算完立刻清零,为下一段做准备 */ /* 100 ticks ≈ 1.4us ≈ 700kHz,22M ticks ≈ 300ms,两端都不要 */ if (ticks < 100u || ticks > 22000000u) { return; } s_period[s_idx++] = ticks; if (s_idx >= CAP_BUF_LEN) { s_idx = 0; s_frame_rdy = 1; /* 攒够一帧,通知主循环 */ } }几个参数需要解释清楚。CAP_BUF_LEN = 16是滤波窗口,取 16 是因为它是 2 的幂,中值取第 8 和第 9 个元素平均,代码里不用处理奇偶分支;窗口再大响应就慢了,1μF 时单次周期 140ms,16 个样本要等两秒多。ticks < 100这条下限是滤毛刺的——555 输出偶尔会因为电源波动多抖一个窄脉冲出来,不滤掉的话中值滤波窗口里混进几个极小值,读数就会往下掉。上限 22M ticks 对应 300ms,超过说明线路断了或者根本没起振,直接丢弃。
把 HAL 的回调接上,转发到上面两个函数:
/* stm32f1xx_it.c 或 main.c 尾部 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { CapCapture_OnCapture(); } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { CapCapture_OnOverflow(); } }注意:这里用
htim->Instance判断而不是htim->Channel。HAL 在进入回调前不会把 Channel 字段复位,多个通道共用回调时那个判断会失灵,是个经典的暗坑。
3.3 中值滤波与容值换算:一帧数据怎么变成 nF
主循环拿到的是一帧 16 个周期计数值。这里不直接用平均值,因为毛刺对均值的影响是线性的,一个异常值能把均值拽偏好几个百分点;中值滤波对孤立异常值完全不敏感,代价只是多一次排序。16 个元素的插入排序在 72MHz 上不到 2μs,开销可以忽略。
/* cap_capture.c 续 */ static uint32_t median_u32(uint32_t *a, uint8_t n) { for (uint8_t i = 1; i < n; i++) { uint32_t key = a[i]; int16_t j = (int16_t)i - 1; while (j >= 0 && a[j] > key) { a[j + 1] = a[j]; j--; } a[j + 1] = key; } return (a[n / 2 - 1] + a[n / 2]) / 2; /* n 为偶数,取中间两个平均 */ } float Cap_GetRawNf(void) { uint32_t buf[CAP_BUF_LEN]; if (!s_frame_rdy) { return -1.0f; /* 数据没攒够,调用方跳过本次刷新 */ } __disable_irq(); /* 拷贝期间禁止捕获改写数组 */ for (uint8_t i = 0; i < CAP_BUF_LEN; i++) { buf[i] = s_period[i]; } s_frame_rdy = 0; __enable_irq(); uint32_t ticks = median_u32(buf, CAP_BUF_LEN); if (ticks == 0u) { return -1.0f; } /* tick -> 频率:f = CAP_TIM_CLK_HZ / ticks */ float freq = (float)CAP_TIM_CLK_HZ / (float)ticks; /* Cx = 1.44 / ((R1 + 2*R2) * f),单位法拉,乘 1e9 转 nF */ float c_farad = 1.44f / ((R1_OHM + 2.0f * R2_OHM) * freq); return c_farad * 1e9f; }这段里有两个细节值得说明。第一,拷贝时关中断而不是用双缓冲,因为捕获中断频率最高也就 70kHz,关中断的窗口只有几百纳秒,不会丢边沿,代码却简单得多。第二,s_frame_rdy由中断置位、主循环清位,这是唯一的跨上下文标志,不需要额外的临界区保护——最坏情况是主循环刚读完标志就被中断重新置位,下一轮再取一次,数据不会错。
用 100nF 的 C0G 电容实测,原始读数是 100.5nF 左右,误差 0.5%,说明公式和时钟都对得上。但换一颗 100pF,读数会稳定地停在 108nF×10⁻³ 也就是 108pF——多出来的 8pF 是开路电容,得靠标定扣掉。
3.4 零点扣除、两点标定与显示输出
标定模块管两件事:一是开路零点,二是增益修正。零点就是什么都不接时读到的那点容值,主要是 PCB 走线、焊盘、555 输入电容的和;增益修正用两点法,拿两颗已知容值的 C0G 电容,记录原始读数,解一条直线。
/* cap_calib.c */ #include "cap_calib.h" #include <math.h> static float s_offset_nf = 0.0f; /* 开路零点 */ static float s_gain = 1.0f; /* 增益系数 */ void CapCalib_SetOffset(float nf) { s_offset_nf = nf; } /* 两点标定:已知 c1/c2,对应原始读数 n1/n2 */ void CapCalib_TwoPoint(float c1, float n1, float c2, float n2) { if (fabsf(n2 - n1) < 1e-3f) { return; /* 两点太近,解出来的增益不可信 */ } s_gain = (c2 - c1) / (n2 - n1); } float CapCalib_Apply(float raw_nf) { return (raw_nf - s_offset_nf) * s_gain; }主循环把三条线串起来,显示和串口都放在这里,中断里一行打印都不留:
/* main.c 主循环片段 */ uint32_t last_ui = 0; while (1) { float raw = Cap_GetRawNf(); if (raw > 0.0f) { float c = CapCalib_Apply(raw); if (HAL_GetTick() - last_ui >= 100u) { last_ui = HAL_GetTick(); OLED_ShowCap(c); /* pF/nF 自动换单位 */ printf("C=%.3f nF\r\n", c); /* 115200 波特率 */ } } HAL_Delay(10); }HAL_Delay(10)看起来是多余的——数据帧本来就 100ms 才凑齐一次。但主循环空转会把 CPU 全占满,加上这一句功耗能降下来,也避免了printf被反复调用的风险。OLED 那边只在单位切换时重绘整屏,数值区域用局部刷新,否则 400kHz 的 I2C 刷一屏要 30ms,肉眼能看到闪。
4. 编译烧录与标定:让读数从「能跑」到「能信」
4.1 工具链与第一次烧录
两种方式都行,看手上有什么。Keil 的话打开 MDK-ARM 目录下的工程,Options 里把 Debug 设成 ST-Link,勾上 Reset and Run,直接 F8。用命令行更清爽,先确认 GCC 工具链在 PATH 里:
arm-none-eabi-gcc --version # 确认工具链可用 make -j8 # 编译,产物在 build/capmeter.elf arm-none-eabi-size build/capmeter.elfsize那条命令要看一眼。Flash 占用一般在 20KB 上下,RAM 不到 4KB,如果数字大得离谱,多半是某个 HAL 模块被误勾了,比如开了 USB 或者 SDIO。烧录用 OpenOCD:
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program build/capmeter.elf verify reset exit"烧完先别急着看 OLED。用示波器或者逻辑分析仪夹 PA0,接一颗 1nF 的 C0G,应该能看到 7.16kHz 左右的方波,占空比略大于 50%(因为 t_high 比 t_low 长),高电平接近 3.3V。波形对了,说明模拟前端没问题,剩下的都是软件的事;波形不对,就不要浪费时间去调代码了。
4.2 上电自检:先看原始计数,再看换算结果
调试阶段我习惯在串口里把原始 ticks 一起打出来,这样出了问题能立刻分清是硬件还是公式。临时把打印改成这样:
printf("ticks=%lu f=%.2f Hz C=%.3f nF\r\n", (unsigned long)ticks, freq, c);然后拿几个已知容值对一遍:
| 标称值 | 理论 ticks | 实测 ticks | 实测容值 | 偏差 |
|---|---|---|---|---|
| 100pF | 1005 | 1084 | 108pF | +8% |
| 1nF | 10052 | 10130 | 1.008nF | +0.8% |
| 10nF | 100527 | 100610 | 10.008nF | +0.08% |
| 100nF | 1005270 | 1005350 | 100.008nF | +0.01% |
这张表能看出两件事。一,小容值偏差大,大容值几乎为 0——典型的固定并联电容特征,8pF 左右的开路电容在 100pF 上占 8%,在 100nF 上只占 0.008%。二,偏差是常数偏置而不是比例误差,所以先扣零点就够了,增益修正留到后面。
用 Python 抓一串串口数据看稳定性,比盯着 OLED 有效得多:
import re, time, serial ser = serial.Serial("COM7", 115200, timeout=1) vals, t0 = [], time.time() while time.time() - t0 < 5: line = ser.readline().decode(errors="ignore").strip() m = re.match(r"C=([\d.]+)\s*nF", line) if m: vals.append(float(m.group(1))) ser.close() vals.sort() n = len(vals) print(f"样本 {n} 中位数 {vals[n//2]:.3f} nF " f"极差 {vals[-1]-vals[0]:.3f} nF")正常情况下极差应该小于中位数的 1%。如果极差到了 5% 以上,先怀疑电源——555 的电源纹波会直接调制成频率抖动,加一颗 10μF 钽电容并到 C2 旁边,往往立刻见效。
4.3 两点标定:把 8% 的偏差压到 1% 以内
标定分三步走,每一步都要等读数稳定下来再做。
第一步,扣零点。什么都不接(焊盘悬空),上电等 10 秒,读串口输出,取 20 个样本的中位数,我这次是 7.9pF。把它写进CapCalib_SetOffset(7.9f),或者存进 Flash 末页在启动时读回。
第二步,测两点。找两颗 1% 精度的 C0G 电容,1nF 和 100nF 比较合适,跨度两个数量级,增益算得准。分别接上,记下扣零点后的读数:1nF 那颗读 0.992nF,100nF 那颗读 99.4nF。
第三步,算增益。k = (100 - 1) / (99.4 - 0.992) = 99 / 98.408 = 1.00602。写进代码,再复测一遍,1nF 读 0.998nF,100nF 读 100.0nF,中间的 10nF 读 9.97nF,全量程误差落在 0.3% 以内。
提示:标定件一定要用 C0G/NP0 材质。X7R 的 100nF 在 3.3V 直流偏置下容量能掉 10% 以上,用它做基准等于给自己埋雷。
想让标定参数掉电不丢,把两个 float 打包成一个 8 字节结构体写进 Flash 最后一页就行。写入前要先擦除整页,擦除期间 CPU 从 Flash 取指会暂停,中断响应会被拖到毫秒级——所以写入动作只能放在主循环里,且必须保证此时没有捕获正在进行。
5. 避坑与排查:五个让我返工重来的现场问题
5.1 读数异常与信号链上的坑
现象一:读数在正常值和一个极小的值之间来回跳,比如 100nF 和 0.3nF 交替出现。
原因是溢出中断没开,或者溢出计数在捕获后没有清零。TIM2 是 16 位计数器,72MHz 下 910μs 就绕一圈,100nF 的周期是 14ms,中间要绕 15 次。只读捕获寄存器的话,算出来的 ticks 只有零头。另一种情况是溢出加上了但捕获后忘了清零,下一次计算会把历史溢出次数也算进去,周期越算越大。
解决就是 3.2 节那段代码:((s_ovf_cnt << 16) + now) - s_last_cap做模运算,算完立刻把s_ovf_cnt置 0。另外确认__HAL_TIM_ENABLE_IT(&htim2, TIM_IT_UPDATE)这行在初始化里执行了——它和HAL_TIM_Base_Start_IT是两回事,后者会把定时器当普通时基启动,跟输入捕获抢同一个计数器。
现象二:MCU 完全数不到边沿,示波器上看 555 输出有波形,但幅度只有 1.7V 左右。
这是双极型 555 在 3.3V 供电下的典型症状。它的输出级是推挽结构,但高边管的基极驱动需要比 VCC 更高的电位,低压下根本拉不满。STM32 的输入高电平门槛按 0.7×VDD 算,3.3V 系统就是 2.31V,1.7V 被判成不确定态,捕获脚上的信号在阈值附近来回抖。
解决:换 CMOS 工艺的 555,输出能到 VCC-0.1V。如果手头只有双极型的,输出串一个 10kΩ 上拉到 3.3V,再把 4 脚复位端也上拉,能勉强工作,但边沿会变缓,高频段(小电容)依然可能丢数。我第一版就是没注意这个,换了三颗芯片才反应过来不是芯片坏了。
现象三:小容值永远偏大 8~12pF,而且换板子数值就变。
原因是并联在待测电容两端的固定电容:PCB 焊盘之间 1~2pF,555 的 2、6 脚输入电容典型 5pF,加上探头的几 pF。这些在 100nF 上完全看不出来,在 100pF 上就是 10% 的误差。
解决:扣零点,也就是 4.3 节的第一步。注意零点跟板子绑定,换一块 PCB 就得重新测。另外布线时把 Cx 的两个焊盘做小、间距拉开,地平面在焊盘正下方挖空,能把这部分压到 3pF 以内。不要试图用增益修正去补零点误差,那条直线在原点附近会直接穿到负值区。
5.2 定时器与中断配置上的坑
现象四:加了串口打印以后,读数整体偏小,而且波动变大。
原因是printf被放在了中断上下文里,或者主循环里打印得太频繁把捕获中断挤掉了。115200 波特率下一个 30 字符的printf要 2.6ms,这期间如果来了捕获中断,虽然中断能抢占,但printf内部用的是阻塞发送,重入会直接把输出缓冲区搞乱,表现就是偶尔丢一整行。
解决:中断里只存数,所有打印放主循环,并且用HAL_GetTick()限流到 10Hz。如果一定要高频上报,改用 DMA 发送加环形缓冲,把printf重定向到HAL_UART_Transmit_DMA。
现象五:HAL_TIM_IC_CaptureCallback里判断htim->Channel判断不住,偶尔走进错误分支。
原因是 HAL 在调用回调前并不复位Channel字段,它只记录“最近一次触发的是哪个通道”。如果工程里同时开了 TIM2 的两个捕获通道,或者别的定时器也用了捕获回调,这个字段的值会和预期不一致。
解决:只判断htim->Instance,通道区分放到自己的状态变量里做。如果确实需要区分通道,用寄存器回调HAL_TIM_IC_CaptureCallback的替代方案——直接读TIM2->CCR1和TIM2->CCR2,配合TIM2->SR的 CC1IF/CC2IF 标志位判断来源,比依赖 HAL 的字段可靠。
6. 二次开发:把固定量程改成自动换挡
单量程的局限很明确:100pF 到 1μF 之外就测不了。想扩到 10pF~100μF,硬件上不用改 555 的接法,只要让 R2 可切换就行——三个量程分别用 1MΩ、100kΩ、10kΩ,对应 10pF~10nF、1nF~1μF、100nF~100μF。切换器件用一颗 CD4051 模拟开关,三个通道分别接三颗电阻,A/B 选择脚接两个 GPIO。
软件上要改两处。第一处是量程判断:先按当前档位测一次,如果 ticks 落在下限(比如小于 200)说明频率太高,升一档;落在上限(比如大于 2,000,000,即周期超过 27ms)说明频率太低,降一档。第二处是公式里的电阻值要跟着档位走,不能写死成宏。
typedef struct { float r2_ohm; /* 该档位的 R2 */ float min_nf; /* 该档位建议下限 */ float max_nf; /* 该档位建议上限 */ uint8_t sel; /* CD4051 的 A/B 选择码 */ } CapRange_t; static const CapRange_t s_ranges[3] = { { 1000000.0f, 10.0f, 10.0f, 0 }, /* 1MΩ -> 10pF~10nF */ { 100000.0f, 1.0f, 1000.0f, 1 }, /* 100k -> 1nF~1uF */ { 10000.0f, 100.0f, 100000.0f, 2 }, /* 10k -> 100nF~100uF */ }; static uint8_t s_cur = 1; /* 上电默认中间档 */ /* 返回 1 表示发生了换挡,调用方应丢弃本次读数 */ uint8_t CapRange_AutoShift(float raw_nf) { const CapRange_t *r = &s_ranges[s_cur]; if (raw_nf < r->min_nf && s_cur > 0) { s_cur--; } else if (raw_nf > r->max_nf && s_cur < 2) { s_cur++; } else { return 0; } /* 切换模拟开关后 555 需要几个周期重新起振 */ HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0 | GPIO_PIN_1, (s_ranges[s_cur].sel & 1) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, (s_ranges[s_cur].sel & 2) ? GPIO_PIN_SET : GPIO_PIN_RESET); CapCapture_Init(); /* 清空缓冲,重新开始积累 */ HAL_Delay(50); /* 等振荡稳定 */ return 1; }换算公式里的R2_OHM也要跟着换:把原来Cap_GetRawNf里写死的宏改成从s_ranges[s_cur].r2_ohm取。这一步漏掉的话,换挡后读数会正好差 10 倍,很容易误判成模拟开关没切过去。
验证方法很直接:拿一颗 4.7nF 和一颗 47nF,分别在三个档位下各测一次,看是不是只有一档的读数是合理的、另外两档要么溢出要么精度差。只要有一档在全部容值下都好看,说明换挡判断的阈值设错了,得往中间收。
还有一个更轻量的改造方向,不用动硬件:把温度也测了。555 的频率会随温度漂,主要是内部比较器的阈值电压有温漂,量级在 50~100ppm/℃。挂一颗 DS18B20,在全温区跑一遍,做一张二维补偿表,室温附近几乎没收益,但如果你要把这个表装到户外设备里,这一步省不掉。
从那以后我每次做频率类测量,都强制自己在中断里只留一句存值,换算、滤波、打印全部赶到主循环——三次里有两次的“读数莫名其妙跳”,最后都追到中断里多写的那一行。这份工程不大,但捕获、溢出、滤波、标定这条链路是完整的,把它跑通一遍,后面遇到编码器、超声波、红外接收头这些靠测周期吃饭的东西,基本是同一套骨架换个引脚。希望帮到你。
本文还有配套的精品资源,点击获取