Maxw的小站

Maxw学习记录

1049. 最后一块石头的重量 II

  • 目标:将所有石头分成两组,使两组总重量尽量接近,最后剩下的石头重量就是两组重量差的绝对值。
  • 转化为01背包问题:每个石头只能选一次,背包容量为所有石头重量和的一半,尽量让一组的重量最大且不超过容量。
  • 为什么倒序遍历
    • 在01背包中,正向遍历会导致同一个物品被多次使用(即变成完全背包),而倒序遍历可以保证每个物品只用一次。
    • 例如:stones = [2, 3, 5],如果正向遍历,dp[4]=dp[2]+2=4,石头2被用了两次,违背了01背包的约束。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum = 0;
int hlf_sum = 0;
for(int s: stones){
sum += s;
}
hlf_sum = sum / 2; // 注意hlf_sum / 2是向下取整的

vector<int> dp(1501, 0); // 一定要保证dp[j]是可以比较的,所以我们选择重量为j
for(int i = 0; i<stones.size();i++){
for(int j = hlf_sum; j>=stones[i]; j--){
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}

return sum - dp[hlf_sum] * 2;
}
};

🚦 信号量 (Semaphore) - 停车场模型

口诀“计数资源,进出加减”
- 像一个停车场管理员: - wait() = 抬起杆子放车进(资源-1) - post() = 落下杆子放车出(资源+1) - 核心:管数量(剩余车位)

1
2
3
4
5
6
7
8
9
// 超简版信号量实现(C++20标准)
#include <semaphore>
std::counting_semaphore<5> parking(5); // 5个车位

void car(int id) {
parking.acquire(); // 等车位(杆子抬起)
std::cout << "Car " << id << " parked\n";
parking.release(); // 开走(杆子落下)
}

🚥 条件变量 (Condition Variable) - 奶茶店模型

口诀“条件不满足,解锁等通知”
- 像奶茶店取餐: - wait() = 没奶茶时放下号码牌睡觉 - notify() = 奶茶做好后叫号唤醒顾客 - 核心:管条件(奶茶是否做好)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 条件变量基本结构
std::mutex mtx;
std::condition_variable cv;
bool milktea_ready = false; // 关键条件

// 顾客线程
void customer() {
std::unique_lock lock(mtx);
cv.wait(lock, []{ return milktea_ready; }); // 没奶茶就睡
std::cout << "Got milktea!\n";
}

// 店员线程
void staff() {
{
std::lock_guard lock(mtx);
milktea_ready = true; // 奶茶做好
}
cv.notify_all(); // 大喊:奶茶好了!
}

🔑 对比记忆表

特征 信号量 条件变量
核心思想 资源计数器(还剩几个?) 条件检查(事情发生了吗?)
操作 wait()减资源,post()加资源 wait()休眠,notify()唤醒
锁依赖 自带锁机制 必须搭配mutex使用
适用场景 连接池/限流 任务协调/事件等待
生活比喻 停车场进出系统 奶茶店叫号系统

🧠 记忆技巧

  1. 信号量记数字
    • 看到“允许N个线程访问” → 选信号量
    • 代码看到.acquire()/.release() → 信号量
  2. 条件变量查状态
    • 看到“当XX发生时唤醒” → 选条件变量
    • 代码看到cv.wait(锁, lambda条件) → 条件变量
  3. 必背两句话
    • 信号量:“资源不够就阻塞,释放资源就加一”
    • 条件变量:“条件不满足时解锁等待,满足时加锁执行”

🌰 1分钟实战场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 场景:仅允许3个线程同时下载
std::counting_semaphore down_sem(3);

void download() {
down_sem.acquire(); // 占位
// ...下载操作...
down_sem.release(); // 释放
}

// 场景:线程等待服务器启动
std::condition_variable cv;
bool server_ready = false;

void client() {
std::unique_lock lk(mtx);
cv.wait(lk, []{ return server_ready; }); // 等就绪
// ...连接服务器...
}

void server() {
// ...启动服务...
server_ready = true;
cv.notify_all(); // 通知所有客户端
}

用条件变量实现信号量

即用条件变量(condition variable)和互斥锁(mutex)来模拟信号量的 P/V 操作。

如果多个资源场景,各线程对资源的要求条件复杂,容易发生死锁的话,建议使用条件变量的实现

例如哲学家吃饭问题,每个哲学家都先拿起自己右手边的叉子,就会产生死锁,没有哲学家再能拿起左手边的叉子并吃饭。

  • P 操作(等待/获取资源)
    1
    2
    3
    4
    5
    6
    7
    void P(sem_t *sem) {
    hold(&sem->mutex) {
    while (!COND)
    cond_wait(&sem->cv, &sem->mutex);
    sem->count--;
    }
    }
    • 先加锁(hold mutex),保证对信号量的操作是原子的。
    • 如果条件不满足(如 count <= 0),就用条件变量等待(cond_wait),并自动释放 mutex,直到被唤醒。
    • 被唤醒后再次加锁,检查条件,满足则 count--,表示获取一个资源。
  • V 操作(释放/归还资源)
    1
    2
    3
    4
    5
    6
    void V(sem_t *sem) {
    hold(&sem->mutex) {
    sem->count++;
    cond_broadcast(&sem->cv);
    }
    }
    • 加锁,count++,表示释放一个资源。
    • 用条件变量唤醒所有等待线程(cond_broadcast)。

用信号量实现条件变量

用信号量实现条件变量的 wait/broadcast 操作:

1. wait 函数

1
2
3
4
5
6
7
8
9
void wait(struct condvar *cv, mutex_t *mutex) {
mutex_lock(&cv->lock);
cv->nwait++;
mutex_unlock(&cv->lock);

mutex_unlock(mutex);
P(&cv->sleep);
mutex_lock(mutex);
}
  • cv->nwait++:记录当前有多少线程在等待条件变量(进入等待队列)。
  • mutex_unlock(mutex):释放主互斥锁,允许其他线程进入临界区修改条件。
  • P(&cv->sleep):在信号量 sleep 上等待(阻塞),直到被唤醒。
  • mutex_lock(mutex):被唤醒后,重新获得主互斥锁,继续执行。

2. broadcast 函数

1
2
3
4
5
6
7
8
void broadcast(struct condvar *cv) {
mutex_lock(&cv->lock);
for (int i = 0; i < cv->nwait; i++) {
V(&cv->sleep);
}
cv->nwait = 0;
mutex_unlock(&cv->lock);
}
  • mutex_lock(&cv->lock):保护 nwait 变量,防止并发修改。
  • for (int i = 0; i < cv->nwait; i++) V(&cv->sleep);:唤醒所有等待的线程(每个线程对应一次 V 操作)。
  • cv->nwait = 0;:重置等待计数。
  • mutex_unlock(&cv->lock):释放锁。

信号量实现条件变量出现的问题

  • 线程T2调用wait(),此时已经把自己标记为等待(nwait++),但还没真正阻塞(还没执行P)。
  • 这时,因为其它线程的操作,另一个线程T1调用broadcast(),唤醒了所有等待线程(V操作),并继续执行。
  • 此时nwait已经归零,T2就会睡死

从理论上来说,我们希望nwait++, mutex_unlock和P操作是原子性的,并且mutex_unlock和P操作不可以互换位置(会在睡时一直持有锁,死锁了),但是实际上我们需要操作系统用类似条件变量的机制帮我们实现wait和release的原子性,这样就套娃了

