STM32 · OLED · DHT11 · 4 Keys · 更新于 2026-08-09

STM32F103C8T6 + 0.96 OLED + DHT11 + 4 个按键 接线与代码

这是一套面包板入门实验:O31 开发板做主控,I41 的 0.96 寸 OLED 显示温湿度,M0613 的 DHT11 采集数据,四个按键切换显示页面,全程用 ST-Link 下载。本页最值得看的是引脚总表和开关脚位那一段——面包板上最常见的两个翻车点都在那里。

O31STM32F103C8T6
开发板(主控)
M1014ST-Link V2
SWD 下载器
I410.96 寸 OLED
I2C 四针
M0613DHT11 模块
三针 温湿度
H21-BMB-102 面包板
830 孔

一句话结论

  • OLED:SCL→PB8 SDA→PB9,要开 I2C1 重映射
  • DHT11:DATA→PA1,供电走 3.3V
  • 按键:左上→PB11 右上→PB10 左下→PB1 右下→PB0,另一脚全部接 GND
  • 不能碰:PA13 / PA14(ST-Link 占用)、PB2(BOOT1)、PA11 / PA12(USB)
  • 四个键:左上翻页 / 右上反色 / 左下亮度 / 右下清零,按下都有可见反馈
  • 屏驱动:SSD1306(✓ 实测,不是 SH1106)
  • 环境:VS Code + PlatformIO + STM32duino,ST-Link 上传

SKU Map

这一套用到的五个 SKU

客户经常只买其中一两样。排查前先问清楚手里到底有哪几件,特别是有没有下载器——没有 ST-Link 就烧不进程序。

SKU产品在这套里做什么什么场景选它 / 注意点
O31STM32F103C8T6 开发板主控3.3V 逻辑的 Blue Pill 板型。芯片批次以实物丝印为准。
M1014ST-Link V2 下载器下载 + 调试本页程序走 SWD 下载。只想用串口下载的客户可以不买,但要自己改 BOOT 跳线,麻烦得多。
I410.96 寸 OLED显示温湿度必须是 I2C 四针(GND/VDD/SCK/SDA)那一款,七针 SPI 版本接法完全不同。本批驱动是 SSD1306(✓ 实测确认),库用错会整屏全黑,见下面代码那一节。
M0613DHT11 温湿度模块采集温湿度三针带底板版本,板上已经有上拉电阻,不用外接。裸的四针 DHT11 传感器要自己加 4.7kΩ 上拉。
H21-BMB-102 面包板 830 孔搭电路163.5 × 53.5 mm。中间沟槽两侧不导通,这一点决定了按键怎么插,见开关那一节。
待确认轻触开关 ×4切换显示 / 输入两脚、四脚都能用,接法不同。SKU 待确认。

本站还有这些相关资料

DHT11 单独的模块资料(量程、nan 读数、Arduino/ESP32 接法)看 DHT11 温湿度传感器模块资料。同一块 O31 开发板配超声波测距的两页:基础版、开关 LED 版本2。

Pin Map

引脚总表(照这张接就行)

先看这张表,再看下面的图。表里颜色和接线图上的线色是对应的。

器件器件引脚接到 STM32说明
ST-Link V2
SKU M1014
SWDIOPA13已接,程序脚位不要占用这两个
SWCLKPA14同上
3.3V3.3供电只走这一路,不要同时插 USB
GNDG共地
0.96 OLED
SKU I41
VDD3.3不要接 5V
GNDG共地
SCK / SCLPB8I2C1 重映射脚,必须开 remap。只能是 SCL,不能和 PB9 对调
SDAPB9同上,只能是 SDA。屏地址常见 0x3C
DHT11 模块
SKU M0613
VCC3.3见耐压说明,不建议 5V
GNDG共地
S / DATAPA1单总线,不是 I2C。模块自带上拉
4 个按键
位置按面包板实物
左上(KEY1)PB11 ↔ GND全部接 GND,代码里配「上拉输入」,按下读到 0,不用外接电阻
右上(KEY2)PB10 ↔ GND
左下(KEY3)PB1 ↔ GND
右下(KEY4)PB0 ↔ GND

这些脚千万别占用

PA13 PA14 是 SWDIO / SWCLK,被程序当普通 IO 用掉之后 ST-Link 就再也连不上,只能靠 BOOT0 拉高进 ISP 才救得回来。

另外:PB2 是 BOOT1,PA11 / PA12 是 USB 差分对,PB3 / PB4 默认是 JTAG 脚(要先关掉 JTAG 才能当普通 IO)。PC13 是板载 LED,可以留着当状态灯。

为什么选这四个脚:PB11、PB10、PB1、PB0 在 Blue Pill 的下排排针上正好是挨着的四个孔,顺序就是从左到右,飞线最短、也最不容易接错;而且都不跟 SWD、USB、I2C 冲突。按键在面包板上的摆放位置(左上 / 右上 / 左下 / 右下)就按这个顺序对过去。

Wiring

彩色接线图

图上颜色和上面表里的徽章颜色一一对应。所有 GND 走面包板负轨,所有 3.3V 走正轨。

OLED I2C(PB8 / PB9) DHT11 单总线(PA1) 按键信号(PB0 / PB1 / PB10 / PB11) 3.3V 电源 GND ST-Link SWD
STM32F103C8T6 接 0.96 寸 OLED、DHT11 和四个按键的彩色接线图
第 1 步:先做电源轨开发板的 3.3 接面包板正轨,G 接负轨。后面所有模块的电和地都从轨上取,别一根根往板子上挤。
第 2 步:接 OLED 四根VDD→正轨,GND→负轨,SCK→PB8,SDA→PB9。SCK/SCL 必须在 PB8、SDA 必须在 PB9,这两个不能对调(芯片定死的),插反了 I2C 扫描一个设备都扫不到。四针 OLED 的丝印顺序不同批次会变,按丝印的字对,不要按插孔位置数。
第 3 步:接四个按键每个按键一脚飞到负轨,另一脚按面包板上的位置接:左上→PB11,右上→PB10,左下→PB1,右下→PB0。插法看下一节,插错等于永久短路。
第 4 步:最后接 DHT11三根:VCC→正轨,GND→负轨,S→PA1。放在下排靠近 PA1 的位置,线最短。
第 5 步:上电前先量一次万用表蜂鸣档量正轨和负轨之间,不响才能上电。响了说明某个模块插反或者按键插错列。
第 6 步:再插 ST-Link3.3V、GND、SWDIO、SWCLK 四根。这时候开发板才第一次上电。

Switch

开关怎么插:两脚和四脚不一样

这是面包板上最常见的翻车点,两种开关的坑正好相反,分开讲。

两脚开关(本套实际使用)

一脚接 GPIO,一脚接 GND,两脚可以互换,没有方向性。

唯一的坑:两只脚必须落在不同的列。面包板是每 5 个孔竖着一列导通、横着不通。开关横着插、两脚岔开在不同列 → 两个独立节点,正确;开关竖着插、两脚落进同一列 → 面包板内部直接把它短路了,程序会一直读到「按下」。

正确(横插,跨两列)      错误(竖插,同一列)
 列30      列32            列30
  ●─────────●               ●  ← 脚1
  │  开关   │               ●  ← 脚2
 独立       独立          板内已短路

插完量一下:万用表蜂鸣档,不按不响、按下响,才算插对。

四脚轻触开关

四只脚内部其实只有两个节点,同一侧并排的两只脚出厂就是短接的:

  1 ●───────● 2     1、2 内部永久导通
    │       │
    ⌒  按键  ⌒       按下时上下两组才导通
    │       │
  3 ●───────● 4     3、4 内部永久导通

正确:用对角的两只脚(1 和 4,或 2 和 3),也可以用上下一对(1 和 3)。

错误:用 1 和 2(或 3 和 4)——那等于一根导线,不管按不按都是通的,GPIO 会被永久拉低。

面包板标准插法:让开关横跨中间沟槽,1、2 脚在沟槽上边,3、4 脚在下边,上下自动分成两个网络,随便上边引一根到 GPIO、下边引一根到 GND 就对了。

软件这边两种开关完全一样

