☰
不买开发板学STM32:纯软件仿真跑通核心外设
2026/10/12 5:14:23 网站建设 项目流程

不花钱买板子,能不能把STM32学扎实?我这一年反复带新人,结论是能,但前提是你得换一种“跑外设”的姿势。这篇文章就记录我做的这套纯软件版STM32入门方案:不依赖真实开发板,不依赖真实下载器,甚至连硬件都不用碰,用一套跑在PC上的轻量外设模拟框架,把GPIO、定时器、串口、外部中断这些入门外设,一个个在命令行里跑通给你看。

适合谁看?如果你手头暂时没有开发板,又不想光靠看视频脑补寄存器操作;或者你有板子,但每次调试都要插线、下载、烧录,折腾到怀疑人生,那这篇内容正好派上用场。我会从方案选型、框架设计、外设逐个拆解、踩坑实录一路讲完,尽量把“为什么可以这样跑”“哪些外设能仿真、哪些不能”都说明白。

1. 不买板子学STM32,为什么我坚持走“纯软件”这条路

1.1 纯软件仿真到底能跑什么外设

很多新人有个误区:以为STM32仿真就是拿一个IDE看代码、编译通过就完事了。编译通过≠程序能跑,更不等于外设配置正确。真正的“跑通”要看行为,比如点灯要点亮、串口要打印出正确数据、定时器要按预期周期触发回调。

纯软件仿真可以覆盖的典型外设,我列一下:

  • GPIO:输出电平翻转、输入读取、上拉下拉模式设置,都可以通过寄存器模拟实现。
  • 定时器:定时溢出、PWM占空比变化,可以用软件时间片模拟,效果接近真实频率。
  • 串口:发送数据直接在终端显示,接收可以用键盘输入来模拟。
  • 外部中断:用键盘按键或定时事件触发对应的“中断标志”,进而跑进回调函数。
  • I2C/SPI这类低速总线,如果只是模拟主机行为、验证通信时序,也能做出简易版本。

但也要认清边界:ADC采集真实模拟电压、Flash掉电保存、DMA搬运真实内存、看门狗复位等现象,纯软件环境很难100%还原。仿真框架能帮你建立“寄存器操作→外设行为”的映射关系,这个核心目标达到了,剩下的细节等有真板子的时候再去验证。

1.2 仿真 vs 真板子:先认清边界再下手

我见过有人把仿真说得无所不能,也有老工程师说“仿真都是花架子,真机会翻车”。这两种说法都对,也都不全对。

我做一个简单的对比表:

维度真开发板纯软件仿真框架
成本板子+下载器+线材,至少几百块零硬件成本,电脑即可
上手速度要先解决驱动、下载器、环境问题开箱即用,聚焦C语言和寄存器逻辑
外设行为真实性高,真实电气特性中,逻辑行为可模拟,电气特性无法体现
调试效率要反复烧录、接线改代码重启即可,周期短
对新手友好度出现硬件故障容易劝退所有问题都可复现、可排查

新人最大的问题不是不会写代码,而是不知道代码写下去会发生什么。仿真框架把“发生什么”用最直观的日志和状态显示出来,所以我坚持在入门阶段用纯软件版本。它解决的是“程序与外设之间如何互相作用”的认知问题,这个地基打好了,以后上真板子就是在验证而已。

2. 我用的方案:一套能跑在PC上的轻量外设模拟框架

2.1 方案选型踩过哪些坑

一开始我也考虑过直接用现成工具。市面上确实有电路级仿真平台,能拖一个STM32芯片进原理图,再加载编译出来的hex文件跑仿真。听着很完美,实际用起来有几个痛点:

第一是环境太重。一个电路仿真工程要建原理图、选元器件、配电源,还没开始写代码,光搭环境就花了两小时。第二是外设模型并不全,很多外设的寄存器行为实现得比较粗糙。第三是调试体验割裂,代码改了要重新编译、重新加载,日志输出也不够直观。