HTTP1.0和HTTP1.1的区别

  1. 🚗 连接方式:从“一次一清”到“多次复用”
    • HTTP 1.0: 默认短连接。浏览器每请求一个资源(图片、CSS、JS等),都要和服务器建立一次新的TCP连接,用完后立即关闭。
    • HTTP 1.1: 默认持久连接。浏览器和服务器建立一个TCP连接后,可以在这个连接上连续发送多个请求和接收多个响应通过Connection: keep-alive头来实现持久连接。
  2. 🏠 Host头:从“单间平房”到“共享公寓”
    • HTTP 1.0: 请求中没有Host请求头。服务器认为一个IP地址就对应一个网站(一个“主机”)。想象成:一个门牌号只住一户人家。
    • HTTP 1.1: 请求中必须包含Host请求头。指明请求要访问的是服务器上的哪个虚拟主机/域名。这使虚拟主机托管成为可能(一台服务器托管多个不同域名的网站)。想象成:一个门牌号(服务器IP)里住了好几户人家(不同网站),送快递必须写明收件人姓名(Host头)才知道送到哪户。
  3. 🧊 缓存控制:从“简单指示”到“精细管理”
    • HTTP 1.0: 主要用If-Modified-Since/Expires(绝对过期时间)和Pragma: no-cache(简单不缓存)控制缓存。功能简单,不够灵活。
    • HTTP 1.1: 引入了更强大、更精细的Cache-Control。可以指定max-age(相对过期时间)、no-cache(需重新验证)、no-store(禁止存储)、public/private等众多指令。(缓存控制能力大大增强!)也可使用If-None-Match/Etag
  4. ⏳ 带宽优化:从“全有全无”到“按需取件”
    • HTTP 1.0: 如果下载大文件中断,必须从头开始重新下载
    • HTTP 1.1: 支持断点续传。使用Range请求头可以指定只请求资源的一部分(如从第1000个字节开始),服务器用206 Partial Content状态码和Content-Range响应头返回部分内容。想象成:下载电影断网了,续播时可以从上次断开的地方继续下载,不用重头看。
  5. 🚀 请求处理:从“排队等”到“连续发”(理论上)
    • HTTP 1.0: 客户端必须等前一个请求的响应完全返回后,才能发送下一个请求。想象成:收费站,前一辆车完全通过栏杆落下再抬起,后一辆车才能进。
    • HTTP 1.1: 支持管道化。客户端可以在一个连接上连续发送多个请求,而不用等待每个响应。服务器必须按照收到请求的顺序返回响应。想象成:收费站允许连续进多辆车,但出来的顺序必须和进去的顺序一致。(理论上提升速度,但实践中问题多,较少用)
  6. 📦 分块传输:从“等菜齐”到“边做边上”
    • HTTP 1.0: 服务器必须在知道资源的完整长度Content-Length)后才能开始发送响应。对于动态生成的内容,需要等全部生成完才能发送。
    • HTTP 1.1: 引入了分块传输编码。服务器可以将响应分成多个“块”发送,并在最后一个块发送完毕后标记结束。使用Transfer-Encoding: chunked响应头。想象成:厨房边做菜边端上桌,不用等所有菜都做好。(提升动态内容响应速度,减少延迟)
  7. 📝 错误处理:从“模糊”到“更明确”
    • HTTP 1.1: 新增了一些状态码,提供更精确的错误信息:
      • 409 Conflict:请求与服务器当前状态冲突。
      • 410 Gone:资源被永久删除(比404 Not Found更明确)。
      • 100 Continue:客户端发送大请求体前,先询问服务器是否愿意接收,服务器同意(100 Continue)后再发完整请求。避免带宽浪费。
    • 响应格式: HTTP 1.1 要求响应行中必须包含原因短语(如 HTTP/1.1 200 OK),而 1.0 只要求状态码是可选的(虽然实践中通常都有)。

HTTP2.0与HTTP1.1的区别?

一句话记忆:HTTP/2 = 更快、更智能、更省资源!

1️⃣ 传输方式:从“文本排队”到“二进制分帧”

  • HTTP/1.1:
    • 纯文本格式发送请求和响应(比如 GET /index.html HTTP/1.1)。
    • 多个请求必须排队串行处理(即使开了管道化也有队头阻塞问题)。
    • 想象: 邮局用明信片寄信,一次只能寄一张,必须等回信才能寄下一张,效率低。✉️➡️✉️➡️✉️
  • HTTP/2:
    • 将数据拆分成更小的二进制帧(Frame)(头部帧 HEADERS + 数据帧 DATA)。
    • 同一个连接上,多个请求/响应的帧可以混合发送、并行传输,互不阻塞!
    • 想象: 快递公司把包裹拆成小件,打上标签,通过立体分拣通道同时运输,到目的地再组装。📦📦📦 → 🚚💨

👉 核心价值:彻底解决队头阻塞,大幅提升并发效率!

2️⃣ 连接方式:从“多路排队”到“真·多路复用”

  • HTTP/1.1:
    • 虽然支持持久连接(一个TCP连多个请求),但响应必须按顺序返回(队头阻塞)。
    • 浏览器通常开 6~8个TCP连接 并行请求资源(但占用资源多)。
  • HTTP/2:
    • 一个TCP连接 上即可实现 成百上千个流的并行传输(每个流是一个请求/响应)。
    • 帧自带流ID标识,接收方能按ID重组数据,无需排队等待!
    • 想象: 从多条乡间小路 → 升级成一条双向十车道高速路,所有车辆(请求)畅通无阻。🛣️🚗🚙🚕

👉 核心价值:一个连接解决所有请求,省资源、低延迟!

3️⃣ 头部信息:从“重复臃肿”到“高效压缩”

  • HTTP/1.1:
    • 每次请求都携带大量重复的文本头部(如Cookie、User-Agent),不压缩。
    • 浪费带宽(尤其小文件请求时,头部可能比数据还大)。
  • HTTP/2:
    • 使用 HPACK 算法压缩头部
      • 客户端和服务端维护“头部字典”,相同头部只传索引;
      • 用霍夫曼编码压缩文本。
    • 头部大小减少 30%~90%
    • 想象: 从每次寄信都手写完整地址 → 改用电子二维码扫码寄件,地址库自动匹配。📮→📲

👉 核心价值:大幅节省带宽,加快小资源加载!

4️⃣ 服务器主动推送:从“被动响应”到“主动送货”

  • HTTP/1.1:
    • 服务器只能被动响应客户端请求。
    • 浏览器需解析HTML后,再请求CSS/JS/图片等依赖资源。
  • HTTP/2:
    • 服务器可主动推送客户端可能需要的资源(如CSS/JS)!
    • 客户端可缓存推送内容,下次直接使用。
    • 想象: 点外卖时,商家不仅送米饭,还主动附赠了筷子和纸巾(你知道你一定会需要)。🍚+🥢+🧻

👉 核心价值:减少请求往返次数,加速页面渲染!

5️⃣ 优先级与流量控制:更智能的资源调度

  • HTTP/2:
    • 客户端可为请求标记优先级(如CSS > 图片),服务器优先处理高优先级流。
    • 支持精细的流量控制(基于每个流控制传输速率)。
  • HTTP/1.1: 无法真正实现优先级调度(依赖浏览器启发式策略)。

⚠️ 注意:

  1. HTTP/2 未加密,但所有主流浏览器只支持 HTTP/2 Over TLS(即 HTTPS)。
  2. HTTP/2 解决了应用层队头阻塞,但 TCP 层仍有队头阻塞(丢包会阻塞所有流)。
  3. 这是 HTTP/3(基于QUIC/UDP)要解决的下一代问题!

HTTP3.0有了解过吗?

🚀 核心一句话:HTTP/3 = 抛弃TCP!拥抱QUIC! > 解决 HTTP/2 的终极痛点:TCP 的队头阻塞!

1️⃣ 底层协议革命:从 TCP 到 QUIC(基于UDP)

  • HTTP/1.1 & HTTP/2: 都跑在 TCP 协议之上。
    • TCP 问题: 如果传输中丢了一个包,后续所有包都要等待重传(即使它们属于不同请求),这就是 TCP 队头阻塞
    • 想象: 快递车队走一条单行道,前一辆车抛锚,后面所有车都得堵着等(无论是不是同一批货物)。🚚❌🚛🚗🚐
  • HTTP/3: 彻底抛弃 TCP,改用全新协议 QUIC(Quick UDP Internet Connections),运行在 UDP 之上。
    • QUIC 优势: 每个请求/响应流是独立传输的,丢包只影响当前流,其他流畅通无阻!
    • 想象: 快递改用无人机配送,每件包裹独立飞行路线,一个包裹出问题,其他包裹照常送达。✈️📦➡️🏠 | ✈️📦➡️🏠 | 💥📦❌ | ✈️📦➡️🏠
      👉 核心价值:彻底消灭传输层队头阻塞,网络波动时性能大幅提升!

2️⃣ 建连速度飞跃:0-RTT 与 1-RTT 握手

  • HTTP/1.1 & HTTP/2(TCP+TLS):
    • 首次连接需 TCP 三次握手(1.5 RTT) + TLS 握手(1~2 RTT) = 总计 2~3.5 RTT 延迟才能发送数据。
  • HTTP/3(QUIC):
    • 首次连接:1-RTT 握手(QUIC 将传输和加密握手合并)。
    • 重连用户:0-RTT 握手!客户端缓存了服务器密钥,首次请求可直接带上加密数据。
    • 想象: 进地铁站——旧方式:先排队买票(TCP握手),再安检(TLS握手);新方式:刷脸直接进(0-RTT)!🎫→🛂→🚇 → 😃🔜🚇
      👉 核心价值:首次访问更快,重复访问“闪电启动”!

