DMA 传输:从外设搬运到内存

本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 13 篇。上一篇:中断驱动串口:环形缓冲区与临界区设计,下一篇:I2C 总线:SSD1306 OLED 显示实战,目录见文末。

DMA 传输:从外设搬运到内存

导读

第 12 篇的中断方案每字节进一次 ISR,115200 波特率下负担不大;但把波特率拉到 921600、或者要把 ADC 的连续采样流灌进内存时,逐字节中断就成了 CPU 的税。DMA(直接内存访问)是终极答案:一个独立于 CPU 的搬运工,外设与内存之间的每个字节由它经总线移动,CPU 只需要"下单"和"验收"。F103ZET6 有 DMA1(7 通道)+ DMA2(5 通道),microzig 为全部 12 个通道提供了预实例化类型。但和 UART 中断一样,这份 DMA HAL 属于"能用但不好用":每个通道底层只有一块100 字节的静态缓冲、中断注册在 Flash 向量表下只有一句 assert、错误事件直接 panic。本篇把这 12 个类型、Config 的全部字段、100 字节限制的来龙去脉和两条出路逐一讲透,并用 USART1_TX 的 DMA 发送做一个端到端的例子。

为什么需要 DMA

CPU 搬运一个字节的完整代价:读外设寄存器(总线事务)+ 写内存(总线事务)+ 循环控制(自增、判断、跳转)。更隐蔽的是总线占用的不公平:轮询搬运时 CPU 完全被绑死;中断搬运时每个字节要付进出 ISR 的固定税(第 12 篇算过约 1 µs),波特率越高税占比越重——921600 波特率下字节间隔只有 10.8 µs,ISR 税吃掉约 10% 的 CPU,还不算主循环被打断的缓存/流水线损失。

DMA 控制器在 AHB 总线上有自己的主端口,与 CPU 共享总线矩阵。它搬运数据时 CPU 可以同时执行(SRAM 与外设的总线事务可并行;真正冲突时总线矩阵仲裁,CPU 让一拍)。对 CPU 而言,DMA 的接口只有三个动作:

  1. 下单:告诉它源地址、目的地址、长度、每次传输后地址是否递增、数据宽度。
  2. 启动:置 EN 位。
  3. 验收:传输完成后 DMA 置 TCIF(transfer complete)标志——你可以轮询它,也可以让 DMA 触发中断。

F103 的 DMA 是"固定布线"的老式设计(与 F4 的 DMAMUX 任意路由相反):每个 DMA 通道只能服务固定的几个外设请求,对应关系写在 RM0008 §13.3.7 的请求映射表里,硬连线、不可改。本篇用到的几个映射(高密度型号 F103ZE,依据 RM0008 Table 78):

通道 服务的外设请求(部分)
DMA1_Channel1 ADC1
DMA1_Channel2 SPI1_RX、USART3_TX、TIM1_CH1、TIM2_CH3
DMA1_Channel3 SPI1_TX、USART3_RX、TIM1_CH2、TIM2_CH4
DMA1_Channel4 SPI2_RX、USART1_TX、TIM1_CH4、TIM1_TRIG_COM
DMA1_Channel5 SPI2_TX、USART1_RX、TIM1_UP、TIM2_CH1
DMA1_Channel6 USART2_RX、TIM1_CH3、TIM3_CH1、TIM2_CH2 之一
DMA1_Channel7 USART2_TX、TIM2_CH2、TIM4_CH2
DMA2_Channel1–5 SPI3、USART3(XL 密度)、SDIO、ADC3、TIM5/TIM8 等

要用哪个外设的 DMA,通道没得选——USART1_TX 永远是 DMA1_Channel4,ADC1 永远是 DMA1_Channel1。通道之间也不能共享:同一时刻一个通道只服务一个请求。

代码案例:USART1_TX 的 DMA 发送

把一句 20 字节的问候通过 DMA1_Channel4 发出去,轮询完成事件:

const std = @import("std");
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const gpio = stm32.gpio;
const time = stm32.time;

pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{ .logFn = stm32.uart.log });
comptime {
    _ = microzig.export_startup();
}

const uart = stm32.uart.UART.init(.USART1);

