前言
大概属于心血来潮,相比起撸代码写工具,手搓实体的工具真的很酷。所以突发奇想开始学单片机开发。实际上也是看见了lmarzen/esp32-weather-epd这个优美且实用的开源天气预报牌项目,激发起了单片机开发的兴趣。
那么,就从单片机的hello world,点亮电容LED开始吧!
至于为什么是esp32,因为上面那个项目用的就是esp32(其实也是简单,对于我这种硬件门外汉来说比较好上手)…
以下所有内容都使用仿真平台Wokwi完成
点亮LED灯珠
点亮led灯珠非常简单,阳极给高电平,阴极接地(低电平)形成回路即可。
阳极连接goip0,阴极连接GND(接地,0电压),形成回路,然后给gpio0一个高电压即可点亮。

下为Arduino代码,非常的简单,将对应引脚设定为输出,然后给一个高电平即可。
const int GPIO_LED_1 = 0;
void setup() {
pinMode(GPIO_LED_1, OUTPUT);
digitalWrite(GPIO_LED_1, HIGH);
}
void loop() {
}
[!INFO]-
- 这里在仿真环境中省略了电阻,真实的电路中,应当在给予一个合适的电阻,ESP32的电压是3.3V,可能会由于电流过大而烧坏led灯珠。
- 这里pinMode用于设定引脚的工作模式。上述代码中将其设定为了输出模式,可以主动控制输出高低电平。除此之外还有INPUT和INPUT_PULLUP等模式。INPUT_PULLUP则是默认高电平,接地时变为低电平,INPUT则不确定高低电平,需要外部的上下拉电阻确定。不同引脚模式间的区别实际上就在于电平的控制方式和默认状态。
- digitalWrite控制了电平,这里给定了一个高电平,使引脚除有了一个3.3v的电压,使led灯两侧产生电势差,可以点亮。
- 所以综上所述,只用
pinMode(GPIO_LED_1, OUTPUT);同样可以点亮led灯珠
LED灯珠交替闪烁
想让一个或多个led灯珠交替闪烁,只需要循环给予灯珠所在的引脚高低电平即可。高电平点亮,低电平不亮。
这里添加了一个阳极连接在4号引脚,阴极同样接地的绿色灯珠。

这里由于用到了两颗灯珠,所以需要设定两个引脚的状态。然后把循环执行的逻辑放在loop内,循环执行,先后给两颗灯珠的阳极设定高低电平交替,设定一定的延时,即可实现交替闪烁的状态。
const int GPIO_LED_1 = 0;
const int GPIO_LED_2 = 4;
void setup() {
pinMode(GPIO_LED_1, OUTPUT);
pinMode(GPIO_LED_2, OUTPUT);}
void loop() {
// 点亮红色,熄灭绿色
digitalWrite(GPIO_LED_1, HIGH);
digitalWrite(GPIO_LED_2, LOW);
// 延迟200毫秒
delay(200);
// 点亮绿色,熄灭红色
digitalWrite(GPIO_LED_1, LOW);
digitalWrite(GPIO_LED_2, HIGH);
// 延迟200毫秒
delay(200);
}
效果如下

LED流水灯
既然可以两颗灯珠交替闪烁,就同理按照类似的逻辑实现多个led的流水灯,只需要多连接几颗灯珠即可。
这里添加了十颗灯珠,分别连接了0,4,16,17,5,18,19,21,22,23号引脚,按照上面相同的方法分别交替设定高低电平即可。代码如下
// 定义LED引脚
const int LED_COUNT = 10;
const int LED_PINS[LED_COUNT] = {0, 4, 16, 17, 5, 18, 19, 21, 22, 23};
const int DELAY_MS = 100; // 流水灯间隔
void setup() {
// 初始化所有LED为输出模式
for(int i = 0; i < LED_COUNT; i++) {
pinMode(LED_PINS[i], OUTPUT);
}
}
void loop() {
// 从左到右流动
for(int i = 0; i < LED_COUNT; i++) {
digitalWrite(LED_PINS[i], HIGH);
delay(DELAY_MS);
digitalWrite(LED_PINS[i], LOW);
}
// 从右到左流动,反向遍引脚
for(int i = LED_COUNT-1; i >= 0; i--) {
digitalWrite(LED_PINS[i], HIGH);
delay(DELAY_MS);
digitalWrite(LED_PINS[i], LOW);
}
}
效果如下