3️⃣ 连接迁移:网络切换不断线

  • HTTP/1.1 & HTTP/2:
    • 连接绑定 IP + 端口 + TCP协议。切换网络(如WiFi→4G)会导致IP变化,连接必须重建!
  • HTTP/3(QUIC):
    • 使用 连接ID(Connection ID) 唯一标识连接。
    • 切换网络时,只要客户端能通信,连接ID不变,会话无缝延续!
    • 想象: 旧手机卡换手机要重新插卡激活;eSIM卡换手机自动联网,号码不变。📱➡️📱 = ❌ vs 📱➡️📱 = ✅
      👉 核心价值:移动端福音!地铁进隧道、WiFi切5G,视频会议不中断!

4️⃣ 内嵌加密:安全是强制要求

  • QUIC 协议设计之初就强制加密(使用 TLS 1.3)。
  • 没有明文的 QUIC! 所有头部和载荷默认加密。
  • 对比: HTTP/2 的加密(HTTPS)是可选但事实强制,HTTP/3 直接内嵌到协议层。
    👉 核心价值:提升安全性,防止运营商劫持、降低中间设备干扰。

5️⃣ 改进的多路复用 & 头部压缩

  • 多路复用: 继承 HTTP/2 的流多路复用(一个连接并发多个流),且由于基于 QUIC,无队头阻塞
  • 头部压缩: 升级为 QPACK 算法(类似 HTTP/2 的 HPACK,但适应 QUIC 乱序特性)。
    👉 核心价值:在 HTTP/2 高效基础上,更稳定!

416. 分割等和子集

动态规划的正确性: - 每次迭代考虑一个新物品的所有可能组合 - 在使用一维dp数组时,倒序遍历保证状态转移只依赖上一轮结果

原始思路主要问题:

1
2
3
4
5
6
// 伪代码
int cnt = target;
for (遍历每个数字) {
if (cnt >= 当前数字) cnt -= 当前数字; // 强制选择
if (dp[cnt]) ... // 但dp从未更新!
}
贪心选择错误:强制按顺序选择数字,不能处理非连续选择 也可以使用布尔值判断:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
bool canPartition(vector<int>& nums) {
int eq = 0;
for(int n : nums){
eq += n;
}
if(eq % 2 == 1){
return false;
}else{
eq = eq / 2;
}
vector<bool> dp(eq+1, false);
dp[0] = true;
int cnt = eq;
for(int n : nums){
for(int e = eq; e>=n; e--){
if(dp[e - n]){
dp[e] = true;
}
if(dp[eq])return true;
}
}
return false;
}
};

UAPI 文件位置

问题背景:
内核头文件中,内联函数往往需要引用其他头文件的结构体或常量,但这些头文件之间又存在相互依赖,导致无法直接引用,只能用 #define 代替,降低了代码质量。

解决方案:
David 提出将内核头文件中的用户空间 API 内容(即用户空间可见的定义)拆分到新的 uapi/ 子目录下的对应头文件中。这样做有以下好处:

  • 简化内核专用头文件,减少体积。
  • 明确区分用户空间与内核空间的 API,减少头文件间复杂的相互依赖。
  • 便于追踪用户空间 API 的变更,方便 C 库维护者、脚本语言绑定、测试、文档等相关项目。

拆分方法:
一般头文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 头部注释 */

#ifndef _XXXXXX_H
#define _XXXXXX_H

[用户空间定义]

#ifdef __KERNEL__

[内核空间定义]

#endif /* __KERNEL__ */

[用户空间定义]

#endif /* _XXXXXX_H */
  • 所有未被 #ifdef __KERNEL__ 包裹的内容,移动到 uapi/ 目录下的新头文件。
  • #ifdef __KERNEL__ 内的内容保留在原头文件,但移除 #ifdef#endif
  • 头部注释保留并复制到新文件。
  • 原头文件需添加 #include <include/uapi/path/to/header.h>,放在已有 #include 之后。
  • 若原文件没有 #ifdef __KERNEL__,则直接重命名为 uapi/ 文件。

技术实现要点

  • 头文件结构调整:将所有未被 #ifdef __KERNEL__ 包裹的内容迁移到 uapi/ 目录下的新头文件,原文件只保留内核专用部分,并通过 #include 引用新的 uapi 头文件。
  • 自动化脚本辅助:由于头文件风格多样,David Howell 编写了大量 shell/Perl 脚本自动完成拆分,并对特殊情况通过预处理标记进行“辅导”。
  • 兼容性与构建保证:拆分后,内核和用户空间的构建流程保持兼容,确保包含关系和功能不变。

社区讨论与挑战

  • 大规模变更的审查难题:一次性修改数千文件、数十万行代码,难以人工逐行审查。社区建议主要审查思路和脚本实现,并通过自动化构建和对比二进制产物来验证正确性。
  • 头文件包含路径的争议:有开发者质疑 uapi 头文件是否应包含内核头文件,实际实现中为兼容性和构建需要,uapi 头文件在内核构建时会包含内核头文件,但用户空间只见到 uapi 头文件。
  • 隐式依赖与编译优化:有建议借此机会清理隐式包含、优化编译速度,但这属于更大范围的重构,超出了本次拆分的目标。
  • 自动化工具的局限:与 Java 等语言不同,C 语言的预处理器和头文件机制更为复杂,自动化脚本难以覆盖所有边界情况,仍需人工干预和后续维护。

系统调用源码指引(以 ARM 架构为例)

系统调用的实现高度依赖于具体架构,包括调用方式和可用的系统调用种类。以下是 ARM 架构下与系统调用密切相关的核心源码文件:

  • include/linux/syscalls.h
    提供所有内核系统调用的架构无关的前向声明。该文件定义了内核内部调用系统调用函数的接口。

  • arch/arm/include/uapi/asm/unistd.h
    定义了 ARM 架构下系统调用号的相关内容。

  • arch/arm/kernel/entry-common.S
    提供 ARM 架构下系统调用的入口汇编实现。

  • arch/arm/tools/syscall.tbl
    负责将系统调用号及其对应的函数地址注册到 ARM 硬件的系统调用表中。

此外,以下文件虽然与系统调用无关,但涉及系统启动(这是内核执行的另一种方式):

  • init 目录
    包含内核初始化相关代码。

  • init/main.c
    提供了 start_kernel 函数,这是 Linux 内核启动后执行的第一个 C 语言函数,且一旦调用永不返回。在此之前,内核仅通过架构相关的汇编代码和固件运行。
    (参考:《Linux 设备驱动》第16章对 main.c 和 start_kernel 的介绍)

系统调用的实现

在 Linux 中,实际实现一个系统调用时,不需要关心系统调用处理器(system call handler)的具体行为。因此,向 Linux 添加一个新的系统调用相对容易。难点在于设计和实现系统调用本身,而将其注册到内核则很简单。下面是编写新系统调用的主要步骤。

实现系统调用的步骤

  1. 明确目的
    首先要定义系统调用的用途。系统调用应该只做一件事。Linux 不鼓励“多路复用”系统调用(即通过一个参数让同一个系统调用做完全不同的事情),如 ioctl() 就是反面教材。

  2. 参数、返回值和错误码设计
    系统调用应有简洁、清晰的接口,参数数量应尽量少。其语义和行为必须稳定,不能随意更改,因为已有应用会依赖这些行为。要有前瞻性,考虑未来是否需要扩展功能,是否能在不破坏兼容性的前提下修复 bug。很多系统调用会设计一个 flag 参数,用于将来扩展功能(不是用来多路复用行为,而是为了兼容性和可扩展性)。

  3. 接口设计要通用、可移植
    不要让接口过于局限当前用途。系统调用的用途可能会变化,但其本质目的应保持不变。要考虑可移植性,不要假设特定架构的字长或字节序。Unix 的设计哲学是“提供机制,不规定策略”。

  4. 关注可移植性和健壮性
    编写系统调用时要考虑未来的可移植性和健壮性。Unix 的基本系统调用经受住了时间考验,几十年后依然适用。


参数校验

