面向未知

路漫漫其修远兮

欢迎来到我的日志

(。・ω・)ノ゙

这里的内容主要和我的学习路线有关
当然也有 Archlinux 的内容
学习是永无止境的
学得越多就越会觉得自己无知
人类终究是有极限的
除非...

说笑了
总之
全員一層奮励努力せよ!

库文件是将多个 .o 目标文件打包后的产物,供程序在编译链接时使用。根据链接方式的不同,分为静态库(.a)和动态库(.so):

  • 静态库:在编译链接时,将库中的代码完整地复制到可执行文件中。生成的可执行文件不再依赖该库。
  • 动态库:在编译链接时,只记录库的引用;程序运行时,由动态链接器加载。多个程序可以共享同一份动态库。

静态库

打包静态库

源文件

1
2
// myadd.h
int add(int a, int b);
1
2
3
4
5
6
7
// myadd.cpp
#include "myadd.h"
#define N 0

int add(int x, int y) {
return x + y + N;
}

编译与打包

首先将源文件编译成机器码,再使用 ar 命令打包成静态库。

1
2
3
4
# 编译成机器码
g++ -c myadd.cpp -o myadd.o
# 打包成静态库
ar rcs libmyadd.a myadd.o

静态库的命名约定为 lib<名称>.a。

使用静态库

1
2
3
4
5
6
7
8
9
// main.cpp
#include <iostream>
#include "myadd.h"

int main() {
int sum = add(1, 2);
std::cout << "add(1, 2) = " << sum << std::endl;
return 0;
}
1
g++ main.cpp -static -L. -lmyadd && ./a.out
1
add(1, 2) = 3
  • -L.:在当前目录 ./ 中寻找库文件。
  • -lmyadd:链接名为 libmyadd 的库。
  • -static:以静态方式链接所有库,生成的可执行文件不再依赖任何外部库。

目录结构

1
2
3
4
5
6
7
test/
├── a.out
├── main.cpp
├── myadd.cpp
├── myadd.h
├── myadd.o
└── libmyadd.a

ar 命令详解

ar 用于创建和管理静态库。完整语法如下:

1
ar [-][dmpqrtx][cfosSuvV] [a<成员>] [b<成员>] [i<成员>] [count] [备存文件] [成员文件]

常用示例

1
2
3
4
5
# 将 test.o 加入 libtest.a,已存在则替换;c 创建库,s 建立符号表
ar rcs libtest.a test.o

# 将 test.o 追加到 libtest.a 末尾
ar qcs libtest.a test.o

操作参数

dmpqrtx 为操作参数,只能且必须使用其中一个。以下将备存文件称为库,成员文件称为模块。

  • d:删除库中指定模块。使用 v 选项可列出被删除的模块。
  • m:变更库中模块的次序。当多个模块有相同的符号定义时,成员的顺序很重要。默认将指定成员移到最后,可用 a/b/i 指定位置。
  • p:将库中模块的内容输出到标准输出。使用 v 选项可在输出前显示模块名。不指定模块名则输出全部。
  • q:快速追加。将新模块追加到库末尾,不检查是否需要替换。a/b/i 对此操作无效,模块总是追加到末尾。使用 v 选项可列出每个模块。追加后符号表未更新,需用 ar s 或 ranlib 刷新。
  • r:插入模块(已存在则替换)。当模块名已存在时替换同名模块。默认插入到末尾,可用 a/b/i 指定位置。
  • t:显示库中的模块清单,一般只显示文件名。
  • x:从库中提取模块。不指定模块名则提取全部。

修饰参数

修饰参数可与操作参数结合使用。

  • a<成员>:在已存在的成员之后插入新文件。
  • b<成员>:在已存在的成员之前插入新文件。
  • i<成员>:等同于 b,在已存在的成员之前插入新文件。
  • c:创建库。不论库是否存在都会创建。
  • f:截短过长的文件名,以兼容其他系统的 ar 实现。
  • N:与 count 配合使用,指定同名文件提取或删除的个数。
  • o:提取成员时保留原始日期。不指定则标为提取时间。
  • P:文件名匹配时使用全路径。ar 创建库时不能使用全路径(不符合 POSIX 标准),但某些工具可以。
  • s:写入或更新库的目标文件索引。等同于 ranlib。
  • S:不创建目标文件索引,加快大库的创建速度。
  • u:仅在 r 操作时有效,只插入比库中同名文件更新的成员。
  • v:显示详细信息。
  • V:显示 ar 版本。

动态库

创建动态库

源文件

1
2
// myadd.h
int add(int a, int b);
1
2
3
4
5
6
7
// myadd.cpp
#include "myadd.h"
#define N 0

int add(int x, int y) {
return x + y + N;
}

编译

