以 CH32X035 的 startup_ch32x035.S 为例:启动文件与启动流程精读

结论速览

  • 上电不是main 开始:硬件从 0x0 取指,第一条是 .init 里的 j handle_reset(实测 j 到 0x4c6),启动代码本体在 .text 里。
  • handle_reset 七步:gp → sp → 搬 .data → 清 .bss → 4 个 CSR → SystemInit()mepc = main; mret
  • .vector数据表不是代码:55 项 / 0xdc 字节;未实现的中断全部指向同一个 2 字节自跳 j .(0x4c4)。
  • mainmretra 未设置,所以 main 不能 return;ISR 依赖 0x804 = 3 打开的硬件栈 + interrupt("WCH-Interrupt-fast")

一、读这份 .S 之前要用的知识

1.1 一个启动文件里其实有三类内容

内容 例子 落到哪个段
段指令/伪指令 .section.align.option.word 不产生代码,只影响布局
数据表 .word NMI_Handler 等 55 行 .vector
指令 j handle_resetla sp, _eusrstackcsrw .init.text.vector_handler.text.handle_reset

汇编器把它们分别塞进不同段,再由链接脚本(Ld/Link.ld)排序定位。两份文件是同一件事的两半.S 负责”顺序和语义”,.ld 负责”地址和符号”。

1.2 出现的指令速认

指令 含义 在本文件里的用途
j label 无条件跳转(不保存返回地址) _start 跳到 handle_reset;弱符号桩 j 1b
jal ra, func 跳转并保存返回地址 jal SystemInit
la rd, sym 取符号地址(auipc + addi 两条) 取 gp/sp/_data_lma
lui / addi 拼一个 32 位立即数/地址 配合 la,也被 li 展开
li rd, imm 装载小立即数 li t0, 0x3
lw / sw 读/写内存 .data 搬运、.bss 清零
bgeu / bltu 无符号比较跳转 搬运与清零的循环条件
csrw csr, rs 写 CSR(Control and Status Register,控制状态寄存器) 0xbc00x804mstatusmtvecmepc
mret 从机器模式异常返回 main;ISR 返回

1.3 数字标签 1: / 1b / 2f

1:2:可重复使用的局部标签1b = 往回找最近的 1:2f = 往前找最近的 2:。所以这个文件里 1: 出现了 4 次、2: 出现了 4 次都不冲突——每段循环各用各的。这是汇编里避免起名字的惯用法。

1.4 .option 三个

写法 作用 为什么需要
.option norelax / .option pop 关掉链接器”放松”优化 la gp, __global_pointer$ 若被放松成 gp 相对寻址,就成了”用 gp 算 gp”的鸡生蛋问题,所以启动代码里必须先关掉
.option norvc / .option rvc 关/开压缩指令(RVC,RISC-V Compressed) 向量表本来要求 4 字节步长;这里表项是 .word(天然 4 字节),norvc 是防止汇编器在该区间生成 2 字节指令破坏布局的保险

1.5 .align 1 在 RISC-V 是 2 的 1 次方

RISC-V 汇编里 .align n2^n 字节(和 ARM 的”字节数”语义不同)。证据:objdump 的段表里 .init.vector 的对齐都是 2**1,即 2 字节,正好对应源码里的 .align 1——为支持 RVC 的半字对齐。ARM 思维下把 .align 1 当成 1 字节,或者把 .align 4 当成 4 字节,都会看错。

1.6 这份文件用到的 CSR

CSR 地址 标准还是私有 本文件写入值
mstatus 0x300 RISC-V 标准 0x88
mtvec 0x305 RISC-V 标准 _vector_base | 3
mepc 0x341 RISC-V 标准 main 的地址
0xbc0 WCH 私有 0x1f
0x804 WCH 私有 0x3

⚠ 还有两个容易混的近亲,都不在本文件里:

  • 0x800:WCH 的全局中断使能,C 里 __enable_irq() 就是 csrs 0x800, 0x88core_riscv.h)。
  • 0x804:中断系统配置(本文件写的那个)。0x800 和 0x804 差一位,功能完全不同,是这类代码最常见的看错点。

1.7 弱符号 + 中断属性