系统调用必须严格校验所有参数,确保其有效和合法。系统调用在内核空间运行,如果用户能随意传递无效参数,系统的安全和稳定性会受到威胁。

  • 例如,文件 I/O 系统调用要检查文件描述符是否有效;进程相关函数要检查 PID 是否有效。每个参数都要验证其正确性,防止进程请求访问其无权访问的资源。

  • 指针参数的校验尤为重要。如果进程能传递任意指针给内核,可能会让内核访问本不该访问的数据(如其他进程的数据或内核空间数据)。因此,在内核跟随用户空间指针前,必须确保:

    1. 指针指向用户空间内存,不能让进程让内核访问内核空间。
    2. 指针指向的是本进程的地址空间,不能访问其他进程的数据。
    3. 读操作时内存必须可读,写操作时必须可写,执行操作时必须可执行,不能绕过内存访问权限。
  • 内核提供了两种方法来进行这些检查和数据拷贝,内核代码绝不能直接跟随用户空间指针,必须使用以下两种方法之一:

    • copy_to_user():用于将数据从内核空间写入用户空间。参数分别为用户空间目标地址、内核空间源地址、拷贝字节数。
    • copy_from_user():用于从用户空间读取数据到内核空间。参数分别为内核空间目标地址、用户空间源地址、拷贝字节数。
  • 这两个函数在出错时返回未拷贝的字节数,成功时返回 0。系统调用遇到这种错误时,通常返回 -EFAULT

下面以一个简单的系统调用 silly_copy() 为例,说明如何在内核中安全地从用户空间读取和写入数据。这个系统调用的功能是:将用户空间 src 指向的数据拷贝到 dst,中间通过内核缓冲区作为中转。虽然实际用途不大,但有助于理解 copy_from_user()copy_to_user() 的用法。

核心代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SYSCALL_DEFINE3(silly_copy,
unsigned long *, src,
unsigned long *, dst,
unsigned long len)
{
unsigned long buf;
// 从用户空间 src 拷贝 len 字节到内核缓冲区 buf
if (copy_from_user(&buf, src, len))
return -EFAULT;
// 从内核缓冲区 buf 拷贝 len 字节到用户空间 dst
if (copy_to_user(dst, &buf, len))
return -EFAULT;
// 返回拷贝的字节数
return len;
}
- copy_from_user():将用户空间数据拷贝到内核空间,失败时返回未拷贝的字节数,成功返回0。 - copy_to_user():将内核空间数据拷贝到用户空间,失败时返回未拷贝的字节数,成功返回0。 - 如果拷贝失败,系统调用返回 -EFAULT

注意:
这两个函数在数据页不在物理内存时可能会阻塞(如数据被换出到磁盘),此时进程会休眠直到页面被调入内存。

权限检查与能力机制

在早期 Linux 版本中,系统调用如果需要超级用户权限,会用 suser() 检查是否为 root。现在,Linux 使用更细粒度的“能力(capabilities)”机制。通过 capable() 函数检查调用进程是否拥有某项能力。例如:

1
2
if (!capable(CAP_SYS_BOOT))
return -EPERM;
  • capable(CAP_SYS_BOOT) 检查调用者是否有重启系统的权限(CAP_SYS_BOOT)。
  • 超级用户(root)默认拥有所有能力,普通用户默认没有。

reboot() 系统调用部分实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SYSCALL_DEFINE4(reboot, int magic1, int magic2, unsigned int cmd, void __user *, arg)
{
char buffer[256];
// 只允许超级用户重启系统
if (!capable(CAP_SYS_BOOT))
return -EPERM;
// 检查 magic 参数,防止误操作
if (magic1 != LINUX_REBOOT_MAGIC1 ||
(magic2 != LINUX_REBOOT_MAGIC2 &&
magic2 != LINUX_REBOOT_MAGIC2A &&
magic2 != LINUX_REBOOT_MAGIC2B &&
magic2 != LINUX_REBOOT_MAGIC2C))
return -EINVAL;
// ... 省略后续命令处理 ...
}
- 首先检查权限,只有拥有 CAP_SYS_BOOT 能力的进程才能重启系统。 - 然后检查 magic 参数,只有传入特定的“魔数”才允许执行,防止误操作。

能力列表可参考 <linux/capability.h>,每种能力对应不同的系统资源访问权限。

系统调用上下文(System Call Context)

如第3章所述,在执行系统调用期间,内核处于进程上下文(process context)。此时,current 指针指向当前任务(即发起系统调用的进程)。

  • 在进程上下文中,内核可以休眠(比如系统调用阻塞或显式调用 schedule()),并且是完全可抢占的
    • 能够休眠意味着系统调用可以使用大部分内核功能,这极大简化了内核编程(相比中断处理程序,中断处理程序不能休眠,功能受限)。
    • 可抢占意味着当前任务可能被其他任务抢占,新的任务可能会执行同一个系统调用,因此系统调用实现必须可重入,这和多核并发下的同步问题类似。

当系统调用返回时,控制权回到 system_call(),最终切换回用户空间,继续执行用户进程。

系统调用注册的最后步骤

系统调用代码写好后,将其注册为正式系统调用的过程很简单:

  1. 在系统调用表中添加条目
    • 对于每个支持该系统调用的架构,都要在系统调用表(如 entry.S)末尾添加一项。表中每一项的位置(从0开始)就是系统调用号。例如,第10项的系统调用号是9。
    • 表示例(部分):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      ENTRY(sys_call_table)
      .long sys_restart_syscall /* 0 */
      .long sys_exit
      .long sys_fork
      .long sys_read
      .long sys_write
      .long sys_open /* 5 */
      ...
      .long sys_foo /* 新增的系统调用 */
    • 新系统调用自动获得下一个可用的系统调用号(如338)。
  2. 在 <asm/unistd.h> 中定义系统调用号
    • 每个架构都要在对应的 <asm/unistd.h> 文件中添加宏定义。例如:
      1
      #define __NR_foo 338
  3. 将系统调用编译进内核镜像
    • 系统调用必须编译进核心内核镜像(不能作为模块)。通常把实现代码放在 kernel/ 目录下相关的文件中,比如 sys.c。如果和调度相关,可以放在 kernel/sched.c。

示例:实现 foo() 系统调用

  • 在 kernel/sys.c 中实现:
    1
    2
    3
    4
    5
    6
    7
    8
    #include <asm/page.h>
    /*
    * sys_foo – 返回每个进程的内核栈大小
    */
    asmlinkage long sys_foo(void)
    {
    return THREAD_SIZE;
    }
  • 编译并启动新内核后,用户空间即可通过系统调用号调用 foo()。

用户空间访问系统调用

通常,C 标准库(如 glibc)会为系统调用提供支持。用户程序只需包含标准头文件并链接 C 库,就可以直接调用系统调用(或调用库函数间接使用系统调用)。但如果你刚刚实现了一个新的系统调用,glibc 很可能还没有为它提供支持!

幸运的是,Linux 提供了一组宏来帮助用户空间访问系统调用。这些宏会设置好寄存器内容并发出陷阱指令。宏的名字为 _syscalln(),其中 n 取 0 到 6,表示系统调用参数的个数。宏需要知道参数个数,以便正确地将参数压入寄存器。

例如,open() 系统调用的原型为:

1
long open(const char *filename, int flags, int mode)
如果没有库支持,可以这样使用宏:
1
2
#define __NR_open 5
_syscall3(long, open, const char *, filename, int, flags, int, mode)
这样,应用程序就可以直接调用 open() 了。

每个宏的参数为 2 + 2 × n:第一个参数是返回类型,第二个是系统调用名,后面依次是每个参数的类型和名字。__NR_open 定义在 <asm/unistd.h>,表示系统调用号。_syscall3 宏会展开为带有内联汇编的 C 函数,自动完成系统调用号和参数的传递,并发出软中断进入内核。只需在应用中写这个宏,就能直接使用 open() 系统调用。

示例:用户空间调用自定义 foo() 系统调用

1
2
3
4
5
6
7
8
9
#define __NR_foo 283
_syscall0(long, foo)

int main() {
long stack_size;
stack_size = foo();
printf("The kernel stack size is %ld\n", stack_size);
return 0;
}

为什么不建议随意实现新系统调用

虽然实现新系统调用很容易,但这并不意味着你应该随意添加。实际上,添加新系统调用要非常谨慎。很多情况下,有更合适的替代方案。

实现新系统调用的优点: - 实现简单,使用方便。 - 在 Linux 上系统调用性能很高。

缺点: - 需要分配一个系统调用号,必须官方分配。 - 一旦进入稳定内核版本,接口就不能随意更改,否则会破坏用户空间应用的兼容性。 - 每个架构都要单独注册和支持该系统调用。 - 系统调用不能直接被脚本调用,也不能直接通过文件系统访问。 - 需要分配系统调用号,难以在主线内核树之外维护和使用。 - 对于简单信息交换,系统调用显得过于繁重。

