温控器项目冬季实测 ADC 读值漂移 ±3%、参数重启后偶发乱码,往往不是传感器本身的问题。本文按 PIC16F877A 数据手册拆解 Factory Calibration Word 读取、ANCON/PCFG 配置、采样时间约束、VREF 选择以及内部 EEPROM 读写解锁序列,给出完整校准代码、三段式参数存储架构与 CRC8 校验方案。
一块用了半年的温控器批量返修,现场症状两个:同一个 PT100 分压网络,冬天启动时 ADC 读值比夏天偏高 3% 左右,开机预热 3 分钟后才慢慢收敛;还有几台更夸张——用户改了控温参数(上限 80℃、下限 20℃),重启一次直接变成 0xFF 和 0x00,屏幕上显示"HHHH"然后不停继电器吸合。
传感器换了、分压电阻换了,漂移还在。EEPROM 擦除次数才几百次,远没到 100 万次寿命。结论先给:前者是没把 Microchip 在出厂时烧在 0x8000~0x8007 的 Factory Calibration Word 读回来放进 ADCON1,后者是 EEPROM 写入没有走解锁序列或者被中断打断了,必须补 CRC 校验和写入去抖。
本文按 Microchip PIC16F87XA 数据手册(DS39582B)第 2、11、13 章整理,寄存器名、模式字、校准地址空间和 EEPROM 解锁序列均以该手册为准。
PIC16F877A 在出厂时,Microchip 会把 ADC 和比较器的校准参数量好、烧在程序存储器地址 0x8000~0x8007 这 8 个字里。这是一段"受保护"的程序存储器区域——正常运行时你的代码从 0x0000 开始执行,永远不会跑到 0x8000 以上;但你可以通过配置寄存器里的 PCFG3:PCFG0 组合,把程序存储器切换成 0x8000 起始的"校准区视图",然后用 TBLRD* 指令读出来。
这 8 个地址的内容不是给用户写的,你只能读。如果你的产品从来没读这几个字,出厂漂移 ±3% 完全正常,因为没校准的 ADC 等效位精度只有 7.5 位左右(典型值),读个 10 位温度通道,1 LSB = 4.88 mV,满量程 5V 下 ±3% 就是 ±150 mV,差不多 31 个码——刚好解释现场看到的症状。
| 地址 | 内容 | 用途 |
|---|---|---|
| 0x8000 | ADC 偏移校准值 | 写入 ADCON1 前两位或偏移修正表 |
| 0x8001 | ADC 增益校准值 | 用于满量程增益修正 |
| 0x8002 | 比较器 1 偏移校准 | 比较器 C1 输出零漂修正 |
| 0x8003 | 比较器 2 偏移校准 | 比较器 C2 输出零漂修正 |
| 0x8004 | 保留(0xFFFF) | — |
| 0x8005 | 保留(0xFFFF) | — |
| 0x8006 | 保留(0xFFFF) | — |
| 0x8007 | 保留(0xFFFF) | — |
PCFG3:PCFG0 这 4 位决定了哪些端口脚是模拟输入、哪些是数字 I/O,也决定了你能不能看到 0x8000 以上的校准区。配置表里写清楚:要进入校准视图,PCFG 必须设成 1111(这是所有端口都是数字 I/O 的配置),而且这 4 位是写在配置寄存器 CONFIG 的 bits 10~7,下载程序时一次性烧入,运行时改不了。
| PCFG3:PCFG0 | RA | RB | RC | 校准可见 |
|---|---|---|---|---|
| 0000 | 模拟 AN0~3 | 模拟 AN4~7 | 全部数字 | 否 |
| 0011 | 模拟 AN0~3 | 数字 | 模拟 AN8~11 | 否 |
| 0110 | 模拟 AN0 | 数字 | 模拟 AN8 | 否 |
| 1111 | 数字 | 数字 | 数字 | 是 |
问题来了:要读校准区就必须把 PCFG 设成 1111(全数字),但这意味着上电时所有模拟脚都被关掉了,你没法直接进主循环。解决方案是:上电后的第一个中断向量 0x0004 里,先把 PCFG 改成 1111,用 TBLRD 读 0x8000~0x8001 两个字,存到 RAM 里,再把 PCFG 改回你实际要的模拟配置,然后跳转 main。整个过程不超过 50 µs,用户完全无感。
PIC16F877A 的 ADC 模块用三个寄存器控制:ADCON0 开模块、选通道、启动转换;ADCON1 选 VREF 源、数字结果对齐方式、PCFG 配置;ADCON2(PIC16F877A 独有,F876A 没有)设 FOSC 分频和采样时间。注意这是 2006 年才出的 F87XA 改进版,老型号(F876 不带 A 后缀)没有 ADCON2,时钟分频只能写死成 FOSC/8。
ADCON1 的高 4 位是 PCFG,低 4 位里 bit 7 是 VCFG1(1 用 RA3/AN3 作 Vref-,0 用 Vss),bit 6 是 VCFG0(1 用 RA2/AN2 作 Vref+,0 用 Vdd),bit 5 是 ADFM(1 右对齐、0 左对齐)。左对齐会把 10 位结果塞进 ADRESH 的高 8 位和 ADRESL 的高 2 位,低 6 位全 0——如果你只需要 8 位精度可以这么干;要 10 位必须右对齐。
| ADCON1 位 | 名称 | 1 的含义 |
|---|---|---|
| bit 7 | VCFG1 | Vref- 来自 RA3/AN3(而非 Vss) |
| bit 6 | VCFG0 | Vref+ 来自 RA2/AN2(而非 Vdd) |
| bit 5 | ADFM | 结果右对齐(10 位全有效) |
| bit 4:1 | PCFG3:0 | 模拟/数字端口分配(见上表) |
| bit 0 | 未实现 | 读出为 0 |
采样时钟分频是另一个经常被忽视的点。ADCON2 的 ADCS2:ADCS0 三位决定:000 = FOSC/2、001 = FOSC/8、010 = FOSC/32、011 = FOSC/64、1x0 = FOSC/128、1x1 = 内部 RC 2 µs。20 MHz 晶振下各分频的实际 A/D 转换时钟和一次转换耗时(采样 + 保持)对比如下:
| ADCS2:0 | A/D 时钟 | 20 MHz 时频率 | 一次转换耗时 | 是否推荐 |
|---|---|---|---|---|
| 000 | FOSC/2 | 10 MHz | 1.6 µs | 否(太快) |
| 001 | FOSC/8 | 2.5 MHz | 6.4 µs | 边缘 |
| 010 | FOSC/32 | 625 kHz | 25.6 µs | 推荐(高速) |
| 011 | FOSC/64 | 312.5 kHz | 51.2 µs | 推荐(常规) |
| 1x0 | FOSC/128 | 156 kHz | 102.4 µs | 推荐(高精度) |
| 1x1 | 内部 RC | 500 kHz | 10 µs(固定) | 电源不稳时用 |
数据手册给的采样保持电容是 10 pF,要求这个电容在 1 个 A/D 时钟周期内充到 95% 电压。FOSC/2 分频(10 MHz、100 ns 周期)下,10 pF 电容允许的源电阻最大值只有约 1 kΩ,否则电容充不满,读值偏低。这就是为什么 PT100 分压网络如果直接接 AN0,而分压电阻是 10 kΩ,你用 FOSC/8 或更快就会有误差。选 FOSC/64 或 FOSC/128 才是稳妥的。
Microchip 官方给出的建议是:把从 0x8000 读出的两个校准字,加一个小的修正值(取决于 VDD 实际值)后写入 ADCON1 的偏移位,同时 ADRESH/ADRESL 再做一次加法/减法修正。实际工程里有两种做法:一种是只写 ADCON1,利用硬件内部的偏移修正电路;另一种是读原始码后在软件里做公式修正。两者互不冲突,可以叠加。
/* ==== 配置区(用 XC8 编译器) ====
* 关键:PCFG3:PCFG0 必须能覆盖 1111 状态,上电先切到 1111 读校准
*/
#pragma config FOSC = HS /* 高速晶振 */
#pragma config WDTE = OFF
#pragma config PWRTE = ON
#pragma config BOR = ON
#pragma config LVP = OFF
#pragma config CPD = OFF
#pragma config WRT = OFF
#pragma config CP = OFF
#include
#define _XTAL_FREQ 20000000
/* 全局变量:保存校准值 */
uint16_t g_adc_offset_cal = 0; /* 0x8000 读出 */
uint16_t g_adc_gain_cal = 0; /* 0x8001 读出 */
/* 目标配置:我们要 RA0~RA3 做模拟 AN0~AN3,其他数字
* PCFG = 0000, VCFG0=0(用Vdd), VCFG1=0(用Vss), ADFM=1
* => ADCON1 = 0b0000_0000 = 0x00 */
#define ADCON1_TARGET 0x80 /* ADFM=1, PCFG=0000, VCFG=00 */
/* ==== 校准读取:必须在上电最早阶段执行 ==== */
void adc_read_factory_cal(void)
{
uint16_t tblptr;
/* 第一步:临时把 PCFG 改成 1111,让程序存储器映射到校准区 */
ADCON1 = 0x0F; /* PCFG=1111, ADFM=0(无所谓), VCFG=00 */
/* 第二步:用 TBLRD* 读 0x8000 和 0x8001 */
TBLPAG = 0x01; /* 指向 0x8000 所在页 */
tblptr = 0x8000;
__asm {
MOVLW low(tblptr)
MOVWF TBLPTRL
MOVLW high(tblptr)
MOVWF TBLPTRH
TBLRD*+
MOVF TABLAT, W
MOVWF g_adc_offset_cal
MOVLW high(g_adc_offset_cal >> 8) & 0xFF
MOVWF g_adc_offset_cal + 1
MOVLW low(tblptr+1)
MOVWF TBLPTRL
MOVLW high(tblptr+1)
MOVWF TBLPTRH
TBLRD*
MOVF TABLAT, W
MOVWF g_adc_gain_cal
}
/* 第三步:恢复 ADCON1 到实际目标值 */
ADCON1 = ADCON1_TARGET;
}
/* ==== ADC 初始化 ==== */
void adc_init(void)
{
/* 先读工厂校准 */
adc_read_factory_cal();
/* ADCON2: FOSC/64 = 011,采样时间 = TAD/2 = 10
* ADCS2:ADCS0 = 011, ACQT2:ACQT0 = 101 */
ADCON2 = 0b00010111;
/* 通道 0,ADON=1,GO/DONE=0 */
ADCON0 = 0b01000001;
/* 端口方向 */
TRISA = 0xFF; /* RA 全部输入 */
/* 等待内部校准完成稳定 */
__delay_us(10);
}
/* ==== ADC 单次采样并返回已校准的 10 位结果 ==== */
uint16_t adc_read_calibrated(uint8_t channel)
{
uint16_t raw;
int16_t corrected;
/* 选通道(ADCON0 的 bits 5:2 是 CHS3:CHS0) */
ADCON0 &= ~0b00011100;
ADCON0 |= (uint8_t)(channel & 0x0F) << 2;
/* 等采样电容充好 */
__delay_us(5);
/* 启动转换 */
ADCON0bits.GO_nDONE = 1;
/* 忙等待完成(也可以用中断) */
while (ADCON0bits.GO_nDONE) continue;
/* 读结果(ADFM=1 右对齐,ADRESH 高 8 位,ADRESL 低 2 位) */
raw = (uint16_t)ADRESH << 8 | (uint16_t)ADRESL;
/* 软件偏移修正:出厂校准值是一个有符号偏移
* 8 位有符号,范围 -128 ~ +127 */
corrected = (int16_t)raw + (int16_t)((int8_t)(g_adc_offset_cal & 0x00FF));
/* 增益修正(可选,用 0x8001 值乘系数)
* gain_factor = 1 + (g_adc_gain_cal - 0x0080) / 256.0
* 这里用整数近似 */
corrected = corrected * (int8_t)(g_adc_gain_cal & 0x00FF) / 0x0080;
/* 钳位到 0~1023 */
if (corrected < 0) corrected = 0;
if (corrected > 1023) corrected = 1023;
return (uint16_t)corrected;
}
关键提醒:如果你用的是 PIC16F876A(不带 7),它只有 5 个通道(AN0~AN4),没有 AN5~AN11,没有 ADCON2,也没有内部 EEPROM。本文讲的 128 字节内部 EEPROM 是 PIC16F877A 独有的。
PIC16F877A 的内部 EEPROM 是 128 字节,地址 0x00~0x7F,擦写次数 100 万次,掉电保持寿命 40 年。这东西的接口不像外部 I²C EEPROM 那样友好,Microchip 把它做成了"bank 切换 + 四个寄存器 + 必须解锁"的组合拳:
读 EEPROM 的步骤很简单:Bank 切到 Bank2 → 写 EEADR → 写 EECON1.RD=1 → 等 1 个指令周期 → 读 EEDATA → Bank 切回 Bank0。耗时约 5 个指令周期。
写 EEPROM 就麻烦了。除了 Bank 切换和地址、数据寄存器,还必须先置 WREN 位,然后在一个临界段里向 EECON2 依次写入 0x55、0xAA,再紧接着把 EECON1.WR 置 1。这个临界段期间绝对不能被中断打断——如果你的 ISR 刚好在解锁序列中间插进来,中断返回后 WR 位根本不会被写进去,EEPROM 里还是旧值。这就是现场"参数偶尔乱掉"的主要原因。
| 操作 | 步骤数 | 耗时 | 是否需要解锁 |
|---|---|---|---|
| 读单字节 | ~5 步 | ~5 指令周期 | 否 |
| 写单字节 | ~10 步 | ~2 ms(内部固有) | 是(0x55→0xAA→WR) |
| 擦除 | — | — | 不需要,写入即覆盖 |
| 访问程序存储器 | 额外 TBLPAG 页切换 | 稍慢 | 是(同解锁序列) |
还有几个容易忽略的细节:EEADR 和 EEDATA 在 Bank0,EECON1 和 EECON2 在 Bank2;写完之后 WREN 必须软件清零,否则下一次中断可能意外触发写操作;EECON1 的 WR 或 RD 置位后,硬件会自动执行,执行完自动清零——你不用轮询,但写完 WREN 必须手动清。
/* ==== PIC16F877A 内部 EEPROM 读写 ==== */
#define EEPROM_BASE 0x00 /* 128 字节起始地址 */
/* 读一个字节 */
uint8_t eeprom_read(uint8_t addr)
{
uint8_t val;
BANKSEL EEADR; /* 切到包含 EEADR 的 bank */
EEADR = addr;
BANKSEL EEDATA;
EECON1bits.EEPGD = 0; /* 0 = EEPROM, 1 = Flash */
EECON1bits.RD = 1; /* 触发读 */
/* 硬件自动执行,等 1 周期 */
val = EEDATA;
BANKSEL PORTA; /* 切回 Bank0 方便后续操作 */
return val;
}
/* 写一个字节(带解锁、关中断、等待完成) */
void eeprom_write(uint8_t addr, uint8_t val)
{
uint8_t gie_save = INTCONbits.GIE;
BANKSEL EEADR;
EEADR = addr;
BANKSEL EEDATA;
EEDATA = val;
EECON1bits.EEPGD = 0;
/* 关中断!解锁序列不能被打断 */
INTCONbits.GIE = 0;
/* 写使能 */
EECON1bits.WREN = 1;
/* 解锁序列(必须在关中断状态下执行) */
EECON2 = 0x55;
EECON2 = 0xAA;
/* 触发写 */
EECON1bits.WR = 1;
/* 恢复全局中断 */
INTCONbits.GIE = gie_save;
/* 等写完成(硬件执行完会自动清 WR) */
while (EECON1bits.WR) continue;
/* 清 WREN,防止意外写 */
EECON1bits.WREN = 0;
BANKSEL PORTA;
}
有了 EEPROM 的基础能力,再来搭参数存储架构。128 字节不大,精打细算用。典型温控器需要持久化的参数包括:控温上限、控温下限、传感器校准偏移、PID 系数 Kp/Ki/Kd、通信地址、波特率、版本号、CRC 校验。
推荐三段式布局:
| 地址范围 | 大小 | 用途 | 内容 |
|---|---|---|---|
| 0x00 ~ 0x1F | 32 字节 | 配置区(只读、极少写) | 出厂版本号、硬件 ID、传感器型号、默认参数模板 |
| 0x20 ~ 0x5F | 64 字节 | 运行区(频繁读写) | 用户设定值、校准偏移、PID 系数、报警阈值 |
| 0x60 ~ 0x7F | 32 字节 | 校验/日志区 | 运行区 CRC8、写计数、上次写入时间戳、最后一次有效快照 |
为什么要这样分?因为配置区和运行区的擦写次数完全不同:版本号、硬件 ID 这些东西生产时写一次就再也不改,放最前面;运行区用户可能每天改好几次,需要用 CRC 保护;校验区放 CRC 和写计数,一旦运行区读到 CRC 不匹配,就退回默认值并且递增写计数,这样出问题时你能看日志定位。
CRC8 用什么多项式?主流三个:0x07(标准 CRC-8,多项式 x⁸+x²+x+1)、0x31(CRC-8/CCITT,多项式 x⁸+x⁴+x³+x+1)、0x9B(CRC-8/Maxim)。嵌入式里推荐 0x31,查表法代码最小、计算最快。一个 64 字节的运行区,计算 CRC8 只需要不到 5 µs。
写入去抖也很重要。用户不小心按错键改了参数,你的程序直接就往 EEPROM 写了一次,这 1 个错误值白白消耗了 1 次寿命。解决方案:在主循环里采样参数,连续 5 次读到完全相同的值,才调用 eeprom_write。5 次采样间隔 100 ms 就是 500 ms,对用户无感,但能把"单次按键抖动"过滤掉。
/* ==== CRC8 查表法(多项式 0x31, CRC-8/CCITT) ==== */
static const uint8_t crc8_table[256] = {
0x00, 0x31, 0x62, 0x53, 0xC4, 0xF5, 0xA6, 0x97,
0xB9, 0x88, 0xDB, 0xEA, 0x7D, 0x4C, 0x1F, 0x2E,
0x43, 0x72, 0x21, 0x10, 0x87, 0xB6, 0xE5, 0xD4,
0xFA, 0xCB, 0x98, 0xA9, 0x3E, 0x0F, 0x5C, 0x6D,
0x86, 0xB7, 0xE4, 0xD5, 0x42, 0x73, 0x20, 0x11,
0x3F, 0x0E, 0x5D, 0x6C, 0xFB, 0xCA, 0x99, 0xA8,
0xC5, 0xF4, 0xA7, 0x96, 0x01, 0x30, 0x63, 0x52,
0x7C, 0x4D, 0x1E, 0x2F, 0xB8, 0x89, 0xDA, 0xEB,
0x3D, 0x0C, 0x5F, 0x6E, 0xF9, 0xC8, 0x9B, 0xAA,
0x84, 0xB5, 0xE6, 0xD7, 0x40, 0x71, 0x22, 0x13,
0x7E, 0x4F, 0x1C, 0x2D, 0xBA, 0x8B, 0xD8, 0xE9,
0xC7, 0xF6, 0xA5, 0x94, 0x03, 0x32, 0x61, 0x50,
0xBB, 0x8A, 0xD9, 0xE8, 0x7F, 0x4E, 0x1D, 0x2C,
0x02, 0x33, 0x60, 0x51, 0xC6, 0xF7, 0xA4, 0x95,
0xF8, 0xC9, 0x9A, 0xAB, 0x3C, 0x0D, 0x5E, 0x6F,
0x41, 0x70, 0x23, 0x12, 0x85, 0xB4, 0xE7, 0xD6,
0x7A, 0x4B, 0x18, 0x29, 0xBE, 0x8F, 0xDC, 0xED,
0xC3, 0xF2, 0xA1, 0x90, 0x07, 0x36, 0x65, 0x54,
0x39, 0x08, 0x5B, 0x6A, 0xFD, 0xCC, 0x9F, 0xAE,
0x80, 0xB1, 0xE2, 0xD3, 0x44, 0x75, 0x26, 0x17,
0xFC, 0xCD, 0x9E, 0xAF, 0x38, 0x09, 0x5A, 0x6B,
0x45, 0x74, 0x27, 0x16, 0x81, 0xB0, 0xE3, 0xD2,
0xBF, 0x8E, 0xDD, 0xEC, 0x7B, 0x4A, 0x19, 0x28,
0x06, 0x37, 0x64, 0x55, 0xC2, 0xF3, 0xA0, 0x91,
0x47, 0x76, 0x25, 0x14, 0x83, 0xB2, 0xE1, 0xD0,
0xFE, 0xCF, 0x9C, 0xAD, 0x3A, 0x0B, 0x58, 0x69,
0x04, 0x35, 0x66, 0x57, 0xC0, 0xF1, 0xA2, 0x93,
0xBD, 0x8C, 0xDF, 0xEE, 0x79, 0x48, 0x1B, 0x2A,
0xC1, 0xF0, 0xA3, 0x92, 0x05, 0x34, 0x67, 0x56,
0x78, 0x49, 0x1A, 0x2B, 0xBC, 0x8D, 0xDE, 0xEF,
0x82, 0xB3, 0xE0, 0xD1, 0x46, 0x77, 0x24, 0x15,
0x3B, 0x0A, 0x59, 0x68, 0xFF, 0xCE, 0x9D, 0xAC
};
uint8_t crc8_calc(const uint8_t *data, uint16_t len)
{
uint8_t crc = 0xFF; /* CRC-8/CCITT 初值 */
while (len--) {
crc = crc8_table[(crc ^ *data++) & 0xFF];
}
return crc;
}
/* ==== 运行区参数结构体 ==== */
#pragma pack(push, 1)
typedef struct {
uint16_t temp_high_limit; /* 控温上限(×10,例如 800 = 80.0℃) */
uint16_t temp_low_limit; /* 控温下限 */
int16_t sensor_offset; /* 传感器校准偏移(×10) */
uint16_t pid_kp; /* PID Kp(×100) */
uint16_t pid_ki; /* PID Ki(×100) */
uint16_t pid_kd; /* PID Kd(×100) */
uint16_t comm_addr; /* 通信地址 */
uint16_t baudrate_index; /* 波特率索引 */
uint8_t reserved[40]; /* 预留填充到 64 字节 */
} run_params_t;
#pragma pack(pop)
static run_params_t g_run_params;
#define RUN_PARAM_ADDR 0x20
#define RUN_CRC_ADDR 0x60
/* ==== 运行区整体读写 ==== */
uint8_t params_load_from_eeprom(void)
{
uint8_t crc_calc, crc_stored, i;
uint8_t *p = (uint8_t *)&g_run_params;
/* 1. 读运行区(64 字节) */
for (i = 0; i < sizeof(run_params_t); i++) {
p[i] = eeprom_read(RUN_PARAM_ADDR + i);
}
/* 2. 读存储的 CRC */
crc_stored = eeprom_read(RUN_CRC_ADDR);
/* 3. 计算当前 CRC 并比对 */
crc_calc = crc8_calc(p, sizeof(run_params_t));
if (crc_calc != crc_stored) {
/* CRC 校验失败:数据损坏或从未写入 */
/* 加载默认值 */
g_run_params.temp_high_limit = 800;
g_run_params.temp_low_limit = 200;
g_run_params.sensor_offset = 0;
g_run_params.pid_kp = 100;
g_run_params.pid_ki = 10;
g_run_params.pid_kd = 5;
g_run_params.comm_addr = 1;
g_run_params.baudrate_index = 2;
memset(g_run_params.reserved, 0, sizeof(g_run_params.reserved));
/* 写回默认值 + 正确 CRC */
params_save_to_eeprom();
return 0; /* 表示加载失败、用了默认值 */
}
return 1; /* 加载成功 */
}
void params_save_to_eeprom(void)
{
uint8_t crc;
uint8_t *p = (uint8_t *)&g_run_params;
uint8_t i;
/* 先算 CRC */
crc = crc8_calc(p, sizeof(run_params_t));
/* 关中断保护:先写数据,最后写 CRC
* 如果中途断电,CRC 不匹配,下次启动会用默认值 */
INTCONbits.GIE = 0;
for (i = 0; i < sizeof(run_params_t); i++) {
eeprom_write(RUN_PARAM_ADDR + i, p[i]);
}
/* 最后写 CRC */
eeprom_write(RUN_CRC_ADDR, crc);
INTCONbits.GIE = 1;
}
/* ==== 主循环写入去抖(5 次相同才写) ==== */
static run_params_t g_pending_save;
static uint8_t g_save_stable_count = 0;
void params_save_debounce_check(void)
{
/* 比较当前参数和上次快照 */
if (memcmp(&g_run_params, &g_pending_save, sizeof(run_params_t)) != 0) {
g_pending_save = g_run_params;
g_save_stable_count = 1;
} else {
g_save_stable_count++;
}
if (g_save_stable_count >= 5) {
params_save_to_eeprom();
g_save_stable_count = 0;
}
}
把前面几段代码拼起来,调用顺序是:上电后 __interrupt() isr() 的高优先级入口里先执行 adc_init()(内部会调 adc_read_factory_cal),然后调 params_load_from_eeprom(),之后才进入 main 主循环。主循环里每 50 ms 调一次 params_save_debounce_check() 做写入去抖。
ISR 里要做的事:Timer0 每 1 ms 溢出 → 置标志让主循环采一次 ADC(用 adc_read_calibrated());UART 收到参数改帧 → 写 g_run_params 结构、让 debounce 在下一轮存;EEPROM 写入中断标志 EEPIF(如果你开了中断模式)。注意所有 EEPROM 读写函数内部已经自己关过全局中断了,ISR 里不用再关第二次。
/* ==== 完整中断服务程序 ISR ==== */
#define MS_PER_TICK 1
volatile uint16_t g_tick_ms = 0; /* 系统毫秒计数 */
volatile uint8_t g_adc_ready = 0; /* 主循环取 ADC 结果的标志 */
volatile uint16_t g_adc_result = 0; /* 已校准的 ADC 结果 */
volatile uint8_t g_params_changed = 0;
void __interrupt(high_priority) isr_high(void)
{
/* Timer0: 1 ms tick(20MHz, 1:256 预分频, 250 计数) */
if (INTCONbits.T0IF) {
INTCONbits.T0IF = 0;
TMR0 = 256 - 125; /* 重装:1 ms */
g_tick_ms += MS_PER_TICK;
/* 每 100 ms 触发一次 ADC 转换 */
if ((g_tick_ms % 100) == 0) {
/* 注意:ISR 里不要做完整采样(太慢),置标志让主循环做 */
PIR1bits.ADIF = 0;
ADCON0bits.GO_nDONE = 1; /* 启动转换 */
PIE1bits.ADIE = 1; /* 开 ADC 完成中断 */
}
}
/* ADC 完成中断:在中断里取结果、算校准值、置 ready 标志 */
if (PIR1bits.ADIF) {
PIR1bits.ADIF = 0;
PIE1bits.ADIE = 0;
{
uint16_t raw = (uint16_t)ADRESH << 8 | (uint16_t)ADRESL;
int16_t corrected = (int16_t)raw +
(int16_t)((int8_t)(g_adc_offset_cal & 0x00FF));
if (corrected < 0) corrected = 0;
if (corrected > 1023) corrected = 1023;
g_adc_result = (uint16_t)corrected;
g_adc_ready = 1;
}
}
/* UART 接收:参数改帧 */
if (PIR1bits.RCIF) {
static uint8_t rxbuf[32], rxidx = 0;
uint8_t b = RCREG;
if (rxidx < sizeof(rxbuf)) rxbuf[rxidx++] = b;
/* 帧结束符 0x0D */
if (b == 0x0D && rxidx >= 8) {
/* 假设协议:0xAA 0x55 + 4字节数据 + 0x0D */
if (rxbuf[0] == 0xAA && rxbuf[1] == 0x55) {
/* 简单示例:rxbuf[2:3] 改上限温度 */
g_run_params.temp_high_limit =
(uint16_t)rxbuf[2] << 8 | rxbuf[3];
g_params_changed = 1;
}
rxidx = 0;
}
}
}
/* ==== main 主函数 ==== */
int main(void)
{
/* 1. 系统初始化 */
OSCCON = 0x70; /* 内部 8 MHz(或外部 HS 晶振) */
TRISA = 0xFF;
TRISB = 0x00;
TRISD = 0x00;
PORTA = 0; PORTB = 0; PORTD = 0;
/* 2. Timer0: 1 ms tick */
OPTION_REG = 0x84; /* T0CS=0, PSA=0, PS=100 */
TMR0 = 256 - 125;
INTCONbits.T0IE = 1;
INTCONbits.T0IF = 0;
/* 3. 串口 */
TXSTA = 0x24; /* BRGH=1, TXEN=1 */
RCSTA = 0x90; /* SPEN=1, CREN=1 */
SPBRG = 51; /* 20MHz 下 9600bps */
PIR1bits.RCIF = 0;
PIE1bits.RCIE = 1;
/* 4. 关键:ADC 初始化 + 读 Factory Calibration */
adc_init();
/* 5. EEPROM 参数加载(带 CRC 校验) */
params_load_from_eeprom();
/* 6. 开总中断 */
INTCONbits.GIE = 1;
INTCONbits.PEIE = 1;
/* 7. 主循环 */
while (1) {
uint16_t temp_raw;
float temp_c;
/* 等 ADC 结果就绪 */
if (g_adc_ready) {
g_adc_ready = 0;
temp_raw = g_adc_result;
/* PT100 分压网络换算成温度(示例公式)
* V = temp_raw * VDD / 1024
* R_pt100 = V / (VDD - V) * R_fixed
* 简化:R_fixed=100Ω, 近似线性 0~100℃ */
temp_c = (float)temp_raw / 1024.0f * 100.0f +
(float)g_run_params.sensor_offset / 10.0f;
/* 控温逻辑(示例) */
if (temp_c < g_run_params.temp_low_limit / 10.0f) {
PORTBbits.RB0 = 1; /* 开加热继电器 */
} else if (temp_c > g_run_params.temp_high_limit / 10.0f) {
PORTBbits.RB0 = 0;
}
/* 串口上报当前温度 */
printf("T=%.1fC, L=%u, H=%u\r\n",
temp_c,
g_run_params.temp_low_limit / 10,
g_run_params.temp_high_limit / 10);
}
/* 参数写入去抖检查 */
if (g_params_changed) {
params_save_debounce_check();
}
/* 喂看门狗(如果开了的话) */
// CLRWDT();
}
return 0;
}
到这里代码层面的工作已经齐了。但现场出了问题,能快速定位根因比代码写得漂亮更重要。下面两张表把"ADC 读不准"和"EEPROM 数据丢失"分别拆成最常见的几种根因和排查/解决手段。
| 现象 | 根因 | 排查手段 | 解决 |
|---|---|---|---|
| 整批偏移 +2%~3% | 没有读 Factory Calibration Word | 用仿真器看 g_adc_offset_cal 是否为 0xFFFF 或未赋值 | 上电先切换 PCFG=1111,TBLRD 读 0x8000~0x8001 |
| 高速场景随机飘 | ADCS 分频太快(FOSC/2 或 FOSC/8),10 pF 保持电容充不满 | 把 ADCS 改成 011(FOSC/64)或 1x0(FOSC/128)再测 | ADCON2 = 0b00010111(FOSC/64,采样时间 10TAD) |
| 低温漂、温漂大 | VDD 随温度变化(模拟电源没加 LDO 或 RC 滤波) | 用示波器盯 VDD 在高低温下的变化量 | ADC 前加独立 220Ω + 10 µF + 0.1 µF RC 滤波 |
| 10 位结果只有 7 位精度 | ADFM 没置 1,结果左对齐只用了 ADRESH 的 8 位 | 看 ADCON1 的 bit 5 是不是 0 | ADCON1 |= 0x80(ADFM=1) |
| VREF 外部时读不准 | RA2/RA3 上的 Vref 源电阻太大 | 用万用表量 Vref 源输出阻抗 | Vref 源电阻应 < 1 kΩ,否则加缓冲运放 |
| 现象 | 根因 | 排查手段 | 解决 |
|---|---|---|---|
| 重启后随机变 0x00/0xFF | 解锁序列(0x55→0xAA→WR)被 ISR 打断 | 在 eeprom_write 里 WREN 置 1 后加 INTCON.GIE=0,写完恢复 | 关中断 + 完整解锁序列,参考本文 eeprom_write 实现 |
| 只有高温下才丢 | WREN 没有清零,高温漏电流导致意外触发写 | 查 EECON1.WREN 在写完成后是否仍为 1 | 每次写完手动 WREN=0 |
| 写入频率过快(每秒几十次) | 擦写寿命耗尽(100 万次) | 用计数器统计 eeprom_write 调用次数 | 加写入去抖(5 次相同才写)或改用 Flash 程序存储区 |
| CRC 经常校验失败 | 断电时机刚好在写入运行区和 CRC 之间 | 写运行区 + 写 CRC 两段都关中断,CRC 放最后 | 先写数据区、最后写 CRC,CRC 校验失败则用默认值 |
ADC 校准的核心:上电早期切 PCFG=1111,TBLRD 读 0x8000~0x8007,放回 RAM 做偏移/增益修正。ADCS 分频不要太快,FOSC/64 是常规安全值。VDD 加独立 RC 滤波。
EEPROM 安全写入的核心:解锁序列期间必须关中断,写完 WREN 清 0。参数存储用"三段式 + CRC8 + 写入去抖"组合,CRC 放在最后写,校验失败回默认值。
温控器现场的典型修复:ADC 漂移 ±3% 是没读 Factory Calibration;参数重启乱码是 EEPROM 解锁序列被中断打断,两个 Bug 同时修完,产品才稳。
还想接着看:PIC16F876A 脉宽捕获实战:CCP 与 MSSP 从机配置、EEPROM 数据可靠性保护设计:校验、备份与磨损均衡、嵌入式高精度 ADC 设计:信号链到校准全流程。
本文基于 Microchip PIC16F87XA 数据手册(DS39582B)技术资料整理优化。
沧州艾诺威电子 — 国家高新技术企业,20+项国家专利
嵌入式系统开发 · 物联网方案 · AI智能硬件 · 一站式交付
电话:13930711029 | 邮箱:tech@czinv.com | 24小时内响应