文章

Cortex-M4 与 MCU 开发:在一个没有操作系统的世界里写代码

先说一个反直觉的事实:你花几千块组装的电脑,无法保证”在精确的第 1000 个时钟周期把某个引脚拉高”这件事;而一片几块钱的 Cortex-M4 单片机(MCU,Microcontroller Unit,微控制器),可以。

再问一个让几乎所有嵌入式新手都愣住的问题:单片机上电之后,CPU 执行的第一条指令从哪来?是 main() 吗?

不是。在 main() 的第一行执行之前,一段你没写过的代码已经跑完了:它初始化了变量、设好了时钟、甚至决定了你的程序会不会跑飞。这段代码出了问题,你的症状可能是”LED 疯狂乱闪”或”上电即死”,而你盯着自己的 C 代码一行都看不出毛病。

这篇文章以应用最广的 32 位内核之一 Cortex-M4 为例,分三层往上爬:先让它跑起来,再看 main 之前发生了什么,最后想明白——为什么这个世界非要设计成这样。

L1:先让它跑起来——”点灯”是嵌入式界的 Hello World

你在第一层,目标是把工具链跑通、点亮一个 LED,对 MCU 开发”在干什么”有个不跑偏的认识。

先建立一个地图感。MCU 和 PC 最大的区别是:PC 是”一块 CPU 插在一个可以插很多东西的主板上”,MCU 是”CPU、内存、外设挤在同一颗芯片里”。一颗 STM32F4(Cortex-M4 内核 + 厂商外设)里面有:

  • 内核(Cortex-M4):真正执行指令的部分,ARM 公司设计,授权给芯片厂
  • Flash(几百 KB 到几 MB):存你的程序,断电不丢
  • SRAM(几十到几百 KB):程序运行时的变量、栈
  • 外设(GPIO、UART、定时器、ADC、SPI……):替 CPU 干活的”部门”

注意内存单位:PC 时代你以 GB 计,这里以 KB 计。一颗 Cortex-M4 常见配置是 1MB Flash + 192KB SRAM。这不是”穷”,而是设计目标不同——后面 L3 会讲。

点灯的最小代码

以 STM32F407(经典的 Discovery 开发板)为例,点亮一个 LED 的”人肉版”代码长这样:

1
2
3
4
5
6
7
8
9
10
11
12
#define RCC_AHB1ENR (*(volatile unsigned int*)0x40023830)
#define GPIOD_MODER (*(volatile unsigned int*)0x40020C00)
#define GPIOD_ODR   (*(volatile unsigned int*)0x40020C14)

int main(void)
{
    RCC_AHB1ENR |= 1u << 3;   /* 打开 GPIOD 的时钟 */
    GPIOD_MODER |= 1u << 24;  /* PD12 设为输出模式 */
    GPIOD_ODR   |= 1u << 12;  /* PD12 输出高电平,LED 亮 */
    while (1)
        ;
}

先别管 volatile 是什么(L2 讲)。看这段代码的结构:点亮一个 LED,做的事情是往三个固定的内存地址写值。没有 API,没有驱动框架,没有 pinMode()——就是写地址。

这就是 MCU 开发的第一课:所谓”操作外设”,本质是往芯片手册里约定好的地址读写数据。芯片手册(datasheet + reference manual,动辄一千多页)里最核心的内容之一,就是一张张”寄存器地址表”:哪个地址、哪个位、控制什么。上面三行的依据是:STM32F407 手册说 0x40023830 是时钟使能寄存器 RCC_AHB1ENR,它的第 3 位管 GPIOD;0x40020C00 是 GPIOD 的模式寄存器……以此类推。

一个你现在就能做的实验

没有开发板也能”编译”这段代码。打开浏览器访问 Compiler Explorer(一个在线编译器,左边写代码右边看汇编),右上角编译器选 ARM GCC,编译选项填 -mcpu=cortex-m4(告诉编译器按 Cortex-M4 的指令集生成代码)。

把上面 main 里的三行寄存器操作贴进去(while(1); 可以留着)。先预测一下:“点亮一个 LED”会被翻译成多少条机器指令?

答案是四条左右:

ldr   r3, =0x40023830      ; 拿到地址
ldr   r2, [r3]             ; 从这个地址读出旧值
orr   r2, r2, #0x8         ; 置位第 3 位
str   r2, [r3]             ; 写回去

(后两行寄存器操作各对应一组同样的读-改-写,编译器可能略有优化。)

一次”硬件操作”在 CPU 眼里就是一次普通的内存读写。这个观察是整篇文章的地基,L3 会回到它。

真实项目不会这样写

人肉查手册写地址,学习和调试时很爽,真实项目里没人这么干。工程上的写法是 STM32Cube HAL 这类库:

1
HAL_GPIO_WritePin(GPIOD, GPIO_PIN_12, GPIO_PIN_SET);

HAL(Hardware Abstraction Layer,硬件抽象层)替你把”哪个引脚对应哪个地址哪个位”的查表工作做掉了,代价是一层函数调用和更多的 Flash 占用。此外还有 CMSIS(ARM 官方的内核头文件和标准接口)夹在中间。

于是 MCU 开发的工具链长这样:

1
2
3
4
5
你的代码 + HAL/CMSIS
    → arm-none-eabi-gcc 交叉编译(在 PC 上编译,给 ARM 用)
    → 产出 ELF → 转 bin/hex
    → 烧录器(ST-Link / J-Link,通过 SWD 两线接口)写进 Flash
    → OpenOCD + GDB 可以在线打断点、看寄存器

“交叉编译”是嵌入式的第一个行话:编译器和目标芯片不是一个架构,你在 x86 的 PC 上生成 ARM 的机器码。这一整套流程跑通、LED 亮起来的那晚,是每个嵌入式工程师的成人礼。

到这里你已经会点灯了。但这解释不了几个每天都在发生的现象——

L2:main 之前发生了什么

到这一层,问题变了:不再问”怎么写”,而是问”底下发生了什么”。三个问题:程序从哪开始跑、volatile 为什么不能省、CPU 怎么”边等边干”。

上电后的第一口奶:向量表

Cortex-M4 上电(或复位)后,硬件做了一件极其简洁的事:

  1. 从地址 0x00000000 读一个 4 字节数,装进栈指针 SP——不执行,纯粹装载
  2. 从地址 0x00000004 读一个 4 字节数,当作函数地址跳过去执行——这就是复位处理函数 Reset_Handler

地址 0 开始的这块区域叫向量表(vector table)。它不是指令,是一张”电话簿”:第 0 项是初始栈顶地址,第 1 项是复位后跳转的地址,第 2 项起是各种中断/异常处理函数的入口地址。

Reset_Handler 是芯片厂提供的启动代码(startup 文件,通常是汇编或编译器自带的 C 启动例程)干的活:

1
2
3
4
5
Reset_Handler:
    把 .data 段的初始值从 Flash 复制到 SRAM   (全局变量的初值)
    把 .bss 段在 SRAM 里清零                  (未初始化的全局变量)
    调用 SystemInit()                         (配置时钟、FPU 等)
    调用 main()

现在你能解释两个新手噩梦了:

  • 为什么全局变量有初值却”不占 Flash 也占 Flash”:初值必须存在 Flash 里(断电不丢),上电后由启动代码搬到 SRAM 才变成”活的变量”。这就是 .data 段——它同时在两个地方存在。
  • 为什么栈指针要从向量表第 0 项装:因为 C 程序一刻也离不开栈(局部变量、函数调用),而调用 main 之前就可能已经需要栈了。硬件先装 SP 再跳转,保证了任何 C 代码执行前栈已经就绪。

顺便拆穿一个 PC 时代的直觉:在 Cortex-M4 上解引用 NULL 指针(读写地址 0)会怎样?读——不会崩,你会读到向量表的内容(地址 0 是合法的 Flash);写——写不进只读的 Flash:STM32F4 上只是悄悄置一个错误标志、数据被丢弃,换些芯片才会直接 HardFault。而 PC 上你熟悉的”段错误”,是操作系统 + MMU(内存管理单元)替你挡的,不是 CPU 天生会报错。MCU 上没人替你挡,错误的代价是”读到垃圾值悄悄算错”或”程序跑飞”。

固定内存地图:一张全芯片通用的门牌系统

上面那些魔法般的地址(0x40023830、0xE000ED00……)不是随便定的。Cortex-M 系列有一张固定的内存地图,所有芯片厂都必须遵守:

地址区间是什么
0x00000000 – 0x1FFFFFFFCode 区(Flash,向量表在这)
0x20000000 – 0x3FFFFFFFSRAM
0x40000000 – 0x5FFFFFFF外设(各芯片厂自己的外设排在这)
0xE0000000 – 0xFFFFFFFF内核私有外设(NVIC、SysTick、调试组件)

类比一下:这栋楼里客房(Flash)、储物间(SRAM)、各部门办公室(外设)、物业办公室(NVIC)的门牌号区间是整条街统一规划的。不管你住哪家芯片厂的”楼”,物业办公室永远在同一个位置——0xE000E100 永远是中断使能寄存器。这就是同一份 CMSIS 代码能通吃所有 Cortex-M 芯片的原因。

volatile:编译器不是你肚子里的蛔虫

回到点灯代码里那个 volatile。做个实验:在 Compiler Explorer 里把 volatile 删掉,编译选项改成 -O2,先预测再观察——

