第 4 章 进程、服务与设备¶
上位机上同时跑着内核线程、systemd 与其管理的 unit、桌面会话、ROS 2 节点、串口 daemon、图形应用——排"服务起不来"、"进程被杀"、"设备打不开"三类问题,需要一张从进程模型 → 服务生命周期 → 设备与权限的连续视图。本章从 fork / execve 讲到 systemctl、从 /dev 的 uid / gid / mode 讲到 sudoers;systemd-analyze 与 journalctl 作为日常运维两条主线在此立住;udev 规则语法与调试留给第 9 章、cgroups 深层留给第 7 章实时调优,此处只把概念边界画清。
1. 进程模型、信号与调度¶
1.1 进程、线程与 PID¶
Linux 内核眼中只有 task:进程与线程是同一种 task_struct,差别在于 clone 时共享哪些资源(地址空间、文件描述符表、信号处理)。PID 是 task 的整数标识,init(Ubuntu 上是 systemd)是 PID 1,负责收尸僵尸与传播信号。
ps -ef # 全系统进程,System V 格式
ps aux # BSD 格式,含 CPU/MEM 占比
pstree -ap # 树状视图,看进程父子链
ls /proc/<pid>/ # 单个进程的内核视图(fd/、maps/、status、cgroup)
top # 交互式实时视图
僵尸(Zombie, Z)与孤儿(Orphan):僵尸是子进程已 exit 但父进程还没 wait() 收尸,ps state 显 Z——僵尸不占资源(只留退出码在进程表)但会堆积。孤儿是父进程先没了,进程被 PID 1 收养;systemd 的 Restart=always 语义正依赖这一点。判据:僵尸数持续增长说明父进程没在处理 SIGCHLD,kill -TERM <parent> 让父进程退出后 init 自动收尸。
1.2 语言级并发:线程怎么开、共享了什么、代价是什么¶
「进程、线程与 PID」的内核视角只回答"线程是什么";写代码时的问题是"语言把哪些风险摊到开发者头上"。C++:标准库自 C++11 起自带线程与内存模型(跨编译器行为一致),线程是真 OS 线程(task_struct,即「进程、线程与 PID」的实现层),同地址空间共享一切——代价是未同步的共享写即数据竞争(data race,未定义行为):
##include <atomic>
##include <mutex>
##include <thread>
##include <vector>
long counter = 0; // 无保护共享:++counter 是「读-改-写」三步,线程交错即丢更新
std::atomic<long> acounter = 0; // 原子:单变量计数用这个,无锁
std::mutex mu; // 锁:保护跨变量的不变式(多字段一起改)
void bump() {
for (int i = 0; i < 200000; ++i) {
++counter; ++acounter;
{ std::lock_guard<std::mutex> lk(mu); /* 临界区 */ }
}
}
int main() {
std::vector<std::thread> ts;
for (int i = 0; i < 4; ++i) ts.emplace_back(bump);
for (auto& t : ts) t.join();
}
把这段用 -std=c++17 -O2 -pthread 编成多线程程序跑起来:无保护的 counter 多次运行都稳定少加几个百分点(期望 800000,实际落在 79~79.9 万),而 acounter 恒等于 800000——竞态的表现形式是波动与丢更新,不是每次都错。锁的两个高频坑:死锁(两线程按相反顺序抢同一对锁)与实时回路内加锁(优先级翻转——PREEMPT_RT 用优先级继承缓解,机制在第 7 章;控制线程运行期禁分配与锁策略见 ROS2-note 第 6 章「驱动开发与运动控制」的实时性一节)。
Python:threading.Thread 底层同样是 OS 线程,但 GIL(全局解释器锁,Global Interpreter Lock)——CPython 解释器级的互斥锁,同一时刻只有一个线程执行字节码,阻塞系统调用期间自动释放。因此 CPU 密集任务并行在线程里不会加速(拿不到多核并行),按任务类型选 concurrency 模型(并发模型三选一):
| 模型 | 适用 | 代价 |
|---|---|---|
threading |
I/O 并发(串口、网络、传感器读取——阻塞时 GIL 释放) | CPU 密集无效;共享数据仍需 threading.Lock |
multiprocessing |
CPU 密集并行(绕开 GIL,每进程独立解释器) | 数据经 pickle 跨进程传递,大数据量时传输成本主导;进程启动开销 |
asyncio |
海量 I/O 连接(单线程事件循环) | 阻塞调用会卡住整个循环,需全链路一致 |
定性结论:拿一个每轮成本恒定的纯计算任务,串行跑一遍记时,再把总量拆成 4 份分给 4 线程与 4 进程各跑一次——4 线程不会比串行快(因 GIL 争用与上下文切换反而略慢),4 进程接近线性提速。选型看总墙钟时间而非单任务耗时;进程侧另含 fork / 启动开销。注意测试负载要选与分块无关的定宽运算,若用会不断变大的大整数(如斐波那契),单任务拆小后总量随之变小,比值会被超线性成本污染而说明不了 GIL。绝对值随硬件变化,比值才是稳定的定性结论。自由线程(free-threading,PEP 703)在 3.13+ 是可选构建的试验特性;Ubuntu 两基线仓库的 Python(3.10 / 3.12)默认构建不含它。
两章之外的接口:ROS 2 的 MultiThreadedExecutor 以回调而非裸线程为并发单位,共享状态(传感器缓冲、map)在 callback group 间并行即竞态——框架侧的互斥与重入判据归 ROS2-note 第 2 章「通信机制与计算图」的 executor 与 callback group 一节,本节是它的语言地基。
1.3 信号¶
信号是内核给进程的唯一异步通信。日常要认的六个:
| 信号 | 编号 | 语义 |
|---|---|---|
SIGHUP |
1 | 终端断开;守护进程约定用它重载配置 |
SIGINT |
2 | Ctrl+C,请求中断 |
SIGQUIT |
3 | Ctrl+\,中断并产生 core dump |
SIGKILL |
9 | 强杀,不可捕获 |
SIGTERM |
15 | 优雅终止,默认可捕获 |
SIGSTOP |
19 | 暂停,不可捕获;配 SIGCONT 恢复 |
kill -TERM 12345 # 默认信号(15)
kill -9 12345 # 最后手段
pkill -f 'ros2 run demo' # 按命令行匹配
判据:SIGKILL 无法被捕获、无法释放资源,用之前先试 SIGTERM;systemd 单元的 TimeoutStopSec 就是给 SIGTERM→SIGKILL 之间的宽限期。
1.4 nice 与调度策略¶
Linux 的调度器(EEVDF 于 6.6 起、此前 CFS)默认按 nice 值加权:-20(最高优先)到 19(最低)。nice 只影响 SCHED_OTHER 的相对权重,不改变策略。
nice -n 10 ./batch_job # 起新进程时降权
renice -n -5 -p 12345 # 改现有进程(需 root)
chrt -p 12345 # 查看当前调度策略与优先级
chrt -f 50 ./rt_loop # 用 SCHED_FIFO 优先级 50 起实时任务
三种常用策略:
SCHED_OTHER(0):默认,非实时。SCHED_FIFO(1):实时,同优先级按 FIFO 排队、不让出 CPU 除非阻塞或被更高优先级抢占。SCHED_RR(2):实时,同优先级轮转时间片。
实时策略下用户态可让整机失联:SCHED_FIFO 优先级 50 的忙循环不阻塞就永远不释放 CPU,同核上优先级低的包括桌面、部分内核线程都会饿到。第 7 章的 CPU 隔离与优先级配置正是为这个约束兜底。默认 RLIMIT_RTTIME 与 /proc/sys/kernel/sched_rt_runtime_us 提供 safety net——超支时内核强制抢占实时任务。
进程、服务与设备三层视图及纵切关系:
flowchart LR
task["内核 task_struct<br/>进程 / 线程"]
subgraph svc["systemd(PID 1)"]
unit["unit:.service / .timer"]
dep["依赖与 target<br/>Wants / After"]
end
dev["/dev 设备节点<br/>uid / gid / mode"]
task -->|"systemd 拉起并监管"| svc
svc -->|"打开设备"| dev
sudo["sudoers / udev 规则"] -.->|"决定谁能打开"| dev
2. systemd 单元模型¶
2.1 单元类型与依赖¶
systemd 用单元(unit)描述"要维持的状态",每类单元一种文件扩展名:
| 类型 | 后缀 | 用途 |
|---|---|---|
| Service | .service |
一个或多个进程的启停 |
| Socket | .socket |
内核 socket 由 systemd 持有,激活时拉起对应服务(socket activation) |
| Timer | .timer |
cron 的现代替代,依赖持久化更明确 |
| Target | .target |
分组锚点(如 multi-user.target),替代旧 runlevel |
| Mount | .mount |
一个 fstab 条目对应一个 mount 单元 |
| Device | .device |
由 udev 生成,反映 /sys 中的设备 |
| Slice | .slice |
cgroups 层级分组,为资源限制铺路 |
依赖关系有六种,日常只关心:
Requires=/Wants=:硬依赖 / 软依赖(Wants 失败不拖倒本单元)。After=/Before=:顺序依赖,不含"必须",与 Requires 独立。常见错例:只写After=network-online.target不写Wants=,服务会在网络还没起时就启动。
单元在四种稳态之间迁移,失败态是第五种:
stateDiagram-v2
state "inactive" as I
state "activating" as A
state "active" as AC
state "deactivating" as D
state "failed" as F
[*] --> I
I --> A: start / 被依赖拉起
A --> AC: ExecStart 成功
AC --> D: stop / restart
D --> I
AC --> F: 退出码非零
F --> A: restart
2.2 最小 service 单元¶
完整单元文件:my-node.service(点击展开)
## /etc/systemd/system/my-node.service
[Unit]
Description=Robot Perception Node
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=robot
Group=robot
WorkingDirectory=/home/robot/ws
EnvironmentFile=-/etc/default/my-node
Environment="ROS_DOMAIN_ID=42"
ExecStart=/usr/bin/bash -c 'source /opt/ros/humble/setup.bash && exec ros2 run my_pkg my_node'
Restart=on-failure
RestartSec=2s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
要点:
Type=:simple(默认,进程就是 ExecStart 本身)、exec(等 execve 成功即认为启动)、forking(传统 daemon 双 fork)、oneshot(跑一次就退,配 timer 常用)、notify(配合sd_notify)。ROS 2 launch 一般simple或exec。ExecStart=前加-:ExecStart=-/bin/foo表示该命令失败不视为单元失败。exec内建:bash -c '... exec cmd'让 shell 被 cmd 替换、只留一个 PID,systemd 信号能直达。不加exec时 TERM 给 shell、cmd 收不到。WantedBy=multi-user.target+systemctl enable:enable建立软链到/etc/systemd/system/multi-user.target.wants/,与start是两回事。- 服务不 source
.bashrc(写成服务就起不来/取不到变量的高频成因):ExecStart跑在非登录、非交互 shell 里,.bashrc与.profile都不生效。环境变量走Environment=/EnvironmentFile=(如上面的ROS_DOMAIN_ID),PATH 精简需写绝对路径或Environment="PATH=..."。一个易错点:即便在ExecStart里显式source ~/.bashrc也拿不到变量——Ubuntu 的.bashrc顶部有case $- in *i*) ;; *) return;; esac守卫,非交互 shell 一进来就return,根本走不到后面的export。所以服务的环境只应靠单元里的Environment=,不依赖用户 rc 文件。
2.3 systemctl 日常命令¶
sudo systemctl daemon-reload # 改单元文件后必须 reload
sudo systemctl enable --now my-node # 开机自启 + 立即启动
systemctl status my-node # 摘要 + 最近日志片段
systemctl is-active my-node # 只回 active/inactive/failed,脚本友好
sudo systemctl restart my-node # 停再启
sudo systemctl edit --full my-node # 打开编辑器改单元
journalctl -u my-node -f # 追这条单元的实时日志
systemctl list-dependencies my-node # 依赖树
systemd-analyze verify my-node.service # 静态检查
排障判据:服务起不来先按双线看——systemctl status 与 journalctl -u <name> -e 拿用户态;dmesg 拿内核态(权限、SELinux、AppArmor、OOM)。第 10 章「排障方法论」把这条链固化。
判据:服务起不来先看两处
systemctl status <unit> 看状态与退出码,journalctl -u <unit> -b 看本次启动的日志;先定位失败发生在哪个阶段,再改配置。
3. 定时器、目标与 journal¶
3.1 .timer 替代 cron¶
## /etc/systemd/system/cleanup.service
[Unit]
Description=Weekly cleanup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/cleanup.sh
## /etc/systemd/system/cleanup.timer
[Unit]
Description=Run cleanup weekly
[Timer]
OnCalendar=Mon *-*-* 03:00:00
RandomizedDelaySec=600
Persistent=true # 错过时刻(比如机器关机期间)下次开机即补跑
[Install]
WantedBy=timers.target
OnCalendar= 语法支持 hourly / daily / weekly / Mon,Fri 09:00 / *:-*-1,15 00:00:00 等。systemd-analyze calendar 'Mon *-*-* 03:00:00' 校验写法。相比 cron 的优势:Persistent 补跑、日志走 journal、失败有 unit 状态可查。
3.2 target 与运行级¶
multi-user.target ≈ 旧 runlevel 3(无 GUI)、graphical.target ≈ runlevel 5。日常只需知道:
systemctl get-default看当前默认。systemctl isolate multi-user.target临时切运行级(调试 GUI 卡死有用)。rescue.target/emergency.target用于修复。
3.3 journal 查询与持久化¶
journalctl 读 systemd 的 journald 后端。默认是易失的(放 /run/log/journal、tmpfs、重启即空);开启持久化写 /var/log/journal:
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
## 常用查询
journalctl -u my-node -b # 本次启动该单元
journalctl -u my-node --since "1 hour ago" --until "10 min ago"
journalctl -p err -b # 本次启动 priority ≥ error
journalctl -k # 内核消息(等价 dmesg 但更完整)
journalctl --disk-usage # 占了多少
sudo journalctl --vacuum-size=200M # 缩到 200 MB
sudo journalctl --vacuum-time=7d # 只留 7 天
配置在 /etc/systemd/journald.conf:SystemMaxUse=、MaxFileSec=、Storage=persistent|auto|volatile|none。判据:vacuum 定期跑或用 logrotate 之外的第二道防线;Storage=auto 在有 /var/log/journal 时持久化、没有则易失(第 10 章维护节再回接)。
4. 设备节点与权限¶
4.1 /dev 里的三类设备¶
- 字符设备:按字节流访问,
crw-。串口/dev/ttyUSB0、看门狗/dev/watchdog、GPIO 字符设备。 - 块设备:整块读写,
brw-。磁盘/dev/sda、分区/dev/sda1。 - 伪设备:内核虚拟出来的,无对应硬件。
/dev/null/dev/zero/dev/random/dev/urandom。
ls -l /dev/ttyUSB0 /dev/sda /dev/null
## crw--w---- 1 root dialout 188, 0 ... /dev/ttyUSB0 ← 字符设备,group dialout
## brw-rw---- 1 root disk 8, 0 ... /dev/sda ← 块设备,group disk
## crw-rw-rw- 1 root devnull 1, 3 ... /dev/null
读写权限位就是访问控制:普通用户要读 /dev/ttyUSB0,进 dialout 组即可,不需要 root。同理:
| 设备类型 | 常见组 | 用途 |
|---|---|---|
| USB 串口 | dialout |
底盘、传感器串口 |
| 输入设备 | input |
键盘、手柄、/dev/input/event* |
| 视频设备 | video |
/dev/video* 相机(第 9 章) |
| GPIO | gpio(若装 libgpiod) |
板端 IO(第 9 章) |
| I²C / SPI | i2c / spi(板端) |
传感器总线 |
加组不生效要重登:sudo usermod -aG dialout $USER 只写组数据库,当前 shell 的 supplementary group 列表不会刷新;要么新开 SSH 会话、要么 newgrp dialout。这是"我明明加了组怎么还是权限拒绝"的高频成因。
4.2 udev 概览¶
/dev 里的节点是 udev(systemd 组件)依据内核 uevent + 规则动态创建的。规则文件在 /etc/udev/rules.d/*.rules(自定义)与 /lib/udev/rules.d/*.rules(发行版默认),数字前缀小者优先。写规则的完整语法与 udevadm test 调试在第 9 章展开;本章只需知道:设备能访问与否是"规则文件 + 组的组合结果",chmod 666 是错误解——每次插拔后权限都会回到规则里的默认值。
4.3 sudo 与 sudoers¶
sudo 让普通用户以他身份(默认 root)执行命令。配置在 /etc/sudoers(编辑必须用 visudo,语法错会导致所有 sudo 失效):
## 常规:wheel 组全体可 sudo 但要密码
%sudo ALL=(ALL:ALL) ALL
## 现场排障:允许 robot 用户免密重启某个服务,其他仍需密码
robot ALL=(root) NOPASSWD: /usr/bin/systemctl restart my-node.service
robot ALL=(root) NOPASSWD: /usr/bin/journalctl -u my-node*
权限最小化判据:无人值守上位机不希望任何操作都要密码,但绝不给 NOPASSWD: ALL——那等于 root。写成具体命令白名单 + 具体参数模式,配合第 10 章 SSH 密钥登录做双闸门。
sudo -l 查自己能用哪些 sudo;sudo --non-interactive cmd 在无 tty 场景(脚本、systemd ExecStart 前置检查)常用。
坑:串口权限靠组,不靠 chmod 777
设备节点的权限是 root:dialout 660 这类约定,把自己加进 dialout 组再重新登录即可;chmod 777 既不持久也绕过了审计。
5. cgroups v2 资源限制概览¶
Ubuntu 22.04 起默认 cgroup v2(unified)、到 24.04(systemd 255)已完成全量切换;systemd 从 249 到 255 单元语法保持兼容,但 user@.service 默认选项与 journal 上限实现细节有变化,双基线写结论时一并注明。cgroups 是"给进程分组并限制 CPU / 内存 / IO"的机制,systemd 的 Slice=、Docker、Kubernetes 都建在这上面。
systemd-cgls # 树状看层级
systemd-cgtop # top 视图
cat /sys/fs/cgroup/system.slice/memory.current
在单元文件里直接限:
[Service]
CPUQuota=200% # 相当于最多吃两个核
MemoryHigh=1G # 超过时开始回收
MemoryMax=2G # 硬上限,触发 OOM
IOWeight=50 # 相对权重(1–100)
判据:给 ROS 2 节点服务加 CPUQuota 与 MemoryMax 能防止单节点跑飞吃光整机;实时控制回路所在的核心不能同时被 cgroups 限到,否则第 7 章的隔离会失效。资源限制的深层机制、实时任务的豁免规则在第 7 章展开。
6. 要点¶
- 进程 = task,PID 1 是 systemd;僵尸不占资源但堆积要收尸,
SCHED_FIFO忙循环能饿死同核的一切;chrt与RLIMIT_RTTIME是实时策略的入门与兜底。 - 语言级并发按任务类型选模型:C++
std::thread是真 OS 线程、共享即需mutex/atomic(未同步的共享写是数据竞争);Python 的 GIL 让线程只适合 I/O 并发,CPU 并行用multiprocessing;实时回路内加锁需考虑优先级翻转。 - systemd 单元 6 类(Service / Socket / Timer / Target / Mount / Device),依赖分 Requires/Wants 与 After/Before 两维;改完单元
daemon-reload,enable是开机自启、start是立即起。 .timer用OnCalendar=与Persistent=true替代 cron,日志走 journal、失败有 unit 状态可查。- journal 默认易失;开持久化建
/var/log/journal,--vacuum-size/--vacuum-time定期收缩。 /dev权限看uid/gid/mode:串口进dialout、输入进input、视频进video;加组要重登才生效,chmod 666每次插拔会失效;udev 规则语法在第 9 章、sudo 白名单最小化在第 10 章现场运维。- cgroups v2 由 systemd 挂载与调度:单元里
CPUQuota=/MemoryMax=是最小成本防跑飞;实时任务与资源限制的冲突在第 7 章解决。