简介:这是一个面向单片机初学者的51单片机C语言示例,压缩包内仅包含一个C语言源文件,用来在LCD液晶屏上实现字符滚动显示效果。资源标题和描述均聚焦于C语言在嵌入式系统中的应用,适合正在学习单片机外设驱动、LCD原理或C语言硬件开发的读者。整个资源包只有560B,内部只有1个C文件,结构极为精简,适合直接阅读代码并对照开发板理解。目前已有132人学习下载。源码涉及LCD初始化、显示模式设置、光标定位、字符写入和滚动位置更新等关键函数,同时可能借助定时器中断周期性修改字符的显示坐标,从而产生文字从屏幕一侧向另一侧平滑移动的效果。在51单片机上运行该程序时,可以直观体会到I/O口电平操作、LCD接口时序配合、字符点阵与ASCII码的转换关系等核心知识点。分析这一示例,不仅能掌握C语言对硬件寄存器的直接控制方式,还能为后续设计菜单界面、滚动显示公告等实际嵌入式项目打下基础。
1. 看名字是压缩包,实际是这套单片机教程里唯一的"硬骨头"
拿到lesson18.rar这个文件,在单片机开发这条学习路线上,基本可以判断课程已经推进到了外设通信阶段。前 17 课大概率覆盖了 GPIO 点灯、按键扫描、外部中断、定时器、串口收发,而第 18 课正好是 C/C++ 在单片机开发中真正拉开差距的地方——总线协议。具体是哪条总线,解压之前可以从编号推断:多数培训版教程在第 18 课左右安排 I2C 或 SPI 操作 EEPROM / Flash,因为单总线(DS18B20)太短,CAN 又太深,I2C 属于"时序看得见、逻辑能闭环、出错能排查"的最佳教学载体。本文不假设 rar 里是什么芯片,但会以最常见的 STC89C52 + AT24C02 为例,把"拿到 lesson18 之后该干什么"完整讲透:环境怎么恢复、代码怎么读、时序怎么调、坑在哪。适合刚学完串口中断、准备接触总线协议的人,也适合回头补课的在职工程师。
2. 解压与工程还原:先把环境变成能编译的状态
拿到压缩包第一件事不是看代码,而是确认工具链。单片机的 C/C++ 开发和桌面端不同:你写的.c文件最终要经过编译器生成 HEX 文件,再用烧录器写入芯片。常见做法是 Keil C51 配合 STC-ISP,或者新一点的环境用 SDCC 配合 Makefile。lesson18 的压缩包结构一般是源码、工程文件、烧录说明三者混杂,先把它们分清楚。
2.1 解压后需要保留哪些文件
mkdir lesson18 && cd lesson18 unzip ../lesson18.rar # 或 rar x ../lesson18.rar # 查看目录结构 find . -maxdepth 2 -type f | sort解压后我一般先执行上述命令,把文件按扩展名归类:.uvproj/.uvopt是 Keil 工程文件,.c/.h是源码,.hex是已编译产物,.txt/.doc是说明文档。重点检查.uvproj对应的芯片型号是否和手上开发板一致,多数情况教程默认 STC89C52RC,但有人用的是 STC12C5A60S2 或 AT89S52,这直接决定烧录时的晶振频率和下载方式。
2.2 Keil 工程的关键配置项
如果压缩包里没有工程文件,只有散落的.c,需要自己新建工程。常见做法是:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| Device 选择 | STC89C52RC 或 AT89C52 | 必须与芯片一致,否则寄存器地址不匹配 |
| Xtal 频率 | 11.0592 或 12.000 MHz | 影响串口波特率计算,教程代码可能硬编码 |
| Memory Model | Small: data 优先 | 51 片上 RAM 只有 128 字节,不能乱分配 |
| Code Rom Size | Large: 64K 程序 | 防止代码超过默认段范围 |
| Output HEX | 勾选 Create HEX File | 否则烧录器无文件可用 |
提示:多数入门者在这里犯的第一个错误是忘记勾选 "Create HEX File",导致编译成功但烧录器找不到文件。第二个错误是晶振频率填错,串口波特率偏差超过 2% 就会收到乱码。
2.3 最小编译验证
// main.c #include "reg52.h" void main(void) { unsigned char i = 0; while (1) { i++; // 占位逻辑,确认工具链可用 } }这段代码不涉及任何外设,目的是验证编译器、链接器、烧录链路是否完整。编译后看 Build Output 窗口里的data和code占用,如果 data 已经接近 128 字节,说明后面写不了多少变量,lesson18 的例程如果报DATA SEGMENT TOO LARGE,就要把数组改成code关键字存到 Flash,或用xdata指向外部 RAM。
2.4 烧录验证
STC 系芯片用 STC-ISP 工具,选择芯片型号、串口号、波特率后加载 HEX 文件。很多人在这一步卡住,原因是下载时需要先断电再上电,STC 芯片是冷启动进入 ISP 模式。如果使用的芯片是 AT89S52,需要 USBASP 或类似下载器,操作方式完全不同。确认能烧录并运行后,lesson18 的代码才算真正进入调试环节。
3. I2C 时序与 AT24C02 读写:lesson18 的核心逻辑拆解
如果 lesson18 的内容如预期是 I2C 总线操作 AT24C02 EEPROM,那么代码里的核心不是"调用函数",而是"手工模拟时序"。STC89C52 没有硬件 I2C 外设,所有 SCL 和 SDA 的翻转都由 GPIO 延时实现,这恰好是理解总线协议的最佳方式。
3.1 起始信号与停止信号
// i2c.h #ifndef __I2C_H_ #define __I2C_H_ sbit SCL = P2^1; sbit SDA = P2^0; void I2C_Start(void); void I2C_Stop(void); void I2C_SendByte(unsigned char dat); unsigned char I2C_RecvByte(void); bit I2C_WaitAck(void); #endif// i2c.c #include "reg52.h" #include "i2c.h" void I2C_Delay(void) { unsigned char i = 10; while (--i); } void I2C_Start(void) { SDA = 1; SCL = 1; I2C_Delay(); SDA = 0; // SCL 高电平期间 SDA 由高到低 I2C_Delay(); SCL = 0; // 拉低 SCL,准备发送数据 } void I2C_Stop(void) { SDA = 0; SCL = 1; I2C_Delay(); SDA = 1; // SCL 高电平期间 SDA 由低到高 I2C_Delay(); }起始信号的定义是 SCL 为高时,SDA 产生一个下降沿。停止信号则相反,是 SCL 为高时 SDA 产生上升沿。初学最容易写反,顺序变成先拉 SDA 再拉 SCL,导致总线状态混乱。I2C_Delay 的值需要根据主频调整,11.0592MHz 下 10 个空循环大约产生 1~2 微秒的延时,满足标准模式 100kbps 的要求。如果主频是 12MHz,延时可能会稍不够,导致时序违例,此时把循环次数调到 20 即可。
3.2 发送字节与接收字节
void I2C_SendByte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { SCL = 0; // 拉低 SCL,允许 SDA 变化 if (dat & 0x80) // 先发送最高位 SDA = 1; else SDA = 0; dat <<= 1; I2C_Delay(); SCL = 1; // 拉高 SCL,从机采样数据 I2C_Delay(); } SCL = 0; }发送字节的关键是"先改 SDA 再拉高 SCL"。I2C 协议规定数据在 SCL 高电平期间必须保持稳定,所以 SDA 的修改必须在 SCL 低电平时期完成。很多人在循环里先拉高 SCL 再改 SDA,这在低速从机上可能碰巧能跑,但在时序严格或线路较长时会出现数据错位。接收字节的逻辑完全一致,只是方向相反:主机在 SCL 低电平期间释放 SDA(置 1),然后在 SCL 高电平期间读取引脚电平。
3.3 AT24C02 的页写与随机读
void AT24C02_WriteByte(unsigned char addr, unsigned char dat) { I2C_Start(); I2C_SendByte(0xA0); // 器件地址 + 写标志 I2C_WaitAck(); I2C_SendByte(addr); // 内部字节地址 I2C_WaitAck(); I2C_SendByte(dat); // 要写入的数据 I2C_WaitAck(); I2C_Stop(); // 等待内部写周期完成(约 5ms) } unsigned char AT24C02_ReadByte(unsigned char addr) { unsigned char dat; I2C_Start(); I2C_SendByte(0xA0); // 器件地址 + 写标志(先写地址) I2C_WaitAck(); I2C_SendByte(addr); // 指定内部地址 I2C_WaitAck(); I2C_Start(); // 重复起始信号 I2C_SendByte(0xA1); // 器件地址 + 读标志 I2C_WaitAck(); dat = I2C_RecvByte(); I2C_Stop(); return dat; }器件地址 0xA0 的构成是:高四位 1010 是固定值,A2/A1/A0 三位对应芯片引脚电平(AT24C02 的 1、2、3 脚),最后一位是读写方向。如果开发板上 A2A1A0 全部接地,地址就是 0xA0;如果其中某个引脚接高电平,地址需相应修改,这是硬件不匹配导致的常见故障点。
读操作比写操作多一个步骤:要先发送一个伪写命令把内部地址指针定位,再用重复起始信号切换为读模式。伪写命令会被从机 ACK,但不会产生真正的写操作,这是 I2C 协议中比较容易忽略的细节。写完数据后必须延时 5~10ms,因为 EEPROM 内部写周期期间不响应任何命令,如果此时连续写入会丢失数据。
4. 移植到新板子的三处必改项与串口联调
lesson18 的代码在教程配套开发板上能跑,换到自己画的板子或网购的裸板就可能白屏、乱码、读写失败。这不一定是代码问题,更可能是硬件差异。以下三处在移植时优先级最高。
4.1 GPIO 引脚映射和外部上拉电阻
// 原代码定义 sbit SCL = P2^1; sbit SDA = P2^0; // 你的板子可能是 sbit SCL = P3^6; sbit SDA = P3^7;I2C 的 SCL 和 SDA 都是开漏结构,必须外接 4.7kΩ 上拉电阻到 VCC。STC89C52 的 P2 口内部有较弱的准双向上拉,直接驱动 I2C 可能勉强工作,但换到 P3 口时,如果板子上没有外部上拉,总线上高电平驱动能力不足,时序波形会严重畸变。常见做法是买 4.7kΩ 排阻,VCC 端接 5V,另一端分别接 SCL 和 SDA。调试时如果示波器看到 SCL 上升沿变成斜坡,先查上拉电阻,不要怀疑代码。
4.2 主频不同导致的延时修正
void I2C_Delay(void) { // 11.0592MHz 时循环 10 次 // 12MHz 时循环 12 次 // 24MHz 时循环 22 次 // 下面按 12MHz 配置 unsigned char i = 12; while (--i); }延时函数直接决定 SCL 高电平保持时间。标准模式 I2C 要求最小高电平时间 4μs,如果主频翻倍而延时不变,SCL 频率可能突破 400kHz,进入快速模式范围。虽然 AT24C02 支持 400kHz,但总线上如果挂了其他慢速器件(比如温湿度传感器),就会因为速率不匹配导致通信失败。稳妥的做法是留足裕量,SCL 实际频率控制在 80~100kHz 左右。
4.3 串口打印验证读写结果
// uart.c void UART_Init(void) { TMOD = 0x20; // 定时器1工作在模式2(8位自动重载) TH1 = 0xFD; // 波特率 9600 @ 11.0592MHz TL1 = 0xFD; TR1 = 1; SCON = 0x50; // 模式1,允许接收 ES = 1; EA = 1; } void UART_SendByte(unsigned char dat) { SBUF = dat; while (!TI); TI = 0; }联调时常见做法是在写读操作之间加上串口输出,把读回来的数据打印到 PC 端串口助手。如果收到 0xFF,说明芯片没有应答,大概率是器件地址错误或上拉电阻缺失;如果收到随机值且每次都不一样,大概率是时序不稳,需要调大延时;如果收到正确值,说明 I2C 通信链路已经打通。注意单片机晶振是 11.0592MHz 时,波特率选择 9600 或 115200 都可以精确分频;如果是 12MHz,波特率误差较大,建议选 2400 或 4800 以保证通信稳定。
5. 用逻辑分析仪抓时序:验证代码是否真正合规
很多人在 Keil 的调试器里单步看变量,觉得状态都对,但一上真机就出问题。原因很简单:软件调试器的sbit操作模拟不了真实 GPIO 的翻转延迟,也看不到外部上拉电阻和总线电容带来的波形畸变。验证 I2C 时序最直接的手段是逻辑分析仪。
5.1 抓取写操作完整时序
配备一个 8 通道、采样率 24MHz 以上的逻辑分析仪(几十元的 USB 逻辑分析仪即可),把通道 0 接 SCL,通道 1 接 SDA,地线共用。设置触发条件为 SDA 下降沿,抓取一段完整的写操作,然后对照协议检查:
| 检查项 | 合格标准 | 常见偏差 |
|---|---|---|
| 起始信号 | SCL 高电平时 SDA 下降沿 | 示波器上看到 SDA 先变低,SCL 后变低 |
| 地址字节 | 0xA0,最高位在前 | 字节顺序颠倒,低位在前 |
| 数据位建立时间 | SCL 上升沿前 ≥ 250ns | 距离太近,从机采样错误 |
| 停止信号 | SCL 高电平时 SDA 上升沿 | SCL 提前拉低,停止信号丢失 |
| 应答位 | 第 9 个时钟 SDA 为低 | 没有应答,地址错误或芯片损坏 |
提示:逻辑分析仪解码出来的 I2C 包如果显示 NACK,先用万用表量 SDA 引脚的静态电平。SDA 悬空且上拉正常时应该是高电平,如果量到低电平,要么是器件地址不对导致从机不应答,要么是 SDA 引脚被占用拉死。
5.2 连续读写压力测试
// stress_test.c unsigned char code test_data[8] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; unsigned char buf[8]; unsigned char i; bit error_flag = 0; for (i = 0; i < 8; i++) { AT24C02_WriteByte(i, test_data[i]); DelayMs(10); // 等待内部写周期 } for (i = 0; i < 8; i++) { buf[i] = AT24C02_ReadByte(i); if (buf[i] != test_data[i]) { error_flag = 1; break; } } if (error_flag) UART_SendString("Error\r\n"); else UART_SendString("Pass\r\n");压力测试要循环执行 100 次以上,每次都重新写入并读回校验。仅跑一次通过不能说明问题,可能是时序紧张但碰巧成功。如果随机出现单个字节读回错误,优先怀疑的是写周期等待时间不足。AT24C02 的写周期典型值是 2ms,最大 5ms,代码里的延时建议设置在 10ms 以上。另外注意不要对同一地址连续写超过 100 万次,EEPROM 的擦写寿命有限,虽然测试不至于磨穿,但工程上要规划好磨损均衡策略。
5.3 移植到 C++ 工程的注意事项
如果项目是用 C++ 编写(比如用 SDCC 配合新的 51 编译器),类成员函数和 C 函数的编译规则不同。I2C 操作建议封装成独立类,GPIO 的sbit定义放在头文件的extern "C"块外,中断服务函数必须加interrupt关键字,且不能用 C++ 的try-catch处理外设异常——中断里只做状态标记,具体处理放到主循环。另外注意编译器对局部变量分配的位置,C++ 代码更容易产生较大的栈使用量,51 单片机的 128 字节 data 段可能编译不过,必要时在工程设置里把栈指针移到xdata区域。
本文还有配套的精品资源,点击获取