接手一个裸机 STM32 项目,第一件事通常不是写业务逻辑,而是把初始化彻底搞明白。很多朋友用惯 HAL 库之后,再回去碰 bare-metal initialization,第一反应往往是:上电之后芯片到底先执行了什么?为什么我点个灯都要配置那么多寄存器?这篇文章就以最普及的 STM32F103 为例,把复位向量、启动文件、链接脚本、时钟树、GPIO、串口和中断的初始化逻辑从头到尾拆一遍。适合刚脱离库函数、想彻底掌控寄存器行为的开发者,也适合已经从其他 MCU 转到 STM32 平台,想搞清楚"为什么这么初始化"的人参考。
1. 为什么还要自己写 bare-metal 初始化
1.1 上电之后芯片到底在干什么
裸机初始化不是指"从零画出芯片内部电路",而是指从复位向量开始,由你自己接管芯片的运行环境。STM32 上电之后,硬件会做几件固定的事:从 flash 首地址读取初始堆栈指针,再读取复位向量,然后跳转执行。这一步由芯片内部的固定逻辑完成,不需要你写代码,但如果你没有把正确的向量表放到 flash 起始位置,芯片第 1 条指令就会跑飞。
跳进 Reset_Handler 之后,真正需要你操心的事情才开始。C 语言约定全局变量在 main 之前完成初始化,但芯片并不会自动知道 .data 段要拷贝到哪里、.bss 段要清零。HAL 库里,SystemInit、__main 和启动文件帮你做了这些;裸机环境里,要么你把启动文件写对,要么就得自己处理。很多看起来诡异的 HardFault,其实都发生在这个无人关注的阶段。
所以裸机初始化的本质,就是回答三个问题:向量表放哪、栈在哪、系统时钟从哪里来。这三个问题没解决,后面所有外设代码都只是在操作一堆不会生效的寄存器。
1.2 裸机初始化和 HAL 初始化有什么本质区别
HAL 库初始化外设时,通常一个函数就能完成:HAL_UART_Init()帮你算波特率,帮你配置 GPIO 复用,帮你把时钟使能打开。看起来省事,但遇到串口乱码、定时器周期不对、芯片唤醒后异常复位时,你很难判断是哪一步没有配对。裸机初始化要求你逐个寄存器确认,反而逼你把芯片手册吃透。
我并不是让大家彻底抛弃 HAL,而是建议至少把底层跑通一遍。比如你手写过 PLL 配置,就会理解为什么 HSE 晶振没起振时,代码会卡在等待标志位的循环里;你手写过 GPIO 复用模式,就会明白为什么明明开了串口时钟,PA9 没有配置成复用推挽输出,数据就发不出去。这些认知,是只用库函数很难获得的。
另外,很多实际项目对代码体积、启动时间、外设响应延迟有要求。HAL 库默认开启的时钟、中断、超时检测不一定符合你的场景。裸机初始化可以把不用的部分全部砍掉,让每次复位后的启动时间压缩到几十个时钟周期以内。再者,国产 MCU 和 STM32 寄存器兼容性参差不齐,裸机代码迁移时反而比庞大 HAL 库更容易控制。
2. 复位之后:启动文件与链接脚本
2.1 一段最小可用的启动文件
启动文件是裸机初始化的地基。它必须包含两样东西:向量表和复位处理函数。向量表的第一项是初始栈指针,第二项是复位函数地址。后面的中断项即使暂时不写处理函数,也要保留占位,否则中断一旦触发,芯片会从错误地址取值,结果通常是 HardFault。
下面是一段适合 STM32F103 最小可用的 Cortex-M3 启动文件,不依赖 CMSIS,可以直接放到工程里:
.syntax unified .cpu cortex-m3 .thumb .global _estack .global Reset_Handler .section .isr_vector, "a", %progbits .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .word MemManage_Handler .word BusFault_Handler .word UsageFault_Handler .word 0 .word 0 .word 0 .word 0 .word SVC_Handler .word DebugMon_Handler .word 0 .word PendSV_Handler .word SysTick_Handler .section .text .thumb_func Reset_Handler: ldr r0, =_estack mov sp, r0 bl SystemInit ldr r0, =__bss_start__ ldr r1, =__bss_end__ movs r2, #0 bss_loop: cmp r0, r1 bge bss_done str r2, [r0], #4 b bss_loop bss_done: bl main b . .thumb_func NMI_Handler: .thumb_func HardFault_Handler: .thumb_func MemManage_Handler: .thumb_func BusFault_Handler: .thumb_func UsageFault_Handler: .thumb_func SVC_Handler: .thumb_func DebugMon_Handler: .thumb_func PendSV_Handler: .thumb_func SysTick_Handler: b .这里有几个关键点。第一,_estack是链接脚本里定义的栈顶地址,必须在向量表第一项正确引用。第二,.thumb_func必须加在 Reset_Handler 前,否则链接器可能把函数地址生成为奇数状态位,导致首条指令执行时出错。第三,BSS 清零循环必须放在调用 main 之前,否则未初始化的全局变量会带着随机值进入主逻辑。
很多人在工程里直接用官方 startup 文件,觉得没必要手写。但当你把启动文件拆开看过一遍,才会理解链接脚本里的__bss_start__、__bss_end__、_estack到底从哪里来。如果这些符号链接失败,问题通常出在链接脚本和启动文件没有对齐。
2.2 链接脚本里的堆栈和内存布局
链接脚本是跟启动文件配合使用的,它告诉链接器 flash 和 RAM 的地址范围,以及各段放到哪里。STM32F103C8T6 的典型配置是 64KB flash、20KB RAM。最小链接脚本长这样:
ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K } _estack = ORIGIN(RAM) + LENGTH(RAM); SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } >FLASH .text : { . = ALIGN(4); *(.text) *(.text*) . = ALIGN(4); } >FLASH .rodata : { . = ALIGN(4); *(.rodata) *(.rodata*) . = ALIGN(4); } >FLASH _sidata = LOADADDR(.data); .data : { . = ALIGN(4); __data_start__ = .; *(.data) *(.data*) __data_end__ = .; . = ALIGN(4); } >RAM AT> FLASH .bss : { . = ALIGN(4); __bss_start__ = .; *(.bss) *(.bss*) *(COMMON) __bss_end__ = .; . = ALIGN(4); } >RAM }这里最容易被忽略的是.data段的AT> FLASH。C 语言中的已初始化全局变量,在编译后初始值存放在 flash 里,但运行时必须拷贝到 RAM。_sidata就是 flash 中初始值的起始地址,__data_start__和__data_end__是 RAM 中的拷贝区间。启动文件里我只写了 BSS 清零,没有写 data 段拷贝,这是故意的,目的是让你先明白:省略这一步会导致所有带初值的全局变量都是 0,或者更乱。
实际工程里,你需要在 Reset_Handler 中补上 data 拷贝循环。也不难,思路跟 BSS 清零一样,只是把_sidata的值逐个搬运到__data_start__。另外,栈和堆如果没在链接脚本里定义_Min_Stack_Size之类符号,默认会跟 BSS 挤在一起,一旦递归调用稍微深一点,栈就把变量区冲掉了。
2.3 SystemInit 与 SystemCoreClock 的分工
很多 STM32 官方工程里,Reset_Handler 会调用 SystemInit,然后才是__main或main。SystemInit 的职责是先把芯片时钟切换到比较稳定的状态,再让 C 环境启动。F103 上电默认使用内部 HSI,精度不高。如果应用需要串口通信、USB 或者精确计时,就必须在早期把 HSE 和 PLL 配好。
SystemCoreClock则是一个全局变量,理论上保存当前系统时钟频率。你会发现,很多 SDK 里的延时函数都读取它来计算循环次数。裸机工程里如果你不维护这个变量,SysTick、定时器溢出时间、串口波特率计算都会出错。我的习惯是:在SystemInit末尾同步更新SystemCoreClock = 72000000;,并让所有依赖时钟频率的模块都去读这个变量,而不是在多个地方写死数值。
如果你不希望引入 CMSIS,可以自己定义SystemCoreClock并手工维护。关键是所有模块必须共用一个时钟频率来源,否则改一个地方漏一个地方,排查起来非常痛苦。
3. 时钟树:寄存器级初始化的分水岭
3.1 时钟树怎么读
刚开始看 STM32F103 时钟树图的人,很容易被一堆 AHB、APB1、APB2 分频器绕晕。其实核心逻辑就一句话:晶振或者内部 RC 产生原始时钟,经过 PLL 倍频得到系统时钟 SYSCLK,再经过 AHB 分频给 AHB 总线,最后 APB1 和 APB2 分别给低速和高速外设供电。
F103 的 HSE 外部晶振常见是 8MHz,内部 HSI 是 8MHz。最大系统时钟是 72MHz,这就要求 PLL 倍频不能超过 9 倍。APB1 总线的最高频率是 36MHz,所以系统时钟 72MHz 时,APB1 必须至少 2 分频。串口 USART1 挂在 APB2 上,波特率计算时要用 72MHz;定时器 TIM2 挂在 APB1 上,但 TIM2 的时钟可能因为分频器再翻倍而变成 72MHz。这就是为什么很多人用 HAL 初始化时,测定时器溢出时间总觉得差一倍。
我建议在动手写代码之前,先拿一张纸画出自己目标时钟链路:HSE 8MHz → PLL x9 → SYSCLK 72MHz → AHB 1分频 → APB1 2分频、APB2 1分频。画完之后再打开寄存器手册找对应位,心里会踏实很多。
3.2 手写一个 72M 的 SetSysClock
裸机初始化时钟树不需要高深算法,就是按顺序操作几个寄存器:先配置 flash 等待周期,再开 HSE 并等待就绪,然后配置 PLL 倍频,最后把系统时钟切换到 PLL。F103 上对应的寄存器是 RCC_CR、RCC_CFGR 和 FLASH_ACR。
#define RCC_BASE 0x40021000UL #define RCC_CR (*(volatile unsigned long *)(RCC_BASE + 0x00)) #define RCC_CFGR (*(volatile unsigned long *)(RCC_BASE + 0x04)) #define FLASH_ACR (*(volatile unsigned long *)(0x40022000UL)) void SystemInit(void) { unsigned int timeout; FLASH_ACR = 0x12; RCC_CR |= (1UL << 16); timeout = 0; while ((RCC_CR & (1UL << 17)) == 0) { if (++timeout > 1000000) break; } RCC_CFGR = (0x7UL << 18) | (1UL << 16); RCC_CR |= (1UL << 24); timeout = 0; while ((RCC_CR & (1UL << 25)) == 0) { if (++timeout > 1000000) break; } RCC_CFGR |= 0x2UL; timeout = 0; while ((RCC_CFGR & 0x0CUL) != 0x08UL) { if (++timeout > 1000000) break; } }FLASH_ACR = 0x12的意义是:LATENCY 设为 2,表示 flash 读取需要 2 个等待周期;bit4 置 1 打开预取缓冲。为什么必须配置等待周期?因为 flash 本身比 CPU 慢,系统时钟到 72MHz 时不加等待周期,取指就会偶发错误。很多人裸机初始化做完,代码不稳定、莫名 HardFault,多半就是漏了这一步。
PLL 倍频值0x7 << 18对应 x9。PLL 源选择1 << 16表示使用 HSE。整个配置顺序不能乱:必须先让 HSE 稳定,再打开 PLL,最后切换时钟源。如果你反过来,可能切到 PLL 后 PLL 还没稳定,指令流就会出问题。
3.3 PLL 参数计算与验证
PLL 参数不是一个一个试出来的,可以算。F103 的 PLL 输入通常是 HSE,比如 8MHz,目标 SYSCLK 是 72MHz,所以倍频系数是 9。如果外部晶振是 12MHz,那倍频系数应改为 6,最大只能到 72MHz。寄存器位值和倍频系数一一对应,查参考手册 RCC_CFGR 中的 PLLMUL 表格就能得到。
验证时钟是否真的切到 72MHz,最简单的方法是用定时器测引脚翻转频率,或者用串口发一个固定字符,再用逻辑分析仪看波特率是否准确。我习惯写一个 LED 翻转函数,把它放在 SysTick 中断里,配一个已知的 1ms 周期,然后对比逻辑分析仪实测周期。如果实际周期和预期差 2 倍,很可能就是 AHB 或 APB1 分频没配对。
还有一个小坑:调试器连接时,可能因为芯片已经跑在错误时钟下,导致 SWD 时序不对。如果出现连接不上,按住复位键再点连接,通常能捡回一条命。这个技巧在真机上我很常用,裸机时钟写错的时候,比什么高级工具都管用。
4. GPIO 与串口:把外设真正跑起来
4.1 点灯之前先看 GPIO 模式的二进制位
F103 的 GPIO 配置分 CRL 和 CRH,分别管低 8 位和高 8 位。每个引脚占用 4 个 bit,其中 MODE 场选输入输出模式和速度,CNF 场选具体模式。刚开始用寄存器点灯时,最容易犯的错误是直接对整个寄存器赋值,把其他引脚配置冲掉了。正确姿势是“先清位再置位”。
比如常用的 PC13 推挽输出初始化:
#define RCC_APB2ENR (*(volatile unsigned long *)(0x40021018UL)) #define GPIOC_CRH (*(volatile unsigned long *)(0x40011004UL)) #define GPIOC_ODR (*(volatile unsigned long *)(0x4001100CUL)) void LedInit(void) { RCC_APB2ENR |= (1UL << 4); GPIOC_CRH &= ~(0xFUL << 20); GPIOC_CRH |= (0x2UL << 20); } void LedToggle(void) { GPIOC_ODR ^= (1UL << 13); }0x2 << 20的二进制是0010,对应的 MODE13 是10,代表输出模式 2MHz;CNF13 是00,代表通用推挽输出。这里我选 2MHz 是为了省电和减少电磁干扰。如果 LED 灯闪烁频率很高,或者要驱动外部负载,再改成 50MHz。跑通之后可以试试 50MHz 和 2MHz 的区别,用示波器看边沿陡峭程度会非常直观。
重点说下 RCC_APB2ENR。这个寄存器是外设时钟开关。很多人配置完 CRH 却没有开 GPIO 时钟,读 ODR、写 ODR 都不会报错,但引脚完全没有输出。寄存器写入会落到总线,可外设根本没被时钟驱动。这种问题不打印、不报错,只能靠检查时钟使能位来排查。
4.2 串口发送的寄存器级实现
串口初始化比 GPIO 多两个步骤:配置 TX/RX 引脚为复用功能,设置波特率寄存器 USART_BRR。F103 的 PA9 是 USART1_TX,PA10 是 USART1_RX。关键是 PA9 必须配置成复用推挽输出,而不是普通推挽输出;PA10 浮空输入即可。
我用宏定义的方式写寄存器操作,直接看代码:
#define GPIOA_CRH (*(volatile unsigned long *)(0x40010804UL)) #define USART1_SR (*(volatile unsigned long *)(0x40013800UL)) #define USART1_DR (*(volatile unsigned long *)(0x40013804UL)) #define USART1_BRR (*(volatile unsigned long *)(0x40013808UL)) #define USART1_CR1 (*(volatile unsigned long *)(0x4001380CUL)) void Uart1Init(void) { RCC_APB2ENR |= (1UL << 2) | (1UL << 14); GPIOA_CRH &= ~(0xFFUL << 4); GPIOA_CRH |= (0xBUL << 4); // PA9 复用推挽 50MHz GPIOA_CRH |= (0x4UL << 8); // PA10 浮空输入 USART1_CR1 = 0; USART1_BRR = 625; // 72MHz / 115200 = 625 USART1_CR1 = (1UL << 13) | (1UL << 3) | (1UL << 2); } void Uart1Putc(char c) { while ((USART1_SR & (1UL << 7)) == 0); USART1_DR = c; }BRR = 625是这么来的:USART1 挂在 APB2 总线上,F103 倍频后 APB2 是 72MHz。目标波特率 115200,所以72000000 / 115200 = 625。这个值放在 BRR 时会自动拆成整数部分和小数部分。如果你的系统时钟不是 72MHz,比如用了 8MHz HSI 而没配置 PLL,那么 BRR 应该改成8000000 / 115200 ≈ 69。否则串口发出的数据就是乱码。
串口发送函数里的while ((USART1_SR & (1UL << 7)) == 0);是等待 TXE 标志位。这个标志表示发送数据寄存器已空,可以写入下个字节。很多人用 HAL 库发送没问题,一到寄存器发送就把数据拼命往 DR 塞,结果因为上一次还没发完,数据直接丢失。裸机下没有超时机制,死等倒是简单可靠,但如果你在中断里调用发送函数,就要小心 TXE 永远置位的场景。
4.3 外部中断初始化顺序
中断初始化的坑比 GPIO 更多,因为顺序不对,中断就是触发不了,或者触发一次后死循环。F103 的外部中断 EXTI 配置链路是:GPIO 引脚连接到 EXTIx,EXTI 触发条件配置,然后映射到 NVIC 使能 IRQ。我这里用一个 PA0 的上升沿中断做例子:
#define AFIO_EXTICR1 (*(volatile unsigned long *)(0x40010008UL)) #define EXTI_IMR (*(volatile unsigned long *)(0x40010400UL)) #define EXTI_RTSR (*(volatile unsigned long *)(0x40010408UL)) #define EXTI_PR (*(volatile unsigned long *)(0x40010414UL)) #define NVIC_ISER0 (*(volatile unsigned long *)(0xE000E100UL)) #define GPIOA_CRL (*(volatile unsigned long *)(0x40010800UL)) #define GPIOA_ODR (*(volatile unsigned long *)(0x4001080CUL)) void EXTI0_Init(void) { RCC_APB2ENR |= (1UL << 0) | (1UL << 2); GPIOA_CRL &= ~(0xFUL << 0); GPIOA_CRL |= (0x8UL << 0); // PA0 上拉/下拉输入 GPIOA_ODR &= ~(1UL << 0); // 默认下拉,按键低电平接法 AFIO_EXTICR1 &= ~(0xFUL << 0); // EXTI0 选 PA0 EXTI_IMR |= (1UL << 0); EXTI_RTSR |= (1UL << 0); NVIC_ISER0 |= (1UL << 6); // EXTI0_IRQn }注意 AFIO 时钟也要打开。F103 上,如果没开 AFIO 时钟,EXTI 引脚映射寄存器写入无效,中断自然不会触发。这个坑在系统刚开始调试时特别隐蔽。
中断处理函数必须保留标志位读取和清除。裸机工程里没有HAL_GPIO_EXTI_IRQHandler这类帮你清标志位的封装,你得在中断服务函数里手动写EXTI_PR |= (1UL << 0);。否则中断会反复进入,看起来像死循环。
5. 裸机初始化翻车现场与排错手段
5.1 五个高频问题
我整理过一张速查表,里面都是裸机初始化阶段最容易踩中的问题:
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 下载程序后没有任何反应 | 复位向量或栈指针错误 | 查看向量表第一项是否为_estack |
| 程序运行到一半 HardFault | flash 等待周期不足,或栈溢出 | 检查 FLASH_ACR 等待周期,检查栈大小 |
| 串口输出乱码 | 波特率计算基础时钟错误 | 确认 SYSCLK/PCLK2 实际频率,重算 BRR |
| 引脚电平不对 | 没使能 GPIO 时钟或 CNF/MODE 配错 | 查 RCC_APB2ENR,对照 CRL/CRH 位定义 |
| 外部中断不触发 | AFIO 时钟没开或 EXTI 映射错 | 检查 AFIO_EXTICR,确认 pending 标志 |
这些问题有一个共同特点:都不会在编译时报错。编译器只保证语法和类型正确,不保证你的时钟配置和外设配置在硬件上成立。
5.2 从 HardFault 反推问题
HardFault 是裸机开发最怕遇到的异常,但只要你掌握了定位方法,它反而能帮你快速缩小问题范围。最简单的办法是看异常发生时的 PC 值和 LR 值。用调试器停在 HardFault_Handler 中,查看当前栈帧里面的返回地址,就能大致知道是从哪个函数炸出来的。
我常见的做法是在 HardFault_Handler 里写一个小循环,保留现场,然后用调试器读取 R14 和栈内存。如果你用的 J-Link 或 ST-Link 都看不了现场,就在 HardFault_Handler 里把 PC 压入某个全局变量,程序死后通过内存查看器读出来。这个技巧比盲猜靠谱得多。
如果 PC 落在 0xFFFFFFFF 附近,往往是函数指针跳错了,或者中断向量表里某个非 4 字节对齐的地址被处理器当成 Thumb 指令地址。如果 PC 落在 flash 范围内但反复异常,大概率是 flash 等待周期不够;你可以在设置 PLL 之前先把 FLASH_ACR 配好,这个问题能直接消除。
5.3 一种很推荐的验证习惯
我调试裸机初始化时的习惯是“一点一点点亮”。意思是不急着把全部外设初始化写完,而是先用一个 LED 验证启动文件和时钟。具体流程是:写好最小启动文件和链接脚本,main 里只做三件事——初始化 LED、翻转若干次、死循环。确认 LED 正常闪烁,再往上加串口;串口打印一小段自检信息,再加中断。
每加一个模块,就重新下载一次并验证一次。不要试图一次把所有外设全部初始化完再调试。因为一旦出问题,你根本不知道是时钟的锅还是 GPIO 的锅。
工具方面,STM32 ST-Link Utility 可以用来快速读 flash、改 option bytes,也可以看内核寄存器的当前值,但它的寄存器查看窗口不如 IDE 里的外设寄存器视图方便。VSCode 配合 OpenOCD 做断点和内存查看,体验已经很接近商业 IDE 了。逻辑分析仪的优先级比示波器高,因为裸机初始化阶段主要在意时序和频率,不需要看波形细节。
6. 把初始化沉淀成自己的工程模板
6.1 模块划分与代码组织
裸机初始化代码如果全塞在 main.c 里,后期维护会非常痛苦。我习惯这样分目录:
project/ ├── startup/ │ ├── startup_stm32f103.s │ └── link.ld ├── core/ │ ├── system_clock.c │ ├── system_clock.h │ ├── delay.c │ └── delay.h ├── drivers/ │ ├── led.c │ ├── led.h │ ├── uart.c │ └── uart.h └── main.ccore目录放跟芯片本身强相关的代码,比如时钟、延时、SysTick。drivers目录放板级外设驱动,比如 LED、按键、串口、I2C。main.c只负责调初始化函数和主循环业务。
重点说一下延时模块。裸机工程没有 HAL_Delay,最简单可靠的方式是用 SysTick 或者一个定时器产生 1ms 中断,在中断里累加一个 volatile 全局变量。所有需要延时的模块都读这个变量,而不是自己写空循环。空循环的等待时间会随着编译器优化等级变化而变化,在 O0 下能跑通,升到 O2 可能闪烁速度突然变快几倍。用定时器做时基是更稳的工程做法。
6.2 从 F103 到其他系列的迁移思路
F103 这套初始化流程搞明白之后,换到 F4、F7、G0 系列其实很快。寄存器名会变,但主线不变:先确认复位向量和启动文件,再配 flash 等待周期和时钟树,再开外设时钟,最后配置外设模式。
F4 系列比较大的变化是 GPIO 从 CRL/CRH 换成了 MODER、OTYPER、OSPEEDR、PUPDR 四个寄存器,每个引脚控制位分布更整齐了。时钟树也重新分组,主要多了一个和 HSI/HSE 相关的 RC 校准、PLL 的 N、M、P 分频参数。换芯片时,先找参考手册里“Reset and clock control”章节,再找目标芯片官方启动文件对照,基本不会迷路。
有些朋友会觉得裸机初始化太繁琐,总想找一键生成的工具。我并不反对用 CubeMX 生成工程,但生成之后建议把它当参考说明书,而不是直接拿去量产。特别是时钟树配置,CubeMX 会根据晶振和电压算出一组它认为正确的参数,但实际板子可能因为晶振负载电容、PCB 走线等原因需要微调。如果你连寄存器里哪个 bit 是 PLL 倍频都不知道,微调时就会一头雾水。
最后分享一个我自己的习惯:第一次拿到新板子,我不急着写业务代码,先做一个最小系统——LED 翻转加串口打印加一个按键中断。然后把 LED 引脚、串口波特率、外部晶振频率全部用宏定义放在头文件里。这套最小系统跑稳之后,后面调总线、调协议栈、调传感器,全靠它兜底。裸机初始化不是背寄存器的过程,而是理解芯片怎么对外设供电、怎么分时钟、怎么连接中断线。把这条主线理清楚,换任何一款 MCU 都只是查手册的事。