1
2
3
4
5
6
# 一步到位
g++ -fPIC -shared myadd.cpp -o libmyadd.so

# 或分两步
g++ -c -fPIC myadd.cpp -o myadd.o
g++ -shared myadd.o -o libmyadd.so
  • -fPIC:生成位置无关代码(Position Independent Code),使代码可以被加载到任意内存地址。
  • -shared:生成共享对象(即动态库)。

动态库的命名约定为 lib<名称>.so。

隐式链接

隐式链接在编译时完成,使用方式与静态库类似,但代码不会被复制到可执行文件中。

编译链接

1
2
3
4
5
6
7
8
9
// main.cpp
#include <iostream>
#include "myadd.h"

int main() {
int sum = add(1, 2);
std::cout << "add(1, 2) = " << sum << std::endl;
return 0;
}
1
g++ main.cpp -L. -lmyadd && ./a.out
1
./a.out: error while loading shared libraries: libmyadd.so: cannot open shared object file: No such file or directory

编译链接成功,但运行时找不到动态库。原因是编译链接期与运行期查找动态库的路径不同:

阶段 搜索路径
编译链接期 -L 指定的路径、LIBRARY_PATH、/usr/lib、/lib
运行期 LD_LIBRARY_PATH、/etc/ld.so.conf、/usr/lib、/lib

临时解决:设置 LD_LIBRARY_PATH

1
2
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/test/
./a.out
1
add(1, 2) = 3

永久解决:设置 RUNPATH

程序的动态节(dynamic section)中可以嵌入库的搜索路径。使用 readelf -d 查看:

1
readelf -d ./a.out

动态节中有两个与搜索路径相关的条目:RPATH 和 RUNPATH。动态链接器按以下顺序搜索:

  1. RPATH(已废弃)
  2. LD_LIBRARY_PATH
  3. /etc/ld.so.conf
  4. RUNPATH

RPATH 优先级最高且无法被环境变量覆盖,因此已被废弃。推荐使用 RUNPATH。

在链接时通过 -Wl 向链接器传递参数来设置:

1
2
3
4
5
6
7
8
9
# 原命令
g++ main.cpp -L. -lmyadd -o a.out

# 添加 RUNPATH
g++ main.cpp -L. -Wl,-R,'$ORIGIN' -lmyadd -o a.out

# 上面那条指令是书中的,我在我的 Archlinux 上无法正确进行操作
# 我在 Archlinux 上使用下面这条指令成功完成操作
g++ main.cpp -L. '-Wl,-R${ORIGIN}' -lmyadd -o a.out
  • -Wl:将后续参数传递给链接器。
  • -R:链接器参数,用于设置 RUNPATH。
  • $ORIGIN:表示可执行文件所在的目录(而非当前工作目录)。

显式链接

显式链接在运行时通过 dlopen 等函数动态加载库,无需在编译时链接。

源文件

1
2
3
4
5
6
7
// myadd.cpp

#define N 0

extern "C" int add(int x, int y) {
return x + y + N;
}

显式链接需要 extern "C",否则 C++ 的 name mangling 会导致 dlsym 找不到函数。

编译动态库

1
g++ -fPIC -shared myadd.cpp -o myadd.so

加载与调用

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
27
28
29
30
31
// main.cpp
#include <dlfcn.h>
#include <iostream>

int main() {
// 加载动态库
// RTLD_NOW:立即加载所有符号
// RTLD_LAZY:延迟加载,调用时才解析
auto *handle = dlopen("./myadd.so", RTLD_LAZY);
if (handle == nullptr) {
std::cerr << "error: dlopen" << std::endl;
return 1;
}

// 获取函数指针
using MYADD_TYPE = int (*)(int, int);
auto add = (MYADD_TYPE)dlsym(handle, "add");
if (add == nullptr) {
std::cerr << "error: dlsym" << std::endl;
dlclose(handle);
return 1;
}

// 调用函数
int sum = add(1, 2);
std::cout << "add(1, 2) = " << sum << std::endl;

// 关闭动态库
dlclose(handle);
return 0;
}
1
g++ main.cpp && ./a.out
1
add(1, 2) = 3

编译与链接主要为下述步骤,其中前三个步骤就是广义上的编译。

  1. 预处理:把一个 .c 源文件处理成 .i 预处理文件
  2. 编译:把 .i 预处理文件进一步处理成 .s 汇编文件(狭义上的编译)
  3. 汇编:把 .s 汇编文件最终处理得到 .o 机器码文件
  4. 链接:把多个 .o 机器码文件链接成可执行文件

准备源代码

为了演示这个过程,我编写了以下两个文件作为源代码。

1
2
3
// myheader.h
#define N 666
#define M 999
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.c

#include "myheader.h"

/**
* 我是注释
* 我也是注释
*/