GPIO 配上拉输入,空闲读到 1,按下读到 0,不用外接任何上下拉电阻——STM32 内部上拉够用。

必须加消抖。机械开关按一下会抖动 5~20 ms,检测到电平变化后延时 20 ms 再确认一次,否则一次按键会被当成好几次。代码见下面示例。

I2C Remap

OLED 走 PB8/PB9 必须开 I2C1 重映射

STM32F103 的 I2C1 默认在 PB6 / PB7;PB8 / PB9 是它的重映射位置。数据手册的引脚表里,PB8 那一行「默认复用功能」写的是 TIM4_CH3,I2C1_SCL 是写在「重映射」那一列的,PB9 同理对应 I2C1_SDA。

所以用硬件 I2C1 时,初始化必须先开 AFIO 时钟再做重映射,漏了这一步 OLED 一定不亮:

三种写法对应的做法

PlatformIO / STM32duino(本页代码):调 Wire.setSCL(PB8); Wire.setSDA(PB9);,库会自己处理重映射,最省事。

HAL / CubeMX:在 Pinout 视图把 I2C1 的 SCL/SDA 直接拖到 PB8/PB9,CubeMX 会自动生成 remap 代码。手写就是 __HAL_RCC_AFIO_CLK_ENABLE(); 加 __HAL_AFIO_REMAP_I2C1_ENABLE();。

标准库:RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); 加 GPIO_PinRemapConfig(GPIO_Remap_I2C1, ENABLE);。

PB8 只能当 SCL,PB9 只能当 SDA —— 接反了硬件 I2C 一定不通

这两个脚的角色是芯片里定死的,不能对调,跟软件怎么写没关系。两处原始依据:ST 数据手册 DS5319 引脚定义表的 Remap 列写的是 PB8 = I2C1_SCL、PB9 = I2C1_SDA;STM32duino 的 F103 变体 PeripheralPins.c 里,PinMap_I2C_SCL[] 收的是 PB_8、PinMap_I2C_SDA[] 收的是 PB_9。

接反的症状:屏完全没反应,I2C 扫描一个设备都扫不到——很容易被误判成「屏坏了」或「地址不对」,其实只是两根线插反了。

怎么办:① 把 OLED 的 SCK/SCL 插到 PB8、SDA 插到 PB9,代码不用改,这是首选;② 实在不想动线,就改用软件模拟 I2C(比如 U8g2 的 U8G2_SH1106_128X64_NONAME_F_SW_I2C 构造器),软件 I2C 是纯 GPIO 翻转,SCL/SDA 在哪个脚都行,代价是慢一点。

另外 GPIO 模式要配成复用开漏输出(AF_OD),不是推挽。如果改用软件模拟 I2C,就配普通开漏输出,不用管 remap。

OLED 关键参数

驱动SSD1306(✓ 2026-08-09 实测确认,用双驱动轮换固件在屏上直接读出来的)
分辨率128 × 64
接口I2C,只用 SCL / SDA 两根信号线
地址常见 0x3C,少数 0x3D(⚠ 待核对)
供电3.3V
库Adafruit SSD1306 + Adafruit GFX(树莓派用 luma.oled 的 ssd1306)

屏不亮时先跑一次 I2C 扫描,确认地址到底是 0x3C 还是 0x3D,再去怀疑程序。扫描代码见 I2C 扫描器资料页。

5V Tolerant

哪些脚是 5V 耐压,哪些不是

这一段直接抄 ST 官方数据手册 DS5319 的引脚定义表(I/O Level 列,FT = 5 V tolerant)。这决定了 DHT11 能不能接 5V。

引脚数据手册 I/O Level能不能接 5V 信号本页用途
PA1—(无 FT 标记)不能DHT11 DATA
PB0—(无 FT 标记)不能按键 右下(只对地,无所谓)
PB1—(无 FT 标记)不能按键 左下(只对地,无所谓)
PB8FT可以OLED SCL
PB9FT可以OLED SDA
PB10FT可以按键 右上
PB11FT可以按键 左上

DHT11 用 3.3V 供电,不要用 5V

DHT11 模块本身 3.3V ~ 5.5V 都能工作,但它的 DATA 输出电平跟着供电走:接 5V 供电,DATA 就会往 STM32 上送 5V。而 PA1 在数据手册里没有 FT 标记,长期硬怼 5V 会伤 IO。

两个正确做法,二选一:①(推荐)DHT11 直接接 3.3V,实测能正常工作,什么都不用加;② 一定要 5V 供电的话,DATA 换到 PB5 或 PA8 这类 FT 脚,同时代码里不要开这个脚的内部上拉——手册注明 FT 脚要承受高于 VDD+0.3V 的电压时,内部上拉/下拉必须关掉。

DHT11 关键参数

接口单总线 DATA,不是 I2C
温度0 ~ 50 °C,整数输出(⚠ 待核对)
湿度20 ~ 90 %RH,整数输出(⚠ 待核对)
读取周期建议 2 秒一次,读太快会返回错误
上拉电阻M0613 三针模块板载已有,不用外接(待确认具体阻值)

Charge Pump

接的是 3.3V,那 7.5V 是哪来的?

这是客户看完「屏全黑」那段最常追问的一句。答案在 SSD1306 手册第 2 节,下面用大白话讲一遍。

先给结论

这块屏其实要用两种电:3.3V 是你从外面接进去的,7.5V 是芯片自己在里面造出来的。所以你拿万用表在排针上量,永远只有 3.3V,量不到 7.5V —— 这是正常的,不是屏坏了。

3.3V 进去以后分两路

四针 OLED 模块上只有一个 VDD 引脚,但芯片内部其实把这一路电分成了两个用途,手册里是两个不同的电源名:

手册里的名字电压范围干什么用的
VDD1.65V ~ 3.3V给芯片的逻辑电路用——就是让它能收 I2C 命令、能思考
VBAT3.3V ~ 4.2V给升压电路当原料
VCC7V ~ 15V
模块实际 7.5V
点亮玻璃用的高压。这个脚不接电源,只接一颗电容——因为它是输出不是输入

四针模块把 VDD 和 VBAT 在板子上并到了一起,所以你只需要接一根 3.3V。

7.5V 是怎么造出来的

芯片里有一个叫电荷泵(charge pump)的电路。打个比方:它就像一个打气筒——你给它 3.3V 的"力气",它在里面反复充放电,把电压一级一级顶上去,最后稳定在 7.5V。这个高压只存在于芯片和它旁边那两颗小电容之间,不会跑到排针上,就像打气筒里的高压不会跑到墙上的插座里一样。

OLED 上有单独的升压芯片吗?没有

模块上找不到第二颗芯片。电荷泵是集成在 SSD1306 驱动芯片内部的,板子上配合它工作的只有两颗很小的电容,接在芯片的 C1P/C1N 和 C2P/C2N 这四个脚上。

手册原文对这两组脚的描述就一句话:
C1P/C1N — Pin for charge pump capacitor; Connect to each other with a capacitor

关键就在这一句:升压要软件开

手册第 2 节写着这个升压电路 can be turned on/off by software command setting——它默认是关的,要程序发命令才会启动。

命令表里写得很清楚:先发 0x8D,再发一个参数,参数的第 2 位(A[2])是 1 就是 Enable charge pump,也就是代码里那条 0x8D 0x14。

这就是「屏全黑」的全部原因:用 SH1106 的库时,发的是它那一套命令(0xAD 0x8B),SSD1306 收到了但听不懂,泵没开,面板没有 7.5V。而 I2C 通信本身只用到 3.3V 那部分,所以扫描、应答、begin() 返回值全都正常——软件层面查不出任何毛病。

Arduino 代码里对应哪一句

display.begin(SSD1306_SWITCHCAPVCC, 0x3C) 里的 SWITCHCAPVCC,直译就是「用开关电容方式产生 VCC」,也就是叫芯片打开内部充电泵。这个参数不是可有可无的写法习惯,漏了它屏一样是黑的。

Other MCU

换主控怎么接:Arduino UNO R3 / ESP32 / 树莓派

