如何确保树莓派Python脚本及子进程在终端正确处理Ctrl-C?
Raspberry Pi OLED 脚本优雅退出问题排查与解决
系统环境
- 运行Buster系统的Raspberry Pi 4,Python 3.7,GPIO引脚连接Adafruit 128x32 2.23" OLED Bonnet
- 脚本基于Adafruit官方示例修改
问题描述
我给脚本添加了SIGINT/SIGTERM信号捕获处理代码,期望程序退出时能清屏并关闭OLED显示,但实际运行存在差异:
- 在Thonny中运行,按下Ctrl-C总能触发优雅退出,清屏并正常结束;
- 在终端窗口运行时:
- 从其他终端发送kill信号,每次都能正常退出;
- 直接在终端按Ctrl-C,多数情况下会抛出
subprocess.CalledProcessError异常,仅少数情况正常。
修改后的脚本如下:
#!/usr/bin/python3.7 # SPDX-FileCopyrightText: <text> 2020 Tony DiCola, James DeVito, # and 2020 Melissa LeBlanc-Williams, for Adafruit Industries </text> # SPDX-License-Identifier: MIT # This example is for use on (Linux) computers that are using CPython with # Adafruit Blinka to support CircuitPython libraries. CircuitPython does # not support PIL/pillow (python imaging library)! # Modified 2024/10/12 by Jim Harris to allow for signal capture # and a graceful shutdown import os import signal import sys from threading import Condition, Thread, Event import time import subprocess from board import SCL, SDA, D4 import busio import digitalio from PIL import Image, ImageDraw, ImageFont import adafruit_ssd1305 # check if it's ran with Python3 assert sys.version_info[0:1] == (3,) # for triggering the shutdown procedure when a signal is detected keyboard_trigger = Event() def signal_handler(signal, frame): print('\nSignal detected. Stopping threads.') keyboard_trigger.set() # registering both types of signals signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) # Get PID of this running script pid = os.getpid() # Define the Reset Pin oled_reset = digitalio.DigitalInOut(D4) # Create the I2C interface. i2c = busio.I2C(SCL, SDA) # Create the SSD1305 OLED class. # The first two parameters are the pixel width and pixel height. Change these # to the right size for your display! disp = adafruit_ssd1305.SSD1305_I2C(128, 32, i2c, reset=oled_reset) # Clear display. disp.fill(0) disp.show() # Create blank image for drawing. # Make sure to create image with mode '1' for 1-bit color. width = disp.width height = disp.height image = Image.new("1", (width, height)) # Get drawing object to draw on image. draw = ImageDraw.Draw(image) # Draw a black filled box to clear the image. draw.rectangle((0, 0, width, height), outline=0, fill=0) # Draw some shapes. # First define some constants to allow easy resizing of shapes. padding = -2 top = padding bottom = height - padding # Move left to right keeping track of the current x position for drawing shapes. x = 0 # Load default font. #font = ImageFont.load_default() # Alternatively load a TTF font. Make sure the .ttf font file is in the # same directory as the python script! # Some other nice fonts to try: http://www.dafont.com/bitmap.php font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 9) print("Started system statistics printout as PID", pid) # Main drawing routine #while True: while not keyboard_trigger.is_set(): # Draw a black filled box to clear the image. draw.rectangle((0, 0, width, height), outline=0, fill=0) # Shell scripts for system monitoring from here: # https://unix.stackexchange.com/questions/119126/command-to-display-memory-usage-disk-usage-and-cpu-load cmd = "hostname -I | cut -d' ' -f1" IP = subprocess.check_output(cmd, shell=True).decode("utf-8") cmd = "top -bn1 | grep load | awk '{printf \"CPU Load: %.2f\", $(NF-2)}'" CPU = subprocess.check_output(cmd, shell=True).decode("utf-8") cmd = "free -m | awk 'NR==2{printf \"Mem: %s/%s MB %.2f%%\", $3,$2,$3*100/$2 }'" MemUsage = subprocess.check_output(cmd, shell=True).decode("utf-8") cmd = 'df -h | awk \'$NF=="/"{printf "Disk: %d/%d GB %s", $3,$2,$5}\'' Disk = subprocess.check_output(cmd, shell=True).decode("utf-8") # Write four lines of text. draw.text((x, top + 0), "IP: " + IP, font=font, fill=255) draw.text((x, top + 8), CPU, font=font, fill=255) draw.text((x, top + 16), MemUsage, font=font, fill=255) draw.text((x, top + 25), Disk, font=font, fill=255) # Display image. disp.image(image) disp.show() time.sleep(0.1) # until some keyboard event is detected print("\nA keyboard event was detected.") print("Clearing and shutting down display") # Clear display. disp.fill(0) disp.show() sys.exit(0)
疑问
- 为什么终端中直接按Ctrl-C会出现这种偶尔抛出异常的情况?
- 如何确保代码能可靠捕获Ctrl-C信号,实现无异常的优雅退出?
问题原因
当在终端直接按Ctrl-C时,SIGINT信号会发送给终端进程组内的所有进程——包括Python主进程和它正在执行的子进程(比如subprocess.check_output调用的shell命令)。
如果信号刚好在subprocess.check_output执行期间触发,子进程会被中断并返回非零退出码,而check_output默认会在子进程非零退出时抛出CalledProcessError,此时信号处理函数还没来得及设置keyboard_trigger事件,异常就先被抛出了。
而Thonny运行时,终端信号被Thonny接管,仅发送给Python主进程;从其他终端发送kill信号时,信号仅针对Python主进程PID,不会影响子进程,因此不会触发异常。
解决方案
1. 捕获子进程异常
在主循环的子进程调用块外添加异常捕获,触发退出逻辑:
while not keyboard_trigger.is_set(): draw.rectangle((0, 0, width, height), outline=0, fill=0) try: cmd = "hostname -I | cut -d' ' -f1" IP = subprocess.check_output(cmd, shell=True).decode("utf-8") cmd = "top -bn1 | grep load | awk '{printf \"CPU Load: %.2f\", $(NF-2)}'" CPU = subprocess.check_output(cmd, shell=True).decode("utf-8") cmd = "free -m | awk 'NR==2{printf \"Mem: %s/%s MB %.2f%%\", $3,$2,$3*100/$2 }'" MemUsage = subprocess.check_output(cmd, shell=True).decode("utf-8") cmd = 'df -h | awk \'$NF=="/"{printf "Disk: %d/%d GB %s", $3,$2,$5}\'' Disk = subprocess.check_output(cmd, shell=True).decode("utf-8") except subprocess.CalledProcessError: keyboard_trigger.set() continue # 后续绘图代码不变
2. 隔离子进程与终端信号
调用subprocess时设置preexec_fn=os.setpgrp,让子进程脱离当前进程组,避免终端SIGINT信号传递给子进程:
IP = subprocess.check_output(cmd, shell=True, preexec_fn=os.setpgrp).decode("utf-8")
注意:此方法需确保子进程能被正确清理,结合信号处理逻辑使用更稳妥。
3. 优化信号处理(可选)
可以在信号处理函数中直接设置全局标志,并在主循环最开始检查标志,减少延迟:
exit_flag = False def signal_handler(signal, frame): global exit_flag print('\nSignal detected. Stopping threads.') exit_flag = True # 注册信号 signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) # 主循环 while not exit_flag: if exit_flag: break # 后续代码不变
内容的提问来源于stack exchange,提问作者Jim JR Harris
相关产品推荐
相关产品推荐

