项目概述
PTTBBS 是🇨🇳台湾最大的 BBS(电子布告栏系统)PTT.cc 的开源软件,由🇨🇳台湾大学学生开发维护,自 1995 年运行至今。它支撑了超过 30 万注册用户、数千个看板(讨论区),是华人互联网历史上最长寿、最活跃的在线社区之一。
核心定位:高性能、高并发的纯文字 BBS 系统,基于 Telnet 协议,支持 SSH/WebSocket 接入。
---
架构全景图
┌─────────────────────────────────────────────────────────────────────────────┐
│ 客户端接入层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Telnet │ │ SSH │ │ WebSocket │ │
│ │ (端口23) │ │ (端口22) │ │ (wsproxy) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────┴─────────────────┘ │
│ │ │
│ ▼ fork() │
├─────────────────────────────────────────────────────────────────────────────┤
│ mbbsd 主进程 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 用户认证流程 │ │
│ │ 1. 连接建立 → 2. 登录验证 → 3. 加载 userec → 4. 进入主循环 │ │
│ │ ↑ │ │ │
│ │ │ ┌─────────────┐ │ │ │
│ │ └──────────────┤ .PASSWDS │◄──────────────────┘ │ │
│ │ │ 用户密码文件 │ │ │
│ │ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 共享内存 (Shared Memory) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ uhash │ │ utmp │ │ bcache │ │ │
│ │ │ 用户ID哈希表 │ │ 在线用户表 │ │ 看板缓存 │ │ │
│ │ │ userid→uid │ │ userinfo_t[] │ │boardheader_t[]│ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ SHM_KEY = 1228 (SysV IPC) │ │
│ │ SHM_SIZE = ~4MB (可配置) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 核心功能模块 │ │
│ │ │ │
│ │ read.c → 文章阅读器 │ board.c → 看板浏览器 │ │
│ │ edit.c → 文章编辑器 │ mail.c → 邮件系统 │ │
│ │ bbs.c → 发帖/推文 │ talk.c → 即时聊天 │ │
│ │ friend.c → 好友系统 │ admin.c → 管理功能 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼ 文件系统
┌─────────────────────────────────────────────────────────────────────────────┐
│ 数据存储层 │
│ │
│ ┌─────────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ 用户数据 (.PASSWDS) │ │ 看板数据 │ │
│ │ │ │ │ │
│ │ userec_t[] 数组 │ │ boards/A/Ask/.DIR ← 索引文件 │ │
│ │ - userid (12字节) │ │ boards/A/Ask/M.xxxxxx ← 文章文件 │ │
│ │ - passwd (14字节) │ │ boards/A/Ask/.DIR.bottom │ │
│ │ - userlevel (权限) │ │ │ │
│ │ - numlogindays │ │ home/a/alice/ ← 用户目录 │ │
│ │ - numposts │ │ ├── .DIR (个人邮箱索引) │ │
│ │ - money (P币) │ │ ├── M.xxxxx (收件箱) │ │
│ │ - ... │ │ └── friend (好友列表) │ │
│ │ │ │ │ │
│ │ 固定长度记录: ~400字节 │ │ .BRD (看板列表文件) │ │
│ │ 最多 MAX_USERS (15万) │ │ boardheader_t[] │ │
│ │ │ │ │ │
│ └─────────────────────────┘ └──────────────────────────────────────┘ │
│ │
│ 文件名格式: M.1120582370.A.1EA │
│ M. + Unix时间戳 + . + 分类字母 + . + 16进制序号 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
---
核心数据结构
1. 用户记录 (userec_t)
typedef struct userec_t {
uint32_t version; // 结构版本号
char userid[IDLEN+1]; // 用户ID (12字符)
char realname[20]; // 真实姓名
char nickname[24]; // 昵称
char passwd[PASSLEN];// 加密密码 (14字节)
uint32_t uflag; // 用户标志位
uint32_t userlevel; // 权限等级
uint32_t numlogindays; // 登录天数
uint32_t numposts; // 发文数
time4_t firstlogin; // 首次登录时间
time4_t lastlogin; // 最后登录时间
int32_t money; // P币余额
char email[EMAILSZ]; // 邮箱
char career[40]; // 职业
char myangel[IDLEN+1]; // 小天使
// 游戏记录、签名档等其他字段...
char pad_tail[28]; // 填充至固定大小
} PACKSTRUCT userec_t; // ~400 字节
存储方式:所有用户记录存储在单个文件 .PASSWDS 中,采用固定长度记录数组。用户 ID 转换为 uid(数组下标)通过内存中的哈希表加速。
---
2. 看板头 (boardheader_t)
typedef struct boardheader_t { // 256 字节
char brdname[IDLEN + 1]; // 看板英文名
char title[BTLEN + 1]; // 看板中文标题 (48字符)
char BM[IDLEN * 3 + 3]; // 板主ID列表 (最多3人)
uint32_t brdattr; // 看板属性标志
time4_t bupdate; // 最后更新时间
uint32_t level; // 进入所需权限
int32_t gid; // 分组ID
int32_t next[2]; // 链表指针
int32_t parent; // 父看板ID
int32_t childcount; // 子看板数
int32_t nuser; // 在线人数
int32_t postexpire; // 文章过期天数
char pad4[40]; // 填充
} PACKSTRUCT boardheader_t;
看板属性标志:
| 标志 | 含义 |
|---|---|
| BRD_POSTMASK | 需盖文章才可阅读 |
| BRD_ANONYMOUS | 匿名版 |
| BRD_VOTEBOARD | 投票版 |
| BRD_OVER18 | 18禁看板 |
| BRD_RESTRICTEDPOST | 限制发文权限 |
| BRD_GUESTPOST | 允许 Guest 发文 |
3. 文章头 (fileheader_t)
typedef struct fileheader_t { // 128 字节
char filename[FNLEN]; // 文件名 M.timestamp.A.xxx
time4_t modified; // 修改时间
char recommend; // 推文数/推荐等级
char owner[IDLEN + 2]; // 作者ID
char date[6]; // 显示日期 [02/02]
char title[TTLEN + 1]; // 标题 (64字符)
union {
int money; // 文章价值 (红包等)
int anon_uid; // 匿名作者UID
struct { ... } vote_limits;
struct { ... } refer;
} multi;
unsigned char filemode; // 文件模式标志
char pad3[3];
} PACKSTRUCT fileheader_t;
文件模式标志:
| 标志 | 含义 |
|---|---|
| FILE_READ | 已读 |
| FILE_MARKED | 标记 |
| FILE_DIGEST | 精华区 |
| FILE_BOTTOM | 置底 |
| FILE_SOLVED | 问题已解决 |
| FILE_ANONYMOUS | 匿名发文 |
多用户架构详解
1. 进程模型:One Process Per Connection
┌─────────────────────────────────────────────────────────┐
│ 父进程 (mbbsd) │
│ - 监听端口 23 (Telnet) / 22 (SSH) │
│ - 等待新连接 │
└────────────────────────┬────────────────────────────────┘
│ accept()
▼
┌─────────────────────────────────────────────────────────┐
│ 子进程 fork() │
│ - 每个用户独立进程 │
│ - 通过共享内存交换数据 │
│ - 独立文件句柄,互不影响 │
└─────────────────────────────────────────────────────────┘
关键代码(mbbsd.c):
static void
start_daemon(struct ProgramOption *option)
{
// 预加载时区数据,避免每个子进程重复加载
time_t dummy = time(NULL);
struct tm dummy_time;
localtime_r(&dummy, &dummy_time);
if (option->flag_fork) {
if (fork()) { exit(0); } // 第一次 fork,脱离父进程
}
setsid(); // 创建新会话
if (option->flag_fork) {
if (fork()) { exit(0); } // 第二次 fork,防止获取控制终端
}
}
---
2. 共享内存架构 (SHM)
typedef struct {
int version; // SHM_VERSION (4842)
int size; // sizeof(SHM_t)
/* uhash - 用户ID哈希表 */
char userid[MAX_USERS][IDLEN + 1];
int next_in_hash[MAX_USERS];
int hash_head[1 << HASH_BITS];
int number; // 总用户数
int loaded; // 是否已加载
/* utmpshm - 在线用户表 */
userinfo_t uinfo[USHM_SIZE]; // USHM_SIZE = MAX_ACTIVE * 41/40
int sorted[2][9][USHM_SIZE]; // 排序缓存
int currsorted;
time4_t UTMPuptime;
int UTMPnumber; // 当前在线人数
/* brdshm - 看板缓存 */
int BMcache[MAX_BOARD][MAX_BMs];
boardheader_t bcache[MAX_BOARD];
int bsorted[2][MAX_BOARD];
int total[MAX_BOARD]; // 各看板文章数
time4_t lastposttime[MAX_BOARD];
int Bnumber; // 看板总数
/* 其他统计、公告等... */
} SHM_t;
共享内存初始化流程:
1. uhash_loader 程序从 .PASSWDS 加载所有用户ID到哈希表
2. shmctl init 初始化共享内存段
3. 所有 mbbsd 子进程通过 shmat() 附加到同一块共享内存
4. 使用 System V IPC (shmget/shmat),Key = 1228
---
3. 在线用户管理 (UTMP)
typedef struct userinfo_t {
int uid; // 用户ID (数组下标)
pid_t pid; // 进程ID (用于信号通知)
int sockaddr;
unsigned int userlevel;
char userid[IDLEN + 1];
char nickname[24];
char from[27]; // 登录来源
in_addr_t from_ip; // IP地址
/* 好友系统 */
short nFriends;
int myfriend[MAX_FRIEND];
unsigned int friend_online[MAX_FRIEND];
int reject[MAX_REJECT];
/* 消息队列 */
char msgcount;
msgque_t msgs[MAX_MSGS];
/* 用户状态 */
unsigned char active; // 是否活跃
unsigned char invisible; // 隐身模式
unsigned char mode; // 当前模式
unsigned char pager; // 寻呼机开关
time4_t lastact; // 最后活动时间
/* 聊天/游戏 */
int destuid; // 聊天对象
unsigned char in_chat;
char chatid[11];
/* 游戏记录... */
} userinfo_t;
多用户交互机制:
- 站内信:通过
msgs[]队列,接收方进程收到SIGUSR1信号 - 即时聊天:通过
destuid指向对方userinfo_t,配合信号通知 - 好友在线:
friend_online[]数组直接标记好友的 utmp 索引
贴文数据存储详解
1. 目录结构
/home/bbs/ # BBSHOME
├── .PASSWDS # 用户密码/资料文件
├── .BRD # 看板列表
├── boards/
│ ├── A/ # 按首字母分类
│ │ ├── Ask/
│ │ │ ├── .DIR # 看板索引 (fileheader_t 数组)
│ │ │ ├── .DIR.bottom # 置底文章索引
│ │ │ ├── M.1120582370.A.1EA # 文章文件
│ │ │ ├── M.1120582371.B.2F3C
│ │ │ └── ...
│ │ └── Avgirl/
│ ├── B/
│ │ ├── Boy-Girl/
│ │ └── Baseball/
│ └── ...
├── home/
│ ├── a/
│ │ ├── alice/ # 用户 alice 的目录
│ │ │ ├── .DIR # 个人邮箱索引
│ │ │ ├── M.xxxxxx # 收件箱文章
│ │ │ ├── friend # 好友列表
│ │ │ ├── reject # 黑名单
│ │ │ └── ...
│ └── b/
│ ├── bob/
│ └── ...
└── man/ # 系统公告/说明
└── boards/
---
2. 文章文件命名规则
M.1120582370.A.1EA
│ └──────────┘ │ └┘
│ 时间戳 │ 序号
│ 分类
文章标记
时间戳:Unix timestamp (发文时间)
分类:A-Z 表示不同类别
序号:16进制,同一秒内的第 N 篇文章
---
3. .DIR 索引文件格式
.DIR 文件是一个固定长度记录数组,每个记录 128 字节(sizeof(fileheader_t)):
┌─────────────────────────────────────────────────────────────┐
│ 记录 0 │ 记录 1 │ 记录 2 │ ... │ 记录 N-1 │
│ 128字节 │ 128字节 │ 128字节 │ │ 128字节 │
│ │ │ │ │ │
│ 第1篇文章 │ 第2篇文章 │ 第3篇文章 │ │ 第N篇文章 │
│ 最旧 │ │ │ │ 最新 │
└─────────────────────────────────────────────────────────────┘
关键操作:
// 读取索引文件
int apply_record(const char *fpath,
int (*fptr)(void *ptr, void *arg),
size_t size, void *arg);
// 添加新记录(追加到末尾)
int append_record(const char *fpath, const void *record, size_t size);
// 替换记录
int substitute_record2(const char *fpath, const void *srcptr,
const void *destptr, size_t size,
int id, int (*is_same)(const void*, const void*));
---
4. 文章内容格式
作者: alice (小愛) 看板: Ask
標題: [問題] 如何學習 C 語言?
時間: Tue Aug 16 22:25:10 2025
───────────────────────────────────────
大家好,我是新手...
文章內容...
--
※ 發信站: 批踢踢實業坊(ptt.cc), 來自: 123.45.67.89
※ 文章網址: https://www.ptt.cc/bbs/Ask/M.1120582370.A.1EA.html
---
多用户并发控制
1. 文件锁机制
// 记录锁 (record locking)
int filelocking(int fd, int type, off_t offset, off_t size);
// 使用示例:修改 .PASSWDS 时加锁
filelocking(passwd_fd, F_WRLCK, uid * sizeof(userec_t), sizeof(userec_t));
// 修改用户记录...
filelocking(passwd_fd, F_UNLCK, uid * sizeof(userec_t), sizeof(userec_t));
2. 信号量 (Semaphore)
// 用于保护共享内存中的计数器
// PASSWDSEM_KEY = 2010
int semid = semget(PASSWDSEM_KEY, 1, IPC_CREAT | 0666);
semop(semid, &lock_op, 1); // P 操作
// 访问共享资源
semop(semid, &unlock_op, 1); // V 操作
3. 原子操作
// 原子递增推文数
// 通过临时文件 + rename 实现原子更新
void recommend_article(int bid, const char *filename) {
// 1. 读取 .DIR 到内存
// 2. 找到对应文章记录
// 3. recommend++
// 4. 写入 .DIR.tmp
// 5. rename(.DIR.tmp, .DIR) // 原子操作
}
---
多用户实践方案
方案一:单机多实例(传统部署)
# 1. 创建 BBS 用户
useradd -u 9999 -g 99 -d /home/bbs -s /home/bbs/bin/bbsrf bbs
# 2. 编译安装
make clean
make all install
# 3. 初始化数据
bin/initbbs -DoIt
# 4. 启动共享内存
bin/shmctl init
# 5. 启动 BBS 服务 (root)
bin/mbbsd -p 23 -d
限制:单台服务器通常支持 1000-2000 并发连接(受限于进程数和内存)。
---
方案二:WebSocket 代理(现代接入)
浏览器 ──► Nginx ──► wsproxy ──► mbbsd (localhost:23)
│ │
│ └─ daemon/wsproxy/
│
└─ SSL/TLS 终结
wsproxy 功能:
- WebSocket <-> Telnet 协议转换
- 允许浏览器用户接入传统 BBS
- 保持 mbbsd 无需修改
方案三:分布式部署(大型站)
┌─────────────┐
用户A ──► 负载均衡器 │ │
用户B ──► (HAProxy) │ mbbsd #1 │──► 共享存储 (NFS)
用户C ──► │ │ (.PASSWDS, boards/)
├─────────────┤
│ mbbsd #2 │──► 共享内存 (需要特殊处理)
│ │
├─────────────┤
│ mbbsd #3 │
│ │
└─────────────┘
挑战:共享内存 (SysV IPC) 不支持跨机器,需要: 1. 使用共享存储 (NFS) 存放文件数据 2. 使用 Redis/Memcached 替代本地共享内存 3. 或使用 DPDK/RDMA 技术实现内存池共享
---
性能优化点
1. 内存对齐与填充
// 使用 __attribute__((packed)) 防止编译器填充
// 保证结构体大小固定,便于随机访问
#define PACKSTRUCT __attribute__ ((packed))
typedef struct fileheader_t {
// ...
} PACKSTRUCT fileheader_t; // 严格 128 字节
2. 哈希表优化
// 用户ID查找使用链式哈希
// 哈希桶数: 1 << HASH_BITS (通常 65536)
// 平均链长: MAX_USERS / (1<<HASH_BITS) ≈ 2-3
int StringHash(const char *str) {
// FNV-1a 哈希算法
Fnv32_t hash = FNV1_32_INIT;
while (*str) {
hash ^= (unsigned char)*str++;
hash *= FNV_32_PRIME;
}
return hash;
}
3. 索引缓存
// 看板索引 (.DIR) 缓存在内存中
// 通过 mmap 或 read 加载到 headers[] 数组
static int headers_size = 0;
static fileheader_t *headers = NULL;
// 切换看板时加载
int enter_board(const char *boardname) {
// 1. 检查权限
// 2. mmap .DIR 文件到 headers
// 3. headers_size = filesize / sizeof(fileheader_t)
}
---
与现代系统的对比
| 特性 | PTTBBS (1995) | 现代论坛 (2024) |
|---|---|---|
| 协议 | Telnet (纯文字) | HTTP/WebSocket |
| 架构 | 进程 per 连接 | 线程池 / 异步IO |
| 存储 | 二进制文件 | 关系数据库 |
| 缓存 | SysV 共享内存 | Redis/Memcached |
| 部署 | 单机 | 微服务/容器 |
| 前端 | 终端模拟器 | React/Vue |
| 安全 | 明文密码 (DES) | bcrypt/Argon2 |
核心设计哲学
1. 简单即美:没有数据库,只有文件;没有线程,只有进程
2. 固定长度记录:所有结构体使用 packed 属性,支持 O(1) 随机访问
3. 共享内存一切:用户状态、在线列表、看板缓存全部在 SHM
4. Unix 哲学:每个功能一个程序,mbbsd 只处理连接,daemon/ 处理后台任务
5. 向后兼容:从 1995 年至今,数据结构保持兼容,平滑升级
---
总结
PTTBBS 的架构是前 Web 时代的杰作,它证明了:
- 纯文字界面可以支撑百万级用户的社区
- 简单的文件系统存储可以替代复杂的数据库
- 共享内存是多进程系统的有效通信方式
- 固定长度记录 + 哈希表 = 高效的键值存储
---
参考链接:
- 官方仓库:https://github.com/ptt/pttbbs
- PTT 站:https://www.ptt.cc
- 维基百科:https://zh.wikipedia.org/wiki/批踢踢
#pttbbs #BBS #架构 #多用户 #台湾 #技术解析 #小凯