同样的三个模块(OLED + DHT11 + 四个按键)换到别的主控上,接法逻辑完全一样:I2C 两根线、单总线一根、四个按键各一脚对 GND。只是脚号不同。

器件 / 引脚STM32F103C8T6Arduino UNO R3ESP32 DevKit树莓派(BCM 编号)
OLED SCLPB8A5GPIO22GPIO3
物理 5 脚
OLED SDAPB9A4GPIO21GPIO2
物理 3 脚
OLED VDD3.3V5V
⚠ 见下方说明
3.3V3.3V
物理 1 脚
DHT11 DATAPA1D2GPIO4GPIO4
物理 7 脚
DHT11 VCC3.3V5V3.3V3.3V
按键 左上PB11D4GPIO32GPIO17
物理 11 脚
按键 右上PB10D5GPIO33GPIO27
物理 13 脚
按键 左下PB1D6GPIO25GPIO22
物理 15 脚
按键 右下PB0D7GPIO26GPIO23
物理 16 脚
按键另一脚GND 四个平台全部一样:另一脚接 GND,程序里开内部上拉,按下读到 0

Arduino UNO R3:注意 5V

UNO 是 5V 逻辑,DHT11 接 5V 完全没问题。OLED 的 VDD 接 5V 前,先看一眼模块背面有没有稳压芯片——常见的 4 针 SSD1306 / SH1106 转接板标的是 3.3~5V 宽压,可以直接 5V;不确定就先接 3.3V,屏一样能亮,只是稍暗一点。(本店 I41 这批的宽压能力待确认,别跟客户打包票。)

另外 A4/A5 是 UNO 唯一的硬件 I2C 脚,不能随便换到别的数字脚,除非用软件 I2C 库。

树莓派:GPIO 只有 3.3V,绝对不能进 5V

树莓派的 GPIO 没有任何 5V 容忍能力,5V 直接怼上去会烧 SoC,而且是不可修的。所以 DHT11 和 OLED 在树莓派上都必须接 3.3V(物理 1 脚),不要接 4 脚的 5V。

还有两件事要先做:① 用 sudo raspi-config → Interface Options → I2C 打开 I2C;② 用 i2cdetect -y 1 确认屏的地址是不是 0x3C。

ESP32:别用这几个脚接按键

GPIO34~39 没有内部上拉电阻,接按键必须外接 10kΩ 上拉,否则读数乱跳。GPIO0 / 2 / 12 / 15 是启动模式脚(strapping pin),上电瞬间被按键拉低会导致进不了系统或烧录模式异常。本页选的 GPIO32 / 33 / 25 / 26 这四个都干净,可以放心用。

Code

示例代码:四个平台

四份代码功能一致:屏上显示 DHT11 温湿度,四个按键各管一件事,每按一下都有立刻能看见的反馈。

四个按键分别干什么

按键的功能是照着「按下去能不能立刻看出来」设计的——客户拿到手不用接串口、不用会写代码,四个键挨个按一遍就知道每个键是不是好的。这一点对售后很重要:客户说「按键没反应」时,可以直接让他按这四个键描述屏上的变化,一句话就能分清是按键坏了、线接错了、还是他根本没按对位置。

按键引脚功能按下去屏上会怎样
左上PB11翻页温湿度页 → 按键计数页 → 最高/最低记录页 → 循环回来
右上PB10反色显示整屏黑白瞬间对调,最一眼能看出来的一个
左下PB1亮度三档暗 → 中 → 亮 循环(改 SSD1306 的对比度寄存器 0x81)
右下PB0清零按键计数归零,温湿度的最高/最低记录重新开始记

另外四个键的按下次数一直在后台累加,翻到「按键计数页」能看到 LT / RT / LB / RB 四个数字(对应左上/右上/左下/右下)。屏幕右上角常驻两个状态标记:B1~B3 是当前亮度档,I 表示正处于反色状态。

客户自测四个按键的

您好,四个按键都有独立功能,您可以自己验一遍:左上按一下会翻页,右上按一下整个屏幕黑白反过来,左下按一下屏幕亮度变一档,右下按一下把计数清零。翻到第二页(按左上键翻)能看到四个键各自被按了多少次,LT 是左上、RT 是右上、LB 是左下、RB 是右下。哪个键按了屏上没反应、计数也不加,那就是这个键的线没接好,重点查它接 GND 的那一脚。

要改功能只要动 loop() 里那个 switch,四个 case 分别对应左上/右上/左下/右下;要换引脚只改 KEY_PINS 那一行,顺序是固定的「左上、右上、左下、右下」。

驱动认错会「屏全黑」,而且骗过所有检查 —— 本页这块屏实测是 SSD1306

2026-08-09 在实物上踩过一次,过程值得记下来:I2C 扫描正常扫到 0x3C、endTransmission() 返回 0(正常应答)、display.begin() 返回 true(成功)——所有能查的都是绿的,但屏一个亮点都没有。

原因是充电泵。SSD1306 面板的驱动高压靠控制器内部 charge pump 升出来,必须发 0x8D 0x14 这条命令才会打开;而 Adafruit SH110X 这个库从头到尾不发它(SH1106 用的是另一套 0xAD 0x8B)。结果就是:总线一切正常、数据也确实写进显存了,但面板没有电压,全黑。所有软件层面的自检都会告诉你「一切正常」。

两个方向的症状正好不一样,记住这个就能反推:

  • SSD1306 的屏用 SH1106 的库 → 整屏全黑,但 I2C 完全正常(本页这次踩的就是这个)
  • SH1106 的屏用 SSD1306 的库 → 屏是亮的,画面左右偏 2 个像素、边缘一条竖线或雪花(SH1106 显存 132 列,只显示中间 128 列)

两种情况屏都没坏。下面四份代码全部按 SSD1306 写好,对应本页这块实物。

客户的屏不是 SSD1306 怎么办:只要改 4 行

本页四份代码全部是按 SSD1306 驱动写的。客户手里如果是 SH1106(1.3 寸屏多为 SH1106,0.96 寸两种都有),不用重写程序,接线一根都不用动——I2C 就是 SCL/SDA 两根线,跟驱动型号无关——只要按下表把库和这几行换掉就行。

改哪里SSD1306(本页代码,本批屏用这个)换成 SH1106 要改成
装哪个库Adafruit SSD1306Adafruit SH110X
头文件#include <Adafruit_SSD1306.h>#include <Adafruit_SH110X.h>
建对象Adafruit_SSD1306 display(128, 64, &Wire, -1);Adafruit_SH1106G display(128, 64, &Wire, -1);
初始化display.begin(SSD1306_SWITCHCAPVCC, 0x3C);display.begin(0x3C, true);
颜色常量SSD1306_WHITESH110X_WHITE
树莓派(luma.oled)from luma.oled.device import ssd1306
device = ssd1306(...)
from luma.oled.device import sh1106
device = sh1106(...)
用 U8g2 库的话U8G2_SSD1306_128X64_NONAME_F_HW_I2CU8G2_SH1106_128X64_NONAME_F_HW_I2C

SSD1306_SWITCHCAPVCC 这个参数不是可有可无的写法习惯,它就是「叫控制器打开内部充电泵」的意思。改代码时最容易漏掉的就是它。

不知道客户的屏是哪个驱动?让他跑一遍「双驱动轮换」

商品页和丝印上经常不写驱动型号,靠猜很浪费时间。最快的办法是写一段两种驱动轮流试的程序:先用 SSD1306 的库初始化并在屏上写「SSD1306」,等 5 秒;再用 SH1106 的库初始化并写「SH1106」,等 5 秒;无限循环。客户看到屏上出现哪个字,就是哪个驱动,一次就问清了,不用来回猜。

本页这块屏就是这么定下来的——人一开始说是 SH1106,按 SH1106 写的代码屏全黑,跑完这个轮换程序,屏上清清楚楚出的是 SSD1306。

如果两种都不出字,那就不是驱动问题了,去查供电:万用表量 OLED 的 VDD 对 GND 有没有 3.3V,特别注意 MB-102 面包板的电源轨中间是断开的,左右两半不通。

验证状态:STM32 这份 ✓ 实物跑通了

