手把手拆解一个完整的嵌入式小项目,代码、电路、原理全都有
很多初学者学完51单片机的语法和基础外设后,都会面临一个尴尬的问题:单独点亮一个LED、单独驱动一个传感器都会,但怎么把这些东西组合成一个"有点意思"的小系统?
今天这篇文章,我就带你完整拆解一个智能家居原型项目 ------它不复杂,但涵盖了嵌入式开发中最核心的几块内容:传感器数据采集、PWM电机调速、蓝牙串口遥控、OLED屏幕显示、光敏自动控制。我会把每一部分的设计思路、代码细节、硬件连接全部讲清楚。
先看系统框图,对整个项目有个整体印象:

一、这个项目能做什么?
先给你一个直观感受,这套系统实现了以下功能:
| 功能模块 | 实现方式 | 实际用途 |
|---|---|---|
| 温湿度检测 | DHT11传感器 | 采集环境温湿度,显示在OLED和串口 |
| 自动补光 | 光敏传感器 + LED | 环境变暗时自动开灯 |
| 风扇调速 | PWM控制电机 | 通过蓝牙或串口无级调速 |
| 灯光控制 | 蓝牙串口指令 | 手机APP远程开关灯 |
| 数据监控 | OLED屏幕显示 | 本地实时查看温湿度、风扇转速 |
| 数据上传 | 串口发送 | 上传到PC或手机APP |
一句话概括:采集环境数据 → 本地显示 + 逻辑控制 → 支持远程干预,这就是一个完整物联网节点的雏形。
二、硬件清单与引脚分配
2.1 元器件清单
| 器件 | 型号/规格 | 数量 | 作用 |
|---|---|---|---|
| 单片机 | STC89C52RC | 1 | 主控 |
| 温湿度传感器 | DHT11 | 1 | 采集温湿度 |
| OLED屏幕 | SSD1306 (I²C接口) | 1 | 本地显示 |
| 蓝牙模块 | HC-05/HC-06 | 1 | 无线串口通信 |
| 光敏传感器 | 光敏电阻模块(数字输出) | 1 | 光线检测 |
| LED灯 | 普通LED + 限流电阻 | 2 | 照明指示 |
| 直流电机 | 小功率5V直流电机 | 1 | 模拟风扇 |
| 电机驱动 | L298N或L9110S | 1 | 驱动电机 |
| 晶振 | 11.0592MHz | 1 | 串口通信精准波特率 |
2.2 引脚连接表
对照代码的main.h,引脚定义如下:
| 外设 | 引脚 | 说明 |
|---|---|---|
| LED2(灯光) | P1.1 | 蓝牙控制灯光(低电平点亮) |
| LED4(光控灯) | P1.3 | 光敏传感器自动控制 |
| PWM风扇 | P1.0 | 同时作为PWM输出引脚 |
| DHT11 | P1.2 | 单总线数据线 |
| OLED_SCL | P1.5 | I²C时钟线 |
| OLED_SDA | P1.4 | I²C数据线 |
| 光敏传感器 | P3.7 | 数字信号输入(0=有光/1=无光) |
| 串口TX | P3.1 | 蓝牙/串口发送 |
| 串口RX | P3.0 | 蓝牙/串口接收 |
三、软件架构分层解读
整个工程的代码结构很清晰,分为以下几层:
main.c/h → 主逻辑、状态管理、中断服务
├── uart.c/h → 串口初始化、收发(蓝牙通信)
├── timer.c/h → 定时器初始化(PWM时基)
├── dht11.c/h → DHT11驱动(时序读取)
├── oled.c/h → OLED屏幕驱动(I²C指令/数据)
├── iic.c/h → I²C底层协议(起始、停止、收发)
├── delay.c/h → 延时函数(微秒/毫秒级)
├── key.c/h → 按键扫描(预留)
└── main.h → 全局引脚定义、类型别名
这个分层的好处是:每一层只做一件事,上层调用下层接口,修改底层驱动不影响上层逻辑。
四、逐个模块深度解析
4.1 串口与蓝牙通信(uart.c)
串口是这套系统的"神经"------它既要接收蓝牙指令,又要发送温湿度数据。
波特率计算
代码中使用了定时器1工作模式2(8位自动重装),波特率9600:
cpp
TH1 = 0xFD;
TL1 = 0xFD; // 11.0592MHz下,9600波特率
为什么是0xFD?这是计算出来的初值:
cpp
定时器溢出速率 = 晶振频率 / (12 × (256 - TH1))
波特率 = 定时器溢出速率 / 32(模式1)
9600 = 11059200 / (12 × (256 - TH1) × 32)
→ TH1 = 256 - 11059200/(9600×12×32) ≈ 256 - 3 = 253 = 0xFD
所以用11.0592MHz晶振,就是为了串口通信时波特率误差极小。
串口中断处理
cpp
void UART_Routine(void) interrupt 4
{
if(RI) {
RI = 0;
recv = SBUF;
switch(recv) {
case 0x01: LED2 = 0; break; // 开灯
case 0x02: LED2 = 1; break; // 关灯
case 0x03: level++; break; // 加速
case 0x04: level--; break; // 减速
}
}
}
这里定义了一个简单的二进制通信协议:
-
0x01→ 开灯 -
0x02→ 关灯 -
0x03→ 风扇加速(占空比+1) -
0x04→ 风扇减速(占空比-1)
补充知识 :实际产品中不会用这种单字节协议(易出错、无校验),通常会加上帧头帧尾和校验和,比如 AA 01 01 02 这种格式。但作为学习项目,简洁优先。
4.2 PWM风扇调速(timer.c)
51单片机没有硬件PWM,所以用定时器中断配合软件计数实现。
原理
定时器0每10ms触发一次中断,中断服务函数里维护两个计数器:
cpp
void Timer0_Routine(void) interrupt 1
{
TH0 = 0xDC; TL0 = 0x00; // 10ms重装值
pwm_count++;
pwm_count = pwm_count % 10; // 周期 = 10 × 10ms = 100ms
if(pwm_count < level) {
PWM_IO = 1; // 高电平
} else {
PWM_IO = 0; // 低电平
}
// 每300个中断 = 3000ms = 3秒读取一次DHT11
tim_count++;
tim_count = tim_count % 300;
if(0 == tim_count) read_dht11_flag = 1;
}
工作机制:
| level值 | 高电平占比 | 风扇转速 |
|---|---|---|
| 0 | 0/10 = 0% | 停转 |
| 3 | 3/10 = 30% | 低速 |
| 5 | 5/10 = 50% | 中速 |
| 8 | 8/10 = 80% | 高速 |
| 10 | 10/10 = 100% | 全速 |
pwm_count从0到9循环,level决定了在一个周期内高电平持续多少次中断。当level=10时,10个时间片全为高电平,相当于直通全速。
为什么周期选100ms? 因为人耳对100ms以上的频率不敏感,电机不会发出可听见的嗡嗡声。实际电机调速通常用1kHz~20kHz,但51单片机用定时器做高频率PWM会占用大量CPU资源,100ms是在资源有限下的折中方案。
4.3 DHT11温湿度读取(dht11.c)
DHT11是单总线传感器,通信时序非常讲究。一次完整读取流程如下:
cpp
主机拉低 ≥ 18ms → 拉高 20~40us → DHT11拉低 80us → 拉高 80us → 开始传数据
每个数据位是这样区分的:
-
位"0":DHT11拉低 50us → 拉高 26~28us(高电平持续时间短)
-
位"1":DHT11拉低 50us → 拉高 70us(高电平持续时间长)
代码用Delay30us()后读取引脚电平来区分0和1:
cpp
Delay30us();
if(1 == DHT11_IO) {
data_bit = 1; // 30us后还是高电平 → 位"1"
while(DHT11_IO && count < 80) count++;
} else {
data_bit = 0; // 30us后已变低 → 位"0"
}
DHT11返回的数据格式是40位(5字节):
| 字节0 | 字节1 | 字节2 | 字节3 | 字节4 |
|---|---|---|---|---|
| 湿度整数 | 湿度小数 | 温度整数 | 温度小数 | 校验和 |
校验公式:字节0 + 字节1 + 字节2 + 字节3 == 字节4
代码中只取了整数部分(humi = dht_buf[0],temp = dht_buf[2]),舍弃了小数------因为DHT11的湿度精度也就±5%,小数没实际意义。
4.4 OLED显示(oled.c + iic.c)
OLED用的是SSD1306驱动芯片 ,7位I²C地址是0x78(含读写位是0x78写/0x79读)。
I²C通信的关键是起始条件、停止条件、应答位:
cpp
void IIC_Start(void) {
IIC_SCL = 1;
IIC_SDA = 1;
Delay5us();
IIC_SDA = 0; // SDA在SCL高电平时拉低 → 起始
Delay5us();
IIC_SCL = 0;
}
void IIC_Stop(void) {
IIC_SCL = 1;
IIC_SDA = 0;
Delay5us();
IIC_SDA = 1; // SDA在SCL高电平时拉高 → 停止
}
向OLED写数据分两步:
-
写命令 (控制指令,如设置光标位置):
IIC_Send_Byte(0x00) -
写数据 (像素内容):
IIC_Send_Byte(0x40)
代码中的OLED_WR_Byte(dat, cmd)封装了这一过程:
cpp
void OLED_WR_Byte(unsigned dat, unsigned cmd) {
IIC_Start();
IIC_Send_Byte(0x78); // 设备地址 + 写
IIC_Recv_Ack();
if(cmd) {
IIC_Send_Byte(0x40); // 数据命令
} else {
IIC_Send_Byte(0x00); // 控制命令
}
IIC_Recv_Ack();
IIC_Send_Byte(dat);
IIC_Recv_Ack();
IIC_Stop();
}
显示原理 :OLED内部有8个页(Page0~Page7),每页128列,每列对应一个字节(8个像素)。要显示一个字符,就是把对应的点阵数据(F8X16或F6x8字库)逐字节写入对应位置。
!OLED显示原理示意
比如显示"Fan Speed:",就是循环调用OLED_ShowChar(),每个字符是16×8像素,x坐标每写一个字符+8。
4.5 光敏传感器控制(main.c)
光敏传感器模块通常输出TTL电平:
-
光线足够 → 输出低电平(0)
-
光线不足 → 输出高电平(1)
代码逻辑极其简单:
cpp
if(SUN == 0) { // 有光
LED4 = 1; // 关灯
} else { // 暗
LED4 = 0; // 开灯(低电平点亮)
}
注意代码中LED4是低电平点亮(
sbit LED4 = P1^3),所以条件判断是反的:SUN == 0时关灯,SUN == 1时开灯。
五、主程序逻辑流程图
整个main()的执行逻辑:
cpp
系统初始化(定时器、串口、OLED)
↓
显示标题 "智能家居系统"
↓
┌──────────────────────────────────┐
│ 循环执行: │
│ ├── 每3秒读一次DHT11 │
│ │ ├── 成功 → OLED显示 + 串口发送 │
│ │ └── 失败 → 忽略 │
│ ├── 光敏检测 → 控制LED4 │
│ └── OLED刷新显示风扇速度 │
└──────────────────────────────────┘
↑ │
└─────────────── 中断驱动 ────┘
定时器中断:PWM输出 + DHT11触发标志
串口中断:蓝牙指令响应
状态标志位 read_dht11_flag起到了关键作用------DHT11读取需要时间,不能在主循环里阻塞,所以用定时器每3秒置位一次标志,主循环检测到标志才去读取,实现了非阻塞的定时采集。
六、代码中隐藏的小细节
6.1 数据类型重定义
cpp
typedef unsigned char uchar;
typedef unsigned int uint;
后面所有的代码都用uchar和uint,比直接写unsigned char更简洁。这是51开发中的常见习惯。
6.2 延时函数的精度
cpp
void Delay30us(void) {
unsigned char data i;
i = 11;
while (--i);
}
注意这里用了data关键字,把变量放在片内RAM 中,访问速度更快,延时更准确。如果不加data,编译器可能把它分配到xdata区,访问变慢导致延时不准。
Delay5us()是空函数,因为5us太短,用空操作_nop_()积累,I²C时序是靠Delay5us()配合IIC_SCL/IIC_SDA电平切换来实现的。
6.3 中断服务函数的注意事项
cpp
void Timer0_Routine(void) interrupt 1
{
TH0 = 0XDC; TL0 = 0X00;
// ...
}
定时器0工作在模式1(16位),每次溢出都需要手动重装初值。如果用模式2(8位自动重装)就不用手动重装,但最大计数值只有256,不够10ms。
6.4 sprintf的使用风险
cpp
sprintf(buf, "H:%u%% T:%uC", (uint)humity, (uint)temperature);
sprintf是标准库函数,在51单片机上会占用大量程序空间(ROM)。如果ROM不够用,建议改成手动拼接字符串。不过STC89C52有8KB Flash,这点代码还好。
七、扩展与改进方向
这个项目还有很大的扩展空间,你可以试试:
| 改进方向 | 实现思路 |
|---|---|
| 增加Wi-Fi模块 | 用ESP8266取代蓝牙,接入阿里云/OneNET |
| 增加手机APP | 用MIT App Inventor或微信小程序做界面 |
| 数据存储 | 外接AT24C02 EEPROM,存储历史数据 |
| 增加按键控制 | 代码里已有key.c,可接入按键手动控制 |
| 增加报警功能 | 温度超限时蜂鸣器报警(P1.6已有BEEP定义) |
| 改进协议 | 定义帧格式:AA 命令 数据 校验和 BB |
八、常见问题排查
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| OLED不显示 | I²C地址不对/连接错误 | 检查0x78地址,确认SDA/SCL接线 |
| DHT11读失败 | 时序不对/上拉电阻 | 加4.7kΩ上拉电阻,检查Delay30us精度 |
| 蓝牙收不到数据 | 波特率不匹配 | HC-05默认9600,确保和代码一致 |
| 风扇不转 | 驱动模块供电不足 | 电机要用外部5V供电,不能从单片机取电 |
| 串口乱码 | 晶振频率不匹配 | 确认用的是11.0592MHz,不是12MHz |
九、总结
这个项目麻雀虽小五脏俱全,涵盖了:
-
GPIO控制(LED、电机)
-
定时器中断(PWM、定时任务)
-
串口中断(蓝牙通信)
-
单总线协议(DHT11)
-
I²C协议(OLED)
-
模数混合控制(光敏传感器)
对于51单片机学习者来说,这是一个非常好的综合练习项目。当你把这些模块全部调通的那一刻,你对嵌入式系统的理解会上一个台阶。
如果你在实操中遇到问题,欢迎在评论区留言,我会尽力解答。
项目代码已在文中完整给出,需要工程文件的可以直接复制整理。硬件连接以文中表格为准。祝你调试顺利!