中断系统:NVIC、优先级分组与向量注册

本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 8 篇。上一篇:时钟树配置:HSE、PLL 与总线分频,下一篇:USART 串口通信:轮询收发与日志重定向,目录见文末。

中断系统:NVIC、优先级分组与向量注册

导读

中断是裸机的第二生命:没有中断,所有 I/O 都是轮询。这一篇先补 Cortex-M3 的架构底座(异常与 IRQ 的区别、向量表布局、NVIC 寄存器),再讲 microzig 的三层向量覆盖模型——这是理解"overwrite_hal_interrupts 什么时候必须 true"的关键,最后落地完整例子:PA0 按键 EXTI0 中断。优先级分组、set_priority 的左移陷阱、ISR 里能不能打日志,都是本篇的必答题。

为什么需要中断模型

第 04 篇看过向量表:前 16 项是异常(Reset/NMI/HardFault/.../SysTick),第 16 项起是外设中断(IRQ)。两者由同一个 NVIC 管理,但在 microzig 里类型不同:

  • 异常(Exception,编号 0–15):microzig.cpu.Exception 枚举,硬Fault/SysTick 这些核内事件。异常不占 NVIC 的 ISER 位(它们永远使能),配置入口是 SCB。
  • 外部中断/IRQ(ExternalInterrupt,IRQn 从 0 起):microzig.cpu.ExternalInterrupt 枚举,NVIC 管理的 60 个 F103 外设中断。microzig.interrupt.enable(.EXTI0) 使能的是这一类。

向量表布局(RM0008 Table 62):IRQn 0–15 是异常(Reset=1、NMI=2、HardFault=3、MemManage=4、BusFault=5、UsageFault=6、SVC=11、PendSV=14、SysTick=15),IRQ 从向量表第 16 项开始。常用 F103 IRQ 编号:WWDG=0、PVD=1、RTC=3、FLASH=4、RCC=5、EXTI0=6…EXTI4=10、DMA1_Channel1=11、ADC1_2=18、USB_LP_CAN1_RX0=20、EXTI9_5=23、TIM1_BRK=24、TIM2=28、TIM3=29、TIM4=30、I2C1_EV=31、SPI1=35、USART1=37、EXTI15_10=40、USBWakeUp=42。向量名字符串以 microzig 生成为准(第 05 篇的 @compileLog 手法可打印全部名字),本系列给出的是 RM0008 编号。

NVIC 寄存器布局(0xE000E100 起,microzig 的 microzig.cpu.peripherals.nvic 就是这个 extern struct):ISER[8](写 1 使能,IRQ n → ISER[n/32] 的 bit n%32)、ICER[8](写 1 禁止)、ISPR[8](写 1 挂起)、ICPR[8](写 1 解挂)、IABR[8](只读 active)、IPR[240](每 IRQ 一个字节优先级)。

microzig.interrupt 的 API(core/src/interrupt.zig):

microzig.interrupt.enable(comptime .EXTI0)   // 置 ISER 位(NVIC 层使能)
microzig.interrupt.disable(comptime .EXTI0)
microzig.interrupt.enable_interrupts()       // cpsie i,开全局
microzig.interrupt.disable_interrupts()      // cpsid i,关全局
microzig.interrupt.enter_critical_section()  // 保存状态 + 关全局,返回句柄
microzig.interrupt.set_priority(.EXTI0, .highest)  // 见下文陷阱

两层使能缺一不可:enable() 只开 NVIC 的门,enable_interrupts() 才开总闸(PRIMASK)。例外:time.init_timer 内部会自动开全局中断——用了 time 模块就不用自己开(这也意味着时序上中断可能比你预想的早开,敏感代码要自己管理顺序)。

代码案例:PA0 按键 EXTI0 中断

接线(第 01 篇表):KEY1 = PA0,按键一端 3.3 V、一端 PA0,配下拉输入、上升沿触发。

const microzig = @import("microzig");
const hal = microzig.hal;

pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{});

comptime {
    _ = microzig.export_startup();
}

pub const microzig_options: microzig.Options = .{
    .interrupts = .{
        // EXTI0 向量绑定我们的 ISR。EXTI0 没被 HAL 占用,无需 overwrite。
        .EXTI0 = .{ .c = exti0_isr },
    },
};