我还试过一次某开源CPU模拟器,能把Cortex-M内核跑起来,但外设支持极度有限,想要模拟一个串口还得自己写插件,门槛比学STM32还高。

所以我最后决定:自己写一套面向教学的轻量外设模拟框架。核心思想很简单——把STM32外设的寄存器,用内存数组和C函数“假扮”出来;把中断,用事件队列和主循环轮询“演”出来。这套东西不需要交叉编译,不需要特殊软件,只要电脑能跑标准C语言就行。我给这个模拟项目起名“sim-lab”,下文所有代码都来自这个项目。

2.2 工程骨架搭建步骤

搭建过程很简单,我一步步讲。

第一步,准备工具链。你只需要一套C语言编译器、一个make工具、任意你顺手的文本编辑器。在Windows、Linux、macOS上都能跑,命令行的使用方式是一致的。我日常用的是跨平台编辑器加一个终端窗口,左边写代码、右边看运行日志,体验很顺畅。

第二步,建目录结构。我会把代码按驱动、内核模拟、应用三层分开:

sim-lab/ ├── core/ │ ├── sim_kernel.c # 模拟内核:寄存器空间、中断队列、系统心跳 │ └── sim_kernel.h ├── drivers/ │ ├── drv_gpio.c # GPIO外设模拟 │ ├── drv_timer.c # 定时器外设模拟 │ ├── drv_uart.c # 串口外设模拟 │ └── drv_exti.c # 外部中断模拟 ├── app/ │ └── main.c # 应用代码,学着它的写法以后能搬到真机 └── Makefile

第三步,写Makefile,把它编译成一个普通PC可执行程序。关键是不要用任何STM32的交叉编译工具链,因为我们不是在模拟指令集,而是把外设逻辑直接映射成C函数执行。

CC = cc CFLAGS = -Wall -g -Icore -Idrivers -Iapp TARGET = sim-lab SRCS = core/sim_kernel.c drivers/drv_gpio.c drivers/drv_timer.c \ drivers/drv_uart.c drivers/drv_exti.c app/main.c $(TARGET): $(SRCS) $(CC) $(CFLAGS) -o $(TARGET) $(SRCS) clean: rm -f $(TARGET)

第四步,写一个最小的main.c,先输出一行“sim-lab start”,然后编译运行。这一步确保环境没问题,再开始逐个外设模拟。为什么先跑通最小工程很重要?因为很多新人后面遇到问题,分不清是业务代码问题还是环境问题,先有一个干净的起点,后面所有现象都有参照。

2.3 寄存器区模拟的底层原理

真实STM32的外设,本质是一块块映射在总线地址空间里的寄存器。我们用PC程序模拟它,不需要真的做一个地址映射到物理内存,只需要把寄存器当成一个静态变量数组里的元素。

我在sim_kernel里维护了这样一块“寄存器空间”:

#define SIM_REG_SPACE_SIZE 0x4000 static uint8_t sim_reg_space[SIM_REG_SPACE_SIZE];

每个外设驱动从这片空间里分配自己的寄存器区。比如GPIOB,我用一个结构体视图来读写它:

typedef struct { volatile uint32_t MODER; volatile uint32_t OTYPER; volatile uint32_t OSPEEDR; volatile uint32_t PUPDR; volatile uint32_t IDR; volatile uint32_t ODR; } gpio_reg_t; #define GPIOB_BASE 0x40020400UL #define GPIOB ((gpio_reg_t *)(sim_reg_space + (GPIOB_BASE & 0x3FFF)))

这样应用代码写GPIOB->ODR |= (1 << 5)的时候,操作的是这片数组里的某个偏移位置,和真机上写一个地址本质上是一回事。用volatile修饰是为了防止编译器优化掉某些看似无用的写入操作,这一点和真机上的寄存器操作完全一致。

理解了这一层,你就掌握了寄存器操作的灵魂:读改写、置位、清位,这些操作的位运算规则,在仿真和真机上没有任何区别。

