STM32F103C8T6用I2C+PCF8574驱动LCD1602的即用型Keil工程

发布时间:2026/7/17 2:40:43
STM32F103C8T6用I2C+PCF8574驱动LCD1602的即用型Keil工程 本文还有配套的精品资源点击获取简介这个资源包提供一套开箱即用的STM32F103C8T6驱动LCD1602液晶屏的完整实现方案通过I2C总线连接PCF8574扩展芯片来简化硬件接线。工程基于Keil MDK环境构建已配置好系统时钟、GPIO、I2C外设标准模式并封装了PCF8574通信协议和LCD1602基础操作函数支持清屏、光标移动、字符串显示等常用功能。代码采用模块化设计main.c负责逻辑调度i2c.c实现底层I2C读写lcd1602_i2c.c封装液晶控制指令与数据发送流程所有源文件均已编译通过生成.axf调试镜像和.bak项目备份适配YT32B1等常见STM32F1系列最小系统板。无需修改引脚定义或初始化参数上电即可验证I2C通信与字符显示效果适合初学者快速入门或项目中直接复用。1. 项目概述为什么用I2CPCF8574驱动LCD1602而不是直接并口接线在STM32F103C8T6这类资源紧凑的“蓝 pill”级MCU上驱动LCD1602你大概率会遇到一个现实困境标准8位并行接口需要占用整整8个GPIOD0–D7再加上RS、RW、E三个控制线总计11个IO口。而C8T6只有37个可用GPIO其中不少已被系统时钟、调试接口、串口、LED、按键等基础外设占满。我手头一块YT32B1开发板焊好LED、按键、蜂鸣器后留给液晶的IO只剩不到10个——根本不够跑并口。这时候I2C方案就不是“锦上添花”而是“救命稻草”。核心思路很朴素用PCF8574这个8位I/O扩展芯片把LCD1602的8位数据总线和3根控制线RS、RW、E全部“打包”进它那8个输出引脚里再通过仅需2根线SCLSDA的I2C总线与STM32通信。这样整个液晶模块只消耗2个GPIO还顺带释放了原本被RW线占用的那个IO因为PCF8574内部不读状态我们改用固定延时替代忙检测这是工程取舍的关键点。你可能会问为什么选PCF8574而不是更常见的PCA9555或MCP23017答案是成本与成熟度——PCF8574单价不到1元资料齐备驱动逻辑简单且市面上绝大多数I2C转LCD模块比如淘宝上几块钱的“I2C LCD1602模块”底层就是它。换句话说这套方案不是实验室玩具而是产线验证过的工业级简化路径。这个Keil工程的价值就在于它把所有“隐形工作”都做了从SystemInit()里精确配置72MHz主频下的I2C时序参数到i2c.c中处理起始/停止/应答/重发的底层时序细节从lcd1602_i2c.c里把LCD指令0x01清屏、0x0C开显示关光标和ASCII字符数据按PCF8574的位定义比如P0RS, P1RW, P2E, P4–P7D4–D7打包成一字节发送再到main.c里封装出lcd_clear()、lcd_print(“Hello”)这种直觉式API。它不是教你“怎么写I2C”而是给你一个拧上螺丝就能转的轮子——插上ST-Link点下载上电屏幕立刻亮起“STM32 I2C LCD OK”连示波器都不用看波形。适合两类人一是刚学完《STM32固件库手册》第18章但卡在“怎么让液晶动起来”的新手二是赶项目进度、需要三天内把温湿度数据显示在屏幕上、没空啃Datasheet的嵌入式工程师。它不炫技不堆砌HAL库用最稳的StdPeriph库裸机思维把复杂性锁死在.c文件里对外只暴露三五个函数调用。2. 硬件连接与电路设计原理PCF8574的“位映射”是怎么定的2.1 物理接线一根线都不能错先说结论这套工程默认适配的是标准I2C LCD1602模块背面贴片PCF8574带可调电阻和背光LED而非裸LCD屏自己搭PCF8574电路。如果你用的是裸屏必须严格按以下方式焊接PCF8574型号务必是PCF8574T或PCF8574AT注意后缀T/AT决定I2C地址STM32侧PA9 → SDAI2C1_SDA注意不是PB7工程用I2C1SCL/SDA固定映射到PA9/PA10PA10 → SCLI2C1_SCLGND → 模块GND3.3V → 模块VCC⚠️关键LCD1602模块输入电压必须是3.3V若模块标称5V需确认其PCF8574是否支持3.3V逻辑电平否则加电平转换PCF8574侧以PCF8574T为例地址0x40P0 → RS寄存器选择0指令1数据P1 → RW读写0写1读本工程全程写操作故P1恒置0P2 → E使能高脉冲触发P4 → D4数据线高位4-bit模式只用D4-D7P5 → D5P6 → D6P7 → D7P3 → BL背光控制低电平点亮工程默认常亮故P3接地A0/A1/A2 → GND决定I2C地址为0x40若模块用PCF8574AT地址为0x38需修改lcd1602_i2c.c中#define PCF8574_ADDR提示很多淘宝模块背面丝印写着“I2C Address: 0x27”那是Arduino库默认值实际硬件地址由A0-A2焊点决定。用万用表测PCF8574的A0-A2引脚对地电阻全通即0x40全断即0x4E。地址错了I2C扫描永远找不到设备这是新手踩坑第一高频点。2.2 4-bit模式为什么不用8-bit省一半带宽还更稳LCD1602原生支持8-bit和4-bit两种数据传输模式。本工程强制采用4-bit模式原因有三PCF8574只有8个IO但LCD控制需要11根线8数据3控制。4-bit模式下D0-D3悬空只用D4-D7共4根数据线加上RS/RW/E共3根总计7根完美塞进PCF8574的8位输出P0-P2P4-P7P3留给背光。时序更宽松。8-bit模式每次写入需发送1字节数据1字节控制而4-bit模式虽需两次发送高4位低4位但PCF8574响应速度远快于LCD内部移位寄存器实测两次发送间隔加20μs延时即可稳定比8-bit模式对I2C时序精度要求更低。兼容性更强。几乎所有I2C LCD模块出厂即设为4-bit模式通过初始化序列强行切8-bit反而要额外发初始化指令增加出错概率。具体位映射关系如下此为工程硬编码逻辑不可更改PCF8574引脚功能电平含义工程中对应操作P0RS0指令1数据lcd_cmd()发0lcd_data()发1P1RW0写1读全程置0不读状态P2E上升沿触发先置1再置0模拟脉冲P4-P7D4-D7数据高4位ASCII字符高4位送P4-P7P3BL0背光亮1灭默认接地常亮注意P1恒置0意味着放弃“忙检测”Busy Flag读取。传统并口驱动会读BF位判断LCD是否忙但I2C速度慢读一次BF要发起始地址读命令数据停止耗时远超简单延时。工程采用“保守延时法”清屏指令后延时2ms其他指令延时50μs字符写入延时40μs。经实测在72MHz主频下这些延时值在-20℃~70℃环境均100%可靠比读BF更省代码、更省时间。2.3 电源与电平匹配3.3V MCU驱动5V LCD的生死线STM32F103是3.3V系统而经典LCD1602模块多为5V设计。这里存在两个致命风险PCF8574供电电压若模块PCF8574标称5V工作如NXP原厂PCF8574直接接3.3V会导致输出高电平仅约2.4V低于LCD的VIH阈值2.7VE脉冲无法被识别屏幕全黑。解决方案选用3.3V兼容型号如TI的TCA9534或确认模块PCF8574已内置LDO降压多数国产模块已优化。LCD VDD与VEELCD的VDD接3.3V没问题但对比度调节端VEE若接负压-5V3.3V MCU无法生成。工程默认VEE接可调电阻中点10KΩ一端接VDD一端接GND通过调节电阻改变对比度。实测发现当VDD3.3V时VEE需调至约0.8V才能获得最佳显示效果太低则字符淡太高则全黑。实操心得第一次调试失败90%概率是电源问题。建议用万用表量PCF8574的VCC引脚电压必须稳定在3.3V±0.1V再量P2E脚在写入时的电平跳变若高电平2.5V立刻换模块或加电平转换芯片TXB0108。别在软件里死磕硬件不对代码再完美也是黑屏。3. Keil工程结构解析每个.c文件到底干了什么3.1 整体架构三层解耦改动一处不影响全局工程采用经典的“硬件抽象层HAL→ 设备驱动层Driver→ 应用层App”分层Hardware层/Hardware/目录存放所有与MCU外设强相关的底层驱动包括i2c.cI2C1初始化与读写、delay.cSysTick精准延时、sys.c系统时钟配置。这些文件直接操作寄存器或StdPeriph库不依赖任何上层逻辑。Driver层/User/目录实现具体外设协议核心是lcd1602_i2c.c。它不关心I2C怎么发只接收1字节数据按位映射规则打包成PCF8574输出字节再调用i2c_write_byte()发出。同时封装lcd_init()发送4-bit初始化序列、lcd_cmd()发指令、lcd_data()发字符等API。App层/User/main.c纯业务逻辑调用Driver层API实现功能。例如lcd_print(Temp: )内部会循环调用lcd_data()发送每个ASCII码lcd_set_cursor(0,1)则计算DDRAM地址并调用lcd_cmd()。这种结构的好处是你想换I2C总线比如从I2C1切到I2C2只需改i2c.c里的RCC使能和GPIO重映射lcd1602_i2c.c和main.c一行代码不用动想换LCD型号如换成LCD2004只需重写lcd1602_i2c.c里的初始化序列和地址计算其他照旧。3.2 关键文件深度拆解i2c.cI2C1的“心脏起搏器”// 核心配置I2C时钟频率100kHz标准模式APB136MHz // 计算公式CCR (APB1_freq / (2 * I2C_freq)) 36000000 / (2 * 100000) 180 // 由于CCR需≥16取整为180对应实际频率36000000/(2*180)100kHz void I2C1_Init(void) { I2C_InitTypeDef I2C_InitStructure; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE); // 使能PA时钟 RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_I2C1, ENABLE); // 使能I2C1时钟 // PA9/PA10复用开漏输出上拉电阻4.7KΩ硬件必需 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9 | GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_OD; // 复用开漏 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); I2C_DeInit(I2C1); I2C_InitStructure.I2C_ClockSpeed 100000; // 100kHz I2C_InitStructure.I2C_Mode I2C_Mode_Normal; // 标准模式 I2C_InitStructure.I2C_DutyCycle I2C_DutyCycle_16_9; // 高电平时间占比16/25 I2C_InitStructure.I2C_OwnAddress1 0x00; // 主机模式无地址 I2C_InitStructure.I2C_AcknowledgedAddress I2C_AcknowledgedAddress_7bit; I2C_Init(I2C1, I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); // 启用I2C1 }注意I2C_DutyCycle_16_9是关键。标准模式下SCL高电平时间必须≥4μs低电平时间≥4.7μs。APB136MHz时设为16/9可精确满足。若误用I2C_DutyCycle_250%占空比在高速下可能因高电平过短导致从机无法采样表现为I2C扫描失败。lcd1602_i2c.cPCF8574的“翻译官”该文件核心是pcf8574_write()函数它把LCD的抽象操作如“写字符’A’”翻译成PCF8574能懂的1字节// 写入一字节到PCF8574自动处理E脉冲 static void pcf8574_write(uint8_t data) { uint8_t buf[1]; buf[0] data; // data格式bit7-bit0 P7-P0 i2c_write_byte(PCF8574_ADDR, buf, 1); // 调用i2c.c的底层写函数 // 模拟E脉冲先拉高延时再拉低 delay_us(1); // E高电平保持1μsLCD要求≥450ns buf[0] data ~0x04; // 清除P2位E0 i2c_write_byte(PCF8574_ADDR, buf, 1); delay_us(40); // E低电平后等待40μs确保LCD锁存 } // 发送LCD指令如0x01清屏 void lcd_cmd(uint8_t cmd) { uint8_t byte; // 高4位先送 byte ((cmd 0xF0) 1) | (00) | (01) | (12); // P0RS0, P1RW0, P2E1, P4-P7cmd高4位左移1位因P3是BL pcf8574_write(byte); delay_us(50); // 低4位再送 byte (((cmd 4) 0xF0) 1) | (00) | (01) | (12); pcf8574_write(byte); delay_us(50); }实操心得byte ((cmd 0xF0) 1)这行是精髓。因为PCF8574的P3被用作BL所以LCD的D4-D7实际接到P4-P7而P3空闲。为了把D4-D7的4位数据放到P4-P7需将cmd高4位左移1位D4→P4, D5→P5…P3自动为0背光亮。若你的模块P3接BL且需软件控制则此处要动态置位P3。main.c让屏幕“活起来”的最后一步int main(void) { SystemInit(); // 设置72MHz系统时钟HSEPLL delay_init(72); // SysTick初始化1us精度 I2C1_Init(); // I2C1初始化 lcd_init(); // LCD初始化发送0x33,0x32,0x28,0x0C,0x01等序列 while(1) { lcd_clear(); lcd_set_cursor(0,0); // 第一行第0列 lcd_print(STM32F103C8T6); lcd_set_cursor(0,1); // 第二行第0列 lcd_print(I2C LCD OK!); delay_ms(2000); // 显示实时计数演示动态刷新 static uint16_t cnt 0; lcd_clear(); lcd_set_cursor(0,0); lcd_print(Count: ); lcd_print_num(cnt); delay_ms(500); } }注意lcd_print_num()函数内部用itoa()转字符串但StdPeriph库无此函数工程自带简易版支持0-65535。若需更大数字需自行扩展。另外lcd_set_cursor(0,1)中的参数是行列行0第一行列0最左符合LCD1602的DDRAM地址映射0x00-0x0F为第一行0x40-0x4F为第二行。4. 初始化流程与LCD指令详解从上电到显示的每一步4.1 LCD1602的“冷启动”为什么必须发0x33、0x32、0x28LCD1602上电后处于未定义状态必须执行严格的初始化序列才能进入4-bit模式。本工程lcd_init()函数按顺序发送第一次发0x33- 屏幕此时是8-bit模式但只取高4位0x3所以实际收到0x03。- LCD内部状态机识别到0x03进入“等待第二次指令”状态。第二次发0x33- 同样只取高4位0x03LCD确认收到两次0x03切换到8-bit初始化流程。第三次发0x32- 取高4位0x03LCD识别到0x03后跟0x02明白用户要切4-bit模式于是将数据总线宽度设为4-bit。第四次发0x28- 此时已在4-bit模式发送0x28二进制00101000DL04-bit modeN12-line displayF05×8 dots屏幕正式启用双行显示。后续指令-0x0C显示开D1光标关C0闪烁关B0-0x01清屏CF1光标归位AC0-0x06地址自增I/D1无移屏S0提示这个序列不能省略任何一步也不能调换顺序。曾有学员删掉第一个0x33结果屏幕只亮不显字——因为LCD还在8-bit模式却收4-bit数据字节错位。用逻辑分析仪抓过波形确认每条指令都按序发出才是调试成功的前提。4.2 字符显示原理ASCII码如何变成像素点LCD1602不是“画图”而是“查表”。它内置一个CGROMCharacter Generator ROM存储了192个标准ASCII字符0x20–0x7F的5×8点阵图案。当你调用lcd_data(A)实际发生的是A的ASCII码是0x41LCD内部将0x41作为索引查CGROM表找到对应点阵0x00, 0x00, 0x00, 0x00, 0x00, // 第1行全空 0x00, 0x00, 0x10, 0x10, 0x00, // 第2行中间两竖 0x00, 0x10, 0x10, 0x10, 0x00, // 第3行三竖 0x10, 0x10, 0x10, 0x10, 0x00, // 第4行四竖 0x10, 0x10, 0x10, 0x10, 0x00, // 第5行四竖 0x10, 0x10, 0x10, 0x10, 0x00, // 第6行四竖 0x10, 0x10, 0x10, 0x10, 0x00, // 第7行四竖 0x00, 0x00, 0x00, 0x00, 0x00, // 第8行全空这8字节数据被送到LCD的显示RAMDDRAM按行列位置刷新屏幕。注意CGROM是只读的但LCD还提供CGRAMCustom Character RAM允许用户定义8个自定义字符如温度符号℃、箭头→。本工程未启用CGRAM但预留了lcd_create_char()接口若需添加只需向CGRAM地址0x40–0x7F写入8字节点阵数据即可。4.3 光标与地址为什么第二行起始地址是0x40LCD1602的DDRAMDisplay Data RAM地址空间为0x00–0x27第一行和0x40–0x67第二行共80字节但只用前32字节16×2。地址映射规则第一行0x00 → 第1列0x01 → 第2列…0x0F → 第16列第二行0x40 → 第1列0x41 → 第2列…0x4F → 第16列lcd_set_cursor(row, col)函数内部计算- 若row0addr 0x00 col- 若row1addr 0x40 col- 然后发送指令0x80 | addr0x80是设置DDRAM地址指令实操心得初学者常误以为lcd_set_cursor(1,0)应该发0x00结果光标跑到第一行末尾。记住口诀“第二行地址0x40不是0x10”。用lcd_print()时内部自动维护当前光标位置所以连续打印不会覆盖但手动调用lcd_set_cursor()后务必确认坐标正确。5. 常见问题排查与避坑指南那些让你熬夜到三点的“幽灵Bug”5.1 黑屏无反应I2C通信链路诊断树当下载程序后屏幕全黑按以下顺序排查90%问题在此检查项方法正常现象异常处理I2C物理连接用万用表测PA9/PA10对地电阻≈10KΩ上拉电阻存在若≈0Ω检查是否短路若∞Ω补焊4.7KΩ上拉电阻PCF8574地址在i2c.c中添加I2C扫描函数循环发送0x40–0x4F地址某地址返回ACK若全NACK检查A0-A2焊点或换模块若返回0x4E修改PCF8574_ADDR为0x4EPCF8574供电测PCF8574的VCC引脚稳定3.3V若为0V查电源线若为5V换3.3V模块E脉冲信号示波器测P2引脚PCF8574的P2有清晰方波周期≈100μs若无波形检查pcf8574_write()是否被调用若有但幅度2.5V换模块经验曾遇到一块模块PCF8574地址是0x4E但丝印写0x27折腾3小时才发现。建议首次使用时用逻辑分析仪抓I2C波形确认SCL/SDA有起始信号、地址0x40后有ACK再往下查。5.2 显示乱码或偏移时序与位映射校准现象字符显示为方块或乱码原因lcd_init()序列未正确执行LCD仍处于8-bit模式。解决确认lcd_init()中四条指令0x33,0x33,0x32,0x28严格按序发送且每条后有足够延时至少5ms。现象文字向右偏移1位或第二行显示在第一行原因lcd_set_cursor()计算错误或DDRAM地址指令0x80addr发送失败。解决在lcd_set_cursor()中加入调试打印用串口输出实际发送的addr值确认0x40是否被正确计算。现象部分字符显示正常部分为方块原因ASCII码超出CGROM范围如发送0x00–0x1F控制字符。解决lcd_print()内部增加过滤if(c 0x20 c 0x7E) lcd_data(c);5.3 背光不亮或闪烁BL引脚的隐藏陷阱PCF8574的P3接背光控制但不同模块设计差异巨大类型1P3BL低电平亮工程默认配置P3接地或写0。类型2P3BL高电平亮需修改pcf8574_write()中BL位为1。类型3P3悬空背光常亮模块内部已接电阻P3可不管。实操心得最稳妥方法是用万用表测模块背光LED正极对地电压。若P30时电压≈3.3V说明是类型2若P30时电压≈0V说明是类型1。别猜实测最准。5.4 延时不准导致显示异常SysTick配置陷阱delay_init(72)中fac_us72表示每微秒72个系统时钟周期。若SystemInit()未正确配置72MHz主频如误设为8MHz则delay_us(50)实际延时≈450μs远超LCD要求导致指令丢失。验证方法在main()中插入while(1) { GPIO_ResetBits(GPIOA, GPIO_Pin_0); // LED灭 delay_ms(500); GPIO_SetBits(GPIOA, GPIO_Pin_0); // LED亮 delay_ms(500); }若LED闪烁周期≠1秒说明系统时钟配置错误需检查system_stm32f10x.c中SetSysClockTo72()是否被调用。6. 扩展与优化从“能用”到“好用”的进阶路径6.1 添加滚动显示让长文本自动滑动LCD1602只有16字符宽但常需显示20字符以上的文本如温度值“Temp: 25.6°C”。工程可扩展lcd_scroll_left()函数// 将DDRAM内容整体左移1字符第一行0x00→0x0F第二行0x40→0x4F void lcd_scroll_left(void) { uint8_t i; uint8_t data; // 保存第一行最后一个字符 lcd_set_cursor(0,15); data lcd_read_data(); // 需实现lcd_read_data()读取当前光标处字符 // 左移第一行0x00←0x01, 0x01←0x02, ..., 0x0E←0x0F for(i0; i15; i) { lcd_set_cursor(0,i); lcd_data(lcd_read_data()); // 读出i1位置的字符写入i位置 } lcd_set_cursor(0,15); lcd_data(data); // 把原最后一个字符写回末尾 // 第二行同理... }注意lcd_read_data()需启用RW线P11并读取但本工程为简化已禁用RW。若需滚动建议硬件上将P1接入MCU GPIO软件中动态控制RW电平。6.2 支持中文字符用CGRAM加载自定义字模LCD1602原生不支持中文但可通过CGRAM定义8个自定义字符。例如定义“℃”符号// ℃符号点阵5×8 const uint8_t degree_icon[8] { 0b00000, 0b01110, 0b01010, 0b01110, 0b00000, 0b00000, 0b00000, 0b00000 }; void lcd_load_degree_icon(void) { uint8_t i; lcd_cmd(0x40); // 设置CGRAM地址为0x40 for(i0; i8; i) { lcd_data(degree_icon[i]); } } // 使用lcd_data(0); // 发送0x00显示第一个CGRAM字符℃提示CGRAM只能存8个字符每个5×8点阵占8字节共64字节。用在线字模生成工具如“LCD字模提取”网站导出点阵数组替换degree_icon即可。6.3 低功耗优化空闲时关闭背光在电池供电场景下背光耗电占比超80%。可在main.c中添加void lcd_backlight_off(void) { uint8_t buf 0x08; // P31假设高电平灭其他位保持 i2c_write_byte(PCF8574_ADDR, buf, 1); } void lcd_backlight_on(void) { uint8_t buf 0x00; // P30低电平亮 i2c_write_byte(PCF8574_ADDR, buf, 1); }然后在while(1)循环中根据传感器数据动态开关背光续航提升3倍以上。最后分享一个小技巧调试时把lcd_print(DEBUG:)放在main()开头再用串口打印关键变量值。当屏幕不亮至少知道程序跑到了哪——这是无数个深夜调试后悟出的保命法则。这套工程不是终点而是你嵌入式路上的第一块垫脚石。把它焊在板子上通电看到“OK”二字亮起的那一刻所有纠结的时序、算错的地址、虚焊的引脚都会变成嘴角上扬的理由。本文还有配套的精品资源点击获取简介这个资源包提供一套开箱即用的STM32F103C8T6驱动LCD1602液晶屏的完整实现方案通过I2C总线连接PCF8574扩展芯片来简化硬件接线。工程基于Keil MDK环境构建已配置好系统时钟、GPIO、I2C外设标准模式并封装了PCF8574通信协议和LCD1602基础操作函数支持清屏、光标移动、字符串显示等常用功能。代码采用模块化设计main.c负责逻辑调度i2c.c实现底层I2C读写lcd1602_i2c.c封装液晶控制指令与数据发送流程所有源文件均已编译通过生成.axf调试镜像和.bak项目备份适配YT32B1等常见STM32F1系列最小系统板。无需修改引脚定义或初始化参数上电即可验证I2C通信与字符显示效果适合初学者快速入门或项目中直接复用。本文还有配套的精品资源点击获取