pub fn main() !void {
    _ = try rcc.apply(.{
        .SYSCLKSource = .PLL1_P,
        .PLLSourceVirtual = .HSE_Div_PREDIV,
        .PLLMUL = .Mul9,
        .APB1CLKDivider = .Div2,
        .RTCClockSelection = .LSI,
        .flags = .{ .HSEOscillator = true, .RTCUsed_ForRCC = true },
    });
    time.init_timer(.TIM4);

    rcc.enable_clock(.GPIOA);
    rcc.enable_clock(.USART1);
    rcc.enable_clock(.DMA1); // DMA1 挂 AHB,别忘了时钟

    const tx = gpio.Pin.from_port(.A, 9);
    tx.set_output_mode(.alternate_function_push_pull, .max_50MHz);
    try uart.apply_runtime(.{ .clock_speed = rcc.get_clock(.USART1) });
    stm32.uart.init_logger(&uart);

    // DMA1_Channel4 <- USART1_TX(RM0008 请求映射,硬连线)
    const dma_ch4 = stm32.dma.DMA1_Channel4;
    const ch = dma_ch4.get_channel();

    const msg = "hello from dma, this line is moved by hardware\r\n";

    ch.apply(.{
        .direction = .FromMemory,        // 内存 -> 外设
        .peripheral_size = .Bits8,       // 外设端 8 位(USART DR)
        .memory_size = .Bits8,           // 内存端 8 位
        .memory_increment = true,        // 内存地址递增
        .peripheral_increment = false,   // 外设地址固定(永远写 DR)
        .priority = .Medium,
        .periph_address = @intFromPtr(&microzig.chip.peripherals.USART1.DR),
        .transfer_count = msg.len,
    });

    // 把数据拷进通道的内部缓冲并装载 MAR/CNDTR(<= 100 字节!)
    try ch.load_memeory_data(msg); // 注意拼写就是 memeory(上游错拼)
    ch.start();

    // 轮询完成事件
    while (ch.is_in_progress()) {}
    const ev = dma_ch4.read_events();
    if (ev.transfer_complete) {
        dma_ch4.clear_events(ev);
        std.log.info("dma transfer done", .{});
    }

    // 之后可以反复改数据再 load + start,配置只下一次
    while (true) {
        try ch.load_memeory_data("tick\r\n");
        ch.start();
        while (ch.is_in_progress()) {}
        time.sleep_ms(1000);
    }
}

逐段解析

12 个预实例化类型与 get_channel 的门道

hal.dma 的全部公开符号就在 17 行的 dma.zig 里:Channel、ChannelEvent、ChannelNumber 三个类型,加 12 个通道类型——DMA1_Channel1 到 DMA1_Channel7、DMA2_Channel1 到 DMA2_Channel5,与 F103ZE 的 7 + 5 通道一一对应。每个类型由泛型工厂 DMA(comptime dma_ctrl, comptime ch) 生成,内部持有一个 var channel: ?Channel 单例和自己的 DMA_Handler。

两步取通道的写法要理解背后的语义:dma_ch4.get_channel() 返回 *Channel——指向该通道类型内部单例的指针(Channel 结构体带着寄存器指针、in_progress 状态和那块 dma_buffer 切片)。所有配置与启停操作都发生在 Channel 上(apply/start/stop/is_in_progress/load_memeory_data/channel_remain_count),而事件查询与中断注册发生在通道类型上(read_events/clear_events/enable_interrupt)。分层记忆:实例方法在 Channel,全局事件在类型命名空间。

Config 的全部字段逐字如下(dma_common.zig:22-43),只有 periph_address 必填:

字段 类型 / 默认值 含义
priority .Low(可选 .High、.Medium 等) 总线仲裁优先级
direction .FromPeripheral(另有 .FromMemory) 传输方向
peripheral_size / memory_size .Bits8(可选 .Bits16/.Bits32) 两端数据宽度
peripheral_increment / memory_increment false 地址是否自增
circular_mode false 循环模式(传完自动重来)
memory_to_memory false 内存对内存搬运
transfer_complete_interrupt / half_transfer_interrupt / transfer_error_interrupt false 三个事件的中断使能
mem_address ?u32 = null 目标/源内存地址
periph_address u32(必填) 外设寄存器地址
transfer_count ?u16 = null 传输数量