3. 外设逐个跑通:从GPIO到定时器、串口、外部中断

3.1 GPIO:先让“灯”亮起来

点灯是STM32入门的第一个实验,仿真框架里没有真实的LED,那怎么看到“灯亮”?

我的做法是:给GPIO输出加一个状态日志函数,每当ODR寄存器变化,就打印当前所有输出引脚的电平状态。跑起来之后,你看到的不是灯的物理亮灭,而是一行电平状态的实时输出。

先看GPIO驱动怎么模拟。在drv_gpio.c里实现初始化、写引脚、读引脚这几个基础函数:

void sim_gpio_init(void) { GPIOB->MODER = 0; GPIOB->ODR = 0; GPIOB->IDR = 0; } void sim_gpio_write_pin(gpio_reg_t *gpio, uint8_t pin, uint8_t level) { if (level) gpio->ODR |= (1U << pin); else gpio->ODR &= ~(1U << pin); /* 每次输出变化,打印当前输出电平分布 */ sim_gpio_dump_state(gpio); } uint8_t sim_gpio_read_pin(const gpio_reg_t *gpio, uint8_t pin) { return (gpio->IDR >> pin) & 1U; }

应用代码写起来和真机风格一致:

#include "drv_gpio.h" int main(void) { sim_gpio_init(); int cnt = 0; while (cnt < 10) { sim_gpio_write_pin(GPIOB, 5, 1); /* PB5亮 */ sim_delay_ms(500); sim_gpio_write_pin(GPIOB, 5, 0); /* PB5灭 */ sim_delay_ms(500); cnt++; } return 0; }

跑起来后,你会看到终端里一行行输出:

[GPIO] PB5 = 1 [GPIO] PB5 = 0

这一步虽然简单,但能帮你理解两个关键点:ODR是输出数据寄存器,往它写1就是拉高电平;电平状态的变化是可以被“观察”到的。你在真机上点灯,无非就是让这个状态变化最终反映到一个物理LED上。

这里有个实操心得:仿真框架里,最好把所有外设状态变化都做成时间戳日志,而不是只输出最终结果。比如:

[1234ms] GPIO PB5 -> 1 [1734ms] GPIO PB5 -> 0

带时间戳的日志能帮你之后调试定时器、串口这类和时序强相关的外设,强烈建议一开始就养这个习惯。

3.2 定时器:用软件时间片模拟硬件心跳

定时器是STM32里技术含量最高的入门外设,因为它涉及预分频、自动重装载、更新事件这些概念。纯软件模拟定时器最大的难点不是逻辑,而是时间基准。PC上没有硬件定时器直接可用,那就得让主循环自己维持一个“心跳”。

我的实现方式是在sim_kernel里维护一个全局的毫秒计数,再提供一个软件定时器列表:

typedef struct { uint32_t period_ms; uint32_t elapsed; void (*callback)(void *arg); void *arg; uint8_t enable; } soft_timer_t; #define SIM_MAX_TIMERS 4 static soft_timer_t sim_timers[SIM_MAX_TIMERS]; static uint32_t sim_tick_ms;

每次主循环调用sim_tick(),就把毫秒计数加1,再去扫描所有已注册的软件定时器。当某个定时器的elapsed累加到超过period_ms时,触发回调,然后把elapsed清零重新开始。这其实是在模拟自动重装载定时器的行为。

void sim_tick(void) { sim_tick_ms++; for (int i = 0; i < SIM_MAX_TIMERS; i++) { if (!sim_timers[i].enable) continue; sim_timers[i].elapsed++; if (sim_timers[i].elapsed >= sim_timers[i].period_ms) { sim_timers[i].elapsed = 0; if (sim_timers[i].callback) sim_timers[i].callback(sim_timers[i].arg); } } }

那怎么让应用代码像调真机定时器那样使用它?封装一个API:

void sim_timer_start(uint8_t index, uint32_t period_ms, void (*callback)(void *arg), void *arg);