int main() {
// 我还是注释
int i = M + N;
return 0;
}

接下来,我将以此文件为基础进行演示。

编译(广义上的编译)

广义上的编译指的是从高级编程语言的代码到二进制机器码的过程。

预处理

预处理会对 # 开头的预处理语句进行处理。

通过 gcc -E main.c -o main.i 命令将 main.c 预处理成 main.i。

main.i 文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 0 "main.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.c"
# 1 "myheader.h" 1
# 2 "main.c" 2








int main() {

int i = 999 + 666;
return 0;
}

可以看得出来 #include 和 #define 这些预处理指令,都已经成功地执行。

  • #include 成功地把 myheader.h 引入到了 main.c 中
  • #define 的宏定义也完成了文本替换
  • 源代码中的注释也全部被去掉了

编译(狭义上的编译)

狭义上的编译指的是把预处理文件编译成汇编代码的过程。

通过 gcc -S main.i -o main.s 命令将 main.i 编译成 main.s。

main.s 文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
	.file	"main.c"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $1665, -4(%rbp)
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 14.2.1 20240910"
.section .note.GNU-stack,"",@progbits

可以看得出来预处理好的文件已经被编译成了汇编代码。

懂一些汇编的话,应该能看出来 movl $1665, -4(%rbp) 这一行汇编代码就是给变量 i 赋值,rbp 寄存器偏移 -4 的位置就是 i 的地址。

汇编

汇编指的是从汇编代码到机器码这个过程,汇编代码和机器码指令是一一对应的。

通过 gcc -c main.s -o main.o 命令将 main.s 汇编成 main.o。

main.o 就是二进制机器码文件,由于汇编代码和机器码一一对应,我们可以从汇编代码知道 main.o 中暴露了 main 这个符号,并且标记了 main 是一个函数。在后续链接的时候,因为 C 语言中 main 函数的特殊性,它将会被处理为整个二进制程序的入口。

如果我们的代码是作为库提供给其他人使用的话,可能就不会有 main 函数,也不会进入下面的链接环节,而是把多个 .o 文件打包成一个文件,这个打包好的文件也就是库文件,根据打包过程的不同可以分为动态库和静态库。

链接

最后我们可以使用 gcc main.o -o main 将编译获得的多个 .o 文件链接成为可执行程序。在本文的例子中,只有一个 .o 文件。

最终 main 就是可执行程序。

目录结构

1
2
3
4
5
6
7
test/
├── main
├── main.c
├── main.i
├── main.s
├── main.o
└── myheader.h

自建 CA

什么是 CA 证书?

CA(Certificate Authority,证书颁发机构) 是一个”可信第三方”。
它用自己的私钥给别人的证书”盖章”(签名),从而证明”这张证书确实是某某机构发的”。

  • 根 CA 证书(Root CA):自签名的、最顶层的证书,是信任链的起点。
    它自己给自己签名,所以也叫”自签名证书”。
  • 服务器证书(Server Certificate):由 CA 签发的、真正用于 HTTPS 的证书。
    浏览器验证流程:服务器证书 ← 由根 CA 签名,而根 CA 在客户端的信任库里,于是信任成立。

密钥对(Key Pair) 的概念:

对象 作用 是否保密
私钥(.key) 签名、解密 必须严格保密,永不外传
公钥/证书(.crt) 验证签名、加密 公开分发

私钥一旦泄露,等于你的 CA 被”盗号”,任何人可以伪造合法证书。

依赖

1
2
# 检查 openssl 是否安装
openssl version

生成根 CA 私钥

1
2
3
4
5
6
7
# -out    输出文件名
# 4096 密钥长度(位),越长越安全,2048 也够用
# genrsa 生成一对 RSA 密钥
openssl genrsa -out KeunlasRootCA.key 4096

# 设置私钥权限:仅属主可读写(安全起见)
chmod 400 KeunlasRootCA.key

编写 CA 配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# KeunlasRootCA.cnf
# OpenSSL 配置文件:定义 CA 证书的身份信息和扩展属性

[ req ]
distinguished_name = req_dn # 使用下面 [req_dn] 段的字段
prompt = no # 不交互式提问,直接读配置

[ req_dn ]
C = CN # 国家 Country
ST = Shanghai # 省/州 State
L = Shanghai # 城市 Locality
O = KeunlasCA # 组织 Organization
CN = KeunlasRootCA # 通用名 Common Name(必填)

[ v3_ca ]
# 以下三项是"它是一张 CA 证书"的关键标志:
basicConstraints = critical, CA:TRUE # 允许它签发其他证书
keyUsage = critical, keyCertSign, cRLSign # 用途:签证书、签吊销列表
subjectKeyIdentifier = hash # 给证书算一个唯一 ID
authorityKeyIdentifier = keyid:always, issuer # 记录"谁签发的我"

