1. 从点灯到画图:为什么STM32驱动TFT-LCD值得深挖
如果你玩过STM32,大概率是从点亮一颗LED开始的。点灯是单片机的“Hello World”,它让你第一次感受到程序对物理世界的控制。但很快,点亮一排流水灯或者数码管可能就无法满足你的表达欲了。你想显示一个汉字、一张图片,甚至一个动态的波形。这时,TFT-LCD彩色液晶屏就成了一个极具诱惑力的选择。它不再是简单的亮灭,而是一个拥有成千上万像素的彩色画布,能呈现的信息量和交互体验是指数级的提升。
我最初接触TFT-LCD时,以为它和1602字符液晶屏差不多,无非是初始化、发数据。结果一脚踩进去,发现水很深。从8080/6800并行总线、SPI串行接口到RGB接口,从ILI9341、ST7789、SSD1963等不同驱动芯片,再到显存管理、图片取模、汉字显示、触摸校准,每一个环节都有讲究。网上资料虽然多,但往往只给代码,不讲为什么。比如,为什么初始化序列里那一长串十六进制数要这么写?为什么刷屏会闪烁?如何高效显示一张图片而不卡顿?这些问题,我都是通过一个个项目、一次次调试才搞明白的。
所以,这篇文章不是简单的代码堆砌。我会结合我这些年做过的智能家居中控、工业仪表盘、手持设备等项目经验,把STM32驱动TFT-LCD的完整链路拆开揉碎了讲。从最基础的硬件连接和驱动芯片原理,到字库、图片的显示,再到高级点的多级菜单、动画和性能优化。目标是让你不仅能“抄”走代码,更能理解背后的逻辑,遇到新屏幕、新需求时能自己分析和解决。无论你是刚入门想做个炫酷的桌面时钟,还是正在为产品开发设计UI,这里的内容都能给你提供扎实的参考。
2. 硬件基石:接口、驱动芯片与显存模型详解
在写第一行代码之前,我们必须把硬件底层的事情搞清楚。这就像盖房子,地基打歪了,后面砌再漂亮的墙也容易塌。
2.1 三大接口选型:并行、串行与RGB
TFT-LCD的接口决定了数据传输的效率和复杂度,也直接影响了你对STM32引脚资源和性能的要求。
1. 8080/6800并行接口:这是早期也是最常见的驱动方式,尤其在驱动芯片如ILI9341、SSD1963上广泛应用。它模拟了老式MPU的读写时序。
- 信号线:主要包括数据线(D0-D15或D0-D7,支持8位或16位模式)、片选CS、写使能WR(8080)或读/写选择R/W(6800)、数据/命令选择RS(或叫D/CX)、复位RST。有些屏还有读使能RD。
- 工作原理:单片机通过GPIO模拟这些信号的时序。比如要写一个命令,先拉低CS选中屏幕,拉低RS表示接下来是命令,然后在数据线上放置命令码,最后产生一个WR的上升沿(8080)将数据“锁存”进屏幕。写数据同理,只是RS拉高。
- 优点:速度较快,控制直观,适合显示大量数据(如图片刷屏)。
- 缺点:占用大量GPIO引脚(8位模式至少需要10+个IO,16位模式更多),软件模拟时序稍有误差可能导致显示异常。
- 选型理由:当你的STM32引脚资源相对充裕,且对刷屏速度有要求时(比如更新全屏图片),并行接口是性价比很高的选择。我常用FSMC(灵活的静态存储器控制器)来驱动8080接口,这相当于硬件“代劳”了时序模拟,速度极快且不占用CPU,后面会详细讲。
2. SPI串行接口:在小尺寸屏幕(1.8寸、2.4寸)上非常流行,驱动芯片如ST7789、ILI9341也常支持。
- 信号线:仅需SCK(时钟)、MOSI(主机输出)、CS(片选)、DC(数据/命令),有的还有RST和背光控制。MISO(主机输入)通常用于读屏幕ID或显存数据,但很多应用只写不读,可以省掉。
- 工作原理:以字节或字为单位,通过时钟线一位一位地串行发送。DC线决定发送的是命令还是数据。
- 优点:占用引脚极少(最少4线),接线简单,非常适合引脚紧张的小型项目。
- 缺点:速度是硬伤。即使SPI跑到最高时钟,传输一屏320x240x16bit的数据量,耗时也比并行慢一个数量级,刷全屏会有明显延迟感。
- 选型理由:项目空间有限、引脚资源紧张,且对刷新率要求不高时(如显示静态菜单、参数),SPI是首选。我曾用SPI驱动一块2.4寸屏做温湿度监测显示,只更新局部数字,体验完全没问题。
3. RGB接口:这属于“高端”玩法,常见于较大尺寸、高分辨率的屏,或者像STM32F429、H7这类带LTDC(液晶显示控制器)的高性能单片机。
- 信号线:包括像素时钟PCLK、行同步HSYNC、场同步VSYNC、数据使能DE,以及RGB数据线(如R0-R5, G0-G5, B0-B5,共16位)。
- 工作原理:它更像电脑显卡输出信号。单片机内部的LTDC控制器自动按照视频时序,将帧缓冲区(Frame Buffer)中的像素数据,通过RGB数据线“流式”输出到屏幕,无需CPU干预。屏幕本身只负责接收像素流并点亮对应像素。
- 优点:速度极快,刷新率高,可实现流畅的动态效果,CPU占用率极低。
- 缺点:需要单片机硬件支持(LTDC),占用大量高速IO,且需要外扩RAM作为帧缓冲区(除非屏很小,单片机内部RAM够用)。
- 选型理由:当你需要做复杂的UI动画、视频播放或高分辨率显示时,必须选择带RGB接口的屏幕和配套的STM32型号。这是从“驱动屏幕”到“控制显示器”的质变。
注意:很多屏幕模块为了兼容,会同时引出多种接口的引脚(如SPI+8080),但一次只能使用一种。购买时一定要看清资料,接错线可能烧毁屏幕或单片机。
2.2 驱动芯片:屏幕背后的“翻译官”
我们单片机操作的不是液晶像素本身,而是驱动芯片。它内部集成了显存(GRAM),并负责将我们写入GRAM的数据,按照RGB格式和时序输出到液晶面板。
- ILI9341:经典中的经典,240x320分辨率支持者。指令集丰富,功能完善,资料极多。支持8080和SPI。它的初始化序列相对固定,网上例程一抓一大把,是入门首选。
- ST7789:在小尺寸屏领域和ILI9341分庭抗礼,常见于1.3寸、1.54寸、2.0寸屏。性能与ILI9341类似,但指令集略有不同。它的一个特点是常用来驱动方形屏(如240x240),在做圆形表盘UI时很有优势。
- SSD1963:常用于7寸、5寸等大屏,支持更高分辨率(如800x480)。它通常需要外接SDRAM作为显存,因为其内置GRAM不够大。驱动它更复杂,涉及到SDRAM的初始化配置。
- 其他:如ILI9488、HX8357等,原理相通,主要是初始化代码和部分指令的差异。
核心要点:拿到一块新屏幕,第一件事就是找到它的数据手册(Datasheet),找到驱动芯片型号和对应的初始化代码(Init Code)。厂家通常会提供。如果没有,就需要根据数据手册里的指令集,自己对照着类似芯片的例程修改。初始化代码的本质,就是通过一系列命令(如设置像素格式、扫描方向、伽马校正等)告诉驱动芯片如何工作。
2.3 显存(GRAM)模型与操作逻辑
这是理解所有显示操作的基础。你可以把驱动芯片内部的GRAM想象成一张画纸,每个像素点在GRAM中都有一个“格子”对应。这个格子的大小(比如16bit)决定了能存储的颜色信息。
- 坐标系统:屏幕有原点(通常初始化时可设置左上角或右下角为原点)。我们设置一个“窗口”(通过指令设置列地址和页地址),后续连续写入的数据就会自动填充到这个窗口内,地址自动递增,这大大提高了连续填充(如画矩形、刷图)的效率。
- 像素格式:最常见的是RGB565(16位色),即R占5位,G占6位,B占5位。也有RGB888(24位)、Monochrome(单色)等。必须在初始化时正确设置,否则颜色显示会错乱。例如,0xF800是红色,0x07E0是绿色,0x001F是蓝色。
- 写入逻辑:单片机通过接口,向驱动芯片发送“写GRAM”命令,然后连续发送像素数据。驱动芯片会按照当前设置的窗口和扫描方向,自动将数据填入GRAM的相应位置。“扫描方向”这个设置特别重要,它决定了原点位置以及写入数据时X和Y的递增方向,会直接影响你画线、显示字符的走向。我遇到过字符显示是倒着的情况,就是扫描方向没设对。
理解了这些硬件基础,我们才能写出稳定、高效的底层驱动函数,为上层应用搭建一个可靠的平台。
3. 软件驱动层:从GPIO模拟到FSMC硬件加速
硬件连接好后,我们需要用软件让STM32和屏幕“对话”。这一层是效率与稳定性的关键。
3.1 GPIO模拟时序:最基础也是最练手的方法
对于并行接口,在不使用FSMC时,我们需要用GPIO来模拟读写时序。这个过程能让你深刻理解时序图。
以8080写时序为例,我们通常需要实现几个基本函数:
// 假设已定义好相关GPIO引脚 #define LCD_CS_SET GPIO_SetBits(GPIOB, GPIO_Pin_12) #define LCD_CS_CLR GPIO_ResetBits(GPIOB, GPIO_Pin_12) // ... 类似定义 RS, WR, RD, D0-D15... void LCD_Write_Cmd(uint16_t cmd) { LCD_CS_CLR; // 选中设备 LCD_RS_CLR; // 写入命令模式 DATA_OUT(cmd); // 将命令码放到数据线上(需要实现一个函数,将16位数据同时设置到16个GPIO上) LCD_WR_CLR; // 产生写脉冲 Delay_us(1); // 短暂延时,满足tWRW时间要求 LCD_WR_SET; LCD_CS_SET; // 释放设备 } void LCD_Write_Data(uint16_t data) { LCD_CS_CLR; LCD_RS_SET; // 写入数据模式 DATA_OUT(data); LCD_WR_CLR; Delay_us(1); LCD_WR_SET; LCD_CS_SET; }关键点在于延时。数据手册里会对建立时间(tDS)、保持时间(tWH)等有ns级的要求。Delay_us(1)可能都太长了,在高速单片机里,几个NOP空指令可能就够了。太快可能导致屏幕识别不了,太慢则影响整体速度。最好的方法是使用示波器测量WR信号的脉宽,进行微调。这是我早期调试时必用的方法。
对于SPI接口,则简单很多,直接使用STM32的硬件SPI外设,配置好时钟极性和相位(CPOL/CPHA,需对照屏幕手册),然后调用HAL库的HAL_SPI_Transmit函数发送即可。DC引脚用普通GPIO控制。
3.2 FSMC:解放CPU的刷屏利器
如果你用的STM32有FSMC(F1/F4系列)或FMC(F7/H7系列),并且屏幕是8080并行接口,那么一定要用它。FSMC可以把一片外部存储器(包括LCD这种类似存储器的设备)映射到单片机的内存地址空间。操作屏幕就像读写内存一样简单。
配置步骤(以STM32F1的FSMC Bank1 NOR/PSRAM 4为例):
- 在CubeMX中使能FSMC,选择“LCD Interface”或“NOR Flash/PSRAM”,数据宽度选8位或16位(与屏幕匹配)。
- 配置时序参数:地址建立时间(ADDSET)、数据建立时间(DATAST)。这些参数需要参考屏幕数据手册的时序要求和FSMC时钟来计算。刚开始可以设置得保守一些(值大一些),确保能点亮,再逐步优化。
- 生成代码后,FSMC会将我们指定的一个基地址(如0x60000000)映射到屏幕。
- 定义两个宏,分别对应命令和数据的写入地址:
// 假设A16接LCD的RS引脚。当A16=0时写命令,A16=1时写数据。 #define LCD_CMD_ADDR ((uint32_t)0x60000000) // A16=0 #define LCD_DATA_ADDR ((uint32_t)0x60020000) // A16=1, 偏移是1<<16 - 此时,底层驱动函数简化到极致:
写入一个像素点,只需要向数据地址写入颜色值。连续填充一块区域,可以用void LCD_Write_Cmd(uint16_t cmd) { *(__IO uint16_t *)LCD_CMD_ADDR = cmd; } void LCD_Write_Data(uint16_t data) { *(__IO uint16_t *)LCD_DATA_ADDR = data; }for循环或者DMA。
FSMC的优势:
- 速度极快:硬件自动产生时序,速度接近总线时钟。
- CPU占用低:特别是结合DMA,CPU只需要设置好传输的源地址和目标地址(LCD_DATA_ADDR)和数量,就可以去干别的事了,DMA会自动搬运数据到屏幕,完成整块区域的填充。
- 代码简洁:无需复杂的时序模拟代码。
我做过一个对比测试,用GPIO模拟刷一张320x240的纯色图需要几百毫秒,而用FSMC+DMA只需要十几毫秒,体验是天壤之别。对于需要频繁更新显示的应用,FSMC几乎是必选项。
3.3 驱动函数封装:构建你的LCD“标准库”
有了底层的写命令/写数据函数,我们就可以封装出更易用的中间层函数。一个好的驱动库应该包含以下核心函数:
- 初始化函数
LCD_Init():包含GPIO/FSMC初始化、发送厂家提供的初始化序列、设置默认方向、清屏等。 - 设置窗口函数
LCD_SetWindow(x_start, y_start, x_end, y_end):这是高效操作的基础。内部通过命令设置列地址和页地址。 - 画点函数
LCD_DrawPoint(x, y, color):所有图形的基础。内部实现:SetWindow(x, y, x, y)->Write_Data(color)。 - 读点函数
LCD_ReadPoint(x, y):用于触摸屏校准、获取屏幕某点颜色。注意,需要将数据线配置为输入模式,并发送读GRAM命令,时序要严格按手册来。很多SPI屏不支持读,或者读起来很慢。 - 填充函数
LCD_Fill(x_start, y_start, x_end, y_end, color):清屏、画矩形块都靠它。优化核心:设置窗口后,用for循环或DMA连续写入颜色数据。 - 颜色转换宏/函数:如将8位R,G,B值合并成RGB565格式的宏
RGB565(r,g,b)。
把这些函数放在一个lcd.c/.h文件里,你的显示驱动层就搭建好了。上层应用只需要调用LCD_DrawPoint、LCD_Fill、LCD_SetWindow这些函数,完全不用关心底层是FSMC还是SPI。
4. 内容显示实战:字符、汉字与图片
驱动层准备好后,我们终于可以在屏幕上“作画”了。显示内容的核心是将“内容信息”转换为“像素颜色”,并放到正确的GRAM位置。
4.1 ASCII字符与自定义字模
显示英文和数字是最简单的。我们需要一个字模数组。
- 获取字模:通常使用一个固定大小(如8x16像素)的点阵来描述一个字符。你可以用取模软件(如PCtoLCD2002)生成,也可以使用现成的字库数组。每个字符对应一个数组,数组中的每个字节代表一列(或一行)的像素点(1亮0灭)。
- 显示函数:
void LCD_ShowChar(uint16_t x, uint16_t y, char chr, uint16_t color, uint16_t bg_color) { uint8_t i, j; uint8_t *pFont = &asc2_8x16[(chr - ' ') * 16]; // 找到字符在字库中的起始位置 LCD_SetWindow(x, y, x+7, y+15); // 8x16的区域 for(j=0; j<16; j++) { // 遍历16行 uint8_t lineData = pFont[j]; // 获取该行的点阵数据 for(i=0; i<8; i++) { // 遍历该行的8个像素 if(lineData & (0x80>>i)) { // 从最高位开始判断,该位是否为1 LCD_Write_Data(color); // 前景色 } else { LCD_Write_Data(bg_color); // 背景色 } } } } - 优化:上述函数每次画一个点都要设置一次窗口,效率低。更高效的做法是,先计算好一个字符所有像素的颜色,存入一个临时缓冲区(
uint16_t buffer[8*16]),然后调用一次LCD_Fill函数(内部是连续写)来显示整个字符。对于大量文本显示,效率提升显著。
4.2 汉字显示:从GBK到矢量
显示汉字原理同ASCII,但汉字数量庞大,不能全部放在单片机ROM里。
- 全字库方案:将整个GB2312或GBK字库(几千个汉字)以点阵形式(如16x16)存储在外部SPI Flash或SD卡中。显示时,根据汉字的GBK编码计算出在字库文件中的偏移地址,读取点阵数据,再像显示ASCII一样画出来。优点是灵活,缺点是读取外部存储有延迟,且字库占用空间大。
- 部分字库方案(推荐):在产品开发中,界面用到的汉字是有限的。我们可以用取模软件,只提取项目所需的汉字点阵,生成一个小的字库数组,直接编译进代码。这是最常用、最可靠的方法。我通常的做法是,在UI设计稿定稿后,统计所有用到的汉字,一次性取模。
- 矢量字库进阶:对于高端应用,可能需要平滑缩放字体。这就需要矢量字库(如TTF)。在STM32上使用矢量字库非常消耗资源,需要解析字体文件、进行栅格化(将轮廓转换为点阵),通常需要借助emWin、LVGL等GUI库来实现,单片机也需要较强的性能和足够的存储空间。
4.3 图片显示:BMP、JPG与资源管理
让屏幕显示图片,是项目变得“炫酷”的关键一步。
- BMP图片取模:最简单直接的方法。在电脑上用工具(如Img2Lcd)将BMP、PNG等图片转换为C语言数组。转换时需要选择正确的像素格式(RGB565)、扫描方向(通常为垂直扫描)。生成的数组就是一个直接的像素颜色数组。显示时,只需设置图片大小的窗口,然后将数组数据连续写入GRAM即可。这是刷图最快的方法,因为数据已经是屏幕能直接吃的格式。
- 缺点:图片数据体积大,非常占用Flash空间。一张320x240的RGB565图片,体积是3202402 = 150KB!这对于只有512KB Flash的STM32F103来说,放两三张就满了。
- JPG解码显示:为了节省空间,可以将图片存储为JPG格式。STM32上需要集成一个轻量级的JPEG解码库(如TinyJPEG、libJPEG)。解码过程需要一定的CPU时间和RAM(用于存放解码后的行数据)。流程是:从SD卡或Flash读取JPG文件 -> 解码库解码出一行RGB数据 -> 立即写入LCD -> 循环直到解码完成。
- 优点:大幅节省存储空间。
- 缺点:解码耗时长,动态显示会卡顿;需要额外的解码库,增加代码复杂度;解码过程需要一片缓冲区(几KB到几十KB)。
- 图片资源管理策略:
- 混合使用:对界面核心的、小的图标(如按钮、logo),用BMP取模,保证瞬间显示。对大的背景图,用JPG格式存放于SD卡,开机时加载一次。
- 压缩存储:对取模的BMP数组,可以尝试简单的压缩算法(如RLE),在显示时解压。但这会增加CPU开销。
- 外部存储器:将所有的图片资源存放在外部SPI Flash或SD卡中,单片机Flash只存放程序。这是产品化的标准做法。你需要实现一个简单的文件系统来管理这些资源。
- 缓存机制:如果内存允许,可以将最常用的、需要快速切换的图片(如选中/未选中状态图标)解码后缓存在内部或外部RAM中,避免重复解码。
在我的一个手持设备项目中,界面有数十个图标和一张背景图。我将所有图标(16x16, 32x32)取模后存入外部SPI Flash的一个连续区域,并制作了一个索引表。背景图则用JPG格式存于SD卡。开机后,先将背景图解码显示,然后将常用图标加载到内部RAM的缓存区。这样既保证了启动速度,又实现了界面的快速响应。
5. 进阶应用与性能优化
当基础显示功能实现后,我们会追求更流畅的体验和更复杂的功能。
5.1 多级菜单与界面框架
复杂的设备往往需要多级菜单。一个清晰、易维护的菜单框架至关重要。
- 状态机模型:这是最经典的实现方式。将每个菜单页定义为一个“状态”(或“界面”)。用一个全局变量
current_menu记录当前状态。 - 菜单项结构体:
typedef struct { char *text; // 菜单显示文本 void (*show_func)(void); // 该菜单的显示函数 void (*key_func)(uint8_t key); // 该菜单下的按键处理函数 struct MenuItem *parent; // 父菜单指针 struct MenuItem *child; // 子菜单链表头 struct MenuItem *next; // 兄弟菜单(同级下一个) } MenuItem; - 工作流程:
- 根据
current_menu,调用对应的show_func绘制界面。 - 有按键事件时,调用当前菜单的
key_func。 - 在
key_func中,根据按键(如上下、确认、返回)更新current_menu,或执行具体功能。 - 返回上一步,重绘界面。
- 根据
- 优点:结构清晰,添加新菜单只需定义新的结构体并链接到树中,显示和逻辑分离。
- 界面绘制优化:避免每次刷新都重绘整个屏幕。只绘制变化的部分(局部刷新)。例如,在列表菜单中,只刷新选中的那一行和之前选中的那一行。
5.2 动画与滑动效果
简单的动画,如进度条、滚动的文字,可以通过定时器中断来实现。
- 定时器刷新:开启一个定时器(如10ms中断一次)。在中断服务函数中,更新动画的变量(如位置、透明度),并设置一个“重绘标志”。
- 主循环检查:在主循环中检查“重绘标志”,如果置位,则调用绘图函数,并清除标志。切记,绝不要在中断服务函数中进行复杂的绘图操作!中断中只做标记和变量更新。
- 滑动效果:以列表滑动为例。假设要滑动的距离是
target_y,当前位置是current_y。在定时中断中,让current_y以一定步长(或使用缓动函数)向target_y靠近。每次更新后,重新计算列表每一项的绘制位置并局部刷新。这需要双缓冲或局部擦除重绘来避免闪烁。
5.3 性能瓶颈分析与优化策略
当界面复杂或刷新频繁时,你可能会遇到卡顿、闪烁问题。以下是我总结的排查和优化路径:
- 测量帧率:在定时中断中翻转一个测试GPIO引脚,用逻辑分析仪或示波器测量其频率,可以粗略估算刷新的最大帧率。
- 定位耗时操作:
- 刷全屏:这是最耗时的。用
LCD_Fill填充全屏,记录时间。这代表了你的驱动接口的极限速度。 - 复杂绘图:画圆、画线涉及浮点运算(
sqrt,sin,cos),在无FPU的单片机上极慢。尽量使用整数运算或查表法。例如画圆可以用Bresenham算法,全是整数加减和比较。 - 外部存储读取:从SD卡或SPI Flash读取图片、字库数据,速度受限于总线速度和存储器件本身。考虑缓存机制。
- 刷全屏:这是最耗时的。用
- 优化手段:
- 使用DMA:对于FSMC接口,用DMA来传输大块数据(如图片数组、填充矩形),能极大解放CPU。
- 减少总线操作:将多次连续的
LCD_Write_Data调用合并为一次传输。例如,画一个矩形,先设置窗口,然后一次性发送所有像素数据。 - 局部刷新:只更新屏幕上变化的部分区域,而不是整个屏幕。
- 使用硬件加速:如果单片机有LTDC和DMA2D(图形加速器),一定要用上。DMA2D可以硬件完成颜色格式转换、图像混合(Alpha Blending)、矩形填充等,速度是软件实现的数十倍。
- 优化算法:用查表代替实时计算,用整数运算代替浮点。
- 双缓冲(高级):在内存中开辟两块和屏幕一样大的缓冲区(Frame Buffer)。所有的绘图操作都在“后台缓冲区”进行。完成一帧的绘制后,通过DMA一次性将整个后台缓冲区拷贝到LCD的GRAM(或通过LTDC切换缓冲区)。这能彻底消除因逐点绘制而产生的屏幕撕裂或闪烁感,但对内存要求极高(320x240x2x2=300KB!),通常需要外扩RAM。
我曾经优化过一个实时波形显示的程序。最初用LCD_DrawPoint画线,波形刷新率不到10Hz,且CPU占用率很高。后来做了如下优化:1) 将波形数据点先在内存数组中用Bresenham算法计算好路径;2) 将需要更新的像素点坐标和颜色整理成连续数组;3) 使用FSMC+DMA一次性传输这个数组。最终刷新率提升到了50Hz以上,CPU占用率大幅下降。
驱动一块TFT-LCD,从点亮到做出流畅的UI,是一个典型的“深入浅出”的过程。它要求你不仅懂软件编程,还要理解硬件时序、内存管理、图形学基础,甚至产品思维。这个过程里踩过的每一个坑,解决的每一个性能瓶颈,都会让你对嵌入式系统的理解加深一层。我最深的体会是,不要只满足于“它能亮”。多问一句“为什么这样初始化?”“怎么才能更快?”,去读驱动芯片的数据手册,去测量波形,去分析代码耗时,你会收获远超项目本身的能力。当你能够随心所欲地在屏幕上呈现任何你想表达的内容时,那种创造力和控制感,正是嵌入式开发最吸引人的地方之一。