回波脉宽抖动、I²C 主站读到旧值,往往不是传感器本身的问题。本文按 PIC16F876A 数据手册拆解 CCP 捕获预分频、比较触发与 10 位 PWM,以及 MSSP 从机的 BF/SSPOV 与 CKP 时钟拉伸,给出七步配置与排查表。
一块 20 MHz 的 PIC16F876A 测距板,CCP1 抓回波脉宽,MSSP 做 I²C 从机把距离上报给主控。现场两个症状很典型:同一个反射面,脉宽总在 0.2 µs 的计数上跳 3~5 个码;主站偶尔读到上一帧的旧距离。结论先给:前者要先把 Timer1 时基和捕获预分频算清,后者不是 I²C 总线慢,而是从机没有在 BF、SSPOV 和 CKP 三处收口。
本文按 Microchip PIC16F87XA 数据手册(DS39582B)第 8、9 章整理,寄存器名、模式字与时序均以该手册为准。
PIC16F876A 在 20 MHz 晶振下,TOSC 是 50 ns,一个指令周期是 4 个 TOSC,也就是 200 ns。Timer1 用内部时钟 FOSC/4 计数,预分频 1:1 时每 200 ns 加一;16 位计数器数满 65536 个计数,量程 13.1072 ms。回波一旦超过这个时间就会翻零,减法算出来的宽度会突然少 13.1 ms。
| Timer1 预分频 | 每计数时间 | 16 位满量程 | 适合的回波长度 |
|---|---|---|---|
| 1:1 | 0.2 µs | 13.11 ms | ≤ 10 ms |
| 1:2 | 0.4 µs | 26.21 ms | ≤ 20 ms |
| 1:4 | 0.8 µs | 52.43 ms | ≤ 40 ms |
| 1:8 | 1.6 µs | 104.86 ms | ≤ 80 ms |
选预分频的原则是:最大回波时间乘 1.2 之后仍小于满量程。8 ms 的超声波回波,1:1 就够;20 ms 的远距光电开关,就要退到 1:4,代价是分辨率从 0.2 µs 降到 0.8 µs。分辨率降下去,1 cm 对应的计数变少,滤波窗口也要跟着放宽。
CCP1 的捕获事件由 CCP1CON<3:0> 决定:0100 每个下降沿、0101 每个上升沿、0110 每 4 个上升沿、0111 每 16 个上升沿。捕获发生时,TMR1 的 16 位值被锁进 CCPR1H:CCPR1L,同时 CCP1IF 置位。这个标志必须软件清零;如果下一次捕获到来前没读走旧值,新值会直接覆盖,现场表现就是“偶尔丢一帧”。
| CCP1M3:CCP1M0 | 捕获事件 | 典型用途 |
|---|---|---|
| 0100 | 每个下降沿 | 先上升后下降的高电平宽度 |
| 0101 | 每个上升沿 | 低电平宽度、周期起点 |
| 0110 | 每 4 个上升沿 | 低频信号降频测量 |
| 0111 | 每 16 个上升沿 | 极低频计数或分频 |
两个容易忽略的约束:第一,RC2/CCP1 要设成输入(TRISC<2>=1),否则写端口也会触发一次捕获;第二,Timer1 只能工作在定时器或同步计数模式,异步计数模式下捕获可能不工作。
更隐蔽的是切换预分频。手册明确写了:直接改 CCP1CON 可能产生一次假捕获中断,而且预分频计数器不会被清零,第一次捕获可能从非零计数值开始。正确做法是先写 CCP1CON=0 关掉模块,再写新的模式字,最后清一次 CCP1IF。
/* FOSC = 20 MHz,Timer1 1:1,CCP1 先捉上升沿再捉下降沿 */
static void ccp1_mode(uint8_t m)
{
CCP1CON = 0x00; /* 先关模块:清预分频计数,避免假中断 */
PIR1bits.CCP1IF = 0;
CCP1CON = m; /* 0101 上升沿 / 0100 下降沿 */
}
void pulse_init(void)
{
TRISCbits.TRISC2 = 1; /* RC2/CCP1 必须是输入 */
T1CON = 0x00; /* Timer1 内部时钟 FOSC/4,预分频 1:1 */
CCPR1H = 0; CCPR1L = 0;
ccp1_mode(0x05);
PIE1bits.CCP1IE = 1;
T1CONbits.TMR1ON = 1;
}
volatile uint16_t g_rise, g_width;
volatile uint8_t g_half;
void __interrupt() isr(void)
{
if (PIR1bits.CCP1IF) {
uint16_t now = ((uint16_t)CCPR1H << 8) | CCPR1L;
PIR1bits.CCP1IF = 0; /* 标志必须软件清零 */
if (g_half == 0) { /* 上升沿:记起点 */
g_rise = now; g_half = 1;
ccp1_mode(0x04); /* 切下降沿 */
} else { /* 下降沿:算宽度 */
g_width = (uint16_t)(now - g_rise);
g_half = 0;
ccp1_mode(0x05);
}
}
}
比较模式把 CCPR1 和 TMR1 不停比较,相等时按模式字把 RC2 拉高、拉低或保持不变。1011 是特殊事件触发:CCP1 会复位 TMR1,CCP2 还会同时启动一次 A/D 转换(A/D 模块使能时)。注意它不会置 TMR1IF,只置 CCP1IF 或 CCP2IF。
在 1:1 预分频下,CCPR1 写 5000 就是 1 ms:5000 × 0.2 µs = 1 ms。让 CCP2 每 1 ms 复位 Timer1 并启动 ADC,采样周期就由硬件锁定,CPU 只负责在中断里取结果。另一个坑是清 CCPxCON 会把比较输出锁存器强制拉低,这个低电平不是 PORTC 数据锁存器的值,调试时别把它当成端口被意外改写。
PWM 的周期由 PR2 决定:周期 = (PR2+1) × 4 × TOSC × TMR2 预分频。占空比由 CCPR1L 和 CCP1CON<5:4> 拼成 10 位:占空比时间 = (CCPR1L:CCP1CON<5:4>) × TOSC × TMR2 预分频。注意占空比用的是 TOSC,不是 200 ns 的指令周期,这两个数差 4 倍,算错就会得到四分之一的亮度。
| PWM 频率 | TMR2 预分频 | PR2 | 最大分辨率 |
|---|---|---|---|
| 1.22 kHz | 16 | 0xFF | 10 位 |
| 4.88 kHz | 4 | 0xFF | 10 位 |
| 19.53 kHz | 1 | 0xFF | 10 位 |
| 78.12 kHz | 1 | 0x3F | 8 位 |
| 156.3 kHz | 1 | 0x1F | 7 位 |
| 208.3 kHz | 1 | 0x17 | 5.5 位 |
要做 38 kHz 红外载波,周期是 26.3 µs。预分频取 1 时,(PR2+1) × 0.2 µs = 26.3 µs,PR2 取 131(0x83)得到 26.4 µs,实际 37.88 kHz。此时 10 位占空比寄存器最大 1023,对应 51.15 µs,已经超过周期;手册明确说明,占空比大于周期时 CCP1 不会被拉低,输出会一直保持高电平。50% 占空比应写 264(CCPR1L=0x42,CCP1CON<5:4>=00),而不是 512。
还有两条:占空比写入后不会立即生效,要等 TMR2 与 PR2 匹配时才从 CCPR1H 锁存,这就是无毛刺的双缓冲;Timer2 的后分频器不参与 PWM 频率,只适合做伺服更新率。
PIC16F876A 的 MSSP 在 I²C 从机模式下只用两根线:RC3/SCL 和 RC4/SDA,TRISC<4:3> 必须置 1。SSPEN=1 后这两脚被强制成开漏,外部上拉电阻不能省。
7 位从机地址写在 SSPADD<6:0>,模式字 SSPM<3:0>=0110。地址在第八个 SCL 下降沿比较,命中后硬件自动回 ACK、把地址装进 SSPBUF、置 BF,并在第九个时钟下降沿置 SSPIF。
这里有两个必须记住的例外:如果接收前 BF 或 SSPOV 已经是 1,硬件不会回 ACK,也不会把 SSPSR 的值装进 SSPBUF,但 SSPIF 照样置位——主站看到的是 NACK,从机看到的是“有中断、没数据”。BF 读 SSPBUF 就能清,SSPOV 必须软件清。
发送方向更绕:主站读地址命中后,从机要先把数据写进 SSPBUF(同时进 SSPSR),再置 CKP=1 释放 SCL;在这之前 SCL 一直被拉低,跟 SEN 无关。接收方向如果 SEN=1,BF 置位时 CKP 会被硬件自动清零,ISR 读完 SSPBUF 后必须手动把 CKP 置 1,否则总线就停在原地。
| 寄存器 | 关键位 | 作用 |
|---|---|---|
| SSPCON | WCOL、SSPOV、SSPEN、CKP、SSPM3:0 | 使能、溢出、时钟释放、模式选择 |
| SSPCON2 | GCEN、SEN | 广播呼叫、接收时自动时钟拉伸 |
| SSPSTAT | D/A、R/W、BF | 地址或数据、方向、缓冲满 |
| SSPADD | SSPADD<6:0> | 从机地址(主机模式为波特率重装值) |
| SSPBUF / SSPSR | — | 接收双缓冲,发送不双缓冲 |
发送不双缓冲这一点决定了一个现象:上一字节还没移完就写 SSPBUF,WCOL 会置位,写入被忽略,主站读到的还是旧值。所谓“读到旧数据”,多数不是总线问题,而是 ISR 里没有按 D/A 和 R/W 分支装载下一字节。
#define I2C_ADDR 0x2A
static uint8_t regs[8], reg_ptr, expect_ptr;
void i2c_init(void)
{
TRISCbits.TRISC3 = 1; /* SCL */
TRISCbits.TRISC4 = 1; /* SDA */
SSPADD = I2C_ADDR; /* 7 位地址写在 SSPADD<6:0> */
SSPCON = 0x36; /* SSPEN=1,CKP=1,SSPM=0110:7 位从机 */
SSPCON2 = 0x01; /* SEN=1:接收时自动时钟拉伸 */
PIR1bits.SSPIF = 0;
PIE1bits.SSPIE = 1;
}
/* 与 CCP1 共用一个中断入口,这里只列 I²C 分支 */
if (PIR1bits.SSPIF) {
uint8_t b = SSPBUF; /* 读 SSPBUF 清 BF */
if ((SSPSTAT & 0x20) == 0) { /* D/A=0:刚收到的是地址 */
expect_ptr = 1;
if (SSPSTAT & 0x04) SSPBUF = regs[reg_ptr]; /* 主站读:先装数据 */
} else if (SSPSTAT & 0x04) {
SSPBUF = regs[reg_ptr & 0x07]; /* 主站继续读 */
} else if (expect_ptr) {
reg_ptr = b & 0x07; expect_ptr = 0; /* 首字节是寄存器指针 */
} else {
regs[reg_ptr & 0x07] = b;
}
PIR1bits.SSPIF = 0;
SSPCONbits.CKP = 1; /* 释放 SCL,结束拉伸 */
}
| 现象 | 根因 | 改法 |
|---|---|---|
| 脉宽偶尔少 13 ms | Timer1 溢出未处理 | 缩短量程,或加预分频并统计溢出次数 |
| 切换边沿后多一次中断 | 直接改 CCP1M,预分频计数未清 | 先写 CCP1CON=0,再写新模式 |
| 捕获值固定差 1~2 个计数 | 中断响应延迟、ISR 里做浮点 | ISR 只做减法,换算放到主循环 |
| 主站读到上一帧 | 发送时 WCOL 置位,写入被忽略 | 等上一字节移完再写 SSPBUF,检查 WCOL |
| I²C 偶发 NACK | 接收前 BF 或 SSPOV 已置位 | ISR 先读 SSPBUF,再软件清 SSPOV |
| SCL 一直被拉低 | CKP 没有置 1 | ISR 末尾统一写 SSPCONbits.CKP=1 |
一句话总结:CCP 的坑在“时基与模式字”,MSSP 的坑在“标志与时钟释放”。把预分频、BF/SSPOV、CKP 三个点写成固定的初始化与 ISR 模板,测距数值和 I²C 上报才会同时稳下来。
还想接着看:PIC16F 片内比较器做低压检测与窗口比较、I²C、SPI、UART 三种总线怎么选、STM32 定时器 PWM 配置与占空比计算。
本文基于 Microchip PIC16F87XA 数据手册(DS39582B)技术资料整理优化。
沧州艾诺威电子 — 国家高新技术企业,20+项国家专利
嵌入式系统开发 · 物联网方案 · AI智能硬件 · 一站式交付
电话:13930711029 | 邮箱:tech@czinv.com | 24小时内响应