预测:没区别?volatile 只是”易变的”的意思,删了顶多风格不好?

实际:while 循环里的轮询代码整段消失。编译器看到”这个变量从未被写过,你在循环里反复读它”,直接把循环优化掉了;对外设寄存器的重复写也可能被合并。LED 停在某个状态,或干脆不亮。

volatile 的真实含义是告诉编译器:这个地址背后是硬件,它的值可能在你的代码流之外被改变(外设状态、中断),每次都老老实实读写,不许缓存、不许合并、不许优化掉。在 MCU 开发里,所有外设寄存器都必须 volatile。

但要小心一个常见误伤:volatile 不等于原子。一个 volatile int i,在主循环里 i++、在中断里也 i++,照样会丢更新——i++ 是”读、加、写”三条指令,中断可能插在中间。PC 上你以为的多线程竞争问题,在 MCU 上一个中断就能复现,而且更隐蔽(只在特定时序下丢一次数)。

轮询、中断、DMA:CPU 的三种”等”法

点完灯,真正的开发需求来了:每秒闪一次。最朴素的写法:

1
2
3
4
5
while (1) {
    GPIOD_ODR ^= 1u << 12;   /* 翻转 LED */
    for (volatile int i = 0; i < 100000; i++)
        ;                     /* 延时:纯靠数数 */
}

这叫轮询(polling)+ 死等延时。能用,但 CPU 全程被”数数”占满,别的事一件干不了——你点了外卖之后搬个凳子坐在楼下等,等一整天,饭等到了,你也废了。

于是有了中断(interrupt):用硬件定时器计时,时间到了,硬件”敲门”通知 CPU。CPU 收到后自动把当前现场(R0–R3、R12、LR、PC、xPSR 这八个寄存器)压栈,然后从向量表里查出你的处理函数地址跳过去执行,完事自动恢复现场继续原来的活。相当于外卖到了快递员给你打电话,你把手头的事标记个书签去开门,回来接着干。

Cortex-M4 的中断延迟是确定的 12 个时钟周期——记住这个数字,L3 它是主角。这里的 NVIC(Nested Vectored Interrupt Controller,嵌套向量中断控制器)就是物业:谁家来电(哪个外设申请中断)、优先让谁接(优先级)、能不能插队(抢占),全由它硬件级裁决。

更狠的是 DMA(Direct Memory Access,直接内存访问):连”敲门”都省了,让一个专职搬运工直接在外设和内存之间搬数据,搬完一整块才通知 CPU 一次。相当于你把收快递这件事整个外包给了公司前台。串口收一张 100KB 的图片,CPU 可以全程在算别的东西。

一个嵌入式工程师的日常,一半是在这三种”等”法之间做选择。到这里你已经懂原理了。但还有一层没揭开——

L3:为什么非得设计成这样

到这一层,问题变成:这些设计是理所当然的吗?换掉行不行?

本质:MCU 开发到底是什么

去掉所有术语,MCU 开发是这样一件事:

拿着一张固定的、写在芯片里的”门牌地址表”,用普通的读写操作直接指挥硬件;没有操作系统替你做抽象和调度,所以每一个微秒的行为都归你负责——也归你掌控。

操作系统时代的编程是”提交申请,等调度”:你 write() 一个文件、sleep() 一段时间,何时真正发生由操作系统说了算。MCU 开发是”直接上岗”:你写一个地址,硬件这一拍就动了;你让它 12 个周期后响应中断,它就是 12 个周期,一个不多。

再追问一层——被否决的替代方案是什么?

方案一:专用 I/O 指令(x86 的路)。 早期 x86 用专门的 IN/OUT 指令访问外设,配独立的 I/O 地址空间。Cortex-M 选择了内存映射 I/O(memory-mapped I/O):外设和内存在同一张地址表上,访问外设用跟访问内存完全相同的 load/store 指令。为什么后者赢了?因为这样一来 C 语言的指针直接就能操作硬件——*(volatile unsigned int*)0x40020C14 就是点灯,不需要任何新语法、新指令;编译器、调试器、链接器全部原样可用。专用指令方案的失败场景也很清楚:每加一种外设能力,指令集就得扩,工具链全得跟着改,而一张地址表只改文档。你 L1 做的那个 Compiler Explorer 实验,就是这个设计决策的直接红利。

方案二:软件查表分发中断(x86 传统中断控制器的路)。 老式方案里,中断来了 CPU 先跳到一个固定入口,跑一段代码去问中断控制器”到底是谁打断的我”,再 switch 跳转。Cortex-M 的 NVIC 把分发做进了硬件:向量表基地址寄存器 + 硬件查表 + 硬件压栈,一步到位,而且多个中断接踵而至时还有”尾链(tail-chaining)”优化——前一个中断刚出、后一个马上进时,省掉重复的出栈入栈。为什么值得花硬件做?因为每次软件分发都是几十个不确定的周期,而 MCU 的立身之本恰恰是确定性。