生成根 CA 自签名证书

1
2
3
4
5
6
7
8
9
10
11
12
13
# -new              生成新证书请求
# -x509 直接输出自签名证书(而不是 .csr)
# -days 10950 有效期 10950 天 ≈ 30 年
# -nodes 私钥不加密(与已有私钥配套,无需重复设密码)
# -key 使用已有的私钥
# -config 使用我们的配置文件
# -extensions v3_ca 附加 [v3_ca] 中的扩展(CA:TRUE 等)

openssl req -new -x509 -days 10950 -nodes \
-key KeunlasRootCA.key \
-config KeunlasRootCA.cnf \
-extensions v3_ca \
-out KeunlasRootCA.crt

校验根 CA 证书

1
2
3
4
5
6
7
# 查看证书内容
openssl x509 -in KeunlasRootCA.crt -noout -text

# 重点确认这两处:
# 1) Basic Constraints: CA:TRUE
# 2) Key Usage: Certificate Sign, CRL Sign
openssl x509 -in KeunlasRootCA.crt -noout -text | grep -E "CA:TRUE|Certificate Sign|CRL Sign"

签发服务器证书

生成服务器私钥和 CSR

1
2
3
4
5
6
7
8
# 服务器私钥
openssl genrsa -out localhost.key 2048

# CSR(证书签名请求):相当于"申请表"
openssl req -new \
-key localhost.key \
-out localhost.csr \
-subj "/C=CN/ST=Shanghai/L=Shanghai/O=LocalhostServer/CN=localhost"

CSR 里包含服务器的公钥和身份信息(CN=localhost),
CA 拿到 CSR 后核验、签名,就能”盖章”发证。
CN=localhost 表示这张证书是给 localhost 域名用的。

使用 CA 签发服务器证书

1
2
3
4
5
6
7
8
9
10
11
12
13
# -CA / -CAkey    用哪个 CA 及其私钥来签名
# -CAcreateserial 自动创建序列号文件 KeunlasRootCA.srl(后续签发号递增,保证每张证书序列号唯一)
# -days 825 有效期约 2.26 年(生产证书一般不超过 825 天)
# -extfile 附加扩展文件,这里写入 SAN(主题备用名)

openssl x509 -req \
-in localhost.csr \
-CA KeunlasRootCA.crt \
-CAkey KeunlasRootCA.key \
-CAcreateserial \
-days 825 \
-extfile <(printf "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1") \
-out localhost.crt

为什么必须加 SAN?
现代浏览器(Chrome 58+、Firefox)只认 SAN,不再认 CN。
没有 SAN 的证书,浏览器会报 ERR_CERT_COMMON_NAME_INVALID。
SAN 必须包含你要访问的实际域名/IP,例如 DNS:localhost、IP:127.0.0.1。

校验服务器证书

1
2
3
4
5
6
# 确认签发者、SAN 是否正确
openssl x509 -in localhost.crt -noout -text | grep -E "Issuer:|Subject:|DNS:|IP Address:"

# 验证证书链是否有效(CA 与服务器证书配对)
openssl verify -CAfile KeunlasRootCA.crt localhost.crt
# 期望输出:localhost.crt: OK

Diffie-Hellman(DH)密钥交换参数

过去常用于 TLS 握手时的前向保密(Perfect Forward Secrecy, PFS)

1
openssl dhparam -out dh4096.pem 4096

曾用于 TLS 1.2 时代的 DHE 密码套件,实现前向保密。TLS 1.3 / ECDHE 已取代它,一般不再需要。

基于 ECMAScript 正则表达式语法

一对括号就是一个分组。\1 引用第一个分组所匹配到的内容,\2、\3 等同理。

示例:(a+)b\1 匹配 aabacaabaa

可匹配到两处:aba 和 aabaa。

第一次匹配

  1. 位置 0:(a+) 贪婪匹配 aa(位置 0–1),b 匹配位置 2 成功,\1 需要 aa 但位置 3–4 是 ac,失败。回溯 (a+) 为 a(位置 0),b 在位置 1 是 a,失败。
  2. 位置 1:(a+) 匹配 a(位置 1),b 匹配位置 2 成功,\1 需要 a,位置 3 是 a,匹配成功。
  3. 匹配结果为 aba(位置 1–3),分组 1 捕获 a。

第二次匹配

  1. 位置 4:字符 c 不符合 a+,跳过。
  2. 位置 5:(a+) 匹配 aa(位置 5–6),b 匹配位置 7 成功,\1 需要 aa,位置 8–9 是 aa,匹配成功。
  3. 匹配结果为 aabaa(位置 5–9),分组 1 捕获 aa。

基于 ECMAScript 正则表达式语法