两种使用模式对应 mem_address 的有无:外设→内存(如 ADC 连续采样)时在 Config 里直接给 mem_address 指向你自己的缓冲区,transfer_count 给长度,不经过 100 字节限制;内存→外设(如串口发送)时用 load_memeory_data,它把你的数据拷贝进通道内部缓冲再装载地址。第 16 篇的 ADC DMA 会走前一种。

100 字节限制:来源、后果与两条出路

load_memeory_data 的实现(dma_common.zig:102-110)先检查长度,超限返回 error.BufferOverflow,然后 @memcpy 进 self.dma_buffer 并装载 MAR/NDTR。缓冲区的定义在 bdma_v1.zig:34:

var dma_buffer: [countDmaChannel * 100]u8 linksection(".dma_buffer") = undefined;

countDmaChannel 取芯片的 dma_channel_count(兜底 14),整块 1400 字节被放进链接脚本专门开辟的 .dma_buffer 段(ld/dma_sram.ld,INSERT BEFORE .heap),再按 buffer_idx * 100 切给每个通道——buffer_idx = dma控制器索引 * 7 + 通道号,DMA1 的 7 个通道占 0–6,DMA2 占 7–11。所以:通过 load_memeory_data 单次最多发 100 字节,第 101 个字节换来一个编译期看不见、运行期才爆的 error.BufferOverflow。

这不是硬件限制(F103 DMA 的 CNDTR 是 16 位,单次最多 65535 个传输),纯粹是 microzig 为"保证缓冲落在 DMA 可访问区域"而做的实现取舍。出路两条:

  1. 分块传输。把数据切成 ≤100 字节的块,load_memeory_data → start → 等完成 → 下一块。对串口发送这种顺序流完全够用,代价是块与块之间有 CPU 参与(拷贝 + 重启),高速率下吞吐受限。
  2. 寄存器直配。绕过 load_memeory_data,在 Config 里不适用时直接写 DMA 寄存器:每个通道有四个寄存器——CCR(控制)、CNDTR(数量,16 位)、CPAR(外设地址)、CMAR(内存地址)。DMA1 基址 0x40020000,通道寄存器从偏移 0x08 起每通道一组 0x14(20 字节)。自己设 CMAR 指向任意大小的缓冲区(建议 linksection(".dma_buffer") 保持总线可达约定),CNDTR 给真实长度。CCR 的关键位(RM0008 §13.4.3):EN=0、TCIE=1、HTIE=2、TEIE=3、DIR=4、CIRC=5、PINC=6、MINC=7、PSIZE=9:8、MSIZE=11:10、PL=13:12、MEM2MEM=14。写法与第 12 篇的 USART 寄存器直写同款:microzig.chip.peripherals.DMA1 + .raw + 位掩码,字段名以生成物为准。

事件模型:ChannelEvent 与它的四个布尔

ChannelEvent 是 packed struct(u4),四个字段没有默认值(不能 .{} 空初始化,必须四个全写):pending_event、transfer_complete、half_transfer、transfer_error。它们对应 DMA ISR 寄存器(DMA_ISR)的四个标志族:全局挂起、传输完成、半传输(传到一半)、传输错误。半传输事件在"乒乓缓冲"里是主角——收到一半就开始处理前半,DMA 继续填后半,实现零拷贝间隔的连续流。

read_events() 读、clear_events(ev) 写 1 清零(DMA 的标志是 W1C 语义)。轮询模式下的标准节奏:while (ch.is_in_progress()) 等待 → read_events 确认 → clear_events 收尾。若开了中断使能位,事件由 DMA_Handler 处理——这里要讲那个最重要的坑:

enable_interrupt 的 assert 陷阱(P-13)

enable_interrupt() 的实现分两个世界:

  • RAM 向量表模式(microzig_options.cpu = .{ .ram_vector_table = true }):它把 DMA_Handler 写进 microzig.cpu.ram_vector_table.DMA1_Channel4,一切正常。
  • Flash 向量表模式(默认):它什么都不写,只执行 std.debug.assert(向量表里那一项 == DMA_Handler)——意思是"你应该已经自己在 microzig_options.interrupts 里绑好了"。而 std.debug.assert 在 ReleaseSmall 下被编译掉,于是忘绑的结果是零报错 + DMA 中断永不触发。