实时性的真相:确定性,不是速度

这是 MCU 世界最容易被误解的一点,值得单独说。

Cortex-M4 主频常见 84–180MHz,比你 PC 慢两个数量级。但”实时(real-time)”从来不指”快”,指“不迟到”: deadline 之前一定完成。你的 5GHz 电脑做不到这一点——cache miss、操作系统调度、别的进程抢核,延迟是概率分布;M4 的 12 周期中断延迟是一条保证,写进 ARM 架构手册的保证。

打个比方:PC 是随时待命的出租车,快,但你不知道它几分钟到;M4 是定点发车的班车,不算快,但发车时间印在时刻表上。工业控制、电机驱动、安全气囊点火,要的都是班车,不是出租车。

顺着这个视角,很多设计豁然开朗:

  • 为什么哈佛结构(指令和数据走独立的总线):让取指和取数据并行,一个周期干两件事——在没几级流水线、没 cache 的世界里,这是最便宜的并行
  • 为什么 Cortex-M4 专门带 DSP 乘加指令和单精度 FPU:电机控制(FOC 算法)、音频处理这些”确定性 + 中等算力”的场景,正好落在”PC 太不确定、DSP 芯片太贵”的缝里,M4 是为这条缝而生的
  • 为什么内存这么小还够用:没有操作系统和动态分配的层层开销,程序贴着硬件需求长。一个精密的电机控制器,全部逻辑编译完也就几十 KB

裸机、RTOS:什么时候需要”小操作系统”

写到这里,一直没出场的东西你可能已经注意到了:操作系统。裸机(bare-metal)程序就是 L1 那样的结构:main 里一个超级大循环(super loop),把所有任务挨个跑一遍,靠中断和状态机拼出”并发”的假象。

任务少时这很优雅。但当”电机控制每 1ms 必须跑一次、按键扫描每 10ms 一次、屏幕刷新每 50ms 一次、还要响应串口命令”堆到一起,手写状态机会迅速长成谁都不敢碰的意大利面。这时你引入 RTOS(实时操作系统,比如 FreeRTOS):它把每个任务写成独立的”看起来在并行”的死循环,内核按优先级调度切换。RTOS 的本质一句话:把”等”这件事从你的代码里拿走,交给调度器——你写 vTaskDelay(100) 让出 CPU 一百毫秒,而不是空转一百毫秒。

代价是 RAM(每个任务一个栈)、Flash(内核代码)和新的坑(优先级反转、栈溢出)。决策可以很朴素:

你的处境建议
任务 ≤3 个,时序关系简单裸机 + 状态机,别上 RTOS
多任务 + 各有 deadlineRTOS
深度睡眠为主、事件稀疏(电池设备)裸机 + 中断唤醒,RTOS 反而费电

收尾地图

把整篇文章收进一张速查表:

场景用什么一句话理由
低速外设、偶尔读一次轮询简单,CPU 反正闲着
事件驱动、要及时响应中断硬件敲门,12 周期必达
大块数据搬运(ADC 连续采样、串口收图)DMA让专业搬运工干,CPU 只看结果
多任务各有 deadlineRTOS把”等”交给调度器
确定性要求极端苛刻裸机 + 中断优先级少一层软件,少一分不确定

三个自测问题,能答上来说明这三层你真的爬过了:

  1. 上电后 CPU 干的第一件事是执行指令吗?(答案在 L2 的向量表:不是,是装载栈指针——想想为什么必须是它)
  2. 把外设寄存器定义里的 volatile 删掉,-O2 下会发生什么?volatile 能保证 i++ 不被打断吗?(答案在 L2 的两段:编译器优化会动手;不能,volatile 管优化不管原子性)
  3. 为什么说 Cortex-M4”慢但实时”,你的 PC”快但不实时”?(答案在 L3:实时是确定性的承诺,不是峰值速度)

下一站:真正跑一块板子。买一块百元级的 STM32F4 Discovery 或一块几十块的正版 STM32 开发板,把本文的点灯代码烧进去,然后用 GDB 连上 OpenOCD,在 Reset_Handler 处打断点——亲眼看看 main 之前的世界。之后再看 FreeRTOS 的任务切换代码(就是故意触发一次异常来换栈),你会对着本文 L2 的向量表会心一笑;再往后是 CMSIS-DSP 和电机控制,那是 Cortex-M4 这颗内核被设计出来的真正理由。

本文由作者按照 CC BY 4.0 进行授权