var press_count: u32 = 0;

fn exti0_isr() callconv(.c) void {
    // 1. 读挂起位图并写回清除(W1C:读出什么清什么)
    const pending = hal.exti.pending();
    hal.exti.clear_pending(pending);

    // 2. ISR 只做最少的事:计数 + 通知主循环
    press_count +%= 1;
}

pub fn main() !void {
    hal.rcc.enable_clock(.GPIOA);
    hal.rcc.enable_clock(.AFIO); // EXTI 端口选择走 AFIO,时钟必须开

    const key = hal.gpio.Pin.from_port(.A, 0);
    key.set_input_mode(.pull);
    key.set_pull(.down);

    // EXTI line 0 选端口 A,上升沿,直接开中断
    hal.exti.apply_line(.{
        .line = 0,
        .port = .A,
        .trigger = .rising,
        .interrupt = true,
        .event = false,
    });

    // 两层使能
    microzig.interrupt.enable(.EXTI0);
    microzig.interrupt.enable_interrupts();

    while (true) {
        if (press_count != 0) {
            // 主循环消费按键事件(真正的"处理"在这里做)
            press_count = 0;
            // ...翻转 LED 等
        }
        microzig.cpu.wfi(); // 睡等中断,省电且不空转
    }
}

逐段解析

三层覆盖模型:向量是怎么被填上的

microzig 的向量表装配(core/src/cpus/cortex_m.zig 的 generate_vector_table)分三层,自下而上覆盖:

第 1 层:regz 生成的向量表类型,每项默认 = unhandled(触发即 @panic("unhandled interrupt"))
第 2 层:HAL 的 default_interrupts(F103 只占 TIM2/TIM3/TIM4/TIM5,指向 time.TIM_handler)
第 3 层:用户 microzig_options.interrupts(压过 HAL 需同时写 overwrite_hal_interrupts = true)

三条硬规则(源码逐字可查):

  1. 用户给了 handler、HAL 也占了这个向量、且没写 overwrite_hal_interrupts = true → 编译期报错:Interrupt TIM2 is used internally by the HAL; overriding it may cause malfunction. If you are sure of what you are doing, set "overwrite_hal_interrupts" to true in: "microzig_options".
  2. 用户没给 → 用 HAL 默认(若有)。
  3. 都没有 → default_exception_handler:Debug 构建会打印向量名,Release 是精简版;本质仍是 panic。

为什么 F103 的 HAL 占着 TIM2–TIM5?因为 time 模块用通用定时器做节拍(init_timer),HAL 把 time.TIM_handler 绑到这四个向量上。TIM2 冲突案例(本系列第 10/11 篇会实际遇到):你想用 TIM2 中断做自己的事(比如 1 ms 任务节拍),HAL 已经占了 TIM2 → 必须 .overwrite_hal_interrupts = true;代价是 HAL 的 time.TIM_handler 不再被调,sleep_ms/sleep_us 永远卡死。所以本系列的约定:需要自己用 TIM2 时,时基换 TIM4(time.init_timer(.TIM4)),TIM2 留给你自己的中断,两边互不干扰。

三种注册写法与 Handler 类型

microzig_options.interrupts 的每个字段类型是 ?microzig.interrupt.Handler,Handler 是 extern union,只有两种形态:

pub const Handler = extern union {
    naked: *const fn () callconv(.naked) void,
    c:     *const fn () callconv(.c) void,
};

写法一 .c(99% 的场景):标准 AAPCS 序言/尾声,编译器自动保存 r4–r11 与 lr,ISR 里可以写任意 Zig 代码。写法二 .naked:不生成任何序言,函数体只能放 asm(不能有局部变量、不能调普通函数),用于极低延迟或 RTOS 上下文切换的入口(第 25 篇的 PendSV 就是)。ISR 函数签名必须是 fn () callconv(.c) void——无参数无返回值,callconv(.c) 小写(0.17 起大写 .C 已删除)。字段名是 .c/.naked,不是老教程的 .handler。

字段名(.EXTI0、.TIM2、.USART1...)必须与芯片中断名逐字一致,拼错报 no field named 'EXTI95' in struct。因为枚举是编译期从生成数据构造的,IDE 补全看不到,拼不准就上 @compileLog(第 05 篇)。

