os操作系统——第1讲:一切从心跳开始

目录

    • 第1讲:一切从心跳开始------定时器驱动下的"假并发"
      • [1. 裸机世界:一条路走到黑](#1. 裸机世界:一条路走到黑)
      • [2. 定时器:系统的心脏起搏器](#2. 定时器:系统的心脏起搏器)
      • [3. 最粗暴的切换:暴力跳转](#3. 最粗暴的切换:暴力跳转)
      • [4. 这就是"假并发"](#4. 这就是“假并发”)
      • [5. 这种粗暴切换的代价与问题](#5. 这种粗暴切换的代价与问题)
      • [6. 本讲小结 & 下集预告](#6. 本讲小结 & 下集预告)
      • [✍️ 思考与练习](#✍️ 思考与练习)

第1讲:一切从心跳开始------定时器驱动下的"假并发"

你打开电脑,一边听着音乐,一边敲着代码,浏览器还在后台加载着网页。

你理所当然地认为这些程序是"同时"运行的。

但真相是:如果你的电脑只有一个CPU核心,那么在任何某一微秒 ,它只做了一件事。

所谓"并发",不过是操作系统用定时器制造的一场精心骗局。


1. 裸机世界:一条路走到黑

我们先忘掉操作系统,回到最底层的"裸机编程"。

想象一个简单的单片机系统(比如STM32或Arduino),你写了这样的代码:

c 复制代码
void taskA() {
    while(1) {
        // 做任务A的事情,比如闪烁LED1
        delay_ms(500);
    }
}

void taskB() {
    while(1) {
        // 做任务B的事情,比如驱动电机
        delay_ms(200);
    }
}

int main() {
    taskA();  // 只会进入这里,taskB永远得不到执行
    taskB();
    return 0;
}

很明显,程序永远卡在 taskA() 的死循环里,taskB() 根本无人问津。

你可能会说:这好办,我不用死循环,我写成状态机轮流调用不就行了?

c 复制代码
int main() {
    while(1) {
        do_taskA_once();
        do_taskB_once();
    }
}

这叫协作式调度 (Cooperative Scheduling),每个任务主动让出CPU。

但问题来了:如果 do_taskA_once() 里有一个耗时的 delay_ms(500),整个CPU还是得傻等500毫秒,taskB 依然被阻塞。

用户需要的是:即便任务A在"睡觉",CPU也能立刻转身去执行任务B

这就像餐厅里一个服务员同时服务十张桌子------当一桌客人点完菜在等上菜时,服务员不会傻站在那里等,而是去招呼其他桌的客人。

实现这种"不傻等"的能力,正是操作系统的核心起点。


2. 定时器:系统的心脏起搏器

CPU自己无法感知"时间流逝"------它只懂一条接一条地执行指令。要想让CPU"每隔一段时间就主动打断当前工作,换个任务干",必须依赖硬件定时器

每个现代CPU内部都集成了一个或多个硬件定时器(比如x86的PIT、ARM的SysTick)。它的工作方式很简单:

  1. 你给它设置一个倒计时数值(比如1000)。
  2. 它每个时钟周期减1,减到0时,向CPU发送一个中断信号
  3. CPU收到中断信号,会暂停当前执行的代码,跳转到一个预先设定好的函数------中断服务函数
  4. 中断服务函数执行完后,CPU回到被暂停的地方继续执行。

如果我们在中断服务函数里故意不回到原来的任务,而是跳转到另一个任务,这不就实现了任务切换吗?

这就是"定时器驱动调度"的本质:利用周期性中断,强行剥夺当前任务对CPU的控制权,分给另一个任务。


3. 最粗暴的切换:暴力跳转

先抛开复杂的操作系统概念,我们手写一个最简单的例子(以ARM Cortex-M为例,用汇编+C混合,更贴近底层)。

假设我们有两个函数,想让它们"同时"运行:

c 复制代码
void func1() {
    while(1) {
        PORTB ^= (1<<5);  // 翻转LED
        delay(100000);
    }
}

void func2() {
    while(1) {
        PORTC ^= (1<<0);  // 翻转另一个LED
        delay(50000);
    }
}

裸机下,我们只能在 main 里调用其中一个。现在,我们在定时器中断里动手脚。

关键数据结构:我们得知道每个任务当前执行到了哪条指令,以及它用到了哪些寄存器。

这些信息被称为上下文(Context) ,通常保存在一块内存区域------栈(Stack) 中。

我们会为每个任务分配一块独立的栈空间。当任务被切换出去时,我们手动把CPU的寄存器(R0-R12, LR, PC, xPSR)压入该任务的栈;当切换回来时,再从它的栈里弹出。


代码实现(简化版)

首先,定义任务控制块(TCB,Task Control Block)------对于第一讲,我们只需要知道两个东西:

  • sp:任务的栈指针(指向栈顶)
  • next:指向下一个任务(简单链表)
c 复制代码
typedef struct tcb {
    unsigned int *sp;        // 栈指针
    struct tcb *next;
} tcb_t;

tcb_t tasks[2];
tcb_t *current_task;

初始化任务时,我们需要构造一个"假的"栈帧。为什么?因为任务第一次被切换进去时,我们希望它好像被中断打断过一样------栈里已经提前放好了寄存器的值。

c 复制代码
#define STACK_SIZE 128
unsigned int stack1[STACK_SIZE];
unsigned int stack2[STACK_SIZE];

void init_task(tcb_t *tcb, unsigned int *stack, void (*entry)()) {
    unsigned int *sp = stack + STACK_SIZE;   // 栈顶(栈向下增长)
    
    // 手动压入一个中断返回时的寄存器布局(xPSR, PC, LR, R12, R3-R0)
    *(--sp) = 0x01000000;  // xPSR (thumb状态)
    *(--sp) = (unsigned int)entry;  // PC = 任务入口
    *(--sp) = 0xFFFFFFFD;  // LR (异常返回专用值)
    *(--sp) = 0x12121212;  // R12
    *(--sp) = 0x03030303;  // R3
    *(--sp) = 0x02020202;  // R2
    *(--sp) = 0x01010101;  // R1
    *(--sp) = 0x00000000;  // R0
    // 剩余R4-R11不会自动压栈,需要手工压(这里简化,先不管)
    
    tcb->sp = sp;
}

定时器中断服务函数:

c 复制代码
void SysTick_Handler(void) {  // 假设用SysTick定时器
    // 1. 保存当前任务的上下文(R4-R11需要额外压栈,这里略,仅做示范)
    // 2. 切换到下一个任务
    current_task = current_task->next;
    
    // 3. 恢复下一个任务的上下文并跳转
    // 在汇编中实现:将current_task->sp 加载到SP,然后执行pop {r4-r11, pc}
}

汇编切换核心(示意):

assembly 复制代码
; 进入中断时硬件已自动压入 xPSR, PC, LR, R12, R3-R0 到当前SP
; 我们只需保存R4-R11到当前任务的栈
PUSH {R4-R11}
; 保存当前SP到 current_task->sp
LDR R0, =current_task
LDR R1, [R0]
STR SP, [R1]

; 切换到下一个任务
LDR R1, [R1, #4]   ; 取出 next 指针
STR R1, [R0]       ; current_task = next
LDR SP, [R1]       ; 加载新任务的栈指针

; 恢复新任务的 R4-R11
POP {R4-R11}
; 执行中断返回,硬件自动弹出剩下的寄存器
BX LR

上述代码极度简化,真实操作系统需要处理更多的细节(比如中断嵌套、优先级、临界区保护),但核心思想不变:定时器中断 → 保存旧任务现场 → 恢复新任务现场 → 完成切换


4. 这就是"假并发"

当定时器每1毫秒触发一次中断,我们的 SysTick_Handler 就会以1ms为周期,在两个任务之间来回切换。从外部看,两个任务的代码几乎同时在执行------这就是抢占式时间片轮转调度

你可以想象这样的时间线:

时间(ms) CPU 正在执行
0-1 func1
1-2 func2
2-3 func1
3-4 func2
... ...

虽然每个时刻只有一个任务在运行,但因为切换足够快(毫秒级),人的感觉就是并发。

计算机科学里给这个现象起了一个浪漫的名字:"假并发"


5. 这种粗暴切换的代价与问题

看到这里你可能会兴奋:一个微型"操作系统"已经成型了!但别急,这个模型还有几个致命缺陷:

  1. 共享资源的冲突

    如果 func1func2 都要修改同一个全局变量 count++,切换可能发生在 count 刚读到寄存器但还没写回内存的时刻。结果就是 count 少加了一次。
    (这个问题将在第3讲"临界区与关中断"解决)

  2. 没有"主动等待"机制

    如果 func1 想等一个外部按键按下,它只能在循环里死等(忙等待)。明明这时候CPU可以去做 func2,但我们的调度器不懂"阻塞",仍然无情地每1ms来切一次。
    (这需要引入"任务状态机",将在第4讲"阻塞机制"解决)

  3. 切换开销

    每1ms就保存/恢复一堆寄存器,如果任务很简单(比如只翻转IO),切换开销甚至可能超过实际工作。
    (真实的OS会动态调整时间片,或者引入"空闲任务"降低无用切换)

  4. 没有优先级

    所有任务轮流执行,如果任务A需要紧急响应(比如刹车信号),它也得乖乖排队等1ms。
    (优先级调度是第2讲的内容)


6. 本讲小结 & 下集预告

今天我们迈出了内核进化的第一步:

  • 硬件定时器是操作系统的"心跳",驱动着调度轮转。
  • 中断服务函数里进行上下文切换,制造出多个任务并发运行的假象。
  • 最简单的调度算法就是时间片轮转,每个任务公平地分得一小段CPU时间。

这个简单的模型正是RTOS(如FreeRTOS、uC/OS)最底层的骨架。甚至最早期的Unix版本,也曾在PDP-11上用类似原理实现过分时系统。

下一讲:任务的三六九等------TCB与任务状态机

我们将告别"两个func全局变量"的野路子,正式引入数据结构来管理任务,并实现就绪、运行、阻塞、挂起等状态。你还会学到:如何实现一个真正按优先级抢占的调度器。


✍️ 思考与练习

  1. 观察:如果你的电脑只有一个CPU,打开任务管理器,观察CPU使用率。为什么即使你不动鼠标,CPU使用率也不为0?那个"空闲任务"在做什么?
  2. 动手 :在QEMU+RISC-V环境中,写一个最简单的定时器中断切换两个死循环的演示程序(参考本章伪代码,网上有现成的mini-riscv-os项目)。
  3. 思辨:为什么早期的Windows 3.1和Mac OS(System 7)采用"协作式多任务"(任务不主动让出就卡死),而现代OS全部采用"抢占式"?背后是硬件能力和用户体验的什么变化?

欢迎在专栏评论区分享你的理解或代码截图,我们下期再见!

相关推荐
纽格立科技6 小时前
数字广播快问快答:从“有没有载波“到“听上去像噪声“
服务器·车载系统·信息与通信·传媒
豆包公子1 天前
AUTOSAR CP XCP 协议栈核心解析-理论篇
车载系统
里晓山10 天前
SOME/IP协议(上)
网络·网络协议·tcp/ip·车载系统
Cho1yon17 天前
【第15期:车机CarPlay使用中语音唤醒失效问题分析与解决方案】
macos·车载系统·objective-c·cocoa
小羊子说19 天前
Android ANR 原理浅析
android·性能优化·车载系统
Cho1yon19 天前
【AI Agent 第十期:基于 scrcpy + PyTorch 的车载系统多屏自动化测试工具开发】
人工智能·pytorch·ui·车载系统·自动化
半个西瓜.20 天前
车联网安全:GPS定位测试.(静态欺骗)
网络·安全·网络安全·车载系统·安全威胁分析
半个西瓜.20 天前
车联网安全:GPS定位测试.(动态欺骗)
网络·安全·网络安全·车载系统
Cho1yon22 天前
【第14期:多屏播放dvr视频和其他三方视频黑屏分析思路闪屏
车载系统·音视频