更多LED的灯珠的流水灯
上述的代码可以很好的实现一个流水灯,但是每一个灯珠占用了一个gpio引脚,如果想要添加更多的灯珠,灯珠数量大于引脚,就无法实现了,所以这里引入一个IC来实现控制更多的灯珠:74HC595。
74HC595芯片简介
74HC595是一个非常实用的集成电路芯片,它最大的特点就是可以用少量的输入控制多个输出。想象一下,如果你有一个8位的二进制数字(比如10101010),74HC595可以把这个数字的每一位都转换成一个独立的输出端口,输出高电平(1)或低电平(0)。
这个芯片主要有三个重要的工作特性:
- 串行输入,并行输出 – 可以一位一位地输入数据(像队列一样),但可以同时输出8位
- 多片级联 – 可以把多个芯片串起来,比如用3个芯片就能控制24个LED
- 带锁存功能 – 可以先准备好所有数据,然后一次性输出,避免中间状态的显示
关键引脚及其功能(就像芯片的"接口"):
- DS (串行数据输入) – 用来输入0或1的数据,就像往瓶子里一滴一滴加水
- SHCP (移位寄存器时钟) – 每次变化会让数据往里移动一位,就像把瓶子里的水往前推
- STCP (存储寄存器时钟) – 决定什么时候把数据显示出来,像是打开瓶盖让水流出来
所以将这三个引脚连接esp32的三个gpio引脚即可,除此之外,还需要将VCC引脚连接供电,GND引脚接地,如下图