优先级:4 位、分组与左移陷阱

F103 的 NVIC 只实现 4 位优先级(16 级,0 最高 15 最低),这 4 位位于 IPR 字节的高 4 位(bit7:4),低 4 位读回恒 0。RM0008 的 5 种分组(ST 编号与 ARM PRIGROUP 相反):NVIC_PRIORITYGROUP_0(0 位抢占/4 位子优先级)到 NVIC_PRIORITYGROUP_4(4 位抢占/0 位子优先级)。抢占优先级决定"能否打断正在执行的中断";子优先级只在多个中断同时挂起时决定执行顺序,不能打断。ST 的 C HAL 默认设 Group_4(16 级纯抢占),裸机 Zig 工程要自己意识到这个语义。

microzig 的类型(cortex_m.zig):

pub const Priority = enum(u8) { lowest = 15, highest = 0, _ };

数值越小优先级越高(与直觉相反),_ 允许写任意 0..15:set_priority(.EXTI0, @fromBackingInt(3))。

一个必须知道的陷阱:microzig 的 nvic.set_priority 把 0–15 直接写进 IPR[n] 字节,而硬件只认高 4 位——写 3 等于写 0x30 的高位是 3?不,写 0x03 的高 4 位是 0,等于优先级 0(最高)。按 ARM 规范正确写法是 IPR[n] = prio << 4。也就是说 microzig 当前实现的 set_priority 实际效果全部落在"最高优先级"附近,用 HAL 设优先级发现"不生效/全都最高"时先怀疑这里。要用真实分级,寄存器直写:microzig.cpu.peripherals.nvic.IPR[6] = 3 << 4;(EXTI0 是 IRQ6)。这个上游实现问题写进你的心智模型:HAL 的优先级 API 目前不可靠,精确优先级控制走寄存器。

ISR 的写法纪律

本例 ISR 只有四行,每行都有理由。清 pending 用"读出什么清什么"(clear_pending(exti.pending()))——EXTI 的 PR 是 W1C 寄存器,把读到的位图原样写回恰好清掉所有已挂起的线,且不会误清(写 0 无动作);如果不清,退出 ISR 立刻重进,表现为"卡死在中断里"。press_count +%= 1 用回绕加避免 Debug 下溢出 panic;它被主循环读,多字节变量在 M3 上对齐读写是原子的(u32 一条 ldr/str),更复杂的共享要用临界区或原子操作(第 22 篇)。

ISR 里不能 std.log:F103 的 logFn 走 UART 轮询,一条 115200 的日志要阻塞约 0.9 ms,高频中断会把主循环饿死;logFn 内部还可能有非重入状态。ISR 的铁律:清标志、存数据、置信号,格式化与业务全放主循环。同理避免 @intCast 越界、数组越界、unreachable——它们触发 panic,而 panic 会 hang(),系统假死。

主循环的 wfi

microzig.cpu.wfi()(Cortex-M 的 Wait-For-Interrupt 指令)让 CPU 睡到下一个中断,省电且避免空转烧电。事件到达时 press_count != 0 的分支处理业务。这是"中断驱动 + 主循环消费"的最小协作模型,第 12 篇的环形缓冲、第 23 篇的调度器都建立在它上面。

运行现象

烧录后:主循环睡在 wfi,按一次 KEY1,EXTI0 触发一次、press_count 加一、主循环消费后继续睡。在调试器(Debug 构建)里 watch press_count:每按一次 +1。用逻辑分析仪抓 PA0 与任意翻转引脚:PA0 上升沿到 ISR 响应(另一引脚翻转)的延迟在十几微秒级(含抖动期无关的纯中断延迟约 12 个时钟 + 栈压入)。

按住不放只计一次(上升沿触发);要连击计数改 .trigger = .both 并在 ISR 里读电平区分按下/释放。若发现一次按键计了多个数,是抖动——软件消抖(第 06 篇)或把触发改到释放沿。

常见问题

Q1:编译报 Interrupt TIM2 is used internally by the HAL; overriding it may cause malfunction...。

