Ressusciter un iPhone 4S en 2025 : datalogger GPS + IMU 10 Hz (de l’idée à l’IPA)
Ressusciter un iPhone 4S en 2025 : datalogger GPS + IMU 10 Hz (de l’idée à l’IPA)
Oui, on peut encore donner une deuxième vie à un iPhone 4S (iOS 9.3.x, ARMv7) : un datalogger GPS + IMU qui enregistre à 10 Hz et exporte en CSV. Ce billet condense toutes les étapes, le code, et les pièges contournés.
TL;DR
- Compiler avec Xcode 13.2.1 sur macOS Monterey pour cibler armv7 et iOS 9.0.
- App Objective-C (UIKit + CoreLocation + CoreMotion), sans dépendances Swift.
- Échantillonnage 10 Hz via
dispatch_source_t ; export CSV + UIFileSharingEnabled.
- Installer sur 4S via Export Development/Ad Hoc ou IPA non signée + Sideloadly.
- Bonus : commandes UTM/QEMU pour brancher l’iPhone en USB à une VM Windows/iTunes.
1) Contexte & contrainte
Les toolchains modernes ne supportent plus armv7/iOS 9. Xcode 13.2.1 est la dernière version confortable pour compiler et signer des apps 32 bits. On reste en Objective‑C pur.
2) Préparer l’environnement
Option A — macOS Monterey natif (recommandé)
- Télécharge Xcode 13.2.1 (More Downloads). Place
Xcode.app dans /Applications.
- Sélectionne la version :
sudo xcode-select -s /Applications/Xcode.app
sudo xcodebuild -license accept
Option B — VM UTM macOS 12 (si ton Mac hôte est trop récent)
- UTM → New → Virtualize → macOS.
- Fournis l’IPSW Monterey (dernière :
UniversalMac_12.6.1_21G217_Restore.ipsw).
- 4 CPU / 8 Go RAM / 80 Go disque. Installe Xcode 13.2.1 dans la VM.
3) Créer le projet (iOS 9, armv7)
- App iOS Objective‑C, Single View App.
- Deployment Target : 9.0
- Architectures : armv7 (ou “Standard (armv7)” dans Xcode 13)
- Enable Bitcode : NO
- Signing automatique ; Bundle ID unique.
Info.plist — clés utiles
NSLocationWhenInUseUsageDescription
NSLocationAlwaysUsageDescription (ou NSLocationAlwaysAndWhenInUseUsageDescription selon la version)
NSMotionUsageDescription
UIBackgroundModes → location (si logging écran éteint)
UIFileSharingEnabled → true (récupérer les CSV via Finder/iTunes)
4) Code de l’app : UI minimaliste & CSV 10 Hz
Deux boutons (Start/Stop, Share CSV) et un label de statut. Location en BestForNavigation, accéléro/gyro via Core Motion, timer 10 Hz.
ViewController.m (complet)
// ViewController.m
#import "ViewController.h"
#import <UIKit/UIKit.h>
#import <CoreLocation/CoreLocation.h>
#import <CoreMotion/CoreMotion.h>
#import <TargetConditionals.h>
#import <math.h>
@interface ViewController () <CLLocationManagerDelegate>
@property (nonatomic, strong) CLLocationManager *loc;
@property (nonatomic, strong) CMMotionManager *mm;
#if __has_feature(objc_arc)
@property (atomic, strong) CLLocation *lastLoc;
#else
@property (atomic, retain) CLLocation *lastLoc;
#endif
@property (atomic) CMAcceleration lastAccel;
@property (atomic) CMRotationRate lastGyro;
#if __has_feature(objc_arc)
@property (nonatomic, strong) NSFileHandle *fh;
@property (nonatomic, strong) NSURL *fileURL;
#else
@property (nonatomic, retain) NSFileHandle *fh;
@property (nonatomic, retain) NSURL *fileURL;
#endif
#if __has_feature(objc_arc)
@property (nonatomic, strong) dispatch_source_t timer;
#else
@property (nonatomic, assign) dispatch_source_t timer;
#endif
@property (nonatomic) UIButton *toggleBtn;
@property (nonatomic) UIButton *shareBtn;
@property (nonatomic) UILabel *statusLabel;
@property (nonatomic) BOOL isLogging;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
[self buildUI];
[self setupLocation];
[self setupMotion];
[self updateUIForState];
}
- (void)buildUI {
UIFont *f = [UIFont boldSystemFontOfSize:16.0];
self.toggleBtn = [UIButton buttonWithType:UIButtonTypeSystem];
self.toggleBtn.frame = CGRectMake(20, 60, 120, 44);
self.toggleBtn.titleLabel.font = f;
[self.toggleBtn setTitle:@"Start" forState:UIControlStateNormal];
[self.toggleBtn addTarget:self action:@selector(toggleLogging) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:self.toggleBtn];
self.shareBtn = [UIButton buttonWithType:UIButtonTypeSystem];
self.shareBtn.frame = CGRectMake(180, 60, 120, 44);
self.shareBtn.titleLabel.font = f;
[self.shareBtn setTitle:@"Share CSV" forState:UIControlStateNormal];
[self.shareBtn addTarget:self action:@selector(shareCSV) forControlEvents:UIControlEventTouchUpInside];
self.shareBtn.enabled = NO;
[self.view addSubview:self.shareBtn];
self.statusLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, 120, 280, 200)];
self.statusLabel.font = [UIFont systemFontOfSize:14.0];
self.statusLabel.textColor = [UIColor darkGrayColor];
self.statusLabel.numberOfLines = 0;
self.statusLabel.text = @"Ready.";
[self.view addSubview:self.statusLabel];
}
- (void)updateUIForState {
[self.toggleBtn setTitle:(self.isLogging ? @"Stop" : @"Start") forState:UIControlStateNormal];
self.shareBtn.enabled = (self.fileURL != nil);
self.statusLabel.text = self.isLogging ? @"Logging @10Hz…" : @"Stopped.";
}
#pragma mark - Location & Motion
- (void)setupLocation {
self.loc = [CLLocationManager new];
self.loc.delegate = self;
self.loc.desiredAccuracy = kCLLocationAccuracyBestForNavigation;
self.loc.distanceFilter = kCLDistanceFilterNone;
if ([self.loc respondsToSelector:@selector(requestWhenInUseAuthorization)]) {
[self.loc requestWhenInUseAuthorization];
}
if ([self.loc respondsToSelector:@selector(requestAlwaysAuthorization)]) {
[self.loc requestAlwaysAuthorization];
}
if ([self.loc respondsToSelector:@selector(setAllowsBackgroundLocationUpdates:)]) {
self.loc.allowsBackgroundLocationUpdates = YES;
}
[self.loc startUpdatingLocation];
if ([self.loc respondsToSelector:@selector(startUpdatingHeading)]) {
[self.loc startUpdatingHeading];
}
}
- (void)setupMotion {
self.mm = [CMMotionManager new];
self.mm.accelerometerUpdateInterval = 1.0/100.0;
self.mm.gyroUpdateInterval = 1.0/100.0;
NSOperationQueue *q = [NSOperationQueue new];
if (self.mm.isAccelerometerAvailable) {
[self.mm startAccelerometerUpdatesToQueue:q withHandler:^(CMAccelerometerData *data, NSError *error) {
if (data) { self.lastAccel = data.acceleration; }
}];
}
if (self.mm.isGyroAvailable) {
[self.mm startGyroUpdatesToQueue:q withHandler:^(CMGyroData *data, NSError *error) {
if (data) { self.lastGyro = data.rotationRate; }
}];
}
}
#pragma mark - Logging
- (void)toggleLogging {
if (self.isLogging) { [self stopLogging]; }
else { [self startLogging]; }
[self updateUIForState];
}
- (void)startLogging {
if (self.fh) return;
[self setupLoggingFile];
[self startTenHzTimer];
self.isLogging = YES;
[self updateUIForState];
}
- (void)stopLogging {
if (self.timer) {
dispatch_source_cancel(self.timer);
#if !__has_feature(objc_arc)
#if !OS_OBJECT_USE_OBJC
dispatch_release(self.timer);
#endif
#endif
self.timer = nil;
}
if (self.fh) { [self.fh closeFile]; self.fh = nil; }
self.isLogging = NO;
[self updateUIForState];
}
- (void)setupLoggingFile {
NSDateFormatter *fmt = [NSDateFormatter new];
fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
fmt.dateFormat = @"yyyy-MM-dd_HH-mm-ss";
NSString *fname = [NSString stringWithFormat:@"log_%@.csv", [fmt stringFromDate:[NSDate date]]];
NSString *docs = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
NSString *path = [docs stringByAppendingPathComponent:fname];
self.fileURL = [NSURL fileURLWithPath:path];
[[NSFileManager defaultManager] createFileAtPath:path contents:nil attributes:nil];
NSError *err = nil;
self.fh = [NSFileHandle fileHandleForWritingToURL:self.fileURL error:&err];
if (!self.fh) {
self.statusLabel.text = [NSString stringWithFormat:@"File open error: %@", err.localizedDescription ?: @"unknown"];
return;
}
NSString *header = @"ts_ms,lat,lon,alt,acc_h,speed,course,ax,ay,az,gx,gy,gz\n";
[self.fh writeData:[header dataUsingEncoding:NSUTF8StringEncoding]];
self.shareBtn.enabled = YES;
}
- (void)startTenHzTimer {
dispatch_queue_t q = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, q);
uint64_t interval = (uint64_t)(0.1 * NSEC_PER_SEC);
dispatch_source_set_timer(self.timer,
dispatch_time(DISPATCH_TIME_NOW, 0),
interval,
(uint64_t)(0.02 * NSEC_PER_SEC));
#if __has_feature(objc_arc)
__weak typeof(self) wself = self;
#else
__unsafe_unretained typeof(self) wself = self;
#endif
dispatch_source_set_event_handler(self.timer, ^{ [wself tickAndWriteCSV]; });
dispatch_resume(self.timer);
}
- (void)tickAndWriteCSV {
if (!self.fh) return;
NSTimeInterval t = [[NSDate date] timeIntervalSince1970];
long long ms = (long long)llround(t * 1000.0);
CLLocation *L = self.lastLoc;
double lat=NAN, lon=NAN, alt=NAN, acc=NAN, speed=NAN, course=NAN;
if (L) { lat=L.coordinate.latitude; lon=L.coordinate.longitude; alt=L.altitude; acc=L.horizontalAccuracy; speed=L.speed; course=L.course; }
CMAcceleration a = self.lastAccel; CMRotationRate g = self.lastGyro;
NSString *line = [NSString stringWithFormat:
@"%lld,%.8f,%.8f,%.2f,%.2f,%.2f,%.2f,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f\n",
ms, lat, lon, alt, acc, speed, course, a.x, a.y, a.z, g.x, g.y, g.z];
@synchronized (self) { [self.fh writeData:[line dataUsingEncoding:NSUTF8StringEncoding]]; }
}
#pragma mark - Partage (simulateur vs appareil)
- (void)shareCSV {
if (!self.fileURL) {
UIAlertController *ac = [UIAlertController alertControllerWithTitle:@"Aucun fichier"
message:@"Commence par Start pour créer un CSV."
preferredStyle:UIAlertControllerStyleAlert];
[ac addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:ac animated:YES completion:nil];
return;
}
if (self.timer) { dispatch_source_cancel(self.timer); self.timer=nil; self.isLogging = NO; [self updateUIForState]; }
if (self.fh) { [self.fh synchronizeFile]; [self.fh closeFile]; self.fh=nil; }
#if TARGET_OS_SIMULATOR
// Simulateur: partager le TEXTE pour éviter -10814
NSError *readErr=nil; NSString *contents=[NSString stringWithContentsOfURL:self.fileURL encoding:NSUTF8StringEncoding error:&readErr];
NSArray *items = contents ? @[contents] : @[self.fileURL.path];
UIActivityViewController *vc=[[UIActivityViewController alloc] initWithActivityItems:items applicationActivities:nil];
[self presentViewController:vc animated:YES completion:nil];
#else
// Appareil: partager l’URL (copie dans /tmp pour éviter les locks)
NSString *tmpPath=[NSTemporaryDirectory() stringByAppendingPathComponent:self.fileURL.lastPathComponent];
[[NSFileManager defaultManager] removeItemAtPath:tmpPath error:NULL];
NSError *copyErr=nil; if (![[NSFileManager defaultManager] copyItemAtPath:self.fileURL.path toPath:tmpPath error:©Err]) {
UIAlertController *ac=[UIAlertController alertControllerWithTitle:@"Copie impossible" message:copyErr.localizedDescription?:@"Erreur inconnue" preferredStyle:UIAlertControllerStyleAlert];
[ac addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:ac animated:YES completion:nil];
return;
}
NSURL *tmpURL=[NSURL fileURLWithPath:tmpPath];
UIActivityViewController *vc=[[UIActivityViewController alloc] initWithActivityItems:@[tmpURL] applicationActivities:nil];
[self presentViewController:vc animated:YES completion:nil];
#endif
}
#pragma mark - CLLocationManagerDelegate
- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray<CLLocation *> *)locations {
CLLocation *last = [locations lastObject];
if (last) {
#if __has_feature(objc_arc)
self.lastLoc = last;
#else
if (self.lastLoc != last) { [self.lastLoc release]; self.lastLoc = [last retain]; }
#endif
dispatch_async(dispatch_get_main_queue(), ^{
self.statusLabel.text = [NSString stringWithFormat:@"GPS: %.5f, %.5f",
last.coordinate.latitude, last.coordinate.longitude];
});
}
}
- (void)dealloc {
if (self.timer) { dispatch_source_cancel(self.timer); self.timer=nil; }
[self.mm stopAccelerometerUpdates]; [self.mm stopGyroUpdates]; [self.loc stopUpdatingLocation];
if (self.fh) { [self.fh closeFile]; self.fh = nil; }
#if !__has_feature(objc_arc)
[self.loc release]; [self.mm release]; [self.fileURL release]; [self.lastLoc release]; [super dealloc];
#endif
}
@end
CSV : ts_ms,lat,lon,alt,acc_h,speed,course,ax,ay,az,gx,gy,gz
5) Générer un .IPA armv7
Depuis Xcode (GUI)
- Scheme : Generic iOS Device (ou iPhone 4S).
- Product → Archive → Organizer.
- Export… : Development (compte gratuit, appareil ajouté) ou Ad Hoc (compte payant + UDID).
Ligne de commande
xcodebuild -project GPSIMU10Hz.xcodeproj -scheme GPSIMU10Hz -configuration Release -sdk iphoneos -archivePath build/GPSIMU10Hz.xcarchive archive
cat > build/exportOptions.plist <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>method</key><string>development</string>
<key>compileBitcode</key><false/>
<key>signingStyle</key><string>automatic</string>
<key>stripSwiftSymbols</key><true/>
<key>destination</key><string>export</string>
</dict></plist>
PLIST
xcodebuild -exportArchive -archivePath build/GPSIMU10Hz.xcarchive -exportOptionsPlist build/exportOptions.plist -exportPath build/export
Plan B : IPA “crue” + Sideloadly
# Depuis DerivedData/.../Build/Products/Release-iphoneos/
mkdir -p Payload
cp -R GPSIMU10Hz.app Payload/
zip -r -y GPSIMU10Hz_unsigned.zip Payload
mv GPSIMU10Hz_unsigned.zip GPSIMU10Hz_unsigned.ipa
Installe l’IPA via Sideloadly (Windows). Avec Apple ID gratuit, la signature dure 7 jours.
6) Bonus : brancher l’iPhone 4S à une VM Windows (UTM/QEMU)
Pour utiliser de vieux iTunes : QEMU x86_64 en TCG (sur Mac ARM), UEFI OVMF, EHCI (USB 2.0), et capture du device Apple 05ac:12a0.
qemu-system-x86_64 -machine q35,accel=tcg,thread=multi -cpu qemu64 -smp 4 -m 4096 -drive if=pflash,format=raw,readonly=on,file=/opt/homebrew/share/qemu/edk2-x86_64-code.fd -drive if=pflash,format=raw,file=$HOME/OVMF_VARS_WIN11.fd -drive file=/chemin/win11.qcow2,if=virtio,format=qcow2 -vga virtio -usb -device usb-ehci,id=ehci -device usb-host,bus=ehci.0,vendorid=0x05ac,productid=0x12a0
Astuce : ferme Photos/Finder/iTunes sur macOS avant de brancher l’iPhone ; branche après le boot de Windows.
7) Dépannage express
- EXC_BAD_ACCESS sur
last.coordinate : la propriété lastLoc doit être strong/retain.
- Simulateur : -10814 au partage : partager NSString (contenu) plutôt que l’URL de fichier.
- Undefined symbol / link failed : si Quick Look utilisé, lier QuickLook.framework.
- Syntaxe ObjC :
[self updateUIForState] (pas [self.updateUIForState]).
- QEMU : write lock : disque ouvert par UTM → quitter UTM.
- QEMU : invalid accelerator hvf : utiliser
-accel tcg,thread=multi.
- UEFI Shell au boot : vérifier
-drive (NVMe/SATA) + présence de Windows Boot Manager.
Conclusion
Avec Xcode 13.2.1, une app Objective‑C compacte et quelques contournements, on transforme un 4S en datalogger GPS/IMU 10 Hz fiable — prêt pour la route, la piste ou le labo.