Flash 模式下的正确姿势:

pub const microzig_options: microzig.Options = .{
    .interrupts = .{ .DMA1_Channel4 = .{ .c = stm32.dma.DMA1_Channel4.DMA_Handler } },
};

向量名由 @tagName(dma_ctrl) ++ "_" ++ @tagName(ch) 拼出,Channel 的 C 是大写——写 DMA1_CHANNEL4 或 DMA1_channel4 都是 no field named。本篇示例为了聚焦数据通路用轮询不开中断;要上中断就加上面这段,并知道 DMA_Handler 对 transfer_error 事件的处理是直接 @panic——量产固件里错误处理要自己接管。

另一个与中断注册相关的事实:microzig.chip.properties.dma_channel_count 是 regz 生成物,若它对 F103ZE 给出的值不足 14,dma_buffer 总大小会随之变化——检查你的 .dma_buffer 段实际大小的方法在第 20 篇(llvm-size/nm 工具链)。

运行现象

烧录后串口先打出一句 hello from dma, this line is moved by hardware\r\n——注意这句话经过的路径:load_memeory_data 把它拷进 DMA1_Channel4 的内部缓冲 → start() 置 EN → DMA 逐字节写 USART1.DR → USART 硬件移位输出。CPU 在 while (ch.is_in_progress()) 里自旋的时间约 20 × 87 µs ≈ 1.7 ms(115200 波特率),其间它什么也不用做——这就是 DMA 的意义。随后每秒一行 tick\r\n。

验证"真的是 DMA 在搬"的两招:

  1. 逻辑分析仪挂 TX 线:波形与轮询发送完全一致(串口波形只看内容,不看来路);改在 start() 前后翻转一个 GPIO,能看到 start() 到首位输出之间只有 DMA 启动的固定开销,没有逐字节循环的软件痕迹。
  2. 对比 CPU 占用:把发送换成 100 字节满块、波特率拉到 460800,轮询版(write_blocking)CPU 被占用约 2.2 ms,DMA 版 load + start 只需几微秒——差异来自"拷贝 100 字节 + 配寄存器"与"100 次 TXE 等待"的对比。

把 msg 加长到 101 字节重编:load_memeory_data 返回 error.BufferOverflow,try 让 main 直接 panic——亲眼见到这个限制比读十遍文档有用。再用分块函数包一层重烧,恢复正常。

常见问题

Q1:load_memeory_data 是什么拼写错误?我写 load_memory_data 编译不过。

上游把 memory 拼成了 memeory(dma_common.zig:102),方法名就是 load_memeory_data。这与 TimerGenealConfig、set_auto_relaod_mode、set_indedpended 属于同一族"上游拼写彩蛋"——写对英文反而编译失败。应对策略与第 10 篇相同:不要"顺手修正",在项目里留一行注释提醒后来者。若真想用正确拼写,可以自己包一层 inline fn。

Q2:DMA 发出去的数据开头对、后面乱,或者发送时好时坏。

按命中概率排:① 源数据生命周期——load_memeory_data 拷贝进内部缓冲所以安全,但如果你用寄存器直配让 CMAR 指向栈上数组或临时变量,函数返回后内存被复用,DMA 搬到一半读到的就是垃圾。DMA 缓冲必须是静态/全局的;② memory_increment 忘了置 true——每个字节都写在内存同一地址,外设收到同一字节的重复;③ 忘了 rcc.enable_clock(.DMA1)——寄存器写不进去,通道根本没启动;④ 上一笔没完成就重新 start——先 stop() 或等 is_in_progress() 为假,CNDTR 会在重新装载前保持旧值。

Q3:开了 transfer_complete_interrupt 却永远进不了中断。