成因:在 microzig_options.interrupts 里注册了 TIM2/TIM3/TIM4/TIM5 之一,而 HAL 的 default_interrupts 已把这四个向量指向 time.TIM_handler。解决方案:确实要接管就加 .overwrite_hal_interrupts = true——但先想清楚代价:time.sleep_ms/us 依赖 TIM_handler 推进计数,被顶掉后所有延时永远卡死。正确架构是本系列的约定:时基挪到 TIM4(time.init_timer(.TIM4)),TIM2/TIM3 留给你的中断(TIM3 若被 PWM 用了就挪 TIM4,第 10 篇的呼吸灯正是这个布局)。EXTI/USART/SPI/I2C/DMA 等向量 HAL 都没占,直接注册即可。

Q2:中断触发后程序像死机,反复进同一个 ISR。

成因:没清 pending。EXTI/TIM/USART 的标志是 W1C 或特定清零序列,ISR 退出时若标志仍置位,NVIC 立即重新进入——逻辑分析仪看 ISR 入口引脚会看到高频翻转。解决方案:ISR 第一件事清标志;EXTI 用 hal.exti.clear_pending(hal.exti.pending());定时器用 comp.clear_interrupts()(第 11 篇);USART 的 RXNE 标志靠读 DR 清。另一个坑:用 modify 清 W1C 寄存器会把读到的所有 1 写回(第 05 篇的规则),标志看似清了其实别的标志全被误清——W1C 只用 raw = 写。

Q3:microzig.interrupt.enable(.SysTick) 报 expected interrupt, got exception: SysTick。

成因:SysTick/HardFault/PendSV 是异常(Exception),不是 NVIC 管理的外部中断(ExternalInterrupt),enable 的 comptime 检查会拒绝。解决方案:异常不经过 NVIC 使能(硬件恒开),要自定义异常处理函数直接在 microzig_options.interrupts 里按向量名写(.HardFault = .{ .c = my_handler });注意 microzig 默认已有一套会打印寄存器的 fault 处理器,覆盖前想清楚。另外异常优先级也不能用 set_priority(它只收 ExternalInterrupt,对异常报 Cannot set the priority for the exception)。

Q4:设了 set_priority(.EXTI0, .lowest),另一个优先级"更高"的中断还是抢不进来。

成因:两个因素叠加。其一,microzig 的 set_priority 写 IPR 字节没有左移 4 位,硬件只认高 4 位,所有经它设置的值实际都落在高位 0(最高优先级)——"设最低"根本没生效。其二,复位默认 PRIGROUP 分组是 0 位抢占,若从未设置分组,所有中断平级互不抢占。解决方案:需要真实分级的场景寄存器直写 nvic.IPR[irq] = prio << 4 并显式配置 AIRCR 的 PRIGROUP(NVIC_PRIORITYGROUP_4 对应 4 位抢占);只有两个中断、不需要分级时,让硬件的默认平级 + 挂起机制自然排队即可。

Q5:ISR 里调 std.log.info 后系统变得巨慢或随机死机。

成因:F103 的 logFn 走 UART 轮询发送(logger 用空缓冲同步直写),115200 下一条 40 字节日志阻塞约 3.5 ms;中断里每次调用都阻塞这个时长,若中断频率 1 kHz,CPU 全耗在日志上。且 logger 的 writer 状态非重入,中断打断主循环的日志输出可能损坏其内部状态。解决方案:ISR 内不格式化——把数据放进环形缓冲(第 12 篇)或置标志,主循环里统一打日志;调试 ISR 时用 GPIO 翻转 + 逻辑分析仪,比日志快六个数量级。

Q6:exti.set_line 和 set_line_edge 传同一个变量,编译报类型不匹配。

成因:上游 API 的参数位宽不一致——set_line(line: u4, port: gpio.Port) 的 line 是 u4(0–15),set_line_edge(line: u5, edge: TriggerEdge) 的 line 是 u5(0–19,含内部线)。同一变量喂两个函数会因位宽不匹配报错。解决方案:直接用一步到位的 apply_line(.{ .line = ..., .port = ..., .trigger = ..., .interrupt = ... })(config 里 line 是 u5),避免和两个 setter 打交道;要手动组合时声明成 u5 再在 set_line 处 @intCast。顺带记两个命名怪癖:TriggerEdge 的 None 是大写、其余小写;EXTI line 号 = 引脚号(PA0/PB0/PC0 都是 line 0),同一条 line 只能选一个端口。

面试考点

