ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

QEMU模拟STM32F103外设驱动开发实战:从GPIO到DMA中断

QEMU模拟STM32F103外设驱动开发实战:从GPIO到DMA中断 1. 为什么我坚持用 QEMU 跑外设驱动而不是等板子到货1.1 没有物理板时驱动开发会遇到什么问题接到一个用 STM32F103 读 AD7190 称重传感器的需求时板子还在路上。按以前的做法只能先把原理图和数据手册翻熟等工作台上有板子了再动手写代码。但这次实在不想干等就把 QEMU 环境翻出来决定先把外设驱动跑通。很多人对 QEMU 跑 STM32F103 的印象还停留在点个灯、打印个 Hello World的层面觉得它做不了正经的外设驱动开发。我刚开始也是这个想法后来系统地试过一轮才发现GPIO、UART、SPI、定时器 PWM、DMA 中断这些常用外设在模拟环境里不仅能跑还能用来做相当深度的逻辑调试。这篇文章把整个过程和踩过的坑记录下来。在 QEMU 里模拟 STM32F103 外设驱动核心价值不是替代真机而是提前完成驱动逻辑的开发、验证和排错。等板子到货之后需要做的只是把编译好的固件烧进去做硬件层面的确认而不是从零开始调驱动。1.2 模拟环境能替你验证哪一层不能替你验证哪一层先说结论模拟环境能验证的是寄存器的读写序列、中断标志位的处理流程、DMA 描述符的配置、状态机的跳转逻辑不能验证的是电气特性、模拟量精度、信号完整性和真实时序参数。我用一个很直白的类比来解释QEMU 里的 STM32F103 外设模型就像一栋楼的消防管道图纸能告诉你阀门该开哪个、水应该往哪个方向流但没法告诉你实际拧开阀门时水压够不够、管道会不会漏水。所以驱动开发中逻辑正确性这一层完全可以在模拟器里解决但电气参数是否满足只能用真机验证。实际测试下来以下外设在模拟环境里表现可靠GPIO、USART、SPI、TIM、EXTI、DMA 和 NVIC 中断控制。而 ADC 的采样精度、I2C 的时序细节、CAN、USB 这些要么没有模型要么模型过于简化不适合作为驱动验证平台。搞清楚这个边界能避免在模拟环境上浪费大量时间。1.3 这篇适合什么阶段的人看这篇是系列第十三篇默认你已经把 QEMU 环境搭好而且能编译并运行一个最小的 STM32F103 程序。如果还没有环境可以回头翻一下系列前面讲环境搭建和启动流程的文章。这篇的内容重点放在外设驱动的模拟实战从 GPIO 点灯开始到 UART 配合 DMA 中断收发再到 SPI 读取外部芯片的模拟策略最后给出我实际使用中总结的边界和技巧。无论你是刚开始接触嵌入式模拟开发还是有一定基础想在 CI 里做自动化测试这篇都应该有能直接用上的东西。2. 跑起来之前先摸清这套模拟环境里的外设边界2.1 哪些外设模型可靠哪些只是能编译不能仿真我用的 QEMU 环境是基于社区维护的 qemu_stm32 分支它提供了 STM32F103 的 SoC 模型machine 选择stm32-p103对应 Olimex STM32-P103 开发板板载 STM32F103RBT6。这个 SoC 模型对外设的支持程度是有差异的我整理了一份实测对比外设模拟完整度实测表现GPIO高输入输出、上下拉、复用功能均正常开漏模式表现与真机一致USART高轮询和中断收发均正常可用-serial stdio直接交互SPI中高主模式收发正常但从设备响应需要自己搭逻辑TIM高定时中断、PWM 输出均正常适合验证配置流程EXTI高外部中断触发和标志位处理正常DMA中高传输完成中断正常但时序特性与真机有差异ADC低寄存器可读写但采样结果无实际物理意义I2C低模型过于简化不建议用来验证 I2C 时序CAN、USB、FSMC无没有对应模型不要尝试这份表对我后续开发帮助很大。比如我要用 SPI 读 AD7190就需要意识到模拟环境里没有 AD7190 这个从设备得想别的办法。而如果只是验证 USART 的中断接收逻辑那直接就可以开工。2.2 我常用的启动命令和调试参数QEMU 跑 STM32F103 的启动命令我基本固定为这样qemu-system-arm -M stm32-p103 \ -kernel build/main.elf \ -nographic \ -serial stdio \ -monitor none几个参数的作用要说明一下。-M stm32-p103指定使用 STM32F103 的 machine 模型这是整个模拟环境的基础。-kernel直接加载 ELF 格式的固件免去了手动指定地址的麻烦。-nographic表示不使用图形界面-serial stdio将 USART1 的输出重定向到当前终端这样串口打印信息能直接看到。-monitor none是我后来加的。默认情况下 QEMU 会占用一个控制台作为 monitor 命令行如果不关掉跟串口输出混在一起很容易干扰调试。需要输入 QEMU monitor 命令时可以临时去掉这个参数或者在运行过程中用CtrlA C切换但日常调试基本用不到。2.3 从内核到外设中断和时钟在模拟器里怎么流转STM32F103 的固件在 QEMU 里运行遵循的启动流程和真机完全一致复位向量 - 跳转 Reset_Handler - 调用 SystemInit - 进入 main。时钟这部分模拟器不会真正模拟外部晶振和 PLL 的电气过程但 RCC 寄存器的值会按照模拟逻辑更新所以代码里读 RCC 寄存器获取时钟频率返回的值是符合预期的。中断这部分值得多说一点。NVIC 在 QEMU 里是有完整模型的外设触发中断 - NVIC 判断优先级 - 跳转中断向量 - 执行中断处理函数 - 清标志位整个链路都是真实的代码逻辑。这意味着中断使能、中断优先级、中断嵌套这些行为在模拟环境里和真机是等价的非常适合用来调试中断相关的驱动代码。我做过一个测试写一个代码配置 TIM2 定时中断每 1ms 在中断里翻转一次 GPIO。在 QEMU 里单步跟踪能看到中断向量表正确跳转标志位更新和中断执行流程都符合预期。这在真机上调试是很麻烦的因为你看不到中断控制器内部的状态。3. 先用 GPIO 找手感点灯、走马灯到继电器控制逻辑3.1 寄存器级点亮 LEDRCC 开启时钟是关键第一步GPIO 驱动是外设模拟实战中最适合先动手的项目因为逻辑简单、反馈直观。虽然 QEMU 里看不到真实的 LED 亮灭但可以通过两种方式验证一是用调试器读取 GPIO 输出数据寄存器的值二是在代码关键位置加串口打印。我用寄存器操作而不是标准外设库来写初始版本目的是更清楚地展示底层逻辑。点亮 LED 的第一步也是新手最容易漏的是开启对应 GPIO 端口的时钟RCC-APB2ENR | RCC_APB2ENR_IOPCEN; // 使能 GPIOC 时钟 GPIOC-CRH ~(0xF 20); // 清空 PC13 配置 GPIOC-CRH | (0x2 20); // 配置 PC13 为推挽输出50MHz GPIOC-BSRR (1 13); // PC13 输出高电平 GPIOC-BSRR (1 (13 16)); // PC13 输出低电平这段代码的思路很清晰RCC 负责给外设供时钟CRH/CRL 决定引脚的模式和速度BSRR 寄存器通过写 1 来设置或清除引脚输出。在 QEMU 里运行这段代码用 GDB 查看GPIOC-ODR的值能观察到它在 0x2000 和 0x0000 之间切换说明驱动逻辑正确。注意很多人在 QEMU 里跑点灯代码发现灯不亮其实不是模拟器的问题而是忘了开 RCC 时钟或者 CRH/CRL 的位段算错了。这些错误在真机上同样会发生但模拟环境里排查起来容易得多。3.2 走马灯和串口指令控制把模拟器当成一块可交互的板子点灯之后可以顺手上一个走马灯配合串口命令来控制 LED 状态。这个思路对应了很多人想验证的串口控制 LED场景。我实现的逻辑是这样的USART1 接收单个字符命令比如1代表第一个 LED 亮、2代表第二个 LED 亮、0代表全部熄灭。在 QEMU 里运行后直接在终端输入数字就能看到串口返回的状态信息。这种交互方式极大提升了模拟开发的体验让人觉得不是在对着一台假的机器操作。void USART1_IRQHandler(void) { uint8_t cmd; if (USART1-SR USART_SR_RXNE) { cmd USART1-DR 0xFF; switch (cmd) { case 1: GPIOC-BSRR (1 13); break; case 0: GPIOC-BSRR (1 (13 16)); break; default: break; } USART_SendData(USART1, cmd); } }这个例子的意义不只是走马灯本身而是把两个外设GPIO 和 USART联合起来。在模拟环境里调试联合外设逻辑比在真机上还方便因为你可以随时中断执行、检查每个外设寄存器的当前状态。3.3 控制继电器真正需要模拟的是时序而不是电流继电器控制是工业控制里非常常见的需求很多人在 STM32F103 上用 GPIO 输出高电平驱动三极管再由三极管控制继电器线圈。真正的继电器驱动电路涉及电流计算、续流二极管、触点容量等一系列硬件问题这些在 QEMU 里完全模拟不了但控制逻辑的时序部分是完全可以模拟的。继电器吸合和释放需要时间。常见的 5V 继电器吸合时间一般在 5ms 到 15ms 之间释放时间在 3ms 到 5ms 之间。在编写控制逻辑时需要保证 GPIO 输出的电平持续时长满足继电器动作需求。我曾经见过一个驱动GPIO 输出高电平后只延时了 1ms 就去检测继电器触点状态结果在真机上时好时坏。在 QEMU 里可以这样模拟继电器的控制时序#define RELAY_ON_TIME_MS 10 #define RELAY_OFF_TIME_MS 5 void relay_on(void) { GPIOB-BSRR (1 0); // 继电器驱动管脚置高 delay_ms(RELAY_ON_TIME_MS); // 等待吸合稳定 relay_feedback_check(); // 检测反馈信号 } void relay_off(void) { GPIOB-BSRR (1 (0 16)); // 继电器驱动管脚置低 delay_ms(RELAY_OFF_TIME_MS); // 等待释放稳定 relay_feedback_check(); }在模拟环境里调试的关键点在于验证驱动管脚电平切换的顺序是否正确、延时是否满足要求、反馈信号检测的时机是否合理。这些逻辑如果在模拟器里跑通了真机上通常不会出现方向性错误。4. UART 驱动与 DMA 中断模拟器最值得投入时间的地方4.1 轮询式 UART 收发先把数据通道打通串口在嵌入式开发里是调试的第一通道几乎所有外设驱动的验证都需要靠串口打印信息。在 QEMU 里跑通 UART 收发是整个外设模拟实战中最立竿见影的一步。轮询式收发逻辑很简单发送一个字节就等 TXE 标志位置位接收一个字节就等 RXNE 标志位置位。初始化配置要用到 USART 的波特率寄存器 BRR这个寄存器的计算方法是BRR PCLK / 波特率。如果系统时钟是 72MHzAPB2 总线时钟也是 72MHzUSART1 的 BRR 值就是 72MHz / 115200 625对应十六进制 0x271。void uart1_init(uint32_t baud) { RCC-APB2ENR | RCC_APB2ENR_USART1EN | RCC_APB2ENR_IOPAEN; GPIOA-CRH ~(0xFF 4); // PA9 TX, PA10 RX GPIOA-CRH | (0xB 4); // TX 复用推挽 GPIOA-CRH | (0x4 8); // RX 浮空输入 USART1-BRR 72000000 / baud; USART1-CR1 | USART_CR1_TE | USART_CR1_RE | USART_CR1_UE; }一个细节是 GPIO 复用功能的配置。很多人在这里配错把 TX 引脚配成通用推挽输出导致串口发送出去的数据都是乱的。在 QEMU 里同样能看到这个问题因为模拟器会检查引脚模式。这个坑在真机和模拟器里都会踩但在模拟器里排查不需要万用表和示波器。4.2 标准库 UART DMA 中断收发描述符配置的检查顺序轮询收发能跑通之后很多人会直接上 DMA 中断收发。这也是网上被问得最多的问题之一为什么 DMA 发送一次之后第二次就无法触发了DMA 的配置有固定的检查顺序我建议按照下面这个顺序来排查确认 DMA 时钟已经开启RCC-AHBENR 的 DMA1EN 位确认 DMA 通道对应的外设地址和内存地址正确确认传输方向设置正确外设到内存是DMA_DIR_PeripheralSRC内存到外设是DMA_DIR_PeripheralDST确认缓冲区大小CNDTR 寄存器在每次传输完成后重新赋值确认 DMA 传输完成中断已经使能而且在中断处理函数里清除了标志位标准库中 DMA 初始化的一个典型写法是void uart1_dma_tx_init(uint8_t *buf, uint16_t len) { DMA_InitTypeDef DMA_InitStructure; RCC-AHBENR | RCC_AHBENR_DMA1EN; DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize len; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel4, DMA_InitStructure); DMA_ITConfig(DMA1_Channel4, DMA_IT_TC, ENABLE); DMA_Cmd(DMA1_Channel4, ENABLE); }在 QEMU 里调试 DMA 驱动有一个天然优势可以随时停下来查看DMA1-CNDTR寄存器确认当前还剩多少个字节没有传输。在真机上即使你能用调试器暂停也比模拟环境里麻烦得多。4.3 一次“第二次发送卡死”的排错记录用 DMA 做 USART 发送遇到一个典型的卡死问题排查过程很有参考价值。现象是第一次调用 DMA 发送函数数据能正常发出去第二次调用同样的函数程序就卡死不再进入发送完成中断。一开始怀疑是 QEMU 模拟器的问题后来发现其实是典型的 DMA 配置逻辑错误。排错路径是这样的第一次中断里调用了DMA_ClearITPendingBit(DMA1_IT_TC4)清除中断标志位但忘记在传输完成后重新设置DMA_SetCurrDataCounter()也就是 CNDTR 寄存器还停留在 0。第二次启动 DMA 时CNDTR 为 0数据传输长度为 0自然永远不会触发传输完成中断。解决方法是在传输完成中断处理函数里要么重新设置 CNDTR要么在启动函数里每次都调用DMA_SetCurrDataCounter()重新指定长度。这个错误在真机上同样会发生但在模拟器里可以通过查看寄存器的值快速定位。当时我在 GDB 里输入p/x DMA1-CNDTR看到值为 0x0立刻就知道问题出在哪了。这个案例很好地说明了模拟器的价值不是它不会出错而是它出错时给你提供了极其清晰的现场视图。真机上这个坑可能要反复试很多次才能定位模拟器里几分钟就解决了。5. SPI DMA 读取外部芯片的模拟实战以 AD7190 为例5.1 直接在 QEMU 里跑 CUBEMX 生成的 SPI DMA 代码会遇到什么STM32F103 通过 SPI 读取外部 ADC 芯片 AD7190是一个典型的传感器采集场景。很多人习惯直接用 CUBEMX 生成初始化代码再填业务逻辑。直接把 CUBEMX 生成的代码拿到 QEMU 里跑会遇到两个问题。第一个问题是 SPI 从设备不存在。QEMU 的 STM32F103 模型里有 SPI 主机控制器但没有挂接任何外部 SPI 从设备。SPI 主机发送数据时MISO 线上没有数据返回读回来的都是 0xFF 或者预期的默认值。这会导致你无法验证读取数据的正确性。第二个问题是 NSS 片选信号的处理。很多 CUBEMX 工程把 NSS 配置为硬件控制模式而在模拟环境里没有真正的外设响应片选信号所以 NSS 电平的变化需要靠代码逻辑验证而不是靠外部测量。解决这两类问题的方法不是放弃模拟而是调整模拟策略不依赖真实外部芯片而是利用软件手段构造一个假的从设备响应。5.2 没有外部芯片时的模拟策略回环模式与软件寄存器模拟我推荐从低到高、从简到繁的三种模拟策略按需选择。第一种是最简单的SPI 回环测试把 SPI 的 MOSI 引脚和 MISO 引脚从逻辑上短接主机发送什么数据就能接收到什么数据。void spi1_loopback_test(void) { uint8_t tx_data 0xA5; uint8_t rx_data 0x00; while (SPI1-SR SPI_SR_BSY); SPI1-DR tx_data; while (!(SPI1-SR SPI_SR_RXNE)); rx_data SPI1-DR; printf(rx_data 0x%02X\n, rx_data); }回环测试能验证 SPI 主机本身的数据通路但无法模拟外部芯片的寄存器行为。对 AD7190 这种有复杂寄存器映射的芯片回环测试就不够用了。第二种是软件模拟从设备寄存器在 MCU 代码里维护一个软件状态机模拟 AD7190 对 SPI 命令的响应。比如上位机通过 SPI 写入命令 0x00AD7190 的通信寄存器地址软件状态机识别出这是读操作就返回一个预设的寄存器值。这种方式能完整模拟整条 SPI 通信链路代码逻辑和真机驱动可以基本一致。第三种是修改 QEMU 源码添加外设模型这是最彻底的方案工作量也最大。如果只是验证驱动逻辑前两种通常就够了。官方文档和社区里能查到相关数据手册但要在 QEMU 里完整实现一个外设模型涉及设备和总线注册、MMIO 映射、中断触发等多个环节不是一两天能搞定的。我自己实际做项目时用到第二种策略的次数最多。5.3 走到自定义 QEMU 外设模型之前先算算成本每当有人问我怎么在 QEMU 里添加一个自己的外设模型时我都会先反问一句你到底想验证什么如果目标是验证 SPI 驱动本身的寄存器配置逻辑、DMA 数据搬运是否正确那软件模拟从设备寄存器的方式完全够用而且成本极低。如果目标是模拟 AD7190 的完整操作时序、掉电模式转换、滤波设置这些芯片特有行为那才需要考虑添加自定义外设模型。自定义 QEMU 外设模型的基本思路是在 QEMU 源码里创建一个新的设备对象注册到 SPI 总线上实现对应的 MMIO 读写回调函数然后在 machine 初始化代码里将这个设备挂载到 SPI 控制器上。这个思路理解起来不难但实现和调试周期往往以周为单位计算。我的建议是除非你有长期的、反复的模拟需求否则先用软件模拟策略。等实践中确实发现软件模拟无法满足需求再考虑动 QEMU 源码。模拟器是手段驱动开发才是目的。6. 外设模拟的边界以及我现在怎么用它6.1 模拟器的时序、中断延迟和真机之间的差距用 QEMU 调试外设驱动是一回事把模拟器里的运行结论直接搬到真机是另一回事。最大的区别在于时序特性。QEMU 的指令执行速度和真机的时钟频率并不严格对应。在模拟器里一段延时循环可能很快执行完也可能因为宿主机负载波动而变慢。这意味着依赖for循环做粗略延时的代码在模拟器和真机上的实际延时时间可能差出几倍到几十倍。中断响应延迟也同理。QEMU 的 NVIC 模型能正确模拟中断优先级和嵌套逻辑但中断从触发到进入处理函数的时间和真机的硬件中断延迟没有任何可比性。如果驱动代码里有必须在多少微秒内响应中断这种硬性假设在模拟器里是验证不了的。我自己在项目里定了一条规则模拟器里验证逻辑正确性和状态机真机上验证时序参数和电气特性。两条线各管一段不互相替代。6.2 QEMU 模拟外设在日常开发流程中的定位经过一段时间的实践我把 QEMU 模拟外设放到了日常开发流程中一个固定的位置驱动逻辑的先行验证平台和持续集成测试的执行环境。驱动逻辑先行验证很好理解板子还没到的时候代码已经在模拟器里跑过一遍了。等板子到手烧录之后能直接进入硬件调试阶段省去了大量低级排错时间。持续集成测试是另一个非常有价值的用法我搭了一个自动化脚本每次代码提交后自动编译并加载到 QEMU 里运行一段外设自检程序如果 UART、SPI、TIM、DMA 的测试用例全部通过才会进入人工审查。这样在改动驱动代码时能有很快的反馈而不必每次都动用实体硬件。这个用法对上板条件受限的场景尤其友好。比如你手里只有一块开发板但验证点有十几个每次插拔烧录都很繁琐QEMU 就能帮你把基础验证先跑完把板子留给真正需要硬件环境的测试。6.3 用 GDB 连接模拟器直接查看外设寄存器最后分享一个让模拟器调试外设驱动的效率翻倍的小技巧用 GDB 连接 QEMU直接查看外设寄存器的实时值。启动 QEMU 时加上-s -S参数-s表示在 TCP 1234 端口开放 GDB 调试接口-S表示启动时暂停等待调试器连接。然后在另一个终端启动 arm-none-eabi-gdb加载固件 ELF 文件连接远程目标arm-none-eabi-gdb build/main.elf (gdb) target remote :1234 (gdb) continue程序跑起来之后随时CtrlC暂停然后就能查看任意地址的寄存器值。比如要看 USART1 的状态寄存器可以这样(gdb) p/x *(unsigned int*)0x40013800这个地址是 USART1 的基地址0x40013800 处的数值就是 USART1_SR 寄存器的当前值。也可以用 GDB 的watch命令监听某个外设寄存器的变化一旦被修改就自动暂停。比如监听 DMA1 的 CNDTR 寄存器变化(gdb) watch *(unsigned int*)0x40020058这项能力在真机调试时几乎不可能实现因为你要在极短的时间内看到外设寄存器的瞬时状态。但在模拟器里随时暂停、随时查看、随时设置值配合寄存器手册排查外设驱动问题比真机还要顺手。踩过几次坑之后我现在写 STM32F103 外设驱动第一件事就是把 QEMU 环境起起来第二件事是挂上 GDB然后才开工写代码。
返回列表