九成是 P-13:Flash 向量表下 enable_interrupt() 只有一句 assert,ReleaseSmall 下被编译掉,你没在 microzig_options.interrupts 里绑 .DMA1_Channel4 = .{ .c = stm32.dma.DMA1_Channel4.DMA_Handler }。Debug 构建下 assert 失败会 panic,所以"Debug 好好的、Release 没中断"是典型指纹。另外检查三件套:Config 里的 transfer_complete_interrupt = true(CCR 的 TCIE)、NVIC 使能(enable_interrupt() 内部会做,但前提是上面那条)、clear_events 在 ISR 里被调用(不清标志会重入)。

Q4:外设→内存方向(比如 ADC)也要走 100 字节限制吗?

不走。100 字节限制只在 load_memeory_data 这条"内存→外设"路径上(它要把数据拷进固定大小的内部缓冲)。反向传输时你在 Config.mem_address 里给的是你自己的缓冲区地址,长度只受 transfer_count(u16,最大 65535 个传输单元)约束。第 16 篇的 ADC + DMA 连续采样、第 13 篇提到的乒乓缓冲都走这条路。不过要留意第 20 篇会讲的 .dma_buffer 段约定:F103 只有一个 SRAM、任何地址 DMA 都可达,但沿用 linksection(".dma_buffer") 习惯无害且跨芯片可移植。

Q5:ChannelEvent 能不能 .{} 初始化?编译器报缺字段。

不能。ChannelEvent 的四个布尔字段全部没有默认值,.{} 会报 missing field。这是上游刻意为之(强制你显式表达全部事件),写法:.{ .pending_event = false, .transfer_complete = true, .half_transfer = false, .transfer_error = false }。惯用技巧是先 read_events() 再把读到的值传回 clear_events,避免手写四个布尔。

Q6:circular_mode 是干什么的?什么时候用?

循环模式:CNDTR 计数到零后自动重载、传输永不停止,配合半传输事件构成乒乓缓冲的经典结构——DMA 不断把 ADC 采样写进固定缓冲,CPU 在"前半满"事件处理前半段、"后半满"事件处理后半段,数据处理与搬运完全重叠,CPU 永远不用重启 DMA。适合连续流(音频、持续采样);不适合一次性请求(一次配置一次完成)。用 circular 时注意 read_events 的时机——事件处理慢了数据会被覆写,没有背压机制。

面试考点

Q1:描述 F103 DMA 的通道与外设请求映射机制,它与带 DMAMUX 的芯片有何本质区别?

参考答案:F103 的 DMA 请求是硬连线映射:每个通道固定服务若干外设(RM0008 §13.3.7 的请求映射表),如 DMA1_Channel4 只能是 USART1_TX/SPI2_RX/TIM1 组合,软件只能在表内选择、不能任意路由。带 DMAMUX 的芯片(F4/H7/G 系列)每个 DMA 通道可通过复用器路由到几乎任意外设请求。F103 模式的后果:通道是稀缺资源,外设间可能争抢同一通道(如 USART1_TX 与 SPI2_RX 都要 DMA1_Channel4),系统设计阶段就要做通道分配表;DMAMUX 模式则把冲突问题转移给运行时配置。

Q2:DMA 的 priority、circular_mode、半传输事件分别解决什么问题?

参考答案:priority 决定总线矩阵仲裁时 DMA 通道之间的次序(以及相对 CPU 的地位),多个高吞吐通道并存时防饿死。circular_mode 让传输完成后自动重载 CNDTR 继续,实现无 CPU 干预的连续流,代价是没有"完成"概念、数据会被覆写。半传输(half transfer)事件在计数过半时触发,与传输完成事件配合构成乒乓缓冲:数据写入与处理重叠,吞吐不受 CPU 响应时间限制。三者组合是"ADC 连续采样 + 实时处理"的标准架构。

Q3:microzig F103 DMA 的 100 字节限制从哪来?如何绕过?它为什么放在 .dma_buffer 段?

参考答案:来源是 HAL 实现:每个通道类型持有一块从全局 dma_buffer[通道数 × 100] 切出的 100 字节静态缓冲,load_memeory_data 先检查长度再 @memcpy 进去,超限返回 error.BufferOverflow。这是实现限制而非硬件限制(CNDTR 是 16 位)。绕过:分块传输(每块 ≤100 字节),或跳过该函数直接配置 CMAR/CPAR/CNDTR/CCR 指向自己的大缓冲。放在 .dma_buffer 段是因为 STM32 多 SRAM 芯片上部分 RAM 块 DMA 不可达,链接脚本把这 1400 字节钉在保证可达的区域(F103 单 SRAM 下冗余但无害,INSERT BEFORE .heap 的位置选择见第 20 篇)。

