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

  1. board.c → 理解RCC时钟、GPIO初始化
  2. driver/led/led.c → GPIO输出,结构体指针传参
  3. driver/cpu_tick/cpu_tick.c → SysTick定时
  4. app/mloop.c → 裸机调度原理
  5. driver/aht20/aht20.c → I2C通信流程
  6. driver/esp_at/esp_at.c → UART通信和AT指令解析

第二阶段(RTOS进阶): 对照读 FreeRTOS

  1. app/main.c → 对比两种启动方式的区别
  2. app/workqueue.c → WorkQueue模式(仅39行,非常值得细读)
  3. app/ui.c → 队列、信号量、union的综合运用
  4. app/app.c → 软件定时器和函数指针的组合技巧
  5. driver/st7789/st7789.c → DMA + 信号量的非阻塞传输

第三阶段(融会贯通): 同一个功能(比如wifi_update)在两个版本中的代码逐行对比,体会RTOS带来的结构变化。