断言不消耗输入中的任何字符,仅判断当前位置是否满足某种条件。例如 ^ 和 $ 分别断言文本的开头和结尾,本身不匹配任何字符。

零宽度正向先行断言

(?=...):当前位置之后的文本能够匹配 ... 时,断言成功。

示例一:(?=(a+))a*b 匹配 baaba

  1. 位置 0(b):a+ 无法匹配,失败。
  2. 位置 1(a):a+ 贪婪匹配 aa(位置 1–2),断言成功,当前位置仍在 1。继续匹配 a*b:a* 贪婪匹配 aa(位置 1–2),b 匹配位置 3 成功。
  3. 最终匹配 aab(位置 1–3),分组 1 捕获 aa。

示例二:(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).{6,} 匹配 abc2ABC

要求包含小写字母、大写字母和数字,且长度至少为 6。依次执行三个前瞻断言(均从位置 0 开始):

(?=.*[a-z]) — 是否包含小写字母

  • .* 贪婪匹配全部 7 字符,[a-z] 无剩余可匹配,失败。
  • 回溯至 6 字符:剩 C,[a-z] 失败。
  • 回溯至 5 字符:剩 B,失败。
  • 回溯至 4 字符:剩 A,失败。
  • 回溯至 3 字符:剩 2,失败。
  • 回溯至 2 字符:剩 c,[a-z] 匹配成功。

(?=.*[A-Z]) — 是否包含大写字母

  • .* 贪婪匹配全部 7 字符,[A-Z] 失败。
  • 回溯至 6 字符:剩 C,[A-Z] 匹配成功。

(?=.*[0-9]) — 是否包含数字

  • .* 贪婪匹配全部 7 字符,[0-9] 失败。
  • 回溯至 6 字符:剩 C,失败。
  • 回溯至 5 字符:剩 B,失败。
  • 回溯至 4 字符:剩 A,失败。
  • 回溯至 3 字符:剩 2,[0-9] 匹配成功。

三个断言均成功,当前位置仍在位置 0。最后 .{6,} 匹配全部 7 个字符,匹配结果为 abc2ABC。

零宽度负向先行断言

(?!...):当前位置之后的文本不能匹配 ... 时,断言成功。

与正向先行断言相反,当 ... 在当前位置匹配失败时,负向断言才算成功。

线程安全问题

SQLite3 在编译的时候可以通过添加定义来控制整个库的线程安全问题。

当 SQLITE_THREADSAFE 这个宏为 0 时,整个库是线程不安全的。

当 SQLITE_THREADSAFE 这个宏为 1 时,整个库是线程安全的。

当 SQLITE_THREADSAFE 这个宏为 2 时,整个库是线程安全的,但是同一个 sqlite3* db 不能被多个线程同时使用。

当编译的时候 SQLITE_THREADSAFE 为 1 或者 2 时,可以通过 sqlite3_config() 这个接口来更改线程安全模式:

  • SQLITE_CONFIG_SINGLETHREAD 单线程
  • SQLITE_CONFIG_MULTITHREAD 多线程
  • SQLITE_CONFIG_SERIALIZED 串行化

面向对象类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <functional>
#include <string>
#include <string_view>
#include <unordered_map>

class SQLite3 {
public:
using TableRow = std::unordered_map<std::string, std::string>;
using ExecCallback = std::function<bool(TableRow)>;

public:
SQLite3(const std::string_view& db_name);
~SQLite3();

int close();

void exec(const std::string_view& sql);
void exec(const std::string_view& sql, ExecCallback callback);

private:
std::string db_name_;
sqlite3* db_{nullptr};
};

我设置了两个成员变量,一个是数据库连接,另一个是打开的数据库名称。

1
2
std::string db_name_;
sqlite3* db_{nullptr};

数据库的开启

首先是类的构造函数,主要是打开数据库连接,并在出错时抛出异常信息。

1
2
3
4
5
6
SQLite3::SQLite3(const std::string_view& db_name) : db_name_(db_name) {
auto errcode = sqlite3_open(db_name.data(), &db_);
if (errcode != SQLITE_OK) {
// 抛出或处理异常信息
}
}

数据库的关闭

然后是析构和关闭数据库连接。

析构函数因为不能抛出异常,所以我使用了 sqlite3_close_v2 来关闭连接。

主动 close 则是使用 sqlite3_close 接口,并返回错误代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SQLite3::~SQLite3() {
if (db_) {
sqlite3_close_v2(db_);
db_ = nullptr;
}
}

int SQLite3::close() {
auto errcode = sqlite3_close(db_);
if (errcode == SQLITE_OK) {
db_ = nullptr;
}
return errcode;
}

执行 SQL 语句

无回调函数

无回调函数的操作,只需要执行 SQL 语句,并抛出或者处理执行失败的情况即可。

