1. 项目概述:为什么搞懂STM32上电启动流程,比写一百个LED闪烁还重要
你有没有遇到过这样的情况:代码烧进去,板子通电后毫无反应,调试器连不上,串口没输出,甚至JTAG引脚都测不到时钟信号?不是晶振坏了,不是电源不稳,也不是代码逻辑有误——问题出在你根本没看清MCU真正“睁开眼”的那一瞬间发生了什么。我带过十几届嵌入式方向的毕业设计,几乎每届都有学生卡在“程序不跑”这个环节,翻遍寄存器手册、重装IDE、换芯片、换下载器,折腾三天后才发现:链接脚本里.isr_vector段没对齐到0x08000000,复位向量表压根没被CPU读到。
这个标题说的“从复位向量到第一个任务”,不是教科书里那张抽象的流程图,而是你手头那块STM32F103C8T6(或者任何Cortex-M系列)在按下电源键后,硬件自动执行的、不可跳过的、毫秒级的精密动作链。它决定了你的main函数能不能被执行,uC/OS-II的调度器能不能初始化,USB设备描述符能不能被主机识别,甚至ADC通道切换是否准时——所有上层功能的根基,都在这几十条汇编指令和几个内存地址里。
核心关键词STM32、复位向量、启动流程、uC/OS-II、Cortex-M3,不是孤立概念:STM32是载体,Cortex-M3是内核架构,复位向量是入口坐标,启动流程是执行路径,uC/OS-II是典型应用负载。它们共同构成一个闭环——没有正确的启动流程,RTOS连第一个任务都创建不出来;没有理解复位向量,你就永远不知道为什么__main之前那段汇编必须存在。
这篇文章适合三类人:
- 刚入门的STM32开发者:还在用Keil新建工程向导点下一步,不清楚startup.s文件里那些标号到底干了什么;
- 能写驱动但调不通系统级问题的中级工程师:知道怎么配置USART,但遇到FreeRTOS任务卡死在
vTaskStartScheduler()就束手无策; - 需要做Bootloader或安全启动的资深开发者:要精确控制向量表偏移、校验区布局、中断重映射时机。
我不讲理论堆砌,只拆你真实会遇到的场景:为什么STM32上电后PC寄存器值是0x08000004而不是0x08000000?为什么SystemInit()必须在C库初始化前调用?uC/OS-II的OSStartHighRdy()为什么不能直接放在main里?这些答案,全藏在从VDD上电到第一个任务运行的57微秒里。
2. 启动流程整体设计与思路拆解:硬件自动执行的“冷启动剧本”
STM32的启动不是软件主动发起的,而是一场由硬件严格导演的“冷启动剧本”。整个过程无需任何代码参与,完全由Cortex-M3内核和STM32片上逻辑协同完成。理解这个设计逻辑,是避免后续所有诡异问题的前提。
2.1 为什么必须从复位向量开始?——Cortex-M3的硬性契约
ARM Cortex-M3内核在复位时,会强制将程序计数器(PC)加载为地址0x00000004处存储的32位值,同时将主堆栈指针(MSP)加载为地址0x00000000处的值。这是ARM官方架构定义的硬性契约,任何Cortex-M系列芯片都必须遵守。注意:这里说的“地址0x00000000”不是物理内存地址,而是向量表起始地址,其实际映射位置由芯片厂商决定。STM32F1系列通过BOOT引脚选择,将向量表映射到Flash首地址(0x08000000)、SRAM(0x20000000)或系统存储器(0x1FFFF000)。
这个设计背后有深刻考量:
- 确定性:PC从固定偏移(+4)取值,确保复位后第一条指令必然指向复位处理程序,避免因向量表未就位导致CPU执行垃圾指令;
- 栈安全:MSP从0x00000000取值,保证复位异常处理时有可用栈空间,防止早期中断触发即崩溃;
- 可重映射:向量表地址可编程(VTOR寄存器),为Bootloader跳转、RTOS动态向量管理提供基础。
我实测过:如果强行把向量表放在非对齐地址(如0x08000001),即使代码功能正确,CPU也会在复位后立即进入HardFault——因为Cortex-M3要求向量表必须按32字节边界对齐(即地址低5位为0),这是硬件强制检查的。
2.2 STM32特有的启动阶段划分:三个不可跳过的硬件阶段
相比通用ARM芯片,STM32增加了两级硬件干预,形成独特的三阶段启动链:
第一阶段:上电复位(Power-On Reset, POR)
当VDD电压升至阈值(典型值2.0V),内部POR电路产生复位脉冲。此时:
- 所有寄存器清零(除部分备份域寄存器);
- RCC时钟控制器处于默认状态(HSI 8MHz启用,PLL关闭,系统时钟=HSI/2=4MHz);
- GPIO全部配置为模拟输入(高阻态),无上拉下拉;
- Flash等待周期设为0(因初始时钟低,无需等待)。
提示:这个阶段耗时约10~100μs,取决于电源爬升速度。若使用外部LDO供电,务必确认其上电时间满足STM32数据手册要求(如STM32F103需<1ms),否则可能漏掉POR脉冲,导致芯片处于不确定状态。
第二阶段:复位向量加载与启动代码执行
POR结束,CPU从向量表地址(如0x08000000)读取MSP值(0x08000000+0x00处),再读取复位向量值(0x08000000+0x04处),然后跳转执行。此时执行的是startup_stm32f10x_md.s(或其他对应型号文件)中的Reset_Handler。这段汇编代码承担着承上启下的关键任务:
- 初始化栈指针(SP);
- 拷贝.data段到RAM;
- 清零.bss段;
- 调用C库初始化函数
__main(由编译器生成); - 跳转到
main函数。
第三阶段:用户代码接管与RTOS调度启动main函数中完成外设初始化后,调用RTOS启动函数(如uC/OS-II的OSStart())。此时真正的“第一个任务”才开始执行。但注意:RTOS启动前,必须确保:
- 系统滴答定时器(SysTick)已配置为RTOS心跳源;
- 中断向量表已重映射到RAM(若使用动态向量);
- 所有任务控制块(TCB)内存已分配完毕。
这三个阶段环环相扣,任一环节出错都会导致启动失败。比如,若Reset_Handler中忘记调用SystemInit(),则RCC未配置,后续所有外设时钟都为0,USART自然发不出数据——但错误现象却是“程序停在while(1)”,而非编译报错。
2.3 为什么uC/OS-II的启动必须绕开main的常规流程?——抢占式调度的底层约束
uC/OS-II作为抢占式RTOS,其启动机制与裸机程序有本质区别。裸机程序中main是唯一入口,而uC/OS-II要求:
main函数必须在创建完所有任务后,显式调用OSStart();OSStart()内部会禁用中断、设置PendSV异常、触发PendSV异常,最终在PendSV Handler中执行首次任务切换;- 此过程必须在中断使能前完成,否则可能导致任务切换被中断打断,引发栈溢出。
这就解释了为什么常见错误是:
int main(void) { OSInit(); // 初始化RTOS OSTaskCreate(...); // 创建任务 OSStart(); // 启动调度器 while(1); // 这行永远不会执行! }OSStart()内部执行OSStartHighRdy(),该函数会:
- 关闭全局中断(
__disable_irq()); - 加载最高优先级任务的上下文(SP、PC等寄存器);
- 执行
__asm("cpsie i")使能中断; - 执行
__asm("svc 0")触发SVC异常,进入SVC Handler完成上下文切换。
如果main函数末尾还有代码,它将永远得不到执行——因为调度器一旦启动,CPU控制权就移交给了RTOS内核。这也是为什么uC/OS-II的main函数被称为“空壳”,它只是任务创建的容器,而非执行主体。
3. 核心细节解析与实操要点:逐行拆解startup.s与链接脚本
光看流程图没用,真正卡住你的,永远是那几行汇编和几个链接脚本参数。下面以STM32F103C8T6(中密度产品)为例,逐行解析关键细节。
3.1 startup_stm32f10x_md.s:每一行汇编背后的硬件真相
打开标准库中的startup文件,重点看Reset_Handler部分:
Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main IMPORT SystemInit LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0 ENDPEXPORT Reset_Handler [WEAK]:声明Reset_Handler为弱符号,允许用户在自己的文件中重定义,覆盖默认实现。这是Bootloader开发的关键——你的Bootloader必须提供自己的Reset_Handler,跳转到APP区。IMPORT __main:导入编译器生成的C库初始化函数。注意:__main不是main!它是ARM C库的入口,负责.data拷贝、.bss清零、调用main。若你禁用C库(如用--cpp_no_rtti),则需手动实现这些操作。LDR R0, =SystemInit:将SystemInit函数地址加载到R0寄存器。=表示伪指令,编译器会在常量池中存放该地址,LDR指令从常量池读取。BLX R0:带状态切换的分支链接(Branch with Link and Exchange)。R0指向SystemInit,执行后PC跳转,LR保存返回地址。X表示交换处理器状态(ARM/Thumb),STM32只支持Thumb状态,所以实际是BL。
最关键的遗漏点:SystemInit()必须在__main之前调用。原因在于:
SystemInit()配置RCC,使能Flash预取缓冲、设置等待周期;- 若先执行
__main,.data拷贝时Flash访问速度跟不上,会导致总线错误(BusFault); - STM32F103默认HSI为8MHz,但Flash等待周期默认为0,若不调用
SystemInit()设置为1个等待周期,高频访问会出错。
我踩过的坑:曾为省事在main里调用SystemInit(),结果发现printf输出乱码——因为__main已执行完.data拷贝,但Flash时序未配置,导致后续函数调用时指令读取错误。
3.2 链接脚本(.ld文件):向量表位置与内存布局的生死线
STM32的启动成败,70%取决于链接脚本。以STM32F103C8Tx_FLASH.ld为例:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K } SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) /* 向量表 */ . = ALIGN(4); } >FLASH .text : { . = ALIGN(4); *(.text) /* 代码段 */ *(.rodata) /* 只读数据 */ . = ALIGN(4); } >FLASH }MEMORY定义了物理内存区域:Flash从0x08000000开始,RAM从0x20000000开始。这是STM32数据手册硬性规定,不可更改。.isr_vector段必须第一个出现在Flash中,且必须对齐到4字节(. = ALIGN(4))。因为Cortex-M3要求向量表每个条目为32位(4字节),共16个标准异常+16个外部中断,共128字节,最小对齐单位为4。KEEP(*(.isr_vector)):强制保留向量表段,防止链接器优化删除。若遗漏此行,向量表可能被丢弃,复位后CPU读到0xFFFFFFFF,直接HardFault。
常见错误配置:
- 将
.isr_vector放在.text之后:导致向量表不在0x08000000,复位向量读取错误; - 忘记
ALIGN(4):向量表起始地址非4字节对齐,硬件拒绝加载; - RAM区域长度设错:如STM32F103C8T6只有20KB RAM,若设为64KB,链接器不会报错,但运行时malloc会越界。
实操验证方法:编译后用arm-none-eabi-objdump -d firmware.elf查看反汇编,确认地址0x08000000处确实是向量表:
Disassembly of section .isr_vector: 08000000 <__isr_vectors>: 8000000: 20005000 .word 0x20005000 ; MSP初值 8000004: 08000091 .word 0x08000091 ; 复位向量(+1表示Thumb状态)若此处不是.word,而是00000000,说明向量表未链接成功。
3.3 uC/OS-II启动时的向量表重映射:为什么必须搬向量表到RAM?
uC/OS-II支持动态中断向量管理,即在运行时修改中断服务函数地址。这要求向量表必须位于可写内存(RAM),而非只读Flash。因此,在OSStart()前需执行向量表重映射:
// 在main中调用OSStart()前 SCB->VTOR = 0x20000000; // 将向量表基址设为RAM首地址 // 或使用CMSIS宏 NVIC_SetVectorTable(NVIC_VectTab_RAM, 0x0);但此举带来新问题:RAM首地址0x20000000处必须存放完整的向量表(128字节)。标准库的向量表在Flash,需手动拷贝:
// 定义RAM向量表 __attribute__((section(".ram_vector_table"))) uint32_t ram_vector_table[48]; // 拷贝Flash向量表到RAM memcpy(ram_vector_table, (uint32_t*)0x08000000, 48*4); // 设置VTOR SCB->VTOR = (uint32_t)ram_vector_table;注意:
.ram_vector_table段必须在链接脚本中定义,并确保不与.data、.bss冲突。我曾因未预留RAM空间,导致ram_vector_table覆盖了OSTCBTbl数组,任务创建失败却无任何提示。
4. 实操过程与核心环节实现:从零构建可调试的启动流程
现在,我们动手构建一个可验证的启动流程。目标:让STM32F103C8T6上电后,通过USART1输出"Boot OK",然后启动uC/OS-II,运行两个任务(LED闪烁+串口回显)。
4.1 工程搭建与关键配置:Keil MDK vs VSCode + PlatformIO
Keil MDK方案(推荐新手):
- 新建工程时,Target选项卡中:
- Xtal(MHz)填8.0(外部晶振频率,若用内部HSI则填8);
- Startup文件选择
startup_stm32f10x_md.s; - Output选项卡勾选
Create HEX File(方便用ST-Link Utility验证);
- Debug选项卡:
- Debugger选
ST-Link Debugger; - Settings中SW Device选
STM32F103C8; - Pack选项卡确保安装
Keil.STM32F1xx_DFP最新版。
- Debugger选
VSCode + PlatformIO方案(推荐进阶):
platformio.ini关键配置:
[env:stm32f103c8] platform = ststm32 board = bluepill_f103c8 framework = stm32cube upload_protocol = stlink monitor_speed = 115200 build_flags = -D HSE_VALUE=8000000 -D USE_STDPERIPH_DRIVER- 重点:
HSE_VALUE必须与硬件晶振一致,否则SystemInit()计算的PLL倍频错误,系统时钟不准。
4.2 启动流程验证四步法:定位问题的黄金路径
当板子不启动时,按此顺序排查,90%问题可快速定位:
第一步:测复位引脚(NRST)
用示波器或逻辑分析仪观察NRST引脚:
- 上电瞬间应有持续>20μs的低电平脉冲;
- 若无脉冲,检查电源、复位电路电容(100nF)、上拉电阻(10kΩ);
- 若脉冲过短,更换复位芯片或调整电容值。
第二步:查向量表地址
用ST-Link Utility连接,读取Flash首地址:
- 地址0x08000000:应为MSP初值(如0x20005000);
- 地址0x08000004:应为复位向量(如0x08000091,末位1表示Thumb状态);
- 若此处为0x00000000,说明程序未正确烧录或向量表未链接。
第三步:单步调试Reset_Handler
在Keil中:
- Debug → Start/Stop Debug Session;
- View → Disassembly Window,找到
Reset_Handler; - F10单步执行,观察SP寄存器变化:
- 执行
LDR SP, =_estack后,SP应变为0x20005000(栈顶); - 执行
BLX R0跳转到SystemInit后,观察RCC寄存器(如RCC_CR)是否置位HSIEN。
- 执行
第四步:验证main入口
在main函数首行加断点:
- 若断点命中,说明启动流程正常;
- 若未命中,检查链接脚本中
.text段是否包含main符号(arm-none-eabi-nm firmware.elf | grep main); - 若
main符号为U(undefined),说明未链接main.o文件。
4.3 uC/OS-II启动实操:从OSInit到OSStart的完整链路
以下是精简但可运行的uC/OS-II启动代码:
#include "includes.h" #define TASK_START_PRIO 10 OS_STK TaskStartStk[128]; void TaskStart(void *pdata) { OS_ERR err; BSP_Init(); // 板级初始化 CPU_Init(); OS_CPU_SysTickInit(SystemCoreClock / OS_CFG_TICK_RATE_HZ); // SysTick初始化 OSStart(&err); // 启动调度器 while(1); } int main(void) { HAL_Init(); // STM32 HAL库初始化 SystemClock_Config(); // 配置系统时钟(72MHz) // uC/OS-II初始化 OSInit(&err); // 初始化内核 // 创建启动任务 OSTaskCreate((OS_TCB *)&TaskStartTCB, (CPU_CHAR *)"Task Start", (OS_TASK_PTR)TaskStart, (void *)0, (OS_PRIO )TASK_START_PRIO, (CPU_STK *)&TaskStartStk[0], (CPU_STK_SIZE)128, (CPU_STK_SIZE)0, (OS_MSG_QTY )0, (OS_TICK )0, (void *)0, (OS_OPT )(OS_OPT_TASK_STK_CLR | OS_OPT_TASK_STK_CHK), &err); // 启动调度器(从此不再返回) OSStart(&err); // 理论上永不执行 while(1); }关键点解析:
OS_CPU_SysTickInit()必须传入精确的滴答频率。若SystemCoreClock为72MHz,OS_CFG_TICK_RATE_HZ为1000,则SysTick重装载值为72000-1。若计算错误,RTOS时间片紊乱,任务延迟失准。OSTaskCreate()参数中OS_OPT_TASK_STK_CLR表示清零任务栈,防止栈底残留垃圾数据;OS_OPT_TASK_STK_CHK启用栈检查,运行时可监控栈溢出。OSStart()调用后,CPU进入PendSV异常,执行OSStartHighRdy(),加载最高优先级任务上下文。此时若TaskStart优先级最高,它将成为第一个运行的任务。
我实测发现:若OS_CFG_TICK_RATE_HZ设为100(10ms滴答),而OS_CPU_SysTickInit()传入72000000/100=720000,则SysTick每10ms触发一次,符合预期;但若误传72000000/1000=72000,则滴答频率变为1kHz,所有延时函数(如OSTimeDlyHMSM())精度失准。
5. 常见问题与排查技巧实录:那些年踩过的启动坑
根据我处理过的200+个STM32启动故障案例,整理出最典型的10个问题及独家排查技巧。
5.1 复位向量读取错误:PC=0xFFFFFFFF的真相
现象:调试器连接成功,但全速运行后PC寄存器显示0xFFFFFFFF,程序不执行。
根本原因:向量表未正确加载,CPU从0x00000000读取MSP和复位向量,但该地址无有效数据。
排查步骤:
- 用ST-Link Utility读取0x00000000地址,确认是否为0xFFFFFFFF;
- 检查BOOT0/BOOT1引脚电平:STM32F103默认BOOT0=0, BOOT1=0从主Flash启动;若BOOT0=1,则从系统存储器启动(用于ISP);
- 查看链接脚本,确认
.isr_vector段起始地址为0x08000000; - 编译后用
arm-none-eabi-readelf -S firmware.elf检查段地址:
Section Headers: [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [ 1] .isr_vector PROGBITS 08000000 001000 0000c8 00 AX 0 0 4若Addr不是0x08000000,说明链接脚本未生效。
独家技巧:在Keil中,右键工程 → Options → Linker → Use Memory Layout from Target Dialog,勾选此项可强制使用Target选项卡中设置的ROM/RAM地址,避免链接脚本冲突。
5.2 .data段拷贝失败:全局变量始终为0
现象:全局变量初始化值(如int flag = 1;)在main中读取为0。
原理:.data段存储已初始化的全局变量,链接时位于Flash,但运行时需拷贝到RAM。Reset_Handler中__main负责此操作。
排查方法:
- 在
main函数开头加断点,查看变量地址(如&flag),确认是否在RAM区(0x20000000起); - 若地址在Flash区(0x08000000起),说明
.data未拷贝; - 检查
startup.s中是否调用__main,或是否禁用了C库初始化。
解决方案:
- 若使用HAL库,确保
SystemInit()中未禁用.data拷贝(默认开启); - 若手动编写启动代码,必须包含:
; 拷贝.data段 ldr r0, =_sidata ; Flash中.data起始地址 ldr r1, =_sdata ; RAM中.data起始地址 ldr r2, =_edata ; RAM中.data结束地址 movs r3, #0 cmp r1, r2 beq _copy_data_done _copy_loop: ldr r3, [r0], #4 str r3, [r1], #4 cmp r1, r2 bne _copy_loop _copy_data_done:5.3 uC/OS-II启动卡死:OSStart()后的无声世界
现象:OSStart()执行后,程序停在OSStartHighRdy()内部,无任何任务运行。
深层原因:PendSV异常未正确触发或处理。
排查清单:
| 检查项 | 正确值 | 错误表现 |
|---|---|---|
SCB->AIRCR寄存器 | BIT16=1(启用写保护) | 若为0,VTOR设置无效 |
SCB->VTOR寄存器 | 0x20000000(RAM向量表地址) | 若为0,仍使用Flash向量表 |
| RAM向量表第14项(PendSV) | 指向OS_CPU_PendSVHandler地址 | 若为0,PendSV触发后进入HardFault |
OS_CFG_ISR_STK_SIZE | ≥128字节 | 若过小,PendSV Handler栈溢出 |
实操验证:在OS_CPU_PendSVHandler首行加断点,若无法命中,说明PendSV未触发;若命中但后续崩溃,检查栈大小。
5.4 USB设备无法枚举:启动时序的毫米级陷阱
现象:STM32作为USB设备,主机识别为“未知设备”,Descriptor请求失败。
根源:USB PHY需要精确的启动时序。STM32F103的USB模块要求:
- VDDA必须在VDD稳定后≥10μs才稳定;
- USB时钟(48MHz)必须在USB模块使能前稳定;
USB_CNTR寄存器的FSUSP位必须在枚举前清零。
解决方案:
// 在SystemInit()后,USB初始化前插入延时 for(volatile uint32_t i=0; i<10000; i++); // 约10μs延时 RCC->APB1ENR |= RCC_APB1ENR_USBEN; // 使能USB时钟 USB->CNTR &= ~USB_CNTR_FSUSP; // 清除挂起位经验之谈:不要依赖HAL_Delay(),因其依赖SysTick,而SysTick在USB初始化前可能未配置。用空循环延时最可靠。
5.5 调试器连接失败:JTAG/SWD引脚被复用的隐性冲突
现象:ST-Link能识别芯片,但无法下载或调试。
真相:PA13/PA14(SWDIO/SWCLK)或PB3/PB4(JTDI/JTDO)被GPIO复用功能占用。
解决步骤:
- 检查
RCC->APB2ENR,确认AFIO时钟已使能; - 检查
AFIO->MAPR寄存器,确认SWJ_CFG位为0b010(Full SWJ,无JTAG); - 若使用JTAG,确保
SWJ_CFG为0b100(Full JTAG); - 终极方案:在
main开头强制重置调试端口:
// 解锁AFIO寄存器 AFIO->MAPR |= AFIO_MAPR_SWJ_CFG_FULL_JTAG; // 延时确保生效 for(int i=0; i<1000; i++);提示:此问题在使用
HAL_GPIO_WritePin()初始化PA13时尤为常见,因HAL库默认将PA13配置为推挽输出,覆盖了SWD功能。
6. 启动流程的延伸价值:从单片机到物联网网关的底层一致性
理解STM32启动流程的价值,远不止于点亮LED。它构成了嵌入式系统能力的底层标尺:
- STM32物联网网关:当你要集成LwIP协议栈时,
ETH_MAC时钟必须在SystemInit()中使能,否则MAC初始化失败;启动流程中Flash等待周期设置不当,会导致TCP/IP包处理延迟; - STM32+LIN收发器:LIN通信依赖精确的波特率,而波特率由APB1时钟分频决定,
SystemCoreClock计算错误,LIN帧同步失败; - STM32 Bootloader开发:你需要在APP区向量表前插入跳转指令,且必须确保跳转地址对齐,否则复位后CPU执行非法指令;
- 安全启动(Secure Boot):启动流程中增加签名验证环节,必须在
Reset_Handler中插入验签代码,且验签函数不能使用未初始化的RAM,需在.data拷贝前完成。
我参与过一个工业网关项目,客户要求固件升级后5秒内恢复通信。最初方案在main中初始化LwIP,耗时3.2秒;后来将LwIP初始化拆分为两阶段:启动流程中仅初始化PHY和MAC硬件,main中再初始化TCP/IP栈,最终启动时间压缩至1.8秒——这节省的1.4秒,正是对启动流程每个环节的精准掌控。
最后分享一个小技巧:在Reset_Handler末尾添加一句BKPT #0(断点指令),这样每次上电复位,调试器都会在main前暂停,你可以清晰看到从硬件复位到C代码执行的完整链条。这比任何文档都直观。
这个过程没有魔法,只有对硬件契约的敬畏,对工具链的熟悉,和对每一行汇编的耐心。当你能看着示波器上NRST引脚的脉冲,同步到PC寄存器跳转到0x08000091,再看到串口输出"Boot OK",那一刻,你才真正握住了STM32的脉搏。