常见替代方案: - 实现一个设备节点,通过 read()/write() 进行数据交换,使用 ioctl() 操作特定设置或获取信息。 - 某些接口(如信号量)可以用文件描述符表示并进行操作。 - 将信息作为文件添加到 sysfs 的合适位置。

对于许多接口,系统调用确实是正确的选择。但 Linux 一直避免为每个新抽象都添加系统调用,这使得系统调用层非常简洁、稳定,很少有废弃接口。新系统调用增加速度慢,说明 Linux 已经相对稳定且功能完善。

与内核通信

系统调用(system call)在硬件和用户空间进程之间提供了一层抽象,这一层有三个主要作用:

  1. 硬件抽象
    系统调用为用户空间提供了统一的硬件接口。例如,应用程序在读写文件时,无需关心底层的磁盘类型、介质类型,甚至文件所在的文件系统类型。

  2. 系统安全与稳定
    有了内核作为中介,内核可以根据权限、用户等标准仲裁资源访问。这防止了应用程序误用硬件、窃取其他进程资源或对系统造成破坏。

  3. 虚拟化与多任务支持
    用户空间与系统其他部分之间有统一的接口,便于实现进程虚拟化和多任务。如果应用能直接访问系统资源,将难以实现多任务和虚拟内存,更无法保证系统的稳定和安全。

在 Linux 中,系统调用是用户空间与内核交互的唯一合法入口(除了异常和陷阱)。即使是设备文件或 /proc 这样的接口,最终也要通过系统调用访问。值得一提的是,Linux 的系统调用数量比大多数系统要少。

API、POSIX 与 C 库

通常,应用程序是基于用户空间的 API(应用程序编程接口)开发的,而不是直接调用系统调用。这很重要,因为 API 与内核实际提供的接口之间不需要一一对应。API 定义了一组供应用程序使用的编程接口,这些接口可以通过一个系统调用实现,也可以通过多个系统调用,甚至完全不依赖系统调用。这样,同样的 API 可以在不同系统上实现,应用程序无需关心底层实现细节。

在 Unix 世界中,最常见的 API 之一是基于 POSIX 标准的。POSIX 是 IEEE 制定的一系列标准,旨在提供基于 Unix 的可移植操作系统标准。Linux 在适用的地方努力兼容 POSIX 和 SUSv3。

POSIX 很好地体现了 API 与系统调用的关系。在大多数 Unix 系统中,POSIX API 与系统调用高度相关,POSIX 标准本身就是参考早期 Unix 系统接口制定的。但有些非 Unix 系统(如 Windows)也提供了 POSIX 兼容库。

C 库的作用

在 Linux 和大多数 Unix 系统中,C 库(如 glibc)部分实现了系统调用接口。C 库不仅实现了标准 C 库,还实现了系统调用接口,是所有 C 程序的基础。由于 C 语言的特性,其他编程语言也可以很方便地调用 C 库。C 库还实现了大部分 POSIX API。

对应用开发者来说,系统调用的细节并不重要,他们只关心 API;而内核只关心系统调用,至于哪些库函数或应用程序会用到这些系统调用,内核并不关心。不过,内核需要保证系统调用的通用性和灵活性,以适应各种用途。

系统调用(Syscalls)

在 Linux 中,系统调用(syscall)通常通过 C 库中定义的函数进行访问。系统调用可以有零个、一个或多个参数(输入),并可能产生一个或多个副作用,例如写文件或将数据复制到指定指针。系统调用还会返回一个 long 类型的值,用于表示成功或错误——通常(但并非总是)负值表示错误,返回值为 0 通常(但也不是总是)表示成功。

当系统调用出错时,C 库会将一个特殊的错误码写入全局变量 errno。可以通过如 perror() 这样的库函数将 errno 转换为可读的错误信息。

系统调用有明确的行为定义。例如,getpid() 系统调用被定义为返回当前进程的 PID。其内核实现大致如下:

1
2
3
4
SYSCALL_DEFINE0(getpid) 
{
return task_tgid_vnr(current); // 返回 current->tgid
}

注:你可能会好奇为什么 getpid() 返回的是 tgid(线程组ID)?在普通进程中,TGID 等于 PID;而在线程中,同一线程组的所有线程 TGID 相同,这样所有线程调用 getpid() 时返回相同的 PID。

需要注意的是,定义只规定了行为,具体实现方式由内核决定,只要结果正确即可。SYSCALL_DEFINE0 是一个宏,用于定义无参数的系统调用(0 表示参数个数)。展开后类似于:

1
asmlinkage long sys_getpid(void)
  • asmlinkage 修饰符告诉编译器只从栈上获取参数,这是所有系统调用都需要的修饰符。
  • 返回类型为 long,是为了兼容 32 位和 64 位系统。即使用户空间定义为 int,内核中也返回 long。
  • 命名约定:内核中系统调用的实现函数名为 sys_xxx(),如 getpid() 对应 sys_getpid()

系统调用号(System Call Numbers)

在 Linux 中,每个系统调用都有唯一的系统调用号(syscall number),用于标识具体的系统调用。用户空间进程执行系统调用时,实际上是通过系统调用号来指定调用哪个系统调用,而不是用名字。

系统调用号非常重要,一旦分配就不能更改,否则已编译的应用程序会出错。同样,如果某个系统调用被移除,其编号也不能被回收,否则旧程序会调用到错误的系统调用。Linux 提供了一个“未实现”系统调用 sys_ni_syscall(),它只返回 -ENOSYS(表示无效系统调用),用于填补被移除或不可用的系统调用号。

内核通过系统调用表(sys_call_table)维护所有已注册的系统调用。在 x86-64 架构下,这个表定义在 arch/i386/kernel/syscall_64.c 文件中,每个有效的系统调用都分配有唯一的编号。

系统调用性能

Linux 的系统调用比许多其他操作系统更快,这部分归功于 Linux 的上下文切换速度快,进入和退出内核的过程非常简洁高效,系统调用处理器和各个系统调用本身也很简单。

系统调用处理器(System Call Handler)

用户空间的应用程序无法直接执行内核代码,也不能直接调用内核空间的方法,因为内核处于受保护的内存空间。如果应用可以直接读写内核地址空间,系统的安全性和稳定性将无法保证。

因此,用户空间的应用程序必须通过某种方式通知内核,让系统切换到内核态,由内核代表应用程序在内核空间执行系统调用。

进入内核的机制

这种通知内核的机制是一种软件中断:即触发一个异常,系统会切换到内核态并执行异常处理程序。在系统调用的场景下,这个异常处理程序就是系统调用处理器(system call handler)。

  • 在 x86 架构上,定义的软件中断号为 128,通过 int $0x80 指令触发。这会导致系统切换到内核态,并执行异常向量 128(即系统调用处理器)。
  • 系统调用处理器的函数名通常为 system_call(),它是与架构相关的代码(如 x86-64 下在 entry_64.S 汇编文件中实现)。
  • 近年来,x86 处理器增加了 sysenter 指令,这是一种比 int $0x80 更快、更专用的进入内核执行系统调用的方法。内核很快就支持了这种方式。
  • 无论采用哪种方式,核心思想都是:用户空间通过异常或陷阱(trap)进入内核。

指定正确的系统调用

仅仅进入内核空间还不够,因为有很多不同的系统调用,它们都是通过相同的方式进入内核的。因此,必须将系统调用号传递给内核

  • 在 x86 架构上,系统调用号通过 eax 32位寄存器传递。在触发陷阱进入内核前,用户空间会把所需系统调用的编号写入 eax

  • 系统调用处理器读取 eax 的值,判断其有效性(与 NR_syscalls 比较)。如果编号无效,返回 -ENOSYS 错误;否则,通过系统调用表调用对应的系统调用函数:

    1
    call *sys_call_table(,%rax,8)
    > rax和eax均为累加器,区别是rax是64位, eax32位

    这里每个表项 8 字节(64 位),所以用 8 乘以系统调用号定位表项(x86-32 下用 4 乘以系统调用号)。

参数传递

除了系统调用号,大多数系统调用还需要传递一个或多个参数。用户空间必须在陷阱捕获过程中时将参数传递给内核。

  • 最简单的方式是通过寄存器传递参数。在 x86-32 架构下,ebxecxedxesiedi 依次存放前五个参数。
  • 如果参数超过五个,则用一个寄存器传递指向用户空间参数数组的指针。
  • 返回值也通过寄存器传递,x86 下写入 eax

调用系统调用处理器并执行系统调用的流程