比如你想让LED每500ms翻转一次:

void led_toggle_cb(void *arg) { static uint8_t level = 0; level = !level; sim_gpio_write_pin(GPIOB, 5, level); } void timer_demo(void) { sim_timer_start(0, 500, led_toggle_cb, NULL); }

这个例子里,500ms这个周期对应到真机是什么概念?假设APB1定时器时钟是72MHz,预分频PSC=71,自动重装载ARR=499,那中断周期就是:

(71 + 1) * (499 + 1) / 72,000,000 = 500ms

公式要能看懂:预分频把72MHz降成1MHz,计数499+1次正好1ms,再乘500次才是500ms?不对,重算一下。72MHz,PSC=71则时钟=72MHz/(71+1)=1MHz,ARR=499则计数器周期=(499+1)/1MHz=500us。如果PSC=719,ARR=499,则时钟=100kHz,周期=5000us。总之,要理解PSC分频和ARR重装载两个参数共同决定中断频率。我在仿真里直接用物理时间,你理解公式结构即可,等上了真机这两个参数才不会调错。

仿真框架里,定时器中间的状态(CNT值、更新标志)也会通过日志打出来:

[TIM] timer0 period=500ms, callback=led_toggle_cb [00500ms] TIM timer0 expired [01000ms] TIM timer0 expired

定时器这关过了,你对“中断频率这个参数到底由哪些寄存器决定”会有非常直观的感觉。

3.3 串口:把数据打进终端,按回车当“按键”

串口是调试STM32的必经之路,没有串口你几乎没法知道程序内部发生了什么。而仿真框架里的“串口”,本质就是把发送的数据输出到终端,把接收的数据从标准输入读进来。

先看发送侧模拟。在drv_uart.c里实现一个发送寄存器位,当数据写入时就把字符打印出来:

void uart_send_byte(uint8_t ch) { putchar(ch); fflush(stdout); /* 强制立即输出,避免被缓冲区吞掉 */ } void uart_send_string(const char *s) { while (*s) { uart_send_byte((uint8_t)*s++); } }

这里有个坑,我一开始没加fflush(stdout),结果终端半天不显示输出,还以为是死循环了。实际上标准输出在非交互模式下是按块缓冲的,不加刷新会等缓冲区满了才输出。这个细节在真机上没有对应物,但会把人卡很久,所以完整体现出仿真学习中“环境问题也不能忽视”的特点。

再看接收侧。真机的串口接收是硬件收到电平变化后把数据放进DR寄存器,然后触发RXNE中断。仿真里我简化成:程序运行期间你去终端敲键盘,敲完按回车,把那一行字符当成串口收到的数据包。

int uart_read_line(char *buf, int max_len) { if (!fgets(buf, max_len, stdin)) return -1; int len = strlen(buf); while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) { buf[len - 1] = '\0'; len--; } return len; }

实际运行效果是,程序打印一行“请输入指令:”,你在终端输入“on”回车,LED状态由“灭”变成“亮”。这比纯看打印信息更有交互感,也更接近真机串口接收的真实流程。

串口模拟的价值不只是打印,你可以在发送函数里加入寄存器状态记录,比如模拟发送完成标志、接收非空标志,这样你后面去查HAL库源码时,看到UART_FLAG_TXE、UART_FLAG_RXNE这些标志位会有真切认知。

3.4 外部中断:按一个虚拟按键触发回调

外部中断是最能体现“事件驱动”概念的外设,真机上通常用一个按键接到某个引脚,按下后引脚电平跳变,触发EXTI中断,程序跳到中断服务函数。仿真里的“按键”,我用终端输入一个字符来模拟。

首先,在sim_kernel里实现一个简单的中断挂起表:

#define SIM_IRQ_MAX_COUNT 8 static volatile uint8_t irq_pending[SIM_IRQ_MAX_COUNT]; void sim_pend_irq(uint8_t irq_num) { if (irq_num < SIM_IRQ_MAX_COUNT) irq_pending[irq_num] = 1; } void sim_irq_dispatch(void) { for (int i = 0; i < SIM_IRQ_MAX_COUNT; i++) { if (irq_pending[i]) { irq_pending[i] = 0; sim_irq_handler_call(i); } } }

sim_irq_handler_call再根据中断号去调用注册的回调函数。应用代码里,我可以在串口接收函数里这样触发按键:

if (ch == 'k') { sim_pend_irq(IRQ_EXTI0); }

然后在main循环里每次迭代都调用sim_irq_dispatch()。这样当你按键盘上的k键时,流程就变成了:字符接收→置位EXTI0挂起标志→主循环发现挂起→调用按键中断回调→打印“按键中断触发”。

外部中断这块,你要理解的不只是“回调函数会被执行”,更重要的是“中断请求和中断处理是异步解耦的”。真实硬件里,外设拉高中断请求线,内核响应后跳转到向量表;仿真里,我们用一个挂起标志位来模拟请求,用主循环轮询来模拟响应。学完这个模型,你再回到真机上查NVIC、EXTI相关寄存器就会顺畅很多。

4. 代码怎么组织才能“仿真/真机”一键切换

4.1 平台抽象层设计

我设计这个仿真框架的时候,一开始就给自己定了一个规则:应用代码里不要出现任何sim_开头的函数,而是要写成一套统一的外设驱动接口,仿真实现只是这套接口的一个后端。

说得直白点,就是应用里只调用:

AppLed_On(); AppLed_Off(); AppTimer_Start(500, led_toggle_cb); Uart_SendString("Hello");

底层再分为stm32_drv和sim_drv两套实现。用编译开关选择跑哪一套。这样以后你买真板子,不用改业务逻辑,只需要把驱动实现从sim_drv换成stm32_drv就行。

这是一次很值的架构投资。很多新手学STM32时,应用代码和寄存器操作混在一起,最后想复用一个功能模块都难。平台抽象层这个概念在嵌入式开发里极其重要,用仿真入门的阶段就能养成这个习惯,后面做项目会少走很多弯路。

4.2 用编译宏切换运行模式

我在Makefile里加了一个开关:

MODE ?= sim ifeq ($(MODE), sim) DRV_SRCS = drivers/sim/drv_gpio.c drivers/sim/drv_timer.c drivers/sim/drv_uart.c CFLAGS += -DRUN_MODE_SIM else DRV_SRCS = drivers/stm32/drv_gpio.c drivers/stm32/drv_timer.c drivers/stm32/drv_uart.c CFLAGS += -DRUN_MODE_STM32 endif

编译时直接make就是仿真模式,make MODE=stm32就是真机模式。应用层代码里也可以用条件编译做一些差异化处理:

void app_init(void) { #if defined(RUN_MODE_SIM) Uart_SendString("[sim] system init\r\n"); #else Uart_SendString("[stm32] system init\r\n"); #endif }

这个机制非常实用,我用它在同一套代码里同时维护仿真版本和真机版本,两台设备的行为差异一眼可见。以后你如果从零写STM32项目,也完全可以沿用这套结构:一个platform.h头文件统一管理平台相关的宏定义和类型重映射。

4.3 这个结构给后续学习带来的好处

分层清晰之后,调试效率会高很多。比如一个功能在真机上输出异常,我先在仿真环境里把逻辑跑一遍,如果仿真正常,问题大概率出在硬件配置上;如果仿真也不正常,那说明算法或逻辑就有问题,不用去怀疑时钟树、接线、下载器这些外部因素。

这就是把“能复现”的价值放大到极致。仿真模式下任何问题都是确定性的:代码在那,堆栈在那,变量在那,打日志就能定位。真机上你要是遇到一次偶发复位,可能好几天都在跟硬件搏斗,排查成本完全不是一个量级。

同时对新人来说,这种结构还有一个心理层面的作用:大项目拆成小模块,每个模块单独验证,不会有“不知道从哪里下手”的焦虑。跑通一个模块,就有一个模块的正反馈,学习动力能保持很久。

5. 跑通过程中我踩过的坑,以及排查技巧

5.1 常见问题速查表

我把这段时间见过的高频问题整理成一个速查表:

现象可能原因解决思路
编译通过但运行无输出标准输出缓冲未刷新在打印后加fflush(stdout)
定时器回调一次后不再触发回调里修改了定时器参数导致算法错乱检查回调内是否误调了sim_timer_stop
按键中断没反应主循环没调用sim_irq_dispatch检查中断分发函数是否在循环体内
串口收到的字符串多了回车输入行尾换行符没去掉用循环去掉末尾\n和\r
仿真LED闪烁速度远快于预期忘记让主循环休眠或限速每次循环后调用sim_sleep_ms(1)
结构体寄存器对齐异常PC和STM32的字节对齐规则不一致给寄存器结构体加__attribute__((packed))

这个表我写成了速查表,基本覆盖了从零搭建纯软件STM32仿真会遇到的大部分问题。

5.2 仿真容易“成功”,真机容易“翻车”的地方

虽然我一直推荐用仿真入门,但有些“成功”是假的,必须提醒你。

第一,引脚复用。仿真的GPIO模块没有真正限制同一个引脚只能复用给一个外设,你同时把PB5当GPIO输出和I2C时钟都能“跑通”,但真机上引脚一旦配置成复用功能,GPIO输出寄存器就失效了。所以仿真代码里应尽量避免这种交叉配置,最好在驱动层做一点冲突检查。

第二,中断嵌套和优先级。我实现的仿真中断是顺序轮询,不支持嵌套和高优先级抢占。也就是说,中断回调执行期间再次触发同号中断,可能会丢掉一次事件。真机上如果开了嵌套,行为会完全不同。新人不要因为在仿真里看到中断回调执行就认为完全掌握了NVIC,真机的优先级分组、抢占、子优先级这些机制还是要看书补课的。

第三,时序精度。仿真里我用的是sim_sleep_ms加单调计数,最大精度在毫秒级,受操作系统调度影响很大。真机定时器的精度是微秒甚至纳秒级。涉及高速波形、精确延时,仿真只是一个逻辑验证工具,不能替代示波器实测。

5.3 一些实用的小技巧

如果你打算自己从这个思路出发做一套仿真,我最后分享几个让我舒服很多的习惯。

日志系统一定要带时间戳。我在sim_kernel里维护的sim_tick_ms,每个日志打印都会带上它。排查定时器问题时,时间戳就是最重要的证据。

单个外设用一个独立Demo验证,不要等所有外设写完一起跑。比如先把GPIO的日志输出调稳定,再去加定时器,每加一个模块都跑一遍回归测试。这样做的好处很明显,出了新问题一定只和刚加的那部分代码有关。

可以在sim_tick里加入一个虚拟示波器输出,把某个引脚的电平变化导出成CSV文件,再用任意图表工具打开。我用这个方式观察PWM波形、串口波特率时序,比纯日志直观得多。不过这是在框架成熟之后可以考虑的扩展,入门阶段日志就够用了。

还有一个小细节:用readline库处理输入会舒服很多,但依赖会增加;我选择用标准fgets加手动去换行符,这样代码在任何平台都能编译,新人也不会被额外依赖卡住。基础版本能跑通,优先保证简单可控。

如果你手头真的没板子,但又急着想入门STM32,这套纯软件方案值得一试。先按我上面说的工程骨架搭起来,用GPIO点灯、定时器闪烁、串口打印、外部中断按键四个实验把几个核心概念串一遍,你会对“寄存器→外设→中断→应用”这条链路有非常清晰的认知。等以后拿到真板子,你会有一种很舒服的感觉:写的不是陌生代码,而是在验证一段已经跑通过的老朋友。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询