1
2
3
4
5
6
7
8
9
10
11
void SQLite3::exec(const std::string_view& sql) {
char* errstr = nullptr;
int errcode = sqlite3_exec(db_, sql.data(), nullptr, nullptr, &errstr);

if (errcode != SQLITE_OK) {
std::string msg = errstr ? errstr : "Unknown error";
sqlite3_free(errstr);
throw std::runtime_error(
std::format("Failed to execute SQL: {}\nError SQL: {}", msg, sql));
}
}

有回调函数

当执行一些带有结果的 SQL 语句时,就需要回调函数去处理结果。

我定义了两个类型用来封装 sqlite3_exec 中的回调函数。

ExecCallback 对象的返回值为 bool 类型,用来指示回调函数是否成功执行。

并且 ExecCallback 对象拥有一个参数 TableRow,这是一个哈希表,存储该行的每一个列数据的 name 和 value 的键值对。

1
2
using TableRow = std::unordered_map<std::string, std::string>;
using ExecCallback = std::function<bool(TableRow)>;

我通过 void* context 传递 ExecCallback 对象。

当回调函数触发时,填充 TableRow row 对象的数据,并以此为参数调用 ExecCallback 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void SQLite3::exec(const std::string_view& sql, ExecCallback callback) {
auto c_callback = [](void* context, int col_size, char** col_values,
char** col_names) -> int {
auto* cb = static_cast<ExecCallback*>(context);
TableRow row;
for (int i = 0; i < col_size; i++) {
std::string col_name = col_names[i];
std::string value = col_values[i] ? col_values[i] : "";
row[col_name] = value;
}
bool success = (*cb)(row);
return success ? 0 : 1;
};

char* errstr = nullptr;
int errcode = sqlite3_exec(db_, sql.data(), c_callback, &callback, &errstr);
if (errcode != SQLITE_OK) {
std::string msg = errstr ? errstr : "Unknown error";
sqlite3_free(errstr);
throw std::runtime_error(std::format(
"Failed to execute SQL with callback: {}\nError SQL: {}", msg, sql));
}
}

测试一下这个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main() {
SQLite3 db("test.db");

try {
db.exec("create table if not exists users (id integer primary key autoincrement, name text);");
db.exec("insert into users (id, name) values (1, 'Alice');");
db.exec("insert into users (id, name) values (2, 'Bob');");
db.exec("insert into users (name) values ('Charlie');");
db.exec("select * from users;", [](SQLite3::TableRow row) {
std::cout << "User ID: " << row["id"] << ", Name: " << row["name"] << '\n';
return true; // Continue iteration
});
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}

return 0;
}

运行上述 main 函数后,获得输出如下:

1
2
3
User ID: 1, Name: Alice
User ID: 2, Name: Bob
User ID: 3, Name: Charlie

再次运行该代码,输出如下:

1
2
Failed to execute SQL: UNIQUE constraint failed: users.id
Error SQL: insert into users (id, name) values (1, 'Alice');

测试完毕,这样一个初步的面向对象封装就完成了。

介绍

最基本的使用 sqlite3 的 c api 还是挺简单的。只需要记住三个接口即可。

  • 打开数据库:sqlite3_open()

  • 关闭数据库:sqlite3_close()

  • 执行 SQL 语句:sqlite3_exec()

开启数据库

先来看看如何开启数据库,我们需要提供两个参数,数据库名称和一个 sqlite3 指针的指针。

1
2
3
4
int sqlite3_open(
const char *filename, /* Database filename (UTF-8) */
sqlite3 **ppDb /* OUT: SQLite db handle */
);

具体的使用基本如下:

1
2
3
char* db_name = "test.db";
sqlite3* db;
sqlite3_open(db_name, &db);

SQLite3 还提供了可以更加精细的控制打开数据库方式的接口:sqlite3_open_v2()

1
2
3
4
5
6
int sqlite3_open_v2(
const char *filename, /* Database filename (UTF-8) */
sqlite3 **ppDb, /* OUT: SQLite db handle */
int flags, /* Flags */
const char *zVfs /* Name of VFS module to use */
);

可以看到多了两个参数,可以控制数据库只读或其他更精细的操作。sqlite3_open() 可以看作是下述特定参数 sqlite3_open_v2()。

1
2
3
char* db_name = "test.db";
sqlite3* db;
sqlite3_open_v2(db_name, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, 0);

具体的 sqlite3_open_v2() 使用可以参考官方文档 Opening A New Database Connection.

关闭数据库

关闭数据库只需要把 sqlite3* db 作为参数传入即可。使用十分简单。

就像 open 接口有多个版本一样,close 接口也有多个版本。

1
2
int sqlite3_close(sqlite3*);
int sqlite3_close_v2(sqlite3*);