① STM32F103 版本:2026-08-09 在实物上完整跑通。PlatformIO 编译无报错,ST-Link 烧录 OpenOCD 回报 Verified OK。实测结果:I2C 扫描只有 0x3C 一个设备、应答码 0;DHT11 读到 28°C / 49%RH;四个按键上拉电平全部正常;屏正常显示(驱动是 SSD1306,用 SH1106 的库会全黑,这个坑的完整经过见上面的红色警示块)。库版本:Adafruit SSD1306 2.5.x、GFX 1.12.6、DHT sensor library 1.4.7。

② UNO / ③ ESP32 / ④ 树莓派:按同一套逻辑改写,尚未在这三个平台上机跑过,标记为「供参考」。

下载 STM32 PlatformIO 工程 ZIP(实测版)

解压后用 VS Code 打开整个文件夹(不要只复制 main.cpp),装 PlatformIO 插件,接好 ST-Link 点 Upload 即可。库会自动下载。

① STM32F103C8T6(VS Code + PlatformIO + STM32duino)

[env:bluepill_f103c8]
platform = ststm32
board = bluepill_f103c8
framework = arduino
upload_protocol = stlink
monitor_speed = 115200
lib_deps =
    adafruit/Adafruit SSD1306@^2.5.13
    adafruit/Adafruit GFX Library@^1.11.11
    adafruit/DHT sensor library@^1.4.6
    adafruit/Adafruit Unified Sensor@^1.1.14
// O31 STM32F103C8T6 + 0.96 OLED(SSD1306) + DHT11 + 4 按键
// 接线:
//   OLED   VDD->3.3V  GND->GND  SCK/SCL->PB8  SDA->PB9
//          注意 PB8 只能当 SCL、PB9 只能当 SDA,芯片定死不能对调
//   DHT11  VCC->3.3V  GND->GND  S->PA1             (模块自带上拉)
//   按键   左上->PB11  右上->PB10  左下->PB1  右下->PB0
//          另一脚全部接 GND(内部上拉,按下为低)
//   下载   ST-Link V2: 3.3V / GND / SWDIO(PA13) / SWCLK(PA14)
//
// 四个按键的功能(每个都有立刻能看见的反馈,客户可以自测):
//   左上 PB11 = 翻页       温湿度页 -> 按键计数页 -> 最值记录页 -> 循环
//   右上 PB10 = 反色       整屏黑白对调
//   左下 PB1  = 亮度       暗 -> 中 -> 亮 循环
//   右下 PB0  = 清零       按键计数清零 + 温湿度最值重新记
//
// 屏驱动:SSD1306(2026-08-09 实测确认。用 SH1106 的库会全黑,因为不发充电泵命令 0x8D 0x14)
// 环境:VS Code + PlatformIO,framework = arduino (STM32duino)

#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <DHT.h>

#define OLED_SCL_PIN  PB8          // I2C1_SCL 的重映射脚(默认在 PB6)
#define OLED_SDA_PIN  PB9          // I2C1_SDA 的重映射脚(默认在 PB7)
#define OLED_ADDR     0x3C         // ✓ 实测:I2C 扫描只有这一个设备
#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT 64

#define DHT_PIN       PA1
#define DHT_TYPE      DHT11
#define DHT_INTERVAL  2000UL       // DHT11 采样慢,最少 2 秒读一次

#define KEY_COUNT     4
#define DEBOUNCE_MS   20UL         // 机械开关抖动 5~20ms
#define PAGE_COUNT    3

// 顺序 = 面包板上的位置:KEY1 左上、KEY2 右上、KEY3 左下、KEY4 右下
const uint32_t KEY_PINS[KEY_COUNT] = {PB11, PB10, PB1, PB0};
const char *KEY_NAME[KEY_COUNT]    = {"LT", "RT", "LB", "RB"};

// 亮度三档:SSD1306 的对比度寄存器 0x81,取值 0x00~0xFF
const uint8_t BRIGHTNESS[3] = {0x10, 0x7F, 0xCF};

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
DHT dht(DHT_PIN, DHT_TYPE);

bool     keyStable[KEY_COUNT]     = {true, true, true, true};   // true = 松开(上拉)
bool     keyLastRaw[KEY_COUNT]    = {true, true, true, true};
uint32_t keyLastChange[KEY_COUNT] = {0, 0, 0, 0};
uint16_t keyCount[KEY_COUNT]      = {0, 0, 0, 0};

int      page       = 0;           // 0=温湿度 1=按键计数 2=最值
bool     inverted   = false;       // 反色
uint8_t  brightIdx  = 2;           // 亮度档位,默认最亮

float    temperature = NAN, humidity = NAN;
float    tMin = NAN, tMax = NAN, hMin = NAN, hMax = NAN;
uint32_t lastDht = 0;

// 返回值:该按键这一轮是否发生「按下」事件(下降沿)
bool scanKey(int i) {
  bool raw = (digitalRead(KEY_PINS[i]) == HIGH);   // 上拉输入:HIGH=松开,LOW=按下
  uint32_t now = millis();

  if (raw != keyLastRaw[i]) {        // 电平变了,重新计时
    keyLastRaw[i] = raw;
    keyLastChange[i] = now;
    return false;
  }
  if (now - keyLastChange[i] < DEBOUNCE_MS) return false;   // 还在抖动窗口内

  if (raw != keyStable[i]) {         // 稳定了且和上次确认的状态不同
    keyStable[i] = raw;
    if (raw == false) return true;   // false = 按下
  }
  return false;
}

void applyBrightness() {
  // SSD1306 设置对比度:命令 0x81 + 一个字节数值
  display.ssd1306_command(SSD1306_SETCONTRAST);
  display.ssd1306_command(BRIGHTNESS[brightIdx]);
}

void resetAll() {
  for (int i = 0; i < KEY_COUNT; i++) keyCount[i] = 0;
  tMin = tMax = hMin = hMax = NAN;
}

void drawHeader(const char *title) {
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print(title);
  // 右上角状态:亮度档 + 是否反色
  display.setCursor(92, 0);
  display.print(F("B"));
  display.print(brightIdx + 1);
  display.print(inverted ? F(" I") : F("  "));
  display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE);
}

void drawScreen() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  if (page == 0) {
    drawHeader("DHT11 T/H");
    display.setTextSize(2);
    display.setCursor(0, 18);
    if (isnan(temperature)) display.print(F("-- C"));
    else { display.print(temperature, 1); display.print(F(" C")); }
    display.setCursor(0, 42);
    if (isnan(humidity)) display.print(F("-- %"));
    else { display.print(humidity, 1); display.print(F(" %")); }

  } else if (page == 1) {
    drawHeader("Key count");
    display.setTextSize(1);
    for (int i = 0; i < KEY_COUNT; i++) {
      display.setCursor((i % 2) * 64, 16 + (i / 2) * 22);
      display.print(KEY_NAME[i]);
      display.print(F(": "));
      display.print(keyCount[i]);
    }
    display.setCursor(0, 54);
    display.print(F("LT page RT inv LB dim"));

  } else {
    drawHeader("Min / Max");
    display.setTextSize(1);
    display.setCursor(0, 16);
    display.print(F("T min "));
    if (isnan(tMin)) display.print(F("--")); else display.print(tMin, 1);
    display.setCursor(64, 16);
    display.print(F("max "));
    if (isnan(tMax)) display.print(F("--")); else display.print(tMax, 1);
    display.setCursor(0, 30);
    display.print(F("H min "));
    if (isnan(hMin)) display.print(F("--")); else display.print(hMin, 1);
    display.setCursor(64, 30);
    display.print(F("max "));
    if (isnan(hMax)) display.print(F("--")); else display.print(hMax, 1);
    display.setCursor(0, 50);
    display.print(F("RB = clear"));
  }
  display.display();
}

