
A同学上周丢给我一台旧网关说宿舍网一到晚高峰就卡成PPT视频会议、直播、下载全都挤在一起他想在出口上给每个终端做一个带宽配额。我登进设备看到的是一个再朴素不过的提示符——没有彩色bash没有readline命令集就是那个被叫烂了的外号“瑞士军刀”。要在这种环境里完成queueset设置说实话跟在桌面Linux上敲tc命令完全不是一回事。这篇文章我会把整个过程的原理和踩坑记录都摊开。内容包括三件事第一搞清楚queueset到底在设置什么第二在精简环境里把tc命令真正跑起来的方法第三一个可以直接改改就能用的多用户限速脚本以及我实际踩过的五个坑。适合正在折腾嵌入式网关、工控设备或者瘦客户机的朋友也适合在路由器上做带宽管理的爱好者。1. 先搞明白两件事瑞士军刀是谁queueset在干什么1.1 为什么嵌入式开发者喜欢把BusyBox叫瑞士军刀所谓瑞士军刀大家一般默认指的是BusyBox。它最大的特点是把上百个常用Unix命令塞进一个可执行文件里体积做到几百KB静态链接之后扔到任何嵌入式rootfs里都能跑。开发者在裁剪系统时不一定需要完整的GNU工具集但总得有个能改网络、看进程、写文件的工具BusyBox刚好都能给。这也是为什么很多商用交换机、网关、网络存储的底层shell里第一个出现的都是这个提示符。不过鱼和熊掌不可兼得。BusyBox的每个子命令都是奔着“能用就行”去的参数被砍得很凶语法兼容程度也随版本波动。你以为它是开箱即用的瑞士军刀实际上它更接近一套应急工具箱该有的功能基本有但精细操作时得先确认手里的版本是不是支持那个参数。这一点在队列设置这件事上体现得特别明显。我遇到过一台设备BusyBox里集成了ping、ifconfig、route、awk看起来什么都有但当我敲出tc这个命令时shell直接回了句“not found”。当时我就明白想在这上面做队列管理必须自己想办法把工具补齐。1.2 queueset到底是一个命令还是一套配置先给结论queueset并不是Linux标准命令你要是直接敲queueset会得到command not found。我在实际项目里通常把它理解成queue settings——队列规则集合。它由三部分构成qdisc排队规则、class流量类别、filter过滤器。qdisc决定包怎么排队class决定队列分几路filter决定哪个包进哪一路。工程上我更喜欢把这一堆规则写进一个可重复执行的脚本文件名就叫queueset.sh。这样每次设备重启或者规则被改坏一条命令就能把整个队列布局拉起来比手工一条条敲tc靠谱得多。所以后面提到queueset你既可以当它是概念也可以当它是一个脚本文件意思都对。1.3 哪些设备上你才需要关心队列设置队列设置不是数据中心的专用技术。只要存在共享出口带宽的场景就值得关注多用户公寓网关需要给每个房间限速工业数据采集器需要保证控制报文优先于普通日志流量AP热点上要给访客和内部员工分不同的带宽池。还有更常见的场景一台旧路由刷了精简系统之后用它来给智能家居设备分带宽防止某个摄像头疯狂上传把整个网络拖垮。只不过在桌面Linux上装个软件包就能搞定的事换到这种精简环境里每一步都可能出岔子。这也是我决定写这篇文章的直接原因想让大家少走那些我已经走完的弯路。2. 队列规则在内核里是怎么流转的qdisc、class与filter的配合2.1 排队无处不在从网卡环形队列到qdisc很多人以为限速就是给网卡设一个rate值实际上没那么简单。网卡驱动内部有环形缓冲区数据包发出去之前都会在这里排队但环形队列只保证先进先出不会替你区分优先级也不会按不同用户分类限速。内核协议栈在把包交给网卡之前还会经过一层qdisc——也就是排队规则。这层规则才是queueset真正操作的地方。类比一下环形队列是超市的收银台排队所有人都必须按顺序来qdisc则像银行大厅的分流系统VIP去VIP柜台普通用户去普通柜台某个柜台排队太长了还能实时调整柜员分配。你要做带宽管理本质就是在这层调度器里给不同流量画格子。2.2 classful qdisc和classless qdisc的区别qdisc分成两大类。一类是classless典型代表是pfifo、pfifo_fast、tbf、red。它们没有子类就像只有一个柜台所有包都进同一个队列要么先进先出要么按简单优先级处理。另一类是classful典型代表是HTB、HFSC、CBQ。它们可以分出多个子类子类下面还能再套qdisc形成一个树状结构。队列设置里最常用的是HTB也就是层级令牌桶。它的核心价值是把一根物理网线拆成多个逻辑“管道”每个管道带宽可配管道之间可以共享、可以借用。比如总带宽100M给A用户的管道设了20MB用户设了30M这两个管道各自保底如果A没跑满B可以临时借用A的余量。这种灵活度是classless qdisc给不了的。顺带提一句虽然HFSC在延迟控制上更细腻但HTB胜在参数直白、内核支持广泛嵌入式设备上99%的场景其实够用。2.3 filter怎么把流量分类送进class有了class之后还得告诉内核哪些包属于哪个class这个任务由filter完成。最常见的是两种u32 filter直接匹配IP头字段fw filter配合iptables打上的标记。u32的写法非常直白比如“匹配源IP是192.168.1.50的包送到classid 1:10”tc filter add dev ifb0 parent 1: protocol ip prio 1 u32 \ match ip src 192.168.1.50 flowid 1:10fw filter则先通过iptables在mangle表给特定流量打mark再用tc filter按mark值分类iptables -t mangle -A POSTROUTING -s 192.168.1.50 -j MARK --set-mark 10 tc filter add dev ifb0 parent 1: protocol ip prio 1 handle 10 fw flowid 1:10实际使用中如果用户数量多、规则复杂我会优先选fw方案。iptables的匹配条件比u32丰富得多而且一条MARK规则可以同时被多个接口的tc filter复用。u32适合简单、固定前缀的少量IP段脚本循环生成几百条u32时命令长、可读性差容易出错。2.4 动手前先把rate/ceil/burst这些参数算明白HTB里三个参数必须搞清楚rate是每个class保证得到的带宽ceil是它能借到或者说突发的上限带宽burst和cburst是令牌桶的突发容量。单位上最容易踩坑。tc命令里速率大小写敏感10Mbps写10mbit10MB/s要写10mbyte。搞混了限速效果会差八倍。burst的计算有个经验公式按内核时钟频率来。假设系统时钟是250Hz也就是一个tick是4msburst一般取rate在2个tick内的数据量。如果rate是10Mbps按字节算大约是1.25MB/s一个tick能发5000字节两个tick就是10000字节burst给10k左右比较稳。这个值时大时小建议现场用iperf打几次流微调一下。重要提醒burst设太大限速会“形同虚设”比如rate只有10Mburst给了1M瞬间就能把10秒钟的流量全发出去设太小TCP的突发节奏会被打断下载速度上不去延迟反而升高。3. 让嵌入式设备speak tcBusyBox自带的局限与静态补全方案3.1 BusyBox自带tc命令的真实面貌我见过不少以为“BusyBox 所有命令都有”的朋友一上来就按桌面Linux的思维敲tc结果一脸懵。实际情况是BusyBox是否包含tc、包含到什么程度完全取决于编译配置。有的版本把tc编进去了但只是阉割版能执行tc qdisc show看看已有规则却没法add有的版本压根没编这个applet直接not found。更隐蔽的是第三种装了也能执行但操作一个classful qdisc时内核模块没加载报错信息藏在“RTNETLINK answers: No such file or directory”的后面让你误以为是命令本身的问题。上设备之后先做三件事验证busybox | grep tc tc qdisc show dev eth0 cat /proc/net/dev第一条看BusyBox有没有tc第二条看即使有tc跟内核通信是否正常第三条顺手确认接口名是不是你以为的那个。3.2 没有tc的应急方案如果设备上确实没有完整tc不要花时间去找包管理器。嵌入式设备往往连dpkg、opkg都没有就算有源也未必配好。最靠谱的方案只有一个交叉编译一个静态链接的tc二进制直接拷贝到设备上。为什么强调静态因为嵌入式系统的动态库版本五花八门你拿宿主机上编译的动态版拷过去大概率会报找不到lib库。静态版把依赖全部打进一个文件虽然体积大了点但从稳定性和可移植性来看非常值得。3.3 交叉编译一个静态tc放进rootfs的完整步骤前提你已经有一套能用的交叉编译工具链比如arm-linux-gnueabihf-gcc。准备好iproute2源码然后进入源码目录编译。# 先只编译tc子目录避免整个工具集都编 cd iproute2 make CCarm-linux-gnueabihf-gcc LDFLAGS-static -C tc编译完成后tc/tc这个文件就是静态产物。用scp或者U盘把它传到设备的/usr/sbin/目录下改权限chmod 755 /usr/sbin/tc tc qdisc show dev eth0如果输出正常说明tc已经能跟内核通信了。这里有个容易被忽略的细节如果BusyBox里已经有一个tc的符号链接bash在解析命令时可能先找到BusyBox的applet导致你新拷的tc不生效。解决方法是直接调用绝对路径或者把/usr/sbin放在PATH最前面。4. 落一个能跑的queueset脚本多用户HTB限速全过程4.1 场景设定宿舍网关的公平限速需求为了讲起来具体我设定一个场景某宿舍网关WAN口是eth0.2内网走br-lan需要给10个房间分别限速。上行每个房间限制10Mbps下行每个房间限制20Mbps总带宽上行50Mbps、下行100Mbps。业务上还希望给视频会议类流量一个比较高优先级的通道。需求整理如下方向总带宽每户配额备注上行50Mbps10Mbps在br-lan egress限源IP下行100Mbps20Mbps通过ifb限目的IP高优先级共享总带宽不做严格封顶配合fwmark标记下行为什么不能直接在WAN口限因为WAN口的egress方向是出公网下行流量是到达路由器后从LAN口发出去的所以要在br-lan的egress或者更彻底的办法是用ifb把WAN口进来的流量重定向到一个虚拟设备上再限。ifb方案最干净内网各IP之间互不干扰而且能对链路层包处理得更精细。4.2 queueset脚本逐段拆解完整脚本如下文件名就叫queueset.sh。BusyBox内置的ash能直接跑不需要额外安装bash。#!/bin/sh # queueset.sh - 基于HTB的多用户限速脚本 # 适用BusyBox环境需要完整tc和ifb内核模块 WAN_IFeth0.2 LAN_IFbr-lan IFB_IFifb0 NUM_IP10 BASE_IP192.168.0 # 0. 加载ifb模块并创建虚拟接口 modprobe ifb numifbs1 2/dev/null || true ip link set $IFB_IF up # 1. 清理旧规则幂等性关键 tc qdisc del dev $WAN_IF ingress 2/dev/null tc qdisc del dev $IFB_IF root 2/dev/null tc qdisc del dev $LAN_IF root 2/dev/null # 2. 把WAN入口流量重定向到ifb0 tc qdisc add dev $WAN_IF handle ffff: ingress tc filter add dev $WAN_IF parent ffff: protocol ip prio 1 u32 \ match u32 0 0 action mirred egress redirect dev $IFB_IF # 3. 下行限速在ifb0上建HTB根和各个class tc qdisc add dev $IFB_IF root handle 1: htb default 9999 tc class add dev $IFB_IF parent 1: classid 1:1 htb rate 100mbit ceil 100mbit burst 15k cburst 15k i1 while [ $i -le $NUM_IP ]; do tc class add dev $IFB_IF parent 1:1 classid 1:$i htb \ rate 20mbit ceil 20mbit burst 15k cburst 15k tc filter add dev $IFB_IF parent 1: protocol ip prio 1 \ u32 match ip dst $BASE_IP.$i flowid 1:$i i$((i1)) done # 默认受限class防止漏网之鱼 tc class add dev $IFB_IF parent 1:1 classid 1:9999 htb \ rate 1mbit ceil 1mbit burst 5k cburst 5k # 4. 上行限速在br-lan egress上按源IP分类 tc qdisc add dev $LAN_IF root handle 2: htb default 9999 tc class add dev $LAN_IF parent 2: classid 2:1 htb \ rate 50mbit ceil 50mbit burst 15k cburst 15k i1 while [ $i -le $NUM_IP ]; do tc class add dev $LAN_IF parent 2:1 classid 2:$i htb \ rate 10mbit ceil 10mbit burst 15k cburst 15k tc filter add dev $LAN_IF parent 2: protocol ip prio 1 \ u32 match ip src $BASE_IP.$i flowid 2:$i i$((i1)) done tc class add dev $LAN_IF parent 2:1 classid 2:9999 htb \ rate 1mbit ceil 1mbit burst 5k cburst 5k几个值得解释的地方。第一开头为什么强调先删规则因为tc的规则是累加的重复执行脚本而不清理第二次跑的时候会提示“File exists”然后脚本停在那里。用2/dev/null把报错屏蔽保证qdisc delete找不到旧规则时不会影响后续流程。这是脚本能重复执行的关键。第二默认class设置成9999而不是自动落到某个用户是为了兜底。只要有设备没在名单里它就不会占满整个带宽而是挤进一个1Mbps的小管道里避免出现“未管理设备把全带宽抢占”的尴尬。第三filter的位置很讲究。下行在ifb0上按目的IP匹配因为从WAN进入的流量目的地址就是用户终端上行在br-lan上按源IP匹配因为内网设备发给外网的流量源地址就是终端IP。4.3 验证效果从测试机看统计数据和实际带宽脚本跑完之后先看下行方向的统计tc -s class show dev ifb0每条class后面会有bytes、packets、drops这些计数。重点观察两个一是bytes是不是只增加不减少说明有流量被分类到这里二是drops如果是0但速度明显达不到预期可能是burst配小了。实际打流测试最简单的办法是在外网的服务器上跑iperf3服务端内网电脑跑iperf3客户端分别把协商速率限制起来看能不能被压住。没有iperf3的时候也可以用主机上挂一个HTTP下载任务观察下载速度是否稳定在目标值附近。实测场景里下行的20Mbps限制通常能压得比较准但上行的10Mbps偶尔会跑到10.4Mbps左右这是因为TCP ACK包、管理帧这些系统流量没进class。真遇到这种需求可以再加一个高优先级class专门放这些控制流量但多数场景那零点几Mbps的误差可以接受。4.4 把queueset做成开机自启嵌入式设备重启之后tc规则默认全部丢失所以要么手动执行脚本要么把它塞进开机流程。最简单的做法是写到/etc/rc.local里/usr/sbin/queueset.sh 注意加因为脚本里的ip link set、modprobe这类命令在某些板子上要等网络接口ready之后才能生效放后台执行可以避免rc.local卡死。如果你的设备重启时间很敏感还可以考虑写一个小的等待循环for i in 1 2 3 4 5; do ip link show br-lan /dev/null 21 break sleep 2 done exec /usr/sbin/queueset.sh这样即使br-lan还没起来脚本也会等一下再执行减少启动时序问题。5. 翻车现场集中盘点五个让queueset设置失效的经典问题5.1 错误一tc: no such file or directory 不一定是没装第一次在这类设备上敲tc报错最迷惑的就是这行。实际原因可能有三种确实没装tc装了但这台机器的tc依赖的动态库不存在或者BusyBox的符号链接冲突导致bash找到了一个无法执行的tc入口。排查办法很简单先which tc看命令路径再ldd /usr/sbin/tc看动态库依赖。输出里出现“not found”字样基本就是动态库缺失。解决方式要么补库要么干脆换静态编译版本。5.2 错误二RTNETLINK answers: Operation not permitted这个报错发生在tc已经能执行、但内核拒绝操作时。原因通常是三个进程没有CAP_NET_ADMIN权限、内核没加载对应模块、或设备处于某个不允许改qdisc的状态。先确认是不是root用户确认用户态有权限。再看内核模块HTB对应的是sch_htbfw filter对应cls_fw用modprobe加载一下。很多时候嵌入式系统的内核裁剪得特别狠调度器根本没编进去这才是真正的“隐藏原因”。加载不成功那就得重新编译内核或换系统没什么捷径。5.3 错误三限制了上行下行却纹丝不动这是我见过最多人踩的坑。很多人在WAN口上建了一个HTBfilter按内网IP匹配源地址满心期待地限制下行结果发现一点效果都没有。原因在于Linux的tc只能调节egress发送方向。WAN口的egress是“设备往外发的数据”也就是上行真正的下行数据到达设备时是ingress方向默认情况下没有队列可以让你管。所以才有ifb这个虚拟接口的存在把WAN ingress的包镜像过去再在ifb上建HTB做下行控制。下回想清楚要限一个方向队列必须建在这个方向对应的egress上。下行用ifb或lan出口上行用wan出口或中间设备聚合口。5.4 错误四restart之后就“失忆”配置没持久化tc规则、class、filter全部是运行时的重启即丢。这个问题在桌面Linux上不明显因为你可以写Network Dispatcher脚本但精简设备上没人会帮你自动恢复。我的习惯是所有规则都收在queueset.sh里每次修改之后把脚本保存到rootfs不常动的分区再chmod x最后在rc.local里调用。改规则只改脚本不要直接在shell里增删零散命令。这样出问题之后一条/usr/sbin/queueset.sh就能恢复省去很多临时排查时间。5.5 错误五BusyBox里面算式跑不动缺bc缺awk我第一版脚本里写过一行算burst的代码用的是awk结果设备上awk版本太老printf语法不兼容直接报错。后来发现BusyBox的ash自带了算术扩展根本不需要awk和bc。rate_kbps10000 burst_bytes$((rate_kbps / 8 * 2))记得$(( ))在BusyBox的ash里是完整支持的除法是整数除法算出来的值偏保守一点没关系后续还能微调。如果非要小数精度用awk的printf也行但先确认版本支持别想当然。6. 收尾的个人经验这套玩法到底适合什么场景6.1 什么场景值得用这套组合这套BusyBox 静态tc queueset脚本的组合最适合那些已经被部署、不能随便换系统、又不方便加软件包的设备。旧网关、量产的工控盒子、实验台上的开发板都属于这个范围。它能让你在不改动内核的前提下快速获得成体系的队列管控能力。反过来如果你一开始就有选择权我仍然建议直接选一个带完整软件包管理的发行版装好tc之后用规则文件管理。既然后者更省心那就没必要非在瑞士军刀上玩杂技。6.2 我最后留下来的最小命令集如果让我只保留最核心的一组命令就这几条modprobe ifb numifbs1 ip link set ifb0 up tc qdisc add dev eth0 handle ffff: ingress tc filter add dev eth0 parent ffff: protocol ip u32 match u32 0 0 action mirred egress redirect dev ifb0 tc qdisc add dev ifb0 root handle 1: htb default 20 tc class add dev ifb0 parent 1: classid 1:1 htb rate 100mbit tc class add dev ifb0 parent 1:1 classid 1:10 htb rate 20mbit tc filter add dev ifb0 parent 1: protocol ip u32 match ip dst 192.168.1.0/24 flowid 1:10这套最小集可以应对绝大多数下行为主的场景往上加class和filter无非是复制粘贴的体力活。6.3 如果还要往下走下一步可以怎么折腾HTB解决的是带宽分配问题但现代网络里还有一个痛点叫“公平性”某些TCP连接占满带宽导致其他应用卡顿。这个问题靠HTB比较难处理可以考虑内核支持时换用SFQ、fq_codel或CAKE这类公平队列。它们对延迟和交互体验更友好尤其适合多用户环境下“大家都能用但不互相拖垮”的需求。我在另一台设备上试过把脚本里的htb换成fq_codel思路整体架构完全不变变的只是qdisc类型和class的写法。所以说这篇里讲透的ifb重定向、filter分类、脚本幂等清理这几个思维才是queueset设置里最值钱的部分以后换调度器、换设备这套逻辑照样能复用。