1
2
3
4
5
6
7
    .weak   NMI_Handler
...
NMI_Handler:
HardFault_Handler:
...
1:
j 1b

.weak 的含义是”这个符号先按我给的地址占位,如果别的 .o 里有强定义,就以它为准”。于是启动文件给每个中断都提供了一个默认桩(全部指向同一个 j 1b 死循环),工程里任何 .c 只要定义同名函数就自动接管。

中断函数的属性也不是随便写的:

1
void TIM3_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")));

WCH-Interrupt-fast 告诉编译器:”硬件栈模式已开,进来时寄存器由硬件压栈,你只需要保存自己用到的 callee-saved 寄存器,返回用 mret。”这个”硬件栈模式已开”的前提,正是启动文件里那句 csrw 0x804, 3

二、启动流程与全程地址

1
2
3
4
5
6
7
8
9
10
11
12
13
复位 → 硬件从 0x0 取指
0x000: 4c60006f j 0x4c6 ← .init 唯一一条指令(_start / _sinit)
0x4c6: auipc/addi gp → 0x20000880 __global_pointer$
0x4ce: auipc/addi sp → 0x20005000 _eusrstack(RAM 顶端)
0x4d6: 搬 .data 0xb458 → 0x20000000,共 0xb0 字节
0x4fe: 清 .bss 0x200000b0 → 0x200020c0
0x518: csrw 0xbc0 ← 0x1f 流水线/指令预测
0x51e: csrw 0x804 ← 0x3 中断嵌套 + 硬件栈
0x524: csrw mstatus← 0x88 MIE | MPIE
0x52c: csrw mtvec ← 0x7 _vector_base(0x4) | 3
0x53c: jal 0x43e SystemInit()
0x540: csrw mepc ← 0x334 main
0x54c: mret → main,永不返回

之后的中断路径:

1
2
3
外设触发中断 → 硬件按 mtvec 模式查表 → 表项 = 4 + 4×中断号
例:TIM3 中断号 54 → 表址 0xdc → 0x2ccc = TIM3_IRQHandler(bsp_tick.c)
硬件压栈(HPE)→ ISR 只存 s0 → 业务 → mret

(表项 0 之外的地址都可数:. vector 从 0x4 起,第 n 项在 0x4 + 4n。)

三、逐段精读

3.1 .init:整段只有一条指令

1
2
3
4
5
	.section	.init,"ax",@progbits
.global _start
.align 1
_start:
j handle_reset
  • .section .init,"ax",@progbits:进入名字叫 .init 的段,"a" = 分配地址、"x" = 可执行,@progbits = 有内容。
  • .global _start 把它导出,这样 Link.ld 里的 ENTRY(_start) 才能找到它。
  • 为什么只有一条 j?因为复位后内核固定从 0x0 取指,而这里空间最紧张、最讲究”只能放一条能跳到远方的指令”。真正的启动代码放到 .text 里,让链接器去排地址。
  • 实测这条 j 编码是 4c60006f,跳转目标 0x4c6——Ld/Link.ld.init 位于 0x0、大小 4 字节完全对得上

_start 是怎么落到 0x0 的——不是启动文件一家干的,是四个环节接力:

环节 动作
汇编 本文件(startup_ch32x035.S:13-16 .section .init_start 放进 .init 段,位置是段内偏移 0;.global 使它可被引用
定位 Ld/Link.ld MEMORYFLASH ORIGIN = 0x00000000,且 SECTIONS.init 是第一个输出段.init 的 VMA/LMA = 0x0,于是 _start = 0x0
记入口 Link.ldENTRY( _start ) 写进 ELF 头 e_entryobjdump 才显示 start address 0x00000000
复位 QingKe 内核 复位后固定从 0x0 取指,不查 ELF 头、不问 ENTRY

⚠ 最后一行是重点:ENTRY(_start) 只是记录,硬件只认 0x0,两者必须一致——这就是 .init 必须在 SECTIONS 里排第一、FLASH 必须从 0x0 开始的原因。另外,若别的 .o 也往 .init 里放东西,谁在 0x0 就取决于链接顺序:KEEP(*(SORT_NONE(.init))) 里的 SORT_NONE 是”不排序、按链接顺序”,这种冲突不会报错,只会静默换掉第一条指令

3.2 .vector:55 项数据表,不是代码

1
2
3
4
5
6
7
8
    .section    .vector,"ax",@progbits
.align 1
_vector_base:
.option norvc;
.word _start
.word 0
.word NMI_Handler
...(共 55 行 .word)

表项分布:

索引 内容 索引 内容
0 _start(惯例占位,复位其实直接走 0x0) 8 Ecall_U_Mode_Handler
1 0(保留) 9 Break_Point_Handler
2 NMI_Handler 10、11 0(保留)
3 HardFault_Handler 12 SysTick_Handler
4 0(保留) 13 0(保留)
5 Ecall_M_Mode_Handler 14 SW_Handler
6、7 0(保留) 15 0(保留)
16 起 外设中断:WWDG、PVD、FLASH、EXTI7_0、AWU、DMA1_CH18、ADC1、I2C1、USART14、SPI1、TIM1~3、USBFS、PIOC、OPA、USBPD…… 共 39 项

⚠ 两个常被算错的数字:

  • 表项数是 55,不是 63.word 一共 55 行 → 内容 0xdc 字节。
  • objdump.vector 段大小显示 0xfc,比 0xdc 多 0x20 —— 那是 Link.ld.vector 段尾的 . = ALIGN(64) 填充,把后面 .text 顶到 0x100 造成的。别把它当成”有 63 项”。

三个内核相关的表项值得单独记:

  • 表项 12 = SysTick_Handler:本工程 SysTick 是内核 64 位时基,只服务 Delay_Us()/Delay_Ms() 的轮询计数,不开中断(见 User/ch32x035_it.c 的说明)。1ms 节拍实际由 TIM3 提供。
  • **表项 14 = SW_Handler**:软件中断。裸机工程里是空的默认桩;FreeRTOS 的 port 在 portASM.S 里自己实现了它,用来做任务切换。
  • **表项 9 = Break_Point_Handler**、表项 8 = Ecall_U_Mode_Handler:说明这颗 QingKe 内核的表里保留了 U 模式和断点异常的位置,模板一律填空。

3.3 弱符号桩:40 多个标签共用一个死循环

1
2
3
4
5
6
7
8
9
    .section    .text.vector_handler, "ax", @progbits
.weak NMI_Handler
...(40 多个 .weak)
NMI_Handler:
HardFault_Handler:
...
TIM3_IRQHandler:
1:
j 1b
  • 从第 128 行到第 172 行是连续的标签,中间没有指令——所以它们全部等于同一个地址。实测就是 0x4c4,里面是一条 2 字节的 c.j .(自跳)。
  • 于是”未实现的中断”= 进去就出不来。这是 WCH 模板的固定行为,不是 bug:它把”漏写 ISR”变成明显现象(卡死)而不是随机跑飞。
  • 覆盖方法:在任何 .c 里定义同名函数即可,不需要动启动文件。本工程就是这么干的:
符号 默认桩 本工程实际
NMI_Handler 0x4c4 死循环 0x322,User/ch32x035_it.c 里也是 while(1)
HardFault_Handler 0x4c4 死循环 0x324,调 NVIC_SystemReset() 后死循环
TIM3_IRQHandler 0x4c4 死循环 0x2ccc,Code/UserBsp/bsp_tick.c 里累加 s_u32TickMs
其余 USART/USBFS/EXTI… 0x4c4 死循环 未被覆盖,仍是死循环

顺便记一个 HardFault 的细节:NVIC_SystemReset() 的实现在 core_riscv.h 里只有一行 NVIC->CFGR = NVIC_KEY3 | (1<<7),即往 0xE000E0480xBEEF0080PFIC->CFGR 的偏移是 0x48,NVIC_KEY3 = 0xBEEF0000)。反汇编里 HardFault_Handler 是内联展开的 lui/addi/sw 三条,和这条 C 语句一模一样——所以这颗芯片”硬故障即复位”是库函数做的,不是硬件自动行为。

3.4 第一步与第二步:gp、sp

