关注+星标公众号,不容错过精彩

有单片机背景的工程师转向嵌入式 Linux 时,都会经历一段"明明查得到资料,但就是写不好"的阶段。当然现在写代码,很多都被AI替代了,这个本章节不提这个了。根本原因不是缺少某个知识点,而是底层的开发模型变了,但思维方式还没跟上。
这篇文章试图把这些思维变化说说。
单片机开发中,操作硬件的路径极短:
// 直接操作寄存器,没有任何中间层RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;GPIOA->CRL &= ~(0xF << 20);GPIOA->CRL |= (0x3 << 20);GPIOA->ODR |= (1 << 5);知道每一行在做什么,知道时序,知道副作用。硬件是确定的,代码和硬件之间是直接映射关系。
Linux 在硬件和应用之间,插入了整套驱动框架:
应用程序 ↓ 系统调用(open/read/write/ioctl)VFS(虚拟文件系统) ↓字符设备 / 块设备 / 网络设备驱动 ↓总线模型(platform / I2C / SPI / USB) ↓硬件寄存器这套分层不是为了让你写起来麻烦,而是为了解决几个单片机不需要面对的问题:
1. 多进程并发访问同一硬件: 驱动层要做互斥、引用计数,保证不会有两个进程同时乱操作同一个外设。
2. 硬件换了,上层代码要不要全改? Linux 的总线-设备-驱动模型把"硬件描述"和"驱动逻辑"分离。换一块板子,改设备树,驱动代码不动。
3. 设备树(DTS): 设备树是硬件的"说明书",描述板子上有哪些外设、接在哪个总线上、中断号是多少。内核启动时读取 DTS,自动匹配并加载对应驱动。
/* 设备树描述一个 I2C 传感器 */&i2c1 { sensor@48 { compatible = "ti,tmp102"; /* 匹配驱动的关键字 */ reg = <0x48>; /* I2C 地址 */ interrupt-parent = <&gpio1>; interrupts = <7 IRQ_TYPE_LEVEL_LOW>; };};驱动代码里通过 compatible 字符串匹配设备,内核自动完成绑定,不需要硬编码任何地址。
真正的挑战在于:当驱动行为不符合预期时,你需要能顺着这条链路向下追——从应用的系统调用,到 VFS,到驱动,到寄存器操作,每一层都可能出问题。
单片机(含 RTOS)的调度是确定的:中断响应时间可以精确到微秒级,任务优先级严格执行,不会有"系统突然卡一下"的情况。这是因为整个软件栈都在你的掌控之内。
标准 Linux 内核使用 CFS(完全公平调度器),目标是所有进程公平地分享 CPU,而不是保证某个任务的最大延迟。
这意味着:
// 这段代码在单片机上精确,在标准 Linux 上不保证struct timespec ts = {0, 1000000}; // 1msnanosleep(&ts, NULL);// 实际可能睡了 1ms,也可能睡了 5ms,取决于调度器心情实际测量标准 Linux 的调度抖动,往往能达到几毫秒甚至几十毫秒。对于工业控制、电机驱动这类场景,这是不可接受的。
方案一:PREEMPT_RT 补丁
将内核大部分不可抢占的临界区改为可抢占,让高优先级任务能更快得到响应。打上补丁后,调度延迟可以降低到几十微秒量级。
# 查看内核是否支持 RTuname -a# 带有 PREEMPT_RT 的内核版本号会包含 rt 字样# Linux 5.15.x-rt...方案二:双核异构(AMP)架构
现在很多 SoC 同时集成了 Cortex-A(跑 Linux)和 Cortex-M(跑 RTOS 或裸机),分别处理不同实时等级的任务。RT 任务给 M 核,Linux 负责通信、UI、网络。
方案三:隔离 CPU 核心
通过 isolcpus 启动参数,把某个核心从内核调度器中隔离出来,专门给实时任务使用。
# 在 bootargs 中添加,隔离第3个核心(从0开始)isolcpus=2链接脚本划好区域,程序运行时知道每个变量的精确物理地址,没有碎片,没有换页,没有意外。
每个进程都认为自己独占一个完整的地址空间(32位系统是4GB),实际的物理内存映射由 MMU 和内核管理。
这带来了几个需要重新理解的概念:
1. malloc 返回的不是物理地址,也不一定立刻分配物理内存
Linux 使用"延迟分配"策略。malloc(1MB) 成功返回,但物理页面只有在你真正写入时才会被分配(缺页中断触发)。
char *buf = malloc(1024 * 1024); // 成功,但物理内存还没分配memset(buf, 0, 1024 * 1024); // 这里才真正触发物理页面分配这意味着:malloc 成功不代表内存真的够用,实际写入时可能触发 OOM。
2. 内存泄漏在长期运行的嵌入式产品上是致命的
// 一个典型的泄漏场景:错误处理路径忘记释放int process_data(void) { char *buf = malloc(4096); if (!buf) return -1; if (read_sensor(buf) < 0) { return -1; // ← 忘记 free(buf),每次出错泄漏 4KB } free(buf); return 0;}设备跑了一个月,内存慢慢涨满,OOM killer 把进程杀掉,问题难以复现。
3. 如何追踪内存问题
# 查看进程内存使用cat /proc/<pid>/status | grep -i vm# Valgrind 检测泄漏(开发阶段)valgrind --leak-check=full ./myapp# mtrace:glibc 内置的内存追踪export MALLOC_TRACE=/tmp/mtrace.log./myappmtrace ./myapp /tmp/mtrace.log4. 栈溢出的行为完全不同
单片机栈溢出通常导致数据被覆盖,行为诡异。Linux 进程有栈保护(guard page),栈溢出会触发 SIGSEGV,产生 core dump,反而更容易定位。
单片机的并发依赖两件事:中断(处理异步事件)+ 主循环或 RTOS 任务(处理业务逻辑)。整个系统的并发结构一目了然,竞争关系可以完全掌控。
线程 vs 进程的选择
Linux 的线程(pthread)和进程都能实现并发,但代价和适用场景不同:
进程:独立地址空间,崩溃不影响其他进程,通信成本高(需要 IPC)线程:共享地址空间,通信成本低,一个线程崩溃可能带倒整个进程对于嵌入式产品,如果某个模块的稳定性不确定(比如解析外部数据),用独立进程隔离是更稳健的选择。
IO 多路复用:epoll 的正确使用姿势
// 监控多个文件描述符,任何一个有数据就处理int epfd = epoll_create1(0);struct epoll_event ev;ev.events = EPOLLIN;ev.data.fd = uart_fd;epoll_ctl(epfd, EPOLL_CTL_ADD, uart_fd, &ev);ev.data.fd = socket_fd;epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &ev);// 事件循环while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == uart_fd) handle_uart(); else if (events[i].data.fd == socket_fd) handle_socket(); }}这比"每个 fd 一个线程阻塞等待"的方式,在大量并发连接时效率高得多。
进程间通信(IPC)的选型
竞争条件(Race Condition)的隐蔽性
单片机调试的工具链相对简单,但嵌入式 Linux 产品一旦出现问题,需要能从多个维度同时分析。
# strace:追踪进程的所有系统调用strace -p <pid>strace -e trace=read,write,ioctl ./myapp# ltrace:追踪库函数调用ltrace ./myapp# 查看进程打开了哪些文件/设备ls -la /proc/<pid>/fd当问题出在内核层(驱动、调度),ftrace 是关键工具:
# 追踪特定内核函数echo function > /sys/kernel/debug/tracing/current_tracerecho 'spi_sync' > /sys/kernel/debug/tracing/set_ftrace_filterecho 1 > /sys/kernel/debug/tracing/tracing_on# ... 运行一段时间 ...cat /sys/kernel/debug/tracing/trace程序崩溃时,保存现场比打印日志更有价值:
# 允许生成 core dumpulimit -c unlimitedecho '/tmp/core.%e.%p' > /proc/sys/kernel/core_pattern# 事后用 gdb 分析gdb ./myapp /tmp/core.myapp.1234(gdb) bt # 查看崩溃时的调用栈(gdb) info locals # 查看局部变量# perf:采样 CPU 热点perf record -g ./myappperf report# 查看调度延迟cyclictest -t1 -p 80 -n -i 1000 -l 10000# 专门用于测量实时系统的调度抖动核心转变:调试能力需要覆盖从应用到内核的完整链路,而不是只盯着自己写的那层代码。
从"编译一个 .hex"到"构建完整的 Linux 系统"
单片机的构建很简单:一个工程,编译链接,生成一个固件文件,烧进去,完事。
需要独立构建和集成的部分包括:
Bootloader(U-Boot) ↓Linux Kernel + 设备树 ↓根文件系统(rootfs) ├── 基础库(glibc / musl) ├── 系统服务(systemd / busybox) ├── 应用程序 └── 配置文件Buildroot vs Yocto 的本质区别
Buildroot:简单直接,适合资源受限、定制化程度高的场景。整个系统从源码编译,结果可控,但灵活性有限。
Yocto:基于层(layer)的架构,可复用性强,适合需要维护多个硬件平台的团队,但学习曲线很陡。
这两个这里就不具体介绍。
从单片机到嵌入式 Linux,不是"学几个命令"的事,而是需要在六个维度上重建认知:
每一个转变单独拿出来都不算太难,难的是同时把这六件事都想清楚,并在实际项目中贯通使用。
往期推荐
GitLab 内部部署与多人协作使用指南
GitLab 局域网安装记录(国内源)
I3C:I2C 的下一代总线
嵌入式 Linux 实时性入门:PREEMPT_RT 到底解决了什么问题?
STM32 擦除失败,解析Option Bytes陷阱
嵌入式里的NPU它到底能干什么?
Linux 中的 buffer 与cache
嵌入式工程师都该懂一点安全
面试技巧-2.注意要点
面试技巧-1.STAR法则
嵌入式中动态库路径的那些坑
嵌入式开发中常用存储认识
A/B 分区 OTA 升级机制与 U-Boot 实现
嵌入式系统 OTA 固件升级
戳“阅读原文”一起来充电吧!