结论速览
上电不是 从 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)。
进 main 用 mret,ra 未设置 ,所以 main 不能 return;ISR 依赖 0x804 = 3 打开的硬件栈 + interrupt("WCH-Interrupt-fast")。
一、读这份 .S 之前要用的知识 1.1 一个启动文件里其实有三类内容
内容
例子
落到哪个段
段指令/伪指令
.section、.align、.option、.word
不产生代码,只影响布局
数据表
.word NMI_Handler 等 55 行
.vector
指令
j handle_reset、la sp, _eusrstack、csrw
.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,控制状态寄存器)
0xbc0、0x804、mstatus、mtvec、mepc
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 n 是 2^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, 0x88(core_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
MEMORY 里 FLASH ORIGIN = 0x00000000,且 SECTIONS 里 .init 是第一个输出段 → .init 的 VMA/LMA = 0x0,于是 _start = 0x0
记入口
Link.ld 的 ENTRY( _start )
写进 ELF 头 e_entry,objdump 才显示 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),即往 0xE000E048 写 0xBEEF0080(PFIC->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),所以只能运行时清零:实测 0x200000b0 → 0x200020c0,约 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
低两位是模式:按向量表、表项里放的是绝对地址
两条容易忽略的联动:
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),两边是对上的。
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 而不是 jal 进 main 是个关键细节:mret 用 mepc 当目标、顺便完成特权级和中断状态的切换,但**它不设置 ra**。所以 main 里 ra 是复位残留值,main 一旦 return 就是跳到垃圾地址。裸机上 main 必须以 while(1) 收尾,这不是风格问题。
反汇编里能看到 main 的第一条不是压栈,而是 jal t0, __riscv_save_0——这是编译选项 -msave-restore 的产物:函数序言/尾声被抽成公共库函数以省代码体积,不认识的读者容易把它当成”调用了什么函数”。
四、中断是怎么进来的 把第二章那张图和本章连起来,一次完整的中断是这样的:
外设触发 (例如 TIM3 更新事件),PFIC(Programmable Fast Interrupt Controller,可编程快速中断控制器,core_riscv.h 里用宏 NVIC 别名指同一块寄存器)拉高中断请求。
硬件按 mtvec 查表 :低两位是 3 → 表项里是绝对地址,取 0x4 + 4 × IRQn 处的字作为入口。TIM3 的 IRQn = 54 → 地址 0xdc → 实测就是 0x2ccc。
硬件压栈(HPE) :因为 csrw 0x804, 3 打开了硬件栈,进入 ISR 时调用者保存寄存器(含 mepc、mstatus)由硬件自动入栈,软件不用管。
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,尾声是 ret。ISR 的 mret 和”只存自己用到的寄存器”这两点,正是 interrupt("WCH-Interrupt-fast") 的效果 ;写错了(比如自己声明成普通函数、或手写 mret 却让编译器也生成返回序列)就会出现寄存器被破坏或连续返回两次的怪现象。
想让某个中断有响应,标准动作只有两步:打开外设的中断使能 (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_end,lw/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)。入口函数、外设初始化都不在这里。
六、坑
坑
说明
main 里 return
进 main 用的是 mret,没设 ra;return 即跳垃圾地址。必须 while(1)
漏写 ISR 就卡死
默认桩是 j . 自跳,且所有 未实现中断共享它,现象是”死在某处、调试器显示不在任何函数里”
混淆 0x800 / 0x804
0x800 是全局中断使能(__enable_irq,csrs),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?
不能。进 main 用 mret,ra 未设置
启动时中断是开着的吗?
是。mstatus = 0x88 让 mret 后 MIE = 1;RTOS 变体改成 0x1800 则是关着的
SystemInit() 做了什么?
开 HSI、配置分频,本工程跑 48MHz HSI;不碰任何外设
换个芯片型号要改哪里?
换 startup_*.S(向量表项和外设中断号都不同)+ 换 Link.ld 的 MEMORY,其余按芯片头文件改