做嵌入式开发的朋友应该都有过这种体会:驱动代码写好了,板子还在打样,或者板子只有一两块,部门里好几个人排队等着用。真板子缺、调试手段又有限,遇到问题只能靠示波器慢慢戳。QEMU在Linux、网络设备模拟上已经很成熟,但提到模拟STM32F103,很多人第一反应是“玩具”,觉得外设模型肯定缺胳膊少腿。实际上,只要把环境搭对、知道哪些外设能信、哪些不能信,这套模拟器是相当能打的。它能帮你快速验证寄存器配置、跑通DMA和中断流程,还能直接扔进CI流程做回归测试。这篇文章是QEMU-STM32系列的第十三篇,主题就是STM32F103外设驱动模拟实战,重点聊GPIO、UART、定时器、SPI和DMA这几类最常用的外设怎么在QEMU上驱动起来,以及在调试中会踩到哪些坑。
1. 整体思路与外设模拟的定位
1.1 为什么拿QEMU来模拟STM32F103外设
嵌入式驱动开发有个很尴尬的阶段:外设驱动代码写完了,但硬件平台还没就绪。如果没有模拟器,只能干等板子。而QEMU恰好能把“外设寄存器”这一层模拟出来,让驱动代码在PC上先跑起来。这里要强调一个认知:QEMU模拟的不是电气特性,而是寄存器行为和中断行为。它适合验证“我的初始化顺序对不对”“DMA搬运完有没有进中断”“UART发送时TXE标志位有没有被清掉”这类逻辑问题,不适合验证“上电瞬间GPIO会不会抖动”“I2C上升沿是不是符合时序规范”这类硬件问题。
我经常把QEMU下的外设调试比作“照着乐高图纸搭积木”。寄存器就是积木块,QEMU负责检查你是不是搭对了位置。如果你把UART的波特率寄存器写错了,模拟器不会像真板子那样“偶尔收发错误”,它会严格按寄存器模型计算出错位的数据。对比真板子,这反而更利于定位问题,因为结果可复现。
1.2 选择支持STM32F103的QEMU方案
先泼一盆冷水:QEMU主线对STM32F103的支持不算完善,主线更完整的是STM32F100(stm32vldiscovery板型)和STM32F405(stm32f405-soc)。要专门跑STM32F103,社区里最常用的是qemu_stm32这个fork,它基于老版本QEMU增加了一部分STM32F103VCT6的外设模型,包括GPIO、USART、SPI、I2C、TIM、DMA、EXTI、ADC等。因为是fork,版本比较老,但恰恰是外设种类最贴合F103的一个分支。
启动命令每个人手里的版本可能不太一样,常见的是这样:
qemu-system-arm -M stm32f103 -kernel firmware.bin -serial stdio如果机器名不对,先用-M help看看当前版本支持哪些板子。拿不到qemu_stm32的环境,也可以先用主线QEMU的stm32vldiscovery凑合学外设框架,但要注意F100和F103的Flash大小、RAM大小和部分外设型号不完全一致。我的建议是能找qemu_stm32就用qemu_stm32,毕竟少踩一个移植坑。
1.3 交叉编译工具链与工程骨架
模拟器层面搞定了,剩下的关键就是交叉编译。STM32F103是Cortex-M3核,需要用arm-none-eabi-gcc工具链。装好后要确认能正常跑:
arm-none-eabi-gcc --version一个最小工程通常包含四样东西:启动文件startup.s、链接脚本stm32f103_flash.ld、主程序main.c、Makefile。我习惯自己维护一个精简版物料库,避免每次翻Cube库。
链接脚本最重要的一点是Flash地址必须从0x08000000开始,RAM从0x20000000开始。F103VCT6有256K Flash和48K RAM,但qemu_stm32如果按VCT6建模,可以用这个配置;如果模拟的容量小一些,就按实际改。
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 256K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 48K } _estack = ORIGIN(RAM) + LENGTH(RAM); SECTIONS { .isr_vector : { KEEP(*(.isr_vector)) } > FLASH .text : { *(.text*) *(.rodata*) } > FLASH .data : { _sdata = .; *(.data*) ; _edata = .; } > RAM AT> FLASH .bss : { _sbss = .; *(.bss*) ; _ebss = .; } > RAM }这里有个细节:_estack不能写在.data段后面,它是向量表第一个Word的初始SP值,必须在链接阶段确定。启动文件里第一行引用的就是它。
启动文件也不复杂,最精简版本就是向量表加上Reset_Handler,然后在Reset_Handler里拷贝数据段、清零BSS段,最后跳转到main:
.syntax unified .cpu cortex-m3 .thumb .section .isr_vector .global _start .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .section .text .thumb_func .global Reset_Handler Reset_Handler: ldr r0, =_sdata ldr r1, =_edata ldr r2, =_sidata copy_loop: cmp r0, r1 bge copy_done ldr r3, [r2], #4 str r3, [r0], #4 b copy_loop copy_done: ldr r0, =_sbss ldr r1, =_ebss bss_loop: cmp r0, r1 bge bss_done movs r3, #0 str r3, [r0], #4 b bss_loop bss_done: bl main b . .thumb_func .global NMI_Handler NMI_Handler: b . .thumb_func .global HardFault_Handler HardFault_Handler: b .这段代码在真板子上能跑,在QEMU里同样能跑。很多人一上来就写完整版启动文件,其实没必要。模拟阶段只要保证向量表和Reset处理流程正确就行。
Makefile我习惯写成无标准库的极简版本:
CROSS=arm-none-eabi- CC=$(CROSS)gcc LD=$(CROSS)gcc OBJCOPY=$(CROSS)objcopy CFLAGS=-mcpu=cortex-m3 -mthumb -O2 -Wall -nostdlib -ffreestanding all: firmware.bin firmware.bin: firmware.elf $(OBJCOPY) -O binary firmware.elf firmware.bin firmware.elf: main.c startup.s stm32f103_flash.ld $(LD) $(CFLAGS) -T stm32f103_flash.ld -o firmware.elf main.c startup.s clean: rm -f firmware.elf firmware.bin2. 从GPIO到UART:让外设“开口说话”
2.1 点灯不是目的,验证GPIO寄存器才是
第一个实战例子我一般选GPIO点灯。不搞复杂初始化,直接寄存器操作,这样能在最短时间内确认三件事:CPU能不能从Flash取指执行、GPIO时钟能不能打开、ODR能不能翻转。在QEMU里,LED不会真的亮,但你可以用QEMU monitor读GPIO寄存器,也可以在GPIO翻转的地方加UART打印,或者直接跑一个好认的延时逻辑。
下面这段代码是我早期验证用的,直接操作寄存器,不依赖标准库:
#define RCC_BASE 0x40021000 #define RCC_APB2ENR (*(volatile uint32_t *)(RCC_BASE + 0x18)) #define GPIOC_BASE 0x40011000 #define GPIOC_CRH (*(volatile uint32_t *)(GPIOC_BASE + 0x04)) #define GPIOC_ODR (*(volatile uint32_t *)(GPIOC_BASE + 0x0C)) static void delay(volatile int count) { while (count--) { __asm__ volatile("nop"); } } int main(void) { /* 使能 GPIOC 时钟:RCC_APB2ENR 的 bit4 是 IOPCEN */ RCC_APB2ENR |= (1 << 4); /* PC13 对应 CRH 的 bit[23:20],配置为推挽输出模式 */ GPIOC_CRH &= ~(0xF << 20); GPIOC_CRH |= (0x2 << 20); while (1) { GPIOC_ODR ^= (1 << 13); delay(1000000); } return 0; }为什么把PC13作为口子?因为很多F103开发板的板载LED就在PC13,这么写比较有代入感。QEMU不会校验你是不是真连了LED,它只负责把ODR寄存器的bit13翻来翻去。调试时你用GDB读0x4001100C这个地址,能看到数值在变化,就说明GPIO外设模型已经正常工作了。
2.2 用UART输出日志:模拟器的“显示屏”
跑点灯还不够,调试驱动力还是得看日志。在QEMU里,UART最直观的出口是-serial stdio,也就是把串口重定向到终端。STM32F103的USART1基地址是0x40013800,配置过程分三步:打开USART1和GPIOA的时钟,配置PA9为TX复用推挽输出,最后设置波特率和使能。
先看初始化代码:
#define RCC_APB2ENR (*(volatile uint32_t *)(RCC_BASE + 0x18)) #define GPIOA_CRH (*(volatile uint32_t *)(GPIOA_BASE + 0x04)) #define USART1_BASE 0x40013800 #define USART1_SR (*(volatile uint32_t *)(USART1_BASE + 0x00)) #define USART1_DR (*(volatile uint32_t *)(USART1_BASE + 0x04)) #define USART1_BRR (*(volatile uint32_t *)(USART1_BASE + 0x08)) #define USART1_CR1 (*(volatile uint32_t *)(USART1_BASE + 0x0C)) void uart_init(void) { /* 使能 USART1 和 GPIOA 时钟 */ RCC_APB2ENR |= (1 << 14); RCC_APB2ENR |= (1 << 2); /* PA9:复用推挽输出,50MHz */ GPIOA_CRH &= ~(0xF << 4); GPIOA_CRH |= (0xB << 4); /* * 波特率计算:72MHz 时钟,115200bps * USARTDIV = 72MHz / (16 * 115200) = 39.0625 * BRR 整数部分 = 39,小数部分 = 0.0625 * 16 = 1 * BRR = (39 << 4) | 1 = 0x271 */ USART1_BRR = 0x271; /* UE=1,TE=1,RE=1 */ USART1_CR1 |= (1 << 13) | (1 << 3) | (1 << 2); } void uart_putc(char c) { /* 等待 TXE 位置位 */ while (!(USART1_SR & (1 << 7))); USART1_DR = c; } void uart_puts(const char *s) { while (*s) { uart_putc(*s++); } }这里有个容易踩的坑:USART1挂的时钟是APB2,频率是72MHz,不是APB1的36MHz。如果按36MHz计算BRR,输出会是乱码。QEMU的UART模型会严格按照BRR配置产生波特率,虽然它不产生真实波形,但你用串口重定向看ASCII字符时,如果配置错,字符照样会变成乱码或偏移。
在main里调用:
int main(void) { uart_init(); uart_puts("Hello from QEMU STM32F103\r\n"); while (1); }启动QEMU时加-serial stdio:
qemu-system-arm -M stm32f103 -kernel firmware.bin -serial stdio终端里能看到Hello from QEMU STM32F103就说明UART外设通了。这一步是整个系列的地基,后续所有外设调试日志都靠它输出。
2.3 几个链接层面的小坑
用-nostdlib编出来的固件,很多人会把printf直接拿过来用,结果发现编译报错或者链接不过。原因很简单:没有C标准库。在模拟阶段,我更建议自己实现简单的uart_puts,或者用半主机(semihosting)方式。半主机可以让你在QEMU里用printf重定向到GDB或主机终端,但要额外写_write和_sbrk函数,有点绕。真正量产驱动时,大家也不会靠printf输出日志,都是走串口协议或者RTT,所以一开始就别依赖printf。
另外,链接脚本里RAM AT> FLASH这种写法要理解透:.data段在运行时要放到RAM里,但初始值存储在Flash里。Reset_Handler里的拷贝循环就是干这个的。如果忘了做这一步,全局变量初值会是0,甚至随机值。在QEMU里表现就是用一个初始化为0x12345678的全局变量,读出来是0,后来排查半天发现是数据段没拷贝。
3. 定时器、PWM与中断:相信状态,别相信真实时间
3.1 TIM定时器:QEMU里的“时间”是虚拟时间
STM32F103的TIM2挂在APB1上,很多需求是“每1ms进一次中断”。在真板子上,这取决于晶振和时钟树。在QEMU里,定时器会按照SoC模型的主频跑,但QEMU是动态二进制翻译,执行速度受宿主机负载影响很大,所以你看到的“1ms”并不是墙上的真实时间。换句话说,可以验证逻辑上的定时关系,但别拿QEMU的定时器去测真实的wall-clock耗时。
一个典型的问题:在QEMU里配置好TIM2定时中断,期望每1秒翻转一次LED,结果发现屏幕上的打印快得飞起,或者卡得莫名其妙。这太正常了,因为模拟器时间不等于现实时间。正确的做法是,把“1秒”理解成虚拟时间里的周期,然后看中断标志、计数器值是否符合预期。
代码层面和真板子一样:
#include "stm32f10x.h" volatile uint32_t tick_count = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); tick_count++; } } void tim2_init(void) { TIM_TimeBaseInitTypeDef tb; NVIC_InitTypeDef nvic; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); tb.TIM_Prescaler = 7200 - 1; tb.TIM_CounterMode = TIM_CounterMode_Up; tb.TIM_Period = 10000 - 1; tb.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, &tb); nvic.NVIC_IRQChannel = TIM2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 0; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); }这里TIM_Prescaler = 7200-1和TIM_Period = 10000-1配合,理论上72MHz经过7200分频后是10kHz,计数10000次就是1秒。但QEMU里的“秒”只是虚拟时钟刻度的换算结果,不是真实秒。调试时我更建议在中断里翻转GPIO或计数,然后通过UART把tick_count打出来,观察是否按预期递增。
3.2 PWM输出怎么“看见”
PWM在QEMU里看起来有点抽象,没有示波器,你没法直观看到占空比波形。很多人以为PWM没跑起来,其实只是不知道怎么验证。我的经验是分成两步看。
第一步,看计数器CNT和比较寄存器CCR的值。PWM的本质就是“CNT和CCR比较后翻转电平”,所以只要TIM->CNT在不断变化,TIM->CCR是你设定的值,就说明时序逻辑在工作。可以用GDB或者UART把这两个值读出来。
第二步,在PWM回调或更新中断里统计翻转行为。比如你用TIM1产生PWM,配置PWM2模式,高电平比较值设为500,周期设为1000,那一个周期内高电平比例就是50%。QEMU的定时器模型会更新OCx输出,但不会给你拉一根虚拟示波器线。你可以在更新中断里判断当前CNT和CCR的比较状态,然后打日志,这样能侧面验证PWM频率和占空比。
我自己常用的一个土办法是:把某个GPIO在PWM更新中断里取反,再用另一个定时器统计这个GPIO每秒钟翻转了多少次。虽然绕,但能在纯模拟环境里把PWM频率验出来。这种方法放到真板子上同样有效,只要别把调试代码带到发布版本里就行。
3.3 中断向量表与NVIC的隐藏坑
在QEMU里调试中断外设,最常遇到的问题不是“没进中断”,而是“进错了中断”或者“进中断后死循环”。F103的中断控制器NVIC已经模拟得比较完善,向量表偏移的处理和真芯片几乎一样。
如果用自定义链接脚本,向量表默认就在0x08000000,这个没问题。但如果你用HAL库的BootLoader模式,要把向量表偏移到别的地址,务必设置SCB->VTOR。F103的SCB地址是0xE000ED00,VTOR偏移是0x08。在QEMU里不设置VTOR,中断来了还是从0x08000000取向量,跑飞是必然的。
另一个常见坑是启动文件中向量表顺序。Cortex-M3的向量表第0项是初始SP,第1项是Reset_Handler,第2项是NMI,第3项是HardFault。如果是标准库,这些符号名称只要和链接结果对上就行。很多人自己写启动文件时把第0项写成了某个函数地址,一上电就HardFault。QEMU会把这个错误暴露得很彻底:第一条指令就进HardFault,而且GDB里看PC指针跑到了不可预期的位置。
4. SPI、I2C与DMA:模拟“外部芯片”的边界在哪里
4.1 SPI+DMA读取外部芯片的模拟思路
STM32F103的SPI1在APB2上,最高18MHz。日常开发里常配合DMA去读外部Flash或传感器。标题里的热门搜索词“stm32f103 spi通过dma方式读取芯片数据 cubemx”正好命中这个场景。
但这里必须说实话:QEMU的F103 SPI模型虽然存在,但能挂载的外部SPI从设备模型非常有限。如果你用qemu_stm32,它可能只实现了SPI控制器自身,不会自动帮你把一个虚拟Flash挂在SPI总线上。所以“SPI+DMA读外部Flash”在纯模拟环境里的可行做法是:验证DMA的搬运过程,而不是验证外部Flash里的数据内容。
具体怎么验证?先初始化SPI1的发送和接收DMA通道。F103的DMA1请求映射中,SPI1_RX对应Channel2,SPI1_TX对应Channel3。使用CubeMX配置时,它会在HAL_SPI_TransmitReceive_DMA函数里自动关联这些通道。但如果你用标准库,就要自己填DMA_InitTypeDef。
一个可用的DMA接收配置思路如下:
DMA_InitTypeDef dma; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); dma.DMA_PeripheralBaseAddr = (uint32_t)&SPI1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)rx_buffer; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = 64; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma.DMA_Mode = DMA_Mode_Normal; dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel2, &dma);在QEMU里跑这套代码,重点观察两点:一是DMA的DMA_ISR寄存器里传输完成标志有没有置位,二是rx_buffer有没有被写入数据。如果QEMU模型没有数据源,rx_buffer可能一直保持初始值,这时候不要慌,不是DMA配置错了,而是总线上没有从机响应。
想要看到真实数据,可以走两条路。第一条,写一个小型QEMU设备模型,给SPI控制器挂上一个虚拟从设备,每收到一个字节就回一个固定字节。第二条,利用SPI的环回模式,把MOSI和MISO短接,让发送的字节直接回到接收路径。第二种在没硬件的情况下很简单,但要看QEMU模型是否支持这种环回。我自己的习惯是先用GDB写SPI1->DR产生一个虚假数据源,再把DMA跑通,至少能验证DMA的数据搬运逻辑没毛病。
4.2 用GPIO模拟I2C,绕开坑人的硬件I2C
STM32F103的硬件I2C模块在真板子上都出了名的难调,更别说QEMU里模型是否完整。所以我做模拟验证时,更倾向用GPIO模拟I2C,也就是bit-bang。这样有几个好处:第一,GPIO模型在QEMU里非常稳定;第二,不依赖I2C外设模型的实现质量;第三,代码移植到任何MCU都通用。
GPIO模拟I2C的核心就是控制SCL和SDA两根线的电平。标准I2C时序里,起始条件是SCL高电平时SDA拉低,停止条件是SCL高电平时SDA拉高。写入一个字节时,从最高位开始,先把SDA设置为数据位,然后拉高SCL、延时、拉低SCL。这是一个很机械的过程,但恰恰是验证模拟器GPIO翻转频率的好机会。
#define I2C_PORT GPIOB #define I2C_SCL GPIO_Pin_6 #define I2C_SDA GPIO_Pin_7 static void i2c_delay(void) { volatile int i; for (i = 0; i < 200; i++); } static void i2c_scl_high(void) { GPIO_SetBits(I2C_PORT, I2C_SCL); i2c_delay(); } static void i2c_scl_low(void) { GPIO_ResetBits(I2C_PORT, I2C_SCL); i2c_delay(); } static void i2c_sda_high(void) { GPIO_SetBits(I2C_PORT, I2C_SDA); i2c_delay(); } static void i2c_sda_low(void) { GPIO_ResetBits(I2C_PORT, I2C_SDA); i2c_delay(); } void i2c_start(void) { i2c_sda_high(); i2c_scl_high(); i2c_sda_low(); i2c_scl_low(); } void i2c_stop(void) { i2c_scl_low(); i2c_sda_low(); i2c_scl_high(); i2c_sda_high(); } unsigned char i2c_write_byte(unsigned char byte) { unsigned char i; for (i = 0; i < 8; i++) { if (byte & 0x80) { i2c_sda_high(); } else { i2c_sda_low(); } byte <<= 1; i2c_scl_high(); i2c_scl_low(); } /* 第9个时钟,释放SDA并读取ACK */ i2c_sda_high(); i2c_scl_high(); unsigned char ack = GPIO_ReadInputDataBit(I2C_PORT, I2C_SDA); i2c_scl_low(); return ack; }这段代码在QEMU里验证时有一点特别有意思:由于没有真实外部器件拉低SDA,ACK通常是高电平。如果只简单地等待低电平ACK,程序会卡在ACK检查里。所以模拟阶段我建议先不看ACK,或者规定“读到高电平也算成功”,重点验证字节发送的波形顺序。当然这只是策略,真实硬件上绝不能这么干。
4.3 UART DMA中断收发通信的模拟重点
标题热词里还有“stm32f103 标准库uart dma中断接收发送通信”,这个场景在QEMU里属于最容易验证的DMA应用。因为UART模型和DMA模型的联动在qemu_stm32里已经相对成熟,可以直接跑完整流程。
USART1的接收DMA请求是DMA1 Channel5,发送是DMA1 Channel4。配置时先把USART1的DR地址给DMA外设地址,内存地址给缓冲区,方向按收发区分。接收DMA通常设置成循环模式,并开启传输过半、传输完成中断。发送DMA则是一次性模式,发送完成后在USART的TC中断里关闭DMA通道。
模拟器里验证时,我喜欢用-serial telnet:127.0.0.1:1234,server,nowait把串口暴露成TCP端口,再用Python脚本往端口发一串数据。STM32F103的UART DMA模型会把收到的数据搬进内存缓冲区,并触发DMA中断。这样一来,自动化和CI化就能实现了。
注意一点:启动QEMU后用-serial stdio时,终端输入会直接变成UART接收数据,可以用它手工触发测试。但这种方式不适合自动化测试。用telnet模式,再配一个测试脚本,能让QEMU里的固件跑“回环测试”。我的习惯是固件收到一帧数据后,用DMA原样发回去,脚本比对收发是否一致。这个测试在真板子上也能用,只是QEMU里跑起来不占硬件资源。
5. 常见问题与调试技巧实录
5.1 外设寄存器写了没反应
这是模拟器里最常遇到的问题。配置了GPIOB时钟,也改了CRL,但读寄存器还是全零。绝大多数情况是时钟没使能。F103外设众多,但每个外设挂在不同总线,APB1、APB2、AHB各有各的时钟开关。比如USART1挂在APB2,使能位是RCC_APB2ENR的bit14;USART2挂在APB1,使能位是RCC_APB1ENR的bit17。把RCC_APB2ENR当万能开关,后面外设自然不响应。
还有一种情况是QEMU模型本身没实现该外设,寄存器读回总是0,写进去也没有行为。这时需要查QEMU源码,看看这个外设到底有没有被建模。以qemu_stm32为例,它实现了不少外设,但像SDIO、USB OTG这类复杂外设通常是没有的。日常用的GPIO/UART/SPI/I2C/TIM/DMA都有,一般够用。
5.2 程序跑飞和HardFault怎么查
QEMU里查HardFault比真板子舒服得多。启动QEMU时加-s -S,然后另开终端用arm-none-eabi-gdb连接:
arm-none-eabi-gdb firmware.elf target remote :1234 continue程序跑飞后,GDB里能看到PC指针,也能读到0xE000ED38的CFSR寄存器,里面记录着HardFault的来源。常见的有总线错误、未对齐访问、无效指令等。F103是Cortex-M3,对未对齐访问容忍度很低,驱动代码里如果出现非对齐的结构体指针访问,真板子可能偶尔正常,QEMU里很容易直接HardFault。
我遇到过最多的情况是DMA内存地址没按对齐要求写,例如把DMA_MemoryBaseAddr设成了一个奇地址。DMA的数据宽度配置成字传输时,地址必须4字节对齐。QEMU对地址的检查比真芯片严格,出错就会挂。把数据宽度改成字节传输,或者保证缓冲区4字节对齐,问题就能解掉。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| UART输出乱码 | 波特率BRR计算错误或时钟源不对 | 确认APB1/APB2频率,重新计算BRR |
| GPIO电平不变 | 对应GPIO时钟没开,或CRL/CRH配置错位 | 读RCC_APB2ENR和GPIOx_CRL/CRH |
| 定时器不中断 | NVIC没使能,或中断标志没清除 | 检查NVIC_IRQHandler是否注册,IRQHandler里清标志 |
| DMA搬运结果全0 | 外设地址写错,或外围设备没有数据源 | GDB读DMA_CCR和DMA_NDTR,确认通道配置 |
| HardFault | 非对齐访问或地址越界 | 通过GDB查看CFSR,检查指针和缓冲区地址 |
| 程序卡死 | 中断向量表偏移未设,或死循环等待中断 | 检查VTOR,检查中断使能位 |
5.4 两个提升模拟效率的操作习惯
第一个习惯是给固件加“自检模式”。启动后自动跑一遍外设自检:GPIO翻转、UART发送、定时器计数、DMA搬运,然后把结果通过UART打印出来。在QEMU里,这个自检模式可以当CI测试用例用。每次改动QEMU机器配置或外设驱动代码后,跑一次自检,看到特定输出就算通过。
第二个习惯是用QEMU monitor读出指定地址。QEMU启动后按Ctrl+A C进入monitor,用xp /4wx 0x4001100C可以看GPIO ODR寄存器的当前值,用info registers看CPU寄存器状态。这个比GDB轻量很多,适合快速检查外设寄存器状态。如果发现QEMU monitor功能不全,再用GDB也不迟。
6. 最后分享一点个人体会
从第一块真板子到现在用QEMU做外设模拟开发,我最大的感受是:模拟器不会替你解决硬件设计问题,但它能把软件问题隔离出来,让你在前置阶段就把寄存器配置、外设中断、DMA链路这些逻辑调得明明白白。以前调SPI+传感器驱动,要在示波器上反复看波形,猜是时序问题还是数据格式问题。现在先在QEMU里把DMA搬运、中断标志这些软件逻辑验完,再上板子时只需要关注真实传感器和电气特性,调试范围小了很多。
再分享一个小技巧:跑QEMU模拟器时,把出问题的固件加-d in_asm参数,QEMU会把执行的指令流打出来。刚开始会觉得刷屏太快,但遇到HardFault时,看最后几十条指令往往能直接找到是哪条访问了非法地址。这个技巧我用了很久,比单纯用GDB打断点更高效。
如果你的工作流里也有“等待板子”的阶段,不妨把这套外设模拟方案加进去。哪怕只是跑通UART日志和GPIO控制,对团队协作和自动化测试都会有很大帮助。