干嵌入式这一行的,最怕的不是需求不合理,而是代码明明编过了、烧进去了,功能跑着跑着忽然死机。追到最后,十有八九要落到「内存破坏」四个字上:数组越界把邻居变量写穿、栈溢出把返回地址踩烂、野指针直接飞到天边。我这些年被这类问题坑过无数次,也练出了一套依赖 Keil 调试器的固定打法,从崩溃现场反推、用填充值当哨兵、下数据观察点抓现行,基本能把大多数随机死机按在地上。今天这篇文章就把这套思路完整拆开,按实操顺序写清楚,适合刚被 HardFault 折磨到怀疑人生的新手,也适合想系统建立内存排查体系的老手对照参考。
1. 内存破坏问题全景拆解
1.1 内存破坏到底是怎么发生的
很多人把内存泄漏和内存破坏混在一块,这两个完全是两个量级的事。内存泄漏是防水管漏水,内存破坏是炸弹炸楼。内存破坏的本质是:某一段代码写了一个超出它权限范围的地址,把原本属于别的变量的数据改了。嵌入式里最常见的就是数组越界、指针指向错误地址后解引用、memcpy 长度算错、栈溢出,以及堆区被踩。
拿生活打个比方。全局变量区就是一排宿舍,每个变量住一间房。数组越界就好比 202 室的房客喝多了,一脚踹开 203 的门进去乱翻家具。问题在于 203 室的房客当时不一定在现场,等你发现家里被动过的时候,202 室那位早跑了。所以内存破坏最恶心人的地方就在这里:干坏事的时间和出事故的时间完全分离,你顺着崩溃点找凶手,崩溃点的代码往往是受害者。
按内存分区来分,内存破坏主要落在四个区域:
- 栈区:局部变量越界、递归过深、中断嵌套过深、函数返回地址被改写。
- 全局/静态区:数组越界、指针错误寻址、memcpy / sprintf 长度失控。
- 堆区:malloc 分配后越界写、free 两次、释放后继续使用。
- 代码区:函数指针被改写后跳飞,甚至往 Flash 里写了不属于程序的数据。
前两类在单片机项目里占九成以上,堆区在用了操作系统的项目里更常见,代码区破坏是最邪门的,一旦出现基本意味着内存已经彻底失控。
1.2 为什么这类 bug 这么难排查
难,不是因为定位手段少,而是因为「案发现场」和「作案现场」通常不是同一个地方。常见的原因有三个。
第一是时间差。一个函数在 setTimeout 类似的时间点把数据写进了错误地址,可能几个小时后另一个函数才发现自己的变量被污染。等你打开调试器、打断点去观察的时候,变量早就不是最初被破坏的状态了,你看到的只是破坏之后的「残骸」。
第二是空间差。变量 A 被越界写坏,但真正崩溃的是变量 B,因为 B 在 A 的邻近地址,被写进了一段非法值,而在某个时刻 B 被赋值给函数指针、被当作数组下标、被用于计算 DMA 长度,才引发故障。你盯着 B 查,永远查不到 A 的代码。
第三是表现随机。很多内存破坏跟时序强相关:只有在中断刚好打断某段代码、DMA 刚好完成传输、某个外设刚好产生事件的交汇点才会触发。所以 Debug 下单步执行一切正常,全速跑起来直接死机,或者在低优化级别下正常、开了优化就崩,这种「薛定谔的崩溃」最让人抓狂。
1.3 Keil 环境下的内存布局直觉
在深入调试方法之前,建议先把目标芯片的内存地图印在脑子里。以最常见的 STM32F103 为例,RAM 起始地址是 0x20000000,栈顶由启动文件里的 Stack_Size 决定,全局变量紧跟其后由编译器分配,堆区在 startup 文件里分配,默认 Heap_Size 往往只有 512 字节或者 1KB。
Keil 编译完会生成 map 文件,「.map」里面记录了每个全局变量、常量、函数的绝对地址和大小。想判断变量是不是被踩,第一步就是打开 map 找到它的地址,然后去 Memory 窗口看它周围的邻居是谁。这个动作是后面所有操作的基础,所以有经验的工程师调内存问题时,第一件事永远是打开 map 文件,而不是漫无目的地打断点。
记住一句话:内存是连续的,变量之间的“墙壁”是编译器画的,但代码不守规矩时,这堵墙跟纸糊的没区别。
2. 调试前的工具与环境准备
2.1 Keil 调试器里必须熟悉的几个窗口
Keil 的调试界面看起来按钮很多,但调内存问题真正有用的就几个窗口,平时一定要养成随时能调出来的习惯。
Memory 窗口是核心中的核心。它可以按 Hex、Signed、Unsigned 等方式显示任意地址的原始数据。你要做的就是在 Address 栏直接输入一个变量名(比如status)或者绝对地址(比如0x20000030),它就会显示这段内存的字节内容。排查越界时,我会先看目标变量前后各 32 字节,把上下相邻变量都看一遍,一旦发现有凭空出现的 0xFF 或 0x00,基本就锁定了被踩的区域。
Watch 窗口用来观察变量值的变化,配合条件断点使用可以在变量变成非法值时停下来。不过 Watch 窗口有个局限,它只能反映当前时刻的值,没法告诉你谁改的。
Call Stack + Locals 窗口在崩溃时能显示调用链和局部变量。但内存破坏的场景下它经常失效,因为栈已经被踩烂了,调用链早就不准。这个窗口显示乱码时不要慌,反而是个重要的线索:说明栈被破坏到连调试器的栈回溯都无法解析。
Disassembly 窗口用来看反汇编。当你从 Hardware Fault 的 PC 寄存器得到崩溃地址后,打开 Disassembly 窗口跳到那个地址,能看到当时正在执行哪条指令、访问哪个寄存器、访问哪个内存地址。这一步能极大缩小嫌疑范围。
Peripherals 窗口偶尔有用。比如观察 DMA 控制器的状态寄存器、中断控制器 NVIC 的挂起状态,确认是否因为中断优先级翻转导致重入。
2.2 硬件断点和数据观察点:抓现行犯的利器
普通断点在 CPU 取指到某条指令时停下,这叫执行断点。遇到内存破坏,你往往不知道应该在哪条指令处打断,你只想知道谁在写某个变量。这时候得用数据观察点(Data Watchpoint),它在 CPU 发生指定地址的读、写或读写访问时立刻暂停。Cortex-M3/M4 内核自带 DWT 模块,支持最多 4 个数据观察点,Keil 里可以直接设置。
在 Keil 里设置的方法很简单:打开 Breakpoints 窗口,在 Expression 栏输入你要监视的变量名或地址,选择 Access 类型为 Write,点击确认。之后全速运行,每当这个地址被写入,CPU 就会停在触发写操作的那条指令之后。你打开 Call Stack 窗口看是谁在调用栈里,凶手当场落网。
使用数据观察点时有三个实用的经验:
- 观察点数量只有 4 个,十分宝贵。不要拿它监视频繁改变的变量,只监视你认为最可能被踩的那块内存,比如数组最后一个字节的下一字节。
- 如果被监视的地址是一个结构体成员,要注意该成员可能被编译器整体写入,导致观察点在结构体拷贝时频繁触发。这时候可以改用监视整个结构体的首地址,配合条件表达式过滤。
- 观察点在调试会话中可能会因为代码优化而失效,比如变量被优化到寄存器里,地址根本不在 RAM。出现这种情况时,先临时把优化等级调到 -O0,再设置观察点。
2.3 给内存“上锁”:MPU 的高级用法
在 Cortex-M3/M4 上还有一个不太被重视的工具:MPU(Memory Protection Unit)。它可以给不同内存区域设置访问权限,比如把栈区设为只读,越界写栈时直接触发 MemManage Fault,停在第一步;或者把某块 RAM 设置为不允许读,只有特定代码才能访问。
虽然听起来复杂,但实际用起来并不难。Keil 工程里可以直接内联一段初始化 MPU 的代码,配置好 Region 的起始地址、大小和权限。调完 bug 后再把 MPU 禁用或者删掉,不影响发布代码。这个手段最大的价值是:把“不知道哪里会越界”变成“越界的一瞬间当场崩溃”,让隐蔽的内存破坏变成可捕获的异常。
MPU 的局限在于配置要占用一段代码执行时间,而且每个 Region 的划分需要按 32 字节对齐。初期调试可以用,发布阶段默认关闭。
2.4 启动文件与编译选项上的准备
调试内存问题前,建议把两块基础检查做完。
第一,检查 startup 文件里的 Stack_Size 和 Heap_Size。很多工程师默认栈配置 0x400(1KB),跑个带 JSON 解析或者打印浮点数的函数就爆了。可以把 Stack_Size 临时调大到 0x1000,Heap_Size 调到 0x800,先排除空间不足的问题,再回头缩小范围。
第二,编译优化等级。Keil 的 Magic Wand 里 Optimization 默认是 Level 3(最激进)。内存破坏时变量可能被优化掉、观察点失效、反汇编跟你源码对不上。调试阶段换成 Level -O0 或 -O1,等定位后再恢复高优化并重新验证。这不是丢人的事,调试内存问题就是要在可读性和真实性之间做取舍。
3. 现场定位实操:从 HardFault 到根因
3.1 崩溃第一时刻怎么保留现场
程序进 HardFault 之后,第一反应千万别是拔电重启。按下复位键,案发现场就只剩一堆大杂烩了。正确做法是让 CPU 停在 HardFault 处理函数里,然后记录三个关键寄存器:PC、LR、SP。
PC 是崩溃时正在执行的指令地址,LR 是调用返回地址。打开 Call Stack 窗口,正常情况下能看到调用链;如果显示的是乱码或者函数名前面带问号,说明栈内容已经被踩过,这时候要走下面的手工回溯流程。SP 当前值决定栈顶在哪,拉开幕帘看栈里的内容,往往能发现线索。
HardFault_Handler 里的代码越简单越好。我见过有人往里加了一堆 printf、变亮 LED、保存寄存器到 EEPROM 的代码,结果这些代码本身又用了栈,把本就不干净的现场又搅了一通。调试阶段建议 HardFault_Handler 里就一个死循环:
void HardFault_Handler(void) { __disable_irq(); while (1); }断点就下在这个 while 死循环上。这样 CPU 停下来的时候,寄存器基本还是崩溃瞬间的值。保存现场的工作交给调试器和你的眼睛,不要画蛇添足。
3.2 手工栈回溯:从“尸体”上还原调用链
如果 Call Stack 窗口已经失效,别放弃,栈里还躺着返回地址。Cortex-M 的函数调用会在栈里保存返回地址(0x08xxxxxx 的 Flash 地址),即使局部变量被踩烂,只要返回地址还没被完全覆盖,就能逆推出调用链。
实操步骤是这样:从当前 SP 寄存器的值开始,在 Memory 窗口里往后翻 64 到 128 字节,逐个寻找看起来像 Flash 地址的值。Flash 地址的特征是开头是 0x0800 或 0x0000,后面跟 6 位数字。每找到一个,就去 Disassembly 窗口输入这个地址,看地址所在的函数。把这些函数名按从底到高的顺序拼起来,基本就是被踩之前的调用链。
这个方法听着原始,但实战中至少能救回五成失灵的调用栈。前提是注意一个细节:栈是向下生长的,SP 是栈顶,地址越往上(地址越大)越靠近栈底,越早入栈。你要从 SP 往高地址找,而不是往低地址找。
3.3 典型越界案例分析:数组写穿邻居变量
我举一个真实感很强的例子。假设工程里有这么一段代码:
static uint8_t tx_buf[16]; static uint8_t status; void copy_data(uint8_t *src, uint32_t len) { memcpy(tx_buf, src, len); }如果上层传入的 len 超过 16,memcpy 就会把 tx_buf 后面的内存全部写坏。由于 status 刚好紧挨着 tx_buf,它被改成了未知值,之后状态机跳进了非法分支,最终触发 HardFault。
调这个问题的套路是这样的:
- 编译后打开 map 文件,查到 tx_buf 和 status 的地址。假设 tx_buf 在
0x20000020,status 在0x20000030。 - 打开 Memory 窗口,输入
0x20000020,观察到从 status 地址开始的数据是不是明显异常,比如出现大段 0x00 或者 0xFF,和初始化值不符。 - 在 Breakpoints 窗口里设置数据观察点,地址填
status,访问类型选 Write。 - 全速运行,等待观察点触发。触发后打开 Call Stack 窗口,看到停在 copy_data 函数。
- 查看调用 copy_data 时传入的 len 参数,发现是 20,于是定位到上层调用者传参错误。
整个过程最快的版本十分钟就能收工。关键技术点在于:观察点帮你跳过了所有正常写入 status 的代码,只在可能异常的时刻停下。如果没有观察点,你就只能瞪着眼睛干等随机崩溃。
3.4 没有观察点名额了怎么办
DWT 只有 4 个观察点,有时候四个目标地址都查完了还没找到凶手,剩下的只能人工替补。
一个有效的替补方案是哨兵检查。在被怀疑的临界区(比如某数组结尾的下一个字节)放置一个特定的填充值,比如 0xAA。程序在定时器中断里周期性检查这个哨兵是否还是 0xAA,一旦发现被改写,就把当时的 PC 和调用链记录到预留的日志区。这样即使观察点用完,也能靠哨兵定位到“第一次破坏”的时机。
另一个思路是把整个 RAM 的地图打印出来。在几个执行阶段分别把 RAM 数据通过串口导出,对比差异,找到从什么时候开始出现异常。虽然没有观察点精准,但不会漏掉大范围破坏。
4. 各类内存破坏的特征速查与研判技巧
4.1 症状与怀疑方向对照表
不同的内存破坏方式,在调试器里表现出的样子是有规律可循的。整理成表方便直接对照:
| 现象 | 最大嫌疑 | 第一动作 |
|---|---|---|
| 局部变量变成 0xAAAAAAAA | 栈溢出或栈区被越界写 | 查 Stack 窗口最大使用量 |
| 某个全局数组值全部变 0xFF | 指针写了空指针或 Flash | 用观察点监视数组首地址 |
| 结构体第一个成员异常 | 前一个缓冲区越界 | 查看前一个变量在内存中的地址 |
| malloc 返回 NULL | 堆空间不足或堆被踩 | 加大 Heap_Size 并加哨兵 |
| 函数指针跳飞 | 栈或全局区被严重覆盖 | 查看崩溃现场的 PC 值 |
| 程序正常运行但某个位域错乱 | 未初始化局部变量或栈污染 | 开编译器警告 -Wall,检查未初始化路径 |
这张表不是万能公式,但能帮你把一片混沌的问题快速收敛到两三个方向。
4.2 野指针与“围栏法”
野指针的本质是地址来源不可控。常见来源:结构体指针经 memset 清空后仍被使用;局部指针变量未初始化;从自定义协议里直接解析出 16 位地址当指针用;还有书本上反复说的悬垂指针。
处理野指针,除了靠观察点,还有一个工程化手段叫围栏法。在布局允许的情况下,给可疑的缓冲区周围手动预留一段空间,用固定值填满。比如把数组 tx_buf 定义成uint8_t tx_buf[16 + 8],真正使用的只有前 16 字节,后 8 字节是围栏。项目里写一个巡检函数,每隔几百毫秒检查所有围栏是否完好。一旦围栏被改,就能得到破坏发生的频率和时间位置,再结合日志往触发点靠。
这个方法的代价是浪费一点 RAM,但对内存本来就宽裕的项目非常值。
4.3 栈溢出的三个实用线索
栈溢出跟数组越界不一样,它破坏的是最常见的运行空间,而且经常表现为 Deep 诡异。排查栈问题时我一般按三个线索走。
第一个是看栈的使用峰值。Keil 的调试视图里有 Stack 窗口,会显示当前 SP 离栈顶还差多少空间。在系统最繁忙、中断最狠的运行时间段暂停,就可以看到峰值大概在什么位置。如果你的 Stack_Size 是 0x400,而峰值已经冲到接近 0x400,那基本可以断定栈不够用,先把 Stack_Size 加大再说。
第二个是看启动文件里那条未初始化区域。可以在 startup 汇编里加一段代码,在进 main 之前把整个栈区填充成 0xCC。程序跑一段时间后,通过 Memory 窗口从栈顶往下看,检查 0xCC 边界被推进到了哪个位置。如果边界已经越过安全线,说明栈溢出了,并且可以粗略看出溢出量。
第三个最隐蔽:编译器有时会把多个函数打包分配同一个栈帧空间,你在单步调试时看起来很充裕的栈,全速运行时可能会因为中断嵌套而爆掉。所以排查栈问题时不要只测主循环路径,必须用全速长时间运行和压测中断来复现。
4.4 堆区破坏的哨兵与审计
用了 OS 或者 C 库动态内存的项目,常见 bug 是分配之后越界写。C 库自带的 malloc 没有任何保护,写穿了就直接踩到别人的堆块元数据或者空闲链上。
一个很土但很有效的方法是封装一层 debug_malloc。我在项目里做过的版本是:每次请求 size 时多申请 16 字节,前 8 字节填魔数 0xA5,后 8 字节填魔数 0x5A,返回给调用者的指针指向中间段。释放时检查魔数是否完好,如果不完好就直接断言并打印 caller 地址。定期巡检函数再扫一遍所有已分配块的魔数,基本能把堆越界控制住。
这个方法对 Keil 的 MicroLIB 和标准 C 库都适用,只要把 malloc 换成 debug_malloc 即可。代价是分配效率稍微下降,调试完可以改回原版。
5. Keil 环境下的隐藏调试手段
5.1 用 map 文件“排地雷”
前面多次提到 map 文件,这里单独讲透。Keil 在输出目录里生成的.map文件包含全部符号地址。打开它,用编辑器搜索目标变量名,能看到变量地址和所属 section。
从 map 里得到地址后,我习惯顺手记下周围几个变量的地址,然后去 Memory 窗口把所有邻居都看一遍。很多时候凶手不是目标变量本身,而是它前面几字节的数组越界。比如你已经确认变量flag被改,但看不懂是谁改的,那就往低地址方向翻一翻,看是谁住在flag前面;如果前面住着一个buf[32],那长写穿越的位置基本就在buf + 32。
map 文件还能帮你判断栈位置。启动文件里定义的STACKsection 会在 map 里有明确地址,你可以直接去 Memory 窗口看栈区的初始填充物有没有被大量消耗。
5.2 Debug (printf) Viewer 与 ITM Trace
很多人一上来就接串口打印,可串口线不够用、板子上没引出来的时候就很头疼。Keil 的 Debug (printf) Viewer 配合 SWD 调试器的 ITM 通道,可以在不占用任何 UART 引脚的情况下输出字符串。
用法很简单:在工程配置的 Debug 页勾选 Use Simulator 或者使用 ULINK2/3、J-Link 调试器时,打开 Trace 选项卡,勾选 Enable Trace 并把 Core Clock 设对,然后在代码里重定向fputc:
int fputc(int ch, FILE *f) { ITM_SendChar(ch); return ch; }之后所有的 printf 输出都会出现在 Debug (printf) Viewer 窗口。内存排查时我常用它打印关键函数调用序列和时间戳,能显著辅助判断破坏发生的前后顺序。但注意 ITM 输出本身会占用一点 CPU,全速运行时会拖慢系统,实测下来对时序敏感型 bug 有影响,用的时候要留个心眼。
5.3 条件断点和表达式
Keil 的断点不仅仅是「到地址停」。你可以在 Breakpoints 窗口里写带条件的表达式,比如:
- 在函数入口打断点,条件是某个变量等于特定值
- 在写操作观察点上设置条件,比如当写入值不等于上一次的值时才停
- 在循环体里设置跳过次数,减少重复干扰
条件断点特别适合处理内存问题里的“偶发”特征。比如你知道status被写坏,但正常路径也会写它,你只关心它被写成 0x55 时的场景,那就给观察点加一个条件:status == 0x55。这样就不会被频繁的正常写入打断。
Keil 的表达式语法不算复杂,常用的比较、算术、变量引用都能用。实在记不住的就现敲现查,打开对应的工具提示能补全。
5.4 Simulator 模式下做内存压力测试
有些内存破坏必须长时间运行才能复现,但硬件板子不可能一直占着。Keil 的 Simulator 模式可以在没有硬件的情况下用指令集仿真运行程序,速度比真机慢,但胜在可以在内存窗口里随意翻看、多次启动不同场景。
我经常用 Simulator 做一类测试:把整个 RAM 初始化成 0xAA,然后运行一段功能代码,暂停后扫描 RAM 里哪些位置的 0xAA 被意外的 0x00 或 0xFF 覆盖。这个方法配合 map 文件能快速找出“全部变量地址之外的写入”——那些写入往往就是越界指针干的好事。Simulator 模式虽然不能模拟外设时序,但对纯内存操作类 bug 的发现能力非常强。
5.5 调试会话中的 Flash 烧写小心机
内存破坏偶尔会牵扯到 Flash 写入。比如你用的是内部 Flash 保存参数、OTA 升级、写 log 等功能,一旦写入长度参数出错,就会把 Flash 中相邻地址的内容冲掉。Keil 的 Flash Download 设置里可以配置算法和起始地址,但调试内存问题时更重要的是确认CPU 是否真的执行了 Flash 写指令。
排查思路是在 Flash 擦写函数入口和内部循环设置断点,确认擦写长度和源地址是否符合预期。对 Flash 擦写本身,我建议加入地址合法性检查:只允许程序中的指定 Flash 段被擦写,一旦传入地址超出边界就立即断言。这个防线逻辑上跟 MPU 一样,属于主动防御。
6. 避坑经验与进阶方案
6.1 内存对齐:最容易忽略的“假故障”
有一类问题,是被误诊成内存破坏的——其实不是写穿了,而是对齐异常。Cortex-M0/M0+ 对非对齐访问会直接 HardFault,M3/M4 默认允许非对齐访问,但在某些外设总线、DMA 描述符访问或者原子操作场景下依然会炸。
我在项目里遇到过:结构体明明没加__attribute__((packed)),字段偏移却指向奇数地址;代码里直接强转uint32_t *去读一个uint8_t数组偏移。如果调了半天内存没有越界痕迹,就把 Disassembly 窗口打开,看崩溃指令是不是LDR或STR访问了非对齐地址。一旦确认,正确做法是修改结构体定义并关注是否开了__attribute__((packed))。
6.2 DMA 与中断:越界的高发地带
DMA 是内存破坏中的惯犯,因为 DMA 控制器没有 C 语言运行时保护,它只认起始地址和长度。DMA 大缓存一旦长度计算错误,会一口气写穿目标缓冲区。尤其在做环形缓冲时,写指针算错一位、缓冲区大小不是传输宽度的整数倍,都会把坏事。
排查 DMA 破坏的关键点是先用 map 文件找到 DMA 缓冲区和它后面的变量,然后去外设寄存器确认 DMA 当前配置的传输长度和地址。更稳妥的做法是给 DMA 缓冲区加围栏,并在 DMA 传输完成中断里做哨兵检查。
6.3 RTOS 下的任务栈“分账”
用 FreeRTOS、RT-Thread 这类操作系统时,每个任务有自己独立的栈,全局栈的概念被弱化了。内存破坏照旧存在,但排查入口变成「哪个任务的栈被踩了」。
FreeRTOS 专门提供了栈高水位函数uxTaskGetStackHighWaterMark(),在任务里调用能拿到任务栈最低剩余量。如果某个任务剩余量为 0,基本可以确定这个任务栈溢出。此外 FreeRTOS 还支持在任务切换时自动检测栈溢出,需要设置configCHECK_FOR_STACK_OVERFLOW为 2,会在栈溢出钩子里通知你。
调 RTOS 内存问题时的经验是:分清主次。先查任务的栈水位,再查队列、信号量、事件标志这些内核对象有没有被非法写入。内核对象的破坏往往会影响调度,表现出「另一个任务死了」的假象。
6.4 优化选项是“薛定谔的开关”
有的内存破坏只在开优化时出现,有的只在关优化出现。前者的例子是:编译优化后编译器把原本连续的内存重新排布,数组越界不再踩到原来变量,却踩到了另一个变量;后者的例子是:优化让栈帧变小掩盖了栈溢出。
遇到这种优化相关的内存破坏,不要硬在一个优化级别下死磕。正确做法是两个级别都测一遍,观察崩溃现场有什么不同。如果 -O0 下能稳定复现,就先用 -O0 配合观察点定位;如果只有 -O2 复现,就临时改成 -O1 逐步逼近。Keil 的优化选项支持分文件设置,可以只对可疑文件开低优化,其余保持高性能,减少整体性能损失。
我个人最后还保留的一个习惯是:内存问题定位完成后,把完整的排查流程写进项目笔记里。这看起来像是记录文档,但它有个重要作用——下次遇到同类问题,你不需要重新从 map 文件和 SP 开始翻,直接把上一次的流程照着跑一遍就行。调试内存破坏是很吃经验的事,每成功一次,大脑里就多一个「症状 → 方向」的映射。这也是我愿意花时间把这条路走通并写下来的原因。