资讯详情 STM32驱动DS1302全程笔记:GPIO模拟时序与BCD码详解
📅 2026/10/13 5:28:11
做带时钟功能的小项目时很多人第一反应就是上DS1302。这颗芯片大概是学习板上出场率最高的RTC芯片之一三线接口、外围只需一颗晶振代码量也就几十行。我这次把STM32驱动DS1302的完整过程整理成学习笔记从选型思路、寄存器细节、GPIO模拟时序到实际调试中踩过的坑一次说清楚。如果你正准备做桌面时钟、数据记录器或者刚开始接触RTC芯片这篇内容应该能帮你少走不少弯路。先说结论DS1302算不上最先进的时间芯片但它胜在便宜、易买、例程多、逻辑直白。用STM32的普通GPIO就能把通信时序模拟出来整个过程能让你把“寄存器读写”“BCD码换算”“时序图”这些概念全部落到实处。这篇文章按照一个完整的个人学习项目来写尽量做到给你可以直接抄作业的代码也给你遇到问题时的排查思路。1. 先说选型为什么STM32片内有RTC还要外挂DS13021.1 常见时钟方案怎么选先回答一个我经常被问的问题STM32片内明明就有RTC外设为什么还要外挂DS1302答案很简单片内RTC的门槛比很多人想象的高。STM32的RTC有独立的备份域和电源管理需要配置LSE外部低速晶振、RTCCLK分频、闹钟和唤醒逻辑虽然用熟了也还好但如果你是第一次接触很容易卡在LSE起振、备份域读写保护这些环节上。而且片内RTC的日期日历模式需要手动设置初始时间不做BKP寄存器保存配置的话一次复位时间就丢了。外挂DS1302则把问题简化成三步连三根线、读写寄存器、换算BCD码。它是独立的时钟芯片只要给个电池或者电容单片机上电、复位、重新烧录程序都不影响走时。对学习来说这种“看得见、摸得着”的外设比片内模块友好得多。市面上常见的时钟方案也不止DS1302一个我把自己对比过的几个写出来方便你根据项目需求选方案典型成本精度外部依赖适合场景DS1302低一般靠外界晶振32.768kHz晶振备份电源学习项目、低成本时钟、温湿度记录DS3231中高高内置温补晶振I2C接口需要走时准确的设备、精密仪器STM32片内RTC0元看LSE晶振质量配置复杂掉电需靠VBAT不需要外挂芯片的产品开发网络/北斗授时高很高依赖网络/信号需要自动校时的设备DS1302的优势是协议简单、资料多、外围少劣势是走时精度完全取决于外部32.768kHz晶振的质量和PCB布局受温度影响有累积误差一个月误差个几十秒很常见。如果项目对精度要求很高直接选DS3231如果只是做个桌面时钟、定时器、日志打点DS1302完全够用。1.2 DS1302的供电和走时原理DS1302有个很有特色的双电源设计VCC2接主电源VCC1接备份电源。正常工作时芯片从VCC2取电VCC2掉电后自动切换到VCC1。这个切换是芯片内部完成的不需要外部二极管和切换电路所以只需要在VCC1上接一个纽扣电池或者法拉电容就能实现掉电走时。不过要注意VCC1并不应该随便接。如果用的是不可充电的CR2032纽扣电池千万别往涓流充电寄存器写数据否则等于给电池充电轻则鼓包重则有安全隐患。只有用可充电电池或超级电容才去配置0x90寄存器的涓流充电参数。芯片内部的时间计数器每秒递增按BCD码格式保存在对应的寄存器里。所以驱动DS1302本质上就是两件事一是按协议读写这些寄存器二是把人类习惯的十进制时间和芯片里的BCD码做互换。理解不透这两点后面写代码很容易拿到一组“看起来不对”的数据。2. DS1302寄存器与BCD码看懂这些才能正确读写时间2.1 芯片引脚与最小电路DS1302最常见的封装是8脚DIP和SOP引脚定义不复杂但初次使用容易在晶振和备份电源上栽跟头。标准最小电路如下引脚接法说明VCC23.3V或5V电源主电源STM32项目接3.3V即可VCC1纽扣电池或法拉电容正极备份电源掉电走时靠它GND地与STM32共地X1 X232.768kHz晶振两端晶振直接跨接两脚即可CEMCU GPIO片选/使能SCLKMCU GPIO串行时钟IOMCU GPIO数据输入/输出晶振选型上32.768kHz是RTC的标配频率不需要太高的精度但一定要买正规渠道的晶振某些廉价晶振甚至直接不起振。晶振的两个引脚之间不需要再接外部负载电容不过PCB上走线要短两条线尽量等长不要跨过高频信号线。我第一次画板子时忽略了X1、X2到芯片之间的距离结果板子出来后有一半的芯片时间不走最后排查发现就是晶振走线太长、旁边又有高速数字信号干扰。飞线解决后一切正常这个问题后面在调试章节里细说。2.2 核心寄存器地图DS1302内部寄存器不多控制时间的也就是秒、分、时、日、月、星期、年这几个。每个寄存器有两个地址一个用于写、一个用于读因为命令字节最低位就是读写标志位。常用寄存器的读写地址如下寄存器写地址读地址取值范围说明秒0x800x810-59bit7是时钟暂停CH位分0x820x830-59常规BCD时0x840x850-23或1-1224小时制直接写日0x860x871-31常规BCD月0x880x891-12常规BCD星期0x8A0x8B1-7注意不同例程映射不同年0x8C0x8D0-99只存后两位如2025存25写保护0x8E0x8F0x00/0x80bit71时禁止写时间涓流充电0x900x91特殊位配置默认0x00不充电写保护寄存器特别关键。很多初学者写完时间后马上读发现数据没写进去大概率就是写保护位没关。我在代码里统一使用“写之前关保护、写之后开保护”的套路这样既保证程序运行时不误改时间又避免每次都忘。2.3 命令字节与BCD码换算DS1302的通信每次都是从发送一个命令字节开始的。命令字节的位定义是最高位固定为1bit6固定为0bit5表示操作时钟寄存器还是RAMbit4到bit1是寄存器地址bit0是读写标志1为读、0为写。也就是说命令字节最低位是1就可以读时间是0就可以写时间地址表和上表一一对应。比如想读秒就发0x81想写秒就发0x80。第二个必要概念是BCD码。芯片内部保存的时间不是十进制数字而是BCD码格式。比如秒寄存器里保存0x59实际上代表十进制59保存0x23代表十进制23。如果你直接把寄存器原始值打印出来会看到0x59、0x23这种“看起来就是时间但又不正常”的数字其实就是忘了转换。换算函数很简单// BCD转十进制如0x59 - 59 uint8_t BCD2Hex(uint8_t bcd) { return (bcd 4) * 10 (bcd 0x0F); } // 十进制转BCD如59 - 0x59 uint8_t Hex2BCD(uint8_t hex) { return ((hex / 10) 4) | (hex % 10); }记住这两个函数后面所有读写逻辑都围绕它们展开。3. 三线串行时序拆解GPIO模拟比硬件SPI更省心3.1 DS1302的“伪SPI”协议DS1302用的是三线串行接口名字和SPI很像但细节上并不完全兼容。真正的SPI有MOSI和MISO两条独立数据线数据在时钟沿被同时收发DS1302只有一根双向IO线发送和接收必须分时复用且先发送命令字节再根据命令决定继续发送数据还是从IO上读取数据。它的时序大致是这个流程CE拉高表示开始通信SCLK产生脉冲每一位数据都在SCLK的上升沿被写入芯片读完或写完命令字节后如果操作是读则切换IO方向为输入在SCLK的下降沿之后从IO采样数据最后CE拉低结束通信。其实这个协议更接近常见的1-Wire或者自定义半双工协议所以网上很多驱动都直接GPIO模拟很少用硬件SPI原因就在这。硬件SPI虽然也能配置CPOL和CPHA但要求数据在固定的相位采样DS1302读和写用的时钟相位不一样中途还要把同一根线从输出切成输入用硬件SPI硬凑反而麻烦增益不大还容易出兼容问题。3.2 读写一个字节的底层操作我写了一个底层字节函数逻辑就是按照上面的时序逐位收发。写字节时先把SCLK拉低再把数据放到IO上然后拉高SCLK数据就被芯片锁存循环8次一个字节就写完。读字节时把IO切换到输入模式拉低SCLK后延时片刻采样IO再拉高SCLK准备下一位。这里有一个初学者容易忽略的细节DS1302的数据是低位先发。如果你习惯SPI的高位先发直接套用就会发现读回来的数据位序是反的。我刚开始自己写驱动时就踩了这个坑读回来的秒值乱七八糟后来对着时序图逐位推才发现是顺序问题。另一个细节是通信速率。DS1302的时钟频率上限不高我模拟GPIO翻转时没有刻意追求速度每个位之间加了一点延时保证芯片在信号稳定后才锁存。实测整个驱动稳定工作在几十kHz的比特率读取一次完整时间也就几十微秒完全够用。3.3 为什么建议用GPIO模拟而不是硬件SPI可能有朋友觉得STM32有硬件SPI外设不用白不用。但DS1302这个芯片设计成半双工三线和标准SPI在物理层就有差异硬件SPI的MISO和MOSI是分开的DS1302只有一根IO硬件SPI的时序相位是固定的DS1302的写和读分别工作在两个相位硬件SPI空闲时可以随意收发DS1302必须严格先命令、后数据中间还要切IO方向。强行用硬件SPI不是不行但需要在字节级别手动切GPIO模式还要处理位序问题折腾半天未必比GPIO模拟简单。GPIO模拟的代码量本来就不大而且可移植性好——今天用STM32明天换51单片机核心逻辑不用改。所以我个人强烈推荐用GPIO模拟学习价值也更高。4. STM32驱动完整实现接线、代码与验证4.1 CubeMX配置和硬件接线我使用的是任意一块STM32F103的普通开发板因为驱动只占用三个GPIO不用特殊外设所以用CubeMX建工程时只需要把对应的引脚初始化为GPIO输出再把其中IO引脚配置为可以动态切换方向就可以了。接线方案如下DS1302引脚STM32引脚备注VCC23.3V开发板电源输出VCC1法拉电容或电池正极可选不加也能用GNDGND共地CEPB0片选SCLKPB1时钟IOPB2数据需要动态切方向在CubeMX中PB0、PB1、PB2都配置成GPIO_Output速度选Low即可。PB2在代码初始化时会根据当前操作动态切换成输入或输出所以CubeMX里先配成输出模式并不影响后续切换。VF1如果不接电池芯片在断电后时间会清零学习阶段完全可以不接。如果想体验掉电保持在VCC1和GND之间接一个大容量法拉电容比如1F/5.5V断电之后至少能撑好几个小时。4.2 底层字节读写函数下面的代码是整个驱动的地基。建议先看写字节再看读字节理解每一行对应时序图的哪一个阶段。#define DS1302_CE_PORT GPIOB #define DS1302_CE_PIN GPIO_PIN_0 #define DS1302_SCLK_PORT GPIOB #define DS1302_SCLK_PIN GPIO_PIN_1 #define DS1302_IO_PORT GPIOB #define DS1302_IO_PIN GPIO_PIN_2 #define DS1302_CE_H() HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_SET) #define DS1302_CE_L() HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_RESET) #define DS1302_SCLK_H() HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET) #define DS1302_SCLK_L() HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET) static void DS1302_IO_OUT(void) { GPIO_InitTypeDef gpio {0}; gpio.Pin DS1302_IO_PIN; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DS1302_IO_PORT, gpio); } static void DS1302_IO_IN(void) { GPIO_InitTypeDef gpio {0}; gpio.Pin DS1302_IO_PIN; gpio.Mode GPIO_MODE_INPUT; gpio.Pull GPIO_PULLUP; HAL_GPIO_Init(DS1302_IO_PORT, gpio); } static void DS1302_WriteByte(uint8_t dat) { uint8_t i; DS1302_IO_OUT(); for (i 0; i 8; i) { DS1302_SCLK_L(); if (dat 0x01) HAL_GPIO_WritePin(DS1302_IO_PORT, DS1302_IO_PIN, GPIO_PIN_SET); else HAL_GPIO_WritePin(DS1302_IO_PORT, DS1302_IO_PIN, GPIO_PIN_RESET); DS1302_SCLK_H(); dat 1; } } static uint8_t DS1302_ReadByte(void) { uint8_t i, dat 0; DS1302_IO_IN(); for (i 0; i 8; i) { dat 1; DS1302_SCLK_L(); if (HAL_GPIO_ReadPin(DS1302_IO_PORT, DS1302_IO_PIN)) dat | 0x80; DS1302_SCLK_H(); } return dat; }写字节时每一次SCLK上升沿锁存一位所以先把SCLK拉低然后改变IO电平最后拉高这个顺序不能乱。如果把顺序反过来数据还没稳定芯片就采样了写进去的位就是错的。读字节时IO要提前切成输入SCLK拉低后DS1302会把当前位输出到IO上延时片刻后读取。读到的位通过右移的方式累积到dat里最终得到完整的字节。这里之所以读到的最高位最后被移到了最低位是因为DS1302本身就是低位先发算法上通过8次右移把LSB留在了bit0。4.3 寄存器读写封装有了字节函数寄存器读写就简单了。每次操作前先拉高CE发送命令字节然后根据读写方向继续操作结束之后拉低CE。void DS1302_WriteReg(uint8_t addr, uint8_t dat) { DS1302_CE_H(); DS1302_WriteByte(addr); // 写地址bit0为0 DS1302_WriteByte(dat); DS1302_CE_L(); } uint8_t DS1302_ReadReg(uint8_t addr) { uint8_t dat; DS1302_CE_H(); DS1302_WriteByte(addr | 0x01); // 读地址bit0置1 dat DS1302_ReadByte(); DS1302_CE_L(); return dat; }注意写地址和读地址的区别调用DS1302_ReadReg(0x81)读秒调用DS1302_WriteReg(0x80, v)写秒。也可以把地址参数统一成写地址在读函数内部加1两种风格都有人用自己保持一致就好。4.4 时间结构体与更新函数驱动里定义了一个时间结构体方便在程序里传参和显示typedef struct { uint8_t year; // 0-99代表2000-2099 uint8_t month; // 1-12 uint8_t day; // 1-31 uint8_t week; // 1-7注意映射自己定义 uint8_t hour; // 0-23 uint8_t minute; // 0-59 uint8_t second; // 0-59 } DS1302_Time_t;设置时间的函数核心是先关写保护依次写入各寄存器最后开写保护。写入时把十进制时间转成BCD码即可。重点强调秒寄存器它的bit7是时钟暂停位CH芯片上电后可能是1此时秒不走。在写入秒数据时只要保证写入值不超过0x59且bit7为0就会自动解除暂停状态。void DS1302_SetTime(const DS1302_Time_t *t) { DS1302_WriteReg(0x8E, 0x00); // 关闭写保护 DS1302_WriteReg(0x80, Hex2BCD(t-second)); // 秒bit7置0自动开启走时 DS1302_WriteReg(0x82, Hex2BCD(t-minute)); DS1302_WriteReg(0x84, Hex2BCD(t-hour)); // 24小时制 DS1302_WriteReg(0x86, Hex2BCD(t-day)); DS1302_WriteReg(0x88, Hex2BCD(t-month)); DS1302_WriteReg(0x8A, t-week); // 星期本身不是BCD DS1302_WriteReg(0x8C, Hex2BCD(t-year)); DS1302_WriteReg(0x8E, 0x80); // 开启写保护 }读取时间正好是逆过程从各寄存器读出再转回十进制。小时寄存器可能包含12/24小时制标志位我习惯在读取时用掩码0x3F只保留低6位避免误读到标志位。void DS1302_GetTime(DS1302_Time_t *t) { t-second BCD2Hex(DS1302_ReadReg(0x81) 0x7F); t-minute BCD2Hex(DS1302_ReadReg(0x83) 0x7F); t-hour BCD2Hex(DS1302_ReadReg(0x85) 0x3F); t-day BCD2Hex(DS1302_ReadReg(0x87) 0x3F); t-month BCD2Hex(DS1302_ReadReg(0x89) 0x1F); t-week DS1302_ReadReg(0x8B) 0x07; t-year BCD2Hex(DS1302_ReadReg(0x8D)); }初始化函数建议放在main函数里最先调用主要做两件事关闭写保护、清除秒寄存器的暂停位。如果是新芯片可能还需要手动设置一次初始时间。void DS1302_Init(void) { // 清掉秒寄存器的CH位让芯片开始走时 uint8_t sec DS1302_ReadReg(0x81); DS1302_WriteReg(0x80, sec 0x7F); }4.5 突发模式一次读写全部时间逐个读7个寄存器虽然直观但每次通信都要发命令字节、切IO方向、来回折腾效率一般。DS1302支持突发模式用一条命令连续读写全部7个时间寄存器数据顺序固定为秒、分、时、日、月、星期、年。突发读命令是0xBF突发写命令是0xBE。我封装了两个函数实际项目中优先使用它们void DS1302_GetTimeBurst(DS1302_Time_t *t) { uint8_t buf[7]; uint8_t i; DS1302_CE_H(); DS1302_WriteByte(0xBF); for (i 0; i 7; i) buf[i] DS1302_ReadByte(); DS1302_CE_L(); t-second BCD2Hex(buf[0] 0x7F); t-minute BCD2Hex(buf[1] 0x7F); t-hour BCD2Hex(buf[2] 0x3F); t-day BCD2Hex(buf[3] 0x3F); t-month BCD2Hex(buf[4] 0x1F); t-week buf[5] 0x07; t-year BCD2Hex(buf[6]); }突发模式的好处不仅是速度快更重要的是保证了一次读取的时间点是一致的。逐字节读取时可能刚读完秒寄存器下一秒才轮到时寄存器导致拿到的一组时间跨了两秒。突发模式在CE有效期间一口气把所有寄存器数据锁存读取数据一致性明显更好。4.6 主程序调用示例整个驱动在主函数里调用非常简单初始化一次设置一次初始时间之后循环读取即可。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); DS1302_Init(); DS1302_Time_t time { .year 25, .month 1, .day 5, .week 7, .hour 23, .minute 58, .second 0 }; DS1302_SetTime(time); while (1) { DS1302_Time_t now; DS1302_GetTimeBurst(now); printf(20%02d-%02d-%02d %02d:%02d:%02d week%d\r\n, now.year, now.month, now.day, now.hour, now.minute, now.second, now.week); HAL_Delay(1000); } }如果串口输出正常你会看到时间每秒刷新一次。这里要注意星期映射不同例程对星期数字的定义不一样我习惯让1对应星期日2对应星期一以此类推。你完全可以根据自己的显示需求调整只要设置和读取使用同一套映射就行。5. 调试实录上电不走、读回全FF、时间乱跳怎么查5.1 上电后时间一直不走现象就是读回来的秒值一直不变或者秒长时间保持0。最可能的原因有两个秒寄存器bit7的CH位没有被清除或者晶振没有起振。先排除CH位。用调试器或者串口打印读回来的0x81寄存器原始值如果bit7是1芯片就处于暂停走时状态。很多驱动初始化时只写了写保护寄存器没有管秒寄存器芯片上电后就不会自动开始跑。解决办法就是在初始化里把秒寄存器最高位清零。如果CH位已经清零但时间还是不走下一个怀疑对象就是晶振。32.768kHz晶振不起振的原因非常多焊接时引脚过长、虚焊X1和X2走线跨过其他信号线晶振外壳碰到旁边铜箔都会导致起振困难。手头有示波器可以测X1脚的波形正常能看到32.768kHz的正弦波不漏波没有示波器就用笨办法断电后把晶振重新焊一遍尽量剪短引脚清理焊盘周围的助焊剂很多时候问题就解决了。另外还有一个很容易忽略的现象时间并不是一直不走而是每走一两秒就卡住过几秒又跳一下。这种多半是晶振振幅太弱或者供电有干扰导致内部计数器丢脉冲。重点检查晶振焊接质量和VCC1、VCC2的电源滤波。5.2 读回来的数据全是0xFF或0x00读回全0xFF通常说明通信根本没建立起来芯片没有响应。先量CE、SCLK、IO三根线的电压确认GPIO配置正确。我之前有一次代码里把CE初始化为输入模式结果CE一直悬空芯片根本没进入通信状态读回的自然全是无效数据。读回全0x00则可能是IO方向切换有问题。DS1302读取数据时IO需要被芯片驱动如果MCU仍然保持输出模式双方就会打架导致读到的全是0。检查代码里读字节之前是否确实调用了DS1302_IO_IN()以及输入模式的上拉是否开启。部分开发板的IO引脚外部没有上拉电阻建议在HAL初始化时打开GPIO_PULLUP保证IO悬空时不会读到不确定电平。5.3 设置的时间写不进去还是老时间这个现象基本就是写保护寄存器引起的。DS1302的写保护位是0x8E寄存器的bit7为1时所有时间寄存器的写入都被忽略。确保写入时间之前调用DS1302_WriteReg(0x8E, 0x00)写完之后再把写保护位恢复为1。有些例程为了省事全程关闭写保护也可以工作只是万一程序跑飞时容易误改时间我习惯写完就恢复。还要注意年份寄存器的取值范围是0到99只存后两位。如果你把2025年整个塞进年份寄存器BCD转换出来的0x2025实际只取了低8位写入结果完全不对。设置年份时传25而不是2025显示时再补上20前缀。5.4 时间正常走但偶尔跳变或丢秒偶尔跳变的原因比完全不走的排查范围更大最常见的是电源问题。DS1302在供电电压不稳时内部计数器可能产生错误翻转。给芯片的VCC2加一个0.1uF和10uF电容放在尽量靠近芯片引脚的位置可以减少大部分跳变。第二种常见原因是通信期间产生了干扰。GPIO模拟通信时如果程序里有中断服务函数恰好在这几微秒内执行或者调试器的SWD接口频繁访问芯片都可能导致时序被拉长或者时钟沿抖动。我调试时遇到过一次是在串口printf和读取时间同时进行时时间偶尔会跳后来把读取操作放到中断外、或者给通信过程加临界区保护现象就消失了。第三种原因发生在多次读取但没有正确结束通信。CE在通信结束后必须拉低如果CE一直保持高电平芯片会认为通信没有结束下一次通信的初始状态就乱了。检查所有读写函数的出口是否都有CE_L()。为了便于排查我把这些现象整理成速查表现象优先排查方向验证手段秒不走秒寄存器CH位、晶振读0x81看bit7示波器测X1波形读回全0xFF接线、GPIO模式、CE上拉万用表量引脚电平读回全0x00IO输入方向切换、上拉单步调试观察IO方向写不进去写保护位读0x8F值确认bit7状态时间跳变供电纹波、晶振频率偏差换稳压源测试打印每次秒值6. 封装成自己的时间模块后续还能怎么玩把驱动跑通只是第一步我个人更推荐再往上封装一层让它变成一个真正可复用的时间模块。比如定义自己的星期映射函数把数字星期转成“周一”“周二”这样的字符串封装一个“获取时间字符串”的函数直接输出“2025-01-05 23:58:00”再加一个闹钟比较函数在main循环里做定时任务。这些封装不涉及新的硬件知识但对后续项目帮助很大。实际项目中我给DS1302配了一块很便宜的OLED屏幕做了一个桌面时钟。OLED通过I2C挂在另外两个引脚上主循环每秒读一次DS1302刷新显示。整个系统低功耗模式下也能走时因为DS1302由法拉电容独立供电MCU进入停止模式完全不影响计时。后来我又在VCC1端加了一个涓流充电配置超级电容由VCC2通过芯片内部电路慢慢充电实现了真正不需要更换电池的永久走时。如果想给这个模块加精度可以做一个简单的软件校准记录24小时内芯片快了还是慢了几秒然后在设置时间时微调秒值或者定期用串口同步一次。虽然不如DS3231的温度补偿那么先进但对个人项目来说完全够用。做这个项目时我最深的体会是DS1302本身很简单但把时序、BCD、寄存器这些都吃透后面玩任何带串行接口的芯片都会轻松很多。很多新手卡在“代码抄了一大段但不知道每一步在干什么”原因就是没有把时序图和代码逐行对应起来。建议你拿到驱动后对照本文的时序说明把读字节和写字节的两个循环手写一遍再考虑移植到自己的项目里。这样踩过的坑才真正变成了你脑子里的东西。