1
2
3
4
5
6
7
handle_reset:
.option push
.option norelax
la gp, __global_pointer$
.option pop
1:
la sp, _eusrstack
  • gp 必须先设:编译器对 .sdata/.sdata2/.sbss 里的小变量用 gp 相对寻址(±2K)。gp 没设好之前,任何访问 small data 的代码都会跑飞——所以这一步排在最前面,而且用 .option norelax 把自己保护起来。
  • sp 相对不那么急,但要在任何函数调用前设好,否则压栈直接写飞。实测 sp = _eusrstack = 0x20005000,即 RAM 顶端(ORIGIN(RAM)+LENGTH(RAM))。
  • 这两步用的都是链接脚本提供的符号,正好接着 Link.ld 那篇说的”符号契约”:.S 要什么名字,.ld 就必须 PROVIDE 出来。

3.5 搬 .data:Flash 初值 → RAM 变量

1
2
3
4
5
6
7
8
9
10
11
	la a0, _data_lma
la a1, _data_vma
la a2, _edata
bgeu a1, a2, 2f
1:
lw t0, (a0)
sw t0, (a1)
addi a0, a0, 4
addi a1, a1, 4
bltu a1, a2, 1b
2:
  • 语义:把 Flash 上 [_data_lma, _data_lma + size) 的内容拷到 RAM 的 [_data_vma, _edata),一次 4 字节(RISC-V 不支持内存到内存的块拷贝指令,只能 lw+sw)。
  • 实测:源 0xb458、目标 0x20000000、终点 0x200000b0,共 0xb0 = 176 字节
  • 一个可以直观看到”链接器放松(relaxation)”的细节:反汇编里 la a2, _edata 变成了 addi a2, gp, -2000——因为 _edata 落在 gp±2K 窗口内,汇编器/链接器直接用一条 gp 相对指令替换了 auipc+addi 两条。而 la sp, _eusrstack 因为太远,只能保留两条。
  • 循环边界用 bgeu(无符号比较):地址比较永远要用无符号,用有符号比较在跨越 0x80000000 时会出错。

3.6 清零 .bss

1
2
3
4
5
6
7
8
	la a0, _sbss
la a1, _ebss
bgeu a0, a1, 2f
1:
sw zero, (a0)
addi a0, a0, 4
bltu a0, a1, 1b
2:
  • .bss 里的变量在 C 语言语义下初值必须是 0,但它们在 Flash 里不占空间(NOBITS),所以只能运行时清零:实测 0x200000b00x200020c0,约 8.2 KB。
  • 这里 sw zero 一次写 4 字节,所以循环条件是”先判断再写”。如果 .bss 不是 4 字节对齐,这种循环会越界——保证它对齐的正是 Link.ld.bss 段头的 . = ALIGN(4)_edata 尾部的对齐。
  • 顺带看一段”拼接关系”:.bss 在 RAM 里紧跟在 .data 之后,实测 _edata = _sbss = 0x200000b0——两个循环首尾相接、互不重叠。它们都按 4 字节步进读写,所以必须靠 Link.ld 里的 ALIGN(4).data 尾的 PROVIDE(_edata = .) 之前、.bss 段头)来保证边界对齐;不对齐时 sw zero, (a0) 会越界写到别的变量上。

3.7 四个 CSR:真正的”内核开关”

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Configure pipelining and instruction prediction */
li t0, 0x1f
csrw 0xbc0, t0
/* Enable interrupt nesting and hardware stack */
li t0, 0x3
csrw 0x804, t0
/* Enable global interrupt and configure privileged mode */
li t0, 0x88
csrw mstatus, t0
/* Configure the interrupt vector table recognition mode and entry address mode */
la t0, _vector_base
ori t0, t0, 3
csrw mtvec, t0
写入 拆位 实测值 后果
0xbc0 ← 0x1f WCH 私有的流水线/指令预测控制 注释原话:”Configure pipelining and instruction prediction”;非标准 CSR,只有 WCH 手册有完整定义
0x804 ← 0x3 bit0 = 中断嵌套使能,bit1 = 硬件栈(HPE)使能 两者都开。这就是 interrupt("WCH-Interrupt-fast") 能成立的前提
mstatus ← 0x88 bit3 MIE = 1,bit7 MPIE = 1 MIE ← MPIE(=1),mret 之后全局中断是打开
mtvec ← 0x7 _vector_base(0x4) | 模式位 3 0x7 低两位是模式:按向量表、表项里放的是绝对地址

