准备工作
在开始编程之前,你需要以下工具:

(图片来源网络,侵删)
-
硬件:
- 一块PIC16F877A开发板(或最小系统板)。
- USB转TTL/串口模块(用于程序下载,如PICkit 3/4或简单的CH340G模块)。
- LED灯(通常开发板已自带)。
- 按键。
- 10kΩ 电阻(用于按键上拉)。
- 面包板和杜邦线。
-
软件:
- MPLAB X IDE: Microchip的官方集成开发环境。
- XC8 Compiler: Microchip的C语言编译器(有免费版)。
点亮一个LED (GPIO输出)
这是所有单片机编程的 "Hello, World!",我们将让连接在RB0引脚上的LED闪烁。
硬件连接
- PIC16F877A的
RB0(PORTB, bit 0) 引脚连接到LED的正极。 - LED的负极通过一个限流电阻(如330Ω)连接到GND(地)。
C语言代码 (blink.c)
#include <xc.h>
// 配置位设置 (非常重要!)
// 对于PIC16F877A,使用#FOSC_HS表示使用外部高速晶振
// 禁看门狗等
__CONFIG(FOSC_HS & WDTE_OFF & PWRTE_ON & BOREN_OFF & LVP_OFF & CPD_OFF & WRT_OFF & CP_OFF);
#define _XTAL_FREQ 4000000 // 定义晶振频率,这里使用4MHz
void main(void) {
// 1. 设置TRISB寄存器
// TRISB是一个方向寄存器,1代表输入,0代表输出
// 我们想控制RB0,所以将其设为0
TRISBbits.TRISB0 = 0; // 将RB0设置为输出模式
// 2. 主循环
while(1) {
// 3. 操作PORTB寄存器来控制LED
// 设置RB0为高电平,点亮LED
PORTBbits.RB0 = 1;
// 延时一段时间
__delay_ms(500); // 延时500毫秒 (需要定义_XTAL_FREQ)
// 设置RB0为低电平,熄灭LED
PORTBbits.RB0 = 0;
// 再次延时
__delay_ms(500);
}
}
代码解释
#include <xc.h>: 包含所有XC8编译器所需的头文件,定义了寄存器(如TRISB,PORTB)和特殊功能函数。__CONFIG(...): 这是配置位设置,它告诉单片机的硬件如何工作,比如选择哪种时钟源、是否启用看门狗等。对于每个项目,这都是必须的。#define _XTAL_FREQ 4000000: 告诉编译器你的系统时钟频率是4MHz,这对于__delay_ms()这类延时函数的精确计算至关重要。TRISBbits.TRISB0 = 0;:TRIS寄存器用于设置I/O引脚的方向。TRISB0是TRISB寄存器的第0位,设为0,表示RB0引脚为输出。PORTBbits.RB0 = 1;:PORT寄存器用于在输出模式下控制引脚的电平,设为1,RB0输出高电平,LED点亮。__delay_ms(500);: 这是一个XC8提供的内置函数,用于产生精确的毫秒级延时。必须定义_XTAL_FREQ才能使用。while(1): 这是一个无限循环,确保程序持续运行,LED不断闪烁。
读取按键状态 (GPIO输入)
我们将读取连接在RB1引脚上的按键状态,并在RB0的LED上显示出来(按键按下时LED亮)。