void setup() {
  for (int i = 0; i < KEY_COUNT; i++) {
    pinMode(KEY_PINS[i], INPUT_PULLUP);   // 内部上拉,外面不用加电阻
  }

  Wire.setSCL(OLED_SCL_PIN);              // STM32duino 会自动处理 I2C1 重映射
  Wire.setSDA(OLED_SDA_PIN);
  Wire.begin();

  // SSD1306_SWITCHCAPVCC 这个参数就是叫控制器打开内部充电泵(命令 0x8D 0x14),
  // 面板的高压全靠它。少了这一步屏会全黑——即使 I2C 通信完全正常。
  display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
  applyBrightness();
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 16);
  display.println(F("O31 + SSD1306"));
  display.println(F("DHT11 + 4 keys"));
  display.println();
  display.println(F("LT page  RT invert"));
  display.println(F("LB bright RB clear"));
  display.display();

  dht.begin();
  delay(1500);                            // DHT11 上电后需要稳定时间
}

void loop() {
  bool needRedraw = false;

  for (int i = 0; i < KEY_COUNT; i++) {
    if (!scanKey(i)) continue;
    keyCount[i]++;
    switch (i) {
      case 0:                                   // 左上:翻页
        page = (page + 1) % PAGE_COUNT;
        break;
      case 1:                                   // 右上:反色
        inverted = !inverted;
        display.invertDisplay(inverted);
        break;
      case 2:                                   // 左下:亮度三档
        brightIdx = (brightIdx + 1) % 3;
        applyBrightness();
        break;
      case 3:                                   // 右下:清零
        resetAll();
        break;
    }
    needRedraw = true;
  }

  if (millis() - lastDht >= DHT_INTERVAL) {
    lastDht = millis();
    float h = dht.readHumidity();
    float t = dht.readTemperature();
    if (!isnan(h) && !isnan(t)) {         // 读失败时保留上一次的值,不要刷成 nan
      humidity = h;
      temperature = t;
      if (isnan(tMin) || t < tMin) tMin = t;
      if (isnan(tMax) || t > tMax) tMax = t;
      if (isnan(hMin) || h < hMin) hMin = h;
      if (isnan(hMax) || h > hMax) hMax = h;
    }
    needRedraw = true;
  }

  if (needRedraw) drawScreen();
}

② Arduino UNO R3(Arduino IDE)

库管理器里装三个:Adafruit SSD1306、Adafruit GFX Library、DHT sensor library(会带上 Adafruit Unified Sensor)。UNO 的硬件 I2C 固定在 A4/A5,不用也不能设置。四个键的功能和 STM32 版完全一致。

// Arduino UNO R3 + 0.96 OLED(SSD1306) + DHT11 + 4 按键
// 接线:
//   OLED   VDD->5V(或3.3V)  GND->GND  SCL->A5  SDA->A4   (UNO 硬件 I2C 固定脚,换不了)
//   DHT11  VCC->5V  GND->GND  S->D2
//   按键   左上->D4  右上->D5  左下->D6  右下->D7,另一脚全部接 GND
// 四键功能:左上翻页 / 右上反色 / 左下亮度三档 / 右下清零
// 环境:Arduino IDE,开发板选 Arduino Uno。库:Adafruit SSD1306 + GFX + DHT sensor library
// 验证状态:未实测,供参考

#define DHT_PIN 2
const uint8_t KEY_PINS[4] = {4, 5, 6, 7};   // 左上 / 右上 / 左下 / 右下
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <DHT.h>

#define OLED_ADDR     0x3C
#define DHT_TYPE      DHT11
#define DHT_INTERVAL  2000UL
#define KEY_COUNT     4
#define DEBOUNCE_MS   20UL
#define PAGE_COUNT    3

const char *KEY_NAME[KEY_COUNT]  = {"LT", "RT", "LB", "RB"};   // 左上/右上/左下/右下
const uint8_t BRIGHTNESS[3]      = {0x10, 0x7F, 0xCF};         // 亮度三档(对比度寄存器 0x81)

Adafruit_SSD1306 display(128, 64, &Wire, -1);
DHT dht(DHT_PIN, DHT_TYPE);

bool     keyStable[KEY_COUNT]  = {true, true, true, true};
bool     keyLastRaw[KEY_COUNT] = {true, true, true, true};
unsigned long keyLastChange[KEY_COUNT] = {0, 0, 0, 0};
unsigned int  keyCount[KEY_COUNT] = {0, 0, 0, 0};

int   page = 0;                 // 0=温湿度 1=按键计数 2=最值
bool  inverted = false;
uint8_t brightIdx = 2;
float temperature = NAN, humidity = NAN;
float tMin = NAN, tMax = NAN, hMin = NAN, hMax = NAN;
unsigned long lastDht = 0;

bool scanKey(int i) {
  bool raw = (digitalRead(KEY_PINS[i]) == HIGH);   // 上拉输入:HIGH=松开,LOW=按下
  unsigned long now = millis();
  if (raw != keyLastRaw[i]) { keyLastRaw[i] = raw; keyLastChange[i] = now; return false; }
  if (now - keyLastChange[i] < DEBOUNCE_MS) return false;
  if (raw != keyStable[i]) { keyStable[i] = raw; if (!raw) return true; }
  return false;
}

void applyBrightness() {
  display.ssd1306_command(SSD1306_SETCONTRAST);
  display.ssd1306_command(BRIGHTNESS[brightIdx]);
}

void resetAll() {
  for (int i = 0; i < KEY_COUNT; i++) keyCount[i] = 0;
  tMin = tMax = hMin = hMax = NAN;
}

void drawHeader(const char *title) {
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print(title);
  display.setCursor(92, 0);
  display.print(F("B")); display.print(brightIdx + 1);
  display.print(inverted ? F(" I") : F("  "));
  display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
}

void drawScreen() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  if (page == 0) {
    drawHeader("DHT11 T/H");
    display.setTextSize(2);
    display.setCursor(0, 18);
    if (isnan(temperature)) display.print(F("-- C"));
    else { display.print(temperature, 1); display.print(F(" C")); }
    display.setCursor(0, 42);
    if (isnan(humidity)) display.print(F("-- %"));
    else { display.print(humidity, 1); display.print(F(" %")); }
  } else if (page == 1) {
    drawHeader("Key count");
    display.setTextSize(1);
    for (int i = 0; i < KEY_COUNT; i++) {
      display.setCursor((i % 2) * 64, 16 + (i / 2) * 22);
      display.print(KEY_NAME[i]); display.print(F(": ")); display.print(keyCount[i]);
    }
    display.setCursor(0, 54);
    display.print(F("LT page RT inv LB dim"));
  } else {
    drawHeader("Min / Max");
    display.setTextSize(1);
    display.setCursor(0, 16); display.print(F("T min "));
    if (isnan(tMin)) display.print(F("--")); else display.print(tMin, 1);
    display.setCursor(64, 16); display.print(F("max "));
    if (isnan(tMax)) display.print(F("--")); else display.print(tMax, 1);
    display.setCursor(0, 30); display.print(F("H min "));
    if (isnan(hMin)) display.print(F("--")); else display.print(hMin, 1);
    display.setCursor(64, 30); display.print(F("max "));
    if (isnan(hMax)) display.print(F("--")); else display.print(hMax, 1);
    display.setCursor(0, 50); display.print(F("RB = clear"));
  }
  display.display();
}


void setup() {
  for (int i = 0; i < KEY_COUNT; i++) pinMode(KEY_PINS[i], INPUT_PULLUP);
  Wire.begin();                                     // UNO 固定 A4=SDA, A5=SCL
  display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);   // SWITCHCAPVCC = 开充电泵,少了会全黑
  applyBrightness();
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 16);
  display.println(F("UNO + SSD1306"));
  display.println(F("DHT11 + 4 keys"));
  display.println();
  display.println(F("LT page  RT invert"));
  display.println(F("LB bright RB clear"));
  display.display();
  dht.begin();
  delay(1500);
}