两条容易忽略的联动:

  1. 0x804 的中断嵌套位必须和 C 侧的优先级分组一致ch32x035_misc.h 里就直接写了提示:”Check the configuration of CSR(0x804) in the startup file(.S)”——嵌套关掉时,优先级寄存器只有 bit[7:5] 一个子优先级字段;嵌套打开时才有”抢占优先级 + 子优先级”。本工程启动时按嵌套开启配置(0x3),main 里的第一件事就是 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1),两边是对上的。
  2. mstatus 里同时也设了特权模式相关位。RTOS 变体把它改成 0x1800(MPP = 0b11、MIE = 0),用意是”返回后中断先别开,交给 OS 自己开”——见第五章。

3.8 SystemInit()mret 进 main

1
2
3
4
   jal  SystemInit
la t0, main
csrw mepc, t0
mret
  • SystemInit()User/system_ch32x035.c)做的是时钟RCC->CTLR |= 1 开 HSI,RCC->CFGR0 |= 0x50&= 0xF8FFFF5F,然后 SetSysClock() 按宏选择频率——本工程启用的是 SYSCLK_FREQ_48MHz_HSI,即 48MHz HSI(HSI_VALUE = 48000000)。注意它不管 RAM/时钟之外的事,也不初始化外设
  • mret 而不是 jalmain 是个关键细节:mretmepc 当目标、顺便完成特权级和中断状态的切换,但**它不设置 ra**。所以 mainra 是复位残留值,main 一旦 return 就是跳到垃圾地址。裸机上 main 必须以 while(1) 收尾,这不是风格问题。
  • 反汇编里能看到 main 的第一条不是压栈,而是 jal t0, __riscv_save_0——这是编译选项 -msave-restore 的产物:函数序言/尾声被抽成公共库函数以省代码体积,不认识的读者容易把它当成”调用了什么函数”。

四、中断是怎么进来的

把第二章那张图和本章连起来,一次完整的中断是这样的:

  1. 外设触发(例如 TIM3 更新事件),PFIC(Programmable Fast Interrupt Controller,可编程快速中断控制器,core_riscv.h 里用宏 NVIC 别名指同一块寄存器)拉高中断请求。
  2. 硬件按 mtvec 查表:低两位是 3 → 表项里是绝对地址,取 0x4 + 4 × IRQn 处的字作为入口。TIM3 的 IRQn = 54 → 地址 0xdc → 实测就是 0x2ccc
  3. 硬件压栈(HPE):因为 csrw 0x804, 3 打开了硬件栈,进入 ISR 时调用者保存寄存器(含 mepcmstatus)由硬件自动入栈,软件不用管。
  4. ISR 只保存自己用的 callee-saved。实测 TIM3_IRQHandler
1
2
3
4
5
6
7
00002ccc <TIM3_IRQHandler>:
2ccc: addi sp,sp,-16
2cce: sw s0,12(sp)
... 业务:查标志、清标志、s_u32TickMs++、LcdTick、BspButtonScanTick…
2d1a: lw s0,12(sp)
2d1c: addi sp,sp,16
2d1e: mret

对比一个普通函数(SystemInit):序言是 jal t0, __riscv_save_0,尾声是 retISR 的 mret 和”只存自己用到的寄存器”这两点,正是 interrupt("WCH-Interrupt-fast") 的效果;写错了(比如自己声明成普通函数、或手写 mret 却让编译器也生成返回序列)就会出现寄存器被破坏或连续返回两次的怪现象。

  1. 想让某个中断有响应,标准动作只有两步:打开外设的中断使能NVIC->IENR[...],外设库函数会做)+ 在任意 .c 里实现同名 xxx_IRQHandler 覆盖弱符号。启动文件一行都不用改。

五、其他例程的启动文件差异