1
2
3
4
5
6
7
8
9
+-------------------+         +-------------------+
| User Space | | Kernel Space |
|-------------------| |-------------------|
| Application | | Syscall Handler |
| call read() | | system_call() |
|-------------------| |-------------------|
| C library | | sys_read() |
| read() wrapper | | |
+-------------------+ +-------------------+

  • 应用程序调用 read(),实际上先调用 C 库的 read() 封装函数。
  • C 库的 read() 封装函数通过软中断(如 int $0x80sysenter)进入内核,调用 system_call()
  • system_call() 读取系统调用号和参数,查找并调用内核中的 sys_read() 实现。
  • 执行完毕后,返回值通过寄存器传回用户空间,流程反向返回到应用程序。

可移植性问题(Portability Issues)

特性测试宏(Feature Test Macros)

  • 系统调用和库函数 API 的行为受多种标准规范(如 The Open Group 的 Single UNIX Specification、BSD、System V Release 4 及其接口定义)约束。
  • 为了让头文件只暴露符合某一标准的定义(如常量、函数原型等),可以在编译时定义一个或多个特性测试宏。定义方式有两种:
    1. 在源代码中包含头文件前定义宏:
      1
      #define _BSD_SOURCE 1
    2. 用编译器的 -D 选项定义:
      1
      $ cc -D_BSD_SOURCE prog.c
  • “特性测试宏”这个名字的由来是:实现会通过 #if 判断这些宏的值,决定头文件中哪些特性对应用可见。

常用特性测试宏

这些宏由相关标准规定,适用于所有支持这些标准的系统:

  • _POSIX_SOURCE
    定义后暴露符合 POSIX.1-1990 和 ISO C (1990) 的定义。已被 _POSIX_C_SOURCE 取代。
  • _POSIX_C_SOURCE
    • 值为 1 时,效果同 _POSIX_SOURCE
    • 值 ≥ 199309 时,暴露 POSIX.1b(实时)定义。
    • 值 ≥ 199506 时,暴露 POSIX.1c(线程)定义。
    • 值为 200112 时,暴露 POSIX.1-2001 基础规范(不含 XSI 扩展)。
    • 值为 200809 时,暴露 POSIX.1-2008 基础规范。
  • _XOPEN_SOURCE
    • 定义后暴露 POSIX.1、POSIX.2 和 X/Open (XPG4) 定义。
    • 值 ≥ 500 时,暴露 SUSv2(UNIX 98 和 XPG5)扩展。
    • 值 ≥ 600 时,暴露 SUSv3 XSI(UNIX 03)和 C99 扩展。
    • 值 ≥ 700 时,暴露 SUSv4 XSI 扩展。

glibc 特有的特性测试宏

  • _BSD_SOURCE
    定义后暴露 BSD 定义,同时定义 _POSIX_C_SOURCE=199506。如只定义此宏,部分标准冲突时优先 BSD 定义。
  • _SVID_SOURCE
    定义后暴露 System V 接口定义(SVID)。
  • _GNU_SOURCE
    定义后暴露所有上述宏的定义及 GNU 扩展。

默认行为与宏组合

  • 默认情况下,GNU C 编译器会定义 _POSIX_SOURCE_POSIX_C_SOURCE=200809(或更早版本的 200112/199506)、_BSD_SOURCE_SVID_SOURCE
  • 如果单独定义了某些宏,或用标准模式(如 cc -ansicc -std=c99)编译,则只暴露请求的定义。
  • 多个宏可以叠加定义。例如:
    1
    $ cc -D_POSIX_SOURCE -D_POSIX_C_SOURCE=199506 -D_BSD_SOURCE -D_SVID_SOURCE prog.c
  • <features.h> 头文件和 feature_test_macros(7) 手册页有详细说明。

POSIX.1/SUS 相关宏

  • POSIX.1-2001/SUSv3 只规定了 _POSIX_C_SOURCE_XOPEN_SOURCE 两个宏,要求值分别为 200112 和 600。
  • POSIX.1-2008/SUSv4 要求值分别为 200809 和 700。
  • 设置 _XOPEN_SOURCE=600 应包含 _POSIX_C_SOURCE=200112 的所有特性,SUSv4 也有类似要求。

示例代码与函数原型中的特性测试宏

  • 手册页会说明使用某个常量或函数声明时需要定义哪些特性测试宏。
  • 本书示例代码可用默认 GNU C 编译器选项或如下方式编译:
    1
    $ cc -std=c99 -D_XOPEN_SOURCE=600
  • 书中每个函数原型都会注明需要定义哪些特性测试宏。
  • 手册页有更详细的宏需求说明。

系统数据类型(System Data Types)

在 UNIX 系统中,许多实现相关的数据类型(如进程ID、用户ID、文件偏移量等)都用标准 C 类型来表示。虽然可以直接用 int、long 等基本类型声明这些变量,但这样会降低程序的可移植性,原因包括:

  • 不同 UNIX 实现中基本类型的大小可能不同(如 long 在某些系统上是4字节,在另一些系统上是8字节),甚至同一系统的不同编译环境也可能不同。
  • 不同实现可能用不同类型表示相同的信息。例如,进程ID在某些系统上是 int,在另一些系统上是 long。
  • 同一实现的不同版本也可能改变类型定义。例如,Linux 2.2 及以前用户和组ID是16位,2.4及以后是32位。

为避免这些移植性问题,SUSv3(Single UNIX Specification, Version 3)规定了一系列标准系统数据类型,并要求实现时正确使用这些类型。这些类型通常用 C 的 typedef 定义。例如,pid_t 用于表示进程ID,在 Linux/x86-32 上定义为:

1
typedef int pid_t;
大多数标准系统数据类型以 _t 结尾,通常声明在 <sys/types.h> 头文件中,部分类型在其他头文件中定义。

建议: 应用程序应使用这些类型来声明变量,以保证在所有符合 SUSv3 的系统上都能正确运行。例如:

1
pid_t mypid;

常用系统数据类型举例

数据类型 类型要求 说明
pid_t 有符号整数 进程ID、进程组ID、会话ID
uid_t 整数 用户ID
gid_t 整数 组ID
size_t 无符号整数 对象字节大小
ssize_t 有符号整数 字节计数或错误指示
off_t 有符号整数 文件偏移量或文件大小
time_t 整数或实数 自 Epoch 起的秒数
mode_t 整数 文件权限和类型
dev_t 算术类型 设备号(主次设备号)
ino_t 无符号整数 文件 i-node 号
socklen_t 至少32位整数 套接字地址结构体大小

打印系统数据类型的数值

  • 在用 printf() 打印表3-1中这些数值型系统数据类型(如 pid_tuid_t)时,要避免实现相关的依赖问题。

  • 由于 C 的参数提升规则,short 类型会被提升为 int,但 intlong 类型保持不变。因此,系统数据类型的底层实现不同,传递给 printf() 的参数类型可能是 intlong

  • 由于 printf() 在运行时无法判断参数类型,调用者必须用合适的格式说明符(如 %d%ld)明确指定类型。但直接写死某个说明符会导致实现依赖。

  • 通常的解决办法是统一用 %ld,并将对应的值强制转换为 long,例如:

    1
    2
    3
    pid_t mypid;
    mypid = getpid(); /* 获取当前进程ID */
    printf("My PID is %ld\n", (long) mypid);

  • 有一个例外:off_t 类型在某些环境下是 long long,因此应强制转换为 long long 并用 %lld 打印(详见5.10节)。

  • C99 标准定义了 z 长度修饰符,用于 size_tssize_t 类型,可以用 %zd 替代 %ld+强转。但该说明符并非所有 UNIX 实现都支持,所以本书避免使用。

  • C99 还定义了 j 长度修饰符,指定参数为 intmax_t(或 uintmax_t),这种类型足够大,可以表示任何整数类型。理论上,使用 (intmax_t) 强转加 %jd 是最通用的做法,能处理 long long 及扩展整数类型(如 int128_t)。但由于并非所有 UNIX 实现都支持,本书也避免使用这种方式。

其他可移植性问题(Miscellaneous Portability Issues)