数据传输原理
数据传输过程可以类比一个8格的"小火车":
- DS引脚就像装货的入口,每次只能放入一个0或1
- SHCP就像火车的前进信号,每来一次信号,火车前进一格
- STCP就像发车信号,当8个数据都装好后,一次性发出去
以下代码展示了如何向芯片发送一个字节的数据:
void shiftOut(uint8_t data) {
// 从最高位(MSB)开始发送,就像从火车头开始装货
for(int i = 7; i >= 0; i--) {
digitalWrite(clockPin, LOW); // 准备接收新数据
// (data >> i) & 0x01 的含义:
// data >> i:把数据右移i位,把要处理的位放到最右边
// & 0x01:只保留最后一位,其他位都变成0
uint8_t bitValue = (data >> i) & 0x01;
digitalWrite(dataPin, bitValue); // 放入数据
digitalWrite(clockPin, HIGH); // 上升沿,确认数据
// 这里的"上升沿"指的是信号从低电平(0)变到高电平(1)的过程
}
}
当我们需要控制多个595芯片时,就需要一次发送多个字节:
void shiftMultiBytes(uint8_t* data, int numBytes) {
digitalWrite(latchPin, LOW); // 关闭输出,准备接收新数据
// 从最后一片开始发送,因为数据要一直往后传
for(int i = numBytes-1; i >= 0; i--) {
shiftOut(data[i]); // 发送每个字节
}
digitalWrite(latchPin, HIGH); // 打开输出,数据会同时显示
}
完整逻辑代码
// 定义595串联使用的引脚
const int dataPin = 2; // DS(数据输入)
const int latchPin = 0; // STCP(存储寄存器时钟)
const int clockPin = 4; // SHCP(移位寄存器时钟)
const int NUM_REGISTERS = 6; // 595芯片数量
const int NUM_LEDS = NUM_REGISTERS * 8; // 总LED数(每片595控制8个LED)
const int DELAY_MS = 100; // 流水灯移动间隔
void setup() {
// 设置595控制引脚为输出模式
pinMode(dataPin, OUTPUT); // 数据线
pinMode(clockPin, OUTPUT); // 时钟线
pinMode(latchPin, OUTPUT); // 锁存线
}
// 向595发送一个字节数据
void shiftOut(uint8_t data) {
// 从最高位(MSB)开始发送
for(int i = 7; i >= 0; i--) {
digitalWrite(clockPin, LOW); // 时钟低电平
// 获取当前位的值并打印
uint8_t bitValue = (data >> i) & 0x01;
Serial.print(bitValue);
digitalWrite(dataPin, bitValue); // 设置数据位
digitalWrite(clockPin, HIGH); // 时钟上升沿,数据被移入
}
Serial.println(); // 换行
}
// 向多片级联的595发送数据
void shiftMultiBytes(uint8_t* data, int numBytes) {
digitalWrite(latchPin, LOW); // 准备数据传输
// 从最后一片595开始发送
for(int i = numBytes-1; i >= 0; i--) {
shiftOut(data[i]);
}
digitalWrite(latchPin, HIGH); // 数据锁存输出
}
void loop() {
uint8_t ledData[NUM_REGISTERS] = {0}; // 存储每个595的LED状态
// 正向流动(从第一个LED到最后一个)
for(int i = 0; i < NUM_LEDS; i++) {
memset(ledData, 0, NUM_REGISTERS); // 清空所有LED状态
ledData[i / 8] = (1 << (i % 8)); // 设置当前LED位,通过位移
shiftMultiBytes(ledData, NUM_REGISTERS);
delay(DELAY_MS);
}
// 反向流动(从最后一个LED到第一个)
for(int i = NUM_LEDS-1; i >= 0; i--) {
memset(ledData, 0, NUM_REGISTERS);
ledData[i / 8] = (1 << (i % 8));
shiftMultiBytes(ledData, NUM_REGISTERS);
delay(DELAY_MS);
}
}
工作逻辑详解
让我们通过一个更具体的例子来理解595是如何工作的:
// 假设我们要点亮3个595芯片控制的第17个LED
// 17 = 8 + 8 + 1,所以这个LED在第三片595上
// 1. 确定LED位置
int ledIndex = 17;
int chipIndex = ledIndex / 8; // = 2,表示第三片芯片
int pinIndex = ledIndex % 8; // = 1,表示该芯片的第2个输出
// 2. 设置数据
uint8_t ledData[3] = {0, 0, 0}; // 3片595,初始全置0
ledData[chipIndex] = (1 << pinIndex); // 只将目标位置1
// 3. 发送数据
shiftMultiBytes(ledData, 3);
实际上Arduino是有shiftOut函数封装的,但是这里为了简单起见,用基础逻辑自行实现了。
对于大量的灯珠,主要方案就是使用74HC595来扩展gpio。其可以对一个输入并行输出,也就是说对一个8位的输入分别输出到其8个引脚上,并且ic之间可以串联使用,间接实现了通过三个引脚来扩展出无限个引脚。
让我们通过一个具体的例子来理解状态控制:
// 示例:点亮第一个LED
uint8_t ledData[NUM_REGISTERS] = {0}; // 全部初始化为0
ledData[0] = 0b00000001; // 第一个595的第一个输出置1
shiftMultiBytes(ledData, NUM_REGISTERS);
// 示例:点亮第九个LED(第二个595的第一个输出)
ledData[1] = 0b00000001; // 第二个595的第一个输出置1
shiftMultiBytes(ledData, NUM_REGISTERS);
上述代码中,为了方便595芯片的扩展,引入了一个总595芯片数量的数组,每个元素存放一个8位的数值用于控制每个595芯片扩展出的8个(Q0-Q7)引脚,所以总灯珠数量就是595芯片数*8。
数据的计算和发送过程如下:
// 计算当前LED属于哪个595芯片
int chipIndex = ledIndex / 8;
// 计算当前LED在芯片上的位置
int pinIndex = ledIndex % 8;
// 设置对应位
ledData[chipIndex] = (1 << pinIndex);
上文中提到,595芯片可以接收一个8位的输入,分别输出到8个引脚,所以控制led灯珠的一次亮起,只需要按顺序向595芯片发送灯珠状态数组000…,100…,010…,001…,0001…,以此类推,595芯片就是分别基于对应引脚为高电平,即可控制led灯珠的依次亮灭。
上面代码中,同样为了方便扩展,595芯片数量并没有直接写死在代码逻辑中,而是通过预先的常量设定,通过当前灯珠的索引/8来计算出当前的灯珠属于哪个595芯片,即应该修改状态数组中的哪一个数值,通过索引%8来确定偏移位数,即哪个灯珠。最后将数据一次发给五个595芯片,实现对所有灯珠的控制。
虽然理论上讲,这种方式可以扩展出无限个灯珠,但是实际上还需要考虑电源和功耗等因素。
最终效果如下

动态调速LED流水灯
在此技术上使用两个按钮进行动态调速,整体逻辑就是添加两个按钮,一个加速,一个减速,用于动态的调节当前的点亮延时,通过一个段码屏显示当前的延时。
这里段码屏使用TM1637,添加其对应的库,这里使用TM1637Display点亮他。VCC和GND分别连接3V3和GND,CLK和DIO连接在两个GPIO引脚上即可使用
按钮选择常规的按钮即可,一端接地,一端接gpip引脚,但是这里的引脚可以选择INPUT_PULLUP,默认为高电平,接地导通后变为低电平,所以对应引脚变为了低电平则说明对应按钮按下。
除此之外也可以选择INPUT模式,这时就需要自行添加额外的上拉或者下拉电路。以上拉为例, 按钮在一段连接gpio,一段接地的基础上,将3V3连接到gpio,同时连接一个高额电阻。示意图如下。
未按下时:
电流: 3.3V → 10kΩ电阻 → GPIO
GPIO读取: HIGH(3.3V)
按下时
电流: GPIO → 按钮 → GND(由于另一端电阻较高,电流会去电阻低的)
GPIO读取: LOW(0V)
3.3V ----[10kΩ]----+
|
+---- GPIO (INPUT)
|
[按钮]|
|
GND
这里就以INPUT_PULLUP模式为例,接线如下