Q1:异常与外部中断(IRQ)在 Cortex-M3 上的区别是什么?各举三个例子并说明配置入口。

参考答案:异常是核内事件,编号 0–15(Reset/NMI/HardFault/MemManage/BusFault/UsageFault/SVC/PendSV/SysTick 等),不占 NVIC 的 ISER 位、永远使能,优先级由 SCB 的 SHPR 寄存器管;IRQ 是外设事件(F103 有 60 个:EXTI0=6、TIM2=28、USART1=37 等),由 NVIC 的 ISER/ICER/ISPR/ICPR/IPR 管理。microzig 里对应 microzig.cpu.Exception 与 ExternalInterrupt 两个枚举,microzig.interrupt.enable 只收后者(对异常编译报错)。向量表共享:IRQn n 对应向量表第 16+n 项。配置入口:IRQ 走 microzig.interrupt.enable/set_priority,异常走 SCB 寄存器或向量表直接覆盖。

Q2:解释 microzig 的三层中断覆盖模型,以及 overwrite_hal_interrupts 的适用场景与代价。

参考答案:三层:regz 生成的向量表每项默认 unhandled(panic);HAL 的 default_interrupts 覆盖其中四个(F103 的 TIM2/3/4/5,指向 time.TIM_handler 支撑 sleep_ms);用户 microzig_options.interrupts 最上层。规则:用户与 HAL 冲突且未声明 overwrite 时编译期报错;声明 overwrite 后用户 handler 顶掉 HAL 的。适用场景:接管被 HAL 占用的定时器中断(比如 TIM2 做自定义节拍)。代价:HAL 的 time 模块失去中断驱动,sleep_ms/sleep_us 永远阻塞——所以标准解法是"时基挪到另一个定时器"(TIM4),让 HAL 与用户各占其位。EXTI/USART 等其余向量 HAL 未占用,无需 overwrite。

Q3:F103 的 4 位优先级与 5 种分组是什么关系?"抢占优先级"和"子优先级"的区别一句话怎么说?

参考答案:4 位优先级共 16 级(数值小=优先级高,位于 IPR 字节高 4 位);AIRCR.PRIGROUP 把这 4 位切成"抢占位 + 子优先级位",RM0008 的 Group_0(0/4)到 Group_4(4/0)五种切法(注意 ST 编号与 ARM PRIGROUP 值相反)。一句话:抢占优先级决定能否打断正在执行的中断,子优先级只在同时挂起时决定先执行谁、不能打断。最常用 Group_4(4 位全抢占,16 级嵌套),ST 的 C HAL 在 HAL_Init 里默认设置它;裸机要自己意识到复位默认是 0 位抢占(所有中断平级)。优先级数值必须左移 4 位写入 IPR 字节(microzig 当前 set_priority 未左移,需注意)。

Q4:写一个安全的 ISR 要遵守哪些纪律?逐条说明违反的后果。

参考答案:① 签名 fn () callconv(.c) void(否则不兼容 Handler 类型或寄存器保存不全);② 第一时间清中断标志(W1C 用 raw 写)——否则无限重入;③ 只做短操作(清标志/存数据/置信号)——F103 的日志、浮点(无 FPU 软浮点几十到几百周期)、长循环都会阻塞同级与低优先级中断;④ 不调用非重入函数(std.log、带锁的分配器)——状态损坏或死锁;⑤ 不做会 panic 的操作(intCast 越界、越界访问、unreachable)——panic 关中断死循环,系统假死;⑥ 与主循环的共享数据用原子访问或临界区——u32 对齐读写天然原子,多字段结构不行;⑦ 需要时用 .naked 形态手控现场,但要自己 push/pop。每一条都能在逻辑分析仪或调试器上复现对应故障。

Q5:为什么 time.init_timer 会"顺手"打开全局中断?这在什么场景下咬人?

参考答案:init_timer 的实现末尾调用 microzig.interrupt.enable_interrupts()(cpsie i)并对所选定时器执行 NVIC 使能——它假设"初始化时基 = 准备好用延时 = 应该能收到节拍中断"。咬人场景:你的初始化顺序是"配外设 A → init_timer → 配外设 B → 最后统一开中断",那么 init_timer 之后 B 的中断条件若已满足会提前触发;或者你在 init_timer 之后才注册自己的 TIM2 handler(HAL 已占位)遇上编译错误。实践规则:把 init_timer 放在所有中断注册与关键初始化之后;或者接受"从这一行起中断已全局打开"的事实来安排临界区。