void loop() {
  bool needRedraw = false;
  for (int i = 0; i < KEY_COUNT; i++) {
    if (!scanKey(i)) continue;
    keyCount[i]++;
    switch (i) {
      case 0: page = (page + 1) % PAGE_COUNT; break;              // 左上:翻页
      case 1: inverted = !inverted; display.invertDisplay(inverted); break;  // 右上:反色
      case 2: brightIdx = (brightIdx + 1) % 3; applyBrightness(); break;     // 左下:亮度
      case 3: resetAll(); break;                                  // 右下:清零
    }
    needRedraw = true;
  }
  if (millis() - lastDht >= DHT_INTERVAL) {
    lastDht = millis();
    float h = dht.readHumidity(), t = dht.readTemperature();
    if (!isnan(h) && !isnan(t)) {
      humidity = h; temperature = t;
      if (isnan(tMin) || t < tMin) tMin = t;
      if (isnan(tMax) || t > tMax) tMax = t;
      if (isnan(hMin) || h < hMin) hMin = h;
      if (isnan(hMax) || h > hMax) hMax = h;
    }
    needRedraw = true;
  }
  if (needRedraw) drawScreen();
}

③ ESP32 DevKit(Arduino IDE / PlatformIO)

和 UNO 那份逻辑完全一样,差别只有两处:ESP32 的 I2C 脚可以自己指定(Wire.begin(SDA, SCL)),以及按键换成了 GPIO32/33/25/26 这四个带内部上拉、又不是启动模式脚的安全脚。

// ESP32 DevKit + 0.96 OLED(SSD1306) + DHT11 + 4 按键
// 接线:
//   OLED   VDD->3.3V  GND->GND  SCL->GPIO22  SDA->GPIO21
//   DHT11  VCC->3.3V  GND->GND  S->GPIO4
//   按键   左上->GPIO32  右上->GPIO33  左下->GPIO25  右下->GPIO26,另一脚全部接 GND
//   注意   GPIO34~39 没有内部上拉不能这样接;GPIO0/2/12/15 是启动模式脚,也别用
// 四键功能:左上翻页 / 右上反色 / 左下亮度三档 / 右下清零
// 环境:Arduino IDE,开发板选 ESP32 Dev Module
// 验证状态:未实测,供参考

#define DHT_PIN  4
#define OLED_SDA 21
#define OLED_SCL 22
const uint8_t KEY_PINS[4] = {32, 33, 25, 26};   // 左上 / 右上 / 左下 / 右下
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <DHT.h>

#define OLED_ADDR     0x3C
#define DHT_TYPE      DHT11
#define DHT_INTERVAL  2000UL
#define KEY_COUNT     4
#define DEBOUNCE_MS   20UL
#define PAGE_COUNT    3

const char *KEY_NAME[KEY_COUNT]  = {"LT", "RT", "LB", "RB"};   // 左上/右上/左下/右下
const uint8_t BRIGHTNESS[3]      = {0x10, 0x7F, 0xCF};         // 亮度三档(对比度寄存器 0x81)

Adafruit_SSD1306 display(128, 64, &Wire, -1);
DHT dht(DHT_PIN, DHT_TYPE);

bool     keyStable[KEY_COUNT]  = {true, true, true, true};
bool     keyLastRaw[KEY_COUNT] = {true, true, true, true};
unsigned long keyLastChange[KEY_COUNT] = {0, 0, 0, 0};
unsigned int  keyCount[KEY_COUNT] = {0, 0, 0, 0};

int   page = 0;                 // 0=温湿度 1=按键计数 2=最值
bool  inverted = false;
uint8_t brightIdx = 2;
float temperature = NAN, humidity = NAN;
float tMin = NAN, tMax = NAN, hMin = NAN, hMax = NAN;
unsigned long lastDht = 0;

bool scanKey(int i) {
  bool raw = (digitalRead(KEY_PINS[i]) == HIGH);   // 上拉输入:HIGH=松开,LOW=按下
  unsigned long now = millis();
  if (raw != keyLastRaw[i]) { keyLastRaw[i] = raw; keyLastChange[i] = now; return false; }
  if (now - keyLastChange[i] < DEBOUNCE_MS) return false;
  if (raw != keyStable[i]) { keyStable[i] = raw; if (!raw) return true; }
  return false;
}

void applyBrightness() {
  display.ssd1306_command(SSD1306_SETCONTRAST);
  display.ssd1306_command(BRIGHTNESS[brightIdx]);
}

void resetAll() {
  for (int i = 0; i < KEY_COUNT; i++) keyCount[i] = 0;
  tMin = tMax = hMin = hMax = NAN;
}

void drawHeader(const char *title) {
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print(title);
  display.setCursor(92, 0);
  display.print(F("B")); display.print(brightIdx + 1);
  display.print(inverted ? F(" I") : F("  "));
  display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
}

void drawScreen() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  if (page == 0) {
    drawHeader("DHT11 T/H");
    display.setTextSize(2);
    display.setCursor(0, 18);
    if (isnan(temperature)) display.print(F("-- C"));
    else { display.print(temperature, 1); display.print(F(" C")); }
    display.setCursor(0, 42);
    if (isnan(humidity)) display.print(F("-- %"));
    else { display.print(humidity, 1); display.print(F(" %")); }
  } else if (page == 1) {
    drawHeader("Key count");
    display.setTextSize(1);
    for (int i = 0; i < KEY_COUNT; i++) {
      display.setCursor((i % 2) * 64, 16 + (i / 2) * 22);
      display.print(KEY_NAME[i]); display.print(F(": ")); display.print(keyCount[i]);
    }
    display.setCursor(0, 54);
    display.print(F("LT page RT inv LB dim"));
  } else {
    drawHeader("Min / Max");
    display.setTextSize(1);
    display.setCursor(0, 16); display.print(F("T min "));
    if (isnan(tMin)) display.print(F("--")); else display.print(tMin, 1);
    display.setCursor(64, 16); display.print(F("max "));
    if (isnan(tMax)) display.print(F("--")); else display.print(tMax, 1);
    display.setCursor(0, 30); display.print(F("H min "));
    if (isnan(hMin)) display.print(F("--")); else display.print(hMin, 1);
    display.setCursor(64, 30); display.print(F("max "));
    if (isnan(hMax)) display.print(F("--")); else display.print(hMax, 1);
    display.setCursor(0, 50); display.print(F("RB = clear"));
  }
  display.display();
}


void setup() {
  for (int i = 0; i < KEY_COUNT; i++) pinMode(KEY_PINS[i], INPUT_PULLUP);
  Wire.begin(OLED_SDA, OLED_SCL);                   // ESP32 的 I2C 脚可以自己指定
  display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);   // SWITCHCAPVCC = 开充电泵,少了会全黑
  applyBrightness();
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 16);
  display.println(F("ESP32 + SSD1306"));
  display.println(F("DHT11 + 4 keys"));
  display.println();
  display.println(F("LT page  RT invert"));
  display.println(F("LB bright RB clear"));
  display.display();
  dht.begin();
  delay(1500);
}

void loop() {
  bool needRedraw = false;
  for (int i = 0; i < KEY_COUNT; i++) {
    if (!scanKey(i)) continue;
    keyCount[i]++;
    switch (i) {
      case 0: page = (page + 1) % PAGE_COUNT; break;              // 左上:翻页
      case 1: inverted = !inverted; display.invertDisplay(inverted); break;  // 右上:反色
      case 2: brightIdx = (brightIdx + 1) % 3; applyBrightness(); break;     // 左下:亮度
      case 3: resetAll(); break;                                  // 右下:清零
    }
    needRedraw = true;
  }
  if (millis() - lastDht >= DHT_INTERVAL) {
    lastDht = millis();
    float h = dht.readHumidity(), t = dht.readTemperature();
    if (!isnan(h) && !isnan(t)) {
      humidity = h; temperature = t;
      if (isnan(tMin) || t < tMin) tMin = t;
      if (isnan(tMax) || t > tMax) tMax = t;
      if (isnan(hMin) || h < hMin) hMin = h;
      if (isnan(hMax) || h > hMax) hMax = h;
    }
    needRedraw = true;
  }
  if (needRedraw) drawScreen();
}

④ 树莓派(Python 3)

树莓派上 OLED 用 luma.oled(ssd1306 和 sh1106 两种驱动都原生支持,换一个词就行)、DHT11 用 adafruit-circuitpython-dht、按键用 gpiozero(bounce_time 自带消抖,比 Arduino 那边省事)。四个键的功能和 STM32 版一致。