为了实现实时更新速度,这里有两点需要注意
1. 不能再使用阻塞式的delay来控制时间
因为delay会阻塞整个代码逻辑,所以也会影响按钮对于延时的更新,在设定为延时很长之后会导致延时更新周期较长(因为在阻塞呢),体现在硬件上就是速度变更不灵敏,这里要更换一种实现方式。
这里改为手动计算执行时间,即在循环体中延时处理的逻辑每次首先执行,后面通过比较代码执行的时间和目前的最新延时设定决定需不需要更新流水灯状态,即
void loop() {
static unsigned long previousMillis = 0; // 上次更新时间
static int currentLed = 0; // 当前LED位置
static bool direction = true; // true为正向,false为反向
uint8_t ledData[NUM_REGISTERS] = {0}; // LED状态数组
// 处理按钮输入以及延时变更
handleButtons();
// 检查是否到达更新时间
unsigned long currentMillis = millis(); // 当前时间
if (currentMillis - previousMillis >= DELAY_MS) {
previousMillis = currentMillis;
// 更新led状态相关....
}
}
2. 需要考虑消抖
按钮的消抖,即消除由于按钮被按下时产生的机械抖动对按钮的结果判定造成的影响。由于按钮被按下可能会产生机械性抖动,如果不做处理,按钮可能会被识别为多次按下。即
电压
^
3.3V| /\/\/\____
|___/
+---------------> 时间
按下瞬间出现多次跳变
这时的解决方案一般分为软件解决和硬件解决。
软件解决逻辑较为简单粗暴,即设定一个延时,等待抖动结束再确认按钮状态即可。即
void handleButtons() {
static unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = DEBOUNCEDELAY; // 消抖延时
if ((millis() - lastDebounceTime) > debounceDelay) {
if (digitalRead(speedUpPin) == LOW) { // 读取加速针脚状态
// 加速按钮按下...
}
if (digitalRead(speedDownPin) == LOW) { // 读取减速按钮状态
// 减速按钮按下...
}
}
}
硬件消抖有多种解决方案,常见的方法有通过RC滤波或通过施密特触发器
RC滤波即通过在电路中添加一个电容,通过对电容的充电来消除短时间内的信号抖动,同时这样的思想还被用于电路延时。示意图如下
VCC(3.3V) ----[10kΩ]----+
|
+----[0.1µF]----GND
|
+---- GPIO
|
[按钮]|
|
GND
或者是双RC滤波,一定程度上可以起到更好的效果
VCC ----[R1]----+----[R2]----+----OUTPUT
| |
[C1] [C2]
| |
GND GND
而施尼特触发器则可以只有当电压超过阈值的时候,才改变状态,抖动引起的较小电压变化并不会让施密特触发器改变状态,从而实现了消抖。示例如下
VCC(3.3V) ----[10kΩ]----+
|
[按钮]|
+---- [74HC14] ---- GPIO
| Pin1 Pin2
[0.1µF]
|
GND
这里为了简单起见,使用软件消抖,最终完整的逻辑代码如下
#include <TM1637Display.h>
// 定义595串联使用的引脚
const int dataPin = 2; // DS(数据输入)
const int latchPin = 0; // STCP(存储寄存器时钟)
const int clockPin = 4; // SHCP(移位寄存器时钟)
// 定义数码管显示器引脚
const int displayCLK = 18; // 数码管时钟引脚
const int displayDIO = 5; // 数码管数据引脚
// 定义按钮引脚
const int speedUpPin = 16; // 加速按钮
const int speedDownPin = 17; // 减速按钮
const int NUM_REGISTERS = 6; // 595芯片数量
const int NUM_LEDS = NUM_REGISTERS * 8; // 总LED数
int DELAY_MS = 50; // 流水灯移动间隔(现在可调)
const int MIN_DELAY = 0; // 最小延迟
const int MAX_DELAY = 9999; // 最大延迟
const int STEP_ON = 10; // 调节步长
const int DEBOUNCEDELAY = 50; // 消抖延时,放置按钮在按压后产生的机械抖动而被错误识别多次按压
// 创建数码管显示对象
TM1637Display display(displayCLK, displayDIO);
void setup() {
// 设置595控制引脚
pinMode(dataPin, OUTPUT);
pinMode(clockPin, OUTPUT);
pinMode(latchPin, OUTPUT);
// 设置按钮输入
pinMode(speedUpPin, INPUT_PULLUP);
pinMode(speedDownPin, INPUT_PULLUP);
// 初始化数码管
display.setBrightness(0x0f);
display.showNumberDec(DELAY_MS);
}
// 处理按钮输入
void handleButtons() {
static unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = DEBOUNCEDELAY; // 消抖延时
if ((millis() - lastDebounceTime) > debounceDelay) {
if (digitalRead(speedUpPin) == LOW) { // 加速
DELAY_MS = max(MIN_DELAY, DELAY_MS - STEP_ON);
display.showNumberDec(DELAY_MS);
lastDebounceTime = millis();
}
if (digitalRead(speedDownPin) == LOW) { // 减速
DELAY_MS = min(MAX_DELAY, DELAY_MS + STEP_ON);
display.showNumberDec(DELAY_MS);
lastDebounceTime = millis();
}
}
}
// 向595发送一个字节数据
void shiftOut(uint8_t data) {
Serial.print("发送数据: ");
// 从最高位(MSB)开始发送
for(int i = 7; i >= 0; i--) {
digitalWrite(clockPin, LOW); // 时钟低电平
// 获取当前位的值并打印
uint8_t bitValue = (data >> i) & 0x01;
Serial.print(bitValue);
digitalWrite(dataPin, bitValue); // 设置数据位
digitalWrite(clockPin, HIGH); // 时钟上升沿,数据被移入
}
Serial.println(); // 换行
}
// 向多片级联的595发送数据
void shiftMultiBytes(uint8_t* data, int numBytes) {
digitalWrite(latchPin, LOW); // 准备数据传输
// 从最后一片595开始发送
for(int i = numBytes-1; i >= 0; i--) {
Serial.printf("595芯片[%d]: ", i);
shiftOut(data[i]);
}
digitalWrite(latchPin, HIGH); // 数据锁存输出
}
void loop() {
static unsigned long previousMillis = 0; // 上次更新时间
static int currentLed = 0; // 当前LED位置
static bool direction = true; // true为正向,false为反向
uint8_t ledData[NUM_REGISTERS] = {0}; // LED状态数组
// 处理按钮输入
handleButtons();
// 检查是否到达更新时间
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= DELAY_MS) {
previousMillis = currentMillis;
// 清空所有LED
memset(ledData, 0, NUM_REGISTERS);
// 设置当前LED
ledData[currentLed / 8] = (1 << (currentLed % 8));
shiftMultiBytes(ledData, NUM_REGISTERS);
// 更新LED位置
if (direction) {
currentLed++;
if (currentLed >= NUM_LEDS) {
currentLed = NUM_LEDS - 1;
direction = false;
}
} else {
currentLed--;
if (currentLed < 0) {
currentLed = 0;
direction = true;
}
}
}
}
最终效果如下