(图片来源网络,侵删)
硬件连接
RB0-> LED -> 电阻 -> GND (同实例一)RB1-> 按键一端- 按键另一端 -> GND
- 在
RB1和VDD(5V) 之间接一个10kΩ上拉电阻,这样按键未按下时,RB1为高电平;按下时,RB1被拉到低电平。
C语言代码 (button_led.c)
#include <xc.h>
__CONFIG(FOSC_HS & WDTE_OFF & PWRTE_ON & BOREN_OFF & LVP_OFF & CPD_OFF & WRT_OFF & CP_OFF);
#define _XTAL_FREQ 4000000
void main(void) {
// 1. 配置引脚方向
TRISBbits.TRISB0 = 0; // RB0为输出
TRISBbits.TRISB1 = 1; // RB1为输入
// 2. 配置RB1内部上拉电阻 (可选,但推荐)
// 当引脚设置为输入时,可以启用内部上拉电阻,这样就可以省略外部上拉电阻
OPTION_REGbits.nRBPU = 0; // 禁止PORTB上拉锁存器,从而启用内部上拉
WPUBbits.WPUB1 = 1; // 为RB1启用内部上拉
while(1) {
// 3. 读取按键状态
// 如果RB1为低电平 (按键按下)
if (PORTBbits.RB1 == 0) {
PORTBbits.RB0 = 1; // 点亮LED
} else {
// 如果RB1为高电平 (按键未按下)
PORTBbits.RB0 = 0; // 熄灭LED
}
}
}
代码解释
TRISBbits.TRISB1 = 1;: 将RB1设置为输入模式。OPTION_REGbits.nRBPU = 0;:OPTION_REG是一个特殊功能寄存器。nRBPU位控制PORTB的所有上拉电阻,将其设为0,表示使能PORTB的上拉功能。WPUBbits.WPUB1 = 1;:WPUB寄存器用于单独控制PORTB每个引脚的上拉电阻。WPUB1 = 1表示为RB1引脚启用内部上拉电阻。if (PORTBbits.RB1 == 0): 读取PORTB寄存器的RB1位,因为按键按下时引脚被拉低,所以检查是否为0。- 注意: 如果没有使用内部上拉,而是外部上拉,读取逻辑不变。
使用定时器中断实现精确闪烁
上面的__delay_ms()虽然方便,但在延时期间,CPU在空转,无法执行其他任务,使用定时器中断可以实现非阻塞的、更精确的定时。
C语言代码 (timer_interrupt.c)
#include <xc.h>
__CONFIG(FOSC_HS & WDTE_OFF & PWRTE_ON & BOREN_OFF & LVP_OFF & CPD_OFF & WRT_OFF & CP_OFF);
#define _XTAL_FREQ 4000000
// 全局变量,用于在中断服务程序和主程序之间传递数据
volatile unsigned int led_counter = 0;
// 中断服务程序
void __interrupt() myISR(void) {
// 检查是否是TMR0溢出中断
if (INTCONbits.T0IF) {
// 1. 清除中断标志位,非常重要!
INTCONbits.T0IF = 0;
// 2. 重新加载TMR0的初值,以产生固定的中断间隔
// 假设我们希望每0.025秒中断一次
// 4MHz晶振,TMR0预分频比为1:256
// TMR0是一个8位定时器,最大计数值为256
// 中断频率 = 4MHz / 256 / (256 - 初值)
// 我们想得到40Hz (0.025s) 的中断
// 4000000 / 256 / (256 - X) = 40
// 256 - X = 4000000 / 256 / 40 = 387.5
// X = 256 - 387.5 (负数,说明需要调整思路)
// 更简单的计算:每次计数增加 1,需要 256 - X 次计数才溢出。
// 我们希望 256 - X = (4MHz / 256) / 40 = 387.5,这不可能,因为X必须小于256。
// 我们调整目标,比如每1ms中断一次。
// 4000000 / 256 / (256 - X) = 1000
// 256 - X = 4000000 / 256 / 1000 = 15.6
// X = 256 - 16 = 240
TMR0 = 240; // 重新加载初值
// 3. 执行中断任务
led_counter++;
// 每40次中断 (40 * 1ms = 40ms) 切换一次LED,实现1Hz闪烁
if (led_counter >= 40) {
PORTBbits.RB0 = ~PORTBbits.RB0; // 取反LED状态
led_counter = 0;
}
}
}
void main(void) {
// 1. 配置TMR0
T0CS = 0; // TMR0由内部指令时钟驱动
PSA = 0; // 分频器分配给TMR0
PS2 = PS1 = PS0 = 1; // 设置分频比为1:256
// 2. 配置中断
GIE = 1; // 全局中断使能
T0IE = 1; // TMR0溢出中断使能
// 3. 配置LED引脚
TRISBbits.TRISB0 = 0; // RB0为输出
PORTBbits.RB0 = 0; // 初始状态为熄灭
// 4. 加载TMR0初值
TMR0 = 240;
// 5. 主循环
while(1) {
// 主循环可以执行其他任务,比如处理按键、通信等
// 这里我们让它空转,因为所有定时任务都在中断里完成了
}
}
代码解释
volatile unsigned int led_counter = 0;:volatile关键字非常重要!它告诉编译器这个变量可能会在程序的其他地方(比如中断服务程序中被改变),防止编译器优化掉看似“不必要”的访问。void __interrupt() myISR(void): 这是中断服务程序的固定写法。if (INTCONbits.T0IF): 检查TMR0的中断标志位是否被置位,如果被置位,说明TMR0已经溢出。INTCONbits.T0IF = 0;: 必须手动清除中断标志位,否则CPU会立即再次进入中断,导致程序卡死。TMR0 = 240;: 在中断服务程序中重新给TMR0赋值,这样它就会从240开始重新计数,再次溢出时就会再次触发中断,形成一个周期性的定时器。GIE = 1;: 全局中断使能位,是开启所有中断的总开关。T0IE = 1;: TMR0溢出中断使能位,是开启TMR0中断的开关。while(1): 主循环现在是空闲的,CPU可以响应其他事件,实现了多任务处理。
进阶实例:驱动LCD1602字符液晶
LCD1602是常用的显示模块,可以显示两行,每行16个字符,这需要更复杂的时序控制,通常使用4位或8位模式,这里我们使用4位模式以节省I/O口。
硬件连接 (4位模式)
| LCD Pin | Function | PIC16F877A Pin |
|---|---|---|
| VSS | GND | GND |
| VDD | +5V | VDD |
| V0 | 对比度 (接电位器中间脚) | - |
| RS | 寄存器选择 | RB4 |
| RW | 读/写 (通常接地) | GND |
| EN | 使能 | RB5 |
| D4 | 数据线 4 | RB6 |
| D5 | 数据线 5 | RB7 |
| D6 | 数据线 6 | RB8 |
| D7 | 数据线 7 | RB9 |
| A | 背光正极 | +5V |
| K | 背光负极 | GND |
C语言代码 (lcd1602.c)
#include <xc.h>
#include <string.h> // 用于strlen函数
__CONFIG(FOSC_HS & WDTE_OFF & PWRTE_ON & BOREN_OFF & LVP_OFF & CPD_OFF & WRT_OFF & CP_OFF);
#define _XTAL_FREQ 4000000
// LCD引脚定义
#define LCD_RS RB4
#define LCD_EN RB5
#define LCD_D4 RB6
#define LCD_D5 RB7
#define LCD_D6 RB8
#define LCD_D7 RB9
// 函数声明
void LCD_Init();
void LCD_Cmd(unsigned char cmd);
void LCD_WriteChar(unsigned char dat);
void LCD_String(const char *str);
void LCD_SetCursor(unsigned char row, unsigned char col);
void main(void) {
// 设置所有LCD引脚为输出
TRISB4 = 0; TRISB5 = 0; TRISB6 = 0; TRISB7 = 0; TRISB8 = 0; TRISB9 = 0;
LCD_Init(); // 初始化LCD
LCD_String("Hello, PIC!"); // 显示第一行字符串
LCD_SetCursor(2, 1); // 移动到第二行,第1列
LCD_String("This is LCD"); // 显示第二行字符串
while(1) {
// 主循环保持程序运行
}
}
// 发送4位数据到LCD
void LCD_Send4Bits(unsigned char data) {
LCD_D4 = (data & 0x01) ? 1 : 0;
LCD_D5 = (data & 0x02) ? 1 : 0;
LCD_D6 = (data & 0x04) ? 1 : 0;
LCD_D7 = (data & 0x08) ? 1 : 0;
}
// 发送命令到LCD
void LCD_Cmd(unsigned char cmd) {
LCD_RS = 0; // RS=0, 表示发送命令
LCD_Send4Bits(cmd >> 4); // 发送高4位
LCD_PulseEnable();
LCD_Send4Bits(cmd); // 发送低4位
LCD_PulseEnable();
__delay_ms(2);
}
// 发送字符到LCD
void LCD_WriteChar(unsigned char dat) {
LCD_RS = 1; // RS=1, 表示发送数据
LCD_Send4Bits(dat >> 4); // 发送高4位
LCD_PulseEnable();
LCD_Send4Bits(dat); // 发送低4位
LCD_PulseEnable();
__delay_ms(2);
}
// 使能脉冲
void LCD_PulseEnable() {
LCD_EN = 1;
__delay_us(1);
LCD_EN = 0;
__delay_us(100);
}
// 初始化LCD
void LCD_Init() {
__delay_ms(50); // 等待LCD上电稳定
LCD_Cmd(0x33);
LCD_Cmd(0x32);
LCD_Cmd(0x28); // 4-bit mode, 2 lines, 5x8 dots
LCD_Cmd(0x0C); // Display on, cursor off, blink off
LCD_Cmd(0x06); // Entry mode set - increment no shift
LCD_Cmd(0x01); // Clear display
__delay_ms(2);
}
// 在指定位置显示字符串
void LCD_String(const char *str) {
for (int i = 0; str[i] != '\0'; i++) {
LCD_WriteChar(str[i]);
}
}
// 设置光标位置
void LCD_SetCursor(unsigned char row, unsigned char col) {
unsigned char row_offsets[] = {0x00, 0x40}; // 第一行偏移0,第二行偏移0x40
LCD_Cmd(0x80 | (col + row_offsets[row]));
}
代码解释
- 引脚宏定义: 使用宏定义让代码更清晰、易于修改。
- LCD_Send4Bits(): 将一个字节数据的高4位或低4位分别送到
D4-D7这4条数据线上。 - LCD_PulseEnable(): LCD的使能信号需要一个高脉冲来锁存数据,这个函数产生这个脉冲。
- LCD_Cmd(): 发送命令,首先将
RS置低,然后分两次(先高4位,后低4位)发送8位命令,并在每次发送后产生一个使能脉冲。 - LCD_WriteChar(): 发送字符,与
LCD_Cmd()类似,只是将RS置高。 - LCD_Init(): LCD上电后需要一系列初始化命令才能正常工作,这些命令时序在LCD的数据手册中有详细说明。
- LCD_String(): 遍历字符串,逐个字符调用
LCD_WriteChar()进行显示。 - LCD_SetCursor(): 通过发送特定的命令(
0x80 | address)来设置光标的位置。0x80是设置DDRAM地址的命令,address是具体的地址(第一行从0x00开始,第二行从0x40开始)。
这些实例涵盖了PIC单片机C语言编程的核心概念:
- GPIO控制: 输入/输出方向设置,读写操作。
- 延时函数:
__delay_ms()的简单使用。 - 中断: 定时器中断的配置和使用,实现非阻塞式编程。
- 外设驱动: 驱动像LCD这样的复杂外设,需要精确的时序控制。
从这些基础开始,你可以逐步学习更复杂的功能,如ADC模数转换、PWM脉宽调制、SPI/I2C通信等,实践是最好的老师,动手搭建电路、编写代码、调试问题,你的技能会飞速提升。

(图片来源网络,侵删)