# 1) 打开 I2C:sudo raspi-config → Interface Options → I2C → Yes → 重启
# 2) 确认屏的地址(应该看到 3c)
sudo apt update
sudo apt install -y python3-pip i2c-tools libgpiod2
i2cdetect -y 1

# 3) 装库(建议放虚拟环境;系统 Python 报 externally-managed 时加 --break-system-packages)
pip3 install luma.oled adafruit-circuitpython-dht gpiozero
# 树莓派 + 0.96 OLED(SSD1306) + DHT11 + 4 按键
# 接线(BCM 编号 / 括号是物理脚号):
#   OLED   VDD->3.3V(1)  GND->GND(6)  SDA->GPIO2(3)  SCL->GPIO3(5)
#   DHT11  VCC->3.3V(1)  GND->GND(9)  S->GPIO4(7)
#   按键   左上->GPIO17(11)  右上->GPIO27(13)  左下->GPIO22(15)  右下->GPIO23(16)
#          另一脚全部接 GND
#   警告   树莓派 GPIO 只有 3.3V,绝对不能接 5V,会烧 SoC
# 四键功能:左上翻页 / 右上反色 / 左下亮度三档 / 右下清零
# 环境:Raspberry Pi OS + Python 3
# 验证状态:未实测,供参考

import time
import board
import adafruit_dht
from gpiozero import Button
from luma.core.interface.serial import i2c
from luma.oled.device import ssd1306        # 本批屏实测是 SSD1306;SH1106 的屏改成 sh1106
from luma.core.render import canvas
from PIL import ImageFont

OLED_ADDR = 0x3C
KEY_BCM   = [("LT", 17), ("RT", 27), ("LB", 22), ("RB", 23)]   # 左上/右上/左下/右下
DHT_INTERVAL = 2.0                                              # DHT11 最少 2 秒读一次
BRIGHTNESS   = [0x10, 0x7F, 0xCF]                               # 亮度三档

device = ssd1306(i2c(port=1, address=OLED_ADDR), width=128, height=64)
font = ImageFont.load_default()
dht = adafruit_dht.DHT11(board.D4)

state = {
    "page": 0,          # 0=温湿度 1=按键计数 2=最值
    "inverted": False,
    "bright": 2,
    "counts": {name: 0 for name, _ in KEY_BCM},
    "t": None, "h": None,
    "tmin": None, "tmax": None, "hmin": None, "hmax": None,
}

def apply_brightness():
    device.contrast(BRIGHTNESS[state["bright"]])

def reset_all():
    for name in state["counts"]:
        state["counts"][name] = 0
    state["tmin"] = state["tmax"] = state["hmin"] = state["hmax"] = None

def on_press(name):
    def handler():
        state["counts"][name] += 1
        if name == "LT":                                  # 左上:翻页
            state["page"] = (state["page"] + 1) % 3
        elif name == "RT":                                # 右上:反色
            state["inverted"] = not state["inverted"]
        elif name == "LB":                                # 左下:亮度三档
            state["bright"] = (state["bright"] + 1) % 3
            apply_brightness()
        elif name == "RB":                                # 右下:清零
            reset_all()
        draw()
    return handler

buttons = []
for name, pin in KEY_BCM:
    b = Button(pin, pull_up=True, bounce_time=0.02)       # 内部上拉 + 20ms 消抖
    b.when_pressed = on_press(name)
    buttons.append(b)

def fmt(v):
    return "--" if v is None else "%.1f" % v