联网天气预报
发展到这里,其实已经和led灯珠没关系了,但是辛辛苦苦搭的,总不能删掉,留着随便显示了个温度的百分比。整体来说依旧非常简单。
获取传感器的值
这里使用仿真平台中的虚拟传感器DHT22获取温度和湿度,使用的是IIC协议。
这里为了简单起见,使用DHTesp库获取温度和湿度
DHTesp dhtSensor;
dhtSensor.setup(DHT_PIN, DHTesp::DHT22);
TempAndHumidity data = dhtSensor.getTempAndHumidity();
墨水屏显示技术详解
这里使用的是基于SPI通信的2.9寸墨水屏,分辨率296×128。使用了以下关键库:
- GxEPD:墨水屏驱动基础库
- U8g2_for_Adafruit_GFX:用于显示中文字体
- Adafruit_GFX:图形库基础类
显示系统初始化过程:
// 1. SPI配置
SPI.begin(CLK_PIN, -1, DIN_PIN); // SCLK, MISO(未用), MOSI
// 2. 显示驱动初始化
GxIO_Class io(SPI, CS_PIN, DC_PIN, RST_PIN);
GxEPD_Class display(io, RST_PIN, BUSY_PIN);
display.init(115200); // 波特率影响刷新速度
// 3. 中文字体系统初始化
U8G2_FOR_ADAFRUIT_GFX u8g2Fonts;
u8g2Fonts.begin(display);
u8g2Fonts.setFont(u8g2_font_wqy16_t_gb2312a); // 文泉驿16像素中文字体
显示坐标系统说明:
(0,0) →→→→→ X轴 (296)
↓
↓ ------------------
↓ | 屏幕 |
↓ ------------------
Y轴
(128)
显示函数详解:
// 屏幕方向设置 (0-3)
display.setRotation(1); // 1=横向,显示方向顺时针旋转90°
// 清屏操作
display.fillScreen(GxEPD_WHITE); // 整屏填充白色
display.fillRect(x, y, w, h, color); // 局部填充
// 文本显示
u8g2Fonts.setCursor(x, y); // 设置起始坐标
u8g2Fonts.setForegroundColor(GxEPD_BLACK); // 文字颜色
u8g2Fonts.setBackgroundColor(GxEPD_WHITE); // 背景色
u8g2Fonts.printf("显示内容"); // 支持格式化字符串
// 图形显示
display.drawBitmap(x, y, bitmap_data, w, h, color); // 位图显示
网络通信详解
这里使用接口https://goweather.herokuapp.com/weather/Shanghai 获取简易的天气预报数据。
ESP32 WiFi连接过程:
WiFi.begin(ssid, password);
// 等待连接完成
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
HTTP请求实现:
bool getWeatherData() {
HTTPClient http;
http.begin(weatherUrl); // 设置目标URL
int httpCode = http.GET(); // 发送GET请求
if (httpCode == HTTP_CODE_OK) {
String payload = http.getString(); // 获取响应内容
// ArduinoJson解析
StaticJsonDocument<1024> doc; // 分配JSON解析缓冲区
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
// 解析数据...
return true;
}
}
http.end(); // 释放资源
return false;
}
最终效果
这里是完整的代码实现
#include "DHTesp.h"
#include <GxEPD.h>
#include <GxGDEM029T94/GxGDEM029T94.h>
#include <GxIO/GxIO_SPI/GxIO_SPI.h>
#include <GxIO/GxIO.h>
#include <SPI.h>
#include <Fonts/FreeMonoBold9pt7b.h>
#include <Fonts/FreeMonoBold12pt7b.h>
#include <U8g2_for_Adafruit_GFX.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
// 硬件引脚定义
// 74HC595级联控制引脚
const int dataPin = 2; // 串行数据输入
const int latchPin = 0; // 存储寄存器时钟,用于数据锁存
const int clockPin = 4; // 移位寄存器时钟,控制数据移位
// E-Paper显示屏SPI接口引脚
const int DIN_PIN = 23; // MOSI引脚
const int CLK_PIN = 22; // SCK引脚
const int CS_PIN = 5; // 片选信号
const int DC_PIN = 19; // 数据/命令控制
const int RST_PIN = 18; // 复位信号
const int BUSY_PIN = 21; // 忙状态指示
// DHT22温湿度传感器引脚
const int DHT_PIN = 25; // 单总线数据引脚
// LED控制相关常量
const int NUM_REGISTERS = 6; // 74HC595芯片数量
const int NUM_LEDS = NUM_REGISTERS * 8; // LED总数(每个595控制8个LED)
// 定时器间隔设置
const unsigned long DISPLAY_REFRESH_INTERVAL = 5000; // 显示刷新间隔(ms)
const unsigned long WEATHER_UPDATE_INTERVAL = 1800000; // 天气更新间隔(30分钟)
// WiFi配置
const char* ssid = "Wokwi-GUEST";
const char* password = "";
const char* weatherUrl = "https://goweather.herokuapp.com/weather/Shanghai";
// 天气数据结构定义
struct WeatherData {
String temperature; // 当前温度
String wind; // 风速
String description; // 天气描述
String forecast[3][2]; // 未来3天预报[天数][温度,风速]
};
WeatherData currentWeather;
// 天气图标定义 (32x32位图)
const unsigned char icon_sunny[] PROGMEM = {
0x00, 0x01, 0x80, 0x00, 0x00, 0x03, 0xc0, 0x00, 0x00, 0x03, 0xc0, 0x00, 0x00, 0x03, 0xc0, 0x00,
0x06, 0x03, 0xc0, 0x60, 0x0f, 0x07, 0xe0, 0xf0, 0x0f, 0x9f, 0xf9, 0xf0, 0x07, 0xfc, 0x3f, 0xe0,
0x03, 0xe0, 0x07, 0xc0, 0x01, 0xc7, 0xe3, 0x80, 0x01, 0x8f, 0xf1, 0x80, 0x03, 0x1f, 0xfc, 0xc0,
0x03, 0x3f, 0xfc, 0xc0, 0x07, 0x7f, 0xfe, 0xe0, 0x7e, 0x7f, 0xfe, 0x7e, 0xfe, 0x7f, 0xfe, 0x7f,
0xfe, 0x7f, 0xfe, 0x7f, 0x7e, 0x7f, 0xfe, 0x7e, 0x07, 0x7f, 0xfe, 0xe0, 0x03, 0x3f, 0xfc, 0xc0,
0x03, 0x1f, 0xfc, 0xc0, 0x01, 0x8f, 0xf1, 0x80, 0x01, 0xc7, 0xe3, 0x80, 0x03, 0xe0, 0x07, 0xc0,
0x07, 0xf8, 0x1f, 0xe0, 0x0f, 0x9f, 0xf9, 0xf0, 0x0f, 0x07, 0xe0, 0xf0, 0x06, 0x03, 0xc0, 0x60,
0x00, 0x03, 0xc0, 0x00, 0x00, 0x03, 0xc0, 0x00, 0x00, 0x03, 0xc0, 0x00, 0x00, 0x01, 0x80, 0x00
};
const unsigned char icon_cloudy[] PROGMEM = {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xfc, 0x00, 0x00, 0x07, 0xfe, 0x00,
0x00, 0x00, 0xfe, 0x00, 0x00, 0x00, 0x7f, 0x00, 0x00, 0x7e, 0x3f, 0x00, 0x01, 0xff, 0x9f, 0x80,
0x03, 0xff, 0x8f, 0xf8, 0x03, 0xff, 0xcf, 0xfe, 0x07, 0xff, 0xe7, 0xfe, 0x0f, 0xff, 0xe0, 0x7f,
0x3f, 0xff, 0xe0, 0x1f, 0x7f, 0xff, 0xff, 0x9f, 0x7f, 0xff, 0xff, 0xce, 0xff, 0xff, 0xff, 0xce,
0xff, 0xff, 0xff, 0xe0, 0xff, 0xff, 0xff, 0xe0, 0xff, 0xff, 0xff, 0xe0, 0xff, 0xff, 0xff, 0xe0,
0x7f, 0xff, 0xff, 0xc0, 0x3f, 0xff, 0xff, 0x80, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00
};
DHTesp dhtSensor;
GxIO_Class io(SPI, /*CS=D8*/ CS_PIN, /*DC=D3*/ DC_PIN, /*RST=D4*/ RST_PIN); // arbitrary selection of D3(=0), D4(=2), selected for default of GxEPD_Class
GxEPD_Class display(io, /*RST=D4*/ RST_PIN, /*BUSY=D2*/ BUSY_PIN); // default selection of D4(=2), D2(=4)
U8G2_FOR_ADAFRUIT_GFX u8g2Fonts;
// 硬件初始化和配置
void setup() {
Serial.begin(115200);
// 设置595控制引脚
pinMode(dataPin, OUTPUT);
pinMode(clockPin, OUTPUT);
pinMode(latchPin, OUTPUT);
// 设置DHT22
dhtSensor.setup(DHT_PIN, DHTesp::DHT22);
SPI.begin(CLK_PIN, -1, DIN_PIN);
display.init(115200); // enable diagnostic output on Serial
u8g2Fonts.begin(display); // 将u8g2过程连接到Adafruit GFX
//u8g2Fonts.setFontMode(1); // 使用u8g2透明模式(这是默认设置)
u8g2Fonts.setFontDirection(0);
u8g2Fonts.setForegroundColor(GxEPD_BLACK); // 设置前景色
u8g2Fonts.setBackgroundColor(GxEPD_WHITE); // 设置背景色
//u8g2Fonts.setFont(chinese_gb2312);
display.setTextColor(GxEPD_BLACK);
u8g2Fonts.setFont(u8g2_font_wqy16_t_gb2312a); // select u8g2 font from here: https://github.com/olikraus/u8g2/wiki/fntlistall
// 初始化WiFi
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi connected");
// 获取初始天气数据
getWeatherData();
}
// 74HC595数据发送函数
void shiftOut(uint8_t data) {
// 按位发送8位数据(MSB优先)
for(int i = 7; i >= 0; i--) {
digitalWrite(clockPin, LOW);
digitalWrite(dataPin, (data >> i) & 0x01);
digitalWrite(clockPin, HIGH); // 上升沿锁存数据
}
}
// 向多个595发送数据
void shiftMultiBytes(uint8_t* data, int numBytes) {
digitalWrite(latchPin, LOW); // 准备数据传输
// 从最后一片595开始发送(级联数据流向)
for(int i = numBytes-1; i >= 0; i--) {
shiftOut(data[i]);
}
digitalWrite(latchPin, HIGH); // 输出锁存
}
// LED显示控制函数
void lightupLed(float num) {
// num: 0-1之间的比例值,控制点亮的LED数量
if (num > 1) num = 1;
if (num < 0) num = 0;
int ledNum = (int)(NUM_LEDS * num);
uint8_t ledData[NUM_REGISTERS] = {0};
// 计算完全点亮的595数量
int fullICs = ledNum / 8;
int remainingLEDs = ledNum % 8;
// 设置完全点亮的芯片
for(int i = 0; i < fullICs; i++) {
ledData[i] = 0xFF;
}
// 处理部分点亮的最后一个芯片
if (remainingLEDs > 0 && fullICs < NUM_REGISTERS) {
ledData[fullICs] = (1 << remainingLEDs) - 1;
}
shiftMultiBytes(ledData, NUM_REGISTERS);
}
// 温度映射函数:将温度(-40到80°C)映射到0-1范围
float mapTemperature(float temp) {
const float MIN_TEMP = -40.0;
const float MAX_TEMP = 80.0;
float percentage = (temp - MIN_TEMP) / (MAX_TEMP - MIN_TEMP);
return constrain(percentage, 0, 1);
}
// HTTP获取天气数据
bool getWeatherData() {
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
http.begin(weatherUrl);
int httpCode = http.GET();
if (httpCode == HTTP_CODE_OK) {
String payload = http.getString();
// 使用ArduinoJson解析数据
StaticJsonDocument<1024> doc;
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
currentWeather.temperature = doc["temperature"].as<String>();
currentWeather.wind = doc["wind"].as<String>();
currentWeather.description = doc["description"].as<String>();
// 获取预报数据
for(int i = 0; i < 3; i++) {
currentWeather.forecast[i][0] = doc["forecast"][i]["temperature"].as<String>();
currentWeather.forecast[i][1] = doc["forecast"][i]["wind"].as<String>();
}
return true;
}
}
http.end();
}
return false;
}
// 绘制天气图标
void drawWeatherIcon(const String& description, int x, int y) {
const unsigned char* icon = icon_sunny; // 默认晴天图标
// 根据描述选择图标
if (description.indexOf("Clear") >= 0) {
icon = icon_sunny;
} else if (description.indexOf("Cloud") >= 0) {
icon = icon_cloudy;
}
display.drawBitmap(x, y, icon, 32, 32, GxEPD_BLACK);
}
// 主循环
void loop() {
unsigned long currentMillis = millis();
static unsigned long lastWeatherUpdate = 0;
static unsigned long lastDisplayRefresh = 0;
// 1. 获取并显示温湿度数据
TempAndHumidity data = dhtSensor.getTempAndHumidity();
float tempPercentage = mapTemperature(data.temperature);
lightupLed(tempPercentage); // LED显示温度比例
// 2. 定期更新天气数据
if (currentMillis - lastWeatherUpdate >= WEATHER_UPDATE_INTERVAL) {
if (getWeatherData()) {
lastWeatherUpdate = currentMillis;
}
}
// 3. 定期更新显示屏
if (currentMillis - lastDisplayRefresh >= DISPLAY_REFRESH_INTERVAL) {
lastDisplayRefresh = currentMillis;
// 显示布局更新
display.setRotation(1);
display.fillScreen(GxEPD_WHITE);
// 3.1 显示室内环境数据
u8g2Fonts.setCursor(0, 20);
u8g2Fonts.printf("室内: %.1f°C %.1f%%",
data.temperature,
data.humidity);
// 3.2 显示室外天气数据
u8g2Fonts.setCursor(0, 45);
u8g2Fonts.printf("室外: %s %s",
currentWeather.temperature.c_str(),
currentWeather.wind.c_str());
// 3.3 显示天气图标
drawWeatherIcon(currentWeather.description, 200, 15);
// 3.4 显示天气预报
u8g2Fonts.setCursor(0, 75);
for(int i = 0; i < 3; i++) {
u8g2Fonts.printf("%d天后: %s %s\n",
i+1,
currentWeather.forecast[i][0].c_str(),
currentWeather.forecast[i][1].c_str());
}
display.update(); // 更新显示内容
}
delay(50); // 适当延时,避免CPU占用过高
}
最终效果如下,通过wifi联网获取简易天气预报数据,并结合温度湿度传感器的数值,显示在2.9英寸墨水屏上,并显示一个图标体现现在的天气状态。温度的比例体现在led灯珠上。