Q4:为什么说 dma.enable_interrupt() 在 ReleaseSmall 下"静默失效"?从根上分析原因。

参考答案:enable_interrupt() 在 Flash 向量表模式下不做注册,只执行 std.debug.assert(向量表项 == DMA_Handler)——断言使用者已自行绑定。std.debug.assert 依赖构建模式,ReleaseSmall 下被完全编译掉,断言退化成空操作。于是"忘记绑定"这个错误在 Debug 下 panic(可发现)、在 Release 下无声通过且中断永不触发(不可发现)。教训有三层:assert 不是错误处理(第 18 篇还会遇到 ReleaseSmall 编译掉 assert 的问题);Flash 向量表的静态注册必须走 microzig_options.interrupts;跨过 Release 边界前要在 Debug 下把中断路径完整验证一遍。

Q5:一次 DMA 传输中,CPU 与 DMA 控制器如何共享总线?会不会互相拖慢?

参考答案:DMA 通道作为 AHB 总线主设备与 CPU 并存,总线矩阵逐事务仲裁。DMA 搬运 SRAM↔外设的每个字节占两个总线事务;CPU 同时取指/访存时若目标相同则排队,冲突时按优先级让路。影响量级:8 位单次传输、总线 72 MHz 下,DMA 独占总线的时长是纳秒级,CPU 的实际减速通常可忽略;但 memory_to_memory 大块搬运或高优先级 DMA 连续流会让 CPU 周期性插入等待状态。工程结论:DMA 解放的是 CPU 的"参与",不是总线的"带宽";评估实时性时要把 DMA 流量计入总线预算。

Q6:同样要"接收不丢字节",第 12 篇的中断方案与本篇的 DMA 方案如何取舍?

参考答案:中断方案逐字节 ISR,开销约 1 µs/字节,115200 下 CPU 占用约 1%,适合低中速率与逐字节协议处理;实现简单、延迟确定、错误处理直观。DMA 方案把搬运下沉硬件,ISR 只在块完成/半完成时进入,适合高波特率(≥460800)与块状数据;但要解决"包边界"问题(空闲检测或固定长度)、缓冲生命周期管理(静态缓冲)、以及 microzig 的 100 字节与 assert 陷阱。选型顺序:先中断,profile 出 ISR 瓶颈再上 DMA;上 DMA 时优先外设→内存方向(不受 100 字节限制),发送用分块即可。

小结

  • F103ZET6:DMA1 七通道 + DMA2 五通道,请求硬连线映射(USART1_TX=DMA1_Channel4、USART1_RX=DMA1_Channel5、ADC1=DMA1_Channel1,出处 RM0008 Table 78)。
  • 12 个预实例化类型 DMA1_Channel1..7/DMA2_Channel1..5;get_channel() → apply(Config) → start();实例方法在 Channel,事件在类型命名空间。
  • Config 全字段:priority/direction/peripheral_size/memory_size/peripheral_increment/memory_increment/circular_mode/memory_to_memory/三个中断开关/mem_address/periph_address(必填)/transfer_count。
  • 每通道 100 字节静态缓冲(dma_buffer[14*100] linksection .dma_buffer),load_memeory_data(拼写就是 memeory)超限 error.BufferOverflow;出路:分块或寄存器直配 CCR/CNDTR/CPAR/CMAR。
  • ChannelEvent 四字段无默认值;read_events/clear_events,W1C 清除。
  • enable_interrupt() 在 Flash 向量表下只有 assert(P-13),ReleaseSmall 静默失效;必须 microzig_options.interrupts 里绑 DMA1_Channel4(大写 C)。
  • DMA 缓冲必须静态分配;circular + half_transfer 是连续流的标准结构。
  • 下一篇 I2C:换一种总线,讲开漏、上拉与 SSD1306 点屏。

系列目录

  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  阅读(18)  评论(0)    收藏  举报