主要区别是,当 db 还有某些未完成的任务时,两个接口会有不同的行为。

sqlite3_close 会返回 SQLITE_BUSY。

sqlite3_close_v2 会返回 SQLITE_OK。但不会立即释放数据库连接,而是将数据库连接标记为不可用的“僵尸”状态,并安排在所有预处理语句完成、所有 BLOB 句柄关闭以及所有备份操作结束后自动释放数据库连接。

执行 SQL 语句

1
2
3
4
5
6
7
int sqlite3_exec(
sqlite3* db, /* 数据库连接 */
const char* sql, /* 要执行的 SQL 语句 */
int (*callback)(void*, int, char**, char**), /* 回调函数 */
void* data, /* 传递给回调函数的参数 */
char** errmsg /* 错误信息指针 */
);

db 是打开的数据库连接。

sql 是要执行的 SQL 语句。

callback 是执行完 SQL 语句后进行的回调函数。(可选参数,不需要可传递空指针)

data 是传给 callback 的第一个参数,可用于传递上下文。(可选参数,不需要可传递空指针)

errmsg 会返回相应的错误信息,如果无错误则被设为空指针。(可选参数,不需要可传递空指针)

回调函数

1
int callback(void* context, int col_size, char** col_values, char** col_names);

只要理解了这个回调函数会对结果的每一行数据执行一次,我们就可以轻松的理解这个回调的后面三个和列相关的参数。

  • context:从 sqlite3_exec() 传递过来的用户数据
  • col_size:结果行的列数
  • col_values:列值的字符串数组
  • col_names:列名的字符串数组

返回值为 0 时,继续查询。

返回值为 1 时,终止查询。

渲染第一个 SDL 窗口

首先我们从一个简单的例子讲起,我们渲染一个绿色背景的窗口。

基本步骤就是创建窗口和渲染器,然后在循环中不断的渲染窗口并展示。

SDL_Init,SDL_CreateWindow 和 SDL_CreateRenderer 拥有 bool 类型的返回值,下列代码中省略了返回值判断。

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
27
#include <SDL3/SDL.h>

int main(int argc, char* argv[]) {
SDL_Init(SDL_INIT_VIDEO); // 初始化 SDL
SDL_Window* window = SDL_CreateWindow("Hello World", 800, 600, 0); // 创建窗口
SDL_Renderer* renderer = SDL_CreateRenderer(window, nullptr); // 创建渲染器

SDL_Event e;
bool quit = false;
while (!quit) {
while (SDL_PollEvent(&e)) {
if (e.type == SDL_EVENT_QUIT) {
quit = true; // 当获取到 QUIT 事件时, 终止循环
}
}

SDL_SetRenderDrawColor(renderer, 0, 255, 0, 255); // 渲染器的颜色
SDL_RenderClear(renderer); // 清空窗口为渲染器的颜色
SDL_RenderPresent(renderer); // 展示渲染好的窗口
}

// 清理资源
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}

更加的模块化 SDL3

SDL3 中只需要 #define SDL_MAIN_USE_CALLBACKS 1 并 #include <SDL3/SDL_main.h> 即可使用回调函数代替 main 函数,从而更加方便的编写程序。

最关键的四个回调函数为:

  • 程序启动时执行:SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]);
  • 有新事件时执行:SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event);
  • 程序每帧都执行:SDL_AppResult SDL_AppIterate(void *appstate);
  • 程序退出时执行:void SDL_AppQuit(void *appstate, SDL_AppResult result);

具体代码结构如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
#define SDL_MAIN_USE_CALLBACKS 1 // 必须设置这个宏
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h> // 并且引入这个头文件
#include <iostream>

static SDL_Window *window = nullptr;
static SDL_Renderer *renderer = nullptr;

// 当程序启动时执行 SDL_AppInit 函数
SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
SDL_Init(SDL_INIT_VIDEO);
window = SDL_CreateWindow("Hello World", 800, 600, 0);
renderer = SDL_CreateRenderer(window, nullptr);

return SDL_APP_CONTINUE;
}

// 当新的事件发生时触发 SDL_AppEvent 函数
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
if (event->type == SDL_EVENT_QUIT) {
return SDL_APP_SUCCESS;
}

return SDL_APP_CONTINUE;
}

// 每帧运行一次 SDL_AppIterate 函数
SDL_AppResult SDL_AppIterate(void *appstate) {
SDL_SetRenderDrawColor(renderer, 0, 255, 0, 255);
SDL_RenderClear(renderer);
SDL_RenderPresent(renderer);

return SDL_APP_CONTINUE;
}

// 当程序退出时执行 SDL_AppQuit 函数
void SDL_AppQuit(void *appstate, SDL_AppResult result) {
std::cerr << "SDL_AppQuit." << std::endl;
}

