PIC16F877A ADC 校准与内部 EEPROM 参数存储实战

温控器项目冬季实测 ADC 读值漂移 ±3%、参数重启后偶发乱码,往往不是传感器本身的问题。本文按 PIC16F877A 数据手册拆解 Factory Calibration Word 读取、ANCON/PCFG 配置、采样时间约束、VREF 选择以及内部 EEPROM 读写解锁序列,给出完整校准代码、三段式参数存储架构与 CRC8 校验方案。

2026-11-03
PIC16F877AADC校准Factory CalibrationEEPROMCRC8校验参数存储

一块用了半年的温控器批量返修,现场症状两个:同一个 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 解锁序列均以该手册为准。

一、先搞明白:Factory Calibration Word 到底藏在哪里

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 个码——刚好解释现场看到的症状。

地址内容用途
0x8000ADC 偏移校准值写入 ADCON1 前两位或偏移修正表
0x8001ADC 增益校准值用于满量程增益修正
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:PCFG0RARBRC校准可见
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,用户完全无感。

二、ADC 寄存器三件套:ADCON1、采样时钟与 VREF

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 7VCFG1Vref- 来自 RA3/AN3(而非 Vss)
bit 6VCFG0Vref+ 来自 RA2/AN2(而非 Vdd)
bit 5ADFM结果右对齐(10 位全有效)
bit 4:1PCFG3: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:0A/D 时钟20 MHz 时频率一次转换耗时是否推荐
000FOSC/210 MHz1.6 µs否(太快)
001FOSC/82.5 MHz6.4 µs边缘
010FOSC/32625 kHz25.6 µs推荐(高速)
011FOSC/64312.5 kHz51.2 µs推荐(常规)
1x0FOSC/128156 kHz102.4 µs推荐(高精度)
1x1内部 RC500 kHz10 µ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 才是稳妥的。

三、校准代码:三步把 Factory Calibration 用起来

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 独有的。

四、EEPROM 基础:128 字节 + 四个寄存器 + 解锁序列

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;
}

五、参数存储架构:配置区 + 运行区 + CRC 校验区

有了 EEPROM 的基础能力,再来搭参数存储架构。128 字节不大,精打细算用。典型温控器需要持久化的参数包括:控温上限、控温下限、传感器校准偏移、PID 系数 Kp/Ki/Kd、通信地址、波特率、版本号、CRC 校验。

推荐三段式布局:

地址范围大小用途内容
0x00 ~ 0x1F32 字节配置区(只读、极少写)出厂版本号、硬件 ID、传感器型号、默认参数模板
0x20 ~ 0x5F64 字节运行区(频繁读写)用户设定值、校准偏移、PID 系数、报警阈值
0x60 ~ 0x7F32 字节校验/日志区运行区 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;
    }
}

六、完整代码整合:ISR 示例 + 调用顺序

把前面几段代码拼起来,调用顺序是:上电后 __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 丢失各自 5/4 种根因

到这里代码层面的工作已经齐了。但现场出了问题,能快速定位根因比代码写得漂亮更重要。下面两张表把"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 是不是 0ADCON1 |= 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小时内响应