结构体的初始化与使用

  • 各 UNIX 实现规定了一系列标准结构体,用于系统调用和库函数。例如,sembuf 结构体用于信号量操作(semop):
    1
    2
    3
    4
    5
    struct sembuf {
    unsigned short sem_num; /* 信号量编号 */
    short sem_op; /* 要执行的操作 */
    short sem_flg; /* 操作标志 */
    };
  • 虽然 SUSv3 规定了这些结构体,但需要注意:
    • 一般来说,结构体成员的顺序未必有标准规定。
    • 某些实现可能会在结构体中添加额外的字段。
  • 因此,不建议用如下方式初始化结构体(因为不同实现成员顺序可能不同):
    1
    struct sembuf s = { 3, -1, SEM_UNDO };
    这种写法在 Linux 下可用,但在其他实现中可能出错。可移植的做法是用显式赋值:
    1
    2
    3
    4
    struct sembuf s;
    s.sem_num = 3;
    s.sem_op = -1;
    s.sem_flg = SEM_UNDO;
    如果使用 C99,可以用新的结构体初始化语法:
    1
    struct sembuf s = { .sem_num = 3, .sem_op = -1, .sem_flg = SEM_UNDO };
  • 如果要将结构体内容写入文件,也要注意成员顺序。不能直接二进制写入结构体,而应按指定顺序逐个字段写入(最好用文本形式)。

某些宏可能并非所有实现都支持

  • 有些宏在所有 UNIX 实现中并不一定存在。例如,WCOREDUMP() 宏(用于检测子进程是否产生 core dump 文件)虽然常见,但 SUSv3 并未规定,因此某些系统可能没有。
  • 可移植的做法是用 #ifdef 判断宏是否存在:
    1
    2
    3
    #ifdef WCOREDUMP
    /* 使用 WCOREDUMP() 宏 */
    #endif

不同实现对头文件的要求不同

  • 某些系统调用和库函数所需的头文件在不同 UNIX 实现中可能不同。本书以 Linux 为主,并注明与 SUSv3 的差异。
  • 书中部分函数原型会注明某个头文件后加注释 /* For portability */,表示该头文件在 Linux 或 SUSv3 下不是必需的,但为了兼容其他(尤其是老旧)实现,建议在可移植程序中包含。
  • POSIX.1-1990 要求在包含与某些函数相关的头文件前,先包含 <sys/types.h>,但这一要求后来被 SUSv1 移除。尽管如此,为了可移植性,建议将 <sys/types.h> 作为首个头文件包含(本书示例为简洁起见省略了它)。

LPI示例程序说明

命令行选项与参数

  • 本书中的许多示例程序依赖命令行选项和参数来决定其行为。
  • 传统 UNIX 命令行选项格式为:一个连字符(-)加一个字母,后面可跟参数。GNU 工具支持扩展格式:两个连字符(--)加选项名和可选参数。
  • 示例程序通常使用标准库函数 getopt() 解析命令行选项(详见附录B)。
  • 只要程序的命令行语法不简单,都会实现一个帮助功能:如果用 --help 选项运行,程序会显示用法说明,指明命令行选项和参数的语法。

公共函数与头文件

  • 大多数示例程序都包含一个公共头文件,定义常用类型和宏,并引用常用的库函数和系统调用声明,使代码更简洁。

公共头文件(lib/tlpi_hdr.h)

  • 该头文件包含了许多常用头文件,定义了布尔类型和求最小/最大值的宏。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #ifndef TLPI_HDR_H
    #define TLPI_HDR_H /* 防止重复包含 */
    #include <sys/types.h> /* 常用类型定义 */
    #include <stdio.h> /* 标准I/O函数 */
    #include <stdlib.h>
    #include <unistd.h>
    #include <errno.h>
    #include <string.h>
    #include "get_num.h" /* 常用数值处理函数声明 */
    #include "error_functions.h" /* 错误处理函数声明 */
    typedef enum { FALSE, TRUE } Boolean;
    #define min(m,n) ((m) < (n) ? (m) : (n))
    #define max(m,n) ((m) > (n) ? (m) : (n))
    #endif

错误诊断函数(lib/error_functions.h)

  • 为简化错误处理,示例程序使用一组通用的错误诊断函数,其声明如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #ifndef ERROR_FUNCTIONS_H
    #define ERROR_FUNCTIONS_H
    void errMsg(const char *format, ...);
    #ifdef __GNUC__
    #define NORETURN __attribute__ ((__noreturn__))
    #else
    #define NORETURN
    #endif
    void errExit(const char *format, ...) NORETURN ;
    void err_exit(const char *format, ...) NORETURN ;
    void errExitEN(int errnum, const char *format, ...) NORETURN ;
    void fatal(const char *format, ...) NORETURN ;
    void usageErr(const char *format, ...) NORETURN ;
    void cmdLineErr(const char *format, ...) NORETURN ;
    #endif

使用 errMsg()、errExit()、err_exit() 和 errExitEN() 诊断系统调用和库函数错误

1
2
3
4
5
#include "tlpi_hdr.h"
void errMsg(const char *format, ...);
void errExit(const char *format, ...);
void err_exit(const char *format, ...);
void errExitEN(int errnum, const char *format, ...);
  • errMsg()
    在标准错误输出错误信息。参数列表与 printf() 相同,输出末尾自动加换行。会输出当前 errno 对应的错误文本(如错误名 EPERM 和 strerror() 返回的描述),再加上格式化输出内容。

  • errExit()
    功能类似 errMsg(),但会终止程序。终止方式为调用 exit(),如果环境变量 EF_DUMPCORE 被设置为非空字符串,则调用 abort() 生成 core dump 文件(用于调试)。

  • err_exit()
    与 errExit() 类似,但有两点不同:

    1. 打印错误信息前不会刷新标准输出。
    2. 通过 _exit() 终止进程,而不是 exit(),这样不会刷新 stdio 缓冲区,也不会调用 exit 处理函数。 这种方式适合在库函数中创建子进程后,因错误需要立即终止子进程时使用,避免影响父进程的缓冲区和退出处理。
  • errExitEN()
    与 errExit() 类似,但输出的是参数 errnum 指定的错误号对应的错误文本(EN 代表 Error Number),而不是当前 errno 的内容。主要用于 POSIX 线程 API(pthread)相关的程序。

    • 传统 UNIX 系统调用出错返回 –1,POSIX 线程函数出错则直接返回错误号(正整数),成功返回 0。
    • 示例:
      1
      2
      3
      4
      int s;
      s = pthread_create(&thread, NULL, func, &arg);
      if (s != 0)
      errExitEN(s, "pthread_create");
    • 这样比直接用 errno 更高效,因为在多线程程序中 errno 是一个宏,会展开为函数调用,返回线程私有的存储区域指针。
  • lvalue(左值) 说明
    lvalue 是指向存储区域的表达式,最常见的是变量名。某些操作符也能产生 lvalue,比如指针解引用 *p。在 POSIX 线程 API 下,errno 被重定义为返回线程私有存储区指针的函数。

诊断其他类型错误的函数

1
2
3
4
#include "tlpi_hdr.h"
void fatal(const char *format, ...);
void usageErr(const char *format, ...);
void cmdLineErr(const char *format, ...);
  • fatal()
    用于诊断一般性错误,包括那些不会设置 errno 的库函数错误。参数列表与 printf() 相同,输出自动换行。该函数会将格式化信息输出到标准错误,并像 errExit() 一样终止程序。

  • usageErr()
    用于诊断命令行参数用法错误。参数同 printf(),输出以 "Usage:" 开头,后跟格式化内容,输出到标准错误,然后调用 exit() 终止程序。(有些示例程序会用扩展版 usageError()。)

  • cmdLineErr()
    类似于 usageErr(),但用于诊断命令行参数本身的错误。输出以 "Command-line usage error:" 开头,后跟格式化内容,输出到标准错误并终止程序。

错误处理函数实现说明

  • 错误处理函数的实现会用到 ename.c.inc 文件,该文件定义了一个字符串数组 ename,用于将 errno 错误号映射为符号名(如 EPERM、EAGAIN/EWOULDBLOCK 等)。
  • 这样做的好处是:strerror() 只返回错误描述,不包含符号名,而手册页用符号名描述错误。输出符号名便于查阅手册定位错误原因。
  • ename.c.inc 文件内容与硬件架构相关,不同平台 errno 值可能不同。可以用书中提供的脚本(lib/Build_ename.sh)为特定平台生成合适的版本。
  • ename 数组中有些字符串为空,对应未使用的错误号;有些字符串包含两个错误名(如 "EAGAIN/EWOULDBLOCK"),表示这两个符号对应同一个错误号。
  • 例如,EAGAIN 和 EWOULDBLOCK 在大多数 UNIX 系统上值相同,分别用于 System V 和 BSD 的不同场景。SUSv3 规范允许非阻塞调用返回这两个错误之一。

