p9 ~ p15(约 116 分钟)
第一个多任务程序
模板里 driver_led 是死循环,oled_test 也是死循环。两个死循环要同时跑,只能上 RTOS。
CubeMX 生成的工程默认已经建了一个任务——osThreadNew 创建的那个,里面跑 LCD 初始化加 LCD 测试。所以只需要再手动建第二个。
osThreadNew 属于 CMSIS-RTOS,是一层统一接口。嵌入式里 FreeRTOS、RT-Thread、RTX 创建任务的函数各不相同(FreeRTOS 是 xTaskCreate,RT-Thread 是 rt_thread_create),于是抽出一层接口,app 只调 osThreadNew,底层按不同 OS 转发。它内部判断之后,要么调 xTaskCreateStatic,要么调 xTaskCreate。
换成原生写法:
static void my_task(void *argument){ (void)argument; for (;;) { /* led_test(); */ }}
xTaskCreate(my_task, /* 任务函数 */ "My first task", /* 名字 */ 128, /* 栈深度 */ NULL, /* 参数,本任务不需要 */ osPriorityNormal, /* 优先级 */ NULL); /* 句柄,先不用 */两个参数的值是抄来的:栈深度参考默认任务的属性写 128(它那边显示的是 128×4),优先级直接用了 osPriorityNormal。进 osThreadNew 内部看,它把属性里的优先级取出来、做几个判断之后一路往下直接用,没做转换,所以原生函数里也能填这个宏。
[待确认] FreeRTOS 原生优先级是 0 ~ configMAX_PRIORITIES−1,osPriorityNormal 对应哪个数值没有明说。
跑起来的效果:屏幕上数字不断累加,LED 不断闪烁。两个死循环同时运行,这就是多任务。
从 C 到汇编
创建任务要指定栈大小,函数内部按这个值分配栈;要理解栈,得先知道处理器怎么工作。
组装电脑是主板插 CPU、内存条、硬盘。单片机是 SoC,一颗芯片里集成 CPU、内存和 Flash。Flash 类比硬盘存程序,CPU 跑程序,运行时用内存。
有个前提必须记住:内存没有计算功能。访问内存只有「写进去」和「读出来」两种操作,所有计算都在 CPU 内部完成。ARM 是精简指令集(RISC),CPU 对内存就只有读、写两条指令。
所以 A = A + B 这样一行 C 代码,拆成汇编是四步:
① 把 A 读进 CPU② 把 B 读进 CPU③ CPU 内部算 A + B④ 结果写回内存里 A 的位置汇编指令存在 Flash,变量值存在内存,CPU 从 Flash 取指令来执行。
CPU 内部有寄存器 R0 ~ R15 和一个计算单元(字幕里的「202122~25」是 R0/R1/R2…R15 的误识别),另外还有程序状态寄存器 PSR。上面那个例子里,A 读进 R0,B 读进 R1,新值放回 R0,最后把 R0 写回内存。
后三个寄存器有专门的名字:
R13 = SP 栈指针,保存栈的地址R14 = LR 链接寄存器,保存返回地址(A 调 B,B 执行完要回到 A)R15 = PC 程序计数器,往它写一个函数地址,CPU 就跳过去执行数据传输类指令和 memcpy 一样有三个要素:源、目的、长度。
LDR / LDRB / LDRH 读 4 字节 / 1 字节 / 2 字节STR / STRB / STRH 写 4 字节 / 1 字节 / 2 字节ADD R0, R1, R2 R0 = R1 + R2SUB R0, R1, R2 R0 = R1 − R2CMP R0, R1 比较,结果存进 PSRB addr 直接跳过去BL addr 先记返回地址,再跳过去LDR R0, [R1, #4] 的意思是从「R1 的值 + 4」这个地址读 4 字节存进 R0,寄存器可以换,偏移也可以改。
BL 分两步:先把 LR 设成返回地址(下一条指令的地址),再把 PC 设成被调函数的地址,于是跳过去执行。
一共七条就够用了:LDR、STR、ADD、SUB、CMP、B、BL,读写指令各带 B、H 变种。
反汇编里看函数调用
写一个 C 加法函数,里面加 volatile 故意阻止编译器优化,再加三个 volatile 局部变量。然后在 Keil 的 User 标签里配一条编译后命令,用 fromelf 把链接输出的 .axf 转成 .dis 反汇编文件。
反汇编有三列:地址、机器码、汇编码。烧进芯片的只有机器码,汇编码只是编译器翻译给人看的。搜函数名时要注意区分大小写。
调用之前先准备两个参数:第一个参数放 R0,第二个放 R1(MOV R1, #1)。反汇编里能看到 R0 = R4(R4 就是变量 count),然后 BL 跳进函数。函数算完的新值放在 R0 返回,调用方再把 R0 写回 R4——这就是 count = ADD(count, 1)。
PUSH 本质上是写内存,是 STR 的变种,把多个寄存器写进栈并调整 SP。假设初始 SP = A,高地址在上、低地址在下:
PUSH {LR, R1, R0} → 写 LR,SP 下移 → 写 R1,SP 下移 → 写 R0,SP 下移 最后 SP = A − 12 三个寄存器占 12 字节所以栈是向低地址方向生长的。函数体内部还能看到 SUB SP, SP, #4 再空出 4 字节、LDRD 一次从 SP+4 读 8 字节、STR R0, [SP, #0] 把结果存进 SP+0——那就是局部变量的位置。POP 反向恢复,低地址对应低编号寄存器,最后把返回地址弹回 PC,PC 一被赋值就回到了调用点。
[待确认] 这里 PUSH 写的是 LR、R1、R0 三个寄存器,但 POP 字幕里出现了四个寄存器,前后不一致,以 PUSH 的三个为准。
一个看着挺唬人的 C 函数,拆开就是读内存、写内存、加减、跳转。
堆
堆就是一块空闲内存,从里面取一部分用,用完还回去。
最简的堆实现是定义一个数组当空闲内存:
char heap_buffer[1024];int pos = 0;
void *my_malloc(int size){ void *p = &heap_buffer[pos]; pos += size; return p;}用一个整数 pos 记空闲位置,分配一次就往后挪一次。这种写法没法实现 free——分配了 buffer(100) 和 buffer2(100),再 my_free(buffer),my_free 只拿到一个地址,不知道要释放多大。所以 my_free 只能先留空。
在 Keil 里点 Debug 选 Use Simulator,打断点单步看:第一次分配返回的是 heap_buffer 的首地址 0x20000004,填 for(i=0;i<26;i++) buffer[i]='A'+i; 之后能看到内容。退出调试时报错,原因是工程放在中文目录,复制到非中文目录就正常了。
真正的堆管理要在头部记账:
分配 100 字节,实际分配「头部 + 100 字节」返回的指针指向头部之后头部里存 size = 100free 时用指针减掉头部大小找到头部,读出 size结构体大概长这样:
struct head { int size; struct head *next_free;};分配 100、50、100 三块,再释放中间那块,就会出现隔开的空闲区域,所以必须用链表把空闲块串起来。全局链表头初始 size = 0,next_free 指向第一个头部;第一个头部 size = 1024,表示从这里开始有 1024 字节空闲。
分配 100 时,从链表头找一块够大的,从 1024 里挖走 108(100 加 8 字节头部),剩余头部 size = 1024 − 108。再分配 50,需要找 size 超过 58 的块。释放就把空闲块挂回链表。申请时从头找第一块,不够找第二块,再不够就失败——这是首次适应。
堆管理有很多种实现,这里演示的是最常见的一种。头部 8 字节按 32 位算就是 size(4) + next_free(4)。
栈
栈也是一块内存,但 CPU 的 SP 寄存器指着它。函数调用、局部变量、任务切换时保存现场,都靠它。每个任务都有自己的栈。
写一条 main → A → B → C 的调用链,加 volatile 防优化,编译后用同样的 fromelf 命令生成反汇编。
函数调用的本质就是 BL 指令。main 调 A 时,LR 里存的是 A 执行完要返回的地址;A 里面调 B,又用 BL 把 LR 覆盖成新的返回地址。原来那个返回地址就丢了。
解决办法是在破坏 LR 之前,先用 PUSH 把 LR 连同其他寄存器存进栈。所以每个 C 函数入口一般都会保存 LR。简单的、不调别的函数的函数可能不保存,取决于优化等级;但只要函数内部还会用 BL,就必定保存。LR 只能存在栈里。
栈帧的变化过程(设初始 SP = A):
main 入口 PUSH {R3~R6, LR} SP = SP − 20 这块是 main 的栈main 调 A A 入口 PUSH {R0, LR} SP = SP − 8 这块是 A 的栈A 调 B B 入口 PUSH {LR, R0} 这块是 B 的栈[待确认] main 入口那句字幕给的是 SP − 20,但四个寄存器只占 16 字节,可能还包含了栈对齐或者第五个寄存器。
关键的一点:C 函数的栈不接在 B 下面,而是复用 B 回收的空间。B 执行完 POP 出栈,SP 回到调用 B 之前的位置,然后才调用 C,C 的栈从这个位置重新分配。
所以每个 C 函数入口就干两件事:划分自己的栈,把 LR 和其他必要寄存器存进去。
RTOS 为什么要给每个任务一个栈
函数 B 把变量加 2 返回新值。建两个任务,代码几乎一样,都是死循环调 B 累加,但 Task A 的 count 初值是 0,Task B 是 100。
运行过程是 A 跑一阵、定时器中断切换、B 跑一阵、再切回 A,切换时机无法控制。切换时做的事就是保存当前任务现场、恢复另一个任务现场。
现场指的是被切换那一瞬间的所有寄存器。设想 Task A 刚在 B 函数里算出 R0 = 0 + 2 = 2,还没用上这个结果就被切走了。为了不浪费算好的值(这次是 R0,别的时候可能是 R1、R2),内核干脆把除 SP 之外的所有寄存器都保存下来——它不知道什么时候会被切,也不知道哪个寄存器里有重要数据。
保存的位置是任务自己的栈:
Task A 运行中被定时器中断切换 → 切换瞬间的所有寄存器存进 Task A 的栈(包括 PC:我执行到哪了) → 保存完,SP 指向这里 → 这个 SP 要记进 Task A 的结构体(TCB)以后要恢复 A,先找到 A 的结构体,从里面取出 SP 赋给硬件,再把保存的寄存器值倒回去。顺序上先恢复普通寄存器,最后恢复 PC:把 2 写回 R0,其他值各归各位,最后把 0x0800017A 写进 PC,CPU 从那个地址接着跑。R0 里还是之前算好的 2,程序正确续上。
一个人同时看语文和数学两本书,脑子只有一个。语文读到第 100 页累了,在第 100 页夹个书签改看数学;数学读到 200 页再夹一个,翻回语文第 100 页接着读。夹书签就是保存现场,从书签处接着读就是恢复现场。
每个任务都得有自己的栈,原因有三条:
调用关系不同 每个任务的调用链不一样,B 函数执行完返回到哪,A 和 B 两个任务各不相同局部变量不同 两个任务都调 B 函数,但初值 0 和 100 不一样,这些变量各自存在自己的栈里现场不同 A 的现场存 A 的栈,B 的现场存 B 的栈坑
- 反汇编里搜函数名区分大小写,搜不到先看这一点。
- 工程放在中文目录,Keil 退出调试会报错。
- 只分配不回收的
my_malloc不能配free:free只拿到一个地址,不知道要释放多大,所以头部里必须存 size。 PUSH之后 SP 是减小的,栈向低地址生长。看反汇编时别把方向弄反。- 内核切换保存的是「除 SP 外的所有寄存器」,不是只保存 R0~R3。少存一个,恢复出来的现场就是错的。
请输入编辑凭据,只有站点所有者可以修改文章。