Q6:EXTI0 到 EXTI15 有 16 条线,F103 只给它们分了几个 NVIC 向量?共享向量的 ISR 怎么区分是谁触发的?

参考答案:7 个:EXTI0、EXTI1、EXTI2、EXTI3、EXTI4 各自独占,EXTI5–9 共享 EXTI9_5,EXTI10–15 共享 EXTI15_10。共享向量的 ISR 用 EXTI_PR 挂起位图区分:const p = hal.exti.pending(); if (p.line_5 == 1) {...} if (p.line_9 == 1) {...}(pendingLine 是 20 位 packed struct,字段名带下划线如 line_9),最后 clear_pending(p) 一次清掉所有已处理的位。注意约束:EXTI line 号 = 引脚号,同一 line 的多个端口只能选一个(AFIO_EXTICR 的端口选择是独占的)——PA0 配了 EXTI0 后 PB0 不能同时用 EXTI0。

小结

  • 异常(0–15,SCB 管)与 IRQ(16 项起,NVIC 管)是两类;enable() 置 ISER、enable_interrupts() 开 PRIMASK,两层缺一不可(init_timer 会替你开全局)。
  • 向量注册三层:regz 默认 unhandled → HAL 占 TIM2/3/4/5 → 用户 microzig_options.interrupts;覆盖 HAL 必须加 overwrite_hal_interrupts = true,代价是 time 模块失效。
  • ISR 签名 fn () callconv(.c) void;.c 形态写任意 Zig,.naked 只放 asm。
  • F103 优先级 4 位 16 级、数值小者高;分组切抢占/子优先级位,Group_4 最常用;microzig 的 set_priority 未左移 4 位,精确控制走寄存器。
  • ISR 纪律:先清标志(W1C 用 raw)、短小、无日志无浮点无 panic 路径、共享数据原子或临界区。
  • EXTI 同号引脚共享 line 且 line 分 7 个向量组,共享向量用 pending 位图区分。
  • 下一篇 USART:波特率公式、apply/apply_runtime 与 std.log 重定向。

系列目录

  1. 系列总览:Zig 裸机开发的定位、生态与平台全景
  2. 开发环境搭建:Zig 0.17、microzig 与 probe-rs
  3. 工程结构剖析:build.zig、MicroBuild 与固件产物
  4. 第一个固件:启动流程、向量表与 GPIO 点灯
  5. 寄存器级编程:PAC、MMIO 与 volatile 语义
  6. GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
  7. 时钟树配置:HSE、PLL 与总线分频
  8. 中断系统:NVIC、优先级分组与向量注册
  9. USART 串口通信:轮询收发与日志重定向
  10. 定时器基础:时基、PWM 与呼吸灯
  11. 输入捕获:频率与占空比测量
  12. 中断驱动串口:环形缓冲区与临界区设计
  13. DMA 传输:从外设搬运到内存
  14. I2C 总线:SSD1306 OLED 显示实战
  15. SPI 总线:W25Q64 Flash 读写与 ID 识别
  16. ADC 采样:单次、扫描与内部温度传感器
  17. 内部 Flash 编程:参数持久化与页擦除
  18. 独立看门狗:复位诊断与可靠性设计
  19. 低功耗模式:Sleep、Stop、Standby 与唤醒
  20. 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
  21. Zig 语言进阶:comptime、错误处理与零成本抽象
  22. 并发模型:中断与主循环的协作与临界区
  23. 协作式调度器:状态机与时间片轮转
  24. 实时性分析:中断延迟、抖动与 WCET
  25. RTOS 内核实现(上):任务控制块与上下文切换
  26. RTOS 内核实现(下):信号量、消息队列与优先级继承
  27. USB 设备开发:CDC 虚拟串口
  28. CAN 总线:bxCAN 过滤器与环回自测
  29. 调试与测试:probe-rs、RTT 与硬件在环
  30. 收官:分层架构、可移植性与 Zig 生态展望
posted @ 2026-10-27 08:52  navysummer_hardware  阅读(12)  评论(0)    收藏  举报