tools/EVT/EXAM/ 下 11 份 startup_ch32x035.S 与工程版逐行比对,差别只有四处(SRC/Startup/ 那份与工程版完全相同):

例程 差异 含义
RunInRam/RunInRAM_Select .bss 清零之后多一段循环:la a0,_highcode_lma / a1 =_highcode_vma_start / a2 =_highcode_vma_endlw/sw 搬到 RAM .ld.highcode >RAM AT>FLASH 配对,把标了 .highcode 的函数搬进 RAM 跑
FreeRTOS / HarmonyOS LiteOS_m / TencentOS csrw 0x804, 2(只开硬件栈,中断嵌套)、mstatus ← 0x1800(MPP=0b11、MIE=0,返回后全局中断保持关闭) RTOS 自己管临界区和优先级,不希望内核悄悄嵌套;中断由 OS 启动调度器时再开
RT-Thread 同上两条,并且把入口从 main 换成 entry RT-Thread 的 C 入口是 entry(),由它再调用 rtthread_startup()
FLASH/BootAsUser 最前面多一个 .bxx 段:.align 2 + lui a4,0x80000; lui a5,0xe000f; sw a4,-752(a5)(即往 0xE000ED10 写 0x80000) .ld.bxx 放在 BOOT FLASH(0x1FFF0000)最前面,这段是 BOOT FLASH 被当作”入口”时先跑的一小段;模板未给注释,寄存器含义以 WCH 手册为准

📌 规律很清楚:改启动文件基本只有两件事——改 CSR 初值(中断嵌套/特权/中断开关),或者在搬运清单里加一段(highcode)。入口函数、外设初始化都不在这里。

六、坑

说明
mainreturn main 用的是 mret,没设 ra;return 即跳垃圾地址。必须 while(1)
漏写 ISR 就卡死 默认桩是 j . 自跳,且所有未实现中断共享它,现象是”死在某处、调试器显示不在任何函数里”
混淆 0x800 / 0x804 0x800 是全局中断使能(__enable_irqcsrs),0x804 是中断嵌套+硬件栈(csrw
.align 1 当 1 字节 RISC-V 是 2^n;用 ARM 直觉读会算错向量表的对齐
.vector 大小当表项数 段大小 0xfc 含 64 字节对齐填充,真正的表项是 55 个 0xdc 字节
0x804 不同步改优先级分组 两者必须配套(ch32x035_misc.h 的注释已明确提示)
ISR 写成普通函数 丢了 interrupt("WCH-Interrupt-fast"),编译器会按普通 ABI 保存寄存器、用 ret 返回,行为不可预期
以为 SystemInit() 会初始化外设 它只管时钟(HSI + 分频);GPIO/USART/TIM 都要在 main 里自己配
只看 .S 不看 .ld _eusrstack_data_lma_vector_base 这些符号全部由链接脚本提供,改 Link.ld 就会改启动行为

七、速查表

问题 答案
复位后第一条指令在哪? 0x0,.init 里的 j handle_reset(实测目标 0x4c6)
启动代码为什么不放在 0x0? 0x0 只留一条跳转;真正的启动代码在 .text 里由链接器排版
.vector 里有多少项? 55 项(0xdc 字节),段大小 0xfc 是因为 ALIGN(64) 填充
我写的 USART1_IRQHandler 为什么生效了? 启动文件用 .weak 给了默认桩,你的强定义自动覆盖,不用改启动文件
中断怎么找到我的函数? mtvec 低两位 = 3(表项为绝对地址),入口地址 = 0x4 + 4 × IRQn
为什么 ISR 不用自己保存所有寄存器? csrw 0x804, 3 开了硬件栈,硬件负责压栈;WCH-Interrupt-fast 属性让编译器只存 callee-saved
main 能不能 return? 不能。进 mainmretra 未设置
启动时中断是开着的吗? 是。mstatus = 0x88mretMIE = 1;RTOS 变体改成 0x1800 则是关着的
SystemInit() 做了什么? 开 HSI、配置分频,本工程跑 48MHz HSI;不碰任何外设
换个芯片型号要改哪里? startup_*.S(向量表项和外设中断号都不同)+ 换 Link.ldMEMORY,其余按芯片头文件改