1. 这不是“Hello World”,而是芯片睁眼的第一瞥:RISC-V bare-metal 启动到底在干啥?
你手头那块刚焊好的 RISC-V 开发板,通电后 LED 不亮、串口没输出、调试器连不上——它不是坏了,它只是还没“醒”。而唤醒它的过程,就是 bare-metal 启动。这不是操作系统加载的后续环节,而是整个系统运转的绝对起点:从复位信号拉低那一刻起,CPU 内部逻辑清零、PC 指针跳转到固定地址、第一条指令开始取指执行……这一整套由硬件强制定义、软件必须严丝合缝配合的初始动作链,就是我们说的“启动流程”。它不依赖任何操作系统、不调用任何库函数、不假设任何内存初始化状态,纯粹靠汇编语言直接操控寄存器、配置时钟、搬运代码、建立栈空间——就像给一台刚出厂的发动机手动点火、挂挡、预热,直到它能自主稳定运转。
核心关键词risc-v、bare-metal、启动、汇编、链接脚本,每一个都不是孤立概念:risc-v 定义了指令集和复位向量地址(0x00000000 或可配置的 ROM 地址),bare-metal 意味着你必须自己承担所有底层责任,启动是这个责任链条的总开关,汇编是唯一能精确控制每一条指令执行顺序的语言,而链接脚本则是告诉链接器“这段代码放哪”“这段数据放哪”“栈顶在哪”的地图。我做过不下二十款 RISC-V 芯片的启动适配,从 GD32VF103 这类入门级 MCU,到 Kendryte K210、StarFive JH7100 这类带双核和 DDR 的 SoC,再到 SiFive U74 多核服务器级芯片,发现一个铁律:启动失败,90% 的问题出在链接脚本与实际硬件布局错位,而非汇编代码写错。比如你把 .data 段链接到 0x80000000,但实际 DDR 初始化后物理地址是从 0x80100000 开始,那程序拷贝过去就全乱套;再比如 .stack_size 设为 1K,但在中断处理里一压几十个寄存器,瞬间栈溢出导致 PC 跳飞——这些细节,在 Linux 下被内核屏蔽得严严实实,但在 bare-metal 里,它们就是生死线。这篇文章不讲抽象理论,只拆解真实开发板上电后那几百毫秒内发生的一切:从 reset vector 到 main() 的完整路径,每一步为什么这么写、怎么验证、踩过哪些坑。适合正在调试开发板、想搞懂 bootloader 原理、或准备做 RISC-V 固件开发的工程师,哪怕你只写过 Arduino,只要愿意看懂寄存器定义,就能跟着跑通第一个 blinky。
2. 启动流程全景图:从硬件复位到 C 函数入口的七步通关
bare-metal 启动不是单一线性过程,而是一个分层递进、环环相扣的状态跃迁。我把它拆成七个不可跳过的阶段,每个阶段都有明确的输入、输出和失败判据。这七步不是教科书上的理想模型,而是我在 JH7100 开发板上用逻辑分析仪抓取 reset 信号、用 OpenOCD 实时监控 PC 变化、用串口打印每个阶段标记后总结出的真实路径。
2.1 阶段一:硬件复位与向量跳转(0μs ~ 1μs)
当开发板按下复位键或上电瞬间,SoC 内部复位电路拉低 CPU 的 reset 引脚,所有寄存器清零,PC(程序计数器)被强制加载为复位向量地址。RISC-V 标准规定该地址为 0x00000000,但实际芯片厂商常通过 OTP 或引脚配置将其重映射到片上 ROM(如 0x1000)、SPI Flash 映射区(如 0x20000000)或 SRAM(如 0x08000000)。以 StarFive JH7100 为例,其默认复位向量指向内部 BootROM(0x1000),该 ROM 会先检测 GPIO 状态决定启动源(SD 卡、eMMC、SPI Flash),再将二级 bootloader(如 FSBL)拷贝到 SRAM 执行。而 GD32VF103 这类 MCU,则直接从 0x00000000(即 Flash 起始)取第一条指令。关键点在于:你写的 startup.s 必须放在链接脚本指定的入口地址,且该地址必须与硬件复位向量一致。如果链接脚本把 _start 放在 0x80000000,但硬件复位跳转到 0x00000000,那 CPU 就会从 Flash 乱码处开始执行,大概率立即触发非法指令异常。
2.2 阶段二:初始栈设置与 CSR 配置(1μs ~ 10μs)
CPU 从复位向量取到第一条指令(通常是la sp, _stack_top),立刻建立初始栈。RISC-V 使用sp寄存器作为栈指针,且要求栈地址必须 16 字节对齐(因sd/ld指令优化)。此时 RAM 尚未初始化,栈只能设在片上 SRAM(如 GD32VF103 的 0x20000000~0x2000FFFF)。紧接着必须配置三个关键 CSR(Control and Status Register):
mstatus:设置 MIE(Machine Interrupt Enable)为 0(关中断),MPIE(Previous Interrupt Enable)为 0,MPP(Previous Privilege Mode)为 0b11(Machine Mode),这是进入机器模式的标志;mtvec:设置中断向量基址,bare-metal 通常设为la t0, _exception_vector; csrw mtvec, t0,指向自定义异常处理表;mhartid:读取当前 HART ID(硬件线程 ID),多核场景下用于分支执行(如仅 HART 0 执行初始化)。
提示:
csrw指令写 CSR 时若目标 CSR 不存在(如某些精简版 core 不支持mepc),会触发 illegal instruction exception。务必查阅芯片手册确认支持的 CSR 列表。
2.3 阶段三:RAM 初始化与数据段搬运(10μs ~ 500μs)
这是最容易被忽略却最致命的阶段。.data段(已初始化全局变量)存储在 Flash 中,但运行时需在 RAM 中有一份副本;.bss段(未初始化全局变量)需在 RAM 中清零。搬运逻辑必须用汇编硬编码:
la t0, _sidata // Flash 中 .data 起始地址 la t1, _sdata // RAM 中 .data 起始地址 la t2, _edata // RAM 中 .data 结束地址 copy_data: beq t1, t2, copy_done lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j copy_data copy_done: la t0, _sbss la t1, _ebss clear_bss: beq t0, t1, clear_done sw zero, 0(t0) addi t0, t0, 4 j clear_bss clear_done:注意:_sidata、_sdata等符号由链接脚本生成,必须与脚本中 MEMORY 和 SECTIONS 定义严格对应。例如若链接脚本将.data定义在SRAM (RWX) : ORIGIN = 0x20000000, LENGTH = 128K,则_sdata就是 0x20000000,_edata是 0x20020000。一旦错位,搬运就会覆盖栈空间或代码区。
2.4 阶段四:时钟与外设使能(500μs ~ 5ms)
MCU 的外设(UART、GPIO、TIMER)默认处于复位禁用状态,必须手动开启。以 GD32VF103 为例:
- 使能 AHB 总线时钟:
li t0, 0x1f; csrw 0x800, t0(0x800 是 AHBCLKEN 寄存器地址); - 使能 GPIOA 时钟:
li t0, 0x2; csrw 0x804, t0(0x804 是 APB2CLKEN); - 配置 PA0 为推挽输出:
li t0, 0x00000001; csrw 0x808, t0(0x808 是 GPIOA_CTL0); - 配置 UART0 时钟并初始化波特率:需计算
USART_BAUD寄存器值,公式为DIV = (APB_CLK / (16 * BAUD)),APB_CLK=108MHz 时,115200 波特率对应 DIV=58.59 → 取整 58。
实操心得:时钟配置错误会导致 UART 输出乱码(波特率不准)或 GPIO 无响应(时钟未使能)。建议用示波器测 PA0 翻转波形,频率应等于
system_clock / (2 * prescaler),这是验证时钟树是否正确的黄金标准。
2.5 阶段五:C 运行时环境构建(5ms ~ 10ms)
调用main()前,GCC 工具链要求建立标准 C 环境:
- 设置
gp(global pointer)寄存器:la gp, __global_pointer$,用于访问 GOT(Global Offset Table); - 设置
tp(thread pointer):li tp, 0(单线程 bare-metal 中可设为 0); - 调用
__libc_init_array():执行.init_array段中的构造函数(如 C++ 全局对象构造); - 最终跳转
jal main。
此阶段失败常表现为main不执行或执行后立即死机。原因多为:.init_array符号未正确定义、gp值错误导致 GOT 访问越界、或main函数签名不符合 ABI(必须为int main(void)或int main(int argc, char *argv[]))。
2.6 阶段六:主循环与异常处理(10ms ~ forever)
main()进入后,典型结构为:
int main(void) { uart_init(); // 初始化串口 gpio_init(); // 初始化 LED 引脚 timer_init(); // 初始化定时器 uart_puts("Bare-metal boot OK!\r\n"); while(1) { gpio_toggle(LED_PIN); delay_ms(500); } }同时必须实现异常处理函数,否则任何未处理异常(如除零、非法地址访问)都会导致系统锁死。RISC-V 异常处理框架要求:
mtvec指向的向量表中,索引 0 为 machine-mode exception,索引 1 为 machine-mode interrupt;- 每个异常处理函数末尾必须执行
mret返回,而非ret; - 关键寄存器(
mepc,mcause,mtval)需在处理前保存,避免被覆盖。
2.7 阶段七:调试锚点与状态验证(贯穿全程)
没有 printf 的裸机调试,全靠“锚点”定位故障点:
- 在阶段一后插入
uart_putc('1'),验证复位向量和初始栈; - 在阶段三后插入
uart_putc('2'),验证数据搬运; - 在阶段四后插入
uart_putc('3'),验证时钟和外设; - 在
main开头插入uart_putc('M'),确认 C 环境就绪。 若卡在某字符,即可精准定位故障阶段。我曾遇到 JH7100 启动卡在 '2',最终发现是链接脚本中_sidata地址比 Flash 中实际.data起始地址小 0x100,导致搬运时读取了无效 Flash 区域——这种细节,只有靠锚点逐段验证才能暴露。
3. 链接脚本:bare-metal 的灵魂地图,写错一行就全盘崩溃
链接脚本(linker script)不是可有可无的配置文件,它是 bare-metal 世界的宪法——它定义了代码、数据、栈在物理内存中的绝对位置,决定了 CPU 能否正确取指、数据能否正确加载、栈能否安全生长。我见过太多人花三天调试 UART 无输出,最后发现只是链接脚本里.text段的ORIGIN写错了 0x100。下面以 GD32VF103(128KB Flash + 32KB SRAM)为例,逐行解析一个生产级链接脚本。
3.1 MEMORY 块:声明物理内存拓扑
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 32K }FLASH (rx):rx表示该区域可读(read)、可执行(execute),但不可写(write),符合 Flash 特性;ORIGIN = 0x08000000:这是 GD32VF103 Flash 的起始物理地址,必须与芯片手册一致;LENGTH = 128K:128*1024 = 131072 字节,不能写成128K(链接器不识别单位),必须是数字;RAM (rwx):rwx表示可读、可写、可执行(因栈和代码可能放 SRAM),ORIGIN = 0x20000000是 SRAM 起始地址。
注意:若开发板使用外部 SPI Flash,
FLASH的ORIGIN应设为映射后的地址(如 0x20000000),且需确保 BootROM 已将其正确映射。写错 ORIGIN 会导致整个程序加载到错误位置,CPU 取到乱码指令。
3.2 SECTIONS 块:定义逻辑段布局
SECTIONS { . = ALIGN(4); _flash_start = .; .text : { *(.text.entry) /* 复位向量必须放在最前 */ *(.text .text.*) /* 主要代码段 */ . = ALIGN(4); _etext = .; } > FLASH .rodata : { *(.rodata .rodata.*) . = ALIGN(4); _erodata = .; } > FLASH .data : { _sdata = .; *(.data .data.*) . = ALIGN(4); _edata = .; } > RAM AT > FLASH /* AT 表示加载地址在 FLASH,运行地址在 RAM */ .bss : { _sbss = .; *(.bss .bss.*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM .stack (NOLOAD) : { . = ALIGN(16); _stack_start = .; . += 2048; /* 2KB 栈空间 */ _stack_top = .; } > RAM }.是当前位置计数器,ALIGN(4)确保段起始地址 4 字节对齐(RISC-V 指令要求);.text.entry:专门存放复位向量和启动代码,*(.text.entry)确保它排在.text段最前面,这样链接器生成的二进制文件开头就是reset_handler;> FLASH:.text段的运行地址(VMA)和加载地址(LMA)都在 FLASH;> RAM AT > FLASH:.data段的运行地址在 RAM,但加载地址(即编译后二进制中存储的位置)在 FLASH,这正是阶段三数据搬运的依据;(NOLOAD):.stack段不占用二进制文件空间,只在运行时分配,避免烧录文件过大;_stack_top = .:定义栈顶地址,供汇编中la sp, _stack_top使用。
3.3 符号定义:连接汇编与 C 的桥梁
链接脚本末尾必须导出关键符号供 startup.s 引用:
PROVIDE(_stack_top = 0x20008000); /* 若 RAM 从 0x20000000 开始,32K=0x8000 */ PROVIDE(_sidata = LOADADDR(.data)); /* .data 加载地址(FLASH 中位置) */ PROVIDE(_sdata = ADDR(.data)); /* .data 运行地址(RAM 中位置) */ PROVIDE(_edata = ADDR(.data) + SIZEOF(.data)); PROVIDE(_sbss = ADDR(.bss)); PROVIDE(_ebss = ADDR(.bss) + SIZEOF(.bss));LOADADDR(.data)返回.data段在二进制文件中的偏移,即 Flash 中的地址;ADDR(.data)返回.data段在内存中的起始地址,即 RAM 中的地址;SIZEOF(.data)返回.data段大小,用于计算结束地址。
实操心得:用
arm-none-eabi-objdump -h your.elf查看各段地址,验证链接脚本是否生效。若.data的 VMA 显示为0x20000000而 LMA 为0x08001000,说明AT > FLASH生效;若两者相同,则AT未生效,数据搬运会失败。
3.4 常见陷阱与避坑指南
| 错误类型 | 具体表现 | 排查方法 | 解决方案 |
|---|---|---|---|
| ORIGIN 错位 | 程序烧录后完全无响应 | 用objdump -d查看_start地址是否等于复位向量 | 核对芯片手册,修正 MEMORY 中 ORIGIN |
| AT 丢失 | .data变量始终为 0 | objdump -t查看_sidata是否等于.dataLMA | 在.data段后添加AT > FLASH |
| 栈溢出 | main执行几条后死机 | 逻辑分析仪抓取sp寄存器变化,看是否低于_stack_start | 增大.stack大小,或检查递归/大数组 |
| 对齐错误 | ld报section alignment错误 | objdump -h查看段 flags 是否含A(allocatable) | 在段定义前加ALIGN(4),确保指令对齐 |
4. 汇编启动文件:用最原始的语言,完成最精密的初始化
startup.s 是 bare-metal 的心脏起搏器,它用 RISC-V 汇编指令,完成从硬件复位到 C 环境就绪的全部工作。下面是一个经过 JH7100 和 GD32VF103 双平台验证的通用模板,每一行都附带原理说明和实操注释。
4.1 复位向量与异常向量表
.section .text.entry, "ax" .global _start _start: # 设置初始栈指针 la sp, _stack_top # 关中断,设为 Machine Mode li t0, 0x80000000 # mstatus: MIE=0, MPIE=0, MPP=0b11 csrw mstatus, t0 # 设置异常向量基址 la t0, _exception_vector csrw mtvec, t0 # 清除 BSS 段(若链接脚本未自动处理) la t0, _sbss la t1, _ebss clear_bss_loop: beq t0, t1, bss_clear_done sw zero, 0(t0) addi t0, t0, 4 j clear_bss_loop bss_clear_done: # 搬运 DATA 段 la t0, _sidata # Flash 中 .data 起始 la t1, _sdata # RAM 中 .data 起始 la t2, _edata # RAM 中 .data 结束 copy_data_loop: beq t1, t2, data_copy_done lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j copy_data_loop data_copy_done: # 设置 global pointer la gp, __global_pointer$ # 跳转到 C 入口 jal main .section .text.exception, "ax" _exception_vector: # 索引 0: Machine Exception j handle_exception # 索引 1: Machine Interrupt j handle_interrupt # 索引 2~31: 保留 .rept 30 j handle_exception .endr.section .text.entry, "ax":"ax"表示该段可分配(a)、可执行(x),且必须放在.text段最前;la sp, _stack_top:la(load address)是伪指令,展开为auipc+addi,高效加载 32 位地址;csrw mstatus, t0:csrw(CSR write)直接写控制寄存器,0x80000000是mstatus的 bit31(MIE),清零即关中断;j handle_exception:j(jump)是无条件跳转,比jal少一个寄存器操作,更高效。
4.2 异常处理函数:让系统在错误中存活
.section .text.exception, "ax" handle_exception: # 保存关键寄存器到栈 addi sp, sp, -128 # 分配 128 字节栈空间 sd ra, 0(sp) # 保存返回地址 sd sp, 8(sp) # 保存原栈指针 sd gp, 16(sp) sd tp, 24(sp) sd t0, 32(sp) sd t1, 40(sp) sd t2, 48(sp) # ... 保存所有 callee-saved 寄存器 # 读取异常信息 csrr t0, mcause # 获取异常原因 csrr t1, mepc # 获取异常发生地址 csrr t2, mtval # 获取异常附加信息(如非法地址) # 打印调试信息(需 UART 驱动已初始化) # call uart_printf("EXCEPTION: mcause=%x, mepc=%x\r\n", t0, t1) # 恢复寄存器 ld ra, 0(sp) ld sp, 8(sp) ld gp, 16(sp) ld tp, 24(sp) ld t0, 32(sp) ld t1, 40(sp) ld t2, 48(sp) # ... 恢复所有寄存器 addi sp, sp, 128 mret # 从异常返回,恢复 mstatus 并跳转到 mepcaddi sp, sp, -128:为异常处理分配独立栈空间,避免破坏主栈;csrr(CSR read)读取mcause(异常原因码),值为 0x00000001 表示指令地址错误,0x00000002 表示指令访问错误;mret是唯一合法的异常返回指令,它会从mstatus恢复中断使能状态,并跳转到mepc地址。
4.3 C 运行时支持:让标准库函数可用
bare-metal 中printf、malloc等函数需重定向底层 I/O。以printf为例,需实现_write系统调用:
#include <sys/stat.h> #include <unistd.h> extern void uart_puts(const char *s); int _write(int fd, char *ptr, int len) { if (fd == STDOUT_FILENO || fd == STDERR_FILENO) { for (int i = 0; i < len; i++) { if (ptr[i] == '\n') { uart_puts("\r\n"); } else { uart_putc(ptr[i]); } } return len; } return -1; } // 必须实现的空函数,否则链接失败 void _exit(int status) { while(1); } void _kill(int pid, int sig) { while(1); } int _getpid(void) { return 1; }STDOUT_FILENO定义在<unistd.h>中,值为 1;uart_puts是你实现的串口输出函数,需确保其为阻塞式(等待发送完成);_exit等函数是 newlib 的桩函数,bare-metal 中只需无限循环,不真正退出。
注意:若使用
printf但未实现_write,链接时会报undefined reference to '_write'。这是新手最常见的链接错误之一。
5. 实操全流程:从零开始点亮 GD32VF103 的 LED
现在,我们把前面所有理论落地为一次完整的实操。目标:在 GD32VF103 开发板上,不依赖任何 SDK,纯手写汇编+链接脚本+C,实现 LED 闪烁。整个过程在 Ubuntu 22.04 下完成,工具链为riscv64-unknown-elf-gcc。
5.1 环境准备与工具链安装
# 添加 Ubuntu 源 sudo apt update && sudo apt install -y software-properties-common sudo add-apt-repository -y ppa:team-gcc-arm-embedded/ppa sudo apt update # 安装 RISC-V 工具链 sudo apt install -y gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf \ gdb-riscv64-unknown-elf openocd # 验证安装 riscv64-unknown-elf-gcc --version # 输出应为 riscv64-unknown-elf-gcc (GNU Toolchain for RISC-V) 12.2.05.2 创建项目目录结构
mkdir -p gd32-baremetal/{src,include,scripts} cd gd32-baremetal目录结构:
gd32-baremetal/ ├── Makefile ├── startup.s # 汇编启动文件 ├── link.ld # 链接脚本 ├── main.c # C 主程序 ├── uart.c # 串口驱动 └── gpio.c # GPIO 驱动5.3 编写链接脚本 link.ld
/* link.ld */ MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 32K } SECTIONS { . = ALIGN(4); _flash_start = .; .text : { *(.text.entry) *(.text .text.*) . = ALIGN(4); _etext = .; } > FLASH .rodata : { *(.rodata .rodata.*) . = ALIGN(4); _erodata = .; } > FLASH .data : { _sdata = .; *(.data .data.*) . = ALIGN(4); _edata = .; } > RAM AT > FLASH .bss : { _sbss = .; *(.bss .bss.*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM .stack (NOLOAD) : { . = ALIGN(16); _stack_start = .; . += 2048; _stack_top = .; } > RAM PROVIDE(_stack_top = 0x20000800); PROVIDE(_sidata = LOADADDR(.data)); PROVIDE(_sdata = ADDR(.data)); PROVIDE(_edata = ADDR(.data) + SIZEOF(.data)); PROVIDE(_sbss = ADDR(.bss)); PROVIDE(_ebss = ADDR(.bss) + SIZEOF(.bss)); }5.4 编写汇编启动文件 startup.s
/* startup.s */ .section .text.entry, "ax" .global _start _start: la sp, _stack_top # 关中断,设为 Machine Mode li t0, 0x80000000 csrw mstatus, t0 # 设置异常向量 la t0, _exception_vector csrw mtvec, t0 # 清 BSS la t0, _sbss la t1, _ebss clear_bss_loop: beq t0, t1, bss_clear_done sw zero, 0(t0) addi t0, t0, 4 j clear_bss_loop bss_clear_done: # 搬 DATA la t0, _sidata la t1, _sdata la t2, _edata copy_data_loop: beq t1, t2, data_copy_done lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j copy_data_loop data_copy_done: # 设置 GP la gp, __global_pointer$ # 跳转 main jal main .section .text.exception, "ax" _exception_vector: j handle_exception j handle_interrupt .rept 30 j handle_exception .endr handle_exception: addi sp, sp, -128 sd ra, 0(sp) sd sp, 8(sp) sd gp, 16(sp) sd tp, 24(sp) sd t0, 32(sp) sd t1, 40(sp) sd t2, 48(sp) csrr t0, mcause csrr t1, mepc csrr t2, mtval # 此处可加调试输出 ld ra, 0(sp) ld sp, 8(sp) ld gp, 16(sp) ld tp, 24(sp) ld t0, 32(sp) ld t1, 40(sp) ld t2, 48(sp) addi sp, sp, 128 mret handle_interrupt: # 简单处理:清除中断标志后返回 mret5.5 编写 C 主程序 main.c
/* main.c */ #include "gpio.h" #include "uart.h" void delay_ms(unsigned int ms) { volatile unsigned int i, j; for (i = 0; i < ms; i++) { for (j = 0; j < 1000; j++); } } int main(void) { uart_init(); gpio_init(); uart_puts("GD32VF103 Bare-metal Boot OK!\r\n"); while(1) { gpio_set(LED_PIN); delay_ms(500); gpio_reset(LED_PIN); delay_ms(500); } return 0; }5.6 编写 GPIO 驱动 gpio.c
/* gpio.c */ #include "gpio.h" #define RCU_BASE 0x50000000 #define GPIOA_BASE 0x20000000 #define RCU_APB2EN *(volatile unsigned int*)(RCU_BASE + 0x18) #define GPIOA_CTL0 *(volatile unsigned int*)(GPIOA_BASE + 0x00) #define LED_PIN 0 void gpio_init(void) { // 使能 GPIOA 时钟 RCU_APB2EN |= (1 << 0); // bit0 = GPIOAEN // 配置 PA0 为推挽输出 GPIOA_CTL0 &= ~(0xf << (LED_PIN * 4)); // 清除原配置 GPIOA_CTL0 |= (0x1 << (LED_PIN * 4)); // MODE = 0b01 (输出) }