关注公众号,回复“入门资料”获取单片机入门到高级开挂教程
开发板带你入门,我们带你飞
文 | 无际(微信:2777492857)
全文约7273字,阅读大约需要 15 分钟
很多新手(甚至一些老手)写裸机代码,起手式往往是打开CubeMX一顿点,生成代码,然后在main.c的while(1)循环里,洋洋洒洒,写下几百上千行代码。一开始可能觉得:“哇,效率真高,功能嗖嗖地就实现了!” 但随着项目越来越复杂,代码量蹭蹭上涨,你会发现:
?main.c 程序越来越乱,各种逻辑缠绕不清,想加个新功能,得翻半天,还生怕改出新bug。
?代码复用?不存在的。写个串口驱动,东一榔头西一棒子,换个项目还得重写。
?一个微小的改动可能引发“蝴蝶效应”,bug满天飞,定位困难。
?如果你的队友看到你那坨“自由奔放”的代码,估计想顺着网线过来给你“物理优化”一下。
那搞STM32裸机开发,到底有没有所谓的“架构”或者“靠谱思路”?
别急,当然有!虽然裸机开发没有像操作系统(RTOS)那样提供现成的任务调度、同步互斥等机制,但这不代表我们只能放任代码“野蛮生长”。
裸机编程,同样需要清晰的架构和思路,才能写出健壮、可维护、可扩展的代码。这不是什么高深莫测的理论,而是在实践中摸索出来的、能让你少走弯路、少掉头发的宝贵经验。
今天,我们就来扒一扒几种常见的、适用于STM32裸机开发的架构思路,并附上一些(希望能让你看懂的)代码示例。
一、从main.c的“大杂烩”到模块化思维
这是最基础,也是最重要的一步。如果你还在main.c里堆砌所有代码,请立刻停下来!
痛点剖析:
main.c里的while(1)像个大筐,什么都往里扔:初始化、LED闪烁、按键扫描、串口收发、传感器读取、数据处理…… 很快,这个文件就会臃肿不堪,逻辑混乱。
这就像你把厨房、卧室、客厅、卫生间的功能全塞进一个单间,那日子还能过吗?
解决思路:模块化!模块化!模块化! (重要的事情说三遍)
把你的项目想象成一个乐高积木。每个功能(比如LED控制、串口通信、ADC读取)都应该是一个独立的“积木块”,也就是一个模块。
?每个模块通常由一个.c文件和一个.h文件组成。
1sq2d52f5wk64030305106.png
?.h 文件(头文件):模块的“接口说明书”。它声明了该模块对外提供的函数(API)、数据类型、宏定义等。别人想用你的模块,只需要包含这个头文件,看看有哪些函数可以用就行了,不需要关心内部实现细节。
?.c 文件(源文件):模块的“内部实现”。它包含了.h文件中声明的函数的具体实现代码,以及模块内部使用的静态变量和静态函数(不对外暴露)。
举个栗子:LED驱动模块
假设我们要控制一个LED。
led_driver.h (接口声明)
#ifndef __LED_DRIVER_H #define __LED_DRIVER_H #include "stm32f1xx_hal.h" // 包含必要的HAL库头文件 (以STM32F1为例) // 定义LED对应的GPIO端口和引脚 (根据实际连接修改) #define LED_PORT GPIOA #define LED_PIN GPIO_PIN_5 // 初始化LED所使用的GPIO引脚 void Led_Init(void); // 打开LED void Led_On(void); // 关闭LED void Led_Off(void); // 翻转LED状态 void Led_Toggle(void); #endif // __LED_DRIVER_H
led_driver.c (具体实现)
#include "led_driver.h"// 初始化函数实现void Led_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; // 1. 使能GPIO端口时钟 (关键一步,忘了时钟,神仙难救) __HAL_RCC_GPIOA_CLK_ENABLE(); // 以GPIOA为例 // 2. 配置GPIO引脚为推挽输出模式 GPIO_InitStruct.Pin = LED_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出 GPIO_InitStruct.Pull = GPIO_NOPULL; // 不带上下拉 GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速即可 HAL_GPIO_Init(LED_PORT, &GPIO_InitStruct); // 3. 初始状态,可以设置为灭 (可选) Led_Off(); // 或者 HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET); // 高电平灭,根据硬件连接}// 打开LED函数实现void Led_On(void) { // 低电平点亮 (假设是这样连接的) HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_RESET); // 如果是高电平点亮,则是: HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET);}// 关闭LED函数实现void Led_Off(void) { // 高电平熄灭 HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET); // 如果是低电平熄灭,则是: HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_RESET);}// 翻转LED状态函数实现void Led_Toggle(void) { HAL_GPIO_TogglePin(LED_PORT, LED_PIN);}// 如果有一些内部辅助函数,不希望外部调用,可以声明为static// static void internal_helper_function(void) { ... }
如何在main.c中使用?
#include "main.h" // CubeMX生成的头文件#include "led_driver.h" // 包含我们自己写的LED驱动头文件#include "usart_driver.h" // 假设还有个串口驱动模块// ... 其他模块头文件int main(void) { // HAL库初始化等... HAL_Init(); Systemclock_Config(); // 配置系统时钟 // 初始化我们自己的模块 Led_Init(); Usart_Init(); // 假设有串口初始化 // ... 其他模块初始化 while (1) { // 在主循环中调用模块提供的函数,而不是直接操作寄存器或HAL函数 Led_Toggle(); HAL_Delay(500); // 延时500ms // 处理串口数据等其他任务 // Usart_Process(); }}
模块化的好处显而易见:
1.结构清晰: 代码按功能划分,main.c变得简洁,只负责初始化和任务调度(后面会讲)。
2.高内聚,低耦合: 模块内部实现紧密相关(高内聚),模块之间通过接口调用,依赖关系清晰(低耦合)。修改一个模块内部实现,通常不影响其他模块。
3.易于复用: 这个led_driver模块,只要硬件连接(端口、引脚)稍作修改,就能轻松移植到其他项目中。
4.易于测试: 可以单独测试某个模块的功能是否正常。
5.便于协作: 如果你的代码需要给别的公司,或者工程师用,就必须要这样写了。
模块化,是裸机编程的“基本素养”,没掌握这个,你的代码就是一盘散沙,风一吹就散了。
二、while(1):从“傻等”到“时间片”与“事件驱动”
光有模块化还不够。main.c里的while(1)怎么写,也大有讲究。最常见的写法是:
while(1) { TaskA(); // 执行任务A TaskB(); // 执行任务B TaskC(); // 执行任务C // ...}这被称为超级循环或 前后台系统(Foreground/Background System) 的简单形式(所有任务都在前台循环执行)。
问题在哪?
1.阻塞: 如果TaskA执行时间很长(比如一个复杂的计算或者等待某个事件),TaskB和TaskC就得“傻等”,系统的实时性会很差。
2.时间不确定: 每个任务的执行频率取决于其他任务的执行时间,难以精确控制某个任务(比如每10ms执行一次传感器读取)的周期。
改进思路:
1. 时间片轮询(非抢占式调度)
利用定时器中断(比如SysTick)来产生一个固定的时间基准(通常是1ms)。在主循环中,不再是简单地依次调用所有任务,而是根据时间来判断哪些任务“到点”该执行了。
// ----- SysTick中断服务函数 (通常在 stm32f1xx_it.c 或类似文件中) -----volatile uint32_t system_ticks = 0; // 全局变量,记录系统tickvoid SysTick_Handler(void) { HAL_IncTick(); // HAL库自带的tick增加,需要保留 system_ticks++; // 我们自己的tick计数器}// ----- main.c -----#include "main.h"#include "led_driver.h"#include "adc_reader.h" // 假设有ADC读取模块// ... 其他模块// 任务执行间隔 (单位: ms)#define TASK_LED_INTERVAL 500 // LED任务每500ms执行一次#define TASK_ADC_INTERVAL 100 // ADC读取任务每100ms执行一次// 上次执行时间记录uint32_t last_led_tick = 0;uint32_t last_adc_tick = 0;int main(void) { HAL_Init(); SystemClock_Config(); Led_Init(); Adc_Init(); // 假设有ADC初始化 // 获取初始tick值 last_led_tick = system_ticks; last_adc_tick = system_ticks; while (1) { // ---- 任务调度逻辑 ---- // 检查LED任务是否到期 if (system_ticks - last_led_tick >= TASK_LED_INTERVAL) { Led_Toggle(); // 执行LED任务 last_led_tick = system_ticks; // 更新上次执行时间 } // 检查ADC读取任务是否到期 if (system_ticks - last_adc_tick >= TASK_ADC_INTERVAL) { Adc_StartConversion(); // 启动ADC转换 (可能很快完成) // 或者 ProcessAdcData(Adc_GetValue()); // 读取并处理ADC值 last_adc_tick = system_ticks; // 更新上次执行时间 } // 可以添加其他任务的调度检查... // ---- 可以放一些不那么时间敏感,或者需要持续执行的操作 ---- // e.g., ProcessSerialInput(); // 处理串口输入缓冲区 // ---- !!! 注意:所有任务函数内部应避免长时间阻塞 !!! ---- // 任务函数应该是“快进快出”的,执行完就返回,不要在里面搞HAL_Delay()之类的长时间等待 }}
代码解读:
?我们用SysTick中断(或其他定时器)每1ms增加一次system_ticks计数。
?while(1)循环不断检查当前system_ticks与任务上次执行时间的差值。
?如果差值达到了任务预设的间隔 (TASK_LED_INTERVAL, TASK_ADC_INTERVAL),就执行该任务,并更新其上次执行时间。
?关键: 任务函数(如Led_Toggle, Adc_StartConversion)本身要设计成非阻塞的,或者执行时间很短。如果一个任务需要等待(比如等待ADC转换完成),应该采用状态机或事件标志的方式(见下文)。
这种“时间片”轮询,比傻等强多了,至少能让不同频率的任务“雨露均沾”。但它依然是非抢占的,如果某个任务执行太久,还是会影响其他任务。不过对于很多中小型裸机项目,这已经是个不错的进步了。
2. 事件驱动(标志位法)
对于那些由外部事件触发的任务(比如按键按下、串口收到数据、DMA传输完成),傻傻地在主循环里轮询查询状态效率很低。更好的方式是在中断服务函数(ISR)中快速处理硬件事件,并设置一个标志位(Flag),然后在主循环中检查这些标志位,再执行相应的处理函数。
// ----- 某个外设的中断服务函数 (e.g., stm32f1xx_it.c) -----#include "main.h" // 需要包含全局标志位定义的地方// 定义全局标志位 (通常放在一个公共头文件或main.h中)volatile uint8_t uart_rx_flag = 0; // 串口接收到数据的标志volatile uint8_t button_pressed_flag = 0; // 按键被按下的标志// 串口接收中断服务函数 (示例)void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) != RESET) { // 检查是否是接收中断 // 快速读取数据到缓冲区 (具体实现省略) // Read_Uart_Byte_To_Buffer(); // 设置标志位,通知主循环处理 uart_rx_flag = 1; // 清除中断标志位 (非常重要!) __HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_RXNE); // 可能HAL库在HAL_UART_Receive_IT里清了,看具体用法 } // 处理其他可能的串口中断标志...}// 外部中断服务函数 (假设按键连接到 EXTI Line 0)void EXTI0_IRQHandler(void) { if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) { // 检查是否是对应的外部中断线 // (可选) 做一些简单的消抖处理,或者只设置标志让主循环处理复杂逻辑 // 设置按键按下标志 button_pressed_flag = 1; // 清除中断挂起位 (极其重要!) __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); }}// ----- main.c -----#include "main.h"#include "led_driver.h"#include "uart_handler.h" // 假设有串口处理模块#include "button_handler.h" // 假设有按键处理模块// 引入全局标志位 (如果定义在别处)extern volatile uint8_t uart_rx_flag;extern volatile uint8_t button_pressed_flag;int main(void) { HAL_Init(); SystemClock_Config(); Led_Init(); Uart_Init(); // 串口初始化,可能包含开启接收中断 Button_Init(); // 按键初始化,配置为中断模式 while (1) { // ---- 事件处理逻辑 ---- // 检查串口接收标志 if (uart_rx_flag) { uart_rx_flag = 0; // 清除标志位 (主循环处理前清除) Process_Uart_Data(); // 调用串口数据处理函数 } // 检查按键按下标志 if (button_pressed_flag) { button_pressed_flag = 0; // 清除标志位 Handle_Button_Press(); // 调用按键处理函数 (可能包含消抖和响应) } // ---- 这里可以放一些周期性轮询的任务 (结合时间片) ---- // if (system_ticks - last_led_tick >= TASK_LED_INTERVAL) { ... } // ---- 系统空闲时可以执行的操作,或者进入低功耗模式 ---- // Enter_Low_Power_Mode(); }}
代码解读:
?中断服务函数(ISR)是“后台”,负责响应硬件事件,做最少的工作(比如读数据到缓冲区、设置标志位),然后快速退出。ISR里严禁执行耗时操作或调用会阻塞的函数!
?main函数的while(1)循环是“前台”,负责轮询检查这些标志位。
?一旦检测到某个标志位被置位,就清除它,并调用相应的处理函数。
?volatile关键字至关重要,它告诉编译器这个变量可能会在程序正常流程之外(比如中断里)被修改,阻止编译器进行可能导致错误的优化。
事件驱动 + 时间片轮询,是裸机编程应对复杂度的"好思维"。它让你的系统既能及时响应外部事件,又能按计划执行周期性任务。掌握了它,你的裸机代码才算开始“上道”了。
三、状态机:管理复杂逻辑的“定海神针”
当一个模块的行为不仅仅是简单的“开/关”,而是有多种状态,并且状态之间会根据不同的条件进行切换时(比如网络连接过程、设备运行模式切换、协议解析等),用一堆if-else嵌套或者复杂的标志位组合来管理,很快就会变成“逻辑噩梦”。
这时候,状态机(State Machine)就是你的救星!
核心思想:
?状态(State): 模块在某个时刻所处的特定情况(如:空闲、连接中、运行中、错误)。
?事件(Event): 导致状态可能发生改变的触发条件(如:收到命令、超时、数据就绪、错误发生)。
?转换(Transition): 从一个状态迁移到另一个状态的过程。
?动作(Action): 在状态转换时或进入/退出某个状态时执行的操作。
实现方式: 通常用一个枚举类型定义所有状态,一个变量存储当前状态,一个函数(通常在主循环中被周期性调用,或者由事件触发)根据当前状态和发生的事件来决定是否切换状态并执行相应动作。
举个栗子:一个简单的按键控制LED模式的状态机
假设我们想让一个按键控制LED:按一下,LED慢闪;再按一下,LED快闪;再按一下,LED熄灭;再按一下,回到慢闪……循环。
// ----- state_machine_led.h -----#ifndef __STATE_MACHINE_LED_H#define __STATE_MACHINE_LED_H// 定义LED状态typedef enum { LED_STATE_OFF, LED_STATE_BLINK_SLOW, LED_STATE_BLINK_FAST} LedState_t;// 初始化状态机void LedStateMachine_Init(void);// 状态机处理函数 (应周期性调用, e.g., 每10ms)void LedStateMachine_Run(void);// 外部事件输入函数 (当检测到按键按下时调用)void LedStateMachine_HandleEvent_ButtonPressed(void);#endif // __STATE_MACHINE_LED_H// ----- state_machine_led.c -----#include "state_machine_led.h"#include "led_driver.h" // 需要控制LED#include "main.h" // 可能需要访问全局tick// 当前状态变量 (用static限制作用域)static LedState_t current_led_state = LED_STATE_OFF;// 用于闪烁的计时器static uint32_t blink_timer = 0;// 状态机初始化void LedStateMachine_Init(void) { Led_Init(); // 初始化底层LED驱动 current_led_state = LED_STATE_OFF; Led_Off(); blink_timer = system_ticks; // 使用 main.c 中的 system_ticks}// 状态机核心处理函数void LedStateMachine_Run(void) { uint32_t current_tick = system_ticks; // 获取当前tick switch (current_led_state) { case LED_STATE_OFF: // 在OFF状态下无事可做,LED保持熄灭 (Init或切换时已设置) break; case LED_STATE_BLINK_SLOW: // 每500ms翻转一次LED if (current_tick - blink_timer >= 500) { Led_Toggle(); blink_timer = current_tick; // 更新计时器 } break; case LED_STATE_BLINK_FAST: // 每100ms翻转一次LED if (current_tick - blink_timer >= 100) { Led_Toggle(); blink_timer = current_tick; // 更新计时器 } break; default: // 不应该发生的状态,可以做个错误处理或复位 current_led_state = LED_STATE_OFF; Led_Off(); break; }}// 处理按键按下事件的函数void LedStateMachine_HandleEvent_ButtonPressed(void) { // 根据当前状态决定下一个状态 switch (current_led_state) { case LED_STATE_OFF: current_led_state = LED_STATE_BLINK_SLOW; Led_On(); // 开始闪烁前先点亮一次,确保初始状态正确 blink_timer = system_ticks; // 重置计时器 break; case LED_STATE_BLINK_SLOW: current_led_state = LED_STATE_BLINK_FAST; // 不需要操作LED,Run函数会处理闪烁频率 blink_timer = system_ticks; // 重置计时器可能让切换更平滑 break; case LED_STATE_BLINK_FAST: current_led_state = LED_STATE_OFF; Led_Off(); // 切换到OFF状态,直接灭灯 break; default: // 回到初始状态 current_led_state = LED_STATE_OFF; Led_Off(); break; }}// ----- main.c 中如何使用 -----#include "state_machine_led.h"// ... 其他 include ...// 假设 button_pressed_flag 由按键中断设置extern volatile uint8_t button_pressed_flag;extern volatile uint32_t system_ticks; // 假设 system_ticks 定义在 main.c 或别处// 定时器任务相关#define TASK_STATE_MACHINE_INTERVAL 10 // 状态机处理函数每10ms调用一次uint32_t last_sm_tick = 0;int main(void) { // ... 初始化 ... LedStateMachine_Init(); last_sm_tick = system_ticks; while (1) { // 处理按键事件 if (button_pressed_flag) { button_pressed_flag = 0; // 调用状态机的事件处理函数 LedStateMachine_HandleEvent_ButtonPressed(); } // 周期性运行状态机 if (system_ticks - last_sm_tick >= TASK_STATE_MACHINE_INTERVAL) { LedStateMachine_Run(); last_sm_tick = system_ticks; } // ... 其他任务 ... }} 代码解读:
?我们用LedState_t枚举定义了三种状态。
?LedStateMachine_Run()函数是核心,它根据current_led_state执行当前状态下的动作(比如控制LED闪烁)。这个函数需要被周期性调用。
?LedStateMachine_HandleEvent_ButtonPressed()函数负责处理外部事件(按键按下),并根据当前状态切换到下一个状态。
?main函数负责周期性调用LedStateMachine_Run(),并在检测到按键事件时调用LedStateMachine_HandleEvent_ButtonPressed()。
状态机就是帮你把一团乱糟糟的if-else理顺成清晰状态转换图的“神器”。
逻辑再复杂,只要状态定义清晰,转换条件明确,代码就能写得像“流程图”一样易懂。
四、分层架构(可选,但更专业)
对于更大型、更复杂的项目,可以借鉴软件工程中的分层思想,将裸机系统划分为几个逻辑层:
1.硬件抽象层(HAL/LL): ST官方提供的库,屏蔽了底层寄存器操作的细节。我们通常基于这一层进行开发。
2.驱动层(Driver Layer): 基于HAL/LL,封装特定外设的操作,提供更简洁易用的接口。我们前面写的led_driver.c就属于这一层。每个驱动模块应尽量独立,只关心自己的硬件。
3.服务层(Service Layer)/中间件层(Middleware Layer): (可选)组合底层驱动,提供更高级的功能服务。比如,一个“通信服务”可能整合了串口驱动、协议解析(可能用到状态机)、数据缓冲等。一个“存储服务”可能封装了Flash驱动和文件系统逻辑。
4.应用层(Application Layer): 实现产品的具体业务逻辑。它调用服务层或驱动层的接口来完成任务,不关心底层硬件细节。比如,一个温控器应用,应用层负责读取温度(调用温度传感器服务)、执行PID算法、控制加热/制冷设备(调用执行器驱动)、更新显示(调用显示驱动)。
这种分层的好处:
?关注点分离: 每层只需关注自己的职责。
?易于维护和替换: 如果底层硬件换了(比如从SPI Flash换成I2C EEPROM),理论上只需要修改驱动层和对应的服务层,应用层代码可以保持不变。
?更好的可测试性: 可以对每一层进行独立的单元测试或集成测试。
分层架构是大型项目的“定海神针”。虽然在简单的裸机项目里可能显得有点“杀鸡用牛刀”,但理解这种思想,有助于你写出更规范、更具扩展性的代码。别总觉得裸机就是“小打小闹”,用专业的思维武装自己,你的代码也能“高大上”起来。
五、多种架构的终极融合
如果你对以上几种比较熟悉了,就可以进入更高阶的阶段,融合以上所有的思维,架构无处不在。
拿我们无际单片机特训营的项目举例。
比如我们把时间片,再进行加一层封装,模拟RTOS的方式去创建任务,创建的同时,可以为每个任务分配执行频率。
eityesiih4m64030305206.png
OS_CreatTask的第一个参数是任务ID,第二个参数是任务回调函数,第三个参数是调度频率,第四个参数是任务状态。
bbe1atc5lsm64030305306.png
我们通过设置调度频率,又可以为每个任务提供一个时间基准Tick,以前做产品,我们都用这个架构,比RTOS爽多了。
然后项目的其它功能,我们穿插几种架构使用,比如状态机+事件去处理按键检测。
icxjohk0yqk64030305406.png
六、总结
回到最初的问题:STM32裸机编程有没有架构或思路?答案是肯定有,而且非常有必要!
我们今天聊的几种思路——模块化、时间片轮询、事件驱动、状态机、分层架构——并非相互排斥,而是可以根据项目的实际需求灵活组合使用。
?模块化是基础。
?时间片+事件驱动是管理while(1)循环的有效手段。
?状态机是应对复杂逻辑的利器。
?分层架构是大型项目的规范化选择。
记住,写裸机代码,不仅仅是让硬件“动起来”那么简单。追求代码的清晰性、可维护性、可扩展性、健壮性,才是一个合格工程师应有的素养。
好了,今天就“吹水”到这里。希望这些“干货”能让你在裸机编程的道路上,走得更稳,也更远。如果你有什么更好的想法或者踩过的坑,欢迎在评论区留言交流,让我们一起“卷”出新高度!
end
lwktarbbbd264030305506.jpg
下面是更多无际原创的个人成长经历、行业经验、技术干货。
1.电子工程师是怎样的成长之路?10年5000字总结
2.如何快速看懂别人的代码和思维
3.单片机开发项目全局变量太多怎么管理?
4.C语言开发单片机为什么大多数都采用全局变量的形式?
5.单片机怎么实现模块化编程?实用程度让人发指!
6.c语言回调函数的使用及实际作用详解
7.手把手教你c语言队列实现代码,通俗易懂超详细!
8.c语言指针用法详解,通俗易懂超详细! |