WhiteLists

虽然已经用了多年的 git, 但是至今都没怎么好好的总结一下 ignore 规则。今天心血来潮,重新仔细的总结一下 gitignore 到底怎么好好的去写,并记录一下 gitignore 的白名单写法。

基础规则

首先是 gitignore 的基础规则。基础规则的总结,我参考了这篇文章 .gitignore File – How to Ignore Files and Folders in Git. 不过参考文章中有些操作在我所使用的 Archlinux 中无法复现, 所以下面的总结以我自己的实际操作为基础。

如何在 Git 中忽略一个文件或文件夹

如何忽略一个文件或目录

1
2
3
4
5
6
7
8
9
10
/text.txt           # 忽略根目录下 text.txt 文件
/test/text.txt # 忽略根目录下 test 目录中的 text.txt 文件

text.txt # 忽略所有名为 text.txt 的文件和目录
test/ # 忽略所有名为 test 的目录
test # 忽略所有名为 test 的文件和目录

*test* # 忽略所有名字带有 test 的文件和目录
img* # 忽略所有名字开头是 img 的文件和目录
*.md # 忽略所有名字结尾是 .md 的文件和目录

如何不忽略一个文件或目录

加入我们忽略了所有md文件, 但唯独不忽略README.md的情况, 如下所示.

1
2
*.md                # 忽略所有名字结尾是 .md 的文件和目录
!README.md # 不忽略所有名字是 README.md 的文件和目录

无法排除已经被忽略的目录内的一个文件

1
2
test/               # 忽略所有名字带有 test 的目录
!test/example.md # 试图在被忽略的目录内排除一个文件是行不通的

如何忽略以前提交的文件

假如你不小心把 .build 文件提交了, 现在想去除这个文件.过程如下所示.

1
2
3
4
5
6
7
8
9
10
11

# 给 .gitignore 添加 .build 文件
echo ".build" >> .gitignore

# 从 git 版本库中去除该文件
git rm --cached .build

# 暂存新的 .gitignore 并提交
git add .gitignore
git commit -m "更新了 .gitignore 文件"

白名单模式

一般来说 gitignore 是使用黑名单模式,也就是写在 ignore 中的项目会被忽略,但是有些时候使用黑名单模式会有一些疏漏,当项目逐渐庞大起来,难免会有一些遗漏,将一些临时的文件和不希望添加入 git 管理的文件加入 git 中。这个时候白名单模式的 gitignore 能够实现更加精细的管理。

假设我们有以下结构的 C++ 项目需要管理:

1
2
3
4
5
6
7
your-project-dir
├── .gitignore # gitignore 文件
├── build/ # 构建项目的临时目录
├── docs/ # 放置各种文档
├── include/ # 放置 .h 头文件
├── README.md # 自述文档
└── src/ # 放置 .cpp 代码文件

首先忽略全部文件,并取消忽略目录

白名单模式的第一步。这里取消忽略目录,是因为被忽略目录下的文件是必定被忽略的,为了能够指定不忽略的文件,需要取消忽略目录。

1
2
*
!*/

忽略不想要的目录 (可选)

因为 build 目录完全是一个临时目录,我不想提交里面的任何文件,所以可以直接忽略掉。如果这个目录里有某些特定的文件需要提交,则不应该忽略这个目录。

之所以这是一个可选的操作,是因为在上一步忽略了所有文件之后,所有的目录内都没有需要提交的文件,在 git 中这样的“空目录”不会被提交。

1
/build/

取消忽略特定的文件

有些文件我们希望必须提交的,我们可以直接取消忽略这些文件。

1
2
!.gitignore
!README.md

这样之后,任何目录下的 .gitignore 和 README.md 文件都会被提交上去。

取消忽略特定目录下的全部文件

我希望提交 docs 目录下的所有文件,包括其子目录下的所有文件。通过下面这种写法,可以取消忽略 docs 目录下的所有文件,并递归的忽略其子目录下的所有文件。

1
!/docs/**/*

取消忽略特定目录下的特定文件

有的目录我只希望提交特定类型的文件,例如 src 目录下的 .cpp 代码文件和 include 目录下的 .h 头文件。

下面这种写法取消忽略了对应目录下所有特定后缀的文件,并递归的忽略其子目录下所有特定后缀的文件。

1
2
!/include/be/**/*.h
!/src/be/**/*.cpp

总结

最后的白名单 gitignore 文件看上去是这样的:

1
2
3
4
5
6
7
8
9
10
11
*
!*/

/build/

!.gitignore
!README.md

!/docs/**/*
!/include/be/**/*.h
!/src/be/**/*.cpp

在比较复杂的项目中,或者需要精细控制提交的文件时,白名单模式的 ignore 文件是真的很好用。

0%