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 的接口只有三个动作:
- 下单:告诉它源地址、目的地址、长度、每次传输后地址是否递增、数据宽度。
- 启动:置 EN 位。
- 验收:传输完成后 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(µzig.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 可访问区域"而做的实现取舍。出路两条:
- 分块传输。把数据切成 ≤100 字节的块,
load_memeory_data→start→ 等完成 → 下一块。对串口发送这种顺序流完全够用,代价是块与块之间有 CPU 参与(拷贝 + 重启),高速率下吞吐受限。 - 寄存器直配。绕过
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 在搬"的两招:
- 逻辑分析仪挂 TX 线:波形与轮询发送完全一致(串口波形只看内容,不看来路);改在
start()前后翻转一个 GPIO,能看到start()到首位输出之间只有 DMA 启动的固定开销,没有逐字节循环的软件痕迹。 - 对比 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 点屏。
系列目录
- 系列总览:Zig 裸机开发的定位、生态与平台全景
- 开发环境搭建:Zig 0.17、microzig 与 probe-rs
- 工程结构剖析:build.zig、MicroBuild 与固件产物
- 第一个固件:启动流程、向量表与 GPIO 点灯
- 寄存器级编程:PAC、MMIO 与 volatile 语义
- GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
- 时钟树配置:HSE、PLL 与总线分频
- 中断系统:NVIC、优先级分组与向量注册
- USART 串口通信:轮询收发与日志重定向
- 定时器基础:时基、PWM 与呼吸灯
- 输入捕获:频率与占空比测量
- 中断驱动串口:环形缓冲区与临界区设计
- DMA 传输:从外设搬运到内存
- I2C 总线:SSD1306 OLED 显示实战
- SPI 总线:W25Q64 Flash 读写与 ID 识别
- ADC 采样:单次、扫描与内部温度传感器
- 内部 Flash 编程:参数持久化与页擦除
- 独立看门狗:复位诊断与可靠性设计
- 低功耗模式:Sleep、Stop、Standby 与唤醒
- 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
- Zig 语言进阶:comptime、错误处理与零成本抽象
- 并发模型:中断与主循环的协作与临界区
- 协作式调度器:状态机与时间片轮转
- 实时性分析:中断延迟、抖动与 WCET
- RTOS 内核实现(上):任务控制块与上下文切换
- RTOS 内核实现(下):信号量、消息队列与优先级继承
- USB 设备开发:CDC 虚拟串口
- CAN 总线:bxCAN 过滤器与环回自测
- 调试与测试:probe-rs、RTT 与硬件在环
- 收官:分层架构、可移植性与 Zig 生态展望

浙公网安备 33010602011771号