STM32 嵌入式项目全面学习报告
基于
STM32F4_WeatherClock(裸机版)与FreeRTOS(多任务版)的对比分析 面向初级嵌入式工程师,力求通俗易懂,涵盖所有重点与亮点
第一部分:两个项目最大的区别
1.1 用一句话概括
| 项目 | 核心特点 |
|---|---|
| STM32F4_WeatherClock(裸机版) | CPU 像一个人顺序干活,一件事没干完就停在那等,别的事全部暂停 |
| FreeRTOS(多任务版) | CPU 像一个公司,多个”员工”(任务)同时工作,等待时就把资源让给别人用 |
1.2 主循环对比——这是两者最本质的区别
裸机版的 main.c:
board_lowlevel_init() → 初始化硬件时钟
board_init() → 初始化各个外设
welcome_page_display() → 显示欢迎页面
wifi_init() → 初始化Wi-Fi(如果Wi-Fi没反应,直接卡死)
main_loop_init() → 注册1ms定时回调
main_page_display() → 显示主界面
while(1) { ← 永远在这个循环里转
time_sync() → 检查:到点了吗?到了就同步时间
wifi_update() → 检查:到点了吗?到了就查Wi-Fi状态
time_update() → 检查:到点了吗?到了就更新时间显示
inner_update() → 检查:到点了吗?到了就读温湿度
outdoor_update() → 检查:到点了吗?到了就获取天气
}
FreeRTOS版的 main.c:
board_lowlevel_init() → 初始化硬件时钟
workqueue_init() → 创建"工作队列"这个后台员工
xTaskCreate(main_init) → 创建一个"初始化任务"(员工A)
vTaskStartScheduler() → 把公司开起来,让调度器开始管理所有员工
↓ 调度器运行后,main函数这里就永远不会往下执行了
任务A (main_init, 最高优先级):
board_init() → ui_init() → wifi_init() → app_init()
vTaskDelete(NULL) ← 初始化完成后这个任务就自我删除了
任务B (ui任务, 优先级8):
while(1) { 等待绘图消息 → 收到后驱动屏幕 }
任务C (workqueue任务, 优先级5):
while(1) { 等待工作消息 → 收到后执行I/O操作 }
关键区别:当裸机版在等待Wi-Fi回包(可能需要几百毫秒)时,整个程序冻结; 而FreeRTOS版在等待Wi-Fi时,调度器会立刻把CPU时间分配给其他任务,屏幕继续刷新,其他事情照样进行。
1.3 定时机制对比
裸机版:手工制作的”软件定时器”
// ① 硬件层面:SysTick每1ms触发一次中断
void SysTick_Handler(void) {
cpu_tick_count += TICKS_PER_MS; // 每1ms给计数器加一个固定值
if (periodic_callback)
periodic_callback(); // 触发注册的回调函数
}
// ② 应用层:回调函数里,把所有任务的"倒计时"各减1ms
static void cpu_periodic_callback(void) {
if (time_sync_delay > 0) time_sync_delay--;
if (wifi_update_delay > 0) wifi_update_delay--;
if (time_update_delay > 0) time_update_delay--;
// ...
}
// ③ 主循环里,每个任务先检查自己的倒计时,为0才执行
static void time_sync(void) {
if (time_sync_delay > 0) return; // 时间没到,跳过
time_sync_delay = DAYS(1); // 重置为1天的倒计时
// ... 执行时间同步的具体逻辑
}理解要点:SysTick就像一个每1ms响一次的闹钟。把每个任务的等待时间换算成ms(1秒=1000ms,1分钟=60000ms),然后用这个闹钟倒计时,到0就执行任务。
FreeRTOS版:系统自带的软件定时器
void app_init(void) {
// 创建一个定时器:名字、周期、是否自动重复、携带参数、触发时调哪个回调函数
time_update_timer = xTimerCreate(
"time update", // 定时器名字(调试用)
pdMS_TO_TICKS(1000), // 1000ms触发一次
pdTRUE, // 自动重复(pdTRUE=是,pdFALSE=只触发一次)
time_update, // 携带的参数(这里是一个函数指针)
app_timer_cb // 触发时调用这个回调函数
);
xTimerStart(time_update_timer, 0); // 启动定时器
}FreeRTOS版的好处:不需要手写轮询,系统自动管理所有定时器,代码更清晰,也更精确。
第二部分:C语言重点知识详解(含指针)
2.1 指针详解——新手最难的地方
指针就是”存储地址的变量”。理解指针必须先理解内存地址。
基本概念
想象内存是一排格子,每个格子有编号(地址),格子里存着数据:
地址: 100 101 102 103
内容: [ 5 ] [ 88 ] [ 0 ] [ 33 ]
int a = 5; // 在地址100的格子里存了数字5
int *p = &a; // p是指针,&a取出a的地址(100),把100存进p里
printf("%d", *p); // *p叫"解引用",意思是:去地址100的格子里取数据 → 打印5
*p = 10; // 通过指针修改:把地址100的格子内容改成10,a也变成了10结构体指针(项目中大量使用)
// 定义一个结构体类型,把LED相关信息打包
struct led_desc {
GPIO_TypeDef* Port; // 哪个GPIO组(A/B/C/D...)
uint32_t Pin; // 哪个引脚号
BitAction OnBit; // 亮灯时引脚应该是高电平还是低电平
BitAction OffBit; // 灭灯时引脚应该是高电平还是低电平
};
typedef struct led_desc* led_desc_t; // 给"指向led_desc结构体的指针"起个别名
// 使用结构体指针:led是指向led_desc结构体的指针
void led_on(led_desc_t led) {
GPIO_WriteBit(led->Port, led->Pin, led->OnBit);
// led->Port 等价于 (*led).Port
// -> 是"通过指针访问成员"的符号
}为什么要传指针而不是直接传结构体? 传结构体会复制整个结构体(浪费内存),传指针只复制一个地址(4字节),更高效。
函数指针(项目中最难懂的部分)
函数指针是”指向函数的指针”,可以把函数当参数传给另一个函数。
// 普通函数的定义
void time_sync(void) { ... }
void wifi_update(void) { ... }
// 函数指针类型的定义:
// "typedef void (*app_job_t)(void);" 意思是:
// app_job_t 是一种类型,这种类型是"无参数、无返回值的函数指针"
typedef void (*app_job_t)(void);
// 使用:把time_sync函数的地址传进去
app_job_t job = time_sync; // job这个变量里存的是time_sync函数的地址
job(); // 调用:相当于直接调用 time_sync()
// 在workqueue中的实际应用:
void workqueue_run(work_t work, void *param) {
// work_t 是 typedef void (*work_t)(void *param);
// 把work(一个函数的地址)和param打包成消息,放进队列
work_message_t msg = { work, param };
xQueueSend(work_msg_queue, &msg, portMAX_DELAY);
}函数指针的作用:实现”回调机制”。就像你去餐厅点了菜,留下电话号码(函数指针),厨师做好了就给你打电话(调用函数),你不用一直站在厨房等。
2.2 静态变量(static)——非常重要
static void time_update(void) {
static rtc_date_time_t last_date = { 0 }; // 关键:static局部变量
rtc_date_time_t date;
rtc_get_time(&date);
// memcmp:逐字节比较两块内存是否相同,相同返回0
if (memcmp(&date, &last_date, sizeof(rtc_date_time_t)) == 0) return;
memcpy(&last_date, &date, sizeof(rtc_date_time_t)); // 更新记录
main_page_redraw_time(&date); // 只有变化了才刷屏
}static局部变量与普通局部变量的区别:
- 普通局部变量:函数每次调用都重新创建,函数返回后消失
- static局部变量:只创建一次,函数返回后仍然保存着值,下次调用还在
函数每秒调用一次,last_date记住了上次的时间。如果这次读到的时间和上次一样,就不刷屏幕,节省资源、防止闪烁。
2.3 联合体(union)——省内存神器
// 在ui.c中
typedef struct {
ui_action_t action; // 动作类型:填色?写字?画图?
union { // union内部的成员共用同一块内存!
struct { uint16_t x, y, width, height, color; } fill_color; // 填色用
struct { uint16_t x, y; const char *str; ... } write_string; // 写字用
struct { uint16_t x, y; const image_t *image; } draw_image; // 画图用
};
} ui_message_t;内存示意图:
union占用的内存(以最大成员为准):
[ byte0 ] [ byte1 ] [ byte2 ] ... [ byteN ]
↑ 当action是FILL_COLOR时,这块内存按fill_color结构体解读
↑ 当action是WRITE_STRING时,同一块内存按write_string结构体解读
同一块内存,根据action的值不同,来决定怎么解读。一个消息结构只占最大成员的空间,比三个结构体分别存省了近2/3的内存。
2.4 void指针——最通用的指针
void workqueue_run(work_t work, void *param);
// ↑
// void* 是"无类型指针",可以存储任何类型的地址
// 它的作用是实现"泛型"——一个函数可以接受任意类型的数据
// 使用时,把函数指针强制转换为void*存入,取出时再转换回来
workqueue_run(app_work, (void*)time_sync); // 存:把函数指针当void*
app_job_t job = (app_job_t)pvTimerGetTimerID(timer); // 取:转回函数指针第三部分:硬件外设知识详解
3.1 RCC——给外设”通电”
这是初学者最容易忘记的步骤!STM32的外设默认是关闭的(省电),使用前必须先开启时钟。
// 在board.c中
void board_lowlevel_init(void) {
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); // 开启GPIOA时钟
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE); // 开启GPIOB时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // 开启串口1时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE); // 开启I2C2时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_SPI2, ENABLE); // 开启SPI2时钟
// ...
}记住:忘记开时钟 = 外设不工作,这是调试时检查的第一步!
3.2 GPIO——最基础的数字IO
// 配置LED引脚为输出模式
GPIO_InitTypeDef GPIO_InitStructure; // 创建配置结构体
GPIO_StructInit(&GPIO_InitStructure); // 先用默认值填充(良好习惯!)
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13; // 选引脚13
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; // 设置为输出
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; // 推挽输出(能驱动LED)
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;
GPIO_Init(GPIOC, &GPIO_InitStructure); // 应用配置
// 控制引脚
GPIO_SetBits(GPIOC, GPIO_Pin_13); // 引脚输出高电平
GPIO_ResetBits(GPIOC, GPIO_Pin_13); // 引脚输出低电平
GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_SET); // 写入指定值3.3 UART串口——与调试PC和ESP8266通信
项目中用了两路串口:
- USART1:连接电脑,用
printf打印调试信息(console.c) - USART2:连接 ESP8266,发送 AT 指令(esp_at.c)
// 配置串口1(波特率115200)
USART_InitTypeDef USART_InitStructure;
USART_StructInit(&USART_InitStructure);
USART_InitStructure.USART_BaudRate = 115200;
USART_InitStructure.USART_WordLength = USART_WordLength_8b; // 8位数据
USART_InitStructure.USART_StopBits = USART_StopBits_1; // 1位停止位
USART_InitStructure.USART_Parity = USART_Parity_No; // 无校验
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; // 收发都开
USART_Init(USART1, &USART_InitStructure);
USART_Cmd(USART1, ENABLE); // 启动串口
// printf 如何输出到串口?
// 在board.c中重写了fputc函数(printf底层会调用它)
int fputc(int ch, FILE *f) {
USART_SendData(USART1, (uint8_t)ch); // 发一个字节
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); // 等发完
return ch;
}3.4 I2C——与AHT20温湿度传感器通信
I2C是一种”主从”协议,用两根线(SCL时钟线、SDA数据线)通信。
// AHT20的通信流程(在aht20.c中):
// 1. 发送启动信号
I2C_GenerateSTART(I2C2, ENABLE);
// 2. 发送设备地址(0x70是AHT20的地址)+ 写入方向标志
I2C_Send7bitAddress(I2C2, 0x70, I2C_Direction_Transmitter);
// 3. 发送命令字节(0xAC=开始测量,0x33,0x00是参数)
I2C_SendData(I2C2, 0xAC);
I2C_SendData(I2C2, 0x33);
I2C_SendData(I2C2, 0x00);
// 4. 发送停止信号
I2C_GenerateSTOP(I2C2, ENABLE);
// 等待测量完成后,切换到读模式,读出6个字节的原始数据
// data[1~2]包含湿度原始值,data[3~5]包含温度原始值
uint32_t raw_humidity = ((uint32_t)data[1] << 12) | ((uint32_t)data[2] << 4) | ((uint32_t)(data[3] & 0xF0) >> 4);
uint32_t raw_temperature = ((uint32_t)(data[3] & 0x0F) << 16) | ((uint32_t)data[4] << 8) | ((uint32_t)data[5]);
// 按AHT20数据手册的公式转换成实际值
*humidity = (float)raw_humidity * 100.0f / (float)0x100000; // 0x100000 = 2^20
*temperature = (float)raw_temperature * 200.0f / (float)0x100000 - 50.0f;位运算理解(<< >> 是移位操作):
data[1] = 0xAB = 1010 1011
data[1] << 12 = 把所有位向左移12位,相当于乘以2^12,用于拼接多字节原始数据
3.5 SPI + DMA——高速刷屏的核心
ST7789屏幕用SPI协议通信。刷满屏需要 240×320 = 76800 个像素,每像素2字节 = 153600字节。
- 没有DMA:CPU 一个一个字节地发送,发完才能干别的事(153600次循环,非常慢)
- 有DMA:CPU告诉DMA “把地址X开始的153600字节发给SPI外设”,然后CPU去干别的事,DMA自己完成传输,完成后触发中断通知CPU
// st7789.c 中刷图的核心:DMA+信号量 实现非阻塞传输
static void st7789_write_gram(uint8_t data[], uint32_t length, bool singlecolor) {
DMA1_Stream4->M0AR = (uint32_t)data; // 告诉DMA:源地址在哪
DMA1_Stream4->NDTR = chunk_size; // 告诉DMA:要搬多少个数据
DMA_Cmd(DMA1_Stream4, ENABLE); // 启动DMA!
xSemaphoreTake(write_gram_semaphore, portMAX_DELAY); // 等DMA搬完(任务挂起,让CPU给别人用)
}
// DMA搬完后触发中断,在中断里"给出"信号量,唤醒上面等待的任务
void DMA1_Stream4_IRQHandler(void) {
xSemaphoreGiveFromISR(write_gram_semaphore, &pxHigherPriorityTaskWoken);
portYIELD_FROM_ISR(pxHigherPriorityTaskWoken); // 立即切换到被唤醒的任务
}3.6 RTC——不断电的时间保持
STM32内置RTC(实时时钟),接备用电池(CR1220)后,即使主板断电,时间也不会丢失。
聪明的读时间方式(rtc.c中):
void rtc_get_time(rtc_date_time_t *date_time) {
rtc_date_time_t time1, time2;
do {
_rtc_get_time_once(&time1); // 第一次读
_rtc_get_time_once(&time2); // 紧接着第二次读
} while (memcmp(&time1, &time2, sizeof(rtc_date_time_t)) != 0);
// 如果两次读的结果不一样,说明读的过程中时间跳了(正好跨秒),重读!
memcpy(date_time, &time1, sizeof(rtc_date_time_t));
}这是一个经典技巧:连读两次,如果结果一致就说明读取是准确的,否则重读。用来解决32bit MCU在读64bit时间数据时可能被中断打断导致数据不一致的问题。
第四部分:FreeRTOS 核心概念详解
4.1 任务(Task)——FreeRTOS的基本单位
// 创建一个任务
xTaskCreate(
main_init, // 任务函数(任务要干什么事)
"init", // 任务名(调试用)
1024, // 栈大小(单位:StackType_t,通常4字节,所以这是4KB)
NULL, // 传给任务函数的参数
9, // 优先级(0最低,configMAX_PRIORITIES-1最高)
NULL // 任务句柄(不需要就传NULL)
);
vTaskStartScheduler(); // 启动调度器,开始执行任务任务的状态(调度器自动管理):
- 运行中:正在使用CPU
- 就绪:随时可以运行,等CPU空出来
- 阻塞:在等某件事(定时器、队列消息、信号量)
- 挂起:被手动暂停
4.2 队列(Queue)——任务间传消息
// 创建队列:可以存16个ui_message_t类型的消息
ui_queue = xQueueCreate(16, sizeof(ui_message_t));
// 发送消息(任何任务都可以往队列里发)
xQueueSend(ui_queue, &msg, portMAX_DELAY);
// portMAX_DELAY:如果队列满了,就无限等,直到有空位
// 接收消息(UI任务等在这里)
xQueueReceive(ui_queue, &msg, portMAX_DELAY);
// 队列里没消息就挂起等待(期间CPU可以去执行其他任务)队列的作用:解决多任务之间的数据传递,并且是线程安全的(内部有保护机制)。
4.3 信号量(Semaphore)——任务间的”通知”机制
二值信号量只有0和1两种状态(类似开关):
// 场景:DMA完成传输 → 通知等待的UI任务
SemaphoreHandle_t write_gram_semaphore = xSemaphoreCreateBinary();
// 等待端(UI任务里):
xSemaphoreTake(write_gram_semaphore, portMAX_DELAY);
// → 如果信号量是0,任务就挂起在这里等,CPU去干别的
// 触发端(DMA中断里):
BaseType_t pxHigherPriorityTaskWoken;
xSemaphoreGiveFromISR(write_gram_semaphore, &pxHigherPriorityTaskWoken);
portYIELD_FROM_ISR(pxHigherPriorityTaskWoken);
// → 把信号量变成1,唤醒等待的任务
// → portYIELD_FROM_ISR:如果唤醒的任务比当前中断更高优先级,立刻切换过去重要规则:在中断(ISR)中只能用带FromISR后缀的函数,普通版会出错!
4.4 软件定时器(Timer)——精确的定时回调
TimerHandle_t time_sync_timer = xTimerCreate(
"time sync", // 名字
pdMS_TO_TICKS(200), // 200ms后触发(pdMS_TO_TICKS将ms转成系统tick)
pdFALSE, // 不自动重复(单次触发)
time_sync, // Timer ID(携带的数据,这里存了函数指针)
work_timer_cb // 触发时调用的回调函数
);
xTimerStart(time_sync_timer, 0);
// 在回调函数里取出携带的函数指针并执行:
static void work_timer_cb(TimerHandle_t timer) {
app_job_t job = (app_job_t)pvTimerGetTimerID(timer); // 取出存的函数指针
workqueue_run(app_work, job); // 把它提交给工作队列执行(因为定时器回调不能阻塞)
}重要限制:定时器回调函数里不能做阻塞操作(不能等串口、不能延时),必须快速返回!这就是为什么要把耗时工作通过workqueue_run转发出去。
4.5 WorkQueue 模式——优雅解决”定时器不能阻塞”问题
// workqueue.c — 非常简洁却非常精妙
typedef struct {
work_t work; // 一个函数指针(要做什么事)
void *param; // 参数
} work_message_t;
// 初始化:创建一个队列 + 一个专门消费队列的任务
void workqueue_init(void) {
work_msg_queue = xQueueCreate(16, sizeof(work_message_t));
xTaskCreate(work_func, "workqueue", 1024, NULL, 5, NULL);
}
// 工作任务:永远阻塞等待,收到消息就执行
static void work_func(void *param) {
work_message_t msg;
while (1) {
xQueueReceive(work_msg_queue, &msg, portMAX_DELAY); // 阻塞等
msg.work(msg.param); // 执行:可以在这里做任意耗时操作
}
}
// 提交一个工作(任何地方调用都安全)
void workqueue_run(work_t work, void *param) {
work_message_t msg = { work, param };
xQueueSend(work_msg_queue, &msg, portMAX_DELAY);
}流程总结:
定时器到期 → work_timer_cb回调(极短,仅入队) → workqueue任务收到消息 → 执行耗时I/O(可以阻塞等网络)
第五部分:屏幕显示系统详解
5.1 字库是怎么来的,字是怎么显示到屏幕上的?
字库(font 文件夹里的那些 .c 文件)是把字形存储为”点阵位图”。
// 以16×8大小的字体为例,每个字符用 16行×1字节=16字节 表示
// 二进制的每一位代表一个像素:1=有这个字的笔画(前景色),0=背景
// 字母 'A' 的点阵示意(0=背景,1=笔画):
// 0000 0000
// 0001 1000
// 0010 0100
// 0110 0110
// 0111 1110 ← 这行的二进制就存在字库数组里
// 1100 0011
// ...// st7789.c 中把点阵转为屏幕像素的代码
static void st7789_draw_font(...) {
for (uint16_t row = 0; row < height; row++) { // 逐行
const uint8_t *row_data = model + row * bytes_per_row;
for (uint16_t col = 0; col < width; col++) { // 逐列
// 取出这个像素点对应的bit(1或0)
uint8_t pixel = row_data[col / 8] & (1 << (7 - col % 8));
// 1就用前景色(字体颜色),0就用背景色
uint16_t pixel_color = pixel ? color : bg_color;
// 写入缓冲区
*pbuf++ = pixel_color & 0xff;
*pbuf++ = (pixel_color >> 8) & 0xff;
}
}
// 一次性通过DMA把缓冲区数据发给屏幕
st7789_write_gram(buff, pbuf - buff, false);
}5.2 颜色是怎么表示的?
ST7789使用RGB565格式:16位表示一个颜色(5位红+6位绿+5位蓝)。
// ui.h 中的颜色转换宏
#define mkcolor(r, g, b) (((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3))
// r取高5位(r & 0xF8),左移8位 → 占bit15~bit11
// g取高6位(g & 0xFC),左移3位 → 占bit10~bit5
// b取高5位(b >> 3) → 占bit4~bit0
// 使用例子:
uint16_t white = mkcolor(255, 255, 255); // 白色
uint16_t red = mkcolor(248, 0, 0); // 红色(R最大,G/B为0)
uint16_t bg = mkcolor(136, 217, 234); // 项目中室内温度区的浅蓝色背景5.3 汉字是怎么显示的?
ASCII字符有标准的顺序(‘A’=65,‘B’=66…),但汉字没有,所以项目用了一个查找表:
typedef struct {
const char *name; // 汉字的字符串(如"星")
const uint8_t *model; // 这个汉字对应的点阵数据
} font_chinese_t;// 显示字符串时,判断是ASCII还是汉字
void st7789_write_string(...) {
while (*str) {
int len = is_gb2312(*str) ? 2 : 1; // GB2312汉字占2个字节
if (len == 1) {
st7789_write_ascii(x, y, *str, ...); // ASCII字符
x += font->size / 2; // 向右移动半个字体宽度
} else {
// 取出2字节汉字编码,在查找表里找对应点阵
char ch[5];
strncpy(ch, str, len);
st7789_write_chinese(x, y, ch, ...);
x += font->size; // 向右移动一个完整字体宽度
}
str += len;
}
}第六部分:编程好习惯总结
6.1 超时保护——防止程序死机
永远不要无限等待硬件响应,必须加超时!
// 用宏封装带超时的事件等待(aht20.c中)
#define I2C_CHECK_EVENT(EVENT, TIMEOUT) \
do { \
uint32_t timeout = TIMEOUT; \
while (!I2C_CheckEvent(I2C2, EVENT) && timeout > 0) { \
tim_delay_us(10); \
timeout -= 10; \
} \
if (timeout <= 0) return false; // 超时了就返回失败,不死机!
} while (0)6.2 错误处理——用goto集中处理
bool esp_at_init(void) {
if (!esp_at_wait_boot(3000)) goto err; // goto:直接跳到错误处理
if (!esp_at_write_command(...)) goto err;
if (!esp_at_wait_ready(5000)) goto err;
return true;
err: // 所有错误统一在这里处理
// 打印错误,显示错误页面,或者重试
return false;
}goto在嵌入式C中是被认可的错误处理方式,比层层嵌套的if-else更清晰。
6.3 初始化时填充默认值
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_StructInit(&GPIO_InitStruct); // 先用默认值填充,防止忘记设置某个字段导致随机值
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_OUT;
// 其他字段用默认值就好
GPIO_Init(GPIOA, &GPIO_InitStruct);6.4 时间单位宏——消灭”魔法数字”
// 不好的写法:
delay(3600000); // 这是什么?多少秒?没人知道
// 好的写法(项目中的用法):
#define MILLISECONDS(x) (x)
#define SECONDS(x) MILLISECONDS((x) * 1000)
#define MINUTES(x) SECONDS((x) * 60)
#define HOURS(x) MINUTES((x) * 60)
delay(HOURS(1)); // 一目了然:等1小时6.5 断言(configASSERT)——在开发阶段发现问题
at_ack_sempahore = xSemaphoreCreateBinary();
configASSERT(at_ack_sempahore); // 如果创建失败(返回NULL),立刻触发断言报错
// 比程序继续运行然后在某个奇怪的地方崩溃好得多第七部分:FreeRTOS 配置文件解读
FreeRTOSConfig.h 控制着整个系统的行为,关键参数:
#define configTICK_RATE_HZ 1000 // 系统时钟频率:1000次/秒 = 每1ms一个tick
#define configMAX_PRIORITIES 10 // 最高支持10个优先级(0~9)
#define configTOTAL_HEAP_SIZE (92 * 1024) // 动态内存堆总大小92KB
#define configCHECK_FOR_STACK_OVERFLOW 1 // 开启栈溢出检测(调试期间必开)
#define configUSE_MALLOC_FAILED_HOOK 1 // 内存申请失败时触发钩子函数
// 最高中断优先级配置(非常重要!)
#define configMAX_SYSCALL_INTERRUPT_PRIORITY (5 << (8 - configPRIO_BITS))
// 意思是:优先级 >= 5 的中断才能调用 FreeRTOS 的 API
// 优先级 < 5(即更高优先级)的中断不受 FreeRTOS 管理,响应速度最快栈溢出检测的处理函数(在board.c中):
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
printf("Stack Overflowed: %s\n", pcTaskName); // 打印是哪个任务栈溢出了
configASSERT(0); // 触发断言,停止运行
}第八部分:学习路线建议
第一阶段(裸机基础): 先彻底读懂 STM32F4_WeatherClock
board.c→ 理解RCC时钟、GPIO初始化driver/led/led.c→ GPIO输出,结构体指针传参driver/cpu_tick/cpu_tick.c→ SysTick定时app/mloop.c→ 裸机调度原理driver/aht20/aht20.c→ I2C通信流程driver/esp_at/esp_at.c→ UART通信和AT指令解析
第二阶段(RTOS进阶): 对照读 FreeRTOS 版
app/main.c→ 对比两种启动方式的区别app/workqueue.c→ WorkQueue模式(仅39行,非常值得细读)app/ui.c→ 队列、信号量、union的综合运用app/app.c→ 软件定时器和函数指针的组合技巧driver/st7789/st7789.c→ DMA + 信号量的非阻塞传输
第三阶段(融会贯通):
同一个功能(比如wifi_update)在两个版本中的代码逐行对比,体会RTOS带来的结构变化。