def draw():
    fg, bg = (0, 255) if state["inverted"] else (255, 0)  # 反色就是前景背景对调
    with canvas(device) as d:
        d.rectangle(device.bounding_box, outline=bg, fill=bg)
        head = ["DHT11 T/H", "Key count", "Min / Max"][state["page"]]
        d.text((0, 0), head, font=font, fill=fg)
        d.text((92, 0), "B%d%s" % (state["bright"] + 1, " I" if state["inverted"] else ""),
               font=font, fill=fg)
        d.line((0, 10, 127, 10), fill=fg)

        if state["page"] == 0:
            d.text((0, 20), "%s C" % fmt(state["t"]), font=font, fill=fg)
            d.text((0, 40), "%s %%" % fmt(state["h"]), font=font, fill=fg)
        elif state["page"] == 1:
            for i, (name, _) in enumerate(KEY_BCM):
                d.text(((i % 2) * 64, 16 + (i // 2) * 22),
                       "%s: %d" % (name, state["counts"][name]), font=font, fill=fg)
            d.text((0, 54), "LT page RT inv LB dim", font=font, fill=fg)
        else:
            d.text((0, 16), "T min %s  max %s" % (fmt(state["tmin"]), fmt(state["tmax"])),
                   font=font, fill=fg)
            d.text((0, 30), "H min %s  max %s" % (fmt(state["hmin"]), fmt(state["hmax"])),
                   font=font, fill=fg)
            d.text((0, 50), "RB = clear", font=font, fill=fg)

apply_brightness()
last_dht = 0.0

try:
    draw()
    while True:
        now = time.monotonic()
        if now - last_dht >= DHT_INTERVAL:
            last_dht = now
            try:
                t, h = dht.temperature, dht.humidity
                if t is not None and h is not None:       # 读失败就保留上一次的值
                    state["t"], state["h"] = t, h
                    state["tmin"] = t if state["tmin"] is None else min(state["tmin"], t)
                    state["tmax"] = t if state["tmax"] is None else max(state["tmax"], t)
                    state["hmin"] = h if state["hmin"] is None else min(state["hmin"], h)
                    state["hmax"] = h if state["hmax"] is None else max(state["hmax"], h)
            except RuntimeError:
                pass          # DHT11 在树莓派上偶尔读失败是正常的,下一轮再来
            draw()
        time.sleep(0.05)
except KeyboardInterrupt:
    pass
finally:
    dht.exit()

树莓派不是实时系统,DHT11 这种靠微秒级时序的单总线传感器偶尔读失败是正常现象,代码里已经 try/except RuntimeError 兜住了,不要当成模块坏。要稳定读温湿度,建议换 I2C 接口的 SHT30 之类。

树莓派这份的按键回调里同样可以照 四键功能表接功能;改引脚只要改 KEY_BCM 那一行。

FAQ

常见问题排查(现象 → 原因 → 怎么办)

OLED 一片黑,什么都不显示

原因:八成是三件事之一——① 用硬件 I2C 但没开重映射;② 地址不是 0x3C;③ 模块没插紧。(屏亮着但画面偏移/花屏是另一回事,那是库用错了,见下一条。)

怎么办:先把 OLED 拔下来重插一次(这一步解决过很多次),再跑一遍 I2C 扫描确认地址,最后确认代码里 Wire.setSCL(PB8) / Wire.setSDA(PB9) 这两行在 Wire.begin() 之前。

I2C 扫描一个设备都扫不到 / 屏完全没反应

原因:先排除供电,然后最常见的是 SCL 和 SDA 两根线插反了。STM32F103 的硬件 I2C1 上,PB8 固定是 SCL、PB9 固定是 SDA,不能对调;插反了总线上根本不会有正确的时钟,自然一个地址都不应答。

怎么办:① 万用表量 OLED 的 VDD 对 GND 有没有 3.3V(注意 MB-102 面包板的电源轨中间是断开的,左右两半不通,这是「完全不亮」的头号原因);② 确认 SCK/SCL 在 PB8、SDA 在 PB9,按模块丝印的字对,不要按插孔位置数;③ 都对了再去怀疑地址和库。

I2C 一切正常、begin() 也返回成功,但屏就是全黑

原因:驱动库和面板对不上,而且方向是「SSD1306 的屏用了 SH1106 的库」。SSD1306 面板的高压靠控制器内部充电泵,必须发 0x8D 0x14 打开;SH110X 这个库不发这条命令。所以总线通、地址应答、begin() 返回 true、数据也写进显存了,面板却没有电压,全黑。软件层面查不出任何毛病。

怎么办:换成 Adafruit SSD1306 库,初始化写成 display.begin(SSD1306_SWITCHCAPVCC, 0x3C)——SWITCHCAPVCC 就是「开充电泵」的意思,漏了它一样全黑。树莓派把 luma.oled 的 sh1106 换成 ssd1306。不确定是哪种就跑「双驱动轮换」那个办法。

✓ 2026-08-09 实测:本页这块屏就是这么查出来的。I2C 扫到 0x3C、应答码 0、begin 返回 1,屏全黑;换 SSD1306 库后正常显示。

屏是亮的,但画面往边上偏 2 个像素 / 边缘有杂线雪花

原因:这是反过来的情况——SH1106 的屏用了 SSD1306 的库。SH1106 的显存有 132 列、实际只显示中间 128 列;SSD1306 的库从第 0 列开始写,画面就整体错位 2 列,右边缘漏出杂线。屏没坏。

怎么办:Arduino 系换成 Adafruit SH110X 库、对象改成 Adafruit_SH1106G、begin(0x3C, true)、颜色常量用 SH110X_WHITE;树莓派把 ssd1306 换成 sh1106。具体见换驱动那张表。

某个按键一直是「按下」状态,或者一按就死机

原因:开关插错了,两只脚落在了面包板的同一列(两脚开关竖插),或者用了四脚开关同一侧的两只脚——这两种情况都相当于把 GPIO 直接短到 GND。

怎么办:断电,万用表蜂鸣档量开关两只脚:不按不响、按下响才对。一直响就是插错了,两脚的改成横插跨两列,四脚的改用对角脚。详见开关那一节。

按一下按键,计数跳了好几次

原因:没做消抖。机械触点在闭合瞬间会抖 5~20 ms,程序在这段时间里会读到很多次电平变化。

怎么办:用本页代码里的 scanKey() 写法(记录变化时刻 + 20 ms 确认窗口),不要用 delay() 硬等——那会让 DHT11 和屏刷新一起卡住。

温湿度一直显示 nan / --

原因:① 读得太快(DHT11 最少 2 秒一次);② DATA 线接错脚;③ 上电后没等稳定就读;④ 少数情况是 3.3V 供电偏低。

怎么办:先确认 S 线确实接在 PA1,代码里 DHT_INTERVAL 不小于 2000 ms,上电后 delay(1000) 再开始读。都对还是 nan,就把 DHT11 换到 5V 供电、DATA 改到 PB5 这类 FT 脚再试一次。

ST-Link 连不上芯片 / 下载报错

原因:① 四根线接错或没接 GND;② 程序里把 PA13/PA14 当普通 IO 用掉了;③ 板子没供电;④ 驱动没装。

怎么办:先量 3.3V 有没有电。确认代码没有动 PA13/PA14。如果之前误占用过这两个脚,把 BOOT0 拉高上电进 ISP,再连一次就能救回来。

为什么不能把按键接到 3.3V 而是接 GND?

可以接 3.3V,那要把代码改成 INPUT_PULLDOWN,按下读到高电平(本站 开关 LED 版本2 那页就是这么做的)。但本页统一用接 GND + 内部上拉,理由:面包板负轨本来就要接,飞线更短;而且 STM32 所有 GPIO 都支持内部上拉,换脚不用改电路。两种做法没有好坏,只要代码和接线一致就行。

Support

需要技术支持时,把这几项一起发来

复制下面这段填一下发给客服,我们一次就能定位,不用来回问。

【STM32F103C8T6 + 0.96 OLED + DHT11 + 4 个按 技术支持】
主控型号:            (如 Arduino UNO / ESP32 / STM32)
供电接的是:          □ 3.3V   □ 5V
I2C Scanner 扫到的地址:
串口显示的内容:
用的是哪份代码:      □ 本页示例   □ 其他(请说明)
接线照片:            (请附一张能看清接线的照片)

Sources

本页信息来源

内容来源可信度
PA1 / PB0 / PB1 无 FT 标记,PB8 / PB9 / PB10 / PB11 为 FT(5V 耐压)ST 官方数据手册 STM32F103x8/xB(DS5319)引脚定义表 I/O Level 列,注 2「FT = 5 V tolerant」手册原文
PB8 = I2C1_SCL、PB9 = I2C1_SDA 属于重映射功能(默认在 PB6/PB7)同上,引脚定义表「Remap」列手册原文
FT 脚承受高于 VDD+0.3V 时须关闭内部上拉/下拉同上,电气特性章节脚注手册原文
MB-102 面包板 830 孔、163.5 × 53.5 mm、81 gSKU H21-B 商品图标注✓ 商品图可读
M0613 DHT11 为三针带底板模块、19 × 16.7 mm、3 gSKU M0613 商品图标注✓ 商品图可读
本套实物已按 ST-Link 接 STM32、OLED 接 PB8/PB9 完成搭建客户实拍面包板照片(2026-08-09)✓ 实拍确认
DHT11 温度 0~50°C、湿度 20~90%RH、建议 2 秒读一次DHT11 公开数据手册 + 本站 T1930 资料页⚠ 待核对
OLED 驱动是 SSD1306烧「双驱动轮换」固件到实物上,屏上显示出 SSD1306 字样✓ 实测(2026-08-09)
I2C 扫描只有 0x3C 一个设备;DHT11 读到 28°C / 49%RH;四个按键上拉电平正常诊断固件写进 RAM,ST-Link + OpenOCD dump 出来✓ 实测(2026-08-09)
I2C 地址 0x3C常见批次经验值;本机烧录后画面正常,可反推地址对⚠ 待核对,上机扫一次最稳
M0613 模块板载上拉电阻阻值、按键 SKU、OLED 具体驱动批次—待确认(需实物补量)
本页 main.cpp 与 platformio.ini按本页接线新写未实测,供参考

开发环境沿用本站另外两页 O31 资料的 VS Code + PlatformIO + STM32duino。需要 STM32CubeMX / HAL 或标准库版本请告知,可以补一份并列版本。

References

参考资料(本页每个数字的出处)

本页所有芯片参数都不是凭记忆写的,全部来自下面这几份官方文档的原文。链接都能直接打开,欢迎自己核对。

文档用它核实了本页的哪些内容链接
Solomon Systech《SSD1306》
Rev 1.1,2008 年 4 月,共 59 页
第 2 节 Charge Pump Regulator:内部电荷泵从 VBAT 升出 7.5V 的 VCC;
VDD = 1.65~3.3V(逻辑)、VBAT = 3.3~4.2V(升压输入);
C1P/C1N、C2P/C2N 是电荷泵电容脚;
命令表:0x8D + A[2]=1b = Enable charge pump;
电气特性表:VCC = 7V to 15V for Panel driving
cdn-shop.adafruit.com/datasheets/SSD1306.pdf
官网:solomon-systech.com
ST《STM32F103x8/xB》
数据手册 DS5319
引脚定义表 I/O Level 列:PA1 / PB0 / PB1 无 FT 标记(不是 5V 耐压),PB8 / PB9 / PB10 / PB11 标 FT;
同表 Remap 列:PB8 = I2C1_SCL、PB9 = I2C1_SDA(属重映射功能,默认在 PB6/PB7);
注 2:FT = 5 V tolerant
st.com/resource/en/datasheet/stm32f103c8.pdf
STM32duino 变体文件
framework-arduinoststm32
variants/STM32F1xx/F103C8T_F103CB(T-U)/PeripheralPins.c:
PinMap_I2C_SCL[] 收 PB_8、PinMap_I2C_SDA[] 收 PB_9,都带 AFIO_I2C1_ENABLE —— 证实用 STM32duino 时重映射由库自动处理,不用手写 AFIO
github.com/stm32duino/Arduino_Core_STM32
Adafruit 库 核对了 SH110X 的初始化序列确实只发 0xAD 0x8B(SH1106 的 DC-DC),全程不发 SSD1306 的 0x8D 0x14 —— 这是「屏全黑」的直接原因 Adafruit_SH110x · Adafruit_SSD1306

怎么自己查手册

PDF 直接搜中文关键词往往搜不到,因为手册是英文的。推荐先把 PDF 转成文本再搜:命令行跑 pdftotext -layout SSD1306.pdf ssd.txt,然后搜 Charge Pump、VCC、7.5V 这类英文词,能直接定位到原文那一行。本页所有结论就是这么核出来的。

返回「开发板与烧录」分类 返回首页 进入淘宝店铺