解析数字型命令行参数的函数

  • 头文件(如清单3-5)声明了两个常用来解析整型命令行参数的函数:getInt()getLong()
  • atoi()atol()strtol() 等标准函数相比,这两个函数的主要优点是能对数字参数进行基本有效性检查。
  • getInt()getLong() 分别将参数 arg 指向的字符串转换为 int 或 long 类型。如果 arg 不是有效的整数字符串(即只包含数字和 +、- 号),函数会输出错误信息并终止程序。
1
2
3
4
#include "tlpi_hdr.h"
int getInt(const char *arg, int flags, const char *name);
long getLong(const char *arg, int flags, const char *name);
// 返回 arg 转换后的数值
  • 如果 name 参数非 NULL,应传入一个字符串,用于标识 arg 参数。该字符串会包含在错误信息中,便于定位问题。

  • flags 参数用于控制 getInt()getLong() 的行为。默认情况下,这两个函数期望参数为有符号十进制整数。通过将一个或多个 GN_* 常量(见清单3-5)按位或(|)赋给 flags,可以选择不同的进制或限制数值范围(如只允许非负数或大于0)。

  • 这两个函数的实现见清单3-6。

  • 虽然 flags 参数可以强制范围检查,但在某些示例程序中我们并未启用这些检查。例如,在清单47-1中,未检查信号量初始值参数,用户可以输入负数,导致后续 semctl() 系统调用出错(ERANGE),因为信号量不能为负。省略范围检查有助于实验系统调用和库函数的正确与错误用法,便于学习。实际应用中通常会对命令行参数做更严格的检查。

系统调用(System Calls)

  • 系统调用是进程进入内核、请求内核代表其执行某些操作的受控入口。内核通过系统调用 API 向程序提供各种服务,如创建新进程、执行 I/O、创建管道等。(可参考 syscalls(2) 手册页查看 Linux 系统调用列表。)
  • 系统调用的几个基本特点:
    • 系统调用会将处理器状态从用户态切换到内核态,使 CPU 能访问受保护的内核内存。
    • 系统调用集合是固定的,每个系统调用有唯一编号(程序通常通过名称而非编号调用)。
    • 每个系统调用可以有参数,用于在用户空间和内核空间之间传递信息。

系统调用的执行流程(以 x86-32 为例)

  1. 应用程序通过 C 库中的封装函数(wrapper function)发起系统调用。
  2. 封装函数将参数从栈传递到特定寄存器,以便内核处理。
  3. 封装函数将系统调用编号写入特定寄存器(如 %eax)。
  4. 封装函数执行 trap 指令(如 int 0x80),使处理器从用户态切换到内核态,执行内核 trap 向量表中对应位置的代码。新架构用 sysenter 指令,速度更快。
  5. 内核的 system_call() 例程被调用,主要步骤:
    • 保存寄存器到内核栈
    • 检查系统调用编号有效性
    • 根据编号查找并调用对应的系统调用服务例程(如 sys_execve()),并检查参数有效性,执行所需操作(如 I/O、内存操作等),返回结果状态
    • 恢复寄存器,并将返回值放到栈上
    • 返回到封装函数,同时切换回用户态
  6. 如果系统调用返回值表示错误,封装函数会设置全局变量 errno,并返回 -1 表示失败;成功时返回非负值。
  • Linux 系统调用服务例程约定:返回非负值表示成功,负值(为 errno 常量的相反数)表示错误。C 库封装函数会将负值转为正数赋给 errno,并返回 -1。
  • 这种约定假设系统调用不会在成功时返回负值,但极少数例外(如 fcntl()F_GETOWN 操作)。
  • 例如,execve() 系统调用在 sys_call_table 的第 11 项,指向 sys_execve() 服务例程。
  • 系统调用的实现虽然对程序员透明,但实际上涉及许多底层操作,因此系统调用有一定的性能开销。例如,getppid() 1,000 万次调用约需 2.2 秒,而等价的 C 函数只需 0.11 秒。
  • 在本书中,“调用系统调用 xyz()”通常指调用对应的 C 库封装函数。
  • 可用 strace 命令跟踪程序的系统调用,便于调试和分析。

库函数(Library Functions)

  • 库函数是标准 C 库(如 glibc)中包含的大量函数之一。它们的功能非常多样,比如打开文件、时间格式转换、字符串比较等。
  • 许多库函数并不涉及系统调用(如字符串处理函数),而有些库函数则是对系统调用的封装。例如,fopen() 库函数内部会调用 open() 系统调用来打开文件。
  • 库函数通常比底层系统调用更易用。例如,printf() 提供了格式化输出和缓冲功能,而 write() 只负责输出字节块。malloc()free() 也比底层的 brk() 系统调用更方便。

标准 C 库与 GNU C 库(glibc)

  • 不同 UNIX 实现有不同的标准 C 库实现。Linux 上最常用的是 GNU C 库(glibc)。

查看系统上的 glibc 版本

  • 可以直接运行 glibc 的共享库文件来查看版本信息,例如:

    1
    $ /lib/libc.so.6
    输出内容会包含 glibc 的版本号等信息。

  • 在某些发行版中,glibc 可能不在 /lib/libc.so.6,可以用 ldd 命令查看某个程序依赖的 glibc 路径:

    1
    2
    $ ldd myprog | grep libc
    libc.so.6 => /lib/tls/libc.so.6 (0x4004b000)

  • 程序可以通过两种方式获取 glibc 版本:

    1. 编译时检测常量:glibc 2.0 及以后定义了 __GLIBC____GLIBC_MINOR__ 两个常量,可用于 #ifdef 判断。
    2. 运行时调用函数:可用 gnu_get_libc_version() 获取运行时 glibc 版本号。
      1
      2
      #include <gnu/libc-version.h>
      const char *gnu_get_libc_version(void);
      该函数返回如 "2.12" 的版本号字符串。
  • 还可以用 confstr() 函数获取 _CS_GNU_LIBC_VERSION 配置变量,返回如 "glibc 2.12" 的字符串。

处理系统调用和库函数的错误

  • 几乎所有系统调用和库函数都会返回一个状态值,指示调用是否成功。必须始终检查这个返回值,如果失败,应采取适当措施(至少要输出错误信息)。
  • 虽然省略这些检查看似省事,但实际上会导致难以排查的 bug,浪费大量调试时间。

系统调用错误处理

  • 每个系统调用的手册页会说明其返回值,通常返回 –1 表示出错。例如:
    1
    2
    3
    4
    5
    6
    7
    fd = open(pathname, flags, mode);
    if (fd == -1) {
    /* 错误处理代码 */
    }
    if (close(fd) == -1) {
    /* 错误处理代码 */
    }
  • 系统调用失败时,会将全局变量 errno 设为正值,表示具体错误类型。需要包含 <errno.h> 头文件。
  • errno 的符号常量都以 E 开头,手册页的 ERRORS 部分会列出可能的 errno 值。
  • 示例:
    1
    2
    3
    4
    5
    6
    7
    8
    cnt = read(fd, buf, numbytes);
    if (cnt == -1) {
    if (errno == EINTR)
    fprintf(stderr, "read was interrupted by a signal\n");
    else {
    /* 其他错误 */
    }
    }
  • 成功的系统调用不会将 errno 设为 0,因此不能用 errno == 0 判断是否成功。应先检查返回值,再看 errno。
  • 少数系统调用(如 getpriority())在成功时也可能返回 –1。此时应在调用前将 errno 设为 0,调用后判断:如果返回 –1 且 errno 不为 0,则为错误。

错误信息输出

  • 常用 perror()strerror() 输出错误信息。
    • perror():输出自定义信息和 errno 对应的错误描述。
      1
      2
      #include <stdio.h>
      void perror(const char *msg);
      用法示例:
      1
      2
      3
      4
      5
      fd = open(pathname, flags, mode);
      if (fd == -1) {
      perror("open");
      exit(EXIT_FAILURE);
      }
    • strerror():返回 errno 对应的错误字符串。
      1
      2
      #include <string.h>
      char *strerror(int errnum);
      注意返回的字符串可能被后续调用覆盖。
  • 如果错误号未知,strerror() 返回 "Unknown error nnn" 或 NULL。
  • 这两个函数支持本地化,错误信息会用本地语言显示。

库函数错误处理

  • 不同库函数返回不同类型和数值表示失败(需查阅手册页)。
  • 常见几类:
    1. 与系统调用一致:返回 –1,errno 指示错误(如 remove())。
    2. 返回其他错误值:如 fopen() 出错返回 NULL,errno 反映具体错误。
    3. 不使用 errno:某些库函数不用 errno,具体错误判断方式见手册页。此时不应用 errno、perror() 或 strerror()。
0%